Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
155c494964 | ||
|
|
8991574873 | ||
|
|
8f7b0a314d | ||
|
|
64090de664 | ||
|
|
de904c433e | ||
|
|
14a27f1960 | ||
|
|
c33c3208e3 | ||
|
|
db0bff82c7 | ||
|
|
e88fc52ce6 | ||
|
|
045b620d0d | ||
|
|
e79429b03f | ||
|
|
02fbcbe3ad | ||
|
|
b5d9fd21b0 | ||
|
|
146b8b6684 | ||
|
|
a071b27b41 | ||
|
|
379db19141 | ||
|
|
f3c94d2099 | ||
|
|
b8c0c00091 | ||
|
|
bd9b12bbcb | ||
|
|
e687ca3e9d | ||
|
|
99b357083e | ||
|
|
32e4de7942 | ||
|
|
8f55e0a7cd | ||
|
|
5ec11885d1 | ||
|
|
c8af48d769 | ||
|
|
dce0f6b4e3 | ||
|
|
22ef761c27 | ||
|
|
865002fd2c | ||
|
|
93367b6db4 | ||
|
|
b6e53c9320 | ||
|
|
dbb42881c0 | ||
|
|
e4b0e1b66b | ||
|
|
02e50cc22d | ||
|
|
287a9f386b | ||
|
|
8e102b3a0f | ||
|
|
e93f6b9953 | ||
|
|
a19c9f8d8a | ||
|
|
a1936d42db | ||
|
|
c123caddd4 | ||
|
|
e08aee49ed | ||
|
|
c9e60d1f26 | ||
|
|
47c03c75d8 | ||
|
|
5b7a64334f | ||
|
|
82ccc187cd | ||
|
|
ce14983133 | ||
|
|
fc5a1b78d2 | ||
|
|
00b8596176 | ||
|
|
c5e046b7d7 | ||
|
|
aac53afe0e | ||
|
|
f0e7a9c7a2 | ||
|
|
26e0a89323 | ||
|
|
d431c57f2e | ||
|
|
635a27189f | ||
|
|
d3b5703b55 | ||
|
|
7d38e67120 | ||
|
|
8b3fe664dc | ||
|
|
a4d263f62f | ||
|
|
ae81dfd933 | ||
|
|
35cab3c814 | ||
|
|
770e35b474 | ||
|
|
846dd310a3 | ||
|
|
9b4575e434 | ||
|
|
1f4cfc44c1 | ||
|
|
e592c5f9e0 | ||
|
|
9fd7e76231 | ||
|
|
785b61d424 | ||
|
|
80af44cad7 | ||
|
|
e2652eb853 | ||
|
|
66ad405dd6 | ||
|
|
1b04e03cc4 | ||
|
|
2be047d1cd | ||
|
|
db725a775a | ||
|
|
30d2589ef0 | ||
|
|
ff7ab2308d | ||
|
|
0204ce285e | ||
|
|
b74cb7891c | ||
|
|
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
|
||||
@@ -747,7 +742,7 @@ The Grok OAuth flow uses PKCE and does not require committing private secrets. T
|
||||
| `XAI_OAUTH_AUTHORIZE_URL` | `https://auth.x.ai/oauth2/authorize` |
|
||||
| `XAI_OAUTH_TOKEN_URL` | `https://auth.x.ai/oauth2/token` |
|
||||
| `XAI_BASE_URL` | `https://api.x.ai/v1`; runtime-diagnostics override (account `base_url` controls request forwarding) |
|
||||
| `XAI_GROK_CLI_VERSION` | `0.2.93`; optional override for the client identity sent to `cli-chat-proxy.grok.com` |
|
||||
| `XAI_GROK_CLI_VERSION` | `0.2.114`; optional override for the client identity sent to `cli-chat-proxy.grok.com`. The pinned value is also the floor: an override below it is dropped |
|
||||
|
||||
Administrators can create Grok OAuth or API-key accounts from the dashboard. OAuth authorization and reauthorization are also available through the admin API:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -57,6 +57,10 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
emailService := service.NewEmailService(settingRepository, emailCache)
|
||||
turnstileVerifier := repository.NewTurnstileVerifier()
|
||||
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)
|
||||
@@ -78,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.NewAuthService(client, userRepository, redeemCodeRepository, refreshTokenCache, configConfig, settingService, emailService, turnstileService, 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)
|
||||
@@ -222,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)
|
||||
|
||||
@@ -1621,6 +1621,8 @@ var (
|
||||
{Name: "model", Type: field.TypeString, Size: 100},
|
||||
{Name: "requested_model", Type: field.TypeString, Nullable: true, Size: 100},
|
||||
{Name: "upstream_model", Type: field.TypeString, Nullable: true, Size: 100},
|
||||
{Name: "upstream_response_model", Type: field.TypeString, Nullable: true, Size: 200},
|
||||
{Name: "upstream_model_mismatch", Type: field.TypeBool, Nullable: true},
|
||||
{Name: "channel_id", Type: field.TypeInt64, Nullable: true},
|
||||
{Name: "model_mapping_chain", Type: field.TypeString, Nullable: true, Size: 500},
|
||||
{Name: "billing_tier", Type: field.TypeString, Nullable: true, Size: 50},
|
||||
@@ -1671,31 +1673,31 @@ var (
|
||||
ForeignKeys: []*schema.ForeignKey{
|
||||
{
|
||||
Symbol: "usage_logs_api_keys_usage_logs",
|
||||
Columns: []*schema.Column{UsageLogsColumns[41]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[43]},
|
||||
RefColumns: []*schema.Column{APIKeysColumns[0]},
|
||||
OnDelete: schema.NoAction,
|
||||
},
|
||||
{
|
||||
Symbol: "usage_logs_accounts_usage_logs",
|
||||
Columns: []*schema.Column{UsageLogsColumns[42]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[44]},
|
||||
RefColumns: []*schema.Column{AccountsColumns[0]},
|
||||
OnDelete: schema.NoAction,
|
||||
},
|
||||
{
|
||||
Symbol: "usage_logs_groups_usage_logs",
|
||||
Columns: []*schema.Column{UsageLogsColumns[43]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[45]},
|
||||
RefColumns: []*schema.Column{GroupsColumns[0]},
|
||||
OnDelete: schema.SetNull,
|
||||
},
|
||||
{
|
||||
Symbol: "usage_logs_users_usage_logs",
|
||||
Columns: []*schema.Column{UsageLogsColumns[44]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[46]},
|
||||
RefColumns: []*schema.Column{UsersColumns[0]},
|
||||
OnDelete: schema.NoAction,
|
||||
},
|
||||
{
|
||||
Symbol: "usage_logs_user_subscriptions_usage_logs",
|
||||
Columns: []*schema.Column{UsageLogsColumns[45]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[47]},
|
||||
RefColumns: []*schema.Column{UserSubscriptionsColumns[0]},
|
||||
OnDelete: schema.SetNull,
|
||||
},
|
||||
@@ -1704,32 +1706,32 @@ var (
|
||||
{
|
||||
Name: "usagelog_user_id",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{UsageLogsColumns[44]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[46]},
|
||||
},
|
||||
{
|
||||
Name: "usagelog_api_key_id",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{UsageLogsColumns[41]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[43]},
|
||||
},
|
||||
{
|
||||
Name: "usagelog_account_id",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{UsageLogsColumns[42]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[44]},
|
||||
},
|
||||
{
|
||||
Name: "usagelog_group_id",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{UsageLogsColumns[43]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[45]},
|
||||
},
|
||||
{
|
||||
Name: "usagelog_subscription_id",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{UsageLogsColumns[45]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[47]},
|
||||
},
|
||||
{
|
||||
Name: "usagelog_created_at",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{UsageLogsColumns[40]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[42]},
|
||||
},
|
||||
{
|
||||
Name: "usagelog_model",
|
||||
@@ -1749,17 +1751,17 @@ var (
|
||||
{
|
||||
Name: "usagelog_user_id_created_at",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{UsageLogsColumns[44], UsageLogsColumns[40]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[46], UsageLogsColumns[42]},
|
||||
},
|
||||
{
|
||||
Name: "usagelog_api_key_id_created_at",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{UsageLogsColumns[41], UsageLogsColumns[40]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[43], UsageLogsColumns[42]},
|
||||
},
|
||||
{
|
||||
Name: "usagelog_group_id_created_at",
|
||||
Unique: false,
|
||||
Columns: []*schema.Column{UsageLogsColumns[43], UsageLogsColumns[40]},
|
||||
Columns: []*schema.Column{UsageLogsColumns[45], UsageLogsColumns[42]},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -43356,6 +43356,8 @@ type UsageLogMutation struct {
|
||||
model *string
|
||||
requested_model *string
|
||||
upstream_model *string
|
||||
upstream_response_model *string
|
||||
upstream_model_mismatch *bool
|
||||
channel_id *int64
|
||||
addchannel_id *int64
|
||||
model_mapping_chain *string
|
||||
@@ -43805,6 +43807,104 @@ func (m *UsageLogMutation) ResetUpstreamModel() {
|
||||
delete(m.clearedFields, usagelog.FieldUpstreamModel)
|
||||
}
|
||||
|
||||
// SetUpstreamResponseModel sets the "upstream_response_model" field.
|
||||
func (m *UsageLogMutation) SetUpstreamResponseModel(s string) {
|
||||
m.upstream_response_model = &s
|
||||
}
|
||||
|
||||
// UpstreamResponseModel returns the value of the "upstream_response_model" field in the mutation.
|
||||
func (m *UsageLogMutation) UpstreamResponseModel() (r string, exists bool) {
|
||||
v := m.upstream_response_model
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// OldUpstreamResponseModel returns the old "upstream_response_model" field's value of the UsageLog entity.
|
||||
// If the UsageLog object wasn't provided to the builder, the object is fetched from the database.
|
||||
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
|
||||
func (m *UsageLogMutation) OldUpstreamResponseModel(ctx context.Context) (v *string, err error) {
|
||||
if !m.op.Is(OpUpdateOne) {
|
||||
return v, errors.New("OldUpstreamResponseModel is only allowed on UpdateOne operations")
|
||||
}
|
||||
if m.id == nil || m.oldValue == nil {
|
||||
return v, errors.New("OldUpstreamResponseModel requires an ID field in the mutation")
|
||||
}
|
||||
oldValue, err := m.oldValue(ctx)
|
||||
if err != nil {
|
||||
return v, fmt.Errorf("querying old value for OldUpstreamResponseModel: %w", err)
|
||||
}
|
||||
return oldValue.UpstreamResponseModel, nil
|
||||
}
|
||||
|
||||
// ClearUpstreamResponseModel clears the value of the "upstream_response_model" field.
|
||||
func (m *UsageLogMutation) ClearUpstreamResponseModel() {
|
||||
m.upstream_response_model = nil
|
||||
m.clearedFields[usagelog.FieldUpstreamResponseModel] = struct{}{}
|
||||
}
|
||||
|
||||
// UpstreamResponseModelCleared returns if the "upstream_response_model" field was cleared in this mutation.
|
||||
func (m *UsageLogMutation) UpstreamResponseModelCleared() bool {
|
||||
_, ok := m.clearedFields[usagelog.FieldUpstreamResponseModel]
|
||||
return ok
|
||||
}
|
||||
|
||||
// ResetUpstreamResponseModel resets all changes to the "upstream_response_model" field.
|
||||
func (m *UsageLogMutation) ResetUpstreamResponseModel() {
|
||||
m.upstream_response_model = nil
|
||||
delete(m.clearedFields, usagelog.FieldUpstreamResponseModel)
|
||||
}
|
||||
|
||||
// SetUpstreamModelMismatch sets the "upstream_model_mismatch" field.
|
||||
func (m *UsageLogMutation) SetUpstreamModelMismatch(b bool) {
|
||||
m.upstream_model_mismatch = &b
|
||||
}
|
||||
|
||||
// UpstreamModelMismatch returns the value of the "upstream_model_mismatch" field in the mutation.
|
||||
func (m *UsageLogMutation) UpstreamModelMismatch() (r bool, exists bool) {
|
||||
v := m.upstream_model_mismatch
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// OldUpstreamModelMismatch returns the old "upstream_model_mismatch" field's value of the UsageLog entity.
|
||||
// If the UsageLog object wasn't provided to the builder, the object is fetched from the database.
|
||||
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
|
||||
func (m *UsageLogMutation) OldUpstreamModelMismatch(ctx context.Context) (v *bool, err error) {
|
||||
if !m.op.Is(OpUpdateOne) {
|
||||
return v, errors.New("OldUpstreamModelMismatch is only allowed on UpdateOne operations")
|
||||
}
|
||||
if m.id == nil || m.oldValue == nil {
|
||||
return v, errors.New("OldUpstreamModelMismatch requires an ID field in the mutation")
|
||||
}
|
||||
oldValue, err := m.oldValue(ctx)
|
||||
if err != nil {
|
||||
return v, fmt.Errorf("querying old value for OldUpstreamModelMismatch: %w", err)
|
||||
}
|
||||
return oldValue.UpstreamModelMismatch, nil
|
||||
}
|
||||
|
||||
// ClearUpstreamModelMismatch clears the value of the "upstream_model_mismatch" field.
|
||||
func (m *UsageLogMutation) ClearUpstreamModelMismatch() {
|
||||
m.upstream_model_mismatch = nil
|
||||
m.clearedFields[usagelog.FieldUpstreamModelMismatch] = struct{}{}
|
||||
}
|
||||
|
||||
// UpstreamModelMismatchCleared returns if the "upstream_model_mismatch" field was cleared in this mutation.
|
||||
func (m *UsageLogMutation) UpstreamModelMismatchCleared() bool {
|
||||
_, ok := m.clearedFields[usagelog.FieldUpstreamModelMismatch]
|
||||
return ok
|
||||
}
|
||||
|
||||
// ResetUpstreamModelMismatch resets all changes to the "upstream_model_mismatch" field.
|
||||
func (m *UsageLogMutation) ResetUpstreamModelMismatch() {
|
||||
m.upstream_model_mismatch = nil
|
||||
delete(m.clearedFields, usagelog.FieldUpstreamModelMismatch)
|
||||
}
|
||||
|
||||
// SetChannelID sets the "channel_id" field.
|
||||
func (m *UsageLogMutation) SetChannelID(i int64) {
|
||||
m.channel_id = &i
|
||||
@@ -46001,7 +46101,7 @@ func (m *UsageLogMutation) Type() string {
|
||||
// order to get all numeric fields that were incremented/decremented, call
|
||||
// AddedFields().
|
||||
func (m *UsageLogMutation) Fields() []string {
|
||||
fields := make([]string, 0, 45)
|
||||
fields := make([]string, 0, 47)
|
||||
if m.user != nil {
|
||||
fields = append(fields, usagelog.FieldUserID)
|
||||
}
|
||||
@@ -46023,6 +46123,12 @@ func (m *UsageLogMutation) Fields() []string {
|
||||
if m.upstream_model != nil {
|
||||
fields = append(fields, usagelog.FieldUpstreamModel)
|
||||
}
|
||||
if m.upstream_response_model != nil {
|
||||
fields = append(fields, usagelog.FieldUpstreamResponseModel)
|
||||
}
|
||||
if m.upstream_model_mismatch != nil {
|
||||
fields = append(fields, usagelog.FieldUpstreamModelMismatch)
|
||||
}
|
||||
if m.channel_id != nil {
|
||||
fields = append(fields, usagelog.FieldChannelID)
|
||||
}
|
||||
@@ -46159,6 +46265,10 @@ func (m *UsageLogMutation) Field(name string) (ent.Value, bool) {
|
||||
return m.RequestedModel()
|
||||
case usagelog.FieldUpstreamModel:
|
||||
return m.UpstreamModel()
|
||||
case usagelog.FieldUpstreamResponseModel:
|
||||
return m.UpstreamResponseModel()
|
||||
case usagelog.FieldUpstreamModelMismatch:
|
||||
return m.UpstreamModelMismatch()
|
||||
case usagelog.FieldChannelID:
|
||||
return m.ChannelID()
|
||||
case usagelog.FieldModelMappingChain:
|
||||
@@ -46258,6 +46368,10 @@ func (m *UsageLogMutation) OldField(ctx context.Context, name string) (ent.Value
|
||||
return m.OldRequestedModel(ctx)
|
||||
case usagelog.FieldUpstreamModel:
|
||||
return m.OldUpstreamModel(ctx)
|
||||
case usagelog.FieldUpstreamResponseModel:
|
||||
return m.OldUpstreamResponseModel(ctx)
|
||||
case usagelog.FieldUpstreamModelMismatch:
|
||||
return m.OldUpstreamModelMismatch(ctx)
|
||||
case usagelog.FieldChannelID:
|
||||
return m.OldChannelID(ctx)
|
||||
case usagelog.FieldModelMappingChain:
|
||||
@@ -46392,6 +46506,20 @@ func (m *UsageLogMutation) SetField(name string, value ent.Value) error {
|
||||
}
|
||||
m.SetUpstreamModel(v)
|
||||
return nil
|
||||
case usagelog.FieldUpstreamResponseModel:
|
||||
v, ok := value.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||
}
|
||||
m.SetUpstreamResponseModel(v)
|
||||
return nil
|
||||
case usagelog.FieldUpstreamModelMismatch:
|
||||
v, ok := value.(bool)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||
}
|
||||
m.SetUpstreamModelMismatch(v)
|
||||
return nil
|
||||
case usagelog.FieldChannelID:
|
||||
v, ok := value.(int64)
|
||||
if !ok {
|
||||
@@ -46949,6 +47077,12 @@ func (m *UsageLogMutation) ClearedFields() []string {
|
||||
if m.FieldCleared(usagelog.FieldUpstreamModel) {
|
||||
fields = append(fields, usagelog.FieldUpstreamModel)
|
||||
}
|
||||
if m.FieldCleared(usagelog.FieldUpstreamResponseModel) {
|
||||
fields = append(fields, usagelog.FieldUpstreamResponseModel)
|
||||
}
|
||||
if m.FieldCleared(usagelog.FieldUpstreamModelMismatch) {
|
||||
fields = append(fields, usagelog.FieldUpstreamModelMismatch)
|
||||
}
|
||||
if m.FieldCleared(usagelog.FieldChannelID) {
|
||||
fields = append(fields, usagelog.FieldChannelID)
|
||||
}
|
||||
@@ -47023,6 +47157,12 @@ func (m *UsageLogMutation) ClearField(name string) error {
|
||||
case usagelog.FieldUpstreamModel:
|
||||
m.ClearUpstreamModel()
|
||||
return nil
|
||||
case usagelog.FieldUpstreamResponseModel:
|
||||
m.ClearUpstreamResponseModel()
|
||||
return nil
|
||||
case usagelog.FieldUpstreamModelMismatch:
|
||||
m.ClearUpstreamModelMismatch()
|
||||
return nil
|
||||
case usagelog.FieldChannelID:
|
||||
m.ClearChannelID()
|
||||
return nil
|
||||
@@ -47106,6 +47246,12 @@ func (m *UsageLogMutation) ResetField(name string) error {
|
||||
case usagelog.FieldUpstreamModel:
|
||||
m.ResetUpstreamModel()
|
||||
return nil
|
||||
case usagelog.FieldUpstreamResponseModel:
|
||||
m.ResetUpstreamResponseModel()
|
||||
return nil
|
||||
case usagelog.FieldUpstreamModelMismatch:
|
||||
m.ResetUpstreamModelMismatch()
|
||||
return nil
|
||||
case usagelog.FieldChannelID:
|
||||
m.ResetChannelID()
|
||||
return nil
|
||||
|
||||
@@ -1982,124 +1982,128 @@ func init() {
|
||||
usagelogDescUpstreamModel := usagelogFields[6].Descriptor()
|
||||
// usagelog.UpstreamModelValidator is a validator for the "upstream_model" field. It is called by the builders before save.
|
||||
usagelog.UpstreamModelValidator = usagelogDescUpstreamModel.Validators[0].(func(string) error)
|
||||
// usagelogDescUpstreamResponseModel is the schema descriptor for upstream_response_model field.
|
||||
usagelogDescUpstreamResponseModel := usagelogFields[7].Descriptor()
|
||||
// usagelog.UpstreamResponseModelValidator is a validator for the "upstream_response_model" field. It is called by the builders before save.
|
||||
usagelog.UpstreamResponseModelValidator = usagelogDescUpstreamResponseModel.Validators[0].(func(string) error)
|
||||
// usagelogDescModelMappingChain is the schema descriptor for model_mapping_chain field.
|
||||
usagelogDescModelMappingChain := usagelogFields[8].Descriptor()
|
||||
usagelogDescModelMappingChain := usagelogFields[10].Descriptor()
|
||||
// usagelog.ModelMappingChainValidator is a validator for the "model_mapping_chain" field. It is called by the builders before save.
|
||||
usagelog.ModelMappingChainValidator = usagelogDescModelMappingChain.Validators[0].(func(string) error)
|
||||
// usagelogDescBillingTier is the schema descriptor for billing_tier field.
|
||||
usagelogDescBillingTier := usagelogFields[9].Descriptor()
|
||||
usagelogDescBillingTier := usagelogFields[11].Descriptor()
|
||||
// usagelog.BillingTierValidator is a validator for the "billing_tier" field. It is called by the builders before save.
|
||||
usagelog.BillingTierValidator = usagelogDescBillingTier.Validators[0].(func(string) error)
|
||||
// usagelogDescBillingMode is the schema descriptor for billing_mode field.
|
||||
usagelogDescBillingMode := usagelogFields[10].Descriptor()
|
||||
usagelogDescBillingMode := usagelogFields[12].Descriptor()
|
||||
// usagelog.BillingModeValidator is a validator for the "billing_mode" field. It is called by the builders before save.
|
||||
usagelog.BillingModeValidator = usagelogDescBillingMode.Validators[0].(func(string) error)
|
||||
// usagelogDescInputTokens is the schema descriptor for input_tokens field.
|
||||
usagelogDescInputTokens := usagelogFields[13].Descriptor()
|
||||
usagelogDescInputTokens := usagelogFields[15].Descriptor()
|
||||
// usagelog.DefaultInputTokens holds the default value on creation for the input_tokens field.
|
||||
usagelog.DefaultInputTokens = usagelogDescInputTokens.Default.(int)
|
||||
// usagelogDescOutputTokens is the schema descriptor for output_tokens field.
|
||||
usagelogDescOutputTokens := usagelogFields[14].Descriptor()
|
||||
usagelogDescOutputTokens := usagelogFields[16].Descriptor()
|
||||
// usagelog.DefaultOutputTokens holds the default value on creation for the output_tokens field.
|
||||
usagelog.DefaultOutputTokens = usagelogDescOutputTokens.Default.(int)
|
||||
// usagelogDescCacheCreationTokens is the schema descriptor for cache_creation_tokens field.
|
||||
usagelogDescCacheCreationTokens := usagelogFields[15].Descriptor()
|
||||
usagelogDescCacheCreationTokens := usagelogFields[17].Descriptor()
|
||||
// usagelog.DefaultCacheCreationTokens holds the default value on creation for the cache_creation_tokens field.
|
||||
usagelog.DefaultCacheCreationTokens = usagelogDescCacheCreationTokens.Default.(int)
|
||||
// usagelogDescCacheReadTokens is the schema descriptor for cache_read_tokens field.
|
||||
usagelogDescCacheReadTokens := usagelogFields[16].Descriptor()
|
||||
usagelogDescCacheReadTokens := usagelogFields[18].Descriptor()
|
||||
// usagelog.DefaultCacheReadTokens holds the default value on creation for the cache_read_tokens field.
|
||||
usagelog.DefaultCacheReadTokens = usagelogDescCacheReadTokens.Default.(int)
|
||||
// usagelogDescCacheCreation5mTokens is the schema descriptor for cache_creation_5m_tokens field.
|
||||
usagelogDescCacheCreation5mTokens := usagelogFields[17].Descriptor()
|
||||
usagelogDescCacheCreation5mTokens := usagelogFields[19].Descriptor()
|
||||
// usagelog.DefaultCacheCreation5mTokens holds the default value on creation for the cache_creation_5m_tokens field.
|
||||
usagelog.DefaultCacheCreation5mTokens = usagelogDescCacheCreation5mTokens.Default.(int)
|
||||
// usagelogDescCacheCreation1hTokens is the schema descriptor for cache_creation_1h_tokens field.
|
||||
usagelogDescCacheCreation1hTokens := usagelogFields[18].Descriptor()
|
||||
usagelogDescCacheCreation1hTokens := usagelogFields[20].Descriptor()
|
||||
// usagelog.DefaultCacheCreation1hTokens holds the default value on creation for the cache_creation_1h_tokens field.
|
||||
usagelog.DefaultCacheCreation1hTokens = usagelogDescCacheCreation1hTokens.Default.(int)
|
||||
// usagelogDescInputCost is the schema descriptor for input_cost field.
|
||||
usagelogDescInputCost := usagelogFields[19].Descriptor()
|
||||
usagelogDescInputCost := usagelogFields[21].Descriptor()
|
||||
// usagelog.DefaultInputCost holds the default value on creation for the input_cost field.
|
||||
usagelog.DefaultInputCost = usagelogDescInputCost.Default.(float64)
|
||||
// usagelogDescOutputCost is the schema descriptor for output_cost field.
|
||||
usagelogDescOutputCost := usagelogFields[20].Descriptor()
|
||||
usagelogDescOutputCost := usagelogFields[22].Descriptor()
|
||||
// usagelog.DefaultOutputCost holds the default value on creation for the output_cost field.
|
||||
usagelog.DefaultOutputCost = usagelogDescOutputCost.Default.(float64)
|
||||
// usagelogDescCacheCreationCost is the schema descriptor for cache_creation_cost field.
|
||||
usagelogDescCacheCreationCost := usagelogFields[21].Descriptor()
|
||||
usagelogDescCacheCreationCost := usagelogFields[23].Descriptor()
|
||||
// usagelog.DefaultCacheCreationCost holds the default value on creation for the cache_creation_cost field.
|
||||
usagelog.DefaultCacheCreationCost = usagelogDescCacheCreationCost.Default.(float64)
|
||||
// usagelogDescCacheReadCost is the schema descriptor for cache_read_cost field.
|
||||
usagelogDescCacheReadCost := usagelogFields[22].Descriptor()
|
||||
usagelogDescCacheReadCost := usagelogFields[24].Descriptor()
|
||||
// usagelog.DefaultCacheReadCost holds the default value on creation for the cache_read_cost field.
|
||||
usagelog.DefaultCacheReadCost = usagelogDescCacheReadCost.Default.(float64)
|
||||
// usagelogDescTotalCost is the schema descriptor for total_cost field.
|
||||
usagelogDescTotalCost := usagelogFields[23].Descriptor()
|
||||
usagelogDescTotalCost := usagelogFields[25].Descriptor()
|
||||
// usagelog.DefaultTotalCost holds the default value on creation for the total_cost field.
|
||||
usagelog.DefaultTotalCost = usagelogDescTotalCost.Default.(float64)
|
||||
// usagelogDescActualCost is the schema descriptor for actual_cost field.
|
||||
usagelogDescActualCost := usagelogFields[24].Descriptor()
|
||||
usagelogDescActualCost := usagelogFields[26].Descriptor()
|
||||
// usagelog.DefaultActualCost holds the default value on creation for the actual_cost field.
|
||||
usagelog.DefaultActualCost = usagelogDescActualCost.Default.(float64)
|
||||
// usagelogDescRateMultiplier is the schema descriptor for rate_multiplier field.
|
||||
usagelogDescRateMultiplier := usagelogFields[25].Descriptor()
|
||||
usagelogDescRateMultiplier := usagelogFields[27].Descriptor()
|
||||
// usagelog.DefaultRateMultiplier holds the default value on creation for the rate_multiplier field.
|
||||
usagelog.DefaultRateMultiplier = usagelogDescRateMultiplier.Default.(float64)
|
||||
// usagelogDescLongContextBillingApplied is the schema descriptor for long_context_billing_applied field.
|
||||
usagelogDescLongContextBillingApplied := usagelogFields[26].Descriptor()
|
||||
usagelogDescLongContextBillingApplied := usagelogFields[28].Descriptor()
|
||||
// usagelog.DefaultLongContextBillingApplied holds the default value on creation for the long_context_billing_applied field.
|
||||
usagelog.DefaultLongContextBillingApplied = usagelogDescLongContextBillingApplied.Default.(bool)
|
||||
// usagelogDescBillingType is the schema descriptor for billing_type field.
|
||||
usagelogDescBillingType := usagelogFields[28].Descriptor()
|
||||
usagelogDescBillingType := usagelogFields[30].Descriptor()
|
||||
// usagelog.DefaultBillingType holds the default value on creation for the billing_type field.
|
||||
usagelog.DefaultBillingType = usagelogDescBillingType.Default.(int8)
|
||||
// usagelogDescStream is the schema descriptor for stream field.
|
||||
usagelogDescStream := usagelogFields[29].Descriptor()
|
||||
usagelogDescStream := usagelogFields[31].Descriptor()
|
||||
// usagelog.DefaultStream holds the default value on creation for the stream field.
|
||||
usagelog.DefaultStream = usagelogDescStream.Default.(bool)
|
||||
// usagelogDescUserAgent is the schema descriptor for user_agent field.
|
||||
usagelogDescUserAgent := usagelogFields[32].Descriptor()
|
||||
usagelogDescUserAgent := usagelogFields[34].Descriptor()
|
||||
// usagelog.UserAgentValidator is a validator for the "user_agent" field. It is called by the builders before save.
|
||||
usagelog.UserAgentValidator = usagelogDescUserAgent.Validators[0].(func(string) error)
|
||||
// usagelogDescIPAddress is the schema descriptor for ip_address field.
|
||||
usagelogDescIPAddress := usagelogFields[33].Descriptor()
|
||||
usagelogDescIPAddress := usagelogFields[35].Descriptor()
|
||||
// usagelog.IPAddressValidator is a validator for the "ip_address" field. It is called by the builders before save.
|
||||
usagelog.IPAddressValidator = usagelogDescIPAddress.Validators[0].(func(string) error)
|
||||
// usagelogDescImageCount is the schema descriptor for image_count field.
|
||||
usagelogDescImageCount := usagelogFields[34].Descriptor()
|
||||
usagelogDescImageCount := usagelogFields[36].Descriptor()
|
||||
// usagelog.DefaultImageCount holds the default value on creation for the image_count field.
|
||||
usagelog.DefaultImageCount = usagelogDescImageCount.Default.(int)
|
||||
// usagelogDescImageSize is the schema descriptor for image_size field.
|
||||
usagelogDescImageSize := usagelogFields[35].Descriptor()
|
||||
usagelogDescImageSize := usagelogFields[37].Descriptor()
|
||||
// usagelog.ImageSizeValidator is a validator for the "image_size" field. It is called by the builders before save.
|
||||
usagelog.ImageSizeValidator = usagelogDescImageSize.Validators[0].(func(string) error)
|
||||
// usagelogDescImageInputSize is the schema descriptor for image_input_size field.
|
||||
usagelogDescImageInputSize := usagelogFields[36].Descriptor()
|
||||
usagelogDescImageInputSize := usagelogFields[38].Descriptor()
|
||||
// usagelog.ImageInputSizeValidator is a validator for the "image_input_size" field. It is called by the builders before save.
|
||||
usagelog.ImageInputSizeValidator = usagelogDescImageInputSize.Validators[0].(func(string) error)
|
||||
// usagelogDescImageOutputSize is the schema descriptor for image_output_size field.
|
||||
usagelogDescImageOutputSize := usagelogFields[37].Descriptor()
|
||||
usagelogDescImageOutputSize := usagelogFields[39].Descriptor()
|
||||
// usagelog.ImageOutputSizeValidator is a validator for the "image_output_size" field. It is called by the builders before save.
|
||||
usagelog.ImageOutputSizeValidator = usagelogDescImageOutputSize.Validators[0].(func(string) error)
|
||||
// usagelogDescImageSizeSource is the schema descriptor for image_size_source field.
|
||||
usagelogDescImageSizeSource := usagelogFields[38].Descriptor()
|
||||
usagelogDescImageSizeSource := usagelogFields[40].Descriptor()
|
||||
// usagelog.ImageSizeSourceValidator is a validator for the "image_size_source" field. It is called by the builders before save.
|
||||
usagelog.ImageSizeSourceValidator = usagelogDescImageSizeSource.Validators[0].(func(string) error)
|
||||
// usagelogDescVideoCount is the schema descriptor for video_count field.
|
||||
usagelogDescVideoCount := usagelogFields[40].Descriptor()
|
||||
usagelogDescVideoCount := usagelogFields[42].Descriptor()
|
||||
// usagelog.DefaultVideoCount holds the default value on creation for the video_count field.
|
||||
usagelog.DefaultVideoCount = usagelogDescVideoCount.Default.(int)
|
||||
// usagelogDescVideoResolution is the schema descriptor for video_resolution field.
|
||||
usagelogDescVideoResolution := usagelogFields[41].Descriptor()
|
||||
usagelogDescVideoResolution := usagelogFields[43].Descriptor()
|
||||
// usagelog.VideoResolutionValidator is a validator for the "video_resolution" field. It is called by the builders before save.
|
||||
usagelog.VideoResolutionValidator = usagelogDescVideoResolution.Validators[0].(func(string) error)
|
||||
// usagelogDescCacheTTLOverridden is the schema descriptor for cache_ttl_overridden field.
|
||||
usagelogDescCacheTTLOverridden := usagelogFields[43].Descriptor()
|
||||
usagelogDescCacheTTLOverridden := usagelogFields[45].Descriptor()
|
||||
// usagelog.DefaultCacheTTLOverridden holds the default value on creation for the cache_ttl_overridden field.
|
||||
usagelog.DefaultCacheTTLOverridden = usagelogDescCacheTTLOverridden.Default.(bool)
|
||||
// usagelogDescCreatedAt is the schema descriptor for created_at field.
|
||||
usagelogDescCreatedAt := usagelogFields[44].Descriptor()
|
||||
usagelogDescCreatedAt := usagelogFields[46].Descriptor()
|
||||
// usagelog.DefaultCreatedAt holds the default value on creation for the created_at field.
|
||||
usagelog.DefaultCreatedAt = usagelogDescCreatedAt.Default.(func() time.Time)
|
||||
userMixin := schema.User{}.Mixin()
|
||||
|
||||
@@ -53,6 +53,17 @@ func (UsageLog) Fields() []ent.Field {
|
||||
MaxLen(100).
|
||||
Optional().
|
||||
Nillable(),
|
||||
// UpstreamResponseModel stores the model name declared by the upstream
|
||||
// response before any protocol conversion or client-facing rewrite.
|
||||
field.String("upstream_response_model").
|
||||
MaxLen(200).
|
||||
Optional().
|
||||
Nillable(),
|
||||
// UpstreamModelMismatch is tri-state: NULL means the upstream response did
|
||||
// not declare a model (or predates this field); false/true means observed.
|
||||
field.Bool("upstream_model_mismatch").
|
||||
Optional().
|
||||
Nillable(),
|
||||
field.Int64("channel_id").Optional().Nillable().Comment("渠道 ID"),
|
||||
field.String("model_mapping_chain").MaxLen(500).Optional().Nillable().Comment("模型映射链"),
|
||||
field.String("billing_tier").MaxLen(50).Optional().Nillable().Comment("计费层级标签"),
|
||||
|
||||
@@ -37,6 +37,10 @@ type UsageLog struct {
|
||||
RequestedModel *string `json:"requested_model,omitempty"`
|
||||
// UpstreamModel holds the value of the "upstream_model" field.
|
||||
UpstreamModel *string `json:"upstream_model,omitempty"`
|
||||
// UpstreamResponseModel holds the value of the "upstream_response_model" field.
|
||||
UpstreamResponseModel *string `json:"upstream_response_model,omitempty"`
|
||||
// UpstreamModelMismatch holds the value of the "upstream_model_mismatch" field.
|
||||
UpstreamModelMismatch *bool `json:"upstream_model_mismatch,omitempty"`
|
||||
// 渠道 ID
|
||||
ChannelID *int64 `json:"channel_id,omitempty"`
|
||||
// 模型映射链
|
||||
@@ -198,13 +202,13 @@ func (*UsageLog) scanValues(columns []string) ([]any, error) {
|
||||
switch columns[i] {
|
||||
case usagelog.FieldImageSizeBreakdown:
|
||||
values[i] = new([]byte)
|
||||
case usagelog.FieldLongContextBillingApplied, usagelog.FieldStream, usagelog.FieldCacheTTLOverridden:
|
||||
case usagelog.FieldUpstreamModelMismatch, usagelog.FieldLongContextBillingApplied, usagelog.FieldStream, usagelog.FieldCacheTTLOverridden:
|
||||
values[i] = new(sql.NullBool)
|
||||
case usagelog.FieldInputCost, usagelog.FieldOutputCost, usagelog.FieldCacheCreationCost, usagelog.FieldCacheReadCost, usagelog.FieldTotalCost, usagelog.FieldActualCost, usagelog.FieldRateMultiplier, usagelog.FieldAccountRateMultiplier:
|
||||
values[i] = new(sql.NullFloat64)
|
||||
case usagelog.FieldID, usagelog.FieldUserID, usagelog.FieldAPIKeyID, usagelog.FieldAccountID, usagelog.FieldChannelID, usagelog.FieldGroupID, usagelog.FieldSubscriptionID, usagelog.FieldInputTokens, usagelog.FieldOutputTokens, usagelog.FieldCacheCreationTokens, usagelog.FieldCacheReadTokens, usagelog.FieldCacheCreation5mTokens, usagelog.FieldCacheCreation1hTokens, usagelog.FieldBillingType, usagelog.FieldDurationMs, usagelog.FieldFirstTokenMs, usagelog.FieldImageCount, usagelog.FieldVideoCount, usagelog.FieldVideoDurationSeconds:
|
||||
values[i] = new(sql.NullInt64)
|
||||
case usagelog.FieldRequestID, usagelog.FieldModel, usagelog.FieldRequestedModel, usagelog.FieldUpstreamModel, usagelog.FieldModelMappingChain, usagelog.FieldBillingTier, usagelog.FieldBillingMode, usagelog.FieldUserAgent, usagelog.FieldIPAddress, usagelog.FieldImageSize, usagelog.FieldImageInputSize, usagelog.FieldImageOutputSize, usagelog.FieldImageSizeSource, usagelog.FieldVideoResolution:
|
||||
case usagelog.FieldRequestID, usagelog.FieldModel, usagelog.FieldRequestedModel, usagelog.FieldUpstreamModel, usagelog.FieldUpstreamResponseModel, usagelog.FieldModelMappingChain, usagelog.FieldBillingTier, usagelog.FieldBillingMode, usagelog.FieldUserAgent, usagelog.FieldIPAddress, usagelog.FieldImageSize, usagelog.FieldImageInputSize, usagelog.FieldImageOutputSize, usagelog.FieldImageSizeSource, usagelog.FieldVideoResolution:
|
||||
values[i] = new(sql.NullString)
|
||||
case usagelog.FieldCreatedAt:
|
||||
values[i] = new(sql.NullTime)
|
||||
@@ -273,6 +277,20 @@ func (_m *UsageLog) assignValues(columns []string, values []any) error {
|
||||
_m.UpstreamModel = new(string)
|
||||
*_m.UpstreamModel = value.String
|
||||
}
|
||||
case usagelog.FieldUpstreamResponseModel:
|
||||
if value, ok := values[i].(*sql.NullString); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field upstream_response_model", values[i])
|
||||
} else if value.Valid {
|
||||
_m.UpstreamResponseModel = new(string)
|
||||
*_m.UpstreamResponseModel = value.String
|
||||
}
|
||||
case usagelog.FieldUpstreamModelMismatch:
|
||||
if value, ok := values[i].(*sql.NullBool); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field upstream_model_mismatch", values[i])
|
||||
} else if value.Valid {
|
||||
_m.UpstreamModelMismatch = new(bool)
|
||||
*_m.UpstreamModelMismatch = value.Bool
|
||||
}
|
||||
case usagelog.FieldChannelID:
|
||||
if value, ok := values[i].(*sql.NullInt64); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field channel_id", values[i])
|
||||
@@ -606,6 +624,16 @@ func (_m *UsageLog) String() string {
|
||||
builder.WriteString(*v)
|
||||
}
|
||||
builder.WriteString(", ")
|
||||
if v := _m.UpstreamResponseModel; v != nil {
|
||||
builder.WriteString("upstream_response_model=")
|
||||
builder.WriteString(*v)
|
||||
}
|
||||
builder.WriteString(", ")
|
||||
if v := _m.UpstreamModelMismatch; v != nil {
|
||||
builder.WriteString("upstream_model_mismatch=")
|
||||
builder.WriteString(fmt.Sprintf("%v", *v))
|
||||
}
|
||||
builder.WriteString(", ")
|
||||
if v := _m.ChannelID; v != nil {
|
||||
builder.WriteString("channel_id=")
|
||||
builder.WriteString(fmt.Sprintf("%v", *v))
|
||||
|
||||
@@ -28,6 +28,10 @@ const (
|
||||
FieldRequestedModel = "requested_model"
|
||||
// FieldUpstreamModel holds the string denoting the upstream_model field in the database.
|
||||
FieldUpstreamModel = "upstream_model"
|
||||
// FieldUpstreamResponseModel holds the string denoting the upstream_response_model field in the database.
|
||||
FieldUpstreamResponseModel = "upstream_response_model"
|
||||
// FieldUpstreamModelMismatch holds the string denoting the upstream_model_mismatch field in the database.
|
||||
FieldUpstreamModelMismatch = "upstream_model_mismatch"
|
||||
// FieldChannelID holds the string denoting the channel_id field in the database.
|
||||
FieldChannelID = "channel_id"
|
||||
// FieldModelMappingChain holds the string denoting the model_mapping_chain field in the database.
|
||||
@@ -163,6 +167,8 @@ var Columns = []string{
|
||||
FieldModel,
|
||||
FieldRequestedModel,
|
||||
FieldUpstreamModel,
|
||||
FieldUpstreamResponseModel,
|
||||
FieldUpstreamModelMismatch,
|
||||
FieldChannelID,
|
||||
FieldModelMappingChain,
|
||||
FieldBillingTier,
|
||||
@@ -222,6 +228,8 @@ var (
|
||||
RequestedModelValidator func(string) error
|
||||
// UpstreamModelValidator is a validator for the "upstream_model" field. It is called by the builders before save.
|
||||
UpstreamModelValidator func(string) error
|
||||
// UpstreamResponseModelValidator is a validator for the "upstream_response_model" field. It is called by the builders before save.
|
||||
UpstreamResponseModelValidator func(string) error
|
||||
// ModelMappingChainValidator is a validator for the "model_mapping_chain" field. It is called by the builders before save.
|
||||
ModelMappingChainValidator func(string) error
|
||||
// BillingTierValidator is a validator for the "billing_tier" field. It is called by the builders before save.
|
||||
@@ -327,6 +335,16 @@ func ByUpstreamModel(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldUpstreamModel, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByUpstreamResponseModel orders the results by the upstream_response_model field.
|
||||
func ByUpstreamResponseModel(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldUpstreamResponseModel, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByUpstreamModelMismatch orders the results by the upstream_model_mismatch field.
|
||||
func ByUpstreamModelMismatch(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldUpstreamModelMismatch, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByChannelID orders the results by the channel_id field.
|
||||
func ByChannelID(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldChannelID, opts...).ToFunc()
|
||||
|
||||
@@ -90,6 +90,16 @@ func UpstreamModel(v string) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldEQ(FieldUpstreamModel, v))
|
||||
}
|
||||
|
||||
// UpstreamResponseModel applies equality check predicate on the "upstream_response_model" field. It's identical to UpstreamResponseModelEQ.
|
||||
func UpstreamResponseModel(v string) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldEQ(FieldUpstreamResponseModel, v))
|
||||
}
|
||||
|
||||
// UpstreamModelMismatch applies equality check predicate on the "upstream_model_mismatch" field. It's identical to UpstreamModelMismatchEQ.
|
||||
func UpstreamModelMismatch(v bool) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldEQ(FieldUpstreamModelMismatch, v))
|
||||
}
|
||||
|
||||
// ChannelID applies equality check predicate on the "channel_id" field. It's identical to ChannelIDEQ.
|
||||
func ChannelID(v int64) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldEQ(FieldChannelID, v))
|
||||
@@ -615,6 +625,101 @@ func UpstreamModelContainsFold(v string) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldContainsFold(FieldUpstreamModel, v))
|
||||
}
|
||||
|
||||
// UpstreamResponseModelEQ applies the EQ predicate on the "upstream_response_model" field.
|
||||
func UpstreamResponseModelEQ(v string) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldEQ(FieldUpstreamResponseModel, v))
|
||||
}
|
||||
|
||||
// UpstreamResponseModelNEQ applies the NEQ predicate on the "upstream_response_model" field.
|
||||
func UpstreamResponseModelNEQ(v string) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldNEQ(FieldUpstreamResponseModel, v))
|
||||
}
|
||||
|
||||
// UpstreamResponseModelIn applies the In predicate on the "upstream_response_model" field.
|
||||
func UpstreamResponseModelIn(vs ...string) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldIn(FieldUpstreamResponseModel, vs...))
|
||||
}
|
||||
|
||||
// UpstreamResponseModelNotIn applies the NotIn predicate on the "upstream_response_model" field.
|
||||
func UpstreamResponseModelNotIn(vs ...string) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldNotIn(FieldUpstreamResponseModel, vs...))
|
||||
}
|
||||
|
||||
// UpstreamResponseModelGT applies the GT predicate on the "upstream_response_model" field.
|
||||
func UpstreamResponseModelGT(v string) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldGT(FieldUpstreamResponseModel, v))
|
||||
}
|
||||
|
||||
// UpstreamResponseModelGTE applies the GTE predicate on the "upstream_response_model" field.
|
||||
func UpstreamResponseModelGTE(v string) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldGTE(FieldUpstreamResponseModel, v))
|
||||
}
|
||||
|
||||
// UpstreamResponseModelLT applies the LT predicate on the "upstream_response_model" field.
|
||||
func UpstreamResponseModelLT(v string) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldLT(FieldUpstreamResponseModel, v))
|
||||
}
|
||||
|
||||
// UpstreamResponseModelLTE applies the LTE predicate on the "upstream_response_model" field.
|
||||
func UpstreamResponseModelLTE(v string) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldLTE(FieldUpstreamResponseModel, v))
|
||||
}
|
||||
|
||||
// UpstreamResponseModelContains applies the Contains predicate on the "upstream_response_model" field.
|
||||
func UpstreamResponseModelContains(v string) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldContains(FieldUpstreamResponseModel, v))
|
||||
}
|
||||
|
||||
// UpstreamResponseModelHasPrefix applies the HasPrefix predicate on the "upstream_response_model" field.
|
||||
func UpstreamResponseModelHasPrefix(v string) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldHasPrefix(FieldUpstreamResponseModel, v))
|
||||
}
|
||||
|
||||
// UpstreamResponseModelHasSuffix applies the HasSuffix predicate on the "upstream_response_model" field.
|
||||
func UpstreamResponseModelHasSuffix(v string) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldHasSuffix(FieldUpstreamResponseModel, v))
|
||||
}
|
||||
|
||||
// UpstreamResponseModelIsNil applies the IsNil predicate on the "upstream_response_model" field.
|
||||
func UpstreamResponseModelIsNil() predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldIsNull(FieldUpstreamResponseModel))
|
||||
}
|
||||
|
||||
// UpstreamResponseModelNotNil applies the NotNil predicate on the "upstream_response_model" field.
|
||||
func UpstreamResponseModelNotNil() predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldNotNull(FieldUpstreamResponseModel))
|
||||
}
|
||||
|
||||
// UpstreamResponseModelEqualFold applies the EqualFold predicate on the "upstream_response_model" field.
|
||||
func UpstreamResponseModelEqualFold(v string) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldEqualFold(FieldUpstreamResponseModel, v))
|
||||
}
|
||||
|
||||
// UpstreamResponseModelContainsFold applies the ContainsFold predicate on the "upstream_response_model" field.
|
||||
func UpstreamResponseModelContainsFold(v string) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldContainsFold(FieldUpstreamResponseModel, v))
|
||||
}
|
||||
|
||||
// UpstreamModelMismatchEQ applies the EQ predicate on the "upstream_model_mismatch" field.
|
||||
func UpstreamModelMismatchEQ(v bool) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldEQ(FieldUpstreamModelMismatch, v))
|
||||
}
|
||||
|
||||
// UpstreamModelMismatchNEQ applies the NEQ predicate on the "upstream_model_mismatch" field.
|
||||
func UpstreamModelMismatchNEQ(v bool) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldNEQ(FieldUpstreamModelMismatch, v))
|
||||
}
|
||||
|
||||
// UpstreamModelMismatchIsNil applies the IsNil predicate on the "upstream_model_mismatch" field.
|
||||
func UpstreamModelMismatchIsNil() predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldIsNull(FieldUpstreamModelMismatch))
|
||||
}
|
||||
|
||||
// UpstreamModelMismatchNotNil applies the NotNil predicate on the "upstream_model_mismatch" field.
|
||||
func UpstreamModelMismatchNotNil() predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldNotNull(FieldUpstreamModelMismatch))
|
||||
}
|
||||
|
||||
// ChannelIDEQ applies the EQ predicate on the "channel_id" field.
|
||||
func ChannelIDEQ(v int64) predicate.UsageLog {
|
||||
return predicate.UsageLog(sql.FieldEQ(FieldChannelID, v))
|
||||
|
||||
@@ -85,6 +85,34 @@ func (_c *UsageLogCreate) SetNillableUpstreamModel(v *string) *UsageLogCreate {
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetUpstreamResponseModel sets the "upstream_response_model" field.
|
||||
func (_c *UsageLogCreate) SetUpstreamResponseModel(v string) *UsageLogCreate {
|
||||
_c.mutation.SetUpstreamResponseModel(v)
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetNillableUpstreamResponseModel sets the "upstream_response_model" field if the given value is not nil.
|
||||
func (_c *UsageLogCreate) SetNillableUpstreamResponseModel(v *string) *UsageLogCreate {
|
||||
if v != nil {
|
||||
_c.SetUpstreamResponseModel(*v)
|
||||
}
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetUpstreamModelMismatch sets the "upstream_model_mismatch" field.
|
||||
func (_c *UsageLogCreate) SetUpstreamModelMismatch(v bool) *UsageLogCreate {
|
||||
_c.mutation.SetUpstreamModelMismatch(v)
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetNillableUpstreamModelMismatch sets the "upstream_model_mismatch" field if the given value is not nil.
|
||||
func (_c *UsageLogCreate) SetNillableUpstreamModelMismatch(v *bool) *UsageLogCreate {
|
||||
if v != nil {
|
||||
_c.SetUpstreamModelMismatch(*v)
|
||||
}
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetChannelID sets the "channel_id" field.
|
||||
func (_c *UsageLogCreate) SetChannelID(v int64) *UsageLogCreate {
|
||||
_c.mutation.SetChannelID(v)
|
||||
@@ -788,6 +816,11 @@ func (_c *UsageLogCreate) check() error {
|
||||
return &ValidationError{Name: "upstream_model", err: fmt.Errorf(`ent: validator failed for field "UsageLog.upstream_model": %w`, err)}
|
||||
}
|
||||
}
|
||||
if v, ok := _c.mutation.UpstreamResponseModel(); ok {
|
||||
if err := usagelog.UpstreamResponseModelValidator(v); err != nil {
|
||||
return &ValidationError{Name: "upstream_response_model", err: fmt.Errorf(`ent: validator failed for field "UsageLog.upstream_response_model": %w`, err)}
|
||||
}
|
||||
}
|
||||
if v, ok := _c.mutation.ModelMappingChain(); ok {
|
||||
if err := usagelog.ModelMappingChainValidator(v); err != nil {
|
||||
return &ValidationError{Name: "model_mapping_chain", err: fmt.Errorf(`ent: validator failed for field "UsageLog.model_mapping_chain": %w`, err)}
|
||||
@@ -950,6 +983,14 @@ func (_c *UsageLogCreate) createSpec() (*UsageLog, *sqlgraph.CreateSpec) {
|
||||
_spec.SetField(usagelog.FieldUpstreamModel, field.TypeString, value)
|
||||
_node.UpstreamModel = &value
|
||||
}
|
||||
if value, ok := _c.mutation.UpstreamResponseModel(); ok {
|
||||
_spec.SetField(usagelog.FieldUpstreamResponseModel, field.TypeString, value)
|
||||
_node.UpstreamResponseModel = &value
|
||||
}
|
||||
if value, ok := _c.mutation.UpstreamModelMismatch(); ok {
|
||||
_spec.SetField(usagelog.FieldUpstreamModelMismatch, field.TypeBool, value)
|
||||
_node.UpstreamModelMismatch = &value
|
||||
}
|
||||
if value, ok := _c.mutation.ChannelID(); ok {
|
||||
_spec.SetField(usagelog.FieldChannelID, field.TypeInt64, value)
|
||||
_node.ChannelID = &value
|
||||
@@ -1327,6 +1368,42 @@ func (u *UsageLogUpsert) ClearUpstreamModel() *UsageLogUpsert {
|
||||
return u
|
||||
}
|
||||
|
||||
// SetUpstreamResponseModel sets the "upstream_response_model" field.
|
||||
func (u *UsageLogUpsert) SetUpstreamResponseModel(v string) *UsageLogUpsert {
|
||||
u.Set(usagelog.FieldUpstreamResponseModel, v)
|
||||
return u
|
||||
}
|
||||
|
||||
// UpdateUpstreamResponseModel sets the "upstream_response_model" field to the value that was provided on create.
|
||||
func (u *UsageLogUpsert) UpdateUpstreamResponseModel() *UsageLogUpsert {
|
||||
u.SetExcluded(usagelog.FieldUpstreamResponseModel)
|
||||
return u
|
||||
}
|
||||
|
||||
// ClearUpstreamResponseModel clears the value of the "upstream_response_model" field.
|
||||
func (u *UsageLogUpsert) ClearUpstreamResponseModel() *UsageLogUpsert {
|
||||
u.SetNull(usagelog.FieldUpstreamResponseModel)
|
||||
return u
|
||||
}
|
||||
|
||||
// SetUpstreamModelMismatch sets the "upstream_model_mismatch" field.
|
||||
func (u *UsageLogUpsert) SetUpstreamModelMismatch(v bool) *UsageLogUpsert {
|
||||
u.Set(usagelog.FieldUpstreamModelMismatch, v)
|
||||
return u
|
||||
}
|
||||
|
||||
// UpdateUpstreamModelMismatch sets the "upstream_model_mismatch" field to the value that was provided on create.
|
||||
func (u *UsageLogUpsert) UpdateUpstreamModelMismatch() *UsageLogUpsert {
|
||||
u.SetExcluded(usagelog.FieldUpstreamModelMismatch)
|
||||
return u
|
||||
}
|
||||
|
||||
// ClearUpstreamModelMismatch clears the value of the "upstream_model_mismatch" field.
|
||||
func (u *UsageLogUpsert) ClearUpstreamModelMismatch() *UsageLogUpsert {
|
||||
u.SetNull(usagelog.FieldUpstreamModelMismatch)
|
||||
return u
|
||||
}
|
||||
|
||||
// SetChannelID sets the "channel_id" field.
|
||||
func (u *UsageLogUpsert) SetChannelID(v int64) *UsageLogUpsert {
|
||||
u.Set(usagelog.FieldChannelID, v)
|
||||
@@ -2162,6 +2239,48 @@ func (u *UsageLogUpsertOne) ClearUpstreamModel() *UsageLogUpsertOne {
|
||||
})
|
||||
}
|
||||
|
||||
// SetUpstreamResponseModel sets the "upstream_response_model" field.
|
||||
func (u *UsageLogUpsertOne) SetUpstreamResponseModel(v string) *UsageLogUpsertOne {
|
||||
return u.Update(func(s *UsageLogUpsert) {
|
||||
s.SetUpstreamResponseModel(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateUpstreamResponseModel sets the "upstream_response_model" field to the value that was provided on create.
|
||||
func (u *UsageLogUpsertOne) UpdateUpstreamResponseModel() *UsageLogUpsertOne {
|
||||
return u.Update(func(s *UsageLogUpsert) {
|
||||
s.UpdateUpstreamResponseModel()
|
||||
})
|
||||
}
|
||||
|
||||
// ClearUpstreamResponseModel clears the value of the "upstream_response_model" field.
|
||||
func (u *UsageLogUpsertOne) ClearUpstreamResponseModel() *UsageLogUpsertOne {
|
||||
return u.Update(func(s *UsageLogUpsert) {
|
||||
s.ClearUpstreamResponseModel()
|
||||
})
|
||||
}
|
||||
|
||||
// SetUpstreamModelMismatch sets the "upstream_model_mismatch" field.
|
||||
func (u *UsageLogUpsertOne) SetUpstreamModelMismatch(v bool) *UsageLogUpsertOne {
|
||||
return u.Update(func(s *UsageLogUpsert) {
|
||||
s.SetUpstreamModelMismatch(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateUpstreamModelMismatch sets the "upstream_model_mismatch" field to the value that was provided on create.
|
||||
func (u *UsageLogUpsertOne) UpdateUpstreamModelMismatch() *UsageLogUpsertOne {
|
||||
return u.Update(func(s *UsageLogUpsert) {
|
||||
s.UpdateUpstreamModelMismatch()
|
||||
})
|
||||
}
|
||||
|
||||
// ClearUpstreamModelMismatch clears the value of the "upstream_model_mismatch" field.
|
||||
func (u *UsageLogUpsertOne) ClearUpstreamModelMismatch() *UsageLogUpsertOne {
|
||||
return u.Update(func(s *UsageLogUpsert) {
|
||||
s.ClearUpstreamModelMismatch()
|
||||
})
|
||||
}
|
||||
|
||||
// SetChannelID sets the "channel_id" field.
|
||||
func (u *UsageLogUpsertOne) SetChannelID(v int64) *UsageLogUpsertOne {
|
||||
return u.Update(func(s *UsageLogUpsert) {
|
||||
@@ -3276,6 +3395,48 @@ func (u *UsageLogUpsertBulk) ClearUpstreamModel() *UsageLogUpsertBulk {
|
||||
})
|
||||
}
|
||||
|
||||
// SetUpstreamResponseModel sets the "upstream_response_model" field.
|
||||
func (u *UsageLogUpsertBulk) SetUpstreamResponseModel(v string) *UsageLogUpsertBulk {
|
||||
return u.Update(func(s *UsageLogUpsert) {
|
||||
s.SetUpstreamResponseModel(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateUpstreamResponseModel sets the "upstream_response_model" field to the value that was provided on create.
|
||||
func (u *UsageLogUpsertBulk) UpdateUpstreamResponseModel() *UsageLogUpsertBulk {
|
||||
return u.Update(func(s *UsageLogUpsert) {
|
||||
s.UpdateUpstreamResponseModel()
|
||||
})
|
||||
}
|
||||
|
||||
// ClearUpstreamResponseModel clears the value of the "upstream_response_model" field.
|
||||
func (u *UsageLogUpsertBulk) ClearUpstreamResponseModel() *UsageLogUpsertBulk {
|
||||
return u.Update(func(s *UsageLogUpsert) {
|
||||
s.ClearUpstreamResponseModel()
|
||||
})
|
||||
}
|
||||
|
||||
// SetUpstreamModelMismatch sets the "upstream_model_mismatch" field.
|
||||
func (u *UsageLogUpsertBulk) SetUpstreamModelMismatch(v bool) *UsageLogUpsertBulk {
|
||||
return u.Update(func(s *UsageLogUpsert) {
|
||||
s.SetUpstreamModelMismatch(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateUpstreamModelMismatch sets the "upstream_model_mismatch" field to the value that was provided on create.
|
||||
func (u *UsageLogUpsertBulk) UpdateUpstreamModelMismatch() *UsageLogUpsertBulk {
|
||||
return u.Update(func(s *UsageLogUpsert) {
|
||||
s.UpdateUpstreamModelMismatch()
|
||||
})
|
||||
}
|
||||
|
||||
// ClearUpstreamModelMismatch clears the value of the "upstream_model_mismatch" field.
|
||||
func (u *UsageLogUpsertBulk) ClearUpstreamModelMismatch() *UsageLogUpsertBulk {
|
||||
return u.Update(func(s *UsageLogUpsert) {
|
||||
s.ClearUpstreamModelMismatch()
|
||||
})
|
||||
}
|
||||
|
||||
// SetChannelID sets the "channel_id" field.
|
||||
func (u *UsageLogUpsertBulk) SetChannelID(v int64) *UsageLogUpsertBulk {
|
||||
return u.Update(func(s *UsageLogUpsert) {
|
||||
|
||||
@@ -142,6 +142,46 @@ func (_u *UsageLogUpdate) ClearUpstreamModel() *UsageLogUpdate {
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetUpstreamResponseModel sets the "upstream_response_model" field.
|
||||
func (_u *UsageLogUpdate) SetUpstreamResponseModel(v string) *UsageLogUpdate {
|
||||
_u.mutation.SetUpstreamResponseModel(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableUpstreamResponseModel sets the "upstream_response_model" field if the given value is not nil.
|
||||
func (_u *UsageLogUpdate) SetNillableUpstreamResponseModel(v *string) *UsageLogUpdate {
|
||||
if v != nil {
|
||||
_u.SetUpstreamResponseModel(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearUpstreamResponseModel clears the value of the "upstream_response_model" field.
|
||||
func (_u *UsageLogUpdate) ClearUpstreamResponseModel() *UsageLogUpdate {
|
||||
_u.mutation.ClearUpstreamResponseModel()
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetUpstreamModelMismatch sets the "upstream_model_mismatch" field.
|
||||
func (_u *UsageLogUpdate) SetUpstreamModelMismatch(v bool) *UsageLogUpdate {
|
||||
_u.mutation.SetUpstreamModelMismatch(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableUpstreamModelMismatch sets the "upstream_model_mismatch" field if the given value is not nil.
|
||||
func (_u *UsageLogUpdate) SetNillableUpstreamModelMismatch(v *bool) *UsageLogUpdate {
|
||||
if v != nil {
|
||||
_u.SetUpstreamModelMismatch(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearUpstreamModelMismatch clears the value of the "upstream_model_mismatch" field.
|
||||
func (_u *UsageLogUpdate) ClearUpstreamModelMismatch() *UsageLogUpdate {
|
||||
_u.mutation.ClearUpstreamModelMismatch()
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetChannelID sets the "channel_id" field.
|
||||
func (_u *UsageLogUpdate) SetChannelID(v int64) *UsageLogUpdate {
|
||||
_u.mutation.ResetChannelID()
|
||||
@@ -1016,6 +1056,11 @@ func (_u *UsageLogUpdate) check() error {
|
||||
return &ValidationError{Name: "upstream_model", err: fmt.Errorf(`ent: validator failed for field "UsageLog.upstream_model": %w`, err)}
|
||||
}
|
||||
}
|
||||
if v, ok := _u.mutation.UpstreamResponseModel(); ok {
|
||||
if err := usagelog.UpstreamResponseModelValidator(v); err != nil {
|
||||
return &ValidationError{Name: "upstream_response_model", err: fmt.Errorf(`ent: validator failed for field "UsageLog.upstream_response_model": %w`, err)}
|
||||
}
|
||||
}
|
||||
if v, ok := _u.mutation.ModelMappingChain(); ok {
|
||||
if err := usagelog.ModelMappingChainValidator(v); err != nil {
|
||||
return &ValidationError{Name: "model_mapping_chain", err: fmt.Errorf(`ent: validator failed for field "UsageLog.model_mapping_chain": %w`, err)}
|
||||
@@ -1108,6 +1153,18 @@ func (_u *UsageLogUpdate) sqlSave(ctx context.Context) (_node int, err error) {
|
||||
if _u.mutation.UpstreamModelCleared() {
|
||||
_spec.ClearField(usagelog.FieldUpstreamModel, field.TypeString)
|
||||
}
|
||||
if value, ok := _u.mutation.UpstreamResponseModel(); ok {
|
||||
_spec.SetField(usagelog.FieldUpstreamResponseModel, field.TypeString, value)
|
||||
}
|
||||
if _u.mutation.UpstreamResponseModelCleared() {
|
||||
_spec.ClearField(usagelog.FieldUpstreamResponseModel, field.TypeString)
|
||||
}
|
||||
if value, ok := _u.mutation.UpstreamModelMismatch(); ok {
|
||||
_spec.SetField(usagelog.FieldUpstreamModelMismatch, field.TypeBool, value)
|
||||
}
|
||||
if _u.mutation.UpstreamModelMismatchCleared() {
|
||||
_spec.ClearField(usagelog.FieldUpstreamModelMismatch, field.TypeBool)
|
||||
}
|
||||
if value, ok := _u.mutation.ChannelID(); ok {
|
||||
_spec.SetField(usagelog.FieldChannelID, field.TypeInt64, value)
|
||||
}
|
||||
@@ -1599,6 +1656,46 @@ func (_u *UsageLogUpdateOne) ClearUpstreamModel() *UsageLogUpdateOne {
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetUpstreamResponseModel sets the "upstream_response_model" field.
|
||||
func (_u *UsageLogUpdateOne) SetUpstreamResponseModel(v string) *UsageLogUpdateOne {
|
||||
_u.mutation.SetUpstreamResponseModel(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableUpstreamResponseModel sets the "upstream_response_model" field if the given value is not nil.
|
||||
func (_u *UsageLogUpdateOne) SetNillableUpstreamResponseModel(v *string) *UsageLogUpdateOne {
|
||||
if v != nil {
|
||||
_u.SetUpstreamResponseModel(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearUpstreamResponseModel clears the value of the "upstream_response_model" field.
|
||||
func (_u *UsageLogUpdateOne) ClearUpstreamResponseModel() *UsageLogUpdateOne {
|
||||
_u.mutation.ClearUpstreamResponseModel()
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetUpstreamModelMismatch sets the "upstream_model_mismatch" field.
|
||||
func (_u *UsageLogUpdateOne) SetUpstreamModelMismatch(v bool) *UsageLogUpdateOne {
|
||||
_u.mutation.SetUpstreamModelMismatch(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableUpstreamModelMismatch sets the "upstream_model_mismatch" field if the given value is not nil.
|
||||
func (_u *UsageLogUpdateOne) SetNillableUpstreamModelMismatch(v *bool) *UsageLogUpdateOne {
|
||||
if v != nil {
|
||||
_u.SetUpstreamModelMismatch(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearUpstreamModelMismatch clears the value of the "upstream_model_mismatch" field.
|
||||
func (_u *UsageLogUpdateOne) ClearUpstreamModelMismatch() *UsageLogUpdateOne {
|
||||
_u.mutation.ClearUpstreamModelMismatch()
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetChannelID sets the "channel_id" field.
|
||||
func (_u *UsageLogUpdateOne) SetChannelID(v int64) *UsageLogUpdateOne {
|
||||
_u.mutation.ResetChannelID()
|
||||
@@ -2486,6 +2583,11 @@ func (_u *UsageLogUpdateOne) check() error {
|
||||
return &ValidationError{Name: "upstream_model", err: fmt.Errorf(`ent: validator failed for field "UsageLog.upstream_model": %w`, err)}
|
||||
}
|
||||
}
|
||||
if v, ok := _u.mutation.UpstreamResponseModel(); ok {
|
||||
if err := usagelog.UpstreamResponseModelValidator(v); err != nil {
|
||||
return &ValidationError{Name: "upstream_response_model", err: fmt.Errorf(`ent: validator failed for field "UsageLog.upstream_response_model": %w`, err)}
|
||||
}
|
||||
}
|
||||
if v, ok := _u.mutation.ModelMappingChain(); ok {
|
||||
if err := usagelog.ModelMappingChainValidator(v); err != nil {
|
||||
return &ValidationError{Name: "model_mapping_chain", err: fmt.Errorf(`ent: validator failed for field "UsageLog.model_mapping_chain": %w`, err)}
|
||||
@@ -2595,6 +2697,18 @@ func (_u *UsageLogUpdateOne) sqlSave(ctx context.Context) (_node *UsageLog, err
|
||||
if _u.mutation.UpstreamModelCleared() {
|
||||
_spec.ClearField(usagelog.FieldUpstreamModel, field.TypeString)
|
||||
}
|
||||
if value, ok := _u.mutation.UpstreamResponseModel(); ok {
|
||||
_spec.SetField(usagelog.FieldUpstreamResponseModel, field.TypeString, value)
|
||||
}
|
||||
if _u.mutation.UpstreamResponseModelCleared() {
|
||||
_spec.ClearField(usagelog.FieldUpstreamResponseModel, field.TypeString)
|
||||
}
|
||||
if value, ok := _u.mutation.UpstreamModelMismatch(); ok {
|
||||
_spec.SetField(usagelog.FieldUpstreamModelMismatch, field.TypeBool, value)
|
||||
}
|
||||
if _u.mutation.UpstreamModelMismatchCleared() {
|
||||
_spec.ClearField(usagelog.FieldUpstreamModelMismatch, field.TypeBool)
|
||||
}
|
||||
if value, ok := _u.mutation.ChannelID(); ok {
|
||||
_spec.SetField(usagelog.FieldChannelID, field.TypeInt64, value)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -165,6 +175,7 @@ require (
|
||||
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,18 +219,34 @@ 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=
|
||||
@@ -182,6 +254,8 @@ 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=
|
||||
@@ -205,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=
|
||||
@@ -215,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=
|
||||
@@ -259,12 +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/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=
|
||||
@@ -286,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=
|
||||
@@ -322,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=
|
||||
@@ -335,11 +420,13 @@ github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An
|
||||
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=
|
||||
@@ -353,6 +440,10 @@ github.com/stripe/stripe-go/v85 v85.0.0 h1:HMlFJXW6I/9WvkeSAtj8V7dI5pzeDu4gS1Taq
|
||||
github.com/stripe/stripe-go/v85 v85.0.0/go.mod h1:5P+HGFenpWgak27T5Is6JMsmDfUC1yJnjhhmquz7kXw=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/captcha v1.3.52 h1:bPz4h9cPAD2psXNdVNHZUM/V13P05rtXoFIFn1IIPoA=
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/captcha v1.3.52/go.mod h1:KDlcSxrt2pw9nenvXpw/VHipuokbA0j2iRXV+2gF5j8=
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.52 h1:agyo5WB5bclK346U0Y4G40c//eA5qZbtBVGdp3HhuK8=
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.52/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0=
|
||||
github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU=
|
||||
github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY=
|
||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0 h1:s2bIayFXlbDFexo96y+htn7FzuhpXLYJNnIuglNKqOk=
|
||||
@@ -372,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=
|
||||
@@ -386,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=
|
||||
@@ -429,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://*.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://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://*.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()
|
||||
|
||||
|
||||
@@ -64,6 +64,18 @@ func parseTimeRange(c *gin.Context) (time.Time, time.Time) {
|
||||
return startTime, endTime
|
||||
}
|
||||
|
||||
func parseOptionalBoolDashboardFilter(c *gin.Context, name string) (*bool, error) {
|
||||
raw := strings.TrimSpace(c.Query(name))
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
value, err := strconv.ParseBool(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &value, nil
|
||||
}
|
||||
|
||||
// GetStats handles getting dashboard statistics
|
||||
// GET /api/v1/admin/dashboard/stats
|
||||
func (h *DashboardHandler) GetStats(c *gin.Context) {
|
||||
@@ -200,6 +212,7 @@ func (h *DashboardHandler) GetUsageTrend(c *gin.Context) {
|
||||
var requestType *int16
|
||||
var stream *bool
|
||||
var billingType *int8
|
||||
var upstreamModelMismatch *bool
|
||||
|
||||
if userIDStr := c.Query("user_id"); userIDStr != "" {
|
||||
if id, err := strconv.ParseInt(userIDStr, 10, 64); err == nil {
|
||||
@@ -249,8 +262,13 @@ func (h *DashboardHandler) GetUsageTrend(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
upstreamModelMismatch, err := parseOptionalBoolDashboardFilter(c, "upstream_model_mismatch")
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid upstream_model_mismatch value, use true or false")
|
||||
return
|
||||
}
|
||||
|
||||
trend, hit, err := h.getUsageTrendCached(c.Request.Context(), startTime, endTime, granularity, userID, apiKeyID, accountID, groupID, model, requestType, stream, billingType)
|
||||
trend, hit, err := h.getUsageTrendCached(c.Request.Context(), startTime, endTime, granularity, userID, apiKeyID, accountID, groupID, model, requestType, stream, billingType, upstreamModelMismatch)
|
||||
if err != nil {
|
||||
response.Error(c, 500, "Failed to get usage trend")
|
||||
return
|
||||
@@ -277,6 +295,7 @@ func (h *DashboardHandler) GetModelStats(c *gin.Context) {
|
||||
var requestType *int16
|
||||
var stream *bool
|
||||
var billingType *int8
|
||||
var upstreamModelMismatch *bool
|
||||
|
||||
if userIDStr := c.Query("user_id"); userIDStr != "" {
|
||||
if id, err := strconv.ParseInt(userIDStr, 10, 64); err == nil {
|
||||
@@ -330,8 +349,13 @@ func (h *DashboardHandler) GetModelStats(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
upstreamModelMismatch, err := parseOptionalBoolDashboardFilter(c, "upstream_model_mismatch")
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid upstream_model_mismatch value, use true or false")
|
||||
return
|
||||
}
|
||||
|
||||
stats, hit, err := h.getModelStatsCached(c.Request.Context(), startTime, endTime, userID, apiKeyID, accountID, groupID, modelSource, requestType, stream, billingType)
|
||||
stats, hit, err := h.getModelStatsCached(c.Request.Context(), startTime, endTime, userID, apiKeyID, accountID, groupID, modelSource, requestType, stream, billingType, upstreamModelMismatch)
|
||||
if err != nil {
|
||||
response.Error(c, 500, "Failed to get model statistics")
|
||||
return
|
||||
@@ -355,6 +379,7 @@ func (h *DashboardHandler) GetGroupStats(c *gin.Context) {
|
||||
var requestType *int16
|
||||
var stream *bool
|
||||
var billingType *int8
|
||||
var upstreamModelMismatch *bool
|
||||
|
||||
if userIDStr := c.Query("user_id"); userIDStr != "" {
|
||||
if id, err := strconv.ParseInt(userIDStr, 10, 64); err == nil {
|
||||
@@ -401,8 +426,13 @@ func (h *DashboardHandler) GetGroupStats(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
upstreamModelMismatch, err := parseOptionalBoolDashboardFilter(c, "upstream_model_mismatch")
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid upstream_model_mismatch value, use true or false")
|
||||
return
|
||||
}
|
||||
|
||||
stats, hit, err := h.getGroupStatsCached(c.Request.Context(), startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType)
|
||||
stats, hit, err := h.getGroupStatsCached(c.Request.Context(), startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType, upstreamModelMismatch)
|
||||
if err != nil {
|
||||
response.Error(c, 500, "Failed to get group statistics")
|
||||
return
|
||||
|
||||
@@ -19,11 +19,26 @@ type dashboardUsageRepoCapture struct {
|
||||
trendStream *bool
|
||||
modelRequestType *int16
|
||||
modelStream *bool
|
||||
trendMismatch *bool
|
||||
modelMismatch *bool
|
||||
groupMismatch *bool
|
||||
rankingLimit int
|
||||
ranking []usagestats.UserSpendingRankingItem
|
||||
rankingTotal float64
|
||||
}
|
||||
|
||||
func (s *dashboardUsageRepoCapture) GetUsageTrendWithUsageFilters(
|
||||
ctx context.Context,
|
||||
startTime, endTime time.Time,
|
||||
granularity string,
|
||||
filters usagestats.UsageLogFilters,
|
||||
) ([]usagestats.TrendDataPoint, error) {
|
||||
s.trendRequestType = filters.RequestType
|
||||
s.trendStream = filters.Stream
|
||||
s.trendMismatch = filters.UpstreamModelMismatch
|
||||
return []usagestats.TrendDataPoint{}, nil
|
||||
}
|
||||
|
||||
func (s *dashboardUsageRepoCapture) GetUsageTrendWithFilters(
|
||||
ctx context.Context,
|
||||
startTime, endTime time.Time,
|
||||
@@ -39,6 +54,27 @@ func (s *dashboardUsageRepoCapture) GetUsageTrendWithFilters(
|
||||
return []usagestats.TrendDataPoint{}, nil
|
||||
}
|
||||
|
||||
func (s *dashboardUsageRepoCapture) GetModelStatsWithUsageFiltersBySource(
|
||||
ctx context.Context,
|
||||
startTime, endTime time.Time,
|
||||
filters usagestats.UsageLogFilters,
|
||||
source string,
|
||||
) ([]usagestats.ModelStat, error) {
|
||||
s.modelRequestType = filters.RequestType
|
||||
s.modelStream = filters.Stream
|
||||
s.modelMismatch = filters.UpstreamModelMismatch
|
||||
return []usagestats.ModelStat{}, nil
|
||||
}
|
||||
|
||||
func (s *dashboardUsageRepoCapture) GetGroupStatsWithUsageFilters(
|
||||
ctx context.Context,
|
||||
startTime, endTime time.Time,
|
||||
filters usagestats.UsageLogFilters,
|
||||
) ([]usagestats.GroupStat, error) {
|
||||
s.groupMismatch = filters.UpstreamModelMismatch
|
||||
return []usagestats.GroupStat{}, nil
|
||||
}
|
||||
|
||||
func (s *dashboardUsageRepoCapture) GetModelStatsWithFilters(
|
||||
ctx context.Context,
|
||||
startTime, endTime time.Time,
|
||||
@@ -73,6 +109,7 @@ func newDashboardRequestTypeTestRouter(repo *dashboardUsageRepoCapture) *gin.Eng
|
||||
router := gin.New()
|
||||
router.GET("/admin/dashboard/trend", handler.GetUsageTrend)
|
||||
router.GET("/admin/dashboard/models", handler.GetModelStats)
|
||||
router.GET("/admin/dashboard/groups", handler.GetGroupStats)
|
||||
router.GET("/admin/dashboard/users-ranking", handler.GetUserSpendingRanking)
|
||||
return router
|
||||
}
|
||||
@@ -171,6 +208,46 @@ func TestDashboardModelStatsValidModelSource(t *testing.T) {
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
}
|
||||
|
||||
func TestDashboardModelAuditFilterPropagatesToTrendModelAndGroupQueries(t *testing.T) {
|
||||
resetDashboardReadCachesForTest()
|
||||
repo := &dashboardUsageRepoCapture{}
|
||||
router := newDashboardRequestTypeTestRouter(repo)
|
||||
|
||||
for _, path := range []string{
|
||||
"/admin/dashboard/trend?upstream_model_mismatch=true",
|
||||
"/admin/dashboard/models?upstream_model_mismatch=true",
|
||||
"/admin/dashboard/groups?upstream_model_mismatch=true",
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
require.Equal(t, http.StatusOK, rec.Code, path)
|
||||
}
|
||||
|
||||
require.NotNil(t, repo.trendMismatch)
|
||||
require.True(t, *repo.trendMismatch)
|
||||
require.NotNil(t, repo.modelMismatch)
|
||||
require.True(t, *repo.modelMismatch)
|
||||
require.NotNil(t, repo.groupMismatch)
|
||||
require.True(t, *repo.groupMismatch)
|
||||
}
|
||||
|
||||
func TestDashboardModelAuditFilterRejectsInvalidBoolean(t *testing.T) {
|
||||
repo := &dashboardUsageRepoCapture{}
|
||||
router := newDashboardRequestTypeTestRouter(repo)
|
||||
|
||||
for _, path := range []string{
|
||||
"/admin/dashboard/trend?upstream_model_mismatch=invalid",
|
||||
"/admin/dashboard/models?upstream_model_mismatch=invalid",
|
||||
"/admin/dashboard/groups?upstream_model_mismatch=invalid",
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code, path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardUsersRankingLimitAndCache(t *testing.T) {
|
||||
dashboardUsersRankingCache = newSnapshotCache(5 * time.Minute)
|
||||
repo := &dashboardUsageRepoCapture{
|
||||
|
||||
@@ -18,30 +18,32 @@ var (
|
||||
)
|
||||
|
||||
type dashboardTrendCacheKey struct {
|
||||
StartTime string `json:"start_time"`
|
||||
EndTime string `json:"end_time"`
|
||||
Granularity string `json:"granularity"`
|
||||
UserID int64 `json:"user_id"`
|
||||
APIKeyID int64 `json:"api_key_id"`
|
||||
AccountID int64 `json:"account_id"`
|
||||
GroupID int64 `json:"group_id"`
|
||||
Model string `json:"model"`
|
||||
RequestType *int16 `json:"request_type"`
|
||||
Stream *bool `json:"stream"`
|
||||
BillingType *int8 `json:"billing_type"`
|
||||
StartTime string `json:"start_time"`
|
||||
EndTime string `json:"end_time"`
|
||||
Granularity string `json:"granularity"`
|
||||
UserID int64 `json:"user_id"`
|
||||
APIKeyID int64 `json:"api_key_id"`
|
||||
AccountID int64 `json:"account_id"`
|
||||
GroupID int64 `json:"group_id"`
|
||||
Model string `json:"model"`
|
||||
RequestType *int16 `json:"request_type"`
|
||||
Stream *bool `json:"stream"`
|
||||
BillingType *int8 `json:"billing_type"`
|
||||
UpstreamModelMismatch *bool `json:"upstream_model_mismatch"`
|
||||
}
|
||||
|
||||
type dashboardModelGroupCacheKey struct {
|
||||
StartTime string `json:"start_time"`
|
||||
EndTime string `json:"end_time"`
|
||||
UserID int64 `json:"user_id"`
|
||||
APIKeyID int64 `json:"api_key_id"`
|
||||
AccountID int64 `json:"account_id"`
|
||||
GroupID int64 `json:"group_id"`
|
||||
ModelSource string `json:"model_source,omitempty"`
|
||||
RequestType *int16 `json:"request_type"`
|
||||
Stream *bool `json:"stream"`
|
||||
BillingType *int8 `json:"billing_type"`
|
||||
StartTime string `json:"start_time"`
|
||||
EndTime string `json:"end_time"`
|
||||
UserID int64 `json:"user_id"`
|
||||
APIKeyID int64 `json:"api_key_id"`
|
||||
AccountID int64 `json:"account_id"`
|
||||
GroupID int64 `json:"group_id"`
|
||||
ModelSource string `json:"model_source,omitempty"`
|
||||
RequestType *int16 `json:"request_type"`
|
||||
Stream *bool `json:"stream"`
|
||||
BillingType *int8 `json:"billing_type"`
|
||||
UpstreamModelMismatch *bool `json:"upstream_model_mismatch"`
|
||||
}
|
||||
|
||||
type dashboardEntityTrendCacheKey struct {
|
||||
@@ -84,22 +86,28 @@ func (h *DashboardHandler) getUsageTrendCached(
|
||||
requestType *int16,
|
||||
stream *bool,
|
||||
billingType *int8,
|
||||
upstreamModelMismatch *bool,
|
||||
) ([]usagestats.TrendDataPoint, bool, error) {
|
||||
key := mustMarshalDashboardCacheKey(dashboardTrendCacheKey{
|
||||
StartTime: startTime.UTC().Format(time.RFC3339),
|
||||
EndTime: endTime.UTC().Format(time.RFC3339),
|
||||
Granularity: granularity,
|
||||
UserID: userID,
|
||||
APIKeyID: apiKeyID,
|
||||
AccountID: accountID,
|
||||
GroupID: groupID,
|
||||
Model: model,
|
||||
RequestType: requestType,
|
||||
Stream: stream,
|
||||
BillingType: billingType,
|
||||
StartTime: startTime.UTC().Format(time.RFC3339),
|
||||
EndTime: endTime.UTC().Format(time.RFC3339),
|
||||
Granularity: granularity,
|
||||
UserID: userID,
|
||||
APIKeyID: apiKeyID,
|
||||
AccountID: accountID,
|
||||
GroupID: groupID,
|
||||
Model: model,
|
||||
RequestType: requestType,
|
||||
Stream: stream,
|
||||
BillingType: billingType,
|
||||
UpstreamModelMismatch: upstreamModelMismatch,
|
||||
})
|
||||
entry, hit, err := dashboardTrendCache.GetOrLoad(key, func() (any, error) {
|
||||
return h.dashboardService.GetUsageTrendWithFilters(ctx, startTime, endTime, granularity, userID, apiKeyID, accountID, groupID, model, requestType, stream, billingType)
|
||||
return h.dashboardService.GetUsageTrendWithUsageFilters(ctx, startTime, endTime, granularity, usagestats.UsageLogFilters{
|
||||
UserID: userID, APIKeyID: apiKeyID, AccountID: accountID, GroupID: groupID,
|
||||
Model: model, RequestType: requestType, Stream: stream, BillingType: billingType,
|
||||
UpstreamModelMismatch: upstreamModelMismatch,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, hit, err
|
||||
@@ -116,21 +124,27 @@ func (h *DashboardHandler) getModelStatsCached(
|
||||
requestType *int16,
|
||||
stream *bool,
|
||||
billingType *int8,
|
||||
upstreamModelMismatch *bool,
|
||||
) ([]usagestats.ModelStat, bool, error) {
|
||||
key := mustMarshalDashboardCacheKey(dashboardModelGroupCacheKey{
|
||||
StartTime: startTime.UTC().Format(time.RFC3339),
|
||||
EndTime: endTime.UTC().Format(time.RFC3339),
|
||||
UserID: userID,
|
||||
APIKeyID: apiKeyID,
|
||||
AccountID: accountID,
|
||||
GroupID: groupID,
|
||||
ModelSource: usagestats.NormalizeModelSource(modelSource),
|
||||
RequestType: requestType,
|
||||
Stream: stream,
|
||||
BillingType: billingType,
|
||||
StartTime: startTime.UTC().Format(time.RFC3339),
|
||||
EndTime: endTime.UTC().Format(time.RFC3339),
|
||||
UserID: userID,
|
||||
APIKeyID: apiKeyID,
|
||||
AccountID: accountID,
|
||||
GroupID: groupID,
|
||||
ModelSource: usagestats.NormalizeModelSource(modelSource),
|
||||
RequestType: requestType,
|
||||
Stream: stream,
|
||||
BillingType: billingType,
|
||||
UpstreamModelMismatch: upstreamModelMismatch,
|
||||
})
|
||||
entry, hit, err := dashboardModelStatsCache.GetOrLoad(key, func() (any, error) {
|
||||
return h.dashboardService.GetModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType, modelSource)
|
||||
return h.dashboardService.GetModelStatsWithUsageFiltersBySource(ctx, startTime, endTime, usagestats.UsageLogFilters{
|
||||
UserID: userID, APIKeyID: apiKeyID, AccountID: accountID, GroupID: groupID,
|
||||
RequestType: requestType, Stream: stream, BillingType: billingType,
|
||||
UpstreamModelMismatch: upstreamModelMismatch,
|
||||
}, modelSource)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, hit, err
|
||||
@@ -146,20 +160,26 @@ func (h *DashboardHandler) getGroupStatsCached(
|
||||
requestType *int16,
|
||||
stream *bool,
|
||||
billingType *int8,
|
||||
upstreamModelMismatch *bool,
|
||||
) ([]usagestats.GroupStat, bool, error) {
|
||||
key := mustMarshalDashboardCacheKey(dashboardModelGroupCacheKey{
|
||||
StartTime: startTime.UTC().Format(time.RFC3339),
|
||||
EndTime: endTime.UTC().Format(time.RFC3339),
|
||||
UserID: userID,
|
||||
APIKeyID: apiKeyID,
|
||||
AccountID: accountID,
|
||||
GroupID: groupID,
|
||||
RequestType: requestType,
|
||||
Stream: stream,
|
||||
BillingType: billingType,
|
||||
StartTime: startTime.UTC().Format(time.RFC3339),
|
||||
EndTime: endTime.UTC().Format(time.RFC3339),
|
||||
UserID: userID,
|
||||
APIKeyID: apiKeyID,
|
||||
AccountID: accountID,
|
||||
GroupID: groupID,
|
||||
RequestType: requestType,
|
||||
Stream: stream,
|
||||
BillingType: billingType,
|
||||
UpstreamModelMismatch: upstreamModelMismatch,
|
||||
})
|
||||
entry, hit, err := dashboardGroupStatsCache.GetOrLoad(key, func() (any, error) {
|
||||
return h.dashboardService.GetGroupStatsWithFilters(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType)
|
||||
return h.dashboardService.GetGroupStatsWithUsageFilters(ctx, startTime, endTime, usagestats.UsageLogFilters{
|
||||
UserID: userID, APIKeyID: apiKeyID, AccountID: accountID, GroupID: groupID,
|
||||
RequestType: requestType, Stream: stream, BillingType: billingType,
|
||||
UpstreamModelMismatch: upstreamModelMismatch,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, hit, err
|
||||
|
||||
@@ -37,34 +37,36 @@ type dashboardSnapshotV2Response struct {
|
||||
}
|
||||
|
||||
type dashboardSnapshotV2Filters struct {
|
||||
UserID int64
|
||||
APIKeyID int64
|
||||
AccountID int64
|
||||
GroupID int64
|
||||
Model string
|
||||
RequestType *int16
|
||||
Stream *bool
|
||||
BillingType *int8
|
||||
UserID int64
|
||||
APIKeyID int64
|
||||
AccountID int64
|
||||
GroupID int64
|
||||
Model string
|
||||
RequestType *int16
|
||||
Stream *bool
|
||||
BillingType *int8
|
||||
UpstreamModelMismatch *bool
|
||||
}
|
||||
|
||||
type dashboardSnapshotV2CacheKey struct {
|
||||
StartTime string `json:"start_time"`
|
||||
EndTime string `json:"end_time"`
|
||||
Granularity string `json:"granularity"`
|
||||
UserID int64 `json:"user_id"`
|
||||
APIKeyID int64 `json:"api_key_id"`
|
||||
AccountID int64 `json:"account_id"`
|
||||
GroupID int64 `json:"group_id"`
|
||||
Model string `json:"model"`
|
||||
RequestType *int16 `json:"request_type"`
|
||||
Stream *bool `json:"stream"`
|
||||
BillingType *int8 `json:"billing_type"`
|
||||
IncludeStats bool `json:"include_stats"`
|
||||
IncludeTrend bool `json:"include_trend"`
|
||||
IncludeModels bool `json:"include_models"`
|
||||
IncludeGroups bool `json:"include_groups"`
|
||||
IncludeUsersTrend bool `json:"include_users_trend"`
|
||||
UsersTrendLimit int `json:"users_trend_limit"`
|
||||
StartTime string `json:"start_time"`
|
||||
EndTime string `json:"end_time"`
|
||||
Granularity string `json:"granularity"`
|
||||
UserID int64 `json:"user_id"`
|
||||
APIKeyID int64 `json:"api_key_id"`
|
||||
AccountID int64 `json:"account_id"`
|
||||
GroupID int64 `json:"group_id"`
|
||||
Model string `json:"model"`
|
||||
RequestType *int16 `json:"request_type"`
|
||||
Stream *bool `json:"stream"`
|
||||
BillingType *int8 `json:"billing_type"`
|
||||
UpstreamModelMismatch *bool `json:"upstream_model_mismatch"`
|
||||
IncludeStats bool `json:"include_stats"`
|
||||
IncludeTrend bool `json:"include_trend"`
|
||||
IncludeModels bool `json:"include_models"`
|
||||
IncludeGroups bool `json:"include_groups"`
|
||||
IncludeUsersTrend bool `json:"include_users_trend"`
|
||||
UsersTrendLimit int `json:"users_trend_limit"`
|
||||
}
|
||||
|
||||
func (h *DashboardHandler) GetSnapshotV2(c *gin.Context) {
|
||||
@@ -93,23 +95,24 @@ func (h *DashboardHandler) GetSnapshotV2(c *gin.Context) {
|
||||
}
|
||||
|
||||
keyRaw, _ := json.Marshal(dashboardSnapshotV2CacheKey{
|
||||
StartTime: startTime.UTC().Format(time.RFC3339),
|
||||
EndTime: endTime.UTC().Format(time.RFC3339),
|
||||
Granularity: granularity,
|
||||
UserID: filters.UserID,
|
||||
APIKeyID: filters.APIKeyID,
|
||||
AccountID: filters.AccountID,
|
||||
GroupID: filters.GroupID,
|
||||
Model: filters.Model,
|
||||
RequestType: filters.RequestType,
|
||||
Stream: filters.Stream,
|
||||
BillingType: filters.BillingType,
|
||||
IncludeStats: includeStats,
|
||||
IncludeTrend: includeTrend,
|
||||
IncludeModels: includeModels,
|
||||
IncludeGroups: includeGroups,
|
||||
IncludeUsersTrend: includeUsersTrend,
|
||||
UsersTrendLimit: usersTrendLimit,
|
||||
StartTime: startTime.UTC().Format(time.RFC3339),
|
||||
EndTime: endTime.UTC().Format(time.RFC3339),
|
||||
Granularity: granularity,
|
||||
UserID: filters.UserID,
|
||||
APIKeyID: filters.APIKeyID,
|
||||
AccountID: filters.AccountID,
|
||||
GroupID: filters.GroupID,
|
||||
Model: filters.Model,
|
||||
RequestType: filters.RequestType,
|
||||
Stream: filters.Stream,
|
||||
BillingType: filters.BillingType,
|
||||
UpstreamModelMismatch: filters.UpstreamModelMismatch,
|
||||
IncludeStats: includeStats,
|
||||
IncludeTrend: includeTrend,
|
||||
IncludeModels: includeModels,
|
||||
IncludeGroups: includeGroups,
|
||||
IncludeUsersTrend: includeUsersTrend,
|
||||
UsersTrendLimit: usersTrendLimit,
|
||||
})
|
||||
cacheKey := string(keyRaw)
|
||||
|
||||
@@ -184,6 +187,7 @@ func (h *DashboardHandler) buildSnapshotV2Response(
|
||||
filters.RequestType,
|
||||
filters.Stream,
|
||||
filters.BillingType,
|
||||
filters.UpstreamModelMismatch,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, errors.New("failed to get usage trend")
|
||||
@@ -204,6 +208,7 @@ func (h *DashboardHandler) buildSnapshotV2Response(
|
||||
filters.RequestType,
|
||||
filters.Stream,
|
||||
filters.BillingType,
|
||||
filters.UpstreamModelMismatch,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, errors.New("failed to get model statistics")
|
||||
@@ -223,6 +228,7 @@ func (h *DashboardHandler) buildSnapshotV2Response(
|
||||
filters.RequestType,
|
||||
filters.Stream,
|
||||
filters.BillingType,
|
||||
filters.UpstreamModelMismatch,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, errors.New("failed to get group statistics")
|
||||
@@ -299,5 +305,13 @@ func parseDashboardSnapshotV2Filters(c *gin.Context) (*dashboardSnapshotV2Filter
|
||||
filters.BillingType = &bt
|
||||
}
|
||||
|
||||
if mismatchStr := strings.TrimSpace(c.Query("upstream_model_mismatch")); mismatchStr != "" {
|
||||
value, err := strconv.ParseBool(mismatchStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
filters.UpstreamModelMismatch = &value
|
||||
}
|
||||
|
||||
return filters, nil
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -156,6 +163,18 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
|
||||
TurnstileEnabled: settings.TurnstileEnabled,
|
||||
TurnstileSiteKey: settings.TurnstileSiteKey,
|
||||
TurnstileSecretKeyConfigured: settings.TurnstileSecretKeyConfigured,
|
||||
TencentCaptchaEnabled: settings.TencentCaptchaEnabled,
|
||||
TencentCaptchaAppID: settings.TencentCaptchaAppID,
|
||||
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,
|
||||
|
||||
@@ -107,6 +107,42 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
|
||||
if req.TurnstileSecretKey != "" {
|
||||
changed = append(changed, "turnstile_secret_key")
|
||||
}
|
||||
if before.TencentCaptchaEnabled != after.TencentCaptchaEnabled {
|
||||
changed = append(changed, "tencent_captcha_enabled")
|
||||
}
|
||||
if before.TencentCaptchaAppID != after.TencentCaptchaAppID {
|
||||
changed = append(changed, "tencent_captcha_app_id")
|
||||
}
|
||||
if req.TencentCaptchaAppSecretKey != "" {
|
||||
changed = append(changed, "tencent_captcha_app_secret_key")
|
||||
}
|
||||
if req.TencentCaptchaCloudSecretID != "" {
|
||||
changed = append(changed, "tencent_captcha_cloud_secret_id")
|
||||
}
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -65,3 +65,112 @@ func TestUpdateSettingsSMTPFromAliasIsWritable(t *testing.T) {
|
||||
|
||||
require.Equal(t, "new@example.com", repo.values[service.SettingKeySMTPFrom])
|
||||
}
|
||||
|
||||
func TestUpdateSettingsRejectsTwoCaptchaProviders(t *testing.T) {
|
||||
h, _ := newStepUpSwitchTestHandler(t, map[string]string{
|
||||
service.SettingKeyTurnstileEnabled: "true",
|
||||
service.SettingKeyTurnstileSiteKey: "site-key",
|
||||
service.SettingKeyTurnstileSecretKey: "turnstile-secret",
|
||||
})
|
||||
|
||||
rec := doUpdateSettings(t, h, map[string]any{
|
||||
"turnstile_enabled": true,
|
||||
"turnstile_site_key": "site-key",
|
||||
"turnstile_secret_key": "turnstile-secret",
|
||||
"tencent_captcha_enabled": true,
|
||||
"tencent_captcha_app_id": "123456789",
|
||||
"tencent_captcha_app_secret_key": "app-secret",
|
||||
"tencent_captcha_cloud_secret_id": "cloud-secret-id",
|
||||
"tencent_captcha_cloud_secret_key": "cloud-secret-key",
|
||||
}, nil)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
require.Contains(t, rec.Body.String(), "cannot be enabled at the same time")
|
||||
}
|
||||
|
||||
func TestUpdateSettingsRequiresFourTencentCaptchaCredentialsWhenEnabled(t *testing.T) {
|
||||
h, _ := newStepUpSwitchTestHandler(t, map[string]string{})
|
||||
|
||||
rec := doUpdateSettings(t, h, map[string]any{
|
||||
"tencent_captcha_enabled": true,
|
||||
"tencent_captcha_app_id": "123456789",
|
||||
}, nil)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
require.Contains(t, rec.Body.String(), "AppSecretKey")
|
||||
}
|
||||
|
||||
func TestUpdateSettingsRetainsStoredTencentCaptchaCredentialsWhenInputsEmpty(t *testing.T) {
|
||||
h, repo := newStepUpSwitchTestHandler(t, map[string]string{
|
||||
service.SettingKeyTencentCaptchaAppSecretKey: "stored-app-secret",
|
||||
service.SettingKeyTencentCaptchaCloudSecretID: "stored-cloud-secret-id",
|
||||
service.SettingKeyTencentCaptchaCloudSecretKey: "stored-cloud-secret-key",
|
||||
})
|
||||
|
||||
rec := doUpdateSettings(t, h, map[string]any{
|
||||
"tencent_captcha_enabled": true,
|
||||
"tencent_captcha_app_id": "123456789",
|
||||
"tencent_captcha_app_secret_key": "",
|
||||
"tencent_captcha_cloud_secret_id": "",
|
||||
"tencent_captcha_cloud_secret_key": "",
|
||||
}, nil)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Equal(t, "stored-app-secret", repo.values[service.SettingKeyTencentCaptchaAppSecretKey])
|
||||
require.Equal(t, "stored-cloud-secret-id", repo.values[service.SettingKeyTencentCaptchaCloudSecretID])
|
||||
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",
|
||||
service.SettingKeyTencentCaptchaAppID: "123456789",
|
||||
service.SettingKeyTencentCaptchaAppSecretKey: "stored-app-secret",
|
||||
service.SettingKeyTencentCaptchaCloudSecretID: "stored-cloud-secret-id",
|
||||
service.SettingKeyTencentCaptchaCloudSecretKey: "stored-cloud-secret-key",
|
||||
})
|
||||
|
||||
rec := doUpdateSettings(t, h, map[string]any{
|
||||
"tencent_captcha_app_id": "not-a-number",
|
||||
}, nil)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
require.Contains(t, rec.Body.String(), "positive integer")
|
||||
}
|
||||
|
||||
@@ -64,6 +64,23 @@ func TestDiffSettings_NoChangeWhenEqual(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsAuditRequestDoesNotInheritStoredTencentSecrets(t *testing.T) {
|
||||
req := UpdateSettingsRequest{
|
||||
TencentCaptchaAppSecretKey: " ",
|
||||
TencentCaptchaCloudSecretID: "\t",
|
||||
TencentCaptchaCloudSecretKey: "\n",
|
||||
}
|
||||
|
||||
auditReq := settingsAuditRequest(req)
|
||||
req.TencentCaptchaAppSecretKey = "stored-app-secret"
|
||||
req.TencentCaptchaCloudSecretID = "stored-secret-id"
|
||||
req.TencentCaptchaCloudSecretKey = "stored-secret-key"
|
||||
|
||||
require.Empty(t, auditReq.TencentCaptchaAppSecretKey)
|
||||
require.Empty(t, auditReq.TencentCaptchaCloudSecretID)
|
||||
require.Empty(t, auditReq.TencentCaptchaCloudSecretKey)
|
||||
}
|
||||
|
||||
func TestDiffSettings_DetectsCompactHomeChange(t *testing.T) {
|
||||
changed := diffSettings(
|
||||
&service.SystemSettings{},
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
@@ -53,6 +54,22 @@ type UpdateSettingsRequest struct {
|
||||
TurnstileSiteKey string `json:"turnstile_site_key"`
|
||||
TurnstileSecretKey string `json:"turnstile_secret_key"`
|
||||
|
||||
// 腾讯天御验证码设置
|
||||
TencentCaptchaEnabled bool `json:"tencent_captcha_enabled"`
|
||||
TencentCaptchaAppID string `json:"tencent_captcha_app_id"`
|
||||
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"`
|
||||
ForwardedClientIPHeaders *[]string `json:"forwarded_client_ip_headers"`
|
||||
@@ -438,6 +455,14 @@ func omittedSettingKeys(sentFields map[string]json.RawMessage) service.OmittedSe
|
||||
return omitted
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
var sentFields map[string]json.RawMessage
|
||||
if err := c.ShouldBindBodyWith(&sentFields, binding.JSON); err != nil {
|
||||
@@ -449,6 +474,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
auditReq := settingsAuditRequest(req)
|
||||
omitted := omittedSettingKeys(sentFields)
|
||||
|
||||
previousSettings, err := h.settingService.GetAllSettings(c.Request.Context())
|
||||
@@ -565,6 +591,10 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
req.SMTPPassword = strings.TrimSpace(req.SMTPPassword)
|
||||
req.SMTPFrom = strings.TrimSpace(req.SMTPFrom)
|
||||
req.SMTPFromName = strings.TrimSpace(req.SMTPFromName)
|
||||
req.TencentCaptchaAppID = strings.TrimSpace(req.TencentCaptchaAppID)
|
||||
req.TencentCaptchaAppSecretKey = strings.TrimSpace(req.TencentCaptchaAppSecretKey)
|
||||
req.TencentCaptchaCloudSecretID = strings.TrimSpace(req.TencentCaptchaCloudSecretID)
|
||||
req.TencentCaptchaCloudSecretKey = strings.TrimSpace(req.TencentCaptchaCloudSecretKey)
|
||||
if req.SMTPPort <= 0 {
|
||||
req.SMTPPort = 587
|
||||
}
|
||||
@@ -586,6 +616,43 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
req.SMTPUseTLS = previousSettings.SMTPUseTLS
|
||||
}
|
||||
|
||||
turnstileEnabled := req.TurnstileEnabled
|
||||
if _, sent := sentFields["turnstile_enabled"]; !sent {
|
||||
turnstileEnabled = previousSettings.TurnstileEnabled
|
||||
}
|
||||
tencentCaptchaEnabled := req.TencentCaptchaEnabled
|
||||
if _, sent := sentFields["tencent_captcha_enabled"]; !sent {
|
||||
tencentCaptchaEnabled = previousSettings.TencentCaptchaEnabled
|
||||
}
|
||||
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 {
|
||||
// 检查必填字段
|
||||
@@ -613,6 +680,83 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
if tencentCaptchaEnabled {
|
||||
if _, sent := sentFields["tencent_captcha_app_id"]; !sent {
|
||||
req.TencentCaptchaAppID = previousSettings.TencentCaptchaAppID
|
||||
}
|
||||
appID, err := strconv.ParseUint(req.TencentCaptchaAppID, 10, 64)
|
||||
if err != nil || appID == 0 {
|
||||
response.BadRequest(c, "Tencent Captcha CaptchaAppId must be a positive integer when enabled")
|
||||
return
|
||||
}
|
||||
if req.TencentCaptchaAppSecretKey == "" {
|
||||
req.TencentCaptchaAppSecretKey = previousSettings.TencentCaptchaAppSecretKey
|
||||
}
|
||||
if req.TencentCaptchaCloudSecretID == "" {
|
||||
req.TencentCaptchaCloudSecretID = previousSettings.TencentCaptchaCloudSecretID
|
||||
}
|
||||
if req.TencentCaptchaCloudSecretKey == "" {
|
||||
req.TencentCaptchaCloudSecretKey = previousSettings.TencentCaptchaCloudSecretKey
|
||||
}
|
||||
if req.TencentCaptchaAppSecretKey == "" {
|
||||
response.BadRequest(c, "Tencent Captcha AppSecretKey is required when enabled")
|
||||
return
|
||||
}
|
||||
if req.TencentCaptchaCloudSecretID == "" {
|
||||
response.BadRequest(c, "Tencent Cloud SecretId is required when Tencent Captcha is enabled")
|
||||
return
|
||||
}
|
||||
if req.TencentCaptchaCloudSecretKey == "" {
|
||||
response.BadRequest(c, "Tencent Cloud SecretKey is required when Tencent Captcha is enabled")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 阿里云验证码 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 {
|
||||
@@ -1360,6 +1504,18 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
TurnstileEnabled: req.TurnstileEnabled,
|
||||
TurnstileSiteKey: req.TurnstileSiteKey,
|
||||
TurnstileSecretKey: req.TurnstileSecretKey,
|
||||
TencentCaptchaEnabled: req.TencentCaptchaEnabled,
|
||||
TencentCaptchaAppID: req.TencentCaptchaAppID,
|
||||
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
|
||||
@@ -1869,7 +2025,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
h.auditSettingsUpdate(c, previousSettings, settings, previousAuthSourceDefaults, authSourceDefaults, req)
|
||||
h.auditSettingsUpdate(c, previousSettings, settings, previousAuthSourceDefaults, authSourceDefaults, auditReq)
|
||||
|
||||
// 重新获取设置返回
|
||||
updatedSettings, err := h.settingService.GetAllSettings(c.Request.Context())
|
||||
@@ -1932,6 +2088,18 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
TurnstileEnabled: updatedSettings.TurnstileEnabled,
|
||||
TurnstileSiteKey: updatedSettings.TurnstileSiteKey,
|
||||
TurnstileSecretKeyConfigured: updatedSettings.TurnstileSecretKeyConfigured,
|
||||
TencentCaptchaEnabled: updatedSettings.TencentCaptchaEnabled,
|
||||
TencentCaptchaAppID: updatedSettings.TencentCaptchaAppID,
|
||||
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,
|
||||
|
||||
@@ -143,6 +143,16 @@ func (h *UsageHandler) List(c *gin.Context) {
|
||||
billingType = &bt
|
||||
}
|
||||
|
||||
var upstreamModelMismatch *bool
|
||||
if raw := strings.TrimSpace(c.Query("upstream_model_mismatch")); raw != "" {
|
||||
value, err := strconv.ParseBool(raw)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid upstream_model_mismatch value, use true or false")
|
||||
return
|
||||
}
|
||||
upstreamModelMismatch = &value
|
||||
}
|
||||
|
||||
// Parse date range
|
||||
var startTime, endTime *time.Time
|
||||
userTZ := c.Query("timezone") // Get user's timezone from request
|
||||
@@ -173,20 +183,21 @@ func (h *UsageHandler) List(c *gin.Context) {
|
||||
SortOrder: c.DefaultQuery("sort_order", "desc"),
|
||||
}
|
||||
filters := usagestats.UsageLogFilters{
|
||||
UserID: userID,
|
||||
APIKeyID: apiKeyID,
|
||||
AccountID: accountID,
|
||||
GroupID: groupID,
|
||||
RequestID: requestID,
|
||||
Model: model,
|
||||
ModelFilterSource: usagestats.ModelSourceRequested,
|
||||
RequestType: requestType,
|
||||
Stream: stream,
|
||||
BillingType: billingType,
|
||||
BillingMode: billingMode,
|
||||
StartTime: startTime,
|
||||
EndTime: endTime,
|
||||
ExactTotal: exactTotal,
|
||||
UserID: userID,
|
||||
APIKeyID: apiKeyID,
|
||||
AccountID: accountID,
|
||||
GroupID: groupID,
|
||||
RequestID: requestID,
|
||||
Model: model,
|
||||
ModelFilterSource: usagestats.ModelSourceRequested,
|
||||
RequestType: requestType,
|
||||
Stream: stream,
|
||||
BillingType: billingType,
|
||||
BillingMode: billingMode,
|
||||
UpstreamModelMismatch: upstreamModelMismatch,
|
||||
StartTime: startTime,
|
||||
EndTime: endTime,
|
||||
ExactTotal: exactTotal,
|
||||
}
|
||||
|
||||
records, result, err := h.usageService.ListWithFilters(c.Request.Context(), params, filters)
|
||||
@@ -276,6 +287,16 @@ func (h *UsageHandler) Stats(c *gin.Context) {
|
||||
billingType = &bt
|
||||
}
|
||||
|
||||
var upstreamModelMismatch *bool
|
||||
if raw := strings.TrimSpace(c.Query("upstream_model_mismatch")); raw != "" {
|
||||
value, err := strconv.ParseBool(raw)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid upstream_model_mismatch value, use true or false")
|
||||
return
|
||||
}
|
||||
upstreamModelMismatch = &value
|
||||
}
|
||||
|
||||
// Parse date range
|
||||
userTZ := c.Query("timezone")
|
||||
now := timezone.NowInUserLocation(userTZ)
|
||||
@@ -315,18 +336,19 @@ func (h *UsageHandler) Stats(c *gin.Context) {
|
||||
|
||||
// Build filters and call GetStatsWithFilters
|
||||
filters := usagestats.UsageLogFilters{
|
||||
UserID: userID,
|
||||
APIKeyID: apiKeyID,
|
||||
AccountID: accountID,
|
||||
GroupID: groupID,
|
||||
Model: model,
|
||||
ModelFilterSource: usagestats.ModelSourceRequested,
|
||||
RequestType: requestType,
|
||||
Stream: stream,
|
||||
BillingType: billingType,
|
||||
BillingMode: billingMode,
|
||||
StartTime: &startTime,
|
||||
EndTime: &endTime,
|
||||
UserID: userID,
|
||||
APIKeyID: apiKeyID,
|
||||
AccountID: accountID,
|
||||
GroupID: groupID,
|
||||
Model: model,
|
||||
ModelFilterSource: usagestats.ModelSourceRequested,
|
||||
RequestType: requestType,
|
||||
Stream: stream,
|
||||
BillingType: billingType,
|
||||
BillingMode: billingMode,
|
||||
UpstreamModelMismatch: upstreamModelMismatch,
|
||||
StartTime: &startTime,
|
||||
EndTime: &endTime,
|
||||
}
|
||||
|
||||
var stats *usagestats.UsageStats
|
||||
|
||||
@@ -11,17 +11,18 @@ import (
|
||||
var usageStatsCache = newSnapshotCache(30 * time.Second)
|
||||
|
||||
type usageStatsCacheKeyData struct {
|
||||
StartTime string `json:"start_time"`
|
||||
EndTime string `json:"end_time"`
|
||||
UserID int64 `json:"user_id"`
|
||||
APIKeyID int64 `json:"api_key_id"`
|
||||
AccountID int64 `json:"account_id"`
|
||||
GroupID int64 `json:"group_id"`
|
||||
Model string `json:"model"`
|
||||
BillingMode string `json:"billing_mode"`
|
||||
RequestType *int16 `json:"request_type"`
|
||||
Stream *bool `json:"stream"`
|
||||
BillingType *int8 `json:"billing_type"`
|
||||
StartTime string `json:"start_time"`
|
||||
EndTime string `json:"end_time"`
|
||||
UserID int64 `json:"user_id"`
|
||||
APIKeyID int64 `json:"api_key_id"`
|
||||
AccountID int64 `json:"account_id"`
|
||||
GroupID int64 `json:"group_id"`
|
||||
Model string `json:"model"`
|
||||
BillingMode string `json:"billing_mode"`
|
||||
RequestType *int16 `json:"request_type"`
|
||||
Stream *bool `json:"stream"`
|
||||
BillingType *int8 `json:"billing_type"`
|
||||
UpstreamModelMismatch *bool `json:"upstream_model_mismatch"`
|
||||
}
|
||||
|
||||
func usageStatsCacheKey(filters usagestats.UsageLogFilters) string {
|
||||
@@ -34,17 +35,18 @@ func usageStatsCacheKey(filters usagestats.UsageLogFilters) string {
|
||||
end = filters.EndTime.UTC().Format(time.RFC3339)
|
||||
}
|
||||
return mustMarshalDashboardCacheKey(usageStatsCacheKeyData{
|
||||
StartTime: start,
|
||||
EndTime: end,
|
||||
UserID: filters.UserID,
|
||||
APIKeyID: filters.APIKeyID,
|
||||
AccountID: filters.AccountID,
|
||||
GroupID: filters.GroupID,
|
||||
Model: filters.Model,
|
||||
BillingMode: filters.BillingMode,
|
||||
RequestType: filters.RequestType,
|
||||
Stream: filters.Stream,
|
||||
BillingType: filters.BillingType,
|
||||
StartTime: start,
|
||||
EndTime: end,
|
||||
UserID: filters.UserID,
|
||||
APIKeyID: filters.APIKeyID,
|
||||
AccountID: filters.AccountID,
|
||||
GroupID: filters.GroupID,
|
||||
Model: filters.Model,
|
||||
BillingMode: filters.BillingMode,
|
||||
RequestType: filters.RequestType,
|
||||
Stream: filters.Stream,
|
||||
BillingType: filters.BillingType,
|
||||
UpstreamModelMismatch: filters.UpstreamModelMismatch,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
//go:build unit
|
||||
|
||||
package handler
|
||||
|
||||
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"}`
|
||||
|
||||
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)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
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,6 +113,9 @@ 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.requireActionCaptchaForOAuthLoginStart(c) {
|
||||
return
|
||||
}
|
||||
cfg, err := h.getDingTalkOAuthConfig(c.Request.Context())
|
||||
if err != nil {
|
||||
frontendCB := dingTalkOAuthDefaultFrontendCB
|
||||
@@ -165,7 +168,7 @@ func (h *AuthHandler) DingTalkOAuthStart(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusFound, authURL)
|
||||
respondOAuthStart(c, authURL)
|
||||
}
|
||||
|
||||
// ─── buildDingTalkAuthorizeURL ─────────────────────────────────────────────
|
||||
|
||||
@@ -59,6 +59,9 @@ func (h *AuthHandler) CompleteGoogleOAuthRegistration(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *AuthHandler) emailOAuthStart(c *gin.Context, provider string) {
|
||||
if !h.requireActionCaptchaForOAuthLoginStart(c) {
|
||||
return
|
||||
}
|
||||
cfg, err := h.getEmailOAuthConfig(c.Request.Context(), provider)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
@@ -90,7 +93,7 @@ func (h *AuthHandler) emailOAuthStart(c *gin.Context, provider string) {
|
||||
response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_BUILD_URL_FAILED", "failed to build oauth authorization url").WithCause(err))
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, authURL)
|
||||
respondOAuthStart(c, authURL)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) emailOAuthCallback(c *gin.Context, provider string) {
|
||||
|
||||
@@ -48,19 +48,23 @@ func NewAuthHandler(cfg *config.Config, authService *service.AuthService, userSe
|
||||
|
||||
// RegisterRequest represents the registration request payload
|
||||
type RegisterRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
VerifyCode string `json:"verify_code"`
|
||||
TurnstileToken string `json:"turnstile_token"`
|
||||
PromoCode string `json:"promo_code"` // 注册优惠码
|
||||
InvitationCode string `json:"invitation_code"` // 邀请码
|
||||
AffCode string `json:"aff_code"` // 邀请返利码
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
VerifyCode string `json:"verify_code"`
|
||||
TurnstileToken string `json:"turnstile_token"`
|
||||
TencentCaptchaTicket string `json:"tencent_captcha_ticket"`
|
||||
TencentCaptchaRandstr string `json:"tencent_captcha_randstr"`
|
||||
PromoCode string `json:"promo_code"` // 注册优惠码
|
||||
InvitationCode string `json:"invitation_code"` // 邀请码
|
||||
AffCode string `json:"aff_code"` // 邀请返利码
|
||||
}
|
||||
|
||||
// SendVerifyCodeRequest 发送验证码请求
|
||||
type SendVerifyCodeRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
TurnstileToken string `json:"turnstile_token"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
TurnstileToken string `json:"turnstile_token"`
|
||||
TencentCaptchaTicket string `json:"tencent_captcha_ticket"`
|
||||
TencentCaptchaRandstr string `json:"tencent_captcha_randstr"`
|
||||
}
|
||||
|
||||
// SendVerifyCodeResponse 发送验证码响应
|
||||
@@ -71,9 +75,19 @@ type SendVerifyCodeResponse struct {
|
||||
|
||||
// LoginRequest represents the login request payload
|
||||
type LoginRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
TurnstileToken string `json:"turnstile_token"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
TurnstileToken string `json:"turnstile_token"`
|
||||
TencentCaptchaTicket string `json:"tencent_captcha_ticket"`
|
||||
TencentCaptchaRandstr string `json:"tencent_captcha_randstr"`
|
||||
}
|
||||
|
||||
func captchaProof(turnstileToken, tencentTicket, tencentRandstr string) service.CaptchaProof {
|
||||
return service.CaptchaProof{
|
||||
TurnstileToken: turnstileToken,
|
||||
TencentTicket: tencentTicket,
|
||||
TencentRandstr: tencentRandstr,
|
||||
}
|
||||
}
|
||||
|
||||
// AuthResponse 认证响应格式(匹配前端期望)
|
||||
@@ -169,8 +183,9 @@ func (h *AuthHandler) Register(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Turnstile 验证(邮箱验证码注册场景避免重复校验一次性 token)
|
||||
if err := h.authService.VerifyTurnstileForRegister(c.Request.Context(), req.TurnstileToken, ip.GetClientIP(c), req.VerifyCode); err != nil {
|
||||
// 验证当前启用的验证码(邮箱验证码注册场景避免重复校验一次性票据)
|
||||
proof := captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr)
|
||||
if err := h.authService.VerifyCaptchaForRegister(c.Request.Context(), proof, ip.GetClientIP(c), req.VerifyCode); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
@@ -201,8 +216,8 @@ func (h *AuthHandler) SendVerifyCode(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Turnstile 验证
|
||||
if err := h.authService.VerifyTurnstile(c.Request.Context(), req.TurnstileToken, ip.GetClientIP(c)); err != nil {
|
||||
proof := captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr)
|
||||
if err := h.authService.VerifyCaptcha(c.Request.Context(), proof, ip.GetClientIP(c)); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
@@ -228,8 +243,8 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Turnstile 验证
|
||||
if err := h.authService.VerifyTurnstile(c.Request.Context(), req.TurnstileToken, ip.GetClientIP(c)); err != nil {
|
||||
proof := captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr)
|
||||
if err := h.authService.VerifyCaptcha(c.Request.Context(), proof, ip.GetClientIP(c)); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
@@ -573,8 +588,10 @@ func (h *AuthHandler) ValidateInvitationCode(c *gin.Context) {
|
||||
|
||||
// ForgotPasswordRequest 忘记密码请求
|
||||
type ForgotPasswordRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
TurnstileToken string `json:"turnstile_token"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
TurnstileToken string `json:"turnstile_token"`
|
||||
TencentCaptchaTicket string `json:"tencent_captcha_ticket"`
|
||||
TencentCaptchaRandstr string `json:"tencent_captcha_randstr"`
|
||||
}
|
||||
|
||||
// ForgotPasswordResponse 忘记密码响应
|
||||
@@ -591,8 +608,8 @@ func (h *AuthHandler) ForgotPassword(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Turnstile 验证
|
||||
if err := h.authService.VerifyTurnstile(c.Request.Context(), req.TurnstileToken, ip.GetClientIP(c)); err != nil {
|
||||
proof := captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr)
|
||||
if err := h.authService.VerifyCaptcha(c.Request.Context(), proof, ip.GetClientIP(c)); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -82,6 +82,9 @@ 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.requireActionCaptchaForOAuthLoginStart(c) {
|
||||
return
|
||||
}
|
||||
cfg, err := h.getLinuxDoOAuthConfig(c.Request.Context())
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
@@ -147,7 +150,7 @@ func (h *AuthHandler) LinuxDoOAuthStart(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusFound, authURL)
|
||||
respondOAuthStart(c, authURL)
|
||||
}
|
||||
|
||||
// LinuxDoOAuthCallback 处理 OAuth 回调:创建/登录用户,然后重定向到前端。
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type oauthStartCaptchaRequest struct {
|
||||
// TurnstileToken 承载阿里云验证码的 captchaVerifyParam(复用既有请求字段名)
|
||||
TurnstileToken string `json:"turnstile_token"`
|
||||
TencentCaptchaTicket string `json:"tencent_captcha_ticket"`
|
||||
TencentCaptchaRandstr string `json:"tencent_captcha_randstr"`
|
||||
}
|
||||
|
||||
type oauthStartResponse struct {
|
||||
AuthorizeURL string `json:"authorize_url"`
|
||||
}
|
||||
|
||||
func (h *AuthHandler) requireActionCaptchaForOAuthLoginStart(c *gin.Context) bool {
|
||||
if strings.HasSuffix(strings.TrimRight(c.Request.URL.Path, "/"), "/bind/start") {
|
||||
return true
|
||||
}
|
||||
|
||||
var req oauthStartCaptchaRequest
|
||||
if c.Request.Method == http.MethodPost {
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
}
|
||||
if err := h.authService.VerifyActionCaptchaIfEnabled(c.Request.Context(), service.CaptchaProof{
|
||||
TurnstileToken: req.TurnstileToken,
|
||||
TencentTicket: req.TencentCaptchaTicket,
|
||||
TencentRandstr: req.TencentCaptchaRandstr,
|
||||
}, ip.GetClientIP(c)); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func respondOAuthStart(c *gin.Context, authorizeURL string) {
|
||||
if c.Request.Method == http.MethodPost {
|
||||
response.Success(c, oauthStartResponse{AuthorizeURL: authorizeURL})
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, authorizeURL)
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
//go:build unit
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type oauthCaptchaSettingRepo struct {
|
||||
values map[string]string
|
||||
}
|
||||
|
||||
func (r *oauthCaptchaSettingRepo) Get(context.Context, string) (*service.Setting, error) {
|
||||
return nil, service.ErrSettingNotFound
|
||||
}
|
||||
func (r *oauthCaptchaSettingRepo) GetValue(_ context.Context, key string) (string, error) {
|
||||
value, ok := r.values[key]
|
||||
if !ok {
|
||||
return "", service.ErrSettingNotFound
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
func (r *oauthCaptchaSettingRepo) Set(context.Context, string, string) error { return nil }
|
||||
func (r *oauthCaptchaSettingRepo) GetMultiple(_ context.Context, keys []string) (map[string]string, error) {
|
||||
values := make(map[string]string, len(keys))
|
||||
for _, key := range keys {
|
||||
if value, ok := r.values[key]; ok {
|
||||
values[key] = value
|
||||
}
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
func (r *oauthCaptchaSettingRepo) SetMultiple(context.Context, map[string]string) error {
|
||||
return nil
|
||||
}
|
||||
func (r *oauthCaptchaSettingRepo) GetAll(context.Context) (map[string]string, error) {
|
||||
return r.values, nil
|
||||
}
|
||||
func (r *oauthCaptchaSettingRepo) Delete(context.Context, string) error { return nil }
|
||||
|
||||
type oauthCaptchaVerifier struct {
|
||||
calls int
|
||||
proof service.TencentCaptchaProof
|
||||
}
|
||||
|
||||
func (v *oauthCaptchaVerifier) VerifyTicket(_ context.Context, _ service.TencentCaptchaCredentials, proof service.TencentCaptchaProof, _ string) (*service.TencentCaptchaVerifyResponse, error) {
|
||||
v.calls++
|
||||
v.proof = proof
|
||||
return &service.TencentCaptchaVerifyResponse{CaptchaCode: 1}, nil
|
||||
}
|
||||
|
||||
func newOAuthCaptchaTestHandler(enabled bool) (*AuthHandler, *oauthCaptchaVerifier) {
|
||||
values := map[string]string{}
|
||||
if enabled {
|
||||
values = map[string]string{
|
||||
service.SettingKeyTencentCaptchaEnabled: "true",
|
||||
service.SettingKeyTencentCaptchaAppID: "123456789",
|
||||
service.SettingKeyTencentCaptchaAppSecretKey: "app-secret",
|
||||
service.SettingKeyTencentCaptchaCloudSecretID: "cloud-secret-id",
|
||||
service.SettingKeyTencentCaptchaCloudSecretKey: "cloud-secret-key",
|
||||
}
|
||||
}
|
||||
cfg := &config.Config{}
|
||||
settings := service.NewSettingService(&oauthCaptchaSettingRepo{values: values}, cfg)
|
||||
verifier := &oauthCaptchaVerifier{}
|
||||
authService := service.NewAuthService(nil, nil, nil, nil, cfg, settings, nil, nil, nil, nil, nil, nil, nil)
|
||||
authService.SetTencentCaptchaService(service.NewTencentCaptchaService(settings, verifier))
|
||||
return &AuthHandler{authService: authService, settingSvc: settings, cfg: cfg}, verifier
|
||||
}
|
||||
|
||||
func oauthStartHandlers() map[string]func(*AuthHandler, *gin.Context) {
|
||||
return map[string]func(*AuthHandler, *gin.Context){
|
||||
"github": func(h *AuthHandler, c *gin.Context) { h.GitHubOAuthStart(c) },
|
||||
"google": func(h *AuthHandler, c *gin.Context) { h.GoogleOAuthStart(c) },
|
||||
"linuxdo": func(h *AuthHandler, c *gin.Context) { h.LinuxDoOAuthStart(c) },
|
||||
"dingtalk": func(h *AuthHandler, c *gin.Context) { h.DingTalkOAuthStart(c) },
|
||||
"wechat": func(h *AuthHandler, c *gin.Context) { h.WeChatOAuthStart(c) },
|
||||
"oidc": func(h *AuthHandler, c *gin.Context) { h.OIDCOAuthStart(c) },
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthStartGetRejectsAnonymousLoginWhenTencentEnabledWithoutSideEffects(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
for provider, start := range oauthStartHandlers() {
|
||||
t.Run(provider, func(t *testing.T) {
|
||||
handler, verifier := newOAuthCaptchaTestHandler(true)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/"+provider+"/start?intent=bind_current_user", nil)
|
||||
|
||||
start(handler, c)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
require.Contains(t, recorder.Body.String(), "TENCENT_CAPTCHA_VERIFICATION_FAILED")
|
||||
require.Empty(t, recorder.Header().Get("Location"))
|
||||
require.Empty(t, recorder.Header().Values("Set-Cookie"))
|
||||
require.Zero(t, verifier.calls)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthStartPostReturnsAuthorizeURLAfterTencentVerification(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
for provider := range oauthStartHandlers() {
|
||||
t.Run(provider, func(t *testing.T) {
|
||||
handler, verifier := newOAuthCaptchaTestHandler(true)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/auth/oauth/"+provider+"/start",
|
||||
strings.NewReader(`{"tencent_captcha_ticket":"ticket-value","tencent_captcha_randstr":"@rand-value"}`),
|
||||
)
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
require.True(t, handler.requireActionCaptchaForOAuthLoginStart(c))
|
||||
respondOAuthStart(c, "https://provider.example/authorize")
|
||||
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
require.Contains(t, recorder.Body.String(), `"authorize_url":"https://provider.example/authorize"`)
|
||||
require.Equal(t, 1, verifier.calls)
|
||||
require.Equal(t, service.TencentCaptchaProof{Ticket: "ticket-value", Randstr: "@rand-value"}, verifier.proof)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthStartPostRequiresTencentProofWhenEnabled(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
for provider := range oauthStartHandlers() {
|
||||
t.Run(provider, func(t *testing.T) {
|
||||
handler, verifier := newOAuthCaptchaTestHandler(true)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
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.requireActionCaptchaForOAuthLoginStart(c))
|
||||
require.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
require.Contains(t, recorder.Body.String(), "TENCENT_CAPTCHA_VERIFICATION_FAILED")
|
||||
require.Zero(t, verifier.calls)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthBindingPathRemainsOutsideTencentGate(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
handler := &AuthHandler{}
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/oidc/bind/start", nil)
|
||||
|
||||
require.True(t, handler.requireActionCaptchaForOAuthLoginStart(c))
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
}
|
||||
|
||||
func TestOAuthStartGetRemainsCompatibleWhenTencentDisabled(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
handler, verifier := newOAuthCaptchaTestHandler(false)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/github/start", nil)
|
||||
|
||||
require.True(t, handler.requireActionCaptchaForOAuthLoginStart(c))
|
||||
respondOAuthStart(c, "https://provider.example/authorize")
|
||||
|
||||
require.Equal(t, http.StatusFound, recorder.Code)
|
||||
require.Equal(t, "https://provider.example/authorize", recorder.Header().Get("Location"))
|
||||
require.Zero(t, verifier.calls)
|
||||
}
|
||||
@@ -66,20 +66,25 @@ type bindPendingOAuthLoginRequest struct {
|
||||
}
|
||||
|
||||
type createPendingOAuthAccountRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
VerifyCode string `json:"verify_code,omitempty"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
InvitationCode string `json:"invitation_code,omitempty"`
|
||||
AffCode string `json:"aff_code,omitempty"`
|
||||
AdoptDisplayName *bool `json:"adopt_display_name,omitempty"`
|
||||
AdoptAvatar *bool `json:"adopt_avatar,omitempty"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
VerifyCode string `json:"verify_code,omitempty"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
TurnstileToken string `json:"turnstile_token,omitempty"`
|
||||
TencentCaptchaTicket string `json:"tencent_captcha_ticket,omitempty"`
|
||||
TencentCaptchaRandstr string `json:"tencent_captcha_randstr,omitempty"`
|
||||
InvitationCode string `json:"invitation_code,omitempty"`
|
||||
AffCode string `json:"aff_code,omitempty"`
|
||||
AdoptDisplayName *bool `json:"adopt_display_name,omitempty"`
|
||||
AdoptAvatar *bool `json:"adopt_avatar,omitempty"`
|
||||
}
|
||||
|
||||
type sendPendingOAuthVerifyCodeRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
TurnstileToken string `json:"turnstile_token,omitempty"`
|
||||
PendingAuthToken string `json:"pending_auth_token,omitempty"`
|
||||
PendingOAuthToken string `json:"pending_oauth_token,omitempty"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
TurnstileToken string `json:"turnstile_token,omitempty"`
|
||||
TencentCaptchaTicket string `json:"tencent_captcha_ticket,omitempty"`
|
||||
TencentCaptchaRandstr string `json:"tencent_captcha_randstr,omitempty"`
|
||||
PendingAuthToken string `json:"pending_auth_token,omitempty"`
|
||||
PendingOAuthToken string `json:"pending_oauth_token,omitempty"`
|
||||
}
|
||||
|
||||
func (r bindPendingOAuthLoginRequest) adoptionDecision() oauthAdoptionDecisionRequest {
|
||||
@@ -564,7 +569,8 @@ func (h *AuthHandler) SendPendingOAuthVerifyCode(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.authService.VerifyTurnstile(c.Request.Context(), req.TurnstileToken, ip.GetClientIP(c)); err != nil {
|
||||
proof := captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr)
|
||||
if err := h.authService.VerifyCaptcha(c.Request.Context(), proof, ip.GetClientIP(c)); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
@@ -1754,6 +1760,11 @@ func (h *AuthHandler) createPendingOAuthAccount(c *gin.Context, provider string)
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
proof := captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr)
|
||||
if err := h.authService.VerifyCaptcha(c.Request.Context(), proof, ip.GetClientIP(c)); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
tokenPair, user, err := h.authService.RegisterOAuthEmailAccount(
|
||||
c.Request.Context(),
|
||||
@@ -1987,6 +1998,21 @@ func (h *AuthHandler) ExchangePendingOAuthCompletion(c *gin.Context) {
|
||||
response.Success(c, payload)
|
||||
return
|
||||
}
|
||||
// ─── 安全修复(账号接管 0day)────────────────────────────────────────────
|
||||
// 非终态 session(如 choose_account_action_required)的 TargetUserID 可能来自
|
||||
// 攻击者提交的他人邮箱:createPendingOAuthAccount / SendPendingOAuthVerifyCode
|
||||
// 发现邮箱已存在时会把本 session 指向该邮箱用户,全程无密码、无邮箱验证码、
|
||||
// 无账号所有权证明。若此时带着 adoption decision 继续执行,下方的
|
||||
// applyPendingOAuthAdoption 会把本 OAuth identity 直接绑定到 TargetUserID,
|
||||
// 攻击者随后再次 OAuth 登录即被系统识别为受害者本人(完整账号接管)。
|
||||
// 只有两类 session 允许在此处执行 adoption/binding:
|
||||
// 1. canIssueTokenPair == true —— 登录终态,identity 已安全绑定该用户;
|
||||
// 2. intent == bind_current_user —— 已登录用户主动发起绑定(绑定目标来自登录态 cookie)。
|
||||
// 其余状态一律只返回 payload,不绑定、不消费 session。
|
||||
if !canIssueTokenPair && !strings.EqualFold(strings.TrimSpace(session.Intent), oauthIntentBindCurrentUser) {
|
||||
response.Success(c, payload)
|
||||
return
|
||||
}
|
||||
if !adoptionDecision.hasDecision() {
|
||||
adoptionRequired, _ := payload["adoption_required"].(bool)
|
||||
if adoptionRequired {
|
||||
|
||||
@@ -910,6 +910,92 @@ func TestExchangePendingOAuthCompletionRejectsDisabledTargetUser(t *testing.T) {
|
||||
require.Nil(t, storedSession.ConsumedAt)
|
||||
}
|
||||
|
||||
func TestExchangePendingOAuthCompletionChoiceStateDoesNotBindIdentity(t *testing.T) {
|
||||
// 回归测试:复刻"补邮箱/创建账户"路径的账号接管 0day。
|
||||
// 攻击者用自己的 OAuth 账号登录后,在 create-account 步骤提交受害者邮箱,
|
||||
// 后端发现邮箱已存在会把 pending session 转入 choice 状态并指向受害者
|
||||
// (TargetUserID=受害者、无密码/验证码证明)。此时带 adoption decision 调
|
||||
// exchange 绝不能把 OAuth identity 绑定到受害者账号。
|
||||
handler, client := newOAuthPendingFlowTestHandler(t, false)
|
||||
ctx := context.Background()
|
||||
|
||||
victim, err := client.User.Create().
|
||||
SetEmail("victim@example.com").
|
||||
SetUsername("victim-user").
|
||||
SetPasswordHash("hash").
|
||||
SetRole(service.RoleUser).
|
||||
SetStatus(service.StatusActive).
|
||||
Save(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
session, err := client.PendingAuthSession.Create().
|
||||
SetSessionToken("choice-state-attack-session-token").
|
||||
SetIntent("login").
|
||||
SetProviderType("linuxdo").
|
||||
SetProviderKey("linuxdo").
|
||||
SetProviderSubject("attacker-subject-123").
|
||||
SetTargetUserID(victim.ID).
|
||||
SetResolvedEmail(victim.Email).
|
||||
SetBrowserSessionKey("choice-state-attack-browser-session-key").
|
||||
SetUpstreamIdentityClaims(map[string]any{
|
||||
"username": "attacker_linuxdo_user",
|
||||
"suggested_display_name": "Attacker Display Name",
|
||||
"suggested_avatar_url": "https://cdn.example/attacker.png",
|
||||
}).
|
||||
SetLocalFlowState(map[string]any{
|
||||
oauthCompletionResponseKey: map[string]any{
|
||||
"step": oauthPendingChoiceStep,
|
||||
"adoption_required": true,
|
||||
"force_email_on_signup": true,
|
||||
"email_binding_required": true,
|
||||
"existing_account_bindable": true,
|
||||
"email": victim.Email,
|
||||
"resolved_email": victim.Email,
|
||||
"redirect": "/dashboard",
|
||||
},
|
||||
}).
|
||||
SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
|
||||
Save(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
body := bytes.NewBufferString(`{"adopt_display_name":true,"adopt_avatar":true}`)
|
||||
recorder := httptest.NewRecorder()
|
||||
ginCtx, _ := gin.CreateTestContext(recorder)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/pending/exchange", body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)})
|
||||
req.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("choice-state-attack-browser-session-key")})
|
||||
ginCtx.Request = req
|
||||
|
||||
handler.ExchangePendingOAuthCompletion(ginCtx)
|
||||
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
data := decodeJSONResponseData(t, recorder)
|
||||
require.NotContains(t, data, "access_token")
|
||||
require.Equal(t, oauthPendingChoiceStep, data["step"])
|
||||
|
||||
// 攻击者的 OAuth identity 绝不能绑定到受害者账号
|
||||
identityCount, err := client.AuthIdentity.Query().
|
||||
Where(
|
||||
authidentity.ProviderTypeEQ("linuxdo"),
|
||||
authidentity.ProviderKeyEQ("linuxdo"),
|
||||
authidentity.ProviderSubjectEQ("attacker-subject-123"),
|
||||
).
|
||||
Count(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Zero(t, identityCount)
|
||||
|
||||
// 受害者资料不得被 adoption 篡改
|
||||
storedVictim, err := client.User.Get(ctx, victim.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "victim-user", storedVictim.Username)
|
||||
|
||||
// session 不得被消费(攻击者无法进入下一环)
|
||||
storedSession, err := client.PendingAuthSession.Get(ctx, session.ID)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, storedSession.ConsumedAt)
|
||||
}
|
||||
|
||||
func TestNormalizePendingOAuthCompletionResponseScrubsLegacyTokenPayload(t *testing.T) {
|
||||
payload := normalizePendingOAuthCompletionResponse(map[string]any{
|
||||
"access_token": "legacy-access-token",
|
||||
|
||||
@@ -115,6 +115,9 @@ type oidcJWK struct {
|
||||
// OIDCOAuthStart 启动通用 OIDC OAuth 登录流程。
|
||||
// GET /api/v1/auth/oauth/oidc/start?redirect=/dashboard
|
||||
func (h *AuthHandler) OIDCOAuthStart(c *gin.Context) {
|
||||
if !h.requireActionCaptchaForOAuthLoginStart(c) {
|
||||
return
|
||||
}
|
||||
cfg, err := h.getOIDCOAuthConfig(c.Request.Context())
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
@@ -190,7 +193,7 @@ func (h *AuthHandler) OIDCOAuthStart(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusFound, authURL)
|
||||
respondOAuthStart(c, authURL)
|
||||
}
|
||||
|
||||
// OIDCOAuthCallback 处理 OIDC 回调:校验 id_token、创建/登录用户并重定向到前端。
|
||||
|
||||
@@ -96,6 +96,9 @@ 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.requireActionCaptchaForOAuthLoginStart(c) {
|
||||
return
|
||||
}
|
||||
cfg, err := h.getWeChatOAuthConfig(c.Request.Context(), c.Query("mode"), c)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
@@ -145,7 +148,7 @@ func (h *AuthHandler) WeChatOAuthStart(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusFound, authURL)
|
||||
respondOAuthStart(c, authURL)
|
||||
}
|
||||
|
||||
// WeChatOAuthCallback exchanges the code with WeChat, resolves openid/unionid,
|
||||
|
||||
@@ -705,6 +705,8 @@ func UsageLogFromServiceAdmin(l *service.UsageLog) *AdminUsageLog {
|
||||
return &AdminUsageLog{
|
||||
UsageLog: usageLog,
|
||||
UpstreamModel: l.UpstreamModel,
|
||||
UpstreamResponseModel: l.UpstreamResponseModel,
|
||||
UpstreamModelMismatch: l.UpstreamModelMismatch,
|
||||
ChannelID: l.ChannelID,
|
||||
ModelMappingChain: l.ModelMappingChain,
|
||||
BillingTier: l.BillingTier,
|
||||
|
||||
@@ -110,11 +110,15 @@ func TestUsageLogFromService_UsesRequestedModelAndKeepsUpstreamAdminOnly(t *test
|
||||
t.Parallel()
|
||||
|
||||
upstreamModel := "claude-sonnet-4-20250514"
|
||||
upstreamResponseModel := "claude-sonnet-4-20250513"
|
||||
upstreamModelMismatch := true
|
||||
log := &service.UsageLog{
|
||||
RequestID: "req_4",
|
||||
Model: upstreamModel,
|
||||
RequestedModel: "claude-sonnet-4",
|
||||
UpstreamModel: &upstreamModel,
|
||||
RequestID: "req_4",
|
||||
Model: upstreamModel,
|
||||
RequestedModel: "claude-sonnet-4",
|
||||
UpstreamModel: &upstreamModel,
|
||||
UpstreamResponseModel: &upstreamResponseModel,
|
||||
UpstreamModelMismatch: &upstreamModelMismatch,
|
||||
}
|
||||
|
||||
userDTO := UsageLogFromService(log)
|
||||
@@ -126,10 +130,14 @@ func TestUsageLogFromService_UsesRequestedModelAndKeepsUpstreamAdminOnly(t *test
|
||||
userJSON, err := json.Marshal(userDTO)
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, string(userJSON), "upstream_model")
|
||||
require.NotContains(t, string(userJSON), "upstream_response_model")
|
||||
require.NotContains(t, string(userJSON), "upstream_model_mismatch")
|
||||
|
||||
adminJSON, err := json.Marshal(adminDTO)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, string(adminJSON), `"upstream_model":"claude-sonnet-4-20250514"`)
|
||||
require.Contains(t, string(adminJSON), `"upstream_response_model":"claude-sonnet-4-20250513"`)
|
||||
require.Contains(t, string(adminJSON), `"upstream_model_mismatch":true`)
|
||||
}
|
||||
|
||||
func TestUsageLogFromService_KeepsUserBillingAndIPWithoutAdminCostFields(t *testing.T) {
|
||||
|
||||
@@ -56,11 +56,23 @@ type SystemSettings struct {
|
||||
SMTPFromName string `json:"smtp_from_name"`
|
||||
SMTPUseTLS bool `json:"smtp_use_tls"`
|
||||
|
||||
TurnstileEnabled bool `json:"turnstile_enabled"`
|
||||
TurnstileSiteKey string `json:"turnstile_site_key"`
|
||||
TurnstileSecretKeyConfigured bool `json:"turnstile_secret_key_configured"`
|
||||
APIKeyACLTrustForwardedIP bool `json:"api_key_acl_trust_forwarded_ip"`
|
||||
ForwardedClientIPHeaders []string `json:"forwarded_client_ip_headers"`
|
||||
TurnstileEnabled bool `json:"turnstile_enabled"`
|
||||
TurnstileSiteKey string `json:"turnstile_site_key"`
|
||||
TurnstileSecretKeyConfigured bool `json:"turnstile_secret_key_configured"`
|
||||
TencentCaptchaEnabled bool `json:"tencent_captcha_enabled"`
|
||||
TencentCaptchaAppID string `json:"tencent_captcha_app_id"`
|
||||
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"`
|
||||
|
||||
LinuxDoConnectEnabled bool `json:"linuxdo_connect_enabled"`
|
||||
LinuxDoConnectClientID string `json:"linuxdo_connect_client_id"`
|
||||
@@ -341,6 +353,13 @@ type PublicSettings struct {
|
||||
LoginAgreementDocuments []LoginAgreementDocument `json:"login_agreement_documents"`
|
||||
TurnstileEnabled bool `json:"turnstile_enabled"`
|
||||
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"`
|
||||
|
||||
@@ -555,6 +555,10 @@ type AdminUsageLog struct {
|
||||
// UpstreamModel is the actual model sent to the upstream provider after mapping.
|
||||
// Omitted when no mapping was applied (requested model was used as-is).
|
||||
UpstreamModel *string `json:"upstream_model,omitempty"`
|
||||
// UpstreamResponseModel is the raw model declared by the upstream response.
|
||||
UpstreamResponseModel *string `json:"upstream_response_model,omitempty"`
|
||||
// UpstreamModelMismatch is nil when the upstream did not declare a model.
|
||||
UpstreamModelMismatch *bool `json:"upstream_model_mismatch,omitempty"`
|
||||
|
||||
// ChannelID 渠道 ID
|
||||
ChannelID *int64 `json:"channel_id,omitempty"`
|
||||
|
||||
@@ -54,19 +54,23 @@ type modelPlazaModel struct {
|
||||
|
||||
// modelPlazaGroup 广场分组条目(白名单字段)。
|
||||
type modelPlazaGroup struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Platform string `json:"platform"`
|
||||
SubscriptionType string `json:"subscription_type"`
|
||||
RateMultiplier float64 `json:"rate_multiplier"`
|
||||
UserRateMultiplier *float64 `json:"user_rate_multiplier,omitempty"`
|
||||
PeakRateEnabled bool `json:"peak_rate_enabled"`
|
||||
PeakStart string `json:"peak_start"`
|
||||
PeakEnd string `json:"peak_end"`
|
||||
PeakRateMultiplier float64 `json:"peak_rate_multiplier"`
|
||||
IsExclusive bool `json:"is_exclusive"`
|
||||
Models []modelPlazaModel `json:"models"`
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Platform string `json:"platform"`
|
||||
SubscriptionType string `json:"subscription_type"`
|
||||
RateMultiplier float64 `json:"rate_multiplier"`
|
||||
UserRateMultiplier *float64 `json:"user_rate_multiplier,omitempty"`
|
||||
PeakRateEnabled bool `json:"peak_rate_enabled"`
|
||||
PeakStart string `json:"peak_start"`
|
||||
PeakEnd string `json:"peak_end"`
|
||||
PeakRateMultiplier float64 `json:"peak_rate_multiplier"`
|
||||
IsExclusive bool `json:"is_exclusive"`
|
||||
// 生图独立倍率:为 true 时图片计费模型的实付倍率取 ImageRateMultiplier,
|
||||
// 不取分组/用户专属倍率。
|
||||
ImageRateIndependent bool `json:"image_rate_independent"`
|
||||
ImageRateMultiplier float64 `json:"image_rate_multiplier"`
|
||||
Models []modelPlazaModel `json:"models"`
|
||||
}
|
||||
|
||||
// modelPlazaResponse 广场页响应。
|
||||
@@ -164,18 +168,20 @@ func toModelPlazaGroupDTO(g *service.PlazaGroup, userRates map[int64]float64) mo
|
||||
})
|
||||
}
|
||||
dto := modelPlazaGroup{
|
||||
ID: g.ID,
|
||||
Name: g.Name,
|
||||
Description: g.Description,
|
||||
Platform: g.Platform,
|
||||
SubscriptionType: g.SubscriptionType,
|
||||
RateMultiplier: g.RateMultiplier,
|
||||
PeakRateEnabled: g.PeakRateEnabled,
|
||||
PeakStart: g.PeakStart,
|
||||
PeakEnd: g.PeakEnd,
|
||||
PeakRateMultiplier: g.PeakRateMultiplier,
|
||||
IsExclusive: g.IsExclusive,
|
||||
Models: models,
|
||||
ID: g.ID,
|
||||
Name: g.Name,
|
||||
Description: g.Description,
|
||||
Platform: g.Platform,
|
||||
SubscriptionType: g.SubscriptionType,
|
||||
RateMultiplier: g.RateMultiplier,
|
||||
PeakRateEnabled: g.PeakRateEnabled,
|
||||
PeakStart: g.PeakStart,
|
||||
PeakEnd: g.PeakEnd,
|
||||
PeakRateMultiplier: g.PeakRateMultiplier,
|
||||
IsExclusive: g.IsExclusive,
|
||||
ImageRateIndependent: g.ImageRateIndependent,
|
||||
ImageRateMultiplier: g.ImageRateMultiplier,
|
||||
Models: models,
|
||||
}
|
||||
if rate, ok := userRates[g.ID]; ok {
|
||||
dto.UserRateMultiplier = &rate
|
||||
|
||||
@@ -91,6 +91,7 @@ func TestToModelPlazaGroupDTO_UserRateAndFieldWhitelist(t *testing.T) {
|
||||
"id", "name", "description", "platform", "subscription_type",
|
||||
"rate_multiplier", "user_rate_multiplier", "is_exclusive", "models",
|
||||
"peak_rate_enabled", "peak_start", "peak_end", "peak_rate_multiplier",
|
||||
"image_rate_independent", "image_rate_multiplier",
|
||||
} {
|
||||
_, exists := decoded[key]
|
||||
require.Truef(t, exists, "plaza group DTO must expose %q", key)
|
||||
|
||||
@@ -1612,7 +1612,8 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
|
||||
reqLog.Info("openai.websocket_ingress_started")
|
||||
clientIP := ip.GetClientIP(c)
|
||||
userAgent := strings.TrimSpace(c.GetHeader("User-Agent"))
|
||||
ctx := c.Request.Context()
|
||||
clientLifecycleCtx := c.Request.Context()
|
||||
ctx := clientLifecycleCtx
|
||||
maxIngressConnections := 0
|
||||
if h.cfg != nil {
|
||||
maxIngressConnections = h.cfg.Gateway.OpenAIWS.MaxIngressConnectionsPerAPIKey
|
||||
@@ -2017,6 +2018,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
|
||||
// openAIWSTurnPricing 的注释——绝不能用建连时刻初始化。
|
||||
var turnPricing openAIWSTurnPricing
|
||||
hooks := &service.OpenAIWSIngressHooks{
|
||||
ClientLifecycleContext: clientLifecycleCtx,
|
||||
InitialRequestModel: reqModel,
|
||||
MaxReasoningEffort: maxReasoningEffort,
|
||||
ReasoningEffortMappings: reasoningEffortMappings,
|
||||
|
||||
@@ -144,7 +144,7 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) {
|
||||
}
|
||||
|
||||
sessionHash := h.gatewayService.GenerateExplicitSessionHash(c, body)
|
||||
requestCtx := service.WithOpenAIImageGenerationIntent(c.Request.Context())
|
||||
requestCtx := service.WithOpenAIImagesEndpoint(service.WithOpenAIImageGenerationIntent(c.Request.Context()))
|
||||
|
||||
maxAccountSwitches := h.maxAccountSwitches
|
||||
switchCount := 0
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
@@ -45,6 +46,13 @@ type passkeyFinishRequest struct {
|
||||
Credential json.RawMessage `json:"credential" binding:"required"`
|
||||
}
|
||||
|
||||
type passkeyBeginLoginRequest struct {
|
||||
// TurnstileToken 承载阿里云验证码的 captchaVerifyParam(复用既有请求字段名)
|
||||
TurnstileToken string `json:"turnstile_token"`
|
||||
TencentCaptchaTicket string `json:"tencent_captcha_ticket"`
|
||||
TencentCaptchaRandstr string `json:"tencent_captcha_randstr"`
|
||||
}
|
||||
|
||||
type passkeyRenameRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
}
|
||||
@@ -70,6 +78,16 @@ func (h *PasskeyHandler) BeginLogin(c *gin.Context) {
|
||||
if !h.requirePasskeysEnabled(c) {
|
||||
return
|
||||
}
|
||||
var req passkeyBeginLoginRequest
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
if err := h.authService.VerifyActionCaptchaIfEnabled(c.Request.Context(), service.CaptchaProof{
|
||||
TurnstileToken: req.TurnstileToken,
|
||||
TencentTicket: req.TencentCaptchaTicket,
|
||||
TencentRandstr: req.TencentCaptchaRandstr,
|
||||
}, ip.GetClientIP(c)); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
assertion, token, err := h.passkeys.BeginLogin(c.Request.Context())
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
@@ -15,8 +16,30 @@ import (
|
||||
)
|
||||
|
||||
type passkeySwitchSettingRepo struct {
|
||||
value string
|
||||
err error
|
||||
value string
|
||||
values map[string]string
|
||||
err error
|
||||
}
|
||||
|
||||
type passkeyCaptchaVerifierStub struct {
|
||||
calls int
|
||||
proof service.TencentCaptchaProof
|
||||
}
|
||||
|
||||
func (s *passkeyCaptchaVerifierStub) VerifyTicket(_ context.Context, _ service.TencentCaptchaCredentials, proof service.TencentCaptchaProof, _ string) (*service.TencentCaptchaVerifyResponse, error) {
|
||||
s.calls++
|
||||
s.proof = proof
|
||||
return &service.TencentCaptchaVerifyResponse{CaptchaCode: 1}, nil
|
||||
}
|
||||
|
||||
type passkeyBeginSessionStoreStub struct {
|
||||
service.PasskeySessionStore
|
||||
storeCalls int
|
||||
}
|
||||
|
||||
func (s *passkeyBeginSessionStoreStub) Store(context.Context, *service.PasskeySession, time.Duration) (string, error) {
|
||||
s.storeCalls++
|
||||
return "passkey-session", nil
|
||||
}
|
||||
|
||||
func (r *passkeySwitchSettingRepo) Get(context.Context, string) (*service.Setting, error) {
|
||||
@@ -27,7 +50,10 @@ func (r *passkeySwitchSettingRepo) GetValue(context.Context, string) (string, er
|
||||
}
|
||||
func (r *passkeySwitchSettingRepo) Set(context.Context, string, string) error { return nil }
|
||||
func (r *passkeySwitchSettingRepo) GetMultiple(context.Context, []string) (map[string]string, error) {
|
||||
return map[string]string{}, nil
|
||||
if r.err != nil {
|
||||
return nil, r.err
|
||||
}
|
||||
return r.values, nil
|
||||
}
|
||||
func (r *passkeySwitchSettingRepo) SetMultiple(context.Context, map[string]string) error {
|
||||
return nil
|
||||
@@ -87,6 +113,74 @@ func TestPasskeyBeginLoginReportsSettingStoreFailure(t *testing.T) {
|
||||
require.NotContains(t, recorder.Body.String(), "PASSKEY_DISABLED")
|
||||
}
|
||||
|
||||
func newTencentProtectedPasskeyHandler(t *testing.T) (*PasskeyHandler, *passkeyCaptchaVerifierStub, *passkeyBeginSessionStoreStub) {
|
||||
t.Helper()
|
||||
cfg := &config.Config{WebAuthn: config.WebAuthnConfig{
|
||||
Enabled: true,
|
||||
RPDisplayName: "Sub2API",
|
||||
RPID: "sub2api.example.com",
|
||||
RPOrigins: []string{"https://sub2api.example.com"},
|
||||
}}
|
||||
repo := &passkeySwitchSettingRepo{
|
||||
value: "true",
|
||||
values: map[string]string{
|
||||
service.SettingKeyTencentCaptchaEnabled: "true",
|
||||
service.SettingKeyTencentCaptchaAppID: "123456789",
|
||||
service.SettingKeyTencentCaptchaAppSecretKey: "app-secret",
|
||||
service.SettingKeyTencentCaptchaCloudSecretID: "cloud-secret-id",
|
||||
service.SettingKeyTencentCaptchaCloudSecretKey: "cloud-secret-key",
|
||||
},
|
||||
}
|
||||
settings := service.NewSettingService(repo, cfg)
|
||||
verifier := &passkeyCaptchaVerifierStub{}
|
||||
authService := service.NewAuthService(nil, nil, nil, nil, cfg, settings, nil, nil, nil, nil, nil, nil, nil)
|
||||
authService.SetTencentCaptchaService(service.NewTencentCaptchaService(settings, verifier))
|
||||
sessions := &passkeyBeginSessionStoreStub{}
|
||||
passkeys, err := service.NewPasskeyService(cfg, nil, sessions, nil)
|
||||
require.NoError(t, err)
|
||||
return NewPasskeyHandler(passkeys, authService, settings), verifier, sessions
|
||||
}
|
||||
|
||||
func newPasskeyBeginLoginContext(body string) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
recorder := httptest.NewRecorder()
|
||||
ginContext, _ := gin.CreateTestContext(recorder)
|
||||
ginContext.Request = httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/auth/passkey/login/begin",
|
||||
strings.NewReader(body),
|
||||
)
|
||||
ginContext.Request.Header.Set("Content-Type", "application/json")
|
||||
return ginContext, recorder
|
||||
}
|
||||
|
||||
func TestPasskeyBeginLoginRejectsMissingTencentCaptchaProof(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
handler, verifier, sessions := newTencentProtectedPasskeyHandler(t)
|
||||
ginContext, recorder := newPasskeyBeginLoginContext(`{}`)
|
||||
|
||||
handler.BeginLogin(ginContext)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
require.Contains(t, recorder.Body.String(), "TENCENT_CAPTCHA_VERIFICATION_FAILED")
|
||||
require.Zero(t, verifier.calls)
|
||||
require.Zero(t, sessions.storeCalls)
|
||||
}
|
||||
|
||||
func TestPasskeyBeginLoginAcceptsTencentCaptchaProofBeforeCeremony(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
handler, verifier, sessions := newTencentProtectedPasskeyHandler(t)
|
||||
ginContext, recorder := newPasskeyBeginLoginContext(
|
||||
`{"tencent_captcha_ticket":"ticket-value","tencent_captcha_randstr":"@rand-value"}`,
|
||||
)
|
||||
|
||||
handler.BeginLogin(ginContext)
|
||||
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
require.Equal(t, 1, verifier.calls)
|
||||
require.Equal(t, service.TencentCaptchaProof{Ticket: "ticket-value", Randstr: "@rand-value"}, verifier.proof)
|
||||
require.Equal(t, 1, sessions.storeCalls)
|
||||
}
|
||||
|
||||
func TestPasskeyCredentialListRemainsAvailableWhenSignInDisabled(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
handler := NewPasskeyHandler(nil, nil, nil)
|
||||
|
||||
@@ -60,6 +60,13 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
|
||||
LoginAgreementDocuments: publicLoginAgreementDocumentsToDTO(settings.LoginAgreementDocuments),
|
||||
TurnstileEnabled: settings.TurnstileEnabled,
|
||||
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,
|
||||
|
||||
@@ -82,6 +82,41 @@ func TestSettingHandler_GetPublicSettings_ExposesForceEmailOnThirdPartySignup(t
|
||||
require.True(t, resp.Data.ForceEmailOnThirdPartySignup)
|
||||
}
|
||||
|
||||
func TestSettingHandler_GetPublicSettings_ExposesTencentCaptchaConfiguration(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
repo := &settingHandlerPublicRepoStub{
|
||||
values: map[string]string{
|
||||
service.SettingKeyTencentCaptchaEnabled: "true",
|
||||
service.SettingKeyTencentCaptchaAppID: "123456789",
|
||||
service.SettingKeyTencentCaptchaRegion: service.TencentCaptchaRegionINTL,
|
||||
},
|
||||
}
|
||||
h := NewSettingHandler(service.NewSettingService(repo, &config.Config{}), "test-version")
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/settings/public", nil)
|
||||
|
||||
h.GetPublicSettings(c)
|
||||
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
|
||||
var resp struct {
|
||||
Code int `json:"code"`
|
||||
Data struct {
|
||||
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) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
h := NewSettingHandler(service.NewSettingService(&settingHandlerPublicRepoStub{
|
||||
|
||||
@@ -224,6 +224,8 @@ func TestUserUsageListKeepsUserBillingAndIPWithoutAdminCostFields(t *testing.T)
|
||||
require.NotContains(t, body, "account_rate_multiplier")
|
||||
require.NotContains(t, body, "account_stats_cost")
|
||||
require.NotContains(t, body, "upstream_model")
|
||||
require.NotContains(t, body, "upstream_response_model")
|
||||
require.NotContains(t, body, "upstream_model_mismatch")
|
||||
require.NotContains(t, body, "billing_tier")
|
||||
require.NotContains(t, body, "channel_id")
|
||||
require.NotContains(t, body, `"account":`)
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
package apicompat
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// anthropicInboundBlockTypes 是 Anthropic Messages 请求体里合法的 content block
|
||||
// 类型。转换结果只能落在这个集合内——发出集合外的类型,上游一律回
|
||||
// 400 "Request body format invalid"(见 issue #5329)。
|
||||
var anthropicInboundBlockTypes = map[string]bool{
|
||||
"text": true,
|
||||
"image": true,
|
||||
"document": true,
|
||||
"tool_use": true,
|
||||
"tool_result": true,
|
||||
"thinking": true,
|
||||
"redacted_thinking": true,
|
||||
}
|
||||
|
||||
func responsesToAnthropicMessages(t *testing.T, input string) []AnthropicMessage {
|
||||
t.Helper()
|
||||
var req ResponsesRequest
|
||||
require.NoError(t, json.Unmarshal([]byte(`{"model":"glm-5.2","input":`+input+`}`), &req))
|
||||
out, err := ResponsesToAnthropicRequest(&req)
|
||||
require.NoError(t, err)
|
||||
return out.Messages
|
||||
}
|
||||
|
||||
// requireAnthropicMessagesAreSendable 断言消息序列不含 Anthropic 会拒收的形态:
|
||||
// 未知 block 类型、空内容消息、纯空白 text 块。
|
||||
func requireAnthropicMessagesAreSendable(t *testing.T, messages []AnthropicMessage) {
|
||||
t.Helper()
|
||||
for i, m := range messages {
|
||||
raw := strings.TrimSpace(string(m.Content))
|
||||
require.NotContains(t, []string{"", "null", `""`, "[]"}, raw,
|
||||
"messages[%d] 内容为空,Anthropic 拒收空内容消息", i)
|
||||
|
||||
var s string
|
||||
if err := json.Unmarshal(m.Content, &s); err == nil {
|
||||
require.NotEmpty(t, strings.TrimSpace(s), "messages[%d] 字符串内容不能全为空白", i)
|
||||
continue
|
||||
}
|
||||
blocks := parseContentBlocks(m.Content)
|
||||
require.NotEmpty(t, blocks, "messages[%d] 解析不出任何 block", i)
|
||||
for j, b := range blocks {
|
||||
require.True(t, anthropicInboundBlockTypes[b.Type],
|
||||
"messages[%d].content[%d] 是 Anthropic 不认识的 block 类型 %q", i, j, b.Type)
|
||||
if b.Type == "text" {
|
||||
require.NotEmpty(t, strings.TrimSpace(b.Text),
|
||||
"messages[%d].content[%d] 是空白 text 块,Anthropic 拒收", i, j)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// issue #5329:工具执行后的下一轮,Codex 会把 reasoning item 一起回放。
|
||||
// 该 item 带 content 数组时,reasoning_text 块以前会被原样塞进 Anthropic 请求体。
|
||||
func TestResponsesToAnthropic_ReasoningItemWithContentIsDropped(t *testing.T) {
|
||||
messages := responsesToAnthropicMessages(t, `[
|
||||
{"type":"message","role":"user","content":[{"type":"input_text","text":"run a shell command"}]},
|
||||
{"type":"reasoning","id":"rs_1","summary":[],"content":[{"type":"reasoning_text","text":"let me think"}]}
|
||||
]`)
|
||||
|
||||
requireAnthropicMessagesAreSendable(t, messages)
|
||||
require.Len(t, messages, 1)
|
||||
require.NotContains(t, string(messages[0].Content), "reasoning_text")
|
||||
require.NotContains(t, string(messages[0].Content), "let me think")
|
||||
}
|
||||
|
||||
// Codex 的常见 reasoning 形态(只有 summary + encrypted_content)本来就会被丢弃,
|
||||
// 这条守卫确保行为没有被改变。
|
||||
func TestResponsesToAnthropic_ReasoningItemSummaryOnlyStillDropped(t *testing.T) {
|
||||
messages := responsesToAnthropicMessages(t, `[
|
||||
{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]},
|
||||
{"type":"reasoning","id":"rs_1","summary":[{"type":"summary_text","text":"s"}],"encrypted_content":"gAAAA"}
|
||||
]`)
|
||||
|
||||
requireAnthropicMessagesAreSendable(t, messages)
|
||||
require.Len(t, messages, 1)
|
||||
require.NotContains(t, string(messages[0].Content), "gAAAA")
|
||||
}
|
||||
|
||||
// 未知 item type 的 content 以前会被逐字透传,把 Responses 专有分片带进上游请求。
|
||||
func TestResponsesToAnthropic_UnknownItemTypeContentIsSanitized(t *testing.T) {
|
||||
messages := responsesToAnthropicMessages(t, `[
|
||||
{"type":"web_search_call","id":"ws_1","content":[{"type":"web_search_result","text":"payload"}]}
|
||||
]`)
|
||||
|
||||
requireAnthropicMessagesAreSendable(t, messages)
|
||||
require.Empty(t, messages, "整条内容都无法映射时不应发出消息")
|
||||
}
|
||||
|
||||
// 未知 item type 里夹带的可识别文本仍然保留,不做无谓丢弃。
|
||||
func TestResponsesToAnthropic_UnknownItemTypeKeepsRecognizableText(t *testing.T) {
|
||||
messages := responsesToAnthropicMessages(t, `[
|
||||
{"type":"some_future_item","content":[
|
||||
{"type":"input_text","text":"keep me"},
|
||||
{"type":"reasoning_text","text":"drop me"}
|
||||
]}
|
||||
]`)
|
||||
|
||||
requireAnthropicMessagesAreSendable(t, messages)
|
||||
require.Len(t, messages, 1)
|
||||
require.Contains(t, string(messages[0].Content), "keep me")
|
||||
require.NotContains(t, string(messages[0].Content), "drop me")
|
||||
}
|
||||
|
||||
// user 消息的分片全部不可识别时,以前会退化成 content:"",Anthropic 拒收空内容消息。
|
||||
func TestResponsesToAnthropic_UserMessageWithOnlyUnknownPartsIsDropped(t *testing.T) {
|
||||
messages := responsesToAnthropicMessages(t, `[
|
||||
{"type":"message","role":"user","content":[{"type":"input_file","file_id":"file_1"}]}
|
||||
]`)
|
||||
|
||||
requireAnthropicMessagesAreSendable(t, messages)
|
||||
require.Empty(t, messages)
|
||||
}
|
||||
|
||||
// assistant 侧同理:以前会退化成单个空 text 块,Anthropic 同样拒收。
|
||||
func TestResponsesToAnthropic_AssistantMessageWithOnlyUnknownPartsIsDropped(t *testing.T) {
|
||||
messages := responsesToAnthropicMessages(t, `[
|
||||
{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]},
|
||||
{"type":"message","role":"assistant","content":[{"type":"refusal","refusal":"no"}]}
|
||||
]`)
|
||||
|
||||
requireAnthropicMessagesAreSendable(t, messages)
|
||||
require.Len(t, messages, 1)
|
||||
require.Equal(t, "user", messages[0].Role)
|
||||
}
|
||||
|
||||
// 完整的 Codex 工具续接回放:tool_use / tool_result 配对必须保持不变,
|
||||
// 同时整个序列满足可发送不变式。
|
||||
func TestResponsesToAnthropic_CodexToolRoundStaysIntactAndSendable(t *testing.T) {
|
||||
messages := responsesToAnthropicMessages(t, `[
|
||||
{"type":"message","role":"user","content":[{"type":"input_text","text":"run ls"}]},
|
||||
{"type":"reasoning","id":"rs_1","summary":[],"content":[{"type":"reasoning_text","text":"plan"}],"encrypted_content":"gAAAA"},
|
||||
{"type":"function_call","id":"fc_1","call_id":"call_1","name":"shell","arguments":"{\"cmd\":\"ls\"}"},
|
||||
{"type":"function_call_output","call_id":"call_1","output":"file1"},
|
||||
{"type":"message","role":"user","content":[{"type":"input_text","text":"continue"}]}
|
||||
]`)
|
||||
|
||||
requireAnthropicMessagesAreSendable(t, messages)
|
||||
|
||||
var sawToolUse, sawToolResult bool
|
||||
for _, m := range messages {
|
||||
for _, b := range parseContentBlocks(m.Content) {
|
||||
switch b.Type {
|
||||
case "tool_use":
|
||||
sawToolUse = true
|
||||
require.Equal(t, "call_1", b.ID)
|
||||
require.Equal(t, "shell", b.Name)
|
||||
case "tool_result":
|
||||
sawToolResult = true
|
||||
require.Equal(t, "call_1", b.ToolUseID)
|
||||
}
|
||||
}
|
||||
}
|
||||
require.True(t, sawToolUse, "function_call 必须转成 tool_use")
|
||||
require.True(t, sawToolResult, "function_call_output 必须转成 tool_result")
|
||||
require.NotContains(t, string(mustMarshal(t, messages)), "reasoning_text")
|
||||
require.NotContains(t, string(mustMarshal(t, messages)), "gAAAA")
|
||||
}
|
||||
|
||||
func mustMarshal(t *testing.T, v any) []byte {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(v)
|
||||
require.NoError(t, err)
|
||||
return b
|
||||
}
|
||||
|
||||
func TestAnthropicContentIsEmpty(t *testing.T) {
|
||||
cases := []struct {
|
||||
raw string
|
||||
want bool
|
||||
}{
|
||||
{``, true},
|
||||
{`""`, true},
|
||||
{`null`, true},
|
||||
{`[]`, true},
|
||||
{` [] `, true},
|
||||
{`"hi"`, false},
|
||||
{`[{"type":"text","text":"hi"}]`, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
require.Equal(t, tc.want, anthropicContentIsEmpty(json.RawMessage(tc.raw)), "raw=%q", tc.raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnthropicContentIsOnlyBlankText(t *testing.T) {
|
||||
cases := []struct {
|
||||
raw string
|
||||
want bool
|
||||
}{
|
||||
{`[{"type":"text","text":""}]`, true},
|
||||
{`[{"type":"text","text":" "}]`, true},
|
||||
{`[{"type":"text","text":""},{"type":"text","text":" "}]`, true},
|
||||
{`[{"type":"text","text":"hi"}]`, false},
|
||||
{`[{"type":"text","text":""},{"type":"image","source":{}}]`, false},
|
||||
{`[]`, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
require.Equal(t, tc.want, anthropicContentIsOnlyBlankText(json.RawMessage(tc.raw)), "raw=%q", tc.raw)
|
||||
}
|
||||
}
|
||||
@@ -164,11 +164,24 @@ func convertResponsesInputToAnthropic(instructions string, inputRaw json.RawMess
|
||||
Content: blockJSON,
|
||||
})
|
||||
|
||||
case item.Type == "reasoning":
|
||||
// Anthropic 无法摄入 OpenAI 的 reasoning:encrypted_content 是不透明的,
|
||||
// 而 thinking 块的重放需要 Anthropic 自己签发的 signature,无法伪造。
|
||||
// Codex 常见形态(只带 summary + encrypted_content)本来就会被丢弃,
|
||||
// 这里让带 content 数组的形态保持同样行为——否则 reasoning_text 块会被
|
||||
// 原样塞进 Anthropic 请求体,上游直接回 400。
|
||||
|
||||
case item.Role == "user":
|
||||
content, err := convertResponsesUserToAnthropicContent(item.Content)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// 内容里只有网关不认识的分片时,sanitize 会得到空串。Anthropic 拒收
|
||||
// 空内容消息("all messages must have non-empty content"),整条丢掉
|
||||
// 比发一条必然 400 的消息更可用。
|
||||
if anthropicContentIsEmpty(content) {
|
||||
continue
|
||||
}
|
||||
messages = append(messages, AnthropicMessage{
|
||||
Role: "user",
|
||||
Content: content,
|
||||
@@ -179,19 +192,35 @@ func convertResponsesInputToAnthropic(instructions string, inputRaw json.RawMess
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// 同上:分片全不认识时会退化成单个空 text 块,而 Anthropic 拒收
|
||||
// 空文本块("text content blocks must contain non-whitespace text")。
|
||||
if anthropicContentIsEmpty(content) || anthropicContentIsOnlyBlankText(content) {
|
||||
continue
|
||||
}
|
||||
messages = append(messages, AnthropicMessage{
|
||||
Role: "assistant",
|
||||
Content: content,
|
||||
})
|
||||
|
||||
default:
|
||||
// Unknown role/type — attempt as user message
|
||||
if item.Content != nil {
|
||||
messages = append(messages, AnthropicMessage{
|
||||
Role: "user",
|
||||
Content: item.Content,
|
||||
})
|
||||
// 未知 role/type —— 尽量当作 user 消息保留其中的文本/图片。
|
||||
// 必须走与真实 user 消息同一套白名单转换:直接透传 item.Content 会把
|
||||
// Responses 专有的分片类型(reasoning_text、web_search_call 的载荷等)
|
||||
// 原样发给 Anthropic,上游只会回 400 把整轮打挂。
|
||||
if item.Content == nil {
|
||||
continue
|
||||
}
|
||||
content, err := convertResponsesUserToAnthropicContent(item.Content)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if anthropicContentIsEmpty(content) {
|
||||
continue
|
||||
}
|
||||
messages = append(messages, AnthropicMessage{
|
||||
Role: "user",
|
||||
Content: content,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -393,6 +422,32 @@ func extractTextFromContent(raw json.RawMessage) string {
|
||||
|
||||
// convertResponsesUserToAnthropicContent converts a Responses user message
|
||||
// content field into Anthropic content blocks JSON.
|
||||
// anthropicContentIsEmpty 判断转换结果是否为"空内容"。
|
||||
// convertResponsesUserToAnthropicContent 在没有任何可识别分片时返回 JSON 空串,
|
||||
// 而 Anthropic 拒收空内容消息。
|
||||
func anthropicContentIsEmpty(content json.RawMessage) bool {
|
||||
trimmed := strings.TrimSpace(string(content))
|
||||
switch trimmed {
|
||||
case "", "null", `""`, "[]":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// anthropicContentIsOnlyBlankText 判断内容是否只由空白 text 块组成。
|
||||
func anthropicContentIsOnlyBlankText(content json.RawMessage) bool {
|
||||
blocks := parseContentBlocks(content)
|
||||
if len(blocks) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, b := range blocks {
|
||||
if b.Type != "text" || strings.TrimSpace(b.Text) != "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func convertResponsesUserToAnthropicContent(raw json.RawMessage) (json.RawMessage, error) {
|
||||
if len(raw) == 0 {
|
||||
return json.Marshal("") // empty string content
|
||||
|
||||
@@ -50,6 +50,12 @@ const (
|
||||
// OpenAIImageGenerationIntent 标识 OpenAI 请求会触发生图能力(用于图片能力维度限流)
|
||||
OpenAIImageGenerationIntent Key = "ctx_openai_image_generation_intent"
|
||||
|
||||
// OpenAIImagesEndpoint 标识请求是从 /v1/images/* 入站的。
|
||||
// 与 OpenAIImageGenerationIntent 的区别:后者只表示"这次请求会生图",
|
||||
// /v1/responses 带图片模型时也会置位;本 key 只在专用生图端点置位,
|
||||
// 用于区分"用错端点"与"端点用对了但账号没能力"。
|
||||
OpenAIImagesEndpoint Key = "ctx_openai_images_endpoint"
|
||||
|
||||
// Group 认证后的分组信息,由 API Key 认证中间件设置
|
||||
Group Key = "ctx_group"
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -19,7 +19,7 @@ const (
|
||||
ClientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
||||
|
||||
// OAuth endpoints
|
||||
AuthorizeURL = "https://claude.ai/oauth/authorize"
|
||||
AuthorizeURL = "https://claude.com/cai/oauth/authorize"
|
||||
TokenURL = "https://platform.claude.com/v1/oauth/token"
|
||||
RedirectURI = "https://platform.claude.com/oauth/code/callback"
|
||||
|
||||
|
||||
@@ -1,11 +1,36 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAuthorizeURLMatchesClaudeCodeCLI(t *testing.T) {
|
||||
const want = "https://claude.com/cai/oauth/authorize"
|
||||
if AuthorizeURL != want {
|
||||
t.Fatalf("AuthorizeURL = %q, want %q", AuthorizeURL, want)
|
||||
}
|
||||
|
||||
authURL := BuildAuthorizationURL("state-value", "challenge-value", ScopeOAuth)
|
||||
if !strings.HasPrefix(authURL, want+"?") {
|
||||
t.Fatalf("BuildAuthorizationURL() = %q, want prefix %q", authURL, want+"?")
|
||||
}
|
||||
for _, part := range []string{
|
||||
"code=true",
|
||||
"client_id=" + ClientID,
|
||||
"response_type=code",
|
||||
"code_challenge=challenge-value",
|
||||
"code_challenge_method=S256",
|
||||
"state=state-value",
|
||||
} {
|
||||
if !strings.Contains(authURL, part) {
|
||||
t.Fatalf("BuildAuthorizationURL() missing %q\nURL: %s", part, authURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionStore_Stop_Idempotent(t *testing.T) {
|
||||
store := NewSessionStore()
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -149,6 +149,7 @@ type UserUsageTrendPoint struct {
|
||||
type UserSpendingRankingItem struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
Username string `json:"username"`
|
||||
ActualCost float64 `json:"actual_cost"` // 实际扣除
|
||||
Requests int64 `json:"requests"`
|
||||
Tokens int64 `json:"tokens"`
|
||||
@@ -273,13 +274,14 @@ type UsageLogFilters struct {
|
||||
RequestID string
|
||||
Model string
|
||||
// ModelFilterSource controls how Model is matched. Empty preserves raw usage_logs.model semantics.
|
||||
ModelFilterSource string
|
||||
RequestType *int16
|
||||
Stream *bool
|
||||
BillingType *int8
|
||||
BillingMode string
|
||||
StartTime *time.Time
|
||||
EndTime *time.Time
|
||||
ModelFilterSource string
|
||||
RequestType *int16
|
||||
Stream *bool
|
||||
BillingType *int8
|
||||
BillingMode string
|
||||
UpstreamModelMismatch *bool
|
||||
StartTime *time.Time
|
||||
EndTime *time.Time
|
||||
// ExactTotal requests exact COUNT(*) for pagination. Default false for fast large-table paging.
|
||||
ExactTotal bool
|
||||
}
|
||||
|
||||
@@ -15,8 +15,11 @@ const (
|
||||
CLITokenAuthHeader = "x-xai-token-auth"
|
||||
CLITokenAuthValue = "xai-grok-cli"
|
||||
CLIClientVersionHeader = "x-grok-client-version"
|
||||
// CLIClientVersion is the one place the pinned Grok CLI version lives. The
|
||||
// repository and service layers build their own client identity from it, so
|
||||
// one bump here covers OAuth traffic and billing probes together.
|
||||
// Keep in sync with https://x.ai/cli/stable.
|
||||
CLIClientVersion = "0.2.93"
|
||||
CLIClientVersion = "0.2.114"
|
||||
CLIUserAgent = "grok-pager/" + CLIClientVersion + " grok-shell/" + CLIClientVersion + " (macos; aarch64)"
|
||||
|
||||
BillingWeeklyPath = "/billing?format=credits"
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/proxyutil"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/servertiming"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/Wei-Shaw/sub2api/internal/util/urlvalidator"
|
||||
"golang.org/x/mod/semver"
|
||||
@@ -54,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
|
||||
@@ -76,7 +89,7 @@ const (
|
||||
// allowing operators to bump it without waiting for a Sub2API release.
|
||||
grokCLIProxyHost = "cli-chat-proxy.grok.com"
|
||||
grokOfficialAPIHost = "api.x.ai"
|
||||
grokCLIStableVersion = "0.2.93"
|
||||
grokCLIStableVersion = xai.CLIClientVersion
|
||||
grokCLIVersionOverride = "XAI_GROK_CLI_VERSION"
|
||||
grokFallbackBodyLimit = 64 << 10
|
||||
)
|
||||
@@ -1245,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
|
||||
// 使用配置文件中的连接池参数,支持生产环境调优
|
||||
//
|
||||
@@ -1257,6 +1281,8 @@ func defaultPoolSettings(cfg *config.Config) poolSettings {
|
||||
// - error: 代理配置错误
|
||||
//
|
||||
// Transport 参数说明:
|
||||
// - DialContext: DNS 解析 + TCP 建连超时(不设置则无上限,退化为内核默认重传)
|
||||
// - TLSHandshakeTimeout: TLS 握手超时
|
||||
// - MaxIdleConns: 所有主机的最大空闲连接总数
|
||||
// - MaxIdleConnsPerHost: 每主机最大空闲连接数(影响连接复用率)
|
||||
// - MaxConnsPerHost: 每主机最大连接数(达到后新请求等待)
|
||||
@@ -1264,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 必须立即中止拨号")
|
||||
}
|
||||
@@ -235,9 +235,9 @@ func TestHTTPUpstreamDoAppliesGrokCLIIdentityBeforeOAuthRoundTrip(t *testing.T)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
|
||||
require.Equal(t, "0.2.93", capturedHeaders.Get("x-grok-client-version"))
|
||||
require.Equal(t, "0.2.114", capturedHeaders.Get("x-grok-client-version"))
|
||||
require.Equal(t, "xai-grok-cli", capturedHeaders.Get("X-XAI-Token-Auth"))
|
||||
require.Equal(t, "xai-grok-workspace/0.2.93", capturedHeaders.Get("User-Agent"))
|
||||
require.Equal(t, "xai-grok-workspace/0.2.114", capturedHeaders.Get("User-Agent"))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -458,61 +458,63 @@ func TestApplyGrokCLIProxyHeaders(t *testing.T) {
|
||||
|
||||
applyGrokCLIProxyHeaders(req)
|
||||
|
||||
require.Equal(t, "0.2.93", req.Header.Get("x-grok-client-version"))
|
||||
require.Equal(t, "0.2.114", req.Header.Get("x-grok-client-version"))
|
||||
require.Equal(t, "xai-grok-cli", req.Header.Get("X-XAI-Token-Auth"))
|
||||
require.Equal(t, "xai-grok-workspace/0.2.93", req.Header.Get("User-Agent"))
|
||||
require.Equal(t, "xai-grok-workspace/0.2.114", req.Header.Get("User-Agent"))
|
||||
})
|
||||
|
||||
t.Run("accepts a valid operator override", func(t *testing.T) {
|
||||
t.Setenv("XAI_GROK_CLI_VERSION", "0.2.95-alpha.1")
|
||||
t.Setenv("XAI_GROK_CLI_VERSION", "0.2.115-alpha.1")
|
||||
req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/chat/completions", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
applyGrokCLIProxyHeaders(req)
|
||||
|
||||
require.Equal(t, "0.2.95-alpha.1", req.Header.Get("x-grok-client-version"))
|
||||
require.Equal(t, "xai-grok-workspace/0.2.95-alpha.1", req.Header.Get("User-Agent"))
|
||||
require.Equal(t, "0.2.115-alpha.1", req.Header.Get("x-grok-client-version"))
|
||||
require.Equal(t, "xai-grok-workspace/0.2.115-alpha.1", req.Header.Get("User-Agent"))
|
||||
})
|
||||
|
||||
t.Run("rejects an unsafe override", func(t *testing.T) {
|
||||
t.Setenv("XAI_GROK_CLI_VERSION", "0.2.95\r\nX-Injected: true")
|
||||
t.Setenv("XAI_GROK_CLI_VERSION", "0.2.115\r\nX-Injected: true")
|
||||
req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/responses", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
applyGrokCLIProxyHeaders(req)
|
||||
|
||||
require.Equal(t, "0.2.93", req.Header.Get("x-grok-client-version"))
|
||||
require.Equal(t, "0.2.114", req.Header.Get("x-grok-client-version"))
|
||||
require.Empty(t, req.Header.Get("X-Injected"))
|
||||
})
|
||||
|
||||
t.Run("rejects an override below the supported minimum", func(t *testing.T) {
|
||||
t.Setenv("XAI_GROK_CLI_VERSION", "0.2.92")
|
||||
t.Setenv("XAI_GROK_CLI_VERSION", "0.2.113")
|
||||
req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/responses", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
applyGrokCLIProxyHeaders(req)
|
||||
|
||||
require.Equal(t, "0.2.93", req.Header.Get("x-grok-client-version"))
|
||||
require.Equal(t, "xai-grok-workspace/0.2.93", req.Header.Get("User-Agent"))
|
||||
require.Equal(t, "0.2.114", req.Header.Get("x-grok-client-version"))
|
||||
require.Equal(t, "xai-grok-workspace/0.2.114", req.Header.Get("User-Agent"))
|
||||
})
|
||||
|
||||
t.Run("rejects a prerelease override at the minimum version", func(t *testing.T) {
|
||||
t.Setenv("XAI_GROK_CLI_VERSION", "0.2.93-beta.1")
|
||||
t.Setenv("XAI_GROK_CLI_VERSION", "0.2.114-beta.1")
|
||||
req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/responses", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
applyGrokCLIProxyHeaders(req)
|
||||
|
||||
require.Equal(t, "0.2.93", req.Header.Get("x-grok-client-version"))
|
||||
require.Equal(t, "xai-grok-workspace/0.2.93", req.Header.Get("User-Agent"))
|
||||
require.Equal(t, "0.2.114", req.Header.Get("x-grok-client-version"))
|
||||
require.Equal(t, "xai-grok-workspace/0.2.114", req.Header.Get("User-Agent"))
|
||||
})
|
||||
|
||||
// Every entry sits above the pinned minimum, so a rejection here can only be
|
||||
// caused by the malformed semver and never by the version being too old.
|
||||
for _, version := range []string{
|
||||
"0.2.093",
|
||||
"0.2.94-alpha..1",
|
||||
"0.2.0115",
|
||||
"0.2.115-alpha..1",
|
||||
"0.3",
|
||||
"1",
|
||||
"0.2.95+build.1",
|
||||
"0.2.115+build.1",
|
||||
} {
|
||||
t.Run("rejects invalid semver "+version, func(t *testing.T) {
|
||||
t.Setenv("XAI_GROK_CLI_VERSION", version)
|
||||
@@ -521,8 +523,8 @@ func TestApplyGrokCLIProxyHeaders(t *testing.T) {
|
||||
|
||||
applyGrokCLIProxyHeaders(req)
|
||||
|
||||
require.Equal(t, "0.2.93", req.Header.Get("x-grok-client-version"))
|
||||
require.Equal(t, "xai-grok-workspace/0.2.93", req.Header.Get("User-Agent"))
|
||||
require.Equal(t, "0.2.114", req.Header.Get("x-grok-client-version"))
|
||||
require.Equal(t, "xai-grok-workspace/0.2.114", req.Header.Get("User-Agent"))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,8 @@ const schedulerOutboxPendingDedupKeyMigration = "153_scheduler_outbox_pending_de
|
||||
const schedulerOutboxPendingDedupKeyIndex = "idx_scheduler_outbox_pending_dedup_key"
|
||||
const latestAPIKeyIPIndexMigration = "174_add_usage_logs_api_key_latest_ip_index_notx.sql"
|
||||
const latestAPIKeyIPIndex = "idx_usage_logs_api_key_latest_ip"
|
||||
const usageLogsUpstreamModelMismatchIndexMigration = "195_add_usage_log_upstream_model_mismatch_index_notx.sql"
|
||||
const usageLogsUpstreamModelMismatchIndex = "idx_usage_logs_upstream_model_mismatch_created_at"
|
||||
|
||||
type migrationChecksumCompatibilityRule struct {
|
||||
fileChecksum string
|
||||
@@ -283,6 +285,8 @@ func prepareNonTransactionalMigration(ctx context.Context, db migrationConnectio
|
||||
return dropInvalidIndexIfPresent(ctx, db, schedulerOutboxPendingDedupKeyIndex)
|
||||
case latestAPIKeyIPIndexMigration:
|
||||
return dropInvalidIndexIfPresent(ctx, db, latestAPIKeyIPIndex)
|
||||
case usageLogsUpstreamModelMismatchIndexMigration:
|
||||
return dropInvalidIndexIfPresent(ctx, db, usageLogsUpstreamModelMismatchIndex)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -155,6 +155,42 @@ CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_usage_logs_api_key_latest_ip
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
|
||||
func TestApplyMigrationsFS_NonTransactionalMigration_UsageModelMismatchIndexDropsInvalidIndexBeforeRetry(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = db.Close() }()
|
||||
|
||||
prepareMigrationsBootstrapExpectations(mock)
|
||||
mock.ExpectQuery("SELECT checksum FROM schema_migrations WHERE filename = \\$1").
|
||||
WithArgs(usageLogsUpstreamModelMismatchIndexMigration).
|
||||
WillReturnError(sql.ErrNoRows)
|
||||
mock.ExpectQuery("SELECT EXISTS \\(").
|
||||
WithArgs(usageLogsUpstreamModelMismatchIndex).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
|
||||
mock.ExpectExec("DROP INDEX CONCURRENTLY IF EXISTS idx_usage_logs_upstream_model_mismatch_created_at").
|
||||
WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
mock.ExpectExec("CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_usage_logs_upstream_model_mismatch_created_at").
|
||||
WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
mock.ExpectExec("INSERT INTO schema_migrations \\(filename, checksum\\) VALUES \\(\\$1, \\$2\\)").
|
||||
WithArgs(usageLogsUpstreamModelMismatchIndexMigration, sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectExec("SELECT pg_advisory_unlock\\(\\$1\\)").
|
||||
WithArgs(migrationsAdvisoryLockID).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
fsys := fstest.MapFS{
|
||||
usageLogsUpstreamModelMismatchIndexMigration: &fstest.MapFile{Data: []byte(`
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_usage_logs_upstream_model_mismatch_created_at
|
||||
ON usage_logs (created_at DESC, id DESC)
|
||||
WHERE upstream_model_mismatch IS TRUE;
|
||||
`)},
|
||||
}
|
||||
|
||||
err = applyMigrationsFS(context.Background(), db, fsys)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
|
||||
func TestApplyMigrationsFS_PaymentOrdersOutTradeNoUniqueMigration_FailsFastOnDuplicatePrecheck(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -76,6 +76,24 @@ func TestMigrationsRunner_IsIdempotent_AndSchemaIsUpToDate(t *testing.T) {
|
||||
requireColumn(t, tx, "usage_logs", "video_count", "integer", 0, false)
|
||||
requireColumn(t, tx, "usage_logs", "video_resolution", "character varying", 10, true)
|
||||
requireColumn(t, tx, "usage_logs", "video_duration_seconds", "integer", 0, true)
|
||||
requireColumn(t, tx, "usage_logs", "upstream_response_model", "character varying", 200, true)
|
||||
requireColumn(t, tx, "usage_logs", "upstream_model_mismatch", "boolean", 0, true)
|
||||
requireIndex(t, tx, "usage_logs", usageLogsUpstreamModelMismatchIndex)
|
||||
|
||||
var mismatchIndexDef string
|
||||
require.NoError(t, tx.QueryRowContext(context.Background(), `
|
||||
SELECT pg_get_indexdef(i.indexrelid)
|
||||
FROM pg_class idx
|
||||
JOIN pg_index i ON i.indexrelid = idx.oid
|
||||
JOIN pg_class tbl ON tbl.oid = i.indrelid
|
||||
JOIN pg_namespace ns ON ns.oid = tbl.relnamespace
|
||||
WHERE ns.nspname = 'public'
|
||||
AND tbl.relname = 'usage_logs'
|
||||
AND idx.relname = $1
|
||||
`, usageLogsUpstreamModelMismatchIndex).Scan(&mismatchIndexDef))
|
||||
require.Contains(t, mismatchIndexDef, "created_at DESC")
|
||||
require.Contains(t, mismatchIndexDef, "id DESC")
|
||||
require.Contains(t, mismatchIndexDef, "WHERE (upstream_model_mismatch IS TRUE)")
|
||||
requireConstraintDefinitionContains(
|
||||
t,
|
||||
tx,
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
captcha "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/captcha/v20190722"
|
||||
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common"
|
||||
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/profile"
|
||||
)
|
||||
|
||||
type tencentCaptchaAPI interface {
|
||||
DescribeCaptchaResultWithContext(context.Context, *captcha.DescribeCaptchaResultRequest) (*captcha.DescribeCaptchaResultResponse, error)
|
||||
}
|
||||
|
||||
type tencentCaptchaClientFactory func(secretID, secretKey, endpoint string) (tencentCaptchaAPI, error)
|
||||
|
||||
type tencentCaptchaVerifier struct {
|
||||
newClient tencentCaptchaClientFactory
|
||||
}
|
||||
|
||||
func NewTencentCaptchaVerifier() service.TencentCaptchaVerifier {
|
||||
return &tencentCaptchaVerifier{newClient: newTencentCaptchaSDKClient}
|
||||
}
|
||||
|
||||
// newTencentCaptchaSDKClient 接入点由调用方按站点下发(中国站 / 国际站)。
|
||||
// service 层的 tencentCaptchaEndpoint 已保证 endpoint 非空;即便为空,
|
||||
// 腾讯 SDK 也会回落到服务默认域 captcha.tencentcloudapi.com(即中国站),不会失败。
|
||||
func newTencentCaptchaSDKClient(secretID, secretKey, endpoint string) (tencentCaptchaAPI, error) {
|
||||
clientProfile := profile.NewClientProfile()
|
||||
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, credentials.Endpoint)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create tencent captcha client: %w", err)
|
||||
}
|
||||
request := captcha.NewDescribeCaptchaResultRequest()
|
||||
request.CaptchaType = common.Uint64Ptr(9)
|
||||
request.Ticket = common.StringPtr(proof.Ticket)
|
||||
request.UserIp = common.StringPtr(remoteIP)
|
||||
request.Randstr = common.StringPtr(proof.Randstr)
|
||||
request.CaptchaAppId = common.Uint64Ptr(credentials.AppID)
|
||||
request.AppSecretKey = common.StringPtr(credentials.AppSecretKey)
|
||||
|
||||
response, err := client.DescribeCaptchaResultWithContext(ctx, request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("describe captcha result: %w", err)
|
||||
}
|
||||
if response == nil || response.Response == nil {
|
||||
return nil, fmt.Errorf("describe captcha result: empty response")
|
||||
}
|
||||
return &service.TencentCaptchaVerifyResponse{
|
||||
CaptchaCode: valueOrZero(response.Response.CaptchaCode),
|
||||
CaptchaMsg: valueOrEmpty(response.Response.CaptchaMsg),
|
||||
RequestID: valueOrEmpty(response.Response.RequestId),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func valueOrZero(value *int64) int64 {
|
||||
if value == nil {
|
||||
return 0
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func valueOrEmpty(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return *value
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
//go:build unit
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/stretchr/testify/require"
|
||||
capcha "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/captcha/v20190722"
|
||||
)
|
||||
|
||||
type tencentCaptchaAPIStub struct {
|
||||
request *capcha.DescribeCaptchaResultRequest
|
||||
response *capcha.DescribeCaptchaResultResponse
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *tencentCaptchaAPIStub) DescribeCaptchaResultWithContext(_ context.Context, request *capcha.DescribeCaptchaResultRequest) (*capcha.DescribeCaptchaResultResponse, error) {
|
||||
s.request = request
|
||||
return s.response, s.err
|
||||
}
|
||||
|
||||
func TestTencentCaptchaVerifierMapsCredentialsRequestAndResponse(t *testing.T) {
|
||||
code := int64(1)
|
||||
message := "OK"
|
||||
requestID := "request-id"
|
||||
client := &tencentCaptchaAPIStub{response: &capcha.DescribeCaptchaResultResponse{
|
||||
Response: &capcha.DescribeCaptchaResultResponseParams{
|
||||
CaptchaCode: &code,
|
||||
CaptchaMsg: &message,
|
||||
RequestId: &requestID,
|
||||
},
|
||||
}}
|
||||
var gotSecretID, gotSecretKey, gotEndpoint string
|
||||
verifier := &tencentCaptchaVerifier{
|
||||
newClient: func(secretID, secretKey, endpoint string) (tencentCaptchaAPI, error) {
|
||||
gotSecretID, gotSecretKey, gotEndpoint = secretID, secretKey, endpoint
|
||||
return client, nil
|
||||
},
|
||||
}
|
||||
|
||||
result, err := verifier.VerifyTicket(context.Background(), service.TencentCaptchaCredentials{
|
||||
AppID: 123456789,
|
||||
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)
|
||||
require.Equal(t, "app-secret", *client.request.AppSecretKey)
|
||||
require.Equal(t, "ticket", *client.request.Ticket)
|
||||
require.Equal(t, "@rand", *client.request.Randstr)
|
||||
require.Equal(t, "203.0.113.10", *client.request.UserIp)
|
||||
require.Equal(t, &service.TencentCaptchaVerifyResponse{
|
||||
CaptchaCode: 1,
|
||||
CaptchaMsg: "OK",
|
||||
RequestID: "request-id",
|
||||
}, result)
|
||||
}
|
||||
@@ -31,6 +31,8 @@ var usageLogInsertArgTypes = [...]string{
|
||||
"text", // model
|
||||
"text", // requested_model
|
||||
"text", // upstream_model
|
||||
"text", // upstream_response_model
|
||||
"boolean", // upstream_model_mismatch
|
||||
"bigint", // group_id
|
||||
"bigint", // subscription_id
|
||||
"integer", // input_tokens
|
||||
@@ -227,6 +229,8 @@ func (r *usageLogRepository) createSingle(ctx context.Context, sqlq sqlExecutor,
|
||||
model,
|
||||
requested_model,
|
||||
upstream_model,
|
||||
upstream_response_model,
|
||||
upstream_model_mismatch,
|
||||
group_id,
|
||||
subscription_id,
|
||||
input_tokens,
|
||||
@@ -278,12 +282,12 @@ func (r *usageLogRepository) createSingle(ctx context.Context, sqlq sqlExecutor,
|
||||
session_id,
|
||||
created_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7,
|
||||
$8, $9,
|
||||
$10, $11, $12, $13,
|
||||
$14, $15, $16, $17,
|
||||
$18, $19, $20, $21, $22, $23,
|
||||
$24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56, $57
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9,
|
||||
$10, $11,
|
||||
$12, $13, $14, $15,
|
||||
$16, $17, $18, $19,
|
||||
$20, $21, $22, $23, $24, $25,
|
||||
$26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56, $57, $58, $59
|
||||
)
|
||||
ON CONFLICT (request_id, api_key_id) DO NOTHING
|
||||
RETURNING id, created_at
|
||||
@@ -682,6 +686,8 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage
|
||||
model,
|
||||
requested_model,
|
||||
upstream_model,
|
||||
upstream_response_model,
|
||||
upstream_model_mismatch,
|
||||
group_id,
|
||||
subscription_id,
|
||||
input_tokens,
|
||||
@@ -734,9 +740,9 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage
|
||||
created_at
|
||||
) AS (VALUES `)
|
||||
|
||||
// Each batch row prepends the synthetic input_index before the 57
|
||||
// Each batch row prepends the synthetic input_index before the 59
|
||||
// usage-log column values.
|
||||
args := make([]any, 0, len(keys)*58)
|
||||
args := make([]any, 0, len(keys)*60)
|
||||
argPos := 1
|
||||
for idx, key := range keys {
|
||||
if idx > 0 {
|
||||
@@ -772,6 +778,8 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage
|
||||
model,
|
||||
requested_model,
|
||||
upstream_model,
|
||||
upstream_response_model,
|
||||
upstream_model_mismatch,
|
||||
group_id,
|
||||
subscription_id,
|
||||
input_tokens,
|
||||
@@ -831,6 +839,8 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage
|
||||
model,
|
||||
requested_model,
|
||||
upstream_model,
|
||||
upstream_response_model,
|
||||
upstream_model_mismatch,
|
||||
group_id,
|
||||
subscription_id,
|
||||
input_tokens,
|
||||
@@ -930,6 +940,8 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) (
|
||||
model,
|
||||
requested_model,
|
||||
upstream_model,
|
||||
upstream_response_model,
|
||||
upstream_model_mismatch,
|
||||
group_id,
|
||||
subscription_id,
|
||||
input_tokens,
|
||||
@@ -982,7 +994,7 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) (
|
||||
created_at
|
||||
) AS (VALUES `)
|
||||
|
||||
args := make([]any, 0, len(preparedList)*57)
|
||||
args := make([]any, 0, len(preparedList)*59)
|
||||
argPos := 1
|
||||
for idx, prepared := range preparedList {
|
||||
if idx > 0 {
|
||||
@@ -1015,6 +1027,8 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) (
|
||||
model,
|
||||
requested_model,
|
||||
upstream_model,
|
||||
upstream_response_model,
|
||||
upstream_model_mismatch,
|
||||
group_id,
|
||||
subscription_id,
|
||||
input_tokens,
|
||||
@@ -1074,6 +1088,8 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) (
|
||||
model,
|
||||
requested_model,
|
||||
upstream_model,
|
||||
upstream_response_model,
|
||||
upstream_model_mismatch,
|
||||
group_id,
|
||||
subscription_id,
|
||||
input_tokens,
|
||||
@@ -1141,6 +1157,8 @@ func execUsageLogInsertNoResult(ctx context.Context, sqlq sqlExecutor, prepared
|
||||
model,
|
||||
requested_model,
|
||||
upstream_model,
|
||||
upstream_response_model,
|
||||
upstream_model_mismatch,
|
||||
group_id,
|
||||
subscription_id,
|
||||
input_tokens,
|
||||
@@ -1192,12 +1210,12 @@ func execUsageLogInsertNoResult(ctx context.Context, sqlq sqlExecutor, prepared
|
||||
session_id,
|
||||
created_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7,
|
||||
$8, $9,
|
||||
$10, $11, $12, $13,
|
||||
$14, $15, $16, $17,
|
||||
$18, $19, $20, $21, $22, $23,
|
||||
$24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56, $57
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9,
|
||||
$10, $11,
|
||||
$12, $13, $14, $15,
|
||||
$16, $17, $18, $19,
|
||||
$20, $21, $22, $23, $24, $25,
|
||||
$26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56, $57, $58, $59
|
||||
)
|
||||
ON CONFLICT (request_id, api_key_id) DO NOTHING
|
||||
`, prepared.args...)
|
||||
@@ -1244,6 +1262,8 @@ func prepareUsageLogInsert(log *service.UsageLog) usageLogInsertPrepared {
|
||||
requestedModel = strings.TrimSpace(log.Model)
|
||||
}
|
||||
upstreamModel := nullString(log.UpstreamModel)
|
||||
upstreamResponseModel := nullString(log.UpstreamResponseModel)
|
||||
upstreamModelMismatch := nullBool(log.UpstreamModelMismatch)
|
||||
|
||||
var requestIDArg any
|
||||
if requestID != "" {
|
||||
@@ -1263,6 +1283,8 @@ func prepareUsageLogInsert(log *service.UsageLog) usageLogInsertPrepared {
|
||||
log.Model,
|
||||
nullString(&requestedModel),
|
||||
upstreamModel,
|
||||
upstreamResponseModel,
|
||||
upstreamModelMismatch,
|
||||
groupID,
|
||||
subscriptionID,
|
||||
log.InputTokens,
|
||||
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
)
|
||||
|
||||
const usageLogSelectColumns = "id, user_id, api_key_id, account_id, request_id, model, requested_model, upstream_model, group_id, subscription_id, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cache_creation_5m_tokens, cache_creation_1h_tokens, image_output_tokens, image_output_cost, image_input_tokens, image_input_cost, input_cost, output_cost, cache_creation_cost, cache_read_cost, total_cost, actual_cost, rate_multiplier, account_rate_multiplier, billing_type, request_type, stream, openai_ws_mode, duration_ms, first_token_ms, user_agent, ip_address, image_count, image_size, image_input_size, image_output_size, image_size_source, image_size_breakdown, video_count, video_resolution, video_duration_seconds, service_tier, reasoning_effort, inbound_endpoint, upstream_endpoint, cache_ttl_overridden, long_context_billing_applied, channel_id, model_mapping_chain, billing_tier, billing_mode, account_stats_cost, session_id, created_at"
|
||||
const usageLogSelectColumns = "id, user_id, api_key_id, account_id, request_id, model, requested_model, upstream_model, upstream_response_model, upstream_model_mismatch, group_id, subscription_id, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cache_creation_5m_tokens, cache_creation_1h_tokens, image_output_tokens, image_output_cost, image_input_tokens, image_input_cost, input_cost, output_cost, cache_creation_cost, cache_read_cost, total_cost, actual_cost, rate_multiplier, account_rate_multiplier, billing_type, request_type, stream, openai_ws_mode, duration_ms, first_token_ms, user_agent, ip_address, image_count, image_size, image_input_size, image_output_size, image_size_source, image_size_breakdown, video_count, video_resolution, video_duration_seconds, service_tier, reasoning_effort, inbound_endpoint, upstream_endpoint, cache_ttl_overridden, long_context_billing_applied, channel_id, model_mapping_chain, billing_tier, billing_mode, account_stats_cost, session_id, created_at"
|
||||
|
||||
func (r *usageLogRepository) GetByID(ctx context.Context, id int64) (log *service.UsageLog, err error) {
|
||||
query := "SELECT " + usageLogSelectColumns + " FROM usage_logs WHERE id = $1"
|
||||
@@ -127,6 +127,9 @@ func (r *usageLogRepository) ListWithFilters(ctx context.Context, params paginat
|
||||
args = append(args, int16(*filters.BillingType))
|
||||
}
|
||||
conditions, args = appendUsageLogBillingModeWhereCondition(conditions, args, filters.BillingMode)
|
||||
if filters.UpstreamModelMismatch != nil {
|
||||
conditions = append(conditions, upstreamModelMismatchCondition("upstream_model_mismatch", *filters.UpstreamModelMismatch))
|
||||
}
|
||||
if filters.StartTime != nil {
|
||||
conditions = append(conditions, fmt.Sprintf("created_at >= $%d", len(args)+1))
|
||||
args = append(args, *filters.StartTime)
|
||||
@@ -157,6 +160,13 @@ func (r *usageLogRepository) ListWithFilters(ctx context.Context, params paginat
|
||||
return logs, page, nil
|
||||
}
|
||||
|
||||
func upstreamModelMismatchCondition(column string, mismatch bool) string {
|
||||
if mismatch {
|
||||
return column + " IS TRUE"
|
||||
}
|
||||
return column + " IS FALSE"
|
||||
}
|
||||
|
||||
func shouldUseFastUsageLogTotal(filters UsageLogFilters) bool {
|
||||
if filters.ExactTotal {
|
||||
return false
|
||||
@@ -437,6 +447,8 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e
|
||||
model string
|
||||
requestedModel sql.NullString
|
||||
upstreamModel sql.NullString
|
||||
upstreamResponseModel sql.NullString
|
||||
upstreamModelMismatch sql.NullBool
|
||||
groupID sql.NullInt64
|
||||
subscriptionID sql.NullInt64
|
||||
inputTokens int
|
||||
@@ -498,6 +510,8 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e
|
||||
&model,
|
||||
&requestedModel,
|
||||
&upstreamModel,
|
||||
&upstreamResponseModel,
|
||||
&upstreamModelMismatch,
|
||||
&groupID,
|
||||
&subscriptionID,
|
||||
&inputTokens,
|
||||
@@ -651,6 +665,13 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e
|
||||
if upstreamModel.Valid {
|
||||
log.UpstreamModel = &upstreamModel.String
|
||||
}
|
||||
if upstreamResponseModel.Valid {
|
||||
log.UpstreamResponseModel = &upstreamResponseModel.String
|
||||
}
|
||||
if upstreamModelMismatch.Valid {
|
||||
value := upstreamModelMismatch.Bool
|
||||
log.UpstreamModelMismatch = &value
|
||||
}
|
||||
if channelID.Valid {
|
||||
value := channelID.Int64
|
||||
log.ChannelID = &value
|
||||
@@ -703,6 +724,13 @@ func nullString(v *string) sql.NullString {
|
||||
return sql.NullString{String: *v, Valid: true}
|
||||
}
|
||||
|
||||
func nullBool(v *bool) sql.NullBool {
|
||||
if v == nil {
|
||||
return sql.NullBool{}
|
||||
}
|
||||
return sql.NullBool{Bool: *v, Valid: true}
|
||||
}
|
||||
|
||||
func nullStringIntMapJSON(v map[string]int) any {
|
||||
if len(v) == 0 {
|
||||
return nil
|
||||
|
||||
@@ -48,6 +48,8 @@ func TestUsageLogRepositoryCreateSyncRequestTypeAndLegacyFields(t *testing.T) {
|
||||
log.Model,
|
||||
log.RequestedModel,
|
||||
sqlmock.AnyArg(), // upstream_model
|
||||
sqlmock.AnyArg(), // upstream_response_model
|
||||
sqlmock.AnyArg(), // upstream_model_mismatch
|
||||
sqlmock.AnyArg(), // group_id
|
||||
sqlmock.AnyArg(), // subscription_id
|
||||
log.InputTokens,
|
||||
@@ -137,9 +139,11 @@ func TestUsageLogRepositoryCreate_PersistsServiceTier(t *testing.T) {
|
||||
log.RequestID,
|
||||
log.Model,
|
||||
log.RequestedModel,
|
||||
sqlmock.AnyArg(),
|
||||
sqlmock.AnyArg(),
|
||||
sqlmock.AnyArg(),
|
||||
sqlmock.AnyArg(), // upstream_model
|
||||
sqlmock.AnyArg(), // upstream_response_model
|
||||
sqlmock.AnyArg(), // upstream_model_mismatch
|
||||
sqlmock.AnyArg(), // group_id
|
||||
sqlmock.AnyArg(), // subscription_id
|
||||
log.InputTokens,
|
||||
log.OutputTokens,
|
||||
log.CacheCreationTokens,
|
||||
@@ -211,8 +215,8 @@ func TestBuildUsageLogBestEffortInsertQuery_IncludesRequestedModelColumn(t *test
|
||||
query, args := buildUsageLogBestEffortInsertQuery([]usageLogInsertPrepared{prepared})
|
||||
|
||||
require.Contains(t, query, "INSERT INTO usage_logs (")
|
||||
require.Contains(t, query, "\n\t\t\tmodel,\n\t\t\trequested_model,\n\t\t\tupstream_model,")
|
||||
require.Contains(t, query, "\n\t\t\trequest_id,\n\t\t\tmodel,\n\t\t\trequested_model,\n\t\t\tupstream_model,")
|
||||
require.Contains(t, query, "\n\t\t\tmodel,\n\t\t\trequested_model,\n\t\t\tupstream_model,\n\t\t\tupstream_response_model,\n\t\t\tupstream_model_mismatch,")
|
||||
require.Contains(t, query, "\n\t\t\trequest_id,\n\t\t\tmodel,\n\t\t\trequested_model,\n\t\t\tupstream_model,\n\t\t\tupstream_response_model,\n\t\t\tupstream_model_mismatch,")
|
||||
require.Len(t, args, len(prepared.args))
|
||||
require.Equal(t, prepared.args[5], args[5])
|
||||
}
|
||||
@@ -273,11 +277,11 @@ func TestPrepareUsageLogInsert_PersistsImageSizeMetadata(t *testing.T) {
|
||||
CreatedAt: time.Date(2025, 1, 6, 12, 0, 0, 0, time.UTC),
|
||||
})
|
||||
|
||||
require.Equal(t, sql.NullString{String: imageSize, Valid: true}, prepared.args[36])
|
||||
require.Equal(t, sql.NullString{String: inputSize, Valid: true}, prepared.args[37])
|
||||
require.Equal(t, sql.NullString{String: outputSize, Valid: true}, prepared.args[38])
|
||||
require.Equal(t, sql.NullString{String: source, Valid: true}, prepared.args[39])
|
||||
breakdownJSON, ok := prepared.args[40].(string)
|
||||
require.Equal(t, sql.NullString{String: imageSize, Valid: true}, prepared.args[38])
|
||||
require.Equal(t, sql.NullString{String: inputSize, Valid: true}, prepared.args[39])
|
||||
require.Equal(t, sql.NullString{String: outputSize, Valid: true}, prepared.args[40])
|
||||
require.Equal(t, sql.NullString{String: source, Valid: true}, prepared.args[41])
|
||||
breakdownJSON, ok := prepared.args[42].(string)
|
||||
require.True(t, ok)
|
||||
require.JSONEq(t, `{"1K":1,"4K":1}`, breakdownJSON)
|
||||
}
|
||||
@@ -713,10 +717,10 @@ func TestUsageLogRepositoryGetUserSpendingRanking(t *testing.T) {
|
||||
start := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
end := start.Add(24 * time.Hour)
|
||||
|
||||
rows := sqlmock.NewRows([]string{"user_id", "email", "actual_cost", "requests", "tokens", "total_actual_cost", "total_requests", "total_tokens"}).
|
||||
AddRow(int64(2), "beta@example.com", 12.5, int64(9), int64(900), 40.0, int64(30), int64(2600)).
|
||||
AddRow(int64(1), "alpha@example.com", 12.5, int64(8), int64(800), 40.0, int64(30), int64(2600)).
|
||||
AddRow(int64(3), "gamma@example.com", 4.25, int64(5), int64(300), 40.0, int64(30), int64(2600))
|
||||
rows := sqlmock.NewRows([]string{"user_id", "email", "username", "actual_cost", "requests", "tokens", "total_actual_cost", "total_requests", "total_tokens"}).
|
||||
AddRow(int64(2), "beta@example.com", "beta", 12.5, int64(9), int64(900), 40.0, int64(30), int64(2600)).
|
||||
AddRow(int64(1), "alpha@example.com", "alpha", 12.5, int64(8), int64(800), 40.0, int64(30), int64(2600)).
|
||||
AddRow(int64(3), "gamma@example.com", "", 4.25, int64(5), int64(300), 40.0, int64(30), int64(2600))
|
||||
|
||||
mock.ExpectQuery("WITH user_spend AS \\(").
|
||||
WithArgs(start, end, 12).
|
||||
@@ -726,8 +730,8 @@ func TestUsageLogRepositoryGetUserSpendingRanking(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, &usagestats.UserSpendingRankingResponse{
|
||||
Ranking: []usagestats.UserSpendingRankingItem{
|
||||
{UserID: 2, Email: "beta@example.com", ActualCost: 12.5, Requests: 9, Tokens: 900},
|
||||
{UserID: 1, Email: "alpha@example.com", ActualCost: 12.5, Requests: 8, Tokens: 800},
|
||||
{UserID: 2, Email: "beta@example.com", Username: "beta", ActualCost: 12.5, Requests: 9, Tokens: 900},
|
||||
{UserID: 1, Email: "alpha@example.com", Username: "alpha", ActualCost: 12.5, Requests: 8, Tokens: 800},
|
||||
{UserID: 3, Email: "gamma@example.com", ActualCost: 4.25, Requests: 5, Tokens: 300},
|
||||
},
|
||||
TotalActualCost: 40.0,
|
||||
@@ -809,6 +813,8 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) {
|
||||
"gpt-image-2",
|
||||
sql.NullString{Valid: true, String: "gpt-image-2"},
|
||||
sql.NullString{},
|
||||
sql.NullString{},
|
||||
sql.NullBool{},
|
||||
sql.NullInt64{},
|
||||
sql.NullInt64{},
|
||||
0, 0, 0, 0, 0, 0,
|
||||
@@ -872,6 +878,8 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) {
|
||||
"gpt-5", // model
|
||||
sql.NullString{Valid: true, String: "gpt-5"}, // requested_model
|
||||
sql.NullString{}, // upstream_model
|
||||
sql.NullString{}, // upstream_response_model
|
||||
sql.NullBool{}, // upstream_model_mismatch
|
||||
sql.NullInt64{}, // group_id
|
||||
sql.NullInt64{}, // subscription_id
|
||||
1, // input_tokens
|
||||
@@ -942,6 +950,8 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) {
|
||||
"gpt-5",
|
||||
sql.NullString{Valid: true, String: "gpt-5"},
|
||||
sql.NullString{},
|
||||
sql.NullString{},
|
||||
sql.NullBool{},
|
||||
sql.NullInt64{},
|
||||
sql.NullInt64{},
|
||||
1, 2, 3, 4, 5, 6,
|
||||
@@ -1000,6 +1010,8 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) {
|
||||
"gpt-5.4",
|
||||
sql.NullString{Valid: true, String: "gpt-5.4"},
|
||||
sql.NullString{},
|
||||
sql.NullString{},
|
||||
sql.NullBool{},
|
||||
sql.NullInt64{},
|
||||
sql.NullInt64{},
|
||||
1, 2, 3, 4, 5, 6,
|
||||
|
||||
@@ -683,6 +683,9 @@ func (r *usageLogRepository) GetStatsWithFilters(ctx context.Context, filters Us
|
||||
args = append(args, int16(*filters.BillingType))
|
||||
}
|
||||
conditions, args = appendUsageLogBillingModeWhereCondition(conditions, args, filters.BillingMode)
|
||||
if filters.UpstreamModelMismatch != nil {
|
||||
conditions = append(conditions, upstreamModelMismatchCondition("upstream_model_mismatch", *filters.UpstreamModelMismatch))
|
||||
}
|
||||
if filters.StartTime != nil {
|
||||
conditions = append(conditions, fmt.Sprintf("created_at >= $%d", len(args)+1))
|
||||
args = append(args, *filters.StartTime)
|
||||
|
||||
@@ -4,6 +4,7 @@ package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -12,6 +13,66 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestUsageLog_UpstreamModelMismatchFilterAndPartialIndex(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tx := testEntTx(t)
|
||||
client := tx.Client()
|
||||
repo := newUsageLogRepositoryWithSQL(client, tx)
|
||||
|
||||
user := mustCreateUser(t, client, &service.User{Email: "model-audit@test.com"})
|
||||
apiKey := mustCreateApiKey(t, client, &service.APIKey{UserID: user.ID, Key: "sk-model-audit", Name: "model-audit"})
|
||||
account := mustCreateAccount(t, client, &service.Account{Name: "model-audit-account"})
|
||||
now := time.Now().UTC()
|
||||
responseModel := "gpt-5.4"
|
||||
for _, mismatch := range []bool{true, false} {
|
||||
mismatchValue := mismatch
|
||||
_, err := repo.Create(ctx, &service.UsageLog{
|
||||
UserID: user.ID, APIKeyID: apiKey.ID, AccountID: account.ID,
|
||||
Model: "gpt-5.5", InputTokens: 1, OutputTokens: 1,
|
||||
UpstreamResponseModel: &responseModel, UpstreamModelMismatch: &mismatchValue,
|
||||
CreatedAt: now,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
start := now.Add(-time.Hour)
|
||||
end := now.Add(time.Hour)
|
||||
trueValue := true
|
||||
stats, err := repo.GetStatsWithFilters(ctx, usagestats.UsageLogFilters{
|
||||
UserID: user.ID, StartTime: &start, EndTime: &end, UpstreamModelMismatch: &trueValue,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1), stats.TotalRequests)
|
||||
|
||||
trend, err := repo.GetUsageTrendWithUsageFilters(ctx, start, end, "hour", usagestats.UsageLogFilters{
|
||||
UserID: user.ID, UpstreamModelMismatch: &trueValue,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, trend, 1)
|
||||
require.Equal(t, int64(1), trend[0].Requests)
|
||||
|
||||
_, err = tx.ExecContext(ctx, "SET LOCAL enable_seqscan = off")
|
||||
require.NoError(t, err)
|
||||
rows, err := tx.QueryContext(ctx, `
|
||||
EXPLAIN (COSTS OFF)
|
||||
SELECT id
|
||||
FROM usage_logs
|
||||
WHERE upstream_model_mismatch IS TRUE
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT 100
|
||||
`)
|
||||
require.NoError(t, err)
|
||||
defer func() { require.NoError(t, rows.Close()) }()
|
||||
var planLines []string
|
||||
for rows.Next() {
|
||||
var line string
|
||||
require.NoError(t, rows.Scan(&line))
|
||||
planLines = append(planLines, line)
|
||||
}
|
||||
require.NoError(t, rows.Err())
|
||||
require.Contains(t, strings.Join(planLines, "\n"), usageLogsUpstreamModelMismatchIndex)
|
||||
}
|
||||
|
||||
func TestUsageLog_GetStatsWithFilters_AggregatesAndEndpoints(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tx := testEntTx(t)
|
||||
|
||||
@@ -150,18 +150,20 @@ func (r *usageLogRepository) GetUserSpendingRanking(ctx context.Context, startTi
|
||||
SELECT
|
||||
u.user_id,
|
||||
COALESCE(us.email, '') as email,
|
||||
COALESCE(us.username, '') as username,
|
||||
COALESCE(SUM(u.actual_cost), 0) as actual_cost,
|
||||
COUNT(*) as requests,
|
||||
COALESCE(SUM(u.input_tokens + u.output_tokens + u.cache_creation_tokens + u.cache_read_tokens), 0) as tokens
|
||||
FROM usage_logs u
|
||||
LEFT JOIN users us ON u.user_id = us.id
|
||||
WHERE u.created_at >= $1 AND u.created_at < $2
|
||||
GROUP BY u.user_id, us.email
|
||||
GROUP BY u.user_id, us.email, us.username
|
||||
),
|
||||
ranked AS (
|
||||
SELECT
|
||||
user_id,
|
||||
email,
|
||||
username,
|
||||
actual_cost,
|
||||
requests,
|
||||
tokens,
|
||||
@@ -175,6 +177,7 @@ func (r *usageLogRepository) GetUserSpendingRanking(ctx context.Context, startTi
|
||||
SELECT
|
||||
user_id,
|
||||
email,
|
||||
username,
|
||||
actual_cost,
|
||||
requests,
|
||||
tokens,
|
||||
@@ -202,7 +205,7 @@ func (r *usageLogRepository) GetUserSpendingRanking(ctx context.Context, startTi
|
||||
totalTokens := int64(0)
|
||||
for rows.Next() {
|
||||
var row UserSpendingRankingItem
|
||||
if err = rows.Scan(&row.UserID, &row.Email, &row.ActualCost, &row.Requests, &row.Tokens, &totalActualCost, &totalRequests, &totalTokens); err != nil {
|
||||
if err = rows.Scan(&row.UserID, &row.Email, &row.Username, &row.ActualCost, &row.Requests, &row.Tokens, &totalActualCost, &totalRequests, &totalTokens); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ranking = append(ranking, row)
|
||||
@@ -262,20 +265,20 @@ func (r *usageLogRepository) GetUserUsageTrendByUserID(ctx context.Context, user
|
||||
|
||||
// GetUserModelStats 获取指定用户的模型统计
|
||||
func (r *usageLogRepository) GetUserModelStats(ctx context.Context, userID int64, startTime, endTime time.Time) (results []ModelStat, err error) {
|
||||
return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, 0, 0, 0, "", nil, nil, nil, usagestats.ModelSourceRequested, "")
|
||||
return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, 0, 0, 0, "", nil, nil, nil, usagestats.ModelSourceRequested, "", nil)
|
||||
}
|
||||
|
||||
// GetUsageTrendWithFilters returns usage trend data with optional filters
|
||||
func (r *usageLogRepository) GetUsageTrendWithFilters(ctx context.Context, startTime, endTime time.Time, granularity string, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8) (results []TrendDataPoint, err error) {
|
||||
return r.getUsageTrendWithFilters(ctx, startTime, endTime, granularity, userID, apiKeyID, accountID, groupID, model, "", requestType, stream, billingType, "")
|
||||
return r.getUsageTrendWithFilters(ctx, startTime, endTime, granularity, userID, apiKeyID, accountID, groupID, model, "", requestType, stream, billingType, "", nil)
|
||||
}
|
||||
|
||||
func (r *usageLogRepository) GetUsageTrendWithUsageFilters(ctx context.Context, startTime, endTime time.Time, granularity string, filters UsageLogFilters) (results []TrendDataPoint, err error) {
|
||||
return r.getUsageTrendWithFilters(ctx, startTime, endTime, granularity, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.ModelFilterSource, filters.RequestType, filters.Stream, filters.BillingType, filters.BillingMode)
|
||||
return r.getUsageTrendWithFilters(ctx, startTime, endTime, granularity, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.ModelFilterSource, filters.RequestType, filters.Stream, filters.BillingType, filters.BillingMode, filters.UpstreamModelMismatch)
|
||||
}
|
||||
|
||||
func (r *usageLogRepository) getUsageTrendWithFilters(ctx context.Context, startTime, endTime time.Time, granularity string, userID, apiKeyID, accountID, groupID int64, model string, modelSource string, requestType *int16, stream *bool, billingType *int8, billingMode string) (results []TrendDataPoint, err error) {
|
||||
if shouldUsePreaggregatedTrend(granularity, userID, apiKeyID, accountID, groupID, model, requestType, stream, billingType, billingMode) {
|
||||
func (r *usageLogRepository) getUsageTrendWithFilters(ctx context.Context, startTime, endTime time.Time, granularity string, userID, apiKeyID, accountID, groupID int64, model string, modelSource string, requestType *int16, stream *bool, billingType *int8, billingMode string, upstreamModelMismatch *bool) (results []TrendDataPoint, err error) {
|
||||
if shouldUsePreaggregatedTrend(granularity, userID, apiKeyID, accountID, groupID, model, requestType, stream, billingType, billingMode, upstreamModelMismatch) {
|
||||
aggregated, aggregatedErr := r.getUsageTrendFromAggregates(ctx, startTime, endTime, granularity)
|
||||
if aggregatedErr == nil && len(aggregated) > 0 {
|
||||
return aggregated, nil
|
||||
@@ -323,6 +326,9 @@ func (r *usageLogRepository) getUsageTrendWithFilters(ctx context.Context, start
|
||||
args = append(args, int16(*billingType))
|
||||
}
|
||||
query, args = appendUsageLogBillingModeQueryFilter(query, args, billingMode, "")
|
||||
if upstreamModelMismatch != nil {
|
||||
query += " AND " + upstreamModelMismatchCondition("upstream_model_mismatch", *upstreamModelMismatch)
|
||||
}
|
||||
query += " GROUP BY date ORDER BY date ASC"
|
||||
|
||||
rows, err := r.sql.QueryContext(ctx, query, args...)
|
||||
@@ -345,7 +351,7 @@ func (r *usageLogRepository) getUsageTrendWithFilters(ctx context.Context, start
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func shouldUsePreaggregatedTrend(granularity string, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8, billingMode string) bool {
|
||||
func shouldUsePreaggregatedTrend(granularity string, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8, billingMode string, upstreamModelMismatch *bool) bool {
|
||||
if granularity != "day" && granularity != "hour" {
|
||||
return false
|
||||
}
|
||||
@@ -357,7 +363,8 @@ func shouldUsePreaggregatedTrend(granularity string, userID, apiKeyID, accountID
|
||||
requestType == nil &&
|
||||
stream == nil &&
|
||||
billingType == nil &&
|
||||
billingMode == ""
|
||||
billingMode == "" &&
|
||||
upstreamModelMismatch == nil
|
||||
}
|
||||
|
||||
func (r *usageLogRepository) getUsageTrendFromAggregates(ctx context.Context, startTime, endTime time.Time, granularity string) (results []TrendDataPoint, err error) {
|
||||
@@ -422,20 +429,20 @@ func (r *usageLogRepository) getUsageTrendFromAggregates(ctx context.Context, st
|
||||
|
||||
// GetModelStatsWithFilters returns model statistics with optional filters
|
||||
func (r *usageLogRepository) GetModelStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8) (results []ModelStat, err error) {
|
||||
return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, "", requestType, stream, billingType, usagestats.ModelSourceRequested, "")
|
||||
return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, "", requestType, stream, billingType, usagestats.ModelSourceRequested, "", nil)
|
||||
}
|
||||
|
||||
// GetModelStatsWithFiltersBySource returns model statistics with optional filters and model source dimension.
|
||||
// source: requested | upstream | mapping.
|
||||
func (r *usageLogRepository) GetModelStatsWithFiltersBySource(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8, source string) (results []ModelStat, err error) {
|
||||
return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, "", requestType, stream, billingType, source, "")
|
||||
return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, "", requestType, stream, billingType, source, "", nil)
|
||||
}
|
||||
|
||||
func (r *usageLogRepository) GetModelStatsWithUsageFiltersBySource(ctx context.Context, startTime, endTime time.Time, filters UsageLogFilters, source string) (results []ModelStat, err error) {
|
||||
return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.RequestType, filters.Stream, filters.BillingType, source, filters.BillingMode)
|
||||
return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.RequestType, filters.Stream, filters.BillingType, source, filters.BillingMode, filters.UpstreamModelMismatch)
|
||||
}
|
||||
|
||||
func (r *usageLogRepository) getModelStatsWithFiltersBySource(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8, source string, billingMode string) (results []ModelStat, err error) {
|
||||
func (r *usageLogRepository) getModelStatsWithFiltersBySource(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8, source string, billingMode string, upstreamModelMismatch *bool) (results []ModelStat, err error) {
|
||||
actualCostExpr := "COALESCE(SUM(actual_cost), 0) as actual_cost"
|
||||
// 当仅按 account_id 聚合时,实际费用使用账号倍率(total_cost * account_rate_multiplier)。
|
||||
if accountID > 0 && userID == 0 && apiKeyID == 0 {
|
||||
@@ -487,6 +494,9 @@ func (r *usageLogRepository) getModelStatsWithFiltersBySource(ctx context.Contex
|
||||
args = append(args, int16(*billingType))
|
||||
}
|
||||
query, args = appendUsageLogBillingModeQueryFilter(query, args, billingMode, "")
|
||||
if upstreamModelMismatch != nil {
|
||||
query += " AND " + upstreamModelMismatchCondition("upstream_model_mismatch", *upstreamModelMismatch)
|
||||
}
|
||||
query += fmt.Sprintf(" GROUP BY %s ORDER BY total_tokens DESC", modelExpr)
|
||||
|
||||
rows, err := r.sql.QueryContext(ctx, query, args...)
|
||||
@@ -511,14 +521,14 @@ func (r *usageLogRepository) getModelStatsWithFiltersBySource(ctx context.Contex
|
||||
|
||||
// GetGroupStatsWithFilters returns group usage statistics with optional filters
|
||||
func (r *usageLogRepository) GetGroupStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8) (results []usagestats.GroupStat, err error) {
|
||||
return r.getGroupStatsWithFilters(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, "", requestType, stream, billingType, "")
|
||||
return r.getGroupStatsWithFilters(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, "", requestType, stream, billingType, "", nil)
|
||||
}
|
||||
|
||||
func (r *usageLogRepository) GetGroupStatsWithUsageFilters(ctx context.Context, startTime, endTime time.Time, filters UsageLogFilters) (results []usagestats.GroupStat, err error) {
|
||||
return r.getGroupStatsWithFilters(ctx, startTime, endTime, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.RequestType, filters.Stream, filters.BillingType, filters.BillingMode)
|
||||
return r.getGroupStatsWithFilters(ctx, startTime, endTime, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.RequestType, filters.Stream, filters.BillingType, filters.BillingMode, filters.UpstreamModelMismatch)
|
||||
}
|
||||
|
||||
func (r *usageLogRepository) getGroupStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8, billingMode string) (results []usagestats.GroupStat, err error) {
|
||||
func (r *usageLogRepository) getGroupStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8, billingMode string, upstreamModelMismatch *bool) (results []usagestats.GroupStat, err error) {
|
||||
query := `
|
||||
SELECT
|
||||
COALESCE(ul.group_id, 0) as group_id,
|
||||
@@ -561,6 +571,9 @@ func (r *usageLogRepository) getGroupStatsWithFilters(ctx context.Context, start
|
||||
args = append(args, int16(*billingType))
|
||||
}
|
||||
query, args = appendUsageLogBillingModeQueryFilter(query, args, billingMode, "ul")
|
||||
if upstreamModelMismatch != nil {
|
||||
query += " AND " + upstreamModelMismatchCondition("ul.upstream_model_mismatch", *upstreamModelMismatch)
|
||||
}
|
||||
query += " GROUP BY ul.group_id, g.name ORDER BY total_tokens DESC"
|
||||
|
||||
rows, err := r.sql.QueryContext(ctx, query, args...)
|
||||
|
||||
@@ -32,7 +32,7 @@ func newSessionIDUsageLog(sessionID *string) *service.UsageLog {
|
||||
// arg slice / arg-type table so the five INSERT column lists stay in sync. session_id
|
||||
// is the penultimate arg (created_at is always last).
|
||||
func TestPrepareUsageLogInsert_SessionIDArgWiring(t *testing.T) {
|
||||
require.Len(t, usageLogInsertArgTypes, 57, "arg-type table must include session_id")
|
||||
require.Len(t, usageLogInsertArgTypes, 59, "arg-type table must include session_id")
|
||||
|
||||
sessionID := "sess-persisted-123"
|
||||
prepared := prepareUsageLogInsert(newSessionIDUsageLog(&sessionID))
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||||
_ "github.com/Wei-Shaw/sub2api/ent/runtime"
|
||||
"github.com/Wei-Shaw/sub2api/ent/usersubscription"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"entgo.io/ent/dialect"
|
||||
entsql "entgo.io/ent/dialect/sql"
|
||||
)
|
||||
|
||||
func TestUserSubscriptionGetByIDForUpdateLocksRow(t *testing.T) {
|
||||
var capturedSQL string
|
||||
db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(captureEntQueryMatcher{actual: &capturedSQL}))
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
driver := entsql.OpenDB(dialect.Postgres, db)
|
||||
client := dbent.NewClient(dbent.Driver(driver))
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
repo := NewUserSubscriptionRepository(client)
|
||||
now := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
mock.ExpectQuery("locked subscription").WillReturnRows(
|
||||
sqlmock.NewRows(usersubscription.Columns).AddRow(
|
||||
int64(7), now, now, nil, int64(11), int64(13), now, now.AddDate(0, 0, 30), "active",
|
||||
nil, nil, nil, 0.0, 0.0, 0.0, nil, now, "renewal",
|
||||
),
|
||||
)
|
||||
|
||||
sub, err := repo.GetByIDForUpdate(context.Background(), 7)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(7), sub.ID)
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
require.Contains(t, strings.ToUpper(normalizeSQLWhitespace(capturedSQL)), "FOR UPDATE")
|
||||
}
|
||||
@@ -75,6 +75,18 @@ func (r *userSubscriptionRepository) GetByID(ctx context.Context, id int64) (*se
|
||||
return userSubscriptionEntityToService(m), nil
|
||||
}
|
||||
|
||||
func (r *userSubscriptionRepository) GetByIDForUpdate(ctx context.Context, id int64) (*service.UserSubscription, error) {
|
||||
client := clientFromContext(ctx, r.client)
|
||||
m, err := client.UserSubscription.Query().
|
||||
Where(usersubscription.IDEQ(id)).
|
||||
ForUpdate().
|
||||
Only(ctx)
|
||||
if err != nil {
|
||||
return nil, translatePersistenceError(err, service.ErrSubscriptionNotFound, nil)
|
||||
}
|
||||
return userSubscriptionEntityToService(m), nil
|
||||
}
|
||||
|
||||
func (r *userSubscriptionRepository) GetByIDIncludeDeleted(ctx context.Context, id int64) (*service.UserSubscription, error) {
|
||||
client := clientFromContext(ctx, r.client)
|
||||
queryCtx := mixins.SkipSoftDelete(ctx)
|
||||
@@ -356,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(
|
||||
@@ -365,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)
|
||||
|
||||
@@ -149,6 +149,8 @@ var ProviderSet = wire.NewSet(
|
||||
|
||||
// HTTP service ports (DI Strategy A: return interface directly)
|
||||
NewTurnstileVerifier,
|
||||
NewTencentCaptchaVerifier,
|
||||
NewAliyunCaptchaVerifier,
|
||||
ProvidePricingRemoteClient,
|
||||
ProvideGitHubReleaseClient,
|
||||
NewProxyExitInfoProber,
|
||||
|
||||