Compare commits

...
Author SHA1 Message Date
shaw b6e53c9320 fix(frontend): align Codex UA setting copy 2026-08-07 10:06:28 +08:00
shaw dbb42881c0 fix(openai): default OAuth identity to codex-tui 2026-08-07 09:56:03 +08:00
Wesley Liddick e4b0e1b66b Merge pull request #5338 from Wei-Shaw/fix/tencent-captcha-region-csp
修复腾讯验证码区域适配、重置与 CSP 加载
2026-08-07 08:37:50 +08:00
shaw 287a9f386b fix: 修复腾讯验证码票据过期与区域切换 2026-08-06 21:27:06 +08:00
shaw 8e102b3a0f fix: 完善腾讯验证码区域适配与 CSP 白名单
修复国内站和国际站 SDK 构造、验证容器、票据重置及动态资源加载问题,并补充认证流程回归测试。
2026-08-06 20:34:37 +08:00
shaw a19c9f8d8a chore: update sponsors 2026-08-06 19:31:17 +08:00
shaw a1936d42db chore: update sponsors 2026-08-06 19:30:44 +08:00
shaw c123caddd4 chore: update sponsors 2026-08-06 14:22:46 +08:00
Wesley Liddick e08aee49ed Merge pull request #5266 from shentry/fix/transient-streak-rate-dependence
fix(openai): keep transient failure streak from resetting on sparse traffic
2026-08-06 14:11:49 +08:00
Wesley Liddick c9e60d1f26 Merge pull request #5031 from keaipiao/fix/easypay-error-utf8
fix(payment): preserve UTF-8 in EasyPay errors
2026-08-06 14:10:41 +08:00
Wesley Liddick 47c03c75d8 Merge pull request #5232 from fengshao1227/fix/billing-quantize-monetary-scale
fix(billing): quantize usage billing amounts to the NUMERIC(20,8) scale
2026-08-06 14:00:04 +08:00
shaw 00b8596176 chore: update sponsors 2026-08-04 21:55:34 +08:00
shaw c5e046b7d7 chore: update sponsors 2026-08-04 21:54:50 +08:00
github-actions[bot] aac53afe0e chore: sync VERSION to 0.1.171 [skip ci] 2026-08-04 13:41:47 +00:00
shentryandClaude Opus 5 7d38e67120 fix(openai): keep transient failure streak from resetting on sparse traffic
The account+model transient breaker reset its failure streak whenever the
gap since the previous failure exceeded a one-minute window. That made the
breaker's sensitivity a function of request rate rather than upstream
health: a gateway called less often than once a minute never advanced past
streak 1, where the cooldown is zero, so a consistently broken account was
never blocked. Every request re-selected it, paid a full upstream attempt,
and only then failed over to a healthy account.

Observed on a low-traffic deployment: two accounts returning 500 and 503
stayed in rotation indefinitely, logging `failure_streak: 1, cooldown_ms: 0`
on every request and adding ~750ms to each one before a working account was
reached.

The streak is already cleared on success — recordSuccess deletes the entry,
and every OpenAI handler reports the schedule result — so the time-based
reset is not needed to recover a healthy account. Keep a TTL purely to bound
the map for account+model pairs that stopped being used, and raise it well
above the cooldowns so it no longer doubles as a streak reset.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 17:59:16 +08:00
li e2652eb853 fix(billing): quantize usage billing amounts to the NUMERIC(20,8) scale
同一笔 ActualCost 会被分别写入两条方向相反的 SQL:

    balance    = balance - $1      -- 存剩余额度,舍入的是"减法结果"
    quota_used = quota_used + $1   -- 存累计用量,舍入的是"加法结果"

两列都是 NUMERIC(20,8),PostgreSQL 按 half-away-from-zero 舍入运算结果。
金额在第 9 位落到 half 边界时,两侧朝相反方向舍入:

    10 输入 token × 0.00000125 + 5 输出 token × 0.00001000 = 0.0000625
    × 1.25(分组倍率) = 0.000078125

    balance:    10000 - 0.000078125 = 9999.999921875 → 9999.99992188  delta 0.00007812
    quota_used:     0 + 0.000078125 =     0.000078125 →     0.00007813  delta 0.00007813

余额少扣、API Key 配额多记,单次相差 1e-8 且随请求量线性累积(1000 次 ≈ 1e-5 USD),
余额、Key 配额与用量记录无法精确对账,只能靠 epsilon 比较勉强吻合。

修复:在参数进入 SQL 之前,把命令中的全部金额统一量化到 8 位小数
(half-away-from-zero,与 PostgreSQL NUMERIC 一致)。两条语句拿到的是
同一个已落在 8 位刻度上的金额,存储阶段不再发生舍入,delta 精确相等。

- 走 shopspring/decimal 而非 math.Round(v*1e8)/1e8:后者在乘除中引入额外
  二进制误差,边界值可能被推到错误的一侧
- 量化排在指纹计算之后:指纹是请求幂等键,保持由原始金额派生,
  避免升级前后同一 request_id 的重试算出不同指纹而被判为 fingerprint conflict

Fixes #5229
2026-08-03 20:00:32 +08:00
keaipiao e3e033bb35 fix(payment): preserve UTF-8 in EasyPay errors 2026-07-28 11:46:12 -07:00
83 changed files with 1485 additions and 184 deletions
+11 -16
View File
@@ -50,7 +50,7 @@ Please read the following carefully before using this project:
<tr>
<td width="180"><a href="https://apikey.fun/register?aff=SUB2API"><img src="assets/partners/logos/apikey-fun.png" alt="APIKEY.FUN" width="150"></a></td>
<td>Thanks to APIKEY.FUN for sponsoring this project! <a href="https://apikey.fun/register?aff=SUB2API">APIKEY.FUN</a> is one of the core contributors to the sub2api open-source project, dedicated to providing open, stable, and cost-effective AI API access. The platform supports API relay services for Claude, OpenAI, Gemini, and other popular models, with pricing starting from as low as 7% of the original rate. Register via the exclusive link: <a href="https://apikey.fun/register?aff=SUB2API">APIKEY</a> to enjoy a permanent 5% discount on all recharges.</td>
<td>Thanks to APIKEY.FUN for sponsoring this project! <a href="https://apikey.fun/register?aff=SUB2API">APIKEY.FUN</a> is one of the core contributors to the sub2api open-source project, dedicated to providing open, stable, and cost-effective AI API access. The platform supports API relay services for Claude, OpenAI, Gemini, and other popular models, with pricing starting from as low as 7% of the original rate. Register via the exclusive link: <a href="https://apikey.fun/register?aff=SUB2API">APIKEY</a> to enjoy up to 5% off on all recharges.</td>
</tr>
<tr>
@@ -58,11 +58,6 @@ Please read the following carefully before using this project:
<td>Thanks to AIGoCode for sponsoring this project! AIGoCode is an all-in-one platform that integrates Claude Code, Codex, and the latest Gemini models, providing you with stable, efficient, and highly cost-effective AI coding services. The platform offers flexible subscription plans, zero risk of account suspension, direct access with no VPN required, and lightning-fast responses. AIGoCode has prepared a special benefit for sub2api users: if you register via <a href="https://aigocode.com/invite/SUB2API">this link</a>, you'll receive an extra 10% bonus credit on your first top-up!</td>
</tr>
<tr>
<td width="180"><a href="https://www.aicodemirror.ai/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.ai/register?invitecode=KMVZQM">this link</a> to enjoy 20% off your first top-up, and enterprise customers can get up to 25% off!</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>Huge thanks to BmoPlus for sponsoring this project! BmoPlus is a highly reliable AI account provider built strictly for heavy AI users and developers. They offer rock-solid, ready-to-use accounts and official top-up services for ChatGPT Plus / ChatGPT Pro (Full Warranty) / Claude Pro / Super Grok / Gemini Pro. By registering and ordering through <a href="https://shop.bmoplus.com/?utm_source=github">BmoPlus - Premium AI Accounts & Top-ups</a>, users can unlock the mind-blowing rate of 10% of the official GPT subscription price (90% OFF)</td>
@@ -102,16 +97,6 @@ Please read the following carefully before using this project:
</td>
</tr>
<tr>
<td width="180"><a href="https://anpin.ai"><img src="assets/partners/logos/anpin.jpg" alt="anpin" width="150"></a></td>
<td>Thanks to <a href="https://anpin.ai">anpin.ai</a> for sponsoring this project! anpin.ai is a premium AI relay service platform dedicated to advancing AI accessibility. With an advanced technical architecture and globally distributed deployment, it provides users with a direct high-speed channel to the world's top-tier large language models.<br>
Self-built primary account pool: 1-3s ultra-fast response, supports channel-partner distribution<br>
Extreme stability: multi-line intelligent routing + redundant backup system, ensuring year-round high-availability operation;<br>
Model authenticity: no content intervention or secondary filtering — experience the purest, most powerful native model capabilities.<br>
1:1 top-up, enterprise-grade service with invoicing available. Anpin AI is not just a relay — it's your secure, reliable, and efficient bridge to the frontier world of intelligence.
</td>
</tr>
<tr>
<td width="180"><a href="https://www.proxy4free.com/?keyword=4yjqecpc"><img src="assets/partners/logos/proxy4free.png" alt="proxy4free" width="150"></a></td>
<td>Thanks to Proxy4Free for sponsoring this project! Proxy4Free is a data proxy service provider for developers and AI applications, offering residential proxies, static residential proxies, ISP proxies, and datacenter proxies for scenarios such as Web Scraping, Browser Automation, and AI Agents. With global IP resources, stable connections, and flexible switching, it helps developers improve data collection success rates and reduce the risk of IP bans. Register via <a href="https://www.proxy4free.com/?keyword=4yjqecpc">this link</a> to get started and easily build more stable and efficient automation workflows.
@@ -169,6 +154,16 @@ Model authenticity: no content intervention or secondary filtering — experienc
<td>Thank you to LanoX AI for sponsoring this project! <a href="https://lanox.ai/?c=6">LanoX AI</a> provides stable, cost-effective global model access services for developers, teams, and enterprises. 🎁 New User Benefits — Claim millions of free tokens, plus 500+ free models for easier low-cost testing, validation, and deployment 🧠 Global Leading Models — GPT · Claude · Gemini · Qwen · Grok... 🎬 Multimodal Creation — Seedance 2.0 · GPT Image · Gemini Nano Banana 🛡️ Enterprise-Grade Reliability — High availability 💎 native capability output 💎 no intelligence degradation 💎 no model mixing 💎 transparent usage and billing 💎 💰 Lower API Costs — Top-tier models from as low as 10% of official pricing, with clear documentation, simple integration, invoicing support, and enterprise-scale batch usage 🏢 Enterprise Choice — Ideal for AI products, Agents, content platforms, and R&D teams with high-volume model usage</td>
</tr>
<tr>
<td width="180"><a href="https://www.rapidproxy.io/?ref=sub2api"><img src="assets/partners/logos/rapidproxy.jpg" alt="RapidProxy" width="150"></a></td>
<td><a href="https://www.rapidproxy.io/?ref=sub2api">RapidProxy</a> is a data collection proxy solution built for developers, providing stable and reliable residential proxy services. With 90M+ global residential IPs and 200+ country coverage, intelligent rotation, and precise geo-targeting, it helps projects such as web scraping, AI data training, SEO monitoring, and e-commerce data analysis break through access restrictions and improve data collection efficiency. It supports mainstream automation frameworks such as Playwright, Selenium, and Puppeteer, with prices as low as $0.65/GB — <a href="https://www.rapidproxy.io/?ref=sub2api">start your free test now</a>.</td>
</tr>
<tr>
<td width="180"><a href="https://hao.ai"><img src="assets/partners/logos/haoai.png" alt="hao.ai" width="150"></a></td>
<td><a href="https://hao.ai">hao.ai</a> is a high-speed, stable unified large-model API gateway for developers and teams. With a single API Key and a unified interface, you can access mainstream models such as GPT, Claude, and xAI Grok, with compatibility for common protocols and SDKs including OpenAI and Anthropic. The platform provides model routing, failover, team management, and complete request logs, with model prices as low as 15% of official reference pricing, helping users build AI applications more simply, more reliably, and at lower cost.</td>
</tr>
</table>
## Overview
+11 -16
View File
@@ -51,7 +51,7 @@
<tr>
<td width="180"><a href="https://apikey.fun/register?aff=SUB2API"><img src="assets/partners/logos/apikey-fun.png" alt="APIKEY.FUN" width="150"></a></td>
<td>感谢 APIKEY.FUN 赞助了本项目!<a href="https://apikey.fun/register?aff=SUB2API">APIKEY.FUN</a> 是 sub2api 开源项目的核心贡献者之一,致力于提供开放、稳定、高性价比的 AI API 接入服务。平台支持 Claude、OpenAI、Gemini 等热门模型的 API 中转服务,价格低至官方原价的 7%。通过专属链接 <a href="https://apikey.fun/register?aff=SUB2API">APIKEY</a> 注册,可享受所有充值永久 95 折优惠。</td>
<td>感谢 APIKEY.FUN 赞助了本项目!<a href="https://apikey.fun/register?aff=SUB2API">APIKEY.FUN</a> 是 sub2api 开源项目的核心贡献者之一,致力于提供开放、稳定、高性价比的 AI API 接入服务。平台支持 Claude、OpenAI、Gemini 等热门模型的 API 中转服务,价格低至官方原价的 7%。通过专属链接 <a href="https://apikey.fun/register?aff=SUB2API">APIKEY</a> 注册,可享受充值最高 95 折优惠。</td>
</tr>
<tr>
@@ -59,11 +59,6 @@
<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://www.aicodemirror.ai/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.ai/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>
@@ -105,16 +100,6 @@
</td>
</tr>
<tr>
<td width="180"><a href="https://anpin.ai"><img src="assets/partners/logos/anpin.jpg" alt="anpin" width="150"></a></td>
<td>感谢 <a href="https://anpin.ai">anpin.ai</a> 赞助本项目!anpin.ai 是一家致力于推动 AI 普惠的高端 AI 中转服务平台。我们以先进的技术架构和全球分布式部署,为用户提供直达国际顶尖大模型的高速通道。<br>
自建一手号池:1-3S超快响应 支持同行分发<br>
极致稳定:多线智能路由 + 冗余备份系统,确保服务全年无休、高可用运行;<br>
模型真实性:不做任何内容干预与二次过滤,让您体验到最纯粹、最强大的原生模型能力。<br>
充值1:1 企业级服务可开票,安品Ai不只是中转站,更是您连接前沿智能世界的安全、可靠、高效桥梁
</td>
</tr>
<tr>
<td width="180"><a href="https://www.proxy4free.com/?keyword=4yjqecpc"><img src="assets/partners/logos/proxy4free.png" alt="proxy4free" width="150"></a></td>
<td>感谢 Proxy4Free 赞助本项目!Proxy4Free 是面向开发者和 AI 应用的数据代理服务商,提供住宅代理、静态住宅代理、ISP 代理及数据中心代理等多种代理解决方案,适用于 Web Scraping、Browser Automation、AI Agent 等场景。支持全球 IP 资源、稳定连接与灵活切换,帮助开发者提升数据采集成功率,降低 IP 封禁风险。通过<a href="https://www.proxy4free.com/?keyword=4yjqecpc">此链接注册</a>即可开始体验,轻松构建更稳定、高效的自动化工作流。
@@ -172,6 +157,16 @@
<td>感谢 LanoX AI 对本项目的赞助!<a href="https://lanox.ai/?c=6">LanoX AI</a> 为开发者、团队与企业提供稳定、高性价比的全球模型接入服务。 🎁 新用户福利 — 免费领取 百万 Token ,更有500+ 免费模型 — 低成本测试、验证、部署更轻松 🧠 全球主流模型 — GPT · Claude · Gemini · Qwen · Grok... 🎬 多模态创作 — Seedance 2.0 · GPT Image · Gemini Nano Banana 🛡️ 企业级稳定服务 — 高可用💎原生能力输出💎不降智💎不混模💎调用与计费透明💎 💰 更低调用成本 — 顶级模型低至官方价 1 折起,文档清晰、接入简单、支持开票与企业批量调用 🏢 企业优选 — 适用于 AI 产品、Agent、内容平台、研发团队批量调用</td>
</tr>
<tr>
<td width="180"><a href="https://www.rapidproxy.io/?ref=sub2api"><img src="assets/partners/logos/rapidproxy.jpg" alt="RapidProxy" width="150"></a></td>
<td><a href="https://www.rapidproxy.io/?ref=sub2api">RapidProxy</a> 是面向开发者的数据采集代理解决方案,提供稳定可靠的住宅代理服务。通过 9000 万+全球住宅 IP和 200+国家覆盖、智能轮换机制和精准地区定位能力,帮助爬虫、AI 数据训练、SEO 监控、电商数据分析等项目突破访问限制,提高数据采集效率。支持 Playwright、Selenium、Puppeteer 等主流自动化框架,价格低至 $0.65/GB,<a href="https://www.rapidproxy.io/?ref=sub2api">立即免费测试吧</a>。</td>
</tr>
<tr>
<td width="180"><a href="https://hao.ai"><img src="assets/partners/logos/haoai.png" alt="hao.ai" width="150"></a></td>
<td><a href="https://hao.ai">hao.ai</a> 是面向开发者与团队的高速、稳定大模型统一 API 网关。通过一个 API Key 和统一接口,即可接入 GPT、Claude、xAI Grok 等主流模型,兼容 OpenAI、Anthropic 等常用协议与 SDK。平台提供模型路由、故障回退、团队管理及完整调用日志,模型价格低至官方参考价的 1.5 折,帮助用户更简单、更稳定、更低成本地构建 AI 应用。</td>
</tr>
</table>
## 项目概述
+10 -15
View File
@@ -58,11 +58,6 @@
<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://www.aicodemirror.ai/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.ai/register?invitecode=KMVZQM">こちらのリンク</a>から登録すると、初回チャージが 20% オフ、法人のお客様は最大 25% オフ!</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を 公式サイト価格の約1割(90% OFF) という驚異的な価格でご利用いただけます!</td>
@@ -104,16 +99,6 @@
</td>
</tr>
<tr>
<td width="180"><a href="https://anpin.ai"><img src="assets/partners/logos/anpin.jpg" alt="anpin" width="150"></a></td>
<td><a href="https://anpin.ai">anpin.ai</a> のご支援に感謝します!anpin.ai は AI の普及推進に取り組むハイエンド AI 中継サービスプラットフォームです。先進的な技術アーキテクチャとグローバル分散デプロイメントにより、国際トップクラスの大規模モデルへの高速直通ルートを提供します。<br>
自社構築の一次アカウントプール:1-3秒の超高速応答、同業者への配布に対応<br>
極めて高い安定性:マルチライン・インテリジェントルーティング + 冗長バックアップシステムにより、年中無休の高可用性運用を確保;<br>
モデルの真正性:一切のコンテンツ介入や二次フィルタリングを行わず、最も純粋で強力なネイティブモデル能力を体験できます。<br>
チャージ 1:1、エンタープライズグレードサービスは請求書発行可能。安品 AI は単なる中継所ではなく、最先端のインテリジェント世界へと繋がる安全・信頼性・高効率の橋渡し役です。
</td>
</tr>
<tr>
<td width="180"><a href="https://www.proxy4free.com/?keyword=4yjqecpc"><img src="assets/partners/logos/proxy4free.png" alt="proxy4free" width="150"></a></td>
<td>Proxy4Free のご支援に感謝します!Proxy4Free は開発者と AI アプリケーション向けのデータプロキシサービスプロバイダーで、住宅プロキシ、静的住宅プロキシ、ISP プロキシ、データセンタープロキシなど多様なプロキシソリューションを提供しており、Web Scraping、Browser Automation、AI Agent などのシナリオに適しています。グローバル IP リソース、安定した接続、柔軟な切り替えをサポートし、開発者のデータ収集成功率の向上と IP ブロックリスクの低減を支援します。<a href="https://www.proxy4free.com/?keyword=4yjqecpc">こちらのリンクから登録</a>して、より安定した効率的な自動化ワークフローを簡単に構築しましょう。
@@ -171,6 +156,16 @@
<td>本プロジェクトをご支援いただいている <a href="https://lanox.ai/?c=6">LanoX AI</a> に感謝します!LanoX AI は、開発者、チーム、企業向けに、安定性とコストパフォーマンスに優れたグローバルモデル接続サービスを提供しています。 🎁 新規ユーザー特典 — 数百万 Token を無料で獲得可能。さらに 500+ の無料モデルで、低コストなテスト、検証、デプロイをより簡単に 🧠 世界の主要モデル — GPT · Claude · Gemini · Qwen · Grok... 🎬 マルチモーダル制作 — Seedance 2.0 · GPT Image · Gemini Nano Banana 🛡️ エンタープライズ級の安定性 — 高可用性 💎 ネイティブ能力の出力 💎 性能劣化なし 💎 モデル混在なし 💎 利用量と課金が透明 💎 💰 より低い利用コスト — トップモデルを公式価格の 1 割から利用可能。明確なドキュメント、簡単な接続、請求書発行、企業向け一括利用に対応 🏢 企業に最適 — AI プロダクト、Agent、コンテンツプラットフォーム、大量利用する開発チームに最適</td>
</tr>
<tr>
<td width="180"><a href="https://www.rapidproxy.io/?ref=sub2api"><img src="assets/partners/logos/rapidproxy.jpg" alt="RapidProxy" width="150"></a></td>
<td><a href="https://www.rapidproxy.io/?ref=sub2api">RapidProxy</a> は開発者向けのデータ収集プロキシソリューションであり、安定して信頼できる住宅用プロキシサービスを提供します。9,000 万以上のグローバル住宅 IP と 200 以上の国・地域のカバー、インテリジェントなローテーション機構、精密な地域ターゲティング機能により、クローラー、AI データ学習、SEO モニタリング、EC データ分析などのプロジェクトがアクセス制限を突破し、データ収集の効率を高めます。Playwright、Selenium、Puppeteer などの主要な自動化フレームワークに対応し、料金は $0.65/GB から。<a href="https://www.rapidproxy.io/?ref=sub2api">今すぐ無料でお試しください</a>。</td>
</tr>
<tr>
<td width="180"><a href="https://hao.ai"><img src="assets/partners/logos/haoai.png" alt="hao.ai" width="150"></a></td>
<td><a href="https://hao.ai">hao.ai</a> は、開発者とチーム向けの高速で安定した大規模モデル統合 API ゲートウェイです。1 つの API Key と統一されたインターフェースで、GPT、Claude、xAI Grok などの主要モデルに接続でき、OpenAI や Anthropic などの一般的なプロトコルと SDK に対応しています。プラットフォームはモデルルーティング、フェイルオーバー、チーム管理、完全な呼び出しログを提供し、モデル価格は公式参考価格の 1.5 割から。よりシンプルに、より安定して、より低コストに AI アプリケーションを構築できます。</td>
</tr>
</table>
## 概要
Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

