瀏覽代碼

Merge branch 'main-upstream' into pr/custom-currency-1923

# Conflicts:
#	web/src/components/settings/personal/cards/AccountManagement.jsx
#	web/src/components/table/channels/modals/EditChannelModal.jsx
#	web/src/hooks/channels/useChannelsData.jsx
#	web/src/hooks/common/useSidebar.js
#	web/src/i18n/locales/fr.json
#	web/src/pages/Setting/Operation/SettingsGeneral.jsx
Seefs 5 月之前
父節點
當前提交
3199e2e8cd
共有 90 個文件被更改,包括 6019 次插入556 次删除
  1. 16 5
      README.en.md
  2. 16 5
      README.fr.md
  3. 224 0
      README.ja.md
  4. 10 10
      README.md
  5. 2 0
      common/api_type.go
  6. 1 0
      common/endpoint_defaults.go
  7. 3 2
      constant/api_type.go
  8. 4 0
      constant/channel.go
  9. 1 0
      constant/endpoint_type.go
  10. 201 82
      controller/channel-test.go
  11. 4 31
      controller/channel.go
  12. 9 0
      controller/misc.go
  13. 497 0
      controller/passkey.go
  14. 313 0
      controller/secure_verification.go
  15. 86 0
      controller/task_video.go
  16. 1 1
      controller/telegram.go
  17. 54 1
      controller/user.go
  18. 47 18
      docker-compose.yml
  19. 3 0
      dto/channel_settings.go
  20. 9 5
      dto/claude.go
  21. 26 13
      dto/openai_request.go
  22. 4 0
      dto/user_settings.go
  23. 18 13
      go.mod
  24. 38 32
      go.sum
  25. 3 2
      main.go
  26. 3 0
      middleware/distributor.go
  27. 131 0
      middleware/secure_verification.go
  28. 2 0
      model/main.go
  29. 209 0
      model/passkey.go
  30. 34 14
      relay/channel/api_request.go
  31. 1 2
      relay/channel/aws/adaptor.go
  32. 6 0
      relay/channel/aws/constants.go
  33. 17 3
      relay/channel/claude/adaptor.go
  34. 2 0
      relay/channel/claude/constants.go
  35. 1 0
      relay/channel/jina/adaptor.go
  36. 5 1
      relay/channel/openai/relay_responses.go
  37. 86 0
      relay/channel/submodel/adaptor.go
  38. 16 0
      relay/channel/submodel/constants.go
  39. 248 0
      relay/channel/task/doubao/adaptor.go
  40. 9 0
      relay/channel/task/doubao/constants.go
  41. 48 20
      relay/channel/vertex/adaptor.go
  42. 18 10
      relay/channel/volcengine/adaptor.go
  43. 6 0
      relay/claude_handler.go
  44. 53 7
      relay/common/relay_info.go
  45. 6 0
      relay/compatible_handler.go
  46. 7 2
      relay/relay_adaptor.go
  47. 7 0
      relay/responses_handler.go
  48. 14 1
      router/api-router.go
  49. 177 0
      service/passkey/service.go
  50. 50 0
      service/passkey/session.go
  51. 71 0
      service/passkey/user.go
  52. 4 1
      service/quota.go
  53. 112 4
      service/user_notify.go
  54. 3 0
      setting/operation_setting/tools.go
  55. 4 0
      setting/ratio_setting/cache_ratio.go
  56. 15 0
      setting/ratio_setting/model_ratio.go
  57. 49 0
      setting/system_setting/passkey.go
  58. 86 1
      web/src/components/auth/LoginForm.jsx
  59. 117 0
      web/src/components/common/examples/ChannelKeyViewExample.jsx
  60. 285 0
      web/src/components/common/modals/SecureVerificationModal.jsx
  61. 34 23
      web/src/components/layout/Footer.jsx
  62. 13 3
      web/src/components/layout/PageLayout.jsx
  63. 1 1
      web/src/components/layout/SiderBar.jsx
  64. 112 0
      web/src/components/settings/PersonalSetting.jsx
  65. 164 0
      web/src/components/settings/SystemSetting.jsx
  66. 75 0
      web/src/components/settings/personal/cards/AccountManagement.jsx
  67. 103 1
      web/src/components/settings/personal/cards/NotificationSettings.jsx
  68. 333 112
      web/src/components/table/channels/modals/EditChannelModal.jsx
  69. 3 0
      web/src/components/table/channels/modals/EditTagModal.jsx
  70. 27 1
      web/src/components/table/channels/modals/ModelTestModal.jsx
  71. 40 6
      web/src/components/table/users/UsersColumnDefs.jsx
  72. 55 1
      web/src/components/table/users/UsersTable.jsx
  73. 39 0
      web/src/components/table/users/modals/ResetPasskeyModal.jsx
  74. 39 0
      web/src/components/table/users/modals/ResetTwoFAModal.jsx
  75. 10 0
      web/src/constants/channel.constants.js
  76. 1 0
      web/src/helpers/index.js
  77. 137 0
      web/src/helpers/passkey.js
  78. 70 68
      web/src/helpers/render.jsx
  79. 62 0
      web/src/helpers/secureApiCall.js
  80. 11 5
      web/src/hooks/channels/useChannelsData.jsx
  81. 1 1
      web/src/hooks/common/useHeaderBar.js
  82. 246 0
      web/src/hooks/common/useSecureVerification.jsx
  83. 36 8
      web/src/hooks/common/useSidebar.js
  84. 37 1
      web/src/hooks/users/useUsersData.jsx
  85. 114 2
      web/src/i18n/locales/en.json
  86. 101 2
      web/src/i18n/locales/fr.json
  87. 59 1
      web/src/i18n/locales/zh.json
  88. 386 32
      web/src/pages/Setting/Chat/SettingsChats.jsx
  89. 1 2
      web/src/pages/Setting/Model/SettingClaudeModel.jsx
  90. 217 0
      web/src/services/secureVerification.js

+ 16 - 5
README.en.md

@@ -1,6 +1,10 @@
 <p align="right">
-   <a href="./README.md">中文</a> | <strong>English</strong> | <a href="./README.fr.md">Français</a>
+   <a href="./README.md">中文</a> | <strong>English</strong> | <a href="./README.fr.md">Français</a> | <a href="./README.ja.md">日本語</a>
 </p>
+
+> [!NOTE]
+> **MT (Machine Translation)**: This document is machine translated. For the most accurate information, please refer to the [Chinese version](./README.md).
+
 <div align="center">
 
 ![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

+ 16 - 5
README.fr.md

@@ -1,6 +1,10 @@
 <p align="right">
-   <a href="./README.md">中文</a> | <a href="./README.en.md">English</a> | <strong>Français</strong>
+   <a href="./README.md">中文</a> | <a href="./README.en.md">English</a> | <strong>Français</strong> | <a href="./README.ja.md">日本語</a>
 </p>
+
+> [!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).
+
 <div align="center">
 
 ![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

+ 224 - 0
README.ja.md

@@ -0,0 +1,224 @@
+<p align="right">
+   <a href="./README.md">中文</a> | <a href="./README.en.md">English</a> | <a href="./README.fr.md">Français</a> | <strong>日本語</strong>
+</p>
+
+> [!NOTE]
+> **MT(機械翻訳)**: この文書は機械翻訳されています。最も正確な情報については、[中国語版](./README.md)を参照してください。
+
+<div align="center">
+
+![new-api](/web/public/logo.png)
+
+# New API
+
+🍥次世代大規模モデルゲートウェイとAI資産管理システム
+
+<a href="https://trendshift.io/repositories/8227" target="_blank"><img src="https://trendshift.io/api/badge/repositories/8227" alt="Calcium-Ion%2Fnew-api | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
+
+<p align="center">
+  <a href="https://raw.githubusercontent.com/Calcium-Ion/new-api/main/LICENSE">
+    <img src="https://img.shields.io/github/license/Calcium-Ion/new-api?color=brightgreen" alt="license">
+  </a>
+  <a href="https://github.com/Calcium-Ion/new-api/releases/latest">
+    <img src="https://img.shields.io/github/v/release/Calcium-Ion/new-api?color=brightgreen&include_prereleases" alt="release">
+  </a>
+  <a href="https://github.com/users/Calcium-Ion/packages/container/package/new-api">
+    <img src="https://img.shields.io/badge/docker-ghcr.io-blue" alt="docker">
+  </a>
+  <a href="https://hub.docker.com/r/CalciumIon/new-api">
+    <img src="https://img.shields.io/badge/docker-dockerHub-blue" alt="docker">
+  </a>
+  <a href="https://goreportcard.com/report/github.com/Calcium-Ion/new-api">
+    <img src="https://goreportcard.com/badge/github.com/Calcium-Ion/new-api" alt="GoReportCard">
+  </a>
+</p>
+</div>
+
+## 📝 プロジェクト説明
+
+> [!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 サービスを提供しないでください。
+
+<h2>🤝 信頼できるパートナー</h2>
+<p id="premium-sponsors">&nbsp;</p>
+<p align="center"><strong>順不同</strong></p>
+<p align="center">
+  <a href="https://www.cherry-ai.com/" target=_blank><img
+    src="./docs/images/cherry-studio.png" alt="Cherry Studio" height="120"
+  /></a>
+  <a href="https://bda.pku.edu.cn/" target=_blank><img
+    src="./docs/images/pku.png" alt="北京大学" height="120"
+  /></a>
+  <a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target=_blank><img
+    src="./docs/images/ucloud.png" alt="UCloud 優刻得" height="120"
+  /></a>
+  <a href="https://www.aliyun.com/" target=_blank><img
+    src="./docs/images/aliyun.png" alt="Alibaba Cloud" height="120"
+  /></a>
+  <a href="https://io.net/" target=_blank><img
+    src="./docs/images/io-net.png" alt="IO.NET" height="120"
+  /></a>
+</p>
+<p>&nbsp;</p>
+
+## 📚 ドキュメント
+
+詳細なドキュメントは公式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)
+

+ 10 - 10
README.md

@@ -1,5 +1,5 @@
 <p align="right">
-   <strong>中文</strong> | <a href="./README.en.md">English</a> | <a href="./README.fr.md">Français</a>
+   <strong>中文</strong> | <a href="./README.en.md">English</a> | <a href="./README.fr.md">Français</a> | <a href="./README.ja.md">日本語</a>
 </p>
 <div align="center">
 
@@ -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的项目:

+ 2 - 0
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

+ 1 - 0
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 返回指定端点类型的默认信息以及是否存在

+ 3 - 2
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
 )

+ 4 - 0
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
 }

+ 1 - 0
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"

+ 201 - 82
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

+ 4 - 31
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,
 		},

+ 9 - 0
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,
 	}
 

+ 497 - 0
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
+}

+ 313 - 0
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,
+		},
+	})
+}

+ 86 - 0
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%"

+ 1 - 1
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) {

+ 54 - 1
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 {

+ 47 - 18
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:

+ 3 - 0
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 {

+ 9 - 5
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 {

+ 26 - 13
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 {

+ 4 - 0
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 推送
 )

+ 18 - 13
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

+ 38 - 32
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=

+ 3 - 2
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.")
+		}
 	}
 
 	// 加载环境变量

+ 3 - 0
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

+ 131 - 0
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()
+}

+ 2 - 0
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"},

+ 209 - 0
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
+}

+ 34 - 14
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)

+ 1 - 2
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
 }
 

+ 6 - 0
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,

+ 17 - 3
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
 }
 

+ 2 - 0
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"

+ 1 - 0
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
 }
 

+ 5 - 1
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++
+							}
+						}
 					}
 				}
 			}

+ 86 - 0
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
+}

+ 16 - 0
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"

+ 248 - 0
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
+}

+ 9 - 0
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"

+ 48 - 20
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 {
+		var keyPrefix string
+		if strings.HasSuffix(suffix, "?alt=sse") {
+			keyPrefix = "&"
 		} 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
+			keyPrefix = "?"
 		}
-	} else {
 		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) {

+ 18 - 10
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)
 }

+ 6 - 0
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)

+ 53 - 7
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
 }

+ 6 - 0
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)

+ 7 - 2
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

+ 7 - 0
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)

+ 14 - 1
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)

+ 177 - 0
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"
+}

+ 50 - 0
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
+}

+ 71 - 0
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
+}

+ 4 - 1
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}},为了不影响您的使用,请及时充值。<br/>充值链接:<a href='{{value}}'>{{value}}</a>"
 				values = []interface{}{prompt, logger.FormatQuota(relayInfo.UserQuota), topUpLink, topUpLink}
 			}

+ 112 - 4
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
+}

+ 3 - 0
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
 }

+ 4 - 0
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{}

+ 15 - 0
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
 	}

+ 49 - 0
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
+}

+ 86 - 1
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 = () => {
                   </div>
                 )}
 
+                {status.passkey_login && passkeySupported && (
+                  <Button
+                    theme='outline'
+                    className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
+                    type='tertiary'
+                    icon={<IconKey size='large' />}
+                    onClick={handlePasskeyLogin}
+                    loading={passkeyLoading}
+                  >
+                    <span className='ml-3'>{t('使用 Passkey 登录')}</span>
+                  </Button>
+                )}
+
                 <Divider margin='12px' align='center'>
                   {t('或')}
                 </Divider>
@@ -437,6 +510,18 @@ const LoginForm = () => {
               </Title>
             </div>
             <div className='px-2 py-8'>
+              {status.passkey_login && passkeySupported && (
+                <Button
+                  theme='outline'
+                  type='tertiary'
+                  className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors mb-4'
+                  icon={<IconKey size='large' />}
+                  onClick={handlePasskeyLogin}
+                  loading={passkeyLoading}
+                >
+                  <span className='ml-3'>{t('使用 Passkey 登录')}</span>
+                </Button>
+              )}
               <Form className='space-y-3'>
                 <Form.Input
                   field='username'

+ 117 - 0
web/src/components/common/examples/ChannelKeyViewExample.jsx

@@ -0,0 +1,117 @@
+/*
+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 <https://www.gnu.org/licenses/>.
+
+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 (
+    <>
+      {/* 查看密钥按钮 */}
+      <Button
+        type='primary'
+        theme='outline'
+        onClick={handleViewKey}
+      >
+        {t('查看密钥')}
+      </Button>
+
+      {/* 安全验证模态框 */}
+      <SecureVerificationModal
+        visible={isModalVisible}
+        verificationMethods={verificationMethods}
+        verificationState={verificationState}
+        onVerify={executeVerification}
+        onCancel={cancelVerification}
+        onCodeChange={setVerificationCode}
+        onMethodSwitch={switchVerificationMethod}
+        title={verificationState.title}
+        description={verificationState.description}
+      />
+
+      {/* 密钥显示模态框 */}
+      <Modal
+        title={t('渠道密钥信息')}
+        visible={showKeyModal}
+        onCancel={() => setShowKeyModal(false)}
+        footer={
+          <Button type='primary' onClick={() => setShowKeyModal(false)}>
+            {t('完成')}
+          </Button>
+        }
+        width={700}
+        style={{ maxWidth: '90vw' }}
+      >
+        <ChannelKeyDisplay
+          keyData={keyData}
+          showSuccessIcon={true}
+          successText={t('密钥获取成功')}
+          showWarning={true}
+        />
+      </Modal>
+    </>
+  );
+};
+
+export default ChannelKeyViewExample;

