Bläddra i källkod

Merge branch 'main' into feat_subscribe_sp1

Little Write 3 månader sedan
förälder
incheckning
fade73d970
100 ändrade filer med 7303 tillägg och 2707 borttagningar
  1. 2 1
      .dockerignore
  2. 1 3
      .env.example
  3. 4 4
      .github/workflows/linux-release.yml
  4. 2 2
      .github/workflows/macos-release.yml
  5. 0 21
      .github/workflows/pr-target-branch-check.yml
  6. 2 2
      .github/workflows/windows-release.yml
  7. 2 1
      .gitignore
  8. 103 201
      LICENSE
  9. 1 1
      README.md
  10. 2 0
      common/api_type.go
  11. 1 0
      common/constants.go
  12. 19 0
      common/copy.go
  13. 5 0
      common/custom-event.go
  14. 1 1
      common/database.go
  15. 32 0
      common/endpoint_defaults.go
  16. 5 1
      common/gin.go
  17. 1 1
      common/init.go
  18. 22 0
      common/ip.go
  19. 22 0
      common/json.go
  20. 1 1
      common/page_info.go
  21. 5 0
      common/quota.go
  22. 327 0
      common/ssrf_protection.go
  23. 140 0
      common/str.go
  24. 55 0
      common/sys_log.go
  25. 150 0
      common/totp.go
  26. 107 27
      common/utils.go
  27. 2 1
      constant/api_type.go
  28. 2 0
      constant/channel.go
  29. 7 1
      constant/context_key.go
  30. 4 4
      constant/task.go
  31. 7 3
      controller/channel-billing.go
  32. 107 29
      controller/channel-test.go
  33. 684 9
      controller/channel.go
  34. 92 91
      controller/console_migrate.go
  35. 21 13
      controller/linuxdo.go
  36. 49 17
      controller/midjourney.go
  37. 44 43
      controller/misc.go
  38. 27 0
      controller/missing_models.go
  39. 53 8
      controller/model.go
  40. 330 0
      controller/model_meta.go
  41. 604 0
      controller/model_sync.go
  42. 4 5
      controller/oidc.go
  43. 51 8
      controller/option.go
  44. 6 30
      controller/playground.go
  45. 90 0
      controller/prefill_group.go
  46. 7 4
      controller/pricing.go
  47. 16 16
      controller/ratio_config.go
  48. 518 453
      controller/ratio_sync.go
  49. 2 1
      controller/redemption.go
  50. 187 187
      controller/relay.go
  51. 11 11
      controller/setup.go
  52. 20 0
      controller/swag_video.go
  53. 18 17
      controller/task.go
  54. 63 17
      controller/task_video.go
  55. 53 1
      controller/token.go
  56. 61 10
      controller/topup.go
  57. 13 3
      controller/topup_stripe.go
  58. 553 0
      controller/twofa.go
  59. 15 15
      controller/uptime_kuma.go
  60. 294 12
      controller/user.go
  61. 124 0
      controller/vendor_meta.go
  62. 1 1
      docker-compose.yml
  63. 24 0
      dto/audio.go
  64. 18 3
      dto/channel_settings.go
  65. 186 10
      dto/claude.go
  66. 0 29
      dto/dalle.go
  67. 32 2
      dto/embedding.go
  68. 200 7
      dto/gemini.go
  69. 172 0
      dto/openai_image.go
  70. 358 54
      dto/openai_request.go
  71. 124 4
      dto/openai_response.go
  72. 24 0
      dto/pricing.go
  73. 18 18
      dto/ratio_sync.go
  74. 25 0
      dto/request_common.go
  75. 33 0
      dto/rerank.go
  76. 3 0
      dto/user_settings.go
  77. 13 6
      go.mod
  78. 28 12
      go.sum
  79. 0 1041
      i18n/zh-cn.json
  80. 22 27
      logger/logger.go
  81. 14 11
      main.go
  82. 39 6
      middleware/auth.go
  83. 12 0
      middleware/disable-cache.go
  84. 61 65
      middleware/distributor.go
  85. 80 0
      middleware/email-verification-rate-limit.go
  86. 66 0
      middleware/jimeng_adapter.go
  87. 4 0
      middleware/kling_adapter.go
  88. 2 2
      middleware/recover.go
  89. 3 3
      middleware/stats.go
  90. 2 2
      middleware/turnstile-check.go
  91. 9 3
      middleware/utils.go
  92. 26 6
      model/ability.go
  93. 147 64
      model/channel.go
  94. 39 17
      model/channel_cache.go
  95. 12 15
      model/log.go
  96. 115 3
      model/main.go
  97. 30 0
      model/missing_models.go
  98. 31 0
      model/model_extra.go
  99. 147 0
      model/model_meta.go
  100. 32 20
      model/option.go

+ 2 - 1
.dockerignore

@@ -4,4 +4,5 @@
 .vscode
 .gitignore
 Makefile
-docs
+docs
+.eslintcache

+ 1 - 3
.env.example

@@ -47,7 +47,7 @@
 # 所有请求超时时间,单位秒,默认为0,表示不限制
 # RELAY_TIMEOUT=0
 # 流模式无响应超时时间,单位秒,如果出现空补全可以尝试改为更大值
-# STREAMING_TIMEOUT=120
+# STREAMING_TIMEOUT=300
 
 # Gemini 识别图片 最大图片数量
 # GEMINI_VISION_MAX_IMAGE_NUM=16
@@ -56,8 +56,6 @@
 # SESSION_SECRET=random_string
 
 # 其他配置
-# 渠道测试频率(单位:秒)
-# CHANNEL_TEST_FREQUENCY=10
 # 生成默认token
 # GENERATE_DEFAULT_TOKEN=false
 # Cohere 安全设置

+ 4 - 4
.github/workflows/linux-release.yml

@@ -38,21 +38,21 @@ jobs:
       - name: Build Backend (amd64)
         run: |
           go mod download
-          go build -ldflags "-s -w -X 'one-api/common.Version=$(git describe --tags)' -extldflags '-static'" -o one-api
+          go build -ldflags "-s -w -X 'one-api/common.Version=$(git describe --tags)' -extldflags '-static'" -o new-api
 
       - name: Build Backend (arm64)
         run: |
           sudo apt-get update
           DEBIAN_FRONTEND=noninteractive sudo apt-get install -y gcc-aarch64-linux-gnu
-          CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOOS=linux GOARCH=arm64 go build -ldflags "-s -w -X 'one-api/common.Version=$(git describe --tags)' -extldflags '-static'" -o one-api-arm64
+          CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOOS=linux GOARCH=arm64 go build -ldflags "-s -w -X 'one-api/common.Version=$(git describe --tags)' -extldflags '-static'" -o new-api-arm64
 
       - name: Release
         uses: softprops/action-gh-release@v1
         if: startsWith(github.ref, 'refs/tags/')
         with:
           files: |
-            one-api
-            one-api-arm64
+            new-api
+            new-api-arm64
           draft: true
           generate_release_notes: true
         env:

+ 2 - 2
.github/workflows/macos-release.yml

@@ -39,12 +39,12 @@ jobs:
       - name: Build Backend
         run: |
           go mod download
-          go build -ldflags "-X 'one-api/common.Version=$(git describe --tags)'" -o one-api-macos
+          go build -ldflags "-X 'one-api/common.Version=$(git describe --tags)'" -o new-api-macos
       - name: Release
         uses: softprops/action-gh-release@v1
         if: startsWith(github.ref, 'refs/tags/')
         with:
-          files: one-api-macos
+          files: new-api-macos
           draft: true
           generate_release_notes: true
         env:

+ 0 - 21
.github/workflows/pr-target-branch-check.yml

@@ -1,21 +0,0 @@
-name: Check PR Branching Strategy
-on:
-  pull_request:
-    types: [opened, synchronize, reopened, edited]
-
-jobs:
-  check-branching-strategy:
-    runs-on: ubuntu-latest
-    steps:
-      - name: Enforce branching strategy
-        run: |
-          if [[ "${{ github.base_ref }}" == "main" ]]; then
-            if [[ "${{ github.head_ref }}" != "alpha" ]]; then
-              echo "Error: Pull requests to 'main' are only allowed from the 'alpha' branch."
-              exit 1
-            fi
-          elif [[ "${{ github.base_ref }}" != "alpha" ]]; then
-            echo "Error: Pull requests must be targeted to the 'alpha' or 'main' branch."
-            exit 1
-          fi
-          echo "Branching strategy check passed."

+ 2 - 2
.github/workflows/windows-release.yml

@@ -41,12 +41,12 @@ jobs:
       - name: Build Backend
         run: |
           go mod download
-          go build -ldflags "-s -w -X 'one-api/common.Version=$(git describe --tags)'" -o one-api.exe
+          go build -ldflags "-s -w -X 'one-api/common.Version=$(git describe --tags)'" -o new-api.exe
       - name: Release
         uses: softprops/action-gh-release@v1
         if: startsWith(github.ref, 'refs/tags/')
         with:
-          files: one-api.exe
+          files: new-api.exe
           draft: true
           generate_release_notes: true
         env:

+ 2 - 1
.gitignore

@@ -10,4 +10,5 @@ web/dist
 .env
 one-api
 .DS_Store
-tiktoken_cache
+tiktoken_cache
+.eslintcache

+ 103 - 201
LICENSE

@@ -1,201 +1,103 @@
-                                 Apache License
-                           Version 2.0, January 2004
-                        http://www.apache.org/licenses/
-
-   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
-
-   1. Definitions.
-
-      "License" shall mean the terms and conditions for use, reproduction,
-      and distribution as defined by Sections 1 through 9 of this document.
-
-      "Licensor" shall mean the copyright owner or entity authorized by
-      the copyright owner that is granting the License.
-
-      "Legal Entity" shall mean the union of the acting entity and all
-      other entities that control, are controlled by, or are under common
-      control with that entity. For the purposes of this definition,
-      "control" means (i) the power, direct or indirect, to cause the
-      direction or management of such entity, whether by contract or
-      otherwise, or (ii) ownership of fifty percent (50%) or more of the
-      outstanding shares, or (iii) beneficial ownership of such entity.
-
-      "You" (or "Your") shall mean an individual or Legal Entity
-      exercising permissions granted by this License.
-
-      "Source" form shall mean the preferred form for making modifications,
-      including but not limited to software source code, documentation
-      source, and configuration files.
-
-      "Object" form shall mean any form resulting from mechanical
-      transformation or translation of a Source form, including but
-      not limited to compiled object code, generated documentation,
-      and conversions to other media types.
-
-      "Work" shall mean the work of authorship, whether in Source or
-      Object form, made available under the License, as indicated by a
-      copyright notice that is included in or attached to the work
-      (an example is provided in the Appendix below).
-
-      "Derivative Works" shall mean any work, whether in Source or Object
-      form, that is based on (or derived from) the Work and for which the
-      editorial revisions, annotations, elaborations, or other modifications
-      represent, as a whole, an original work of authorship. For the purposes
-      of this License, Derivative Works shall not include works that remain
-      separable from, or merely link (or bind by name) to the interfaces of,
-      the Work and Derivative Works thereof.
-
-      "Contribution" shall mean any work of authorship, including
-      the original version of the Work and any modifications or additions
-      to that Work or Derivative Works thereof, that is intentionally
-      submitted to Licensor for inclusion in the Work by the copyright owner
-      or by an individual or Legal Entity authorized to submit on behalf of
-      the copyright owner. For the purposes of this definition, "submitted"
-      means any form of electronic, verbal, or written communication sent
-      to the Licensor or its representatives, including but not limited to
-      communication on electronic mailing lists, source code control systems,
-      and issue tracking systems that are managed by, or on behalf of, the
-      Licensor for the purpose of discussing and improving the Work, but
-      excluding communication that is conspicuously marked or otherwise
-      designated in writing by the copyright owner as "Not a Contribution."
-
-      "Contributor" shall mean Licensor and any individual or Legal Entity
-      on behalf of whom a Contribution has been received by Licensor and
-      subsequently incorporated within the Work.
-
-   2. Grant of Copyright License. Subject to the terms and conditions of
-      this License, each Contributor hereby grants to You a perpetual,
-      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
-      copyright license to reproduce, prepare Derivative Works of,
-      publicly display, publicly perform, sublicense, and distribute the
-      Work and such Derivative Works in Source or Object form.
-
-   3. Grant of Patent License. Subject to the terms and conditions of
-      this License, each Contributor hereby grants to You a perpetual,
-      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
-      (except as stated in this section) patent license to make, have made,
-      use, offer to sell, sell, import, and otherwise transfer the Work,
-      where such license applies only to those patent claims licensable
-      by such Contributor that are necessarily infringed by their
-      Contribution(s) alone or by combination of their Contribution(s)
-      with the Work to which such Contribution(s) was submitted. If You
-      institute patent litigation against any entity (including a
-      cross-claim or counterclaim in a lawsuit) alleging that the Work
-      or a Contribution incorporated within the Work constitutes direct
-      or contributory patent infringement, then any patent licenses
-      granted to You under this License for that Work shall terminate
-      as of the date such litigation is filed.
-
-   4. Redistribution. You may reproduce and distribute copies of the
-      Work or Derivative Works thereof in any medium, with or without
-      modifications, and in Source or Object form, provided that You
-      meet the following conditions:
-
-      (a) You must give any other recipients of the Work or
-          Derivative Works a copy of this License; and
-
-      (b) You must cause any modified files to carry prominent notices
-          stating that You changed the files; and
-
-      (c) You must retain, in the Source form of any Derivative Works
-          that You distribute, all copyright, patent, trademark, and
-          attribution notices from the Source form of the Work,
-          excluding those notices that do not pertain to any part of
-          the Derivative Works; and
-
-      (d) If the Work includes a "NOTICE" text file as part of its
-          distribution, then any Derivative Works that You distribute must
-          include a readable copy of the attribution notices contained
-          within such NOTICE file, excluding those notices that do not
-          pertain to any part of the Derivative Works, in at least one
-          of the following places: within a NOTICE text file distributed
-          as part of the Derivative Works; within the Source form or
-          documentation, if provided along with the Derivative Works; or,
-          within a display generated by the Derivative Works, if and
-          wherever such third-party notices normally appear. The contents
-          of the NOTICE file are for informational purposes only and
-          do not modify the License. You may add Your own attribution
-          notices within Derivative Works that You distribute, alongside
-          or as an addendum to the NOTICE text from the Work, provided
-          that such additional attribution notices cannot be construed
-          as modifying the License.
-
-      You may add Your own copyright statement to Your modifications and
-      may provide additional or different license terms and conditions
-      for use, reproduction, or distribution of Your modifications, or
-      for any such Derivative Works as a whole, provided Your use,
-      reproduction, and distribution of the Work otherwise complies with
-      the conditions stated in this License.
-
-   5. Submission of Contributions. Unless You explicitly state otherwise,
-      any Contribution intentionally submitted for inclusion in the Work
-      by You to the Licensor shall be under the terms and conditions of
-      this License, without any additional terms or conditions.
-      Notwithstanding the above, nothing herein shall supersede or modify
-      the terms of any separate license agreement you may have executed
-      with Licensor regarding such Contributions.
-
-   6. Trademarks. This License does not grant permission to use the trade
-      names, trademarks, service marks, or product names of the Licensor,
-      except as required for reasonable and customary use in describing the
-      origin of the Work and reproducing the content of the NOTICE file.
-
-   7. Disclaimer of Warranty. Unless required by applicable law or
-      agreed to in writing, Licensor provides the Work (and each
-      Contributor provides its Contributions) on an "AS IS" BASIS,
-      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
-      implied, including, without limitation, any warranties or conditions
-      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
-      PARTICULAR PURPOSE. You are solely responsible for determining the
-      appropriateness of using or redistributing the Work and assume any
-      risks associated with Your exercise of permissions under this License.
-
-   8. Limitation of Liability. In no event and under no legal theory,
-      whether in tort (including negligence), contract, or otherwise,
-      unless required by applicable law (such as deliberate and grossly
-      negligent acts) or agreed to in writing, shall any Contributor be
-      liable to You for damages, including any direct, indirect, special,
-      incidental, or consequential damages of any character arising as a
-      result of this License or out of the use or inability to use the
-      Work (including but not limited to damages for loss of goodwill,
-      work stoppage, computer failure or malfunction, or any and all
-      other commercial damages or losses), even if such Contributor
-      has been advised of the possibility of such damages.
-
-   9. Accepting Warranty or Additional Liability. While redistributing
-      the Work or Derivative Works thereof, You may choose to offer,
-      and charge a fee for, acceptance of support, warranty, indemnity,
-      or other liability obligations and/or rights consistent with this
-      License. However, in accepting such obligations, You may act only
-      on Your own behalf and on Your sole responsibility, not on behalf
-      of any other Contributor, and only if You agree to indemnify,
-      defend, and hold each Contributor harmless for any liability
-      incurred by, or claims asserted against, such Contributor by reason
-      of your accepting any such warranty or additional liability.
-
-   END OF TERMS AND CONDITIONS
-
-   APPENDIX: How to apply the Apache License to your work.
-
-      To apply the Apache License to your work, attach the following
-      boilerplate notice, with the fields enclosed by brackets "[]"
-      replaced with your own identifying information. (Don't include
-      the brackets!)  The text should be enclosed in the appropriate
-      comment syntax for the file format. We also recommend that a
-      file or class name and description of purpose be included on the
-      same "printed page" as the copyright notice for easier
-      identification within third-party archives.
-
-   Copyright [yyyy] [name of copyright owner]
-
-   Licensed under the Apache License, Version 2.0 (the "License");
-   you may not use this file except in compliance with the License.
-   You may obtain a copy of the License at
-
-       http://www.apache.org/licenses/LICENSE-2.0
-
-   Unless required by applicable law or agreed to in writing, software
-   distributed under the License is distributed on an "AS IS" BASIS,
-   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-   See the License for the specific language governing permissions and
-   limitations under the License.
+# **New API 许可协议 (Licensing)**
+
+本项目采用**基于使用场景的双重许可 (Usage-Based Dual Licensing)** 模式。
+
+**核心原则:**
+
+- **默认许可:** 本项目默认在 **GNU Affero 通用公共许可证 v3.0 (AGPLv3)** 下提供。任何用户在遵守 AGPLv3 条款和下述附加限制的前提下,均可免费使用。
+- **商业许可:** 在特定商业场景下,或当您希望获得 AGPLv3 之外的权利时,**必须**获取**商业许可证 (Commercial License)**。
+
+---
+
+## **1. 开源许可证 (Open Source License): AGPLv3 - 适用于基础使用**
+
+- 在遵守 **AGPLv3** 条款的前提下,您可以自由地使用、修改和分发 New API。AGPLv3 的完整文本可以访问 [https://www.gnu.org/licenses/agpl-3.0.html](https://www.gnu.org/licenses/agpl-3.0.html) 获取。
+- **核心义务:** AGPLv3 的一个关键要求是,如果您修改了 New API 并通过网络提供服务 (SaaS),或者分发了修改后的版本,您必须以 AGPLv3 许可证向所有用户提供相应的**完整源代码**。
+- **附加限制 (重要):** 在仅使用 AGPLv3 开源许可证的情况下,您**必须**完整保留项目代码中原有的品牌标识、LOGO 及版权声明信息。**禁止以任何形式修改、移除或遮盖**这些信息。如需移除,必须获取商业许可证。
+- 使用前请务必仔细阅读并理解 AGPLv3 的所有条款及上述附加限制。
+
+## **2. 商业许可证 (Commercial License) - 适用于高级场景及闭源需求**
+
+在以下任一情况下,您**必须**联系我们获取并签署一份商业许可证,才能合法使用 New API:
+
+- **场景一:移除品牌和版权信息**  
+  您希望在您的产品或服务中移除 New API 的 LOGO、UI界面中的版权声明或其他品牌标识。
+
+- **场景二:规避 AGPLv3 开源义务**  
+  您基于 New API 进行了修改,并希望:  
+    - 通过网络提供服务(SaaS),但**不希望**向您的服务用户公开您修改后的源代码。  
+    - 分发一个集成了 New API 的软件产品,但**不希望**以 AGPLv3 许可证发布您的产品或公开源代码。
+
+- **场景三:企业政策与集成需求**  
+    - 您所在公司的政策、客户合同或项目要求不允许使用 AGPLv3 许可的软件。  
+    - 您需要进行 OEM 集成,将 New API 作为您闭源商业产品的一部分进行再分发。
+
+- **场景四:需要商业支持与保障**  
+    您需要 AGPLv3 未提供的商业保障,如官方技术支持等。
+
+**获取商业许可:**  
+请通过电子邮件 **[email protected]** 联系 New API 团队洽谈商业授权事宜。
+
+## **3. 贡献 (Contributions)**
+
+- 我们欢迎社区对 New API 的贡献。所有向本项目提交的贡献(例如通过 Pull Request)都将被视为在 **AGPLv3** 许可证下提供。
+- 通过向本项目提交贡献,即表示您同意您的代码以 AGPLv3 许可证授权给本项目及所有后续使用者(无论这些使用者最终遵循 AGPLv3 还是商业许可)。
+- 您也理解并同意,您的贡献可能会被包含在根据商业许可证分发的 New API 版本中。
+
+## **4. 其他条款 (Other Terms)**
+
+- 关于商业许可证的具体条款、条件和价格,以双方签署的正式商业许可协议为准。
+- 项目维护者保留根据需要更新本许可政策的权利。相关更新将通过项目官方渠道(如代码仓库、官方网站)进行通知。
+
+---
+
+# **New API Licensing**
+
+This project uses a **Usage-Based Dual Licensing** model.
+
+**Core Principles:**
+
+- **Default License:** This project is available by default under the **GNU Affero General Public License v3.0 (AGPLv3)**. Any user may use it free of charge, provided they comply with both the AGPLv3 terms and the additional restrictions listed below.
+- **Commercial License:** For specific commercial scenarios, or if you require rights beyond those granted by AGPLv3, you **must** obtain a **Commercial License**.
+
+---
+
+## **1. Open Source License: AGPLv3 – For Basic Usage**
+
+- Under the terms of the **AGPLv3**, you are free to use, modify, and distribute New API. The complete AGPLv3 license text can be viewed at [https://www.gnu.org/licenses/agpl-3.0.html](https://www.gnu.org/licenses/agpl-3.0.html).
+- **Core Obligation:** A key AGPLv3 requirement is that if you modify New API and provide it as a network service (SaaS), or distribute a modified version, you must make the **complete corresponding source code** available to all users under the AGPLv3 license.
+- **Additional Restriction (Important):** When using only the AGPLv3 open-source license, you **must** retain all original branding, logos, and copyright statements within the project’s code. **You are strictly prohibited from modifying, removing, or concealing** any such information. If you wish to remove this, you must obtain a Commercial License.
+- Please read and ensure that you fully understand all AGPLv3 terms and the above additional restriction before use.
+
+## **2. Commercial License – For Advanced Scenarios & Closed Source Needs**
+
+You **must** contact us to obtain and sign a Commercial License in any of the following scenarios in order to legally use New API:
+
+- **Scenario 1: Removal of Branding and Copyright**  
+  You wish to remove the New API logo, copyright statement, or other branding elements from your product or service.
+
+- **Scenario 2: Avoidance of AGPLv3 Open Source Obligations**  
+  You have modified New API and wish to:
+    - Offer it as a network service (SaaS) **without** disclosing your modifications' source code to your users.
+    - Distribute a software product integrated with New API **without** releasing your product under AGPLv3 or open-sourcing the code.
+
+- **Scenario 3: Enterprise Policy & Integration Needs**  
+    - Your organization’s policies, client contracts, or project requirements prohibit the use of AGPLv3-licensed software.
+    - You require OEM integration and need to redistribute New API as part of your closed-source commercial product.
+
+- **Scenario 4: Commercial Support and Assurances**  
+    You require commercial assurances not provided by AGPLv3, such as official technical support.
+
+**Obtaining a Commercial License:**  
+Please contact the New API team via email at **[email protected]** to discuss commercial licensing.
+
+## **3. Contributions**
+
+- We welcome community contributions to New API. All contributions (e.g., via Pull Request) are deemed to be provided under the **AGPLv3** license.
+- By submitting a contribution, you agree that your code is licensed to this project and all downstream users under the AGPLv3 license (regardless of whether those users ultimately operate under AGPLv3 or a Commercial License).
+- You also acknowledge and agree that your contribution may be included in New API releases distributed under a Commercial License.
+
+## **4. Other Terms**
+
+- The specific terms, conditions, and pricing of the Commercial License are governed by the formal commercial license agreement executed by both parties.
+- Project maintainers reserve the right to update this licensing policy as needed. Updates will be communicated via official project channels (e.g., repository, official website).

+ 1 - 1
README.md

@@ -100,7 +100,7 @@ New API提供了丰富的功能,详细特性请参考[特性说明](https://do
     1. OpenAI Chat Completions => Claude Messages
     2. Clade Messages => OpenAI Chat Completions (可用于Claude Code调用第三方模型)
     3. OpenAI Chat Completions => Gemini Chat
-20. 💰 缓存计费支持,开启后可以在缓存命中时按照设定的比例计费:
+19. 💰 缓存计费支持,开启后可以在缓存命中时按照设定的比例计费:
     1. 在 `系统设置-运营设置` 中设置 `提示缓存倍率` 选项
     2. 在渠道中设置 `提示缓存倍率`,范围 0-1,例如设置为 0.5 表示缓存命中时按照 50% 计费
     3. 支持的渠道:

+ 2 - 0
common/api_type.go

@@ -65,6 +65,8 @@ func ChannelType2APIType(channelType int) (int, bool) {
 		apiType = constant.APITypeCoze
 	case constant.ChannelTypeJimeng:
 		apiType = constant.APITypeJimeng
+	case constant.ChannelTypeMoonshot:
+		apiType = constant.APITypeMoonshot
 	}
 	if apiType == -1 {
 		return constant.APITypeOpenAI, false

+ 1 - 0
common/constants.go

@@ -83,6 +83,7 @@ var GitHubClientId = ""
 var GitHubClientSecret = ""
 var LinuxDOClientId = ""
 var LinuxDOClientSecret = ""
+var LinuxDOMinimumTrustLevel = 0
 
 var WeChatServerAddress = ""
 var WeChatServerToken = ""

+ 19 - 0
common/copy.go

@@ -0,0 +1,19 @@
+package common
+
+import (
+	"fmt"
+
+	"github.com/jinzhu/copier"
+)
+
+func DeepCopy[T any](src *T) (*T, error) {
+	if src == nil {
+		return nil, fmt.Errorf("copy source cannot be nil")
+	}
+	var dst T
+	err := copier.CopyWithOption(&dst, src, copier.Option{DeepCopy: true, IgnoreEmpty: true})
+	if err != nil {
+		return nil, err
+	}
+	return &dst, nil
+}

+ 5 - 0
common/custom-event.go

@@ -9,6 +9,7 @@ import (
 	"io"
 	"net/http"
 	"strings"
+	"sync"
 )
 
 type stringWriter interface {
@@ -52,6 +53,8 @@ type CustomEvent struct {
 	Id    string
 	Retry uint
 	Data  interface{}
+
+	Mutex sync.Mutex
 }
 
 func encode(writer io.Writer, event CustomEvent) error {
@@ -73,6 +76,8 @@ func (r CustomEvent) Render(w http.ResponseWriter) error {
 }
 
 func (r CustomEvent) WriteContentType(w http.ResponseWriter) {
+	r.Mutex.Lock()
+	defer r.Mutex.Unlock()
 	header := w.Header()
 	header["Content-Type"] = contentType
 

+ 1 - 1
common/database.go

@@ -12,4 +12,4 @@ var LogSqlType = DatabaseTypeSQLite // Default to SQLite for logging SQL queries
 var UsingMySQL = false
 var UsingClickHouse = false
 
-var SQLitePath = "one-api.db?_busy_timeout=5000"
+var SQLitePath = "one-api.db?_busy_timeout=30000"

+ 32 - 0
common/endpoint_defaults.go

@@ -0,0 +1,32 @@
+package common
+
+import "one-api/constant"
+
+// EndpointInfo 描述单个端点的默认请求信息
+// path: 上游路径
+// method: HTTP 请求方式,例如 POST/GET
+// 目前均为 POST,后续可扩展
+//
+// json 标签用于直接序列化到 API 输出
+// 例如:{"path":"/v1/chat/completions","method":"POST"}
+
+type EndpointInfo struct {
+	Path   string `json:"path"`
+	Method string `json:"method"`
+}
+
+// defaultEndpointInfoMap 保存内置端点的默认 Path 与 Method
+var defaultEndpointInfoMap = map[constant.EndpointType]EndpointInfo{
+	constant.EndpointTypeOpenAI:          {Path: "/v1/chat/completions", Method: "POST"},
+	constant.EndpointTypeOpenAIResponse:  {Path: "/v1/responses", Method: "POST"},
+	constant.EndpointTypeAnthropic:       {Path: "/v1/messages", Method: "POST"},
+	constant.EndpointTypeGemini:          {Path: "/v1beta/models/{model}:generateContent", Method: "POST"},
+	constant.EndpointTypeJinaRerank:      {Path: "/rerank", Method: "POST"},
+	constant.EndpointTypeImageGeneration: {Path: "/v1/images/generations", Method: "POST"},
+}
+
+// GetDefaultEndpointInfo 返回指定端点类型的默认信息以及是否存在
+func GetDefaultEndpointInfo(et constant.EndpointType) (EndpointInfo, bool) {
+	info, ok := defaultEndpointInfoMap[et]
+	return info, ok
+}

+ 5 - 1
common/gin.go

@@ -2,12 +2,13 @@ package common
 
 import (
 	"bytes"
-	"github.com/gin-gonic/gin"
 	"io"
 	"net/http"
 	"one-api/constant"
 	"strings"
 	"time"
+
+	"github.com/gin-gonic/gin"
 )
 
 const KeyRequestBody = "key_request_body"
@@ -31,6 +32,9 @@ func UnmarshalBodyReusable(c *gin.Context, v any) error {
 	if err != nil {
 		return err
 	}
+	//if DebugEnabled {
+	//	println("UnmarshalBodyReusable request body:", string(requestBody))
+	//}
 	contentType := c.Request.Header.Get("Content-Type")
 	if strings.HasPrefix(contentType, "application/json") {
 		err = Unmarshal(requestBody, &v)

+ 1 - 1
common/init.go

@@ -101,7 +101,7 @@ func InitEnv() {
 }
 
 func initConstantEnv() {
-	constant.StreamingTimeout = GetEnvOrDefault("STREAMING_TIMEOUT", 120)
+	constant.StreamingTimeout = GetEnvOrDefault("STREAMING_TIMEOUT", 300)
 	constant.DifyDebug = GetEnvOrDefaultBool("DIFY_DEBUG", true)
 	constant.MaxFileDownloadMB = GetEnvOrDefault("MAX_FILE_DOWNLOAD_MB", 20)
 	// ForceStreamOption 覆盖请求参数,强制返回usage信息

+ 22 - 0
common/ip.go

@@ -0,0 +1,22 @@
+package common
+
+import "net"
+
+func IsPrivateIP(ip net.IP) bool {
+	if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
+		return true
+	}
+
+	private := []net.IPNet{
+		{IP: net.IPv4(10, 0, 0, 0), Mask: net.CIDRMask(8, 32)},
+		{IP: net.IPv4(172, 16, 0, 0), Mask: net.CIDRMask(12, 32)},
+		{IP: net.IPv4(192, 168, 0, 0), Mask: net.CIDRMask(16, 32)},
+	}
+
+	for _, privateNet := range private {
+		if privateNet.Contains(ip) {
+			return true
+		}
+	}
+	return false
+}

+ 22 - 0
common/json.go

@@ -20,3 +20,25 @@ func DecodeJson(reader *bytes.Reader, v any) error {
 func Marshal(v any) ([]byte, error) {
 	return json.Marshal(v)
 }
+
+func GetJsonType(data json.RawMessage) string {
+	data = bytes.TrimSpace(data)
+	if len(data) == 0 {
+		return "unknown"
+	}
+	firstChar := bytes.TrimSpace(data)[0]
+	switch firstChar {
+	case '{':
+		return "object"
+	case '[':
+		return "array"
+	case '"':
+		return "string"
+	case 't', 'f':
+		return "boolean"
+	case 'n':
+		return "null"
+	default:
+		return "number"
+	}
+}

+ 1 - 1
common/page_info.go

@@ -41,7 +41,7 @@ func (p *PageInfo) SetItems(items any) {
 func GetPageQuery(c *gin.Context) *PageInfo {
 	pageInfo := &PageInfo{}
 	// 手动获取并处理每个参数
-	if page, err := strconv.Atoi(c.Query("page")); err == nil {
+	if page, err := strconv.Atoi(c.Query("p")); err == nil {
 		pageInfo.Page = page
 	}
 	if pageSize, err := strconv.Atoi(c.Query("page_size")); err == nil {

+ 5 - 0
common/quota.go

@@ -0,0 +1,5 @@
+package common
+
+func GetTrustQuota() int {
+	return int(10 * QuotaPerUnit)
+}

+ 327 - 0
common/ssrf_protection.go

@@ -0,0 +1,327 @@
+package common
+
+import (
+	"fmt"
+	"net"
+	"net/url"
+	"strconv"
+	"strings"
+)
+
+// SSRFProtection SSRF防护配置
+type SSRFProtection struct {
+	AllowPrivateIp         bool
+	DomainFilterMode       bool     // true: 白名单, false: 黑名单
+	DomainList             []string // domain format, e.g. example.com, *.example.com
+	IpFilterMode           bool     // true: 白名单, false: 黑名单
+	IpList                 []string // CIDR or single IP
+	AllowedPorts           []int    // 允许的端口范围
+	ApplyIPFilterForDomain bool     // 对域名启用IP过滤
+}
+
+// DefaultSSRFProtection 默认SSRF防护配置
+var DefaultSSRFProtection = &SSRFProtection{
+	AllowPrivateIp:   false,
+	DomainFilterMode: true,
+	DomainList:       []string{},
+	IpFilterMode:     true,
+	IpList:           []string{},
+	AllowedPorts:     []int{},
+}
+
+// isPrivateIP 检查IP是否为私有地址
+func isPrivateIP(ip net.IP) bool {
+	if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
+		return true
+	}
+
+	// 检查私有网段
+	private := []net.IPNet{
+		{IP: net.IPv4(10, 0, 0, 0), Mask: net.CIDRMask(8, 32)},     // 10.0.0.0/8
+		{IP: net.IPv4(172, 16, 0, 0), Mask: net.CIDRMask(12, 32)},  // 172.16.0.0/12
+		{IP: net.IPv4(192, 168, 0, 0), Mask: net.CIDRMask(16, 32)}, // 192.168.0.0/16
+		{IP: net.IPv4(127, 0, 0, 0), Mask: net.CIDRMask(8, 32)},    // 127.0.0.0/8
+		{IP: net.IPv4(169, 254, 0, 0), Mask: net.CIDRMask(16, 32)}, // 169.254.0.0/16 (链路本地)
+		{IP: net.IPv4(224, 0, 0, 0), Mask: net.CIDRMask(4, 32)},    // 224.0.0.0/4 (组播)
+		{IP: net.IPv4(240, 0, 0, 0), Mask: net.CIDRMask(4, 32)},    // 240.0.0.0/4 (保留)
+	}
+
+	for _, privateNet := range private {
+		if privateNet.Contains(ip) {
+			return true
+		}
+	}
+
+	// 检查IPv6私有地址
+	if ip.To4() == nil {
+		// IPv6 loopback
+		if ip.Equal(net.IPv6loopback) {
+			return true
+		}
+		// IPv6 link-local
+		if strings.HasPrefix(ip.String(), "fe80:") {
+			return true
+		}
+		// IPv6 unique local
+		if strings.HasPrefix(ip.String(), "fc") || strings.HasPrefix(ip.String(), "fd") {
+			return true
+		}
+	}
+
+	return false
+}
+
+// parsePortRanges 解析端口范围配置
+// 支持格式: "80", "443", "8000-9000"
+func parsePortRanges(portConfigs []string) ([]int, error) {
+	var ports []int
+
+	for _, config := range portConfigs {
+		config = strings.TrimSpace(config)
+		if config == "" {
+			continue
+		}
+
+		if strings.Contains(config, "-") {
+			// 处理端口范围 "8000-9000"
+			parts := strings.Split(config, "-")
+			if len(parts) != 2 {
+				return nil, fmt.Errorf("invalid port range format: %s", config)
+			}
+
+			startPort, err := strconv.Atoi(strings.TrimSpace(parts[0]))
+			if err != nil {
+				return nil, fmt.Errorf("invalid start port in range %s: %v", config, err)
+			}
+
+			endPort, err := strconv.Atoi(strings.TrimSpace(parts[1]))
+			if err != nil {
+				return nil, fmt.Errorf("invalid end port in range %s: %v", config, err)
+			}
+
+			if startPort > endPort {
+				return nil, fmt.Errorf("invalid port range %s: start port cannot be greater than end port", config)
+			}
+
+			if startPort < 1 || startPort > 65535 || endPort < 1 || endPort > 65535 {
+				return nil, fmt.Errorf("port range %s contains invalid port numbers (must be 1-65535)", config)
+			}
+
+			// 添加范围内的所有端口
+			for port := startPort; port <= endPort; port++ {
+				ports = append(ports, port)
+			}
+		} else {
+			// 处理单个端口 "80"
+			port, err := strconv.Atoi(config)
+			if err != nil {
+				return nil, fmt.Errorf("invalid port number: %s", config)
+			}
+
+			if port < 1 || port > 65535 {
+				return nil, fmt.Errorf("invalid port number %d (must be 1-65535)", port)
+			}
+
+			ports = append(ports, port)
+		}
+	}
+
+	return ports, nil
+}
+
+// isAllowedPort 检查端口是否被允许
+func (p *SSRFProtection) isAllowedPort(port int) bool {
+	if len(p.AllowedPorts) == 0 {
+		return true // 如果没有配置端口限制,则允许所有端口
+	}
+
+	for _, allowedPort := range p.AllowedPorts {
+		if port == allowedPort {
+			return true
+		}
+	}
+	return false
+}
+
+// isDomainWhitelisted 检查域名是否在白名单中
+func isDomainListed(domain string, list []string) bool {
+	if len(list) == 0 {
+		return false
+	}
+
+	domain = strings.ToLower(domain)
+	for _, item := range list {
+		item = strings.ToLower(strings.TrimSpace(item))
+		if item == "" {
+			continue
+		}
+		// 精确匹配
+		if domain == item {
+			return true
+		}
+		// 通配符匹配 (*.example.com)
+		if strings.HasPrefix(item, "*.") {
+			suffix := strings.TrimPrefix(item, "*.")
+			if strings.HasSuffix(domain, "."+suffix) || domain == suffix {
+				return true
+			}
+		}
+	}
+	return false
+}
+
+func (p *SSRFProtection) isDomainAllowed(domain string) bool {
+	listed := isDomainListed(domain, p.DomainList)
+	if p.DomainFilterMode { // 白名单
+		return listed
+	}
+	// 黑名单
+	return !listed
+}
+
+// isIPWhitelisted 检查IP是否在白名单中
+
+func isIPListed(ip net.IP, list []string) bool {
+	if len(list) == 0 {
+		return false
+	}
+
+	for _, whitelistCIDR := range list {
+		_, network, err := net.ParseCIDR(whitelistCIDR)
+		if err != nil {
+			// 尝试作为单个IP处理
+			if whitelistIP := net.ParseIP(whitelistCIDR); whitelistIP != nil {
+				if ip.Equal(whitelistIP) {
+					return true
+				}
+			}
+			continue
+		}
+
+		if network.Contains(ip) {
+			return true
+		}
+	}
+	return false
+}
+
+// IsIPAccessAllowed 检查IP是否允许访问
+func (p *SSRFProtection) IsIPAccessAllowed(ip net.IP) bool {
+	// 私有IP限制
+	if isPrivateIP(ip) && !p.AllowPrivateIp {
+		return false
+	}
+
+	listed := isIPListed(ip, p.IpList)
+	if p.IpFilterMode { // 白名单
+		return listed
+	}
+	// 黑名单
+	return !listed
+}
+
+// ValidateURL 验证URL是否安全
+func (p *SSRFProtection) ValidateURL(urlStr string) error {
+	// 解析URL
+	u, err := url.Parse(urlStr)
+	if err != nil {
+		return fmt.Errorf("invalid URL format: %v", err)
+	}
+
+	// 只允许HTTP/HTTPS协议
+	if u.Scheme != "http" && u.Scheme != "https" {
+		return fmt.Errorf("unsupported protocol: %s (only http/https allowed)", u.Scheme)
+	}
+
+	// 解析主机和端口
+	host, portStr, err := net.SplitHostPort(u.Host)
+	if err != nil {
+		// 没有端口,使用默认端口
+		host = u.Hostname()
+		if u.Scheme == "https" {
+			portStr = "443"
+		} else {
+			portStr = "80"
+		}
+	}
+
+	// 验证端口
+	port, err := strconv.Atoi(portStr)
+	if err != nil {
+		return fmt.Errorf("invalid port: %s", portStr)
+	}
+
+	if !p.isAllowedPort(port) {
+		return fmt.Errorf("port %d is not allowed", port)
+	}
+
+	// 如果 host 是 IP,则跳过域名检查
+	if ip := net.ParseIP(host); ip != nil {
+		if !p.IsIPAccessAllowed(ip) {
+			if isPrivateIP(ip) {
+				return fmt.Errorf("private IP address not allowed: %s", ip.String())
+			}
+			if p.IpFilterMode {
+				return fmt.Errorf("ip not in whitelist: %s", ip.String())
+			}
+			return fmt.Errorf("ip in blacklist: %s", ip.String())
+		}
+		return nil
+	}
+
+	// 先进行域名过滤
+	if !p.isDomainAllowed(host) {
+		if p.DomainFilterMode {
+			return fmt.Errorf("domain not in whitelist: %s", host)
+		}
+		return fmt.Errorf("domain in blacklist: %s", host)
+	}
+
+	// 若未启用对域名应用IP过滤,则到此通过
+	if !p.ApplyIPFilterForDomain {
+		return nil
+	}
+
+	// 解析域名对应IP并检查
+	ips, err := net.LookupIP(host)
+	if err != nil {
+		return fmt.Errorf("DNS resolution failed for %s: %v", host, err)
+	}
+	for _, ip := range ips {
+		if !p.IsIPAccessAllowed(ip) {
+			if isPrivateIP(ip) && !p.AllowPrivateIp {
+				return fmt.Errorf("private IP address not allowed: %s resolves to %s", host, ip.String())
+			}
+			if p.IpFilterMode {
+				return fmt.Errorf("ip not in whitelist: %s resolves to %s", host, ip.String())
+			}
+			return fmt.Errorf("ip in blacklist: %s resolves to %s", host, ip.String())
+		}
+	}
+	return nil
+}
+
+// ValidateURLWithFetchSetting 使用FetchSetting配置验证URL
+func ValidateURLWithFetchSetting(urlStr string, enableSSRFProtection, allowPrivateIp bool, domainFilterMode bool, ipFilterMode bool, domainList, ipList, allowedPorts []string, applyIPFilterForDomain bool) error {
+	// 如果SSRF防护被禁用,直接返回成功
+	if !enableSSRFProtection {
+		return nil
+	}
+
+	// 解析端口范围配置
+	allowedPortInts, err := parsePortRanges(allowedPorts)
+	if err != nil {
+		return fmt.Errorf("request reject - invalid port configuration: %v", err)
+	}
+
+	protection := &SSRFProtection{
+		AllowPrivateIp:         allowPrivateIp,
+		DomainFilterMode:       domainFilterMode,
+		DomainList:             domainList,
+		IpFilterMode:           ipFilterMode,
+		IpList:                 ipList,
+		AllowedPorts:           allowedPortInts,
+		ApplyIPFilterForDomain: applyIPFilterForDomain,
+	}
+	return protection.ValidateURL(urlStr)
+}

+ 140 - 0
common/str.go

@@ -4,7 +4,10 @@ import (
 	"encoding/base64"
 	"encoding/json"
 	"math/rand"
+	"net/url"
+	"regexp"
 	"strconv"
+	"strings"
 	"unsafe"
 )
 
@@ -95,3 +98,140 @@ func GetJsonString(data any) string {
 	b, _ := json.Marshal(data)
 	return string(b)
 }
+
+// MaskEmail masks a user email to prevent PII leakage in logs
+// Returns "***masked***" if email is empty, otherwise shows only the domain part
+func MaskEmail(email string) string {
+	if email == "" {
+		return "***masked***"
+	}
+
+	// Find the @ symbol
+	atIndex := strings.Index(email, "@")
+	if atIndex == -1 {
+		// No @ symbol found, return masked
+		return "***masked***"
+	}
+
+	// Return only the domain part with @ symbol
+	return "***@" + email[atIndex+1:]
+}
+
+// maskHostTail returns the tail parts of a domain/host that should be preserved.
+// It keeps 2 parts for likely country-code TLDs (e.g., co.uk, com.cn), otherwise keeps only the TLD.
+func maskHostTail(parts []string) []string {
+	if len(parts) < 2 {
+		return parts
+	}
+	lastPart := parts[len(parts)-1]
+	secondLastPart := parts[len(parts)-2]
+	if len(lastPart) == 2 && len(secondLastPart) <= 3 {
+		// Likely country code TLD like co.uk, com.cn
+		return []string{secondLastPart, lastPart}
+	}
+	return []string{lastPart}
+}
+
+// maskHostForURL collapses subdomains and keeps only masked prefix + preserved tail.
+// Example: api.openai.com -> ***.com, sub.domain.co.uk -> ***.co.uk
+func maskHostForURL(host string) string {
+	parts := strings.Split(host, ".")
+	if len(parts) < 2 {
+		return "***"
+	}
+	tail := maskHostTail(parts)
+	return "***." + strings.Join(tail, ".")
+}
+
+// maskHostForPlainDomain masks a plain domain and reflects subdomain depth with multiple ***.
+// Example: openai.com -> ***.com, api.openai.com -> ***.***.com, sub.domain.co.uk -> ***.***.co.uk
+func maskHostForPlainDomain(domain string) string {
+	parts := strings.Split(domain, ".")
+	if len(parts) < 2 {
+		return domain
+	}
+	tail := maskHostTail(parts)
+	numStars := len(parts) - len(tail)
+	if numStars < 1 {
+		numStars = 1
+	}
+	stars := strings.TrimSuffix(strings.Repeat("***.", numStars), ".")
+	return stars + "." + strings.Join(tail, ".")
+}
+
+// MaskSensitiveInfo masks sensitive information like URLs, IPs, and domain names in a string
+// Example:
+// http://example.com -> http://***.com
+// https://api.test.org/v1/users/123?key=secret -> https://***.org/***/***/?key=***
+// https://sub.domain.co.uk/path/to/resource -> https://***.co.uk/***/***
+// 192.168.1.1 -> ***.***.***.***
+// openai.com -> ***.com
+// www.openai.com -> ***.***.com
+// api.openai.com -> ***.***.com
+func MaskSensitiveInfo(str string) string {
+	// Mask URLs
+	urlPattern := regexp.MustCompile(`(http|https)://[^\s/$.?#].[^\s]*`)
+	str = urlPattern.ReplaceAllStringFunc(str, func(urlStr string) string {
+		u, err := url.Parse(urlStr)
+		if err != nil {
+			return urlStr
+		}
+
+		host := u.Host
+		if host == "" {
+			return urlStr
+		}
+
+		// Mask host with unified logic
+		maskedHost := maskHostForURL(host)
+
+		result := u.Scheme + "://" + maskedHost
+
+		// Mask path
+		if u.Path != "" && u.Path != "/" {
+			pathParts := strings.Split(strings.Trim(u.Path, "/"), "/")
+			maskedPathParts := make([]string, len(pathParts))
+			for i := range pathParts {
+				if pathParts[i] != "" {
+					maskedPathParts[i] = "***"
+				}
+			}
+			if len(maskedPathParts) > 0 {
+				result += "/" + strings.Join(maskedPathParts, "/")
+			}
+		} else if u.Path == "/" {
+			result += "/"
+		}
+
+		// Mask query parameters
+		if u.RawQuery != "" {
+			values, err := url.ParseQuery(u.RawQuery)
+			if err != nil {
+				// If can't parse query, just mask the whole query string
+				result += "?***"
+			} else {
+				maskedParams := make([]string, 0, len(values))
+				for key := range values {
+					maskedParams = append(maskedParams, key+"=***")
+				}
+				if len(maskedParams) > 0 {
+					result += "?" + strings.Join(maskedParams, "&")
+				}
+			}
+		}
+
+		return result
+	})
+
+	// Mask domain names without protocol (like openai.com, www.openai.com)
+	domainPattern := regexp.MustCompile(`\b(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}\b`)
+	str = domainPattern.ReplaceAllStringFunc(str, func(domain string) string {
+		return maskHostForPlainDomain(domain)
+	})
+
+	// Mask IP addresses
+	ipPattern := regexp.MustCompile(`\b(?:\d{1,3}\.){3}\d{1,3}\b`)
+	str = ipPattern.ReplaceAllString(str, "***.***.***.***")
+
+	return str
+}

+ 55 - 0
common/sys_log.go

@@ -0,0 +1,55 @@
+package common
+
+import (
+	"fmt"
+	"os"
+	"time"
+
+	"github.com/gin-gonic/gin"
+)
+
+func SysLog(s string) {
+	t := time.Now()
+	_, _ = fmt.Fprintf(gin.DefaultWriter, "[SYS] %v | %s \n", t.Format("2006/01/02 - 15:04:05"), s)
+}
+
+func SysError(s string) {
+	t := time.Now()
+	_, _ = fmt.Fprintf(gin.DefaultErrorWriter, "[SYS] %v | %s \n", t.Format("2006/01/02 - 15:04:05"), s)
+}
+
+func FatalLog(v ...any) {
+	t := time.Now()
+	_, _ = fmt.Fprintf(gin.DefaultErrorWriter, "[FATAL] %v | %v \n", t.Format("2006/01/02 - 15:04:05"), v)
+	os.Exit(1)
+}
+
+func LogStartupSuccess(startTime time.Time, port string) {
+
+	duration := time.Since(startTime)
+	durationMs := duration.Milliseconds()
+
+	// Get network IPs
+	networkIps := GetNetworkIps()
+
+	// Print blank line for spacing
+	fmt.Fprintf(gin.DefaultWriter, "\n")
+
+	// Print the main success message
+	fmt.Fprintf(gin.DefaultWriter, "  \033[32m%s %s\033[0m  ready in %d ms\n", SystemName, Version, durationMs)
+	fmt.Fprintf(gin.DefaultWriter, "\n")
+
+	// Skip fancy startup message in container environments
+	if !IsRunningInContainer() {
+		// Print local URL
+		fmt.Fprintf(gin.DefaultWriter, "  ➜  \033[1mLocal:\033[0m   http://localhost:%s/\n", port)
+	}
+
+	// Print network URLs
+	for _, ip := range networkIps {
+		fmt.Fprintf(gin.DefaultWriter, "  ➜  \033[1mNetwork:\033[0m http://%s:%s/\n", ip, port)
+	}
+
+	// Print blank line for spacing
+	fmt.Fprintf(gin.DefaultWriter, "\n")
+}

+ 150 - 0
common/totp.go

@@ -0,0 +1,150 @@
+package common
+
+import (
+	"crypto/rand"
+	"fmt"
+	"os"
+	"strconv"
+	"strings"
+
+	"github.com/pquerna/otp"
+	"github.com/pquerna/otp/totp"
+)
+
+const (
+	// 备用码配置
+	BackupCodeLength = 8 // 备用码长度
+	BackupCodeCount  = 4 // 生成备用码数量
+
+	// 限制配置
+	MaxFailAttempts = 5   // 最大失败尝试次数
+	LockoutDuration = 300 // 锁定时间(秒)
+)
+
+// GenerateTOTPSecret 生成TOTP密钥和配置
+func GenerateTOTPSecret(accountName string) (*otp.Key, error) {
+	issuer := Get2FAIssuer()
+	return totp.Generate(totp.GenerateOpts{
+		Issuer:      issuer,
+		AccountName: accountName,
+		Period:      30,
+		Digits:      otp.DigitsSix,
+		Algorithm:   otp.AlgorithmSHA1,
+	})
+}
+
+// ValidateTOTPCode 验证TOTP验证码
+func ValidateTOTPCode(secret, code string) bool {
+	// 清理验证码格式
+	cleanCode := strings.ReplaceAll(code, " ", "")
+	if len(cleanCode) != 6 {
+		return false
+	}
+
+	// 验证验证码
+	return totp.Validate(cleanCode, secret)
+}
+
+// GenerateBackupCodes 生成备用恢复码
+func GenerateBackupCodes() ([]string, error) {
+	codes := make([]string, BackupCodeCount)
+
+	for i := 0; i < BackupCodeCount; i++ {
+		code, err := generateRandomBackupCode()
+		if err != nil {
+			return nil, err
+		}
+		codes[i] = code
+	}
+
+	return codes, nil
+}
+
+// generateRandomBackupCode 生成单个备用码
+func generateRandomBackupCode() (string, error) {
+	const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
+	code := make([]byte, BackupCodeLength)
+
+	for i := range code {
+		randomBytes := make([]byte, 1)
+		_, err := rand.Read(randomBytes)
+		if err != nil {
+			return "", err
+		}
+		code[i] = charset[int(randomBytes[0])%len(charset)]
+	}
+
+	// 格式化为 XXXX-XXXX 格式
+	return fmt.Sprintf("%s-%s", string(code[:4]), string(code[4:])), nil
+}
+
+// ValidateBackupCode 验证备用码格式
+func ValidateBackupCode(code string) bool {
+	// 移除所有分隔符并转为大写
+	cleanCode := strings.ToUpper(strings.ReplaceAll(code, "-", ""))
+	if len(cleanCode) != BackupCodeLength {
+		return false
+	}
+
+	// 检查字符是否合法
+	for _, char := range cleanCode {
+		if !((char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9')) {
+			return false
+		}
+	}
+
+	return true
+}
+
+// NormalizeBackupCode 标准化备用码格式
+func NormalizeBackupCode(code string) string {
+	cleanCode := strings.ToUpper(strings.ReplaceAll(code, "-", ""))
+	if len(cleanCode) == BackupCodeLength {
+		return fmt.Sprintf("%s-%s", cleanCode[:4], cleanCode[4:])
+	}
+	return code
+}
+
+// HashBackupCode 对备用码进行哈希
+func HashBackupCode(code string) (string, error) {
+	normalizedCode := NormalizeBackupCode(code)
+	return Password2Hash(normalizedCode)
+}
+
+// Get2FAIssuer 获取2FA发行者名称
+func Get2FAIssuer() string {
+	return SystemName
+}
+
+// getEnvOrDefault 获取环境变量或默认值
+func getEnvOrDefault(key, defaultValue string) string {
+	if value, exists := os.LookupEnv(key); exists {
+		return value
+	}
+	return defaultValue
+}
+
+// ValidateNumericCode 验证数字验证码格式
+func ValidateNumericCode(code string) (string, error) {
+	// 移除空格
+	code = strings.ReplaceAll(code, " ", "")
+
+	if len(code) != 6 {
+		return "", fmt.Errorf("验证码必须是6位数字")
+	}
+
+	// 检查是否为纯数字
+	if _, err := strconv.Atoi(code); err != nil {
+		return "", fmt.Errorf("验证码只能包含数字")
+	}
+
+	return code, nil
+}
+
+// GenerateQRCodeData 生成二维码数据
+func GenerateQRCodeData(secret, username string) string {
+	issuer := Get2FAIssuer()
+	accountName := fmt.Sprintf("%s (%s)", username, issuer)
+	return fmt.Sprintf("otpauth://totp/%s:%s?secret=%s&issuer=%s&digits=6&period=30",
+		issuer, accountName, secret, issuer)
+}

+ 107 - 27
common/utils.go

@@ -68,6 +68,78 @@ func GetIp() (ip string) {
 	return
 }
 
+func GetNetworkIps() []string {
+	var networkIps []string
+	ips, err := net.InterfaceAddrs()
+	if err != nil {
+		log.Println(err)
+		return networkIps
+	}
+
+	for _, a := range ips {
+		if ipNet, ok := a.(*net.IPNet); ok && !ipNet.IP.IsLoopback() {
+			if ipNet.IP.To4() != nil {
+				ip := ipNet.IP.String()
+				// Include common private network ranges
+				if strings.HasPrefix(ip, "10.") ||
+					strings.HasPrefix(ip, "172.") ||
+					strings.HasPrefix(ip, "192.168.") {
+					networkIps = append(networkIps, ip)
+				}
+			}
+		}
+	}
+	return networkIps
+}
+
+// IsRunningInContainer detects if the application is running inside a container
+func IsRunningInContainer() bool {
+	// Method 1: Check for .dockerenv file (Docker containers)
+	if _, err := os.Stat("/.dockerenv"); err == nil {
+		return true
+	}
+
+	// Method 2: Check cgroup for container indicators
+	if data, err := os.ReadFile("/proc/1/cgroup"); err == nil {
+		content := string(data)
+		if strings.Contains(content, "docker") ||
+			strings.Contains(content, "containerd") ||
+			strings.Contains(content, "kubepods") ||
+			strings.Contains(content, "/lxc/") {
+			return true
+		}
+	}
+
+	// Method 3: Check environment variables commonly set by container runtimes
+	containerEnvVars := []string{
+		"KUBERNETES_SERVICE_HOST",
+		"DOCKER_CONTAINER",
+		"container",
+	}
+
+	for _, envVar := range containerEnvVars {
+		if os.Getenv(envVar) != "" {
+			return true
+		}
+	}
+
+	// Method 4: Check if init process is not the traditional init
+	if data, err := os.ReadFile("/proc/1/comm"); err == nil {
+		comm := strings.TrimSpace(string(data))
+		// In containers, process 1 is often not "init" or "systemd"
+		if comm != "init" && comm != "systemd" {
+			// Additional check: if it's a common container entrypoint
+			if strings.Contains(comm, "docker") ||
+				strings.Contains(comm, "containerd") ||
+				strings.Contains(comm, "runc") {
+				return true
+			}
+		}
+	}
+
+	return false
+}
+
 var sizeKB = 1024
 var sizeMB = sizeKB * 1024
 var sizeGB = sizeMB * 1024
@@ -123,8 +195,16 @@ func Interface2String(inter interface{}) string {
 		return fmt.Sprintf("%d", inter.(int))
 	case float64:
 		return fmt.Sprintf("%f", inter.(float64))
+	case bool:
+		if inter.(bool) {
+			return "true"
+		} else {
+			return "false"
+		}
+	case nil:
+		return ""
 	}
-	return "Not Implemented"
+	return fmt.Sprintf("%v", inter)
 }
 
 func UnescapeHTML(x string) interface{} {
@@ -257,32 +337,32 @@ func GetAudioDuration(ctx context.Context, filename string, ext string) (float64
 	if err != nil {
 		return 0, errors.Wrap(err, "failed to get audio duration")
 	}
-  durationStr := string(bytes.TrimSpace(output))
-  if durationStr == "N/A" {
-    // Create a temporary output file name
-    tmpFp, err := os.CreateTemp("", "audio-*"+ext)
-    if err != nil {
-      return 0, errors.Wrap(err, "failed to create temporary file")
-    }
-    tmpName := tmpFp.Name()
-    // Close immediately so ffmpeg can open the file on Windows.
-    _ = tmpFp.Close()
-    defer os.Remove(tmpName)
-
-    // ffmpeg -y -i filename -vcodec copy -acodec copy <tmpName>
-    ffmpegCmd := exec.CommandContext(ctx, "ffmpeg", "-y", "-i", filename, "-vcodec", "copy", "-acodec", "copy", tmpName)
-    if err := ffmpegCmd.Run(); err != nil {
-      return 0, errors.Wrap(err, "failed to run ffmpeg")
-    }
-
-    // Recalculate the duration of the new file
-    c = exec.CommandContext(ctx, "ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", tmpName)
-    output, err := c.Output()
-    if err != nil {
-      return 0, errors.Wrap(err, "failed to get audio duration after ffmpeg")
-    }
-    durationStr = string(bytes.TrimSpace(output))
-  }
+	durationStr := string(bytes.TrimSpace(output))
+	if durationStr == "N/A" {
+		// Create a temporary output file name
+		tmpFp, err := os.CreateTemp("", "audio-*"+ext)
+		if err != nil {
+			return 0, errors.Wrap(err, "failed to create temporary file")
+		}
+		tmpName := tmpFp.Name()
+		// Close immediately so ffmpeg can open the file on Windows.
+		_ = tmpFp.Close()
+		defer os.Remove(tmpName)
+
+		// ffmpeg -y -i filename -vcodec copy -acodec copy <tmpName>
+		ffmpegCmd := exec.CommandContext(ctx, "ffmpeg", "-y", "-i", filename, "-vcodec", "copy", "-acodec", "copy", tmpName)
+		if err := ffmpegCmd.Run(); err != nil {
+			return 0, errors.Wrap(err, "failed to run ffmpeg")
+		}
+
+		// Recalculate the duration of the new file
+		c = exec.CommandContext(ctx, "ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", tmpName)
+		output, err := c.Output()
+		if err != nil {
+			return 0, errors.Wrap(err, "failed to get audio duration after ffmpeg")
+		}
+		durationStr = string(bytes.TrimSpace(output))
+	}
 	return strconv.ParseFloat(durationStr, 64)
 }
 

+ 2 - 1
constant/api_type.go

@@ -31,5 +31,6 @@ const (
 	APITypeXai
 	APITypeCoze
 	APITypeJimeng
-	APITypeDummy // this one is only for count, do not add any channel after this
+	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
 )

+ 2 - 0
constant/channel.go

@@ -49,6 +49,7 @@ const (
 	ChannelTypeCoze           = 49
 	ChannelTypeKling          = 50
 	ChannelTypeJimeng         = 51
+	ChannelTypeVidu           = 52
 	ChannelTypeDummy          // this one is only for count, do not add any channel after this
 
 )
@@ -106,4 +107,5 @@ var ChannelBaseURLs = []string{
 	"https://api.coze.cn",                       //49
 	"https://api.klingai.com",                   //50
 	"https://visual.volcengineapi.com",          //51
+	"https://api.vidu.cn",                       //52
 }

+ 7 - 1
constant/context_key.go

@@ -3,6 +3,9 @@ package constant
 type ContextKey string
 
 const (
+	ContextKeyTokenCountMeta ContextKey = "token_count_meta"
+	ContextKeyPromptTokens   ContextKey = "prompt_tokens"
+
 	ContextKeyOriginalModel    ContextKey = "original_model"
 	ContextKeyRequestStartTime ContextKey = "request_start_time"
 
@@ -11,7 +14,6 @@ const (
 	ContextKeyTokenKey               ContextKey = "token_key"
 	ContextKeyTokenId                ContextKey = "token_id"
 	ContextKeyTokenGroup             ContextKey = "token_group"
-	ContextKeyTokenAllowIps          ContextKey = "allow_ips"
 	ContextKeyTokenSpecificChannelId ContextKey = "specific_channel_id"
 	ContextKeyTokenModelLimitEnabled ContextKey = "token_model_limit_enabled"
 	ContextKeyTokenModelLimit        ContextKey = "token_model_limit"
@@ -23,7 +25,9 @@ const (
 	ContextKeyChannelBaseUrl           ContextKey = "base_url"
 	ContextKeyChannelType              ContextKey = "channel_type"
 	ContextKeyChannelSetting           ContextKey = "channel_setting"
+	ContextKeyChannelOtherSetting      ContextKey = "channel_other_setting"
 	ContextKeyChannelParamOverride     ContextKey = "param_override"
+	ContextKeyChannelHeaderOverride    ContextKey = "header_override"
 	ContextKeyChannelOrganization      ContextKey = "channel_organization"
 	ContextKeyChannelAutoBan           ContextKey = "auto_ban"
 	ContextKeyChannelModelMapping      ContextKey = "model_mapping"
@@ -41,4 +45,6 @@ const (
 	ContextKeyUserGroup   ContextKey = "user_group"
 	ContextKeyUsingGroup  ContextKey = "group"
 	ContextKeyUserName    ContextKey = "username"
+
+	ContextKeySystemPromptOverride ContextKey = "system_prompt_override"
 )

+ 4 - 4
constant/task.go

@@ -5,16 +5,16 @@ type TaskPlatform string
 const (
 	TaskPlatformSuno       TaskPlatform = "suno"
 	TaskPlatformMidjourney              = "mj"
-	TaskPlatformKling      TaskPlatform = "kling"
-	TaskPlatformJimeng     TaskPlatform = "jimeng"
 )
 
 const (
 	SunoActionMusic  = "MUSIC"
 	SunoActionLyrics = "LYRICS"
 
-	TaskActionGenerate     = "generate"
-	TaskActionTextGenerate = "textGenerate"
+	TaskActionGenerate          = "generate"
+	TaskActionTextGenerate      = "textGenerate"
+	TaskActionFirstTailGenerate = "firstTailGenerate"
+	TaskActionReferenceGenerate = "referenceGenerate"
 )
 
 var SunoModel2Action = map[string]string{

+ 7 - 3
controller/channel-billing.go

@@ -10,7 +10,7 @@ import (
 	"one-api/constant"
 	"one-api/model"
 	"one-api/service"
-	"one-api/setting"
+	"one-api/setting/operation_setting"
 	"one-api/types"
 	"strconv"
 	"time"
@@ -135,7 +135,11 @@ func GetResponseBody(method, url string, channel *model.Channel, headers http.He
 	for k := range headers {
 		req.Header.Add(k, headers.Get(k))
 	}
-	res, err := service.GetHttpClient().Do(req)
+	client, err := service.NewProxyHttpClient(channel.GetSetting().Proxy)
+	if err != nil {
+		return nil, err
+	}
+	res, err := client.Do(req)
 	if err != nil {
 		return nil, err
 	}
@@ -338,7 +342,7 @@ func updateChannelMoonshotBalance(channel *model.Channel) (float64, error) {
 		return 0, fmt.Errorf("failed to update moonshot balance, status: %v, code: %d, scode: %s", response.Status, response.Code, response.Scode)
 	}
 	availableBalanceCny := response.Data.AvailableBalance
-	availableBalanceUsd := decimal.NewFromFloat(availableBalanceCny).Div(decimal.NewFromFloat(setting.Price)).InexactFloat64()
+	availableBalanceUsd := decimal.NewFromFloat(availableBalanceCny).Div(decimal.NewFromFloat(operation_setting.Price)).InexactFloat64()
 	channel.UpdateBalance(availableBalanceUsd)
 	return availableBalanceUsd, nil
 }

+ 107 - 29
controller/channel-test.go

@@ -20,6 +20,7 @@ import (
 	relayconstant "one-api/relay/constant"
 	"one-api/relay/helper"
 	"one-api/service"
+	"one-api/setting/operation_setting"
 	"one-api/types"
 	"strconv"
 	"strings"
@@ -69,6 +70,12 @@ func testChannel(channel *model.Channel, testModel string) testResult {
 			newAPIError: nil,
 		}
 	}
+	if channel.Type == constant.ChannelTypeVidu {
+		return testResult{
+			localErr:    errors.New("vidu channel test is not supported"),
+			newAPIError: nil,
+		}
+	}
 	w := httptest.NewRecorder()
 	c, _ := gin.CreateTestContext(w)
 
@@ -83,6 +90,11 @@ func testChannel(channel *model.Channel, testModel string) testResult {
 		requestPath = "/v1/embeddings" // 修改请求路径
 	}
 
+	// VolcEngine 图像生成模型
+	if channel.Type == constant.ChannelTypeVolcEngine && strings.Contains(testModel, "seedream") {
+		requestPath = "/v1/images/generations"
+	}
+
 	c.Request = &http.Request{
 		Method: "POST",
 		URL:    &url.URL{Path: requestPath}, // 使用动态路径
@@ -102,6 +114,21 @@ 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{
@@ -126,10 +153,30 @@ 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
+	}
 
-	info := relaycommon.GenRelayInfo(c)
+	info, err := relaycommon.GenRelayInfo(c, relayFormat, request, nil)
 
-	err = helper.ModelMappedHelper(c, info, nil)
+	if err != nil {
+		return testResult{
+			context:     c,
+			localErr:    err,
+			newAPIError: types.NewError(err, types.ErrorCodeGenRelayInfoFailed),
+		}
+	}
+
+	info.InitChannelMeta(c)
+
+	err = helper.ModelMappedHelper(c, info, request)
 	if err != nil {
 		return testResult{
 			context:     c,
@@ -137,7 +184,9 @@ func testChannel(channel *model.Channel, testModel string) testResult {
 			newAPIError: types.NewError(err, types.ErrorCodeChannelModelMappedError),
 		}
 	}
+
 	testModel = info.UpstreamModelName
+	request.Model = testModel
 
 	apiType, _ := common.ChannelType2APIType(channel.Type)
 	adaptor := relay.GetAdaptor(apiType)
@@ -149,13 +198,12 @@ func testChannel(channel *model.Channel, testModel string) testResult {
 		}
 	}
 
-	request := buildTestRequest(testModel)
-	// 创建一个用于日志的 info 副本,移除 ApiKey
-	logInfo := *info
-	logInfo.ApiKey = ""
-	common.SysLog(fmt.Sprintf("testing channel %d with model %s , info %+v ", channel.Id, testModel, logInfo))
+	//// 创建一个用于日志的 info 副本,移除 ApiKey
+	//logInfo := info
+	//logInfo.ApiKey = ""
+	common.SysLog(fmt.Sprintf("testing channel %d with model %s , info %+v ", channel.Id, testModel, info.ToString()))
 
-	priceData, err := helper.ModelPriceHelper(c, info, 0, int(request.MaxTokens))
+	priceData, err := helper.ModelPriceHelper(c, info, 0, request.GetTokenCountMeta())
 	if err != nil {
 		return testResult{
 			context:     c,
@@ -176,6 +224,22 @@ func testChannel(channel *model.Channel, testModel string) testResult {
 		}
 		// 调用专门用于 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
+			}
+		}
+		imageRequest := dto.ImageRequest{
+			Prompt: prompt,
+			Model:  request.Model,
+			N:      uint(request.N),
+			Size:   request.Size,
+		}
+		// 调用专门用于图像生成的转换函数
+		convertedRequest, err = adaptor.ConvertImageRequest(c, info, imageRequest)
 	} else {
 		// 对其他所有请求类型(如 Chat),保持原有逻辑
 		convertedRequest, err = adaptor.ConvertOpenAIRequest(c, info, request)
@@ -203,18 +267,18 @@ func testChannel(channel *model.Channel, testModel string) testResult {
 		return testResult{
 			context:     c,
 			localErr:    err,
-			newAPIError: types.NewError(err, types.ErrorCodeDoRequestFailed),
+			newAPIError: types.NewOpenAIError(err, types.ErrorCodeDoRequestFailed, http.StatusInternalServerError),
 		}
 	}
 	var httpResp *http.Response
 	if resp != nil {
 		httpResp = resp.(*http.Response)
 		if httpResp.StatusCode != http.StatusOK {
-			err := service.RelayErrorHandler(httpResp, true)
+			err := service.RelayErrorHandler(c.Request.Context(), httpResp, true)
 			return testResult{
 				context:     c,
 				localErr:    err,
-				newAPIError: types.NewError(err, types.ErrorCodeBadResponse),
+				newAPIError: types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError),
 			}
 		}
 	}
@@ -230,7 +294,7 @@ func testChannel(channel *model.Channel, testModel string) testResult {
 		return testResult{
 			context:     c,
 			localErr:    errors.New("usage is nil"),
-			newAPIError: types.NewError(errors.New("usage is nil"), types.ErrorCodeBadResponseBody),
+			newAPIError: types.NewOpenAIError(errors.New("usage is nil"), types.ErrorCodeBadResponseBody, http.StatusInternalServerError),
 		}
 	}
 	usage := usageA.(*dto.Usage)
@@ -240,7 +304,7 @@ func testChannel(channel *model.Channel, testModel string) testResult {
 		return testResult{
 			context:     c,
 			localErr:    err,
-			newAPIError: types.NewError(err, types.ErrorCodeReadResponseBodyFailed),
+			newAPIError: types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError),
 		}
 	}
 	info.PromptTokens = usage.PromptTokens
@@ -269,7 +333,7 @@ func testChannel(channel *model.Channel, testModel string) testResult {
 		Quota:            quota,
 		Content:          "模型测试",
 		UseTimeSeconds:   int(consumedTime),
-		IsStream:         false,
+		IsStream:         info.IsStream,
 		Group:            info.UsingGroup,
 		Other:            other,
 	})
@@ -326,8 +390,11 @@ func TestChannel(c *gin.Context) {
 	}
 	channel, err := model.CacheGetChannel(channelId)
 	if err != nil {
-		common.ApiError(c, err)
-		return
+		channel, err = model.GetChannelById(channelId, true)
+		if err != nil {
+			common.ApiError(c, err)
+			return
+		}
 	}
 	//defer func() {
 	//	if channel.ChannelInfo.IsMultiKey {
@@ -411,14 +478,14 @@ 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))
-					newAPIError = types.NewError(err, types.ErrorCodeChannelResponseTimeExceeded)
+					newAPIError = types.NewOpenAIError(err, types.ErrorCodeChannelResponseTimeExceeded, http.StatusRequestTimeout)
 					shouldBanChannel = true
 				}
 			}
 
 			// disable channel
 			if isChannelEnabled && shouldBanChannel && channel.GetAutoBan() {
-				go processChannelError(result.context, *types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, common.GetContextKeyString(result.context, constant.ContextKeyChannelKey), channel.GetAutoBan()), newAPIError)
+				processChannelError(result.context, *types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, common.GetContextKeyString(result.context, constant.ContextKeyChannelKey), channel.GetAutoBan()), newAPIError)
 			}
 
 			// enable channel
@@ -450,15 +517,26 @@ func TestAllChannels(c *gin.Context) {
 	return
 }
 
-func AutomaticallyTestChannels(frequency int) {
-	if frequency <= 0 {
-		common.SysLog("CHANNEL_TEST_FREQUENCY is not set or invalid, skipping automatic channel test")
-		return
-	}
-	for {
-		time.Sleep(time.Duration(frequency) * time.Minute)
-		common.SysLog("testing all channels")
-		_ = testAllChannels(false)
-		common.SysLog("channel test finished")
-	}
+var autoTestChannelsOnce sync.Once
+
+func AutomaticallyTestChannels() {
+	autoTestChannelsOnce.Do(func() {
+		for {
+			if !operation_setting.GetMonitorSetting().AutoTestChannelEnabled {
+				time.Sleep(10 * time.Minute)
+				continue
+			}
+			frequency := operation_setting.GetMonitorSetting().AutoTestChannelMinutes
+			common.SysLog(fmt.Sprintf("automatically test channels with interval %d minutes", frequency))
+			for {
+				time.Sleep(time.Duration(frequency) * time.Minute)
+				common.SysLog("automatically testing all channels")
+				_ = testAllChannels(false)
+				common.SysLog("automatically channel test finished")
+				if !operation_setting.GetMonitorSetting().AutoTestChannelEnabled {
+					break
+				}
+			}
+		}
+	})
 }

+ 684 - 9
controller/channel.go

@@ -6,6 +6,7 @@ import (
 	"net/http"
 	"one-api/common"
 	"one-api/constant"
+	"one-api/dto"
 	"one-api/model"
 	"strconv"
 	"strings"
@@ -52,6 +53,13 @@ func parseStatusFilter(statusParam string) int {
 	}
 }
 
+func clearChannelInfo(channel *model.Channel) {
+	if channel.ChannelInfo.IsMultiKey {
+		channel.ChannelInfo.MultiKeyDisabledReason = nil
+		channel.ChannelInfo.MultiKeyDisabledTime = nil
+	}
+}
+
 func GetAllChannels(c *gin.Context) {
 	pageInfo := common.GetPageQuery(c)
 	channelData := make([]*model.Channel, 0)
@@ -126,6 +134,10 @@ func GetAllChannels(c *gin.Context) {
 		}
 	}
 
+	for _, datum := range channelData {
+		clearChannelInfo(datum)
+	}
+
 	countQuery := model.DB.Model(&model.Channel{})
 	if statusFilter == common.ChannelStatusEnabled {
 		countQuery = countQuery.Where("status = ?", common.ChannelStatusEnabled)
@@ -168,14 +180,28 @@ func FetchUpstreamModels(c *gin.Context) {
 	if channel.GetBaseURL() != "" {
 		baseURL = channel.GetBaseURL()
 	}
-	url := fmt.Sprintf("%s/v1/models", baseURL)
+
+	var url string
 	switch channel.Type {
 	case constant.ChannelTypeGemini:
-		url = fmt.Sprintf("%s/v1beta/openai/models", baseURL)
+		// curl https://example.com/v1beta/models?key=$GEMINI_API_KEY
+		url = fmt.Sprintf("%s/v1beta/openai/models", baseURL) // Remove key in url since we need to use AuthHeader
 	case constant.ChannelTypeAli:
 		url = fmt.Sprintf("%s/compatible-mode/v1/models", baseURL)
+	case constant.ChannelTypeZhipu_v4:
+		url = fmt.Sprintf("%s/api/paas/v4/models", baseURL)
+	default:
+		url = fmt.Sprintf("%s/v1/models", baseURL)
+	}
+
+	// 获取响应体 - 根据渠道类型决定是否添加 AuthHeader
+	var body []byte
+	key := strings.Split(channel.Key, "\n")[0]
+	if channel.Type == constant.ChannelTypeGemini {
+		body, err = GetResponseBody("GET", url, channel, GetAuthHeader(key)) // Use AuthHeader since Gemini now forces it
+	} else {
+		body, err = GetResponseBody("GET", url, channel, GetAuthHeader(key))
 	}
-	body, err := GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key))
 	if err != nil {
 		common.ApiError(c, err)
 		return
@@ -319,6 +345,10 @@ func SearchChannels(c *gin.Context) {
 
 	pagedData := channelData[startIdx:endIdx]
 
+	for _, datum := range pagedData {
+		clearChannelInfo(datum)
+	}
+
 	c.JSON(http.StatusOK, gin.H{
 		"success": true,
 		"message": "",
@@ -342,6 +372,9 @@ func GetChannel(c *gin.Context) {
 		common.ApiError(c, err)
 		return
 	}
+	if channel != nil {
+		clearChannelInfo(channel)
+	}
 	c.JSON(http.StatusOK, gin.H{
 		"success": true,
 		"message": "",
@@ -350,6 +383,85 @@ func GetChannel(c *gin.Context) {
 	return
 }
 
+// GetChannelKey 验证2FA后获取渠道密钥
+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 {
+		common.ApiError(c, fmt.Errorf("渠道ID格式错误: %v", err))
+		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 {
+		common.ApiError(c, fmt.Errorf("获取渠道信息失败: %v", err))
+		return
+	}
+
+	if channel == nil {
+		common.ApiError(c, fmt.Errorf("渠道不存在"))
+		return
+	}
+
+	// 记录操作日志
+	model.RecordLog(userId, model.LogTypeSystem, fmt.Sprintf("查看渠道密钥信息 (渠道ID: %d)", channelId))
+
+	// 统一的成功响应格式
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "验证成功",
+		"data": map[string]interface{}{
+			"key": channel.Key,
+		},
+	})
+}
+
+// validateTwoFactorAuth 统一的2FA验证函数
+func validateTwoFactorAuth(twoFA *model.TwoFA, code string) bool {
+	// 尝试验证TOTP
+	if cleanCode, err := common.ValidateNumericCode(code); err == nil {
+		if isValid, _ := twoFA.ValidateTOTPAndUpdateUsage(cleanCode); isValid {
+			return true
+		}
+	}
+
+	// 尝试验证备用码
+	if isValid, err := twoFA.ValidateBackupCodeAndUpdateUsage(code); err == nil && isValid {
+		return true
+	}
+
+	return false
+}
+
 // validateChannel 通用的渠道校验函数
 func validateChannel(channel *model.Channel, isAdd bool) error {
 	// 校验 channel settings
@@ -391,9 +503,10 @@ func validateChannel(channel *model.Channel, isAdd bool) error {
 }
 
 type AddChannelRequest struct {
-	Mode         string                `json:"mode"`
-	MultiKeyMode constant.MultiKeyMode `json:"multi_key_mode"`
-	Channel      *model.Channel        `json:"channel"`
+	Mode                      string                `json:"mode"`
+	MultiKeyMode              constant.MultiKeyMode `json:"multi_key_mode"`
+	BatchAddSetKeyPrefix2Name bool                  `json:"batch_add_set_key_prefix_2_name"`
+	Channel                   *model.Channel        `json:"channel"`
 }
 
 func getVertexArrayKeys(keys string) ([]string, error) {
@@ -451,7 +564,7 @@ func AddChannel(c *gin.Context) {
 	case "multi_to_single":
 		addChannelRequest.Channel.ChannelInfo.IsMultiKey = true
 		addChannelRequest.Channel.ChannelInfo.MultiKeyMode = addChannelRequest.MultiKeyMode
-		if addChannelRequest.Channel.Type == constant.ChannelTypeVertexAi {
+		if addChannelRequest.Channel.Type == constant.ChannelTypeVertexAi && addChannelRequest.Channel.GetOtherSettings().VertexKeyType != dto.VertexKeyTypeAPIKey {
 			array, err := getVertexArrayKeys(addChannelRequest.Channel.Key)
 			if err != nil {
 				c.JSON(http.StatusOK, gin.H{
@@ -476,7 +589,7 @@ func AddChannel(c *gin.Context) {
 		}
 		keys = []string{addChannelRequest.Channel.Key}
 	case "batch":
-		if addChannelRequest.Channel.Type == constant.ChannelTypeVertexAi {
+		if addChannelRequest.Channel.Type == constant.ChannelTypeVertexAi && addChannelRequest.Channel.GetOtherSettings().VertexKeyType != dto.VertexKeyTypeAPIKey {
 			// multi json
 			keys, err = getVertexArrayKeys(addChannelRequest.Channel.Key)
 			if err != nil {
@@ -506,6 +619,13 @@ func AddChannel(c *gin.Context) {
 		}
 		localChannel := addChannelRequest.Channel
 		localChannel.Key = key
+		if addChannelRequest.BatchAddSetKeyPrefix2Name && len(keys) > 1 {
+			keyPrefix := localChannel.Key
+			if len(localChannel.Key) > 8 {
+				keyPrefix = localChannel.Key[:8]
+			}
+			localChannel.Name = fmt.Sprintf("%s %s", localChannel.Name, keyPrefix)
+		}
 		channels = append(channels, *localChannel)
 	}
 	err = model.BatchInsertChannels(channels)
@@ -669,6 +789,7 @@ func DeleteChannelBatch(c *gin.Context) {
 type PatchChannel struct {
 	model.Channel
 	MultiKeyMode *string `json:"multi_key_mode"`
+	KeyMode      *string `json:"key_mode"` // 多key模式下密钥覆盖或者追加
 }
 
 func UpdateChannel(c *gin.Context) {
@@ -688,7 +809,7 @@ func UpdateChannel(c *gin.Context) {
 		return
 	}
 	// Preserve existing ChannelInfo to ensure multi-key channels keep correct state even if the client does not send ChannelInfo in the request.
-	originChannel, err := model.GetChannelById(channel.Id, false)
+	originChannel, err := model.GetChannelById(channel.Id, true)
 	if err != nil {
 		c.JSON(http.StatusOK, gin.H{
 			"success": false,
@@ -704,6 +825,69 @@ func UpdateChannel(c *gin.Context) {
 	if channel.MultiKeyMode != nil && *channel.MultiKeyMode != "" {
 		channel.ChannelInfo.MultiKeyMode = constant.MultiKeyMode(*channel.MultiKeyMode)
 	}
+
+	// 处理多key模式下的密钥追加/覆盖逻辑
+	if channel.KeyMode != nil && channel.ChannelInfo.IsMultiKey {
+		switch *channel.KeyMode {
+		case "append":
+			// 追加模式:将新密钥添加到现有密钥列表
+			if originChannel.Key != "" {
+				var newKeys []string
+				var existingKeys []string
+
+				// 解析现有密钥
+				if strings.HasPrefix(strings.TrimSpace(originChannel.Key), "[") {
+					// JSON数组格式
+					var arr []json.RawMessage
+					if err := json.Unmarshal([]byte(strings.TrimSpace(originChannel.Key)), &arr); err == nil {
+						existingKeys = make([]string, len(arr))
+						for i, v := range arr {
+							existingKeys[i] = string(v)
+						}
+					}
+				} else {
+					// 换行分隔格式
+					existingKeys = strings.Split(strings.Trim(originChannel.Key, "\n"), "\n")
+				}
+
+				// 处理 Vertex AI 的特殊情况
+				if channel.Type == constant.ChannelTypeVertexAi && channel.GetOtherSettings().VertexKeyType != dto.VertexKeyTypeAPIKey {
+					// 尝试解析新密钥为JSON数组
+					if strings.HasPrefix(strings.TrimSpace(channel.Key), "[") {
+						array, err := getVertexArrayKeys(channel.Key)
+						if err != nil {
+							c.JSON(http.StatusOK, gin.H{
+								"success": false,
+								"message": "追加密钥解析失败: " + err.Error(),
+							})
+							return
+						}
+						newKeys = array
+					} else {
+						// 单个JSON密钥
+						newKeys = []string{channel.Key}
+					}
+					// 合并密钥
+					allKeys := append(existingKeys, newKeys...)
+					channel.Key = strings.Join(allKeys, "\n")
+				} else {
+					// 普通渠道的处理
+					inputKeys := strings.Split(channel.Key, "\n")
+					for _, key := range inputKeys {
+						key = strings.TrimSpace(key)
+						if key != "" {
+							newKeys = append(newKeys, key)
+						}
+					}
+					// 合并密钥
+					allKeys := append(existingKeys, newKeys...)
+					channel.Key = strings.Join(allKeys, "\n")
+				}
+			}
+		case "replace":
+			// 覆盖模式:直接使用新密钥(默认行为,不需要特殊处理)
+		}
+	}
 	err = channel.Update()
 	if err != nil {
 		common.ApiError(c, err)
@@ -711,6 +895,7 @@ func UpdateChannel(c *gin.Context) {
 	}
 	model.InitChannelCache()
 	channel.Key = ""
+	clearChannelInfo(&channel.Channel)
 	c.JSON(http.StatusOK, gin.H{
 		"success": true,
 		"message": "",
@@ -914,3 +1099,493 @@ func CopyChannel(c *gin.Context) {
 	// success
 	c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": gin.H{"id": clone.Id}})
 }
+
+// MultiKeyManageRequest represents the request for multi-key management operations
+type MultiKeyManageRequest struct {
+	ChannelId int    `json:"channel_id"`
+	Action    string `json:"action"`              // "disable_key", "enable_key", "delete_key", "delete_disabled_keys", "get_key_status"
+	KeyIndex  *int   `json:"key_index,omitempty"` // for disable_key, enable_key, and delete_key actions
+	Page      int    `json:"page,omitempty"`      // for get_key_status pagination
+	PageSize  int    `json:"page_size,omitempty"` // for get_key_status pagination
+	Status    *int   `json:"status,omitempty"`    // for get_key_status filtering: 1=enabled, 2=manual_disabled, 3=auto_disabled, nil=all
+}
+
+// MultiKeyStatusResponse represents the response for key status query
+type MultiKeyStatusResponse struct {
+	Keys       []KeyStatus `json:"keys"`
+	Total      int         `json:"total"`
+	Page       int         `json:"page"`
+	PageSize   int         `json:"page_size"`
+	TotalPages int         `json:"total_pages"`
+	// Statistics
+	EnabledCount        int `json:"enabled_count"`
+	ManualDisabledCount int `json:"manual_disabled_count"`
+	AutoDisabledCount   int `json:"auto_disabled_count"`
+}
+
+type KeyStatus struct {
+	Index        int    `json:"index"`
+	Status       int    `json:"status"` // 1: enabled, 2: disabled
+	DisabledTime int64  `json:"disabled_time,omitempty"`
+	Reason       string `json:"reason,omitempty"`
+	KeyPreview   string `json:"key_preview"` // first 10 chars of key for identification
+}
+
+// ManageMultiKeys handles multi-key management operations
+func ManageMultiKeys(c *gin.Context) {
+	request := MultiKeyManageRequest{}
+	err := c.ShouldBindJSON(&request)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	channel, err := model.GetChannelById(request.ChannelId, true)
+	if err != nil {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "渠道不存在",
+		})
+		return
+	}
+
+	if !channel.ChannelInfo.IsMultiKey {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "该渠道不是多密钥模式",
+		})
+		return
+	}
+
+	lock := model.GetChannelPollingLock(channel.Id)
+	lock.Lock()
+	defer lock.Unlock()
+
+	switch request.Action {
+	case "get_key_status":
+		keys := channel.GetKeys()
+
+		// Default pagination parameters
+		page := request.Page
+		pageSize := request.PageSize
+		if page <= 0 {
+			page = 1
+		}
+		if pageSize <= 0 {
+			pageSize = 50 // Default page size
+		}
+
+		// Statistics for all keys (unchanged by filtering)
+		var enabledCount, manualDisabledCount, autoDisabledCount int
+
+		// Build all key status data first
+		var allKeyStatusList []KeyStatus
+		for i, key := range keys {
+			status := 1 // default enabled
+			var disabledTime int64
+			var reason string
+
+			if channel.ChannelInfo.MultiKeyStatusList != nil {
+				if s, exists := channel.ChannelInfo.MultiKeyStatusList[i]; exists {
+					status = s
+				}
+			}
+
+			// Count for statistics (all keys)
+			switch status {
+			case 1:
+				enabledCount++
+			case 2:
+				manualDisabledCount++
+			case 3:
+				autoDisabledCount++
+			}
+
+			if status != 1 {
+				if channel.ChannelInfo.MultiKeyDisabledTime != nil {
+					disabledTime = channel.ChannelInfo.MultiKeyDisabledTime[i]
+				}
+				if channel.ChannelInfo.MultiKeyDisabledReason != nil {
+					reason = channel.ChannelInfo.MultiKeyDisabledReason[i]
+				}
+			}
+
+			// Create key preview (first 10 chars)
+			keyPreview := key
+			if len(key) > 10 {
+				keyPreview = key[:10] + "..."
+			}
+
+			allKeyStatusList = append(allKeyStatusList, KeyStatus{
+				Index:        i,
+				Status:       status,
+				DisabledTime: disabledTime,
+				Reason:       reason,
+				KeyPreview:   keyPreview,
+			})
+		}
+
+		// Apply status filter if specified
+		var filteredKeyStatusList []KeyStatus
+		if request.Status != nil {
+			for _, keyStatus := range allKeyStatusList {
+				if keyStatus.Status == *request.Status {
+					filteredKeyStatusList = append(filteredKeyStatusList, keyStatus)
+				}
+			}
+		} else {
+			filteredKeyStatusList = allKeyStatusList
+		}
+
+		// Calculate pagination based on filtered results
+		filteredTotal := len(filteredKeyStatusList)
+		totalPages := (filteredTotal + pageSize - 1) / pageSize
+		if totalPages == 0 {
+			totalPages = 1
+		}
+		if page > totalPages {
+			page = totalPages
+		}
+
+		// Calculate range for current page
+		start := (page - 1) * pageSize
+		end := start + pageSize
+		if end > filteredTotal {
+			end = filteredTotal
+		}
+
+		// Get the page data
+		var pageKeyStatusList []KeyStatus
+		if start < filteredTotal {
+			pageKeyStatusList = filteredKeyStatusList[start:end]
+		}
+
+		c.JSON(http.StatusOK, gin.H{
+			"success": true,
+			"message": "",
+			"data": MultiKeyStatusResponse{
+				Keys:                pageKeyStatusList,
+				Total:               filteredTotal, // Total of filtered results
+				Page:                page,
+				PageSize:            pageSize,
+				TotalPages:          totalPages,
+				EnabledCount:        enabledCount,        // Overall statistics
+				ManualDisabledCount: manualDisabledCount, // Overall statistics
+				AutoDisabledCount:   autoDisabledCount,   // Overall statistics
+			},
+		})
+		return
+
+	case "disable_key":
+		if request.KeyIndex == nil {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": "未指定要禁用的密钥索引",
+			})
+			return
+		}
+
+		keyIndex := *request.KeyIndex
+		if keyIndex < 0 || keyIndex >= channel.ChannelInfo.MultiKeySize {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": "密钥索引超出范围",
+			})
+			return
+		}
+
+		if channel.ChannelInfo.MultiKeyStatusList == nil {
+			channel.ChannelInfo.MultiKeyStatusList = make(map[int]int)
+		}
+		if channel.ChannelInfo.MultiKeyDisabledTime == nil {
+			channel.ChannelInfo.MultiKeyDisabledTime = make(map[int]int64)
+		}
+		if channel.ChannelInfo.MultiKeyDisabledReason == nil {
+			channel.ChannelInfo.MultiKeyDisabledReason = make(map[int]string)
+		}
+
+		channel.ChannelInfo.MultiKeyStatusList[keyIndex] = 2 // disabled
+
+		err = channel.Update()
+		if err != nil {
+			common.ApiError(c, err)
+			return
+		}
+
+		model.InitChannelCache()
+		c.JSON(http.StatusOK, gin.H{
+			"success": true,
+			"message": "密钥已禁用",
+		})
+		return
+
+	case "enable_key":
+		if request.KeyIndex == nil {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": "未指定要启用的密钥索引",
+			})
+			return
+		}
+
+		keyIndex := *request.KeyIndex
+		if keyIndex < 0 || keyIndex >= channel.ChannelInfo.MultiKeySize {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": "密钥索引超出范围",
+			})
+			return
+		}
+
+		// 从状态列表中删除该密钥的记录,使其回到默认启用状态
+		if channel.ChannelInfo.MultiKeyStatusList != nil {
+			delete(channel.ChannelInfo.MultiKeyStatusList, keyIndex)
+		}
+		if channel.ChannelInfo.MultiKeyDisabledTime != nil {
+			delete(channel.ChannelInfo.MultiKeyDisabledTime, keyIndex)
+		}
+		if channel.ChannelInfo.MultiKeyDisabledReason != nil {
+			delete(channel.ChannelInfo.MultiKeyDisabledReason, keyIndex)
+		}
+
+		err = channel.Update()
+		if err != nil {
+			common.ApiError(c, err)
+			return
+		}
+
+		model.InitChannelCache()
+		c.JSON(http.StatusOK, gin.H{
+			"success": true,
+			"message": "密钥已启用",
+		})
+		return
+
+	case "enable_all_keys":
+		// 清空所有禁用状态,使所有密钥回到默认启用状态
+		var enabledCount int
+		if channel.ChannelInfo.MultiKeyStatusList != nil {
+			enabledCount = len(channel.ChannelInfo.MultiKeyStatusList)
+		}
+
+		channel.ChannelInfo.MultiKeyStatusList = make(map[int]int)
+		channel.ChannelInfo.MultiKeyDisabledTime = make(map[int]int64)
+		channel.ChannelInfo.MultiKeyDisabledReason = make(map[int]string)
+
+		err = channel.Update()
+		if err != nil {
+			common.ApiError(c, err)
+			return
+		}
+
+		model.InitChannelCache()
+		c.JSON(http.StatusOK, gin.H{
+			"success": true,
+			"message": fmt.Sprintf("已启用 %d 个密钥", enabledCount),
+		})
+		return
+
+	case "disable_all_keys":
+		// 禁用所有启用的密钥
+		if channel.ChannelInfo.MultiKeyStatusList == nil {
+			channel.ChannelInfo.MultiKeyStatusList = make(map[int]int)
+		}
+		if channel.ChannelInfo.MultiKeyDisabledTime == nil {
+			channel.ChannelInfo.MultiKeyDisabledTime = make(map[int]int64)
+		}
+		if channel.ChannelInfo.MultiKeyDisabledReason == nil {
+			channel.ChannelInfo.MultiKeyDisabledReason = make(map[int]string)
+		}
+
+		var disabledCount int
+		for i := 0; i < channel.ChannelInfo.MultiKeySize; i++ {
+			status := 1 // default enabled
+			if s, exists := channel.ChannelInfo.MultiKeyStatusList[i]; exists {
+				status = s
+			}
+
+			// 只禁用当前启用的密钥
+			if status == 1 {
+				channel.ChannelInfo.MultiKeyStatusList[i] = 2 // disabled
+				disabledCount++
+			}
+		}
+
+		if disabledCount == 0 {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": "没有可禁用的密钥",
+			})
+			return
+		}
+
+		err = channel.Update()
+		if err != nil {
+			common.ApiError(c, err)
+			return
+		}
+
+		model.InitChannelCache()
+		c.JSON(http.StatusOK, gin.H{
+			"success": true,
+			"message": fmt.Sprintf("已禁用 %d 个密钥", disabledCount),
+		})
+		return
+
+	case "delete_key":
+		if request.KeyIndex == nil {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": "未指定要删除的密钥索引",
+			})
+			return
+		}
+
+		keyIndex := *request.KeyIndex
+		if keyIndex < 0 || keyIndex >= channel.ChannelInfo.MultiKeySize {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": "密钥索引超出范围",
+			})
+			return
+		}
+
+		keys := channel.GetKeys()
+		var remainingKeys []string
+		var newStatusList = make(map[int]int)
+		var newDisabledTime = make(map[int]int64)
+		var newDisabledReason = make(map[int]string)
+
+		newIndex := 0
+		for i, key := range keys {
+			// 跳过要删除的密钥
+			if i == keyIndex {
+				continue
+			}
+
+			remainingKeys = append(remainingKeys, key)
+
+			// 保留其他密钥的状态信息,重新索引
+			if channel.ChannelInfo.MultiKeyStatusList != nil {
+				if status, exists := channel.ChannelInfo.MultiKeyStatusList[i]; exists && status != 1 {
+					newStatusList[newIndex] = status
+				}
+			}
+			if channel.ChannelInfo.MultiKeyDisabledTime != nil {
+				if t, exists := channel.ChannelInfo.MultiKeyDisabledTime[i]; exists {
+					newDisabledTime[newIndex] = t
+				}
+			}
+			if channel.ChannelInfo.MultiKeyDisabledReason != nil {
+				if r, exists := channel.ChannelInfo.MultiKeyDisabledReason[i]; exists {
+					newDisabledReason[newIndex] = r
+				}
+			}
+			newIndex++
+		}
+
+		if len(remainingKeys) == 0 {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": "不能删除最后一个密钥",
+			})
+			return
+		}
+
+		// Update channel with remaining keys
+		channel.Key = strings.Join(remainingKeys, "\n")
+		channel.ChannelInfo.MultiKeySize = len(remainingKeys)
+		channel.ChannelInfo.MultiKeyStatusList = newStatusList
+		channel.ChannelInfo.MultiKeyDisabledTime = newDisabledTime
+		channel.ChannelInfo.MultiKeyDisabledReason = newDisabledReason
+
+		err = channel.Update()
+		if err != nil {
+			common.ApiError(c, err)
+			return
+		}
+
+		model.InitChannelCache()
+		c.JSON(http.StatusOK, gin.H{
+			"success": true,
+			"message": "密钥已删除",
+		})
+		return
+
+	case "delete_disabled_keys":
+		keys := channel.GetKeys()
+		var remainingKeys []string
+		var deletedCount int
+		var newStatusList = make(map[int]int)
+		var newDisabledTime = make(map[int]int64)
+		var newDisabledReason = make(map[int]string)
+
+		newIndex := 0
+		for i, key := range keys {
+			status := 1 // default enabled
+			if channel.ChannelInfo.MultiKeyStatusList != nil {
+				if s, exists := channel.ChannelInfo.MultiKeyStatusList[i]; exists {
+					status = s
+				}
+			}
+
+			// 只删除自动禁用(status == 3)的密钥,保留启用(status == 1)和手动禁用(status == 2)的密钥
+			if status == 3 {
+				deletedCount++
+			} else {
+				remainingKeys = append(remainingKeys, key)
+				// 保留非自动禁用密钥的状态信息,重新索引
+				if status != 1 {
+					newStatusList[newIndex] = status
+					if channel.ChannelInfo.MultiKeyDisabledTime != nil {
+						if t, exists := channel.ChannelInfo.MultiKeyDisabledTime[i]; exists {
+							newDisabledTime[newIndex] = t
+						}
+					}
+					if channel.ChannelInfo.MultiKeyDisabledReason != nil {
+						if r, exists := channel.ChannelInfo.MultiKeyDisabledReason[i]; exists {
+							newDisabledReason[newIndex] = r
+						}
+					}
+				}
+				newIndex++
+			}
+		}
+
+		if deletedCount == 0 {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": "没有需要删除的自动禁用密钥",
+			})
+			return
+		}
+
+		// Update channel with remaining keys
+		channel.Key = strings.Join(remainingKeys, "\n")
+		channel.ChannelInfo.MultiKeySize = len(remainingKeys)
+		channel.ChannelInfo.MultiKeyStatusList = newStatusList
+		channel.ChannelInfo.MultiKeyDisabledTime = newDisabledTime
+		channel.ChannelInfo.MultiKeyDisabledReason = newDisabledReason
+
+		err = channel.Update()
+		if err != nil {
+			common.ApiError(c, err)
+			return
+		}
+
+		model.InitChannelCache()
+		c.JSON(http.StatusOK, gin.H{
+			"success": true,
+			"message": fmt.Sprintf("已删除 %d 个自动禁用的密钥", deletedCount),
+			"data":    deletedCount,
+		})
+		return
+
+	default:
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "不支持的操作",
+		})
+		return
+	}
+}

+ 92 - 91
controller/console_migrate.go

@@ -3,101 +3,102 @@
 package controller
 
 import (
-    "encoding/json"
-    "net/http"
-    "one-api/common"
-    "one-api/model"
-    "github.com/gin-gonic/gin"
+	"encoding/json"
+	"net/http"
+	"one-api/common"
+	"one-api/model"
+
+	"github.com/gin-gonic/gin"
 )
 
 // MigrateConsoleSetting 迁移旧的控制台相关配置到 console_setting.*
 func MigrateConsoleSetting(c *gin.Context) {
-    // 读取全部 option
-    opts, err := model.AllOption()
-    if err != nil {
-        c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
-        return
-    }
-    // 建立 map
-    valMap := map[string]string{}
-    for _, o := range opts {
-        valMap[o.Key] = o.Value
-    }
+	// 读取全部 option
+	opts, err := model.AllOption()
+	if err != nil {
+		c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
+		return
+	}
+	// 建立 map
+	valMap := map[string]string{}
+	for _, o := range opts {
+		valMap[o.Key] = o.Value
+	}
 
-    // 处理 APIInfo
-    if v := valMap["ApiInfo"]; v != "" {
-        var arr []map[string]interface{}
-        if err := json.Unmarshal([]byte(v), &arr); err == nil {
-            if len(arr) > 50 {
-                arr = arr[:50]
-            }
-            bytes, _ := json.Marshal(arr)
-            model.UpdateOption("console_setting.api_info", string(bytes))
-        }
-        model.UpdateOption("ApiInfo", "")
-    }
-    // Announcements 直接搬
-    if v := valMap["Announcements"]; v != "" {
-        model.UpdateOption("console_setting.announcements", v)
-        model.UpdateOption("Announcements", "")
-    }
-    // FAQ 转换
-    if v := valMap["FAQ"]; v != "" {
-        var arr []map[string]interface{}
-        if err := json.Unmarshal([]byte(v), &arr); err == nil {
-            out := []map[string]interface{}{}
-            for _, item := range arr {
-                q, _ := item["question"].(string)
-                if q == "" {
-                    q, _ = item["title"].(string)
-                }
-                a, _ := item["answer"].(string)
-                if a == "" {
-                    a, _ = item["content"].(string)
-                }
-                if q != "" && a != "" {
-                    out = append(out, map[string]interface{}{"question": q, "answer": a})
-                }
-            }
-            if len(out) > 50 {
-                out = out[:50]
-            }
-            bytes, _ := json.Marshal(out)
-            model.UpdateOption("console_setting.faq", string(bytes))
-        }
-        model.UpdateOption("FAQ", "")
-    }
-    // Uptime Kuma 迁移到新的 groups 结构(console_setting.uptime_kuma_groups)
-    url := valMap["UptimeKumaUrl"]
-    slug := valMap["UptimeKumaSlug"]
-    if url != "" && slug != "" {
-        // 仅当同时存在 URL 与 Slug 时才进行迁移
-        groups := []map[string]interface{}{
-            {
-                "id":           1,
-                "categoryName": "old",
-                "url":          url,
-                "slug":         slug,
-                "description":  "",
-            },
-        }
-        bytes, _ := json.Marshal(groups)
-        model.UpdateOption("console_setting.uptime_kuma_groups", string(bytes))
-    }
-    // 清空旧键内容
-    if url != "" {
-        model.UpdateOption("UptimeKumaUrl", "")
-    }
-    if slug != "" {
-        model.UpdateOption("UptimeKumaSlug", "")
-    }
+	// 处理 APIInfo
+	if v := valMap["ApiInfo"]; v != "" {
+		var arr []map[string]interface{}
+		if err := json.Unmarshal([]byte(v), &arr); err == nil {
+			if len(arr) > 50 {
+				arr = arr[:50]
+			}
+			bytes, _ := json.Marshal(arr)
+			model.UpdateOption("console_setting.api_info", string(bytes))
+		}
+		model.UpdateOption("ApiInfo", "")
+	}
+	// Announcements 直接搬
+	if v := valMap["Announcements"]; v != "" {
+		model.UpdateOption("console_setting.announcements", v)
+		model.UpdateOption("Announcements", "")
+	}
+	// FAQ 转换
+	if v := valMap["FAQ"]; v != "" {
+		var arr []map[string]interface{}
+		if err := json.Unmarshal([]byte(v), &arr); err == nil {
+			out := []map[string]interface{}{}
+			for _, item := range arr {
+				q, _ := item["question"].(string)
+				if q == "" {
+					q, _ = item["title"].(string)
+				}
+				a, _ := item["answer"].(string)
+				if a == "" {
+					a, _ = item["content"].(string)
+				}
+				if q != "" && a != "" {
+					out = append(out, map[string]interface{}{"question": q, "answer": a})
+				}
+			}
+			if len(out) > 50 {
+				out = out[:50]
+			}
+			bytes, _ := json.Marshal(out)
+			model.UpdateOption("console_setting.faq", string(bytes))
+		}
+		model.UpdateOption("FAQ", "")
+	}
+	// Uptime Kuma 迁移到新的 groups 结构(console_setting.uptime_kuma_groups)
+	url := valMap["UptimeKumaUrl"]
+	slug := valMap["UptimeKumaSlug"]
+	if url != "" && slug != "" {
+		// 仅当同时存在 URL 与 Slug 时才进行迁移
+		groups := []map[string]interface{}{
+			{
+				"id":           1,
+				"categoryName": "old",
+				"url":          url,
+				"slug":         slug,
+				"description":  "",
+			},
+		}
+		bytes, _ := json.Marshal(groups)
+		model.UpdateOption("console_setting.uptime_kuma_groups", string(bytes))
+	}
+	// 清空旧键内容
+	if url != "" {
+		model.UpdateOption("UptimeKumaUrl", "")
+	}
+	if slug != "" {
+		model.UpdateOption("UptimeKumaSlug", "")
+	}
 
-    // 删除旧键记录
-    oldKeys := []string{"ApiInfo", "Announcements", "FAQ", "UptimeKumaUrl", "UptimeKumaSlug"}
-    model.DB.Where("key IN ?", oldKeys).Delete(&model.Option{})
+	// 删除旧键记录
+	oldKeys := []string{"ApiInfo", "Announcements", "FAQ", "UptimeKumaUrl", "UptimeKumaSlug"}
+	model.DB.Where("key IN ?", oldKeys).Delete(&model.Option{})
 
-    // 重新加载 OptionMap
-    model.InitOptionMap()
-    common.SysLog("console setting migrated")
-    c.JSON(http.StatusOK, gin.H{"success": true, "message": "migrated"})
-} 
+	// 重新加载 OptionMap
+	model.InitOptionMap()
+	common.SysLog("console setting migrated")
+	c.JSON(http.StatusOK, gin.H{"success": true, "message": "migrated"})
+}

+ 21 - 13
controller/linuxdo.go

@@ -220,21 +220,29 @@ func LinuxdoOAuth(c *gin.Context) {
 		}
 	} else {
 		if common.RegisterEnabled {
-			user.Username = "linuxdo_" + strconv.Itoa(model.GetMaxUserId()+1)
-			user.DisplayName = linuxdoUser.Name
-			user.Role = common.RoleCommonUser
-			user.Status = common.UserStatusEnabled
-
-			affCode := session.Get("aff")
-			inviterId := 0
-			if affCode != nil {
-				inviterId, _ = model.GetUserIdByAffCode(affCode.(string))
-			}
-
-			if err := user.Insert(inviterId); err != nil {
+			if linuxdoUser.TrustLevel >= common.LinuxDOMinimumTrustLevel {
+				user.Username = "linuxdo_" + strconv.Itoa(model.GetMaxUserId()+1)
+				user.DisplayName = linuxdoUser.Name
+				user.Role = common.RoleCommonUser
+				user.Status = common.UserStatusEnabled
+
+				affCode := session.Get("aff")
+				inviterId := 0
+				if affCode != nil {
+					inviterId, _ = model.GetUserIdByAffCode(affCode.(string))
+				}
+
+				if err := user.Insert(inviterId); err != nil {
+					c.JSON(http.StatusOK, gin.H{
+						"success": false,
+						"message": err.Error(),
+					})
+					return
+				}
+			} else {
 				c.JSON(http.StatusOK, gin.H{
 					"success": false,
-					"message": err.Error(),
+					"message": "Linux DO 信任等级未达到管理员设置的最低信任等级",
 				})
 				return
 			}

+ 49 - 17
controller/midjourney.go

@@ -9,9 +9,11 @@ import (
 	"net/http"
 	"one-api/common"
 	"one-api/dto"
+	"one-api/logger"
 	"one-api/model"
 	"one-api/service"
 	"one-api/setting"
+	"one-api/setting/system_setting"
 	"time"
 
 	"github.com/gin-gonic/gin"
@@ -28,7 +30,7 @@ func UpdateMidjourneyTaskBulk() {
 			continue
 		}
 
-		common.LogInfo(ctx, fmt.Sprintf("检测到未完成的任务数有: %v", len(tasks)))
+		logger.LogInfo(ctx, fmt.Sprintf("检测到未完成的任务数有: %v", len(tasks)))
 		taskChannelM := make(map[int][]string)
 		taskM := make(map[string]*model.Midjourney)
 		nullTaskIds := make([]int, 0)
@@ -47,9 +49,9 @@ func UpdateMidjourneyTaskBulk() {
 				"progress": "100%",
 			})
 			if err != nil {
-				common.LogError(ctx, fmt.Sprintf("Fix null mj_id task error: %v", err))
+				logger.LogError(ctx, fmt.Sprintf("Fix null mj_id task error: %v", err))
 			} else {
-				common.LogInfo(ctx, fmt.Sprintf("Fix null mj_id task success: %v", nullTaskIds))
+				logger.LogInfo(ctx, fmt.Sprintf("Fix null mj_id task success: %v", nullTaskIds))
 			}
 		}
 		if len(taskChannelM) == 0 {
@@ -57,20 +59,20 @@ func UpdateMidjourneyTaskBulk() {
 		}
 
 		for channelId, taskIds := range taskChannelM {
-			common.LogInfo(ctx, fmt.Sprintf("渠道 #%d 未完成的任务有: %d", channelId, len(taskIds)))
+			logger.LogInfo(ctx, fmt.Sprintf("渠道 #%d 未完成的任务有: %d", channelId, len(taskIds)))
 			if len(taskIds) == 0 {
 				continue
 			}
 			midjourneyChannel, err := model.CacheGetChannel(channelId)
 			if err != nil {
-				common.LogError(ctx, fmt.Sprintf("CacheGetChannel: %v", err))
+				logger.LogError(ctx, fmt.Sprintf("CacheGetChannel: %v", err))
 				err := model.MjBulkUpdate(taskIds, map[string]any{
 					"fail_reason": fmt.Sprintf("获取渠道信息失败,请联系管理员,渠道ID:%d", channelId),
 					"status":      "FAILURE",
 					"progress":    "100%",
 				})
 				if err != nil {
-					common.LogInfo(ctx, fmt.Sprintf("UpdateMidjourneyTask error: %v", err))
+					logger.LogInfo(ctx, fmt.Sprintf("UpdateMidjourneyTask error: %v", err))
 				}
 				continue
 			}
@@ -81,7 +83,7 @@ func UpdateMidjourneyTaskBulk() {
 			})
 			req, err := http.NewRequest("POST", requestUrl, bytes.NewBuffer(body))
 			if err != nil {
-				common.LogError(ctx, fmt.Sprintf("Get Task error: %v", err))
+				logger.LogError(ctx, fmt.Sprintf("Get Task error: %v", err))
 				continue
 			}
 			// 设置超时时间
@@ -93,22 +95,22 @@ func UpdateMidjourneyTaskBulk() {
 			req.Header.Set("mj-api-secret", midjourneyChannel.Key)
 			resp, err := service.GetHttpClient().Do(req)
 			if err != nil {
-				common.LogError(ctx, fmt.Sprintf("Get Task Do req error: %v", err))
+				logger.LogError(ctx, fmt.Sprintf("Get Task Do req error: %v", err))
 				continue
 			}
 			if resp.StatusCode != http.StatusOK {
-				common.LogError(ctx, fmt.Sprintf("Get Task status code: %d", resp.StatusCode))
+				logger.LogError(ctx, fmt.Sprintf("Get Task status code: %d", resp.StatusCode))
 				continue
 			}
 			responseBody, err := io.ReadAll(resp.Body)
 			if err != nil {
-				common.LogError(ctx, fmt.Sprintf("Get Task parse body error: %v", err))
+				logger.LogError(ctx, fmt.Sprintf("Get Task parse body error: %v", err))
 				continue
 			}
 			var responseItems []dto.MidjourneyDto
 			err = json.Unmarshal(responseBody, &responseItems)
 			if err != nil {
-				common.LogError(ctx, fmt.Sprintf("Get Task parse body error2: %v, body: %s", err, string(responseBody)))
+				logger.LogError(ctx, fmt.Sprintf("Get Task parse body error2: %v, body: %s", err, string(responseBody)))
 				continue
 			}
 			resp.Body.Close()
@@ -145,9 +147,25 @@ func UpdateMidjourneyTaskBulk() {
 					buttonStr, _ := json.Marshal(responseItem.Buttons)
 					task.Buttons = string(buttonStr)
 				}
+				// 映射 VideoUrl
+				task.VideoUrl = responseItem.VideoUrl
+
+				// 映射 VideoUrls - 将数组序列化为 JSON 字符串
+				if responseItem.VideoUrls != nil && len(responseItem.VideoUrls) > 0 {
+					videoUrlsStr, err := json.Marshal(responseItem.VideoUrls)
+					if err != nil {
+						logger.LogError(ctx, fmt.Sprintf("序列化 VideoUrls 失败: %v", err))
+						task.VideoUrls = "[]" // 失败时设置为空数组
+					} else {
+						task.VideoUrls = string(videoUrlsStr)
+					}
+				} else {
+					task.VideoUrls = "" // 空值时清空字段
+				}
+
 				shouldReturnQuota := false
 				if (task.Progress != "100%" && responseItem.FailReason != "") || (task.Progress == "100%" && task.Status == "FAILURE") {
-					common.LogInfo(ctx, task.MjId+" 构建失败,"+task.FailReason)
+					logger.LogInfo(ctx, task.MjId+" 构建失败,"+task.FailReason)
 					task.Progress = "100%"
 					if task.Quota != 0 {
 						shouldReturnQuota = true
@@ -155,14 +173,14 @@ func UpdateMidjourneyTaskBulk() {
 				}
 				err = task.Update()
 				if err != nil {
-					common.LogError(ctx, "UpdateMidjourneyTask task error: "+err.Error())
+					logger.LogError(ctx, "UpdateMidjourneyTask task error: "+err.Error())
 				} else {
 					if shouldReturnQuota {
 						err = model.IncreaseUserQuota(task.UserId, task.Quota, false)
 						if err != nil {
-							common.LogError(ctx, "fail to increase user quota: "+err.Error())
+							logger.LogError(ctx, "fail to increase user quota: "+err.Error())
 						}
-						logContent := fmt.Sprintf("构图失败 %s,补偿 %s", task.MjId, common.LogQuota(task.Quota))
+						logContent := fmt.Sprintf("构图失败 %s,补偿 %s", task.MjId, logger.LogQuota(task.Quota))
 						model.RecordLog(task.UserId, model.LogTypeSystem, logContent)
 					}
 				}
@@ -208,6 +226,20 @@ func checkMjTaskNeedUpdate(oldTask *model.Midjourney, newTask dto.MidjourneyDto)
 	if oldTask.Progress != "100%" && newTask.FailReason != "" {
 		return true
 	}
+	// 检查 VideoUrl 是否需要更新
+	if oldTask.VideoUrl != newTask.VideoUrl {
+		return true
+	}
+	// 检查 VideoUrls 是否需要更新
+	if newTask.VideoUrls != nil && len(newTask.VideoUrls) > 0 {
+		newVideoUrlsStr, _ := json.Marshal(newTask.VideoUrls)
+		if oldTask.VideoUrls != string(newVideoUrlsStr) {
+			return true
+		}
+	} else if oldTask.VideoUrls != "" {
+		// 如果新数据没有 VideoUrls 但旧数据有,需要更新(清空)
+		return true
+	}
 
 	return false
 }
@@ -228,7 +260,7 @@ func GetAllMidjourney(c *gin.Context) {
 
 	if setting.MjForwardUrlEnabled {
 		for i, midjourney := range items {
-			midjourney.ImageUrl = setting.ServerAddress + "/mj/image/" + midjourney.MjId
+			midjourney.ImageUrl = system_setting.ServerAddress + "/mj/image/" + midjourney.MjId
 			items[i] = midjourney
 		}
 	}
@@ -253,7 +285,7 @@ func GetUserMidjourney(c *gin.Context) {
 
 	if setting.MjForwardUrlEnabled {
 		for i, midjourney := range items {
-			midjourney.ImageUrl = setting.ServerAddress + "/mj/image/" + midjourney.MjId
+			midjourney.ImageUrl = system_setting.ServerAddress + "/mj/image/" + midjourney.MjId
 			items[i] = midjourney
 		}
 	}

+ 44 - 43
controller/misc.go

@@ -39,50 +39,47 @@ func TestStatus(c *gin.Context) {
 func GetStatus(c *gin.Context) {
 
 	cs := console_setting.GetConsoleSetting()
+	common.OptionMapRWMutex.RLock()
+	defer common.OptionMapRWMutex.RUnlock()
 
 	data := gin.H{
-		"version":                  common.Version,
-		"start_time":               common.StartTime,
-		"email_verification":       common.EmailVerificationEnabled,
-		"github_oauth":             common.GitHubOAuthEnabled,
-		"github_client_id":         common.GitHubClientId,
-		"linuxdo_oauth":            common.LinuxDOOAuthEnabled,
-		"linuxdo_client_id":        common.LinuxDOClientId,
-		"telegram_oauth":           common.TelegramOAuthEnabled,
-		"telegram_bot_name":        common.TelegramBotName,
-		"system_name":              common.SystemName,
-		"logo":                     common.Logo,
-		"footer_html":              common.Footer,
-		"wechat_qrcode":            common.WeChatAccountQRCodeImageURL,
-		"wechat_login":             common.WeChatAuthEnabled,
-		"server_address":           setting.ServerAddress,
-		"price":                    setting.Price,
-		"stripe_unit_price":        setting.StripeUnitPrice,
-		"min_topup":                setting.MinTopUp,
-		"stripe_min_topup":         setting.StripeMinTopUp,
-		"turnstile_check":          common.TurnstileCheckEnabled,
-		"turnstile_site_key":       common.TurnstileSiteKey,
-		"top_up_link":              common.TopUpLink,
-		"docs_link":                operation_setting.GetGeneralSetting().DocsLink,
-		"quota_per_unit":           common.QuotaPerUnit,
-		"display_in_currency":      common.DisplayInCurrencyEnabled,
-		"enable_batch_update":      common.BatchUpdateEnabled,
-		"enable_drawing":           common.DrawingEnabled,
-		"enable_task":              common.TaskEnabled,
-		"enable_data_export":       common.DataExportEnabled,
-		"data_export_default_time": common.DataExportDefaultTime,
-		"default_collapse_sidebar": common.DefaultCollapseSidebar,
-		"enable_online_topup":      setting.PayAddress != "" && setting.EpayId != "" && setting.EpayKey != "",
-		"enable_stripe_topup":      setting.StripeApiSecret != "" && setting.StripeWebhookSecret != "" && setting.StripePriceId != "",
-		"enable_creem_topup":       setting.CreemApiKey != "" && setting.CreemProducts != "[]",
-		"creem_products":           setting.CreemProducts,
-		"mj_notify_enabled":        setting.MjNotifyEnabled,
-		"chats":                    setting.Chats,
-		"demo_site_enabled":        operation_setting.DemoSiteEnabled,
-		"self_use_mode_enabled":    operation_setting.SelfUseModeEnabled,
-		"default_use_auto_group":   setting.DefaultUseAutoGroup,
-		"pay_methods":              setting.PayMethods,
-		"usd_exchange_rate":        setting.USDExchangeRate,
+		"version":                     common.Version,
+		"start_time":                  common.StartTime,
+		"email_verification":          common.EmailVerificationEnabled,
+		"github_oauth":                common.GitHubOAuthEnabled,
+		"github_client_id":            common.GitHubClientId,
+		"linuxdo_oauth":               common.LinuxDOOAuthEnabled,
+		"linuxdo_client_id":           common.LinuxDOClientId,
+		"linuxdo_minimum_trust_level": common.LinuxDOMinimumTrustLevel,
+		"telegram_oauth":              common.TelegramOAuthEnabled,
+		"telegram_bot_name":           common.TelegramBotName,
+		"system_name":                 common.SystemName,
+		"logo":                        common.Logo,
+		"footer_html":                 common.Footer,
+		"wechat_qrcode":               common.WeChatAccountQRCodeImageURL,
+		"wechat_login":                common.WeChatAuthEnabled,
+		"server_address":              system_setting.ServerAddress,
+		"turnstile_check":             common.TurnstileCheckEnabled,
+		"turnstile_site_key":          common.TurnstileSiteKey,
+		"top_up_link":                 common.TopUpLink,
+		"docs_link":                   operation_setting.GetGeneralSetting().DocsLink,
+		"quota_per_unit":              common.QuotaPerUnit,
+		"display_in_currency":         common.DisplayInCurrencyEnabled,
+		"enable_batch_update":         common.BatchUpdateEnabled,
+		"enable_drawing":              common.DrawingEnabled,
+		"enable_task":                 common.TaskEnabled,
+		"enable_data_export":          common.DataExportEnabled,
+		"data_export_default_time":    common.DataExportDefaultTime,
+		"default_collapse_sidebar":    common.DefaultCollapseSidebar,
+		"mj_notify_enabled":           setting.MjNotifyEnabled,
+		"chats":                       setting.Chats,
+		"demo_site_enabled":           operation_setting.DemoSiteEnabled,
+		"self_use_mode_enabled":       operation_setting.SelfUseModeEnabled,
+		"default_use_auto_group":      setting.DefaultUseAutoGroup,
+
+		"usd_exchange_rate": operation_setting.USDExchangeRate,
+		"price":             operation_setting.Price,
+		"stripe_unit_price": setting.StripeUnitPrice,
 
 		// 面板启用开关
 		"api_info_enabled":      cs.ApiInfoEnabled,
@@ -90,6 +87,10 @@ func GetStatus(c *gin.Context) {
 		"announcements_enabled": cs.AnnouncementsEnabled,
 		"faq_enabled":           cs.FAQEnabled,
 
+		// 模块管理配置
+		"HeaderNavModules":    common.OptionMap["HeaderNavModules"],
+		"SidebarModulesAdmin": common.OptionMap["SidebarModulesAdmin"],
+
 		"oidc_enabled":                system_setting.GetOIDCSettings().Enabled,
 		"oidc_client_id":              system_setting.GetOIDCSettings().ClientId,
 		"oidc_authorization_endpoint": system_setting.GetOIDCSettings().AuthorizationEndpoint,
@@ -248,7 +249,7 @@ func SendPasswordResetEmail(c *gin.Context) {
 	}
 	code := common.GenerateVerificationCode(0)
 	common.RegisterVerificationCodeWithKey(email, code, common.PasswordResetPurpose)
-	link := fmt.Sprintf("%s/user/reset?email=%s&token=%s", setting.ServerAddress, email, code)
+	link := fmt.Sprintf("%s/user/reset?email=%s&token=%s", system_setting.ServerAddress, email, code)
 	subject := fmt.Sprintf("%s密码重置", common.SystemName)
 	content := fmt.Sprintf("<p>您好,你正在进行%s密码重置。</p>"+
 		"<p>点击 <a href='%s'>此处</a> 进行密码重置。</p>"+

+ 27 - 0
controller/missing_models.go

@@ -0,0 +1,27 @@
+package controller
+
+import (
+	"net/http"
+	"one-api/model"
+
+	"github.com/gin-gonic/gin"
+)
+
+// GetMissingModels returns the list of model names that are referenced by channels
+// but do not have corresponding records in the models meta table.
+// This helps administrators quickly discover models that need configuration.
+func GetMissingModels(c *gin.Context) {
+	missing, err := model.GetMissingModels()
+	if err != nil {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": err.Error(),
+		})
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"data":    missing,
+	})
+}

+ 53 - 8
controller/model.go

@@ -16,6 +16,7 @@ import (
 	"one-api/relay/channel/moonshot"
 	relaycommon "one-api/relay/common"
 	"one-api/setting"
+	"time"
 )
 
 // https://platform.openai.com/docs/api-reference/models/list
@@ -92,7 +93,9 @@ func init() {
 		if !success || apiType == constant.APITypeAIProxyLibrary {
 			continue
 		}
-		meta := &relaycommon.RelayInfo{ChannelType: i}
+		meta := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{
+			ChannelType: i,
+		}}
 		adaptor := relay.GetAdaptor(apiType)
 		adaptor.Init(meta)
 		channelId2Models[i] = adaptor.GetModelList()
@@ -102,7 +105,7 @@ func init() {
 	})
 }
 
-func ListModels(c *gin.Context) {
+func ListModels(c *gin.Context, modelType int) {
 	userOpenAiModels := make([]dto.OpenAIModels, 0)
 
 	modelLimitEnable := common.GetContextKeyBool(c, constant.ContextKeyTokenModelLimitEnabled)
@@ -171,10 +174,42 @@ func ListModels(c *gin.Context) {
 			}
 		}
 	}
-	c.JSON(200, gin.H{
-		"success": true,
-		"data":    userOpenAiModels,
-	})
+	switch modelType {
+	case constant.ChannelTypeAnthropic:
+		useranthropicModels := make([]dto.AnthropicModel, len(userOpenAiModels))
+		for i, model := range userOpenAiModels {
+			useranthropicModels[i] = dto.AnthropicModel{
+				ID:          model.Id,
+				CreatedAt:   time.Unix(int64(model.Created), 0).UTC().Format(time.RFC3339),
+				DisplayName: model.Id,
+				Type:        "model",
+			}
+		}
+		c.JSON(200, gin.H{
+			"data":     useranthropicModels,
+			"first_id": useranthropicModels[0].ID,
+			"has_more": false,
+			"last_id":  useranthropicModels[len(useranthropicModels)-1].ID,
+		})
+	case constant.ChannelTypeGemini:
+		userGeminiModels := make([]dto.GeminiModel, len(userOpenAiModels))
+		for i, model := range userOpenAiModels {
+			userGeminiModels[i] = dto.GeminiModel{
+				Name:        model.Id,
+				DisplayName: model.Id,
+			}
+		}
+		c.JSON(200, gin.H{
+			"models":        userGeminiModels,
+			"nextPageToken": nil,
+		})
+	default:
+		c.JSON(200, gin.H{
+			"success": true,
+			"data":    userOpenAiModels,
+			"object":  "list",
+		})
+	}
 }
 
 func ChannelListModels(c *gin.Context) {
@@ -198,10 +233,20 @@ func EnabledListModels(c *gin.Context) {
 	})
 }
 
-func RetrieveModel(c *gin.Context) {
+func RetrieveModel(c *gin.Context, modelType int) {
 	modelId := c.Param("model")
 	if aiModel, ok := openAIModelsMap[modelId]; ok {
-		c.JSON(200, aiModel)
+		switch modelType {
+		case constant.ChannelTypeAnthropic:
+			c.JSON(200, dto.AnthropicModel{
+				ID:          aiModel.Id,
+				CreatedAt:   time.Unix(int64(aiModel.Created), 0).UTC().Format(time.RFC3339),
+				DisplayName: aiModel.Id,
+				Type:        "model",
+			})
+		default:
+			c.JSON(200, aiModel)
+		}
 	} else {
 		openAIError := dto.OpenAIError{
 			Message: fmt.Sprintf("The model '%s' does not exist", modelId),

+ 330 - 0
controller/model_meta.go

@@ -0,0 +1,330 @@
+package controller
+
+import (
+	"encoding/json"
+	"sort"
+	"strconv"
+	"strings"
+
+	"one-api/common"
+	"one-api/constant"
+	"one-api/model"
+
+	"github.com/gin-gonic/gin"
+)
+
+// GetAllModelsMeta 获取模型列表(分页)
+func GetAllModelsMeta(c *gin.Context) {
+
+	pageInfo := common.GetPageQuery(c)
+	modelsMeta, err := model.GetAllModels(pageInfo.GetStartIdx(), pageInfo.GetPageSize())
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+	// 批量填充附加字段,提升列表接口性能
+	enrichModels(modelsMeta)
+	var total int64
+	model.DB.Model(&model.Model{}).Count(&total)
+
+	// 统计供应商计数(全部数据,不受分页影响)
+	vendorCounts, _ := model.GetVendorModelCounts()
+
+	pageInfo.SetTotal(int(total))
+	pageInfo.SetItems(modelsMeta)
+	common.ApiSuccess(c, gin.H{
+		"items":         modelsMeta,
+		"total":         total,
+		"page":          pageInfo.GetPage(),
+		"page_size":     pageInfo.GetPageSize(),
+		"vendor_counts": vendorCounts,
+	})
+}
+
+// SearchModelsMeta 搜索模型列表
+func SearchModelsMeta(c *gin.Context) {
+
+	keyword := c.Query("keyword")
+	vendor := c.Query("vendor")
+	pageInfo := common.GetPageQuery(c)
+
+	modelsMeta, total, err := model.SearchModels(keyword, vendor, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+	// 批量填充附加字段,提升列表接口性能
+	enrichModels(modelsMeta)
+	pageInfo.SetTotal(int(total))
+	pageInfo.SetItems(modelsMeta)
+	common.ApiSuccess(c, pageInfo)
+}
+
+// GetModelMeta 根据 ID 获取单条模型信息
+func GetModelMeta(c *gin.Context) {
+	idStr := c.Param("id")
+	id, err := strconv.Atoi(idStr)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+	var m model.Model
+	if err := model.DB.First(&m, id).Error; err != nil {
+		common.ApiError(c, err)
+		return
+	}
+	enrichModels([]*model.Model{&m})
+	common.ApiSuccess(c, &m)
+}
+
+// CreateModelMeta 新建模型
+func CreateModelMeta(c *gin.Context) {
+	var m model.Model
+	if err := c.ShouldBindJSON(&m); err != nil {
+		common.ApiError(c, err)
+		return
+	}
+	if m.ModelName == "" {
+		common.ApiErrorMsg(c, "模型名称不能为空")
+		return
+	}
+	// 名称冲突检查
+	if dup, err := model.IsModelNameDuplicated(0, m.ModelName); err != nil {
+		common.ApiError(c, err)
+		return
+	} else if dup {
+		common.ApiErrorMsg(c, "模型名称已存在")
+		return
+	}
+
+	if err := m.Insert(); err != nil {
+		common.ApiError(c, err)
+		return
+	}
+	model.RefreshPricing()
+	common.ApiSuccess(c, &m)
+}
+
+// UpdateModelMeta 更新模型
+func UpdateModelMeta(c *gin.Context) {
+	statusOnly := c.Query("status_only") == "true"
+
+	var m model.Model
+	if err := c.ShouldBindJSON(&m); err != nil {
+		common.ApiError(c, err)
+		return
+	}
+	if m.Id == 0 {
+		common.ApiErrorMsg(c, "缺少模型 ID")
+		return
+	}
+
+	if statusOnly {
+		// 只更新状态,防止误清空其他字段
+		if err := model.DB.Model(&model.Model{}).Where("id = ?", m.Id).Update("status", m.Status).Error; err != nil {
+			common.ApiError(c, err)
+			return
+		}
+	} else {
+		// 名称冲突检查
+		if dup, err := model.IsModelNameDuplicated(m.Id, m.ModelName); err != nil {
+			common.ApiError(c, err)
+			return
+		} else if dup {
+			common.ApiErrorMsg(c, "模型名称已存在")
+			return
+		}
+
+		if err := m.Update(); err != nil {
+			common.ApiError(c, err)
+			return
+		}
+	}
+	model.RefreshPricing()
+	common.ApiSuccess(c, &m)
+}
+
+// DeleteModelMeta 删除模型
+func DeleteModelMeta(c *gin.Context) {
+	idStr := c.Param("id")
+	id, err := strconv.Atoi(idStr)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+	if err := model.DB.Delete(&model.Model{}, id).Error; err != nil {
+		common.ApiError(c, err)
+		return
+	}
+	model.RefreshPricing()
+	common.ApiSuccess(c, nil)
+}
+
+// enrichModels 批量填充附加信息:端点、渠道、分组、计费类型,避免 N+1 查询
+func enrichModels(models []*model.Model) {
+	if len(models) == 0 {
+		return
+	}
+
+	// 1) 拆分精确与规则匹配
+	exactNames := make([]string, 0)
+	exactIdx := make(map[string][]int) // modelName -> indices in models
+	ruleIndices := make([]int, 0)
+	for i, m := range models {
+		if m == nil {
+			continue
+		}
+		if m.NameRule == model.NameRuleExact {
+			exactNames = append(exactNames, m.ModelName)
+			exactIdx[m.ModelName] = append(exactIdx[m.ModelName], i)
+		} else {
+			ruleIndices = append(ruleIndices, i)
+		}
+	}
+
+	// 2) 批量查询精确模型的绑定渠道
+	channelsByModel, _ := model.GetBoundChannelsByModelsMap(exactNames)
+
+	// 3) 精确模型:端点从缓存、渠道批量映射、分组/计费类型从缓存
+	for name, indices := range exactIdx {
+		chs := channelsByModel[name]
+		for _, idx := range indices {
+			mm := models[idx]
+			if mm.Endpoints == "" {
+				eps := model.GetModelSupportEndpointTypes(mm.ModelName)
+				if b, err := json.Marshal(eps); err == nil {
+					mm.Endpoints = string(b)
+				}
+			}
+			mm.BoundChannels = chs
+			mm.EnableGroups = model.GetModelEnableGroups(mm.ModelName)
+			mm.QuotaTypes = model.GetModelQuotaTypes(mm.ModelName)
+		}
+	}
+
+	if len(ruleIndices) == 0 {
+		return
+	}
+
+	// 4) 一次性读取定价缓存,内存匹配所有规则模型
+	pricings := model.GetPricing()
+
+	// 为全部规则模型收集匹配名集合、端点并集、分组并集、配额集合
+	matchedNamesByIdx := make(map[int][]string)
+	endpointSetByIdx := make(map[int]map[constant.EndpointType]struct{})
+	groupSetByIdx := make(map[int]map[string]struct{})
+	quotaSetByIdx := make(map[int]map[int]struct{})
+
+	for _, p := range pricings {
+		for _, idx := range ruleIndices {
+			mm := models[idx]
+			var matched bool
+			switch mm.NameRule {
+			case model.NameRulePrefix:
+				matched = strings.HasPrefix(p.ModelName, mm.ModelName)
+			case model.NameRuleSuffix:
+				matched = strings.HasSuffix(p.ModelName, mm.ModelName)
+			case model.NameRuleContains:
+				matched = strings.Contains(p.ModelName, mm.ModelName)
+			}
+			if !matched {
+				continue
+			}
+			matchedNamesByIdx[idx] = append(matchedNamesByIdx[idx], p.ModelName)
+
+			es := endpointSetByIdx[idx]
+			if es == nil {
+				es = make(map[constant.EndpointType]struct{})
+				endpointSetByIdx[idx] = es
+			}
+			for _, et := range p.SupportedEndpointTypes {
+				es[et] = struct{}{}
+			}
+
+			gs := groupSetByIdx[idx]
+			if gs == nil {
+				gs = make(map[string]struct{})
+				groupSetByIdx[idx] = gs
+			}
+			for _, g := range p.EnableGroup {
+				gs[g] = struct{}{}
+			}
+
+			qs := quotaSetByIdx[idx]
+			if qs == nil {
+				qs = make(map[int]struct{})
+				quotaSetByIdx[idx] = qs
+			}
+			qs[p.QuotaType] = struct{}{}
+		}
+	}
+
+	// 5) 汇总所有匹配到的模型名称,批量查询一次渠道
+	allMatchedSet := make(map[string]struct{})
+	for _, names := range matchedNamesByIdx {
+		for _, n := range names {
+			allMatchedSet[n] = struct{}{}
+		}
+	}
+	allMatched := make([]string, 0, len(allMatchedSet))
+	for n := range allMatchedSet {
+		allMatched = append(allMatched, n)
+	}
+	matchedChannelsByModel, _ := model.GetBoundChannelsByModelsMap(allMatched)
+
+	// 6) 回填每个规则模型的并集信息
+	for _, idx := range ruleIndices {
+		mm := models[idx]
+
+		// 端点并集 -> 序列化
+		if es, ok := endpointSetByIdx[idx]; ok && mm.Endpoints == "" {
+			eps := make([]constant.EndpointType, 0, len(es))
+			for et := range es {
+				eps = append(eps, et)
+			}
+			if b, err := json.Marshal(eps); err == nil {
+				mm.Endpoints = string(b)
+			}
+		}
+
+		// 分组并集
+		if gs, ok := groupSetByIdx[idx]; ok {
+			groups := make([]string, 0, len(gs))
+			for g := range gs {
+				groups = append(groups, g)
+			}
+			mm.EnableGroups = groups
+		}
+
+		// 配额类型集合(保持去重并排序)
+		if qs, ok := quotaSetByIdx[idx]; ok {
+			arr := make([]int, 0, len(qs))
+			for k := range qs {
+				arr = append(arr, k)
+			}
+			sort.Ints(arr)
+			mm.QuotaTypes = arr
+		}
+
+		// 渠道并集
+		names := matchedNamesByIdx[idx]
+		channelSet := make(map[string]model.BoundChannel)
+		for _, n := range names {
+			for _, ch := range matchedChannelsByModel[n] {
+				key := ch.Name + "_" + strconv.Itoa(ch.Type)
+				channelSet[key] = ch
+			}
+		}
+		if len(channelSet) > 0 {
+			chs := make([]model.BoundChannel, 0, len(channelSet))
+			for _, ch := range channelSet {
+				chs = append(chs, ch)
+			}
+			mm.BoundChannels = chs
+		}
+
+		// 匹配信息
+		mm.MatchedModels = names
+		mm.MatchedCount = len(names)
+	}
+}

+ 604 - 0
controller/model_sync.go

@@ -0,0 +1,604 @@
+package controller
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"math/rand"
+	"net"
+	"net/http"
+	"strings"
+	"sync"
+	"time"
+
+	"one-api/common"
+	"one-api/model"
+
+	"github.com/gin-gonic/gin"
+	"gorm.io/gorm"
+)
+
+// 上游地址
+const (
+	upstreamModelsURL  = "https://basellm.github.io/llm-metadata/api/newapi/models.json"
+	upstreamVendorsURL = "https://basellm.github.io/llm-metadata/api/newapi/vendors.json"
+)
+
+func normalizeLocale(locale string) (string, bool) {
+	l := strings.ToLower(strings.TrimSpace(locale))
+	switch l {
+	case "en", "zh", "ja":
+		return l, true
+	default:
+		return "", false
+	}
+}
+
+func getUpstreamBase() string {
+	return common.GetEnvOrDefaultString("SYNC_UPSTREAM_BASE", "https://basellm.github.io/llm-metadata")
+}
+
+func getUpstreamURLs(locale string) (modelsURL, vendorsURL string) {
+	base := strings.TrimRight(getUpstreamBase(), "/")
+	if l, ok := normalizeLocale(locale); ok && l != "" {
+		return fmt.Sprintf("%s/api/i18n/%s/newapi/models.json", base, l),
+			fmt.Sprintf("%s/api/i18n/%s/newapi/vendors.json", base, l)
+	}
+	return fmt.Sprintf("%s/api/newapi/models.json", base), fmt.Sprintf("%s/api/newapi/vendors.json", base)
+}
+
+type upstreamEnvelope[T any] struct {
+	Success bool   `json:"success"`
+	Message string `json:"message"`
+	Data    []T    `json:"data"`
+}
+
+type upstreamModel struct {
+	Description string          `json:"description"`
+	Endpoints   json.RawMessage `json:"endpoints"`
+	Icon        string          `json:"icon"`
+	ModelName   string          `json:"model_name"`
+	NameRule    int             `json:"name_rule"`
+	Status      int             `json:"status"`
+	Tags        string          `json:"tags"`
+	VendorName  string          `json:"vendor_name"`
+}
+
+type upstreamVendor struct {
+	Description string `json:"description"`
+	Icon        string `json:"icon"`
+	Name        string `json:"name"`
+	Status      int    `json:"status"`
+}
+
+var (
+	etagCache  = make(map[string]string)
+	bodyCache  = make(map[string][]byte)
+	cacheMutex sync.RWMutex
+)
+
+type overwriteField struct {
+	ModelName string   `json:"model_name"`
+	Fields    []string `json:"fields"`
+}
+
+type syncRequest struct {
+	Overwrite []overwriteField `json:"overwrite"`
+	Locale    string           `json:"locale"`
+}
+
+func newHTTPClient() *http.Client {
+	timeoutSec := common.GetEnvOrDefault("SYNC_HTTP_TIMEOUT_SECONDS", 10)
+	dialer := &net.Dialer{Timeout: time.Duration(timeoutSec) * time.Second}
+	transport := &http.Transport{
+		MaxIdleConns:          100,
+		IdleConnTimeout:       90 * time.Second,
+		TLSHandshakeTimeout:   time.Duration(timeoutSec) * time.Second,
+		ExpectContinueTimeout: 1 * time.Second,
+		ResponseHeaderTimeout: time.Duration(timeoutSec) * time.Second,
+	}
+	transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
+		host, _, err := net.SplitHostPort(addr)
+		if err != nil {
+			host = addr
+		}
+		if strings.HasSuffix(host, "github.io") {
+			if conn, err := dialer.DialContext(ctx, "tcp4", addr); err == nil {
+				return conn, nil
+			}
+			return dialer.DialContext(ctx, "tcp6", addr)
+		}
+		return dialer.DialContext(ctx, network, addr)
+	}
+	return &http.Client{Transport: transport}
+}
+
+var httpClient = newHTTPClient()
+
+func fetchJSON[T any](ctx context.Context, url string, out *upstreamEnvelope[T]) error {
+	var lastErr error
+	attempts := common.GetEnvOrDefault("SYNC_HTTP_RETRY", 3)
+	if attempts < 1 {
+		attempts = 1
+	}
+	baseDelay := 200 * time.Millisecond
+	maxMB := common.GetEnvOrDefault("SYNC_HTTP_MAX_MB", 10)
+	maxBytes := int64(maxMB) << 20
+	for attempt := 0; attempt < attempts; attempt++ {
+		req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+		if err != nil {
+			return err
+		}
+		// ETag conditional request
+		cacheMutex.RLock()
+		if et := etagCache[url]; et != "" {
+			req.Header.Set("If-None-Match", et)
+		}
+		cacheMutex.RUnlock()
+
+		resp, err := httpClient.Do(req)
+		if err != nil {
+			lastErr = err
+			// backoff with jitter
+			sleep := baseDelay * time.Duration(1<<attempt)
+			jitter := time.Duration(rand.Intn(150)) * time.Millisecond
+			time.Sleep(sleep + jitter)
+			continue
+		}
+		func() {
+			defer resp.Body.Close()
+			switch resp.StatusCode {
+			case http.StatusOK:
+				// read body into buffer for caching and flexible decode
+				limited := io.LimitReader(resp.Body, maxBytes)
+				buf, err := io.ReadAll(limited)
+				if err != nil {
+					lastErr = err
+					return
+				}
+				// cache body and ETag
+				cacheMutex.Lock()
+				if et := resp.Header.Get("ETag"); et != "" {
+					etagCache[url] = et
+				}
+				bodyCache[url] = buf
+				cacheMutex.Unlock()
+
+				// Try decode as envelope first
+				if err := json.Unmarshal(buf, out); err != nil {
+					// Try decode as pure array
+					var arr []T
+					if err2 := json.Unmarshal(buf, &arr); err2 != nil {
+						lastErr = err
+						return
+					}
+					out.Success = true
+					out.Data = arr
+					out.Message = ""
+				} else {
+					if !out.Success && len(out.Data) == 0 && out.Message == "" {
+						out.Success = true
+					}
+				}
+				lastErr = nil
+			case http.StatusNotModified:
+				// use cache
+				cacheMutex.RLock()
+				buf := bodyCache[url]
+				cacheMutex.RUnlock()
+				if len(buf) == 0 {
+					lastErr = errors.New("cache miss for 304 response")
+					return
+				}
+				if err := json.Unmarshal(buf, out); err != nil {
+					var arr []T
+					if err2 := json.Unmarshal(buf, &arr); err2 != nil {
+						lastErr = err
+						return
+					}
+					out.Success = true
+					out.Data = arr
+					out.Message = ""
+				} else {
+					if !out.Success && len(out.Data) == 0 && out.Message == "" {
+						out.Success = true
+					}
+				}
+				lastErr = nil
+			default:
+				lastErr = errors.New(resp.Status)
+			}
+		}()
+		if lastErr == nil {
+			return nil
+		}
+		sleep := baseDelay * time.Duration(1<<attempt)
+		jitter := time.Duration(rand.Intn(150)) * time.Millisecond
+		time.Sleep(sleep + jitter)
+	}
+	return lastErr
+}
+
+func ensureVendorID(vendorName string, vendorByName map[string]upstreamVendor, vendorIDCache map[string]int, createdVendors *int) int {
+	if vendorName == "" {
+		return 0
+	}
+	if id, ok := vendorIDCache[vendorName]; ok {
+		return id
+	}
+	var existing model.Vendor
+	if err := model.DB.Where("name = ?", vendorName).First(&existing).Error; err == nil {
+		vendorIDCache[vendorName] = existing.Id
+		return existing.Id
+	}
+	uv := vendorByName[vendorName]
+	v := &model.Vendor{
+		Name:        vendorName,
+		Description: uv.Description,
+		Icon:        coalesce(uv.Icon, ""),
+		Status:      chooseStatus(uv.Status, 1),
+	}
+	if err := v.Insert(); err == nil {
+		*createdVendors++
+		vendorIDCache[vendorName] = v.Id
+		return v.Id
+	}
+	vendorIDCache[vendorName] = 0
+	return 0
+}
+
+// SyncUpstreamModels 同步上游模型与供应商,仅对「未配置模型」生效
+func SyncUpstreamModels(c *gin.Context) {
+	var req syncRequest
+	// 允许空体
+	_ = c.ShouldBindJSON(&req)
+	// 1) 获取未配置模型列表
+	missing, err := model.GetMissingModels()
+	if err != nil {
+		c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
+		return
+	}
+	if len(missing) == 0 {
+		c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{
+			"created_models":  0,
+			"created_vendors": 0,
+			"skipped_models":  []string{},
+		}})
+		return
+	}
+
+	// 2) 拉取上游 vendors 与 models
+	timeoutSec := common.GetEnvOrDefault("SYNC_HTTP_TIMEOUT_SECONDS", 15)
+	ctx, cancel := context.WithTimeout(c.Request.Context(), time.Duration(timeoutSec)*time.Second)
+	defer cancel()
+
+	modelsURL, vendorsURL := getUpstreamURLs(req.Locale)
+	var vendorsEnv upstreamEnvelope[upstreamVendor]
+	var modelsEnv upstreamEnvelope[upstreamModel]
+	var fetchErr error
+	var wg sync.WaitGroup
+	wg.Add(2)
+	go func() {
+		defer wg.Done()
+		// vendor 失败不拦截
+		_ = fetchJSON(ctx, vendorsURL, &vendorsEnv)
+	}()
+	go func() {
+		defer wg.Done()
+		if err := fetchJSON(ctx, modelsURL, &modelsEnv); err != nil {
+			fetchErr = err
+		}
+	}()
+	wg.Wait()
+	if fetchErr != nil {
+		c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取上游模型失败: " + fetchErr.Error(), "locale": req.Locale, "source_urls": gin.H{"models_url": modelsURL, "vendors_url": vendorsURL}})
+		return
+	}
+
+	// 建立映射
+	vendorByName := make(map[string]upstreamVendor)
+	for _, v := range vendorsEnv.Data {
+		if v.Name != "" {
+			vendorByName[v.Name] = v
+		}
+	}
+	modelByName := make(map[string]upstreamModel)
+	for _, m := range modelsEnv.Data {
+		if m.ModelName != "" {
+			modelByName[m.ModelName] = m
+		}
+	}
+
+	// 3) 执行同步:仅创建缺失模型;若上游缺失该模型则跳过
+	createdModels := 0
+	createdVendors := 0
+	updatedModels := 0
+	var skipped []string
+	var createdList []string
+	var updatedList []string
+
+	// 本地缓存:vendorName -> id
+	vendorIDCache := make(map[string]int)
+
+	for _, name := range missing {
+		up, ok := modelByName[name]
+		if !ok {
+			skipped = append(skipped, name)
+			continue
+		}
+
+		// 若本地已存在且设置为不同步,则跳过(极端情况:缺失列表与本地状态不同步时)
+		var existing model.Model
+		if err := model.DB.Where("model_name = ?", name).First(&existing).Error; err == nil {
+			if existing.SyncOfficial == 0 {
+				skipped = append(skipped, name)
+				continue
+			}
+		}
+
+		// 确保 vendor 存在
+		vendorID := ensureVendorID(up.VendorName, vendorByName, vendorIDCache, &createdVendors)
+
+		// 创建模型
+		mi := &model.Model{
+			ModelName:   name,
+			Description: up.Description,
+			Icon:        up.Icon,
+			Tags:        up.Tags,
+			VendorID:    vendorID,
+			Status:      chooseStatus(up.Status, 1),
+			NameRule:    up.NameRule,
+		}
+		if err := mi.Insert(); err == nil {
+			createdModels++
+			createdList = append(createdList, name)
+		} else {
+			skipped = append(skipped, name)
+		}
+	}
+
+	// 4) 处理可选覆盖(更新本地已有模型的差异字段)
+	if len(req.Overwrite) > 0 {
+		// vendorIDCache 已用于创建阶段,可复用
+		for _, ow := range req.Overwrite {
+			up, ok := modelByName[ow.ModelName]
+			if !ok {
+				continue
+			}
+			var local model.Model
+			if err := model.DB.Where("model_name = ?", ow.ModelName).First(&local).Error; err != nil {
+				continue
+			}
+
+			// 跳过被禁用官方同步的模型
+			if local.SyncOfficial == 0 {
+				continue
+			}
+
+			// 映射 vendor
+			newVendorID := ensureVendorID(up.VendorName, vendorByName, vendorIDCache, &createdVendors)
+
+			// 应用字段覆盖(事务)
+			_ = model.DB.Transaction(func(tx *gorm.DB) error {
+				needUpdate := false
+				if containsField(ow.Fields, "description") {
+					local.Description = up.Description
+					needUpdate = true
+				}
+				if containsField(ow.Fields, "icon") {
+					local.Icon = up.Icon
+					needUpdate = true
+				}
+				if containsField(ow.Fields, "tags") {
+					local.Tags = up.Tags
+					needUpdate = true
+				}
+				if containsField(ow.Fields, "vendor") {
+					local.VendorID = newVendorID
+					needUpdate = true
+				}
+				if containsField(ow.Fields, "name_rule") {
+					local.NameRule = up.NameRule
+					needUpdate = true
+				}
+				if containsField(ow.Fields, "status") {
+					local.Status = chooseStatus(up.Status, local.Status)
+					needUpdate = true
+				}
+				if !needUpdate {
+					return nil
+				}
+				if err := tx.Save(&local).Error; err != nil {
+					return err
+				}
+				updatedModels++
+				updatedList = append(updatedList, ow.ModelName)
+				return nil
+			})
+		}
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"data": gin.H{
+			"created_models":  createdModels,
+			"created_vendors": createdVendors,
+			"updated_models":  updatedModels,
+			"skipped_models":  skipped,
+			"created_list":    createdList,
+			"updated_list":    updatedList,
+			"source": gin.H{
+				"locale":      req.Locale,
+				"models_url":  modelsURL,
+				"vendors_url": vendorsURL,
+			},
+		},
+	})
+}
+
+func containsField(fields []string, key string) bool {
+	key = strings.ToLower(strings.TrimSpace(key))
+	for _, f := range fields {
+		if strings.ToLower(strings.TrimSpace(f)) == key {
+			return true
+		}
+	}
+	return false
+}
+
+func coalesce(a, b string) string {
+	if strings.TrimSpace(a) != "" {
+		return a
+	}
+	return b
+}
+
+func chooseStatus(primary, fallback int) int {
+	if primary == 0 && fallback != 0 {
+		return fallback
+	}
+	if primary != 0 {
+		return primary
+	}
+	return 1
+}
+
+// SyncUpstreamPreview 预览上游与本地的差异(仅用于弹窗选择)
+func SyncUpstreamPreview(c *gin.Context) {
+	// 1) 拉取上游数据
+	timeoutSec := common.GetEnvOrDefault("SYNC_HTTP_TIMEOUT_SECONDS", 15)
+	ctx, cancel := context.WithTimeout(c.Request.Context(), time.Duration(timeoutSec)*time.Second)
+	defer cancel()
+
+	locale := c.Query("locale")
+	modelsURL, vendorsURL := getUpstreamURLs(locale)
+
+	var vendorsEnv upstreamEnvelope[upstreamVendor]
+	var modelsEnv upstreamEnvelope[upstreamModel]
+	var fetchErr error
+	var wg sync.WaitGroup
+	wg.Add(2)
+	go func() {
+		defer wg.Done()
+		_ = fetchJSON(ctx, vendorsURL, &vendorsEnv)
+	}()
+	go func() {
+		defer wg.Done()
+		if err := fetchJSON(ctx, modelsURL, &modelsEnv); err != nil {
+			fetchErr = err
+		}
+	}()
+	wg.Wait()
+	if fetchErr != nil {
+		c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取上游模型失败: " + fetchErr.Error(), "locale": locale, "source_urls": gin.H{"models_url": modelsURL, "vendors_url": vendorsURL}})
+		return
+	}
+
+	vendorByName := make(map[string]upstreamVendor)
+	for _, v := range vendorsEnv.Data {
+		if v.Name != "" {
+			vendorByName[v.Name] = v
+		}
+	}
+	modelByName := make(map[string]upstreamModel)
+	upstreamNames := make([]string, 0, len(modelsEnv.Data))
+	for _, m := range modelsEnv.Data {
+		if m.ModelName != "" {
+			modelByName[m.ModelName] = m
+			upstreamNames = append(upstreamNames, m.ModelName)
+		}
+	}
+
+	// 2) 本地已有模型
+	var locals []model.Model
+	if len(upstreamNames) > 0 {
+		_ = model.DB.Where("model_name IN ? AND sync_official <> 0", upstreamNames).Find(&locals).Error
+	}
+
+	// 本地 vendor 名称映射
+	vendorIdSet := make(map[int]struct{})
+	for _, m := range locals {
+		if m.VendorID != 0 {
+			vendorIdSet[m.VendorID] = struct{}{}
+		}
+	}
+	vendorIDs := make([]int, 0, len(vendorIdSet))
+	for id := range vendorIdSet {
+		vendorIDs = append(vendorIDs, id)
+	}
+	idToVendorName := make(map[int]string)
+	if len(vendorIDs) > 0 {
+		var dbVendors []model.Vendor
+		_ = model.DB.Where("id IN ?", vendorIDs).Find(&dbVendors).Error
+		for _, v := range dbVendors {
+			idToVendorName[v.Id] = v.Name
+		}
+	}
+
+	// 3) 缺失且上游存在的模型
+	missingList, _ := model.GetMissingModels()
+	var missing []string
+	for _, name := range missingList {
+		if _, ok := modelByName[name]; ok {
+			missing = append(missing, name)
+		}
+	}
+
+	// 4) 计算冲突字段
+	type conflictField struct {
+		Field    string      `json:"field"`
+		Local    interface{} `json:"local"`
+		Upstream interface{} `json:"upstream"`
+	}
+	type conflictItem struct {
+		ModelName string          `json:"model_name"`
+		Fields    []conflictField `json:"fields"`
+	}
+
+	var conflicts []conflictItem
+	for _, local := range locals {
+		up, ok := modelByName[local.ModelName]
+		if !ok {
+			continue
+		}
+		fields := make([]conflictField, 0, 6)
+		if strings.TrimSpace(local.Description) != strings.TrimSpace(up.Description) {
+			fields = append(fields, conflictField{Field: "description", Local: local.Description, Upstream: up.Description})
+		}
+		if strings.TrimSpace(local.Icon) != strings.TrimSpace(up.Icon) {
+			fields = append(fields, conflictField{Field: "icon", Local: local.Icon, Upstream: up.Icon})
+		}
+		if strings.TrimSpace(local.Tags) != strings.TrimSpace(up.Tags) {
+			fields = append(fields, conflictField{Field: "tags", Local: local.Tags, Upstream: up.Tags})
+		}
+		// vendor 对比使用名称
+		localVendor := idToVendorName[local.VendorID]
+		if strings.TrimSpace(localVendor) != strings.TrimSpace(up.VendorName) {
+			fields = append(fields, conflictField{Field: "vendor", Local: localVendor, Upstream: up.VendorName})
+		}
+		if local.NameRule != up.NameRule {
+			fields = append(fields, conflictField{Field: "name_rule", Local: local.NameRule, Upstream: up.NameRule})
+		}
+		if local.Status != chooseStatus(up.Status, local.Status) {
+			fields = append(fields, conflictField{Field: "status", Local: local.Status, Upstream: up.Status})
+		}
+		if len(fields) > 0 {
+			conflicts = append(conflicts, conflictItem{ModelName: local.ModelName, Fields: fields})
+		}
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"data": gin.H{
+			"missing":   missing,
+			"conflicts": conflicts,
+			"source": gin.H{
+				"locale":      locale,
+				"models_url":  modelsURL,
+				"vendors_url": vendorsURL,
+			},
+		},
+	})
+}

+ 4 - 5
controller/oidc.go

@@ -8,7 +8,6 @@ import (
 	"net/url"
 	"one-api/common"
 	"one-api/model"
-	"one-api/setting"
 	"one-api/setting/system_setting"
 	"strconv"
 	"strings"
@@ -45,7 +44,7 @@ func getOidcUserInfoByCode(code string) (*OidcUser, error) {
 	values.Set("client_secret", system_setting.GetOIDCSettings().ClientSecret)
 	values.Set("code", code)
 	values.Set("grant_type", "authorization_code")
-	values.Set("redirect_uri", fmt.Sprintf("%s/oauth/oidc", setting.ServerAddress))
+	values.Set("redirect_uri", fmt.Sprintf("%s/oauth/oidc", system_setting.ServerAddress))
 	formData := values.Encode()
 	req, err := http.NewRequest("POST", system_setting.GetOIDCSettings().TokenEndpoint, strings.NewReader(formData))
 	if err != nil {
@@ -69,7 +68,7 @@ func getOidcUserInfoByCode(code string) (*OidcUser, error) {
 	}
 
 	if oidcResponse.AccessToken == "" {
-		common.SysError("OIDC 获取 Token 失败,请检查设置!")
+		common.SysLog("OIDC 获取 Token 失败,请检查设置!")
 		return nil, errors.New("OIDC 获取 Token 失败,请检查设置!")
 	}
 
@@ -85,7 +84,7 @@ func getOidcUserInfoByCode(code string) (*OidcUser, error) {
 	}
 	defer res2.Body.Close()
 	if res2.StatusCode != http.StatusOK {
-		common.SysError("OIDC 获取用户信息失败!请检查设置!")
+		common.SysLog("OIDC 获取用户信息失败!请检查设置!")
 		return nil, errors.New("OIDC 获取用户信息失败!请检查设置!")
 	}
 
@@ -95,7 +94,7 @@ func getOidcUserInfoByCode(code string) (*OidcUser, error) {
 		return nil, err
 	}
 	if oidcUser.OpenID == "" || oidcUser.Email == "" {
-		common.SysError("OIDC 获取用户信息为空!请检查设置!")
+		common.SysLog("OIDC 获取用户信息为空!请检查设置!")
 		return nil, errors.New("OIDC 获取用户信息为空!请检查设置!")
 	}
 	return &oidcUser, nil

+ 51 - 8
controller/option.go

@@ -2,6 +2,7 @@ package controller
 
 import (
 	"encoding/json"
+	"fmt"
 	"net/http"
 	"one-api/common"
 	"one-api/model"
@@ -35,8 +36,13 @@ func GetOptions(c *gin.Context) {
 	return
 }
 
+type OptionUpdateRequest struct {
+	Key   string `json:"key"`
+	Value any    `json:"value"`
+}
+
 func UpdateOption(c *gin.Context) {
-	var option model.Option
+	var option OptionUpdateRequest
 	err := json.NewDecoder(c.Request.Body).Decode(&option)
 	if err != nil {
 		c.JSON(http.StatusBadRequest, gin.H{
@@ -45,6 +51,16 @@ func UpdateOption(c *gin.Context) {
 		})
 		return
 	}
+	switch option.Value.(type) {
+	case bool:
+		option.Value = common.Interface2String(option.Value.(bool))
+	case float64:
+		option.Value = common.Interface2String(option.Value.(float64))
+	case int:
+		option.Value = common.Interface2String(option.Value.(int))
+	default:
+		option.Value = fmt.Sprintf("%v", option.Value)
+	}
 	switch option.Key {
 	case "GitHubOAuthEnabled":
 		if option.Value == "true" && common.GitHubClientId == "" {
@@ -104,7 +120,7 @@ func UpdateOption(c *gin.Context) {
 			return
 		}
 	case "GroupRatio":
-		err = ratio_setting.CheckGroupRatio(option.Value)
+		err = ratio_setting.CheckGroupRatio(option.Value.(string))
 		if err != nil {
 			c.JSON(http.StatusOK, gin.H{
 				"success": false,
@@ -112,8 +128,35 @@ func UpdateOption(c *gin.Context) {
 			})
 			return
 		}
+	case "ImageRatio":
+		err = ratio_setting.UpdateImageRatioByJSONString(option.Value.(string))
+		if err != nil {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": "图片倍率设置失败: " + err.Error(),
+			})
+			return
+		}
+	case "AudioRatio":
+		err = ratio_setting.UpdateAudioRatioByJSONString(option.Value.(string))
+		if err != nil {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": "音频倍率设置失败: " + err.Error(),
+			})
+			return
+		}
+	case "AudioCompletionRatio":
+		err = ratio_setting.UpdateAudioCompletionRatioByJSONString(option.Value.(string))
+		if err != nil {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": "音频补全倍率设置失败: " + err.Error(),
+			})
+			return
+		}
 	case "ModelRequestRateLimitGroup":
-		err = setting.CheckModelRequestRateLimitGroup(option.Value)
+		err = setting.CheckModelRequestRateLimitGroup(option.Value.(string))
 		if err != nil {
 			c.JSON(http.StatusOK, gin.H{
 				"success": false,
@@ -122,7 +165,7 @@ func UpdateOption(c *gin.Context) {
 			return
 		}
 	case "console_setting.api_info":
-		err = console_setting.ValidateConsoleSettings(option.Value, "ApiInfo")
+		err = console_setting.ValidateConsoleSettings(option.Value.(string), "ApiInfo")
 		if err != nil {
 			c.JSON(http.StatusOK, gin.H{
 				"success": false,
@@ -131,7 +174,7 @@ func UpdateOption(c *gin.Context) {
 			return
 		}
 	case "console_setting.announcements":
-		err = console_setting.ValidateConsoleSettings(option.Value, "Announcements")
+		err = console_setting.ValidateConsoleSettings(option.Value.(string), "Announcements")
 		if err != nil {
 			c.JSON(http.StatusOK, gin.H{
 				"success": false,
@@ -140,7 +183,7 @@ func UpdateOption(c *gin.Context) {
 			return
 		}
 	case "console_setting.faq":
-		err = console_setting.ValidateConsoleSettings(option.Value, "FAQ")
+		err = console_setting.ValidateConsoleSettings(option.Value.(string), "FAQ")
 		if err != nil {
 			c.JSON(http.StatusOK, gin.H{
 				"success": false,
@@ -149,7 +192,7 @@ func UpdateOption(c *gin.Context) {
 			return
 		}
 	case "console_setting.uptime_kuma_groups":
-		err = console_setting.ValidateConsoleSettings(option.Value, "UptimeKumaGroups")
+		err = console_setting.ValidateConsoleSettings(option.Value.(string), "UptimeKumaGroups")
 		if err != nil {
 			c.JSON(http.StatusOK, gin.H{
 				"success": false,
@@ -158,7 +201,7 @@ func UpdateOption(c *gin.Context) {
 			return
 		}
 	}
-	err = model.UpdateOption(option.Key, option.Value)
+	err = model.UpdateOption(option.Key, option.Value.(string))
 	if err != nil {
 		common.ApiError(c, err)
 		return

+ 6 - 30
controller/playground.go

@@ -5,10 +5,8 @@ import (
 	"fmt"
 	"one-api/common"
 	"one-api/constant"
-	"one-api/dto"
 	"one-api/middleware"
 	"one-api/model"
-	"one-api/setting"
 	"one-api/types"
 	"time"
 
@@ -28,41 +26,19 @@ func Playground(c *gin.Context) {
 
 	useAccessToken := c.GetBool("use_access_token")
 	if useAccessToken {
-		newAPIError = types.NewError(errors.New("暂不支持使用 access token"), types.ErrorCodeAccessDenied)
+		newAPIError = types.NewError(errors.New("暂不支持使用 access token"), types.ErrorCodeAccessDenied, types.ErrOptionWithSkipRetry())
 		return
 	}
 
-	playgroundRequest := &dto.PlayGroundRequest{}
-	err := common.UnmarshalBodyReusable(c, playgroundRequest)
-	if err != nil {
-		newAPIError = types.NewError(err, types.ErrorCodeInvalidRequest)
-		return
-	}
-
-	if playgroundRequest.Model == "" {
-		newAPIError = types.NewError(errors.New("请选择模型"), types.ErrorCodeInvalidRequest)
-		return
-	}
-	c.Set("original_model", playgroundRequest.Model)
-	group := playgroundRequest.Group
-	userGroup := c.GetString("group")
-
-	if group == "" {
-		group = userGroup
-	} else {
-		if !setting.GroupInUserUsableGroups(group) && group != userGroup {
-			newAPIError = types.NewError(errors.New("无权访问该分组"), types.ErrorCodeAccessDenied)
-			return
-		}
-		c.Set("group", group)
-	}
+	group := c.GetString("group")
+	modelName := c.GetString("original_model")
 
 	userId := c.GetInt("id")
 
 	// Write user context to ensure acceptUnsetRatio is available
 	userCache, err := model.GetUserCache(userId)
 	if err != nil {
-		newAPIError = types.NewError(err, types.ErrorCodeQueryDataError)
+		newAPIError = types.NewError(err, types.ErrorCodeQueryDataError, types.ErrOptionWithSkipRetry())
 		return
 	}
 	userCache.WriteContext(c)
@@ -73,12 +49,12 @@ func Playground(c *gin.Context) {
 		Group:  group,
 	}
 	_ = middleware.SetupContextForToken(c, tempToken)
-	_, newAPIError = getChannel(c, group, playgroundRequest.Model, 0)
+	_, newAPIError = getChannel(c, group, modelName, 0)
 	if newAPIError != nil {
 		return
 	}
 	//middleware.SetupContextForSelectedChannel(c, channel, playgroundRequest.Model)
 	common.SetContextKey(c, constant.ContextKeyRequestStartTime, time.Now())
 
-	Relay(c)
+	Relay(c, types.RelayFormatOpenAI)
 }

+ 90 - 0
controller/prefill_group.go

@@ -0,0 +1,90 @@
+package controller
+
+import (
+	"strconv"
+
+	"one-api/common"
+	"one-api/model"
+
+	"github.com/gin-gonic/gin"
+)
+
+// GetPrefillGroups 获取预填组列表,可通过 ?type=xxx 过滤
+func GetPrefillGroups(c *gin.Context) {
+	groupType := c.Query("type")
+	groups, err := model.GetAllPrefillGroups(groupType)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+	common.ApiSuccess(c, groups)
+}
+
+// CreatePrefillGroup 创建新的预填组
+func CreatePrefillGroup(c *gin.Context) {
+	var g model.PrefillGroup
+	if err := c.ShouldBindJSON(&g); err != nil {
+		common.ApiError(c, err)
+		return
+	}
+	if g.Name == "" || g.Type == "" {
+		common.ApiErrorMsg(c, "组名称和类型不能为空")
+		return
+	}
+	// 创建前检查名称
+	if dup, err := model.IsPrefillGroupNameDuplicated(0, g.Name); err != nil {
+		common.ApiError(c, err)
+		return
+	} else if dup {
+		common.ApiErrorMsg(c, "组名称已存在")
+		return
+	}
+
+	if err := g.Insert(); err != nil {
+		common.ApiError(c, err)
+		return
+	}
+	common.ApiSuccess(c, &g)
+}
+
+// UpdatePrefillGroup 更新预填组
+func UpdatePrefillGroup(c *gin.Context) {
+	var g model.PrefillGroup
+	if err := c.ShouldBindJSON(&g); err != nil {
+		common.ApiError(c, err)
+		return
+	}
+	if g.Id == 0 {
+		common.ApiErrorMsg(c, "缺少组 ID")
+		return
+	}
+	// 名称冲突检查
+	if dup, err := model.IsPrefillGroupNameDuplicated(g.Id, g.Name); err != nil {
+		common.ApiError(c, err)
+		return
+	} else if dup {
+		common.ApiErrorMsg(c, "组名称已存在")
+		return
+	}
+
+	if err := g.Update(); err != nil {
+		common.ApiError(c, err)
+		return
+	}
+	common.ApiSuccess(c, &g)
+}
+
+// DeletePrefillGroup 删除预填组
+func DeletePrefillGroup(c *gin.Context) {
+	idStr := c.Param("id")
+	id, err := strconv.Atoi(idStr)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+	if err := model.DeletePrefillGroupByID(id); err != nil {
+		common.ApiError(c, err)
+		return
+	}
+	common.ApiSuccess(c, nil)
+}

+ 7 - 4
controller/pricing.go

@@ -39,10 +39,13 @@ func GetPricing(c *gin.Context) {
 	}
 
 	c.JSON(200, gin.H{
-		"success":      true,
-		"data":         pricing,
-		"group_ratio":  groupRatio,
-		"usable_group": usableGroup,
+		"success":            true,
+		"data":               pricing,
+		"vendors":            model.GetVendors(),
+		"group_ratio":        groupRatio,
+		"usable_group":       usableGroup,
+		"supported_endpoint": model.GetSupportedEndpointMap(),
+		"auto_groups":        setting.AutoGroups,
 	})
 }
 

+ 16 - 16
controller/ratio_config.go

@@ -1,24 +1,24 @@
 package controller
 
 import (
-    "net/http"
-    "one-api/setting/ratio_setting"
+	"net/http"
+	"one-api/setting/ratio_setting"
 
-    "github.com/gin-gonic/gin"
+	"github.com/gin-gonic/gin"
 )
 
 func GetRatioConfig(c *gin.Context) {
-    if !ratio_setting.IsExposeRatioEnabled() {
-        c.JSON(http.StatusForbidden, gin.H{
-            "success": false,
-            "message": "倍率配置接口未启用",
-        })
-        return
-    }
+	if !ratio_setting.IsExposeRatioEnabled() {
+		c.JSON(http.StatusForbidden, gin.H{
+			"success": false,
+			"message": "倍率配置接口未启用",
+		})
+		return
+	}
 
-    c.JSON(http.StatusOK, gin.H{
-        "success": true,
-        "message": "",
-        "data":    ratio_setting.GetExposedData(),
-    })
-} 
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "",
+		"data":    ratio_setting.GetExposedData(),
+	})
+}

+ 518 - 453
controller/ratio_sync.go

@@ -1,474 +1,539 @@
 package controller
 
 import (
-    "context"
-    "encoding/json"
-    "fmt"
-    "net/http"
-    "strings"
-    "sync"
-    "time"
-
-    "one-api/common"
-    "one-api/dto"
-    "one-api/model"
-    "one-api/setting/ratio_setting"
-
-    "github.com/gin-gonic/gin"
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net"
+	"net/http"
+	"one-api/logger"
+	"strings"
+	"sync"
+	"time"
+
+	"one-api/dto"
+	"one-api/model"
+	"one-api/setting/ratio_setting"
+
+	"github.com/gin-gonic/gin"
 )
 
 const (
-    defaultTimeoutSeconds  = 10
-    defaultEndpoint        = "/api/ratio_config"
-    maxConcurrentFetches   = 8
+	defaultTimeoutSeconds = 10
+	defaultEndpoint       = "/api/ratio_config"
+	maxConcurrentFetches  = 8
+	maxRatioConfigBytes   = 10 << 20 // 10MB
+	floatEpsilon          = 1e-9
 )
 
+func nearlyEqual(a, b float64) bool {
+	if a > b {
+		return a-b < floatEpsilon
+	}
+	return b-a < floatEpsilon
+}
+
+func valuesEqual(a, b interface{}) bool {
+	af, aok := a.(float64)
+	bf, bok := b.(float64)
+	if aok && bok {
+		return nearlyEqual(af, bf)
+	}
+	return a == b
+}
+
 var ratioTypes = []string{"model_ratio", "completion_ratio", "cache_ratio", "model_price"}
 
 type upstreamResult struct {
-    Name string                 `json:"name"`
-    Data map[string]any         `json:"data,omitempty"`
-    Err  string                 `json:"err,omitempty"`
+	Name string         `json:"name"`
+	Data map[string]any `json:"data,omitempty"`
+	Err  string         `json:"err,omitempty"`
 }
 
 func FetchUpstreamRatios(c *gin.Context) {
-    var req dto.UpstreamRequest
-    if err := c.ShouldBindJSON(&req); err != nil {
-        c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
-        return
-    }
-
-    if req.Timeout <= 0 {
-        req.Timeout = defaultTimeoutSeconds
-    }
-
-    var upstreams []dto.UpstreamDTO
-
-    if len(req.Upstreams) > 0 {
-        for _, u := range req.Upstreams {
-            if strings.HasPrefix(u.BaseURL, "http") {
-                if u.Endpoint == "" {
-                    u.Endpoint = defaultEndpoint
-                }
-                u.BaseURL = strings.TrimRight(u.BaseURL, "/")
-                upstreams = append(upstreams, u)
-            }
-        }
-    } else if len(req.ChannelIDs) > 0 {
-        intIds := make([]int, 0, len(req.ChannelIDs))
-        for _, id64 := range req.ChannelIDs {
-            intIds = append(intIds, int(id64))
-        }
-        dbChannels, err := model.GetChannelsByIds(intIds)
-        if err != nil {
-            common.LogError(c.Request.Context(), "failed to query channels: "+err.Error())
-            c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "查询渠道失败"})
-            return
-        }
-        for _, ch := range dbChannels {
-            if base := ch.GetBaseURL(); strings.HasPrefix(base, "http") {
-                upstreams = append(upstreams, dto.UpstreamDTO{
-                    ID:       ch.Id,
-                    Name:     ch.Name,
-                    BaseURL:  strings.TrimRight(base, "/"),
-                    Endpoint: "",
-                })
-            }
-        }
-    }
-
-    if len(upstreams) == 0 {
-        c.JSON(http.StatusOK, gin.H{"success": false, "message": "无有效上游渠道"})
-        return
-    }
-
-    var wg sync.WaitGroup
-    ch := make(chan upstreamResult, len(upstreams))
-
-    sem := make(chan struct{}, maxConcurrentFetches)
-
-    client := &http.Client{Transport: &http.Transport{MaxIdleConns: 100, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second}}
-
-    for _, chn := range upstreams {
-        wg.Add(1)
-        go func(chItem dto.UpstreamDTO) {
-            defer wg.Done()
-
-            sem <- struct{}{}
-            defer func() { <-sem }()
-
-            endpoint := chItem.Endpoint
-            if endpoint == "" {
-                endpoint = defaultEndpoint
-            } else if !strings.HasPrefix(endpoint, "/") {
-                endpoint = "/" + endpoint
-            }
-            fullURL := chItem.BaseURL + endpoint
-
-            uniqueName := chItem.Name
-            if chItem.ID != 0 {
-                uniqueName = fmt.Sprintf("%s(%d)", chItem.Name, chItem.ID)
-            }
-
-            ctx, cancel := context.WithTimeout(c.Request.Context(), time.Duration(req.Timeout)*time.Second)
-            defer cancel()
-
-            httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil)
-            if err != nil {
-                common.LogWarn(c.Request.Context(), "build request failed: "+err.Error())
-                ch <- upstreamResult{Name: uniqueName, Err: err.Error()}
-                return
-            }
-
-            resp, err := client.Do(httpReq)
-            if err != nil {
-                common.LogWarn(c.Request.Context(), "http error on "+chItem.Name+": "+err.Error())
-                ch <- upstreamResult{Name: uniqueName, Err: err.Error()}
-                return
-            }
-            defer resp.Body.Close()
-            if resp.StatusCode != http.StatusOK {
-                common.LogWarn(c.Request.Context(), "non-200 from "+chItem.Name+": "+resp.Status)
-                ch <- upstreamResult{Name: uniqueName, Err: resp.Status}
-                return
-            }
-            // 兼容两种上游接口格式:
-            //  type1: /api/ratio_config -> data 为 map[string]any,包含 model_ratio/completion_ratio/cache_ratio/model_price
-            //  type2: /api/pricing      -> data 为 []Pricing 列表,需要转换为与 type1 相同的 map 格式
-            var body struct {
-                Success bool            `json:"success"`
-                Data    json.RawMessage `json:"data"`
-                Message string          `json:"message"`
-            }
-
-            if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
-                common.LogWarn(c.Request.Context(), "json decode failed from "+chItem.Name+": "+err.Error())
-                ch <- upstreamResult{Name: uniqueName, Err: err.Error()}
-                return
-            }
-
-            if !body.Success {
-                ch <- upstreamResult{Name: uniqueName, Err: body.Message}
-                return
-            }
-
-            // 尝试按 type1 解析
-            var type1Data map[string]any
-            if err := json.Unmarshal(body.Data, &type1Data); err == nil {
-                // 如果包含至少一个 ratioTypes 字段,则认为是 type1
-                isType1 := false
-                for _, rt := range ratioTypes {
-                    if _, ok := type1Data[rt]; ok {
-                        isType1 = true
-                        break
-                    }
-                }
-                if isType1 {
-                    ch <- upstreamResult{Name: uniqueName, Data: type1Data}
-                    return
-                }
-            }
-
-            // 如果不是 type1,则尝试按 type2 (/api/pricing) 解析
-            var pricingItems []struct {
-                ModelName       string  `json:"model_name"`
-                QuotaType       int     `json:"quota_type"`
-                ModelRatio      float64 `json:"model_ratio"`
-                ModelPrice      float64 `json:"model_price"`
-                CompletionRatio float64 `json:"completion_ratio"`
-            }
-            if err := json.Unmarshal(body.Data, &pricingItems); err != nil {
-                common.LogWarn(c.Request.Context(), "unrecognized data format from "+chItem.Name+": "+err.Error())
-                ch <- upstreamResult{Name: uniqueName, Err: "无法解析上游返回数据"}
-                return
-            }
-
-            modelRatioMap := make(map[string]float64)
-            completionRatioMap := make(map[string]float64)
-            modelPriceMap := make(map[string]float64)
-
-            for _, item := range pricingItems {
-                if item.QuotaType == 1 {
-                    modelPriceMap[item.ModelName] = item.ModelPrice
-                } else {
-                    modelRatioMap[item.ModelName] = item.ModelRatio
-                    // completionRatio 可能为 0,此时也直接赋值,保持与上游一致
-                    completionRatioMap[item.ModelName] = item.CompletionRatio
-                }
-            }
-
-            converted := make(map[string]any)
-
-            if len(modelRatioMap) > 0 {
-                ratioAny := make(map[string]any, len(modelRatioMap))
-                for k, v := range modelRatioMap {
-                    ratioAny[k] = v
-                }
-                converted["model_ratio"] = ratioAny
-            }
-
-            if len(completionRatioMap) > 0 {
-                compAny := make(map[string]any, len(completionRatioMap))
-                for k, v := range completionRatioMap {
-                    compAny[k] = v
-                }
-                converted["completion_ratio"] = compAny
-            }
-
-            if len(modelPriceMap) > 0 {
-                priceAny := make(map[string]any, len(modelPriceMap))
-                for k, v := range modelPriceMap {
-                    priceAny[k] = v
-                }
-                converted["model_price"] = priceAny
-            }
-
-            ch <- upstreamResult{Name: uniqueName, Data: converted}
-        }(chn)
-    }
-
-    wg.Wait()
-    close(ch)
-
-    localData := ratio_setting.GetExposedData()
-
-    var testResults []dto.TestResult
-    var successfulChannels []struct {
-        name string
-        data map[string]any
-    }
-
-    for r := range ch {
-        if r.Err != "" {
-            testResults = append(testResults, dto.TestResult{
-                Name:   r.Name,
-                Status: "error",
-                Error:  r.Err,
-            })
-        } else {
-            testResults = append(testResults, dto.TestResult{
-                Name:   r.Name,
-                Status: "success",
-            })
-            successfulChannels = append(successfulChannels, struct {
-                name string
-                data map[string]any
-            }{name: r.Name, data: r.Data})
-        }
-    }
-
-    differences := buildDifferences(localData, successfulChannels)
-
-    c.JSON(http.StatusOK, gin.H{
-        "success": true,
-        "data": gin.H{
-            "differences":  differences,
-            "test_results": testResults,
-        },
-    })
+	var req dto.UpstreamRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
+		return
+	}
+
+	if req.Timeout <= 0 {
+		req.Timeout = defaultTimeoutSeconds
+	}
+
+	var upstreams []dto.UpstreamDTO
+
+	if len(req.Upstreams) > 0 {
+		for _, u := range req.Upstreams {
+			if strings.HasPrefix(u.BaseURL, "http") {
+				if u.Endpoint == "" {
+					u.Endpoint = defaultEndpoint
+				}
+				u.BaseURL = strings.TrimRight(u.BaseURL, "/")
+				upstreams = append(upstreams, u)
+			}
+		}
+	} else if len(req.ChannelIDs) > 0 {
+		intIds := make([]int, 0, len(req.ChannelIDs))
+		for _, id64 := range req.ChannelIDs {
+			intIds = append(intIds, int(id64))
+		}
+		dbChannels, err := model.GetChannelsByIds(intIds)
+		if err != nil {
+			logger.LogError(c.Request.Context(), "failed to query channels: "+err.Error())
+			c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "查询渠道失败"})
+			return
+		}
+		for _, ch := range dbChannels {
+			if base := ch.GetBaseURL(); strings.HasPrefix(base, "http") {
+				upstreams = append(upstreams, dto.UpstreamDTO{
+					ID:       ch.Id,
+					Name:     ch.Name,
+					BaseURL:  strings.TrimRight(base, "/"),
+					Endpoint: "",
+				})
+			}
+		}
+	}
+
+	if len(upstreams) == 0 {
+		c.JSON(http.StatusOK, gin.H{"success": false, "message": "无有效上游渠道"})
+		return
+	}
+
+	var wg sync.WaitGroup
+	ch := make(chan upstreamResult, len(upstreams))
+
+	sem := make(chan struct{}, maxConcurrentFetches)
+
+	dialer := &net.Dialer{Timeout: 10 * time.Second}
+	transport := &http.Transport{MaxIdleConns: 100, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second, ResponseHeaderTimeout: 10 * time.Second}
+	transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
+		host, _, err := net.SplitHostPort(addr)
+		if err != nil {
+			host = addr
+		}
+		// 对 github.io 优先尝试 IPv4,失败则回退 IPv6
+		if strings.HasSuffix(host, "github.io") {
+			if conn, err := dialer.DialContext(ctx, "tcp4", addr); err == nil {
+				return conn, nil
+			}
+			return dialer.DialContext(ctx, "tcp6", addr)
+		}
+		return dialer.DialContext(ctx, network, addr)
+	}
+	client := &http.Client{Transport: transport}
+
+	for _, chn := range upstreams {
+		wg.Add(1)
+		go func(chItem dto.UpstreamDTO) {
+			defer wg.Done()
+
+			sem <- struct{}{}
+			defer func() { <-sem }()
+
+			endpoint := chItem.Endpoint
+			var fullURL string
+			if strings.HasPrefix(endpoint, "http://") || strings.HasPrefix(endpoint, "https://") {
+				fullURL = endpoint
+			} else {
+				if endpoint == "" {
+					endpoint = defaultEndpoint
+				} else if !strings.HasPrefix(endpoint, "/") {
+					endpoint = "/" + endpoint
+				}
+				fullURL = chItem.BaseURL + endpoint
+			}
+
+			uniqueName := chItem.Name
+			if chItem.ID != 0 {
+				uniqueName = fmt.Sprintf("%s(%d)", chItem.Name, chItem.ID)
+			}
+
+			ctx, cancel := context.WithTimeout(c.Request.Context(), time.Duration(req.Timeout)*time.Second)
+			defer cancel()
+
+			httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil)
+			if err != nil {
+				logger.LogWarn(c.Request.Context(), "build request failed: "+err.Error())
+				ch <- upstreamResult{Name: uniqueName, Err: err.Error()}
+				return
+			}
+
+			// 简单重试:最多 3 次,指数退避
+			var resp *http.Response
+			var lastErr error
+			for attempt := 0; attempt < 3; attempt++ {
+				resp, lastErr = client.Do(httpReq)
+				if lastErr == nil {
+					break
+				}
+				time.Sleep(time.Duration(200*(1<<attempt)) * time.Millisecond)
+			}
+			if lastErr != nil {
+				logger.LogWarn(c.Request.Context(), "http error on "+chItem.Name+": "+lastErr.Error())
+				ch <- upstreamResult{Name: uniqueName, Err: lastErr.Error()}
+				return
+			}
+			defer resp.Body.Close()
+			if resp.StatusCode != http.StatusOK {
+				logger.LogWarn(c.Request.Context(), "non-200 from "+chItem.Name+": "+resp.Status)
+				ch <- upstreamResult{Name: uniqueName, Err: resp.Status}
+				return
+			}
+
+			// Content-Type 和响应体大小校验
+			if ct := resp.Header.Get("Content-Type"); ct != "" && !strings.Contains(strings.ToLower(ct), "application/json") {
+				logger.LogWarn(c.Request.Context(), "unexpected content-type from "+chItem.Name+": "+ct)
+			}
+			limited := io.LimitReader(resp.Body, maxRatioConfigBytes)
+			// 兼容两种上游接口格式:
+			//  type1: /api/ratio_config -> data 为 map[string]any,包含 model_ratio/completion_ratio/cache_ratio/model_price
+			//  type2: /api/pricing      -> data 为 []Pricing 列表,需要转换为与 type1 相同的 map 格式
+			var body struct {
+				Success bool            `json:"success"`
+				Data    json.RawMessage `json:"data"`
+				Message string          `json:"message"`
+			}
+
+			if err := json.NewDecoder(limited).Decode(&body); err != nil {
+				logger.LogWarn(c.Request.Context(), "json decode failed from "+chItem.Name+": "+err.Error())
+				ch <- upstreamResult{Name: uniqueName, Err: err.Error()}
+				return
+			}
+
+			if !body.Success {
+				ch <- upstreamResult{Name: uniqueName, Err: body.Message}
+				return
+			}
+
+			// 若 Data 为空,将继续按 type1 尝试解析(与多数静态 ratio_config 兼容)
+
+			// 尝试按 type1 解析
+			var type1Data map[string]any
+			if err := json.Unmarshal(body.Data, &type1Data); err == nil {
+				// 如果包含至少一个 ratioTypes 字段,则认为是 type1
+				isType1 := false
+				for _, rt := range ratioTypes {
+					if _, ok := type1Data[rt]; ok {
+						isType1 = true
+						break
+					}
+				}
+				if isType1 {
+					ch <- upstreamResult{Name: uniqueName, Data: type1Data}
+					return
+				}
+			}
+
+			// 如果不是 type1,则尝试按 type2 (/api/pricing) 解析
+			var pricingItems []struct {
+				ModelName       string  `json:"model_name"`
+				QuotaType       int     `json:"quota_type"`
+				ModelRatio      float64 `json:"model_ratio"`
+				ModelPrice      float64 `json:"model_price"`
+				CompletionRatio float64 `json:"completion_ratio"`
+			}
+			if err := json.Unmarshal(body.Data, &pricingItems); err != nil {
+				logger.LogWarn(c.Request.Context(), "unrecognized data format from "+chItem.Name+": "+err.Error())
+				ch <- upstreamResult{Name: uniqueName, Err: "无法解析上游返回数据"}
+				return
+			}
+
+			modelRatioMap := make(map[string]float64)
+			completionRatioMap := make(map[string]float64)
+			modelPriceMap := make(map[string]float64)
+
+			for _, item := range pricingItems {
+				if item.QuotaType == 1 {
+					modelPriceMap[item.ModelName] = item.ModelPrice
+				} else {
+					modelRatioMap[item.ModelName] = item.ModelRatio
+					// completionRatio 可能为 0,此时也直接赋值,保持与上游一致
+					completionRatioMap[item.ModelName] = item.CompletionRatio
+				}
+			}
+
+			converted := make(map[string]any)
+
+			if len(modelRatioMap) > 0 {
+				ratioAny := make(map[string]any, len(modelRatioMap))
+				for k, v := range modelRatioMap {
+					ratioAny[k] = v
+				}
+				converted["model_ratio"] = ratioAny
+			}
+
+			if len(completionRatioMap) > 0 {
+				compAny := make(map[string]any, len(completionRatioMap))
+				for k, v := range completionRatioMap {
+					compAny[k] = v
+				}
+				converted["completion_ratio"] = compAny
+			}
+
+			if len(modelPriceMap) > 0 {
+				priceAny := make(map[string]any, len(modelPriceMap))
+				for k, v := range modelPriceMap {
+					priceAny[k] = v
+				}
+				converted["model_price"] = priceAny
+			}
+
+			ch <- upstreamResult{Name: uniqueName, Data: converted}
+		}(chn)
+	}
+
+	wg.Wait()
+	close(ch)
+
+	localData := ratio_setting.GetExposedData()
+
+	var testResults []dto.TestResult
+	var successfulChannels []struct {
+		name string
+		data map[string]any
+	}
+
+	for r := range ch {
+		if r.Err != "" {
+			testResults = append(testResults, dto.TestResult{
+				Name:   r.Name,
+				Status: "error",
+				Error:  r.Err,
+			})
+		} else {
+			testResults = append(testResults, dto.TestResult{
+				Name:   r.Name,
+				Status: "success",
+			})
+			successfulChannels = append(successfulChannels, struct {
+				name string
+				data map[string]any
+			}{name: r.Name, data: r.Data})
+		}
+	}
+
+	differences := buildDifferences(localData, successfulChannels)
+
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"data": gin.H{
+			"differences":  differences,
+			"test_results": testResults,
+		},
+	})
 }
 
 func buildDifferences(localData map[string]any, successfulChannels []struct {
-    name string
-    data map[string]any
+	name string
+	data map[string]any
 }) map[string]map[string]dto.DifferenceItem {
-    differences := make(map[string]map[string]dto.DifferenceItem)
-
-    allModels := make(map[string]struct{})
-    
-    for _, ratioType := range ratioTypes {
-        if localRatioAny, ok := localData[ratioType]; ok {
-            if localRatio, ok := localRatioAny.(map[string]float64); ok {
-                for modelName := range localRatio {
-                    allModels[modelName] = struct{}{}
-                }
-            }
-        }
-    }
-    
-    for _, channel := range successfulChannels {
-        for _, ratioType := range ratioTypes {
-            if upstreamRatio, ok := channel.data[ratioType].(map[string]any); ok {
-                for modelName := range upstreamRatio {
-                    allModels[modelName] = struct{}{}
-                }
-            }
-        }
-    }
-
-    confidenceMap := make(map[string]map[string]bool)
-    
-    // 预处理阶段:检查pricing接口的可信度
-    for _, channel := range successfulChannels {
-        confidenceMap[channel.name] = make(map[string]bool)
-        
-        modelRatios, hasModelRatio := channel.data["model_ratio"].(map[string]any)
-        completionRatios, hasCompletionRatio := channel.data["completion_ratio"].(map[string]any)
-        
-        if hasModelRatio && hasCompletionRatio {
-            // 遍历所有模型,检查是否满足不可信条件
-            for modelName := range allModels {
-                // 默认为可信
-                confidenceMap[channel.name][modelName] = true
-                
-                // 检查是否满足不可信条件:model_ratio为37.5且completion_ratio为1
-                if modelRatioVal, ok := modelRatios[modelName]; ok {
-                    if completionRatioVal, ok := completionRatios[modelName]; ok {
-                        // 转换为float64进行比较
-                        if modelRatioFloat, ok := modelRatioVal.(float64); ok {
-                            if completionRatioFloat, ok := completionRatioVal.(float64); ok {
-                                if modelRatioFloat == 37.5 && completionRatioFloat == 1.0 {
-                                    confidenceMap[channel.name][modelName] = false
-                                }
-                            }
-                        }
-                    }
-                }
-            }
-        } else {
-            // 如果不是从pricing接口获取的数据,则全部标记为可信
-            for modelName := range allModels {
-                confidenceMap[channel.name][modelName] = true
-            }
-        }
-    }
-
-    for modelName := range allModels {
-        for _, ratioType := range ratioTypes {
-            var localValue interface{} = nil
-            if localRatioAny, ok := localData[ratioType]; ok {
-                if localRatio, ok := localRatioAny.(map[string]float64); ok {
-                    if val, exists := localRatio[modelName]; exists {
-                        localValue = val
-                    }
-                }
-            }
-
-            upstreamValues := make(map[string]interface{})
-            confidenceValues := make(map[string]bool)
-            hasUpstreamValue := false
-            hasDifference := false
-
-            for _, channel := range successfulChannels {
-                var upstreamValue interface{} = nil
-                
-                if upstreamRatio, ok := channel.data[ratioType].(map[string]any); ok {
-                    if val, exists := upstreamRatio[modelName]; exists {
-                        upstreamValue = val
-                        hasUpstreamValue = true
-                        
-                        if localValue != nil && localValue != val {
-                            hasDifference = true
-                        } else if localValue == val {
-                            upstreamValue = "same"
-                        }
-                    }
-                }
-                if upstreamValue == nil && localValue == nil {
-                    upstreamValue = "same"
-                }
-                
-                if localValue == nil && upstreamValue != nil && upstreamValue != "same" {
-                    hasDifference = true
-                }
-                
-                upstreamValues[channel.name] = upstreamValue
-                
-                confidenceValues[channel.name] = confidenceMap[channel.name][modelName]
-            }
-
-            shouldInclude := false
-            
-            if localValue != nil {
-                if hasDifference {
-                    shouldInclude = true
-                }
-            } else {
-                if hasUpstreamValue {
-                    shouldInclude = true
-                }
-            }
-
-            if shouldInclude {
-                if differences[modelName] == nil {
-                    differences[modelName] = make(map[string]dto.DifferenceItem)
-                }
-                differences[modelName][ratioType] = dto.DifferenceItem{
-                    Current:   localValue,
-                    Upstreams: upstreamValues,
-                    Confidence: confidenceValues,
-                }
-            }
-        }
-    }
-
-    channelHasDiff := make(map[string]bool)
-    for _, ratioMap := range differences {
-        for _, item := range ratioMap {
-            for chName, val := range item.Upstreams {
-                if val != nil && val != "same" {
-                    channelHasDiff[chName] = true
-                }
-            }
-        }
-    }
-
-    for modelName, ratioMap := range differences {
-        for ratioType, item := range ratioMap {
-            for chName := range item.Upstreams {
-                if !channelHasDiff[chName] {
-                    delete(item.Upstreams, chName)
-                    delete(item.Confidence, chName)
-                }
-            }
-
-            allSame := true
-            for _, v := range item.Upstreams {
-                if v != "same" {
-                    allSame = false
-                    break
-                }
-            }
-            if len(item.Upstreams) == 0 || allSame {
-                delete(ratioMap, ratioType)
-            } else {
-                differences[modelName][ratioType] = item
-            }
-        }
-
-        if len(ratioMap) == 0 {
-            delete(differences, modelName)
-        }
-    }
-
-    return differences
+	differences := make(map[string]map[string]dto.DifferenceItem)
+
+	allModels := make(map[string]struct{})
+
+	for _, ratioType := range ratioTypes {
+		if localRatioAny, ok := localData[ratioType]; ok {
+			if localRatio, ok := localRatioAny.(map[string]float64); ok {
+				for modelName := range localRatio {
+					allModels[modelName] = struct{}{}
+				}
+			}
+		}
+	}
+
+	for _, channel := range successfulChannels {
+		for _, ratioType := range ratioTypes {
+			if upstreamRatio, ok := channel.data[ratioType].(map[string]any); ok {
+				for modelName := range upstreamRatio {
+					allModels[modelName] = struct{}{}
+				}
+			}
+		}
+	}
+
+	confidenceMap := make(map[string]map[string]bool)
+
+	// 预处理阶段:检查pricing接口的可信度
+	for _, channel := range successfulChannels {
+		confidenceMap[channel.name] = make(map[string]bool)
+
+		modelRatios, hasModelRatio := channel.data["model_ratio"].(map[string]any)
+		completionRatios, hasCompletionRatio := channel.data["completion_ratio"].(map[string]any)
+
+		if hasModelRatio && hasCompletionRatio {
+			// 遍历所有模型,检查是否满足不可信条件
+			for modelName := range allModels {
+				// 默认为可信
+				confidenceMap[channel.name][modelName] = true
+
+				// 检查是否满足不可信条件:model_ratio为37.5且completion_ratio为1
+				if modelRatioVal, ok := modelRatios[modelName]; ok {
+					if completionRatioVal, ok := completionRatios[modelName]; ok {
+						// 转换为float64进行比较
+						if modelRatioFloat, ok := modelRatioVal.(float64); ok {
+							if completionRatioFloat, ok := completionRatioVal.(float64); ok {
+								if modelRatioFloat == 37.5 && completionRatioFloat == 1.0 {
+									confidenceMap[channel.name][modelName] = false
+								}
+							}
+						}
+					}
+				}
+			}
+		} else {
+			// 如果不是从pricing接口获取的数据,则全部标记为可信
+			for modelName := range allModels {
+				confidenceMap[channel.name][modelName] = true
+			}
+		}
+	}
+
+	for modelName := range allModels {
+		for _, ratioType := range ratioTypes {
+			var localValue interface{} = nil
+			if localRatioAny, ok := localData[ratioType]; ok {
+				if localRatio, ok := localRatioAny.(map[string]float64); ok {
+					if val, exists := localRatio[modelName]; exists {
+						localValue = val
+					}
+				}
+			}
+
+			upstreamValues := make(map[string]interface{})
+			confidenceValues := make(map[string]bool)
+			hasUpstreamValue := false
+			hasDifference := false
+
+			for _, channel := range successfulChannels {
+				var upstreamValue interface{} = nil
+
+				if upstreamRatio, ok := channel.data[ratioType].(map[string]any); ok {
+					if val, exists := upstreamRatio[modelName]; exists {
+						upstreamValue = val
+						hasUpstreamValue = true
+
+						if localValue != nil && !valuesEqual(localValue, val) {
+							hasDifference = true
+						} else if valuesEqual(localValue, val) {
+							upstreamValue = "same"
+						}
+					}
+				}
+				if upstreamValue == nil && localValue == nil {
+					upstreamValue = "same"
+				}
+
+				if localValue == nil && upstreamValue != nil && upstreamValue != "same" {
+					hasDifference = true
+				}
+
+				upstreamValues[channel.name] = upstreamValue
+
+				confidenceValues[channel.name] = confidenceMap[channel.name][modelName]
+			}
+
+			shouldInclude := false
+
+			if localValue != nil {
+				if hasDifference {
+					shouldInclude = true
+				}
+			} else {
+				if hasUpstreamValue {
+					shouldInclude = true
+				}
+			}
+
+			if shouldInclude {
+				if differences[modelName] == nil {
+					differences[modelName] = make(map[string]dto.DifferenceItem)
+				}
+				differences[modelName][ratioType] = dto.DifferenceItem{
+					Current:    localValue,
+					Upstreams:  upstreamValues,
+					Confidence: confidenceValues,
+				}
+			}
+		}
+	}
+
+	channelHasDiff := make(map[string]bool)
+	for _, ratioMap := range differences {
+		for _, item := range ratioMap {
+			for chName, val := range item.Upstreams {
+				if val != nil && val != "same" {
+					channelHasDiff[chName] = true
+				}
+			}
+		}
+	}
+
+	for modelName, ratioMap := range differences {
+		for ratioType, item := range ratioMap {
+			for chName := range item.Upstreams {
+				if !channelHasDiff[chName] {
+					delete(item.Upstreams, chName)
+					delete(item.Confidence, chName)
+				}
+			}
+
+			allSame := true
+			for _, v := range item.Upstreams {
+				if v != "same" {
+					allSame = false
+					break
+				}
+			}
+			if len(item.Upstreams) == 0 || allSame {
+				delete(ratioMap, ratioType)
+			} else {
+				differences[modelName][ratioType] = item
+			}
+		}
+
+		if len(ratioMap) == 0 {
+			delete(differences, modelName)
+		}
+	}
+
+	return differences
 }
 
 func GetSyncableChannels(c *gin.Context) {
-    channels, err := model.GetAllChannels(0, 0, true, false)
-    if err != nil {
-        c.JSON(http.StatusOK, gin.H{
-            "success": false,
-            "message": err.Error(),
-        })
-        return
-    }
-
-    var syncableChannels []dto.SyncableChannel
-    for _, channel := range channels {
-        if channel.GetBaseURL() != "" {
-            syncableChannels = append(syncableChannels, dto.SyncableChannel{
-                ID:      channel.Id,
-                Name:    channel.Name,
-                BaseURL: channel.GetBaseURL(),
-                Status:  channel.Status,
-            })
-        }
-    }
-
-    c.JSON(http.StatusOK, gin.H{
-        "success": true,
-        "message": "",
-        "data":    syncableChannels,
-    })
-} 
+	channels, err := model.GetAllChannels(0, 0, true, false)
+	if err != nil {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": err.Error(),
+		})
+		return
+	}
+
+	var syncableChannels []dto.SyncableChannel
+	for _, channel := range channels {
+		if channel.GetBaseURL() != "" {
+			syncableChannels = append(syncableChannels, dto.SyncableChannel{
+				ID:      channel.Id,
+				Name:    channel.Name,
+				BaseURL: channel.GetBaseURL(),
+				Status:  channel.Status,
+			})
+		}
+	}
+
+	syncableChannels = append(syncableChannels, dto.SyncableChannel{
+		ID:      -100,
+		Name:    "官方倍率预设",
+		BaseURL: "https://basellm.github.io",
+		Status:  1,
+	})
+
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "",
+		"data":    syncableChannels,
+	})
+}

+ 2 - 1
controller/redemption.go

@@ -6,6 +6,7 @@ import (
 	"one-api/common"
 	"one-api/model"
 	"strconv"
+	"unicode/utf8"
 
 	"github.com/gin-gonic/gin"
 )
@@ -63,7 +64,7 @@ func AddRedemption(c *gin.Context) {
 		common.ApiError(c, err)
 		return
 	}
-	if len(redemption.Name) == 0 || len(redemption.Name) > 20 {
+	if utf8.RuneCountInString(redemption.Name) == 0 || utf8.RuneCountInString(redemption.Name) > 20 {
 		c.JSON(http.StatusOK, gin.H{
 			"success": false,
 			"message": "兑换码名称长度必须在1-20之间",

+ 187 - 187
controller/relay.go

@@ -2,239 +2,201 @@ package controller
 
 import (
 	"bytes"
-	"errors"
 	"fmt"
 	"io"
 	"log"
 	"net/http"
 	"one-api/common"
 	"one-api/constant"
-	constant2 "one-api/constant"
 	"one-api/dto"
+	"one-api/logger"
 	"one-api/middleware"
 	"one-api/model"
 	"one-api/relay"
+	relaycommon "one-api/relay/common"
 	relayconstant "one-api/relay/constant"
 	"one-api/relay/helper"
 	"one-api/service"
+	"one-api/setting"
 	"one-api/types"
 	"strings"
 
+	"github.com/bytedance/gopkg/util/gopool"
+
 	"github.com/gin-gonic/gin"
 	"github.com/gorilla/websocket"
 )
 
-func relayHandler(c *gin.Context, relayMode int) *types.NewAPIError {
+func relayHandler(c *gin.Context, info *relaycommon.RelayInfo) *types.NewAPIError {
 	var err *types.NewAPIError
-	switch relayMode {
+	switch info.RelayMode {
 	case relayconstant.RelayModeImagesGenerations, relayconstant.RelayModeImagesEdits:
-		err = relay.ImageHelper(c)
+		err = relay.ImageHelper(c, info)
 	case relayconstant.RelayModeAudioSpeech:
 		fallthrough
 	case relayconstant.RelayModeAudioTranslation:
 		fallthrough
 	case relayconstant.RelayModeAudioTranscription:
-		err = relay.AudioHelper(c)
+		err = relay.AudioHelper(c, info)
 	case relayconstant.RelayModeRerank:
-		err = relay.RerankHelper(c, relayMode)
+		err = relay.RerankHelper(c, info)
 	case relayconstant.RelayModeEmbeddings:
-		err = relay.EmbeddingHelper(c)
+		err = relay.EmbeddingHelper(c, info)
 	case relayconstant.RelayModeResponses:
-		err = relay.ResponsesHelper(c)
-	case relayconstant.RelayModeGemini:
-		err = relay.GeminiHelper(c)
+		err = relay.ResponsesHelper(c, info)
 	default:
-		err = relay.TextHelper(c)
+		err = relay.TextHelper(c, info)
 	}
+	return err
+}
 
-	if constant2.ErrorLogEnabled && err != nil {
-		// 保存错误日志到mysql中
-		userId := c.GetInt("id")
-		tokenName := c.GetString("token_name")
-		modelName := c.GetString("original_model")
-		tokenId := c.GetInt("token_id")
-		userGroup := c.GetString("group")
-		channelId := c.GetInt("channel_id")
-		other := make(map[string]interface{})
-		other["error_type"] = err.ErrorType
-		other["error_code"] = err.GetErrorCode()
-		other["status_code"] = err.StatusCode
-		other["channel_id"] = channelId
-		other["channel_name"] = c.GetString("channel_name")
-		other["channel_type"] = c.GetInt("channel_type")
-
-		model.RecordErrorLog(c, userId, channelId, modelName, tokenName, err.Error(), tokenId, 0, false, userGroup, other)
+func geminiRelayHandler(c *gin.Context, info *relaycommon.RelayInfo) *types.NewAPIError {
+	var err *types.NewAPIError
+	if strings.Contains(c.Request.URL.Path, "embed") {
+		err = relay.GeminiEmbeddingHandler(c, info)
+	} else {
+		err = relay.GeminiHelper(c, info)
 	}
-
 	return err
 }
 
-func Relay(c *gin.Context) {
-	relayMode := relayconstant.Path2RelayMode(c.Request.URL.Path)
-	requestId := c.GetString(common.RequestIdKey)
-	group := c.GetString("group")
-	originalModel := c.GetString("original_model")
-	var newAPIError *types.NewAPIError
+func Relay(c *gin.Context, relayFormat types.RelayFormat) {
 
-	for i := 0; i <= common.RetryTimes; i++ {
-		channel, err := getChannel(c, group, originalModel, i)
-		if err != nil {
-			common.LogError(c, err.Error())
-			newAPIError = err
-			break
-		}
+	requestId := c.GetString(common.RequestIdKey)
+	group := common.GetContextKeyString(c, constant.ContextKeyUsingGroup)
+	originalModel := common.GetContextKeyString(c, constant.ContextKeyOriginalModel)
 
-		newAPIError = relayRequest(c, relayMode, channel)
+	var (
+		newAPIError *types.NewAPIError
+		ws          *websocket.Conn
+	)
 
-		if newAPIError == nil {
-			return // 成功处理请求,直接返回
+	if relayFormat == types.RelayFormatOpenAIRealtime {
+		var err error
+		ws, err = upgrader.Upgrade(c.Writer, c.Request, nil)
+		if err != nil {
+			helper.WssError(c, ws, types.NewError(err, types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry()).ToOpenAIError())
+			return
 		}
+		defer ws.Close()
+	}
 
-		go processChannelError(c, *types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, common.GetContextKeyString(c, constant.ContextKeyChannelKey), channel.GetAutoBan()), newAPIError)
-
-		if !shouldRetry(c, newAPIError, common.RetryTimes-i) {
-			break
+	defer func() {
+		if newAPIError != nil {
+			newAPIError.SetMessage(common.MessageWithRequestId(newAPIError.Error(), requestId))
+			switch relayFormat {
+			case types.RelayFormatOpenAIRealtime:
+				helper.WssError(c, ws, newAPIError.ToOpenAIError())
+			case types.RelayFormatClaude:
+				c.JSON(newAPIError.StatusCode, gin.H{
+					"type":  "error",
+					"error": newAPIError.ToClaudeError(),
+				})
+			default:
+				c.JSON(newAPIError.StatusCode, gin.H{
+					"error": newAPIError.ToOpenAIError(),
+				})
+			}
 		}
-	}
-	useChannel := c.GetStringSlice("use_channel")
-	if len(useChannel) > 1 {
-		retryLogStr := fmt.Sprintf("重试:%s", strings.Trim(strings.Join(strings.Fields(fmt.Sprint(useChannel)), "->"), "[]"))
-		common.LogInfo(c, retryLogStr)
-	}
+	}()
 
-	if newAPIError != nil {
-		//if newAPIError.StatusCode == http.StatusTooManyRequests {
-		//	common.LogError(c, fmt.Sprintf("origin 429 error: %s", newAPIError.Error()))
-		//	newAPIError.SetMessage("当前分组上游负载已饱和,请稍后再试")
-		//}
-		newAPIError.SetMessage(common.MessageWithRequestId(newAPIError.Error(), requestId))
-		c.JSON(newAPIError.StatusCode, gin.H{
-			"error": newAPIError.ToOpenAIError(),
-		})
+	request, err := helper.GetAndValidateRequest(c, relayFormat)
+	if err != nil {
+		newAPIError = types.NewError(err, types.ErrorCodeInvalidRequest)
+		return
 	}
-}
-
-var upgrader = websocket.Upgrader{
-	Subprotocols: []string{"realtime"}, // WS 握手支持的协议,如果有使用 Sec-WebSocket-Protocol,则必须在此声明对应的 Protocol TODO add other protocol
-	CheckOrigin: func(r *http.Request) bool {
-		return true // 允许跨域
-	},
-}
-
-func WssRelay(c *gin.Context) {
-	// 将 HTTP 连接升级为 WebSocket 连接
-
-	ws, err := upgrader.Upgrade(c.Writer, c.Request, nil)
-	defer ws.Close()
 
+	relayInfo, err := relaycommon.GenRelayInfo(c, relayFormat, request, ws)
 	if err != nil {
-		helper.WssError(c, ws, types.NewError(err, types.ErrorCodeGetChannelFailed).ToOpenAIError())
+		newAPIError = types.NewError(err, types.ErrorCodeGenRelayInfoFailed)
 		return
 	}
 
-	relayMode := relayconstant.Path2RelayMode(c.Request.URL.Path)
-	requestId := c.GetString(common.RequestIdKey)
-	group := c.GetString("group")
-	//wss://api.openai.com/v1/realtime?model=gpt-4o-realtime-preview-2024-10-01
-	originalModel := c.GetString("original_model")
-	var newAPIError *types.NewAPIError
+	meta := request.GetTokenCountMeta()
 
-	for i := 0; i <= common.RetryTimes; i++ {
-		channel, err := getChannel(c, group, originalModel, i)
-		if err != nil {
-			common.LogError(c, err.Error())
-			newAPIError = err
-			break
+	if setting.ShouldCheckPromptSensitive() {
+		contains, words := service.CheckSensitiveText(meta.CombineText)
+		if contains {
+			logger.LogWarn(c, fmt.Sprintf("user sensitive words detected: %s", strings.Join(words, ", ")))
+			newAPIError = types.NewError(err, types.ErrorCodeSensitiveWordsDetected)
+			return
 		}
+	}
 
-		newAPIError = wssRequest(c, ws, relayMode, channel)
-
-		if newAPIError == nil {
-			return // 成功处理请求,直接返回
-		}
+	tokens, err := service.CountRequestToken(c, meta, relayInfo)
+	if err != nil {
+		newAPIError = types.NewError(err, types.ErrorCodeCountTokenFailed)
+		return
+	}
 
-		go processChannelError(c, *types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, common.GetContextKeyString(c, constant.ContextKeyChannelKey), channel.GetAutoBan()), newAPIError)
+	relayInfo.SetPromptTokens(tokens)
 
-		if !shouldRetry(c, newAPIError, common.RetryTimes-i) {
-			break
-		}
-	}
-	useChannel := c.GetStringSlice("use_channel")
-	if len(useChannel) > 1 {
-		retryLogStr := fmt.Sprintf("重试:%s", strings.Trim(strings.Join(strings.Fields(fmt.Sprint(useChannel)), "->"), "[]"))
-		common.LogInfo(c, retryLogStr)
+	priceData, err := helper.ModelPriceHelper(c, relayInfo, tokens, meta)
+	if err != nil {
+		newAPIError = types.NewError(err, types.ErrorCodeModelPriceError)
+		return
 	}
 
+	// common.SetContextKey(c, constant.ContextKeyTokenCountMeta, meta)
+
+	newAPIError = service.PreConsumeQuota(c, priceData.ShouldPreConsumedQuota, relayInfo)
 	if newAPIError != nil {
-		//if newAPIError.StatusCode == http.StatusTooManyRequests {
-		//	newAPIError.SetMessage("当前分组上游负载已饱和,请稍后再试")
-		//}
-		newAPIError.SetMessage(common.MessageWithRequestId(newAPIError.Error(), requestId))
-		helper.WssError(c, ws, newAPIError.ToOpenAIError())
+		return
 	}
-}
 
-func RelayClaude(c *gin.Context) {
-	//relayMode := constant.Path2RelayMode(c.Request.URL.Path)
-	requestId := c.GetString(common.RequestIdKey)
-	group := c.GetString("group")
-	originalModel := c.GetString("original_model")
-	var newAPIError *types.NewAPIError
+	defer func() {
+		// Only return quota if downstream failed and quota was actually pre-consumed
+		if newAPIError != nil && relayInfo.FinalPreConsumedQuota != 0 {
+			service.ReturnPreConsumedQuota(c, relayInfo)
+		}
+	}()
 
 	for i := 0; i <= common.RetryTimes; i++ {
 		channel, err := getChannel(c, group, originalModel, i)
 		if err != nil {
-			common.LogError(c, err.Error())
+			logger.LogError(c, err.Error())
 			newAPIError = err
 			break
 		}
 
-		newAPIError = claudeRequest(c, channel)
+		addUsedChannel(c, channel.Id)
+		requestBody, _ := common.GetRequestBody(c)
+		c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody))
+
+		switch relayFormat {
+		case types.RelayFormatOpenAIRealtime:
+			newAPIError = relay.WssHelper(c, relayInfo)
+		case types.RelayFormatClaude:
+			newAPIError = relay.ClaudeHelper(c, relayInfo)
+		case types.RelayFormatGemini:
+			newAPIError = geminiRelayHandler(c, relayInfo)
+		default:
+			newAPIError = relayHandler(c, relayInfo)
+		}
 
 		if newAPIError == nil {
-			return // 成功处理请求,直接返回
+			return
 		}
 
-		go processChannelError(c, *types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, common.GetContextKeyString(c, constant.ContextKeyChannelKey), channel.GetAutoBan()), newAPIError)
+		processChannelError(c, *types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, common.GetContextKeyString(c, constant.ContextKeyChannelKey), channel.GetAutoBan()), newAPIError)
 
 		if !shouldRetry(c, newAPIError, common.RetryTimes-i) {
 			break
 		}
 	}
+
 	useChannel := c.GetStringSlice("use_channel")
 	if len(useChannel) > 1 {
 		retryLogStr := fmt.Sprintf("重试:%s", strings.Trim(strings.Join(strings.Fields(fmt.Sprint(useChannel)), "->"), "[]"))
-		common.LogInfo(c, retryLogStr)
+		logger.LogInfo(c, retryLogStr)
 	}
-
-	if newAPIError != nil {
-		newAPIError.SetMessage(common.MessageWithRequestId(newAPIError.Error(), requestId))
-		c.JSON(newAPIError.StatusCode, gin.H{
-			"type":  "error",
-			"error": newAPIError.ToClaudeError(),
-		})
-	}
-}
-
-func relayRequest(c *gin.Context, relayMode int, channel *model.Channel) *types.NewAPIError {
-	addUsedChannel(c, channel.Id)
-	requestBody, _ := common.GetRequestBody(c)
-	c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody))
-	return relayHandler(c, relayMode)
-}
-
-func wssRequest(c *gin.Context, ws *websocket.Conn, relayMode int, channel *model.Channel) *types.NewAPIError {
-	addUsedChannel(c, channel.Id)
-	requestBody, _ := common.GetRequestBody(c)
-	c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody))
-	return relay.WssHelper(c, ws)
 }
 
-func claudeRequest(c *gin.Context, channel *model.Channel) *types.NewAPIError {
-	addUsedChannel(c, channel.Id)
-	requestBody, _ := common.GetRequestBody(c)
-	c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody))
-	return relay.ClaudeHelper(c)
+var upgrader = websocket.Upgrader{
+	Subprotocols: []string{"realtime"}, // WS 握手支持的协议,如果有使用 Sec-WebSocket-Protocol,则必须在此声明对应的 Protocol TODO add other protocol
+	CheckOrigin: func(r *http.Request) bool {
+		return true // 允许跨域
+	},
 }
 
 func addUsedChannel(c *gin.Context, channelId int) {
@@ -259,10 +221,10 @@ func getChannel(c *gin.Context, group, originalModel string, retryCount int) (*m
 	}
 	channel, selectGroup, err := model.CacheGetRandomSatisfiedChannel(c, group, originalModel, retryCount)
 	if err != nil {
-		if group == "auto" {
-			return nil, types.NewError(errors.New(fmt.Sprintf("获取自动分组下模型 %s 的可用渠道失败: %s", originalModel, err.Error())), types.ErrorCodeGetChannelFailed)
-		}
-		return nil, types.NewError(errors.New(fmt.Sprintf("获取分组 %s 下模型 %s 的可用渠道失败: %s", selectGroup, originalModel, err.Error())), types.ErrorCodeGetChannelFailed)
+		return nil, types.NewError(fmt.Errorf("获取分组 %s 下模型 %s 的可用渠道失败(retry): %s", selectGroup, originalModel, err.Error()), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry())
+	}
+	if channel == nil {
+		return nil, types.NewError(fmt.Errorf("分组 %s 下模型 %s 的可用渠道不存在(数据库一致性已被破坏,retry)", selectGroup, originalModel), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry())
 	}
 	newAPIError := middleware.SetupContextForSelectedChannel(c, channel, originalModel)
 	if newAPIError != nil {
@@ -278,7 +240,7 @@ func shouldRetry(c *gin.Context, openaiErr *types.NewAPIError, retryTimes int) b
 	if types.IsChannelError(openaiErr) {
 		return true
 	}
-	if types.IsLocalError(openaiErr) {
+	if types.IsSkipRetryError(openaiErr) {
 		return false
 	}
 	if retryTimes <= 0 {
@@ -301,10 +263,6 @@ func shouldRetry(c *gin.Context, openaiErr *types.NewAPIError, retryTimes int) b
 		return true
 	}
 	if openaiErr.StatusCode == http.StatusBadRequest {
-		channelType := c.GetInt("channel_type")
-		if channelType == constant.ChannelTypeAnthropic {
-			return true
-		}
 		return false
 	}
 	if openaiErr.StatusCode == 408 {
@@ -318,44 +276,83 @@ func shouldRetry(c *gin.Context, openaiErr *types.NewAPIError, retryTimes int) b
 }
 
 func processChannelError(c *gin.Context, channelError types.ChannelError, err *types.NewAPIError) {
+	logger.LogError(c, fmt.Sprintf("relay error (channel #%d, status code: %d): %s", channelError.ChannelId, err.StatusCode, err.Error()))
 	// 不要使用context获取渠道信息,异步处理时可能会出现渠道信息不一致的情况
 	// do not use context to get channel info, there may be inconsistent channel info when processing asynchronously
-	common.LogError(c, fmt.Sprintf("relay error (channel #%d, status code: %d): %s", channelError.ChannelId, err.StatusCode, err.Error()))
 	if service.ShouldDisableChannel(channelError.ChannelId, err) && channelError.AutoBan {
-		service.DisableChannel(channelError, err.Error())
+		gopool.Go(func() {
+			service.DisableChannel(channelError, err.Error())
+		})
+	}
+
+	if constant.ErrorLogEnabled && types.IsRecordErrorLog(err) {
+		// 保存错误日志到mysql中
+		userId := c.GetInt("id")
+		tokenName := c.GetString("token_name")
+		modelName := c.GetString("original_model")
+		tokenId := c.GetInt("token_id")
+		userGroup := c.GetString("group")
+		channelId := c.GetInt("channel_id")
+		other := make(map[string]interface{})
+		other["error_type"] = err.GetErrorType()
+		other["error_code"] = err.GetErrorCode()
+		other["status_code"] = err.StatusCode
+		other["channel_id"] = channelId
+		other["channel_name"] = c.GetString("channel_name")
+		other["channel_type"] = c.GetInt("channel_type")
+		adminInfo := make(map[string]interface{})
+		adminInfo["use_channel"] = c.GetStringSlice("use_channel")
+		isMultiKey := common.GetContextKeyBool(c, constant.ContextKeyChannelIsMultiKey)
+		if isMultiKey {
+			adminInfo["is_multi_key"] = true
+			adminInfo["multi_key_index"] = common.GetContextKeyInt(c, constant.ContextKeyChannelMultiKeyIndex)
+		}
+		other["admin_info"] = adminInfo
+		model.RecordErrorLog(c, userId, channelId, modelName, tokenName, err.MaskSensitiveError(), tokenId, 0, false, userGroup, other)
 	}
+
 }
 
 func RelayMidjourney(c *gin.Context) {
-	relayMode := c.GetInt("relay_mode")
-	var err *dto.MidjourneyResponse
-	switch relayMode {
+	relayInfo, err := relaycommon.GenRelayInfo(c, types.RelayFormatMjProxy, nil, nil)
+
+	if err != nil {
+		c.JSON(http.StatusInternalServerError, gin.H{
+			"description": fmt.Sprintf("failed to generate relay info: %s", err.Error()),
+			"type":        "upstream_error",
+			"code":        4,
+		})
+		return
+	}
+
+	var mjErr *dto.MidjourneyResponse
+	switch relayInfo.RelayMode {
 	case relayconstant.RelayModeMidjourneyNotify:
-		err = relay.RelayMidjourneyNotify(c)
+		mjErr = relay.RelayMidjourneyNotify(c)
 	case relayconstant.RelayModeMidjourneyTaskFetch, relayconstant.RelayModeMidjourneyTaskFetchByCondition:
-		err = relay.RelayMidjourneyTask(c, relayMode)
+		mjErr = relay.RelayMidjourneyTask(c, relayInfo.RelayMode)
 	case relayconstant.RelayModeMidjourneyTaskImageSeed:
-		err = relay.RelayMidjourneyTaskImageSeed(c)
+		mjErr = relay.RelayMidjourneyTaskImageSeed(c)
 	case relayconstant.RelayModeSwapFace:
-		err = relay.RelaySwapFace(c)
+		mjErr = relay.RelaySwapFace(c, relayInfo)
 	default:
-		err = relay.RelayMidjourneySubmit(c, relayMode)
+		mjErr = relay.RelayMidjourneySubmit(c, relayInfo)
 	}
 	//err = relayMidjourneySubmit(c, relayMode)
-	log.Println(err)
-	if err != nil {
+	log.Println(mjErr)
+	if mjErr != nil {
 		statusCode := http.StatusBadRequest
-		if err.Code == 30 {
-			err.Result = "当前分组负载已饱和,请稍后再试,或升级账户以提升服务质量。"
+		if mjErr.Code == 30 {
+			mjErr.Result = "当前分组负载已饱和,请稍后再试,或升级账户以提升服务质量。"
 			statusCode = http.StatusTooManyRequests
 		}
 		c.JSON(statusCode, gin.H{
-			"description": fmt.Sprintf("%s %s", err.Description, err.Result),
+			"description": fmt.Sprintf("%s %s", mjErr.Description, mjErr.Result),
 			"type":        "upstream_error",
-			"code":        err.Code,
+			"code":        mjErr.Code,
 		})
 		channelId := c.GetInt("channel_id")
-		common.LogError(c, fmt.Sprintf("relay error (channel #%d, status code %d): %s", channelId, statusCode, fmt.Sprintf("%s %s", err.Description, err.Result)))
+		logger.LogError(c, fmt.Sprintf("relay error (channel #%d, status code %d): %s", channelId, statusCode, fmt.Sprintf("%s %s", mjErr.Description, mjErr.Result)))
 	}
 }
 
@@ -386,18 +383,21 @@ func RelayNotFound(c *gin.Context) {
 func RelayTask(c *gin.Context) {
 	retryTimes := common.RetryTimes
 	channelId := c.GetInt("channel_id")
-	relayMode := c.GetInt("relay_mode")
 	group := c.GetString("group")
 	originalModel := c.GetString("original_model")
 	c.Set("use_channel", []string{fmt.Sprintf("%d", channelId)})
-	taskErr := taskRelayHandler(c, relayMode)
+	relayInfo, err := relaycommon.GenRelayInfo(c, types.RelayFormatTask, nil, nil)
+	if err != nil {
+		return
+	}
+	taskErr := taskRelayHandler(c, relayInfo)
 	if taskErr == nil {
 		retryTimes = 0
 	}
 	for i := 0; shouldRetryTaskRelay(c, channelId, taskErr, retryTimes) && i < retryTimes; i++ {
 		channel, newAPIError := getChannel(c, group, originalModel, i)
 		if newAPIError != nil {
-			common.LogError(c, fmt.Sprintf("CacheGetRandomSatisfiedChannel failed: %s", newAPIError.Error()))
+			logger.LogError(c, fmt.Sprintf("CacheGetRandomSatisfiedChannel failed: %s", newAPIError.Error()))
 			taskErr = service.TaskErrorWrapperLocal(newAPIError.Err, "get_channel_failed", http.StatusInternalServerError)
 			break
 		}
@@ -405,17 +405,17 @@ func RelayTask(c *gin.Context) {
 		useChannel := c.GetStringSlice("use_channel")
 		useChannel = append(useChannel, fmt.Sprintf("%d", channelId))
 		c.Set("use_channel", useChannel)
-		common.LogInfo(c, fmt.Sprintf("using channel #%d to retry (remain times %d)", channel.Id, i))
+		logger.LogInfo(c, fmt.Sprintf("using channel #%d to retry (remain times %d)", channel.Id, i))
 		//middleware.SetupContextForSelectedChannel(c, channel, originalModel)
 
 		requestBody, _ := common.GetRequestBody(c)
 		c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody))
-		taskErr = taskRelayHandler(c, relayMode)
+		taskErr = taskRelayHandler(c, relayInfo)
 	}
 	useChannel := c.GetStringSlice("use_channel")
 	if len(useChannel) > 1 {
 		retryLogStr := fmt.Sprintf("重试:%s", strings.Trim(strings.Join(strings.Fields(fmt.Sprint(useChannel)), "->"), "[]"))
-		common.LogInfo(c, retryLogStr)
+		logger.LogInfo(c, retryLogStr)
 	}
 	if taskErr != nil {
 		if taskErr.StatusCode == http.StatusTooManyRequests {
@@ -425,13 +425,13 @@ func RelayTask(c *gin.Context) {
 	}
 }
 
-func taskRelayHandler(c *gin.Context, relayMode int) *dto.TaskError {
+func taskRelayHandler(c *gin.Context, relayInfo *relaycommon.RelayInfo) *dto.TaskError {
 	var err *dto.TaskError
-	switch relayMode {
-	case relayconstant.RelayModeSunoFetch, relayconstant.RelayModeSunoFetchByID, relayconstant.RelayModeKlingFetchByID:
-		err = relay.RelayTaskFetch(c, relayMode)
+	switch relayInfo.RelayMode {
+	case relayconstant.RelayModeSunoFetch, relayconstant.RelayModeSunoFetchByID, relayconstant.RelayModeVideoFetchByID:
+		err = relay.RelayTaskFetch(c, relayInfo.RelayMode)
 	default:
-		err = relay.RelayTaskSubmit(c, relayMode)
+		err = relay.RelayTaskSubmit(c, relayInfo)
 	}
 	return err
 }

+ 11 - 11
controller/setup.go

@@ -53,7 +53,7 @@ func GetSetup(c *gin.Context) {
 func PostSetup(c *gin.Context) {
 	// Check if setup is already completed
 	if constant.Setup {
-		c.JSON(400, gin.H{
+		c.JSON(200, gin.H{
 			"success": false,
 			"message": "系统已经初始化完成",
 		})
@@ -66,7 +66,7 @@ func PostSetup(c *gin.Context) {
 	var req SetupRequest
 	err := c.ShouldBindJSON(&req)
 	if err != nil {
-		c.JSON(400, gin.H{
+		c.JSON(200, gin.H{
 			"success": false,
 			"message": "请求参数有误",
 		})
@@ -77,7 +77,7 @@ func PostSetup(c *gin.Context) {
 	if !rootExists {
 		// Validate username length: max 12 characters to align with model.User validation
 		if len(req.Username) > 12 {
-			c.JSON(400, gin.H{
+			c.JSON(200, gin.H{
 				"success": false,
 				"message": "用户名长度不能超过12个字符",
 			})
@@ -85,7 +85,7 @@ func PostSetup(c *gin.Context) {
 		}
 		// Validate password
 		if req.Password != req.ConfirmPassword {
-			c.JSON(400, gin.H{
+			c.JSON(200, gin.H{
 				"success": false,
 				"message": "两次输入的密码不一致",
 			})
@@ -93,7 +93,7 @@ func PostSetup(c *gin.Context) {
 		}
 
 		if len(req.Password) < 8 {
-			c.JSON(400, gin.H{
+			c.JSON(200, gin.H{
 				"success": false,
 				"message": "密码长度至少为8个字符",
 			})
@@ -103,7 +103,7 @@ func PostSetup(c *gin.Context) {
 		// Create root user
 		hashedPassword, err := common.Password2Hash(req.Password)
 		if err != nil {
-			c.JSON(500, gin.H{
+			c.JSON(200, gin.H{
 				"success": false,
 				"message": "系统错误: " + err.Error(),
 			})
@@ -120,7 +120,7 @@ func PostSetup(c *gin.Context) {
 		}
 		err = model.DB.Create(&rootUser).Error
 		if err != nil {
-			c.JSON(500, gin.H{
+			c.JSON(200, gin.H{
 				"success": false,
 				"message": "创建管理员账号失败: " + err.Error(),
 			})
@@ -135,7 +135,7 @@ func PostSetup(c *gin.Context) {
 	// Save operation modes to database for persistence
 	err = model.UpdateOption("SelfUseModeEnabled", boolToString(req.SelfUseModeEnabled))
 	if err != nil {
-		c.JSON(500, gin.H{
+		c.JSON(200, gin.H{
 			"success": false,
 			"message": "保存自用模式设置失败: " + err.Error(),
 		})
@@ -144,7 +144,7 @@ func PostSetup(c *gin.Context) {
 
 	err = model.UpdateOption("DemoSiteEnabled", boolToString(req.DemoSiteEnabled))
 	if err != nil {
-		c.JSON(500, gin.H{
+		c.JSON(200, gin.H{
 			"success": false,
 			"message": "保存演示站点模式设置失败: " + err.Error(),
 		})
@@ -160,7 +160,7 @@ func PostSetup(c *gin.Context) {
 	}
 	err = model.DB.Create(&setup).Error
 	if err != nil {
-		c.JSON(500, gin.H{
+		c.JSON(200, gin.H{
 			"success": false,
 			"message": "系统初始化失败: " + err.Error(),
 		})
@@ -178,4 +178,4 @@ func boolToString(b bool) string {
 		return "true"
 	}
 	return "false"
-}
+}

+ 20 - 0
controller/swag_video.go

@@ -114,3 +114,23 @@ type KlingImage2VideoRequest struct {
 	CallbackURL    string              `json:"callback_url,omitempty" example:"https://your.domain/callback"`
 	ExternalTaskId string              `json:"external_task_id,omitempty" example:"custom-task-002"`
 }
+
+// KlingImage2videoTaskId godoc
+// @Summary 可灵任务查询--图生视频
+// @Description Query the status and result of a Kling video generation task by task ID
+// @Tags Origin
+// @Accept json
+// @Produce json
+// @Param task_id path string true "Task ID"
+// @Router /kling/v1/videos/image2video/{task_id} [get]
+func KlingImage2videoTaskId(c *gin.Context) {}
+
+// KlingText2videoTaskId godoc
+// @Summary 可灵任务查询--文生视频
+// @Description Query the status and result of a Kling text-to-video generation task by task ID
+// @Tags Origin
+// @Accept json
+// @Produce json
+// @Param task_id path string true "Task ID"
+// @Router /kling/v1/videos/text2video/{task_id} [get]
+func KlingText2videoTaskId(c *gin.Context) {}

+ 18 - 17
controller/task.go

@@ -10,6 +10,7 @@ import (
 	"one-api/common"
 	"one-api/constant"
 	"one-api/dto"
+	"one-api/logger"
 	"one-api/model"
 	"one-api/relay"
 	"sort"
@@ -54,9 +55,9 @@ func UpdateTaskBulk() {
 					"progress": "100%",
 				})
 				if err != nil {
-					common.LogError(ctx, fmt.Sprintf("Fix null task_id task error: %v", err))
+					logger.LogError(ctx, fmt.Sprintf("Fix null task_id task error: %v", err))
 				} else {
-					common.LogInfo(ctx, fmt.Sprintf("Fix null task_id task success: %v", nullTaskIds))
+					logger.LogInfo(ctx, fmt.Sprintf("Fix null task_id task success: %v", nullTaskIds))
 				}
 			}
 			if len(taskChannelM) == 0 {
@@ -75,10 +76,10 @@ func UpdateTaskByPlatform(platform constant.TaskPlatform, taskChannelM map[int][
 		//_ = UpdateMidjourneyTaskAll(context.Background(), tasks)
 	case constant.TaskPlatformSuno:
 		_ = UpdateSunoTaskAll(context.Background(), taskChannelM, taskM)
-	case constant.TaskPlatformKling, constant.TaskPlatformJimeng:
-		_ = UpdateVideoTaskAll(context.Background(), platform, taskChannelM, taskM)
 	default:
-		common.SysLog("未知平台")
+		if err := UpdateVideoTaskAll(context.Background(), platform, taskChannelM, taskM); err != nil {
+			common.SysLog(fmt.Sprintf("UpdateVideoTaskAll fail: %s", err))
+		}
 	}
 }
 
@@ -86,14 +87,14 @@ func UpdateSunoTaskAll(ctx context.Context, taskChannelM map[int][]string, taskM
 	for channelId, taskIds := range taskChannelM {
 		err := updateSunoTaskAll(ctx, channelId, taskIds, taskM)
 		if err != nil {
-			common.LogError(ctx, fmt.Sprintf("渠道 #%d 更新异步任务失败: %d", channelId, err.Error()))
+			logger.LogError(ctx, fmt.Sprintf("渠道 #%d 更新异步任务失败: %d", channelId, err.Error()))
 		}
 	}
 	return nil
 }
 
 func updateSunoTaskAll(ctx context.Context, channelId int, taskIds []string, taskM map[string]*model.Task) error {
-	common.LogInfo(ctx, fmt.Sprintf("渠道 #%d 未完成的任务有: %d", channelId, len(taskIds)))
+	logger.LogInfo(ctx, fmt.Sprintf("渠道 #%d 未完成的任务有: %d", channelId, len(taskIds)))
 	if len(taskIds) == 0 {
 		return nil
 	}
@@ -106,7 +107,7 @@ func updateSunoTaskAll(ctx context.Context, channelId int, taskIds []string, tas
 			"progress":    "100%",
 		})
 		if err != nil {
-			common.SysError(fmt.Sprintf("UpdateMidjourneyTask error2: %v", err))
+			common.SysLog(fmt.Sprintf("UpdateMidjourneyTask error2: %v", err))
 		}
 		return err
 	}
@@ -118,23 +119,23 @@ func updateSunoTaskAll(ctx context.Context, channelId int, taskIds []string, tas
 		"ids": taskIds,
 	})
 	if err != nil {
-		common.SysError(fmt.Sprintf("Get Task Do req error: %v", err))
+		common.SysLog(fmt.Sprintf("Get Task Do req error: %v", err))
 		return err
 	}
 	if resp.StatusCode != http.StatusOK {
-		common.LogError(ctx, fmt.Sprintf("Get Task status code: %d", resp.StatusCode))
+		logger.LogError(ctx, fmt.Sprintf("Get Task status code: %d", resp.StatusCode))
 		return errors.New(fmt.Sprintf("Get Task status code: %d", resp.StatusCode))
 	}
 	defer resp.Body.Close()
 	responseBody, err := io.ReadAll(resp.Body)
 	if err != nil {
-		common.SysError(fmt.Sprintf("Get Task parse body error: %v", err))
+		common.SysLog(fmt.Sprintf("Get Task parse body error: %v", err))
 		return err
 	}
 	var responseItems dto.TaskResponse[[]dto.SunoDataResponse]
 	err = json.Unmarshal(responseBody, &responseItems)
 	if err != nil {
-		common.LogError(ctx, fmt.Sprintf("Get Task parse body error2: %v, body: %s", err, string(responseBody)))
+		logger.LogError(ctx, fmt.Sprintf("Get Task parse body error2: %v, body: %s", err, string(responseBody)))
 		return err
 	}
 	if !responseItems.IsSuccess() {
@@ -154,19 +155,19 @@ func updateSunoTaskAll(ctx context.Context, channelId int, taskIds []string, tas
 		task.StartTime = lo.If(responseItem.StartTime != 0, responseItem.StartTime).Else(task.StartTime)
 		task.FinishTime = lo.If(responseItem.FinishTime != 0, responseItem.FinishTime).Else(task.FinishTime)
 		if responseItem.FailReason != "" || task.Status == model.TaskStatusFailure {
-			common.LogInfo(ctx, task.TaskID+" 构建失败,"+task.FailReason)
+			logger.LogInfo(ctx, task.TaskID+" 构建失败,"+task.FailReason)
 			task.Progress = "100%"
 			//err = model.CacheUpdateUserQuota(task.UserId) ?
 			if err != nil {
-				common.LogError(ctx, "error update user quota cache: "+err.Error())
+				logger.LogError(ctx, "error update user quota cache: "+err.Error())
 			} else {
 				quota := task.Quota
 				if quota != 0 {
 					err = model.IncreaseUserQuota(task.UserId, quota, false)
 					if err != nil {
-						common.LogError(ctx, "fail to increase user quota: "+err.Error())
+						logger.LogError(ctx, "fail to increase user quota: "+err.Error())
 					}
-					logContent := fmt.Sprintf("异步任务执行失败 %s,补偿 %s", task.TaskID, common.LogQuota(quota))
+					logContent := fmt.Sprintf("异步任务执行失败 %s,补偿 %s", task.TaskID, logger.LogQuota(quota))
 					model.RecordLog(task.UserId, model.LogTypeSystem, logContent)
 				}
 			}
@@ -178,7 +179,7 @@ func updateSunoTaskAll(ctx context.Context, channelId int, taskIds []string, tas
 
 		err = task.Update()
 		if err != nil {
-			common.SysError("UpdateMidjourneyTask task error: " + err.Error())
+			common.SysLog("UpdateMidjourneyTask task error: " + err.Error())
 		}
 	}
 	return nil

+ 63 - 17
controller/task_video.go

@@ -2,27 +2,31 @@ package controller
 
 import (
 	"context"
+	"encoding/json"
 	"fmt"
 	"io"
 	"one-api/common"
 	"one-api/constant"
+	"one-api/dto"
+	"one-api/logger"
 	"one-api/model"
 	"one-api/relay"
 	"one-api/relay/channel"
+	relaycommon "one-api/relay/common"
 	"time"
 )
 
 func UpdateVideoTaskAll(ctx context.Context, platform constant.TaskPlatform, taskChannelM map[int][]string, taskM map[string]*model.Task) error {
 	for channelId, taskIds := range taskChannelM {
 		if err := updateVideoTaskAll(ctx, platform, channelId, taskIds, taskM); err != nil {
-			common.LogError(ctx, fmt.Sprintf("Channel #%d failed to update video async tasks: %s", channelId, err.Error()))
+			logger.LogError(ctx, fmt.Sprintf("Channel #%d failed to update video async tasks: %s", channelId, err.Error()))
 		}
 	}
 	return nil
 }
 
 func updateVideoTaskAll(ctx context.Context, platform constant.TaskPlatform, channelId int, taskIds []string, taskM map[string]*model.Task) error {
-	common.LogInfo(ctx, fmt.Sprintf("Channel #%d pending video tasks: %d", channelId, len(taskIds)))
+	logger.LogInfo(ctx, fmt.Sprintf("Channel #%d pending video tasks: %d", channelId, len(taskIds)))
 	if len(taskIds) == 0 {
 		return nil
 	}
@@ -34,7 +38,7 @@ func updateVideoTaskAll(ctx context.Context, platform constant.TaskPlatform, cha
 			"progress":    "100%",
 		})
 		if errUpdate != nil {
-			common.SysError(fmt.Sprintf("UpdateVideoTask error: %v", errUpdate))
+			common.SysLog(fmt.Sprintf("UpdateVideoTask error: %v", errUpdate))
 		}
 		return fmt.Errorf("CacheGetChannel failed: %w", err)
 	}
@@ -44,7 +48,7 @@ func updateVideoTaskAll(ctx context.Context, platform constant.TaskPlatform, cha
 	}
 	for _, taskId := range taskIds {
 		if err := updateVideoSingleTask(ctx, adaptor, cacheGetChannel, taskId, taskM); err != nil {
-			common.LogError(ctx, fmt.Sprintf("Failed to update video task %s: %s", taskId, err.Error()))
+			logger.LogError(ctx, fmt.Sprintf("Failed to update video task %s: %s", taskId, err.Error()))
 		}
 	}
 	return nil
@@ -58,7 +62,7 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha
 
 	task := taskM[taskId]
 	if task == nil {
-		common.LogError(ctx, fmt.Sprintf("Task %s not found in taskM", taskId))
+		logger.LogError(ctx, fmt.Sprintf("Task %s not found in taskM", taskId))
 		return fmt.Errorf("task %s not found", taskId)
 	}
 	resp, err := adaptor.FetchTask(baseURL, channel.Key, map[string]any{
@@ -77,13 +81,21 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha
 		return fmt.Errorf("readAll failed for task %s: %w", taskId, err)
 	}
 
-	taskResult, err := adaptor.ParseTaskResult(responseBody)
-	if err != nil {
+	taskResult := &relaycommon.TaskInfo{}
+	// try parse as New API response format
+	var responseItems dto.TaskResponse[model.Task]
+	if err = json.Unmarshal(responseBody, &responseItems); err == nil && responseItems.IsSuccess() {
+		t := responseItems.Data
+		taskResult.TaskID = t.TaskID
+		taskResult.Status = string(t.Status)
+		taskResult.Url = t.FailReason
+		taskResult.Progress = t.Progress
+		taskResult.Reason = t.FailReason
+	} else if taskResult, err = adaptor.ParseTaskResult(responseBody); err != nil {
 		return fmt.Errorf("parseTaskResult failed for task %s: %w", taskId, err)
+	} else {
+		task.Data = redactVideoResponseBody(responseBody)
 	}
-	//if taskResult.Code != 0 {
-	//	return fmt.Errorf("video task fetch failed for task %s", taskId)
-	//}
 
 	now := time.Now().Unix()
 	if taskResult.Status == "" {
@@ -105,7 +117,9 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha
 		if task.FinishTime == 0 {
 			task.FinishTime = now
 		}
-		task.FailReason = taskResult.Url
+		if !(len(taskResult.Url) > 5 && taskResult.Url[:5] == "data:") {
+			task.FailReason = taskResult.Url
+		}
 	case model.TaskStatusFailure:
 		task.Status = model.TaskStatusFailure
 		task.Progress = "100%"
@@ -113,13 +127,13 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha
 			task.FinishTime = now
 		}
 		task.FailReason = taskResult.Reason
-		common.LogInfo(ctx, fmt.Sprintf("Task %s failed: %s", task.TaskID, task.FailReason))
+		logger.LogInfo(ctx, fmt.Sprintf("Task %s failed: %s", task.TaskID, task.FailReason))
 		quota := task.Quota
 		if quota != 0 {
 			if err := model.IncreaseUserQuota(task.UserId, quota, false); err != nil {
-				common.LogError(ctx, "Failed to increase user quota: "+err.Error())
+				logger.LogError(ctx, "Failed to increase user quota: "+err.Error())
 			}
-			logContent := fmt.Sprintf("Video async task failed %s, refund %s", task.TaskID, common.LogQuota(quota))
+			logContent := fmt.Sprintf("Video async task failed %s, refund %s", task.TaskID, logger.LogQuota(quota))
 			model.RecordLog(task.UserId, model.LogTypeSystem, logContent)
 		}
 	default:
@@ -128,11 +142,43 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha
 	if taskResult.Progress != "" {
 		task.Progress = taskResult.Progress
 	}
-
-	task.Data = responseBody
 	if err := task.Update(); err != nil {
-		common.SysError("UpdateVideoTask task error: " + err.Error())
+		common.SysLog("UpdateVideoTask task error: " + err.Error())
 	}
 
 	return nil
 }
+
+func redactVideoResponseBody(body []byte) []byte {
+	var m map[string]any
+	if err := json.Unmarshal(body, &m); err != nil {
+		return body
+	}
+	resp, _ := m["response"].(map[string]any)
+	if resp != nil {
+		delete(resp, "bytesBase64Encoded")
+		if v, ok := resp["video"].(string); ok {
+			resp["video"] = truncateBase64(v)
+		}
+		if vs, ok := resp["videos"].([]any); ok {
+			for i := range vs {
+				if vm, ok := vs[i].(map[string]any); ok {
+					delete(vm, "bytesBase64Encoded")
+				}
+			}
+		}
+	}
+	b, err := json.Marshal(m)
+	if err != nil {
+		return body
+	}
+	return b
+}
+
+func truncateBase64(s string) string {
+	const maxKeep = 256
+	if len(s) <= maxKeep {
+		return s
+	}
+	return s[:maxKeep] + "..."
+}

+ 53 - 1
controller/token.go

@@ -5,6 +5,7 @@ import (
 	"one-api/common"
 	"one-api/model"
 	"strconv"
+	"strings"
 
 	"github.com/gin-gonic/gin"
 )
@@ -82,6 +83,57 @@ func GetTokenStatus(c *gin.Context) {
 	})
 }
 
+func GetTokenUsage(c *gin.Context) {
+	authHeader := c.GetHeader("Authorization")
+	if authHeader == "" {
+		c.JSON(http.StatusUnauthorized, gin.H{
+			"success": false,
+			"message": "No Authorization header",
+		})
+		return
+	}
+
+	parts := strings.Split(authHeader, " ")
+	if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
+		c.JSON(http.StatusUnauthorized, gin.H{
+			"success": false,
+			"message": "Invalid Bearer token",
+		})
+		return
+	}
+	tokenKey := parts[1]
+
+	token, err := model.GetTokenByKey(strings.TrimPrefix(tokenKey, "sk-"), false)
+	if err != nil {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": err.Error(),
+		})
+		return
+	}
+
+	expiredAt := token.ExpiredTime
+	if expiredAt == -1 {
+		expiredAt = 0
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"code":    true,
+		"message": "ok",
+		"data": gin.H{
+			"object":               "token_usage",
+			"name":                 token.Name,
+			"total_granted":        token.RemainQuota + token.UsedQuota,
+			"total_used":           token.UsedQuota,
+			"total_available":      token.RemainQuota,
+			"unlimited_quota":      token.UnlimitedQuota,
+			"model_limits":         token.GetModelLimitsMap(),
+			"model_limits_enabled": token.ModelLimitsEnabled,
+			"expires_at":           expiredAt,
+		},
+	})
+}
+
 func AddToken(c *gin.Context) {
 	token := model.Token{}
 	err := c.ShouldBindJSON(&token)
@@ -102,7 +154,7 @@ func AddToken(c *gin.Context) {
 			"success": false,
 			"message": "生成令牌失败",
 		})
-		common.SysError("failed to generate token key: " + err.Error())
+		common.SysLog("failed to generate token key: " + err.Error())
 		return
 	}
 	cleanToken := model.Token{

+ 61 - 10
controller/topup.go

@@ -5,9 +5,12 @@ import (
 	"log"
 	"net/url"
 	"one-api/common"
+	"one-api/logger"
 	"one-api/model"
 	"one-api/service"
 	"one-api/setting"
+	"one-api/setting/operation_setting"
+	"one-api/setting/system_setting"
 	"strconv"
 	"sync"
 	"time"
@@ -18,6 +21,46 @@ import (
 	"github.com/shopspring/decimal"
 )
 
+func GetTopUpInfo(c *gin.Context) {
+	// 获取支付方式
+	payMethods := operation_setting.PayMethods
+
+	// 如果启用了 Stripe 支付,添加到支付方法列表
+	if setting.StripeApiSecret != "" && setting.StripeWebhookSecret != "" && setting.StripePriceId != "" {
+		// 检查是否已经包含 Stripe
+		hasStripe := false
+		for _, method := range payMethods {
+			if method["type"] == "stripe" {
+				hasStripe = true
+				break
+			}
+		}
+
+		if !hasStripe {
+			stripeMethod := map[string]string{
+				"name":      "Stripe",
+				"type":      "stripe",
+				"color":     "rgba(var(--semi-purple-5), 1)",
+				"min_topup": strconv.Itoa(setting.StripeMinTopUp),
+			}
+			payMethods = append(payMethods, stripeMethod)
+		}
+	}
+
+	data := gin.H{
+		"enable_online_topup": operation_setting.PayAddress != "" && operation_setting.EpayId != "" && operation_setting.EpayKey != "",
+		"enable_stripe_topup": setting.StripeApiSecret != "" && setting.StripeWebhookSecret != "" && setting.StripePriceId != "",
+		"enable_creem_topup":  setting.CreemApiKey != "" && setting.CreemProducts != "[]",
+		"creem_products":      setting.CreemProducts,
+		"pay_methods":         payMethods,
+		"min_topup":           operation_setting.MinTopUp,
+		"stripe_min_topup":    setting.StripeMinTopUp,
+		"amount_options":      operation_setting.GetPaymentSetting().AmountOptions,
+		"discount":            operation_setting.GetPaymentSetting().AmountDiscount,
+	}
+	common.ApiSuccess(c, data)
+}
+
 type EpayRequest struct {
 	Amount        int64  `json:"amount"`
 	PaymentMethod string `json:"payment_method"`
@@ -30,13 +73,13 @@ type AmountRequest struct {
 }
 
 func GetEpayClient() *epay.Client {
-	if setting.PayAddress == "" || setting.EpayId == "" || setting.EpayKey == "" {
+	if operation_setting.PayAddress == "" || operation_setting.EpayId == "" || operation_setting.EpayKey == "" {
 		return nil
 	}
 	withUrl, err := epay.NewClient(&epay.Config{
-		PartnerID: setting.EpayId,
-		Key:       setting.EpayKey,
-	}, setting.PayAddress)
+		PartnerID: operation_setting.EpayId,
+		Key:       operation_setting.EpayKey,
+	}, operation_setting.PayAddress)
 	if err != nil {
 		return nil
 	}
@@ -57,15 +100,23 @@ func getPayMoney(amount int64, group string) float64 {
 	}
 
 	dTopupGroupRatio := decimal.NewFromFloat(topupGroupRatio)
-	dPrice := decimal.NewFromFloat(setting.Price)
+	dPrice := decimal.NewFromFloat(operation_setting.Price)
+	// apply optional preset discount by the original request amount (if configured), default 1.0
+	discount := 1.0
+	if ds, ok := operation_setting.GetPaymentSetting().AmountDiscount[int(amount)]; ok {
+		if ds > 0 {
+			discount = ds
+		}
+	}
+	dDiscount := decimal.NewFromFloat(discount)
 
-	payMoney := dAmount.Mul(dPrice).Mul(dTopupGroupRatio)
+	payMoney := dAmount.Mul(dPrice).Mul(dTopupGroupRatio).Mul(dDiscount)
 
 	return payMoney.InexactFloat64()
 }
 
 func getMinTopup() int64 {
-	minTopup := setting.MinTopUp
+	minTopup := operation_setting.MinTopUp
 	if !common.DisplayInCurrencyEnabled {
 		dMinTopup := decimal.NewFromInt(int64(minTopup))
 		dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
@@ -98,13 +149,13 @@ func RequestEpay(c *gin.Context) {
 		return
 	}
 
-	if !setting.ContainsPayMethod(req.PaymentMethod) {
+	if !operation_setting.ContainsPayMethod(req.PaymentMethod) {
 		c.JSON(200, gin.H{"message": "error", "data": "支付方式不存在"})
 		return
 	}
 
 	callBackAddress := service.GetCallbackAddress()
-	returnUrl, _ := url.Parse(setting.ServerAddress + "/console/log")
+	returnUrl, _ := url.Parse(system_setting.ServerAddress + "/console/log")
 	notifyUrl, _ := url.Parse(callBackAddress + "/api/user/epay/notify")
 	tradeNo := fmt.Sprintf("%s%d", common.GetRandomString(6), time.Now().Unix())
 	tradeNo = fmt.Sprintf("USR%dNO%s", id, tradeNo)
@@ -231,7 +282,7 @@ func EpayNotify(c *gin.Context) {
 				return
 			}
 			log.Printf("易支付回调更新用户成功 %v", topUp)
-			model.RecordLog(topUp.UserId, model.LogTypeTopup, fmt.Sprintf("使用在线充值成功,充值金额: %v,支付金额:%f", common.LogQuota(quotaToAdd), topUp.Money))
+			model.RecordLog(topUp.UserId, model.LogTypeTopup, fmt.Sprintf("使用在线充值成功,充值金额: %v,支付金额:%f", logger.LogQuota(quotaToAdd), topUp.Money))
 		}
 	} else {
 		log.Printf("易支付异常回调: %v", verifyInfo)

+ 13 - 3
controller/topup_stripe.go

@@ -8,6 +8,8 @@ import (
 	"one-api/common"
 	"one-api/model"
 	"one-api/setting"
+	"one-api/setting/operation_setting"
+	"one-api/setting/system_setting"
 	"strconv"
 	"strings"
 	"time"
@@ -215,8 +217,8 @@ func genStripeLink(referenceId string, customerId string, email string, amount i
 
 	params := &stripe.CheckoutSessionParams{
 		ClientReferenceID: stripe.String(referenceId),
-		SuccessURL:        stripe.String(setting.ServerAddress + "/log"),
-		CancelURL:         stripe.String(setting.ServerAddress + "/topup"),
+		SuccessURL:        stripe.String(system_setting.ServerAddress + "/console/log"),
+		CancelURL:         stripe.String(system_setting.ServerAddress + "/topup"),
 		LineItems: []*stripe.CheckoutSessionLineItemParams{
 			{
 				Price:    stripe.String(setting.StripePriceId),
@@ -254,6 +256,7 @@ func GetChargedAmount(count float64, user model.User) float64 {
 }
 
 func getStripePayMoney(amount float64, group string) float64 {
+	originalAmount := amount
 	if !common.DisplayInCurrencyEnabled {
 		amount = amount / common.QuotaPerUnit
 	}
@@ -262,7 +265,14 @@ func getStripePayMoney(amount float64, group string) float64 {
 	if topupGroupRatio == 0 {
 		topupGroupRatio = 1
 	}
-	payMoney := amount * setting.StripeUnitPrice * topupGroupRatio
+	// apply optional preset discount by the original request amount (if configured), default 1.0
+	discount := 1.0
+	if ds, ok := operation_setting.GetPaymentSetting().AmountDiscount[int(originalAmount)]; ok {
+		if ds > 0 {
+			discount = ds
+		}
+	}
+	payMoney := amount * setting.StripeUnitPrice * topupGroupRatio * discount
 	return payMoney
 }
 

+ 553 - 0
controller/twofa.go

@@ -0,0 +1,553 @@
+package controller
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+	"one-api/common"
+	"one-api/model"
+	"strconv"
+
+	"github.com/gin-contrib/sessions"
+	"github.com/gin-gonic/gin"
+)
+
+// Setup2FARequest 设置2FA请求结构
+type Setup2FARequest struct {
+	Code string `json:"code" binding:"required"`
+}
+
+// Verify2FARequest 验证2FA请求结构
+type Verify2FARequest struct {
+	Code string `json:"code" binding:"required"`
+}
+
+// Setup2FAResponse 设置2FA响应结构
+type Setup2FAResponse struct {
+	Secret      string   `json:"secret"`
+	QRCodeData  string   `json:"qr_code_data"`
+	BackupCodes []string `json:"backup_codes"`
+}
+
+// Setup2FA 初始化2FA设置
+func Setup2FA(c *gin.Context) {
+	userId := c.GetInt("id")
+
+	// 检查用户是否已经启用2FA
+	existing, err := model.GetTwoFAByUserId(userId)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+	if existing != nil && existing.IsEnabled {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "用户已启用2FA,请先禁用后重新设置",
+		})
+		return
+	}
+
+	// 如果存在已禁用的2FA记录,先删除它
+	if existing != nil && !existing.IsEnabled {
+		if err := existing.Delete(); err != nil {
+			common.ApiError(c, err)
+			return
+		}
+		existing = nil // 重置为nil,后续将创建新记录
+	}
+
+	// 获取用户信息
+	user, err := model.GetUserById(userId, false)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	// 生成TOTP密钥
+	key, err := common.GenerateTOTPSecret(user.Username)
+	if err != nil {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "生成2FA密钥失败",
+		})
+		common.SysLog("生成TOTP密钥失败: " + err.Error())
+		return
+	}
+
+	// 生成备用码
+	backupCodes, err := common.GenerateBackupCodes()
+	if err != nil {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "生成备用码失败",
+		})
+		common.SysLog("生成备用码失败: " + err.Error())
+		return
+	}
+
+	// 生成二维码数据
+	qrCodeData := common.GenerateQRCodeData(key.Secret(), user.Username)
+
+	// 创建或更新2FA记录(暂未启用)
+	twoFA := &model.TwoFA{
+		UserId:    userId,
+		Secret:    key.Secret(),
+		IsEnabled: false,
+	}
+
+	if existing != nil {
+		// 更新现有记录
+		twoFA.Id = existing.Id
+		err = twoFA.Update()
+	} else {
+		// 创建新记录
+		err = twoFA.Create()
+	}
+
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	// 创建备用码记录
+	if err := model.CreateBackupCodes(userId, backupCodes); err != nil {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "保存备用码失败",
+		})
+		common.SysLog("保存备用码失败: " + err.Error())
+		return
+	}
+
+	// 记录操作日志
+	model.RecordLog(userId, model.LogTypeSystem, "开始设置两步验证")
+
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "2FA设置初始化成功,请使用认证器扫描二维码并输入验证码完成设置",
+		"data": Setup2FAResponse{
+			Secret:      key.Secret(),
+			QRCodeData:  qrCodeData,
+			BackupCodes: backupCodes,
+		},
+	})
+}
+
+// Enable2FA 启用2FA
+func Enable2FA(c *gin.Context) {
+	var req Setup2FARequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "参数错误",
+		})
+		return
+	}
+
+	userId := c.GetInt("id")
+
+	// 获取2FA记录
+	twoFA, err := model.GetTwoFAByUserId(userId)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+	if twoFA == nil {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "请先完成2FA初始化设置",
+		})
+		return
+	}
+	if twoFA.IsEnabled {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "2FA已经启用",
+		})
+		return
+	}
+
+	// 验证TOTP验证码
+	cleanCode, err := common.ValidateNumericCode(req.Code)
+	if err != nil {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": err.Error(),
+		})
+		return
+	}
+
+	if !common.ValidateTOTPCode(twoFA.Secret, cleanCode) {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "验证码或备用码错误,请重试",
+		})
+		return
+	}
+
+	// 启用2FA
+	if err := twoFA.Enable(); err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	// 记录操作日志
+	model.RecordLog(userId, model.LogTypeSystem, "成功启用两步验证")
+
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "两步验证启用成功",
+	})
+}
+
+// Disable2FA 禁用2FA
+func Disable2FA(c *gin.Context) {
+	var req Verify2FARequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "参数错误",
+		})
+		return
+	}
+
+	userId := c.GetInt("id")
+
+	// 获取2FA记录
+	twoFA, err := model.GetTwoFAByUserId(userId)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+	if twoFA == nil || !twoFA.IsEnabled {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "用户未启用2FA",
+		})
+		return
+	}
+
+	// 验证TOTP验证码或备用码
+	cleanCode, err := common.ValidateNumericCode(req.Code)
+	isValidTOTP := false
+	isValidBackup := false
+
+	if err == nil {
+		// 尝试验证TOTP
+		isValidTOTP, _ = twoFA.ValidateTOTPAndUpdateUsage(cleanCode)
+	}
+
+	if !isValidTOTP {
+		// 尝试验证备用码
+		isValidBackup, err = twoFA.ValidateBackupCodeAndUpdateUsage(req.Code)
+		if err != nil {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": err.Error(),
+			})
+			return
+		}
+	}
+
+	if !isValidTOTP && !isValidBackup {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "验证码或备用码错误,请重试",
+		})
+		return
+	}
+
+	// 禁用2FA
+	if err := model.DisableTwoFA(userId); err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	// 记录操作日志
+	model.RecordLog(userId, model.LogTypeSystem, "禁用两步验证")
+
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "两步验证已禁用",
+	})
+}
+
+// Get2FAStatus 获取用户2FA状态
+func Get2FAStatus(c *gin.Context) {
+	userId := c.GetInt("id")
+
+	twoFA, err := model.GetTwoFAByUserId(userId)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	status := map[string]interface{}{
+		"enabled": false,
+		"locked":  false,
+	}
+
+	if twoFA != nil {
+		status["enabled"] = twoFA.IsEnabled
+		status["locked"] = twoFA.IsLocked()
+		if twoFA.IsEnabled {
+			// 获取剩余备用码数量
+			backupCount, err := model.GetUnusedBackupCodeCount(userId)
+			if err != nil {
+				common.SysLog("获取备用码数量失败: " + err.Error())
+			} else {
+				status["backup_codes_remaining"] = backupCount
+			}
+		}
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "",
+		"data":    status,
+	})
+}
+
+// RegenerateBackupCodes 重新生成备用码
+func RegenerateBackupCodes(c *gin.Context) {
+	var req Verify2FARequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "参数错误",
+		})
+		return
+	}
+
+	userId := c.GetInt("id")
+
+	// 获取2FA记录
+	twoFA, err := model.GetTwoFAByUserId(userId)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+	if twoFA == nil || !twoFA.IsEnabled {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "用户未启用2FA",
+		})
+		return
+	}
+
+	// 验证TOTP验证码
+	cleanCode, err := common.ValidateNumericCode(req.Code)
+	if err != nil {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": err.Error(),
+		})
+		return
+	}
+
+	valid, err := twoFA.ValidateTOTPAndUpdateUsage(cleanCode)
+	if err != nil {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": err.Error(),
+		})
+		return
+	}
+	if !valid {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "验证码或备用码错误,请重试",
+		})
+		return
+	}
+
+	// 生成新的备用码
+	backupCodes, err := common.GenerateBackupCodes()
+	if err != nil {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "生成备用码失败",
+		})
+		common.SysLog("生成备用码失败: " + err.Error())
+		return
+	}
+
+	// 保存新的备用码
+	if err := model.CreateBackupCodes(userId, backupCodes); err != nil {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "保存备用码失败",
+		})
+		common.SysLog("保存备用码失败: " + err.Error())
+		return
+	}
+
+	// 记录操作日志
+	model.RecordLog(userId, model.LogTypeSystem, "重新生成两步验证备用码")
+
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "备用码重新生成成功",
+		"data": map[string]interface{}{
+			"backup_codes": backupCodes,
+		},
+	})
+}
+
+// Verify2FALogin 登录时验证2FA
+func Verify2FALogin(c *gin.Context) {
+	var req Verify2FARequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "参数错误",
+		})
+		return
+	}
+
+	// 从会话中获取pending用户信息
+	session := sessions.Default(c)
+	pendingUserId := session.Get("pending_user_id")
+	if pendingUserId == nil {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "会话已过期,请重新登录",
+		})
+		return
+	}
+	userId, ok := pendingUserId.(int)
+	if !ok {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "会话数据无效,请重新登录",
+		})
+		return
+	}
+	// 获取用户信息
+	user, err := model.GetUserById(userId, false)
+	if err != nil {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "用户不存在",
+		})
+		return
+	}
+
+	// 获取2FA记录
+	twoFA, err := model.GetTwoFAByUserId(user.Id)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+	if twoFA == nil || !twoFA.IsEnabled {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "用户未启用2FA",
+		})
+		return
+	}
+
+	// 验证TOTP验证码或备用码
+	cleanCode, err := common.ValidateNumericCode(req.Code)
+	isValidTOTP := false
+	isValidBackup := false
+
+	if err == nil {
+		// 尝试验证TOTP
+		isValidTOTP, _ = twoFA.ValidateTOTPAndUpdateUsage(cleanCode)
+	}
+
+	if !isValidTOTP {
+		// 尝试验证备用码
+		isValidBackup, err = twoFA.ValidateBackupCodeAndUpdateUsage(req.Code)
+		if err != nil {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": err.Error(),
+			})
+			return
+		}
+	}
+
+	if !isValidTOTP && !isValidBackup {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "验证码或备用码错误,请重试",
+		})
+		return
+	}
+
+	// 2FA验证成功,清理pending会话信息并完成登录
+	session.Delete("pending_username")
+	session.Delete("pending_user_id")
+	session.Save()
+
+	setupLogin(user, c)
+}
+
+// Admin2FAStats 管理员获取2FA统计信息
+func Admin2FAStats(c *gin.Context) {
+	stats, err := model.GetTwoFAStats()
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "",
+		"data":    stats,
+	})
+}
+
+// AdminDisable2FA 管理员强制禁用用户2FA
+func AdminDisable2FA(c *gin.Context) {
+	userIdStr := c.Param("id")
+	userId, err := strconv.Atoi(userIdStr)
+	if err != nil {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "用户ID格式错误",
+		})
+		return
+	}
+
+	// 检查目标用户权限
+	targetUser, err := model.GetUserById(userId, false)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	myRole := c.GetInt("role")
+	if myRole <= targetUser.Role && myRole != common.RoleRootUser {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "无权操作同级或更高级用户的2FA设置",
+		})
+		return
+	}
+
+	// 禁用2FA
+	if err := model.DisableTwoFA(userId); err != nil {
+		if errors.Is(err, model.ErrTwoFANotEnabled) {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": "用户未启用2FA",
+			})
+			return
+		}
+		common.ApiError(c, err)
+		return
+	}
+
+	// 记录操作日志
+	adminId := c.GetInt("id")
+	model.RecordLog(userId, model.LogTypeManage,
+		fmt.Sprintf("管理员(ID:%d)强制禁用了用户的两步验证", adminId))
+
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "用户2FA已被强制禁用",
+	})
+}

+ 15 - 15
controller/uptime_kuma.go

@@ -31,7 +31,7 @@ type Monitor struct {
 
 type UptimeGroupResult struct {
 	CategoryName string    `json:"categoryName"`
-	Monitors  []Monitor `json:"monitors"`
+	Monitors     []Monitor `json:"monitors"`
 }
 
 func getAndDecode(ctx context.Context, client *http.Client, url string, dest interface{}) error {
@@ -57,29 +57,29 @@ func fetchGroupData(ctx context.Context, client *http.Client, groupConfig map[st
 	url, _ := groupConfig["url"].(string)
 	slug, _ := groupConfig["slug"].(string)
 	categoryName, _ := groupConfig["categoryName"].(string)
-	
+
 	result := UptimeGroupResult{
 		CategoryName: categoryName,
-		Monitors:  []Monitor{},
+		Monitors:     []Monitor{},
 	}
-	
+
 	if url == "" || slug == "" {
 		return result
 	}
 
 	baseURL := strings.TrimSuffix(url, "/")
-	
+
 	var statusData struct {
 		PublicGroupList []struct {
-			ID   int    `json:"id"`
-			Name string `json:"name"`
+			ID          int    `json:"id"`
+			Name        string `json:"name"`
 			MonitorList []struct {
 				ID   int    `json:"id"`
 				Name string `json:"name"`
 			} `json:"monitorList"`
 		} `json:"publicGroupList"`
 	}
-	
+
 	var heartbeatData struct {
 		HeartbeatList map[string][]struct {
 			Status int `json:"status"`
@@ -88,11 +88,11 @@ func fetchGroupData(ctx context.Context, client *http.Client, groupConfig map[st
 	}
 
 	g, gCtx := errgroup.WithContext(ctx)
-	g.Go(func() error { 
-		return getAndDecode(gCtx, client, baseURL+apiStatusPath+slug, &statusData) 
+	g.Go(func() error {
+		return getAndDecode(gCtx, client, baseURL+apiStatusPath+slug, &statusData)
 	})
-	g.Go(func() error { 
-		return getAndDecode(gCtx, client, baseURL+apiHeartbeatPath+slug, &heartbeatData) 
+	g.Go(func() error {
+		return getAndDecode(gCtx, client, baseURL+apiHeartbeatPath+slug, &heartbeatData)
 	})
 
 	if g.Wait() != nil {
@@ -139,7 +139,7 @@ func GetUptimeKumaStatus(c *gin.Context) {
 
 	client := &http.Client{Timeout: httpTimeout}
 	results := make([]UptimeGroupResult, len(groups))
-	
+
 	g, gCtx := errgroup.WithContext(ctx)
 	for i, group := range groups {
 		i, group := i, group
@@ -148,7 +148,7 @@ func GetUptimeKumaStatus(c *gin.Context) {
 			return nil
 		})
 	}
-	
+
 	g.Wait()
 	c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": results})
-} 
+}

+ 294 - 12
controller/user.go

@@ -7,6 +7,7 @@ import (
 	"net/url"
 	"one-api/common"
 	"one-api/dto"
+	"one-api/logger"
 	"one-api/model"
 	"one-api/setting"
 	"strconv"
@@ -62,6 +63,32 @@ func Login(c *gin.Context) {
 		})
 		return
 	}
+
+	// 检查是否启用2FA
+	if model.IsTwoFAEnabled(user.Id) {
+		// 设置pending session,等待2FA验证
+		session := sessions.Default(c)
+		session.Set("pending_username", user.Username)
+		session.Set("pending_user_id", user.Id)
+		err := session.Save()
+		if err != nil {
+			c.JSON(http.StatusOK, gin.H{
+				"message": "无法保存会话信息,请重试",
+				"success": false,
+			})
+			return
+		}
+
+		c.JSON(http.StatusOK, gin.H{
+			"message": "请输入两步验证码",
+			"success": true,
+			"data": map[string]interface{}{
+				"require_2fa": true,
+			},
+		})
+		return
+	}
+
 	setupLogin(&user, c)
 }
 
@@ -166,7 +193,7 @@ func Register(c *gin.Context) {
 			"success": false,
 			"message": "数据库错误,请稍后重试",
 		})
-		common.SysError(fmt.Sprintf("CheckUserExistOrDeleted error: %v", err))
+		common.SysLog(fmt.Sprintf("CheckUserExistOrDeleted error: %v", err))
 		return
 	}
 	if exist {
@@ -183,6 +210,7 @@ func Register(c *gin.Context) {
 		Password:    user.Password,
 		DisplayName: user.Username,
 		InviterId:   inviterId,
+		Role:        common.RoleCommonUser, // 明确设置角色为普通用户
 	}
 	if common.EmailVerificationEnabled {
 		cleanUser.Email = user.Email
@@ -209,7 +237,7 @@ func Register(c *gin.Context) {
 				"success": false,
 				"message": "生成默认令牌失败",
 			})
-			common.SysError("failed to generate token key: " + err.Error())
+			common.SysLog("failed to generate token key: " + err.Error())
 			return
 		}
 		// 生成默认令牌
@@ -316,7 +344,7 @@ func GenerateAccessToken(c *gin.Context) {
 			"success": false,
 			"message": "生成失败",
 		})
-		common.SysError("failed to generate key: " + err.Error())
+		common.SysLog("failed to generate key: " + err.Error())
 		return
 	}
 	user.SetAccessToken(key)
@@ -399,6 +427,7 @@ func GetAffCode(c *gin.Context) {
 
 func GetSelf(c *gin.Context) {
 	id := c.GetInt("id")
+	userRole := c.GetInt("role")
 	user, err := model.GetUserById(id, false)
 	if err != nil {
 		common.ApiError(c, err)
@@ -407,14 +436,134 @@ func GetSelf(c *gin.Context) {
 	// Hide admin remarks: set to empty to trigger omitempty tag, ensuring the remark field is not included in JSON returned to regular users
 	user.Remark = ""
 
+	// 计算用户权限信息
+	permissions := calculateUserPermissions(userRole)
+
+	// 获取用户设置并提取sidebar_modules
+	userSetting := user.GetSetting()
+
+	// 构建响应数据,包含用户信息和权限
+	responseData := map[string]interface{}{
+		"id":                user.Id,
+		"username":          user.Username,
+		"display_name":      user.DisplayName,
+		"role":              user.Role,
+		"status":            user.Status,
+		"email":             user.Email,
+		"group":             user.Group,
+		"quota":             user.Quota,
+		"used_quota":        user.UsedQuota,
+		"request_count":     user.RequestCount,
+		"aff_code":          user.AffCode,
+		"aff_count":         user.AffCount,
+		"aff_quota":         user.AffQuota,
+		"aff_history_quota": user.AffHistoryQuota,
+		"inviter_id":        user.InviterId,
+		"linux_do_id":       user.LinuxDOId,
+		"setting":           user.Setting,
+		"stripe_customer":   user.StripeCustomer,
+		"sidebar_modules":   userSetting.SidebarModules, // 正确提取sidebar_modules字段
+		"permissions":       permissions,                // 新增权限字段
+	}
+
 	c.JSON(http.StatusOK, gin.H{
 		"success": true,
 		"message": "",
-		"data":    user,
+		"data":    responseData,
 	})
 	return
 }
 
+// 计算用户权限的辅助函数
+func calculateUserPermissions(userRole int) map[string]interface{} {
+	permissions := map[string]interface{}{}
+
+	// 根据用户角色计算权限
+	if userRole == common.RoleRootUser {
+		// 超级管理员不需要边栏设置功能
+		permissions["sidebar_settings"] = false
+		permissions["sidebar_modules"] = map[string]interface{}{}
+	} else if userRole == common.RoleAdminUser {
+		// 管理员可以设置边栏,但不包含系统设置功能
+		permissions["sidebar_settings"] = true
+		permissions["sidebar_modules"] = map[string]interface{}{
+			"admin": map[string]interface{}{
+				"setting": false, // 管理员不能访问系统设置
+			},
+		}
+	} else {
+		// 普通用户只能设置个人功能,不包含管理员区域
+		permissions["sidebar_settings"] = true
+		permissions["sidebar_modules"] = map[string]interface{}{
+			"admin": false, // 普通用户不能访问管理员区域
+		}
+	}
+
+	return permissions
+}
+
+// 根据用户角色生成默认的边栏配置
+func generateDefaultSidebarConfig(userRole int) string {
+	defaultConfig := map[string]interface{}{}
+
+	// 聊天区域 - 所有用户都可以访问
+	defaultConfig["chat"] = map[string]interface{}{
+		"enabled":    true,
+		"playground": true,
+		"chat":       true,
+	}
+
+	// 控制台区域 - 所有用户都可以访问
+	defaultConfig["console"] = map[string]interface{}{
+		"enabled":    true,
+		"detail":     true,
+		"token":      true,
+		"log":        true,
+		"midjourney": true,
+		"task":       true,
+	}
+
+	// 个人中心区域 - 所有用户都可以访问
+	defaultConfig["personal"] = map[string]interface{}{
+		"enabled":  true,
+		"topup":    true,
+		"personal": true,
+	}
+
+	// 管理员区域 - 根据角色决定
+	if userRole == common.RoleAdminUser {
+		// 管理员可以访问管理员区域,但不能访问系统设置
+		defaultConfig["admin"] = map[string]interface{}{
+			"enabled":    true,
+			"channel":    true,
+			"models":     true,
+			"redemption": true,
+			"user":       true,
+			"setting":    false, // 管理员不能访问系统设置
+		}
+	} else if userRole == common.RoleRootUser {
+		// 超级管理员可以访问所有功能
+		defaultConfig["admin"] = map[string]interface{}{
+			"enabled":    true,
+			"channel":    true,
+			"models":     true,
+			"redemption": true,
+			"user":       true,
+			"setting":    true,
+		}
+	}
+	// 普通用户不包含admin区域
+
+	// 转换为JSON字符串
+	configBytes, err := json.Marshal(defaultConfig)
+	if err != nil {
+		common.SysLog("生成默认边栏配置失败: " + err.Error())
+		return ""
+	}
+
+	return string(configBytes)
+}
+
 func GetUserModels(c *gin.Context) {
 	id, err := strconv.Atoi(c.Param("id"))
 	if err != nil {
@@ -491,7 +640,7 @@ func UpdateUser(c *gin.Context) {
 		return
 	}
 	if originUser.Quota != updatedUser.Quota {
-		model.RecordLog(originUser.Id, model.LogTypeManage, fmt.Sprintf("管理员将用户额度从 %s修改为 %s", common.LogQuota(originUser.Quota), common.LogQuota(updatedUser.Quota)))
+		model.RecordLog(originUser.Id, model.LogTypeManage, fmt.Sprintf("管理员将用户额度从 %s修改为 %s", logger.LogQuota(originUser.Quota), logger.LogQuota(updatedUser.Quota)))
 	}
 	c.JSON(http.StatusOK, gin.H{
 		"success": true,
@@ -501,8 +650,61 @@ func UpdateUser(c *gin.Context) {
 }
 
 func UpdateSelf(c *gin.Context) {
+	var requestData map[string]interface{}
+	err := json.NewDecoder(c.Request.Body).Decode(&requestData)
+	if err != nil {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "无效的参数",
+		})
+		return
+	}
+
+	// 检查是否是sidebar_modules更新请求
+	if sidebarModules, exists := requestData["sidebar_modules"]; exists {
+		userId := c.GetInt("id")
+		user, err := model.GetUserById(userId, false)
+		if err != nil {
+			common.ApiError(c, err)
+			return
+		}
+
+		// 获取当前用户设置
+		currentSetting := user.GetSetting()
+
+		// 更新sidebar_modules字段
+		if sidebarModulesStr, ok := sidebarModules.(string); ok {
+			currentSetting.SidebarModules = sidebarModulesStr
+		}
+
+		// 保存更新后的设置
+		user.SetSetting(currentSetting)
+		if err := user.Update(false); err != nil {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": "更新设置失败: " + err.Error(),
+			})
+			return
+		}
+
+		c.JSON(http.StatusOK, gin.H{
+			"success": true,
+			"message": "设置更新成功",
+		})
+		return
+	}
+
+	// 原有的用户信息更新逻辑
 	var user model.User
-	err := json.NewDecoder(c.Request.Body).Decode(&user)
+	requestDataBytes, err := json.Marshal(requestData)
+	if err != nil {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "无效的参数",
+		})
+		return
+	}
+	err = json.Unmarshal(requestDataBytes, &user)
 	if err != nil {
 		c.JSON(http.StatusOK, gin.H{
 			"success": false,
@@ -510,6 +712,7 @@ func UpdateSelf(c *gin.Context) {
 		})
 		return
 	}
+
 	if user.Password == "" {
 		user.Password = "$I_LOVE_U" // make Validator happy :)
 	}
@@ -652,6 +855,7 @@ func CreateUser(c *gin.Context) {
 		Username:    user.Username,
 		Password:    user.Password,
 		DisplayName: user.DisplayName,
+		Role:        user.Role, // 保持管理员设置的角色
 	}
 	if err := cleanUser.Insert(0); err != nil {
 		common.ApiError(c, err)
@@ -817,18 +1021,64 @@ type topUpRequest struct {
 	Key string `json:"key"`
 }
 
-var topUpLock = sync.Mutex{}
+var topUpLocks sync.Map
+var topUpCreateLock sync.Mutex
+
+type topUpTryLock struct {
+	ch chan struct{}
+}
+
+func newTopUpTryLock() *topUpTryLock {
+	return &topUpTryLock{ch: make(chan struct{}, 1)}
+}
+
+func (l *topUpTryLock) TryLock() bool {
+	select {
+	case l.ch <- struct{}{}:
+		return true
+	default:
+		return false
+	}
+}
+
+func (l *topUpTryLock) Unlock() {
+	select {
+	case <-l.ch:
+	default:
+	}
+}
+
+func getTopUpLock(userID int) *topUpTryLock {
+	if v, ok := topUpLocks.Load(userID); ok {
+		return v.(*topUpTryLock)
+	}
+	topUpCreateLock.Lock()
+	defer topUpCreateLock.Unlock()
+	if v, ok := topUpLocks.Load(userID); ok {
+		return v.(*topUpTryLock)
+	}
+	l := newTopUpTryLock()
+	topUpLocks.Store(userID, l)
+	return l
+}
 
 func TopUp(c *gin.Context) {
-	topUpLock.Lock()
-	defer topUpLock.Unlock()
+	id := c.GetInt("id")
+	lock := getTopUpLock(id)
+	if !lock.TryLock() {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "充值处理中,请稍后重试",
+		})
+		return
+	}
+	defer lock.Unlock()
 	req := topUpRequest{}
 	err := c.ShouldBindJSON(&req)
 	if err != nil {
 		common.ApiError(c, err)
 		return
 	}
-	id := c.GetInt("id")
 	quota, err := model.Redeem(req.Key, id)
 	if err != nil {
 		common.ApiError(c, err)
@@ -839,7 +1089,6 @@ func TopUp(c *gin.Context) {
 		"message": "",
 		"data":    quota,
 	})
-	return
 }
 
 type UpdateUserSettingRequest struct {
@@ -848,6 +1097,7 @@ type UpdateUserSettingRequest struct {
 	WebhookUrl                 string  `json:"webhook_url,omitempty"`
 	WebhookSecret              string  `json:"webhook_secret,omitempty"`
 	NotificationEmail          string  `json:"notification_email,omitempty"`
+	BarkUrl                    string  `json:"bark_url,omitempty"`
 	AcceptUnsetModelRatioModel bool    `json:"accept_unset_model_ratio_model"`
 	RecordIpLog                bool    `json:"record_ip_log"`
 }
@@ -863,7 +1113,7 @@ func UpdateUserSetting(c *gin.Context) {
 	}
 
 	// 验证预警类型
-	if req.QuotaWarningType != dto.NotifyTypeEmail && req.QuotaWarningType != dto.NotifyTypeWebhook {
+	if req.QuotaWarningType != dto.NotifyTypeEmail && req.QuotaWarningType != dto.NotifyTypeWebhook && req.QuotaWarningType != dto.NotifyTypeBark {
 		c.JSON(http.StatusOK, gin.H{
 			"success": false,
 			"message": "无效的预警类型",
@@ -911,6 +1161,33 @@ func UpdateUserSetting(c *gin.Context) {
 		}
 	}
 
+	// 如果是Bark类型,验证Bark URL
+	if req.QuotaWarningType == dto.NotifyTypeBark {
+		if req.BarkUrl == "" {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": "Bark推送URL不能为空",
+			})
+			return
+		}
+		// 验证URL格式
+		if _, err := url.ParseRequestURI(req.BarkUrl); err != nil {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": "无效的Bark推送URL",
+			})
+			return
+		}
+		// 检查是否是HTTP或HTTPS
+		if !strings.HasPrefix(req.BarkUrl, "https://") && !strings.HasPrefix(req.BarkUrl, "http://") {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": "Bark推送URL必须以http://或https://开头",
+			})
+			return
+		}
+	}
+
 	userId := c.GetInt("id")
 	user, err := model.GetUserById(userId, true)
 	if err != nil {
@@ -939,6 +1216,11 @@ func UpdateUserSetting(c *gin.Context) {
 		settings.NotificationEmail = req.NotificationEmail
 	}
 
+	// 如果是Bark类型,添加Bark URL到设置中
+	if req.QuotaWarningType == dto.NotifyTypeBark {
+		settings.BarkUrl = req.BarkUrl
+	}
+
 	// 更新用户设置
 	user.SetSetting(settings)
 	if err := user.Update(false); err != nil {

+ 124 - 0
controller/vendor_meta.go

@@ -0,0 +1,124 @@
+package controller
+
+import (
+	"strconv"
+
+	"one-api/common"
+	"one-api/model"
+
+	"github.com/gin-gonic/gin"
+)
+
+// GetAllVendors 获取供应商列表(分页)
+func GetAllVendors(c *gin.Context) {
+	pageInfo := common.GetPageQuery(c)
+	vendors, err := model.GetAllVendors(pageInfo.GetStartIdx(), pageInfo.GetPageSize())
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+	var total int64
+	model.DB.Model(&model.Vendor{}).Count(&total)
+	pageInfo.SetTotal(int(total))
+	pageInfo.SetItems(vendors)
+	common.ApiSuccess(c, pageInfo)
+}
+
+// SearchVendors 搜索供应商
+func SearchVendors(c *gin.Context) {
+	keyword := c.Query("keyword")
+	pageInfo := common.GetPageQuery(c)
+	vendors, total, err := model.SearchVendors(keyword, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+	pageInfo.SetTotal(int(total))
+	pageInfo.SetItems(vendors)
+	common.ApiSuccess(c, pageInfo)
+}
+
+// GetVendorMeta 根据 ID 获取供应商
+func GetVendorMeta(c *gin.Context) {
+	idStr := c.Param("id")
+	id, err := strconv.Atoi(idStr)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+	v, err := model.GetVendorByID(id)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+	common.ApiSuccess(c, v)
+}
+
+// CreateVendorMeta 新建供应商
+func CreateVendorMeta(c *gin.Context) {
+	var v model.Vendor
+	if err := c.ShouldBindJSON(&v); err != nil {
+		common.ApiError(c, err)
+		return
+	}
+	if v.Name == "" {
+		common.ApiErrorMsg(c, "供应商名称不能为空")
+		return
+	}
+	// 创建前先检查名称
+	if dup, err := model.IsVendorNameDuplicated(0, v.Name); err != nil {
+		common.ApiError(c, err)
+		return
+	} else if dup {
+		common.ApiErrorMsg(c, "供应商名称已存在")
+		return
+	}
+
+	if err := v.Insert(); err != nil {
+		common.ApiError(c, err)
+		return
+	}
+	common.ApiSuccess(c, &v)
+}
+
+// UpdateVendorMeta 更新供应商
+func UpdateVendorMeta(c *gin.Context) {
+	var v model.Vendor
+	if err := c.ShouldBindJSON(&v); err != nil {
+		common.ApiError(c, err)
+		return
+	}
+	if v.Id == 0 {
+		common.ApiErrorMsg(c, "缺少供应商 ID")
+		return
+	}
+	// 名称冲突检查
+	if dup, err := model.IsVendorNameDuplicated(v.Id, v.Name); err != nil {
+		common.ApiError(c, err)
+		return
+	} else if dup {
+		common.ApiErrorMsg(c, "供应商名称已存在")
+		return
+	}
+
+	if err := v.Update(); err != nil {
+		common.ApiError(c, err)
+		return
+	}
+	common.ApiSuccess(c, &v)
+}
+
+// DeleteVendorMeta 删除供应商
+func DeleteVendorMeta(c *gin.Context) {
+	idStr := c.Param("id")
+	id, err := strconv.Atoi(idStr)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+	if err := model.DB.Delete(&model.Vendor{}, id).Error; err != nil {
+		common.ApiError(c, err)
+		return
+	}
+	common.ApiSuccess(c, nil)
+}

+ 1 - 1
docker-compose.yml

@@ -16,7 +16,7 @@ services:
       - REDIS_CONN_STRING=redis://redis
       - TZ=Asia/Shanghai
       - ERROR_LOG_ENABLED=true # 是否启用错误日志记录
-    #      - STREAMING_TIMEOUT=120  # 流模式无响应超时时间,单位秒,默认120秒,如果出现空补全可以尝试改为更大值
+    #      - 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

+ 24 - 0
dto/audio.go

@@ -1,5 +1,11 @@
 package dto
 
+import (
+	"one-api/types"
+
+	"github.com/gin-gonic/gin"
+)
+
 type AudioRequest struct {
 	Model          string  `json:"model"`
 	Input          string  `json:"input"`
@@ -8,6 +14,24 @@ type AudioRequest struct {
 	ResponseFormat string  `json:"response_format,omitempty"`
 }
 
+func (r *AudioRequest) GetTokenCountMeta() *types.TokenCountMeta {
+	meta := &types.TokenCountMeta{
+		CombineText: r.Input,
+		TokenType:   types.TokenTypeTextNumber,
+	}
+	return meta
+}
+
+func (r *AudioRequest) IsStream(c *gin.Context) bool {
+	return false
+}
+
+func (r *AudioRequest) SetModelName(modelName string) {
+	if modelName != "" {
+		r.Model = modelName
+	}
+}
+
 type AudioResponse struct {
 	Text string `json:"text"`
 }

+ 18 - 3
dto/channel_settings.go

@@ -1,7 +1,22 @@
 package dto
 
 type ChannelSettings struct {
-	ForceFormat       bool   `json:"force_format,omitempty"`
-	ThinkingToContent bool   `json:"thinking_to_content,omitempty"`
-	Proxy             string `json:"proxy"`
+	ForceFormat            bool   `json:"force_format,omitempty"`
+	ThinkingToContent      bool   `json:"thinking_to_content,omitempty"`
+	Proxy                  string `json:"proxy"`
+	PassThroughBodyEnabled bool   `json:"pass_through_body_enabled,omitempty"`
+	SystemPrompt           string `json:"system_prompt,omitempty"`
+	SystemPromptOverride   bool   `json:"system_prompt_override,omitempty"`
+}
+
+type VertexKeyType string
+
+const (
+	VertexKeyTypeJSON   VertexKeyType = "json"
+	VertexKeyTypeAPIKey VertexKeyType = "api_key"
+)
+
+type ChannelOtherSettings struct {
+	AzureResponsesVersion string        `json:"azure_responses_version,omitempty"`
+	VertexKeyType         VertexKeyType `json:"vertex_key_type,omitempty"` // "json" or "api_key"
 }

+ 186 - 10
dto/claude.go

@@ -2,8 +2,12 @@ package dto
 
 import (
 	"encoding/json"
+	"fmt"
 	"one-api/common"
 	"one-api/types"
+	"strings"
+
+	"github.com/gin-gonic/gin"
 )
 
 type ClaudeMetadata struct {
@@ -80,7 +84,7 @@ func (c *ClaudeMediaMessage) GetStringContent() string {
 }
 
 func (c *ClaudeMediaMessage) GetJsonRowString() string {
-	jsonContent, _ := json.Marshal(c)
+	jsonContent, _ := common.Marshal(c)
 	return string(jsonContent)
 }
 
@@ -198,6 +202,147 @@ type ClaudeRequest struct {
 	Thinking   *Thinking `json:"thinking,omitempty"`
 }
 
+func (c *ClaudeRequest) GetTokenCountMeta() *types.TokenCountMeta {
+	var tokenCountMeta = types.TokenCountMeta{
+		TokenType: types.TokenTypeTokenizer,
+		MaxTokens: int(c.MaxTokens),
+	}
+
+	var texts = make([]string, 0)
+	var fileMeta = make([]*types.FileMeta, 0)
+
+	// system
+	if c.System != nil {
+		if c.IsStringSystem() {
+			sys := c.GetStringSystem()
+			if sys != "" {
+				texts = append(texts, sys)
+			}
+		} else {
+			systemMedia := c.ParseSystem()
+			for _, media := range systemMedia {
+				switch media.Type {
+				case "text":
+					texts = append(texts, media.GetText())
+				case "image":
+					if media.Source != nil {
+						data := media.Source.Url
+						if data == "" {
+							data = common.Interface2String(media.Source.Data)
+						}
+						if data != "" {
+							fileMeta = append(fileMeta, &types.FileMeta{FileType: types.FileTypeImage, OriginData: data})
+						}
+					}
+				}
+			}
+		}
+	}
+
+	// messages
+	for _, message := range c.Messages {
+		tokenCountMeta.MessagesCount++
+		texts = append(texts, message.Role)
+		if message.IsStringContent() {
+			content := message.GetStringContent()
+			if content != "" {
+				texts = append(texts, content)
+			}
+			continue
+		}
+
+		content, _ := message.ParseContent()
+		for _, media := range content {
+			switch media.Type {
+			case "text":
+				texts = append(texts, media.GetText())
+			case "image":
+				if media.Source != nil {
+					data := media.Source.Url
+					if data == "" {
+						data = common.Interface2String(media.Source.Data)
+					}
+					if data != "" {
+						fileMeta = append(fileMeta, &types.FileMeta{FileType: types.FileTypeImage, OriginData: data})
+					}
+				}
+			case "tool_use":
+				if media.Name != "" {
+					texts = append(texts, media.Name)
+				}
+				if media.Input != nil {
+					b, _ := common.Marshal(media.Input)
+					texts = append(texts, string(b))
+				}
+			case "tool_result":
+				if media.Content != nil {
+					b, _ := common.Marshal(media.Content)
+					texts = append(texts, string(b))
+				}
+			}
+		}
+	}
+
+	// tools
+	if c.Tools != nil {
+		tools := c.GetTools()
+		normalTools, webSearchTools := ProcessTools(tools)
+		if normalTools != nil {
+			for _, t := range normalTools {
+				tokenCountMeta.ToolsCount++
+				if t.Name != "" {
+					texts = append(texts, t.Name)
+				}
+				if t.Description != "" {
+					texts = append(texts, t.Description)
+				}
+				if t.InputSchema != nil {
+					b, _ := common.Marshal(t.InputSchema)
+					texts = append(texts, string(b))
+				}
+			}
+		}
+		if webSearchTools != nil {
+			for _, t := range webSearchTools {
+				tokenCountMeta.ToolsCount++
+				if t.Name != "" {
+					texts = append(texts, t.Name)
+				}
+				if t.UserLocation != nil {
+					b, _ := common.Marshal(t.UserLocation)
+					texts = append(texts, string(b))
+				}
+			}
+		}
+	}
+
+	tokenCountMeta.CombineText = strings.Join(texts, "\n")
+	tokenCountMeta.Files = fileMeta
+	return &tokenCountMeta
+}
+
+func (c *ClaudeRequest) IsStream(ctx *gin.Context) bool {
+	return c.Stream
+}
+
+func (c *ClaudeRequest) SetModelName(modelName string) {
+	if modelName != "" {
+		c.Model = modelName
+	}
+}
+
+func (c *ClaudeRequest) SearchToolNameByToolCallId(toolCallId string) string {
+	for _, message := range c.Messages {
+		content, _ := message.ParseContent()
+		for _, mediaMessage := range content {
+			if mediaMessage.Id == toolCallId {
+				return mediaMessage.Name
+			}
+		}
+	}
+	return ""
+}
+
 // AddTool 添加工具到请求中
 func (c *ClaudeRequest) AddTool(tool any) {
 	if c.Tools == nil {
@@ -284,14 +429,9 @@ func (c *ClaudeRequest) ParseSystem() []ClaudeMediaMessage {
 	return mediaContent
 }
 
-type ClaudeError struct {
-	Type    string `json:"type,omitempty"`
-	Message string `json:"message,omitempty"`
-}
-
 type ClaudeErrorWithStatusCode struct {
-	Error      ClaudeError `json:"error"`
-	StatusCode int         `json:"status_code"`
+	Error      types.ClaudeError `json:"error"`
+	StatusCode int               `json:"status_code"`
 	LocalError bool
 }
 
@@ -303,7 +443,7 @@ type ClaudeResponse struct {
 	Completion   string               `json:"completion,omitempty"`
 	StopReason   string               `json:"stop_reason,omitempty"`
 	Model        string               `json:"model,omitempty"`
-	Error        *types.ClaudeError   `json:"error,omitempty"`
+	Error        any                  `json:"error,omitempty"`
 	Usage        *ClaudeUsage         `json:"usage,omitempty"`
 	Index        *int                 `json:"index,omitempty"`
 	ContentBlock *ClaudeMediaMessage  `json:"content_block,omitempty"`
@@ -324,12 +464,48 @@ func (c *ClaudeResponse) GetIndex() int {
 	return *c.Index
 }
 
+// GetClaudeError 从动态错误类型中提取ClaudeError结构
+func (c *ClaudeResponse) GetClaudeError() *types.ClaudeError {
+	if c.Error == nil {
+		return nil
+	}
+
+	switch err := c.Error.(type) {
+	case types.ClaudeError:
+		return &err
+	case *types.ClaudeError:
+		return err
+	case map[string]interface{}:
+		// 处理从JSON解析来的map结构
+		claudeErr := &types.ClaudeError{}
+		if errType, ok := err["type"].(string); ok {
+			claudeErr.Type = errType
+		}
+		if errMsg, ok := err["message"].(string); ok {
+			claudeErr.Message = errMsg
+		}
+		return claudeErr
+	case string:
+		// 处理简单字符串错误
+		return &types.ClaudeError{
+			Type:    "upstream_error",
+			Message: err,
+		}
+	default:
+		// 未知类型,尝试转换为字符串
+		return &types.ClaudeError{
+			Type:    "unknown_upstream_error",
+			Message: fmt.Sprintf("unknown_error: %v", err),
+		}
+	}
+}
+
 type ClaudeUsage struct {
 	InputTokens              int                  `json:"input_tokens"`
 	CacheCreationInputTokens int                  `json:"cache_creation_input_tokens"`
 	CacheReadInputTokens     int                  `json:"cache_read_input_tokens"`
 	OutputTokens             int                  `json:"output_tokens"`
-	ServerToolUse            *ClaudeServerToolUse `json:"server_tool_use"`
+	ServerToolUse            *ClaudeServerToolUse `json:"server_tool_use,omitempty"`
 }
 
 type ClaudeServerToolUse struct {

+ 0 - 29
dto/dalle.go

@@ -1,29 +0,0 @@
-package dto
-
-import "encoding/json"
-
-type ImageRequest struct {
-	Model          string          `json:"model"`
-	Prompt         string          `json:"prompt" binding:"required"`
-	N              int             `json:"n,omitempty"`
-	Size           string          `json:"size,omitempty"`
-	Quality        string          `json:"quality,omitempty"`
-	ResponseFormat string          `json:"response_format,omitempty"`
-	Style          string          `json:"style,omitempty"`
-	User           string          `json:"user,omitempty"`
-	ExtraFields    json.RawMessage `json:"extra_fields,omitempty"`
-	Background     string          `json:"background,omitempty"`
-	Moderation     string          `json:"moderation,omitempty"`
-	OutputFormat   string          `json:"output_format,omitempty"`
-	Watermark      *bool           `json:"watermark,omitempty"`
-}
-
-type ImageResponse struct {
-	Data    []ImageData `json:"data"`
-	Created int64       `json:"created"`
-}
-type ImageData struct {
-	Url           string `json:"url"`
-	B64Json       string `json:"b64_json"`
-	RevisedPrompt string `json:"revised_prompt"`
-}

+ 32 - 2
dto/embedding.go

@@ -1,5 +1,12 @@
 package dto
 
+import (
+	"one-api/types"
+	"strings"
+
+	"github.com/gin-gonic/gin"
+)
+
 type EmbeddingOptions struct {
 	Seed             int      `json:"seed,omitempty"`
 	Temperature      *float64 `json:"temperature,omitempty"`
@@ -24,9 +31,32 @@ type EmbeddingRequest struct {
 	PresencePenalty  float64  `json:"presence_penalty,omitempty"`
 }
 
-func (r EmbeddingRequest) ParseInput() []string {
+func (r *EmbeddingRequest) GetTokenCountMeta() *types.TokenCountMeta {
+	var texts = make([]string, 0)
+
+	inputs := r.ParseInput()
+	for _, input := range inputs {
+		texts = append(texts, input)
+	}
+
+	return &types.TokenCountMeta{
+		CombineText: strings.Join(texts, "\n"),
+	}
+}
+
+func (r *EmbeddingRequest) IsStream(c *gin.Context) bool {
+	return false
+}
+
+func (r *EmbeddingRequest) SetModelName(modelName string) {
+	if modelName != "" {
+		r.Model = modelName
+	}
+}
+
+func (r *EmbeddingRequest) ParseInput() []string {
 	if r.Input == nil {
-		return nil
+		return make([]string, 0)
 	}
 	var input []string
 	switch r.Input.(type) {

+ 200 - 7
relay/channel/gemini/dto.go → dto/gemini.go

@@ -1,13 +1,138 @@
-package gemini
+package dto
 
-import "encoding/json"
+import (
+	"encoding/json"
+	"github.com/gin-gonic/gin"
+	"one-api/common"
+	"one-api/logger"
+	"one-api/types"
+	"strings"
+)
 
 type GeminiChatRequest struct {
 	Contents           []GeminiChatContent        `json:"contents"`
 	SafetySettings     []GeminiChatSafetySettings `json:"safetySettings,omitempty"`
 	GenerationConfig   GeminiChatGenerationConfig `json:"generationConfig,omitempty"`
-	Tools              []GeminiChatTool           `json:"tools,omitempty"`
+	Tools              json.RawMessage            `json:"tools,omitempty"`
+	ToolConfig         *ToolConfig                `json:"toolConfig,omitempty"`
 	SystemInstructions *GeminiChatContent         `json:"systemInstruction,omitempty"`
+	CachedContent      string                     `json:"cachedContent,omitempty"`
+}
+
+type ToolConfig struct {
+	FunctionCallingConfig *FunctionCallingConfig `json:"functionCallingConfig,omitempty"`
+	RetrievalConfig       *RetrievalConfig       `json:"retrievalConfig,omitempty"`
+}
+
+type FunctionCallingConfig struct {
+	Mode                 FunctionCallingConfigMode `json:"mode,omitempty"`
+	AllowedFunctionNames []string                  `json:"allowedFunctionNames,omitempty"`
+}
+type FunctionCallingConfigMode string
+
+type RetrievalConfig struct {
+	LatLng       *LatLng `json:"latLng,omitempty"`
+	LanguageCode string  `json:"languageCode,omitempty"`
+}
+
+type LatLng struct {
+	Latitude  *float64 `json:"latitude,omitempty"`
+	Longitude *float64 `json:"longitude,omitempty"`
+}
+
+func (r *GeminiChatRequest) GetTokenCountMeta() *types.TokenCountMeta {
+	var files []*types.FileMeta = make([]*types.FileMeta, 0)
+
+	var maxTokens int
+
+	if r.GenerationConfig.MaxOutputTokens > 0 {
+		maxTokens = int(r.GenerationConfig.MaxOutputTokens)
+	}
+
+	var inputTexts []string
+	for _, content := range r.Contents {
+		for _, part := range content.Parts {
+			if part.Text != "" {
+				inputTexts = append(inputTexts, part.Text)
+			}
+			if part.InlineData != nil && part.InlineData.Data != "" {
+				if strings.HasPrefix(part.InlineData.MimeType, "image/") {
+					files = append(files, &types.FileMeta{
+						FileType:   types.FileTypeImage,
+						OriginData: part.InlineData.Data,
+					})
+				} else if strings.HasPrefix(part.InlineData.MimeType, "audio/") {
+					files = append(files, &types.FileMeta{
+						FileType:   types.FileTypeAudio,
+						OriginData: part.InlineData.Data,
+					})
+				} else if strings.HasPrefix(part.InlineData.MimeType, "video/") {
+					files = append(files, &types.FileMeta{
+						FileType:   types.FileTypeVideo,
+						OriginData: part.InlineData.Data,
+					})
+				} else {
+					files = append(files, &types.FileMeta{
+						FileType:   types.FileTypeFile,
+						OriginData: part.InlineData.Data,
+					})
+				}
+			}
+		}
+	}
+
+	inputText := strings.Join(inputTexts, "\n")
+	return &types.TokenCountMeta{
+		CombineText: inputText,
+		Files:       files,
+		MaxTokens:   maxTokens,
+	}
+}
+
+func (r *GeminiChatRequest) IsStream(c *gin.Context) bool {
+	if c.Query("alt") == "sse" {
+		return true
+	}
+	return false
+}
+
+func (r *GeminiChatRequest) SetModelName(modelName string) {
+	// GeminiChatRequest does not have a model field, so this method does nothing.
+}
+
+func (r *GeminiChatRequest) GetTools() []GeminiChatTool {
+	var tools []GeminiChatTool
+	if strings.HasSuffix(string(r.Tools), "[") {
+		// is array
+		if err := common.Unmarshal(r.Tools, &tools); err != nil {
+			logger.LogError(nil, "error_unmarshalling_tools: "+err.Error())
+			return nil
+		}
+	} else if strings.HasPrefix(string(r.Tools), "{") {
+		// is object
+		singleTool := GeminiChatTool{}
+		if err := common.Unmarshal(r.Tools, &singleTool); err != nil {
+			logger.LogError(nil, "error_unmarshalling_single_tool: "+err.Error())
+			return nil
+		}
+		tools = []GeminiChatTool{singleTool}
+	}
+	return tools
+}
+
+func (r *GeminiChatRequest) SetTools(tools []GeminiChatTool) {
+	if len(tools) == 0 {
+		r.Tools = json.RawMessage("[]")
+		return
+	}
+
+	// Marshal the tools to JSON
+	data, err := common.Marshal(tools)
+	if err != nil {
+		logger.LogError(nil, "error_marshalling_tools: "+err.Error())
+		return
+	}
+	r.Tools = data
 }
 
 type GeminiThinkingConfig struct {
@@ -32,7 +157,7 @@ func (g *GeminiInlineData) UnmarshalJSON(data []byte) error {
 		MimeTypeSnake string `json:"mime_type"`
 	}
 
-	if err := json.Unmarshal(data, &aux); err != nil {
+	if err := common.Unmarshal(data, &aux); err != nil {
 		return err
 	}
 
@@ -53,7 +178,7 @@ type FunctionCall struct {
 	Arguments    any    `json:"args"`
 }
 
-type FunctionResponse struct {
+type GeminiFunctionResponse struct {
 	Name     string                 `json:"name"`
 	Response map[string]interface{} `json:"response"`
 }
@@ -78,7 +203,7 @@ type GeminiPart struct {
 	Thought             bool                           `json:"thought,omitempty"`
 	InlineData          *GeminiInlineData              `json:"inlineData,omitempty"`
 	FunctionCall        *FunctionCall                  `json:"functionCall,omitempty"`
-	FunctionResponse    *FunctionResponse              `json:"functionResponse,omitempty"`
+	FunctionResponse    *GeminiFunctionResponse        `json:"functionResponse,omitempty"`
 	FileData            *GeminiFileData                `json:"fileData,omitempty"`
 	ExecutableCode      *GeminiPartExecutableCode      `json:"executableCode,omitempty"`
 	CodeExecutionResult *GeminiPartCodeExecutionResult `json:"codeExecutionResult,omitempty"`
@@ -93,7 +218,7 @@ func (p *GeminiPart) UnmarshalJSON(data []byte) error {
 		InlineDataSnake *GeminiInlineData `json:"inline_data,omitempty"` // snake_case variant
 	}
 
-	if err := json.Unmarshal(data, &aux); err != nil {
+	if err := common.Unmarshal(data, &aux); err != nil {
 		return err
 	}
 
@@ -137,12 +262,20 @@ type GeminiChatGenerationConfig struct {
 	StopSequences      []string              `json:"stopSequences,omitempty"`
 	ResponseMimeType   string                `json:"responseMimeType,omitempty"`
 	ResponseSchema     any                   `json:"responseSchema,omitempty"`
+	ResponseJsonSchema json.RawMessage       `json:"responseJsonSchema,omitempty"`
+	PresencePenalty    *float32              `json:"presencePenalty,omitempty"`
+	FrequencyPenalty   *float32              `json:"frequencyPenalty,omitempty"`
+	ResponseLogprobs   bool                  `json:"responseLogprobs,omitempty"`
+	Logprobs           *int32                `json:"logprobs,omitempty"`
+	MediaResolution    MediaResolution       `json:"mediaResolution,omitempty"`
 	Seed               int64                 `json:"seed,omitempty"`
 	ResponseModalities []string              `json:"responseModalities,omitempty"`
 	ThinkingConfig     *GeminiThinkingConfig `json:"thinkingConfig,omitempty"`
 	SpeechConfig       json.RawMessage       `json:"speechConfig,omitempty"` // RawMessage to allow flexible speech config
 }
 
+type MediaResolution string
+
 type GeminiChatCandidate struct {
 	Content       GeminiChatContent        `json:"content"`
 	FinishReason  *string                  `json:"finishReason"`
@@ -207,16 +340,76 @@ type GeminiImagePrediction struct {
 
 // Embedding related structs
 type GeminiEmbeddingRequest struct {
+	Model                string            `json:"model,omitempty"`
 	Content              GeminiChatContent `json:"content"`
 	TaskType             string            `json:"taskType,omitempty"`
 	Title                string            `json:"title,omitempty"`
 	OutputDimensionality int               `json:"outputDimensionality,omitempty"`
 }
 
+func (r *GeminiEmbeddingRequest) IsStream(c *gin.Context) bool {
+	// Gemini embedding requests are not streamed
+	return false
+}
+
+func (r *GeminiEmbeddingRequest) GetTokenCountMeta() *types.TokenCountMeta {
+	var inputTexts []string
+	for _, part := range r.Content.Parts {
+		if part.Text != "" {
+			inputTexts = append(inputTexts, part.Text)
+		}
+	}
+	inputText := strings.Join(inputTexts, "\n")
+	return &types.TokenCountMeta{
+		CombineText: inputText,
+	}
+}
+
+func (r *GeminiEmbeddingRequest) SetModelName(modelName string) {
+	if modelName != "" {
+		r.Model = modelName
+	}
+}
+
+type GeminiBatchEmbeddingRequest struct {
+	Requests []*GeminiEmbeddingRequest `json:"requests"`
+}
+
+func (r *GeminiBatchEmbeddingRequest) IsStream(c *gin.Context) bool {
+	// Gemini batch embedding requests are not streamed
+	return false
+}
+
+func (r *GeminiBatchEmbeddingRequest) GetTokenCountMeta() *types.TokenCountMeta {
+	var inputTexts []string
+	for _, request := range r.Requests {
+		meta := request.GetTokenCountMeta()
+		if meta != nil && meta.CombineText != "" {
+			inputTexts = append(inputTexts, meta.CombineText)
+		}
+	}
+	inputText := strings.Join(inputTexts, "\n")
+	return &types.TokenCountMeta{
+		CombineText: inputText,
+	}
+}
+
+func (r *GeminiBatchEmbeddingRequest) SetModelName(modelName string) {
+	if modelName != "" {
+		for _, req := range r.Requests {
+			req.SetModelName(modelName)
+		}
+	}
+}
+
 type GeminiEmbeddingResponse struct {
 	Embedding ContentEmbedding `json:"embedding"`
 }
 
+type GeminiBatchEmbeddingResponse struct {
+	Embeddings []*ContentEmbedding `json:"embeddings"`
+}
+
 type ContentEmbedding struct {
 	Values []float64 `json:"values"`
 }

+ 172 - 0
dto/openai_image.go

@@ -0,0 +1,172 @@
+package dto
+
+import (
+	"encoding/json"
+	"one-api/common"
+	"one-api/types"
+	"reflect"
+	"strings"
+
+	"github.com/gin-gonic/gin"
+)
+
+type ImageRequest struct {
+	Model             string          `json:"model"`
+	Prompt            string          `json:"prompt" binding:"required"`
+	N                 uint            `json:"n,omitempty"`
+	Size              string          `json:"size,omitempty"`
+	Quality           string          `json:"quality,omitempty"`
+	ResponseFormat    string          `json:"response_format,omitempty"`
+	Style             json.RawMessage `json:"style,omitempty"`
+	User              json.RawMessage `json:"user,omitempty"`
+	ExtraFields       json.RawMessage `json:"extra_fields,omitempty"`
+	Background        json.RawMessage `json:"background,omitempty"`
+	Moderation        json.RawMessage `json:"moderation,omitempty"`
+	OutputFormat      json.RawMessage `json:"output_format,omitempty"`
+	OutputCompression json.RawMessage `json:"output_compression,omitempty"`
+	PartialImages     json.RawMessage `json:"partial_images,omitempty"`
+	// Stream            bool            `json:"stream,omitempty"`
+	Watermark *bool `json:"watermark,omitempty"`
+	// 用匿名参数接收额外参数
+	Extra map[string]json.RawMessage `json:"-"`
+}
+
+func (i *ImageRequest) UnmarshalJSON(data []byte) error {
+	// 先解析成 map[string]interface{}
+	var rawMap map[string]json.RawMessage
+	if err := common.Unmarshal(data, &rawMap); err != nil {
+		return err
+	}
+
+	// 用 struct tag 获取所有已定义字段名
+	knownFields := GetJSONFieldNames(reflect.TypeOf(*i))
+
+	// 再正常解析已定义字段
+	type Alias ImageRequest
+	var known Alias
+	if err := common.Unmarshal(data, &known); err != nil {
+		return err
+	}
+	*i = ImageRequest(known)
+
+	// 提取多余字段
+	i.Extra = make(map[string]json.RawMessage)
+	for k, v := range rawMap {
+		if _, ok := knownFields[k]; !ok {
+			i.Extra[k] = v
+		}
+	}
+	return nil
+}
+
+// 序列化时需要重新把字段平铺
+func (r ImageRequest) MarshalJSON() ([]byte, error) {
+	// 将已定义字段转为 map
+	type Alias ImageRequest
+	alias := Alias(r)
+	base, err := common.Marshal(alias)
+	if err != nil {
+		return nil, err
+	}
+
+	var baseMap map[string]json.RawMessage
+	if err := common.Unmarshal(base, &baseMap); err != nil {
+		return nil, err
+	}
+
+	// 合并 ExtraFields
+	for k, v := range r.Extra {
+		if _, exists := baseMap[k]; !exists {
+			baseMap[k] = v
+		}
+	}
+
+	return json.Marshal(baseMap)
+}
+
+func GetJSONFieldNames(t reflect.Type) map[string]struct{} {
+	fields := make(map[string]struct{})
+	for i := 0; i < t.NumField(); i++ {
+		field := t.Field(i)
+
+		// 跳过匿名字段(例如 ExtraFields)
+		if field.Anonymous {
+			continue
+		}
+
+		tag := field.Tag.Get("json")
+		if tag == "-" || tag == "" {
+			continue
+		}
+
+		// 取逗号前字段名(排除 omitempty 等)
+		name := tag
+		if commaIdx := indexComma(tag); commaIdx != -1 {
+			name = tag[:commaIdx]
+		}
+		fields[name] = struct{}{}
+	}
+	return fields
+}
+
+func indexComma(s string) int {
+	for i := 0; i < len(s); i++ {
+		if s[i] == ',' {
+			return i
+		}
+	}
+	return -1
+}
+
+func (i *ImageRequest) GetTokenCountMeta() *types.TokenCountMeta {
+	var sizeRatio = 1.0
+	var qualityRatio = 1.0
+
+	if strings.HasPrefix(i.Model, "dall-e") {
+		// Size
+		if i.Size == "256x256" {
+			sizeRatio = 0.4
+		} else if i.Size == "512x512" {
+			sizeRatio = 0.45
+		} else if i.Size == "1024x1024" {
+			sizeRatio = 1
+		} else if i.Size == "1024x1792" || i.Size == "1792x1024" {
+			sizeRatio = 2
+		}
+
+		if i.Model == "dall-e-3" && i.Quality == "hd" {
+			qualityRatio = 2.0
+			if i.Size == "1024x1792" || i.Size == "1792x1024" {
+				qualityRatio = 1.5
+			}
+		}
+	}
+
+	// not support token count for dalle
+	return &types.TokenCountMeta{
+		CombineText:     i.Prompt,
+		MaxTokens:       1584,
+		ImagePriceRatio: sizeRatio * qualityRatio * float64(i.N),
+	}
+}
+
+func (i *ImageRequest) IsStream(c *gin.Context) bool {
+	return false
+}
+
+func (i *ImageRequest) SetModelName(modelName string) {
+	if modelName != "" {
+		i.Model = modelName
+	}
+}
+
+type ImageResponse struct {
+	Data    []ImageData `json:"data"`
+	Created int64       `json:"created"`
+	Extra   any         `json:"extra,omitempty"`
+}
+type ImageData struct {
+	Url           string `json:"url"`
+	B64Json       string `json:"b64_json"`
+	RevisedPrompt string `json:"revised_prompt"`
+}

+ 358 - 54
dto/openai_request.go

@@ -2,20 +2,24 @@ package dto
 
 import (
 	"encoding/json"
+	"fmt"
 	"one-api/common"
+	"one-api/types"
 	"strings"
+
+	"github.com/gin-gonic/gin"
 )
 
 type ResponseFormat struct {
-	Type       string            `json:"type,omitempty"`
-	JsonSchema *FormatJsonSchema `json:"json_schema,omitempty"`
+	Type       string          `json:"type,omitempty"`
+	JsonSchema json.RawMessage `json:"json_schema,omitempty"`
 }
 
 type FormatJsonSchema struct {
-	Description string `json:"description,omitempty"`
-	Name        string `json:"name"`
-	Schema      any    `json:"schema,omitempty"`
-	Strict      any    `json:"strict,omitempty"`
+	Description string          `json:"description,omitempty"`
+	Name        string          `json:"name"`
+	Schema      any             `json:"schema,omitempty"`
+	Strict      json.RawMessage `json:"strict,omitempty"`
 }
 
 type GeneralOpenAIRequest struct {
@@ -29,6 +33,7 @@ type GeneralOpenAIRequest struct {
 	MaxTokens           uint              `json:"max_tokens,omitempty"`
 	MaxCompletionTokens uint              `json:"max_completion_tokens,omitempty"`
 	ReasoningEffort     string            `json:"reasoning_effort,omitempty"`
+	Verbosity           json.RawMessage   `json:"verbosity,omitempty"` // gpt-5
 	Temperature         *float64          `json:"temperature,omitempty"`
 	TopP                float64           `json:"top_p,omitempty"`
 	TopK                int               `json:"top_k,omitempty"`
@@ -52,16 +57,142 @@ type GeneralOpenAIRequest struct {
 	Dimensions          int               `json:"dimensions,omitempty"`
 	Modalities          json.RawMessage   `json:"modalities,omitempty"`
 	Audio               json.RawMessage   `json:"audio,omitempty"`
-	EnableThinking      any               `json:"enable_thinking,omitempty"` // ali
-	THINKING            json.RawMessage   `json:"thinking,omitempty"`        // doubao
-	ExtraBody           json.RawMessage   `json:"extra_body,omitempty"`
-	SearchParameters    any               `json:"search_parameters,omitempty"` //xai
-	WebSearchOptions    *WebSearchOptions `json:"web_search_options,omitempty"`
+	// gemini
+	ExtraBody json.RawMessage `json:"extra_body,omitempty"`
+	//xai
+	SearchParameters json.RawMessage `json:"search_parameters,omitempty"`
+	// claude
+	WebSearchOptions *WebSearchOptions `json:"web_search_options,omitempty"`
 	// OpenRouter Params
 	Usage     json.RawMessage `json:"usage,omitempty"`
 	Reasoning json.RawMessage `json:"reasoning,omitempty"`
 	// Ali Qwen Params
 	VlHighResolutionImages json.RawMessage `json:"vl_high_resolution_images,omitempty"`
+	EnableThinking         any             `json:"enable_thinking,omitempty"`
+	// ollama Params
+	Think json.RawMessage `json:"think,omitempty"`
+	// baidu v2
+	WebSearch json.RawMessage `json:"web_search,omitempty"`
+	// doubao,zhipu_v4
+	THINKING json.RawMessage `json:"thinking,omitempty"`
+}
+
+func (r *GeneralOpenAIRequest) GetTokenCountMeta() *types.TokenCountMeta {
+	var tokenCountMeta types.TokenCountMeta
+	var texts = make([]string, 0)
+	var fileMeta = make([]*types.FileMeta, 0)
+
+	if r.Prompt != nil {
+		switch v := r.Prompt.(type) {
+		case string:
+			texts = append(texts, v)
+		case []any:
+			for _, item := range v {
+				if str, ok := item.(string); ok {
+					texts = append(texts, str)
+				}
+			}
+		default:
+			texts = append(texts, fmt.Sprintf("%v", r.Prompt))
+		}
+	}
+
+	if r.Input != nil {
+		inputs := r.ParseInput()
+		texts = append(texts, inputs...)
+	}
+
+	if r.MaxCompletionTokens > r.MaxTokens {
+		tokenCountMeta.MaxTokens = int(r.MaxCompletionTokens)
+	} else {
+		tokenCountMeta.MaxTokens = int(r.MaxTokens)
+	}
+
+	for _, message := range r.Messages {
+		tokenCountMeta.MessagesCount++
+		texts = append(texts, message.Role)
+		if message.Content != nil {
+			if message.Name != nil {
+				tokenCountMeta.NameCount++
+				texts = append(texts, *message.Name)
+			}
+			arrayContent := message.ParseContent()
+			for _, m := range arrayContent {
+				if m.Type == ContentTypeImageURL {
+					imageUrl := m.GetImageMedia()
+					if imageUrl != nil {
+						if imageUrl.Url != "" {
+							meta := &types.FileMeta{
+								FileType: types.FileTypeImage,
+							}
+							meta.OriginData = imageUrl.Url
+							meta.Detail = imageUrl.Detail
+							fileMeta = append(fileMeta, meta)
+						}
+					}
+				} else if m.Type == ContentTypeInputAudio {
+					inputAudio := m.GetInputAudio()
+					if inputAudio != nil {
+						meta := &types.FileMeta{
+							FileType: types.FileTypeAudio,
+						}
+						meta.OriginData = inputAudio.Data
+						fileMeta = append(fileMeta, meta)
+					}
+				} else if m.Type == ContentTypeFile {
+					file := m.GetFile()
+					if file != nil {
+						meta := &types.FileMeta{
+							FileType: types.FileTypeFile,
+						}
+						meta.OriginData = file.FileData
+						fileMeta = append(fileMeta, meta)
+					}
+				} else if m.Type == ContentTypeVideoUrl {
+					videoUrl := m.GetVideoUrl()
+					if videoUrl != nil && videoUrl.Url != "" {
+						meta := &types.FileMeta{
+							FileType: types.FileTypeVideo,
+						}
+						meta.OriginData = videoUrl.Url
+						fileMeta = append(fileMeta, meta)
+					}
+				} else {
+					texts = append(texts, m.Text)
+				}
+			}
+		}
+	}
+
+	if r.Tools != nil {
+		openaiTools := r.Tools
+		for _, tool := range openaiTools {
+			tokenCountMeta.ToolsCount++
+			texts = append(texts, tool.Function.Name)
+			if tool.Function.Description != "" {
+				texts = append(texts, tool.Function.Description)
+			}
+			if tool.Function.Parameters != nil {
+				texts = append(texts, fmt.Sprintf("%v", tool.Function.Parameters))
+			}
+		}
+		//toolTokens := CountTokenInput(countStr, request.Model)
+		//tkm += 8
+		//tkm += toolTokens
+	}
+	tokenCountMeta.CombineText = strings.Join(texts, "\n")
+	tokenCountMeta.Files = fileMeta
+	return &tokenCountMeta
+}
+
+func (r *GeneralOpenAIRequest) IsStream(c *gin.Context) bool {
+	return r.Stream
+}
+
+func (r *GeneralOpenAIRequest) SetModelName(modelName string) {
+	if modelName != "" {
+		r.Model = modelName
+	}
 }
 
 func (r *GeneralOpenAIRequest) ToMap() map[string]any {
@@ -71,6 +202,17 @@ func (r *GeneralOpenAIRequest) ToMap() map[string]any {
 	return result
 }
 
+func (r *GeneralOpenAIRequest) GetSystemRoleName() string {
+	if strings.HasPrefix(r.Model, "o") {
+		if !strings.HasPrefix(r.Model, "o1-mini") && !strings.HasPrefix(r.Model, "o1-preview") {
+			return "developer"
+		}
+	} else if strings.HasPrefix(r.Model, "gpt-5") {
+		return "developer"
+	}
+	return "system"
+}
+
 type ToolCallRequest struct {
 	ID       string          `json:"id,omitempty"`
 	Type     string          `json:"type"`
@@ -88,8 +230,11 @@ type StreamOptions struct {
 	IncludeUsage bool `json:"include_usage,omitempty"`
 }
 
-func (r *GeneralOpenAIRequest) GetMaxTokens() int {
-	return int(r.MaxTokens)
+func (r *GeneralOpenAIRequest) GetMaxTokens() uint {
+	if r.MaxCompletionTokens != 0 {
+		return r.MaxCompletionTokens
+	}
+	return r.MaxTokens
 }
 
 func (r *GeneralOpenAIRequest) ParseInput() []string {
@@ -185,6 +330,21 @@ func (m *MediaContent) GetFile() *MessageFile {
 	return nil
 }
 
+func (m *MediaContent) GetVideoUrl() *MessageVideoUrl {
+	if m.VideoUrl != nil {
+		if _, ok := m.VideoUrl.(*MessageVideoUrl); ok {
+			return m.VideoUrl.(*MessageVideoUrl)
+		}
+		if itemMap, ok := m.VideoUrl.(map[string]any); ok {
+			out := &MessageVideoUrl{
+				Url: common.Interface2String(itemMap["url"]),
+			}
+			return out
+		}
+	}
+	return nil
+}
+
 type MessageImageUrl struct {
 	Url      string `json:"url"`
 	Detail   string `json:"detail"`
@@ -216,6 +376,7 @@ const (
 	ContentTypeInputAudio = "input_audio"
 	ContentTypeFile       = "file"
 	ContentTypeVideoUrl   = "video_url" // 阿里百炼视频识别
+	//ContentTypeAudioUrl   = "audio_url"
 )
 
 func (m *Message) GetPrefix() bool {
@@ -605,27 +766,105 @@ type WebSearchOptions struct {
 
 // https://platform.openai.com/docs/api-reference/responses/create
 type OpenAIResponsesRequest struct {
-	Model              string           `json:"model"`
-	Input              json.RawMessage  `json:"input,omitempty"`
-	Include            json.RawMessage  `json:"include,omitempty"`
-	Instructions       json.RawMessage  `json:"instructions,omitempty"`
-	MaxOutputTokens    uint             `json:"max_output_tokens,omitempty"`
-	Metadata           json.RawMessage  `json:"metadata,omitempty"`
-	ParallelToolCalls  bool             `json:"parallel_tool_calls,omitempty"`
-	PreviousResponseID string           `json:"previous_response_id,omitempty"`
-	Reasoning          *Reasoning       `json:"reasoning,omitempty"`
-	ServiceTier        string           `json:"service_tier,omitempty"`
-	Store              bool             `json:"store,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              []map[string]any `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"`
+	Model              string          `json:"model"`
+	Input              json.RawMessage `json:"input,omitempty"`
+	Include            json.RawMessage `json:"include,omitempty"`
+	Instructions       json.RawMessage `json:"instructions,omitempty"`
+	MaxOutputTokens    uint            `json:"max_output_tokens,omitempty"`
+	Metadata           json.RawMessage `json:"metadata,omitempty"`
+	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"`
+}
+
+func (r *OpenAIResponsesRequest) GetTokenCountMeta() *types.TokenCountMeta {
+	var fileMeta = make([]*types.FileMeta, 0)
+	var texts = make([]string, 0)
+
+	if r.Input != nil {
+		inputs := r.ParseInput()
+		for _, input := range inputs {
+			if input.Type == "input_image" {
+				if input.ImageUrl != "" {
+					fileMeta = append(fileMeta, &types.FileMeta{
+						FileType:   types.FileTypeImage,
+						OriginData: input.ImageUrl,
+						Detail:     input.Detail,
+					})
+				}
+			} else if input.Type == "input_file" {
+				if input.FileUrl != "" {
+					fileMeta = append(fileMeta, &types.FileMeta{
+						FileType:   types.FileTypeFile,
+						OriginData: input.FileUrl,
+					})
+				}
+			} else {
+				texts = append(texts, input.Text)
+			}
+		}
+	}
+
+	if len(r.Instructions) > 0 {
+		texts = append(texts, string(r.Instructions))
+	}
+
+	if len(r.Metadata) > 0 {
+		texts = append(texts, string(r.Metadata))
+	}
+
+	if len(r.Text) > 0 {
+		texts = append(texts, string(r.Text))
+	}
+
+	if len(r.ToolChoice) > 0 {
+		texts = append(texts, string(r.ToolChoice))
+	}
+
+	if len(r.Prompt) > 0 {
+		texts = append(texts, string(r.Prompt))
+	}
+
+	if len(r.Tools) > 0 {
+		texts = append(texts, string(r.Tools))
+	}
+
+	return &types.TokenCountMeta{
+		CombineText: strings.Join(texts, "\n"),
+		Files:       fileMeta,
+		MaxTokens:   int(r.MaxOutputTokens),
+	}
+}
+
+func (r *OpenAIResponsesRequest) IsStream(c *gin.Context) bool {
+	return r.Stream
+}
+
+func (r *OpenAIResponsesRequest) SetModelName(modelName string) {
+	if modelName != "" {
+		r.Model = modelName
+	}
+}
+
+func (r *OpenAIResponsesRequest) GetToolsMap() []map[string]any {
+	var toolsMap []map[string]any
+	if len(r.Tools) > 0 {
+		_ = common.Unmarshal(r.Tools, &toolsMap)
+	}
+	return toolsMap
 }
 
 type Reasoning struct {
@@ -633,23 +872,88 @@ type Reasoning struct {
 	Summary string `json:"summary,omitempty"`
 }
 
-//type ResponsesToolsCall struct {
-//	Type string `json:"type"`
-//	// Web Search
-//	UserLocation      json.RawMessage `json:"user_location,omitempty"`
-//	SearchContextSize string          `json:"search_context_size,omitempty"`
-//	// File Search
-//	VectorStoreIds []string        `json:"vector_store_ids,omitempty"`
-//	MaxNumResults  uint            `json:"max_num_results,omitempty"`
-//	Filters        json.RawMessage `json:"filters,omitempty"`
-//	// Computer Use
-//	DisplayWidth  uint   `json:"display_width,omitempty"`
-//	DisplayHeight uint   `json:"display_height,omitempty"`
-//	Environment   string `json:"environment,omitempty"`
-//	// Function
-//	Name        string          `json:"name,omitempty"`
-//	Description string          `json:"description,omitempty"`
-//	Parameters  json.RawMessage `json:"parameters,omitempty"`
-//	Function    json.RawMessage `json:"function,omitempty"`
-//	Container   json.RawMessage `json:"container,omitempty"`
-//}
+type MediaInput struct {
+	Type     string `json:"type"`
+	Text     string `json:"text,omitempty"`
+	FileUrl  string `json:"file_url,omitempty"`
+	ImageUrl string `json:"image_url,omitempty"`
+	Detail   string `json:"detail,omitempty"` // 仅 input_image 有效
+}
+
+// ParseInput parses the Responses API `input` field into a normalized slice of MediaInput.
+// Reference implementation mirrors Message.ParseContent:
+//   - input can be a string, treated as an input_text item
+//   - input can be an array of objects with a `type` field
+//     supported types: input_text, input_image, input_file
+func (r *OpenAIResponsesRequest) ParseInput() []MediaInput {
+	if r.Input == nil {
+		return nil
+	}
+
+	var inputs []MediaInput
+
+	// Try string first
+	// if str, ok := common.GetJsonType(r.Input); ok {
+	// 	inputs = append(inputs, MediaInput{Type: "input_text", Text: str})
+	// 	return inputs
+	// }
+	if common.GetJsonType(r.Input) == "string" {
+		var str string
+		_ = common.Unmarshal(r.Input, &str)
+		inputs = append(inputs, MediaInput{Type: "input_text", Text: str})
+		return inputs
+	}
+
+	// Try array of parts
+	if common.GetJsonType(r.Input) == "array" {
+		var array []any
+		_ = common.Unmarshal(r.Input, &array)
+		for _, itemAny := range array {
+			// Already parsed MediaInput
+			if media, ok := itemAny.(MediaInput); ok {
+				inputs = append(inputs, media)
+				continue
+			}
+			// Generic map
+			item, ok := itemAny.(map[string]any)
+			if !ok {
+				continue
+			}
+			typeVal, ok := item["type"].(string)
+			if !ok {
+				continue
+			}
+			switch typeVal {
+			case "input_text":
+				text, _ := item["text"].(string)
+				inputs = append(inputs, MediaInput{Type: "input_text", Text: text})
+			case "input_image":
+				// image_url may be string or object with url field
+				var imageUrl string
+				switch v := item["image_url"].(type) {
+				case string:
+					imageUrl = v
+				case map[string]any:
+					if url, ok := v["url"].(string); ok {
+						imageUrl = url
+					}
+				}
+				inputs = append(inputs, MediaInput{Type: "input_image", ImageUrl: imageUrl})
+			case "input_file":
+				// file_url may be string or object with url field
+				var fileUrl string
+				switch v := item["file_url"].(type) {
+				case string:
+					fileUrl = v
+				case map[string]any:
+					if url, ok := v["url"].(string); ok {
+						fileUrl = url
+					}
+				}
+				inputs = append(inputs, MediaInput{Type: "input_file", FileUrl: fileUrl})
+			}
+		}
+	}
+
+	return inputs
+}

+ 124 - 4
dto/openai_response.go

@@ -2,12 +2,22 @@ package dto
 
 import (
 	"encoding/json"
+	"fmt"
 	"one-api/types"
 )
 
+const (
+	ResponsesOutputTypeImageGenerationCall = "image_generation_call"
+)
+
 type SimpleResponse struct {
 	Usage `json:"usage"`
-	Error *OpenAIError `json:"error"`
+	Error any `json:"error"`
+}
+
+// GetOpenAIError 从动态错误类型中提取OpenAIError结构
+func (s *SimpleResponse) GetOpenAIError() *types.OpenAIError {
+	return GetOpenAIError(s.Error)
 }
 
 type TextResponse struct {
@@ -31,10 +41,15 @@ type OpenAITextResponse struct {
 	Object  string                     `json:"object"`
 	Created any                        `json:"created"`
 	Choices []OpenAITextResponseChoice `json:"choices"`
-	Error   *types.OpenAIError         `json:"error,omitempty"`
+	Error   any                        `json:"error,omitempty"`
 	Usage   `json:"usage"`
 }
 
+// GetOpenAIError 从动态错误类型中提取OpenAIError结构
+func (o *OpenAITextResponse) GetOpenAIError() *types.OpenAIError {
+	return GetOpenAIError(o.Error)
+}
+
 type OpenAIEmbeddingResponseItem struct {
 	Object    string    `json:"object"`
 	Index     int       `json:"index"`
@@ -99,7 +114,7 @@ func (c *ChatCompletionsStreamResponseChoiceDelta) GetReasoningContent() string
 
 func (c *ChatCompletionsStreamResponseChoiceDelta) SetReasoningContent(s string) {
 	c.ReasoningContent = &s
-	c.Reasoning = &s
+	//c.Reasoning = &s
 }
 
 type ToolCallResponse struct {
@@ -132,6 +147,13 @@ type ChatCompletionsStreamResponse struct {
 	Usage             *Usage                                `json:"usage"`
 }
 
+func (c *ChatCompletionsStreamResponse) IsFinished() bool {
+	if len(c.Choices) == 0 {
+		return false
+	}
+	return c.Choices[0].FinishReason != nil && *c.Choices[0].FinishReason != ""
+}
+
 func (c *ChatCompletionsStreamResponse) IsToolCall() bool {
 	if len(c.Choices) == 0 {
 		return false
@@ -146,6 +168,19 @@ func (c *ChatCompletionsStreamResponse) GetFirstToolCall() *ToolCallResponse {
 	return nil
 }
 
+func (c *ChatCompletionsStreamResponse) ClearToolCalls() {
+	if !c.IsToolCall() {
+		return
+	}
+	for choiceIdx := range c.Choices {
+		for callIdx := range c.Choices[choiceIdx].Delta.ToolCalls {
+			c.Choices[choiceIdx].Delta.ToolCalls[callIdx].ID = ""
+			c.Choices[choiceIdx].Delta.ToolCalls[callIdx].Type = nil
+			c.Choices[choiceIdx].Delta.ToolCalls[callIdx].Function.Name = ""
+		}
+	}
+}
+
 func (c *ChatCompletionsStreamResponse) Copy() *ChatCompletionsStreamResponse {
 	choices := make([]ChatCompletionsStreamResponseChoice, len(c.Choices))
 	copy(choices, c.Choices)
@@ -217,7 +252,7 @@ type OpenAIResponsesResponse struct {
 	Object             string             `json:"object"`
 	CreatedAt          int                `json:"created_at"`
 	Status             string             `json:"status"`
-	Error              *types.OpenAIError `json:"error,omitempty"`
+	Error              any                `json:"error,omitempty"`
 	IncompleteDetails  *IncompleteDetails `json:"incomplete_details,omitempty"`
 	Instructions       string             `json:"instructions"`
 	MaxOutputTokens    int                `json:"max_output_tokens"`
@@ -237,6 +272,47 @@ type OpenAIResponsesResponse struct {
 	Metadata           json.RawMessage    `json:"metadata"`
 }
 
+// GetOpenAIError 从动态错误类型中提取OpenAIError结构
+func (o *OpenAIResponsesResponse) GetOpenAIError() *types.OpenAIError {
+	return GetOpenAIError(o.Error)
+}
+
+func (o *OpenAIResponsesResponse) HasImageGenerationCall() bool {
+	if len(o.Output) == 0 {
+		return false
+	}
+	for _, output := range o.Output {
+		if output.Type == ResponsesOutputTypeImageGenerationCall {
+			return true
+		}
+	}
+	return false
+}
+
+func (o *OpenAIResponsesResponse) GetQuality() string {
+	if len(o.Output) == 0 {
+		return ""
+	}
+	for _, output := range o.Output {
+		if output.Type == ResponsesOutputTypeImageGenerationCall {
+			return output.Quality
+		}
+	}
+	return ""
+}
+
+func (o *OpenAIResponsesResponse) GetSize() string {
+	if len(o.Output) == 0 {
+		return ""
+	}
+	for _, output := range o.Output {
+		if output.Type == ResponsesOutputTypeImageGenerationCall {
+			return output.Size
+		}
+	}
+	return ""
+}
+
 type IncompleteDetails struct {
 	Reasoning string `json:"reasoning"`
 }
@@ -247,6 +323,8 @@ type ResponsesOutput struct {
 	Status  string                   `json:"status"`
 	Role    string                   `json:"role"`
 	Content []ResponsesOutputContent `json:"content"`
+	Quality string                   `json:"quality"`
+	Size    string                   `json:"size"`
 }
 
 type ResponsesOutputContent struct {
@@ -276,3 +354,45 @@ type ResponsesStreamResponse struct {
 	Delta    string                   `json:"delta,omitempty"`
 	Item     *ResponsesOutput         `json:"item,omitempty"`
 }
+
+// GetOpenAIError 从动态错误类型中提取OpenAIError结构
+func GetOpenAIError(errorField any) *types.OpenAIError {
+	if errorField == nil {
+		return nil
+	}
+
+	switch err := errorField.(type) {
+	case types.OpenAIError:
+		return &err
+	case *types.OpenAIError:
+		return err
+	case map[string]interface{}:
+		// 处理从JSON解析来的map结构
+		openaiErr := &types.OpenAIError{}
+		if errType, ok := err["type"].(string); ok {
+			openaiErr.Type = errType
+		}
+		if errMsg, ok := err["message"].(string); ok {
+			openaiErr.Message = errMsg
+		}
+		if errParam, ok := err["param"].(string); ok {
+			openaiErr.Param = errParam
+		}
+		if errCode, ok := err["code"]; ok {
+			openaiErr.Code = errCode
+		}
+		return openaiErr
+	case string:
+		// 处理简单字符串错误
+		return &types.OpenAIError{
+			Type:    "error",
+			Message: err,
+		}
+	default:
+		// 未知类型,尝试转换为字符串
+		return &types.OpenAIError{
+			Type:    "unknown_error",
+			Message: fmt.Sprintf("%v", err),
+		}
+	}
+}

+ 24 - 0
dto/pricing.go

@@ -2,6 +2,7 @@ package dto
 
 import "one-api/constant"
 
+// 这里不好动就不动了,本来想独立出来的(
 type OpenAIModels struct {
 	Id                     string                  `json:"id"`
 	Object                 string                  `json:"object"`
@@ -9,3 +10,26 @@ type OpenAIModels struct {
 	OwnedBy                string                  `json:"owned_by"`
 	SupportedEndpointTypes []constant.EndpointType `json:"supported_endpoint_types"`
 }
+
+type AnthropicModel struct {
+	ID          string `json:"id"`
+	CreatedAt   string `json:"created_at"`
+	DisplayName string `json:"display_name"`
+	Type        string `json:"type"`
+}
+
+type GeminiModel struct {
+	Name                       interface{}   `json:"name"`
+	BaseModelId                interface{}   `json:"baseModelId"`
+	Version                    interface{}   `json:"version"`
+	DisplayName                interface{}   `json:"displayName"`
+	Description                interface{}   `json:"description"`
+	InputTokenLimit            interface{}   `json:"inputTokenLimit"`
+	OutputTokenLimit           interface{}   `json:"outputTokenLimit"`
+	SupportedGenerationMethods []interface{} `json:"supportedGenerationMethods"`
+	Thinking                   interface{}   `json:"thinking"`
+	Temperature                interface{}   `json:"temperature"`
+	MaxTemperature             interface{}   `json:"maxTemperature"`
+	TopP                       interface{}   `json:"topP"`
+	TopK                       interface{}   `json:"topK"`
+}

+ 18 - 18
dto/ratio_sync.go

@@ -1,23 +1,23 @@
 package dto
 
 type UpstreamDTO struct {
-    ID       int    `json:"id,omitempty"`
-    Name     string `json:"name" binding:"required"`
-    BaseURL  string `json:"base_url" binding:"required"`
-    Endpoint string `json:"endpoint"`
+	ID       int    `json:"id,omitempty"`
+	Name     string `json:"name" binding:"required"`
+	BaseURL  string `json:"base_url" binding:"required"`
+	Endpoint string `json:"endpoint"`
 }
 
 type UpstreamRequest struct {
-    ChannelIDs []int64 `json:"channel_ids"`
-    Upstreams   []UpstreamDTO `json:"upstreams"`
-    Timeout    int     `json:"timeout"`
+	ChannelIDs []int64       `json:"channel_ids"`
+	Upstreams  []UpstreamDTO `json:"upstreams"`
+	Timeout    int           `json:"timeout"`
 }
 
 // TestResult 上游测试连通性结果
 type TestResult struct {
-    Name   string `json:"name"`
-    Status string `json:"status"`
-    Error  string `json:"error,omitempty"`
+	Name   string `json:"name"`
+	Status string `json:"status"`
+	Error  string `json:"error,omitempty"`
 }
 
 // DifferenceItem 差异项
@@ -25,14 +25,14 @@ type TestResult struct {
 // Upstreams 为各渠道的上游值,具体数值 / "same" / nil
 
 type DifferenceItem struct {
-    Current   interface{}            `json:"current"`
-    Upstreams map[string]interface{} `json:"upstreams"`
-    Confidence map[string]bool       `json:"confidence"`
+	Current    interface{}            `json:"current"`
+	Upstreams  map[string]interface{} `json:"upstreams"`
+	Confidence map[string]bool        `json:"confidence"`
 }
 
 type SyncableChannel struct {
-    ID      int    `json:"id"`
-    Name    string `json:"name"`
-    BaseURL string `json:"base_url"`
-    Status  int    `json:"status"`
-} 
+	ID      int    `json:"id"`
+	Name    string `json:"name"`
+	BaseURL string `json:"base_url"`
+	Status  int    `json:"status"`
+}

+ 25 - 0
dto/request_common.go

@@ -0,0 +1,25 @@
+package dto
+
+import (
+	"github.com/gin-gonic/gin"
+	"one-api/types"
+)
+
+type Request interface {
+	GetTokenCountMeta() *types.TokenCountMeta
+	IsStream(c *gin.Context) bool
+	SetModelName(modelName string)
+}
+
+type BaseRequest struct {
+}
+
+func (b *BaseRequest) GetTokenCountMeta() *types.TokenCountMeta {
+	return &types.TokenCountMeta{
+		TokenType: types.TokenTypeTokenizer,
+	}
+}
+func (b *BaseRequest) IsStream(c *gin.Context) bool {
+	return false
+}
+func (b *BaseRequest) SetModelName(modelName string) {}

+ 33 - 0
dto/rerank.go

@@ -1,5 +1,12 @@
 package dto
 
+import (
+	"fmt"
+	"github.com/gin-gonic/gin"
+	"one-api/types"
+	"strings"
+)
+
 type RerankRequest struct {
 	Documents       []any  `json:"documents"`
 	Query           string `json:"query"`
@@ -10,6 +17,32 @@ type RerankRequest struct {
 	OverLapTokens   int    `json:"overlap_tokens,omitempty"`
 }
 
+func (r *RerankRequest) IsStream(c *gin.Context) bool {
+	return false
+}
+
+func (r *RerankRequest) GetTokenCountMeta() *types.TokenCountMeta {
+	var texts = make([]string, 0)
+
+	for _, document := range r.Documents {
+		texts = append(texts, fmt.Sprintf("%v", document))
+	}
+
+	if r.Query != "" {
+		texts = append(texts, r.Query)
+	}
+
+	return &types.TokenCountMeta{
+		CombineText: strings.Join(texts, "\n"),
+	}
+}
+
+func (r *RerankRequest) SetModelName(modelName string) {
+	if modelName != "" {
+		r.Model = modelName
+	}
+}
+
 func (r *RerankRequest) GetReturnDocuments() bool {
 	if r.ReturnDocuments == nil {
 		return false

+ 3 - 0
dto/user_settings.go

@@ -6,11 +6,14 @@ type UserSetting struct {
 	WebhookUrl            string  `json:"webhook_url,omitempty"`                    // WebhookUrl webhook地址
 	WebhookSecret         string  `json:"webhook_secret,omitempty"`                 // WebhookSecret webhook密钥
 	NotificationEmail     string  `json:"notification_email,omitempty"`             // NotificationEmail 通知邮箱地址
+	BarkUrl               string  `json:"bark_url,omitempty"`                       // BarkUrl Bark推送URL
 	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 左侧边栏模块配置
 }
 
 var (
 	NotifyTypeEmail   = "email"   // Email 邮件
 	NotifyTypeWebhook = "webhook" // Webhook
+	NotifyTypeBark    = "bark"    // Bark 推送
 )

+ 13 - 6
go.mod

@@ -7,9 +7,10 @@ require (
 	github.com/Calcium-Ion/go-epay v0.0.4
 	github.com/andybalholm/brotli v1.1.1
 	github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0
-	github.com/aws/aws-sdk-go-v2 v1.26.1
+	github.com/aws/aws-sdk-go-v2 v1.37.2
 	github.com/aws/aws-sdk-go-v2/credentials v1.17.11
-	github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.7.4
+	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/gin-contrib/cors v1.7.2
 	github.com/gin-contrib/gzip v0.0.6
@@ -22,13 +23,17 @@ require (
 	github.com/golang-jwt/jwt v3.2.2+incompatible
 	github.com/google/uuid v1.6.0
 	github.com/gorilla/websocket v1.5.0
+	github.com/jinzhu/copier v0.4.0
 	github.com/joho/godotenv v1.5.1
 	github.com/pkg/errors v0.9.1
+	github.com/pquerna/otp v1.5.0
 	github.com/samber/lo v1.39.0
 	github.com/shirou/gopsutil v3.21.11+incompatible
 	github.com/shopspring/decimal v1.4.0
 	github.com/stripe/stripe-go/v81 v81.4.0
 	github.com/thanhpk/randstr v1.0.6
+	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/image v0.23.0
@@ -41,10 +46,10 @@ require (
 
 require (
 	github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6 // indirect
-	github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.2 // indirect
-	github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.5 // indirect
-	github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.5 // indirect
-	github.com/aws/smithy-go v1.20.2 // indirect
+	github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0 // indirect
+	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/cespare/xxhash/v2 v2.3.0 // indirect
@@ -80,6 +85,8 @@ require (
 	github.com/modern-go/reflect2 v1.0.2 // indirect
 	github.com/pelletier/go-toml/v2 v2.2.1 // indirect
 	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+	github.com/tidwall/match v1.1.1 // indirect
+	github.com/tidwall/pretty v1.2.0 // indirect
 	github.com/tklauser/go-sysconf v0.3.12 // indirect
 	github.com/tklauser/numcpus v0.6.1 // indirect
 	github.com/twitchyliquid64/golang-asm v0.15.1 // indirect

+ 28 - 12
go.sum

@@ -6,20 +6,23 @@ github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0 h1:onfun1RA+Kc
 github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0/go.mod h1:4yg+jNTYlDEzBjhGS96v+zjyA3lfXlFd5CiTLIkPBLI=
 github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6 h1:HblK3eJHq54yET63qPCTJnks3loDse5xRmmqHgHzwoI=
 github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6/go.mod h1:pbiaLIeYLUbgMY1kwEAdwO6UKD5ZNwdPGQlwokS9fe8=
-github.com/aws/aws-sdk-go-v2 v1.26.1 h1:5554eUqIYVWpU0YmeeYZ0wU64H2VLBs8TlhRB2L+EkA=
-github.com/aws/aws-sdk-go-v2 v1.26.1/go.mod h1:ffIFB97e2yNsv4aTSGkqtHnppsIJzw7G7BReUZ3jCXM=
-github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.2 h1:x6xsQXGSmW6frevwDA+vi/wqhp1ct18mVXYN08/93to=
-github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.2/go.mod h1:lPprDr1e6cJdyYeGXnRaJoP4Md+cDBvi2eOj00BlGmg=
+github.com/aws/aws-sdk-go-v2 v1.37.2 h1:xkW1iMYawzcmYFYEV0UCMxc8gSsjCGEhBXQkdQywVbo=
+github.com/aws/aws-sdk-go-v2 v1.37.2/go.mod h1:9Q0OoGQoboYIAJyslFyF1f5K1Ryddop8gqMhWx/n4Wg=
+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0 h1:6GMWV6CNpA/6fbFHnoAjrv4+LGfyTqZz2LtCHnspgDg=
+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0/go.mod h1:/mXlTIVG9jbxkqDnr5UQNQxW1HRYxeGklkM9vAFeabg=
 github.com/aws/aws-sdk-go-v2/credentials v1.17.11 h1:YuIB1dJNf1Re822rriUOTxopaHHvIq0l/pX3fwO+Tzs=
 github.com/aws/aws-sdk-go-v2/credentials v1.17.11/go.mod h1:AQtFPsDH9bI2O+71anW6EKL+NcD7LG3dpKGMV4SShgo=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.5 h1:aw39xVGeRWlWx9EzGVnhOR4yOjQDHPQ6o6NmBlscyQg=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.5/go.mod h1:FSaRudD0dXiMPK2UjknVwwTYyZMRsHv3TtkabsZih5I=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.5 h1:PG1F3OD1szkuQPzDw3CIQsRIrtTlUC3lP84taWzHlq0=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.5/go.mod h1:jU1li6RFryMz+so64PpKtudI+QzbKoIEivqdf6LNpOc=
-github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.7.4 h1:JgHnonzbnA3pbqj76wYsSZIZZQYBxkmMEjvL6GHy8XU=
-github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.7.4/go.mod h1:nZspkhg+9p8iApLFoyAqfyuMP0F38acy2Hm3r5r95Cg=
-github.com/aws/smithy-go v1.20.2 h1:tbp628ireGtzcHDDmLT/6ADHidqnwgF57XOXZe6tp4Q=
-github.com/aws/smithy-go v1.20.2/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.2 h1:sPiRHLVUIIQcoVZTNwqQcdtjkqkPopyYmIX0M5ElRf4=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.2/go.mod h1:ik86P3sgV+Bk7c1tBFCwI3VxMoSEwl4YkRB9xn1s340=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.2 h1:ZdzDAg075H6stMZtbD2o+PyB933M/f20e9WmCBC17wA=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.2/go.mod h1:eE1IIzXG9sdZCB0pNNpMpsYTLl4YdOQD3njiVN1e/E4=
+github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.33.0 h1:JzidOz4Hcn2RbP5fvIS1iAP+DcRv5VJtgixbEYDsI5g=
+github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.33.0/go.mod h1:9A4/PJYlWjvjEzzoOLGQjkLt4bYK9fRWi7uz1GSsAcA=
+github.com/aws/smithy-go v1.22.5 h1:P9ATCXPMb2mPjYBgueqJNCA5S9UfktsW0tTxi+a7eqw=
+github.com/aws/smithy-go v1.22.5/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
+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=
@@ -117,6 +120,8 @@ github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs=
 github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA=
 github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
 github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
+github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8=
+github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg=
 github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
 github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
 github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
@@ -169,6 +174,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
+github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
 github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
@@ -199,6 +206,15 @@ github.com/stripe/stripe-go/v81 v81.4.0 h1:AuD9XzdAvl193qUCSaLocf8H+nRopOouXhxqJ
 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=
 github.com/thanhpk/randstr v1.0.6/go.mod h1:M/H2P1eNLZzlDwAzpkkkUvoyNNMbzRGhESZuEQk3r0U=
+github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
+github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
+github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
+github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
+github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
+github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
+github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
+github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
+github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
 github.com/tiktoken-go/tokenizer v0.6.2 h1:t0GN2DvcUZSFWT/62YOgoqb10y7gSXBGs0A+4VCQK+g=
 github.com/tiktoken-go/tokenizer v0.6.2/go.mod h1:6UCYI/DtOallbmL7sSy30p6YQv60qNyU/4aVigPOx6w=
 github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=

+ 0 - 1041
i18n/zh-cn.json

@@ -1,1041 +0,0 @@
-{
-  "未登录或登录已过期,请重新登录": "未登录或登录已过期,请重新登录",
-  "登 录": "登 录",
-  "使用 微信 继续": "使用 微信 继续",
-  "使用 GitHub 继续": "使用 GitHub 继续",
-  "使用 LinuxDO 继续": "使用 LinuxDO 继续",
-  "使用 邮箱或用户名 登录": "使用 邮箱或用户名 登录",
-  "没有账户?": "没有账户?",
-  "用户名或邮箱": "用户名或邮箱",
-  "请输入您的用户名或邮箱地址": "请输入您的用户名或邮箱地址",
-  "请输入您的密码": "请输入您的密码",
-  "继续": "继续",
-  "忘记密码?": "忘记密码?",
-  "其他登录选项": "其他登录选项",
-  "微信扫码登录": "微信扫码登录",
-  "登录": "登录",
-  "微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)": "微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)",
-  "验证码": "验证码",
-  "处理中...": "处理中...",
-  "绑定成功!": "绑定成功!",
-  "登录成功!": "登录成功!",
-  "操作失败,重定向至登录界面中...": "操作失败,重定向至登录界面中...",
-  "出现错误,第 ${count} 次重试中...": "出现错误,第 ${count} 次重试中...",
-  "无效的重置链接,请重新发起密码重置请求": "无效的重置链接,请重新发起密码重置请求",
-  "密码已重置并已复制到剪贴板:": "密码已重置并已复制到剪贴板:",
-  "密码重置确认": "密码重置确认",
-  "等待获取邮箱信息...": "等待获取邮箱信息...",
-  "新密码": "新密码",
-  "密码已复制到剪贴板:": "密码已复制到剪贴板:",
-  "密码重置完成": "密码重置完成",
-  "确认重置密码": "确认重置密码",
-  "返回登录": "返回登录",
-  "请输入邮箱地址": "请输入邮箱地址",
-  "请稍后几秒重试,Turnstile 正在检查用户环境!": "请稍后几秒重试,Turnstile 正在检查用户环境!",
-  "重置邮件发送成功,请检查邮箱!": "重置邮件发送成功,请检查邮箱!",
-  "密码重置": "密码重置",
-  "请输入您的邮箱地址": "请输入您的邮箱地址",
-  "重试": "重试",
-  "想起来了?": "想起来了?",
-  "注 册": "注 册",
-  "使用 用户名 注册": "使用 用户名 注册",
-  "已有账户?": "已有账户?",
-  "用户名": "用户名",
-  "请输入用户名": "请输入用户名",
-  "输入密码,最短 8 位,最长 20 位": "输入密码,最短 8 位,最长 20 位",
-  "确认密码": "确认密码",
-  "输入邮箱地址": "输入邮箱地址",
-  "获取验证码": "获取验证码",
-  "输入验证码": "输入验证码",
-  "或": "或",
-  "其他注册选项": "其他注册选项",
-  "加载中...": "加载中...",
-  "复制代码": "复制代码",
-  "代码已复制到剪贴板": "代码已复制到剪贴板",
-  "复制失败,请手动复制": "复制失败,请手动复制",
-  "显示更多": "显示更多",
-  "关于我们": "关于我们",
-  "关于项目": "关于项目",
-  "联系我们": "联系我们",
-  "功能特性": "功能特性",
-  "快速开始": "快速开始",
-  "安装指南": "安装指南",
-  "API 文档": "API 文档",
-  "基于New API的项目": "基于New API的项目",
-  "版权所有": "版权所有",
-  "设计与开发由": "设计与开发由",
-  "首页": "首页",
-  "控制台": "控制台",
-  "文档": "文档",
-  "关于": "关于",
-  "注销成功!": "注销成功!",
-  "个人设置": "个人设置",
-  "API令牌": "API令牌",
-  "退出": "退出",
-  "关闭侧边栏": "关闭侧边栏",
-  "打开侧边栏": "打开侧边栏",
-  "关闭菜单": "关闭菜单",
-  "打开菜单": "打开菜单",
-  "演示站点": "演示站点",
-  "自用模式": "自用模式",
-  "系统公告": "系统公告",
-  "切换主题": "切换主题",
-  "切换语言": "切换语言",
-  "暂无公告": "暂无公告",
-  "暂无系统公告": "暂无系统公告",
-  "今日关闭": "今日关闭",
-  "关闭公告": "关闭公告",
-  "数据看板": "数据看板",
-  "绘图日志": "绘图日志",
-  "任务日志": "任务日志",
-  "渠道": "渠道",
-  "兑换码": "兑换码",
-  "用户管理": "用户管理",
-  "操练场": "操练场",
-  "聊天": "聊天",
-  "管理员": "管理员",
-  "个人中心": "个人中心",
-  "展开侧边栏": "展开侧边栏",
-  "AI 对话": "AI 对话",
-  "选择模型开始对话": "选择模型开始对话",
-  "显示调试": "显示调试",
-  "请输入您的问题...": "请输入您的问题...",
-  "已复制到剪贴板": "已复制到剪贴板",
-  "复制失败": "复制失败",
-  "正在构造请求体预览...": "正在构造请求体预览...",
-  "暂无请求数据": "暂无请求数据",
-  "暂无响应数据": "暂无响应数据",
-  "内容较大,已启用性能优化模式": "内容较大,已启用性能优化模式",
-  "内容较大,部分功能可能受限": "内容较大,部分功能可能受限",
-  "已复制": "已复制",
-  "正在处理大内容...": "正在处理大内容...",
-  "显示完整内容": "显示完整内容",
-  "收起": "收起",
-  "配置已导出到下载文件夹": "配置已导出到下载文件夹",
-  "导出配置失败: ": "导出配置失败: ",
-  "确认导入配置": "确认导入配置",
-  "导入的配置将覆盖当前设置,是否继续?": "导入的配置将覆盖当前设置,是否继续?",
-  "取消": "取消",
-  "配置导入成功": "配置导入成功",
-  "导入配置失败: ": "导入配置失败: ",
-  "重置配置": "重置配置",
-  "将清除所有保存的配置并恢复默认设置,此操作不可撤销。是否继续?": "将清除所有保存的配置并恢复默认设置,此操作不可撤销。是否继续?",
-  "重置选项": "重置选项",
-  "是否同时重置对话消息?选择\"是\"将清空所有对话记录并恢复默认示例;选择\"否\"将保留当前对话记录。": "是否同时重置对话消息?选择\"是\"将清空所有对话记录并恢复默认示例;选择\"否\"将保留当前对话记录。",
-  "同时重置消息": "同时重置消息",
-  "仅重置配置": "仅重置配置",
-  "配置和消息已全部重置": "配置和消息已全部重置",
-  "配置已重置,对话消息已保留": "配置已重置,对话消息已保留",
-  "已有保存的配置": "已有保存的配置",
-  "暂无保存的配置": "暂无保存的配置",
-  "导出配置": "导出配置",
-  "导入配置": "导入配置",
-  "导出": "导出",
-  "导入": "导入",
-  "调试信息": "调试信息",
-  "预览请求体": "预览请求体",
-  "实际请求体": "实际请求体",
-  "预览更新": "预览更新",
-  "最后请求": "最后请求",
-  "操作暂时被禁用": "操作暂时被禁用",
-  "复制": "复制",
-  "编辑": "编辑",
-  "切换为System角色": "切换为System角色",
-  "切换为Assistant角色": "切换为Assistant角色",
-  "删除": "删除",
-  "请求发生错误": "请求发生错误",
-  "系统消息": "系统消息",
-  "请输入消息内容...": "请输入消息内容...",
-  "保存": "保存",
-  "模型配置": "模型配置",
-  "分组": "分组",
-  "请选择分组": "请选择分组",
-  "请选择模型": "请选择模型",
-  "思考中...": "思考中...",
-  "思考过程": "思考过程",
-  "选择同步渠道": "选择同步渠道",
-  "搜索渠道名称或地址": "搜索渠道名称或地址",
-  "暂无渠道": "暂无渠道",
-  "暂无选择": "暂无选择",
-  "无搜索结果": "无搜索结果",
-  "公告已更新": "公告已更新",
-  "公告更新失败": "公告更新失败",
-  "系统名称已更新": "系统名称已更新",
-  "系统名称更新失败": "系统名称更新失败",
-  "系统信息": "系统信息",
-  "当前版本": "当前版本",
-  "检查更新": "检查更新",
-  "启动时间": "启动时间",
-  "通用设置": "通用设置",
-  "设置公告": "设置公告",
-  "个性化设置": "个性化设置",
-  "系统名称": "系统名称",
-  "在此输入系统名称": "在此输入系统名称",
-  "设置系统名称": "设置系统名称",
-  "Logo 图片地址": "Logo 图片地址",
-  "在此输入 Logo 图片地址": "在此输入 Logo 图片地址",
-  "首页内容": "首页内容",
-  "设置首页内容": "设置首页内容",
-  "设置关于": "设置关于",
-  "页脚": "页脚",
-  "设置页脚": "设置页脚",
-  "详情": "详情",
-  "刷新失败": "刷新失败",
-  "令牌已重置并已复制到剪贴板": "令牌已重置并已复制到剪贴板",
-  "加载模型列表失败": "加载模型列表失败",
-  "系统令牌已复制到剪切板": "系统令牌已复制到剪切板",
-  "请输入你的账户名以确认删除!": "请输入你的账户名以确认删除!",
-  "账户已删除!": "账户已删除!",
-  "微信账户绑定成功!": "微信账户绑定成功!",
-  "请输入原密码!": "请输入原密码!",
-  "请输入新密码!": "请输入新密码!",
-  "新密码需要和原密码不一致!": "新密码需要和原密码不一致!",
-  "两次输入的密码不一致!": "两次输入的密码不一致!",
-  "密码修改成功!": "密码修改成功!",
-  "验证码发送成功,请检查邮箱!": "验证码发送成功,请检查邮箱!",
-  "请输入邮箱验证码!": "请输入邮箱验证码!",
-  "邮箱账户绑定成功!": "邮箱账户绑定成功!",
-  "无法复制到剪贴板,请手动复制": "无法复制到剪贴板,请手动复制",
-  "设置保存成功": "设置保存成功",
-  "设置保存失败": "设置保存失败",
-  "超级管理员": "超级管理员",
-  "普通用户": "普通用户",
-  "当前余额": "当前余额",
-  "历史消耗": "历史消耗",
-  "请求次数": "请求次数",
-  "默认": "默认",
-  "可用模型": "可用模型",
-  "模型列表": "模型列表",
-  "点击模型名称可复制": "点击模型名称可复制",
-  "没有可用模型": "没有可用模型",
-  "该分类下没有可用模型": "该分类下没有可用模型",
-  "更多": "更多",
-  "个模型": "个模型",
-  "账户绑定": "账户绑定",
-  "未绑定": "未绑定",
-  "修改绑定": "修改绑定",
-  "微信": "微信",
-  "已绑定": "已绑定",
-  "未启用": "未启用",
-  "绑定": "绑定",
-  "安全设置": "安全设置",
-  "系统访问令牌": "系统访问令牌",
-  "用于API调用的身份验证令牌,请妥善保管": "用于API调用的身份验证令牌,请妥善保管",
-  "生成令牌": "生成令牌",
-  "密码管理": "密码管理",
-  "定期更改密码可以提高账户安全性": "定期更改密码可以提高账户安全性",
-  "修改密码": "修改密码",
-  "此操作不可逆,所有数据将被永久删除": "此操作不可逆,所有数据将被永久删除",
-  "删除账户": "删除账户",
-  "其他设置": "其他设置",
-  "通知设置": "通知设置",
-  "邮件通知": "邮件通知",
-  "通过邮件接收通知": "通过邮件接收通知",
-  "Webhook通知": "Webhook通知",
-  "通过HTTP请求接收通知": "通过HTTP请求接收通知",
-  "请输入Webhook地址,例如: https://example.com/webhook": "请输入Webhook地址,例如: https://example.com/webhook",
-  "只支持https,系统将以 POST 方式发送通知,请确保地址可以接收 POST 请求": "只支持https,系统将以 POST 方式发送通知,请确保地址可以接收 POST 请求",
-  "接口凭证(可选)": "接口凭证(可选)",
-  "请输入密钥": "请输入密钥",
-  "密钥将以 Bearer 方式添加到请求头中,用于验证webhook请求的合法性": "密钥将以 Bearer 方式添加到请求头中,用于验证webhook请求的合法性",
-  "通知邮箱": "通知邮箱",
-  "留空则使用账号绑定的邮箱": "留空则使用账号绑定的邮箱",
-  "设置用于接收额度预警的邮箱地址,不填则使用账号绑定的邮箱": "设置用于接收额度预警的邮箱地址,不填则使用账号绑定的邮箱",
-  "额度预警阈值": "额度预警阈值",
-  "请输入预警额度": "请输入预警额度",
-  "当剩余额度低于此数值时,系统将通过选择的方式发送通知": "当剩余额度低于此数值时,系统将通过选择的方式发送通知",
-  "接受未设置价格模型": "接受未设置价格模型",
-  "当模型没有设置价格时仍接受调用,仅当您信任该网站时使用,可能会产生高额费用": "当模型没有设置价格时仍接受调用,仅当您信任该网站时使用,可能会产生高额费用",
-  "IP记录": "IP记录",
-  "记录请求与错误日志 IP": "记录请求与错误日志 IP",
-  "开启后,仅“消费”和“错误”日志将记录您的客户端 IP 地址": "开启后,仅“消费”和“错误”日志将记录您的客户端 IP 地址",
-  "绑定邮箱地址": "绑定邮箱地址",
-  "重新发送": "重新发送",
-  "绑定微信账户": "绑定微信账户",
-  "删除账户确认": "删除账户确认",
-  "您正在删除自己的帐户,将清空所有数据且不可恢复": "您正在删除自己的帐户,将清空所有数据且不可恢复",
-  "请输入您的用户名以确认删除": "请输入您的用户名以确认删除",
-  "输入你的账户名{{username}}以确认删除": "输入你的账户名{{username}}以确认删除",
-  "原密码": "原密码",
-  "请输入原密码": "请输入原密码",
-  "请输入新密码": "请输入新密码",
-  "确认新密码": "确认新密码",
-  "请再次输入新密码": "请再次输入新密码",
-  "模型倍率设置": "模型倍率设置",
-  "可视化倍率设置": "可视化倍率设置",
-  "未设置倍率模型": "未设置倍率模型",
-  "上游倍率同步": "上游倍率同步",
-  "未知类型": "未知类型",
-  "标签聚合": "标签聚合",
-  "已启用": "已启用",
-  "自动禁用": "自动禁用",
-  "未知状态": "未知状态",
-  "未测试": "未测试",
-  "名称": "名称",
-  "类型": "类型",
-  "状态": "状态",
-  ",时间:": ",时间:",
-  "响应时间": "响应时间",
-  "已用/剩余": "已用/剩余",
-  "剩余额度$": "剩余额度$",
-  ",点击更新": ",点击更新",
-  "已用额度": "已用额度",
-  "修改子渠道优先级": "修改子渠道优先级",
-  "确定要修改所有子渠道优先级为 ": "确定要修改所有子渠道优先级为 ",
-  "权重": "权重",
-  "修改子渠道权重": "修改子渠道权重",
-  "确定要修改所有子渠道权重为 ": "确定要修改所有子渠道权重为 ",
-  "确定是否要删除此渠道?": "确定是否要删除此渠道?",
-  "此修改将不可逆": "此修改将不可逆",
-  "确定是否要复制此渠道?": "确定是否要复制此渠道?",
-  "复制渠道的所有信息": "复制渠道的所有信息",
-  "测试单个渠道操作项目组": "测试单个渠道操作项目组",
-  "禁用": "禁用",
-  "启用": "启用",
-  "启用全部": "启用全部",
-  "禁用全部": "禁用全部",
-  "重置": "重置",
-  "全选": "全选",
-  "_复制": "_复制",
-  "渠道未找到,请刷新页面后重试。": "渠道未找到,请刷新页面后重试。",
-  "渠道复制成功": "渠道复制成功",
-  "渠道复制失败: ": "渠道复制失败: ",
-  "操作成功完成!": "操作成功完成!",
-  "通道 ${name} 测试成功,耗时 ${time.toFixed(2)} 秒。": "通道 ${name} 测试成功,耗时 ${time.toFixed(2)} 秒。",
-  "已停止测试": "已停止测试",
-  "全部": "全部",
-  "请先选择要设置标签的渠道!": "请先选择要设置标签的渠道!",
-  "标签不能为空!": "标签不能为空!",
-  "已为 ${count} 个渠道设置标签!": "已为 ${count} 个渠道设置标签!",
-  "已成功开始测试所有已启用通道,请刷新页面查看结果。": "已成功开始测试所有已启用通道,请刷新页面查看结果。",
-  "已删除所有禁用渠道,共计 ${data} 个": "已删除所有禁用渠道,共计 ${data} 个",
-  "已更新完毕所有已启用通道余额!": "已更新完毕所有已启用通道余额!",
-  "通道 ${name} 余额更新成功!": "通道 ${name} 余额更新成功!",
-  "已删除 ${data} 个通道!": "已删除 ${data} 个通道!",
-  "已修复 ${data} 个通道!": "已修复 ${data} 个通道!",
-  "确定是否要删除所选通道?": "确定是否要删除所选通道?",
-  "删除所选通道": "删除所选通道",
-  "批量设置标签": "批量设置标签",
-  "确定要测试所有通道吗?": "确定要测试所有通道吗?",
-  "测试所有通道": "测试所有通道",
-  "确定要更新所有已启用通道余额吗?": "确定要更新所有已启用通道余额吗?",
-  "更新所有已启用通道余额": "更新所有已启用通道余额",
-  "确定是否要删除禁用通道?": "确定是否要删除禁用通道?",
-  "删除禁用通道": "删除禁用通道",
-  "确定是否要修复数据库一致性?": "确定是否要修复数据库一致性?",
-  "进行该操作时,可能导致渠道访问错误,请仅在数据库出现问题时使用": "进行该操作时,可能导致渠道访问错误,请仅在数据库出现问题时使用",
-  "批量操作": "批量操作",
-  "使用ID排序": "使用ID排序",
-  "开启批量操作": "开启批量操作",
-  "标签聚合模式": "标签聚合模式",
-  "刷新": "刷新",
-  "列设置": "列设置",
-  "搜索渠道的 ID,名称,密钥和API地址 ...": "搜索渠道的 ID,名称,密钥和API地址 ...",
-  "模型关键字": "模型关键字",
-  "选择分组": "选择分组",
-  "查询": "查询",
-  "第 {{start}} - {{end}} 条,共 {{total}} 条": "第 {{start}} - {{end}} 条,共 {{total}} 条",
-  "搜索无结果": "搜索无结果",
-  "请输入要设置的标签名称": "请输入要设置的标签名称",
-  "请输入标签名称": "请输入标签名称",
-  "已选择 ${count} 个渠道": "已选择 ${count} 个渠道",
-  "共": "共",
-  "停止测试": "停止测试",
-  "测试中...": "测试中...",
-  "批量测试${count}个模型": "批量测试${count}个模型",
-  "搜索模型...": "搜索模型...",
-  "模型名称": "模型名称",
-  "测试中": "测试中",
-  "未开始": "未开始",
-  "失败": "失败",
-  "请求时长: ${time}s": "请求时长: ${time}s",
-  "充值": "充值",
-  "消费": "消费",
-  "系统": "系统",
-  "错误": "错误",
-  "流": "流",
-  "非流": "非流",
-  "请求并计费模型": "请求并计费模型",
-  "实际模型": "实际模型",
-  "用户": "用户",
-  "用时/首字": "用时/首字",
-  "提示": "提示",
-  "花费": "花费",
-  "只有当用户设置开启IP记录时,才会进行请求和错误类型日志的IP记录": "只有当用户设置开启IP记录时,才会进行请求和错误类型日志的IP记录",
-  "确定": "确定",
-  "用户信息": "用户信息",
-  "渠道信息": "渠道信息",
-  "语音输入": "语音输入",
-  "文字输入": "文字输入",
-  "文字输出": "文字输出",
-  "缓存创建 Tokens": "缓存创建 Tokens",
-  "日志详情": "日志详情",
-  "消耗额度": "消耗额度",
-  "开始时间": "开始时间",
-  "结束时间": "结束时间",
-  "用户名称": "用户名称",
-  "日志类型": "日志类型",
-  "绘图": "绘图",
-  "放大": "放大",
-  "变换": "变换",
-  "强变换": "强变换",
-  "平移": "平移",
-  "图生文": "图生文",
-  "图混合": "图混合",
-  "重绘": "重绘",
-  "局部重绘-提交": "局部重绘-提交",
-  "自定义变焦-提交": "自定义变焦-提交",
-  "窗口处理": "窗口处理",
-  "未知": "未知",
-  "已提交": "已提交",
-  "等待中": "等待中",
-  "重复提交": "重复提交",
-  "成功": "成功",
-  "未启动": "未启动",
-  "执行中": "执行中",
-  "窗口等待": "窗口等待",
-  "秒": "秒",
-  "提交时间": "提交时间",
-  "花费时间": "花费时间",
-  "任务ID": "任务ID",
-  "提交结果": "提交结果",
-  "任务状态": "任务状态",
-  "结果图片": "结果图片",
-  "查看图片": "查看图片",
-  "无": "无",
-  "失败原因": "失败原因",
-  "已复制:": "已复制:",
-  "当前未开启Midjourney回调,部分项目可能无法获得绘图结果,可在运营设置中开启。": "当前未开启Midjourney回调,部分项目可能无法获得绘图结果,可在运营设置中开启。",
-  "Midjourney 任务记录": "Midjourney 任务记录",
-  "任务 ID": "任务 ID",
-  "按次计费": "按次计费",
-  "按量计费": "按量计费",
-  "您的分组可以使用该模型": "您的分组可以使用该模型",
-  "可用性": "可用性",
-  "计费类型": "计费类型",
-  "当前查看的分组为:{{group}},倍率为:{{ratio}}": "当前查看的分组为:{{group}},倍率为:{{ratio}}",
-  "倍率": "倍率",
-  "倍率是为了方便换算不同价格的模型": "倍率是为了方便换算不同价格的模型",
-  "模型倍率": "模型倍率",
-  "补全倍率": "补全倍率",
-  "分组倍率": "分组倍率",
-  "模型价格": "模型价格",
-  "补全": "补全",
-  "模糊搜索模型名称": "模糊搜索模型名称",
-  "复制选中模型": "复制选中模型",
-  "模型定价": "模型定价",
-  "当前分组": "当前分组",
-  "未登录,使用默认分组倍率": "未登录,使用默认分组倍率",
-  "按量计费费用 = 分组倍率 × 模型倍率 × (提示token数 + 补全token数 × 补全倍率)/ 500000 (单位:美元)": "按量计费费用 = 分组倍率 × 模型倍率 × (提示token数 + 补全token数 × 补全倍率)/ 500000 (单位:美元)",
-  "已过期": "已过期",
-  "未使用": "未使用",
-  "已禁用": "已禁用",
-  "创建时间": "创建时间",
-  "过期时间": "过期时间",
-  "永不过期": "永不过期",
-  "确定是否要删除此兑换码?": "确定是否要删除此兑换码?",
-  "查看": "查看",
-  "已复制到剪贴板!": "已复制到剪贴板!",
-  "兑换码可以批量生成和分发,适合用于推广活动或批量充值。": "兑换码可以批量生成和分发,适合用于推广活动或批量充值。",
-  "添加兑换码": "添加兑换码",
-  "请至少选择一个兑换码!": "请至少选择一个兑换码!",
-  "复制所选兑换码到剪贴板": "复制所选兑换码到剪贴板",
-  "确定清除所有失效兑换码?": "确定清除所有失效兑换码?",
-  "将删除已使用、已禁用及过期的兑换码,此操作不可撤销。": "将删除已使用、已禁用及过期的兑换码,此操作不可撤销。",
-  "已删除 {{count}} 条失效兑换码": "已删除 {{count}} 条失效兑换码",
-  "关键字(id或者名称)": "关键字(id或者名称)",
-  "生成音乐": "生成音乐",
-  "生成歌词": "生成歌词",
-  "生成视频": "生成视频",
-  "排队中": "排队中",
-  "正在提交": "正在提交",
-  "平台": "平台",
-  "点击预览视频": "点击预览视频",
-  "任务记录": "任务记录",
-  "渠道 ID": "渠道 ID",
-  "已启用:限制模型": "已启用:限制模型",
-  "已耗尽": "已耗尽",
-  "剩余额度": "剩余额度",
-  "聊天链接配置错误,请联系管理员": "聊天链接配置错误,请联系管理员",
-  "令牌详情": "令牌详情",
-  "确定是否要删除此令牌?": "确定是否要删除此令牌?",
-  "项目操作按钮组": "项目操作按钮组",
-  "请联系管理员配置聊天链接": "请联系管理员配置聊天链接",
-  "令牌用于API访问认证,可以设置额度限制和模型权限。": "令牌用于API访问认证,可以设置额度限制和模型权限。",
-  "添加令牌": "添加令牌",
-  "请至少选择一个令牌!": "请至少选择一个令牌!",
-  "复制所选令牌到剪贴板": "复制所选令牌到剪贴板",
-  "搜索关键字": "搜索关键字",
-  "未知身份": "未知身份",
-  "已封禁": "已封禁",
-  "统计信息": "统计信息",
-  "剩余": "剩余",
-  "调用": "调用",
-  "邀请信息": "邀请信息",
-  "收益": "收益",
-  "无邀请人": "无邀请人",
-  "已注销": "已注销",
-  "确定要提升此用户吗?": "确定要提升此用户吗?",
-  "此操作将提升用户的权限级别": "此操作将提升用户的权限级别",
-  "确定要降级此用户吗?": "确定要降级此用户吗?",
-  "此操作将降低用户的权限级别": "此操作将降低用户的权限级别",
-  "确定是否要注销此用户?": "确定是否要注销此用户?",
-  "相当于删除用户,此修改将不可逆": "相当于删除用户,此修改将不可逆",
-  "用户管理页面,可以查看和管理所有注册用户的信息、权限和状态。": "用户管理页面,可以查看和管理所有注册用户的信息、权限和状态。",
-  "添加用户": "添加用户",
-  "支持搜索用户的 ID、用户名、显示名称和邮箱地址": "支持搜索用户的 ID、用户名、显示名称和邮箱地址",
-  "全部模型": "全部模型",
-  "智谱": "智谱",
-  "通义千问": "通义千问",
-  "文心一言": "文心一言",
-  "腾讯混元": "腾讯混元",
-  "360智脑": "360智脑",
-  "豆包": "豆包",
-  "用户分组": "用户分组",
-  "专属倍率": "专属倍率",
-  "输入价格:${{price}} / 1M tokens{{audioPrice}}": "输入价格:${{price}} / 1M tokens{{audioPrice}}",
-  "Web搜索价格:${{price}} / 1K 次": "Web搜索价格:${{price}} / 1K 次",
-  "文件搜索价格:${{price}} / 1K 次": "文件搜索价格:${{price}} / 1K 次",
-  "仅供参考,以实际扣费为准": "仅供参考,以实际扣费为准",
-  "价格:${{price}} * {{ratioType}}:{{ratio}}": "价格:${{price}} * {{ratioType}}:{{ratio}}",
-  "模型: {{ratio}} * {{ratioType}}:{{groupRatio}}": "模型: {{ratio}} * {{ratioType}}:{{groupRatio}}",
-  "提示价格:${{price}} / 1M tokens": "提示价格:${{price}} / 1M tokens",
-  "模型价格 ${{price}},{{ratioType}} {{ratio}}": "模型价格 ${{price}},{{ratioType}} {{ratio}}",
-  "模型: {{ratio}} * {{ratioType}}: {{groupRatio}}": "模型: {{ratio}} * {{ratioType}}: {{groupRatio}}",
-  "不是合法的 JSON 字符串": "不是合法的 JSON 字符串",
-  "请求发生错误: ": "请求发生错误: ",
-  "解析响应数据时发生错误": "解析响应数据时发生错误",
-  "连接已断开": "连接已断开",
-  "建立连接时发生错误": "建立连接时发生错误",
-  "加载模型失败": "加载模型失败",
-  "加载分组失败": "加载分组失败",
-  "消息已复制到剪贴板": "消息已复制到剪贴板",
-  "确认删除": "确认删除",
-  "确定要删除这条消息吗?": "确定要删除这条消息吗?",
-  "已删除消息及其回复": "已删除消息及其回复",
-  "消息已删除": "消息已删除",
-  "消息已编辑": "消息已编辑",
-  "检测到该消息后有AI回复,是否删除后续回复并重新生成?": "检测到该消息后有AI回复,是否删除后续回复并重新生成?",
-  "重新生成": "重新生成",
-  "消息已更新": "消息已更新",
-  "加载关于内容失败...": "加载关于内容失败...",
-  "可在设置页面设置关于内容,支持 HTML & Markdown": "可在设置页面设置关于内容,支持 HTML & Markdown",
-  "New API项目仓库地址:": "New API项目仓库地址:",
-  "| 基于": "| 基于",
-  "本项目根据": "本项目根据",
-  "MIT许可证": "MIT许可证",
-  "授权,需在遵守": "授权,需在遵守",
-  "Apache-2.0协议": "Apache-2.0协议",
-  "管理员暂时未设置任何关于内容": "管理员暂时未设置任何关于内容",
-  "仅支持 OpenAI 接口格式": "仅支持 OpenAI 接口格式",
-  "请填写密钥": "请填写密钥",
-  "获取模型列表成功": "获取模型列表成功",
-  "获取模型列表失败": "获取模型列表失败",
-  "请填写渠道名称和渠道密钥!": "请填写渠道名称和渠道密钥!",
-  "请至少选择一个模型!": "请至少选择一个模型!",
-  "模型映射必须是合法的 JSON 格式!": "模型映射必须是合法的 JSON 格式!",
-  "提交失败,请勿重复提交!": "提交失败,请勿重复提交!",
-  "渠道创建成功!": "渠道创建成功!",
-  "已新增 {{count}} 个模型:{{list}}": "已新增 {{count}} 个模型:{{list}}",
-  "未发现新增模型": "未发现新增模型",
-  "新建": "新建",
-  "更新渠道信息": "更新渠道信息",
-  "创建新的渠道": "创建新的渠道",
-  "基本信息": "基本信息",
-  "渠道的基本配置信息": "渠道的基本配置信息",
-  "请选择渠道类型": "请选择渠道类型",
-  "请为渠道命名": "请为渠道命名",
-  "请输入密钥,一行一个": "请输入密钥,一行一个",
-  "批量创建": "批量创建",
-  "API 配置": "API 配置",
-  "API 地址和相关配置": "API 地址和相关配置",
-  "2025年5月10日后添加的渠道,不需要再在部署的时候移除模型名称中的\".\"": "2025年5月10日后添加的渠道,不需要再在部署的时候移除模型名称中的\".\"",
-  "请输入 AZURE_OPENAI_ENDPOINT,例如:https://docs-test-001.openai.azure.com": "请输入 AZURE_OPENAI_ENDPOINT,例如:https://docs-test-001.openai.azure.com",
-  "请输入默认 API 版本,例如:2025-04-01-preview": "请输入默认 API 版本,例如:2025-04-01-preview",
-  "如果你对接的是上游One API或者New API等转发项目,请使用OpenAI类型,不要使用此类型,除非你知道你在做什么。": "如果你对接的是上游One API或者New API等转发项目,请使用OpenAI类型,不要使用此类型,除非你知道你在做什么。",
-  "完整的 Base URL,支持变量{model}": "完整的 Base URL,支持变量{model}",
-  "请输入完整的URL,例如:https://api.openai.com/v1/chat/completions": "请输入完整的URL,例如:https://api.openai.com/v1/chat/completions",
-  "Dify渠道只适配chatflow和agent,并且agent不支持图片!": "Dify渠道只适配chatflow和agent,并且agent不支持图片!",
-  "此项可选,用于通过自定义API地址来进行 API 调用,末尾不要带/v1和/": "此项可选,用于通过自定义API地址来进行 API 调用,末尾不要带/v1和/",
-  "对于官方渠道,new-api已经内置地址,除非是第三方代理站点或者Azure的特殊接入地址,否则不需要填写": "对于官方渠道,new-api已经内置地址,除非是第三方代理站点或者Azure的特殊接入地址,否则不需要填写",
-  "私有部署地址": "私有部署地址",
-  "请输入私有部署地址,格式为:https://fastgpt.run/api/openapi": "请输入私有部署地址,格式为:https://fastgpt.run/api/openapi",
-  "注意非Chat API,请务必填写正确的API地址,否则可能导致无法使用": "注意非Chat API,请务必填写正确的API地址,否则可能导致无法使用",
-  "请输入到 /suno 前的路径,通常就是域名,例如:https://api.example.com": "请输入到 /suno 前的路径,通常就是域名,例如:https://api.example.com",
-  "模型选择和映射设置": "模型选择和映射设置",
-  "模型": "模型",
-  "请选择该渠道所支持的模型": "请选择该渠道所支持的模型",
-  "填入相关模型": "填入相关模型",
-  "填入所有模型": "填入所有模型",
-  "获取模型列表": "获取模型列表",
-  "清除所有模型": "清除所有模型",
-  "输入自定义模型名称": "输入自定义模型名称",
-  "模型重定向": "模型重定向",
-  "此项可选,用于修改请求体中的模型名称,为一个 JSON 字符串,键为请求中模型名称,值为要替换的模型名称,例如:": "此项可选,用于修改请求体中的模型名称,为一个 JSON 字符串,键为请求中模型名称,值为要替换的模型名称,例如:",
-  "填入模板": "填入模板",
-  "默认测试模型": "默认测试模型",
-  "不填则为模型列表第一个": "不填则为模型列表第一个",
-  "渠道的高级配置选项": "渠道的高级配置选项",
-  "请选择可以使用该渠道的分组": "请选择可以使用该渠道的分组",
-  "请在系统设置页面编辑分组倍率以添加新的分组:": "请在系统设置页面编辑分组倍率以添加新的分组:",
-  "部署地区": "部署地区",
-  "知识库 ID": "知识库 ID",
-  "渠道标签": "渠道标签",
-  "渠道优先级": "渠道优先级",
-  "渠道权重": "渠道权重",
-  "渠道额外设置": "渠道额外设置",
-  "此项可选,用于配置渠道特定设置,为一个 JSON 字符串,例如:": "此项可选,用于配置渠道特定设置,为一个 JSON 字符串,例如:",
-  "参数覆盖": "参数覆盖",
-  "此项可选,用于覆盖请求参数。不支持覆盖 stream 参数。为一个 JSON 字符串,例如:": "此项可选,用于覆盖请求参数。不支持覆盖 stream 参数。为一个 JSON 字符串,例如:",
-  "请输入组织org-xxx": "请输入组织org-xxx",
-  "组织,可选,不填则为默认组织": "组织,可选,不填则为默认组织",
-  "是否自动禁用(仅当自动禁用开启时有效),关闭后不会自动禁用该渠道": "是否自动禁用(仅当自动禁用开启时有效),关闭后不会自动禁用该渠道",
-  "状态码复写(仅影响本地判断,不修改返回到上游的状态码)": "状态码复写(仅影响本地判断,不修改返回到上游的状态码)",
-  "此项可选,用于复写返回的状态码,比如将claude渠道的400错误复写为500(用于重试),请勿滥用该功能,例如:": "此项可选,用于复写返回的状态码,比如将claude渠道的400错误复写为500(用于重试),请勿滥用该功能,例如:",
-  "编辑标签": "编辑标签",
-  "标签信息": "标签信息",
-  "标签的基本配置": "标签的基本配置",
-  "所有编辑均为覆盖操作,留空则不更改": "所有编辑均为覆盖操作,留空则不更改",
-  "标签名称": "标签名称",
-  "请输入新标签,留空则解散标签": "请输入新标签,留空则解散标签",
-  "当前模型列表为该标签下所有渠道模型列表最长的一个,并非所有渠道的并集,请注意可能导致某些渠道模型丢失。": "当前模型列表为该标签下所有渠道模型列表最长的一个,并非所有渠道的并集,请注意可能导致某些渠道模型丢失。",
-  "请选择该渠道所支持的模型,留空则不更改": "请选择该渠道所支持的模型,留空则不更改",
-  "此项可选,用于修改请求体中的模型名称,为一个 JSON 字符串,键为请求中模型名称,值为要替换的模型名称,留空则不更改": "此项可选,用于修改请求体中的模型名称,为一个 JSON 字符串,键为请求中模型名称,值为要替换的模型名称,留空则不更改",
-  "清空重定向": "清空重定向",
-  "分组设置": "分组设置",
-  "用户分组配置": "用户分组配置",
-  "请选择可以使用该渠道的分组,留空则不更改": "请选择可以使用该渠道的分组,留空则不更改",
-  "正在跳转...": "正在跳转...",
-  "小时": "小时",
-  "周": "周",
-  "模型调用次数占比": "模型调用次数占比",
-  "模型消耗分布": "模型消耗分布",
-  "总计": "总计",
-  "早上好": "早上好",
-  "中午好": "中午好",
-  "下午好": "下午好",
-  "账户数据": "账户数据",
-  "使用统计": "使用统计",
-  "统计次数": "统计次数",
-  "资源消耗": "资源消耗",
-  "统计额度": "统计额度",
-  "性能指标": "性能指标",
-  "平均RPM": "平均RPM",
-  "复制成功": "复制成功",
-  "进行中": "进行中",
-  "异常": "异常",
-  "正常": "正常",
-  "可用率": "可用率",
-  "有异常": "有异常",
-  "高延迟": "高延迟",
-  "维护中": "维护中",
-  "暂无监控数据": "暂无监控数据",
-  "搜索条件": "搜索条件",
-  "时间粒度": "时间粒度",
-  "模型数据分析": "模型数据分析",
-  "消耗分布": "消耗分布",
-  "调用次数分布": "调用次数分布",
-  "API信息": "API信息",
-  "暂无API信息": "暂无API信息",
-  "请联系管理员在系统设置中配置API信息": "请联系管理员在系统设置中配置API信息",
-  "显示最新20条": "显示最新20条",
-  "请联系管理员在系统设置中配置公告信息": "请联系管理员在系统设置中配置公告信息",
-  "暂无常见问答": "暂无常见问答",
-  "请联系管理员在系统设置中配置常见问答": "请联系管理员在系统设置中配置常见问答",
-  "服务可用性": "服务可用性",
-  "请联系管理员在系统设置中配置Uptime": "请联系管理员在系统设置中配置Uptime",
-  "加载首页内容失败...": "加载首页内容失败...",
-  "统一的大模型接口网关": "统一的大模型接口网关",
-  "更好的价格,更好的稳定性,无需订阅": "更好的价格,更好的稳定性,无需订阅",
-  "开始使用": "开始使用",
-  "支持众多的大模型供应商": "支持众多的大模型供应商",
-  "页面未找到,请检查您的浏览器地址是否正确": "页面未找到,请检查您的浏览器地址是否正确",
-  "登录过期,请重新登录!": "登录过期,请重新登录!",
-  "兑换码更新成功!": "兑换码更新成功!",
-  "兑换码创建成功!": "兑换码创建成功!",
-  "兑换码创建成功": "兑换码创建成功",
-  "兑换码创建成功,是否下载兑换码?": "兑换码创建成功,是否下载兑换码?",
-  "兑换码将以文本文件的形式下载,文件名为兑换码的名称。": "兑换码将以文本文件的形式下载,文件名为兑换码的名称。",
-  "更新兑换码信息": "更新兑换码信息",
-  "创建新的兑换码": "创建新的兑换码",
-  "设置兑换码的基本信息": "设置兑换码的基本信息",
-  "请输入名称": "请输入名称",
-  "选择过期时间(可选,留空为永久)": "选择过期时间(可选,留空为永久)",
-  "额度设置": "额度设置",
-  "设置兑换码的额度和数量": "设置兑换码的额度和数量",
-  "请输入额度": "请输入额度",
-  "生成数量": "生成数量",
-  "请输入生成数量": "请输入生成数量",
-  "你似乎并没有修改什么": "你似乎并没有修改什么",
-  "部分保存失败,请重试": "部分保存失败,请重试",
-  "保存成功": "保存成功",
-  "保存失败,请重试": "保存失败,请重试",
-  "请检查输入": "请检查输入",
-  "聊天配置": "聊天配置",
-  "为一个 JSON 文本": "为一个 JSON 文本",
-  "保存聊天设置": "保存聊天设置",
-  "设置已保存": "设置已保存",
-  "API地址": "API地址",
-  "说明": "说明",
-  "颜色": "颜色",
-  "API信息管理,可以配置多个API地址用于状态展示和负载均衡(最多50个)": "API信息管理,可以配置多个API地址用于状态展示和负载均衡(最多50个)",
-  "批量删除": "批量删除",
-  "保存设置": "保存设置",
-  "添加API": "添加API",
-  "请输入API地址": "请输入API地址",
-  "如:香港线路": "如:香港线路",
-  "请输入线路描述": "请输入线路描述",
-  "如:大带宽批量分析图片推荐": "如:大带宽批量分析图片推荐",
-  "请输入说明": "请输入说明",
-  "标识颜色": "标识颜色",
-  "确定要删除此API信息吗?": "确定要删除此API信息吗?",
-  "警告": "警告",
-  "发布时间": "发布时间",
-  "操作": "操作",
-  "系统公告管理,可以发布系统通知和重要消息(最多100个,前端显示最新20条)": "系统公告管理,可以发布系统通知和重要消息(最多100个,前端显示最新20条)",
-  "添加公告": "添加公告",
-  "编辑公告": "编辑公告",
-  "公告内容": "公告内容",
-  "请输入公告内容": "请输入公告内容",
-  "请选择发布日期": "请选择发布日期",
-  "公告类型": "公告类型",
-  "说明信息": "说明信息",
-  "可选,公告的补充说明": "可选,公告的补充说明",
-  "确定要删除此公告吗?": "确定要删除此公告吗?",
-  "数据看板设置": "数据看板设置",
-  "启用数据看板(实验性)": "启用数据看板(实验性)",
-  "数据看板更新间隔": "数据看板更新间隔",
-  "设置过短会影响数据库性能": "设置过短会影响数据库性能",
-  "数据看板默认时间粒度": "数据看板默认时间粒度",
-  "仅修改展示粒度,统计精确到小时": "仅修改展示粒度,统计精确到小时",
-  "保存数据看板设置": "保存数据看板设置",
-  "问题标题": "问题标题",
-  "回答内容": "回答内容",
-  "常见问答管理,为用户提供常见问题的答案(最多50个,前端显示最新20条)": "常见问答管理,为用户提供常见问题的答案(最多50个,前端显示最新20条)",
-  "添加问答": "添加问答",
-  "编辑问答": "编辑问答",
-  "请输入问题标题": "请输入问题标题",
-  "请输入回答内容": "请输入回答内容",
-  "确定要删除此问答吗?": "确定要删除此问答吗?",
-  "分类名称": "分类名称",
-  "Uptime Kuma地址": "Uptime Kuma地址",
-  "Uptime Kuma监控分类管理,可以配置多个监控分类用于服务状态展示(最多20个)": "Uptime Kuma监控分类管理,可以配置多个监控分类用于服务状态展示(最多20个)",
-  "编辑分类": "编辑分类",
-  "添加分类": "添加分类",
-  "请输入分类名称,如:OpenAI、Claude等": "请输入分类名称,如:OpenAI、Claude等",
-  "请输入分类名称": "请输入分类名称",
-  "请输入Uptime Kuma服务地址,如:https://status.example.com": "请输入Uptime Kuma服务地址,如:https://status.example.com",
-  "请输入Uptime Kuma地址": "请输入Uptime Kuma地址",
-  "请输入状态页面的Slug,如:my-status": "请输入状态页面的Slug,如:my-status",
-  "请输入状态页面Slug": "请输入状态页面Slug",
-  "确定要删除此分类吗?": "确定要删除此分类吗?",
-  "绘图设置": "绘图设置",
-  "启用绘图功能": "启用绘图功能",
-  "允许回调(会泄露服务器 IP 地址)": "允许回调(会泄露服务器 IP 地址)",
-  "允许 AccountFilter 参数": "允许 AccountFilter 参数",
-  "开启之后会清除用户提示词中的": "开启之后会清除用户提示词中的",
-  "以及": "以及",
-  "检测必须等待绘图成功才能进行放大等操作": "检测必须等待绘图成功才能进行放大等操作",
-  "保存绘图设置": "保存绘图设置",
-  "Claude设置": "Claude设置",
-  "Claude请求头覆盖": "Claude请求头覆盖",
-  "为一个 JSON 文本,例如:": "为一个 JSON 文本,例如:",
-  "缺省 MaxTokens": "缺省 MaxTokens",
-  "启用Claude思考适配(-thinking后缀)": "启用Claude思考适配(-thinking后缀)",
-  "思考适配 BudgetTokens 百分比": "思考适配 BudgetTokens 百分比",
-  "0.1-1之间的小数": "0.1-1之间的小数",
-  "Gemini设置": "Gemini设置",
-  "Gemini安全设置": "Gemini安全设置",
-  "default为默认设置,可单独设置每个模型的版本": "default为默认设置,可单独设置每个模型的版本",
-  "例如:": "例如:",
-  "Gemini思考适配设置": "Gemini思考适配设置",
-  "启用Gemini思考后缀适配": "启用Gemini思考后缀适配",
-  "适配 -thinking、-thinking-预算数字 和 -nothinking 后缀": "适配 -thinking、-thinking-预算数字 和 -nothinking 后缀",
-  "0.002-1之间的小数": "0.002-1之间的小数",
-  "全局设置": "全局设置",
-  "启用请求透传": "启用请求透传",
-  "连接保活设置": "连接保活设置",
-  "启用Ping间隔": "启用Ping间隔",
-  "Ping间隔(秒)": "Ping间隔(秒)",
-  "新用户初始额度": "新用户初始额度",
-  "请求预扣费额度": "请求预扣费额度",
-  "请求结束后多退少补": "请求结束后多退少补",
-  "邀请新用户奖励额度": "邀请新用户奖励额度",
-  "新用户使用邀请码奖励额度": "新用户使用邀请码奖励额度",
-  "例如:1000": "例如:1000",
-  "保存额度设置": "保存额度设置",
-  "例如发卡网站的购买链接": "例如发卡网站的购买链接",
-  "文档地址": "文档地址",
-  "单位美元额度": "单位美元额度",
-  "一单位货币能兑换的额度": "一单位货币能兑换的额度",
-  "失败重试次数": "失败重试次数",
-  "以货币形式显示额度": "以货币形式显示额度",
-  "额度查询接口返回令牌额度而非用户额度": "额度查询接口返回令牌额度而非用户额度",
-  "默认折叠侧边栏": "默认折叠侧边栏",
-  "开启后不限制:必须设置模型倍率": "开启后不限制:必须设置模型倍率",
-  "保存通用设置": "保存通用设置",
-  "请选择日志记录时间": "请选择日志记录时间",
-  "条日志已清理!": "条日志已清理!",
-  "日志清理失败:": "日志清理失败:",
-  "启用额度消费日志记录": "启用额度消费日志记录",
-  "日志记录时间": "日志记录时间",
-  "清除历史日志": "清除历史日志",
-  "保存日志设置": "保存日志设置",
-  "监控设置": "监控设置",
-  "测试所有渠道的最长响应时间": "测试所有渠道的最长响应时间",
-  "额度提醒阈值": "额度提醒阈值",
-  "低于此额度时将发送邮件提醒用户": "低于此额度时将发送邮件提醒用户",
-  "失败时自动禁用通道": "失败时自动禁用通道",
-  "成功时自动启用通道": "成功时自动启用通道",
-  "自动禁用关键词": "自动禁用关键词",
-  "一行一个,不区分大小写": "一行一个,不区分大小写",
-  "屏蔽词过滤设置": "屏蔽词过滤设置",
-  "启用屏蔽词过滤功能": "启用屏蔽词过滤功能",
-  "启用 Prompt 检查": "启用 Prompt 检查",
-  "一行一个屏蔽词,不需要符号分割": "一行一个屏蔽词,不需要符号分割",
-  "保存屏蔽词过滤设置": "保存屏蔽词过滤设置",
-  "更新成功": "更新成功",
-  "更新失败": "更新失败",
-  "服务器地址": "服务器地址",
-  "更新服务器地址": "更新服务器地址",
-  "请先填写服务器地址": "请先填写服务器地址",
-  "充值分组倍率不是合法的 JSON 字符串": "充值分组倍率不是合法的 JSON 字符串",
-  "充值方式设置不是合法的 JSON 字符串": "充值方式设置不是合法的 JSON 字符串",
-  "支付设置": "支付设置",
-  "(当前仅支持易支付接口,默认使用上方服务器地址作为回调地址!)": "(当前仅支持易支付接口,默认使用上方服务器地址作为回调地址!)",
-  "例如:https://yourdomain.com": "例如:https://yourdomain.com",
-  "易支付商户ID": "易支付商户ID",
-  "易支付商户密钥": "易支付商户密钥",
-  "敏感信息不会发送到前端显示": "敏感信息不会发送到前端显示",
-  "回调地址": "回调地址",
-  "充值价格(x元/美金)": "充值价格(x元/美金)",
-  "例如:7,就是7元/美金": "例如:7,就是7元/美金",
-  "最低充值美元数量": "最低充值美元数量",
-  "例如:2,就是最低充值2$": "例如:2,就是最低充值2$",
-  "为一个 JSON 文本,键为组名称,值为倍率": "为一个 JSON 文本,键为组名称,值为倍率",
-  "充值方式设置": "充值方式设置",
-  "更新支付设置": "更新支付设置",
-  "模型请求速率限制": "模型请求速率限制",
-  "启用用户模型请求速率限制(可能会影响高并发性能)": "启用用户模型请求速率限制(可能会影响高并发性能)",
-  "分钟": "分钟",
-  "频率限制的周期(分钟)": "频率限制的周期(分钟)",
-  "用户每周期最多请求次数": "用户每周期最多请求次数",
-  "包括失败请求的次数,0代表不限制": "包括失败请求的次数,0代表不限制",
-  "用户每周期最多请求完成次数": "用户每周期最多请求完成次数",
-  "只包括请求成功的次数": "只包括请求成功的次数",
-  "分组速率限制": "分组速率限制",
-  "使用 JSON 对象格式,格式为:{\"组名\": [最多请求次数, 最多请求完成次数]}": "使用 JSON 对象格式,格式为:{\"组名\": [最多请求次数, 最多请求完成次数]}",
-  "示例:{\"default\": [200, 100], \"vip\": [0, 1000]}。": "示例:{\"default\": [200, 100], \"vip\": [0, 1000]}。",
-  "[最多请求次数]必须大于等于0,[最多请求完成次数]必须大于等于1。": "[最多请求次数]必须大于等于0,[最多请求完成次数]必须大于等于1。",
-  "分组速率配置优先级高于全局速率限制。": "分组速率配置优先级高于全局速率限制。",
-  "限制周期统一使用上方配置的“限制周期”值。": "限制周期统一使用上方配置的“限制周期”值。",
-  "保存模型速率限制": "保存模型速率限制",
-  "保存失败": "保存失败",
-  "为一个 JSON 文本,键为分组名称,值为倍率": "为一个 JSON 文本,键为分组名称,值为倍率",
-  "用户可选分组": "用户可选分组",
-  "为一个 JSON 文本,键为分组名称,值为分组描述": "为一个 JSON 文本,键为分组名称,值为分组描述",
-  "自动分组auto,从第一个开始选择": "自动分组auto,从第一个开始选择",
-  "必须是有效的 JSON 字符串数组,例如:[\"g1\",\"g2\"]": "必须是有效的 JSON 字符串数组,例如:[\"g1\",\"g2\"]",
-  "模型固定价格": "模型固定价格",
-  "一次调用消耗多少刀,优先级大于模型倍率": "一次调用消耗多少刀,优先级大于模型倍率",
-  "为一个 JSON 文本,键为模型名称,值为倍率": "为一个 JSON 文本,键为模型名称,值为倍率",
-  "模型补全倍率(仅对自定义模型有效)": "模型补全倍率(仅对自定义模型有效)",
-  "仅对自定义模型有效": "仅对自定义模型有效",
-  "保存模型倍率设置": "保存模型倍率设置",
-  "确定重置模型倍率吗?": "确定重置模型倍率吗?",
-  "重置模型倍率": "重置模型倍率",
-  "获取启用模型失败:": "获取启用模型失败:",
-  "获取启用模型失败": "获取启用模型失败",
-  "JSON解析错误:": "JSON解析错误:",
-  "保存失败:": "保存失败:",
-  "输入模型倍率": "输入模型倍率",
-  "输入补全倍率": "输入补全倍率",
-  "请输入数字": "请输入数字",
-  "模型名称已存在": "模型名称已存在",
-  "请先选择需要批量设置的模型": "请先选择需要批量设置的模型",
-  "请输入模型倍率和补全倍率": "请输入模型倍率和补全倍率",
-  "请输入有效的数字": "请输入有效的数字",
-  "请输入填充值": "请输入填充值",
-  "批量设置成功": "批量设置成功",
-  "已为 {{count}} 个模型设置{{type}}": "已为 {{count}} 个模型设置{{type}}",
-  "模型倍率和补全倍率": "模型倍率和补全倍率",
-  "添加模型": "添加模型",
-  "批量设置": "批量设置",
-  "应用更改": "应用更改",
-  "搜索模型名称": "搜索模型名称",
-  "此页面仅显示未设置价格或倍率的模型,设置后将自动从列表中移除": "此页面仅显示未设置价格或倍率的模型,设置后将自动从列表中移除",
-  "定价模式": "定价模式",
-  "固定价格": "固定价格",
-  "固定价格(每次)": "固定价格(每次)",
-  "输入每次价格": "输入每次价格",
-  "输入补全价格": "输入补全价格",
-  "批量设置模型参数": "批量设置模型参数",
-  "设置类型": "设置类型",
-  "模型倍率和补全倍率同时设置": "模型倍率和补全倍率同时设置",
-  "模型倍率值": "模型倍率值",
-  "请输入模型倍率": "请输入模型倍率",
-  "补全倍率值": "补全倍率值",
-  "请输入补全倍率": "请输入补全倍率",
-  "请输入数值": "请输入数值",
-  "将为选中的 ": "将为选中的 ",
-  " 个模型设置相同的值": " 个模型设置相同的值",
-  "当前设置类型: ": "当前设置类型: ",
-  "默认补全倍率": "默认补全倍率",
-  "添加成功": "添加成功",
-  "价格设置方式": "价格设置方式",
-  "按倍率设置": "按倍率设置",
-  "按价格设置": "按价格设置",
-  "输入价格": "输入价格",
-  "输出价格": "输出价格",
-  "获取渠道失败:": "获取渠道失败:",
-  "请至少选择一个渠道": "请至少选择一个渠道",
-  "后端请求失败": "后端请求失败",
-  "部分渠道测试失败:": "部分渠道测试失败:",
-  "未找到差异化倍率,无需同步": "未找到差异化倍率,无需同步",
-  "请求后端接口失败:": "请求后端接口失败:",
-  "同步成功": "同步成功",
-  "部分保存失败": "部分保存失败",
-  "未找到匹配的模型": "未找到匹配的模型",
-  "暂无差异化倍率显示": "暂无差异化倍率显示",
-  "请先选择同步渠道": "请先选择同步渠道",
-  "倍率类型": "倍率类型",
-  "缓存倍率": "缓存倍率",
-  "当前值": "当前值",
-  "未设置": "未设置",
-  "与本地相同": "与本地相同",
-  "运营设置": "运营设置",
-  "聊天设置": "聊天设置",
-  "速率限制设置": "速率限制设置",
-  "模型相关设置": "模型相关设置",
-  "系统设置": "系统设置",
-  "仪表盘设置": "仪表盘设置",
-  "获取初始化状态失败": "获取初始化状态失败",
-  "表单引用错误,请刷新页面重试": "表单引用错误,请刷新页面重试",
-  "请输入管理员用户名": "请输入管理员用户名",
-  "密码长度至少为8个字符": "密码长度至少为8个字符",
-  "两次输入的密码不一致": "两次输入的密码不一致",
-  "系统初始化成功,正在跳转...": "系统初始化成功,正在跳转...",
-  "初始化失败,请重试": "初始化失败,请重试",
-  "系统初始化失败,请重试": "系统初始化失败,请重试",
-  "系统初始化": "系统初始化",
-  "欢迎使用,请完成以下设置以开始使用系统": "欢迎使用,请完成以下设置以开始使用系统",
-  "数据库信息": "数据库信息",
-  "管理员账号": "管理员账号",
-  "设置系统管理员的登录信息": "设置系统管理员的登录信息",
-  "管理员账号已经初始化过,请继续设置其他参数": "管理员账号已经初始化过,请继续设置其他参数",
-  "密码": "密码",
-  "请输入管理员密码": "请输入管理员密码",
-  "请确认管理员密码": "请确认管理员密码",
-  "选择适合您使用场景的模式": "选择适合您使用场景的模式",
-  "对外运营模式": "对外运营模式",
-  "适用于为多个用户提供服务的场景": "适用于为多个用户提供服务的场景",
-  "默认模式": "默认模式",
-  "适用于个人使用的场景,不需要设置模型价格": "适用于个人使用的场景,不需要设置模型价格",
-  "无需计费": "无需计费",
-  "演示站点模式": "演示站点模式",
-  "适用于展示系统功能的场景,提供基础功能演示": "适用于展示系统功能的场景,提供基础功能演示",
-  "初始化系统": "初始化系统",
-  "使用模式说明": "使用模式说明",
-  "我已了解": "我已了解",
-  "默认模式,适用于为多个用户提供服务的场景。": "默认模式,适用于为多个用户提供服务的场景。",
-  "此模式下,系统将计算每次调用的用量,您需要对每个模型都设置价格,如果没有设置价格,用户将无法使用该模型。": "此模式下,系统将计算每次调用的用量,您需要对每个模型都设置价格,如果没有设置价格,用户将无法使用该模型。",
-  "多用户支持": "多用户支持",
-  "适用于个人使用的场景。": "适用于个人使用的场景。",
-  "不需要设置模型价格,系统将弱化用量计算,您可专注于使用模型。": "不需要设置模型价格,系统将弱化用量计算,您可专注于使用模型。",
-  "个人使用": "个人使用",
-  "适用于展示系统功能的场景。": "适用于展示系统功能的场景。",
-  "提供基础功能演示,方便用户了解系统特性。": "提供基础功能演示,方便用户了解系统特性。",
-  "体验试用": "体验试用",
-  "自动选择": "自动选择",
-  "过期时间格式错误!": "过期时间格式错误!",
-  "令牌更新成功!": "令牌更新成功!",
-  "令牌创建成功,请在列表页面点击复制获取令牌!": "令牌创建成功,请在列表页面点击复制获取令牌!",
-  "更新令牌信息": "更新令牌信息",
-  "创建新的令牌": "创建新的令牌",
-  "设置令牌的基本信息": "设置令牌的基本信息",
-  "请选择过期时间": "请选择过期时间",
-  "一天": "一天",
-  "一个月": "一个月",
-  "设置令牌可用额度和数量": "设置令牌可用额度和数量",
-  "新建数量": "新建数量",
-  "请选择或输入创建令牌的数量": "请选择或输入创建令牌的数量",
-  "20个": "20个",
-  "100个": "100个",
-  "取消无限额度": "取消无限额度",
-  "设为无限额度": "设为无限额度",
-  "设置令牌的访问限制": "设置令牌的访问限制",
-  "IP白名单": "IP白名单",
-  "允许的IP,一行一个,不填写则不限制": "允许的IP,一行一个,不填写则不限制",
-  "请勿过度信任此功能,IP可能被伪造": "请勿过度信任此功能,IP可能被伪造",
-  "勾选启用模型限制后可选择": "勾选启用模型限制后可选择",
-  "非必要,不建议启用模型限制": "非必要,不建议启用模型限制",
-  "分组信息": "分组信息",
-  "设置令牌的分组": "设置令牌的分组",
-  "令牌分组,默认为用户的分组": "令牌分组,默认为用户的分组",
-  "管理员未设置用户可选分组": "管理员未设置用户可选分组",
-  "请输入兑换码!": "请输入兑换码!",
-  "兑换成功!": "兑换成功!",
-  "成功兑换额度:": "成功兑换额度:",
-  "请求失败": "请求失败",
-  "超级管理员未设置充值链接!": "超级管理员未设置充值链接!",
-  "管理员未开启在线充值!": "管理员未开启在线充值!",
-  "充值数量不能小于": "充值数量不能小于",
-  "支付请求失败": "支付请求失败",
-  "划转金额最低为": "划转金额最低为",
-  "邀请链接已复制到剪切板": "邀请链接已复制到剪切板",
-  "支付方式配置错误, 请联系管理员": "支付方式配置错误, 请联系管理员",
-  "划转邀请额度": "划转邀请额度",
-  "可用邀请额度": "可用邀请额度",
-  "划转额度": "划转额度",
-  "充值确认": "充值确认",
-  "充值数量": "充值数量",
-  "实付金额": "实付金额",
-  "支付方式": "支付方式",
-  "在线充值": "在线充值",
-  "快速方便的充值方式": "快速方便的充值方式",
-  "选择充值额度": "选择充值额度",
-  "实付": "实付",
-  "或输入自定义金额": "或输入自定义金额",
-  "充值数量,最低 ": "充值数量,最低 ",
-  "选择支付方式": "选择支付方式",
-  "处理中": "处理中",
-  "兑换码充值": "兑换码充值",
-  "使用兑换码快速充值": "使用兑换码快速充值",
-  "请输入兑换码": "请输入兑换码",
-  "兑换中...": "兑换中...",
-  "兑换": "兑换",
-  "邀请奖励": "邀请奖励",
-  "邀请好友获得额外奖励": "邀请好友获得额外奖励",
-  "待使用收益": "待使用收益",
-  "总收益": "总收益",
-  "邀请人数": "邀请人数",
-  "邀请链接": "邀请链接",
-  "邀请好友注册,好友充值后您可获得相应奖励": "邀请好友注册,好友充值后您可获得相应奖励",
-  "通过划转功能将奖励额度转入到您的账户余额中": "通过划转功能将奖励额度转入到您的账户余额中",
-  "邀请的好友越多,获得的奖励越多": "邀请的好友越多,获得的奖励越多",
-  "用户名和密码不能为空!": "用户名和密码不能为空!",
-  "用户账户创建成功!": "用户账户创建成功!",
-  "提交": "提交",
-  "创建新用户账户": "创建新用户账户",
-  "请输入显示名称": "请输入显示名称",
-  "请输入密码": "请输入密码",
-  "请输入备注(仅管理员可见)": "请输入备注(仅管理员可见)",
-  "编辑用户": "编辑用户",
-  "用户的基本账户信息": "用户的基本账户信息",
-  "请输入新的用户名": "请输入新的用户名",
-  "请输入新的密码,最短 8 位": "请输入新的密码,最短 8 位",
-  "显示名称": "显示名称",
-  "请输入新的显示名称": "请输入新的显示名称",
-  "权限设置": "权限设置",
-  "用户分组和额度管理": "用户分组和额度管理",
-  "请输入新的剩余额度": "请输入新的剩余额度",
-  "添加额度": "添加额度",
-  "第三方账户绑定状态(只读)": "第三方账户绑定状态(只读)",
-  "已绑定的 GitHub 账户": "已绑定的 GitHub 账户",
-  "已绑定的 OIDC 账户": "已绑定的 OIDC 账户",
-  "已绑定的微信账户": "已绑定的微信账户",
-  "已绑定的邮箱账户": "已绑定的邮箱账户",
-  "已绑定的 Telegram 账户": "已绑定的 Telegram 账户",
-  "新额度": "新额度",
-  "需要添加的额度(支持负数)": "需要添加的额度(支持负数)"
-}

+ 22 - 27
common/logger.go → logger/logger.go

@@ -1,23 +1,26 @@
-package common
+package logger
 
 import (
 	"context"
 	"encoding/json"
 	"fmt"
-	"github.com/bytedance/gopkg/util/gopool"
-	"github.com/gin-gonic/gin"
 	"io"
 	"log"
+	"one-api/common"
 	"os"
 	"path/filepath"
 	"sync"
 	"time"
+
+	"github.com/bytedance/gopkg/util/gopool"
+	"github.com/gin-gonic/gin"
 )
 
 const (
 	loggerINFO  = "INFO"
 	loggerWarn  = "WARN"
 	loggerError = "ERR"
+	loggerDebug = "DEBUG"
 )
 
 const maxLogCount = 1000000
@@ -27,7 +30,10 @@ var setupLogLock sync.Mutex
 var setupLogWorking bool
 
 func SetupLogger() {
-	if *LogDir != "" {
+	defer func() {
+		setupLogWorking = false
+	}()
+	if *common.LogDir != "" {
 		ok := setupLogLock.TryLock()
 		if !ok {
 			log.Println("setup log is already working")
@@ -35,9 +41,8 @@ func SetupLogger() {
 		}
 		defer func() {
 			setupLogLock.Unlock()
-			setupLogWorking = false
 		}()
-		logPath := filepath.Join(*LogDir, fmt.Sprintf("oneapi-%s.log", time.Now().Format("20060102150405")))
+		logPath := filepath.Join(*common.LogDir, fmt.Sprintf("oneapi-%s.log", time.Now().Format("20060102150405")))
 		fd, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
 		if err != nil {
 			log.Fatal("failed to open log file")
@@ -47,16 +52,6 @@ func SetupLogger() {
 	}
 }
 
-func SysLog(s string) {
-	t := time.Now()
-	_, _ = fmt.Fprintf(gin.DefaultWriter, "[SYS] %v | %s \n", t.Format("2006/01/02 - 15:04:05"), s)
-}
-
-func SysError(s string) {
-	t := time.Now()
-	_, _ = fmt.Fprintf(gin.DefaultErrorWriter, "[SYS] %v | %s \n", t.Format("2006/01/02 - 15:04:05"), s)
-}
-
 func LogInfo(ctx context.Context, msg string) {
 	logHelper(ctx, loggerINFO, msg)
 }
@@ -69,12 +64,18 @@ func LogError(ctx context.Context, msg string) {
 	logHelper(ctx, loggerError, msg)
 }
 
+func LogDebug(ctx context.Context, msg string) {
+	if common.DebugEnabled {
+		logHelper(ctx, loggerDebug, msg)
+	}
+}
+
 func logHelper(ctx context.Context, level string, msg string) {
 	writer := gin.DefaultErrorWriter
 	if level == loggerINFO {
 		writer = gin.DefaultWriter
 	}
-	id := ctx.Value(RequestIdKey)
+	id := ctx.Value(common.RequestIdKey)
 	if id == nil {
 		id = "SYSTEM"
 	}
@@ -90,23 +91,17 @@ func logHelper(ctx context.Context, level string, msg string) {
 	}
 }
 
-func FatalLog(v ...any) {
-	t := time.Now()
-	_, _ = fmt.Fprintf(gin.DefaultErrorWriter, "[FATAL] %v | %v \n", t.Format("2006/01/02 - 15:04:05"), v)
-	os.Exit(1)
-}
-
 func LogQuota(quota int) string {
-	if DisplayInCurrencyEnabled {
-		return fmt.Sprintf("$%.6f 额度", float64(quota)/QuotaPerUnit)
+	if common.DisplayInCurrencyEnabled {
+		return fmt.Sprintf("$%.6f 额度", float64(quota)/common.QuotaPerUnit)
 	} else {
 		return fmt.Sprintf("%d 点额度", quota)
 	}
 }
 
 func FormatQuota(quota int) string {
-	if DisplayInCurrencyEnabled {
-		return fmt.Sprintf("$%.6f", float64(quota)/QuotaPerUnit)
+	if common.DisplayInCurrencyEnabled {
+		return fmt.Sprintf("$%.6f", float64(quota)/common.QuotaPerUnit)
 	} else {
 		return fmt.Sprintf("%d", quota)
 	}

+ 14 - 11
main.go

@@ -8,6 +8,7 @@ import (
 	"one-api/common"
 	"one-api/constant"
 	"one-api/controller"
+	"one-api/logger"
 	"one-api/middleware"
 	"one-api/model"
 	"one-api/router"
@@ -15,6 +16,7 @@ import (
 	"one-api/setting/ratio_setting"
 	"os"
 	"strconv"
+	"time"
 
 	"github.com/bytedance/gopkg/util/gopool"
 	"github.com/gin-contrib/sessions"
@@ -32,6 +34,7 @@ var buildFS embed.FS
 var indexPage []byte
 
 func main() {
+	startTime := time.Now()
 
 	err := InitResources()
 	if err != nil {
@@ -60,13 +63,13 @@ func main() {
 	}
 	if common.MemoryCacheEnabled {
 		common.SysLog("memory cache enabled")
-		common.SysError(fmt.Sprintf("sync frequency: %d seconds", common.SyncFrequency))
+		common.SysLog(fmt.Sprintf("sync frequency: %d seconds", common.SyncFrequency))
 
 		// Add panic recovery and retry for InitChannelCache
 		func() {
 			defer func() {
 				if r := recover(); r != nil {
-					common.SysError(fmt.Sprintf("InitChannelCache panic: %v, retrying once", r))
+					common.SysLog(fmt.Sprintf("InitChannelCache panic: %v, retrying once", r))
 					// Retry once
 					_, _, fixErr := model.FixAbility()
 					if fixErr != nil {
@@ -93,13 +96,9 @@ func main() {
 		}
 		go controller.AutomaticallyUpdateChannels(frequency)
 	}
-	if os.Getenv("CHANNEL_TEST_FREQUENCY") != "" {
-		frequency, err := strconv.Atoi(os.Getenv("CHANNEL_TEST_FREQUENCY"))
-		if err != nil {
-			common.FatalLog("failed to parse CHANNEL_TEST_FREQUENCY: " + err.Error())
-		}
-		go controller.AutomaticallyTestChannels(frequency)
-	}
+
+	go controller.AutomaticallyTestChannels()
+
 	if common.IsMasterNode && constant.UpdateTask {
 		gopool.Go(func() {
 			controller.UpdateMidjourneyTaskBulk()
@@ -125,7 +124,7 @@ func main() {
 	// Initialize HTTP server
 	server := gin.New()
 	server.Use(gin.CustomRecovery(func(c *gin.Context, err any) {
-		common.SysError(fmt.Sprintf("panic detected: %v", err))
+		common.SysLog(fmt.Sprintf("panic detected: %v", err))
 		c.JSON(http.StatusInternalServerError, gin.H{
 			"error": gin.H{
 				"message": fmt.Sprintf("Panic detected, error: %v. Please submit a issue here: https://github.com/Calcium-Ion/new-api", err),
@@ -153,6 +152,10 @@ func main() {
 	if port == "" {
 		port = strconv.Itoa(*common.Port)
 	}
+
+	// Log startup success message
+	common.LogStartupSuccess(startTime, port)
+
 	err = server.Run(":" + port)
 	if err != nil {
 		common.FatalLog("failed to start HTTP server: " + err.Error())
@@ -171,7 +174,7 @@ func InitResources() error {
 	// 加载环境变量
 	common.InitEnv()
 
-	common.SetupLogger()
+	logger.SetupLogger()
 
 	// Initialize model settings
 	ratio_setting.InitRatioSettings()

+ 39 - 6
middleware/auth.go

@@ -4,7 +4,10 @@ import (
 	"fmt"
 	"net/http"
 	"one-api/common"
+	"one-api/constant"
 	"one-api/model"
+	"one-api/setting"
+	"one-api/setting/ratio_setting"
 	"strconv"
 	"strings"
 
@@ -122,6 +125,7 @@ func authHelper(c *gin.Context, minRole int) {
 	c.Set("role", role)
 	c.Set("id", id)
 	c.Set("group", session.Get("group"))
+	c.Set("user_group", session.Get("group"))
 	c.Set("use_access_token", useAccessToken)
 
 	//userCache, err := model.GetUserCache(id.(int))
@@ -190,14 +194,15 @@ func TokenAuth() func(c *gin.Context) {
 		}
 		// 检查path包含/v1/messages
 		if strings.Contains(c.Request.URL.Path, "/v1/messages") {
-			// 从x-api-key中获取key
-			key := c.Request.Header.Get("x-api-key")
-			if key != "" {
-				c.Request.Header.Set("Authorization", "Bearer "+key)
+			anthropicKey := c.Request.Header.Get("x-api-key")
+			if anthropicKey != "" {
+				c.Request.Header.Set("Authorization", "Bearer "+anthropicKey)
 			}
 		}
 		// gemini api 从query中获取key
-		if strings.HasPrefix(c.Request.URL.Path, "/v1beta/models/") || strings.HasPrefix(c.Request.URL.Path, "/v1/models/") {
+		if strings.HasPrefix(c.Request.URL.Path, "/v1beta/models") ||
+			strings.HasPrefix(c.Request.URL.Path, "/v1beta/openai/models") ||
+			strings.HasPrefix(c.Request.URL.Path, "/v1/models/") {
 			skKey := c.Query("key")
 			if skKey != "" {
 				c.Request.Header.Set("Authorization", "Bearer "+skKey)
@@ -233,6 +238,16 @@ func TokenAuth() func(c *gin.Context) {
 			abortWithOpenAiMessage(c, http.StatusUnauthorized, err.Error())
 			return
 		}
+
+		allowIpsMap := token.GetIpLimitsMap()
+		if len(allowIpsMap) != 0 {
+			clientIp := c.ClientIP()
+			if _, ok := allowIpsMap[clientIp]; !ok {
+				abortWithOpenAiMessage(c, http.StatusForbidden, "您的 IP 不在令牌允许访问的列表中")
+				return
+			}
+		}
+
 		userCache, err := model.GetUserCache(token.UserId)
 		if err != nil {
 			abortWithOpenAiMessage(c, http.StatusInternalServerError, err.Error())
@@ -246,6 +261,25 @@ func TokenAuth() func(c *gin.Context) {
 
 		userCache.WriteContext(c)
 
+		userGroup := userCache.Group
+		tokenGroup := token.Group
+		if tokenGroup != "" {
+			// check common.UserUsableGroups[userGroup]
+			if _, ok := setting.GetUserUsableGroups(userGroup)[tokenGroup]; !ok {
+				abortWithOpenAiMessage(c, http.StatusForbidden, fmt.Sprintf("令牌分组 %s 已被禁用", tokenGroup))
+				return
+			}
+			// check group in common.GroupRatio
+			if !ratio_setting.ContainsGroupRatio(tokenGroup) {
+				if tokenGroup != "auto" {
+					abortWithOpenAiMessage(c, http.StatusForbidden, fmt.Sprintf("分组 %s 已被弃用", tokenGroup))
+					return
+				}
+			}
+			userGroup = tokenGroup
+		}
+		common.SetContextKey(c, constant.ContextKeyUsingGroup, userGroup)
+
 		err = SetupContextForToken(c, token, parts...)
 		if err != nil {
 			return
@@ -272,7 +306,6 @@ func SetupContextForToken(c *gin.Context, token *model.Token, parts ...string) e
 	} else {
 		c.Set("token_model_limit_enabled", false)
 	}
-	c.Set("allow_ips", token.GetIpLimitsMap())
 	c.Set("token_group", token.Group)
 	if len(parts) > 1 {
 		if model.IsAdmin(token.UserId) {

+ 12 - 0
middleware/disable-cache.go

@@ -0,0 +1,12 @@
+package middleware
+
+import "github.com/gin-gonic/gin"
+
+func DisableCache() gin.HandlerFunc {
+	return func(c *gin.Context) {
+		c.Header("Cache-Control", "no-store, no-cache, must-revalidate, private, max-age=0")
+		c.Header("Pragma", "no-cache")
+		c.Header("Expires", "0")
+		c.Next()
+	}
+}

+ 61 - 65
middleware/distributor.go

@@ -27,14 +27,6 @@ type ModelRequest struct {
 
 func Distribute() func(c *gin.Context) {
 	return func(c *gin.Context) {
-		allowIpsMap := common.GetContextKeyStringMap(c, constant.ContextKeyTokenAllowIps)
-		if len(allowIpsMap) != 0 {
-			clientIp := c.ClientIP()
-			if _, ok := allowIpsMap[clientIp]; !ok {
-				abortWithOpenAiMessage(c, http.StatusForbidden, "您的 IP 不在令牌允许访问的列表中")
-				return
-			}
-		}
 		var channel *model.Channel
 		channelId, ok := common.GetContextKey(c, constant.ContextKeyTokenSpecificChannelId)
 		modelRequest, shouldSelectChannel, err := getModelRequest(c)
@@ -42,24 +34,6 @@ func Distribute() func(c *gin.Context) {
 			abortWithOpenAiMessage(c, http.StatusBadRequest, "Invalid request, "+err.Error())
 			return
 		}
-		userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup)
-		tokenGroup := common.GetContextKeyString(c, constant.ContextKeyTokenGroup)
-		if tokenGroup != "" {
-			// check common.UserUsableGroups[userGroup]
-			if _, ok := setting.GetUserUsableGroups(userGroup)[tokenGroup]; !ok {
-				abortWithOpenAiMessage(c, http.StatusForbidden, fmt.Sprintf("令牌分组 %s 已被禁用", tokenGroup))
-				return
-			}
-			// check group in common.GroupRatio
-			if !ratio_setting.ContainsGroupRatio(tokenGroup) {
-				if tokenGroup != "auto" {
-					abortWithOpenAiMessage(c, http.StatusForbidden, fmt.Sprintf("分组 %s 已被弃用", tokenGroup))
-					return
-				}
-			}
-			userGroup = tokenGroup
-		}
-		common.SetContextKey(c, constant.ContextKeyUsingGroup, userGroup)
 		if ok {
 			id, err := strconv.Atoi(channelId.(string))
 			if err != nil {
@@ -81,44 +55,63 @@ func Distribute() func(c *gin.Context) {
 			modelLimitEnable := common.GetContextKeyBool(c, constant.ContextKeyTokenModelLimitEnabled)
 			if modelLimitEnable {
 				s, ok := common.GetContextKey(c, constant.ContextKeyTokenModelLimit)
+				if !ok {
+					// token model limit is empty, all models are not allowed
+					abortWithOpenAiMessage(c, http.StatusForbidden, "该令牌无权访问任何模型")
+					return
+				}
 				var tokenModelLimit map[string]bool
-				if ok {
-					tokenModelLimit = s.(map[string]bool)
-				} else {
+				tokenModelLimit, ok = s.(map[string]bool)
+				if !ok {
 					tokenModelLimit = map[string]bool{}
 				}
-				if tokenModelLimit != nil {
-					if _, ok := tokenModelLimit[modelRequest.Model]; !ok {
-						abortWithOpenAiMessage(c, http.StatusForbidden, "该令牌无权访问模型 "+modelRequest.Model)
-						return
-					}
-				} else {
-					// token model limit is empty, all models are not allowed
-					abortWithOpenAiMessage(c, http.StatusForbidden, "该令牌无权访问任何模型")
+				matchName := ratio_setting.FormatMatchingModelName(modelRequest.Model) // match gpts & thinking-*
+				if _, ok := tokenModelLimit[matchName]; !ok {
+					abortWithOpenAiMessage(c, http.StatusForbidden, "该令牌无权访问模型 "+modelRequest.Model)
 					return
 				}
 			}
 
 			if shouldSelectChannel {
+				if modelRequest.Model == "" {
+					abortWithOpenAiMessage(c, http.StatusBadRequest, "未指定模型名称,模型名称不能为空")
+					return
+				}
 				var selectGroup string
+				userGroup := common.GetContextKeyString(c, constant.ContextKeyUsingGroup)
+				// check path is /pg/chat/completions
+				if strings.HasPrefix(c.Request.URL.Path, "/pg/chat/completions") {
+					playgroundRequest := &dto.PlayGroundRequest{}
+					err = common.UnmarshalBodyReusable(c, playgroundRequest)
+					if err != nil {
+						abortWithOpenAiMessage(c, http.StatusBadRequest, "无效的请求, "+err.Error())
+						return
+					}
+					if playgroundRequest.Group != "" {
+						if !setting.GroupInUserUsableGroups(playgroundRequest.Group) && playgroundRequest.Group != userGroup {
+							abortWithOpenAiMessage(c, http.StatusForbidden, "无权访问该分组")
+							return
+						}
+						userGroup = playgroundRequest.Group
+					}
+				}
 				channel, selectGroup, err = model.CacheGetRandomSatisfiedChannel(c, userGroup, modelRequest.Model, 0)
 				if err != nil {
 					showGroup := userGroup
 					if userGroup == "auto" {
 						showGroup = fmt.Sprintf("auto(%s)", selectGroup)
 					}
-					message := fmt.Sprintf("当前分组 %s 下对于模型 %s 无可用渠道", showGroup, modelRequest.Model)
+					message := fmt.Sprintf("获取分组 %s 下模型 %s 的可用渠道失败(数据库一致性已被破坏,distributor): %s", showGroup, modelRequest.Model, err.Error())
 					// 如果错误,但是渠道不为空,说明是数据库一致性问题
-					if channel != nil {
-						common.SysError(fmt.Sprintf("渠道不存在:%d", channel.Id))
-						message = "数据库一致性已被破坏,请联系管理员"
-					}
-					// 如果错误,而且渠道为空,说明是没有可用渠道
-					abortWithOpenAiMessage(c, http.StatusServiceUnavailable, message)
+					//if channel != nil {
+					//	common.SysError(fmt.Sprintf("渠道不存在:%d", channel.Id))
+					//	message = "数据库一致性已被破坏,请联系管理员"
+					//}
+					abortWithOpenAiMessage(c, http.StatusServiceUnavailable, message, string(types.ErrorCodeModelNotFound))
 					return
 				}
 				if channel == nil {
-					abortWithOpenAiMessage(c, http.StatusServiceUnavailable, fmt.Sprintf("当前分组 %s 下对于模型 %s 无可用渠道(数据库一致性已被破坏)", userGroup, modelRequest.Model))
+					abortWithOpenAiMessage(c, http.StatusServiceUnavailable, fmt.Sprintf("分组 %s 下模型 %s 无可用渠道(distributor)", userGroup, modelRequest.Model), string(types.ErrorCodeModelNotFound))
 					return
 				}
 			}
@@ -173,24 +166,17 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
 		c.Set("platform", string(constant.TaskPlatformSuno))
 		c.Set("relay_mode", relayMode)
 	} else if strings.Contains(c.Request.URL.Path, "/v1/video/generations") {
-		err = common.UnmarshalBodyReusable(c, &modelRequest)
-		var platform string
-		var relayMode int
-		if strings.HasPrefix(modelRequest.Model, "jimeng") {
-			platform = string(constant.TaskPlatformJimeng)
-			relayMode = relayconstant.Path2RelayJimeng(c.Request.Method, c.Request.URL.Path)
-			if relayMode == relayconstant.RelayModeJimengFetchByID {
-				shouldSelectChannel = false
-			}
-		} else {
-			platform = string(constant.TaskPlatformKling)
-			relayMode = relayconstant.Path2RelayKling(c.Request.Method, c.Request.URL.Path)
-			if relayMode == relayconstant.RelayModeKlingFetchByID {
-				shouldSelectChannel = false
-			}
+		relayMode := relayconstant.RelayModeUnknown
+		if c.Request.Method == http.MethodPost {
+			err = common.UnmarshalBodyReusable(c, &modelRequest)
+			relayMode = relayconstant.RelayModeVideoSubmit
+		} else if c.Request.Method == http.MethodGet {
+			relayMode = relayconstant.RelayModeVideoFetchByID
+			shouldSelectChannel = false
+		}
+		if _, ok := c.Get("relay_mode"); !ok {
+			c.Set("relay_mode", relayMode)
 		}
-		c.Set("platform", platform)
-		c.Set("relay_mode", relayMode)
 	} else if strings.HasPrefix(c.Request.URL.Path, "/v1beta/models/") || strings.HasPrefix(c.Request.URL.Path, "/v1/models/") {
 		// Gemini API 路径处理: /v1beta/models/gemini-2.0-flash:generateContent
 		relayMode := relayconstant.RelayModeGemini
@@ -199,7 +185,7 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
 			modelRequest.Model = modelName
 		}
 		c.Set("relay_mode", relayMode)
-	} else if !strings.HasPrefix(c.Request.URL.Path, "/v1/audio/transcriptions") && !strings.HasPrefix(c.Request.URL.Path, "/v1/images/edits") {
+	} else if !strings.HasPrefix(c.Request.URL.Path, "/v1/audio/transcriptions") && !strings.Contains(c.Request.Header.Get("Content-Type"), "multipart/form-data") {
 		err = common.UnmarshalBodyReusable(c, &modelRequest)
 	}
 	if err != nil {
@@ -222,7 +208,10 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
 	if strings.HasPrefix(c.Request.URL.Path, "/v1/images/generations") {
 		modelRequest.Model = common.GetStringIfEmpty(modelRequest.Model, "dall-e")
 	} else if strings.HasPrefix(c.Request.URL.Path, "/v1/images/edits") {
-		modelRequest.Model = common.GetStringIfEmpty(c.PostForm("model"), "gpt-image-1")
+		//modelRequest.Model = common.GetStringIfEmpty(c.PostForm("model"), "gpt-image-1")
+		if strings.Contains(c.Request.Header.Get("Content-Type"), "multipart/form-data") {
+			modelRequest.Model = c.PostForm("model")
+		}
 	}
 	if strings.HasPrefix(c.Request.URL.Path, "/v1/audio") {
 		relayMode := relayconstant.RelayModeAudioSpeech
@@ -253,14 +242,16 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
 func SetupContextForSelectedChannel(c *gin.Context, channel *model.Channel, modelName string) *types.NewAPIError {
 	c.Set("original_model", modelName) // for retry
 	if channel == nil {
-		return types.NewError(errors.New("channel is nil"), types.ErrorCodeGetChannelFailed)
+		return types.NewError(errors.New("channel is nil"), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry())
 	}
 	common.SetContextKey(c, constant.ContextKeyChannelId, channel.Id)
 	common.SetContextKey(c, constant.ContextKeyChannelName, channel.Name)
 	common.SetContextKey(c, constant.ContextKeyChannelType, channel.Type)
 	common.SetContextKey(c, constant.ContextKeyChannelCreateTime, channel.CreatedTime)
 	common.SetContextKey(c, constant.ContextKeyChannelSetting, channel.GetSetting())
+	common.SetContextKey(c, constant.ContextKeyChannelOtherSetting, channel.GetOtherSettings())
 	common.SetContextKey(c, constant.ContextKeyChannelParamOverride, channel.GetParamOverride())
+	common.SetContextKey(c, constant.ContextKeyChannelHeaderOverride, channel.GetHeaderOverride())
 	if nil != channel.OpenAIOrganization && *channel.OpenAIOrganization != "" {
 		common.SetContextKey(c, constant.ContextKeyChannelOrganization, *channel.OpenAIOrganization)
 	}
@@ -275,11 +266,16 @@ func SetupContextForSelectedChannel(c *gin.Context, channel *model.Channel, mode
 	if channel.ChannelInfo.IsMultiKey {
 		common.SetContextKey(c, constant.ContextKeyChannelIsMultiKey, true)
 		common.SetContextKey(c, constant.ContextKeyChannelMultiKeyIndex, index)
+	} else {
+		// 必须设置为 false,否则在重试到单个 key 的时候会导致日志显示错误
+		common.SetContextKey(c, constant.ContextKeyChannelIsMultiKey, false)
 	}
 	// c.Request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", key))
 	common.SetContextKey(c, constant.ContextKeyChannelKey, key)
 	common.SetContextKey(c, constant.ContextKeyChannelBaseUrl, channel.GetBaseURL())
 
+	common.SetContextKey(c, constant.ContextKeySystemPromptOverride, false)
+
 	// TODO: api_version统一
 	switch channel.Type {
 	case constant.ChannelTypeAzure:

+ 80 - 0
middleware/email-verification-rate-limit.go

@@ -0,0 +1,80 @@
+package middleware
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+	"one-api/common"
+	"time"
+
+	"github.com/gin-gonic/gin"
+)
+
+const (
+	EmailVerificationRateLimitMark = "EV"
+	EmailVerificationMaxRequests   = 2  // 30秒内最多2次
+	EmailVerificationDuration      = 30 // 30秒时间窗口
+)
+
+func redisEmailVerificationRateLimiter(c *gin.Context) {
+	ctx := context.Background()
+	rdb := common.RDB
+	key := "emailVerification:" + EmailVerificationRateLimitMark + ":" + c.ClientIP()
+
+	count, err := rdb.Incr(ctx, key).Result()
+	if err != nil {
+		// fallback
+		memoryEmailVerificationRateLimiter(c)
+		return
+	}
+
+	// 第一次设置键时设置过期时间
+	if count == 1 {
+		_ = rdb.Expire(ctx, key, time.Duration(EmailVerificationDuration)*time.Second).Err()
+	}
+
+	// 检查是否超出限制
+	if count <= int64(EmailVerificationMaxRequests) {
+		c.Next()
+		return
+	}
+
+	// 获取剩余等待时间
+	ttl, err := rdb.TTL(ctx, key).Result()
+	waitSeconds := int64(EmailVerificationDuration)
+	if err == nil && ttl > 0 {
+		waitSeconds = int64(ttl.Seconds())
+	}
+
+	c.JSON(http.StatusTooManyRequests, gin.H{
+		"success": false,
+		"message": fmt.Sprintf("发送过于频繁,请等待 %d 秒后再试", waitSeconds),
+	})
+	c.Abort()
+}
+
+func memoryEmailVerificationRateLimiter(c *gin.Context) {
+	key := EmailVerificationRateLimitMark + ":" + c.ClientIP()
+
+	if !inMemoryRateLimiter.Request(key, EmailVerificationMaxRequests, EmailVerificationDuration) {
+		c.JSON(http.StatusTooManyRequests, gin.H{
+			"success": false,
+			"message": "发送过于频繁,请稍后再试",
+		})
+		c.Abort()
+		return
+	}
+
+	c.Next()
+}
+
+func EmailVerificationRateLimit() gin.HandlerFunc {
+	return func(c *gin.Context) {
+		if common.RedisEnabled {
+			redisEmailVerificationRateLimiter(c)
+		} else {
+			inMemoryRateLimiter.Init(common.RateLimitKeyExpirationDuration)
+			memoryEmailVerificationRateLimiter(c)
+		}
+	}
+}

+ 66 - 0
middleware/jimeng_adapter.go

@@ -0,0 +1,66 @@
+package middleware
+
+import (
+	"bytes"
+	"encoding/json"
+	"github.com/gin-gonic/gin"
+	"io"
+	"net/http"
+	"one-api/common"
+	"one-api/constant"
+	relayconstant "one-api/relay/constant"
+)
+
+func JimengRequestConvert() func(c *gin.Context) {
+	return func(c *gin.Context) {
+		action := c.Query("Action")
+		if action == "" {
+			abortWithOpenAiMessage(c, http.StatusBadRequest, "Action query parameter is required")
+			return
+		}
+
+		// Handle Jimeng official API request
+		var originalReq map[string]interface{}
+		if err := common.UnmarshalBodyReusable(c, &originalReq); err != nil {
+			abortWithOpenAiMessage(c, http.StatusBadRequest, "Invalid request body")
+			return
+		}
+		model, _ := originalReq["req_key"].(string)
+		prompt, _ := originalReq["prompt"].(string)
+
+		unifiedReq := map[string]interface{}{
+			"model":    model,
+			"prompt":   prompt,
+			"metadata": originalReq,
+		}
+
+		jsonData, err := json.Marshal(unifiedReq)
+		if err != nil {
+			abortWithOpenAiMessage(c, http.StatusInternalServerError, "Failed to marshal request body")
+			return
+		}
+
+		// Update request body
+		c.Request.Body = io.NopCloser(bytes.NewBuffer(jsonData))
+		c.Set(common.KeyRequestBody, jsonData)
+
+		if image, ok := originalReq["image"]; !ok || image == "" {
+			c.Set("action", constant.TaskActionTextGenerate)
+		}
+
+		c.Request.URL.Path = "/v1/video/generations"
+
+		if action == "CVSync2AsyncGetResult" {
+			taskId, ok := originalReq["task_id"].(string)
+			if !ok || taskId == "" {
+				abortWithOpenAiMessage(c, http.StatusBadRequest, "task_id is required for CVSync2AsyncGetResult")
+				return
+			}
+			c.Request.URL.Path = "/v1/video/generations/" + taskId
+			c.Request.Method = http.MethodGet
+			c.Set("task_id", taskId)
+			c.Set("relay_mode", relayconstant.RelayModeVideoFetchByID)
+		}
+		c.Next()
+	}
+}

+ 4 - 0
middleware/kling_adapter.go

@@ -18,7 +18,11 @@ func KlingRequestConvert() func(c *gin.Context) {
 			return
 		}
 
+		// Support both model_name and model fields
 		model, _ := originalReq["model_name"].(string)
+		if model == "" {
+			model, _ = originalReq["model"].(string)
+		}
 		prompt, _ := originalReq["prompt"].(string)
 
 		unifiedReq := map[string]interface{}{

+ 2 - 2
middleware/recover.go

@@ -12,8 +12,8 @@ func RelayPanicRecover() gin.HandlerFunc {
 	return func(c *gin.Context) {
 		defer func() {
 			if err := recover(); err != nil {
-				common.SysError(fmt.Sprintf("panic detected: %v", err))
-				common.SysError(fmt.Sprintf("stacktrace from panic: %s", string(debug.Stack())))
+				common.SysLog(fmt.Sprintf("panic detected: %v", err))
+				common.SysLog(fmt.Sprintf("stacktrace from panic: %s", string(debug.Stack())))
 				c.JSON(http.StatusInternalServerError, gin.H{
 					"error": gin.H{
 						"message": fmt.Sprintf("Panic detected, error: %v. Please submit a issue here: https://github.com/Calcium-Ion/new-api", err),

+ 3 - 3
middleware/stats.go

@@ -18,12 +18,12 @@ func StatsMiddleware() gin.HandlerFunc {
 	return func(c *gin.Context) {
 		// 增加活跃连接数
 		atomic.AddInt64(&globalStats.activeConnections, 1)
-		
+
 		// 确保在请求结束时减少连接数
 		defer func() {
 			atomic.AddInt64(&globalStats.activeConnections, -1)
 		}()
-		
+
 		c.Next()
 	}
 }
@@ -38,4 +38,4 @@ func GetStats() StatsInfo {
 	return StatsInfo{
 		ActiveConnections: atomic.LoadInt64(&globalStats.activeConnections),
 	}
-} 
+}

+ 2 - 2
middleware/turnstile-check.go

@@ -37,7 +37,7 @@ func TurnstileCheck() gin.HandlerFunc {
 				"remoteip": {c.ClientIP()},
 			})
 			if err != nil {
-				common.SysError(err.Error())
+				common.SysLog(err.Error())
 				c.JSON(http.StatusOK, gin.H{
 					"success": false,
 					"message": err.Error(),
@@ -49,7 +49,7 @@ func TurnstileCheck() gin.HandlerFunc {
 			var res turnstileCheckResponse
 			err = json.NewDecoder(rawRes.Body).Decode(&res)
 			if err != nil {
-				common.SysError(err.Error())
+				common.SysLog(err.Error())
 				c.JSON(http.StatusOK, gin.H{
 					"success": false,
 					"message": err.Error(),

+ 9 - 3
middleware/utils.go

@@ -4,18 +4,24 @@ import (
 	"fmt"
 	"github.com/gin-gonic/gin"
 	"one-api/common"
+	"one-api/logger"
 )
 
-func abortWithOpenAiMessage(c *gin.Context, statusCode int, message string) {
+func abortWithOpenAiMessage(c *gin.Context, statusCode int, message string, code ...string) {
+	codeStr := ""
+	if len(code) > 0 {
+		codeStr = code[0]
+	}
 	userId := c.GetInt("id")
 	c.JSON(statusCode, gin.H{
 		"error": gin.H{
 			"message": common.MessageWithRequestId(message, c.GetString(common.RequestIdKey)),
 			"type":    "new_api_error",
+			"code":    codeStr,
 		},
 	})
 	c.Abort()
-	common.LogError(c.Request.Context(), fmt.Sprintf("user %d | %s", userId, message))
+	logger.LogError(c.Request.Context(), fmt.Sprintf("user %d | %s", userId, message))
 }
 
 func abortWithMidjourneyMessage(c *gin.Context, statusCode int, code int, description string) {
@@ -25,5 +31,5 @@ func abortWithMidjourneyMessage(c *gin.Context, statusCode int, code int, descri
 		"code":        code,
 	})
 	c.Abort()
-	common.LogError(c.Request.Context(), description)
+	logger.LogError(c.Request.Context(), description)
 }

+ 26 - 6
model/ability.go

@@ -136,13 +136,13 @@ func GetRandomSatisfiedChannel(group string, model string, retry int) (*Channel,
 			}
 		}
 	} else {
-		return nil, errors.New("channel not found")
+		return nil, nil
 	}
 	err = DB.First(&channel, "id = ?", channel.Id).Error
 	return &channel, err
 }
 
-func (channel *Channel) AddAbilities() error {
+func (channel *Channel) AddAbilities(tx *gorm.DB) error {
 	models_ := strings.Split(channel.Models, ",")
 	groups_ := strings.Split(channel.Group, ",")
 	abilitySet := make(map[string]struct{})
@@ -169,8 +169,13 @@ func (channel *Channel) AddAbilities() error {
 	if len(abilities) == 0 {
 		return nil
 	}
+	// choose DB or provided tx
+	useDB := DB
+	if tx != nil {
+		useDB = tx
+	}
 	for _, chunk := range lo.Chunk(abilities, 50) {
-		err := DB.Clauses(clause.OnConflict{DoNothing: true}).Create(&chunk).Error
+		err := useDB.Clauses(clause.OnConflict{DoNothing: true}).Create(&chunk).Error
 		if err != nil {
 			return err
 		}
@@ -284,6 +289,21 @@ func FixAbility() (int, int, error) {
 		return 0, 0, errors.New("已经有一个修复任务在运行中,请稍后再试")
 	}
 	defer fixLock.Unlock()
+
+	// truncate abilities table
+	if common.UsingSQLite {
+		err := DB.Exec("DELETE FROM abilities").Error
+		if err != nil {
+			common.SysLog(fmt.Sprintf("Delete abilities failed: %s", err.Error()))
+			return 0, 0, err
+		}
+	} else {
+		err := DB.Exec("TRUNCATE TABLE abilities").Error
+		if err != nil {
+			common.SysLog(fmt.Sprintf("Truncate abilities failed: %s", err.Error()))
+			return 0, 0, err
+		}
+	}
 	var channels []*Channel
 	// Find all channels
 	err := DB.Model(&Channel{}).Find(&channels).Error
@@ -300,15 +320,15 @@ func FixAbility() (int, int, error) {
 		// Delete all abilities of this channel
 		err = DB.Where("channel_id IN ?", ids).Delete(&Ability{}).Error
 		if err != nil {
-			common.SysError(fmt.Sprintf("Delete abilities failed: %s", err.Error()))
+			common.SysLog(fmt.Sprintf("Delete abilities failed: %s", err.Error()))
 			failCount += len(chunk)
 			continue
 		}
 		// Then add new abilities
 		for _, channel := range chunk {
-			err = channel.AddAbilities()
+			err = channel.AddAbilities(nil)
 			if err != nil {
-				common.SysError(fmt.Sprintf("Add abilities for channel %d failed: %s", channel.Id, err.Error()))
+				common.SysLog(fmt.Sprintf("Add abilities for channel %d failed: %s", channel.Id, err.Error()))
 				failCount++
 			} else {
 				successCount++

+ 147 - 64
model/channel.go

@@ -13,6 +13,7 @@ import (
 	"strings"
 	"sync"
 
+	"github.com/samber/lo"
 	"gorm.io/gorm"
 )
 
@@ -44,16 +45,25 @@ type Channel struct {
 	Tag               *string `json:"tag" gorm:"index"`
 	Setting           *string `json:"setting" gorm:"type:text"` // 渠道额外设置
 	ParamOverride     *string `json:"param_override" gorm:"type:text"`
+	HeaderOverride    *string `json:"header_override" gorm:"type:text"`
+	Remark            string  `json:"remark,omitempty" gorm:"type:varchar(255)" validate:"max=255"`
 	// add after v0.8.5
 	ChannelInfo ChannelInfo `json:"channel_info" gorm:"type:json"`
+
+	OtherSettings string `json:"settings" gorm:"column:settings"` // 其他设置,存储azure版本等不需要检索的信息,详见dto.ChannelOtherSettings
+
+	// cache info
+	Keys []string `json:"-" gorm:"-"`
 }
 
 type ChannelInfo struct {
-	IsMultiKey           bool                  `json:"is_multi_key"`            // 是否多Key模式
-	MultiKeySize         int                   `json:"multi_key_size"`          // 多Key模式下的Key数量
-	MultiKeyStatusList   map[int]int           `json:"multi_key_status_list"`   // key状态列表,key index -> status
-	MultiKeyPollingIndex int                   `json:"multi_key_polling_index"` // 多Key模式下轮询的key索引
-	MultiKeyMode         constant.MultiKeyMode `json:"multi_key_mode"`
+	IsMultiKey             bool                  `json:"is_multi_key"`                        // 是否多Key模式
+	MultiKeySize           int                   `json:"multi_key_size"`                      // 多Key模式下的Key数量
+	MultiKeyStatusList     map[int]int           `json:"multi_key_status_list"`               // key状态列表,key index -> status
+	MultiKeyDisabledReason map[int]string        `json:"multi_key_disabled_reason,omitempty"` // key禁用原因列表,key index -> reason
+	MultiKeyDisabledTime   map[int]int64         `json:"multi_key_disabled_time,omitempty"`   // key禁用时间列表,key index -> time
+	MultiKeyPollingIndex   int                   `json:"multi_key_polling_index"`             // 多Key模式下轮询的key索引
+	MultiKeyMode           constant.MultiKeyMode `json:"multi_key_mode"`
 }
 
 // Value implements driver.Valuer interface
@@ -67,15 +77,18 @@ func (c *ChannelInfo) Scan(value interface{}) error {
 	return common.Unmarshal(bytesValue, c)
 }
 
-func (channel *Channel) getKeys() []string {
+func (channel *Channel) GetKeys() []string {
 	if channel.Key == "" {
 		return []string{}
 	}
+	if len(channel.Keys) > 0 {
+		return channel.Keys
+	}
 	trimmed := strings.TrimSpace(channel.Key)
 	// If the key starts with '[', try to parse it as a JSON array (e.g., for Vertex AI scenarios)
 	if strings.HasPrefix(trimmed, "[") {
 		var arr []json.RawMessage
-		if err := json.Unmarshal([]byte(trimmed), &arr); err == nil {
+		if err := common.Unmarshal([]byte(trimmed), &arr); err == nil {
 			res := make([]string, len(arr))
 			for i, v := range arr {
 				res[i] = string(v)
@@ -95,12 +108,16 @@ func (channel *Channel) GetNextEnabledKey() (string, int, *types.NewAPIError) {
 	}
 
 	// Obtain all keys (split by \n)
-	keys := channel.getKeys()
+	keys := channel.GetKeys()
 	if len(keys) == 0 {
 		// No keys available, return error, should disable the channel
 		return "", 0, types.NewError(errors.New("no keys available"), types.ErrorCodeChannelNoAvailableKey)
 	}
 
+	lock := GetChannelPollingLock(channel.Id)
+	lock.Lock()
+	defer lock.Unlock()
+
 	statusList := channel.ChannelInfo.MultiKeyStatusList
 	// helper to get key status, default to enabled when missing
 	getStatus := func(idx int) int {
@@ -132,13 +149,10 @@ func (channel *Channel) GetNextEnabledKey() (string, int, *types.NewAPIError) {
 		return keys[selectedIdx], selectedIdx, nil
 	case constant.MultiKeyModePolling:
 		// Use channel-specific lock to ensure thread-safe polling
-		lock := getChannelPollingLock(channel.Id)
-		lock.Lock()
-		defer lock.Unlock()
 
 		channelInfo, err := CacheGetChannelInfo(channel.Id)
 		if err != nil {
-			return "", 0, types.NewError(err, types.ErrorCodeGetChannelFailed)
+			return "", 0, types.NewError(err, types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry())
 		}
 		//println("before polling index:", channel.ChannelInfo.MultiKeyPollingIndex)
 		defer func() {
@@ -197,9 +211,9 @@ func (channel *Channel) GetGroups() []string {
 func (channel *Channel) GetOtherInfo() map[string]interface{} {
 	otherInfo := make(map[string]interface{})
 	if channel.OtherInfo != "" {
-		err := json.Unmarshal([]byte(channel.OtherInfo), &otherInfo)
+		err := common.Unmarshal([]byte(channel.OtherInfo), &otherInfo)
 		if err != nil {
-			common.SysError("failed to unmarshal other info: " + err.Error())
+			common.SysLog(fmt.Sprintf("failed to unmarshal other info: channel_id=%d, tag=%s, name=%s, error=%v", channel.Id, channel.GetTag(), channel.Name, err))
 		}
 	}
 	return otherInfo
@@ -208,7 +222,7 @@ func (channel *Channel) GetOtherInfo() map[string]interface{} {
 func (channel *Channel) SetOtherInfo(otherInfo map[string]interface{}) {
 	otherInfoBytes, err := json.Marshal(otherInfo)
 	if err != nil {
-		common.SysError("failed to marshal other info: " + err.Error())
+		common.SysLog(fmt.Sprintf("failed to marshal other info: channel_id=%d, tag=%s, name=%s, error=%v", channel.Id, channel.GetTag(), channel.Name, err))
 		return
 	}
 	channel.OtherInfo = string(otherInfoBytes)
@@ -236,6 +250,10 @@ func (channel *Channel) Save() error {
 	return DB.Save(channel).Error
 }
 
+func (channel *Channel) SaveWithoutKey() error {
+	return DB.Omit("key").Save(channel).Error
+}
+
 func GetAllChannels(startIdx int, num int, selectAll bool, idSort bool) ([]*Channel, error) {
 	var channels []*Channel
 	var err error
@@ -328,38 +346,54 @@ func GetChannelById(id int, selectAll bool) (*Channel, error) {
 }
 
 func BatchInsertChannels(channels []Channel) error {
-	var err error
-	err = DB.Create(&channels).Error
-	if err != nil {
-		return err
+	if len(channels) == 0 {
+		return nil
 	}
-	for _, channel_ := range channels {
-		err = channel_.AddAbilities()
-		if err != nil {
+	tx := DB.Begin()
+	if tx.Error != nil {
+		return tx.Error
+	}
+	defer func() {
+		if r := recover(); r != nil {
+			tx.Rollback()
+		}
+	}()
+
+	for _, chunk := range lo.Chunk(channels, 50) {
+		if err := tx.Create(&chunk).Error; err != nil {
+			tx.Rollback()
 			return err
 		}
+		for _, channel_ := range chunk {
+			if err := channel_.AddAbilities(tx); err != nil {
+				tx.Rollback()
+				return err
+			}
+		}
 	}
-	return nil
+	return tx.Commit().Error
 }
 
 func BatchDeleteChannels(ids []int) error {
-	//使用事务 删除channel表和channel_ability表
+	if len(ids) == 0 {
+		return nil
+	}
+	// 使用事务 分批删除channel表和abilities表
 	tx := DB.Begin()
-	err := tx.Where("id in (?)", ids).Delete(&Channel{}).Error
-	if err != nil {
-		// 回滚事务
-		tx.Rollback()
-		return err
+	if tx.Error != nil {
+		return tx.Error
 	}
-	err = tx.Where("channel_id in (?)", ids).Delete(&Ability{}).Error
-	if err != nil {
-		// 回滚事务
-		tx.Rollback()
-		return err
+	for _, chunk := range lo.Chunk(ids, 200) {
+		if err := tx.Where("id in (?)", chunk).Delete(&Channel{}).Error; err != nil {
+			tx.Rollback()
+			return err
+		}
+		if err := tx.Where("channel_id in (?)", chunk).Delete(&Ability{}).Error; err != nil {
+			tx.Rollback()
+			return err
+		}
 	}
-	// 提交事务
-	tx.Commit()
-	return err
+	return tx.Commit().Error
 }
 
 func (channel *Channel) GetPriority() int64 {
@@ -380,7 +414,11 @@ func (channel *Channel) GetBaseURL() string {
 	if channel.BaseURL == nil {
 		return ""
 	}
-	return *channel.BaseURL
+	url := *channel.BaseURL
+	if url == "" {
+		url = constant.ChannelBaseURLs[channel.Type]
+	}
+	return url
 }
 
 func (channel *Channel) GetModelMapping() string {
@@ -403,7 +441,7 @@ func (channel *Channel) Insert() error {
 	if err != nil {
 		return err
 	}
-	err = channel.AddAbilities()
+	err = channel.AddAbilities(nil)
 	return err
 }
 
@@ -425,7 +463,7 @@ func (channel *Channel) Update() error {
 			trimmed := strings.TrimSpace(keyStr)
 			if strings.HasPrefix(trimmed, "[") {
 				var arr []json.RawMessage
-				if err := json.Unmarshal([]byte(trimmed), &arr); err == nil {
+				if err := common.Unmarshal([]byte(trimmed), &arr); err == nil {
 					keys = make([]string, len(arr))
 					for i, v := range arr {
 						keys[i] = string(v)
@@ -462,7 +500,7 @@ func (channel *Channel) UpdateResponseTime(responseTime int64) {
 		ResponseTime: int(responseTime),
 	}).Error
 	if err != nil {
-		common.SysError("failed to update response time: " + err.Error())
+		common.SysLog(fmt.Sprintf("failed to update response time: channel_id=%d, error=%v", channel.Id, err))
 	}
 }
 
@@ -472,7 +510,7 @@ func (channel *Channel) UpdateBalance(balance float64) {
 		Balance:            balance,
 	}).Error
 	if err != nil {
-		common.SysError("failed to update balance: " + err.Error())
+		common.SysLog(fmt.Sprintf("failed to update balance: channel_id=%d, error=%v", channel.Id, err))
 	}
 }
 
@@ -491,8 +529,8 @@ var channelStatusLock sync.Mutex
 // channelPollingLocks stores locks for each channel.id to ensure thread-safe polling
 var channelPollingLocks sync.Map
 
-// getChannelPollingLock returns or creates a mutex for the given channel ID
-func getChannelPollingLock(channelId int) *sync.Mutex {
+// GetChannelPollingLock returns or creates a mutex for the given channel ID
+func GetChannelPollingLock(channelId int) *sync.Mutex {
 	if lock, exists := channelPollingLocks.Load(channelId); exists {
 		return lock.(*sync.Mutex)
 	}
@@ -522,8 +560,8 @@ func CleanupChannelPollingLocks() {
 	})
 }
 
-func handlerMultiKeyUpdate(channel *Channel, usingKey string, status int) {
-	keys := channel.getKeys()
+func handlerMultiKeyUpdate(channel *Channel, usingKey string, status int, reason string) {
+	keys := channel.GetKeys()
 	if len(keys) == 0 {
 		channel.Status = status
 	} else {
@@ -541,6 +579,14 @@ func handlerMultiKeyUpdate(channel *Channel, usingKey string, status int) {
 			delete(channel.ChannelInfo.MultiKeyStatusList, keyIndex)
 		} else {
 			channel.ChannelInfo.MultiKeyStatusList[keyIndex] = status
+			if channel.ChannelInfo.MultiKeyDisabledReason == nil {
+				channel.ChannelInfo.MultiKeyDisabledReason = make(map[int]string)
+			}
+			if channel.ChannelInfo.MultiKeyDisabledTime == nil {
+				channel.ChannelInfo.MultiKeyDisabledTime = make(map[int]int64)
+			}
+			channel.ChannelInfo.MultiKeyDisabledReason[keyIndex] = reason
+			channel.ChannelInfo.MultiKeyDisabledTime[keyIndex] = common.GetTimestamp()
 		}
 		if len(channel.ChannelInfo.MultiKeyStatusList) >= channel.ChannelInfo.MultiKeySize {
 			channel.Status = common.ChannelStatusAutoDisabled
@@ -562,8 +608,12 @@ func UpdateChannelStatus(channelId int, usingKey string, status int, reason stri
 			return false
 		}
 		if channelCache.ChannelInfo.IsMultiKey {
+			// Use per-channel lock to prevent concurrent map read/write with GetNextEnabledKey
+			pollingLock := GetChannelPollingLock(channelId)
+			pollingLock.Lock()
 			// 如果是多Key模式,更新缓存中的状态
-			handlerMultiKeyUpdate(channelCache, usingKey, status)
+			handlerMultiKeyUpdate(channelCache, usingKey, status, reason)
+			pollingLock.Unlock()
 			//CacheUpdateChannel(channelCache)
 			//return true
 		} else {
@@ -571,10 +621,6 @@ func UpdateChannelStatus(channelId int, usingKey string, status int, reason stri
 			if channelCache.Status == status {
 				return false
 			}
-			// 如果缓存渠道不存在(说明已经被禁用),且要设置的状态不为启用,直接返回
-			if status != common.ChannelStatusEnabled {
-				return false
-			}
 			CacheUpdateChannelStatus(channelId, status)
 		}
 	}
@@ -584,7 +630,7 @@ func UpdateChannelStatus(channelId int, usingKey string, status int, reason stri
 		if shouldUpdateAbilities {
 			err := UpdateAbilityStatus(channelId, status == common.ChannelStatusEnabled)
 			if err != nil {
-				common.SysError("failed to update ability status: " + err.Error())
+				common.SysLog(fmt.Sprintf("failed to update ability status: channel_id=%d, error=%v", channelId, err))
 			}
 		}
 	}()
@@ -598,7 +644,11 @@ func UpdateChannelStatus(channelId int, usingKey string, status int, reason stri
 
 		if channel.ChannelInfo.IsMultiKey {
 			beforeStatus := channel.Status
-			handlerMultiKeyUpdate(channel, usingKey, status)
+			// Protect map writes with the same per-channel lock used by readers
+			pollingLock := GetChannelPollingLock(channelId)
+			pollingLock.Lock()
+			handlerMultiKeyUpdate(channel, usingKey, status, reason)
+			pollingLock.Unlock()
 			if beforeStatus != channel.Status {
 				shouldUpdateAbilities = true
 			}
@@ -610,9 +660,9 @@ func UpdateChannelStatus(channelId int, usingKey string, status int, reason stri
 			channel.Status = status
 			shouldUpdateAbilities = true
 		}
-		err = channel.Save()
+		err = channel.SaveWithoutKey()
 		if err != nil {
-			common.SysError("failed to update channel status: " + err.Error())
+			common.SysLog(fmt.Sprintf("failed to update channel status: channel_id=%d, status=%d, error=%v", channel.Id, status, err))
 			return false
 		}
 	}
@@ -674,7 +724,7 @@ func EditChannelByTag(tag string, newTag *string, modelMapping *string, models *
 			for _, channel := range channels {
 				err = channel.UpdateAbilities(nil)
 				if err != nil {
-					common.SysError("failed to update abilities: " + err.Error())
+					common.SysLog(fmt.Sprintf("failed to update abilities: channel_id=%d, tag=%s, error=%v", channel.Id, channel.GetTag(), err))
 				}
 			}
 		}
@@ -698,7 +748,7 @@ func UpdateChannelUsedQuota(id int, quota int) {
 func updateChannelUsedQuota(id int, quota int) {
 	err := DB.Model(&Channel{}).Where("id = ?", id).Update("used_quota", gorm.Expr("used_quota + ?", quota)).Error
 	if err != nil {
-		common.SysError("failed to update channel used quota: " + err.Error())
+		common.SysLog(fmt.Sprintf("failed to update channel used quota: channel_id=%d, delta_quota=%d, error=%v", id, quota, err))
 	}
 }
 
@@ -778,7 +828,7 @@ func SearchTags(keyword string, group string, model string, idSort bool) ([]*str
 func (channel *Channel) ValidateSettings() error {
 	channelParams := &dto.ChannelSettings{}
 	if channel.Setting != nil && *channel.Setting != "" {
-		err := json.Unmarshal([]byte(*channel.Setting), channelParams)
+		err := common.Unmarshal([]byte(*channel.Setting), channelParams)
 		if err != nil {
 			return err
 		}
@@ -789,9 +839,9 @@ func (channel *Channel) ValidateSettings() error {
 func (channel *Channel) GetSetting() dto.ChannelSettings {
 	setting := dto.ChannelSettings{}
 	if channel.Setting != nil && *channel.Setting != "" {
-		err := json.Unmarshal([]byte(*channel.Setting), &setting)
+		err := common.Unmarshal([]byte(*channel.Setting), &setting)
 		if err != nil {
-			common.SysError("failed to unmarshal setting: " + err.Error())
+			common.SysLog(fmt.Sprintf("failed to unmarshal setting: channel_id=%d, error=%v", channel.Id, err))
 			channel.Setting = nil // 清空设置以避免后续错误
 			_ = channel.Save()    // 保存修改
 		}
@@ -800,25 +850,58 @@ func (channel *Channel) GetSetting() dto.ChannelSettings {
 }
 
 func (channel *Channel) SetSetting(setting dto.ChannelSettings) {
-	settingBytes, err := json.Marshal(setting)
+	settingBytes, err := common.Marshal(setting)
 	if err != nil {
-		common.SysError("failed to marshal setting: " + err.Error())
+		common.SysLog(fmt.Sprintf("failed to marshal setting: channel_id=%d, error=%v", channel.Id, err))
 		return
 	}
 	channel.Setting = common.GetPointer[string](string(settingBytes))
 }
 
+func (channel *Channel) GetOtherSettings() dto.ChannelOtherSettings {
+	setting := dto.ChannelOtherSettings{}
+	if channel.OtherSettings != "" {
+		err := common.UnmarshalJsonStr(channel.OtherSettings, &setting)
+		if err != nil {
+			common.SysLog(fmt.Sprintf("failed to unmarshal setting: channel_id=%d, error=%v", channel.Id, err))
+			channel.OtherSettings = "{}" // 清空设置以避免后续错误
+			_ = channel.Save()           // 保存修改
+		}
+	}
+	return setting
+}
+
+func (channel *Channel) SetOtherSettings(setting dto.ChannelOtherSettings) {
+	settingBytes, err := common.Marshal(setting)
+	if err != nil {
+		common.SysLog(fmt.Sprintf("failed to marshal setting: channel_id=%d, error=%v", channel.Id, err))
+		return
+	}
+	channel.OtherSettings = string(settingBytes)
+}
+
 func (channel *Channel) GetParamOverride() map[string]interface{} {
 	paramOverride := make(map[string]interface{})
 	if channel.ParamOverride != nil && *channel.ParamOverride != "" {
-		err := json.Unmarshal([]byte(*channel.ParamOverride), &paramOverride)
+		err := common.Unmarshal([]byte(*channel.ParamOverride), &paramOverride)
 		if err != nil {
-			common.SysError("failed to unmarshal param override: " + err.Error())
+			common.SysLog(fmt.Sprintf("failed to unmarshal param override: channel_id=%d, error=%v", channel.Id, err))
 		}
 	}
 	return paramOverride
 }
 
+func (channel *Channel) GetHeaderOverride() map[string]interface{} {
+	headerOverride := make(map[string]interface{})
+	if channel.HeaderOverride != nil && *channel.HeaderOverride != "" {
+		err := common.Unmarshal([]byte(*channel.HeaderOverride), &headerOverride)
+		if err != nil {
+			common.SysLog(fmt.Sprintf("failed to unmarshal header override: channel_id=%d, error=%v", channel.Id, err))
+		}
+	}
+	return headerOverride
+}
+
 func GetChannelsByIds(ids []int) ([]*Channel, error) {
 	var channels []*Channel
 	err := DB.Where("id in (?)", ids).Find(&channels).Error

+ 39 - 17
model/channel_cache.go

@@ -5,7 +5,9 @@ import (
 	"fmt"
 	"math/rand"
 	"one-api/common"
+	"one-api/constant"
 	"one-api/setting"
+	"one-api/setting/ratio_setting"
 	"sort"
 	"strings"
 	"sync"
@@ -66,6 +68,20 @@ func InitChannelCache() {
 
 	channelSyncLock.Lock()
 	group2model2channels = newGroup2model2channels
+	//channelsIDM = newChannelId2channel
+	for i, channel := range newChannelId2channel {
+		if channel.ChannelInfo.IsMultiKey {
+			channel.Keys = channel.GetKeys()
+			if channel.ChannelInfo.MultiKeyMode == constant.MultiKeyModePolling {
+				if oldChannel, ok := channelsIDM[i]; ok {
+					// 存在旧的渠道,如果是多key且轮询,保留轮询索引信息
+					if oldChannel.ChannelInfo.IsMultiKey && oldChannel.ChannelInfo.MultiKeyMode == constant.MultiKeyModePolling {
+						channel.ChannelInfo.MultiKeyPollingIndex = oldChannel.ChannelInfo.MultiKeyPollingIndex
+					}
+				}
+			}
+		}
+	}
 	channelsIDM = newChannelId2channel
 	channelSyncLock.Unlock()
 	common.SysLog("channels synced from database")
@@ -109,20 +125,10 @@ func CacheGetRandomSatisfiedChannel(c *gin.Context, group string, model string,
 			return nil, group, err
 		}
 	}
-	if channel == nil {
-		return nil, group, errors.New("channel not found")
-	}
 	return channel, selectGroup, nil
 }
 
 func getRandomSatisfiedChannel(group string, model string, retry int) (*Channel, error) {
-	if strings.HasPrefix(model, "gpt-4-gizmo") {
-		model = "gpt-4-gizmo-*"
-	}
-	if strings.HasPrefix(model, "gpt-4o-gizmo") {
-		model = "gpt-4o-gizmo-*"
-	}
-
 	// if memory cache is disabled, get channel directly from database
 	if !common.MemoryCacheEnabled {
 		return GetRandomSatisfiedChannel(group, model, retry)
@@ -130,10 +136,18 @@ func getRandomSatisfiedChannel(group string, model string, retry int) (*Channel,
 
 	channelSyncLock.RLock()
 	defer channelSyncLock.RUnlock()
+
+	// First, try to find channels with the exact model name.
 	channels := group2model2channels[group][model]
 
+	// If no channels found, try to find channels with the normalized model name.
 	if len(channels) == 0 {
-		return nil, errors.New("channel not found")
+		normalizedModel := ratio_setting.FormatMatchingModelName(model)
+		channels = group2model2channels[group][normalizedModel]
+	}
+
+	if len(channels) == 0 {
+		return nil, nil
 	}
 
 	if len(channels) == 1 {
@@ -206,9 +220,6 @@ func CacheGetChannel(id int) (*Channel, error) {
 	if !ok {
 		return nil, fmt.Errorf("渠道# %d,已不存在", id)
 	}
-	if c.Status != common.ChannelStatusEnabled {
-		return nil, fmt.Errorf("渠道# %d,已被禁用", id)
-	}
 	return c, nil
 }
 
@@ -227,9 +238,6 @@ func CacheGetChannelInfo(id int) (*ChannelInfo, error) {
 	if !ok {
 		return nil, fmt.Errorf("渠道# %d,已不存在", id)
 	}
-	if c.Status != common.ChannelStatusEnabled {
-		return nil, fmt.Errorf("渠道# %d,已被禁用", id)
-	}
 	return &c.ChannelInfo, nil
 }
 
@@ -242,6 +250,20 @@ func CacheUpdateChannelStatus(id int, status int) {
 	if channel, ok := channelsIDM[id]; ok {
 		channel.Status = status
 	}
+	if status != common.ChannelStatusEnabled {
+		// delete the channel from group2model2channels
+		for group, model2channels := range group2model2channels {
+			for model, channels := range model2channels {
+				for i, channelId := range channels {
+					if channelId == id {
+						// remove the channel from the slice
+						group2model2channels[group][model] = append(channels[:i], channels[i+1:]...)
+						break
+					}
+				}
+			}
+		}
+	}
 }
 
 func CacheUpdateChannel(channel *Channel) {

+ 12 - 15
model/log.go

@@ -4,6 +4,8 @@ import (
 	"context"
 	"fmt"
 	"one-api/common"
+	"one-api/logger"
+	"one-api/types"
 	"os"
 	"strings"
 	"time"
@@ -87,13 +89,13 @@ func RecordLog(userId int, logType int, content string) {
 	}
 	err := LOG_DB.Create(log).Error
 	if err != nil {
-		common.SysError("failed to record log: " + err.Error())
+		common.SysLog("failed to record log: " + err.Error())
 	}
 }
 
 func RecordErrorLog(c *gin.Context, userId int, channelId int, modelName string, tokenName string, content string, tokenId int, useTimeSeconds int,
 	isStream bool, group string, other map[string]interface{}) {
-	common.LogInfo(c, fmt.Sprintf("record error log: userId=%d, channelId=%d, modelName=%s, tokenName=%s, content=%s", userId, channelId, modelName, tokenName, content))
+	logger.LogInfo(c, fmt.Sprintf("record error log: userId=%d, channelId=%d, modelName=%s, tokenName=%s, content=%s", userId, channelId, modelName, tokenName, content))
 	username := c.GetString("username")
 	otherStr := common.MapToJsonStr(other)
 	// 判断是否需要记录 IP
@@ -129,7 +131,7 @@ func RecordErrorLog(c *gin.Context, userId int, channelId int, modelName string,
 	}
 	err := LOG_DB.Create(log).Error
 	if err != nil {
-		common.LogError(c, "failed to record log: "+err.Error())
+		logger.LogError(c, "failed to record log: "+err.Error())
 	}
 }
 
@@ -142,7 +144,6 @@ type RecordConsumeLogParams struct {
 	Quota            int                    `json:"quota"`
 	Content          string                 `json:"content"`
 	TokenId          int                    `json:"token_id"`
-	UserQuota        int                    `json:"user_quota"`
 	UseTimeSeconds   int                    `json:"use_time_seconds"`
 	IsStream         bool                   `json:"is_stream"`
 	Group            string                 `json:"group"`
@@ -150,10 +151,10 @@ type RecordConsumeLogParams struct {
 }
 
 func RecordConsumeLog(c *gin.Context, userId int, params RecordConsumeLogParams) {
-	common.LogInfo(c, fmt.Sprintf("record consume log: userId=%d, params=%s", userId, common.GetJsonString(params)))
 	if !common.LogConsumeEnabled {
 		return
 	}
+	logger.LogInfo(c, fmt.Sprintf("record consume log: userId=%d, params=%s", userId, common.GetJsonString(params)))
 	username := c.GetString("username")
 	otherStr := common.MapToJsonStr(params.Other)
 	// 判断是否需要记录 IP
@@ -189,7 +190,7 @@ func RecordConsumeLog(c *gin.Context, userId int, params RecordConsumeLogParams)
 	}
 	err := LOG_DB.Create(log).Error
 	if err != nil {
-		common.LogError(c, "failed to record log: "+err.Error())
+		logger.LogError(c, "failed to record log: "+err.Error())
 	}
 	if common.DataExportEnabled {
 		gopool.Go(func() {
@@ -236,26 +237,22 @@ func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName
 		return nil, 0, err
 	}
 
-	channelIdsMap := make(map[int]struct{})
-	channelMap := make(map[int]string)
+	channelIds := types.NewSet[int]()
 	for _, log := range logs {
 		if log.ChannelId != 0 {
-			channelIdsMap[log.ChannelId] = struct{}{}
+			channelIds.Add(log.ChannelId)
 		}
 	}
 
-	channelIds := make([]int, 0, len(channelIdsMap))
-	for channelId := range channelIdsMap {
-		channelIds = append(channelIds, channelId)
-	}
-	if len(channelIds) > 0 {
+	if channelIds.Len() > 0 {
 		var channels []struct {
 			Id   int    `gorm:"column:id"`
 			Name string `gorm:"column:name"`
 		}
-		if err = DB.Table("channels").Select("id, name").Where("id IN ?", channelIds).Find(&channels).Error; err != nil {
+		if err = DB.Table("channels").Select("id, name").Where("id IN ?", channelIds.Items()).Find(&channels).Error; err != nil {
 			return logs, total, err
 		}
+		channelMap := make(map[int]string, len(channels))
 		for _, channel := range channels {
 			channelMap[channel.Id] = channel.Name
 		}

+ 115 - 3
model/main.go

@@ -180,6 +180,12 @@ func InitDB() (err error) {
 			db = db.Debug()
 		}
 		DB = db
+		// MySQL charset/collation startup check: ensure Chinese-capable charset
+		if common.UsingMySQL {
+			if err := checkMySQLChineseSupport(DB); err != nil {
+				panic(err)
+			}
+		}
 		sqlDB, err := DB.DB()
 		if err != nil {
 			return err
@@ -214,6 +220,12 @@ func InitLogDB() (err error) {
 			db = db.Debug()
 		}
 		LOG_DB = db
+		// If log DB is MySQL, also ensure Chinese-capable charset
+		if common.LogSqlType == common.DatabaseTypeMySQL {
+			if err := checkMySQLChineseSupport(LOG_DB); err != nil {
+				panic(err)
+			}
+		}
 		sqlDB, err := LOG_DB.DB()
 		if err != nil {
 			return err
@@ -235,9 +247,6 @@ func InitLogDB() (err error) {
 }
 
 func migrateDB() error {
-	if !common.UsingPostgreSQL {
-		return migrateDBFast()
-	}
 	err := DB.AutoMigrate(
 		&Channel{},
 		&Token{},
@@ -250,7 +259,12 @@ func migrateDB() error {
 		&TopUp{},
 		&QuotaData{},
 		&Task{},
+		&Model{},
+		&Vendor{},
+		&PrefillGroup{},
 		&Setup{},
+		&TwoFA{},
+		&TwoFABackupCode{},
 	)
 	if err != nil {
 		return err
@@ -259,6 +273,7 @@ func migrateDB() error {
 }
 
 func migrateDBFast() error {
+
 	var wg sync.WaitGroup
 
 	migrations := []struct {
@@ -276,7 +291,12 @@ func migrateDBFast() error {
 		{&TopUp{}, "TopUp"},
 		{&QuotaData{}, "QuotaData"},
 		{&Task{}, "Task"},
+		{&Model{}, "Model"},
+		{&Vendor{}, "Vendor"},
+		{&PrefillGroup{}, "PrefillGroup"},
 		{&Setup{}, "Setup"},
+		{&TwoFA{}, "TwoFA"},
+		{&TwoFABackupCode{}, "TwoFABackupCode"},
 	}
 	// 动态计算migration数量,确保errChan缓冲区足够大
 	errChan := make(chan error, len(migrations))
@@ -332,6 +352,98 @@ func CloseDB() error {
 	return closeDB(DB)
 }
 
+// checkMySQLChineseSupport ensures the MySQL connection and current schema
+// default charset/collation can store Chinese characters. It allows common
+// Chinese-capable charsets (utf8mb4, utf8, gbk, big5, gb18030) and panics otherwise.
+func checkMySQLChineseSupport(db *gorm.DB) error {
+	// 仅检测:当前库默认字符集/排序规则 + 各表的排序规则(隐含字符集)
+
+	// Read current schema defaults
+	var schemaCharset, schemaCollation string
+	err := db.Raw("SELECT DEFAULT_CHARACTER_SET_NAME, DEFAULT_COLLATION_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = DATABASE()").Row().Scan(&schemaCharset, &schemaCollation)
+	if err != nil {
+		return fmt.Errorf("读取当前库默认字符集/排序规则失败 / Failed to read schema default charset/collation: %v", err)
+	}
+
+	toLower := func(s string) string { return strings.ToLower(s) }
+	// Allowed charsets that can store Chinese text
+	allowedCharsets := map[string]string{
+		"utf8mb4": "utf8mb4_",
+		"utf8":    "utf8_",
+		"gbk":     "gbk_",
+		"big5":    "big5_",
+		"gb18030": "gb18030_",
+	}
+	isChineseCapable := func(cs, cl string) bool {
+		csLower := toLower(cs)
+		clLower := toLower(cl)
+		if prefix, ok := allowedCharsets[csLower]; ok {
+			if clLower == "" {
+				return true
+			}
+			return strings.HasPrefix(clLower, prefix)
+		}
+		// 如果仅提供了排序规则,尝试按排序规则前缀判断
+		for _, prefix := range allowedCharsets {
+			if strings.HasPrefix(clLower, prefix) {
+				return true
+			}
+		}
+		return false
+	}
+
+	// 1) 当前库默认值必须支持中文
+	if !isChineseCapable(schemaCharset, schemaCollation) {
+		return fmt.Errorf("当前库默认字符集/排序规则不支持中文:schema(%s/%s)。请将库设置为 utf8mb4/utf8/gbk/big5/gb18030 / Schema default charset/collation is not Chinese-capable: schema(%s/%s). Please set to utf8mb4/utf8/gbk/big5/gb18030",
+			schemaCharset, schemaCollation, schemaCharset, schemaCollation)
+	}
+
+	// 2) 所有物理表的排序规则(隐含字符集)必须支持中文
+	type tableInfo struct {
+		Name      string
+		Collation *string
+	}
+	var tables []tableInfo
+	if err := db.Raw("SELECT TABLE_NAME, TABLE_COLLATION FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_TYPE = 'BASE TABLE'").Scan(&tables).Error; err != nil {
+		return fmt.Errorf("读取表排序规则失败 / Failed to read table collations: %v", err)
+	}
+
+	var badTables []string
+	for _, t := range tables {
+		// NULL 或空表示继承库默认设置,已在上面校验库默认,视为通过
+		if t.Collation == nil || *t.Collation == "" {
+			continue
+		}
+		cl := *t.Collation
+		// 仅凭排序规则判断是否中文可用
+		ok := false
+		lower := strings.ToLower(cl)
+		for _, prefix := range allowedCharsets {
+			if strings.HasPrefix(lower, prefix) {
+				ok = true
+				break
+			}
+		}
+		if !ok {
+			badTables = append(badTables, fmt.Sprintf("%s(%s)", t.Name, cl))
+		}
+	}
+
+	if len(badTables) > 0 {
+		// 限制输出数量以避免日志过长
+		maxShow := 20
+		shown := badTables
+		if len(shown) > maxShow {
+			shown = shown[:maxShow]
+		}
+		return fmt.Errorf(
+			"存在不支持中文的表,请修复其排序规则/字符集。示例(最多展示 %d 项):%v / Found tables not Chinese-capable. Please fix their collation/charset. Examples (showing up to %d): %v",
+			maxShow, shown, maxShow, shown,
+		)
+	}
+	return nil
+}
+
 var (
 	lastPingTime time.Time
 	pingMutex    sync.Mutex

+ 30 - 0
model/missing_models.go

@@ -0,0 +1,30 @@
+package model
+
+// GetMissingModels returns model names that are referenced in the system
+func GetMissingModels() ([]string, error) {
+	// 1. 获取所有已启用模型(去重)
+	models := GetEnabledModels()
+	if len(models) == 0 {
+		return []string{}, nil
+	}
+
+	// 2. 查询已有的元数据模型名
+	var existing []string
+	if err := DB.Model(&Model{}).Where("model_name IN ?", models).Pluck("model_name", &existing).Error; err != nil {
+		return nil, err
+	}
+
+	existingSet := make(map[string]struct{}, len(existing))
+	for _, e := range existing {
+		existingSet[e] = struct{}{}
+	}
+
+	// 3. 收集缺失模型
+	var missing []string
+	for _, name := range models {
+		if _, ok := existingSet[name]; !ok {
+			missing = append(missing, name)
+		}
+	}
+	return missing, nil
+}

+ 31 - 0
model/model_extra.go

@@ -0,0 +1,31 @@
+package model
+
+func GetModelEnableGroups(modelName string) []string {
+	// 确保缓存最新
+	GetPricing()
+
+	if modelName == "" {
+		return make([]string, 0)
+	}
+
+	modelEnableGroupsLock.RLock()
+	groups, ok := modelEnableGroups[modelName]
+	modelEnableGroupsLock.RUnlock()
+	if !ok {
+		return make([]string, 0)
+	}
+	return groups
+}
+
+// GetModelQuotaTypes 返回指定模型的计费类型集合(来自缓存)
+func GetModelQuotaTypes(modelName string) []int {
+	GetPricing()
+
+	modelEnableGroupsLock.RLock()
+	quota, ok := modelQuotaTypeMap[modelName]
+	modelEnableGroupsLock.RUnlock()
+	if !ok {
+		return []int{}
+	}
+	return []int{quota}
+}

+ 147 - 0
model/model_meta.go

@@ -0,0 +1,147 @@
+package model
+
+import (
+	"one-api/common"
+	"strconv"
+
+	"gorm.io/gorm"
+)
+
+const (
+	NameRuleExact = iota
+	NameRulePrefix
+	NameRuleContains
+	NameRuleSuffix
+)
+
+type BoundChannel struct {
+	Name string `json:"name"`
+	Type int    `json:"type"`
+}
+
+type Model struct {
+	Id           int            `json:"id"`
+	ModelName    string         `json:"model_name" gorm:"size:128;not null;uniqueIndex:uk_model_name_delete_at,priority:1"`
+	Description  string         `json:"description,omitempty" gorm:"type:text"`
+	Icon         string         `json:"icon,omitempty" gorm:"type:varchar(128)"`
+	Tags         string         `json:"tags,omitempty" gorm:"type:varchar(255)"`
+	VendorID     int            `json:"vendor_id,omitempty" gorm:"index"`
+	Endpoints    string         `json:"endpoints,omitempty" gorm:"type:text"`
+	Status       int            `json:"status" gorm:"default:1"`
+	SyncOfficial int            `json:"sync_official" gorm:"default:1"`
+	CreatedTime  int64          `json:"created_time" gorm:"bigint"`
+	UpdatedTime  int64          `json:"updated_time" gorm:"bigint"`
+	DeletedAt    gorm.DeletedAt `json:"-" gorm:"index;uniqueIndex:uk_model_name_delete_at,priority:2"`
+
+	BoundChannels []BoundChannel `json:"bound_channels,omitempty" gorm:"-"`
+	EnableGroups  []string       `json:"enable_groups,omitempty" gorm:"-"`
+	QuotaTypes    []int          `json:"quota_types,omitempty" gorm:"-"`
+	NameRule      int            `json:"name_rule" gorm:"default:0"`
+
+	MatchedModels []string `json:"matched_models,omitempty" gorm:"-"`
+	MatchedCount  int      `json:"matched_count,omitempty" gorm:"-"`
+}
+
+func (mi *Model) Insert() error {
+	now := common.GetTimestamp()
+	mi.CreatedTime = now
+	mi.UpdatedTime = now
+	return DB.Create(mi).Error
+}
+
+func IsModelNameDuplicated(id int, name string) (bool, error) {
+	if name == "" {
+		return false, nil
+	}
+	var cnt int64
+	err := DB.Model(&Model{}).Where("model_name = ? AND id <> ?", name, id).Count(&cnt).Error
+	return cnt > 0, err
+}
+
+func (mi *Model) Update() error {
+	mi.UpdatedTime = common.GetTimestamp()
+	return DB.Session(&gorm.Session{AllowGlobalUpdate: false, FullSaveAssociations: false}).
+		Model(&Model{}).
+		Where("id = ?", mi.Id).
+		Omit("created_time").
+		Select("*").
+		Updates(mi).Error
+}
+
+func (mi *Model) Delete() error {
+	return DB.Delete(mi).Error
+}
+
+func GetVendorModelCounts() (map[int64]int64, error) {
+	var stats []struct {
+		VendorID int64
+		Count    int64
+	}
+	if err := DB.Model(&Model{}).
+		Select("vendor_id as vendor_id, count(*) as count").
+		Group("vendor_id").
+		Scan(&stats).Error; err != nil {
+		return nil, err
+	}
+	m := make(map[int64]int64, len(stats))
+	for _, s := range stats {
+		m[s.VendorID] = s.Count
+	}
+	return m, nil
+}
+
+func GetAllModels(offset int, limit int) ([]*Model, error) {
+	var models []*Model
+	err := DB.Order("id DESC").Offset(offset).Limit(limit).Find(&models).Error
+	return models, err
+}
+
+func GetBoundChannelsByModelsMap(modelNames []string) (map[string][]BoundChannel, error) {
+	result := make(map[string][]BoundChannel)
+	if len(modelNames) == 0 {
+		return result, nil
+	}
+	type row struct {
+		Model string
+		Name  string
+		Type  int
+	}
+	var rows []row
+	err := DB.Table("channels").
+		Select("abilities.model as model, channels.name as name, channels.type as type").
+		Joins("JOIN abilities ON abilities.channel_id = channels.id").
+		Where("abilities.model IN ? AND abilities.enabled = ?", modelNames, true).
+		Distinct().
+		Scan(&rows).Error
+	if err != nil {
+		return nil, err
+	}
+	for _, r := range rows {
+		result[r.Model] = append(result[r.Model], BoundChannel{Name: r.Name, Type: r.Type})
+	}
+	return result, nil
+}
+
+func SearchModels(keyword string, vendor string, offset int, limit int) ([]*Model, int64, error) {
+	var models []*Model
+	db := DB.Model(&Model{})
+	if keyword != "" {
+		like := "%" + keyword + "%"
+		db = db.Where("model_name LIKE ? OR description LIKE ? OR tags LIKE ?", like, like, like)
+	}
+	if vendor != "" {
+		if vid, err := strconv.Atoi(vendor); err == nil {
+			db = db.Where("models.vendor_id = ?", vid)
+		} else {
+			db = db.Joins("JOIN vendors ON vendors.id = models.vendor_id").Where("vendors.name LIKE ?", "%"+vendor+"%")
+		}
+	}
+	var total int64
+	if err := db.Count(&total).Error; err != nil {
+		return nil, 0, err
+	}
+	if err := db.Order("models.id DESC").Offset(offset).Limit(limit).Find(&models).Error; err != nil {
+		return nil, 0, err
+	}
+	return models, total, nil
+}

+ 32 - 20
model/option.go

@@ -6,6 +6,7 @@ import (
 	"one-api/setting/config"
 	"one-api/setting/operation_setting"
 	"one-api/setting/ratio_setting"
+	"one-api/setting/system_setting"
 	"strconv"
 	"strings"
 	"time"
@@ -66,16 +67,16 @@ func InitOptionMap() {
 	common.OptionMap["SystemName"] = common.SystemName
 	common.OptionMap["Logo"] = common.Logo
 	common.OptionMap["ServerAddress"] = ""
-	common.OptionMap["WorkerUrl"] = setting.WorkerUrl
-	common.OptionMap["WorkerValidKey"] = setting.WorkerValidKey
-	common.OptionMap["WorkerAllowHttpImageRequestEnabled"] = strconv.FormatBool(setting.WorkerAllowHttpImageRequestEnabled)
+	common.OptionMap["WorkerUrl"] = system_setting.WorkerUrl
+	common.OptionMap["WorkerValidKey"] = system_setting.WorkerValidKey
+	common.OptionMap["WorkerAllowHttpImageRequestEnabled"] = strconv.FormatBool(system_setting.WorkerAllowHttpImageRequestEnabled)
 	common.OptionMap["PayAddress"] = ""
 	common.OptionMap["CustomCallbackAddress"] = ""
 	common.OptionMap["EpayId"] = ""
 	common.OptionMap["EpayKey"] = ""
-	common.OptionMap["Price"] = strconv.FormatFloat(setting.Price, 'f', -1, 64)
-	common.OptionMap["USDExchangeRate"] = strconv.FormatFloat(setting.USDExchangeRate, 'f', -1, 64)
-	common.OptionMap["MinTopUp"] = strconv.Itoa(setting.MinTopUp)
+	common.OptionMap["Price"] = strconv.FormatFloat(operation_setting.Price, 'f', -1, 64)
+	common.OptionMap["USDExchangeRate"] = strconv.FormatFloat(operation_setting.USDExchangeRate, 'f', -1, 64)
+	common.OptionMap["MinTopUp"] = strconv.Itoa(operation_setting.MinTopUp)
 	common.OptionMap["StripeMinTopUp"] = strconv.Itoa(setting.StripeMinTopUp)
 	common.OptionMap["StripeApiSecret"] = setting.StripeApiSecret
 	common.OptionMap["StripeWebhookSecret"] = setting.StripeWebhookSecret
@@ -89,7 +90,7 @@ func InitOptionMap() {
 	common.OptionMap["Chats"] = setting.Chats2JsonString()
 	common.OptionMap["AutoGroups"] = setting.AutoGroups2JsonString()
 	common.OptionMap["DefaultUseAutoGroup"] = strconv.FormatBool(setting.DefaultUseAutoGroup)
-	common.OptionMap["PayMethods"] = setting.PayMethods2JsonString()
+	common.OptionMap["PayMethods"] = operation_setting.PayMethods2JsonString()
 	common.OptionMap["GitHubClientId"] = ""
 	common.OptionMap["GitHubClientSecret"] = ""
 	common.OptionMap["TelegramBotToken"] = ""
@@ -115,6 +116,9 @@ func InitOptionMap() {
 	common.OptionMap["GroupGroupRatio"] = ratio_setting.GroupGroupRatio2JSONString()
 	common.OptionMap["UserUsableGroups"] = setting.UserUsableGroups2JSONString()
 	common.OptionMap["CompletionRatio"] = ratio_setting.CompletionRatio2JSONString()
+	common.OptionMap["ImageRatio"] = ratio_setting.ImageRatio2JSONString()
+	common.OptionMap["AudioRatio"] = ratio_setting.AudioRatio2JSONString()
+	common.OptionMap["AudioCompletionRatio"] = ratio_setting.AudioCompletionRatio2JSONString()
 	common.OptionMap["TopUpLink"] = common.TopUpLink
 	//common.OptionMap["ChatLink"] = common.ChatLink
 	//common.OptionMap["ChatLink2"] = common.ChatLink2
@@ -154,7 +158,7 @@ func loadOptionsFromDatabase() {
 	for _, option := range options {
 		err := updateOptionMap(option.Key, option.Value)
 		if err != nil {
-			common.SysError("failed to update option map: " + err.Error())
+			common.SysLog("failed to update option map: " + err.Error())
 		}
 	}
 }
@@ -275,7 +279,7 @@ func updateOptionMap(key string, value string) (err error) {
 		case "SMTPSSLEnabled":
 			common.SMTPSSLEnabled = boolValue
 		case "WorkerAllowHttpImageRequestEnabled":
-			setting.WorkerAllowHttpImageRequestEnabled = boolValue
+			system_setting.WorkerAllowHttpImageRequestEnabled = boolValue
 		case "DefaultUseAutoGroup":
 			setting.DefaultUseAutoGroup = boolValue
 		case "ExposeRatioEnabled":
@@ -297,29 +301,29 @@ func updateOptionMap(key string, value string) (err error) {
 	case "SMTPToken":
 		common.SMTPToken = value
 	case "ServerAddress":
-		setting.ServerAddress = value
+		system_setting.ServerAddress = value
 	case "WorkerUrl":
-		setting.WorkerUrl = value
+		system_setting.WorkerUrl = value
 	case "WorkerValidKey":
-		setting.WorkerValidKey = value
+		system_setting.WorkerValidKey = value
 	case "PayAddress":
-		setting.PayAddress = value
+		operation_setting.PayAddress = value
 	case "Chats":
 		err = setting.UpdateChatsByJsonString(value)
 	case "AutoGroups":
 		err = setting.UpdateAutoGroupsByJsonString(value)
 	case "CustomCallbackAddress":
-		setting.CustomCallbackAddress = value
+		operation_setting.CustomCallbackAddress = value
 	case "EpayId":
-		setting.EpayId = value
+		operation_setting.EpayId = value
 	case "EpayKey":
-		setting.EpayKey = value
+		operation_setting.EpayKey = value
 	case "Price":
-		setting.Price, _ = strconv.ParseFloat(value, 64)
+		operation_setting.Price, _ = strconv.ParseFloat(value, 64)
 	case "USDExchangeRate":
-		setting.USDExchangeRate, _ = strconv.ParseFloat(value, 64)
+		operation_setting.USDExchangeRate, _ = strconv.ParseFloat(value, 64)
 	case "MinTopUp":
-		setting.MinTopUp, _ = strconv.Atoi(value)
+		operation_setting.MinTopUp, _ = strconv.Atoi(value)
 	case "StripeApiSecret":
 		setting.StripeApiSecret = value
 	case "StripeWebhookSecret":
@@ -348,6 +352,8 @@ func updateOptionMap(key string, value string) (err error) {
 		common.LinuxDOClientId = value
 	case "LinuxDOClientSecret":
 		common.LinuxDOClientSecret = value
+	case "LinuxDOMinimumTrustLevel":
+		common.LinuxDOMinimumTrustLevel, _ = strconv.Atoi(value)
 	case "Footer":
 		common.Footer = value
 	case "SystemName":
@@ -406,6 +412,12 @@ func updateOptionMap(key string, value string) (err error) {
 		err = ratio_setting.UpdateModelPriceByJSONString(value)
 	case "CacheRatio":
 		err = ratio_setting.UpdateCacheRatioByJSONString(value)
+	case "ImageRatio":
+		err = ratio_setting.UpdateImageRatioByJSONString(value)
+	case "AudioRatio":
+		err = ratio_setting.UpdateAudioRatioByJSONString(value)
+	case "AudioCompletionRatio":
+		err = ratio_setting.UpdateAudioCompletionRatioByJSONString(value)
 	case "TopUpLink":
 		common.TopUpLink = value
 	//case "ChatLink":
@@ -423,7 +435,7 @@ func updateOptionMap(key string, value string) (err error) {
 	case "StreamCacheQueueLength":
 		setting.StreamCacheQueueLength, _ = strconv.Atoi(value)
 	case "PayMethods":
-		err = setting.UpdatePayMethodsByJsonString(value)
+		err = operation_setting.UpdatePayMethodsByJsonString(value)
 	}
 	return err
 }

Vissa filer visades inte eftersom för många filer har ändrats