+32
View File
@@ -0,0 +1,32 @@
<svg
width="340"
height="112"
viewBox="0 0 340 112"
fill="none"
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-label="Hao.ai"
>
<!-- Fill the compact landscape canvas while preserving the homepage lockup proportions. -->
<g transform="translate(170 56) scale(1.45) translate(-170 -56)">
<!-- Homepage HaoAI mark, rendered in the brand green. -->
<g transform="translate(66.55 12) scale(0.55)">
<path
d="M49 30C42.3726 30 37 35.3726 37 42V118C37 124.627 42.3726 130 49 130C55.6274 130 61 124.627 61 118V42C61 35.3726 55.6274 30 49 30ZM111 30C104.373 30 99 35.3726 99 42V118C99 124.627 104.373 130 111 130C117.627 130 123 124.627 123 118V42C123 35.3726 117.627 30 111 30ZM80 92.5C86.9036 92.5 92.5 86.9036 92.5 80C92.5 73.0964 86.9036 67.5 80 67.5C73.0964 67.5 67.5 73.0964 67.5 80C67.5 86.9036 73.0964 92.5 80 92.5Z"
fill="#1DB954"
fill-rule="evenodd"
clip-rule="evenodd"
/>
</g>
<!-- Homepage wordmark converted from Inter SemiBold to vector outlines. -->
<g transform="translate(150.35 69) scale(0.0166015625 -0.0166015625)" fill="#111111">
<path d="M150 0L150 1490L417 1490L417 874L1110 874L1110 1490L1377 1490L1377 0L1110 0L1110 649L417 649L417 0Z" />
<path transform="translate(1527 0)" d="M450 -23Q344 -23 259 15.5Q174 54 125 129Q76 204 76 314Q76 409 111.5 471Q147 533 207.5 570Q268 607 344 626Q420 645 502 653Q599 664 660 672Q721 680 749.5 698Q778 716 778 754L778 759Q778 814 756 852.5Q734 891 690.5 911.5Q647 932 583 932Q518 932 470.5 912Q423 892 393 860Q363 828 349 791L108 840Q141 939 210 1003.5Q279 1068 374.5 1100Q470 1132 582 1132Q661 1132 741.5 1113.5Q822 1095 889 1051Q956 1007 997 933Q1038 859 1038 747L1038 0L789 0L789 154L779 154Q754 107 710.5 66.5Q667 26 602.5 1.5Q538 -23 450 -23ZM517 170Q598 170 656.5 202Q715 234 747.5 286.5Q780 339 780 401L780 533Q767 523 736.5 514Q706 505 668.5 498Q631 491 594.5 486Q558 481 533 478Q475 470 428 452Q381 434 354.5 401.5Q328 369 328 317Q328 269 352.5 236.5Q377 204 419.5 187Q462 170 517 170Z" />
<path transform="translate(2703 0)" d="M623 -23Q460 -23 340 49.5Q220 122 154.5 251.5Q89 381 89 553Q89 727 154.5 857.5Q220 988 340 1060Q460 1132 623 1132Q787 1132 907 1060Q1027 988 1092.5 857.5Q1158 727 1158 553Q1158 381 1092.5 251.5Q1027 122 907 49.5Q787 -23 623 -23ZM623 188Q714 188 774.5 237Q835 286 864.5 369Q894 452 894 554Q894 657 864.5 740Q835 823 774.5 872Q714 921 623 921Q532 921 472.5 872Q413 823 383.5 740.5Q354 658 354 554Q354 452 383.5 369Q413 286 472.5 237Q532 188 623 188Z" />
<path transform="translate(3950 0)" fill="#1DB954" d="M326 -17Q259 -17 212.5 29Q166 75 166 143Q166 210 212.5 256Q259 302 326 302Q394 302 440.5 256Q487 210 487 143Q487 75 440.5 29Q394 -17 326 -17Z" />
<path transform="translate(4603 0)" d="M450 -23Q344 -23 259 15.5Q174 54 125 129Q76 204 76 314Q76 409 111.5 471Q147 533 207.5 570Q268 607 344 626Q420 645 502 653Q599 664 660 672Q721 680 749.5 698Q778 716 778 754L778 759Q778 814 756 852.5Q734 891 690.5 911.5Q647 932 583 932Q518 932 470.5 912Q423 892 393 860Q363 828 349 791L108 840Q141 939 210 1003.5Q279 1068 374.5 1100Q470 1132 582 1132Q661 1132 741.5 1113.5Q822 1095 889 1051Q956 1007 997 933Q1038 859 1038 747L1038 0L789 0L789 154L779 154Q754 107 710.5 66.5Q667 26 602.5 1.5Q538 -23 450 -23ZM517 170Q598 170 656.5 202Q715 234 747.5 286.5Q780 339 780 401L780 533Q767 523 736.5 514Q706 505 668.5 498Q631 491 594.5 486Q558 481 533 478Q475 470 428 452Q381 434 354.5 401.5Q328 369 328 317Q328 269 352.5 236.5Q377 204 419.5 187Q462 170 517 170Z" />
<path transform="translate(5779 0)" d="M138 0L138 1118L398 1118L398 0ZM268 1276Q206 1276 161.5 1318Q117 1360 117 1418Q117 1478 161.5 1519.5Q206 1561 268 1561Q331 1561 375.5 1519.5Q420 1478 420 1419Q420 1360 375.5 1318Q331 1276 268 1276Z" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 744 KiB

