diff --git a/README.en.md b/README.en.md index 2349104aa..60d4f6a02 100644 --- a/README.en.md +++ b/README.en.md @@ -1,6 +1,10 @@

- 中文 | English | Français + 中文 | English | Français | 日本語

+ +> [!NOTE] +> **MT (Machine Translation)**: This document is machine translated. For the most accurate information, please refer to the [Chinese version](./README.md). +
![new-api](/web/public/logo.png) @@ -75,7 +79,7 @@ New API offers a wide range of features, please refer to [Features Introduction] 1. 🎨 Brand new UI interface 2. 🌍 Multi-language support -3. 💰 Online recharge functionality (YiPay) +3. 💰 Online recharge functionality, currently supports EPay and Stripe 4. 🔍 Support for querying usage quotas with keys (works with [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool)) 5. 🔄 Compatible with the original One API database 6. 💵 Support for pay-per-use model pricing @@ -96,7 +100,11 @@ New API offers a wide range of features, please refer to [Features Introduction] - Add `-thinking` suffix to enable thinking mode (e.g.: `claude-3-7-sonnet-20250219-thinking`) 16. 🔄 Thinking-to-content functionality 17. 🔄 Model rate limiting for users -18. 💰 Cache billing support, which allows billing at a set ratio when cache is hit: +18. 🔄 Request format conversion functionality, supporting the following three format conversions: + 1. OpenAI Chat Completions => Claude Messages + 2. Claude Messages => OpenAI Chat Completions (can be used for Claude Code to call third-party models) + 3. OpenAI Chat Completions => Gemini Chat +19. 💰 Cache billing support, which allows billing at a set ratio when cache is hit: 1. Set the `Prompt Cache Ratio` option in `System Settings-Operation Settings` 2. Set `Prompt Cache Ratio` in the channel, range 0-1, e.g., setting to 0.5 means billing at 50% when cache is hit 3. Supported channels: @@ -115,7 +123,9 @@ This version supports multiple models, please refer to [API Documentation-Relay 4. Custom channels, supporting full call address input 5. Rerank models ([Cohere](https://cohere.ai/) and [Jina](https://jina.ai/)), [API Documentation](https://docs.newapi.pro/api/jinaai-rerank) 6. Claude Messages format, [API Documentation](https://docs.newapi.pro/api/anthropic-chat) -7. Dify, currently only supports chatflow +7. Google Gemini format, [API Documentation](https://docs.newapi.pro/api/google-gemini-chat/) +8. Dify, currently only supports chatflow +9. For more interfaces, please refer to [API Documentation](https://docs.newapi.pro/api) ## Environment Variable Configuration @@ -192,7 +202,8 @@ For detailed API documentation, please refer to [API Documentation](https://docs - [Image API](https://docs.newapi.pro/api/openai-image) - [Rerank API](https://docs.newapi.pro/api/jinaai-rerank) - [Realtime API](https://docs.newapi.pro/api/openai-realtime) -- [Claude Chat API (messages)](https://docs.newapi.pro/api/anthropic-chat) +- [Claude Chat API](https://docs.newapi.pro/api/anthropic-chat) +- [Google Gemini Chat API](https://docs.newapi.pro/api/google-gemini-chat) ## Related Projects - [One API](https://github.com/songquanpeng/one-api): Original project diff --git a/README.fr.md b/README.fr.md index de788ede4..d06980053 100644 --- a/README.fr.md +++ b/README.fr.md @@ -1,6 +1,10 @@

- 中文 | English | Français + 中文 | English | Français | 日本語

+ +> [!NOTE] +> **MT (Traduction Automatique)**: Ce document est traduit automatiquement. Pour les informations les plus précises, veuillez vous référer à la [version chinoise](./README.md). +
![new-api](/web/public/logo.png) @@ -75,7 +79,7 @@ New API offre un large éventail de fonctionnalités, veuillez vous référer à 1. 🎨 Nouvelle interface utilisateur 2. 🌍 Prise en charge multilingue -3. 💰 Fonctionnalité de recharge en ligne (YiPay) +3. 💰 Fonctionnalité de recharge en ligne, prend actuellement en charge EPay et Stripe 4. 🔍 Prise en charge de la recherche de quotas d'utilisation avec des clés (fonctionne avec [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool)) 5. 🔄 Compatible avec la base de données originale de One API 6. 💵 Prise en charge de la tarification des modèles de paiement à l'utilisation @@ -96,7 +100,11 @@ New API offre un large éventail de fonctionnalités, veuillez vous référer à - Ajouter le suffixe `-thinking` pour activer le mode de pensée (par exemple : `claude-3-7-sonnet-20250219-thinking`) 16. 🔄 Fonctionnalité de la pensée au contenu 17. 🔄 Limitation du débit du modèle pour les utilisateurs -18. 💰 Prise en charge de la facturation du cache, qui permet de facturer à un ratio défini lorsque le cache est atteint : +18. 🔄 Fonctionnalité de conversion de format de requête, prenant en charge les trois conversions de format suivantes : + 1. OpenAI Chat Completions => Claude Messages + 2. Claude Messages => OpenAI Chat Completions (peut être utilisé pour Claude Code pour appeler des modèles tiers) + 3. OpenAI Chat Completions => Gemini Chat +19. 💰 Prise en charge de la facturation du cache, qui permet de facturer à un ratio défini lorsque le cache est atteint : 1. Définir l'option `Ratio de cache d'invite` dans `Paramètres système->Paramètres de fonctionnement` 2. Définir le `Ratio de cache d'invite` dans le canal, plage de 0 à 1, par exemple, le définir sur 0,5 signifie facturer à 50 % lorsque le cache est atteint 3. Canaux pris en charge : @@ -115,7 +123,9 @@ Cette version prend en charge plusieurs modèles, veuillez vous référer à [Do 4. Canaux personnalisés, prenant en charge la saisie complète de l'adresse d'appel 5. Modèles Rerank ([Cohere](https://cohere.ai/) et [Jina](https://jina.ai/)), [Documentation de l'API](https://docs.newapi.pro/api/jinaai-rerank) 6. Format de messages Claude, [Documentation de l'API](https://docs.newapi.pro/api/anthropic-chat) -7. Dify, ne prend actuellement en charge que chatflow +7. Format Google Gemini, [Documentation de l'API](https://docs.newapi.pro/api/google-gemini-chat/) +8. Dify, ne prend actuellement en charge que chatflow +9. Pour plus d'interfaces, veuillez vous référer à la [Documentation de l'API](https://docs.newapi.pro/api) ## Configuration des variables d'environnement @@ -192,7 +202,8 @@ Pour une documentation détaillée de l'API, veuillez vous référer à [Documen - [API d'image](https://docs.newapi.pro/api/openai-image) - [API de rerank](https://docs.newapi.pro/api/jinaai-rerank) - [API en temps réel](https://docs.newapi.pro/api/openai-realtime) -- [API de discussion Claude (messages)](https://docs.newapi.pro/api/anthropic-chat) +- [API de discussion Claude](https://docs.newapi.pro/api/anthropic-chat) +- [API de discussion Google Gemini](https://docs.newapi.pro/api/google-gemini-chat) ## Projets connexes - [One API](https://github.com/songquanpeng/one-api) : Projet original diff --git a/README.ja.md b/README.ja.md new file mode 100644 index 000000000..13049e86d --- /dev/null +++ b/README.ja.md @@ -0,0 +1,224 @@ +

+ 中文 | English | Français | 日本語 +

+ +> [!NOTE] +> **MT(機械翻訳)**: この文書は機械翻訳されています。最も正確な情報については、[中国語版](./README.md)を参照してください。 + +
+ +![new-api](/web/public/logo.png) + +# New API + +🍥次世代大規模モデルゲートウェイとAI資産管理システム + +Calcium-Ion%2Fnew-api | Trendshift + +

+ + license + + + release + + + docker + + + docker + + + GoReportCard + +

+
+ +## 📝 プロジェクト説明 + +> [!NOTE] +> 本プロジェクトは、[One API](https://github.com/songquanpeng/one-api)をベースに二次開発されたオープンソースプロジェクトです + +> [!IMPORTANT] +> - 本プロジェクトは個人学習用のみであり、安定性の保証や技術サポートは提供しません。 +> - ユーザーは、OpenAIの[利用規約](https://openai.com/policies/terms-of-use)および**法律法規**を遵守する必要があり、違法な目的で使用してはいけません。 +> - [《生成式人工智能服务管理暂行办法》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm)の要求に従い、中国地域の公衆に未登録の生成式AI サービスを提供しないでください。 + +

🤝 信頼できるパートナー

+

 

+

順不同

+

+ Cherry Studio + 北京大学 + UCloud 優刻得 + Alibaba Cloud + IO.NET +

+

 

+ +## 📚 ドキュメント + +詳細なドキュメントは公式Wikiをご覧ください:[https://docs.newapi.pro/](https://docs.newapi.pro/) + +AIが生成したDeepWikiにもアクセスできます: +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/QuantumNous/new-api) + +## ✨ 主な機能 + +New APIは豊富な機能を提供しています。詳細な機能については[機能説明](https://docs.newapi.pro/wiki/features-introduction)を参照してください: + +1. 🎨 全く新しいUIインターフェース +2. 🌍 多言語サポート +3. 💰 オンラインチャージ機能をサポート、現在EPayとStripeをサポート +4. 🔍 キーによる使用量クォータの照会をサポート([neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool)と連携) +5. 🔄 オリジナルのOne APIデータベースと互換性あり +6. 💵 モデルの従量課金をサポート +7. ⚖️ チャネルの重み付けランダムをサポート +8. 📈 データダッシュボード(コンソール) +9. 🔒 トークングループ化、モデル制限 +10. 🤖 より多くの認証ログイン方法をサポート(LinuxDO、Telegram、OIDC) +11. 🔄 Rerankモデルをサポート(CohereとJina)、[API ドキュメント](https://docs.newapi.pro/api/jinaai-rerank) +12. ⚡ OpenAI Realtime APIをサポート(Azureチャネルを含む)、[APIドキュメント](https://docs.newapi.pro/api/openai-realtime) +13. ⚡ Claude Messages形式をサポート、[APIドキュメント](https://docs.newapi.pro/api/anthropic-chat) +14. /chat2linkルートを使用してチャット画面に入ることをサポート +15. 🧠 モデル名のサフィックスを通じてreasoning effortを設定することをサポート: + 1. OpenAI oシリーズモデル + - `-high`サフィックスを追加してhigh reasoning effortに設定(例:`o3-mini-high`) + - `-medium`サフィックスを追加してmedium reasoning effortに設定(例:`o3-mini-medium`) + - `-low`サフィックスを追加してlow reasoning effortに設定(例:`o3-mini-low`) + 2. Claude思考モデル + - `-thinking`サフィックスを追加して思考モードを有効にする(例:`claude-3-7-sonnet-20250219-thinking`) +16. 🔄 思考からコンテンツへの機能 +17. 🔄 ユーザーに対するモデルレート制限機能 +18. 🔄 リクエストフォーマット変換機能、以下の3つのフォーマット変換をサポート: + 1. OpenAI Chat Completions => Claude Messages + 2. Claude Messages => OpenAI Chat Completions(Claude Codeがサードパーティモデルを呼び出す際に使用可能) + 3. OpenAI Chat Completions => Gemini Chat +19. 💰 キャッシュ課金サポート、有効にするとキャッシュがヒットした際に設定された比率で課金できます: + 1. `システム設定-運営設定`で`プロンプトキャッシュ倍率`オプションを設定 + 2. チャネルで`プロンプトキャッシュ倍率`を設定、範囲は0-1、例えば0.5に設定するとキャッシュがヒットした際に50%で課金 + 3. サポートされているチャネル: + - [x] OpenAI + - [x] Azure + - [x] DeepSeek + - [x] Claude + +## モデルサポート + +このバージョンは複数のモデルをサポートしています。詳細は[APIドキュメント-中継インターフェース](https://docs.newapi.pro/api)を参照してください: + +1. サードパーティモデル **gpts**(gpt-4-gizmo-*) +2. サードパーティチャネル[Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy)インターフェース、[APIドキュメント](https://docs.newapi.pro/api/midjourney-proxy-image) +3. サードパーティチャネル[Suno API](https://github.com/Suno-API/Suno-API)インターフェース、[APIドキュメント](https://docs.newapi.pro/api/suno-music) +4. カスタムチャネル、完全な呼び出しアドレスの入力をサポート +5. Rerankモデル([Cohere](https://cohere.ai/)と[Jina](https://jina.ai/))、[APIドキュメント](https://docs.newapi.pro/api/jinaai-rerank) +6. Claude Messages形式、[APIドキュメント](https://docs.newapi.pro/api/anthropic-chat) +7. Google Gemini形式、[APIドキュメント](https://docs.newapi.pro/api/google-gemini-chat/) +8. Dify、現在はchatflowのみをサポート +9. その他のインターフェースについては[APIドキュメント](https://docs.newapi.pro/api)を参照してください + +## 環境変数設定 + +詳細な設定説明については[インストールガイド-環境変数設定](https://docs.newapi.pro/installation/environment-variables)を参照してください: + +- `GENERATE_DEFAULT_TOKEN`:新規登録ユーザーに初期トークンを生成するかどうか、デフォルトは`false` +- `STREAMING_TIMEOUT`:ストリーミング応答のタイムアウト時間、デフォルトは300秒 +- `DIFY_DEBUG`:Difyチャネルがワークフローとノード情報を出力するかどうか、デフォルトは`true` +- `GET_MEDIA_TOKEN`:画像トークンを統計するかどうか、デフォルトは`true` +- `GET_MEDIA_TOKEN_NOT_STREAM`:非ストリーミングの場合に画像トークンを統計するかどうか、デフォルトは`true` +- `UPDATE_TASK`:非同期タスク(Midjourney、Suno)を更新するかどうか、デフォルトは`true` +- `GEMINI_VISION_MAX_IMAGE_NUM`:Geminiモデルの最大画像数、デフォルトは`16` +- `MAX_FILE_DOWNLOAD_MB`: 最大ファイルダウンロードサイズ、単位MB、デフォルトは`20` +- `CRYPTO_SECRET`:暗号化キー、Redisデータベースの内容を暗号化するために使用 +- `AZURE_DEFAULT_API_VERSION`:Azureチャネルのデフォルトのバージョン、デフォルトは`2025-04-01-preview` +- `NOTIFICATION_LIMIT_DURATION_MINUTE`:メールなどの通知制限の継続時間、デフォルトは`10`分 +- `NOTIFY_LIMIT_COUNT`:指定された継続時間内のユーザー通知の最大数、デフォルトは`2` +- `ERROR_LOG_ENABLED=true`: エラーログを記録して表示するかどうか、デフォルトは`false` + +## デプロイ + +詳細なデプロイガイドについては[インストールガイド-デプロイ方法](https://docs.newapi.pro/installation)を参照してください: + +> [!TIP] +> 最新のDockerイメージ:`calciumion/new-api:latest` + +### マルチマシンデプロイの注意事項 +- 環境変数`SESSION_SECRET`を設定する必要があります。そうしないとマルチマシンデプロイ時にログイン状態が不一致になります +- Redisを共有する場合、`CRYPTO_SECRET`を設定する必要があります。そうしないとマルチマシンデプロイ時にRedisの内容を取得できません + +### デプロイ要件 +- ローカルデータベース(デフォルト):SQLite(Dockerデプロイの場合は`/data`ディレクトリをマウントする必要があります) +- リモートデータベース:MySQLバージョン >= 5.7.8、PgSQLバージョン >= 9.6 + +### デプロイ方法 + +#### 宝塔パネルのDocker機能を使用してデプロイ +宝塔パネル(**9.2.0バージョン**以上)をインストールし、アプリケーションストアで**New-API**を見つけてインストールします。 +[画像付きチュートリアル](./docs/BT.md) + +#### Docker Composeを使用してデプロイ(推奨) +```shell +# プロジェクトをダウンロード +git clone https://github.com/Calcium-Ion/new-api.git +cd new-api +# 必要に応じてdocker-compose.ymlを編集 +# 起動 +docker-compose up -d +``` + +#### Dockerイメージを直接使用 +```shell +# SQLiteを使用 +docker run --name new-api -d --restart always -p 3000:3000 -e TZ=Asia/Shanghai -v /home/ubuntu/data/new-api:/data calciumion/new-api:latest + +# MySQLを使用 +docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" -e TZ=Asia/Shanghai -v /home/ubuntu/data/new-api:/data calciumion/new-api:latest +``` + +## チャネルリトライとキャッシュ +チャネルリトライ機能はすでに実装されており、`設定->運営設定->一般設定->失敗リトライ回数`でリトライ回数を設定できます。**キャッシュ機能を有効にすることを推奨します**。 + +### キャッシュ設定方法 +1. `REDIS_CONN_STRING`:Redisをキャッシュとして設定 +2. `MEMORY_CACHE_ENABLED`:メモリキャッシュを有効にする(Redisを設定した場合は手動設定不要) + +## APIドキュメント + +詳細なAPIドキュメントについては[APIドキュメント](https://docs.newapi.pro/api)を参照してください: + +- [チャットインターフェース(Chat)](https://docs.newapi.pro/api/openai-chat) +- [画像インターフェース(Image)](https://docs.newapi.pro/api/openai-image) +- [再ランク付けインターフェース(Rerank)](https://docs.newapi.pro/api/jinaai-rerank) +- [リアルタイム対話インターフェース(Realtime)](https://docs.newapi.pro/api/openai-realtime) +- [Claudeチャットインターフェース](https://docs.newapi.pro/api/anthropic-chat) +- [Google Geminiチャットインターフェース](https://docs.newapi.pro/api/google-gemini-chat) + +## 関連プロジェクト +- [One API](https://github.com/songquanpeng/one-api):オリジナルプロジェクト +- [Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy):Midjourneyインターフェースサポート +- [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool):キーを使用して使用量クォータを照会 + +New APIベースのその他のプロジェクト: +- [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon):New API高性能最適化版 + +## ヘルプサポート + +問題がある場合は、[ヘルプサポート](https://docs.newapi.pro/support)を参照してください: +- [コミュニティ交流](https://docs.newapi.pro/support/community-interaction) +- [問題のフィードバック](https://docs.newapi.pro/support/feedback-issues) +- [よくある質問](https://docs.newapi.pro/support/faq) + +## 🌟 Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=Calcium-Ion/new-api&type=Date)](https://star-history.com/#Calcium-Ion/new-api&Date) + diff --git a/README.md b/README.md index 2103fe8fc..af2b64b44 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- 中文 | English | Français + 中文 | English | Français | 日本語

@@ -75,7 +75,7 @@ New API提供了丰富的功能,详细特性请参考[特性说明](https://do 1. 🎨 全新的UI界面 2. 🌍 多语言支持 -3. 💰 支持在线充值功能(易支付) +3. 💰 支持在线充值功能,当前支持易支付和Stripe 4. 🔍 支持用key查询使用额度(配合[neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool)) 5. 🔄 兼容原版One API的数据库 6. 💵 支持模型按次数收费 @@ -119,7 +119,9 @@ New API提供了丰富的功能,详细特性请参考[特性说明](https://do 4. 自定义渠道,支持填入完整调用地址 5. Rerank模型([Cohere](https://cohere.ai/)和[Jina](https://jina.ai/)),[接口文档](https://docs.newapi.pro/api/jinaai-rerank) 6. Claude Messages 格式,[接口文档](https://docs.newapi.pro/api/anthropic-chat) -7. Dify,当前仅支持chatflow +7. Google Gemini格式,[接口文档](https://docs.newapi.pro/api/google-gemini-chat/) +8. Dify,当前仅支持chatflow +9. 更多接口请参考[接口文档](https://docs.newapi.pro/api) ## 环境变量配置 @@ -128,16 +130,14 @@ New API提供了丰富的功能,详细特性请参考[特性说明](https://do - `GENERATE_DEFAULT_TOKEN`:是否为新注册用户生成初始令牌,默认为 `false` - `STREAMING_TIMEOUT`:流式回复超时时间,默认300秒 - `DIFY_DEBUG`:Dify渠道是否输出工作流和节点信息,默认 `true` -- `FORCE_STREAM_OPTION`:是否覆盖客户端stream_options参数,默认 `true` - `GET_MEDIA_TOKEN`:是否统计图片token,默认 `true` - `GET_MEDIA_TOKEN_NOT_STREAM`:非流情况下是否统计图片token,默认 `true` - `UPDATE_TASK`:是否更新异步任务(Midjourney、Suno),默认 `true` -- `COHERE_SAFETY_SETTING`:Cohere模型安全设置,可选值为 `NONE`, `CONTEXTUAL`, `STRICT`,默认 `NONE` - `GEMINI_VISION_MAX_IMAGE_NUM`:Gemini模型最大图片数量,默认 `16` - `MAX_FILE_DOWNLOAD_MB`: 最大文件下载大小,单位MB,默认 `20` -- `CRYPTO_SECRET`:加密密钥,用于加密数据库内容 +- `CRYPTO_SECRET`:加密密钥,用于加密Redis数据库内容 - `AZURE_DEFAULT_API_VERSION`:Azure渠道默认API版本,默认 `2025-04-01-preview` -- `NOTIFICATION_LIMIT_DURATION_MINUTE`:通知限制持续时间,默认 `10`分钟 +- `NOTIFICATION_LIMIT_DURATION_MINUTE`:邮件等通知限制持续时间,默认 `10`分钟 - `NOTIFY_LIMIT_COUNT`:用户通知在指定持续时间内的最大数量,默认 `2` - `ERROR_LOG_ENABLED=true`: 是否记录并显示错误日志,默认`false` @@ -182,7 +182,7 @@ docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:1234 ``` ## 渠道重试与缓存 -渠道重试功能已经实现,可以在`设置->运营设置->通用设置`设置重试次数,**建议开启缓存**功能。 +渠道重试功能已经实现,可以在`设置->运营设置->通用设置->失败重试次数`设置重试次数,**建议开启缓存**功能。 ### 缓存设置方法 1. `REDIS_CONN_STRING`:设置Redis作为缓存 @@ -196,12 +196,12 @@ docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:1234 - [图像接口(Image)](https://docs.newapi.pro/api/openai-image) - [重排序接口(Rerank)](https://docs.newapi.pro/api/jinaai-rerank) - [实时对话接口(Realtime)](https://docs.newapi.pro/api/openai-realtime) -- [Claude聊天接口(messages)](https://docs.newapi.pro/api/anthropic-chat) +- [Claude聊天接口](https://docs.newapi.pro/api/anthropic-chat) +- [Google Gemini聊天接口](https://docs.newapi.pro/api/google-gemini-chat) ## 相关项目 - [One API](https://github.com/songquanpeng/one-api):原版项目 - [Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy):Midjourney接口支持 -- [chatnio](https://github.com/Deeptrain-Community/chatnio):下一代AI一站式B/C端解决方案 - [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool):用key查询使用额度 其他基于New API的项目: diff --git a/common/api_type.go b/common/api_type.go index 5ac46c863..855eef84f 100644 --- a/common/api_type.go +++ b/common/api_type.go @@ -67,6 +67,8 @@ func ChannelType2APIType(channelType int) (int, bool) { apiType = constant.APITypeJimeng case constant.ChannelTypeMoonshot: apiType = constant.APITypeMoonshot + case constant.ChannelTypeSubmodel: + apiType = constant.APITypeSubmodel } if apiType == -1 { return constant.APITypeOpenAI, false diff --git a/common/endpoint_defaults.go b/common/endpoint_defaults.go index ffc263507..25f9c68eb 100644 --- a/common/endpoint_defaults.go +++ b/common/endpoint_defaults.go @@ -23,6 +23,7 @@ var defaultEndpointInfoMap = map[constant.EndpointType]EndpointInfo{ constant.EndpointTypeGemini: {Path: "/v1beta/models/{model}:generateContent", Method: "POST"}, constant.EndpointTypeJinaRerank: {Path: "/rerank", Method: "POST"}, constant.EndpointTypeImageGeneration: {Path: "/v1/images/generations", Method: "POST"}, + constant.EndpointTypeEmbeddings: {Path: "/v1/embeddings", Method: "POST"}, } // GetDefaultEndpointInfo 返回指定端点类型的默认信息以及是否存在 diff --git a/constant/api_type.go b/constant/api_type.go index f62d91d53..130ae9455 100644 --- a/constant/api_type.go +++ b/constant/api_type.go @@ -31,6 +31,7 @@ const ( APITypeXai APITypeCoze APITypeJimeng - APITypeMoonshot // this one is only for count, do not add any channel after this - APITypeDummy // this one is only for count, do not add any channel after this + APITypeMoonshot + APITypeSubmodel + APITypeDummy // this one is only for count, do not add any channel after this ) diff --git a/constant/channel.go b/constant/channel.go index 2e1cc5b07..7d8893c1d 100644 --- a/constant/channel.go +++ b/constant/channel.go @@ -50,6 +50,8 @@ const ( ChannelTypeKling = 50 ChannelTypeJimeng = 51 ChannelTypeVidu = 52 + ChannelTypeSubmodel = 53 + ChannelTypeDoubaoVideo = 54 ChannelTypeDummy // this one is only for count, do not add any channel after this ) @@ -108,4 +110,6 @@ var ChannelBaseURLs = []string{ "https://api.klingai.com", //50 "https://visual.volcengineapi.com", //51 "https://api.vidu.cn", //52 + "https://llm.submodel.ai", //53 + "https://ark.cn-beijing.volces.com", //54 } diff --git a/constant/endpoint_type.go b/constant/endpoint_type.go index ef096b759..f799e5ba8 100644 --- a/constant/endpoint_type.go +++ b/constant/endpoint_type.go @@ -9,6 +9,7 @@ const ( EndpointTypeGemini EndpointType = "gemini" EndpointTypeJinaRerank EndpointType = "jina-rerank" EndpointTypeImageGeneration EndpointType = "image-generation" + EndpointTypeEmbeddings EndpointType = "embeddings" //EndpointTypeMidjourney EndpointType = "midjourney-proxy" //EndpointTypeSuno EndpointType = "suno-proxy" //EndpointTypeKling EndpointType = "kling" diff --git a/controller/channel-test.go b/controller/channel-test.go index 9ea6eed75..ff1e8cef4 100644 --- a/controller/channel-test.go +++ b/controller/channel-test.go @@ -38,7 +38,7 @@ type testResult struct { newAPIError *types.NewAPIError } -func testChannel(channel *model.Channel, testModel string) testResult { +func testChannel(channel *model.Channel, testModel string, endpointType string) testResult { tik := time.Now() if channel.Type == constant.ChannelTypeMidjourney { return testResult{ @@ -70,6 +70,12 @@ func testChannel(channel *model.Channel, testModel string) testResult { newAPIError: nil, } } + if channel.Type == constant.ChannelTypeDoubaoVideo { + return testResult{ + localErr: errors.New("doubao video channel test is not supported"), + newAPIError: nil, + } + } if channel.Type == constant.ChannelTypeVidu { return testResult{ localErr: errors.New("vidu channel test is not supported"), @@ -81,18 +87,26 @@ func testChannel(channel *model.Channel, testModel string) testResult { requestPath := "/v1/chat/completions" - // 先判断是否为 Embedding 模型 - if strings.Contains(strings.ToLower(testModel), "embedding") || - strings.HasPrefix(testModel, "m3e") || // m3e 系列模型 - strings.Contains(testModel, "bge-") || // bge 系列模型 - strings.Contains(testModel, "embed") || - channel.Type == constant.ChannelTypeMokaAI { // 其他 embedding 模型 - requestPath = "/v1/embeddings" // 修改请求路径 - } + // 如果指定了端点类型,使用指定的端点类型 + if endpointType != "" { + if endpointInfo, ok := common.GetDefaultEndpointInfo(constant.EndpointType(endpointType)); ok { + requestPath = endpointInfo.Path + } + } else { + // 如果没有指定端点类型,使用原有的自动检测逻辑 + // 先判断是否为 Embedding 模型 + if strings.Contains(strings.ToLower(testModel), "embedding") || + strings.HasPrefix(testModel, "m3e") || // m3e 系列模型 + strings.Contains(testModel, "bge-") || // bge 系列模型 + strings.Contains(testModel, "embed") || + channel.Type == constant.ChannelTypeMokaAI { // 其他 embedding 模型 + requestPath = "/v1/embeddings" // 修改请求路径 + } - // VolcEngine 图像生成模型 - if channel.Type == constant.ChannelTypeVolcEngine && strings.Contains(testModel, "seedream") { - requestPath = "/v1/images/generations" + // VolcEngine 图像生成模型 + if channel.Type == constant.ChannelTypeVolcEngine && strings.Contains(testModel, "seedream") { + requestPath = "/v1/images/generations" + } } c.Request = &http.Request{ @@ -114,21 +128,6 @@ func testChannel(channel *model.Channel, testModel string) testResult { } } - // 重新检查模型类型并更新请求路径 - if strings.Contains(strings.ToLower(testModel), "embedding") || - strings.HasPrefix(testModel, "m3e") || - strings.Contains(testModel, "bge-") || - strings.Contains(testModel, "embed") || - channel.Type == constant.ChannelTypeMokaAI { - requestPath = "/v1/embeddings" - c.Request.URL.Path = requestPath - } - - if channel.Type == constant.ChannelTypeVolcEngine && strings.Contains(testModel, "seedream") { - requestPath = "/v1/images/generations" - c.Request.URL.Path = requestPath - } - cache, err := model.GetUserCache(1) if err != nil { return testResult{ @@ -153,17 +152,54 @@ func testChannel(channel *model.Channel, testModel string) testResult { newAPIError: newAPIError, } } - request := buildTestRequest(testModel) - // Determine relay format based on request path - relayFormat := types.RelayFormatOpenAI - if c.Request.URL.Path == "/v1/embeddings" { - relayFormat = types.RelayFormatEmbedding - } - if c.Request.URL.Path == "/v1/images/generations" { - relayFormat = types.RelayFormatOpenAIImage + // Determine relay format based on endpoint type or request path + var relayFormat types.RelayFormat + if endpointType != "" { + // 根据指定的端点类型设置 relayFormat + switch constant.EndpointType(endpointType) { + case constant.EndpointTypeOpenAI: + relayFormat = types.RelayFormatOpenAI + case constant.EndpointTypeOpenAIResponse: + relayFormat = types.RelayFormatOpenAIResponses + case constant.EndpointTypeAnthropic: + relayFormat = types.RelayFormatClaude + case constant.EndpointTypeGemini: + relayFormat = types.RelayFormatGemini + case constant.EndpointTypeJinaRerank: + relayFormat = types.RelayFormatRerank + case constant.EndpointTypeImageGeneration: + relayFormat = types.RelayFormatOpenAIImage + case constant.EndpointTypeEmbeddings: + relayFormat = types.RelayFormatEmbedding + default: + relayFormat = types.RelayFormatOpenAI + } + } else { + // 根据请求路径自动检测 + relayFormat = types.RelayFormatOpenAI + if c.Request.URL.Path == "/v1/embeddings" { + relayFormat = types.RelayFormatEmbedding + } + if c.Request.URL.Path == "/v1/images/generations" { + relayFormat = types.RelayFormatOpenAIImage + } + if c.Request.URL.Path == "/v1/messages" { + relayFormat = types.RelayFormatClaude + } + if strings.Contains(c.Request.URL.Path, "/v1beta/models") { + relayFormat = types.RelayFormatGemini + } + if c.Request.URL.Path == "/v1/rerank" || c.Request.URL.Path == "/rerank" { + relayFormat = types.RelayFormatRerank + } + if c.Request.URL.Path == "/v1/responses" { + relayFormat = types.RelayFormatOpenAIResponses + } } + request := buildTestRequest(testModel, endpointType) + info, err := relaycommon.GenRelayInfo(c, relayFormat, request, nil) if err != nil { @@ -186,7 +222,8 @@ func testChannel(channel *model.Channel, testModel string) testResult { } testModel = info.UpstreamModelName - request.Model = testModel + // 更新请求中的模型名称 + request.SetModelName(testModel) apiType, _ := common.ChannelType2APIType(channel.Type) adaptor := relay.GetAdaptor(apiType) @@ -216,33 +253,62 @@ func testChannel(channel *model.Channel, testModel string) testResult { var convertedRequest any // 根据 RelayMode 选择正确的转换函数 - if info.RelayMode == relayconstant.RelayModeEmbeddings { - // 创建一个 EmbeddingRequest - embeddingRequest := dto.EmbeddingRequest{ - Input: request.Input, - Model: request.Model, - } - // 调用专门用于 Embedding 的转换函数 - convertedRequest, err = adaptor.ConvertEmbeddingRequest(c, info, embeddingRequest) - } else if info.RelayMode == relayconstant.RelayModeImagesGenerations { - // 创建一个 ImageRequest - prompt := "cat" - if request.Prompt != nil { - if promptStr, ok := request.Prompt.(string); ok && promptStr != "" { - prompt = promptStr + switch info.RelayMode { + case relayconstant.RelayModeEmbeddings: + // Embedding 请求 - request 已经是正确的类型 + if embeddingReq, ok := request.(*dto.EmbeddingRequest); ok { + convertedRequest, err = adaptor.ConvertEmbeddingRequest(c, info, *embeddingReq) + } else { + return testResult{ + context: c, + localErr: errors.New("invalid embedding request type"), + newAPIError: types.NewError(errors.New("invalid embedding request type"), types.ErrorCodeConvertRequestFailed), } } - imageRequest := dto.ImageRequest{ - Prompt: prompt, - Model: request.Model, - N: uint(request.N), - Size: request.Size, + case relayconstant.RelayModeImagesGenerations: + // 图像生成请求 - request 已经是正确的类型 + if imageReq, ok := request.(*dto.ImageRequest); ok { + convertedRequest, err = adaptor.ConvertImageRequest(c, info, *imageReq) + } else { + return testResult{ + context: c, + localErr: errors.New("invalid image request type"), + newAPIError: types.NewError(errors.New("invalid image request type"), types.ErrorCodeConvertRequestFailed), + } + } + case relayconstant.RelayModeRerank: + // Rerank 请求 - request 已经是正确的类型 + if rerankReq, ok := request.(*dto.RerankRequest); ok { + convertedRequest, err = adaptor.ConvertRerankRequest(c, info.RelayMode, *rerankReq) + } else { + return testResult{ + context: c, + localErr: errors.New("invalid rerank request type"), + newAPIError: types.NewError(errors.New("invalid rerank request type"), types.ErrorCodeConvertRequestFailed), + } + } + case relayconstant.RelayModeResponses: + // Response 请求 - request 已经是正确的类型 + if responseReq, ok := request.(*dto.OpenAIResponsesRequest); ok { + convertedRequest, err = adaptor.ConvertOpenAIResponsesRequest(c, info, *responseReq) + } else { + return testResult{ + context: c, + localErr: errors.New("invalid response request type"), + newAPIError: types.NewError(errors.New("invalid response request type"), types.ErrorCodeConvertRequestFailed), + } + } + default: + // Chat/Completion 等其他请求类型 + if generalReq, ok := request.(*dto.GeneralOpenAIRequest); ok { + convertedRequest, err = adaptor.ConvertOpenAIRequest(c, info, generalReq) + } else { + return testResult{ + context: c, + localErr: errors.New("invalid general request type"), + newAPIError: types.NewError(errors.New("invalid general request type"), types.ErrorCodeConvertRequestFailed), + } } - // 调用专门用于图像生成的转换函数 - convertedRequest, err = adaptor.ConvertImageRequest(c, info, imageRequest) - } else { - // 对其他所有请求类型(如 Chat),保持原有逻辑 - convertedRequest, err = adaptor.ConvertOpenAIRequest(c, info, request) } if err != nil { @@ -345,22 +411,82 @@ func testChannel(channel *model.Channel, testModel string) testResult { } } -func buildTestRequest(model string) *dto.GeneralOpenAIRequest { - testRequest := &dto.GeneralOpenAIRequest{ - Model: "", // this will be set later - Stream: false, +func buildTestRequest(model string, endpointType string) dto.Request { + // 根据端点类型构建不同的测试请求 + if endpointType != "" { + switch constant.EndpointType(endpointType) { + case constant.EndpointTypeEmbeddings: + // 返回 EmbeddingRequest + return &dto.EmbeddingRequest{ + Model: model, + Input: []any{"hello world"}, + } + case constant.EndpointTypeImageGeneration: + // 返回 ImageRequest + return &dto.ImageRequest{ + Model: model, + Prompt: "a cute cat", + N: 1, + Size: "1024x1024", + } + case constant.EndpointTypeJinaRerank: + // 返回 RerankRequest + return &dto.RerankRequest{ + Model: model, + Query: "What is Deep Learning?", + Documents: []any{"Deep Learning is a subset of machine learning.", "Machine learning is a field of artificial intelligence."}, + TopN: 2, + } + case constant.EndpointTypeOpenAIResponse: + // 返回 OpenAIResponsesRequest + return &dto.OpenAIResponsesRequest{ + Model: model, + Input: json.RawMessage("\"hi\""), + } + case constant.EndpointTypeAnthropic, constant.EndpointTypeGemini, constant.EndpointTypeOpenAI: + // 返回 GeneralOpenAIRequest + maxTokens := uint(10) + if constant.EndpointType(endpointType) == constant.EndpointTypeGemini { + maxTokens = 3000 + } + return &dto.GeneralOpenAIRequest{ + Model: model, + Stream: false, + Messages: []dto.Message{ + { + Role: "user", + Content: "hi", + }, + }, + MaxTokens: maxTokens, + } + } } + // 自动检测逻辑(保持原有行为) // 先判断是否为 Embedding 模型 - if strings.Contains(strings.ToLower(model), "embedding") || // 其他 embedding 模型 - strings.HasPrefix(model, "m3e") || // m3e 系列模型 + if strings.Contains(strings.ToLower(model), "embedding") || + strings.HasPrefix(model, "m3e") || strings.Contains(model, "bge-") { - testRequest.Model = model - // Embedding 请求 - testRequest.Input = []any{"hello world"} // 修改为any,因为dto/openai_request.go 的ParseInput方法无法处理[]string类型 - return testRequest + // 返回 EmbeddingRequest + return &dto.EmbeddingRequest{ + Model: model, + Input: []any{"hello world"}, + } } - // 并非Embedding 模型 + + // Chat/Completion 请求 - 返回 GeneralOpenAIRequest + testRequest := &dto.GeneralOpenAIRequest{ + Model: model, + Stream: false, + Messages: []dto.Message{ + { + Role: "user", + Content: "hi", + }, + }, + } + if strings.HasPrefix(model, "o") { testRequest.MaxCompletionTokens = 10 } else if strings.Contains(model, "thinking") { @@ -373,12 +499,6 @@ func buildTestRequest(model string) *dto.GeneralOpenAIRequest { testRequest.MaxTokens = 10 } - testMessage := dto.Message{ - Role: "user", - Content: "hi", - } - testRequest.Model = model - testRequest.Messages = append(testRequest.Messages, testMessage) return testRequest } @@ -402,8 +522,9 @@ func TestChannel(c *gin.Context) { // } //}() testModel := c.Query("model") + endpointType := c.Query("endpoint_type") tik := time.Now() - result := testChannel(channel, testModel) + result := testChannel(channel, testModel, endpointType) if result.localErr != nil { c.JSON(http.StatusOK, gin.H{ "success": false, @@ -429,7 +550,6 @@ func TestChannel(c *gin.Context) { "message": "", "time": consumedTime, }) - return } var testAllChannelsLock sync.Mutex @@ -463,7 +583,7 @@ func testAllChannels(notify bool) error { for _, channel := range channels { isChannelEnabled := channel.Status == common.ChannelStatusEnabled tik := time.Now() - result := testChannel(channel, "") + result := testChannel(channel, "", "") tok := time.Now() milliseconds := tok.Sub(tik).Milliseconds() @@ -477,7 +597,7 @@ func testAllChannels(notify bool) error { // 当错误检查通过,才检查响应时间 if common.AutomaticDisableChannelEnabled && !shouldBanChannel { if milliseconds > disableThreshold { - err := errors.New(fmt.Sprintf("响应时间 %.2fs 超过阈值 %.2fs", float64(milliseconds)/1000.0, float64(disableThreshold)/1000.0)) + err := fmt.Errorf("响应时间 %.2fs 超过阈值 %.2fs", float64(milliseconds)/1000.0, float64(disableThreshold)/1000.0) newAPIError = types.NewOpenAIError(err, types.ErrorCodeChannelResponseTimeExceeded, http.StatusRequestTimeout) shouldBanChannel = true } @@ -514,7 +634,6 @@ func TestAllChannels(c *gin.Context) { "success": true, "message": "", }) - return } var autoTestChannelsOnce sync.Once diff --git a/controller/channel.go b/controller/channel.go index 5d075f3c5..4aedee3b3 100644 --- a/controller/channel.go +++ b/controller/channel.go @@ -384,18 +384,9 @@ func GetChannel(c *gin.Context) { return } -// GetChannelKey 验证2FA后获取渠道密钥 +// GetChannelKey 获取渠道密钥(需要通过安全验证中间件) +// 此函数依赖 SecureVerificationRequired 中间件,确保用户已通过安全验证 func GetChannelKey(c *gin.Context) { - type GetChannelKeyRequest struct { - Code string `json:"code" binding:"required"` - } - - var req GetChannelKeyRequest - if err := c.ShouldBindJSON(&req); err != nil { - common.ApiError(c, fmt.Errorf("参数错误: %v", err)) - return - } - userId := c.GetInt("id") channelId, err := strconv.Atoi(c.Param("id")) if err != nil { @@ -403,24 +394,6 @@ func GetChannelKey(c *gin.Context) { return } - // 获取2FA记录并验证 - twoFA, err := model.GetTwoFAByUserId(userId) - if err != nil { - common.ApiError(c, fmt.Errorf("获取2FA信息失败: %v", err)) - return - } - - if twoFA == nil || !twoFA.IsEnabled { - common.ApiError(c, fmt.Errorf("用户未启用2FA,无法查看密钥")) - return - } - - // 统一的2FA验证逻辑 - if !validateTwoFactorAuth(twoFA, req.Code) { - common.ApiError(c, fmt.Errorf("验证码或备用码错误,请重试")) - return - } - // 获取渠道信息(包含密钥) channel, err := model.GetChannelById(channelId, true) if err != nil { @@ -436,10 +409,10 @@ func GetChannelKey(c *gin.Context) { // 记录操作日志 model.RecordLog(userId, model.LogTypeSystem, fmt.Sprintf("查看渠道密钥信息 (渠道ID: %d)", channelId)) - // 统一的成功响应格式 + // 返回渠道密钥 c.JSON(http.StatusOK, gin.H{ "success": true, - "message": "验证成功", + "message": "获取成功", "data": map[string]interface{}{ "key": channel.Key, }, diff --git a/controller/misc.go b/controller/misc.go index 8d78e3358..a3e017f87 100644 --- a/controller/misc.go +++ b/controller/misc.go @@ -42,6 +42,8 @@ func GetStatus(c *gin.Context) { common.OptionMapRWMutex.RLock() defer common.OptionMapRWMutex.RUnlock() + passkeySetting := system_setting.GetPasskeySettings() + data := gin.H{ "version": common.Version, "start_time": common.StartTime, @@ -98,6 +100,13 @@ func GetStatus(c *gin.Context) { "oidc_enabled": system_setting.GetOIDCSettings().Enabled, "oidc_client_id": system_setting.GetOIDCSettings().ClientId, "oidc_authorization_endpoint": system_setting.GetOIDCSettings().AuthorizationEndpoint, + "passkey_login": passkeySetting.Enabled, + "passkey_display_name": passkeySetting.RPDisplayName, + "passkey_rp_id": passkeySetting.RPID, + "passkey_origins": passkeySetting.Origins, + "passkey_allow_insecure": passkeySetting.AllowInsecureOrigin, + "passkey_user_verification": passkeySetting.UserVerification, + "passkey_attachment": passkeySetting.AttachmentPreference, "setup": constant.Setup, } diff --git a/controller/passkey.go b/controller/passkey.go new file mode 100644 index 000000000..7ffacf5d6 --- /dev/null +++ b/controller/passkey.go @@ -0,0 +1,497 @@ +package controller + +import ( + "errors" + "fmt" + "net/http" + "strconv" + "time" + + "one-api/common" + "one-api/model" + passkeysvc "one-api/service/passkey" + "one-api/setting/system_setting" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + "github.com/go-webauthn/webauthn/protocol" + webauthnlib "github.com/go-webauthn/webauthn/webauthn" +) + +func PasskeyRegisterBegin(c *gin.Context) { + if !system_setting.GetPasskeySettings().Enabled { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "管理员未启用 Passkey 登录", + }) + return + } + + user, err := getSessionUser(c) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + credential, err := model.GetPasskeyByUserID(user.Id) + if err != nil && !errors.Is(err, model.ErrPasskeyNotFound) { + common.ApiError(c, err) + return + } + if errors.Is(err, model.ErrPasskeyNotFound) { + credential = nil + } + + wa, err := passkeysvc.BuildWebAuthn(c.Request) + if err != nil { + common.ApiError(c, err) + return + } + + waUser := passkeysvc.NewWebAuthnUser(user, credential) + var options []webauthnlib.RegistrationOption + if credential != nil { + descriptor := credential.ToWebAuthnCredential().Descriptor() + options = append(options, webauthnlib.WithExclusions([]protocol.CredentialDescriptor{descriptor})) + } + + creation, sessionData, err := wa.BeginRegistration(waUser, options...) + if err != nil { + common.ApiError(c, err) + return + } + + if err := passkeysvc.SaveSessionData(c, passkeysvc.RegistrationSessionKey, sessionData); err != nil { + common.ApiError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "options": creation, + }, + }) +} + +func PasskeyRegisterFinish(c *gin.Context) { + if !system_setting.GetPasskeySettings().Enabled { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "管理员未启用 Passkey 登录", + }) + return + } + + user, err := getSessionUser(c) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + wa, err := passkeysvc.BuildWebAuthn(c.Request) + if err != nil { + common.ApiError(c, err) + return + } + + credentialRecord, err := model.GetPasskeyByUserID(user.Id) + if err != nil && !errors.Is(err, model.ErrPasskeyNotFound) { + common.ApiError(c, err) + return + } + if errors.Is(err, model.ErrPasskeyNotFound) { + credentialRecord = nil + } + + sessionData, err := passkeysvc.PopSessionData(c, passkeysvc.RegistrationSessionKey) + if err != nil { + common.ApiError(c, err) + return + } + + waUser := passkeysvc.NewWebAuthnUser(user, credentialRecord) + credential, err := wa.FinishRegistration(waUser, *sessionData, c.Request) + if err != nil { + common.ApiError(c, err) + return + } + + passkeyCredential := model.NewPasskeyCredentialFromWebAuthn(user.Id, credential) + if passkeyCredential == nil { + common.ApiErrorMsg(c, "无法创建 Passkey 凭证") + return + } + + if err := model.UpsertPasskeyCredential(passkeyCredential); err != nil { + common.ApiError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Passkey 注册成功", + }) +} + +func PasskeyDelete(c *gin.Context) { + user, err := getSessionUser(c) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + if err := model.DeletePasskeyByUserID(user.Id); err != nil { + common.ApiError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Passkey 已解绑", + }) +} + +func PasskeyStatus(c *gin.Context) { + user, err := getSessionUser(c) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + credential, err := model.GetPasskeyByUserID(user.Id) + if errors.Is(err, model.ErrPasskeyNotFound) { + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "enabled": false, + }, + }) + return + } + if err != nil { + common.ApiError(c, err) + return + } + + data := gin.H{ + "enabled": true, + "last_used_at": credential.LastUsedAt, + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": data, + }) +} + +func PasskeyLoginBegin(c *gin.Context) { + if !system_setting.GetPasskeySettings().Enabled { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "管理员未启用 Passkey 登录", + }) + return + } + + wa, err := passkeysvc.BuildWebAuthn(c.Request) + if err != nil { + common.ApiError(c, err) + return + } + + assertion, sessionData, err := wa.BeginDiscoverableLogin() + if err != nil { + common.ApiError(c, err) + return + } + + if err := passkeysvc.SaveSessionData(c, passkeysvc.LoginSessionKey, sessionData); err != nil { + common.ApiError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "options": assertion, + }, + }) +} + +func PasskeyLoginFinish(c *gin.Context) { + if !system_setting.GetPasskeySettings().Enabled { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "管理员未启用 Passkey 登录", + }) + return + } + + wa, err := passkeysvc.BuildWebAuthn(c.Request) + if err != nil { + common.ApiError(c, err) + return + } + + sessionData, err := passkeysvc.PopSessionData(c, passkeysvc.LoginSessionKey) + if err != nil { + common.ApiError(c, err) + return + } + + handler := func(rawID, userHandle []byte) (webauthnlib.User, error) { + // 首先通过凭证ID查找用户 + credential, err := model.GetPasskeyByCredentialID(rawID) + if err != nil { + return nil, fmt.Errorf("未找到 Passkey 凭证: %w", err) + } + + // 通过凭证获取用户 + user := &model.User{Id: credential.UserID} + if err := user.FillUserById(); err != nil { + return nil, fmt.Errorf("用户信息获取失败: %w", err) + } + + if user.Status != common.UserStatusEnabled { + return nil, errors.New("该用户已被禁用") + } + + if len(userHandle) > 0 { + userID, parseErr := strconv.Atoi(string(userHandle)) + if parseErr != nil { + // 记录异常但继续验证,因为某些客户端可能使用非数字格式 + common.SysLog(fmt.Sprintf("PasskeyLogin: userHandle parse error for credential, length: %d", len(userHandle))) + } else if userID != user.Id { + return nil, errors.New("用户句柄与凭证不匹配") + } + } + + return passkeysvc.NewWebAuthnUser(user, credential), nil + } + + waUser, credential, err := wa.FinishPasskeyLogin(handler, *sessionData, c.Request) + if err != nil { + common.ApiError(c, err) + return + } + + userWrapper, ok := waUser.(*passkeysvc.WebAuthnUser) + if !ok { + common.ApiErrorMsg(c, "Passkey 登录状态异常") + return + } + + modelUser := userWrapper.ModelUser() + if modelUser == nil { + common.ApiErrorMsg(c, "Passkey 登录状态异常") + return + } + + if modelUser.Status != common.UserStatusEnabled { + common.ApiErrorMsg(c, "该用户已被禁用") + return + } + + // 更新凭证信息 + updatedCredential := model.NewPasskeyCredentialFromWebAuthn(modelUser.Id, credential) + if updatedCredential == nil { + common.ApiErrorMsg(c, "Passkey 凭证更新失败") + return + } + now := time.Now() + updatedCredential.LastUsedAt = &now + if err := model.UpsertPasskeyCredential(updatedCredential); err != nil { + common.ApiError(c, err) + return + } + + setupLogin(modelUser, c) + return +} + +func AdminResetPasskey(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + common.ApiErrorMsg(c, "无效的用户 ID") + return + } + + user := &model.User{Id: id} + if err := user.FillUserById(); err != nil { + common.ApiError(c, err) + return + } + + if _, err := model.GetPasskeyByUserID(user.Id); err != nil { + if errors.Is(err, model.ErrPasskeyNotFound) { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "该用户尚未绑定 Passkey", + }) + return + } + common.ApiError(c, err) + return + } + + if err := model.DeletePasskeyByUserID(user.Id); err != nil { + common.ApiError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Passkey 已重置", + }) +} + +func PasskeyVerifyBegin(c *gin.Context) { + if !system_setting.GetPasskeySettings().Enabled { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "管理员未启用 Passkey 登录", + }) + return + } + + user, err := getSessionUser(c) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + credential, err := model.GetPasskeyByUserID(user.Id) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "该用户尚未绑定 Passkey", + }) + return + } + + wa, err := passkeysvc.BuildWebAuthn(c.Request) + if err != nil { + common.ApiError(c, err) + return + } + + waUser := passkeysvc.NewWebAuthnUser(user, credential) + assertion, sessionData, err := wa.BeginLogin(waUser) + if err != nil { + common.ApiError(c, err) + return + } + + if err := passkeysvc.SaveSessionData(c, passkeysvc.VerifySessionKey, sessionData); err != nil { + common.ApiError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "options": assertion, + }, + }) +} + +func PasskeyVerifyFinish(c *gin.Context) { + if !system_setting.GetPasskeySettings().Enabled { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "管理员未启用 Passkey 登录", + }) + return + } + + user, err := getSessionUser(c) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + wa, err := passkeysvc.BuildWebAuthn(c.Request) + if err != nil { + common.ApiError(c, err) + return + } + + credential, err := model.GetPasskeyByUserID(user.Id) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "该用户尚未绑定 Passkey", + }) + return + } + + sessionData, err := passkeysvc.PopSessionData(c, passkeysvc.VerifySessionKey) + if err != nil { + common.ApiError(c, err) + return + } + + waUser := passkeysvc.NewWebAuthnUser(user, credential) + _, err = wa.FinishLogin(waUser, *sessionData, c.Request) + if err != nil { + common.ApiError(c, err) + return + } + + // 更新凭证的最后使用时间 + now := time.Now() + credential.LastUsedAt = &now + if err := model.UpsertPasskeyCredential(credential); err != nil { + common.ApiError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Passkey 验证成功", + }) +} + +func getSessionUser(c *gin.Context) (*model.User, error) { + session := sessions.Default(c) + idRaw := session.Get("id") + if idRaw == nil { + return nil, errors.New("未登录") + } + id, ok := idRaw.(int) + if !ok { + return nil, errors.New("无效的会话信息") + } + user := &model.User{Id: id} + if err := user.FillUserById(); err != nil { + return nil, err + } + if user.Status != common.UserStatusEnabled { + return nil, errors.New("该用户已被禁用") + } + return user, nil +} diff --git a/controller/secure_verification.go b/controller/secure_verification.go new file mode 100644 index 000000000..1c5f0981a --- /dev/null +++ b/controller/secure_verification.go @@ -0,0 +1,313 @@ +package controller + +import ( + "fmt" + "net/http" + "one-api/common" + "one-api/model" + passkeysvc "one-api/service/passkey" + "one-api/setting/system_setting" + "time" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" +) + +const ( + // SecureVerificationSessionKey 安全验证的 session key + SecureVerificationSessionKey = "secure_verified_at" + // SecureVerificationTimeout 验证有效期(秒) + SecureVerificationTimeout = 300 // 5分钟 +) + +type UniversalVerifyRequest struct { + Method string `json:"method"` // "2fa" 或 "passkey" + Code string `json:"code,omitempty"` +} + +type VerificationStatusResponse struct { + Verified bool `json:"verified"` + ExpiresAt int64 `json:"expires_at,omitempty"` +} + +// UniversalVerify 通用验证接口 +// 支持 2FA 和 Passkey 验证,验证成功后在 session 中记录时间戳 +func UniversalVerify(c *gin.Context) { + userId := c.GetInt("id") + if userId == 0 { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "未登录", + }) + return + } + + var req UniversalVerifyRequest + if err := c.ShouldBindJSON(&req); err != nil { + common.ApiError(c, fmt.Errorf("参数错误: %v", err)) + return + } + + // 获取用户信息 + user := &model.User{Id: userId} + if err := user.FillUserById(); err != nil { + common.ApiError(c, fmt.Errorf("获取用户信息失败: %v", err)) + return + } + + if user.Status != common.UserStatusEnabled { + common.ApiError(c, fmt.Errorf("该用户已被禁用")) + return + } + + // 检查用户的验证方式 + twoFA, _ := model.GetTwoFAByUserId(userId) + has2FA := twoFA != nil && twoFA.IsEnabled + + passkey, passkeyErr := model.GetPasskeyByUserID(userId) + hasPasskey := passkeyErr == nil && passkey != nil + + if !has2FA && !hasPasskey { + common.ApiError(c, fmt.Errorf("用户未启用2FA或Passkey")) + return + } + + // 根据验证方式进行验证 + var verified bool + var verifyMethod string + + switch req.Method { + case "2fa": + if !has2FA { + common.ApiError(c, fmt.Errorf("用户未启用2FA")) + return + } + if req.Code == "" { + common.ApiError(c, fmt.Errorf("验证码不能为空")) + return + } + verified = validateTwoFactorAuth(twoFA, req.Code) + verifyMethod = "2FA" + + case "passkey": + if !hasPasskey { + common.ApiError(c, fmt.Errorf("用户未启用Passkey")) + return + } + // Passkey 验证需要先调用 PasskeyVerifyBegin 和 PasskeyVerifyFinish + // 这里只是验证 Passkey 验证流程是否已经完成 + // 实际上,前端应该先调用这两个接口,然后再调用本接口 + verified = true // Passkey 验证逻辑已在 PasskeyVerifyFinish 中完成 + verifyMethod = "Passkey" + + default: + common.ApiError(c, fmt.Errorf("不支持的验证方式: %s", req.Method)) + return + } + + if !verified { + common.ApiError(c, fmt.Errorf("验证失败,请检查验证码")) + return + } + + // 验证成功,在 session 中记录时间戳 + session := sessions.Default(c) + now := time.Now().Unix() + session.Set(SecureVerificationSessionKey, now) + if err := session.Save(); err != nil { + common.ApiError(c, fmt.Errorf("保存验证状态失败: %v", err)) + return + } + + // 记录日志 + model.RecordLog(userId, model.LogTypeSystem, fmt.Sprintf("通用安全验证成功 (验证方式: %s)", verifyMethod)) + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "验证成功", + "data": gin.H{ + "verified": true, + "expires_at": now + SecureVerificationTimeout, + }, + }) +} + +// GetVerificationStatus 获取验证状态 +func GetVerificationStatus(c *gin.Context) { + userId := c.GetInt("id") + if userId == 0 { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "未登录", + }) + return + } + + session := sessions.Default(c) + verifiedAtRaw := session.Get(SecureVerificationSessionKey) + + if verifiedAtRaw == nil { + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": VerificationStatusResponse{ + Verified: false, + }, + }) + return + } + + verifiedAt, ok := verifiedAtRaw.(int64) + if !ok { + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": VerificationStatusResponse{ + Verified: false, + }, + }) + return + } + + elapsed := time.Now().Unix() - verifiedAt + if elapsed >= SecureVerificationTimeout { + // 验证已过期 + session.Delete(SecureVerificationSessionKey) + _ = session.Save() + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": VerificationStatusResponse{ + Verified: false, + }, + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": VerificationStatusResponse{ + Verified: true, + ExpiresAt: verifiedAt + SecureVerificationTimeout, + }, + }) +} + +// CheckSecureVerification 检查是否已通过安全验证 +// 返回 true 表示验证有效,false 表示需要重新验证 +func CheckSecureVerification(c *gin.Context) bool { + session := sessions.Default(c) + verifiedAtRaw := session.Get(SecureVerificationSessionKey) + + if verifiedAtRaw == nil { + return false + } + + verifiedAt, ok := verifiedAtRaw.(int64) + if !ok { + return false + } + + elapsed := time.Now().Unix() - verifiedAt + if elapsed >= SecureVerificationTimeout { + // 验证已过期,清除 session + session.Delete(SecureVerificationSessionKey) + _ = session.Save() + return false + } + + return true +} + +// PasskeyVerifyAndSetSession Passkey 验证完成后设置 session +// 这是一个辅助函数,供 PasskeyVerifyFinish 调用 +func PasskeyVerifyAndSetSession(c *gin.Context) { + session := sessions.Default(c) + now := time.Now().Unix() + session.Set(SecureVerificationSessionKey, now) + _ = session.Save() +} + +// PasskeyVerifyForSecure 用于安全验证的 Passkey 验证流程 +// 整合了 begin 和 finish 流程 +func PasskeyVerifyForSecure(c *gin.Context) { + if !system_setting.GetPasskeySettings().Enabled { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "管理员未启用 Passkey 登录", + }) + return + } + + userId := c.GetInt("id") + if userId == 0 { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "未登录", + }) + return + } + + user := &model.User{Id: userId} + if err := user.FillUserById(); err != nil { + common.ApiError(c, fmt.Errorf("获取用户信息失败: %v", err)) + return + } + + if user.Status != common.UserStatusEnabled { + common.ApiError(c, fmt.Errorf("该用户已被禁用")) + return + } + + credential, err := model.GetPasskeyByUserID(userId) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "该用户尚未绑定 Passkey", + }) + return + } + + wa, err := passkeysvc.BuildWebAuthn(c.Request) + if err != nil { + common.ApiError(c, err) + return + } + + waUser := passkeysvc.NewWebAuthnUser(user, credential) + sessionData, err := passkeysvc.PopSessionData(c, passkeysvc.VerifySessionKey) + if err != nil { + common.ApiError(c, err) + return + } + + _, err = wa.FinishLogin(waUser, *sessionData, c.Request) + if err != nil { + common.ApiError(c, err) + return + } + + // 更新凭证的最后使用时间 + now := time.Now() + credential.LastUsedAt = &now + if err := model.UpsertPasskeyCredential(credential); err != nil { + common.ApiError(c, err) + return + } + + // 验证成功,设置 session + PasskeyVerifyAndSetSession(c) + + // 记录日志 + model.RecordLog(userId, model.LogTypeSystem, "Passkey 安全验证成功") + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Passkey 验证成功", + "data": gin.H{ + "verified": true, + "expires_at": time.Now().Unix() + SecureVerificationTimeout, + }, + }) +} diff --git a/controller/task_video.go b/controller/task_video.go index 73d5c39b1..ded011fe9 100644 --- a/controller/task_video.go +++ b/controller/task_video.go @@ -13,6 +13,7 @@ import ( "one-api/relay" "one-api/relay/channel" relaycommon "one-api/relay/common" + "one-api/setting/ratio_setting" "time" ) @@ -120,6 +121,91 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha if !(len(taskResult.Url) > 5 && taskResult.Url[:5] == "data:") { task.FailReason = taskResult.Url } + + // 如果返回了 total_tokens 并且配置了模型倍率(非固定价格),则重新计费 + if taskResult.TotalTokens > 0 { + // 获取模型名称 + var taskData map[string]interface{} + if err := json.Unmarshal(task.Data, &taskData); err == nil { + if modelName, ok := taskData["model"].(string); ok && modelName != "" { + // 获取模型价格和倍率 + modelRatio, hasRatioSetting, _ := ratio_setting.GetModelRatio(modelName) + + // 只有配置了倍率(非固定价格)时才按 token 重新计费 + if hasRatioSetting && modelRatio > 0 { + // 获取用户和组的倍率信息 + user, err := model.GetUserById(task.UserId, false) + if err == nil { + groupRatio := ratio_setting.GetGroupRatio(user.Group) + userGroupRatio, hasUserGroupRatio := ratio_setting.GetGroupGroupRatio(user.Group, user.Group) + + var finalGroupRatio float64 + if hasUserGroupRatio { + finalGroupRatio = userGroupRatio + } else { + finalGroupRatio = groupRatio + } + + // 计算实际应扣费额度: totalTokens * modelRatio * groupRatio + actualQuota := int(float64(taskResult.TotalTokens) * modelRatio * finalGroupRatio) + + // 计算差额 + preConsumedQuota := task.Quota + quotaDelta := actualQuota - preConsumedQuota + + if quotaDelta > 0 { + // 需要补扣费 + logger.LogInfo(ctx, fmt.Sprintf("视频任务 %s 预扣费后补扣费:%s(实际消耗:%s,预扣费:%s,tokens:%d)", + task.TaskID, + logger.LogQuota(quotaDelta), + logger.LogQuota(actualQuota), + logger.LogQuota(preConsumedQuota), + taskResult.TotalTokens, + )) + if err := model.DecreaseUserQuota(task.UserId, quotaDelta); err != nil { + logger.LogError(ctx, fmt.Sprintf("补扣费失败: %s", err.Error())) + } else { + model.UpdateUserUsedQuotaAndRequestCount(task.UserId, quotaDelta) + model.UpdateChannelUsedQuota(task.ChannelId, quotaDelta) + task.Quota = actualQuota // 更新任务记录的实际扣费额度 + + // 记录消费日志 + logContent := fmt.Sprintf("视频任务成功补扣费,模型倍率 %.2f,分组倍率 %.2f,tokens %d,预扣费 %s,实际扣费 %s,补扣费 %s", + modelRatio, finalGroupRatio, taskResult.TotalTokens, + logger.LogQuota(preConsumedQuota), logger.LogQuota(actualQuota), logger.LogQuota(quotaDelta)) + model.RecordLog(task.UserId, model.LogTypeSystem, logContent) + } + } else if quotaDelta < 0 { + // 需要退还多扣的费用 + refundQuota := -quotaDelta + logger.LogInfo(ctx, fmt.Sprintf("视频任务 %s 预扣费后返还:%s(实际消耗:%s,预扣费:%s,tokens:%d)", + task.TaskID, + logger.LogQuota(refundQuota), + logger.LogQuota(actualQuota), + logger.LogQuota(preConsumedQuota), + taskResult.TotalTokens, + )) + if err := model.IncreaseUserQuota(task.UserId, refundQuota, false); err != nil { + logger.LogError(ctx, fmt.Sprintf("退还预扣费失败: %s", err.Error())) + } else { + task.Quota = actualQuota // 更新任务记录的实际扣费额度 + + // 记录退款日志 + logContent := fmt.Sprintf("视频任务成功退还多扣费用,模型倍率 %.2f,分组倍率 %.2f,tokens %d,预扣费 %s,实际扣费 %s,退还 %s", + modelRatio, finalGroupRatio, taskResult.TotalTokens, + logger.LogQuota(preConsumedQuota), logger.LogQuota(actualQuota), logger.LogQuota(refundQuota)) + model.RecordLog(task.UserId, model.LogTypeSystem, logContent) + } + } else { + // quotaDelta == 0, 预扣费刚好准确 + logger.LogInfo(ctx, fmt.Sprintf("视频任务 %s 预扣费准确(%s,tokens:%d)", + task.TaskID, logger.LogQuota(actualQuota), taskResult.TotalTokens)) + } + } + } + } + } + } case model.TaskStatusFailure: task.Status = model.TaskStatusFailure task.Progress = "100%" diff --git a/controller/telegram.go b/controller/telegram.go index 8d07fc940..2b1ec4fcf 100644 --- a/controller/telegram.go +++ b/controller/telegram.go @@ -65,7 +65,7 @@ func TelegramBind(c *gin.Context) { return } - c.Redirect(302, "/setting") + c.Redirect(302, "/console/personal") } func TelegramLogin(c *gin.Context) { diff --git a/controller/user.go b/controller/user.go index 982329cec..33d4636b7 100644 --- a/controller/user.go +++ b/controller/user.go @@ -450,6 +450,10 @@ func GetSelf(c *gin.Context) { "role": user.Role, "status": user.Status, "email": user.Email, + "github_id": user.GitHubId, + "oidc_id": user.OidcId, + "wechat_id": user.WeChatId, + "telegram_id": user.TelegramId, "group": user.Group, "quota": user.Quota, "used_quota": user.UsedQuota, @@ -1098,6 +1102,9 @@ type UpdateUserSettingRequest struct { WebhookSecret string `json:"webhook_secret,omitempty"` NotificationEmail string `json:"notification_email,omitempty"` BarkUrl string `json:"bark_url,omitempty"` + GotifyUrl string `json:"gotify_url,omitempty"` + GotifyToken string `json:"gotify_token,omitempty"` + GotifyPriority int `json:"gotify_priority,omitempty"` AcceptUnsetModelRatioModel bool `json:"accept_unset_model_ratio_model"` RecordIpLog bool `json:"record_ip_log"` } @@ -1113,7 +1120,7 @@ func UpdateUserSetting(c *gin.Context) { } // 验证预警类型 - if req.QuotaWarningType != dto.NotifyTypeEmail && req.QuotaWarningType != dto.NotifyTypeWebhook && req.QuotaWarningType != dto.NotifyTypeBark { + if req.QuotaWarningType != dto.NotifyTypeEmail && req.QuotaWarningType != dto.NotifyTypeWebhook && req.QuotaWarningType != dto.NotifyTypeBark && req.QuotaWarningType != dto.NotifyTypeGotify { c.JSON(http.StatusOK, gin.H{ "success": false, "message": "无效的预警类型", @@ -1188,6 +1195,40 @@ func UpdateUserSetting(c *gin.Context) { } } + // 如果是Gotify类型,验证Gotify URL和Token + if req.QuotaWarningType == dto.NotifyTypeGotify { + if req.GotifyUrl == "" { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "Gotify服务器地址不能为空", + }) + return + } + if req.GotifyToken == "" { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "Gotify令牌不能为空", + }) + return + } + // 验证URL格式 + if _, err := url.ParseRequestURI(req.GotifyUrl); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无效的Gotify服务器地址", + }) + return + } + // 检查是否是HTTP或HTTPS + if !strings.HasPrefix(req.GotifyUrl, "https://") && !strings.HasPrefix(req.GotifyUrl, "http://") { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "Gotify服务器地址必须以http://或https://开头", + }) + return + } + } + userId := c.GetInt("id") user, err := model.GetUserById(userId, true) if err != nil { @@ -1221,6 +1262,18 @@ func UpdateUserSetting(c *gin.Context) { settings.BarkUrl = req.BarkUrl } + // 如果是Gotify类型,添加Gotify配置到设置中 + if req.QuotaWarningType == dto.NotifyTypeGotify { + settings.GotifyUrl = req.GotifyUrl + settings.GotifyToken = req.GotifyToken + // Gotify优先级范围0-10,超出范围则使用默认值5 + if req.GotifyPriority < 0 || req.GotifyPriority > 10 { + settings.GotifyPriority = 5 + } else { + settings.GotifyPriority = req.GotifyPriority + } + } + // 更新用户设置 user.SetSetting(settings) if err := user.Update(false); err != nil { diff --git a/docker-compose.yml b/docker-compose.yml index d98fd706e..e657390a7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,18 @@ -version: '3.4' +# New-API Docker Compose Configuration +# +# Quick Start: +# 1. docker-compose up -d +# 2. Access at http://localhost:3000 +# +# Using MySQL instead of PostgreSQL: +# 1. Comment out the postgres service and SQL_DSN line 15 +# 2. Uncomment the mysql service and SQL_DSN line 16 +# 3. Uncomment mysql in depends_on (line 28) +# 4. Uncomment mysql_data in volumes section (line 64) +# +# ⚠️ IMPORTANT: Change all default passwords before deploying to production! + +version: '3.4' # For compatibility with older Docker versions services: new-api: @@ -12,21 +26,22 @@ services: - ./data:/data - ./logs:/app/logs environment: - - SQL_DSN=root:123456@tcp(mysql:3306)/new-api # Point to the mysql service + - SQL_DSN=postgresql://root:123456@postgres:5432/new-api # ⚠️ IMPORTANT: Change the password in production! +# - SQL_DSN=root:123456@tcp(mysql:3306)/new-api # Point to the mysql service, uncomment if using MySQL - REDIS_CONN_STRING=redis://redis - TZ=Asia/Shanghai - ERROR_LOG_ENABLED=true # 是否启用错误日志记录 - # - STREAMING_TIMEOUT=300 # 流模式无响应超时时间,单位秒,默认120秒,如果出现空补全可以尝试改为更大值 - # - SESSION_SECRET=random_string # 多机部署时设置,必须修改这个随机字符串!!!!!!! - # - NODE_TYPE=slave # Uncomment for slave node in multi-node deployment - # - SYNC_FREQUENCY=60 # Uncomment if regular database syncing is needed - # - FRONTEND_BASE_URL=https://openai.justsong.cn # Uncomment for multi-node deployment with front-end URL + - BATCH_UPDATE_ENABLED=true # 是否启用批量更新 batch update enabled +# - STREAMING_TIMEOUT=300 # 流模式无响应超时时间,单位秒,默认120秒,如果出现空补全可以尝试改为更大值 Streaming timeout in seconds, default is 120s. Increase if experiencing empty completions +# - SESSION_SECRET=random_string # 多机部署时设置,必须修改这个随机字符串!! multi-node deployment, set this to a random string!!!!!!! +# - SYNC_FREQUENCY=60 # Uncomment if regular database syncing is needed depends_on: - redis - - mysql + - postgres +# - mysql # Uncomment if using MySQL healthcheck: - test: ["CMD-SHELL", "wget -q -O - http://localhost:3000/api/status | grep -o '\"success\":\\s*true' | awk -F: '{print $$2}'"] + test: ["CMD-SHELL", "wget -q -O - http://localhost:3000/api/status | grep -o '\"success\":\\s*true' || exit 1"] interval: 30s timeout: 10s retries: 3 @@ -36,17 +51,31 @@ services: container_name: redis restart: always - mysql: - image: mysql:8.2 - container_name: mysql + postgres: + image: postgres:15 + container_name: postgres restart: always environment: - MYSQL_ROOT_PASSWORD: 123456 # Ensure this matches the password in SQL_DSN - MYSQL_DATABASE: new-api + POSTGRES_USER: root + POSTGRES_PASSWORD: 123456 # ⚠️ IMPORTANT: Change this password in production! + POSTGRES_DB: new-api volumes: - - mysql_data:/var/lib/mysql - # ports: - # - "3306:3306" # If you want to access MySQL from outside Docker, uncomment + - pg_data:/var/lib/postgresql/data +# ports: +# - "5432:5432" # Uncomment if you need to access PostgreSQL from outside Docker + +# mysql: +# image: mysql:8.2 +# container_name: mysql +# restart: always +# environment: +# MYSQL_ROOT_PASSWORD: 123456 # ⚠️ IMPORTANT: Change this password in production! +# MYSQL_DATABASE: new-api +# volumes: +# - mysql_data:/var/lib/mysql +# ports: +# - "3306:3306" # Uncomment if you need to access MySQL from outside Docker volumes: - mysql_data: + pg_data: +# mysql_data: diff --git a/dto/channel_settings.go b/dto/channel_settings.go index d6d6e0848..d57184b38 100644 --- a/dto/channel_settings.go +++ b/dto/channel_settings.go @@ -20,6 +20,9 @@ type ChannelOtherSettings struct { AzureResponsesVersion string `json:"azure_responses_version,omitempty"` VertexKeyType VertexKeyType `json:"vertex_key_type,omitempty"` // "json" or "api_key" OpenRouterEnterprise *bool `json:"openrouter_enterprise,omitempty"` + AllowServiceTier bool `json:"allow_service_tier,omitempty"` // 是否允许 service_tier 透传(默认过滤以避免额外计费) + DisableStore bool `json:"disable_store,omitempty"` // 是否禁用 store 透传(默认允许透传,禁用后可能导致 Codex 无法使用) + AllowSafetyIdentifier bool `json:"allow_safety_identifier,omitempty"` // 是否允许 safety_identifier 透传(默认过滤以保护用户隐私) } func (s *ChannelOtherSettings) IsOpenRouterEnterprise() bool { diff --git a/dto/claude.go b/dto/claude.go index 963e588bf..dfc5cfd4c 100644 --- a/dto/claude.go +++ b/dto/claude.go @@ -195,11 +195,15 @@ type ClaudeRequest struct { Temperature *float64 `json:"temperature,omitempty"` TopP float64 `json:"top_p,omitempty"` TopK int `json:"top_k,omitempty"` - //ClaudeMetadata `json:"metadata,omitempty"` - Stream bool `json:"stream,omitempty"` - Tools any `json:"tools,omitempty"` - ToolChoice any `json:"tool_choice,omitempty"` - Thinking *Thinking `json:"thinking,omitempty"` + Stream bool `json:"stream,omitempty"` + Tools any `json:"tools,omitempty"` + ContextManagement json.RawMessage `json:"context_management,omitempty"` + ToolChoice any `json:"tool_choice,omitempty"` + Thinking *Thinking `json:"thinking,omitempty"` + McpServers json.RawMessage `json:"mcp_servers,omitempty"` + Metadata json.RawMessage `json:"metadata,omitempty"` + // 服务层级字段,用于指定 API 服务等级。允许透传可能导致实际计费高于预期,默认应过滤 + ServiceTier string `json:"service_tier,omitempty"` } func (c *ClaudeRequest) GetTokenCountMeta() *types.TokenCountMeta { diff --git a/dto/openai_request.go b/dto/openai_request.go index 191fa638f..dbdfad446 100644 --- a/dto/openai_request.go +++ b/dto/openai_request.go @@ -57,6 +57,18 @@ type GeneralOpenAIRequest struct { Dimensions int `json:"dimensions,omitempty"` Modalities json.RawMessage `json:"modalities,omitempty"` Audio json.RawMessage `json:"audio,omitempty"` + // 安全标识符,用于帮助 OpenAI 检测可能违反使用政策的应用程序用户 + // 注意:此字段会向 OpenAI 发送用户标识信息,默认过滤以保护用户隐私 + SafetyIdentifier string `json:"safety_identifier,omitempty"` + // Whether or not to store the output of this chat completion request for use in our model distillation or evals products. + // 是否存储此次请求数据供 OpenAI 用于评估和优化产品 + // 注意:默认过滤此字段以保护用户隐私,但过滤后可能导致 Codex 无法正常使用 + Store json.RawMessage `json:"store,omitempty"` + // Used by OpenAI to cache responses for similar requests to optimize your cache hit rates. Replaces the user field + PromptCacheKey string `json:"prompt_cache_key,omitempty"` + LogitBias json.RawMessage `json:"logit_bias,omitempty"` + Metadata json.RawMessage `json:"metadata,omitempty"` + Prediction json.RawMessage `json:"prediction,omitempty"` // gemini ExtraBody json.RawMessage `json:"extra_body,omitempty"` //xai @@ -775,19 +787,20 @@ type OpenAIResponsesRequest struct { ParallelToolCalls json.RawMessage `json:"parallel_tool_calls,omitempty"` PreviousResponseID string `json:"previous_response_id,omitempty"` Reasoning *Reasoning `json:"reasoning,omitempty"` - ServiceTier string `json:"service_tier,omitempty"` - Store json.RawMessage `json:"store,omitempty"` - PromptCacheKey json.RawMessage `json:"prompt_cache_key,omitempty"` - Stream bool `json:"stream,omitempty"` - Temperature float64 `json:"temperature,omitempty"` - Text json.RawMessage `json:"text,omitempty"` - ToolChoice json.RawMessage `json:"tool_choice,omitempty"` - Tools json.RawMessage `json:"tools,omitempty"` // 需要处理的参数很少,MCP 参数太多不确定,所以用 map - TopP float64 `json:"top_p,omitempty"` - Truncation string `json:"truncation,omitempty"` - User string `json:"user,omitempty"` - MaxToolCalls uint `json:"max_tool_calls,omitempty"` - Prompt json.RawMessage `json:"prompt,omitempty"` + // 服务层级字段,用于指定 API 服务等级。允许透传可能导致实际计费高于预期,默认应过滤 + ServiceTier string `json:"service_tier,omitempty"` + Store json.RawMessage `json:"store,omitempty"` + PromptCacheKey json.RawMessage `json:"prompt_cache_key,omitempty"` + Stream bool `json:"stream,omitempty"` + Temperature float64 `json:"temperature,omitempty"` + Text json.RawMessage `json:"text,omitempty"` + ToolChoice json.RawMessage `json:"tool_choice,omitempty"` + Tools json.RawMessage `json:"tools,omitempty"` // 需要处理的参数很少,MCP 参数太多不确定,所以用 map + TopP float64 `json:"top_p,omitempty"` + Truncation string `json:"truncation,omitempty"` + User string `json:"user,omitempty"` + MaxToolCalls uint `json:"max_tool_calls,omitempty"` + Prompt json.RawMessage `json:"prompt,omitempty"` } func (r *OpenAIResponsesRequest) GetTokenCountMeta() *types.TokenCountMeta { diff --git a/dto/user_settings.go b/dto/user_settings.go index 89dd926ef..16ce7b985 100644 --- a/dto/user_settings.go +++ b/dto/user_settings.go @@ -7,6 +7,9 @@ type UserSetting struct { WebhookSecret string `json:"webhook_secret,omitempty"` // WebhookSecret webhook密钥 NotificationEmail string `json:"notification_email,omitempty"` // NotificationEmail 通知邮箱地址 BarkUrl string `json:"bark_url,omitempty"` // BarkUrl Bark推送URL + GotifyUrl string `json:"gotify_url,omitempty"` // GotifyUrl Gotify服务器地址 + GotifyToken string `json:"gotify_token,omitempty"` // GotifyToken Gotify应用令牌 + GotifyPriority int `json:"gotify_priority"` // GotifyPriority Gotify消息优先级 AcceptUnsetRatioModel bool `json:"accept_unset_model_ratio_model,omitempty"` // AcceptUnsetRatioModel 是否接受未设置价格的模型 RecordIpLog bool `json:"record_ip_log,omitempty"` // 是否记录请求和错误日志IP SidebarModules string `json:"sidebar_modules,omitempty"` // SidebarModules 左侧边栏模块配置 @@ -16,4 +19,5 @@ var ( NotifyTypeEmail = "email" // Email 邮件 NotifyTypeWebhook = "webhook" // Webhook NotifyTypeBark = "bark" // Bark 推送 + NotifyTypeGotify = "gotify" // Gotify 推送 ) diff --git a/go.mod b/go.mod index 501d966d5..4e743d231 100644 --- a/go.mod +++ b/go.mod @@ -1,7 +1,7 @@ module one-api // +heroku goVersion go1.18 -go 1.23.4 +go 1.25.1 require ( github.com/Calcium-Ion/go-epay v0.0.4 @@ -11,7 +11,7 @@ require ( github.com/aws/aws-sdk-go-v2/credentials v1.17.11 github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.33.0 github.com/aws/smithy-go v1.22.5 - github.com/bytedance/gopkg v0.0.0-20220118071334-3db87571198b + github.com/bytedance/gopkg v0.1.3 github.com/gin-contrib/cors v1.7.2 github.com/gin-contrib/gzip v0.0.6 github.com/gin-contrib/sessions v0.0.5 @@ -20,6 +20,7 @@ require ( github.com/glebarez/sqlite v1.9.0 github.com/go-playground/validator/v10 v10.20.0 github.com/go-redis/redis/v8 v8.11.5 + github.com/go-webauthn/webauthn v0.14.0 github.com/golang-jwt/jwt v3.2.2+incompatible github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.0 @@ -35,10 +36,10 @@ require ( github.com/tidwall/gjson v1.18.0 github.com/tidwall/sjson v1.2.5 github.com/tiktoken-go/tokenizer v0.6.2 - golang.org/x/crypto v0.35.0 + golang.org/x/crypto v0.42.0 golang.org/x/image v0.23.0 - golang.org/x/net v0.35.0 - golang.org/x/sync v0.11.0 + golang.org/x/net v0.43.0 + golang.org/x/sync v0.17.0 gorm.io/driver/mysql v1.4.3 gorm.io/driver/postgres v1.5.2 gorm.io/gorm v1.25.2 @@ -50,14 +51,14 @@ require ( github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.2 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.2 // indirect github.com/boombuler/barcode v1.1.0 // indirect - github.com/bytedance/sonic v1.11.6 // indirect - github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/bytedance/sonic v1.14.1 // indirect + github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/cloudwego/base64x v0.1.4 // indirect - github.com/cloudwego/iasm v0.2.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gin-contrib/sse v0.1.0 // indirect github.com/glebarez/go-sqlite v1.21.2 // indirect @@ -65,8 +66,11 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-sql-driver/mysql v1.7.0 // indirect + github.com/go-webauthn/x v0.1.25 // indirect github.com/goccy/go-json v0.10.2 // indirect + github.com/golang-jwt/jwt/v5 v5.3.0 // indirect github.com/google/go-cmp v0.6.0 // indirect + github.com/google/go-tpm v0.9.5 // indirect github.com/gorilla/context v1.1.1 // indirect github.com/gorilla/securecookie v1.1.1 // indirect github.com/gorilla/sessions v1.2.1 // indirect @@ -77,7 +81,7 @@ require ( github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/cpuid/v2 v2.2.9 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect @@ -91,11 +95,12 @@ require ( github.com/tklauser/numcpus v0.6.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect + github.com/x448/float16 v0.8.4 // indirect github.com/yusufpapurcu/wmi v1.2.3 // indirect - golang.org/x/arch v0.12.0 // indirect + golang.org/x/arch v0.21.0 // indirect golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 // indirect - golang.org/x/sys v0.30.0 // indirect - golang.org/x/text v0.22.0 // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.29.0 // indirect google.golang.org/protobuf v1.34.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/libc v1.22.5 // indirect diff --git a/go.sum b/go.sum index 189d09de4..bea3261b9 100644 --- a/go.sum +++ b/go.sum @@ -23,18 +23,16 @@ github.com/aws/smithy-go v1.22.5/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo= github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= -github.com/bytedance/gopkg v0.0.0-20220118071334-3db87571198b h1:LTGVFpNmNHhj0vhOlfgWueFJ32eK9blaIlHR2ciXOT0= -github.com/bytedance/gopkg v0.0.0-20220118071334-3db87571198b/go.mod h1:2ZlV9BaUH4+NXIBF0aMdKKAnHTzqH+iMU4KUjAbL23Q= -github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= -github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= -github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= -github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.14.1 h1:FBMC0zVz5XUmE4z9wF4Jey0An5FueFvOsTKKKtwIl7w= +github.com/bytedance/sonic v1.14.1/go.mod h1:gi6uhQLMbTdeP0muCnrjHLeCUPyb70ujhnNlhOylAFc= +github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= +github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= 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/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= -github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= -github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= -github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -47,6 +45,8 @@ 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/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw= @@ -89,16 +89,24 @@ github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc= github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= +github.com/go-webauthn/webauthn v0.14.0 h1:ZLNPUgPcDlAeoxe+5umWG/tEeCoQIDr7gE2Zx2QnhL0= +github.com/go-webauthn/webauthn v0.14.0/go.mod h1:QZzPFH3LJ48u5uEPAu+8/nWJImoLBWM7iAH/kSVSo6k= +github.com/go-webauthn/x v0.1.25 h1:g/0noooIGcz/yCVqebcFgNnGIgBlJIccS+LYAa+0Z88= +github.com/go-webauthn/x v0.1.25/go.mod h1:ieblaPY1/BVCV0oQTsA/VAo08/TWayQuJuo5Q+XxmTY= github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= 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 v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-tpm v0.9.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU= +github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= @@ -132,10 +140,8 @@ github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwA github.com/json-iterator/go v1.1.9/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/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= -github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= -github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= @@ -200,8 +206,9 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stripe/stripe-go/v81 v81.4.0 h1:AuD9XzdAvl193qUCSaLocf8H+nRopOouXhxqJUzCLbw= github.com/stripe/stripe-go/v81 v81.4.0/go.mod h1:C/F4jlmnGNacvYtBp/LUHCvVUJEZffFQCobkzwY1WOo= github.com/thanhpk/randstr v1.0.6 h1:psAOktJFD4vV9NEVb3qkhRSMvYh4ORRaj1+w/hn4B+o= @@ -229,27 +236,29 @@ github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLY github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +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/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw= github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= -golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= -golang.org/x/arch v0.12.0 h1:UsYJhbzPYGsT0HbEdmYcqtCv8UNGvnaL561NnIUvaKg= -golang.org/x/arch v0.12.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/arch v0.21.0 h1:iTC9o7+wP6cPWpDWkivCvQFGAHDQ59SrSxsLPcnkArw= +golang.org/x/arch v0.21.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= -golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= +golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= +golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 h1:985EYyeCOxTpcgOTJpflJUwOeEz0CQOdPt73OzpE9F8= golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI= golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68= golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= -golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= -golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= -golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -257,18 +266,17 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220110181412-a018aaa089fe/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.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 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.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= -golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= @@ -305,5 +313,3 @@ modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= -nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= -rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/main.go b/main.go index e12dddc5a..ba96d2099 100644 --- a/main.go +++ b/main.go @@ -185,8 +185,9 @@ func InitResources() error { // This is a placeholder function for future resource initialization err := godotenv.Load(".env") if err != nil { - common.SysLog("未找到 .env 文件,使用默认环境变量,如果需要,请创建 .env 文件并设置相关变量") - common.SysLog("No .env file found, using default environment variables. If needed, please create a .env file and set the relevant variables.") + if common.DebugEnabled { + common.SysLog("No .env file found, using default environment variables. If needed, please create a .env file and set the relevant variables.") + } } // 加载环境变量 diff --git a/middleware/distributor.go b/middleware/distributor.go index 7fefeda49..3d929df49 100644 --- a/middleware/distributor.go +++ b/middleware/distributor.go @@ -169,6 +169,9 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) { relayMode := relayconstant.RelayModeUnknown if c.Request.Method == http.MethodPost { err = common.UnmarshalBodyReusable(c, &modelRequest) + if err != nil { + return nil, false, errors.New("video无效的请求, " + err.Error()) + } relayMode = relayconstant.RelayModeVideoSubmit } else if c.Request.Method == http.MethodGet { relayMode = relayconstant.RelayModeVideoFetchByID diff --git a/middleware/secure_verification.go b/middleware/secure_verification.go new file mode 100644 index 000000000..19fae9a59 --- /dev/null +++ b/middleware/secure_verification.go @@ -0,0 +1,131 @@ +package middleware + +import ( + "net/http" + "time" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" +) + +const ( + // SecureVerificationSessionKey 安全验证的 session key(与 controller 保持一致) + SecureVerificationSessionKey = "secure_verified_at" + // SecureVerificationTimeout 验证有效期(秒) + SecureVerificationTimeout = 300 // 5分钟 +) + +// SecureVerificationRequired 安全验证中间件 +// 检查用户是否在有效时间内通过了安全验证 +// 如果未验证或验证已过期,返回 401 错误 +func SecureVerificationRequired() gin.HandlerFunc { + return func(c *gin.Context) { + // 检查用户是否已登录 + userId := c.GetInt("id") + if userId == 0 { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "未登录", + }) + c.Abort() + return + } + + // 检查 session 中的验证时间戳 + session := sessions.Default(c) + verifiedAtRaw := session.Get(SecureVerificationSessionKey) + + if verifiedAtRaw == nil { + c.JSON(http.StatusForbidden, gin.H{ + "success": false, + "message": "需要安全验证", + "code": "VERIFICATION_REQUIRED", + }) + c.Abort() + return + } + + verifiedAt, ok := verifiedAtRaw.(int64) + if !ok { + // session 数据格式错误 + session.Delete(SecureVerificationSessionKey) + _ = session.Save() + c.JSON(http.StatusForbidden, gin.H{ + "success": false, + "message": "验证状态异常,请重新验证", + "code": "VERIFICATION_INVALID", + }) + c.Abort() + return + } + + // 检查验证是否过期 + elapsed := time.Now().Unix() - verifiedAt + if elapsed >= SecureVerificationTimeout { + // 验证已过期,清除 session + session.Delete(SecureVerificationSessionKey) + _ = session.Save() + c.JSON(http.StatusForbidden, gin.H{ + "success": false, + "message": "验证已过期,请重新验证", + "code": "VERIFICATION_EXPIRED", + }) + c.Abort() + return + } + + // 验证有效,继续处理请求 + c.Next() + } +} + +// OptionalSecureVerification 可选的安全验证中间件 +// 如果用户已验证,则在 context 中设置标记,但不阻止请求继续 +// 用于某些需要区分是否已验证的场景 +func OptionalSecureVerification() gin.HandlerFunc { + return func(c *gin.Context) { + userId := c.GetInt("id") + if userId == 0 { + c.Set("secure_verified", false) + c.Next() + return + } + + session := sessions.Default(c) + verifiedAtRaw := session.Get(SecureVerificationSessionKey) + + if verifiedAtRaw == nil { + c.Set("secure_verified", false) + c.Next() + return + } + + verifiedAt, ok := verifiedAtRaw.(int64) + if !ok { + c.Set("secure_verified", false) + c.Next() + return + } + + elapsed := time.Now().Unix() - verifiedAt + if elapsed >= SecureVerificationTimeout { + session.Delete(SecureVerificationSessionKey) + _ = session.Save() + c.Set("secure_verified", false) + c.Next() + return + } + + c.Set("secure_verified", true) + c.Set("secure_verified_at", verifiedAt) + c.Next() + } +} + +// ClearSecureVerification 清除安全验证状态 +// 用于用户登出或需要强制重新验证的场景 +func ClearSecureVerification(c *gin.Context) { + session := sessions.Default(c) + session.Delete(SecureVerificationSessionKey) + _ = session.Save() +} diff --git a/model/main.go b/model/main.go index 1a38d371b..14384caf9 100644 --- a/model/main.go +++ b/model/main.go @@ -251,6 +251,7 @@ func migrateDB() error { &Channel{}, &Token{}, &User{}, + &PasskeyCredential{}, &Option{}, &Redemption{}, &Ability{}, @@ -283,6 +284,7 @@ func migrateDBFast() error { {&Channel{}, "Channel"}, {&Token{}, "Token"}, {&User{}, "User"}, + {&PasskeyCredential{}, "PasskeyCredential"}, {&Option{}, "Option"}, {&Redemption{}, "Redemption"}, {&Ability{}, "Ability"}, diff --git a/model/passkey.go b/model/passkey.go new file mode 100644 index 000000000..5b2a15474 --- /dev/null +++ b/model/passkey.go @@ -0,0 +1,209 @@ +package model + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "one-api/common" + "strings" + "time" + + "github.com/go-webauthn/webauthn/protocol" + "github.com/go-webauthn/webauthn/webauthn" + "gorm.io/gorm" +) + +var ( + ErrPasskeyNotFound = errors.New("passkey credential not found") + ErrFriendlyPasskeyNotFound = errors.New("Passkey 验证失败,请重试或联系管理员") +) + +type PasskeyCredential struct { + ID int `json:"id" gorm:"primaryKey"` + UserID int `json:"user_id" gorm:"uniqueIndex;not null"` + CredentialID string `json:"credential_id" gorm:"type:varchar(512);uniqueIndex;not null"` // base64 encoded + PublicKey string `json:"public_key" gorm:"type:text;not null"` // base64 encoded + AttestationType string `json:"attestation_type" gorm:"type:varchar(255)"` + AAGUID string `json:"aaguid" gorm:"type:varchar(512)"` // base64 encoded + SignCount uint32 `json:"sign_count" gorm:"default:0"` + CloneWarning bool `json:"clone_warning"` + UserPresent bool `json:"user_present"` + UserVerified bool `json:"user_verified"` + BackupEligible bool `json:"backup_eligible"` + BackupState bool `json:"backup_state"` + Transports string `json:"transports" gorm:"type:text"` + Attachment string `json:"attachment" gorm:"type:varchar(32)"` + LastUsedAt *time.Time `json:"last_used_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` +} + +func (p *PasskeyCredential) TransportList() []protocol.AuthenticatorTransport { + if p == nil || strings.TrimSpace(p.Transports) == "" { + return nil + } + var transports []string + if err := json.Unmarshal([]byte(p.Transports), &transports); err != nil { + return nil + } + result := make([]protocol.AuthenticatorTransport, 0, len(transports)) + for _, transport := range transports { + result = append(result, protocol.AuthenticatorTransport(transport)) + } + return result +} + +func (p *PasskeyCredential) SetTransports(list []protocol.AuthenticatorTransport) { + if len(list) == 0 { + p.Transports = "" + return + } + stringList := make([]string, len(list)) + for i, transport := range list { + stringList[i] = string(transport) + } + encoded, err := json.Marshal(stringList) + if err != nil { + return + } + p.Transports = string(encoded) +} + +func (p *PasskeyCredential) ToWebAuthnCredential() webauthn.Credential { + flags := webauthn.CredentialFlags{ + UserPresent: p.UserPresent, + UserVerified: p.UserVerified, + BackupEligible: p.BackupEligible, + BackupState: p.BackupState, + } + + credID, _ := base64.StdEncoding.DecodeString(p.CredentialID) + pubKey, _ := base64.StdEncoding.DecodeString(p.PublicKey) + aaguid, _ := base64.StdEncoding.DecodeString(p.AAGUID) + + return webauthn.Credential{ + ID: credID, + PublicKey: pubKey, + AttestationType: p.AttestationType, + Transport: p.TransportList(), + Flags: flags, + Authenticator: webauthn.Authenticator{ + AAGUID: aaguid, + SignCount: p.SignCount, + CloneWarning: p.CloneWarning, + Attachment: protocol.AuthenticatorAttachment(p.Attachment), + }, + } +} + +func NewPasskeyCredentialFromWebAuthn(userID int, credential *webauthn.Credential) *PasskeyCredential { + if credential == nil { + return nil + } + passkey := &PasskeyCredential{ + UserID: userID, + CredentialID: base64.StdEncoding.EncodeToString(credential.ID), + PublicKey: base64.StdEncoding.EncodeToString(credential.PublicKey), + AttestationType: credential.AttestationType, + AAGUID: base64.StdEncoding.EncodeToString(credential.Authenticator.AAGUID), + SignCount: credential.Authenticator.SignCount, + CloneWarning: credential.Authenticator.CloneWarning, + UserPresent: credential.Flags.UserPresent, + UserVerified: credential.Flags.UserVerified, + BackupEligible: credential.Flags.BackupEligible, + BackupState: credential.Flags.BackupState, + Attachment: string(credential.Authenticator.Attachment), + } + passkey.SetTransports(credential.Transport) + return passkey +} + +func (p *PasskeyCredential) ApplyValidatedCredential(credential *webauthn.Credential) { + if credential == nil || p == nil { + return + } + p.CredentialID = base64.StdEncoding.EncodeToString(credential.ID) + p.PublicKey = base64.StdEncoding.EncodeToString(credential.PublicKey) + p.AttestationType = credential.AttestationType + p.AAGUID = base64.StdEncoding.EncodeToString(credential.Authenticator.AAGUID) + p.SignCount = credential.Authenticator.SignCount + p.CloneWarning = credential.Authenticator.CloneWarning + p.UserPresent = credential.Flags.UserPresent + p.UserVerified = credential.Flags.UserVerified + p.BackupEligible = credential.Flags.BackupEligible + p.BackupState = credential.Flags.BackupState + p.Attachment = string(credential.Authenticator.Attachment) + p.SetTransports(credential.Transport) +} + +func GetPasskeyByUserID(userID int) (*PasskeyCredential, error) { + if userID == 0 { + common.SysLog("GetPasskeyByUserID: empty user ID") + return nil, ErrFriendlyPasskeyNotFound + } + var credential PasskeyCredential + if err := DB.Where("user_id = ?", userID).First(&credential).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + // 未找到记录是正常情况(用户未绑定),返回 ErrPasskeyNotFound 而不记录日志 + return nil, ErrPasskeyNotFound + } + // 只有真正的数据库错误才记录日志 + common.SysLog(fmt.Sprintf("GetPasskeyByUserID: database error for user %d: %v", userID, err)) + return nil, ErrFriendlyPasskeyNotFound + } + return &credential, nil +} + +func GetPasskeyByCredentialID(credentialID []byte) (*PasskeyCredential, error) { + if len(credentialID) == 0 { + common.SysLog("GetPasskeyByCredentialID: empty credential ID") + return nil, ErrFriendlyPasskeyNotFound + } + + credIDStr := base64.StdEncoding.EncodeToString(credentialID) + var credential PasskeyCredential + if err := DB.Where("credential_id = ?", credIDStr).First(&credential).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + common.SysLog(fmt.Sprintf("GetPasskeyByCredentialID: passkey not found for credential ID length %d", len(credentialID))) + return nil, ErrFriendlyPasskeyNotFound + } + common.SysLog(fmt.Sprintf("GetPasskeyByCredentialID: database error for credential ID: %v", err)) + return nil, ErrFriendlyPasskeyNotFound + } + + return &credential, nil +} + +func UpsertPasskeyCredential(credential *PasskeyCredential) error { + if credential == nil { + common.SysLog("UpsertPasskeyCredential: nil credential provided") + return fmt.Errorf("Passkey 保存失败,请重试") + } + return DB.Transaction(func(tx *gorm.DB) error { + // 使用Unscoped()进行硬删除,避免唯一索引冲突 + if err := tx.Unscoped().Where("user_id = ?", credential.UserID).Delete(&PasskeyCredential{}).Error; err != nil { + common.SysLog(fmt.Sprintf("UpsertPasskeyCredential: failed to delete existing credential for user %d: %v", credential.UserID, err)) + return fmt.Errorf("Passkey 保存失败,请重试") + } + if err := tx.Create(credential).Error; err != nil { + common.SysLog(fmt.Sprintf("UpsertPasskeyCredential: failed to create credential for user %d: %v", credential.UserID, err)) + return fmt.Errorf("Passkey 保存失败,请重试") + } + return nil + }) +} + +func DeletePasskeyByUserID(userID int) error { + if userID == 0 { + common.SysLog("DeletePasskeyByUserID: empty user ID") + return fmt.Errorf("删除失败,请重试") + } + // 使用Unscoped()进行硬删除,避免唯一索引冲突 + if err := DB.Unscoped().Where("user_id = ?", userID).Delete(&PasskeyCredential{}).Error; err != nil { + common.SysLog(fmt.Sprintf("DeletePasskeyByUserID: failed to delete passkey for user %d: %v", userID, err)) + return fmt.Errorf("删除失败,请重试") + } + return nil +} diff --git a/relay/channel/api_request.go b/relay/channel/api_request.go index 79a0f7060..548e720d9 100644 --- a/relay/channel/api_request.go +++ b/relay/channel/api_request.go @@ -14,6 +14,7 @@ import ( "one-api/service" "one-api/setting/operation_setting" "one-api/types" + "strings" "sync" "time" @@ -36,6 +37,26 @@ func SetupApiRequestHeader(info *common.RelayInfo, c *gin.Context, req *http.Hea } } +// processHeaderOverride 处理请求头覆盖,支持变量替换 +// 支持的变量:{api_key} +func processHeaderOverride(info *common.RelayInfo) (map[string]string, error) { + headerOverride := make(map[string]string) + for k, v := range info.HeadersOverride { + str, ok := v.(string) + if !ok { + return nil, types.NewError(nil, types.ErrorCodeChannelHeaderOverrideInvalid) + } + + // 替换支持的变量 + if strings.Contains(str, "{api_key}") { + str = strings.ReplaceAll(str, "{api_key}", info.ApiKey) + } + + headerOverride[k] = str + } + return headerOverride, nil +} + func DoApiRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { fullRequestURL, err := a.GetRequestURL(info) if err != nil { @@ -49,13 +70,9 @@ func DoApiRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody return nil, fmt.Errorf("new request failed: %w", err) } headers := req.Header - headerOverride := make(map[string]string) - for k, v := range info.HeadersOverride { - if str, ok := v.(string); ok { - headerOverride[k] = str - } else { - return nil, types.NewError(err, types.ErrorCodeChannelHeaderOverrideInvalid) - } + headerOverride, err := processHeaderOverride(info) + if err != nil { + return nil, err } for key, value := range headerOverride { headers.Set(key, value) @@ -86,13 +103,9 @@ func DoFormRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBod // set form data req.Header.Set("Content-Type", c.Request.Header.Get("Content-Type")) headers := req.Header - headerOverride := make(map[string]string) - for k, v := range info.HeadersOverride { - if str, ok := v.(string); ok { - headerOverride[k] = str - } else { - return nil, types.NewError(err, types.ErrorCodeChannelHeaderOverrideInvalid) - } + headerOverride, err := processHeaderOverride(info) + if err != nil { + return nil, err } for key, value := range headerOverride { headers.Set(key, value) @@ -114,6 +127,13 @@ func DoWssRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody return nil, fmt.Errorf("get request url failed: %w", err) } targetHeader := http.Header{} + headerOverride, err := processHeaderOverride(info) + if err != nil { + return nil, err + } + for key, value := range headerOverride { + targetHeader.Set(key, value) + } err = a.SetupRequestHeader(c, &targetHeader, info) if err != nil { return nil, fmt.Errorf("setup request header failed: %w", err) diff --git a/relay/channel/aws/adaptor.go b/relay/channel/aws/adaptor.go index 9d5e5891e..92d60df48 100644 --- a/relay/channel/aws/adaptor.go +++ b/relay/channel/aws/adaptor.go @@ -7,7 +7,6 @@ import ( "one-api/dto" "one-api/relay/channel/claude" relaycommon "one-api/relay/common" - "one-api/setting/model_setting" "one-api/types" "github.com/gin-gonic/gin" @@ -52,7 +51,7 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { } func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { - model_setting.GetClaudeSettings().WriteHeaders(info.OriginModelName, req) + claude.CommonClaudeHeadersOperation(c, req, info) return nil } diff --git a/relay/channel/aws/constants.go b/relay/channel/aws/constants.go index 5ac7ce998..45112d231 100644 --- a/relay/channel/aws/constants.go +++ b/relay/channel/aws/constants.go @@ -16,6 +16,7 @@ var awsModelIDMap = map[string]string{ "claude-sonnet-4-20250514": "anthropic.claude-sonnet-4-20250514-v1:0", "claude-opus-4-20250514": "anthropic.claude-opus-4-20250514-v1:0", "claude-opus-4-1-20250805": "anthropic.claude-opus-4-1-20250805-v1:0", + "claude-sonnet-4-5-20250929": "anthropic.claude-sonnet-4-5-20250929-v1:0", // Nova models "nova-micro-v1:0": "amazon.nova-micro-v1:0", "nova-lite-v1:0": "amazon.nova-lite-v1:0", @@ -69,6 +70,11 @@ var awsModelCanCrossRegionMap = map[string]map[string]bool{ "anthropic.claude-opus-4-1-20250805-v1:0": { "us": true, }, + "anthropic.claude-sonnet-4-5-20250929-v1:0": { + "us": true, + "ap": true, + "eu": true, + }, // Nova models - all support three major regions "amazon.nova-micro-v1:0": { "us": true, diff --git a/relay/channel/claude/adaptor.go b/relay/channel/claude/adaptor.go index 959327e16..17e7cbd2b 100644 --- a/relay/channel/claude/adaptor.go +++ b/relay/channel/claude/adaptor.go @@ -52,11 +52,25 @@ func (a *Adaptor) Init(info *relaycommon.RelayInfo) { } func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + baseURL := "" if a.RequestMode == RequestModeMessage { - return fmt.Sprintf("%s/v1/messages", info.ChannelBaseUrl), nil + baseURL = fmt.Sprintf("%s/v1/messages", info.ChannelBaseUrl) } else { - return fmt.Sprintf("%s/v1/complete", info.ChannelBaseUrl), nil + baseURL = fmt.Sprintf("%s/v1/complete", info.ChannelBaseUrl) } + if info.IsClaudeBetaQuery { + baseURL = baseURL + "?beta=true" + } + return baseURL, nil +} + +func CommonClaudeHeadersOperation(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) { + // common headers operation + anthropicBeta := c.Request.Header.Get("anthropic-beta") + if anthropicBeta != "" { + req.Set("anthropic-beta", anthropicBeta) + } + model_setting.GetClaudeSettings().WriteHeaders(info.OriginModelName, req) } func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { @@ -67,7 +81,7 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *rel anthropicVersion = "2023-06-01" } req.Set("anthropic-version", anthropicVersion) - model_setting.GetClaudeSettings().WriteHeaders(info.OriginModelName, req) + CommonClaudeHeadersOperation(c, req, info) return nil } diff --git a/relay/channel/claude/constants.go b/relay/channel/claude/constants.go index a23543d21..d0b36fe4f 100644 --- a/relay/channel/claude/constants.go +++ b/relay/channel/claude/constants.go @@ -19,6 +19,8 @@ var ModelList = []string{ "claude-opus-4-20250514-thinking", "claude-opus-4-1-20250805", "claude-opus-4-1-20250805-thinking", + "claude-sonnet-4-5-20250929", + "claude-sonnet-4-5-20250929-thinking", } var ChannelName = "claude" diff --git a/relay/channel/jina/adaptor.go b/relay/channel/jina/adaptor.go index a383728f7..dbfe314d5 100644 --- a/relay/channel/jina/adaptor.go +++ b/relay/channel/jina/adaptor.go @@ -76,6 +76,7 @@ func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dt } func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + request.EncodingFormat = "" return request, nil } diff --git a/relay/channel/openai/relay_responses.go b/relay/channel/openai/relay_responses.go index 85938a771..7b148f323 100644 --- a/relay/channel/openai/relay_responses.go +++ b/relay/channel/openai/relay_responses.go @@ -115,7 +115,11 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp if streamResponse.Item != nil { switch streamResponse.Item.Type { case dto.BuildInCallWebSearchCall: - info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolWebSearchPreview].CallCount++ + if info != nil && info.ResponsesUsageInfo != nil && info.ResponsesUsageInfo.BuiltInTools != nil { + if webSearchTool, exists := info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolWebSearchPreview]; exists && webSearchTool != nil { + webSearchTool.CallCount++ + } + } } } } diff --git a/relay/channel/submodel/adaptor.go b/relay/channel/submodel/adaptor.go new file mode 100644 index 000000000..db58fe64c --- /dev/null +++ b/relay/channel/submodel/adaptor.go @@ -0,0 +1,86 @@ +package submodel + +import ( + "errors" + "io" + "net/http" + "one-api/dto" + "one-api/relay/channel" + "one-api/relay/channel/openai" + relaycommon "one-api/relay/common" + "one-api/types" + + "github.com/gin-gonic/gin" +) + +type Adaptor struct { +} + +func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error) { + return nil, errors.New("submodel channel: endpoint not supported") +} + +func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { + return nil, errors.New("submodel channel: endpoint not supported") +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + return nil, errors.New("submodel channel: endpoint not supported") +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + return nil, errors.New("submodel channel: endpoint not supported") +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + return relaycommon.GetFullRequestURL(info.ChannelBaseUrl, info.RequestURLPath, info.ChannelType), nil +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { + channel.SetupApiRequestHeader(info, c, req) + req.Set("Authorization", "Bearer "+info.ApiKey) + return nil +} + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + return request, nil +} + +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + return nil, errors.New("submodel channel: endpoint not supported") +} + +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + return nil, errors.New("submodel channel: endpoint not supported") +} + +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + return nil, errors.New("submodel channel: endpoint not supported") +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + return channel.DoApiRequest(a, c, info, requestBody) +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + if info.IsStream { + usage, err = openai.OaiStreamHandler(c, info, resp) + } else { + usage, err = openai.OpenaiHandler(c, info, resp) + } + return +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} diff --git a/relay/channel/submodel/constants.go b/relay/channel/submodel/constants.go new file mode 100644 index 000000000..72d6fee31 --- /dev/null +++ b/relay/channel/submodel/constants.go @@ -0,0 +1,16 @@ +package submodel + +var ModelList = []string{ + "NousResearch/Hermes-4-405B-FP8", + "Qwen/Qwen3-235B-A22B-Thinking-2507", + "Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8", + "Qwen/Qwen3-235B-A22B-Instruct-2507", + "zai-org/GLM-4.5-FP8", + "openai/gpt-oss-120b", + "deepseek-ai/DeepSeek-R1-0528", + "deepseek-ai/DeepSeek-R1", + "deepseek-ai/DeepSeek-V3-0324", + "deepseek-ai/DeepSeek-V3.1", +} + +const ChannelName = "submodel" diff --git a/relay/channel/task/doubao/adaptor.go b/relay/channel/task/doubao/adaptor.go new file mode 100644 index 000000000..8cc1fa4f5 --- /dev/null +++ b/relay/channel/task/doubao/adaptor.go @@ -0,0 +1,248 @@ +package doubao + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "one-api/constant" + "one-api/dto" + "one-api/model" + "one-api/relay/channel" + relaycommon "one-api/relay/common" + "one-api/service" + + "github.com/gin-gonic/gin" + "github.com/pkg/errors" +) + +// ============================ +// Request / Response structures +// ============================ + +type ContentItem struct { + Type string `json:"type"` // "text" or "image_url" + Text string `json:"text,omitempty"` // for text type + ImageURL *ImageURL `json:"image_url,omitempty"` // for image_url type +} + +type ImageURL struct { + URL string `json:"url"` +} + +type requestPayload struct { + Model string `json:"model"` + Content []ContentItem `json:"content"` +} + +type responsePayload struct { + ID string `json:"id"` // task_id +} + +type responseTask struct { + ID string `json:"id"` + Model string `json:"model"` + Status string `json:"status"` + Content struct { + VideoURL string `json:"video_url"` + } `json:"content"` + Seed int `json:"seed"` + Resolution string `json:"resolution"` + Duration int `json:"duration"` + Ratio string `json:"ratio"` + FramesPerSecond int `json:"framespersecond"` + Usage struct { + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` + } `json:"usage"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +// ============================ +// Adaptor implementation +// ============================ + +type TaskAdaptor struct { + ChannelType int + apiKey string + baseURL string +} + +func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) { + a.ChannelType = info.ChannelType + a.baseURL = info.ChannelBaseUrl + a.apiKey = info.ApiKey +} + +// ValidateRequestAndSetAction parses body, validates fields and sets default action. +func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *dto.TaskError) { + // Accept only POST /v1/video/generations as "generate" action. + return relaycommon.ValidateBasicTaskRequest(c, info, constant.TaskActionGenerate) +} + +// BuildRequestURL constructs the upstream URL. +func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) { + return fmt.Sprintf("%s/api/v3/contents/generations/tasks", a.baseURL), nil +} + +// BuildRequestHeader sets required headers. +func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error { + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+a.apiKey) + return nil +} + +// BuildRequestBody converts request into Doubao specific format. +func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) { + v, exists := c.Get("task_request") + if !exists { + return nil, fmt.Errorf("request not found in context") + } + req := v.(relaycommon.TaskSubmitReq) + + body, err := a.convertToRequestPayload(&req) + if err != nil { + return nil, errors.Wrap(err, "convert request payload failed") + } + data, err := json.Marshal(body) + if err != nil { + return nil, err + } + return bytes.NewReader(data), nil +} + +// DoRequest delegates to common helper. +func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error) { + return channel.DoTaskApiRequest(a, c, info, requestBody) +} + +// DoResponse handles upstream response, returns taskID etc. +func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) { + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + taskErr = service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError) + return + } + _ = resp.Body.Close() + + // Parse Doubao response + var dResp responsePayload + if err := json.Unmarshal(responseBody, &dResp); err != nil { + taskErr = service.TaskErrorWrapper(errors.Wrapf(err, "body: %s", responseBody), "unmarshal_response_body_failed", http.StatusInternalServerError) + return + } + + if dResp.ID == "" { + taskErr = service.TaskErrorWrapper(fmt.Errorf("task_id is empty"), "invalid_response", http.StatusInternalServerError) + return + } + + c.JSON(http.StatusOK, gin.H{"task_id": dResp.ID}) + return dResp.ID, responseBody, nil +} + +// FetchTask fetch task status +func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http.Response, error) { + taskID, ok := body["task_id"].(string) + if !ok { + return nil, fmt.Errorf("invalid task_id") + } + + uri := fmt.Sprintf("%s/api/v3/contents/generations/tasks/%s", baseUrl, taskID) + + req, err := http.NewRequest(http.MethodGet, uri, nil) + if err != nil { + return nil, err + } + + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+key) + + return service.GetHttpClient().Do(req) +} + +func (a *TaskAdaptor) GetModelList() []string { + return ModelList +} + +func (a *TaskAdaptor) GetChannelName() string { + return ChannelName +} + +func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*requestPayload, error) { + r := requestPayload{ + Model: req.Model, + Content: []ContentItem{}, + } + + // Add text prompt + if req.Prompt != "" { + r.Content = append(r.Content, ContentItem{ + Type: "text", + Text: req.Prompt, + }) + } + + // Add images if present + if req.HasImage() { + for _, imgURL := range req.Images { + r.Content = append(r.Content, ContentItem{ + Type: "image_url", + ImageURL: &ImageURL{ + URL: imgURL, + }, + }) + } + } + + // TODO: Add support for additional parameters from metadata + // such as ratio, duration, seed, etc. + // metadata := req.Metadata + // if metadata != nil { + // // Parse and apply metadata parameters + // } + + return &r, nil +} + +func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) { + resTask := responseTask{} + if err := json.Unmarshal(respBody, &resTask); err != nil { + return nil, errors.Wrap(err, "unmarshal task result failed") + } + + taskResult := relaycommon.TaskInfo{ + Code: 0, + } + + // Map Doubao status to internal status + switch resTask.Status { + case "pending", "queued": + taskResult.Status = model.TaskStatusQueued + taskResult.Progress = "10%" + case "processing": + taskResult.Status = model.TaskStatusInProgress + taskResult.Progress = "50%" + case "succeeded": + taskResult.Status = model.TaskStatusSuccess + taskResult.Progress = "100%" + taskResult.Url = resTask.Content.VideoURL + // 解析 usage 信息用于按倍率计费 + taskResult.CompletionTokens = resTask.Usage.CompletionTokens + taskResult.TotalTokens = resTask.Usage.TotalTokens + case "failed": + taskResult.Status = model.TaskStatusFailure + taskResult.Progress = "100%" + taskResult.Reason = "task failed" + default: + // Unknown status, treat as processing + taskResult.Status = model.TaskStatusInProgress + taskResult.Progress = "30%" + } + + return &taskResult, nil +} diff --git a/relay/channel/task/doubao/constants.go b/relay/channel/task/doubao/constants.go new file mode 100644 index 000000000..74b416c6d --- /dev/null +++ b/relay/channel/task/doubao/constants.go @@ -0,0 +1,9 @@ +package doubao + +var ModelList = []string{ + "doubao-seedance-1-0-pro-250528", + "doubao-seedance-1-0-lite-t2v", + "doubao-seedance-1-0-lite-i2v", +} + +var ChannelName = "doubao-video" diff --git a/relay/channel/vertex/adaptor.go b/relay/channel/vertex/adaptor.go index 834ed45db..c4781813c 100644 --- a/relay/channel/vertex/adaptor.go +++ b/relay/channel/vertex/adaptor.go @@ -37,6 +37,7 @@ var claudeModelMap = map[string]string{ "claude-sonnet-4-20250514": "claude-sonnet-4@20250514", "claude-opus-4-20250514": "claude-opus-4@20250514", "claude-opus-4-1-20250805": "claude-opus-4-1@20250805", + "claude-sonnet-4-5-20250929": "claude-sonnet-4-5@20250929", } const anthropicVersion = "vertex-2023-10-16" @@ -90,7 +91,43 @@ func (a *Adaptor) getRequestUrl(info *relaycommon.RelayInfo, modelName, suffix s } a.AccountCredentials = *adc - if a.RequestMode == RequestModeLlama { + if a.RequestMode == RequestModeGemini { + if region == "global" { + return fmt.Sprintf( + "https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/google/models/%s:%s", + adc.ProjectID, + modelName, + suffix, + ), nil + } else { + return fmt.Sprintf( + "https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:%s", + region, + adc.ProjectID, + region, + modelName, + suffix, + ), nil + } + } else if a.RequestMode == RequestModeClaude { + if region == "global" { + return fmt.Sprintf( + "https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/anthropic/models/%s:%s", + adc.ProjectID, + modelName, + suffix, + ), nil + } else { + return fmt.Sprintf( + "https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/anthropic/models/%s:%s", + region, + adc.ProjectID, + region, + modelName, + suffix, + ), nil + } + } else if a.RequestMode == RequestModeLlama { return fmt.Sprintf( "https://%s-aiplatform.googleapis.com/v1beta1/projects/%s/locations/%s/endpoints/openapi/chat/completions", region, @@ -98,42 +135,33 @@ func (a *Adaptor) getRequestUrl(info *relaycommon.RelayInfo, modelName, suffix s region, ), nil } - - if region == "global" { - return fmt.Sprintf( - "https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/google/models/%s:%s", - adc.ProjectID, - modelName, - suffix, - ), nil - } else { - return fmt.Sprintf( - "https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:%s", - region, - adc.ProjectID, - region, - modelName, - suffix, - ), nil - } } else { + var keyPrefix string + if strings.HasSuffix(suffix, "?alt=sse") { + keyPrefix = "&" + } else { + keyPrefix = "?" + } if region == "global" { return fmt.Sprintf( - "https://aiplatform.googleapis.com/v1/publishers/google/models/%s:%s?key=%s", + "https://aiplatform.googleapis.com/v1/publishers/google/models/%s:%s%skey=%s", modelName, suffix, + keyPrefix, info.ApiKey, ), nil } else { return fmt.Sprintf( - "https://%s-aiplatform.googleapis.com/v1/publishers/google/models/%s:%s?key=%s", + "https://%s-aiplatform.googleapis.com/v1/publishers/google/models/%s:%s%skey=%s", region, modelName, suffix, + keyPrefix, info.ApiKey, ), nil } } + return "", errors.New("unsupported request mode") } func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { diff --git a/relay/channel/volcengine/adaptor.go b/relay/channel/volcengine/adaptor.go index 21d6e1705..234ab4c99 100644 --- a/relay/channel/volcengine/adaptor.go +++ b/relay/channel/volcengine/adaptor.go @@ -195,21 +195,29 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { baseUrl = channelconstant.ChannelBaseURLs[channelconstant.ChannelTypeVolcEngine] } - switch info.RelayMode { - case constant.RelayModeChatCompletions: + switch info.RelayFormat { + case types.RelayFormatClaude: if strings.HasPrefix(info.UpstreamModelName, "bot") { return fmt.Sprintf("%s/api/v3/bots/chat/completions", baseUrl), nil } return fmt.Sprintf("%s/api/v3/chat/completions", baseUrl), nil - case constant.RelayModeEmbeddings: - return fmt.Sprintf("%s/api/v3/embeddings", baseUrl), nil - case constant.RelayModeImagesGenerations: - return fmt.Sprintf("%s/api/v3/images/generations", baseUrl), nil - case constant.RelayModeImagesEdits: - return fmt.Sprintf("%s/api/v3/images/edits", baseUrl), nil - case constant.RelayModeRerank: - return fmt.Sprintf("%s/api/v3/rerank", baseUrl), nil default: + switch info.RelayMode { + case constant.RelayModeChatCompletions: + if strings.HasPrefix(info.UpstreamModelName, "bot") { + return fmt.Sprintf("%s/api/v3/bots/chat/completions", baseUrl), nil + } + return fmt.Sprintf("%s/api/v3/chat/completions", baseUrl), nil + case constant.RelayModeEmbeddings: + return fmt.Sprintf("%s/api/v3/embeddings", baseUrl), nil + case constant.RelayModeImagesGenerations: + return fmt.Sprintf("%s/api/v3/images/generations", baseUrl), nil + case constant.RelayModeImagesEdits: + return fmt.Sprintf("%s/api/v3/images/edits", baseUrl), nil + case constant.RelayModeRerank: + return fmt.Sprintf("%s/api/v3/rerank", baseUrl), nil + default: + } } return "", fmt.Errorf("unsupported relay mode: %d", info.RelayMode) } diff --git a/relay/claude_handler.go b/relay/claude_handler.go index 59d12abe4..3a739785f 100644 --- a/relay/claude_handler.go +++ b/relay/claude_handler.go @@ -112,6 +112,12 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } + // remove disabled fields for Claude API + jsonData, err = relaycommon.RemoveDisabledFields(jsonData, info.ChannelOtherSettings) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } + // apply param override if len(info.ParamOverride) > 0 { jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride) diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index 99925dc5d..cc860abd9 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -105,7 +105,8 @@ type RelayInfo struct { UserQuota int RelayFormat types.RelayFormat SendResponseCount int - FinalPreConsumedQuota int // 最终预消耗的配额 + FinalPreConsumedQuota int // 最终预消耗的配额 + IsClaudeBetaQuery bool // /v1/messages?beta=true PriceData types.PriceData @@ -279,6 +280,9 @@ func GenRelayInfoClaude(c *gin.Context, request dto.Request) *RelayInfo { info.ClaudeConvertInfo = &ClaudeConvertInfo{ LastMessagesType: LastMessageTypeNone, } + if c.Query("beta") == "true" { + info.IsClaudeBetaQuery = true + } return info } @@ -496,10 +500,52 @@ func (t TaskSubmitReq) HasImage() bool { } type TaskInfo struct { - Code int `json:"code"` - TaskID string `json:"task_id"` - Status string `json:"status"` - Reason string `json:"reason,omitempty"` - Url string `json:"url,omitempty"` - Progress string `json:"progress,omitempty"` + Code int `json:"code"` + TaskID string `json:"task_id"` + Status string `json:"status"` + Reason string `json:"reason,omitempty"` + Url string `json:"url,omitempty"` + Progress string `json:"progress,omitempty"` + CompletionTokens int `json:"completion_tokens,omitempty"` // 用于按倍率计费 + TotalTokens int `json:"total_tokens,omitempty"` // 用于按倍率计费 +} + +// RemoveDisabledFields 从请求 JSON 数据中移除渠道设置中禁用的字段 +// service_tier: 服务层级字段,可能导致额外计费(OpenAI、Claude、Responses API 支持) +// store: 数据存储授权字段,涉及用户隐私(仅 OpenAI、Responses API 支持,默认允许透传,禁用后可能导致 Codex 无法使用) +// safety_identifier: 安全标识符,用于向 OpenAI 报告违规用户(仅 OpenAI 支持,涉及用户隐私) +func RemoveDisabledFields(jsonData []byte, channelOtherSettings dto.ChannelOtherSettings) ([]byte, error) { + var data map[string]interface{} + if err := common.Unmarshal(jsonData, &data); err != nil { + common.SysError("RemoveDisabledFields Unmarshal error :" + err.Error()) + return jsonData, nil + } + + // 默认移除 service_tier,除非明确允许(避免额外计费风险) + if !channelOtherSettings.AllowServiceTier { + if _, exists := data["service_tier"]; exists { + delete(data, "service_tier") + } + } + + // 默认允许 store 透传,除非明确禁用(禁用可能影响 Codex 使用) + if channelOtherSettings.DisableStore { + if _, exists := data["store"]; exists { + delete(data, "store") + } + } + + // 默认移除 safety_identifier,除非明确允许(保护用户隐私,避免向 OpenAI 报告用户信息) + if !channelOtherSettings.AllowSafetyIdentifier { + if _, exists := data["safety_identifier"]; exists { + delete(data, "safety_identifier") + } + } + + jsonDataAfter, err := common.Marshal(data) + if err != nil { + common.SysError("RemoveDisabledFields Marshal error :" + err.Error()) + return jsonData, nil + } + return jsonDataAfter, nil } diff --git a/relay/compatible_handler.go b/relay/compatible_handler.go index 38b820f72..a3ddf6d49 100644 --- a/relay/compatible_handler.go +++ b/relay/compatible_handler.go @@ -135,6 +135,12 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types return types.NewError(err, types.ErrorCodeJsonMarshalFailed, types.ErrOptionWithSkipRetry()) } + // remove disabled fields for OpenAI API + jsonData, err = relaycommon.RemoveDisabledFields(jsonData, info.ChannelOtherSettings) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } + // apply param override if len(info.ParamOverride) > 0 { jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride) diff --git a/relay/relay_adaptor.go b/relay/relay_adaptor.go index 0c271210b..c8fd51a11 100644 --- a/relay/relay_adaptor.go +++ b/relay/relay_adaptor.go @@ -1,6 +1,7 @@ package relay import ( + "github.com/gin-gonic/gin" "one-api/constant" "one-api/relay/channel" "one-api/relay/channel/ali" @@ -24,6 +25,8 @@ import ( "one-api/relay/channel/palm" "one-api/relay/channel/perplexity" "one-api/relay/channel/siliconflow" + "one-api/relay/channel/submodel" + taskdoubao "one-api/relay/channel/task/doubao" taskjimeng "one-api/relay/channel/task/jimeng" "one-api/relay/channel/task/kling" "one-api/relay/channel/task/suno" @@ -37,8 +40,6 @@ import ( "one-api/relay/channel/zhipu" "one-api/relay/channel/zhipu_4v" "strconv" - - "github.com/gin-gonic/gin" ) func GetAdaptor(apiType int) channel.Adaptor { @@ -103,6 +104,8 @@ func GetAdaptor(apiType int) channel.Adaptor { return &jimeng.Adaptor{} case constant.APITypeMoonshot: return &moonshot.Adaptor{} // Moonshot uses Claude API + case constant.APITypeSubmodel: + return &submodel.Adaptor{} } return nil } @@ -132,6 +135,8 @@ func GetTaskAdaptor(platform constant.TaskPlatform) channel.TaskAdaptor { return &taskvertex.TaskAdaptor{} case constant.ChannelTypeVidu: return &taskVidu.TaskAdaptor{} + case constant.ChannelTypeDoubaoVideo: + return &taskdoubao.TaskAdaptor{} } } return nil diff --git a/relay/responses_handler.go b/relay/responses_handler.go index 0c57a303f..6958f96ef 100644 --- a/relay/responses_handler.go +++ b/relay/responses_handler.go @@ -56,6 +56,13 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError * if err != nil { return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } + + // remove disabled fields for OpenAI Responses API + jsonData, err = relaycommon.RemoveDisabledFields(jsonData, info.ChannelOtherSettings) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } + // apply param override if len(info.ParamOverride) > 0 { jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride) diff --git a/router/api-router.go b/router/api-router.go index e16d06628..d29615914 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -40,11 +40,17 @@ func SetApiRouter(router *gin.Engine) { apiRouter.POST("/stripe/webhook", controller.StripeWebhook) + // Universal secure verification routes + apiRouter.POST("/verify", middleware.UserAuth(), middleware.CriticalRateLimit(), controller.UniversalVerify) + apiRouter.GET("/verify/status", middleware.UserAuth(), controller.GetVerificationStatus) + userRoute := apiRouter.Group("/user") { userRoute.POST("/register", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.Register) userRoute.POST("/login", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.Login) userRoute.POST("/login/2fa", middleware.CriticalRateLimit(), controller.Verify2FALogin) + userRoute.POST("/passkey/login/begin", middleware.CriticalRateLimit(), controller.PasskeyLoginBegin) + userRoute.POST("/passkey/login/finish", middleware.CriticalRateLimit(), controller.PasskeyLoginFinish) //userRoute.POST("/tokenlog", middleware.CriticalRateLimit(), controller.TokenLog) userRoute.GET("/logout", controller.Logout) userRoute.GET("/epay/notify", controller.EpayNotify) @@ -59,6 +65,12 @@ func SetApiRouter(router *gin.Engine) { selfRoute.PUT("/self", controller.UpdateSelf) selfRoute.DELETE("/self", controller.DeleteSelf) selfRoute.GET("/token", controller.GenerateAccessToken) + selfRoute.GET("/passkey", controller.PasskeyStatus) + selfRoute.POST("/passkey/register/begin", controller.PasskeyRegisterBegin) + selfRoute.POST("/passkey/register/finish", controller.PasskeyRegisterFinish) + selfRoute.POST("/passkey/verify/begin", controller.PasskeyVerifyBegin) + selfRoute.POST("/passkey/verify/finish", controller.PasskeyVerifyFinish) + selfRoute.DELETE("/passkey", controller.PasskeyDelete) selfRoute.GET("/aff", controller.GetAffCode) selfRoute.GET("/topup/info", controller.GetTopUpInfo) selfRoute.POST("/topup", middleware.CriticalRateLimit(), controller.TopUp) @@ -87,6 +99,7 @@ func SetApiRouter(router *gin.Engine) { adminRoute.POST("/manage", controller.ManageUser) adminRoute.PUT("/", controller.UpdateUser) adminRoute.DELETE("/:id", controller.DeleteUser) + adminRoute.DELETE("/:id/reset_passkey", controller.AdminResetPasskey) // Admin 2FA routes adminRoute.GET("/2fa/stats", controller.Admin2FAStats) @@ -115,7 +128,7 @@ func SetApiRouter(router *gin.Engine) { channelRoute.GET("/models", controller.ChannelListModels) channelRoute.GET("/models_enabled", controller.EnabledListModels) channelRoute.GET("/:id", controller.GetChannel) - channelRoute.POST("/:id/key", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.GetChannelKey) + channelRoute.POST("/:id/key", middleware.CriticalRateLimit(), middleware.DisableCache(), middleware.SecureVerificationRequired(), controller.GetChannelKey) channelRoute.GET("/test", controller.TestAllChannels) channelRoute.GET("/test/:id", controller.TestChannel) channelRoute.GET("/update_balance", controller.UpdateAllChannelsBalance) diff --git a/service/passkey/service.go b/service/passkey/service.go new file mode 100644 index 000000000..dc8da0ccc --- /dev/null +++ b/service/passkey/service.go @@ -0,0 +1,177 @@ +package passkey + +import ( + "errors" + "fmt" + "net" + "net/http" + "net/url" + "strings" + "time" + + "one-api/common" + "one-api/setting/system_setting" + + "github.com/go-webauthn/webauthn/protocol" + webauthn "github.com/go-webauthn/webauthn/webauthn" +) + +const ( + RegistrationSessionKey = "passkey_registration_session" + LoginSessionKey = "passkey_login_session" + VerifySessionKey = "passkey_verify_session" +) + +// BuildWebAuthn constructs a WebAuthn instance using the current passkey settings and request context. +func BuildWebAuthn(r *http.Request) (*webauthn.WebAuthn, error) { + settings := system_setting.GetPasskeySettings() + if settings == nil { + return nil, errors.New("未找到 Passkey 设置") + } + + displayName := strings.TrimSpace(settings.RPDisplayName) + if displayName == "" { + displayName = common.SystemName + } + + origins, err := resolveOrigins(r, settings) + if err != nil { + return nil, err + } + + rpID, err := resolveRPID(r, settings, origins) + if err != nil { + return nil, err + } + + selection := protocol.AuthenticatorSelection{ + ResidentKey: protocol.ResidentKeyRequirementRequired, + RequireResidentKey: protocol.ResidentKeyRequired(), + UserVerification: protocol.UserVerificationRequirement(settings.UserVerification), + } + if selection.UserVerification == "" { + selection.UserVerification = protocol.VerificationPreferred + } + if attachment := strings.TrimSpace(settings.AttachmentPreference); attachment != "" { + selection.AuthenticatorAttachment = protocol.AuthenticatorAttachment(attachment) + } + + config := &webauthn.Config{ + RPID: rpID, + RPDisplayName: displayName, + RPOrigins: origins, + AuthenticatorSelection: selection, + Debug: common.DebugEnabled, + Timeouts: webauthn.TimeoutsConfig{ + Login: webauthn.TimeoutConfig{ + Enforce: true, + Timeout: 2 * time.Minute, + TimeoutUVD: 2 * time.Minute, + }, + Registration: webauthn.TimeoutConfig{ + Enforce: true, + Timeout: 2 * time.Minute, + TimeoutUVD: 2 * time.Minute, + }, + }, + } + + return webauthn.New(config) +} + +func resolveOrigins(r *http.Request, settings *system_setting.PasskeySettings) ([]string, error) { + originsStr := strings.TrimSpace(settings.Origins) + if originsStr != "" { + originList := strings.Split(originsStr, ",") + origins := make([]string, 0, len(originList)) + for _, origin := range originList { + trimmed := strings.TrimSpace(origin) + if trimmed == "" { + continue + } + if !settings.AllowInsecureOrigin && strings.HasPrefix(strings.ToLower(trimmed), "http://") { + return nil, fmt.Errorf("Passkey 不允许使用不安全的 Origin: %s", trimmed) + } + origins = append(origins, trimmed) + } + if len(origins) == 0 { + // 如果配置了Origins但过滤后为空,使用自动推导 + goto autoDetect + } + return origins, nil + } + +autoDetect: + scheme := detectScheme(r) + if scheme == "http" && !settings.AllowInsecureOrigin && r.Host != "localhost" && r.Host != "127.0.0.1" && !strings.HasPrefix(r.Host, "127.0.0.1:") && !strings.HasPrefix(r.Host, "localhost:") { + return nil, fmt.Errorf("Passkey 仅支持 HTTPS,当前访问: %s://%s,请在 Passkey 设置中允许不安全 Origin 或配置 HTTPS", scheme, r.Host) + } + // 优先使用请求的完整Host(包含端口) + host := r.Host + + // 如果无法从请求获取Host,尝试从ServerAddress获取 + if host == "" && system_setting.ServerAddress != "" { + if parsed, err := url.Parse(system_setting.ServerAddress); err == nil && parsed.Host != "" { + host = parsed.Host + if scheme == "" && parsed.Scheme != "" { + scheme = parsed.Scheme + } + } + } + if host == "" { + return nil, fmt.Errorf("无法确定 Passkey 的 Origin,请在系统设置或 Passkey 设置中指定。当前 Host: '%s', ServerAddress: '%s'", r.Host, system_setting.ServerAddress) + } + if scheme == "" { + scheme = "https" + } + origin := fmt.Sprintf("%s://%s", scheme, host) + return []string{origin}, nil +} + +func resolveRPID(r *http.Request, settings *system_setting.PasskeySettings, origins []string) (string, error) { + rpID := strings.TrimSpace(settings.RPID) + if rpID != "" { + return hostWithoutPort(rpID), nil + } + if len(origins) == 0 { + return "", errors.New("Passkey 未配置 Origin,无法推导 RPID") + } + parsed, err := url.Parse(origins[0]) + if err != nil { + return "", fmt.Errorf("无法解析 Passkey Origin: %w", err) + } + return hostWithoutPort(parsed.Host), nil +} + +func hostWithoutPort(host string) string { + host = strings.TrimSpace(host) + if host == "" { + return "" + } + if strings.Contains(host, ":") { + if host, _, err := net.SplitHostPort(host); err == nil { + return host + } + } + return host +} + +func detectScheme(r *http.Request) string { + if r == nil { + return "" + } + if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" { + parts := strings.Split(proto, ",") + return strings.ToLower(strings.TrimSpace(parts[0])) + } + if r.TLS != nil { + return "https" + } + if r.URL != nil && r.URL.Scheme != "" { + return strings.ToLower(r.URL.Scheme) + } + if r.Header.Get("X-Forwarded-Protocol") != "" { + return strings.ToLower(strings.TrimSpace(r.Header.Get("X-Forwarded-Protocol"))) + } + return "http" +} diff --git a/service/passkey/session.go b/service/passkey/session.go new file mode 100644 index 000000000..15e619326 --- /dev/null +++ b/service/passkey/session.go @@ -0,0 +1,50 @@ +package passkey + +import ( + "encoding/json" + "errors" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + webauthn "github.com/go-webauthn/webauthn/webauthn" +) + +var errSessionNotFound = errors.New("Passkey 会话不存在或已过期") + +func SaveSessionData(c *gin.Context, key string, data *webauthn.SessionData) error { + session := sessions.Default(c) + if data == nil { + session.Delete(key) + return session.Save() + } + payload, err := json.Marshal(data) + if err != nil { + return err + } + session.Set(key, string(payload)) + return session.Save() +} + +func PopSessionData(c *gin.Context, key string) (*webauthn.SessionData, error) { + session := sessions.Default(c) + raw := session.Get(key) + if raw == nil { + return nil, errSessionNotFound + } + session.Delete(key) + _ = session.Save() + var data webauthn.SessionData + switch value := raw.(type) { + case string: + if err := json.Unmarshal([]byte(value), &data); err != nil { + return nil, err + } + case []byte: + if err := json.Unmarshal(value, &data); err != nil { + return nil, err + } + default: + return nil, errors.New("Passkey 会话格式无效") + } + return &data, nil +} diff --git a/service/passkey/user.go b/service/passkey/user.go new file mode 100644 index 000000000..8b8c559f0 --- /dev/null +++ b/service/passkey/user.go @@ -0,0 +1,71 @@ +package passkey + +import ( + "fmt" + "strconv" + "strings" + + "one-api/model" + + webauthn "github.com/go-webauthn/webauthn/webauthn" +) + +type WebAuthnUser struct { + user *model.User + credential *model.PasskeyCredential +} + +func NewWebAuthnUser(user *model.User, credential *model.PasskeyCredential) *WebAuthnUser { + return &WebAuthnUser{user: user, credential: credential} +} + +func (u *WebAuthnUser) WebAuthnID() []byte { + if u == nil || u.user == nil { + return nil + } + return []byte(strconv.Itoa(u.user.Id)) +} + +func (u *WebAuthnUser) WebAuthnName() string { + if u == nil || u.user == nil { + return "" + } + name := strings.TrimSpace(u.user.Username) + if name == "" { + return fmt.Sprintf("user-%d", u.user.Id) + } + return name +} + +func (u *WebAuthnUser) WebAuthnDisplayName() string { + if u == nil || u.user == nil { + return "" + } + display := strings.TrimSpace(u.user.DisplayName) + if display != "" { + return display + } + return u.WebAuthnName() +} + +func (u *WebAuthnUser) WebAuthnCredentials() []webauthn.Credential { + if u == nil || u.credential == nil { + return nil + } + cred := u.credential.ToWebAuthnCredential() + return []webauthn.Credential{cred} +} + +func (u *WebAuthnUser) ModelUser() *model.User { + if u == nil { + return nil + } + return u.user +} + +func (u *WebAuthnUser) PasskeyCredential() *model.PasskeyCredential { + if u == nil { + return nil + } + return u.credential +} diff --git a/service/quota.go b/service/quota.go index 12017e11e..43c4024ae 100644 --- a/service/quota.go +++ b/service/quota.go @@ -549,8 +549,11 @@ func checkAndSendQuotaNotify(relayInfo *relaycommon.RelayInfo, quota int, preCon // Bark推送使用简短文本,不支持HTML content = "{{value}},剩余额度:{{value}},请及时充值" values = []interface{}{prompt, logger.FormatQuota(relayInfo.UserQuota)} + } else if notifyType == dto.NotifyTypeGotify { + content = "{{value}},当前剩余额度为 {{value}},请及时充值。" + values = []interface{}{prompt, logger.FormatQuota(relayInfo.UserQuota)} } else { - // 默认内容格式,适用于Email和Webhook + // 默认内容格式,适用于Email和Webhook(支持HTML) content = "{{value}},当前剩余额度为 {{value}},为了不影响您的使用,请及时充值。
充值链接:{{value}}" values = []interface{}{prompt, logger.FormatQuota(relayInfo.UserQuota), topUpLink, topUpLink} } diff --git a/service/user_notify.go b/service/user_notify.go index fba12d9db..0f92e7d75 100644 --- a/service/user_notify.go +++ b/service/user_notify.go @@ -1,6 +1,8 @@ package service import ( + "bytes" + "encoding/json" "fmt" "net/http" "net/url" @@ -37,13 +39,16 @@ func NotifyUser(userId int, userEmail string, userSetting dto.UserSetting, data switch notifyType { case dto.NotifyTypeEmail: - // check setting email - userEmail = userSetting.NotificationEmail - if userEmail == "" { + // 优先使用设置中的通知邮箱,如果为空则使用用户的默认邮箱 + emailToUse := userSetting.NotificationEmail + if emailToUse == "" { + emailToUse = userEmail + } + if emailToUse == "" { common.SysLog(fmt.Sprintf("user %d has no email, skip sending email", userId)) return nil } - return sendEmailNotify(userEmail, data) + return sendEmailNotify(emailToUse, data) case dto.NotifyTypeWebhook: webhookURLStr := userSetting.WebhookUrl if webhookURLStr == "" { @@ -61,6 +66,14 @@ func NotifyUser(userId int, userEmail string, userSetting dto.UserSetting, data return nil } return sendBarkNotify(barkURL, data) + case dto.NotifyTypeGotify: + gotifyUrl := userSetting.GotifyUrl + gotifyToken := userSetting.GotifyToken + if gotifyUrl == "" || gotifyToken == "" { + common.SysLog(fmt.Sprintf("user %d has no gotify url or token, skip sending gotify", userId)) + return nil + } + return sendGotifyNotify(gotifyUrl, gotifyToken, userSetting.GotifyPriority, data) } return nil } @@ -144,3 +157,98 @@ func sendBarkNotify(barkURL string, data dto.Notify) error { return nil } + +func sendGotifyNotify(gotifyUrl string, gotifyToken string, priority int, data dto.Notify) error { + // 处理占位符 + content := data.Content + for _, value := range data.Values { + content = strings.Replace(content, dto.ContentValueParam, fmt.Sprintf("%v", value), 1) + } + + // 构建完整的 Gotify API URL + // 确保 URL 以 /message 结尾 + finalURL := strings.TrimSuffix(gotifyUrl, "/") + "/message?token=" + url.QueryEscape(gotifyToken) + + // Gotify优先级范围0-10,如果超出范围则使用默认值5 + if priority < 0 || priority > 10 { + priority = 5 + } + + // 构建 JSON payload + type GotifyMessage struct { + Title string `json:"title"` + Message string `json:"message"` + Priority int `json:"priority"` + } + + payload := GotifyMessage{ + Title: data.Title, + Message: content, + Priority: priority, + } + + // 序列化为 JSON + payloadBytes, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal gotify payload: %v", err) + } + + var req *http.Request + var resp *http.Response + + if system_setting.EnableWorker() { + // 使用worker发送请求 + workerReq := &WorkerRequest{ + URL: finalURL, + Key: system_setting.WorkerValidKey, + Method: http.MethodPost, + Headers: map[string]string{ + "Content-Type": "application/json; charset=utf-8", + "User-Agent": "OneAPI-Gotify-Notify/1.0", + }, + Body: payloadBytes, + } + + resp, err = DoWorkerRequest(workerReq) + if err != nil { + return fmt.Errorf("failed to send gotify request through worker: %v", err) + } + defer resp.Body.Close() + + // 检查响应状态 + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("gotify request failed with status code: %d", resp.StatusCode) + } + } else { + // SSRF防护:验证Gotify URL(非Worker模式) + fetchSetting := system_setting.GetFetchSetting() + if err := common.ValidateURLWithFetchSetting(finalURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil { + return fmt.Errorf("request reject: %v", err) + } + + // 直接发送请求 + req, err = http.NewRequest(http.MethodPost, finalURL, bytes.NewBuffer(payloadBytes)) + if err != nil { + return fmt.Errorf("failed to create gotify request: %v", err) + } + + // 设置请求头 + req.Header.Set("Content-Type", "application/json; charset=utf-8") + req.Header.Set("User-Agent", "NewAPI-Gotify-Notify/1.0") + + // 发送请求 + client := GetHttpClient() + resp, err = client.Do(req) + if err != nil { + return fmt.Errorf("failed to send gotify request: %v", err) + } + defer resp.Body.Close() + + // 检查响应状态 + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("gotify request failed with status code: %d", resp.StatusCode) + } + } + + return nil +} diff --git a/setting/operation_setting/tools.go b/setting/operation_setting/tools.go index 5b89d6fec..adb76bfc0 100644 --- a/setting/operation_setting/tools.go +++ b/setting/operation_setting/tools.go @@ -29,6 +29,7 @@ const ( Gemini25FlashLitePreviewInputAudioPrice = 0.50 Gemini25FlashNativeAudioInputAudioPrice = 3.00 Gemini20FlashInputAudioPrice = 0.70 + GeminiRoboticsER15InputAudioPrice = 1.00 ) const ( @@ -74,6 +75,8 @@ func GetGeminiInputAudioPricePerMillionTokens(modelName string) float64 { return Gemini25FlashProductionInputAudioPrice } else if strings.HasPrefix(modelName, "gemini-2.0-flash") { return Gemini20FlashInputAudioPrice + } else if strings.HasPrefix(modelName, "gemini-robotics-er-1.5") { + return GeminiRoboticsER15InputAudioPrice } return 0 } diff --git a/setting/ratio_setting/cache_ratio.go b/setting/ratio_setting/cache_ratio.go index 5993cdeeb..8e4b227a6 100644 --- a/setting/ratio_setting/cache_ratio.go +++ b/setting/ratio_setting/cache_ratio.go @@ -52,6 +52,8 @@ var defaultCacheRatio = map[string]float64{ "claude-opus-4-20250514-thinking": 0.1, "claude-opus-4-1-20250805": 0.1, "claude-opus-4-1-20250805-thinking": 0.1, + "claude-sonnet-4-5-20250929": 0.1, + "claude-sonnet-4-5-20250929-thinking": 0.1, } var defaultCreateCacheRatio = map[string]float64{ @@ -69,6 +71,8 @@ var defaultCreateCacheRatio = map[string]float64{ "claude-opus-4-20250514-thinking": 1.25, "claude-opus-4-1-20250805": 1.25, "claude-opus-4-1-20250805-thinking": 1.25, + "claude-sonnet-4-5-20250929": 1.25, + "claude-sonnet-4-5-20250929-thinking": 1.25, } //var defaultCreateCacheRatio = map[string]float64{} diff --git a/setting/ratio_setting/model_ratio.go b/setting/ratio_setting/model_ratio.go index 887c5bd50..5e55576fd 100644 --- a/setting/ratio_setting/model_ratio.go +++ b/setting/ratio_setting/model_ratio.go @@ -141,6 +141,7 @@ var defaultModelRatio = map[string]float64{ "claude-3-7-sonnet-20250219": 1.5, "claude-3-7-sonnet-20250219-thinking": 1.5, "claude-sonnet-4-20250514": 1.5, + "claude-sonnet-4-5-20250929": 1.5, "claude-3-opus-20240229": 7.5, // $15 / 1M tokens "claude-opus-4-20250514": 7.5, "claude-opus-4-1-20250805": 7.5, @@ -178,6 +179,7 @@ var defaultModelRatio = map[string]float64{ "gemini-2.5-flash-lite-preview-thinking-*": 0.05, "gemini-2.5-flash-lite-preview-06-17": 0.05, "gemini-2.5-flash": 0.15, + "gemini-robotics-er-1.5-preview": 0.15, "gemini-embedding-001": 0.075, "text-embedding-004": 0.001, "chatglm_turbo": 0.3572, // ¥0.005 / 1k tokens @@ -251,6 +253,17 @@ var defaultModelRatio = map[string]float64{ "grok-vision-beta": 2.5, "grok-3-fast-beta": 2.5, "grok-3-mini-fast-beta": 0.3, + // submodel + "NousResearch/Hermes-4-405B-FP8": 0.8, + "Qwen/Qwen3-235B-A22B-Thinking-2507": 0.6, + "Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8": 0.8, + "Qwen/Qwen3-235B-A22B-Instruct-2507": 0.3, + "zai-org/GLM-4.5-FP8": 0.8, + "openai/gpt-oss-120b": 0.5, + "deepseek-ai/DeepSeek-R1-0528": 0.8, + "deepseek-ai/DeepSeek-R1": 0.8, + "deepseek-ai/DeepSeek-V3-0324": 0.8, + "deepseek-ai/DeepSeek-V3.1": 0.8, } var defaultModelPrice = map[string]float64{ @@ -575,6 +588,8 @@ func getHardcodedCompletionModelRatio(name string) (float64, bool) { return 4, false } return 2.5 / 0.3, false + } else if strings.HasPrefix(name, "gemini-robotics-er-1.5") { + return 2.5 / 0.3, false } return 4, false } diff --git a/setting/system_setting/passkey.go b/setting/system_setting/passkey.go new file mode 100644 index 000000000..a0766a67b --- /dev/null +++ b/setting/system_setting/passkey.go @@ -0,0 +1,49 @@ +package system_setting + +import ( + "net/url" + "one-api/common" + "one-api/setting/config" + "strings" +) + +type PasskeySettings struct { + Enabled bool `json:"enabled"` + RPDisplayName string `json:"rp_display_name"` + RPID string `json:"rp_id"` + Origins string `json:"origins"` + AllowInsecureOrigin bool `json:"allow_insecure_origin"` + UserVerification string `json:"user_verification"` + AttachmentPreference string `json:"attachment_preference"` +} + +var defaultPasskeySettings = PasskeySettings{ + Enabled: false, + RPDisplayName: common.SystemName, + RPID: "", + Origins: "", + AllowInsecureOrigin: false, + UserVerification: "preferred", + AttachmentPreference: "", +} + +func init() { + config.GlobalConfig.Register("passkey", &defaultPasskeySettings) +} + +func GetPasskeySettings() *PasskeySettings { + if defaultPasskeySettings.RPID == "" && ServerAddress != "" { + // 从ServerAddress提取域名作为RPID + // ServerAddress可能是 "https://newapi.pro" 这种格式 + serverAddr := strings.TrimSpace(ServerAddress) + if parsed, err := url.Parse(serverAddr); err == nil && parsed.Host != "" { + defaultPasskeySettings.RPID = parsed.Host + } else { + defaultPasskeySettings.RPID = serverAddr + } + } + if defaultPasskeySettings.Origins == "" || defaultPasskeySettings.Origins == "[]" { + defaultPasskeySettings.Origins = ServerAddress + } + return &defaultPasskeySettings +} diff --git a/web/src/components/auth/LoginForm.jsx b/web/src/components/auth/LoginForm.jsx index 32087ab02..828e71786 100644 --- a/web/src/components/auth/LoginForm.jsx +++ b/web/src/components/auth/LoginForm.jsx @@ -32,6 +32,9 @@ import { onGitHubOAuthClicked, onOIDCClicked, onLinuxDOOAuthClicked, + prepareCredentialRequestOptions, + buildAssertionResult, + isPasskeySupported, } from '../../helpers'; import Turnstile from 'react-turnstile'; import { Button, Card, Divider, Form, Icon, Modal } from '@douyinfe/semi-ui'; @@ -39,7 +42,7 @@ import Title from '@douyinfe/semi-ui/lib/es/typography/title'; import Text from '@douyinfe/semi-ui/lib/es/typography/text'; import TelegramLoginButton from 'react-telegram-login'; -import { IconGithubLogo, IconMail, IconLock } from '@douyinfe/semi-icons'; +import { IconGithubLogo, IconMail, IconLock, IconKey } from '@douyinfe/semi-icons'; import OIDCIcon from '../common/logo/OIDCIcon'; import WeChatIcon from '../common/logo/WeChatIcon'; import LinuxDoIcon from '../common/logo/LinuxDoIcon'; @@ -74,6 +77,8 @@ const LoginForm = () => { useState(false); const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false); const [showTwoFA, setShowTwoFA] = useState(false); + const [passkeySupported, setPasskeySupported] = useState(false); + const [passkeyLoading, setPasskeyLoading] = useState(false); const logo = getLogo(); const systemName = getSystemName(); @@ -95,6 +100,12 @@ const LoginForm = () => { } }, [status]); + useEffect(() => { + isPasskeySupported() + .then(setPasskeySupported) + .catch(() => setPasskeySupported(false)); + }, []); + useEffect(() => { if (searchParams.get('expired')) { showError(t('未登录或登录已过期,请重新登录')); @@ -266,6 +277,55 @@ const LoginForm = () => { setEmailLoginLoading(false); }; + const handlePasskeyLogin = async () => { + if (!passkeySupported) { + showInfo('当前环境无法使用 Passkey 登录'); + return; + } + if (!window.PublicKeyCredential) { + showInfo('当前浏览器不支持 Passkey'); + return; + } + + setPasskeyLoading(true); + try { + const beginRes = await API.post('/api/user/passkey/login/begin'); + const { success, message, data } = beginRes.data; + if (!success) { + showError(message || '无法发起 Passkey 登录'); + return; + } + + const publicKeyOptions = prepareCredentialRequestOptions(data?.options || data?.publicKey || data); + const assertion = await navigator.credentials.get({ publicKey: publicKeyOptions }); + const payload = buildAssertionResult(assertion); + if (!payload) { + showError('Passkey 验证失败,请重试'); + return; + } + + const finishRes = await API.post('/api/user/passkey/login/finish', payload); + const finish = finishRes.data; + if (finish.success) { + userDispatch({ type: 'login', payload: finish.data }); + setUserData(finish.data); + updateAPI(); + showSuccess('登录成功!'); + navigate('/console'); + } else { + showError(finish.message || 'Passkey 登录失败,请重试'); + } + } catch (error) { + if (error?.name === 'AbortError') { + showInfo('已取消 Passkey 登录'); + } else { + showError('Passkey 登录失败,请重试'); + } + } finally { + setPasskeyLoading(false); + } + }; + // 包装的重置密码点击处理 const handleResetPasswordClick = () => { setResetPasswordLoading(true); @@ -385,6 +445,19 @@ const LoginForm = () => {
)} + {status.passkey_login && passkeySupported && ( + + )} + {t('或')} @@ -437,6 +510,18 @@ const LoginForm = () => {
+ {status.passkey_login && passkeySupported && ( + + )}
. + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, Modal } from '@douyinfe/semi-ui'; +import { useSecureVerification } from '../../../hooks/common/useSecureVerification'; +import { createApiCalls } from '../../../services/secureVerification'; +import SecureVerificationModal from '../modals/SecureVerificationModal'; +import ChannelKeyDisplay from '../ui/ChannelKeyDisplay'; + +/** + * 渠道密钥查看组件使用示例 + * 展示如何使用通用安全验证系统 + */ +const ChannelKeyViewExample = ({ channelId }) => { + const { t } = useTranslation(); + const [keyData, setKeyData] = useState(''); + const [showKeyModal, setShowKeyModal] = useState(false); + + // 使用通用安全验证 Hook + const { + isModalVisible, + verificationMethods, + verificationState, + startVerification, + executeVerification, + cancelVerification, + setVerificationCode, + switchVerificationMethod, + } = useSecureVerification({ + onSuccess: (result) => { + // 验证成功后处理结果 + if (result.success && result.data?.key) { + setKeyData(result.data.key); + setShowKeyModal(true); + } + }, + successMessage: t('密钥获取成功'), + }); + + // 开始查看密钥流程 + const handleViewKey = async () => { + const apiCall = createApiCalls.viewChannelKey(channelId); + + await startVerification(apiCall, { + title: t('查看渠道密钥'), + description: t('为了保护账户安全,请验证您的身份。'), + preferredMethod: 'passkey', // 可以指定首选验证方式 + }); + }; + + return ( + <> + {/* 查看密钥按钮 */} + + + {/* 安全验证模态框 */} + + + {/* 密钥显示模态框 */} + setShowKeyModal(false)} + footer={ + + } + width={700} + style={{ maxWidth: '90vw' }} + > + + + + ); +}; + +export default ChannelKeyViewExample; \ No newline at end of file diff --git a/web/src/components/common/modals/SecureVerificationModal.jsx b/web/src/components/common/modals/SecureVerificationModal.jsx new file mode 100644 index 000000000..06f18c7e6 --- /dev/null +++ b/web/src/components/common/modals/SecureVerificationModal.jsx @@ -0,0 +1,285 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Modal, Button, Input, Typography, Tabs, TabPane, Space, Spin } from '@douyinfe/semi-ui'; + +/** + * 通用安全验证模态框组件 + * 配合 useSecureVerification Hook 使用 + * @param {Object} props + * @param {boolean} props.visible - 是否显示模态框 + * @param {Object} props.verificationMethods - 可用的验证方式 + * @param {Object} props.verificationState - 当前验证状态 + * @param {Function} props.onVerify - 验证回调 + * @param {Function} props.onCancel - 取消回调 + * @param {Function} props.onCodeChange - 验证码变化回调 + * @param {Function} props.onMethodSwitch - 验证方式切换回调 + * @param {string} props.title - 模态框标题 + * @param {string} props.description - 验证描述文本 + */ +const SecureVerificationModal = ({ + visible, + verificationMethods, + verificationState, + onVerify, + onCancel, + onCodeChange, + onMethodSwitch, + title, + description, +}) => { + const { t } = useTranslation(); + const [isAnimating, setIsAnimating] = useState(false); + const [verifySuccess, setVerifySuccess] = useState(false); + + const { has2FA, hasPasskey, passkeySupported } = verificationMethods; + const { method, loading, code } = verificationState; + + useEffect(() => { + if (visible) { + setIsAnimating(true); + setVerifySuccess(false); + } else { + setIsAnimating(false); + } + }, [visible]); + + const handleKeyDown = (e) => { + if (e.key === 'Enter' && code.trim() && !loading && method === '2fa') { + onVerify(method, code); + } + if (e.key === 'Escape' && !loading) { + onCancel(); + } + }; + + // 如果用户没有启用任何验证方式 + if (visible && !has2FA && !hasPasskey) { + return ( + {t('确定')} + } + width={500} + style={{ maxWidth: '90vw' }} + > +
+
+ + + +
+ + {t('需要安全验证')} + + + {t('您需要先启用两步验证或 Passkey 才能查看敏感信息。')} + +
+ + {t('请前往个人设置 → 安全设置进行配置。')} + +
+
+ ); + } + + return ( + +
+ {/* 描述信息 */} + {description && ( + + {description} + + )} + + {/* 验证方式选择 */} + + {has2FA && ( + +
+
+ + + + } + style={{ width: '100%' }} + /> +
+ + + {t('从认证器应用中获取验证码,或使用备用码')} + + +
+ + +
+
+
+ )} + + {hasPasskey && passkeySupported && ( + +
+
+
+ + + +
+ + {t('使用 Passkey 验证')} + + + {t('点击验证按钮,使用您的生物特征或安全密钥')} + +
+ +
+ + +
+
+
+ )} +
+
+
+ ); +}; + +export default SecureVerificationModal; \ No newline at end of file diff --git a/web/src/components/layout/Footer.jsx b/web/src/components/layout/Footer.jsx index 5c210fca8..c827a581b 100644 --- a/web/src/components/layout/Footer.jsx +++ b/web/src/components/layout/Footer.jsx @@ -142,14 +142,6 @@ const FooterBar = () => { > Midjourney-Proxy - - chatnio - {
@@ -200,15 +207,6 @@ const FooterBar = () => { > New API - & - - One API -
@@ -223,10 +221,23 @@ const FooterBar = () => { return (
{footer ? ( -
+
+
+
+ {t('设计与开发由')} + + New API + +
+
) : ( customFooter )} diff --git a/web/src/components/layout/PageLayout.jsx b/web/src/components/layout/PageLayout.jsx index f8cdfb0cb..6474501dd 100644 --- a/web/src/components/layout/PageLayout.jsx +++ b/web/src/components/layout/PageLayout.jsx @@ -48,9 +48,19 @@ const PageLayout = () => { const { i18n } = useTranslation(); const location = useLocation(); - const shouldHideFooter = - location.pathname.startsWith('/console') || - location.pathname === '/pricing'; + const cardProPages = [ + '/console/channel', + '/console/log', + '/console/redemption', + '/console/user', + '/console/token', + '/console/midjourney', + '/console/task', + '/console/models', + '/pricing', + ]; + + const shouldHideFooter = cardProPages.includes(location.pathname); const shouldInnerPadding = location.pathname.includes('/console') && diff --git a/web/src/components/layout/SiderBar.jsx b/web/src/components/layout/SiderBar.jsx index 793e48355..39d6d4489 100644 --- a/web/src/components/layout/SiderBar.jsx +++ b/web/src/components/layout/SiderBar.jsx @@ -58,7 +58,7 @@ const SiderBar = ({ onNavigate = () => {} }) => { loading: sidebarLoading, } = useSidebar(); - const showSkeleton = useMinimumLoadingTime(sidebarLoading); + const showSkeleton = useMinimumLoadingTime(sidebarLoading, 200); const [selectedKeys, setSelectedKeys] = useState(['home']); const [chatItems, setChatItems] = useState([]); diff --git a/web/src/components/settings/PersonalSetting.jsx b/web/src/components/settings/PersonalSetting.jsx index 15dfbd973..c9934604c 100644 --- a/web/src/components/settings/PersonalSetting.jsx +++ b/web/src/components/settings/PersonalSetting.jsx @@ -26,6 +26,10 @@ import { showInfo, showSuccess, setStatusData, + prepareCredentialCreationOptions, + buildRegistrationResult, + isPasskeySupported, + setUserData, } from '../../helpers'; import { UserContext } from '../../context/User'; import { Modal } from '@douyinfe/semi-ui'; @@ -66,6 +70,10 @@ const PersonalSetting = () => { const [disableButton, setDisableButton] = useState(false); const [countdown, setCountdown] = useState(30); const [systemToken, setSystemToken] = useState(''); + const [passkeyStatus, setPasskeyStatus] = useState({ enabled: false }); + const [passkeyRegisterLoading, setPasskeyRegisterLoading] = useState(false); + const [passkeyDeleteLoading, setPasskeyDeleteLoading] = useState(false); + const [passkeySupported, setPasskeySupported] = useState(false); const [notificationSettings, setNotificationSettings] = useState({ warningType: 'email', warningThreshold: 100000, @@ -73,6 +81,9 @@ const PersonalSetting = () => { webhookSecret: '', notificationEmail: '', barkUrl: '', + gotifyUrl: '', + gotifyToken: '', + gotifyPriority: 5, acceptUnsetModelRatioModel: false, recordIpLog: false, }); @@ -112,6 +123,10 @@ const PersonalSetting = () => { })(); getUserData(); + + isPasskeySupported() + .then(setPasskeySupported) + .catch(() => setPasskeySupported(false)); }, []); useEffect(() => { @@ -137,6 +152,12 @@ const PersonalSetting = () => { webhookSecret: settings.webhook_secret || '', notificationEmail: settings.notification_email || '', barkUrl: settings.bark_url || '', + gotifyUrl: settings.gotify_url || '', + gotifyToken: settings.gotify_token || '', + gotifyPriority: + settings.gotify_priority !== undefined + ? settings.gotify_priority + : 5, acceptUnsetModelRatioModel: settings.accept_unset_model_ratio_model || false, recordIpLog: settings.record_ip_log || false, @@ -160,11 +181,90 @@ const PersonalSetting = () => { } }; + const loadPasskeyStatus = async () => { + try { + const res = await API.get('/api/user/passkey'); + const { success, data, message } = res.data; + if (success) { + setPasskeyStatus({ + enabled: data?.enabled || false, + last_used_at: data?.last_used_at || null, + backup_eligible: data?.backup_eligible || false, + backup_state: data?.backup_state || false, + }); + } else { + showError(message); + } + } catch (error) { + // 忽略错误,保留默认状态 + } + }; + + const handleRegisterPasskey = async () => { + if (!passkeySupported || !window.PublicKeyCredential) { + showInfo(t('当前设备不支持 Passkey')); + return; + } + setPasskeyRegisterLoading(true); + try { + const beginRes = await API.post('/api/user/passkey/register/begin'); + const { success, message, data } = beginRes.data; + if (!success) { + showError(message || t('无法发起 Passkey 注册')); + return; + } + + const publicKey = prepareCredentialCreationOptions(data?.options || data?.publicKey || data); + const credential = await navigator.credentials.create({ publicKey }); + const payload = buildRegistrationResult(credential); + if (!payload) { + showError(t('Passkey 注册失败,请重试')); + return; + } + + const finishRes = await API.post('/api/user/passkey/register/finish', payload); + if (finishRes.data.success) { + showSuccess(t('Passkey 注册成功')); + await loadPasskeyStatus(); + } else { + showError(finishRes.data.message || t('Passkey 注册失败,请重试')); + } + } catch (error) { + if (error?.name === 'AbortError') { + showInfo(t('已取消 Passkey 注册')); + } else { + showError(t('Passkey 注册失败,请重试')); + } + } finally { + setPasskeyRegisterLoading(false); + } + }; + + const handleRemovePasskey = async () => { + setPasskeyDeleteLoading(true); + try { + const res = await API.delete('/api/user/passkey'); + const { success, message } = res.data; + if (success) { + showSuccess(t('Passkey 已解绑')); + await loadPasskeyStatus(); + } else { + showError(message || t('操作失败,请重试')); + } + } catch (error) { + showError(t('操作失败,请重试')); + } finally { + setPasskeyDeleteLoading(false); + } + }; + const getUserData = async () => { let res = await API.get(`/api/user/self`); const { success, message, data } = res.data; if (success) { userDispatch({ type: 'login', payload: data }); + setUserData(data); + await loadPasskeyStatus(); } else { showError(message); } @@ -315,6 +415,12 @@ const PersonalSetting = () => { webhook_secret: notificationSettings.webhookSecret, notification_email: notificationSettings.notificationEmail, bark_url: notificationSettings.barkUrl, + gotify_url: notificationSettings.gotifyUrl, + gotify_token: notificationSettings.gotifyToken, + gotify_priority: (() => { + const parsed = parseInt(notificationSettings.gotifyPriority); + return isNaN(parsed) ? 5 : parsed; + })(), accept_unset_model_ratio_model: notificationSettings.acceptUnsetModelRatioModel, record_ip_log: notificationSettings.recordIpLog, @@ -352,6 +458,12 @@ const PersonalSetting = () => { handleSystemTokenClick={handleSystemTokenClick} setShowChangePasswordModal={setShowChangePasswordModal} setShowAccountDeleteModal={setShowAccountDeleteModal} + passkeyStatus={passkeyStatus} + passkeySupported={passkeySupported} + passkeyRegisterLoading={passkeyRegisterLoading} + passkeyDeleteLoading={passkeyDeleteLoading} + onPasskeyRegister={handleRegisterPasskey} + onPasskeyDelete={handleRemovePasskey} /> {/* 右侧:其他设置 */} diff --git a/web/src/components/settings/SystemSetting.jsx b/web/src/components/settings/SystemSetting.jsx index 6a567d227..780e89fb1 100644 --- a/web/src/components/settings/SystemSetting.jsx +++ b/web/src/components/settings/SystemSetting.jsx @@ -30,6 +30,7 @@ import { Spin, Card, Radio, + Select, } from '@douyinfe/semi-ui'; const { Text } = Typography; import { @@ -76,6 +77,13 @@ const SystemSetting = () => { TurnstileSiteKey: '', TurnstileSecretKey: '', RegisterEnabled: '', + 'passkey.enabled': '', + 'passkey.rp_display_name': '', + 'passkey.rp_id': '', + 'passkey.origins': [], + 'passkey.allow_insecure_origin': '', + 'passkey.user_verification': 'preferred', + 'passkey.attachment_preference': '', EmailDomainRestrictionEnabled: '', EmailAliasRestrictionEnabled: '', SMTPSSLEnabled: '', @@ -172,9 +180,25 @@ const SystemSetting = () => { case 'SMTPSSLEnabled': case 'LinuxDOOAuthEnabled': case 'oidc.enabled': + case 'passkey.enabled': + case 'passkey.allow_insecure_origin': case 'WorkerAllowHttpImageRequestEnabled': item.value = toBoolean(item.value); break; + case 'passkey.origins': + // origins是逗号分隔的字符串,直接使用 + item.value = item.value || ''; + break; + case 'passkey.rp_display_name': + case 'passkey.rp_id': + case 'passkey.attachment_preference': + // 确保字符串字段不为null/undefined + item.value = item.value || ''; + break; + case 'passkey.user_verification': + // 确保有默认值 + item.value = item.value || 'preferred'; + break; case 'Price': case 'MinTopUp': item.value = parseFloat(item.value); @@ -583,6 +607,36 @@ const SystemSetting = () => { } }; + const submitPasskeySettings = async () => { + // 使用formApi直接获取当前表单值 + const formValues = formApiRef.current?.getValues() || {}; + + const options = []; + + options.push({ + key: 'passkey.rp_display_name', + value: formValues['passkey.rp_display_name'] || inputs['passkey.rp_display_name'] || '', + }); + options.push({ + key: 'passkey.rp_id', + value: formValues['passkey.rp_id'] || inputs['passkey.rp_id'] || '', + }); + options.push({ + key: 'passkey.user_verification', + value: formValues['passkey.user_verification'] || inputs['passkey.user_verification'] || 'preferred', + }); + options.push({ + key: 'passkey.attachment_preference', + value: formValues['passkey.attachment_preference'] || inputs['passkey.attachment_preference'] || '', + }); + options.push({ + key: 'passkey.origins', + value: formValues['passkey.origins'] || inputs['passkey.origins'] || '', + }); + + await updateOptions(options); + }; + const handleCheckboxChange = async (optionKey, event) => { const value = event.target.checked; @@ -985,6 +1039,116 @@ const SystemSetting = () => { + + + {t('用以支持基于 WebAuthn 的无密码登录注册')} + + + + + handleCheckboxChange('passkey.enabled', e) + } + > + {t('允许通过 Passkey 登录 & 认证')} + + + + + + + + + + + + + + + + + + + + + + + handleCheckboxChange('passkey.allow_insecure_origin', e) + } + > + {t('允许不安全的 Origin(HTTP)')} + + + + + + + + + + + + {t('用以防止恶意用户利用临时邮箱批量注册')} diff --git a/web/src/components/settings/personal/cards/AccountManagement.jsx b/web/src/components/settings/personal/cards/AccountManagement.jsx index 01f2fcdd3..ac2146c27 100644 --- a/web/src/components/settings/personal/cards/AccountManagement.jsx +++ b/web/src/components/settings/personal/cards/AccountManagement.jsx @@ -59,6 +59,12 @@ const AccountManagement = ({ handleSystemTokenClick, setShowChangePasswordModal, setShowAccountDeleteModal, + passkeyStatus, + passkeySupported, + passkeyRegisterLoading, + passkeyDeleteLoading, + onPasskeyRegister, + onPasskeyDelete, }) => { const renderAccountInfo = (accountId, label) => { if (!accountId || accountId === '') { @@ -87,6 +93,10 @@ const AccountManagement = ({ const isBound = (accountId) => Boolean(accountId); const [showTelegramBindModal, setShowTelegramBindModal] = React.useState(false); + const passkeyEnabled = passkeyStatus?.enabled; + const lastUsedLabel = passkeyStatus?.last_used_at + ? new Date(passkeyStatus.last_used_at).toLocaleString() + : t('尚未使用'); return ( @@ -479,6 +489,71 @@ const AccountManagement = ({
+ {/* Passkey 设置 */} + +
+
+
+ +
+
+ + {t('Passkey 登录')} + + + {passkeyEnabled + ? t('已启用 Passkey,无需密码即可登录') + : t('使用 Passkey 实现免密且更安全的登录体验')} + +
+
+ {t('最后使用时间')}:{lastUsedLabel} +
+ {/*{passkeyEnabled && (*/} + {/*
*/} + {/* {t('备份支持')}:*/} + {/* {passkeyStatus?.backup_eligible*/} + {/* ? t('支持备份')*/} + {/* : t('不支持')}*/} + {/* ,{t('备份状态')}:*/} + {/* {passkeyStatus?.backup_state ? t('已备份') : t('未备份')}*/} + {/*
*/} + {/*)}*/} + {!passkeySupported && ( +
+ {t('当前设备不支持 Passkey')} +
+ )} +
+
+
+ +
+
+ {/* 两步验证设置 */} diff --git a/web/src/components/settings/personal/cards/NotificationSettings.jsx b/web/src/components/settings/personal/cards/NotificationSettings.jsx index aad612d2c..0c99e2855 100644 --- a/web/src/components/settings/personal/cards/NotificationSettings.jsx +++ b/web/src/components/settings/personal/cards/NotificationSettings.jsx @@ -400,6 +400,7 @@ const NotificationSettings = ({ {t('邮件通知')} {t('Webhook通知')} {t('Bark通知')} + {t('Gotify通知')} - Bark 官方文档 + Bark {t('官方文档')} + + + + + + )} + + {/* Gotify推送设置 */} + {notificationSettings.warningType === 'gotify' && ( + <> + handleFormChange('gotifyUrl', val)} + prefix={} + extraText={t( + '支持HTTP和HTTPS,填写Gotify服务器的完整URL地址', + )} + showClear + rules={[ + { + required: + notificationSettings.warningType === 'gotify', + message: t('请输入Gotify服务器地址'), + }, + { + pattern: /^https?:\/\/.+/, + message: t('Gotify服务器地址必须以http://或https://开头'), + }, + ]} + /> + + handleFormChange('gotifyToken', val)} + prefix={} + extraText={t( + '在Gotify服务器创建应用后获得的令牌,用于发送通知', + )} + showClear + rules={[ + { + required: + notificationSettings.warningType === 'gotify', + message: t('请输入Gotify应用令牌'), + }, + ]} + /> + + + handleFormChange('gotifyPriority', val) + } + prefix={} + extraText={t('消息优先级,范围0-10,默认为5')} + style={{ width: '100%', maxWidth: '300px' }} + /> + +
+
+ {t('配置说明')} +
+
+
+ 1. {t('在Gotify服务器的应用管理中创建新应用')} +
+
+ 2.{' '} + {t( + '复制应用的令牌(Token)并填写到上方的应用令牌字段', + )} +
+
+ 3. {t('填写Gotify服务器的完整URL地址')} +
+
+ + {t('更多信息请参考')} + {' '} + + Gotify {t('官方文档')}
diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index 841373889..d5d299969 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/src/components/table/channels/modals/EditChannelModal.jsx @@ -56,8 +56,10 @@ import { } from '../../../../helpers'; import ModelSelectModal from './ModelSelectModal'; import JSONEditor from '../../../common/ui/JSONEditor'; -import TwoFactorAuthModal from '../../../common/modals/TwoFactorAuthModal'; +import SecureVerificationModal from '../../../common/modals/SecureVerificationModal'; import ChannelKeyDisplay from '../../../common/ui/ChannelKeyDisplay'; +import { useSecureVerification } from '../../../../hooks/common/useSecureVerification'; +import { createApiCalls } from '../../../../services/secureVerification'; import { IconSave, IconClose, @@ -66,6 +68,8 @@ import { IconCode, IconGlobe, IconBolt, + IconChevronUp, + IconChevronDown, } from '@douyinfe/semi-icons'; const { Text, Title } = Typography; @@ -151,6 +155,10 @@ const EditChannelModal = (props) => { vertex_key_type: 'json', // 企业账户设置 is_enterprise_account: false, + // 字段透传控制默认值 + allow_service_tier: false, + disable_store: false, // false = 允许透传(默认开启) + allow_safety_identifier: false, }; const [batch, setBatch] = useState(false); const [multiToSingle, setMultiToSingle] = useState(false); @@ -178,12 +186,9 @@ const EditChannelModal = (props) => { const [keyMode, setKeyMode] = useState('append'); // 密钥模式:replace(覆盖)或 append(追加) const [isEnterpriseAccount, setIsEnterpriseAccount] = useState(false); // 是否为企业账户 - // 2FA验证查看密钥相关状态 - const [twoFAState, setTwoFAState] = useState({ + // 密钥显示状态 + const [keyDisplayState, setKeyDisplayState] = useState({ showModal: false, - code: '', - loading: false, - showKey: false, keyData: '', }); @@ -192,18 +197,57 @@ const EditChannelModal = (props) => { const [verifyCode, setVerifyCode] = useState(''); const [verifyLoading, setVerifyLoading] = useState(false); + // 表单块导航相关状态 + const formSectionRefs = useRef({ + basicInfo: null, + apiConfig: null, + modelConfig: null, + advancedSettings: null, + channelExtraSettings: null, + }); + const [currentSectionIndex, setCurrentSectionIndex] = useState(0); + const formSections = ['basicInfo', 'apiConfig', 'modelConfig', 'advancedSettings', 'channelExtraSettings']; + const formContainerRef = useRef(null); + // 2FA状态更新辅助函数 const updateTwoFAState = (updates) => { setTwoFAState((prev) => ({ ...prev, ...updates })); }; + // 使用通用安全验证 Hook + const { + isModalVisible, + verificationMethods, + verificationState, + withVerification, + executeVerification, + cancelVerification, + setVerificationCode, + switchVerificationMethod, + } = useSecureVerification({ + onSuccess: (result) => { + // 验证成功后显示密钥 + console.log('Verification success, result:', result); + if (result && result.success && result.data?.key) { + showSuccess(t('密钥获取成功')); + setKeyDisplayState({ + showModal: true, + keyData: result.data.key, + }); + } else if (result && result.key) { + // 直接返回了 key(没有包装在 data 中) + showSuccess(t('密钥获取成功')); + setKeyDisplayState({ + showModal: true, + keyData: result.key, + }); + } + }, + }); - // 重置2FA状态 - const resetTwoFAState = () => { - setTwoFAState({ + // 重置密钥显示状态 + const resetKeyDisplayState = () => { + setKeyDisplayState({ showModal: false, - code: '', - loading: false, - showKey: false, keyData: '', }); }; @@ -215,6 +259,37 @@ const EditChannelModal = (props) => { setVerifyLoading(false); }; + // 表单导航功能 + const scrollToSection = (sectionKey) => { + const sectionElement = formSectionRefs.current[sectionKey]; + if (sectionElement) { + sectionElement.scrollIntoView({ + behavior: 'smooth', + block: 'start', + inline: 'nearest' + }); + } + }; + + const navigateToSection = (direction) => { + const availableSections = formSections.filter(section => { + if (section === 'apiConfig') { + return showApiConfigCard; + } + return true; + }); + + let newIndex; + if (direction === 'up') { + newIndex = currentSectionIndex > 0 ? currentSectionIndex - 1 : availableSections.length - 1; + } else { + newIndex = currentSectionIndex < availableSections.length - 1 ? currentSectionIndex + 1 : 0; + } + + setCurrentSectionIndex(newIndex); + scrollToSection(availableSections[newIndex]); + }; + // 渠道额外设置状态 const [channelSettings, setChannelSettings] = useState({ force_format: false, @@ -431,17 +506,27 @@ const EditChannelModal = (props) => { // 读取企业账户设置 data.is_enterprise_account = parsedSettings.openrouter_enterprise === true; + // 读取字段透传控制设置 + data.allow_service_tier = parsedSettings.allow_service_tier || false; + data.disable_store = parsedSettings.disable_store || false; + data.allow_safety_identifier = parsedSettings.allow_safety_identifier || false; } catch (error) { console.error('解析其他设置失败:', error); data.azure_responses_version = ''; data.region = ''; data.vertex_key_type = 'json'; data.is_enterprise_account = false; + data.allow_service_tier = false; + data.disable_store = false; + data.allow_safety_identifier = false; } } else { // 兼容历史数据:老渠道没有 settings 时,默认按 json 展示 data.vertex_key_type = 'json'; data.is_enterprise_account = false; + data.allow_service_tier = false; + data.disable_store = false; + data.allow_safety_identifier = false; } if ( @@ -591,42 +676,33 @@ const EditChannelModal = (props) => { } }; - // 使用TwoFactorAuthModal的验证函数 - const handleVerify2FA = async () => { - if (!verifyCode) { - showError(t('请输入验证码或备用码')); - return; - } - - setVerifyLoading(true); + // 查看渠道密钥(透明验证) + const handleShow2FAModal = async () => { try { - const res = await API.post(`/api/channel/${channelId}/key`, { - code: verifyCode, - }); - if (res.data.success) { - // 验证成功,显示密钥 - updateTwoFAState({ + // 使用 withVerification 包装,会自动处理需要验证的情况 + const result = await withVerification( + createApiCalls.viewChannelKey(channelId), + { + title: t('查看渠道密钥'), + description: t('为了保护账户安全,请验证您的身份。'), + preferredMethod: 'passkey', // 优先使用 Passkey + } + ); + + // 如果直接返回了结果(已验证),显示密钥 + if (result && result.success && result.data?.key) { + showSuccess(t('密钥获取成功')); + setKeyDisplayState({ showModal: true, - showKey: true, - keyData: res.data.data.key, + keyData: result.data.key, }); - reset2FAVerifyState(); - showSuccess(t('验证成功')); - } else { - showError(res.data.message); } } catch (error) { - showError(t('获取密钥失败')); - } finally { - setVerifyLoading(false); + console.error('Failed to view channel key:', error); + showError(error.message || t('获取密钥失败')); } }; - // 显示2FA验证模态框 - 使用TwoFactorAuthModal - const handleShow2FAModal = () => { - setShow2FAVerifyModal(true); - }; - useEffect(() => { const modelMap = new Map(); @@ -702,6 +778,8 @@ const EditChannelModal = (props) => { fetchModelGroups(); // 重置手动输入模式状态 setUseManualInput(false); + // 重置导航状态 + setCurrentSectionIndex(0); } else { // 统一的模态框关闭重置逻辑 resetModalState(); @@ -730,10 +808,8 @@ const EditChannelModal = (props) => { } // 重置本地输入,避免下次打开残留上一次的 JSON 字段值 setInputs(getInitValues()); - // 重置2FA状态 - resetTwoFAState(); - // 重置2FA验证状态 - reset2FAVerifyState(); + // 重置密钥显示状态 + resetKeyDisplayState(); }; const handleVertexUploadChange = ({ fileList }) => { @@ -892,22 +968,34 @@ const EditChannelModal = (props) => { }; localInputs.setting = JSON.stringify(channelExtraSettings); - // 处理type === 20的企业账户设置 - if (localInputs.type === 20) { - let settings = {}; - if (localInputs.settings) { - try { - settings = JSON.parse(localInputs.settings); - } catch (error) { - console.error('解析settings失败:', error); - } + // 处理 settings 字段(包括企业账户设置和字段透传控制) + let settings = {}; + if (localInputs.settings) { + try { + settings = JSON.parse(localInputs.settings); + } catch (error) { + console.error('解析settings失败:', error); } - // 设置企业账户标识,无论是true还是false都要传到后端 + } + + // type === 20: 设置企业账户标识,无论是true还是false都要传到后端 + if (localInputs.type === 20) { settings.openrouter_enterprise = localInputs.is_enterprise_account === true; - localInputs.settings = JSON.stringify(settings); } + // type === 1 (OpenAI) 或 type === 14 (Claude): 设置字段透传控制(显式保存布尔值) + if (localInputs.type === 1 || localInputs.type === 14) { + settings.allow_service_tier = localInputs.allow_service_tier === true; + // 仅 OpenAI 渠道需要 store 和 safety_identifier + if (localInputs.type === 1) { + settings.disable_store = localInputs.disable_store === true; + settings.allow_safety_identifier = localInputs.allow_safety_identifier === true; + } + } + + localInputs.settings = JSON.stringify(settings); + // 清理不需要发送到后端的字段 delete localInputs.force_format; delete localInputs.thinking_to_content; @@ -918,6 +1006,10 @@ const EditChannelModal = (props) => { delete localInputs.is_enterprise_account; // 顶层的 vertex_key_type 不应发送给后端 delete localInputs.vertex_key_type; + // 清理字段透传控制的临时字段 + delete localInputs.allow_service_tier; + delete localInputs.disable_store; + delete localInputs.allow_safety_identifier; let res; localInputs.auto_ban = localInputs.auto_ban ? 1 : 0; @@ -1233,7 +1325,41 @@ const EditChannelModal = (props) => { visible={props.visible} width={isMobile ? '100%' : 600} footer={ -
+
+
+
} @@ -2537,7 +2758,7 @@ const EditChannelModal = (props) => { style={{ maxWidth: '90vw' }} > { case 36: localModels = ['suno_music', 'suno_lyrics']; break; + case 53: + localModels = ['NousResearch/Hermes-4-405B-FP8', 'Qwen/Qwen3-235B-A22B-Thinking-2507', 'Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8','Qwen/Qwen3-235B-A22B-Instruct-2507', 'zai-org/GLM-4.5-FP8', 'openai/gpt-oss-120b', 'deepseek-ai/DeepSeek-R1-0528', 'deepseek-ai/DeepSeek-R1', 'deepseek-ai/DeepSeek-V3-0324', 'deepseek-ai/DeepSeek-V3.1']; + break; default: localModels = getChannelModels(value); break; diff --git a/web/src/components/table/channels/modals/ModelTestModal.jsx b/web/src/components/table/channels/modals/ModelTestModal.jsx index c643ed100..7cc56612d 100644 --- a/web/src/components/table/channels/modals/ModelTestModal.jsx +++ b/web/src/components/table/channels/modals/ModelTestModal.jsx @@ -25,6 +25,7 @@ import { Table, Tag, Typography, + Select, } from '@douyinfe/semi-ui'; import { IconSearch } from '@douyinfe/semi-icons'; import { copy, showError, showInfo, showSuccess } from '../../../../helpers'; @@ -45,6 +46,8 @@ const ModelTestModal = ({ testChannel, modelTablePage, setModelTablePage, + selectedEndpointType, + setSelectedEndpointType, allSelectingRef, isMobile, t, @@ -59,6 +62,17 @@ const ModelTestModal = ({ ) : []; + const endpointTypeOptions = [ + { value: '', label: t('自动检测') }, + { value: 'openai', label: 'OpenAI (/v1/chat/completions)' }, + { value: 'openai-response', label: 'OpenAI Response (/v1/responses)' }, + { value: 'anthropic', label: 'Anthropic (/v1/messages)' }, + { value: 'gemini', label: 'Gemini (/v1beta/models/{model}:generateContent)' }, + { value: 'jina-rerank', label: 'Jina Rerank (/rerank)' }, + { value: 'image-generation', label: t('图像生成') + ' (/v1/images/generations)' }, + { value: 'embeddings', label: 'Embeddings (/v1/embeddings)' }, + ]; + const handleCopySelected = () => { if (selectedModelKeys.length === 0) { showError(t('请先选择模型!')); @@ -152,7 +166,7 @@ const ModelTestModal = ({ return ( - + + + + ), + }, + ]; + return ( - (refForm.current = formAPI)} - style={{ marginBottom: 15 }} - > + - { - return verifyJSON(value); - }, - message: t('不是合法的 JSON 字符串'), - }, - ]} - onChange={(value) => - setInputs({ - ...inputs, - Chats: value, - }) - } - /> + + + +
+ + {t('编辑模式')}: + + { + const newMode = e.target.value; + setEditMode(newMode); + + // 确保模式切换时数据正确同步 + setTimeout(() => { + if (newMode === 'json' && refForm.current) { + refForm.current.setValues(inputs); + } + }, 100); + }} + > + {t('可视化编辑')} + {t('JSON编辑')} + +
+ + {editMode === 'visual' ? ( +
+ + + + } + placeholder={t('搜索聊天应用名称')} + value={searchText} + onChange={(value) => setSearchText(value)} + style={{ width: 250 }} + showClear + /> + + + + t('共 {{total}} 项,当前显示 {{start}}-{{end}} 项', { + total, + start: range[0], + end: range[1], + }), + }} + /> + + ) : ( + (refForm.current = formAPI)} + > + { + return verifyJSON(value); + }, + message: t('不是合法的 JSON 字符串'), + }, + ]} + onChange={(value) => + setInputs({ + ...inputs, + Chats: value, + }) + } + /> + + )} - - - + + {editMode === 'json' && ( + + + + )} + + +
(modalFormRef.current = api)}> + + + + +
); } diff --git a/web/src/pages/Setting/Model/SettingClaudeModel.jsx b/web/src/pages/Setting/Model/SettingClaudeModel.jsx index 04d7956aa..688fc2d3e 100644 --- a/web/src/pages/Setting/Model/SettingClaudeModel.jsx +++ b/web/src/pages/Setting/Model/SettingClaudeModel.jsx @@ -202,9 +202,8 @@ export default function SettingClaudeModel(props) { label={t('思考适配 BudgetTokens 百分比')} field={'claude.thinking_adapter_budget_tokens_percentage'} initValue={''} - extraText={t('0.1-1之间的小数')} + extraText={t('0.1以上的小数')} min={0.1} - max={1} onChange={(value) => setInputs({ ...inputs, diff --git a/web/src/services/secureVerification.js b/web/src/services/secureVerification.js new file mode 100644 index 000000000..93cdd0a4d --- /dev/null +++ b/web/src/services/secureVerification.js @@ -0,0 +1,217 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import { API, showError } from '../helpers'; +import { + prepareCredentialRequestOptions, + buildAssertionResult, + isPasskeySupported +} from '../helpers/passkey'; + +/** + * 通用安全验证服务 + * 验证状态完全由后端 Session 控制,前端不存储任何状态 + */ +export class SecureVerificationService { + /** + * 检查用户可用的验证方式 + * @returns {Promise<{has2FA: boolean, hasPasskey: boolean, passkeySupported: boolean}>} + */ + static async checkAvailableVerificationMethods() { + try { + const [twoFAResponse, passkeyResponse, passkeySupported] = await Promise.all([ + API.get('/api/user/2fa/status'), + API.get('/api/user/passkey'), + isPasskeySupported() + ]); + + console.log('=== DEBUGGING VERIFICATION METHODS ==='); + console.log('2FA Response:', JSON.stringify(twoFAResponse, null, 2)); + console.log('Passkey Response:', JSON.stringify(passkeyResponse, null, 2)); + + const has2FA = twoFAResponse.data?.success && twoFAResponse.data?.data?.enabled === true; + const hasPasskey = passkeyResponse.data?.success && passkeyResponse.data?.data?.enabled === true; + + console.log('has2FA calculation:', { + success: twoFAResponse.data?.success, + dataExists: !!twoFAResponse.data?.data, + enabled: twoFAResponse.data?.data?.enabled, + result: has2FA + }); + + console.log('hasPasskey calculation:', { + success: passkeyResponse.data?.success, + dataExists: !!passkeyResponse.data?.data, + enabled: passkeyResponse.data?.data?.enabled, + result: hasPasskey + }); + + const result = { + has2FA, + hasPasskey, + passkeySupported + }; + + return result; + } catch (error) { + console.error('Failed to check verification methods:', error); + return { + has2FA: false, + hasPasskey: false, + passkeySupported: false + }; + } + } + + /** + * 执行2FA验证 + * @param {string} code - 验证码 + * @returns {Promise} + */ + static async verify2FA(code) { + if (!code?.trim()) { + throw new Error('请输入验证码或备用码'); + } + + // 调用通用验证 API,验证成功后后端会设置 session + const verifyResponse = await API.post('/api/verify', { + method: '2fa', + code: code.trim() + }); + + if (!verifyResponse.data?.success) { + throw new Error(verifyResponse.data?.message || '验证失败'); + } + + // 验证成功,session 已在后端设置 + } + + /** + * 执行Passkey验证 + * @returns {Promise} + */ + static async verifyPasskey() { + try { + // 开始Passkey验证 + const beginResponse = await API.post('/api/user/passkey/verify/begin'); + if (!beginResponse.data?.success) { + throw new Error(beginResponse.data?.message || '开始验证失败'); + } + + // 准备WebAuthn选项 + const publicKey = prepareCredentialRequestOptions(beginResponse.data.data.options); + + // 执行WebAuthn验证 + const credential = await navigator.credentials.get({ publicKey }); + if (!credential) { + throw new Error('Passkey 验证被取消'); + } + + // 构建验证结果 + const assertionResult = buildAssertionResult(credential); + + // 完成验证 + const finishResponse = await API.post('/api/user/passkey/verify/finish', assertionResult); + if (!finishResponse.data?.success) { + throw new Error(finishResponse.data?.message || '验证失败'); + } + + // 调用通用验证 API 设置 session(Passkey 验证已完成) + const verifyResponse = await API.post('/api/verify', { + method: 'passkey' + }); + + if (!verifyResponse.data?.success) { + throw new Error(verifyResponse.data?.message || '验证失败'); + } + + // 验证成功,session 已在后端设置 + } catch (error) { + if (error.name === 'NotAllowedError') { + throw new Error('Passkey 验证被取消或超时'); + } else if (error.name === 'InvalidStateError') { + throw new Error('Passkey 验证状态无效'); + } else { + throw error; + } + } + } + + /** + * 通用验证方法,根据验证类型执行相应的验证流程 + * @param {string} method - 验证方式: '2fa' | 'passkey' + * @param {string} code - 2FA验证码(当method为'2fa'时必需) + * @returns {Promise} + */ + static async verify(method, code = '') { + switch (method) { + case '2fa': + return await this.verify2FA(code); + case 'passkey': + return await this.verifyPasskey(); + default: + throw new Error(`不支持的验证方式: ${method}`); + } + } +} + +/** + * 预设的API调用函数工厂 + */ +export const createApiCalls = { + /** + * 创建查看渠道密钥的API调用 + * @param {number} channelId - 渠道ID + */ + viewChannelKey: (channelId) => async () => { + // 新系统中,验证已通过中间件处理,直接调用 API 即可 + const response = await API.post(`/api/channel/${channelId}/key`, {}); + return response.data; + }, + + /** + * 创建自定义API调用 + * @param {string} url - API URL + * @param {string} method - HTTP方法,默认为 'POST' + * @param {Object} extraData - 额外的请求数据 + */ + custom: (url, method = 'POST', extraData = {}) => async () => { + // 新系统中,验证已通过中间件处理 + const data = extraData; + + let response; + switch (method.toUpperCase()) { + case 'GET': + response = await API.get(url, { params: data }); + break; + case 'POST': + response = await API.post(url, data); + break; + case 'PUT': + response = await API.put(url, data); + break; + case 'DELETE': + response = await API.delete(url, { data }); + break; + default: + throw new Error(`不支持的HTTP方法: ${method}`); + } + return response.data; + } +}; \ No newline at end of file