+ 285 - 0
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 <https://www.gnu.org/licenses/>.
+
+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 (
+      <Modal
+        title={title || t('安全验证')}
+        visible={visible}
+        onCancel={onCancel}
+        footer={
+          <Button onClick={onCancel}>{t('确定')}</Button>
+        }
+        width={500}
+        style={{ maxWidth: '90vw' }}
+      >
+        <div className='text-center py-6'>
+          <div className='mb-4'>
+            <svg
+              className='w-16 h-16 text-yellow-500 mx-auto mb-4'
+              fill='currentColor'
+              viewBox='0 0 20 20'
+            >
+              <path
+                fillRule='evenodd'
+                d='M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z'
+                clipRule='evenodd'
+              />
+            </svg>
+          </div>
+          <Typography.Title heading={4} className='mb-2'>
+            {t('需要安全验证')}
+          </Typography.Title>
+          <Typography.Text type='tertiary'>
+            {t('您需要先启用两步验证或 Passkey 才能查看敏感信息。')}
+          </Typography.Text>
+          <br />
+          <Typography.Text type='tertiary'>
+            {t('请前往个人设置 → 安全设置进行配置。')}
+          </Typography.Text>
+        </div>
+      </Modal>
+    );
+  }
+
+  return (
+    <Modal
+      title={title || t('安全验证')}
+      visible={visible}
+      onCancel={loading ? undefined : onCancel}
+      closeOnEsc={!loading}
+      footer={null}
+      width={460}
+      centered
+      style={{
+        maxWidth: 'calc(100vw - 32px)'
+      }}
+      bodyStyle={{
+        padding: '20px 24px'
+      }}
+    >
+      <div style={{ width: '100%' }}>
+        {/* 描述信息 */}
+        {description && (
+          <Typography.Paragraph
+            type="tertiary"
+            style={{
+              margin: '0 0 20px 0',
+              fontSize: '14px',
+              lineHeight: '1.6'
+            }}
+          >
+            {description}
+          </Typography.Paragraph>
+        )}
+
+        {/* 验证方式选择 */}
+        <Tabs
+          activeKey={method}
+          onChange={onMethodSwitch}
+          type='line'
+          size='default'
+          style={{ margin: 0 }}
+        >
+          {has2FA && (
+            <TabPane
+              tab={t('两步验证')}
+              itemKey='2fa'
+            >
+              <div style={{ paddingTop: '20px' }}>
+                <div style={{ marginBottom: '12px' }}>
+                  <Input
+                    placeholder={t('请输入6位验证码或8位备用码')}
+                    value={code}
+                    onChange={onCodeChange}
+                    size='large'
+                    maxLength={8}
+                    onKeyDown={handleKeyDown}
+                    autoFocus={method === '2fa'}
+                    disabled={loading}
+                    prefix={
+                      <svg style={{ width: 16, height: 16, marginRight: 8, flexShrink: 0 }} fill='currentColor' viewBox='0 0 20 20'>
+                        <path fillRule='evenodd' d='M18 8a6 6 0 01-7.743 5.743L10 14l-1 1-1 1H6v2H2v-4l4.257-4.257A6 6 0 1118 8zm-6-4a1 1 0 100 2 2 2 0 012 2 1 1 0 102 0 4 4 0 00-4-4z' clipRule='evenodd' />
+                      </svg>
+                    }
+                    style={{ width: '100%' }}
+                  />
+                </div>
+
+                <Typography.Text
+                  type="tertiary"
+                  size="small"
+                  style={{
+                    display: 'block',
+                    marginBottom: '20px',
+                    fontSize: '13px',
+                    lineHeight: '1.5'
+                  }}
+                >
+                  {t('从认证器应用中获取验证码,或使用备用码')}
+                </Typography.Text>
+
+                <div style={{
+                  display: 'flex',
+                  justifyContent: 'flex-end',
+                  gap: '8px',
+                  flexWrap: 'wrap'
+                }}>
+                  <Button onClick={onCancel} disabled={loading}>
+                    {t('取消')}
+                  </Button>
+                  <Button
+                    theme='solid'
+                    type='primary'
+                    loading={loading}
+                    disabled={!code.trim() || loading}
+                    onClick={() => onVerify(method, code)}
+                  >
+                    {t('验证')}
+                  </Button>
+                </div>
+              </div>
+            </TabPane>
+          )}
+
+          {hasPasskey && passkeySupported && (
+            <TabPane
+              tab={t('Passkey')}
+              itemKey='passkey'
+            >
+              <div style={{ paddingTop: '20px' }}>
+                <div style={{
+                  textAlign: 'center',
+                  padding: '24px 16px',
+                  marginBottom: '20px'
+                }}>
+                  <div style={{
+                    width: 56,
+                    height: 56,
+                    margin: '0 auto 16px',
+                    display: 'flex',
+                    alignItems: 'center',
+                    justifyContent: 'center',
+                    borderRadius: '50%',
+                    background: 'var(--semi-color-primary-light-default)',
+                  }}>
+                    <svg style={{ width: 28, height: 28, color: 'var(--semi-color-primary)' }} fill='currentColor' viewBox='0 0 20 20'>
+                      <path fillRule='evenodd' d='M18 8a6 6 0 01-7.743 5.743L10 14l-1 1-1 1H6v2H2v-4l4.257-4.257A6 6 0 1118 8zm-6-4a1 1 0 100 2 2 2 0 012 2 1 1 0 102 0 4 4 0 00-4-4z' clipRule='evenodd' />
+                    </svg>
+                  </div>
+                  <Typography.Title heading={5} style={{ margin: '0 0 8px', fontSize: '16px' }}>
+                    {t('使用 Passkey 验证')}
+                  </Typography.Title>
+                  <Typography.Text
+                    type='tertiary'
+                    style={{
+                      display: 'block',
+                      margin: 0,
+                      fontSize: '13px',
+                      lineHeight: '1.5'
+                    }}
+                  >
+                    {t('点击验证按钮,使用您的生物特征或安全密钥')}
+                  </Typography.Text>
+                </div>
+
+                <div style={{
+                  display: 'flex',
+                  justifyContent: 'flex-end',
+                  gap: '8px',
+                  flexWrap: 'wrap'
+                }}>
+                  <Button onClick={onCancel} disabled={loading}>
+                    {t('取消')}
+                  </Button>
+                  <Button
+                    theme='solid'
+                    type='primary'
+                    loading={loading}
+                    disabled={loading}
+                    onClick={() => onVerify(method)}
+                  >
+                    {t('验证 Passkey')}
+                  </Button>
+                </div>
+              </div>
+            </TabPane>
+          )}
+        </Tabs>
+      </div>
+    </Modal>
+  );
+};
+
+export default SecureVerificationModal;

+ 34 - 23
web/src/components/layout/Footer.jsx

