Spaces:
Build error
Build error
Upload 732 files
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .dockerignore +8 -0
- .env.example +73 -0
- .gitattributes +2 -0
- .gitignore +14 -0
- Dockerfile +35 -0
- LICENSE +103 -0
- README.en.md +216 -0
- README.fr.md +216 -0
- README.md +219 -11
- VERSION +0 -0
- bin/migration_v0.2-v0.3.sql +6 -0
- bin/migration_v0.3-v0.4.sql +17 -0
- bin/time_test.sh +40 -0
- common/api_type.go +77 -0
- common/constants.go +202 -0
- common/copy.go +19 -0
- common/crypto.go +31 -0
- common/custom-event.go +87 -0
- common/database.go +15 -0
- common/email-outlook-auth.go +40 -0
- common/email.go +90 -0
- common/embed-file-system.go +32 -0
- common/endpoint_defaults.go +33 -0
- common/endpoint_type.go +41 -0
- common/env.go +38 -0
- common/gin.go +115 -0
- common/go-channel.go +53 -0
- common/gopool.go +24 -0
- common/hash.go +34 -0
- common/init.go +120 -0
- common/ip.go +22 -0
- common/json.go +44 -0
- common/limiter/limiter.go +89 -0
- common/limiter/lua/rate_limit.lua +44 -0
- common/model.go +42 -0
- common/page_info.go +82 -0
- common/pprof.go +44 -0
- common/quota.go +5 -0
- common/rate-limit.go +70 -0
- common/redis.go +327 -0
- common/ssrf_protection.go +327 -0
- common/str.go +237 -0
- common/sys_log.go +55 -0
- common/topup-ratio.go +33 -0
- common/totp.go +150 -0
- common/utils.go +384 -0
- common/validate.go +9 -0
- common/verification.go +77 -0
- constant/README.md +26 -0
- constant/api_type.go +37 -0
.dockerignore
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.github
|
| 2 |
+
.git
|
| 3 |
+
*.md
|
| 4 |
+
.vscode
|
| 5 |
+
.gitignore
|
| 6 |
+
Makefile
|
| 7 |
+
docs
|
| 8 |
+
.eslintcache
|
.env.example
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 端口号
|
| 2 |
+
# PORT=3000
|
| 3 |
+
# 前端基础URL
|
| 4 |
+
# FRONTEND_BASE_URL=https://your-frontend-url.com
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
# 调试相关配置
|
| 8 |
+
# 启用pprof
|
| 9 |
+
# ENABLE_PPROF=true
|
| 10 |
+
# 启用调试模式
|
| 11 |
+
# DEBUG=true
|
| 12 |
+
|
| 13 |
+
# 数据库相关配置
|
| 14 |
+
# 数据库连接字符串
|
| 15 |
+
# SQL_DSN=user:password@tcp(127.0.0.1:3306)/dbname?parseTime=true
|
| 16 |
+
# 日志数据库连接字符串
|
| 17 |
+
# LOG_SQL_DSN=user:password@tcp(127.0.0.1:3306)/logdb?parseTime=true
|
| 18 |
+
# SQLite数据库路径
|
| 19 |
+
# SQLITE_PATH=/path/to/sqlite.db
|
| 20 |
+
# 数据库最大空闲连接数
|
| 21 |
+
# SQL_MAX_IDLE_CONNS=100
|
| 22 |
+
# 数据库最大打开连接数
|
| 23 |
+
# SQL_MAX_OPEN_CONNS=1000
|
| 24 |
+
# 数据库连接最大生命周期(秒)
|
| 25 |
+
# SQL_MAX_LIFETIME=60
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# 缓存相关配置
|
| 29 |
+
# Redis连接字符串
|
| 30 |
+
# REDIS_CONN_STRING=redis://user:password@localhost:6379/0
|
| 31 |
+
# 同步频率(单位:秒)
|
| 32 |
+
# SYNC_FREQUENCY=60
|
| 33 |
+
# 内存缓存启用
|
| 34 |
+
# MEMORY_CACHE_ENABLED=true
|
| 35 |
+
# 渠道更新频率(单位:秒)
|
| 36 |
+
# CHANNEL_UPDATE_FREQUENCY=30
|
| 37 |
+
# 批量更新启用
|
| 38 |
+
# BATCH_UPDATE_ENABLED=true
|
| 39 |
+
# 批量更新间隔(单位:秒)
|
| 40 |
+
# BATCH_UPDATE_INTERVAL=5
|
| 41 |
+
|
| 42 |
+
# 任务和功能配置
|
| 43 |
+
# 更新任务启用
|
| 44 |
+
# UPDATE_TASK=true
|
| 45 |
+
|
| 46 |
+
# 对话超时设置
|
| 47 |
+
# 所有请求超时时间,单位秒,默认为0,表示不限制
|
| 48 |
+
# RELAY_TIMEOUT=0
|
| 49 |
+
# 流模式无响应超时时间,单位秒,如果出现空补全可以尝试改为更大值
|
| 50 |
+
# STREAMING_TIMEOUT=300
|
| 51 |
+
|
| 52 |
+
# Gemini 识别图片 最大图片数量
|
| 53 |
+
# GEMINI_VISION_MAX_IMAGE_NUM=16
|
| 54 |
+
|
| 55 |
+
# 会话密钥
|
| 56 |
+
# SESSION_SECRET=random_string
|
| 57 |
+
|
| 58 |
+
# 其他配置
|
| 59 |
+
# 生成默认token
|
| 60 |
+
# GENERATE_DEFAULT_TOKEN=false
|
| 61 |
+
# Cohere 安全设置
|
| 62 |
+
# COHERE_SAFETY_SETTING=NONE
|
| 63 |
+
# 是否统计图片token
|
| 64 |
+
# GET_MEDIA_TOKEN=true
|
| 65 |
+
# 是否在非流(stream=false)情况下统计图片token
|
| 66 |
+
# GET_MEDIA_TOKEN_NOT_STREAM=true
|
| 67 |
+
# 设置 Dify 渠道是否输出工作流和节点信息到客户端
|
| 68 |
+
# DIFY_DEBUG=true
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
# 节点类型
|
| 72 |
+
# 如果是主节点则为master
|
| 73 |
+
# NODE_TYPE=master
|
.gitattributes
CHANGED
|
@@ -35,3 +35,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
new-api/web/public/azure_model_name.png filter=lfs diff=lfs merge=lfs -text
|
| 37 |
new-api/web/public/ratio.png filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
new-api/web/public/azure_model_name.png filter=lfs diff=lfs merge=lfs -text
|
| 37 |
new-api/web/public/ratio.png filter=lfs diff=lfs merge=lfs -text
|
| 38 |
+
web/public/azure_model_name.png filter=lfs diff=lfs merge=lfs -text
|
| 39 |
+
web/public/ratio.png filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.idea
|
| 2 |
+
.vscode
|
| 3 |
+
upload
|
| 4 |
+
*.exe
|
| 5 |
+
*.db
|
| 6 |
+
build
|
| 7 |
+
*.db-journal
|
| 8 |
+
logs
|
| 9 |
+
web/dist
|
| 10 |
+
.env
|
| 11 |
+
one-api
|
| 12 |
+
.DS_Store
|
| 13 |
+
tiktoken_cache
|
| 14 |
+
.eslintcache
|
Dockerfile
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM oven/bun:latest AS builder
|
| 2 |
+
|
| 3 |
+
WORKDIR /build
|
| 4 |
+
COPY web/package.json .
|
| 5 |
+
COPY web/bun.lock .
|
| 6 |
+
RUN bun install
|
| 7 |
+
COPY ./web .
|
| 8 |
+
COPY ./VERSION .
|
| 9 |
+
RUN DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat VERSION) bun run build
|
| 10 |
+
|
| 11 |
+
FROM golang:alpine AS builder2
|
| 12 |
+
|
| 13 |
+
ENV GO111MODULE=on \
|
| 14 |
+
CGO_ENABLED=0 \
|
| 15 |
+
GOOS=linux
|
| 16 |
+
|
| 17 |
+
WORKDIR /build
|
| 18 |
+
|
| 19 |
+
ADD go.mod go.sum ./
|
| 20 |
+
RUN go mod download
|
| 21 |
+
|
| 22 |
+
COPY . .
|
| 23 |
+
COPY --from=builder /build/dist ./web/dist
|
| 24 |
+
RUN go build -ldflags "-s -w -X 'one-api/common.Version=$(cat VERSION)'" -o one-api
|
| 25 |
+
|
| 26 |
+
FROM alpine
|
| 27 |
+
|
| 28 |
+
RUN apk upgrade --no-cache \
|
| 29 |
+
&& apk add --no-cache ca-certificates tzdata ffmpeg \
|
| 30 |
+
&& update-ca-certificates
|
| 31 |
+
|
| 32 |
+
COPY --from=builder2 /build/one-api /
|
| 33 |
+
EXPOSE 3000
|
| 34 |
+
WORKDIR /data
|
| 35 |
+
ENTRYPOINT ["/one-api"]
|
LICENSE
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# **New API 许可协议 (Licensing)**
|
| 2 |
+
|
| 3 |
+
本项目采用**基于使用场景的双重许可 (Usage-Based Dual Licensing)** 模式。
|
| 4 |
+
|
| 5 |
+
**核心原则:**
|
| 6 |
+
|
| 7 |
+
- **默认许可:** 本项目默认在 **GNU Affero 通用公共许可证 v3.0 (AGPLv3)** 下提供。任何用户在遵守 AGPLv3 条款和下述附加限制的前提下,均可免费使用。
|
| 8 |
+
- **商业许可:** 在特定商业场景下,或当您希望获得 AGPLv3 之外的权利时,**必须**获取**商业许可证 (Commercial License)**。
|
| 9 |
+
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
## **1. 开源许可证 (Open Source License): AGPLv3 - 适用于基础使用**
|
| 13 |
+
|
| 14 |
+
- 在遵守 **AGPLv3** 条款的前提下,您可以自由地使用、修改和分发 New API。AGPLv3 的完整文本可以访问 [https://www.gnu.org/licenses/agpl-3.0.html](https://www.gnu.org/licenses/agpl-3.0.html) 获取。
|
| 15 |
+
- **核心义务:** AGPLv3 的一个关键要求是,如果您修改了 New API 并通过网络提供服务 (SaaS),或者分发了修改后的版本,您必须以 AGPLv3 许可证向所有用户提供相应的**完整源代码**。
|
| 16 |
+
- **附加限制 (重要):** 在仅使用 AGPLv3 开源许可证的情况下,您**必须**完整保留项目代码中原有的品牌标识、LOGO 及版权声明信息。**禁止以任何形式修改、移除或遮盖**这些信息。如需移除,必须获取商业许可证。
|
| 17 |
+
- 使用前请务必仔细阅读并理解 AGPLv3 的所有条款及上述附加限制。
|
| 18 |
+
|
| 19 |
+
## **2. 商业许可证 (Commercial License) - 适用于高级场景及闭源需求**
|
| 20 |
+
|
| 21 |
+
在以下任一情况下,您**必须**联系我们获取并签署一份商业许可证,才能合法使用 New API:
|
| 22 |
+
|
| 23 |
+
- **场景一:移除品牌和版权信息**
|
| 24 |
+
您希望在您的产品或服务中移除 New API 的 LOGO、UI界面中的版权声明或其他品牌标识。
|
| 25 |
+
|
| 26 |
+
- **场景二:规避 AGPLv3 开源义务**
|
| 27 |
+
您基于 New API 进行了修改,并希望:
|
| 28 |
+
- 通过网络提供服务(SaaS),但**不希望**向您的服务用户公开您修改后的源代码。
|
| 29 |
+
- 分发一个集成了 New API 的软件产品,但**不希望**以 AGPLv3 许可证发布您的产品或公开源代码。
|
| 30 |
+
|
| 31 |
+
- **场景三:企业政策与集成需求**
|
| 32 |
+
- 您所在公司的政策、客户合同或项目要求不允许使用 AGPLv3 许可的软件。
|
| 33 |
+
- 您需要进行 OEM 集成,将 New API 作为您闭源商业产品的一部分进行再分发。
|
| 34 |
+
|
| 35 |
+
- **场景四:需要商业支持与保障**
|
| 36 |
+
您需要 AGPLv3 未提供的商业保障,如官方技术支持等。
|
| 37 |
+
|
| 38 |
+
**获取商业许可:**
|
| 39 |
+
请通过电子邮件 **support@quantumnous.com** 联系 New API 团队洽谈商业授权事宜。
|
| 40 |
+
|
| 41 |
+
## **3. 贡献 (Contributions)**
|
| 42 |
+
|
| 43 |
+
- 我们欢迎社区对 New API 的贡献。所有向本项目提交的贡献(例如通过 Pull Request)都将被视为在 **AGPLv3** 许可证下提供。
|
| 44 |
+
- 通过向本项目提交贡献,即表示您同意您的代码以 AGPLv3 许可证授权给本项目及所有后续使用者(无论这些使用者最终遵循 AGPLv3 还是商业许可)。
|
| 45 |
+
- 您也理解并同意,您的贡献可能会被包含在根据商业许可证分发的 New API 版本中。
|
| 46 |
+
|
| 47 |
+
## **4. 其他条款 (Other Terms)**
|
| 48 |
+
|
| 49 |
+
- 关于商业许可证的具体条款、条件和价格,以双方签署的正式商业许可协议为准。
|
| 50 |
+
- 项目维护者保留根据需要更新本许可政策的权利。相关更新将通过项目官方渠道(如代码仓库、官方网站)进行通知。
|
| 51 |
+
|
| 52 |
+
---
|
| 53 |
+
|
| 54 |
+
# **New API Licensing**
|
| 55 |
+
|
| 56 |
+
This project uses a **Usage-Based Dual Licensing** model.
|
| 57 |
+
|
| 58 |
+
**Core Principles:**
|
| 59 |
+
|
| 60 |
+
- **Default License:** This project is available by default under the **GNU Affero General Public License v3.0 (AGPLv3)**. Any user may use it free of charge, provided they comply with both the AGPLv3 terms and the additional restrictions listed below.
|
| 61 |
+
- **Commercial License:** For specific commercial scenarios, or if you require rights beyond those granted by AGPLv3, you **must** obtain a **Commercial License**.
|
| 62 |
+
|
| 63 |
+
---
|
| 64 |
+
|
| 65 |
+
## **1. Open Source License: AGPLv3 – For Basic Usage**
|
| 66 |
+
|
| 67 |
+
- Under the terms of the **AGPLv3**, you are free to use, modify, and distribute New API. The complete AGPLv3 license text can be viewed at [https://www.gnu.org/licenses/agpl-3.0.html](https://www.gnu.org/licenses/agpl-3.0.html).
|
| 68 |
+
- **Core Obligation:** A key AGPLv3 requirement is that if you modify New API and provide it as a network service (SaaS), or distribute a modified version, you must make the **complete corresponding source code** available to all users under the AGPLv3 license.
|
| 69 |
+
- **Additional Restriction (Important):** When using only the AGPLv3 open-source license, you **must** retain all original branding, logos, and copyright statements within the project’s code. **You are strictly prohibited from modifying, removing, or concealing** any such information. If you wish to remove this, you must obtain a Commercial License.
|
| 70 |
+
- Please read and ensure that you fully understand all AGPLv3 terms and the above additional restriction before use.
|
| 71 |
+
|
| 72 |
+
## **2. Commercial License – For Advanced Scenarios & Closed Source Needs**
|
| 73 |
+
|
| 74 |
+
You **must** contact us to obtain and sign a Commercial License in any of the following scenarios in order to legally use New API:
|
| 75 |
+
|
| 76 |
+
- **Scenario 1: Removal of Branding and Copyright**
|
| 77 |
+
You wish to remove the New API logo, copyright statement, or other branding elements from your product or service.
|
| 78 |
+
|
| 79 |
+
- **Scenario 2: Avoidance of AGPLv3 Open Source Obligations**
|
| 80 |
+
You have modified New API and wish to:
|
| 81 |
+
- Offer it as a network service (SaaS) **without** disclosing your modifications' source code to your users.
|
| 82 |
+
- Distribute a software product integrated with New API **without** releasing your product under AGPLv3 or open-sourcing the code.
|
| 83 |
+
|
| 84 |
+
- **Scenario 3: Enterprise Policy & Integration Needs**
|
| 85 |
+
- Your organization’s policies, client contracts, or project requirements prohibit the use of AGPLv3-licensed software.
|
| 86 |
+
- You require OEM integration and need to redistribute New API as part of your closed-source commercial product.
|
| 87 |
+
|
| 88 |
+
- **Scenario 4: Commercial Support and Assurances**
|
| 89 |
+
You require commercial assurances not provided by AGPLv3, such as official technical support.
|
| 90 |
+
|
| 91 |
+
**Obtaining a Commercial License:**
|
| 92 |
+
Please contact the New API team via email at **support@quantumnous.com** to discuss commercial licensing.
|
| 93 |
+
|
| 94 |
+
## **3. Contributions**
|
| 95 |
+
|
| 96 |
+
- We welcome community contributions to New API. All contributions (e.g., via Pull Request) are deemed to be provided under the **AGPLv3** license.
|
| 97 |
+
- By submitting a contribution, you agree that your code is licensed to this project and all downstream users under the AGPLv3 license (regardless of whether those users ultimately operate under AGPLv3 or a Commercial License).
|
| 98 |
+
- You also acknowledge and agree that your contribution may be included in New API releases distributed under a Commercial License.
|
| 99 |
+
|
| 100 |
+
## **4. Other Terms**
|
| 101 |
+
|
| 102 |
+
- The specific terms, conditions, and pricing of the Commercial License are governed by the formal commercial license agreement executed by both parties.
|
| 103 |
+
- Project maintainers reserve the right to update this licensing policy as needed. Updates will be communicated via official project channels (e.g., repository, official website).
|
README.en.md
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<p align="right">
|
| 2 |
+
<a href="./README.md">中文</a> | <strong>English</strong> | <a href="./README.fr.md">Français</a>
|
| 3 |
+
</p>
|
| 4 |
+
<div align="center">
|
| 5 |
+
|
| 6 |
+

|
| 7 |
+
|
| 8 |
+
# New API
|
| 9 |
+
|
| 10 |
+
🍥 Next-Generation Large Model Gateway and AI Asset Management System
|
| 11 |
+
|
| 12 |
+
<a href="https://trendshift.io/repositories/8227" target="_blank"><img src="https://trendshift.io/api/badge/repositories/8227" alt="Calcium-Ion%2Fnew-api | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
| 13 |
+
|
| 14 |
+
<p align="center">
|
| 15 |
+
<a href="https://raw.githubusercontent.com/Calcium-Ion/new-api/main/LICENSE">
|
| 16 |
+
<img src="https://img.shields.io/github/license/Calcium-Ion/new-api?color=brightgreen" alt="license">
|
| 17 |
+
</a>
|
| 18 |
+
<a href="https://github.com/Calcium-Ion/new-api/releases/latest">
|
| 19 |
+
<img src="https://img.shields.io/github/v/release/Calcium-Ion/new-api?color=brightgreen&include_prereleases" alt="release">
|
| 20 |
+
</a>
|
| 21 |
+
<a href="https://github.com/users/Calcium-Ion/packages/container/package/new-api">
|
| 22 |
+
<img src="https://img.shields.io/badge/docker-ghcr.io-blue" alt="docker">
|
| 23 |
+
</a>
|
| 24 |
+
<a href="https://hub.docker.com/r/CalciumIon/new-api">
|
| 25 |
+
<img src="https://img.shields.io/badge/docker-dockerHub-blue" alt="docker">
|
| 26 |
+
</a>
|
| 27 |
+
<a href="https://goreportcard.com/report/github.com/Calcium-Ion/new-api">
|
| 28 |
+
<img src="https://goreportcard.com/badge/github.com/Calcium-Ion/new-api" alt="GoReportCard">
|
| 29 |
+
</a>
|
| 30 |
+
</p>
|
| 31 |
+
</div>
|
| 32 |
+
|
| 33 |
+
## 📝 Project Description
|
| 34 |
+
|
| 35 |
+
> [!NOTE]
|
| 36 |
+
> This is an open-source project developed based on [One API](https://github.com/songquanpeng/one-api)
|
| 37 |
+
|
| 38 |
+
> [!IMPORTANT]
|
| 39 |
+
> - This project is for personal learning purposes only, with no guarantee of stability or technical support.
|
| 40 |
+
> - 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.
|
| 41 |
+
> - 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.
|
| 42 |
+
|
| 43 |
+
<h2>🤝 Trusted Partners</h2>
|
| 44 |
+
<p id="premium-sponsors"> </p>
|
| 45 |
+
<p align="center"><strong>No particular order</strong></p>
|
| 46 |
+
<p align="center">
|
| 47 |
+
<a href="https://www.cherry-ai.com/" target=_blank><img
|
| 48 |
+
src="./docs/images/cherry-studio.png" alt="Cherry Studio" height="120"
|
| 49 |
+
/></a>
|
| 50 |
+
<a href="https://bda.pku.edu.cn/" target=_blank><img
|
| 51 |
+
src="./docs/images/pku.png" alt="Peking University" height="120"
|
| 52 |
+
/></a>
|
| 53 |
+
<a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target=_blank><img
|
| 54 |
+
src="./docs/images/ucloud.png" alt="UCloud" height="120"
|
| 55 |
+
/></a>
|
| 56 |
+
<a href="https://www.aliyun.com/" target=_blank><img
|
| 57 |
+
src="./docs/images/aliyun.png" alt="Alibaba Cloud" height="120"
|
| 58 |
+
/></a>
|
| 59 |
+
<a href="https://io.net/" target=_blank><img
|
| 60 |
+
src="./docs/images/io-net.png" alt="IO.NET" height="120"
|
| 61 |
+
/></a>
|
| 62 |
+
</p>
|
| 63 |
+
<p> </p>
|
| 64 |
+
|
| 65 |
+
## 📚 Documentation
|
| 66 |
+
|
| 67 |
+
For detailed documentation, please visit our official Wiki: [https://docs.newapi.pro/](https://docs.newapi.pro/)
|
| 68 |
+
|
| 69 |
+
You can also access the AI-generated DeepWiki:
|
| 70 |
+
[](https://deepwiki.com/QuantumNous/new-api)
|
| 71 |
+
|
| 72 |
+
## ✨ Key Features
|
| 73 |
+
|
| 74 |
+
New API offers a wide range of features, please refer to [Features Introduction](https://docs.newapi.pro/wiki/features-introduction) for details:
|
| 75 |
+
|
| 76 |
+
1. 🎨 Brand new UI interface
|
| 77 |
+
2. 🌍 Multi-language support
|
| 78 |
+
3. 💰 Online recharge functionality (YiPay)
|
| 79 |
+
4. 🔍 Support for querying usage quotas with keys (works with [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool))
|
| 80 |
+
5. 🔄 Compatible with the original One API database
|
| 81 |
+
6. 💵 Support for pay-per-use model pricing
|
| 82 |
+
7. ⚖️ Support for weighted random channel selection
|
| 83 |
+
8. 📈 Data dashboard (console)
|
| 84 |
+
9. 🔒 Token grouping and model restrictions
|
| 85 |
+
10. 🤖 Support for more authorization login methods (LinuxDO, Telegram, OIDC)
|
| 86 |
+
11. 🔄 Support for Rerank models (Cohere and Jina), [API Documentation](https://docs.newapi.pro/api/jinaai-rerank)
|
| 87 |
+
12. ⚡ Support for OpenAI Realtime API (including Azure channels), [API Documentation](https://docs.newapi.pro/api/openai-realtime)
|
| 88 |
+
13. ⚡ Support for Claude Messages format, [API Documentation](https://docs.newapi.pro/api/anthropic-chat)
|
| 89 |
+
14. Support for entering chat interface via /chat2link route
|
| 90 |
+
15. 🧠 Support for setting reasoning effort through model name suffixes:
|
| 91 |
+
1. OpenAI o-series models
|
| 92 |
+
- Add `-high` suffix for high reasoning effort (e.g.: `o3-mini-high`)
|
| 93 |
+
- Add `-medium` suffix for medium reasoning effort (e.g.: `o3-mini-medium`)
|
| 94 |
+
- Add `-low` suffix for low reasoning effort (e.g.: `o3-mini-low`)
|
| 95 |
+
2. Claude thinking models
|
| 96 |
+
- Add `-thinking` suffix to enable thinking mode (e.g.: `claude-3-7-sonnet-20250219-thinking`)
|
| 97 |
+
16. 🔄 Thinking-to-content functionality
|
| 98 |
+
17. 🔄 Model rate limiting for users
|
| 99 |
+
18. 💰 Cache billing support, which allows billing at a set ratio when cache is hit:
|
| 100 |
+
1. Set the `Prompt Cache Ratio` option in `System Settings-Operation Settings`
|
| 101 |
+
2. Set `Prompt Cache Ratio` in the channel, range 0-1, e.g., setting to 0.5 means billing at 50% when cache is hit
|
| 102 |
+
3. Supported channels:
|
| 103 |
+
- [x] OpenAI
|
| 104 |
+
- [x] Azure
|
| 105 |
+
- [x] DeepSeek
|
| 106 |
+
- [x] Claude
|
| 107 |
+
|
| 108 |
+
## Model Support
|
| 109 |
+
|
| 110 |
+
This version supports multiple models, please refer to [API Documentation-Relay Interface](https://docs.newapi.pro/api) for details:
|
| 111 |
+
|
| 112 |
+
1. Third-party models **gpts** (gpt-4-gizmo-*)
|
| 113 |
+
2. Third-party channel [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) interface, [API Documentation](https://docs.newapi.pro/api/midjourney-proxy-image)
|
| 114 |
+
3. Third-party channel [Suno API](https://github.com/Suno-API/Suno-API) interface, [API Documentation](https://docs.newapi.pro/api/suno-music)
|
| 115 |
+
4. Custom channels, supporting full call address input
|
| 116 |
+
5. Rerank models ([Cohere](https://cohere.ai/) and [Jina](https://jina.ai/)), [API Documentation](https://docs.newapi.pro/api/jinaai-rerank)
|
| 117 |
+
6. Claude Messages format, [API Documentation](https://docs.newapi.pro/api/anthropic-chat)
|
| 118 |
+
7. Dify, currently only supports chatflow
|
| 119 |
+
|
| 120 |
+
## Environment Variable Configuration
|
| 121 |
+
|
| 122 |
+
For detailed configuration instructions, please refer to [Installation Guide-Environment Variables Configuration](https://docs.newapi.pro/installation/environment-variables):
|
| 123 |
+
|
| 124 |
+
- `GENERATE_DEFAULT_TOKEN`: Whether to generate initial tokens for newly registered users, default is `false`
|
| 125 |
+
- `STREAMING_TIMEOUT`: Streaming response timeout, default is 300 seconds
|
| 126 |
+
- `DIFY_DEBUG`: Whether to output workflow and node information for Dify channels, default is `true`
|
| 127 |
+
- `FORCE_STREAM_OPTION`: Whether to override client stream_options parameter, default is `true`
|
| 128 |
+
- `GET_MEDIA_TOKEN`: Whether to count image tokens, default is `true`
|
| 129 |
+
- `GET_MEDIA_TOKEN_NOT_STREAM`: Whether to count image tokens in non-streaming cases, default is `true`
|
| 130 |
+
- `UPDATE_TASK`: Whether to update asynchronous tasks (Midjourney, Suno), default is `true`
|
| 131 |
+
- `COHERE_SAFETY_SETTING`: Cohere model safety settings, options are `NONE`, `CONTEXTUAL`, `STRICT`, default is `NONE`
|
| 132 |
+
- `GEMINI_VISION_MAX_IMAGE_NUM`: Maximum number of images for Gemini models, default is `16`
|
| 133 |
+
- `MAX_FILE_DOWNLOAD_MB`: Maximum file download size in MB, default is `20`
|
| 134 |
+
- `CRYPTO_SECRET`: Encryption key used for encrypting database content
|
| 135 |
+
- `AZURE_DEFAULT_API_VERSION`: Azure channel default API version, default is `2025-04-01-preview`
|
| 136 |
+
- `NOTIFICATION_LIMIT_DURATION_MINUTE`: Notification limit duration, default is `10` minutes
|
| 137 |
+
- `NOTIFY_LIMIT_COUNT`: Maximum number of user notifications within the specified duration, default is `2`
|
| 138 |
+
- `ERROR_LOG_ENABLED=true`: Whether to record and display error logs, default is `false`
|
| 139 |
+
|
| 140 |
+
## Deployment
|
| 141 |
+
|
| 142 |
+
For detailed deployment guides, please refer to [Installation Guide-Deployment Methods](https://docs.newapi.pro/installation):
|
| 143 |
+
|
| 144 |
+
> [!TIP]
|
| 145 |
+
> Latest Docker image: `calciumion/new-api:latest`
|
| 146 |
+
|
| 147 |
+
### Multi-machine Deployment Considerations
|
| 148 |
+
- Environment variable `SESSION_SECRET` must be set, otherwise login status will be inconsistent across multiple machines
|
| 149 |
+
- If sharing Redis, `CRYPTO_SECRET` must be set, otherwise Redis content cannot be accessed across multiple machines
|
| 150 |
+
|
| 151 |
+
### Deployment Requirements
|
| 152 |
+
- Local database (default): SQLite (Docker deployment must mount the `/data` directory)
|
| 153 |
+
- Remote database: MySQL version >= 5.7.8, PgSQL version >= 9.6
|
| 154 |
+
|
| 155 |
+
### Deployment Methods
|
| 156 |
+
|
| 157 |
+
#### Using BaoTa Panel Docker Feature
|
| 158 |
+
Install BaoTa Panel (version **9.2.0** or above), find **New-API** in the application store and install it.
|
| 159 |
+
[Tutorial with images](./docs/BT.md)
|
| 160 |
+
|
| 161 |
+
#### Using Docker Compose (Recommended)
|
| 162 |
+
```shell
|
| 163 |
+
# Download the project
|
| 164 |
+
git clone https://github.com/Calcium-Ion/new-api.git
|
| 165 |
+
cd new-api
|
| 166 |
+
# Edit docker-compose.yml as needed
|
| 167 |
+
# Start
|
| 168 |
+
docker-compose up -d
|
| 169 |
+
```
|
| 170 |
+
|
| 171 |
+
#### Using Docker Image Directly
|
| 172 |
+
```shell
|
| 173 |
+
# Using SQLite
|
| 174 |
+
docker run --name new-api -d --restart always -p 3000:3000 -e TZ=Asia/Shanghai -v /home/ubuntu/data/new-api:/data calciumion/new-api:latest
|
| 175 |
+
|
| 176 |
+
# Using MySQL
|
| 177 |
+
docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" -e TZ=Asia/Shanghai -v /home/ubuntu/data/new-api:/data calciumion/new-api:latest
|
| 178 |
+
```
|
| 179 |
+
|
| 180 |
+
## Channel Retry and Cache
|
| 181 |
+
Channel retry functionality has been implemented, you can set the number of retries in `Settings->Operation Settings->General Settings`. It is **recommended to enable caching**.
|
| 182 |
+
|
| 183 |
+
### Cache Configuration Method
|
| 184 |
+
1. `REDIS_CONN_STRING`: Set Redis as cache
|
| 185 |
+
2. `MEMORY_CACHE_ENABLED`: Enable memory cache (no need to set manually if Redis is set)
|
| 186 |
+
|
| 187 |
+
## API Documentation
|
| 188 |
+
|
| 189 |
+
For detailed API documentation, please refer to [API Documentation](https://docs.newapi.pro/api):
|
| 190 |
+
|
| 191 |
+
- [Chat API](https://docs.newapi.pro/api/openai-chat)
|
| 192 |
+
- [Image API](https://docs.newapi.pro/api/openai-image)
|
| 193 |
+
- [Rerank API](https://docs.newapi.pro/api/jinaai-rerank)
|
| 194 |
+
- [Realtime API](https://docs.newapi.pro/api/openai-realtime)
|
| 195 |
+
- [Claude Chat API (messages)](https://docs.newapi.pro/api/anthropic-chat)
|
| 196 |
+
|
| 197 |
+
## Related Projects
|
| 198 |
+
- [One API](https://github.com/songquanpeng/one-api): Original project
|
| 199 |
+
- [Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy): Midjourney interface support
|
| 200 |
+
- [chatnio](https://github.com/Deeptrain-Community/chatnio): Next-generation AI one-stop B/C-end solution
|
| 201 |
+
- [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool): Query usage quota with key
|
| 202 |
+
|
| 203 |
+
Other projects based on New API:
|
| 204 |
+
- [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon): High-performance optimized version of New API
|
| 205 |
+
- [VoAPI](https://github.com/VoAPI/VoAPI): Frontend beautified version based on New API
|
| 206 |
+
|
| 207 |
+
## Help and Support
|
| 208 |
+
|
| 209 |
+
If you have any questions, please refer to [Help and Support](https://docs.newapi.pro/support):
|
| 210 |
+
- [Community Interaction](https://docs.newapi.pro/support/community-interaction)
|
| 211 |
+
- [Issue Feedback](https://docs.newapi.pro/support/feedback-issues)
|
| 212 |
+
- [FAQ](https://docs.newapi.pro/support/faq)
|
| 213 |
+
|
| 214 |
+
## 🌟 Star History
|
| 215 |
+
|
| 216 |
+
[](https://star-history.com/#Calcium-Ion/new-api&Date)
|
README.fr.md
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<p align="right">
|
| 2 |
+
<a href="./README.md">中文</a> | <a href="./README.en.md">English</a> | <strong>Français</strong>
|
| 3 |
+
</p>
|
| 4 |
+
<div align="center">
|
| 5 |
+
|
| 6 |
+

|
| 7 |
+
|
| 8 |
+
# New API
|
| 9 |
+
|
| 10 |
+
🍥 Passerelle de modèles étendus de nouvelle génération et système de gestion d'actifs d'IA
|
| 11 |
+
|
| 12 |
+
<a href="https://trendshift.io/repositories/8227" target="_blank"><img src="https://trendshift.io/api/badge/repositories/8227" alt="Calcium-Ion%2Fnew-api | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
| 13 |
+
|
| 14 |
+
<p align="center">
|
| 15 |
+
<a href="https://raw.githubusercontent.com/Calcium-Ion/new-api/main/LICENSE">
|
| 16 |
+
<img src="https://img.shields.io/github/license/Calcium-Ion/new-api?color=brightgreen" alt="licence">
|
| 17 |
+
</a>
|
| 18 |
+
<a href="https://github.com/Calcium-Ion/new-api/releases/latest">
|
| 19 |
+
<img src="https://img.shields.io/github/v/release/Calcium-Ion/new-api?color=brightgreen&include_prereleases" alt="version">
|
| 20 |
+
</a>
|
| 21 |
+
<a href="https://github.com/users/Calcium-Ion/packages/container/package/new-api">
|
| 22 |
+
<img src="https://img.shields.io/badge/docker-ghcr.io-blue" alt="docker">
|
| 23 |
+
</a>
|
| 24 |
+
<a href="https://hub.docker.com/r/CalciumIon/new-api">
|
| 25 |
+
<img src="https://img.shields.io/badge/docker-dockerHub-blue" alt="docker">
|
| 26 |
+
</a>
|
| 27 |
+
<a href="https://goreportcard.com/report/github.com/Calcium-Ion/new-api">
|
| 28 |
+
<img src="https://goreportcard.com/badge/github.com/Calcium-Ion/new-api" alt="GoReportCard">
|
| 29 |
+
</a>
|
| 30 |
+
</p>
|
| 31 |
+
</div>
|
| 32 |
+
|
| 33 |
+
## 📝 Description du projet
|
| 34 |
+
|
| 35 |
+
> [!NOTE]
|
| 36 |
+
> Il s'agit d'un projet open-source développé sur la base de [One API](https://github.com/songquanpeng/one-api)
|
| 37 |
+
|
| 38 |
+
> [!IMPORTANT]
|
| 39 |
+
> - Ce projet est uniquement destiné à des fins d'apprentissage personnel, sans garantie de stabilité ni de support technique.
|
| 40 |
+
> - 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.
|
| 41 |
+
> - 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.
|
| 42 |
+
|
| 43 |
+
<h2>🤝 Partenaires de confiance</h2>
|
| 44 |
+
<p id="premium-sponsors"> </p>
|
| 45 |
+
<p align="center"><strong>Sans ordre particulier</strong></p>
|
| 46 |
+
<p align="center">
|
| 47 |
+
<a href="https://www.cherry-ai.com/" target=_blank><img
|
| 48 |
+
src="./docs/images/cherry-studio.png" alt="Cherry Studio" height="120"
|
| 49 |
+
/></a>
|
| 50 |
+
<a href="https://bda.pku.edu.cn/" target=_blank><img
|
| 51 |
+
src="./docs/images/pku.png" alt="Université de Pékin" height="120"
|
| 52 |
+
/></a>
|
| 53 |
+
<a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target=_blank><img
|
| 54 |
+
src="./docs/images/ucloud.png" alt="UCloud" height="120"
|
| 55 |
+
/></a>
|
| 56 |
+
<a href="https://www.aliyun.com/" target=_blank><img
|
| 57 |
+
src="./docs/images/aliyun.png" alt="Alibaba Cloud" height="120"
|
| 58 |
+
/></a>
|
| 59 |
+
<a href="https://io.net/" target=_blank><img
|
| 60 |
+
src="./docs/images/io-net.png" alt="IO.NET" height="120"
|
| 61 |
+
/></a>
|
| 62 |
+
</p>
|
| 63 |
+
<p> </p>
|
| 64 |
+
|
| 65 |
+
## 📚 Documentation
|
| 66 |
+
|
| 67 |
+
Pour une documentation détaillée, veuillez consulter notre Wiki officiel : [https://docs.newapi.pro/](https://docs.newapi.pro/)
|
| 68 |
+
|
| 69 |
+
Vous pouvez également accéder au DeepWiki généré par l'IA :
|
| 70 |
+
[](https://deepwiki.com/QuantumNous/new-api)
|
| 71 |
+
|
| 72 |
+
## ✨ Fonctionnalités clés
|
| 73 |
+
|
| 74 |
+
New API offre un large éventail de fonctionnalités, veuillez vous référer à [Présentation des fonctionnalités](https://docs.newapi.pro/wiki/features-introduction) pour plus de détails :
|
| 75 |
+
|
| 76 |
+
1. 🎨 Nouvelle interface utilisateur
|
| 77 |
+
2. 🌍 Prise en charge multilingue
|
| 78 |
+
3. 💰 Fonctionnalité de recharge en ligne (YiPay)
|
| 79 |
+
4. 🔍 Prise en charge de la recherche de quotas d'utilisation avec des clés (fonctionne avec [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool))
|
| 80 |
+
5. 🔄 Compatible avec la base de données originale de One API
|
| 81 |
+
6. 💵 Prise en charge de la tarification des modèles de paiement à l'utilisation
|
| 82 |
+
7. ⚖️ Prise en charge de la sélection aléatoire pondérée des canaux
|
| 83 |
+
8. 📈 Tableau de bord des données (console)
|
| 84 |
+
9. 🔒 Regroupement de jetons et restrictions de modèles
|
| 85 |
+
10. 🤖 Prise en charge de plus de méthodes de connexion par autorisation (LinuxDO, Telegram, OIDC)
|
| 86 |
+
11. 🔄 Prise en charge des modèles Rerank (Cohere et Jina), [Documentation de l'API](https://docs.newapi.pro/api/jinaai-rerank)
|
| 87 |
+
12. ⚡ Prise en charge de l'API OpenAI Realtime (y compris les canaux Azure), [Documentation de l'API](https://docs.newapi.pro/api/openai-realtime)
|
| 88 |
+
13. ⚡ Prise en charge du format Claude Messages, [Documentation de l'API](https://docs.newapi.pro/api/anthropic-chat)
|
| 89 |
+
14. Prise en charge de l'accès à l'interface de discussion via la route /chat2link
|
| 90 |
+
15. 🧠 Prise en charge de la définition de l'effort de raisonnement via les suffixes de nom de modèle :
|
| 91 |
+
1. Modèles de la série o d'OpenAI
|
| 92 |
+
- Ajouter le suffixe `-high` pour un effort de raisonnement élevé (par exemple : `o3-mini-high`)
|
| 93 |
+
- Ajouter le suffixe `-medium` pour un effort de raisonnement moyen (par exemple : `o3-mini-medium`)
|
| 94 |
+
- Ajouter le suffixe `-low` pour un effort de raisonnement faible (par exemple : `o3-mini-low`)
|
| 95 |
+
2. Modèles de pensée de Claude
|
| 96 |
+
- Ajouter le suffixe `-thinking` pour activer le mode de pensée (par exemple : `claude-3-7-sonnet-20250219-thinking`)
|
| 97 |
+
16. 🔄 Fonctionnalité de la pensée au contenu
|
| 98 |
+
17. 🔄 Limitation du débit du modèle pour les utilisateurs
|
| 99 |
+
18. 💰 Prise en charge de la facturation du cache, qui permet de facturer à un ratio défini lorsque le cache est atteint :
|
| 100 |
+
1. Définir l'option `Ratio de cache d'invite` dans `Paramètres système->Paramètres de fonctionnement`
|
| 101 |
+
2. Définir le `Ratio de cache d'invite` dans le canal, plage de 0 à 1, par exemple, le définir sur 0,5 signifie facturer à 50 % lorsque le cache est atteint
|
| 102 |
+
3. Canaux pris en charge :
|
| 103 |
+
- [x] OpenAI
|
| 104 |
+
- [x] Azure
|
| 105 |
+
- [x] DeepSeek
|
| 106 |
+
- [x] Claude
|
| 107 |
+
|
| 108 |
+
## Prise en charge des modèles
|
| 109 |
+
|
| 110 |
+
Cette version prend en charge plusieurs modèles, veuillez vous référer à [Documentation de l'API-Interface de relais](https://docs.newapi.pro/api) pour plus de détails :
|
| 111 |
+
|
| 112 |
+
1. Modèles tiers **gpts** (gpt-4-gizmo-*)
|
| 113 |
+
2. Canal tiers [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy), [Documentation de l'API](https://docs.newapi.pro/api/midjourney-proxy-image)
|
| 114 |
+
3. Canal tiers [Suno API](https://github.com/Suno-API/Suno-API), [Documentation de l'API](https://docs.newapi.pro/api/suno-music)
|
| 115 |
+
4. Canaux personnalisés, prenant en charge la saisie complète de l'adresse d'appel
|
| 116 |
+
5. Modèles Rerank ([Cohere](https://cohere.ai/) et [Jina](https://jina.ai/)), [Documentation de l'API](https://docs.newapi.pro/api/jinaai-rerank)
|
| 117 |
+
6. Format de messages Claude, [Documentation de l'API](https://docs.newapi.pro/api/anthropic-chat)
|
| 118 |
+
7. Dify, ne prend actuellement en charge que chatflow
|
| 119 |
+
|
| 120 |
+
## Configuration des variables d'environnement
|
| 121 |
+
|
| 122 |
+
Pour des instructions de configuration détaillées, veuillez vous référer à [Guide d'installation-Configuration des variables d'environnement](https://docs.newapi.pro/installation/environment-variables) :
|
| 123 |
+
|
| 124 |
+
- `GENERATE_DEFAULT_TOKEN` : S'il faut générer des jetons initiaux pour les utilisateurs nouvellement enregistrés, la valeur par défaut est `false`
|
| 125 |
+
- `STREAMING_TIMEOUT` : Délai d'expiration de la réponse en streaming, la valeur par défaut est de 300 secondes
|
| 126 |
+
- `DIFY_DEBUG` : S'il faut afficher les informations sur le flux de travail et les nœuds pour les canaux Dify, la valeur par défaut est `true`
|
| 127 |
+
- `FORCE_STREAM_OPTION` : S'il faut remplacer le paramètre client stream_options, la valeur par défaut est `true`
|
| 128 |
+
- `GET_MEDIA_TOKEN` : S'il faut compter les jetons d'image, la valeur par défaut est `true`
|
| 129 |
+
- `GET_MEDIA_TOKEN_NOT_STREAM` : S'il faut compter les jetons d'image dans les cas sans streaming, la valeur par défaut est `true`
|
| 130 |
+
- `UPDATE_TASK` : S'il faut mettre à jour les tâches asynchrones (Midjourney, Suno), la valeur par défaut est `true`
|
| 131 |
+
- `COHERE_SAFETY_SETTING` : Paramètres de sécurité du modèle Cohere, les options sont `NONE`, `CONTEXTUAL`, `STRICT`, la valeur par défaut est `NONE`
|
| 132 |
+
- `GEMINI_VISION_MAX_IMAGE_NUM` : Nombre maximum d'images pour les modèles Gemini, la valeur par défaut est `16`
|
| 133 |
+
- `MAX_FILE_DOWNLOAD_MB` : Taille maximale de téléchargement de fichier en Mo, la valeur par défaut est `20`
|
| 134 |
+
- `CRYPTO_SECRET` : Clé de chiffrement utilisée pour chiffrer le contenu de la base de données
|
| 135 |
+
- `AZURE_DEFAULT_API_VERSION` : Version de l'API par défaut du canal Azure, la valeur par défaut est `2025-04-01-preview`
|
| 136 |
+
- `NOTIFICATION_LIMIT_DURATION_MINUTE` : Durée de la limite de notification, la valeur par défaut est de `10` minutes
|
| 137 |
+
- `NOTIFY_LIMIT_COUNT` : Nombre maximal de notifications utilisateur dans la durée spécifiée, la valeur par défaut est `2`
|
| 138 |
+
- `ERROR_LOG_ENABLED=true` : S'il faut enregistrer et afficher les journaux d'erreurs, la valeur par défaut est `false`
|
| 139 |
+
|
| 140 |
+
## Déploiement
|
| 141 |
+
|
| 142 |
+
Pour des guides de déploiement détaillés, veuillez vous référer à [Guide d'installation-Méthodes de déploiement](https://docs.newapi.pro/installation) :
|
| 143 |
+
|
| 144 |
+
> [!TIP]
|
| 145 |
+
> Dernière image Docker : `calciumion/new-api:latest`
|
| 146 |
+
|
| 147 |
+
### Considérations sur le déploiement multi-machines
|
| 148 |
+
- La variable d'environnement `SESSION_SECRET` doit être définie, sinon l'état de connexion sera incohérent sur plusieurs machines
|
| 149 |
+
- Si vous partagez Redis, `CRYPTO_SECRET` doit être défini, sinon le contenu de Redis ne pourra pas être consulté sur plusieurs machines
|
| 150 |
+
|
| 151 |
+
### Exigences de déploiement
|
| 152 |
+
- Base de données locale (par défaut) : SQLite (le déploiement Docker doit monter le répertoire `/data`)
|
| 153 |
+
- Base de données distante : MySQL version >= 5.7.8, PgSQL version >= 9.6
|
| 154 |
+
|
| 155 |
+
### Méthodes de déploiement
|
| 156 |
+
|
| 157 |
+
#### Utilisation de la fonctionnalité Docker du panneau BaoTa
|
| 158 |
+
Installez le panneau BaoTa (version **9.2.0** ou supérieure), recherchez **New-API** dans le magasin d'applications et installez-le.
|
| 159 |
+
[Tutoriel avec des images](./docs/BT.md)
|
| 160 |
+
|
| 161 |
+
#### Utilisation de Docker Compose (recommandé)
|
| 162 |
+
```shell
|
| 163 |
+
# Télécharger le projet
|
| 164 |
+
git clone https://github.com/Calcium-Ion/new-api.git
|
| 165 |
+
cd new-api
|
| 166 |
+
# Modifier docker-compose.yml si nécessaire
|
| 167 |
+
# Démarrer
|
| 168 |
+
docker-compose up -d
|
| 169 |
+
```
|
| 170 |
+
|
| 171 |
+
#### Utilisation directe de l'image Docker
|
| 172 |
+
```shell
|
| 173 |
+
# Utilisation de SQLite
|
| 174 |
+
docker run --name new-api -d --restart always -p 3000:3000 -e TZ=Asia/Shanghai -v /home/ubuntu/data/new-api:/data calciumion/new-api:latest
|
| 175 |
+
|
| 176 |
+
# Utilisation de MySQL
|
| 177 |
+
docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" -e TZ=Asia/Shanghai -v /home/ubuntu/data/new-api:/data calciumion/new-api:latest
|
| 178 |
+
```
|
| 179 |
+
|
| 180 |
+
## Nouvelle tentative de canal et cache
|
| 181 |
+
La fonctionnalité de nouvelle tentative de canal a été implémentée, vous pouvez définir le nombre de tentatives dans `Paramètres->Paramètres de fonctionnement->Paramètres généraux`. Il est **recommandé d'activer la mise en cache**.
|
| 182 |
+
|
| 183 |
+
### Méthode de configuration du cache
|
| 184 |
+
1. `REDIS_CONN_STRING` : Définir Redis comme cache
|
| 185 |
+
2. `MEMORY_CACHE_ENABLED` : Activer le cache mémoire (pas besoin de le définir manuellement si Redis est défini)
|
| 186 |
+
|
| 187 |
+
## Documentation de l'API
|
| 188 |
+
|
| 189 |
+
Pour une documentation détaillée de l'API, veuillez vous référer à [Documentation de l'API](https://docs.newapi.pro/api) :
|
| 190 |
+
|
| 191 |
+
- [API de discussion](https://docs.newapi.pro/api/openai-chat)
|
| 192 |
+
- [API d'image](https://docs.newapi.pro/api/openai-image)
|
| 193 |
+
- [API de rerank](https://docs.newapi.pro/api/jinaai-rerank)
|
| 194 |
+
- [API en temps réel](https://docs.newapi.pro/api/openai-realtime)
|
| 195 |
+
- [API de discussion Claude (messages)](https://docs.newapi.pro/api/anthropic-chat)
|
| 196 |
+
|
| 197 |
+
## Projets connexes
|
| 198 |
+
- [One API](https://github.com/songquanpeng/one-api) : Projet original
|
| 199 |
+
- [Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy) : Prise en charge de l'interface Midjourney
|
| 200 |
+
- [chatnio](https://github.com/Deeptrain-Community/chatnio) : Solution B/C unique d'IA de nouvelle génération
|
| 201 |
+
- [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool) : Interroger le quota d'utilisation avec une clé
|
| 202 |
+
|
| 203 |
+
Autres projets basés sur New API :
|
| 204 |
+
- [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon) : Version optimisée hautes performances de New API
|
| 205 |
+
- [VoAPI](https://github.com/VoAPI/VoAPI) : Version embellie du frontend basée sur New API
|
| 206 |
+
|
| 207 |
+
## Aide et support
|
| 208 |
+
|
| 209 |
+
Si vous avez des questions, veuillez vous référer à [Aide et support](https://docs.newapi.pro/support) :
|
| 210 |
+
- [Interaction avec la communauté](https://docs.newapi.pro/support/community-interaction)
|
| 211 |
+
- [Commentaires sur les problèmes](https://docs.newapi.pro/support/feedback-issues)
|
| 212 |
+
- [FAQ](https://docs.newapi.pro/support/faq)
|
| 213 |
+
|
| 214 |
+
## 🌟 Historique des étoiles
|
| 215 |
+
|
| 216 |
+
[](https://star-history.com/#Calcium-Ion/new-api&Date)
|
README.md
CHANGED
|
@@ -1,11 +1,219 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<p align="right">
|
| 2 |
+
<strong>中文</strong> | <a href="./README.en.md">English</a> | <a href="./README.fr.md">Français</a>
|
| 3 |
+
</p>
|
| 4 |
+
<div align="center">
|
| 5 |
+
|
| 6 |
+

|
| 7 |
+
|
| 8 |
+
# New API
|
| 9 |
+
|
| 10 |
+
🍥新一代大模型网关与AI资产管理系统
|
| 11 |
+
|
| 12 |
+
<a href="https://trendshift.io/repositories/8227" target="_blank"><img src="https://trendshift.io/api/badge/repositories/8227" alt="Calcium-Ion%2Fnew-api | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
| 13 |
+
|
| 14 |
+
<p align="center">
|
| 15 |
+
<a href="https://raw.githubusercontent.com/Calcium-Ion/new-api/main/LICENSE">
|
| 16 |
+
<img src="https://img.shields.io/github/license/Calcium-Ion/new-api?color=brightgreen" alt="license">
|
| 17 |
+
</a>
|
| 18 |
+
<a href="https://github.com/Calcium-Ion/new-api/releases/latest">
|
| 19 |
+
<img src="https://img.shields.io/github/v/release/Calcium-Ion/new-api?color=brightgreen&include_prereleases" alt="release">
|
| 20 |
+
</a>
|
| 21 |
+
<a href="https://github.com/users/Calcium-Ion/packages/container/package/new-api">
|
| 22 |
+
<img src="https://img.shields.io/badge/docker-ghcr.io-blue" alt="docker">
|
| 23 |
+
</a>
|
| 24 |
+
<a href="https://hub.docker.com/r/CalciumIon/new-api">
|
| 25 |
+
<img src="https://img.shields.io/badge/docker-dockerHub-blue" alt="docker">
|
| 26 |
+
</a>
|
| 27 |
+
<a href="https://goreportcard.com/report/github.com/Calcium-Ion/new-api">
|
| 28 |
+
<img src="https://goreportcard.com/badge/github.com/Calcium-Ion/new-api" alt="GoReportCard">
|
| 29 |
+
</a>
|
| 30 |
+
</p>
|
| 31 |
+
</div>
|
| 32 |
+
|
| 33 |
+
## 📝 项目说明
|
| 34 |
+
|
| 35 |
+
> [!NOTE]
|
| 36 |
+
> 本项目为开源项目,在[One API](https://github.com/songquanpeng/one-api)的基础上进行二次开发
|
| 37 |
+
|
| 38 |
+
> [!IMPORTANT]
|
| 39 |
+
> - 本项目仅供个人学习使用,不保证稳定性,且不提供任何技术支持。
|
| 40 |
+
> - 使用者必须在遵循 OpenAI 的[使用条款](https://openai.com/policies/terms-of-use)以及**法律法规**的情况下使用,不得用于非法用途。
|
| 41 |
+
> - 根据[《生成式人工智能服务管理暂行办法》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm)的要求,请勿对中国地区公众提供一切未经备案的生成式人工智能服务。
|
| 42 |
+
|
| 43 |
+
<h2>🤝 我们信任的合作伙伴</h2>
|
| 44 |
+
<p id="premium-sponsors"> </p>
|
| 45 |
+
<p align="center"><strong>排名不分先后</strong></p>
|
| 46 |
+
<p align="center">
|
| 47 |
+
<a href="https://www.cherry-ai.com/" target=_blank><img
|
| 48 |
+
src="./docs/images/cherry-studio.png" alt="Cherry Studio" height="120"
|
| 49 |
+
/></a>
|
| 50 |
+
<a href="https://bda.pku.edu.cn/" target=_blank><img
|
| 51 |
+
src="./docs/images/pku.png" alt="北京大学" height="120"
|
| 52 |
+
/></a>
|
| 53 |
+
<a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target=_blank><img
|
| 54 |
+
src="./docs/images/ucloud.png" alt="UCloud 优刻得" height="120"
|
| 55 |
+
/></a>
|
| 56 |
+
<a href="https://www.aliyun.com/" target=_blank><img
|
| 57 |
+
src="./docs/images/aliyun.png" alt="阿里云" height="120"
|
| 58 |
+
/></a>
|
| 59 |
+
<a href="https://io.net/" target=_blank><img
|
| 60 |
+
src="./docs/images/io-net.png" alt="IO.NET" height="120"
|
| 61 |
+
/></a>
|
| 62 |
+
</p>
|
| 63 |
+
<p> </p>
|
| 64 |
+
|
| 65 |
+
## 📚 文档
|
| 66 |
+
|
| 67 |
+
详细文档请访问我们的官方Wiki:[https://docs.newapi.pro/](https://docs.newapi.pro/)
|
| 68 |
+
|
| 69 |
+
也可访问AI生成的DeepWiki:
|
| 70 |
+
[](https://deepwiki.com/QuantumNous/new-api)
|
| 71 |
+
|
| 72 |
+
## ✨ 主要特性
|
| 73 |
+
|
| 74 |
+
New API提供了丰富的功能,详细特性请参考[特性说明](https://docs.newapi.pro/wiki/features-introduction):
|
| 75 |
+
|
| 76 |
+
1. 🎨 全新的UI界面
|
| 77 |
+
2. 🌍 多语言支持
|
| 78 |
+
3. 💰 支持在线充值功能(易支付)
|
| 79 |
+
4. 🔍 支持用key查询使用额度(配合[neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool))
|
| 80 |
+
5. 🔄 兼容原版One API的数据库
|
| 81 |
+
6. 💵 支持模型按次数收费
|
| 82 |
+
7. ⚖️ 支持渠道加权随机
|
| 83 |
+
8. 📈 数据看板(控制台)
|
| 84 |
+
9. 🔒 令牌分组、模型限制
|
| 85 |
+
10. 🤖 支持更多授权登陆方式(LinuxDO,Telegram、OIDC)
|
| 86 |
+
11. 🔄 支持Rerank模型(Cohere和Jina),[接口文档](https://docs.newapi.pro/api/jinaai-rerank)
|
| 87 |
+
12. ⚡ 支持OpenAI Realtime API(包括Azure渠道),[接口文档](https://docs.newapi.pro/api/openai-realtime)
|
| 88 |
+
13. ⚡ 支持Claude Messages 格式,[接口文档](https://docs.newapi.pro/api/anthropic-chat)
|
| 89 |
+
14. 支持使用路由/chat2link进入聊天界面
|
| 90 |
+
15. 🧠 支持通过模型名称后缀设置 reasoning effort:
|
| 91 |
+
1. OpenAI o系列模型
|
| 92 |
+
- 添加后缀 `-high` 设置为 high reasoning effort (例如: `o3-mini-high`)
|
| 93 |
+
- 添加后缀 `-medium` 设置为 medium reasoning effort (例如: `o3-mini-medium`)
|
| 94 |
+
- 添加后缀 `-low` 设置为 low reasoning effort (例如: `o3-mini-low`)
|
| 95 |
+
2. Claude 思考模型
|
| 96 |
+
- 添加后缀 `-thinking` 启用思考模式 (例如: `claude-3-7-sonnet-20250219-thinking`)
|
| 97 |
+
16. 🔄 思考转内容功能
|
| 98 |
+
17. 🔄 针对用户的模型限流功能
|
| 99 |
+
18. 🔄 请求格式转换功能,支持以下三种格式转换:
|
| 100 |
+
1. OpenAI Chat Completions => Claude Messages
|
| 101 |
+
2. Clade Messages => OpenAI Chat Completions (可用于Claude Code调用第三方模型)
|
| 102 |
+
3. OpenAI Chat Completions => Gemini Chat
|
| 103 |
+
19. 💰 缓存计费支持,开启后可以在缓存命中时按照设定的比例计费:
|
| 104 |
+
1. 在 `系统设置-运营设置` 中设置 `提示缓存倍率` 选项
|
| 105 |
+
2. 在渠道中设置 `提示缓存倍率`,范围 0-1,例如设置为 0.5 表示缓存命中时按照 50% 计费
|
| 106 |
+
3. 支持的渠道:
|
| 107 |
+
- [x] OpenAI
|
| 108 |
+
- [x] Azure
|
| 109 |
+
- [x] DeepSeek
|
| 110 |
+
- [x] Claude
|
| 111 |
+
|
| 112 |
+
## 模型支持
|
| 113 |
+
|
| 114 |
+
此版本支持多种模型,详情请参考[接口文档-中继接口](https://docs.newapi.pro/api):
|
| 115 |
+
|
| 116 |
+
1. 第三方模型 **gpts** (gpt-4-gizmo-*)
|
| 117 |
+
2. 第三方渠道[Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy)接口,[接口文档](https://docs.newapi.pro/api/midjourney-proxy-image)
|
| 118 |
+
3. 第三方渠道[Suno API](https://github.com/Suno-API/Suno-API)接口,[接口文档](https://docs.newapi.pro/api/suno-music)
|
| 119 |
+
4. 自定义渠道,支持填入完整调用地址
|
| 120 |
+
5. Rerank模型([Cohere](https://cohere.ai/)和[Jina](https://jina.ai/)),[接口文档](https://docs.newapi.pro/api/jinaai-rerank)
|
| 121 |
+
6. Claude Messages 格式,[接口文档](https://docs.newapi.pro/api/anthropic-chat)
|
| 122 |
+
7. Dify,当前仅支持chatflow
|
| 123 |
+
|
| 124 |
+
## 环境变量配置
|
| 125 |
+
|
| 126 |
+
详细配置说明请参考[安装指南-环境变量配置](https://docs.newapi.pro/installation/environment-variables):
|
| 127 |
+
|
| 128 |
+
- `GENERATE_DEFAULT_TOKEN`:是否为新注册用户生成初始令牌,默认为 `false`
|
| 129 |
+
- `STREAMING_TIMEOUT`:流式回复超时时间,默认300秒
|
| 130 |
+
- `DIFY_DEBUG`:Dify渠道是否输出工作流和节点信息,默认 `true`
|
| 131 |
+
- `FORCE_STREAM_OPTION`:是否覆盖客户端stream_options参数,默认 `true`
|
| 132 |
+
- `GET_MEDIA_TOKEN`:是否统计图片token,默认 `true`
|
| 133 |
+
- `GET_MEDIA_TOKEN_NOT_STREAM`:非流情况下是否统计图片token,默认 `true`
|
| 134 |
+
- `UPDATE_TASK`:是否更新异步任务(Midjourney、Suno),默认 `true`
|
| 135 |
+
- `COHERE_SAFETY_SETTING`:Cohere模型安全设置,可选值为 `NONE`, `CONTEXTUAL`, `STRICT`,默认 `NONE`
|
| 136 |
+
- `GEMINI_VISION_MAX_IMAGE_NUM`:Gemini模型最大图片数量,默认 `16`
|
| 137 |
+
- `MAX_FILE_DOWNLOAD_MB`: 最大文件下载大小,单位MB,默认 `20`
|
| 138 |
+
- `CRYPTO_SECRET`:加密密钥,用于加密数据库内容
|
| 139 |
+
- `AZURE_DEFAULT_API_VERSION`:Azure渠道默认API版本,默认 `2025-04-01-preview`
|
| 140 |
+
- `NOTIFICATION_LIMIT_DURATION_MINUTE`:通知限制持续时间,默认 `10`分钟
|
| 141 |
+
- `NOTIFY_LIMIT_COUNT`:用户通知在指定持续时间内的最大数量,默认 `2`
|
| 142 |
+
- `ERROR_LOG_ENABLED=true`: 是否记录并显示错误日志,默认`false`
|
| 143 |
+
|
| 144 |
+
## 部署
|
| 145 |
+
|
| 146 |
+
详细部署指南请参考[安装指南-部署方式](https://docs.newapi.pro/installation):
|
| 147 |
+
|
| 148 |
+
> [!TIP]
|
| 149 |
+
> 最新版Docker镜像:`calciumion/new-api:latest`
|
| 150 |
+
|
| 151 |
+
### 多机部署注意事项
|
| 152 |
+
- 必须设置环境变量 `SESSION_SECRET`,否则会导致多机部署时登录状态不一致
|
| 153 |
+
- 如果公用Redis,必须设置 `CRYPTO_SECRET`,否则会导致多机部署时Redis内容无法获取
|
| 154 |
+
|
| 155 |
+
### 部署要求
|
| 156 |
+
- 本地数据库(默认):SQLite(Docker部署必须挂载`/data`目录)
|
| 157 |
+
- 远程数据库:MySQL版本 >= 5.7.8,PgSQL版本 >= 9.6
|
| 158 |
+
|
| 159 |
+
### 部署方式
|
| 160 |
+
|
| 161 |
+
#### 使用宝塔面板Docker功能部署
|
| 162 |
+
安装宝塔面板(**9.2.0版本**及以上),在应用商店中找到**New-API**安装即可。
|
| 163 |
+
[图文教程](./docs/BT.md)
|
| 164 |
+
|
| 165 |
+
#### 使用Docker Compose部署(推荐)
|
| 166 |
+
```shell
|
| 167 |
+
# 下载项目
|
| 168 |
+
git clone https://github.com/Calcium-Ion/new-api.git
|
| 169 |
+
cd new-api
|
| 170 |
+
# 按需编辑docker-compose.yml
|
| 171 |
+
# 启动
|
| 172 |
+
docker-compose up -d
|
| 173 |
+
```
|
| 174 |
+
|
| 175 |
+
#### 直接使用Docker镜像
|
| 176 |
+
```shell
|
| 177 |
+
# 使用SQLite
|
| 178 |
+
docker run --name new-api -d --restart always -p 3000:3000 -e TZ=Asia/Shanghai -v /home/ubuntu/data/new-api:/data calciumion/new-api:latest
|
| 179 |
+
|
| 180 |
+
# 使用MySQL
|
| 181 |
+
docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" -e TZ=Asia/Shanghai -v /home/ubuntu/data/new-api:/data calciumion/new-api:latest
|
| 182 |
+
```
|
| 183 |
+
|
| 184 |
+
## 渠道重试与缓存
|
| 185 |
+
渠道重试功能已经实现,可以在`设置->运营设置->通用设置`设置重试次数,**建议开启缓存**功能。
|
| 186 |
+
|
| 187 |
+
### 缓存设置方法
|
| 188 |
+
1. `REDIS_CONN_STRING`:设置Redis作为缓存
|
| 189 |
+
2. `MEMORY_CACHE_ENABLED`:启用内存缓存(设置了Redis则无需手动设置)
|
| 190 |
+
|
| 191 |
+
## 接口文档
|
| 192 |
+
|
| 193 |
+
详细接口文档请参考[接口文档](https://docs.newapi.pro/api):
|
| 194 |
+
|
| 195 |
+
- [聊天接口(Chat)](https://docs.newapi.pro/api/openai-chat)
|
| 196 |
+
- [图像接口(Image)](https://docs.newapi.pro/api/openai-image)
|
| 197 |
+
- [重排序接口(Rerank)](https://docs.newapi.pro/api/jinaai-rerank)
|
| 198 |
+
- [实时对话接口(Realtime)](https://docs.newapi.pro/api/openai-realtime)
|
| 199 |
+
- [Claude聊天接口(messages)](https://docs.newapi.pro/api/anthropic-chat)
|
| 200 |
+
|
| 201 |
+
## 相关项目
|
| 202 |
+
- [One API](https://github.com/songquanpeng/one-api):原版项目
|
| 203 |
+
- [Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy):Midjourney接口支持
|
| 204 |
+
- [chatnio](https://github.com/Deeptrain-Community/chatnio):下一代AI一站式B/C端解决方案
|
| 205 |
+
- [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool):用key查询使用额度
|
| 206 |
+
|
| 207 |
+
其他基于New API的项目:
|
| 208 |
+
- [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon):New API高性能优化版
|
| 209 |
+
|
| 210 |
+
## 帮助支持
|
| 211 |
+
|
| 212 |
+
如有问题,请参考[帮助支持](https://docs.newapi.pro/support):
|
| 213 |
+
- [社区交流](https://docs.newapi.pro/support/community-interaction)
|
| 214 |
+
- [反馈问题](https://docs.newapi.pro/support/feedback-issues)
|
| 215 |
+
- [常见问题](https://docs.newapi.pro/support/faq)
|
| 216 |
+
|
| 217 |
+
## 🌟 Star History
|
| 218 |
+
|
| 219 |
+
[](https://star-history.com/#Calcium-Ion/new-api&Date)
|
VERSION
ADDED
|
File without changes
|
bin/migration_v0.2-v0.3.sql
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
UPDATE users
|
| 2 |
+
SET quota = quota + (
|
| 3 |
+
SELECT SUM(remain_quota)
|
| 4 |
+
FROM tokens
|
| 5 |
+
WHERE tokens.user_id = users.id
|
| 6 |
+
)
|
bin/migration_v0.3-v0.4.sql
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
INSERT INTO abilities (`group`, model, channel_id, enabled)
|
| 2 |
+
SELECT c.`group`, m.model, c.id, 1
|
| 3 |
+
FROM channels c
|
| 4 |
+
CROSS JOIN (
|
| 5 |
+
SELECT 'gpt-3.5-turbo' AS model UNION ALL
|
| 6 |
+
SELECT 'gpt-3.5-turbo-0301' AS model UNION ALL
|
| 7 |
+
SELECT 'gpt-4' AS model UNION ALL
|
| 8 |
+
SELECT 'gpt-4-0314' AS model
|
| 9 |
+
) AS m
|
| 10 |
+
WHERE c.status = 1
|
| 11 |
+
AND NOT EXISTS (
|
| 12 |
+
SELECT 1
|
| 13 |
+
FROM abilities a
|
| 14 |
+
WHERE a.`group` = c.`group`
|
| 15 |
+
AND a.model = m.model
|
| 16 |
+
AND a.channel_id = c.id
|
| 17 |
+
);
|
bin/time_test.sh
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
|
| 3 |
+
if [ $# -lt 3 ]; then
|
| 4 |
+
echo "Usage: time_test.sh <domain> <key> <count> [<model>]"
|
| 5 |
+
exit 1
|
| 6 |
+
fi
|
| 7 |
+
|
| 8 |
+
domain=$1
|
| 9 |
+
key=$2
|
| 10 |
+
count=$3
|
| 11 |
+
model=${4:-"gpt-3.5-turbo"} # 设置默认模型为 gpt-3.5-turbo
|
| 12 |
+
|
| 13 |
+
total_time=0
|
| 14 |
+
times=()
|
| 15 |
+
|
| 16 |
+
for ((i=1; i<=count; i++)); do
|
| 17 |
+
result=$(curl -o /dev/null -s -w "%{http_code} %{time_total}\\n" \
|
| 18 |
+
https://"$domain"/v1/chat/completions \
|
| 19 |
+
-H "Content-Type: application/json" \
|
| 20 |
+
-H "Authorization: Bearer $key" \
|
| 21 |
+
-d '{"messages": [{"content": "echo hi", "role": "user"}], "model": "'"$model"'", "stream": false, "max_tokens": 1}')
|
| 22 |
+
http_code=$(echo "$result" | awk '{print $1}')
|
| 23 |
+
time=$(echo "$result" | awk '{print $2}')
|
| 24 |
+
echo "HTTP status code: $http_code, Time taken: $time"
|
| 25 |
+
total_time=$(bc <<< "$total_time + $time")
|
| 26 |
+
times+=("$time")
|
| 27 |
+
done
|
| 28 |
+
|
| 29 |
+
average_time=$(echo "scale=4; $total_time / $count" | bc)
|
| 30 |
+
|
| 31 |
+
sum_of_squares=0
|
| 32 |
+
for time in "${times[@]}"; do
|
| 33 |
+
difference=$(echo "scale=4; $time - $average_time" | bc)
|
| 34 |
+
square=$(echo "scale=4; $difference * $difference" | bc)
|
| 35 |
+
sum_of_squares=$(echo "scale=4; $sum_of_squares + $square" | bc)
|
| 36 |
+
done
|
| 37 |
+
|
| 38 |
+
standard_deviation=$(echo "scale=4; sqrt($sum_of_squares / $count)" | bc)
|
| 39 |
+
|
| 40 |
+
echo "Average time: $average_time±$standard_deviation"
|
common/api_type.go
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package common
|
| 2 |
+
|
| 3 |
+
import "one-api/constant"
|
| 4 |
+
|
| 5 |
+
func ChannelType2APIType(channelType int) (int, bool) {
|
| 6 |
+
apiType := -1
|
| 7 |
+
switch channelType {
|
| 8 |
+
case constant.ChannelTypeOpenAI:
|
| 9 |
+
apiType = constant.APITypeOpenAI
|
| 10 |
+
case constant.ChannelTypeAnthropic:
|
| 11 |
+
apiType = constant.APITypeAnthropic
|
| 12 |
+
case constant.ChannelTypeBaidu:
|
| 13 |
+
apiType = constant.APITypeBaidu
|
| 14 |
+
case constant.ChannelTypePaLM:
|
| 15 |
+
apiType = constant.APITypePaLM
|
| 16 |
+
case constant.ChannelTypeZhipu:
|
| 17 |
+
apiType = constant.APITypeZhipu
|
| 18 |
+
case constant.ChannelTypeAli:
|
| 19 |
+
apiType = constant.APITypeAli
|
| 20 |
+
case constant.ChannelTypeXunfei:
|
| 21 |
+
apiType = constant.APITypeXunfei
|
| 22 |
+
case constant.ChannelTypeAIProxyLibrary:
|
| 23 |
+
apiType = constant.APITypeAIProxyLibrary
|
| 24 |
+
case constant.ChannelTypeTencent:
|
| 25 |
+
apiType = constant.APITypeTencent
|
| 26 |
+
case constant.ChannelTypeGemini:
|
| 27 |
+
apiType = constant.APITypeGemini
|
| 28 |
+
case constant.ChannelTypeZhipu_v4:
|
| 29 |
+
apiType = constant.APITypeZhipuV4
|
| 30 |
+
case constant.ChannelTypeOllama:
|
| 31 |
+
apiType = constant.APITypeOllama
|
| 32 |
+
case constant.ChannelTypePerplexity:
|
| 33 |
+
apiType = constant.APITypePerplexity
|
| 34 |
+
case constant.ChannelTypeAws:
|
| 35 |
+
apiType = constant.APITypeAws
|
| 36 |
+
case constant.ChannelTypeCohere:
|
| 37 |
+
apiType = constant.APITypeCohere
|
| 38 |
+
case constant.ChannelTypeDify:
|
| 39 |
+
apiType = constant.APITypeDify
|
| 40 |
+
case constant.ChannelTypeJina:
|
| 41 |
+
apiType = constant.APITypeJina
|
| 42 |
+
case constant.ChannelCloudflare:
|
| 43 |
+
apiType = constant.APITypeCloudflare
|
| 44 |
+
case constant.ChannelTypeSiliconFlow:
|
| 45 |
+
apiType = constant.APITypeSiliconFlow
|
| 46 |
+
case constant.ChannelTypeVertexAi:
|
| 47 |
+
apiType = constant.APITypeVertexAi
|
| 48 |
+
case constant.ChannelTypeMistral:
|
| 49 |
+
apiType = constant.APITypeMistral
|
| 50 |
+
case constant.ChannelTypeDeepSeek:
|
| 51 |
+
apiType = constant.APITypeDeepSeek
|
| 52 |
+
case constant.ChannelTypeMokaAI:
|
| 53 |
+
apiType = constant.APITypeMokaAI
|
| 54 |
+
case constant.ChannelTypeVolcEngine:
|
| 55 |
+
apiType = constant.APITypeVolcEngine
|
| 56 |
+
case constant.ChannelTypeBaiduV2:
|
| 57 |
+
apiType = constant.APITypeBaiduV2
|
| 58 |
+
case constant.ChannelTypeOpenRouter:
|
| 59 |
+
apiType = constant.APITypeOpenRouter
|
| 60 |
+
case constant.ChannelTypeXinference:
|
| 61 |
+
apiType = constant.APITypeXinference
|
| 62 |
+
case constant.ChannelTypeXai:
|
| 63 |
+
apiType = constant.APITypeXai
|
| 64 |
+
case constant.ChannelTypeCoze:
|
| 65 |
+
apiType = constant.APITypeCoze
|
| 66 |
+
case constant.ChannelTypeJimeng:
|
| 67 |
+
apiType = constant.APITypeJimeng
|
| 68 |
+
case constant.ChannelTypeMoonshot:
|
| 69 |
+
apiType = constant.APITypeMoonshot
|
| 70 |
+
case constant.ChannelTypeSubmodel:
|
| 71 |
+
apiType = constant.APITypeSubmodel
|
| 72 |
+
}
|
| 73 |
+
if apiType == -1 {
|
| 74 |
+
return constant.APITypeOpenAI, false
|
| 75 |
+
}
|
| 76 |
+
return apiType, true
|
| 77 |
+
}
|
common/constants.go
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package common
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
//"os"
|
| 5 |
+
//"strconv"
|
| 6 |
+
"sync"
|
| 7 |
+
"time"
|
| 8 |
+
|
| 9 |
+
"github.com/google/uuid"
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
var StartTime = time.Now().Unix() // unit: second
|
| 13 |
+
var Version = "v0.0.0" // this hard coding will be replaced automatically when building, no need to manually change
|
| 14 |
+
var SystemName = "New API"
|
| 15 |
+
var Footer = ""
|
| 16 |
+
var Logo = ""
|
| 17 |
+
var TopUpLink = ""
|
| 18 |
+
|
| 19 |
+
// var ChatLink = ""
|
| 20 |
+
// var ChatLink2 = ""
|
| 21 |
+
var QuotaPerUnit = 500 * 1000.0 // $0.002 / 1K tokens
|
| 22 |
+
var DisplayInCurrencyEnabled = true
|
| 23 |
+
var DisplayTokenStatEnabled = true
|
| 24 |
+
var DrawingEnabled = true
|
| 25 |
+
var TaskEnabled = true
|
| 26 |
+
var DataExportEnabled = true
|
| 27 |
+
var DataExportInterval = 5 // unit: minute
|
| 28 |
+
var DataExportDefaultTime = "hour" // unit: minute
|
| 29 |
+
var DefaultCollapseSidebar = false // default value of collapse sidebar
|
| 30 |
+
|
| 31 |
+
// Any options with "Secret", "Token" in its key won't be return by GetOptions
|
| 32 |
+
|
| 33 |
+
var SessionSecret = uuid.New().String()
|
| 34 |
+
var CryptoSecret = uuid.New().String()
|
| 35 |
+
|
| 36 |
+
var OptionMap map[string]string
|
| 37 |
+
var OptionMapRWMutex sync.RWMutex
|
| 38 |
+
|
| 39 |
+
var ItemsPerPage = 10
|
| 40 |
+
var MaxRecentItems = 100
|
| 41 |
+
|
| 42 |
+
var PasswordLoginEnabled = true
|
| 43 |
+
var PasswordRegisterEnabled = true
|
| 44 |
+
var EmailVerificationEnabled = false
|
| 45 |
+
var GitHubOAuthEnabled = false
|
| 46 |
+
var LinuxDOOAuthEnabled = false
|
| 47 |
+
var WeChatAuthEnabled = false
|
| 48 |
+
var TelegramOAuthEnabled = false
|
| 49 |
+
var TurnstileCheckEnabled = false
|
| 50 |
+
var RegisterEnabled = true
|
| 51 |
+
|
| 52 |
+
var EmailDomainRestrictionEnabled = false // 是否启用邮箱域名限制
|
| 53 |
+
var EmailAliasRestrictionEnabled = false // 是否启用邮箱别名限制
|
| 54 |
+
var EmailDomainWhitelist = []string{
|
| 55 |
+
"gmail.com",
|
| 56 |
+
"163.com",
|
| 57 |
+
"126.com",
|
| 58 |
+
"qq.com",
|
| 59 |
+
"outlook.com",
|
| 60 |
+
"hotmail.com",
|
| 61 |
+
"icloud.com",
|
| 62 |
+
"yahoo.com",
|
| 63 |
+
"foxmail.com",
|
| 64 |
+
}
|
| 65 |
+
var EmailLoginAuthServerList = []string{
|
| 66 |
+
"smtp.sendcloud.net",
|
| 67 |
+
"smtp.azurecomm.net",
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
var DebugEnabled bool
|
| 71 |
+
var MemoryCacheEnabled bool
|
| 72 |
+
|
| 73 |
+
var LogConsumeEnabled = true
|
| 74 |
+
|
| 75 |
+
var SMTPServer = ""
|
| 76 |
+
var SMTPPort = 587
|
| 77 |
+
var SMTPSSLEnabled = false
|
| 78 |
+
var SMTPAccount = ""
|
| 79 |
+
var SMTPFrom = ""
|
| 80 |
+
var SMTPToken = ""
|
| 81 |
+
|
| 82 |
+
var GitHubClientId = ""
|
| 83 |
+
var GitHubClientSecret = ""
|
| 84 |
+
var LinuxDOClientId = ""
|
| 85 |
+
var LinuxDOClientSecret = ""
|
| 86 |
+
var LinuxDOMinimumTrustLevel = 0
|
| 87 |
+
|
| 88 |
+
var WeChatServerAddress = ""
|
| 89 |
+
var WeChatServerToken = ""
|
| 90 |
+
var WeChatAccountQRCodeImageURL = ""
|
| 91 |
+
|
| 92 |
+
var TurnstileSiteKey = ""
|
| 93 |
+
var TurnstileSecretKey = ""
|
| 94 |
+
|
| 95 |
+
var TelegramBotToken = ""
|
| 96 |
+
var TelegramBotName = ""
|
| 97 |
+
|
| 98 |
+
var QuotaForNewUser = 0
|
| 99 |
+
var QuotaForInviter = 0
|
| 100 |
+
var QuotaForInvitee = 0
|
| 101 |
+
var ChannelDisableThreshold = 5.0
|
| 102 |
+
var AutomaticDisableChannelEnabled = false
|
| 103 |
+
var AutomaticEnableChannelEnabled = false
|
| 104 |
+
var QuotaRemindThreshold = 1000
|
| 105 |
+
var PreConsumedQuota = 500
|
| 106 |
+
|
| 107 |
+
var RetryTimes = 0
|
| 108 |
+
|
| 109 |
+
//var RootUserEmail = ""
|
| 110 |
+
|
| 111 |
+
var IsMasterNode bool
|
| 112 |
+
|
| 113 |
+
var requestInterval int
|
| 114 |
+
var RequestInterval time.Duration
|
| 115 |
+
|
| 116 |
+
var SyncFrequency int // unit is second
|
| 117 |
+
|
| 118 |
+
var BatchUpdateEnabled = false
|
| 119 |
+
var BatchUpdateInterval int
|
| 120 |
+
|
| 121 |
+
var RelayTimeout int // unit is second
|
| 122 |
+
|
| 123 |
+
var GeminiSafetySetting string
|
| 124 |
+
|
| 125 |
+
// https://docs.cohere.com/docs/safety-modes Type; NONE/CONTEXTUAL/STRICT
|
| 126 |
+
var CohereSafetySetting string
|
| 127 |
+
|
| 128 |
+
const (
|
| 129 |
+
RequestIdKey = "X-Oneapi-Request-Id"
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
const (
|
| 133 |
+
RoleGuestUser = 0
|
| 134 |
+
RoleCommonUser = 1
|
| 135 |
+
RoleAdminUser = 10
|
| 136 |
+
RoleRootUser = 100
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
func IsValidateRole(role int) bool {
|
| 140 |
+
return role == RoleGuestUser || role == RoleCommonUser || role == RoleAdminUser || role == RoleRootUser
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
var (
|
| 144 |
+
FileUploadPermission = RoleGuestUser
|
| 145 |
+
FileDownloadPermission = RoleGuestUser
|
| 146 |
+
ImageUploadPermission = RoleGuestUser
|
| 147 |
+
ImageDownloadPermission = RoleGuestUser
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
// All duration's unit is seconds
|
| 151 |
+
// Shouldn't larger then RateLimitKeyExpirationDuration
|
| 152 |
+
var (
|
| 153 |
+
GlobalApiRateLimitEnable bool
|
| 154 |
+
GlobalApiRateLimitNum int
|
| 155 |
+
GlobalApiRateLimitDuration int64
|
| 156 |
+
|
| 157 |
+
GlobalWebRateLimitEnable bool
|
| 158 |
+
GlobalWebRateLimitNum int
|
| 159 |
+
GlobalWebRateLimitDuration int64
|
| 160 |
+
|
| 161 |
+
UploadRateLimitNum = 10
|
| 162 |
+
UploadRateLimitDuration int64 = 60
|
| 163 |
+
|
| 164 |
+
DownloadRateLimitNum = 10
|
| 165 |
+
DownloadRateLimitDuration int64 = 60
|
| 166 |
+
|
| 167 |
+
CriticalRateLimitNum = 20
|
| 168 |
+
CriticalRateLimitDuration int64 = 20 * 60
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
var RateLimitKeyExpirationDuration = 20 * time.Minute
|
| 172 |
+
|
| 173 |
+
const (
|
| 174 |
+
UserStatusEnabled = 1 // don't use 0, 0 is the default value!
|
| 175 |
+
UserStatusDisabled = 2 // also don't use 0
|
| 176 |
+
)
|
| 177 |
+
|
| 178 |
+
const (
|
| 179 |
+
TokenStatusEnabled = 1 // don't use 0, 0 is the default value!
|
| 180 |
+
TokenStatusDisabled = 2 // also don't use 0
|
| 181 |
+
TokenStatusExpired = 3
|
| 182 |
+
TokenStatusExhausted = 4
|
| 183 |
+
)
|
| 184 |
+
|
| 185 |
+
const (
|
| 186 |
+
RedemptionCodeStatusEnabled = 1 // don't use 0, 0 is the default value!
|
| 187 |
+
RedemptionCodeStatusDisabled = 2 // also don't use 0
|
| 188 |
+
RedemptionCodeStatusUsed = 3 // also don't use 0
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
+
const (
|
| 192 |
+
ChannelStatusUnknown = 0
|
| 193 |
+
ChannelStatusEnabled = 1 // don't use 0, 0 is the default value!
|
| 194 |
+
ChannelStatusManuallyDisabled = 2 // also don't use 0
|
| 195 |
+
ChannelStatusAutoDisabled = 3
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
const (
|
| 199 |
+
TopUpStatusPending = "pending"
|
| 200 |
+
TopUpStatusSuccess = "success"
|
| 201 |
+
TopUpStatusExpired = "expired"
|
| 202 |
+
)
|
common/copy.go
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package common
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"fmt"
|
| 5 |
+
|
| 6 |
+
"github.com/jinzhu/copier"
|
| 7 |
+
)
|
| 8 |
+
|
| 9 |
+
func DeepCopy[T any](src *T) (*T, error) {
|
| 10 |
+
if src == nil {
|
| 11 |
+
return nil, fmt.Errorf("copy source cannot be nil")
|
| 12 |
+
}
|
| 13 |
+
var dst T
|
| 14 |
+
err := copier.CopyWithOption(&dst, src, copier.Option{DeepCopy: true, IgnoreEmpty: true})
|
| 15 |
+
if err != nil {
|
| 16 |
+
return nil, err
|
| 17 |
+
}
|
| 18 |
+
return &dst, nil
|
| 19 |
+
}
|
common/crypto.go
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package common
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"crypto/hmac"
|
| 5 |
+
"crypto/sha256"
|
| 6 |
+
"encoding/hex"
|
| 7 |
+
"golang.org/x/crypto/bcrypt"
|
| 8 |
+
)
|
| 9 |
+
|
| 10 |
+
func GenerateHMACWithKey(key []byte, data string) string {
|
| 11 |
+
h := hmac.New(sha256.New, key)
|
| 12 |
+
h.Write([]byte(data))
|
| 13 |
+
return hex.EncodeToString(h.Sum(nil))
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
func GenerateHMAC(data string) string {
|
| 17 |
+
h := hmac.New(sha256.New, []byte(CryptoSecret))
|
| 18 |
+
h.Write([]byte(data))
|
| 19 |
+
return hex.EncodeToString(h.Sum(nil))
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
func Password2Hash(password string) (string, error) {
|
| 23 |
+
passwordBytes := []byte(password)
|
| 24 |
+
hashedPassword, err := bcrypt.GenerateFromPassword(passwordBytes, bcrypt.DefaultCost)
|
| 25 |
+
return string(hashedPassword), err
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
func ValidatePasswordAndHash(password string, hash string) bool {
|
| 29 |
+
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
| 30 |
+
return err == nil
|
| 31 |
+
}
|
common/custom-event.go
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Copyright 2014 Manu Martinez-Almeida. All rights reserved.
|
| 2 |
+
// Use of this source code is governed by a MIT style
|
| 3 |
+
// license that can be found in the LICENSE file.
|
| 4 |
+
|
| 5 |
+
package common
|
| 6 |
+
|
| 7 |
+
import (
|
| 8 |
+
"fmt"
|
| 9 |
+
"io"
|
| 10 |
+
"net/http"
|
| 11 |
+
"strings"
|
| 12 |
+
"sync"
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
type stringWriter interface {
|
| 16 |
+
io.Writer
|
| 17 |
+
writeString(string) (int, error)
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
type stringWrapper struct {
|
| 21 |
+
io.Writer
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
func (w stringWrapper) writeString(str string) (int, error) {
|
| 25 |
+
return w.Writer.Write([]byte(str))
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
func checkWriter(writer io.Writer) stringWriter {
|
| 29 |
+
if w, ok := writer.(stringWriter); ok {
|
| 30 |
+
return w
|
| 31 |
+
} else {
|
| 32 |
+
return stringWrapper{writer}
|
| 33 |
+
}
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
// Server-Sent Events
|
| 37 |
+
// W3C Working Draft 29 October 2009
|
| 38 |
+
// http://www.w3.org/TR/2009/WD-eventsource-20091029/
|
| 39 |
+
|
| 40 |
+
var contentType = []string{"text/event-stream"}
|
| 41 |
+
var noCache = []string{"no-cache"}
|
| 42 |
+
|
| 43 |
+
var fieldReplacer = strings.NewReplacer(
|
| 44 |
+
"\n", "\\n",
|
| 45 |
+
"\r", "\\r")
|
| 46 |
+
|
| 47 |
+
var dataReplacer = strings.NewReplacer(
|
| 48 |
+
"\n", "\n",
|
| 49 |
+
"\r", "\\r")
|
| 50 |
+
|
| 51 |
+
type CustomEvent struct {
|
| 52 |
+
Event string
|
| 53 |
+
Id string
|
| 54 |
+
Retry uint
|
| 55 |
+
Data interface{}
|
| 56 |
+
|
| 57 |
+
Mutex sync.Mutex
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
func encode(writer io.Writer, event CustomEvent) error {
|
| 61 |
+
w := checkWriter(writer)
|
| 62 |
+
return writeData(w, event.Data)
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
func writeData(w stringWriter, data interface{}) error {
|
| 66 |
+
dataReplacer.WriteString(w, fmt.Sprint(data))
|
| 67 |
+
if strings.HasPrefix(data.(string), "data") {
|
| 68 |
+
w.writeString("\n\n")
|
| 69 |
+
}
|
| 70 |
+
return nil
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
func (r CustomEvent) Render(w http.ResponseWriter) error {
|
| 74 |
+
r.WriteContentType(w)
|
| 75 |
+
return encode(w, r)
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
func (r CustomEvent) WriteContentType(w http.ResponseWriter) {
|
| 79 |
+
r.Mutex.Lock()
|
| 80 |
+
defer r.Mutex.Unlock()
|
| 81 |
+
header := w.Header()
|
| 82 |
+
header["Content-Type"] = contentType
|
| 83 |
+
|
| 84 |
+
if _, exist := header["Cache-Control"]; !exist {
|
| 85 |
+
header["Cache-Control"] = noCache
|
| 86 |
+
}
|
| 87 |
+
}
|
common/database.go
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package common
|
| 2 |
+
|
| 3 |
+
const (
|
| 4 |
+
DatabaseTypeMySQL = "mysql"
|
| 5 |
+
DatabaseTypeSQLite = "sqlite"
|
| 6 |
+
DatabaseTypePostgreSQL = "postgres"
|
| 7 |
+
)
|
| 8 |
+
|
| 9 |
+
var UsingSQLite = false
|
| 10 |
+
var UsingPostgreSQL = false
|
| 11 |
+
var LogSqlType = DatabaseTypeSQLite // Default to SQLite for logging SQL queries
|
| 12 |
+
var UsingMySQL = false
|
| 13 |
+
var UsingClickHouse = false
|
| 14 |
+
|
| 15 |
+
var SQLitePath = "one-api.db?_busy_timeout=30000"
|
common/email-outlook-auth.go
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package common
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"errors"
|
| 5 |
+
"net/smtp"
|
| 6 |
+
"strings"
|
| 7 |
+
)
|
| 8 |
+
|
| 9 |
+
type outlookAuth struct {
|
| 10 |
+
username, password string
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
func LoginAuth(username, password string) smtp.Auth {
|
| 14 |
+
return &outlookAuth{username, password}
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
func (a *outlookAuth) Start(_ *smtp.ServerInfo) (string, []byte, error) {
|
| 18 |
+
return "LOGIN", []byte{}, nil
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
func (a *outlookAuth) Next(fromServer []byte, more bool) ([]byte, error) {
|
| 22 |
+
if more {
|
| 23 |
+
switch string(fromServer) {
|
| 24 |
+
case "Username:":
|
| 25 |
+
return []byte(a.username), nil
|
| 26 |
+
case "Password:":
|
| 27 |
+
return []byte(a.password), nil
|
| 28 |
+
default:
|
| 29 |
+
return nil, errors.New("unknown fromServer")
|
| 30 |
+
}
|
| 31 |
+
}
|
| 32 |
+
return nil, nil
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
func isOutlookServer(server string) bool {
|
| 36 |
+
// 兼容多地区的outlook邮箱和ofb邮箱
|
| 37 |
+
// 其实应该加一个Option来区分是否用LOGIN的方式登录
|
| 38 |
+
// 先临时兼容一下
|
| 39 |
+
return strings.Contains(server, "outlook") || strings.Contains(server, "onmicrosoft")
|
| 40 |
+
}
|
common/email.go
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package common
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"crypto/tls"
|
| 5 |
+
"encoding/base64"
|
| 6 |
+
"fmt"
|
| 7 |
+
"net/smtp"
|
| 8 |
+
"slices"
|
| 9 |
+
"strings"
|
| 10 |
+
"time"
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
func generateMessageID() (string, error) {
|
| 14 |
+
split := strings.Split(SMTPFrom, "@")
|
| 15 |
+
if len(split) < 2 {
|
| 16 |
+
return "", fmt.Errorf("invalid SMTP account")
|
| 17 |
+
}
|
| 18 |
+
domain := strings.Split(SMTPFrom, "@")[1]
|
| 19 |
+
return fmt.Sprintf("<%d.%s@%s>", time.Now().UnixNano(), GetRandomString(12), domain), nil
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
func SendEmail(subject string, receiver string, content string) error {
|
| 23 |
+
if SMTPFrom == "" { // for compatibility
|
| 24 |
+
SMTPFrom = SMTPAccount
|
| 25 |
+
}
|
| 26 |
+
id, err2 := generateMessageID()
|
| 27 |
+
if err2 != nil {
|
| 28 |
+
return err2
|
| 29 |
+
}
|
| 30 |
+
if SMTPServer == "" && SMTPAccount == "" {
|
| 31 |
+
return fmt.Errorf("SMTP 服务器未配置")
|
| 32 |
+
}
|
| 33 |
+
encodedSubject := fmt.Sprintf("=?UTF-8?B?%s?=", base64.StdEncoding.EncodeToString([]byte(subject)))
|
| 34 |
+
mail := []byte(fmt.Sprintf("To: %s\r\n"+
|
| 35 |
+
"From: %s<%s>\r\n"+
|
| 36 |
+
"Subject: %s\r\n"+
|
| 37 |
+
"Date: %s\r\n"+
|
| 38 |
+
"Message-ID: %s\r\n"+ // 添加 Message-ID 头
|
| 39 |
+
"Content-Type: text/html; charset=UTF-8\r\n\r\n%s\r\n",
|
| 40 |
+
receiver, SystemName, SMTPFrom, encodedSubject, time.Now().Format(time.RFC1123Z), id, content))
|
| 41 |
+
auth := smtp.PlainAuth("", SMTPAccount, SMTPToken, SMTPServer)
|
| 42 |
+
addr := fmt.Sprintf("%s:%d", SMTPServer, SMTPPort)
|
| 43 |
+
to := strings.Split(receiver, ";")
|
| 44 |
+
var err error
|
| 45 |
+
if SMTPPort == 465 || SMTPSSLEnabled {
|
| 46 |
+
tlsConfig := &tls.Config{
|
| 47 |
+
InsecureSkipVerify: true,
|
| 48 |
+
ServerName: SMTPServer,
|
| 49 |
+
}
|
| 50 |
+
conn, err := tls.Dial("tcp", fmt.Sprintf("%s:%d", SMTPServer, SMTPPort), tlsConfig)
|
| 51 |
+
if err != nil {
|
| 52 |
+
return err
|
| 53 |
+
}
|
| 54 |
+
client, err := smtp.NewClient(conn, SMTPServer)
|
| 55 |
+
if err != nil {
|
| 56 |
+
return err
|
| 57 |
+
}
|
| 58 |
+
defer client.Close()
|
| 59 |
+
if err = client.Auth(auth); err != nil {
|
| 60 |
+
return err
|
| 61 |
+
}
|
| 62 |
+
if err = client.Mail(SMTPFrom); err != nil {
|
| 63 |
+
return err
|
| 64 |
+
}
|
| 65 |
+
receiverEmails := strings.Split(receiver, ";")
|
| 66 |
+
for _, receiver := range receiverEmails {
|
| 67 |
+
if err = client.Rcpt(receiver); err != nil {
|
| 68 |
+
return err
|
| 69 |
+
}
|
| 70 |
+
}
|
| 71 |
+
w, err := client.Data()
|
| 72 |
+
if err != nil {
|
| 73 |
+
return err
|
| 74 |
+
}
|
| 75 |
+
_, err = w.Write(mail)
|
| 76 |
+
if err != nil {
|
| 77 |
+
return err
|
| 78 |
+
}
|
| 79 |
+
err = w.Close()
|
| 80 |
+
if err != nil {
|
| 81 |
+
return err
|
| 82 |
+
}
|
| 83 |
+
} else if isOutlookServer(SMTPAccount) || slices.Contains(EmailLoginAuthServerList, SMTPServer) {
|
| 84 |
+
auth = LoginAuth(SMTPAccount, SMTPToken)
|
| 85 |
+
err = smtp.SendMail(addr, auth, SMTPFrom, to, mail)
|
| 86 |
+
} else {
|
| 87 |
+
err = smtp.SendMail(addr, auth, SMTPFrom, to, mail)
|
| 88 |
+
}
|
| 89 |
+
return err
|
| 90 |
+
}
|
common/embed-file-system.go
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package common
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"embed"
|
| 5 |
+
"github.com/gin-contrib/static"
|
| 6 |
+
"io/fs"
|
| 7 |
+
"net/http"
|
| 8 |
+
)
|
| 9 |
+
|
| 10 |
+
// Credit: https://github.com/gin-contrib/static/issues/19
|
| 11 |
+
|
| 12 |
+
type embedFileSystem struct {
|
| 13 |
+
http.FileSystem
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
func (e embedFileSystem) Exists(prefix string, path string) bool {
|
| 17 |
+
_, err := e.Open(path)
|
| 18 |
+
if err != nil {
|
| 19 |
+
return false
|
| 20 |
+
}
|
| 21 |
+
return true
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
func EmbedFolder(fsEmbed embed.FS, targetPath string) static.ServeFileSystem {
|
| 25 |
+
efs, err := fs.Sub(fsEmbed, targetPath)
|
| 26 |
+
if err != nil {
|
| 27 |
+
panic(err)
|
| 28 |
+
}
|
| 29 |
+
return embedFileSystem{
|
| 30 |
+
FileSystem: http.FS(efs),
|
| 31 |
+
}
|
| 32 |
+
}
|
common/endpoint_defaults.go
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package common
|
| 2 |
+
|
| 3 |
+
import "one-api/constant"
|
| 4 |
+
|
| 5 |
+
// EndpointInfo 描述单个端点的默认请求信息
|
| 6 |
+
// path: 上游路径
|
| 7 |
+
// method: HTTP 请求方式,例如 POST/GET
|
| 8 |
+
// 目前均为 POST,后续可扩展
|
| 9 |
+
//
|
| 10 |
+
// json 标签用于直接序列化到 API 输出
|
| 11 |
+
// 例如:{"path":"/v1/chat/completions","method":"POST"}
|
| 12 |
+
|
| 13 |
+
type EndpointInfo struct {
|
| 14 |
+
Path string `json:"path"`
|
| 15 |
+
Method string `json:"method"`
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
// defaultEndpointInfoMap 保存内置端点的默认 Path 与 Method
|
| 19 |
+
var defaultEndpointInfoMap = map[constant.EndpointType]EndpointInfo{
|
| 20 |
+
constant.EndpointTypeOpenAI: {Path: "/v1/chat/completions", Method: "POST"},
|
| 21 |
+
constant.EndpointTypeOpenAIResponse: {Path: "/v1/responses", Method: "POST"},
|
| 22 |
+
constant.EndpointTypeAnthropic: {Path: "/v1/messages", Method: "POST"},
|
| 23 |
+
constant.EndpointTypeGemini: {Path: "/v1beta/models/{model}:generateContent", Method: "POST"},
|
| 24 |
+
constant.EndpointTypeJinaRerank: {Path: "/rerank", Method: "POST"},
|
| 25 |
+
constant.EndpointTypeImageGeneration: {Path: "/v1/images/generations", Method: "POST"},
|
| 26 |
+
constant.EndpointTypeEmbeddings: {Path: "/v1/embeddings", Method: "POST"},
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
// GetDefaultEndpointInfo 返回指定端点类型的默认信息以及是否存在
|
| 30 |
+
func GetDefaultEndpointInfo(et constant.EndpointType) (EndpointInfo, bool) {
|
| 31 |
+
info, ok := defaultEndpointInfoMap[et]
|
| 32 |
+
return info, ok
|
| 33 |
+
}
|
common/endpoint_type.go
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package common
|
| 2 |
+
|
| 3 |
+
import "one-api/constant"
|
| 4 |
+
|
| 5 |
+
// GetEndpointTypesByChannelType 获取渠道最优先端点类型(所有的渠道都支持 OpenAI 端点)
|
| 6 |
+
func GetEndpointTypesByChannelType(channelType int, modelName string) []constant.EndpointType {
|
| 7 |
+
var endpointTypes []constant.EndpointType
|
| 8 |
+
switch channelType {
|
| 9 |
+
case constant.ChannelTypeJina:
|
| 10 |
+
endpointTypes = []constant.EndpointType{constant.EndpointTypeJinaRerank}
|
| 11 |
+
//case constant.ChannelTypeMidjourney, constant.ChannelTypeMidjourneyPlus:
|
| 12 |
+
// endpointTypes = []constant.EndpointType{constant.EndpointTypeMidjourney}
|
| 13 |
+
//case constant.ChannelTypeSunoAPI:
|
| 14 |
+
// endpointTypes = []constant.EndpointType{constant.EndpointTypeSuno}
|
| 15 |
+
//case constant.ChannelTypeKling:
|
| 16 |
+
// endpointTypes = []constant.EndpointType{constant.EndpointTypeKling}
|
| 17 |
+
//case constant.ChannelTypeJimeng:
|
| 18 |
+
// endpointTypes = []constant.EndpointType{constant.EndpointTypeJimeng}
|
| 19 |
+
case constant.ChannelTypeAws:
|
| 20 |
+
fallthrough
|
| 21 |
+
case constant.ChannelTypeAnthropic:
|
| 22 |
+
endpointTypes = []constant.EndpointType{constant.EndpointTypeAnthropic, constant.EndpointTypeOpenAI}
|
| 23 |
+
case constant.ChannelTypeVertexAi:
|
| 24 |
+
fallthrough
|
| 25 |
+
case constant.ChannelTypeGemini:
|
| 26 |
+
endpointTypes = []constant.EndpointType{constant.EndpointTypeGemini, constant.EndpointTypeOpenAI}
|
| 27 |
+
case constant.ChannelTypeOpenRouter: // OpenRouter 只支持 OpenAI 端点
|
| 28 |
+
endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAI}
|
| 29 |
+
default:
|
| 30 |
+
if IsOpenAIResponseOnlyModel(modelName) {
|
| 31 |
+
endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAIResponse}
|
| 32 |
+
} else {
|
| 33 |
+
endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAI}
|
| 34 |
+
}
|
| 35 |
+
}
|
| 36 |
+
if IsImageGenerationModel(modelName) {
|
| 37 |
+
// add to first
|
| 38 |
+
endpointTypes = append([]constant.EndpointType{constant.EndpointTypeImageGeneration}, endpointTypes...)
|
| 39 |
+
}
|
| 40 |
+
return endpointTypes
|
| 41 |
+
}
|
common/env.go
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package common
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"fmt"
|
| 5 |
+
"os"
|
| 6 |
+
"strconv"
|
| 7 |
+
)
|
| 8 |
+
|
| 9 |
+
func GetEnvOrDefault(env string, defaultValue int) int {
|
| 10 |
+
if env == "" || os.Getenv(env) == "" {
|
| 11 |
+
return defaultValue
|
| 12 |
+
}
|
| 13 |
+
num, err := strconv.Atoi(os.Getenv(env))
|
| 14 |
+
if err != nil {
|
| 15 |
+
SysError(fmt.Sprintf("failed to parse %s: %s, using default value: %d", env, err.Error(), defaultValue))
|
| 16 |
+
return defaultValue
|
| 17 |
+
}
|
| 18 |
+
return num
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
func GetEnvOrDefaultString(env string, defaultValue string) string {
|
| 22 |
+
if env == "" || os.Getenv(env) == "" {
|
| 23 |
+
return defaultValue
|
| 24 |
+
}
|
| 25 |
+
return os.Getenv(env)
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
func GetEnvOrDefaultBool(env string, defaultValue bool) bool {
|
| 29 |
+
if env == "" || os.Getenv(env) == "" {
|
| 30 |
+
return defaultValue
|
| 31 |
+
}
|
| 32 |
+
b, err := strconv.ParseBool(os.Getenv(env))
|
| 33 |
+
if err != nil {
|
| 34 |
+
SysError(fmt.Sprintf("failed to parse %s: %s, using default value: %t", env, err.Error(), defaultValue))
|
| 35 |
+
return defaultValue
|
| 36 |
+
}
|
| 37 |
+
return b
|
| 38 |
+
}
|
common/gin.go
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package common
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"bytes"
|
| 5 |
+
"io"
|
| 6 |
+
"net/http"
|
| 7 |
+
"one-api/constant"
|
| 8 |
+
"strings"
|
| 9 |
+
"time"
|
| 10 |
+
|
| 11 |
+
"github.com/gin-gonic/gin"
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
const KeyRequestBody = "key_request_body"
|
| 15 |
+
|
| 16 |
+
func GetRequestBody(c *gin.Context) ([]byte, error) {
|
| 17 |
+
requestBody, _ := c.Get(KeyRequestBody)
|
| 18 |
+
if requestBody != nil {
|
| 19 |
+
return requestBody.([]byte), nil
|
| 20 |
+
}
|
| 21 |
+
requestBody, err := io.ReadAll(c.Request.Body)
|
| 22 |
+
if err != nil {
|
| 23 |
+
return nil, err
|
| 24 |
+
}
|
| 25 |
+
_ = c.Request.Body.Close()
|
| 26 |
+
c.Set(KeyRequestBody, requestBody)
|
| 27 |
+
return requestBody.([]byte), nil
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
func UnmarshalBodyReusable(c *gin.Context, v any) error {
|
| 31 |
+
requestBody, err := GetRequestBody(c)
|
| 32 |
+
if err != nil {
|
| 33 |
+
return err
|
| 34 |
+
}
|
| 35 |
+
//if DebugEnabled {
|
| 36 |
+
// println("UnmarshalBodyReusable request body:", string(requestBody))
|
| 37 |
+
//}
|
| 38 |
+
contentType := c.Request.Header.Get("Content-Type")
|
| 39 |
+
if strings.HasPrefix(contentType, "application/json") {
|
| 40 |
+
err = Unmarshal(requestBody, &v)
|
| 41 |
+
} else {
|
| 42 |
+
// skip for now
|
| 43 |
+
// TODO: someday non json request have variant model, we will need to implementation this
|
| 44 |
+
}
|
| 45 |
+
if err != nil {
|
| 46 |
+
return err
|
| 47 |
+
}
|
| 48 |
+
// Reset request body
|
| 49 |
+
c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody))
|
| 50 |
+
return nil
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
func SetContextKey(c *gin.Context, key constant.ContextKey, value any) {
|
| 54 |
+
c.Set(string(key), value)
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
func GetContextKey(c *gin.Context, key constant.ContextKey) (any, bool) {
|
| 58 |
+
return c.Get(string(key))
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
func GetContextKeyString(c *gin.Context, key constant.ContextKey) string {
|
| 62 |
+
return c.GetString(string(key))
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
func GetContextKeyInt(c *gin.Context, key constant.ContextKey) int {
|
| 66 |
+
return c.GetInt(string(key))
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
func GetContextKeyBool(c *gin.Context, key constant.ContextKey) bool {
|
| 70 |
+
return c.GetBool(string(key))
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
func GetContextKeyStringSlice(c *gin.Context, key constant.ContextKey) []string {
|
| 74 |
+
return c.GetStringSlice(string(key))
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
func GetContextKeyStringMap(c *gin.Context, key constant.ContextKey) map[string]any {
|
| 78 |
+
return c.GetStringMap(string(key))
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
func GetContextKeyTime(c *gin.Context, key constant.ContextKey) time.Time {
|
| 82 |
+
return c.GetTime(string(key))
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
func GetContextKeyType[T any](c *gin.Context, key constant.ContextKey) (T, bool) {
|
| 86 |
+
if value, ok := c.Get(string(key)); ok {
|
| 87 |
+
if v, ok := value.(T); ok {
|
| 88 |
+
return v, true
|
| 89 |
+
}
|
| 90 |
+
}
|
| 91 |
+
var t T
|
| 92 |
+
return t, false
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
func ApiError(c *gin.Context, err error) {
|
| 96 |
+
c.JSON(http.StatusOK, gin.H{
|
| 97 |
+
"success": false,
|
| 98 |
+
"message": err.Error(),
|
| 99 |
+
})
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
func ApiErrorMsg(c *gin.Context, msg string) {
|
| 103 |
+
c.JSON(http.StatusOK, gin.H{
|
| 104 |
+
"success": false,
|
| 105 |
+
"message": msg,
|
| 106 |
+
})
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
func ApiSuccess(c *gin.Context, data any) {
|
| 110 |
+
c.JSON(http.StatusOK, gin.H{
|
| 111 |
+
"success": true,
|
| 112 |
+
"message": "",
|
| 113 |
+
"data": data,
|
| 114 |
+
})
|
| 115 |
+
}
|
common/go-channel.go
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package common
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"time"
|
| 5 |
+
)
|
| 6 |
+
|
| 7 |
+
func SafeSendBool(ch chan bool, value bool) (closed bool) {
|
| 8 |
+
defer func() {
|
| 9 |
+
// Recover from panic if one occured. A panic would mean the channel was closed.
|
| 10 |
+
if recover() != nil {
|
| 11 |
+
closed = true
|
| 12 |
+
}
|
| 13 |
+
}()
|
| 14 |
+
|
| 15 |
+
// This will panic if the channel is closed.
|
| 16 |
+
ch <- value
|
| 17 |
+
|
| 18 |
+
// If the code reaches here, then the channel was not closed.
|
| 19 |
+
return false
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
func SafeSendString(ch chan string, value string) (closed bool) {
|
| 23 |
+
defer func() {
|
| 24 |
+
// Recover from panic if one occured. A panic would mean the channel was closed.
|
| 25 |
+
if recover() != nil {
|
| 26 |
+
closed = true
|
| 27 |
+
}
|
| 28 |
+
}()
|
| 29 |
+
|
| 30 |
+
// This will panic if the channel is closed.
|
| 31 |
+
ch <- value
|
| 32 |
+
|
| 33 |
+
// If the code reaches here, then the channel was not closed.
|
| 34 |
+
return false
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
// SafeSendStringTimeout send, return true, else return false
|
| 38 |
+
func SafeSendStringTimeout(ch chan string, value string, timeout int) (closed bool) {
|
| 39 |
+
defer func() {
|
| 40 |
+
// Recover from panic if one occured. A panic would mean the channel was closed.
|
| 41 |
+
if recover() != nil {
|
| 42 |
+
closed = false
|
| 43 |
+
}
|
| 44 |
+
}()
|
| 45 |
+
|
| 46 |
+
// This will panic if the channel is closed.
|
| 47 |
+
select {
|
| 48 |
+
case ch <- value:
|
| 49 |
+
return true
|
| 50 |
+
case <-time.After(time.Duration(timeout) * time.Second):
|
| 51 |
+
return false
|
| 52 |
+
}
|
| 53 |
+
}
|
common/gopool.go
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package common
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
"fmt"
|
| 6 |
+
"github.com/bytedance/gopkg/util/gopool"
|
| 7 |
+
"math"
|
| 8 |
+
)
|
| 9 |
+
|
| 10 |
+
var relayGoPool gopool.Pool
|
| 11 |
+
|
| 12 |
+
func init() {
|
| 13 |
+
relayGoPool = gopool.NewPool("gopool.RelayPool", math.MaxInt32, gopool.NewConfig())
|
| 14 |
+
relayGoPool.SetPanicHandler(func(ctx context.Context, i interface{}) {
|
| 15 |
+
if stopChan, ok := ctx.Value("stop_chan").(chan bool); ok {
|
| 16 |
+
SafeSendBool(stopChan, true)
|
| 17 |
+
}
|
| 18 |
+
SysError(fmt.Sprintf("panic in gopool.RelayPool: %v", i))
|
| 19 |
+
})
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
func RelayCtxGo(ctx context.Context, f func()) {
|
| 23 |
+
relayGoPool.CtxGo(ctx, f)
|
| 24 |
+
}
|
common/hash.go
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package common
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"crypto/hmac"
|
| 5 |
+
"crypto/sha1"
|
| 6 |
+
"crypto/sha256"
|
| 7 |
+
"encoding/hex"
|
| 8 |
+
)
|
| 9 |
+
|
| 10 |
+
func Sha256Raw(data []byte) []byte {
|
| 11 |
+
h := sha256.New()
|
| 12 |
+
h.Write(data)
|
| 13 |
+
return h.Sum(nil)
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
func Sha1Raw(data []byte) []byte {
|
| 17 |
+
h := sha1.New()
|
| 18 |
+
h.Write(data)
|
| 19 |
+
return h.Sum(nil)
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
func Sha1(data []byte) string {
|
| 23 |
+
return hex.EncodeToString(Sha1Raw(data))
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
func HmacSha256Raw(message, key []byte) []byte {
|
| 27 |
+
h := hmac.New(sha256.New, key)
|
| 28 |
+
h.Write(message)
|
| 29 |
+
return h.Sum(nil)
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
func HmacSha256(message, key string) string {
|
| 33 |
+
return hex.EncodeToString(HmacSha256Raw([]byte(message), []byte(key)))
|
| 34 |
+
}
|
common/init.go
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package common
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"flag"
|
| 5 |
+
"fmt"
|
| 6 |
+
"log"
|
| 7 |
+
"one-api/constant"
|
| 8 |
+
"os"
|
| 9 |
+
"path/filepath"
|
| 10 |
+
"strconv"
|
| 11 |
+
"time"
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
var (
|
| 15 |
+
Port = flag.Int("port", 3000, "the listening port")
|
| 16 |
+
PrintVersion = flag.Bool("version", false, "print version and exit")
|
| 17 |
+
PrintHelp = flag.Bool("help", false, "print help and exit")
|
| 18 |
+
LogDir = flag.String("log-dir", "./logs", "specify the log directory")
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
func printHelp() {
|
| 22 |
+
fmt.Println("New API " + Version + " - All in one API service for OpenAI API.")
|
| 23 |
+
fmt.Println("Copyright (C) 2023 JustSong. All rights reserved.")
|
| 24 |
+
fmt.Println("GitHub: https://github.com/songquanpeng/one-api")
|
| 25 |
+
fmt.Println("Usage: one-api [--port <port>] [--log-dir <log directory>] [--version] [--help]")
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
func InitEnv() {
|
| 29 |
+
flag.Parse()
|
| 30 |
+
|
| 31 |
+
if *PrintVersion {
|
| 32 |
+
fmt.Println(Version)
|
| 33 |
+
os.Exit(0)
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
if *PrintHelp {
|
| 37 |
+
printHelp()
|
| 38 |
+
os.Exit(0)
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
if os.Getenv("SESSION_SECRET") != "" {
|
| 42 |
+
ss := os.Getenv("SESSION_SECRET")
|
| 43 |
+
if ss == "random_string" {
|
| 44 |
+
log.Println("WARNING: SESSION_SECRET is set to the default value 'random_string', please change it to a random string.")
|
| 45 |
+
log.Println("警告:SESSION_SECRET被设置为默认值'random_string',请修改为随机字符串。")
|
| 46 |
+
log.Fatal("Please set SESSION_SECRET to a random string.")
|
| 47 |
+
} else {
|
| 48 |
+
SessionSecret = ss
|
| 49 |
+
}
|
| 50 |
+
}
|
| 51 |
+
if os.Getenv("CRYPTO_SECRET") != "" {
|
| 52 |
+
CryptoSecret = os.Getenv("CRYPTO_SECRET")
|
| 53 |
+
} else {
|
| 54 |
+
CryptoSecret = SessionSecret
|
| 55 |
+
}
|
| 56 |
+
if os.Getenv("SQLITE_PATH") != "" {
|
| 57 |
+
SQLitePath = os.Getenv("SQLITE_PATH")
|
| 58 |
+
}
|
| 59 |
+
if *LogDir != "" {
|
| 60 |
+
var err error
|
| 61 |
+
*LogDir, err = filepath.Abs(*LogDir)
|
| 62 |
+
if err != nil {
|
| 63 |
+
log.Fatal(err)
|
| 64 |
+
}
|
| 65 |
+
if _, err := os.Stat(*LogDir); os.IsNotExist(err) {
|
| 66 |
+
err = os.Mkdir(*LogDir, 0777)
|
| 67 |
+
if err != nil {
|
| 68 |
+
log.Fatal(err)
|
| 69 |
+
}
|
| 70 |
+
}
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
// Initialize variables from constants.go that were using environment variables
|
| 74 |
+
DebugEnabled = os.Getenv("DEBUG") == "true"
|
| 75 |
+
MemoryCacheEnabled = os.Getenv("MEMORY_CACHE_ENABLED") == "true"
|
| 76 |
+
IsMasterNode = os.Getenv("NODE_TYPE") != "slave"
|
| 77 |
+
|
| 78 |
+
// Parse requestInterval and set RequestInterval
|
| 79 |
+
requestInterval, _ = strconv.Atoi(os.Getenv("POLLING_INTERVAL"))
|
| 80 |
+
RequestInterval = time.Duration(requestInterval) * time.Second
|
| 81 |
+
|
| 82 |
+
// Initialize variables with GetEnvOrDefault
|
| 83 |
+
SyncFrequency = GetEnvOrDefault("SYNC_FREQUENCY", 60)
|
| 84 |
+
BatchUpdateInterval = GetEnvOrDefault("BATCH_UPDATE_INTERVAL", 5)
|
| 85 |
+
RelayTimeout = GetEnvOrDefault("RELAY_TIMEOUT", 0)
|
| 86 |
+
|
| 87 |
+
// Initialize string variables with GetEnvOrDefaultString
|
| 88 |
+
GeminiSafetySetting = GetEnvOrDefaultString("GEMINI_SAFETY_SETTING", "BLOCK_NONE")
|
| 89 |
+
CohereSafetySetting = GetEnvOrDefaultString("COHERE_SAFETY_SETTING", "NONE")
|
| 90 |
+
|
| 91 |
+
// Initialize rate limit variables
|
| 92 |
+
GlobalApiRateLimitEnable = GetEnvOrDefaultBool("GLOBAL_API_RATE_LIMIT_ENABLE", true)
|
| 93 |
+
GlobalApiRateLimitNum = GetEnvOrDefault("GLOBAL_API_RATE_LIMIT", 180)
|
| 94 |
+
GlobalApiRateLimitDuration = int64(GetEnvOrDefault("GLOBAL_API_RATE_LIMIT_DURATION", 180))
|
| 95 |
+
|
| 96 |
+
GlobalWebRateLimitEnable = GetEnvOrDefaultBool("GLOBAL_WEB_RATE_LIMIT_ENABLE", true)
|
| 97 |
+
GlobalWebRateLimitNum = GetEnvOrDefault("GLOBAL_WEB_RATE_LIMIT", 60)
|
| 98 |
+
GlobalWebRateLimitDuration = int64(GetEnvOrDefault("GLOBAL_WEB_RATE_LIMIT_DURATION", 180))
|
| 99 |
+
|
| 100 |
+
initConstantEnv()
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
func initConstantEnv() {
|
| 104 |
+
constant.StreamingTimeout = GetEnvOrDefault("STREAMING_TIMEOUT", 300)
|
| 105 |
+
constant.DifyDebug = GetEnvOrDefaultBool("DIFY_DEBUG", true)
|
| 106 |
+
constant.MaxFileDownloadMB = GetEnvOrDefault("MAX_FILE_DOWNLOAD_MB", 20)
|
| 107 |
+
// ForceStreamOption 覆盖请求参数,强制返回usage信息
|
| 108 |
+
constant.ForceStreamOption = GetEnvOrDefaultBool("FORCE_STREAM_OPTION", true)
|
| 109 |
+
constant.GetMediaToken = GetEnvOrDefaultBool("GET_MEDIA_TOKEN", true)
|
| 110 |
+
constant.GetMediaTokenNotStream = GetEnvOrDefaultBool("GET_MEDIA_TOKEN_NOT_STREAM", true)
|
| 111 |
+
constant.UpdateTask = GetEnvOrDefaultBool("UPDATE_TASK", true)
|
| 112 |
+
constant.AzureDefaultAPIVersion = GetEnvOrDefaultString("AZURE_DEFAULT_API_VERSION", "2025-04-01-preview")
|
| 113 |
+
constant.GeminiVisionMaxImageNum = GetEnvOrDefault("GEMINI_VISION_MAX_IMAGE_NUM", 16)
|
| 114 |
+
constant.NotifyLimitCount = GetEnvOrDefault("NOTIFY_LIMIT_COUNT", 2)
|
| 115 |
+
constant.NotificationLimitDurationMinute = GetEnvOrDefault("NOTIFICATION_LIMIT_DURATION_MINUTE", 10)
|
| 116 |
+
// GenerateDefaultToken 是否生成初始令牌,默认关闭。
|
| 117 |
+
constant.GenerateDefaultToken = GetEnvOrDefaultBool("GENERATE_DEFAULT_TOKEN", false)
|
| 118 |
+
// 是否启用错误日志
|
| 119 |
+
constant.ErrorLogEnabled = GetEnvOrDefaultBool("ERROR_LOG_ENABLED", false)
|
| 120 |
+
}
|
common/ip.go
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package common
|
| 2 |
+
|
| 3 |
+
import "net"
|
| 4 |
+
|
| 5 |
+
func IsPrivateIP(ip net.IP) bool {
|
| 6 |
+
if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
|
| 7 |
+
return true
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
private := []net.IPNet{
|
| 11 |
+
{IP: net.IPv4(10, 0, 0, 0), Mask: net.CIDRMask(8, 32)},
|
| 12 |
+
{IP: net.IPv4(172, 16, 0, 0), Mask: net.CIDRMask(12, 32)},
|
| 13 |
+
{IP: net.IPv4(192, 168, 0, 0), Mask: net.CIDRMask(16, 32)},
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
for _, privateNet := range private {
|
| 17 |
+
if privateNet.Contains(ip) {
|
| 18 |
+
return true
|
| 19 |
+
}
|
| 20 |
+
}
|
| 21 |
+
return false
|
| 22 |
+
}
|
common/json.go
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package common
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"bytes"
|
| 5 |
+
"encoding/json"
|
| 6 |
+
)
|
| 7 |
+
|
| 8 |
+
func Unmarshal(data []byte, v any) error {
|
| 9 |
+
return json.Unmarshal(data, v)
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
func UnmarshalJsonStr(data string, v any) error {
|
| 13 |
+
return json.Unmarshal(StringToByteSlice(data), v)
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
func DecodeJson(reader *bytes.Reader, v any) error {
|
| 17 |
+
return json.NewDecoder(reader).Decode(v)
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
func Marshal(v any) ([]byte, error) {
|
| 21 |
+
return json.Marshal(v)
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
func GetJsonType(data json.RawMessage) string {
|
| 25 |
+
data = bytes.TrimSpace(data)
|
| 26 |
+
if len(data) == 0 {
|
| 27 |
+
return "unknown"
|
| 28 |
+
}
|
| 29 |
+
firstChar := bytes.TrimSpace(data)[0]
|
| 30 |
+
switch firstChar {
|
| 31 |
+
case '{':
|
| 32 |
+
return "object"
|
| 33 |
+
case '[':
|
| 34 |
+
return "array"
|
| 35 |
+
case '"':
|
| 36 |
+
return "string"
|
| 37 |
+
case 't', 'f':
|
| 38 |
+
return "boolean"
|
| 39 |
+
case 'n':
|
| 40 |
+
return "null"
|
| 41 |
+
default:
|
| 42 |
+
return "number"
|
| 43 |
+
}
|
| 44 |
+
}
|
common/limiter/limiter.go
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package limiter
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
_ "embed"
|
| 6 |
+
"fmt"
|
| 7 |
+
"github.com/go-redis/redis/v8"
|
| 8 |
+
"one-api/common"
|
| 9 |
+
"sync"
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
//go:embed lua/rate_limit.lua
|
| 13 |
+
var rateLimitScript string
|
| 14 |
+
|
| 15 |
+
type RedisLimiter struct {
|
| 16 |
+
client *redis.Client
|
| 17 |
+
limitScriptSHA string
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
var (
|
| 21 |
+
instance *RedisLimiter
|
| 22 |
+
once sync.Once
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
func New(ctx context.Context, r *redis.Client) *RedisLimiter {
|
| 26 |
+
once.Do(func() {
|
| 27 |
+
// 预加载脚本
|
| 28 |
+
limitSHA, err := r.ScriptLoad(ctx, rateLimitScript).Result()
|
| 29 |
+
if err != nil {
|
| 30 |
+
common.SysLog(fmt.Sprintf("Failed to load rate limit script: %v", err))
|
| 31 |
+
}
|
| 32 |
+
instance = &RedisLimiter{
|
| 33 |
+
client: r,
|
| 34 |
+
limitScriptSHA: limitSHA,
|
| 35 |
+
}
|
| 36 |
+
})
|
| 37 |
+
|
| 38 |
+
return instance
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
func (rl *RedisLimiter) Allow(ctx context.Context, key string, opts ...Option) (bool, error) {
|
| 42 |
+
// 默认配置
|
| 43 |
+
config := &Config{
|
| 44 |
+
Capacity: 10,
|
| 45 |
+
Rate: 1,
|
| 46 |
+
Requested: 1,
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
// 应用选项模式
|
| 50 |
+
for _, opt := range opts {
|
| 51 |
+
opt(config)
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
// 执行限流
|
| 55 |
+
result, err := rl.client.EvalSha(
|
| 56 |
+
ctx,
|
| 57 |
+
rl.limitScriptSHA,
|
| 58 |
+
[]string{key},
|
| 59 |
+
config.Requested,
|
| 60 |
+
config.Rate,
|
| 61 |
+
config.Capacity,
|
| 62 |
+
).Int()
|
| 63 |
+
|
| 64 |
+
if err != nil {
|
| 65 |
+
return false, fmt.Errorf("rate limit failed: %w", err)
|
| 66 |
+
}
|
| 67 |
+
return result == 1, nil
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
// Config 配置选项模式
|
| 71 |
+
type Config struct {
|
| 72 |
+
Capacity int64
|
| 73 |
+
Rate int64
|
| 74 |
+
Requested int64
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
type Option func(*Config)
|
| 78 |
+
|
| 79 |
+
func WithCapacity(c int64) Option {
|
| 80 |
+
return func(cfg *Config) { cfg.Capacity = c }
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
func WithRate(r int64) Option {
|
| 84 |
+
return func(cfg *Config) { cfg.Rate = r }
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
func WithRequested(n int64) Option {
|
| 88 |
+
return func(cfg *Config) { cfg.Requested = n }
|
| 89 |
+
}
|
common/limiter/lua/rate_limit.lua
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- 令牌桶限流器
|
| 2 |
+
-- KEYS[1]: 限流器唯一标识
|
| 3 |
+
-- ARGV[1]: 请求令牌数 (通常为1)
|
| 4 |
+
-- ARGV[2]: 令牌生成速率 (每秒)
|
| 5 |
+
-- ARGV[3]: 桶容量
|
| 6 |
+
|
| 7 |
+
local key = KEYS[1]
|
| 8 |
+
local requested = tonumber(ARGV[1])
|
| 9 |
+
local rate = tonumber(ARGV[2])
|
| 10 |
+
local capacity = tonumber(ARGV[3])
|
| 11 |
+
|
| 12 |
+
-- 获取当前时间(Redis服务器时间)
|
| 13 |
+
local now = redis.call('TIME')
|
| 14 |
+
local nowInSeconds = tonumber(now[1])
|
| 15 |
+
|
| 16 |
+
-- 获取桶状态
|
| 17 |
+
local bucket = redis.call('HMGET', key, 'tokens', 'last_time')
|
| 18 |
+
local tokens = tonumber(bucket[1])
|
| 19 |
+
local last_time = tonumber(bucket[2])
|
| 20 |
+
|
| 21 |
+
-- 初始化桶(首次请求或过期)
|
| 22 |
+
if not tokens or not last_time then
|
| 23 |
+
tokens = capacity
|
| 24 |
+
last_time = nowInSeconds
|
| 25 |
+
else
|
| 26 |
+
-- 计算新增令牌
|
| 27 |
+
local elapsed = nowInSeconds - last_time
|
| 28 |
+
local add_tokens = elapsed * rate
|
| 29 |
+
tokens = math.min(capacity, tokens + add_tokens)
|
| 30 |
+
last_time = nowInSeconds
|
| 31 |
+
end
|
| 32 |
+
|
| 33 |
+
-- 判断是否允许请求
|
| 34 |
+
local allowed = false
|
| 35 |
+
if tokens >= requested then
|
| 36 |
+
tokens = tokens - requested
|
| 37 |
+
allowed = true
|
| 38 |
+
end
|
| 39 |
+
|
| 40 |
+
---- 更新桶状态并设置过期时间
|
| 41 |
+
redis.call('HMSET', key, 'tokens', tokens, 'last_time', last_time)
|
| 42 |
+
--redis.call('EXPIRE', key, math.ceil(capacity / rate) + 60) -- 适当延长过期时间
|
| 43 |
+
|
| 44 |
+
return allowed and 1 or 0
|
common/model.go
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package common
|
| 2 |
+
|
| 3 |
+
import "strings"
|
| 4 |
+
|
| 5 |
+
var (
|
| 6 |
+
// OpenAIResponseOnlyModels is a list of models that are only available for OpenAI responses.
|
| 7 |
+
OpenAIResponseOnlyModels = []string{
|
| 8 |
+
"o3-pro",
|
| 9 |
+
"o3-deep-research",
|
| 10 |
+
"o4-mini-deep-research",
|
| 11 |
+
}
|
| 12 |
+
ImageGenerationModels = []string{
|
| 13 |
+
"dall-e-3",
|
| 14 |
+
"dall-e-2",
|
| 15 |
+
"gpt-image-1",
|
| 16 |
+
"prefix:imagen-",
|
| 17 |
+
"flux-",
|
| 18 |
+
"flux.1-",
|
| 19 |
+
}
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
func IsOpenAIResponseOnlyModel(modelName string) bool {
|
| 23 |
+
for _, m := range OpenAIResponseOnlyModels {
|
| 24 |
+
if strings.Contains(modelName, m) {
|
| 25 |
+
return true
|
| 26 |
+
}
|
| 27 |
+
}
|
| 28 |
+
return false
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
func IsImageGenerationModel(modelName string) bool {
|
| 32 |
+
modelName = strings.ToLower(modelName)
|
| 33 |
+
for _, m := range ImageGenerationModels {
|
| 34 |
+
if strings.Contains(modelName, m) {
|
| 35 |
+
return true
|
| 36 |
+
}
|
| 37 |
+
if strings.HasPrefix(m, "prefix:") && strings.HasPrefix(modelName, strings.TrimPrefix(m, "prefix:")) {
|
| 38 |
+
return true
|
| 39 |
+
}
|
| 40 |
+
}
|
| 41 |
+
return false
|
| 42 |
+
}
|
common/page_info.go
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package common
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"strconv"
|
| 5 |
+
|
| 6 |
+
"github.com/gin-gonic/gin"
|
| 7 |
+
)
|
| 8 |
+
|
| 9 |
+
type PageInfo struct {
|
| 10 |
+
Page int `json:"page"` // page num 页码
|
| 11 |
+
PageSize int `json:"page_size"` // page size 页大小
|
| 12 |
+
|
| 13 |
+
Total int `json:"total"` // 总条数,后设置
|
| 14 |
+
Items any `json:"items"` // 数据,后设置
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
func (p *PageInfo) GetStartIdx() int {
|
| 18 |
+
return (p.Page - 1) * p.PageSize
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
func (p *PageInfo) GetEndIdx() int {
|
| 22 |
+
return p.Page * p.PageSize
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
func (p *PageInfo) GetPageSize() int {
|
| 26 |
+
return p.PageSize
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
func (p *PageInfo) GetPage() int {
|
| 30 |
+
return p.Page
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
func (p *PageInfo) SetTotal(total int) {
|
| 34 |
+
p.Total = total
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
func (p *PageInfo) SetItems(items any) {
|
| 38 |
+
p.Items = items
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
func GetPageQuery(c *gin.Context) *PageInfo {
|
| 42 |
+
pageInfo := &PageInfo{}
|
| 43 |
+
// 手动获取并处理每个参数
|
| 44 |
+
if page, err := strconv.Atoi(c.Query("p")); err == nil {
|
| 45 |
+
pageInfo.Page = page
|
| 46 |
+
}
|
| 47 |
+
if pageSize, err := strconv.Atoi(c.Query("page_size")); err == nil {
|
| 48 |
+
pageInfo.PageSize = pageSize
|
| 49 |
+
}
|
| 50 |
+
if pageInfo.Page < 1 {
|
| 51 |
+
// 兼容
|
| 52 |
+
page, _ := strconv.Atoi(c.Query("p"))
|
| 53 |
+
if page != 0 {
|
| 54 |
+
pageInfo.Page = page
|
| 55 |
+
} else {
|
| 56 |
+
pageInfo.Page = 1
|
| 57 |
+
}
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
if pageInfo.PageSize == 0 {
|
| 61 |
+
// 兼容
|
| 62 |
+
pageSize, _ := strconv.Atoi(c.Query("ps"))
|
| 63 |
+
if pageSize != 0 {
|
| 64 |
+
pageInfo.PageSize = pageSize
|
| 65 |
+
}
|
| 66 |
+
if pageInfo.PageSize == 0 {
|
| 67 |
+
pageSize, _ = strconv.Atoi(c.Query("size")) // token page
|
| 68 |
+
if pageSize != 0 {
|
| 69 |
+
pageInfo.PageSize = pageSize
|
| 70 |
+
}
|
| 71 |
+
}
|
| 72 |
+
if pageInfo.PageSize == 0 {
|
| 73 |
+
pageInfo.PageSize = ItemsPerPage
|
| 74 |
+
}
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
if pageInfo.PageSize > 100 {
|
| 78 |
+
pageInfo.PageSize = 100
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
return pageInfo
|
| 82 |
+
}
|
common/pprof.go
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package common
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"fmt"
|
| 5 |
+
"github.com/shirou/gopsutil/cpu"
|
| 6 |
+
"os"
|
| 7 |
+
"runtime/pprof"
|
| 8 |
+
"time"
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
// Monitor 定时监控cpu使用率,超过阈值输出pprof文件
|
| 12 |
+
func Monitor() {
|
| 13 |
+
for {
|
| 14 |
+
percent, err := cpu.Percent(time.Second, false)
|
| 15 |
+
if err != nil {
|
| 16 |
+
panic(err)
|
| 17 |
+
}
|
| 18 |
+
if percent[0] > 80 {
|
| 19 |
+
fmt.Println("cpu usage too high")
|
| 20 |
+
// write pprof file
|
| 21 |
+
if _, err := os.Stat("./pprof"); os.IsNotExist(err) {
|
| 22 |
+
err := os.Mkdir("./pprof", os.ModePerm)
|
| 23 |
+
if err != nil {
|
| 24 |
+
SysLog("创建pprof文件夹失败 " + err.Error())
|
| 25 |
+
continue
|
| 26 |
+
}
|
| 27 |
+
}
|
| 28 |
+
f, err := os.Create("./pprof/" + fmt.Sprintf("cpu-%s.pprof", time.Now().Format("20060102150405")))
|
| 29 |
+
if err != nil {
|
| 30 |
+
SysLog("创建pprof文件失败 " + err.Error())
|
| 31 |
+
continue
|
| 32 |
+
}
|
| 33 |
+
err = pprof.StartCPUProfile(f)
|
| 34 |
+
if err != nil {
|
| 35 |
+
SysLog("启动pprof失败 " + err.Error())
|
| 36 |
+
continue
|
| 37 |
+
}
|
| 38 |
+
time.Sleep(10 * time.Second) // profile for 30 seconds
|
| 39 |
+
pprof.StopCPUProfile()
|
| 40 |
+
f.Close()
|
| 41 |
+
}
|
| 42 |
+
time.Sleep(30 * time.Second)
|
| 43 |
+
}
|
| 44 |
+
}
|
common/quota.go
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package common
|
| 2 |
+
|
| 3 |
+
func GetTrustQuota() int {
|
| 4 |
+
return int(10 * QuotaPerUnit)
|
| 5 |
+
}
|
common/rate-limit.go
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package common
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"sync"
|
| 5 |
+
"time"
|
| 6 |
+
)
|
| 7 |
+
|
| 8 |
+
type InMemoryRateLimiter struct {
|
| 9 |
+
store map[string]*[]int64
|
| 10 |
+
mutex sync.Mutex
|
| 11 |
+
expirationDuration time.Duration
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
func (l *InMemoryRateLimiter) Init(expirationDuration time.Duration) {
|
| 15 |
+
if l.store == nil {
|
| 16 |
+
l.mutex.Lock()
|
| 17 |
+
if l.store == nil {
|
| 18 |
+
l.store = make(map[string]*[]int64)
|
| 19 |
+
l.expirationDuration = expirationDuration
|
| 20 |
+
if expirationDuration > 0 {
|
| 21 |
+
go l.clearExpiredItems()
|
| 22 |
+
}
|
| 23 |
+
}
|
| 24 |
+
l.mutex.Unlock()
|
| 25 |
+
}
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
func (l *InMemoryRateLimiter) clearExpiredItems() {
|
| 29 |
+
for {
|
| 30 |
+
time.Sleep(l.expirationDuration)
|
| 31 |
+
l.mutex.Lock()
|
| 32 |
+
now := time.Now().Unix()
|
| 33 |
+
for key := range l.store {
|
| 34 |
+
queue := l.store[key]
|
| 35 |
+
size := len(*queue)
|
| 36 |
+
if size == 0 || now-(*queue)[size-1] > int64(l.expirationDuration.Seconds()) {
|
| 37 |
+
delete(l.store, key)
|
| 38 |
+
}
|
| 39 |
+
}
|
| 40 |
+
l.mutex.Unlock()
|
| 41 |
+
}
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
// Request parameter duration's unit is seconds
|
| 45 |
+
func (l *InMemoryRateLimiter) Request(key string, maxRequestNum int, duration int64) bool {
|
| 46 |
+
l.mutex.Lock()
|
| 47 |
+
defer l.mutex.Unlock()
|
| 48 |
+
// [old <-- new]
|
| 49 |
+
queue, ok := l.store[key]
|
| 50 |
+
now := time.Now().Unix()
|
| 51 |
+
if ok {
|
| 52 |
+
if len(*queue) < maxRequestNum {
|
| 53 |
+
*queue = append(*queue, now)
|
| 54 |
+
return true
|
| 55 |
+
} else {
|
| 56 |
+
if now-(*queue)[0] >= duration {
|
| 57 |
+
*queue = (*queue)[1:]
|
| 58 |
+
*queue = append(*queue, now)
|
| 59 |
+
return true
|
| 60 |
+
} else {
|
| 61 |
+
return false
|
| 62 |
+
}
|
| 63 |
+
}
|
| 64 |
+
} else {
|
| 65 |
+
s := make([]int64, 0, maxRequestNum)
|
| 66 |
+
l.store[key] = &s
|
| 67 |
+
*(l.store[key]) = append(*(l.store[key]), now)
|
| 68 |
+
}
|
| 69 |
+
return true
|
| 70 |
+
}
|
common/redis.go
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package common
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"context"
|
| 5 |
+
"errors"
|
| 6 |
+
"fmt"
|
| 7 |
+
"os"
|
| 8 |
+
"reflect"
|
| 9 |
+
"strconv"
|
| 10 |
+
"time"
|
| 11 |
+
|
| 12 |
+
"github.com/go-redis/redis/v8"
|
| 13 |
+
"gorm.io/gorm"
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
var RDB *redis.Client
|
| 17 |
+
var RedisEnabled = true
|
| 18 |
+
|
| 19 |
+
func RedisKeyCacheSeconds() int {
|
| 20 |
+
return SyncFrequency
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
// InitRedisClient This function is called after init()
|
| 24 |
+
func InitRedisClient() (err error) {
|
| 25 |
+
if os.Getenv("REDIS_CONN_STRING") == "" {
|
| 26 |
+
RedisEnabled = false
|
| 27 |
+
SysLog("REDIS_CONN_STRING not set, Redis is not enabled")
|
| 28 |
+
return nil
|
| 29 |
+
}
|
| 30 |
+
if os.Getenv("SYNC_FREQUENCY") == "" {
|
| 31 |
+
SysLog("SYNC_FREQUENCY not set, use default value 60")
|
| 32 |
+
SyncFrequency = 60
|
| 33 |
+
}
|
| 34 |
+
SysLog("Redis is enabled")
|
| 35 |
+
opt, err := redis.ParseURL(os.Getenv("REDIS_CONN_STRING"))
|
| 36 |
+
if err != nil {
|
| 37 |
+
FatalLog("failed to parse Redis connection string: " + err.Error())
|
| 38 |
+
}
|
| 39 |
+
opt.PoolSize = GetEnvOrDefault("REDIS_POOL_SIZE", 10)
|
| 40 |
+
RDB = redis.NewClient(opt)
|
| 41 |
+
|
| 42 |
+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
| 43 |
+
defer cancel()
|
| 44 |
+
|
| 45 |
+
_, err = RDB.Ping(ctx).Result()
|
| 46 |
+
if err != nil {
|
| 47 |
+
FatalLog("Redis ping test failed: " + err.Error())
|
| 48 |
+
}
|
| 49 |
+
if DebugEnabled {
|
| 50 |
+
SysLog(fmt.Sprintf("Redis connected to %s", opt.Addr))
|
| 51 |
+
SysLog(fmt.Sprintf("Redis database: %d", opt.DB))
|
| 52 |
+
}
|
| 53 |
+
return err
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
func ParseRedisOption() *redis.Options {
|
| 57 |
+
opt, err := redis.ParseURL(os.Getenv("REDIS_CONN_STRING"))
|
| 58 |
+
if err != nil {
|
| 59 |
+
FatalLog("failed to parse Redis connection string: " + err.Error())
|
| 60 |
+
}
|
| 61 |
+
return opt
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
func RedisSet(key string, value string, expiration time.Duration) error {
|
| 65 |
+
if DebugEnabled {
|
| 66 |
+
SysLog(fmt.Sprintf("Redis SET: key=%s, value=%s, expiration=%v", key, value, expiration))
|
| 67 |
+
}
|
| 68 |
+
ctx := context.Background()
|
| 69 |
+
return RDB.Set(ctx, key, value, expiration).Err()
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
func RedisGet(key string) (string, error) {
|
| 73 |
+
if DebugEnabled {
|
| 74 |
+
SysLog(fmt.Sprintf("Redis GET: key=%s", key))
|
| 75 |
+
}
|
| 76 |
+
ctx := context.Background()
|
| 77 |
+
val, err := RDB.Get(ctx, key).Result()
|
| 78 |
+
return val, err
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
//func RedisExpire(key string, expiration time.Duration) error {
|
| 82 |
+
// ctx := context.Background()
|
| 83 |
+
// return RDB.Expire(ctx, key, expiration).Err()
|
| 84 |
+
//}
|
| 85 |
+
//
|
| 86 |
+
//func RedisGetEx(key string, expiration time.Duration) (string, error) {
|
| 87 |
+
// ctx := context.Background()
|
| 88 |
+
// return RDB.GetSet(ctx, key, expiration).Result()
|
| 89 |
+
//}
|
| 90 |
+
|
| 91 |
+
func RedisDel(key string) error {
|
| 92 |
+
if DebugEnabled {
|
| 93 |
+
SysLog(fmt.Sprintf("Redis DEL: key=%s", key))
|
| 94 |
+
}
|
| 95 |
+
ctx := context.Background()
|
| 96 |
+
return RDB.Del(ctx, key).Err()
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
func RedisDelKey(key string) error {
|
| 100 |
+
if DebugEnabled {
|
| 101 |
+
SysLog(fmt.Sprintf("Redis DEL Key: key=%s", key))
|
| 102 |
+
}
|
| 103 |
+
ctx := context.Background()
|
| 104 |
+
return RDB.Del(ctx, key).Err()
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
func RedisHSetObj(key string, obj interface{}, expiration time.Duration) error {
|
| 108 |
+
if DebugEnabled {
|
| 109 |
+
SysLog(fmt.Sprintf("Redis HSET: key=%s, obj=%+v, expiration=%v", key, obj, expiration))
|
| 110 |
+
}
|
| 111 |
+
ctx := context.Background()
|
| 112 |
+
|
| 113 |
+
data := make(map[string]interface{})
|
| 114 |
+
|
| 115 |
+
// 使用反射遍历结构体字段
|
| 116 |
+
v := reflect.ValueOf(obj).Elem()
|
| 117 |
+
t := v.Type()
|
| 118 |
+
for i := 0; i < v.NumField(); i++ {
|
| 119 |
+
field := t.Field(i)
|
| 120 |
+
value := v.Field(i)
|
| 121 |
+
|
| 122 |
+
// Skip DeletedAt field
|
| 123 |
+
if field.Type.String() == "gorm.DeletedAt" {
|
| 124 |
+
continue
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
// 处理指针类型
|
| 128 |
+
if value.Kind() == reflect.Ptr {
|
| 129 |
+
if value.IsNil() {
|
| 130 |
+
data[field.Name] = ""
|
| 131 |
+
continue
|
| 132 |
+
}
|
| 133 |
+
value = value.Elem()
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
// 处理布尔类型
|
| 137 |
+
if value.Kind() == reflect.Bool {
|
| 138 |
+
data[field.Name] = strconv.FormatBool(value.Bool())
|
| 139 |
+
continue
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
// 其他类型直接转换为字符串
|
| 143 |
+
data[field.Name] = fmt.Sprintf("%v", value.Interface())
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
txn := RDB.TxPipeline()
|
| 147 |
+
txn.HSet(ctx, key, data)
|
| 148 |
+
|
| 149 |
+
// 只有在 expiration 大于 0 时才设置过期时间
|
| 150 |
+
if expiration > 0 {
|
| 151 |
+
txn.Expire(ctx, key, expiration)
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
_, err := txn.Exec(ctx)
|
| 155 |
+
if err != nil {
|
| 156 |
+
return fmt.Errorf("failed to execute transaction: %w", err)
|
| 157 |
+
}
|
| 158 |
+
return nil
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
func RedisHGetObj(key string, obj interface{}) error {
|
| 162 |
+
if DebugEnabled {
|
| 163 |
+
SysLog(fmt.Sprintf("Redis HGETALL: key=%s", key))
|
| 164 |
+
}
|
| 165 |
+
ctx := context.Background()
|
| 166 |
+
|
| 167 |
+
result, err := RDB.HGetAll(ctx, key).Result()
|
| 168 |
+
if err != nil {
|
| 169 |
+
return fmt.Errorf("failed to load hash from Redis: %w", err)
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
if len(result) == 0 {
|
| 173 |
+
return fmt.Errorf("key %s not found in Redis", key)
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
// Handle both pointer and non-pointer values
|
| 177 |
+
val := reflect.ValueOf(obj)
|
| 178 |
+
if val.Kind() != reflect.Ptr {
|
| 179 |
+
return fmt.Errorf("obj must be a pointer to a struct, got %T", obj)
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
v := val.Elem()
|
| 183 |
+
if v.Kind() != reflect.Struct {
|
| 184 |
+
return fmt.Errorf("obj must be a pointer to a struct, got pointer to %T", v.Interface())
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
t := v.Type()
|
| 188 |
+
for i := 0; i < v.NumField(); i++ {
|
| 189 |
+
field := t.Field(i)
|
| 190 |
+
fieldName := field.Name
|
| 191 |
+
if value, ok := result[fieldName]; ok {
|
| 192 |
+
fieldValue := v.Field(i)
|
| 193 |
+
|
| 194 |
+
// Handle pointer types
|
| 195 |
+
if fieldValue.Kind() == reflect.Ptr {
|
| 196 |
+
if value == "" {
|
| 197 |
+
continue
|
| 198 |
+
}
|
| 199 |
+
if fieldValue.IsNil() {
|
| 200 |
+
fieldValue.Set(reflect.New(fieldValue.Type().Elem()))
|
| 201 |
+
}
|
| 202 |
+
fieldValue = fieldValue.Elem()
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
// Enhanced type handling for Token struct
|
| 206 |
+
switch fieldValue.Kind() {
|
| 207 |
+
case reflect.String:
|
| 208 |
+
fieldValue.SetString(value)
|
| 209 |
+
case reflect.Int, reflect.Int64:
|
| 210 |
+
intValue, err := strconv.ParseInt(value, 10, 64)
|
| 211 |
+
if err != nil {
|
| 212 |
+
return fmt.Errorf("failed to parse int field %s: %w", fieldName, err)
|
| 213 |
+
}
|
| 214 |
+
fieldValue.SetInt(intValue)
|
| 215 |
+
case reflect.Bool:
|
| 216 |
+
boolValue, err := strconv.ParseBool(value)
|
| 217 |
+
if err != nil {
|
| 218 |
+
return fmt.Errorf("failed to parse bool field %s: %w", fieldName, err)
|
| 219 |
+
}
|
| 220 |
+
fieldValue.SetBool(boolValue)
|
| 221 |
+
case reflect.Struct:
|
| 222 |
+
// Special handling for gorm.DeletedAt
|
| 223 |
+
if fieldValue.Type().String() == "gorm.DeletedAt" {
|
| 224 |
+
if value != "" {
|
| 225 |
+
timeValue, err := time.Parse(time.RFC3339, value)
|
| 226 |
+
if err != nil {
|
| 227 |
+
return fmt.Errorf("failed to parse DeletedAt field %s: %w", fieldName, err)
|
| 228 |
+
}
|
| 229 |
+
fieldValue.Set(reflect.ValueOf(gorm.DeletedAt{Time: timeValue, Valid: true}))
|
| 230 |
+
}
|
| 231 |
+
}
|
| 232 |
+
default:
|
| 233 |
+
return fmt.Errorf("unsupported field type: %s for field %s", fieldValue.Kind(), fieldName)
|
| 234 |
+
}
|
| 235 |
+
}
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
return nil
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
// RedisIncr Add this function to handle atomic increments
|
| 242 |
+
func RedisIncr(key string, delta int64) error {
|
| 243 |
+
if DebugEnabled {
|
| 244 |
+
SysLog(fmt.Sprintf("Redis INCR: key=%s, delta=%d", key, delta))
|
| 245 |
+
}
|
| 246 |
+
// 检查键的剩余生存时间
|
| 247 |
+
ttlCmd := RDB.TTL(context.Background(), key)
|
| 248 |
+
ttl, err := ttlCmd.Result()
|
| 249 |
+
if err != nil && !errors.Is(err, redis.Nil) {
|
| 250 |
+
return fmt.Errorf("failed to get TTL: %w", err)
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
// 只有在 key 存在且有 TTL 时才需要特殊处理
|
| 254 |
+
if ttl > 0 {
|
| 255 |
+
ctx := context.Background()
|
| 256 |
+
// 开始一个Redis事务
|
| 257 |
+
txn := RDB.TxPipeline()
|
| 258 |
+
|
| 259 |
+
// 减少余额
|
| 260 |
+
decrCmd := txn.IncrBy(ctx, key, delta)
|
| 261 |
+
if err := decrCmd.Err(); err != nil {
|
| 262 |
+
return err // 如果减少失败,则直接返回错误
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
// 重新设置过期时间,使用原来的过期时间
|
| 266 |
+
txn.Expire(ctx, key, ttl)
|
| 267 |
+
|
| 268 |
+
// 执行事务
|
| 269 |
+
_, err = txn.Exec(ctx)
|
| 270 |
+
return err
|
| 271 |
+
}
|
| 272 |
+
return nil
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
func RedisHIncrBy(key, field string, delta int64) error {
|
| 276 |
+
if DebugEnabled {
|
| 277 |
+
SysLog(fmt.Sprintf("Redis HINCRBY: key=%s, field=%s, delta=%d", key, field, delta))
|
| 278 |
+
}
|
| 279 |
+
ttlCmd := RDB.TTL(context.Background(), key)
|
| 280 |
+
ttl, err := ttlCmd.Result()
|
| 281 |
+
if err != nil && !errors.Is(err, redis.Nil) {
|
| 282 |
+
return fmt.Errorf("failed to get TTL: %w", err)
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
if ttl > 0 {
|
| 286 |
+
ctx := context.Background()
|
| 287 |
+
txn := RDB.TxPipeline()
|
| 288 |
+
|
| 289 |
+
incrCmd := txn.HIncrBy(ctx, key, field, delta)
|
| 290 |
+
if err := incrCmd.Err(); err != nil {
|
| 291 |
+
return err
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
txn.Expire(ctx, key, ttl)
|
| 295 |
+
|
| 296 |
+
_, err = txn.Exec(ctx)
|
| 297 |
+
return err
|
| 298 |
+
}
|
| 299 |
+
return nil
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
func RedisHSetField(key, field string, value interface{}) error {
|
| 303 |
+
if DebugEnabled {
|
| 304 |
+
SysLog(fmt.Sprintf("Redis HSET field: key=%s, field=%s, value=%v", key, field, value))
|
| 305 |
+
}
|
| 306 |
+
ttlCmd := RDB.TTL(context.Background(), key)
|
| 307 |
+
ttl, err := ttlCmd.Result()
|
| 308 |
+
if err != nil && !errors.Is(err, redis.Nil) {
|
| 309 |
+
return fmt.Errorf("failed to get TTL: %w", err)
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
if ttl > 0 {
|
| 313 |
+
ctx := context.Background()
|
| 314 |
+
txn := RDB.TxPipeline()
|
| 315 |
+
|
| 316 |
+
hsetCmd := txn.HSet(ctx, key, field, value)
|
| 317 |
+
if err := hsetCmd.Err(); err != nil {
|
| 318 |
+
return err
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
txn.Expire(ctx, key, ttl)
|
| 322 |
+
|
| 323 |
+
_, err = txn.Exec(ctx)
|
| 324 |
+
return err
|
| 325 |
+
}
|
| 326 |
+
return nil
|
| 327 |
+
}
|
common/ssrf_protection.go
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package common
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"fmt"
|
| 5 |
+
"net"
|
| 6 |
+
"net/url"
|
| 7 |
+
"strconv"
|
| 8 |
+
"strings"
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
// SSRFProtection SSRF防护配置
|
| 12 |
+
type SSRFProtection struct {
|
| 13 |
+
AllowPrivateIp bool
|
| 14 |
+
DomainFilterMode bool // true: 白名单, false: 黑名单
|
| 15 |
+
DomainList []string // domain format, e.g. example.com, *.example.com
|
| 16 |
+
IpFilterMode bool // true: 白名单, false: 黑名单
|
| 17 |
+
IpList []string // CIDR or single IP
|
| 18 |
+
AllowedPorts []int // 允许的端口范围
|
| 19 |
+
ApplyIPFilterForDomain bool // 对域名启用IP过滤
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
// DefaultSSRFProtection 默认SSRF防护配置
|
| 23 |
+
var DefaultSSRFProtection = &SSRFProtection{
|
| 24 |
+
AllowPrivateIp: false,
|
| 25 |
+
DomainFilterMode: true,
|
| 26 |
+
DomainList: []string{},
|
| 27 |
+
IpFilterMode: true,
|
| 28 |
+
IpList: []string{},
|
| 29 |
+
AllowedPorts: []int{},
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
// isPrivateIP 检查IP是否为私有地址
|
| 33 |
+
func isPrivateIP(ip net.IP) bool {
|
| 34 |
+
if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
|
| 35 |
+
return true
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
// 检查私有网段
|
| 39 |
+
private := []net.IPNet{
|
| 40 |
+
{IP: net.IPv4(10, 0, 0, 0), Mask: net.CIDRMask(8, 32)}, // 10.0.0.0/8
|
| 41 |
+
{IP: net.IPv4(172, 16, 0, 0), Mask: net.CIDRMask(12, 32)}, // 172.16.0.0/12
|
| 42 |
+
{IP: net.IPv4(192, 168, 0, 0), Mask: net.CIDRMask(16, 32)}, // 192.168.0.0/16
|
| 43 |
+
{IP: net.IPv4(127, 0, 0, 0), Mask: net.CIDRMask(8, 32)}, // 127.0.0.0/8
|
| 44 |
+
{IP: net.IPv4(169, 254, 0, 0), Mask: net.CIDRMask(16, 32)}, // 169.254.0.0/16 (链路本地)
|
| 45 |
+
{IP: net.IPv4(224, 0, 0, 0), Mask: net.CIDRMask(4, 32)}, // 224.0.0.0/4 (组播)
|
| 46 |
+
{IP: net.IPv4(240, 0, 0, 0), Mask: net.CIDRMask(4, 32)}, // 240.0.0.0/4 (保留)
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
for _, privateNet := range private {
|
| 50 |
+
if privateNet.Contains(ip) {
|
| 51 |
+
return true
|
| 52 |
+
}
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
// 检查IPv6私有地址
|
| 56 |
+
if ip.To4() == nil {
|
| 57 |
+
// IPv6 loopback
|
| 58 |
+
if ip.Equal(net.IPv6loopback) {
|
| 59 |
+
return true
|
| 60 |
+
}
|
| 61 |
+
// IPv6 link-local
|
| 62 |
+
if strings.HasPrefix(ip.String(), "fe80:") {
|
| 63 |
+
return true
|
| 64 |
+
}
|
| 65 |
+
// IPv6 unique local
|
| 66 |
+
if strings.HasPrefix(ip.String(), "fc") || strings.HasPrefix(ip.String(), "fd") {
|
| 67 |
+
return true
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
return false
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
// parsePortRanges 解析端口范围配置
|
| 75 |
+
// 支持格式: "80", "443", "8000-9000"
|
| 76 |
+
func parsePortRanges(portConfigs []string) ([]int, error) {
|
| 77 |
+
var ports []int
|
| 78 |
+
|
| 79 |
+
for _, config := range portConfigs {
|
| 80 |
+
config = strings.TrimSpace(config)
|
| 81 |
+
if config == "" {
|
| 82 |
+
continue
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
if strings.Contains(config, "-") {
|
| 86 |
+
// 处理端口范围 "8000-9000"
|
| 87 |
+
parts := strings.Split(config, "-")
|
| 88 |
+
if len(parts) != 2 {
|
| 89 |
+
return nil, fmt.Errorf("invalid port range format: %s", config)
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
startPort, err := strconv.Atoi(strings.TrimSpace(parts[0]))
|
| 93 |
+
if err != nil {
|
| 94 |
+
return nil, fmt.Errorf("invalid start port in range %s: %v", config, err)
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
endPort, err := strconv.Atoi(strings.TrimSpace(parts[1]))
|
| 98 |
+
if err != nil {
|
| 99 |
+
return nil, fmt.Errorf("invalid end port in range %s: %v", config, err)
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
if startPort > endPort {
|
| 103 |
+
return nil, fmt.Errorf("invalid port range %s: start port cannot be greater than end port", config)
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
if startPort < 1 || startPort > 65535 || endPort < 1 || endPort > 65535 {
|
| 107 |
+
return nil, fmt.Errorf("port range %s contains invalid port numbers (must be 1-65535)", config)
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
// 添加范围内的所有端口
|
| 111 |
+
for port := startPort; port <= endPort; port++ {
|
| 112 |
+
ports = append(ports, port)
|
| 113 |
+
}
|
| 114 |
+
} else {
|
| 115 |
+
// 处理单个端口 "80"
|
| 116 |
+
port, err := strconv.Atoi(config)
|
| 117 |
+
if err != nil {
|
| 118 |
+
return nil, fmt.Errorf("invalid port number: %s", config)
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
if port < 1 || port > 65535 {
|
| 122 |
+
return nil, fmt.Errorf("invalid port number %d (must be 1-65535)", port)
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
ports = append(ports, port)
|
| 126 |
+
}
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
return ports, nil
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
// isAllowedPort 检查端口是否被允许
|
| 133 |
+
func (p *SSRFProtection) isAllowedPort(port int) bool {
|
| 134 |
+
if len(p.AllowedPorts) == 0 {
|
| 135 |
+
return true // 如果没有配置端口限制,则允许所有端口
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
for _, allowedPort := range p.AllowedPorts {
|
| 139 |
+
if port == allowedPort {
|
| 140 |
+
return true
|
| 141 |
+
}
|
| 142 |
+
}
|
| 143 |
+
return false
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
// isDomainWhitelisted 检查域名是否在白名单中
|
| 147 |
+
func isDomainListed(domain string, list []string) bool {
|
| 148 |
+
if len(list) == 0 {
|
| 149 |
+
return false
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
domain = strings.ToLower(domain)
|
| 153 |
+
for _, item := range list {
|
| 154 |
+
item = strings.ToLower(strings.TrimSpace(item))
|
| 155 |
+
if item == "" {
|
| 156 |
+
continue
|
| 157 |
+
}
|
| 158 |
+
// 精确匹配
|
| 159 |
+
if domain == item {
|
| 160 |
+
return true
|
| 161 |
+
}
|
| 162 |
+
// 通配符匹配 (*.example.com)
|
| 163 |
+
if strings.HasPrefix(item, "*.") {
|
| 164 |
+
suffix := strings.TrimPrefix(item, "*.")
|
| 165 |
+
if strings.HasSuffix(domain, "."+suffix) || domain == suffix {
|
| 166 |
+
return true
|
| 167 |
+
}
|
| 168 |
+
}
|
| 169 |
+
}
|
| 170 |
+
return false
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
func (p *SSRFProtection) isDomainAllowed(domain string) bool {
|
| 174 |
+
listed := isDomainListed(domain, p.DomainList)
|
| 175 |
+
if p.DomainFilterMode { // 白名单
|
| 176 |
+
return listed
|
| 177 |
+
}
|
| 178 |
+
// 黑名单
|
| 179 |
+
return !listed
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
// isIPWhitelisted 检查IP是否在白名单中
|
| 183 |
+
|
| 184 |
+
func isIPListed(ip net.IP, list []string) bool {
|
| 185 |
+
if len(list) == 0 {
|
| 186 |
+
return false
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
for _, whitelistCIDR := range list {
|
| 190 |
+
_, network, err := net.ParseCIDR(whitelistCIDR)
|
| 191 |
+
if err != nil {
|
| 192 |
+
// 尝试作为单个IP处理
|
| 193 |
+
if whitelistIP := net.ParseIP(whitelistCIDR); whitelistIP != nil {
|
| 194 |
+
if ip.Equal(whitelistIP) {
|
| 195 |
+
return true
|
| 196 |
+
}
|
| 197 |
+
}
|
| 198 |
+
continue
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
if network.Contains(ip) {
|
| 202 |
+
return true
|
| 203 |
+
}
|
| 204 |
+
}
|
| 205 |
+
return false
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
// IsIPAccessAllowed 检查IP是否允许访问
|
| 209 |
+
func (p *SSRFProtection) IsIPAccessAllowed(ip net.IP) bool {
|
| 210 |
+
// 私有IP限制
|
| 211 |
+
if isPrivateIP(ip) && !p.AllowPrivateIp {
|
| 212 |
+
return false
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
listed := isIPListed(ip, p.IpList)
|
| 216 |
+
if p.IpFilterMode { // 白名单
|
| 217 |
+
return listed
|
| 218 |
+
}
|
| 219 |
+
// 黑名单
|
| 220 |
+
return !listed
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
// ValidateURL 验证URL是否安全
|
| 224 |
+
func (p *SSRFProtection) ValidateURL(urlStr string) error {
|
| 225 |
+
// 解析URL
|
| 226 |
+
u, err := url.Parse(urlStr)
|
| 227 |
+
if err != nil {
|
| 228 |
+
return fmt.Errorf("invalid URL format: %v", err)
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
// 只允许HTTP/HTTPS协议
|
| 232 |
+
if u.Scheme != "http" && u.Scheme != "https" {
|
| 233 |
+
return fmt.Errorf("unsupported protocol: %s (only http/https allowed)", u.Scheme)
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
// 解析主机和端口
|
| 237 |
+
host, portStr, err := net.SplitHostPort(u.Host)
|
| 238 |
+
if err != nil {
|
| 239 |
+
// 没有端口,使用默认端口
|
| 240 |
+
host = u.Hostname()
|
| 241 |
+
if u.Scheme == "https" {
|
| 242 |
+
portStr = "443"
|
| 243 |
+
} else {
|
| 244 |
+
portStr = "80"
|
| 245 |
+
}
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
// 验证端口
|
| 249 |
+
port, err := strconv.Atoi(portStr)
|
| 250 |
+
if err != nil {
|
| 251 |
+
return fmt.Errorf("invalid port: %s", portStr)
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
if !p.isAllowedPort(port) {
|
| 255 |
+
return fmt.Errorf("port %d is not allowed", port)
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
// 如果 host 是 IP,则跳过域名检查
|
| 259 |
+
if ip := net.ParseIP(host); ip != nil {
|
| 260 |
+
if !p.IsIPAccessAllowed(ip) {
|
| 261 |
+
if isPrivateIP(ip) {
|
| 262 |
+
return fmt.Errorf("private IP address not allowed: %s", ip.String())
|
| 263 |
+
}
|
| 264 |
+
if p.IpFilterMode {
|
| 265 |
+
return fmt.Errorf("ip not in whitelist: %s", ip.String())
|
| 266 |
+
}
|
| 267 |
+
return fmt.Errorf("ip in blacklist: %s", ip.String())
|
| 268 |
+
}
|
| 269 |
+
return nil
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
// 先进行域名过滤
|
| 273 |
+
if !p.isDomainAllowed(host) {
|
| 274 |
+
if p.DomainFilterMode {
|
| 275 |
+
return fmt.Errorf("domain not in whitelist: %s", host)
|
| 276 |
+
}
|
| 277 |
+
return fmt.Errorf("domain in blacklist: %s", host)
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
// 若未启用对域名应用IP过滤,则到此通过
|
| 281 |
+
if !p.ApplyIPFilterForDomain {
|
| 282 |
+
return nil
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
// 解析域名对应IP并检查
|
| 286 |
+
ips, err := net.LookupIP(host)
|
| 287 |
+
if err != nil {
|
| 288 |
+
return fmt.Errorf("DNS resolution failed for %s: %v", host, err)
|
| 289 |
+
}
|
| 290 |
+
for _, ip := range ips {
|
| 291 |
+
if !p.IsIPAccessAllowed(ip) {
|
| 292 |
+
if isPrivateIP(ip) && !p.AllowPrivateIp {
|
| 293 |
+
return fmt.Errorf("private IP address not allowed: %s resolves to %s", host, ip.String())
|
| 294 |
+
}
|
| 295 |
+
if p.IpFilterMode {
|
| 296 |
+
return fmt.Errorf("ip not in whitelist: %s resolves to %s", host, ip.String())
|
| 297 |
+
}
|
| 298 |
+
return fmt.Errorf("ip in blacklist: %s resolves to %s", host, ip.String())
|
| 299 |
+
}
|
| 300 |
+
}
|
| 301 |
+
return nil
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
// ValidateURLWithFetchSetting 使用FetchSetting配置验证URL
|
| 305 |
+
func ValidateURLWithFetchSetting(urlStr string, enableSSRFProtection, allowPrivateIp bool, domainFilterMode bool, ipFilterMode bool, domainList, ipList, allowedPorts []string, applyIPFilterForDomain bool) error {
|
| 306 |
+
// 如果SSRF防护被禁用,直接返回成功
|
| 307 |
+
if !enableSSRFProtection {
|
| 308 |
+
return nil
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
// 解析端口范围配置
|
| 312 |
+
allowedPortInts, err := parsePortRanges(allowedPorts)
|
| 313 |
+
if err != nil {
|
| 314 |
+
return fmt.Errorf("request reject - invalid port configuration: %v", err)
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
protection := &SSRFProtection{
|
| 318 |
+
AllowPrivateIp: allowPrivateIp,
|
| 319 |
+
DomainFilterMode: domainFilterMode,
|
| 320 |
+
DomainList: domainList,
|
| 321 |
+
IpFilterMode: ipFilterMode,
|
| 322 |
+
IpList: ipList,
|
| 323 |
+
AllowedPorts: allowedPortInts,
|
| 324 |
+
ApplyIPFilterForDomain: applyIPFilterForDomain,
|
| 325 |
+
}
|
| 326 |
+
return protection.ValidateURL(urlStr)
|
| 327 |
+
}
|
common/str.go
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package common
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"encoding/base64"
|
| 5 |
+
"encoding/json"
|
| 6 |
+
"math/rand"
|
| 7 |
+
"net/url"
|
| 8 |
+
"regexp"
|
| 9 |
+
"strconv"
|
| 10 |
+
"strings"
|
| 11 |
+
"unsafe"
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
func GetStringIfEmpty(str string, defaultValue string) string {
|
| 15 |
+
if str == "" {
|
| 16 |
+
return defaultValue
|
| 17 |
+
}
|
| 18 |
+
return str
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
func GetRandomString(length int) string {
|
| 22 |
+
//rand.Seed(time.Now().UnixNano())
|
| 23 |
+
key := make([]byte, length)
|
| 24 |
+
for i := 0; i < length; i++ {
|
| 25 |
+
key[i] = keyChars[rand.Intn(len(keyChars))]
|
| 26 |
+
}
|
| 27 |
+
return string(key)
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
func MapToJsonStr(m map[string]interface{}) string {
|
| 31 |
+
bytes, err := json.Marshal(m)
|
| 32 |
+
if err != nil {
|
| 33 |
+
return ""
|
| 34 |
+
}
|
| 35 |
+
return string(bytes)
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
func StrToMap(str string) (map[string]interface{}, error) {
|
| 39 |
+
m := make(map[string]interface{})
|
| 40 |
+
err := Unmarshal([]byte(str), &m)
|
| 41 |
+
if err != nil {
|
| 42 |
+
return nil, err
|
| 43 |
+
}
|
| 44 |
+
return m, nil
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
func StrToJsonArray(str string) ([]interface{}, error) {
|
| 48 |
+
var js []interface{}
|
| 49 |
+
err := json.Unmarshal([]byte(str), &js)
|
| 50 |
+
if err != nil {
|
| 51 |
+
return nil, err
|
| 52 |
+
}
|
| 53 |
+
return js, nil
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
func IsJsonArray(str string) bool {
|
| 57 |
+
var js []interface{}
|
| 58 |
+
return json.Unmarshal([]byte(str), &js) == nil
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
func IsJsonObject(str string) bool {
|
| 62 |
+
var js map[string]interface{}
|
| 63 |
+
return json.Unmarshal([]byte(str), &js) == nil
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
func String2Int(str string) int {
|
| 67 |
+
num, err := strconv.Atoi(str)
|
| 68 |
+
if err != nil {
|
| 69 |
+
return 0
|
| 70 |
+
}
|
| 71 |
+
return num
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
func StringsContains(strs []string, str string) bool {
|
| 75 |
+
for _, s := range strs {
|
| 76 |
+
if s == str {
|
| 77 |
+
return true
|
| 78 |
+
}
|
| 79 |
+
}
|
| 80 |
+
return false
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
// StringToByteSlice []byte only read, panic on append
|
| 84 |
+
func StringToByteSlice(s string) []byte {
|
| 85 |
+
tmp1 := (*[2]uintptr)(unsafe.Pointer(&s))
|
| 86 |
+
tmp2 := [3]uintptr{tmp1[0], tmp1[1], tmp1[1]}
|
| 87 |
+
return *(*[]byte)(unsafe.Pointer(&tmp2))
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
func EncodeBase64(str string) string {
|
| 91 |
+
return base64.StdEncoding.EncodeToString([]byte(str))
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
func GetJsonString(data any) string {
|
| 95 |
+
if data == nil {
|
| 96 |
+
return ""
|
| 97 |
+
}
|
| 98 |
+
b, _ := json.Marshal(data)
|
| 99 |
+
return string(b)
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
// MaskEmail masks a user email to prevent PII leakage in logs
|
| 103 |
+
// Returns "***masked***" if email is empty, otherwise shows only the domain part
|
| 104 |
+
func MaskEmail(email string) string {
|
| 105 |
+
if email == "" {
|
| 106 |
+
return "***masked***"
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
// Find the @ symbol
|
| 110 |
+
atIndex := strings.Index(email, "@")
|
| 111 |
+
if atIndex == -1 {
|
| 112 |
+
// No @ symbol found, return masked
|
| 113 |
+
return "***masked***"
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
// Return only the domain part with @ symbol
|
| 117 |
+
return "***@" + email[atIndex+1:]
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
// maskHostTail returns the tail parts of a domain/host that should be preserved.
|
| 121 |
+
// It keeps 2 parts for likely country-code TLDs (e.g., co.uk, com.cn), otherwise keeps only the TLD.
|
| 122 |
+
func maskHostTail(parts []string) []string {
|
| 123 |
+
if len(parts) < 2 {
|
| 124 |
+
return parts
|
| 125 |
+
}
|
| 126 |
+
lastPart := parts[len(parts)-1]
|
| 127 |
+
secondLastPart := parts[len(parts)-2]
|
| 128 |
+
if len(lastPart) == 2 && len(secondLastPart) <= 3 {
|
| 129 |
+
// Likely country code TLD like co.uk, com.cn
|
| 130 |
+
return []string{secondLastPart, lastPart}
|
| 131 |
+
}
|
| 132 |
+
return []string{lastPart}
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
// maskHostForURL collapses subdomains and keeps only masked prefix + preserved tail.
|
| 136 |
+
// Example: api.openai.com -> ***.com, sub.domain.co.uk -> ***.co.uk
|
| 137 |
+
func maskHostForURL(host string) string {
|
| 138 |
+
parts := strings.Split(host, ".")
|
| 139 |
+
if len(parts) < 2 {
|
| 140 |
+
return "***"
|
| 141 |
+
}
|
| 142 |
+
tail := maskHostTail(parts)
|
| 143 |
+
return "***." + strings.Join(tail, ".")
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
// maskHostForPlainDomain masks a plain domain and reflects subdomain depth with multiple ***.
|
| 147 |
+
// Example: openai.com -> ***.com, api.openai.com -> ***.***.com, sub.domain.co.uk -> ***.***.co.uk
|
| 148 |
+
func maskHostForPlainDomain(domain string) string {
|
| 149 |
+
parts := strings.Split(domain, ".")
|
| 150 |
+
if len(parts) < 2 {
|
| 151 |
+
return domain
|
| 152 |
+
}
|
| 153 |
+
tail := maskHostTail(parts)
|
| 154 |
+
numStars := len(parts) - len(tail)
|
| 155 |
+
if numStars < 1 {
|
| 156 |
+
numStars = 1
|
| 157 |
+
}
|
| 158 |
+
stars := strings.TrimSuffix(strings.Repeat("***.", numStars), ".")
|
| 159 |
+
return stars + "." + strings.Join(tail, ".")
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
// MaskSensitiveInfo masks sensitive information like URLs, IPs, and domain names in a string
|
| 163 |
+
// Example:
|
| 164 |
+
// http://example.com -> http://***.com
|
| 165 |
+
// https://api.test.org/v1/users/123?key=secret -> https://***.org/***/***/?key=***
|
| 166 |
+
// https://sub.domain.co.uk/path/to/resource -> https://***.co.uk/***/***
|
| 167 |
+
// 192.168.1.1 -> ***.***.***.***
|
| 168 |
+
// openai.com -> ***.com
|
| 169 |
+
// www.openai.com -> ***.***.com
|
| 170 |
+
// api.openai.com -> ***.***.com
|
| 171 |
+
func MaskSensitiveInfo(str string) string {
|
| 172 |
+
// Mask URLs
|
| 173 |
+
urlPattern := regexp.MustCompile(`(http|https)://[^\s/$.?#].[^\s]*`)
|
| 174 |
+
str = urlPattern.ReplaceAllStringFunc(str, func(urlStr string) string {
|
| 175 |
+
u, err := url.Parse(urlStr)
|
| 176 |
+
if err != nil {
|
| 177 |
+
return urlStr
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
host := u.Host
|
| 181 |
+
if host == "" {
|
| 182 |
+
return urlStr
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
// Mask host with unified logic
|
| 186 |
+
maskedHost := maskHostForURL(host)
|
| 187 |
+
|
| 188 |
+
result := u.Scheme + "://" + maskedHost
|
| 189 |
+
|
| 190 |
+
// Mask path
|
| 191 |
+
if u.Path != "" && u.Path != "/" {
|
| 192 |
+
pathParts := strings.Split(strings.Trim(u.Path, "/"), "/")
|
| 193 |
+
maskedPathParts := make([]string, len(pathParts))
|
| 194 |
+
for i := range pathParts {
|
| 195 |
+
if pathParts[i] != "" {
|
| 196 |
+
maskedPathParts[i] = "***"
|
| 197 |
+
}
|
| 198 |
+
}
|
| 199 |
+
if len(maskedPathParts) > 0 {
|
| 200 |
+
result += "/" + strings.Join(maskedPathParts, "/")
|
| 201 |
+
}
|
| 202 |
+
} else if u.Path == "/" {
|
| 203 |
+
result += "/"
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
// Mask query parameters
|
| 207 |
+
if u.RawQuery != "" {
|
| 208 |
+
values, err := url.ParseQuery(u.RawQuery)
|
| 209 |
+
if err != nil {
|
| 210 |
+
// If can't parse query, just mask the whole query string
|
| 211 |
+
result += "?***"
|
| 212 |
+
} else {
|
| 213 |
+
maskedParams := make([]string, 0, len(values))
|
| 214 |
+
for key := range values {
|
| 215 |
+
maskedParams = append(maskedParams, key+"=***")
|
| 216 |
+
}
|
| 217 |
+
if len(maskedParams) > 0 {
|
| 218 |
+
result += "?" + strings.Join(maskedParams, "&")
|
| 219 |
+
}
|
| 220 |
+
}
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
return result
|
| 224 |
+
})
|
| 225 |
+
|
| 226 |
+
// Mask domain names without protocol (like openai.com, www.openai.com)
|
| 227 |
+
domainPattern := regexp.MustCompile(`\b(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}\b`)
|
| 228 |
+
str = domainPattern.ReplaceAllStringFunc(str, func(domain string) string {
|
| 229 |
+
return maskHostForPlainDomain(domain)
|
| 230 |
+
})
|
| 231 |
+
|
| 232 |
+
// Mask IP addresses
|
| 233 |
+
ipPattern := regexp.MustCompile(`\b(?:\d{1,3}\.){3}\d{1,3}\b`)
|
| 234 |
+
str = ipPattern.ReplaceAllString(str, "***.***.***.***")
|
| 235 |
+
|
| 236 |
+
return str
|
| 237 |
+
}
|
common/sys_log.go
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package common
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"fmt"
|
| 5 |
+
"os"
|
| 6 |
+
"time"
|
| 7 |
+
|
| 8 |
+
"github.com/gin-gonic/gin"
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
func SysLog(s string) {
|
| 12 |
+
t := time.Now()
|
| 13 |
+
_, _ = fmt.Fprintf(gin.DefaultWriter, "[SYS] %v | %s \n", t.Format("2006/01/02 - 15:04:05"), s)
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
func SysError(s string) {
|
| 17 |
+
t := time.Now()
|
| 18 |
+
_, _ = fmt.Fprintf(gin.DefaultErrorWriter, "[SYS] %v | %s \n", t.Format("2006/01/02 - 15:04:05"), s)
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
func FatalLog(v ...any) {
|
| 22 |
+
t := time.Now()
|
| 23 |
+
_, _ = fmt.Fprintf(gin.DefaultErrorWriter, "[FATAL] %v | %v \n", t.Format("2006/01/02 - 15:04:05"), v)
|
| 24 |
+
os.Exit(1)
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
func LogStartupSuccess(startTime time.Time, port string) {
|
| 28 |
+
|
| 29 |
+
duration := time.Since(startTime)
|
| 30 |
+
durationMs := duration.Milliseconds()
|
| 31 |
+
|
| 32 |
+
// Get network IPs
|
| 33 |
+
networkIps := GetNetworkIps()
|
| 34 |
+
|
| 35 |
+
// Print blank line for spacing
|
| 36 |
+
fmt.Fprintf(gin.DefaultWriter, "\n")
|
| 37 |
+
|
| 38 |
+
// Print the main success message
|
| 39 |
+
fmt.Fprintf(gin.DefaultWriter, " \033[32m%s %s\033[0m ready in %d ms\n", SystemName, Version, durationMs)
|
| 40 |
+
fmt.Fprintf(gin.DefaultWriter, "\n")
|
| 41 |
+
|
| 42 |
+
// Skip fancy startup message in container environments
|
| 43 |
+
if !IsRunningInContainer() {
|
| 44 |
+
// Print local URL
|
| 45 |
+
fmt.Fprintf(gin.DefaultWriter, " ➜ \033[1mLocal:\033[0m http://localhost:%s/\n", port)
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
// Print network URLs
|
| 49 |
+
for _, ip := range networkIps {
|
| 50 |
+
fmt.Fprintf(gin.DefaultWriter, " ➜ \033[1mNetwork:\033[0m http://%s:%s/\n", ip, port)
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
// Print blank line for spacing
|
| 54 |
+
fmt.Fprintf(gin.DefaultWriter, "\n")
|
| 55 |
+
}
|
common/topup-ratio.go
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package common
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"encoding/json"
|
| 5 |
+
)
|
| 6 |
+
|
| 7 |
+
var TopupGroupRatio = map[string]float64{
|
| 8 |
+
"default": 1,
|
| 9 |
+
"vip": 1,
|
| 10 |
+
"svip": 1,
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
func TopupGroupRatio2JSONString() string {
|
| 14 |
+
jsonBytes, err := json.Marshal(TopupGroupRatio)
|
| 15 |
+
if err != nil {
|
| 16 |
+
SysError("error marshalling model ratio: " + err.Error())
|
| 17 |
+
}
|
| 18 |
+
return string(jsonBytes)
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
func UpdateTopupGroupRatioByJSONString(jsonStr string) error {
|
| 22 |
+
TopupGroupRatio = make(map[string]float64)
|
| 23 |
+
return json.Unmarshal([]byte(jsonStr), &TopupGroupRatio)
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
func GetTopupGroupRatio(name string) float64 {
|
| 27 |
+
ratio, ok := TopupGroupRatio[name]
|
| 28 |
+
if !ok {
|
| 29 |
+
SysError("topup group ratio not found: " + name)
|
| 30 |
+
return 1
|
| 31 |
+
}
|
| 32 |
+
return ratio
|
| 33 |
+
}
|
common/totp.go
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package common
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"crypto/rand"
|
| 5 |
+
"fmt"
|
| 6 |
+
"os"
|
| 7 |
+
"strconv"
|
| 8 |
+
"strings"
|
| 9 |
+
|
| 10 |
+
"github.com/pquerna/otp"
|
| 11 |
+
"github.com/pquerna/otp/totp"
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
const (
|
| 15 |
+
// 备用码配置
|
| 16 |
+
BackupCodeLength = 8 // 备用码长度
|
| 17 |
+
BackupCodeCount = 4 // 生成备用码数量
|
| 18 |
+
|
| 19 |
+
// 限制配置
|
| 20 |
+
MaxFailAttempts = 5 // 最大失败尝试次数
|
| 21 |
+
LockoutDuration = 300 // 锁定时间(秒)
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
// GenerateTOTPSecret 生成TOTP密钥和配置
|
| 25 |
+
func GenerateTOTPSecret(accountName string) (*otp.Key, error) {
|
| 26 |
+
issuer := Get2FAIssuer()
|
| 27 |
+
return totp.Generate(totp.GenerateOpts{
|
| 28 |
+
Issuer: issuer,
|
| 29 |
+
AccountName: accountName,
|
| 30 |
+
Period: 30,
|
| 31 |
+
Digits: otp.DigitsSix,
|
| 32 |
+
Algorithm: otp.AlgorithmSHA1,
|
| 33 |
+
})
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
// ValidateTOTPCode 验证TOTP验证码
|
| 37 |
+
func ValidateTOTPCode(secret, code string) bool {
|
| 38 |
+
// 清理验证码格式
|
| 39 |
+
cleanCode := strings.ReplaceAll(code, " ", "")
|
| 40 |
+
if len(cleanCode) != 6 {
|
| 41 |
+
return false
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
// 验证验证码
|
| 45 |
+
return totp.Validate(cleanCode, secret)
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
// GenerateBackupCodes 生成备用恢复码
|
| 49 |
+
func GenerateBackupCodes() ([]string, error) {
|
| 50 |
+
codes := make([]string, BackupCodeCount)
|
| 51 |
+
|
| 52 |
+
for i := 0; i < BackupCodeCount; i++ {
|
| 53 |
+
code, err := generateRandomBackupCode()
|
| 54 |
+
if err != nil {
|
| 55 |
+
return nil, err
|
| 56 |
+
}
|
| 57 |
+
codes[i] = code
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
return codes, nil
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
// generateRandomBackupCode 生成单个备用码
|
| 64 |
+
func generateRandomBackupCode() (string, error) {
|
| 65 |
+
const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
| 66 |
+
code := make([]byte, BackupCodeLength)
|
| 67 |
+
|
| 68 |
+
for i := range code {
|
| 69 |
+
randomBytes := make([]byte, 1)
|
| 70 |
+
_, err := rand.Read(randomBytes)
|
| 71 |
+
if err != nil {
|
| 72 |
+
return "", err
|
| 73 |
+
}
|
| 74 |
+
code[i] = charset[int(randomBytes[0])%len(charset)]
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
// 格式化为 XXXX-XXXX 格式
|
| 78 |
+
return fmt.Sprintf("%s-%s", string(code[:4]), string(code[4:])), nil
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
// ValidateBackupCode 验证备用码格式
|
| 82 |
+
func ValidateBackupCode(code string) bool {
|
| 83 |
+
// 移除所有分隔符并转为大写
|
| 84 |
+
cleanCode := strings.ToUpper(strings.ReplaceAll(code, "-", ""))
|
| 85 |
+
if len(cleanCode) != BackupCodeLength {
|
| 86 |
+
return false
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
// 检查字符是否合法
|
| 90 |
+
for _, char := range cleanCode {
|
| 91 |
+
if !((char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9')) {
|
| 92 |
+
return false
|
| 93 |
+
}
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
return true
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
// NormalizeBackupCode 标准化备用码格式
|
| 100 |
+
func NormalizeBackupCode(code string) string {
|
| 101 |
+
cleanCode := strings.ToUpper(strings.ReplaceAll(code, "-", ""))
|
| 102 |
+
if len(cleanCode) == BackupCodeLength {
|
| 103 |
+
return fmt.Sprintf("%s-%s", cleanCode[:4], cleanCode[4:])
|
| 104 |
+
}
|
| 105 |
+
return code
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
// HashBackupCode 对备用码进行哈希
|
| 109 |
+
func HashBackupCode(code string) (string, error) {
|
| 110 |
+
normalizedCode := NormalizeBackupCode(code)
|
| 111 |
+
return Password2Hash(normalizedCode)
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
// Get2FAIssuer 获取2FA发行者名称
|
| 115 |
+
func Get2FAIssuer() string {
|
| 116 |
+
return SystemName
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
// getEnvOrDefault 获取环境变量或默认值
|
| 120 |
+
func getEnvOrDefault(key, defaultValue string) string {
|
| 121 |
+
if value, exists := os.LookupEnv(key); exists {
|
| 122 |
+
return value
|
| 123 |
+
}
|
| 124 |
+
return defaultValue
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
// ValidateNumericCode 验证数字验证码格式
|
| 128 |
+
func ValidateNumericCode(code string) (string, error) {
|
| 129 |
+
// 移除空格
|
| 130 |
+
code = strings.ReplaceAll(code, " ", "")
|
| 131 |
+
|
| 132 |
+
if len(code) != 6 {
|
| 133 |
+
return "", fmt.Errorf("验证码必须是6位数字")
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
// 检查是否为纯数字
|
| 137 |
+
if _, err := strconv.Atoi(code); err != nil {
|
| 138 |
+
return "", fmt.Errorf("验证码只能包含数字")
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
return code, nil
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
// GenerateQRCodeData 生成二维码数据
|
| 145 |
+
func GenerateQRCodeData(secret, username string) string {
|
| 146 |
+
issuer := Get2FAIssuer()
|
| 147 |
+
accountName := fmt.Sprintf("%s (%s)", username, issuer)
|
| 148 |
+
return fmt.Sprintf("otpauth://totp/%s:%s?secret=%s&issuer=%s&digits=6&period=30",
|
| 149 |
+
issuer, accountName, secret, issuer)
|
| 150 |
+
}
|
common/utils.go
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package common
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"bytes"
|
| 5 |
+
"context"
|
| 6 |
+
crand "crypto/rand"
|
| 7 |
+
"encoding/base64"
|
| 8 |
+
"encoding/json"
|
| 9 |
+
"fmt"
|
| 10 |
+
"html/template"
|
| 11 |
+
"io"
|
| 12 |
+
"log"
|
| 13 |
+
"math/big"
|
| 14 |
+
"math/rand"
|
| 15 |
+
"net"
|
| 16 |
+
"net/url"
|
| 17 |
+
"os"
|
| 18 |
+
"os/exec"
|
| 19 |
+
"runtime"
|
| 20 |
+
"strconv"
|
| 21 |
+
"strings"
|
| 22 |
+
"time"
|
| 23 |
+
|
| 24 |
+
"github.com/google/uuid"
|
| 25 |
+
"github.com/pkg/errors"
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
func OpenBrowser(url string) {
|
| 29 |
+
var err error
|
| 30 |
+
|
| 31 |
+
switch runtime.GOOS {
|
| 32 |
+
case "linux":
|
| 33 |
+
err = exec.Command("xdg-open", url).Start()
|
| 34 |
+
case "windows":
|
| 35 |
+
err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
|
| 36 |
+
case "darwin":
|
| 37 |
+
err = exec.Command("open", url).Start()
|
| 38 |
+
}
|
| 39 |
+
if err != nil {
|
| 40 |
+
log.Println(err)
|
| 41 |
+
}
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
func GetIp() (ip string) {
|
| 45 |
+
ips, err := net.InterfaceAddrs()
|
| 46 |
+
if err != nil {
|
| 47 |
+
log.Println(err)
|
| 48 |
+
return ip
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
for _, a := range ips {
|
| 52 |
+
if ipNet, ok := a.(*net.IPNet); ok && !ipNet.IP.IsLoopback() {
|
| 53 |
+
if ipNet.IP.To4() != nil {
|
| 54 |
+
ip = ipNet.IP.String()
|
| 55 |
+
if strings.HasPrefix(ip, "10") {
|
| 56 |
+
return
|
| 57 |
+
}
|
| 58 |
+
if strings.HasPrefix(ip, "172") {
|
| 59 |
+
return
|
| 60 |
+
}
|
| 61 |
+
if strings.HasPrefix(ip, "192.168") {
|
| 62 |
+
return
|
| 63 |
+
}
|
| 64 |
+
ip = ""
|
| 65 |
+
}
|
| 66 |
+
}
|
| 67 |
+
}
|
| 68 |
+
return
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
func GetNetworkIps() []string {
|
| 72 |
+
var networkIps []string
|
| 73 |
+
ips, err := net.InterfaceAddrs()
|
| 74 |
+
if err != nil {
|
| 75 |
+
log.Println(err)
|
| 76 |
+
return networkIps
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
for _, a := range ips {
|
| 80 |
+
if ipNet, ok := a.(*net.IPNet); ok && !ipNet.IP.IsLoopback() {
|
| 81 |
+
if ipNet.IP.To4() != nil {
|
| 82 |
+
ip := ipNet.IP.String()
|
| 83 |
+
// Include common private network ranges
|
| 84 |
+
if strings.HasPrefix(ip, "10.") ||
|
| 85 |
+
strings.HasPrefix(ip, "172.") ||
|
| 86 |
+
strings.HasPrefix(ip, "192.168.") {
|
| 87 |
+
networkIps = append(networkIps, ip)
|
| 88 |
+
}
|
| 89 |
+
}
|
| 90 |
+
}
|
| 91 |
+
}
|
| 92 |
+
return networkIps
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
// IsRunningInContainer detects if the application is running inside a container
|
| 96 |
+
func IsRunningInContainer() bool {
|
| 97 |
+
// Method 1: Check for .dockerenv file (Docker containers)
|
| 98 |
+
if _, err := os.Stat("/.dockerenv"); err == nil {
|
| 99 |
+
return true
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
// Method 2: Check cgroup for container indicators
|
| 103 |
+
if data, err := os.ReadFile("/proc/1/cgroup"); err == nil {
|
| 104 |
+
content := string(data)
|
| 105 |
+
if strings.Contains(content, "docker") ||
|
| 106 |
+
strings.Contains(content, "containerd") ||
|
| 107 |
+
strings.Contains(content, "kubepods") ||
|
| 108 |
+
strings.Contains(content, "/lxc/") {
|
| 109 |
+
return true
|
| 110 |
+
}
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
// Method 3: Check environment variables commonly set by container runtimes
|
| 114 |
+
containerEnvVars := []string{
|
| 115 |
+
"KUBERNETES_SERVICE_HOST",
|
| 116 |
+
"DOCKER_CONTAINER",
|
| 117 |
+
"container",
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
for _, envVar := range containerEnvVars {
|
| 121 |
+
if os.Getenv(envVar) != "" {
|
| 122 |
+
return true
|
| 123 |
+
}
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
// Method 4: Check if init process is not the traditional init
|
| 127 |
+
if data, err := os.ReadFile("/proc/1/comm"); err == nil {
|
| 128 |
+
comm := strings.TrimSpace(string(data))
|
| 129 |
+
// In containers, process 1 is often not "init" or "systemd"
|
| 130 |
+
if comm != "init" && comm != "systemd" {
|
| 131 |
+
// Additional check: if it's a common container entrypoint
|
| 132 |
+
if strings.Contains(comm, "docker") ||
|
| 133 |
+
strings.Contains(comm, "containerd") ||
|
| 134 |
+
strings.Contains(comm, "runc") {
|
| 135 |
+
return true
|
| 136 |
+
}
|
| 137 |
+
}
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
return false
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
var sizeKB = 1024
|
| 144 |
+
var sizeMB = sizeKB * 1024
|
| 145 |
+
var sizeGB = sizeMB * 1024
|
| 146 |
+
|
| 147 |
+
func Bytes2Size(num int64) string {
|
| 148 |
+
numStr := ""
|
| 149 |
+
unit := "B"
|
| 150 |
+
if num/int64(sizeGB) > 1 {
|
| 151 |
+
numStr = fmt.Sprintf("%.2f", float64(num)/float64(sizeGB))
|
| 152 |
+
unit = "GB"
|
| 153 |
+
} else if num/int64(sizeMB) > 1 {
|
| 154 |
+
numStr = fmt.Sprintf("%d", int(float64(num)/float64(sizeMB)))
|
| 155 |
+
unit = "MB"
|
| 156 |
+
} else if num/int64(sizeKB) > 1 {
|
| 157 |
+
numStr = fmt.Sprintf("%d", int(float64(num)/float64(sizeKB)))
|
| 158 |
+
unit = "KB"
|
| 159 |
+
} else {
|
| 160 |
+
numStr = fmt.Sprintf("%d", num)
|
| 161 |
+
}
|
| 162 |
+
return numStr + " " + unit
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
func Seconds2Time(num int) (time string) {
|
| 166 |
+
if num/31104000 > 0 {
|
| 167 |
+
time += strconv.Itoa(num/31104000) + " 年 "
|
| 168 |
+
num %= 31104000
|
| 169 |
+
}
|
| 170 |
+
if num/2592000 > 0 {
|
| 171 |
+
time += strconv.Itoa(num/2592000) + " 个月 "
|
| 172 |
+
num %= 2592000
|
| 173 |
+
}
|
| 174 |
+
if num/86400 > 0 {
|
| 175 |
+
time += strconv.Itoa(num/86400) + " 天 "
|
| 176 |
+
num %= 86400
|
| 177 |
+
}
|
| 178 |
+
if num/3600 > 0 {
|
| 179 |
+
time += strconv.Itoa(num/3600) + " 小时 "
|
| 180 |
+
num %= 3600
|
| 181 |
+
}
|
| 182 |
+
if num/60 > 0 {
|
| 183 |
+
time += strconv.Itoa(num/60) + " 分钟 "
|
| 184 |
+
num %= 60
|
| 185 |
+
}
|
| 186 |
+
time += strconv.Itoa(num) + " 秒"
|
| 187 |
+
return
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
func Interface2String(inter interface{}) string {
|
| 191 |
+
switch inter.(type) {
|
| 192 |
+
case string:
|
| 193 |
+
return inter.(string)
|
| 194 |
+
case int:
|
| 195 |
+
return fmt.Sprintf("%d", inter.(int))
|
| 196 |
+
case float64:
|
| 197 |
+
return fmt.Sprintf("%f", inter.(float64))
|
| 198 |
+
case bool:
|
| 199 |
+
if inter.(bool) {
|
| 200 |
+
return "true"
|
| 201 |
+
} else {
|
| 202 |
+
return "false"
|
| 203 |
+
}
|
| 204 |
+
case nil:
|
| 205 |
+
return ""
|
| 206 |
+
}
|
| 207 |
+
return fmt.Sprintf("%v", inter)
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
func UnescapeHTML(x string) interface{} {
|
| 211 |
+
return template.HTML(x)
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
func IntMax(a int, b int) int {
|
| 215 |
+
if a >= b {
|
| 216 |
+
return a
|
| 217 |
+
} else {
|
| 218 |
+
return b
|
| 219 |
+
}
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
func IsIP(s string) bool {
|
| 223 |
+
ip := net.ParseIP(s)
|
| 224 |
+
return ip != nil
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
func GetUUID() string {
|
| 228 |
+
code := uuid.New().String()
|
| 229 |
+
code = strings.Replace(code, "-", "", -1)
|
| 230 |
+
return code
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
const keyChars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
| 234 |
+
|
| 235 |
+
func init() {
|
| 236 |
+
rand.New(rand.NewSource(time.Now().UnixNano()))
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
func GenerateRandomCharsKey(length int) (string, error) {
|
| 240 |
+
b := make([]byte, length)
|
| 241 |
+
maxI := big.NewInt(int64(len(keyChars)))
|
| 242 |
+
|
| 243 |
+
for i := range b {
|
| 244 |
+
n, err := crand.Int(crand.Reader, maxI)
|
| 245 |
+
if err != nil {
|
| 246 |
+
return "", err
|
| 247 |
+
}
|
| 248 |
+
b[i] = keyChars[n.Int64()]
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
return string(b), nil
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
func GenerateRandomKey(length int) (string, error) {
|
| 255 |
+
bytes := make([]byte, length*3/4) // 对于48位的输出,这里应该是36
|
| 256 |
+
if _, err := crand.Read(bytes); err != nil {
|
| 257 |
+
return "", err
|
| 258 |
+
}
|
| 259 |
+
return base64.StdEncoding.EncodeToString(bytes), nil
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
func GenerateKey() (string, error) {
|
| 263 |
+
//rand.Seed(time.Now().UnixNano())
|
| 264 |
+
return GenerateRandomCharsKey(48)
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
func GetRandomInt(max int) int {
|
| 268 |
+
//rand.Seed(time.Now().UnixNano())
|
| 269 |
+
return rand.Intn(max)
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
func GetTimestamp() int64 {
|
| 273 |
+
return time.Now().Unix()
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
func GetTimeString() string {
|
| 277 |
+
now := time.Now()
|
| 278 |
+
return fmt.Sprintf("%s%d", now.Format("20060102150405"), now.UnixNano()%1e9)
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
func Max(a int, b int) int {
|
| 282 |
+
if a >= b {
|
| 283 |
+
return a
|
| 284 |
+
} else {
|
| 285 |
+
return b
|
| 286 |
+
}
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
func MessageWithRequestId(message string, id string) string {
|
| 290 |
+
return fmt.Sprintf("%s (request id: %s)", message, id)
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
func RandomSleep() {
|
| 294 |
+
// Sleep for 0-3000 ms
|
| 295 |
+
time.Sleep(time.Duration(rand.Intn(3000)) * time.Millisecond)
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
func GetPointer[T any](v T) *T {
|
| 299 |
+
return &v
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
func Any2Type[T any](data any) (T, error) {
|
| 303 |
+
var zero T
|
| 304 |
+
bytes, err := json.Marshal(data)
|
| 305 |
+
if err != nil {
|
| 306 |
+
return zero, err
|
| 307 |
+
}
|
| 308 |
+
var res T
|
| 309 |
+
err = json.Unmarshal(bytes, &res)
|
| 310 |
+
if err != nil {
|
| 311 |
+
return zero, err
|
| 312 |
+
}
|
| 313 |
+
return res, nil
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
// SaveTmpFile saves data to a temporary file. The filename would be apppended with a random string.
|
| 317 |
+
func SaveTmpFile(filename string, data io.Reader) (string, error) {
|
| 318 |
+
f, err := os.CreateTemp(os.TempDir(), filename)
|
| 319 |
+
if err != nil {
|
| 320 |
+
return "", errors.Wrapf(err, "failed to create temporary file %s", filename)
|
| 321 |
+
}
|
| 322 |
+
defer f.Close()
|
| 323 |
+
|
| 324 |
+
_, err = io.Copy(f, data)
|
| 325 |
+
if err != nil {
|
| 326 |
+
return "", errors.Wrapf(err, "failed to copy data to temporary file %s", filename)
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
return f.Name(), nil
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
// GetAudioDuration returns the duration of an audio file in seconds.
|
| 333 |
+
func GetAudioDuration(ctx context.Context, filename string, ext string) (float64, error) {
|
| 334 |
+
// ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 {{input}}
|
| 335 |
+
c := exec.CommandContext(ctx, "ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", filename)
|
| 336 |
+
output, err := c.Output()
|
| 337 |
+
if err != nil {
|
| 338 |
+
return 0, errors.Wrap(err, "failed to get audio duration")
|
| 339 |
+
}
|
| 340 |
+
durationStr := string(bytes.TrimSpace(output))
|
| 341 |
+
if durationStr == "N/A" {
|
| 342 |
+
// Create a temporary output file name
|
| 343 |
+
tmpFp, err := os.CreateTemp("", "audio-*"+ext)
|
| 344 |
+
if err != nil {
|
| 345 |
+
return 0, errors.Wrap(err, "failed to create temporary file")
|
| 346 |
+
}
|
| 347 |
+
tmpName := tmpFp.Name()
|
| 348 |
+
// Close immediately so ffmpeg can open the file on Windows.
|
| 349 |
+
_ = tmpFp.Close()
|
| 350 |
+
defer os.Remove(tmpName)
|
| 351 |
+
|
| 352 |
+
// ffmpeg -y -i filename -vcodec copy -acodec copy <tmpName>
|
| 353 |
+
ffmpegCmd := exec.CommandContext(ctx, "ffmpeg", "-y", "-i", filename, "-vcodec", "copy", "-acodec", "copy", tmpName)
|
| 354 |
+
if err := ffmpegCmd.Run(); err != nil {
|
| 355 |
+
return 0, errors.Wrap(err, "failed to run ffmpeg")
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
// Recalculate the duration of the new file
|
| 359 |
+
c = exec.CommandContext(ctx, "ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", tmpName)
|
| 360 |
+
output, err := c.Output()
|
| 361 |
+
if err != nil {
|
| 362 |
+
return 0, errors.Wrap(err, "failed to get audio duration after ffmpeg")
|
| 363 |
+
}
|
| 364 |
+
durationStr = string(bytes.TrimSpace(output))
|
| 365 |
+
}
|
| 366 |
+
return strconv.ParseFloat(durationStr, 64)
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
// BuildURL concatenates base and endpoint, returns the complete url string
|
| 370 |
+
func BuildURL(base string, endpoint string) string {
|
| 371 |
+
u, err := url.Parse(base)
|
| 372 |
+
if err != nil {
|
| 373 |
+
return base + endpoint
|
| 374 |
+
}
|
| 375 |
+
end := endpoint
|
| 376 |
+
if end == "" {
|
| 377 |
+
end = "/"
|
| 378 |
+
}
|
| 379 |
+
ref, err := url.Parse(end)
|
| 380 |
+
if err != nil {
|
| 381 |
+
return base + endpoint
|
| 382 |
+
}
|
| 383 |
+
return u.ResolveReference(ref).String()
|
| 384 |
+
}
|
common/validate.go
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package common
|
| 2 |
+
|
| 3 |
+
import "github.com/go-playground/validator/v10"
|
| 4 |
+
|
| 5 |
+
var Validate *validator.Validate
|
| 6 |
+
|
| 7 |
+
func init() {
|
| 8 |
+
Validate = validator.New()
|
| 9 |
+
}
|
common/verification.go
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package common
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"github.com/google/uuid"
|
| 5 |
+
"strings"
|
| 6 |
+
"sync"
|
| 7 |
+
"time"
|
| 8 |
+
)
|
| 9 |
+
|
| 10 |
+
type verificationValue struct {
|
| 11 |
+
code string
|
| 12 |
+
time time.Time
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
const (
|
| 16 |
+
EmailVerificationPurpose = "v"
|
| 17 |
+
PasswordResetPurpose = "r"
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
var verificationMutex sync.Mutex
|
| 21 |
+
var verificationMap map[string]verificationValue
|
| 22 |
+
var verificationMapMaxSize = 10
|
| 23 |
+
var VerificationValidMinutes = 10
|
| 24 |
+
|
| 25 |
+
func GenerateVerificationCode(length int) string {
|
| 26 |
+
code := uuid.New().String()
|
| 27 |
+
code = strings.Replace(code, "-", "", -1)
|
| 28 |
+
if length == 0 {
|
| 29 |
+
return code
|
| 30 |
+
}
|
| 31 |
+
return code[:length]
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
func RegisterVerificationCodeWithKey(key string, code string, purpose string) {
|
| 35 |
+
verificationMutex.Lock()
|
| 36 |
+
defer verificationMutex.Unlock()
|
| 37 |
+
verificationMap[purpose+key] = verificationValue{
|
| 38 |
+
code: code,
|
| 39 |
+
time: time.Now(),
|
| 40 |
+
}
|
| 41 |
+
if len(verificationMap) > verificationMapMaxSize {
|
| 42 |
+
removeExpiredPairs()
|
| 43 |
+
}
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
func VerifyCodeWithKey(key string, code string, purpose string) bool {
|
| 47 |
+
verificationMutex.Lock()
|
| 48 |
+
defer verificationMutex.Unlock()
|
| 49 |
+
value, okay := verificationMap[purpose+key]
|
| 50 |
+
now := time.Now()
|
| 51 |
+
if !okay || int(now.Sub(value.time).Seconds()) >= VerificationValidMinutes*60 {
|
| 52 |
+
return false
|
| 53 |
+
}
|
| 54 |
+
return code == value.code
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
func DeleteKey(key string, purpose string) {
|
| 58 |
+
verificationMutex.Lock()
|
| 59 |
+
defer verificationMutex.Unlock()
|
| 60 |
+
delete(verificationMap, purpose+key)
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
// no lock inside, so the caller must lock the verificationMap before calling!
|
| 64 |
+
func removeExpiredPairs() {
|
| 65 |
+
now := time.Now()
|
| 66 |
+
for key := range verificationMap {
|
| 67 |
+
if int(now.Sub(verificationMap[key].time).Seconds()) >= VerificationValidMinutes*60 {
|
| 68 |
+
delete(verificationMap, key)
|
| 69 |
+
}
|
| 70 |
+
}
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
func init() {
|
| 74 |
+
verificationMutex.Lock()
|
| 75 |
+
defer verificationMutex.Unlock()
|
| 76 |
+
verificationMap = make(map[string]verificationValue)
|
| 77 |
+
}
|
constant/README.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# constant 包 (`/constant`)
|
| 2 |
+
|
| 3 |
+
该目录仅用于放置全局可复用的**常量定义**,不包含任何业务逻辑或依赖关系。
|
| 4 |
+
|
| 5 |
+
## 当前文件
|
| 6 |
+
|
| 7 |
+
| 文件 | 说明 |
|
| 8 |
+
|----------------------|---------------------------------------------------------------------|
|
| 9 |
+
| `azure.go` | 定义与 Azure 相关的全局常量,如 `AzureNoRemoveDotTime`(控制删除 `.` 的截止时间)。 |
|
| 10 |
+
| `cache_key.go` | 缓存键格式字符串及 Token 相关字段常量,统一缓存命名规则。 |
|
| 11 |
+
| `channel_setting.go` | Channel 级别的设置键,如 `proxy`、`force_format` 等。 |
|
| 12 |
+
| `context_key.go` | 定义 `ContextKey` 类型以及在整个项目中使用的上下文键常量(请求时间、Token/Channel/User 相关信息等)。 |
|
| 13 |
+
| `env.go` | 环境配置相关的全局变量,在启动阶段根据配置文件或环境变量注入。 |
|
| 14 |
+
| `finish_reason.go` | OpenAI/GPT 请求返回的 `finish_reason` 字符串常量集合。 |
|
| 15 |
+
| `midjourney.go` | Midjourney 相关错误码及动作(Action)常量与模型到动作的映射表。 |
|
| 16 |
+
| `setup.go` | 标识项目是否已完成初始化安装 (`Setup` 布尔值)。 |
|
| 17 |
+
| `task.go` | 各种任务(Task)平台、动作常量及模型与动作映射表,如 Suno、Midjourney 等。 |
|
| 18 |
+
| `user_setting.go` | 用户设置相关键常量以及通知类型(Email/Webhook)等。 |
|
| 19 |
+
|
| 20 |
+
## 使用约定
|
| 21 |
+
|
| 22 |
+
1. `constant` 包**只能被其他包引用**(import),**禁止在此包中引用项目内的其他自定义包**。如确有需要,仅允许引用 **Go 标准库**。
|
| 23 |
+
2. 不允许在此目录内编写任何与业务流程、数据库操作、第三方服务调用等相关的逻辑代码。
|
| 24 |
+
3. 新增类型时,请保持命名语义清晰,并在本 README 的 **当前文件** 表格中补充说明,确保团队成员能够快速了解其用途。
|
| 25 |
+
|
| 26 |
+
> ⚠️ 违反以上约定将导致包之间产生不必要的耦合,影响代码可维护性与可测试性。请在提交代码前自行检查。
|
constant/api_type.go
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package constant
|
| 2 |
+
|
| 3 |
+
const (
|
| 4 |
+
APITypeOpenAI = iota
|
| 5 |
+
APITypeAnthropic
|
| 6 |
+
APITypePaLM
|
| 7 |
+
APITypeBaidu
|
| 8 |
+
APITypeZhipu
|
| 9 |
+
APITypeAli
|
| 10 |
+
APITypeXunfei
|
| 11 |
+
APITypeAIProxyLibrary
|
| 12 |
+
APITypeTencent
|
| 13 |
+
APITypeGemini
|
| 14 |
+
APITypeZhipuV4
|
| 15 |
+
APITypeOllama
|
| 16 |
+
APITypePerplexity
|
| 17 |
+
APITypeAws
|
| 18 |
+
APITypeCohere
|
| 19 |
+
APITypeDify
|
| 20 |
+
APITypeJina
|
| 21 |
+
APITypeCloudflare
|
| 22 |
+
APITypeSiliconFlow
|
| 23 |
+
APITypeVertexAi
|
| 24 |
+
APITypeMistral
|
| 25 |
+
APITypeDeepSeek
|
| 26 |
+
APITypeMokaAI
|
| 27 |
+
APITypeVolcEngine
|
| 28 |
+
APITypeBaiduV2
|
| 29 |
+
APITypeOpenRouter
|
| 30 |
+
APITypeXinference
|
| 31 |
+
APITypeXai
|
| 32 |
+
APITypeCoze
|
| 33 |
+
APITypeJimeng
|
| 34 |
+
APITypeMoonshot
|
| 35 |
+
APITypeSubmodel
|
| 36 |
+
APITypeDummy // this one is only for count, do not add any channel after this
|
| 37 |
+
)
|