Compare commits

..
13 Commits
Author SHA1 Message Date
shaw a6c80e8aa8 docs(audit): PR #3242 终审三路审计总报告——零 P0/P1
①整 PR 生产 diff 对抗审查(跨阶段交互)P0/P1/P2=0/0/0
②-race 全扫+count=3 抖动+integration 实测+embed 构建:零竞争零 flake
③59 文件全归类 A/B/C 零例外,'默认配置零行为变更'逐条核证成立
六轮审计累计:突变 13/13 捕获、热路径 allocs 全程恒等
2026-06-12 11:14:00 +08:00
shaw 8aef24f3a6 test(plugin): 修复默认注册表委托测试对 -count>1 的不幂等
终审第二路审计发现:该测试向进程级全局默认注册表注册探针且无法清理,
go test -count=2 同进程重跑撞重复注册 panic。改为已注册即复用探针,
使 -count>1 抖动扫描(审计常用手段)可覆盖本包。count=3 与 -race -count=2 验证通过。
2026-06-12 11:12:32 +08:00
shaw 1922c271c8 docs(phase-4): R6 勘误——AccountRuntimeBlocker'归属错误'债务经实施前核实撤回
代码事实:接口本就定义在消费者侧 ratelimit_service.go:37(评审称'定义在
openai 卫星文件'有误)、命名平台中立;实现挂 OpenAIGatewayService 正确
(守护 openai 调度器内部状态,非 openai 账号为设计内 no-op)。
执行 R6 重构=纯 churn,撤回。Phase-4 Deferred 总裁决不受影响。
本项目第三次'下游核实推翻上游结论'——多轮验证纪律双向起效。
2026-06-12 09:01:53 +08:00
shaw b3261c94ea docs(phase-4): 依赖测绘 + Phase-4 整包搬家 Deferred 裁决
双视角评审实测证伪整包搬家可行性前提(go build 证实 import cycle):
- openai 子系统实为 46 文件 65K 行(含 69 白盒测试),非摸底称的 7168 行单文件
- 双向硬耦合:正向 178 符号/64 未导出;反向 57 符号经 6 个核心文件结构级引用
- 69 个 openai 测试 100% 白盒,move 即破 gate,无黑盒等价锚点
- 循环论证(L2 触发=搬家完成、搬家收益=为 L2 铺路)+ 零拉动信号

裁决:交付 C-C 依赖测绘,Phase-4 标 Deferred + 可观测触发条件。
架构债务发现:AccountRuntimeBlocker 误命名 openai 专属、实为通用调度依赖。
含 Phase-3 PHASE_SUMMARY 归档。
2026-06-12 08:46:02 +08:00
shaw 625c691b63 refactor(platform-seam): 默认模型 ID 同构 switch 收敛为单一来源
handler 与 admin_service 各持一份逐分支同构的平台默认模型 switch(必须人工同步)。
提取 service.DefaultModelIDsForPlatform 为单一来源 + 表驱动测试;
原函数改单行委托(调用点零改动)。含 Phase-3 审计报告归档(零 P0/P1,突变 3/3 捕获)。
2026-06-12 00:58:14 +08:00
shaw f08f9111e5 refactor(platform-seam): gatewayplatform 接缝——Provider/Registry + 两处 Forward 分发替换
经双视角评审裁决的收窄版 Phase-3 接缝(零行为变更):
- internal/gatewayplatform:Provider{Platform,Forward} 两方法 v1 接口 +
  ForwardRequest{Parsed,Body,IsStickySession,SessionGroupID,SessionKey} +
  构造期注册 Registry(重复 panic,运行期并发只读)
- 3 个单语句直返 adapter(anthropic/antigravity/gemini,错误零包裹——
  BetaBlockedError/PromptTooLong 的 errors.As 链由 T4 特征化守护)
- Messages 端点两处平台分发改 registry 查找;Type!=APIKey 条件保留调用点
- OpenAI(独立 handler)、v1beta 端点、内核 internal/plugin 零触碰

门禁:T1-T5 特征化全绿、安全网 45 包、bench allocs 全部持平(266/329/93/134)。
2026-06-12 00:27:10 +08:00
shaw 57a2e7184b test(platform-seam): Phase-3 前置特征化 T1-T5——平台分发行为锁定
为 gatewayplatform 接缝(:444/:794 分发改 registry)建立等价性 gate:
- T1 :794 路由矩阵(antigravity×{非APIKey,APIKey},锁定 Type!=APIKey 真实条件)
- T2 :444 gemini→ForwardGemini 参数逐一断言 + action 契约(service 层)
- T3 粘性 session 选项端到端透传(DeleteSessionAccountID 参数 + force_cache_billing)
- T4 BetaBlockedError/PromptTooLong 错误链(adapter 包裹即红)
- T5 antigravity forward 基准 ×2 纳入基线(旧基准零漂移后重采)

含 Phase-3 设计双视角评审归档(修正摸底三处事实错误、接口砍至 2 方法、OpenAI 剔出本期)。
2026-06-12 00:07:02 +08:00
shaw 0df861f450 chore: 收窄 backend/scripts 白名单至 bench-baseline.sh(保持既有本地脚本忽略状态) 2026-06-11 23:01:57 +08:00
shaw 7d67eb083a docs(refactor): 插件化改造各阶段完成报告与审计记录归档
issues/plugin-refactor/:Phase-0~2 的任务完成报告、阶段总结、
两轮内核审计报告、Phase-2 接缝设计双视角评审与实施后对抗审计、调用点清单。
2026-06-11 23:00:19 +08:00
shaw 1cdab6f858 refactor(seams): Phase-2 接缝——payment 注册表化 + 网关 pre-flight 钩子链
设计经双视角架构评审裁决(零行为变更,特征化测试先行锁定):
- payment/provider:factory switch → 包内私有构造器注册表(init 自注册);
  unknown-key 文案与 ApplicationError 透传逐字节等价(前端 i18n 依赖)
- internal/gatewayhook:协议无关 pre-flight 钩子链(panic 隔离、error 默认
  fail-open、首 Blocked 短路、空链零分配);Decision→格式化保留在各调用点
- 8 个 HTTP moderation 调用点收敛经链(各点入参表达式/格式化函数逐一保留),
  WebSocket 两点按裁决保持原路径;moderation 为 Wire 装配的核心钩子
- 新增 7 个拦截格式特征化测试(5 种 HTTP 格式差异 + WS turn-2 + fail-open)

实施后对抗审计零 P0/P1,突变实验 4/4 被测试捕获;bench allocs 全部零增加。
2026-06-11 22:59:47 +08:00
shaw 9be516448e feat(devkit): Phase-1.5 模块脚手架 + 前端模块可观测页 + 作者指南
- tools/newmodule + make new-module ID=job.foo:开箱即编译即测试的模块骨架
  (ID 校验复用内核规则、渲染必经 gofmt、失败原子回滚、显式插装提示)
- 前端 /admin/modules 只读页:列表/状态徽章/错误悬浮/中英 i18n + vitest
- docs/plugin-architecture/MODULE-AUTHOR-GUIDE.md:模块作者指南
  (生命周期契约/配置子树/插装清单/plugintest 用法/开发调试工作流闭环/常见坑)
2026-06-11 22:59:07 +08:00
shaw 8ba2fbd786 feat(plugin): Phase-1/1.5 进程内插件内核 + 开发套件后端
Caddy 式进程内模块系统(详见 docs/plugin-architecture/MODULE-AUTHOR-GUIDE.md):
- internal/plugin:命名空间注册表(init 注册/重复 panic)、Provision→Validate→Start→Stop
  生命周期(Start 半途逆序回滚、Stop 逆序+ctx deadline)、Host 四项 ports 能力面、
  modules: 配置子树(enabled 三态;viper 含点 key 手工提取)、Runtime 状态机
- internal/plugin/plugintest:模块测试夹具(NewHost Options + RunLifecycle)
- internal/modules:standard/imports.go 唯一插装清单 + job.hello 示例模块(默认 disabled)
- Wire/main 接入:Build+Start 失败 fail-fast(显式 Cleanup 防 leader 锁泄漏)、
  runtime.Stop 在 cleanup 并行组首位(先于 Redis/Ent)
- admin 可观测:GET /api/v1/admin/modules(只读,错误文本脱敏)
- 默认配置零行为变更(Phase-0 安全网 + bench allocs 零漂移验证)

含两轮对抗式审计修复(1×P1 启动失败泄漏 leader 锁 + 11×P2)与回归测试。
2026-06-11 22:58:51 +08:00
shaw f9fac7acbe test(safety-net): Phase-0 回归安全网——特征化/不变量测试 + 基准基线 + CI 门禁
插件化改造前置:把'绝不回归'变成机器可验证的硬约束。
- 透传特征化:anthropic 逐字节/SSE 逐事件、openai、gemini 路径与错误透传
- 计费不变量:5m/1h 双档缓存价、倍率叠加顺序、overages、端到端金额与配额增量
- 调度不变量:粘性会话、failover 10/3/3 完整循环、并发槽配平、等待队列
- 热路径基准基线 + scripts/bench-baseline.sh(allocs 严格/ns 15%/基准缺失 FAIL)
- Makefile test-invariants 目标 + CI invariants job;修复失效的 test-e2e 脚本引用
- .gitignore:放行 backend/scripts 与 docs/plugin-architecture(既有白名单模式)

注:本分支按整体门禁验证(部分测试夹具引用后续提交的签名,中间提交不保证独立编译)。
2026-06-11 22:57:54 +08:00
515 changed files with 14818 additions and 26354 deletions
-5
View File
@@ -11,11 +11,6 @@
*.md
!deploy/DOCKER.md
docs/
# admin-compliance gate (LegalDocumentView.vue) build-time imports
# docs/legal/*.md?raw into the frontend bundle; keep that subtree in the
# Docker build context even though docs/ is otherwise excluded.
!docs/legal/
!docs/legal/*.md
# IDE
.idea/
+18 -1
View File
@@ -28,12 +28,29 @@ jobs:
working-directory: backend
run: make test-integration
# 插件化改造回归安全网:特征化/不变量测试作为独立命名的合并门禁
# (这些测试也包含在 test-unit 中,独立 job 是为了回归信号清晰可辨)
# 详见 .claude/plugin-refactor/INVARIANTS.md
invariants:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: backend/go.mod
check-latest: false
cache: true
cache-dependency-path: backend/go.sum
- name: Regression invariants (plugin-refactor safety net)
working-directory: backend
run: make test-invariants
frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v6
uses: pnpm/action-setup@v4
with:
version: 9
- name: Setup Node.js
-3
View File
@@ -12,9 +12,6 @@ permissions:
pull-requests: write
statuses: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
cla-check:
if: |
+1 -1
View File
@@ -58,7 +58,7 @@ jobs:
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v6
uses: pnpm/action-setup@v4
with:
version: 9
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
steps:
- uses: actions/checkout@v6
- name: Set up pnpm
uses: pnpm/action-setup@v6
uses: pnpm/action-setup@v4
with:
version: 9
- name: Set up Node.js
+5
View File
@@ -133,6 +133,11 @@ docs/*
!docs/ADMIN_PAYMENT_INTEGRATION_API.md
!docs/legal/
!docs/legal/*.md
!docs/plugin-architecture/
!docs/plugin-architecture/*.md
!backend/scripts/
backend/scripts/*
!backend/scripts/bench-baseline.sh
.serena/
.codex/
frontend/coverage/
+1 -6
View File
@@ -27,13 +27,8 @@ RUN corepack enable && corepack prepare pnpm@9 --activate
COPY frontend/package.json frontend/pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
# Copy frontend source and build.
# LegalDocumentView.vue (admin-compliance gate) build-time imports
# ../../../../docs/legal/*.md?raw, so docs/legal/ must sit beside frontend/
# in the image (WORKDIR /app/frontend -> resolves to /app/docs/legal/*.md).
# Copy only that subtree to keep the build dependency minimal.
# Copy frontend source and build
COPY frontend/ ./
COPY docs/legal/ /app/docs/legal/
RUN pnpm run build
# -----------------------------------------------------------------------------
+28 -100
View File
@@ -12,7 +12,7 @@
**AI API Gateway Platform for Subscription Quota Distribution**
English | [中文](README_CN.md) | [日本語](README_JA.md)
English | [日本語](README_JA.md)
</div>
@@ -24,6 +24,22 @@ Please read the following carefully before using this project:
- **⚖️ Compliant Use**: Use this project only in compliance with the laws and regulations of your country or region. Any unlawful use is strictly prohibited.
- **📖 Disclaimer**: This project is provided for technical learning and research purposes only. The authors assume no liability for account bans, service interruptions, data loss, or any other direct or indirect damages resulting from the use of this project.
## Overview
Sub2API is an AI API gateway platform designed to distribute and manage API quotas from AI product subscriptions. Users can access upstream AI services through platform-generated API Keys, while the platform handles authentication, billing, load balancing, and request forwarding.
## Features
- **Multi-Account Management** - Support multiple upstream account types (OAuth, API Key)
- **API Key Distribution** - Generate and manage API Keys for users
- **Precise Billing** - Token-level usage tracking and cost calculation
- **Smart Scheduling** - Intelligent account selection with sticky sessions
- **Concurrency Control** - Per-user and per-account concurrency limits
- **Rate Limiting** - Configurable request and token rate limits
- **Built-in Payment System** - Supports EasyPay, Alipay, WeChat Pay, and Stripe for user self-service top-up, no separate payment service needed ([Configuration Guide](docs/PAYMENT.md))
- **Admin Dashboard** - Web interface for monitoring and management
- **External System Integration** - Embed external systems (e.g. ticketing) via iframe to extend the admin dashboard
## ❤️ Sponsors
> [Want to appear here?](mailto:support@pincc.ai)
@@ -50,6 +66,11 @@ Please read the following carefully before using this project:
<td>Thanks to SilkAPI for sponsoring this project! <a href="https://code.silkapi.com/register?aff=SUB2API">SilkAPI</a> is a relay service built on Sub2API, specializing in providing high-speed and stable Codex API relay.</td>
</tr>
<tr>
<td width="180"><a href="https://ylscode.com/"><img src="assets/partners/logos/ylscode.png" alt="ylscode" width="150"></a></td>
<td>Thanks to YLS Code for sponsoring this project! <a href="https://ylscode.com/">YLS Code</a> is dedicated to building secure enterprise-grade Coding Agent productivity services, offering stable and fast Codex / Claude / Gemini subscription services along with pay-as-you-go API options for flexible choices. Register now for a limited-time 3-day Codex trial bonus!</td>
</tr>
<tr>
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=KMVZQM"><img src="assets/partners/logos/AICodeMirror.jpg" alt="AICodeMirror" width="150"></a></td>
<td>Thanks to AICodeMirror for sponsoring this project! AICodeMirror provides official high-stability relay services for Claude Code / Codex / Gemini CLI, with enterprise-grade concurrency, fast invoicing, and 24/7 dedicated technical support. Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original price, with extra discounts on top-ups! AICodeMirror offers special benefits for sub2api users: register via <a href="https://www.aicodemirror.com/register?invitecode=KMVZQM">this link</a> to enjoy 20% off your first top-up, and enterprise customers can get up to 25% off!</td>
@@ -76,6 +97,12 @@ Please read the following carefully before using this project:
</td>
</tr>
<tr>
<td width="180"><a href="https://runapi.co/register?aff=fu2E"><img src="assets/partners/logos/runapi.png" alt="RunAPI" width="150"></a></td>
<td>Thanks to RunAPI for sponsoring this project! <a href="https://runapi.co/register?aff=fu2E">RunAPI</a> is an efficient and stable API platform and OpenRouter alternative. With one API Key, you can access 150+ popular models including OpenAI, Claude, Gemini, DeepSeek, and Grok, with pricing as low as 10% of the original rate. It is highly stable and seamlessly compatible with tools such as Claude Code and OpenClaw.
</td>
</tr>
<tr>
<td width="180"><a href="https://unity2.ai/register?source=sub2api"><img src="assets/partners/logos/unity2.png" alt="unity2" width="150"></a></td>
<td>Thanks to Unity2 for sponsoring this project! <a href="https://unity2.ai/register?source=sub2api">Unity2</a> is a high-performance AI model API relay for individuals, teams, and enterprises, handling 30B+ tokens/day with 5000 RPM concurrency. One API Key works across Claude Code, Codex, OpenAI models, IDE plugins, and Agent workflows, with balance billing, bundled subscriptions, enterprise invoicing, and 1-on-1 support. <a href="https://unity2.ai/register?source=sub2api">Register</a> to claim $2 in balance, plus $10 more by joining the official group — up to $12 in free credit.
@@ -94,63 +121,8 @@ Please read the following carefully before using this project:
</td>
</tr>
<tr>
<td width="180"><a href="https://apikl.com"><img src="assets/partners/logos/apikl.png" alt="apikl" width="150"></a></td>
<td>Thanks to Apikl for sponsoring this project! Built on Sub2API, the platform provides developers with relay services for Codex / Claude series models, focusing on long-term stability, high-speed direct connections, and excellent cost-effectiveness. It offers pay-as-you-go balance billing, enterprise-grade official invoices, and one-on-one dedicated support. <a href="https://apikl.com">Register now</a> for a 1:1 top-up bonus — double your balance!
</td>
</tr>
<tr>
<td width="180"><a href="https://tokeneum.ai"><img src="assets/partners/logos/tokeneum.png" alt="tokeneum" width="150"></a></td>
<td>Thanks to TokenEum for sponsoring this project! <a href="https://tokeneum.ai">TokenEum</a> is a comprehensive AI model aggregation platform and intelligent agent development company. It brings together top-tier international models — including Claude, Gemini, and OpenAI — alongside leading open-source models such as GLM, Qwen, and Kimi, offering a wide range of options across different quality and price tiers to suit every need. TokenEum also provides access to cutting-edge video generation models like Seedance2.0 and Happy Horse. Committed to transparency and honest business practices, TokenEum ensures all model information is accurate and reliable. Visit <a href="https://tokeneum.ai">tokeneum.ai</a> to get started.
</td>
</tr>
<tr>
<td width="180"><a href="https://666api.work/sub2api"><img src="assets/partners/logos/666api.jpg" alt="666api" width="150"></a></td>
<td>Thanks to 666api for sponsoring this project! <a href="https://666api.work/sub2api">666api</a> is an all-in-one platform offering:<br>
⚡ API Relay — Pay-as-you-go access to global models sourced 100% from official providers, up to 75% off official pricing<br>
&nbsp;&nbsp;&nbsp;&nbsp;Exclusive: Zhipu GLM 50% off · DeepSeek V4-pro 50% off · Seedance 2.0 8% off (whitelisted) · HappyHorse Overseas 30% off (whitelisted)<br>
🔑 GPT Subscription Accounts (same-origin IP included) · Global Residential IP <br>
💰 Invoices supported
</td>
</tr>
<tr>
<td width="180"><a href="https://dis.chatdesks.cn/chatdesk/hsyqsub2api.html"><img src="assets/partners/logos/byteplus.png" alt="BytePlus" width="150"></a></td>
<td>Thanks to Dola seed for sponsoring this project! Dola Seed 2.0 is a full‑modal general large model independently developed by ByteDance for the global market. Built on a unified multimodal architecture, it supports joint understanding and generation of text, images, audio, and video. It natively enables agent collaboration, with strong reasoning, long‑task execution, tool integration, and coding capabilities. It is widely applicable to smart cockpits, personal assistants, education, customer support, marketing, retail, and other scenarios. It excels in multimodal perception, end‑to‑end complex task delivery, stable interaction, and data security, and is readily accessible and deployable via the ModelArk platform.Register via <a href="https://dis.chatdesks.cn/chatdesk/hsyqsub2api.html">this link</a> to get 500,000 tokens of free inference quota per model.<a href="https://dis.chatdesks.cn/chatdesk/hsyqsub2api.html"> >>中国大陆地区的开发者请点击这里</a></td>
</tr>
<tr>
<td width="180"><a href="https://sui-xiang.com/"><img src="assets/partners/logos/sui-xiang.jpg" alt="sui-xiang" width="150"></a></td>
<td>Thanks to Suixiang AI Gateway for sponsoring this project! <a href="https://sui-xiang.com/">Suixiang AI Gateway</a> is a reliable and efficient API relay service provider offering relay services for Claude, Codex, Gemini, and more. A privacy-focused relay — no data reselling, no model dilution; privacy, transparency, and lightning-fast after-sales support. New accounts get ¥0.5 in trial credit daily by signing in; top-ups are 1:1, no subscription required, pay-as-you-go. Multi-line redundancy, cross-region disaster recovery, automatic failover, and uninterrupted long-link SSE. 99.9% availability — critical calls never fall behind.
</td>
</tr>
<tr>
<td width="180"><a href="https://www.miyaip.com/?invitecode=sub2api"><img src="assets/partners/logos/miyaip.png" alt="miyaip" width="150"></a></td>
<td>Thanks to MiyaIP for sponsoring this project! <a href="https://www.miyaip.com/?invitecode=sub2api">MiyaIP</a> is a platform dedicated to global residential proxy network services, committed to providing high-quality, pure overseas residential IP resources for enterprise developers, cross-border business teams, and AI application users. It delivers stable, independent overseas network environments for AI platforms, overseas SaaS, and other online services, supporting multi-region access testing and project environment isolation. Ideal for development and testing scenarios that require access to overseas AI services, such as: AI model platform access, AI development testing, AI SaaS service usage, AI API debugging, and multi-region network environment validation.
</td>
</tr>
</table>
## Overview
Sub2API is an AI API gateway platform designed to distribute and manage API quotas from AI product subscriptions. Users can access upstream AI services through platform-generated API Keys, while the platform handles authentication, billing, load balancing, and request forwarding.
## Features
- **Multi-Account Management** - Support multiple upstream account types (OAuth, API Key)
- **API Key Distribution** - Generate and manage API Keys for users
- **Precise Billing** - Token-level usage tracking and cost calculation
- **Smart Scheduling** - Intelligent account selection with sticky sessions
- **Concurrency Control** - Per-user and per-account concurrency limits
- **Rate Limiting** - Configurable request and token rate limits
- **Built-in Payment System** - Supports EasyPay, Alipay, WeChat Pay, and Stripe for user self-service top-up, no separate payment service needed ([Configuration Guide](docs/PAYMENT.md))
- **Admin Dashboard** - Web interface for monitoring and management
- **External System Integration** - Embed external systems (e.g. ticketing) via iframe to extend the admin dashboard
## Ecosystem
Community projects that extend or integrate with Sub2API:
@@ -587,50 +559,6 @@ Simple Mode is designed for individual developers or internal teams who want qui
---
## Grok / xAI OAuth Support
Sub2API supports Grok subscription accounts through xAI OAuth and forwards OpenAI-compatible Responses traffic to xAI.
### Supported Scope
- Platform name: `grok`
- Account type: OAuth subscription accounts
- Public gateway target: `/v1/responses` and `/responses`, forwarded to `${XAI_BASE_URL:-https://api.x.ai/v1}/responses`
- Initial models: `grok-4.3`, `grok-build-0.1`, `grok-4.20-0309-reasoning`, `grok-4.20-0309-non-reasoning`, and `grok-4.20-multi-agent-0309`
- Out of scope for this provider: public Grok Chat Completions routes, image, video, TTS, transcription, browser automation, cookies, and Grok web scraping
### OAuth Configuration
The Grok OAuth flow uses PKCE and does not require committing private secrets. The default client details follow the public xAI OAuth flow used by compatible clients, and every value can be overridden by environment variable:
| Variable | Default |
|----------|---------|
| `XAI_OAUTH_CLIENT_ID` | Public xAI OAuth client ID |
| `XAI_OAUTH_SCOPE` | `openid profile email offline_access grok-cli:access api:access` |
| `XAI_OAUTH_REDIRECT_URI` | `http://127.0.0.1:56121/callback` |
| `XAI_OAUTH_AUTHORIZE_URL` | `https://auth.x.ai/oauth2/authorize` |
| `XAI_OAUTH_TOKEN_URL` | `https://auth.x.ai/oauth2/token` |
| `XAI_BASE_URL` | `https://api.x.ai/v1` |
Administrators can create or reauthorize Grok accounts from the dashboard, or use the admin API:
| Endpoint | Purpose |
|----------|---------|
| `POST /api/v1/admin/grok/oauth/auth-url` | Generate an xAI OAuth authorization URL |
| `POST /api/v1/admin/grok/oauth/exchange-code` | Exchange a callback URL, query string, or code for OAuth credentials |
| `POST /api/v1/admin/grok/oauth/refresh-token` | Validate or refresh a Grok refresh token |
| `POST /api/v1/admin/grok/accounts/:id/refresh` | Refresh an existing Grok account |
Credential storage reuses the existing account JSON fields: `access_token`, `refresh_token`, `token_type`, `expires_at`, optional `email`, optional `subscription_tier`, and `entitlement_status`.
### Usage And Quota Display
xAI quota is passive. Sub2API does not invent subscription quota values; it records whitelisted xAI rate-limit headers from successful or rate-limited upstream responses when xAI sends them. Before the first usable upstream response, the dashboard shows quota as unknown and still displays local Sub2API usage stats.
`401` responses mark the account as needing reauthorization. `403` responses are treated as entitlement or subscription-tier failures instead of token-refresh loops. `429` responses use `Retry-After` or a short cooldown to temporarily remove the account from scheduling.
---
## Antigravity Support
Sub2API supports [Antigravity](https://antigravity.so/) accounts. After authorization, dedicated endpoints are available for Claude and Gemini models.
-749
View File
@@ -1,749 +0,0 @@
# Sub2API
<div align="center">
[![Go](https://img.shields.io/badge/Go-1.25.7-00ADD8.svg)](https://golang.org/)
[![Vue](https://img.shields.io/badge/Vue-3.4+-4FC08D.svg)](https://vuejs.org/)
[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-15+-336791.svg)](https://www.postgresql.org/)
[![Redis](https://img.shields.io/badge/Redis-7+-DC382D.svg)](https://redis.io/)
[![Docker](https://img.shields.io/badge/Docker-Ready-2496ED.svg)](https://www.docker.com/)
<a href="https://trendshift.io/repositories/21823" target="_blank"><img src="https://trendshift.io/api/badge/repositories/21823" alt="Wei-Shaw%2Fsub2api | Trendshift" width="250" height="55"/></a>
**AI API 网关平台 - 订阅配额分发管理**
[English](README.md) | 中文 | [日本語](README_JA.md)
</div>
## ⚠️ 重要提醒
使用本项目前,请务必仔细阅读以下内容:
- **🚨 服务条款风险**:使用本项目可能违反 Anthropic 等上游服务商的服务条款。请在使用前仔细阅读相关服务商的用户协议,由此产生的一切风险由用户自行承担。
- **⚖️ 合规使用**:请在符合您所在国家或地区法律法规的前提下使用本项目,严禁将其用于任何违法违规用途。
- **📖 免责声明**:本项目仅供技术学习与研究使用,作者不对因使用本项目导致的账户封禁、服务中断、数据丢失或其他任何直接或间接损失承担责任。
## ❤️ 赞助商
> [想出现在这里?](mailto:support@pincc.ai)
<table>
<tr>
<td width="180"><a href="https://www.openmodel.ai?ref=sub2api"><img src="assets/partners/logos/openmodel.jpg" alt="openmodel" width="150"></a></td>
<td>一个API,顶级模型随便用!<a href="https://www.openmodel.ai?ref=sub2api">OpenModel</a> 专注于生产级、高可用的 AI API 网关,让你的应用真正做到高速稳定:自动故障转移、智能选最优渠道、生产级 SLA 保障。远超单一供应商的 SLA,让稳定性成为您的核心竞争力。</td>
</tr>
<tr>
<td width="180"><a href="https://etok.ai"><img src="assets/partners/logos/etok.png" alt="ETok" width="150"></a></td>
<td>感谢 ETok.ai 赞助了本项目!ETok.ai 致力于打造一站式 AI 编程工具服务平台。我们提供 Claude Code 专业套餐及技术社群服务,同时支持 Google Gemini 和 OpenAI Codex。通过精心设计的套餐方案和专业的技术社群,为开发者提供稳定的服务保障和持续的技术支持,让 AI 辅助编程真正成为开发者的生产力工具。点击<a href="https://etok.ai">这里</a>注册!</td>
</tr>
<tr>
<td width="180"><a href="https://aigocode.com/invite/SUB2API"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
<td>感谢 AIGoCode 赞助了本项目!AIGoCode 是一站式集成 Claude Code、Codex 以及最新 Gemini 模型的综合平台,为您提供稳定、高效、高性价比的 AI 编程服务。平台提供灵活的订阅方案,零封号风险,免 VPN 直连,响应极速。AIGoCode 为 sub2api 用户准备了专属福利:通过<a href="https://aigocode.com/invite/SUB2API">此链接</a>注册,首次充值可额外获得 10% 赠送额度!</td>
</tr>
<tr>
<td width="180"><a href="https://code.silkapi.com/register?aff=SUB2API"><img src="assets/partners/logos/silkapi.png" alt="silkapi" width="150"></a></td>
<td>感谢 丝绸API 赞助了本项目! <a href="https://code.silkapi.com/register?aff=SUB2API">丝绸API</a> 是基于 Sub2API 搭建的中转服务,专注于提供 Codex 高速稳定API中转。</td>
</tr>
<tr>
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=KMVZQM"><img src="assets/partners/logos/AICodeMirror.jpg" alt="AICodeMirror" width="150"></a></td>
<td>感谢 AICodeMirror 赞助了本项目!AICodeMirror 提供 Claude Code / Codex / Gemini CLI 官方高稳定性中转服务,企业级并发、快速开票、7×24 小时专属技术支持。Claude Code / Codex / Gemini 官方通道低至原价 38% / 2% / 9%,充值更享额外折扣!AICodeMirror 为 sub2api 用户提供专属福利:通过<a href="https://www.aicodemirror.com/register?invitecode=KMVZQM">此链接</a>注册,首次充值立享 8 折优惠,企业客户最高可享 75 折!</td>
</tr>
<tr>
<td width="180"><a href="https://shop.bmoplus.com/?utm_source=github"><img src="assets/partners/logos/bmoplus.jpg" alt="bmoplus" width="150"></a></td>
<td>感谢 BmoPlus 赞助了本项目!BmoPlus 是一家专为AI订阅重度用户打造的可靠 AI 账号代充服务商,提供稳定的 ChatGPT Plus / ChatGPT Pro(全程质保) / Claude Pro / Super Grok / Gemini Pro 的官方代充&成品账号。 通过<a href="https://shop.bmoplus.com/?utm_source=github">BmoPlus AI成品号专卖/代充</a>注册下单的用户,可享GPT 官网订阅一折 的震撼价格!</td>
</tr>
<tr>
<td width="180"><a href="https://bestproxy.com/?keyword=a2e8iuol"><img src="assets/partners/logos/bestproxy.png" alt="bestproxy" width="150"></a></td>
<td>感谢 Bestproxy 赞助了本项目!<a href="https://bestproxy.com/?keyword=a2e8iuol">Bestproxy</a> 是一家提供高纯度住宅IP,支持一号一IP独享,结合真实家庭网络与指纹隔离,可实现链路环境隔离,降低关联风控概率。</td>
</tr>
<tr>
<td width="180"><a href="https://pateway.ai/?ch=1tsfr51"><img src="assets/partners/logos/pateway.png" alt="pateway" width="150"></a></td>
<td>感谢 PatewayAI 赞助了本项目!PatewayAI 是一家面向重度 AI 开发者、专注官方直连的高品质模型 API 中转服务商。提供 Claude 全系列与 Codex 系列模型,100% 官方源直供,不掺假不注水,欢迎检验。计费透明,Token 级账单可逐笔核验。
同时支持企业级高并发,并为企业客户提供了专业的管理平台,企业客户可签订正式合同并开具发票,更多详情进入官网获取联系方式。
现在通过 <a href="https://pateway.ai/?ch=1tsfr51">此链接</a> 注册即送 $3 试用额度,用户充值低至 6 折,邀请好友双向赠送,邀请奖励可达 $150。</td>
</tr>
<tr>
<td width="180"><a href="https://api.pptoken.org/register?promo=SUB2API"><img src="assets/partners/logos/pptoken.png" alt="pptoken" width="150"></a></td>
<td>感谢 PPToken.org 赞助本项目! <a href="https://api.pptoken.org/register?promo=SUB2API">PPToken.org</a> 主打 GPT 系列模型 API 中转服务,支持 Codex、Claude Code、OpenAI 兼容客户端及 Gemini CLI 等工具接入。充值 1:1,1 元=1 美元额度;GPT 模型最低 0.16 倍倍率,综合成本约为官方价格的 0.22 折,最快首字 Token 约 1 秒,适合开发者低成本、高响应速度接入 GPT 模型能力。技术支持: 7×24 小时真人响应(不是机器人),群内@技术,10 分钟内有回复 。赞助商福利:前 200 名用户通过 <a href="https://api.pptoken.org/register?promo=SUB2API">[专属注册链接]</a> 注册,输入优惠码 `SUB2API`,可领取 Codex / Claude Code 免费试用额度,无门槛、不绑卡。
</td>
</tr>
<tr>
<td width="180"><a href="https://unity2.ai/register?source=sub2api"><img src="assets/partners/logos/unity2.png" alt="unity2" width="150"></a></td>
<td>感谢 Unity2 赞助本项目! <a href="https://unity2.ai/register?source=sub2api">Unity2</a> 是面向个人开发者、团队、企业的高性能 AI 模型 API 中转平台,长期服务国内头部企业,日均承载超 300 亿 token 调用,支持 5000 RPM 级高并发。一个 API Key 即可适配 Claude Code、Codex、OpenAI 模型、IDE 插件和 Agent 工作流等场景。具备企业级稳定供应能力,在高并发、持续调用和团队集中采购场景下依然保持低延迟、高可用。同时支持余额计费、组合订阅、首充优惠、企业开票、专属 1v1 对接,适合个人高频使用和企业长期接入。现在注册 Unity2.ai 可领取 $2 余额,加入官方群再送 $10 余额,合计最高可领 $12 免费额度,适合先体验后长期使用。<a href="https://unity2.ai/register?source=sub2api">注册链接</a>
</td>
</tr>
<tr>
<td width="180"><a href="https://veilx.io/#/hello/SJRBRVDV"><img src="assets/partners/logos/veilx.png" alt="veilx" width="150"></a></td>
<td>感谢 Veilx 赞助本项目! <a href="https://veilx.io/#/hello/SJRBRVDV">Veilx</a> CDN 专为超大规模 API 请求场景打造,针对 AI 中转站业务与 AI API 调用链路进行了深度优化,轻松应对高并发、高频请求与大流量传输,为开发者与企业提供更快、更稳、更低延迟的加速体验。无论是 OpenAI、Claude、Gemini 等 AI 接口中转,还是聊天、绘图、Embedding、流式输出等复杂场景,Veilx 都能显著提升响应速度与连接稳定性,有效降低网络波动带来的超时与失败问题。同时,Veilx 提供中国三网优化回国极速线路,大幅提升中国大陆地区访问海外 AI 服务的速度与稳定性,特别适合全球 AI 中转平台、海外 AI SaaS、跨境业务与高并发 API 系统部署。专为 AI API 而生,让你的 AI 中转服务更快、更稳、更省心。<a href="https://veilx.io/#/hello/SJRBRVDV">购买地址</a>
</td>
</tr>
<tr>
<td width="180"><a href="https://roxybrowser.com/invite/bgGKG7"><img src="assets/partners/logos/RoxyBrowser.png" alt="RoxyBrowser" width="150"></a></td>
<td>感谢 RoxyBrowser 赞助本项目!<a href="https://roxybrowser.com/invite/bgGKG7">RoxyBrowser</a> 是 Sub2API 的理想搭档:内置原生 Roxy AI Agent 与高质量原生住宅 IP,支持通过简单命令实现批量自动化,显著提升多账号管理的安全性与效率!点击<a href="https://roxybrowser.com/invite/bgGKG7">此链接</a>注册,可领取免费住宅 IP 套餐与终身 9 折优惠。
</td>
</tr>
<tr>
<td width="180"><a href="https://apikl.com"><img src="assets/partners/logos/apikl.png" alt="apikl" width="150"></a></td>
<td>感谢 Apikl 赞助本项目!平台基于 Sub2API 搭建,为开发者提供 Codex / Claude 系列模型的中转服务,专注于长期稳定、高速直连与高性价比。支持按量计费的余额结算、企业级正规发票及一对一专属对接。<a href="https://apikl.com">立即注册</a>即享充值 1:1 赠送 — 余额翻倍!
</td>
</tr>
<tr>
<td width="180"><a href="https://tokeneum.ai"><img src="assets/partners/logos/tokeneum.png" alt="tokeneum" width="150"></a></td>
<td>感谢 TokenEum 赞助本项目!<a href="https://tokeneum.ai">TokenEum</a> 是一家综合性 AI 模型聚合平台与智能体开发公司,汇聚 Claude、Gemini、OpenAI 等国际顶级模型,以及 GLM、Qwen、Kimi 等主流开源模型,提供不同质量与价格梯度的丰富选择,满足多样化需求。平台还接入了 Seedance2.0、Happy Horse 等前沿视频生成模型。秉持透明诚信的经营理念,TokenEum 确保所有模型信息真实可靠。访问 <a href="https://tokeneum.ai">tokeneum.ai</a> 开始使用。
</td>
</tr>
<tr>
<td width="180"><a href="https://666api.work/sub2api"><img src="assets/partners/logos/666api.jpg" alt="666api" width="150"></a></td>
<td>感谢 666api 赞助本项目!<a href="https://666api.work/sub2api">666api</a> 是一站式综合服务平台,提供:<br>
⚡ API 中转 — 全球模型按量计费接入,100% 官方源直供,最高 75 折优惠<br>
&nbsp;&nbsp;&nbsp;&nbsp;独家特惠:智谱 GLM 5 折 · DeepSeek V4-pro 5 折 · Seedance 2.0 0.8 折(白名单)· HappyHorse 海外版 3 折(白名单)<br>
🔑 GPT 订阅账号(含同源 IP)· 全球住宅 IP<br>
💰 支持开票
</td>
</tr>
<tr>
<td width="180"><a href="https://dis.chatdesks.cn/chatdesk/hsyqsub2api.html"><img src="assets/partners/logos/huoshan.png" alt="HuoShan" width="150"></a></td>
<td>感谢火山方舟 Agent Plan 模型赞助了本项目!方舟 Agent Plan 模型订阅套餐集成了包含 Doubao-Seed、Doubao-Seedance、Doubao-Seedream 等在内的字节跳动自研 SOTA 级模型,覆盖文本、代码、图像、视频等多模态任务。最新支持 MiniMax-M3、DeepSeek-V4 系列、GLM-5.1、Doubao-Seed-2.0 系列、Kimi-K2.6 等模型,工具不限。超全模态模型与 Harness 升级一步到位,深度支持 Agent 框架与 AI 编程工具。一次订阅,可以为不同任务切换合适的 AI 引擎。方舟 Coding Plan 为 Sub2Api 的用户提供了专属福利:通过<a href="https://dis.chatdesks.cn/chatdesk/hsyqsub2api.html">此链接</a>订阅方舟 Coding Plan,新客户首两个月享 2.5 折优惠 <a href="https://dis.chatdesks.cn/chatdesk/hsyqsub2api.html">>>For developers outside Mainland China, please click here</a></td>
</tr>
<tr>
<td width="180"><a href="https://sui-xiang.com/"><img src="assets/partners/logos/sui-xiang.jpg" alt="sui-xiang" width="150"></a></td>
<td>感谢 随想AI网关 赞助本项目!<a href="https://sui-xiang.com/">随想AI网关</a> 是一家可靠高效的 API 中继服务提供商,提供 Claude、Codex、Gemini 等的中继服务。注重隐私的中转站·无数据倒卖·无模型掺水,隐私,透明,极速售后。新账户注册每日签到就送 0.5 元测试额度,充值额度 1:1,无需订阅,按量付费。多线路冗余、跨区域容灾、自动故障切换,长链路 SSE 不中断。99.9% 可用性,关键调用从不掉队。
</td>
</tr>
<tr>
<td width="180"><a href="https://www.miyaip.com/?invitecode=sub2api"><img src="assets/partners/logos/miyaip.png" alt="miyaip" width="150"></a></td>
<td>感谢 MiyaIP 赞助本项目!<a href="https://www.miyaip.com/?invitecode=sub2api">MiyaIP</a> 是一家专注于全球住宅代理网络服务的平台,致力于为企业开发者、跨境业务团队及AI 应用用户提供高质量、纯净的海外住宅 IP 资源。为 AI 平台、海外 SaaS 及其他在线服务提供稳定、独立的海外网络环境,支持多地区访问测试和项目环境隔离。适用于需要访问海外 AI 服务的开发和测试场景,例如:AI 模型平台访问、AI 开发测试、AI SaaS 服务使用、AI API 调试、多地区网络环境验证
</td>
</tr>
</table>
## 项目概述
Sub2API 是一个 AI API 网关平台,用于分发和管理 AI 产品订阅的 API 配额。用户通过平台生成的 API Key 调用上游 AI 服务,平台负责鉴权、计费、负载均衡和请求转发。
## 核心功能
- **多账号管理** - 支持多种上游账号类型(OAuth、API Key)
- **API Key 分发** - 为用户生成和管理 API Key
- **精确计费** - Token 级别的用量追踪和成本计算
- **智能调度** - 智能账号选择,支持粘性会话
- **并发控制** - 用户级和账号级并发限制
- **速率限制** - 可配置的请求和 Token 速率限制
- **内置支付系统** - 支持 EasyPay 易支付、支付宝官方、微信官方、Stripe,用户自助充值,无需独立部署支付服务([配置指南](docs/PAYMENT_CN.md))
- **管理后台** - Web 界面进行监控和管理
- **外部系统集成** - 支持通过 iframe 嵌入外部系统(如工单等),扩展管理后台功能
## 生态项目
围绕 Sub2API 的社区扩展与集成项目:
| 项目 | 说明 | 功能 |
|------|------|------|
| ~~[Sub2ApiPay](https://github.com/touwaeriol/sub2apipay)~~ | ~~自助支付系统~~ | **已内置** — 支付功能已集成到 Sub2API 中,无需独立部署。详见 [支付配置指南](docs/PAYMENT_CN.md) |
| [sub2api-mobile](https://github.com/ckken/sub2api-mobile) | 移动端管理控制台 | 跨平台应用(iOS/Android/Web),支持用户管理、账号管理、监控看板、多后端切换;基于 Expo + React Native 构建 |
## 技术栈
| 组件 | 技术 |
|------|------|
| 后端 | Go 1.25.7, Gin, Ent |
| 前端 | Vue 3.4+, Vite 5+, TailwindCSS |
| 数据库 | PostgreSQL 15+ |
| 缓存/队列 | Redis 7+ |
---
## Nginx 反向代理注意事项
通过 Nginx 反向代理 Sub2API(或 CRS 服务)并搭配 Codex CLI 使用时,需要在 Nginx 配置的 `http` 块中添加:
```nginx
underscores_in_headers on;
```
Nginx 默认会丢弃名称中含下划线的请求头(如 `session_id`),这会导致多账号环境下的粘性会话功能失效。
---
## 部署方式
### 方式一:脚本安装(推荐)
一键安装脚本,自动从 GitHub Releases 下载预编译的二进制文件。
#### 前置条件
- Linux 服务器(amd64 或 arm64)
- PostgreSQL 15+(已安装并运行)
- Redis 7+(已安装并运行)
- Root 权限
#### 安装步骤
```bash
curl -sSL https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/deploy/install.sh | sudo bash
```
脚本会自动:
1. 检测系统架构
2. 下载最新版本
3. 安装二进制文件到 `/opt/sub2api`
4. 创建 systemd 服务
5. 配置系统用户和权限
#### 安装后配置
```bash
# 1. 启动服务
sudo systemctl start sub2api
# 2. 设置开机自启
sudo systemctl enable sub2api
# 3. 在浏览器中打开设置向导
# http://你的服务器IP:8080
```
设置向导将引导你完成:
- 数据库配置
- Redis 配置
- 管理员账号创建
#### 升级
可以直接在 **管理后台** 左上角点击 **检测更新** 按钮进行在线升级。
网页升级功能支持:
- 自动检测新版本
- 一键下载并应用更新
- 支持回滚
#### 常用命令
```bash
# 查看状态
sudo systemctl status sub2api
# 查看日志
sudo journalctl -u sub2api -f
# 重启服务
sudo systemctl restart sub2api
# 卸载
curl -sSL https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/deploy/install.sh | sudo bash -s -- uninstall -y
```
---
### 方式二:Docker Compose(推荐)
使用 Docker Compose 部署,包含 PostgreSQL 和 Redis 容器。
#### 前置条件
- Docker 20.10+
- Docker Compose v2+
#### 快速开始(一键部署)
使用自动化部署脚本快速搭建:
```bash
# 创建部署目录
mkdir -p sub2api-deploy && cd sub2api-deploy
# 下载并运行部署准备脚本
curl -sSL https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/deploy/docker-deploy.sh | bash
# 启动服务
docker compose up -d
# 查看日志
docker compose logs -f sub2api
```
**脚本功能:**
- 下载 `docker-compose.local.yml`(本地保存为 `docker-compose.yml`)和 `.env.example`
- 自动生成安全凭证(JWT_SECRET、TOTP_ENCRYPTION_KEY、POSTGRES_PASSWORD)
- 创建 `.env` 文件并填充自动生成的密钥
- 创建数据目录(使用本地目录,便于备份和迁移)
- 显示生成的凭证供你记录
#### 手动部署
如果你希望手动配置:
```bash
# 1. 克隆仓库
git clone https://github.com/Wei-Shaw/sub2api.git
cd sub2api/deploy
# 2. 复制环境配置文件
cp .env.example .env
# 3. 编辑配置(生成安全密码)
nano .env
```
**`.env` 必须配置项:**
```bash
# PostgreSQL 密码(必需)
POSTGRES_PASSWORD=your_secure_password_here
# JWT 密钥(推荐 - 重启后保持用户登录状态)
JWT_SECRET=your_jwt_secret_here
# TOTP 加密密钥(推荐 - 重启后保留双因素认证)
TOTP_ENCRYPTION_KEY=your_totp_key_here
# 可选:管理员账号
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=your_admin_password
# 可选:自定义端口
SERVER_PORT=8080
```
**生成安全密钥:**
```bash
# 生成 JWT_SECRET
openssl rand -hex 32
# 生成 TOTP_ENCRYPTION_KEY
openssl rand -hex 32
# 生成 POSTGRES_PASSWORD
openssl rand -hex 32
```
```bash
# 4. 创建数据目录(本地版)
mkdir -p data postgres_data redis_data
# 5. 启动所有服务
# 选项 A:本地目录版(推荐 - 易于迁移)
docker compose -f docker-compose.local.yml up -d
# 选项 B:命名卷版(简单设置)
docker compose up -d
# 6. 查看状态
docker compose -f docker-compose.local.yml ps
# 7. 查看日志
docker compose -f docker-compose.local.yml logs -f sub2api
```
#### 部署版本对比
| 版本 | 数据存储 | 迁移便利性 | 适用场景 |
|------|---------|-----------|---------|
| **docker-compose.local.yml** | 本地目录 | ✅ 简单(打包整个目录) | 生产环境、频繁备份 |
| **docker-compose.yml** | 命名卷 | ⚠️ 需要 docker 命令 | 简单设置 |
**推荐:** 使用 `docker-compose.local.yml`(脚本部署)以便更轻松地管理数据。
#### 启用“数据管理”功能(datamanagementd)
如需启用管理后台“数据管理”,需要额外部署宿主机数据管理进程 `datamanagementd`。
关键点:
- 主进程固定探测:`/tmp/sub2api-datamanagement.sock`
- 只有该 Socket 可连通时,数据管理功能才会开启
- Docker 场景需将宿主机 Socket 挂载到容器同路径
详细部署步骤见:`deploy/DATAMANAGEMENTD_CN.md`
#### 访问
在浏览器中打开 `http://你的服务器IP:8080`
如果管理员密码是自动生成的,在日志中查找:
```bash
docker compose -f docker-compose.local.yml logs sub2api | grep "admin password"
```
#### 升级
```bash
# 拉取最新镜像并重建容器
docker compose -f docker-compose.local.yml pull
docker compose -f docker-compose.local.yml up -d
```
#### 轻松迁移(本地目录版)
使用 `docker-compose.local.yml` 时,可以轻松迁移到新服务器:
```bash
# 源服务器
docker compose -f docker-compose.local.yml down
cd ..
tar czf sub2api-complete.tar.gz sub2api-deploy/
# 传输到新服务器
scp sub2api-complete.tar.gz user@new-server:/path/
# 新服务器
tar xzf sub2api-complete.tar.gz
cd sub2api-deploy/
docker compose -f docker-compose.local.yml up -d
```
#### 常用命令
```bash
# 停止所有服务
docker compose -f docker-compose.local.yml down
# 重启
docker compose -f docker-compose.local.yml restart
# 查看所有日志
docker compose -f docker-compose.local.yml logs -f
# 删除所有数据(谨慎!)
docker compose -f docker-compose.local.yml down
rm -rf data/ postgres_data/ redis_data/
```
---
### 方式三:源码编译
从源码编译安装,适合开发或定制需求。
#### 前置条件
- Go 1.21+
- Node.js 18+
- PostgreSQL 15+
- Redis 7+
#### 编译步骤
```bash
# 1. 克隆仓库
git clone https://github.com/Wei-Shaw/sub2api.git
cd sub2api
# 2. 安装 pnpm(如果还没有安装)
npm install -g pnpm
# 3. 编译前端
cd frontend
pnpm install
pnpm run build
# 构建产物输出到 ../backend/internal/web/dist/
# 4. 编译后端(嵌入前端)
cd ../backend
go build -tags embed -o sub2api ./cmd/server
# 5. 创建配置文件
cp ../deploy/config.example.yaml ./config.yaml
# 6. 编辑配置
nano config.yaml
```
> **注意:** `-tags embed` 参数会将前端嵌入到二进制文件中。不使用此参数编译的程序将不包含前端界面。
**`config.yaml` 关键配置:**
```yaml
server:
host: "0.0.0.0"
port: 8080
mode: "release"
database:
host: "localhost"
port: 5432
user: "postgres"
password: "your_password"
dbname: "sub2api"
redis:
host: "localhost"
port: 6379
password: ""
jwt:
secret: "change-this-to-a-secure-random-string"
expire_hour: 24
default:
user_concurrency: 5
user_balance: 0
api_key_prefix: "sk-"
rate_multiplier: 1.0
```
### Sora 功能状态(暂不可用)
> ⚠️ 当前 Sora 相关功能因上游接入与媒体链路存在技术问题,暂时不可用。
> 现阶段请勿在生产环境依赖 Sora 能力。
> 文档中的 `gateway.sora_*` 配置仅作预留,待技术问题修复后再恢复可用。
### Sora 媒体签名 URL(功能恢复后可选)
当配置 `gateway.sora_media_signing_key` 且 `gateway.sora_media_signed_url_ttl_seconds > 0` 时,网关会将 Sora 输出的媒体地址改写为临时签名 URL(`/sora/media-signed/...`)。这样无需 API Key 即可在浏览器中直接访问,且具备过期控制与防篡改能力(签名包含 path + query)。
```yaml
gateway:
# /sora/media 是否强制要求 API Key(默认 false)
sora_media_require_api_key: false
# 媒体临时签名密钥(为空则禁用签名)
sora_media_signing_key: "your-signing-key"
# 临时签名 URL 有效期(秒)
sora_media_signed_url_ttl_seconds: 900
```
> 若未配置签名密钥,`/sora/media-signed` 将返回 503。
> 如需更严格的访问控制,可将 `sora_media_require_api_key` 设为 true,仅允许携带 API Key 的 `/sora/media` 访问。
访问策略说明:
- `/sora/media`:内部调用或客户端携带 API Key 才能下载
- `/sora/media-signed`:外部可访问,但有签名 + 过期控制
`config.yaml` 还支持以下安全相关配置:
- `cors.allowed_origins` 配置 CORS 白名单
- `security.url_allowlist` 配置上游/价格数据/CRS 主机白名单
- `security.url_allowlist.enabled` 可关闭 URL 校验(慎用)
- `security.url_allowlist.allow_insecure_http` 关闭校验时允许 HTTP URL
- `security.url_allowlist.allow_private_hosts` 允许私有/本地 IP 地址
- `security.response_headers.enabled` 可启用可配置响应头过滤(关闭时使用默认白名单)
- `security.csp` 配置 Content-Security-Policy
- `billing.circuit_breaker` 计费异常时 fail-closed
- `server.trusted_proxies` 启用可信代理解析 X-Forwarded-For
- `turnstile.required` 在 release 模式强制启用 Turnstile
**网关防御纵深建议(重点)**
- `gateway.upstream_response_read_max_bytes`:限制非流式上游响应读取大小(默认 `8MB`),用于防止异常响应导致内存放大。
- `gateway.proxy_probe_response_read_max_bytes`:限制代理探测响应读取大小(默认 `1MB`)。
- `gateway.gemini_debug_response_headers`:默认 `false`,仅在排障时短时开启,避免高频请求日志开销。
- `/auth/register`、`/auth/login`、`/auth/login/2fa`、`/auth/send-verify-code` 已提供服务端兜底限流(Redis 故障时 fail-close)。
- 推荐将 WAF/CDN 作为第一层防护,服务端限流与响应读取上限作为第二层兜底;两层同时保留,避免旁路流量与误配置风险。
**⚠️ 安全警告:HTTP URL 配置**
当 `security.url_allowlist.enabled=false` 时,系统默认执行最小 URL 校验,**拒绝 HTTP URL**,仅允许 HTTPS。要允许 HTTP URL(例如用于开发或内网测试),必须显式设置:
```yaml
security:
url_allowlist:
enabled: false # 禁用白名单检查
allow_insecure_http: true # 允许 HTTP URL(⚠️ 不安全)
```
**或通过环境变量:**
```bash
SECURITY_URL_ALLOWLIST_ENABLED=false
SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP=true
```
**允许 HTTP 的风险:**
- API 密钥和数据以**明文传输**(可被截获)
- 易受**中间人攻击 (MITM)**
- **不适合生产环境**
**适用场景:**
- ✅ 开发/测试环境的本地服务器(http://localhost)
- ✅ 内网可信端点
- ✅ 获取 HTTPS 前测试账号连通性
- ❌ 生产环境(仅使用 HTTPS)
**未设置此项时的错误示例:**
```
Invalid base URL: invalid url scheme: http
```
如关闭 URL 校验或响应头过滤,请加强网络层防护:
- 出站访问白名单限制上游域名/IP
- 阻断私网/回环/链路本地地址
- 强制仅允许 TLS 出站
- 在反向代理层移除敏感响应头
```bash
# 6. 运行应用
./sub2api
```
#### HTTP/2 (h2c) 与 HTTP/1.1 回退
后端明文端口默认支持 h2c,并保留 HTTP/1.1 回退用于 WebSocket 与旧客户端。浏览器通常不支持 h2c,性能收益主要在反向代理或内网链路。
**反向代理示例(Caddy):**
```caddyfile
transport http {
versions h2c h1
}
```
**验证:**
```bash
# h2c prior knowledge
curl --http2-prior-knowledge -I http://localhost:8080/health
# HTTP/1.1 回退
curl --http1.1 -I http://localhost:8080/health
# WebSocket 回退验证(需管理员 token)
websocat -H="Sec-WebSocket-Protocol: sub2api-admin, jwt.<ADMIN_TOKEN>" ws://localhost:8080/api/v1/admin/ops/ws/qps
```
#### 开发模式
```bash
# 后端(支持热重载)
cd backend
go run ./cmd/server
# 前端(支持热重载)
cd frontend
pnpm run dev
```
#### 代码生成
修改 `backend/ent/schema` 后,需要重新生成 Ent + Wire:
```bash
cd backend
go generate ./ent
go generate ./cmd/server
```
---
## 简易模式
简易模式适合个人开发者或内部团队快速使用,不依赖完整 SaaS 功能。
- 启用方式:设置环境变量 `RUN_MODE=simple`
- 功能差异:隐藏 SaaS 相关功能,跳过计费流程
- 安全注意事项:生产环境需同时设置 `SIMPLE_MODE_CONFIRM=true` 才允许启动
---
## Antigravity 使用说明
Sub2API 支持 [Antigravity](https://antigravity.so/) 账户,授权后可通过专用端点访问 Claude 和 Gemini 模型。
### 专用端点
| 端点 | 模型 |
|------|------|
| `/antigravity/v1/messages` | Claude 模型 |
| `/antigravity/v1beta/` | Gemini 模型 |
### Claude Code 配置示例
```bash
export ANTHROPIC_BASE_URL="http://localhost:8080/antigravity"
export ANTHROPIC_AUTH_TOKEN="sk-xxx"
```
### 混合调度模式
Antigravity 账户支持可选的**混合调度**功能。开启后,通用端点 `/v1/messages` 和 `/v1beta/` 也会调度该账户。
> **⚠️ 注意**:Anthropic Claude 和 Antigravity Claude **不能在同一上下文中混合使用**,请通过分组功能做好隔离。
### 已知问题
在 Claude Code 中,无法自动退出Plan Mode。(正常使用原生Claude Api时,Plan 完成后,Claude Code会弹出弹出选项让用户同意或拒绝Plan。)
解决办法:shift + Tab,手动退出Plan mode,然后输入内容 告诉 Claude Code 同意或拒绝 Plan
---
## 项目结构
```
sub2api/
├── backend/ # Go 后端服务
│ ├── cmd/server/ # 应用入口
│ ├── internal/ # 内部模块
│ │ ├── config/ # 配置管理
│ │ ├── model/ # 数据模型
│ │ ├── service/ # 业务逻辑
│ │ ├── handler/ # HTTP 处理器
│ │ └── gateway/ # API 网关核心
│ └── resources/ # 静态资源
│
├── frontend/ # Vue 3 前端
│ └── src/
│ ├── api/ # API 调用
│ ├── stores/ # 状态管理
│ ├── views/ # 页面组件
│ └── components/ # 通用组件
│
└── deploy/ # 部署文件
├── docker-compose.yml # Docker Compose 配置
├── .env.example # Docker Compose 环境变量
├── config.example.yaml # 二进制部署完整配置文件
└── install.sh # 一键安装脚本
```
## 免责声明
> **使用本项目前请仔细阅读:**
>
> :rotating_light: **服务条款风险**: 使用本项目可能违反 Anthropic 的服务条款。请在使用前仔细阅读 Anthropic 的用户协议,使用本项目的一切风险由用户自行承担。
>
> :book: **免责声明**: 本项目仅供技术学习和研究使用,作者不对因使用本项目导致的账户封禁、服务中断或其他损失承担任何责任。
---
## Star History
<a href="https://star-history.com/#Wei-Shaw/sub2api&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=Wei-Shaw/sub2api&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=Wei-Shaw/sub2api&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=Wei-Shaw/sub2api&type=Date" />
</picture>
</a>
---
## 许可证
本项目基于 [GNU 宽通用公共许可证 v3.0](LICENSE)(或更高版本)授权。
Copyright (c) 2026 Wesley Liddick
---
<div align="center">
**如果觉得有用,请给个 Star 支持一下!**
</div>
+28 -52
View File
@@ -12,7 +12,7 @@
**サブスクリプションクォータ配分のための AI API ゲートウェイプラットフォーム**
[English](README.md) | [中文](README_CN.md) | 日本語
[English](README.md) | 日本語
</div>
@@ -24,6 +24,22 @@
- **⚖️ 法令遵守**:お住まいの国または地域の法令を遵守した上で本プロジェクトをご利用ください。いかなる違法な目的での使用も固く禁じます。
- **📖 免責事項**:本プロジェクトは技術的な学習および研究の目的でのみ提供されます。本プロジェクトの使用により生じたアカウントの停止、サービスの中断、データの損失、その他一切の直接的または間接的な損害について、作者は一切の責任を負いません。
## 概要
Sub2API は、AI 製品のサブスクリプションから API クォータを配分・管理するために設計された AI API ゲートウェイプラットフォームです。ユーザーはプラットフォームが生成した API キーを通じて上流の AI サービスにアクセスでき、プラットフォームは認証、課金、負荷分散、リクエスト転送を処理します。
## 機能
- **マルチアカウント管理** - 複数の上流アカウントタイプ(OAuth、APIキー)をサポート
- **APIキー配布** - ユーザー向けの APIキーの生成と管理
- **精密な課金** - トークンレベルの使用量追跡とコスト計算
- **スマートスケジューリング** - スティッキーセッション付きのインテリジェントなアカウント選択
- **同時実行制御** - ユーザーごと・アカウントごとの同時実行数制限
- **レート制限** - 設定可能なリクエスト数およびトークンレート制限
- **内蔵決済システム** - EasyPay、Alipay、WeChat Pay、Stripe に対応。ユーザーのセルフサービスチャージが可能で、別途決済サービスのデプロイは不要([設定ガイド](docs/PAYMENT.md))
- **管理ダッシュボード** - 監視・管理のための Web インターフェース
- **外部システム連携** - 外部システム(チケット管理など)を iframe 経由で管理ダッシュボードに埋め込み可能
## ❤️ スポンサー
> [こちらに掲載しませんか?](mailto:support@pincc.ai)
@@ -50,6 +66,11 @@
<td>SilkAPI のご支援に感謝します!<a href="https://code.silkapi.com/register?aff=SUB2API">SilkAPI</a> は Sub2API をベースに構築された中継サービスで、高速かつ安定した Codex API 中継の提供に特化しています。</td>
</tr>
<tr>
<td width="180"><a href="https://ylscode.com/"><img src="assets/partners/logos/ylscode.png" alt="ylscode" width="150"></a></td>
<td>YLS Code のご支援に感謝します!<a href="https://ylscode.com/">YLS Code</a> は安全なエンタープライズグレードの Coding Agent 生産性サービスの構築に取り組んでおり、安定かつ高速な Codex / Claude / Gemini サブスクリプションサービスと従量課金 API の柔軟なプランを提供しています。期間限定で新規登録者に 3 日間の Codex 試用特典をプレゼント中!</td>
</tr>
<tr>
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=KMVZQM"><img src="assets/partners/logos/AICodeMirror.jpg" alt="AICodeMirror" width="150"></a></td>
<td>AICodeMirror のご支援に感謝します!AICodeMirror は Claude Code / Codex / Gemini CLI の公式高安定性リレーサービスを提供しており、エンタープライズグレードの同時実行、迅速な請求書発行、24時間年中無休の専属テクニカルサポートを備えています。Claude Code / Codex / Gemini の公式チャネルを定価の 38% / 2% / 9% で利用可能、チャージ時にはさらに追加割引!AICodeMirror は sub2api ユーザー向けに特別特典を提供中:<a href="https://www.aicodemirror.com/register?invitecode=KMVZQM">こちらのリンク</a>から登録すると、初回チャージが 20% オフ、法人のお客様は最大 25% オフ!</td>
@@ -78,6 +99,12 @@
</td>
</tr>
<tr>
<td width="180"><a href="https://runapi.co/register?aff=fu2E"><img src="assets/partners/logos/runapi.png" alt="RunAPI" width="150"></a></td>
<td>RunAPI のご支援に感謝します!<a href="https://runapi.co/register?aff=fu2E">RunAPI</a> は効率的で安定した API プラットフォームで、OpenRouter の代替として利用できます。1つの API キーで OpenAI、Claude、Gemini、DeepSeek、Grok など 150以上の主要モデルにアクセスでき、価格は最低 10% から。非常に安定しており、Claude Code や OpenClaw などのツールとシームレスに互換します。
</td>
</tr>
<tr>
<td width="180"><a href="https://unity2.ai/register?source=sub2api"><img src="assets/partners/logos/unity2.png" alt="unity2" width="150"></a></td>
<td>Unity2 のご支援に感謝します!<a href="https://unity2.ai/register?source=sub2api">Unity2</a> は個人開発者、チーム、企業向けの高性能 AI モデル API 中継プラットフォームです。中国の大手企業に長期にわたりサービスを提供しており、1日あたり 300 億以上のトークン呼び出しを処理し、5000 RPM 級の高並列性をサポートします。1つの API キーで Claude Code、Codex、OpenAI モデル、IDE プラグイン、Agent ワークフローなど様々なシナリオに対応できます。エンタープライズグレードの安定供給能力を備え、高並列・継続的な呼び出し・チームの集中購入シーンでも低レイテンシと高可用性を維持します。残高課金、組み合わせサブスクリプション、初回チャージ特典、企業向け請求書発行、専属 1v1 サポートにも対応しており、個人の頻繁な利用にも企業の長期導入にも適しています。今 Unity2.ai に登録すると $2 の残高、公式グループに参加するとさらに $10 の残高がもらえ、合計最大 $12 の無料クレジットを獲得できます — 試用後に長期利用したい方に最適です。<a href="https://unity2.ai/register?source=sub2api">登録リンク</a>
@@ -96,59 +123,8 @@
</td>
</tr>
<tr>
<td width="180"><a href="https://apikl.com"><img src="assets/partners/logos/apikl.png" alt="apikl" width="150"></a></td>
<td>Apikl のご支援に感謝します!Sub2API をベースに構築された本プラットフォームは、開発者向けに Codex / Claude シリーズモデルの中継サービスを提供しています。長期安定性、高速直結、高いコストパフォーマンスを重視し、従量課金の残高ベース課金、エンタープライズグレードの正規請求書、1対1の専属サポートを提供します。<a href="https://apikl.com">今すぐ登録</a>でチャージ 1:1 ボーナス — 残高が倍に!
</td>
</tr>
<tr>
<td width="180"><a href="https://tokeneum.ai"><img src="assets/partners/logos/tokeneum.png" alt="tokeneum" width="150"></a></td>
<td>TokenEum のご支援に感謝します!<a href="https://tokeneum.ai">TokenEum</a> は総合的な AI モデル集約プラットフォームおよびインテリジェントエージェント開発企業です。Claude、Gemini、OpenAI などの世界トップクラスのモデルに加え、GLM、Qwen、Kimi などの主要なオープンソースモデルも集約しており、品質と価格の異なる豊富な選択肢を提供してあらゆるニーズに対応します。また、Seedance2.0 や Happy Horse などの最先端の動画生成モデルも利用可能です。TokenEum は透明性と誠実なビジネスを重視し、すべてのモデル情報の正確性と信頼性を保証します。<a href="https://tokeneum.ai">tokeneum.ai</a> でぜひお試しください。
</td>
</tr>
<tr>
<td width="180"><a href="https://666api.work/sub2api"><img src="assets/partners/logos/666api.jpg" alt="666api" width="150"></a></td>
<td>666api のご支援に感謝します!<a href="https://666api.work/sub2api">666api</a> はオールインワンプラットフォームで、以下を提供しています:⚡ API 中継 — グローバルモデルへの従量課金アクセス、100% 公式ソースから直接供給、公式価格の最大 75% オフ。独占特典:Zhipu GLM 50% オフ・DeepSeek V4-pro 50% オフ・Seedance2.0 8% オフ(ホワイトリスト)・HappyHorse Overseas 30% オフ(ホワイトリスト)🔑 GPT サブスクリプションアカウント — 同源 IP 込み・グローバル住宅 IP 💰 請求書発行対応
</td>
</tr>
<tr>
<td width="180"><a href="https://dis.chatdesks.cn/chatdesk/hsyqsub2api.html"><img src="assets/partners/logos/byteplus.png" alt="BytePlus" width="150"></a></td>
<td>Dola seed のご支援に感謝します!Dola Seed 2.0 は、ByteDance がグローバル市場向けに独自開発した全モーダル汎用大規模モデルです。統一されたマルチモーダルアーキテクチャに基づき、テキスト・画像・音声・動画の共同理解と生成をサポートします。エージェント協調をネイティブに有効化し、強力な推論・長期タスク実行・ツール統合・コーディング能力を備えています。スマートコックピット、パーソナルアシスタント、教育、カスタマーサポート、マーケティング、リテールなど多様なシナリオに広く適用可能です。マルチモーダル知覚、エンドツーエンドの複雑タスク配信、安定したインタラクション、データセキュリティに優れ、ModelArk プラットフォームを通じて容易にアクセス・デプロイできます。<a href="https://dis.chatdesks.cn/chatdesk/hsyqsub2api.html">こちらのリンク</a>から登録すると、各モデルにつき 50 万トークンの無料推論クォータを獲得できます。<a href="https://dis.chatdesks.cn/chatdesk/hsyqsub2api.html"> >>中国大陸地域の開発者はこちらをクリック</a></td>
</tr>
<tr>
<td width="180"><a href="https://sui-xiang.com/"><img src="assets/partners/logos/sui-xiang.jpg" alt="sui-xiang" width="150"></a></td>
<td>随想AI ゲートウェイのご支援に感謝します!<a href="https://sui-xiang.com/">随想AI ゲートウェイ</a> は信頼性と効率に優れた API 中継サービスプロバイダーで、Claude、Codex、Gemini などの中継サービスを提供しています。プライバシー重視の中継ステーション・データ転売なし・モデル水増しなし、プライバシー・透明性・超高速アフターサービス。新規アカウント登録後、毎日サインインで 0.5 元のテストクレジットがもらえ、チャージは 1:1、サブスクリプション不要、従量課金。マルチライン冗長、クロスリージョン災害復旧、自動フェイルオーバー、長時間 SSE 接続が途切れません。99.9% の可用性、重要な呼び出しは決して遅れません。
</td>
</tr>
<tr>
<td width="180"><a href="https://www.miyaip.com/?invitecode=sub2api"><img src="assets/partners/logos/miyaip.png" alt="miyaip" width="150"></a></td>
<td>MiyaIP のご支援に感謝します!<a href="https://www.miyaip.com/?invitecode=sub2api">MiyaIP</a> はグローバル住宅プロキシネットワークサービスに特化したプラットフォームで、企業開発者、越境ビジネスチーム、AI アプリケーションユーザーに高品質で純粋な海外住宅 IP リソースを提供することに注力しています。AI プラットフォーム、海外 SaaS、その他のオンラインサービスに安定した独立した海外ネットワーク環境を提供し、複数地域でのアクセステストやプロジェクト環境の分離をサポートします。海外 AI サービスへのアクセスが必要な開発・テストシナリオに最適です。例:AI モデルプラットフォームアクセス、AI 開発テスト、AI SaaS サービス利用、AI API デバッグ、複数地域のネットワーク環境検証など。
</td>
</tr>
</table>
## 概要
Sub2API は、AI 製品のサブスクリプションから API クォータを配分・管理するために設計された AI API ゲートウェイプラットフォームです。ユーザーはプラットフォームが生成した API キーを通じて上流の AI サービスにアクセスでき、プラットフォームは認証、課金、負荷分散、リクエスト転送を処理します。
## 機能
- **マルチアカウント管理** - 複数の上流アカウントタイプ(OAuth、APIキー)をサポート
- **APIキー配布** - ユーザー向けの APIキーの生成と管理
- **精密な課金** - トークンレベルの使用量追跡とコスト計算
- **スマートスケジューリング** - スティッキーセッション付きのインテリジェントなアカウント選択
- **同時実行制御** - ユーザーごと・アカウントごとの同時実行数制限
- **レート制限** - 設定可能なリクエスト数およびトークンレート制限
- **内蔵決済システム** - EasyPay、Alipay、WeChat Pay、Stripe に対応。ユーザーのセルフサービスチャージが可能で、別途決済サービスのデプロイは不要([設定ガイド](docs/PAYMENT.md))
- **管理ダッシュボード** - 監視・管理のための Web インターフェース
- **外部システム連携** - 外部システム(チケット管理など)を iframe 経由で管理ダッシュボードに埋め込み可能
## エコシステム
Sub2API を拡張・統合するコミュニティプロジェクト:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

+15 -2
View File
@@ -1,4 +1,4 @@
.PHONY: build generate test test-unit test-integration test-e2e
.PHONY: build generate test test-unit test-integration test-e2e test-invariants new-module
VERSION ?= $(shell tr -d '\r\n' < ./cmd/server/VERSION)
LDFLAGS ?= -s -w -X main.Version=$(VERSION)
@@ -20,8 +20,21 @@ test-unit:
test-integration:
go test -tags=integration ./...
# 插件化改造回归安全网(特征化/不变量测试,详见 .claude/plugin-refactor/INVARIANTS.md)
test-invariants:
go test -tags=unit -count=1 -run 'Characterization|Invariant' ./internal/...
# 生成插件模块骨架:make new-module ID=job.foo
# (生成后按提示在 internal/modules/standard/imports.go 手工加 import 行)
new-module:
ifndef ID
$(error ID is required: make new-module ID=job.foo)
endif
go run ./tools/newmodule -id "$(ID)"
# scripts/e2e-test.sh 已不存在,test-e2e 与 test-e2e-local 等价(env 驱动的真实服务 e2e)
test-e2e:
./scripts/e2e-test.sh
go test -tags=e2e -v -timeout=300s ./internal/integration/...
test-e2e-local:
go test -tags=e2e -v -timeout=300s ./internal/integration/...
+1 -1
View File
@@ -1 +1 @@
0.1.138
0.1.136
+16
View File
@@ -18,6 +18,7 @@ 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/modules/standard" // 插件模块插装清单(唯一插装点)
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/setup"
@@ -154,6 +155,21 @@ func runMainServer() {
}
defer app.Cleanup()
// 启动插件模块:Build(实例化 → Provision → Validate)+ Start(按稳定序)。
// 任一失败立即中止启动(fail-fast)。注意:log.Fatalf 经 os.Exit 退出,
// defer 的 app.Cleanup() 不会执行;而部分后台服务在 Wire 构造期已自启动并
// 持有 Redis leader 锁,必须先显式 Cleanup 再退出,避免锁滞留到 TTL 过期。
// ⚠ 不要把下面的 log.Fatalf 改成 return:那会让上面 defer 的 Cleanup 与
// 此处显式 Cleanup 双触发,而 Cleanup 各步骤并未统一保证幂等(审计 B-2)。
if err := app.Runtime.Build(); err != nil {
app.Cleanup()
log.Fatalf("Failed to build plugin modules: %v", err)
}
if err := app.Runtime.Start(context.Background()); err != nil {
app.Cleanup()
log.Fatalf("Failed to start plugin modules: %v", err)
}
// 启动服务器
go func() {
if err := app.Server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
+18 -9
View File
@@ -14,6 +14,7 @@ import (
"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/plugin"
"github.com/Wei-Shaw/sub2api/internal/repository"
"github.com/Wei-Shaw/sub2api/internal/server"
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
@@ -24,7 +25,10 @@ import (
)
type Application struct {
Server *http.Server
Server *http.Server
// Runtime 是插件模块生命周期驱动器:main 在 HTTP server 启动前
// 调用 Build + Start,关闭时由 Cleanup 序列执行 Stop。
Runtime *plugin.Runtime
Cleanup func()
}
@@ -43,6 +47,9 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
// Server layer ProviderSet
server.ProviderSet,
// Plugin kernel ProviderSet (Host + module Runtime)
plugin.ProviderSet,
// Privacy client factory for OpenAI training opt-out
providePrivacyClientFactory,
@@ -53,7 +60,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
provideCleanup,
// Application struct
wire.Struct(new(Application), "Server", "Cleanup"),
wire.Struct(new(Application), "Server", "Runtime", "Cleanup"),
)
return nil, nil
}
@@ -94,13 +101,13 @@ func provideCleanup(
openaiOAuth *service.OpenAIOAuthService,
geminiOAuth *service.GeminiOAuthService,
antigravityOAuth *service.AntigravityOAuthService,
grokOAuth *service.GrokOAuthService,
openAIGateway *service.OpenAIGatewayService,
scheduledTestRunner *service.ScheduledTestRunnerService,
backupSvc *service.BackupService,
paymentOrderExpiry *service.PaymentOrderExpiryService,
channelMonitorRunner *service.ChannelMonitorRunner,
quotaFlusher *service.UserPlatformQuotaUsageFlusher,
moduleRuntime *plugin.Runtime,
) func() {
return func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
@@ -113,6 +120,14 @@ func provideCleanup(
// 应用层清理步骤可并行执行,基础设施资源(Redis/Ent)最后按顺序关闭。
parallelSteps := []cleanupStep{
// 插件模块 Stop 属于业务服务关闭组:模块停止时可能仍需写 DB/Redis,
// 必须先于基础设施(Redis/Ent)关闭执行。
{"PluginModuleRuntime", func() error {
if moduleRuntime == nil {
return nil
}
return moduleRuntime.Stop(ctx)
}},
{"OpsScheduledReportService", func() error {
if opsScheduledReport != nil {
opsScheduledReport.Stop()
@@ -223,12 +238,6 @@ func provideCleanup(
antigravityOAuth.Stop()
return nil
}},
{"GrokOAuthService", func() error {
if grokOAuth != nil {
grokOAuth.Stop()
}
return nil
}},
{"OpenAIWSPool", func() error {
if openAIGateway != nil {
openAIGateway.CloseOpenAIWSPool()
+32 -23
View File
@@ -13,6 +13,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/handler"
"github.com/Wei-Shaw/sub2api/internal/handler/admin"
"github.com/Wei-Shaw/sub2api/internal/payment"
"github.com/Wei-Shaw/sub2api/internal/plugin"
"github.com/Wei-Shaw/sub2api/internal/repository"
"github.com/Wei-Shaw/sub2api/internal/server"
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
@@ -27,6 +28,7 @@ import (
import (
_ "embed"
_ "github.com/Wei-Shaw/sub2api/ent/runtime"
_ "github.com/Wei-Shaw/sub2api/internal/modules/standard"
)
// Injectors from wire.go:
@@ -141,10 +143,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
privacyClientFactory := providePrivacyClientFactory()
openAIOAuthService := service.ProvideOpenAIOAuthService(proxyRepository, openAIOAuthClient, privacyClientFactory)
openAITokenProvider := service.ProvideOpenAITokenProvider(accountRepository, geminiTokenCache, openAIOAuthService, oAuthRefreshAPI)
grokOAuthClient := repository.NewGrokOAuthClient()
grokOAuthService := service.NewGrokOAuthService(proxyRepository, grokOAuthClient)
grokTokenProvider := service.ProvideGrokTokenProvider(accountRepository, geminiTokenCache, grokOAuthService, oAuthRefreshAPI, tempUnschedCache)
openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, httpUpstream, deferredService, openAITokenProvider, grokTokenProvider, modelPricingResolver, channelService, balanceNotifyService, settingService, serviceUserPlatformQuotaRepository)
openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, httpUpstream, deferredService, openAITokenProvider, modelPricingResolver, channelService, balanceNotifyService, settingService, serviceUserPlatformQuotaRepository)
geminiOAuthClient := repository.NewGeminiOAuthClient(configConfig)
geminiCliCodeAssistClient := repository.NewGeminiCliCodeAssistClient()
driveClient := repository.NewGeminiDriveClient()
@@ -181,9 +180,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
groupHandler := admin.NewGroupHandler(adminService, dashboardService, groupCapacityService)
claudeUsageFetcher := repository.NewClaudeUsageFetcher(httpUpstream)
antigravityQuotaFetcher := service.NewAntigravityQuotaFetcher(proxyRepository)
grokQuotaFetcher := service.NewGrokQuotaFetcher()
usageCache := service.NewUsageCache()
accountUsageService := service.NewAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher, geminiQuotaService, antigravityQuotaFetcher, grokQuotaFetcher, usageCache, identityCache, tlsFingerprintProfileService)
accountUsageService := service.NewAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher, geminiQuotaService, antigravityQuotaFetcher, usageCache, identityCache, tlsFingerprintProfileService)
accountTestService := service.NewAccountTestService(accountRepository, geminiTokenProvider, claudeTokenProvider, antigravityGatewayService, httpUpstream, configConfig, tlsFingerprintProfileService)
crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig)
accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService, sessionLimitCache, rpmCache, compositeTokenCacheInvalidator)
@@ -195,12 +193,9 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
backupService := service.ProvideBackupService(settingRepository, configConfig, secretEncryptor, backupObjectStoreFactory, dbDumper)
backupHandler := admin.NewBackupHandler(backupService, userService)
oAuthHandler := admin.NewOAuthHandler(oAuthService)
openAIQuotaService := service.ProvideOpenAIQuotaService(accountRepository, proxyRepository, openAITokenProvider, privacyClientFactory)
openAIOAuthHandler := admin.NewOpenAIOAuthHandler(openAIOAuthService, adminService, openAIQuotaService)
openAIOAuthHandler := admin.NewOpenAIOAuthHandler(openAIOAuthService, adminService)
geminiOAuthHandler := admin.NewGeminiOAuthHandler(geminiOAuthService)
antigravityOAuthHandler := admin.NewAntigravityOAuthHandler(antigravityOAuthService)
grokQuotaService := service.ProvideGrokQuotaService(accountRepository, proxyRepository, grokTokenProvider, httpUpstream)
grokOAuthHandler := admin.NewGrokOAuthHandler(grokOAuthService, adminService, grokQuotaService)
proxyHandler := admin.NewProxyHandler(adminService)
adminRedeemHandler := admin.NewRedeemHandler(adminService, redeemService)
promoHandler := admin.NewPromoHandler(promoService)
@@ -248,12 +243,21 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
paymentHandler := admin.NewPaymentHandler(paymentService, paymentConfigService)
affiliateHandler := admin.NewAffiliateHandler(affiliateService, adminService)
complianceHandler := admin.NewComplianceHandler(settingService)
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, grokOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, paymentHandler, affiliateHandler, complianceHandler)
pluginConfig, err := plugin.ProvideModuleConfig(configConfig)
if err != nil {
return nil, err
}
host := plugin.ProvideHost(pluginConfig, client, redisClient)
runtime := plugin.ProvideModuleRuntime(host, pluginConfig)
moduleHandler := admin.NewModuleHandler(runtime)
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, complianceHandler, moduleHandler)
gatewayplatformRegistry := handler.ProvideGatewayPlatformRegistry(gatewayService, antigravityGatewayService)
usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig)
chain := handler.ProvideGatewayHookChain(contentModerationService)
userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient)
userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, 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, opsService, configConfig)
gatewayHandler := handler.NewGatewayHandler(gatewayService, geminiMessagesCompatService, antigravityGatewayService, gatewayplatformRegistry, userService, concurrencyService, billingCacheService, usageService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, chain, userMessageQueueService, configConfig, settingService)
openAIGatewayHandler := handler.NewOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, chain, configConfig)
handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo, notificationEmailService)
totpHandler := handler.NewTotpHandler(totpService)
handlerPaymentHandler := handler.NewPaymentHandler(paymentService, paymentConfigService, channelService)
@@ -272,7 +276,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
opsAlertEvaluatorService := service.ProvideOpsAlertEvaluatorService(opsService, opsRepository, emailService, redisClient, configConfig, proxyRepository)
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, grokOAuthService, compositeTokenCacheInvalidator, schedulerCache, configConfig, tempUnschedCache, privacyClientFactory, proxyRepository, oAuthRefreshAPI, openAIGatewayService)
tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, compositeTokenCacheInvalidator, schedulerCache, configConfig, tempUnschedCache, privacyClientFactory, proxyRepository, oAuthRefreshAPI, openAIGatewayService)
accountExpiryService := service.ProvideAccountExpiryService(accountRepository)
proxyExpiryService := service.ProvideProxyExpiryService(proxyRepository)
subscriptionExpiryService := service.ProvideSubscriptionExpiryService(userSubscriptionRepository, settingRepository, notificationEmailService, leaderLockCache, db)
@@ -280,9 +284,10 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
paymentOrderExpiryService := service.ProvidePaymentOrderExpiryService(paymentService, leaderLockCache, db)
channelMonitorRunner := service.ProvideChannelMonitorRunner(channelMonitorService, settingService)
userPlatformQuotaUsageFlusher := service.ProvideUserPlatformQuotaUsageFlusher(configConfig, billingCache, serviceUserPlatformQuotaRepository, timingWheelService)
v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, schedulerSnapshotService, tokenRefreshService, accountExpiryService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, userPlatformQuotaUsageFlusher)
v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, schedulerSnapshotService, tokenRefreshService, accountExpiryService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, userPlatformQuotaUsageFlusher, runtime)
application := &Application{
Server: httpServer,
Runtime: runtime,
Cleanup: v,
}
return application, nil
@@ -291,7 +296,10 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
// wire.go:
type Application struct {
Server *http.Server
Server *http.Server
// Runtime 是插件模块生命周期驱动器:main 在 HTTP server 启动前
// 调用 Build + Start,关闭时由 Cleanup 序列执行 Stop。
Runtime *plugin.Runtime
Cleanup func()
}
@@ -331,13 +339,13 @@ func provideCleanup(
openaiOAuth *service.OpenAIOAuthService,
geminiOAuth *service.GeminiOAuthService,
antigravityOAuth *service.AntigravityOAuthService,
grokOAuth *service.GrokOAuthService,
openAIGateway *service.OpenAIGatewayService,
scheduledTestRunner *service.ScheduledTestRunnerService,
backupSvc *service.BackupService,
paymentOrderExpiry *service.PaymentOrderExpiryService,
channelMonitorRunner *service.ChannelMonitorRunner,
quotaFlusher *service.UserPlatformQuotaUsageFlusher,
moduleRuntime *plugin.Runtime,
) func() {
return func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
@@ -349,6 +357,13 @@ func provideCleanup(
}
parallelSteps := []cleanupStep{
{"PluginModuleRuntime", func() error {
if moduleRuntime == nil {
return nil
}
return moduleRuntime.Stop(ctx)
}},
{"OpsScheduledReportService", func() error {
if opsScheduledReport != nil {
opsScheduledReport.Stop()
@@ -459,12 +474,6 @@ func provideCleanup(
antigravityOAuth.Stop()
return nil
}},
{"GrokOAuthService", func() error {
if grokOAuth != nil {
grokOAuth.Stop()
}
return nil
}},
{"OpenAIWSPool", func() error {
if openAIGateway != nil {
openAIGateway.CloseOpenAIWSPool()
+1 -1
View File
@@ -74,13 +74,13 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) {
openAIOAuthSvc,
geminiOAuthSvc,
antigravityOAuthSvc,
nil, // grokOAuth
nil, // openAIGateway
nil, // scheduledTestRunner
nil, // backupSvc
nil, // paymentOrderExpiry
nil, // channelMonitorRunner
nil, // quotaFlusher
nil, // moduleRuntime
)
require.NotPanics(t, func() {
+1 -12
View File
@@ -43,8 +43,6 @@ type ChannelMonitor struct {
Enabled bool `json:"enabled,omitempty"`
// IntervalSeconds holds the value of the "interval_seconds" field.
IntervalSeconds int `json:"interval_seconds,omitempty"`
// 每次调度在 interval 基础上 ± [0, jitter] 的均匀随机偏移(秒);0 表示固定间隔。service 层另保证 interval - jitter >= 15
JitterSeconds int `json:"jitter_seconds,omitempty"`
// LastCheckedAt holds the value of the "last_checked_at" field.
LastCheckedAt *time.Time `json:"last_checked_at,omitempty"`
// CreatedBy holds the value of the "created_by" field.
@@ -114,7 +112,7 @@ func (*ChannelMonitor) scanValues(columns []string) ([]any, error) {
values[i] = new([]byte)
case channelmonitor.FieldEnabled:
values[i] = new(sql.NullBool)
case channelmonitor.FieldID, channelmonitor.FieldIntervalSeconds, channelmonitor.FieldJitterSeconds, channelmonitor.FieldCreatedBy, channelmonitor.FieldTemplateID:
case channelmonitor.FieldID, channelmonitor.FieldIntervalSeconds, channelmonitor.FieldCreatedBy, channelmonitor.FieldTemplateID:
values[i] = new(sql.NullInt64)
case channelmonitor.FieldName, channelmonitor.FieldProvider, channelmonitor.FieldAPIMode, channelmonitor.FieldEndpoint, channelmonitor.FieldAPIKeyEncrypted, channelmonitor.FieldPrimaryModel, channelmonitor.FieldGroupName, channelmonitor.FieldBodyOverrideMode:
values[i] = new(sql.NullString)
@@ -215,12 +213,6 @@ func (_m *ChannelMonitor) assignValues(columns []string, values []any) error {
} else if value.Valid {
_m.IntervalSeconds = int(value.Int64)
}
case channelmonitor.FieldJitterSeconds:
if value, ok := values[i].(*sql.NullInt64); !ok {
return fmt.Errorf("unexpected type %T for field jitter_seconds", values[i])
} else if value.Valid {
_m.JitterSeconds = int(value.Int64)
}
case channelmonitor.FieldLastCheckedAt:
if value, ok := values[i].(*sql.NullTime); !ok {
return fmt.Errorf("unexpected type %T for field last_checked_at", values[i])
@@ -349,9 +341,6 @@ func (_m *ChannelMonitor) String() string {
builder.WriteString("interval_seconds=")
builder.WriteString(fmt.Sprintf("%v", _m.IntervalSeconds))
builder.WriteString(", ")
builder.WriteString("jitter_seconds=")
builder.WriteString(fmt.Sprintf("%v", _m.JitterSeconds))
builder.WriteString(", ")
if v := _m.LastCheckedAt; v != nil {
builder.WriteString("last_checked_at=")
builder.WriteString(v.Format(time.ANSIC))
@@ -39,8 +39,6 @@ const (
FieldEnabled = "enabled"
// FieldIntervalSeconds holds the string denoting the interval_seconds field in the database.
FieldIntervalSeconds = "interval_seconds"
// FieldJitterSeconds holds the string denoting the jitter_seconds field in the database.
FieldJitterSeconds = "jitter_seconds"
// FieldLastCheckedAt holds the string denoting the last_checked_at field in the database.
FieldLastCheckedAt = "last_checked_at"
// FieldCreatedBy holds the string denoting the created_by field in the database.
@@ -99,7 +97,6 @@ var Columns = []string{
FieldGroupName,
FieldEnabled,
FieldIntervalSeconds,
FieldJitterSeconds,
FieldLastCheckedAt,
FieldCreatedBy,
FieldTemplateID,
@@ -147,10 +144,6 @@ var (
DefaultEnabled bool
// IntervalSecondsValidator is a validator for the "interval_seconds" field. It is called by the builders before save.
IntervalSecondsValidator func(int) error
// DefaultJitterSeconds holds the default value on creation for the "jitter_seconds" field.
DefaultJitterSeconds int
// JitterSecondsValidator is a validator for the "jitter_seconds" field. It is called by the builders before save.
JitterSecondsValidator func(int) error
// DefaultExtraHeaders holds the default value on creation for the "extra_headers" field.
DefaultExtraHeaders map[string]string
// DefaultBodyOverrideMode holds the default value on creation for the "body_override_mode" field.
@@ -246,11 +239,6 @@ func ByIntervalSeconds(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldIntervalSeconds, opts...).ToFunc()
}
// ByJitterSeconds orders the results by the jitter_seconds field.
func ByJitterSeconds(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldJitterSeconds, opts...).ToFunc()
}
// ByLastCheckedAt orders the results by the last_checked_at field.
func ByLastCheckedAt(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldLastCheckedAt, opts...).ToFunc()
-45
View File
@@ -105,11 +105,6 @@ func IntervalSeconds(v int) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldEQ(FieldIntervalSeconds, v))
}
// JitterSeconds applies equality check predicate on the "jitter_seconds" field. It's identical to JitterSecondsEQ.
func JitterSeconds(v int) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldEQ(FieldJitterSeconds, v))
}
// LastCheckedAt applies equality check predicate on the "last_checked_at" field. It's identical to LastCheckedAtEQ.
func LastCheckedAt(v time.Time) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldEQ(FieldLastCheckedAt, v))
@@ -680,46 +675,6 @@ func IntervalSecondsLTE(v int) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldLTE(FieldIntervalSeconds, v))
}
// JitterSecondsEQ applies the EQ predicate on the "jitter_seconds" field.
func JitterSecondsEQ(v int) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldEQ(FieldJitterSeconds, v))
}
// JitterSecondsNEQ applies the NEQ predicate on the "jitter_seconds" field.
func JitterSecondsNEQ(v int) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldNEQ(FieldJitterSeconds, v))
}
// JitterSecondsIn applies the In predicate on the "jitter_seconds" field.
func JitterSecondsIn(vs ...int) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldIn(FieldJitterSeconds, vs...))
}
// JitterSecondsNotIn applies the NotIn predicate on the "jitter_seconds" field.
func JitterSecondsNotIn(vs ...int) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldNotIn(FieldJitterSeconds, vs...))
}
// JitterSecondsGT applies the GT predicate on the "jitter_seconds" field.
func JitterSecondsGT(v int) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldGT(FieldJitterSeconds, v))
}
// JitterSecondsGTE applies the GTE predicate on the "jitter_seconds" field.
func JitterSecondsGTE(v int) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldGTE(FieldJitterSeconds, v))
}
// JitterSecondsLT applies the LT predicate on the "jitter_seconds" field.
func JitterSecondsLT(v int) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldLT(FieldJitterSeconds, v))
}
// JitterSecondsLTE applies the LTE predicate on the "jitter_seconds" field.
func JitterSecondsLTE(v int) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldLTE(FieldJitterSeconds, v))
}
// LastCheckedAtEQ applies the EQ predicate on the "last_checked_at" field.
func LastCheckedAtEQ(v time.Time) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldEQ(FieldLastCheckedAt, v))
-90
View File
@@ -137,20 +137,6 @@ func (_c *ChannelMonitorCreate) SetIntervalSeconds(v int) *ChannelMonitorCreate
return _c
}
// SetJitterSeconds sets the "jitter_seconds" field.
func (_c *ChannelMonitorCreate) SetJitterSeconds(v int) *ChannelMonitorCreate {
_c.mutation.SetJitterSeconds(v)
return _c
}
// SetNillableJitterSeconds sets the "jitter_seconds" field if the given value is not nil.
func (_c *ChannelMonitorCreate) SetNillableJitterSeconds(v *int) *ChannelMonitorCreate {
if v != nil {
_c.SetJitterSeconds(*v)
}
return _c
}
// SetLastCheckedAt sets the "last_checked_at" field.
func (_c *ChannelMonitorCreate) SetLastCheckedAt(v time.Time) *ChannelMonitorCreate {
_c.mutation.SetLastCheckedAt(v)
@@ -319,10 +305,6 @@ func (_c *ChannelMonitorCreate) defaults() {
v := channelmonitor.DefaultEnabled
_c.mutation.SetEnabled(v)
}
if _, ok := _c.mutation.JitterSeconds(); !ok {
v := channelmonitor.DefaultJitterSeconds
_c.mutation.SetJitterSeconds(v)
}
if _, ok := _c.mutation.ExtraHeaders(); !ok {
v := channelmonitor.DefaultExtraHeaders
_c.mutation.SetExtraHeaders(v)
@@ -408,14 +390,6 @@ func (_c *ChannelMonitorCreate) check() error {
return &ValidationError{Name: "interval_seconds", err: fmt.Errorf(`ent: validator failed for field "ChannelMonitor.interval_seconds": %w`, err)}
}
}
if _, ok := _c.mutation.JitterSeconds(); !ok {
return &ValidationError{Name: "jitter_seconds", err: errors.New(`ent: missing required field "ChannelMonitor.jitter_seconds"`)}
}
if v, ok := _c.mutation.JitterSeconds(); ok {
if err := channelmonitor.JitterSecondsValidator(v); err != nil {
return &ValidationError{Name: "jitter_seconds", err: fmt.Errorf(`ent: validator failed for field "ChannelMonitor.jitter_seconds": %w`, err)}
}
}
if _, ok := _c.mutation.CreatedBy(); !ok {
return &ValidationError{Name: "created_by", err: errors.New(`ent: missing required field "ChannelMonitor.created_by"`)}
}
@@ -505,10 +479,6 @@ func (_c *ChannelMonitorCreate) createSpec() (*ChannelMonitor, *sqlgraph.CreateS
_spec.SetField(channelmonitor.FieldIntervalSeconds, field.TypeInt, value)
_node.IntervalSeconds = value
}
if value, ok := _c.mutation.JitterSeconds(); ok {
_spec.SetField(channelmonitor.FieldJitterSeconds, field.TypeInt, value)
_node.JitterSeconds = value
}
if value, ok := _c.mutation.LastCheckedAt(); ok {
_spec.SetField(channelmonitor.FieldLastCheckedAt, field.TypeTime, value)
_node.LastCheckedAt = &value
@@ -774,24 +744,6 @@ func (u *ChannelMonitorUpsert) AddIntervalSeconds(v int) *ChannelMonitorUpsert {
return u
}
// SetJitterSeconds sets the "jitter_seconds" field.
func (u *ChannelMonitorUpsert) SetJitterSeconds(v int) *ChannelMonitorUpsert {
u.Set(channelmonitor.FieldJitterSeconds, v)
return u
}
// UpdateJitterSeconds sets the "jitter_seconds" field to the value that was provided on create.
func (u *ChannelMonitorUpsert) UpdateJitterSeconds() *ChannelMonitorUpsert {
u.SetExcluded(channelmonitor.FieldJitterSeconds)
return u
}
// AddJitterSeconds adds v to the "jitter_seconds" field.
func (u *ChannelMonitorUpsert) AddJitterSeconds(v int) *ChannelMonitorUpsert {
u.Add(channelmonitor.FieldJitterSeconds, v)
return u
}
// SetLastCheckedAt sets the "last_checked_at" field.
func (u *ChannelMonitorUpsert) SetLastCheckedAt(v time.Time) *ChannelMonitorUpsert {
u.Set(channelmonitor.FieldLastCheckedAt, v)
@@ -1101,27 +1053,6 @@ func (u *ChannelMonitorUpsertOne) UpdateIntervalSeconds() *ChannelMonitorUpsertO
})
}
// SetJitterSeconds sets the "jitter_seconds" field.
func (u *ChannelMonitorUpsertOne) SetJitterSeconds(v int) *ChannelMonitorUpsertOne {
return u.Update(func(s *ChannelMonitorUpsert) {
s.SetJitterSeconds(v)
})
}
// AddJitterSeconds adds v to the "jitter_seconds" field.
func (u *ChannelMonitorUpsertOne) AddJitterSeconds(v int) *ChannelMonitorUpsertOne {
return u.Update(func(s *ChannelMonitorUpsert) {
s.AddJitterSeconds(v)
})
}
// UpdateJitterSeconds sets the "jitter_seconds" field to the value that was provided on create.
func (u *ChannelMonitorUpsertOne) UpdateJitterSeconds() *ChannelMonitorUpsertOne {
return u.Update(func(s *ChannelMonitorUpsert) {
s.UpdateJitterSeconds()
})
}
// SetLastCheckedAt sets the "last_checked_at" field.
func (u *ChannelMonitorUpsertOne) SetLastCheckedAt(v time.Time) *ChannelMonitorUpsertOne {
return u.Update(func(s *ChannelMonitorUpsert) {
@@ -1613,27 +1544,6 @@ func (u *ChannelMonitorUpsertBulk) UpdateIntervalSeconds() *ChannelMonitorUpsert
})
}
// SetJitterSeconds sets the "jitter_seconds" field.
func (u *ChannelMonitorUpsertBulk) SetJitterSeconds(v int) *ChannelMonitorUpsertBulk {
return u.Update(func(s *ChannelMonitorUpsert) {
s.SetJitterSeconds(v)
})
}
// AddJitterSeconds adds v to the "jitter_seconds" field.
func (u *ChannelMonitorUpsertBulk) AddJitterSeconds(v int) *ChannelMonitorUpsertBulk {
return u.Update(func(s *ChannelMonitorUpsert) {
s.AddJitterSeconds(v)
})
}
// UpdateJitterSeconds sets the "jitter_seconds" field to the value that was provided on create.
func (u *ChannelMonitorUpsertBulk) UpdateJitterSeconds() *ChannelMonitorUpsertBulk {
return u.Update(func(s *ChannelMonitorUpsert) {
s.UpdateJitterSeconds()
})
}
// SetLastCheckedAt sets the "last_checked_at" field.
func (u *ChannelMonitorUpsertBulk) SetLastCheckedAt(v time.Time) *ChannelMonitorUpsertBulk {
return u.Update(func(s *ChannelMonitorUpsert) {
-64
View File
@@ -189,27 +189,6 @@ func (_u *ChannelMonitorUpdate) AddIntervalSeconds(v int) *ChannelMonitorUpdate
return _u
}
// SetJitterSeconds sets the "jitter_seconds" field.
func (_u *ChannelMonitorUpdate) SetJitterSeconds(v int) *ChannelMonitorUpdate {
_u.mutation.ResetJitterSeconds()
_u.mutation.SetJitterSeconds(v)
return _u
}
// SetNillableJitterSeconds sets the "jitter_seconds" field if the given value is not nil.
func (_u *ChannelMonitorUpdate) SetNillableJitterSeconds(v *int) *ChannelMonitorUpdate {
if v != nil {
_u.SetJitterSeconds(*v)
}
return _u
}
// AddJitterSeconds adds value to the "jitter_seconds" field.
func (_u *ChannelMonitorUpdate) AddJitterSeconds(v int) *ChannelMonitorUpdate {
_u.mutation.AddJitterSeconds(v)
return _u
}
// SetLastCheckedAt sets the "last_checked_at" field.
func (_u *ChannelMonitorUpdate) SetLastCheckedAt(v time.Time) *ChannelMonitorUpdate {
_u.mutation.SetLastCheckedAt(v)
@@ -483,11 +462,6 @@ func (_u *ChannelMonitorUpdate) check() error {
return &ValidationError{Name: "interval_seconds", err: fmt.Errorf(`ent: validator failed for field "ChannelMonitor.interval_seconds": %w`, err)}
}
}
if v, ok := _u.mutation.JitterSeconds(); ok {
if err := channelmonitor.JitterSecondsValidator(v); err != nil {
return &ValidationError{Name: "jitter_seconds", err: fmt.Errorf(`ent: validator failed for field "ChannelMonitor.jitter_seconds": %w`, err)}
}
}
if v, ok := _u.mutation.BodyOverrideMode(); ok {
if err := channelmonitor.BodyOverrideModeValidator(v); err != nil {
return &ValidationError{Name: "body_override_mode", err: fmt.Errorf(`ent: validator failed for field "ChannelMonitor.body_override_mode": %w`, err)}
@@ -552,12 +526,6 @@ func (_u *ChannelMonitorUpdate) sqlSave(ctx context.Context) (_node int, err err
if value, ok := _u.mutation.AddedIntervalSeconds(); ok {
_spec.AddField(channelmonitor.FieldIntervalSeconds, field.TypeInt, value)
}
if value, ok := _u.mutation.JitterSeconds(); ok {
_spec.SetField(channelmonitor.FieldJitterSeconds, field.TypeInt, value)
}
if value, ok := _u.mutation.AddedJitterSeconds(); ok {
_spec.AddField(channelmonitor.FieldJitterSeconds, field.TypeInt, value)
}
if value, ok := _u.mutation.LastCheckedAt(); ok {
_spec.SetField(channelmonitor.FieldLastCheckedAt, field.TypeTime, value)
}
@@ -878,27 +846,6 @@ func (_u *ChannelMonitorUpdateOne) AddIntervalSeconds(v int) *ChannelMonitorUpda
return _u
}
// SetJitterSeconds sets the "jitter_seconds" field.
func (_u *ChannelMonitorUpdateOne) SetJitterSeconds(v int) *ChannelMonitorUpdateOne {
_u.mutation.ResetJitterSeconds()
_u.mutation.SetJitterSeconds(v)
return _u
}
// SetNillableJitterSeconds sets the "jitter_seconds" field if the given value is not nil.
func (_u *ChannelMonitorUpdateOne) SetNillableJitterSeconds(v *int) *ChannelMonitorUpdateOne {
if v != nil {
_u.SetJitterSeconds(*v)
}
return _u
}
// AddJitterSeconds adds value to the "jitter_seconds" field.
func (_u *ChannelMonitorUpdateOne) AddJitterSeconds(v int) *ChannelMonitorUpdateOne {
_u.mutation.AddJitterSeconds(v)
return _u
}
// SetLastCheckedAt sets the "last_checked_at" field.
func (_u *ChannelMonitorUpdateOne) SetLastCheckedAt(v time.Time) *ChannelMonitorUpdateOne {
_u.mutation.SetLastCheckedAt(v)
@@ -1185,11 +1132,6 @@ func (_u *ChannelMonitorUpdateOne) check() error {
return &ValidationError{Name: "interval_seconds", err: fmt.Errorf(`ent: validator failed for field "ChannelMonitor.interval_seconds": %w`, err)}
}
}
if v, ok := _u.mutation.JitterSeconds(); ok {
if err := channelmonitor.JitterSecondsValidator(v); err != nil {
return &ValidationError{Name: "jitter_seconds", err: fmt.Errorf(`ent: validator failed for field "ChannelMonitor.jitter_seconds": %w`, err)}
}
}
if v, ok := _u.mutation.BodyOverrideMode(); ok {
if err := channelmonitor.BodyOverrideModeValidator(v); err != nil {
return &ValidationError{Name: "body_override_mode", err: fmt.Errorf(`ent: validator failed for field "ChannelMonitor.body_override_mode": %w`, err)}
@@ -1271,12 +1213,6 @@ func (_u *ChannelMonitorUpdateOne) sqlSave(ctx context.Context) (_node *ChannelM
if value, ok := _u.mutation.AddedIntervalSeconds(); ok {
_spec.AddField(channelmonitor.FieldIntervalSeconds, field.TypeInt, value)
}
if value, ok := _u.mutation.JitterSeconds(); ok {
_spec.SetField(channelmonitor.FieldJitterSeconds, field.TypeInt, value)
}
if value, ok := _u.mutation.AddedJitterSeconds(); ok {
_spec.AddField(channelmonitor.FieldJitterSeconds, field.TypeInt, value)
}
if value, ok := _u.mutation.LastCheckedAt(); ok {
_spec.SetField(channelmonitor.FieldLastCheckedAt, field.TypeTime, value)
}
+3 -4
View File
@@ -437,7 +437,6 @@ var (
{Name: "group_name", Type: field.TypeString, Nullable: true, Size: 100, Default: ""},
{Name: "enabled", Type: field.TypeBool, Default: true},
{Name: "interval_seconds", Type: field.TypeInt},
{Name: "jitter_seconds", Type: field.TypeInt, Default: 0},
{Name: "last_checked_at", Type: field.TypeTime, Nullable: true},
{Name: "created_by", Type: field.TypeInt64},
{Name: "extra_headers", Type: field.TypeJSON},
@@ -453,7 +452,7 @@ var (
ForeignKeys: []*schema.ForeignKey{
{
Symbol: "channel_monitors_channel_monitor_request_templates_request_template",
Columns: []*schema.Column{ChannelMonitorsColumns[19]},
Columns: []*schema.Column{ChannelMonitorsColumns[18]},
RefColumns: []*schema.Column{ChannelMonitorRequestTemplatesColumns[0]},
OnDelete: schema.SetNull,
},
@@ -462,7 +461,7 @@ var (
{
Name: "channelmonitor_enabled_last_checked_at",
Unique: false,
Columns: []*schema.Column{ChannelMonitorsColumns[11], ChannelMonitorsColumns[14]},
Columns: []*schema.Column{ChannelMonitorsColumns[11], ChannelMonitorsColumns[13]},
},
{
Name: "channelmonitor_provider",
@@ -482,7 +481,7 @@ var (
{
Name: "channelmonitor_template_id",
Unique: false,
Columns: []*schema.Column{ChannelMonitorsColumns[19]},
Columns: []*schema.Column{ChannelMonitorsColumns[18]},
},
},
}
+1 -88
View File
@@ -8871,8 +8871,6 @@ type ChannelMonitorMutation struct {
enabled *bool
interval_seconds *int
addinterval_seconds *int
jitter_seconds *int
addjitter_seconds *int
last_checked_at *time.Time
created_by *int64
addcreated_by *int64
@@ -9471,62 +9469,6 @@ func (m *ChannelMonitorMutation) ResetIntervalSeconds() {
m.addinterval_seconds = nil
}
// SetJitterSeconds sets the "jitter_seconds" field.
func (m *ChannelMonitorMutation) SetJitterSeconds(i int) {
m.jitter_seconds = &i
m.addjitter_seconds = nil
}
// JitterSeconds returns the value of the "jitter_seconds" field in the mutation.
func (m *ChannelMonitorMutation) JitterSeconds() (r int, exists bool) {
v := m.jitter_seconds
if v == nil {
return
}
return *v, true
}
// OldJitterSeconds returns the old "jitter_seconds" field's value of the ChannelMonitor entity.
// If the ChannelMonitor 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 *ChannelMonitorMutation) OldJitterSeconds(ctx context.Context) (v int, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldJitterSeconds is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldJitterSeconds requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldJitterSeconds: %w", err)
}
return oldValue.JitterSeconds, nil
}
// AddJitterSeconds adds i to the "jitter_seconds" field.
func (m *ChannelMonitorMutation) AddJitterSeconds(i int) {
if m.addjitter_seconds != nil {
*m.addjitter_seconds += i
} else {
m.addjitter_seconds = &i
}
}
// AddedJitterSeconds returns the value that was added to the "jitter_seconds" field in this mutation.
func (m *ChannelMonitorMutation) AddedJitterSeconds() (r int, exists bool) {
v := m.addjitter_seconds
if v == nil {
return
}
return *v, true
}
// ResetJitterSeconds resets all changes to the "jitter_seconds" field.
func (m *ChannelMonitorMutation) ResetJitterSeconds() {
m.jitter_seconds = nil
m.addjitter_seconds = nil
}
// SetLastCheckedAt sets the "last_checked_at" field.
func (m *ChannelMonitorMutation) SetLastCheckedAt(t time.Time) {
m.last_checked_at = &t
@@ -9984,7 +9926,7 @@ func (m *ChannelMonitorMutation) Type() string {
// order to get all numeric fields that were incremented/decremented, call
// AddedFields().
func (m *ChannelMonitorMutation) Fields() []string {
fields := make([]string, 0, 19)
fields := make([]string, 0, 18)
if m.created_at != nil {
fields = append(fields, channelmonitor.FieldCreatedAt)
}
@@ -10021,9 +9963,6 @@ func (m *ChannelMonitorMutation) Fields() []string {
if m.interval_seconds != nil {
fields = append(fields, channelmonitor.FieldIntervalSeconds)
}
if m.jitter_seconds != nil {
fields = append(fields, channelmonitor.FieldJitterSeconds)
}
if m.last_checked_at != nil {
fields = append(fields, channelmonitor.FieldLastCheckedAt)
}
@@ -10074,8 +10013,6 @@ func (m *ChannelMonitorMutation) Field(name string) (ent.Value, bool) {
return m.Enabled()
case channelmonitor.FieldIntervalSeconds:
return m.IntervalSeconds()
case channelmonitor.FieldJitterSeconds:
return m.JitterSeconds()
case channelmonitor.FieldLastCheckedAt:
return m.LastCheckedAt()
case channelmonitor.FieldCreatedBy:
@@ -10121,8 +10058,6 @@ func (m *ChannelMonitorMutation) OldField(ctx context.Context, name string) (ent
return m.OldEnabled(ctx)
case channelmonitor.FieldIntervalSeconds:
return m.OldIntervalSeconds(ctx)
case channelmonitor.FieldJitterSeconds:
return m.OldJitterSeconds(ctx)
case channelmonitor.FieldLastCheckedAt:
return m.OldLastCheckedAt(ctx)
case channelmonitor.FieldCreatedBy:
@@ -10228,13 +10163,6 @@ func (m *ChannelMonitorMutation) SetField(name string, value ent.Value) error {
}
m.SetIntervalSeconds(v)
return nil
case channelmonitor.FieldJitterSeconds:
v, ok := value.(int)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetJitterSeconds(v)
return nil
case channelmonitor.FieldLastCheckedAt:
v, ok := value.(time.Time)
if !ok {
@@ -10288,9 +10216,6 @@ func (m *ChannelMonitorMutation) AddedFields() []string {
if m.addinterval_seconds != nil {
fields = append(fields, channelmonitor.FieldIntervalSeconds)
}
if m.addjitter_seconds != nil {
fields = append(fields, channelmonitor.FieldJitterSeconds)
}
if m.addcreated_by != nil {
fields = append(fields, channelmonitor.FieldCreatedBy)
}
@@ -10304,8 +10229,6 @@ func (m *ChannelMonitorMutation) AddedField(name string) (ent.Value, bool) {
switch name {
case channelmonitor.FieldIntervalSeconds:
return m.AddedIntervalSeconds()
case channelmonitor.FieldJitterSeconds:
return m.AddedJitterSeconds()
case channelmonitor.FieldCreatedBy:
return m.AddedCreatedBy()
}
@@ -10324,13 +10247,6 @@ func (m *ChannelMonitorMutation) AddField(name string, value ent.Value) error {
}
m.AddIntervalSeconds(v)
return nil
case channelmonitor.FieldJitterSeconds:
v, ok := value.(int)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.AddJitterSeconds(v)
return nil
case channelmonitor.FieldCreatedBy:
v, ok := value.(int64)
if !ok {
@@ -10428,9 +10344,6 @@ func (m *ChannelMonitorMutation) ResetField(name string) error {
case channelmonitor.FieldIntervalSeconds:
m.ResetIntervalSeconds()
return nil
case channelmonitor.FieldJitterSeconds:
m.ResetJitterSeconds()
return nil
case channelmonitor.FieldLastCheckedAt:
m.ResetLastCheckedAt()
return nil
+2 -8
View File
@@ -529,18 +529,12 @@ func init() {
channelmonitorDescIntervalSeconds := channelmonitorFields[9].Descriptor()
// channelmonitor.IntervalSecondsValidator is a validator for the "interval_seconds" field. It is called by the builders before save.
channelmonitor.IntervalSecondsValidator = channelmonitorDescIntervalSeconds.Validators[0].(func(int) error)
// channelmonitorDescJitterSeconds is the schema descriptor for jitter_seconds field.
channelmonitorDescJitterSeconds := channelmonitorFields[10].Descriptor()
// channelmonitor.DefaultJitterSeconds holds the default value on creation for the jitter_seconds field.
channelmonitor.DefaultJitterSeconds = channelmonitorDescJitterSeconds.Default.(int)
// channelmonitor.JitterSecondsValidator is a validator for the "jitter_seconds" field. It is called by the builders before save.
channelmonitor.JitterSecondsValidator = channelmonitorDescJitterSeconds.Validators[0].(func(int) error)
// channelmonitorDescExtraHeaders is the schema descriptor for extra_headers field.
channelmonitorDescExtraHeaders := channelmonitorFields[14].Descriptor()
channelmonitorDescExtraHeaders := channelmonitorFields[13].Descriptor()
// channelmonitor.DefaultExtraHeaders holds the default value on creation for the extra_headers field.
channelmonitor.DefaultExtraHeaders = channelmonitorDescExtraHeaders.Default.(map[string]string)
// channelmonitorDescBodyOverrideMode is the schema descriptor for body_override_mode field.
channelmonitorDescBodyOverrideMode := channelmonitorFields[15].Descriptor()
channelmonitorDescBodyOverrideMode := channelmonitorFields[14].Descriptor()
// channelmonitor.DefaultBodyOverrideMode holds the default value on creation for the body_override_mode field.
channelmonitor.DefaultBodyOverrideMode = channelmonitorDescBodyOverrideMode.Default.(string)
// channelmonitor.BodyOverrideModeValidator is a validator for the "body_override_mode" field. It is called by the builders before save.
-4
View File
@@ -62,10 +62,6 @@ func (ChannelMonitor) Fields() []ent.Field {
Default(true),
field.Int("interval_seconds").
Range(15, 3600),
field.Int("jitter_seconds").
Default(0).
Range(0, 3600).
Comment("每次调度在 interval 基础上 ± [0, jitter] 的均匀随机偏移(秒);0 表示固定间隔。service 层另保证 interval - jitter >= 15"),
field.Time("last_checked_at").
Optional().
Nillable(),
+1 -1
View File
@@ -41,7 +41,7 @@ func (UserPlatformQuota) Fields() []ent.Field {
// 注意:平台列表的单一权威源为 service.AllowedQuotaPlatforms;
// 此处为 ent 构建期约束,需与 service.AllowedQuotaPlatforms 保持同步。
switch s {
case "anthropic", "openai", "gemini", "antigravity", "grok":
case "anthropic", "openai", "gemini", "antigravity":
return nil
default:
return fmt.Errorf("platform %q is not allowed", s)
+1 -1
View File
@@ -119,7 +119,7 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mdelapenya/tlscert v0.2.0 // indirect
github.com/mitchellh/go-wordwrap v1.0.1 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/mitchellh/mapstructure v1.5.0
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.1.0 // indirect
github.com/moby/patternmatcher v0.6.0 // indirect
-2
View File
@@ -164,8 +164,6 @@ github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4=
+41 -18
View File
@@ -93,6 +93,15 @@ type Config struct {
Gemini GeminiConfig `mapstructure:"gemini"`
Update UpdateConfig `mapstructure:"update"`
Idempotency IdempotencyConfig `mapstructure:"idempotency"`
// Modules 是插件模块配置子树(modules:)的原始内容。
// 顶层 key 为模块 ID 字面量(如 "job.hello"),value 为该模块的原始配置
// (含可选 enabled 键),语义解析由 internal/plugin 包负责。
//
// 注意:该字段不走 viper.Unmarshal(mapstructure:"-"),而是在 load() 中通过
// viper.Get 手工提取——Unmarshal 基于 AllSettings 重建嵌套结构,会把含点的
// 模块 ID key 错误拆分为多级嵌套。缺省(未配置)时为空 map,行为与现状完全等价。
Modules map[string]map[string]any `mapstructure:"-" yaml:"-"`
}
type LogConfig struct {
@@ -643,10 +652,6 @@ type ProxyProbeConfig struct {
type BillingConfig struct {
CircuitBreaker CircuitBreakerConfig `mapstructure:"circuit_breaker"`
// MinimumBalanceReserve is the conservative preflight floor for balance billing.
// Requests in balance mode are rejected when the cached balance is below this
// amount, even if it is still positive. Set to 0 to keep the legacy balance > 0 gate.
MinimumBalanceReserve float64 `mapstructure:"minimum_balance_reserve"`
// UserPlatformQuotaCacheTTLSeconds 用户 × 平台 quota 缓存 TTL(秒),默认 86400=1天,覆盖典型 daily 窗口。
// 消费点:
// - billing_cache_service.cacheWriteWorker 异步累加
@@ -961,9 +966,6 @@ type GatewayOpenAIWSSchedulerScoreWeights struct {
Queue float64 `mapstructure:"queue"`
ErrorRate float64 `mapstructure:"error_rate"`
TTFT float64 `mapstructure:"ttft"`
// Reset 倾向「会话窗口最早重置」的账号(use-it-or-lose-it)。
// >0 时,剩余重置时间越短的账号得分越高,从而被优先用尽。默认 0(关闭,不改变原有行为)。
Reset float64 `mapstructure:"reset"`
}
// GatewayOpenAISchedulerConfig OpenAI 高级调度器配置。
@@ -1062,11 +1064,6 @@ type GatewaySchedulingConfig struct {
// 兜底层账户选择策略: "last_used"(按最后使用时间排序,默认) 或 "random"(随机)
FallbackSelectionMode string `mapstructure:"fallback_selection_mode"`
// PreferSoonestReset 开启后,负载感知选择会优先选用「会话窗口最早重置」的账号
// (use-it-or-lose-it:先用尽即将重置的账号,保留重置时间还很久的账号)。
// 默认 false,保持原有「优先级 → 负载率 → LRU」行为不变。
PreferSoonestReset bool `mapstructure:"prefer_soonest_reset"`
// 负载计算
LoadBatchEnabled bool `mapstructure:"load_batch_enabled"`
LoadBatchCacheTTLMS int `mapstructure:"load_batch_cache_ttl_ms"`
@@ -1409,6 +1406,11 @@ func load(allowMissingJWTSecret bool) (*Config, error) {
if err := viper.Unmarshal(&cfg); err != nil {
return nil, fmt.Errorf("unmarshal config error: %w", err)
}
modules, err := normalizeModulesSubtree(viper.Get("modules"))
if err != nil {
return nil, fmt.Errorf("parse modules config error: %w", err)
}
cfg.Modules = modules
if cfg.Gateway.OpenAIScheduler.StickyEscapeTTFTMs == 0 {
cfg.Gateway.OpenAIScheduler.StickyEscapeTTFTMs = 15000
}
@@ -1542,6 +1544,33 @@ func load(allowMissingJWTSecret bool) (*Config, error) {
return &cfg, nil
}
// normalizeModulesSubtree 将 viper 读取的 modules: 原始子树规整为
// map[模块ID]map[配置键]值。raw 为 nil(未配置)时返回空 map(零行为影响);
// 子树或某个模块项不是映射时返回错误。空模块项(如 "job.hello:" 后无内容)
// 规整为空 map,表示"提及但无私有配置"。
func normalizeModulesSubtree(raw any) (map[string]map[string]any, error) {
result := make(map[string]map[string]any)
if raw == nil {
return result, nil
}
top, ok := raw.(map[string]any)
if !ok {
return nil, fmt.Errorf("modules: expected a mapping, got %T", raw)
}
for key, value := range top {
if value == nil {
result[key] = map[string]any{}
continue
}
entry, ok := value.(map[string]any)
if !ok {
return nil, fmt.Errorf("modules.%s: expected a mapping, got %T", key, value)
}
result[key] = entry
}
return result, nil
}
func setDefaults() {
viper.SetDefault("run_mode", RunModeStandard)
@@ -1619,7 +1648,6 @@ func setDefaults() {
viper.SetDefault("billing.circuit_breaker.failure_threshold", 5)
viper.SetDefault("billing.circuit_breaker.reset_timeout_seconds", 30)
viper.SetDefault("billing.circuit_breaker.half_open_requests", 3)
viper.SetDefault("billing.minimum_balance_reserve", 0.000001)
viper.SetDefault("billing.user_platform_quota_cache_ttl_seconds", 86400)
viper.SetDefault("billing.user_platform_quota_sentinel_ttl_seconds", 3600)
@@ -1883,7 +1911,6 @@ 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.openai_ws.scheduler_score_weights.reset", 0.0)
// OpenAI HTTP upstream protocol strategy
viper.SetDefault("gateway.openai_http2.enabled", true)
viper.SetDefault("gateway.openai_http2.allow_proxy_fallback_to_http1", true)
@@ -1920,7 +1947,6 @@ func setDefaults() {
viper.SetDefault("gateway.scheduling.fallback_wait_timeout", 30*time.Second)
viper.SetDefault("gateway.scheduling.fallback_max_waiting", 100)
viper.SetDefault("gateway.scheduling.fallback_selection_mode", "last_used")
viper.SetDefault("gateway.scheduling.prefer_soonest_reset", false)
viper.SetDefault("gateway.scheduling.load_batch_enabled", true)
viper.SetDefault("gateway.scheduling.load_batch_cache_ttl_ms", 200)
viper.SetDefault("gateway.scheduling.snapshot_mget_chunk_size", 128)
@@ -2282,9 +2308,6 @@ func (c *Config) Validate() error {
return fmt.Errorf("billing.circuit_breaker.half_open_requests must be positive")
}
}
if c.Billing.MinimumBalanceReserve < 0 {
return fmt.Errorf("billing.minimum_balance_reserve must be non-negative")
}
if c.Database.MaxOpenConns <= 0 {
return fmt.Errorf("database.max_open_conns must be positive")
}
-5
View File
@@ -1157,11 +1157,6 @@ func TestValidateConfigErrors(t *testing.T) {
mutate: func(c *Config) { c.Billing.CircuitBreaker.HalfOpenRequests = 0 },
wantErr: "billing.circuit_breaker.half_open_requests",
},
{
name: "billing minimum balance reserve",
mutate: func(c *Config) { c.Billing.MinimumBalanceReserve = -0.01 },
wantErr: "billing.minimum_balance_reserve",
},
{
name: "database max open conns",
mutate: func(c *Config) { c.Database.MaxOpenConns = 0 },
@@ -0,0 +1,89 @@
package config
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
)
// TestLoadModulesSubtreeDefaultEmpty 验证不配置 modules: 子树时,
// cfg.Modules 为空(非 nil)map,解析结果与现状完全等价。
func TestLoadModulesSubtreeDefaultEmpty(t *testing.T) {
resetViperWithJWTSecret(t)
cfg, err := Load()
require.NoError(t, err)
require.NotNil(t, cfg.Modules)
require.Empty(t, cfg.Modules)
}
// TestLoadModulesSubtreeParsing 验证 modules: 子树解析:
// 含点的模块 ID 必须作为完整 key 保留(不被 viper 按 "." 拆分为多级嵌套)。
func TestLoadModulesSubtreeParsing(t *testing.T) {
resetViperWithJWTSecret(t)
tempDir := t.TempDir()
configPath := filepath.Join(tempDir, "config.yaml")
yaml := `
modules:
job.hello:
enabled: true
greeting: "hi"
payment.provider.stripe:
enabled: false
job.bare:
`
require.NoError(t, os.WriteFile(configPath, []byte(yaml), 0o644))
t.Setenv("DATA_DIR", tempDir)
cfg, err := Load()
require.NoError(t, err)
require.Len(t, cfg.Modules, 3)
require.Equal(t, map[string]any{"enabled": true, "greeting": "hi"}, cfg.Modules["job.hello"])
require.Equal(t, map[string]any{"enabled": false}, cfg.Modules["payment.provider.stripe"])
// 空模块项规整为空 map(提及但无私有配置)。
require.NotNil(t, cfg.Modules["job.bare"])
require.Empty(t, cfg.Modules["job.bare"])
}
// TestLoadModulesSubtreeInvalidShape 验证 modules 子树形状非法时报错。
func TestLoadModulesSubtreeInvalidShape(t *testing.T) {
resetViperWithJWTSecret(t)
tempDir := t.TempDir()
configPath := filepath.Join(tempDir, "config.yaml")
require.NoError(t, os.WriteFile(configPath, []byte("modules:\n job.hello: true\n"), 0o644))
t.Setenv("DATA_DIR", tempDir)
_, err := Load()
require.Error(t, err)
require.Contains(t, err.Error(), "modules")
}
// TestNormalizeModulesSubtree 直接覆盖规整函数的边界分支。
func TestNormalizeModulesSubtree(t *testing.T) {
t.Run("nil input", func(t *testing.T) {
out, err := normalizeModulesSubtree(nil)
require.NoError(t, err)
require.NotNil(t, out)
require.Empty(t, out)
})
t.Run("non-map top level", func(t *testing.T) {
_, err := normalizeModulesSubtree([]any{"x"})
require.Error(t, err)
})
t.Run("non-map entry", func(t *testing.T) {
_, err := normalizeModulesSubtree(map[string]any{"job.hello": 42})
require.Error(t, err)
require.Contains(t, err.Error(), "job.hello")
})
t.Run("nil entry becomes empty map", func(t *testing.T) {
out, err := normalizeModulesSubtree(map[string]any{"job.hello": nil})
require.NoError(t, err)
require.NotNil(t, out["job.hello"])
require.Empty(t, out["job.hello"])
})
}
-1
View File
@@ -22,7 +22,6 @@ const (
PlatformOpenAI = "openai"
PlatformGemini = "gemini"
PlatformAntigravity = "antigravity"
PlatformGrok = "grok"
)
// Account type constants
+82
View File
@@ -0,0 +1,82 @@
package gatewayhook
import (
"context"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"go.uber.org/zap"
)
// Chain 按固定顺序执行 pre-flight 钩子。
//
// 执行语义(SEAM-DESIGN 裁决 H 链层三件必补):
// 1. 每钩子 recover():panic 记 error 日志后按 fail-open 继续下一钩子;
// 2. 钩子返回 error 默认 fail-open:记日志后继续下一钩子(与内容审核现状一致);
// 3. 首个 Blocked Decision 即短路返回;非 Blocked 的 Decision 视为放行并继续。
//
// Run 永不向调用方返回 error:返回 nil 即放行。
type Chain struct {
hooks []PreFlightHook
log *zap.Logger
}
// NewChain 构造钩子链。log 传 nil 时延迟回退到进程全局 logger;
// nil 钩子被忽略。
func NewChain(log *zap.Logger, hooks ...PreFlightHook) *Chain {
filtered := make([]PreFlightHook, 0, len(hooks))
for _, hook := range hooks {
if hook != nil {
filtered = append(filtered, hook)
}
}
return &Chain{hooks: filtered, log: log}
}
// IsEmpty 报告链上是否没有任何钩子(nil 链视为空链)。
// 调用侧可据此零开销跳过钩子请求的构造。
func (c *Chain) IsEmpty() bool {
return c == nil || len(c.hooks) == 0
}
// Run 按序执行钩子,返回首个 Blocked Decision;全部放行(或链为空)返回 nil。
func (c *Chain) Run(ctx context.Context, req *Request) *Decision {
if c.IsEmpty() {
return nil
}
for _, hook := range c.hooks {
if decision := c.runHook(ctx, hook, req); decision != nil && decision.Blocked {
return decision
}
}
return nil
}
// runHook 执行单个钩子并隔离其 panic / error(均按 fail-open 降级为放行)。
func (c *Chain) runHook(ctx context.Context, hook PreFlightHook, req *Request) (decision *Decision) {
hookID := ""
defer func() {
if r := recover(); r != nil {
c.logger().Error("gatewayhook.hook_panic",
zap.String("hook_id", hookID),
zap.Any("panic_value", r),
zap.Stack("stack"))
decision = nil // fail-open:panic 钩子视为放行
}
}()
hookID = hook.HookID()
d, err := hook.CheckPreFlight(ctx, req)
if err != nil {
c.logger().Warn("gatewayhook.hook_error",
zap.String("hook_id", hookID),
zap.Error(err))
return nil // fail-open:钩子自身故障视为放行
}
return d
}
func (c *Chain) logger() *zap.Logger {
if c != nil && c.log != nil {
return c.log
}
return logger.L()
}
+130
View File
@@ -0,0 +1,130 @@
package gatewayhook
import (
"context"
"errors"
"testing"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"go.uber.org/zap/zaptest/observer"
)
// stubHook 是可编程的测试钩子,记录自己是否被执行。
type stubHook struct {
id string
decision *Decision
err error
panicVal any
calls *[]string
}
func (h *stubHook) HookID() string { return h.id }
func (h *stubHook) CheckPreFlight(ctx context.Context, req *Request) (*Decision, error) {
if h.calls != nil {
*h.calls = append(*h.calls, h.id)
}
if h.panicVal != nil {
panic(h.panicVal)
}
return h.decision, h.err
}
func TestChainRun_ExecutesHooksInOrder(t *testing.T) {
var calls []string
chain := NewChain(zap.NewNop(),
&stubHook{id: "first", calls: &calls},
&stubHook{id: "second", calls: &calls},
&stubHook{id: "third", calls: &calls},
)
decision := chain.Run(context.Background(), &Request{})
require.Nil(t, decision, "全部放行时 Run 必须返回 nil")
require.Equal(t, []string{"first", "second", "third"}, calls, "钩子必须按注册顺序执行")
}
func TestChainRun_FirstBlockedShortCircuits(t *testing.T) {
var calls []string
blocked := &Decision{Blocked: true, StatusCode: 403, ErrorType: "content_policy_violation", Message: "blocked"}
chain := NewChain(zap.NewNop(),
&stubHook{id: "pass", calls: &calls},
&stubHook{id: "block", decision: blocked, calls: &calls},
&stubHook{id: "never", calls: &calls},
)
decision := chain.Run(context.Background(), &Request{})
require.Same(t, blocked, decision, "必须原样返回首个 Blocked Decision")
require.Equal(t, []string{"pass", "block"}, calls, "Blocked 之后的钩子不得执行")
}
func TestChainRun_NonBlockedDecisionContinues(t *testing.T) {
var calls []string
chain := NewChain(zap.NewNop(),
&stubHook{id: "flag-only", decision: &Decision{Blocked: false, Message: "flagged"}, calls: &calls},
&stubHook{id: "after", calls: &calls},
)
decision := chain.Run(context.Background(), &Request{})
require.Nil(t, decision, "非 Blocked 的 Decision 视为放行")
require.Equal(t, []string{"flag-only", "after"}, calls)
}
func TestChainRun_PanicIsolatedAndFailOpen(t *testing.T) {
core, logs := observer.New(zap.ErrorLevel)
var calls []string
blocked := &Decision{Blocked: true, StatusCode: 403, Message: "blocked"}
chain := NewChain(zap.New(core),
&stubHook{id: "boomer", panicVal: "boom", calls: &calls},
&stubHook{id: "blocker", decision: blocked, calls: &calls},
)
decision := chain.Run(context.Background(), &Request{})
require.Same(t, blocked, decision, "panic 钩子 fail-open 后链必须继续执行后续钩子")
require.Equal(t, []string{"boomer", "blocker"}, calls)
entries := logs.FilterMessage("gatewayhook.hook_panic").All()
require.Len(t, entries, 1, "panic 必须记一条 error 日志")
fields := entries[0].ContextMap()
require.Equal(t, "boomer", fields["hook_id"])
require.Equal(t, "boom", fields["panic_value"])
// 仅 panic 钩子的链:整体放行。
onlyPanic := NewChain(zap.New(core), &stubHook{id: "boomer2", panicVal: errors.New("kaboom")})
require.Nil(t, onlyPanic.Run(context.Background(), &Request{}))
}
func TestChainRun_HookErrorFailOpen(t *testing.T) {
core, logs := observer.New(zap.WarnLevel)
var calls []string
chain := NewChain(zap.New(core),
// 即使 error 钩子同时返回了 Blocked Decision,error 优先按 fail-open 丢弃。
&stubHook{id: "broken", decision: &Decision{Blocked: true}, err: errors.New("hook exploded"), calls: &calls},
&stubHook{id: "after", calls: &calls},
)
decision := chain.Run(context.Background(), &Request{})
require.Nil(t, decision, "钩子 error 必须 fail-open 放行")
require.Equal(t, []string{"broken", "after"}, calls, "error 钩子之后链必须继续")
entries := logs.FilterMessage("gatewayhook.hook_error").All()
require.Len(t, entries, 1)
require.Equal(t, "broken", entries[0].ContextMap()["hook_id"])
}
func TestChainRun_EmptyChain(t *testing.T) {
require.Nil(t, NewChain(zap.NewNop()).Run(context.Background(), &Request{}))
require.True(t, NewChain(zap.NewNop()).IsEmpty())
var nilChain *Chain
require.True(t, nilChain.IsEmpty(), "nil 链视为空链")
require.Nil(t, nilChain.Run(context.Background(), &Request{}), "nil 链 Run 必须安全放行")
// nil 钩子在构造时被过滤。
require.True(t, NewChain(zap.NewNop(), nil, nil).IsEmpty())
}
+96
View File
@@ -0,0 +1,96 @@
// Package gatewayhook 定义网关 pre-flight 钩子链(Phase-2 SEAM-DESIGN 裁决 H)。
//
// 设计要点:
// - 独立包:不依赖 internal/handler 与 internal/server/middleware(CallerInfo
// 即为与 middleware.AuthSubject 解耦的最小身份视图);
// - 链只产 Decision,错误格式化留在各调用点(5 种 HTTP 拦截格式差异由
// 特征化测试锁定,与协议常量无映射关系);
// - 核心钩子(如内容审核)由 Wire 装配;gateway.hook.* 命名空间的模块收集
// 机制留待 Phase-3 与平台 Provider 需求合并设计,本包不做 Runtime 收集。
package gatewayhook
import (
"context"
"net/http"
"github.com/Wei-Shaw/sub2api/internal/service"
)
// CallerInfo 是调用方身份的最小视图(与 middleware.AuthSubject 解耦)。
type CallerInfo struct {
// UserID 为认证后的用户 ID。
UserID int64
// GroupID 为 API Key 所属分组 ID(可能为 nil)。
GroupID *int64
// KeyID 为平台 API Key ID。
KeyID int64
}
// RequestHeaders 是入站请求头的只读访问器(不暴露 gin.Context / http.Header 本体)。
type RequestHeaders struct {
header http.Header
}
// NewRequestHeaders 包装 http.Header 为只读访问器;传 nil 得到空访问器。
func NewRequestHeaders(header http.Header) RequestHeaders {
return RequestHeaders{header: header}
}
// Get 返回指定 key 的首个 header 值(语义同 http.Header.Get)。
func (r RequestHeaders) Get(key string) string {
if r.header == nil {
return ""
}
return r.header.Get(key)
}
// Values 返回指定 key 的全部 header 值(语义同 http.Header.Values)。
func (r RequestHeaders) Values(key string) []string {
if r.header == nil {
return nil
}
return r.header.Values(key)
}
// Request 是 pre-flight 钩子的协议无关只读入参。
type Request struct {
// Protocol 复用 service.ContentModerationProtocol* 现有常量值
// (如 "anthropic_messages" / "openai_chat_completions"),禁止新造枚举。
Protocol string
// Model 为请求的模型名(未做 TrimSpace,由钩子按需规范化)。
Model string
// Body 为送检请求体。各调用点保留自己的入参表达式
// (如 images 调用点传 parsed.ModerationBody()),不强行统一。
Body []byte
// Caller 为调用方最小身份视图。
Caller CallerInfo
// APIKey 为完整平台 API Key 上下文(audit 类钩子需要;可能为 nil)。
APIKey *service.APIKey
// Headers 为入站请求头的只读访问器。
Headers RequestHeaders
// Path 为入站端点路径(调用侧按 inbound endpoint 归一化后传入)。
Path string
}
// Decision 是钩子的协议无关决策;nil 表示放行。
// ErrorType 对 gemini 调用点无效(googleError 格式化器不消费该字段)。
type Decision struct {
Blocked bool
StatusCode int
ErrorType string
Message string
}
// PreFlightHook 是网关转发前的拦截钩子。
//
// 契约(链层强制,见 chain.go):
// - 返回 (nil, nil) 表示放行;
// - 返回 Blocked Decision 表示拦截(链短路返回);
// - 返回 error 表示钩子自身故障,链层按 fail-open 处理(记日志后继续下一钩子);
// - 钩子内 panic 同样被链层隔离并按 fail-open 降级。
type PreFlightHook interface {
// HookID 返回钩子的稳定标识(用于日志与排障)。
HookID() string
// CheckPreFlight 对请求执行转发前检查。
CheckPreFlight(ctx context.Context, req *Request) (*Decision, error)
}
@@ -0,0 +1,91 @@
package gatewayplatform
// 本文件的三个 adapter 仅包装现有 service 的 Forward 入口(实现零移动)。
//
// 错误透传铁律(SEAM-DESIGN 裁决 / T4 特征化锁定):底层返回的 error 一律
// 原样 return,禁止任何包裹——handler 对 *service.BetaBlockedError /
// *service.PromptTooLongError / *service.UpstreamFailoverError 的 errors.As
// 断言链依赖错误原值。
import (
"context"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
// geminiForwardAction 是 /v1/messages gemini 分支传给 ForwardGemini 的 action
// 常量(SEAM-DESIGN 裁决:硬编码 "generateContent" + 测试锁定;该参数仅决定
// 客户端响应形态合法性,上游恒为 streamGenerateContent)。
const geminiForwardAction = "generateContent"
// AnthropicProvider 包装 GatewayService.Forward(Claude 协议主路径;
// :794 条件不命中侧,含 antigravity 平台的 APIKey 账号)。
type AnthropicProvider struct {
gateway *service.GatewayService
}
// NewAnthropicProvider 构造 anthropic 平台 Provider。
func NewAnthropicProvider(gateway *service.GatewayService) *AnthropicProvider {
return &AnthropicProvider{gateway: gateway}
}
// Platform 返回 service.PlatformAnthropic。
func (p *AnthropicProvider) Platform() string { return service.PlatformAnthropic }
// Forward 转发到 GatewayService.Forward;error 原样透传。
func (p *AnthropicProvider) Forward(ctx context.Context, c *gin.Context, account *service.Account, req *ForwardRequest) (*service.ForwardResult, error) {
return p.gateway.Forward(ctx, c, account, req.Parsed)
}
// AntigravityProvider 包装 AntigravityGatewayService.Forward(Claude 协议 →
// antigravity 上游;:794 条件命中侧:Platform == Antigravity && Type != APIKey)。
type AntigravityProvider struct {
antigravity *service.AntigravityGatewayService
}
// NewAntigravityProvider 构造 antigravity 平台 Provider。
func NewAntigravityProvider(antigravity *service.AntigravityGatewayService) *AntigravityProvider {
return &AntigravityProvider{antigravity: antigravity}
}
// Platform 返回 service.PlatformAntigravity。
func (p *AntigravityProvider) Platform() string { return service.PlatformAntigravity }
// Forward 转发到 AntigravityGatewayService.Forward;error 原样透传。
func (p *AntigravityProvider) Forward(ctx context.Context, c *gin.Context, account *service.Account, req *ForwardRequest) (*service.ForwardResult, error) {
return p.antigravity.Forward(ctx, c, account, req.Body, req.IsStickySession)
}
// GeminiProvider 包装 AntigravityGatewayService.ForwardGemini(gemini 平台
// 分组下的 antigravity 账号,:444 命中侧;gemini 平台的非 antigravity 账号
// 走 GeminiMessagesCompatService,映射逻辑保留在调用点)。
type GeminiProvider struct {
antigravity *service.AntigravityGatewayService
}
// NewGeminiProvider 构造 gemini 平台 Provider。
func NewGeminiProvider(antigravity *service.AntigravityGatewayService) *GeminiProvider {
return &GeminiProvider{antigravity: antigravity}
}
// Platform 返回 service.PlatformGemini。
func (p *GeminiProvider) Platform() string { return service.PlatformGemini }
// Forward 转发到 AntigravityGatewayService.ForwardGemini;error 原样透传。
// model/stream 取自 req.Parsed(与原调用点 reqModel/reqStream 同源),
// 粘性会话维度经 WithForwardGeminiSession 透传。
func (p *GeminiProvider) Forward(ctx context.Context, c *gin.Context, account *service.Account, req *ForwardRequest) (*service.ForwardResult, error) {
return p.antigravity.ForwardGemini(
ctx,
c,
account,
req.Parsed.Model,
geminiForwardAction,
req.Parsed.Stream,
req.Body,
req.IsStickySession,
service.WithForwardGeminiSession(req.SessionGroupID, req.SessionKey),
)
}
@@ -0,0 +1,52 @@
// Package gatewayplatform 定义 /v1/messages 平台 Forward 分发的 Provider 接缝
// (Phase-3 SEAM-DESIGN v2 裁决记录)。
//
// 设计要点:
// - Provider v1 接口仅 Platform/Forward 两方法(DefaultModels / FallbackModel /
// InvalidateToken 等经评审裁决不进接口);
// - adapter 实现零移动:仅包装现有 service 的 Forward 入口;底层 error 一律
// 原样透传(handler 对 BetaBlockedError / PromptTooLongError 等的
// errors.As 断言链依赖错误原值,禁止任何包裹);
// - Registry 构造期注册、运行期并发只读;平台 → Provider 的映射逻辑
// (如 :794 的 Type != APIKey 条件)保留在调用点,registry 只做查找;
// - OpenAI 走独立 OpenAIGatewayHandler 与独立计费管线,不进本注册表;
// 禁止统一跨平台返回类型(OpenAIForwardResult 载荷计费字段会丢失)。
package gatewayplatform
import (
"context"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
// ForwardRequest 是平台 Forward 的统一入参面。字段集为两处分发点现有入参的
// 并集 {Parsed, Body, IsStickySession, SessionGroupID, SessionKey},不为未接入
// 平台虚构维度;各调用点保留自己的入参表达式,按各 Provider 的消费面填充。
type ForwardRequest struct {
// Parsed 为已解析的网关请求(anthropic / gemini Provider 消费;
// gemini Provider 读取 Parsed.Model 与 Parsed.Stream)。
Parsed *service.ParsedRequest
// Body 为转发请求体原文(antigravity / gemini Provider 消费)。
Body []byte
// IsStickySession 表示请求已命中粘性会话绑定(antigravity / gemini
// Provider 消费)。
IsStickySession bool
// SessionGroupID 与 SessionKey 为粘性会话维度(仅 gemini Provider 消费,
// 经 WithForwardGeminiSession 透传到模型限流切换时的粘性绑定清除)。
SessionGroupID int64
SessionKey string
}
// Provider 是单一平台的 Forward 入口。
//
// 契约:
// - Platform 返回 service.Platform* 现有常量,作为 Registry 的查找键;
// - Forward 将请求转发到对应平台 service,error 必须原样透传(禁止包裹)。
type Provider interface {
// Platform 返回该 Provider 服务的平台标识(service.Platform* 常量)。
Platform() string
// Forward 执行平台转发。
Forward(ctx context.Context, c *gin.Context, account *service.Account, req *ForwardRequest) (*service.ForwardResult, error)
}
@@ -0,0 +1,38 @@
package gatewayplatform
import "fmt"
// Registry 是 platform → Provider 的只读查找表:构造期一次性注册,
// 构造完成后并发只读(无锁)。
type Registry struct {
providers map[string]Provider
}
// NewRegistry 构造注册表。providers 中出现 nil Provider、空 Platform() 或
// 重复平台时 panic——装配错误必须在启动期(Wire 装配)暴露,而非请求期。
func NewRegistry(providers ...Provider) *Registry {
m := make(map[string]Provider, len(providers))
for _, p := range providers {
if p == nil {
panic("gatewayplatform: nil provider")
}
platform := p.Platform()
if platform == "" {
panic("gatewayplatform: provider with empty platform")
}
if _, dup := m[platform]; dup {
panic(fmt.Sprintf("gatewayplatform: duplicate provider for platform %q", platform))
}
m[platform] = p
}
return &Registry{providers: m}
}
// Get 返回平台对应的 Provider;未注册(或 nil Registry)返回 nil。
// 调用点须保证所查平台已在装配期注册(Wire 注册全集,miss 属编程错误)。
func (r *Registry) Get(platform string) Provider {
if r == nil {
return nil
}
return r.providers[platform]
}
@@ -0,0 +1,112 @@
package gatewayplatform
import (
"context"
"sync"
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
// stubProvider 是注册表测试用的最小 Provider 实现。
type stubProvider struct {
platform string
}
func (s *stubProvider) Platform() string { return s.platform }
func (s *stubProvider) Forward(_ context.Context, _ *gin.Context, _ *service.Account, _ *ForwardRequest) (*service.ForwardResult, error) {
return nil, nil
}
func TestRegistry_GetReturnsRegisteredProvider(t *testing.T) {
anthropic := &stubProvider{platform: service.PlatformAnthropic}
antigravity := &stubProvider{platform: service.PlatformAntigravity}
gemini := &stubProvider{platform: service.PlatformGemini}
registry := NewRegistry(anthropic, antigravity, gemini)
require.Same(t, Provider(anthropic), registry.Get(service.PlatformAnthropic))
require.Same(t, Provider(antigravity), registry.Get(service.PlatformAntigravity))
require.Same(t, Provider(gemini), registry.Get(service.PlatformGemini))
}
func TestRegistry_GetUnregisteredPlatformReturnsNil(t *testing.T) {
registry := NewRegistry(&stubProvider{platform: service.PlatformAnthropic})
require.Nil(t, registry.Get(service.PlatformOpenAI), "未注册平台必须返回 nil")
require.Nil(t, registry.Get(""), "空平台必须返回 nil")
var nilRegistry *Registry
require.Nil(t, nilRegistry.Get(service.PlatformAnthropic), "nil Registry 必须返回 nil 而非 panic")
}
func TestNewRegistry_DuplicatePlatformPanics(t *testing.T) {
require.PanicsWithValue(t,
`gatewayplatform: duplicate provider for platform "anthropic"`,
func() {
NewRegistry(
&stubProvider{platform: service.PlatformAnthropic},
&stubProvider{platform: service.PlatformAnthropic},
)
})
}
func TestNewRegistry_NilProviderPanics(t *testing.T) {
require.PanicsWithValue(t, "gatewayplatform: nil provider", func() {
NewRegistry(&stubProvider{platform: service.PlatformAnthropic}, nil)
})
}
func TestNewRegistry_EmptyPlatformPanics(t *testing.T) {
require.PanicsWithValue(t, "gatewayplatform: provider with empty platform", func() {
NewRegistry(&stubProvider{platform: ""})
})
}
// TestRegistry_ConcurrentGet 验证构造完成后的注册表可被并发只读访问
// (配合 -race 检测数据竞争)。
func TestRegistry_ConcurrentGet(t *testing.T) {
registry := NewRegistry(
&stubProvider{platform: service.PlatformAnthropic},
&stubProvider{platform: service.PlatformAntigravity},
&stubProvider{platform: service.PlatformGemini},
)
platforms := []string{
service.PlatformAnthropic,
service.PlatformAntigravity,
service.PlatformGemini,
service.PlatformOpenAI, // miss 路径同样并发安全
}
var wg sync.WaitGroup
for i := 0; i < 32; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
for j := 0; j < 200; j++ {
platform := platforms[(i+j)%len(platforms)]
got := registry.Get(platform)
if platform == service.PlatformOpenAI {
require.Nil(t, got)
} else {
require.NotNil(t, got)
require.Equal(t, platform, got.Platform())
}
}
}(i)
}
wg.Wait()
}
// TestAdapters_PlatformIdentity 锁定三个 adapter 的注册键 = service.Platform* 常量
// (Registry 查找键与 :444/:794 调用点的查找表达式一致)。
func TestAdapters_PlatformIdentity(t *testing.T) {
require.Equal(t, service.PlatformAnthropic, NewAnthropicProvider(nil).Platform())
require.Equal(t, service.PlatformAntigravity, NewAntigravityProvider(nil).Platform())
require.Equal(t, service.PlatformGemini, NewGeminiProvider(nil).Platform())
}
@@ -774,20 +774,16 @@ func sanitizeCodexImportCredentialExtras(input map[string]any) map[string]any {
return nil
}
protected := map[string]struct{}{
"access_token": {},
"refresh_token": {},
"id_token": {},
"expires_at": {},
"email": {},
"chatgpt_account_id": {},
"chatgpt_user_id": {},
"organization_id": {},
"plan_type": {},
"client_id": {},
"auth_mode": {},
"openai_auth_mode": {},
"token_type": {},
"chatgpt_account_is_fedramp": {},
"access_token": {},
"refresh_token": {},
"id_token": {},
"expires_at": {},
"email": {},
"chatgpt_account_id": {},
"chatgpt_user_id": {},
"organization_id": {},
"plan_type": {},
"client_id": {},
}
out := make(map[string]any, len(input))
for key, value := range input {
@@ -849,7 +849,6 @@ func (h *AccountHandler) refreshSingleAccount(ctx context.Context, account *serv
newCredentials[k] = v
}
}
newCredentials = service.NormalizeOpenAIPersonalAccessTokenCredentials(account, tokenInfo, newCredentials)
} else if account.Platform == service.PlatformGemini {
tokenInfo, err := h.geminiOAuthService.RefreshAccountToken(ctx, account)
if err != nil {
@@ -509,7 +509,6 @@ var platformToLiteLLMProvider = map[string]string{
service.PlatformOpenAI: "openai",
service.PlatformGemini: "google",
service.PlatformAntigravity: "anthropic",
service.PlatformGrok: "xai",
}
// SyncPricingModels 返回 LiteLLM 定价目录中指定平台的最新模型列表
@@ -46,7 +46,6 @@ type channelMonitorCreateRequest struct {
GroupName string `json:"group_name" binding:"max=100"`
Enabled *bool `json:"enabled"`
IntervalSeconds int `json:"interval_seconds" binding:"required,min=15,max=3600"`
JitterSeconds int `json:"jitter_seconds" binding:"omitempty,min=0,max=3585"`
TemplateID *int64 `json:"template_id"`
ExtraHeaders map[string]string `json:"extra_headers"`
BodyOverrideMode string `json:"body_override_mode" binding:"omitempty,oneof=off merge replace"`
@@ -64,7 +63,6 @@ type channelMonitorUpdateRequest struct {
GroupName *string `json:"group_name" binding:"omitempty,max=100"`
Enabled *bool `json:"enabled"`
IntervalSeconds *int `json:"interval_seconds" binding:"omitempty,min=15,max=3600"`
JitterSeconds *int `json:"jitter_seconds" binding:"omitempty,min=0,max=3585"`
TemplateID *int64 `json:"template_id"`
ClearTemplate bool `json:"clear_template"` // true 时把 template_id 置空,忽略 TemplateID
ExtraHeaders *map[string]string `json:"extra_headers"`
@@ -85,7 +83,6 @@ type channelMonitorResponse struct {
GroupName string `json:"group_name"`
Enabled bool `json:"enabled"`
IntervalSeconds int `json:"interval_seconds"`
JitterSeconds int `json:"jitter_seconds"`
LastCheckedAt *string `json:"last_checked_at"`
CreatedBy int64 `json:"created_by"`
CreatedAt string `json:"created_at"`
@@ -153,7 +150,6 @@ func channelMonitorToResponse(m *service.ChannelMonitor) *channelMonitorResponse
GroupName: m.GroupName,
Enabled: m.Enabled,
IntervalSeconds: m.IntervalSeconds,
JitterSeconds: m.JitterSeconds,
CreatedBy: m.CreatedBy,
CreatedAt: m.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: m.UpdatedAt.UTC().Format(time.RFC3339),
@@ -319,7 +315,6 @@ func (h *ChannelMonitorHandler) Create(c *gin.Context) {
GroupName: req.GroupName,
Enabled: enabled,
IntervalSeconds: req.IntervalSeconds,
JitterSeconds: req.JitterSeconds,
CreatedBy: subject.UserID,
TemplateID: req.TemplateID,
ExtraHeaders: req.ExtraHeaders,
@@ -356,7 +351,6 @@ func (h *ChannelMonitorHandler) Update(c *gin.Context) {
GroupName: req.GroupName,
Enabled: req.Enabled,
IntervalSeconds: req.IntervalSeconds,
JitterSeconds: req.JitterSeconds,
TemplateID: req.TemplateID,
ClearTemplate: req.ClearTemplate,
ExtraHeaders: req.ExtraHeaders,
@@ -20,39 +20,36 @@ func NewContentModerationHandler(svc *service.ContentModerationService) *Content
}
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"`
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"`
// cyber_policy 命中是否排除出自动封号计数;前端 RiskControlView 已发送该字段,
// service.UpdateContentModerationConfigInput 已支持,此前 handler 层缺透传导致开关静默失效。
CyberPolicyExcludeFromBanCount *bool `json:"cyber_policy_exclude_from_ban_count"`
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"`
BlockedKeywords *[]string `json:"blocked_keywords"`
KeywordBlockingMode *string `json:"keyword_blocking_mode"`
ModelFilter *service.ContentModerationModelFilter `json:"model_filter"`
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"`
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"`
BlockedKeywords *[]string `json:"blocked_keywords"`
KeywordBlockingMode *string `json:"keyword_blocking_mode"`
ModelFilter *service.ContentModerationModelFilter `json:"model_filter"`
}
type contentModerationAPIKeyTestRequest struct {
@@ -84,37 +81,36 @@ func (h *ContentModerationHandler) UpdateConfig(c *gin.Context) {
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,
Thresholds: req.Thresholds,
WorkerCount: req.WorkerCount,
QueueSize: req.QueueSize,
BlockStatus: req.BlockStatus,
BlockMessage: req.BlockMessage,
EmailOnHit: req.EmailOnHit,
AutoBanEnabled: req.AutoBanEnabled,
BanThreshold: req.BanThreshold,
ViolationWindowHours: req.ViolationWindowHours,
CyberPolicyExcludeFromBanCount: req.CyberPolicyExcludeFromBanCount,
RetryCount: req.RetryCount,
HitRetentionDays: req.HitRetentionDays,
NonHitRetentionDays: req.NonHitRetentionDays,
PreHashCheckEnabled: req.PreHashCheckEnabled,
BlockedKeywords: req.BlockedKeywords,
KeywordBlockingMode: req.KeywordBlockingMode,
ModelFilter: req.ModelFilter,
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,
Thresholds: req.Thresholds,
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,
BlockedKeywords: req.BlockedKeywords,
KeywordBlockingMode: req.KeywordBlockingMode,
ModelFilter: req.ModelFilter,
})
if err != nil {
response.ErrorFrom(c, err)
@@ -1,246 +0,0 @@
package admin
import (
"strconv"
"strings"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
type GrokOAuthHandler struct {
grokOAuthService *service.GrokOAuthService
adminService service.AdminService
quotaService *service.GrokQuotaService
}
func NewGrokOAuthHandler(
grokOAuthService *service.GrokOAuthService,
adminService service.AdminService,
quotaService *service.GrokQuotaService,
) *GrokOAuthHandler {
return &GrokOAuthHandler{
grokOAuthService: grokOAuthService,
adminService: adminService,
quotaService: quotaService,
}
}
type GrokGenerateAuthURLRequest struct {
ProxyID *int64 `json:"proxy_id"`
RedirectURI string `json:"redirect_uri"`
}
func (h *GrokOAuthHandler) GenerateAuthURL(c *gin.Context) {
var req GrokGenerateAuthURLRequest
if err := c.ShouldBindJSON(&req); err != nil {
req = GrokGenerateAuthURLRequest{}
}
result, err := h.grokOAuthService.GenerateAuthURL(c.Request.Context(), req.ProxyID, req.RedirectURI)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, result)
}
type GrokExchangeCodeRequest struct {
SessionID string `json:"session_id" binding:"required"`
Code string `json:"code" binding:"required"`
State string `json:"state"`
RedirectURI string `json:"redirect_uri"`
ProxyID *int64 `json:"proxy_id"`
}
func (h *GrokOAuthHandler) ExchangeCode(c *gin.Context) {
var req GrokExchangeCodeRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
tokenInfo, err := h.grokOAuthService.ExchangeCode(c.Request.Context(), &service.GrokExchangeCodeInput{
SessionID: req.SessionID,
Code: req.Code,
State: req.State,
RedirectURI: req.RedirectURI,
ProxyID: req.ProxyID,
})
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, tokenInfo)
}
type GrokRefreshTokenRequest struct {
RefreshToken string `json:"refresh_token"`
RT string `json:"rt"`
ClientID string `json:"client_id"`
ProxyID *int64 `json:"proxy_id"`
}
func (h *GrokOAuthHandler) RefreshToken(c *gin.Context) {
var req GrokRefreshTokenRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
refreshToken := strings.TrimSpace(req.RefreshToken)
if refreshToken == "" {
refreshToken = strings.TrimSpace(req.RT)
}
if refreshToken == "" {
response.BadRequest(c, "refresh_token is required")
return
}
var proxyURL string
if req.ProxyID != nil {
proxy, err := h.adminService.GetProxy(c.Request.Context(), *req.ProxyID)
if err == nil && proxy != nil {
proxyURL = proxy.URL()
}
}
tokenInfo, err := h.grokOAuthService.RefreshToken(c.Request.Context(), refreshToken, proxyURL, req.ClientID)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, tokenInfo)
}
func (h *GrokOAuthHandler) RefreshAccountToken(c *gin.Context) {
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid account ID")
return
}
account, err := h.adminService.GetAccount(c.Request.Context(), accountID)
if err != nil {
response.ErrorFrom(c, err)
return
}
if account.Platform != service.PlatformGrok {
response.BadRequest(c, "Account platform does not match Grok OAuth endpoint")
return
}
if !account.IsOAuth() {
response.BadRequest(c, "Cannot refresh non-OAuth account credentials")
return
}
tokenInfo, err := h.grokOAuthService.RefreshAccountToken(c.Request.Context(), account)
if err != nil {
response.ErrorFrom(c, err)
return
}
newCredentials := h.grokOAuthService.BuildAccountCredentials(tokenInfo)
newCredentials = service.MergeCredentials(account.Credentials, newCredentials)
if baseURL := strings.TrimSpace(account.GetCredential("base_url")); baseURL != "" {
newCredentials["base_url"] = baseURL
}
updatedAccount, err := h.adminService.UpdateAccount(c.Request.Context(), accountID, &service.UpdateAccountInput{
Credentials: newCredentials,
})
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, dto.AccountFromService(updatedAccount))
}
func (h *GrokOAuthHandler) CreateAccountFromOAuth(c *gin.Context) {
var req struct {
SessionID string `json:"session_id" binding:"required"`
Code string `json:"code" binding:"required"`
State string `json:"state"`
RedirectURI string `json:"redirect_uri"`
ProxyID *int64 `json:"proxy_id"`
Name string `json:"name"`
Concurrency int `json:"concurrency"`
Priority int `json:"priority"`
GroupIDs []int64 `json:"group_ids"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
tokenInfo, err := h.grokOAuthService.ExchangeCode(c.Request.Context(), &service.GrokExchangeCodeInput{
SessionID: req.SessionID,
Code: req.Code,
State: req.State,
RedirectURI: req.RedirectURI,
ProxyID: req.ProxyID,
})
if err != nil {
response.ErrorFrom(c, err)
return
}
credentials := h.grokOAuthService.BuildAccountCredentials(tokenInfo)
name := strings.TrimSpace(req.Name)
if name == "" && tokenInfo.Email != "" {
name = tokenInfo.Email
}
if name == "" {
name = "Grok OAuth Account"
}
account, err := h.adminService.CreateAccount(c.Request.Context(), &service.CreateAccountInput{
Name: name,
Platform: service.PlatformGrok,
Type: service.AccountTypeOAuth,
Credentials: credentials,
ProxyID: req.ProxyID,
Concurrency: req.Concurrency,
Priority: req.Priority,
GroupIDs: req.GroupIDs,
})
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, dto.AccountFromService(account))
}
func (h *GrokOAuthHandler) QueryQuota(c *gin.Context) {
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid account ID")
return
}
if h.quotaService == nil {
response.BadRequest(c, "grok quota service is not enabled")
return
}
result, err := h.quotaService.ProbeUsage(c.Request.Context(), accountID)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, result)
}
func (h *GrokOAuthHandler) ResetQuota(c *gin.Context) {
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid account ID")
return
}
if h.quotaService == nil {
response.BadRequest(c, "grok quota service is not enabled")
return
}
result, err := h.quotaService.ResetQuota(c.Request.Context(), accountID)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, result)
}
func (h *GrokOAuthHandler) RuntimeSanity(c *gin.Context) {
response.Success(c, xai.RuntimeSanity())
}
@@ -1,147 +0,0 @@
//go:build unit
package admin
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
"github.com/Wei-Shaw/sub2api/internal/service"
)
type grokQuotaHandlerAccountRepo struct {
service.AccountRepository
account *service.Account
updates map[int64]map[string]any
}
func (r *grokQuotaHandlerAccountRepo) GetByID(_ context.Context, id int64) (*service.Account, error) {
if r.account != nil && r.account.ID == id {
return r.account, nil
}
return nil, service.ErrAccountNotFound
}
func (r *grokQuotaHandlerAccountRepo) UpdateExtra(_ context.Context, id int64, updates map[string]any) error {
if r.updates == nil {
r.updates = make(map[int64]map[string]any)
}
r.updates[id] = updates
return nil
}
type grokQuotaHandlerUpstream struct {
resp *http.Response
lastReq *http.Request
lastBody []byte
}
func (u *grokQuotaHandlerUpstream) Do(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
u.lastReq = req
if req.Body != nil {
u.lastBody, _ = io.ReadAll(req.Body)
}
return u.resp, nil
}
func (u *grokQuotaHandlerUpstream) DoWithTLS(
req *http.Request,
proxyURL string,
accountID int64,
accountConcurrency int,
_ *tlsfingerprint.Profile,
) (*http.Response, error) {
return u.Do(req, proxyURL, accountID, accountConcurrency)
}
func TestGrokOAuthHandlerQueryQuotaProbesUpstream(t *testing.T) {
gin.SetMode(gin.TestMode)
repo := &grokQuotaHandlerAccountRepo{account: &service.Account{
ID: 42,
Platform: service.PlatformGrok,
Type: service.AccountTypeOAuth,
Concurrency: 1,
Credentials: map[string]any{
"access_token": "access-token",
"expires_at": time.Now().Add(time.Hour).UTC().Format(time.RFC3339),
},
}}
upstream := &grokQuotaHandlerUpstream{resp: &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{
"X-Ratelimit-Limit-Requests": []string{"10"},
"X-Ratelimit-Remaining-Requests": []string{"8"},
},
Body: io.NopCloser(strings.NewReader(`{"id":"resp_probe"}`)),
}}
quotaService := service.NewGrokQuotaService(repo, nil, service.NewGrokTokenProvider(repo, nil), upstream)
handler := NewGrokOAuthHandler(nil, nil, quotaService)
router := gin.New()
router.GET("/api/v1/admin/grok/accounts/:id/quota", handler.QueryQuota)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/grok/accounts/42/quota", nil)
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
require.Contains(t, rec.Body.String(), `"source":"active_probe"`)
require.Contains(t, rec.Body.String(), `"headers_observed":true`)
require.NotContains(t, rec.Body.String(), "access-token")
require.Equal(t, xai.DefaultBaseURL+"/responses", upstream.lastReq.URL.String())
require.Equal(t, "Bearer access-token", upstream.lastReq.Header.Get("Authorization"))
require.Contains(t, string(upstream.lastBody), `"store":false`)
require.NotNil(t, repo.updates[42])
}
func TestGrokOAuthHandlerResetQuotaReturnsUnsupported(t *testing.T) {
gin.SetMode(gin.TestMode)
repo := &grokQuotaHandlerAccountRepo{account: &service.Account{
ID: 43,
Platform: service.PlatformGrok,
Type: service.AccountTypeOAuth,
}}
quotaService := service.NewGrokQuotaService(repo, nil, nil, nil)
handler := NewGrokOAuthHandler(nil, nil, quotaService)
router := gin.New()
router.POST("/api/v1/admin/grok/accounts/:id/reset-quota", handler.ResetQuota)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/grok/accounts/43/reset-quota", nil)
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusNotImplemented, rec.Code)
require.Contains(t, rec.Body.String(), `"reason":"GROK_QUOTA_RESET_UNSUPPORTED"`)
require.NotContains(t, rec.Body.String(), "access-token")
}
func TestGrokOAuthHandlerRuntimeSanityDoesNotExposeSecrets(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv(xai.EnvBaseURL, "http://127.0.0.1:8080/v1?access_token=secret")
t.Setenv(xai.EnvClientID, "client-secret-like-value")
handler := NewGrokOAuthHandler(nil, nil, nil)
router := gin.New()
router.GET("/api/v1/admin/grok/runtime-sanity", handler.RuntimeSanity)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/grok/runtime-sanity", nil)
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
require.Contains(t, rec.Body.String(), `"public_gateway_scope":"responses_only"`)
require.Contains(t, rec.Body.String(), `"valid":false`)
require.NotContains(t, rec.Body.String(), "access_token")
require.NotContains(t, rec.Body.String(), "secret")
require.NotContains(t, rec.Body.String(), "client-secret-like-value")
}
@@ -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 grok"`
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity"`
RateMultiplier float64 `json:"rate_multiplier"`
IsExclusive bool `json:"is_exclusive"`
SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"`
@@ -124,7 +124,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 grok"`
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity"`
RateMultiplier *float64 `json:"rate_multiplier"`
IsExclusive *bool `json:"is_exclusive"`
Status string `json:"status" binding:"omitempty,oneof=active inactive"`
@@ -0,0 +1,60 @@
package admin
import (
"sort"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/plugin"
"github.com/Wei-Shaw/sub2api/internal/util/logredact"
"github.com/gin-gonic/gin"
)
// moduleStatusSource 提供插件模块状态快照能力(*plugin.Runtime 实现该接口,测试可注入桩)。
type moduleStatusSource interface {
Snapshot() []plugin.ModuleStatus
}
// ModuleHandler handles plugin module observability operations (read-only)
type ModuleHandler struct {
runtime moduleStatusSource
}
// NewModuleHandler creates a new ModuleHandler
func NewModuleHandler(runtime *plugin.Runtime) *ModuleHandler {
return &ModuleHandler{runtime: runtime}
}
// ModuleStatusItem 是单个插件模块状态的响应条目
type ModuleStatusItem struct {
ID string `json:"id"`
Namespace string `json:"namespace"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
State string `json:"state"`
Error string `json:"error"`
}
// List 返回全部已注册插件模块的状态清单(含未启用模块),按模块 ID 字典序排序
// GET /api/v1/admin/modules
func (h *ModuleHandler) List(c *gin.Context) {
statuses := h.runtime.Snapshot()
modules := make([]ModuleStatusItem, 0, len(statuses))
for _, status := range statuses {
errText := status.Err
if errText != "" {
// 错误文本可能包含敏感信息(如上游返回的凭据片段),输出前统一脱敏。
errText = logredact.RedactText(errText)
}
modules = append(modules, ModuleStatusItem{
ID: string(status.ID),
Namespace: status.ID.Namespace(),
Name: status.ID.Name(),
Enabled: status.Enabled,
State: string(status.State),
Error: errText,
})
}
sort.Slice(modules, func(i, j int) bool { return modules[i].ID < modules[j].ID })
response.Success(c, gin.H{"modules": modules})
}
@@ -0,0 +1,177 @@
package admin
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/plugin"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
type moduleStatusSourceStub struct {
statuses []plugin.ModuleStatus
}
func (s *moduleStatusSourceStub) Snapshot() []plugin.ModuleStatus {
return s.statuses
}
type moduleListEnvelope struct {
Code int `json:"code"`
Message string `json:"message"`
Data struct {
Modules []struct {
ID string `json:"id"`
Namespace string `json:"namespace"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
State string `json:"state"`
Error string `json:"error"`
} `json:"modules"`
} `json:"data"`
}
func performModuleList(t *testing.T, source moduleStatusSource) (*httptest.ResponseRecorder, moduleListEnvelope) {
t.Helper()
gin.SetMode(gin.TestMode)
router := gin.New()
handler := &ModuleHandler{runtime: source}
router.GET("/admin/modules", handler.List)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/admin/modules", nil)
router.ServeHTTP(w, req)
var envelope moduleListEnvelope
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &envelope))
return w, envelope
}
func TestModuleHandlerListSerializesSnapshot(t *testing.T) {
stub := &moduleStatusSourceStub{statuses: []plugin.ModuleStatus{
{ID: "gateway.platform.anthropic", Enabled: true, State: plugin.StateRunning},
{ID: "job.hello", Enabled: false, State: plugin.StateRegistered},
}}
w, envelope := performModuleList(t, stub)
require.Equal(t, http.StatusOK, w.Code)
require.Equal(t, 0, envelope.Code)
require.Equal(t, "success", envelope.Message)
require.Len(t, envelope.Data.Modules, 2)
first := envelope.Data.Modules[0]
require.Equal(t, "gateway.platform.anthropic", first.ID)
require.Equal(t, "gateway.platform", first.Namespace)
require.Equal(t, "anthropic", first.Name)
require.True(t, first.Enabled)
require.Equal(t, "running", first.State)
require.Empty(t, first.Error)
// 未启用模块也在列:state=registered、enabled=false
second := envelope.Data.Modules[1]
require.Equal(t, "job.hello", second.ID)
require.Equal(t, "job", second.Namespace)
require.Equal(t, "hello", second.Name)
require.False(t, second.Enabled)
require.Equal(t, "registered", second.State)
require.Empty(t, second.Error)
// snake_case 字段恒定输出(含空 error)
require.Contains(t, w.Body.String(), `"error":""`)
require.Contains(t, w.Body.String(), `"state":"registered"`)
}
func TestModuleHandlerListSortsByID(t *testing.T) {
stub := &moduleStatusSourceStub{statuses: []plugin.ModuleStatus{
{ID: "job.zeta", Enabled: true, State: plugin.StateRunning},
{ID: "gateway.alpha", Enabled: true, State: plugin.StateRunning},
{ID: "job.alpha", Enabled: false, State: plugin.StateRegistered},
}}
_, envelope := performModuleList(t, stub)
ids := make([]string, 0, len(envelope.Data.Modules))
for _, m := range envelope.Data.Modules {
ids = append(ids, m.ID)
}
require.Equal(t, []string{"gateway.alpha", "job.alpha", "job.zeta"}, ids)
}
func TestModuleHandlerListRedactsErrorText(t *testing.T) {
stub := &moduleStatusSourceStub{statuses: []plugin.ModuleStatus{
{ID: "job.bad", Enabled: true, State: plugin.StateErrored, Err: "provision failed: access_token=ya29.secret-value"},
}}
_, envelope := performModuleList(t, stub)
require.Len(t, envelope.Data.Modules, 1)
errText := envelope.Data.Modules[0].Error
require.Contains(t, errText, "provision failed")
require.NotContains(t, errText, "ya29.secret-value")
}
func TestModuleHandlerListEmptySnapshot(t *testing.T) {
w, envelope := performModuleList(t, &moduleStatusSourceStub{})
require.Equal(t, http.StatusOK, w.Code)
require.NotNil(t, envelope.Data.Modules)
require.Empty(t, envelope.Data.Modules)
require.Contains(t, w.Body.String(), `"modules":[]`)
}
type moduleHandlerFakeModule struct {
id plugin.ModuleID
enabledByDefault bool
}
func (m *moduleHandlerFakeModule) ModuleInfo() plugin.ModuleInfo {
id := m.id
enabled := m.enabledByDefault
return plugin.ModuleInfo{
ID: id,
New: func() plugin.Module { return &moduleHandlerFakeModule{id: id, enabledByDefault: enabled} },
EnabledByDefault: enabled,
}
}
// TestModuleHandlerListWithRuntimeSnapshot 用真实 Runtime(隔离注册表)验证
// 接口返回与 Runtime.Snapshot 一致:enabled 模块为 running,
// disabled 模块也在列且 state=registered、enabled=false。
func TestModuleHandlerListWithRuntimeSnapshot(t *testing.T) {
registry := plugin.NewRegistry()
registry.RegisterModule(&moduleHandlerFakeModule{id: "job.enabled", enabledByDefault: true})
registry.RegisterModule(&moduleHandlerFakeModule{id: "job.disabled", enabledByDefault: false})
runtime := plugin.NewRuntimeWithRegistry(nil, nil, registry)
require.NoError(t, runtime.Build())
require.NoError(t, runtime.Start(context.Background()))
t.Cleanup(func() { _ = runtime.Stop(context.Background()) })
gin.SetMode(gin.TestMode)
router := gin.New()
router.GET("/admin/modules", NewModuleHandler(runtime).List)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/admin/modules", nil)
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var envelope moduleListEnvelope
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &envelope))
require.Len(t, envelope.Data.Modules, 2)
disabled := envelope.Data.Modules[0]
require.Equal(t, "job.disabled", disabled.ID)
require.False(t, disabled.Enabled)
require.Equal(t, "registered", disabled.State)
enabled := envelope.Data.Modules[1]
require.Equal(t, "job.enabled", enabled.ID)
require.True(t, enabled.Enabled)
require.Equal(t, "running", enabled.State)
}
@@ -3,7 +3,6 @@ package admin
import (
"strconv"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
@@ -17,7 +16,6 @@ import (
type OpenAIOAuthHandler struct {
openaiOAuthService *service.OpenAIOAuthService
adminService service.AdminService
quotaService *service.OpenAIQuotaService
}
func oauthPlatformFromPath(c *gin.Context) string {
@@ -25,15 +23,10 @@ func oauthPlatformFromPath(c *gin.Context) string {
}
// NewOpenAIOAuthHandler creates a new OpenAI OAuth handler
func NewOpenAIOAuthHandler(
openaiOAuthService *service.OpenAIOAuthService,
adminService service.AdminService,
quotaService *service.OpenAIQuotaService,
) *OpenAIOAuthHandler {
func NewOpenAIOAuthHandler(openaiOAuthService *service.OpenAIOAuthService, adminService service.AdminService) *OpenAIOAuthHandler {
return &OpenAIOAuthHandler{
openaiOAuthService: openaiOAuthService,
adminService: adminService,
quotaService: quotaService,
}
}
@@ -107,24 +100,6 @@ type OpenAIRefreshTokenRequest struct {
ProxyID *int64 `json:"proxy_id"`
}
type OpenAICodexPATCreateRequest struct {
AccessToken string `json:"access_token" binding:"required"`
Name string `json:"name"`
Notes *string `json:"notes"`
GroupIDs []int64 `json:"group_ids"`
ProxyID *int64 `json:"proxy_id"`
Concurrency *int `json:"concurrency"`
Priority *int `json:"priority"`
RateMultiplier *float64 `json:"rate_multiplier"`
LoadFactor *int `json:"load_factor"`
ExpiresAt *int64 `json:"expires_at"`
AutoPauseOnExpired *bool `json:"auto_pause_on_expired"`
CredentialExtras map[string]any `json:"credential_extras"`
Extra map[string]any `json:"extra"`
SkipDefaultGroupBind *bool `json:"skip_default_group_bind"`
ConfirmMixedChannelRisk *bool `json:"confirm_mixed_channel_risk"`
}
// RefreshToken refreshes an OpenAI OAuth token
// POST /api/v1/admin/openai/refresh-token
func (h *OpenAIOAuthHandler) RefreshToken(c *gin.Context) {
@@ -210,7 +185,6 @@ func (h *OpenAIOAuthHandler) RefreshAccountToken(c *gin.Context) {
newCredentials[k] = v
}
}
newCredentials = service.NormalizeOpenAIPersonalAccessTokenCredentials(account, tokenInfo, newCredentials)
updatedAccount, err := h.adminService.UpdateAccount(c.Request.Context(), accountID, &service.UpdateAccountInput{
Credentials: newCredentials,
@@ -288,151 +262,3 @@ func (h *OpenAIOAuthHandler) CreateAccountFromOAuth(c *gin.Context) {
response.Success(c, dto.AccountFromService(account))
}
// CreateAccountFromCodexPAT creates an OpenAI OAuth account from a Codex at-* personal access token.
// POST /api/v1/admin/openai/create-from-codex-pat
func (h *OpenAIOAuthHandler) CreateAccountFromCodexPAT(c *gin.Context) {
var req OpenAICodexPATCreateRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
if req.Concurrency != nil && *req.Concurrency < 0 {
response.BadRequest(c, "concurrency must be >= 0")
return
}
if req.Priority != nil && *req.Priority < 0 {
response.BadRequest(c, "priority must be >= 0")
return
}
if req.RateMultiplier != nil && *req.RateMultiplier < 0 {
response.BadRequest(c, "rate_multiplier must be >= 0")
return
}
if req.LoadFactor != nil && *req.LoadFactor > 10000 {
response.BadRequest(c, "load_factor must be <= 10000")
return
}
var proxyURL string
if req.ProxyID != nil {
proxy, err := h.adminService.GetProxy(c.Request.Context(), *req.ProxyID)
if err != nil {
response.ErrorFrom(c, err)
return
}
if proxy != nil {
proxyURL = proxy.URL()
}
}
tokenInfo, err := h.openaiOAuthService.ValidateCodexPersonalAccessToken(c.Request.Context(), req.AccessToken, proxyURL)
if err != nil {
response.ErrorFrom(c, err)
return
}
credentials := mergeCodexImportMap(
h.openaiOAuthService.BuildAccountCredentials(tokenInfo),
sanitizeCodexImportCredentialExtras(req.CredentialExtras),
)
extra := mergeCodexImportMap(req.Extra, map[string]any{
"import_source": "codex_personal_access_token",
"auth_provider": "codex_personal_access_token",
"imported_at": time.Now().UTC().Format(time.RFC3339),
"access_token_sha256": codexTokenFingerprint(req.AccessToken),
})
concurrency := 3
if req.Concurrency != nil {
concurrency = *req.Concurrency
}
priority := 50
if req.Priority != nil {
priority = *req.Priority
}
skipDefaultGroupBind := false
if req.SkipDefaultGroupBind != nil {
skipDefaultGroupBind = *req.SkipDefaultGroupBind
}
account, err := h.adminService.CreateAccount(c.Request.Context(), &service.CreateAccountInput{
Name: buildOpenAICodexPATAccountName(req.Name, tokenInfo),
Notes: req.Notes,
Platform: service.PlatformOpenAI,
Type: service.AccountTypeOAuth,
Credentials: credentials,
Extra: extra,
ProxyID: req.ProxyID,
Concurrency: concurrency,
Priority: priority,
RateMultiplier: req.RateMultiplier,
LoadFactor: req.LoadFactor,
GroupIDs: req.GroupIDs,
ExpiresAt: req.ExpiresAt,
AutoPauseOnExpired: req.AutoPauseOnExpired,
SkipDefaultGroupBind: skipDefaultGroupBind,
SkipMixedChannelCheck: req.ConfirmMixedChannelRisk != nil && *req.ConfirmMixedChannelRisk,
})
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, dto.AccountFromService(account))
}
func buildOpenAICodexPATAccountName(name string, tokenInfo *service.OpenAITokenInfo) string {
name = strings.TrimSpace(name)
if name != "" {
return name
}
if tokenInfo != nil {
for _, candidate := range []string{tokenInfo.Email, tokenInfo.ChatGPTAccountID, tokenInfo.ChatGPTUserID} {
if candidate = strings.TrimSpace(candidate); candidate != "" {
return candidate
}
}
}
return "Codex PAT Account"
}
// QueryQuota queries the rate-limit / quota usage for an OpenAI account.
// GET /api/v1/admin/openai/accounts/:id/quota
func (h *OpenAIOAuthHandler) QueryQuota(c *gin.Context) {
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid account ID")
return
}
if h.quotaService == nil {
response.BadRequest(c, "openai quota service is not enabled")
return
}
usage, err := h.quotaService.QueryUsage(c.Request.Context(), accountID)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, usage)
}
// ResetQuota consumes one rate-limit reset credit for an OpenAI account.
// POST /api/v1/admin/openai/accounts/:id/reset-quota
func (h *OpenAIOAuthHandler) ResetQuota(c *gin.Context) {
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid account ID")
return
}
if h.quotaService == nil {
response.BadRequest(c, "openai quota service is not enabled")
return
}
result, err := h.quotaService.ResetCredit(c.Request.Context(), accountID)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, result)
}
@@ -2,7 +2,6 @@ package admin
import (
"strconv"
"time"
dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
@@ -116,105 +115,24 @@ func (h *PaymentHandler) RetryFulfillment(c *gin.Context) {
response.Success(c, gin.H{"message": "fulfillment retried"})
}
type AdminPaymentOrderResult struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
UserEmail string `json:"user_email,omitempty"`
UserName string `json:"user_name,omitempty"`
UserNotes *string `json:"user_notes,omitempty"`
Amount float64 `json:"amount"`
PayAmount float64 `json:"pay_amount"`
FeeRate float64 `json:"fee_rate"`
Currency string `json:"currency"`
RechargeCode string `json:"recharge_code,omitempty"`
OutTradeNo string `json:"out_trade_no"`
PaymentType string `json:"payment_type"`
PaymentTradeNo string `json:"payment_trade_no,omitempty"`
PayURL *string `json:"pay_url,omitempty"`
QRCode *string `json:"qr_code,omitempty"`
QRCodeImg *string `json:"qr_code_img,omitempty"`
OrderType string `json:"order_type"`
PlanID *int64 `json:"plan_id,omitempty"`
SubscriptionGroupID *int64 `json:"subscription_group_id,omitempty"`
SubscriptionDays *int `json:"subscription_days,omitempty"`
ProviderInstanceID *string `json:"provider_instance_id,omitempty"`
ProviderKey *string `json:"provider_key,omitempty"`
Status string `json:"status"`
RefundAmount float64 `json:"refund_amount"`
RefundReason *string `json:"refund_reason,omitempty"`
RefundAt *time.Time `json:"refund_at,omitempty"`
ForceRefund bool `json:"force_refund,omitempty"`
RefundRequestedAt *time.Time `json:"refund_requested_at,omitempty"`
RefundRequestReason *string `json:"refund_request_reason,omitempty"`
RefundRequestedBy *string `json:"refund_requested_by,omitempty"`
ExpiresAt time.Time `json:"expires_at"`
PaidAt *time.Time `json:"paid_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
FailedAt *time.Time `json:"failed_at,omitempty"`
FailedReason *string `json:"failed_reason,omitempty"`
ClientIP string `json:"client_ip,omitempty"`
SrcHost string `json:"src_host,omitempty"`
SrcURL *string `json:"src_url,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func sanitizeAdminPaymentOrdersForResponse(orders []*dbent.PaymentOrder) []*AdminPaymentOrderResult {
out := make([]*AdminPaymentOrderResult, 0, len(orders))
func sanitizeAdminPaymentOrdersForResponse(orders []*dbent.PaymentOrder) []*dbent.PaymentOrder {
if len(orders) == 0 {
return orders
}
out := make([]*dbent.PaymentOrder, 0, len(orders))
for _, order := range orders {
if item := sanitizeAdminPaymentOrderForResponse(order); item != nil {
out = append(out, item)
}
out = append(out, sanitizeAdminPaymentOrderForResponse(order))
}
return out
}
func sanitizeAdminPaymentOrderForResponse(order *dbent.PaymentOrder) *AdminPaymentOrderResult {
func sanitizeAdminPaymentOrderForResponse(order *dbent.PaymentOrder) *dbent.PaymentOrder {
if order == nil {
return nil
}
return &AdminPaymentOrderResult{
ID: order.ID,
UserID: order.UserID,
UserEmail: order.UserEmail,
UserName: order.UserName,
UserNotes: order.UserNotes,
Amount: order.Amount,
PayAmount: order.PayAmount,
FeeRate: order.FeeRate,
Currency: service.PaymentOrderCurrency(order),
RechargeCode: order.RechargeCode,
OutTradeNo: order.OutTradeNo,
PaymentType: order.PaymentType,
PaymentTradeNo: order.PaymentTradeNo,
PayURL: order.PayURL,
QRCode: order.QrCode,
QRCodeImg: order.QrCodeImg,
OrderType: order.OrderType,
PlanID: order.PlanID,
SubscriptionGroupID: order.SubscriptionGroupID,
SubscriptionDays: order.SubscriptionDays,
ProviderInstanceID: order.ProviderInstanceID,
ProviderKey: order.ProviderKey,
Status: order.Status,
RefundAmount: order.RefundAmount,
RefundReason: order.RefundReason,
RefundAt: order.RefundAt,
ForceRefund: order.ForceRefund,
RefundRequestedAt: order.RefundRequestedAt,
RefundRequestReason: order.RefundRequestReason,
RefundRequestedBy: order.RefundRequestedBy,
ExpiresAt: order.ExpiresAt,
PaidAt: order.PaidAt,
CompletedAt: order.CompletedAt,
FailedAt: order.FailedAt,
FailedReason: order.FailedReason,
ClientIP: order.ClientIP,
SrcHost: order.SrcHost,
SrcURL: order.SrcURL,
CreatedAt: order.CreatedAt,
UpdatedAt: order.UpdatedAt,
}
cloned := *order
cloned.ProviderSnapshot = nil
return &cloned
}
// AdminProcessRefundRequest is the request body for admin refund processing.
@@ -1,48 +0,0 @@
package admin
import (
"encoding/json"
"strings"
"testing"
"time"
dbent "github.com/Wei-Shaw/sub2api/ent"
)
func TestSanitizeAdminPaymentOrderForResponseAddsCurrency(t *testing.T) {
now := time.Now()
order := &dbent.PaymentOrder{
ID: 1,
UserID: 2,
Amount: 100,
PayAmount: 108,
FeeRate: 8,
OutTradeNo: "sub2_202606250001",
PaymentType: "stripe",
OrderType: "subscription",
Status: "COMPLETED",
ExpiresAt: now,
CreatedAt: now,
UpdatedAt: now,
ProviderSnapshot: map[string]any{
"schema_version": 2,
"currency": "USD",
},
}
got := sanitizeAdminPaymentOrderForResponse(order)
if got == nil {
t.Fatal("expected sanitized order")
}
if got.Currency != "USD" {
t.Fatalf("expected currency USD, got %q", got.Currency)
}
body, err := json.Marshal(got)
if err != nil {
t.Fatalf("marshal sanitized order: %v", err)
}
if strings.Contains(string(body), "provider_snapshot") {
t.Fatalf("expected provider_snapshot to be omitted, got %s", string(body))
}
}
@@ -148,7 +148,7 @@ func (h *PromoHandler) Update(c *gin.Context) {
if req.ExpiresAt != nil {
if *req.ExpiresAt == 0 {
// 0 表示清除过期时间
input.ExpiresAt = &time.Time{}
input.ExpiresAt = nil
} else {
t := time.Unix(*req.ExpiresAt, 0)
input.ExpiresAt = &t
+19 -152
View File
@@ -228,8 +228,6 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
DefaultConcurrency: settings.DefaultConcurrency,
DefaultBalance: settings.DefaultBalance,
RiskControlEnabled: settings.RiskControlEnabled,
CyberSessionBlockEnabled: settings.CyberSessionBlockEnabled,
CyberSessionBlockTTLSeconds: settings.CyberSessionBlockTTLSeconds,
AffiliateRebateRate: settings.AffiliateRebateRate,
AffiliateRebateFreezeHours: settings.AffiliateRebateFreezeHours,
AffiliateRebateDurationDays: settings.AffiliateRebateDurationDays,
@@ -254,19 +252,11 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
EnableFingerprintUnification: settings.EnableFingerprintUnification,
EnableMetadataPassthrough: settings.EnableMetadataPassthrough,
EnableCCHSigning: settings.EnableCCHSigning,
EnableClaudeOAuthSystemPromptInjection: settings.EnableClaudeOAuthSystemPromptInjection,
ClaudeOAuthSystemPrompt: settings.ClaudeOAuthSystemPrompt,
ClaudeOAuthSystemPromptBlocks: settings.ClaudeOAuthSystemPromptBlocks,
EnableAnthropicCacheTTL1hInjection: settings.EnableAnthropicCacheTTL1hInjection,
RewriteMessageCacheControl: settings.RewriteMessageCacheControl,
AntigravityUserAgentVersion: settings.AntigravityUserAgentVersion,
OpenAICodexUserAgent: settings.OpenAICodexUserAgent,
MinCodexVersion: settings.MinCodexVersion,
MaxCodexVersion: settings.MaxCodexVersion,
CodexCLIOnlyBlacklist: settings.CodexCLIOnlyBlacklist,
CodexCLIOnlyWhitelist: settings.CodexCLIOnlyWhitelist,
CodexCLIOnlyAllowAppServerClients: settings.CodexCLIOnlyAllowAppServerClients,
CodexCLIOnlyEngineFingerprintSignals: settings.CodexCLIOnlyEngineFingerprintSignals,
OpenAIAllowClaudeCodeCodexPlugin: settings.OpenAIAllowClaudeCodeCodexPlugin,
WebSearchEmulationEnabled: settings.WebSearchEmulationEnabled,
PaymentVisibleMethodAlipaySource: settings.PaymentVisibleMethodAlipaySource,
PaymentVisibleMethodWxpaySource: settings.PaymentVisibleMethodWxpaySource,
@@ -590,24 +580,14 @@ type UpdateSettingsRequest struct {
BackendModeEnabled bool `json:"backend_mode_enabled"`
// Gateway forwarding behavior
EnableFingerprintUnification *bool `json:"enable_fingerprint_unification"`
EnableMetadataPassthrough *bool `json:"enable_metadata_passthrough"`
EnableCCHSigning *bool `json:"enable_cch_signing"`
EnableClaudeOAuthSystemPromptInjection *bool `json:"enable_claude_oauth_system_prompt_injection"`
ClaudeOAuthSystemPrompt *string `json:"claude_oauth_system_prompt"`
ClaudeOAuthSystemPromptBlocks *string `json:"claude_oauth_system_prompt_blocks"`
EnableAnthropicCacheTTL1hInjection *bool `json:"enable_anthropic_cache_ttl_1h_injection"`
RewriteMessageCacheControl *bool `json:"rewrite_message_cache_control"`
AntigravityUserAgentVersion *string `json:"antigravity_user_agent_version"`
OpenAICodexUserAgent *string `json:"openai_codex_user_agent"`
// codex_cli_only 加固(global-only)
MinCodexVersion string `json:"min_codex_version"`
MaxCodexVersion string `json:"max_codex_version"`
CodexCLIOnlyBlacklist string `json:"codex_cli_only_blacklist"`
CodexCLIOnlyWhitelist string `json:"codex_cli_only_whitelist"`
CodexCLIOnlyAllowAppServerClients *bool `json:"codex_cli_only_allow_app_server_clients"`
CodexCLIOnlyEngineFingerprintSignals string `json:"codex_cli_only_engine_fingerprint_signals"`
EnableFingerprintUnification *bool `json:"enable_fingerprint_unification"`
EnableMetadataPassthrough *bool `json:"enable_metadata_passthrough"`
EnableCCHSigning *bool `json:"enable_cch_signing"`
EnableAnthropicCacheTTL1hInjection *bool `json:"enable_anthropic_cache_ttl_1h_injection"`
RewriteMessageCacheControl *bool `json:"rewrite_message_cache_control"`
AntigravityUserAgentVersion *string `json:"antigravity_user_agent_version"`
OpenAICodexUserAgent *string `json:"openai_codex_user_agent"`
OpenAIAllowClaudeCodeCodexPlugin *bool `json:"openai_allow_claude_code_codex_plugin"`
// Payment visible method routing
PaymentVisibleMethodAlipaySource *string `json:"payment_visible_method_alipay_source"`
@@ -666,10 +646,6 @@ type UpdateSettingsRequest struct {
// 风控中心功能开关
RiskControlEnabled *bool `json:"risk_control_enabled"`
// cyber 会话屏蔽开关 + TTL
CyberSessionBlockEnabled *bool `json:"cyber_session_block_enabled"`
CyberSessionBlockTTLSeconds *int `json:"cyber_session_block_ttl_seconds"`
// OpenAI fast/flex policy (optional, only updated when provided)
OpenAIFastPolicySettings *dto.OpenAIFastPolicySettings `json:"openai_fast_policy_settings,omitempty"`
@@ -1478,34 +1454,6 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
}
// codex_cli_only 加固:最低/最高 Codex 版本(空=禁用,或合法 semver;max>=min)
if req.MinCodexVersion != "" && !semverPattern.MatchString(req.MinCodexVersion) {
response.Error(c, http.StatusBadRequest, "min_codex_version must be empty or a valid semver (e.g. 0.141.0)")
return
}
if req.MaxCodexVersion != "" && !semverPattern.MatchString(req.MaxCodexVersion) {
response.Error(c, http.StatusBadRequest, "max_codex_version must be empty or a valid semver (e.g. 0.200.0)")
return
}
if req.MinCodexVersion != "" && req.MaxCodexVersion != "" && service.CompareVersions(req.MaxCodexVersion, req.MinCodexVersion) < 0 {
response.Error(c, http.StatusBadRequest, "max_codex_version must be greater than or equal to min_codex_version")
return
}
// codex_cli_only 黑/白名单:非空须为合法 []AllowedClientEntry JSON。
// 黑名单 OR 宽 deny(允许 originator-only);白名单双因子 AND,额外要求每条可命中(非空 originator + ua_contains)。
if err := service.ValidateCodexClientEntriesJSON(req.CodexCLIOnlyBlacklist); err != nil {
response.Error(c, http.StatusBadRequest, "codex_cli_only_blacklist "+err.Error())
return
}
if err := service.ValidateCodexWhitelistEntriesJSON(req.CodexCLIOnlyWhitelist); err != nil {
response.Error(c, http.StatusBadRequest, "codex_cli_only_whitelist "+err.Error())
return
}
if err := service.ValidateEngineFingerprintSignalsJSON(req.CodexCLIOnlyEngineFingerprintSignals); err != nil {
response.Error(c, http.StatusBadRequest, "codex_cli_only_engine_fingerprint_signals "+err.Error())
return
}
// 交叉验证:如果同时设置了最低和最高版本号,最高版本号必须 >= 最低版本号
if req.MinClaudeCodeVersion != "" && req.MaxClaudeCodeVersion != "" {
if service.CompareVersions(req.MaxClaudeCodeVersion, req.MinClaudeCodeVersion) < 0 {
@@ -1514,12 +1462,6 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
}
// cyber 会话屏蔽 TTL 校验:提供时必须 > 0
if req.CyberSessionBlockTTLSeconds != nil && *req.CyberSessionBlockTTLSeconds <= 0 {
response.BadRequest(c, "cyber_session_block_ttl_seconds must be > 0")
return
}
settings := &service.SystemSettings{
// 系统全局 platform quota 默认值(整体替换语义)
DefaultPlatformQuotas: req.DefaultPlatformQuotas,
@@ -1701,24 +1643,6 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
return previousSettings.EnableCCHSigning
}(),
EnableClaudeOAuthSystemPromptInjection: func() bool {
if req.EnableClaudeOAuthSystemPromptInjection != nil {
return *req.EnableClaudeOAuthSystemPromptInjection
}
return previousSettings.EnableClaudeOAuthSystemPromptInjection
}(),
ClaudeOAuthSystemPrompt: func() string {
if req.ClaudeOAuthSystemPrompt != nil {
return *req.ClaudeOAuthSystemPrompt
}
return previousSettings.ClaudeOAuthSystemPrompt
}(),
ClaudeOAuthSystemPromptBlocks: func() string {
if req.ClaudeOAuthSystemPromptBlocks != nil {
return *req.ClaudeOAuthSystemPromptBlocks
}
return previousSettings.ClaudeOAuthSystemPromptBlocks
}(),
EnableAnthropicCacheTTL1hInjection: func() bool {
if req.EnableAnthropicCacheTTL1hInjection != nil {
return *req.EnableAnthropicCacheTTL1hInjection
@@ -1743,17 +1667,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
return previousSettings.OpenAICodexUserAgent
}(),
MinCodexVersion: strings.TrimSpace(req.MinCodexVersion),
MaxCodexVersion: strings.TrimSpace(req.MaxCodexVersion),
CodexCLIOnlyBlacklist: strings.TrimSpace(req.CodexCLIOnlyBlacklist),
CodexCLIOnlyWhitelist: strings.TrimSpace(req.CodexCLIOnlyWhitelist),
CodexCLIOnlyAllowAppServerClients: func() bool {
if req.CodexCLIOnlyAllowAppServerClients != nil {
return *req.CodexCLIOnlyAllowAppServerClients
OpenAIAllowClaudeCodeCodexPlugin: func() bool {
if req.OpenAIAllowClaudeCodeCodexPlugin != nil {
return *req.OpenAIAllowClaudeCodeCodexPlugin
}
return previousSettings.CodexCLIOnlyAllowAppServerClients
return previousSettings.OpenAIAllowClaudeCodeCodexPlugin
}(),
CodexCLIOnlyEngineFingerprintSignals: strings.TrimSpace(req.CodexCLIOnlyEngineFingerprintSignals),
PaymentVisibleMethodAlipaySource: func() string {
if req.PaymentVisibleMethodAlipaySource != nil {
return strings.TrimSpace(*req.PaymentVisibleMethodAlipaySource)
@@ -1850,18 +1769,6 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
return previousSettings.RiskControlEnabled
}(),
CyberSessionBlockEnabled: func() bool {
if req.CyberSessionBlockEnabled != nil {
return *req.CyberSessionBlockEnabled
}
return previousSettings.CyberSessionBlockEnabled
}(),
CyberSessionBlockTTLSeconds: func() int {
if req.CyberSessionBlockTTLSeconds != nil {
return *req.CyberSessionBlockTTLSeconds
}
return previousSettings.CyberSessionBlockTTLSeconds
}(),
}
// req.AuthSourceXxxPlatformQuotas 为 nil 表示本次请求未包含该 source 的 quota 配置(保留 previousAuthSourceDefaults 中的值);
@@ -2138,19 +2045,11 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
EnableFingerprintUnification: updatedSettings.EnableFingerprintUnification,
EnableMetadataPassthrough: updatedSettings.EnableMetadataPassthrough,
EnableCCHSigning: updatedSettings.EnableCCHSigning,
EnableClaudeOAuthSystemPromptInjection: updatedSettings.EnableClaudeOAuthSystemPromptInjection,
ClaudeOAuthSystemPrompt: updatedSettings.ClaudeOAuthSystemPrompt,
ClaudeOAuthSystemPromptBlocks: updatedSettings.ClaudeOAuthSystemPromptBlocks,
EnableAnthropicCacheTTL1hInjection: updatedSettings.EnableAnthropicCacheTTL1hInjection,
RewriteMessageCacheControl: updatedSettings.RewriteMessageCacheControl,
AntigravityUserAgentVersion: updatedSettings.AntigravityUserAgentVersion,
OpenAICodexUserAgent: updatedSettings.OpenAICodexUserAgent,
MinCodexVersion: updatedSettings.MinCodexVersion,
MaxCodexVersion: updatedSettings.MaxCodexVersion,
CodexCLIOnlyBlacklist: updatedSettings.CodexCLIOnlyBlacklist,
CodexCLIOnlyWhitelist: updatedSettings.CodexCLIOnlyWhitelist,
CodexCLIOnlyAllowAppServerClients: updatedSettings.CodexCLIOnlyAllowAppServerClients,
CodexCLIOnlyEngineFingerprintSignals: updatedSettings.CodexCLIOnlyEngineFingerprintSignals,
OpenAIAllowClaudeCodeCodexPlugin: updatedSettings.OpenAIAllowClaudeCodeCodexPlugin,
PaymentVisibleMethodAlipaySource: updatedSettings.PaymentVisibleMethodAlipaySource,
PaymentVisibleMethodWxpaySource: updatedSettings.PaymentVisibleMethodWxpaySource,
PaymentVisibleMethodAlipayEnabled: updatedSettings.PaymentVisibleMethodAlipayEnabled,
@@ -2191,10 +2090,8 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
AffiliateEnabled: updatedSettings.AffiliateEnabled,
RiskControlEnabled: updatedSettings.RiskControlEnabled,
CyberSessionBlockEnabled: updatedSettings.CyberSessionBlockEnabled,
CyberSessionBlockTTLSeconds: updatedSettings.CyberSessionBlockTTLSeconds,
AllowUserViewErrorRequests: updatedSettings.AllowUserViewErrorRequests,
RiskControlEnabled: updatedSettings.RiskControlEnabled,
AllowUserViewErrorRequests: updatedSettings.AllowUserViewErrorRequests,
}
if fastPolicy, err := h.settingService.GetOpenAIFastPolicySettings(c.Request.Context()); err != nil {
slog.Error("openai_fast_policy_settings_get_failed", "error", err)
@@ -2578,24 +2475,6 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if before.MaxClaudeCodeVersion != after.MaxClaudeCodeVersion {
changed = append(changed, "max_claude_code_version")
}
if before.MinCodexVersion != after.MinCodexVersion {
changed = append(changed, "min_codex_version")
}
if before.MaxCodexVersion != after.MaxCodexVersion {
changed = append(changed, "max_codex_version")
}
if before.CodexCLIOnlyAllowAppServerClients != after.CodexCLIOnlyAllowAppServerClients {
changed = append(changed, "codex_cli_only_allow_app_server_clients")
}
if before.CodexCLIOnlyEngineFingerprintSignals != after.CodexCLIOnlyEngineFingerprintSignals {
changed = append(changed, "codex_cli_only_engine_fingerprint_signals")
}
if before.CodexCLIOnlyBlacklist != after.CodexCLIOnlyBlacklist {
changed = append(changed, "codex_cli_only_blacklist")
}
if before.CodexCLIOnlyWhitelist != after.CodexCLIOnlyWhitelist {
changed = append(changed, "codex_cli_only_whitelist")
}
if before.AllowUngroupedKeyScheduling != after.AllowUngroupedKeyScheduling {
changed = append(changed, "allow_ungrouped_key_scheduling")
}
@@ -2629,15 +2508,6 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if before.EnableCCHSigning != after.EnableCCHSigning {
changed = append(changed, "enable_cch_signing")
}
if before.EnableClaudeOAuthSystemPromptInjection != after.EnableClaudeOAuthSystemPromptInjection {
changed = append(changed, "enable_claude_oauth_system_prompt_injection")
}
if before.ClaudeOAuthSystemPrompt != after.ClaudeOAuthSystemPrompt {
changed = append(changed, "claude_oauth_system_prompt")
}
if before.ClaudeOAuthSystemPromptBlocks != after.ClaudeOAuthSystemPromptBlocks {
changed = append(changed, "claude_oauth_system_prompt_blocks")
}
if before.EnableAnthropicCacheTTL1hInjection != after.EnableAnthropicCacheTTL1hInjection {
changed = append(changed, "enable_anthropic_cache_ttl_1h_injection")
}
@@ -2650,6 +2520,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if before.OpenAICodexUserAgent != after.OpenAICodexUserAgent {
changed = append(changed, "openai_codex_user_agent")
}
if before.OpenAIAllowClaudeCodeCodexPlugin != after.OpenAIAllowClaudeCodeCodexPlugin {
changed = append(changed, "openai_allow_claude_code_codex_plugin")
}
if before.PaymentVisibleMethodAlipaySource != after.PaymentVisibleMethodAlipaySource {
changed = append(changed, "payment_visible_method_alipay_source")
}
@@ -2699,12 +2572,6 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if before.RiskControlEnabled != after.RiskControlEnabled {
changed = append(changed, "risk_control_enabled")
}
if before.CyberSessionBlockEnabled != after.CyberSessionBlockEnabled {
changed = append(changed, "cyber_session_block_enabled")
}
if before.CyberSessionBlockTTLSeconds != after.CyberSessionBlockTTLSeconds {
changed = append(changed, "cyber_session_block_ttl_seconds")
}
// Default platform quotas(JSON map,整体比较)
if !equalPlatformQuotaSettings(before.DefaultPlatformQuotas, after.DefaultPlatformQuotas) {
changed = append(changed, service.SettingKeyDefaultPlatformQuotas)
@@ -1,58 +0,0 @@
//go:build unit
package admin
import (
"bytes"
"encoding/json"
"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"
)
// updateSettingsCodexStatus PUT /settings 仅带给定字段,返回 HTTP 状态码(轻量 stub repo,无 DB)。
func updateSettingsCodexStatus(t *testing.T, body map[string]any) int {
t.Helper()
gin.SetMode(gin.TestMode)
repo := &settingHandlerRepoStub{values: map[string]string{service.SettingKeyPromoCodeEnabled: "true"}}
svc := service.NewSettingService(repo, &config.Config{Default: config.DefaultConfig{UserConcurrency: 5}})
handler := NewSettingHandler(svc, nil, nil, nil, nil, nil, nil)
raw, err := json.Marshal(body)
require.NoError(t, err)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings", bytes.NewReader(raw))
c.Request.Header.Set("Content-Type", "application/json")
handler.UpdateSettings(c)
return rec.Code
}
// 白名单是双因子 AND:originator-only 条目在运行时永不命中(静默失效)。
// handler 应路由到 ValidateCodexWhitelistEntriesJSON,在写入时即拒(400)。
func TestUpdateSettings_CodexWhitelistRejectsUnmatchable(t *testing.T) {
code := updateSettingsCodexStatus(t, map[string]any{
"codex_cli_only_whitelist": `[{"originator":"opencode"}]`,
})
require.Equal(t, http.StatusBadRequest, code, "白名单 originator-only 应被拒(静默失效防护)")
}
func TestUpdateSettings_CodexWhitelistAcceptsMatchable(t *testing.T) {
code := updateSettingsCodexStatus(t, map[string]any{
"codex_cli_only_whitelist": `[{"originator":"opencode","ua_contains":["opencode/"]}]`,
})
require.Equal(t, http.StatusOK, code, "可命中白名单条目应通过")
}
// 黑名单是 OR 宽 deny:允许 originator-only。非对称——不受白名单收紧影响。
func TestUpdateSettings_CodexBlacklistAllowsOriginatorOnly(t *testing.T) {
code := updateSettingsCodexStatus(t, map[string]any{
"codex_cli_only_blacklist": `[{"originator":"evil"}]`,
})
require.Equal(t, http.StatusOK, code, "黑名单 originator-only 应允许(非对称)")
}
@@ -112,9 +112,9 @@ func TestUpdateUserPlatformQuotas_Success(t *testing.T) {
if repo.upsertCalls[0].userID != 42 || len(repo.upsertCalls[0].records) != 2 {
t.Errorf("unexpected upsert call: %+v", repo.upsertCalls[0])
}
// 缓存失效:请求中 2 个 platform + 软删除的 3 个 platform(gemini, antigravity, grok)= 5 次
if len(cache.deleteCalls) != 5 {
t.Errorf("expected 5 cache delete calls, got %d: %+v", len(cache.deleteCalls), cache.deleteCalls)
// 缓存失效:请求中 2 个 platform + 软删除的 2 个 platform(gemini, antigravity)= 4 次
if len(cache.deleteCalls) != 4 {
t.Errorf("expected 4 cache delete calls, got %d: %+v", len(cache.deleteCalls), cache.deleteCalls)
}
}
@@ -143,7 +143,6 @@ func (h *AuthHandler) DingTalkOAuthStart(c *gin.Context) {
intent := normalizeOAuthIntent(c.Query("intent"))
setDingTalkCookie(c, dingTalkOAuthIntentCookieName, encodeCookieValue(intent), dingTalkOAuthCookieMaxAgeSec, secureCookie)
captureOAuthPromoCode(c, secureCookie)
setOAuthPendingBrowserCookie(c, browserSessionKey, secureCookie)
clearOAuthPendingSessionCookie(c, secureCookie)
@@ -318,7 +317,6 @@ func (h *AuthHandler) DingTalkOAuthCallback(c *gin.Context) {
clearDingTalkCookie(c, dingTalkOAuthStateCookieName, secureCookie)
clearDingTalkCookie(c, dingTalkOAuthRedirectCookie, secureCookie)
clearDingTalkCookie(c, dingTalkOAuthIntentCookieName, secureCookie)
clearOAuthPromoCodeCookie(c, secureCookie)
}()
expectedState, err := readCookieDecoded(c, dingTalkOAuthStateCookieName)
@@ -781,15 +779,7 @@ func (h *AuthHandler) CompleteDingTalkOAuthRegistration(c *gin.Context) {
response.ErrorFrom(c, err)
return
}
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPairAndPromoCode(
c.Request.Context(),
email,
username,
req.InvitationCode,
req.AffCode,
pendingOAuthPromoCode(session),
"dingtalk",
)
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode, req.AffCode, "dingtalk")
if err != nil {
response.ErrorFrom(c, err)
return
+1 -10
View File
@@ -78,7 +78,6 @@ func (h *AuthHandler) emailOAuthStart(c *gin.Context, provider string) {
emailOAuthSetCookie(c, emailOAuthStateCookieName, encodeCookieValue(state), secureCookie)
emailOAuthSetCookie(c, emailOAuthRedirectCookie, encodeCookieValue(redirectTo), secureCookie)
emailOAuthSetCookie(c, emailOAuthProviderCookie, encodeCookieValue(provider), secureCookie)
captureOAuthPromoCode(c, secureCookie)
if affCode := strings.TrimSpace(firstNonEmpty(c.Query("aff_code"), c.Query("aff"))); affCode != "" {
emailOAuthSetCookie(c, emailOAuthAffiliateCookie, encodeCookieValue(affCode), secureCookie)
} else {
@@ -120,7 +119,6 @@ func (h *AuthHandler) emailOAuthCallback(c *gin.Context, provider string) {
emailOAuthClearCookie(c, emailOAuthRedirectCookie, secureCookie)
emailOAuthClearCookie(c, emailOAuthProviderCookie, secureCookie)
emailOAuthClearCookie(c, emailOAuthAffiliateCookie, secureCookie)
clearOAuthPromoCodeCookie(c, secureCookie)
}()
expectedState, err := readCookieDecoded(c, emailOAuthStateCookieName)
if err != nil || expectedState == "" || expectedState != state {
@@ -183,13 +181,7 @@ func (h *AuthHandler) emailOAuthCallbackWithProfile(
return
}
tokenPair, user, err := h.authService.LoginOrRegisterVerifiedEmailOAuthWithSignupCodes(
c.Request.Context(),
input,
"",
affiliateCode,
readOAuthPromoCode(c),
)
tokenPair, user, err := h.authService.LoginOrRegisterVerifiedEmailOAuthWithInvitation(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 {
@@ -435,7 +427,6 @@ func (h *AuthHandler) completeEmailOAuthRegistration(c *gin.Context, provider st
response.ErrorFrom(c, infraerrors.InternalServer("PENDING_AUTH_BIND_APPLY_FAILED", "failed to consume pending oauth session").WithCause(err))
return
}
h.authService.ApplyOAuthSignupPromoCode(c.Request.Context(), user.ID, pendingOAuthPromoCode(session))
h.authService.RecordSuccessfulLogin(c.Request.Context(), user.ID)
clearCookies()
writeOAuthTokenPairResponse(c, tokenPair)
@@ -184,49 +184,6 @@ func TestEmailOAuthCallbackCreatesPasswordRegistrationSessionForNewEmail(t *test
require.Equal(t, "aff-user@example.com", completion["resolved_email"])
}
func TestEmailOAuthStartPreservesPromoCodeInPendingSession(t *testing.T) {
handler, client := newOAuthPendingFlowTestHandlerWithDependencies(t, oauthPendingFlowTestHandlerOptions{
settingValues: map[string]string{
service.SettingKeyGitHubOAuthEnabled: "true",
service.SettingKeyGitHubOAuthClientID: "github-client",
service.SettingKeyGitHubOAuthClientSecret: "github-secret",
service.SettingKeyGitHubOAuthRedirectURL: "https://app.example/api/v1/auth/oauth/github/callback",
},
})
ctx := context.Background()
startRecorder := httptest.NewRecorder()
startCtx, _ := gin.CreateTestContext(startRecorder)
startCtx.Request = httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/github/start?promo_code=WELCOME2024", nil)
handler.GitHubOAuthStart(startCtx)
require.Equal(t, http.StatusFound, startRecorder.Code)
promoCookie := findCookie(startRecorder.Result().Cookies(), oauthPromoCodeCookieName)
require.NotNil(t, promoCookie)
require.Equal(t, "WELCOME2024", decodeCookieValueForTest(t, promoCookie.Value))
callbackRecorder := httptest.NewRecorder()
callbackCtx, _ := gin.CreateTestContext(callbackRecorder)
callbackReq := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/github/callback", nil)
callbackReq.AddCookie(promoCookie)
callbackCtx.Request = callbackReq
handler.emailOAuthCallbackWithProfile(callbackCtx, "github", config.EmailOAuthProviderConfig{
FrontendRedirectURL: "/auth/oauth/callback",
}, "/auth/oauth/callback", "/dashboard", &emailOAuthProfile{
Subject: "github-promo-user",
Email: "promo-user@example.com",
EmailVerified: true,
Username: "promo-user",
})
require.Equal(t, http.StatusFound, callbackRecorder.Code)
session, err := client.PendingAuthSession.Query().Only(ctx)
require.NoError(t, err)
require.Equal(t, "WELCOME2024", pendingOAuthPromoCode(session))
}
func TestCompleteEmailOAuthRegistrationUsesAffiliateCodeFromPendingSession(t *testing.T) {
affiliateRepo := newOAuthEmailAffiliateRepoStub(map[string]int64{"AFF456": 2002})
handler, client := newOAuthPendingFlowTestHandlerWithDependencies(t, oauthPendingFlowTestHandlerOptions{
+2 -20
View File
@@ -110,7 +110,6 @@ func (h *AuthHandler) LinuxDoOAuthStart(c *gin.Context) {
setCookie(c, linuxDoOAuthRedirectCookie, encodeCookieValue(redirectTo), linuxDoOAuthCookieMaxAgeSec, secureCookie)
intent := normalizeOAuthIntent(c.Query("intent"))
setCookie(c, linuxDoOAuthIntentCookieName, encodeCookieValue(intent), linuxDoOAuthCookieMaxAgeSec, secureCookie)
captureOAuthPromoCode(c, secureCookie)
setOAuthPendingBrowserCookie(c, browserSessionKey, secureCookie)
clearOAuthPendingSessionCookie(c, secureCookie)
if intent == oauthIntentBindCurrentUser {
@@ -183,7 +182,6 @@ func (h *AuthHandler) LinuxDoOAuthCallback(c *gin.Context) {
clearCookie(c, linuxDoOAuthRedirectCookie, secureCookie)
clearCookie(c, linuxDoOAuthIntentCookieName, secureCookie)
clearCookie(c, linuxDoOAuthBindUserCookieName, secureCookie)
clearOAuthPromoCodeCookie(c, secureCookie)
}()
expectedState, err := readCookieDecoded(c, linuxDoOAuthStateCookieName)
@@ -331,15 +329,7 @@ func (h *AuthHandler) LinuxDoOAuthCallback(c *gin.Context) {
redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err))
return
}
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPairAndPromoCode(
c.Request.Context(),
email,
username,
"",
"",
readOAuthPromoCode(c),
"linuxdo",
)
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, "", "", "linuxdo")
if err == nil {
if err := applyPendingOAuthBinding(
c.Request.Context(),
@@ -571,15 +561,7 @@ func (h *AuthHandler) CompleteLinuxDoOAuthRegistration(c *gin.Context) {
response.ErrorFrom(c, err)
return
}
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPairAndPromoCode(
c.Request.Context(),
email,
username,
req.InvitationCode,
req.AffCode,
pendingOAuthPromoCode(session),
"linuxdo",
)
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode, req.AffCode, "linuxdo")
if err != nil {
response.ErrorFrom(c, err)
return
@@ -32,12 +32,10 @@ const (
oauthPendingBrowserCookieName = "oauth_pending_browser_session"
oauthPendingSessionCookiePath = "/api/v1/auth/oauth"
oauthPendingSessionCookieName = "oauth_pending_session"
oauthPromoCodeCookieName = "oauth_promo_code"
oauthPendingCookieMaxAgeSec = 10 * 60
oauthPendingChoiceStep = "choose_account_action_required"
oauthCompletionResponseKey = "completion_response"
oauthPromoCodeStateKey = "promo_code"
)
var pendingOAuthCreateAccountPreCommitHook func(context.Context, *dbent.PendingAuthSession) error
@@ -163,53 +161,6 @@ func readOAuthPendingSessionCookie(c *gin.Context) (string, error) {
return readCookieDecoded(c, oauthPendingSessionCookieName)
}
func captureOAuthPromoCode(c *gin.Context, secure bool) {
promoCode := strings.TrimSpace(c.Query("promo_code"))
if promoCode == "" {
clearOAuthPromoCodeCookie(c, secure)
return
}
http.SetCookie(c.Writer, &http.Cookie{
Name: oauthPromoCodeCookieName,
Value: encodeCookieValue(promoCode),
Path: oauthPendingBrowserCookiePath,
MaxAge: oauthPendingCookieMaxAgeSec,
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
})
}
func clearOAuthPromoCodeCookie(c *gin.Context, secure bool) {
http.SetCookie(c.Writer, &http.Cookie{
Name: oauthPromoCodeCookieName,
Value: "",
Path: oauthPendingBrowserCookiePath,
MaxAge: -1,
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
})
}
func readOAuthPromoCode(c *gin.Context) string {
if c == nil {
return ""
}
promoCode, err := readCookieDecoded(c, oauthPromoCodeCookieName)
if err != nil {
return ""
}
return strings.TrimSpace(promoCode)
}
func pendingOAuthPromoCode(session *dbent.PendingAuthSession) string {
if session == nil {
return ""
}
return pendingSessionStringValue(session.LocalFlowState, oauthPromoCodeStateKey)
}
func redirectToFrontendCallback(c *gin.Context, frontendCallback string) {
u, err := url.Parse(frontendCallback)
if err != nil {
@@ -232,13 +183,6 @@ func (h *AuthHandler) createOAuthPendingSession(c *gin.Context, payload oauthPen
return err
}
localFlowState := map[string]any{
oauthCompletionResponseKey: payload.CompletionResponse,
}
if promoCode := readOAuthPromoCode(c); promoCode != "" {
localFlowState[oauthPromoCodeStateKey] = promoCode
}
session, err := svc.CreatePendingSession(c.Request.Context(), service.CreatePendingAuthSessionInput{
Intent: strings.TrimSpace(payload.Intent),
Identity: payload.Identity,
@@ -247,7 +191,9 @@ func (h *AuthHandler) createOAuthPendingSession(c *gin.Context, payload oauthPen
RedirectTo: strings.TrimSpace(payload.RedirectTo),
BrowserSessionKey: strings.TrimSpace(payload.BrowserSessionKey),
UpstreamIdentityClaims: payload.UpstreamIdentityClaims,
LocalFlowState: localFlowState,
LocalFlowState: map[string]any{
oauthCompletionResponseKey: payload.CompletionResponse,
},
})
if err != nil {
slog.Error("pending auth session create failed",
@@ -1874,7 +1820,6 @@ func (h *AuthHandler) createPendingOAuthAccount(c *gin.Context, provider string)
return
}
h.authService.ApplyOAuthSignupPromoCode(c.Request.Context(), user.ID, pendingOAuthPromoCode(session))
h.authService.RecordSuccessfulLogin(c.Request.Context(), user.ID)
// createPendingOAuthAccount = 注册新账户,需要把钉钉昵称同步到 users.username 作为初始值
h.maybeSyncDingTalkAfterRegistration(c.Request.Context(), session, user.ID)
@@ -8,7 +8,6 @@ import (
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
@@ -1050,201 +1049,6 @@ func TestCreateOIDCOAuthAccountCreatesUserBindsIdentityAndConsumesSession(t *tes
require.NotNil(t, storedSession.ConsumedAt)
}
func TestCreateOIDCOAuthAccountAppliesPromoCodeFromPendingSession(t *testing.T) {
promoRepo := newOAuthPendingFlowPromoRepoStub("WELCOME2024", 25)
emailCache := &oauthPendingFlowEmailCacheStub{
verificationCodes: map[string]*service.VerificationCodeData{
"promo@example.com": {
Code: "246810",
CreatedAt: time.Now().UTC(),
ExpiresAt: time.Now().UTC().Add(15 * time.Minute),
},
},
}
handler, client := newOAuthPendingFlowTestHandlerWithDependencies(t, oauthPendingFlowTestHandlerOptions{
emailVerifyEnabled: true,
emailCache: emailCache,
promoRepo: promoRepo,
settingValues: map[string]string{
service.SettingKeyPromoCodeEnabled: "true",
},
})
ctx := context.Background()
session, err := client.PendingAuthSession.Create().
SetSessionToken("promo-create-account-session-token").
SetIntent(oauthIntentLogin).
SetProviderType("oidc").
SetProviderKey("https://issuer.example").
SetProviderSubject("oidc-promo-123").
SetBrowserSessionKey("promo-create-account-browser-key").
SetUpstreamIdentityClaims(map[string]any{"username": "promo_user"}).
SetLocalFlowState(map[string]any{oauthPromoCodeStateKey: "WELCOME2024"}).
SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
Save(ctx)
require.NoError(t, err)
body := bytes.NewBufferString(`{"email":"promo@example.com","verify_code":"246810","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(session.BrowserSessionKey)})
ginCtx.Request = req
handler.CreateOIDCOAuthAccount(ginCtx)
require.Equal(t, http.StatusOK, recorder.Code)
require.Equal(t, []string{"WELCOME2024"}, promoRepo.applyCalls)
createdUser, err := client.User.Query().Where(dbuser.EmailEQ("promo@example.com")).Only(ctx)
require.NoError(t, err)
require.Equal(t, 25.0, createdUser.Balance)
require.Len(t, promoRepo.usages, 1)
require.Equal(t, createdUser.ID, promoRepo.usages[0].UserID)
}
func TestCreateOIDCOAuthAccountWithoutPromoCodeDoesNotApplyPromo(t *testing.T) {
promoRepo := newOAuthPendingFlowPromoRepoStub("WELCOME2024", 25)
emailCache := &oauthPendingFlowEmailCacheStub{
verificationCodes: map[string]*service.VerificationCodeData{
"no-promo@example.com": {
Code: "246810",
CreatedAt: time.Now().UTC(),
ExpiresAt: time.Now().UTC().Add(15 * time.Minute),
},
},
}
handler, client := newOAuthPendingFlowTestHandlerWithDependencies(t, oauthPendingFlowTestHandlerOptions{
emailVerifyEnabled: true,
emailCache: emailCache,
promoRepo: promoRepo,
settingValues: map[string]string{
service.SettingKeyPromoCodeEnabled: "true",
},
})
ctx := context.Background()
session, err := client.PendingAuthSession.Create().
SetSessionToken("no-promo-create-account-session-token").
SetIntent(oauthIntentLogin).
SetProviderType("oidc").
SetProviderKey("https://issuer.example").
SetProviderSubject("oidc-no-promo-123").
SetBrowserSessionKey("no-promo-create-account-browser-key").
SetUpstreamIdentityClaims(map[string]any{"username": "no_promo_user"}).
SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
Save(ctx)
require.NoError(t, err)
body := bytes.NewBufferString(`{"email":"no-promo@example.com","verify_code":"246810","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(session.BrowserSessionKey)})
ginCtx.Request = req
handler.CreateOIDCOAuthAccount(ginCtx)
require.Equal(t, http.StatusOK, recorder.Code)
require.Empty(t, promoRepo.applyCalls)
createdUser, err := client.User.Query().Where(dbuser.EmailEQ("no-promo@example.com")).Only(ctx)
require.NoError(t, err)
require.Zero(t, createdUser.Balance)
}
func TestCreateOIDCOAuthAccountDoesNotApplyPromoWhenDisabled(t *testing.T) {
promoRepo := newOAuthPendingFlowPromoRepoStub("WELCOME2024", 25)
emailCache := &oauthPendingFlowEmailCacheStub{
verificationCodes: map[string]*service.VerificationCodeData{
"promo-disabled@example.com": {
Code: "246810",
CreatedAt: time.Now().UTC(),
ExpiresAt: time.Now().UTC().Add(15 * time.Minute),
},
},
}
handler, client := newOAuthPendingFlowTestHandlerWithDependencies(t, oauthPendingFlowTestHandlerOptions{
emailVerifyEnabled: true,
emailCache: emailCache,
promoRepo: promoRepo,
settingValues: map[string]string{
service.SettingKeyPromoCodeEnabled: "false",
},
})
ctx := context.Background()
session, err := client.PendingAuthSession.Create().
SetSessionToken("promo-disabled-session-token").
SetIntent(oauthIntentLogin).
SetProviderType("oidc").
SetProviderKey("https://issuer.example").
SetProviderSubject("oidc-promo-disabled-123").
SetBrowserSessionKey("promo-disabled-browser-key").
SetUpstreamIdentityClaims(map[string]any{"username": "promo_disabled_user"}).
SetLocalFlowState(map[string]any{oauthPromoCodeStateKey: "WELCOME2024"}).
SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
Save(ctx)
require.NoError(t, err)
body := bytes.NewBufferString(`{"email":"promo-disabled@example.com","verify_code":"246810","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(session.BrowserSessionKey)})
ginCtx.Request = req
handler.CreateOIDCOAuthAccount(ginCtx)
require.Equal(t, http.StatusOK, recorder.Code)
require.Empty(t, promoRepo.applyCalls)
createdUser, err := client.User.Query().Where(dbuser.EmailEQ("promo-disabled@example.com")).Only(ctx)
require.NoError(t, err)
require.Zero(t, createdUser.Balance)
}
func TestOAuthExistingUserLoginDoesNotApplyPromoCode(t *testing.T) {
promoRepo := newOAuthPendingFlowPromoRepoStub("WELCOME2024", 25)
handler, client := newOAuthPendingFlowTestHandlerWithDependencies(t, oauthPendingFlowTestHandlerOptions{
promoRepo: promoRepo,
settingValues: map[string]string{
service.SettingKeyPromoCodeEnabled: "true",
},
})
ctx := context.Background()
existingUser, err := client.User.Create().
SetEmail("existing-promo@example.com").
SetUsername("existing").
SetPasswordHash("hash").
SetRole(service.RoleUser).
SetStatus(service.StatusActive).
SetBalance(7).
Save(ctx)
require.NoError(t, err)
_, loggedInUser, err := handler.authService.LoginOrRegisterOAuthWithTokenPairAndPromoCode(
ctx,
existingUser.Email,
existingUser.Username,
"",
"",
"WELCOME2024",
"oidc",
)
require.NoError(t, err)
require.Equal(t, existingUser.ID, loggedInUser.ID)
require.Empty(t, promoRepo.applyCalls)
reloadedUser, err := client.User.Get(ctx, existingUser.ID)
require.NoError(t, err)
require.Equal(t, 7.0, reloadedUser.Balance)
}
func TestCreateOIDCOAuthAccountExistingEmailReturnsChoicePendingSessionState(t *testing.T) {
handler, client := newOAuthPendingFlowTestHandlerWithEmailVerification(t, false, "owner@example.com", "135790")
ctx := context.Background()
@@ -1374,58 +1178,6 @@ func TestCreateOIDCOAuthAccountExistingEmailNormalizesLegacySpacingAndCase(t *te
require.Equal(t, "owner@example.com", storedSession.ResolvedEmail)
}
func TestCreateOIDCOAuthAccountRejectsEmailOutsideRegistrationSuffixWhitelist(t *testing.T) {
handler, client := newOAuthPendingFlowTestHandlerWithDependencies(t, oauthPendingFlowTestHandlerOptions{
emailVerifyEnabled: true,
emailCache: &oauthPendingFlowEmailCacheStub{
verificationCodes: map[string]*service.VerificationCodeData{
"foo@gmail.com": {
Code: "135790",
CreatedAt: time.Now().UTC(),
ExpiresAt: time.Now().UTC().Add(15 * time.Minute),
},
},
},
settingValues: map[string]string{
service.SettingKeyRegistrationEmailSuffixWhitelist: `["@qq.com"]`,
},
})
ctx := context.Background()
session, err := client.PendingAuthSession.Create().
SetSessionToken("suffix-whitelist-session-token").
SetIntent("login").
SetProviderType("oidc").
SetProviderKey("https://issuer.example").
SetProviderSubject("oidc-suffix-whitelist-123").
SetBrowserSessionKey("suffix-whitelist-browser-session-key").
SetUpstreamIdentityClaims(map[string]any{
"username": "oidc_user",
}).
SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
Save(ctx)
require.NoError(t, err)
body := bytes.NewBufferString(`{"email":"foo@gmail.com","verify_code":"135790","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("suffix-whitelist-browser-session-key")})
ginCtx.Request = req
handler.CreateOIDCOAuthAccount(ginCtx)
require.Equal(t, http.StatusBadRequest, recorder.Code)
payload := decodeJSONBody(t, recorder)
require.Equal(t, "EMAIL_SUFFIX_NOT_ALLOWED", payload["reason"])
count, err := client.User.Query().Where(dbuser.EmailEQ("foo@gmail.com")).Count(ctx)
require.NoError(t, err)
require.Zero(t, count)
}
func TestSendPendingOAuthVerifyCodeExistingEmailReturnsBindLoginState(t *testing.T) {
handler, client := newOAuthPendingFlowTestHandlerWithEmailVerification(t, false, "owner@example.com", "135790")
ctx := context.Background()
@@ -2368,7 +2120,6 @@ type oauthPendingFlowTestHandlerOptions struct {
emailVerifyEnabled bool
emailCache service.EmailCache
settingValues map[string]string
promoRepo service.PromoCodeRepository
defaultSubAssigner service.DefaultSubscriptionAssigner
affiliateService *service.AffiliateService
affiliateFactory func(*dbent.Client, *service.SettingService) *service.AffiliateService
@@ -2461,10 +2212,6 @@ CREATE TABLE IF NOT EXISTS user_affiliates (
options: options.userRepoOptions,
}
redeemRepo := &oauthPendingFlowRedeemCodeRepo{client: client}
var promoService *service.PromoService
if options.promoRepo != nil {
promoService = service.NewPromoService(options.promoRepo, userRepo, nil, client, nil)
}
var emailService *service.EmailService
if options.emailCache != nil {
emailService = service.NewEmailService(&oauthPendingFlowSettingRepoStub{
@@ -2483,7 +2230,7 @@ CREATE TABLE IF NOT EXISTS user_affiliates (
emailService,
nil,
nil,
promoService,
nil,
options.defaultSubAssigner,
affiliateService,
nil,
@@ -2503,11 +2250,10 @@ CREATE TABLE IF NOT EXISTS user_affiliates (
}
return &AuthHandler{
authService: authSvc,
userService: userSvc,
settingSvc: settingSvc,
promoService: promoService,
totpService: totpSvc,
authService: authSvc,
userService: userSvc,
settingSvc: settingSvc,
totpService: totpSvc,
}, client
}
@@ -2526,84 +2272,6 @@ type oauthPendingFlowSettingRepoStub struct {
values map[string]string
}
type oauthPendingFlowPromoRepoStub struct {
promo *service.PromoCode
applyCalls []string
usages []service.PromoCodeUsage
}
func newOAuthPendingFlowPromoRepoStub(code string, bonusAmount float64) *oauthPendingFlowPromoRepoStub {
return &oauthPendingFlowPromoRepoStub{
promo: &service.PromoCode{
ID: 1,
Code: code,
BonusAmount: bonusAmount,
Status: service.PromoCodeStatusActive,
},
}
}
func (r *oauthPendingFlowPromoRepoStub) Create(context.Context, *service.PromoCode) error {
panic("unexpected Create call")
}
func (r *oauthPendingFlowPromoRepoStub) GetByID(context.Context, int64) (*service.PromoCode, error) {
panic("unexpected GetByID call")
}
func (r *oauthPendingFlowPromoRepoStub) GetByCode(_ context.Context, code string) (*service.PromoCode, error) {
if r.promo == nil || !strings.EqualFold(strings.TrimSpace(code), r.promo.Code) {
return nil, service.ErrPromoCodeNotFound
}
clone := *r.promo
return &clone, nil
}
func (r *oauthPendingFlowPromoRepoStub) GetByCodeForUpdate(ctx context.Context, code string) (*service.PromoCode, error) {
promoCode, err := r.GetByCode(ctx, code)
if err != nil {
return nil, err
}
r.applyCalls = append(r.applyCalls, strings.TrimSpace(code))
return promoCode, nil
}
func (r *oauthPendingFlowPromoRepoStub) Update(context.Context, *service.PromoCode) error {
panic("unexpected Update call")
}
func (r *oauthPendingFlowPromoRepoStub) Delete(context.Context, int64) error {
panic("unexpected Delete call")
}
func (r *oauthPendingFlowPromoRepoStub) List(context.Context, pagination.PaginationParams) ([]service.PromoCode, *pagination.PaginationResult, error) {
panic("unexpected List call")
}
func (r *oauthPendingFlowPromoRepoStub) ListWithFilters(context.Context, pagination.PaginationParams, string, string) ([]service.PromoCode, *pagination.PaginationResult, error) {
panic("unexpected ListWithFilters call")
}
func (r *oauthPendingFlowPromoRepoStub) CreateUsage(_ context.Context, usage *service.PromoCodeUsage) error {
r.usages = append(r.usages, *usage)
return nil
}
func (r *oauthPendingFlowPromoRepoStub) GetUsageByPromoCodeAndUser(context.Context, int64, int64) (*service.PromoCodeUsage, error) {
return nil, nil
}
func (r *oauthPendingFlowPromoRepoStub) ListUsagesByPromoCode(context.Context, int64, pagination.PaginationParams) ([]service.PromoCodeUsage, *pagination.PaginationResult, error) {
panic("unexpected ListUsagesByPromoCode call")
}
func (r *oauthPendingFlowPromoRepoStub) IncrementUsedCount(context.Context, int64) error {
if r.promo != nil {
r.promo.UsedCount++
}
return nil
}
func (s *oauthPendingFlowSettingRepoStub) Get(context.Context, string) (*service.Setting, error) {
return nil, service.ErrSettingNotFound
}
@@ -3145,12 +2813,8 @@ func (r *oauthPendingFlowUserRepo) ListWithFilters(context.Context, pagination.P
panic("unexpected ListWithFilters call")
}
func (r *oauthPendingFlowUserRepo) UpdateBalance(ctx context.Context, userID int64, amount float64) error {
client := r.client
if tx := dbent.TxFromContext(ctx); tx != nil {
client = tx.Client()
}
return client.User.UpdateOneID(userID).AddBalance(amount).Exec(ctx)
func (r *oauthPendingFlowUserRepo) UpdateBalance(context.Context, int64, float64) error {
panic("unexpected UpdateBalance call")
}
func (r *oauthPendingFlowUserRepo) DeductBalance(context.Context, int64, float64) error {
+2 -18
View File
@@ -143,7 +143,6 @@ func (h *AuthHandler) OIDCOAuthStart(c *gin.Context) {
oidcSetCookie(c, oidcOAuthRedirectCookie, encodeCookieValue(redirectTo), oidcOAuthCookieMaxAgeSec, secureCookie)
intent := normalizeOAuthIntent(c.Query("intent"))
oidcSetCookie(c, oidcOAuthIntentCookieName, encodeCookieValue(intent), oidcOAuthCookieMaxAgeSec, secureCookie)
captureOAuthPromoCode(c, secureCookie)
setOAuthPendingBrowserCookie(c, browserSessionKey, secureCookie)
clearOAuthPendingSessionCookie(c, secureCookie)
if intent == oauthIntentBindCurrentUser {
@@ -227,7 +226,6 @@ func (h *AuthHandler) OIDCOAuthCallback(c *gin.Context) {
oidcClearCookie(c, oidcOAuthNonceCookie, secureCookie)
oidcClearCookie(c, oidcOAuthIntentCookieName, secureCookie)
oidcClearCookie(c, oidcOAuthBindUserCookieName, secureCookie)
clearOAuthPromoCodeCookie(c, secureCookie)
}()
expectedState, err := readCookieDecoded(c, oidcOAuthStateCookieName)
@@ -687,15 +685,7 @@ func (h *AuthHandler) CompleteOIDCOAuthRegistration(c *gin.Context) {
response.ErrorFrom(c, err)
return
}
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPairAndPromoCode(
c.Request.Context(),
email,
username,
req.InvitationCode,
req.AffCode,
pendingOAuthPromoCode(session),
"oidc",
)
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode, req.AffCode, "oidc")
if err != nil {
response.ErrorFrom(c, err)
return
@@ -1268,13 +1258,7 @@ func (h *AuthHandler) tryOIDCVerifiedEmailFastPath(
AvatarURL: pendingSessionStringValue(upstreamClaims, "suggested_avatar_url"),
UpstreamMetadata: upstreamMetadata,
}
tokenPair, _, err := h.authService.LoginOrRegisterVerifiedEmailOAuthWithSignupCodes(
ctx,
input,
"",
"",
readOAuthPromoCode(c),
)
tokenPair, _, err := h.authService.LoginOrRegisterVerifiedEmailOAuthWithInvitation(ctx, input, "", "")
if err != nil {
log.Printf("[OIDC OAuth] verified-email fast path skipped: reason=%s", infraerrors.Reason(err))
return false
+1 -11
View File
@@ -125,7 +125,6 @@ func (h *AuthHandler) WeChatOAuthStart(c *gin.Context) {
wechatSetCookie(c, wechatOAuthRedirectCookieName, encodeCookieValue(redirectTo), wechatOAuthCookieMaxAgeSec, secureCookie)
wechatSetCookie(c, wechatOAuthIntentCookieName, encodeCookieValue(intent), wechatOAuthCookieMaxAgeSec, secureCookie)
wechatSetCookie(c, wechatOAuthModeCookieName, encodeCookieValue(cfg.mode), wechatOAuthCookieMaxAgeSec, secureCookie)
captureOAuthPromoCode(c, secureCookie)
setOAuthPendingBrowserCookie(c, browserSessionKey, secureCookie)
clearOAuthPendingSessionCookie(c, secureCookie)
if intent == oauthIntentBindCurrentUser {
@@ -172,7 +171,6 @@ func (h *AuthHandler) WeChatOAuthCallback(c *gin.Context) {
wechatClearCookie(c, wechatOAuthIntentCookieName, secureCookie)
wechatClearCookie(c, wechatOAuthModeCookieName, secureCookie)
wechatClearCookie(c, wechatOAuthBindUserCookieName, secureCookie)
clearOAuthPromoCodeCookie(c, secureCookie)
}()
expectedState, err := readCookieDecoded(c, wechatOAuthStateCookieName)
@@ -550,15 +548,7 @@ func (h *AuthHandler) CompleteWeChatOAuthRegistration(c *gin.Context) {
return
}
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPairAndPromoCode(
c.Request.Context(),
email,
username,
req.InvitationCode,
req.AffCode,
pendingOAuthPromoCode(session),
"wechat",
)
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode, req.AffCode, "wechat")
if err != nil {
response.ErrorFrom(c, err)
return
@@ -10,12 +10,6 @@ import (
const statusClientClosedRequest = 499
func concurrencyErrorResponse(err error, slotType string) (int, string, string) {
var waitQueueFullErr *WaitQueueFullError
if errors.As(err, &waitQueueFullErr) {
return http.StatusTooManyRequests, "rate_limit_error",
"Too many pending requests, please retry later"
}
var concurrencyErr *ConcurrencyError
if errors.As(err, &concurrencyErr) {
if concurrencyErr.SlotType != "" {
@@ -2,7 +2,6 @@ package handler
import (
"context"
"net/http"
"strings"
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
@@ -12,19 +11,10 @@ import (
"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
}
// 本文件保留 WebSocket 调用点(openai_gateway_handler.go ResponsesWebSocket 的
// turn-1 首帧与 turn≥2 每消息审核)仍在使用的内容审核 helper。
// HTTP 调用点已迁移至 pre-flight 钩子链(见 gateway_preflight.go,SEAM-DESIGN 裁决 H:
// WS 两点因每消息语义 + WS 帧错误格式排除在链改造之外,保持现状)。
func contentModerationErrorCode(decision *service.ContentModerationDecision) string {
return "content_policy_violation"
+8 -22
View File
@@ -178,24 +178,14 @@ type SystemSettings struct {
BackendModeEnabled bool `json:"backend_mode_enabled"`
// Gateway forwarding behavior
EnableFingerprintUnification bool `json:"enable_fingerprint_unification"`
EnableMetadataPassthrough bool `json:"enable_metadata_passthrough"`
EnableCCHSigning bool `json:"enable_cch_signing"`
EnableClaudeOAuthSystemPromptInjection bool `json:"enable_claude_oauth_system_prompt_injection"`
ClaudeOAuthSystemPrompt string `json:"claude_oauth_system_prompt"`
ClaudeOAuthSystemPromptBlocks string `json:"claude_oauth_system_prompt_blocks"`
EnableAnthropicCacheTTL1hInjection bool `json:"enable_anthropic_cache_ttl_1h_injection"`
RewriteMessageCacheControl bool `json:"rewrite_message_cache_control"`
AntigravityUserAgentVersion string `json:"antigravity_user_agent_version"`
OpenAICodexUserAgent string `json:"openai_codex_user_agent"`
// codex_cli_only 加固
MinCodexVersion string `json:"min_codex_version"`
MaxCodexVersion string `json:"max_codex_version"`
CodexCLIOnlyBlacklist string `json:"codex_cli_only_blacklist"`
CodexCLIOnlyWhitelist string `json:"codex_cli_only_whitelist"`
CodexCLIOnlyAllowAppServerClients bool `json:"codex_cli_only_allow_app_server_clients"`
CodexCLIOnlyEngineFingerprintSignals string `json:"codex_cli_only_engine_fingerprint_signals"`
EnableFingerprintUnification bool `json:"enable_fingerprint_unification"`
EnableMetadataPassthrough bool `json:"enable_metadata_passthrough"`
EnableCCHSigning bool `json:"enable_cch_signing"`
EnableAnthropicCacheTTL1hInjection bool `json:"enable_anthropic_cache_ttl_1h_injection"`
RewriteMessageCacheControl bool `json:"rewrite_message_cache_control"`
AntigravityUserAgentVersion string `json:"antigravity_user_agent_version"`
OpenAICodexUserAgent string `json:"openai_codex_user_agent"`
OpenAIAllowClaudeCodeCodexPlugin bool `json:"openai_allow_claude_code_codex_plugin"`
// Web Search Emulation
WebSearchEmulationEnabled bool `json:"web_search_emulation_enabled"`
@@ -254,10 +244,6 @@ type SystemSettings struct {
// 风控中心功能开关
RiskControlEnabled bool `json:"risk_control_enabled"`
// cyber 会话屏蔽开关 + TTL
CyberSessionBlockEnabled bool `json:"cyber_session_block_enabled"`
CyberSessionBlockTTLSeconds int `json:"cyber_session_block_ttl_seconds"`
// Affiliate (邀请返利) feature switch
AffiliateEnabled bool `json:"affiliate_enabled"`
+1 -1
View File
@@ -77,7 +77,7 @@ func DeriveUpstreamEndpoint(inbound, rawRequestPath, platform string) string {
inbound = strings.TrimSpace(inbound)
switch platform {
case service.PlatformOpenAI, service.PlatformGrok:
case service.PlatformOpenAI:
if inbound == EndpointEmbeddings || inbound == EndpointImagesGenerations || inbound == EndpointImagesEdits {
return inbound
}
+75 -93
View File
@@ -15,6 +15,8 @@ import (
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/domain"
"github.com/Wei-Shaw/sub2api/internal/gatewayhook"
"github.com/Wei-Shaw/sub2api/internal/gatewayplatform"
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
@@ -25,7 +27,6 @@ import (
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
@@ -42,13 +43,14 @@ type GatewayHandler struct {
gatewayService *service.GatewayService
geminiCompatService *service.GeminiMessagesCompatService
antigravityGatewayService *service.AntigravityGatewayService
platformRegistry *gatewayplatform.Registry
userService *service.UserService
billingCacheService *service.BillingCacheService
usageService *service.UsageService
apiKeyService *service.APIKeyService
usageRecordWorkerPool *service.UsageRecordWorkerPool
errorPassthroughService *service.ErrorPassthroughService
contentModerationService *service.ContentModerationService
preFlightHooks *gatewayhook.Chain
concurrencyHelper *ConcurrencyHelper
userMsgQueueHelper *UserMsgQueueHelper
maxAccountSwitches int
@@ -62,6 +64,7 @@ func NewGatewayHandler(
gatewayService *service.GatewayService,
geminiCompatService *service.GeminiMessagesCompatService,
antigravityGatewayService *service.AntigravityGatewayService,
platformRegistry *gatewayplatform.Registry,
userService *service.UserService,
concurrencyService *service.ConcurrencyService,
billingCacheService *service.BillingCacheService,
@@ -69,7 +72,7 @@ func NewGatewayHandler(
apiKeyService *service.APIKeyService,
usageRecordWorkerPool *service.UsageRecordWorkerPool,
errorPassthroughService *service.ErrorPassthroughService,
contentModerationService *service.ContentModerationService,
preFlightHooks *gatewayhook.Chain,
userMsgQueueService *service.UserMessageQueueService,
cfg *config.Config,
settingService *service.SettingService,
@@ -97,13 +100,14 @@ func NewGatewayHandler(
gatewayService: gatewayService,
geminiCompatService: geminiCompatService,
antigravityGatewayService: antigravityGatewayService,
platformRegistry: platformRegistry,
userService: userService,
billingCacheService: billingCacheService,
usageService: usageService,
apiKeyService: apiKeyService,
usageRecordWorkerPool: usageRecordWorkerPool,
errorPassthroughService: errorPassthroughService,
contentModerationService: contentModerationService,
preFlightHooks: preFlightHooks,
concurrencyHelper: NewConcurrencyHelper(concurrencyService, SSEPingFormatClaude, pingInterval),
userMsgQueueHelper: umqHelper,
maxAccountSwitches: maxAccountSwitches,
@@ -170,7 +174,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
// 设置 max_tokens=1 + haiku 探测请求标识到 context 中
// 必须在 SetClaudeCodeClientContext 之前设置,因为 ClaudeCodeValidator 需要读取此标识进行绕过判断
if isMaxTokensOneHaikuRequest(reqModel, parsedReq.MaxTokens) {
if isMaxTokensOneHaikuRequest(reqModel, parsedReq.MaxTokens, reqStream) {
ctx := service.WithIsMaxTokensOneHaikuRequest(c.Request.Context(), true, h.metadataBridgeEnabled())
c.Request = c.Request.WithContext(ctx)
}
@@ -196,8 +200,8 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
return
}
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)
if decision := h.runPreFlightHooks(c, reqLog, apiKey, subject, service.ContentModerationProtocolAnthropicMessages, reqModel, body); decision != nil && decision.Blocked {
h.errorResponse(c, preFlightStatus(decision), preFlightErrorCode(decision), decision.Message)
return
}
@@ -212,6 +216,28 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
// 获取订阅信息(可能为nil)- 提前获取用于后续检查
subscription, _ := middleware2.GetSubscriptionFromContext(c)
// 0. 检查wait队列是否已满
maxWait := service.CalculateMaxWait(subject.Concurrency)
canWait, err := h.concurrencyHelper.IncrementWaitCount(c.Request.Context(), subject.UserID, maxWait)
waitCounted := false
if err != nil {
reqLog.Warn("gateway.user_wait_counter_increment_failed", zap.Error(err))
// On error, allow request to proceed
} else if !canWait {
reqLog.Info("gateway.user_wait_queue_full", zap.Int("max_wait", maxWait))
h.errorResponse(c, http.StatusTooManyRequests, "rate_limit_error", "Too many pending requests, please retry later")
return
}
if err == nil && canWait {
waitCounted = true
}
// Ensure we decrement if we exit before acquiring the user slot.
defer func() {
if waitCounted {
h.concurrencyHelper.DecrementWaitCount(c.Request.Context(), subject.UserID)
}
}()
// 1. 首先获取用户并发槽位
userReleaseFunc, err := h.concurrencyHelper.AcquireUserSlotWithWait(c, subject.UserID, subject.Concurrency, reqStream, &streamStarted)
if err != nil {
@@ -219,6 +245,11 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
h.handleConcurrencyError(c, err, "user", streamStarted)
return
}
// User slot acquired: no longer waiting in the queue.
if waitCounted {
h.concurrencyHelper.DecrementWaitCount(c.Request.Context(), subject.UserID)
waitCounted = false
}
// 在请求结束或 Context 取消时确保释放槽位,避免客户端断开造成泄漏
userReleaseFunc = wrapReleaseOnDone(c.Request.Context(), userReleaseFunc)
if userReleaseFunc != nil {
@@ -302,22 +333,14 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), apiKey.GroupID, sessionKey, reqModel, fs.FailedAccountIDs, "", int64(0)) // Gemini 不使用会话限制
if err != nil {
if len(fs.FailedAccountIDs) == 0 {
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel, service.PlatformGemini)
if !cls.ModelNotFound {
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
}
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
reqLog.Warn("gateway.select_account_no_available",
zap.String("model", reqModel),
zap.Int64p("group_id", apiKey.GroupID),
zap.String("platform", platform),
zap.Bool("model_not_found", cls.ModelNotFound),
zap.Error(err),
)
message := cls.Message
if !cls.ModelNotFound {
message = "No available accounts: " + err.Error()
}
h.handleStreamingAwareError(c, cls.Status, cls.ErrType, message, streamStarted)
h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available accounts: "+err.Error(), streamStarted)
return
}
action := fs.HandleSelectionExhausted(c.Request.Context())
@@ -342,7 +365,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
// 检查请求拦截(预热请求、SUGGESTION MODE等)
if account.IsInterceptWarmupEnabled() {
interceptType := detectInterceptType(body, reqModel, parsedReq.MaxTokens, isClaudeCodeClient)
interceptType := detectInterceptType(body, reqModel, parsedReq.MaxTokens, reqStream, isClaudeCodeClient)
if interceptType != InterceptTypeNone {
if selection.Acquired && selection.ReleaseFunc != nil {
selection.ReleaseFunc()
@@ -423,16 +446,20 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
// 记录 Forward 前已写入字节数,Forward 后若增加则说明 SSE 内容已发,禁止 failover
writerSizeBeforeForward := c.Writer.Size()
if account.Platform == service.PlatformAntigravity {
result, err = h.antigravityGatewayService.ForwardGemini(
// gemini provider → AntigravityGatewayService.ForwardGemini:
// model/stream 经 Parsed 透传(与原 reqModel/reqStream 同源),
// action 由 adapter 硬编码 "generateContent"。
result, err = h.platformRegistry.Get(service.PlatformGemini).Forward(
requestCtx,
c,
account,
reqModel,
"generateContent",
reqStream,
body,
hasBoundSession,
service.WithForwardGeminiSession(derefGroupID(apiKey.GroupID), sessionKey),
&gatewayplatform.ForwardRequest{
Parsed: parsedReq,
Body: body,
IsStickySession: hasBoundSession,
SessionGroupID: derefGroupID(apiKey.GroupID),
SessionKey: sessionKey,
},
)
} else {
result, err = h.geminiCompatService.Forward(requestCtx, c, account, body)
@@ -505,16 +532,6 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
if result.ReasoningEffort == nil {
result.ReasoningEffort = service.NormalizeClaudeOutputEffort(parsedReq.OutputEffort)
}
// 国产模型 thinking-enabled 默认 effort 填充:Kimi/GLM/MiniMax 这些不支持 effort 档位的
// passback-required 上游,仅要 thinking 启用且 OutputEffort 未明确传递时,在 usage_log 写 "high"
// 避免该字段长期为 NULL(详见 DefaultEffortForThinkingEnabled 文档)。
if result.ReasoningEffort == nil && parsedReq.ThinkingEnabled {
protocolModel := result.UpstreamModel
if protocolModel == "" {
protocolModel = result.Model
}
result.ReasoningEffort = service.DefaultEffortForThinkingEnabled(protocolModel)
}
// 使用量记录通过有界 worker 池提交,避免请求热路径创建无界 goroutine。
// ForceCacheBilling 提前拍成标量,避免 worker 闭包保活 failover 状态里的响应体。
@@ -587,23 +604,15 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), currentAPIKey.GroupID, sessionKey, reqModel, fs.FailedAccountIDs, parsedReq.MetadataUserID, subject.UserID)
if err != nil {
if len(fs.FailedAccountIDs) == 0 {
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, currentAPIKey, reqModel, reqModel, platform)
if !cls.ModelNotFound {
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
}
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
reqLog.Warn("gateway.select_account_no_available",
zap.String("model", reqModel),
zap.Int64p("group_id", currentAPIKey.GroupID),
zap.String("platform", platform),
zap.Bool("fallback_used", fallbackUsed),
zap.Bool("model_not_found", cls.ModelNotFound),
zap.Error(err),
)
message := cls.Message
if !cls.ModelNotFound {
message = "No available accounts: " + err.Error()
}
h.handleStreamingAwareError(c, cls.Status, cls.ErrType, message, streamStarted)
h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available accounts: "+err.Error(), streamStarted)
return
}
action := fs.HandleSelectionExhausted(c.Request.Context())
@@ -638,7 +647,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
// 检查请求拦截(预热请求、SUGGESTION MODE等)
if account.IsInterceptWarmupEnabled() {
interceptType := detectInterceptType(body, reqModel, parsedReq.MaxTokens, isClaudeCodeClient)
interceptType := detectInterceptType(body, reqModel, parsedReq.MaxTokens, reqStream, isClaudeCodeClient)
if interceptType != InterceptTypeNone {
if selection.Acquired && selection.ReleaseFunc != nil {
selection.ReleaseFunc()
@@ -790,10 +799,17 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
}
// 记录 Forward 前已写入字节数,Forward 后若增加则说明 SSE 内容已发,禁止 failover
writerSizeBeforeForward := c.Writer.Size()
// 平台 → Provider 的映射逻辑保留在调用点:antigravity 平台的
// APIKey 账号走 anthropic provider(Claude 协议直连上游)。
forwardReq := &gatewayplatform.ForwardRequest{
Parsed: attemptParsedReq,
Body: attemptBody,
IsStickySession: hasBoundSession,
}
if account.Platform == service.PlatformAntigravity && account.Type != service.AccountTypeAPIKey {
result, err = h.antigravityGatewayService.Forward(requestCtx, c, account, attemptBody, hasBoundSession)
result, err = h.platformRegistry.Get(service.PlatformAntigravity).Forward(requestCtx, c, account, forwardReq)
} else {
result, err = h.gatewayService.Forward(requestCtx, c, account, attemptParsedReq)
result, err = h.platformRegistry.Get(service.PlatformAnthropic).Forward(requestCtx, c, account, forwardReq)
}
// 兜底释放串行锁(正常情况已通过回调提前释放)
@@ -937,14 +953,6 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
if result.ReasoningEffort == nil {
result.ReasoningEffort = service.NormalizeClaudeOutputEffort(attemptParsedReq.OutputEffort)
}
// 同上(重试路径中的对称填充)。详见非重试路径同名注释。
if result.ReasoningEffort == nil && attemptParsedReq.ThinkingEnabled {
protocolModel := result.UpstreamModel
if protocolModel == "" {
protocolModel = result.Model
}
result.ReasoningEffort = service.DefaultEffortForThinkingEnabled(protocolModel)
}
// 使用量记录通过有界 worker 池提交,避免请求热路径创建无界 goroutine。
// ForceCacheBilling 提前拍成标量,避免 worker 闭包保活 failover 状态里的响应体。
@@ -1142,31 +1150,7 @@ func customModelsListAllowsModel(availablePatterns []string, model string) bool
}
func defaultModelIDsForPlatform(platform string) []string {
switch platform {
case service.PlatformOpenAI:
return openai.DefaultModelIDs()
case service.PlatformGemini:
ids := make([]string, 0, len(geminicli.DefaultModels))
for _, model := range geminicli.DefaultModels {
ids = append(ids, model.ID)
}
return ids
case service.PlatformAntigravity:
models := antigravity.DefaultModels()
ids := make([]string, 0, len(models))
for _, model := range models {
ids = append(ids, model.ID)
}
return ids
case service.PlatformGrok:
return xai.DefaultModelIDs()
default:
ids := make([]string, 0, len(claude.DefaultModels))
for _, model := range claude.DefaultModels {
ids = append(ids, model.ID)
}
return ids
}
return service.DefaultModelIDsForPlatform(platform)
}
// AntigravityModels 返回 Antigravity 支持的全部模型
@@ -1804,11 +1788,8 @@ func (h *GatewayHandler) CountTokens(c *gin.Context) {
account, err := h.gatewayService.SelectAccountForModel(c.Request.Context(), apiKey.GroupID, sessionHash, parsedReq.Model)
if err != nil {
reqLog.Warn("gateway.count_tokens_select_account_failed", zap.Error(err))
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, parsedReq.Model, parsedReq.Model, service.PlatformAnthropic)
if !cls.ModelNotFound {
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
}
h.errorResponse(c, cls.Status, cls.ErrType, cls.Message)
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
h.errorResponse(c, http.StatusServiceUnavailable, "api_error", "Service temporarily unavailable")
return
}
setOpsSelectedAccount(c, account.ID, account.Platform)
@@ -1837,10 +1818,10 @@ func isHaikuModel(model string) bool {
}
// isMaxTokensOneHaikuRequest 检查是否为 max_tokens=1 + haiku 模型的探测请求
// 这类请求用于 Claude Code 验证 API 连通性(流式/非流式均会出现,如 cc-switch v3.9.0 起的健康检查探测为流式)
// 条件:max_tokens == 1 且 model 包含 "haiku"
func isMaxTokensOneHaikuRequest(model string, maxTokens int) bool {
return maxTokens == 1 && isHaikuModel(model)
// 这类请求用于 Claude Code 验证 API 连通性
// 条件:max_tokens == 1 且 model 包含 "haiku" 且非流式请求
func isMaxTokensOneHaikuRequest(model string, maxTokens int, isStream bool) bool {
return maxTokens == 1 && isHaikuModel(model) && !isStream
}
// detectInterceptType 检测请求是否需要拦截,返回拦截类型
@@ -1848,10 +1829,11 @@ func isMaxTokensOneHaikuRequest(model string, maxTokens int) bool {
// - body: 请求体字节
// - model: 请求的模型名称
// - maxTokens: max_tokens 值
// - isStream: 是否为流式请求
// - isClaudeCodeClient: 是否已通过 Claude Code 客户端校验
func detectInterceptType(body []byte, model string, maxTokens int, isClaudeCodeClient bool) InterceptType {
// 优先检查 max_tokens=1 + haiku 探测请求(流式/非流式均适用)
if isClaudeCodeClient && isMaxTokensOneHaikuRequest(model, maxTokens) {
func detectInterceptType(body []byte, model string, maxTokens int, isStream bool, isClaudeCodeClient bool) InterceptType {
// 优先检查 max_tokens=1 + haiku 探测请求(仅非流式)
if isClaudeCodeClient && isMaxTokensOneHaikuRequest(model, maxTokens, isStream) {
return InterceptTypeMaxTokensOneHaiku
}
@@ -95,8 +95,8 @@ 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)
if decision := h.runPreFlightHooks(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIChat, reqModel, body); decision != nil && decision.Blocked {
h.chatCompletionsErrorResponse(c, preFlightStatus(decision), preFlightErrorCode(decision), decision.Message)
return
}
@@ -109,12 +109,35 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) {
service.SetOpsLatencyMs(c, service.OpsAuthLatencyMsKey, time.Since(requestStart).Milliseconds())
// 1. Acquire user concurrency slot
maxWait := service.CalculateMaxWait(subject.Concurrency)
canWait, err := h.concurrencyHelper.IncrementWaitCount(c.Request.Context(), subject.UserID, maxWait)
waitCounted := false
if err != nil {
reqLog.Warn("gateway.cc.user_wait_counter_increment_failed", zap.Error(err))
} else if !canWait {
h.chatCompletionsErrorResponse(c, http.StatusTooManyRequests, "rate_limit_error", "Too many pending requests, please retry later")
return
}
if err == nil && canWait {
waitCounted = true
}
defer func() {
if waitCounted {
h.concurrencyHelper.DecrementWaitCount(c.Request.Context(), subject.UserID)
}
}()
userReleaseFunc, err := h.concurrencyHelper.AcquireUserSlotWithWait(c, subject.UserID, subject.Concurrency, reqStream, &streamStarted)
if err != nil {
reqLog.Warn("gateway.cc.user_slot_acquire_failed", zap.Error(err))
h.handleConcurrencyError(c, err, "user", streamStarted)
return
}
if waitCounted {
h.concurrencyHelper.DecrementWaitCount(c.Request.Context(), subject.UserID)
waitCounted = false
}
userReleaseFunc = wrapReleaseOnDone(c.Request.Context(), userReleaseFunc)
if userReleaseFunc != nil {
defer userReleaseFunc()
@@ -162,15 +185,8 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) {
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), apiKey.GroupID, selectionSessionHash, reqModel, fs.FailedAccountIDs, "", int64(0))
if err != nil {
if len(fs.FailedAccountIDs) == 0 {
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel, groupPlatform)
if !cls.ModelNotFound {
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
}
message := cls.Message
if !cls.ModelNotFound {
message = "No available accounts: " + err.Error()
}
h.chatCompletionsErrorResponse(c, cls.Status, cls.ErrType, message)
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
h.chatCompletionsErrorResponse(c, http.StatusServiceUnavailable, "api_error", "No available accounts: "+err.Error())
return
}
action := fs.HandleSelectionExhausted(c.Request.Context())
@@ -14,10 +14,10 @@ import (
func TestDetectInterceptType_MaxTokensOneHaikuRequiresClaudeCodeClient(t *testing.T) {
body := []byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`)
notClaudeCode := detectInterceptType(body, "claude-haiku-4-5", 1, false)
notClaudeCode := detectInterceptType(body, "claude-haiku-4-5", 1, false, false)
require.Equal(t, InterceptTypeNone, notClaudeCode)
isClaudeCode := detectInterceptType(body, "claude-haiku-4-5", 1, true)
isClaudeCode := detectInterceptType(body, "claude-haiku-4-5", 1, false, true)
require.Equal(t, InterceptTypeMaxTokensOneHaiku, isClaudeCode)
}
@@ -30,7 +30,7 @@ func TestDetectInterceptType_SuggestionModeUnaffected(t *testing.T) {
"system":[]
}`)
got := detectInterceptType(body, "claude-sonnet-4-5", 256, false)
got := detectInterceptType(body, "claude-sonnet-4-5", 256, false, false)
require.Equal(t, InterceptTypeSuggestionMode, got)
}
@@ -104,8 +104,8 @@ 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)
if decision := h.runPreFlightHooks(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIResponses, reqModel, body); decision != nil && decision.Blocked {
h.responsesErrorResponse(c, preFlightStatus(decision), preFlightErrorCode(decision), decision.Message)
return
}
@@ -118,12 +118,35 @@ func (h *GatewayHandler) Responses(c *gin.Context) {
service.SetOpsLatencyMs(c, service.OpsAuthLatencyMsKey, time.Since(requestStart).Milliseconds())
// 1. Acquire user concurrency slot
maxWait := service.CalculateMaxWait(subject.Concurrency)
canWait, err := h.concurrencyHelper.IncrementWaitCount(c.Request.Context(), subject.UserID, maxWait)
waitCounted := false
if err != nil {
reqLog.Warn("gateway.responses.user_wait_counter_increment_failed", zap.Error(err))
} else if !canWait {
h.responsesErrorResponse(c, http.StatusTooManyRequests, "rate_limit_error", "Too many pending requests, please retry later")
return
}
if err == nil && canWait {
waitCounted = true
}
defer func() {
if waitCounted {
h.concurrencyHelper.DecrementWaitCount(c.Request.Context(), subject.UserID)
}
}()
userReleaseFunc, err := h.concurrencyHelper.AcquireUserSlotWithWait(c, subject.UserID, subject.Concurrency, reqStream, &streamStarted)
if err != nil {
reqLog.Warn("gateway.responses.user_slot_acquire_failed", zap.Error(err))
h.handleConcurrencyError(c, err, "user", streamStarted)
return
}
if waitCounted {
h.concurrencyHelper.DecrementWaitCount(c.Request.Context(), subject.UserID)
waitCounted = false
}
userReleaseFunc = wrapReleaseOnDone(c.Request.Context(), userReleaseFunc)
if userReleaseFunc != nil {
defer userReleaseFunc()
@@ -160,15 +183,8 @@ func (h *GatewayHandler) Responses(c *gin.Context) {
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(requestCtx, apiKey.GroupID, sessionHash, reqModel, fs.FailedAccountIDs, "", int64(0))
if err != nil {
if len(fs.FailedAccountIDs) == 0 {
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel, service.PlatformAnthropic)
if !cls.ModelNotFound {
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
}
message := cls.Message
if !cls.ModelNotFound {
message = "No available accounts: " + err.Error()
}
h.responsesErrorResponse(c, cls.Status, cls.ErrType, message)
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
h.responsesErrorResponse(c, http.StatusServiceUnavailable, "api_error", "No available accounts: "+err.Error())
return
}
action := fs.HandleSelectionExhausted(requestCtx)
+1 -26
View File
@@ -127,14 +127,6 @@ func (e *ConcurrencyError) Error() string {
return fmt.Sprintf("%s concurrency limit reached", e.SlotType)
}
type WaitQueueFullError struct {
SlotType string
}
func (e *WaitQueueFullError) Error() string {
return "Too many pending requests, please retry later"
}
// ConcurrencyHelper provides common concurrency slot management for gateway handlers
type ConcurrencyHelper struct {
concurrencyService *service.ConcurrencyService
@@ -228,10 +220,6 @@ func (h *ConcurrencyHelper) TryAcquireAccountSlot(ctx context.Context, accountID
// For streaming requests, sends ping events during the wait.
// streamStarted is updated if streaming response has begun.
func (h *ConcurrencyHelper) AcquireUserSlotWithWait(c *gin.Context, userID int64, maxConcurrency int, isStream bool, streamStarted *bool) (func(), error) {
return h.acquireUserSlotWithWaitTimeout(c, userID, maxConcurrency, maxConcurrencyWait, isStream, streamStarted)
}
func (h *ConcurrencyHelper) acquireUserSlotWithWaitTimeout(c *gin.Context, userID int64, maxConcurrency int, timeout time.Duration, isStream bool, streamStarted *bool) (func(), error) {
ctx := c.Request.Context()
// Try to acquire immediately
@@ -244,21 +232,8 @@ func (h *ConcurrencyHelper) acquireUserSlotWithWaitTimeout(c *gin.Context, userI
return releaseFunc, nil
}
queueLimit := service.CalculateMaxWait(maxConcurrency) - maxConcurrency
if queueLimit < 1 {
queueLimit = 1
}
canWait, err := h.IncrementWaitCount(ctx, userID, queueLimit)
if err != nil {
return nil, err
}
if !canWait {
return nil, &WaitQueueFullError{SlotType: "user"}
}
defer h.DecrementWaitCount(ctx, userID)
// Need to wait - handle streaming ping if needed
return h.waitForSlotWithPingTimeout(c, "user", userID, maxConcurrency, timeout, isStream, streamStarted, false)
return h.waitForSlotWithPing(c, "user", userID, maxConcurrency, isStream, streamStarted)
}
// AcquireAccountSlotWithWait acquires an account concurrency slot, waiting if necessary.
@@ -24,11 +24,6 @@ type helperConcurrencyCacheStub struct {
userAcquireCalls int
accountReleaseCalls int
userReleaseCalls int
waitAllowed bool
waitIncrementCalls int
waitDecrementCalls int
waitMaxWait int
waitIncrementHook func()
}
func (s *helperConcurrencyCacheStub) AcquireAccountSlot(ctx context.Context, accountID int64, maxConcurrency int, requestID string) (bool, error) {
@@ -98,25 +93,10 @@ func (s *helperConcurrencyCacheStub) GetUserConcurrency(ctx context.Context, use
}
func (s *helperConcurrencyCacheStub) IncrementWaitCount(ctx context.Context, userID int64, maxWait int) (bool, error) {
s.mu.Lock()
s.waitIncrementCalls++
s.waitMaxWait = maxWait
waitAllowed := s.waitAllowed
hook := s.waitIncrementHook
s.mu.Unlock()
if hook != nil {
hook()
}
if !waitAllowed {
return false, nil
}
return true, nil
}
func (s *helperConcurrencyCacheStub) DecrementWaitCount(ctx context.Context, userID int64) error {
s.mu.Lock()
defer s.mu.Unlock()
s.waitDecrementCalls++
return nil
}
@@ -246,98 +226,6 @@ func TestWaitForSlotWithPingTimeout_AccountAndUserAcquire(t *testing.T) {
})
}
func TestAcquireUserSlotWithWait_ImmediateAcquireSkipsWaitQueue(t *testing.T) {
cache := &helperConcurrencyCacheStub{
userSeq: []bool{true},
}
concurrency := service.NewConcurrencyService(cache)
helper := NewConcurrencyHelper(concurrency, SSEPingFormatNone, 5*time.Millisecond)
c, _ := newHelperTestContext(http.MethodPost, "/v1/messages")
streamStarted := false
release, err := helper.acquireUserSlotWithWaitTimeout(c, 202, 3, time.Second, false, &streamStarted)
require.NoError(t, err)
require.NotNil(t, release)
release()
require.Equal(t, 1, cache.userAcquireCalls)
require.Equal(t, 0, cache.waitIncrementCalls)
require.Equal(t, 0, cache.waitDecrementCalls)
require.Equal(t, 1, cache.userReleaseCalls)
}
func TestAcquireUserSlotWithWait_WaitSuccessDecrementsBeforeReturn(t *testing.T) {
cache := &helperConcurrencyCacheStub{
userSeq: []bool{false, true},
waitAllowed: true,
}
concurrency := service.NewConcurrencyService(cache)
helper := NewConcurrencyHelper(concurrency, SSEPingFormatNone, 5*time.Millisecond)
c, _ := newHelperTestContext(http.MethodPost, "/v1/messages")
streamStarted := false
release, err := helper.acquireUserSlotWithWaitTimeout(c, 202, 3, time.Second, false, &streamStarted)
require.NoError(t, err)
require.NotNil(t, release)
require.Equal(t, 2, cache.userAcquireCalls)
require.Equal(t, 1, cache.waitIncrementCalls)
require.Equal(t, 20, cache.waitMaxWait)
require.Equal(t, 1, cache.waitDecrementCalls)
release()
require.Equal(t, 1, cache.userReleaseCalls)
}
func TestAcquireUserSlotWithWait_TimeoutDecrementsWaitQueue(t *testing.T) {
cache := &helperConcurrencyCacheStub{
userSeq: []bool{false, false, false},
waitAllowed: true,
}
concurrency := service.NewConcurrencyService(cache)
helper := NewConcurrencyHelper(concurrency, SSEPingFormatNone, 5*time.Millisecond)
c, _ := newHelperTestContext(http.MethodPost, "/v1/messages")
streamStarted := false
release, err := helper.acquireUserSlotWithWaitTimeout(c, 202, 3, 30*time.Millisecond, false, &streamStarted)
require.Nil(t, release)
var cErr *ConcurrencyError
require.ErrorAs(t, err, &cErr)
require.True(t, cErr.IsTimeout)
require.Equal(t, 1, cache.waitIncrementCalls)
require.Equal(t, 1, cache.waitDecrementCalls)
require.Equal(t, 0, cache.userReleaseCalls)
}
func TestAcquireUserSlotWithWait_RequestCancelDecrementsWaitQueue(t *testing.T) {
cancelled := make(chan struct{})
var cancel context.CancelFunc
cache := &helperConcurrencyCacheStub{
userSeq: []bool{false, false},
waitAllowed: true,
waitIncrementHook: func() {
cancel()
close(cancelled)
},
}
concurrency := service.NewConcurrencyService(cache)
helper := NewConcurrencyHelper(concurrency, SSEPingFormatNone, 5*time.Millisecond)
c, _ := newHelperTestContext(http.MethodPost, "/v1/messages")
reqCtx, cancelFunc := context.WithCancel(c.Request.Context())
cancel = cancelFunc
defer cancel()
c.Request = c.Request.WithContext(reqCtx)
streamStarted := false
release, err := helper.acquireUserSlotWithWaitTimeout(c, 202, 3, time.Second, false, &streamStarted)
<-cancelled
require.Nil(t, release)
require.ErrorIs(t, err, context.Canceled)
require.Equal(t, 1, cache.waitIncrementCalls)
require.Equal(t, 1, cache.waitDecrementCalls)
require.Equal(t, 0, cache.userReleaseCalls)
}
func TestWaitForSlotWithPingTimeout_TimeoutAndStreamPing(t *testing.T) {
cache := &helperConcurrencyCacheStub{
accountSeq: []bool{false, false, false},
@@ -0,0 +1,367 @@
//go:build unit
// Phase-0 TASK-002 特征化测试:网关拦截链路(INVARIANTS I-7.1 / I-7.2)。
// 通过完整的 GatewayHandler.Messages / CountTokens 入口驱动,断言客户端最终
// 收到的状态码与错误体:
// - I-7.1 内容审核拦截:403 + content_policy_violation 错误体;审核服务失败 = 放行(fail-open);
// - I-7.2 Claude Code 版本检查:低版本 400 + 升级提示;/count_tokens 路径豁免。
//
// 复用 gateway_handler_warmup_intercept_unit_test.go 的 newTestGatewayHandler 夹具:
// antigravity 账号 intercept_warmup_requests=true 时,Warmup 请求在转发上游前被
// mock 拦截返回 200,使"请求被放行"成为可观测结果(无需真实上游)。
//
// 注意:Claude Code 版本上下限走 service 包内全局 60s TTL 缓存
// (setting_service.go versionBoundsCache)。本文件所有版本测试固定使用同一组
// 上下限(min=1.5.0,无 max),避免同进程内缓存交叉污染。
package handler
import (
"bytes"
"context"
"encoding/json"
"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/pkg/pagination"
middleware "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"
)
// passCharSettingRepo 是 service.SettingRepository 的内存实现。
type passCharSettingRepo struct {
values map[string]string
}
func (r *passCharSettingRepo) Get(_ context.Context, key string) (*service.Setting, error) {
if v, ok := r.values[key]; ok {
return &service.Setting{Key: key, Value: v}, nil
}
return nil, service.ErrSettingNotFound
}
func (r *passCharSettingRepo) GetValue(_ context.Context, key string) (string, error) {
if v, ok := r.values[key]; ok {
return v, nil
}
return "", service.ErrSettingNotFound
}
func (r *passCharSettingRepo) Set(_ context.Context, key, value string) error {
if r.values == nil {
r.values = map[string]string{}
}
r.values[key] = value
return nil
}
func (r *passCharSettingRepo) GetMultiple(_ context.Context, keys []string) (map[string]string, error) {
out := map[string]string{}
for _, key := range keys {
if v, ok := r.values[key]; ok {
out[key] = v
}
}
return out, nil
}
func (r *passCharSettingRepo) SetMultiple(_ context.Context, settings map[string]string) error {
for k, v := range settings {
_ = r.Set(context.Background(), k, v)
}
return nil
}
func (r *passCharSettingRepo) GetAll(_ context.Context) (map[string]string, error) {
out := make(map[string]string, len(r.values))
for k, v := range r.values {
out[k] = v
}
return out, nil
}
func (r *passCharSettingRepo) Delete(_ context.Context, key string) error {
delete(r.values, key)
return nil
}
// passCharModerationRepo 是 service.ContentModerationRepository 的空操作实现。
type passCharModerationRepo struct{}
func (r *passCharModerationRepo) CreateLog(context.Context, *service.ContentModerationLog) error {
return nil
}
func (r *passCharModerationRepo) ListLogs(context.Context, service.ContentModerationLogFilter) ([]service.ContentModerationLog, *pagination.PaginationResult, error) {
return nil, nil, nil
}
func (r *passCharModerationRepo) CountFlaggedByUserSince(context.Context, int64, time.Time) (int, error) {
return 0, nil
}
func (r *passCharModerationRepo) CleanupExpiredLogs(context.Context, time.Time, time.Time) (*service.ContentModerationCleanupResult, error) {
return &service.ContentModerationCleanupResult{}, nil
}
// passCharInterceptFixture 构造完整的 Messages 链路夹具(拦截预热账号 + 上下文注入)。
func passCharInterceptFixture(t *testing.T) (*GatewayHandler, func()) {
t.Helper()
groupID := int64(7001)
accountID := int64(7101)
group := &service.Group{
ID: groupID,
Hydrated: true,
Platform: service.PlatformAnthropic,
Status: service.StatusActive,
}
account := &service.Account{
ID: accountID,
Name: "pass-char-intercept",
Platform: service.PlatformAntigravity,
Type: service.AccountTypeOAuth,
Credentials: map[string]any{
"access_token": "tok_char",
"intercept_warmup_requests": true,
},
Extra: map[string]any{
"mixed_scheduling": true,
},
Concurrency: 1,
Priority: 1,
Status: service.StatusActive,
Schedulable: true,
AccountGroups: []service.AccountGroup{{AccountID: accountID, GroupID: groupID}},
}
return newTestGatewayHandler(t, group, []*service.Account{account})
}
// passCharNewMessagesContext 构造带认证上下文的 /v1/messages 请求。
func passCharNewMessagesContext(t *testing.T, path string, body []byte) (*gin.Context, *httptest.ResponseRecorder) {
t.Helper()
gin.SetMode(gin.TestMode)
groupID := int64(7001)
group := &service.Group{
ID: groupID,
Hydrated: true,
Platform: service.PlatformAnthropic,
Status: service.StatusActive,
}
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(context.WithValue(req.Context(), ctxkey.Group, group))
c.Request = req
apiKey := &service.APIKey{
ID: 7301,
UserID: 7401,
GroupID: &groupID,
Status: service.StatusActive,
User: &service.User{
ID: 7401,
Concurrency: 10,
Balance: 100,
},
Group: group,
}
c.Set(string(middleware.ContextKeyAPIKey), apiKey)
c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: apiKey.UserID, Concurrency: 10})
return c, rec
}
// passCharWarmupBody 返回触发预热拦截的最小请求体。
func passCharWarmupBody() []byte {
return []byte(`{
"model": "claude-sonnet-4-5",
"max_tokens": 256,
"messages": [{"role":"user","content":[{"type":"text","text":"Warmup"}]}]
}`)
}
// passCharClaudeCodeWarmupBody 返回能通过 Claude Code 客户端校验且触发预热拦截的请求体。
func passCharClaudeCodeWarmupBody() []byte {
deviceID := strings.Repeat("a", 64)
return []byte(`{
"model": "claude-sonnet-4-5",
"max_tokens": 256,
"system": [{"type":"text","text":"You are Claude Code, Anthropic's official CLI for Claude."}],
"metadata": {"user_id": "user_` + deviceID + `_account__session_01234567-89ab-cdef-0123-456789abcdef"},
"messages": [{"role":"user","content":[{"type":"text","text":"Warmup"}]}]
}`)
}
// passCharSetClaudeCodeHeaders 设置 Claude Code 客户端校验所需的请求头。
func passCharSetClaudeCodeHeaders(c *gin.Context, ua string) {
c.Request.Header.Set("User-Agent", ua)
c.Request.Header.Set("X-App", "cli")
c.Request.Header.Set("anthropic-beta", "claude-code-20250219")
c.Request.Header.Set("anthropic-version", "2023-06-01")
}
// passCharModerationService 构造指向 mock 审核 API 的内容审核服务。
func passCharModerationService(t *testing.T, moderationBaseURL string) *service.ContentModerationService {
t.Helper()
cfgJSON, err := json.Marshal(map[string]any{
"enabled": true,
"mode": "pre_block",
"base_url": moderationBaseURL,
"api_keys": []string{"sk-audit"},
"sample_rate": 100,
"all_groups": true,
"auto_ban_enabled": false,
"email_on_hit": false,
"retry_count": 0,
"timeout_ms": 2000,
})
require.NoError(t, err)
settingRepo := &passCharSettingRepo{values: map[string]string{
service.SettingKeyRiskControlEnabled: "true",
service.SettingKeyContentModerationConfig: string(cfgJSON),
}}
return service.NewContentModerationService(settingRepo, &passCharModerationRepo{}, nil, nil, nil, nil, nil)
}
// passCharRequireWarmupMock 断言响应为预热拦截 mock(请求被放行并完成)。
func passCharRequireWarmupMock(t *testing.T, rec *httptest.ResponseRecorder) {
t.Helper()
require.Equal(t, http.StatusOK, rec.Code)
var resp map[string]any
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
require.Equal(t, "msg_mock_warmup", resp["id"])
}
// TestGatewayCharacterization_ContentModerationBlock 固化 I-7.1 拦截侧:
// 审核 API 判定命中(pre_block 模式)时,客户端收到 403 +
// {"type":"error","error":{"type":"content_policy_violation","message":<配置的拦截文案>}}。
func TestGatewayCharacterization_ContentModerationBlock(t *testing.T) {
moderationSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"results":[{"flagged":true,"category_scores":{"sexual":0.99}}]}`))
}))
defer moderationSrv.Close()
h, cleanup := passCharInterceptFixture(t)
defer cleanup()
h.preFlightHooks = ProvideGatewayHookChain(passCharModerationService(t, moderationSrv.URL))
c, rec := passCharNewMessagesContext(t, "/v1/messages", passCharWarmupBody())
h.Messages(c)
require.Equal(t, http.StatusForbidden, rec.Code, "内容审核命中应返回 403")
require.JSONEq(t,
`{"type":"error","error":{"type":"content_policy_violation","message":"内容审计命中风险规则,请调整输入后重试"}}`,
rec.Body.String())
}
// TestGatewayCharacterization_ContentModerationFailOpen 固化 I-7.1 fail-open 侧:
// 审核 API 自身失败(HTTP 500)时请求必须放行——客户端收到正常业务响应
// (此处为预热拦截 mock 200),而不是 403。
func TestGatewayCharacterization_ContentModerationFailOpen(t *testing.T) {
moderationSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "moderation backend exploded", http.StatusInternalServerError)
}))
defer moderationSrv.Close()
h, cleanup := passCharInterceptFixture(t)
defer cleanup()
h.preFlightHooks = ProvideGatewayHookChain(passCharModerationService(t, moderationSrv.URL))
c, rec := passCharNewMessagesContext(t, "/v1/messages", passCharWarmupBody())
h.Messages(c)
passCharRequireWarmupMock(t, rec)
}
// passCharVersionBoundSettingService 返回固定 min=1.5.0(无 max)的 SettingService。
// 注意:版本上下限有进程内全局 60s 缓存,本文件所有版本测试统一使用该配置。
func passCharVersionBoundSettingService() *service.SettingService {
repo := &passCharSettingRepo{values: map[string]string{
service.SettingKeyMinClaudeCodeVersion: "1.5.0",
}}
return service.NewSettingService(repo, &config.Config{})
}
// TestGatewayCharacterization_ClaudeCodeVersionCheck 固化 I-7.2 主路径:
// Claude Code 客户端(UA claude-cli/x.y.z + 完整客户端特征)在 /v1/messages 上:
// - CLI 版本低于最低要求 → 400 invalid_request_error + 升级提示;
// - CLI 版本满足要求 → 请求放行(预热拦截 mock 200)。
func TestGatewayCharacterization_ClaudeCodeVersionCheck(t *testing.T) {
t.Run("低版本_400升级提示", func(t *testing.T) {
h, cleanup := passCharInterceptFixture(t)
defer cleanup()
h.settingService = passCharVersionBoundSettingService()
c, rec := passCharNewMessagesContext(t, "/v1/messages", passCharClaudeCodeWarmupBody())
passCharSetClaudeCodeHeaders(c, "claude-cli/1.0.0 (external)")
h.Messages(c)
require.Equal(t, http.StatusBadRequest, rec.Code)
var resp struct {
Type string `json:"type"`
Error struct {
Type string `json:"type"`
Message string `json:"message"`
} `json:"error"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
require.Equal(t, "error", resp.Type)
require.Equal(t, "invalid_request_error", resp.Error.Type)
require.Contains(t, resp.Error.Message, "Your Claude Code version (1.0.0) is below the minimum required version (1.5.0)")
require.Contains(t, resp.Error.Message, "npm update -g @anthropic-ai/claude-code", "错误信息应包含升级指引")
})
t.Run("版本达标_请求放行", func(t *testing.T) {
h, cleanup := passCharInterceptFixture(t)
defer cleanup()
h.settingService = passCharVersionBoundSettingService()
c, rec := passCharNewMessagesContext(t, "/v1/messages", passCharClaudeCodeWarmupBody())
passCharSetClaudeCodeHeaders(c, "claude-cli/2.0.0 (external)")
h.Messages(c)
passCharRequireWarmupMock(t, rec)
})
}
// TestGatewayCharacterization_ClaudeCodeVersionCheck_CountTokensExempt 固化 I-7.2 豁免侧:
// /v1/messages/count_tokens 不做版本检查——低版本 Claude Code CLI 不会收到版本 400,
// 请求继续走 count_tokens 业务(antigravity 账号当前返回 404 not_found_error)。
func TestGatewayCharacterization_ClaudeCodeVersionCheck_CountTokensExempt(t *testing.T) {
h, cleanup := passCharInterceptFixture(t)
defer cleanup()
h.settingService = passCharVersionBoundSettingService()
body := []byte(`{"model":"claude-sonnet-4-5","messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`)
c, rec := passCharNewMessagesContext(t, "/v1/messages/count_tokens", body)
passCharSetClaudeCodeHeaders(c, "claude-cli/1.0.0 (external)")
h.CountTokens(c)
var resp struct {
Type string `json:"type"`
Error struct {
Type string `json:"type"`
Message string `json:"message"`
} `json:"error"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
require.NotContains(t, resp.Error.Message, "below the minimum required version",
"count_tokens 必须豁免版本检查")
// 固化当前实际行为:antigravity 账号不支持 count_tokens,返回 404 not_found_error。
require.Equal(t, http.StatusNotFound, rec.Code)
require.Equal(t, "not_found_error", resp.Error.Type)
}
@@ -0,0 +1,479 @@
//go:build unit
// Phase-2 TASK-001 特征化测试:按"格式类"锁定内容审核拦截后的客户端可观测输出
// (钩子链改造 TASK-003 的实施前置 gate,调用点清单与格式类定义见
// issues/plugin-refactor/phase-2_pilots/CALLSITE-INVENTORY.md)。
//
// 覆盖(已有测试不重复):
// - B 格式 chat_completions(无顶层 type) —— gateway_handler_chat_completions.go:98
// - C 格式 responses(error.code 字符串字段) —— gateway_handler_responses.go:107
// - D 格式 gemini googleError(code int + status 串)—— gemini_v1beta_handler.go:190
// - A' 格式 openai 网关 anthropic(顶层 type:"error")—— openai_gateway_handler.go:676(格式化 :934)
// - B' 格式 openai images(与 B 字节级同形,归并覆盖 openai_gateway_handler.go:244 / openai_chat_completions.go:88,
// 三点共用 openai_gateway_handler.go:1920 同一格式化函数)—— openai_images.go:89
// - fail-open 非 anthropic 协议(chat_completions 审核服务 500 → 放行至账号调度阶段)
// - WS turn-2 每消息审核 close-error 路径 —— openai_gateway_handler.go:1426
//
// 特征化纪律:所有断言均为先驱动真实链路跑出实际行为后固化,不写应然值。
// 复用 gateway_intercept_characterization_test.go 的 passChar* 夹具与
// openai_gateway_handler_test.go 的 WS 测试基建;新增辅助统一 p2Char 前缀。
package handler
import (
"bytes"
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
middleware "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
coderws "github.com/coder/websocket"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// p2CharFlagAllModerationServer 返回对任意输入都判定命中的 mock 审核 API。
func p2CharFlagAllModerationServer(t *testing.T) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"results":[{"flagged":true,"category_scores":{"sexual":0.99}}]}`))
}))
}
// p2CharNewContext 构造带认证上下文的网关请求(任意路径/分组/请求体)。
func p2CharNewContext(t *testing.T, path string, group *service.Group, body []byte) (*gin.Context, *httptest.ResponseRecorder) {
t.Helper()
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(context.WithValue(req.Context(), ctxkey.Group, group))
c.Request = req
apiKey := &service.APIKey{
ID: 7311,
UserID: 7411,
GroupID: &group.ID,
Status: service.StatusActive,
User: &service.User{
ID: 7411,
Concurrency: 10,
Balance: 100,
},
Group: group,
}
c.Set(string(middleware.ContextKeyAPIKey), apiKey)
c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: apiKey.UserID, Concurrency: 10})
return c, rec
}
// p2CharAnthropicGroup 返回 anthropic 平台分组(GatewayHandler 系端点用)。
func p2CharAnthropicGroup() *service.Group {
return &service.Group{
ID: 7001,
Hydrated: true,
Platform: service.PlatformAnthropic,
Status: service.StatusActive,
}
}
// TestP2Characterization_ModerationBlock_ChatCompletionsFormat 锁定 B 格式:
// GatewayHandler.ChatCompletions(/v1/chat/completions)审核命中时返回 403 +
// {"error":{"type":"content_policy_violation","message":<拦截文案>}}——无顶层 type 字段。
func TestP2Characterization_ModerationBlock_ChatCompletionsFormat(t *testing.T) {
moderationSrv := p2CharFlagAllModerationServer(t)
defer moderationSrv.Close()
group := p2CharAnthropicGroup()
h, cleanup := newTestGatewayHandler(t, group, nil)
defer cleanup()
h.preFlightHooks = ProvideGatewayHookChain(passCharModerationService(t, moderationSrv.URL))
body := []byte(`{"model":"claude-sonnet-4-5","stream":false,"messages":[{"role":"user","content":"bad words"}]}`)
c, rec := p2CharNewContext(t, "/v1/chat/completions", group, body)
h.ChatCompletions(c)
require.Equal(t, http.StatusForbidden, rec.Code)
require.JSONEq(t,
`{"error":{"type":"content_policy_violation","message":"内容审计命中风险规则,请调整输入后重试"}}`,
rec.Body.String())
// 关键差异点:chat_completions 格式没有顶层 type:"error"(区别于 anthropic 格式)。
require.False(t, gjson.GetBytes(rec.Body.Bytes(), "type").Exists(),
"chat_completions 拦截体不应有顶层 type 字段")
}
// TestP2Characterization_ModerationBlock_ResponsesFormat 锁定 C 格式:
// GatewayHandler.Responses(/v1/responses)审核命中时返回 403 +
// {"error":{"code":"content_policy_violation","message":<拦截文案>}}——
// error 内是 code 字段(字符串)而非 type 字段。
func TestP2Characterization_ModerationBlock_ResponsesFormat(t *testing.T) {
moderationSrv := p2CharFlagAllModerationServer(t)
defer moderationSrv.Close()
group := p2CharAnthropicGroup()
h, cleanup := newTestGatewayHandler(t, group, nil)
defer cleanup()
h.preFlightHooks = ProvideGatewayHookChain(passCharModerationService(t, moderationSrv.URL))
body := []byte(`{"model":"claude-sonnet-4-5","input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"bad words"}]}]}`)
c, rec := p2CharNewContext(t, "/v1/responses", group, body)
h.Responses(c)
require.Equal(t, http.StatusForbidden, rec.Code)
require.JSONEq(t,
`{"error":{"code":"content_policy_violation","message":"内容审计命中风险规则,请调整输入后重试"}}`,
rec.Body.String())
// 关键差异点:responses 格式用 error.code(字符串),无 error.type、无顶层 type。
require.False(t, gjson.GetBytes(rec.Body.Bytes(), "type").Exists())
require.False(t, gjson.GetBytes(rec.Body.Bytes(), "error.type").Exists(),
"responses 拦截体 error 内不应有 type 字段(用 code)")
require.Equal(t, gjson.String, gjson.GetBytes(rec.Body.Bytes(), "error.code").Type,
"responses 拦截体 error.code 应为字符串")
}
// TestP2Characterization_ModerationBlock_GeminiGoogleErrorFormat 锁定 D 格式:
// GatewayHandler.GeminiV1BetaModels(/v1beta/models/{model}:{action})审核命中时返回 403 +
// {"error":{"code":403,"message":<拦截文案>,"status":"PERMISSION_DENIED"}}。
// 注意:该调用点不消费 contentModerationErrorCode(content_policy_violation 被丢弃)。
func TestP2Characterization_ModerationBlock_GeminiGoogleErrorFormat(t *testing.T) {
moderationSrv := p2CharFlagAllModerationServer(t)
defer moderationSrv.Close()
group := &service.Group{
ID: 7002,
Hydrated: true,
Platform: service.PlatformGemini,
Status: service.StatusActive,
}
h, cleanup := newTestGatewayHandler(t, group, nil)
defer cleanup()
h.preFlightHooks = ProvideGatewayHookChain(passCharModerationService(t, moderationSrv.URL))
body := []byte(`{"contents":[{"role":"user","parts":[{"text":"bad words"}]}]}`)
c, rec := p2CharNewContext(t, "/v1beta/models/gemini-2.5-pro:generateContent", group, body)
c.Params = gin.Params{{Key: "modelAction", Value: "/gemini-2.5-pro:generateContent"}}
h.GeminiV1BetaModels(c)
require.Equal(t, http.StatusForbidden, rec.Code)
require.JSONEq(t,
`{"error":{"code":403,"message":"内容审计命中风险规则,请调整输入后重试","status":"PERMISSION_DENIED"}}`,
rec.Body.String())
// 关键差异点:googleError 三字段——code 是 int HTTP 状态码、status 是 google 状态串;
// 无顶层 type、无 error.type,审核错误码 content_policy_violation 不出现。
parsed := gjson.ParseBytes(rec.Body.Bytes())
require.False(t, parsed.Get("type").Exists(), "googleError 不应有顶层 type 字段")
require.False(t, parsed.Get("error.type").Exists(), "googleError 的 error 内不应有 type 字段")
require.Equal(t, gjson.Number, parsed.Get("error.code").Type, "googleError 的 error.code 应为数字")
require.NotContains(t, rec.Body.String(), "content_policy_violation",
"gemini 调用点不消费 contentModerationErrorCode")
}
// p2CharOpenAIGatewayHandler 构造满足 ensureResponsesDependencies 的最小 OpenAIGatewayHandler。
// 审核拦截发生在账号调度/转发之前,故各 service 用零值即可。
// HTTP 调用点走 preFlightHooks 链;contentModerationService 同时装配以镜像生产接线
// (WS 调用点仍直接消费该字段)。
func p2CharOpenAIGatewayHandler(t *testing.T, moderationBaseURL string) *OpenAIGatewayHandler {
t.Helper()
moderationSvc := passCharModerationService(t, moderationBaseURL)
return &OpenAIGatewayHandler{
gatewayService: &service.OpenAIGatewayService{},
billingCacheService: &service.BillingCacheService{},
apiKeyService: &service.APIKeyService{},
contentModerationService: moderationSvc,
preFlightHooks: ProvideGatewayHookChain(moderationSvc),
concurrencyHelper: NewConcurrencyHelper(service.NewConcurrencyService(&concurrencyCacheMock{}), SSEPingFormatNone, time.Second),
}
}
// TestP2Characterization_ModerationBlock_OpenAIGatewayAnthropicFormat 锁定 A' 格式:
// OpenAIGatewayHandler.Messages(openai 平台分组的 /v1/messages dispatch)审核命中时返回 403 +
// {"type":"error","error":{"type":"content_policy_violation","message":<拦截文案>}}
//(openai_gateway_handler.go:934 anthropicErrorResponse 一族,与 anthropic 主格式字节级同形)。
func TestP2Characterization_ModerationBlock_OpenAIGatewayAnthropicFormat(t *testing.T) {
moderationSrv := p2CharFlagAllModerationServer(t)
defer moderationSrv.Close()
group := &service.Group{
ID: 7003,
Hydrated: true,
Platform: service.PlatformOpenAI,
Status: service.StatusActive,
AllowMessagesDispatch: true,
}
h := p2CharOpenAIGatewayHandler(t, moderationSrv.URL)
body := []byte(`{"model":"claude-sonnet-4-5","max_tokens":64,"messages":[{"role":"user","content":[{"type":"text","text":"bad words"}]}]}`)
c, rec := p2CharNewContext(t, "/v1/messages", group, body)
h.Messages(c)
require.Equal(t, http.StatusForbidden, rec.Code)
require.JSONEq(t,
`{"type":"error","error":{"type":"content_policy_violation","message":"内容审计命中风险规则,请调整输入后重试"}}`,
rec.Body.String())
// 关键差异点:openai 网关 anthropic 格式保留顶层 type:"error"。
require.Equal(t, "error", gjson.GetBytes(rec.Body.Bytes(), "type").String())
}
// TestP2Characterization_ModerationBlock_OpenAIImagesFormat 锁定 B' 格式:
// OpenAIGatewayHandler.Images(/v1/images/generations,审核入参为 parsed.ModerationBody())
// 审核命中时返回 403 + {"error":{"type":"content_policy_violation","message":<拦截文案>}}。
// 该格式与 B(chat_completions)字节级同形;openai 网关 /v1/responses(openai_gateway_handler.go:244)
// 与 /v1/chat/completions(openai_chat_completions.go:88)共用同一格式化函数
//(openai_gateway_handler.go:1920),由本测试归并覆盖(见 CALLSITE-INVENTORY.md)。
func TestP2Characterization_ModerationBlock_OpenAIImagesFormat(t *testing.T) {
moderationSrv := p2CharFlagAllModerationServer(t)
defer moderationSrv.Close()
group := &service.Group{
ID: 7004,
Hydrated: true,
Platform: service.PlatformOpenAI,
Status: service.StatusActive,
AllowImageGeneration: true,
}
h := p2CharOpenAIGatewayHandler(t, moderationSrv.URL)
body := []byte(`{"model":"gpt-image-1","prompt":"bad words"}`)
c, rec := p2CharNewContext(t, "/v1/images/generations", group, body)
h.Images(c)
require.Equal(t, http.StatusForbidden, rec.Code)
require.JSONEq(t,
`{"error":{"type":"content_policy_violation","message":"内容审计命中风险规则,请调整输入后重试"}}`,
rec.Body.String())
require.False(t, gjson.GetBytes(rec.Body.Bytes(), "type").Exists(),
"openai 网关通用错误格式不应有顶层 type 字段")
}
// TestP2Characterization_ModerationFailOpen_ChatCompletionsFormat 锁定非 anthropic 协议的
// fail-open 行为:chat_completions 链路上审核 API 自身故障(HTTP 500)时请求必须放行——
// 请求穿过审核 gate 继续进入账号调度阶段(夹具无可用账号,故观测到 503 No available accounts,
// 而不是 403 content_policy_violation)。
func TestP2Characterization_ModerationFailOpen_ChatCompletionsFormat(t *testing.T) {
moderationSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "moderation backend exploded", http.StatusInternalServerError)
}))
defer moderationSrv.Close()
group := p2CharAnthropicGroup()
h, cleanup := newTestGatewayHandler(t, group, nil)
defer cleanup()
h.preFlightHooks = ProvideGatewayHookChain(passCharModerationService(t, moderationSrv.URL))
body := []byte(`{"model":"claude-sonnet-4-5","stream":false,"messages":[{"role":"user","content":"bad words"}]}`)
c, rec := p2CharNewContext(t, "/v1/chat/completions", group, body)
h.ChatCompletions(c)
// fail-open:未被审核拦截(非 403 + content_policy_violation),
// 请求推进到账号调度阶段(夹具无账号 → 503 api_error)。
require.Equal(t, http.StatusServiceUnavailable, rec.Code)
require.NotContains(t, rec.Body.String(), "content_policy_violation")
require.Equal(t, "api_error", gjson.GetBytes(rec.Body.Bytes(), "error.type").String())
require.Contains(t, gjson.GetBytes(rec.Body.Bytes(), "error.message").String(), "No available accounts")
}
// p2CharConditionalModerationServer 返回仅当审核请求中含 marker 才判定命中的 mock 审核 API。
func p2CharConditionalModerationServer(t *testing.T, marker string) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw, _ := io.ReadAll(r.Body)
w.Header().Set("Content-Type", "application/json")
if strings.Contains(string(raw), marker) {
_, _ = w.Write([]byte(`{"results":[{"flagged":true,"category_scores":{"sexual":0.99}}]}`))
return
}
_, _ = w.Write([]byte(`{"results":[{"flagged":false,"category_scores":{"sexual":0.0}}]}`))
}))
}
// TestP2Characterization_OpenAIResponsesWSTurn2ModerationCloseError 锁定 E 格式 turn-2 路径
// (openai_gateway_handler.go:1426,OpenAIWSIngressHooks.BeforeRequest 的 turn≥2 每消息审核):
// turn-1 干净消息正常转发并完成;turn-2 消息审核命中后客户端先收到
// writeContentModerationWSError 错误帧,随后连接以 close(1008 StatusPolicyViolation,
// reason=<拦截文案>) 关闭,且 turn-2 消息不会到达上游。
// 复用 TestOpenAIResponsesWebSocket_ContentModerationBlocksFirstFrame 的 WS 基建
//(newOpenAIWSHandlerTestServer 路由形态 + passthrough 上游夹具)。
func TestP2Characterization_OpenAIResponsesWSTurn2ModerationCloseError(t *testing.T) {
gin.SetMode(gin.TestMode)
const turn2Marker = "p2char-turn2-bad"
moderationSrv := p2CharConditionalModerationServer(t, turn2Marker)
defer moderationSrv.Close()
upstreamFrames := make(chan []byte, 4)
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := coderws.Accept(w, r, &coderws.AcceptOptions{CompressionMode: coderws.CompressionContextTakeover})
if err != nil {
return
}
defer func() { _ = conn.CloseNow() }()
for {
readCtx, cancelRead := context.WithTimeout(r.Context(), 5*time.Second)
_, payload, readErr := conn.Read(readCtx)
cancelRead()
if readErr != nil {
return
}
upstreamFrames <- payload
writeCtx, cancelWrite := context.WithTimeout(r.Context(), 3*time.Second)
writeErr := conn.Write(writeCtx, coderws.MessageText, []byte(
`{"type":"response.completed","response":{"id":"resp_p2char_turn1","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":1}}}`,
))
cancelWrite()
if writeErr != nil {
return
}
}
}))
defer upstreamServer.Close()
account := service.Account{
ID: 9911,
Name: "p2char-ws-turn2",
Platform: service.PlatformOpenAI,
Type: service.AccountTypeAPIKey,
Status: service.StatusActive,
Schedulable: true,
Concurrency: 1,
Credentials: map[string]any{
"api_key": "sk-p2char",
"base_url": upstreamServer.URL,
},
Extra: map[string]any{
"openai_apikey_responses_websockets_v2_enabled": true,
"openai_apikey_responses_websockets_v2_mode": service.OpenAIWSIngressModePassthrough,
},
}
cfg := &config.Config{}
cfg.RunMode = config.RunModeSimple
cfg.Default.RateMultiplier = 1
cfg.Security.URLAllowlist.Enabled = false
cfg.Security.URLAllowlist.AllowInsecureHTTP = 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 = 5
cfg.Gateway.OpenAIWS.WriteTimeoutSeconds = 3
accountRepo := &openAIWSUsageHandlerAccountRepoStub{account: account}
usageRepo := &openAIWSUsageHandlerUsageLogRepoStub{created: make(chan *service.UsageLog, 4)}
billingCacheSvc := service.NewBillingCacheService(nil, nil, nil, nil, nil, nil, cfg, nil)
gatewaySvc := service.NewOpenAIGatewayService(
accountRepo,
usageRepo,
nil,
nil,
nil,
nil,
nil,
cfg,
nil,
nil,
service.NewBillingService(cfg, nil),
nil,
billingCacheSvc,
nil,
&service.DeferredService{},
nil,
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{},
contentModerationService: passCharModerationService(t, moderationSrv.URL),
concurrencyHelper: NewConcurrencyHelper(service.NewConcurrencyService(cache), 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",
&coderws.DialOptions{CompressionMode: coderws.CompressionContextTakeover},
)
cancelDial()
require.NoError(t, err)
defer func() { _ = clientConn.CloseNow() }()
// turn-1:干净消息正常转发,收到上游 response.completed。
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,"input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"clean prompt"}]}]}`,
))
cancelWrite()
require.NoError(t, err)
readCtx, cancelRead := context.WithTimeout(context.Background(), 5*time.Second)
_, turn1Event, err := clientConn.Read(readCtx)
cancelRead()
require.NoError(t, err)
require.Equal(t, "response.completed", gjson.GetBytes(turn1Event, "type").String())
select {
case <-upstreamFrames:
case <-time.After(3 * time.Second):
t.Fatal("等待上游收到 turn-1 帧超时")
}
// turn-2:命中审核的消息被 BeforeRequest 拦截。
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,"input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"`+turn2Marker+`"}]}]}`,
))
cancelWrite()
require.NoError(t, err)
// 实际行为:先收到 writeContentModerationWSError 错误帧,再收到 close(1008) 关闭。
readCtx, cancelRead = context.WithTimeout(context.Background(), 5*time.Second)
_, errFrame, readErr := clientConn.Read(readCtx)
cancelRead()
require.NoError(t, readErr, "turn-2 拦截后应先收到错误帧")
require.JSONEq(t,
`{"event_id":"evt_content_moderation_blocked","type":"error","error":{"type":"invalid_request_error","code":"content_policy_violation","message":"内容审计命中风险规则,请调整输入后重试"}}`,
string(errFrame))
readCtx, cancelRead = context.WithTimeout(context.Background(), 5*time.Second)
_, _, closeReadErr := clientConn.Read(readCtx)
cancelRead()
require.Error(t, closeReadErr)
var closeErr coderws.CloseError
require.ErrorAs(t, closeReadErr, &closeErr)
require.Equal(t, coderws.StatusPolicyViolation, closeErr.Code)
require.Equal(t, "内容审计命中风险规则,请调整输入后重试", closeErr.Reason)
// turn-2 消息不得到达上游。
select {
case frame := <-upstreamFrames:
t.Fatalf("turn-2 被拦截的消息不应转发到上游,实际收到: %s", frame)
case <-time.After(300 * time.Millisecond):
}
}
@@ -0,0 +1,20 @@
package handler
import (
"github.com/Wei-Shaw/sub2api/internal/gatewayplatform"
"github.com/Wei-Shaw/sub2api/internal/service"
)
// ProvideGatewayPlatformRegistry 装配 /v1/messages 平台 Forward 分发注册表
// (Phase-3 SEAM-DESIGN 裁决:anthropic / antigravity / gemini 三 adapter;
// OpenAI 走独立 OpenAIGatewayHandler 与独立计费管线,不进本注册表)。
func ProvideGatewayPlatformRegistry(
gatewayService *service.GatewayService,
antigravityGatewayService *service.AntigravityGatewayService,
) *gatewayplatform.Registry {
return gatewayplatform.NewRegistry(
gatewayplatform.NewAnthropicProvider(gatewayService),
gatewayplatform.NewAntigravityProvider(antigravityGatewayService),
gatewayplatform.NewGeminiProvider(antigravityGatewayService),
)
}
@@ -0,0 +1,742 @@
//go:build unit
// Phase-3 TASK-001 前置特征化测试:/v1/messages 两处平台 Forward 分发点
// (gateway_handler.go :444 与 :794)的路由与参数透传锁定,作为 TASK-002
// Provider 接缝改造(registry 替换分发点)的等价性 gate。
//
// 固化内容(对应 SEAM-DESIGN.md 裁决记录的 T1-T4):
// - T1 :794 路由矩阵:antigravity 平台分组下,
// {OAuth/Upstream(Type != APIKey)账号 → AntigravityGatewayService.Forward,
// APIKey 账号 → GatewayService.Forward};通过两个独立的上游记录桩区分命中路径;
// - T2 :444 路由:gemini 平台分组 + antigravity 账号 → ForwardGemini,
// 参数透传可观测面:model(v1internal 包裹体 model 字段=映射后模型)、
// 上游 action 恒为 streamGenerateContent、stream(客户端响应形态
// SSE/收集 JSON)、body 原文进入包裹体;
// - T3 session 选项透传:WithForwardGeminiSession(groupID, "gemini:"+sessionHash)
// 在模型限流切换路径上经 clearStickySession 落到
// GatewayCache.DeleteSessionAccountID(groupID, sessionKey),参数逐一断言;
// 粘性绑定账号被优先选中(sessionBoundAccountID → 调度命中);
// - T4 错误链:GatewayService.Forward 返回 *BetaBlockedError → handler errors.As
// 命中 → 400 + invalid_request_error + 策略消息;AntigravityGatewayService.Forward
// 返回 *PromptTooLongError(无兜底分组)→ errors.As 命中 → WriteMappedClaudeError
// 输出。改造后 adapter 若包裹/吞掉这两类错误,本组测试必红。
//
// mock 方式取舍说明:GatewayHandler 的 service 字段均为具体类型
// (*service.GatewayService / *service.AntigravityGatewayService),无法以接口桩
// 替换记录调用参数;故采用真实 service + 注入 HTTPUpstream/GatewayCache 记录桩,
// 通过上游收到的请求特征(URL/包裹体)与缓存调用参数区分路径并断言透传。
// isStickySession 实参本身在 handler 层无外部可观测面(其效果 ForceCacheBilling
// 已由 service 层既有测试 TestAntigravityGatewayService_Forward_StickySessionForceCacheBilling /
// _ForwardGemini_StickySessionForceCacheBilling 锁定);handler 层锁定其数据来源
// hasBoundSession 的判定链:粘性绑定命中 → 绑定账号被优先调度(见 T3 用例)。
//
// 复用同包既有夹具:fakeSchedulerCache / fakeGroupRepo
// (gateway_handler_warmup_intercept_unit_test.go)、schedInvNewCountingCache
// (scheduling_invariants_failover_test.go)、passCharSettingRepo
// (gateway_intercept_characterization_test.go)。
// 本文件新增的包级辅助类型/函数一律带 p3Char 前缀。
package handler
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
middleware "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"
)
// ---------------------------------------------------------------------------
// p3Char 夹具
// ---------------------------------------------------------------------------
// p3CharUpstreamCall 记录一次上游调用的可观测特征。
type p3CharUpstreamCall struct {
AccountID int64
Method string
URL string
Header http.Header
Body []byte
}
// p3CharUpstream 是可编程的 HTTPUpstream 记录桩:按 accountID 返回脚本化响应,
// 并记录每次调用的完整请求特征(URL/Header/Body)。
type p3CharUpstream struct {
mu sync.Mutex
respond func(accountID int64) (*http.Response, error)
calls []p3CharUpstreamCall
}
var _ service.HTTPUpstream = (*p3CharUpstream)(nil)
func (u *p3CharUpstream) Do(req *http.Request, _ string, accountID int64, _ int) (*http.Response, error) {
call := p3CharUpstreamCall{AccountID: accountID}
if req != nil {
call.Method = req.Method
call.URL = req.URL.String()
call.Header = req.Header.Clone()
if req.Body != nil {
body, _ := io.ReadAll(req.Body)
_ = req.Body.Close()
call.Body = body
}
}
u.mu.Lock()
u.calls = append(u.calls, call)
respond := u.respond
u.mu.Unlock()
if respond == nil {
return nil, fmt.Errorf("p3CharUpstream: unexpected upstream call (account=%d url=%s)", accountID, call.URL)
}
return respond(accountID)
}
func (u *p3CharUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, _ *tlsfingerprint.Profile) (*http.Response, error) {
return u.Do(req, proxyURL, accountID, accountConcurrency)
}
func (u *p3CharUpstream) recordedCalls() []p3CharUpstreamCall {
u.mu.Lock()
defer u.mu.Unlock()
return append([]p3CharUpstreamCall(nil), u.calls...)
}
// p3CharSessionCall 记录一次粘性会话缓存调用的参数。
type p3CharSessionCall struct {
GroupID int64
SessionHash string
AccountID int64
}
// p3CharStickyCache 是 service.GatewayCache 的内存实现,记录所有调用参数。
// 同一实例同时注入 GatewayService(粘性查询/绑定)与 AntigravityGatewayService
// (模型限流时清除粘性绑定),与生产共用同一 Redis 缓存的接线一致。
type p3CharStickyCache struct {
mu sync.Mutex
bound map[string]int64
getCalls []p3CharSessionCall
setCalls []p3CharSessionCall
deleteCalls []p3CharSessionCall
}
var _ service.GatewayCache = (*p3CharStickyCache)(nil)
func p3CharNewStickyCache() *p3CharStickyCache {
return &p3CharStickyCache{bound: make(map[string]int64)}
}
func p3CharSessionKey(groupID int64, sessionHash string) string {
return fmt.Sprintf("%d|%s", groupID, sessionHash)
}
func (c *p3CharStickyCache) bind(groupID int64, sessionHash string, accountID int64) {
c.mu.Lock()
defer c.mu.Unlock()
c.bound[p3CharSessionKey(groupID, sessionHash)] = accountID
}
func (c *p3CharStickyCache) GetSessionAccountID(_ context.Context, groupID int64, sessionHash string) (int64, error) {
c.mu.Lock()
defer c.mu.Unlock()
c.getCalls = append(c.getCalls, p3CharSessionCall{GroupID: groupID, SessionHash: sessionHash})
return c.bound[p3CharSessionKey(groupID, sessionHash)], nil
}
func (c *p3CharStickyCache) SetSessionAccountID(_ context.Context, groupID int64, sessionHash string, accountID int64, _ time.Duration) error {
c.mu.Lock()
defer c.mu.Unlock()
c.setCalls = append(c.setCalls, p3CharSessionCall{GroupID: groupID, SessionHash: sessionHash, AccountID: accountID})
c.bound[p3CharSessionKey(groupID, sessionHash)] = accountID
return nil
}
func (c *p3CharStickyCache) RefreshSessionTTL(_ context.Context, _ int64, _ string, _ time.Duration) error {
return nil
}
func (c *p3CharStickyCache) DeleteSessionAccountID(_ context.Context, groupID int64, sessionHash string) error {
c.mu.Lock()
defer c.mu.Unlock()
c.deleteCalls = append(c.deleteCalls, p3CharSessionCall{GroupID: groupID, SessionHash: sessionHash})
delete(c.bound, p3CharSessionKey(groupID, sessionHash))
return nil
}
func (c *p3CharStickyCache) recordedDeleteCalls() []p3CharSessionCall {
c.mu.Lock()
defer c.mu.Unlock()
return append([]p3CharSessionCall(nil), c.deleteCalls...)
}
func (c *p3CharStickyCache) recordedGetCalls() []p3CharSessionCall {
c.mu.Lock()
defer c.mu.Unlock()
return append([]p3CharSessionCall(nil), c.getCalls...)
}
// p3CharGroup 构造指定平台的测试分组。
func p3CharGroup(groupID int64, platform string) *service.Group {
return &service.Group{
ID: groupID,
Hydrated: true,
Platform: platform,
Status: service.StatusActive,
}
}
// p3CharAntigravityAccount 构造 antigravity 账号夹具。
func p3CharAntigravityAccount(id, groupID int64, accountType string, creds, extra map[string]any) *service.Account {
credentials := map[string]any{"access_token": fmt.Sprintf("ag-token-%d", id)}
for k, v := range creds {
credentials[k] = v
}
return &service.Account{
ID: id,
Name: fmt.Sprintf("p3-char-ag-%d", id),
Platform: service.PlatformAntigravity,
Type: accountType,
Credentials: credentials,
Extra: extra,
Concurrency: 5,
Priority: 1,
Status: service.StatusActive,
Schedulable: true,
AccountGroups: []service.AccountGroup{{AccountID: id, GroupID: groupID}},
}
}
// p3CharHandlerEnv 聚合一套完整 GatewayHandler 测试环境。
type p3CharHandlerEnv struct {
handler *GatewayHandler
gwUpstream *p3CharUpstream // GatewayService.Forward 路径的上游
agUpstream *p3CharUpstream // AntigravityGatewayService.Forward/ForwardGemini 路径的上游
stickyCache *p3CharStickyCache
cleanup func()
}
// p3CharNewHandler 构造完整 GatewayHandler:真实 GatewayService 与
// AntigravityGatewayService,各自注入独立上游记录桩;共享粘性会话缓存桩。
// settingValues 写入两个 service 共享的 SettingService 内存仓库(beta policy 等)。
func p3CharNewHandler(t *testing.T, group *service.Group, accounts []*service.Account, settingValues map[string]string) *p3CharHandlerEnv {
t.Helper()
t.Setenv("SUB2API_DEBUG_GATEWAY_BODY", "")
gwUpstream := &p3CharUpstream{}
agUpstream := &p3CharUpstream{}
stickyCache := p3CharNewStickyCache()
schedulerSnapshot := service.NewSchedulerSnapshotService(&fakeSchedulerCache{accounts: accounts}, nil, nil, nil, nil)
concurrencySvc := service.NewConcurrencyService(schedInvNewCountingCache())
settingSvc := service.NewSettingService(&passCharSettingRepo{values: settingValues}, &config.Config{})
gwSvc := service.NewGatewayService(
nil, // accountRepo
&fakeGroupRepo{group: group},
nil, nil, nil, nil, nil, // usageLogRepo / usageBillingRepo / userRepo / userSubRepo / userGroupRateRepo
stickyCache,
&config.Config{},
schedulerSnapshot,
concurrencySvc,
nil, // billingService
&service.RateLimitService{}, // 零值:错误码仅记录日志
nil, // billingCacheService
nil, // identityService
gwUpstream,
&service.DeferredService{},
nil, nil, nil, nil, // claudeTokenProvider / sessionLimitCache / rpmCache / digestStore
settingSvc,
nil, nil, nil, nil, nil, // tlsFPProfileService / channelService / resolver / balanceNotifyService / userPlatformQuotaRepo
)
agSvc := service.NewAntigravityGatewayService(
nil, // accountRepo(模型限流写库不在锁定面,nil → 仅走 handleError 兜底分支)
stickyCache,
schedulerSnapshot,
&service.AntigravityTokenProvider{},
nil, // rateLimitService
agUpstream,
settingSvc,
nil, // internal500Cache
)
billingCacheSvc := service.NewBillingCacheService(nil, nil, nil, nil, nil, nil,
&config.Config{RunMode: config.RunModeSimple}, nil)
h := NewGatewayHandler(
gwSvc,
nil, // geminiCompatService(:444 非 antigravity 分支误入会 panic → 用例显式失败)
agSvc,
ProvideGatewayPlatformRegistry(gwSvc, agSvc), // 与生产 Wire 装配同构的平台分发注册表
nil, // userService
concurrencySvc,
billingCacheSvc,
nil, nil, nil, nil, nil, nil, // usageService / apiKeyService / workerPool / errorPassthrough / preFlightHooks / userMsgQueue
nil, // cfg
nil, // settingService(handler 级版本检查等不在锁定面)
)
return &p3CharHandlerEnv{
handler: h,
gwUpstream: gwUpstream,
agUpstream: agUpstream,
stickyCache: stickyCache,
cleanup: func() { billingCacheSvc.Stop() },
}
}
// p3CharMessagesContext 构造带认证上下文的 /v1/messages 请求(可附加 header)。
func p3CharMessagesContext(t *testing.T, group *service.Group, body []byte, headers map[string]string) (*gin.Context, *httptest.ResponseRecorder, context.CancelFunc) {
t.Helper()
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
req := httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
for k, v := range headers {
req.Header.Set(k, v)
}
ctx := context.WithValue(req.Context(), ctxkey.Group, group)
ctx, cancel := context.WithCancel(ctx)
c.Request = req.WithContext(ctx)
apiKey := &service.APIKey{
ID: 8501,
UserID: 8601,
GroupID: &group.ID,
Status: service.StatusActive,
User: &service.User{
ID: 8601,
Concurrency: 10,
Balance: 100,
},
Group: group,
}
c.Set(string(middleware.ContextKeyAPIKey), apiKey)
c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: apiKey.UserID, Concurrency: 10})
return c, rec, cancel
}
// p3CharSessionID 是 metadata.user_id 中的 session 段(36 字符 UUID),
// 用于产生确定性的 sessionHash(GenerateSessionHash 最高优先级来源)。
const p3CharSessionID = "12345678-1234-1234-1234-123456789abc"
// p3CharMetadataUserID 构造 legacy 格式 metadata.user_id。
func p3CharMetadataUserID() string {
return "user_" + strings.Repeat("a", 64) + "_account__session_" + p3CharSessionID
}
// p3CharClaudeBody 构造 Claude 协议请求体(:794 路径)。
func p3CharClaudeBody(stream bool) []byte {
return []byte(fmt.Sprintf(`{
"model": "claude-sonnet-4-5",
"max_tokens": 64,
"stream": %t,
"metadata": {"user_id": %q},
"messages": [{"role":"user","content":"p3 dispatch probe"}]
}`, stream, p3CharMetadataUserID()))
}
// p3CharGeminiLoopBody 构造同时满足 /v1/messages 解析(model/stream/metadata)
// 与 ForwardGemini 原文透传(contents)的混合请求体(:444 路径将原始 body
// 原样传给 ForwardGemini)。
func p3CharGeminiLoopBody(stream bool) []byte {
return []byte(fmt.Sprintf(`{
"model": "gemini-2.5-flash",
"stream": %t,
"metadata": {"user_id": %q},
"contents": [{"role":"user","parts":[{"text":"p3 gemini dispatch probe"}]}]
}`, stream, p3CharMetadataUserID()))
}
// p3CharAntigravitySSEResponse 构造 v1internal 包裹的上游流式成功响应。
func p3CharAntigravitySSEResponse() *http.Response {
sse := "data: {\"response\":{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"ok\"}]},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":8,\"candidatesTokenCount\":3}}}\n\n"
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{
"Content-Type": []string{"text/event-stream"},
"X-Request-Id": []string{"rid-p3-ag"},
},
Body: io.NopCloser(strings.NewReader(sse)),
}
}
// p3CharRateLimit429Response 构造触发"模型限流 + 切换账号"的 429 响应
// (RATE_LIMIT_EXCEEDED + retryDelay 60s ≥ 7s 阈值,无智能重试等待)。
func p3CharRateLimit429Response() *http.Response {
body := `{
"error": {
"code": 429,
"message": "rate limited",
"status": "RESOURCE_EXHAUSTED",
"details": [
{"@type": "type.googleapis.com/google.rpc.ErrorInfo", "reason": "RATE_LIMIT_EXCEEDED", "metadata": {"model": "gemini-3-pro-high"}},
{"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "60s"}
]
}
}`
return &http.Response{
StatusCode: http.StatusTooManyRequests,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(body)),
}
}
// ---------------------------------------------------------------------------
// T1 :794 路由矩阵
// ---------------------------------------------------------------------------
// TestPlatformDispatchCharacterization_AntigravityNonAPIKeyRoutesToAntigravityForward
// 固化 :794 分支条件 `Platform == Antigravity && Type != APIKey` 的"命中"侧:
// OAuth 与 Upstream 两种非 APIKey 账号均走 AntigravityGatewayService.Forward
// (上游收到 antigravity v1internal 请求 / 上游透传请求),GatewayService 上游零调用。
func TestPlatformDispatchCharacterization_AntigravityNonAPIKeyRoutesToAntigravityForward(t *testing.T) {
t.Run("OAuth账号走antigravity转发", func(t *testing.T) {
group := p3CharGroup(9501, service.PlatformAntigravity)
account := p3CharAntigravityAccount(9511, group.ID, service.AccountTypeOAuth, map[string]any{
"model_mapping": map[string]any{"claude-sonnet-4-5": "gemini-3-pro-high"},
}, nil)
env := p3CharNewHandler(t, group, []*service.Account{account}, nil)
defer env.cleanup()
env.agUpstream.respond = func(int64) (*http.Response, error) {
return p3CharAntigravitySSEResponse(), nil
}
c, rec, cancel := p3CharMessagesContext(t, group, p3CharClaudeBody(false), nil)
defer cancel()
env.handler.Messages(c)
agCalls := env.agUpstream.recordedCalls()
require.Len(t, agCalls, 1, "antigravity 上游应被调用恰好一次")
require.Empty(t, env.gwUpstream.recordedCalls(), "GatewayService 上游不应被调用")
// Claude→Gemini 转换 + v1internal 包裹:URL 恒为 streamGenerateContent,
// 包裹体 model = 账号映射后的最终模型
require.Contains(t, agCalls[0].URL, "/v1internal:streamGenerateContent?alt=sse")
require.Equal(t, "gemini-3-pro-high", gjson.GetBytes(agCalls[0].Body, "model").String())
require.Equal(t, http.StatusOK, rec.Code)
})
t.Run("Upstream账号走antigravity转发", func(t *testing.T) {
group := p3CharGroup(9502, service.PlatformAntigravity)
account := p3CharAntigravityAccount(9512, group.ID, service.AccountTypeUpstream, map[string]any{
"api_key": "ag-upstream-key",
"base_url": "https://ag-upstream.example.com",
"model_mapping": map[string]any{"claude-sonnet-4-5": "claude-sonnet-4-5"},
}, nil)
env := p3CharNewHandler(t, group, []*service.Account{account}, nil)
defer env.cleanup()
env.agUpstream.respond = func(int64) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(
`{"id":"msg_p3_up","type":"message","role":"assistant","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":5,"output_tokens":2}}`)),
}, nil
}
c, rec, cancel := p3CharMessagesContext(t, group, p3CharClaudeBody(false), nil)
defer cancel()
env.handler.Messages(c)
agCalls := env.agUpstream.recordedCalls()
require.Len(t, agCalls, 1, "antigravity 上游应被调用恰好一次(ForwardUpstream 透传)")
require.Empty(t, env.gwUpstream.recordedCalls(), "GatewayService 上游不应被调用")
require.Equal(t, "https://ag-upstream.example.com/v1/messages", agCalls[0].URL)
require.Equal(t, http.StatusOK, rec.Code)
})
}
// TestPlatformDispatchCharacterization_AntigravityAPIKeyRoutesToGatewayForward
// 固化 :794 分支条件的"未命中"侧:antigravity 平台的 APIKey 账号走
// GatewayService.Forward(Claude 协议直连上游),antigravity 上游零调用。
func TestPlatformDispatchCharacterization_AntigravityAPIKeyRoutesToGatewayForward(t *testing.T) {
group := p3CharGroup(9503, service.PlatformAntigravity)
account := p3CharAntigravityAccount(9513, group.ID, service.AccountTypeAPIKey, map[string]any{
"api_key": "ag-apikey-cred",
"model_mapping": map[string]any{"claude-sonnet-4-5": "claude-sonnet-4-5"},
}, nil)
env := p3CharNewHandler(t, group, []*service.Account{account}, nil)
defer env.cleanup()
env.gwUpstream.respond = func(int64) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(
`{"id":"msg_p3_gw","type":"message","role":"assistant","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":5,"output_tokens":2}}`)),
}, nil
}
c, rec, cancel := p3CharMessagesContext(t, group, p3CharClaudeBody(false), nil)
defer cancel()
env.handler.Messages(c)
gwCalls := env.gwUpstream.recordedCalls()
require.Len(t, gwCalls, 1, "GatewayService 上游应被调用恰好一次")
require.Empty(t, env.agUpstream.recordedCalls(), "antigravity 上游不应被调用")
// APIKey 账号未配置 base_url 时直连 Anthropic 默认地址,认证走 x-api-key
// (setHeaderRaw 以小写原样写入 header map,须用原始 key 读取)
require.Equal(t, "https://api.anthropic.com/v1/messages?beta=true", gwCalls[0].URL)
require.Equal(t, []string{"ag-apikey-cred"}, gwCalls[0].Header["x-api-key"])
require.Equal(t, http.StatusOK, rec.Code)
}
// ---------------------------------------------------------------------------
// T2 :444 路由 + 参数透传
// ---------------------------------------------------------------------------
// TestPlatformDispatchCharacterization_GeminiLoopAntigravityRoutesToForwardGemini
// 固化 :444 分支:gemini 平台分组下 antigravity 账号 → ForwardGemini。
// 参数可观测面逐一断言:
// - model:v1internal 包裹体 model = 账号映射后的模型(请求体 model 经映射进入上游);
// - 上游 action 恒为 streamGenerateContent(handler 传入的 action="generateContent"
// 仅决定客户端响应形态合法性,上游不变量见 service 层 action 契约测试);
// - stream=false → 网关收集上游流式响应,客户端收到单个 JSON(application/json);
// - stream=true → SSE 解包透传(text/event-stream);
// - body 原文(contents 文本)进入包裹体 request 字段;
// - sessionKey 格式 "gemini:"+sessionHash 进入粘性查询。
func TestPlatformDispatchCharacterization_GeminiLoopAntigravityRoutesToForwardGemini(t *testing.T) {
newEnv := func(t *testing.T, groupID int64) (*p3CharHandlerEnv, *service.Group) {
group := p3CharGroup(groupID, service.PlatformGemini)
account := p3CharAntigravityAccount(groupID+10, groupID, service.AccountTypeOAuth, map[string]any{
"model_mapping": map[string]any{"gemini-2.5-flash": "gemini-3-pro-high"},
}, map[string]any{"mixed_scheduling": true})
env := p3CharNewHandler(t, group, []*service.Account{account}, nil)
env.agUpstream.respond = func(int64) (*http.Response, error) {
return p3CharAntigravitySSEResponse(), nil
}
return env, group
}
t.Run("非流式收集为单个JSON", func(t *testing.T) {
env, group := newEnv(t, 9521)
defer env.cleanup()
c, rec, cancel := p3CharMessagesContext(t, group, p3CharGeminiLoopBody(false), nil)
defer cancel()
env.handler.Messages(c)
agCalls := env.agUpstream.recordedCalls()
require.Len(t, agCalls, 1, "antigravity 上游应被调用恰好一次")
require.Empty(t, env.gwUpstream.recordedCalls())
// model 参数:包裹体 model = 映射后模型
require.Contains(t, agCalls[0].URL, "/v1internal:streamGenerateContent?alt=sse")
require.Equal(t, "gemini-3-pro-high", gjson.GetBytes(agCalls[0].Body, "model").String())
// body 透传:原始 contents 文本进入包裹体 request 字段
require.Contains(t, gjson.GetBytes(agCalls[0].Body, "request").Raw, "p3 gemini dispatch probe")
// stream=false:收集上游流式响应为单个 JSON
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, "application/json", rec.Header().Get("Content-Type"))
require.Equal(t, "ok", gjson.Get(rec.Body.String(), "candidates.0.content.parts.0.text").String())
// sessionKey 格式:gemini 平台为 "gemini:"+sessionHash
getCalls := env.stickyCache.recordedGetCalls()
require.NotEmpty(t, getCalls, "粘性会话查询应发生")
for _, call := range getCalls {
require.Equal(t, group.ID, call.GroupID)
require.Equal(t, "gemini:"+p3CharSessionID, call.SessionHash)
}
})
t.Run("流式SSE解包透传", func(t *testing.T) {
env, group := newEnv(t, 9522)
defer env.cleanup()
c, rec, cancel := p3CharMessagesContext(t, group, p3CharGeminiLoopBody(true), nil)
defer cancel()
env.handler.Messages(c)
agCalls := env.agUpstream.recordedCalls()
require.Len(t, agCalls, 1)
require.Contains(t, agCalls[0].URL, "/v1internal:streamGenerateContent?alt=sse")
// stream=true:SSE 解包透传
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, "text/event-stream", rec.Header().Get("Content-Type"))
require.Contains(t, rec.Body.String(), `data: {"candidates"`)
})
}
// ---------------------------------------------------------------------------
// T3 session 选项透传 + 粘性绑定语义
// ---------------------------------------------------------------------------
// TestPlatformDispatchCharacterization_GeminiLoopSessionOptionsReachStickyClear
// 固化 WithForwardGeminiSession(groupID, sessionKey) 的端到端透传:
// 1. 粘性绑定账号被优先调度(首次上游调用命中绑定账号);
// 2. 绑定账号 429(RATE_LIMIT_EXCEEDED + 长 retryDelay)触发模型限流切换时,
// ForwardGemini 内部以 handler 传入的 (groupID, sessionKey) 调用
// GatewayCache.DeleteSessionAccountID 清除粘性绑定——参数逐一断言;
// 3. failover 切换第二账号成功,客户端最终 200。
//
// 此场景同时锁定 isStickySession=hasBoundSession 的端到端透传:绑定命中 →
// ForwardGemini(isSticky=true) → 切换信号携带 IsStickySession → handler
// fs.ForceCacheBilling → 第二账号成功后 RecordUsage 应用 force_cache_billing
// (经结构化日志 sink 断言;isSticky 在 service 内的语义另由 service 层既有
// 测试 TestAntigravityGatewayService_ForwardGemini_StickySessionForceCacheBilling 锁定)。
func TestPlatformDispatchCharacterization_GeminiLoopSessionOptionsReachStickyClear(t *testing.T) {
sink, restoreSink := captureHandlerStructuredLog(t)
defer restoreSink()
group := p3CharGroup(9531, service.PlatformGemini)
boundAccount := p3CharAntigravityAccount(9541, group.ID, service.AccountTypeOAuth, map[string]any{
"model_mapping": map[string]any{"gemini-2.5-flash": "gemini-3-pro-high"},
}, map[string]any{"mixed_scheduling": true})
healthyAccount := p3CharAntigravityAccount(9542, group.ID, service.AccountTypeOAuth, map[string]any{
"model_mapping": map[string]any{"gemini-2.5-flash": "gemini-3-pro-high"},
}, map[string]any{"mixed_scheduling": true})
env := p3CharNewHandler(t, group, []*service.Account{boundAccount, healthyAccount}, nil)
defer env.cleanup()
sessionKey := "gemini:" + p3CharSessionID
env.stickyCache.bind(group.ID, sessionKey, boundAccount.ID)
env.agUpstream.respond = func(accountID int64) (*http.Response, error) {
if accountID == boundAccount.ID {
return p3CharRateLimit429Response(), nil
}
return p3CharAntigravitySSEResponse(), nil
}
c, rec, cancel := p3CharMessagesContext(t, group, p3CharGeminiLoopBody(false), nil)
defer cancel()
env.handler.Messages(c)
// 1. 粘性绑定账号被优先调度,429 后切换到第二账号
agCalls := env.agUpstream.recordedCalls()
require.Len(t, agCalls, 2, "绑定账号 1 次 + failover 后第二账号 1 次")
require.Equal(t, boundAccount.ID, agCalls[0].AccountID, "首次尝试必须命中粘性绑定账号")
require.Equal(t, healthyAccount.ID, agCalls[1].AccountID, "failover 切换到第二账号")
// 2. WithForwardGeminiSession 的 (groupID, sessionKey) 透传到粘性清除
deleteCalls := env.stickyCache.recordedDeleteCalls()
require.NotEmpty(t, deleteCalls, "模型限流切换必须清除粘性绑定")
for _, call := range deleteCalls {
require.Equal(t, group.ID, call.GroupID, "清除粘性绑定的 groupID 必须等于 handler 传入的分组 ID")
require.Equal(t, sessionKey, call.SessionHash, "清除粘性绑定的 sessionHash 必须等于 handler 传入的 sessionKey")
}
// 3. failover 成功,客户端 200
require.Equal(t, http.StatusOK, rec.Code)
require.Empty(t, env.gwUpstream.recordedCalls())
// 4. isStickySession 透传链:hasBoundSession=true → ForwardGemini 切换信号
// IsStickySession=true → fs.ForceCacheBilling → RecordUsage 应用
// force_cache_billing(input_tokens 转 cache_read)。
require.True(t,
sink.ContainsMessageAtLevel("force_cache_billing", "info"),
"粘性会话切换后第二账号成功,RecordUsage 必须应用 force_cache_billing")
}
// ---------------------------------------------------------------------------
// T4 错误链现状锁定
// ---------------------------------------------------------------------------
// TestErrorChainCharacterization_BetaBlockedErrorReturns400 固化:
// GatewayService.Forward 返回 *service.BetaBlockedError 时,handler 的
// errors.As 命中 → 400 + invalid_request_error + 策略消息原文;不触发 failover、
// 不触上游。改造后 adapter 若包裹该错误且破坏 errors.As 链,本用例必红。
func TestErrorChainCharacterization_BetaBlockedErrorReturns400(t *testing.T) {
group := p3CharGroup(9551, service.PlatformAnthropic)
// anthropic APIKey 账号(未启用透传)→ GatewayService.Forward 主路径会评估 beta policy
account := &service.Account{
ID: 9561,
Name: "p3-char-anthropic-apikey",
Platform: service.PlatformAnthropic,
Type: service.AccountTypeAPIKey,
Credentials: map[string]any{"api_key": "sk-p3-beta"},
Concurrency: 5,
Priority: 1,
Status: service.StatusActive,
Schedulable: true,
AccountGroups: []service.AccountGroup{{AccountID: 9561, GroupID: group.ID}},
}
betaPolicy := `{"rules":[{"beta_token":"p3-blocked-beta","action":"block","scope":"all","error_message":"beta blocked by p3 policy"}]}`
env := p3CharNewHandler(t, group, []*service.Account{account}, map[string]string{
"beta_policy_settings": betaPolicy,
})
defer env.cleanup()
c, rec, cancel := p3CharMessagesContext(t, group, p3CharClaudeBody(false), map[string]string{
"anthropic-beta": "p3-blocked-beta",
})
defer cancel()
env.handler.Messages(c)
require.Equal(t, http.StatusBadRequest, rec.Code)
require.JSONEq(t,
`{"type":"error","error":{"type":"invalid_request_error","message":"beta blocked by p3 policy"}}`,
rec.Body.String())
require.Empty(t, env.gwUpstream.recordedCalls(), "beta policy 拦截发生在触上游之前")
require.Empty(t, env.agUpstream.recordedCalls())
}
// TestErrorChainCharacterization_PromptTooLongWritesMappedClaudeError 固化:
// AntigravityGatewayService.Forward 返回 *service.PromptTooLongError 且无兜底
// 分组时,handler 的 errors.As 命中 → WriteMappedClaudeError 写回上游状态码 +
// Claude 风格错误体;不触发 failover(单次上游调用)。
func TestErrorChainCharacterization_PromptTooLongWritesMappedClaudeError(t *testing.T) {
group := p3CharGroup(9552, service.PlatformAntigravity)
account := p3CharAntigravityAccount(9562, group.ID, service.AccountTypeOAuth, map[string]any{
"model_mapping": map[string]any{"claude-sonnet-4-5": "gemini-3-pro-high"},
}, nil)
env := p3CharNewHandler(t, group, []*service.Account{account}, nil)
defer env.cleanup()
env.agUpstream.respond = func(int64) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusBadRequest,
Header: http.Header{
"Content-Type": []string{"application/json"},
"X-Request-Id": []string{"rid-p3-ptl"},
},
Body: io.NopCloser(strings.NewReader(`{"error":{"message":"Prompt is too long"}}`)),
}, nil
}
c, rec, cancel := p3CharMessagesContext(t, group, p3CharClaudeBody(false), nil)
defer cancel()
env.handler.Messages(c)
require.Len(t, env.agUpstream.recordedCalls(), 1, "prompt too long 不触发 failover")
require.Equal(t, http.StatusBadRequest, rec.Code, "WriteMappedClaudeError 透传上游状态码")
var errBody map[string]any
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &errBody))
require.Equal(t, "error", errBody["type"])
errObj, ok := errBody["error"].(map[string]any)
require.True(t, ok, "错误体必须是 Claude 风格 {type, error:{...}}")
require.Contains(t, fmt.Sprintf("%v", errObj["message"]), "Prompt is too long")
}
@@ -0,0 +1,215 @@
package handler
import (
"context"
"net/http"
"strings"
"github.com/Wei-Shaw/sub2api/internal/gatewayhook"
"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"
)
// ProvideGatewayHookChain 装配网关 pre-flight 钩子链(Wire 注入的核心钩子;
// 裁决 H-C:只建链,不做 gateway.hook.* 命名空间的 Runtime 收集——该机制留待
// Phase-3 与平台 Provider 真实需求合并设计)。当前链 = [content_moderation]。
func ProvideGatewayHookChain(contentModerationService *service.ContentModerationService) *gatewayhook.Chain {
if contentModerationService == nil {
return gatewayhook.NewChain(nil)
}
return gatewayhook.NewChain(nil, &contentModerationPreFlightHook{svc: contentModerationService})
}
// preFlightLoggerKey 在钩子执行期间通过 ctx 传递请求级 logger(reqLog),
// 保留其已累积的 component/model/stream 等字段,保证审核日志字段等价。
type preFlightLoggerKey struct{}
func withPreFlightLogger(ctx context.Context, log *zap.Logger) context.Context {
if log == nil {
return ctx
}
return context.WithValue(ctx, preFlightLoggerKey{}, log)
}
func preFlightLoggerFromContext(ctx context.Context) *zap.Logger {
if ctx == nil {
return nil
}
log, _ := ctx.Value(preFlightLoggerKey{}).(*zap.Logger)
return log
}
// newGatewayHookRequest 从 gin 上下文构造钩子请求(协议无关只读视图)。
// Path 的计算与原 buildContentModerationInput 的 Endpoint 等价:
// GetInboundEndpoint 优先,空值回退原始 URL path。
func newGatewayHookRequest(c *gin.Context, apiKey *service.APIKey, subject middleware2.AuthSubject, protocol string, model string, body []byte) *gatewayhook.Request {
caller := gatewayhook.CallerInfo{UserID: subject.UserID}
if apiKey != nil {
caller.KeyID = apiKey.ID
caller.GroupID = apiKey.GroupID
}
path := GetInboundEndpoint(c)
if path == "" && c.Request.URL != nil {
path = c.Request.URL.Path
}
return &gatewayhook.Request{
Protocol: protocol,
Model: model,
Body: body,
Caller: caller,
APIKey: apiKey,
Headers: gatewayhook.NewRequestHeaders(c.Request.Header),
Path: path,
}
}
// runGatewayPreFlight 构造钩子请求并执行 pre-flight 链。
// 等价性约束(对应原 runContentModeration 的调用面):
// - 链为空(审核服务未装配/测试夹具)时直接放行,且不构造任何对象(零分配);
// - c / c.Request 为 nil 时直接放行(与原 helper 的 nil 防御一致)。
func runGatewayPreFlight(chain *gatewayhook.Chain, c *gin.Context, reqLog *zap.Logger, apiKey *service.APIKey, subject middleware2.AuthSubject, protocol string, model string, body []byte) *gatewayhook.Decision {
if chain.IsEmpty() || c == nil || c.Request == nil {
return nil
}
req := newGatewayHookRequest(c, apiKey, subject, protocol, model, body)
return chain.Run(withPreFlightLogger(c.Request.Context(), reqLog), req)
}
// runPreFlightHooks 执行网关 pre-flight 钩子链(替代原 checkContentModeration 的 HTTP 调用面)。
func (h *GatewayHandler) runPreFlightHooks(c *gin.Context, reqLog *zap.Logger, apiKey *service.APIKey, subject middleware2.AuthSubject, protocol string, model string, body []byte) *gatewayhook.Decision {
if h == nil {
return nil
}
return runGatewayPreFlight(h.preFlightHooks, c, reqLog, apiKey, subject, protocol, model, body)
}
// runPreFlightHooks 执行网关 pre-flight 钩子链(HTTP 调用点专用;
// WebSocket 两调用点仍走 checkContentModeration,按裁决排除在链改造之外)。
func (h *OpenAIGatewayHandler) runPreFlightHooks(c *gin.Context, reqLog *zap.Logger, apiKey *service.APIKey, subject middleware2.AuthSubject, protocol string, model string, body []byte) *gatewayhook.Decision {
if h == nil {
return nil
}
return runGatewayPreFlight(h.preFlightHooks, c, reqLog, apiKey, subject, protocol, model, body)
}
// preFlightStatus 与原 contentModerationStatus 等价:非 4xx/5xx 状态码钳制为 403。
func preFlightStatus(decision *gatewayhook.Decision) int {
if decision == nil || decision.StatusCode < 400 || decision.StatusCode > 599 {
return http.StatusForbidden
}
return decision.StatusCode
}
// preFlightErrorCode 与原 contentModerationErrorCode 等价:
// 拦截错误码固定回退 content_policy_violation(gemini 调用点不消费该值)。
func preFlightErrorCode(decision *gatewayhook.Decision) string {
if decision == nil || strings.TrimSpace(decision.ErrorType) == "" {
return "content_policy_violation"
}
return decision.ErrorType
}
// contentModerationPreFlightHook 是内容审核的核心钩子 adapter
// (裁决 H:moderation 本体依赖面超出 Host 能力,不模块化,仅包装现有 Check)。
//
// 等价性硬约束(与原 runContentModeration 逐项对应,WS 路径仍直接使用后者):
// - gateway_check_start(11 字段)/ gateway_check_done(8 字段)结构化日志
// 的事件名、字段名、字段来源逐一保留,且经请求级 logger(reqLog)输出;
// - Check 返回 error 时记 content_moderation.check_failed 后放行(fail-open,
// 由本钩子自行吞错,不依赖链层的通用 error 降级,保持原日志事件名);
// - svc 为 nil 时直接放行。
type contentModerationPreFlightHook struct {
svc *service.ContentModerationService
}
func (h *contentModerationPreFlightHook) HookID() string { return "content_moderation" }
func (h *contentModerationPreFlightHook) CheckPreFlight(ctx context.Context, req *gatewayhook.Request) (*gatewayhook.Decision, error) {
if h == nil || h.svc == nil || req == nil {
return nil, nil
}
reqLog := preFlightLoggerFromContext(ctx)
input := moderationInputFromHookRequest(ctx, req)
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.Int("body_bytes", len(req.Body)),
)
}
decision, err := h.svc.Check(ctx, input)
if err != nil {
if reqLog != nil {
reqLog.Warn("content_moderation.check_failed", zap.Error(err))
}
return nil, 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.Int("status_code", decision.StatusCode),
zap.String("highest_category", decision.HighestCategory),
zap.Float64("highest_score", decision.HighestScore),
)
}
if decision == nil || !decision.Blocked {
return nil, nil
}
return &gatewayhook.Decision{
Blocked: true,
StatusCode: decision.StatusCode,
ErrorType: "content_policy_violation",
Message: decision.Message,
}, nil
}
// moderationInputFromHookRequest 从钩子请求构造审核输入,与 buildContentModerationInput
// (WS 路径仍在使用)逐字段等价:
// - RequestID 来自 ctx(调用侧传 c.Request.Context());
// - Endpoint 即 req.Path(调用侧已按 GetInboundEndpoint + URL.Path 兜底预计算);
// - 强制平台覆盖改读 ctxkey.ForcePlatform(middleware.ForcePlatform 同步写入
// gin key 与 request context,两者在审核执行点恒一致)。
func moderationInputFromHookRequest(ctx context.Context, req *gatewayhook.Request) service.ContentModerationCheckInput {
input := service.ContentModerationCheckInput{
RequestID: contentModerationRequestID(ctx),
UserID: req.Caller.UserID,
Endpoint: req.Path,
Provider: contentModerationProvider(req.APIKey),
Model: strings.TrimSpace(req.Model),
Protocol: req.Protocol,
Body: req.Body,
}
if forcedPlatform, ok := ctx.Value(ctxkey.ForcePlatform).(string); ok {
input.Provider = strings.TrimSpace(forcedPlatform)
}
if req.APIKey != nil {
input.APIKeyID = req.APIKey.ID
input.APIKeyName = req.APIKey.Name
if req.APIKey.User != nil {
input.UserEmail = req.APIKey.User.Email
}
if req.APIKey.GroupID != nil {
groupID := *req.APIKey.GroupID
input.GroupID = &groupID
}
if req.APIKey.Group != nil {
input.GroupName = req.APIKey.Group.Name
}
}
return input
}
@@ -0,0 +1,193 @@
//go:build unit
// Phase-2 TASK-003 钩子链等价性单测:
// - moderationInputFromHookRequest 与 buildContentModerationInput(WS 路径仍用)逐字段等价;
// - moderation 核心钩子的 gateway_check_start(11 字段)/ gateway_check_done(8 字段)
// 结构化日志契约;
// - 空链 / nil 防御路径零分配(moderation 未装配时热路径无新增开销)。
package handler
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/gatewayhook"
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
middleware "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"
"go.uber.org/zap"
"go.uber.org/zap/zaptest/observer"
)
// preFlightTestGinContext 构造带请求上下文的 gin.Context。
// forcePlatform 非空时按 middleware.ForcePlatform 的真实行为同时写入
// request context(ctxkey.ForcePlatform)与 gin key。
func preFlightTestGinContext(t *testing.T, path string, forcePlatform string) *gin.Context {
t.Helper()
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader([]byte(`{}`)))
ctx := context.WithValue(req.Context(), ctxkey.RequestID, "req-pf-001")
if forcePlatform != "" {
ctx = context.WithValue(ctx, ctxkey.ForcePlatform, forcePlatform)
}
c.Request = req.WithContext(ctx)
if forcePlatform != "" {
c.Set(string(middleware.ContextKeyForcePlatform), forcePlatform)
}
return c
}
func preFlightTestAPIKey() *service.APIKey {
groupID := int64(9001)
return &service.APIKey{
ID: 9101,
UserID: 9201,
Name: "pf-key",
GroupID: &groupID,
User: &service.User{ID: 9201, Email: "pf@example.com"},
Group: &service.Group{ID: groupID, Name: "pf-group", Platform: service.PlatformAnthropic},
}
}
// TestPreFlightModerationInput_EquivalentToLegacyBuilder 锁定钩子侧审核输入
// 与 WS 路径仍在使用的 buildContentModerationInput 逐字段等价。
func TestPreFlightModerationInput_EquivalentToLegacyBuilder(t *testing.T) {
subject := middleware.AuthSubject{UserID: 9201, Concurrency: 5}
body := []byte(`{"model":"claude-sonnet-4-5"}`)
cases := []struct {
name string
forcePlatform string
apiKey *service.APIKey
model string
}{
{name: "常规请求", apiKey: preFlightTestAPIKey(), model: " claude-sonnet-4-5 "},
{name: "强制平台覆盖provider", forcePlatform: "antigravity", apiKey: preFlightTestAPIKey(), model: "claude-sonnet-4-5"},
{name: "nil apiKey防御", apiKey: nil, model: "gpt-5.1"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
c := preFlightTestGinContext(t, "/v1/messages", tc.forcePlatform)
legacy := buildContentModerationInput(c, tc.apiKey, subject, service.ContentModerationProtocolAnthropicMessages, tc.model, body)
req := newGatewayHookRequest(c, tc.apiKey, subject, service.ContentModerationProtocolAnthropicMessages, tc.model, body)
hookInput := moderationInputFromHookRequest(c.Request.Context(), req)
require.Equal(t, legacy, hookInput, "钩子侧审核输入必须与原 builder 逐字段等价")
})
}
}
// TestContentModerationPreFlightHook_CheckLogContract 锁定核心钩子的
// gateway_check_start / gateway_check_done 日志字段契约与拦截决策映射。
func TestContentModerationPreFlightHook_CheckLogContract(t *testing.T) {
moderationSrv := p2CharFlagAllModerationServer(t)
defer moderationSrv.Close()
core, logs := observer.New(zap.InfoLevel)
reqLog := zap.New(core)
hook := &contentModerationPreFlightHook{svc: passCharModerationService(t, moderationSrv.URL)}
c := preFlightTestGinContext(t, "/v1/messages", "")
apiKey := preFlightTestAPIKey()
subject := middleware.AuthSubject{UserID: 9201, Concurrency: 5}
body := []byte(`{"model":"claude-sonnet-4-5","messages":[{"role":"user","content":"bad words"}]}`)
req := newGatewayHookRequest(c, apiKey, subject, service.ContentModerationProtocolAnthropicMessages, "claude-sonnet-4-5", body)
ctx := withPreFlightLogger(c.Request.Context(), reqLog)
decision, err := hook.CheckPreFlight(ctx, req)
require.NoError(t, err)
require.NotNil(t, decision)
require.True(t, decision.Blocked)
require.Equal(t, "content_policy_violation", decision.ErrorType)
require.NotEmpty(t, decision.Message)
start := logs.FilterMessage("content_moderation.gateway_check_start").All()
require.Len(t, start, 1, "必须记一条 gateway_check_start")
startFields := start[0].ContextMap()
require.Equal(t, []string{
"request_id", "user_id", "api_key_id", "api_key_name", "group_id",
"group_name", "endpoint", "provider", "protocol", "model", "body_bytes",
}, preFlightLogFieldKeys(start[0]), "gateway_check_start 11 字段的名称与顺序必须保留")
require.Equal(t, "req-pf-001", startFields["request_id"])
require.Equal(t, int64(9201), startFields["user_id"])
require.Equal(t, int64(9101), startFields["api_key_id"])
require.Equal(t, "pf-key", startFields["api_key_name"])
require.Equal(t, int64(9001), startFields["group_id"])
require.Equal(t, "pf-group", startFields["group_name"])
require.Equal(t, "/v1/messages", startFields["endpoint"])
require.Equal(t, service.PlatformAnthropic, startFields["provider"])
require.Equal(t, service.ContentModerationProtocolAnthropicMessages, startFields["protocol"])
require.Equal(t, "claude-sonnet-4-5", startFields["model"])
require.Equal(t, int64(len(body)), startFields["body_bytes"])
done := logs.FilterMessage("content_moderation.gateway_check_done").All()
require.Len(t, done, 1, "必须记一条 gateway_check_done")
require.Equal(t, []string{
"request_id", "allowed", "blocked", "flagged", "action",
"status_code", "highest_category", "highest_score",
}, preFlightLogFieldKeys(done[0]), "gateway_check_done 8 字段的名称与顺序必须保留")
doneFields := done[0].ContextMap()
require.Equal(t, true, doneFields["blocked"])
require.Equal(t, "req-pf-001", doneFields["request_id"])
}
func preFlightLogFieldKeys(entry observer.LoggedEntry) []string {
keys := make([]string, 0, len(entry.Context))
for _, f := range entry.Context {
keys = append(keys, f.Key)
}
return keys
}
// TestRunGatewayPreFlight_EmptyChainZeroAlloc 锁定审核未装配(空链/nil 链)时
// pre-flight 调用面零分配直接放行——热路径无新增开销。
func TestRunGatewayPreFlight_EmptyChainZeroAlloc(t *testing.T) {
c := preFlightTestGinContext(t, "/v1/messages", "")
apiKey := preFlightTestAPIKey()
subject := middleware.AuthSubject{UserID: 9201}
body := []byte(`{}`)
reqLog := zap.NewNop()
for name, chain := range map[string]*gatewayhook.Chain{
"nil链": nil,
"空链": ProvideGatewayHookChain(nil),
} {
t.Run(name, func(t *testing.T) {
require.Nil(t, runGatewayPreFlight(chain, c, reqLog, apiKey, subject, service.ContentModerationProtocolAnthropicMessages, "m", body))
allocs := testing.AllocsPerRun(100, func() {
_ = runGatewayPreFlight(chain, c, reqLog, apiKey, subject, service.ContentModerationProtocolAnthropicMessages, "m", body)
})
require.Zero(t, allocs, "空链路径必须零分配")
})
}
}
// TestContentModerationPreFlightHook_NilGuards 锁定 nil 防御:
// svc/req 为 nil 或 ctx 无请求级 logger 时不 panic 且放行。
func TestContentModerationPreFlightHook_NilGuards(t *testing.T) {
var nilHook *contentModerationPreFlightHook
d, err := nilHook.CheckPreFlight(context.Background(), &gatewayhook.Request{})
require.NoError(t, err)
require.Nil(t, d)
hookNoSvc := &contentModerationPreFlightHook{}
d, err = hookNoSvc.CheckPreFlight(context.Background(), &gatewayhook.Request{})
require.NoError(t, err)
require.Nil(t, d)
moderationSrv := p2CharFlagAllModerationServer(t)
defer moderationSrv.Close()
hook := &contentModerationPreFlightHook{svc: passCharModerationService(t, moderationSrv.URL)}
d, err = hook.CheckPreFlight(context.Background(), nil)
require.NoError(t, err)
require.Nil(t, d)
}
@@ -187,8 +187,8 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
setOpsRequestContext(c, modelName, stream)
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)
if decision := h.runPreFlightHooks(c, reqLog, apiKey, authSubject, service.ContentModerationProtocolGemini, modelName, body); decision != nil && decision.Blocked {
googleError(c, preFlightStatus(decision), decision.Message)
return
}
@@ -205,6 +205,26 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
// For Gemini native API, do not send Claude-style ping frames.
geminiConcurrency := NewConcurrencyHelper(h.concurrencyHelper.concurrencyService, SSEPingFormatNone, 0)
// 0) wait queue check
maxWait := service.CalculateMaxWait(authSubject.Concurrency)
canWait, err := geminiConcurrency.IncrementWaitCount(c.Request.Context(), authSubject.UserID, maxWait)
waitCounted := false
if err != nil {
reqLog.Warn("gemini.user_wait_counter_increment_failed", zap.Error(err))
} else if !canWait {
reqLog.Info("gemini.user_wait_queue_full", zap.Int("max_wait", maxWait))
googleError(c, http.StatusTooManyRequests, "Too many pending requests, please retry later")
return
}
if err == nil && canWait {
waitCounted = true
}
defer func() {
if waitCounted {
geminiConcurrency.DecrementWaitCount(c.Request.Context(), authSubject.UserID)
}
}()
// 1) user concurrency slot
streamStarted := false
if h.errorPassthroughService != nil {
@@ -216,6 +236,10 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
googleError(c, http.StatusTooManyRequests, err.Error())
return
}
if waitCounted {
geminiConcurrency.DecrementWaitCount(c.Request.Context(), authSubject.UserID)
waitCounted = false
}
// 确保请求取消时也会释放槽位,避免长连接被动中断造成泄漏
userReleaseFunc = wrapReleaseOnDone(c.Request.Context(), userReleaseFunc)
if userReleaseFunc != nil {
@@ -350,15 +374,8 @@ 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 {
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, modelName, modelName, service.PlatformGemini)
if !cls.ModelNotFound {
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
}
message := cls.Message
if !cls.ModelNotFound {
message = "No available Gemini accounts: " + err.Error()
}
googleError(c, cls.Status, message)
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
googleError(c, http.StatusServiceUnavailable, "No available Gemini accounts: "+err.Error())
return
}
action := fs.HandleSelectionExhausted(c.Request.Context())
+1 -1
View File
@@ -17,7 +17,6 @@ type AdminHandlers struct {
OpenAIOAuth *admin.OpenAIOAuthHandler
GeminiOAuth *admin.GeminiOAuthHandler
AntigravityOAuth *admin.AntigravityOAuthHandler
GrokOAuth *admin.GrokOAuthHandler
Proxy *admin.ProxyHandler
Redeem *admin.RedeemHandler
Promo *admin.PromoHandler
@@ -38,6 +37,7 @@ type AdminHandlers struct {
Payment *admin.PaymentHandler
Affiliate *admin.AffiliateHandler
Compliance *admin.ComplianceHandler
Module *admin.ModuleHandler
}
// Handlers contains all HTTP handlers
@@ -1,109 +0,0 @@
package handler
import (
"context"
"fmt"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/Wei-Shaw/sub2api/internal/service"
)
// noAccountErrorClassification describes the HTTP response to emit when
// account selection failed with ErrNoAvailableAccounts. Handlers obtain it
// via classifyNoAccountError and choose between:
//
// - 404 model_not_found — the group has accounts, but none of them are
// configured to serve the requested model (config / typo / unsupported
// model). Returning 503 here misleads operators and trips reverse-proxy
// health checks; 404 lets the client surface the real problem.
//
// - 503 api_error — accounts that could serve the model exist but are
// temporarily exhausted (rate limit, quota auto-pause, runtime block) OR
// the group has no accounts at all. Both stay on 503 because retrying
// after a backoff can plausibly succeed (or, in the empty-pool case, the
// operator may be in the middle of adding accounts).
type noAccountErrorClassification struct {
Status int
ErrType string
Message string
ModelNotFound bool // true when this is a 404 model_not_found classification
}
// classifyNoAccountError decides between 404 model_not_found and 503
// api_error for "no available accounts" failures.
//
// The classifier intentionally does not consume the original error: the
// selection layer never tells us *why* the pool came up empty (rate-limited
// vs. unsupported model are both wrapped as ErrNoAvailableAccounts). Instead
// we re-check pool composition through DiagnoseModelAvailabilityForPlatform,
// which only inspects model_mapping configuration and ignores transient
// state. That guarantees a 404 is only returned when no operator action
// short of editing the account's model_mapping could make this request
// succeed.
//
// routingModel is the model name that account selection actually compared
// against (i.e. after group-level dispatch mapping). displayModel is the
// raw model the caller asked for; it is used only in the user-facing error
// message so that internal mapping details don't leak. Most callers pass
// the same value for both.
//
// platform is the platform the request was routed to (use
// service.PlatformOpenAI / PlatformAnthropic / PlatformGemini). It is
// required because Anthropic/Gemini routes additionally surface
// mixed-scheduled Antigravity accounts; passing the wrong platform would
// flip a legitimate 503 to a misleading 404 (or vice versa).
func classifyNoAccountError(
ctx context.Context,
diag service.ModelAvailabilityDiagnoser,
apiKey *service.APIKey,
routingModel string,
displayModel string,
platform string,
) noAccountErrorClassification {
fallback := noAccountErrorClassification{
Status: http.StatusServiceUnavailable,
ErrType: "api_error",
Message: "Service temporarily unavailable",
}
routingModel = strings.TrimSpace(routingModel)
displayModel = strings.TrimSpace(displayModel)
if displayModel == "" {
displayModel = routingModel
}
if diag == nil || apiKey == nil || apiKey.GroupID == nil || routingModel == "" {
return fallback
}
result := diag.DiagnoseModelAvailabilityForPlatform(ctx, apiKey.GroupID, routingModel, platform)
if result.HasAccountsInPool && !result.HasModelSupport {
return noAccountErrorClassification{
Status: http.StatusNotFound,
ErrType: "model_not_found",
Message: fmt.Sprintf("Model %q is not supported by any configured account in this group", displayModel),
ModelNotFound: true,
}
}
return fallback
}
// classifyNoAccountErrorFromGin is a thin wrapper that forwards the gin
// context's underlying request context. Most call sites already have a
// *gin.Context handy, so this keeps the call sites uncluttered.
func classifyNoAccountErrorFromGin(
c *gin.Context,
diag service.ModelAvailabilityDiagnoser,
apiKey *service.APIKey,
routingModel string,
displayModel string,
platform string,
) noAccountErrorClassification {
ctx := context.Background()
if c != nil && c.Request != nil {
ctx = c.Request.Context()
}
return classifyNoAccountError(ctx, diag, apiKey, routingModel, displayModel, platform)
}
@@ -1,161 +0,0 @@
//go:build unit
package handler
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/Wei-Shaw/sub2api/internal/service"
)
type fakeDiagnoser struct {
calls []fakeDiagnoseCall
resp service.ModelAvailabilityDiagnosis
}
type fakeDiagnoseCall struct {
GroupID *int64
Model string
Platform string
}
func (f *fakeDiagnoser) DiagnoseModelAvailabilityForPlatform(
_ context.Context,
groupID *int64,
model, platform string,
) service.ModelAvailabilityDiagnosis {
f.calls = append(f.calls, fakeDiagnoseCall{
GroupID: groupID,
Model: model,
Platform: platform,
})
return f.resp
}
func ptrInt64(v int64) *int64 { return &v }
// newTestGinContextWithRequest wraps the bare newTestGinContext helper
// (defined in openai_gateway_cyber_test.go) by additionally attaching a stub
// *http.Request so the classifier can extract c.Request.Context().
func newTestGinContextWithRequest() *gin.Context {
c := newTestGinContext()
c.Request = httptest.NewRequest(http.MethodPost, "/test", nil)
return c
}
func TestClassifyNoAccountError_NilDiagnoser_Falls503(t *testing.T) {
c := newTestGinContextWithRequest()
apiKey := &service.APIKey{GroupID: ptrInt64(7)}
cls := classifyNoAccountErrorFromGin(c, nil, apiKey, "gpt-5", "gpt-5", service.PlatformOpenAI)
require.Equal(t, http.StatusServiceUnavailable, cls.Status)
require.Equal(t, "api_error", cls.ErrType)
require.False(t, cls.ModelNotFound)
}
func TestClassifyNoAccountError_NilAPIKey_Falls503(t *testing.T) {
c := newTestGinContextWithRequest()
fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: false}}
cls := classifyNoAccountErrorFromGin(c, fd, nil, "gpt-5", "gpt-5", service.PlatformOpenAI)
require.Equal(t, http.StatusServiceUnavailable, cls.Status)
require.False(t, cls.ModelNotFound)
require.Empty(t, fd.calls, "diagnoser must not be consulted when apiKey missing")
}
func TestClassifyNoAccountError_NilGroupID_Falls503(t *testing.T) {
c := newTestGinContextWithRequest()
fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: false}}
apiKey := &service.APIKey{GroupID: nil}
cls := classifyNoAccountErrorFromGin(c, fd, apiKey, "gpt-5", "gpt-5", service.PlatformOpenAI)
require.Equal(t, http.StatusServiceUnavailable, cls.Status)
require.False(t, cls.ModelNotFound)
require.Empty(t, fd.calls, "diagnoser must not be consulted when group not bound")
}
func TestClassifyNoAccountError_EmptyModel_Falls503(t *testing.T) {
c := newTestGinContextWithRequest()
fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: false}}
apiKey := &service.APIKey{GroupID: ptrInt64(7)}
cls := classifyNoAccountErrorFromGin(c, fd, apiKey, " ", "", service.PlatformOpenAI)
require.Equal(t, http.StatusServiceUnavailable, cls.Status)
require.False(t, cls.ModelNotFound)
require.Empty(t, fd.calls)
}
func TestClassifyNoAccountError_ModelNotSupported_Returns404(t *testing.T) {
c := newTestGinContextWithRequest()
fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: false}}
apiKey := &service.APIKey{GroupID: ptrInt64(42)}
cls := classifyNoAccountErrorFromGin(c, fd, apiKey, "gpt-5.1-codex-mini", "gpt-5.1-codex-mini", service.PlatformOpenAI)
require.Equal(t, http.StatusNotFound, cls.Status)
require.Equal(t, "model_not_found", cls.ErrType)
require.True(t, cls.ModelNotFound)
require.Contains(t, cls.Message, "gpt-5.1-codex-mini", "message must surface the requested model")
require.Len(t, fd.calls, 1)
require.Equal(t, "gpt-5.1-codex-mini", fd.calls[0].Model)
require.Equal(t, service.PlatformOpenAI, fd.calls[0].Platform)
require.NotNil(t, fd.calls[0].GroupID)
require.Equal(t, int64(42), *fd.calls[0].GroupID)
}
func TestClassifyNoAccountError_HasModelSupport_KeepsRoutingMessageGenerationToCaller(t *testing.T) {
c := newTestGinContextWithRequest()
fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: true}}
apiKey := &service.APIKey{GroupID: ptrInt64(7)}
cls := classifyNoAccountErrorFromGin(c, fd, apiKey, "gpt-5", "gpt-5", service.PlatformOpenAI)
require.Equal(t, http.StatusServiceUnavailable, cls.Status, "model exists somewhere — caller stays on 503")
require.Equal(t, "api_error", cls.ErrType)
require.False(t, cls.ModelNotFound)
}
func TestClassifyNoAccountError_NoAccountsInPool_Stays503(t *testing.T) {
c := newTestGinContextWithRequest()
fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: false, HasModelSupport: false}}
apiKey := &service.APIKey{GroupID: ptrInt64(7)}
cls := classifyNoAccountErrorFromGin(c, fd, apiKey, "gpt-5", "gpt-5", service.PlatformOpenAI)
require.Equal(t, http.StatusServiceUnavailable, cls.Status, "empty pool is a service-availability issue, not a model issue")
require.False(t, cls.ModelNotFound)
}
func TestClassifyNoAccountError_DisplayModelOverridesRoutingForMessage(t *testing.T) {
c := newTestGinContextWithRequest()
fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: false}}
apiKey := &service.APIKey{GroupID: ptrInt64(7)}
cls := classifyNoAccountErrorFromGin(c, fd, apiKey, "gpt-5", "claude-3-fancy", service.PlatformOpenAI)
require.True(t, cls.ModelNotFound)
require.Contains(t, cls.Message, "claude-3-fancy", "user-facing message must reference the model the user asked for, not the post-mapping routing model")
require.Len(t, fd.calls, 1)
require.Equal(t, "gpt-5", fd.calls[0].Model, "diagnosis must run against the routing model (post group dispatch mapping)")
}
func TestClassifyNoAccountError_FromGin_NilContextStillSafe(t *testing.T) {
fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: false}}
apiKey := &service.APIKey{GroupID: ptrInt64(7)}
cls := classifyNoAccountErrorFromGin(nil, fd, apiKey, "gpt-5", "gpt-5", service.PlatformOpenAI)
require.Equal(t, http.StatusNotFound, cls.Status, "even with a nil gin context the classifier must still run and yield a coherent response")
require.True(t, cls.ModelNotFound)
}
@@ -85,11 +85,8 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
setOpsRequestContext(c, reqModel, reqStream)
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
}
if h.rejectIfCyberSessionBlocked(c, apiKey, body, reqModel, cyberBlockFormatChat) {
if decision := h.runPreFlightHooks(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIChat, reqModel, body); decision != nil && decision.Blocked {
h.errorResponse(c, preFlightStatus(decision), preFlightErrorCode(decision), decision.Message)
return
}
@@ -101,7 +98,6 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
}
subscription, _ := middleware2.GetSubscriptionFromContext(c)
requestPlatform := openAICompatibleRequestPlatform(apiKey)
service.SetOpsLatencyMs(c, service.OpsAuthLatencyMsKey, time.Since(requestStart).Milliseconds())
routingStart := time.Now()
@@ -145,7 +141,6 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
service.OpenAIUpstreamTransportAny,
service.OpenAIEndpointCapabilityChatCompletions,
false,
requestPlatform,
)
if err != nil {
reqLog.Warn("openai_chat_completions.account_select_failed",
@@ -153,11 +148,8 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
zap.Int("excluded_account_count", len(failedAccountIDs)),
)
if len(failedAccountIDs) == 0 {
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel, service.PlatformOpenAI)
if !cls.ModelNotFound {
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
}
h.handleStreamingAwareError(c, cls.Status, cls.ErrType, cls.Message, streamStarted)
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "Service temporarily unavailable", streamStarted)
return
} else {
if lastFailoverErr != nil {
@@ -169,11 +161,8 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
}
}
if selection == nil || selection.Account == nil {
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel, service.PlatformOpenAI)
if !cls.ModelNotFound {
markOpsRoutingCapacityLimited(c)
}
h.handleStreamingAwareError(c, cls.Status, cls.ErrType, cls.Message, streamStarted)
markOpsRoutingCapacityLimited(c)
h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available accounts", streamStarted)
return
}
account := selection.Account
@@ -203,11 +192,6 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
}()
return h.gatewayService.ForwardAsChatCompletions(c.Request.Context(), c, account, forwardBody, promptCacheKey, "")
}()
cyberBlockKeyChat := ""
if service.GetOpsCyberPolicy(c) != nil {
cyberBlockKeyChat = service.CyberSessionBlockKey(apiKey.ID, c, body)
}
h.recordCyberPolicyIfMarked(c, apiKey, account, subscription, reqModel, err != nil, cyberBlockKeyChat, channelMapping.ToUsageFields(reqModel, ""), service.HashUsageRequestPayload(body))
forwardDurationMs := time.Since(forwardStart).Milliseconds()
upstreamLatencyMs, _ := getContextInt64(c, service.OpsUpstreamLatencyMsKey)
@@ -297,9 +281,8 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
userAgent := c.GetHeader("User-Agent")
clientIP := ip.GetClientIP(c)
inboundEndpoint := GetInboundEndpoint(c)
upstreamEndpoint := resolveOpenAIUpstreamEndpoint(c, account)
upstreamEndpoint := resolveRawCCUpstreamEndpoint(c, account)
cyberBlocked := service.GetOpsCyberPolicy(c) != nil
h.submitOpenAIUsageRecordTask(c.Request.Context(), result, func(ctx context.Context) {
if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{
Result: result,
@@ -313,7 +296,6 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
IPAddress: clientIP,
APIKeyService: h.apiKeyService,
ChannelUsageFields: channelMapping.ToUsageFields(reqModel, result.UpstreamModel),
CyberBlocked: cyberBlocked,
}); err != nil {
logger.L().With(
zap.String("component", "handler.openai_gateway.chat_completions"),
@@ -333,12 +315,12 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
}
}
// resolveOpenAIUpstreamEndpoint returns the actual upstream endpoint for an
// OpenAI account, used by every OpenAI usage-recording site. APIKey accounts
// whose upstream is forced or probed to not support the Responses API are
// served directly via /v1/chat/completions (the raw chat path) regardless of
// the inbound endpoint; everything else goes through the Responses API.
func resolveOpenAIUpstreamEndpoint(c *gin.Context, account *service.Account) string {
// resolveRawCCUpstreamEndpoint returns the actual upstream endpoint for
// OpenAI Chat Completions requests. For APIKey accounts whose upstream
// is forced or 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"
+4 -10
View File
@@ -124,11 +124,8 @@ func (h *OpenAIGatewayHandler) Embeddings(c *gin.Context) {
zap.Int("excluded_account_count", len(failedAccountIDs)),
)
if len(failedAccountIDs) == 0 {
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel, service.PlatformOpenAI)
if !cls.ModelNotFound {
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
}
h.errorResponse(c, cls.Status, cls.ErrType, cls.Message)
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
h.errorResponse(c, http.StatusServiceUnavailable, "api_error", "Service temporarily unavailable")
return
}
if lastFailoverErr != nil {
@@ -139,11 +136,8 @@ func (h *OpenAIGatewayHandler) Embeddings(c *gin.Context) {
return
}
if selection == nil || selection.Account == nil {
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel, service.PlatformOpenAI)
if !cls.ModelNotFound {
markOpsRoutingCapacityLimited(c)
}
h.errorResponse(c, cls.Status, cls.ErrType, cls.Message)
markOpsRoutingCapacityLimited(c)
h.errorResponse(c, http.StatusServiceUnavailable, "api_error", "No available accounts")
return
}
account := selection.Account
@@ -1,180 +0,0 @@
package handler
import (
"net/http/httptest"
"strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
// newTestGinContext builds a bare gin.Context backed by an httptest recorder.
func newTestGinContext() *gin.Context {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
return c
}
// TestRecordCyberPolicyIfMarked_NoMark verifies that when no cyber mark is set,
// the function returns immediately and does NOT set the recorded flag.
func TestRecordCyberPolicyIfMarked_NoMark(t *testing.T) {
c := newTestGinContext()
h := &OpenAIGatewayHandler{}
h.recordCyberPolicyIfMarked(c, nil, nil, nil, "gpt-5", true, "", service.ChannelUsageFields{}, "")
// Flag must NOT be set when there was no mark.
require.False(t, c.GetBool(cyberPolicyRecordedKey),
"cyberPolicyRecordedKey must remain false when no cyber mark is present")
}
// TestRecordCyberPolicyIfMarked_WithMark verifies that:
// 1. When a cyber mark is present, the recorded flag is set (guard activated).
// 2. A second call is a no-op (idempotent guard).
// 3. Nil services do not panic.
func TestRecordCyberPolicyIfMarked_WithMark(t *testing.T) {
c := newTestGinContext()
service.MarkOpsCyberPolicy(c, service.CyberPolicyMark{
Message: "flagged",
Body: `{"error":{"code":"cyber_policy"}}`,
UpstreamStatus: 400,
})
h := &OpenAIGatewayHandler{} // nil services — must not panic
// First call: should set the flag.
require.NotPanics(t, func() {
h.recordCyberPolicyIfMarked(c, nil, nil, nil, "gpt-5", true, "", service.ChannelUsageFields{}, "")
})
require.True(t, c.GetBool(cyberPolicyRecordedKey),
"cyberPolicyRecordedKey must be true after first call with a mark")
// Second call: flag already set — must be a no-op (idempotent).
require.NotPanics(t, func() {
h.recordCyberPolicyIfMarked(c, nil, nil, nil, "gpt-5", false, "", service.ChannelUsageFields{}, "")
})
// Flag should still be true (not toggled or cleared).
require.True(t, c.GetBool(cyberPolicyRecordedKey),
"cyberPolicyRecordedKey must remain true after second call (guard)")
}
// TestRecordCyberPolicyIfMarked_ForwardSuccessSkipsUsageLog verifies the semantic:
// when forwardErrored=false the function still sets the guard flag (mark present),
// but the cyber usage row is NOT requested (only RecordCyberPolicyEvent fires).
// Since services are nil here we only verify the guard flag and no panic.
func TestRecordCyberPolicyIfMarked_ForwardSuccessSkipsUsageLog(t *testing.T) {
c := newTestGinContext()
service.MarkOpsCyberPolicy(c, service.CyberPolicyMark{
Message: "flagged",
UpstreamStatus: 200,
})
h := &OpenAIGatewayHandler{}
require.NotPanics(t, func() {
h.recordCyberPolicyIfMarked(c, nil, nil, nil, "gpt-5", false /* forwardErrored=false */, "", service.ChannelUsageFields{}, "")
})
require.True(t, c.GetBool(cyberPolicyRecordedKey))
}
// TestClearCyberPolicyTurnState verifies F1 at the handler level: after a turn
// is finalized, both the mark and the recorded guard are reset so the next WS
// turn detects/records independently.
func TestClearCyberPolicyTurnState(t *testing.T) {
c := newTestGinContext()
h := &OpenAIGatewayHandler{}
service.MarkOpsCyberPolicy(c, service.CyberPolicyMark{Message: "turn1", UpstreamStatus: 200})
h.recordCyberPolicyIfMarked(c, nil, nil, nil, "gpt-5", false, "", service.ChannelUsageFields{}, "")
require.True(t, c.GetBool(cyberPolicyRecordedKey))
clearCyberPolicyTurnState(c)
require.Nil(t, service.GetOpsCyberPolicy(c))
require.False(t, c.GetBool(cyberPolicyRecordedKey))
// turn2: a fresh cyber hit must be recordable again.
service.MarkOpsCyberPolicy(c, service.CyberPolicyMark{Message: "turn2", UpstreamStatus: 200})
h.recordCyberPolicyIfMarked(c, nil, nil, nil, "gpt-5", false, "", service.ChannelUsageFields{}, "")
require.True(t, c.GetBool(cyberPolicyRecordedKey))
require.Equal(t, "turn2", service.GetOpsCyberPolicy(c).Message)
}
// TestBuildCyberSessionBlockedOpsEntry verifies the locally-rejected request is
// auditable: 403 / phase=request / type=cyber_policy_session_blocked — distinct
// from upstream cyber_policy hits, and it must NOT touch moderation/violation.
func TestBuildCyberSessionBlockedOpsEntry(t *testing.T) {
entry := buildCyberSessionBlockedOpsEntry(cyberPolicyOpsErrorMeta{
RequestID: "req-9", Model: "gpt-5", RequestPath: "/openai/v1/responses",
})
require.Equal(t, 403, entry.StatusCode)
require.Equal(t, "cyber_policy_session_blocked", entry.ErrorType)
require.Equal(t, "request", entry.ErrorPhase)
require.True(t, entry.IsBusinessLimited)
require.Equal(t, "gateway_local", entry.ErrorSource)
require.Equal(t, "platform", entry.ErrorOwner)
require.Empty(t, entry.ErrorBody, "no session block key → ErrorBody must be empty")
entryWithKey := buildCyberSessionBlockedOpsEntry(cyberPolicyOpsErrorMeta{
RequestID: "req-9", Model: "gpt-5", RequestPath: "/openai/v1/responses",
SessionBlockKey: "abc123",
})
require.Equal(t, "session_block_key=abc123", entryWithKey.ErrorBody)
}
// TestRejectIfCyberSessionBlocked_FailOpen verifies fail-open paths: nil handler
// services, no explicit session signal, and (implicitly) disabled switch all
// pass the request through.
func TestRejectIfCyberSessionBlocked_FailOpen(t *testing.T) {
c := newTestGinContext()
c.Request = httptest.NewRequest("POST", "/openai/v1/responses", strings.NewReader(`{}`))
h := &OpenAIGatewayHandler{}
require.False(t, h.rejectIfCyberSessionBlocked(c, nil, []byte(`{}`), "gpt-5", cyberBlockFormatResponses), "nil apiKey → pass")
h2 := &OpenAIGatewayHandler{gatewayService: nil}
key := &service.APIKey{ID: 1}
require.False(t, h2.rejectIfCyberSessionBlocked(c, key, []byte(`{}`), "gpt-5", cyberBlockFormatResponses), "nil gateway service → pass")
}
// TestRecordCyberPolicyIfMarked_BlockKeyPlumbed verifies the 6th param is
// accepted and a non-empty key with nil gateway service does not panic
// (write-side guards live in the service layer).
func TestRecordCyberPolicyIfMarked_BlockKeyPlumbed(t *testing.T) {
c := newTestGinContext()
service.MarkOpsCyberPolicy(c, service.CyberPolicyMark{Message: "x", UpstreamStatus: 400})
h := &OpenAIGatewayHandler{}
require.NotPanics(t, func() {
h.recordCyberPolicyIfMarked(c, nil, nil, nil, "gpt-5", true, "deadbeef", service.ChannelUsageFields{}, "")
})
}
// TestBuildCyberPolicyOpsErrorEntry_StatusCode verifies F6: the ops error log
// records the status the codex client actually received (400 non-stream / 200 stream),
// not a hardcoded 403.
func TestBuildCyberPolicyOpsErrorEntry_StatusCode(t *testing.T) {
for _, tc := range []struct {
name string
upstreamStatus int
}{
{"non_stream_400", 400},
{"stream_200", 200},
{"zero_value", 0},
} {
t.Run(tc.name, func(t *testing.T) {
mark := &service.CyberPolicyMark{
Code: "cyber_policy",
Message: "blocked",
UpstreamStatus: tc.upstreamStatus,
}
entry := buildCyberPolicyOpsErrorEntry(cyberPolicyOpsErrorMeta{
RequestID: "req-1", Model: "gpt-5", RequestPath: "/openai/v1/responses",
}, mark)
require.Equal(t, tc.upstreamStatus, entry.StatusCode)
require.Equal(t, "cyber_policy", entry.ErrorType)
require.Equal(t, "request", entry.ErrorPhase)
})
}
}
@@ -12,6 +12,7 @@ import (
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/gatewayhook"
"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"
@@ -34,7 +35,7 @@ type OpenAIGatewayHandler struct {
usageRecordWorkerPool *service.UsageRecordWorkerPool
errorPassthroughService *service.ErrorPassthroughService
contentModerationService *service.ContentModerationService
opsService *service.OpsService
preFlightHooks *gatewayhook.Chain
concurrencyHelper *ConcurrencyHelper
imageLimiter *imageConcurrencyLimiter
maxAccountSwitches int
@@ -97,13 +98,6 @@ func wrapUsageRecordTaskContext(parent context.Context, task service.UsageRecord
}
}
func openAICompatibleRequestPlatform(apiKey *service.APIKey) string {
if apiKey != nil && apiKey.Group != nil && apiKey.Group.Platform == service.PlatformGrok {
return service.PlatformGrok
}
return service.PlatformOpenAI
}
// NewOpenAIGatewayHandler creates a new OpenAIGatewayHandler
func NewOpenAIGatewayHandler(
gatewayService *service.OpenAIGatewayService,
@@ -113,7 +107,7 @@ func NewOpenAIGatewayHandler(
usageRecordWorkerPool *service.UsageRecordWorkerPool,
errorPassthroughService *service.ErrorPassthroughService,
contentModerationService *service.ContentModerationService,
opsService *service.OpsService,
preFlightHooks *gatewayhook.Chain,
cfg *config.Config,
) *OpenAIGatewayHandler {
pingInterval := time.Duration(0)
@@ -131,7 +125,7 @@ func NewOpenAIGatewayHandler(
usageRecordWorkerPool: usageRecordWorkerPool,
errorPassthroughService: errorPassthroughService,
contentModerationService: contentModerationService,
opsService: opsService,
preFlightHooks: preFlightHooks,
concurrencyHelper: NewConcurrencyHelper(concurrencyService, SSEPingFormatComment, pingInterval),
imageLimiter: &imageConcurrencyLimiter{},
maxAccountSwitches: maxAccountSwitches,
@@ -251,8 +245,8 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
setOpsRequestContext(c, reqModel, reqStream)
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)
if decision := h.runPreFlightHooks(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIResponses, reqModel, body); decision != nil && decision.Blocked {
h.errorResponse(c, preFlightStatus(decision), preFlightErrorCode(decision), decision.Message)
return
}
@@ -289,7 +283,6 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
// Get subscription info (may be nil)
subscription, _ := middleware2.GetSubscriptionFromContext(c)
requestPlatform := openAICompatibleRequestPlatform(apiKey)
service.SetOpsLatencyMs(c, service.OpsAuthLatencyMsKey, time.Since(requestStart).Milliseconds())
routingStart := time.Now()
@@ -316,9 +309,6 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
// Generate session hash (header first; fallback to prompt_cache_key)
sessionHash := h.gatewayService.GenerateSessionHash(c, sessionHashBody)
if h.rejectIfCyberSessionBlocked(c, apiKey, sessionHashBody, reqModel, cyberBlockFormatResponses) {
return
}
requireCompact := isOpenAIRemoteCompactPath(c)
maxAccountSwitches := h.maxAccountSwitches
@@ -340,7 +330,6 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
service.OpenAIUpstreamTransportAny,
service.OpenAIEndpointCapabilityChatCompletions,
requireCompact,
requestPlatform,
)
if err != nil {
reqLog.Warn("openai.account_select_failed",
@@ -348,16 +337,12 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
zap.Int("excluded_account_count", len(failedAccountIDs)),
)
if len(failedAccountIDs) == 0 {
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
if errors.Is(err, service.ErrNoAvailableCompactAccounts) {
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "compact_not_supported", "No available OpenAI accounts support /responses/compact", streamStarted)
return
}
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel, service.PlatformOpenAI)
if !cls.ModelNotFound {
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
}
h.handleStreamingAwareError(c, cls.Status, cls.ErrType, cls.Message, streamStarted)
h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "Service temporarily unavailable", streamStarted)
return
}
if lastFailoverErr != nil {
@@ -368,11 +353,8 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
return
}
if selection == nil || selection.Account == nil {
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel, service.PlatformOpenAI)
if !cls.ModelNotFound {
markOpsRoutingCapacityLimited(c)
}
h.handleStreamingAwareError(c, cls.Status, cls.ErrType, cls.Message, streamStarted)
markOpsRoutingCapacityLimited(c)
h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available accounts", streamStarted)
return
}
if previousResponseID != "" && selection != nil && selection.Account != nil {
@@ -409,11 +391,6 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
}()
return h.gatewayService.Forward(c.Request.Context(), c, account, forwardBody)
}()
cyberBlockKeyHTTP := ""
if service.GetOpsCyberPolicy(c) != nil {
cyberBlockKeyHTTP = service.CyberSessionBlockKey(apiKey.ID, c, sessionHashBody)
}
h.recordCyberPolicyIfMarked(c, apiKey, account, subscription, reqModel, err != nil, cyberBlockKeyHTTP, channelMapping.ToUsageFields(reqModel, ""), service.HashUsageRequestPayload(body))
forwardDurationMs := time.Since(forwardStart).Milliseconds()
upstreamLatencyMs, _ := getContextInt64(c, service.OpsUpstreamLatencyMsKey)
responseLatencyMs := forwardDurationMs
@@ -512,10 +489,9 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
clientIP := ip.GetClientIP(c)
requestPayloadHash := service.HashUsageRequestPayload(body)
inboundEndpoint := GetInboundEndpoint(c)
upstreamEndpoint := resolveOpenAIUpstreamEndpoint(c, account)
upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform)
// 使用量记录通过有界 worker 池提交,避免请求热路径创建无界 goroutine。
cyberBlocked := service.GetOpsCyberPolicy(c) != nil
h.submitOpenAIUsageRecordTask(c.Request.Context(), result, func(ctx context.Context) {
if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{
Result: result,
@@ -530,7 +506,6 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
RequestPayloadHash: requestPayloadHash,
APIKeyService: h.apiKeyService,
ChannelUsageFields: channelMapping.ToUsageFields(reqModel, result.UpstreamModel),
CyberBlocked: cyberBlocked,
}); err != nil {
logger.L().With(
zap.String("component", "handler.openai_gateway.responses"),
@@ -702,8 +677,8 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
setOpsRequestContext(c, reqModel, reqStream)
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)
if decision := h.runPreFlightHooks(c, reqLog, apiKey, subject, service.ContentModerationProtocolAnthropicMessages, reqModel, body); decision != nil && decision.Blocked {
h.anthropicErrorResponse(c, preFlightStatus(decision), preFlightErrorCode(decision), decision.Message)
return
}
@@ -717,7 +692,6 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
}
subscription, _ := middleware2.GetSubscriptionFromContext(c)
requestPlatform := openAICompatibleRequestPlatform(apiKey)
service.SetOpsLatencyMs(c, service.OpsAuthLatencyMsKey, time.Since(requestStart).Milliseconds())
routingStart := time.Now()
@@ -743,9 +717,6 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
sessionHash := h.gatewayService.GenerateSessionHash(c, body)
promptCacheKey := h.gatewayService.ExtractSessionID(c, body)
sessionHash, promptCacheKey = resolveOpenAIMessagesMetadataSession(sessionHash, promptCacheKey, reqModel, body)
if h.rejectIfCyberSessionBlocked(c, apiKey, body, reqModel, cyberBlockFormatAnthropic) {
return
}
maxAccountSwitches := h.maxAccountSwitches
switchCount := 0
@@ -770,7 +741,6 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
service.OpenAIUpstreamTransportAny,
service.OpenAIEndpointCapabilityChatCompletions,
false,
requestPlatform,
)
if err != nil {
reqLog.Warn("openai_messages.account_select_failed",
@@ -779,11 +749,8 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
)
if len(failedAccountIDs) == 0 {
if err != nil {
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, currentRoutingModel, reqModel, service.PlatformOpenAI)
if !cls.ModelNotFound {
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
}
h.anthropicStreamingAwareError(c, cls.Status, cls.ErrType, cls.Message, streamStarted)
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
h.anthropicStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "Service temporarily unavailable", streamStarted)
return
}
} else {
@@ -796,11 +763,8 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
}
}
if selection == nil || selection.Account == nil {
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, currentRoutingModel, reqModel, service.PlatformOpenAI)
if !cls.ModelNotFound {
markOpsRoutingCapacityLimited(c)
}
h.anthropicStreamingAwareError(c, cls.Status, cls.ErrType, cls.Message, streamStarted)
markOpsRoutingCapacityLimited(c)
h.anthropicStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available accounts", streamStarted)
return
}
account := selection.Account
@@ -829,11 +793,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
}()
return h.gatewayService.ForwardAsAnthropic(c.Request.Context(), c, account, forwardBody, promptCacheKey, defaultMappedModel)
}()
cyberBlockKeyMsg := ""
if service.GetOpsCyberPolicy(c) != nil {
cyberBlockKeyMsg = service.CyberSessionBlockKey(apiKey.ID, c, body)
}
h.recordCyberPolicyIfMarked(c, apiKey, account, subscription, reqModel, err != nil, cyberBlockKeyMsg, channelMappingMsg.ToUsageFields(reqModel, ""), service.HashUsageRequestPayload(body))
forwardDurationMs := time.Since(forwardStart).Milliseconds()
upstreamLatencyMs, _ := getContextInt64(c, service.OpsUpstreamLatencyMsKey)
responseLatencyMs := forwardDurationMs
@@ -925,9 +885,8 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
clientIP := ip.GetClientIP(c)
requestPayloadHash := service.HashUsageRequestPayload(body)
inboundEndpoint := GetInboundEndpoint(c)
upstreamEndpoint := resolveOpenAIUpstreamEndpoint(c, account)
upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform)
cyberBlocked := service.GetOpsCyberPolicy(c) != nil
h.submitOpenAIUsageRecordTask(c.Request.Context(), result, func(ctx context.Context) {
if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{
Result: result,
@@ -942,7 +901,6 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
RequestPayloadHash: requestPayloadHash,
APIKeyService: h.apiKeyService,
ChannelUsageFields: channelMappingMsg.ToUsageFields(reqModel, result.UpstreamModel),
CyberBlocked: cyberBlocked,
}); err != nil {
logger.L().With(
zap.String("component", "handler.openai_gateway.messages"),
@@ -1065,12 +1023,46 @@ func (h *OpenAIGatewayHandler) acquireResponsesUserSlot(
reqLog *zap.Logger,
) (func(), bool) {
ctx := c.Request.Context()
userReleaseFunc, err := h.concurrencyHelper.AcquireUserSlotWithWait(c, userID, userConcurrency, reqStream, streamStarted)
userReleaseFunc, userAcquired, err := h.concurrencyHelper.TryAcquireUserSlot(ctx, userID, userConcurrency)
if err != nil {
reqLog.Warn("openai.user_slot_acquire_failed", zap.Error(err))
h.handleConcurrencyError(c, err, "user", *streamStarted)
return nil, false
}
if userAcquired {
return wrapReleaseOnDone(ctx, userReleaseFunc), true
}
maxWait := service.CalculateMaxWait(userConcurrency)
canWait, waitErr := h.concurrencyHelper.IncrementWaitCount(ctx, userID, maxWait)
if waitErr != nil {
reqLog.Warn("openai.user_wait_counter_increment_failed", zap.Error(waitErr))
// 按现有降级语义:等待计数异常时放行后续抢槽流程
} else if !canWait {
reqLog.Info("openai.user_wait_queue_full", zap.Int("max_wait", maxWait))
h.errorResponse(c, http.StatusTooManyRequests, "rate_limit_error", "Too many pending requests, please retry later")
return nil, false
}
waitCounted := waitErr == nil && canWait
defer func() {
if waitCounted {
h.concurrencyHelper.DecrementWaitCount(ctx, userID)
}
}()
userReleaseFunc, err = h.concurrencyHelper.AcquireUserSlotWithWait(c, userID, userConcurrency, reqStream, streamStarted)
if err != nil {
reqLog.Warn("openai.user_slot_acquire_failed_after_wait", zap.Error(err))
h.handleConcurrencyError(c, err, "user", *streamStarted)
return nil, false
}
// 槽位获取成功后,立刻退出等待计数。
if waitCounted {
h.concurrencyHelper.DecrementWaitCount(ctx, userID)
waitCounted = false
}
return wrapReleaseOnDone(ctx, userReleaseFunc), true
}
@@ -1271,17 +1263,6 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
return
}
// F5a: 握手层会话屏蔽检查。WS 握手无 body,显式标识仅来自握手 header
// (session_id / conversation_id);无标识则放行,连接内仍有本地 flag 兜底。
cyberBlockKey := service.CyberSessionBlockKey(apiKey.ID, c, nil)
if cyberBlockKey != "" && h.gatewayService.IsCyberSessionBlocked(c.Request.Context(), cyberBlockKey) {
writeCyberSessionBlockedWSError(c.Request.Context(), wsConn)
closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, "session blocked by cyber-security policy")
h.enqueueCyberSessionBlockedOpsEntry(c, apiKey, reqModel, cyberBlockKey)
return
}
cyberBlockedThisConn := false
// 解析渠道级模型映射
channelMappingWS, _ := h.gatewayService.ResolveChannelMappingAndRestrict(ctx, apiKey.GroupID, reqModel)
@@ -1333,7 +1314,6 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
}
subscription, _ := middleware2.GetSubscriptionFromContext(c)
requestPlatform := openAICompatibleRequestPlatform(apiKey)
if err := h.billingCacheService.CheckBillingEligibility(ctx, apiKey.User, apiKey, apiKey.Group, subscription, service.QuotaPlatform(c.Request.Context(), apiKey)); err != nil {
reqLog.Info("openai.websocket_billing_eligibility_check_failed", zap.Error(err))
closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, "billing check failed")
@@ -1362,7 +1342,6 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
service.OpenAIUpstreamTransportResponsesWebsocketV2,
service.OpenAIEndpointCapabilityChatCompletions,
false,
requestPlatform,
)
if err != nil {
reqLog.Warn("openai.websocket_account_select_failed",
@@ -1455,10 +1434,6 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
return nil
},
BeforeTurn: func(turn int) error {
// turn==1 的会话屏蔽已由握手层检查覆盖;连接内 flag 只拦截后续 turn。
if cyberBlockedThisConn {
return service.NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, cyberSessionBlockedClientMsg, nil)
}
if turn == 1 {
return nil
}
@@ -1490,24 +1465,11 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
return nil
},
AfterTurn: func(turn int, result *service.OpenAIForwardResult, turnErr error) {
// F1: cyber 标记按 turn 生命周期清理——defer 保证任意早返回路径都执行;
// CyberBlocked 必须在 submit 前同步预捕获(task 闭包由 worker 池异步执行,
// 届时 defer 已清除标记)。
defer clearCyberPolicyTurnState(c)
releaseTurnSlots()
h.recordCyberPolicyIfMarked(c, apiKey, account, subscription, reqModel, turnErr != nil, cyberBlockKey, channelMappingWS.ToUsageFields(reqModel, ""), requestPayloadHash)
if service.GetOpsCyberPolicy(c) != nil {
cyberBlockedThisConn = true
}
if turnErr != nil {
if result == nil || result.ImageCount <= 0 {
return
}
// cyber 命中时该 turn 的用量已由 recordCyberPolicyIfMarked(forwardErrored=true)
// 按真实 token 记录,这里不再走下方 RecordUsage,避免对同一 turn 双写/双扣费。
if service.GetOpsCyberPolicy(c) != nil {
return
}
reqLog.Warn("openai.websocket_partial_error_with_image_result",
zap.Int64("account_id", account.ID),
zap.Int("image_count", result.ImageCount),
@@ -1522,8 +1484,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
}
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, true, result.FirstTokenMs)
inboundEndpoint := GetInboundEndpoint(c)
upstreamEndpoint := resolveOpenAIUpstreamEndpoint(c, account)
cyberBlocked := service.GetOpsCyberPolicy(c) != nil
upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform)
h.submitOpenAIUsageRecordTask(ctx, result, func(taskCtx context.Context) {
if err := h.gatewayService.RecordUsage(taskCtx, &service.OpenAIRecordUsageInput{
Result: result,
@@ -1538,7 +1499,6 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
RequestPayloadHash: requestPayloadHash,
APIKeyService: h.apiKeyService,
ChannelUsageFields: channelMappingWS.ToUsageFields(reqModel, result.UpstreamModel),
CyberBlocked: cyberBlocked,
}); err != nil {
reqLog.Error("openai.websocket_record_usage_failed",
zap.Int64("account_id", account.ID),
@@ -1948,14 +1908,6 @@ func openAIForwardErrorAlreadyCommunicated(c *gin.Context, writerSizeBeforeForwa
return false
}
// cyber_policy 命中时上游原始错误体已透传给客户端(非流式 c.Data 写出 400 body,
// 流式写出 response.failed 事件),不能再让 ensureForwardErrorResponse 追加
// fallback —— 否则在已写出的完整响应尾部追加 SSE(responses 端点尾随
// response.failed、chat 端点尾随 event:error),污染响应体。Size 已变化证明响应确已写出。
if service.GetOpsCyberPolicy(c) != nil {
return true
}
msg := strings.TrimSpace(err.Error())
for _, prefix := range []string{
"upstream response failed:",
@@ -2069,364 +2021,6 @@ func writeContentModerationWSError(ctx context.Context, conn *coderws.Conn, deci
_ = conn.Write(writeCtx, coderws.MessageText, payload)
}
// writeCyberSessionBlockedWSError sends an error frame telling the client this
// session is blocked by the cyber session block (F5a) before closing.
func writeCyberSessionBlockedWSError(ctx context.Context, conn *coderws.Conn) {
if conn == nil {
return
}
if ctx == nil {
ctx = context.Background()
}
payload, err := json.Marshal(gin.H{
"event_id": "evt_cyber_session_blocked",
"type": "error",
"error": gin.H{
"type": "permission_error",
"code": "session_blocked_by_cyber_policy",
"message": cyberSessionBlockedClientMsg,
},
})
if err != nil {
payload = []byte(`{"event_id":"evt_cyber_session_blocked","type":"error","error":{"type":"permission_error","code":"session_blocked_by_cyber_policy","message":"This session is blocked by cyber-security policy, please start a new session"}}`)
}
writeCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
_ = conn.Write(writeCtx, coderws.MessageText, payload)
}
// cyberPolicyRecordedKey guards against double-firing recordCyberPolicyIfMarked
// within one request (e.g. in a retry/failover loop).
const cyberPolicyRecordedKey = "ops_cyber_recorded"
// cyberPolicyOpsErrorMeta carries request-scoped fields captured outside the
// async goroutine for building the cyber ops_error_logs entry.
type cyberPolicyOpsErrorMeta struct {
RequestID string
ClientRequestID string
Platform string
Model string
RequestPath string
Stream bool
InboundEndpoint string
UserAgent string
APIKeyPrefix string
UserID int64
APIKeyID int64
AccountID int64
GroupID *int64
ClientIP string
CreatedAt time.Time
SessionBlockKey string
}
// buildCyberPolicyOpsErrorEntry builds the ops_error_logs entry for an upstream
// cyber_policy hit. StatusCode mirrors what the codex client actually received
// (400 non-stream / 200 stream), per F6.
func buildCyberPolicyOpsErrorEntry(meta cyberPolicyOpsErrorMeta, mark *service.CyberPolicyMark) *service.OpsInsertErrorLogInput {
rt := int16(service.RequestTypeCyberBlocked)
entry := &service.OpsInsertErrorLogInput{
RequestID: meta.RequestID,
ClientRequestID: meta.ClientRequestID,
Platform: meta.Platform,
Model: meta.Model,
RequestPath: meta.RequestPath,
Stream: meta.Stream,
InboundEndpoint: meta.InboundEndpoint,
RequestType: &rt,
UserAgent: meta.UserAgent,
APIKeyPrefix: meta.APIKeyPrefix,
ErrorPhase: "request",
ErrorType: "cyber_policy",
Severity: "P3",
StatusCode: mark.UpstreamStatus,
IsBusinessLimited: true,
ErrorMessage: "cyber_policy: " + mark.Message,
// 原始 body 直接入队;ops service 落库前统一走 sanitizeErrorBodyForStorage 脱敏与截断。
ErrorBody: mark.Body,
ErrorSource: "upstream_http",
ErrorOwner: "provider",
CreatedAt: meta.CreatedAt,
}
if meta.UserID > 0 {
entry.UserID = &meta.UserID
}
if meta.APIKeyID > 0 {
entry.APIKeyID = &meta.APIKeyID
}
if meta.AccountID > 0 {
entry.AccountID = &meta.AccountID
}
entry.GroupID = meta.GroupID
if meta.ClientIP != "" {
entry.ClientIP = &meta.ClientIP
}
return entry
}
// 双语单串:网关客户端面向中英用户,且本错误无 i18n 协商通道。
const cyberSessionBlockedClientMsg = "该会话已被网络安全策略屏蔽,请开启新会话 / This session is blocked by cyber-security policy, please start a new session"
// buildCyberSessionBlockedOpsEntry builds the ops_error_logs entry for a request
// rejected locally by the cyber session block (F5a). Distinct error_type from
// upstream `cyber_policy`; never feeds moderation logs / violation counting
// (the request never reached upstream — see spec).
func buildCyberSessionBlockedOpsEntry(meta cyberPolicyOpsErrorMeta) *service.OpsInsertErrorLogInput {
rt := int16(service.RequestTypeCyberBlocked)
entry := &service.OpsInsertErrorLogInput{
RequestID: meta.RequestID,
ClientRequestID: meta.ClientRequestID,
Platform: meta.Platform,
Model: meta.Model,
RequestPath: meta.RequestPath,
Stream: meta.Stream,
InboundEndpoint: meta.InboundEndpoint,
RequestType: &rt,
UserAgent: meta.UserAgent,
APIKeyPrefix: meta.APIKeyPrefix,
ErrorPhase: "request",
ErrorType: "cyber_policy_session_blocked",
Severity: "P3",
StatusCode: http.StatusForbidden,
IsBusinessLimited: true,
ErrorMessage: "cyber_policy_session_blocked: request rejected locally by session block",
ErrorSource: "gateway_local",
ErrorOwner: "platform",
CreatedAt: meta.CreatedAt,
// AccountID 有意不设:请求在账号选择前即被拒绝。
}
if meta.SessionBlockKey != "" {
entry.ErrorBody = "session_block_key=" + meta.SessionBlockKey
}
if meta.UserID > 0 {
entry.UserID = &meta.UserID
}
if meta.APIKeyID > 0 {
entry.APIKeyID = &meta.APIKeyID
}
entry.GroupID = meta.GroupID
if meta.ClientIP != "" {
entry.ClientIP = &meta.ClientIP
}
return entry
}
// cyberSessionBlockFormat selects the per-endpoint error envelope for a locally
// blocked session (用户决策:兼容路径各自格式).
type cyberSessionBlockFormat int
const (
cyberBlockFormatResponses cyberSessionBlockFormat = iota
cyberBlockFormatChat
cyberBlockFormatAnthropic
)
// rejectIfCyberSessionBlocked checks the session-block table BEFORE account
// selection. Returns true when the request was rejected (response already
// written + ops entry enqueued). Fail-open: disabled switch / empty key /
// store error → false.
func (h *OpenAIGatewayHandler) rejectIfCyberSessionBlocked(c *gin.Context, apiKey *service.APIKey, body []byte, model string, format cyberSessionBlockFormat) bool {
if h == nil || h.gatewayService == nil || apiKey == nil {
return false
}
// 开关默认关:先走 ~ns 级缓存开关检查,再付出 key 派生(gjson+sha256)成本。
if enabled, _ := h.gatewayService.CyberSessionBlockRuntime(c.Request.Context()); !enabled {
return false
}
key := service.CyberSessionBlockKey(apiKey.ID, c, body)
if key == "" {
return false
}
if !h.gatewayService.IsCyberSessionBlocked(c.Request.Context(), key) {
return false
}
switch format {
case cyberBlockFormatAnthropic:
c.JSON(http.StatusForbidden, gin.H{"type": "error", "error": gin.H{
"type": "permission_error",
"message": cyberSessionBlockedClientMsg,
}})
default: // cyberBlockFormatResponses 与 cyberBlockFormatChat:同构的 OpenAI error envelope
c.JSON(http.StatusForbidden, gin.H{"error": gin.H{
"type": "permission_error",
"code": "session_blocked_by_cyber_policy",
"message": cyberSessionBlockedClientMsg,
}})
}
h.enqueueCyberSessionBlockedOpsEntry(c, apiKey, model, key)
return true
}
// enqueueCyberSessionBlockedOpsEntry captures request meta and enqueues the
// ops_error_logs entry for a locally blocked request.
func (h *OpenAIGatewayHandler) enqueueCyberSessionBlockedOpsEntry(c *gin.Context, apiKey *service.APIKey, model string, sessionBlockKey string) {
if h.opsService == nil {
return
}
meta := cyberPolicyOpsErrorMeta{Model: model, InboundEndpoint: GetInboundEndpoint(c), CreatedAt: time.Now(), SessionBlockKey: sessionBlockKey}
meta.RequestID = c.Writer.Header().Get("X-Request-Id")
if c.Request != nil && c.Request.URL != nil {
meta.RequestPath = c.Request.URL.Path
}
if v, ok := c.Get(opsStreamKey); ok {
if b, ok := v.(bool); ok {
meta.Stream = b
}
}
meta.Platform = resolveOpsPlatform(apiKey, guessPlatformFromPath(meta.RequestPath))
if c.Request != nil {
meta.ClientRequestID, _ = c.Request.Context().Value(ctxkey.ClientRequestID).(string)
meta.UserAgent = c.GetHeader("User-Agent")
meta.ClientIP = strings.TrimSpace(ip.GetClientIP(c))
}
meta.APIKeyID = apiKey.ID
meta.GroupID = apiKey.GroupID
meta.APIKeyPrefix = keyPrefix(apiKey.Key, 8)
if apiKey.User != nil {
meta.UserID = apiKey.User.ID
}
enqueueOpsErrorLog(h.opsService, buildCyberSessionBlockedOpsEntry(meta))
}
// recordCyberPolicyIfMarked 在 gateway forward 返回后检查 cyber 标记,异步写风控日志/邮件,
// 并在 forward 返回错误时写一条 tokens=0 用量行。标记由 gateway 服务层在透传 cyber 后设置;
// 当前请求已发给用户,本方法只做事后记录,不影响响应。forwardErrored 为 true 时才写用量行,
// 避免与正常 RecordUsage(forward 成功路径)重复。每请求至多记录一次。
func (h *OpenAIGatewayHandler) recordCyberPolicyIfMarked(c *gin.Context, apiKey *service.APIKey, account *service.Account, subscription *service.UserSubscription, model string, forwardErrored bool, cyberBlockKey string, channelFields service.ChannelUsageFields, requestPayloadHash string) {
mark := service.GetOpsCyberPolicy(c)
if mark == nil {
return
}
if c.GetBool(cyberPolicyRecordedKey) {
return
}
c.Set(cyberPolicyRecordedKey, true)
requestID := c.Writer.Header().Get("X-Request-Id")
var userID, apiKeyID int64
var userEmail, apiKeyName, groupName string
var groupID *int64
if apiKey != nil {
apiKeyID = apiKey.ID
apiKeyName = apiKey.Name
groupID = apiKey.GroupID
if apiKey.User != nil {
userID = apiKey.User.ID
userEmail = apiKey.User.Email
}
if apiKey.Group != nil {
groupName = apiKey.Group.Name
}
}
inboundEndpoint := GetInboundEndpoint(c)
upstreamEndpoint := ""
var accountID int64
if account != nil {
accountID = account.ID
upstreamEndpoint = resolveOpenAIUpstreamEndpoint(c, account)
}
stream := false
if v, ok := c.Get(opsStreamKey); ok {
if b, ok := v.(bool); ok {
stream = b
}
}
cmSvc := h.contentModerationService
gwSvc := h.gatewayService
opsSvc := h.opsService
apiKeySvc := h.apiKeyService
requestPath := ""
if c.Request != nil && c.Request.URL != nil {
requestPath = c.Request.URL.Path
}
platform := resolveOpsPlatform(apiKey, guessPlatformFromPath(requestPath))
var clientRequestID, userAgent, clientIPStr string
if c.Request != nil {
clientRequestID, _ = c.Request.Context().Value(ctxkey.ClientRequestID).(string)
userAgent = c.GetHeader("User-Agent")
clientIPStr = strings.TrimSpace(ip.GetClientIP(c))
}
apiKeyPrefix := ""
if apiKey != nil {
apiKeyPrefix = keyPrefix(apiKey.Key, 8)
}
opsMeta := cyberPolicyOpsErrorMeta{
RequestID: requestID,
ClientRequestID: clientRequestID,
Platform: platform,
Model: model,
RequestPath: requestPath,
Stream: stream,
InboundEndpoint: inboundEndpoint,
UserAgent: userAgent,
APIKeyPrefix: apiKeyPrefix,
UserID: userID,
APIKeyID: apiKeyID,
AccountID: accountID,
GroupID: groupID,
ClientIP: clientIPStr,
CreatedAt: time.Now(),
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if cmSvc != nil {
cmSvc.RecordCyberPolicyEvent(ctx, service.CyberPolicyRecordInput{
RequestID: requestID,
UserID: userID,
UserEmail: userEmail,
APIKeyID: apiKeyID,
APIKeyName: apiKeyName,
GroupID: groupID,
GroupName: groupName,
Endpoint: inboundEndpoint,
Model: model,
UpstreamMessage: mark.Message,
UpstreamBody: mark.Body,
UpstreamStatus: mark.UpstreamStatus,
UpstreamInTok: mark.UpstreamInTok,
UpstreamOutTok: mark.UpstreamOutTok,
})
}
if forwardErrored && gwSvc != nil {
gwSvc.RecordCyberPolicyUsageLog(ctx, service.CyberPolicyUsageInput{
APIKey: apiKey,
Account: account,
Subscription: subscription,
RequestID: requestID,
Model: model,
Stream: stream,
InputTokens: mark.UpstreamInTok,
OutputTokens: mark.UpstreamOutTok,
InboundEndpoint: inboundEndpoint,
UpstreamEndpoint: upstreamEndpoint,
UserAgent: userAgent,
IPAddress: clientIPStr,
RequestPayloadHash: requestPayloadHash,
APIKeyService: apiKeySvc,
ChannelUsageFields: channelFields,
})
}
if gwSvc != nil && cyberBlockKey != "" {
gwSvc.MarkCyberSessionBlocked(ctx, cyberBlockKey)
}
if opsSvc != nil {
enqueueOpsErrorLog(opsSvc, buildCyberPolicyOpsErrorEntry(opsMeta, mark))
}
}()
}
// clearCyberPolicyTurnState resets the cyber mark and the per-request recorded
// guard. WS-only: called at the END of AfterTurn, after recordCyberPolicyIfMarked
// and RecordUsage (which reads CyberBlocked) have both consumed the mark.
func clearCyberPolicyTurnState(c *gin.Context) {
if c == nil {
return
}
service.ClearOpsCyberPolicy(c)
c.Set(cyberPolicyRecordedKey, false)
}
func summarizeWSCloseErrorForLog(err error) (string, string) {
if err == nil {
return "-", "-"
@@ -805,7 +805,7 @@ func (r *contentModerationHandlerTestRepo) ListLogs(ctx context.Context, filter
return nil, nil, nil
}
func (r *contentModerationHandlerTestRepo) CountFlaggedByUserSince(ctx context.Context, userID int64, since time.Time, excludeCyberPolicy bool) (int, error) {
func (r *contentModerationHandlerTestRepo) CountFlaggedByUserSince(ctx context.Context, userID int64, since time.Time) (int, error) {
return 0, nil
}
@@ -813,10 +813,6 @@ func (r *contentModerationHandlerTestRepo) CleanupExpiredLogs(ctx context.Contex
return &service.ContentModerationCleanupResult{}, nil
}
func (r *contentModerationHandlerTestRepo) UpdateLogEmailSent(ctx context.Context, id int64, sent bool) error {
return nil
}
func TestOpenAIResponsesWebSocket_ContentModerationBlocksFirstFrame(t *testing.T) {
gin.SetMode(gin.TestMode)
@@ -1341,7 +1337,6 @@ func TestOpenAIResponsesWebSocket_FailoverOnUpstreamUsageLimitEvent(t *testing.T
nil,
nil,
nil,
nil,
)
cache := &concurrencyCacheMock{
@@ -1524,7 +1519,6 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU
&service.DeferredService{},
nil,
nil,
nil,
channelSvc,
nil,
nil,
@@ -1660,28 +1654,4 @@ data: {"type":"response.failed","error":{"message":"This content was flagged"}}
require.False(t, reported)
})
// H-2: cyber_policy 命中且响应已写出时,即便 err 前缀不在白名单(非流式 400 cyber
// 返回 "openai cyber_policy:"、透传账号返回 "upstream error:"),也须判定已透传,避免
// ensureForwardErrorResponse 在已写出的完整响应尾部追加 SSE 污染响应体。
t.Run("cyber policy hit after write is already communicated", func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, EndpointResponses, nil)
service.MarkOpsCyberPolicy(c, service.CyberPolicyMark{Message: "blocked", UpstreamStatus: 400})
before := c.Writer.Size()
_, _ = c.Writer.WriteString(`{"error":{"code":"cyber_policy","message":"blocked"}}`)
require.True(t, openAIForwardErrorAlreadyCommunicated(c, before, errors.New("openai cyber_policy: blocked")))
})
// Size 守卫优先于 cyber 短路:cyber 命中但未写出任何响应时仍需补写错误。
t.Run("cyber policy without write still needs fallback", func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, EndpointResponses, nil)
service.MarkOpsCyberPolicy(c, service.CyberPolicyMark{Message: "blocked", UpstreamStatus: 400})
require.False(t, openAIForwardErrorAlreadyCommunicated(c, c.Writer.Size(), errors.New("openai cyber_policy: blocked")))
})
}
+8 -35
View File
@@ -86,8 +86,8 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) {
h.errorResponse(c, http.StatusForbidden, "permission_error", service.ImageGenerationPermissionMessage())
return
}
if decision := h.checkContentModeration(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIImages, requestModel, parsed.ModerationBody()); decision != nil && decision.Blocked {
h.errorResponse(c, contentModerationStatus(decision), contentModerationErrorCode(decision), decision.Message)
if decision := h.runPreFlightHooks(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIImages, requestModel, parsed.ModerationBody()); decision != nil && decision.Blocked {
h.errorResponse(c, preFlightStatus(decision), preFlightErrorCode(decision), decision.Message)
return
}
imageReleaseFunc, acquired := h.acquireImageGenerationSlot(c, streamStarted)
@@ -159,15 +159,8 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) {
zap.Int("excluded_account_count", len(failedAccountIDs)),
)
if len(failedAccountIDs) == 0 {
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, requestModel, requestModel, service.PlatformOpenAI)
if !cls.ModelNotFound {
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
}
message := cls.Message
if !cls.ModelNotFound {
message = "No available compatible accounts"
}
h.handleStreamingAwareError(c, cls.Status, cls.ErrType, message, streamStarted)
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available compatible accounts", streamStarted)
return
}
if lastFailoverErr != nil {
@@ -178,15 +171,8 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) {
return
}
if selection == nil || selection.Account == nil {
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, requestModel, requestModel, service.PlatformOpenAI)
if !cls.ModelNotFound {
markOpsRoutingCapacityLimited(c)
}
message := cls.Message
if !cls.ModelNotFound {
message = "No available compatible accounts"
}
h.handleStreamingAwareError(c, cls.Status, cls.ErrType, message, streamStarted)
markOpsRoutingCapacityLimited(c)
h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available compatible accounts", streamStarted)
return
}
@@ -240,13 +226,8 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) {
} else {
var imageUpstreamErr *service.OpenAIImagesUpstreamError
if errors.As(err, &imageUpstreamErr) {
retryableServerError := service.IsOpenAIImagesRetryableUpstreamError(imageUpstreamErr)
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, !retryableServerError, nil)
logEvent := "openai.images.upstream_user_error"
if retryableServerError {
logEvent = "openai.images.upstream_server_error_after_flush"
}
reqLog.Warn(logEvent,
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, true, nil)
reqLog.Warn("openai.images.upstream_user_error",
zap.Int64("account_id", account.ID),
zap.Int("status_code", imageUpstreamErr.StatusCode),
zap.String("error_type", imageUpstreamErr.ErrorType),
@@ -258,14 +239,6 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) {
var failoverErr *service.UpstreamFailoverError
if errors.As(err, &failoverErr) {
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil)
if c.Writer.Size() != writerSizeBeforeForward {
reqLog.Warn("openai.images.upstream_failover_skipped_after_flush",
zap.Int64("account_id", account.ID),
zap.Int("upstream_status", failoverErr.StatusCode),
)
h.handleFailoverExhausted(c, failoverErr, true)
return
}
if failoverErr.RetryableOnSameAccount {
retryLimit := account.GetPoolModeRetryCount()
if sameAccountRetryCount[account.ID] < retryLimit {
@@ -1,188 +0,0 @@
//go:build unit
package handler
import (
"bytes"
"context"
"io"
"net/http"
"net/http/httptest"
"sync"
"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"
"github.com/tidwall/gjson"
)
type openAIImagesFailoverAccountRepo struct {
service.AccountRepository
accounts []service.Account
}
func (r openAIImagesFailoverAccountRepo) GetByID(_ context.Context, id int64) (*service.Account, error) {
for i := range r.accounts {
if r.accounts[i].ID == id {
account := r.accounts[i]
return &account, nil
}
}
return nil, service.ErrNoAvailableAccounts
}
func (r openAIImagesFailoverAccountRepo) ListSchedulableByGroupIDAndPlatform(_ context.Context, _ int64, platform string) ([]service.Account, error) {
return r.accountsForPlatform(platform), nil
}
func (r openAIImagesFailoverAccountRepo) ListSchedulableByPlatform(_ context.Context, platform string) ([]service.Account, error) {
return r.accountsForPlatform(platform), nil
}
func (r openAIImagesFailoverAccountRepo) ListSchedulableUngroupedByPlatform(_ context.Context, platform string) ([]service.Account, error) {
return r.accountsForPlatform(platform), nil
}
func (r openAIImagesFailoverAccountRepo) accountsForPlatform(platform string) []service.Account {
out := make([]service.Account, 0, len(r.accounts))
for _, account := range r.accounts {
if account.Platform == platform {
out = append(out, account)
}
}
return out
}
type openAIImagesFailoverHTTPUpstream struct {
service.HTTPUpstream
mu sync.Mutex
accountIDs []int64
}
func (u *openAIImagesFailoverHTTPUpstream) Do(_ *http.Request, _ string, accountID int64, _ int) (*http.Response, error) {
u.mu.Lock()
u.accountIDs = append(u.accountIDs, accountID)
u.mu.Unlock()
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{
"Content-Type": []string{"text/event-stream"},
"X-Request-Id": []string{"req_img_failover"},
},
Body: io.NopCloser(bytes.NewBufferString(
"data: {\"type\":\"error\",\"error\":{\"type\":\"server_error\",\"code\":\"server_error\",\"message\":\"image backend unavailable\"}}\n\n",
)),
}, nil
}
func (u *openAIImagesFailoverHTTPUpstream) calls() []int64 {
u.mu.Lock()
defer u.mu.Unlock()
return append([]int64(nil), u.accountIDs...)
}
func TestOpenAIGatewayHandlerImages_ServerErrorFailsOverAndReturnsClearErrorWhenExhausted(t *testing.T) {
gin.SetMode(gin.TestMode)
groupID := int64(3130)
accounts := []service.Account{
{
ID: 1,
Name: "image-account-1",
Platform: service.PlatformOpenAI,
Type: service.AccountTypeOAuth,
Status: service.StatusActive,
Schedulable: true,
Concurrency: 0,
Priority: 0,
Credentials: map[string]any{"access_token": "token-1"},
},
{
ID: 2,
Name: "image-account-2",
Platform: service.PlatformOpenAI,
Type: service.AccountTypeOAuth,
Status: service.StatusActive,
Schedulable: true,
Concurrency: 0,
Priority: 1,
Credentials: map[string]any{"access_token": "token-2"},
},
}
accountRepo := openAIImagesFailoverAccountRepo{accounts: accounts}
upstream := &openAIImagesFailoverHTTPUpstream{}
cfg := &config.Config{RunMode: config.RunModeSimple}
gatewayService := service.NewOpenAIGatewayService(
accountRepo,
nil,
nil,
nil,
nil,
nil,
nil,
cfg,
nil,
nil,
nil,
nil,
nil,
upstream,
nil,
nil,
nil,
nil,
nil,
nil,
nil,
nil,
)
billingService := service.NewBillingCacheService(nil, nil, nil, nil, nil, nil, cfg, nil)
t.Cleanup(billingService.Stop)
concurrencyService := service.NewConcurrencyService(nil)
handler := NewOpenAIGatewayHandler(
gatewayService,
concurrencyService,
billingService,
service.NewAPIKeyService(nil, nil, nil, nil, nil, nil, cfg),
nil,
nil,
nil,
nil,
cfg,
)
handler.maxAccountSwitches = 10
body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat"}`)
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.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
ID: 99,
GroupID: &groupID,
Group: &service.Group{
ID: groupID,
AllowImageGeneration: true,
},
User: &service.User{ID: 100},
})
c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 100, Concurrency: 0})
handler.Images(c)
require.Equal(t, []int64{1, 2}, upstream.calls())
require.Equal(t, http.StatusBadGateway, rec.Code)
require.Equal(t, "upstream_error", gjson.GetBytes(rec.Body.Bytes(), "error.type").String())
require.Equal(t, "Upstream service temporarily unavailable", gjson.GetBytes(rec.Body.Bytes(), "error.message").String())
rawEvents, ok := c.Get(service.OpsUpstreamErrorsKey)
require.True(t, ok)
events, ok := rawEvents.([]*service.OpsUpstreamErrorEvent)
require.True(t, ok)
require.Len(t, events, 2)
require.Equal(t, "failover", events[0].Kind)
require.Equal(t, "failover", events[1].Kind)
}
@@ -549,10 +549,6 @@ func OpsErrorLoggerMiddleware(ops *service.OpsService) gin.HandlerFunc {
return
}
if shouldSkipOpsErrorLogForCyber(c) {
return
}
status := c.Writer.Status()
if status < 400 {
// Even when the client request succeeds, we still want to persist upstream error attempts
@@ -1471,9 +1467,3 @@ func shouldSkipOpsErrorLog(ctx context.Context, ops *service.OpsService, message
return false
}
// shouldSkipOpsErrorLogForCyber:cyber_policy 命中的请求由 recordCyberPolicyIfMarked
// 统一落一条 status=403 的错误请求,故中间件跳过自身落库,避免双写。
func shouldSkipOpsErrorLogForCyber(c *gin.Context) bool {
return service.GetOpsCyberPolicy(c) != nil
}

Some files were not shown because too many files have changed in this diff Show More