+1 -1
View File
@@ -1 +1 @@
0.1.170
0.1.171
+1 -1
View File
@@ -32,7 +32,7 @@ const (
// DefaultCSPPolicy is the default Content-Security-Policy with nonce support
// __CSP_NONCE__ will be replaced with actual nonce at request time by the SecurityHeaders middleware
const DefaultCSPPolicy = "default-src 'self'; script-src 'self' __CSP_NONCE__ https://challenges.cloudflare.com https://*.alicdn.com https://static.cloudflareinsights.com https://turing.captcha.qcloud.com https://*.stripe.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; style-src 'self' 'unsafe-inline' https://*.captcha.gtimg.com https://fonts.googleapis.com https://*.alicdn.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https:; frame-src https://challenges.cloudflare.com https://turing.captcha.qcloud.com https://*.stripe.com https://checkout.airwallex.com https://checkout-demo.airwallex.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
const DefaultCSPPolicy = "default-src 'self'; worker-src 'self' blob:; script-src 'self' __CSP_NONCE__ https://challenges.cloudflare.com https://*.alicdn.com https://static.cloudflareinsights.com https://turing.captcha.qcloud.com https://turing.captcha.gtimg.com https://ca.turing.captcha.qcloud.com https://global.turing.captcha.gtimg.com https://www.tycaptcha.com https://cloudcache.tencentcs.com https://*.stripe.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; style-src 'self' 'unsafe-inline' https://*.captcha.gtimg.com https://fonts.googleapis.com https://*.alicdn.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://turing.captcha.qcloud.com https://www.tycaptcha.com https://rce.tencentrio.com https:; frame-src https://challenges.cloudflare.com https://turing.captcha.qcloud.com https://ca.turing.captcha.qcloud.com https://www.tycaptcha.com https://*.stripe.com https://checkout.airwallex.com https://checkout-demo.airwallex.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
// UMQ(用户消息队列)模式常量
const (
@@ -168,6 +168,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
TencentCaptchaAppSecretKeyConfigured: settings.TencentCaptchaAppSecretKeyConfigured,
TencentCaptchaCloudSecretIDConfigured: settings.TencentCaptchaCloudSecretIDConfigured,
TencentCaptchaCloudSecretKeyConfigured: settings.TencentCaptchaCloudSecretKeyConfigured,
TencentCaptchaRegion: settings.TencentCaptchaRegion,
AliyunCaptchaEnabled: settings.AliyunCaptchaEnabled,
AliyunCaptchaAccessKeyID: settings.AliyunCaptchaAccessKeyID,
AliyunCaptchaAccessKeySecretConfigured: settings.AliyunCaptchaAccessKeySecretConfigured,
@@ -122,6 +122,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if req.TencentCaptchaCloudSecretKey != "" {
changed = append(changed, "tencent_captcha_cloud_secret_key")
}
if before.TencentCaptchaRegion != after.TencentCaptchaRegion {
changed = append(changed, "tencent_captcha_region")
}
if before.AliyunCaptchaEnabled != after.AliyunCaptchaEnabled {
changed = append(changed, "aliyun_captcha_enabled")
}
@@ -121,6 +121,43 @@ func TestUpdateSettingsRetainsStoredTencentCaptchaCredentialsWhenInputsEmpty(t *
require.Equal(t, "stored-cloud-secret-key", repo.values[service.SettingKeyTencentCaptchaCloudSecretKey])
}
// 天御站点决定前端加载哪个 SDK 与服务端打哪个接入点,两端必须一致。
// 部分载荷把它重置回中国站,会让已配国际站的部署在下一次任意保存后整体失效。
func TestUpdateSettingsPartialPayloadKeepsTencentCaptchaRegion(t *testing.T) {
h, repo := newStepUpSwitchTestHandler(t, map[string]string{
service.SettingKeyTencentCaptchaRegion: service.TencentCaptchaRegionINTL,
})
rec := doUpdateSettings(t, h, map[string]any{"risk_control_enabled": true}, nil)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, service.TencentCaptchaRegionINTL,
repo.values[service.SettingKeyTencentCaptchaRegion])
}
func TestUpdateSettingsNormalizesUnknownTencentCaptchaRegion(t *testing.T) {
h, repo := newStepUpSwitchTestHandler(t, map[string]string{
service.SettingKeyTencentCaptchaRegion: service.TencentCaptchaRegionINTL,
})
rec := doUpdateSettings(t, h, map[string]any{"tencent_captcha_region": "sgp"}, nil)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, service.TencentCaptchaRegionCN,
repo.values[service.SettingKeyTencentCaptchaRegion],
"未知站点必须落回中国站,不能写入无法识别的值")
}
func TestUpdateSettingsWritesTencentCaptchaRegionWhenSent(t *testing.T) {
h, repo := newStepUpSwitchTestHandler(t, map[string]string{})
rec := doUpdateSettings(t, h, map[string]any{"tencent_captcha_region": "intl"}, nil)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, service.TencentCaptchaRegionINTL,
repo.values[service.SettingKeyTencentCaptchaRegion])
}
func TestUpdateSettingsValidatesTencentCaptchaAppIDWhenEnabledFlagIsOmitted(t *testing.T) {
h, _ := newStepUpSwitchTestHandler(t, map[string]string{
service.SettingKeyTencentCaptchaEnabled: "true",
@@ -60,6 +60,7 @@ type UpdateSettingsRequest struct {
TencentCaptchaAppSecretKey string `json:"tencent_captcha_app_secret_key"`
TencentCaptchaCloudSecretID string `json:"tencent_captcha_cloud_secret_id"`
TencentCaptchaCloudSecretKey string `json:"tencent_captcha_cloud_secret_key"`
TencentCaptchaRegion string `json:"tencent_captcha_region"`
// 阿里云验证码 2.0 设置
AliyunCaptchaEnabled bool `json:"aliyun_captcha_enabled"`
@@ -644,6 +645,13 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
if req.AliyunCaptchaRegion != service.AliyunCaptchaRegionSGP {
req.AliyunCaptchaRegion = service.AliyunCaptchaRegionCN
}
// 天御站点 normalize:未发送保留已存值,非法值一律按中国站落库
if _, sent := sentFields["tencent_captcha_region"]; !sent {
req.TencentCaptchaRegion = previousSettings.TencentCaptchaRegion
}
if req.TencentCaptchaRegion != service.TencentCaptchaRegionINTL {
req.TencentCaptchaRegion = service.TencentCaptchaRegionCN
}
// Turnstile 参数验证
if req.TurnstileEnabled {
@@ -1501,6 +1509,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
TencentCaptchaAppSecretKey: req.TencentCaptchaAppSecretKey,
TencentCaptchaCloudSecretID: req.TencentCaptchaCloudSecretID,
TencentCaptchaCloudSecretKey: req.TencentCaptchaCloudSecretKey,
TencentCaptchaRegion: req.TencentCaptchaRegion,
AliyunCaptchaEnabled: req.AliyunCaptchaEnabled,
AliyunCaptchaAccessKeyID: req.AliyunCaptchaAccessKeyID,
AliyunCaptchaAccessKeySecret: req.AliyunCaptchaAccessKeySecret,
@@ -2084,6 +2093,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
TencentCaptchaAppSecretKeyConfigured: updatedSettings.TencentCaptchaAppSecretKeyConfigured,
TencentCaptchaCloudSecretIDConfigured: updatedSettings.TencentCaptchaCloudSecretIDConfigured,
TencentCaptchaCloudSecretKeyConfigured: updatedSettings.TencentCaptchaCloudSecretKeyConfigured,
TencentCaptchaRegion: updatedSettings.TencentCaptchaRegion,
AliyunCaptchaEnabled: updatedSettings.AliyunCaptchaEnabled,
AliyunCaptchaAccessKeyID: updatedSettings.AliyunCaptchaAccessKeyID,
AliyunCaptchaAccessKeySecretConfigured: updatedSettings.AliyunCaptchaAccessKeySecretConfigured,
@@ -6,21 +6,88 @@ import (
"encoding/json"
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
)
func TestAuthRequestsBindTencentCaptchaProof(t *testing.T) {
const payload = `{"email":"user@example.com","password":"secret-123","tencent_captcha_ticket":"ticket-value","tencent_captcha_randstr":"@rand-value"}`
var login LoginRequest
require.NoError(t, json.Unmarshal([]byte(payload), &login))
proof := captchaProof(login.TurnstileToken, login.TencentCaptchaTicket, login.TencentCaptchaRandstr)
require.Equal(t, "ticket-value", proof.TencentTicket)
require.Equal(t, "@rand-value", proof.TencentRandstr)
tests := []struct {
name string
decode func([]byte) service.CaptchaProof
}{
{
name: "登录",
decode: func(raw []byte) service.CaptchaProof {
var req LoginRequest
require.NoError(t, json.Unmarshal(raw, &req))
return captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr)
},
},
{
name: "注册",
decode: func(raw []byte) service.CaptchaProof {
var req RegisterRequest
require.NoError(t, json.Unmarshal(raw, &req))
return captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr)
},
},
{
name: "发送邮箱验证码",
decode: func(raw []byte) service.CaptchaProof {
var req SendVerifyCodeRequest
require.NoError(t, json.Unmarshal(raw, &req))
return captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr)
},
},
{
name: "忘记密码",
decode: func(raw []byte) service.CaptchaProof {
var req ForgotPasswordRequest
require.NoError(t, json.Unmarshal(raw, &req))
return captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr)
},
},
{
name: "OAuth启动",
decode: func(raw []byte) service.CaptchaProof {
var req oauthStartCaptchaRequest
require.NoError(t, json.Unmarshal(raw, &req))
return captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr)
},
},
{
name: "Passkey登录",
decode: func(raw []byte) service.CaptchaProof {
var req passkeyBeginLoginRequest
require.NoError(t, json.Unmarshal(raw, &req))
return captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr)
},
},
{
name: "OAuth待处理账号发送邮箱验证码",
decode: func(raw []byte) service.CaptchaProof {
var req sendPendingOAuthVerifyCodeRequest
require.NoError(t, json.Unmarshal(raw, &req))
return captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr)
},
},
{
name: "OAuth待处理账号创建",
decode: func(raw []byte) service.CaptchaProof {
var req createPendingOAuthAccountRequest
require.NoError(t, json.Unmarshal(raw, &req))
return captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr)
},
},
}
var pending createPendingOAuthAccountRequest
require.NoError(t, json.Unmarshal([]byte(payload), &pending))
proof = captchaProof(pending.TurnstileToken, pending.TencentCaptchaTicket, pending.TencentCaptchaRandstr)
require.Equal(t, "ticket-value", proof.TencentTicket)
require.Equal(t, "@rand-value", proof.TencentRandstr)
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
proof := test.decode([]byte(payload))
require.Equal(t, "ticket-value", proof.TencentTicket)
require.Equal(t, "@rand-value", proof.TencentRandstr)
})
}
}
+2
View File
@@ -64,6 +64,7 @@ type SystemSettings struct {
TencentCaptchaAppSecretKeyConfigured bool `json:"tencent_captcha_app_secret_key_configured"`
TencentCaptchaCloudSecretIDConfigured bool `json:"tencent_captcha_cloud_secret_id_configured"`
TencentCaptchaCloudSecretKeyConfigured bool `json:"tencent_captcha_cloud_secret_key_configured"`
TencentCaptchaRegion string `json:"tencent_captcha_region"`
AliyunCaptchaEnabled bool `json:"aliyun_captcha_enabled"`
AliyunCaptchaAccessKeyID string `json:"aliyun_captcha_access_key_id"`
AliyunCaptchaAccessKeySecretConfigured bool `json:"aliyun_captcha_access_key_secret_configured"`
@@ -354,6 +355,7 @@ type PublicSettings struct {
TurnstileSiteKey string `json:"turnstile_site_key"`
TencentCaptchaEnabled bool `json:"tencent_captcha_enabled"`
TencentCaptchaAppID string `json:"tencent_captcha_app_id"`
TencentCaptchaRegion string `json:"tencent_captcha_region"`
AliyunCaptchaEnabled bool `json:"aliyun_captcha_enabled"`
AliyunCaptchaSceneID string `json:"aliyun_captcha_scene_id"`
AliyunCaptchaPrefix string `json:"aliyun_captcha_prefix"`
@@ -62,6 +62,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
TurnstileSiteKey: settings.TurnstileSiteKey,
TencentCaptchaEnabled: settings.TencentCaptchaEnabled,
TencentCaptchaAppID: settings.TencentCaptchaAppID,
TencentCaptchaRegion: settings.TencentCaptchaRegion,
AliyunCaptchaEnabled: settings.AliyunCaptchaEnabled,
AliyunCaptchaSceneID: settings.AliyunCaptchaSceneID,
AliyunCaptchaPrefix: settings.AliyunCaptchaPrefix,
@@ -89,6 +89,7 @@ func TestSettingHandler_GetPublicSettings_ExposesTencentCaptchaConfiguration(t *
values: map[string]string{
service.SettingKeyTencentCaptchaEnabled: "true",
service.SettingKeyTencentCaptchaAppID: "123456789",
service.SettingKeyTencentCaptchaRegion: service.TencentCaptchaRegionINTL,
},
}
h := NewSettingHandler(service.NewSettingService(repo, &config.Config{}), "test-version")
@@ -106,12 +107,14 @@ func TestSettingHandler_GetPublicSettings_ExposesTencentCaptchaConfiguration(t *
Data struct {
TencentCaptchaEnabled bool `json:"tencent_captcha_enabled"`
TencentCaptchaAppID string `json:"tencent_captcha_app_id"`
TencentCaptchaRegion string `json:"tencent_captcha_region"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp))
require.Equal(t, 0, resp.Code)
require.True(t, resp.Data.TencentCaptchaEnabled)
require.Equal(t, "123456789", resp.Data.TencentCaptchaAppID)
require.Equal(t, service.TencentCaptchaRegionINTL, resp.Data.TencentCaptchaRegion)
}
func TestSettingHandler_GetPublicSettings_ExposesWeChatOAuthModeCapabilities(t *testing.T) {
+6 -1
View File
@@ -15,6 +15,7 @@ import (
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/Wei-Shaw/sub2api/internal/payment"
)
@@ -477,7 +478,11 @@ func summarizeEasyPayResponse(body []byte) string {
return "<empty>"
}
if len(summary) > maxEasypayErrorSummary {
return summary[:maxEasypayErrorSummary] + "..."
truncated := summary[:maxEasypayErrorSummary]
for len(truncated) > 0 && !utf8.ValidString(truncated) {
truncated = truncated[:len(truncated)-1]
}
return truncated + "..."
}
return summary
}
@@ -7,6 +7,7 @@ import (
"net/url"
"strings"
"testing"
"unicode/utf8"
"github.com/Wei-Shaw/sub2api/internal/payment"
)
@@ -179,6 +180,18 @@ func TestEasyPayRefundResponseErrors(t *testing.T) {
}
}
func TestSummarizeEasyPayResponsePreservesUTF8(t *testing.T) {
t.Parallel()
summary := summarizeEasyPayResponse([]byte(strings.Repeat("错", 171)))
if !utf8.ValidString(summary) {
t.Fatalf("summarizeEasyPayResponse returned invalid UTF-8: %q", summary)
}
if !strings.HasSuffix(summary, "...") {
t.Fatalf("summarizeEasyPayResponse() = %q, want truncated suffix", summary)
}
}
func TestEasyPayCustomMethodsUseConfiguredUpstreamType(t *testing.T) {
t.Parallel()
+1 -1
View File
@@ -585,7 +585,7 @@ func (c *Client) OnboardUser(ctx context.Context, accessToken, tierID string) (s
return "", lastErr
}
// done=false 时等待后重试(与 CLIProxyAPI 行为一致)
// done=false 时等待后重试,避免在上游仍未完成时提前结束轮询。
select {
case <-time.After(2 * time.Second):
case <-ctx.Done():
+21 -6
View File
@@ -113,13 +113,13 @@ func resolveCustomForwardedClientIP(c *gin.Context, headers []string) (string, s
func resolveLegacyForwardedHeaderIP(c *gin.Context) (string, string) {
var fallback string
if forwarded := normalizeIP(c.GetHeader("CF-Connecting-IP")); forwarded != "" {
if forwarded := normalizeValidIP(c.GetHeader("CF-Connecting-IP")); forwarded != "" {
fallback = forwarded
if !isPrivateIP(forwarded) {
return forwarded, fallback
}
}
if realIP := normalizeIP(c.GetHeader("X-Real-IP")); realIP != "" {
if realIP := normalizeValidIP(c.GetHeader("X-Real-IP")); realIP != "" {
if fallback == "" {
fallback = realIP
}
@@ -130,13 +130,18 @@ func resolveLegacyForwardedHeaderIP(c *gin.Context) (string, string) {
if xff := c.GetHeader("X-Forwarded-For"); xff != "" {
ips := strings.Split(xff, ",")
for _, candidate := range ips {
candidate = strings.TrimSpace(candidate)
candidate = normalizeValidIP(candidate)
if candidate != "" && !isPrivateIP(candidate) {
return normalizeIP(candidate), fallback
return candidate, fallback
}
}
if fallback == "" && len(ips) > 0 {
fallback = normalizeIP(strings.TrimSpace(ips[0]))
if fallback == "" {
for _, candidate := range ips {
if candidate = normalizeValidIP(candidate); candidate != "" {
fallback = candidate
break
}
}
}
}
return "", fallback
@@ -176,6 +181,16 @@ func normalizeIP(ip string) string {
return ip
}
// normalizeValidIP 规范化并验证代理头中的候选值,避免把 unknown、主机名等非法值传给安全服务。
func normalizeValidIP(value string) string {
normalized := normalizeIP(value)
parsed := net.ParseIP(normalized)
if parsed == nil {
return ""
}
return parsed.String()
}
// privateNets contains the private/loopback ranges skipped while selecting a
// public address from a legacy X-Forwarded-For chain.
var privateNets []*net.IPNet
+20
View File
@@ -153,6 +153,26 @@ func TestGetSecurityClientIPCustomHeaderPrecedenceAndFallback(t *testing.T) {
},
want: "4.4.4.4",
},
{
name: "invalid legacy values continue to a valid forwarded address",
trustForward: true,
requestHeaders: map[string]string{
"CF-Connecting-IP": "unknown",
"X-Real-IP": "proxy.internal",
"X-Forwarded-For": "also-invalid, 203.0.113.50",
},
want: "203.0.113.50",
},
{
name: "all invalid legacy values fall back to the connection address",
trustForward: true,
requestHeaders: map[string]string{
"CF-Connecting-IP": "unknown",
"X-Real-IP": "proxy.internal",
"X-Forwarded-For": "also-invalid",
},
want: "9.9.9.9",
},
{
name: "disabled mode ignores custom and legacy headers",
trustForward: false,
+4 -2
View File
@@ -244,10 +244,12 @@ func canonicalizeCodexOriginator(name string) string {
return name
}
// CodexCLIOriginator 官方 Codex CLI 默认 originator(codex-rs DEFAULT_ORIGINATOR),
// 也是身份归一化的目标身份。
// CodexCLIOriginator 是 codex-rs 客户端的历史默认 originator,保留用于兼容识别。
const CodexCLIOriginator = "codex_cli_rs"
// CodexDefaultOriginator 是网关默认使用的 Codex TUI originator。
const CodexDefaultOriginator = "codex-tui"
// CodexUserAgentVersion 提取 Codex UA 的完整版本段,即 `{client}/{version} (...` 中的 version。
// 与 ParseCodexEngineVersion 的区别:后者只取三段数字用于引擎版本比较(会丢掉 -alpha.4
// 之类的预发布后缀),本函数保留原样,因为出站 version 头必须与 UA 版本段逐字一致。
@@ -10,13 +10,11 @@ import (
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/profile"
)
const tencentCaptchaEndpoint = "captcha.tencentcloudapi.com"
type tencentCaptchaAPI interface {
DescribeCaptchaResultWithContext(context.Context, *captcha.DescribeCaptchaResultRequest) (*captcha.DescribeCaptchaResultResponse, error)
}
type tencentCaptchaClientFactory func(secretID, secretKey string) (tencentCaptchaAPI, error)
type tencentCaptchaClientFactory func(secretID, secretKey, endpoint string) (tencentCaptchaAPI, error)
type tencentCaptchaVerifier struct {
newClient tencentCaptchaClientFactory
@@ -26,16 +24,19 @@ func NewTencentCaptchaVerifier() service.TencentCaptchaVerifier {
return &tencentCaptchaVerifier{newClient: newTencentCaptchaSDKClient}
}
func newTencentCaptchaSDKClient(secretID, secretKey string) (tencentCaptchaAPI, error) {
// newTencentCaptchaSDKClient 接入点由调用方按站点下发(中国站 / 国际站)。
// service 层的 tencentCaptchaEndpoint 已保证 endpoint 非空;即便为空,
// 腾讯 SDK 也会回落到服务默认域 captcha.tencentcloudapi.com(即中国站),不会失败。
func newTencentCaptchaSDKClient(secretID, secretKey, endpoint string) (tencentCaptchaAPI, error) {
clientProfile := profile.NewClientProfile()
clientProfile.HttpProfile.Endpoint = tencentCaptchaEndpoint
clientProfile.HttpProfile.Endpoint = endpoint
clientProfile.HttpProfile.ReqMethod = "POST"
clientProfile.HttpProfile.ReqTimeout = 5
return captcha.NewClient(common.NewCredential(secretID, secretKey), "", clientProfile)
}
func (v *tencentCaptchaVerifier) VerifyTicket(ctx context.Context, credentials service.TencentCaptchaCredentials, proof service.TencentCaptchaProof, remoteIP string) (*service.TencentCaptchaVerifyResponse, error) {
client, err := v.newClient(credentials.CloudSecretID, credentials.CloudSecretKey)
client, err := v.newClient(credentials.CloudSecretID, credentials.CloudSecretKey, credentials.Endpoint)
if err != nil {
return nil, fmt.Errorf("create tencent captcha client: %w", err)
}
@@ -33,10 +33,10 @@ func TestTencentCaptchaVerifierMapsCredentialsRequestAndResponse(t *testing.T) {
RequestId: &requestID,
},
}}
var gotSecretID, gotSecretKey string
var gotSecretID, gotSecretKey, gotEndpoint string
verifier := &tencentCaptchaVerifier{
newClient: func(secretID, secretKey string) (tencentCaptchaAPI, error) {
gotSecretID, gotSecretKey = secretID, secretKey
newClient: func(secretID, secretKey, endpoint string) (tencentCaptchaAPI, error) {
gotSecretID, gotSecretKey, gotEndpoint = secretID, secretKey, endpoint
return client, nil
},
}
@@ -46,11 +46,14 @@ func TestTencentCaptchaVerifierMapsCredentialsRequestAndResponse(t *testing.T) {
AppSecretKey: "app-secret",
CloudSecretID: "cloud-secret-id",
CloudSecretKey: "cloud-secret-key",
Endpoint: "captcha.intl.tencentcloudapi.com",
}, service.TencentCaptchaProof{Ticket: "ticket", Randstr: "@rand"}, "203.0.113.10")
require.NoError(t, err)
require.Equal(t, "cloud-secret-id", gotSecretID)
require.Equal(t, "cloud-secret-key", gotSecretKey)
// 接入点由 service 按站点下发,repository 不得再写死国内站
require.Equal(t, "captcha.intl.tencentcloudapi.com", gotEndpoint)
require.NotNil(t, client.request)
require.Equal(t, uint64(9), *client.request.CaptchaType)
require.Equal(t, uint64(123456789), *client.request.CaptchaAppId)
@@ -746,6 +746,7 @@ func TestAPIContracts(t *testing.T) {
"tencent_captcha_app_secret_key_configured": false,
"tencent_captcha_cloud_secret_id_configured": false,
"tencent_captcha_cloud_secret_key_configured": false,
"tencent_captcha_region": "cn",
"aliyun_captcha_enabled": false,
"aliyun_captcha_access_key_id": "",
"aliyun_captcha_access_key_secret_configured": false,
@@ -1085,6 +1086,7 @@ func TestAPIContracts(t *testing.T) {
"tencent_captcha_app_secret_key_configured": false,
"tencent_captcha_cloud_secret_id_configured": false,
"tencent_captcha_cloud_secret_key_configured": false,
"tencent_captcha_region": "cn",
"aliyun_captcha_enabled": false,
"aliyun_captcha_access_key_id": "",
"aliyun_captcha_access_key_secret_configured": false,
@@ -18,10 +18,25 @@ const (
NonceTemplate = "__CSP_NONCE__"
// CloudflareInsightsDomain is the domain for Cloudflare Web Analytics
CloudflareInsightsDomain = "https://static.cloudflareinsights.com"
// TencentCaptchaDomain is the Tencent Captcha 2.0 Web SDK domain.
// TencentCaptchaDomain is the Tencent Captcha 2.0 Web SDK domain (Chinese mainland site).
TencentCaptchaDomain = "https://turing.captcha.qcloud.com"
// TencentCaptchaStaticDomain is the Tencent Captcha static asset domain.
TencentCaptchaStaticDomain = "https://*.captcha.gtimg.com"
// TencentCaptchaCDNDomain 是天御国内站的核心 JS CDN 主机:
// 入口脚本 TJCaptcha.js 会再从这里加载 /1/tgJCap.*.js,缺失时会被 script-src 拦截。
TencentCaptchaCDNDomain = "https://turing.captcha.gtimg.com"
// TencentCaptchaGlobalDomain 是天御国际站的 Web SDK 与验证弹窗 iframe 主机。
TencentCaptchaGlobalDomain = "https://ca.turing.captcha.qcloud.com"
// TencentCaptchaGlobalCDNDomain 是天御国际站的核心 JS CDN 主机。
TencentCaptchaGlobalCDNDomain = "https://global.turing.captcha.gtimg.com"
// TencentCaptchaPrehandleDomain 是天御 SDK 动态预处理脚本与预处理接口主机。
TencentCaptchaPrehandleDomain = "https://www.tycaptcha.com"
// TencentCaptchaJQueryDomain 是国内站入口脚本动态加载的 jQuery CDN 主机。
TencentCaptchaJQueryDomain = "https://cloudcache.tencentcs.com"
// TencentCaptchaRceDomain 是国际站风控校验接口主机。
TencentCaptchaRceDomain = "https://rce.tencentrio.com"
// TencentCaptchaWorkerSource 是天御国际站创建验证码 Web Worker 时使用的来源。
TencentCaptchaWorkerSource = "blob:"
// StripeDomain is the domain for Stripe.js SDK
StripeDomain = "https://*.stripe.com"
// AirwallexStaticDomain 是 Airwallex 生产环境 SDK 脚本域名。
@@ -42,6 +57,17 @@ var requiredCSPDirectiveValues = []struct {
{"script-src", TencentCaptchaDomain},
{"frame-src", TencentCaptchaDomain},
{"style-src", TencentCaptchaStaticDomain},
{"script-src", TencentCaptchaCDNDomain},
{"script-src", TencentCaptchaGlobalDomain},
{"script-src", TencentCaptchaGlobalCDNDomain},
{"script-src", TencentCaptchaPrehandleDomain},
{"script-src", TencentCaptchaJQueryDomain},
{"connect-src", TencentCaptchaDomain},
{"connect-src", TencentCaptchaPrehandleDomain},
{"connect-src", TencentCaptchaRceDomain},
{"frame-src", TencentCaptchaGlobalDomain},
{"frame-src", TencentCaptchaPrehandleDomain},
{"worker-src", TencentCaptchaWorkerSource},
{"script-src", StripeDomain},
{"frame-src", StripeDomain},
{"script-src", AirwallexStaticDomain},
@@ -129,6 +129,7 @@ func TestSecurityHeaders(t *testing.T) {
assert.Contains(t, csp, "default-src 'self'")
assert.Contains(t, csp, "'nonce-")
assert.Contains(t, csp, CloudflareInsightsDomain)
assert.Equal(t, 1, countDirectiveValue(csp, "worker-src", TencentCaptchaWorkerSource))
})
t.Run("api_route_skips_csp_nonce_generation", func(t *testing.T) {
@@ -322,6 +323,35 @@ func TestEnhanceCSPPolicy(t *testing.T) {
assert.Equal(t, 1, countDirectiveValue(enhanced, "frame-src", TencentCaptchaDomain))
assert.Equal(t, 1, countDirectiveValue(enhanced, "style-src", TencentCaptchaStaticDomain))
assert.Contains(t, config.DefaultCSPPolicy, "style-src 'self' 'unsafe-inline' https://*.captcha.gtimg.com")
// 入口脚本会再从 CDN 拉核心 JS,国际站还会换用 ca./global. 两个主机;
// 缺任意一个都会让天御 SDK 触发 script-src 拦截。
assert.Equal(t, 1, countDirectiveValue(enhanced, "script-src", TencentCaptchaCDNDomain))
assert.Equal(t, 1, countDirectiveValue(enhanced, "script-src", TencentCaptchaGlobalDomain))
assert.Equal(t, 1, countDirectiveValue(enhanced, "script-src", TencentCaptchaGlobalCDNDomain))
assert.Equal(t, 1, countDirectiveValue(enhanced, "script-src", TencentCaptchaPrehandleDomain))
assert.Equal(t, 1, countDirectiveValue(enhanced, "script-src", TencentCaptchaJQueryDomain))
assert.Equal(t, 1, countDirectiveValue(enhanced, "connect-src", TencentCaptchaDomain))
assert.Equal(t, 1, countDirectiveValue(enhanced, "connect-src", TencentCaptchaPrehandleDomain))
assert.Equal(t, 1, countDirectiveValue(enhanced, "connect-src", TencentCaptchaRceDomain))
assert.Equal(t, 1, countDirectiveValue(enhanced, "frame-src", TencentCaptchaGlobalDomain))
assert.Equal(t, 1, countDirectiveValue(enhanced, "frame-src", TencentCaptchaPrehandleDomain))
assert.Equal(t, 1, countDirectiveValue(enhanced, "worker-src", TencentCaptchaWorkerSource))
})
t.Run("does_not_duplicate_tencent_captcha_worker_source", func(t *testing.T) {
policy := "default-src 'self'; worker-src 'self' blob:; script-src 'self' __CSP_NONCE__"
enhanced := enhanceCSPPolicy(policy)
assert.Equal(t, 1, countDirectiveValue(enhanced, "worker-src", TencentCaptchaWorkerSource))
})
t.Run("default_policy_already_carries_tencent_captcha_domains", func(t *testing.T) {
// 默认策略与中间件强制注入表必须同形,否则 config.example.yaml 会误导自建用户
for _, required := range requiredCSPDirectiveValues {
assert.Equal(t, 1, countDirectiveValue(config.DefaultCSPPolicy, required.directive, required.value),
"DefaultCSPPolicy 缺少 %s %s", required.directive, required.value)
}
})
t.Run("handles_policy_without_script_src", func(t *testing.T) {
@@ -646,7 +646,7 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account
req.Host = "chatgpt.com"
req.Header.Set("accept", "text/event-stream")
req.Header.Set("OpenAI-Beta", "responses=experimental")
req.Header.Set("Originator", "codex_cli_rs")
req.Header.Set("Originator", openai.CodexDefaultOriginator)
if customUA := strings.TrimSpace(credentialAccount.GetOpenAIUserAgent()); customUA != "" {
req.Header.Set("User-Agent", customUA)
} else {
@@ -1860,7 +1860,7 @@ func (s *AccountTestService) testOpenAIImageOAuth(c *gin.Context, ctx context.Co
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
req.Header.Set("OpenAI-Beta", "responses=experimental")
req.Header.Set("originator", "codex_cli_rs")
req.Header.Set("originator", openai.CodexDefaultOriginator)
if customUA := strings.TrimSpace(credentialAccount.GetOpenAIUserAgent()); customUA != "" {
req.Header.Set("User-Agent", customUA)
} else {
@@ -759,7 +759,7 @@ func (s *AccountUsageService) probeOpenAICodexSnapshot(ctx context.Context, acco
}
req.Header.Set("Accept", "text/event-stream")
req.Header.Set("OpenAI-Beta", "responses=experimental")
req.Header.Set("Originator", "codex_cli_rs")
req.Header.Set("Originator", openaipkg.CodexDefaultOriginator)
req.Header.Set("Version", openAICodexProbeVersion)
req.Header.Set("User-Agent", codexCLIUserAgent)
if s.identityCache != nil {
@@ -170,6 +170,7 @@ const (
SettingKeyTencentCaptchaAppSecretKey = "tencent_captcha_app_secret_key"
SettingKeyTencentCaptchaCloudSecretID = "tencent_captcha_cloud_secret_id"
SettingKeyTencentCaptchaCloudSecretKey = "tencent_captcha_cloud_secret_key"
SettingKeyTencentCaptchaRegion = "tencent_captcha_region" // 站点:"cn"|"intl",决定前端 SDK 脚本与服务端接入点
// 阿里云验证码 2.0 设置(与 Turnstile、腾讯天御互斥,同一时间仅可启用一家)
SettingKeyAliyunCaptchaEnabled = "aliyun_captcha_enabled" // 是否启用阿里云验证码
@@ -7,7 +7,19 @@ import (
)
const (
openAIModelTransientFailureWindow = time.Minute
// openAIModelTransientStreakTTL bounds how long a failure streak survives
// without a new failure. It exists only so the map does not keep state for
// account+model pairs that stopped being used; a streak is otherwise reset
// by recordSuccess alone.
//
// It must stay well above the cooldowns. Resetting the streak on a short
// wall-clock window makes the breaker's sensitivity depend on request rate:
// a gateway called less often than the window never reaches streak 2, so a
// broken upstream is never cooled down and every request pays a failed
// attempt plus a failover before reaching a healthy account. Low-traffic
// deployments were hit hardest, which is the opposite of what a breaker
// should do.
openAIModelTransientStreakTTL = 30 * time.Minute
openAIModelTransientShortCooldown = 10 * time.Second
openAIModelTransientLongCooldown = 45 * time.Second
openAIModelTransientDefaultMax = 4096
@@ -86,7 +98,9 @@ func (s *openAIAccountModelTransientState) recordFailure(accountID int64, model
if !exists {
s.evictOldestLocked()
}
if !exists || entry.lastFailure.IsZero() || now.Sub(entry.lastFailure) > openAIModelTransientFailureWindow || now.Before(entry.lastFailure) {
// The streak is cleared by recordSuccess. Only drop it here when the entry
// is stale beyond the TTL, or when the clock moved backwards.
if !exists || entry.lastFailure.IsZero() || now.Sub(entry.lastFailure) > openAIModelTransientStreakTTL || now.Before(entry.lastFailure) {
entry.failureStreak = 0
entry.blockUntil = time.Time{}
}
@@ -139,7 +153,7 @@ func (s *openAIAccountModelTransientState) isBlocked(accountID int64, model stri
if !exists {
return false
}
if !entry.lastFailure.IsZero() && now.Sub(entry.lastFailure) > openAIModelTransientFailureWindow {
if !entry.lastFailure.IsZero() && now.Sub(entry.lastFailure) > openAIModelTransientStreakTTL {
delete(s.entries, key)
return false
}
@@ -80,12 +80,52 @@ func TestOpenAIModelTransient_StaleStreakExpires(t *testing.T) {
now := time.Date(2026, 7, 10, 10, 0, 0, 0, time.UTC)
state.recordFailure(35, "gpt-5.5", now)
decision := state.recordFailure(35, "gpt-5.5", now.Add(openAIModelTransientFailureWindow+time.Second))
decision := state.recordFailure(35, "gpt-5.5", now.Add(openAIModelTransientStreakTTL+time.Second))
assert.Equal(t, 1, decision.FailureStreak)
assert.Zero(t, decision.Cooldown)
}
// A streak must not depend on how often the gateway is called. Sparse traffic
// used to reset the streak between every request, so a broken account+model was
// never cooled down and each request paid a failed attempt plus a failover.
func TestOpenAIModelTransient_StreakSurvivesSparseTraffic(t *testing.T) {
state := newOpenAIAccountModelTransientState(128)
now := time.Date(2026, 7, 10, 10, 0, 0, 0, time.UTC)
gap := 5 * time.Minute
require.Greater(t, gap, openAIModelTransientLongCooldown,
"the gap must exceed every cooldown, otherwise this passes for the wrong reason")
first := state.recordFailure(35, "gpt-5.5", now)
second := state.recordFailure(35, "gpt-5.5", now.Add(gap))
third := state.recordFailure(35, "gpt-5.5", now.Add(2*gap))
assert.Equal(t, 1, first.FailureStreak)
assert.Zero(t, first.Cooldown)
assert.Equal(t, 2, second.FailureStreak)
assert.Equal(t, openAIModelTransientShortCooldown, second.Cooldown)
assert.Equal(t, 3, third.FailureStreak)
assert.Equal(t, openAIModelTransientLongCooldown, third.Cooldown)
assert.True(t, state.isBlocked(35, "gpt-5.5", now.Add(2*gap+time.Second)))
}
// A success between two sparse failures still clears the streak, so an account
// that intermittently works is not pushed into the long cooldown.
func TestOpenAIModelTransient_SuccessResetsStreakAcrossSparseTraffic(t *testing.T) {
state := newOpenAIAccountModelTransientState(128)
now := time.Date(2026, 7, 10, 10, 0, 0, 0, time.UTC)
gap := 5 * time.Minute
state.recordFailure(35, "gpt-5.5", now)
state.recordSuccess(35, "gpt-5.5")
decision := state.recordFailure(35, "gpt-5.5", now.Add(gap))
assert.Equal(t, 1, decision.FailureStreak)
assert.Zero(t, decision.Cooldown)
assert.False(t, state.isBlocked(35, "gpt-5.5", now.Add(gap+time.Second)))
}
func TestOpenAIModelTransient_IgnoresInvalidKeys(t *testing.T) {
state := newOpenAIAccountModelTransientState(128)
now := time.Date(2026, 7, 10, 10, 0, 0, 0, time.UTC)
@@ -11,6 +11,7 @@ import (
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"github.com/gin-gonic/gin"
"github.com/tidwall/gjson"
)
@@ -246,7 +247,7 @@ func (s *OpenAIGatewayService) buildOpenAIAlphaSearchResponsesWebSearchRequest(c
if originator := openAIAlphaSearchInboundHeader(c, "Originator"); originator != "" {
req.Header.Set("Originator", originator)
} else {
req.Header.Set("Originator", "codex_cli_rs")
req.Header.Set("Originator", openai.CodexDefaultOriginator)
}
if customUA := account.GetOpenAIUserAgent(); customUA != "" {
req.Header.Set("User-Agent", customUA)
@@ -389,7 +390,7 @@ func (s *OpenAIGatewayService) buildOpenAIAlphaSearchRequest(ctx context.Context
if originator := openAIAlphaSearchInboundHeader(c, "Originator"); originator != "" {
req.Header.Set("Originator", originator)
} else {
req.Header.Set("Originator", "codex_cli_rs")
req.Header.Set("Originator", openai.CodexDefaultOriginator)
}
if customUA := account.GetOpenAIUserAgent(); customUA != "" {
req.Header.Set("User-Agent", customUA)
@@ -12,6 +12,7 @@ import (
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
@@ -155,7 +156,7 @@ func TestForwardAlphaSearchPATUsesResponsesWebSearchFallback(t *testing.T) {
require.Equal(t, "responses=experimental", upstream.lastReq.Header.Get("OpenAI-Beta"))
require.Equal(t, codexCLIVersion, upstream.lastReq.Header.Get("Version"))
require.Equal(t, `{"turn_id":"turn-1"}`, upstream.lastReq.Header.Get("X-Codex-Turn-Metadata"))
require.Equal(t, "codex_cli_rs", upstream.lastReq.Header.Get("Originator"))
require.Equal(t, openai.CodexDefaultOriginator, upstream.lastReq.Header.Get("Originator"))
require.Empty(t, upstream.lastReq.Header.Get("X-Codex-Beta-Features"))
require.Empty(t, upstream.lastReq.Header.Get("X-Codex-Turn-State"))
require.Empty(t, upstream.lastReq.Header.Get(responsesLiteHeaderKey))
@@ -7,6 +7,7 @@ import (
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"github.com/stretchr/testify/require"
)
@@ -75,7 +76,7 @@ func TestStreamFailedEventCapacityShedRetriesOnSameAccount(t *testing.T) {
// 各自硬编码会漂移成互相矛盾的身份,而自相矛盾或陈旧的身份会被上游优先降载。
func TestCodexOutboundVersionHasSingleSource(t *testing.T) {
require.True(t,
strings.HasPrefix(codexCLIUserAgent, "codex_cli_rs/"+codexCLIVersion+" "),
strings.HasPrefix(codexCLIUserAgent, openai.CodexDefaultOriginator+"/"+codexCLIVersion+" "),
"codexCLIUserAgent=%q 必须以 codexCLIVersion=%q 作为版本段", codexCLIUserAgent, codexCLIVersion,
)
require.Equal(t, codexCLIVersion, openAICodexProbeVersion)
@@ -32,13 +32,13 @@ func NormalizeCodexClientVersion(version string) string {
return version
}
// buildCodexCLIUserAgent 按版本号拼出规范 Codex CLI User-Agent。
// buildCodexCLIUserAgent 按版本号拼出规范 Codex TUI User-Agent。
// UA 形态只在 codexCLIUserAgentSuffix 一处定义,避免多处拼装漂移。
func buildCodexCLIUserAgent(version string) string {
if version = NormalizeCodexClientVersion(version); version == "" {
return codexCLIUserAgent
}
return openai.CodexCLIOriginator + "/" + version + codexCLIUserAgentSuffix
return openai.CodexDefaultOriginator + "/" + version + codexCLIUserAgentSuffix
}
// codexIdentityEnforcement 控制 enforceCodexIdentityHeaders 是否强制统一出站身份,
@@ -99,7 +99,7 @@ type codexOutboundIdentity struct {
}
// resolveCodexOutboundIdentity 由候选 User-Agent 推导自洽的出站身份。
// candidateUA 为空时使用规范 User-Agent;推导不出官方身份时整体回退为规范 CLI 身份。
// candidateUA 为空时使用规范 User-Agent;推导不出官方身份时整体回退为规范 TUI 身份。
//
// 候选 UA(面板 / 账号级的管理员显式配置)只贡献客户端名与 OS / 架构 / 终端指纹,
// 其自带的版本段一律用当前生效版本重建:一条填写于某个历史版本的 UA 否则会把出站身份
@@ -114,7 +114,7 @@ func resolveCodexOutboundIdentity(candidateUA string) codexOutboundIdentity {
originator, pairedUA, ok := openai.PairCodexClientIdentity(ua)
if !ok {
if originator, pairedUA, ok = openai.PairCodexClientIdentity(canonical); !ok {
originator, pairedUA = openai.CodexCLIOriginator, codexCLIUserAgent
originator, pairedUA = openai.CodexDefaultOriginator, codexCLIUserAgent
}
}
// 生效版本只有一个来源:规范身份(面板版本号 → 自动同步值 → 内置常量,见
@@ -5,13 +5,14 @@ import (
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"github.com/stretchr/testify/require"
)
func requireOpenAICodexProbeHeaders(t *testing.T, h http.Header) {
t.Helper()
require.Equal(t, codexCLIUserAgent, h.Get("User-Agent"))
require.Equal(t, "codex_cli_rs", h.Get("Originator"))
require.Equal(t, openai.CodexDefaultOriginator, h.Get("Originator"))
require.Equal(t, codexCLIVersion, h.Get("Version"))
require.Equal(t, "responses=experimental", h.Get("OpenAI-Beta"))
require.NotEmpty(t, h.Get("X-Codex-Window-ID"))
@@ -27,7 +28,7 @@ func TestEnsureCodexIdentityHeaders(t *testing.T) {
ensureCodexIdentityHeaders(h)
enforceCodexIdentityHeaders(h)
require.Equal(t, "codex_cli_rs", h.Get("originator"))
require.Equal(t, openai.CodexDefaultOriginator, h.Get("originator"))
require.Equal(t, codexCLIUserAgent, h.Get("user-agent"))
require.Equal(t, codexCLIVersion, h.Get("version"))
require.Equal(t, "responses=experimental", h.Get("OpenAI-Beta"))
@@ -42,7 +43,7 @@ func TestEnsureCodexIdentityHeaders(t *testing.T) {
ensureCodexIdentityHeaders(h)
enforceCodexIdentityHeaders(h)
require.Equal(t, "codex_cli_rs", h.Get("originator"))
require.Equal(t, openai.CodexDefaultOriginator, h.Get("originator"))
require.Equal(t, codexCLIUserAgent, h.Get("user-agent"))
require.Equal(t, codexCLIVersion, h.Get("version"))
require.Equal(t, "responses=experimental", h.Get("OpenAI-Beta"))
@@ -112,7 +113,7 @@ func TestEnforceCodexIdentityHeaders(t *testing.T) {
enforceCodexIdentityHeaders(h)
require.Equal(t, "codex_cli_rs", h.Get("originator"))
require.Equal(t, openai.CodexDefaultOriginator, h.Get("originator"))
require.Equal(t, codexCLIUserAgent, h.Get("user-agent"))
require.Equal(t, codexCLIVersion, h.Get("version"))
})
@@ -141,7 +142,7 @@ func TestEnforceCodexIdentityHeadersWithAccountOverrideUA(t *testing.T) {
enforceCodexIdentityHeadersWithUA(h, "luna/1.0.0")
require.Equal(t, "codex_cli_rs", h.Get("originator"))
require.Equal(t, openai.CodexDefaultOriginator, h.Get("originator"))
require.Equal(t, codexCLIUserAgent, h.Get("user-agent"))
require.Equal(t, codexCLIVersion, h.Get("version"))
})
@@ -212,7 +213,7 @@ func TestEnforceCodexIdentityHeadersRejectsInvalidCanonicalUA(t *testing.T) {
enforceCodexIdentityHeaders(h)
require.Equal(t, "codex_cli_rs", h.Get("originator"))
require.Equal(t, openai.CodexDefaultOriginator, h.Get("originator"))
require.Equal(t, codexCLIUserAgent, h.Get("user-agent"))
require.Equal(t, codexCLIVersion, h.Get("version"))
}
@@ -235,7 +236,7 @@ func TestCodexIdentityEnforcementZeroValueConfigKeepsItEnabled(t *testing.T) {
enforceCodexIdentityHeaders(h)
require.Equal(t, "codex_cli_rs", h.Get("originator"))
require.Equal(t, openai.CodexDefaultOriginator, h.Get("originator"))
require.Equal(t, codexCLIUserAgent, h.Get("user-agent"))
}
@@ -271,7 +272,7 @@ func TestEnforceCodexIdentityHeaders_EnforcementDisabledThirdPartyFallback(t *te
enforceCodexIdentityHeaders(h)
require.Equal(t, "codex_cli_rs", h.Get("originator"))
require.Equal(t, openai.CodexDefaultOriginator, h.Get("originator"))
require.Equal(t, codexCLIUserAgent, h.Get("user-agent"))
require.Equal(t, codexCLIVersion, h.Get("version"))
}
@@ -288,7 +289,7 @@ func TestEnforceCodexIdentityHeadersIsIdempotent(t *testing.T) {
require.Equal(t, firstUA, h.Get("user-agent"))
require.Equal(t, firstVersion, h.Get("version"))
require.Equal(t, "codex_cli_rs", h.Get("originator"))
require.Equal(t, openai.CodexDefaultOriginator, h.Get("originator"))
}
// 缺少 originator 时必须保持 no-op:compat 桥接等非 ChatGPT 内部接口路径会显式删除
@@ -316,7 +317,7 @@ func TestNormalizeCodexClientVersion(t *testing.T) {
}
func TestBuildCodexCLIUserAgent(t *testing.T) {
require.Equal(t, "codex_cli_rs/0.200.1"+codexCLIUserAgentSuffix, buildCodexCLIUserAgent("0.200.1"))
require.Equal(t, openai.CodexDefaultOriginator+"/0.200.1"+codexCLIUserAgentSuffix, buildCodexCLIUserAgent("0.200.1"))
// 非法版本号必须回退到内置 UA,不能拼出畸形身份。
require.Equal(t, codexCLIUserAgent, buildCodexCLIUserAgent("bogus version"))
require.Equal(t, codexCLIUserAgent, buildCodexCLIUserAgent(""))
@@ -18,6 +18,7 @@ import (
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/httpclient"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"golang.org/x/net/http2"
"golang.org/x/sync/singleflight"
)
@@ -304,7 +305,7 @@ func (s *OpenAIGatewayService) FetchCodexModelsManifest(ctx context.Context, acc
setOpenAIChatGPTAccountHeaders(headers, credAccount)
}
headers.Set("Accept", "application/json")
headers.Set("Originator", "codex_cli_rs")
headers.Set("Originator", openai.CodexDefaultOriginator)
headers.Set("Version", clientVersion)
headers.Set("User-Agent", codexCLIUserAgent)
@@ -17,6 +17,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/config"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
"github.com/stretchr/testify/require"
"golang.org/x/net/http2"
@@ -193,7 +194,7 @@ func TestFetchCodexModelsManifestPassthrough(t *testing.T) {
if gotAccountID != "acc-123" {
t.Errorf("chatgpt-account-id header: got %q", gotAccountID)
}
if gotOriginator != "codex_cli_rs" {
if gotOriginator != openai.CodexDefaultOriginator {
t.Errorf("originator header: got %q", gotOriginator)
}
if gotClientVersion != "0.137.0" {
@@ -447,7 +448,7 @@ func TestFetchCodexModelsManifestAPIKeyCustomUpstream(t *testing.T) {
if gotRequest.Header.Get("Authorization") != "Bearer sk-upstream" {
t.Errorf("authorization header: got %q", gotRequest.Header.Get("Authorization"))
}
if gotRequest.Header.Get("Originator") != "codex_cli_rs" {
if gotRequest.Header.Get("Originator") != openai.CodexDefaultOriginator {
t.Errorf("originator header: got %q", gotRequest.Header.Get("Originator"))
}
if gotRequest.Header.Get("Version") != "0.144.0" {
@@ -10,6 +10,7 @@ import (
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/httpclient"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
)
const openAICodexPATWhoamiURLDefault = "https://auth.openai.com/api/accounts/v1/user-auth-credential/whoami"
@@ -58,7 +59,7 @@ func (s *OpenAIOAuthService) ValidateCodexPersonalAccessToken(ctx context.Contex
}
req.Header.Set("authorization", "Bearer "+accessToken)
req.Header.Set("accept", "application/json")
req.Header.Set("originator", "codex_cli_rs")
req.Header.Set("originator", openai.CodexDefaultOriginator)
req.Header.Set("user-agent", codexCLIUserAgent)
resp, err := client.Do(req)
@@ -6,6 +6,7 @@ import (
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"github.com/stretchr/testify/require"
)
@@ -38,7 +39,7 @@ func TestOpenAIOAuthService_ValidateCodexPersonalAccessToken(t *testing.T) {
info, err := svc.ValidateCodexPersonalAccessToken(context.Background(), " at-test-token ", "")
require.NoError(t, err)
require.Equal(t, "Bearer at-test-token", gotAuthorization)
require.Equal(t, "codex_cli_rs", gotOriginator)
require.Equal(t, openai.CodexDefaultOriginator, gotOriginator)
require.Equal(t, codexCLIUserAgent, gotUserAgent)
require.Equal(t, OpenAIAuthModePersonalAccessToken, info.AuthMode)
require.Equal(t, "user@example.com", info.Email)
@@ -6,6 +6,7 @@ import (
"strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"github.com/stretchr/testify/require"
)
@@ -13,7 +14,7 @@ func TestCodexVersionConstants_Consistency(t *testing.T) {
require.Equal(t, codexCLIVersion, openAICodexProbeVersion,
"codexCLIVersion and openAICodexProbeVersion must stay in sync")
require.True(t, strings.Contains(codexCLIUserAgent, "codex_cli_rs/"+codexCLIVersion),
require.True(t, strings.Contains(codexCLIUserAgent, openai.CodexDefaultOriginator+"/"+codexCLIVersion),
"codexCLIUserAgent must embed codexCLIVersion")
require.True(t, strings.Contains(DefaultOpenAICodexUserAgent, codexCLIVersion),
@@ -327,14 +327,14 @@ func TestGetOpenAICodexClientVersionFallsBackOnError(t *testing.T) {
require.Equal(t, codexCLIVersion, svc.GetOpenAICodexClientVersion(context.Background()))
}
// 规范 UA:面板未填完整 UA 时按当前生效版本号拼出标准 CLI 形态。
// 规范 UA:面板未填完整 UA 时按当前生效版本号拼出标准 TUI 形态。
func TestGetOpenAICodexCanonicalUserAgentBuildsFromVersion(t *testing.T) {
svc := NewSettingService(&codexVersionSettingRepoStub{values: map[string]string{
SettingKeyOpenAICodexClientVersionSynced: "0.200.1",
}}, nil)
require.Equal(t,
"codex_cli_rs/0.200.1"+codexCLIUserAgentSuffix,
"codex-tui/0.200.1"+codexCLIUserAgentSuffix,
svc.GetOpenAICodexCanonicalUserAgent(context.Background()),
)
}
@@ -368,6 +368,18 @@ func TestGetOpenAICodexCanonicalUserAgentRebuildsPanelUAVersion(t *testing.T) {
)
})
t.Run("TUI UA 的首尾两个版本号同时更新", func(t *testing.T) {
svc := NewSettingService(&codexVersionSettingRepoStub{values: map[string]string{
SettingKeyOpenAICodexUserAgent: "codex-tui/0.146.1 (Ubuntu 22.4.0; x86_64) WindowsTerminal (codex-tui; 0.146.1)",
SettingKeyOpenAICodexClientVersionSynced: "0.200.1",
}}, nil)
require.Equal(t,
"codex-tui/0.200.1 (Ubuntu 22.4.0; x86_64) WindowsTerminal (codex-tui; 0.200.1)",
svc.GetOpenAICodexCanonicalUserAgent(context.Background()),
)
})
// 面板版本号覆写优先级仍然高于同步值:管理员固定版本的诉求不被重建绕开。
t.Run("面板版本号覆写优先", func(t *testing.T) {
svc := NewSettingService(&codexVersionSettingRepoStub{values: map[string]string{
@@ -18,6 +18,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
@@ -997,7 +998,7 @@ func TestForwardAsAnthropic_ReusesOAuthCodexTurnState(t *testing.T) {
require.NoError(t, err)
require.NotNil(t, firstResult)
require.Empty(t, upstream.requests[0].Header.Get("x-codex-turn-state"))
requireOpenAIMessagesCodexIdentity(t, upstream.requests[0], codexCLIUserAgent, "codex_cli_rs")
requireOpenAIMessagesCodexIdentity(t, upstream.requests[0], codexCLIUserAgent, openai.CodexDefaultOriginator)
secondBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"first"},{"role":"assistant","content":"ok"},{"role":"user","content":"second"}],"stream":false}`)
secondRec := httptest.NewRecorder()
@@ -1011,7 +1012,7 @@ func TestForwardAsAnthropic_ReusesOAuthCodexTurnState(t *testing.T) {
require.Equal(t, "turn_state_first", upstream.requests[1].Header.Get("x-codex-turn-state"))
require.Equal(t, generateSessionUUID(isolateOpenAISessionID(0, "stable-cache-key")), upstream.requests[1].Header.Get("session_id"))
require.Empty(t, upstream.requests[1].Header.Get("conversation_id"))
requireOpenAIMessagesCodexIdentity(t, upstream.requests[1], codexCLIUserAgent, "codex_cli_rs")
requireOpenAIMessagesCodexIdentity(t, upstream.requests[1], codexCLIUserAgent, openai.CodexDefaultOriginator)
require.False(t, gjson.GetBytes(upstream.bodies[1], "prompt_cache_key").Exists())
require.False(t, gjson.GetBytes(upstream.bodies[1], "previous_response_id").Exists())
}
@@ -1062,7 +1063,7 @@ func TestForwardAsAnthropic_OAuthRestoresCodexIdentityHeaders(t *testing.T) {
result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "gpt-5.4")
require.NoError(t, err)
require.NotNil(t, result)
requireOpenAIMessagesCodexIdentity(t, upstream.lastReq, codexCLIUserAgent, "codex_cli_rs")
requireOpenAIMessagesCodexIdentity(t, upstream.lastReq, codexCLIUserAgent, openai.CodexDefaultOriginator)
})
}
}
@@ -1105,7 +1106,7 @@ func TestForwardAsAnthropic_OAuthDigestFallbackReusesTurnStateWithoutExplicitKey
firstSessionID := upstream.requests[0].Header.Get("session_id")
require.NotEmpty(t, firstSessionID)
require.Empty(t, upstream.requests[0].Header.Get("x-codex-turn-state"))
requireOpenAIMessagesCodexIdentity(t, upstream.requests[0], codexCLIUserAgent, "codex_cli_rs")
requireOpenAIMessagesCodexIdentity(t, upstream.requests[0], codexCLIUserAgent, openai.CodexDefaultOriginator)
require.False(t, gjson.GetBytes(upstream.bodies[0], "prompt_cache_key").Exists())
secondBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"first"},{"role":"assistant","content":"ok"},{"role":"user","content":"second"}],"stream":false}`)
@@ -1120,7 +1121,7 @@ func TestForwardAsAnthropic_OAuthDigestFallbackReusesTurnStateWithoutExplicitKey
require.Equal(t, firstSessionID, upstream.requests[1].Header.Get("session_id"))
require.Equal(t, "turn_state_digest_first", upstream.requests[1].Header.Get("x-codex-turn-state"))
require.Empty(t, upstream.requests[1].Header.Get("conversation_id"))
requireOpenAIMessagesCodexIdentity(t, upstream.requests[1], codexCLIUserAgent, "codex_cli_rs")
requireOpenAIMessagesCodexIdentity(t, upstream.requests[1], codexCLIUserAgent, openai.CodexDefaultOriginator)
require.False(t, gjson.GetBytes(upstream.bodies[1], "prompt_cache_key").Exists())
require.False(t, gjson.GetBytes(upstream.bodies[1], "previous_response_id").Exists())
}
@@ -1275,7 +1276,7 @@ func TestForwardAsAnthropic_OAuthKeepsSystemAsDeveloperInput(t *testing.T) {
instructions := gjson.GetBytes(upstream.lastBody, "instructions")
require.True(t, instructions.Exists())
require.Empty(t, instructions.String())
requireOpenAIMessagesCodexIdentity(t, upstream.requests[0], codexCLIUserAgent, "codex_cli_rs")
requireOpenAIMessagesCodexIdentity(t, upstream.requests[0], codexCLIUserAgent, openai.CodexDefaultOriginator)
}
func TestForwardAsAnthropic_OAuthAddsClaudeCodeTodoGuardForCompatModel(t *testing.T) {
@@ -104,7 +104,7 @@ func resolveGrokCacheIdentity(c *gin.Context, body []byte, explicitKey, upstream
func explicitGrokCacheSeed(c *gin.Context, body []byte, explicitKey string) string {
// Claude Code session is the most stable multi-turn identity for
// /v1/messages → Grok bridges. Prefer it over generic session headers so
// prompt cache routing matches CPA behavior.
// prompt cache routing follows the gateway's existing cache affinity rules.
seed := extractClaudeCodeSessionID(c, body)
if seed == "" {
seed = explicitOpenAIHeaderSessionID(c)
@@ -17,6 +17,7 @@ import (
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"github.com/Wei-Shaw/sub2api/internal/util/responseheaders"
"github.com/gin-gonic/gin"
"github.com/tidwall/gjson"
@@ -413,7 +414,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough(
req.Header.Set("OpenAI-Beta", "responses=experimental")
}
if req.Header.Get("originator") == "" {
req.Header.Set("originator", "codex_cli_rs")
req.Header.Set("originator", openai.CodexDefaultOriginator)
}
// 用隔离后的 session 标识符覆盖客户端透传值,防止跨用户会话碰撞。
if clientSessionID == "" {
@@ -14,6 +14,7 @@ import (
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
"github.com/gin-gonic/gin"
"github.com/tidwall/gjson"
@@ -157,7 +158,7 @@ func resolveOpenAIUpstreamOriginator(c *gin.Context, isOfficialClient bool) stri
}
}
if isOfficialClient {
return "codex_cli_rs"
return openai.CodexDefaultOriginator
}
return "opencode"
}
@@ -33,7 +33,7 @@ const (
openaiPlatformAPIURL = "https://api.openai.com/v1/responses"
openaiPlatformAPIInputTokensURL = "https://api.openai.com/v1/responses/input_tokens"
openaiStickySessionTTL = time.Hour // 粘性会话TTL
// 与真实 Codex CLI 的 User-Agent 结构对齐:
// 与真实 Codex TUI 的 User-Agent 结构对齐:
// {originator}/{version} ({OS} {OS_version}; {arch}) {terminal}
// 缺少 OS/架构/终端后缀的形态易被上游指纹识别为非官方客户端。
// 该后缀是 UA 形态的唯一定义处,buildCodexCLIUserAgent 按运行时版本号复用它。
@@ -41,7 +41,7 @@ const (
// codexCLIUserAgent 是编译期兜底 UA;运行时优先使用由后台版本号拼出的规范 UA。
// 版本段必须来自 codexCLIVersion:UA 与 version 头是同一个版本声明的两个出口,
// 各自硬编码会漂移成互相矛盾的身份。
codexCLIUserAgent = "codex_cli_rs/" + codexCLIVersion + codexCLIUserAgentSuffix
codexCLIUserAgent = openai.CodexDefaultOriginator + "/" + codexCLIVersion + codexCLIUserAgentSuffix
// codex_cli_only 拒绝时单个请求头日志长度上限(字符)
codexCLIOnlyHeaderValueMaxBytes = 256
@@ -3030,7 +3030,7 @@ func TestOpenAIBuildUpstreamRequestOAuthOfficialClientOriginatorCompatibility(t
isCodexCLI := openai.IsCodexOfficialClientByHeaders(c.GetHeader("User-Agent"), c.GetHeader("originator"))
req, err := svc.buildUpstreamRequest(c.Request.Context(), c, account, []byte(`{"model":"gpt-5"}`), "token", false, "", isCodexCLI)
require.NoError(t, err)
require.Equal(t, "codex_cli_rs", req.Header.Get("originator"))
require.Equal(t, openai.CodexDefaultOriginator, req.Header.Get("originator"))
require.Equal(t, codexCLIUserAgent, req.Header.Get("User-Agent"))
require.Equal(t, codexCLIVersion, req.Header.Get("version"))
})
@@ -16,6 +16,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat"
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
"github.com/gin-gonic/gin"
@@ -989,10 +990,10 @@ func TestOpenAIGatewayService_OAuthLegacy_CompositeCodexUAUsesCodexOriginator(t
_, err := svc.Forward(context.Background(), c, account, inputBody)
require.NoError(t, err)
require.NotNil(t, upstream.lastReq)
// 浏览器型复合 UA 被替换为默认 Codex UA(CLI 形态,避开上游降载桶),
// 浏览器型复合 UA 被替换为默认 Codex TUI UA,
// originator 随最终 UA 配套(issue #3901)。
require.Equal(t, DefaultOpenAICodexUserAgent, upstream.lastReq.Header.Get("User-Agent"))
require.Equal(t, "codex_cli_rs", upstream.lastReq.Header.Get("originator"))
require.Equal(t, openai.CodexDefaultOriginator, upstream.lastReq.Header.Get("originator"))
require.NotEqual(t, "opencode", upstream.lastReq.Header.Get("originator"))
}
@@ -1831,7 +1832,7 @@ func TestOpenAIGatewayService_OAuthPassthrough_OfficialIdentityUnified(t *testin
require.NoError(t, err)
require.NotNil(t, upstream.lastReq)
require.Equal(t, codexCLIUserAgent, upstream.lastReq.Header.Get("User-Agent"))
require.Equal(t, "codex_cli_rs", upstream.lastReq.Header.Get("originator"))
require.Equal(t, openai.CodexDefaultOriginator, upstream.lastReq.Header.Get("originator"))
require.Equal(t, codexCLIVersion, upstream.lastReq.Header.Get("version"))
}
@@ -1875,7 +1876,7 @@ func TestOpenAIGatewayService_OAuthPassthrough_CodexTuiIdentityUnified(t *testin
require.NoError(t, err)
require.NotNil(t, upstream.lastReq)
require.Equal(t, codexCLIUserAgent, upstream.lastReq.Header.Get("User-Agent"))
require.Equal(t, "codex_cli_rs", upstream.lastReq.Header.Get("originator"))
require.Equal(t, openai.CodexDefaultOriginator, upstream.lastReq.Header.Get("originator"))
require.Equal(t, codexCLIVersion, upstream.lastReq.Header.Get("version"))
}
@@ -15,6 +15,7 @@ import (
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
coderws "github.com/coder/websocket"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
@@ -839,7 +840,7 @@ func TestOpenAIGatewayService_Forward_WSv2_OAuthOriginatorCompatibility(t *testi
result, err := svc.Forward(context.Background(), c, account, body)
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, "codex_cli_rs", captureDialer.lastHeaders.Get("originator"))
require.Equal(t, openai.CodexDefaultOriginator, captureDialer.lastHeaders.Get("originator"))
require.Equal(t, codexCLIUserAgent, captureDialer.lastHeaders.Get("user-agent"))
require.Equal(t, codexCLIVersion, captureDialer.lastHeaders.Get("version"))
})
@@ -463,6 +463,7 @@ type TencentCaptchaConfig struct {
AppSecretKey string
CloudSecretID string
CloudSecretKey string
Region string
}
// AliyunCaptchaConfig contains the credentials required by Aliyun Captcha 2.0's
@@ -491,6 +492,7 @@ func (s *SettingService) GetCaptchaProviderConfig(ctx context.Context) (CaptchaP
SettingKeyTencentCaptchaAppSecretKey,
SettingKeyTencentCaptchaCloudSecretID,
SettingKeyTencentCaptchaCloudSecretKey,
SettingKeyTencentCaptchaRegion,
SettingKeyAliyunCaptchaEnabled,
SettingKeyAliyunCaptchaAccessKeyID,
SettingKeyAliyunCaptchaAccessKeySecret,
@@ -509,6 +511,7 @@ func (s *SettingService) GetCaptchaProviderConfig(ctx context.Context) (CaptchaP
AppSecretKey: values[SettingKeyTencentCaptchaAppSecretKey],
CloudSecretID: values[SettingKeyTencentCaptchaCloudSecretID],
CloudSecretKey: values[SettingKeyTencentCaptchaCloudSecretKey],
Region: normalizeTencentCaptchaRegion(values[SettingKeyTencentCaptchaRegion]),
},
Aliyun: AliyunCaptchaConfig{
Enabled: values[SettingKeyAliyunCaptchaEnabled] == "true",
@@ -82,10 +82,8 @@ const antigravityUserAgentVersionCacheTTL = 60 * time.Second
const antigravityUserAgentVersionErrorTTL = 5 * time.Second
const antigravityUserAgentVersionDBTimeout = 5 * time.Second
// DefaultOpenAICodexUserAgent OpenAI Codex 默认 User-Agent(用于规避 Cloudflare 对浏览器 UA 的质询)。
// 取官方 CLI 身份而非 TUI 身份:上游在容量紧张时按客户端身份分优先级降载,TUI 身份被观测到
// 优先命中降载,而该默认值是浏览器 UA 兜底路径上最主要的身份来源。版本段随 codexCLIVersion
// 一起更新——陈旧版本同样会被优先降载。
// DefaultOpenAICodexUserAgent 是 OpenAI Codex 默认 User-Agent,用于规避浏览器 UA 的质询。
// 默认采用 codex-tui 身份,版本段随 codexCLIVersion 一起更新。
const DefaultOpenAICodexUserAgent = codexCLIUserAgent
// cachedOpenAICodexUserAgent 缓存 OpenAI Codex UA(进程内缓存,60s TTL)
@@ -366,7 +364,7 @@ func (s *SettingService) InvalidateOpenAICodexClientVersionCache() {
}
// GetOpenAICodexCanonicalUserAgent 返回出站规范 Codex User-Agent。
// 未填面板 UA 时按当前生效的客户端版本号拼出标准 CLI UA。
// 未填面板 UA 时按当前生效的客户端版本号拼出标准 Codex TUI UA。
//
// 面板 UA 只贡献客户端名与 OS / 架构 / 终端指纹,版本段一律用生效版本重建:该输入框是
// 唯一能改 UA 后缀的地方,但它填写于某个历史版本,逐字沿用会把出站身份永久钉死在陈旧
@@ -329,6 +329,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
TencentCaptchaAppSecretKeyConfigured: settings[SettingKeyTencentCaptchaAppSecretKey] != "",
TencentCaptchaCloudSecretIDConfigured: settings[SettingKeyTencentCaptchaCloudSecretID] != "",
TencentCaptchaCloudSecretKeyConfigured: settings[SettingKeyTencentCaptchaCloudSecretKey] != "",
TencentCaptchaRegion: normalizeTencentCaptchaRegion(settings[SettingKeyTencentCaptchaRegion]),
AliyunCaptchaEnabled: settings[SettingKeyAliyunCaptchaEnabled] == "true",
AliyunCaptchaAccessKeyID: settings[SettingKeyAliyunCaptchaAccessKeyID],
AliyunCaptchaAccessKeySecretConfigured: settings[SettingKeyAliyunCaptchaAccessKeySecret] != "",
@@ -173,6 +173,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
SettingKeyTurnstileSiteKey,
SettingKeyTencentCaptchaEnabled,
SettingKeyTencentCaptchaAppID,
SettingKeyTencentCaptchaRegion,
SettingKeyAliyunCaptchaEnabled,
SettingKeyAliyunCaptchaSceneID,
SettingKeyAliyunCaptchaPrefix,
@@ -309,6 +310,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey],
TencentCaptchaEnabled: settings[SettingKeyTencentCaptchaEnabled] == "true",
TencentCaptchaAppID: settings[SettingKeyTencentCaptchaAppID],
TencentCaptchaRegion: normalizeTencentCaptchaRegion(settings[SettingKeyTencentCaptchaRegion]),
AliyunCaptchaEnabled: settings[SettingKeyAliyunCaptchaEnabled] == "true",
AliyunCaptchaSceneID: settings[SettingKeyAliyunCaptchaSceneID],
AliyunCaptchaPrefix: settings[SettingKeyAliyunCaptchaPrefix],
@@ -504,6 +506,7 @@ type PublicSettingsInjectionPayload struct {
TurnstileSiteKey string `json:"turnstile_site_key"`
TencentCaptchaEnabled bool `json:"tencent_captcha_enabled"`
TencentCaptchaAppID string `json:"tencent_captcha_app_id"`
TencentCaptchaRegion string `json:"tencent_captcha_region"`
AliyunCaptchaEnabled bool `json:"aliyun_captcha_enabled"`
AliyunCaptchaSceneID string `json:"aliyun_captcha_scene_id"`
AliyunCaptchaPrefix string `json:"aliyun_captcha_prefix"`
@@ -583,6 +586,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
TurnstileSiteKey: settings.TurnstileSiteKey,
TencentCaptchaEnabled: settings.TencentCaptchaEnabled,
TencentCaptchaAppID: settings.TencentCaptchaAppID,
TencentCaptchaRegion: settings.TencentCaptchaRegion,
AliyunCaptchaEnabled: settings.AliyunCaptchaEnabled,
AliyunCaptchaSceneID: settings.AliyunCaptchaSceneID,
AliyunCaptchaPrefix: settings.AliyunCaptchaPrefix,
@@ -221,6 +221,7 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting
if settings.TencentCaptchaCloudSecretKey != "" {
updates[SettingKeyTencentCaptchaCloudSecretKey] = settings.TencentCaptchaCloudSecretKey
}
updates[SettingKeyTencentCaptchaRegion] = normalizeTencentCaptchaRegion(settings.TencentCaptchaRegion)
// 阿里云验证码 2.0 设置(只有非空才更新密钥)
updates[SettingKeyAliyunCaptchaEnabled] = strconv.FormatBool(settings.AliyunCaptchaEnabled)
updates[SettingKeyAliyunCaptchaAccessKeyID] = settings.AliyunCaptchaAccessKeyID
+3 -1
View File
@@ -50,6 +50,7 @@ type SystemSettings struct {
TencentCaptchaCloudSecretIDConfigured bool
TencentCaptchaCloudSecretKey string
TencentCaptchaCloudSecretKeyConfigured bool
TencentCaptchaRegion string
AliyunCaptchaEnabled bool
AliyunCaptchaAccessKeyID string
AliyunCaptchaAccessKeySecret string
@@ -227,7 +228,7 @@ type SystemSettings struct {
EnableClientDatelineNormalization bool // 是否对 Anthropic OAuth/SetupToken 请求体做客户端 dateline 归一化(默认 true)
RewriteMessageCacheControl bool // 是否改写 messages[*].content[*].cache_control(默认 false)
AntigravityUserAgentVersion string // Antigravity 上游 User-Agent 版本号;空值使用配置/默认值
OpenAICodexUserAgent string // OpenAI Codex 上游完整 User-Agent;空值由 Codex 客户端版本号拼出标准 CLI UA
OpenAICodexUserAgent string // OpenAI Codex 上游完整 User-Agent;空值由 Codex 客户端版本号拼出标准 TUI UA
OpenAICodexClientVersion string // 出站声明的 Codex 客户端版本号(管理员覆写);空值跟随自动同步值
OpenAICodexClientVersionSynced string // 自动同步到的官方最新稳定版版本号(只读展示)
OpenAICodexVersionAutoSyncEnabled bool // 是否启用 Codex 客户端版本号自动同步(默认 true)
@@ -319,6 +320,7 @@ type PublicSettings struct {
TurnstileSiteKey string
TencentCaptchaEnabled bool
TencentCaptchaAppID string
TencentCaptchaRegion string
AliyunCaptchaEnabled bool
AliyunCaptchaSceneID string
AliyunCaptchaPrefix string
@@ -25,6 +25,35 @@ type TencentCaptchaCredentials struct {
AppSecretKey string
CloudSecretID string
CloudSecretKey string
// Endpoint 服务端票据校验接入点,由地域推导,repository 层直接使用
Endpoint string
}
const (
// TencentCaptchaRegionCN 中国站(cloud.tencent.com);TencentCaptchaRegionINTL 国际站(tencentcloud.com)。
// 该值同时决定前端加载的 SDK 脚本与服务端校验接入点,两端必须一致:
// 国际站 CaptchaAppId 配国内站 SDK 会被腾讯直接判为「appid 所属地域与实际使用地域不符」。
TencentCaptchaRegionCN = "cn"
TencentCaptchaRegionINTL = "intl"
tencentCaptchaEndpointCN = "captcha.tencentcloudapi.com"
tencentCaptchaEndpointINTL = "captcha.intl.tencentcloudapi.com"
)
// tencentCaptchaEndpoint 按后台配置的地域返回服务端接入点,未知值回退中国站
func tencentCaptchaEndpoint(region string) string {
if region == TencentCaptchaRegionINTL {
return tencentCaptchaEndpointINTL
}
return tencentCaptchaEndpointCN
}
// normalizeTencentCaptchaRegion 非法值一律视为中国站
func normalizeTencentCaptchaRegion(value string) string {
if value == TencentCaptchaRegionINTL {
return TencentCaptchaRegionINTL
}
return TencentCaptchaRegionCN
}
type TencentCaptchaVerifyResponse struct {
@@ -78,12 +107,31 @@ func (s *TencentCaptchaService) VerifyTicketWithConfig(ctx context.Context, conf
result, err := s.verifier.VerifyTicket(ctx, credentials, proof, remoteIP)
if err != nil {
logger.LegacyPrintf("service.tencent_captcha", "%s", "[TencentCaptcha] verification request failed")
logger.LegacyPrintf(
"service.tencent_captcha",
"[TencentCaptcha] verification request failed region=%s endpoint=%s error=%v",
normalizeTencentCaptchaRegion(config.Region),
credentials.Endpoint,
err,
)
return fmt.Errorf("%w: verifier request failed", ErrTencentCaptchaVerificationFailed)
}
if result == nil || result.CaptchaCode != 1 {
if result != nil {
logger.LegacyPrintf("service.tencent_captcha", "[TencentCaptcha] rejected code=%d request_id=%s", result.CaptchaCode, result.RequestID)
logger.LegacyPrintf(
"service.tencent_captcha",
"[TencentCaptcha] rejected region=%s code=%d message=%q request_id=%q",
normalizeTencentCaptchaRegion(config.Region),
result.CaptchaCode,
result.CaptchaMsg,
result.RequestID,
)
} else {
logger.LegacyPrintf(
"service.tencent_captcha",
"[TencentCaptcha] rejected region=%s empty_response=true",
normalizeTencentCaptchaRegion(config.Region),
)
}
return ErrTencentCaptchaVerificationFailed
}
@@ -100,6 +148,7 @@ func parseTencentCaptchaCredentials(config TencentCaptchaConfig) (TencentCaptcha
AppSecretKey: strings.TrimSpace(config.AppSecretKey),
CloudSecretID: strings.TrimSpace(config.CloudSecretID),
CloudSecretKey: strings.TrimSpace(config.CloudSecretKey),
Endpoint: tencentCaptchaEndpoint(config.Region),
}
if credentials.AppSecretKey == "" || credentials.CloudSecretID == "" || credentials.CloudSecretKey == "" {
return TencentCaptchaCredentials{}, false
@@ -12,31 +12,65 @@ import (
)
type tencentCaptchaVerifierStub struct {
response *TencentCaptchaVerifyResponse
err error
calls int
proof TencentCaptchaProof
remoteIP string
response *TencentCaptchaVerifyResponse
err error
calls int
proof TencentCaptchaProof
remoteIP string
credentials TencentCaptchaCredentials
}
func (s *tencentCaptchaVerifierStub) VerifyTicket(_ context.Context, _ TencentCaptchaCredentials, proof TencentCaptchaProof, remoteIP string) (*TencentCaptchaVerifyResponse, error) {
func (s *tencentCaptchaVerifierStub) VerifyTicket(_ context.Context, credentials TencentCaptchaCredentials, proof TencentCaptchaProof, remoteIP string) (*TencentCaptchaVerifyResponse, error) {
s.calls++
s.credentials = credentials
s.proof = proof
s.remoteIP = remoteIP
return s.response, s.err
}
func newTencentCaptchaTestService(verifier TencentCaptchaVerifier) *TencentCaptchaService {
settings := NewSettingService(&settingPublicRepoStub{values: map[string]string{
return newTencentCaptchaTestServiceWithRegion(verifier, "")
}
func newTencentCaptchaTestServiceWithRegion(verifier TencentCaptchaVerifier, region string) *TencentCaptchaService {
values := map[string]string{
SettingKeyTencentCaptchaEnabled: "true",
SettingKeyTencentCaptchaAppID: "123456789",
SettingKeyTencentCaptchaAppSecretKey: "app-secret",
SettingKeyTencentCaptchaCloudSecretID: "cloud-secret-id",
SettingKeyTencentCaptchaCloudSecretKey: "cloud-secret-key",
}}, &config.Config{})
}
if region != "" {
values[SettingKeyTencentCaptchaRegion] = region
}
settings := NewSettingService(&settingPublicRepoStub{values: values}, &config.Config{})
return NewTencentCaptchaService(settings, verifier)
}
// 站点决定服务端票据校验接入点:国际站账号的密钥在国内站接入点上无法通过鉴权,
// 因此这条映射一旦错位,国际站验证码会整体失效。
func TestTencentCaptchaServiceRoutesVerifyEndpointByRegion(t *testing.T) {
cases := []struct {
name string
region string
wantEndpoint string
}{
{"未配置回落中国站", "", "captcha.tencentcloudapi.com"},
{"中国站", TencentCaptchaRegionCN, "captcha.tencentcloudapi.com"},
{"国际站", TencentCaptchaRegionINTL, "captcha.intl.tencentcloudapi.com"},
{"非法值回落中国站", "sgp", "captcha.tencentcloudapi.com"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
verifier := &tencentCaptchaVerifierStub{response: &TencentCaptchaVerifyResponse{CaptchaCode: 1}}
svc := newTencentCaptchaTestServiceWithRegion(verifier, tc.region)
require.NoError(t, svc.VerifyTicket(context.Background(), "ticket", "@rand", "203.0.113.10"))
require.Equal(t, tc.wantEndpoint, verifier.credentials.Endpoint)
})
}
}
func TestTencentCaptchaServiceAcceptsCaptchaCodeOne(t *testing.T) {
verifier := &tencentCaptchaVerifierStub{response: &TencentCaptchaVerifyResponse{CaptchaCode: 1}}
svc := newTencentCaptchaTestService(verifier)
@@ -40,12 +40,15 @@ func TestSettingService_GetPublicSettingsExposesOnlyTencentCaptchaAppID(t *testi
SettingKeyTencentCaptchaAppSecretKey: "app-secret",
SettingKeyTencentCaptchaCloudSecretID: "cloud-secret-id",
SettingKeyTencentCaptchaCloudSecretKey: "cloud-secret-key",
SettingKeyTencentCaptchaRegion: TencentCaptchaRegionINTL,
}}, &config.Config{})
settings, err := svc.GetPublicSettings(context.Background())
require.NoError(t, err)
require.True(t, settings.TencentCaptchaEnabled)
require.Equal(t, "123456789", settings.TencentCaptchaAppID)
// 站点必须原样公开下发:前端据此决定加载哪个站点的 SDK 脚本与构造函数形态。
require.Equal(t, TencentCaptchaRegionINTL, settings.TencentCaptchaRegion)
raw, err := json.Marshal(settings)
require.NoError(t, err)
@@ -72,5 +75,7 @@ func TestSettingService_GetTencentCaptchaConfig(t *testing.T) {
AppSecretKey: "app-secret",
CloudSecretID: "cloud-secret-id",
CloudSecretKey: "cloud-secret-key",
// 未配置站点时回落中国站,保持存量部署行为不变
Region: TencentCaptchaRegionCN,
}, got)
}
+51
View File
@@ -6,7 +6,10 @@ import (
"encoding/hex"
"errors"
"fmt"
"math"
"strings"
"github.com/shopspring/decimal"
)
var ErrUsageBillingRequestIDRequired = errors.New("usage billing request_id is required")
@@ -49,6 +52,54 @@ func (c *UsageBillingCommand) Normalize() {
if strings.TrimSpace(c.RequestFingerprint) == "" {
c.RequestFingerprint = buildUsageBillingFingerprint(c)
}
// 量化必须在指纹计算之后:指纹是请求幂等键,保持由原始金额派生可以避免
// 升级前后同一 request_id 的重试算出不同指纹而被判为 fingerprint conflict。
c.quantizeMonetaryFields()
}
// UsageBillingMonetaryScale 是所有计费金额的规范小数位数,
// 对齐 users.balance / api_keys.quota_used 的 NUMERIC(20,8)。
const UsageBillingMonetaryScale = 8
// quantizeMonetaryFields 把命令中的金额统一量化到 NUMERIC(20,8)。
//
// 不量化时,同一笔 ActualCost 会在两条方向相反的 SQL 上被 PostgreSQL 分别舍入:
//
// balance = balance - $1 // 存剩余额度,舍入的是「减法结果」
// quota_used = quota_used + $1 // 存累计用量,舍入的是「加法结果」
//
// PostgreSQL 对 NUMERIC 采用 half-away-from-zero。当金额在第 9 位出现 half 边界
// (例:10 输入 token × 0.00000125 + 5 输出 token × 0.00001000,再乘分组倍率
// 1.25 = 0.000078125)时:
//
// balance: 10000 - 0.000078125 = 9999.999921875 → 9999.99992188(delta 0.00007812)
// quota_used: 0 + 0.000078125 = 0.000078125 → 0.00007813(delta 0.00007813)
//
// 两个 delta 相差 1e-8,且方向相反——余额少扣、Key 配额多记,随请求量线性累积,
// 使余额、API Key 配额与用量记录无法精确对账(需要 epsilon 比较才能勉强吻合)。
//
// 在参数进入 SQL 之前量化一次,两条语句就都拿到已经落在 8 位刻度上的同一个金额,
// 存储阶段不再发生任何舍入,delta 精确相等。
func (c *UsageBillingCommand) quantizeMonetaryFields() {
c.BalanceCost = QuantizeUsageBillingAmount(c.BalanceCost)
c.SubscriptionCost = QuantizeUsageBillingAmount(c.SubscriptionCost)
c.APIKeyQuotaCost = QuantizeUsageBillingAmount(c.APIKeyQuotaCost)
c.APIKeyRateLimitCost = QuantizeUsageBillingAmount(c.APIKeyRateLimitCost)
c.AccountQuotaCost = QuantizeUsageBillingAmount(c.AccountQuotaCost)
}
// QuantizeUsageBillingAmount 把金额舍入到 UsageBillingMonetaryScale 位小数,
// 采用与 PostgreSQL NUMERIC 一致的 half-away-from-zero 规则。
//
// 走 decimal 而不是 math.Round(v*1e8)/1e8:后者在乘除过程中会引入额外的二进制
// 误差,边界值可能被推到错误的一侧。decimal.NewFromFloat 取 float64 的最短十进制
// 表示,正是 PostgreSQL 把 float8 参数转成 numeric 时所用的表示。
func QuantizeUsageBillingAmount(v float64) float64 {
if v == 0 || math.IsNaN(v) || math.IsInf(v, 0) {
return v
}
quantized, _ := decimal.NewFromFloat(v).Round(UsageBillingMonetaryScale).Float64()
return quantized
}
func buildUsageBillingFingerprint(c *UsageBillingCommand) string {
@@ -0,0 +1,194 @@
package service
import (
"math"
"testing"
"github.com/shopspring/decimal"
"github.com/stretchr/testify/require"
)
// decimalPlaces 返回 float64 最短十进制表示的小数位数,
// 即 PostgreSQL 把 float8 参数转成 numeric 时看到的刻度。
func decimalPlaces(v float64) int32 {
return -decimal.NewFromFloat(v).Exponent()
}
// 复现 #5229:同一笔 ActualCost 分别流向
//
// balance = balance - $1
// quota_used = quota_used + $1
//
// 两列都是 NUMERIC(20,8),PostgreSQL 按 half-away-from-zero 舍入「运算结果」。
// 金额在第 9 位落在 half 边界时,减法与加法会朝相反方向舍入,
// 导致余额 delta 与 Key 配额 delta 相差 1e-8,永远无法精确对账。
//
// 修复后命令里的金额已经落在 8 位刻度上,存储阶段不再舍入。
func TestUsageBillingCommandQuantizesBalanceAndQuotaIdentically(t *testing.T) {
// 10 input × 0.00000125 + 5 output × 0.00001000 = 0.0000625
// 0.0000625 × 1.25(分组倍率) = 0.000078125
const actualCost = 0.000078125
cmd := &UsageBillingCommand{
RequestID: "req-5229",
UserID: 1,
APIKeyID: 2,
AccountID: 3,
BalanceCost: actualCost,
APIKeyQuotaCost: actualCost,
}
cmd.Normalize()
require.Equal(t, cmd.BalanceCost, cmd.APIKeyQuotaCost,
"余额扣减与 API Key 配额累加必须使用同一个规范金额")
require.LessOrEqual(t, decimalPlaces(cmd.BalanceCost), int32(UsageBillingMonetaryScale),
"金额超过 NUMERIC(20,8) 刻度时 PostgreSQL 仍会在存储阶段舍入")
}
// 第 9 位 half 边界的表驱动覆盖。
func TestQuantizeUsageBillingAmountBoundaries(t *testing.T) {
cases := []struct {
name string
in float64
}{
{"below_half", 0.000078120},
{"just_below_half", 0.000078124},
{"exact_half", 0.000078125},
{"just_above_half", 0.000078126},
{"above_half", 0.000078130},
{"long_tail", 0.0000781234567},
{"already_quantized", 0.00007813},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := QuantizeUsageBillingAmount(tc.in)
require.LessOrEqual(t, decimalPlaces(got), int32(UsageBillingMonetaryScale))
want, _ := decimal.NewFromFloat(tc.in).Round(UsageBillingMonetaryScale).Float64()
require.Equal(t, want, got)
// 量化误差不得超过半个刻度(5e-9)。
require.LessOrEqual(t, math.Abs(got-tc.in), 5e-9)
})
}
}
// 累积对账:重复应用同一笔金额,余额侧与配额侧的总量必须精确相等,
// 不允许依赖 epsilon 比较。
func TestQuantizedAmountsReconcileExactlyOverManyApplications(t *testing.T) {
const actualCost = 0.000078125
cmd := &UsageBillingCommand{
RequestID: "req-5229-bulk",
UserID: 1,
APIKeyID: 2,
AccountID: 3,
BalanceCost: actualCost,
SubscriptionCost: 0,
APIKeyQuotaCost: actualCost,
APIKeyRateLimitCost: actualCost,
}
cmd.Normalize()
unit := decimal.NewFromFloat(cmd.BalanceCost)
for _, n := range []int64{1, 10, 100, 1000} {
total := unit.Mul(decimal.NewFromInt(n))
balance := decimal.NewFromInt(10000).Sub(total)
quotaUsed := total
// NUMERIC(20,8) 存储不改变任何一侧的值。
require.True(t, balance.Equal(balance.Round(UsageBillingMonetaryScale)),
"n=%d 余额结果超出 NUMERIC(20,8) 刻度", n)
require.True(t, quotaUsed.Equal(quotaUsed.Round(UsageBillingMonetaryScale)),
"n=%d 配额结果超出 NUMERIC(20,8) 刻度", n)
balanceDelta := decimal.NewFromInt(10000).Sub(balance)
require.True(t, balanceDelta.Equal(quotaUsed),
"n=%d 余额 delta 与配额 delta 不相等: %s vs %s", n, balanceDelta, quotaUsed)
}
}
// 所有金额字段都要量化,避免订阅用量 / 账号配额留在未规范化的刻度上。
func TestNormalizeQuantizesEveryMonetaryField(t *testing.T) {
const raw = 0.0000781234567
cmd := &UsageBillingCommand{
RequestID: "req-5229-fields",
UserID: 1,
APIKeyID: 2,
AccountID: 3,
BalanceCost: raw,
SubscriptionCost: raw,
APIKeyQuotaCost: raw,
APIKeyRateLimitCost: raw,
AccountQuotaCost: raw,
}
cmd.Normalize()
for name, got := range map[string]float64{
"BalanceCost": cmd.BalanceCost,
"SubscriptionCost": cmd.SubscriptionCost,
"APIKeyQuotaCost": cmd.APIKeyQuotaCost,
"APIKeyRateLimitCost": cmd.APIKeyRateLimitCost,
"AccountQuotaCost": cmd.AccountQuotaCost,
} {
require.LessOrEqual(t, decimalPlaces(got), int32(UsageBillingMonetaryScale), name)
}
}
// 指纹是请求幂等键,必须仍由原始金额派生:
// 若量化发生在指纹之前,升级前后同一 request_id 的重试会算出不同指纹,
// 被误判为 fingerprint conflict。
func TestNormalizeKeepsFingerprintDerivedFromRawAmounts(t *testing.T) {
const raw = 0.000078125
newCmd := func() *UsageBillingCommand {
return &UsageBillingCommand{
RequestID: "req-5229-fp",
UserID: 1,
APIKeyID: 2,
AccountID: 3,
BalanceCost: raw,
APIKeyQuotaCost: raw,
}
}
cmd := newCmd()
expected := buildUsageBillingFingerprint(newCmd())
cmd.Normalize()
require.Equal(t, expected, cmd.RequestFingerprint)
}
// 显式设置的指纹不被覆盖,且金额仍会被量化。
func TestNormalizePreservesExplicitFingerprint(t *testing.T) {
cmd := &UsageBillingCommand{
RequestID: "req-5229-explicit",
RequestFingerprint: "preset-fingerprint",
BalanceCost: 0.0000781234567,
}
cmd.Normalize()
require.Equal(t, "preset-fingerprint", cmd.RequestFingerprint)
require.LessOrEqual(t, decimalPlaces(cmd.BalanceCost), int32(UsageBillingMonetaryScale))
}
func TestQuantizeUsageBillingAmountPassesThroughNonFinite(t *testing.T) {
require.Equal(t, 0.0, QuantizeUsageBillingAmount(0))
require.True(t, math.IsNaN(QuantizeUsageBillingAmount(math.NaN())))
require.True(t, math.IsInf(QuantizeUsageBillingAmount(math.Inf(1)), 1))
require.True(t, math.IsInf(QuantizeUsageBillingAmount(math.Inf(-1)), -1))
}
// 退款/负向金额同样按 half-away-from-zero 对称处理。
func TestQuantizeUsageBillingAmountHandlesNegativeAmounts(t *testing.T) {
got := QuantizeUsageBillingAmount(-0.000078125)
want, _ := decimal.NewFromFloat(-0.000078125).Round(UsageBillingMonetaryScale).Float64()
require.Equal(t, want, got)
require.Equal(t, -QuantizeUsageBillingAmount(0.000078125), got,
"正负金额必须对称量化")
}
+1 -1
View File
@@ -181,7 +181,7 @@ security:
# 默认 CSP 策略(如果静态资源托管在其他域名,请自行覆盖)
# Note: __CSP_NONCE__ will be replaced with 'nonce-xxx' at request time for inline script security
# 注意:__CSP_NONCE__ 会在请求时被替换为 'nonce-xxx',用于内联脚本安全
policy: "default-src 'self'; script-src 'self' __CSP_NONCE__ https://challenges.cloudflare.com https://*.alicdn.com https://static.cloudflareinsights.com https://turing.captcha.qcloud.com https://*.stripe.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; style-src 'self' 'unsafe-inline' https://*.captcha.gtimg.com https://fonts.googleapis.com https://*.alicdn.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https:; frame-src https://challenges.cloudflare.com https://turing.captcha.qcloud.com https://*.stripe.com https://checkout.airwallex.com https://checkout-demo.airwallex.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
policy: "default-src 'self'; worker-src 'self' blob:; script-src 'self' __CSP_NONCE__ https://challenges.cloudflare.com https://*.alicdn.com https://static.cloudflareinsights.com https://turing.captcha.qcloud.com https://turing.captcha.gtimg.com https://ca.turing.captcha.qcloud.com https://global.turing.captcha.gtimg.com https://www.tycaptcha.com https://cloudcache.tencentcs.com https://*.stripe.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; style-src 'self' 'unsafe-inline' https://*.captcha.gtimg.com https://fonts.googleapis.com https://*.alicdn.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://turing.captcha.qcloud.com https://www.tycaptcha.com https://rce.tencentrio.com https:; frame-src https://challenges.cloudflare.com https://turing.captcha.qcloud.com https://ca.turing.captcha.qcloud.com https://www.tycaptcha.com https://*.stripe.com https://checkout.airwallex.com https://checkout-demo.airwallex.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
proxy_probe:
# Allow skipping TLS verification for proxy probe (debug only)
# 允许代理探测时跳过 TLS 证书验证(仅用于调试)
+2
View File
@@ -464,6 +464,7 @@ export interface SystemSettings {
tencent_captcha_app_secret_key_configured: boolean;
tencent_captcha_cloud_secret_id_configured: boolean;
tencent_captcha_cloud_secret_key_configured: boolean;
tencent_captcha_region: string;
aliyun_captcha_enabled: boolean;
aliyun_captcha_access_key_id: string;
aliyun_captcha_access_key_secret_configured: boolean;
@@ -789,6 +790,7 @@ export interface UpdateSettingsRequest {
tencent_captcha_app_secret_key?: string;
tencent_captcha_cloud_secret_id?: string;
tencent_captcha_cloud_secret_key?: string;
tencent_captcha_region?: string;
aliyun_captcha_enabled?: boolean;
aliyun_captcha_access_key_id?: string;
aliyun_captcha_access_key_secret?: string;
@@ -11,6 +11,7 @@
v-else-if="tencentEnabled && tencentAppId"
ref="tencentRef"
:app-id="tencentAppId"
:region="tencentRegion"
/>
<AliyunCaptchaWidget
v-else-if="aliyunEnabled && aliyunSceneId && aliyunPrefix"
@@ -43,6 +44,7 @@ const props = defineProps<{
turnstileSiteKey: string
tencentEnabled: boolean
tencentAppId: string
tencentRegion?: string
aliyunEnabled?: boolean
aliyunSceneId?: string
aliyunPrefix?: string
+142 -18
View File
@@ -1,44 +1,74 @@
<template>
<span v-if="false" />
<div
v-if="isInternational"
ref="internationalContainerRef"
data-testid="tencent-captcha-international-container"
:class="
internationalContainerVisible
? 'flex min-h-[60px] w-full justify-center'
: 'pointer-events-none fixed left-1/2 top-0 flex h-[60px] w-[302px] -translate-x-1/2 scale-[0.01] opacity-0'
"
/>
</template>
<script setup lang="ts">
import { onBeforeUnmount } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import {
loadTencentCaptcha,
normalizeTencentCaptchaRegion,
type TencentCaptchaProof,
type TencentCaptchaResult
} from '@/utils/tencentCaptcha'
const { locale } = useI18n()
const props = defineProps<{ appId: string }>()
const props = withDefaults(defineProps<{ appId: string; region?: string }>(), { region: 'cn' })
const isInternational = computed(
() => normalizeTencentCaptchaRegion(props.region) === 'intl'
)
const internationalContainerRef = ref<HTMLDivElement | null>(null)
const internationalContainerVisible = ref<boolean>(isInternational.value)
let instance: { show(): void; destroy(): void } | null = null
let pending: Promise<TencentCaptchaProof | null> | null = null
let cancelPending: (() => void) | null = null
let cachedProof: TencentCaptchaProof | null = null
let cachedProofCreatedAt = 0
let isMounted = false
function createVerificationPromise(): Promise<TencentCaptchaProof | null> {
// 腾讯国际站票据官方有效期为 5 分钟,提前 1 分钟放弃缓存,避免提交时刚好过期。
const cachedProofMaxAgeMs = 4 * 60 * 1000
function createVerificationPromise(revealInternational: boolean = true): Promise<TencentCaptchaProof | null> {
return new Promise((resolve, reject) => {
let settled = false
const finish = (callback: () => void): void => {
const finish = (callback: () => void, keepInternationalContainer: boolean = false): void => {
if (settled) return
settled = true
if (cancelPending === cancel) cancelPending = null
instance?.destroy()
instance = null
if (!keepInternationalContainer) {
instance?.destroy()
instance = null
internationalContainerVisible.value = false
}
callback()
}
const cancel = (): void => finish(() => resolve(null))
cancelPending = cancel
void loadTencentCaptcha()
const region = normalizeTencentCaptchaRegion(props.region)
if (region === 'intl' && revealInternational) {
internationalContainerVisible.value = true
}
void nextTick()
.then(() => loadTencentCaptcha(region))
.then((TencentCaptcha) => {
if (cancelPending !== cancel) return
const userLanguage = locale.value.toLowerCase().startsWith('zh') ? 'zh-cn' : 'en'
instance = new TencentCaptcha(props.appId, (result: TencentCaptchaResult) => {
const handleResult = (result: TencentCaptchaResult): void => {
if (result.ret === 2) {
finish(() => resolve(null))
return
@@ -51,8 +81,27 @@ function createVerificationPromise(): Promise<TencentCaptchaProof | null> {
return
}
finish(() => resolve({ ticket, randstr }))
}, { userLanguage })
// 国际站保留已勾选的控件,避免成功回调后页面出现跳动;登录流程结束时由 reset 清理。
finish(() => resolve({ ticket, randstr }), region === 'intl')
}
if (region === 'intl') {
// 新版国际站先在指定容器内展示 Robot checkbox,用户点击后才弹出挑战。
// document.body 会把 checkbox 追加到整个页面末尾,在登录页上通常落到视口外,
// 表现为 SDK 已成功初始化但页面没有任何可见组件。
const container = internationalContainerRef.value
if (!container) {
throw new Error('Tencent Captcha international container is unavailable')
}
instance = new TencentCaptcha(container, props.appId, handleResult, {
// 国际站与国内站保持一致:页面加载后显示 Robot checkbox,由用户主动确认。
enableAutoCheck: false,
userLanguage,
type: 'popup'
})
} else {
instance = new TencentCaptcha(props.appId, handleResult, { userLanguage })
}
instance.show()
})
.catch((error: unknown) => finish(() => reject(error)))
@@ -60,20 +109,95 @@ function createVerificationPromise(): Promise<TencentCaptchaProof | null> {
}
function verify(): Promise<TencentCaptchaProof | null> {
if (pending) return pending
pending = createVerificationPromise().finally(() => {
pending = null
})
return pending
if (cachedProof) {
if (Date.now() - cachedProofCreatedAt >= cachedProofMaxAgeMs) {
// 过期票据不能再次提交;先同步销毁旧实例,再在本次调用中创建新验证。
reset(false)
} else {
const proof = cachedProof
cachedProof = null
cachedProofCreatedAt = 0
// 预加载 promise 已经完成,消费缓存时同步清除引用,避免同一票据被并发复用。
pending = null
return Promise.resolve(proof)
}
}
if (pending) {
const verification = pending
internationalContainerVisible.value = true
// 预加载阶段可能因容器不可见而被 SDK 延迟绘制,提交时在容器显示后重新触发同一个实例。
void nextTick().then(() => {
if (pending === verification) instance?.show()
})
return verification.then((proof) => {
if (cachedProof === proof) {
cachedProof = null
cachedProofCreatedAt = 0
}
return proof
})
}
internationalContainerVisible.value = true
const verification = createVerificationPromise(true)
pending = verification
void verification.then(
() => {
if (pending === verification) pending = null
},
() => {
if (pending === verification) pending = null
}
)
return verification
}
function reset(): void {
function preload(): void {
if (!isInternational.value || pending || cachedProof) return
// 国际站要求首屏直接展示 checkbox,避免用户点击登录后才看到验证码。
const verification = createVerificationPromise(true)
pending = verification
void verification
.then((proof) => {
if (proof) {
cachedProof = proof
cachedProofCreatedAt = Date.now()
}
})
.catch(() => undefined)
.finally(() => {
if (pending === verification) pending = null
})
}
function reset(reinitialize: boolean = true): void {
instance?.destroy()
instance = null
cancelPending?.()
cancelPending = null
pending = null
cachedProof = null
cachedProofCreatedAt = 0
internationalContainerVisible.value = isInternational.value
// 国际站的票据一次性使用,登录失败后需要立即创建新的 checkbox,
// 不能等下一次点击登录才重新初始化。卸载阶段不再启动预加载。
if (reinitialize && isMounted && isInternational.value) {
void nextTick().then(() => {
if (isMounted) preload()
})
}
}
onBeforeUnmount(reset)
onMounted(() => {
isMounted = true
preload()
})
onBeforeUnmount(() => {
isMounted = false
reset()
})
defineExpose({ verify, reset })
</script>
@@ -1,7 +1,7 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import TencentCaptchaGate from '@/components/TencentCaptchaGate.vue'
import { resetTencentCaptchaLoaderForTest } from '@/utils/tencentCaptcha'
import { loadTencentCaptcha, resetTencentCaptchaLoaderForTest } from '@/utils/tencentCaptcha'
const locale = { value: 'zh' }
@@ -20,7 +20,10 @@ describe('TencentCaptchaGate', () => {
beforeEach(() => {
locale.value = 'zh'
delete window.TencentCaptcha
document.head.querySelectorAll('script[src*="TJCaptcha.js"]').forEach((node) => node.remove())
delete window.TCaptchaGlobal
document.head
.querySelectorAll('script[src*="TJCaptcha.js"], script[src*="TJNCaptcha-global.js"]')
.forEach((node) => node.remove())
resetTencentCaptchaLoaderForTest()
})
@@ -69,6 +72,7 @@ describe('TencentCaptchaGate', () => {
it('rejects SDK load failures and disaster-recovery tickets', async () => {
const failedLoad = mount(TencentCaptchaGate, { props: { appId: '123456789' } })
const loadVerification = failedLoad.vm.verify()
await flushPromises()
const script = document.head.querySelector<HTMLScriptElement>('script[src*="TJCaptcha.js"]')
expect(script).not.toBeNull()
script?.dispatchEvent(new Event('error'))
@@ -112,6 +116,195 @@ describe('TencentCaptchaGate', () => {
expect(show).toHaveBeenCalledOnce()
})
// 国际站新版 SDK 会先把 Robot checkbox 渲染到首参容器,点击后才弹出挑战。
// 容器必须位于当前表单中;传 document.body 会让 checkbox 落在页面末尾并超出视口。
it('passes a visible in-form container first for the international site', async () => {
const args: unknown[][] = []
let callback: ((result: CaptchaResult) => void) | undefined
window.TCaptchaGlobal = true
window.TencentCaptcha = class {
constructor(...received: unknown[]) {
args.push(received)
callback = received[2] as (result: CaptchaResult) => void
}
show = vi.fn()
destroy = vi.fn()
} as unknown as typeof window.TencentCaptcha
const wrapper = mount(TencentCaptchaGate, {
props: { appId: '123456789', region: 'intl' }
})
const verification = wrapper.vm.verify()
await flushPromises()
const container = wrapper.get('[data-testid="tencent-captcha-international-container"]')
expect(args).toHaveLength(1)
expect(args[0][0]).toBe(container.element)
expect(args[0][0]).not.toBe(document.body)
await vi.waitFor(() => expect(container.classes()).not.toContain('scale-[0.01]'))
expect(args[0][1]).toBe('123456789')
expect(typeof args[0][2]).toBe('function')
expect(args[0][3]).toMatchObject({ enableAutoCheck: false, type: 'popup' })
callback?.({ ret: 0, ticket: 'ticket-value', randstr: 'rand-value' })
await expect(verification).resolves.toEqual({
ticket: 'ticket-value',
randstr: 'rand-value'
})
expect(container.classes()).not.toContain('scale-[0.01]')
})
it('preloads and displays the international checkbox on mount', async () => {
window.TCaptchaGlobal = true
window.TencentCaptcha = class {
constructor() {}
show = vi.fn()
destroy = vi.fn()
} as unknown as typeof window.TencentCaptcha
const wrapper = mount(TencentCaptchaGate, {
props: { appId: '123456789', region: 'intl' }
})
const container = wrapper.get('[data-testid="tencent-captcha-international-container"]')
await flushPromises()
await vi.waitFor(() => expect(container.classes()).not.toContain('scale-[0.01]'))
})
it('reuses a preloaded proof when the SDK reports success', async () => {
let callback: ((result: CaptchaResult) => void) | undefined
window.TCaptchaGlobal = true
window.TencentCaptcha = class {
constructor(...received: unknown[]) {
callback = received[2] as (result: CaptchaResult) => void
}
show = vi.fn()
destroy = vi.fn()
} as unknown as typeof window.TencentCaptcha
const wrapper = mount(TencentCaptchaGate, {
props: { appId: '123456789', region: 'intl' }
})
await flushPromises()
callback?.({ ret: 0, ticket: 'preloaded-ticket', randstr: 'preloaded-rand' })
await flushPromises()
await expect(wrapper.vm.verify()).resolves.toEqual({
ticket: 'preloaded-ticket',
randstr: 'preloaded-rand'
})
expect(wrapper.get('[data-testid="tencent-captcha-international-container"]').classes()).not.toContain(
'scale-[0.01]'
)
})
it('refreshes a preloaded proof before the provider ticket expires', async () => {
vi.useFakeTimers()
try {
let callback: ((result: CaptchaResult) => void) | undefined
let constructorCount = 0
window.TCaptchaGlobal = true
window.TencentCaptcha = class {
constructor(...received: unknown[]) {
constructorCount += 1
callback = received[2] as (result: CaptchaResult) => void
}
show = vi.fn()
destroy = vi.fn()
} as unknown as typeof window.TencentCaptcha
const wrapper = mount(TencentCaptchaGate, {
props: { appId: '123456789', region: 'intl' }
})
await flushPromises()
callback?.({ ret: 0, ticket: 'expired-ticket', randstr: 'expired-rand' })
await flushPromises()
vi.advanceTimersByTime(4 * 60 * 1000)
const verification = wrapper.vm.verify()
await flushPromises()
expect(constructorCount).toBe(2)
callback?.({ ret: 0, ticket: 'fresh-ticket', randstr: 'fresh-rand' })
await expect(verification).resolves.toEqual({
ticket: 'fresh-ticket',
randstr: 'fresh-rand'
})
} finally {
vi.useRealTimers()
}
})
it('keeps the three-argument form for the Chinese mainland site', async () => {
const args: unknown[][] = []
window.TencentCaptcha = class {
constructor(...received: unknown[]) {
args.push(received)
}
show = vi.fn()
destroy = vi.fn()
} as unknown as typeof window.TencentCaptcha
const wrapper = mount(TencentCaptchaGate, { props: { appId: '123456789' } })
void wrapper.vm.verify()
await flushPromises()
expect(args[0][0]).toBe('123456789')
expect(typeof args[0][1]).toBe('function')
})
it('loads the international SDK script for the international site', async () => {
const wrapper = mount(TencentCaptchaGate, {
props: { appId: '123456789', region: 'intl' }
})
void wrapper.vm.verify()
await flushPromises()
const script = document.head.querySelector<HTMLScriptElement>(
'script[src*="TJNCaptcha-global.js"]'
)
expect(script?.src).toBe('https://ca.turing.captcha.qcloud.com/TJNCaptcha-global.js')
})
// 页面上已有的全局构造函数可能来自另一个站点(例如开发期 HMR 重置了模块状态)。
// 两个站点签名不兼容,错配会抛错,因此站点不一致时必须重新加载对应脚本而不是复用。
it('does not reuse a mainland global for the international site', async () => {
window.TencentCaptcha = class {
constructor() {}
show = vi.fn()
destroy = vi.fn()
} as unknown as typeof window.TencentCaptcha
const wrapper = mount(TencentCaptchaGate, {
props: { appId: '123456789', region: 'intl' }
})
void wrapper.vm.verify()
await flushPromises()
expect(
document.head.querySelector('script[src*="TJNCaptcha-global.js"]')
).not.toBeNull()
})
it('does not inject a second SDK when the region changes in one page', async () => {
const constructor = class {
constructor() {}
show = vi.fn()
destroy = vi.fn()
} as unknown as typeof window.TencentCaptcha
const firstLoad = loadTencentCaptcha('cn')
const firstScript = document.head.querySelector<HTMLScriptElement>('script[src*="TJCaptcha.js"]')
expect(firstScript).not.toBeNull()
window.TencentCaptcha = constructor
firstScript?.dispatchEvent(new Event('load'))
await expect(firstLoad).resolves.toBe(constructor)
await expect(loadTencentCaptcha('intl')).rejects.toThrow(
'Tencent Captcha region changed; reload the page to apply it'
)
expect(document.head.querySelector('script[src*="TJNCaptcha-global.js"]')).toBeNull()
})
it('settles a pending verification when reset', async () => {
const destroy = vi.fn()
window.TencentCaptcha = class {
@@ -128,4 +321,31 @@ describe('TencentCaptchaGate', () => {
await expect(verification).resolves.toBeNull()
expect(destroy).toHaveBeenCalledOnce()
})
it('reinitializes the international checkbox after reset', async () => {
const destroy = vi.fn()
let constructorCount = 0
window.TCaptchaGlobal = true
window.TencentCaptcha = class {
constructor() {
constructorCount += 1
}
show = vi.fn()
destroy = destroy
} as unknown as typeof window.TencentCaptcha
const wrapper = mount(TencentCaptchaGate, {
props: { appId: '123456789', region: 'intl' }
})
await flushPromises()
expect(constructorCount).toBe(1)
wrapper.vm.reset()
await flushPromises()
expect(destroy).toHaveBeenCalledOnce()
expect(constructorCount).toBe(2)
expect(
wrapper.get('[data-testid="tencent-captcha-international-container"]').classes()
).not.toContain('scale-[0.01]')
})
})
@@ -24,6 +24,7 @@
:turnstile-site-key="turnstileSiteKey"
:tencent-enabled="tencentCaptchaEnabled"
:tencent-app-id="tencentCaptchaAppId"
:tencent-region="tencentCaptchaRegion"
:aliyun-enabled="aliyunCaptchaEnabled"
:aliyun-scene-id="aliyunCaptchaSceneId"
:aliyun-prefix="aliyunCaptchaPrefix"
@@ -141,6 +142,7 @@ const turnstileEnabled = ref(false)
const turnstileSiteKey = ref('')
const tencentCaptchaEnabled = ref(false)
const tencentCaptchaAppId = ref('')
const tencentCaptchaRegion = ref('cn')
const aliyunCaptchaEnabled = ref(false)
const aliyunCaptchaSceneId = ref('')
const aliyunCaptchaPrefix = ref('')
@@ -350,6 +352,7 @@ onMounted(async () => {
turnstileSiteKey.value = settings.turnstile_site_key || ''
tencentCaptchaEnabled.value = settings.tencent_captcha_enabled === true
tencentCaptchaAppId.value = settings.tencent_captcha_app_id || ''
tencentCaptchaRegion.value = settings.tencent_captcha_region || 'cn'
aliyunCaptchaEnabled.value = settings.aliyun_captcha_enabled === true
aliyunCaptchaSceneId.value = settings.aliyun_captcha_scene_id || ''
aliyunCaptchaPrefix.value = settings.aliyun_captcha_prefix || ''
@@ -361,6 +364,7 @@ onMounted(async () => {
turnstileSiteKey.value = ''
tencentCaptchaEnabled.value = false
tencentCaptchaAppId.value = ''
tencentCaptchaRegion.value = 'cn'
aliyunCaptchaEnabled.value = false
aliyunCaptchaSceneId.value = ''
aliyunCaptchaPrefix.value = ''
@@ -202,6 +202,10 @@ export default {
configured: 'Configured. Leave empty to keep it.',
required: 'Required before enabling.',
mutualExclusion: 'Tencent Captcha, Cloudflare Turnstile and Aliyun Captcha are mutually exclusive. Enabling one disables the others.',
region: 'Service site',
regionCn: 'Chinese mainland',
regionIntl: 'International',
regionHint: 'Selects the SDK script and the server-side verification endpoint. It must match the site that issued your CaptchaAppId; international apps are created in the tencentcloud.com console.',
appCredentialsTitle: 'Captcha application credentials',
appCredentialsHint: 'Get CaptchaAppId and AppSecretKey from Verification Management in the Captcha console.',
cloudCredentialsTitle: 'Cloud API credentials',
@@ -471,8 +475,8 @@ export default {
antigravityUserAgentVersionPlaceholder: '1.23.2',
antigravityUserAgentVersionHint: 'Leave empty to use ANTIGRAVITY_USER_AGENT_VERSION or the built-in default 1.23.2; when set, the admin setting takes precedence.',
openaiCodexUserAgent: 'OpenAI Codex UA',
openaiCodexUserAgentPlaceholder: 'codex_cli_rs/0.146.0 (Ubuntu 22.4.0; x86_64) xterm-256color',
openaiCodexUserAgentHint: 'The full Codex User-Agent used for all outbound requests, for customizing the OS / arch / terminal fingerprint. Leave empty to build the standard codex_cli_rs identity from the version below (recommended). If set, its version segment is still replaced by the version below, so the UA never stays pinned to whatever release you typed it on — under capacity pressure the upstream sheds load by client identity and drops stale or non-official identities first with server_is_overloaded.',
openaiCodexUserAgentPlaceholder: 'codex-tui/0.146.1 (Ubuntu 22.4.0; x86_64) WindowsTerminal (codex-tui; 0.146.1)',
openaiCodexUserAgentHint: 'The full Codex User-Agent used for all outbound requests, for customizing the OS / arch / terminal fingerprint. Leave empty to build the standard codex-tui identity from the version below (recommended). If set, both the leading and trailing version declarations are synchronized to the version below, so the UA never stays pinned to the release entered here — under capacity pressure the upstream sheds load by client identity and drops stale or non-official identities first with server_is_overloaded.',
openaiCodexClientVersion: 'Codex client version',
openaiCodexClientVersionPlaceholder: 'Leave empty to follow auto-sync',
openaiCodexClientVersionHint: 'The Codex client version this gateway declares upstream, shared by the User-Agent and the version header. Leave empty to use the auto-synced latest stable release; setting a value pins it and stops following auto-sync.',
@@ -202,6 +202,10 @@ export default {
configured: '已配置,留空不会覆盖。',
required: '启用前必须填写此项。',
mutualExclusion: '腾讯天御验证码与 Cloudflare Turnstile、阿里云验证码互斥,开启其中一个会自动关闭其它。',
region: '服务站点',
regionCn: '中国站',
regionIntl: '国际站',
regionHint: '决定前端加载的 SDK 脚本与服务端校验接入点,需与 CaptchaAppId 所属站点一致;国际站请在 tencentcloud.com 控制台创建验证',
appCredentialsTitle: '验证码应用密钥',
appCredentialsHint: 'CaptchaAppId 与 AppSecretKey 来自验证码控制台的验证管理页面。',
cloudCredentialsTitle: '云 API 调用密钥',
@@ -464,8 +468,8 @@ export default {
antigravityUserAgentVersionPlaceholder: '1.23.2',
antigravityUserAgentVersionHint: '留空时使用 ANTIGRAVITY_USER_AGENT_VERSION 或内置默认值 1.23.2;填写后后台设置优先。',
openaiCodexUserAgent: 'OpenAI Codex UA',
openaiCodexUserAgentPlaceholder: 'codex_cli_rs/0.146.0 (Ubuntu 22.4.0; x86_64) xterm-256color',
openaiCodexUserAgentHint: '出站统一使用的完整 Codex User-Agent,用于自定义 OS / 架构 / 终端指纹。留空则按下方版本号拼出标准 codex_cli_rs 形态(推荐)。填写后其版本段仍会被下方版本号覆盖,避免这条 UA 停在填写时的旧版本——上游在容量紧张时按客户端身份分优先级降载,陈旧或非官方形态的身份会被优先丢弃并回 server_is_overloaded。',
openaiCodexUserAgentPlaceholder: 'codex-tui/0.146.1 (Ubuntu 22.4.0; x86_64) WindowsTerminal (codex-tui; 0.146.1)',
openaiCodexUserAgentHint: '出站统一使用的完整 Codex User-Agent,用于自定义 OS / 架构 / 终端指纹。留空则按下方版本号拼出标准 codex-tui 形态(推荐)。填写后首段和尾部的版本号仍会被下方版本号同步覆盖,避免这条 UA 停在填写时的旧版本——上游在容量紧张时按客户端身份分优先级降载,陈旧或非官方形态的身份会被优先丢弃并回 server_is_overloaded。',
openaiCodexClientVersion: 'Codex 客户端版本号',
openaiCodexClientVersionPlaceholder: '留空则跟随自动同步',
openaiCodexClientVersionHint: '网关对上游声明的 Codex 客户端版本号,User-Agent 与 version 头同源使用。留空表示使用自动同步到的官方最新稳定版;填写后固定为该版本,不再跟随同步。',
+1
View File
@@ -220,6 +220,7 @@ export interface PublicSettings {
turnstile_enabled: boolean
tencent_captcha_enabled?: boolean
tencent_captcha_app_id?: string
tencent_captcha_region?: string
passkey_enabled?: boolean
turnstile_site_key: string
aliyun_captcha_enabled?: boolean
+69 -11
View File
@@ -1,3 +1,7 @@
// 腾讯天御验证码站点:cn=中国站(cloud.tencent.com),intl=国际站(tencentcloud.com)。
// 两个站点的 CaptchaAppId 不互通,SDK 脚本与服务端校验接入点都必须成对使用。
export type TencentCaptchaRegion = 'cn' | 'intl'
export interface TencentCaptchaProof {
ticket: string
randstr: string
@@ -16,39 +20,92 @@ interface TencentCaptchaInstance {
destroy(): void
}
type TencentCaptchaConstructor = new (
appId: string,
callback: (result: TencentCaptchaResult) => void,
options?: Record<string, unknown>
) => TencentCaptchaInstance
type TencentCaptchaCallback = (result: TencentCaptchaResult) => void
// 两个站点的构造函数签名不同,且不可互换:
// - 中国站 TJCaptcha.js:第一个参数是 CaptchaAppId 字符串(传 DOM 走另一条分支)。
// - 国际站 TJNCaptcha-global.js:第一个参数必须是承载 Robot checkbox 的 DOM 容器,传字符串会直接抛
// "The parameter of the constructor of the Captcha is passed incorrectly"。
type TencentCaptchaConstructor = {
new (
appId: string,
callback: TencentCaptchaCallback,
options?: Record<string, unknown>
): TencentCaptchaInstance
new (
element: HTMLElement,
appId: string,
callback: TencentCaptchaCallback,
options?: Record<string, unknown>
): TencentCaptchaInstance
}
declare global {
interface Window {
TencentCaptcha?: TencentCaptchaConstructor
// 国际站入口脚本会置为 true;国内站脚本只读取、从不写入。
// 用于判定页面上已存在的 TencentCaptcha 属于哪个站点。
TCaptchaGlobal?: boolean
}
}
const SCRIPT_SRC = 'https://turing.captcha.qcloud.com/TJCaptcha.js'
const SCRIPT_SRC: Record<TencentCaptchaRegion, string> = {
cn: 'https://turing.captcha.qcloud.com/TJCaptcha.js',
intl: 'https://ca.turing.captcha.qcloud.com/TJNCaptcha-global.js'
}
export function normalizeTencentCaptchaRegion(value?: string | null): TencentCaptchaRegion {
return value === 'intl' ? 'intl' : 'cn'
}
let scriptPromise: Promise<TencentCaptchaConstructor> | null = null
let loadedRegion: TencentCaptchaRegion | null = null
export function loadTencentCaptcha(): Promise<TencentCaptchaConstructor> {
if (window.TencentCaptcha) return Promise.resolve(window.TencentCaptcha)
if (scriptPromise) return scriptPromise
// existingGlobalRegion 推断页面上已存在的全局构造函数属于哪个站点。
// 两个站点的构造函数签名不兼容(国际站首参必须是 DOM 元素),错配会直接抛错,
// 因此复用已存在的全局前必须先确认站点一致。
function existingGlobalRegion(): TencentCaptchaRegion {
return window.TCaptchaGlobal === true ? 'intl' : 'cn'
}
export function loadTencentCaptcha(
region: TencentCaptchaRegion = 'cn'
): Promise<TencentCaptchaConstructor> {
// 两个站点的 SDK 注册同名全局 TencentCaptcha,缓存必须按站点隔离,
// 否则管理员切换站点后仍会复用上一个站点的构造函数。
// loadedRegion 为 null 表示全局不是本模块注入的(如开发期 HMR 后模块状态被重置),
// 此时靠 TCaptchaGlobal 判定站点,一致才复用。
const globalRegion = window.TencentCaptcha ? existingGlobalRegion() : null
if (window.TencentCaptcha && (loadedRegion === region || globalRegion === region)) {
return Promise.resolve(window.TencentCaptcha)
}
if (window.TencentCaptcha && loadedRegion !== null && loadedRegion !== region) {
// 两个站点共享 TencentCaptcha 全局且构造签名不兼容,不能在同一页面注入第二份 SDK。
// 管理员切换区域后由部署流程刷新页面,刷新前直接失败可避免全局构造函数被污染。
return Promise.reject(new Error('Tencent Captcha region changed; reload the page to apply it'))
}
if (scriptPromise && loadedRegion === region) return scriptPromise
loadedRegion = region
scriptPromise = new Promise((resolve, reject) => {
const script = document.createElement('script')
script.src = SCRIPT_SRC
script.src = SCRIPT_SRC[region]
script.async = true
script.onload = () => {
if (window.TencentCaptcha) {
// 入口脚本被重复引入时腾讯会在求值阶段抛错("请勿多次引用腾讯验证码的接入js"),
// 此时 onload 仍会触发而全局仍是上一个站点的构造函数。校验站点,避免把
// 签名不兼容的构造函数当成本站点的返回出去。
if (window.TencentCaptcha && existingGlobalRegion() === region) {
resolve(window.TencentCaptcha)
return
}
scriptPromise = null
loadedRegion = null
reject(new Error('Tencent Captcha SDK is unavailable'))
}
script.onerror = () => {
scriptPromise = null
loadedRegion = null
reject(new Error('Failed to load Tencent Captcha SDK'))
}
document.head.appendChild(script)
@@ -59,4 +116,5 @@ export function loadTencentCaptcha(): Promise<TencentCaptchaConstructor> {
export function resetTencentCaptchaLoaderForTest(): void {
scriptPromise = null
loadedRegion = null
}
+57 -3
View File
@@ -2108,6 +2108,42 @@
<!-- Tencent Captcha fields -->
<div v-else-if="captchaProviderSelection === 'tencent'">
<div class="mb-6 max-w-sm">
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t("admin.settings.tencentCaptcha.region") }}
</label>
<div class="grid grid-cols-2 gap-2 rounded-lg bg-gray-100 p-1 dark:bg-dark-700">
<button
type="button"
data-testid="tencent-captcha-region-cn"
class="inline-flex items-center justify-center rounded-md px-3 py-1.5 text-sm font-medium transition"
:class="
form.tencent_captcha_region !== 'intl'
? 'bg-white text-primary-700 shadow-sm dark:bg-dark-800 dark:text-primary-300'
: 'text-gray-600 hover:text-gray-900 dark:text-dark-300 dark:hover:text-white'
"
@click="form.tencent_captcha_region = 'cn'"
>
{{ t("admin.settings.tencentCaptcha.regionCn") }}
</button>
<button
type="button"
data-testid="tencent-captcha-region-intl"
class="inline-flex items-center justify-center rounded-md px-3 py-1.5 text-sm font-medium transition"
:class="
form.tencent_captcha_region === 'intl'
? 'bg-white text-primary-700 shadow-sm dark:bg-dark-800 dark:text-primary-300'
: 'text-gray-600 hover:text-gray-900 dark:text-dark-300 dark:hover:text-white'
"
@click="form.tencent_captcha_region = 'intl'"
>
{{ t("admin.settings.tencentCaptcha.regionIntl") }}
</button>
</div>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.settings.tencentCaptcha.regionHint") }}
</p>
</div>
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<div class="md:col-span-2">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
@@ -2191,7 +2227,7 @@
</p>
<div class="mt-3 flex flex-wrap gap-x-4 gap-y-2 text-sm">
<a
href="https://console.cloud.tencent.com/captcha"
:href="tencentCaptchaLinks.console"
target="_blank"
rel="noopener noreferrer"
class="text-primary-600 hover:text-primary-500"
@@ -2199,7 +2235,7 @@
{{ t("admin.settings.tencentCaptcha.openCaptchaConsole") }}
</a>
<a
href="https://console.cloud.tencent.com/cam/capi"
:href="tencentCaptchaLinks.cloudKeys"
target="_blank"
rel="noopener noreferrer"
class="text-primary-600 hover:text-primary-500"
@@ -2207,7 +2243,7 @@
{{ t("admin.settings.tencentCaptcha.createCloudKeys") }}
</a>
<a
href="https://cloud.tencent.com/document/product/1110/36841"
:href="tencentCaptchaLinks.webDocs"
target="_blank"
rel="noopener noreferrer"
class="text-primary-600 hover:text-primary-500"
@@ -9264,6 +9300,7 @@ const form = reactive<SettingsForm>({
tencent_captcha_cloud_secret_id_configured: false,
tencent_captcha_cloud_secret_key: "",
tencent_captcha_cloud_secret_key_configured: false,
tencent_captcha_region: "cn",
aliyun_captcha_enabled: false,
aliyun_captcha_access_key_id: "",
aliyun_captcha_access_key_secret: "",
@@ -9460,6 +9497,22 @@ function selectCaptchaProvider(provider: CaptchaProviderSelection): void {
applyCaptchaSelection(provider);
}
// 天御中国站与国际站是两套独立账号体系,控制台与文档入口不通用,
// 按当前选择的站点给出对应链接,避免管理员在错误的控制台里找不到 CaptchaAppId。
const tencentCaptchaLinks = computed(() =>
form.tencent_captcha_region === "intl"
? {
console: "https://console.tencentcloud.com/captcha/graphical",
cloudKeys: "https://console.tencentcloud.com/cam/capi",
webDocs: "https://www.tencentcloud.com/document/product/1159/49680",
}
: {
console: "https://console.cloud.tencent.com/captcha",
cloudKeys: "https://console.cloud.tencent.com/cam/capi",
webDocs: "https://cloud.tencent.com/document/product/1110/36841",
},
);
function syncCaptchaProviderSelection(): void {
if (form.tencent_captcha_enabled) {
captchaProviderSelection.value = "tencent";
@@ -10817,6 +10870,7 @@ async function saveSettings() {
form.tencent_captcha_cloud_secret_id || undefined,
tencent_captcha_cloud_secret_key:
form.tencent_captcha_cloud_secret_key || undefined,
tencent_captcha_region: form.tencent_captcha_region,
aliyun_captcha_enabled: form.aliyun_captcha_enabled,
aliyun_captcha_access_key_id: form.aliyun_captcha_access_key_id,
aliyun_captcha_access_key_secret:
@@ -813,6 +813,36 @@ describe("admin SettingsView payment visible method controls", () => {
tencent_captcha_app_secret_key: "app-secret-value",
tencent_captcha_cloud_secret_id: "cloud-secret-id-value",
tencent_captcha_cloud_secret_key: "cloud-secret-key-value",
tencent_captcha_region: "cn",
}),
);
});
it("腾讯天御切换到国际站后保存站点并更新控制台入口", async () => {
const wrapper = mountView();
await flushPromises();
await openSecurityTab(wrapper);
await wrapper.get('[data-testid="captcha-enabled-toggle"]').setValue(true);
await wrapper.get('[data-testid="captcha-provider-tencent"]').trigger("click");
await wrapper.get('[data-testid="tencent-captcha-region-intl"]').trigger("click");
const card = wrapper
.findAll(".card")
.find((node) => node.text().includes("admin.settings.captcha.title"));
expect(card).toBeDefined();
expect(card!.get('a[href="https://console.tencentcloud.com/captcha/graphical"]').exists()).toBe(
true,
);
expect(card!.get('a[href="https://console.tencentcloud.com/cam/capi"]').exists()).toBe(true);
await wrapper.find("form").trigger("submit.prevent");
await flushPromises();
expect(updateSettings).toHaveBeenCalledWith(
expect.objectContaining({
tencent_captcha_enabled: true,
tencent_captcha_region: "intl",
}),
);
});
@@ -75,6 +75,7 @@
:turnstile-site-key="turnstileSiteKey"
:tencent-enabled="tencentCaptchaEnabled"
:tencent-app-id="tencentCaptchaAppId"
:tencent-region="tencentCaptchaRegion"
:aliyun-enabled="aliyunCaptchaEnabled"
:aliyun-scene-id="aliyunCaptchaSceneId"
:aliyun-prefix="aliyunCaptchaPrefix"
@@ -93,6 +94,7 @@
:turnstile-site-key="turnstileSiteKey"
:tencent-enabled="tencentCaptchaEnabled"
:tencent-app-id="tencentCaptchaAppId"
:tencent-region="tencentCaptchaRegion"
:aliyun-enabled="aliyunCaptchaEnabled"
:aliyun-scene-id="aliyunCaptchaSceneId"
:aliyun-prefix="aliyunCaptchaPrefix"
@@ -262,6 +264,7 @@ const turnstileEnabled = ref<boolean>(false)
const turnstileSiteKey = ref<string>('')
const tencentCaptchaEnabled = ref<boolean>(false)
const tencentCaptchaAppId = ref<string>('')
const tencentCaptchaRegion = ref<string>('cn')
const aliyunCaptchaEnabled = ref<boolean>(false)
const aliyunCaptchaSceneId = ref<string>('')
const aliyunCaptchaPrefix = ref<string>('')
@@ -361,6 +364,7 @@ onMounted(async () => {
turnstileSiteKey.value = settings.turnstile_site_key || ''
tencentCaptchaEnabled.value = settings.tencent_captcha_enabled === true
tencentCaptchaAppId.value = settings.tencent_captcha_app_id || ''
tencentCaptchaRegion.value = settings.tencent_captcha_region || 'cn'
aliyunCaptchaEnabled.value = settings.aliyun_captcha_enabled === true
aliyunCaptchaSceneId.value = settings.aliyun_captcha_scene_id || ''
aliyunCaptchaPrefix.value = settings.aliyun_captcha_prefix || ''
@@ -74,6 +74,7 @@
:turnstile-site-key="turnstileSiteKey"
:tencent-enabled="tencentCaptchaEnabled"
:tencent-app-id="tencentCaptchaAppId"
:tencent-region="tencentCaptchaRegion"
:aliyun-enabled="aliyunCaptchaEnabled"
:aliyun-scene-id="aliyunCaptchaSceneId"
:aliyun-prefix="aliyunCaptchaPrefix"
@@ -157,6 +158,7 @@ const turnstileEnabled = ref<boolean>(false)
const turnstileSiteKey = ref<string>('')
const tencentCaptchaEnabled = ref<boolean>(false)
const tencentCaptchaAppId = ref<string>('')
const tencentCaptchaRegion = ref<string>('cn')
const aliyunCaptchaEnabled = ref<boolean>(false)
const aliyunCaptchaSceneId = ref<string>('')
const aliyunCaptchaPrefix = ref<string>('')
@@ -209,6 +211,7 @@ onMounted(async () => {
turnstileSiteKey.value = settings.turnstile_site_key || ''
tencentCaptchaEnabled.value = settings.tencent_captcha_enabled === true
tencentCaptchaAppId.value = settings.tencent_captcha_app_id || ''
tencentCaptchaRegion.value = settings.tencent_captcha_region || 'cn'
aliyunCaptchaEnabled.value = settings.aliyun_captcha_enabled === true
aliyunCaptchaSceneId.value = settings.aliyun_captcha_scene_id || ''
aliyunCaptchaPrefix.value = settings.aliyun_captcha_prefix || ''
+3
View File
@@ -86,6 +86,7 @@
:turnstile-site-key="turnstileSiteKey"
:tencent-enabled="tencentCaptchaEnabled"
:tencent-app-id="tencentCaptchaAppId"
:tencent-region="tencentCaptchaRegion"
:aliyun-enabled="aliyunCaptchaEnabled"
:aliyun-scene-id="aliyunCaptchaSceneId"
:aliyun-prefix="aliyunCaptchaPrefix"
@@ -273,6 +274,7 @@ const turnstileEnabled = ref<boolean>(false)
const turnstileSiteKey = ref<string>('')
const tencentCaptchaEnabled = ref<boolean>(false)
const tencentCaptchaAppId = ref<string>('')
const tencentCaptchaRegion = ref<string>('cn')
const aliyunCaptchaEnabled = ref<boolean>(false)
const aliyunCaptchaSceneId = ref<string>('')
const aliyunCaptchaPrefix = ref<string>('')
@@ -383,6 +385,7 @@ onMounted(async () => {
turnstileSiteKey.value = settings.turnstile_site_key || ''
tencentCaptchaEnabled.value = settings.tencent_captcha_enabled === true
tencentCaptchaAppId.value = settings.tencent_captcha_app_id || ''
tencentCaptchaRegion.value = settings.tencent_captcha_region || 'cn'
aliyunCaptchaEnabled.value = settings.aliyun_captcha_enabled === true
aliyunCaptchaSceneId.value = settings.aliyun_captcha_scene_id || ''
aliyunCaptchaPrefix.value = settings.aliyun_captcha_prefix || ''
+3
View File
@@ -211,6 +211,7 @@
:turnstile-site-key="turnstileSiteKey"
:tencent-enabled="tencentCaptchaEnabled"
:tencent-app-id="tencentCaptchaAppId"
:tencent-region="tencentCaptchaRegion"
:aliyun-enabled="aliyunCaptchaEnabled"
:aliyun-scene-id="aliyunCaptchaSceneId"
:aliyun-prefix="aliyunCaptchaPrefix"
@@ -392,6 +393,7 @@ const turnstileEnabled = ref<boolean>(false)
const turnstileSiteKey = ref<string>('')
const tencentCaptchaEnabled = ref<boolean>(false)
const tencentCaptchaAppId = ref<string>('')
const tencentCaptchaRegion = ref<string>('cn')
const aliyunCaptchaEnabled = ref<boolean>(false)
const aliyunCaptchaSceneId = ref<string>('')
const aliyunCaptchaPrefix = ref<string>('')
@@ -524,6 +526,7 @@ onMounted(async () => {
turnstileSiteKey.value = settings.turnstile_site_key || ''
tencentCaptchaEnabled.value = settings.tencent_captcha_enabled === true
tencentCaptchaAppId.value = settings.tencent_captcha_app_id || ''
tencentCaptchaRegion.value = settings.tencent_captcha_region || 'cn'
aliyunCaptchaEnabled.value = settings.aliyun_captcha_enabled === true
aliyunCaptchaSceneId.value = settings.aliyun_captcha_scene_id || ''
aliyunCaptchaPrefix.value = settings.aliyun_captcha_prefix || ''
@@ -0,0 +1,104 @@
import { defineComponent, h } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ForgotPasswordView from '@/views/auth/ForgotPasswordView.vue'
const getPublicSettingsMock = vi.fn()
const forgotPasswordMock = vi.fn()
const verifyActionMock = vi.fn()
const captchaResetMock = vi.fn()
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({ t: (key: string) => key })
}
})
vi.mock('@/stores', () => ({
useAppStore: () => ({
showError: vi.fn(),
showSuccess: vi.fn()
})
}))
vi.mock('@/api/auth', () => ({
getPublicSettings: (...args: unknown[]) => getPublicSettingsMock(...args),
forgotPassword: (...args: unknown[]) => forgotPasswordMock(...args)
}))
const CaptchaChallengeStub = defineComponent({
props: {
tencentEnabled: Boolean,
tencentAppId: String,
tencentRegion: String
},
setup(_, { expose }) {
expose({
verifyAction: verifyActionMock,
reset: captchaResetMock
})
return () => h('div', { 'data-testid': 'captcha-challenge' })
}
})
function mountForgotPassword() {
return mount(ForgotPasswordView, {
global: {
stubs: {
AuthLayout: { template: '<div><slot /><slot name="footer" /></div>' },
Icon: true,
RouterLink: true,
TurnstileWidget: CaptchaChallengeStub
}
}
})
}
describe('忘记密码腾讯验证码门禁', () => {
beforeEach(() => {
getPublicSettingsMock.mockReset()
forgotPasswordMock.mockReset()
verifyActionMock.mockReset()
captchaResetMock.mockReset()
getPublicSettingsMock.mockResolvedValue({
turnstile_enabled: false,
turnstile_site_key: '',
tencent_captcha_enabled: true,
tencent_captcha_app_id: '123456789',
tencent_captcha_region: 'intl',
aliyun_captcha_enabled: false
})
verifyActionMock.mockResolvedValue({ token: 'ticket-value', randstr: '@rand-value' })
forgotPasswordMock.mockResolvedValue({ message: 'ok' })
})
it('把公开设置中的站点传给验证码组件', async () => {
const wrapper = mountForgotPassword()
await flushPromises()
const captcha = wrapper.findComponent(CaptchaChallengeStub)
expect(captcha.props('tencentEnabled')).toBe(true)
expect(captcha.props('tencentAppId')).toBe('123456789')
expect(captcha.props('tencentRegion')).toBe('intl')
})
it('提交前获取新票据并原样发送到忘记密码接口', async () => {
const wrapper = mountForgotPassword()
await flushPromises()
await wrapper.get('#email').setValue('user@example.com')
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(verifyActionMock).toHaveBeenCalledOnce()
expect(forgotPasswordMock).toHaveBeenCalledWith({
email: 'user@example.com',
turnstile_token: undefined,
tencent_captcha_ticket: 'ticket-value',
tencent_captcha_randstr: '@rand-value'
})
expect(captchaResetMock).toHaveBeenCalledOnce()
})
})