Compare commits
56 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c29d80f015 | |||
| 3ba01a7dcd | |||
| 20d3e73734 | |||
| 2d1ca15384 | |||
| 0d4b25795a | |||
| 146dd77b83 | |||
| 5e88f97ac1 | |||
| 0cd9a3a068 | |||
| 032993ed49 | |||
| c78573ce03 | |||
| 8db32213e7 | |||
| cb9270ed23 | |||
| fc08c133e2 | |||
| b397c58bab | |||
| 8ae095c3b8 | |||
| 04b4483d7d | |||
| ee9736bbc8 | |||
| 0936e25046 | |||
| 5dd0d3bcbd | |||
| f69ceb6967 | |||
| 68830e6097 | |||
| 2d968c3eab | |||
| cb7a61466e | |||
| 132d7b9f94 | |||
| 6f8668e4c3 | |||
| 8a10dedb7d | |||
| 554defe4f4 | |||
| 8f9ee9ba88 | |||
| 3caa6e467b | |||
| 18282e610d | |||
| 51b5cbe1bd | |||
| 3e588b4d4f | |||
| 0526a22643 | |||
| aa56667b8f | |||
| 428e3d91f2 | |||
| 3856b9d2c0 | |||
| 469d3747af | |||
| a720064d91 | |||
| fde2cac9d3 | |||
| 2b89989f62 | |||
| 7fe896d2f8 | |||
| 3057f04a17 | |||
| 03d537328a | |||
| 19fc384e67 | |||
| ba474393fb | |||
| 5fa103fa5b | |||
| 543cc64ea3 | |||
| d146e45e2f | |||
| 560ba57c88 | |||
| 948780e3fa | |||
| c19d5aa663 | |||
| faa0f1425a | |||
| a7475a1e67 | |||
| 415d21d071 | |||
| abc255dd6d | |||
| a7d019e3a9 |
+6
-1
@@ -7,4 +7,9 @@ Makefile
|
||||
docs
|
||||
.eslintcache
|
||||
.gocache
|
||||
/web/node_modules
|
||||
/web/node_modules
|
||||
/web/default/node_modules
|
||||
/web/default/dist
|
||||
/web/classic/node_modules
|
||||
/web/classic/dist
|
||||
!THIRD-PARTY-LICENSES.md
|
||||
|
||||
+1
-1
@@ -9,4 +9,4 @@ community_bridge: # Replace with a single Community Bridge project-name e.g., cl
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
otechie: # Replace with a single Otechie username
|
||||
custom: ['https://afdian.com/a/new-api'] # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
|
||||
@@ -44,6 +44,7 @@ RUN apt-get update \
|
||||
&& update-ca-certificates
|
||||
|
||||
COPY --from=builder2 /build/new-api /
|
||||
COPY LICENSE NOTICE THIRD-PARTY-LICENSES.md /licenses/
|
||||
EXPOSE 3000
|
||||
WORKDIR /data
|
||||
ENTRYPOINT ["/new-api"]
|
||||
|
||||
@@ -30,6 +30,7 @@ RUN apt-get update \
|
||||
&& update-ca-certificates
|
||||
|
||||
COPY --from=builder /build/new-api /
|
||||
COPY LICENSE NOTICE THIRD-PARTY-LICENSES.md /licenses/
|
||||
EXPOSE 3000
|
||||
WORKDIR /data
|
||||
ENTRYPOINT ["/new-api"]
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
new-api Notices
|
||||
|
||||
new-api
|
||||
Copyright (c) QuantumNous and contributors.
|
||||
|
||||
This project is licensed under the GNU Affero General Public License v3.0.
|
||||
See LICENSE for the full project license terms.
|
||||
|
||||
==== Additional Terms under AGPLv3 Section 7 ====
|
||||
|
||||
Pursuant to Section 7(b) of the GNU Affero General Public License version 3,
|
||||
the following reasonable legal notice and author attribution must be preserved
|
||||
by modified versions in the Appropriate Legal Notices and in any prominent
|
||||
about, legal, footer, or attribution location presented by the user interface:
|
||||
|
||||
"Frontend design and development by New API contributors."
|
||||
|
||||
Modified versions that present a user interface must also preserve a visible
|
||||
link to the original project in a prominent about, legal, footer, or
|
||||
attribution location:
|
||||
|
||||
https://github.com/QuantumNous/new-api
|
||||
|
||||
Modified versions must not misrepresent the origin of the software and must
|
||||
mark their changes in accordance with AGPLv3 Section 7(c).
|
||||
|
||||
==== Third-Party Notices ====
|
||||
|
||||
This product includes third-party open source software. Copyright notices and
|
||||
license terms for direct third-party dependencies are listed in
|
||||
THIRD-PARTY-LICENSES.md.
|
||||
|
||||
Apache-2.0 upstream NOTICE entries identified for direct dependencies are
|
||||
reproduced below. Preserve this file with Docker images, standalone binaries,
|
||||
frontend bundles, and Electron desktop installers distributed to users.
|
||||
|
||||
==== Apache-2.0 Notices ====
|
||||
|
||||
AWS SDK for Go
|
||||
Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
||||
Copyright 2014-2015 Stripe, Inc.
|
||||
|
||||
smithy-go
|
||||
Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
||||
|
||||
otp
|
||||
Copyright (c) 2014, Paul Querna
|
||||
|
||||
This product includes software developed by
|
||||
Paul Querna (http://paul.querna.org/).
|
||||
|
||||
==== Electron / Chromium Notices ====
|
||||
|
||||
Desktop distributions include Electron, which embeds Chromium, Node.js, V8,
|
||||
and other third-party components. Electron and Chromium third-party license
|
||||
notices must remain available with desktop installers and installed apps.
|
||||
|
||||
==== End of Notices ====
|
||||
+15
-11
@@ -53,9 +53,10 @@
|
||||
> This is an open-source project developed based on [One API](https://github.com/songquanpeng/one-api)
|
||||
|
||||
> [!IMPORTANT]
|
||||
> - This project is for personal learning purposes only, with no guarantee of stability or technical support
|
||||
> - Users must comply with OpenAI's [Terms of Use](https://openai.com/policies/terms-of-use) and **applicable laws and regulations**, and must not use it for illegal purposes
|
||||
> - According to the [《Interim Measures for the Management of Generative Artificial Intelligence Services》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm), please do not provide any unregistered generative AI services to the public in China.
|
||||
> - This project is intended solely for lawful and authorized AI API gateway, organization-level authentication, multi-model management, usage analytics, cost accounting, and private deployment scenarios.
|
||||
> - Users must lawfully obtain upstream API keys, accounts, model services, and interface permissions, and must comply with upstream terms of service and applicable laws and regulations.
|
||||
> - Users should ensure their use complies with upstream terms of service and applicable laws and regulations.
|
||||
> - When providing generative AI services to the public, users should comply with applicable regulatory requirements and fulfill all filing, licensing, content safety, real-name verification, log retention, tax, and upstream authorization obligations required by their jurisdiction.
|
||||
|
||||
---
|
||||
|
||||
@@ -146,6 +147,9 @@ docker run --name new-api -d --restart always \
|
||||
|
||||
🎉 After deployment is complete, visit `http://localhost:3000` to start using!
|
||||
|
||||
> [!WARNING]
|
||||
> When operating this project as a public generative AI service or API resale service, users should first complete all required filing, licensing, content safety, real-name verification, log retention, tax, payment, and upstream authorization obligations.
|
||||
|
||||
📖 For more deployment methods, please refer to [Deployment Guide](https://docs.newapi.pro/en/docs/installation)
|
||||
|
||||
---
|
||||
@@ -184,12 +188,12 @@ docker run --name new-api -d --restart always \
|
||||
| 📈 Data Dashboard | Visual console and statistical analysis |
|
||||
| 🔒 Permission Management | Token grouping, model restrictions, user management |
|
||||
|
||||
### 💰 Payment and Billing
|
||||
### 💰 Authorized Usage Accounting and Billing
|
||||
|
||||
- ✅ Online recharge (EPay, Stripe)
|
||||
- ✅ Pay-per-use model pricing
|
||||
- ✅ Cache billing support (OpenAI, Azure, DeepSeek, Claude, Qwen and all supported models)
|
||||
- ✅ Flexible billing policy configuration
|
||||
- ✅ Internal top-up and quota allocation for lawful authorized scenarios (EPay, Stripe)
|
||||
- ✅ Organization-level per-request, usage-based, and cache-hit cost accounting
|
||||
- ✅ Cache billing statistics for OpenAI, Azure, DeepSeek, Claude, Qwen, and supported models
|
||||
- ✅ Flexible billing policies for internal management or authorized enterprise customers
|
||||
|
||||
### 🔐 Authorization and Security
|
||||
|
||||
@@ -248,7 +252,7 @@ docker run --name new-api -d --restart always \
|
||||
|
||||
## 🤖 Model Support
|
||||
|
||||
> For details, please refer to [API Documentation - Relay Interface](https://docs.newapi.pro/en/docs/api)
|
||||
> For details, please refer to [API Documentation - Gateway Interface](https://docs.newapi.pro/en/docs/api)
|
||||
|
||||
| Model Type | Description | Documentation |
|
||||
|---------|------|------|
|
||||
@@ -259,7 +263,7 @@ docker run --name new-api -d --restart always \
|
||||
| 💬 Claude | Messages format | [Documentation](https://docs.newapi.pro/en/docs/api/ai-model/chat/create-message) |
|
||||
| 🌐 Gemini | Google Gemini format | [Documentation](https://doc.newapi.pro/en/api/google-gemini-chat) |
|
||||
| 🔧 Dify | ChatFlow mode | - |
|
||||
| 🎯 Custom | Supports complete call address | - |
|
||||
| 🎯 Custom upstream | Supports configuring legally authorized upstream endpoints | - |
|
||||
|
||||
### 📡 Supported Interfaces
|
||||
|
||||
@@ -409,7 +413,7 @@ docker run --name new-api -d --restart always \
|
||||
|
||||
| Project | Description |
|
||||
|------|------|
|
||||
| [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool) | Key quota query tool |
|
||||
| [new-api-key-tool](https://github.com/Calcium-Ion/new-api-key-tool) | Key quota query tool |
|
||||
| [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon) | New API high-performance optimized version |
|
||||
|
||||
---
|
||||
|
||||
+16
-12
@@ -55,9 +55,10 @@
|
||||
## 📝 Description du projet
|
||||
|
||||
> [!IMPORTANT]
|
||||
> - Ce projet est uniquement destiné à des fins d'apprentissage personnel, sans garantie de stabilité ni de support technique.
|
||||
> - Les utilisateurs doivent se conformer aux [Conditions d'utilisation](https://openai.com/policies/terms-of-use) d'OpenAI et aux **lois et réglementations applicables**, et ne doivent pas l'utiliser à des fins illégales.
|
||||
> - Conformément aux [《Mesures provisoires pour la gestion des services d'intelligence artificielle générative》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm), veuillez ne fournir aucun service d'IA générative non enregistré au public en Chine.
|
||||
> - Ce projet est exclusivement destiné aux scénarios de passerelle API d'IA légalement autorisés, d'authentification organisationnelle, de gestion multi-modèles, d'analyse d'utilisation, de comptabilisation des coûts et de déploiement privé.
|
||||
> - Les utilisateurs doivent obtenir légalement les clés API, comptes, services de modèles et autorisations d'interface en amont, et doivent respecter les conditions d'utilisation en amont et les lois et réglementations applicables.
|
||||
> - Les utilisateurs doivent s'assurer que leur utilisation est conforme aux conditions d'utilisation en amont et aux lois et réglementations applicables.
|
||||
> - Lors de la fourniture de services d'IA générative au public, les utilisateurs doivent se conformer aux exigences réglementaires applicables et remplir toutes les obligations d'enregistrement, de licence, de sécurité du contenu, de vérification d'identité, de conservation des journaux, de fiscalité et d'autorisation en amont requises par leur juridiction.
|
||||
|
||||
---
|
||||
|
||||
@@ -151,6 +152,9 @@ docker run --name new-api -d --restart always \
|
||||
|
||||
🎉 Après le déploiement, visitez `http://localhost:3000` pour commencer à utiliser!
|
||||
|
||||
> [!WARNING]
|
||||
> Lorsque vous exploitez ce projet en tant que service public d'IA générative ou service de revente d'API, les utilisateurs doivent d'abord remplir toutes les obligations requises en matière d'enregistrement, de licence, de sécurité du contenu, de vérification d'identité, de conservation des journaux, de fiscalité, de paiement et d'autorisation en amont.
|
||||
|
||||
📖 Pour plus de méthodes de déploiement, veuillez vous référer à [Guide de déploiement](https://docs.newapi.pro/en/docs/installation)
|
||||
|
||||
---
|
||||
@@ -189,12 +193,12 @@ docker run --name new-api -d --restart always \
|
||||
| 📈 Tableau de bord des données | Console visuelle et analyse statistique |
|
||||
| 🔒 Gestion des permissions | Regroupement de jetons, restrictions de modèles, gestion des utilisateurs |
|
||||
|
||||
### 💰 Paiement et facturation
|
||||
### 💰 Comptabilisation et facturation des usages autorisés
|
||||
|
||||
- ✅ Recharge en ligne (EPay, Stripe)
|
||||
- ✅ Tarification des modèles de paiement à l'utilisation
|
||||
- ✅ Prise en charge de la facturation du cache (OpenAI, Azure, DeepSeek, Claude, Qwen et tous les modèles pris en charge)
|
||||
- ✅ Configuration flexible des politiques de facturation
|
||||
- ✅ Rechargement interne et allocation de quotas pour les scénarios légalement autorisés (EPay, Stripe)
|
||||
- ✅ Comptabilisation des coûts par requête, par utilisation et par hit de cache au niveau organisationnel
|
||||
- ✅ Statistiques de facturation du cache pour OpenAI, Azure, DeepSeek, Claude, Qwen et les modèles pris en charge
|
||||
- ✅ Politiques de facturation flexibles pour la gestion interne ou les clients entreprise autorisés
|
||||
|
||||
### 🔐 Autorisation et sécurité
|
||||
|
||||
@@ -202,7 +206,7 @@ docker run --name new-api -d --restart always \
|
||||
- 🤖 Connexion par autorisation LinuxDO
|
||||
- 📱 Connexion par autorisation Telegram
|
||||
- 🔑 Authentification unifiée OIDC
|
||||
- 🔍 Requête de quota d'utilisation de clé (avec [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool))
|
||||
- 🔍 Requête de quota d'utilisation de clé (avec [new-api-key-tool](https://github.com/Calcium-Ion/new-api-key-tool))
|
||||
|
||||
### 🚀 Fonctionnalités avancées
|
||||
|
||||
@@ -254,7 +258,7 @@ docker run --name new-api -d --restart always \
|
||||
|
||||
## 🤖 Prise en charge des modèles
|
||||
|
||||
> Pour les détails, veuillez vous référer à [Documentation de l'API - Interface de relais](https://docs.newapi.pro/en/docs/api)
|
||||
> Pour les détails, veuillez vous référer à [Documentation de l'API - Interface de passerelle](https://docs.newapi.pro/en/docs/api)
|
||||
|
||||
| Type de modèle | Description | Documentation |
|
||||
|---------|------|------|
|
||||
@@ -266,7 +270,7 @@ docker run --name new-api -d --restart always \
|
||||
| 💬 Claude | Format Messages | [Documentation](https://docs.newapi.pro/en/docs/api/ai-model/chat/createmessage) |
|
||||
| 🌐 Gemini | Format Google Gemini | [Documentation](https://docs.newapi.pro/en/docs/api/ai-model/chat/gemini/geminirelayv1beta) |
|
||||
| 🔧 Dify | Mode ChatFlow | - |
|
||||
| 🎯 Personnalisé | Prise en charge de l'adresse d'appel complète | - |
|
||||
| 🎯 Amont personnalisé | Configuration des points d'accès amont légalement autorisés | - |
|
||||
|
||||
### 📡 Interfaces prises en charge
|
||||
|
||||
@@ -416,7 +420,7 @@ docker run --name new-api -d --restart always \
|
||||
|
||||
| Projet | Description |
|
||||
|------|------|
|
||||
| [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool) | Outil de recherche de quota d'utilisation avec une clé |
|
||||
| [new-api-key-tool](https://github.com/Calcium-Ion/new-api-key-tool) | Outil de recherche de quota d'utilisation avec une clé |
|
||||
| [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon) | Version optimisée haute performance de New API |
|
||||
|
||||
---
|
||||
|
||||
+16
-12
@@ -55,9 +55,10 @@
|
||||
## 📝 プロジェクト説明
|
||||
|
||||
> [!IMPORTANT]
|
||||
> - 本プロジェクトは個人学習用のみであり、安定性の保証や技術サポートは提供しません。
|
||||
> - ユーザーは、OpenAIの[利用規約](https://openai.com/policies/terms-of-use)および**法律法規**を遵守する必要があり、違法な目的で使用してはいけません。
|
||||
> - [《生成式人工智能服务管理暂行办法》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm)の要求に従い、中国地域の公衆に未登録の生成式AI サービスを提供しないでください。
|
||||
> - 本プロジェクトは、合法的に許可された AI API ゲートウェイ、組織レベルの認証、マルチモデル管理、利用量分析、コスト管理、プライベートデプロイのシナリオのみを対象としています。
|
||||
> - ユーザーは、上流の API キー、アカウント、モデルサービス、インターフェース権限を合法的に取得し、上流のサービス利用規約および適用される法律法規を遵守する必要があります。
|
||||
> - ユーザーは、利用方法が上流のサービス利用規約および適用される法律法規に準拠していることを確認してください。
|
||||
> - 生成 AI サービスを公衆に提供する場合、ユーザーは適用される規制要件を遵守し、管轄区域で求められる届出、ライセンス、コンテンツセキュリティ、本人確認、ログ保持、税務、上流認可などのすべての義務を履行してください。
|
||||
|
||||
---
|
||||
|
||||
@@ -151,6 +152,9 @@ docker run --name new-api -d --restart always \
|
||||
|
||||
🎉 デプロイが完了したら、`http://localhost:3000` にアクセスして使用を開始してください!
|
||||
|
||||
> [!WARNING]
|
||||
> 本プロジェクトを公衆向け生成 AI サービスまたは API 再販サービスとして運営する場合、ユーザーは届出、コンテンツセキュリティ、本人確認、ログ保持、税務、決済、上流認可などの必要なコンプライアンス義務を先に完了してください。
|
||||
|
||||
📖 その他のデプロイ方法については[デプロイガイド](https://docs.newapi.pro/ja/docs/installation)を参照してください。
|
||||
|
||||
---
|
||||
@@ -189,12 +193,12 @@ docker run --name new-api -d --restart always \
|
||||
| 📈 データダッシュボード | ビジュアルコンソールと統計分析 |
|
||||
| 🔒 権限管理 | トークングループ化、モデル制限、ユーザー管理 |
|
||||
|
||||
### 💰 支払いと課金
|
||||
### 💰 認可済み利用量とコスト管理
|
||||
|
||||
- ✅ オンライン充電(EPay、Stripe)
|
||||
- ✅ モデルの従量課金
|
||||
- ✅ キャッシュ課金サポート(OpenAI、Azure、DeepSeek、Claude、Qwenなどすべてのサポートされているモデル)
|
||||
- ✅ 柔軟な課金ポリシー設定
|
||||
- ✅ 合法的に許可されたシナリオでの内部チャージとクォータ割り当て(EPay、Stripe)
|
||||
- ✅ 組織レベルのリクエスト単位、使用量ベース、キャッシュヒットのコスト会計
|
||||
- ✅ OpenAI、Azure、DeepSeek、Claude、Qwen などのモデルのキャッシュ課金統計
|
||||
- ✅ 内部管理または認可済み企業顧客向けの柔軟な課金ポリシー
|
||||
|
||||
### 🔐 認証とセキュリティ
|
||||
|
||||
@@ -202,7 +206,7 @@ docker run --name new-api -d --restart always \
|
||||
- 🤖 LinuxDO認証ログイン
|
||||
- 📱 Telegram認証ログイン
|
||||
- 🔑 OIDC統一認証
|
||||
- 🔍 Key使用量クォータ照会([neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool)と併用)
|
||||
- 🔍 Key使用量クォータ照会([new-api-key-tool](https://github.com/Calcium-Ion/new-api-key-tool)と併用)
|
||||
|
||||
|
||||
|
||||
@@ -256,7 +260,7 @@ docker run --name new-api -d --restart always \
|
||||
|
||||
## 🤖 モデルサポート
|
||||
|
||||
> 詳細については[APIドキュメント - 中継インターフェース](https://docs.newapi.pro/ja/docs/api)
|
||||
> 詳細については[APIドキュメント - ゲートウェイインターフェース](https://docs.newapi.pro/ja/docs/api)
|
||||
|
||||
| モデルタイプ | 説明 | ドキュメント |
|
||||
|---------|------|------|
|
||||
@@ -268,7 +272,7 @@ docker run --name new-api -d --restart always \
|
||||
| 💬 Claude | Messagesフォーマット | [ドキュメント](https://docs.newapi.pro/ja/docs/api/ai-model/chat/createmessage) |
|
||||
| 🌐 Gemini | Google Geminiフォーマット | [ドキュメント](https://docs.newapi.pro/ja/docs/api/ai-model/chat/gemini/geminirelayv1beta) |
|
||||
| 🔧 Dify | ChatFlowモード | - |
|
||||
| 🎯 カスタム | 完全な呼び出しアドレスの入力をサポート | - |
|
||||
| 🎯 カスタム上流 | 合法的に許可された上流エンドポイントの設定をサポート | - |
|
||||
|
||||
### 📡 サポートされているインターフェース
|
||||
|
||||
@@ -416,7 +420,7 @@ docker run --name new-api -d --restart always \
|
||||
|
||||
| プロジェクト | 説明 |
|
||||
|------|------|
|
||||
| [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool) | キー使用量クォータ照会ツール |
|
||||
| [new-api-key-tool](https://github.com/Calcium-Ion/new-api-key-tool) | キー使用量クォータ照会ツール |
|
||||
| [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon) | New API高性能最適化版 |
|
||||
|
||||
---
|
||||
|
||||
@@ -55,9 +55,10 @@
|
||||
## 📝 Project Description
|
||||
|
||||
> [!IMPORTANT]
|
||||
> - This project is for personal learning purposes only, with no guarantee of stability or technical support
|
||||
> - Users must comply with OpenAI's [Terms of Use](https://openai.com/policies/terms-of-use) and **applicable laws and regulations**, and must not use it for illegal purposes
|
||||
> - According to the [《Interim Measures for the Management of Generative Artificial Intelligence Services》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm), please do not provide any unregistered generative AI services to the public in China.
|
||||
> - This project is intended solely for lawful and authorized AI API gateway, organization-level authentication, multi-model management, usage analytics, cost accounting, and private deployment scenarios.
|
||||
> - Users must lawfully obtain upstream API keys, accounts, model services, and interface permissions, and must comply with upstream terms of service and applicable laws and regulations.
|
||||
> - Users should ensure their use complies with upstream terms of service and applicable laws and regulations.
|
||||
> - When providing generative AI services to the public, users should comply with applicable regulatory requirements and fulfill all filing, licensing, content safety, real-name verification, log retention, tax, and upstream authorization obligations required by their jurisdiction.
|
||||
|
||||
---
|
||||
|
||||
@@ -151,6 +152,9 @@ docker run --name new-api -d --restart always \
|
||||
|
||||
🎉 After deployment is complete, visit `http://localhost:3000` to start using!
|
||||
|
||||
> [!WARNING]
|
||||
> When operating this project as a public generative AI service or API resale service, users should first complete all required filing, licensing, content safety, real-name verification, log retention, tax, payment, and upstream authorization obligations.
|
||||
|
||||
📖 For more deployment methods, please refer to [Deployment Guide](https://docs.newapi.pro/en/docs/installation)
|
||||
|
||||
---
|
||||
@@ -189,12 +193,12 @@ docker run --name new-api -d --restart always \
|
||||
| 📈 Data Dashboard | Visual console and statistical analysis |
|
||||
| 🔒 Permission Management | Token grouping, model restrictions, user management |
|
||||
|
||||
### 💰 Payment and Billing
|
||||
### 💰 Authorized Usage Accounting and Billing
|
||||
|
||||
- ✅ Online recharge (EPay, Stripe)
|
||||
- ✅ Pay-per-use model pricing
|
||||
- ✅ Cache billing support (OpenAI, Azure, DeepSeek, Claude, Qwen and all supported models)
|
||||
- ✅ Flexible billing policy configuration
|
||||
- ✅ Internal top-up and quota allocation for lawful authorized scenarios (EPay, Stripe)
|
||||
- ✅ Organization-level per-request, usage-based, and cache-hit cost accounting
|
||||
- ✅ Cache billing statistics for OpenAI, Azure, DeepSeek, Claude, Qwen, and supported models
|
||||
- ✅ Flexible billing policies for internal management or authorized enterprise customers
|
||||
|
||||
### 🔐 Authorization and Security
|
||||
|
||||
@@ -202,7 +206,7 @@ docker run --name new-api -d --restart always \
|
||||
- 🤖 LinuxDO authorization login
|
||||
- 📱 Telegram authorization login
|
||||
- 🔑 OIDC unified authentication
|
||||
- 🔍 Key quota query usage (with [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool))
|
||||
- 🔍 Key quota query usage (with [new-api-key-tool](https://github.com/Calcium-Ion/new-api-key-tool))
|
||||
|
||||
### 🚀 Advanced Features
|
||||
|
||||
@@ -254,7 +258,7 @@ docker run --name new-api -d --restart always \
|
||||
|
||||
## 🤖 Model Support
|
||||
|
||||
> For details, please refer to [API Documentation - Relay Interface](https://docs.newapi.pro/en/docs/api)
|
||||
> For details, please refer to [API Documentation - Gateway Interface](https://docs.newapi.pro/en/docs/api)
|
||||
|
||||
| Model Type | Description | Documentation |
|
||||
|---------|------|------|
|
||||
@@ -266,7 +270,7 @@ docker run --name new-api -d --restart always \
|
||||
| 💬 Claude | Messages format | [Documentation](https://docs.newapi.pro/en/docs/api/ai-model/chat/createmessage) |
|
||||
| 🌐 Gemini | Google Gemini format | [Documentation](https://docs.newapi.pro/en/docs/api/ai-model/chat/gemini/geminirelayv1beta) |
|
||||
| 🔧 Dify | ChatFlow mode | - |
|
||||
| 🎯 Custom | Supports complete call address | - |
|
||||
| 🎯 Custom upstream | Supports configuring legally authorized upstream endpoints | - |
|
||||
|
||||
### 📡 Supported Interfaces
|
||||
|
||||
@@ -416,7 +420,7 @@ docker run --name new-api -d --restart always \
|
||||
|
||||
| Project | Description |
|
||||
|------|------|
|
||||
| [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool) | Key quota query tool |
|
||||
| [new-api-key-tool](https://github.com/Calcium-Ion/new-api-key-tool) | Key quota query tool |
|
||||
| [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon) | New API high-performance optimized version |
|
||||
|
||||
---
|
||||
@@ -447,6 +451,14 @@ Welcome all forms of contribution!
|
||||
|
||||
This project is licensed under the [GNU Affero General Public License v3.0 (AGPLv3)](./LICENSE).
|
||||
|
||||
Additional terms under AGPLv3 Section 7 apply. Modified versions must preserve
|
||||
the author attribution notice `Frontend design and development by New API
|
||||
contributors.` in the appropriate legal notices and in any prominent about,
|
||||
legal, footer, or attribution location presented by the user interface.
|
||||
|
||||
Modified versions that present a user interface must also preserve a visible
|
||||
link to the original project: <https://github.com/QuantumNous/new-api>.
|
||||
|
||||
This is an open-source project developed based on [One API](https://github.com/songquanpeng/one-api) (MIT License).
|
||||
|
||||
If your organization's policies do not permit the use of AGPLv3-licensed software, or if you wish to avoid the open-source obligations of AGPLv3, please contact us at: [support@quantumnous.com](mailto:support@quantumnous.com)
|
||||
|
||||
+16
-12
@@ -55,9 +55,10 @@
|
||||
## 📝 项目说明
|
||||
|
||||
> [!IMPORTANT]
|
||||
> - 本项目仅供个人学习使用,不保证稳定性,且不提供任何技术支持
|
||||
> - 使用者必须在遵循 OpenAI 的 [使用条款](https://openai.com/policies/terms-of-use) 以及**法律法规**的情况下使用,不得用于非法用途
|
||||
> - 根据 [《生成式人工智能服务管理暂行办法》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm) 的要求,请勿对中国地区公众提供一切未经备案的生成式人工智能服务
|
||||
> - 本项目仅面向合法授权的 AI API 网关、组织内部鉴权、多模型管理、用量统计、成本核算和私有化部署场景。
|
||||
> - 使用者必须合法取得上游 API Key、账号、模型服务或接口权限,并遵守上游服务条款及适用法律法规。
|
||||
> - 使用者应确保其使用方式符合上游服务条款及适用法律法规。
|
||||
> - 面向公众提供生成式人工智能服务时,使用者应遵守[《生成式人工智能服务管理暂行办法》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm)等监管要求,自行完成所在司法辖区要求的备案、许可、内容安全、实名、日志留存、税务和上游授权等合规义务。
|
||||
|
||||
---
|
||||
|
||||
@@ -151,6 +152,9 @@ docker run --name new-api -d --restart always \
|
||||
|
||||
🎉 部署完成后,访问 `http://localhost:3000` 即可使用!
|
||||
|
||||
> [!WARNING]
|
||||
> 将本项目作为面向公众的生成式 AI 服务或 API 转售服务运营时,使用者应先完成备案、内容安全、实名、日志留存、税务、支付和上游授权等合规义务。
|
||||
|
||||
📖 更多部署方式请参考 [部署指南](https://docs.newapi.pro/zh/docs/installation)
|
||||
|
||||
---
|
||||
@@ -189,12 +193,12 @@ docker run --name new-api -d --restart always \
|
||||
| 📈 数据看板 | 可视化控制台与统计分析 |
|
||||
| 🔒 权限管理 | 令牌分组、模型限制、用户管理 |
|
||||
|
||||
### 💰 支付与计费
|
||||
### 💰 授权用量与成本管理
|
||||
|
||||
- ✅ 在线充值(易支付、Stripe)
|
||||
- ✅ 模型按次数收费
|
||||
- ✅ 缓存计费支持(OpenAI、Azure、DeepSeek、Claude、Qwen等所有支持的模型)
|
||||
- ✅ 灵活的计费策略配置
|
||||
- ✅ 合法授权场景下的内部充值与额度分配(易支付、Stripe)
|
||||
- ✅ 组织内按次、按量或缓存命中成本核算
|
||||
- ✅ 支持 OpenAI、Azure、DeepSeek、Claude、Qwen 等模型的缓存计费统计
|
||||
- ✅ 面向内部管理或企业客户的灵活计费策略配置
|
||||
|
||||
### 🔐 授权与安全
|
||||
|
||||
@@ -202,7 +206,7 @@ docker run --name new-api -d --restart always \
|
||||
- 🤖 LinuxDO 授权登录
|
||||
- 📱 Telegram 授权登录
|
||||
- 🔑 OIDC 统一认证
|
||||
- 🔍 Key 查询使用额度(配合 [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool))
|
||||
- 🔍 Key 查询使用额度(配合 [new-api-key-tool](https://github.com/Calcium-Ion/new-api-key-tool))
|
||||
|
||||
### 🚀 高级功能
|
||||
|
||||
@@ -254,7 +258,7 @@ docker run --name new-api -d --restart always \
|
||||
|
||||
## 🤖 模型支持
|
||||
|
||||
> 详情请参考 [接口文档 - 中继接口](https://docs.newapi.pro/zh/docs/api)
|
||||
> 详情请参考 [接口文档 - 网关接口](https://docs.newapi.pro/zh/docs/api)
|
||||
|
||||
| 模型类型 | 说明 | 文档 |
|
||||
|---------|------|------|
|
||||
@@ -266,7 +270,7 @@ docker run --name new-api -d --restart always \
|
||||
| 💬 Claude | Messages 格式 | [文档](https://docs.newapi.pro/zh/docs/api/ai-model/chat/createmessage) |
|
||||
| 🌐 Gemini | Google Gemini 格式 | [文档](https://docs.newapi.pro/zh/docs/api/ai-model/chat/gemini/geminirelayv1beta) |
|
||||
| 🔧 Dify | ChatFlow 模式 | - |
|
||||
| 🎯 自定义 | 支持完整调用地址 | - |
|
||||
| 🎯 自定义上游 | 支持配置合法授权的上游接口地址 | - |
|
||||
|
||||
### 📡 支持的接口
|
||||
|
||||
@@ -416,7 +420,7 @@ docker run --name new-api -d --restart always \
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool) | Key 额度查询工具 |
|
||||
| [new-api-key-tool](https://github.com/Calcium-Ion/new-api-key-tool) | Key 额度查询工具 |
|
||||
| [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon) | New API 高性能优化版 |
|
||||
|
||||
---
|
||||
|
||||
+16
-12
@@ -55,9 +55,10 @@
|
||||
## 📝 項目說明
|
||||
|
||||
> [!IMPORTANT]
|
||||
> - 本項目僅供個人學習使用,不保證穩定性,且不提供任何技術支援
|
||||
> - 使用者必須在遵循 OpenAI 的 [使用條款](https://openai.com/policies/terms-of-use) 以及**法律法規**的情況下使用,不得用於非法用途
|
||||
> - 根據 [《生成式人工智慧服務管理暫行辦法》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm) 的要求,請勿對中國地區公眾提供一切未經備案的生成式人工智慧服務
|
||||
> - 本專案僅面向合法授權的 AI API 閘道、組織內部鑑權、多模型管理、用量統計、成本核算和私有化部署場景。
|
||||
> - 使用者必須合法取得上游 API Key、帳號、模型服務或介面權限,並遵守上游服務條款及適用法律法規。
|
||||
> - 使用者應確保其使用方式符合上游服務條款及適用法律法規。
|
||||
> - 面向公眾提供生成式人工智慧服務時,使用者應遵守[《生成式人工智慧服務管理暫行辦法》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm)等監管要求,自行完成所在司法轄區要求的備案、許可、內容安全、實名、日誌留存、稅務和上游授權等合規義務。
|
||||
|
||||
---
|
||||
|
||||
@@ -151,6 +152,9 @@ docker run --name new-api -d --restart always \
|
||||
|
||||
🎉 部署完成後,訪問 `http://localhost:3000` 即可使用!
|
||||
|
||||
> [!WARNING]
|
||||
> 將本專案作為面向公眾的生成式 AI 服務或 API 轉售服務運營時,使用者應先完成備案、內容安全、實名、日誌留存、稅務、支付和上游授權等合規義務。
|
||||
|
||||
📖 更多部署方式請參考 [部署指南](https://docs.newapi.pro/zh/docs/installation)
|
||||
|
||||
---
|
||||
@@ -189,12 +193,12 @@ docker run --name new-api -d --restart always \
|
||||
| 📈 數據看板 | 視覺化控制檯與統計分析 |
|
||||
| 🔒 權限管理 | 令牌分組、模型限制、用戶管理 |
|
||||
|
||||
### 💰 支付與計費
|
||||
### 💰 授權用量與成本管理
|
||||
|
||||
- ✅ 在線儲值(易支付、Stripe)
|
||||
- ✅ 模型按次數收費
|
||||
- ✅ 快取計費支援(OpenAI、Azure、DeepSeek、Claude、Qwen等所有支援的模型)
|
||||
- ✅ 靈活的計費策略配置
|
||||
- ✅ 合法授權場景下的內部儲值與額度分配(易支付、Stripe)
|
||||
- ✅ 組織內按次、按量或快取命中成本核算
|
||||
- ✅ 支援 OpenAI、Azure、DeepSeek、Claude、Qwen 等模型的快取計費統計
|
||||
- ✅ 面向內部管理或企業客戶的靈活計費策略配置
|
||||
|
||||
### 🔐 授權與安全
|
||||
|
||||
@@ -202,7 +206,7 @@ docker run --name new-api -d --restart always \
|
||||
- 🤖 LinuxDO 授權登錄
|
||||
- 📱 Telegram 授權登錄
|
||||
- 🔑 OIDC 統一認證
|
||||
- 🔍 Key 查詢使用額度(配合 [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool))
|
||||
- 🔍 Key 查詢使用額度(配合 [new-api-key-tool](https://github.com/Calcium-Ion/new-api-key-tool))
|
||||
|
||||
### 🚀 高級功能
|
||||
|
||||
@@ -254,7 +258,7 @@ docker run --name new-api -d --restart always \
|
||||
|
||||
## 🤖 模型支援
|
||||
|
||||
> 詳情請參考 [接口文件 - 中繼接口](https://docs.newapi.pro/zh/docs/api)
|
||||
> 詳情請參考 [接口文件 - 閘道接口](https://docs.newapi.pro/zh/docs/api)
|
||||
|
||||
| 模型類型 | 說明 | 文件 |
|
||||
|---------|------|------|
|
||||
@@ -266,7 +270,7 @@ docker run --name new-api -d --restart always \
|
||||
| 💬 Claude | Messages 格式 | [文件](https://docs.newapi.pro/zh/docs/api/ai-model/chat/createmessage) |
|
||||
| 🌐 Gemini | Google Gemini 格式 | [文件](https://docs.newapi.pro/zh/docs/api/ai-model/chat/gemini/geminirelayv1beta) |
|
||||
| 🔧 Dify | ChatFlow 模式 | - |
|
||||
| 🎯 自訂 | 支援完整調用位址 | - |
|
||||
| 🎯 自訂上游 | 支援配置合法授權的上游介面位址 | - |
|
||||
|
||||
### 📡 支援的接口
|
||||
|
||||
@@ -416,7 +420,7 @@ docker run --name new-api -d --restart always \
|
||||
|
||||
| 項目 | 說明 |
|
||||
|------|------|
|
||||
| [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool) | Key 額度查詢工具 |
|
||||
| [new-api-key-tool](https://github.com/Calcium-Ion/new-api-key-tool) | Key 額度查詢工具 |
|
||||
| [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon) | New API 高性能優化版 |
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
# Third-Party Licenses
|
||||
|
||||
This file summarizes direct third-party dependencies used by distributed builds of this project.
|
||||
It is an engineering compliance artifact and should be kept with Docker images, standalone binaries, frontend bundles, and Electron installers.
|
||||
|
||||
Scope: direct dependencies from `go.mod`, `web/default/package.json`, `web/classic/package.json`, and `electron/package.json`.
|
||||
Transitive dependencies should be audited before a final external release.
|
||||
|
||||
## Dependency Inventory
|
||||
|
||||
| Area | Scope | Ecosystem | Dependency | Version | License |
|
||||
|-------------|-------------|-----------|-------------------------------------------------------|--------------------------------------|----------------------------------------------------|
|
||||
| backend | production | Go | `github.com/Calcium-Ion/go-epay` | `v0.0.4` | Proprietary/Internal - owned by project maintainer |
|
||||
| backend | production | Go | `github.com/abema/go-mp4` | `v1.4.1` | MIT |
|
||||
| backend | production | Go | `github.com/andybalholm/brotli` | `v1.1.1` | MIT |
|
||||
| backend | production | Go | `github.com/anknown/ahocorasick` | `v0.0.0-20190904063843-d75dbd5169c0` | MIT |
|
||||
| backend | production | Go | `github.com/aws/aws-sdk-go-v2` | `v1.41.5` | Apache-2.0 |
|
||||
| backend | production | Go | `github.com/aws/aws-sdk-go-v2/credentials` | `v1.19.10` | Apache-2.0 |
|
||||
| backend | production | Go | `github.com/aws/aws-sdk-go-v2/service/bedrockruntime` | `v1.50.4` | Apache-2.0 |
|
||||
| backend | production | Go | `github.com/aws/smithy-go` | `v1.24.2` | Apache-2.0 |
|
||||
| backend | production | Go | `github.com/bytedance/gopkg` | `v0.1.3` | Apache-2.0 |
|
||||
| backend | production | Go | `github.com/gin-contrib/cors` | `v1.7.2` | MIT |
|
||||
| backend | production | Go | `github.com/gin-contrib/gzip` | `v0.0.6` | MIT |
|
||||
| backend | production | Go | `github.com/gin-contrib/sessions` | `v0.0.5` | MIT |
|
||||
| backend | production | Go | `github.com/gin-contrib/static` | `v0.0.1` | MIT |
|
||||
| backend | production | Go | `github.com/gin-gonic/gin` | `v1.9.1` | MIT |
|
||||
| backend | production | Go | `github.com/glebarez/sqlite` | `v1.9.0` | MIT |
|
||||
| backend | production | Go | `github.com/go-audio/aiff` | `v1.1.0` | Apache-2.0 |
|
||||
| backend | production | Go | `github.com/go-audio/wav` | `v1.1.0` | Apache-2.0 |
|
||||
| backend | production | Go | `github.com/go-playground/validator/v10` | `v10.20.0` | MIT |
|
||||
| backend | production | Go | `github.com/go-redis/redis/v8` | `v8.11.5` | BSD-2-Clause |
|
||||
| backend | production | Go | `github.com/go-webauthn/webauthn` | `v0.14.0` | BSD-3-Clause |
|
||||
| backend | production | Go | `github.com/golang-jwt/jwt/v5` | `v5.3.0` | MIT |
|
||||
| backend | production | Go | `github.com/google/uuid` | `v1.6.0` | BSD-3-Clause |
|
||||
| backend | production | Go | `github.com/gorilla/websocket` | `v1.5.0` | BSD-2-Clause |
|
||||
| backend | production | Go | `github.com/grafana/pyroscope-go` | `v1.2.7` | Apache-2.0 |
|
||||
| backend | production | Go | `github.com/jfreymuth/oggvorbis` | `v1.0.5` | MIT |
|
||||
| backend | production | Go | `github.com/jinzhu/copier` | `v0.4.0` | MIT |
|
||||
| backend | production | Go | `github.com/joho/godotenv` | `v1.5.1` | MIT |
|
||||
| backend | production | Go | `github.com/mewkiz/flac` | `v1.0.13` | Unlicense |
|
||||
| backend | production | Go | `github.com/nicksnyder/go-i18n/v2` | `v2.6.1` | MIT |
|
||||
| backend | production | Go | `github.com/pkg/errors` | `v0.9.1` | BSD-2-Clause |
|
||||
| backend | production | Go | `github.com/pquerna/otp` | `v1.5.0` | Apache-2.0 |
|
||||
| backend | production | Go | `github.com/samber/hot` | `v0.11.0` | MIT |
|
||||
| backend | production | Go | `github.com/samber/lo` | `v1.52.0` | MIT |
|
||||
| backend | production | Go | `github.com/shirou/gopsutil` | `v3.21.11+incompatible` | BSD-3-Clause |
|
||||
| backend | production | Go | `github.com/shopspring/decimal` | `v1.4.0` | MIT |
|
||||
| backend | production | Go | `github.com/stretchr/testify` | `v1.11.1` | MIT |
|
||||
| backend | production | Go | `github.com/stripe/stripe-go/v81` | `v81.4.0` | MIT |
|
||||
| backend | production | Go | `github.com/tcolgate/mp3` | `v0.0.0-20170426193717-e79c5a46d300` | MIT |
|
||||
| backend | production | Go | `github.com/thanhpk/randstr` | `v1.0.6` | MIT |
|
||||
| backend | production | Go | `github.com/tidwall/gjson` | `v1.18.0` | MIT |
|
||||
| backend | production | Go | `github.com/tidwall/sjson` | `v1.2.5` | MIT |
|
||||
| backend | production | Go | `github.com/tiktoken-go/tokenizer` | `v0.6.2` | MIT |
|
||||
| backend | production | Go | `github.com/waffo-com/waffo-go` | `v1.3.1` | MIT |
|
||||
| backend | production | Go | `github.com/yapingcat/gomedia` | `v0.0.0-20240906162731-17feea57090c` | MIT |
|
||||
| backend | production | Go | `golang.org/x/crypto` | `v0.45.0` | BSD-3-Clause |
|
||||
| backend | production | Go | `golang.org/x/image` | `v0.38.0` | BSD-3-Clause |
|
||||
| backend | production | Go | `golang.org/x/net` | `v0.47.0` | BSD-3-Clause |
|
||||
| backend | production | Go | `golang.org/x/sync` | `v0.20.0` | BSD-3-Clause |
|
||||
| backend | production | Go | `golang.org/x/sys` | `v0.38.0` | BSD-3-Clause |
|
||||
| backend | production | Go | `golang.org/x/text` | `v0.35.0` | BSD-3-Clause |
|
||||
| backend | production | Go | `gopkg.in/yaml.v3` | `v3.0.1` | Apache-2.0 OR MIT |
|
||||
| backend | production | Go | `gorm.io/driver/mysql` | `v1.4.3` | MIT |
|
||||
| backend | production | Go | `gorm.io/driver/postgres` | `v1.5.2` | MIT |
|
||||
| backend | production | Go | `gorm.io/gorm` | `v1.25.2` | MIT |
|
||||
| backend | production | Go | `github.com/expr-lang/expr` | `v1.17.8` | MIT |
|
||||
| web/default | production | npm | `@base-ui/react` | `1.4.1` | MIT |
|
||||
| web/default | production | npm | `@fontsource-variable/public-sans` | `5.2.7` | OFL-1.1 |
|
||||
| web/default | production | npm | `@hookform/resolvers` | `5.2.2` | MIT |
|
||||
| web/default | production | npm | `@hugeicons/core-free-icons` | `4.1.1` | MIT |
|
||||
| web/default | production | npm | `@hugeicons/react` | `1.1.6` | MIT |
|
||||
| web/default | production | npm | `@lobehub/icons` | `4.12.0` | MIT |
|
||||
| web/default | production | npm | `@tailwindcss/postcss` | `4.2.2` | MIT |
|
||||
| web/default | production | npm | `@tanstack/react-query` | `5.97.0` | MIT |
|
||||
| web/default | production | npm | `@tanstack/react-router` | `1.168.23` | MIT |
|
||||
| web/default | production | npm | `@tanstack/react-table` | `8.21.3` | MIT |
|
||||
| web/default | production | npm | `@tanstack/react-virtual` | `3.13.23` | MIT |
|
||||
| web/default | production | npm | `@visactor/react-vchart` | `2.0.21` | MIT |
|
||||
| web/default | production | npm | `@visactor/vchart` | `2.0.21` | MIT |
|
||||
| web/default | production | npm | `ai` | `6.0.158` | Apache-2.0 |
|
||||
| web/default | production | npm | `auto-skeleton-react` | `1.0.5` | MIT |
|
||||
| web/default | production | npm | `axios` | `1.15.0` | MIT |
|
||||
| web/default | production | npm | `class-variance-authority` | `0.7.1` | Apache-2.0 |
|
||||
| web/default | production | npm | `clsx` | `2.1.1` | MIT |
|
||||
| web/default | production | npm | `cmdk` | `1.1.1` | MIT |
|
||||
| web/default | production | npm | `date-fns` | `4.1.0` | MIT |
|
||||
| web/default | production | npm | `dayjs` | `1.11.20` | MIT |
|
||||
| web/default | production | npm | `i18next` | `25.10.10` | MIT |
|
||||
| web/default | production | npm | `i18next-browser-languagedetector` | `8.2.1` | MIT |
|
||||
| web/default | production | npm | `input-otp` | `1.4.2` | MIT |
|
||||
| web/default | production | npm | `lucide-react` | `1.8.0` | ISC |
|
||||
| web/default | production | npm | `motion` | `12.38.0` | MIT |
|
||||
| web/default | production | npm | `nanoid` | `5.1.7` | MIT |
|
||||
| web/default | production | npm | `next-themes` | `0.4.6` | MIT |
|
||||
| web/default | production | npm | `qrcode.react` | `4.2.0` | ISC |
|
||||
| web/default | production | npm | `react` | `19.2.5` | MIT |
|
||||
| web/default | production | npm | `react-day-picker` | `9.14.0` | MIT |
|
||||
| web/default | production | npm | `react-dom` | `19.2.5` | MIT |
|
||||
| web/default | production | npm | `react-hook-form` | `7.72.1` | MIT |
|
||||
| web/default | production | npm | `react-i18next` | `16.6.6` | MIT |
|
||||
| web/default | production | npm | `react-icons` | `5.6.0` | MIT |
|
||||
| web/default | production | npm | `react-markdown` | `10.1.0` | MIT |
|
||||
| web/default | production | npm | `react-resizable-panels` | `4.11.0` | MIT |
|
||||
| web/default | production | npm | `react-top-loading-bar` | `3.0.2` | MIT |
|
||||
| web/default | production | npm | `recharts` | `3.8.0` | MIT |
|
||||
| web/default | production | npm | `rehype-raw` | `7.0.0` | MIT |
|
||||
| web/default | production | npm | `remark-gfm` | `4.0.1` | MIT |
|
||||
| web/default | production | npm | `shiki` | `4.0.2` | MIT |
|
||||
| web/default | production | npm | `sonner` | `2.0.7` | MIT |
|
||||
| web/default | production | npm | `sse.js` | `2.8.0` | Apache-2.0 |
|
||||
| web/default | production | npm | `streamdown` | `2.5.0` | Apache-2.0 |
|
||||
| web/default | production | npm | `tailwind-merge` | `3.5.0` | MIT |
|
||||
| web/default | production | npm | `tailwindcss` | `4.2.2` | MIT |
|
||||
| web/default | production | npm | `tokenlens` | `1.3.1` | MIT |
|
||||
| web/default | production | npm | `tw-animate-css` | `1.4.0` | MIT |
|
||||
| web/default | production | npm | `use-stick-to-bottom` | `1.1.3` | MIT |
|
||||
| web/default | production | npm | `vaul` | `1.1.2` | MIT |
|
||||
| web/default | production | npm | `zod` | `4.3.6` | MIT |
|
||||
| web/default | production | npm | `zustand` | `5.0.12` | MIT |
|
||||
| web/default | development | npm | `@eslint/js` | `10.0.1` | MIT |
|
||||
| web/default | development | npm | `@rsbuild/core` | `2.0.1` | MIT |
|
||||
| web/default | development | npm | `@rsbuild/plugin-react` | `2.0.0` | MIT |
|
||||
| web/default | development | npm | `@tanstack/eslint-plugin-query` | `5.97.0` | MIT |
|
||||
| web/default | development | npm | `@tanstack/react-query-devtools` | `5.97.0` | MIT |
|
||||
| web/default | development | npm | `@tanstack/react-router-devtools` | `1.166.13` | MIT |
|
||||
| web/default | development | npm | `@tanstack/router-plugin` | `1.167.23` | MIT |
|
||||
| web/default | development | npm | `@trivago/prettier-plugin-sort-imports` | `6.0.2` | Apache-2.0 |
|
||||
| web/default | development | npm | `@types/node` | `25.6.0` | MIT |
|
||||
| web/default | development | npm | `@types/react` | `19.2.14` | MIT |
|
||||
| web/default | development | npm | `@types/react-dom` | `19.2.3` | MIT |
|
||||
| web/default | development | npm | `@xyflow/react` | `12.10.2` | MIT |
|
||||
| web/default | development | npm | `embla-carousel-react` | `8.6.0` | MIT |
|
||||
| web/default | development | npm | `eslint` | `10.2.0` | MIT |
|
||||
| web/default | development | npm | `eslint-plugin-react-hooks` | `7.0.1` | MIT |
|
||||
| web/default | development | npm | `eslint-plugin-react-refresh` | `0.5.2` | MIT |
|
||||
| web/default | development | npm | `globals` | `17.4.0` | MIT |
|
||||
| web/default | development | npm | `knip` | `6.3.1` | ISC |
|
||||
| web/default | development | npm | `prettier` | `3.8.2` | MIT |
|
||||
| web/default | development | npm | `prettier-plugin-tailwindcss` | `0.7.2` | MIT |
|
||||
| web/default | development | npm | `shadcn` | `3.8.5` | MIT |
|
||||
| web/default | development | npm | `typescript` | `5.9.3` | Apache-2.0 |
|
||||
| web/default | development | npm | `typescript-eslint` | `8.58.1` | MIT |
|
||||
| web/classic | production | npm | `@douyinfe/semi-icons` | `2.72.2` | MIT |
|
||||
| web/classic | production | npm | `@douyinfe/semi-ui` | `2.72.2` | MIT |
|
||||
| web/classic | production | npm | `@lobehub/icons` | `2.1.0` | MIT |
|
||||
| web/classic | production | npm | `@visactor/react-vchart` | `1.8.11` | MIT |
|
||||
| web/classic | production | npm | `@visactor/vchart` | `1.8.11` | MIT |
|
||||
| web/classic | production | npm | `@visactor/vchart-semi-theme` | `1.8.8` | MIT |
|
||||
| web/classic | production | npm | `axios` | `1.15.0` | MIT |
|
||||
| web/classic | production | npm | `clsx` | `2.1.1` | MIT |
|
||||
| web/classic | production | npm | `dayjs` | `1.11.13` | MIT |
|
||||
| web/classic | production | npm | `history` | `5.3.0` | MIT |
|
||||
| web/classic | production | npm | `i18next` | `23.16.8` | MIT |
|
||||
| web/classic | production | npm | `i18next-browser-languagedetector` | `7.2.2` | MIT |
|
||||
| web/classic | production | npm | `katex` | `0.16.22` | MIT |
|
||||
| web/classic | production | npm | `lucide-react` | `0.511.0` | ISC |
|
||||
| web/classic | production | npm | `marked` | `4.3.0` | MIT |
|
||||
| web/classic | production | npm | `mermaid` | `11.6.0` | MIT |
|
||||
| web/classic | production | npm | `qrcode.react` | `4.2.0` | ISC |
|
||||
| web/classic | production | npm | `react` | `18.3.1` | MIT |
|
||||
| web/classic | production | npm | `react-dom` | `18.3.1` | MIT |
|
||||
| web/classic | production | npm | `react-dropzone` | `14.3.5` | MIT |
|
||||
| web/classic | production | npm | `react-fireworks` | `1.0.4` | ISC |
|
||||
| web/classic | production | npm | `react-i18next` | `13.5.0` | MIT |
|
||||
| web/classic | production | npm | `react-icons` | `5.5.0` | MIT |
|
||||
| web/classic | production | npm | `react-markdown` | `10.1.0` | MIT |
|
||||
| web/classic | production | npm | `react-router-dom` | `6.28.1` | MIT |
|
||||
| web/classic | production | npm | `react-telegram-login` | `1.1.2` | MIT |
|
||||
| web/classic | production | npm | `react-toastify` | `9.1.3` | MIT |
|
||||
| web/classic | production | npm | `react-turnstile` | `1.1.4` | MIT |
|
||||
| web/classic | production | npm | `rehype-highlight` | `7.0.2` | MIT |
|
||||
| web/classic | production | npm | `rehype-katex` | `7.0.1` | MIT |
|
||||
| web/classic | production | npm | `remark-breaks` | `4.0.0` | MIT |
|
||||
| web/classic | production | npm | `remark-gfm` | `4.0.1` | MIT |
|
||||
| web/classic | production | npm | `remark-math` | `6.0.0` | MIT |
|
||||
| web/classic | production | npm | `sse.js` | `2.6.0` | Apache-2.0 |
|
||||
| web/classic | production | npm | `unist-util-visit` | `5.0.0` | MIT |
|
||||
| web/classic | production | npm | `use-debounce` | `10.0.4` | MIT |
|
||||
| web/classic | development | npm | `@douyinfe/vite-plugin-semi` | `2.74.0-alpha.6` | MIT |
|
||||
| web/classic | development | npm | `@so1ve/prettier-config` | `3.1.0` | MIT |
|
||||
| web/classic | development | npm | `@vitejs/plugin-react` | `4.3.4` | MIT |
|
||||
| web/classic | development | npm | `autoprefixer` | `10.4.21` | MIT |
|
||||
| web/classic | development | npm | `code-inspector-plugin` | `1.3.3` | MIT |
|
||||
| web/classic | development | npm | `eslint` | `8.57.0` | MIT |
|
||||
| web/classic | development | npm | `eslint-plugin-header` | `3.1.1` | MIT |
|
||||
| web/classic | development | npm | `eslint-plugin-react-hooks` | `5.2.0` | MIT |
|
||||
| web/classic | development | npm | `i18next-cli` | `1.15.0` | MIT |
|
||||
| web/classic | development | npm | `postcss` | `8.5.3` | MIT |
|
||||
| web/classic | development | npm | `prettier` | `3.4.2` | MIT |
|
||||
| web/classic | development | npm | `tailwindcss` | `3.4.17` | MIT |
|
||||
| web/classic | development | npm | `typescript` | `4.4.2` | Apache-2.0 |
|
||||
| web/classic | development | npm | `vite` | `5.4.11` | MIT |
|
||||
| electron | development | npm | `cross-env` | `7.0.3` | MIT |
|
||||
| electron | development | npm | `electron` | `39.8.5` | MIT |
|
||||
| electron | development | npm | `electron-builder` | `26.7.0` | MIT |
|
||||
|
||||
## License Texts
|
||||
|
||||
### Apache-2.0
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
https://www.apache.org/licenses/
|
||||
|
||||
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:
|
||||
|
||||
https://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.
|
||||
|
||||
### Apache-2.0 OR MIT
|
||||
|
||||
Dual-licensed components may be used under Apache-2.0 or MIT. Both standard license texts are included below.
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
https://www.apache.org/licenses/
|
||||
|
||||
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:
|
||||
|
||||
https://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.
|
||||
|
||||
MIT License
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
### BSD-2-Clause
|
||||
|
||||
BSD 2-Clause License
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
### BSD-3-Clause
|
||||
|
||||
BSD 3-Clause License
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
3. Neither the name of the copyright holder nor the names of its contributors
|
||||
may be used to endorse or promote products derived from this software
|
||||
without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
### ISC
|
||||
|
||||
ISC License
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any
|
||||
purpose with or without fee is hereby granted, provided that the above
|
||||
copyright notice and this permission notice appear in all copies.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
### MIT
|
||||
|
||||
MIT License
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
### OFL-1.1
|
||||
|
||||
SIL Open Font License 1.1
|
||||
|
||||
The font dependency listed under OFL-1.1 is licensed under the SIL Open Font
|
||||
License, Version 1.1. The full license text is available at:
|
||||
https://openfontlicense.org/open-font-license-official-text/
|
||||
|
||||
When distributing font files, preserve the OFL license text, copyright notices,
|
||||
and reserved font name restrictions supplied by the upstream font project.
|
||||
|
||||
### Proprietary/Internal - owned by project maintainer
|
||||
|
||||
This dependency is owned by the project maintainer and is not treated as a third-party open source dependency for this review.
|
||||
|
||||
### Unlicense
|
||||
|
||||
The Unlicense
|
||||
|
||||
This is free and unencumbered software released into the public domain.
|
||||
Anyone is free to copy, modify, publish, use, compile, sell, or distribute
|
||||
this software, either in source code form or as a compiled binary, for any
|
||||
purpose, commercial or non-commercial, and by any means.
|
||||
|
||||
For more information, please refer to https://unlicense.org/
|
||||
|
||||
+23
-1
@@ -4,6 +4,7 @@ import (
|
||||
"crypto/tls"
|
||||
//"os"
|
||||
//"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
@@ -36,6 +37,26 @@ func SetTheme(t string) {
|
||||
}
|
||||
}
|
||||
|
||||
// ThemeAwarePath rewrites legacy /console/* paths to the default-theme
|
||||
// equivalents when the active theme is "default". For "classic" (or any
|
||||
// other theme) the path is returned unchanged. The function only touches
|
||||
// known prefixes so it is safe to call with arbitrary suffixes and query
|
||||
// strings.
|
||||
func ThemeAwarePath(suffix string) string {
|
||||
if GetTheme() != "default" {
|
||||
return suffix
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(suffix, "/console/topup"):
|
||||
return strings.Replace(suffix, "/console/topup", "/wallet", 1)
|
||||
case strings.HasPrefix(suffix, "/console/log"):
|
||||
return strings.Replace(suffix, "/console/log", "/usage-logs", 1)
|
||||
case strings.HasPrefix(suffix, "/console/personal"):
|
||||
return strings.Replace(suffix, "/console/personal", "/profile", 1)
|
||||
}
|
||||
return suffix
|
||||
}
|
||||
|
||||
// var ChatLink = ""
|
||||
// var ChatLink2 = ""
|
||||
var QuotaPerUnit = 500 * 1000.0 // $0.002 / 1K tokens
|
||||
@@ -158,7 +179,8 @@ var GeminiSafetySetting string
|
||||
var CohereSafetySetting string
|
||||
|
||||
const (
|
||||
RequestIdKey = "X-Oneapi-Request-Id"
|
||||
RequestIdKey = "X-Oneapi-Request-Id"
|
||||
UpstreamRequestIdKey = "X-Upstream-Request-Id"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
+63
-39
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type OpenAIModel struct {
|
||||
@@ -68,12 +69,33 @@ func clearChannelInfo(channel *model.Channel) {
|
||||
}
|
||||
}
|
||||
|
||||
func applyChannelStatusFilter(query *gorm.DB, statusFilter int) *gorm.DB {
|
||||
if statusFilter == common.ChannelStatusEnabled {
|
||||
return query.Where("status = ?", common.ChannelStatusEnabled)
|
||||
}
|
||||
if statusFilter == 0 {
|
||||
return query.Where("status != ?", common.ChannelStatusEnabled)
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
func buildChannelListQuery(group string, statusFilter int, typeFilter int) *gorm.DB {
|
||||
query := model.DB.Model(&model.Channel{})
|
||||
query = model.ApplyChannelGroupFilter(query, group)
|
||||
query = applyChannelStatusFilter(query, statusFilter)
|
||||
if typeFilter >= 0 {
|
||||
query = query.Where("type = ?", typeFilter)
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
func GetAllChannels(c *gin.Context) {
|
||||
pageInfo := common.GetPageQuery(c)
|
||||
channelData := make([]*model.Channel, 0)
|
||||
idSort, _ := strconv.ParseBool(c.Query("id_sort"))
|
||||
sortOptions := model.NewChannelSortOptions(c.Query("sort_by"), c.Query("sort_order"), idSort)
|
||||
enableTagMode, _ := strconv.ParseBool(c.Query("tag_mode"))
|
||||
groupFilter := model.NormalizeChannelGroupFilter(c.Query("group"))
|
||||
statusParam := c.Query("status")
|
||||
// statusFilter: -1 all, 1 enabled, 0 disabled (include auto & manual)
|
||||
statusFilter := parseStatusFilter(statusParam)
|
||||
@@ -89,50 +111,45 @@ func GetAllChannels(c *gin.Context) {
|
||||
var total int64
|
||||
|
||||
if enableTagMode {
|
||||
tags, err := model.GetPaginatedTags(pageInfo.GetStartIdx(), pageInfo.GetPageSize())
|
||||
tags, err := model.GetPaginatedChannelTags(buildChannelListQuery(groupFilter, statusFilter, typeFilter), pageInfo.GetStartIdx(), pageInfo.GetPageSize())
|
||||
if err != nil {
|
||||
common.SysError("failed to get paginated tags: " + err.Error())
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取标签失败,请稍后重试"})
|
||||
return
|
||||
}
|
||||
total, err = model.CountChannelTags(buildChannelListQuery(groupFilter, statusFilter, typeFilter))
|
||||
if err != nil {
|
||||
common.SysError("failed to count tags: " + err.Error())
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取标签数量失败,请稍后重试"})
|
||||
return
|
||||
}
|
||||
for _, tag := range tags {
|
||||
if tag == nil || *tag == "" {
|
||||
continue
|
||||
}
|
||||
tagChannels, err := model.GetChannelsByTag(*tag, idSort, false, sortOptions)
|
||||
var tagChannels []*model.Channel
|
||||
err := sortOptions.Apply(buildChannelListQuery(groupFilter, statusFilter, typeFilter).Where("tag = ?", *tag)).
|
||||
Omit("key").
|
||||
Find(&tagChannels).Error
|
||||
if err != nil {
|
||||
continue
|
||||
common.SysError("failed to get channels by tag: " + err.Error())
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取标签渠道失败,请稍后重试"})
|
||||
return
|
||||
}
|
||||
filtered := make([]*model.Channel, 0)
|
||||
for _, ch := range tagChannels {
|
||||
if statusFilter == common.ChannelStatusEnabled && ch.Status != common.ChannelStatusEnabled {
|
||||
continue
|
||||
}
|
||||
if statusFilter == 0 && ch.Status == common.ChannelStatusEnabled {
|
||||
continue
|
||||
}
|
||||
if typeFilter >= 0 && ch.Type != typeFilter {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, ch)
|
||||
}
|
||||
channelData = append(channelData, filtered...)
|
||||
channelData = append(channelData, tagChannels...)
|
||||
}
|
||||
total, _ = model.CountAllTags()
|
||||
} else {
|
||||
baseQuery := model.DB.Model(&model.Channel{})
|
||||
if typeFilter >= 0 {
|
||||
baseQuery = baseQuery.Where("type = ?", typeFilter)
|
||||
}
|
||||
if statusFilter == common.ChannelStatusEnabled {
|
||||
baseQuery = baseQuery.Where("status = ?", common.ChannelStatusEnabled)
|
||||
} else if statusFilter == 0 {
|
||||
baseQuery = baseQuery.Where("status != ?", common.ChannelStatusEnabled)
|
||||
if err := buildChannelListQuery(groupFilter, statusFilter, typeFilter).Count(&total).Error; err != nil {
|
||||
common.SysError("failed to count channels: " + err.Error())
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取渠道数量失败,请稍后重试"})
|
||||
return
|
||||
}
|
||||
|
||||
baseQuery.Count(&total)
|
||||
|
||||
err := sortOptions.Apply(baseQuery).Limit(pageInfo.GetPageSize()).Offset(pageInfo.GetStartIdx()).Omit("key").Find(&channelData).Error
|
||||
err := sortOptions.Apply(buildChannelListQuery(groupFilter, statusFilter, typeFilter)).
|
||||
Limit(pageInfo.GetPageSize()).
|
||||
Offset(pageInfo.GetStartIdx()).
|
||||
Omit("key").
|
||||
Find(&channelData).Error
|
||||
if err != nil {
|
||||
common.SysError("failed to get channels: " + err.Error())
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取渠道列表失败,请稍后重试"})
|
||||
@@ -144,17 +161,16 @@ func GetAllChannels(c *gin.Context) {
|
||||
clearChannelInfo(datum)
|
||||
}
|
||||
|
||||
countQuery := model.DB.Model(&model.Channel{})
|
||||
if statusFilter == common.ChannelStatusEnabled {
|
||||
countQuery = countQuery.Where("status = ?", common.ChannelStatusEnabled)
|
||||
} else if statusFilter == 0 {
|
||||
countQuery = countQuery.Where("status != ?", common.ChannelStatusEnabled)
|
||||
}
|
||||
countQuery := buildChannelListQuery(groupFilter, statusFilter, -1)
|
||||
var results []struct {
|
||||
Type int64
|
||||
Count int64
|
||||
}
|
||||
_ = countQuery.Select("type, count(*) as count").Group("type").Find(&results).Error
|
||||
if err := countQuery.Select("type, count(*) as count").Group("type").Find(&results).Error; err != nil {
|
||||
common.SysError("failed to count channel types: " + err.Error())
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取渠道类型统计失败,请稍后重试"})
|
||||
return
|
||||
}
|
||||
typeCounts := make(map[int64]int64)
|
||||
for _, r := range results {
|
||||
typeCounts[r.Type] = r.Count
|
||||
@@ -262,10 +278,18 @@ func SearchChannels(c *gin.Context) {
|
||||
}
|
||||
for _, tag := range tags {
|
||||
if tag != nil && *tag != "" {
|
||||
tagChannel, err := model.GetChannelsByTag(*tag, idSort, false, sortOptions)
|
||||
if err == nil {
|
||||
channelData = append(channelData, tagChannel...)
|
||||
var tagChannels []*model.Channel
|
||||
err := sortOptions.Apply(buildChannelListQuery(group, -1, -1).Where("tag = ?", *tag)).
|
||||
Omit("key").
|
||||
Find(&tagChannels).Error
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
channelData = append(channelData, tagChannels...)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -501,7 +501,7 @@ func GetUserOAuthBindingsByAdmin(c *gin.Context) {
|
||||
}
|
||||
|
||||
myRole := c.GetInt("role")
|
||||
if myRole <= targetUser.Role && myRole != common.RoleRootUser {
|
||||
if !canManageTargetRole(myRole, targetUser.Role) {
|
||||
common.ApiErrorMsg(c, "no permission")
|
||||
return
|
||||
}
|
||||
@@ -560,7 +560,7 @@ func UnbindCustomOAuthByAdmin(c *gin.Context) {
|
||||
}
|
||||
|
||||
myRole := c.GetInt("role")
|
||||
if myRole <= targetUser.Role && myRole != common.RoleRootUser {
|
||||
if !canManageTargetRole(myRole, targetUser.Role) {
|
||||
common.ApiErrorMsg(c, "no permission")
|
||||
return
|
||||
}
|
||||
|
||||
+4
-2
@@ -21,7 +21,8 @@ func GetAllLogs(c *gin.Context) {
|
||||
channel, _ := strconv.Atoi(c.Query("channel"))
|
||||
group := c.Query("group")
|
||||
requestId := c.Query("request_id")
|
||||
logs, total, err := model.GetAllLogs(logType, startTimestamp, endTimestamp, modelName, username, tokenName, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), channel, group, requestId)
|
||||
upstreamRequestId := c.Query("upstream_request_id")
|
||||
logs, total, err := model.GetAllLogs(logType, startTimestamp, endTimestamp, modelName, username, tokenName, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), channel, group, requestId, upstreamRequestId)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
@@ -42,7 +43,8 @@ func GetUserLogs(c *gin.Context) {
|
||||
modelName := c.Query("model_name")
|
||||
group := c.Query("group")
|
||||
requestId := c.Query("request_id")
|
||||
logs, total, err := model.GetUserLogs(userId, logType, startTimestamp, endTimestamp, modelName, tokenName, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), group, requestId)
|
||||
upstreamRequestId := c.Query("upstream_request_id")
|
||||
logs, total, err := model.GetUserLogs(userId, logType, startTimestamp, endTimestamp, modelName, tokenName, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), group, requestId, upstreamRequestId)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
|
||||
@@ -87,6 +87,8 @@ func GetStatus(c *gin.Context) {
|
||||
"chats": setting.Chats,
|
||||
"demo_site_enabled": operation_setting.DemoSiteEnabled,
|
||||
"self_use_mode_enabled": operation_setting.SelfUseModeEnabled,
|
||||
"register_enabled": common.RegisterEnabled,
|
||||
"password_register_enabled": common.PasswordRegisterEnabled,
|
||||
"default_use_auto_group": setting.DefaultUseAutoGroup,
|
||||
|
||||
"usd_exchange_rate": operation_setting.USDExchangeRate,
|
||||
|
||||
+27
-2
@@ -3,9 +3,11 @@ package controller
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/i18n"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/setting"
|
||||
"github.com/QuantumNous/new-api/setting/console_setting"
|
||||
@@ -27,6 +29,19 @@ var completionRatioMetaOptionKeys = []string{
|
||||
"AudioCompletionRatio",
|
||||
}
|
||||
|
||||
func isPaymentComplianceOptionKey(key string) bool {
|
||||
return strings.HasPrefix(key, "payment_setting.compliance_")
|
||||
}
|
||||
|
||||
func isPositiveOptionValue(value string) bool {
|
||||
intValue, err := strconv.Atoi(strings.TrimSpace(value))
|
||||
if err == nil {
|
||||
return intValue > 0
|
||||
}
|
||||
floatValue, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
|
||||
return err == nil && floatValue > 0
|
||||
}
|
||||
|
||||
func isVisiblePublicKeyOption(key string) bool {
|
||||
switch key {
|
||||
case "WaffoPancakeWebhookPublicKey", "WaffoPancakeWebhookTestKey":
|
||||
@@ -104,7 +119,6 @@ func GetOptions(c *gin.Context) {
|
||||
"message": "",
|
||||
"data": options,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
type OptionUpdateRequest struct {
|
||||
@@ -133,6 +147,18 @@ func UpdateOption(c *gin.Context) {
|
||||
option.Value = fmt.Sprintf("%v", option.Value)
|
||||
}
|
||||
switch option.Key {
|
||||
case "QuotaForInviter", "QuotaForInvitee":
|
||||
if isPositiveOptionValue(option.Value.(string)) && !operation_setting.IsPaymentComplianceConfirmed() {
|
||||
common.ApiErrorI18n(c, i18n.MsgPaymentComplianceRequired)
|
||||
return
|
||||
}
|
||||
default:
|
||||
if isPaymentComplianceOptionKey(option.Key) {
|
||||
common.ApiErrorMsg(c, "合规确认字段不允许通过通用设置接口修改")
|
||||
return
|
||||
}
|
||||
}
|
||||
switch option.Key {
|
||||
case "GitHubOAuthEnabled":
|
||||
if option.Value == "true" && common.GitHubClientId == "" {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
@@ -324,5 +350,4 @@ func UpdateOption(c *gin.Context) {
|
||||
"success": true,
|
||||
"message": "",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -350,6 +350,11 @@ func AdminResetPasskey(c *gin.Context) {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
myRole := c.GetInt("role")
|
||||
if !canManageTargetRole(myRole, user.Role) {
|
||||
common.ApiErrorMsg(c, "no permission")
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := model.GetPasskeyByUserID(user.Id); err != nil {
|
||||
if errors.Is(err, model.ErrPasskeyNotFound) {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/i18n"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type PaymentComplianceRequest struct {
|
||||
Confirmed bool `json:"confirmed"`
|
||||
}
|
||||
|
||||
func requirePaymentCompliance(c *gin.Context) bool {
|
||||
if !operation_setting.IsPaymentComplianceConfirmed() {
|
||||
common.ApiErrorI18n(c, i18n.MsgPaymentComplianceRequired)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func ConfirmPaymentCompliance(c *gin.Context) {
|
||||
if c.GetBool("use_access_token") {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"success": false,
|
||||
"message": "This operation requires dashboard session authentication. API access token is not allowed.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req PaymentComplianceRequest
|
||||
if err := common.DecodeJson(c.Request.Body, &req); err != nil {
|
||||
common.ApiErrorMsg(c, "参数错误")
|
||||
return
|
||||
}
|
||||
if !req.Confirmed {
|
||||
common.ApiErrorMsg(c, "请确认合规声明")
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
userId := c.GetInt("id")
|
||||
clientIP := c.ClientIP()
|
||||
|
||||
updates := map[string]string{
|
||||
"payment_setting.compliance_confirmed": "true",
|
||||
"payment_setting.compliance_terms_version": operation_setting.CurrentComplianceTermsVersion,
|
||||
"payment_setting.compliance_confirmed_at": strconv.FormatInt(now, 10),
|
||||
"payment_setting.compliance_confirmed_by": strconv.Itoa(userId),
|
||||
"payment_setting.compliance_confirmed_ip": clientIP,
|
||||
}
|
||||
|
||||
for key, value := range updates {
|
||||
if err := model.UpdateOption(key, value); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf(
|
||||
"payment compliance confirmed user_id=%d ip=%s terms_version=%s confirmed_at=%d",
|
||||
userId,
|
||||
clientIP,
|
||||
operation_setting.CurrentComplianceTermsVersion,
|
||||
now,
|
||||
))
|
||||
|
||||
common.ApiSuccess(c, gin.H{
|
||||
"confirmed": true,
|
||||
"terms_version": operation_setting.CurrentComplianceTermsVersion,
|
||||
"confirmed_at": now,
|
||||
"confirmed_by": userId,
|
||||
})
|
||||
}
|
||||
@@ -7,7 +7,14 @@ import (
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
)
|
||||
|
||||
func isPaymentComplianceConfirmed() bool {
|
||||
return operation_setting.IsPaymentComplianceConfirmed()
|
||||
}
|
||||
|
||||
func isStripeTopUpEnabled() bool {
|
||||
if !isPaymentComplianceConfirmed() {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(setting.StripeApiSecret) != "" &&
|
||||
strings.TrimSpace(setting.StripeWebhookSecret) != "" &&
|
||||
strings.TrimSpace(setting.StripePriceId) != ""
|
||||
@@ -22,6 +29,9 @@ func isStripeWebhookEnabled() bool {
|
||||
}
|
||||
|
||||
func isCreemTopUpEnabled() bool {
|
||||
if !isPaymentComplianceConfirmed() {
|
||||
return false
|
||||
}
|
||||
products := strings.TrimSpace(setting.CreemProducts)
|
||||
return strings.TrimSpace(setting.CreemApiKey) != "" &&
|
||||
products != "" &&
|
||||
@@ -37,6 +47,9 @@ func isCreemWebhookEnabled() bool {
|
||||
}
|
||||
|
||||
func isWaffoTopUpEnabled() bool {
|
||||
if !isPaymentComplianceConfirmed() {
|
||||
return false
|
||||
}
|
||||
if !setting.WaffoEnabled {
|
||||
return false
|
||||
}
|
||||
@@ -61,6 +74,9 @@ func isWaffoWebhookEnabled() bool {
|
||||
}
|
||||
|
||||
func isWaffoPancakeTopUpEnabled() bool {
|
||||
if !isPaymentComplianceConfirmed() {
|
||||
return false
|
||||
}
|
||||
if !setting.WaffoPancakeEnabled {
|
||||
return false
|
||||
}
|
||||
@@ -86,6 +102,9 @@ func isWaffoPancakeWebhookEnabled() bool {
|
||||
}
|
||||
|
||||
func isEpayTopUpEnabled() bool {
|
||||
if !isPaymentComplianceConfirmed() {
|
||||
return false
|
||||
}
|
||||
return isEpayWebhookConfigured() && len(operation_setting.PayMethods) > 0
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,21 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func confirmPaymentComplianceForTest(t *testing.T) {
|
||||
t.Helper()
|
||||
paymentSetting := operation_setting.GetPaymentSetting()
|
||||
originalConfirmed := paymentSetting.ComplianceConfirmed
|
||||
originalTermsVersion := paymentSetting.ComplianceTermsVersion
|
||||
t.Cleanup(func() {
|
||||
paymentSetting.ComplianceConfirmed = originalConfirmed
|
||||
paymentSetting.ComplianceTermsVersion = originalTermsVersion
|
||||
})
|
||||
paymentSetting.ComplianceConfirmed = true
|
||||
paymentSetting.ComplianceTermsVersion = operation_setting.CurrentComplianceTermsVersion
|
||||
}
|
||||
|
||||
func TestStripeWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) {
|
||||
confirmPaymentComplianceForTest(t)
|
||||
originalAPISecret := setting.StripeApiSecret
|
||||
originalWebhookSecret := setting.StripeWebhookSecret
|
||||
originalPriceID := setting.StripePriceId
|
||||
@@ -31,6 +45,7 @@ func TestStripeWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCreemWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) {
|
||||
confirmPaymentComplianceForTest(t)
|
||||
originalAPIKey := setting.CreemApiKey
|
||||
originalProducts := setting.CreemProducts
|
||||
originalWebhookSecret := setting.CreemWebhookSecret
|
||||
@@ -53,6 +68,7 @@ func TestCreemWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestWaffoWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) {
|
||||
confirmPaymentComplianceForTest(t)
|
||||
originalEnabled := setting.WaffoEnabled
|
||||
originalSandbox := setting.WaffoSandbox
|
||||
originalAPIKey := setting.WaffoApiKey
|
||||
@@ -97,6 +113,7 @@ func TestWaffoWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestWaffoPancakeWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) {
|
||||
confirmPaymentComplianceForTest(t)
|
||||
originalEnabled := setting.WaffoPancakeEnabled
|
||||
originalSandbox := setting.WaffoPancakeSandbox
|
||||
originalMerchantID := setting.WaffoPancakeMerchantID
|
||||
@@ -141,6 +158,7 @@ func TestWaffoPancakeWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEpayWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) {
|
||||
confirmPaymentComplianceForTest(t)
|
||||
originalPayAddress := operation_setting.PayAddress
|
||||
originalEpayID := operation_setting.EpayId
|
||||
originalEpayKey := operation_setting.EpayKey
|
||||
|
||||
@@ -5,8 +5,10 @@ import (
|
||||
"strconv"
|
||||
|
||||
perfmetrics "github.com/QuantumNous/new-api/pkg/perf_metrics"
|
||||
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
func GetPerfMetricsSummary(c *gin.Context) {
|
||||
@@ -17,7 +19,8 @@ func GetPerfMetricsSummary(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
result, err := perfmetrics.QuerySummaryAll(hours)
|
||||
activeGroups := append(lo.Keys(ratio_setting.GetGroupRatioCopy()), "auto")
|
||||
result, err := perfmetrics.QuerySummaryAll(hours, activeGroups)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
@@ -62,8 +65,18 @@ func GetPerfMetrics(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
result.Groups = filterActiveGroups(result.Groups)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": result,
|
||||
})
|
||||
}
|
||||
|
||||
func filterActiveGroups(groups []perfmetrics.GroupResult) []perfmetrics.GroupResult {
|
||||
activeRatios := ratio_setting.GetGroupRatioCopy()
|
||||
return lo.Filter(groups, func(g perfmetrics.GroupResult, _ int) bool {
|
||||
_, ok := activeRatios[g.Group]
|
||||
return ok || g.Group == "auto"
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/i18n"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -59,6 +60,11 @@ func GetRedemption(c *gin.Context) {
|
||||
}
|
||||
|
||||
func AddRedemption(c *gin.Context) {
|
||||
if !operation_setting.IsPaymentComplianceConfirmed() {
|
||||
common.ApiErrorI18n(c, i18n.MsgPaymentComplianceRequired)
|
||||
return
|
||||
}
|
||||
|
||||
redemption := model.Redemption{}
|
||||
err := c.ShouldBindJSON(&redemption)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/setting/system_setting"
|
||||
)
|
||||
|
||||
func paymentReturnPath(suffix string) string {
|
||||
base := strings.TrimRight(system_setting.ServerAddress, "/")
|
||||
return base + common.ThemeAwarePath(suffix)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
@@ -24,6 +25,11 @@ type BillingPreferenceRequest struct {
|
||||
// ---- User APIs ----
|
||||
|
||||
func GetSubscriptionPlans(c *gin.Context) {
|
||||
if !operation_setting.IsPaymentComplianceConfirmed() {
|
||||
common.ApiSuccess(c, []SubscriptionPlanDTO{})
|
||||
return
|
||||
}
|
||||
|
||||
var plans []model.SubscriptionPlan
|
||||
if err := model.DB.Where("enabled = ?", true).Order("sort_order desc, id desc").Find(&plans).Error; err != nil {
|
||||
common.ApiError(c, err)
|
||||
@@ -108,6 +114,10 @@ type AdminUpsertSubscriptionPlanRequest struct {
|
||||
}
|
||||
|
||||
func AdminCreateSubscriptionPlan(c *gin.Context) {
|
||||
if !requirePaymentCompliance(c) {
|
||||
return
|
||||
}
|
||||
|
||||
var req AdminUpsertSubscriptionPlanRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
common.ApiErrorMsg(c, "参数错误")
|
||||
@@ -166,6 +176,10 @@ func AdminCreateSubscriptionPlan(c *gin.Context) {
|
||||
}
|
||||
|
||||
func AdminUpdateSubscriptionPlan(c *gin.Context) {
|
||||
if !requirePaymentCompliance(c) {
|
||||
return
|
||||
}
|
||||
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
if id <= 0 {
|
||||
common.ApiErrorMsg(c, "无效的ID")
|
||||
@@ -259,6 +273,10 @@ type AdminUpdateSubscriptionPlanStatusRequest struct {
|
||||
}
|
||||
|
||||
func AdminUpdateSubscriptionPlanStatus(c *gin.Context) {
|
||||
if !requirePaymentCompliance(c) {
|
||||
return
|
||||
}
|
||||
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
if id <= 0 {
|
||||
common.ApiErrorMsg(c, "无效的ID")
|
||||
@@ -283,6 +301,10 @@ type AdminBindSubscriptionRequest struct {
|
||||
}
|
||||
|
||||
func AdminBindSubscription(c *gin.Context) {
|
||||
if !requirePaymentCompliance(c) {
|
||||
return
|
||||
}
|
||||
|
||||
var req AdminBindSubscriptionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.UserId <= 0 || req.PlanId <= 0 {
|
||||
common.ApiErrorMsg(c, "参数错误")
|
||||
@@ -322,6 +344,10 @@ type AdminCreateUserSubscriptionRequest struct {
|
||||
|
||||
// AdminCreateUserSubscription creates a new user subscription from a plan (no payment).
|
||||
func AdminCreateUserSubscription(c *gin.Context) {
|
||||
if !requirePaymentCompliance(c) {
|
||||
return
|
||||
}
|
||||
|
||||
userId, _ := strconv.Atoi(c.Param("id"))
|
||||
if userId <= 0 {
|
||||
common.ApiErrorMsg(c, "无效的用户ID")
|
||||
|
||||
@@ -21,6 +21,10 @@ type SubscriptionCreemPayRequest struct {
|
||||
}
|
||||
|
||||
func SubscriptionRequestCreemPay(c *gin.Context) {
|
||||
if !requirePaymentCompliance(c) {
|
||||
return
|
||||
}
|
||||
|
||||
var req SubscriptionCreemPayRequest
|
||||
|
||||
// Keep body for debugging consistency (like RequestCreemPay)
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
"github.com/QuantumNous/new-api/setting/system_setting"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
@@ -23,6 +22,10 @@ type SubscriptionEpayPayRequest struct {
|
||||
}
|
||||
|
||||
func SubscriptionRequestEpay(c *gin.Context) {
|
||||
if !requirePaymentCompliance(c) {
|
||||
return
|
||||
}
|
||||
|
||||
var req SubscriptionEpayPayRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.PlanId <= 0 {
|
||||
common.ApiErrorMsg(c, "参数错误")
|
||||
@@ -173,7 +176,7 @@ func SubscriptionEpayReturn(c *gin.Context) {
|
||||
if c.Request.Method == "POST" {
|
||||
// POST 请求:从 POST body 解析参数
|
||||
if err := c.Request.ParseForm(); err != nil {
|
||||
c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/topup?pay=fail")
|
||||
c.Redirect(http.StatusFound, paymentReturnPath("/console/topup?pay=fail"))
|
||||
return
|
||||
}
|
||||
params = lo.Reduce(lo.Keys(c.Request.PostForm), func(r map[string]string, t string, i int) map[string]string {
|
||||
@@ -189,29 +192,29 @@ func SubscriptionEpayReturn(c *gin.Context) {
|
||||
}
|
||||
|
||||
if len(params) == 0 {
|
||||
c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/topup?pay=fail")
|
||||
c.Redirect(http.StatusFound, paymentReturnPath("/console/topup?pay=fail"))
|
||||
return
|
||||
}
|
||||
|
||||
client := GetEpayClient()
|
||||
if client == nil {
|
||||
c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/topup?pay=fail")
|
||||
c.Redirect(http.StatusFound, paymentReturnPath("/console/topup?pay=fail"))
|
||||
return
|
||||
}
|
||||
verifyInfo, err := client.Verify(params)
|
||||
if err != nil || !verifyInfo.VerifyStatus {
|
||||
c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/topup?pay=fail")
|
||||
c.Redirect(http.StatusFound, paymentReturnPath("/console/topup?pay=fail"))
|
||||
return
|
||||
}
|
||||
if verifyInfo.TradeStatus == epay.StatusTradeSuccess {
|
||||
LockOrder(verifyInfo.ServiceTradeNo)
|
||||
defer UnlockOrder(verifyInfo.ServiceTradeNo)
|
||||
if err := model.CompleteSubscriptionOrder(verifyInfo.ServiceTradeNo, common.GetJsonString(verifyInfo), model.PaymentProviderEpay, verifyInfo.Type); err != nil {
|
||||
c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/topup?pay=fail")
|
||||
c.Redirect(http.StatusFound, paymentReturnPath("/console/topup?pay=fail"))
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/topup?pay=success")
|
||||
c.Redirect(http.StatusFound, paymentReturnPath("/console/topup?pay=success"))
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/topup?pay=pending")
|
||||
c.Redirect(http.StatusFound, paymentReturnPath("/console/topup?pay=pending"))
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/setting"
|
||||
"github.com/QuantumNous/new-api/setting/system_setting"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stripe/stripe-go/v81"
|
||||
"github.com/stripe/stripe-go/v81/checkout/session"
|
||||
@@ -22,6 +21,10 @@ type SubscriptionStripePayRequest struct {
|
||||
}
|
||||
|
||||
func SubscriptionRequestStripePay(c *gin.Context) {
|
||||
if !requirePaymentCompliance(c) {
|
||||
return
|
||||
}
|
||||
|
||||
var req SubscriptionStripePayRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.PlanId <= 0 {
|
||||
common.ApiErrorMsg(c, "参数错误")
|
||||
@@ -111,8 +114,8 @@ func genStripeSubscriptionLink(referenceId string, customerId string, email stri
|
||||
|
||||
params := &stripe.CheckoutSessionParams{
|
||||
ClientReferenceID: stripe.String(referenceId),
|
||||
SuccessURL: stripe.String(system_setting.ServerAddress + "/console/topup"),
|
||||
CancelURL: stripe.String(system_setting.ServerAddress + "/console/topup"),
|
||||
SuccessURL: stripe.String(paymentReturnPath("/console/topup")),
|
||||
CancelURL: stripe.String(paymentReturnPath("/console/topup")),
|
||||
LineItems: []*stripe.CheckoutSessionLineItemParams{
|
||||
{
|
||||
Price: stripe.String(priceId),
|
||||
|
||||
@@ -96,13 +96,13 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha
|
||||
return fmt.Errorf("readAll failed for task %s: %w", taskId, err)
|
||||
}
|
||||
|
||||
logger.LogDebug(ctx, fmt.Sprintf("UpdateVideoSingleTask response: %s", string(responseBody)))
|
||||
logger.LogDebug(ctx, "UpdateVideoSingleTask response: %s", responseBody)
|
||||
|
||||
taskResult := &relaycommon.TaskInfo{}
|
||||
// try parse as New API response format
|
||||
var responseItems dto.TaskResponse[model.Task]
|
||||
if err = common.Unmarshal(responseBody, &responseItems); err == nil && responseItems.IsSuccess() {
|
||||
logger.LogDebug(ctx, fmt.Sprintf("UpdateVideoSingleTask parsed as new api response format: %+v", responseItems))
|
||||
logger.LogDebug(ctx, "UpdateVideoSingleTask parsed as new api response format: %+v", responseItems)
|
||||
t := responseItems.Data
|
||||
taskResult.TaskID = t.TaskID
|
||||
taskResult.Status = string(t.Status)
|
||||
@@ -116,7 +116,7 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha
|
||||
task.Data = redactVideoResponseBody(responseBody)
|
||||
}
|
||||
|
||||
logger.LogDebug(ctx, fmt.Sprintf("UpdateVideoSingleTask taskResult: %+v", taskResult))
|
||||
logger.LogDebug(ctx, "UpdateVideoSingleTask taskResult: %+v", taskResult)
|
||||
|
||||
now := time.Now().Unix()
|
||||
if taskResult.Status == "" {
|
||||
|
||||
@@ -66,7 +66,7 @@ func TelegramBind(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(302, "/console/personal")
|
||||
c.Redirect(302, common.ThemeAwarePath("/console/personal"))
|
||||
}
|
||||
|
||||
func TelegramLogin(c *gin.Context) {
|
||||
|
||||
+14
-7
@@ -14,7 +14,6 @@ import (
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/setting"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
"github.com/QuantumNous/new-api/setting/system_setting"
|
||||
|
||||
"github.com/Calcium-Ion/go-epay/epay"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -23,8 +22,13 @@ import (
|
||||
)
|
||||
|
||||
func GetTopUpInfo(c *gin.Context) {
|
||||
complianceConfirmed := operation_setting.IsPaymentComplianceConfirmed()
|
||||
|
||||
// 获取支付方式
|
||||
payMethods := operation_setting.PayMethods
|
||||
if !complianceConfirmed {
|
||||
payMethods = []map[string]string{}
|
||||
}
|
||||
|
||||
// 如果启用了 Stripe 支付,添加到支付方法列表
|
||||
if isStripeTopUpEnabled() {
|
||||
@@ -91,11 +95,14 @@ func GetTopUpInfo(c *gin.Context) {
|
||||
}
|
||||
|
||||
data := gin.H{
|
||||
"enable_online_topup": isEpayTopUpEnabled(),
|
||||
"enable_stripe_topup": isStripeTopUpEnabled(),
|
||||
"enable_creem_topup": isCreemTopUpEnabled(),
|
||||
"enable_waffo_topup": enableWaffo,
|
||||
"enable_waffo_pancake_topup": enableWaffoPancake,
|
||||
"enable_online_topup": isEpayTopUpEnabled(),
|
||||
"enable_stripe_topup": isStripeTopUpEnabled(),
|
||||
"enable_creem_topup": isCreemTopUpEnabled(),
|
||||
"enable_waffo_topup": enableWaffo,
|
||||
"enable_waffo_pancake_topup": enableWaffoPancake,
|
||||
"enable_redemption": complianceConfirmed,
|
||||
"payment_compliance_confirmed": complianceConfirmed,
|
||||
"payment_compliance_terms_version": operation_setting.CurrentComplianceTermsVersion,
|
||||
"waffo_pay_methods": func() interface{} {
|
||||
if enableWaffo {
|
||||
return setting.GetWaffoPayMethods()
|
||||
@@ -208,7 +215,7 @@ func RequestEpay(c *gin.Context) {
|
||||
}
|
||||
|
||||
callBackAddress := service.GetCallbackAddress()
|
||||
returnUrl, _ := url.Parse(system_setting.ServerAddress + "/console/log")
|
||||
returnUrl, _ := url.Parse(paymentReturnPath("/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)
|
||||
|
||||
@@ -15,7 +15,6 @@ import (
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/setting"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
"github.com/QuantumNous/new-api/setting/system_setting"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stripe/stripe-go/v81"
|
||||
@@ -348,10 +347,10 @@ func genStripeLink(referenceId string, customerId string, email string, amount i
|
||||
|
||||
// Use custom URLs if provided, otherwise use defaults
|
||||
if successURL == "" {
|
||||
successURL = system_setting.ServerAddress + "/console/log"
|
||||
successURL = paymentReturnPath("/console/log")
|
||||
}
|
||||
if cancelURL == "" {
|
||||
cancelURL = system_setting.ServerAddress + "/console/topup"
|
||||
cancelURL = paymentReturnPath("/console/topup")
|
||||
}
|
||||
|
||||
params := &stripe.CheckoutSessionParams{
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/setting"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
"github.com/QuantumNous/new-api/setting/system_setting"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/thanhpk/randstr"
|
||||
waffo "github.com/waffo-com/waffo-go"
|
||||
@@ -237,7 +236,7 @@ func RequestWaffoPay(c *gin.Context) {
|
||||
if setting.WaffoNotifyUrl != "" {
|
||||
notifyUrl = setting.WaffoNotifyUrl
|
||||
}
|
||||
returnUrl := system_setting.ServerAddress + "/console/topup?show_history=true"
|
||||
returnUrl := paymentReturnPath("/console/topup?show_history=true")
|
||||
if setting.WaffoReturnUrl != "" {
|
||||
returnUrl = setting.WaffoReturnUrl
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/setting"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
"github.com/QuantumNous/new-api/setting/system_setting"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/shopspring/decimal"
|
||||
"github.com/thanhpk/randstr"
|
||||
@@ -107,7 +106,7 @@ func getWaffoPancakeReturnURL() string {
|
||||
if strings.TrimSpace(setting.WaffoPancakeReturnURL) != "" {
|
||||
return setting.WaffoPancakeReturnURL
|
||||
}
|
||||
return strings.TrimRight(system_setting.ServerAddress, "/") + "/console/topup?show_history=true"
|
||||
return paymentReturnPath("/console/topup?show_history=true")
|
||||
}
|
||||
|
||||
func RequestWaffoPancakePay(c *gin.Context) {
|
||||
|
||||
+1
-1
@@ -520,7 +520,7 @@ func AdminDisable2FA(c *gin.Context) {
|
||||
}
|
||||
|
||||
myRole := c.GetInt("role")
|
||||
if myRole <= targetUser.Role && myRole != common.RoleRootUser {
|
||||
if !canManageTargetRole(myRole, targetUser.Role) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无权操作同级或更高级用户的2FA设置",
|
||||
|
||||
+25
-9
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/setting"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
|
||||
@@ -263,6 +264,10 @@ func SearchUsers(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
func canManageTargetRole(myRole int, targetRole int) bool {
|
||||
return myRole == common.RoleRootUser || myRole > targetRole
|
||||
}
|
||||
|
||||
func GetUser(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
@@ -275,7 +280,7 @@ func GetUser(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
myRole := c.GetInt("role")
|
||||
if myRole <= user.Role && myRole != common.RoleRootUser {
|
||||
if !canManageTargetRole(myRole, user.Role) {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserNoPermissionSameLevel)
|
||||
return
|
||||
}
|
||||
@@ -327,6 +332,10 @@ type TransferAffQuotaRequest struct {
|
||||
}
|
||||
|
||||
func TransferAffQuota(c *gin.Context) {
|
||||
if !requirePaymentCompliance(c) {
|
||||
return
|
||||
}
|
||||
|
||||
id := c.GetInt("id")
|
||||
user, err := model.GetUserById(id, true)
|
||||
if err != nil {
|
||||
@@ -562,11 +571,11 @@ func UpdateUser(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
myRole := c.GetInt("role")
|
||||
if myRole <= originUser.Role && myRole != common.RoleRootUser {
|
||||
if !canManageTargetRole(myRole, originUser.Role) {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserNoPermissionHigherLevel)
|
||||
return
|
||||
}
|
||||
if myRole <= updatedUser.Role && myRole != common.RoleRootUser {
|
||||
if !canManageTargetRole(myRole, updatedUser.Role) {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserCannotCreateHigherLevel)
|
||||
return
|
||||
}
|
||||
@@ -605,7 +614,7 @@ func AdminClearUserBinding(c *gin.Context) {
|
||||
}
|
||||
|
||||
myRole := c.GetInt("role")
|
||||
if myRole <= user.Role && myRole != common.RoleRootUser {
|
||||
if !canManageTargetRole(myRole, user.Role) {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserNoPermissionSameLevel)
|
||||
return
|
||||
}
|
||||
@@ -773,12 +782,14 @@ func DeleteUser(c *gin.Context) {
|
||||
}
|
||||
err = model.HardDeleteUserById(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
})
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
func DeleteSelf(c *gin.Context) {
|
||||
@@ -867,7 +878,7 @@ func ManageUser(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
myRole := c.GetInt("role")
|
||||
if myRole <= user.Role && myRole != common.RoleRootUser {
|
||||
if !canManageTargetRole(myRole, user.Role) {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserNoPermissionHigherLevel)
|
||||
return
|
||||
}
|
||||
@@ -1081,6 +1092,11 @@ func getTopUpLock(userID int) *topUpTryLock {
|
||||
}
|
||||
|
||||
func TopUp(c *gin.Context) {
|
||||
if !operation_setting.IsPaymentComplianceConfirmed() {
|
||||
common.ApiErrorI18n(c, i18n.MsgPaymentComplianceRequired)
|
||||
return
|
||||
}
|
||||
|
||||
id := c.GetInt("id")
|
||||
lock := getTopUpLock(id)
|
||||
if !lock.TryLock() {
|
||||
|
||||
+3
-3
@@ -3097,9 +3097,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
||||
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
Vendored
+61
-1
@@ -63,6 +63,26 @@
|
||||
{
|
||||
"from": "../web/dist",
|
||||
"to": "web/dist"
|
||||
},
|
||||
{
|
||||
"from": "../LICENSE",
|
||||
"to": "licenses/LICENSE"
|
||||
},
|
||||
{
|
||||
"from": "../NOTICE",
|
||||
"to": "licenses/NOTICE"
|
||||
},
|
||||
{
|
||||
"from": "../THIRD-PARTY-LICENSES.md",
|
||||
"to": "licenses/THIRD-PARTY-LICENSES.md"
|
||||
},
|
||||
{
|
||||
"from": "node_modules/electron/dist/LICENSE",
|
||||
"to": "licenses/electron/LICENSE"
|
||||
},
|
||||
{
|
||||
"from": "node_modules/electron/dist/LICENSES.chromium.html",
|
||||
"to": "licenses/electron/LICENSES.chromium.html"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -76,6 +96,26 @@
|
||||
{
|
||||
"from": "../new-api.exe",
|
||||
"to": "bin/new-api.exe"
|
||||
},
|
||||
{
|
||||
"from": "../LICENSE",
|
||||
"to": "licenses/LICENSE"
|
||||
},
|
||||
{
|
||||
"from": "../NOTICE",
|
||||
"to": "licenses/NOTICE"
|
||||
},
|
||||
{
|
||||
"from": "../THIRD-PARTY-LICENSES.md",
|
||||
"to": "licenses/THIRD-PARTY-LICENSES.md"
|
||||
},
|
||||
{
|
||||
"from": "node_modules/electron/dist/LICENSE",
|
||||
"to": "licenses/electron/LICENSE"
|
||||
},
|
||||
{
|
||||
"from": "node_modules/electron/dist/LICENSES.chromium.html",
|
||||
"to": "licenses/electron/LICENSES.chromium.html"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -90,6 +130,26 @@
|
||||
{
|
||||
"from": "../new-api",
|
||||
"to": "bin/new-api"
|
||||
},
|
||||
{
|
||||
"from": "../LICENSE",
|
||||
"to": "licenses/LICENSE"
|
||||
},
|
||||
{
|
||||
"from": "../NOTICE",
|
||||
"to": "licenses/NOTICE"
|
||||
},
|
||||
{
|
||||
"from": "../THIRD-PARTY-LICENSES.md",
|
||||
"to": "licenses/THIRD-PARTY-LICENSES.md"
|
||||
},
|
||||
{
|
||||
"from": "node_modules/electron/dist/LICENSE",
|
||||
"to": "licenses/electron/LICENSE"
|
||||
},
|
||||
{
|
||||
"from": "node_modules/electron/dist/LICENSES.chromium.html",
|
||||
"to": "licenses/electron/LICENSES.chromium.html"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -98,4 +158,4 @@
|
||||
"allowToChangeInstallationDirectory": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+11
-10
@@ -142,16 +142,17 @@ const (
|
||||
|
||||
// Payment related messages
|
||||
const (
|
||||
MsgPaymentNotConfigured = "payment.not_configured"
|
||||
MsgPaymentMethodNotExists = "payment.method_not_exists"
|
||||
MsgPaymentCallbackError = "payment.callback_error"
|
||||
MsgPaymentCreateFailed = "payment.create_failed"
|
||||
MsgPaymentStartFailed = "payment.start_failed"
|
||||
MsgPaymentAmountTooLow = "payment.amount_too_low"
|
||||
MsgPaymentStripeNotConfig = "payment.stripe_not_configured"
|
||||
MsgPaymentWebhookNotConfig = "payment.webhook_not_configured"
|
||||
MsgPaymentPriceIdNotConfig = "payment.price_id_not_configured"
|
||||
MsgPaymentCreemNotConfig = "payment.creem_not_configured"
|
||||
MsgPaymentNotConfigured = "payment.not_configured"
|
||||
MsgPaymentMethodNotExists = "payment.method_not_exists"
|
||||
MsgPaymentCallbackError = "payment.callback_error"
|
||||
MsgPaymentCreateFailed = "payment.create_failed"
|
||||
MsgPaymentStartFailed = "payment.start_failed"
|
||||
MsgPaymentAmountTooLow = "payment.amount_too_low"
|
||||
MsgPaymentStripeNotConfig = "payment.stripe_not_configured"
|
||||
MsgPaymentWebhookNotConfig = "payment.webhook_not_configured"
|
||||
MsgPaymentPriceIdNotConfig = "payment.price_id_not_configured"
|
||||
MsgPaymentCreemNotConfig = "payment.creem_not_configured"
|
||||
MsgPaymentComplianceRequired = "payment.compliance_required"
|
||||
)
|
||||
|
||||
// Topup related messages
|
||||
|
||||
@@ -134,6 +134,7 @@ payment.stripe_not_configured: "Stripe is not configured or key is invalid"
|
||||
payment.webhook_not_configured: "Webhook is not configured"
|
||||
payment.price_id_not_configured: "StripePriceId is not configured for this plan"
|
||||
payment.creem_not_configured: "CreemProductId is not configured for this plan"
|
||||
payment.compliance_required: "Payment, redemption, subscription, and invitation reward features are disabled. The administrator must confirm compliance terms before enabling them."
|
||||
|
||||
# Topup messages
|
||||
topup.not_provided: "Payment order number not provided"
|
||||
|
||||
@@ -135,6 +135,7 @@ payment.stripe_not_configured: "Stripe 未配置或密钥无效"
|
||||
payment.webhook_not_configured: "Webhook 未配置"
|
||||
payment.price_id_not_configured: "该套餐未配置 StripePriceId"
|
||||
payment.creem_not_configured: "该套餐未配置 CreemProductId"
|
||||
payment.compliance_required: "支付、兑换码、订阅计划和邀请返利功能已禁用。管理员需先确认合规声明后方可启用。"
|
||||
|
||||
# Topup messages
|
||||
topup.not_provided: "未提供支付单号"
|
||||
|
||||
@@ -135,6 +135,7 @@ payment.stripe_not_configured: "Stripe 未設定或密鑰無效"
|
||||
payment.webhook_not_configured: "Webhook 未設定"
|
||||
payment.price_id_not_configured: "該訂閱方案未設定 StripePriceId"
|
||||
payment.creem_not_configured: "該訂閱方案未設定 CreemProductId"
|
||||
payment.compliance_required: "支付、兌換碼、訂閱方案和邀請返利功能已停用。管理員需先確認合規聲明後方可啟用。"
|
||||
|
||||
# Topup messages
|
||||
topup.not_provided: "未提供支付單號"
|
||||
|
||||
+9
-4
@@ -95,9 +95,11 @@ func LogDebug(ctx context.Context, msg string, args ...any) {
|
||||
}
|
||||
|
||||
func logHelper(ctx context.Context, level string, msg string) {
|
||||
id := ctx.Value(common.RequestIdKey)
|
||||
if id == nil {
|
||||
id = "SYSTEM"
|
||||
var id any = "SYSTEM"
|
||||
if ctx != nil {
|
||||
if requestID := ctx.Value(common.RequestIdKey); requestID != nil {
|
||||
id = requestID
|
||||
}
|
||||
}
|
||||
now := time.Now()
|
||||
common.LogWriterMu.RLock()
|
||||
@@ -172,10 +174,13 @@ func FormatQuota(quota int) string {
|
||||
|
||||
// LogJson 仅供测试使用 only for test
|
||||
func LogJson(ctx context.Context, msg string, obj any) {
|
||||
if !common.DebugEnabled {
|
||||
return
|
||||
}
|
||||
jsonStr, err := common.Marshal(obj)
|
||||
if err != nil {
|
||||
LogError(ctx, fmt.Sprintf("json marshal failed: %s", err.Error()))
|
||||
return
|
||||
}
|
||||
LogDebug(ctx, fmt.Sprintf("%s | %s", msg, string(jsonStr)))
|
||||
LogDebug(ctx, "%s | %s", msg, jsonStr)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
FRONTEND_DIR = ./web/default
|
||||
FRONTEND_CLASSIC_DIR = ./web/classic
|
||||
BACKEND_DIR = .
|
||||
DEV_COMPOSE_FILE = docker-compose.dev.yml
|
||||
DEV_POSTGRES_SERVICE = postgres
|
||||
DEV_BACKEND_SERVICE = new-api
|
||||
DEV_POSTGRES_DB = new-api
|
||||
DEV_POSTGRES_USER = root
|
||||
DEV_SQLITE_PATH ?= one-api.db
|
||||
|
||||
.PHONY: all build-frontend build-frontend-classic build-all-frontends start-backend dev dev-api dev-web dev-web-classic
|
||||
.PHONY: all build-frontend build-frontend-classic build-all-frontends start-backend dev dev-api dev-api-rebuild dev-web dev-web-classic reset-setup
|
||||
|
||||
all: build-all-frontends start-backend
|
||||
|
||||
@@ -22,7 +28,11 @@ start-backend:
|
||||
|
||||
dev-api:
|
||||
@echo "Starting backend services (docker)..."
|
||||
@docker compose -f docker-compose.dev.yml up -d
|
||||
@docker compose -f $(DEV_COMPOSE_FILE) up -d
|
||||
|
||||
dev-api-rebuild:
|
||||
@echo "Rebuilding and starting backend service (docker)..."
|
||||
@docker compose -f $(DEV_COMPOSE_FILE) up -d --build $(DEV_BACKEND_SERVICE)
|
||||
|
||||
dev-web:
|
||||
@echo "Starting frontend dev server..."
|
||||
@@ -33,3 +43,27 @@ dev-web-classic:
|
||||
@cd $(FRONTEND_CLASSIC_DIR) && bun install && bun run dev
|
||||
|
||||
dev: dev-api dev-web
|
||||
|
||||
reset-setup:
|
||||
@echo "Resetting local setup wizard state..."
|
||||
@if docker compose -f $(DEV_COMPOSE_FILE) ps --services --status running | grep -qx "$(DEV_POSTGRES_SERVICE)"; then \
|
||||
echo "Detected running docker dev PostgreSQL. Removing setup record and root users..."; \
|
||||
docker compose -f $(DEV_COMPOSE_FILE) exec -T $(DEV_POSTGRES_SERVICE) \
|
||||
psql -U $(DEV_POSTGRES_USER) -d $(DEV_POSTGRES_DB) \
|
||||
-c 'DELETE FROM setups;' \
|
||||
-c 'DELETE FROM users WHERE role = 100;' \
|
||||
-c "DELETE FROM options WHERE key IN ('SelfUseModeEnabled', 'DemoSiteEnabled');"; \
|
||||
echo "Restarting docker dev backend so setup status is recalculated..."; \
|
||||
docker compose -f $(DEV_COMPOSE_FILE) restart $(DEV_BACKEND_SERVICE); \
|
||||
elif db_path="$${SQLITE_PATH:-$(DEV_SQLITE_PATH)}"; db_path="$${db_path%%\?*}"; [ -f "$$db_path" ]; then \
|
||||
db_path="$${SQLITE_PATH:-$(DEV_SQLITE_PATH)}"; \
|
||||
db_path="$${db_path%%\?*}"; \
|
||||
echo "Detected local SQLite database: $$db_path"; \
|
||||
sqlite3 "$$db_path" \
|
||||
"DELETE FROM setups; DELETE FROM users WHERE role = 100; DELETE FROM options WHERE key IN ('SelfUseModeEnabled', 'DemoSiteEnabled');"; \
|
||||
echo "SQLite setup state reset. Restart the local backend process before testing the setup wizard."; \
|
||||
else \
|
||||
echo "No running docker dev PostgreSQL or local SQLite database found."; \
|
||||
echo "Start the dev stack with 'make dev-api', or set SQLITE_PATH/DEV_SQLITE_PATH to your local SQLite database."; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type headerNavAccess struct {
|
||||
Enabled bool
|
||||
RequireAuth bool
|
||||
}
|
||||
|
||||
func getHeaderNavAccess(module string) headerNavAccess {
|
||||
fallback := headerNavAccess{
|
||||
Enabled: true,
|
||||
RequireAuth: false,
|
||||
}
|
||||
|
||||
common.OptionMapRWMutex.RLock()
|
||||
raw := common.OptionMap["HeaderNavModules"]
|
||||
common.OptionMapRWMutex.RUnlock()
|
||||
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return fallback
|
||||
}
|
||||
|
||||
var parsed map[string]any
|
||||
if err := common.Unmarshal([]byte(raw), &parsed); err != nil {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return parseHeaderNavAccess(parsed[module], fallback)
|
||||
}
|
||||
|
||||
func parseHeaderNavAccess(raw any, fallback headerNavAccess) headerNavAccess {
|
||||
switch value := raw.(type) {
|
||||
case bool:
|
||||
return headerNavAccess{
|
||||
Enabled: value,
|
||||
RequireAuth: fallback.RequireAuth,
|
||||
}
|
||||
case string:
|
||||
return headerNavAccess{
|
||||
Enabled: parseHeaderNavBool(value, fallback.Enabled),
|
||||
RequireAuth: fallback.RequireAuth,
|
||||
}
|
||||
case float64:
|
||||
return headerNavAccess{
|
||||
Enabled: parseHeaderNavBool(value, fallback.Enabled),
|
||||
RequireAuth: fallback.RequireAuth,
|
||||
}
|
||||
case map[string]any:
|
||||
access := fallback
|
||||
if enabled, ok := value["enabled"]; ok {
|
||||
access.Enabled = parseHeaderNavBool(enabled, fallback.Enabled)
|
||||
}
|
||||
if requireAuth, ok := value["requireAuth"]; ok {
|
||||
access.RequireAuth = parseHeaderNavBool(requireAuth, fallback.RequireAuth)
|
||||
}
|
||||
return access
|
||||
default:
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
func parseHeaderNavBool(value any, fallback bool) bool {
|
||||
switch v := value.(type) {
|
||||
case bool:
|
||||
return v
|
||||
case string:
|
||||
switch strings.ToLower(strings.TrimSpace(v)) {
|
||||
case "true", "1":
|
||||
return true
|
||||
case "false", "0":
|
||||
return false
|
||||
default:
|
||||
return fallback
|
||||
}
|
||||
case float64:
|
||||
if v == 1 {
|
||||
return true
|
||||
}
|
||||
if v == 0 {
|
||||
return false
|
||||
}
|
||||
return fallback
|
||||
case int:
|
||||
if v == 1 {
|
||||
return true
|
||||
}
|
||||
if v == 0 {
|
||||
return false
|
||||
}
|
||||
return fallback
|
||||
default:
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
func HeaderNavModuleAuth(module string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
access := getHeaderNavAccess(module)
|
||||
if !access.Enabled {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"success": false,
|
||||
"message": fmt.Sprintf("%s is disabled", module),
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
if access.RequireAuth {
|
||||
UserAuth()(c)
|
||||
return
|
||||
}
|
||||
|
||||
TryUserAuth()(c)
|
||||
}
|
||||
}
|
||||
|
||||
func HeaderNavModulePublicOrUserAuth(module string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
access := getHeaderNavAccess(module)
|
||||
if !access.Enabled || access.RequireAuth {
|
||||
UserAuth()(c)
|
||||
return
|
||||
}
|
||||
|
||||
TryUserAuth()(c)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-contrib/sessions/cookie"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func withHeaderNavModules(t *testing.T, raw string) {
|
||||
t.Helper()
|
||||
|
||||
common.OptionMapRWMutex.Lock()
|
||||
if common.OptionMap == nil {
|
||||
common.OptionMap = map[string]string{}
|
||||
}
|
||||
previous, hadPrevious := common.OptionMap["HeaderNavModules"]
|
||||
common.OptionMap["HeaderNavModules"] = raw
|
||||
common.OptionMapRWMutex.Unlock()
|
||||
|
||||
t.Cleanup(func() {
|
||||
common.OptionMapRWMutex.Lock()
|
||||
defer common.OptionMapRWMutex.Unlock()
|
||||
if hadPrevious {
|
||||
common.OptionMap["HeaderNavModules"] = previous
|
||||
return
|
||||
}
|
||||
delete(common.OptionMap, "HeaderNavModules")
|
||||
})
|
||||
}
|
||||
|
||||
func performHeaderNavRequest(t *testing.T, handler gin.HandlerFunc, authenticated bool) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.Use(sessions.Sessions("session", cookie.NewStore([]byte("header-nav-test"))))
|
||||
router.GET("/login", func(c *gin.Context) {
|
||||
session := sessions.Default(c)
|
||||
session.Set("username", "tester")
|
||||
session.Set("role", common.RoleCommonUser)
|
||||
session.Set("id", 1)
|
||||
session.Set("status", common.UserStatusEnabled)
|
||||
session.Set("group", "default")
|
||||
if err := session.Save(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
})
|
||||
router.GET("/api/test", handler, func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
})
|
||||
|
||||
var cookies []*http.Cookie
|
||||
if authenticated {
|
||||
loginRecorder := httptest.NewRecorder()
|
||||
loginRequest := httptest.NewRequest(http.MethodGet, "/login", nil)
|
||||
router.ServeHTTP(loginRecorder, loginRequest)
|
||||
require.Equal(t, http.StatusNoContent, loginRecorder.Code)
|
||||
cookies = loginRecorder.Result().Cookies()
|
||||
}
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/test", nil)
|
||||
if authenticated {
|
||||
request.Header.Set("New-Api-User", "1")
|
||||
for _, cookie := range cookies {
|
||||
request.AddCookie(cookie)
|
||||
}
|
||||
}
|
||||
router.ServeHTTP(recorder, request)
|
||||
return recorder
|
||||
}
|
||||
|
||||
func TestHeaderNavModuleAuthAllowsDefaultPublicAccess(t *testing.T) {
|
||||
withHeaderNavModules(t, "")
|
||||
|
||||
recorder := performHeaderNavRequest(t, HeaderNavModuleAuth("pricing"), false)
|
||||
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
}
|
||||
|
||||
func TestHeaderNavModuleAuthRejectsDisabledPricing(t *testing.T) {
|
||||
raw := `{"pricing":{"enabled":false,"requireAuth":false}}`
|
||||
withHeaderNavModules(t, raw)
|
||||
|
||||
recorder := performHeaderNavRequest(t, HeaderNavModuleAuth("pricing"), false)
|
||||
|
||||
require.Equal(t, http.StatusForbidden, recorder.Code)
|
||||
}
|
||||
|
||||
func TestHeaderNavModuleAuthRequiresLoginForPricing(t *testing.T) {
|
||||
raw := `{"pricing":{"enabled":true,"requireAuth":true}}`
|
||||
withHeaderNavModules(t, raw)
|
||||
|
||||
recorder := performHeaderNavRequest(t, HeaderNavModuleAuth("pricing"), false)
|
||||
|
||||
require.Equal(t, http.StatusUnauthorized, recorder.Code)
|
||||
}
|
||||
|
||||
func TestHeaderNavModuleAuthRequiresLoginForRankings(t *testing.T) {
|
||||
raw := `{"rankings":{"enabled":true,"requireAuth":true}}`
|
||||
withHeaderNavModules(t, raw)
|
||||
|
||||
recorder := performHeaderNavRequest(t, HeaderNavModuleAuth("rankings"), false)
|
||||
|
||||
require.Equal(t, http.StatusUnauthorized, recorder.Code)
|
||||
}
|
||||
|
||||
func TestHeaderNavModuleAuthRejectsLegacyDisabledModule(t *testing.T) {
|
||||
raw := `{"rankings":false}`
|
||||
withHeaderNavModules(t, raw)
|
||||
|
||||
recorder := performHeaderNavRequest(t, HeaderNavModuleAuth("rankings"), false)
|
||||
|
||||
require.Equal(t, http.StatusForbidden, recorder.Code)
|
||||
}
|
||||
|
||||
func TestHeaderNavModulePublicOrUserAuthAllowsDefaultPublicAccess(t *testing.T) {
|
||||
withHeaderNavModules(t, "")
|
||||
|
||||
recorder := performHeaderNavRequest(t, HeaderNavModulePublicOrUserAuth("pricing"), false)
|
||||
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
}
|
||||
|
||||
func TestHeaderNavModulePublicOrUserAuthRequiresLoginWhenDisabled(t *testing.T) {
|
||||
raw := `{"pricing":{"enabled":false,"requireAuth":false}}`
|
||||
withHeaderNavModules(t, raw)
|
||||
|
||||
recorder := performHeaderNavRequest(t, HeaderNavModulePublicOrUserAuth("pricing"), false)
|
||||
|
||||
require.Equal(t, http.StatusUnauthorized, recorder.Code)
|
||||
}
|
||||
|
||||
func TestHeaderNavModulePublicOrUserAuthAllowsLoggedInWhenDisabled(t *testing.T) {
|
||||
raw := `{"pricing":{"enabled":false,"requireAuth":false}}`
|
||||
withHeaderNavModules(t, raw)
|
||||
|
||||
recorder := performHeaderNavRequest(t, HeaderNavModulePublicOrUserAuth("pricing"), true)
|
||||
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
}
|
||||
|
||||
func TestHeaderNavModulePublicOrUserAuthRequiresLoginWhenRequireAuth(t *testing.T) {
|
||||
raw := `{"pricing":{"enabled":true,"requireAuth":true}}`
|
||||
withHeaderNavModules(t, raw)
|
||||
|
||||
recorder := performHeaderNavRequest(t, HeaderNavModulePublicOrUserAuth("pricing"), false)
|
||||
|
||||
require.Equal(t, http.StatusUnauthorized, recorder.Code)
|
||||
}
|
||||
|
||||
func TestHeaderNavModulePublicOrUserAuthRequiresLoginForLegacyDisabledModule(t *testing.T) {
|
||||
raw := `{"pricing":false}`
|
||||
withHeaderNavModules(t, raw)
|
||||
|
||||
recorder := performHeaderNavRequest(t, HeaderNavModulePublicOrUserAuth("pricing"), false)
|
||||
|
||||
require.Equal(t, http.StatusUnauthorized, recorder.Code)
|
||||
}
|
||||
+92
-44
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
|
||||
"github.com/samber/lo"
|
||||
@@ -128,6 +129,38 @@ func resolveChannelSortOptions(idSort bool, sortOptions []ChannelSortOptions) Ch
|
||||
return options
|
||||
}
|
||||
|
||||
func NormalizeChannelGroupFilter(group string) string {
|
||||
group = strings.TrimSpace(group)
|
||||
if group == "" || strings.EqualFold(group, "all") || strings.EqualFold(group, "null") {
|
||||
return ""
|
||||
}
|
||||
return group
|
||||
}
|
||||
|
||||
func channelGroupFilterCondition() string {
|
||||
if common.UsingMySQL {
|
||||
return `CONCAT(',', ` + commonGroupCol + `, ',') LIKE ? ESCAPE '!'`
|
||||
}
|
||||
return `(',' || ` + commonGroupCol + ` || ',') LIKE ? ESCAPE '!'`
|
||||
}
|
||||
|
||||
func channelGroupFilterPattern(group string) string {
|
||||
group = strings.NewReplacer(
|
||||
"!", "!!",
|
||||
"%", "!%",
|
||||
"_", "!_",
|
||||
).Replace(group)
|
||||
return "%," + group + ",%"
|
||||
}
|
||||
|
||||
func ApplyChannelGroupFilter(query *gorm.DB, group string) *gorm.DB {
|
||||
group = NormalizeChannelGroupFilter(group)
|
||||
if group == "" {
|
||||
return query
|
||||
}
|
||||
return query.Where(channelGroupFilterCondition(), channelGroupFilterPattern(group))
|
||||
}
|
||||
|
||||
// Value implements driver.Valuer interface
|
||||
func (c ChannelInfo) Value() (driver.Value, error) {
|
||||
return common.Marshal(&c)
|
||||
@@ -218,10 +251,9 @@ func (channel *Channel) GetNextEnabledKey() (string, int, *types.NewAPIError) {
|
||||
if err != nil {
|
||||
return "", 0, types.NewError(err, types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
//println("before polling index:", channel.ChannelInfo.MultiKeyPollingIndex)
|
||||
defer func() {
|
||||
if common.DebugEnabled {
|
||||
println(fmt.Sprintf("channel %d polling index: %d", channel.Id, channel.ChannelInfo.MultiKeyPollingIndex))
|
||||
logger.LogDebug(nil, "channel %d polling index: %d", channel.Id, channel.ChannelInfo.MultiKeyPollingIndex)
|
||||
}
|
||||
if !common.MemoryCacheEnabled {
|
||||
_ = channel.SaveChannelInfo()
|
||||
@@ -365,25 +397,12 @@ func SearchChannels(keyword string, group string, model string, idSort bool, sor
|
||||
baseQuery := DB.Model(&Channel{}).Omit("key")
|
||||
|
||||
// 构造WHERE子句
|
||||
var whereClause string
|
||||
var args []interface{}
|
||||
if group != "" && group != "null" {
|
||||
var groupCondition string
|
||||
if common.UsingMySQL {
|
||||
groupCondition = `CONCAT(',', ` + commonGroupCol + `, ',') LIKE ?`
|
||||
} else {
|
||||
// sqlite, PostgreSQL
|
||||
groupCondition = `(',' || ` + commonGroupCol + ` || ',') LIKE ?`
|
||||
}
|
||||
whereClause = "(id = ? OR name LIKE ? OR " + commonKeyCol + " = ? OR " + baseURLCol + " LIKE ?) AND " + modelsCol + ` LIKE ? AND ` + groupCondition
|
||||
args = append(args, common.String2Int(keyword), "%"+keyword+"%", keyword, "%"+keyword+"%", "%"+model+"%", "%,"+group+",%")
|
||||
} else {
|
||||
whereClause = "(id = ? OR name LIKE ? OR " + commonKeyCol + " = ? OR " + baseURLCol + " LIKE ?) AND " + modelsCol + " LIKE ?"
|
||||
args = append(args, common.String2Int(keyword), "%"+keyword+"%", keyword, "%"+keyword+"%", "%"+model+"%")
|
||||
}
|
||||
whereClause := "(id = ? OR name LIKE ? OR " + commonKeyCol + " = ? OR " + baseURLCol + " LIKE ?) AND " + modelsCol + " LIKE ?"
|
||||
args := []any{common.String2Int(keyword), "%" + keyword + "%", keyword, "%" + keyword + "%", "%" + model + "%"}
|
||||
baseQuery = ApplyChannelGroupFilter(baseQuery.Where(whereClause, args...), group)
|
||||
|
||||
// 执行查询
|
||||
err := order.Apply(baseQuery.Where(whereClause, args...)).Find(&channels).Error
|
||||
err := order.Apply(baseQuery).Find(&channels).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -401,9 +420,6 @@ func GetChannelById(id int, selectAll bool) (*Channel, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if channel == nil {
|
||||
return nil, errors.New("channel not found")
|
||||
}
|
||||
return channel, nil
|
||||
}
|
||||
|
||||
@@ -627,13 +643,25 @@ func handlerMultiKeyUpdate(channel *Channel, usingKey string, status int, reason
|
||||
if len(keys) == 0 {
|
||||
channel.Status = status
|
||||
} else {
|
||||
var keyIndex int
|
||||
keyIndex := -1
|
||||
for i, key := range keys {
|
||||
if key == usingKey {
|
||||
keyIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if keyIndex < 0 {
|
||||
if usingKey != "" {
|
||||
common.SysLog(fmt.Sprintf("failed to update multi-key status: channel_id=%d, using key not found", channel.Id))
|
||||
return
|
||||
}
|
||||
channel.Status = status
|
||||
info := channel.GetOtherInfo()
|
||||
info["status_reason"] = reason
|
||||
info["status_time"] = common.GetTimestamp()
|
||||
channel.SetOtherInfo(info)
|
||||
return
|
||||
}
|
||||
if channel.ChannelInfo.MultiKeyStatusList == nil {
|
||||
channel.ChannelInfo.MultiKeyStatusList = make(map[int]int)
|
||||
}
|
||||
@@ -650,16 +678,31 @@ func handlerMultiKeyUpdate(channel *Channel, usingKey string, status int, reason
|
||||
channel.ChannelInfo.MultiKeyDisabledReason[keyIndex] = reason
|
||||
channel.ChannelInfo.MultiKeyDisabledTime[keyIndex] = common.GetTimestamp()
|
||||
}
|
||||
if len(channel.ChannelInfo.MultiKeyStatusList) >= channel.ChannelInfo.MultiKeySize {
|
||||
if !hasEnabledMultiKey(keys, channel.ChannelInfo.MultiKeyStatusList) {
|
||||
channel.Status = common.ChannelStatusAutoDisabled
|
||||
info := channel.GetOtherInfo()
|
||||
info["status_reason"] = "All keys are disabled"
|
||||
info["status_time"] = common.GetTimestamp()
|
||||
channel.SetOtherInfo(info)
|
||||
} else if status == common.ChannelStatusEnabled {
|
||||
channel.Status = common.ChannelStatusEnabled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func hasEnabledMultiKey(keys []string, statusList map[int]int) bool {
|
||||
for i := range keys {
|
||||
if statusList == nil {
|
||||
return true
|
||||
}
|
||||
status, ok := statusList[i]
|
||||
if !ok || status == common.ChannelStatusEnabled {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func UpdateChannelStatus(channelId int, usingKey string, status int, reason string) bool {
|
||||
if common.MemoryCacheEnabled {
|
||||
channelStatusLock.Lock()
|
||||
@@ -671,11 +714,15 @@ func UpdateChannelStatus(channelId int, usingKey string, status int, reason stri
|
||||
}
|
||||
if channelCache.ChannelInfo.IsMultiKey {
|
||||
// Use per-channel lock to prevent concurrent map read/write with GetNextEnabledKey
|
||||
beforeStatus := channelCache.Status
|
||||
pollingLock := GetChannelPollingLock(channelId)
|
||||
pollingLock.Lock()
|
||||
// 如果是多Key模式,更新缓存中的状态
|
||||
handlerMultiKeyUpdate(channelCache, usingKey, status, reason)
|
||||
pollingLock.Unlock()
|
||||
if beforeStatus != channelCache.Status {
|
||||
CacheUpdateChannelStatus(channelId, channelCache.Status)
|
||||
}
|
||||
//CacheUpdateChannel(channelCache)
|
||||
//return true
|
||||
} else {
|
||||
@@ -758,7 +805,7 @@ func EditChannelByTag(tag string, newTag *string, modelMapping *string, models *
|
||||
updateData.Tag = newTag
|
||||
updatedTag = *newTag
|
||||
}
|
||||
if modelMapping != nil && *modelMapping != "" {
|
||||
if modelMapping != nil {
|
||||
updateData.ModelMapping = modelMapping
|
||||
}
|
||||
if models != nil && *models != "" {
|
||||
@@ -831,8 +878,18 @@ func DeleteDisabledChannel() (int64, error) {
|
||||
}
|
||||
|
||||
func GetPaginatedTags(offset int, limit int) ([]*string, error) {
|
||||
return GetPaginatedChannelTags(DB.Model(&Channel{}), offset, limit)
|
||||
}
|
||||
|
||||
func GetPaginatedChannelTags(query *gorm.DB, offset int, limit int) ([]*string, error) {
|
||||
var tags []*string
|
||||
err := DB.Model(&Channel{}).Select("DISTINCT tag").Where("tag != ''").Offset(offset).Limit(limit).Find(&tags).Error
|
||||
err := query.
|
||||
Select("DISTINCT tag").
|
||||
Where("tag is not null AND tag != ''").
|
||||
Order(clause.OrderByColumn{Column: clause.Column{Name: "tag"}}).
|
||||
Offset(offset).
|
||||
Limit(limit).
|
||||
Find(&tags).Error
|
||||
return tags, err
|
||||
}
|
||||
|
||||
@@ -860,24 +917,11 @@ func SearchTags(keyword string, group string, model string, idSort bool) ([]*str
|
||||
baseQuery := DB.Model(&Channel{}).Omit("key")
|
||||
|
||||
// 构造WHERE子句
|
||||
var whereClause string
|
||||
var args []interface{}
|
||||
if group != "" && group != "null" {
|
||||
var groupCondition string
|
||||
if common.UsingMySQL {
|
||||
groupCondition = `CONCAT(',', ` + commonGroupCol + `, ',') LIKE ?`
|
||||
} else {
|
||||
// sqlite, PostgreSQL
|
||||
groupCondition = `(',' || ` + commonGroupCol + ` || ',') LIKE ?`
|
||||
}
|
||||
whereClause = "(id = ? OR name LIKE ? OR " + commonKeyCol + " = ? OR " + baseURLCol + " LIKE ?) AND " + modelsCol + ` LIKE ? AND ` + groupCondition
|
||||
args = append(args, common.String2Int(keyword), "%"+keyword+"%", keyword, "%"+keyword+"%", "%"+model+"%", "%,"+group+",%")
|
||||
} else {
|
||||
whereClause = "(id = ? OR name LIKE ? OR " + commonKeyCol + " = ? OR " + baseURLCol + " LIKE ?) AND " + modelsCol + " LIKE ?"
|
||||
args = append(args, common.String2Int(keyword), "%"+keyword+"%", keyword, "%"+keyword+"%", "%"+model+"%")
|
||||
}
|
||||
whereClause := "(id = ? OR name LIKE ? OR " + commonKeyCol + " = ? OR " + baseURLCol + " LIKE ?) AND " + modelsCol + " LIKE ?"
|
||||
args := []any{common.String2Int(keyword), "%" + keyword + "%", keyword, "%" + keyword + "%", "%" + model + "%"}
|
||||
baseQuery = ApplyChannelGroupFilter(baseQuery.Where(whereClause, args...), group)
|
||||
|
||||
subQuery := baseQuery.Where(whereClause, args...).
|
||||
subQuery := baseQuery.
|
||||
Select("tag").
|
||||
Where("tag != ''").
|
||||
Order(order)
|
||||
@@ -1018,8 +1062,12 @@ func CountAllChannels() (int64, error) {
|
||||
|
||||
// CountAllTags returns number of non-empty distinct tags
|
||||
func CountAllTags() (int64, error) {
|
||||
return CountChannelTags(DB.Model(&Channel{}))
|
||||
}
|
||||
|
||||
func CountChannelTags(query *gorm.DB) (int64, error) {
|
||||
var total int64
|
||||
err := DB.Model(&Channel{}).Where("tag is not null AND tag != ''").Distinct("tag").Count(&total).Error
|
||||
err := query.Where("tag is not null AND tag != ''").Distinct("tag").Count(&total).Error
|
||||
return total, err
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||||
)
|
||||
|
||||
@@ -257,9 +258,12 @@ func CacheUpdateChannel(channel *Channel) {
|
||||
return
|
||||
}
|
||||
|
||||
println("CacheUpdateChannel:", channel.Id, channel.Name, channel.Status, channel.ChannelInfo.MultiKeyPollingIndex)
|
||||
|
||||
println("before:", channelsIDM[channel.Id].ChannelInfo.MultiKeyPollingIndex)
|
||||
if channelsIDM == nil {
|
||||
channelsIDM = make(map[int]*Channel)
|
||||
}
|
||||
if oldChannel, ok := channelsIDM[channel.Id]; ok {
|
||||
logger.LogDebug(nil, "CacheUpdateChannel before: id=%d, name=%s, status=%d, polling_index=%d", channel.Id, channel.Name, channel.Status, oldChannel.ChannelInfo.MultiKeyPollingIndex)
|
||||
}
|
||||
channelsIDM[channel.Id] = channel
|
||||
println("after :", channelsIDM[channel.Id].ChannelInfo.MultiKeyPollingIndex)
|
||||
logger.LogDebug(nil, "CacheUpdateChannel after: id=%d, name=%s, status=%d, polling_index=%d", channel.Id, channel.Name, channel.Status, channel.ChannelInfo.MultiKeyPollingIndex)
|
||||
}
|
||||
|
||||
+49
-43
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
@@ -35,8 +36,9 @@ type Log struct {
|
||||
TokenId int `json:"token_id" gorm:"default:0;index"`
|
||||
Group string `json:"group" gorm:"index"`
|
||||
Ip string `json:"ip" gorm:"index;default:''"`
|
||||
RequestId string `json:"request_id,omitempty" gorm:"type:varchar(64);index:idx_logs_request_id;default:''"`
|
||||
Other string `json:"other"`
|
||||
RequestId string `json:"request_id,omitempty" gorm:"type:varchar(64);index:idx_logs_request_id;default:''"`
|
||||
UpstreamRequestId string `json:"upstream_request_id,omitempty" gorm:"type:varchar(128);index:idx_logs_upstream_request_id;default:''"`
|
||||
Other string `json:"other"`
|
||||
}
|
||||
|
||||
// don't use iota, avoid change log type value
|
||||
@@ -147,6 +149,7 @@ func RecordErrorLog(c *gin.Context, userId int, channelId int, modelName string,
|
||||
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")
|
||||
requestId := c.GetString(common.RequestIdKey)
|
||||
upstreamRequestId := c.GetString(common.UpstreamRequestIdKey)
|
||||
otherStr := common.MapToJsonStr(other)
|
||||
// 判断是否需要记录 IP
|
||||
needRecordIp := false
|
||||
@@ -177,8 +180,9 @@ func RecordErrorLog(c *gin.Context, userId int, channelId int, modelName string,
|
||||
}
|
||||
return ""
|
||||
}(),
|
||||
RequestId: requestId,
|
||||
Other: otherStr,
|
||||
RequestId: requestId,
|
||||
UpstreamRequestId: upstreamRequestId,
|
||||
Other: otherStr,
|
||||
}
|
||||
err := LOG_DB.Create(log).Error
|
||||
if err != nil {
|
||||
@@ -208,6 +212,7 @@ func RecordConsumeLog(c *gin.Context, userId int, params RecordConsumeLogParams)
|
||||
logger.LogInfo(c, fmt.Sprintf("record consume log: userId=%d, params=%s", userId, common.GetJsonString(params)))
|
||||
username := c.GetString("username")
|
||||
requestId := c.GetString(common.RequestIdKey)
|
||||
upstreamRequestId := c.GetString(common.UpstreamRequestIdKey)
|
||||
otherStr := common.MapToJsonStr(params.Other)
|
||||
// 判断是否需要记录 IP
|
||||
needRecordIp := false
|
||||
@@ -238,8 +243,9 @@ func RecordConsumeLog(c *gin.Context, userId int, params RecordConsumeLogParams)
|
||||
}
|
||||
return ""
|
||||
}(),
|
||||
RequestId: requestId,
|
||||
Other: otherStr,
|
||||
RequestId: requestId,
|
||||
UpstreamRequestId: upstreamRequestId,
|
||||
Other: otherStr,
|
||||
}
|
||||
err := LOG_DB.Create(log).Error
|
||||
if err != nil {
|
||||
@@ -295,7 +301,7 @@ func RecordTaskBillingLog(params RecordTaskBillingLogParams) {
|
||||
}
|
||||
}
|
||||
|
||||
func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string, startIdx int, num int, channel int, group string, requestId string) (logs []*Log, total int64, err error) {
|
||||
func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string, startIdx int, num int, channel int, group string, requestId string, upstreamRequestId string) (logs []*Log, total int64, err error) {
|
||||
var tx *gorm.DB
|
||||
if logType == LogTypeUnknown {
|
||||
tx = LOG_DB
|
||||
@@ -303,18 +309,15 @@ func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName
|
||||
tx = LOG_DB.Where("logs.type = ?", logType)
|
||||
}
|
||||
|
||||
if modelName != "" {
|
||||
tx = tx.Where("logs.model_name like ?", modelName)
|
||||
}
|
||||
if username != "" {
|
||||
tx = tx.Where("logs.username = ?", username)
|
||||
}
|
||||
if tokenName != "" {
|
||||
tx = tx.Where("logs.token_name = ?", tokenName)
|
||||
}
|
||||
tx = applyLogContainsFilter(tx, "logs.model_name", modelName)
|
||||
tx = applyLogContainsFilter(tx, "logs.username", username)
|
||||
tx = applyLogContainsFilter(tx, "logs.token_name", tokenName)
|
||||
if requestId != "" {
|
||||
tx = tx.Where("logs.request_id = ?", requestId)
|
||||
}
|
||||
if upstreamRequestId != "" {
|
||||
tx = tx.Where("logs.upstream_request_id = ?", upstreamRequestId)
|
||||
}
|
||||
if startTimestamp != 0 {
|
||||
tx = tx.Where("logs.created_at >= ?", startTimestamp)
|
||||
}
|
||||
@@ -381,7 +384,7 @@ func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName
|
||||
|
||||
const logSearchCountLimit = 10000
|
||||
|
||||
func GetUserLogs(userId int, logType int, startTimestamp int64, endTimestamp int64, modelName string, tokenName string, startIdx int, num int, group string, requestId string) (logs []*Log, total int64, err error) {
|
||||
func GetUserLogs(userId int, logType int, startTimestamp int64, endTimestamp int64, modelName string, tokenName string, startIdx int, num int, group string, requestId string, upstreamRequestId string) (logs []*Log, total int64, err error) {
|
||||
var tx *gorm.DB
|
||||
if logType == LogTypeUnknown {
|
||||
tx = LOG_DB.Where("logs.user_id = ?", userId)
|
||||
@@ -389,19 +392,14 @@ func GetUserLogs(userId int, logType int, startTimestamp int64, endTimestamp int
|
||||
tx = LOG_DB.Where("logs.user_id = ? and logs.type = ?", userId, logType)
|
||||
}
|
||||
|
||||
if modelName != "" {
|
||||
modelNamePattern, err := sanitizeLikePattern(modelName)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
tx = tx.Where("logs.model_name LIKE ? ESCAPE '!'", modelNamePattern)
|
||||
}
|
||||
if tokenName != "" {
|
||||
tx = tx.Where("logs.token_name = ?", tokenName)
|
||||
}
|
||||
tx = applyLogContainsFilter(tx, "logs.model_name", modelName)
|
||||
tx = applyLogContainsFilter(tx, "logs.token_name", tokenName)
|
||||
if requestId != "" {
|
||||
tx = tx.Where("logs.request_id = ?", requestId)
|
||||
}
|
||||
if upstreamRequestId != "" {
|
||||
tx = tx.Where("logs.upstream_request_id = ?", upstreamRequestId)
|
||||
}
|
||||
if startTimestamp != 0 {
|
||||
tx = tx.Where("logs.created_at >= ?", startTimestamp)
|
||||
}
|
||||
@@ -432,34 +430,42 @@ type Stat struct {
|
||||
Tpm int `json:"tpm"`
|
||||
}
|
||||
|
||||
func logContainsPattern(input string) (string, bool) {
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
replacer := strings.NewReplacer("!", "!!", "%", "!%", "_", "!_")
|
||||
return "%" + replacer.Replace(input) + "%", true
|
||||
}
|
||||
|
||||
func applyLogContainsFilter(tx *gorm.DB, column string, value string) *gorm.DB {
|
||||
pattern, ok := logContainsPattern(value)
|
||||
if !ok {
|
||||
return tx
|
||||
}
|
||||
return tx.Where(column+" LIKE ? ESCAPE '!'", pattern)
|
||||
}
|
||||
|
||||
func SumUsedQuota(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string, channel int, group string) (stat Stat, err error) {
|
||||
tx := LOG_DB.Table("logs").Select("sum(quota) quota")
|
||||
|
||||
// 为rpm和tpm创建单独的查询
|
||||
rpmTpmQuery := LOG_DB.Table("logs").Select("count(*) rpm, sum(prompt_tokens) + sum(completion_tokens) tpm")
|
||||
|
||||
if username != "" {
|
||||
tx = tx.Where("username = ?", username)
|
||||
rpmTpmQuery = rpmTpmQuery.Where("username = ?", username)
|
||||
}
|
||||
if tokenName != "" {
|
||||
tx = tx.Where("token_name = ?", tokenName)
|
||||
rpmTpmQuery = rpmTpmQuery.Where("token_name = ?", tokenName)
|
||||
}
|
||||
tx = applyLogContainsFilter(tx, "username", username)
|
||||
rpmTpmQuery = applyLogContainsFilter(rpmTpmQuery, "username", username)
|
||||
tx = applyLogContainsFilter(tx, "token_name", tokenName)
|
||||
rpmTpmQuery = applyLogContainsFilter(rpmTpmQuery, "token_name", tokenName)
|
||||
if startTimestamp != 0 {
|
||||
tx = tx.Where("created_at >= ?", startTimestamp)
|
||||
}
|
||||
if endTimestamp != 0 {
|
||||
tx = tx.Where("created_at <= ?", endTimestamp)
|
||||
}
|
||||
if modelName != "" {
|
||||
modelNamePattern, err := sanitizeLikePattern(modelName)
|
||||
if err != nil {
|
||||
return stat, err
|
||||
}
|
||||
tx = tx.Where("model_name LIKE ? ESCAPE '!'", modelNamePattern)
|
||||
rpmTpmQuery = rpmTpmQuery.Where("model_name LIKE ? ESCAPE '!'", modelNamePattern)
|
||||
}
|
||||
tx = applyLogContainsFilter(tx, "model_name", modelName)
|
||||
rpmTpmQuery = applyLogContainsFilter(rpmTpmQuery, "model_name", modelName)
|
||||
if channel != 0 {
|
||||
tx = tx.Where("channel_id = ?", channel)
|
||||
rpmTpmQuery = rpmTpmQuery.Where("channel_id = ?", channel)
|
||||
|
||||
+17
-10
@@ -37,13 +37,13 @@ func UpsertPerfMetric(metric *PerfMetric) error {
|
||||
{Name: "bucket_ts"},
|
||||
},
|
||||
DoUpdates: clause.Assignments(map[string]interface{}{
|
||||
"request_count": gorm.Expr("request_count + ?", metric.RequestCount),
|
||||
"success_count": gorm.Expr("success_count + ?", metric.SuccessCount),
|
||||
"total_latency_ms": gorm.Expr("total_latency_ms + ?", metric.TotalLatencyMs),
|
||||
"ttft_sum_ms": gorm.Expr("ttft_sum_ms + ?", metric.TtftSumMs),
|
||||
"ttft_count": gorm.Expr("ttft_count + ?", metric.TtftCount),
|
||||
"output_tokens": gorm.Expr("output_tokens + ?", metric.OutputTokens),
|
||||
"generation_ms": gorm.Expr("generation_ms + ?", metric.GenerationMs),
|
||||
"request_count": gorm.Expr("perf_metrics.request_count + ?", metric.RequestCount),
|
||||
"success_count": gorm.Expr("perf_metrics.success_count + ?", metric.SuccessCount),
|
||||
"total_latency_ms": gorm.Expr("perf_metrics.total_latency_ms + ?", metric.TotalLatencyMs),
|
||||
"ttft_sum_ms": gorm.Expr("perf_metrics.ttft_sum_ms + ?", metric.TtftSumMs),
|
||||
"ttft_count": gorm.Expr("perf_metrics.ttft_count + ?", metric.TtftCount),
|
||||
"output_tokens": gorm.Expr("perf_metrics.output_tokens + ?", metric.OutputTokens),
|
||||
"generation_ms": gorm.Expr("perf_metrics.generation_ms + ?", metric.GenerationMs),
|
||||
}),
|
||||
}).Create(metric).Error
|
||||
}
|
||||
@@ -68,11 +68,18 @@ type PerfMetricSummary struct {
|
||||
GenerationMs int64 `json:"generation_ms"`
|
||||
}
|
||||
|
||||
func GetPerfMetricsSummaryAll(startTs int64, endTs int64) ([]PerfMetricSummary, error) {
|
||||
func GetPerfMetricsSummaryAll(startTs int64, endTs int64, groups []string) ([]PerfMetricSummary, error) {
|
||||
var summaries []PerfMetricSummary
|
||||
err := DB.Model(&PerfMetric{}).
|
||||
query := DB.Model(&PerfMetric{}).
|
||||
Select("model_name, SUM(request_count) as request_count, SUM(success_count) as success_count, SUM(total_latency_ms) as total_latency_ms, SUM(output_tokens) as output_tokens, SUM(generation_ms) as generation_ms").
|
||||
Where("bucket_ts >= ? AND bucket_ts <= ?", startTs, endTs).
|
||||
Where("bucket_ts >= ? AND bucket_ts <= ?", startTs, endTs)
|
||||
if groups != nil {
|
||||
if len(groups) == 0 {
|
||||
return summaries, nil
|
||||
}
|
||||
query = query.Where(commonGroupCol+" IN ?", groups)
|
||||
}
|
||||
err := query.
|
||||
Group("model_name").
|
||||
Having("SUM(request_count) > 0").
|
||||
Find(&summaries).Error
|
||||
|
||||
@@ -26,6 +26,7 @@ func TestMain(m *testing.M) {
|
||||
common.RedisEnabled = false
|
||||
common.BatchUpdateEnabled = false
|
||||
common.LogConsumeEnabled = true
|
||||
initCol()
|
||||
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
@@ -43,6 +44,7 @@ func TestMain(m *testing.M) {
|
||||
&SubscriptionPlan{},
|
||||
&SubscriptionOrder{},
|
||||
&UserSubscription{},
|
||||
&PerfMetric{},
|
||||
); err != nil {
|
||||
panic("failed to migrate: " + err.Error())
|
||||
}
|
||||
@@ -62,6 +64,7 @@ func truncateTables(t *testing.T) {
|
||||
DB.Exec("DELETE FROM subscription_orders")
|
||||
DB.Exec("DELETE FROM subscription_plans")
|
||||
DB.Exec("DELETE FROM user_subscriptions")
|
||||
DB.Exec("DELETE FROM perf_metrics")
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+5
-4
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
|
||||
"github.com/bytedance/gopkg/util/gopool"
|
||||
"gorm.io/gorm"
|
||||
@@ -34,8 +35,8 @@ type User struct {
|
||||
OidcId string `json:"oidc_id" gorm:"column:oidc_id;index"`
|
||||
WeChatId string `json:"wechat_id" gorm:"column:wechat_id;index"`
|
||||
TelegramId string `json:"telegram_id" gorm:"column:telegram_id;index"`
|
||||
VerificationCode string `json:"verification_code" gorm:"-:all"` // this field is only for Email verification, don't save it to database!
|
||||
AccessToken *string `json:"access_token" gorm:"type:char(32);column:access_token;uniqueIndex"` // this token is for system management
|
||||
VerificationCode string `json:"verification_code" gorm:"-:all"` // this field is only for Email verification, don't save it to database!
|
||||
AccessToken *string `json:"-" gorm:"type:char(32);column:access_token;uniqueIndex"` // this token is for system management
|
||||
Quota int `json:"quota" gorm:"type:int;default:0"`
|
||||
UsedQuota int `json:"used_quota" gorm:"type:int;default:0;column:used_quota"` // used quota
|
||||
RequestCount int `json:"request_count" gorm:"type:int;default:0;"` // request number
|
||||
@@ -420,7 +421,7 @@ func (user *User) Insert(inviterId int) error {
|
||||
if common.QuotaForNewUser > 0 {
|
||||
RecordLog(user.Id, LogTypeSystem, fmt.Sprintf("新用户注册赠送 %s", logger.LogQuota(common.QuotaForNewUser)))
|
||||
}
|
||||
if inviterId != 0 {
|
||||
if inviterId != 0 && operation_setting.IsPaymentComplianceConfirmed() {
|
||||
if common.QuotaForInvitee > 0 {
|
||||
_ = IncreaseUserQuota(user.Id, common.QuotaForInvitee, true)
|
||||
RecordLog(user.Id, LogTypeSystem, fmt.Sprintf("使用邀请码赠送 %s", logger.LogQuota(common.QuotaForInvitee)))
|
||||
@@ -481,7 +482,7 @@ func (user *User) FinalizeOAuthUserCreation(inviterId int) {
|
||||
if common.QuotaForNewUser > 0 {
|
||||
RecordLog(user.Id, LogTypeSystem, fmt.Sprintf("新用户注册赠送 %s", logger.LogQuota(common.QuotaForNewUser)))
|
||||
}
|
||||
if inviterId != 0 {
|
||||
if inviterId != 0 && operation_setting.IsPaymentComplianceConfirmed() {
|
||||
if common.QuotaForInvitee > 0 {
|
||||
_ = IncreaseUserQuota(user.Id, common.QuotaForInvitee, true)
|
||||
RecordLog(user.Id, LogTypeSystem, fmt.Sprintf("使用邀请码赠送 %s", logger.LogQuota(common.QuotaForInvitee)))
|
||||
|
||||
@@ -122,7 +122,7 @@ func Query(params QueryParams) (QueryResult, error) {
|
||||
return buildQueryResult(params.Model, merged), nil
|
||||
}
|
||||
|
||||
func QuerySummaryAll(hours int) (SummaryAllResult, error) {
|
||||
func QuerySummaryAll(hours int, groups []string) (SummaryAllResult, error) {
|
||||
if hours <= 0 {
|
||||
hours = 24
|
||||
}
|
||||
@@ -131,8 +131,9 @@ func QuerySummaryAll(hours int) (SummaryAllResult, error) {
|
||||
}
|
||||
endTs := time.Now().Unix()
|
||||
startTs := endTs - int64(hours)*3600
|
||||
allowedGroups := allowedGroupSet(groups)
|
||||
|
||||
rows, err := model.GetPerfMetricsSummaryAll(startTs, endTs)
|
||||
rows, err := model.GetPerfMetricsSummaryAll(startTs, endTs, groups)
|
||||
if err != nil {
|
||||
return SummaryAllResult{}, err
|
||||
}
|
||||
@@ -153,6 +154,11 @@ func QuerySummaryAll(hours int) (SummaryAllResult, error) {
|
||||
if k.bucketTs < startTs || k.bucketTs > endTs {
|
||||
return true
|
||||
}
|
||||
if allowedGroups != nil {
|
||||
if _, ok := allowedGroups[k.group]; !ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
snap := value.(*atomicBucket).snapshot()
|
||||
if snap.requestCount == 0 {
|
||||
return true
|
||||
@@ -187,12 +193,23 @@ func QuerySummaryAll(hours int) (SummaryAllResult, error) {
|
||||
})
|
||||
}
|
||||
sort.Slice(models, func(i, j int) bool {
|
||||
return models[i].ModelName < models[j].ModelName
|
||||
return models[i].RequestCount > models[j].RequestCount
|
||||
})
|
||||
|
||||
return SummaryAllResult{Models: models}, nil
|
||||
}
|
||||
|
||||
func allowedGroupSet(groups []string) map[string]struct{} {
|
||||
if groups == nil {
|
||||
return nil
|
||||
}
|
||||
allowed := make(map[string]struct{}, len(groups))
|
||||
for _, group := range groups {
|
||||
allowed[group] = struct{}{}
|
||||
}
|
||||
return allowed
|
||||
}
|
||||
|
||||
func bucketStart(ts int64) int64 {
|
||||
bucketSeconds := perf_metrics_setting.GetBucketSeconds()
|
||||
if bucketSeconds <= 0 {
|
||||
|
||||
@@ -52,7 +52,7 @@ type ModelSummary struct {
|
||||
AvgLatencyMs int64 `json:"avg_latency_ms"`
|
||||
SuccessRate float64 `json:"success_rate"`
|
||||
AvgTps float64 `json:"avg_tps"`
|
||||
RequestCount int64 `json:"request_count"`
|
||||
RequestCount int64 `json:"-"`
|
||||
}
|
||||
|
||||
type SummaryAllResult struct {
|
||||
|
||||
@@ -229,7 +229,7 @@ func asyncTaskWait(c *gin.Context, info *relaycommon.RelayInfo, taskID string) (
|
||||
time.Sleep(time.Duration(5) * time.Second)
|
||||
|
||||
for {
|
||||
logger.LogDebug(c, fmt.Sprintf("asyncTaskWait step %d/%d, wait %d seconds", step, maxStep, waitSeconds))
|
||||
logger.LogDebug(c, "asyncTaskWait step %d/%d, wait %d seconds", step, maxStep, waitSeconds)
|
||||
step++
|
||||
rsp, err, body := updateTask(info, taskID)
|
||||
responseBody = body
|
||||
@@ -320,11 +320,10 @@ func aliImageHandler(a *Adaptor, c *gin.Context, resp *http.Response, info *rela
|
||||
}
|
||||
}
|
||||
|
||||
//logger.LogDebug(c, "ali_async_task_result: "+string(originRespBody))
|
||||
if a.IsSyncImageModel {
|
||||
logger.LogDebug(c, "ali_sync_image_result: "+string(originRespBody))
|
||||
logger.LogDebug(c, "ali_sync_image_result: %s", originRespBody)
|
||||
} else {
|
||||
logger.LogDebug(c, "ali_async_image_result: "+string(originRespBody))
|
||||
logger.LogDebug(c, "ali_async_image_result: %s", originRespBody)
|
||||
}
|
||||
|
||||
imageResponses := responseAli2OpenAIImage(c, aliResponse, originRespBody, info, responseFormat)
|
||||
|
||||
@@ -292,9 +292,7 @@ func DoApiRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get request url failed: %w", err)
|
||||
}
|
||||
if common2.DebugEnabled {
|
||||
println("fullRequestURL:", fullRequestURL)
|
||||
}
|
||||
logger.LogDebug(c, "fullRequestURL: %s", fullRequestURL)
|
||||
req, err := http.NewRequest(c.Request.Method, fullRequestURL, requestBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new request failed: %w", err)
|
||||
@@ -323,9 +321,7 @@ func DoFormRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBod
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get request url failed: %w", err)
|
||||
}
|
||||
if common2.DebugEnabled {
|
||||
println("fullRequestURL:", fullRequestURL)
|
||||
}
|
||||
logger.LogDebug(c, "fullRequestURL: %s", fullRequestURL)
|
||||
req, err := http.NewRequest(c.Request.Method, fullRequestURL, requestBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new request failed: %w", err)
|
||||
@@ -388,13 +384,9 @@ func startPingKeepAlive(c *gin.Context, pingInterval time.Duration) context.Canc
|
||||
defer func() {
|
||||
// 增加panic恢复处理
|
||||
if r := recover(); r != nil {
|
||||
if common2.DebugEnabled {
|
||||
println("SSE ping goroutine panic recovered:", fmt.Sprintf("%v", r))
|
||||
}
|
||||
}
|
||||
if common2.DebugEnabled {
|
||||
println("SSE ping goroutine stopped.")
|
||||
logger.LogDebug(c, "SSE ping goroutine panic recovered: %v", r)
|
||||
}
|
||||
logger.LogDebug(c, "SSE ping goroutine stopped")
|
||||
}()
|
||||
|
||||
if pingInterval <= 0 {
|
||||
@@ -405,15 +397,11 @@ func startPingKeepAlive(c *gin.Context, pingInterval time.Duration) context.Canc
|
||||
// 确保在任何情况下都清理ticker
|
||||
defer func() {
|
||||
ticker.Stop()
|
||||
if common2.DebugEnabled {
|
||||
println("SSE ping ticker stopped")
|
||||
}
|
||||
logger.LogDebug(c, "SSE ping ticker stopped")
|
||||
}()
|
||||
|
||||
var pingMutex sync.Mutex
|
||||
if common2.DebugEnabled {
|
||||
println("SSE ping goroutine started")
|
||||
}
|
||||
logger.LogDebug(c, "SSE ping goroutine started")
|
||||
|
||||
// 增加超时控制,防止goroutine长时间运行
|
||||
maxPingDuration := 120 * time.Minute // 最大ping持续时间
|
||||
@@ -425,9 +413,7 @@ func startPingKeepAlive(c *gin.Context, pingInterval time.Duration) context.Canc
|
||||
// 发送 ping 数据
|
||||
case <-ticker.C:
|
||||
if err := sendPingData(c, &pingMutex); err != nil {
|
||||
if common2.DebugEnabled {
|
||||
println("SSE ping error, stopping goroutine:", err.Error())
|
||||
}
|
||||
logger.LogDebug(c, "SSE ping error, stopping goroutine: %s", err.Error())
|
||||
return
|
||||
}
|
||||
// 收到退出信号
|
||||
@@ -438,9 +424,7 @@ func startPingKeepAlive(c *gin.Context, pingInterval time.Duration) context.Canc
|
||||
return
|
||||
// 超时保护,防止goroutine无限运行
|
||||
case <-pingTimeout.C:
|
||||
if common2.DebugEnabled {
|
||||
println("SSE ping goroutine timeout, stopping")
|
||||
}
|
||||
logger.LogDebug(c, "SSE ping goroutine timeout, stopping")
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -463,9 +447,7 @@ func sendPingData(c *gin.Context, mutex *sync.Mutex) error {
|
||||
return
|
||||
}
|
||||
|
||||
if common2.DebugEnabled {
|
||||
println("SSE ping data sent.")
|
||||
}
|
||||
logger.LogDebug(c, "SSE ping data sent")
|
||||
done <- nil
|
||||
}()
|
||||
|
||||
@@ -507,9 +489,7 @@ func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http
|
||||
defer func() {
|
||||
if stopPinger != nil {
|
||||
stopPinger()
|
||||
if common2.DebugEnabled {
|
||||
println("SSE ping goroutine stopped by defer")
|
||||
}
|
||||
logger.LogDebug(c, "SSE ping goroutine stopped by defer")
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -524,6 +504,10 @@ func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http
|
||||
return nil, errors.New("resp is nil")
|
||||
}
|
||||
|
||||
if upID := resp.Header.Get(common2.RequestIdKey); upID != "" {
|
||||
c.Set(common2.UpstreamRequestIdKey, upID)
|
||||
}
|
||||
|
||||
_ = req.Body.Close()
|
||||
_ = c.Request.Body.Close()
|
||||
return resp, nil
|
||||
|
||||
@@ -949,9 +949,7 @@ func ClaudeHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayI
|
||||
if err != nil {
|
||||
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
|
||||
}
|
||||
if common.DebugEnabled {
|
||||
println("responseBody: ", string(responseBody))
|
||||
}
|
||||
logger.LogDebug(c, "responseBody: %s", responseBody)
|
||||
handleErr := HandleClaudeResponseData(c, info, claudeInfo, resp, responseBody)
|
||||
if handleErr != nil {
|
||||
return nil, handleErr
|
||||
|
||||
@@ -26,9 +26,7 @@ func GeminiTextGenerationHandler(c *gin.Context, info *relaycommon.RelayInfo, re
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
if common.DebugEnabled {
|
||||
println(string(responseBody))
|
||||
}
|
||||
logger.LogDebug(c, "Gemini native response body: %s", responseBody)
|
||||
|
||||
// 解析为 Gemini 原生响应格式
|
||||
var geminiResponse dto.GeminiChatResponse
|
||||
@@ -57,9 +55,7 @@ func NativeGeminiEmbeddingHandler(c *gin.Context, resp *http.Response, info *rel
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
if common.DebugEnabled {
|
||||
println(string(responseBody))
|
||||
}
|
||||
logger.LogDebug(c, "Gemini native embedding response body: %s", responseBody)
|
||||
|
||||
usage := service.ResponseText2Usage(c, "", info.UpstreamModelName, info.GetEstimatePromptTokens())
|
||||
|
||||
|
||||
@@ -1362,7 +1362,7 @@ func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogDebug(c, fmt.Sprintf("info.SendResponseCount = %d", info.SendResponseCount))
|
||||
logger.LogDebug(c, "info.SendResponseCount = %d", info.SendResponseCount)
|
||||
if info.SendResponseCount == 0 {
|
||||
// send first response
|
||||
emptyResponse := helper.GenerateStartEmptyResponse(id, createAt, info.UpstreamModelName, nil)
|
||||
@@ -1422,9 +1422,7 @@ func GeminiChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
service.CloseResponseBodyGracefully(resp)
|
||||
if common.DebugEnabled {
|
||||
println(string(responseBody))
|
||||
}
|
||||
logger.LogDebug(c, "Gemini response body: %s", responseBody)
|
||||
var geminiResponse dto.GeminiChatResponse
|
||||
err = common.Unmarshal(responseBody, &geminiResponse)
|
||||
if err != nil {
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -184,6 +185,9 @@ func handleChatCompletionResponse(c *gin.Context, resp *http.Response, info *rel
|
||||
|
||||
// Set response headers
|
||||
for key, values := range resp.Header {
|
||||
if !service.ShouldCopyUpstreamHeader(c, key, values) {
|
||||
continue
|
||||
}
|
||||
for _, value := range values {
|
||||
c.Header(key, value)
|
||||
}
|
||||
|
||||
@@ -377,7 +377,7 @@ func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInf
|
||||
}
|
||||
|
||||
// 打印类似 curl 命令格式的信息
|
||||
logger.LogDebug(c.Request.Context(), fmt.Sprintf("--form 'model=\"%s\"'", request.Model))
|
||||
logger.LogDebug(c.Request.Context(), "--form 'model=\"%s\"'", request.Model)
|
||||
|
||||
// 遍历表单字段并打印输出
|
||||
for key, values := range formData.Value {
|
||||
@@ -386,7 +386,7 @@ func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInf
|
||||
}
|
||||
for _, value := range values {
|
||||
writer.WriteField(key, value)
|
||||
logger.LogDebug(c.Request.Context(), fmt.Sprintf("--form '%s=\"%s\"'", key, value))
|
||||
logger.LogDebug(c.Request.Context(), "--form '%s=\"%s\"'", key, value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -398,8 +398,8 @@ func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInf
|
||||
|
||||
// 使用 formData 中的第一个文件
|
||||
fileHeader := fileHeaders[0]
|
||||
logger.LogDebug(c.Request.Context(), fmt.Sprintf("--form 'file=@\"%s\"' (size: %d bytes, content-type: %s)",
|
||||
fileHeader.Filename, fileHeader.Size, fileHeader.Header.Get("Content-Type")))
|
||||
logger.LogDebug(c.Request.Context(), "--form 'file=@\"%s\"' (size: %d bytes, content-type: %s)",
|
||||
fileHeader.Filename, fileHeader.Size, fileHeader.Header.Get("Content-Type"))
|
||||
|
||||
file, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
@@ -418,7 +418,7 @@ func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInf
|
||||
// 关闭 multipart 编写器以设置分界线
|
||||
writer.Close()
|
||||
c.Request.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
logger.LogDebug(c.Request.Context(), fmt.Sprintf("--header 'Content-Type: %s'", writer.FormDataContentType()))
|
||||
logger.LogDebug(c.Request.Context(), "--header 'Content-Type: %s'", writer.FormDataContentType())
|
||||
return &requestBody, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,9 @@ func OpenaiTTSHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rel
|
||||
usage.PromptTokens = info.GetEstimatePromptTokens()
|
||||
usage.TotalTokens = info.GetEstimatePromptTokens()
|
||||
for k, v := range resp.Header {
|
||||
if !service.ShouldCopyUpstreamHeader(c, k, v) {
|
||||
continue
|
||||
}
|
||||
c.Writer.Header().Set(k, v[0])
|
||||
}
|
||||
c.Writer.WriteHeader(resp.StatusCode)
|
||||
|
||||
@@ -155,9 +155,9 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re
|
||||
containStreamUsage = true
|
||||
|
||||
if common.DebugEnabled {
|
||||
logger.LogDebug(c, fmt.Sprintf("Audio model usage extracted from second last SSE: PromptTokens=%d, CompletionTokens=%d, TotalTokens=%d, InputTokens=%d, OutputTokens=%d",
|
||||
logger.LogDebug(c, "Audio model usage extracted from second last SSE: PromptTokens=%d, CompletionTokens=%d, TotalTokens=%d, InputTokens=%d, OutputTokens=%d",
|
||||
usage.PromptTokens, usage.CompletionTokens, usage.TotalTokens,
|
||||
usage.InputTokens, usage.OutputTokens))
|
||||
usage.InputTokens, usage.OutputTokens)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -200,9 +200,7 @@ func OpenaiHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo
|
||||
if err != nil {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError)
|
||||
}
|
||||
if common.DebugEnabled {
|
||||
println("upstream response body:", string(responseBody))
|
||||
}
|
||||
logger.LogDebug(c, "upstream response body: %s", responseBody)
|
||||
// Unmarshal to simpleResponse
|
||||
if info.ChannelType == constant.ChannelTypeOpenRouter && info.ChannelOtherSettings.IsOpenRouterEnterprise() {
|
||||
// 尝试解析为 openrouter enterprise
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/relay/helper"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
@@ -177,9 +178,7 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
|
||||
}
|
||||
}
|
||||
|
||||
if common.DebugEnabled {
|
||||
println("requestBody: ", string(jsonData))
|
||||
}
|
||||
logger.LogDebug(c, "requestBody: %s", jsonData)
|
||||
requestBody = bytes.NewBuffer(jsonData)
|
||||
}
|
||||
|
||||
@@ -202,7 +201,6 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
|
||||
}
|
||||
|
||||
usage, newAPIError := adaptor.DoResponse(c, httpResp, info)
|
||||
//log.Printf("usage: %v", usage)
|
||||
if newAPIError != nil {
|
||||
// reset status code 重置状态码
|
||||
service.ResetStatusCode(newAPIError, statusCodeMappingStr)
|
||||
|
||||
+22
-10
@@ -26,13 +26,20 @@ const (
|
||||
|
||||
var errSourceHeaderNotFound = errors.New("source header does not exist")
|
||||
|
||||
var paramOverrideKeyAuditPaths = map[string]struct{}{
|
||||
"model": {},
|
||||
"original_model": {},
|
||||
"upstream_model": {},
|
||||
"service_tier": {},
|
||||
"inference_geo": {},
|
||||
"speed": {},
|
||||
var paramOverrideSensitivePathPrefixes = []string{
|
||||
"model",
|
||||
"original_model",
|
||||
"upstream_model",
|
||||
"service_tier",
|
||||
"inference_geo",
|
||||
"speed",
|
||||
"messages",
|
||||
"input",
|
||||
"instructions",
|
||||
"system",
|
||||
"contents",
|
||||
"systemInstruction",
|
||||
"system_instruction",
|
||||
}
|
||||
|
||||
type paramOverrideAuditRecorder struct {
|
||||
@@ -206,6 +213,7 @@ func shouldEnableParamOverrideAudit(paramOverride map[string]interface{}) bool {
|
||||
if operations, ok := tryParseOperations(paramOverride); ok {
|
||||
for _, operation := range operations {
|
||||
if shouldAuditParamPath(strings.TrimSpace(operation.Path)) ||
|
||||
shouldAuditParamPath(strings.TrimSpace(operation.From)) ||
|
||||
shouldAuditParamPath(strings.TrimSpace(operation.To)) {
|
||||
return true
|
||||
}
|
||||
@@ -255,15 +263,19 @@ func shouldAuditParamPath(path string) bool {
|
||||
if common.DebugEnabled {
|
||||
return true
|
||||
}
|
||||
_, ok := paramOverrideKeyAuditPaths[path]
|
||||
return ok
|
||||
for _, prefix := range paramOverrideSensitivePathPrefixes {
|
||||
if path == prefix || strings.HasPrefix(path, prefix+".") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func shouldAuditOperation(mode, path, from, to string) bool {
|
||||
if common.DebugEnabled {
|
||||
return true
|
||||
}
|
||||
for _, candidate := range []string{path, to} {
|
||||
for _, candidate := range []string{path, from, to} {
|
||||
if shouldAuditParamPath(candidate) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/setting/model_setting"
|
||||
"github.com/samber/lo"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestApplyParamOverrideTrimPrefix(t *testing.T) {
|
||||
@@ -2184,6 +2185,95 @@ func TestApplyParamOverrideWithRelayInfoRecordsOnlyKeyOperationsWhenDebugDisable
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyParamOverrideWithRelayInfoRecordsConversationBodyOperationsWhenDebugDisabled(t *testing.T) {
|
||||
originalDebugEnabled := common2.DebugEnabled
|
||||
common2.DebugEnabled = false
|
||||
t.Cleanup(func() {
|
||||
common2.DebugEnabled = originalDebugEnabled
|
||||
})
|
||||
|
||||
info := &RelayInfo{
|
||||
ChannelMeta: &ChannelMeta{
|
||||
ParamOverride: map[string]interface{}{
|
||||
"operations": []interface{}{
|
||||
map[string]interface{}{
|
||||
"mode": "replace",
|
||||
"path": "messages.0.content",
|
||||
"from": "hello",
|
||||
"to": "hi",
|
||||
},
|
||||
map[string]interface{}{
|
||||
"mode": "set",
|
||||
"path": "input.0.content.0.text",
|
||||
"value": "rewritten response input",
|
||||
},
|
||||
map[string]interface{}{
|
||||
"mode": "set",
|
||||
"path": "instructions",
|
||||
"value": "new instruction",
|
||||
},
|
||||
map[string]interface{}{
|
||||
"mode": "append",
|
||||
"path": "contents.0.parts",
|
||||
"value": map[string]interface{}{"text": "new gemini part"},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"mode": "copy",
|
||||
"from": "system",
|
||||
"to": "metadata.system_copy",
|
||||
},
|
||||
map[string]interface{}{
|
||||
"mode": "set",
|
||||
"path": "temperature",
|
||||
"value": 0.1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
out, err := ApplyParamOverrideWithRelayInfo([]byte(`{
|
||||
"messages":[{"role":"user","content":"hello world"}],
|
||||
"input":[{"role":"user","content":[{"type":"input_text","text":"original response input"}]}],
|
||||
"instructions":"old instruction",
|
||||
"system":"old system",
|
||||
"contents":[{"role":"user","parts":[{"text":"hello gemini"}]}],
|
||||
"temperature":0.7
|
||||
}`), info)
|
||||
require.NoError(t, err)
|
||||
assertJSONEqual(t, `{
|
||||
"messages":[{"role":"user","content":"hi world"}],
|
||||
"input":[{"role":"user","content":[{"type":"input_text","text":"rewritten response input"}]}],
|
||||
"instructions":"new instruction",
|
||||
"system":"old system",
|
||||
"contents":[{"role":"user","parts":[{"text":"hello gemini"},{"text":"new gemini part"}]}],
|
||||
"temperature":0.1,
|
||||
"metadata":{"system_copy":"old system"}
|
||||
}`, string(out))
|
||||
|
||||
require.Equal(t, []string{
|
||||
"replace messages.0.content from hello to hi",
|
||||
"set input.0.content.0.text = rewritten response input",
|
||||
"set instructions = new instruction",
|
||||
"append contents.0.parts with {\"text\":\"new gemini part\"}",
|
||||
"copy system -> metadata.system_copy",
|
||||
}, info.ParamOverrideAudit)
|
||||
}
|
||||
|
||||
func TestShouldAuditParamPathUsesFieldBoundaryPrefixMatching(t *testing.T) {
|
||||
originalDebugEnabled := common2.DebugEnabled
|
||||
common2.DebugEnabled = false
|
||||
t.Cleanup(func() {
|
||||
common2.DebugEnabled = originalDebugEnabled
|
||||
})
|
||||
|
||||
require.True(t, shouldAuditParamPath("messages"))
|
||||
require.True(t, shouldAuditParamPath("messages.0.content"))
|
||||
require.True(t, shouldAuditParamPath("systemInstruction.parts.0.text"))
|
||||
require.False(t, shouldAuditParamPath("model_name"))
|
||||
require.False(t, shouldAuditParamPath("message"))
|
||||
}
|
||||
|
||||
func assertJSONEqual(t *testing.T, want, got string) {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/relay/channel/xinference"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
@@ -21,9 +22,7 @@ func RerankHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError)
|
||||
}
|
||||
service.CloseResponseBodyGracefully(resp)
|
||||
if common.DebugEnabled {
|
||||
println("reranker response body: ", string(responseBody))
|
||||
}
|
||||
logger.LogDebug(c, "reranker response body: %s", responseBody)
|
||||
var jinaResp dto.RerankResponse
|
||||
if info.ChannelType == constant.ChannelTypeXinference {
|
||||
var xinRerankResponse xinference.XinRerankResponse
|
||||
|
||||
@@ -102,7 +102,7 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types
|
||||
}
|
||||
if common.DebugEnabled {
|
||||
if debugBytes, bErr := storage.Bytes(); bErr == nil {
|
||||
println("requestBody: ", string(debugBytes))
|
||||
logger.LogDebug(c, "requestBody: %s", debugBytes)
|
||||
}
|
||||
}
|
||||
requestBody = common.ReaderOnly(storage)
|
||||
@@ -174,7 +174,7 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogDebug(c, fmt.Sprintf("text request body: %s", string(jsonData)))
|
||||
logger.LogDebug(c, "text request body: %s", jsonData)
|
||||
|
||||
requestBody = bytes.NewBuffer(jsonData)
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ func EmbeddingHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogDebug(c, fmt.Sprintf("converted embedding request body: %s", string(jsonData)))
|
||||
logger.LogDebug(c, "converted embedding request body: %s", jsonData)
|
||||
var requestBody io.Reader = bytes.NewBuffer(jsonData)
|
||||
statusCodeMappingStr := c.GetString("status_code_mapping")
|
||||
resp, err := adaptor.DoRequest(c, info, requestBody)
|
||||
|
||||
@@ -163,7 +163,7 @@ func GeminiHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogDebug(c, "Gemini request body: "+string(jsonData))
|
||||
logger.LogDebug(c, "Gemini request body: %s", jsonData)
|
||||
|
||||
requestBody = bytes.NewReader(jsonData)
|
||||
}
|
||||
@@ -262,7 +262,7 @@ func GeminiEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo) (newAPI
|
||||
return newAPIErrorFromParamOverride(err)
|
||||
}
|
||||
}
|
||||
logger.LogDebug(c, "Gemini embedding request body: "+string(jsonData))
|
||||
logger.LogDebug(c, "Gemini embedding request body: %s", jsonData)
|
||||
requestBody = bytes.NewReader(jsonData)
|
||||
|
||||
resp, err := adaptor.DoRequest(c, info, requestBody)
|
||||
|
||||
@@ -45,7 +45,7 @@ func HandleGroupRatio(ctx *gin.Context, relayInfo *relaycommon.RelayInfo) types.
|
||||
// check auto group
|
||||
autoGroup, exists := ctx.Get("auto_group")
|
||||
if exists {
|
||||
logger.LogDebug(ctx, fmt.Sprintf("final group: %s", autoGroup))
|
||||
logger.LogDebug(ctx, "final group: %s", autoGroup)
|
||||
relayInfo.UsingGroup = autoGroup.(string)
|
||||
}
|
||||
|
||||
@@ -157,7 +157,7 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
|
||||
}
|
||||
|
||||
if common.DebugEnabled {
|
||||
println(fmt.Sprintf("model_price_helper result: %s", priceData.ToSetting()))
|
||||
logger.LogDebug(c, "model_price_helper result: %s", priceData.ToSetting())
|
||||
}
|
||||
info.PriceData = priceData
|
||||
return priceData, nil
|
||||
@@ -299,9 +299,7 @@ func modelPriceHelperTiered(c *gin.Context, info *relaycommon.RelayInfo, promptT
|
||||
QuotaToPreConsume: preConsumedQuota,
|
||||
}
|
||||
|
||||
if common.DebugEnabled {
|
||||
println(fmt.Sprintf("model_price_helper_tiered result: model=%s preConsume=%d quotaBeforeGroup=%.2f groupRatio=%.2f tier=%s", info.OriginModelName, preConsumedQuota, quotaBeforeGroup, groupRatioInfo.GroupRatio, trace.MatchedTier))
|
||||
}
|
||||
logger.LogDebug(c, "model_price_helper_tiered result: model=%s preConsume=%d quotaBeforeGroup=%.2f groupRatio=%.2f tier=%s", info.OriginModelName, preConsumedQuota, quotaBeforeGroup, groupRatioInfo.GroupRatio, trace.MatchedTier)
|
||||
|
||||
info.PriceData = priceData
|
||||
return priceData, nil
|
||||
|
||||
@@ -72,14 +72,11 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
|
||||
pingTicker = time.NewTicker(pingInterval)
|
||||
}
|
||||
|
||||
if common.DebugEnabled {
|
||||
// print timeout and ping interval for debugging
|
||||
println("relay timeout seconds:", common.RelayTimeout)
|
||||
println("relay max idle conns:", common.RelayMaxIdleConns)
|
||||
println("relay max idle conns per host:", common.RelayMaxIdleConnsPerHost)
|
||||
println("streaming timeout seconds:", int64(streamingTimeout.Seconds()))
|
||||
println("ping interval seconds:", int64(pingInterval.Seconds()))
|
||||
}
|
||||
logger.LogDebug(c, "relay timeout seconds: %d", common.RelayTimeout)
|
||||
logger.LogDebug(c, "relay max idle conns: %d", common.RelayMaxIdleConns)
|
||||
logger.LogDebug(c, "relay max idle conns per host: %d", common.RelayMaxIdleConnsPerHost)
|
||||
logger.LogDebug(c, "streaming timeout seconds: %d", int64(streamingTimeout.Seconds()))
|
||||
logger.LogDebug(c, "ping interval seconds: %d", int64(pingInterval.Seconds()))
|
||||
|
||||
// 改进资源清理,确保所有 goroutine 正确退出
|
||||
defer func() {
|
||||
@@ -127,9 +124,7 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
|
||||
info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonPanic, fmt.Errorf("ping panic: %v", r))
|
||||
common.SafeSendBool(stopChan, true)
|
||||
}
|
||||
if common.DebugEnabled {
|
||||
println("ping goroutine exited")
|
||||
}
|
||||
logger.LogDebug(c, "ping goroutine exited")
|
||||
}()
|
||||
|
||||
// 添加超时保护,防止 goroutine 无限运行
|
||||
@@ -155,9 +150,7 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
|
||||
info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonPingFail, err)
|
||||
return
|
||||
}
|
||||
if common.DebugEnabled {
|
||||
println("ping data sent")
|
||||
}
|
||||
logger.LogDebug(c, "ping data sent")
|
||||
case <-time.After(10 * time.Second):
|
||||
logger.LogError(c, "ping data send timeout")
|
||||
info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonPingFail, fmt.Errorf("ping send timeout"))
|
||||
@@ -217,9 +210,7 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
|
||||
info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonPanic, fmt.Errorf("scanner panic: %v", r))
|
||||
}
|
||||
common.SafeSendBool(stopChan, true)
|
||||
if common.DebugEnabled {
|
||||
println("scanner goroutine exited")
|
||||
}
|
||||
logger.LogDebug(c, "scanner goroutine exited")
|
||||
}()
|
||||
|
||||
for scanner.Scan() {
|
||||
@@ -237,9 +228,7 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
|
||||
|
||||
ticker.Reset(streamingTimeout)
|
||||
data := scanner.Text()
|
||||
if common.DebugEnabled {
|
||||
println(data)
|
||||
}
|
||||
logger.LogDebug(c, "stream scanner data: %s", data)
|
||||
|
||||
if len(data) < 6 {
|
||||
continue
|
||||
@@ -265,9 +254,7 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
|
||||
}
|
||||
} else {
|
||||
info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonDone, nil)
|
||||
if common.DebugEnabled {
|
||||
println("received [DONE], stopping scanner")
|
||||
}
|
||||
logger.LogDebug(c, "received [DONE], stopping scanner")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,9 +76,7 @@ func ImageHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type
|
||||
}
|
||||
}
|
||||
|
||||
if common.DebugEnabled {
|
||||
logger.LogDebug(c, fmt.Sprintf("image request body: %s", string(jsonData)))
|
||||
}
|
||||
logger.LogDebug(c, "image request body: %s", jsonData)
|
||||
requestBody = bytes.NewBuffer(jsonData)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
relayconstant "github.com/QuantumNous/new-api/relay/constant"
|
||||
@@ -473,7 +474,7 @@ func RelayMidjourneySubmit(c *gin.Context, relayInfo *relaycommon.RelayInfo) *dt
|
||||
c.Set("base_url", channel.GetBaseURL())
|
||||
c.Set("channel_id", originTask.ChannelId)
|
||||
c.Request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", channel.Key))
|
||||
log.Printf("检测到此操作为放大、变换、重绘,获取原channel信息: %s,%s", strconv.Itoa(originTask.ChannelId), channel.GetBaseURL())
|
||||
logger.LogDebug(c, "Midjourney action uses origin channel: id=%s, base_url=%s", strconv.Itoa(originTask.ChannelId), channel.GetBaseURL())
|
||||
}
|
||||
midjRequest.Prompt = originTask.Prompt
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/relay/helper"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
@@ -67,9 +68,7 @@ func RerankHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
|
||||
}
|
||||
}
|
||||
|
||||
if common.DebugEnabled {
|
||||
println(fmt.Sprintf("Rerank request body: %s", string(jsonData)))
|
||||
}
|
||||
logger.LogDebug(c, "Rerank request body: %s", jsonData)
|
||||
requestBody = bytes.NewBuffer(jsonData)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
appconstant "github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
relayconstant "github.com/QuantumNous/new-api/relay/constant"
|
||||
"github.com/QuantumNous/new-api/relay/helper"
|
||||
@@ -102,9 +103,7 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *
|
||||
}
|
||||
}
|
||||
|
||||
if common.DebugEnabled {
|
||||
println("requestBody: ", string(jsonData))
|
||||
}
|
||||
logger.LogDebug(c, "requestBody: %s", jsonData)
|
||||
requestBody = bytes.NewBuffer(jsonData)
|
||||
}
|
||||
|
||||
|
||||
@@ -30,14 +30,14 @@ func SetApiRouter(router *gin.Engine) {
|
||||
apiRouter.GET("/about", controller.GetAbout)
|
||||
//apiRouter.GET("/midjourney", controller.GetMidjourney)
|
||||
apiRouter.GET("/home_page_content", controller.GetHomePageContent)
|
||||
apiRouter.GET("/pricing", middleware.TryUserAuth(), controller.GetPricing)
|
||||
apiRouter.GET("/pricing", middleware.HeaderNavModuleAuth("pricing"), controller.GetPricing)
|
||||
perfMetricsRoute := apiRouter.Group("/perf-metrics")
|
||||
perfMetricsRoute.Use(middleware.TryUserAuth())
|
||||
perfMetricsRoute.Use(middleware.HeaderNavModulePublicOrUserAuth("pricing"))
|
||||
{
|
||||
perfMetricsRoute.GET("/summary", controller.GetPerfMetricsSummary)
|
||||
perfMetricsRoute.GET("", controller.GetPerfMetrics)
|
||||
}
|
||||
apiRouter.GET("/rankings", controller.GetRankings)
|
||||
apiRouter.GET("/rankings", middleware.HeaderNavModuleAuth("rankings"), controller.GetRankings)
|
||||
apiRouter.GET("/verification", middleware.EmailVerificationRateLimit(), middleware.TurnstileCheck(), controller.SendEmailVerification)
|
||||
apiRouter.GET("/reset_password", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.SendPasswordResetEmail)
|
||||
apiRouter.POST("/user/reset", middleware.CriticalRateLimit(), controller.ResetPassword)
|
||||
@@ -181,6 +181,7 @@ func SetApiRouter(router *gin.Engine) {
|
||||
{
|
||||
optionRoute.GET("/", controller.GetOptions)
|
||||
optionRoute.PUT("/", controller.UpdateOption)
|
||||
optionRoute.POST("/payment_compliance", controller.ConfirmPaymentCompliance)
|
||||
optionRoute.GET("/channel_affinity_cache", controller.GetChannelAffinityCacheStats)
|
||||
optionRoute.DELETE("/channel_affinity_cache", controller.ClearChannelAffinityCache)
|
||||
optionRoute.POST("/rest_model_ratio", controller.ResetModelRatio)
|
||||
|
||||
@@ -302,6 +302,11 @@ func extractChannelAffinityValue(c *gin.Context, src operation_setting.ChannelAf
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(c.GetString(src.Key))
|
||||
case "request_header":
|
||||
if c == nil || c.Request == nil || src.Key == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(c.Request.Header.Get(src.Key))
|
||||
case "gjson":
|
||||
if src.Path == "" {
|
||||
return ""
|
||||
|
||||
@@ -176,6 +176,66 @@ func TestShouldSkipRetryAfterChannelAffinityFailure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractChannelAffinityValue_RequestHeader(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(rec)
|
||||
ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
|
||||
ctx.Request.Header.Set("X-Affinity-Key", " tenant-123 ")
|
||||
|
||||
value := extractChannelAffinityValue(ctx, operation_setting.ChannelAffinityKeySource{
|
||||
Type: "request_header",
|
||||
Key: "X-Affinity-Key",
|
||||
})
|
||||
|
||||
require.Equal(t, "tenant-123", value)
|
||||
}
|
||||
|
||||
func TestGetPreferredChannelByAffinity_RequestHeaderKeySource(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
rule := operation_setting.ChannelAffinityRule{
|
||||
Name: "header-affinity",
|
||||
ModelRegex: []string{"^gpt-.*$"},
|
||||
PathRegex: []string{"/v1/responses"},
|
||||
KeySources: []operation_setting.ChannelAffinityKeySource{
|
||||
{Type: "request_header", Key: "X-Affinity-Key"},
|
||||
},
|
||||
IncludeRuleName: true,
|
||||
IncludeModelName: true,
|
||||
}
|
||||
|
||||
affinityValue := fmt.Sprintf("header-hit-%d", time.Now().UnixNano())
|
||||
cacheKeySuffix := buildChannelAffinityCacheKeySuffix(rule, "gpt-5", "default", affinityValue)
|
||||
|
||||
cache := getChannelAffinityCache()
|
||||
require.NoError(t, cache.SetWithTTL(cacheKeySuffix, 9528, time.Minute))
|
||||
t.Cleanup(func() {
|
||||
_, _ = cache.DeleteMany([]string{cacheKeySuffix})
|
||||
})
|
||||
|
||||
setting := operation_setting.GetChannelAffinitySetting()
|
||||
originalRules := setting.Rules
|
||||
setting.Rules = append([]operation_setting.ChannelAffinityRule{rule}, originalRules...)
|
||||
t.Cleanup(func() {
|
||||
setting.Rules = originalRules
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(rec)
|
||||
ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
|
||||
ctx.Request.Header.Set("X-Affinity-Key", affinityValue)
|
||||
|
||||
channelID, found := GetPreferredChannelByAffinity(ctx, "gpt-5", "default")
|
||||
require.True(t, found)
|
||||
require.Equal(t, 9528, channelID)
|
||||
|
||||
meta, ok := getChannelAffinityMeta(ctx)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "request_header", meta.KeySourceType)
|
||||
require.Equal(t, "X-Affinity-Key", meta.KeySourceKey)
|
||||
require.Equal(t, buildChannelAffinityKeyHint(affinityValue), meta.KeyHint)
|
||||
}
|
||||
|
||||
func TestChannelAffinityHitCodexTemplatePassHeadersEffective(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ func GetFileTypeFromUrl(c *gin.Context, url string, reason ...string) (string, e
|
||||
var readData []byte
|
||||
limits := []int{512, 8 * 1024, 24 * 1024, 64 * 1024}
|
||||
for _, limit := range limits {
|
||||
logger.LogDebug(c, fmt.Sprintf("Trying to read %d bytes to determine file type", limit))
|
||||
logger.LogDebug(c, "Trying to read %d bytes to determine file type", limit)
|
||||
if len(readData) < limit {
|
||||
need := limit - len(readData)
|
||||
tmp := make([]byte, need)
|
||||
|
||||
@@ -50,7 +50,7 @@ func LoadFileSource(c *gin.Context, source types.FileSource, reason ...string) (
|
||||
}
|
||||
|
||||
if common.DebugEnabled {
|
||||
logger.LogDebug(c, fmt.Sprintf("LoadFileSource starting for: %s", source.GetIdentifier()))
|
||||
logger.LogDebug(c, "LoadFileSource starting for: %s", source.GetIdentifier())
|
||||
}
|
||||
|
||||
// 1. 快速检查内部缓存
|
||||
@@ -208,7 +208,7 @@ func loadFromURL(c *gin.Context, url string, reason ...string) (*types.CachedFil
|
||||
}
|
||||
common.IncrementDiskFiles(base64Size)
|
||||
if common.DebugEnabled {
|
||||
logger.LogDebug(c, fmt.Sprintf("File cached to disk: %s, size: %d bytes", diskPath, base64Size))
|
||||
logger.LogDebug(c, "File cached to disk: %s, size: %d bytes", diskPath, base64Size)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
+20
-2
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
@@ -22,6 +23,24 @@ func CloseResponseBodyGracefully(httpResponse *http.Response) {
|
||||
}
|
||||
}
|
||||
|
||||
// ShouldCopyUpstreamHeader checks whether a given upstream response header
|
||||
// should be copied to the client response. It returns false for Content-Length
|
||||
// (managed separately) and X-Oneapi-Request-Id (to preserve the local instance
|
||||
// ID). When the upstream header is X-Oneapi-Request-Id, the value is captured
|
||||
// into the Gin context for later logging.
|
||||
func ShouldCopyUpstreamHeader(c *gin.Context, k string, v []string) bool {
|
||||
if strings.EqualFold(k, "Content-Length") {
|
||||
return false
|
||||
}
|
||||
if strings.EqualFold(k, common.RequestIdKey) {
|
||||
if c != nil && len(v) > 0 {
|
||||
c.Set(common.UpstreamRequestIdKey, v[0])
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func IOCopyBytesGracefully(c *gin.Context, src *http.Response, data []byte) {
|
||||
if c.Writer == nil {
|
||||
return
|
||||
@@ -35,8 +54,7 @@ func IOCopyBytesGracefully(c *gin.Context, src *http.Response, data []byte) {
|
||||
// For example, Postman will report error, and we cannot check the response at all.
|
||||
if src != nil {
|
||||
for k, v := range src.Header {
|
||||
// avoid setting Content-Length
|
||||
if k == "Content-Length" {
|
||||
if !ShouldCopyUpstreamHeader(c, k, v) {
|
||||
continue
|
||||
}
|
||||
c.Writer.Header().Set(k, v[0])
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -13,6 +12,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
relayconstant "github.com/QuantumNous/new-api/relay/constant"
|
||||
"github.com/QuantumNous/new-api/setting"
|
||||
|
||||
@@ -235,9 +235,8 @@ func DoMidjourneyHttpRequest(c *gin.Context, timeout time.Duration, fullRequestU
|
||||
return MidjourneyErrorWithStatusCodeWrapper(constant.MjErrorUnknown, "read_response_body_failed", statusCode), nullBytes, err
|
||||
}
|
||||
CloseResponseBodyGracefully(resp)
|
||||
respStr := string(responseBody)
|
||||
log.Printf("respStr: %s", respStr)
|
||||
if respStr == "" {
|
||||
logger.LogDebug(c, "midjourney response body: %s", responseBody)
|
||||
if len(responseBody) == 0 {
|
||||
return MidjourneyErrorWithStatusCodeWrapper(constant.MjErrorUnknown, "empty_response_body", statusCode), responseBody, nil
|
||||
} else {
|
||||
err = json.Unmarshal(responseBody, &midjResponse)
|
||||
@@ -248,7 +247,6 @@ func DoMidjourneyHttpRequest(c *gin.Context, timeout time.Duration, fullRequestU
|
||||
}
|
||||
}
|
||||
}
|
||||
//log.Printf("midjResponse: %v", midjResponse)
|
||||
//for k, v := range resp.Header {
|
||||
// c.Writer.Header().Set(k, v[0])
|
||||
//}
|
||||
|
||||
+3
-5
@@ -3,7 +3,6 @@ package service
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -17,7 +16,6 @@ import (
|
||||
perfmetrics "github.com/QuantumNous/new-api/pkg/perf_metrics"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||||
"github.com/QuantumNous/new-api/setting/system_setting"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
|
||||
"github.com/bytedance/gopkg/util/gopool"
|
||||
@@ -113,7 +111,7 @@ func PreWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usag
|
||||
autoGroup, exists := common.GetContextKey(ctx, constant.ContextKeyAutoGroup)
|
||||
if exists {
|
||||
groupRatio = ratio_setting.GetGroupRatio(autoGroup.(string))
|
||||
log.Printf("final group ratio: %f", groupRatio)
|
||||
logger.LogDebug(ctx, "final group ratio: %f", groupRatio)
|
||||
relayInfo.UsingGroup = autoGroup.(string)
|
||||
}
|
||||
|
||||
@@ -467,7 +465,7 @@ func checkAndSendQuotaNotify(relayInfo *relaycommon.RelayInfo, quota int, preCon
|
||||
}
|
||||
if quotaTooLow {
|
||||
prompt := "您的额度即将用尽"
|
||||
topUpLink := fmt.Sprintf("%s/console/topup", system_setting.ServerAddress)
|
||||
topUpLink := PaymentReturnURL("/console/topup")
|
||||
|
||||
// 根据通知方式生成不同的内容格式
|
||||
var content string
|
||||
@@ -521,7 +519,7 @@ func checkAndSendSubscriptionQuotaNotify(relayInfo *relaycommon.RelayInfo) {
|
||||
}
|
||||
|
||||
prompt := "您的订阅额度即将用尽"
|
||||
topUpLink := fmt.Sprintf("%s/console/topup", system_setting.ServerAddress)
|
||||
topUpLink := PaymentReturnURL("/console/topup")
|
||||
|
||||
var content string
|
||||
var values []interface{}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/setting/system_setting"
|
||||
)
|
||||
|
||||
func PaymentReturnURL(suffix string) string {
|
||||
base := strings.TrimRight(system_setting.ServerAddress, "/")
|
||||
return base + common.ThemeAwarePath(suffix)
|
||||
}
|
||||
@@ -372,7 +372,7 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch *
|
||||
return fmt.Errorf("readAll failed for task %s: %w", taskId, err)
|
||||
}
|
||||
|
||||
logger.LogDebug(ctx, fmt.Sprintf("updateVideoSingleTask response: %s", string(responseBody)))
|
||||
logger.LogDebug(ctx, "updateVideoSingleTask response: %s", responseBody)
|
||||
|
||||
snap := task.Snapshot()
|
||||
|
||||
@@ -380,7 +380,7 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch *
|
||||
// try parse as New API response format
|
||||
var responseItems dto.TaskResponse[model.Task]
|
||||
if err = common.Unmarshal(responseBody, &responseItems); err == nil && responseItems.IsSuccess() {
|
||||
logger.LogDebug(ctx, fmt.Sprintf("updateVideoSingleTask parsed as new api response format: %+v", responseItems))
|
||||
logger.LogDebug(ctx, "updateVideoSingleTask parsed as new api response format: %+v", responseItems)
|
||||
t := responseItems.Data
|
||||
taskResult.TaskID = t.TaskID
|
||||
taskResult.Status = string(t.Status)
|
||||
@@ -394,7 +394,7 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch *
|
||||
|
||||
task.Data = redactVideoResponseBody(responseBody)
|
||||
|
||||
logger.LogDebug(ctx, fmt.Sprintf("updateVideoSingleTask taskResult: %+v", taskResult))
|
||||
logger.LogDebug(ctx, "updateVideoSingleTask taskResult: %+v", taskResult)
|
||||
|
||||
now := time.Now().Unix()
|
||||
if taskResult.Status == "" {
|
||||
@@ -488,7 +488,7 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch *
|
||||
}
|
||||
} else {
|
||||
// No changes, skip update
|
||||
logger.LogDebug(ctx, fmt.Sprintf("No update needed for task %s", task.TaskID))
|
||||
logger.LogDebug(ctx, "No update needed for task %s", task.TaskID)
|
||||
}
|
||||
|
||||
if shouldSettle {
|
||||
|
||||
@@ -3,7 +3,6 @@ package service
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -12,6 +11,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
constant2 "github.com/QuantumNous/new-api/relay/constant"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
@@ -111,7 +111,7 @@ func getImageToken(c *gin.Context, fileMeta *types.FileMeta, model string, strea
|
||||
|
||||
width := config.Width
|
||||
height := config.Height
|
||||
log.Printf("format: %s, width: %d, height: %d", format, width, height)
|
||||
logger.LogDebug(c, "image token input: format=%s, width=%d, height=%d", format, width, height)
|
||||
|
||||
if isPatchBased {
|
||||
// 32x32 patch-based calculation with 1536 cap and model multiplier
|
||||
@@ -171,9 +171,7 @@ func getImageToken(c *gin.Context, fileMeta *types.FileMeta, model string, strea
|
||||
tilesH := (finalH + 512 - 1) / 512
|
||||
tiles := tilesW * tilesH
|
||||
|
||||
if common.DebugEnabled {
|
||||
log.Printf("scaled to: %dx%d, tiles: %d", finalW, finalH, tiles)
|
||||
}
|
||||
logger.LogDebug(c, "image token scaled size: width=%d, height=%d, tiles=%d", finalW, finalH, tiles)
|
||||
|
||||
return tiles*tileTokens + baseTokens, nil
|
||||
}
|
||||
|
||||
@@ -22,6 +22,9 @@ var Chats = []map[string]string{
|
||||
{
|
||||
"CC Switch": "ccswitch",
|
||||
},
|
||||
{
|
||||
"DeepChat": "deepchat://provider/install?v=1&data={deepchatConfig}",
|
||||
},
|
||||
{
|
||||
"Lobe Chat 官方示例": "https://chat-preview.lobehub.com/?settings={\"keyVaults\":{\"openai\":{\"apiKey\":\"{key}\",\"baseURL\":\"{address}/v1\"}}}",
|
||||
},
|
||||
|
||||
@@ -17,7 +17,7 @@ var (
|
||||
"blue": true, "green": true, "cyan": true, "purple": true, "pink": true,
|
||||
"red": true, "orange": true, "amber": true, "yellow": true, "lime": true,
|
||||
"light-green": true, "teal": true, "light-blue": true, "indigo": true,
|
||||
"violet": true, "grey": true,
|
||||
"violet": true, "grey": true, "slate": true,
|
||||
}
|
||||
slugRegex = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@ package operation_setting
|
||||
import "github.com/QuantumNous/new-api/setting/config"
|
||||
|
||||
type ChannelAffinityKeySource struct {
|
||||
Type string `json:"type"` // context_int, context_string, gjson
|
||||
Type string `json:"type"` // context_int, context_string, request_header, gjson
|
||||
Key string `json:"key,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
}
|
||||
|
||||
@@ -5,8 +5,16 @@ import "github.com/QuantumNous/new-api/setting/config"
|
||||
type PaymentSetting struct {
|
||||
AmountOptions []int `json:"amount_options"`
|
||||
AmountDiscount map[int]float64 `json:"amount_discount"` // 充值金额对应的折扣,例如 100 元 0.9 表示 100 元充值享受 9 折优惠
|
||||
|
||||
ComplianceConfirmed bool `json:"compliance_confirmed"`
|
||||
ComplianceTermsVersion string `json:"compliance_terms_version"`
|
||||
ComplianceConfirmedAt int64 `json:"compliance_confirmed_at"`
|
||||
ComplianceConfirmedBy int `json:"compliance_confirmed_by"`
|
||||
ComplianceConfirmedIP string `json:"compliance_confirmed_ip"`
|
||||
}
|
||||
|
||||
const CurrentComplianceTermsVersion = "v1"
|
||||
|
||||
// 默认配置
|
||||
var paymentSetting = PaymentSetting{
|
||||
AmountOptions: []int{10, 20, 50, 100, 200, 500},
|
||||
@@ -21,3 +29,8 @@ func init() {
|
||||
func GetPaymentSetting() *PaymentSetting {
|
||||
return &paymentSetting
|
||||
}
|
||||
|
||||
func IsPaymentComplianceConfirmed() bool {
|
||||
return paymentSetting.ComplianceConfirmed &&
|
||||
paymentSetting.ComplianceTermsVersion == CurrentComplianceTermsVersion
|
||||
}
|
||||
|
||||
Vendored
+2
-2
@@ -10,7 +10,7 @@
|
||||
"@visactor/react-vchart": "~1.8.8",
|
||||
"@visactor/vchart": "~1.8.8",
|
||||
"@visactor/vchart-semi-theme": "~1.8.8",
|
||||
"axios": "1.15.0",
|
||||
"axios": "1.15.2",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.11",
|
||||
"history": "^5.3.0",
|
||||
@@ -776,7 +776,7 @@
|
||||
|
||||
"autoprefixer": ["autoprefixer@10.4.21", "", { "dependencies": { "browserslist": "^4.24.4", "caniuse-lite": "^1.0.30001702", "fraction.js": "^4.3.7", "normalize-range": "^0.1.2", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ=="],
|
||||
|
||||
"axios": ["axios@1.15.0", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q=="],
|
||||
"axios": ["axios@1.15.2", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A=="],
|
||||
|
||||
"babel-plugin-macros": ["babel-plugin-macros@3.1.0", "", { "dependencies": { "@babel/runtime": "^7.12.5", "cosmiconfig": "^7.0.0", "resolve": "^1.19.0" } }, "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg=="],
|
||||
|
||||
|
||||
Vendored
+1
-1
@@ -10,7 +10,7 @@
|
||||
"@visactor/react-vchart": "~1.8.8",
|
||||
"@visactor/vchart": "~1.8.8",
|
||||
"@visactor/vchart-semi-theme": "~1.8.8",
|
||||
"axios": "1.15.0",
|
||||
"axios": "1.15.2",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.11",
|
||||
"history": "^5.3.0",
|
||||
|
||||
@@ -62,6 +62,7 @@ const RiskAcknowledgementModal = React.memo(function RiskAcknowledgementModal({
|
||||
checklist = [],
|
||||
inputPrompt = '',
|
||||
requiredText = '',
|
||||
requiredTextParts = [],
|
||||
inputPlaceholder = '',
|
||||
mismatchText = '',
|
||||
cancelText = '',
|
||||
@@ -72,24 +73,68 @@ const RiskAcknowledgementModal = React.memo(function RiskAcknowledgementModal({
|
||||
const isMobile = useIsMobile();
|
||||
const [checkedItems, setCheckedItems] = useState([]);
|
||||
const [typedText, setTypedText] = useState('');
|
||||
const [typedTextParts, setTypedTextParts] = useState([]);
|
||||
|
||||
const normalizedRequiredTextParts = useMemo(() => {
|
||||
let inputIndex = 0;
|
||||
return requiredTextParts.map((part) => {
|
||||
if (part.type === 'input') {
|
||||
const normalizedPart = { ...part, inputIndex };
|
||||
inputIndex += 1;
|
||||
return normalizedPart;
|
||||
}
|
||||
return part;
|
||||
});
|
||||
}, [requiredTextParts]);
|
||||
|
||||
const requiredTextInputCount = useMemo(
|
||||
() =>
|
||||
normalizedRequiredTextParts.filter((part) => part.type === 'input')
|
||||
.length,
|
||||
[normalizedRequiredTextParts],
|
||||
);
|
||||
const hasSegmentedRequiredText = requiredTextInputCount > 0;
|
||||
const requiredTextToDisplay = hasSegmentedRequiredText
|
||||
? normalizedRequiredTextParts.map((part) => part.text).join('')
|
||||
: requiredText;
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
setCheckedItems(Array(checklist.length).fill(false));
|
||||
setTypedText('');
|
||||
}, [visible, checklist.length]);
|
||||
setTypedTextParts(Array(requiredTextInputCount).fill(''));
|
||||
}, [visible, checklist.length, requiredTextInputCount]);
|
||||
|
||||
const allChecked = useMemo(() => {
|
||||
if (checklist.length === 0) return true;
|
||||
return checkedItems.length === checklist.length && checkedItems.every(Boolean);
|
||||
return (
|
||||
checkedItems.length === checklist.length && checkedItems.every(Boolean)
|
||||
);
|
||||
}, [checkedItems, checklist.length]);
|
||||
|
||||
const typedMatched = useMemo(() => {
|
||||
if (hasSegmentedRequiredText) {
|
||||
return normalizedRequiredTextParts.every((part) => {
|
||||
if (part.type === 'static') return true;
|
||||
return (
|
||||
typedTextParts[part.inputIndex ?? 0]?.trim() === part.text.trim()
|
||||
);
|
||||
});
|
||||
}
|
||||
if (!requiredText) return true;
|
||||
return typedText.trim() === requiredText.trim();
|
||||
}, [typedText, requiredText]);
|
||||
}, [
|
||||
hasSegmentedRequiredText,
|
||||
normalizedRequiredTextParts,
|
||||
requiredText,
|
||||
typedText,
|
||||
typedTextParts,
|
||||
]);
|
||||
|
||||
const detailText = useMemo(() => detailItems.join(', '), [detailItems]);
|
||||
const hasTypedRequiredText = hasSegmentedRequiredText
|
||||
? typedTextParts.some((part) => part.trim() !== '')
|
||||
: typedText.length > 0;
|
||||
const canConfirm = allChecked && typedMatched;
|
||||
|
||||
const handleChecklistChange = useCallback((index, checked) => {
|
||||
@@ -100,6 +145,14 @@ const RiskAcknowledgementModal = React.memo(function RiskAcknowledgementModal({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleTextPartChange = useCallback((index, value) => {
|
||||
setTypedTextParts((previous) => {
|
||||
const next = [...previous];
|
||||
next[index] = value;
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
@@ -134,7 +187,6 @@ const RiskAcknowledgementModal = React.memo(function RiskAcknowledgementModal({
|
||||
}
|
||||
>
|
||||
<div className='flex flex-col gap-4'>
|
||||
|
||||
<RiskMarkdownBlock markdownContent={markdownContent} />
|
||||
|
||||
{detailItems.length > 0 ? (
|
||||
@@ -176,7 +228,7 @@ const RiskAcknowledgementModal = React.memo(function RiskAcknowledgementModal({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{requiredText ? (
|
||||
{requiredTextToDisplay ? (
|
||||
<div
|
||||
className='flex flex-col gap-2 rounded-lg'
|
||||
style={{
|
||||
@@ -187,19 +239,51 @@ const RiskAcknowledgementModal = React.memo(function RiskAcknowledgementModal({
|
||||
>
|
||||
{inputPrompt ? <Text strong>{inputPrompt}</Text> : null}
|
||||
<div className='font-mono text-xs break-all rounded-md p-2 bg-gray-50 border border-gray-200'>
|
||||
{requiredText}
|
||||
{requiredTextToDisplay}
|
||||
</div>
|
||||
<Input
|
||||
value={typedText}
|
||||
onChange={setTypedText}
|
||||
placeholder={inputPlaceholder}
|
||||
autoFocus={visible}
|
||||
onCopy={(event) => event.preventDefault()}
|
||||
onCut={(event) => event.preventDefault()}
|
||||
onPaste={(event) => event.preventDefault()}
|
||||
onDrop={(event) => event.preventDefault()}
|
||||
/>
|
||||
{!typedMatched && typedText ? (
|
||||
{hasSegmentedRequiredText ? (
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
{normalizedRequiredTextParts.map((part, index) =>
|
||||
part.type === 'static' ? (
|
||||
<span
|
||||
key={`static-${index}`}
|
||||
className='select-none rounded-md border border-gray-200 bg-white px-2 py-1 font-mono text-sm text-gray-500'
|
||||
>
|
||||
{part.text}
|
||||
</span>
|
||||
) : (
|
||||
<Input
|
||||
key={`input-${index}`}
|
||||
value={typedTextParts[part.inputIndex ?? 0] ?? ''}
|
||||
onChange={(value) =>
|
||||
handleTextPartChange(part.inputIndex ?? 0, value)
|
||||
}
|
||||
placeholder={
|
||||
part.placeholder || part.text || inputPlaceholder
|
||||
}
|
||||
autoFocus={visible && part.inputIndex === 0}
|
||||
onCopy={(event) => event.preventDefault()}
|
||||
onCut={(event) => event.preventDefault()}
|
||||
onPaste={(event) => event.preventDefault()}
|
||||
onDrop={(event) => event.preventDefault()}
|
||||
style={{ width: isMobile ? '100%' : 260 }}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
value={typedText}
|
||||
onChange={setTypedText}
|
||||
placeholder={inputPlaceholder}
|
||||
autoFocus={visible}
|
||||
onCopy={(event) => event.preventDefault()}
|
||||
onCut={(event) => event.preventDefault()}
|
||||
onPaste={(event) => event.preventDefault()}
|
||||
onDrop={(event) => event.preventDefault()}
|
||||
/>
|
||||
)}
|
||||
{!typedMatched && hasTypedRequiredText ? (
|
||||
<Text type='danger' size='small'>
|
||||
{mismatchText}
|
||||
</Text>
|
||||
|
||||
+2
-2
@@ -143,12 +143,12 @@ const FooterBar = () => {
|
||||
Midjourney-Proxy
|
||||
</a>
|
||||
<a
|
||||
href='https://github.com/Calcium-Ion/neko-api-key-tool'
|
||||
href='https://github.com/Calcium-Ion/new-api-key-tool'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='!text-semi-color-text-1'
|
||||
>
|
||||
neko-api-key-tool
|
||||
new-api-key-tool
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+5
-1
@@ -251,7 +251,11 @@ const SiderBar = ({ onNavigate = () => {} }) => {
|
||||
for (let key in chats[i]) {
|
||||
let link = chats[i][key];
|
||||
if (typeof link !== 'string') continue; // 确保链接是字符串
|
||||
if (link.startsWith('fluent') || link.startsWith('ccswitch')) {
|
||||
if (
|
||||
link.startsWith('fluent') ||
|
||||
link.startsWith('ccswitch') ||
|
||||
link.startsWith('deepchat')
|
||||
) {
|
||||
shouldSkip = true;
|
||||
break;
|
||||
}
|
||||
|
||||
+196
-49
@@ -18,15 +18,18 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Card, Spin, Tabs } from '@douyinfe/semi-ui';
|
||||
import { Banner, Button, Card, Spin, Tabs } from '@douyinfe/semi-ui';
|
||||
import SettingsGeneralPayment from '../../pages/Setting/Payment/SettingsGeneralPayment';
|
||||
import SettingsPaymentGateway from '../../pages/Setting/Payment/SettingsPaymentGateway';
|
||||
import SettingsPaymentGatewayStripe from '../../pages/Setting/Payment/SettingsPaymentGatewayStripe';
|
||||
import SettingsPaymentGatewayCreem from '../../pages/Setting/Payment/SettingsPaymentGatewayCreem';
|
||||
import SettingsPaymentGatewayWaffo from '../../pages/Setting/Payment/SettingsPaymentGatewayWaffo';
|
||||
import SettingsPaymentGatewayWaffoPancake from '../../pages/Setting/Payment/SettingsPaymentGatewayWaffoPancake';
|
||||
import { API, showError, toBoolean } from '../../helpers';
|
||||
import { API, showError, showSuccess, toBoolean } from '../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import RiskAcknowledgementModal from '../common/modals/RiskAcknowledgementModal';
|
||||
|
||||
const CURRENT_COMPLIANCE_TERMS_VERSION = 'v1';
|
||||
|
||||
const PaymentSetting = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -60,9 +63,59 @@ const PaymentSetting = () => {
|
||||
WaffoPancakeCurrency: 'USD',
|
||||
WaffoPancakeUnitPrice: 1.0,
|
||||
WaffoPancakeMinTopUp: 1,
|
||||
'payment_setting.compliance_confirmed': false,
|
||||
'payment_setting.compliance_terms_version': '',
|
||||
'payment_setting.compliance_confirmed_at': 0,
|
||||
'payment_setting.compliance_confirmed_by': 0,
|
||||
});
|
||||
|
||||
let [loading, setLoading] = useState(false);
|
||||
const [complianceVisible, setComplianceVisible] = useState(false);
|
||||
|
||||
const complianceStatements = [
|
||||
t('你已合法取得所接入模型 API、账号、密钥和额度的授权;'),
|
||||
t(
|
||||
'你承诺仅在已取得上游服务商、模型服务提供方或相关权利方合法授权的范围内使用其 API、账号、密钥、额度及服务能力,不进行未经授权的转售、倒卖、分销或其他违规商业化使用。',
|
||||
),
|
||||
t(
|
||||
'如向中华人民共和国境内公众提供生成式人工智能服务,你将依法履行备案登记、安全评估、内容安全、投诉举报、生成合成内容标识、日志留存、个人信息保护等合规义务;',
|
||||
),
|
||||
t(
|
||||
'你承诺不会利用本系统实施、协助实施或变相实施违反适用法律法规、监管要求、平台规则、社会公共利益或第三方合法权益的行为。',
|
||||
),
|
||||
t('你理解并自行承担部署、运营和收费行为产生的法律责任。'),
|
||||
t(
|
||||
'你理解本合规提醒仅用于风险提示,不构成法律意见、合规审查结论或对你使用本系统行为合法性的保证;你应根据实际业务场景自行咨询专业法律或合规顾问。',
|
||||
),
|
||||
];
|
||||
const requiredComplianceText = t(
|
||||
'我已阅读并理解上述合规提醒,知悉相关法律风险,并确认自行承担部署、运营和收费行为产生的法律责任',
|
||||
);
|
||||
const requiredComplianceTextParts = [
|
||||
{
|
||||
type: 'input',
|
||||
text: t('我已阅读并理解上述合规提醒'),
|
||||
},
|
||||
{ type: 'static', text: t(',') },
|
||||
{
|
||||
type: 'input',
|
||||
text: t('知悉相关法律风险'),
|
||||
},
|
||||
{ type: 'static', text: t(',') },
|
||||
{
|
||||
type: 'input',
|
||||
text: t('并确认自行承担部署'),
|
||||
},
|
||||
{ type: 'static', text: t('、') },
|
||||
{
|
||||
type: 'input',
|
||||
text: t('运营和收费行为产生的法律责任'),
|
||||
},
|
||||
];
|
||||
const complianceConfirmed =
|
||||
inputs['payment_setting.compliance_confirmed'] &&
|
||||
inputs['payment_setting.compliance_terms_version'] ===
|
||||
CURRENT_COMPLIANCE_TERMS_VERSION;
|
||||
|
||||
const getOptions = async () => {
|
||||
const res = await API.get('/api/option/');
|
||||
@@ -104,6 +157,16 @@ const PaymentSetting = () => {
|
||||
newInputs['AmountDiscount'] = item.value;
|
||||
}
|
||||
break;
|
||||
case 'payment_setting.compliance_confirmed':
|
||||
newInputs[item.key] = toBoolean(item.value);
|
||||
break;
|
||||
case 'payment_setting.compliance_confirmed_at':
|
||||
case 'payment_setting.compliance_confirmed_by':
|
||||
newInputs[item.key] = parseInt(item.value) || 0;
|
||||
break;
|
||||
case 'payment_setting.compliance_terms_version':
|
||||
newInputs[item.key] = item.value;
|
||||
break;
|
||||
case 'Price':
|
||||
case 'MinTopUp':
|
||||
case 'StripeUnitPrice':
|
||||
@@ -154,59 +217,143 @@ const PaymentSetting = () => {
|
||||
onRefresh();
|
||||
}, []);
|
||||
|
||||
const confirmCompliance = async () => {
|
||||
try {
|
||||
const res = await API.post('/api/option/payment_compliance', {
|
||||
confirmed: true,
|
||||
});
|
||||
if (res.data.success) {
|
||||
showSuccess(t('合规声明确认成功'));
|
||||
setComplianceVisible(false);
|
||||
await onRefresh();
|
||||
} else {
|
||||
showError(res.data.message || t('确认失败'));
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('确认失败'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Spin spinning={loading} size='large'>
|
||||
<Card style={{ marginTop: '10px' }}>
|
||||
<Tabs
|
||||
type='card'
|
||||
defaultActiveKey='general'
|
||||
contentStyle={{ paddingTop: 24 }}
|
||||
{!complianceConfirmed ? (
|
||||
<Banner
|
||||
type='warning'
|
||||
title={t('需要确认合规声明')}
|
||||
description={
|
||||
<div className='flex flex-col gap-2'>
|
||||
<span>
|
||||
{t(
|
||||
'确认前,支付、兑换码、订阅计划和邀请返利功能将保持锁定。',
|
||||
)}
|
||||
</span>
|
||||
<Button
|
||||
type='warning'
|
||||
theme='solid'
|
||||
onClick={() => setComplianceVisible(true)}
|
||||
>
|
||||
{t('确认合规声明')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
closeIcon={null}
|
||||
style={{ marginBottom: 16 }}
|
||||
fullMode={false}
|
||||
/>
|
||||
) : (
|
||||
<Banner
|
||||
type='success'
|
||||
title={t('合规声明已确认')}
|
||||
description={t('确认时间:{{time}},确认用户:#{{userId}}', {
|
||||
time: inputs['payment_setting.compliance_confirmed_at']
|
||||
? new Date(
|
||||
inputs['payment_setting.compliance_confirmed_at'] * 1000,
|
||||
).toLocaleString()
|
||||
: '-',
|
||||
userId:
|
||||
inputs['payment_setting.compliance_confirmed_by'] || '-',
|
||||
})}
|
||||
closeIcon={null}
|
||||
style={{ marginBottom: 16 }}
|
||||
fullMode={false}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
style={
|
||||
complianceConfirmed
|
||||
? undefined
|
||||
: { opacity: 0.4, pointerEvents: 'none' }
|
||||
}
|
||||
>
|
||||
<Tabs.TabPane tab={t('通用设置')} itemKey='general'>
|
||||
<SettingsGeneralPayment
|
||||
options={inputs}
|
||||
refresh={onRefresh}
|
||||
hideSectionTitle
|
||||
/>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={t('易支付设置')} itemKey='epay'>
|
||||
<SettingsPaymentGateway
|
||||
options={inputs}
|
||||
refresh={onRefresh}
|
||||
hideSectionTitle
|
||||
/>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={t('Stripe 设置')} itemKey='stripe'>
|
||||
<SettingsPaymentGatewayStripe
|
||||
options={inputs}
|
||||
refresh={onRefresh}
|
||||
hideSectionTitle
|
||||
/>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={t('Creem 设置')} itemKey='creem'>
|
||||
<SettingsPaymentGatewayCreem
|
||||
options={inputs}
|
||||
refresh={onRefresh}
|
||||
hideSectionTitle
|
||||
/>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={t('Waffo 设置')} itemKey='waffo'>
|
||||
<SettingsPaymentGatewayWaffo
|
||||
options={inputs}
|
||||
refresh={onRefresh}
|
||||
hideSectionTitle
|
||||
/>
|
||||
</Tabs.TabPane>
|
||||
{/*<Tabs.TabPane tab={t('Waffo Pancake 设置')} itemKey='waffo-pancake'>*/}
|
||||
{/* <SettingsPaymentGatewayWaffoPancake*/}
|
||||
{/* options={inputs}*/}
|
||||
{/* refresh={onRefresh}*/}
|
||||
{/* hideSectionTitle*/}
|
||||
{/* />*/}
|
||||
{/*</Tabs.TabPane>*/}
|
||||
</Tabs>
|
||||
<Tabs
|
||||
type='card'
|
||||
defaultActiveKey='general'
|
||||
contentStyle={{ paddingTop: 24 }}
|
||||
>
|
||||
<Tabs.TabPane tab={t('通用设置')} itemKey='general'>
|
||||
<SettingsGeneralPayment
|
||||
options={inputs}
|
||||
refresh={onRefresh}
|
||||
hideSectionTitle
|
||||
/>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={t('易支付设置')} itemKey='epay'>
|
||||
<SettingsPaymentGateway
|
||||
options={inputs}
|
||||
refresh={onRefresh}
|
||||
hideSectionTitle
|
||||
/>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={t('Stripe 设置')} itemKey='stripe'>
|
||||
<SettingsPaymentGatewayStripe
|
||||
options={inputs}
|
||||
refresh={onRefresh}
|
||||
hideSectionTitle
|
||||
/>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={t('Creem 设置')} itemKey='creem'>
|
||||
<SettingsPaymentGatewayCreem
|
||||
options={inputs}
|
||||
refresh={onRefresh}
|
||||
hideSectionTitle
|
||||
/>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={t('Waffo 设置')} itemKey='waffo'>
|
||||
<SettingsPaymentGatewayWaffo
|
||||
options={inputs}
|
||||
refresh={onRefresh}
|
||||
hideSectionTitle
|
||||
/>
|
||||
</Tabs.TabPane>
|
||||
{/*<Tabs.TabPane tab={t('Waffo Pancake 设置')} itemKey='waffo-pancake'>*/}
|
||||
{/* <SettingsPaymentGatewayWaffoPancake*/}
|
||||
{/* options={inputs}*/}
|
||||
{/* refresh={onRefresh}*/}
|
||||
{/* hideSectionTitle*/}
|
||||
{/* />*/}
|
||||
{/*</Tabs.TabPane>*/}
|
||||
</Tabs>
|
||||
</div>
|
||||
</Card>
|
||||
<RiskAcknowledgementModal
|
||||
visible={complianceVisible}
|
||||
title={t('确认合规声明')}
|
||||
markdownContent={t(
|
||||
'该操作将启用支付、兑换码、订阅计划和邀请返利相关功能。请仔细阅读并确认以下声明。',
|
||||
)}
|
||||
checklist={complianceStatements}
|
||||
inputPrompt={t('请输入以下文字以确认:')}
|
||||
requiredText={requiredComplianceText}
|
||||
requiredTextParts={requiredComplianceTextParts}
|
||||
inputPlaceholder={t('请输入确认文案')}
|
||||
mismatchText={t('输入内容与要求文案不一致')}
|
||||
cancelText={t('取消')}
|
||||
confirmText={t('确认并启用')}
|
||||
onCancel={() => setComplianceVisible(false)}
|
||||
onConfirm={confirmCompliance}
|
||||
/>
|
||||
</Spin>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -20,7 +20,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
import React from 'react';
|
||||
import { Button } from '@douyinfe/semi-ui';
|
||||
|
||||
const SubscriptionsActions = ({ openCreate, t }) => {
|
||||
const SubscriptionsActions = ({ openCreate, t, disabled = false }) => {
|
||||
return (
|
||||
<div className='flex gap-2 w-full md:w-auto'>
|
||||
<Button
|
||||
@@ -28,6 +28,7 @@ const SubscriptionsActions = ({ openCreate, t }) => {
|
||||
className='w-full md:w-auto'
|
||||
onClick={openCreate}
|
||||
size='small'
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('新建套餐')}
|
||||
</Button>
|
||||
|
||||
+21
-3
@@ -228,7 +228,11 @@ const renderPaymentConfig = (text, record, t, enableEpay) => {
|
||||
);
|
||||
};
|
||||
|
||||
const renderOperations = (text, record, { openEdit, setPlanEnabled, t }) => {
|
||||
const renderOperations = (
|
||||
text,
|
||||
record,
|
||||
{ openEdit, setPlanEnabled, t, complianceConfirmed },
|
||||
) => {
|
||||
const isEnabled = record?.plan?.enabled;
|
||||
|
||||
const handleToggle = () => {
|
||||
@@ -256,11 +260,18 @@ const renderOperations = (text, record, { openEdit, setPlanEnabled, t }) => {
|
||||
type='tertiary'
|
||||
size='small'
|
||||
onClick={() => openEdit(record)}
|
||||
disabled={!complianceConfirmed}
|
||||
>
|
||||
{t('编辑')}
|
||||
</Button>
|
||||
{isEnabled ? (
|
||||
<Button theme='light' type='danger' size='small' onClick={handleToggle}>
|
||||
<Button
|
||||
theme='light'
|
||||
type='danger'
|
||||
size='small'
|
||||
onClick={handleToggle}
|
||||
disabled={!complianceConfirmed}
|
||||
>
|
||||
{t('禁用')}
|
||||
</Button>
|
||||
) : (
|
||||
@@ -269,6 +280,7 @@ const renderOperations = (text, record, { openEdit, setPlanEnabled, t }) => {
|
||||
type='primary'
|
||||
size='small'
|
||||
onClick={handleToggle}
|
||||
disabled={!complianceConfirmed}
|
||||
>
|
||||
{t('启用')}
|
||||
</Button>
|
||||
@@ -282,6 +294,7 @@ export const getSubscriptionsColumns = ({
|
||||
openEdit,
|
||||
setPlanEnabled,
|
||||
enableEpay,
|
||||
complianceConfirmed = true,
|
||||
}) => {
|
||||
return [
|
||||
{
|
||||
@@ -351,7 +364,12 @@ export const getSubscriptionsColumns = ({
|
||||
fixed: 'right',
|
||||
width: 160,
|
||||
render: (text, record) =>
|
||||
renderOperations(text, record, { openEdit, setPlanEnabled, t }),
|
||||
renderOperations(text, record, {
|
||||
openEdit,
|
||||
setPlanEnabled,
|
||||
t,
|
||||
complianceConfirmed,
|
||||
}),
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user