Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99b357083e | ||
|
|
32e4de7942 | ||
|
|
8f55e0a7cd | ||
|
|
5ec11885d1 | ||
|
|
c8af48d769 | ||
|
|
dce0f6b4e3 | ||
|
|
22ef761c27 | ||
|
|
865002fd2c | ||
|
|
93367b6db4 | ||
|
|
b6e53c9320 | ||
|
|
dbb42881c0 | ||
|
|
e4b0e1b66b | ||
|
|
287a9f386b | ||
|
|
8e102b3a0f | ||
|
|
e93f6b9953 | ||
|
|
a19c9f8d8a | ||
|
|
a1936d42db | ||
|
|
c123caddd4 | ||
|
|
e08aee49ed | ||
|
|
c9e60d1f26 | ||
|
|
47c03c75d8 | ||
|
|
5b7a64334f | ||
|
|
82ccc187cd | ||
|
|
ce14983133 | ||
|
|
fc5a1b78d2 | ||
|
|
00b8596176 | ||
|
|
c5e046b7d7 | ||
|
|
aac53afe0e | ||
|
|
f0e7a9c7a2 | ||
|
|
26e0a89323 | ||
|
|
d431c57f2e | ||
|
|
d3b5703b55 | ||
|
|
7d38e67120 | ||
|
|
e2652eb853 | ||
|
|
66ad405dd6 | ||
|
|
e3e033bb35 |
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
## 项目概述
|
||||
|
||||
@@ -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>
|
||||
|
||||
## 概要
|
||||
|
||||
|
Before Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 28 KiB |
@@ -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 |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 744 KiB |
@@ -1 +1 @@
|
||||
0.1.170
|
||||
0.1.171
|
||||
|
||||
@@ -59,6 +59,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
turnstileService := service.NewTurnstileService(settingService, turnstileVerifier)
|
||||
tencentCaptchaVerifier := repository.NewTencentCaptchaVerifier()
|
||||
tencentCaptchaService := service.NewTencentCaptchaService(settingService, tencentCaptchaVerifier)
|
||||
aliyunCaptchaVerifier := repository.NewAliyunCaptchaVerifier()
|
||||
aliyunCaptchaService := service.NewAliyunCaptchaService(settingService, aliyunCaptchaVerifier)
|
||||
emailQueueService := service.ProvideEmailQueueService(emailService)
|
||||
promoCodeRepository := repository.NewPromoCodeRepository(client)
|
||||
billingCache := repository.NewBillingCache(redisClient)
|
||||
@@ -80,7 +82,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
subscriptionService := service.NewSubscriptionService(groupRepository, userSubscriptionRepository, billingCacheService, client, configConfig)
|
||||
affiliateRepository := repository.NewAffiliateRepository(client, db)
|
||||
affiliateService := service.NewAffiliateService(affiliateRepository, settingService, apiKeyAuthCacheInvalidator, billingCacheService)
|
||||
authService := service.ProvideAuthService(client, userRepository, redeemCodeRepository, refreshTokenCache, configConfig, settingService, emailService, turnstileService, tencentCaptchaService, emailQueueService, promoService, subscriptionService, affiliateService, serviceUserPlatformQuotaRepository)
|
||||
authService := service.ProvideAuthService(client, userRepository, redeemCodeRepository, refreshTokenCache, configConfig, settingService, emailService, turnstileService, tencentCaptchaService, aliyunCaptchaService, emailQueueService, promoService, subscriptionService, affiliateService, serviceUserPlatformQuotaRepository)
|
||||
userService := service.NewUserService(userRepository, settingRepository, apiKeyAuthCacheInvalidator, billingCache)
|
||||
redeemCache := repository.NewRedeemCache(redisClient)
|
||||
redeemService := service.NewRedeemService(redeemCodeRepository, userRepository, subscriptionService, redeemCache, billingCacheService, client, apiKeyAuthCacheInvalidator, affiliateService)
|
||||
@@ -224,7 +226,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
registry := payment.ProvideRegistry()
|
||||
defaultLoadBalancer := payment.ProvideDefaultLoadBalancer(client, encryptionKey)
|
||||
paymentService := service.ProvidePaymentService(client, registry, defaultLoadBalancer, redeemService, subscriptionService, paymentConfigService, userRepository, groupRepository, affiliateService, notificationEmailService)
|
||||
settingHandler := handler.ProvideAdminSettingHandler(settingService, emailService, turnstileService, opsService, paymentConfigService, paymentService, userAttributeService, notificationEmailService, totpService, userService)
|
||||
settingHandler := handler.ProvideAdminSettingHandler(settingService, emailService, turnstileService, aliyunCaptchaService, opsService, paymentConfigService, paymentService, userAttributeService, notificationEmailService, totpService, userService)
|
||||
opsHandler := admin.NewOpsHandler(opsService)
|
||||
updateCache := repository.NewUpdateCache(redisClient)
|
||||
gitHubReleaseClient := repository.ProvideGitHubReleaseClient(configConfig)
|
||||
|
||||
@@ -5,6 +5,9 @@ go 1.26.5
|
||||
require (
|
||||
entgo.io/ent v0.14.5
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2
|
||||
github.com/alibabacloud-go/captcha-20230305 v1.1.3
|
||||
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.13
|
||||
github.com/alibabacloud-go/tea v1.3.13
|
||||
github.com/alicebob/miniredis/v2 v2.38.0
|
||||
github.com/alitto/pond/v2 v2.6.2
|
||||
github.com/andybalholm/brotli v1.2.0
|
||||
@@ -35,6 +38,8 @@ require (
|
||||
github.com/spf13/viper v1.18.2
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/stripe/stripe-go/v85 v85.0.0
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/captcha v1.3.52
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.52
|
||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0
|
||||
github.com/testcontainers/testcontainers-go/modules/redis v0.40.0
|
||||
github.com/tidwall/gjson v1.18.0
|
||||
@@ -60,6 +65,10 @@ require (
|
||||
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/agext/levenshtein v1.2.3 // indirect
|
||||
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 // indirect
|
||||
github.com/alibabacloud-go/debug v1.0.1 // indirect
|
||||
github.com/alibabacloud-go/tea-utils/v2 v2.0.7 // indirect
|
||||
github.com/aliyun/credentials-go v1.4.5 // indirect
|
||||
github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18 // indirect
|
||||
@@ -81,6 +90,7 @@ require (
|
||||
github.com/bytedance/sonic v1.9.1 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
|
||||
github.com/clbanning/mxj/v2 v2.7.0 // indirect
|
||||
github.com/containerd/errdefs v1.0.0 // indirect
|
||||
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||
github.com/containerd/log v0.1.0 // indirect
|
||||
@@ -161,12 +171,11 @@ require (
|
||||
github.com/spf13/cast v1.6.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/captcha v1.3.52 // indirect
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.52 // indirect
|
||||
github.com/testcontainers/testcontainers-go v0.40.0 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.0 // indirect
|
||||
github.com/tinylib/msgp v1.6.4 // indirect
|
||||
github.com/tjfoc/gmsm v1.4.1 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
||||
github.com/tklauser/numcpus v0.6.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
ariga.io/atlas v0.32.1-0.20250325101103-175b25e1c1b9 h1:E0wvcUXTkgyN4wy4LGtNzMNGMytJN8afmIWXJVMi4cc=
|
||||
ariga.io/atlas v0.32.1-0.20250325101103-175b25e1c1b9/go.mod h1:Oe1xWPuu5q9LzyrWfbZmEZxFYeu4BHTyzfjeW2aZp/w=
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||
entgo.io/ent v0.14.5 h1:Rj2WOYJtCkWyFo6a+5wB3EfBRP0rnx1fMk6gGA0UUe4=
|
||||
@@ -8,6 +9,7 @@ github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8af
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
@@ -16,10 +18,56 @@ github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7l
|
||||
github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558=
|
||||
github.com/agiledragon/gomonkey v2.0.2+incompatible h1:eXKi9/piiC3cjJD1658mEE2o3NjkJ5vDLgYjCQu0Xlw=
|
||||
github.com/agiledragon/gomonkey v2.0.2+incompatible/go.mod h1:2NGfXu1a80LLr2cmWXGBDaHEjb1idR6+FVlX5T3D9hw=
|
||||
github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6 h1:eIf+iGJxdU4U9ypaUfbtOWCsZSbTb8AUHvyPrxu6mAA=
|
||||
github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6/go.mod h1:4EUIoxs/do24zMOGGqYVWgw0s9NtiylnJglOeEB5UJo=
|
||||
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.4/go.mod h1:sCavSAvdzOjul4cEqeVtvlSaSScfNsTQ+46HwlTL1hc=
|
||||
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 h1:zE8vH9C7JiZLNJJQ5OwjU9mSi4T9ef9u3BURT6LCLC8=
|
||||
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5/go.mod h1:tWnyE9AjF8J8qqLk645oUmVUnFybApTQWklQmi5tY6g=
|
||||
github.com/alibabacloud-go/captcha-20230305 v1.1.3 h1:0Aobw12m3x28aeDMPjwjXsfF8MuLvRjlQ4Hhoy5hFOY=
|
||||
github.com/alibabacloud-go/captcha-20230305 v1.1.3/go.mod h1:ydzBIN2OiM7eeQPpAFyBrv1H5TY1MtUP2rQig44C4UQ=
|
||||
github.com/alibabacloud-go/darabonba-array v0.1.0 h1:vR8s7b1fWAQIjEjWnuF0JiKsCvclSRTfDzZHTYqfufY=
|
||||
github.com/alibabacloud-go/darabonba-array v0.1.0/go.mod h1:BLKxr0brnggqOJPqT09DFJ8g3fsDshapUD3C3aOEFaI=
|
||||
github.com/alibabacloud-go/darabonba-encode-util v0.0.2 h1:1uJGrbsGEVqWcWxrS9MyC2NG0Ax+GpOM5gtupki31XE=
|
||||
github.com/alibabacloud-go/darabonba-encode-util v0.0.2/go.mod h1:JiW9higWHYXm7F4PKuMgEUETNZasrDM6vqVr/Can7H8=
|
||||
github.com/alibabacloud-go/darabonba-map v0.0.2 h1:qvPnGB4+dJbJIxOOfawxzF3hzMnIpjmafa0qOTp6udc=
|
||||
github.com/alibabacloud-go/darabonba-map v0.0.2/go.mod h1:28AJaX8FOE/ym8OUFWga+MtEzBunJwQGceGQlvaPGPc=
|
||||
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.13 h1:Q00FU3H94Ts0ZIHDmY+fYGgB7dV9D/YX6FGsgorQPgw=
|
||||
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.13/go.mod h1:lxFGfobinVsQ49ntjpgWghXmIF0/Sm4+wvBJ1h5RtaE=
|
||||
github.com/alibabacloud-go/darabonba-signature-util v0.0.7 h1:UzCnKvsjPFzApvODDNEYqBHMFt1w98wC7FOo0InLyxg=
|
||||
github.com/alibabacloud-go/darabonba-signature-util v0.0.7/go.mod h1:oUzCYV2fcCH797xKdL6BDH8ADIHlzrtKVjeRtunBNTQ=
|
||||
github.com/alibabacloud-go/darabonba-string v1.0.2 h1:E714wms5ibdzCqGeYJ9JCFywE5nDyvIXIIQbZVFkkqo=
|
||||
github.com/alibabacloud-go/darabonba-string v1.0.2/go.mod h1:93cTfV3vuPhhEwGGpKKqhVW4jLe7tDpo3LUM0i0g6mA=
|
||||
github.com/alibabacloud-go/debug v0.0.0-20190504072949-9472017b5c68/go.mod h1:6pb/Qy8c+lqua8cFpEy7g39NRRqOWc3rOwAy8m5Y2BY=
|
||||
github.com/alibabacloud-go/debug v1.0.0/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc=
|
||||
github.com/alibabacloud-go/debug v1.0.1 h1:MsW9SmUtbb1Fnt3ieC6NNZi6aEwrXfDksD4QA6GSbPg=
|
||||
github.com/alibabacloud-go/debug v1.0.1/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc=
|
||||
github.com/alibabacloud-go/endpoint-util v1.1.0 h1:r/4D3VSw888XGaeNpP994zDUaxdgTSHBbVfZlzf6b5Q=
|
||||
github.com/alibabacloud-go/endpoint-util v1.1.0/go.mod h1:O5FuCALmCKs2Ff7JFJMudHs0I5EBgecXXxZRyswlEjE=
|
||||
github.com/alibabacloud-go/openapi-util v0.1.0 h1:0z75cIULkDrdEhkLWgi9tnLe+KhAFE/r5Pb3312/eAY=
|
||||
github.com/alibabacloud-go/openapi-util v0.1.0/go.mod h1:sQuElr4ywwFRlCCberQwKRFhRzIyG4QTP/P4y1CJ6Ws=
|
||||
github.com/alibabacloud-go/tea v1.1.0/go.mod h1:IkGyUSX4Ba1V+k4pCtJUc6jDpZLFph9QMy2VUPTwukg=
|
||||
github.com/alibabacloud-go/tea v1.1.7/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4=
|
||||
github.com/alibabacloud-go/tea v1.1.8/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4=
|
||||
github.com/alibabacloud-go/tea v1.1.11/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4=
|
||||
github.com/alibabacloud-go/tea v1.1.17/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A=
|
||||
github.com/alibabacloud-go/tea v1.1.20/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A=
|
||||
github.com/alibabacloud-go/tea v1.2.2/go.mod h1:CF3vOzEMAG+bR4WOql8gc2G9H3EkH3ZLAQdpmpXMgwk=
|
||||
github.com/alibabacloud-go/tea v1.3.13 h1:WhGy6LIXaMbBM6VBYcsDCz6K/TPsT1Ri2hPmmZffZ94=
|
||||
github.com/alibabacloud-go/tea v1.3.13/go.mod h1:A560v/JTQ1n5zklt2BEpurJzZTI8TUT+Psg2drWlxRg=
|
||||
github.com/alibabacloud-go/tea-utils v1.3.1 h1:iWQeRzRheqCMuiF3+XkfybB3kTgUXkXX+JMrqfLeB2I=
|
||||
github.com/alibabacloud-go/tea-utils v1.3.1/go.mod h1:EI/o33aBfj3hETm4RLiAxF/ThQdSngxrpF8rKUDJjPE=
|
||||
github.com/alibabacloud-go/tea-utils/v2 v2.0.5/go.mod h1:dL6vbUT35E4F4bFTHL845eUloqaerYBYPsdWR2/jhe4=
|
||||
github.com/alibabacloud-go/tea-utils/v2 v2.0.7 h1:WDx5qW3Xa5ZgJ1c8NfqJkF6w+AU5wB8835UdhPr6Ax0=
|
||||
github.com/alibabacloud-go/tea-utils/v2 v2.0.7/go.mod h1:qxn986l+q33J5VkialKMqT/TTs3E+U9MJpd001iWQ9I=
|
||||
github.com/alicebob/miniredis/v2 v2.38.0 h1:nZAzCR+Lj+Vxk4ZXzm2NuKq2O33RXj1XxJ2e2uP9jiw=
|
||||
github.com/alicebob/miniredis/v2 v2.38.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
||||
github.com/alitto/pond/v2 v2.6.2 h1:Sphe40g0ILeM1pA2c2K+Th0DGU+pt0A/Kprr+WB24Pw=
|
||||
github.com/alitto/pond/v2 v2.6.2/go.mod h1:xkjYEgQ05RSpWdfSd1nM3OVv7TBhLdy7rMp3+2Nq+yE=
|
||||
github.com/aliyun/credentials-go v1.1.2/go.mod h1:ozcZaMR5kLM7pwtCMEpVmQ242suV6qTJya2bDq4X1Tw=
|
||||
github.com/aliyun/credentials-go v1.3.1/go.mod h1:8jKYhQuDawt8x2+fusqa1Y6mPxemTsBEN04dgcAcYz0=
|
||||
github.com/aliyun/credentials-go v1.3.6/go.mod h1:1LxUuX7L5YrZUWzBrRyk0SwSdH4OmPrib8NVePL3fxM=
|
||||
github.com/aliyun/credentials-go v1.4.5 h1:O76WYKgdy1oQYYiJkERjlA2dxGuvLRrzuO2ScrtGWSk=
|
||||
github.com/aliyun/credentials-go v1.4.5/go.mod h1:Jm6d+xIgwJVLVWT561vy67ZRP4lPTQxMbEYRuT2Ti1U=
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY=
|
||||
@@ -77,11 +125,16 @@ github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s
|
||||
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
||||
github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME=
|
||||
github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
||||
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||
@@ -120,6 +173,9 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
|
||||
github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
@@ -163,27 +219,43 @@ github.com/go-webauthn/x v0.2.6 h1:TEyDuQAIiEgYpx60nKiBJIX/5nSUC8LxNbH+uf5U9uk=
|
||||
github.com/go-webauthn/x v0.2.6/go.mod h1:45bA7YEqyQhRcQJ/TiBb46Ww8yqHBGvgEhQ3WWF0aDo=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
|
||||
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
|
||||
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
|
||||
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4=
|
||||
github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg=
|
||||
@@ -207,8 +279,10 @@ github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg=
|
||||
github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE=
|
||||
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
||||
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
@@ -217,6 +291,8 @@ github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZX
|
||||
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
@@ -234,8 +310,6 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
||||
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
|
||||
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
|
||||
@@ -263,14 +337,15 @@ github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
|
||||
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
@@ -292,6 +367,7 @@ github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
|
||||
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
|
||||
github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
@@ -304,8 +380,6 @@ github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEv
|
||||
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
@@ -330,6 +404,9 @@ github.com/smartwalle/ngx v1.1.0 h1:q8nANgWSPRGeI/u+ixBoA4mf68DrUq6vZ+n9L5UKv9I=
|
||||
github.com/smartwalle/ngx v1.1.0/go.mod h1:mx/nz2Pk5j+RBs7t6u6k22MPiBG/8CtOMpCnALIG8Y0=
|
||||
github.com/smartwalle/nsign v1.0.9 h1:8poAgG7zBd8HkZy9RQDwasC6XZvJpDGQWSjzL2FZL6E=
|
||||
github.com/smartwalle/nsign v1.0.9/go.mod h1:eY6I4CJlyNdVMP+t6z1H6Jpd4m5/V+8xi44ufSTxXgc=
|
||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
|
||||
github.com/smartystreets/assertions v1.1.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo=
|
||||
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
|
||||
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
||||
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
|
||||
@@ -338,18 +415,18 @@ github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
|
||||
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
|
||||
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
|
||||
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
|
||||
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ=
|
||||
github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
@@ -386,6 +463,9 @@ github.com/tiktoken-go/tokenizer v0.8.0 h1:drHWno2Zx3eAm/hk/LmvBKXPpSImB7BRyh/ru
|
||||
github.com/tiktoken-go/tokenizer v0.8.0/go.mod h1:pTmPz4r14MV3JkUGAmAcdLdYhSxN68MCjrP+EoxBdx0=
|
||||
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
|
||||
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
github.com/tjfoc/gmsm v1.3.2/go.mod h1:HaUcFuY0auTiaHB9MHFGCPx5IaLhTUd2atbCFBQXn9w=
|
||||
github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho=
|
||||
github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE=
|
||||
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
|
||||
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
||||
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
|
||||
@@ -400,6 +480,9 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.1.30/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
||||
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
@@ -443,59 +526,189 @@ go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
|
||||
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20191219195013-becbf705a915/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
|
||||
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
|
||||
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
||||
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200509044756-6aff5f38e54f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
|
||||
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
|
||||
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
|
||||
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
|
||||
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
|
||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200509030707-2212a7e161a5/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17 h1:wpZ8pe2x1Q3f2KyT5f8oP/fa9rHAKgFPr/HZdNuS+PQ=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4 h1:8XJ4pajGwOlasW+L13MnEGA8W4115jJySQtVfS2/IBU=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4/go.mod h1:NnuHhy+bxcg30o7FnVAZbXsPHUDQ9qKWAQKCD7VxFtk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4 h1:i8QOKZfYg6AbGVZzUAY3LrNWCKF8O6zFisU9Wl9RER4=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||
google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI=
|
||||
google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/ini.v1 v1.56.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
||||
|
||||
@@ -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://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://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 (
|
||||
|
||||
@@ -117,6 +117,12 @@ var DefaultAntigravityModelMapping = map[string]string{
|
||||
"gemini-3.1-flash-image": "gemini-3.1-flash-image",
|
||||
// Gemini 3.1 image preview 映射
|
||||
"gemini-3.1-flash-image-preview": "gemini-3.1-flash-image",
|
||||
// Gemini 3.6 Flash tiered models
|
||||
"gemini-3.6-flash": "gemini-3.6-flash",
|
||||
"gemini-3.6-flash-high": "gemini-3.6-flash-high",
|
||||
"gemini-3.6-flash-low": "gemini-3.6-flash-low",
|
||||
"gemini-3.6-flash-medium": "gemini-3.6-flash-medium",
|
||||
"gemini-3.6-flash-tiered": "gemini-3.6-flash-tiered",
|
||||
// Gemini 3 image 兼容映射(向 3.1 image 迁移)
|
||||
"gemini-3-pro-image": "gemini-3.1-flash-image",
|
||||
"gemini-3-pro-image-preview": "gemini-3.1-flash-image",
|
||||
|
||||
@@ -65,6 +65,14 @@ func TestDefaultAntigravityModelMapping_Gemini31ProAliases(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultAntigravityModelMapping_Gemini36FlashModels(t *testing.T) {
|
||||
for _, model := range []string{"gemini-3.6-flash", "gemini-3.6-flash-high", "gemini-3.6-flash-low", "gemini-3.6-flash-medium", "gemini-3.6-flash-tiered"} {
|
||||
if got := DefaultAntigravityModelMapping[model]; got != model {
|
||||
t.Fatalf("expected %s to map to itself, got %q", model, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultBedrockModelMapping_ContainsNewClaudeModels(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ type SettingHandler struct {
|
||||
settingService *service.SettingService
|
||||
emailService *service.EmailService
|
||||
turnstileService *service.TurnstileService
|
||||
aliyunCaptchaService *service.AliyunCaptchaService
|
||||
opsService *service.OpsService
|
||||
paymentConfigService *service.PaymentConfigService
|
||||
paymentService *service.PaymentService
|
||||
@@ -82,6 +83,12 @@ func (h *SettingHandler) SetNotificationEmailService(notificationEmailService *s
|
||||
h.notificationEmailService = notificationEmailService
|
||||
}
|
||||
|
||||
// SetAliyunCaptchaService attaches the Aliyun captcha credential validator without
|
||||
// changing the constructor signature used by existing unit tests.
|
||||
func (h *SettingHandler) SetAliyunCaptchaService(aliyunCaptchaService *service.AliyunCaptchaService) {
|
||||
h.aliyunCaptchaService = aliyunCaptchaService
|
||||
}
|
||||
|
||||
// SetStepUpDeps attaches the services backing the step-up switch preconditions
|
||||
// (enable requires the acting admin to have TOTP enabled; disable is itself a
|
||||
// step-up gated operation), without changing the constructor signature used by
|
||||
@@ -161,6 +168,13 @@ 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,
|
||||
AliyunCaptchaSceneID: settings.AliyunCaptchaSceneID,
|
||||
AliyunCaptchaPrefix: settings.AliyunCaptchaPrefix,
|
||||
AliyunCaptchaRegion: settings.AliyunCaptchaRegion,
|
||||
APIKeyACLTrustForwardedIP: settings.APIKeyACLTrustForwardedIP,
|
||||
ForwardedClientIPHeaders: settings.ForwardedClientIPHeaders,
|
||||
LinuxDoConnectEnabled: settings.LinuxDoConnectEnabled,
|
||||
|
||||
@@ -122,6 +122,27 @@ 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")
|
||||
}
|
||||
if before.AliyunCaptchaAccessKeyID != after.AliyunCaptchaAccessKeyID {
|
||||
changed = append(changed, "aliyun_captcha_access_key_id")
|
||||
}
|
||||
if req.AliyunCaptchaAccessKeySecret != "" {
|
||||
changed = append(changed, "aliyun_captcha_access_key_secret")
|
||||
}
|
||||
if before.AliyunCaptchaSceneID != after.AliyunCaptchaSceneID {
|
||||
changed = append(changed, "aliyun_captcha_scene_id")
|
||||
}
|
||||
if before.AliyunCaptchaPrefix != after.AliyunCaptchaPrefix {
|
||||
changed = append(changed, "aliyun_captcha_prefix")
|
||||
}
|
||||
if before.AliyunCaptchaRegion != after.AliyunCaptchaRegion {
|
||||
changed = append(changed, "aliyun_captcha_region")
|
||||
}
|
||||
if before.APIKeyACLTrustForwardedIP != after.APIKeyACLTrustForwardedIP {
|
||||
changed = append(changed, "api_key_acl_trust_forwarded_ip")
|
||||
}
|
||||
|
||||
@@ -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,15 @@ 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"`
|
||||
AliyunCaptchaAccessKeyID string `json:"aliyun_captcha_access_key_id"`
|
||||
AliyunCaptchaAccessKeySecret string `json:"aliyun_captcha_access_key_secret"`
|
||||
AliyunCaptchaSceneID string `json:"aliyun_captcha_scene_id"`
|
||||
AliyunCaptchaPrefix string `json:"aliyun_captcha_prefix"`
|
||||
AliyunCaptchaRegion string `json:"aliyun_captcha_region"`
|
||||
|
||||
// API Key IP 访问控制设置
|
||||
APIKeyACLTrustForwardedIP *bool `json:"api_key_acl_trust_forwarded_ip"`
|
||||
@@ -450,6 +459,7 @@ func settingsAuditRequest(req UpdateSettingsRequest) UpdateSettingsRequest {
|
||||
req.TencentCaptchaAppSecretKey = strings.TrimSpace(req.TencentCaptchaAppSecretKey)
|
||||
req.TencentCaptchaCloudSecretID = strings.TrimSpace(req.TencentCaptchaCloudSecretID)
|
||||
req.TencentCaptchaCloudSecretKey = strings.TrimSpace(req.TencentCaptchaCloudSecretKey)
|
||||
req.AliyunCaptchaAccessKeySecret = strings.TrimSpace(req.AliyunCaptchaAccessKeySecret)
|
||||
return req
|
||||
}
|
||||
|
||||
@@ -614,10 +624,34 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
if _, sent := sentFields["tencent_captcha_enabled"]; !sent {
|
||||
tencentCaptchaEnabled = previousSettings.TencentCaptchaEnabled
|
||||
}
|
||||
if turnstileEnabled && tencentCaptchaEnabled {
|
||||
response.BadRequest(c, "Cloudflare Turnstile and Tencent Captcha cannot be enabled at the same time")
|
||||
aliyunCaptchaEnabled := req.AliyunCaptchaEnabled
|
||||
if _, sent := sentFields["aliyun_captcha_enabled"]; !sent {
|
||||
aliyunCaptchaEnabled = previousSettings.AliyunCaptchaEnabled
|
||||
}
|
||||
enabledCaptchaProviders := 0
|
||||
for _, enabled := range []bool{turnstileEnabled, tencentCaptchaEnabled, aliyunCaptchaEnabled} {
|
||||
if enabled {
|
||||
enabledCaptchaProviders++
|
||||
}
|
||||
}
|
||||
if enabledCaptchaProviders > 1 {
|
||||
response.BadRequest(c, "Multiple captcha providers (Cloudflare Turnstile / Tencent Captcha / Aliyun Captcha) cannot be enabled at the same time")
|
||||
return
|
||||
}
|
||||
// 阿里云地域 normalize:未发送保留已存值,非法值一律按中国内地落库
|
||||
if _, sent := sentFields["aliyun_captcha_region"]; !sent {
|
||||
req.AliyunCaptchaRegion = previousSettings.AliyunCaptchaRegion
|
||||
}
|
||||
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 {
|
||||
@@ -678,6 +712,51 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// 阿里云验证码 2.0 参数验证
|
||||
if aliyunCaptchaEnabled {
|
||||
if _, sent := sentFields["aliyun_captcha_scene_id"]; !sent {
|
||||
req.AliyunCaptchaSceneID = previousSettings.AliyunCaptchaSceneID
|
||||
}
|
||||
if _, sent := sentFields["aliyun_captcha_prefix"]; !sent {
|
||||
req.AliyunCaptchaPrefix = previousSettings.AliyunCaptchaPrefix
|
||||
}
|
||||
if _, sent := sentFields["aliyun_captcha_access_key_id"]; !sent {
|
||||
req.AliyunCaptchaAccessKeyID = previousSettings.AliyunCaptchaAccessKeyID
|
||||
}
|
||||
if req.AliyunCaptchaSceneID == "" {
|
||||
response.BadRequest(c, "Aliyun Captcha Scene ID is required when enabled")
|
||||
return
|
||||
}
|
||||
if req.AliyunCaptchaPrefix == "" {
|
||||
response.BadRequest(c, "Aliyun Captcha Prefix is required when enabled")
|
||||
return
|
||||
}
|
||||
if req.AliyunCaptchaAccessKeyID == "" {
|
||||
response.BadRequest(c, "Aliyun Captcha AccessKey ID is required when enabled")
|
||||
return
|
||||
}
|
||||
// 如果未提供 AccessKey Secret,使用已保存的值(留空保留当前值)
|
||||
if req.AliyunCaptchaAccessKeySecret == "" {
|
||||
if previousSettings.AliyunCaptchaAccessKeySecret == "" {
|
||||
response.BadRequest(c, "Aliyun Captcha AccessKey Secret is required when enabled")
|
||||
return
|
||||
}
|
||||
req.AliyunCaptchaAccessKeySecret = previousSettings.AliyunCaptchaAccessKeySecret
|
||||
}
|
||||
|
||||
// 凭证任一变化时真实调用一次阿里云校验(避免配置错误导致无法登录)
|
||||
credentialsChanged := previousSettings.AliyunCaptchaAccessKeyID != req.AliyunCaptchaAccessKeyID ||
|
||||
previousSettings.AliyunCaptchaAccessKeySecret != req.AliyunCaptchaAccessKeySecret ||
|
||||
previousSettings.AliyunCaptchaSceneID != req.AliyunCaptchaSceneID ||
|
||||
previousSettings.AliyunCaptchaRegion != req.AliyunCaptchaRegion
|
||||
if credentialsChanged {
|
||||
if err := h.aliyunCaptchaService.ValidateCredentials(c.Request.Context(), req.AliyunCaptchaAccessKeyID, req.AliyunCaptchaAccessKeySecret, req.AliyunCaptchaSceneID, req.AliyunCaptchaRegion); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TOTP 双因素认证参数验证
|
||||
// 只有手动配置了加密密钥才允许启用 TOTP 功能
|
||||
if req.TotpEnabled && !previousSettings.TotpEnabled {
|
||||
@@ -1430,6 +1509,13 @@ 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,
|
||||
AliyunCaptchaSceneID: req.AliyunCaptchaSceneID,
|
||||
AliyunCaptchaPrefix: req.AliyunCaptchaPrefix,
|
||||
AliyunCaptchaRegion: req.AliyunCaptchaRegion,
|
||||
APIKeyACLTrustForwardedIP: func() bool {
|
||||
if req.APIKeyACLTrustForwardedIP != nil {
|
||||
return *req.APIKeyACLTrustForwardedIP
|
||||
@@ -2007,6 +2093,13 @@ 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,
|
||||
AliyunCaptchaSceneID: updatedSettings.AliyunCaptchaSceneID,
|
||||
AliyunCaptchaPrefix: updatedSettings.AliyunCaptchaPrefix,
|
||||
AliyunCaptchaRegion: updatedSettings.AliyunCaptchaRegion,
|
||||
APIKeyACLTrustForwardedIP: updatedSettings.APIKeyACLTrustForwardedIP,
|
||||
ForwardedClientIPHeaders: updatedSettings.ForwardedClientIPHeaders,
|
||||
LinuxDoConnectEnabled: updatedSettings.LinuxDoConnectEnabled,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ func clearDingTalkCookie(c *gin.Context, name string, secure bool) {
|
||||
// DingTalkOAuthStart 启动 DingTalk Connect OAuth 登录流程。
|
||||
// GET /api/v1/auth/oauth/dingtalk/start?redirect=/dashboard&intent=login
|
||||
func (h *AuthHandler) DingTalkOAuthStart(c *gin.Context) {
|
||||
if !h.requireTencentCaptchaForOAuthLoginStart(c) {
|
||||
if !h.requireActionCaptchaForOAuthLoginStart(c) {
|
||||
return
|
||||
}
|
||||
cfg, err := h.getDingTalkOAuthConfig(c.Request.Context())
|
||||
|
||||
@@ -59,7 +59,7 @@ func (h *AuthHandler) CompleteGoogleOAuthRegistration(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *AuthHandler) emailOAuthStart(c *gin.Context, provider string) {
|
||||
if !h.requireTencentCaptchaForOAuthLoginStart(c) {
|
||||
if !h.requireActionCaptchaForOAuthLoginStart(c) {
|
||||
return
|
||||
}
|
||||
cfg, err := h.getEmailOAuthConfig(c.Request.Context(), provider)
|
||||
|
||||
@@ -82,7 +82,7 @@ func (e *linuxDoTokenExchangeError) Error() string {
|
||||
// LinuxDoOAuthStart 启动 LinuxDo Connect OAuth 登录流程。
|
||||
// GET /api/v1/auth/oauth/linuxdo/start?redirect=/dashboard
|
||||
func (h *AuthHandler) LinuxDoOAuthStart(c *gin.Context) {
|
||||
if !h.requireTencentCaptchaForOAuthLoginStart(c) {
|
||||
if !h.requireActionCaptchaForOAuthLoginStart(c) {
|
||||
return
|
||||
}
|
||||
cfg, err := h.getLinuxDoOAuthConfig(c.Request.Context())
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
)
|
||||
|
||||
type oauthStartCaptchaRequest struct {
|
||||
// TurnstileToken 承载阿里云验证码的 captchaVerifyParam(复用既有请求字段名)
|
||||
TurnstileToken string `json:"turnstile_token"`
|
||||
TencentCaptchaTicket string `json:"tencent_captcha_ticket"`
|
||||
TencentCaptchaRandstr string `json:"tencent_captcha_randstr"`
|
||||
}
|
||||
@@ -19,7 +21,7 @@ type oauthStartResponse struct {
|
||||
AuthorizeURL string `json:"authorize_url"`
|
||||
}
|
||||
|
||||
func (h *AuthHandler) requireTencentCaptchaForOAuthLoginStart(c *gin.Context) bool {
|
||||
func (h *AuthHandler) requireActionCaptchaForOAuthLoginStart(c *gin.Context) bool {
|
||||
if strings.HasSuffix(strings.TrimRight(c.Request.URL.Path, "/"), "/bind/start") {
|
||||
return true
|
||||
}
|
||||
@@ -28,7 +30,8 @@ func (h *AuthHandler) requireTencentCaptchaForOAuthLoginStart(c *gin.Context) bo
|
||||
if c.Request.Method == http.MethodPost {
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
}
|
||||
if err := h.authService.VerifyTencentCaptchaIfEnabled(c.Request.Context(), service.CaptchaProof{
|
||||
if err := h.authService.VerifyActionCaptchaIfEnabled(c.Request.Context(), service.CaptchaProof{
|
||||
TurnstileToken: req.TurnstileToken,
|
||||
TencentTicket: req.TencentCaptchaTicket,
|
||||
TencentRandstr: req.TencentCaptchaRandstr,
|
||||
}, ip.GetClientIP(c)); err != nil {
|
||||
|
||||
@@ -122,7 +122,7 @@ func TestOAuthStartPostReturnsAuthorizeURLAfterTencentVerification(t *testing.T)
|
||||
)
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
require.True(t, handler.requireTencentCaptchaForOAuthLoginStart(c))
|
||||
require.True(t, handler.requireActionCaptchaForOAuthLoginStart(c))
|
||||
respondOAuthStart(c, "https://provider.example/authorize")
|
||||
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
@@ -143,7 +143,7 @@ func TestOAuthStartPostRequiresTencentProofWhenEnabled(t *testing.T) {
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/"+provider+"/start", strings.NewReader(`{}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
require.False(t, handler.requireTencentCaptchaForOAuthLoginStart(c))
|
||||
require.False(t, handler.requireActionCaptchaForOAuthLoginStart(c))
|
||||
require.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
require.Contains(t, recorder.Body.String(), "TENCENT_CAPTCHA_VERIFICATION_FAILED")
|
||||
require.Zero(t, verifier.calls)
|
||||
@@ -158,7 +158,7 @@ func TestOAuthBindingPathRemainsOutsideTencentGate(t *testing.T) {
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/oidc/bind/start", nil)
|
||||
|
||||
require.True(t, handler.requireTencentCaptchaForOAuthLoginStart(c))
|
||||
require.True(t, handler.requireActionCaptchaForOAuthLoginStart(c))
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ func TestOAuthStartGetRemainsCompatibleWhenTencentDisabled(t *testing.T) {
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/github/start", nil)
|
||||
|
||||
require.True(t, handler.requireTencentCaptchaForOAuthLoginStart(c))
|
||||
require.True(t, handler.requireActionCaptchaForOAuthLoginStart(c))
|
||||
respondOAuthStart(c, "https://provider.example/authorize")
|
||||
|
||||
require.Equal(t, http.StatusFound, recorder.Code)
|
||||
|
||||
@@ -115,7 +115,7 @@ type oidcJWK struct {
|
||||
// OIDCOAuthStart 启动通用 OIDC OAuth 登录流程。
|
||||
// GET /api/v1/auth/oauth/oidc/start?redirect=/dashboard
|
||||
func (h *AuthHandler) OIDCOAuthStart(c *gin.Context) {
|
||||
if !h.requireTencentCaptchaForOAuthLoginStart(c) {
|
||||
if !h.requireActionCaptchaForOAuthLoginStart(c) {
|
||||
return
|
||||
}
|
||||
cfg, err := h.getOIDCOAuthConfig(c.Request.Context())
|
||||
|
||||
@@ -96,7 +96,7 @@ type wechatPaymentOAuthContext struct {
|
||||
// WeChatOAuthStart starts the WeChat OAuth login flow and stores the short-lived
|
||||
// browser cookies required by the rebuild pending-auth bridge.
|
||||
func (h *AuthHandler) WeChatOAuthStart(c *gin.Context) {
|
||||
if !h.requireTencentCaptchaForOAuthLoginStart(c) {
|
||||
if !h.requireActionCaptchaForOAuthLoginStart(c) {
|
||||
return
|
||||
}
|
||||
cfg, err := h.getWeChatOAuthConfig(c.Request.Context(), c.Query("mode"), c)
|
||||
|
||||
@@ -64,6 +64,13 @@ 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"`
|
||||
AliyunCaptchaSceneID string `json:"aliyun_captcha_scene_id"`
|
||||
AliyunCaptchaPrefix string `json:"aliyun_captcha_prefix"`
|
||||
AliyunCaptchaRegion string `json:"aliyun_captcha_region"`
|
||||
APIKeyACLTrustForwardedIP bool `json:"api_key_acl_trust_forwarded_ip"`
|
||||
ForwardedClientIPHeaders []string `json:"forwarded_client_ip_headers"`
|
||||
|
||||
@@ -348,6 +355,11 @@ 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"`
|
||||
AliyunCaptchaRegion string `json:"aliyun_captcha_region"`
|
||||
SiteName string `json:"site_name"`
|
||||
SiteLogo string `json:"site_logo"`
|
||||
SiteSubtitle string `json:"site_subtitle"`
|
||||
|
||||
@@ -47,6 +47,8 @@ type passkeyFinishRequest struct {
|
||||
}
|
||||
|
||||
type passkeyBeginLoginRequest struct {
|
||||
// TurnstileToken 承载阿里云验证码的 captchaVerifyParam(复用既有请求字段名)
|
||||
TurnstileToken string `json:"turnstile_token"`
|
||||
TencentCaptchaTicket string `json:"tencent_captcha_ticket"`
|
||||
TencentCaptchaRandstr string `json:"tencent_captcha_randstr"`
|
||||
}
|
||||
@@ -78,7 +80,8 @@ func (h *PasskeyHandler) BeginLogin(c *gin.Context) {
|
||||
}
|
||||
var req passkeyBeginLoginRequest
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
if err := h.authService.VerifyTencentCaptchaIfEnabled(c.Request.Context(), service.CaptchaProof{
|
||||
if err := h.authService.VerifyActionCaptchaIfEnabled(c.Request.Context(), service.CaptchaProof{
|
||||
TurnstileToken: req.TurnstileToken,
|
||||
TencentTicket: req.TencentCaptchaTicket,
|
||||
TencentRandstr: req.TencentCaptchaRandstr,
|
||||
}, ip.GetClientIP(c)); err != nil {
|
||||
|
||||
@@ -62,6 +62,11 @@ 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,
|
||||
AliyunCaptchaRegion: settings.AliyunCaptchaRegion,
|
||||
SiteName: settings.SiteName,
|
||||
SiteLogo: settings.SiteLogo,
|
||||
SiteSubtitle: settings.SiteSubtitle,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -157,9 +157,10 @@ func ProvideSettingHandler(settingService *service.SettingService, buildInfo Bui
|
||||
}
|
||||
|
||||
// ProvideAdminSettingHandler creates admin.SettingHandler with notification template APIs.
|
||||
func ProvideAdminSettingHandler(settingService *service.SettingService, emailService *service.EmailService, turnstileService *service.TurnstileService, opsService *service.OpsService, paymentConfigService *service.PaymentConfigService, paymentService *service.PaymentService, userAttributeService *service.UserAttributeService, notificationEmailService *service.NotificationEmailService, totpService *service.TotpService, userService *service.UserService) *admin.SettingHandler {
|
||||
func ProvideAdminSettingHandler(settingService *service.SettingService, emailService *service.EmailService, turnstileService *service.TurnstileService, aliyunCaptchaService *service.AliyunCaptchaService, opsService *service.OpsService, paymentConfigService *service.PaymentConfigService, paymentService *service.PaymentService, userAttributeService *service.UserAttributeService, notificationEmailService *service.NotificationEmailService, totpService *service.TotpService, userService *service.UserService) *admin.SettingHandler {
|
||||
h := admin.NewSettingHandler(settingService, emailService, turnstileService, opsService, paymentConfigService, paymentService, userAttributeService)
|
||||
h.SetNotificationEmailService(notificationEmailService)
|
||||
h.SetAliyunCaptchaService(aliyunCaptchaService)
|
||||
h.SetStepUpDeps(totpService, userService)
|
||||
return h
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -178,6 +178,11 @@ var geminiModels = []modelDef{
|
||||
{ID: "gemini-3.1-pro-high", DisplayName: "Gemini 3.1 Pro High", CreatedAt: "2026-02-19T00:00:00Z", IsReasoning: true},
|
||||
{ID: "gemini-3.1-flash-image", DisplayName: "Gemini 3.1 Flash Image", CreatedAt: "2026-02-19T00:00:00Z"},
|
||||
{ID: "gemini-3.1-flash-image-preview", DisplayName: "Gemini 3.1 Flash Image Preview", CreatedAt: "2026-02-19T00:00:00Z"},
|
||||
{ID: "gemini-3.6-flash", DisplayName: "Gemini 3.6 Flash", CreatedAt: "2026-07-21T00:00:00Z"},
|
||||
{ID: "gemini-3.6-flash-high", DisplayName: "Gemini 3.6 Flash High", CreatedAt: "2026-07-21T00:00:00Z", IsReasoning: true},
|
||||
{ID: "gemini-3.6-flash-low", DisplayName: "Gemini 3.6 Flash Low", CreatedAt: "2026-07-21T00:00:00Z", IsReasoning: true},
|
||||
{ID: "gemini-3.6-flash-medium", DisplayName: "Gemini 3.6 Flash Medium", CreatedAt: "2026-07-21T00:00:00Z", IsReasoning: true},
|
||||
{ID: "gemini-3.6-flash-tiered", DisplayName: "Gemini 3.6 Flash", CreatedAt: "2026-07-21T00:00:00Z", IsReasoning: true},
|
||||
{ID: "gemini-3-pro-preview", DisplayName: "Gemini 3 Pro Preview", CreatedAt: "2025-06-01T00:00:00Z", IsReasoning: true},
|
||||
{ID: "gemini-3-pro-image", DisplayName: "Gemini 3 Pro Image", CreatedAt: "2025-06-01T00:00:00Z"},
|
||||
}
|
||||
|
||||
@@ -20,6 +20,11 @@ func TestDefaultModels_ContainsNewAndLegacyImageModels(t *testing.T) {
|
||||
"gemini-3.1-flash-image",
|
||||
"gemini-3.1-flash-image-preview",
|
||||
"gemini-3-pro-image", // legacy compatibility
|
||||
"gemini-3.6-flash",
|
||||
"gemini-3.6-flash-high",
|
||||
"gemini-3.6-flash-low",
|
||||
"gemini-3.6-flash-medium",
|
||||
"gemini-3.6-flash-tiered",
|
||||
}
|
||||
|
||||
for _, id := range requiredIDs {
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 版本段逐字一致。
|
||||
|
||||
@@ -16,10 +16,29 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/proxy"
|
||||
)
|
||||
|
||||
const (
|
||||
// socks5DialTimeout 限制到 SOCKS5 代理自身的 TCP 建连耗时。
|
||||
socks5DialTimeout = 10 * time.Second
|
||||
// socks5DialKeepAlive 与 Go 默认 keepalive 探测间隔保持一致。
|
||||
socks5DialKeepAlive = 30 * time.Second
|
||||
)
|
||||
|
||||
// socks5ForwardDialer 是 SOCKS5 dialer 的底层拨号器。
|
||||
//
|
||||
// proxy.FromURL 的默认 forward dialer 是 proxy.Direct(零值 net.Dialer,无超时),
|
||||
// 代理地址不可达时会一直卡到内核 TCP 重传耗尽(Linux 约 130 秒)。SOCKS5 分支会
|
||||
// 覆盖 Transport.DialContext,因此调用方在 Transport 上设置的建连超时对这条路径
|
||||
// 无效,必须在这里补上。
|
||||
var socks5ForwardDialer = &net.Dialer{
|
||||
Timeout: socks5DialTimeout,
|
||||
KeepAlive: socks5DialKeepAlive,
|
||||
}
|
||||
|
||||
// ConfigureTransportProxy 根据代理 URL 配置 Transport
|
||||
//
|
||||
// 支持的协议:
|
||||
@@ -45,7 +64,7 @@ func ConfigureTransportProxy(transport *http.Transport, proxyURL *url.URL) error
|
||||
return nil
|
||||
|
||||
case "socks5", "socks5h":
|
||||
dialer, err := proxy.FromURL(proxyURL, proxy.Direct)
|
||||
dialer, err := proxy.FromURL(proxyURL, socks5ForwardDialer)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create socks5 dialer: %w", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package proxyutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var errStub = errors.New("stub dial")
|
||||
|
||||
// 回归:SOCKS5 分支覆盖了调用方在 Transport 上设置的 DialContext,
|
||||
// 底层 forward dialer 必须自带建连超时。proxy.Direct 是零值 net.Dialer,
|
||||
// 代理不可达时会一直卡到内核 TCP 重传耗尽(Linux 约 130 秒)。
|
||||
func TestSOCKS5ForwardDialerHasBoundedTimeout(t *testing.T) {
|
||||
require.Greater(t, socks5ForwardDialer.Timeout, time.Duration(0))
|
||||
require.Equal(t, socks5DialTimeout, socks5ForwardDialer.Timeout)
|
||||
require.Equal(t, socks5DialKeepAlive, socks5ForwardDialer.KeepAlive)
|
||||
}
|
||||
|
||||
func TestConfigureTransportProxySOCKS5SetsDialContext(t *testing.T) {
|
||||
for _, scheme := range []string{"socks5", "socks5h"} {
|
||||
t.Run(scheme, func(t *testing.T) {
|
||||
proxyURL, err := url.Parse(scheme + "://127.0.0.1:1080")
|
||||
require.NoError(t, err)
|
||||
|
||||
transport := &http.Transport{}
|
||||
require.NoError(t, ConfigureTransportProxy(transport, proxyURL))
|
||||
require.NotNil(t, transport.DialContext)
|
||||
require.Nil(t, transport.Proxy, "SOCKS5 不应设置 Transport.Proxy")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// HTTP 代理走 Transport.Proxy,不得覆盖调用方设置的 DialContext。
|
||||
func TestConfigureTransportProxyHTTPPreservesDialContext(t *testing.T) {
|
||||
proxyURL, err := url.Parse("http://127.0.0.1:8080")
|
||||
require.NoError(t, err)
|
||||
|
||||
called := false
|
||||
transport := &http.Transport{}
|
||||
transport.DialContext = func(_ context.Context, _, _ string) (net.Conn, error) {
|
||||
called = true
|
||||
return nil, errStub
|
||||
}
|
||||
|
||||
require.NoError(t, ConfigureTransportProxy(transport, proxyURL))
|
||||
require.NotNil(t, transport.Proxy)
|
||||
require.NotNil(t, transport.DialContext)
|
||||
|
||||
_, _ = transport.DialContext(context.Background(), "tcp", "127.0.0.1:1")
|
||||
require.True(t, called, "HTTP 代理分支不应替换调用方的 DialContext")
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
captcha "github.com/alibabacloud-go/captcha-20230305/client"
|
||||
openapiutil "github.com/alibabacloud-go/darabonba-openapi/v2/utils"
|
||||
"github.com/alibabacloud-go/tea/dara"
|
||||
"github.com/alibabacloud-go/tea/tea"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
)
|
||||
|
||||
const aliyunCaptchaTimeoutMillis = 10_000
|
||||
|
||||
type aliyunCaptchaVerifier struct {
|
||||
protocol string // "HTTPS";测试注入 "HTTP" 指向 httptest.Server
|
||||
timeoutMillis int
|
||||
}
|
||||
|
||||
func NewAliyunCaptchaVerifier() service.AliyunCaptchaVerifier {
|
||||
return &aliyunCaptchaVerifier{
|
||||
protocol: "HTTPS",
|
||||
timeoutMillis: aliyunCaptchaTimeoutMillis,
|
||||
}
|
||||
}
|
||||
|
||||
// VerifyCaptcha 调用阿里云验证码 2.0 VerifyIntelligentCaptcha。
|
||||
// AK/SK 是可热更的后台设置,每次调用按当前凭证新建 client。
|
||||
func (v *aliyunCaptchaVerifier) VerifyCaptcha(ctx context.Context, cred service.AliyunCaptchaCredentials, captchaVerifyParam string) (*service.AliyunCaptchaVerifyResult, error) {
|
||||
client, err := captcha.NewClient(&openapiutil.Config{
|
||||
AccessKeyId: dara.String(cred.AccessKeyID),
|
||||
AccessKeySecret: dara.String(cred.AccessKeySecret),
|
||||
Endpoint: dara.String(cred.Endpoint),
|
||||
Protocol: dara.String(v.protocol),
|
||||
ConnectTimeout: dara.Int(v.timeoutMillis),
|
||||
ReadTimeout: dara.Int(v.timeoutMillis),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create aliyun captcha client: %w", err)
|
||||
}
|
||||
|
||||
request := &captcha.VerifyIntelligentCaptchaRequest{
|
||||
CaptchaVerifyParam: dara.String(captchaVerifyParam),
|
||||
SceneId: dara.String(cred.SceneID),
|
||||
}
|
||||
|
||||
response, err := client.VerifyIntelligentCaptchaWithContext(ctx, request, &dara.RuntimeOptions{})
|
||||
if err != nil {
|
||||
return nil, normalizeAliyunCaptchaError(err)
|
||||
}
|
||||
|
||||
result := &service.AliyunCaptchaVerifyResult{}
|
||||
if body := response.Body; body != nil && body.Result != nil {
|
||||
result.VerifyResult = dara.BoolValue(body.Result.VerifyResult)
|
||||
result.VerifyCode = dara.StringValue(body.Result.VerifyCode)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// normalizeAliyunCaptchaError 把 SDK 的两种错误类型归一化为 service.AliyunCaptchaAPIError,
|
||||
// 其余错误(网络/超时等)原样返回。
|
||||
func normalizeAliyunCaptchaError(err error) error {
|
||||
var teaErr *tea.SDKError
|
||||
if errors.As(err, &teaErr) {
|
||||
return &service.AliyunCaptchaAPIError{
|
||||
Code: tea.StringValue(teaErr.Code),
|
||||
Message: tea.StringValue(teaErr.Message),
|
||||
}
|
||||
}
|
||||
var daraErr *dara.SDKError
|
||||
if errors.As(err, &daraErr) {
|
||||
return &service.AliyunCaptchaAPIError{
|
||||
Code: dara.StringValue(daraErr.Code),
|
||||
Message: dara.StringValue(daraErr.Message),
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
)
|
||||
|
||||
// newAliyunCaptchaTestTarget 起一个假的阿里云端点,让真实 SDK 走完整的签名/序列化链路。
|
||||
func newAliyunCaptchaTestTarget(t *testing.T, handler http.HandlerFunc) (*aliyunCaptchaVerifier, service.AliyunCaptchaCredentials) {
|
||||
t.Helper()
|
||||
server := httptest.NewServer(handler)
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
verifier := &aliyunCaptchaVerifier{protocol: "HTTP", timeoutMillis: 2_000}
|
||||
cred := service.AliyunCaptchaCredentials{
|
||||
AccessKeyID: "test-ak-id",
|
||||
AccessKeySecret: "test-ak-secret",
|
||||
SceneID: "scene-1",
|
||||
Endpoint: strings.TrimPrefix(server.URL, "http://"),
|
||||
}
|
||||
return verifier, cred
|
||||
}
|
||||
|
||||
func TestAliyunCaptchaVerifier_VerifySuccess(t *testing.T) {
|
||||
var capturedParam, capturedSceneID string
|
||||
verifier, cred := newAliyunCaptchaTestTarget(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
require.NoError(t, r.ParseForm())
|
||||
capturedParam = r.Form.Get("CaptchaVerifyParam")
|
||||
capturedSceneID = r.Form.Get("SceneId")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"Code":"Success","Message":"success","RequestId":"req-1","Success":true,"Result":{"VerifyResult":true,"VerifyCode":"T001"}}`))
|
||||
})
|
||||
|
||||
result, err := verifier.VerifyCaptcha(context.Background(), cred, "the-verify-param")
|
||||
require.NoError(t, err)
|
||||
require.True(t, result.VerifyResult)
|
||||
require.Equal(t, "T001", result.VerifyCode)
|
||||
require.Equal(t, "the-verify-param", capturedParam)
|
||||
require.Equal(t, "scene-1", capturedSceneID)
|
||||
}
|
||||
|
||||
func TestAliyunCaptchaVerifier_VerifyResultFalse(t *testing.T) {
|
||||
verifier, cred := newAliyunCaptchaTestTarget(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"Code":"Success","RequestId":"req-2","Success":true,"Result":{"VerifyResult":false,"VerifyCode":"F002"}}`))
|
||||
})
|
||||
|
||||
result, err := verifier.VerifyCaptcha(context.Background(), cred, "bad-param")
|
||||
require.NoError(t, err)
|
||||
require.False(t, result.VerifyResult)
|
||||
require.Equal(t, "F002", result.VerifyCode)
|
||||
}
|
||||
|
||||
func TestAliyunCaptchaVerifier_APIErrorNormalized(t *testing.T) {
|
||||
verifier, cred := newAliyunCaptchaTestTarget(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_, _ = w.Write([]byte(`{"Code":"SignatureDoesNotMatch","Message":"Specified signature is not matched with our calculation.","RequestId":"req-3"}`))
|
||||
})
|
||||
|
||||
_, err := verifier.VerifyCaptcha(context.Background(), cred, "param")
|
||||
require.Error(t, err)
|
||||
var apiErr *service.AliyunCaptchaAPIError
|
||||
require.ErrorAs(t, err, &apiErr)
|
||||
require.Equal(t, "SignatureDoesNotMatch", apiErr.Code)
|
||||
}
|
||||
|
||||
func TestAliyunCaptchaVerifier_TransportError(t *testing.T) {
|
||||
server := httptest.NewServer(http.NotFoundHandler())
|
||||
endpoint := strings.TrimPrefix(server.URL, "http://")
|
||||
server.Close() // 立即关闭,制造连接失败
|
||||
|
||||
verifier := &aliyunCaptchaVerifier{protocol: "HTTP", timeoutMillis: 2_000}
|
||||
cred := service.AliyunCaptchaCredentials{
|
||||
AccessKeyID: "test-ak-id",
|
||||
AccessKeySecret: "test-ak-secret",
|
||||
SceneID: "scene-1",
|
||||
Endpoint: endpoint,
|
||||
}
|
||||
|
||||
_, err := verifier.VerifyCaptcha(context.Background(), cred, "param")
|
||||
require.Error(t, err)
|
||||
var apiErr *service.AliyunCaptchaAPIError
|
||||
require.False(t, errors.As(err, &apiErr), "transport errors must not be normalized to API errors")
|
||||
}
|
||||
@@ -55,6 +55,18 @@ const (
|
||||
// defaultResponseHeaderTimeout: 默认等待响应头超时时间(5分钟)
|
||||
// LLM 请求可能排队较久,需要较长超时
|
||||
defaultResponseHeaderTimeout = 300 * time.Second
|
||||
// defaultUpstreamDialTimeout: 默认 TCP/DNS 建连超时(10秒)
|
||||
// Transport 不设置 DialContext 时会退化为零值 net.Dialer(无超时),建连阶段
|
||||
// 只能依赖内核默认 TCP 重传(Linux 约 130 秒)。ResponseHeaderTimeout 只约束
|
||||
// 连接建立之后等待响应头的阶段,覆盖不到 DNS 解析与 TCP 握手。
|
||||
// 上游域名被解析到 443 不可达的 IP 时(DNS 污染/路由异常),单个账号就要卡满
|
||||
// 内核超时;而多账号故障转移是串行的,一次请求会阻塞数分钟且不写中间错误。
|
||||
defaultUpstreamDialTimeout = 10 * time.Second
|
||||
// defaultUpstreamDialKeepAlive: TCP keepalive 探测间隔,与 Go 默认值保持一致
|
||||
defaultUpstreamDialKeepAlive = 30 * time.Second
|
||||
// defaultUpstreamTLSHandshakeTimeout: TLS 握手超时(10秒)
|
||||
// 与建连超时同量级,避免 TCP 已连通但对端不推进握手时无限等待
|
||||
defaultUpstreamTLSHandshakeTimeout = 10 * time.Second
|
||||
// defaultMaxUpstreamClients: 默认最大客户端缓存数量
|
||||
// 超出后会淘汰最久未使用的客户端
|
||||
defaultMaxUpstreamClients = 5000
|
||||
@@ -1246,6 +1258,17 @@ func defaultPoolSettings(cfg *config.Config) poolSettings {
|
||||
}
|
||||
}
|
||||
|
||||
// newUpstreamDialer 构建上游 Transport 的 TCP dialer。
|
||||
//
|
||||
// 必须显式提供:http.Transport 的 DialContext 为 nil 时使用零值 net.Dialer,
|
||||
// 建连没有任何超时上限,只能等内核 TCP 重传耗尽(Linux 约 130 秒)。
|
||||
func newUpstreamDialer() *net.Dialer {
|
||||
return &net.Dialer{
|
||||
Timeout: defaultUpstreamDialTimeout,
|
||||
KeepAlive: defaultUpstreamDialKeepAlive,
|
||||
}
|
||||
}
|
||||
|
||||
// buildUpstreamTransport 构建上游请求的 Transport
|
||||
// 使用配置文件中的连接池参数,支持生产环境调优
|
||||
//
|
||||
@@ -1258,6 +1281,8 @@ func defaultPoolSettings(cfg *config.Config) poolSettings {
|
||||
// - error: 代理配置错误
|
||||
//
|
||||
// Transport 参数说明:
|
||||
// - DialContext: DNS 解析 + TCP 建连超时(不设置则无上限,退化为内核默认重传)
|
||||
// - TLSHandshakeTimeout: TLS 握手超时
|
||||
// - MaxIdleConns: 所有主机的最大空闲连接总数
|
||||
// - MaxIdleConnsPerHost: 每主机最大空闲连接数(影响连接复用率)
|
||||
// - MaxConnsPerHost: 每主机最大连接数(达到后新请求等待)
|
||||
@@ -1265,6 +1290,8 @@ func defaultPoolSettings(cfg *config.Config) poolSettings {
|
||||
// - ResponseHeaderTimeout: 等待响应头超时(不影响流式传输)
|
||||
func buildUpstreamTransport(settings poolSettings, proxyURL *url.URL, protocolMode string) (*http.Transport, error) {
|
||||
transport := &http.Transport{
|
||||
DialContext: newUpstreamDialer().DialContext,
|
||||
TLSHandshakeTimeout: defaultUpstreamTLSHandshakeTimeout,
|
||||
MaxIdleConns: settings.maxIdleConns,
|
||||
MaxIdleConnsPerHost: settings.maxIdleConnsPerHost,
|
||||
MaxConnsPerHost: settings.maxConnsPerHost,
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// 回归:上游 Transport 必须显式配置建连超时。
|
||||
//
|
||||
// http.Transport.DialContext 为 nil 时 Go 使用零值 net.Dialer(Timeout=0),
|
||||
// DNS 解析与 TCP 握手没有任何上限,只能等内核重传耗尽(Linux 约 130 秒)。
|
||||
// ResponseHeaderTimeout 只覆盖连接建立之后的阶段,管不到建连。
|
||||
// 上游域名被解析到不可达 IP 时,串行的多账号故障转移会把一次请求拖到数分钟。
|
||||
func TestBuildUpstreamTransportSetsDialTimeout(t *testing.T) {
|
||||
settings := defaultPoolSettings(nil)
|
||||
|
||||
transport, err := buildUpstreamTransport(settings, nil, upstreamProtocolModeDefault)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, transport.DialContext, "DialContext 缺失会退化为无超时的零值 dialer")
|
||||
require.Equal(t, defaultUpstreamTLSHandshakeTimeout, transport.TLSHandshakeTimeout)
|
||||
}
|
||||
|
||||
func TestNewUpstreamDialerHasBoundedTimeout(t *testing.T) {
|
||||
dialer := newUpstreamDialer()
|
||||
|
||||
require.Greater(t, dialer.Timeout, time.Duration(0), "建连超时必须有上限")
|
||||
require.Equal(t, defaultUpstreamDialTimeout, dialer.Timeout)
|
||||
require.Equal(t, defaultUpstreamDialKeepAlive, dialer.KeepAlive)
|
||||
}
|
||||
|
||||
// 建连超时对 HTTP 代理同样生效:Transport.Proxy 走的仍是 DialContext,
|
||||
// 代理地址不可达时必须快速失败而不是挂满内核超时。
|
||||
func TestBuildUpstreamTransportKeepsDialTimeoutWithHTTPProxy(t *testing.T) {
|
||||
proxyURL, err := url.Parse("http://127.0.0.1:1080")
|
||||
require.NoError(t, err)
|
||||
|
||||
transport, err := buildUpstreamTransport(defaultPoolSettings(nil), proxyURL, upstreamProtocolModeDefault)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, transport.Proxy)
|
||||
require.NotNil(t, transport.DialContext)
|
||||
}
|
||||
|
||||
// SOCKS5 分支会覆盖 Transport.DialContext,覆盖后仍必须是有超时的拨号器。
|
||||
func TestBuildUpstreamTransportKeepsDialContextWithSOCKS5Proxy(t *testing.T) {
|
||||
proxyURL, err := url.Parse("socks5h://127.0.0.1:1080")
|
||||
require.NoError(t, err)
|
||||
|
||||
transport, err := buildUpstreamTransport(defaultPoolSettings(nil), proxyURL, upstreamProtocolModeDefault)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, transport.DialContext)
|
||||
}
|
||||
|
||||
// Timeout 字段确实被 net.Dialer 用于建连:拨一个已被 close 的本地监听端口,
|
||||
// 断言 Dialer 走的是自己的超时路径而不是无限等待。
|
||||
// (不依赖外网可达性,CI 中确定性执行。)
|
||||
func TestUpstreamDialerRespectsContextCancellation(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
addr := listener.Addr().String()
|
||||
require.NoError(t, listener.Close())
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
conn, err := newUpstreamDialer().DialContext(ctx, "tcp", addr)
|
||||
if conn != nil {
|
||||
_ = conn.Close()
|
||||
}
|
||||
require.Error(t, err, "已取消的 context 必须立即中止拨号")
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -368,7 +368,7 @@ func (r *userSubscriptionRepository) UpdateNotes(ctx context.Context, subscripti
|
||||
return translatePersistenceError(err, service.ErrSubscriptionNotFound, nil)
|
||||
}
|
||||
|
||||
func (r *userSubscriptionRepository) ActivateWindows(ctx context.Context, id int64, start time.Time) error {
|
||||
func (r *userSubscriptionRepository) ActivateWindows(ctx context.Context, id int64, dailyStart, periodicStart time.Time) error {
|
||||
client := clientFromContext(ctx, r.client)
|
||||
n, err := client.UserSubscription.Update().
|
||||
Where(
|
||||
@@ -377,24 +377,24 @@ func (r *userSubscriptionRepository) ActivateWindows(ctx context.Context, id int
|
||||
usersubscription.WeeklyWindowStartIsNil(),
|
||||
usersubscription.MonthlyWindowStartIsNil(),
|
||||
).
|
||||
SetDailyWindowStart(start).
|
||||
SetWeeklyWindowStart(start).
|
||||
SetMonthlyWindowStart(start).
|
||||
SetDailyWindowStart(dailyStart).
|
||||
SetWeeklyWindowStart(periodicStart).
|
||||
SetMonthlyWindowStart(periodicStart).
|
||||
Save(ctx)
|
||||
return r.translateConditionalWindowReset(ctx, client, id, n, err)
|
||||
}
|
||||
|
||||
func (r *userSubscriptionRepository) ResetUsageWindows(ctx context.Context, id int64, resetDaily, resetWeekly, resetMonthly bool, newWindowStart time.Time) error {
|
||||
func (r *userSubscriptionRepository) ResetUsageWindows(ctx context.Context, id int64, resetDaily, resetWeekly, resetMonthly bool, dailyStart, periodicStart time.Time) error {
|
||||
client := clientFromContext(ctx, r.client)
|
||||
update := client.UserSubscription.UpdateOneID(id)
|
||||
if resetDaily {
|
||||
update.SetDailyUsageUsd(0).SetDailyWindowStart(newWindowStart)
|
||||
update.SetDailyUsageUsd(0).SetDailyWindowStart(dailyStart)
|
||||
}
|
||||
if resetWeekly {
|
||||
update.SetWeeklyUsageUsd(0).SetWeeklyWindowStart(newWindowStart)
|
||||
update.SetWeeklyUsageUsd(0).SetWeeklyWindowStart(periodicStart)
|
||||
}
|
||||
if resetMonthly {
|
||||
update.SetMonthlyUsageUsd(0).SetMonthlyWindowStart(newWindowStart)
|
||||
update.SetMonthlyUsageUsd(0).SetMonthlyWindowStart(periodicStart)
|
||||
}
|
||||
_, err := update.Save(ctx)
|
||||
return translatePersistenceError(err, service.ErrSubscriptionNotFound, nil)
|
||||
|
||||
@@ -451,8 +451,9 @@ func (s *UserSubscriptionRepoSuite) TestActivateWindows() {
|
||||
group := s.mustCreateGroup("g-activate")
|
||||
sub := s.mustCreateSubscription(user.ID, group.ID, nil)
|
||||
|
||||
dailyStart := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
activateAt := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
err := s.repo.ActivateWindows(s.ctx, sub.ID, activateAt)
|
||||
err := s.repo.ActivateWindows(s.ctx, sub.ID, dailyStart, activateAt)
|
||||
s.Require().NoError(err, "ActivateWindows")
|
||||
|
||||
got, err := s.repo.GetByID(s.ctx, sub.ID)
|
||||
@@ -460,7 +461,9 @@ func (s *UserSubscriptionRepoSuite) TestActivateWindows() {
|
||||
s.Require().NotNil(got.DailyWindowStart)
|
||||
s.Require().NotNil(got.WeeklyWindowStart)
|
||||
s.Require().NotNil(got.MonthlyWindowStart)
|
||||
s.Require().WithinDuration(activateAt, *got.DailyWindowStart, time.Microsecond)
|
||||
s.Require().WithinDuration(dailyStart, *got.DailyWindowStart, time.Microsecond)
|
||||
s.Require().WithinDuration(activateAt, *got.WeeklyWindowStart, time.Microsecond)
|
||||
s.Require().WithinDuration(activateAt, *got.MonthlyWindowStart, time.Microsecond)
|
||||
}
|
||||
|
||||
func (s *UserSubscriptionRepoSuite) TestActivateWindows_StaleActivationPreservesExistingWindows() {
|
||||
@@ -469,15 +472,16 @@ func (s *UserSubscriptionRepoSuite) TestActivateWindows_StaleActivationPreserves
|
||||
sub := s.mustCreateSubscription(user.ID, group.ID, nil)
|
||||
activatedAt := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
manualResetAt := activatedAt.Add(2 * time.Hour)
|
||||
manualDailyStart := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
s.Require().NoError(s.repo.ActivateWindows(s.ctx, sub.ID, activatedAt))
|
||||
s.Require().NoError(s.repo.ResetUsageWindows(s.ctx, sub.ID, true, true, true, manualResetAt))
|
||||
s.Require().NoError(s.repo.ActivateWindows(s.ctx, sub.ID, activatedAt, activatedAt))
|
||||
s.Require().NoError(s.repo.ResetUsageWindows(s.ctx, sub.ID, true, true, true, manualDailyStart, manualResetAt))
|
||||
// Simulate a concurrent request carrying the original unactivated snapshot.
|
||||
s.Require().NoError(s.repo.ActivateWindows(s.ctx, sub.ID, activatedAt.Add(time.Hour)))
|
||||
s.Require().NoError(s.repo.ActivateWindows(s.ctx, sub.ID, activatedAt.Add(time.Hour), activatedAt.Add(time.Hour)))
|
||||
|
||||
got, err := s.repo.GetByID(s.ctx, sub.ID)
|
||||
s.Require().NoError(err)
|
||||
s.Require().WithinDuration(manualResetAt, *got.DailyWindowStart, time.Microsecond)
|
||||
s.Require().WithinDuration(manualDailyStart, *got.DailyWindowStart, time.Microsecond)
|
||||
s.Require().WithinDuration(manualResetAt, *got.WeeklyWindowStart, time.Microsecond)
|
||||
s.Require().WithinDuration(manualResetAt, *got.MonthlyWindowStart, time.Microsecond)
|
||||
}
|
||||
@@ -535,7 +539,7 @@ func (s *UserSubscriptionRepoSuite) TestResetUsageWindows_ClearsUsageAfterAutoma
|
||||
newWindowStart := oldWindowStart.Add(24 * time.Hour)
|
||||
s.Require().NoError(s.repo.ResetDailyUsage(s.ctx, sub.ID, &oldWindowStart, newWindowStart))
|
||||
s.Require().NoError(s.repo.IncrementUsage(s.ctx, sub.ID, 3))
|
||||
s.Require().NoError(s.repo.ResetUsageWindows(s.ctx, sub.ID, true, false, false, newWindowStart))
|
||||
s.Require().NoError(s.repo.ResetUsageWindows(s.ctx, sub.ID, true, false, false, newWindowStart, newWindowStart))
|
||||
|
||||
got, err := s.repo.GetByID(s.ctx, sub.ID)
|
||||
s.Require().NoError(err)
|
||||
@@ -770,7 +774,7 @@ func (s *UserSubscriptionRepoSuite) TestActiveExpiredBoundaries_UsageAndReset_Ba
|
||||
s.Require().Equal(active.ID, got.ID, "expected active subscription")
|
||||
|
||||
activateAt := time.Now().Add(-25 * time.Hour)
|
||||
s.Require().NoError(s.repo.ActivateWindows(s.ctx, active.ID, activateAt), "ActivateWindows")
|
||||
s.Require().NoError(s.repo.ActivateWindows(s.ctx, active.ID, activateAt, activateAt), "ActivateWindows")
|
||||
s.Require().NoError(s.repo.IncrementUsage(s.ctx, active.ID, 1.25), "IncrementUsage")
|
||||
|
||||
after, err := s.repo.GetByID(s.ctx, active.ID)
|
||||
|
||||
@@ -150,6 +150,7 @@ var ProviderSet = wire.NewSet(
|
||||
// HTTP service ports (DI Strategy A: return interface directly)
|
||||
NewTurnstileVerifier,
|
||||
NewTencentCaptchaVerifier,
|
||||
NewAliyunCaptchaVerifier,
|
||||
ProvidePricingRemoteClient,
|
||||
ProvideGitHubReleaseClient,
|
||||
NewProxyExitInfoProber,
|
||||
|
||||
@@ -746,6 +746,13 @@ 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,
|
||||
"aliyun_captcha_scene_id": "",
|
||||
"aliyun_captcha_prefix": "",
|
||||
"aliyun_captcha_region": "cn",
|
||||
"linuxdo_connect_enabled": false,
|
||||
"linuxdo_connect_client_id": "",
|
||||
"linuxdo_connect_client_secret_configured": false,
|
||||
@@ -1079,6 +1086,13 @@ 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,
|
||||
"aliyun_captcha_scene_id": "",
|
||||
"aliyun_captcha_prefix": "",
|
||||
"aliyun_captcha_region": "cn",
|
||||
"linuxdo_connect_enabled": false,
|
||||
"linuxdo_connect_client_id": "",
|
||||
"linuxdo_connect_client_secret_configured": false,
|
||||
@@ -2233,10 +2247,10 @@ func (stubUserSubscriptionRepo) UpdateStatus(ctx context.Context, subscriptionID
|
||||
func (stubUserSubscriptionRepo) UpdateNotes(ctx context.Context, subscriptionID int64, notes string) error {
|
||||
return errors.New("not implemented")
|
||||
}
|
||||
func (stubUserSubscriptionRepo) ActivateWindows(ctx context.Context, id int64, start time.Time) error {
|
||||
func (stubUserSubscriptionRepo) ActivateWindows(ctx context.Context, id int64, dailyStart, periodicStart time.Time) error {
|
||||
return errors.New("not implemented")
|
||||
}
|
||||
func (stubUserSubscriptionRepo) ResetUsageWindows(ctx context.Context, id int64, resetDaily, resetWeekly, resetMonthly bool, newWindowStart time.Time) error {
|
||||
func (stubUserSubscriptionRepo) ResetUsageWindows(ctx context.Context, id int64, resetDaily, resetWeekly, resetMonthly bool, dailyStart, periodicStart time.Time) error {
|
||||
return errors.New("not implemented")
|
||||
}
|
||||
func (stubUserSubscriptionRepo) ResetDailyUsage(ctx context.Context, id int64, expectedWindowStart *time.Time, newWindowStart time.Time) error {
|
||||
|
||||
@@ -82,7 +82,7 @@ type fakeGoogleSubscriptionRepo struct {
|
||||
getByID func(ctx context.Context, id int64) (*service.UserSubscription, error)
|
||||
getActive func(ctx context.Context, userID, groupID int64) (*service.UserSubscription, error)
|
||||
updateStatus func(ctx context.Context, subscriptionID int64, status string) error
|
||||
activateWindow func(ctx context.Context, id int64, start time.Time) error
|
||||
activateWindow func(ctx context.Context, id int64, dailyStart, periodicStart time.Time) error
|
||||
resetDaily func(ctx context.Context, id int64, start time.Time) error
|
||||
resetWeekly func(ctx context.Context, id int64, start time.Time) error
|
||||
resetMonthly func(ctx context.Context, id int64, start time.Time) error
|
||||
@@ -231,13 +231,13 @@ func (f fakeGoogleSubscriptionRepo) UpdateStatus(ctx context.Context, subscripti
|
||||
func (f fakeGoogleSubscriptionRepo) UpdateNotes(ctx context.Context, subscriptionID int64, notes string) error {
|
||||
return errors.New("not implemented")
|
||||
}
|
||||
func (f fakeGoogleSubscriptionRepo) ActivateWindows(ctx context.Context, id int64, start time.Time) error {
|
||||
func (f fakeGoogleSubscriptionRepo) ActivateWindows(ctx context.Context, id int64, dailyStart, periodicStart time.Time) error {
|
||||
if f.activateWindow != nil {
|
||||
return f.activateWindow(ctx, id, start)
|
||||
return f.activateWindow(ctx, id, dailyStart, periodicStart)
|
||||
}
|
||||
return errors.New("not implemented")
|
||||
}
|
||||
func (f fakeGoogleSubscriptionRepo) ResetUsageWindows(context.Context, int64, bool, bool, bool, time.Time) error {
|
||||
func (f fakeGoogleSubscriptionRepo) ResetUsageWindows(context.Context, int64, bool, bool, bool, time.Time, time.Time) error {
|
||||
return errors.New("not implemented")
|
||||
}
|
||||
func (f fakeGoogleSubscriptionRepo) ResetDailyUsage(ctx context.Context, id int64, _ *time.Time, start time.Time) error {
|
||||
@@ -886,7 +886,7 @@ func TestApiKeyAuthWithSubscriptionGoogle_SubscriptionLimitExceededReturns429(t
|
||||
return &clone, nil
|
||||
},
|
||||
updateStatus: func(ctx context.Context, subscriptionID int64, status string) error { return nil },
|
||||
activateWindow: func(ctx context.Context, id int64, start time.Time) error { return nil },
|
||||
activateWindow: func(ctx context.Context, id int64, dailyStart, periodicStart time.Time) error { return nil },
|
||||
resetDaily: func(ctx context.Context, id int64, start time.Time) error { return nil },
|
||||
resetWeekly: func(ctx context.Context, id int64, start time.Time) error { return nil },
|
||||
resetMonthly: func(ctx context.Context, id int64, start time.Time) error { return nil },
|
||||
|
||||
@@ -119,7 +119,7 @@ func TestSimpleModeBypassesQuotaCheck(t *testing.T) {
|
||||
return &clone, nil
|
||||
},
|
||||
updateStatus: func(ctx context.Context, subscriptionID int64, status string) error { return nil },
|
||||
activateWindow: func(ctx context.Context, id int64, start time.Time) error { return nil },
|
||||
activateWindow: func(ctx context.Context, id int64, dailyStart, periodicStart time.Time) error { return nil },
|
||||
resetDaily: func(ctx context.Context, id int64, start time.Time) error {
|
||||
sub.DailyWindowStart = &start
|
||||
sub.DailyUsageUSD = 0
|
||||
@@ -252,7 +252,7 @@ func TestSimpleModeBypassesQuotaCheck(t *testing.T) {
|
||||
return &clone, nil
|
||||
},
|
||||
updateStatus: func(ctx context.Context, subscriptionID int64, status string) error { return nil },
|
||||
activateWindow: func(ctx context.Context, id int64, start time.Time) error { return nil },
|
||||
activateWindow: func(ctx context.Context, id int64, dailyStart, periodicStart time.Time) error { return nil },
|
||||
resetDaily: func(ctx context.Context, id int64, start time.Time) error { return nil },
|
||||
resetWeekly: func(ctx context.Context, id int64, start time.Time) error { return nil },
|
||||
resetMonthly: func(ctx context.Context, id int64, start time.Time) error { return nil },
|
||||
@@ -1631,7 +1631,7 @@ type stubUserSubscriptionRepo struct {
|
||||
getByID func(ctx context.Context, id int64) (*service.UserSubscription, error)
|
||||
getActive func(ctx context.Context, userID, groupID int64) (*service.UserSubscription, error)
|
||||
updateStatus func(ctx context.Context, subscriptionID int64, status string) error
|
||||
activateWindow func(ctx context.Context, id int64, start time.Time) error
|
||||
activateWindow func(ctx context.Context, id int64, dailyStart, periodicStart time.Time) error
|
||||
resetDaily func(ctx context.Context, id int64, start time.Time) error
|
||||
resetWeekly func(ctx context.Context, id int64, start time.Time) error
|
||||
resetMonthly func(ctx context.Context, id int64, start time.Time) error
|
||||
@@ -1753,14 +1753,14 @@ func (r *stubUserSubscriptionRepo) UpdateNotes(ctx context.Context, subscription
|
||||
return errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (r *stubUserSubscriptionRepo) ActivateWindows(ctx context.Context, id int64, start time.Time) error {
|
||||
func (r *stubUserSubscriptionRepo) ActivateWindows(ctx context.Context, id int64, dailyStart, periodicStart time.Time) error {
|
||||
if r.activateWindow != nil {
|
||||
return r.activateWindow(ctx, id, start)
|
||||
return r.activateWindow(ctx, id, dailyStart, periodicStart)
|
||||
}
|
||||
return errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (r *stubUserSubscriptionRepo) ResetUsageWindows(context.Context, int64, bool, bool, bool, time.Time) error {
|
||||
func (r *stubUserSubscriptionRepo) ResetUsageWindows(context.Context, int64, bool, bool, bool, time.Time, time.Time) error {
|
||||
return errors.New("not implemented")
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -620,6 +620,11 @@ func (a *Account) resolveModelMapping(rawMapping map[string]any) map[string]stri
|
||||
"gemini-3-flash",
|
||||
"gemini-3.1-pro-high",
|
||||
"gemini-3.1-pro-low",
|
||||
"gemini-3.6-flash",
|
||||
"gemini-3.6-flash-high",
|
||||
"gemini-3.6-flash-low",
|
||||
"gemini-3.6-flash-medium",
|
||||
"gemini-3.6-flash-tiered",
|
||||
})
|
||||
applyAntigravityGemini31ProAliases(result)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAliyunCaptchaVerificationFailed = infraerrors.BadRequest("ALIYUN_CAPTCHA_VERIFICATION_FAILED", "aliyun captcha verification failed")
|
||||
ErrAliyunCaptchaNotConfigured = infraerrors.ServiceUnavailable("ALIYUN_CAPTCHA_NOT_CONFIGURED", "aliyun captcha not configured")
|
||||
// ErrCaptchaInvalidCredentials 阿里云验证码凭证无效(仅后台保存校验时返回,公开接口错误码不变)
|
||||
ErrCaptchaInvalidCredentials = infraerrors.BadRequest("CAPTCHA_INVALID_CREDENTIALS", "invalid aliyun captcha credentials")
|
||||
)
|
||||
|
||||
// AliyunCaptchaCredentials 阿里云验证码 2.0 服务端校验所需的完整凭证
|
||||
type AliyunCaptchaCredentials struct {
|
||||
AccessKeyID string
|
||||
AccessKeySecret string
|
||||
SceneID string
|
||||
Endpoint string
|
||||
}
|
||||
|
||||
// AliyunCaptchaVerifyResult VerifyIntelligentCaptcha 的归一化结果
|
||||
type AliyunCaptchaVerifyResult struct {
|
||||
VerifyResult bool
|
||||
VerifyCode string // 阿里云细分结果码,仅用于日志
|
||||
}
|
||||
|
||||
// AliyunCaptchaAPIError 阿里云 OpenAPI 业务错误。
|
||||
// repository 层负责把 SDK 错误归一化为该类型,service 层不依赖 SDK 包。
|
||||
type AliyunCaptchaAPIError struct {
|
||||
Code string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *AliyunCaptchaAPIError) Error() string {
|
||||
return fmt.Sprintf("aliyun captcha api error: %s: %s", e.Code, e.Message)
|
||||
}
|
||||
|
||||
// AliyunCaptchaVerifier 调用阿里云验证码 2.0 服务端校验的端口
|
||||
type AliyunCaptchaVerifier interface {
|
||||
VerifyCaptcha(ctx context.Context, cred AliyunCaptchaCredentials, captchaVerifyParam string) (*AliyunCaptchaVerifyResult, error)
|
||||
}
|
||||
|
||||
const (
|
||||
// AliyunCaptchaRegionCN 中国内地;AliyunCaptchaRegionSGP 新加坡。
|
||||
// 该值同时下发给前端 AliyunCaptchaConfig.region,两端必须一致。
|
||||
AliyunCaptchaRegionCN = "cn"
|
||||
AliyunCaptchaRegionSGP = "sgp"
|
||||
|
||||
aliyunCaptchaEndpointCN = "captcha.cn-shanghai.aliyuncs.com"
|
||||
aliyunCaptchaEndpointSGP = "captcha.ap-southeast-1.aliyuncs.com"
|
||||
)
|
||||
|
||||
// aliyunCaptchaEndpoint 按后台配置的地域返回服务端接入点,未知值回退中国内地
|
||||
func aliyunCaptchaEndpoint(region string) string {
|
||||
if region == AliyunCaptchaRegionSGP {
|
||||
return aliyunCaptchaEndpointSGP
|
||||
}
|
||||
return aliyunCaptchaEndpointCN
|
||||
}
|
||||
|
||||
// normalizeAliyunCaptchaRegion 非法值一律视为中国内地
|
||||
func normalizeAliyunCaptchaRegion(value string) string {
|
||||
if value == AliyunCaptchaRegionSGP {
|
||||
return AliyunCaptchaRegionSGP
|
||||
}
|
||||
return AliyunCaptchaRegionCN
|
||||
}
|
||||
|
||||
// aliyunCredentialValidationParam 用于后台保存时探测凭证有效性的假验证参数
|
||||
const aliyunCredentialValidationParam = "sub2api-credential-validation"
|
||||
|
||||
// aliyunInvalidCredentialCodes 表示 AK/SK 本身无效的阿里云错误码;
|
||||
// 其余错误码(如 param 无效)说明签名已通过、凭证可用。
|
||||
var aliyunInvalidCredentialCodes = map[string]struct{}{
|
||||
"InvalidAccessKeyId.NotFound": {},
|
||||
"InvalidAccessKeyId.Inactive": {},
|
||||
"SignatureDoesNotMatch": {},
|
||||
"Forbidden.AccessKeyDisabled": {},
|
||||
"IncompleteSignature": {},
|
||||
"InvalidSecurityToken.Expired": {},
|
||||
}
|
||||
|
||||
// AliyunCaptchaService 阿里云验证码 2.0 服务端校验
|
||||
type AliyunCaptchaService struct {
|
||||
settingService *SettingService
|
||||
verifier AliyunCaptchaVerifier
|
||||
}
|
||||
|
||||
func NewAliyunCaptchaService(settingService *SettingService, verifier AliyunCaptchaVerifier) *AliyunCaptchaService {
|
||||
return &AliyunCaptchaService{settingService: settingService, verifier: verifier}
|
||||
}
|
||||
|
||||
func aliyunCaptchaCredentials(config AliyunCaptchaConfig) (AliyunCaptchaCredentials, bool) {
|
||||
cred := AliyunCaptchaCredentials{
|
||||
AccessKeyID: strings.TrimSpace(config.AccessKeyID),
|
||||
AccessKeySecret: strings.TrimSpace(config.AccessKeySecret),
|
||||
SceneID: strings.TrimSpace(config.SceneID),
|
||||
Endpoint: aliyunCaptchaEndpoint(config.Region),
|
||||
}
|
||||
if cred.AccessKeyID == "" || cred.AccessKeySecret == "" || cred.SceneID == "" {
|
||||
return AliyunCaptchaCredentials{}, false
|
||||
}
|
||||
return cred, true
|
||||
}
|
||||
|
||||
// VerifyParamWithConfig 校验阿里云验证码 2.0 的 captchaVerifyParam。
|
||||
// 调用异常时返回错误(fail-closed),与 Turnstile 网络错误行为对称。
|
||||
func (s *AliyunCaptchaService) VerifyParamWithConfig(ctx context.Context, config AliyunCaptchaConfig, captchaVerifyParam string) error {
|
||||
if s == nil || s.verifier == nil {
|
||||
return ErrAliyunCaptchaNotConfigured
|
||||
}
|
||||
cred, ok := aliyunCaptchaCredentials(config)
|
||||
if !ok {
|
||||
logger.LegacyPrintf("service.aliyun_captcha", "%s", "[AliyunCaptcha] credentials not configured")
|
||||
return ErrAliyunCaptchaNotConfigured
|
||||
}
|
||||
|
||||
if strings.TrimSpace(captchaVerifyParam) == "" {
|
||||
logger.LegacyPrintf("service.aliyun_captcha", "%s", "[AliyunCaptcha] captchaVerifyParam is empty")
|
||||
return ErrAliyunCaptchaVerificationFailed
|
||||
}
|
||||
|
||||
result, err := s.verifier.VerifyCaptcha(ctx, cred, captchaVerifyParam)
|
||||
if err != nil {
|
||||
logger.LegacyPrintf("service.aliyun_captcha", "[AliyunCaptcha] verify request failed: %v", err)
|
||||
return fmt.Errorf("%w: verifier request failed", ErrAliyunCaptchaVerificationFailed)
|
||||
}
|
||||
|
||||
if result == nil || !result.VerifyResult {
|
||||
if result != nil {
|
||||
logger.LegacyPrintf("service.aliyun_captcha", "[AliyunCaptcha] rejected, verify code: %s", result.VerifyCode)
|
||||
}
|
||||
return ErrAliyunCaptchaVerificationFailed
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateCredentials 用假验证参数探测阿里云 AK/SK 是否可用(后台保存设置时调用)。
|
||||
// 凭证类错误码返回 ErrCaptchaInvalidCredentials;正常响应(包括 param 无效导致的
|
||||
// VerifyResult=false)说明签名通过、凭证有效;其余错误原样返回给管理员排查。
|
||||
func (s *AliyunCaptchaService) ValidateCredentials(ctx context.Context, accessKeyID, accessKeySecret, sceneID, region string) error {
|
||||
if s.verifier == nil {
|
||||
return ErrAliyunCaptchaNotConfigured
|
||||
}
|
||||
cred := AliyunCaptchaCredentials{
|
||||
AccessKeyID: accessKeyID,
|
||||
AccessKeySecret: accessKeySecret,
|
||||
SceneID: sceneID,
|
||||
Endpoint: aliyunCaptchaEndpoint(region),
|
||||
}
|
||||
|
||||
_, err := s.verifier.VerifyCaptcha(ctx, cred, aliyunCredentialValidationParam)
|
||||
if err != nil {
|
||||
var apiErr *AliyunCaptchaAPIError
|
||||
if errors.As(err, &apiErr) {
|
||||
if _, invalid := aliyunInvalidCredentialCodes[apiErr.Code]; invalid {
|
||||
return ErrCaptchaInvalidCredentials
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("validate aliyun captcha credentials: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type aliyunVerifierSpy struct {
|
||||
called int
|
||||
lastCred AliyunCaptchaCredentials
|
||||
lastParam string
|
||||
result *AliyunCaptchaVerifyResult
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *aliyunVerifierSpy) VerifyCaptcha(_ context.Context, cred AliyunCaptchaCredentials, param string) (*AliyunCaptchaVerifyResult, error) {
|
||||
s.called++
|
||||
s.lastCred = cred
|
||||
s.lastParam = param
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
if s.result != nil {
|
||||
return s.result, nil
|
||||
}
|
||||
return &AliyunCaptchaVerifyResult{VerifyResult: true}, nil
|
||||
}
|
||||
|
||||
func aliyunEnabledSettings() map[string]string {
|
||||
return map[string]string{
|
||||
SettingKeyAliyunCaptchaEnabled: "true",
|
||||
SettingKeyAliyunCaptchaAccessKeyID: "ak-id",
|
||||
SettingKeyAliyunCaptchaAccessKeySecret: "ak-secret",
|
||||
SettingKeyAliyunCaptchaSceneID: "scene-1",
|
||||
SettingKeyAliyunCaptchaPrefix: "prefix-1",
|
||||
}
|
||||
}
|
||||
|
||||
func aliyunTestConfig() AliyunCaptchaConfig {
|
||||
return AliyunCaptchaConfig{
|
||||
Enabled: true,
|
||||
AccessKeyID: "ak-id",
|
||||
AccessKeySecret: "ak-secret",
|
||||
SceneID: "scene-1",
|
||||
Region: AliyunCaptchaRegionCN,
|
||||
}
|
||||
}
|
||||
|
||||
func newAliyunAuthServiceForTest(cfg *config.Config, settings map[string]string, aliyunSpy *aliyunVerifierSpy) *AuthService {
|
||||
settingService := NewSettingService(&settingPublicRepoStub{values: settings}, cfg)
|
||||
authService := NewAuthService(
|
||||
nil, // entClient
|
||||
nil, // userRepo
|
||||
nil, // redeemRepo
|
||||
nil, // refreshTokenCache
|
||||
cfg,
|
||||
settingService,
|
||||
nil, // emailService
|
||||
NewTurnstileService(settingService, &turnstileVerifierSpy{}),
|
||||
nil, // emailQueueService
|
||||
nil, // promoService
|
||||
nil, // defaultSubAssigner
|
||||
nil, // affiliateService
|
||||
nil, // userPlatformQuotaRepo
|
||||
)
|
||||
authService.SetAliyunCaptchaService(NewAliyunCaptchaService(settingService, aliyunSpy))
|
||||
return authService
|
||||
}
|
||||
|
||||
func TestAliyunCaptchaServiceVerifyParamDispatch(t *testing.T) {
|
||||
spy := &aliyunVerifierSpy{}
|
||||
svc := NewAliyunCaptchaService(nil, spy)
|
||||
|
||||
err := svc.VerifyParamWithConfig(context.Background(), aliyunTestConfig(), "captcha-verify-param")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, spy.called)
|
||||
require.Equal(t, "captcha-verify-param", spy.lastParam)
|
||||
require.Equal(t, "ak-id", spy.lastCred.AccessKeyID)
|
||||
require.Equal(t, "scene-1", spy.lastCred.SceneID)
|
||||
require.Equal(t, "captcha.cn-shanghai.aliyuncs.com", spy.lastCred.Endpoint)
|
||||
}
|
||||
|
||||
func TestAliyunCaptchaServiceSgpEndpoint(t *testing.T) {
|
||||
spy := &aliyunVerifierSpy{}
|
||||
svc := NewAliyunCaptchaService(nil, spy)
|
||||
cfg := aliyunTestConfig()
|
||||
cfg.Region = AliyunCaptchaRegionSGP
|
||||
|
||||
err := svc.VerifyParamWithConfig(context.Background(), cfg, "captcha-verify-param")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "captcha.ap-southeast-1.aliyuncs.com", spy.lastCred.Endpoint)
|
||||
}
|
||||
|
||||
func TestAliyunCaptchaServiceFailsClosedOnVerifierError(t *testing.T) {
|
||||
spy := &aliyunVerifierSpy{err: errors.New("network down")}
|
||||
svc := NewAliyunCaptchaService(nil, spy)
|
||||
|
||||
err := svc.VerifyParamWithConfig(context.Background(), aliyunTestConfig(), "captcha-verify-param")
|
||||
|
||||
require.ErrorIs(t, err, ErrAliyunCaptchaVerificationFailed)
|
||||
}
|
||||
|
||||
func TestAliyunCaptchaServiceRejectsVerifyResultFalse(t *testing.T) {
|
||||
spy := &aliyunVerifierSpy{result: &AliyunCaptchaVerifyResult{VerifyResult: false, VerifyCode: "F001"}}
|
||||
svc := NewAliyunCaptchaService(nil, spy)
|
||||
|
||||
err := svc.VerifyParamWithConfig(context.Background(), aliyunTestConfig(), "captcha-verify-param")
|
||||
|
||||
require.ErrorIs(t, err, ErrAliyunCaptchaVerificationFailed)
|
||||
}
|
||||
|
||||
func TestAliyunCaptchaServiceRejectsIncompleteCredentials(t *testing.T) {
|
||||
spy := &aliyunVerifierSpy{}
|
||||
svc := NewAliyunCaptchaService(nil, spy)
|
||||
cfg := aliyunTestConfig()
|
||||
cfg.AccessKeySecret = ""
|
||||
|
||||
err := svc.VerifyParamWithConfig(context.Background(), cfg, "captcha-verify-param")
|
||||
|
||||
require.ErrorIs(t, err, ErrAliyunCaptchaNotConfigured)
|
||||
require.Zero(t, spy.called)
|
||||
}
|
||||
|
||||
func TestAliyunCaptchaServiceRejectsEmptyParam(t *testing.T) {
|
||||
spy := &aliyunVerifierSpy{}
|
||||
svc := NewAliyunCaptchaService(nil, spy)
|
||||
|
||||
err := svc.VerifyParamWithConfig(context.Background(), aliyunTestConfig(), "")
|
||||
|
||||
require.ErrorIs(t, err, ErrAliyunCaptchaVerificationFailed)
|
||||
require.Zero(t, spy.called)
|
||||
}
|
||||
|
||||
func TestAliyunCaptchaServiceValidateCredentials(t *testing.T) {
|
||||
t.Run("invalid credential code", func(t *testing.T) {
|
||||
spy := &aliyunVerifierSpy{err: &AliyunCaptchaAPIError{Code: "SignatureDoesNotMatch", Message: "bad sk"}}
|
||||
svc := NewAliyunCaptchaService(nil, spy)
|
||||
|
||||
err := svc.ValidateCredentials(context.Background(), "id", "sk", "scene", "cn")
|
||||
require.ErrorIs(t, err, ErrCaptchaInvalidCredentials)
|
||||
})
|
||||
|
||||
t.Run("network error surfaces", func(t *testing.T) {
|
||||
spy := &aliyunVerifierSpy{err: errors.New("timeout")}
|
||||
svc := NewAliyunCaptchaService(nil, spy)
|
||||
|
||||
err := svc.ValidateCredentials(context.Background(), "id", "sk", "scene", "cn")
|
||||
require.Error(t, err)
|
||||
require.NotErrorIs(t, err, ErrCaptchaInvalidCredentials)
|
||||
})
|
||||
|
||||
t.Run("verify result false means credentials valid", func(t *testing.T) {
|
||||
spy := &aliyunVerifierSpy{result: &AliyunCaptchaVerifyResult{VerifyResult: false}}
|
||||
svc := NewAliyunCaptchaService(nil, spy)
|
||||
|
||||
err := svc.ValidateCredentials(context.Background(), "id", "sk", "scene", "sgp")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "captcha.ap-southeast-1.aliyuncs.com", spy.lastCred.Endpoint)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAuthServiceVerifyCaptchaDispatchesAliyun(t *testing.T) {
|
||||
spy := &aliyunVerifierSpy{}
|
||||
authService := newAliyunAuthServiceForTest(&config.Config{}, aliyunEnabledSettings(), spy)
|
||||
|
||||
// 阿里云 captchaVerifyParam 复用 turnstile_token 请求字段
|
||||
err := authService.VerifyCaptcha(context.Background(), CaptchaProof{TurnstileToken: "captcha-verify-param"}, "127.0.0.1")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, spy.called)
|
||||
require.Equal(t, "captcha-verify-param", spy.lastParam)
|
||||
}
|
||||
|
||||
func TestAuthServiceVerifyCaptchaRejectsProviderConflict(t *testing.T) {
|
||||
settings := aliyunEnabledSettings()
|
||||
settings[SettingKeyTurnstileEnabled] = "true"
|
||||
settings[SettingKeyTurnstileSecretKey] = "secret"
|
||||
spy := &aliyunVerifierSpy{}
|
||||
authService := newAliyunAuthServiceForTest(&config.Config{}, settings, spy)
|
||||
|
||||
err := authService.VerifyCaptcha(context.Background(), CaptchaProof{TurnstileToken: "param"}, "127.0.0.1")
|
||||
|
||||
require.ErrorIs(t, err, ErrCaptchaProviderConflict)
|
||||
require.Zero(t, spy.called)
|
||||
}
|
||||
|
||||
func TestAuthServiceVerifyCaptchaRequiredModeWithAliyun(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Server: config.ServerConfig{Mode: "release"},
|
||||
Turnstile: config.TurnstileConfig{Required: true},
|
||||
}
|
||||
spy := &aliyunVerifierSpy{}
|
||||
authService := newAliyunAuthServiceForTest(cfg, aliyunEnabledSettings(), spy)
|
||||
|
||||
// required 模式 + 阿里云启用且凭证齐全:不误报 NOT_CONFIGURED,正常走阿里云校验
|
||||
err := authService.VerifyCaptcha(context.Background(), CaptchaProof{TurnstileToken: "captcha-verify-param"}, "127.0.0.1")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, spy.called)
|
||||
}
|
||||
|
||||
func TestAuthServiceVerifyActionCaptchaIfEnabledDispatchesAliyun(t *testing.T) {
|
||||
spy := &aliyunVerifierSpy{}
|
||||
authService := newAliyunAuthServiceForTest(&config.Config{}, aliyunEnabledSettings(), spy)
|
||||
|
||||
err := authService.VerifyActionCaptchaIfEnabled(context.Background(), CaptchaProof{TurnstileToken: "captcha-verify-param"}, "127.0.0.1")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, spy.called)
|
||||
require.Equal(t, "captcha-verify-param", spy.lastParam)
|
||||
}
|
||||
|
||||
func TestAuthServiceVerifyActionCaptchaIfEnabledSkipsWhenOnlyTurnstile(t *testing.T) {
|
||||
spy := &aliyunVerifierSpy{}
|
||||
authService := newAliyunAuthServiceForTest(&config.Config{}, map[string]string{
|
||||
SettingKeyTurnstileEnabled: "true",
|
||||
SettingKeyTurnstileSecretKey: "secret",
|
||||
}, spy)
|
||||
|
||||
// Turnstile 不扩大既有覆盖:扩展入口不拦截
|
||||
err := authService.VerifyActionCaptchaIfEnabled(context.Background(), CaptchaProof{}, "127.0.0.1")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Zero(t, spy.called)
|
||||
}
|
||||
@@ -76,6 +76,7 @@ type AuthService struct {
|
||||
emailService *EmailService
|
||||
turnstileService *TurnstileService
|
||||
tencentCaptchaService *TencentCaptchaService
|
||||
aliyunCaptchaService *AliyunCaptchaService
|
||||
emailQueueService *EmailQueueService
|
||||
promoService *PromoService
|
||||
affiliateService *AffiliateService
|
||||
@@ -84,6 +85,7 @@ type AuthService struct {
|
||||
}
|
||||
|
||||
type CaptchaProof struct {
|
||||
// TurnstileToken 承载 Cloudflare Turnstile token;阿里云验证码复用该字段承载 captchaVerifyParam
|
||||
TurnstileToken string
|
||||
TencentTicket string
|
||||
TencentRandstr string
|
||||
@@ -144,6 +146,10 @@ func (s *AuthService) SetTencentCaptchaService(tencentCaptchaService *TencentCap
|
||||
s.tencentCaptchaService = tencentCaptchaService
|
||||
}
|
||||
|
||||
func (s *AuthService) SetAliyunCaptchaService(aliyunCaptchaService *AliyunCaptchaService) {
|
||||
s.aliyunCaptchaService = aliyunCaptchaService
|
||||
}
|
||||
|
||||
// Register 用户注册,返回token和用户
|
||||
func (s *AuthService) Register(ctx context.Context, email, password string) (string, *User, error) {
|
||||
return s.RegisterWithVerification(ctx, email, password, "", "", "", "")
|
||||
@@ -412,7 +418,8 @@ func (s *AuthService) VerifyCaptcha(ctx context.Context, proof CaptchaProof, rem
|
||||
}
|
||||
turnstileEnabled := providerConfig.TurnstileEnabled
|
||||
tencentEnabled := providerConfig.Tencent.Enabled
|
||||
if turnstileEnabled && tencentEnabled {
|
||||
aliyunEnabled := providerConfig.Aliyun.Enabled
|
||||
if captchaProvidersConflict(turnstileEnabled, tencentEnabled, aliyunEnabled) {
|
||||
return ErrCaptchaProviderConflict
|
||||
}
|
||||
if tencentEnabled {
|
||||
@@ -421,6 +428,12 @@ func (s *AuthService) VerifyCaptcha(ctx context.Context, proof CaptchaProof, rem
|
||||
}
|
||||
return s.tencentCaptchaService.VerifyTicketWithConfig(ctx, providerConfig.Tencent, proof.TencentTicket, proof.TencentRandstr, remoteIP)
|
||||
}
|
||||
if aliyunEnabled {
|
||||
if s.aliyunCaptchaService == nil {
|
||||
return ErrAliyunCaptchaNotConfigured
|
||||
}
|
||||
return s.aliyunCaptchaService.VerifyParamWithConfig(ctx, providerConfig.Aliyun, proof.TurnstileToken)
|
||||
}
|
||||
if turnstileEnabled {
|
||||
if s.turnstileService == nil || strings.TrimSpace(providerConfig.TurnstileSecretKey) == "" {
|
||||
return ErrTurnstileNotConfigured
|
||||
@@ -433,9 +446,20 @@ func (s *AuthService) VerifyCaptcha(ctx context.Context, proof CaptchaProof, rem
|
||||
return nil
|
||||
}
|
||||
|
||||
// VerifyTencentCaptchaIfEnabled 仅保护新增的腾讯验证码动作入口,
|
||||
// 不扩大 Cloudflare Turnstile 的既有覆盖范围。
|
||||
func (s *AuthService) VerifyTencentCaptchaIfEnabled(ctx context.Context, proof CaptchaProof, remoteIP string) error {
|
||||
// captchaProvidersConflict 同一时间仅允许启用一家人机验证服务商
|
||||
func captchaProvidersConflict(enabled ...bool) bool {
|
||||
count := 0
|
||||
for _, e := range enabled {
|
||||
if e {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count > 1
|
||||
}
|
||||
|
||||
// VerifyActionCaptchaIfEnabled 仅保护动作触发的扩展入口(OAuth 登录启动、passkey 登录),
|
||||
// 腾讯天御与阿里云验证码启用时拦截;不扩大 Cloudflare Turnstile 的既有覆盖范围。
|
||||
func (s *AuthService) VerifyActionCaptchaIfEnabled(ctx context.Context, proof CaptchaProof, remoteIP string) error {
|
||||
if s == nil || s.settingService == nil {
|
||||
return ErrServiceUnavailable
|
||||
}
|
||||
@@ -445,12 +469,20 @@ func (s *AuthService) VerifyTencentCaptchaIfEnabled(ctx context.Context, proof C
|
||||
logger.LegacyPrintf("service.auth", "%s", "[Auth] Failed to read captcha provider settings")
|
||||
return ErrServiceUnavailable
|
||||
}
|
||||
if !providerConfig.Tencent.Enabled {
|
||||
tencentEnabled := providerConfig.Tencent.Enabled
|
||||
aliyunEnabled := providerConfig.Aliyun.Enabled
|
||||
if !tencentEnabled && !aliyunEnabled {
|
||||
return nil
|
||||
}
|
||||
if providerConfig.TurnstileEnabled {
|
||||
if captchaProvidersConflict(providerConfig.TurnstileEnabled, tencentEnabled, aliyunEnabled) {
|
||||
return ErrCaptchaProviderConflict
|
||||
}
|
||||
if aliyunEnabled {
|
||||
if s.aliyunCaptchaService == nil {
|
||||
return ErrAliyunCaptchaNotConfigured
|
||||
}
|
||||
return s.aliyunCaptchaService.VerifyParamWithConfig(ctx, providerConfig.Aliyun, proof.TurnstileToken)
|
||||
}
|
||||
if s.tencentCaptchaService == nil {
|
||||
return ErrTencentCaptchaNotConfigured
|
||||
}
|
||||
|
||||
@@ -150,11 +150,11 @@ func TestVerifyCaptchaRejectsEnabledTencentProviderWithIncompleteCredentials(t *
|
||||
require.Zero(t, verifier.calls)
|
||||
}
|
||||
|
||||
func TestVerifyTencentCaptchaIfEnabledVerifiesTencentProof(t *testing.T) {
|
||||
func TestVerifyActionCaptchaIfEnabledVerifiesTencentProof(t *testing.T) {
|
||||
verifier := &tencentCaptchaVerifierStub{response: &TencentCaptchaVerifyResponse{CaptchaCode: 1}}
|
||||
svc := newAuthServiceForCaptchaTest(tencentCaptchaSettings(), false, nil, verifier)
|
||||
|
||||
err := svc.VerifyTencentCaptchaIfEnabled(context.Background(), CaptchaProof{
|
||||
err := svc.VerifyActionCaptchaIfEnabled(context.Background(), CaptchaProof{
|
||||
TencentTicket: "ticket",
|
||||
TencentRandstr: "@rand",
|
||||
}, "203.0.113.10")
|
||||
@@ -164,7 +164,7 @@ func TestVerifyTencentCaptchaIfEnabledVerifiesTencentProof(t *testing.T) {
|
||||
require.Equal(t, TencentCaptchaProof{Ticket: "ticket", Randstr: "@rand"}, verifier.proof)
|
||||
}
|
||||
|
||||
func TestVerifyTencentCaptchaIfEnabledDoesNotExpandTurnstileCoverage(t *testing.T) {
|
||||
func TestVerifyActionCaptchaIfEnabledDoesNotExpandTurnstileCoverage(t *testing.T) {
|
||||
settings := map[string]string{
|
||||
SettingKeyTurnstileEnabled: "true",
|
||||
SettingKeyTurnstileSecretKey: "turnstile-secret",
|
||||
@@ -172,17 +172,17 @@ func TestVerifyTencentCaptchaIfEnabledDoesNotExpandTurnstileCoverage(t *testing.
|
||||
turnstileVerifier := &turnstileVerifierSpy{}
|
||||
svc := newAuthServiceForCaptchaTest(settings, false, turnstileVerifier, nil)
|
||||
|
||||
err := svc.VerifyTencentCaptchaIfEnabled(context.Background(), CaptchaProof{}, "203.0.113.10")
|
||||
err := svc.VerifyActionCaptchaIfEnabled(context.Background(), CaptchaProof{}, "203.0.113.10")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Zero(t, turnstileVerifier.called)
|
||||
}
|
||||
|
||||
func TestVerifyTencentCaptchaIfEnabledFailsClosedOnSettingReadError(t *testing.T) {
|
||||
func TestVerifyActionCaptchaIfEnabledFailsClosedOnSettingReadError(t *testing.T) {
|
||||
repo := &settingRepoStub{err: errors.New("settings unavailable")}
|
||||
svc := newAuthServiceForCaptchaRepoTest(repo, false, &turnstileVerifierSpy{}, &tencentCaptchaVerifierStub{})
|
||||
|
||||
err := svc.VerifyTencentCaptchaIfEnabled(context.Background(), CaptchaProof{}, "203.0.113.10")
|
||||
err := svc.VerifyActionCaptchaIfEnabled(context.Background(), CaptchaProof{}, "203.0.113.10")
|
||||
|
||||
require.ErrorIs(t, err, ErrServiceUnavailable)
|
||||
}
|
||||
|
||||
@@ -28,7 +28,8 @@ type PlazaModel struct {
|
||||
// PlazaGroup 模型广场中以分组为顶层的条目。
|
||||
//
|
||||
// 与 AvailableGroupRef 相比多了 Description 与 Models;Models 来自该分组关联渠道的
|
||||
// 支持模型(按分组平台隔离,防跨平台泄漏),与「可用渠道」页口径一致。
|
||||
// 支持模型(普通分组按分组平台隔离,Composite 分组展开关联渠道已配置的
|
||||
// 具体平台),与「可用渠道」页口径一致。
|
||||
type PlazaGroup struct {
|
||||
ID int64
|
||||
Name string
|
||||
@@ -99,8 +100,12 @@ func (s *ChannelService) ListPlazaGroups(ctx context.Context) ([]PlazaGroup, err
|
||||
order = append(order, g.ID)
|
||||
}
|
||||
|
||||
// modelIdx[groupID][modelName] = index into byGroup[groupID].Models
|
||||
modelIdx := make(map[int64]map[string]int, len(groups))
|
||||
type modelKey struct {
|
||||
platform string
|
||||
name string
|
||||
}
|
||||
// modelIdx[groupID][platform+modelName] = index into byGroup[groupID].Models
|
||||
modelIdx := make(map[int64]map[modelKey]int, len(groups))
|
||||
for i := range channels {
|
||||
ch := &channels[i]
|
||||
if ch.Status != StatusActive {
|
||||
@@ -117,23 +122,28 @@ func (s *ChannelService) ListPlazaGroups(ctx context.Context) ([]PlazaGroup, err
|
||||
}
|
||||
idx := modelIdx[gid]
|
||||
if idx == nil {
|
||||
idx = make(map[string]int, len(supported))
|
||||
idx = make(map[modelKey]int, len(supported))
|
||||
modelIdx[gid] = idx
|
||||
}
|
||||
for j := range supported {
|
||||
m := supported[j]
|
||||
if m.Platform != pg.Platform {
|
||||
if pg.Platform == PlatformComposite {
|
||||
if !isConcreteRequestPlatform(m.Platform) {
|
||||
continue
|
||||
}
|
||||
} else if m.Platform != pg.Platform {
|
||||
continue
|
||||
}
|
||||
pricing := plazaImageDisplayPricing(m.Pricing, groupEnt[gid])
|
||||
if at, seen := idx[m.Name]; seen {
|
||||
key := modelKey{platform: m.Platform, name: m.Name}
|
||||
if at, seen := idx[key]; seen {
|
||||
// 先见者胜;仅当已存条目无定价而新条目有定价时升级。
|
||||
if pg.Models[at].Pricing == nil && pricing != nil {
|
||||
pg.Models[at].Pricing = pricing
|
||||
}
|
||||
continue
|
||||
}
|
||||
idx[m.Name] = len(pg.Models)
|
||||
idx[key] = len(pg.Models)
|
||||
pg.Models = append(pg.Models, PlazaModel{
|
||||
Name: m.Name,
|
||||
Platform: m.Platform,
|
||||
@@ -150,7 +160,12 @@ func (s *ChannelService) ListPlazaGroups(ctx context.Context) ([]PlazaGroup, err
|
||||
if len(pg.Models) == 0 {
|
||||
continue
|
||||
}
|
||||
sort.SliceStable(pg.Models, func(i, j int) bool { return pg.Models[i].Name < pg.Models[j].Name })
|
||||
sort.SliceStable(pg.Models, func(i, j int) bool {
|
||||
if pg.Models[i].Name != pg.Models[j].Name {
|
||||
return pg.Models[i].Name < pg.Models[j].Name
|
||||
}
|
||||
return pg.Models[i].Platform < pg.Models[j].Platform
|
||||
})
|
||||
for j := range pg.Models {
|
||||
pg.Models[j].OfficialPricing = s.lookupOfficialPricing(pg.Models[j].Name, officialMemo)
|
||||
}
|
||||
|
||||
@@ -107,6 +107,68 @@ func TestListPlazaGroups_PlatformIsolation(t *testing.T) {
|
||||
require.Equal(t, "gpt-5", byName["g-gpt"][0].Name)
|
||||
}
|
||||
|
||||
func TestListPlazaGroups_CompositeIncludesConfiguredConcretePlatforms(t *testing.T) {
|
||||
anthropicPrice := 3e-6
|
||||
openAIPrice := 2e-6
|
||||
ch := Channel{
|
||||
ID: 1, Name: "multi", Status: StatusActive, GroupIDs: []int64{10},
|
||||
ModelPricing: []ChannelModelPricing{
|
||||
{Platform: PlatformAnthropic, Models: []string{"shared-model"}, InputPrice: &anthropicPrice},
|
||||
{Platform: PlatformOpenAI, Models: []string{"shared-model"}, InputPrice: &openAIPrice},
|
||||
{Platform: "", Models: []string{"empty-platform"}},
|
||||
{Platform: PlatformComposite, Models: []string{"nested-composite"}},
|
||||
{Platform: "unknown-platform", Models: []string{"unknown-platform"}},
|
||||
},
|
||||
}
|
||||
groups := []Group{{ID: 10, Name: "composite", Platform: PlatformComposite, RateMultiplier: 1}}
|
||||
|
||||
out, err := newPlazaChannelService([]Channel{ch}, groups, nil).ListPlazaGroups(context.Background())
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out, 1)
|
||||
require.Len(t, out[0].Models, 2, "only concrete platforms are included and same-named models remain distinct")
|
||||
require.Equal(t, PlatformAnthropic, out[0].Models[0].Platform)
|
||||
require.Equal(t, PlatformOpenAI, out[0].Models[1].Platform)
|
||||
require.InDelta(t, anthropicPrice, *out[0].Models[0].Pricing.InputPrice, 1e-12)
|
||||
require.InDelta(t, openAIPrice, *out[0].Models[1].Pricing.InputPrice, 1e-12)
|
||||
}
|
||||
|
||||
func TestListPlazaGroups_CompositeAndOrdinaryGroupsDoNotLeakPlatforms(t *testing.T) {
|
||||
ch := Channel{
|
||||
ID: 1, Name: "multi", Status: StatusActive, GroupIDs: []int64{10, 20},
|
||||
ModelPricing: []ChannelModelPricing{
|
||||
{Platform: PlatformAnthropic, Models: []string{"claude-sonnet"}, InputPrice: testPtrFloat64(3e-6)},
|
||||
{Platform: PlatformOpenAI, Models: []string{"gpt-5"}, InputPrice: testPtrFloat64(2e-6)},
|
||||
},
|
||||
}
|
||||
groups := []Group{
|
||||
{ID: 10, Name: "anthropic-only", Platform: PlatformAnthropic, RateMultiplier: 1},
|
||||
{ID: 20, Name: "composite", Platform: PlatformComposite, RateMultiplier: 1},
|
||||
}
|
||||
|
||||
out, err := newPlazaChannelService([]Channel{ch}, groups, nil).ListPlazaGroups(context.Background())
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out, 2)
|
||||
byName := map[string]PlazaGroup{}
|
||||
for _, group := range out {
|
||||
byName[group.Name] = group
|
||||
}
|
||||
require.Len(t, byName["anthropic-only"].Models, 1)
|
||||
require.Equal(t, []PlazaModel{{
|
||||
Name: "claude-sonnet", Platform: PlatformAnthropic, Pricing: byName["anthropic-only"].Models[0].Pricing,
|
||||
}}, byName["anthropic-only"].Models)
|
||||
require.Len(t, byName["composite"].Models, 2)
|
||||
require.Equal(t, []string{"claude-sonnet", "gpt-5"}, []string{
|
||||
byName["composite"].Models[0].Name,
|
||||
byName["composite"].Models[1].Name,
|
||||
})
|
||||
require.Equal(t, []string{PlatformAnthropic, PlatformOpenAI}, []string{
|
||||
byName["composite"].Models[0].Platform,
|
||||
byName["composite"].Models[1].Platform,
|
||||
})
|
||||
}
|
||||
|
||||
func TestListPlazaGroups_InactiveChannelSkipped(t *testing.T) {
|
||||
inactive := plazaPricedChannel(1, "off", []int64{10}, "anthropic", "claude-sonnet")
|
||||
inactive.Status = "inactive"
|
||||
|
||||
@@ -170,6 +170,15 @@ 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" // 是否启用阿里云验证码
|
||||
SettingKeyAliyunCaptchaAccessKeyID = "aliyun_captcha_access_key_id" // 阿里云 AccessKey ID
|
||||
SettingKeyAliyunCaptchaAccessKeySecret = "aliyun_captcha_access_key_secret" // 阿里云 AccessKey Secret
|
||||
SettingKeyAliyunCaptchaSceneID = "aliyun_captcha_scene_id" // 验证场景 ID(所有认证流程共用)
|
||||
SettingKeyAliyunCaptchaPrefix = "aliyun_captcha_prefix" // 身份标,前端 SDK 初始化用
|
||||
SettingKeyAliyunCaptchaRegion = "aliyun_captcha_region" // 地域:"cn"|"sgp",决定前端脚本区域与服务端接入点
|
||||
|
||||
// API Key IP 访问控制设置
|
||||
SettingKeyAPIKeyACLTrustForwardedIP = "api_key_acl_trust_forwarded_ip" // API Key IP 白/黑名单是否信任转发 IP
|
||||
|
||||
@@ -816,7 +816,7 @@ func extractGrokMediaVideoRequestID(body []byte) string {
|
||||
if len(body) == 0 || !gjson.ValidBytes(body) {
|
||||
return ""
|
||||
}
|
||||
for _, path := range []string{"request_id", "id", "data.request_id", "data.id", "video.request_id", "video.id"} {
|
||||
for _, path := range []string{"request_id", "id", "data.request_id", "data.id", "video.request_id", "video.id", "task_id", "data.task_id", "video.task_id"} {
|
||||
if id := strings.TrimSpace(gjson.GetBytes(body, path).String()); id != "" {
|
||||
return id
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -345,12 +345,26 @@ func isOpenAIOAuthInputTokensUnsupported(statusCode int, body []byte) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// OAuth's platform endpoint can be blocked by an upstream proxy before it
|
||||
// reaches the API and return an HTML 403 page without a structured error.
|
||||
// Treat that endpoint-level response like the other unsupported cases so
|
||||
// count_tokens remains a local, non-health-affecting convenience request.
|
||||
if statusCode == http.StatusForbidden && isHTMLResponse(body) {
|
||||
return true
|
||||
}
|
||||
|
||||
return strings.Contains(msg, "input_tokens") &&
|
||||
(strings.Contains(msg, "not found") ||
|
||||
strings.Contains(msg, "not supported") ||
|
||||
strings.Contains(msg, "unsupported"))
|
||||
}
|
||||
|
||||
func isHTMLResponse(body []byte) bool {
|
||||
trimmed := strings.TrimSpace(strings.ToLower(string(body)))
|
||||
return strings.HasPrefix(trimmed, "<!doctype html") ||
|
||||
strings.HasPrefix(trimmed, "<html")
|
||||
}
|
||||
|
||||
func estimateOpenAIInputTokens(req openAIInputTokensCountRequest) (int, error) {
|
||||
codec, err := openAIInputTokensCodecForModel(req.Model)
|
||||
if err != nil {
|
||||
|
||||
@@ -123,6 +123,11 @@ func TestOpenAIGatewayService_ForwardCountTokensAsAnthropic_OAuthFallsBackWhenPl
|
||||
statusCode: http.StatusForbidden,
|
||||
body: `{"error":{"type":"invalid_request_error","code":"missing_scope","message":"Missing scopes: api.responses.write"}}`,
|
||||
},
|
||||
{
|
||||
name: "403_html_proxy_page",
|
||||
statusCode: http.StatusForbidden,
|
||||
body: "<!doctype html><html><body>Forbidden</body></html>",
|
||||
},
|
||||
{
|
||||
name: "404_input_tokens_unsupported",
|
||||
statusCode: http.StatusNotFound,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1216,6 +1216,51 @@ func TestForwardGrokMediaVideoGenerationReturnsUsageAndResponseID(t *testing.T)
|
||||
require.Equal(t, 10, result.VideoDurationSeconds)
|
||||
}
|
||||
|
||||
func TestForwardGrokMediaVideoGenerationReturnsTaskIDAsResponseID(t *testing.T) {
|
||||
t.Setenv(xai.EnvAllowUnsafeURLOverrides, "true")
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
body := []byte(`{"model":"grok-imagine-video","prompt":"waves"}`)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/videos/generations", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
account := &Account{
|
||||
ID: 63,
|
||||
Name: "grok",
|
||||
Platform: PlatformGrok,
|
||||
Type: AccountTypeAPIKey,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"api_key": "api-key",
|
||||
"base_url": "https://xai.test/v1",
|
||||
},
|
||||
}
|
||||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"task_id":"video-task-123"}`)),
|
||||
}}
|
||||
svc := &OpenAIGatewayService{httpUpstream: upstream}
|
||||
|
||||
result, err := svc.ForwardGrokMedia(context.Background(), c, account, GrokMediaEndpointVideosGenerations, "", body, "application/json")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "video-task-123", result.ResponseID)
|
||||
}
|
||||
|
||||
func TestExtractGrokMediaVideoRequestIDPreservesExistingPrecedence(t *testing.T) {
|
||||
body := []byte(`{
|
||||
"request_id":"request-id",
|
||||
"id":"id",
|
||||
"task_id":"task-id",
|
||||
"data":{"request_id":"data-request-id","id":"data-id","task_id":"data-task-id"},
|
||||
"video":{"request_id":"video-request-id","id":"video-id","task_id":"video-task-id"}
|
||||
}`)
|
||||
|
||||
require.Equal(t, "request-id", extractGrokMediaVideoRequestID(body))
|
||||
}
|
||||
|
||||
func TestForwardGrokMediaVideoGenerationPreservesImageToVideoModel(t *testing.T) {
|
||||
t.Setenv(xai.EnvAllowUnsafeURLOverrides, "true")
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
|
||||
@@ -551,13 +551,22 @@ func TestNormalizeOpenAIWSPayloadWithoutInputAndPreviousResponseID(t *testing.T)
|
||||
t.Parallel()
|
||||
|
||||
normalized, err := normalizeOpenAIWSPayloadWithoutInputAndPreviousResponseID(
|
||||
[]byte(`{"model":"gpt-5.1","input":[1],"previous_response_id":"resp_x","metadata":{"b":2,"a":1}}`),
|
||||
[]byte(`{"model":"gpt-5.1","input":[1],"previous_response_id":"resp_x","client_metadata":{"request_start_ms":"1"},"stream_options":{"include_usage":true},"generate":false,"metadata":{"b":2,"a":1}}`),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.False(t, gjson.GetBytes(normalized, "input").Exists())
|
||||
require.False(t, gjson.GetBytes(normalized, "previous_response_id").Exists())
|
||||
require.False(t, gjson.GetBytes(normalized, "client_metadata").Exists())
|
||||
require.False(t, gjson.GetBytes(normalized, "stream_options").Exists())
|
||||
require.False(t, gjson.GetBytes(normalized, "generate").Exists())
|
||||
require.Equal(t, float64(1), gjson.GetBytes(normalized, "metadata.a").Float())
|
||||
|
||||
normalized, err = normalizeOpenAIWSPayloadWithoutInputAndPreviousResponseID(
|
||||
[]byte(`{"model":"gpt-5.1","generate":true}`),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.True(t, gjson.GetBytes(normalized, "generate").Bool())
|
||||
|
||||
_, err = normalizeOpenAIWSPayloadWithoutInputAndPreviousResponseID(nil)
|
||||
require.Error(t, err)
|
||||
|
||||
@@ -662,6 +671,36 @@ func TestShouldKeepIngressPreviousResponseID(t *testing.T) {
|
||||
require.Equal(t, "strict_incremental_ok", reason)
|
||||
})
|
||||
|
||||
t.Run("codex_prewarm_to_business_keep", func(t *testing.T) {
|
||||
prewarmPayload := []byte(`{
|
||||
"type":"response.create",
|
||||
"model":"gpt-5.1",
|
||||
"store":false,
|
||||
"generate":false,
|
||||
"client_metadata":{"x-codex-ws-stream-request-start-ms":"100"},
|
||||
"stream_options":{"include_usage":true},
|
||||
"input":[{"type":"input_text","text":"hello"}]
|
||||
}`)
|
||||
businessPayload := []byte(`{
|
||||
"type":"response.create",
|
||||
"model":"gpt-5.1",
|
||||
"store":false,
|
||||
"client_metadata":{"x-codex-ws-stream-request-start-ms":"200"},
|
||||
"previous_response_id":"resp_prewarm",
|
||||
"input":[{"type":"input_text","text":"hello"}]
|
||||
}`)
|
||||
|
||||
keep, reason, err := shouldKeepIngressPreviousResponseID(
|
||||
prewarmPayload,
|
||||
businessPayload,
|
||||
"resp_prewarm",
|
||||
false,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.True(t, keep)
|
||||
require.Equal(t, "strict_incremental_ok", reason)
|
||||
})
|
||||
|
||||
t.Run("missing_previous_response_id", func(t *testing.T) {
|
||||
payload := []byte(`{"type":"response.create","model":"gpt-5.1","input":[]}`)
|
||||
keep, reason, err := shouldKeepIngressPreviousResponseID(previousPayload, payload, "resp_turn_1", false)
|
||||
|
||||
@@ -449,6 +449,17 @@ func normalizeOpenAIWSPayloadWithoutInputAndPreviousResponseID(payload []byte) (
|
||||
}
|
||||
delete(decoded, "input")
|
||||
delete(decoded, "previous_response_id")
|
||||
// Codex changes transport-only metadata for every response.create. These fields
|
||||
// do not alter the context referenced by previous_response_id and are excluded
|
||||
// from Codex's own websocket reuse comparison.
|
||||
delete(decoded, "client_metadata")
|
||||
delete(decoded, "stream_options")
|
||||
// Official Codex prewarms a connection with generate=false, then omits the
|
||||
// field on the business request that continues from the prewarm response.
|
||||
// Only normalize false so a meaningful generate=true change remains visible.
|
||||
if generate, ok := decoded["generate"].(bool); ok && !generate {
|
||||
delete(decoded, "generate")
|
||||
}
|
||||
return json.Marshal(decoded)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,12 +463,24 @@ type TencentCaptchaConfig struct {
|
||||
AppSecretKey string
|
||||
CloudSecretID string
|
||||
CloudSecretKey string
|
||||
Region string
|
||||
}
|
||||
|
||||
// AliyunCaptchaConfig contains the credentials required by Aliyun Captcha 2.0's
|
||||
// server-side verification API. It must never be returned by a public handler.
|
||||
type AliyunCaptchaConfig struct {
|
||||
Enabled bool
|
||||
AccessKeyID string
|
||||
AccessKeySecret string
|
||||
SceneID string
|
||||
Region string
|
||||
}
|
||||
|
||||
type CaptchaProviderConfig struct {
|
||||
TurnstileEnabled bool
|
||||
TurnstileSecretKey string
|
||||
Tencent TencentCaptchaConfig
|
||||
Aliyun AliyunCaptchaConfig
|
||||
}
|
||||
|
||||
func (s *SettingService) GetCaptchaProviderConfig(ctx context.Context) (CaptchaProviderConfig, error) {
|
||||
@@ -480,6 +492,12 @@ func (s *SettingService) GetCaptchaProviderConfig(ctx context.Context) (CaptchaP
|
||||
SettingKeyTencentCaptchaAppSecretKey,
|
||||
SettingKeyTencentCaptchaCloudSecretID,
|
||||
SettingKeyTencentCaptchaCloudSecretKey,
|
||||
SettingKeyTencentCaptchaRegion,
|
||||
SettingKeyAliyunCaptchaEnabled,
|
||||
SettingKeyAliyunCaptchaAccessKeyID,
|
||||
SettingKeyAliyunCaptchaAccessKeySecret,
|
||||
SettingKeyAliyunCaptchaSceneID,
|
||||
SettingKeyAliyunCaptchaRegion,
|
||||
})
|
||||
if err != nil {
|
||||
return CaptchaProviderConfig{}, fmt.Errorf("read captcha provider settings: %w", err)
|
||||
@@ -493,6 +511,14 @@ 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",
|
||||
AccessKeyID: values[SettingKeyAliyunCaptchaAccessKeyID],
|
||||
AccessKeySecret: values[SettingKeyAliyunCaptchaAccessKeySecret],
|
||||
SceneID: values[SettingKeyAliyunCaptchaSceneID],
|
||||
Region: normalizeAliyunCaptchaRegion(values[SettingKeyAliyunCaptchaRegion]),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -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,13 @@ 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] != "",
|
||||
AliyunCaptchaSceneID: settings[SettingKeyAliyunCaptchaSceneID],
|
||||
AliyunCaptchaPrefix: settings[SettingKeyAliyunCaptchaPrefix],
|
||||
AliyunCaptchaRegion: normalizeAliyunCaptchaRegion(settings[SettingKeyAliyunCaptchaRegion]),
|
||||
APIKeyACLTrustForwardedIP: apiKeyACLTrustForwardedIP,
|
||||
ForwardedClientIPHeaders: forwardedClientIPHeaders,
|
||||
SiteName: s.getStringOrDefault(settings, SettingKeySiteName, "Sub2API"),
|
||||
@@ -403,6 +410,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
|
||||
result.TencentCaptchaAppSecretKey = settings[SettingKeyTencentCaptchaAppSecretKey]
|
||||
result.TencentCaptchaCloudSecretID = settings[SettingKeyTencentCaptchaCloudSecretID]
|
||||
result.TencentCaptchaCloudSecretKey = settings[SettingKeyTencentCaptchaCloudSecretKey]
|
||||
result.AliyunCaptchaAccessKeySecret = settings[SettingKeyAliyunCaptchaAccessKeySecret]
|
||||
|
||||
// LinuxDo Connect 设置:
|
||||
// - 兼容 config.yaml/env(避免老部署因为未迁移到数据库设置而被意外关闭)
|
||||
|
||||
@@ -173,6 +173,11 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
|
||||
SettingKeyTurnstileSiteKey,
|
||||
SettingKeyTencentCaptchaEnabled,
|
||||
SettingKeyTencentCaptchaAppID,
|
||||
SettingKeyTencentCaptchaRegion,
|
||||
SettingKeyAliyunCaptchaEnabled,
|
||||
SettingKeyAliyunCaptchaSceneID,
|
||||
SettingKeyAliyunCaptchaPrefix,
|
||||
SettingKeyAliyunCaptchaRegion,
|
||||
SettingKeyAPIKeyACLTrustForwardedIP,
|
||||
SettingKeySiteName,
|
||||
SettingKeySiteLogo,
|
||||
@@ -305,6 +310,11 @@ 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],
|
||||
AliyunCaptchaRegion: normalizeAliyunCaptchaRegion(settings[SettingKeyAliyunCaptchaRegion]),
|
||||
SiteName: s.getStringOrDefault(settings, SettingKeySiteName, "Sub2API"),
|
||||
SiteLogo: settings[SettingKeySiteLogo],
|
||||
SiteSubtitle: s.getStringOrDefault(settings, SettingKeySiteSubtitle, "Subscription to API Conversion Platform"),
|
||||
@@ -496,6 +506,11 @@ 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"`
|
||||
AliyunCaptchaRegion string `json:"aliyun_captcha_region"`
|
||||
SiteName string `json:"site_name"`
|
||||
SiteLogo string `json:"site_logo"`
|
||||
SiteSubtitle string `json:"site_subtitle"`
|
||||
@@ -571,6 +586,11 @@ 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,
|
||||
AliyunCaptchaRegion: settings.AliyunCaptchaRegion,
|
||||
SiteName: settings.SiteName,
|
||||
SiteLogo: settings.SiteLogo,
|
||||
SiteSubtitle: settings.SiteSubtitle,
|
||||
|
||||
@@ -221,6 +221,16 @@ 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
|
||||
if settings.AliyunCaptchaAccessKeySecret != "" {
|
||||
updates[SettingKeyAliyunCaptchaAccessKeySecret] = settings.AliyunCaptchaAccessKeySecret
|
||||
}
|
||||
updates[SettingKeyAliyunCaptchaSceneID] = settings.AliyunCaptchaSceneID
|
||||
updates[SettingKeyAliyunCaptchaPrefix] = settings.AliyunCaptchaPrefix
|
||||
updates[SettingKeyAliyunCaptchaRegion] = normalizeAliyunCaptchaRegion(settings.AliyunCaptchaRegion)
|
||||
updates[SettingKeyAPIKeyACLTrustForwardedIP] = strconv.FormatBool(settings.APIKeyACLTrustForwardedIP)
|
||||
forwardedClientIPHeadersJSON, err := json.Marshal(settings.ForwardedClientIPHeaders)
|
||||
if err != nil {
|
||||
|
||||