@@ -142,14 +142,6 @@ const FooterBar = () => {
                   >
                     Midjourney-Proxy
                   </a>
-                  <a
-                    href='https://github.com/Deeptrain-Community/chatnio'
-                    target='_blank'
-                    rel='noopener noreferrer'
-                    className='!text-semi-color-text-1'
-                  >
-                    chatnio
-                  </a>
                   <a
                     href='https://github.com/Calcium-Ion/neko-api-key-tool'
                     target='_blank'
@@ -163,7 +155,7 @@ const FooterBar = () => {
 
               <div className='text-left'>
                 <p className='!text-semi-color-text-0 font-semibold mb-5'>
-                  {t('基于New API的项目')}
+                  {t('友情链接')}
                 </p>
                 <div className='flex flex-col gap-4'>
                   <a
@@ -174,7 +166,22 @@ const FooterBar = () => {
                   >
                     new-api-horizon
                   </a>
-                  {/* <a href="https://github.com/VoAPI/VoAPI" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">VoAPI</a> */}
+                  <a
+                    href='https://github.com/coaidev/coai'
+                    target='_blank'
+                    rel='noopener noreferrer'
+                    className='!text-semi-color-text-1'
+                  >
+                    CoAI
+                  </a>
+                  <a
+                    href='https://www.gpt-load.com/'
+                    target='_blank'
+                    rel='noopener noreferrer'
+                    className='!text-semi-color-text-1'
+                  >
+                    GPT-Load
+                  </a>
                 </div>
               </div>
             </div>
@@ -200,15 +207,6 @@ const FooterBar = () => {
             >
               New API
             </a>
-            <span className='!text-semi-color-text-1'> & </span>
-            <a
-              href='https://github.com/songquanpeng/one-api'
-              target='_blank'
-              rel='noopener noreferrer'
-              className='!text-semi-color-primary font-medium'
-            >
-              One API
-            </a>
           </div>
         </div>
       </footer>
@@ -223,10 +221,23 @@ const FooterBar = () => {
   return (
     <div className='w-full'>
       {footer ? (
-        <div
-          className='custom-footer'
-          dangerouslySetInnerHTML={{ __html: footer }}
-        ></div>
+        <div className='relative'>
+          <div
+            className='custom-footer'
+            dangerouslySetInnerHTML={{ __html: footer }}
+          ></div>
+          <div className='absolute bottom-2 right-4 text-xs !text-semi-color-text-2 opacity-70'>
+            <span>{t('设计与开发由')} </span>
+            <a
+              href='https://github.com/QuantumNous/new-api'
+              target='_blank'
+              rel='noopener noreferrer'
+              className='!text-semi-color-primary font-medium'
+            >
+              New API
+            </a>
+          </div>
+        </div>
       ) : (
         customFooter
       )}

+ 13 - 3
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') &&

+ 1 - 1
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([]);

+ 112 - 0
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}
             />
 
             {/* 右侧:其他设置 */}

+ 164 - 0
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 = () => {
                 </Form.Section>
               </Card>
 
+              <Card>
+                <Form.Section text={t('配置 Passkey')}>
+                  <Text>{t('用以支持基于 WebAuthn 的无密码登录注册')}</Text>
+                  <Banner
+                    type='info'
+                    description={t('Passkey 是基于 WebAuthn 标准的无密码身份验证方法,支持指纹、面容、硬件密钥等认证方式')}
+                    style={{ marginBottom: 20, marginTop: 16 }}
+                  />
+                  <Row
+                    gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
+                  >
+                    <Col xs={24} sm={24} md={24} lg={24} xl={24}>
+                      <Form.Checkbox
+                        field="['passkey.enabled']"
+                        noLabel
+                        onChange={(e) =>
+                          handleCheckboxChange('passkey.enabled', e)
+                        }
+                      >
+                        {t('允许通过 Passkey 登录 & 认证')}
+                      </Form.Checkbox>
+                    </Col>
+                  </Row>
+                  <Row
+                    gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
+                  >
+                    <Col xs={24} sm={24} md={12} lg={12} xl={12}>
+                      <Form.Input
+                        field="['passkey.rp_display_name']"
+                        label={t('服务显示名称')}
+                        placeholder={t('默认使用系统名称')}
+                        extraText={t('用户注册时看到的网站名称,比如\'我的网站\'')}
+                      />
+                    </Col>
+                    <Col xs={24} sm={24} md={12} lg={12} xl={12}>
+                      <Form.Input
+                        field="['passkey.rp_id']"
+                        label={t('网站域名标识')}
+                        placeholder={t('例如:example.com')}
+                        extraText={t('留空则默认使用服务器地址,注意不能携带http://或者https://')}
+                      />
+                    </Col>
+                  </Row>
+                  <Row
+                    gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
+                    style={{ marginTop: 16 }}
+                  >
+                    <Col xs={24} sm={24} md={12} lg={12} xl={12}>
+                      <Form.Select
+                        field="['passkey.user_verification']"
+                        label={t('安全验证级别')}
+                        placeholder={t('是否要求指纹/面容等生物识别')}
+                        optionList={[
+                          { label: t('推荐使用(用户可选)'), value: 'preferred' },
+                          { label: t('强制要求'), value: 'required' },
+                          { label: t('不建议使用'), value: 'discouraged' },
+                        ]}
+                        extraText={t('推荐:用户可以选择是否使用指纹等验证')}
+                      />
+                    </Col>
+                    <Col xs={24} sm={24} md={12} lg={12} xl={12}>
+                      <Form.Select
+                        field="['passkey.attachment_preference']"
+                        label={t('设备类型偏好')}
+                        placeholder={t('选择支持的认证设备类型')}
+                        optionList={[
+                          { label: t('不限制'), value: '' },
+                          { label: t('本设备内置'), value: 'platform' },
+                          { label: t('外接设备'), value: 'cross-platform' },
+                        ]}
+                        extraText={t('本设备:手机指纹/面容,外接:USB安全密钥')}
+                      />
+                    </Col>
+                  </Row>
+                  <Row
+                    gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
+                    style={{ marginTop: 16 }}
+                  >
+                    <Col xs={24} sm={24} md={24} lg={24} xl={24}>
+                      <Form.Checkbox
+                        field="['passkey.allow_insecure_origin']"
+                        noLabel
+                        extraText={t('仅用于开发环境,生产环境应使用 HTTPS')}
+                        onChange={(e) =>
+                          handleCheckboxChange('passkey.allow_insecure_origin', e)
+                        }
+                      >
+                        {t('允许不安全的 Origin(HTTP)')}
+                      </Form.Checkbox>
+                    </Col>
+                  </Row>
+                  <Row
+                    gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
+                    style={{ marginTop: 16 }}
+                  >
+                    <Col xs={24} sm={24} md={24} lg={24} xl={24}>
+                      <Form.Input
+                        field="['passkey.origins']"
+                        label={t('允许的 Origins')}
+                        placeholder={t('填写带https的域名,逗号分隔')}
+                        extraText={t('为空则默认使用服务器地址,多个 Origin 用逗号分隔,例如 https://newapi.pro,https://newapi.com ,注意不能携带[],需使用https')}
+                      />
+                    </Col>
+                  </Row>
+                  <Button onClick={submitPasskeySettings} style={{ marginTop: 16 }}>
+                    {t('保存 Passkey 设置')}
+                  </Button>
+                </Form.Section>
+              </Card>
+
               <Card>
                 <Form.Section text={t('配置邮箱域名白名单')}>
                   <Text>{t('用以防止恶意用户利用临时邮箱批量注册')}</Text>

+ 75 - 0
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 (
     <Card className='!rounded-2xl'>
@@ -479,6 +489,71 @@ const AccountManagement = ({
                   </div>
                 </Card>
 
+                {/* Passkey 设置 */}
+                <Card className='!rounded-xl w-full'>
+                  <div className='flex flex-col sm:flex-row items-start sm:justify-between gap-4'>
+                    <div className='flex items-start w-full sm:w-auto'>
+                      <div className='w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center mr-4 flex-shrink-0'>
+                        <IconKey size='large' className='text-slate-600' />
+                      </div>
+                      <div>
+                        <Typography.Title heading={6} className='mb-1'>
+                          {t('Passkey 登录')}
+                        </Typography.Title>
+                        <Typography.Text type='tertiary' className='text-sm'>
+                          {passkeyEnabled
+                            ? t('已启用 Passkey,无需密码即可登录')
+                            : t('使用 Passkey 实现免密且更安全的登录体验')}
+                        </Typography.Text>
+                        <div className='mt-2 text-xs text-gray-500 space-y-1'>
+                          <div>
+                            {t('最后使用时间')}:{lastUsedLabel}
+                          </div>
+                          {/*{passkeyEnabled && (*/}
+                          {/*  <div>*/}
+                          {/*    {t('备份支持')}:*/}
+                          {/*    {passkeyStatus?.backup_eligible*/}
+                          {/*      ? t('支持备份')*/}
+                          {/*      : t('不支持')}*/}
+                          {/*    ,{t('备份状态')}:*/}
+                          {/*    {passkeyStatus?.backup_state ? t('已备份') : t('未备份')}*/}
+                          {/*  </div>*/}
+                          {/*)}*/}
+                          {!passkeySupported && (
+                            <div className='text-amber-600'>
+                              {t('当前设备不支持 Passkey')}
+                            </div>
+                          )}
+                        </div>
+                      </div>
+                    </div>
+                    <Button
+                      type={passkeyEnabled ? 'danger' : 'primary'}
+                      theme={passkeyEnabled ? 'solid' : 'solid'}
+                      onClick={
+                        passkeyEnabled
+                          ? () => {
+                              Modal.confirm({
+                                title: t('确认解绑 Passkey'),
+                                content: t('解绑后将无法使用 Passkey 登录,确定要继续吗?'),
+                                okText: t('确认解绑'),
+                                cancelText: t('取消'),
+                                okType: 'danger',
+                                onOk: onPasskeyDelete,
+                              });
+                            }
+                          : onPasskeyRegister
+                      }
+                      className={`w-full sm:w-auto ${passkeyEnabled ? '!bg-slate-500 hover:!bg-slate-600' : ''}`}
+                      icon={<IconKey />}
+                      disabled={!passkeySupported && !passkeyEnabled}
+                      loading={passkeyEnabled ? passkeyDeleteLoading : passkeyRegisterLoading}
+                    >
+                      {passkeyEnabled ? t('解绑 Passkey') : t('注册 Passkey')}
+                    </Button>
+                  </div>
+                </Card>
+
                 {/* 两步验证设置 */}
                 <TwoFASetting t={t} />
 

+ 103 - 1
web/src/components/settings/personal/cards/NotificationSettings.jsx

@@ -400,6 +400,7 @@ const NotificationSettings = ({
                   <Radio value='email'>{t('邮件通知')}</Radio>
                   <Radio value='webhook'>{t('Webhook通知')}</Radio>
                   <Radio value='bark'>{t('Bark通知')}</Radio>
+                  <Radio value='gotify'>{t('Gotify通知')}</Radio>
                 </Form.RadioGroup>
 
                 <Form.AutoComplete
@@ -589,7 +590,108 @@ const NotificationSettings = ({
                             rel='noopener noreferrer'
                             className='text-blue-500 hover:text-blue-600 font-medium'
                           >
-                            Bark 官方文档
+                            Bark {t('官方文档')}
+                          </a>
+                        </div>
+                      </div>
+                    </div>
+                  </>
+                )}
+
+                {/* Gotify推送设置 */}
+                {notificationSettings.warningType === 'gotify' && (
+                  <>
+                    <Form.Input
+                      field='gotifyUrl'
+                      label={t('Gotify服务器地址')}
+                      placeholder={t(
+                        '请输入Gotify服务器地址,例如: https://gotify.example.com',
+                      )}
+                      onChange={(val) => handleFormChange('gotifyUrl', val)}
+                      prefix={<IconLink />}
+                      extraText={t(
+                        '支持HTTP和HTTPS,填写Gotify服务器的完整URL地址',
+                      )}
+                      showClear
+                      rules={[
+                        {
+                          required:
+                            notificationSettings.warningType === 'gotify',
+                          message: t('请输入Gotify服务器地址'),
+                        },
+                        {
+                          pattern: /^https?:\/\/.+/,
+                          message: t('Gotify服务器地址必须以http://或https://开头'),
+                        },
+                      ]}
+                    />
+
+                    <Form.Input
+                      field='gotifyToken'
+                      label={t('Gotify应用令牌')}
+                      placeholder={t('请输入Gotify应用令牌')}
+                      onChange={(val) => handleFormChange('gotifyToken', val)}
+                      prefix={<IconKey />}
+                      extraText={t(
+                        '在Gotify服务器创建应用后获得的令牌,用于发送通知',
+                      )}
+                      showClear
+                      rules={[
+                        {
+                          required:
+                            notificationSettings.warningType === 'gotify',
+                          message: t('请输入Gotify应用令牌'),
+                        },
+                      ]}
+                    />
+
+                    <Form.AutoComplete
+                      field='gotifyPriority'
+                      label={t('消息优先级')}
+                      placeholder={t('请选择消息优先级')}
+                      data={[
+                        { value: 0, label: t('0 - 最低') },
+                        { value: 2, label: t('2 - 低') },
+                        { value: 5, label: t('5 - 正常(默认)') },
+                        { value: 8, label: t('8 - 高') },
+                        { value: 10, label: t('10 - 最高') },
+                      ]}
+                      onChange={(val) =>
+                        handleFormChange('gotifyPriority', val)
+                      }
+                      prefix={<IconBell />}
+                      extraText={t('消息优先级,范围0-10,默认为5')}
+                      style={{ width: '100%', maxWidth: '300px' }}
+                    />
+
+                    <div className='mt-3 p-4 bg-gray-50/50 rounded-xl'>
+                      <div className='text-sm text-gray-700 mb-3'>
+                        <strong>{t('配置说明')}</strong>
+                      </div>
+                      <div className='text-xs text-gray-500 space-y-2'>
+                        <div>
+                          1. {t('在Gotify服务器的应用管理中创建新应用')}
+                        </div>
+                        <div>
+                          2.{' '}
+                          {t(
+                            '复制应用的令牌(Token)并填写到上方的应用令牌字段',
+                          )}
+                        </div>
+                        <div>
+                          3. {t('填写Gotify服务器的完整URL地址')}
+                        </div>
+                        <div className='mt-3 pt-3 border-t border-gray-200'>
+                          <span className='text-gray-400'>
+                            {t('更多信息请参考')}
+                          </span>{' '}
+                          <a
+                            href='https://gotify.net/'
+                            target='_blank'
+                            rel='noopener noreferrer'
+                            className='text-blue-500 hover:text-blue-600 font-medium'
+                          >
+                            Gotify {t('官方文档')}
                           </a>
                         </div>
                       </div>

+ 333 - 112
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={
-          <div className='flex justify-end bg-white'>
+          <div className='flex justify-between items-center bg-white'>
+            <div className='flex gap-2'>
+              <Button
+                size='small'
+                type='tertiary'
+                icon={<IconChevronUp />}
+                onClick={() => navigateToSection('up')}
+                style={{
+                  borderRadius: '50%',
+                  width: '32px',
+                  height: '32px',
+                  padding: 0,
+                  display: 'flex',
+                  alignItems: 'center',
+                  justifyContent: 'center'
+                }}
+                title={t('上一个表单块')}
+              />
+              <Button
+                size='small'
+                type='tertiary'
+                icon={<IconChevronDown />}
+                onClick={() => navigateToSection('down')}
+                style={{
+                  borderRadius: '50%',
+                  width: '32px',
+                  height: '32px',
+                  padding: 0,
+                  display: 'flex',
+                  alignItems: 'center',
+                  justifyContent: 'center'
+                }}
+                title={t('下一个表单块')}
+              />
+            </div>
             <Space>
               <Button
                 theme='solid'
@@ -1264,10 +1390,14 @@ const EditChannelModal = (props) => {
         >
           {() => (
             <Spin spinning={loading}>
-              <div className='p-2'>
-                <Card className='!rounded-2xl shadow-sm border-0 mb-6'>
-                  {/* Header: Basic Info */}
-                  <div className='flex items-center mb-2'>
+              <div
+                className='p-2'
+                ref={formContainerRef}
+              >
+                <div ref={el => formSectionRefs.current.basicInfo = el}>
+                  <Card className='!rounded-2xl shadow-sm border-0 mb-6'>
+                    {/* Header: Basic Info */}
+                    <div className='flex items-center mb-2'>
                     <Avatar
                       size='small'
                       color='blue'
@@ -1743,13 +1873,15 @@ const EditChannelModal = (props) => {
                       }
                     />
                   )}
-                </Card>
+                  </Card>
+                </div>
 
                 {/* API Configuration Card */}
                 {showApiConfigCard && (
-                  <Card className='!rounded-2xl shadow-sm border-0 mb-6'>
-                    {/* Header: API Config */}
-                    <div className='flex items-center mb-2'>
+                  <div ref={el => formSectionRefs.current.apiConfig = el}>
+                    <Card className='!rounded-2xl shadow-sm border-0 mb-6'>
+                      {/* Header: API Config */}
+                      <div className='flex items-center mb-2'>
                       <Avatar
                         size='small'
                         color='green'
@@ -1960,13 +2092,15 @@ const EditChannelModal = (props) => {
                         />
                       </div>
                     )}
-                  </Card>
+                    </Card>
+                  </div>
                 )}
 
                 {/* Model Configuration Card */}
-                <Card className='!rounded-2xl shadow-sm border-0 mb-6'>
-                  {/* Header: Model Config */}
-                  <div className='flex items-center mb-2'>
+                <div ref={el => formSectionRefs.current.modelConfig = el}>
+                  <Card className='!rounded-2xl shadow-sm border-0 mb-6'>
+                    {/* Header: Model Config */}
+                    <div className='flex items-center mb-2'>
                     <Avatar
                       size='small'
                       color='purple'
@@ -2161,12 +2295,14 @@ const EditChannelModal = (props) => {
                     formApi={formApiRef.current}
                     extraText={t('键为请求中的模型名称,值为要替换的模型名称')}
                   />
-                </Card>
+                  </Card>
+                </div>
 
                 {/* Advanced Settings Card */}
-                <Card className='!rounded-2xl shadow-sm border-0 mb-6'>
-                  {/* Header: Advanced Settings */}
-                  <div className='flex items-center mb-2'>
+                <div ref={el => formSectionRefs.current.advancedSettings = el}>
+                  <Card className='!rounded-2xl shadow-sm border-0 mb-6'>
+                    {/* Header: Advanced Settings */}
+                    <div className='flex items-center mb-2'>
                     <Avatar
                       size='small'
                       color='orange'
@@ -2325,32 +2461,44 @@ const EditChannelModal = (props) => {
                       t('此项可选,用于覆盖请求头参数') +
                       '\n' +
                       t('格式示例:') +
-                      '\n{\n  "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36 Edg/139.0.0.0"\n}'
+                      '\n{\n  "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36 Edg/139.0.0.0",\n  "Authorization": "Bearer {api_key}"\n}'
                     }
                     autosize
                     onChange={(value) =>
                       handleInputChange('header_override', value)
                     }
                     extraText={
-                      <div className='flex gap-2 flex-wrap'>
-                        <Text
-                          className='!text-semi-color-primary cursor-pointer'
-                          onClick={() =>
-                            handleInputChange(
-                              'header_override',
-                              JSON.stringify(
-                                {
-                                  'User-Agent':
-                                    'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36 Edg/139.0.0.0',
-                                },
-                                null,
-                                2,
-                              ),
-                            )
-                          }
-                        >
-                          {t('格式模板')}
-                        </Text>
+
+                      <div className='flex flex-col gap-1'>
+                        <div className='flex gap-2 flex-wrap items-center'>
+                          <Text
+                            className='!text-semi-color-primary cursor-pointer'
+                            onClick={() =>
+                              handleInputChange(
+                                'header_override',
+                                JSON.stringify(
+                                  {
+                                    'User-Agent':
+                                      'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36 Edg/139.0.0.0',
+                                    'Authorization': 'Bearer{api_key}',
+                                  },
+                                  null,
+                                  2,
+                                ),
+                              )
+                            }
+                          >
+                            {t('填入模板')}
+                          </Text>
+                        </div>
+                        <div>
+                          <Text type='tertiary' size='small'>
+                            {t('支持变量:')}
+                          </Text>
+                          <div className='text-xs text-tertiary ml-2'>
+                            <div>{t('渠道密钥')}: {'{api_key}'}</div>
+                          </div>
+                        </div>
                       </div>
                     }
                     showClear
@@ -2379,12 +2527,84 @@ const EditChannelModal = (props) => {
                       '键为原状态码,值为要复写的状态码,仅影响本地判断',
                     )}
                   />
-                </Card>
+
+                  {/* 字段透传控制 - OpenAI 渠道 */}
+                  {inputs.type === 1 && (
+                    <>
+                      <div className='mt-4 mb-2 text-sm font-medium text-gray-700'>
+                        {t('字段透传控制')}
+                      </div>
+
+                      <Form.Switch
+                        field='allow_service_tier'
+                        label={t('允许 service_tier 透传')}
+                        checkedText={t('开')}
+                        uncheckedText={t('关')}
+                        onChange={(value) =>
+                          handleChannelOtherSettingsChange('allow_service_tier', value)
+                        }
+                        extraText={t(
+                          'service_tier 字段用于指定服务层级,允许透传可能导致实际计费高于预期。默认关闭以避免额外费用',
+                        )}
+                      />
+
+                      <Form.Switch
+                        field='disable_store'
+                        label={t('禁用 store 透传')}
+                        checkedText={t('开')}
+                        uncheckedText={t('关')}
+                        onChange={(value) =>
+                          handleChannelOtherSettingsChange('disable_store', value)
+                        }
+                        extraText={t(
+                          'store 字段用于授权 OpenAI 存储请求数据以评估和优化产品。默认关闭,开启后可能导致 Codex 无法正常使用',
+                        )}
+                      />
+
+                      <Form.Switch
+                        field='allow_safety_identifier'
+                        label={t('允许 safety_identifier 透传')}
+                        checkedText={t('开')}
+                        uncheckedText={t('关')}
+                        onChange={(value) =>
+                          handleChannelOtherSettingsChange('allow_safety_identifier', value)
+                        }
+                        extraText={t(
+                          'safety_identifier 字段用于帮助 OpenAI 识别可能违反使用政策的应用程序用户。默认关闭以保护用户隐私',
+                        )}
+                      />
+                    </>
+                  )}
+
+                  {/* 字段透传控制 - Claude 渠道 */}
+                  {(inputs.type === 14) && (
+                    <>
+                      <div className='mt-4 mb-2 text-sm font-medium text-gray-700'>
+                        {t('字段透传控制')}
+                      </div>
+
+                      <Form.Switch
+                        field='allow_service_tier'
+                        label={t('允许 service_tier 透传')}
+                        checkedText={t('开')}
+                        uncheckedText={t('关')}
+                        onChange={(value) =>
+                          handleChannelOtherSettingsChange('allow_service_tier', value)
+                        }
+                        extraText={t(
+                          'service_tier 字段用于指定服务层级,允许透传可能导致实际计费高于预期。默认关闭以避免额外费用',
+                        )}
+                      />
+                    </>
+                  )}
+                  </Card>
+                </div>
 
                 {/* Channel Extra Settings Card */}
-                <Card className='!rounded-2xl shadow-sm border-0 mb-6'>
-                  {/* Header: Channel Extra Settings */}
-                  <div className='flex items-center mb-2'>
+                <div ref={el => formSectionRefs.current.channelExtraSettings = el}>
+                  <Card className='!rounded-2xl shadow-sm border-0 mb-6'>
+                    {/* Header: Channel Extra Settings */}
+                    <div className='flex items-center mb-2'>
                     <Avatar
                       size='small'
                       color='violet'
@@ -2482,7 +2702,8 @@ const EditChannelModal = (props) => {
                       '如果用户请求中包含系统提示词,则使用此设置拼接到用户的系统提示词前面',
                     )}
                   />
-                </Card>
+                  </Card>
+                </div>
               </div>
             </Spin>
           )}
@@ -2493,17 +2714,17 @@ const EditChannelModal = (props) => {
           onVisibleChange={(visible) => setIsModalOpenurl(visible)}
         />
       </SideSheet>
-      {/* 使用TwoFactorAuthModal组件进行2FA验证 */}
-      <TwoFactorAuthModal
-        visible={show2FAVerifyModal}
-        code={verifyCode}
-        loading={verifyLoading}
-        onCodeChange={setVerifyCode}
-        onVerify={handleVerify2FA}
-        onCancel={reset2FAVerifyState}
-        title={t('查看渠道密钥')}
-        description={t('为了保护账户安全,请验证您的两步验证码。')}
-        placeholder={t('请输入验证码或备用码')}
+      {/* 使用通用安全验证模态框 */}
+      <SecureVerificationModal
+        visible={isModalVisible}
+        verificationMethods={verificationMethods}
+        verificationState={verificationState}
+        onVerify={executeVerification}
+        onCancel={cancelVerification}
+        onCodeChange={setVerificationCode}
+        onMethodSwitch={switchVerificationMethod}
+        title={verificationState.title}
+        description={verificationState.description}
       />
 
       {/* 使用ChannelKeyDisplay组件显示密钥 */}
@@ -2526,10 +2747,10 @@ const EditChannelModal = (props) => {
             {t('渠道密钥信息')}
           </div>
         }
-        visible={twoFAState.showModal && twoFAState.showKey}
-        onCancel={resetTwoFAState}
+        visible={keyDisplayState.showModal}
+        onCancel={resetKeyDisplayState}
         footer={
-          <Button type='primary' onClick={resetTwoFAState}>
+          <Button type='primary' onClick={resetKeyDisplayState}>
             {t('完成')}
           </Button>
         }
@@ -2537,7 +2758,7 @@ const EditChannelModal = (props) => {
         style={{ maxWidth: '90vw' }}
       >
         <ChannelKeyDisplay
-          keyData={twoFAState.keyData}
+          keyData={keyDisplayState.keyData}
           showSuccessIcon={true}
           successText={t('密钥获取成功')}
           showWarning={true}

+ 3 - 0
web/src/components/table/channels/modals/EditTagModal.jsx

@@ -118,6 +118,9 @@ const EditTagModal = (props) => {
         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;

+ 27 - 1
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 (
           <Button
             type='tertiary'
-            onClick={() => testChannel(currentTestChannel, record.model)}
+            onClick={() => testChannel(currentTestChannel, record.model, selectedEndpointType)}
             loading={isTesting}
             size='small'
           >
@@ -228,6 +242,18 @@ const ModelTestModal = ({
     >
       {hasChannel && (
         <div className='model-test-scroll'>
+          {/* 端点类型选择器 */}
+          <div className='flex items-center gap-2 w-full mb-2'>
+            <Typography.Text strong>{t('端点类型')}:</Typography.Text>
+            <Select
+              value={selectedEndpointType}
+              onChange={setSelectedEndpointType}
+              optionList={endpointTypeOptions}
+              className='!w-full'
+              placeholder={t('选择端点类型')}
+            />
+          </div>
+
           {/* 搜索与操作按钮 */}
           <div className='flex items-center justify-end gap-2 w-full mb-2'>
             <Input

+ 40 - 6
web/src/components/table/users/UsersColumnDefs.jsx

@@ -26,7 +26,9 @@ import {
   Progress,
   Popover,
   Typography,
+  Dropdown,
 } from '@douyinfe/semi-ui';
+import { IconMore } from '@douyinfe/semi-icons';
 import { renderGroup, renderNumber, renderQuota } from '../../../helpers';
 
 /**
@@ -204,6 +206,8 @@ const renderOperations = (
     showDemoteModal,
     showEnableDisableModal,
     showDeleteModal,
+    showResetPasskeyModal,
+    showResetTwoFAModal,
     t,
   },
 ) => {
@@ -211,6 +215,28 @@ const renderOperations = (
     return <></>;
   }
 
+  const moreMenu = [
+    {
+      node: 'item',
+      name: t('重置 Passkey'),
+      onClick: () => showResetPasskeyModal(record),
+    },
+    {
+      node: 'item',
+      name: t('重置 2FA'),
+      onClick: () => showResetTwoFAModal(record),
+    },
+    {
+      node: 'divider',
+    },
+    {
+      node: 'item',
+      name: t('注销'),
+      type: 'danger',
+      onClick: () => showDeleteModal(record),
+    },
+  ];
+
   return (
     <Space>
       {record.status === 1 ? (
@@ -253,13 +279,17 @@ const renderOperations = (
       >
         {t('降级')}
       </Button>
-      <Button
-        type='danger'
-        size='small'
-        onClick={() => showDeleteModal(record)}
+      <Dropdown
+        menu={moreMenu}
+        trigger='click'
+        position='bottomRight'
       >
-        {t('注销')}
-      </Button>
+        <Button
+          type='tertiary'
+          size='small'
+          icon={<IconMore />}
+        />
+      </Dropdown>
     </Space>
   );
 };
@@ -275,6 +305,8 @@ export const getUsersColumns = ({
   showDemoteModal,
   showEnableDisableModal,
   showDeleteModal,
+  showResetPasskeyModal,
+  showResetTwoFAModal,
 }) => {
   return [
     {
@@ -329,6 +361,8 @@ export const getUsersColumns = ({
           showDemoteModal,
           showEnableDisableModal,
           showDeleteModal,
+          showResetPasskeyModal,
+          showResetTwoFAModal,
           t,
         }),
     },

+ 55 - 1
web/src/components/table/users/UsersTable.jsx

@@ -29,6 +29,8 @@ import PromoteUserModal from './modals/PromoteUserModal';
 import DemoteUserModal from './modals/DemoteUserModal';
 import EnableDisableUserModal from './modals/EnableDisableUserModal';
 import DeleteUserModal from './modals/DeleteUserModal';
+import ResetPasskeyModal from './modals/ResetPasskeyModal';
+import ResetTwoFAModal from './modals/ResetTwoFAModal';
 
 const UsersTable = (usersData) => {
   const {
@@ -45,6 +47,8 @@ const UsersTable = (usersData) => {
     setShowEditUser,
     manageUser,
     refresh,
+    resetUserPasskey,
+    resetUserTwoFA,
     t,
   } = usersData;
 
@@ -55,6 +59,8 @@ const UsersTable = (usersData) => {
   const [showDeleteModal, setShowDeleteModal] = useState(false);
   const [modalUser, setModalUser] = useState(null);
   const [enableDisableAction, setEnableDisableAction] = useState('');
+  const [showResetPasskeyModal, setShowResetPasskeyModal] = useState(false);
+  const [showResetTwoFAModal, setShowResetTwoFAModal] = useState(false);
 
   // Modal handlers
   const showPromoteUserModal = (user) => {
@@ -78,6 +84,16 @@ const UsersTable = (usersData) => {
     setShowDeleteModal(true);
   };
 
+  const showResetPasskeyUserModal = (user) => {
+    setModalUser(user);
+    setShowResetPasskeyModal(true);
+  };
+
+  const showResetTwoFAUserModal = (user) => {
+    setModalUser(user);
+    setShowResetTwoFAModal(true);
+  };
+
   // Modal confirm handlers
   const handlePromoteConfirm = () => {
     manageUser(modalUser.id, 'promote', modalUser);
@@ -94,6 +110,16 @@ const UsersTable = (usersData) => {
     setShowEnableDisableModal(false);
   };
 
+  const handleResetPasskeyConfirm = async () => {
+    await resetUserPasskey(modalUser);
+    setShowResetPasskeyModal(false);
+  };
+
+  const handleResetTwoFAConfirm = async () => {
+    await resetUserTwoFA(modalUser);
+    setShowResetTwoFAModal(false);
+  };
+
   // Get all columns
   const columns = useMemo(() => {
     return getUsersColumns({
@@ -104,8 +130,20 @@ const UsersTable = (usersData) => {
       showDemoteModal: showDemoteUserModal,
       showEnableDisableModal: showEnableDisableUserModal,
       showDeleteModal: showDeleteUserModal,
+      showResetPasskeyModal: showResetPasskeyUserModal,
+      showResetTwoFAModal: showResetTwoFAUserModal,
     });
-  }, [t, setEditingUser, setShowEditUser]);
+  }, [
+    t,
+    setEditingUser,
+    setShowEditUser,
+    showPromoteUserModal,
+    showDemoteUserModal,
+    showEnableDisableUserModal,
+    showDeleteUserModal,
+    showResetPasskeyUserModal,
+    showResetTwoFAUserModal,
+  ]);
 
   // Handle compact mode by removing fixed positioning
   const tableColumns = useMemo(() => {
@@ -188,6 +226,22 @@ const UsersTable = (usersData) => {
         manageUser={manageUser}
         t={t}
       />
+
+      <ResetPasskeyModal
+        visible={showResetPasskeyModal}
+        onCancel={() => setShowResetPasskeyModal(false)}
+        onConfirm={handleResetPasskeyConfirm}
+        user={modalUser}
+        t={t}
+      />
+
+      <ResetTwoFAModal
+        visible={showResetTwoFAModal}
+        onCancel={() => setShowResetTwoFAModal(false)}
+        onConfirm={handleResetTwoFAConfirm}
+        user={modalUser}
+        t={t}
+      />
     </>
   );
 };

+ 39 - 0
web/src/components/table/users/modals/ResetPasskeyModal.jsx

@@ -0,0 +1,39 @@
+/*
+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 <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React from 'react';
+import { Modal } from '@douyinfe/semi-ui';
+
+const ResetPasskeyModal = ({ visible, onCancel, onConfirm, user, t }) => {
+  return (
+    <Modal
+      title={t('确认重置 Passkey')}
+      visible={visible}
+      onCancel={onCancel}
+      onOk={onConfirm}
+      type='warning'
+    >
+      {t('此操作将解绑用户当前的 Passkey,下次登录需要重新注册。')}{' '}
+      {user?.username ? t('目标用户:{{username}}', { username: user.username }) : ''}
+    </Modal>
+  );
+};
+
+export default ResetPasskeyModal;
+

+ 39 - 0
web/src/components/table/users/modals/ResetTwoFAModal.jsx

@@ -0,0 +1,39 @@
+/*
+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 <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import React from 'react';
+import { Modal } from '@douyinfe/semi-ui';
+
+const ResetTwoFAModal = ({ visible, onCancel, onConfirm, user, t }) => {
+  return (
+    <Modal
+      title={t('确认重置两步验证')}
+      visible={visible}
+      onCancel={onCancel}
+      onOk={onConfirm}
+      type='warning'
+    >
+      {t('此操作将禁用该用户当前的两步验证配置,下次登录将不再强制输入验证码,直到用户重新启用。')}{' '}
+      {user?.username ? t('目标用户:{{username}}', { username: user.username }) : ''}
+    </Modal>
+  );
+};
+
+export default ResetTwoFAModal;
+

+ 10 - 0
web/src/constants/channel.constants.js

@@ -159,6 +159,16 @@ export const CHANNEL_OPTIONS = [
     color: 'purple',
     label: 'Vidu',
   },
+   {
+    value: 53,
+    color: 'blue',
+    label: 'SubModel',
+  },
+  {
+    value: 54,
+    color: 'blue',
+    label: '豆包视频',
+  },
 ];
 
 export const MODEL_TABLE_PAGE_SIZE = 10;

+ 1 - 0
web/src/helpers/index.js

@@ -27,3 +27,4 @@ export * from './data';
 export * from './token';
 export * from './boolean';
 export * from './dashboard';
+export * from './passkey';

+ 137 - 0
web/src/helpers/passkey.js

@@ -0,0 +1,137 @@
+export function base64UrlToBuffer(base64url) {
+  if (!base64url) return new ArrayBuffer(0);
+  let padding = '='.repeat((4 - (base64url.length % 4)) % 4);
+  const base64 = (base64url + padding).replace(/-/g, '+').replace(/_/g, '/');
+  const rawData = window.atob(base64);
+  const buffer = new ArrayBuffer(rawData.length);
+  const uintArray = new Uint8Array(buffer);
+  for (let i = 0; i < rawData.length; i += 1) {
+    uintArray[i] = rawData.charCodeAt(i);
+  }
+  return buffer;
+}
+
+export function bufferToBase64Url(buffer) {
+  if (!buffer) return '';
+  const uintArray = new Uint8Array(buffer);
+  let binary = '';
+  for (let i = 0; i < uintArray.byteLength; i += 1) {
+    binary += String.fromCharCode(uintArray[i]);
+  }
+  return window
+    .btoa(binary)
+    .replace(/\+/g, '-')
+    .replace(/\//g, '_')
+    .replace(/=+$/g, '');
+}
+
+export function prepareCredentialCreationOptions(payload) {
+  const options = payload?.publicKey || payload?.PublicKey || payload?.response || payload?.Response;
+  if (!options) {
+    throw new Error('无法从服务端响应中解析 Passkey 注册参数');
+  }
+  const publicKey = {
+    ...options,
+    challenge: base64UrlToBuffer(options.challenge),
+    user: {
+      ...options.user,
+      id: base64UrlToBuffer(options.user?.id),
+    },
+  };
+
+  if (Array.isArray(options.excludeCredentials)) {
+    publicKey.excludeCredentials = options.excludeCredentials.map((item) => ({
+      ...item,
+      id: base64UrlToBuffer(item.id),
+    }));
+  }
+
+  if (Array.isArray(options.attestationFormats) && options.attestationFormats.length === 0) {
+    delete publicKey.attestationFormats;
+  }
+
+  return publicKey;
+}
+
+export function prepareCredentialRequestOptions(payload) {
+  const options = payload?.publicKey || payload?.PublicKey || payload?.response || payload?.Response;
+  if (!options) {
+    throw new Error('无法从服务端响应中解析 Passkey 登录参数');
+  }
+  const publicKey = {
+    ...options,
+    challenge: base64UrlToBuffer(options.challenge),
+  };
+
+  if (Array.isArray(options.allowCredentials)) {
+    publicKey.allowCredentials = options.allowCredentials.map((item) => ({
+      ...item,
+      id: base64UrlToBuffer(item.id),
+    }));
+  }
+
+  return publicKey;
+}
+
+export function buildRegistrationResult(credential) {
+  if (!credential) return null;
+
+  const { response } = credential;
+  const transports = typeof response.getTransports === 'function' ? response.getTransports() : undefined;
+
+  return {
+    id: credential.id,
+    rawId: bufferToBase64Url(credential.rawId),
+    type: credential.type,
+    authenticatorAttachment: credential.authenticatorAttachment,
+    response: {
+      attestationObject: bufferToBase64Url(response.attestationObject),
+      clientDataJSON: bufferToBase64Url(response.clientDataJSON),
+      transports,
+    },
+    clientExtensionResults: credential.getClientExtensionResults?.() ?? {},
+  };
+}
+
+export function buildAssertionResult(assertion) {
+  if (!assertion) return null;
+
+  const { response } = assertion;
+
+  return {
+    id: assertion.id,
+    rawId: bufferToBase64Url(assertion.rawId),
+    type: assertion.type,
+    authenticatorAttachment: assertion.authenticatorAttachment,
+    response: {
+      authenticatorData: bufferToBase64Url(response.authenticatorData),
+      clientDataJSON: bufferToBase64Url(response.clientDataJSON),
+      signature: bufferToBase64Url(response.signature),
+      userHandle: response.userHandle ? bufferToBase64Url(response.userHandle) : null,
+    },
+    clientExtensionResults: assertion.getClientExtensionResults?.() ?? {},
+  };
+}
+
+export async function isPasskeySupported() {
+  if (typeof window === 'undefined' || !window.PublicKeyCredential) {
+    return false;
+  }
+  if (typeof window.PublicKeyCredential.isConditionalMediationAvailable === 'function') {
+    try {
+      const available = await window.PublicKeyCredential.isConditionalMediationAvailable();
+      if (available) return true;
+    } catch (error) {
+      // ignore
+    }
+  }
+  if (typeof window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable === 'function') {
+    try {
+      return await window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
+    } catch (error) {
+      return false;
+    }
+  }
+  return true;
+}
+

+ 70 - 68
web/src/helpers/render.jsx

@@ -337,6 +337,8 @@ export function getChannelIcon(channelType) {
       return <Kling.Color size={iconSize} />;
     case 51: // 即梦 Jimeng
       return <Jimeng.Color size={iconSize} />;
+    case 54: // 豆包视频 Doubao Video
+      return <Doubao.Color size={iconSize} />;
     case 8: // 自定义渠道
     case 22: // 知识库:FastGPT
       return <FastGPT.Color size={iconSize} />;
@@ -1247,25 +1249,25 @@ export function renderModelPrice(
               const extraServices = [
                 webSearch && webSearchCallCount > 0
                   ? i18next.t(
-                      ' + Web搜索 {{count}}次 / 1K 次 * ${{price}} * {{ratioType}} {{ratio}}',
-                      {
-                        count: webSearchCallCount,
-                        price: webSearchPrice,
-                        ratio: groupRatio,
-                        ratioType: ratioLabel,
-                      },
-                    )
+                    ' + Web搜索 {{count}}次 / 1K 次 * ${{price}} * {{ratioType}} {{ratio}}',
+                    {
+                      count: webSearchCallCount,
+                      price: webSearchPrice,
+                      ratio: groupRatio,
+                      ratioType: ratioLabel,
+                    },
+                  )
                   : '',
                 fileSearch && fileSearchCallCount > 0
                   ? i18next.t(
-                      ' + 文件搜索 {{count}}次 / 1K 次 * ${{price}} * {{ratioType}} {{ratio}}',
-                      {
-                        count: fileSearchCallCount,
-                        price: fileSearchPrice,
-                        ratio: groupRatio,
-                        ratioType: ratioLabel,
-                      },
-                    )
+                    ' + 文件搜索 {{count}}次 / 1K 次 * ${{price}} * {{ratioType}} {{ratio}}',
+                    {
+                      count: fileSearchCallCount,
+                      price: fileSearchPrice,
+                      ratio: groupRatio,
+                      ratioType: ratioLabel,
+                    },
+                  )
                   : '',
                 imageGenerationCall && imageGenerationCallPrice > 0
                   ? i18next.t(
@@ -1445,10 +1447,10 @@ export function renderAudioModelPrice(
     let audioPrice =
       (audioInputTokens / 1000000) * inputRatioPrice * audioRatio * groupRatio +
       (audioCompletionTokens / 1000000) *
-        inputRatioPrice *
-        audioRatio *
-        audioCompletionRatio *
-        groupRatio;
+      inputRatioPrice *
+      audioRatio *
+      audioCompletionRatio *
+      groupRatio;
     let price = textPrice + audioPrice;
     return (
       <>
@@ -1504,27 +1506,27 @@ export function renderAudioModelPrice(
           <p>
             {cacheTokens > 0
               ? i18next.t(
-                  '文字提示 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} = ${{total}}',
-                  {
-                    nonCacheInput: inputTokens - cacheTokens,
-                    cacheInput: cacheTokens,
-                    cachePrice: inputRatioPrice * cacheRatio,
-                    price: inputRatioPrice,
-                    completion: completionTokens,
-                    compPrice: completionRatioPrice,
-                    total: textPrice.toFixed(6),
-                  },
-                )
+                '文字提示 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} = ${{total}}',
+                {
+                  nonCacheInput: inputTokens - cacheTokens,
+                  cacheInput: cacheTokens,
+                  cachePrice: inputRatioPrice * cacheRatio,
+                  price: inputRatioPrice,
+                  completion: completionTokens,
+                  compPrice: completionRatioPrice,
+                  total: textPrice.toFixed(6),
+                },
+              )
               : i18next.t(
-                  '文字提示 {{input}} tokens / 1M tokens * ${{price}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} = ${{total}}',
-                  {
-                    input: inputTokens,
-                    price: inputRatioPrice,
-                    completion: completionTokens,
-                    compPrice: completionRatioPrice,
-                    total: textPrice.toFixed(6),
-                  },
-                )}
+                '文字提示 {{input}} tokens / 1M tokens * ${{price}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} = ${{total}}',
+                {
+                  input: inputTokens,
+                  price: inputRatioPrice,
+                  completion: completionTokens,
+                  compPrice: completionRatioPrice,
+                  total: textPrice.toFixed(6),
+                },
+              )}
           </p>
           <p>
             {i18next.t(
@@ -1663,35 +1665,35 @@ export function renderClaudeModelPrice(
           <p>
             {cacheTokens > 0 || cacheCreationTokens > 0
               ? i18next.t(
-                  '提示 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 缓存创建 {{cacheCreationInput}} tokens / 1M tokens * ${{cacheCreationPrice}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * {{ratioType}} {{ratio}} = ${{total}}',
-                  {
-                    nonCacheInput: nonCachedTokens,
-                    cacheInput: cacheTokens,
-                    cacheRatio: cacheRatio,
-                    cacheCreationInput: cacheCreationTokens,
-                    cacheCreationRatio: cacheCreationRatio,
-                    cachePrice: cacheRatioPrice,
-                    cacheCreationPrice: cacheCreationRatioPrice,
-                    price: inputRatioPrice,
-                    completion: completionTokens,
-                    compPrice: completionRatioPrice,
-                    ratio: groupRatio,
-                    ratioType: ratioLabel,
-                    total: price.toFixed(6),
-                  },
-                )
+                '提示 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 缓存创建 {{cacheCreationInput}} tokens / 1M tokens * ${{cacheCreationPrice}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * {{ratioType}} {{ratio}} = ${{total}}',
+                {
+                  nonCacheInput: nonCachedTokens,
+                  cacheInput: cacheTokens,
+                  cacheRatio: cacheRatio,
+                  cacheCreationInput: cacheCreationTokens,
+                  cacheCreationRatio: cacheCreationRatio,
+                  cachePrice: cacheRatioPrice,
+                  cacheCreationPrice: cacheCreationRatioPrice,
+                  price: inputRatioPrice,
+                  completion: completionTokens,
+                  compPrice: completionRatioPrice,
+                  ratio: groupRatio,
+                  ratioType: ratioLabel,
+                  total: price.toFixed(6),
+                },
+              )
               : i18next.t(
-                  '提示 {{input}} tokens / 1M tokens * ${{price}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * {{ratioType}} {{ratio}} = ${{total}}',
-                  {
-                    input: inputTokens,
-                    price: inputRatioPrice,
-                    completion: completionTokens,
-                    compPrice: completionRatioPrice,
-                    ratio: groupRatio,
-                    ratioType: ratioLabel,
-                    total: price.toFixed(6),
-                  },
-                )}
+                '提示 {{input}} tokens / 1M tokens * ${{price}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * {{ratioType}} {{ratio}} = ${{total}}',
+                {
+                  input: inputTokens,
+                  price: inputRatioPrice,
+                  completion: completionTokens,
+                  compPrice: completionRatioPrice,
+                  ratio: groupRatio,
+                  ratioType: ratioLabel,
+                  total: price.toFixed(6),
+                },
+              )}
           </p>
           <p>{i18next.t('仅供参考,以实际扣费为准')}</p>
         </article>

+ 62 - 0
web/src/helpers/secureApiCall.js

@@ -0,0 +1,62 @@
+/*
+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 <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+/**
+ * 安全 API 调用包装器
+ * 自动处理需要验证的 403 错误,透明地触发验证流程
+ */
+
+/**
+ * 检查错误是否是需要安全验证的错误
+ * @param {Error} error - 错误对象
+ * @returns {boolean}
+ */
+export function isVerificationRequiredError(error) {
+  if (!error.response) return false;
+
+  const { status, data } = error.response;
+
+  // 检查是否是 403 错误且包含验证相关的错误码
+  if (status === 403 && data) {
+    const verificationCodes = [
+      'VERIFICATION_REQUIRED',
+      'VERIFICATION_EXPIRED',
+      'VERIFICATION_INVALID'
+    ];
+
+    return verificationCodes.includes(data.code);
+  }
+
+  return false;
+}
+
+/**
+ * 从错误中提取验证需求信息
+ * @param {Error} error - 错误对象
+ * @returns {Object} 验证需求信息
+ */
+export function extractVerificationInfo(error) {
+  const data = error.response?.data || {};
+
+  return {
+    code: data.code,
+    message: data.message || '需要安全验证',
+    required: true
+  };
+}

+ 11 - 5
web/src/hooks/channels/useChannelsData.jsx

@@ -84,6 +84,7 @@ export const useChannelsData = () => {
   const [selectedModelKeys, setSelectedModelKeys] = useState([]);
   const [isBatchTesting, setIsBatchTesting] = useState(false);
   const [modelTablePage, setModelTablePage] = useState(1);
+const [selectedEndpointType, setSelectedEndpointType] = useState('');
 
   // 使用 ref 来避免闭包问题,类似旧版实现
   const shouldStopBatchTestingRef = useRef(false);
@@ -753,7 +754,7 @@ export const useChannelsData = () => {
   };
 
   // Test channel - 单个模型测试,参考旧版实现
-  const testChannel = async (record, model) => {
+  const testChannel = async (record, model, endpointType = '') => {
     const testKey = `${record.id}-${model}`;
 
     // 检查是否应该停止批量测试
@@ -765,9 +766,11 @@ export const useChannelsData = () => {
     setTestingModels((prev) => new Set([...prev, model]));
 
     try {
-      const res = await API.get(
-        `/api/channel/test/${record.id}?model=${model}`,
-      );
+      let url = `/api/channel/test/${record.id}?model=${model}`;
+      if (endpointType) {
+        url += `&endpoint_type=${endpointType}`;
+      }
+      const res = await API.get(url);
 
       // 检查是否在请求期间被停止
       if (shouldStopBatchTestingRef.current && isBatchTesting) {
@@ -895,7 +898,7 @@ export const useChannelsData = () => {
         );
 
         const batchPromises = batch.map((model) =>
-          testChannel(currentTestChannel, model),
+          testChannel(currentTestChannel, model, selectedEndpointType),
         );
         const batchResults = await Promise.allSettled(batchPromises);
         results.push(...batchResults);
@@ -979,6 +982,7 @@ export const useChannelsData = () => {
     setTestingModels(new Set());
     setSelectedModelKeys([]);
     setModelTablePage(1);
+    setSelectedEndpointType('');
     // 可选择性保留测试结果,这里不清空以便用户查看
   };
 
@@ -1066,6 +1070,8 @@ export const useChannelsData = () => {
     isBatchTesting,
     modelTablePage,
     setModelTablePage,
+    selectedEndpointType,
+    setSelectedEndpointType,
     allSelectingRef,
 
     // Multi-key management states

+ 1 - 1
web/src/hooks/common/useHeaderBar.js

@@ -40,7 +40,7 @@ export const useHeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
   const location = useLocation();
 
   const loading = statusState?.status === undefined;
-  const isLoading = useMinimumLoadingTime(loading);
+  const isLoading = useMinimumLoadingTime(loading, 200);
 
   const systemName = getSystemName();
   const logo = getLogo();

+ 246 - 0
web/src/hooks/common/useSecureVerification.jsx

@@ -0,0 +1,246 @@
+/*
+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 <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+import { useState, useEffect, useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+import { SecureVerificationService } from '../../services/secureVerification';
+import { showError, showSuccess } from '../../helpers';
+import { isVerificationRequiredError } from '../../helpers/secureApiCall';
+
+/**
+ * 通用安全验证 Hook
+ * @param {Object} options - 配置选项
+ * @param {Function} options.onSuccess - 验证成功回调
+ * @param {Function} options.onError - 验证失败回调
+ * @param {string} options.successMessage - 成功提示消息
+ * @param {boolean} options.autoReset - 验证完成后是否自动重置状态,默认为 true
+ */
+export const useSecureVerification = ({ 
+  onSuccess, 
+  onError, 
+  successMessage,
+  autoReset = true 
+} = {}) => {
+  const { t } = useTranslation();
+
+  // 验证方式可用性状态
+  const [verificationMethods, setVerificationMethods] = useState({
+    has2FA: false,
+    hasPasskey: false,
+    passkeySupported: false
+  });
+
+  // 模态框状态
+  const [isModalVisible, setIsModalVisible] = useState(false);
+
+  // 当前验证状态
+  const [verificationState, setVerificationState] = useState({
+    method: null, // '2fa' | 'passkey'
+    loading: false,
+    code: '',
+    apiCall: null
+  });
+
+  // 检查可用的验证方式
+  const checkVerificationMethods = useCallback(async () => {
+    const methods = await SecureVerificationService.checkAvailableVerificationMethods();
+    setVerificationMethods(methods);
+    return methods;
+  }, []);
+
+  // 初始化时检查验证方式
+  useEffect(() => {
+    checkVerificationMethods();
+  }, [checkVerificationMethods]);
+
+  // 重置状态
+  const resetState = useCallback(() => {
+    setVerificationState({
+      method: null,
+      loading: false,
+      code: '',
+      apiCall: null
+    });
+    setIsModalVisible(false);
+  }, []);
+
+  // 开始验证流程
+  const startVerification = useCallback(async (apiCall, options = {}) => {
+    const { preferredMethod, title, description } = options;
+
+    // 检查验证方式
+    const methods = await checkVerificationMethods();
+
+    if (!methods.has2FA && !methods.hasPasskey) {
+      const errorMessage = t('您需要先启用两步验证或 Passkey 才能执行此操作');
+      showError(errorMessage);
+      onError?.(new Error(errorMessage));
+      return false;
+    }
+
+    // 设置默认验证方式
+    let defaultMethod = preferredMethod;
+    if (!defaultMethod) {
+      if (methods.hasPasskey && methods.passkeySupported) {
+        defaultMethod = 'passkey';
+      } else if (methods.has2FA) {
+        defaultMethod = '2fa';
+      }
+    }
+
+    setVerificationState(prev => ({
+      ...prev,
+      method: defaultMethod,
+      apiCall,
+      title,
+      description
+    }));
+    setIsModalVisible(true);
+
+    return true;
+  }, [checkVerificationMethods, onError, t]);
+
+  // 执行验证
+  const executeVerification = useCallback(async (method, code = '') => {
+    if (!verificationState.apiCall) {
+      showError(t('验证配置错误'));
+      return;
+    }
+
+    setVerificationState(prev => ({ ...prev, loading: true }));
+
+    try {
+      // 先调用验证 API,成功后后端会设置 session
+      await SecureVerificationService.verify(method, code);
+
+      // 验证成功,调用业务 API(此时中间件会通过)
+      const result = await verificationState.apiCall();
+
+      // 显示成功消息
+      if (successMessage) {
+        showSuccess(successMessage);
+      }
+
+      // 调用成功回调
+      onSuccess?.(result, method);
+
+      // 自动重置状态
+      if (autoReset) {
+        resetState();
+      }
+
+      return result;
+    } catch (error) {
+      showError(error.message || t('验证失败,请重试'));
+      onError?.(error);
+      throw error;
+    } finally {
+      setVerificationState(prev => ({ ...prev, loading: false }));
+    }
+  }, [verificationState.apiCall, successMessage, onSuccess, onError, autoReset, resetState, t]);
+
+  // 设置验证码
+  const setVerificationCode = useCallback((code) => {
+    setVerificationState(prev => ({ ...prev, code }));
+  }, []);
+
+  // 切换验证方式
+  const switchVerificationMethod = useCallback((method) => {
+    setVerificationState(prev => ({ ...prev, method, code: '' }));
+  }, []);
+
+  // 取消验证
+  const cancelVerification = useCallback(() => {
+    resetState();
+  }, [resetState]);
+
+  // 检查是否可以使用某种验证方式
+  const canUseMethod = useCallback((method) => {
+    switch (method) {
+      case '2fa':
+        return verificationMethods.has2FA;
+      case 'passkey':
+        return verificationMethods.hasPasskey && verificationMethods.passkeySupported;
+      default:
+        return false;
+    }
+  }, [verificationMethods]);
+
+  // 获取推荐的验证方式
+  const getRecommendedMethod = useCallback(() => {
+    if (verificationMethods.hasPasskey && verificationMethods.passkeySupported) {
+      return 'passkey';
+    }
+    if (verificationMethods.has2FA) {
+      return '2fa';
+    }
+    return null;
+  }, [verificationMethods]);
+
+  /**
+   * 包装 API 调用,自动处理验证错误
+   * 当 API 返回需要验证的错误时,自动弹出验证模态框
+   * @param {Function} apiCall - API 调用函数
+   * @param {Object} options - 验证选项(同 startVerification)
+   * @returns {Promise<any>}
+   */
+  const withVerification = useCallback(async (apiCall, options = {}) => {
+    try {
+      // 直接尝试调用 API
+      return await apiCall();
+    } catch (error) {
+      // 检查是否是需要验证的错误
+      if (isVerificationRequiredError(error)) {
+        // 自动触发验证流程
+        await startVerification(apiCall, options);
+        // 不抛出错误,让验证模态框处理
+        return null;
+      }
+      // 其他错误继续抛出
+      throw error;
+    }
+  }, [startVerification]);
+
+  return {
+    // 状态
+    isModalVisible,
+    verificationMethods,
+    verificationState,
+
+    // 方法
+    startVerification,
+    executeVerification,
+    cancelVerification,
+    resetState,
+    setVerificationCode,
+    switchVerificationMethod,
+    checkVerificationMethods,
+
+    // 辅助方法
+    canUseMethod,
+    getRecommendedMethod,
+    withVerification, // 新增:自动处理验证的包装函数
+
+    // 便捷属性
+    hasAnyVerificationMethod: verificationMethods.has2FA || verificationMethods.hasPasskey,
+    isLoading: verificationState.loading,
+    currentMethod: verificationState.method,
+    code: verificationState.code
+  };
+};

+ 36 - 8
web/src/hooks/common/useSidebar.js

@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
 For commercial licensing, please contact support@quantumnous.com
 */
 
-import { useState, useEffect, useMemo, useContext } from 'react';
+import { useState, useEffect, useMemo, useContext, useRef } from 'react';
 import { StatusContext } from '../../context/Status';
 import { API } from '../../helpers';
 
@@ -29,6 +29,13 @@ export const useSidebar = () => {
   const [statusState] = useContext(StatusContext);
   const [userConfig, setUserConfig] = useState(null);
   const [loading, setLoading] = useState(true);
+  const instanceIdRef = useRef(null);
+  const hasLoadedOnceRef = useRef(false);
+
+  if (!instanceIdRef.current) {
+    const randomPart = Math.random().toString(16).slice(2);
+    instanceIdRef.current = `sidebar-${Date.now()}-${randomPart}`;
+  }
 
   // 默认配置
   const defaultAdminConfig = {
@@ -74,9 +81,17 @@ export const useSidebar = () => {
   }, [statusState?.status?.SidebarModulesAdmin]);
 
   // 加载用户配置的通用方法
-  const loadUserConfig = async () => {
+  const loadUserConfig = async ({ withLoading } = {}) => {
+    const shouldShowLoader =
+      typeof withLoading === 'boolean'
+        ? withLoading
+        : !hasLoadedOnceRef.current;
+
     try {
-      setLoading(true);
+      if (shouldShowLoader) {
+        setLoading(true);
+      }
+
       const res = await API.get('/api/user/self');
       if (res.data.success && res.data.data.sidebar_modules) {
         let config;
@@ -122,18 +137,25 @@ export const useSidebar = () => {
       });
       setUserConfig(defaultUserConfig);
     } finally {
-      setLoading(false);
+      if (shouldShowLoader) {
+        setLoading(false);
+      }
+      hasLoadedOnceRef.current = true;
     }
   };
 
   // 刷新用户配置的方法(供外部调用)
   const refreshUserConfig = async () => {
     if (Object.keys(adminConfig).length > 0) {
-      await loadUserConfig();
+      await loadUserConfig({ withLoading: false });
     }
 
     // 触发全局刷新事件,通知所有useSidebar实例更新
-    sidebarEventTarget.dispatchEvent(new CustomEvent(SIDEBAR_REFRESH_EVENT));
+    sidebarEventTarget.dispatchEvent(
+      new CustomEvent(SIDEBAR_REFRESH_EVENT, {
+        detail: { sourceId: instanceIdRef.current, skipLoader: true },
+      }),
+    );
   };
 
   // 加载用户配置
@@ -146,9 +168,15 @@ export const useSidebar = () => {
 
   // 监听全局刷新事件
   useEffect(() => {
-    const handleRefresh = () => {
+    const handleRefresh = (event) => {
+      if (event?.detail?.sourceId === instanceIdRef.current) {
+        return;
+      }
+
       if (Object.keys(adminConfig).length > 0) {
-        loadUserConfig();
+        loadUserConfig({
+          withLoading: event?.detail?.skipLoader ? false : undefined,
+        });
       }
     };
 

+ 37 - 1
web/src/hooks/users/useUsersData.jsx

@@ -86,7 +86,7 @@ export const useUsersData = () => {
   };
 
   // Search users with keyword and group
-  const searchUsers = async (
+const searchUsers = async (
     startIdx,
     pageSize,
     searchKeyword = null,
@@ -154,6 +154,40 @@ export const useUsersData = () => {
     setLoading(false);
   };
 
+  const resetUserPasskey = async (user) => {
+    if (!user) {
+      return;
+    }
+    try {
+      const res = await API.delete(`/api/user/${user.id}/reset_passkey`);
+      const { success, message } = res.data;
+      if (success) {
+        showSuccess(t('Passkey 已重置'));
+      } else {
+        showError(message || t('操作失败,请重试'));
+      }
+    } catch (error) {
+      showError(t('操作失败,请重试'));
+    }
+  };
+
+  const resetUserTwoFA = async (user) => {
+    if (!user) {
+      return;
+    }
+    try {
+      const res = await API.delete(`/api/user/${user.id}/2fa`);
+      const { success, message } = res.data;
+      if (success) {
+        showSuccess(t('二步验证已重置'));
+      } else {
+        showError(message || t('操作失败,请重试'));
+      }
+    } catch (error) {
+      showError(t('操作失败,请重试'));
+    }
+  };
+
   // Handle page change
   const handlePageChange = (page) => {
     setActivePage(page);
@@ -271,6 +305,8 @@ export const useUsersData = () => {
     loadUsers,
     searchUsers,
     manageUser,
+    resetUserPasskey,
+    resetUserTwoFA,
     handlePageChange,
     handlePageSizeChange,
     handleRow,

+ 114 - 2
web/src/i18n/locales/en.json

@@ -6,6 +6,7 @@
   "登 录": "Log In",
   "注 册": "Sign Up",
   "使用 邮箱或用户名 登录": "Sign in with Email or Username",
+  "使用 Passkey 认证": "Authenticate with Passkey",
   "使用 GitHub 继续": "Continue with GitHub",
   "使用 OIDC 继续": "Continue with OIDC",
   "使用 微信 继续": "Continue with WeChat",
@@ -332,6 +333,10 @@
   "通过密码注册时需要进行邮箱验证": "Email verification is required when registering via password",
   "允许通过 GitHub 账户登录 & 注册": "Allow login & registration via GitHub account",
   "允许通过微信登录 & 注册": "Allow login & registration via WeChat",
+  "允许通过 Passkey 登录 & 认证": "Allow login & authentication via Passkey",
+  "确认解绑 Passkey": "Confirm Unbind Passkey",
+  "解绑后将无法使用 Passkey 登录,确定要继续吗?": "After unbinding, you will not be able to login with Passkey. Are you sure you want to continue?",
+  "确认解绑": "Confirm Unbind",
   "允许新用户注册(此项为否时,新用户将无法以任何方式进行注册": "Allow new user registration (if this option is off, new users will not be able to register in any way",
   "启用 Turnstile 用户校验": "Enable Turnstile user verification",
   "配置 SMTP": "Configure SMTP",
@@ -754,7 +759,6 @@
   "获取当前设置失败": "Failed to get current settings",
   "设置已更新": "Settings updated",
   "更新设置失败": "Update settings failed",
-  "确认解绑": "Confirm unbinding",
   "您确定要解绑WxPusher吗?": "Are you sure you want to unbind WxPusher?",
   "解绑失败": "Unbinding failed",
   "订阅事件": "Subscribe to events",
@@ -1308,6 +1312,8 @@
   "请输入Webhook地址,例如: https://example.com/webhook": "Please enter the Webhook URL, e.g.: https://example.com/webhook",
   "邮件通知": "Email notification",
   "Webhook通知": "Webhook notification",
+  "Bark通知": "Bark notification",
+  "Gotify通知": "Gotify notification",
   "接口凭证(可选)": "Interface credentials (optional)",
   "密钥将以 Bearer 方式添加到请求头中,用于验证webhook请求的合法性": "The secret will be added to the request header as a Bearer token to verify the legitimacy of the webhook request",
   "Authorization: Bearer your-secret-key": "Authorization: Bearer your-secret-key",
@@ -1318,6 +1324,36 @@
   "通知邮箱": "Notification email",
   "设置用于接收额度预警的邮箱地址,不填则使用账号绑定的邮箱": "Set the email address for receiving quota warning notifications, if not set, the email address bound to the account will be used",
   "留空则使用账号绑定的邮箱": "If left blank, the email address bound to the account will be used",
+  "Bark推送URL": "Bark Push URL",
+  "请输入Bark推送URL,例如: https://api.day.app/yourkey/{{title}}/{{content}}": "Please enter Bark push URL, e.g.: https://api.day.app/yourkey/{{title}}/{{content}}",
+  "支持HTTP和HTTPS,模板变量: {{title}} (通知标题), {{content}} (通知内容)": "Supports HTTP and HTTPS, template variables: {{title}} (notification title), {{content}} (notification content)",
+  "请输入Bark推送URL": "Please enter Bark push URL",
+  "Bark推送URL必须以http://或https://开头": "Bark push URL must start with http:// or https://",
+  "模板示例": "Template example",
+  "更多参数请参考": "For more parameters, please refer to",
+  "Gotify服务器地址": "Gotify server address",
+  "请输入Gotify服务器地址,例如: https://gotify.example.com": "Please enter Gotify server address, e.g.: https://gotify.example.com",
+  "支持HTTP和HTTPS,填写Gotify服务器的完整URL地址": "Supports HTTP and HTTPS, enter the complete URL of the Gotify server",
+  "请输入Gotify服务器地址": "Please enter Gotify server address",
+  "Gotify服务器地址必须以http://或https://开头": "Gotify server address must start with http:// or https://",
+  "Gotify应用令牌": "Gotify application token",
+  "请输入Gotify应用令牌": "Please enter Gotify application token",
+  "在Gotify服务器创建应用后获得的令牌,用于发送通知": "Token obtained after creating an application on the Gotify server, used to send notifications",
+  "消息优先级": "Message priority",
+  "请选择消息优先级": "Please select message priority",
+  "0 - 最低": "0 - Lowest",
+  "2 - 低": "2 - Low",
+  "5 - 正常(默认)": "5 - Normal (default)",
+  "8 - 高": "8 - High",
+  "10 - 最高": "10 - Highest",
+  "消息优先级,范围0-10,默认为5": "Message priority, range 0-10, default is 5",
+  "配置说明": "Configuration instructions",
+  "在Gotify服务器的应用管理中创建新应用": "Create a new application in the Gotify server's application management",
+  "复制应用的令牌(Token)并填写到上方的应用令牌字段": "Copy the application token and fill it in the application token field above",
+  "填写Gotify服务器的完整URL地址": "Fill in the complete URL address of the Gotify server",
+  "更多信息请参考": "For more information, please refer to",
+  "通知内容": "Notification content",
+  "官方文档": "Official documentation",
   "API地址": "Base URL",
   "对于官方渠道,new-api已经内置地址,除非是第三方代理站点或者Azure的特殊接入地址,否则不需要填写": "For official channels, the new-api has a built-in address. Unless it is a third-party proxy site or a special Azure access address, there is no need to fill it in",
   "渠道额外设置": "Channel extra settings",
@@ -1404,6 +1440,7 @@
   "Claude思考适配 BudgetTokens = MaxTokens * BudgetTokens 百分比": "Claude thinking adaptation BudgetTokens = MaxTokens * BudgetTokens percentage",
   "思考适配 BudgetTokens 百分比": "Thinking adaptation BudgetTokens percentage",
   "0.1-1之间的小数": "Decimal between 0.1 and 1",
+  "0.1以上的小数": "Decimal above 0.1",
   "模型相关设置": "Model related settings",
   "收起侧边栏": "Collapse sidebar",
   "展开侧边栏": "Expand sidebar",
@@ -1440,7 +1477,7 @@
   "相关项目": "Related Projects",
   "基于New API的项目": "Projects Based on New API",
   "版权所有": "All rights reserved",
-  "设计与开发由": "Designed & Developed with love by",
+  "设计与开发由": "Designed & Developed by",
   "演示站点": "Demo Site",
   "页面未找到,请检查您的浏览器地址是否正确": "Page not found, please check if your browser address is correct",
   "您无权访问此页面,请联系管理员": "You do not have permission to access this page. Please contact the administrator.",
@@ -2134,6 +2171,81 @@
   "域名黑名单": "Domain Blacklist",
   "白名单": "Whitelist",
   "黑名单": "Blacklist",
+  "Passkey 登录": "Passkey Sign-in",
+  "已启用 Passkey,无需密码即可登录": "Passkey enabled. Passwordless login available.",
+  "使用 Passkey 实现免密且更安全的登录体验": "Use Passkey for a passwordless and more secure login experience.",
+  "最后使用时间": "Last used time",
+  "备份支持": "Backup support",
+  "支持备份": "Supported",
+  "不支持": "Not supported",
+  "备份状态": "Backup state",
+  "已备份": "Backed up",
+  "未备份": "Not backed up",
+  "当前设备不支持 Passkey": "Passkey is not supported on this device",
+  "注册 Passkey": "Register Passkey",
+  "解绑 Passkey": "Remove Passkey",
+  "Passkey 注册成功": "Passkey registration successful",
+  "Passkey 注册失败,请重试": "Passkey registration failed. Please try again.",
+  "已取消 Passkey 注册": "Passkey registration cancelled",
+  "Passkey 已解绑": "Passkey removed",
+  "操作失败,请重试": "Operation failed, please retry",
+  "重置 Passkey": "Reset Passkey",
+  "重置 2FA": "Reset 2FA",
+  "确认重置 Passkey": "Confirm Passkey Reset",
+  "确认重置两步验证": "Confirm Two-Factor Reset",
+  "此操作将解绑用户当前的 Passkey,下次登录需要重新注册。": "This will detach the user's current Passkey. They will need to register again on next login.",
+  "此操作将禁用该用户当前的两步验证配置,下次登录将不再强制输入验证码,直到用户重新启用。": "This will disable the user's current two-factor setup. No verification code will be required until they enable it again.",
+  "目标用户:{{username}}": "Target user: {{username}}",
+  "Passkey 已重置": "Passkey has been reset",
+  "二步验证已重置": "Two-factor authentication has been reset",
+  "配置 Passkey": "Configure Passkey",
+  "用以支持基于 WebAuthn 的无密码登录注册": "Support WebAuthn-based passwordless login and registration",
+  "Passkey 是基于 WebAuthn 标准的无密码身份验证方法,支持指纹、面容、硬件密钥等认证方式": "Passkey is a passwordless authentication method based on WebAuthn standard, supporting fingerprint, face recognition, hardware keys and other authentication methods",
+  "服务显示名称": "Service Display Name",
+  "默认使用系统名称": "Default uses system name",
+  "用户注册时看到的网站名称,比如'我的网站'": "Website name users see during registration, e.g. 'My Website'",
+  "网站域名标识": "Website Domain ID",
+  "例如:example.com": "e.g.: example.com",
+  "留空自动使用当前域名": "Leave blank to auto-use current domain",
+  "安全验证级别": "Security Verification Level",
+  "是否要求指纹/面容等生物识别": "Whether to require fingerprint/face recognition",
+  "preferred": "preferred",
+  "required": "required",
+  "discouraged": "discouraged",
+  "推荐:用户可以选择是否使用指纹等验证": "Recommended: Users can choose whether to use fingerprint verification",
+  "设备类型偏好": "Device Type Preference",
+  "选择支持的认证设备类型": "Choose supported authentication device types",
+  "platform": "platform",
+  "cross-platform": "cross-platform",
+  "本设备:手机指纹/面容,外接:USB安全密钥": "Built-in: phone fingerprint/face, External: USB security key",
+  "允许不安全的 Origin(HTTP)": "Allow insecure Origin (HTTP)",
+  "仅用于开发环境,生产环境应使用 HTTPS": "For development only, use HTTPS in production",
+  "允许的 Origins": "Allowed Origins",
+  "留空将自动使用服务器地址,多个 Origin 用于支持多域名部署": "Leave blank to auto-use server address, multiple Origins for multi-domain deployment",
+  "输入 Origin 后回车,如:https://example.com": "Enter Origin and press Enter, e.g.: https://example.com",
+  "保存 Passkey 设置": "Save Passkey Settings",
+  "字段透传控制": "Field Pass-through Control",
+  "允许 service_tier 透传": "Allow service_tier Pass-through",
+  "禁用 store 透传": "Disable store Pass-through",
+  "允许 safety_identifier 透传": "Allow safety_identifier Pass-through",
+  "service_tier 字段用于指定服务层级,允许透传可能导致实际计费高于预期。默认关闭以避免额外费用": "The service_tier field is used to specify service level. Allowing pass-through may result in higher billing than expected. Disabled by default to avoid extra charges",
+  "store 字段用于授权 OpenAI 存储请求数据以评估和优化产品。默认关闭,开启后可能导致 Codex 无法正常使用": "The store field authorizes OpenAI to store request data for product evaluation and optimization. Disabled by default. Enabling may cause Codex to malfunction",
+  "safety_identifier 字段用于帮助 OpenAI 识别可能违反使用政策的应用程序用户。默认关闭以保护用户隐私": "The safety_identifier field helps OpenAI identify application users who may violate usage policies. Disabled by default to protect user privacy",
+  "支持变量:": "Supported variables:",
+  "请求头覆盖": "Request header override",
+  "旧格式模板": "Old format template",
+  "新格式模板": "New format template",
+  "系统提示词拼接": "System prompt append",
+  "如果用户请求中包含系统提示词,则使用此设置拼接到用户的系统提示词前面": "If the user request contains a system prompt, this setting will be appended to the user's system prompt",
+  "键为请求中的模型名称,值为要替换的模型名称": "Key is the model name in the request, value is the model name to replace",
+  "仅影响本地判断,不修改返回到上游的状态码,比如将claude渠道的400错误复写为500(用于重试),请勿滥用该功能,例如:": "Only affects local judgment, does not modify the status code returned to the upstream, for example, rewrite the 400 error of the claude channel to 500 (for retry). Please do not abuse this function, for example:",
+  "密钥更新模式": "Key update mode",
+  "请选择密钥更新模式": "Please select key update mode",
+  "追加到现有密钥": "Append to existing key",
+  "覆盖现有密钥": "Overwrite existing key",
+  "追加模式:将新密钥添加到现有密钥列表末尾": "Append mode: add new keys to the end of the existing key list",
+  "覆盖模式:将完全替换现有的所有密钥": "Overwrite mode: completely replace all existing keys",
+  "轮询模式必须搭配Redis和内存缓存功能使用,否则性能将大幅降低,并且无法实现轮询功能": "Polling mode must be used with Redis and memory cache functions, otherwise the performance will be significantly reduced and the polling function will not be implemented",
   "common": {
     "changeLanguage": "Change Language"
   }

+ 101 - 2
web/src/i18n/locales/fr.json

@@ -1308,6 +1308,8 @@
   "请输入Webhook地址,例如: https://example.com/webhook": "Veuillez saisir l'URL du Webhook, par exemple : https://example.com/webhook",
   "邮件通知": "Notification par e-mail",
   "Webhook通知": "Notification par Webhook",
+  "Bark通知": "Notification Bark",
+  "Gotify通知": "Notification Gotify",
   "接口凭证(可选)": "Informations d'identification de l'interface (facultatif)",
   "密钥将以 Bearer 方式添加到请求头中,用于验证webhook请求的合法性": "Le secret sera ajouté à l'en-tête de la requête en tant que jeton Bearer pour vérifier la légitimité de la requête webhook",
   "Authorization: Bearer your-secret-key": "Autorisation : Bearer votre-clé-secrète",
@@ -1318,6 +1320,36 @@
   "通知邮箱": "E-mail de notification",
   "设置用于接收额度预警的邮箱地址,不填则使用账号绑定的邮箱": "Définissez l'adresse e-mail pour recevoir les notifications d'avertissement de quota, si elle n'est pas définie, l'adresse e-mail liée au compte sera utilisée",
   "留空则使用账号绑定的邮箱": "Si ce champ est laissé vide, l'adresse e-mail liée au compte sera utilisée",
+  "Bark推送URL": "URL de notification Bark",
+  "请输入Bark推送URL,例如: https://api.day.app/yourkey/{{title}}/{{content}}": "Veuillez saisir l'URL de notification Bark, par exemple : https://api.day.app/yourkey/{{title}}/{{content}}",
+  "支持HTTP和HTTPS,模板变量: {{title}} (通知标题), {{content}} (通知内容)": "Prend en charge HTTP et HTTPS, variables de modèle : {{title}} (titre de la notification), {{content}} (contenu de la notification)",
+  "请输入Bark推送URL": "Veuillez saisir l'URL de notification Bark",
+  "Bark推送URL必须以http://或https://开头": "L'URL de notification Bark doit commencer par http:// ou https://",
+  "模板示例": "Exemple de modèle",
+  "更多参数请参考": "Pour plus de paramètres, veuillez vous référer à",
+  "Gotify服务器地址": "Adresse du serveur Gotify",
+  "请输入Gotify服务器地址,例如: https://gotify.example.com": "Veuillez saisir l'adresse du serveur Gotify, par exemple : https://gotify.example.com",
+  "支持HTTP和HTTPS,填写Gotify服务器的完整URL地址": "Prend en charge HTTP et HTTPS, saisissez l'URL complète du serveur Gotify",
+  "请输入Gotify服务器地址": "Veuillez saisir l'adresse du serveur Gotify",
+  "Gotify服务器地址必须以http://或https://开头": "L'adresse du serveur Gotify doit commencer par http:// ou https://",
+  "Gotify应用令牌": "Jeton d'application Gotify",
+  "请输入Gotify应用令牌": "Veuillez saisir le jeton d'application Gotify",
+  "在Gotify服务器创建应用后获得的令牌,用于发送通知": "Jeton obtenu après la création d'une application sur le serveur Gotify, utilisé pour envoyer des notifications",
+  "消息优先级": "Priorité du message",
+  "请选择消息优先级": "Veuillez sélectionner la priorité du message",
+  "0 - 最低": "0 - La plus basse",
+  "2 - 低": "2 - Basse",
+  "5 - 正常(默认)": "5 - Normale (par défaut)",
+  "8 - 高": "8 - Haute",
+  "10 - 最高": "10 - La plus haute",
+  "消息优先级,范围0-10,默认为5": "Priorité du message, plage 0-10, par défaut 5",
+  "配置说明": "Instructions de configuration",
+  "在Gotify服务器的应用管理中创建新应用": "Créer une nouvelle application dans la gestion des applications du serveur Gotify",
+  "复制应用的令牌(Token)并填写到上方的应用令牌字段": "Copier le jeton de l'application et le remplir dans le champ de jeton d'application ci-dessus",
+  "填写Gotify服务器的完整URL地址": "Remplir l'adresse URL complète du serveur Gotify",
+  "更多信息请参考": "Pour plus d'informations, veuillez vous référer à",
+  "通知内容": "Contenu de la notification",
+  "官方文档": "Documentation officielle",
   "API地址": "URL de base",
   "对于官方渠道,new-api已经内置地址,除非是第三方代理站点或者Azure的特殊接入地址,否则不需要填写": "Pour les canaux officiels, le new-api a une adresse intégrée. Sauf s'il s'agit d'un site proxy tiers ou d'une adresse d'accès Azure spéciale, il n'est pas nécessaire de la remplir",
   "渠道额外设置": "Paramètres supplémentaires du canal",
@@ -1404,6 +1436,7 @@
   "Claude思考适配 BudgetTokens = MaxTokens * BudgetTokens 百分比": "Adaptation de la pensée Claude BudgetTokens = MaxTokens * BudgetTokens pourcentage",
   "思考适配 BudgetTokens 百分比": "Adaptation de la pensée BudgetTokens pourcentage",
   "0.1-1之间的小数": "Décimal entre 0,1 et 1",
+  "0.1以上的小数": "Décimal supérieur à 0,1",
   "模型相关设置": "Paramètres liés au modèle",
   "收起侧边栏": "Réduire la barre latérale",
   "展开侧边栏": "Développer la barre latérale",
@@ -2003,7 +2036,6 @@
   "查看渠道密钥": "Afficher la clé du canal",
   "渠道密钥信息": "Informations sur la clé du canal",
   "密钥获取成功": "Acquisition de la clé réussie",
-  "模型补全倍率(仅对自定义模型有效)": "Ratio d'achèvement de modèle (uniquement efficace pour les modèles personnalisés)",
   "图片倍率": "Ratio d'image",
   "音频倍率": "Ratio audio",
   "音频补全倍率": "Ratio d'achèvement audio",
@@ -2137,7 +2169,74 @@
   "关闭侧边栏": "Fermer la barre latérale",
   "定价": "Tarification",
   "语言": "Langue",
+  "字段透传控制": "Contrôle du passage des champs",
+  "允许 service_tier 透传": "Autoriser le passage de service_tier",
+  "禁用 store 透传": "Désactiver le passage de store",
+  "允许 safety_identifier 透传": "Autoriser le passage de safety_identifier",
+  "service_tier 字段用于指定服务层级,允许透传可能导致实际计费高于预期。默认关闭以避免额外费用": "Le champ service_tier est utilisé pour spécifier le niveau de service. Permettre le passage peut entraîner une facturation plus élevée que prévu. Désactivé par défaut pour éviter des frais supplémentaires",
+  "store 字段用于授权 OpenAI 存储请求数据以评估和优化产品。默认关闭,开启后可能导致 Codex 无法正常使用": "Le champ store autorise OpenAI à stocker les données de requête pour l'évaluation et l'optimisation du produit. Désactivé par défaut. L'activation peut causer un dysfonctionnement de Codex",
+  "safety_identifier 字段用于帮助 OpenAI 识别可能违反使用政策的应用程序用户。默认关闭以保护用户隐私": "Le champ safety_identifier aide OpenAI à identifier les utilisateurs d'applications susceptibles de violer les politiques d'utilisation. Désactivé par défaut pour protéger la confidentialité des utilisateurs",
   "common": {
     "changeLanguage": "Changer de langue"
-  }
+  },
+  "Passkey 已解绑": "Passkey supprimé",
+  "Passkey 已重置": "Le Passkey a été réinitialisé",
+  "Passkey 是基于 WebAuthn 标准的无密码身份验证方法,支持指纹、面容、硬件密钥等认证方式": "Passkey est une méthode d'authentification sans mot de passe basée sur la norme WebAuthn, prenant en charge les empreintes digitales, la reconnaissance faciale, les clés matérielles et d'autres méthodes d'authentification",
+  "Passkey 注册失败,请重试": "L'enregistrement du Passkey a échoué. Veuillez réessayer.",
+  "Passkey 注册成功": "Enregistrement du Passkey réussi",
+  "Passkey 登录": "Connexion avec Passkey",
+  "cross-platform": "multiplateforme",
+  "discouraged": "déconseillé",
+  "platform": "plateforme",
+  "preferred": "préféré",
+  "required": "requis",
+  "二步验证已重置": "L'authentification à deux facteurs a été réinitialisée",
+  "仅用于开发环境,生产环境应使用 HTTPS": "Pour le développement uniquement, utilisez HTTPS en production",
+  "使用 Passkey 实现免密且更安全的登录体验": "Utilisez Passkey pour une expérience de connexion sans mot de passe et plus sécurisée.",
+  "使用 Passkey 认证": "S'authentifier avec Passkey",
+  "例如:example.com": "ex: example.com",
+  "保存 Passkey 设置": "Enregistrer les paramètres Passkey",
+  "允许不安全的 Origin(HTTP)": "Autoriser une origine non sécurisée (HTTP)",
+  "允许的 Origins": "Origines autorisées",
+  "允许通过 Passkey 登录 & 认证": "Autoriser la connexion et l'authentification via Passkey",
+  "删除密钥失败": "Échec de la suppression de la clé",
+  "备份支持": "Prise en charge de la sauvegarde",
+  "备份状态": "État de la sauvegarde",
+  "安全验证级别": "Niveau de vérification de la sécurité",
+  "密钥已删除": "La clé a été supprimée",
+  "已取消 Passkey 注册": "Enregistrement du Passkey annulé",
+  "已启用 Passkey,无需密码即可登录": "Passkey activé. Connexion sans mot de passe disponible.",
+  "已备份": "Sauvegardé",
+  "当前设备不支持 Passkey": "Passkey n'est pas pris en charge sur cet appareil",
+  "推荐:用户可以选择是否使用指纹等验证": "Recommandé : les utilisateurs peuvent choisir d'utiliser ou non la vérification par empreinte digitale",
+  "操作失败,请重试": "L'opération a échoué, veuillez réessayer",
+  "支持备份": "Pris en charge",
+  "是否要求指纹/面容等生物识别": "Exiger une reconnaissance biométrique par empreinte digitale/faciale",
+  "最后使用时间": "Dernière utilisation",
+  "服务显示名称": "Nom d'affichage du service",
+  "未备份": "Non sauvegardé",
+  "本设备:手机指纹/面容,外接:USB安全密钥": "Intégré : empreinte digitale/visage du téléphone, Externe : clé de sécurité USB",
+  "此操作不可撤销,将永久删除该密钥": "Cette opération ne peut être annulée et la clé sera définitivement supprimée.",
+  "此操作将禁用该用户当前的两步验证配置,下次登录将不再强制输入验证码,直到用户重新启用。": "Cela désactivera la configuration actuelle de l'authentification à deux facteurs de l'utilisateur. Aucun code de vérification ne sera requis jusqu'à ce qu'il la réactive.",
+  "此操作将解绑用户当前的 Passkey,下次登录需要重新注册。": "Cela détachera le Passkey actuel de l'utilisateur. Il devra se réenregistrer lors de sa prochaine connexion.",
+  "注册 Passkey": "Enregistrer un Passkey",
+  "用以支持基于 WebAuthn 的无密码登录注册": "Prise en charge de la connexion et de l'enregistrement sans mot de passe basés sur WebAuthn",
+  "用户注册时看到的网站名称,比如'我的网站'": "Nom du site Web que les utilisateurs voient lors de l'inscription, par exemple 'Mon site Web'",
+  "留空将自动使用服务器地址,多个 Origin 用于支持多域名部署": "Laissez vide pour utiliser automatiquement l'adresse du serveur, plusieurs origines pour la prise en charge du déploiement multi-domaines",
+  "留空自动使用当前域名": "Laissez vide pour utiliser automatiquement le domaine actuel",
+  "目标用户:{{username}}": "Utilisateur cible : {{username}}",
+  "确定要删除此密钥吗?": "Êtes-vous sûr de vouloir supprimer cette clé ?",
+  "确认解绑 Passkey": "Confirmer la dissociation du Passkey",
+  "确认重置 Passkey": "Confirmer la réinitialisation du Passkey",
+  "确认重置两步验证": "Confirmer la réinitialisation de l'authentification à deux facteurs",
+  "网站域名标识": "ID de domaine du site Web",
+  "解绑 Passkey": "Supprimer le Passkey",
+  "解绑后将无法使用 Passkey 登录,确定要继续吗?": "Après la dissociation, vous ne pourrez plus vous connecter avec Passkey. Êtes-vous sûr de vouloir continuer ?",
+  "设备类型偏好": "Préférence de type d'appareil",
+  "输入 Origin 后回车,如:https://example.com": "Saisissez l'origine et appuyez sur Entrée, ex : https://example.com",
+  "选择支持的认证设备类型": "Choisissez les types d'appareils d'authentification pris en charge",
+  "配置 Passkey": "Configurer Passkey",
+  "重置 2FA": "Réinitialiser 2FA",
+  "重置 Passkey": "Réinitialiser le Passkey",
+  "默认使用系统名称": "Le nom du système est utilisé par défaut"
 }

+ 59 - 1
web/src/i18n/locales/zh.json

@@ -5,6 +5,7 @@
   "关于": "关于",
   "登录": "登录",
   "注册": "注册",
+  "使用 Passkey 认证": "使用 Passkey 认证",
   "退出": "退出",
   "语言": "语言",
   "展开侧边栏": "展开侧边栏",
@@ -36,5 +37,62 @@
   "common": {
     "changeLanguage": "切换语言"
   },
-  "允许在 Stripe 支付中输入促销码": "允许在 Stripe 支付中输入促销码"
+  "允许在 Stripe 支付中输入促销码": "允许在 Stripe 支付中输入促销码",
+  "Passkey 认证": "Passkey 认证",
+  "已启用 Passkey,可进行无密码认证": "已启用 Passkey,可进行无密码认证",
+  "使用 Passkey 实现免密且更安全的认证体验": "使用 Passkey 实现免密且更安全的认证体验",
+  "最后使用时间": "最后使用时间",
+  "备份支持": "备份支持",
+  "支持备份": "支持备份",
+  "不支持": "不支持",
+  "备份状态": "备份状态",
+  "已备份": "已备份",
+  "未备份": "未备份",
+  "当前设备不支持 Passkey": "当前设备不支持 Passkey",
+  "注册 Passkey": "注册 Passkey",
+  "解绑 Passkey": "解绑 Passkey",
+  "配置 Passkey": "配置 Passkey",
+  "用以支持基于 WebAuthn 的无密码登录注册": "用以支持基于 WebAuthn 的无密码登录注册",
+  "Passkey 是基于 WebAuthn 标准的无密码身份验证方法,支持指纹、面容、硬件密钥等认证方式": "Passkey 是基于 WebAuthn 标准的无密码身份验证方法,支持指纹、面容、硬件密钥等认证方式",
+  "服务显示名称": "服务显示名称",
+  "默认使用系统名称": "默认使用系统名称",
+  "用户注册时看到的网站名称,比如'我的网站'": "用户注册时看到的网站名称,比如'我的网站'",
+  "网站域名标识": "网站域名标识",
+  "例如:example.com": "例如:example.com",
+  "留空自动使用当前域名": "留空自动使用当前域名",
+  "安全验证级别": "安全验证级别",
+  "是否要求指纹/面容等生物识别": "是否要求指纹/面容等生物识别",
+  "preferred": "preferred",
+  "required": "required",
+  "discouraged": "discouraged",
+  "推荐:用户可以选择是否使用指纹等验证": "推荐:用户可以选择是否使用指纹等验证",
+  "设备类型偏好": "设备类型偏好",
+  "选择支持的认证设备类型": "选择支持的认证设备类型",
+  "platform": "platform",
+  "cross-platform": "cross-platform",
+  "本设备:手机指纹/面容,外接:USB安全密钥": "本设备:手机指纹/面容,外接:USB安全密钥",
+  "允许不安全的 Origin(HTTP)": "允许不安全的 Origin(HTTP)",
+  "仅用于开发环境,生产环境应使用 HTTPS": "仅用于开发环境,生产环境应使用 HTTPS",
+  "允许的 Origins": "允许的 Origins",
+  "留空将自动使用服务器地址,多个 Origin 用于支持多域名部署": "留空将自动使用服务器地址,多个 Origin 用于支持多域名部署",
+  "输入 Origin 后回车,如:https://example.com": "输入 Origin 后回车,如:https://example.com",
+  "保存 Passkey 设置": "保存 Passkey 设置",
+  "Passkey 注册成功": "Passkey 注册成功",
+  "Passkey 注册失败,请重试": "Passkey 注册失败,请重试",
+  "已取消 Passkey 注册": "已取消 Passkey 注册",
+  "Passkey 已解绑": "Passkey 已解绑",
+  "操作失败,请重试": "操作失败,请重试",
+  "重置 Passkey": "重置 Passkey",
+  "重置 2FA": "重置 2FA",
+  "确认重置 Passkey": "确认重置 Passkey",
+  "确认重置两步验证": "确认重置两步验证",
+  "此操作将解绑用户当前的 Passkey,下次登录需要重新注册。": "此操作将解绑用户当前的 Passkey,下次登录需要重新注册。",
+  "此操作将禁用该用户当前的两步验证配置,下次登录将不再强制输入验证码,直到用户重新启用。": "此操作将禁用该用户当前的两步验证配置,下次登录将不再强制输入验证码,直到用户重新启用。",
+  "目标用户:{{username}}": "目标用户:{{username}}",
+  "Passkey 已重置": "Passkey 已重置",
+  "二步验证已重置": "二步验证已重置",
+  "允许通过 Passkey 登录 & 认证": "允许通过 Passkey 登录 & 认证",
+  "确认解绑 Passkey": "确认解绑 Passkey",
+  "解绑后将无法使用 Passkey 登录,确定要继续吗?": "解绑后将无法使用 Passkey 登录,确定要继续吗?",
+  "确认解绑": "确认解绑"
 }

+ 386 - 32
web/src/pages/Setting/Chat/SettingsChats.jsx

@@ -18,7 +18,26 @@ For commercial licensing, please contact support@quantumnous.com
 */
 
 import React, { useEffect, useState, useRef } from 'react';
-import { Banner, Button, Form, Space, Spin } from '@douyinfe/semi-ui';
+import {
+  Banner,
+  Button,
+  Form,
+  Space,
+  Spin,
+  RadioGroup,
+  Radio,
+  Table,
+  Modal,
+  Input,
+  Divider,
+} from '@douyinfe/semi-ui';
+import {
+  IconPlus,
+  IconEdit,
+  IconDelete,
+  IconSearch,
+  IconSaveStroked,
+} from '@douyinfe/semi-icons';
 import {
   compareObjects,
   API,
@@ -37,6 +56,52 @@ export default function SettingsChats(props) {
   });
   const refForm = useRef();
   const [inputsRow, setInputsRow] = useState(inputs);
+  const [editMode, setEditMode] = useState('visual');
+  const [chatConfigs, setChatConfigs] = useState([]);
+  const [modalVisible, setModalVisible] = useState(false);
+  const [editingConfig, setEditingConfig] = useState(null);
+  const [isEdit, setIsEdit] = useState(false);
+  const [searchText, setSearchText] = useState('');
+  const modalFormRef = useRef();
+
+  const jsonToConfigs = (jsonString) => {
+    try {
+      const configs = JSON.parse(jsonString);
+      return Array.isArray(configs)
+        ? configs.map((config, index) => ({
+            id: index,
+            name: Object.keys(config)[0] || '',
+            url: Object.values(config)[0] || '',
+          }))
+        : [];
+    } catch (error) {
+      console.error('JSON parse error:', error);
+      return [];
+    }
+  };
+
+  const configsToJson = (configs) => {
+    const jsonArray = configs.map((config) => ({
+      [config.name]: config.url,
+    }));
+    return JSON.stringify(jsonArray, null, 2);
+  };
+
+  const syncJsonToConfigs = () => {
+    const configs = jsonToConfigs(inputs.Chats);
+    setChatConfigs(configs);
+  };
+
+  const syncConfigsToJson = (configs) => {
+    const jsonString = configsToJson(configs);
+    setInputs((prev) => ({
+      ...prev,
+      Chats: jsonString,
+    }));
+    if (refForm.current && editMode === 'json') {
+      refForm.current.setValues({ Chats: jsonString });
+    }
+  };
 
   async function onSubmit() {
     try {
@@ -103,16 +168,184 @@ export default function SettingsChats(props) {
     }
     setInputs(currentInputs);
     setInputsRow(structuredClone(currentInputs));
-    refForm.current.setValues(currentInputs);
+    if (refForm.current) {
+      refForm.current.setValues(currentInputs);
+    }
+
+    // 同步到可视化配置
+    const configs = jsonToConfigs(currentInputs.Chats || '[]');
+    setChatConfigs(configs);
   }, [props.options]);
 
+  useEffect(() => {
+    if (editMode === 'visual') {
+      syncJsonToConfigs();
+    }
+  }, [inputs.Chats, editMode]);
+
+  useEffect(() => {
+    if (refForm.current && editMode === 'json') {
+      refForm.current.setValues(inputs);
+    }
+  }, [editMode, inputs]);
+
+  const handleAddConfig = () => {
+    setEditingConfig({ name: '', url: '' });
+    setIsEdit(false);
+    setModalVisible(true);
+    setTimeout(() => {
+      if (modalFormRef.current) {
+        modalFormRef.current.setValues({ name: '', url: '' });
+      }
+    }, 100);
+  };
+
+  const handleEditConfig = (config) => {
+    setEditingConfig({ ...config });
+    setIsEdit(true);
+    setModalVisible(true);
+    setTimeout(() => {
+      if (modalFormRef.current) {
+        modalFormRef.current.setValues(config);
+      }
+    }, 100);
+  };
+
+  const handleDeleteConfig = (id) => {
+    const newConfigs = chatConfigs.filter((config) => config.id !== id);
+    setChatConfigs(newConfigs);
+    syncConfigsToJson(newConfigs);
+    showSuccess(t('删除成功'));
+  };
+
+  const handleModalOk = () => {
+    if (modalFormRef.current) {
+      modalFormRef.current
+        .validate()
+        .then((values) => {
+          // 检查名称是否重复
+          const isDuplicate = chatConfigs.some(
+            (config) =>
+              config.name === values.name &&
+              (!isEdit || config.id !== editingConfig.id)
+          );
+
+          if (isDuplicate) {
+            showError(t('聊天应用名称已存在,请使用其他名称'));
+            return;
+          }
+
+          if (isEdit) {
+            const newConfigs = chatConfigs.map((config) =>
+              config.id === editingConfig.id
+                ? { ...editingConfig, name: values.name, url: values.url }
+                : config,
+            );
+            setChatConfigs(newConfigs);
+            syncConfigsToJson(newConfigs);
+          } else {
+            const maxId =
+              chatConfigs.length > 0
+                ? Math.max(...chatConfigs.map((c) => c.id))
+                : -1;
+            const newConfig = {
+              id: maxId + 1,
+              name: values.name,
+              url: values.url,
+            };
+            const newConfigs = [...chatConfigs, newConfig];
+            setChatConfigs(newConfigs);
+            syncConfigsToJson(newConfigs);
+          }
+          setModalVisible(false);
+          setEditingConfig(null);
+          showSuccess(isEdit ? t('编辑成功') : t('添加成功'));
+        })
+        .catch((error) => {
+          console.error('Modal form validation error:', error);
+        });
+    }
+  };
+
+  const handleModalCancel = () => {
+    setModalVisible(false);
+    setEditingConfig(null);
+  };
+
+  const filteredConfigs = chatConfigs.filter(
+    (config) =>
+      !searchText ||
+      config.name.toLowerCase().includes(searchText.toLowerCase()),
+  );
+
+  const highlightKeywords = (text) => {
+    if (!text) return text;
+
+    const parts = text.split(/(\{address\}|\{key\})/g);
+    return parts.map((part, index) => {
+      if (part === '{address}') {
+        return (
+          <span key={index} style={{ color: '#0077cc', fontWeight: 600 }}>
+            {part}
+          </span>
+        );
+      } else if (part === '{key}') {
+        return (
+          <span key={index} style={{ color: '#ff6b35', fontWeight: 600 }}>
+            {part}
+          </span>
+        );
+      }
+      return part;
+    });
+  };
+
+  const columns = [
+    {
+      title: t('聊天应用名称'),
+      dataIndex: 'name',
+      key: 'name',
+      render: (text) => text || t('未命名'),
+    },
+    {
+      title: t('URL链接'),
+      dataIndex: 'url',
+      key: 'url',
+      render: (text) => (
+        <div style={{ maxWidth: 300, wordBreak: 'break-all' }}>
+          {highlightKeywords(text)}
+        </div>
+      ),
+    },
+    {
+      title: t('操作'),
+      key: 'action',
+      render: (_, record) => (
+        <Space>
+          <Button
+            type='primary'
+            icon={<IconEdit />}
+            size='small'
+            onClick={() => handleEditConfig(record)}
+          >
+            {t('编辑')}
+          </Button>
+          <Button
+            type='danger'
+            icon={<IconDelete />}
+            size='small'
+            onClick={() => handleDeleteConfig(record.id)}
+          >
+            {t('删除')}
+          </Button>
+        </Space>
+      ),
+    },
+  ];
+
   return (
     <Spin spinning={loading}>
-      <Form
-        values={inputs}
-        getFormApi={(formAPI) => (refForm.current = formAPI)}
-        style={{ marginBottom: 15 }}
-      >
+      <Space vertical style={{ width: '100%' }}>
         <Form.Section text={t('聊天设置')}>
           <Banner
             type='info'
@@ -120,34 +353,155 @@ export default function SettingsChats(props) {
               '链接中的{key}将自动替换为sk-xxxx,{address}将自动替换为系统设置的服务器地址,末尾不带/和/v1',
             )}
           />
-          <Form.TextArea
-            label={t('聊天配置')}
-            extraText={''}
-            placeholder={t('为一个 JSON 文本')}
-            field={'Chats'}
-            autosize={{ minRows: 6, maxRows: 12 }}
-            trigger='blur'
-            stopValidateWithError
+
+          <Divider />
+
+          <div style={{ marginBottom: 16 }}>
+            <span style={{ marginRight: 16, fontWeight: 600 }}>
+              {t('编辑模式')}:
+            </span>
+            <RadioGroup
+              type='button'
+              value={editMode}
+              onChange={(e) => {
+                const newMode = e.target.value;
+                setEditMode(newMode);
+
+                // 确保模式切换时数据正确同步
+                setTimeout(() => {
+                  if (newMode === 'json' && refForm.current) {
+                    refForm.current.setValues(inputs);
+                  }
+                }, 100);
+              }}
+            >
+              <Radio value='visual'>{t('可视化编辑')}</Radio>
+              <Radio value='json'>{t('JSON编辑')}</Radio>
+            </RadioGroup>
+          </div>
+
+          {editMode === 'visual' ? (
+            <div>
+              <Space style={{ marginBottom: 16 }}>
+                <Button
+                  type='primary'
+                  icon={<IconPlus />}
+                  onClick={handleAddConfig}
+                >
+                  {t('添加聊天配置')}
+                </Button>
+                <Button
+                  type='primary'
+                  theme='solid'
+                  icon={<IconSaveStroked />}
+                  onClick={onSubmit}
+                >
+                  {t('保存聊天设置')}
+                </Button>
+                <Input
+                  prefix={<IconSearch />}
+                  placeholder={t('搜索聊天应用名称')}
+                  value={searchText}
+                  onChange={(value) => setSearchText(value)}
+                  style={{ width: 250 }}
+                  showClear
+                />
+              </Space>
+
+              <Table
+                columns={columns}
+                dataSource={filteredConfigs}
+                rowKey='id'
+                pagination={{
+                  pageSize: 10,
+                  showSizeChanger: false,
+                  showQuickJumper: true,
+                  showTotal: (total, range) =>
+                    t('共 {{total}} 项,当前显示 {{start}}-{{end}} 项', {
+                      total,
+                      start: range[0],
+                      end: range[1],
+                    }),
+                }}
+              />
+            </div>
+          ) : (
+            <Form
+              values={inputs}
+              getFormApi={(formAPI) => (refForm.current = formAPI)}
+            >
+              <Form.TextArea
+                label={t('聊天配置')}
+                extraText={''}
+                placeholder={t('为一个 JSON 文本')}
+                field={'Chats'}
+                autosize={{ minRows: 6, maxRows: 12 }}
+                trigger='blur'
+                stopValidateWithError
+                rules={[
+                  {
+                    validator: (rule, value) => {
+                      return verifyJSON(value);
+                    },
+                    message: t('不是合法的 JSON 字符串'),
+                  },
+                ]}
+                onChange={(value) =>
+                  setInputs({
+                    ...inputs,
+                    Chats: value,
+                  })
+                }
+              />
+            </Form>
+          )}
+        </Form.Section>
+
+        {editMode === 'json' && (
+          <Space>
+            <Button
+              type='primary'
+              icon={<IconSaveStroked />}
+              onClick={onSubmit}
+            >
+              {t('保存聊天设置')}
+            </Button>
+          </Space>
+        )}
+      </Space>
+
+      <Modal
+        title={isEdit ? t('编辑聊天配置') : t('添加聊天配置')}
+        visible={modalVisible}
+        onOk={handleModalOk}
+        onCancel={handleModalCancel}
+        width={600}
+      >
+        <Form getFormApi={(api) => (modalFormRef.current = api)}>
+          <Form.Input
+            field='name'
+            label={t('聊天应用名称')}
+            placeholder={t('请输入聊天应用名称')}
             rules={[
-              {
-                validator: (rule, value) => {
-                  return verifyJSON(value);
-                },
-                message: t('不是合法的 JSON 字符串'),
-              },
+              { required: true, message: t('请输入聊天应用名称') },
+              { min: 1, message: t('名称不能为空') },
             ]}
-            onChange={(value) =>
-              setInputs({
-                ...inputs,
-                Chats: value,
-              })
-            }
           />
-        </Form.Section>
-      </Form>
-      <Space>
-        <Button onClick={onSubmit}>{t('保存聊天设置')}</Button>
-      </Space>
+          <Form.Input
+            field='url'
+            label={t('URL链接')}
+            placeholder={t('请输入完整的URL链接')}
+            rules={[{ required: true, message: t('请输入URL链接') }]}
+          />
+          <Banner
+            type='info'
+            description={t(
+              '提示:链接中的{key}将被替换为API密钥,{address}将被替换为服务器地址',
+            )}
+            style={{ marginTop: 16 }}
+          />
+        </Form>
+      </Modal>
     </Spin>
   );
 }

+ 1 - 2
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,

+ 217 - 0
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 <https://www.gnu.org/licenses/>.
+
+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<void>}
+   */
+  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<void>}
+   */
+  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<void>}
+   */
+  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;
+  }
+};