Initial commit
This commit is contained in:
37
.air.toml
Normal file
37
.air.toml
Normal file
@@ -0,0 +1,37 @@
|
||||
# .air.toml
|
||||
|
||||
# [_meta] 部分是为了让IDE(如VSCode)知道这是TOML文件,可选
|
||||
[_meta]
|
||||
"version" = "v1.49.0"
|
||||
|
||||
# 工作目录,"." 表示项目根目录
|
||||
root = "."
|
||||
# 临时文件目录,air会在这里生成可执行文件
|
||||
tmp_dir = "tmp"
|
||||
|
||||
[build]
|
||||
# 编译的入口文件,请确保路径与您的项目结构一致
|
||||
cmd = "go build -o ./tmp/main.exe ./cmd/server"
|
||||
# cmd = "cmd /c build.bat"
|
||||
# 编译后生成的可执行文件
|
||||
bin = "tmp/main.exe"
|
||||
# 监视以下后缀名的文件,一旦变动就触发重新编译
|
||||
# include_ext = ["go", "tpl", "tmpl", "html", "css", "js"]
|
||||
include_ext = ["go", "tpl", "tmpl", "html"]
|
||||
# 排除以下目录,避免不必要地重载
|
||||
exclude_dir = ["assets", "tmp", "vendor", "web/static/images"]
|
||||
# 发生构建错误时,只打印日志而不退出
|
||||
stop_on_error = false
|
||||
# 给构建日志加上一个漂亮的前缀,方便识别。
|
||||
log = "air-build.log"
|
||||
# 增加一点延迟,防止文件系统在保存瞬间触发多次事件。
|
||||
delay = 1000 # ms
|
||||
|
||||
[log]
|
||||
# 日志输出带时间
|
||||
time = true
|
||||
color = true
|
||||
|
||||
[misc]
|
||||
# 删除临时文件
|
||||
clean_on_exit = true
|
||||
26
.gitattributes
vendored
Normal file
26
.gitattributes
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
# [文本统一规范]
|
||||
|
||||
* text=auto
|
||||
|
||||
*.go text
|
||||
*.js text
|
||||
*.css text
|
||||
*.html text
|
||||
*.json text
|
||||
*.md text
|
||||
*.xml text
|
||||
*.yml text
|
||||
*.yaml text
|
||||
Dockerfile text
|
||||
Makefile text
|
||||
go.mod text
|
||||
go.sum text
|
||||
|
||||
*.sh text eol=lf
|
||||
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.wasm binary
|
||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
*.db
|
||||
|
||||
.env
|
||||
143
ARCHITECTURE.md
Normal file
143
ARCHITECTURE.md
Normal file
@@ -0,0 +1,143 @@
|
||||
# M:N 架构升级与性能优化说明书
|
||||
|
||||
## 1. 引言与背景
|
||||
|
||||
### 1.1 初始状态
|
||||
|
||||
项目初期,API 密钥(`APIKey`)与密钥组(`KeyGroup`)之间采用的是 **一对多 (1:N)** 的数据模型。在这种设计下,一个 `APIKey` 实体通过 `group_id` 字段直接隶属于一个 `KeyGroup`。
|
||||
|
||||
### 1.2 核心痛点
|
||||
|
||||
随着业务发展,`1:N` 模型的局限性日益凸显:
|
||||
|
||||
1. **数据冗余**: 同一个 API 密钥若需在不同场景(分组)下使用,必须在数据库中创建多条重复的记录,造成存储浪费。
|
||||
2. **管理复杂**: 更新一个通用密钥的状态需要在多个副本之间同步,操作繁琐且容易出错。
|
||||
3. **统计失真**: 无法准确统计一个物理密钥的真实使用情况,因为它在系统中表现为多个独立实体。
|
||||
|
||||
### 1.3 重构目标
|
||||
|
||||
为解决上述问题,我们启动了本次架构升级,核心目标是将数据模型从 `1:N` 重构为 **多对多 (M:N)**。这将允许一个 `APIKey` 实体被多个 `KeyGroup` 共享和复用,从根本上解决数据冗余和管理复杂性的问题。
|
||||
|
||||
---
|
||||
|
||||
## 2. 核心设计:M:N 架构
|
||||
|
||||
### 2.1 新数据模型
|
||||
|
||||
新的 `M:N` 模型通过引入一个中间关联表来解除 `APIKey` 和 `KeyGroup` 之间的直接耦合。
|
||||
|
||||
* **`apikeys` 表**: 只存储 `APIKey` 的唯一信息(如 `api_key` 值、状态、错误统计等),不再包含 `group_id` 字段。
|
||||
* **`key_groups` 表**: 保持不变,存储分组信息。
|
||||
* **`keygroup_apikey_mappings` 表 (新增)**: 这是实现 `M:N` 关系的核心。它包含 `key_group_id` 和 `api_key_id` 两个外键,用于记录 `APIKey` 和 `KeyGroup` 之间的关联关系。
|
||||
|
||||
### 2.2 GORM 模型调整
|
||||
|
||||
我们对 `internal/models/models.go` 中的 GORM 模型进行了相应调整:
|
||||
|
||||
```go
|
||||
// APIKey 模型移除了 GroupID 字段
|
||||
type APIKey struct {
|
||||
ID uint `gorm:"primarykey"`
|
||||
APIKey string `gorm:"column:api_key;uniqueIndex;not null"`
|
||||
KeyGroups []*KeyGroup `gorm:"many2many:keygroup_apikey_mappings;"` // 新增 M:N 关系定义
|
||||
}
|
||||
|
||||
// KeyGroup 模型新增了 M:N 关系定义
|
||||
type KeyGroup struct {
|
||||
ID uint `gorm:"primarykey"`
|
||||
Name string `gorm:"uniqueIndex;not null"`
|
||||
APIKeys []*APIKey `gorm:"many2many:keygroup_apikey_mappings;"`
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 高性能缓存架构
|
||||
|
||||
为了在 `M:N` 模型下依然保持极高的读写性能,我们设计了一套三层复合缓存结构,存储在 Redis (或内存 Store) 中。
|
||||
|
||||
### 3.1 对象缓存 (HASH)
|
||||
|
||||
* **键 (Key)**: `key:{id}`
|
||||
* **值 (Value)**: `APIKey` 实体的序列化数据 (如 `api_key` 值, `status` 等)。
|
||||
* **作用**: 提供对单个 `APIKey` 详细信息的 `O(1)` 快速访问。
|
||||
|
||||
### 3.2 正向索引 (LIST)
|
||||
|
||||
* **键 (Key)**: `group:{id}:keys:active`
|
||||
* **值 (Value)**: 一个 `APIKey` ID 的列表。
|
||||
* **作用**: 这是 API 代理服务的核心读路径。通过对该列表执行 `LMOVE` (由 `store.Rotate` 封装) 原子操作,我们可以实现高效的、轮询式的 (`Round-Robin`) 活跃密钥选择。
|
||||
|
||||
### 3.3 反向索引 (SET)
|
||||
|
||||
* **键 (Key)**: `key:{id}:groups`
|
||||
* **值 (Value)**: 一个包含所有关联 `KeyGroup` ID 的集合。
|
||||
* **作用**: 这是 `M:N` 架构下的关键设计。当一个 `APIKey` 的状态发生变化时(例如,从 `active` 变为 `disabled`),我们能通过此索引在 `O(1)` 时间内找到所有受影响的 `KeyGroup`,进而精确地更新相关的 `group:{id}:keys:active` 列表,实现了高效的缓存同步。
|
||||
|
||||
这套缓存架构确保了核心的密钥读取操作性能不受影响,同时优雅地解决了 `M:N` 模型下状态变更的“扇出”(fan-out) 更新难题。
|
||||
|
||||
---
|
||||
|
||||
## 4. 分层架构重构
|
||||
|
||||
为适配新的数据和缓存模型,我们对应用各层进行了自底向上的重构。
|
||||
|
||||
### 4.1 Repository 层
|
||||
|
||||
`internal/repository/repository.go` 作为数据访问层,改动最为核心:
|
||||
|
||||
* **查询变更**: 所有原先基于 `group_id` 的查询,现在都改为通过 `JOIN` `keygroup_apikey_mappings` 关联表来完成。
|
||||
* **新增 M:N 操作**: 增加了如 `LinkKeysToGroup`, `UnlinkKeysFromGroup`, `GetGroupsForKey` 等直接操作关联关系的方法。
|
||||
* **缓存逻辑更新**: `LoadAllKeysToStore`, `updateStoreCacheForKey`, `removeStoreCacheForKey` 等缓存辅助函数被重写,以正确地维护上述三种缓存结构。
|
||||
|
||||
### 4.2 Service 层
|
||||
|
||||
`internal/service/*.go` 作为业务逻辑层,主要改动集中在事件处理和状态同步:
|
||||
|
||||
* **事件驱动**: 当一个 `APIKey` 状态变更时,`APIKeyService` 会发布一个 `KeyStatusChangedEvent` 事件。
|
||||
* **状态同步**: `StatsService` 等其他服务会订阅此事件,并根据事件内容更新自身的统计数据(如 `group_stats`),实现了服务间的解耦。
|
||||
* **扇出通知**: `publishStatusChangeEvents` 辅助函数会利用缓存中的“反向索引”(`key:{id}:groups`),为所有受影响的 `KeyGroup` 发布状态变更事件。
|
||||
|
||||
### 4.3 Handler & Router 层
|
||||
|
||||
`internal/handlers/*.go` 和 `internal/router/router.go` 的改动相对较小,主要是适配 `Service` 层接口的变更,确保 API 的输入输出符合新的业务逻辑。
|
||||
|
||||
---
|
||||
|
||||
## 5. 数据迁移方案
|
||||
|
||||
从 `1:N` 迁移到 `M:N` 需要一次性的数据迁移。我们为此编写了 `scripts/migrate_mn.go` 脚本。
|
||||
|
||||
* **核心逻辑**:
|
||||
1. **创建新表**: 自动创建 `keygroup_apikey_mappings` 关联表。
|
||||
2. **去重 `apikeys`**: 遍历旧的 `apikeys` 表,将重复的 `api_key` 值合并为单个实体,并记录旧 ID 到新 ID 的映射。
|
||||
3. **填充关联表**: 根据旧 `apikeys` 表中的 `group_id` 和 `api_key` 值,在新 `keygroup_apikey_mappings` 表中创建正确的关联关系。
|
||||
4. **清理旧数据**: 删除旧的、有冗余的 `apikeys` 表,并将去重后的新表重命名。
|
||||
* **健壮性**: 脚本被设计为幂等的,并包含多重检查,以防止在已迁移或部分迁移的数据库上重复执行。
|
||||
* **数据库兼容性**: 脚本使用 GORM 的 `clause` 来构建插入语句,避免了因原生 SQL 语法差异导致的数据库兼容性问题(如 SQLite 和 PostgreSQL)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 最终性能与健壮性优化
|
||||
|
||||
在完成核心重构后,我们借鉴业界最佳实践,实施了以下两项关键优化:
|
||||
|
||||
### 6.1 Redis Pipeline 优化
|
||||
|
||||
* **问题**: 在 `LoadAllKeysToStore` 或更新一个 Key 状态时,需要执行多个独立的 Redis 命令,导致多次网络往返(RTT)。
|
||||
* **解决方案**: 我们在 `store` 抽象层引入了 `Pipeliner` 接口。在 `repository` 中,所有多步缓存操作(如 `HSet` + `SAdd` + `LRem` + `LPush`)都被重构为使用 `Pipeline`。这会将多个命令打包成一次网络请求,显著降低了批量操作的延迟。
|
||||
|
||||
### 6.2 异步状态更新与事务重试
|
||||
|
||||
* **问题**:
|
||||
1. 来自管理后台的手动状态更新是同步阻塞的,影响 UI 响应速度。
|
||||
2. 高并发下,数据库事务可能因 `database is locked` 等瞬时错误而失败。
|
||||
* **解决方案**:
|
||||
1. **异步化**: 我们将 `APIKeyService` 中的 `UpdateAPIKey` 方法的核心逻辑移入一个后台 `goroutine`,使其成为一个“即发即忘”的非阻塞操作。
|
||||
2. **事务重试**: 我们在 `repository` 层实现了一个 `executeTransactionWithRetry` 辅助函数,当事务遇到可重试的锁错误时,会进行带**随机抖动 (Jitter)** 的延时重试,极大地增强了系统的健壮性。
|
||||
|
||||
---
|
||||
|
||||
## 7. 总结
|
||||
|
||||
本次架构升级成功地将系统从受限的 `1:N` 模型演进为灵活、可扩展的 `M:N` 模型。通过精心设计的缓存架构、清晰的分层重构、可靠的数据迁移方案以及最终的性能与健壮性优化,新系统不仅解决了旧架构的核心痛点,还在性能、稳定性和可维护性方面达到了更高的标准。
|
||||
40
build.bat
Normal file
40
build.bat
Normal file
@@ -0,0 +1,40 @@
|
||||
@echo off
|
||||
setlocal
|
||||
|
||||
:: =========================================
|
||||
:: build.bat - Automated Build Script (ASCII-Safe Windows Version)
|
||||
:: =========================================
|
||||
|
||||
:: Step 1: Announce that the build process has started.
|
||||
echo.
|
||||
echo [AIR] ^> Build process initiated...
|
||||
|
||||
pushd "%~dp0"
|
||||
|
||||
:: Step 2: [CORE] Compile the latest stylesheet using Tailwind CSS.
|
||||
echo [AIR] ^> Compiling Tailwind CSS...
|
||||
tailwindcss -i ./web/static/css/input.css -o ./web/static/css/output.css --minify
|
||||
|
||||
:: Step 3: Check if Tailwind compilation was successful.
|
||||
if %errorlevel% neq 0 (
|
||||
echo [AIR] ^> !!! Tailwind CSS compilation FAILED. Halting build.
|
||||
popd
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
:: Step 4: After CSS is confirmed to be up-to-date, build the Go application.
|
||||
echo [AIR] ^> Building Go application (main.exe)...
|
||||
go build -o ./tmp/main.exe ./cmd/server
|
||||
|
||||
:: Step 5: Check if Go compilation was successful.
|
||||
if %errorlevel% neq 0 (
|
||||
echo [AIR] ^> !!! Go application build FAILED.
|
||||
exit /b %errorlevel%
|
||||
)
|
||||
|
||||
:: Step 6: Report that all tasks have been completed successfully.
|
||||
echo [AIR] ^> Build finished successfully.
|
||||
|
||||
popd
|
||||
echo.
|
||||
endlocal
|
||||
20
cmd/server/main.go
Normal file
20
cmd/server/main.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"gemini-balancer/internal/app"
|
||||
"gemini-balancer/internal/container"
|
||||
"log"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cont, err := container.BuildContainer()
|
||||
if err != nil {
|
||||
log.Fatalf("FATAL: Failed to build dependency container: %v", err)
|
||||
}
|
||||
err = cont.Invoke(func(application *app.App) error {
|
||||
return application.Run()
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("FATAL: Error during application execution: %v", err)
|
||||
}
|
||||
}
|
||||
25
config.yaml
Normal file
25
config.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# 数据库配置
|
||||
database:
|
||||
# 类型: sqlite, postgres, mysql
|
||||
type: "sqlite"
|
||||
# 数据源名称 (DSN)
|
||||
# 对于 sqlite, 这只是一个文件路径
|
||||
dsn: "gemini-balancer.db"
|
||||
|
||||
# 服务器配置
|
||||
server:
|
||||
port: "9000"
|
||||
|
||||
# 日志级别
|
||||
log:
|
||||
level: "info"
|
||||
|
||||
redis:
|
||||
dsn: "redis://localhost:6379/0"
|
||||
|
||||
session_secret: "a-very-long-and-super-secure-random-string-for-session-encryption-change-this" # [ADD]
|
||||
|
||||
# [NEW] The master key for encrypting API keys.
|
||||
# MUST be 32 bytes (64 hex characters).
|
||||
# It is STRONGLY RECOMMENDED to set this via an environment variable instead (ENCRYPTION_KEY).
|
||||
encryption_key: "c7e0b5f8a2d1c9e8b3a4f5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6"
|
||||
27
dev.bat
Normal file
27
dev.bat
Normal file
@@ -0,0 +1,27 @@
|
||||
@echo off
|
||||
setlocal
|
||||
echo =========================================
|
||||
echo Starting DEVELOPMENT WATCH mode...
|
||||
echo =========================================
|
||||
echo Press Ctrl+C to stop.
|
||||
echo.
|
||||
:: Start Tailwind in watch mode in the background
|
||||
echo [DEV] Starting Tailwind CSS watcher...
|
||||
start "Tailwind Watcher" .\tailwindcss.exe -i .\frontend\input.css -o .\web\static\css\output.css --watch
|
||||
|
||||
if %errorlevel% neq 0 (
|
||||
echo [AIR] ^> !!! Tailwind CSS compilation FAILED. Halting build.
|
||||
popd
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
:: Start esbuild in watch mode in the foreground
|
||||
::.\esbuild.exe .\frontend\js\main.js --bundle --outfile=.\web\static\js\app.js --watch
|
||||
echo [DEV] Starting JavaScript watcher with Code Splitting...
|
||||
.\esbuild.exe .\frontend\js\main.js ^
|
||||
--bundle ^
|
||||
--outdir=.\web\static\js ^
|
||||
--splitting ^
|
||||
--format=esm ^
|
||||
--watch
|
||||
endlocal
|
||||
753
frontend/input.css
Normal file
753
frontend/input.css
Normal file
@@ -0,0 +1,753 @@
|
||||
/* static/css/input.css */
|
||||
@import "tailwindcss";
|
||||
|
||||
/* =================================================================== */
|
||||
/* [核心] 定义 shadcn/ui 的设计系统变量 */
|
||||
/* =================================================================== */
|
||||
@layer base {
|
||||
/* 亮色模式 */
|
||||
:root {
|
||||
--background: theme(colors.white);
|
||||
--foreground: theme(colors.zinc.900);
|
||||
|
||||
--muted: theme(colors.zinc.100);
|
||||
--muted-foreground: theme(colors.zinc.500);
|
||||
|
||||
--primary: theme(colors.blue.600);
|
||||
--primary-foreground: theme(colors.white);
|
||||
|
||||
--secondary: theme(colors.zinc.200);
|
||||
--secondary-foreground: theme(colors.zinc.900);
|
||||
|
||||
--destructive: theme(colors.red.600);
|
||||
--destructive-foreground: theme(colors.white);
|
||||
--accent: theme(colors.zinc.100);
|
||||
--accent-foreground: theme(colors.zinc.900);
|
||||
|
||||
--border: theme(colors.zinc.300); /* 统一使用 zinc 以保持一致性 */
|
||||
--input: theme(colors.zinc.300);
|
||||
--ring: theme(colors.blue.500);
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
/* 暗色模式 */
|
||||
.dark {
|
||||
--background: theme(colors.zinc.900);
|
||||
--foreground: theme(colors.zinc.100);
|
||||
|
||||
--muted: theme(colors.zinc.900 / 0.5);
|
||||
--muted-foreground: theme(colors.zinc.400);
|
||||
|
||||
--primary: theme(colors.blue.600); /* 亮色和暗色模式下的主色通常保持一致 */
|
||||
--primary-foreground: theme(colors.white);
|
||||
|
||||
--secondary: theme(colors.zinc.900);
|
||||
--secondary-foreground: theme(colors.zinc.100);
|
||||
|
||||
--destructive: theme(colors.red.700); /* 暗色模式下可以稍暗一些以保持对比度 */
|
||||
--destructive-foreground: theme(colors.white);
|
||||
|
||||
--accent: theme(colors.zinc.700 / 0.6); /* 示例: dark:border-zinc-700/60 */
|
||||
--accent-foreground: theme(colors.zinc.100);
|
||||
--border: theme(colors.zinc.900);
|
||||
--input: theme(colors.zinc.700);
|
||||
--ring: theme(colors.blue.500);
|
||||
}
|
||||
}
|
||||
|
||||
/* =================================================================== */
|
||||
/* [核心] shadcn/ui 组件层定义 */
|
||||
/* =================================================================== */
|
||||
@layer components {
|
||||
/* --- 按钮 (Button) --- */
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors
|
||||
focus-visible:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[(var(--ring))]
|
||||
disabled:pointer-events-none disabled:opacity-50;
|
||||
}
|
||||
.btn-primary {
|
||||
@apply bg-primary text-primary-foreground hover:bg-primary/90;
|
||||
}
|
||||
.btn-secondary {
|
||||
@apply bg-secondary text-secondary-foreground hover:bg-secondary/80;
|
||||
}
|
||||
.btn-destructive {
|
||||
@apply bg-destructive text-destructive-foreground hover:bg-destructive/90;
|
||||
}
|
||||
.btn-outline {
|
||||
@apply border border-input bg-background hover:bg-accent hover:text-accent-foreground;
|
||||
}
|
||||
.btn-ghost {
|
||||
@apply hover:bg-accent hover:text-accent-foreground;
|
||||
}
|
||||
.btn-link {
|
||||
@apply text-primary underline-offset-4 hover:underline;
|
||||
}
|
||||
|
||||
/* 按钮尺寸变体 */
|
||||
.btn-lg { @apply h-11 rounded-md px-8; }
|
||||
.btn-md { @apply h-10 px-4 py-2; }
|
||||
.btn-sm { @apply h-9 rounded-md px-3; }
|
||||
.btn-icon { @apply h-10 w-10; }
|
||||
/* --- 输入框 (Input) --- */
|
||||
.input {
|
||||
@apply flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm
|
||||
file:border-0 file:bg-transparent file:text-sm file:font-medium
|
||||
placeholder:text-muted-foreground
|
||||
focus-visible:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[(var(--ring))]
|
||||
disabled:cursor-not-allowed disabled:opacity-50;
|
||||
}
|
||||
}
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
/* =================================================================== */
|
||||
/* [核心] 主题定义层 (Theming Layer for Tailwind JIT) */
|
||||
/* =================================================================== */
|
||||
@theme {
|
||||
/* 颜色: --color-KEY 会自动生成 bg-KEY, text-KEY, border-KEY 等 */
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
/* 圆角: --radius-KEY 会生成 rounded-KEY */
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
/* 动画 Keyframes */
|
||||
@keyframes toast-in { from { opacity: 0; transform: translateY(20px) scale(0.95); } to { opacity: 1; transform: translateY(0) scale(1); } }
|
||||
@keyframes toast-out { from { opacity: 1; transform: translateX(0); } to { opacity: 0; transform: translateX(20px); } }
|
||||
@keyframes panel-in { from { opacity: 0; transform: translateY(-10px) scale(0.95); } to { opacity: 1; transform: translateY(0) scale(1); } }
|
||||
@keyframes panel-out { from { opacity: 1; transform: translateY(0) scale(1); } to { opacity: 0; transform: translateY(-10px) scale(0.95); } }
|
||||
/* 动画工具类: --animation-KEY 生成 animate-KEY */
|
||||
--animation-toast-in: toast-in 0.4s cubic-bezier(0.21, 1.02, 0.73, 1) forwards;
|
||||
--animation-toast-out: toast-out 0.3s ease-out forwards;
|
||||
--animation-panel-in: panel-in 0.2s ease-out;
|
||||
--animation-panel-out: panel-out 0.2s ease-out forwards;
|
||||
}
|
||||
|
||||
/* v4引擎已经为你生成了 animate-panel-in 等类,所以@apply可以找到它们 */
|
||||
|
||||
/* -------------------------*/
|
||||
/* ------ base.html ------- */
|
||||
/* ------------------------ */
|
||||
|
||||
/* [最终悬浮版] 细线轨道 + 宽滑块 */
|
||||
.main-content-scroll::-webkit-scrollbar {
|
||||
width: 16px; /* 为轨道和滑块的交互留出足够的空间 */
|
||||
}
|
||||
/* [核心] 使用渐变“画”出一条1px的细线作为轨道 */
|
||||
.main-content-scroll::-webkit-scrollbar-track {
|
||||
background: transparent; /* 轨道区域完全透明 */
|
||||
background-image: linear-gradient(
|
||||
to right,
|
||||
transparent 7px, /* 7px 的透明空间 */
|
||||
rgba(108, 108, 108, 0.1) 7px, /* 线的开始 */
|
||||
rgba(100, 100, 100, 0.1) 8px, /* 线的结束 (1px宽) */
|
||||
transparent 8px /* 剩余的透明空间 */
|
||||
);
|
||||
}
|
||||
.dark .main-content-scroll::-webkit-scrollbar-track {
|
||||
background-image: linear-gradient(
|
||||
to right,
|
||||
transparent 7px,
|
||||
rgba(0, 0, 0, 0.2) 7px, /* 暗色模式的细线 */
|
||||
rgba(0, 0, 0, 0.2) 8px,
|
||||
transparent 8px
|
||||
);
|
||||
}
|
||||
/* 滑块比轨道线更宽,并带有光晕效果 */
|
||||
.main-content-scroll::-webkit-scrollbar-thumb {
|
||||
height: 50px; /* 给滑块一个最小高度 */
|
||||
background-color: rgb(222, 222, 222); /* 浅色模式 slate-500 @ 40% */
|
||||
border-radius: 9999px;
|
||||
|
||||
/* [核心] 使用透明边框让滑块宽度只有 8px (16px - 4px*2) */
|
||||
border: 5px solid transparent;
|
||||
lg:border: 4px solid transparent;
|
||||
background-clip: content-box;
|
||||
/* 光晕效果 */
|
||||
box-shadow: inset 0 0 0 1px rgba(150, 150, 150, 0.1);
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
.dark .main-content-scroll::-webkit-scrollbar-thumb {
|
||||
background-color: #181818; /* 暗色模式 zinc-900 */
|
||||
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
/* 悬停时,滑块“变实”,颜色加深 */
|
||||
.main-content-scroll::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgb(202, 202, 202);
|
||||
border-width: 4px; /* [核心交互] 透明边框消失,滑块宽度变为完整的16px,产生吸附放大的效果 */
|
||||
}
|
||||
.dark .main-content-scroll::-webkit-scrollbar-thumb:hover {
|
||||
background-color: #151515;
|
||||
}
|
||||
|
||||
.shadow-main { box-shadow: 0 0 25px rgba(0, 0, 0, 0.08); }
|
||||
|
||||
|
||||
@layer components {
|
||||
/* 1. 父容器: group 统一写入html */
|
||||
.nav-item-wrapper {
|
||||
@apply relative mx-4; /* 原始 margin: 0 1rem; -> mx-4 */
|
||||
}
|
||||
/* 2. 链接本身 */
|
||||
.nav-link {
|
||||
@apply flex items-baseline p-3 rounded-l-lg text-[#f4f4f5] justify-center lg:justify-start;
|
||||
@apply transition-colors duration-200 ease-in-out;
|
||||
/* 悬停状态 */
|
||||
@apply group-hover:bg-[rgba(63,63,70,0.8)] dark:group-hover:bg-white/10;
|
||||
}
|
||||
/* 3. 图标 */
|
||||
.nav-icon {
|
||||
@apply w-[1.2rem] text-center;
|
||||
@apply transition-all duration-300 ease-in-out;
|
||||
/* 悬停和激活状态 */
|
||||
@apply group-hover:text-[#60a5fa] group-hover:[filter:drop-shadow(0_0_5px_rgba(59,130,246,0.5))];
|
||||
@apply group-data-[active='true']:text-[#60a5fa] group-data-[active='true']:[filter:drop-shadow(0_0_5px_rgba(59,130,246,0.7))];
|
||||
}
|
||||
/* 4. 指示器 */
|
||||
.nav-indicator {
|
||||
@apply absolute -left-4 top-0 h-full w-1 bg-[#f4f4f5] rounded-r-full pointer-events-none;
|
||||
@apply opacity-0 transition-opacity duration-300 ease-in-out;
|
||||
/* 激活状态 */
|
||||
@apply group-data-[active='true']:opacity-100;
|
||||
}
|
||||
/* 5. 像素装饰文本 */
|
||||
.pixel-decoration {
|
||||
@apply font-['Pixelify_Sans'] text-[0.6rem] text-[#3ac06b] ml-2 relative top-px tracking-[0.5px];
|
||||
@apply opacity-0 [text-shadow:0_0_5px_rgba(74,222,128,0.5)];
|
||||
@apply transition-opacity duration-200 ease-in-out;
|
||||
/* 悬停状态 */
|
||||
@apply group-data-[active='true']:opacity-100 group-hover:opacity-100;
|
||||
}
|
||||
|
||||
|
||||
/* =================================================================== */
|
||||
/* [融合版] 全局异步任务中心样式 */
|
||||
/* =================================================================== */
|
||||
|
||||
/* --- 组件 A: Toast 通知样式 (保持不变) --- */
|
||||
.toast-item {
|
||||
@apply flex items-start p-3 w-full rounded-lg shadow-lg ring-1 ring-black/5 dark:ring-white/10 bg-white/80 dark:bg-zinc-800/80 backdrop-blur-md pointer-events-auto;
|
||||
}
|
||||
.toast-icon {
|
||||
@apply flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center text-white mr-3;
|
||||
}
|
||||
.toast-icon-loading {@apply bg-blue-500;}
|
||||
.toast-icon-success {@apply bg-green-500;}
|
||||
.toast-icon-error {@apply bg-red-500;}
|
||||
.toast-content {
|
||||
@apply flex-grow;
|
||||
}
|
||||
.toast-title {
|
||||
@apply font-semibold text-sm text-zinc-800 dark:text-zinc-100;
|
||||
}
|
||||
.toast-message {
|
||||
@apply text-xs text-zinc-600 dark:text-zinc-400 mt-0.5;
|
||||
}
|
||||
.toast-close-btn {
|
||||
@apply ml-4 text-zinc-400 hover:text-zinc-800 dark:hover:text-white transition-colors;
|
||||
}
|
||||
|
||||
/* --- [升级] 组件 B: 多阶段任务项样式 --- */
|
||||
|
||||
/* 1. 任务项主容器: 移除了 flex 布局,采用块级布局容纳复杂内容 */
|
||||
.task-list-item {
|
||||
@apply flex justify-between items-start px-3 py-2 rounded-lg transition-colors duration-200 overflow-hidden border;
|
||||
@apply hover:bg-black/5 dark:hover:bg-white/5;
|
||||
@apply border-transparent dark:border-transparent;
|
||||
@apply bg-zinc-50 dark:bg-zinc-800/50;
|
||||
}
|
||||
|
||||
/* --- 任务项主内容区 (左栏) --- */
|
||||
.task-item-main {
|
||||
@apply flex items-center justify-between flex-grow gap-1; /* flex-grow 使其占据所有可用空间 */
|
||||
}
|
||||
/* 2. 任务项头部: 包含标题和时间戳 */
|
||||
.task-item-header {
|
||||
@apply flex justify-between items-center mb-2;
|
||||
}
|
||||
.task-item-title {
|
||||
/* 融合了您原有的字体样式 */
|
||||
@apply font-medium text-sm text-zinc-700 dark:text-zinc-200;
|
||||
}
|
||||
.task-item-timestamp {
|
||||
/* 融合了您原有的字体样式 */
|
||||
@apply text-xs self-start pt-1.5 pl-2 text-zinc-400 dark:text-zinc-500 flex-shrink-0;
|
||||
}
|
||||
|
||||
/* 3. [新增] 阶段动画的核心容器 */
|
||||
.task-stages-container {
|
||||
@apply flex flex-col gap-1.5 overflow-hidden;
|
||||
}
|
||||
|
||||
/* 4. [新增] 单个阶段的样式 */
|
||||
.task-stage {
|
||||
@apply flex items-center gap-2 p-1.5 rounded-md transition-all duration-300 ease-in-out relative;
|
||||
}
|
||||
.task-stage-icon {
|
||||
@apply w-4 h-4 relative flex-shrink-0 text-zinc-400;
|
||||
}
|
||||
.task-stage-icon i {
|
||||
@apply absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 opacity-0 transition-opacity duration-200;
|
||||
}
|
||||
.task-stage-content {
|
||||
@apply flex-grow flex justify-between items-baseline text-xs;
|
||||
}
|
||||
.task-stage-name {
|
||||
@apply text-zinc-600 dark:text-zinc-400;
|
||||
}
|
||||
.task-stage-progress-text {
|
||||
@apply font-mono text-zinc-500 dark:text-zinc-500;
|
||||
}
|
||||
.task-stage-progress-bg {
|
||||
@apply absolute bottom-0 left-0 right-0 h-0.5 bg-black/10 dark:bg-white/10 rounded-full overflow-hidden;
|
||||
}
|
||||
.task-stage-progress-bar {
|
||||
@apply h-full w-0 rounded-full;
|
||||
}
|
||||
|
||||
/* 5. [新增] 各阶段的状态视觉 */
|
||||
/* Pending: 待处理 */
|
||||
.task-stage.stage-pending { @apply opacity-40; }
|
||||
.task-stage.stage-pending .fa-circle { @apply opacity-100; }
|
||||
|
||||
/* Active: 当前活动 */
|
||||
.task-stage.stage-active {
|
||||
@apply opacity-100 bg-blue-500/10;
|
||||
}
|
||||
.task-stage.stage-active .fa-spinner { @apply opacity-100 text-blue-500; }
|
||||
.task-stage.stage-active .task-stage-name { @apply font-semibold text-zinc-800 dark:text-zinc-100; }
|
||||
.task-stage.stage-active .task-stage-progress-bar { @apply bg-blue-500; }
|
||||
|
||||
/* Completed: 已完成 */
|
||||
.task-stage.stage-completed { @apply opacity-60; }
|
||||
.task-stage.stage-completed .fa-check { @apply opacity-100 text-green-500; }
|
||||
.task-stage.stage-completed .task-stage-name { @apply line-through text-zinc-500 dark:text-zinc-500; }
|
||||
.task-stage.stage-completed .task-stage-progress-bar { @apply bg-green-500; }
|
||||
|
||||
/* Error / Skipped: 错误或跳过 */
|
||||
.task-stage.stage-error, .task-stage.stage-skipped { @apply opacity-50 bg-red-500/5; }
|
||||
.task-stage.stage-error .fa-times, .task-stage.stage-skipped .fa-times { @apply opacity-100 text-red-500; }
|
||||
.task-stage.stage-error .task-stage-name, .task-stage.stage-skipped .task-stage-name { @apply line-through; }
|
||||
|
||||
/* 6. [新增] 任务最终状态的容器 */
|
||||
.task-final-status {
|
||||
@apply overflow-hidden text-sm;
|
||||
}
|
||||
.task-final-status i {
|
||||
@apply text-lg;
|
||||
}
|
||||
|
||||
/* =================================================================== */
|
||||
/* 任务中心智能摘要卡片样式 (Tailwind @apply 统一版) */
|
||||
/* =================================================================== */
|
||||
/* --- 1. 摘要行的行内图标 --- */
|
||||
.task-item-icon-summary {
|
||||
@apply text-lg mr-3; /* text-lg=18px, mr-3=12px, mt-px=1px */
|
||||
}
|
||||
/* --- 2. 可折叠内容区 --- */
|
||||
.task-details-content {
|
||||
@apply transition-all duration-300 ease-in-out overflow-hidden;
|
||||
}
|
||||
.task-details-content.collapsed {
|
||||
@apply max-h-0 opacity-0 mt-0 pt-0 pb-0 border-t-0;
|
||||
}
|
||||
/* --- 3. 折叠区内的详情列表 --- */
|
||||
/* 注意: space-y-1 现在被移到HTML中,由父元素直接控制,更符合Tailwind用法 */
|
||||
.task-details-body {
|
||||
@apply pt-2 mt-2 border-t border-black/5 text-xs text-zinc-600;
|
||||
@apply dark:border-white/[.07] dark:text-zinc-400;
|
||||
}
|
||||
/* --- 4. 折叠/展开的雪佛兰图标 --- */
|
||||
.task-toggle-icon {
|
||||
@apply transition-transform duration-300 ease-in-out text-zinc-400 flex-shrink-0 ml-2;
|
||||
}
|
||||
/* --- 5. 展开状态下的图标旋转 --- */
|
||||
/*
|
||||
使用 .expanded 类来控制旋转。
|
||||
这个 .expanded 类由我们的JS代码在点击时添加到 [data-task-toggle] 元素上。
|
||||
*/
|
||||
[data-task-toggle].expanded .task-toggle-icon {
|
||||
@apply rotate-180;
|
||||
}
|
||||
|
||||
/* -------------------------*/
|
||||
/* ------ dashboard ------- */
|
||||
/* ------------------------ */
|
||||
|
||||
/* [核心] dashboard tab 组件重构 */
|
||||
|
||||
/* 基础的标签项样式 (现在背景透明) */
|
||||
.tab-item {
|
||||
/* [核心变化] 移除背景,添加 z-index 使其位于指示器之上 */
|
||||
@apply relative z-10 inline-flex items-center justify-center whitespace-nowrap rounded-md px-2 sm:px-5 py-1.5 text-sm font-medium transition-colors duration-200;
|
||||
|
||||
/* 默认/非激活状态的文字颜色 */
|
||||
@apply text-zinc-200 dark:text-zinc-400;
|
||||
|
||||
/* 悬停时的文字颜色 */
|
||||
@apply hover:text-zinc-900 hover:font-medium dark:hover:text-zinc-200 dark:hover:font-medium;
|
||||
|
||||
/* 焦点样式 (无需改动) */
|
||||
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500;
|
||||
}
|
||||
|
||||
/* 激活状态的标签 (只改变文字颜色) */
|
||||
.tab-active {
|
||||
/* [核心变化] 激活状态只改变文字颜色,使其在白色指示器上可见 */
|
||||
@apply text-zinc-900 dark:text-zinc-50;
|
||||
}
|
||||
|
||||
|
||||
/* 自定义下拉选项的样式 */
|
||||
.custom-select-option {
|
||||
@apply cursor-pointer select-none rounded-md px-3 py-1.5 text-sm text-zinc-800 dark:text-zinc-200;
|
||||
@apply transition-colors duration-150;
|
||||
}
|
||||
|
||||
/* 选项的悬停和高亮状态 */
|
||||
.custom-select-option:hover, .custom-select-option.is-highlighted {
|
||||
@apply bg-zinc-200 dark:bg-zinc-700 outline-none;
|
||||
}
|
||||
/* 定义下拉菜单面板的基础样式 */
|
||||
.dropdown-panel {
|
||||
@apply absolute right-0 z-10 mt-1 w-32 rounded-md border border-zinc-200 bg-white shadow-lg dark:border-zinc-600 dark:bg-zinc-700;
|
||||
}
|
||||
/* 定义菜单项(按钮)的基础样式 */
|
||||
.menu-item {
|
||||
@apply w-full text-left flex items-center gap-x-3 px-4 py-2 text-sm text-zinc-700 hover:bg-zinc-100 dark:text-zinc-200 dark:hover:bg-zinc-600 transition-colors;
|
||||
}
|
||||
/* 可选:为危险操作(如删除)创建一个变体 */
|
||||
.menu-item-danger {
|
||||
@apply text-red-600;
|
||||
}
|
||||
.menu-item-icon-neutral {
|
||||
@apply text-blue-500;
|
||||
}
|
||||
/* 定义菜单项内部图标的样式 */
|
||||
.menu-item-icon {
|
||||
@apply w-4;
|
||||
}
|
||||
/* 定义菜单中的分隔线 */
|
||||
.menu-divider {
|
||||
@apply my-1 h-px bg-zinc-200 dark:bg-zinc-700;
|
||||
}
|
||||
|
||||
/*
|
||||
* ds-stats-card (Dashboard Statistics Card)
|
||||
* 定义了仪表盘页面顶部核心指标卡片的统一外观。
|
||||
* 包含了内边距、圆角、边框、背景色、阴影以及深色模式下的样式。
|
||||
*/
|
||||
.ds-stats-card {
|
||||
@apply p-6 rounded-2xl border border-zinc-400 shadow-sm dark:bg-zinc-900/50 dark:border-zinc-800;
|
||||
}
|
||||
|
||||
/* -------------------------*/
|
||||
/* ------ keygroups ------- */
|
||||
/* ------------------------ */
|
||||
/**
|
||||
* 1. 【新增】Group 卡片的通用基础样式
|
||||
* 抽离了 active 和 inactive 状态共享的所有样式。
|
||||
* 注意:它不会替换 .group-card-active/inactive,而是与之共存。
|
||||
*/
|
||||
.group-card {
|
||||
@apply cursor-pointer rounded-lg p-3 transition-all duration-200 h-16 flex flex-col justify-center;
|
||||
}
|
||||
/**
|
||||
* 2. 【新增】移动端首屏的 "当前分组" 选择器样式
|
||||
*/
|
||||
.mobile-group-selector {
|
||||
@apply flex-grow flex items-center justify-between p-3 border border-zinc-200 dark:border-zinc-700 rounded-lg;
|
||||
}
|
||||
/* 移动端群组下拉列表样式 */
|
||||
.mobile-group-menu-active {
|
||||
/* --- Positioning & Layering --- */
|
||||
@apply absolute z-20 top-full left-0 right-0;
|
||||
/* --- Appearance --- */
|
||||
@apply bg-white dark:bg-zinc-800 shadow-lg rounded-b-lg border border-t-0 border-zinc-200 dark:border-zinc-700;
|
||||
/* --- Spacing & Content Flow --- */
|
||||
@apply p-4;
|
||||
|
||||
max-height: 14rem;
|
||||
@apply overflow-y-auto;
|
||||
}
|
||||
/**
|
||||
* 3. 【新增】卡片/选择器中的次要描述文本样式
|
||||
*/
|
||||
.card-sub-text {
|
||||
@apply text-xs text-zinc-500 dark:text-zinc-400 truncate;
|
||||
}
|
||||
/**
|
||||
* 4. 【新增】桌面端 “添加分组” 按钮的特定样式
|
||||
*/
|
||||
.add-group-btn-desktop {
|
||||
/**
|
||||
* [核心修正]
|
||||
* 1. 新增 p-1: 增加一个4px的内边距,为背景裁剪创造空间。
|
||||
* 2. 新增 bg-clip-content: 让背景只在内容区(内边距内部)绘制。
|
||||
*/
|
||||
@apply w-full h-16 border-zinc-300 dark:border-zinc-600 text-zinc-400 dark:text-zinc-500
|
||||
hover:border-blue-500 hover:text-blue-500
|
||||
p-1 bg-clip-content;
|
||||
--stripe-color: theme('colors.zinc.200');
|
||||
.dark & {
|
||||
--stripe-color: theme('colors.zinc.700');
|
||||
}
|
||||
|
||||
/**
|
||||
* [核心修正]
|
||||
* 1. 角度改为 -45deg 实现镜像。
|
||||
* 2. 宽度和间距从 15px/30px 减半为 7.5px/15px。
|
||||
*/
|
||||
background-image: repeating-linear-gradient(
|
||||
-45deg,
|
||||
var(--stripe-color),
|
||||
var(--stripe-color) 6px,
|
||||
transparent 6px,
|
||||
transparent 12px
|
||||
);
|
||||
}
|
||||
/**
|
||||
* 5. 【新增】移动端 “添加分组” 按钮的特定样式
|
||||
*/
|
||||
.add-group-btn-mobile {
|
||||
@apply w-15 h-15 border-blue-500/50 text-blue-500;
|
||||
}
|
||||
/**
|
||||
* 6. 【新增】健康指示器的外环样式
|
||||
*/
|
||||
.health-indicator-ring {
|
||||
@apply w-5 h-5 flex items-center justify-center rounded-full shrink-0;
|
||||
}
|
||||
/**
|
||||
* 7. 【新增】健康指示器的核心圆点样式
|
||||
*/
|
||||
.health-indicator-dot {
|
||||
@apply w-2 h-2 rounded-full;
|
||||
}
|
||||
/*
|
||||
* 8. JS依赖
|
||||
*/
|
||||
.group-card-active {
|
||||
@apply cursor-pointer rounded-lg p-3 mr-3 bg-blue-500/10 border border-blue-500/30 text-zinc-800 dark:text-zinc-200 transition-all duration-200;
|
||||
}
|
||||
.group-card-inactive {
|
||||
@apply cursor-pointer rounded-lg p-3 mr-3 bg-white dark:bg-zinc-800/50 border border-zinc-200 dark:border-zinc-700/60 text-zinc-800 dark:text-zinc-200 hover:border-blue-500/50 hover:bg-blue-500/5;
|
||||
transition-property: background-color, border-color, transform;
|
||||
transition-duration: 200ms;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Modal Component Styles (单功能模态框组件)
|
||||
========================================================================== */
|
||||
/**
|
||||
* 1. 模态框遮罩层 (Modal Overlay)
|
||||
* 覆盖整个屏幕的半透明背景。
|
||||
*/
|
||||
.modal-overlay {
|
||||
@apply fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm;
|
||||
}
|
||||
/* ... (modal-panel, header, title, close-btn, body, label, footer 保持不变) ... */
|
||||
.modal-panel {
|
||||
@apply w-full max-w-2xl rounded-lg bg-white p-8 shadow-2xl dark:bg-zinc-800/90 border dark:border-zinc-700 flex flex-col;
|
||||
}
|
||||
.modal-header {
|
||||
@apply flex items-center justify-between pb-4 border-b dark:border-zinc-700;
|
||||
}
|
||||
.modal-title {
|
||||
@apply text-xl font-semibold;
|
||||
}
|
||||
.modal-close-btn {
|
||||
@apply text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200 transition-colors;
|
||||
}
|
||||
.modal-body {
|
||||
@apply mt-6 p-1 pr-4 -mr-4;
|
||||
}
|
||||
.modal-label {
|
||||
@apply text-sm font-medium text-zinc-700 dark:text-zinc-300;
|
||||
}
|
||||
.modal-footer {
|
||||
@apply mt-2 flex justify-end gap-x-3 pt-3;
|
||||
}
|
||||
/**
|
||||
* [修正] 将所有按钮的样式定义分离,避免 @apply 嵌套自定义类。
|
||||
* HTML中将同时使用 .modal-btn 和 .modal-btn-primary/secondary/danger。
|
||||
*/
|
||||
/* 9. 模态框按钮基础样式 (Modal Button Base) */
|
||||
.modal-btn {
|
||||
@apply rounded-md px-4 py-2 text-sm font-medium transition-colors;
|
||||
}
|
||||
|
||||
/* 10. 主要操作按钮 (如: 导入, 保存) */
|
||||
.modal-btn-primary {
|
||||
@apply bg-blue-600 text-white hover:bg-blue-700;
|
||||
}
|
||||
|
||||
/* 11. 次要/取消按钮 */
|
||||
.modal-btn-secondary {
|
||||
@apply bg-zinc-200 text-zinc-700 hover:bg-zinc-300 dark:bg-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-600;
|
||||
}
|
||||
|
||||
/* 12. 危险操作按钮 (如: 删除) */
|
||||
.modal-btn-danger {
|
||||
@apply bg-red-600 text-white hover:bg-red-700;
|
||||
}
|
||||
|
||||
/* Modal Inputs */
|
||||
.modal-input {
|
||||
/*@apply mt-1 block w-full rounded-md border border-zinc-300 bg-white p-2 min-h-[40px] focus:border-blue-500 focus:ring-blue-500 dark:bg-zinc-700 dark:border-zinc-600 sm:text-sm;*/
|
||||
@apply mt-1 block w-full px-3 py-2 text-xs bg-white dark:bg-zinc-800 border border-zinc-300 dark:border-zinc-600 rounded-md placeholder:text-zinc-400 dark:placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-500 focus:border-transparent transition duration-150 ease-in-out;
|
||||
}
|
||||
/*
|
||||
* 修正:确保textarea也使用相同的字体大小和行高
|
||||
* Tailwind forms 插件有时会覆盖这些,所以我们明确指定
|
||||
*/
|
||||
textarea.modal-input {
|
||||
@apply text-sm leading-6;
|
||||
}
|
||||
/* Tag Input Component */
|
||||
.tag-input-container {
|
||||
|
||||
@apply flex flex-wrap items-center gap-2 mt-1 w-full rounded-md bg-white dark:bg-zinc-700 border border-zinc-300 dark:border-zinc-600 p-2 min-h-[40px] focus-within:border-blue-500 focus-within:ring-1 focus-within:ring-blue-500;
|
||||
}
|
||||
.tag-item {
|
||||
@apply flex items-center gap-x-1.5 bg-blue-100 dark:bg-blue-500/20 text-blue-800 dark:text-blue-200 text-sm font-medium rounded-full px-2.5 py-0.5;
|
||||
}
|
||||
.tag-delete {
|
||||
@apply text-blue-500 dark:text-blue-300 hover:text-blue-700 dark:hover:text-blue-100 font-bold;
|
||||
}
|
||||
.tag-input-new {
|
||||
/* 使其在容器内垂直居中,感觉更好 */
|
||||
@apply flex-grow bg-transparent focus:outline-none text-sm self-center;
|
||||
}
|
||||
|
||||
/* 为复制按钮提供基础样式 */
|
||||
.tag-copy-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1.0rem; /* 调整图标大小 */
|
||||
padding: 0 8px;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.2s, transform 0.2s;
|
||||
}
|
||||
.tag-copy-btn:hover {
|
||||
opacity: 1;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
/* 复制成功后的状态 */
|
||||
.tag-copy-btn.copied span {
|
||||
color: #4CAF50; /* 绿色,表示成功 */
|
||||
font-size: 0.8rem; /* 可以让提示文字小一点 */
|
||||
font-weight: bold;
|
||||
}
|
||||
/* 复制成功后的状态 */
|
||||
.tag-copy-btn.none span {
|
||||
color: #3766c3; /* 绿色,表示成功 */
|
||||
font-size: 0.8rem; /* 可以让提示文字小一点 */
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Advanced Request Settings Modal Specifics */
|
||||
details summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
details > summary {
|
||||
list-style: none;
|
||||
}
|
||||
details > summary .fa-chevron-down {
|
||||
transition: transform 0.2s ease-in-out;
|
||||
}
|
||||
details[open] > summary .fa-chevron-down {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.dynamic-kv-item {
|
||||
@apply flex items-center gap-x-2;
|
||||
}
|
||||
.dynamic-kv-key {
|
||||
@apply w-full px-3 py-2 bg-zinc-100 dark:bg-zinc-700/50 border border-zinc-300 dark:border-zinc-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-500 focus:border-transparent transition duration-150 ease-in-out font-mono text-xs;
|
||||
}
|
||||
.dynamic-kv-value {
|
||||
@apply w-full px-3 py-2 bg-white dark:bg-zinc-800 border border-zinc-300 dark:border-zinc-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-500 focus:border-transparent transition duration-150 ease-in-out font-mono text-xs;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Toggle Switch Component */
|
||||
.toggle-checkbox:checked {
|
||||
@apply right-0 border-blue-600;
|
||||
right: 0;
|
||||
}
|
||||
.toggle-checkbox:checked + .toggle-label {
|
||||
@apply bg-blue-600;
|
||||
}
|
||||
|
||||
/* Tooltip Component */
|
||||
.tooltip-icon {
|
||||
@apply relative inline-flex items-center justify-center ml-2 text-zinc-400 cursor-pointer;
|
||||
}
|
||||
/* .tooltip-text is now dynamically generated by JS */
|
||||
.global-tooltip {
|
||||
@apply fixed z-[9999] w-max max-w-xs whitespace-normal rounded-lg bg-zinc-800 px-3 py-2 text-sm font-medium text-white shadow-lg transition-opacity duration-200;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* SortableJS Drag Styles */
|
||||
.sortable-ghost {
|
||||
@apply opacity-40 bg-blue-200 dark:bg-blue-900/50;
|
||||
}
|
||||
.sortable-drag {
|
||||
@apply shadow-lg scale-105 cursor-grabbing;
|
||||
}
|
||||
|
||||
/* Fallback class for when forceFallback is true */
|
||||
.sortable-fallback {
|
||||
@apply shadow-lg scale-105 cursor-grabbing;
|
||||
}
|
||||
}
|
||||
|
||||
/* =================================================================== */
|
||||
/* 自定义 SweetAlert2 样式 (Custom SweetAlert2 Styles) */
|
||||
/* =================================================================== */
|
||||
.swal2-popup.swal2-custom-style {
|
||||
@apply bg-zinc-300 dark:bg-zinc-800 text-sm; /* 为整个弹窗设置基础字体大小 */
|
||||
}
|
||||
.swal2-popup.swal2-custom-style .swal2-title {
|
||||
@apply text-xl font-semibold text-zinc-800 dark:text-zinc-100; /* 应用标题样式 */
|
||||
}
|
||||
.swal2-popup.swal2-custom-style .swal2-html-container {
|
||||
@apply text-sm text-zinc-600 dark:text-zinc-300; /* 应用正文样式 */
|
||||
}
|
||||
.swal2-popup.swal2-custom-style .swal2-confirm-button,
|
||||
.swal2-popup.swal2-custom-style .swal2-cancel-button {
|
||||
@apply text-sm font-medium px-4 py-2 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2; /* 应用按钮样式 */
|
||||
@apply focus:ring-offset-white dark:focus:ring-offset-zinc-800;
|
||||
}
|
||||
.swal2-popup.swal2-custom-style .swal2-confirm-button {
|
||||
@apply bg-red-600 text-white hover:bg-red-700 focus:ring-red-500;
|
||||
}
|
||||
.swal2-popup.swal2-custom-style .swal2-cancel-button {
|
||||
@apply bg-transparent text-zinc-700 dark:text-zinc-200 border border-zinc-300 dark:border-zinc-600 hover:bg-zinc-50 dark:hover:bg-zinc-700 focus:ring-zinc-500;
|
||||
}
|
||||
/* 覆盖图标大小 */
|
||||
.swal2-popup.swal2-custom-style .swal2-icon {
|
||||
@apply w-8 h-8 my-2;
|
||||
}
|
||||
.swal2-popup.swal2-custom-style .swal2-icon .swal2-icon-content {
|
||||
@apply text-4xl;
|
||||
}
|
||||
312
frontend/js/components/apiKeyManager.js
Normal file
312
frontend/js/components/apiKeyManager.js
Normal file
@@ -0,0 +1,312 @@
|
||||
// frontend/js/components/apiKeyManager.js
|
||||
|
||||
//import { apiFetch } from "../main.js"; // Assuming apiFetch is exported from main.js
|
||||
import { apiFetch, apiFetchJson } from '../services/api.js';
|
||||
import { modalManager } from "./ui.js";
|
||||
|
||||
/**
|
||||
* Manages all API operations related to keys.
|
||||
* This class provides a centralized interface for actions such as
|
||||
* fetching, verifying, resetting, and deleting keys.
|
||||
*/
|
||||
class ApiKeyManager {
|
||||
constructor() {
|
||||
// The constructor can be used to initialize any properties,
|
||||
// though for this static-like service class, it might be empty.
|
||||
}
|
||||
|
||||
// [新增] 开始一个向指定分组添加Keys的异步任务
|
||||
/**
|
||||
* Starts a task to add multiple API keys to a specific group.
|
||||
* @param {number} groupId - The ID of the group.
|
||||
* @param {string} keysText - A string of keys, separated by newlines.
|
||||
* @returns {Promise<object>} A promise that resolves to the initial task status object.
|
||||
*/
|
||||
async addKeysToGroup(groupId, keysText, validate) {
|
||||
// 后端期望的 Body 结构
|
||||
const payload = {
|
||||
key_group_id: groupId,
|
||||
keys: keysText,
|
||||
validate_on_import: validate
|
||||
};
|
||||
// POST 请求不应被缓存,使用原始的 apiFetch 并设置 noCache
|
||||
const response = await apiFetch(`/admin/keygroups/${groupId}/apikeys/bulk`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
noCache: true
|
||||
});
|
||||
return response.json();
|
||||
}
|
||||
// [新增] 查询一个指定任务的当前状态
|
||||
/**
|
||||
* Gets the current status of a background task.
|
||||
* @param {string} taskId - The ID of the task.
|
||||
* @returns {Promise<object>} A promise that resolves to the task status object.
|
||||
*/
|
||||
getTaskStatus(taskId, options = {}) {
|
||||
return apiFetchJson(`/admin/tasks/${taskId}`, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a paginated and filtered list of keys.
|
||||
* @param {string} type - The type of keys to fetch ('valid' or 'invalid').
|
||||
* @param {number} [page=1] - The page number to retrieve.
|
||||
* @param {number} [limit=10] - The number of keys per page.
|
||||
* @param {string} [searchTerm=''] - A search term to filter keys.
|
||||
* @param {number|null} [failCountThreshold=null] - A threshold for filtering by failure count.
|
||||
* @returns {Promise<object>} A promise that resolves to the API response data.
|
||||
*/
|
||||
async fetchKeys(type, page = 1, limit = 10, searchTerm = '', failCountThreshold = null) {
|
||||
const params = new URLSearchParams({
|
||||
page: page,
|
||||
limit: limit,
|
||||
status: type,
|
||||
});
|
||||
if (searchTerm) params.append('search', searchTerm);
|
||||
if (failCountThreshold !== null) params.append('fail_count_threshold', failCountThreshold);
|
||||
|
||||
return await apiFetch(`/api/keys?${params.toString()}`);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Starts a task to unlink multiple API keys from a specific group.
|
||||
* @param {number} groupId - The ID of the group.
|
||||
* @param {string} keysText - A string of keys, separated by newlines.
|
||||
* @returns {Promise<object>} A promise that resolves to the initial task status object.
|
||||
*/
|
||||
async unlinkKeysFromGroup(groupId, keysInput) {
|
||||
let keysAsText;
|
||||
if (Array.isArray(keysInput)) {
|
||||
keysAsText = keysInput.join('\n');
|
||||
} else {
|
||||
keysAsText = keysInput;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
key_group_id: groupId,
|
||||
keys: keysAsText
|
||||
};
|
||||
|
||||
const response = await apiFetch(`/admin/keygroups/${groupId}/apikeys/bulk`, {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify(payload),
|
||||
noCache: true
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ message: response.statusText }));
|
||||
throw new Error(errorData.message || `Request failed with status ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新一个Key在特定分组中的状态 (e.g., 'ACTIVE', 'DISABLED').
|
||||
* @param {number} groupId - The ID of the group.
|
||||
* @param {number} keyId - The ID of the API key (api_keys.id).
|
||||
* @param {string} newStatus - The new operational status ('ACTIVE', 'DISABLED', etc.).
|
||||
* @returns {Promise<object>} A promise that resolves to the updated mapping object.
|
||||
*/
|
||||
async updateKeyStatusInGroup(groupId, keyId, newStatus) {
|
||||
const endpoint = `/admin/keygroups/${groupId}/apikeys/${keyId}`;
|
||||
const payload = { status: newStatus };
|
||||
return await apiFetchJson(endpoint, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
noCache: true
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* [MODIFIED] Fetches a paginated and filtered list of API key details for a specific group.
|
||||
* @param {number} groupId - The ID of the group.
|
||||
* @param {object} [params={}] - An object containing pagination and filter parameters.
|
||||
* @param {number} [params.page=1] - The page number to fetch.
|
||||
* @param {number} [params.limit=20] - The number of items per page.
|
||||
* @param {string} [params.status] - An optional status to filter the keys by.
|
||||
* @returns {Promise<object>} A promise that resolves to a pagination object.
|
||||
*/
|
||||
async getKeysForGroup(groupId, params = {}) {
|
||||
// Step 1: Create a URLSearchParams object. This is the modern, safe way to build query strings.
|
||||
const query = new URLSearchParams({
|
||||
page: params.page || 1, // Default to page 1 if not provided
|
||||
limit: params.limit || 20, // Default to 20 per page if not provided
|
||||
});
|
||||
// Step 2: Conditionally add the 'status' parameter IF it exists in the params object.
|
||||
if (params.status) {
|
||||
query.append('status', params.status);
|
||||
}
|
||||
if (params.keyword && params.keyword.trim() !== '') {
|
||||
query.append('keyword', params.keyword.trim());
|
||||
}
|
||||
// Step 3: Construct the final URL by converting the query object to a string.
|
||||
const url = `/admin/keygroups/${groupId}/apikeys?${query.toString()}`;
|
||||
|
||||
// The rest of the logic remains the same.
|
||||
const responseData = await apiFetchJson(url, { noCache: true });
|
||||
|
||||
if (!responseData.success || typeof responseData.data !== 'object' || !Array.isArray(responseData.data.items)) {
|
||||
throw new Error(responseData.message || 'Failed to fetch paginated keys for the group.');
|
||||
}
|
||||
|
||||
return responseData.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动一个重新验证一个或多个Key的异步任务。
|
||||
* @param {number} groupId - The ID of the group context for validation.
|
||||
* @param {string[]} keyValues - An array of API key strings to revalidate.
|
||||
* @returns {Promise<object>} A promise that resolves to the initial task status object.
|
||||
*/
|
||||
async revalidateKeys(groupId, keyValues) {
|
||||
const payload = {
|
||||
keys: keyValues.join('\n')
|
||||
};
|
||||
|
||||
const url = `/admin/keygroups/${groupId}/apikeys/test`;
|
||||
const responseData = await apiFetchJson(url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
noCache: true,
|
||||
});
|
||||
if (!responseData.success || !responseData.data) {
|
||||
throw new Error(responseData.message || "Failed to start revalidation task.");
|
||||
}
|
||||
return responseData.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a generic bulk action task for an entire group based on filters.
|
||||
* This single function replaces the need for separate cleanup, revalidate, and restore functions.
|
||||
* @param {number} groupId The group ID.
|
||||
* @param {object} payload The body of the request, defining the action and filters.
|
||||
* @returns {Promise<object>} The initial task response with a task_id.
|
||||
*/
|
||||
async startGroupBulkActionTask(groupId, payload) {
|
||||
// This assumes a new, unified endpoint on the backend.
|
||||
const url = `/admin/keygroups/${groupId}/bulk-actions`;
|
||||
const responseData = await apiFetchJson(url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!responseData.success || !responseData.data) {
|
||||
throw new Error(responseData.message || "未能启动分组批量任务。");
|
||||
}
|
||||
return responseData.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* [NEW] Fetches all keys for a group, filtered by status, for export purposes using the dedicated export API.
|
||||
* @param {number} groupId The ID of the group.
|
||||
* @param {string[]} statuses An array of statuses to filter by (e.g., ['active', 'cooldown']). Use ['all'] for everything.
|
||||
* @returns {Promise<string[]>} A promise that resolves to an array of API key strings.
|
||||
*/
|
||||
async exportKeysForGroup(groupId, statuses = ['all']) {
|
||||
const params = new URLSearchParams();
|
||||
statuses.forEach(status => params.append('status', status));
|
||||
|
||||
// This now points to our new, clean, non-paginated API endpoint
|
||||
const url = `/admin/keygroups/${groupId}/apikeys/export?${params.toString()}`;
|
||||
const responseData = await apiFetchJson(url, { noCache: true });
|
||||
if (!responseData.success || !Array.isArray(responseData.data)) {
|
||||
throw new Error(responseData.message || '未能获取用于导出的Key列表。');
|
||||
}
|
||||
|
||||
return responseData.data;
|
||||
}
|
||||
|
||||
/** !!!以下为GB预置函数,未做对齐
|
||||
* Verifies a single API key.
|
||||
* @param {string} key - The API key to verify.
|
||||
* @returns {Promise<object>} A promise that resolves to the API response data.
|
||||
*/
|
||||
async verifyKey(key) {
|
||||
return await apiFetch(`/gemini/v1beta/verify-key/${key}`, { method: "POST" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies a batch of selected API keys.
|
||||
* @param {string[]} keys - An array of API keys to verify.
|
||||
* @returns {Promise<object>} A promise that resolves to the API response data.
|
||||
*/
|
||||
async verifySelectedKeys(keys) {
|
||||
return await apiFetch(`/gemini/v1beta/verify-selected-keys`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ keys }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the failure count for a single API key.
|
||||
* @param {string} key - The API key whose failure count is to be reset.
|
||||
* @returns {Promise<object>} A promise that resolves to the API response data.
|
||||
*/
|
||||
async resetFailCount(key) {
|
||||
return await apiFetch(`/gemini/v1beta/reset-fail-count/${key}`, { method: "POST" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the failure count for a batch of selected API keys.
|
||||
* @param {string[]} keys - An array of API keys to reset.
|
||||
* @returns {Promise<object>} A promise that resolves to the API response data.
|
||||
*/
|
||||
async resetSelectedFailCounts(keys) {
|
||||
return await apiFetch(`/gemini/v1beta/reset-selected-fail-counts`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ keys }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a single API key.
|
||||
* @param {string} key - The API key to delete.
|
||||
* @returns {Promise<object>} A promise that resolves to the API response data.
|
||||
*/
|
||||
async deleteKey(key) {
|
||||
return await apiFetch(`/api/config/keys/${key}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a batch of selected API keys.
|
||||
* @param {string[]} keys - An array of API keys to delete.
|
||||
* @returns {Promise<object>} A promise that resolves to the API response data.
|
||||
*/
|
||||
async deleteSelectedKeys(keys) {
|
||||
return await apiFetch("/api/config/keys/delete-selected", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ keys }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches all keys, both valid and invalid.
|
||||
* @returns {Promise<object>} A promise that resolves to an object containing 'valid_keys' and 'invalid_keys' arrays.
|
||||
*/
|
||||
async fetchAllKeys() {
|
||||
return await apiFetch('/api/keys/all');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches usage details for a specific key over the last 24 hours.
|
||||
* @param {string} key - The API key to get details for.
|
||||
* @returns {Promise<object>} A promise that resolves to the API response data.
|
||||
*/
|
||||
async getKeyUsageDetails(key) {
|
||||
return await apiFetch(`/api/key-usage-details/${key}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches API call statistics for a given period.
|
||||
* @param {string} period - The time period for the stats (e.g., '1m', '1h', '24h').
|
||||
* @returns {Promise<object>} A promise that resolves to the API response data.
|
||||
*/
|
||||
async getStatsDetails(period) {
|
||||
return await apiFetch(`/api/stats/details?period=${period}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const apiKeyManager = new ApiKeyManager();
|
||||
126
frontend/js/components/customSelect.js
Normal file
126
frontend/js/components/customSelect.js
Normal file
@@ -0,0 +1,126 @@
|
||||
// Filename: frontend/js/components/customSelect.js
|
||||
|
||||
export default class CustomSelect {
|
||||
constructor(container) {
|
||||
this.container = container;
|
||||
this.trigger = this.container.querySelector('.custom-select-trigger');
|
||||
this.panel = this.container.querySelector('.custom-select-panel');
|
||||
|
||||
if (!this.trigger || !this.panel) {
|
||||
console.warn('CustomSelect cannot initialize: missing .custom-select-trigger or .custom-select-panel.', this.container);
|
||||
return;
|
||||
}
|
||||
|
||||
this.nativeSelect = this.container.querySelector('select');
|
||||
this.triggerText = this.trigger.querySelector('span');
|
||||
this.template = this.panel.querySelector('.custom-select-option-template');
|
||||
|
||||
|
||||
if (typeof CustomSelect.openInstance === 'undefined') {
|
||||
CustomSelect.openInstance = null;
|
||||
CustomSelect.initGlobalListener();
|
||||
}
|
||||
|
||||
if (this.nativeSelect) {
|
||||
this.generateOptions();
|
||||
this.updateTriggerText();
|
||||
}
|
||||
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
static initGlobalListener() {
|
||||
document.addEventListener('click', (event) => {
|
||||
if (CustomSelect.openInstance && !CustomSelect.openInstance.container.contains(event.target)) {
|
||||
CustomSelect.openInstance.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
generateOptions() {
|
||||
this.panel.querySelectorAll(':scope > *:not(.custom-select-option-template)').forEach(child => child.remove());
|
||||
Array.from(this.nativeSelect.options).forEach(option => {
|
||||
let item;
|
||||
if (this.template) {
|
||||
item = this.template.cloneNode(true);
|
||||
item.classList.remove('custom-select-option-template');
|
||||
item.removeAttribute('hidden');
|
||||
} else {
|
||||
item = document.createElement('a');
|
||||
item.href = '#';
|
||||
item.className = 'block px-4 py-2 text-sm text-zinc-700 hover:bg-zinc-100 dark:text-zinc-200 dark:hover:bg-zinc-600';
|
||||
}
|
||||
item.classList.add('custom-select-option');
|
||||
item.textContent = option.textContent;
|
||||
item.dataset.value = option.value;
|
||||
if (option.selected) { item.classList.add('is-selected'); }
|
||||
this.panel.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
this.trigger.addEventListener('click', (event) => {
|
||||
// [NEW] Guard clause: If the trigger is functionally disabled, do nothing.
|
||||
if (this.trigger.classList.contains('is-disabled')) {
|
||||
return;
|
||||
}
|
||||
event.stopPropagation();
|
||||
if (CustomSelect.openInstance && CustomSelect.openInstance !== this) {
|
||||
CustomSelect.openInstance.close();
|
||||
}
|
||||
this.toggle();
|
||||
});
|
||||
if (this.nativeSelect) {
|
||||
this.panel.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
const option = event.target.closest('.custom-select-option');
|
||||
if (option) { this.selectOption(option); }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
selectOption(optionEl) {
|
||||
const selectedValue = optionEl.dataset.value;
|
||||
if (this.nativeSelect.value !== selectedValue) {
|
||||
this.nativeSelect.value = selectedValue;
|
||||
this.nativeSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
this.updateTriggerText();
|
||||
this.panel.querySelectorAll('.custom-select-option').forEach(el => el.classList.remove('is-selected'));
|
||||
optionEl.classList.add('is-selected');
|
||||
this.close();
|
||||
}
|
||||
|
||||
updateTriggerText() {
|
||||
// [IMPROVEMENT] Guard against missing elements.
|
||||
if (!this.nativeSelect || !this.triggerText) return;
|
||||
|
||||
const selectedOption = this.nativeSelect.options[this.nativeSelect.selectedIndex];
|
||||
if (selectedOption) {
|
||||
this.triggerText.textContent = selectedOption.textContent;
|
||||
}
|
||||
}
|
||||
|
||||
toggle() {
|
||||
this.panel.classList.toggle('hidden');
|
||||
if (this.panel.classList.contains('hidden')) {
|
||||
if (CustomSelect.openInstance === this) {
|
||||
CustomSelect.openInstance = null;
|
||||
}
|
||||
} else {
|
||||
CustomSelect.openInstance = this;
|
||||
}
|
||||
}
|
||||
|
||||
open() {
|
||||
this.panel.classList.remove('hidden');
|
||||
CustomSelect.openInstance = this;
|
||||
}
|
||||
|
||||
close() {
|
||||
this.panel.classList.add('hidden');
|
||||
if (CustomSelect.openInstance === this) {
|
||||
CustomSelect.openInstance = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
80
frontend/js/components/slidingTabs.js
Normal file
80
frontend/js/components/slidingTabs.js
Normal file
@@ -0,0 +1,80 @@
|
||||
export default class SlidingTabs {
|
||||
/**
|
||||
* @param {HTMLElement} containerElement - The main container element with the `data-sliding-tabs-container` attribute.
|
||||
*/
|
||||
constructor(containerElement) {
|
||||
this.container = containerElement;
|
||||
this.indicator = this.container.querySelector('[data-tab-indicator]');
|
||||
this.tabs = this.container.querySelectorAll('[data-tab-item]');
|
||||
|
||||
// Find the initially active tab and store it as the component's state
|
||||
this.activeTab = this.container.querySelector('.tab-active');
|
||||
|
||||
if (!this.indicator || this.tabs.length === 0) {
|
||||
console.error('SlidingTabs component is missing required elements (indicator or items).', this.container);
|
||||
return;
|
||||
}
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
// Set initial indicator position
|
||||
if (this.activeTab) {
|
||||
// Use a small delay to ensure layout is fully calculated
|
||||
setTimeout(() => this.updateIndicator(this.activeTab), 50);
|
||||
}
|
||||
|
||||
// Bind all necessary event listeners
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
updateIndicator(targetTab) {
|
||||
if (!targetTab) return;
|
||||
|
||||
const containerRect = this.container.getBoundingClientRect();
|
||||
const targetRect = targetTab.getBoundingClientRect();
|
||||
|
||||
const left = targetRect.left - containerRect.left;
|
||||
const width = targetRect.width;
|
||||
|
||||
this.indicator.style.left = `${left}px`;
|
||||
this.indicator.style.width = `${width}px`;
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
this.tabs.forEach(tab => {
|
||||
// On click, update the active state
|
||||
tab.addEventListener('click', (e) => {
|
||||
// e.preventDefault(); // Uncomment if using <a> tags for SPA routing
|
||||
|
||||
if (this.activeTab) {
|
||||
this.activeTab.classList.remove('tab-active');
|
||||
}
|
||||
|
||||
tab.classList.add('tab-active');
|
||||
this.activeTab = tab; // Update the component's state
|
||||
this.updateIndicator(this.activeTab);
|
||||
});
|
||||
|
||||
// On hover, preview the indicator position
|
||||
tab.addEventListener('mouseenter', () => {
|
||||
this.updateIndicator(tab);
|
||||
});
|
||||
});
|
||||
|
||||
// When the mouse leaves the entire container, reset indicator to the active tab
|
||||
this.container.addEventListener('mouseleave', () => {
|
||||
this.updateIndicator(this.activeTab);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Auto-Initialization Logic ----
|
||||
// This is the "bootstrapper". It finds all components on the page and brings them to life.
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const allTabContainers = document.querySelectorAll('[data-sliding-tabs-container]');
|
||||
allTabContainers.forEach(container => {
|
||||
new SlidingTabs(container);
|
||||
});
|
||||
});
|
||||
132
frontend/js/components/tagInput.js
Normal file
132
frontend/js/components/tagInput.js
Normal file
@@ -0,0 +1,132 @@
|
||||
// frontend/js/components/tagInput.js
|
||||
|
||||
export default class TagInput {
|
||||
constructor(container, options = {}) {
|
||||
if (!container) {
|
||||
console.error("TagInput container not found.");
|
||||
return;
|
||||
}
|
||||
this.container = container;
|
||||
this.input = container.querySelector('.tag-input-new');
|
||||
this.tags = [];
|
||||
this.options = {
|
||||
validator: /.+/,
|
||||
validationMessage: '输入格式无效',
|
||||
...options
|
||||
};
|
||||
|
||||
this.copyBtn = document.createElement('button');
|
||||
this.copyBtn.className = 'tag-copy-btn';
|
||||
this.copyBtn.innerHTML = '<i class="fas fa-copy"></i>';
|
||||
this.copyBtn.title = '复制所有';
|
||||
this.container.appendChild(this.copyBtn);
|
||||
|
||||
this._initEventListeners();
|
||||
}
|
||||
|
||||
_initEventListeners() {
|
||||
this.container.addEventListener('click', (e) => {
|
||||
// 使用 .closest() 来处理点击事件,即使点击到图标也能正确触发
|
||||
if (e.target.closest('.tag-delete')) {
|
||||
this._removeTag(e.target.closest('.tag-item'));
|
||||
}
|
||||
});
|
||||
|
||||
if (this.input) {
|
||||
this.input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ',' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
const value = this.input.value.trim();
|
||||
if (value) {
|
||||
this._addTag(value);
|
||||
this.input.value = '';
|
||||
}
|
||||
}
|
||||
});
|
||||
this.input.addEventListener('blur', () => {
|
||||
const value = this.input.value.trim();
|
||||
if (value) {
|
||||
this._addTag(value);
|
||||
this.input.value = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 为“复制”按钮绑定点击事件
|
||||
this.copyBtn.addEventListener('click', this._handleCopyAll.bind(this));
|
||||
}
|
||||
|
||||
_addTag(raw_value) {
|
||||
// 在所有操作之前,自动转换为小写
|
||||
const value = raw_value.toLowerCase();
|
||||
|
||||
if (!this.options.validator.test(value)) {
|
||||
console.warn(`Tag validation failed for value: "${value}". Rule: ${this.options.validator}`);
|
||||
this.input.placeholder = this.options.validationMessage;
|
||||
this.input.classList.add('input-error');
|
||||
setTimeout(() => {
|
||||
this.input.classList.remove('input-error');
|
||||
this.input.placeholder = '添加...';
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.tags.includes(value)) return;
|
||||
this.tags.push(value);
|
||||
|
||||
const tagEl = document.createElement('span');
|
||||
tagEl.className = 'tag-item';
|
||||
tagEl.innerHTML = `<span class="tag-text">${value}</span><button class="tag-delete">×</button>`;
|
||||
this.container.insertBefore(tagEl, this.input);
|
||||
}
|
||||
|
||||
// 处理复制逻辑的专用方法
|
||||
_handleCopyAll() {
|
||||
const tagsString = this.tags.join(',');
|
||||
if (!tagsString) {
|
||||
// 如果没有标签,可以给个提示
|
||||
this.copyBtn.innerHTML = '<span>无内容!</span>';
|
||||
this.copyBtn.classList.add('none');
|
||||
setTimeout(() => {
|
||||
this.copyBtn.innerHTML = '<i class="fas fa-copy"></i>';
|
||||
this.copyBtn.classList.remove('copied');
|
||||
}, 1500);
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.clipboard.writeText(tagsString).then(() => {
|
||||
// 复制成功,提供视觉反馈
|
||||
this.copyBtn.innerHTML = '<span>已复制!</span>';
|
||||
this.copyBtn.classList.add('copied');
|
||||
setTimeout(() => {
|
||||
this.copyBtn.innerHTML = '<i class="fas fa-copy"></i>';
|
||||
this.copyBtn.classList.remove('copied');
|
||||
}, 2000);
|
||||
}).catch(err => {
|
||||
// 复制失败
|
||||
console.error('Could not copy text: ', err);
|
||||
this.copyBtn.innerHTML = '<span>失败!</span>';
|
||||
setTimeout(() => {
|
||||
this.copyBtn.innerHTML = '<i class="fas fa-copy"></i>';
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
_removeTag(tagEl) {
|
||||
const value = tagEl.querySelector('.tag-text').textContent;
|
||||
this.tags = this.tags.filter(t => t !== value);
|
||||
tagEl.remove();
|
||||
}
|
||||
|
||||
getValues() {
|
||||
return this.tags;
|
||||
}
|
||||
|
||||
setValues(values) {
|
||||
this.container.querySelectorAll('.tag-item').forEach(el => el.remove());
|
||||
this.tags = [];
|
||||
if (Array.isArray(values)) {
|
||||
values.filter(value => value).forEach(value => this._addTag(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
502
frontend/js/components/taskCenter.js
Normal file
502
frontend/js/components/taskCenter.js
Normal file
@@ -0,0 +1,502 @@
|
||||
/**
|
||||
* @file taskCenter.js
|
||||
* @description Centralizes Task component classes for global.
|
||||
* This module exports singleton instances of `TaskCenterManager` and `ToastManager`
|
||||
* to ensure consistent task service across the application.
|
||||
*/
|
||||
// ===================================================================
|
||||
// 任务中心UI管理器
|
||||
// ===================================================================
|
||||
|
||||
/**
|
||||
* Manages the UI and state for the global asynchronous task center.
|
||||
* It handles task rendering, state updates, and user interactions like
|
||||
* opening/closing the panel and clearing completed tasks.
|
||||
*/
|
||||
class TaskCenterManager {
|
||||
constructor() {
|
||||
// --- 核心状态 ---
|
||||
this.tasks = []; // A history of all tasks started in this session.
|
||||
this.activePolls = new Map();
|
||||
this.heartbeatInterval = null;
|
||||
this.MINIMUM_TASK_DISPLAY_TIME_MS = 800;
|
||||
this.hasUnreadCompletedTasks = false;
|
||||
this.isAnimating = false;
|
||||
this.countdownTimer = null;
|
||||
// --- 核心DOM元素引用 ---
|
||||
this.trigger = document.getElementById('task-hub-trigger');
|
||||
this.panel = document.getElementById('task-hub-panel');
|
||||
this.countdownBar = document.getElementById('task-hub-countdown-bar');
|
||||
this.countdownRing = document.getElementById('task-hub-countdown-ring');
|
||||
this.indicator = document.getElementById('task-hub-indicator');
|
||||
this.clearBtn = document.getElementById('task-hub-clear-btn');
|
||||
this.taskListContainer = document.getElementById('task-list-container');
|
||||
this.emptyState = document.getElementById('task-list-empty');
|
||||
}
|
||||
// [THE FINAL, DEFINITIVE VERSION]
|
||||
init() {
|
||||
if (!this.trigger || !this.panel) {
|
||||
console.warn('Task Center UI core elements not found. Initialization skipped.');
|
||||
return;
|
||||
}
|
||||
|
||||
// --- UI Event Listeners (Corrected and final) ---
|
||||
this.trigger.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
if (this.isAnimating) return;
|
||||
if (this.panel.classList.contains('hidden')) {
|
||||
this._handleUserInteraction();
|
||||
this.openPanel();
|
||||
} else {
|
||||
this.closePanel();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('click', (event) => {
|
||||
if (!this.panel.classList.contains('hidden') && !this.isAnimating && !this.panel.contains(event.target) && !this.trigger.contains(event.target)) {
|
||||
this.closePanel();
|
||||
}
|
||||
});
|
||||
this.trigger.addEventListener('mouseenter', this._stopCountdown.bind(this));
|
||||
this.panel.addEventListener('mouseenter', this._handleUserInteraction.bind(this));
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (!this.panel.classList.contains('hidden')) {
|
||||
this._startCountdown();
|
||||
}
|
||||
};
|
||||
this.panel.addEventListener('mouseleave', handleMouseLeave);
|
||||
this.trigger.addEventListener('mouseleave', handleMouseLeave);
|
||||
|
||||
this.clearBtn?.addEventListener('click', this.clearCompletedTasks.bind(this));
|
||||
|
||||
this.taskListContainer.addEventListener('click', (event) => {
|
||||
const toggleHeader = event.target.closest('[data-task-toggle]');
|
||||
if (!toggleHeader) return;
|
||||
this._handleUserInteraction();
|
||||
const taskItem = toggleHeader.closest('.task-list-item');
|
||||
const content = taskItem.querySelector('[data-task-content]');
|
||||
if (!content) return;
|
||||
const isCollapsed = content.classList.contains('collapsed');
|
||||
toggleHeader.classList.toggle('expanded', isCollapsed);
|
||||
if (isCollapsed) {
|
||||
content.classList.remove('collapsed');
|
||||
content.style.maxHeight = `${content.scrollHeight}px`;
|
||||
content.style.opacity = '1';
|
||||
content.addEventListener('transitionend', () => {
|
||||
if (!content.classList.contains('collapsed')) content.style.maxHeight = 'none';
|
||||
}, { once: true });
|
||||
} else {
|
||||
content.style.maxHeight = `${content.scrollHeight}px`;
|
||||
requestAnimationFrame(() => {
|
||||
content.style.maxHeight = '0px';
|
||||
content.style.opacity = '0';
|
||||
content.classList.add('collapsed');
|
||||
});
|
||||
}
|
||||
});
|
||||
this._render();
|
||||
|
||||
// [CRITICAL FIX] IGNITION! START THE ENGINE!
|
||||
this._startHeartbeat();
|
||||
|
||||
console.log('Task Center UI Initialized [Multi-Task Heartbeat Polling Architecture - IGNITED].');
|
||||
}
|
||||
async startTask(taskDefinition) {
|
||||
try {
|
||||
const initialTaskData = await taskDefinition.start();
|
||||
if (!initialTaskData || !initialTaskData.id) throw new Error("Task definition did not return a valid initial task object.");
|
||||
|
||||
const newTask = {
|
||||
id: initialTaskData.id,
|
||||
definition: taskDefinition,
|
||||
data: initialTaskData,
|
||||
timestamp: new Date(),
|
||||
startTime: Date.now()
|
||||
};
|
||||
|
||||
if (!initialTaskData.is_running) {
|
||||
console.log(`[TaskCenter] Task ${newTask.id} completed synchronously. Skipping poll.`);
|
||||
// We still show a brief toast for UX feedback.
|
||||
taskDefinition.renderToastNarrative(newTask.data, {}, toastManager);
|
||||
this.tasks.unshift(newTask);
|
||||
this._render();
|
||||
this._handleTaskCompletion(newTask);
|
||||
return; // IMPORTANT: Exit here to avoid adding it to the polling queue.
|
||||
}
|
||||
|
||||
this.tasks.unshift(newTask);
|
||||
this.activePolls.set(newTask.id, newTask);
|
||||
|
||||
this._render();
|
||||
this.openPanel();
|
||||
|
||||
taskDefinition.renderToastNarrative(newTask.data, {}, toastManager);
|
||||
this._updateIndicatorState(); // [SAFETY] Update indicator immediately on new task.
|
||||
} catch (error) {
|
||||
console.error("Failed to start task:", error);
|
||||
toastManager.show(`任务启动失败: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
_startHeartbeat() {
|
||||
if (this.heartbeatInterval) return;
|
||||
this.heartbeatInterval = setInterval(this._tick.bind(this), 1500);
|
||||
}
|
||||
|
||||
_stopHeartbeat() {
|
||||
if (this.heartbeatInterval) {
|
||||
clearInterval(this.heartbeatInterval);
|
||||
this.heartbeatInterval = null;
|
||||
}
|
||||
}
|
||||
async _tick() {
|
||||
if (this.activePolls.size === 0) {
|
||||
return;
|
||||
}
|
||||
// Iterate over a copy of keys to safely remove items during iteration.
|
||||
for (const taskId of [...this.activePolls.keys()]) {
|
||||
const task = this.activePolls.get(taskId);
|
||||
if (!task) continue; // Safety check
|
||||
try {
|
||||
const response = await task.definition.poll(taskId);
|
||||
if (!response.success || !response.data) throw new Error(response.message || "Polling failed");
|
||||
const oldData = { ...task.data };
|
||||
task.data = response.data;
|
||||
this._updateTaskItemInHistory(task.id, task.data); // [SAFETY] Keep history in sync.
|
||||
task.definition.renderToastNarrative(task.data, oldData, toastManager);
|
||||
|
||||
if (!task.data.is_running) {
|
||||
this._handleTaskCompletion(task);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Polling for task ${taskId} failed:`, error);
|
||||
task.data.error = error.message;
|
||||
this._updateTaskItemInHistory(task.id, task.data);
|
||||
this._handleTaskCompletion(task);
|
||||
}
|
||||
}
|
||||
}
|
||||
_handleTaskCompletion(task) {
|
||||
this.activePolls.delete(task.id);
|
||||
this._updateIndicatorState(); // [SAFETY] Update indicator as soon as a task is no longer active.
|
||||
|
||||
const toastId = `task-${task.id}`;
|
||||
|
||||
const finalize = async () => {
|
||||
await toastManager.dismiss(toastId, !task.data.error);
|
||||
this._updateTaskItemInDom(task);
|
||||
this.hasUnreadCompletedTasks = true;
|
||||
this._updateIndicatorState();
|
||||
if (task.data.error) {
|
||||
if (task.definition.onError) task.definition.onError(task.data);
|
||||
} else {
|
||||
if (task.definition.onSuccess) task.definition.onSuccess(task.data);
|
||||
}
|
||||
};
|
||||
const elapsedTime = Date.now() - task.startTime;
|
||||
const remainingTime = this.MINIMUM_TASK_DISPLAY_TIME_MS - elapsedTime;
|
||||
|
||||
if (remainingTime > 0) {
|
||||
setTimeout(finalize, remainingTime);
|
||||
} else {
|
||||
finalize();
|
||||
}
|
||||
}
|
||||
// [REFACTORED for robustness]
|
||||
_updateIndicatorState() {
|
||||
const hasRunningTasks = this.activePolls.size > 0;
|
||||
const shouldBeVisible = hasRunningTasks || this.hasUnreadCompletedTasks;
|
||||
this.indicator.classList.toggle('hidden', !shouldBeVisible);
|
||||
}
|
||||
|
||||
// [REFACTORED for robustness]
|
||||
clearCompletedTasks() {
|
||||
// Only keep tasks that are still in the active polling map.
|
||||
this.tasks = this.tasks.filter(task => this.activePolls.has(task.id));
|
||||
this.hasUnreadCompletedTasks = false;
|
||||
this._render();
|
||||
}
|
||||
|
||||
// [NEW SAFETY METHOD]
|
||||
_updateTaskItemInHistory(taskId, newData) {
|
||||
const taskInHistory = this.tasks.find(t => t.id === taskId);
|
||||
if (taskInHistory) {
|
||||
taskInHistory.data = newData;
|
||||
}
|
||||
}
|
||||
|
||||
// --- 渲染与DOM操作 ---
|
||||
_render() {
|
||||
this.taskListContainer.innerHTML = this.tasks.map(task => this._createTaskItemHtml(task)).join('');
|
||||
|
||||
const hasTasks = this.tasks.length > 0;
|
||||
this.taskListContainer.classList.toggle('hidden', !hasTasks);
|
||||
this.emptyState.classList.toggle('hidden', hasTasks);
|
||||
|
||||
this._updateIndicatorState();
|
||||
}
|
||||
_createTaskItemHtml(task) {
|
||||
// [MODIFIED] 将 this._formatTimeAgo 作为一个服务传递给渲染器
|
||||
const innerHtml = task.definition.renderTaskCenterItem(task.data, task.timestamp, this._formatTimeAgo);
|
||||
return `<div class="task-list-item" data-task-id="${task.id}">${innerHtml}</div>`;
|
||||
}
|
||||
_updateTaskItemInDom(task) {
|
||||
const item = this.taskListContainer.querySelector(`[data-task-id="${task.id}"]`);
|
||||
if (item) {
|
||||
// [MODIFIED] 将 this._formatTimeAgo 作为一个服务传递给渲染器
|
||||
item.innerHTML = task.definition.renderTaskCenterItem(task.data, task.timestamp, this._formatTimeAgo);
|
||||
}
|
||||
}
|
||||
|
||||
// --- 核心面板开关逻辑 ---
|
||||
openPanel() {
|
||||
if (this.isAnimating || !this.panel.classList.contains('hidden')) return;
|
||||
|
||||
this.isAnimating = true;
|
||||
this.panel.classList.remove('hidden');
|
||||
this.panel.classList.add('animate-panel-in');
|
||||
// 动画结束后,启动倒计时
|
||||
setTimeout(() => {
|
||||
this.panel.classList.remove('animate-panel-in');
|
||||
this.isAnimating = false;
|
||||
this._startCountdown(); // 启动倒计时
|
||||
}, 150);
|
||||
}
|
||||
|
||||
closePanel() {
|
||||
if (this.isAnimating || this.panel.classList.contains('hidden')) return;
|
||||
|
||||
this._stopCountdown(); // [修改] 关闭前立即停止倒计时
|
||||
this.isAnimating = true;
|
||||
this.panel.classList.add('animate-panel-out');
|
||||
setTimeout(() => {
|
||||
this.panel.classList.remove('animate-panel-out');
|
||||
this.panel.classList.add('hidden');
|
||||
this.isAnimating = false;
|
||||
}, 50);
|
||||
}
|
||||
|
||||
// --- [新增] 倒计时管理方法 ---
|
||||
/**
|
||||
* 启动或重启倒计时和进度条动画
|
||||
* @private
|
||||
*/
|
||||
_startCountdown() {
|
||||
this._stopCountdown(); // 先重置
|
||||
// 启动进度条动画
|
||||
this.countdownBar.classList.add('w-full', 'duration-[4950ms]');
|
||||
|
||||
// 启动圆环动画 (通过可靠的JS强制重绘)
|
||||
this.countdownRing.style.transition = 'none'; // 1. 禁用动画
|
||||
this.countdownRing.style.strokeDashoffset = '72.26'; // 2. 立即重置
|
||||
void this.countdownRing.offsetHeight; // 3. 强制浏览器重排
|
||||
this.countdownRing.style.transition = 'stroke-dashoffset 4.95s linear'; // 4. 重新启用动画
|
||||
this.countdownRing.style.strokeDashoffset = '0'; // 5. 设置目标值,开始动画
|
||||
|
||||
// 启动关闭计时器
|
||||
this.countdownTimer = setTimeout(() => {
|
||||
this.closePanel();
|
||||
}, 4950);
|
||||
}
|
||||
/**
|
||||
* 停止倒计时并重置进度条
|
||||
* @private
|
||||
*/
|
||||
_stopCountdown() {
|
||||
if (this.countdownTimer) {
|
||||
clearTimeout(this.countdownTimer);
|
||||
this.countdownTimer = null;
|
||||
}
|
||||
// 重置进度条的视觉状态
|
||||
this.countdownBar.classList.remove('w-full');
|
||||
|
||||
this.countdownRing.style.transition = 'none';
|
||||
this.countdownRing.style.strokeDashoffset = '72.26';
|
||||
}
|
||||
|
||||
// [NEW] A central handler for any action that confirms the user has seen the panel.
|
||||
_handleUserInteraction() {
|
||||
// 1. Stop the auto-close countdown because the user is now interacting.
|
||||
this._stopCountdown();
|
||||
// 2. If there were unread tasks, mark them as read *now*.
|
||||
if (this.hasUnreadCompletedTasks) {
|
||||
this.hasUnreadCompletedTasks = false;
|
||||
this._updateIndicatorState(); // The indicator light turns off at this moment.
|
||||
}
|
||||
}
|
||||
|
||||
_formatTimeAgo(date) {
|
||||
if (!date) return '';
|
||||
const seconds = Math.floor((new Date() - new Date(date)) / 1000);
|
||||
if (seconds < 2) return "刚刚";
|
||||
if (seconds < 60) return `${seconds}秒前`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}分钟前`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}小时前`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}天前`;
|
||||
}
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// [NEW] Toast 通知管理器
|
||||
// ===================================================================
|
||||
class ToastManager {
|
||||
constructor() {
|
||||
this.container = document.getElementById('toast-container');
|
||||
if (!this.container) {
|
||||
this.container = document.createElement('div');
|
||||
this.container.id = 'toast-container';
|
||||
this.container.className = 'fixed bottom-4 right-4 z-[100] w-full max-w-sm space-y-3'; // 宽度可稍大
|
||||
document.body.appendChild(this.container);
|
||||
}
|
||||
this.activeToasts = new Map(); // [NEW] 用于跟踪可更新的进度Toast
|
||||
}
|
||||
/**
|
||||
* 显示一个 Toast 通知
|
||||
* @param {string} message - The message to display.
|
||||
* @param {string} [type='info'] - 'info', 'success', or 'error'.
|
||||
* @param {number} [duration=4000] - Duration in milliseconds.
|
||||
*/
|
||||
show(message, type = 'info', duration = 4000) {
|
||||
const toastElement = this._createToastHtml(message, type);
|
||||
this.container.appendChild(toastElement);
|
||||
// 强制重绘以触发入场动画
|
||||
requestAnimationFrame(() => {
|
||||
toastElement.classList.remove('opacity-0', 'translate-y-2');
|
||||
toastElement.classList.add('opacity-100', 'translate-y-0');
|
||||
});
|
||||
// 设置定时器以移除 Toast
|
||||
setTimeout(() => {
|
||||
toastElement.classList.remove('opacity-100', 'translate-y-0');
|
||||
toastElement.classList.add('opacity-0', 'translate-y-2');
|
||||
// 在动画结束后从 DOM 中移除
|
||||
toastElement.addEventListener('transitionend', () => toastElement.remove(), { once: true });
|
||||
}, duration);
|
||||
}
|
||||
|
||||
// [NEW] 创建或更新一个带进度条的Toast
|
||||
showProgressToast(toastId, title, message, progress) {
|
||||
if (this.activeToasts.has(toastId)) {
|
||||
// --- 更新现有Toast ---
|
||||
const toastElement = this.activeToasts.get(toastId);
|
||||
const messageEl = toastElement.querySelector('.toast-message');
|
||||
const progressBar = toastElement.querySelector('.toast-progress-bar');
|
||||
|
||||
messageEl.textContent = `${message} - ${Math.round(progress)}%`;
|
||||
anime({
|
||||
targets: progressBar,
|
||||
width: `${progress}%`,
|
||||
duration: 400,
|
||||
easing: 'easeOutQuad'
|
||||
});
|
||||
} else {
|
||||
// --- 创建新的Toast ---
|
||||
const toastElement = this._createProgressToastHtml(toastId, title, message, progress);
|
||||
this.container.appendChild(toastElement);
|
||||
this.activeToasts.set(toastId, toastElement);
|
||||
requestAnimationFrame(() => {
|
||||
toastElement.classList.remove('opacity-0', 'translate-x-full');
|
||||
toastElement.classList.add('opacity-100', 'translate-x-0');
|
||||
});
|
||||
}
|
||||
}
|
||||
// [NEW] 移除一个进度Toast
|
||||
dismiss(toastId, success = null) {
|
||||
return new Promise((resolve) => {
|
||||
if (!this.activeToasts.has(toastId)) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const toastElement = this.activeToasts.get(toastId);
|
||||
const performFadeOut = () => {
|
||||
toastElement.classList.remove('opacity-100', 'translate-x-0');
|
||||
toastElement.classList.add('opacity-0', 'translate-x-full');
|
||||
toastElement.addEventListener('transitionend', () => {
|
||||
toastElement.remove();
|
||||
this.activeToasts.delete(toastId);
|
||||
resolve(); // Resolve the promise ONLY when the element is fully gone.
|
||||
}, { once: true });
|
||||
};
|
||||
if (success === null) { // Immediate dismissal
|
||||
performFadeOut();
|
||||
} else { // Graceful, animated dismissal
|
||||
const iconContainer = toastElement.querySelector('.toast-icon');
|
||||
const messageEl = toastElement.querySelector('.toast-message');
|
||||
if (success) {
|
||||
const progressBar = toastElement.querySelector('.toast-progress-bar');
|
||||
messageEl.textContent = '已完成';
|
||||
anime({
|
||||
targets: progressBar,
|
||||
width: '100%',
|
||||
duration: 300,
|
||||
easing: 'easeOutQuad',
|
||||
complete: () => {
|
||||
iconContainer.innerHTML = `<i class="fas fa-check-circle text-white"></i>`;
|
||||
iconContainer.className = `toast-icon bg-green-500`;
|
||||
setTimeout(performFadeOut, 900);
|
||||
}
|
||||
});
|
||||
} else { // Failure
|
||||
iconContainer.innerHTML = `<i class="fas fa-times-circle text-white"></i>`;
|
||||
iconContainer.className = `toast-icon bg-red-500`;
|
||||
messageEl.textContent = '失败';
|
||||
setTimeout(performFadeOut, 1200);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_createToastHtml(message, type) {
|
||||
const icons = {
|
||||
info: { class: 'bg-blue-500', icon: 'fa-info-circle' },
|
||||
success: { class: 'bg-green-500', icon: 'fa-check-circle' },
|
||||
error: { class: 'bg-red-500', icon: 'fa-exclamation-triangle' }
|
||||
};
|
||||
const typeInfo = icons[type] || icons.info;
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'toast-item opacity-0 translate-y-2 transition-all duration-300 ease-out'; // 初始状态为动画准备
|
||||
toast.innerHTML = `
|
||||
<div class="toast-icon ${typeInfo.class}">
|
||||
<i class="fas ${typeInfo.icon}"></i>
|
||||
</div>
|
||||
<div class="toast-content">
|
||||
<p class="toast-title">${this._capitalizeFirstLetter(type)}</p>
|
||||
<p class="toast-message">${message}</p>
|
||||
</div>
|
||||
`;
|
||||
return toast;
|
||||
}
|
||||
|
||||
// [NEW] 创建带进度条Toast的HTML结构
|
||||
_createProgressToastHtml(toastId, title, message, progress) {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'toast-item opacity-0 translate-x-full transition-all duration-300 ease-out';
|
||||
toast.dataset.toastId = toastId;
|
||||
toast.innerHTML = `
|
||||
<div class="toast-icon bg-blue-500">
|
||||
<i class="fas fa-spinner animate-spin"></i>
|
||||
</div>
|
||||
<div class="toast-content">
|
||||
<p class="toast-title">${title}</p>
|
||||
<p class="toast-message">${message} - ${Math.round(progress)}%</p>
|
||||
<div class="w-full bg-slate-200 dark:bg-zinc-700 rounded-full h-1 mt-1.5 overflow-hidden">
|
||||
<div class="toast-progress-bar bg-blue-500 h-1 rounded-full" style="width: ${progress}%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
return toast;
|
||||
}
|
||||
|
||||
_capitalizeFirstLetter(string) {
|
||||
return string.charAt(0).toUpperCase() + string.slice(1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const taskCenterManager = new TaskCenterManager();
|
||||
export const toastManager = new ToastManager();
|
||||
|
||||
// [OPTIONAL] 为了向后兼容或简化调用,可以导出一个独立的 showToast 函数
|
||||
export const showToast = (message, type, duration) => toastManager.show(message, type, duration);
|
||||
105
frontend/js/components/themeManager.js
Normal file
105
frontend/js/components/themeManager.js
Normal file
@@ -0,0 +1,105 @@
|
||||
// Filename: frontend/js/components/themeManager.js
|
||||
|
||||
/**
|
||||
* 负责管理应用程序的三态主题(系统、亮色、暗色)。
|
||||
* 封装了所有与主题切换相关的 DOM 操作、事件监听和 localStorage 交互。
|
||||
*/
|
||||
export const themeManager = {
|
||||
// 用于存储图标的 SVG HTML
|
||||
icons: {},
|
||||
|
||||
init: function() {
|
||||
this.html = document.documentElement;
|
||||
this.buttons = document.querySelectorAll('.theme-btn');
|
||||
this.cyclerBtn = document.getElementById('theme-cycler-btn');
|
||||
this.cyclerIconContainer = document.getElementById('theme-cycler-icon');
|
||||
|
||||
if (!this.html || this.buttons.length === 0 || !this.cyclerBtn || !this.cyclerIconContainer) {
|
||||
console.warn("ThemeManager init failed: one or more required elements not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
this.mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
|
||||
// 初始化时,从三按钮组中提取 SVG 并存储起来
|
||||
this.storeIcons();
|
||||
|
||||
// 绑定宽屏按钮组的点击事件
|
||||
this.buttons.forEach(btn => {
|
||||
btn.addEventListener('click', () => this.setTheme(btn.dataset.theme));
|
||||
});
|
||||
|
||||
// 绑定移动端循环按钮的点击事件
|
||||
this.cyclerBtn.addEventListener('click', () => this.cycleTheme());
|
||||
|
||||
this.mediaQuery.addEventListener('change', () => this.applyTheme());
|
||||
this.applyTheme();
|
||||
},
|
||||
|
||||
// 从现有按钮中提取并存储 SVG 图标
|
||||
storeIcons: function() {
|
||||
this.buttons.forEach(btn => {
|
||||
const theme = btn.dataset.theme;
|
||||
const svg = btn.querySelector('svg');
|
||||
if (theme && svg) {
|
||||
this.icons[theme] = svg.outerHTML;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// 循环切换主题的核心逻辑
|
||||
cycleTheme: function() {
|
||||
const themes = ['system', 'light', 'dark'];
|
||||
const currentTheme = this.getTheme();
|
||||
const currentIndex = themes.indexOf(currentTheme);
|
||||
const nextIndex = (currentIndex + 1) % themes.length; // brilliantly simple cycling logic
|
||||
this.setTheme(themes[nextIndex]);
|
||||
},
|
||||
|
||||
applyTheme: function() {
|
||||
let theme = this.getTheme();
|
||||
if (theme === 'system') {
|
||||
theme = this.mediaQuery.matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
if (theme === 'dark') {
|
||||
this.html.classList.add('dark');
|
||||
} else {
|
||||
this.html.classList.remove('dark');
|
||||
}
|
||||
|
||||
this.updateButtons();
|
||||
this.updateCyclerIcon();
|
||||
},
|
||||
|
||||
setTheme: function(theme) {
|
||||
localStorage.setItem('theme', theme);
|
||||
this.applyTheme();
|
||||
},
|
||||
|
||||
getTheme: function() {
|
||||
return localStorage.getItem('theme') || 'system';
|
||||
},
|
||||
|
||||
updateButtons: function() {
|
||||
const currentTheme = this.getTheme();
|
||||
this.buttons.forEach(btn => {
|
||||
if (btn.dataset.theme === currentTheme) {
|
||||
btn.classList.add('bg-white', 'dark:bg-zinc-700');
|
||||
} else {
|
||||
btn.classList.remove('bg-white', 'dark:bg-zinc-700');
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// 更新移动端循环按钮的图标
|
||||
updateCyclerIcon: function() {
|
||||
if (this.cyclerIconContainer) {
|
||||
const currentTheme = this.getTheme();
|
||||
// 从我们存储的 icons 对象中找到对应的 SVG 并注入
|
||||
if (this.icons[currentTheme]) {
|
||||
this.cyclerIconContainer.innerHTML = this.icons[currentTheme];
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
338
frontend/js/components/ui.js
Normal file
338
frontend/js/components/ui.js
Normal file
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* @file ui.js
|
||||
* @description Centralizes UI component classes for modals and common UI patterns.
|
||||
* This module exports singleton instances of `ModalManager` and `UIPatterns`
|
||||
* to ensure consistent UI behavior across the application.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Manages the display and interaction of various modals across the application.
|
||||
* This class centralizes modal logic to ensure consistency and ease of use.
|
||||
* It assumes specific HTML structures for modals (e.g., resultModal, progressModal).
|
||||
*/
|
||||
class ModalManager {
|
||||
/**
|
||||
* Shows a generic modal by its ID.
|
||||
* @param {string} modalId The ID of the modal element to show.
|
||||
*/
|
||||
show(modalId) {
|
||||
const modal = document.getElementById(modalId);
|
||||
if (modal) {
|
||||
modal.classList.remove("hidden");
|
||||
} else {
|
||||
console.error(`Modal with ID "${modalId}" not found.`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hides a generic modal by its ID.
|
||||
* @param {string} modalId The ID of the modal element to hide.
|
||||
*/
|
||||
hide(modalId) {
|
||||
const modal = document.getElementById(modalId);
|
||||
if (modal) {
|
||||
modal.classList.add("hidden");
|
||||
} else {
|
||||
console.error(`Modal with ID "${modalId}" not found.`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a confirmation dialog. This is a versatile method for 'Are you sure?' style prompts.
|
||||
* It dynamically sets the title, message, and confirm action for a generic confirmation modal.
|
||||
* @param {object} options - The options for the confirmation modal.
|
||||
* @param {string} options.modalId - The ID of the confirmation modal element (e.g., 'resetModal', 'deleteConfirmModal').
|
||||
* @param {string} options.title - The title to display in the modal header.
|
||||
* @param {string} options.message - The message to display in the modal body. Can contain HTML.
|
||||
* @param {function} options.onConfirm - The callback function to execute when the confirm button is clicked.
|
||||
* @param {boolean} [options.disableConfirm=false] - Whether the confirm button should be initially disabled.
|
||||
*/
|
||||
showConfirm({ modalId, title, message, onConfirm, disableConfirm = false }) {
|
||||
const modalElement = document.getElementById(modalId);
|
||||
if (!modalElement) {
|
||||
console.error(`Confirmation modal with ID "${modalId}" not found.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const titleElement = modalElement.querySelector('[id$="ModalTitle"]');
|
||||
const messageElement = modalElement.querySelector('[id$="ModalMessage"]');
|
||||
const confirmButton = modalElement.querySelector('[id^="confirm"]');
|
||||
|
||||
if (!titleElement || !messageElement || !confirmButton) {
|
||||
console.error(`Modal "${modalId}" is missing required child elements (title, message, or confirm button).`);
|
||||
return;
|
||||
}
|
||||
|
||||
titleElement.textContent = title;
|
||||
messageElement.innerHTML = message;
|
||||
confirmButton.disabled = disableConfirm;
|
||||
|
||||
// Re-clone the button to remove old event listeners and attach the new one.
|
||||
const newConfirmButton = confirmButton.cloneNode(true);
|
||||
confirmButton.parentNode.replaceChild(newConfirmButton, confirmButton);
|
||||
newConfirmButton.onclick = () => onConfirm();
|
||||
|
||||
this.show(modalId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a result modal to indicate the outcome of an operation (success or failure).
|
||||
* @param {boolean} success - If true, displays a success icon and title; otherwise, shows failure indicators.
|
||||
* @param {string|Node} message - The message to display. Can be a simple string or a complex DOM Node for rich content.
|
||||
* @param {boolean} [autoReload=false] - If true, the page will automatically reload when the modal is closed.
|
||||
*/
|
||||
showResult(success, message, autoReload = false) {
|
||||
const modalElement = document.getElementById("resultModal");
|
||||
if (!modalElement) {
|
||||
console.error("Result modal with ID 'resultModal' not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
const titleElement = document.getElementById("resultModalTitle");
|
||||
const messageElement = document.getElementById("resultModalMessage");
|
||||
const iconElement = document.getElementById("resultIcon");
|
||||
const confirmButton = document.getElementById("resultModalConfirmBtn");
|
||||
|
||||
if (!titleElement || !messageElement || !iconElement || !confirmButton) {
|
||||
console.error("Result modal is missing required child elements.");
|
||||
return;
|
||||
}
|
||||
|
||||
titleElement.textContent = success ? "操作成功" : "操作失败";
|
||||
|
||||
if (success) {
|
||||
iconElement.innerHTML = '<i class="fas fa-check-circle text-success-500"></i>';
|
||||
iconElement.className = "text-6xl mb-3 text-success-500";
|
||||
} else {
|
||||
iconElement.innerHTML = '<i class="fas fa-times-circle text-danger-500"></i>';
|
||||
iconElement.className = "text-6xl mb-3 text-danger-500";
|
||||
}
|
||||
|
||||
messageElement.innerHTML = "";
|
||||
if (typeof message === "string") {
|
||||
const messageDiv = document.createElement("div");
|
||||
messageDiv.innerText = message; // Use innerText for security with plain strings
|
||||
messageElement.appendChild(messageDiv);
|
||||
} else if (message instanceof Node) {
|
||||
messageElement.appendChild(message); // Append if it's already a DOM node
|
||||
} else {
|
||||
const messageDiv = document.createElement("div");
|
||||
messageDiv.innerText = String(message);
|
||||
messageElement.appendChild(messageDiv);
|
||||
}
|
||||
|
||||
confirmButton.onclick = () => this.closeResult(autoReload);
|
||||
this.show("resultModal");
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the result modal.
|
||||
* @param {boolean} [reload=false] - If true, reloads the page after closing the modal.
|
||||
*/
|
||||
closeResult(reload = false) {
|
||||
this.hide("resultModal");
|
||||
if (reload) {
|
||||
location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows and initializes the progress modal for long-running operations.
|
||||
* @param {string} title - The title to display for the progress modal.
|
||||
*/
|
||||
showProgress(title) {
|
||||
const modal = document.getElementById("progressModal");
|
||||
if (!modal) {
|
||||
console.error("Progress modal with ID 'progressModal' not found.");
|
||||
return;
|
||||
}
|
||||
const titleElement = document.getElementById("progressModalTitle");
|
||||
const statusText = document.getElementById("progressStatusText");
|
||||
const progressBar = document.getElementById("progressBar");
|
||||
const progressPercentage = document.getElementById("progressPercentage");
|
||||
const progressLog = document.getElementById("progressLog");
|
||||
const closeButton = document.getElementById("progressModalCloseBtn");
|
||||
const closeIcon = document.getElementById("closeProgressModalBtn");
|
||||
|
||||
if (!titleElement || !statusText || !progressBar || !progressPercentage || !progressLog || !closeButton || !closeIcon) {
|
||||
console.error("Progress modal is missing required child elements.");
|
||||
return;
|
||||
}
|
||||
|
||||
titleElement.textContent = title;
|
||||
statusText.textContent = "准备开始...";
|
||||
progressBar.style.width = "0%";
|
||||
progressPercentage.textContent = "0%";
|
||||
progressLog.innerHTML = "";
|
||||
closeButton.disabled = true;
|
||||
closeIcon.disabled = true;
|
||||
|
||||
this.show("progressModal");
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the progress bar and status text within the progress modal.
|
||||
* @param {number} processed - The number of items that have been processed.
|
||||
* @param {number} total - The total number of items to process.
|
||||
* @param {string} status - The current status message to display.
|
||||
*/
|
||||
updateProgress(processed, total, status) {
|
||||
const modal = document.getElementById("progressModal");
|
||||
if (!modal || modal.classList.contains('hidden')) return;
|
||||
|
||||
const progressBar = document.getElementById("progressBar");
|
||||
const progressPercentage = document.getElementById("progressPercentage");
|
||||
const statusText = document.getElementById("progressStatusText");
|
||||
const closeButton = document.getElementById("progressModalCloseBtn");
|
||||
const closeIcon = document.getElementById("closeProgressModalBtn");
|
||||
|
||||
const percentage = total > 0 ? Math.round((processed / total) * 100) : 0;
|
||||
progressBar.style.width = `${percentage}%`;
|
||||
progressPercentage.textContent = `${percentage}%`;
|
||||
statusText.textContent = status;
|
||||
|
||||
if (processed === total) {
|
||||
closeButton.disabled = false;
|
||||
closeIcon.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a log entry to the progress modal's log area.
|
||||
* @param {string} message - The log message to append.
|
||||
* @param {boolean} [isError=false] - If true, styles the log entry as an error.
|
||||
*/
|
||||
addProgressLog(message, isError = false) {
|
||||
const progressLog = document.getElementById("progressLog");
|
||||
if (!progressLog) return;
|
||||
|
||||
const logEntry = document.createElement("div");
|
||||
logEntry.textContent = message;
|
||||
logEntry.className = isError ? "text-danger-600" : "text-gray-700";
|
||||
progressLog.appendChild(logEntry);
|
||||
progressLog.scrollTop = progressLog.scrollHeight; // Auto-scroll to the latest log
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the progress modal.
|
||||
* @param {boolean} [reload=false] - If true, reloads the page after closing.
|
||||
*/
|
||||
closeProgress(reload = false) {
|
||||
this.hide("progressModal");
|
||||
if (reload) {
|
||||
location.reload();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides a collection of common UI patterns and animations.
|
||||
* This class includes helpers for creating engaging and consistent user experiences,
|
||||
* such as animated counters and collapsible sections.
|
||||
*/
|
||||
class UIPatterns {
|
||||
/**
|
||||
* Animates numerical values in elements from 0 to their target number.
|
||||
* The target number is read from the element's text content.
|
||||
* @param {string} selector - The CSS selector for the elements to animate (e.g., '.stat-value').
|
||||
* @param {number} [duration=1500] - The duration of the animation in milliseconds.
|
||||
*/
|
||||
animateCounters(selector = ".stat-value", duration = 1500) {
|
||||
const statValues = document.querySelectorAll(selector);
|
||||
statValues.forEach((valueElement) => {
|
||||
const finalValue = parseInt(valueElement.textContent, 10);
|
||||
if (isNaN(finalValue)) return;
|
||||
|
||||
if (!valueElement.dataset.originalValue) {
|
||||
valueElement.dataset.originalValue = valueElement.textContent;
|
||||
}
|
||||
|
||||
let startValue = 0;
|
||||
const startTime = performance.now();
|
||||
|
||||
const updateCounter = (currentTime) => {
|
||||
const elapsedTime = currentTime - startTime;
|
||||
if (elapsedTime < duration) {
|
||||
const progress = elapsedTime / duration;
|
||||
const easeOutValue = 1 - Math.pow(1 - progress, 3); // Ease-out cubic
|
||||
const currentValue = Math.floor(easeOutValue * finalValue);
|
||||
valueElement.textContent = currentValue;
|
||||
requestAnimationFrame(updateCounter);
|
||||
} else {
|
||||
valueElement.textContent = valueElement.dataset.originalValue; // Ensure final value is accurate
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(updateCounter);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles the visibility of a content section with a smooth height animation.
|
||||
* It expects a specific HTML structure where the header and content are within a common parent (e.g., a card).
|
||||
* The content element should have a `collapsed` class when hidden.
|
||||
* @param {HTMLElement} header - The header element that was clicked to trigger the toggle.
|
||||
*/
|
||||
toggleSection(header) {
|
||||
const card = header.closest(".stats-card");
|
||||
if (!card) return;
|
||||
|
||||
const content = card.querySelector(".key-content");
|
||||
const toggleIcon = header.querySelector(".toggle-icon");
|
||||
|
||||
if (!content || !toggleIcon) {
|
||||
console.error("Toggle section failed: Content or icon element not found.", { header });
|
||||
return;
|
||||
}
|
||||
|
||||
const isCollapsed = content.classList.contains("collapsed");
|
||||
toggleIcon.classList.toggle("collapsed", !isCollapsed);
|
||||
|
||||
if (isCollapsed) {
|
||||
// Expand
|
||||
content.classList.remove("collapsed");
|
||||
content.style.maxHeight = null;
|
||||
content.style.opacity = null;
|
||||
content.style.paddingTop = null;
|
||||
content.style.paddingBottom = null;
|
||||
content.style.overflow = "hidden";
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const targetHeight = content.scrollHeight;
|
||||
content.style.maxHeight = `${targetHeight}px`;
|
||||
content.style.opacity = "1";
|
||||
content.style.paddingTop = "1rem"; // Assumes p-4, adjust if needed
|
||||
content.style.paddingBottom = "1rem";
|
||||
|
||||
content.addEventListener("transitionend", function onExpansionEnd() {
|
||||
content.removeEventListener("transitionend", onExpansionEnd);
|
||||
if (!content.classList.contains("collapsed")) {
|
||||
content.style.maxHeight = "";
|
||||
content.style.overflow = "visible";
|
||||
}
|
||||
}, { once: true });
|
||||
});
|
||||
} else {
|
||||
// Collapse
|
||||
const currentHeight = content.scrollHeight;
|
||||
content.style.maxHeight = `${currentHeight}px`;
|
||||
content.style.overflow = "hidden";
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
content.style.maxHeight = "0px";
|
||||
content.style.opacity = "0";
|
||||
content.style.paddingTop = "0";
|
||||
content.style.paddingBottom = "0";
|
||||
content.classList.add("collapsed");
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports singleton instances of the UI component classes for easy import and use elsewhere.
|
||||
* This allows any part of the application to access the same instance of ModalManager and UIPatterns,
|
||||
* ensuring a single source of truth for UI component management.
|
||||
*/
|
||||
export const modalManager = new ModalManager();
|
||||
export const uiPatterns = new UIPatterns();
|
||||
|
||||
56
frontend/js/layout/base.js
Normal file
56
frontend/js/layout/base.js
Normal file
@@ -0,0 +1,56 @@
|
||||
// Filename: frontend/js/layout/base.js
|
||||
|
||||
// [模块导入]
|
||||
import { themeManager } from '../components/themeManager.js';
|
||||
import { apiFetch, apiFetchJson } from '../services/api.js';
|
||||
|
||||
/**
|
||||
* 激活当前页面的侧边栏导航项。
|
||||
*/
|
||||
function initActiveNav() {
|
||||
const currentPath = window.location.pathname;
|
||||
const navLinks = document.querySelectorAll('.nav-link');
|
||||
|
||||
navLinks.forEach(link => {
|
||||
const linkPath = link.getAttribute('href');
|
||||
if (linkPath && linkPath !== '/' && currentPath.startsWith(linkPath)) {
|
||||
const wrapper = link.closest('.nav-item-wrapper');
|
||||
if (wrapper) {
|
||||
wrapper.dataset.active = 'true';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 将核心 API 函数挂载到 window 对象,以便在需要时进行全局访问或调试。
|
||||
* 这充当了模块化世界和全局作用域之间的“桥梁”。
|
||||
*/
|
||||
function bridgeApiToGlobal() {
|
||||
window.apiFetch = apiFetch;
|
||||
window.apiFetchJson = apiFetchJson;
|
||||
console.log('[Bridge] apiFetch and apiFetchJson are now globally available.');
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化所有与 base.html 布局相关的全局UI元素和事件监听器。
|
||||
*/
|
||||
function initLayout() {
|
||||
console.log('[Init] Executing global layout JavaScript...');
|
||||
|
||||
// 1. 初始化侧边栏导航状态
|
||||
initActiveNav();
|
||||
|
||||
// 2. 初始化主题管理器
|
||||
themeManager.init();
|
||||
|
||||
// 3. 建立 API 函数的全局桥梁
|
||||
bridgeApiToGlobal();
|
||||
|
||||
// 4. (预留) 在此处添加未来可能的其他布局逻辑,例如侧边栏的折叠/展开功能
|
||||
// const sidebarToggle = document.getElementById('sidebar-toggle');
|
||||
// if (sidebarToggle) { ... }
|
||||
}
|
||||
|
||||
// 默认导出主初始化函数
|
||||
export default initLayout;
|
||||
50
frontend/js/main.js
Normal file
50
frontend/js/main.js
Normal file
@@ -0,0 +1,50 @@
|
||||
// Filename: frontend/js/main.js
|
||||
|
||||
// === 1. 导入通用组件 (这些是所有页面都可能用到的,保持静态导入) ===
|
||||
import SlidingTabs from './components/slidingTabs.js';
|
||||
import CustomSelect from './components/customSelect.js';
|
||||
import { modalManager, uiPatterns } from './components/ui.js';
|
||||
import { taskCenterManager, toastManager} from './components/taskCenter.js';
|
||||
// === 2. 导入布局专属的初始化模块 ===
|
||||
import initLayout from './layout/base.js';
|
||||
// === 3. 定义动态导入的页面模块映射 ===
|
||||
const pageModules = {
|
||||
// 键 'dashboard' 对应一个函数,该函数调用 import() 返回一个 Promise
|
||||
// esbuild 看到这个 import() 语法,就会自动将 dashboard.js 及其依赖打包成一个独立的 chunk 文件
|
||||
'dashboard': () => import('./pages/dashboard.js'),
|
||||
'keys': () => import('./pages/keys/index.js'),
|
||||
'logs': () => import('./pages/logs/index.js'),
|
||||
// 'settings': () => import('./pages/settings.js'), // 未来启用 settings 页面
|
||||
// 未来新增的页面,只需在这里添加一行映射,esbuild会自动处理
|
||||
};
|
||||
// === 4. 主执行逻辑,现在是异步的 ===
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
initLayout();
|
||||
// --- 通用组件初始化 (总会执行) ---
|
||||
const allTabContainers = document.querySelectorAll('[data-sliding-tabs-container]');
|
||||
allTabContainers.forEach(container => new SlidingTabs(container));
|
||||
const allSelectContainers = document.querySelectorAll('[data-custom-select-container]');
|
||||
allSelectContainers.forEach(container => new CustomSelect(container));
|
||||
taskCenterManager.init();
|
||||
|
||||
// --- 页面专属逻辑调度 (按需执行) ---
|
||||
const pageContainer = document.querySelector('[data-page-id]');
|
||||
if (pageContainer) {
|
||||
const pageId = pageContainer.dataset.pageId;
|
||||
if (pageId && pageModules[pageId]) {
|
||||
try {
|
||||
const pageModule = await pageModules[pageId]();
|
||||
if (pageModule.default && typeof pageModule.default === 'function') {
|
||||
pageModule.default();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to load module for page: ${pageId}`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
// 将管理器挂载到全局,方便在浏览器控制台调试
|
||||
window.modalManager = modalManager;
|
||||
window.taskCenterManager = taskCenterManager;
|
||||
window.toastManager = toastManager;
|
||||
window.uiPatterns = uiPatterns;
|
||||
34
frontend/js/pages/dashboard.js
Normal file
34
frontend/js/pages/dashboard.js
Normal file
@@ -0,0 +1,34 @@
|
||||
// frontend/js/pages/dashboard.js
|
||||
/**
|
||||
* @fileoverview Dashboard Page Initialization Module (Placeholder)
|
||||
*
|
||||
* @description
|
||||
* This file is the designated entry point for all modern, modular JavaScript
|
||||
* specific to the dashboard page.
|
||||
*
|
||||
* CURRENT STATUS:
|
||||
* As of [25.08.23], the dashboard's primary logic is still handled by legacy
|
||||
* scripts loaded via <script> tags in `dashboard.html` (e.g., `static/js/dashboard.js`).
|
||||
*
|
||||
* MIGRATION STRATEGY:
|
||||
* 1. Identify a piece of functionality in the legacy scripts (e.g., auto-refresh timer).
|
||||
* 2. Re-implement that functionality within the `init()` function below, following
|
||||
* modern ES module standards.
|
||||
* 3. Remove the corresponding code from the legacy script file.
|
||||
* 4. Repeat until the legacy scripts are empty and can be removed entirely.
|
||||
*
|
||||
* @version 0.1.0
|
||||
* @author [xof/团队名]
|
||||
*/
|
||||
export default function init() {
|
||||
// This console log serves as a confirmation that the modern module is being
|
||||
// correctly dispatched by main.js. It's safe to leave here during migration.
|
||||
console.log('[Modern Frontend] Dashboard module loaded. Future logic will execute here.');
|
||||
// === MIGRATION AREA ===
|
||||
// When you migrate a feature, add its initialization code here.
|
||||
// For example:
|
||||
//
|
||||
// import { initializeAutoRefresh } from '../features/autoRefresh.js';
|
||||
// initializeAutoRefresh();
|
||||
//
|
||||
}
|
||||
1182
frontend/js/pages/error_logs.js
Normal file
1182
frontend/js/pages/error_logs.js
Normal file
File diff suppressed because it is too large
Load Diff
1823
frontend/js/pages/keys.js
Normal file
1823
frontend/js/pages/keys.js
Normal file
File diff suppressed because it is too large
Load Diff
167
frontend/js/pages/keys/addApiModal.js
Normal file
167
frontend/js/pages/keys/addApiModal.js
Normal file
@@ -0,0 +1,167 @@
|
||||
// frontend/js/pages/keys/addApiModal.js
|
||||
|
||||
// [REFACTORED] 引入全局的 taskCenterManager 和 modalManager
|
||||
import { modalManager } from '../../components/ui.js';
|
||||
import { taskCenterManager, toastManager } from '../../components/taskCenter.js';
|
||||
import { apiKeyManager } from '../../components/apiKeyManager.js';
|
||||
import { isValidApiKeyFormat } from '../../utils/utils.js';
|
||||
|
||||
export default class AddApiModal {
|
||||
constructor({ onImportSuccess }) {
|
||||
this.modalId = 'add-api-modal';
|
||||
this.onImportSuccess = onImportSuccess;
|
||||
this.activeGroupId = null;
|
||||
|
||||
this.elements = {
|
||||
modal: document.getElementById(this.modalId),
|
||||
title: document.getElementById('add-api-modal-title'),
|
||||
inputView: document.getElementById('add-api-input-view'),
|
||||
textarea: document.getElementById('api-add-textarea'),
|
||||
importBtn: document.getElementById('add-api-import-btn'),
|
||||
validateCheckbox: document.getElementById('validate-on-import-checkbox'),
|
||||
};
|
||||
|
||||
if (!this.elements.modal) {
|
||||
throw new Error(`Modal with id "${this.modalId}" not found.`);
|
||||
}
|
||||
|
||||
this._initEventListeners();
|
||||
}
|
||||
|
||||
open(activeGroupId) {
|
||||
if (!activeGroupId) {
|
||||
console.error("Cannot open AddApiModal: activeGroupId is required.");
|
||||
return;
|
||||
}
|
||||
this.activeGroupId = activeGroupId;
|
||||
this._reset();
|
||||
modalManager.show(this.modalId);
|
||||
}
|
||||
|
||||
_initEventListeners() {
|
||||
this.elements.importBtn?.addEventListener('click', this._handleSubmit.bind(this));
|
||||
|
||||
const closeAction = () => {
|
||||
this._reset();
|
||||
modalManager.hide(this.modalId);
|
||||
};
|
||||
|
||||
const closeTriggers = this.elements.modal.querySelectorAll(`[data-modal-close="${this.modalId}"]`);
|
||||
closeTriggers.forEach(trigger => trigger.addEventListener('click', closeAction));
|
||||
this.elements.modal.addEventListener('click', (event) => {
|
||||
if (event.target === this.elements.modal) closeAction();
|
||||
});
|
||||
}
|
||||
|
||||
async _handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
const cleanedKeys = this._parseAndCleanKeys(this.elements.textarea.value);
|
||||
if (cleanedKeys.length === 0) {
|
||||
alert('没有检测到有效的API Keys。');
|
||||
return;
|
||||
}
|
||||
|
||||
this.elements.importBtn.disabled = true;
|
||||
this.elements.importBtn.innerHTML = `<i class="fas fa-spinner fa-spin mr-2"></i>正在启动...`;
|
||||
const addKeysTask = {
|
||||
start: async () => {
|
||||
const shouldValidate = this.elements.validateCheckbox.checked;
|
||||
const response = await apiKeyManager.addKeysToGroup(this.activeGroupId, cleanedKeys.join('\n'), shouldValidate);
|
||||
if (!response.success || !response.data) throw new Error(response.message || '启动导入任务失败。');
|
||||
return response.data;
|
||||
},
|
||||
poll: async (taskId) => {
|
||||
return await apiKeyManager.getTaskStatus(taskId, { noCache: true });
|
||||
},
|
||||
renderTaskCenterItem: (data, timestamp, formatTimeAgo) => {
|
||||
const timeAgo = formatTimeAgo(timestamp);
|
||||
let contentHtml = '';
|
||||
if (!data.is_running && !data.error) { // --- SUCCESS state ---
|
||||
const result = data.result || {};
|
||||
const newlyLinked = result.newly_linked_count || 0;
|
||||
const alreadyLinked = result.already_linked_count || 0;
|
||||
const summaryTitle = `批量链接 ${newlyLinked} Key,已跳过 ${alreadyLinked}`;
|
||||
|
||||
contentHtml = `
|
||||
<div class="task-item-main">
|
||||
<div class="task-item-icon-summary text-green-500"><i class="fas fa-check-circle"></i></div>
|
||||
<div class="task-item-content flex-grow">
|
||||
<div class="flex justify-between items-center cursor-pointer" data-task-toggle>
|
||||
<p class="task-item-title">${summaryTitle}</p>
|
||||
<i class="fas fa-chevron-down task-toggle-icon"></i>
|
||||
</div>
|
||||
<div class="task-details-content collapsed" data-task-content>
|
||||
<div class="task-details-body space-y-1">
|
||||
<p class="flex justify-between"><span>有效输入:</span> <span class="font-semibold">${data.total}</span></p>
|
||||
<p class="flex justify-between"><span>分组中已存在 (跳过):</span> <span class="font-semibold">${alreadyLinked}</span></p>
|
||||
<p class="flex justify-between font-bold"><span>新增链接:</span> <span>${newlyLinked}</span></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else if (!data.is_running && data.error) { // --- ERROR state ---
|
||||
contentHtml = `
|
||||
<div class="task-item-main">
|
||||
<div class="task-item-icon-summary text-red-500"><i class="fas fa-times-circle"></i></div>
|
||||
<div class="task-item-content flex-grow">
|
||||
<p class="task-item-title">批量添加失败</p>
|
||||
<p class="task-item-status text-red-500 truncate" title="${data.error || '未知错误'}">
|
||||
${data.error || '未知错误'}
|
||||
</p>
|
||||
</div>
|
||||
</div>`;
|
||||
} else { // --- RUNNING state ---
|
||||
contentHtml = `
|
||||
<div class="task-item-main gap-3">
|
||||
<div class="task-item-icon task-item-icon-running"><i class="fas fa-spinner animate-spin"></i></div>
|
||||
<div class="task-item-content flex-grow">
|
||||
<p class="task-item-title">批量添加 ${data.total} 个API Key</p>
|
||||
<p class="task-item-status">运行中...</p>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
return `${contentHtml}<div class="task-item-timestamp">${timeAgo}</div>`;
|
||||
},
|
||||
renderToastNarrative: (data, oldData, toastManager) => {
|
||||
const toastId = `task-${data.id}`;
|
||||
const progress = data.total > 0 ? (data.processed / data.total) * 100 : 0;
|
||||
// It just reports the current progress, that's its only job.
|
||||
toastManager.showProgressToast(toastId, `批量添加Key`, '处理中', progress); // (Change title for delete modal)
|
||||
},
|
||||
|
||||
// This now ONLY shows the FINAL summary toast, after everything else is done.
|
||||
onSuccess: (data) => {
|
||||
if (this.onImportSuccess) this.onImportSuccess(); // (Or onDeleteSuccess)
|
||||
const newlyLinked = data.result?.newly_linked_count || 0; // (Or unlinked_count)
|
||||
toastManager.show(`任务完成!成功链接 ${newlyLinked} 个Key。`, 'success'); // (Adjust text for delete)
|
||||
},
|
||||
|
||||
// This is the final error handler.
|
||||
onError: (data) => {
|
||||
toastManager.show(`任务失败: ${data.error || '未知错误'}`, 'error');
|
||||
}
|
||||
};
|
||||
// Pass the entire definition to the dispatcher
|
||||
taskCenterManager.startTask(addKeysTask);
|
||||
|
||||
modalManager.hide(this.modalId);
|
||||
this._reset();
|
||||
}
|
||||
|
||||
_reset() {
|
||||
// [REMOVED] 不再需要管理 resultView
|
||||
this.elements.title.textContent = '批量添加 API Keys';
|
||||
this.elements.inputView.classList.remove('hidden');
|
||||
this.elements.textarea.value = '';
|
||||
this.elements.textarea.disabled = false;
|
||||
this.elements.importBtn.disabled = false;
|
||||
this.elements.importBtn.innerHTML = '导入'; // 使用 innerHTML 避免潜在的 XSS
|
||||
}
|
||||
|
||||
_parseAndCleanKeys(text) {
|
||||
const keys = text.replace(/[,;]/g, ' ').split(/[\s\n]+/);
|
||||
const cleanedKeys = keys.map(key => key.trim()).filter(key => isValidApiKeyFormat(key));
|
||||
return [...new Set(cleanedKeys)];
|
||||
}
|
||||
}
|
||||
1434
frontend/js/pages/keys/apiKeyList.js
Normal file
1434
frontend/js/pages/keys/apiKeyList.js
Normal file
File diff suppressed because it is too large
Load Diff
90
frontend/js/pages/keys/cloneGroupModal.js
Normal file
90
frontend/js/pages/keys/cloneGroupModal.js
Normal file
@@ -0,0 +1,90 @@
|
||||
// Filename: frontend/js/pages/keys/cloneGroupModal.js
|
||||
|
||||
import { modalManager } from '../../components/ui.js';
|
||||
import { apiFetch } from '../../services/api.js';
|
||||
import { toastManager } from '../../components/taskCenter.js';
|
||||
|
||||
export default class CloneGroupModal {
|
||||
constructor({ onCloneSuccess }) {
|
||||
this.modalId = 'clone-group-modal';
|
||||
this.onCloneSuccess = onCloneSuccess;
|
||||
this.activeGroup = null;
|
||||
|
||||
this.elements = {
|
||||
modal: document.getElementById(this.modalId),
|
||||
title: document.getElementById('clone-group-modal-title'),
|
||||
confirmBtn: document.getElementById('clone-group-confirm-btn'),
|
||||
};
|
||||
|
||||
if (!this.elements.modal) {
|
||||
console.error(`Modal with id "${this.modalId}" not found. Ensure the HTML is in your document.`);
|
||||
return;
|
||||
}
|
||||
this._initEventListeners();
|
||||
}
|
||||
|
||||
open(group) {
|
||||
if (!group || !group.id) {
|
||||
console.error("Cannot open CloneGroupModal: a group object with an ID is required.");
|
||||
return;
|
||||
}
|
||||
this.activeGroup = group;
|
||||
this.elements.title.innerHTML = `确认克隆分组 <code class="text-base font-semibold text-blue-500">${group.display_name}</code>`;
|
||||
this._reset();
|
||||
modalManager.show(this.modalId);
|
||||
}
|
||||
|
||||
_initEventListeners() {
|
||||
this.elements.confirmBtn?.addEventListener('click', this._handleSubmit.bind(this));
|
||||
|
||||
const closeAction = () => {
|
||||
this._reset();
|
||||
modalManager.hide(this.modalId);
|
||||
};
|
||||
|
||||
const closeTriggers = this.elements.modal.querySelectorAll(`[data-modal-close="${this.modalId}"]`);
|
||||
closeTriggers.forEach(trigger => trigger.addEventListener('click', closeAction));
|
||||
this.elements.modal.addEventListener('click', (event) => {
|
||||
if (event.target === this.elements.modal) closeAction();
|
||||
});
|
||||
}
|
||||
|
||||
async _handleSubmit() {
|
||||
if (!this.activeGroup) return;
|
||||
|
||||
this.elements.confirmBtn.disabled = true;
|
||||
this.elements.confirmBtn.innerHTML = `<i class="fas fa-spinner fa-spin mr-2"></i>克隆中...`;
|
||||
|
||||
try {
|
||||
const endpoint = `/admin/keygroups/${this.activeGroup.id}/clone`;
|
||||
const response = await apiFetch(endpoint, {
|
||||
method: 'POST',
|
||||
noCache: true
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && result.data) {
|
||||
toastManager.show(`分组 '${this.activeGroup.display_name}' 已成功克隆。`, 'success');
|
||||
if (this.onCloneSuccess) {
|
||||
// Pass the entire new group object back to the main controller.
|
||||
this.onCloneSuccess(result.data);
|
||||
}
|
||||
modalManager.hide(this.modalId);
|
||||
} else {
|
||||
throw new Error(result.error?.message || result.message || '克隆失败,请稍后再试。');
|
||||
}
|
||||
} catch (error) {
|
||||
toastManager.show(`克隆失败: ${error.message}`, 'error');
|
||||
} finally {
|
||||
this._reset();
|
||||
}
|
||||
}
|
||||
|
||||
_reset() {
|
||||
if (this.elements.confirmBtn) {
|
||||
this.elements.confirmBtn.disabled = false;
|
||||
this.elements.confirmBtn.innerHTML = '确认克隆';
|
||||
}
|
||||
}
|
||||
}
|
||||
165
frontend/js/pages/keys/deleteApiModal.js
Normal file
165
frontend/js/pages/keys/deleteApiModal.js
Normal file
@@ -0,0 +1,165 @@
|
||||
// frontend/js/pages/keys/deleteApiModal.js
|
||||
|
||||
import { modalManager } from '../../components/ui.js';
|
||||
import { taskCenterManager, toastManager } from '../../components/taskCenter.js';
|
||||
import { apiKeyManager } from '../../components/apiKeyManager.js';
|
||||
import { isValidApiKeyFormat } from '../../utils/utils.js';
|
||||
|
||||
export default class DeleteApiModal {
|
||||
constructor({ onDeleteSuccess }) {
|
||||
this.modalId = 'delete-api-modal';
|
||||
this.onDeleteSuccess = onDeleteSuccess;
|
||||
this.activeGroupId = null;
|
||||
|
||||
this.elements = {
|
||||
modal: document.getElementById(this.modalId),
|
||||
textarea: document.getElementById('api-delete-textarea'),
|
||||
deleteBtn: document.getElementById(this.modalId).querySelector('.modal-btn-danger'),
|
||||
};
|
||||
|
||||
if (!this.elements.modal) {
|
||||
throw new Error(`Modal with id "${this.modalId}" not found.`);
|
||||
}
|
||||
|
||||
this._initEventListeners();
|
||||
}
|
||||
|
||||
open(activeGroupId) {
|
||||
if (!activeGroupId) {
|
||||
console.error("Cannot open DeleteApiModal: activeGroupId is required.");
|
||||
return;
|
||||
}
|
||||
this.activeGroupId = activeGroupId;
|
||||
this._reset();
|
||||
modalManager.show(this.modalId);
|
||||
}
|
||||
|
||||
_initEventListeners() {
|
||||
this.elements.deleteBtn?.addEventListener('click', this._handleSubmit.bind(this));
|
||||
|
||||
const closeAction = () => {
|
||||
this._reset();
|
||||
modalManager.hide(this.modalId);
|
||||
};
|
||||
|
||||
const closeTriggers = this.elements.modal.querySelectorAll(`[data-modal-close="${this.modalId}"]`);
|
||||
closeTriggers.forEach(trigger => trigger.addEventListener('click', closeAction));
|
||||
this.elements.modal.addEventListener('click', (event) => {
|
||||
if (event.target === this.elements.modal) closeAction();
|
||||
});
|
||||
}
|
||||
|
||||
async _handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
const cleanedKeys = this._parseAndCleanKeys(this.elements.textarea.value);
|
||||
if (cleanedKeys.length === 0) {
|
||||
alert('没有检测到有效的API Keys。');
|
||||
return;
|
||||
}
|
||||
this.elements.deleteBtn.disabled = true;
|
||||
this.elements.deleteBtn.innerHTML = `<i class="fas fa-spinner fa-spin mr-2"></i>正在启动...`;
|
||||
const deleteKeysTask = {
|
||||
start: async () => {
|
||||
const response = await apiKeyManager.unlinkKeysFromGroup(this.activeGroupId, cleanedKeys.join('\n'));
|
||||
if (!response.success || !response.data) throw new Error(response.message || '启动解绑任务失败。');
|
||||
return response.data;
|
||||
},
|
||||
poll: async (taskId) => {
|
||||
return await apiKeyManager.getTaskStatus(taskId, { noCache: true });
|
||||
},
|
||||
renderTaskCenterItem: (data, timestamp, formatTimeAgo) => {
|
||||
const timeAgo = formatTimeAgo(timestamp);
|
||||
let contentHtml = '';
|
||||
if (!data.is_running && !data.error) { // --- SUCCESS state ---
|
||||
const result = data.result || {};
|
||||
const unlinked = result.unlinked_count || 0;
|
||||
const deleted = result.hard_deleted_count || 0;
|
||||
const notFound = result.not_found_count || 0;
|
||||
const totalInput = data.total;
|
||||
const summaryTitle = `解绑 ${unlinked} Key,清理 ${deleted}`;
|
||||
|
||||
// [MODIFIED] Applied Flexbox layout for proper spacing.
|
||||
contentHtml = `
|
||||
<div class="task-item-main">
|
||||
<div class="task-item-icon-summary text-green-500"><i class="fas fa-check-circle"></i></div>
|
||||
<div class="task-item-content flex-grow">
|
||||
<div class="flex justify-between items-center cursor-pointer" data-task-toggle>
|
||||
<p class="task-item-title">${summaryTitle}</p>
|
||||
<i class="fas fa-chevron-down task-toggle-icon"></i>
|
||||
</div>
|
||||
<div class="task-details-content collapsed" data-task-content>
|
||||
<div class="task-details-body space-y-1">
|
||||
<p class="flex justify-between"><span>有效输入:</span> <span class="font-semibold">${totalInput}</span></p>
|
||||
<p class="flex justify-between"><span>未在分组中找到:</span> <span class="font-semibold">${notFound}</span></p>
|
||||
<p class="flex justify-between"><span>从分组中解绑:</span> <span class="font-semibold">${unlinked}</span></p>
|
||||
<p class="flex justify-between font-bold"><span>彻底清理孤立Key:</span> <span>${deleted}</span></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else if (!data.is_running && data.error) { // --- ERROR state ---
|
||||
// [MODIFIED] Applied Flexbox layout for proper spacing.
|
||||
contentHtml = `
|
||||
<div class="task-item-main">
|
||||
<div class="task-item-icon-summary text-red-500"><i class="fas fa-times-circle"></i></div>
|
||||
<div class="task-item-content flex-grow">
|
||||
<p class="task-item-title">批量删除失败</p>
|
||||
<p class="task-item-status text-red-500 truncate" title="${data.error || '未知错误'}">
|
||||
${data.error || '未知错误'}
|
||||
</p>
|
||||
</div>
|
||||
</div>`;
|
||||
} else { // --- RUNNING state ---
|
||||
// [MODIFIED] Applied Flexbox layout with gap for spacing.
|
||||
// [FIX] Replaced 'fa-spin' with Tailwind's 'animate-spin' for reliable animation.
|
||||
contentHtml = `
|
||||
<div class="task-item-main gap-3">
|
||||
<div class="task-item-icon task-item-icon-running"><i class="fas fa-spinner animate-spin"></i></div>
|
||||
<div class="task-item-content flex-grow">
|
||||
<p class="task-item-title">批量删除 ${data.total} 个API Key</p>
|
||||
<p class="task-item-status">运行中...</p>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
return `${contentHtml}<div class="task-item-timestamp">${timeAgo}</div>`;
|
||||
},
|
||||
// he Toast is now solely responsible for showing real-time progress.
|
||||
renderToastNarrative: (data, oldData, toastManager) => {
|
||||
const toastId = `task-${data.id}`;
|
||||
const progress = data.total > 0 ? (data.processed / data.total) * 100 : 0;
|
||||
// It just reports the current progress, that's its only job.
|
||||
toastManager.showProgressToast(toastId, `批量删除Key`, '处理中', progress); // (Change title for delete modal)
|
||||
},
|
||||
|
||||
// This now ONLY shows the FINAL summary toast, after everything else is done.
|
||||
onSuccess: (data) => {
|
||||
if (this.onDeleteSuccess) this.onDeleteSuccess(); // (Or onDeleteSuccess)
|
||||
const newlyLinked = data.result?.newly_linked_count || 0; // (Or unlinked_count)
|
||||
toastManager.show(`任务完成!成功删除 ${newlyLinked} 个Key。`, 'success'); // (Adjust text for delete)
|
||||
},
|
||||
|
||||
// This is the final error handler.
|
||||
onError: (data) => {
|
||||
toastManager.show(`任务失败: ${data.error || '未知错误'}`, 'error');
|
||||
}
|
||||
};
|
||||
taskCenterManager.startTask(deleteKeysTask);
|
||||
|
||||
modalManager.hide(this.modalId);
|
||||
this._reset();
|
||||
}
|
||||
|
||||
_reset() {
|
||||
this.elements.textarea.value = '';
|
||||
this.elements.deleteBtn.disabled = false;
|
||||
this.elements.deleteBtn.innerHTML = '删除';
|
||||
}
|
||||
|
||||
_parseAndCleanKeys(text) {
|
||||
const keys = text.replace(/[,;]/g, ' ').split(/[\s\n]+/);
|
||||
const cleanedKeys = keys.map(key => key.trim()).filter(key => isValidApiKeyFormat(key));
|
||||
return [...new Set(cleanedKeys)];
|
||||
}
|
||||
}
|
||||
|
||||
88
frontend/js/pages/keys/deleteGroupModal.js
Normal file
88
frontend/js/pages/keys/deleteGroupModal.js
Normal file
@@ -0,0 +1,88 @@
|
||||
// Filename: frontend/js/pages/keys/deleteGroupModal.js
|
||||
|
||||
import { modalManager } from '../../components/ui.js';
|
||||
import { apiFetch } from '../../services/api.js';
|
||||
import { toastManager } from '../../components/taskCenter.js';
|
||||
|
||||
export default class DeleteGroupModal {
|
||||
constructor({ onDeleteSuccess }) {
|
||||
this.modalId = 'delete-group-modal';
|
||||
this.onDeleteSuccess = onDeleteSuccess;
|
||||
this.activeGroup = null;
|
||||
this.elements = {
|
||||
modal: document.getElementById(this.modalId),
|
||||
title: document.getElementById('delete-group-modal-title'),
|
||||
confirmInput: document.getElementById('delete-group-confirm-input'),
|
||||
confirmBtn: document.getElementById('delete-group-confirm-btn'),
|
||||
};
|
||||
if (!this.elements.modal) {
|
||||
throw new Error(`Modal with id "${this.modalId}" not found.`);
|
||||
}
|
||||
this._initEventListeners();
|
||||
}
|
||||
open(group) {
|
||||
if (!group || !group.id) {
|
||||
console.error("Cannot open DeleteGroupModal: group object with id is required.");
|
||||
return;
|
||||
}
|
||||
this.activeGroup = group;
|
||||
this.elements.title.innerHTML = `确认删除分组 <code class="text-base font-semibold text-red-500">${group.display_name}</code>`;
|
||||
this._reset();
|
||||
modalManager.show(this.modalId);
|
||||
}
|
||||
_initEventListeners() {
|
||||
this.elements.confirmBtn?.addEventListener('click', this._handleSubmit.bind(this));
|
||||
this.elements.confirmInput?.addEventListener('input', () => {
|
||||
const isConfirmed = this.elements.confirmInput.value.trim() === '删除';
|
||||
this.elements.confirmBtn.disabled = !isConfirmed;
|
||||
});
|
||||
const closeAction = () => {
|
||||
this._reset();
|
||||
modalManager.hide(this.modalId);
|
||||
};
|
||||
|
||||
const closeTriggers = this.elements.modal.querySelectorAll(`[data-modal-close="${this.modalId}"]`);
|
||||
closeTriggers.forEach(trigger => trigger.addEventListener('click', closeAction));
|
||||
this.elements.modal.addEventListener('click', (event) => {
|
||||
if (event.target === this.elements.modal) closeAction();
|
||||
});
|
||||
}
|
||||
async _handleSubmit() {
|
||||
if (!this.activeGroup) return;
|
||||
this.elements.confirmBtn.disabled = true;
|
||||
this.elements.confirmBtn.innerHTML = `<i class="fas fa-spinner fa-spin mr-2"></i>删除中...`;
|
||||
try {
|
||||
// [FIX] Use apiFetch directly to call the backend endpoint.
|
||||
const endpoint = `/admin/keygroups/${this.activeGroup.id}`;
|
||||
const response = await apiFetch(endpoint, {
|
||||
method: 'DELETE',
|
||||
noCache: true // Ensure a fresh request
|
||||
});
|
||||
|
||||
const result = await response.json(); // Parse the JSON response
|
||||
if (result.success) {
|
||||
toastManager.show(`分组 '${this.activeGroup.display_name}' 已成功删除。`, 'success');
|
||||
if (this.onDeleteSuccess) {
|
||||
this.onDeleteSuccess(this.activeGroup.id);
|
||||
}
|
||||
modalManager.hide(this.modalId);
|
||||
} else {
|
||||
// Use the error message from the backend response
|
||||
throw new Error(result.error?.message || result.message || '删除失败,请稍后再试。');
|
||||
}
|
||||
} catch (error) {
|
||||
toastManager.show(`删除失败: ${error.message}`, 'error');
|
||||
} finally {
|
||||
// We do a full reset in finally to ensure the button state is always correct.
|
||||
this._reset();
|
||||
}
|
||||
}
|
||||
|
||||
_reset() {
|
||||
if (this.elements.confirmInput) this.elements.confirmInput.value = '';
|
||||
if (this.elements.confirmBtn) {
|
||||
this.elements.confirmBtn.disabled = true;
|
||||
this.elements.confirmBtn.innerHTML = '确认删除';
|
||||
}
|
||||
}
|
||||
}
|
||||
657
frontend/js/pages/keys/index.js
Normal file
657
frontend/js/pages/keys/index.js
Normal file
@@ -0,0 +1,657 @@
|
||||
// frontend/js/pages/keys/index.js
|
||||
|
||||
// --- 導入全局和頁面專屬模塊 ---
|
||||
import { modalManager } from '../../components/ui.js';
|
||||
import TagInput from '../../components/tagInput.js';
|
||||
import CustomSelect from '../../components/customSelect.js';
|
||||
import RequestSettingsModal from './requestSettingsModal.js';
|
||||
import AddApiModal from './addApiModal.js';
|
||||
import DeleteApiModal from './deleteApiModal.js';
|
||||
import KeyGroupModal from './keyGroupModal.js';
|
||||
import CloneGroupModal from './cloneGroupModal.js';
|
||||
import DeleteGroupModal from './deleteGroupModal.js';
|
||||
import { debounce } from '../../utils/utils.js';
|
||||
import { apiFetch, apiFetchJson } from '../../services/api.js';
|
||||
import { apiKeyManager } from '../../components/apiKeyManager.js';
|
||||
import { toastManager } from '../../components/taskCenter.js';
|
||||
import ApiKeyList from './apiKeyList.js';
|
||||
import Sortable from '../../vendor/sortable.esm.js';
|
||||
|
||||
class KeyGroupsPage {
|
||||
constructor() {
|
||||
this.state = {
|
||||
groups: [],
|
||||
groupDetailsCache: {},
|
||||
activeGroupId: null,
|
||||
isLoading: true,
|
||||
};
|
||||
|
||||
this.debouncedSaveOrder = debounce(this.saveGroupOrder.bind(this), 1500);
|
||||
|
||||
// elements對象現在只關心頁面級元素
|
||||
this.elements = {
|
||||
|
||||
dashboardTitle: document.querySelector('#group-dashboard h2'),
|
||||
dashboardControls: document.querySelector('#group-dashboard .flex.items-center.gap-x-3'),
|
||||
apiListContainer: document.getElementById('api-list-container'),
|
||||
groupListCollapsible: document.getElementById('group-list-collapsible'),
|
||||
desktopGroupContainer: document.querySelector('#desktop-group-cards-list .card-list-content'),
|
||||
mobileGroupContainer: document.getElementById('mobile-group-cards-list'),
|
||||
addGroupBtnContainer: document.getElementById('add-group-btn-container'),
|
||||
groupMenuToggle: document.getElementById('group-menu-toggle'),
|
||||
mobileActiveGroupDisplay: document.querySelector('.mobile-group-selector > div'),
|
||||
};
|
||||
|
||||
this.initialized = this.elements.desktopGroupContainer !== null &&
|
||||
this.elements.apiListContainer !== null;
|
||||
|
||||
if (this.initialized) {
|
||||
this.apiKeyList = new ApiKeyList(this.elements.apiListContainer);
|
||||
}
|
||||
// 實例化頁面專屬的子組件
|
||||
const allowedModelsInput = new TagInput(document.getElementById('allowed-models-container'), {
|
||||
validator: /^[a-z0-9\.-]+$/,
|
||||
validationMessage: '无效的模型格式'
|
||||
});
|
||||
// 验证上游地址:一个基础的 URL 格式验证
|
||||
const allowedUpstreamsInput = new TagInput(document.getElementById('allowed-upstreams-container'), {
|
||||
validator: /^(https?:\/\/)?[\w\.-]+\.[a-z]{2,}(\/[\w\.-]*)*\/?$/i,
|
||||
validationMessage: '无效的 URL 格式'
|
||||
});
|
||||
// 令牌验证:确保不为空即可
|
||||
const allowedTokensInput = new TagInput(document.getElementById('allowed-tokens-container'), {
|
||||
validator: /.+/,
|
||||
validationMessage: '令牌不能为空'
|
||||
});
|
||||
this.keyGroupModal = new KeyGroupModal({
|
||||
onSave: this.handleSaveGroup.bind(this),
|
||||
tagInputInstances: {
|
||||
models: allowedModelsInput,
|
||||
upstreams: allowedUpstreamsInput,
|
||||
tokens: allowedTokensInput,
|
||||
}
|
||||
});
|
||||
|
||||
this.deleteGroupModal = new DeleteGroupModal({
|
||||
onDeleteSuccess: (deletedGroupId) => {
|
||||
if (this.state.activeGroupId === deletedGroupId) {
|
||||
this.state.activeGroupId = null;
|
||||
this.apiKeyList.loadApiKeys(null);
|
||||
}
|
||||
this.loadKeyGroups(true);
|
||||
}
|
||||
});
|
||||
|
||||
this.addApiModal = new AddApiModal({
|
||||
onImportSuccess: () => this.apiKeyList.loadApiKeys(this.state.activeGroupId, true),
|
||||
});
|
||||
// CloneGroupModal
|
||||
this.cloneGroupModal = new CloneGroupModal({
|
||||
onCloneSuccess: (clonedGroup) => {
|
||||
if (clonedGroup && clonedGroup.id) {
|
||||
this.state.activeGroupId = clonedGroup.id;
|
||||
}
|
||||
this.loadKeyGroups(true);
|
||||
}
|
||||
});
|
||||
// DeleteApiModal
|
||||
this.deleteApiModal = new DeleteApiModal({
|
||||
onDeleteSuccess: () => this.apiKeyList.loadApiKeys(this.state.activeGroupId, true),
|
||||
});
|
||||
|
||||
this.requestSettingsModal = new RequestSettingsModal({
|
||||
onSave: this.handleSaveRequestSettings.bind(this)
|
||||
});
|
||||
this.activeTooltip = null;
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (!this.initialized) {
|
||||
console.error("KeyGroupsPage: Could not initialize. Essential container elements like 'desktopGroupContainer' or 'apiListContainer' are missing from the DOM.");
|
||||
return;
|
||||
}
|
||||
this.initEventListeners();
|
||||
if (this.apiKeyList) {
|
||||
this.apiKeyList.init();
|
||||
}
|
||||
await this.loadKeyGroups();
|
||||
}
|
||||
|
||||
initEventListeners() {
|
||||
// --- 模态框全局触发器 ---
|
||||
document.body.addEventListener('click', (event) => {
|
||||
const addGroupBtn = event.target.closest('.add-group-btn');
|
||||
const addApiBtn = event.target.closest('#add-api-btn');
|
||||
const deleteApiBtn = event.target.closest('#delete-api-btn');
|
||||
if (addGroupBtn) this.keyGroupModal.open();
|
||||
if (addApiBtn) this.addApiModal.open(this.state.activeGroupId);
|
||||
if (deleteApiBtn) this.deleteApiModal.open(this.state.activeGroupId);
|
||||
});
|
||||
|
||||
// --- 使用事件委託來統一處理儀表板上的所有操作 ---
|
||||
this.elements.dashboardControls?.addEventListener('click', (event) => {
|
||||
const button = event.target.closest('button[data-action]');
|
||||
if (!button) return;
|
||||
const action = button.dataset.action;
|
||||
const activeGroup = this.state.groups.find(g => g.id === this.state.activeGroupId);
|
||||
switch(action) {
|
||||
case 'edit-group':
|
||||
if (activeGroup) {
|
||||
this.openEditGroupModal(activeGroup.id);
|
||||
} else {
|
||||
alert("请先选择一个分组进行编辑。");
|
||||
}
|
||||
break;
|
||||
case 'open-settings':
|
||||
this.openRequestSettingsModal();
|
||||
break;
|
||||
case 'clone-group':
|
||||
if (activeGroup) {
|
||||
this.cloneGroupModal.open(activeGroup);
|
||||
} else {
|
||||
alert("请先选择一个分组进行克隆。");
|
||||
}
|
||||
break;
|
||||
case 'delete-group':
|
||||
console.log('Delete action triggered for group:', this.state.activeGroupId);
|
||||
this.deleteGroupModal.open(activeGroup);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// --- 核心交互区域的事件委托 ---
|
||||
// 在共同父级上监听群组卡片点击
|
||||
this.elements.groupListCollapsible?.addEventListener('click', (event) => {
|
||||
this.handleGroupCardClick(event);
|
||||
});
|
||||
|
||||
// 移动端菜单切换
|
||||
this.elements.groupMenuToggle?.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
const menu = this.elements.groupListCollapsible;
|
||||
if (!menu) return;
|
||||
menu.classList.toggle('hidden');
|
||||
setTimeout(() => {
|
||||
menu.classList.toggle('mobile-group-menu-active');
|
||||
}, 0);
|
||||
});
|
||||
|
||||
// Add a global listener to close the menu if clicking outside
|
||||
document.addEventListener('click', (event) => {
|
||||
const menu = this.elements.groupListCollapsible;
|
||||
const toggle = this.elements.groupMenuToggle;
|
||||
if (menu && menu.classList.contains('mobile-group-menu-active') && !menu.contains(event.target) && !toggle.contains(event.target)) {
|
||||
this._closeMobileMenu();
|
||||
}
|
||||
});
|
||||
|
||||
// ... [其他頁面級事件監聽] ...
|
||||
this.initCustomSelects();
|
||||
this.initTooltips();
|
||||
this.initDragAndDrop();
|
||||
this._initBatchActions();
|
||||
}
|
||||
|
||||
// 4. 数据获取与渲染逻辑
|
||||
async loadKeyGroups(force = false) {
|
||||
this.state.isLoading = true;
|
||||
try {
|
||||
const responseData = await apiFetchJson("/admin/keygroups", { noCache: force });
|
||||
if (responseData && responseData.success && Array.isArray(responseData.data)) {
|
||||
this.state.groups = responseData.data;
|
||||
} else {
|
||||
console.error("API response format is incorrect:", responseData);
|
||||
this.state.groups = [];
|
||||
}
|
||||
|
||||
if (this.state.groups.length > 0 && !this.state.activeGroupId) {
|
||||
this.state.activeGroupId = this.state.groups[0].id;
|
||||
}
|
||||
|
||||
this.renderGroupList();
|
||||
if (this.state.activeGroupId) {
|
||||
this.updateDashboard();
|
||||
}
|
||||
this.updateAllHealthIndicators();
|
||||
|
||||
} catch (error) {
|
||||
console.error("Failed to load or parse key groups:", error);
|
||||
this.state.groups = [];
|
||||
this.renderGroupList(); // 渲染空列表
|
||||
this.updateDashboard(); // 更新仪表盘为空状态
|
||||
} finally {
|
||||
this.state.isLoading = false;
|
||||
}
|
||||
if (this.state.activeGroupId) {
|
||||
this.updateDashboard();
|
||||
} else {
|
||||
// If no groups exist, ensure the API key list is also cleared.
|
||||
this.apiKeyList.loadApiKeys(null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to determine health indicator CSS classes based on success rate.
|
||||
* @param {number} rate - The success rate (0-100).
|
||||
* @returns {{ring: string, dot: string}} - The CSS classes for the ring and dot.
|
||||
*/
|
||||
_getHealthIndicatorClasses(rate) {
|
||||
if (rate >= 50) return { ring: 'bg-green-500/20', dot: 'bg-green-500' };
|
||||
if (rate >= 30) return { ring: 'bg-yellow-500/20', dot: 'bg-yellow-500' };
|
||||
if (rate >= 10) return { ring: 'bg-orange-500/20', dot: 'bg-orange-500' };
|
||||
return { ring: 'bg-red-500/20', dot: 'bg-red-500' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the list of group cards based on the current state.
|
||||
*/
|
||||
renderGroupList() {
|
||||
if (!this.state.groups) return;
|
||||
// --- 桌面端列表渲染 (最终卡片布局) ---
|
||||
const desktopListHtml = this.state.groups.map(group => {
|
||||
const isActive = group.id === this.state.activeGroupId;
|
||||
const cardClass = isActive ? 'group-card-active' : 'group-card-inactive';
|
||||
const successRate = 100; // Placeholder
|
||||
const healthClasses = this._getHealthIndicatorClasses(successRate);
|
||||
|
||||
// [核心修正] 同时生成两种类型的标签
|
||||
const channelTag = this._getChannelTypeTag(group.channel_type || 'Local');
|
||||
const customTags = this._getCustomTags(group.custom_tags); // 假设 group.custom_tags 是一个数组
|
||||
return `
|
||||
<div class="${cardClass}" data-group-id="${group.id}" data-success-rate="${successRate}">
|
||||
<div class="flex items-center gap-x-3">
|
||||
<div data-health-indicator class="health-indicator-ring ${healthClasses.ring}">
|
||||
<div data-health-dot class="health-indicator-dot ${healthClasses.dot}"></div>
|
||||
</div>
|
||||
<div class="grow">
|
||||
<!-- [最终布局] 1. 名称 -> 2. 描述 -> 3. 标签 -->
|
||||
<h3 class="font-semibold text-sm">${group.display_name}</h3>
|
||||
<p class="card-sub-text my-1.5">${group.description || 'No description available'}</p>
|
||||
<div class="flex items-center gap-x-1.5 flex-wrap">
|
||||
${channelTag}
|
||||
${customTags}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
if (this.elements.desktopGroupContainer) {
|
||||
this.elements.desktopGroupContainer.innerHTML = desktopListHtml;
|
||||
if (this.elements.addGroupBtnContainer) {
|
||||
this.elements.desktopGroupContainer.parentElement.appendChild(this.elements.addGroupBtnContainer);
|
||||
}
|
||||
}
|
||||
// --- 移动端列表渲染 (保持不变) ---
|
||||
const mobileListHtml = this.state.groups.map(group => {
|
||||
const isActive = group.id === this.state.activeGroupId;
|
||||
const cardClass = isActive ? 'group-card-active' : 'group-card-inactive';
|
||||
return `
|
||||
<div class="${cardClass}" data-group-id="${group.id}">
|
||||
<h3 class="font-semibold text-sm">${group.display_name})</h3>
|
||||
<p class="card-sub-text my-1.5">${group.description || 'No description available'}</p>
|
||||
</div>`;
|
||||
}).join('');
|
||||
if (this.elements.mobileGroupContainer) {
|
||||
this.elements.mobileGroupContainer.innerHTML = mobileListHtml;
|
||||
}
|
||||
}
|
||||
|
||||
// 事件处理器和UI更新函数,现在完全由 state 驱动
|
||||
handleGroupCardClick(event) {
|
||||
const clickedCard = event.target.closest('[data-group-id]');
|
||||
if (!clickedCard) return;
|
||||
const groupId = parseInt(clickedCard.dataset.groupId, 10);
|
||||
if (this.state.activeGroupId !== groupId) {
|
||||
this.state.activeGroupId = groupId;
|
||||
this.renderGroupList();
|
||||
this.updateDashboard(); // updateDashboard 现在会处理 API key 的加载
|
||||
}
|
||||
|
||||
|
||||
if (window.innerWidth < 1024) {
|
||||
this._closeMobileMenu();
|
||||
}
|
||||
}
|
||||
|
||||
// [NEW HELPER METHOD] Centralizes the logic for closing the mobile menu.
|
||||
_closeMobileMenu() {
|
||||
const menu = this.elements.groupListCollapsible;
|
||||
if (!menu) return;
|
||||
|
||||
menu.classList.remove('mobile-group-menu-active');
|
||||
menu.classList.add('hidden');
|
||||
}
|
||||
|
||||
updateDashboard() {
|
||||
const activeGroup = this.state.groups.find(g => g.id === this.state.activeGroupId);
|
||||
|
||||
if (activeGroup) {
|
||||
if (this.elements.dashboardTitle) {
|
||||
this.elements.dashboardTitle.textContent = `${activeGroup.display_name}`;
|
||||
}
|
||||
if (this.elements.mobileActiveGroupDisplay) {
|
||||
this.elements.mobileActiveGroupDisplay.innerHTML = `
|
||||
<h3 class="font-semibold text-sm">${activeGroup.display_name}</h3>
|
||||
<p class="card-sub-text">当前选择</p>`;
|
||||
}
|
||||
// 更新 Dashboard 时,加载对应的 API Keys
|
||||
this.apiKeyList.setActiveGroup(activeGroup.id, activeGroup.display_name);
|
||||
this.apiKeyList.loadApiKeys(activeGroup.id);
|
||||
} else {
|
||||
if (this.elements.dashboardTitle) this.elements.dashboardTitle.textContent = 'No Group Selected';
|
||||
if (this.elements.mobileActiveGroupDisplay) this.elements.mobileActiveGroupDisplay.innerHTML = `<h3 class="font-semibold text-sm">请选择一个分组</h3>`;
|
||||
// 如果没有选中的分组,清空 API Key 列表
|
||||
this.apiKeyList.loadApiKeys(null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the saving of a key group with modern toast notifications.
|
||||
* @param {object} groupData The data collected from the KeyGroupModal.
|
||||
*/
|
||||
async handleSaveGroup(groupData) {
|
||||
const isEditing = !!groupData.id;
|
||||
const endpoint = isEditing ? `/admin/keygroups/${groupData.id}` : '/admin/keygroups';
|
||||
const method = isEditing ? 'PUT' : 'POST';
|
||||
console.log(`[CONTROLLER] ${isEditing ? 'Updating' : 'Creating'} group...`, { endpoint, method, data: groupData });
|
||||
try {
|
||||
const response = await apiFetch(endpoint, {
|
||||
method: method,
|
||||
body: JSON.stringify(groupData),
|
||||
noCache: true
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || 'An unknown error occurred on the server.');
|
||||
}
|
||||
if (isEditing) {
|
||||
console.log(`[CACHE INVALIDATION] Deleting cached details for group ${groupData.id}.`);
|
||||
delete this.state.groupDetailsCache[groupData.id];
|
||||
}
|
||||
|
||||
if (!isEditing && result.data && result.data.id) {
|
||||
this.state.activeGroupId = result.data.id;
|
||||
}
|
||||
|
||||
toastManager.show(`分组 "${groupData.display_name}" 已成功保存。`, 'success');
|
||||
|
||||
await this.loadKeyGroups(true);
|
||||
} catch (error) {
|
||||
console.error(`Failed to save group:`, error.message);
|
||||
toastManager.show(`保存失败: ${error.message}`, 'error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the KeyGroupModal for editing, utilizing a cache-then-fetch strategy.
|
||||
* @param {number} groupId The ID of the group to edit.
|
||||
*/
|
||||
async openEditGroupModal(groupId) {
|
||||
// Step 1: Check the details cache first.
|
||||
if (this.state.groupDetailsCache[groupId]) {
|
||||
console.log(`[CACHE HIT] Using cached details for group ${groupId}.`);
|
||||
// If details exist, open the modal immediately with the cached data.
|
||||
this.keyGroupModal.open(this.state.groupDetailsCache[groupId]);
|
||||
return;
|
||||
}
|
||||
// Step 2: If not in cache, fetch from the API.
|
||||
console.log(`[CACHE MISS] Fetching details for group ${groupId}.`);
|
||||
try {
|
||||
// NOTE: No complex UI spinners on the button itself. The user just waits a moment.
|
||||
const endpoint = `/admin/keygroups/${groupId}`;
|
||||
const responseData = await apiFetchJson(endpoint, { noCache: true });
|
||||
if (responseData && responseData.success) {
|
||||
const groupDetails = responseData.data;
|
||||
// Step 3: Store the newly fetched details in the cache.
|
||||
this.state.groupDetailsCache[groupId] = groupDetails;
|
||||
|
||||
// Step 4: Open the modal with the fetched data.
|
||||
this.keyGroupModal.open(groupDetails);
|
||||
} else {
|
||||
throw new Error(responseData.message || 'Failed to load group details.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch details for group ${groupId}:`, error);
|
||||
alert(`无法加载分组详情: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async openRequestSettingsModal() {
|
||||
if (!this.state.activeGroupId) {
|
||||
modalManager.showResult(false, "请先选择一个分组。");
|
||||
return;
|
||||
}
|
||||
// [重構] 簡化後的邏輯:獲取數據,然後調用子模塊的 open 方法
|
||||
console.log(`Opening request settings for group ID: ${this.state.activeGroupId}`);
|
||||
const data = {}; // 模擬從API獲取數據
|
||||
this.requestSettingsModal.open(data);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* @param {object} data The data collected from the RequestSettingsModal.
|
||||
*/
|
||||
async handleSaveRequestSettings(data) {
|
||||
if (!this.state.activeGroupId) {
|
||||
throw new Error("No active group selected.");
|
||||
}
|
||||
console.log(`[CONTROLLER] Saving request settings for group ${this.state.activeGroupId}:`, data);
|
||||
// 此處執行API調用
|
||||
// await apiFetch(...)
|
||||
// 成功後可以觸發一個全局通知或刷新列表
|
||||
// this.loadKeyGroups();
|
||||
return Promise.resolve(); // 模擬API調用成功
|
||||
}
|
||||
|
||||
initCustomSelects() {
|
||||
const customSelects = document.querySelectorAll('.custom-select');
|
||||
customSelects.forEach(select => new CustomSelect(select));
|
||||
}
|
||||
|
||||
_initBatchActions() {}
|
||||
|
||||
/**
|
||||
* Sends the new group UI order to the backend API.
|
||||
* @param {Array<object>} orderData - An array of objects, e.g., [{id: 1, order: 0}, {id: 2, order: 1}]
|
||||
*/
|
||||
async saveGroupOrder(orderData) {
|
||||
console.log('Debounced save triggered. Sending UI order to API:', orderData);
|
||||
try {
|
||||
// 调用您已验证成功的API端点
|
||||
const response = await apiFetch('/admin/keygroups/order', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(orderData),
|
||||
noCache: true
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!result.success) {
|
||||
// 如果后端返回操作失败,抛出错误
|
||||
throw new Error(result.message || 'Failed to save UI order on the server.');
|
||||
}
|
||||
console.log('UI order saved successfully.');
|
||||
// (可选) 在这里可以显示一个短暂的 "保存成功" 的提示消息 (Toast/Snackbar)
|
||||
} catch (error) {
|
||||
console.error('Failed to save new group UI order:', error);
|
||||
// [重要] 如果API调用失败,应该重新加载一次分组列表,
|
||||
// 以便UI回滚到数据库中存储的、未经修改的正确顺序。
|
||||
this.loadKeyGroups();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes drag-and-drop functionality for the group list.
|
||||
*/
|
||||
initDragAndDrop() {
|
||||
const container = this.elements.desktopGroupContainer;
|
||||
if (!container) return;
|
||||
|
||||
new Sortable(container, {
|
||||
animation: 150,
|
||||
ghostClass: 'sortable-ghost',
|
||||
dragClass: 'sortable-drag',
|
||||
filter: '#add-group-btn-container',
|
||||
onEnd: (evt) => {
|
||||
const groupCards = Array.from(container.querySelectorAll('[data-group-id]'));
|
||||
const orderedState = groupCards.map(card => {
|
||||
const cardId = parseInt(card.dataset.groupId, 10);
|
||||
return this.state.groups.find(group => group.id === cardId);
|
||||
}).filter(Boolean);
|
||||
|
||||
if (orderedState.length !== this.state.groups.length) {
|
||||
console.error("Drag-and-drop failed: Could not map all DOM elements to state. Aborting.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新正确的状态数组
|
||||
this.state.groups = orderedState;
|
||||
|
||||
const payload = this.state.groups.map((group, index) => ({
|
||||
id: group.id,
|
||||
order: index
|
||||
}));
|
||||
|
||||
this.debouncedSaveOrder(payload);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to generate a styled HTML tag for the channel type.
|
||||
* @param {string} type - The channel type string (e.g., 'OpenAI', 'Azure').
|
||||
* @returns {string} - The generated HTML span element.
|
||||
*/
|
||||
_getChannelTypeTag(type) {
|
||||
if (!type) return ''; // 如果没有类型,则返回空字符串
|
||||
const styles = {
|
||||
'OpenAI': 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300',
|
||||
'Azure': 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300',
|
||||
'Claude': 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300',
|
||||
'Gemini': 'bg-sky-100 text-sky-800 dark:bg-sky-900 dark:text-sky-300',
|
||||
'Local': 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
|
||||
};
|
||||
const baseClass = 'inline-block text-xs font-medium px-2 py-0.5 rounded-md';
|
||||
const tagClass = styles[type] || styles['Local']; // 如果类型未知,则使用默认样式
|
||||
return `<span class="${baseClass} ${tagClass}">${type}</span>`;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Generates styled HTML for custom tags with deterministically assigned colors.
|
||||
* @param {string[]} tags - An array of custom tag strings.
|
||||
* @returns {string} - The generated HTML for all custom tags.
|
||||
*/
|
||||
_getCustomTags(tags) {
|
||||
if (!tags || !Array.isArray(tags) || tags.length === 0) {
|
||||
return '';
|
||||
}
|
||||
// 预设的彩色背景调色板 (Tailwind classes)
|
||||
const colorPalette = [
|
||||
'bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-300',
|
||||
'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300',
|
||||
'bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-300',
|
||||
'bg-cyan-100 text-cyan-800 dark:bg-cyan-900 dark:text-cyan-300',
|
||||
'bg-teal-100 text-teal-800 dark:bg-teal-900 dark:text-teal-300',
|
||||
'bg-lime-100 text-lime-800 dark:bg-lime-900 dark:text-lime-300',
|
||||
];
|
||||
const baseClass = 'inline-block text-xs font-medium px-2 py-0.5 rounded-md';
|
||||
return tags.map(tag => {
|
||||
// 使用一个简单的确定性哈希算法,确保同一个标签名总能获得同一种颜色
|
||||
let hash = 0;
|
||||
for (let i = 0; i < tag.length; i++) {
|
||||
hash += tag.charCodeAt(i);
|
||||
}
|
||||
const colorClass = colorPalette[hash % colorPalette.length];
|
||||
return `<span class="${baseClass} ${colorClass}">${tag}</span>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
|
||||
_updateHealthIndicator(cardElement) {
|
||||
const rate = parseFloat(cardElement.dataset.successRate);
|
||||
if (isNaN(rate)) return;
|
||||
|
||||
const indicator = cardElement.querySelector('[data-health-indicator]');
|
||||
const dot = cardElement.querySelector('[data-health-dot]');
|
||||
if (!indicator || !dot) return;
|
||||
|
||||
const colors = {
|
||||
green: ['bg-green-500/20', 'bg-green-500'],
|
||||
yellow: ['bg-yellow-500/20', 'bg-yellow-500'],
|
||||
orange: ['bg-orange-500/20', 'bg-orange-500'],
|
||||
red: ['bg-red-500/20', 'bg-red-500'],
|
||||
};
|
||||
|
||||
Object.values(colors).forEach(([bgClass, dotClass]) => {
|
||||
indicator.classList.remove(bgClass);
|
||||
dot.classList.remove(dotClass);
|
||||
});
|
||||
|
||||
let newColor;
|
||||
if (rate >= 50) newColor = colors.green;
|
||||
else if (rate >= 25) newColor = colors.yellow;
|
||||
else if (rate >= 10) newColor = colors.orange;
|
||||
else newColor = colors.red;
|
||||
|
||||
indicator.classList.add(newColor[0]);
|
||||
dot.classList.add(newColor[1]);
|
||||
}
|
||||
|
||||
updateAllHealthIndicators() {
|
||||
if (!this.elements.groupListCollapsible) return;
|
||||
const allCards = this.elements.groupListCollapsible.querySelectorAll('[data-success-rate]');
|
||||
allCards.forEach(card => this._updateHealthIndicator(card));
|
||||
}
|
||||
|
||||
initTooltips() {
|
||||
const tooltipIcons = document.querySelectorAll('.tooltip-icon');
|
||||
tooltipIcons.forEach(icon => {
|
||||
icon.addEventListener('mouseenter', (e) => this.showTooltip(e));
|
||||
icon.addEventListener('mouseleave', () => this.hideTooltip());
|
||||
});
|
||||
}
|
||||
|
||||
showTooltip(e) {
|
||||
this.hideTooltip();
|
||||
|
||||
const target = e.currentTarget;
|
||||
const text = target.dataset.tooltipText;
|
||||
if (!text) return;
|
||||
|
||||
const tooltip = document.createElement('div');
|
||||
tooltip.className = 'global-tooltip';
|
||||
tooltip.textContent = text;
|
||||
document.body.appendChild(tooltip);
|
||||
this.activeTooltip = tooltip;
|
||||
|
||||
const targetRect = target.getBoundingClientRect();
|
||||
const tooltipRect = tooltip.getBoundingClientRect();
|
||||
|
||||
let top = targetRect.top - tooltipRect.height - 8;
|
||||
let left = targetRect.left + (targetRect.width / 2) - (tooltipRect.width / 2);
|
||||
|
||||
if (top < 0) top = targetRect.bottom + 8;
|
||||
if (left < 0) left = 8;
|
||||
if (left + tooltipRect.width > window.innerWidth) {
|
||||
left = window.innerWidth - tooltipRect.width - 8;
|
||||
}
|
||||
|
||||
tooltip.style.top = `${top}px`;
|
||||
tooltip.style.left = `${left}px`;
|
||||
}
|
||||
|
||||
hideTooltip() {
|
||||
if (this.activeTooltip) {
|
||||
this.activeTooltip.remove();
|
||||
this.activeTooltip = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default function init() {
|
||||
console.log('[Modern Frontend] Keys page controller loaded.');
|
||||
const page = new KeyGroupsPage();
|
||||
page.init();
|
||||
}
|
||||
221
frontend/js/pages/keys/keyGroupModal.js
Normal file
221
frontend/js/pages/keys/keyGroupModal.js
Normal file
@@ -0,0 +1,221 @@
|
||||
// frontend/js/pages/keys/keyGroupModal.js
|
||||
|
||||
import { modalManager } from '../../components/ui.js';
|
||||
|
||||
const MAX_GROUP_NAME_LENGTH = 32;
|
||||
|
||||
export default class KeyGroupModal {
|
||||
constructor({ onSave, tagInputInstances }) {
|
||||
this.modalId = 'keygroup-modal';
|
||||
this.onSave = onSave;
|
||||
this.tagInputs = tagInputInstances;
|
||||
this.editingGroupId = null;
|
||||
|
||||
const modal = document.getElementById(this.modalId);
|
||||
if (!modal) {
|
||||
throw new Error(`Modal with id "${this.modalId}" not found.`);
|
||||
}
|
||||
|
||||
this.elements = {
|
||||
modal: modal,
|
||||
title: document.getElementById('modal-title'),
|
||||
saveBtn: document.getElementById('modal-save-btn'),
|
||||
|
||||
// 表单字段
|
||||
nameInput: document.getElementById('group-name'),
|
||||
nameHelper: document.getElementById('group-name-helper'),
|
||||
displayNameInput: document.getElementById('group-display-name'),
|
||||
descriptionInput: document.getElementById('group-description'),
|
||||
strategySelect: document.getElementById('group-strategy'),
|
||||
maxRetriesInput: document.getElementById('group-max-retries'),
|
||||
failureThresholdInput: document.getElementById('group-key-blacklist-threshold'),
|
||||
enableProxyToggle: document.getElementById('group-enable-proxy'),
|
||||
enableSmartGatewayToggle: document.getElementById('group-enable-smart-gateway'),
|
||||
|
||||
// 自动验证设置
|
||||
enableKeyCheckToggle: document.getElementById('group-enable-key-check'),
|
||||
keyCheckSettingsPanel: document.getElementById('key-check-settings'),
|
||||
keyCheckModelInput: document.getElementById('group-key-check-model'),
|
||||
keyCheckIntervalInput: document.getElementById('group-key-check-interval-minutes'),
|
||||
keyCheckConcurrencyInput: document.getElementById('group-key-check-concurrency'),
|
||||
keyCooldownInput: document.getElementById('group-key-cooldown-minutes'),
|
||||
keyCheckEndpointInput: document.getElementById('group-key-check-endpoint'),
|
||||
};
|
||||
|
||||
this._initEventListeners();
|
||||
}
|
||||
|
||||
open(groupData = null) {
|
||||
this._populateForm(groupData);
|
||||
modalManager.show(this.modalId);
|
||||
}
|
||||
|
||||
close() {
|
||||
modalManager.hide(this.modalId);
|
||||
}
|
||||
|
||||
_initEventListeners() {
|
||||
if (this.elements.saveBtn) {
|
||||
this.elements.saveBtn.addEventListener('click', this._handleSave.bind(this));
|
||||
}
|
||||
if (this.elements.nameInput) {
|
||||
this.elements.nameInput.addEventListener('input', this._sanitizeGroupName.bind(this));
|
||||
}
|
||||
// 自动验证开关控制面板显隐
|
||||
if (this.elements.enableKeyCheckToggle) {
|
||||
this.elements.enableKeyCheckToggle.addEventListener('change', (e) => {
|
||||
this.elements.keyCheckSettingsPanel.classList.toggle('hidden', !e.target.checked);
|
||||
});
|
||||
}
|
||||
|
||||
const closeAction = () => this.close();
|
||||
const closeTriggers = this.elements.modal.querySelectorAll(`[data-modal-close="${this.modalId}"]`);
|
||||
closeTriggers.forEach(trigger => trigger.addEventListener('click', closeAction));
|
||||
this.elements.modal.addEventListener('click', (event) => {
|
||||
if (event.target === this.elements.modal) closeAction();
|
||||
});
|
||||
}
|
||||
// 实时净化 group name 的哨兵函数
|
||||
_sanitizeGroupName(event) {
|
||||
const input = event.target;
|
||||
let value = input.value;
|
||||
// 1. Convert to lowercase.
|
||||
value = value.toLowerCase();
|
||||
// 2. Remove all illegal characters.
|
||||
value = value.replace(/[^a-z0-9-]/g, '');
|
||||
// 3. Enforce the length limit by truncating.
|
||||
if (value.length > MAX_GROUP_NAME_LENGTH) {
|
||||
value = value.substring(0, MAX_GROUP_NAME_LENGTH);
|
||||
}
|
||||
if (input.value !== value) {
|
||||
input.value = value;
|
||||
}
|
||||
}
|
||||
|
||||
async _handleSave() {
|
||||
// [MODIFICATION] The save button's disabled state is now reset in a finally block for robustness.
|
||||
this._sanitizeGroupName({ target: this.elements.nameInput });
|
||||
const data = this._collectFormData();
|
||||
if (!data.name || !data.display_name) {
|
||||
alert('分组名称和显示名称是必填项。');
|
||||
return;
|
||||
}
|
||||
// 最终提交前的正则验证
|
||||
const groupNameRegex = /^[a-z0-9-]+$/;
|
||||
if (!groupNameRegex.test(data.name) || data.name.length > MAX_GROUP_NAME_LENGTH) {
|
||||
alert('分组名称格式无效。仅限使用小写字母、数字和连字符(-),且长度不超过32个字符。');
|
||||
return;
|
||||
}
|
||||
if (this.onSave) {
|
||||
this.elements.saveBtn.disabled = true;
|
||||
this.elements.saveBtn.textContent = '保存中...';
|
||||
try {
|
||||
await this.onSave(data);
|
||||
this.close();
|
||||
} catch (error) {
|
||||
console.error("Failed to save key group:", error);
|
||||
} finally {
|
||||
this.elements.saveBtn.disabled = false;
|
||||
this.elements.saveBtn.textContent = '保存';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
_populateForm(data) {
|
||||
if (data) { // 编辑模式
|
||||
this.editingGroupId = data.id;
|
||||
this.elements.title.textContent = '编辑 Key Group';
|
||||
this.elements.nameInput.value = data.name || '';
|
||||
this.elements.nameInput.disabled = false;
|
||||
this.elements.displayNameInput.value = data.display_name || '';
|
||||
this.elements.descriptionInput.value = data.description || '';
|
||||
this.elements.strategySelect.value = data.polling_strategy || 'random';
|
||||
this.elements.enableProxyToggle.checked = data.enable_proxy || false;
|
||||
|
||||
const settings = data.settings && data.settings.SettingsJSON ? data.settings.SettingsJSON : {};
|
||||
|
||||
this.elements.maxRetriesInput.value = settings.max_retries ?? '';
|
||||
this.elements.failureThresholdInput.value = settings.key_blacklist_threshold ?? '';
|
||||
this.elements.enableSmartGatewayToggle.checked = settings.enable_smart_gateway || false;
|
||||
|
||||
const isKeyCheckEnabled = settings.enable_key_check || false;
|
||||
this.elements.enableKeyCheckToggle.checked = isKeyCheckEnabled;
|
||||
|
||||
this.elements.keyCheckSettingsPanel.classList.toggle('hidden', !isKeyCheckEnabled);
|
||||
this.elements.keyCheckModelInput.value = settings.key_check_model || '';
|
||||
this.elements.keyCheckIntervalInput.value = settings.key_check_interval_minutes ?? '';
|
||||
this.elements.keyCheckConcurrencyInput.value = settings.key_check_concurrency ?? '';
|
||||
this.elements.keyCooldownInput.value = settings.key_cooldown_minutes ?? '';
|
||||
this.elements.keyCheckEndpointInput.value = settings.key_check_endpoint || '';
|
||||
|
||||
this.tagInputs.models.setValues(data.allowed_models || []);
|
||||
this.tagInputs.upstreams.setValues(data.allowed_upstreams || []);
|
||||
this.tagInputs.tokens.setValues(data.allowed_tokens || []);
|
||||
|
||||
} else { // 创建模式
|
||||
this.editingGroupId = null;
|
||||
this.elements.title.textContent = '创建新的 Key Group';
|
||||
this._resetForm();
|
||||
}
|
||||
}
|
||||
|
||||
_collectFormData() {
|
||||
const parseIntOrNull = (value) => {
|
||||
const trimmed = value.trim();
|
||||
return trimmed === '' ? null : parseInt(trimmed, 10);
|
||||
};
|
||||
const formData = {
|
||||
name: this.elements.nameInput.value.trim(),
|
||||
display_name: this.elements.displayNameInput.value.trim(),
|
||||
description: this.elements.descriptionInput.value.trim(),
|
||||
polling_strategy: this.elements.strategySelect.value,
|
||||
max_retries: parseIntOrNull(this.elements.maxRetriesInput.value),
|
||||
key_blacklist_threshold: parseIntOrNull(this.elements.failureThresholdInput.value),
|
||||
enable_proxy: this.elements.enableProxyToggle.checked,
|
||||
enable_smart_gateway: this.elements.enableSmartGatewayToggle.checked,
|
||||
|
||||
enable_key_check: this.elements.enableKeyCheckToggle.checked,
|
||||
key_check_model: this.elements.keyCheckModelInput.value.trim() || null,
|
||||
key_check_interval_minutes: parseIntOrNull(this.elements.keyCheckIntervalInput.value),
|
||||
key_check_concurrency: parseIntOrNull(this.elements.keyCheckConcurrencyInput.value),
|
||||
key_cooldown_minutes: parseIntOrNull(this.elements.keyCooldownInput.value),
|
||||
key_check_endpoint: this.elements.keyCheckEndpointInput.value.trim() || null,
|
||||
|
||||
allowed_models: this.tagInputs.models.getValues(),
|
||||
allowed_upstreams: this.tagInputs.upstreams.getValues(),
|
||||
allowed_tokens: this.tagInputs.tokens.getValues(),
|
||||
};
|
||||
if (this.editingGroupId) {
|
||||
formData.id = this.editingGroupId;
|
||||
}
|
||||
return formData;
|
||||
}
|
||||
|
||||
/**
|
||||
* [核心修正] 完整且健壮的表单重置方法
|
||||
*/
|
||||
_resetForm() {
|
||||
this.elements.nameInput.value = '';
|
||||
this.elements.nameInput.disabled = false;
|
||||
this.elements.displayNameInput.value = '';
|
||||
this.elements.descriptionInput.value = '';
|
||||
this.elements.strategySelect.value = 'random';
|
||||
this.elements.maxRetriesInput.value = '';
|
||||
this.elements.failureThresholdInput.value = '';
|
||||
this.elements.enableProxyToggle.checked = false;
|
||||
this.elements.enableSmartGatewayToggle.checked = false;
|
||||
|
||||
this.elements.enableKeyCheckToggle.checked = false;
|
||||
this.elements.keyCheckSettingsPanel.classList.add('hidden');
|
||||
this.elements.keyCheckModelInput.value = '';
|
||||
this.elements.keyCheckIntervalInput.value = '';
|
||||
this.elements.keyCheckConcurrencyInput.value = '';
|
||||
this.elements.keyCooldownInput.value = '';
|
||||
this.elements.keyCheckEndpointInput.value = '';
|
||||
|
||||
this.tagInputs.models.setValues([]);
|
||||
this.tagInputs.upstreams.setValues([]);
|
||||
this.tagInputs.tokens.setValues([]);
|
||||
}
|
||||
}
|
||||
306
frontend/js/pages/keys/requestSettingsModal.js
Normal file
306
frontend/js/pages/keys/requestSettingsModal.js
Normal file
@@ -0,0 +1,306 @@
|
||||
// frontend/js/pages/keys/requestSettingsModal.js
|
||||
import { modalManager } from '../../components/ui.js';
|
||||
|
||||
export default class RequestSettingsModal {
|
||||
constructor({ onSave }) {
|
||||
this.modalId = 'request-settings-modal';
|
||||
this.modal = document.getElementById(this.modalId);
|
||||
this.onSave = onSave; // 注入保存回調函數
|
||||
if (!this.modal) {
|
||||
throw new Error(`Modal with id "${this.modalId}" not found.`);
|
||||
}
|
||||
// 映射所有內部DOM元素
|
||||
this.elements = {
|
||||
saveBtn: document.getElementById('request-settings-save-btn'),
|
||||
customHeadersContainer: document.getElementById('CUSTOM_HEADERS_container'),
|
||||
addCustomHeaderBtn: document.getElementById('addCustomHeaderBtn'),
|
||||
streamOptimizerEnabled: document.getElementById('STREAM_OPTIMIZER_ENABLED'),
|
||||
streamingSettingsPanel: document.getElementById('streaming-settings-panel'),
|
||||
streamMinDelay: document.getElementById('STREAM_MIN_DELAY'),
|
||||
streamMaxDelay: document.getElementById('STREAM_MAX_DELAY'),
|
||||
streamShortTextThresh: document.getElementById('STREAM_SHORT_TEXT_THRESHOLD'),
|
||||
streamLongTextThresh: document.getElementById('STREAM_LONG_TEXT_THRESHOLD'),
|
||||
streamChunkSize: document.getElementById('STREAM_CHUNK_SIZE'),
|
||||
fakeStreamEnabled: document.getElementById('FAKE_STREAM_ENABLED'),
|
||||
fakeStreamInterval: document.getElementById('FAKE_STREAM_EMPTY_DATA_INTERVAL_SECONDS'),
|
||||
toolsCodeExecutionEnabled: document.getElementById('TOOLS_CODE_EXECUTION_ENABLED'),
|
||||
urlContextEnabled: document.getElementById('URL_CONTEXT_ENABLED'),
|
||||
showSearchLink: document.getElementById('SHOW_SEARCH_LINK'),
|
||||
showThinkingProcess: document.getElementById('SHOW_THINKING_PROCESS'),
|
||||
safetySettingsContainer: document.getElementById('SAFETY_SETTINGS_container'),
|
||||
addSafetySettingBtn: document.getElementById('addSafetySettingBtn'),
|
||||
configOverrides: document.getElementById('group-config-overrides'),
|
||||
};
|
||||
this._initEventListeners();
|
||||
}
|
||||
// --- 公共 API ---
|
||||
/**
|
||||
* 打開模態框並填充數據
|
||||
* @param {object} data - 用於填充表單的數據
|
||||
*/
|
||||
open(data) {
|
||||
this._populateForm(data);
|
||||
modalManager.show(this.modalId);
|
||||
}
|
||||
/**
|
||||
* 關閉模態框
|
||||
*/
|
||||
close() {
|
||||
modalManager.hide(this.modalId);
|
||||
}
|
||||
// --- 內部事件與邏輯 ---
|
||||
_initEventListeners() {
|
||||
// 事件委託,處理動態添加元素的移除
|
||||
this.modal.addEventListener('click', (e) => {
|
||||
const removeBtn = e.target.closest('.remove-btn');
|
||||
if (removeBtn) {
|
||||
removeBtn.parentElement.remove();
|
||||
}
|
||||
});
|
||||
if (this.elements.addCustomHeaderBtn) {
|
||||
this.elements.addCustomHeaderBtn.addEventListener('click', () => this.addCustomHeaderItem());
|
||||
}
|
||||
if (this.elements.addSafetySettingBtn) {
|
||||
this.elements.addSafetySettingBtn.addEventListener('click', () => this.addSafetySettingItem());
|
||||
}
|
||||
if (this.elements.saveBtn) {
|
||||
this.elements.saveBtn.addEventListener('click', this._handleSave.bind(this));
|
||||
}
|
||||
if (this.elements.streamOptimizerEnabled) {
|
||||
this.elements.streamOptimizerEnabled.addEventListener('change', (e) => {
|
||||
this._toggleStreamingPanel(e.target.checked);
|
||||
});
|
||||
}
|
||||
// --- 完整的、統一的關閉邏輯 ---
|
||||
const closeAction = () => {
|
||||
// 此處無需 _reset(),因為每次 open 都會重新 populate
|
||||
modalManager.hide(this.modalId);
|
||||
};
|
||||
// 綁定所有帶有 data-modal-close 屬性的按鈕
|
||||
const closeTriggers = this.modal.querySelectorAll(`[data-modal-close="${this.modalId}"]`);
|
||||
closeTriggers.forEach(trigger => {
|
||||
trigger.addEventListener('click', closeAction);
|
||||
});
|
||||
// 綁定點擊模態框背景遮罩層的事件
|
||||
this.modal.addEventListener('click', (event) => {
|
||||
if (event.target === this.modal) {
|
||||
closeAction();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async _handleSave() {
|
||||
const data = this._collectFormData();
|
||||
if (this.onSave) {
|
||||
try {
|
||||
if (this.elements.saveBtn) {
|
||||
this.elements.saveBtn.disabled = true;
|
||||
this.elements.saveBtn.textContent = 'Saving...';
|
||||
}
|
||||
// 調用注入的回調函數,將數據傳遞給頁面控制器處理
|
||||
await this.onSave(data);
|
||||
this.close();
|
||||
} catch (error) {
|
||||
console.error("Failed to save request settings:", error);
|
||||
alert(`保存失敗: ${error.message}`); // 在模態框內給出反饋
|
||||
} finally {
|
||||
if (this.elements.saveBtn) {
|
||||
this.elements.saveBtn.disabled = false;
|
||||
this.elements.saveBtn.textContent = 'Save Changes';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// --- 所有表單處理輔助方法 ---
|
||||
_populateForm(data = {}) {
|
||||
// [完整遷移] 填充表單的邏輯
|
||||
const isStreamOptimizerEnabled = !!data.stream_optimizer_enabled;
|
||||
this._setToggle(this.elements.streamOptimizerEnabled, isStreamOptimizerEnabled);
|
||||
this._toggleStreamingPanel(isStreamOptimizerEnabled);
|
||||
this._setValue(this.elements.streamMinDelay, data.stream_min_delay);
|
||||
this._setValue(this.elements.streamMaxDelay, data.stream_max_delay);
|
||||
this._setValue(this.elements.streamShortTextThresh, data.stream_short_text_threshold);
|
||||
this._setValue(this.elements.streamLongTextThresh, data.stream_long_text_threshold);
|
||||
this._setValue(this.elements.streamChunkSize, data.stream_chunk_size);
|
||||
this._setToggle(this.elements.fakeStreamEnabled, data.fake_stream_enabled);
|
||||
this._setValue(this.elements.fakeStreamInterval, data.fake_stream_empty_data_interval_seconds);
|
||||
this._setToggle(this.elements.toolsCodeExecutionEnabled, data.tools_code_execution_enabled);
|
||||
this._setToggle(this.elements.urlContextEnabled, data.url_context_enabled);
|
||||
this._setToggle(this.elements.showSearchLink, data.show_search_link);
|
||||
this._setToggle(this.elements.showThinkingProcess, data.show_thinking_process);
|
||||
this._setValue(this.elements.configOverrides, data.config_overrides);
|
||||
// --- Dynamic & Complex Fields ---
|
||||
this._populateKVItems(this.elements.customHeadersContainer, data.custom_headers, this.addCustomHeaderItem.bind(this));
|
||||
|
||||
this._clearContainer(this.elements.safetySettingsContainer);
|
||||
if (data.safety_settings && typeof data.safety_settings === 'object') {
|
||||
for (const [key, value] of Object.entries(data.safety_settings)) {
|
||||
this.addSafetySettingItem(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Collects all data from the form fields and returns it as an object.
|
||||
* @returns {object} The collected request configuration data.
|
||||
*/
|
||||
collectFormData() {
|
||||
return {
|
||||
// Simple Toggles & Inputs
|
||||
stream_optimizer_enabled: this.elements.streamOptimizerEnabled.checked,
|
||||
stream_min_delay: parseInt(this.elements.streamMinDelay.value, 10),
|
||||
stream_max_delay: parseInt(this.elements.streamMaxDelay.value, 10),
|
||||
stream_short_text_threshold: parseInt(this.elements.streamShortTextThresh.value, 10),
|
||||
stream_long_text_threshold: parseInt(this.elements.streamLongTextThresh.value, 10),
|
||||
stream_chunk_size: parseInt(this.elements.streamChunkSize.value, 10),
|
||||
fake_stream_enabled: this.elements.fakeStreamEnabled.checked,
|
||||
fake_stream_empty_data_interval_seconds: parseInt(this.elements.fakeStreamInterval.value, 10),
|
||||
tools_code_execution_enabled: this.elements.toolsCodeExecutionEnabled.checked,
|
||||
url_context_enabled: this.elements.urlContextEnabled.checked,
|
||||
show_search_link: this.elements.showSearchLink.checked,
|
||||
show_thinking_process: this.elements.showThinkingProcess.checked,
|
||||
config_overrides: this.elements.configOverrides.value,
|
||||
|
||||
// Dynamic & Complex Fields
|
||||
custom_headers: this._collectKVItems(this.elements.customHeadersContainer),
|
||||
safety_settings: this._collectSafetySettings(this.elements.safetySettingsContainer),
|
||||
|
||||
// TODO: Collect from Tag Inputs
|
||||
// image_models: this.imageModelsInput.getValues(),
|
||||
};
|
||||
}
|
||||
|
||||
// 控制流式面板显示/隐藏的辅助函数
|
||||
_toggleStreamingPanel(is_enabled) {
|
||||
if (this.elements.streamingSettingsPanel) {
|
||||
if (is_enabled) {
|
||||
this.elements.streamingSettingsPanel.classList.remove('hidden');
|
||||
} else {
|
||||
this.elements.streamingSettingsPanel.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new key-value pair item for Custom Headers.
|
||||
* @param {string} [key=''] - The initial key.
|
||||
* @param {string} [value=''] - The initial value.
|
||||
*/
|
||||
addCustomHeaderItem(key = '', value = '') {
|
||||
const container = this.elements.customHeadersContainer;
|
||||
const item = document.createElement('div');
|
||||
item.className = 'dynamic-kv-item';
|
||||
item.innerHTML = `
|
||||
<input type="text" class="modal-input text-xs bg-zinc-100 dark:bg-zinc-700/50" placeholder="Header Name" value="${key}">
|
||||
<input type="text" class="modal-input text-xs" placeholder="Header Value" value="${value}">
|
||||
<button type="button" class="remove-btn text-zinc-400 hover:text-red-500 transition-colors"><i class="fas fa-trash-alt"></i></button>
|
||||
`;
|
||||
container.appendChild(item);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new item for Safety Settings.
|
||||
* @param {string} [category=''] - The initial category.
|
||||
* @param {string} [threshold=''] - The initial threshold.
|
||||
*/
|
||||
addSafetySettingItem(category = '', threshold = '') {
|
||||
const container = this.elements.safetySettingsContainer;
|
||||
const item = document.createElement('div');
|
||||
item.className = 'safety-setting-item flex items-center gap-x-2';
|
||||
|
||||
const harmCategories = [
|
||||
"HARM_CATEGORY_HARASSMENT", "HARM_CATEGORY_HATE_SPEECH",
|
||||
"HARM_CATEGORY_SEXUALLY_EXPLICIT", "HARM_CATEGORY_DANGEROUS_CONTENT","HARM_CATEGORY_DANGEROUS_CONTENT",
|
||||
"HARM_CATEGORY_CIVIC_INTEGRITY"
|
||||
];
|
||||
const harmThresholds = [
|
||||
"BLOCK_OFF","BLOCK_NONE", "BLOCK_LOW_AND_ABOVE",
|
||||
"BLOCK_MEDIUM_AND_ABOVE", "BLOCK_ONLY_HIGH"
|
||||
];
|
||||
|
||||
const categorySelect = document.createElement('select');
|
||||
categorySelect.className = 'modal-input flex-grow'; // .modal-input 在静态<select>上是有效的
|
||||
harmCategories.forEach(cat => {
|
||||
const option = new Option(cat.replace('HARM_CATEGORY_', ''), cat);
|
||||
if (cat === category) option.selected = true;
|
||||
categorySelect.add(option);
|
||||
});
|
||||
const thresholdSelect = document.createElement('select');
|
||||
thresholdSelect.className = 'modal-input w-48';
|
||||
harmThresholds.forEach(thr => {
|
||||
const option = new Option(thr.replace('BLOCK_', '').replace('_AND_ABOVE', '+'), thr);
|
||||
if (thr === threshold) option.selected = true;
|
||||
thresholdSelect.add(option);
|
||||
});
|
||||
|
||||
const removeButton = document.createElement('button');
|
||||
removeButton.type = 'button';
|
||||
removeButton.className = 'remove-btn text-zinc-400 hover:text-red-500 transition-colors';
|
||||
removeButton.innerHTML = `<i class="fas fa-trash-alt"></i>`;
|
||||
item.appendChild(categorySelect);
|
||||
item.appendChild(thresholdSelect);
|
||||
item.appendChild(removeButton);
|
||||
container.appendChild(item);
|
||||
}
|
||||
|
||||
// --- Private Helper Methods for Form Handling ---
|
||||
|
||||
_setValue(element, value) {
|
||||
if (element && value !== null && value !== undefined) {
|
||||
element.value = value;
|
||||
}
|
||||
}
|
||||
|
||||
_setToggle(element, value) {
|
||||
if (element) {
|
||||
element.checked = !!value;
|
||||
}
|
||||
}
|
||||
|
||||
_clearContainer(container) {
|
||||
if (container) {
|
||||
// Keep the first child if it's a template or header
|
||||
const firstChild = container.firstElementChild;
|
||||
const isTemplate = firstChild && (firstChild.tagName === 'TEMPLATE' || firstChild.id === 'kv-item-header');
|
||||
|
||||
let child = isTemplate ? firstChild.nextElementSibling : container.firstElementChild;
|
||||
while (child) {
|
||||
const next = child.nextElementSibling;
|
||||
child.remove();
|
||||
child = next;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_populateKVItems(container, items, addItemFn) {
|
||||
this._clearContainer(container);
|
||||
if (items && typeof items === 'object') {
|
||||
for (const [key, value] of Object.entries(items)) {
|
||||
addItemFn(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_collectKVItems(container) {
|
||||
const items = {};
|
||||
container.querySelectorAll('.dynamic-kv-item').forEach(item => {
|
||||
const keyEl = item.querySelector('.dynamic-kv-key');
|
||||
const valueEl = item.querySelector('.dynamic-kv-value');
|
||||
if (keyEl && valueEl && keyEl.value) {
|
||||
items[keyEl.value] = valueEl.value;
|
||||
}
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
_collectSafetySettings(container) {
|
||||
const items = {};
|
||||
container.querySelectorAll('.safety-setting-item').forEach(item => {
|
||||
const categorySelect = item.querySelector('select:first-child');
|
||||
const thresholdSelect = item.querySelector('select:last-of-type');
|
||||
if (categorySelect && thresholdSelect && categorySelect.value) {
|
||||
items[categorySelect.value] = thresholdSelect.value;
|
||||
}
|
||||
});
|
||||
return items;
|
||||
}
|
||||
}
|
||||
2190
frontend/js/pages/keys_status.js
Normal file
2190
frontend/js/pages/keys_status.js
Normal file
File diff suppressed because it is too large
Load Diff
77
frontend/js/pages/logs/index.js
Normal file
77
frontend/js/pages/logs/index.js
Normal file
@@ -0,0 +1,77 @@
|
||||
// Filename: frontend/js/pages/logs/index.js
|
||||
|
||||
import { apiFetchJson } from '../../services/api.js';
|
||||
import LogList from './logList.js';
|
||||
|
||||
class LogsPage {
|
||||
constructor() {
|
||||
this.state = {
|
||||
logs: [],
|
||||
// [修正] 暂时将分页状态设为默认值,直到后端添加分页支持
|
||||
pagination: { page: 1, pages: 1, total: 0 },
|
||||
isLoading: true,
|
||||
filters: { page: 1, page_size: 20 }
|
||||
};
|
||||
|
||||
this.elements = {
|
||||
tableBody: document.getElementById('logs-table-body'),
|
||||
};
|
||||
|
||||
this.initialized = !!this.elements.tableBody;
|
||||
|
||||
if (this.initialized) {
|
||||
this.logList = new LogList(this.elements.tableBody);
|
||||
}
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (!this.initialized) {
|
||||
console.error("LogsPage: Could not initialize. Essential container element 'logs-table-body' is missing.");
|
||||
return;
|
||||
}
|
||||
this.initEventListeners();
|
||||
await this.loadAndRenderLogs();
|
||||
}
|
||||
|
||||
initEventListeners() {
|
||||
// 分页和筛选的事件监听器将在后续任务中添加
|
||||
}
|
||||
|
||||
async loadAndRenderLogs() {
|
||||
this.state.isLoading = true;
|
||||
this.logList.renderLoading();
|
||||
|
||||
try {
|
||||
const url = `/admin/logs?page=${this.state.filters.page}&page_size=${this.state.filters.page_size}`;
|
||||
const responseData = await apiFetchJson(url);
|
||||
|
||||
// [核心修正] 调整条件以匹配当前 API 返回的 { success: true, data: [...] } 结构
|
||||
if (responseData && responseData.success && Array.isArray(responseData.data)) {
|
||||
|
||||
// [核心修正] 直接从 responseData.data 获取日志数组
|
||||
this.state.logs = responseData.data;
|
||||
|
||||
// [临时] 由于当前响应不包含分页信息,我们暂时不更新 this.state.pagination
|
||||
// 等待后端完善分页后,再恢复这里的逻辑
|
||||
|
||||
this.logList.render(this.state.logs);
|
||||
|
||||
// this.renderPaginationControls();
|
||||
} else {
|
||||
console.error("API response for logs is incorrect:", responseData);
|
||||
this.logList.render([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load logs:", error);
|
||||
// this.logList.renderError(error);
|
||||
} finally {
|
||||
this.state.isLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出符合 main.js 规范的 default 函数
|
||||
export default function() {
|
||||
const page = new LogsPage();
|
||||
page.init();
|
||||
}
|
||||
60
frontend/js/pages/logs/logList.js
Normal file
60
frontend/js/pages/logs/logList.js
Normal file
@@ -0,0 +1,60 @@
|
||||
// Filename: frontend/js/pages/logs/logList.js
|
||||
|
||||
class LogList {
|
||||
constructor(container) {
|
||||
this.container = container;
|
||||
if (!this.container) {
|
||||
console.error("LogList: container element (tbody) not found.");
|
||||
}
|
||||
}
|
||||
|
||||
renderLoading() {
|
||||
if (!this.container) return;
|
||||
this.container.innerHTML = `<tr><td colspan="9" class="p-8 text-center text-muted-foreground"><i class="fas fa-spinner fa-spin mr-2"></i> 加载日志中...</td></tr>`;
|
||||
}
|
||||
|
||||
render(logs) {
|
||||
if (!this.container) return;
|
||||
|
||||
if (!logs || logs.length === 0) {
|
||||
this.container.innerHTML = `<tr><td colspan="9" class="p-8 text-center text-muted-foreground">没有找到相关的日志记录。</td></tr>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const logsHtml = logs.map(log => this.createLogRowHtml(log)).join('');
|
||||
this.container.innerHTML = logsHtml;
|
||||
}
|
||||
|
||||
createLogRowHtml(log) {
|
||||
// [后端协作点] 假设后端未来会提供 GroupDisplayName 和 APIKeyName
|
||||
const groupName = log.GroupDisplayName || (log.GroupID ? `Group #${log.GroupID}` : 'N/A');
|
||||
const apiKeyName = log.APIKeyName || (log.KeyID ? `Key #${log.KeyID}` : 'N/A');
|
||||
|
||||
const errorTag = log.IsSuccess
|
||||
? `<span class="inline-flex items-center rounded-md bg-green-500/10 px-2 py-1 text-xs font-medium text-green-600">成功</span>`
|
||||
: `<span class="inline-flex items-center rounded-md bg-destructive/10 px-2 py-1 text-xs font-medium text-destructive">${log.ErrorCode || '失败'}</span>`;
|
||||
|
||||
// 使用 toLocaleString 格式化时间,更符合用户本地习惯
|
||||
const requestTime = new Date(log.RequestTime).toLocaleString();
|
||||
|
||||
return `
|
||||
<tr class="border-b border-b-border transition-colors hover:bg-muted/80" data-log-id="${log.ID}">
|
||||
<td class="p-4 align-middle"><input type="checkbox" class="h-4 w-4 rounded border-zinc-300 text-blue-600 focus:ring-blue-500"></td>
|
||||
<td class="p-4 align-middle font-mono text-muted-foreground">#${log.ID}</td>
|
||||
<td class="p-4 align-middle font-medium font-mono">${apiKeyName}</td>
|
||||
<td class="p-4 align-middle">${groupName}</td>
|
||||
<td class="p-4 align-middle text-foreground">${log.ErrorMessage || (log.IsSuccess ? '' : '未知错误')}</td>
|
||||
<td class="p-4 align-middle">${errorTag}</td>
|
||||
<td class="p-4 align-middle font-mono">${log.ModelName}</td>
|
||||
<td class="p-4 align-middle text-muted-foreground text-xs">${requestTime}</td>
|
||||
<td class="p-4 align-middle">
|
||||
<button class="btn btn-ghost btn-icon btn-sm" aria-label="查看详情">
|
||||
<i class="fas fa-ellipsis-h h-4 w-4"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
export default LogList;
|
||||
124
frontend/js/services/api.js
Normal file
124
frontend/js/services/api.js
Normal file
@@ -0,0 +1,124 @@
|
||||
// Filename: frontend/js/services/api.js
|
||||
|
||||
class APIClientError extends Error {
|
||||
constructor(message, status, code, rawMessageFromServer) {
|
||||
super(message);
|
||||
this.name = 'APIClientError';
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
this.rawMessageFromServer = rawMessageFromServer;
|
||||
}
|
||||
}
|
||||
|
||||
// Global Promise cache for raw responses
|
||||
const apiPromiseCache = new Map();
|
||||
|
||||
/**
|
||||
* [CORRECTED & CACHE-AWARE] A low-level fetch wrapper.
|
||||
* It handles caching, authentication, and centralized error handling.
|
||||
* On success (2xx), it returns the raw, unread Response object.
|
||||
* On failure (non-2xx), it consumes the body to throw a detailed APIClientError.
|
||||
*/
|
||||
export async function apiFetch(url, options = {}) {
|
||||
// For non-GET requests or noCache requests, we bypass the promise cache.
|
||||
const isGetRequest = !options.method || options.method.toUpperCase() === 'GET';
|
||||
const cacheKey = isGetRequest && !options.noCache ? url : null;
|
||||
|
||||
if (cacheKey && apiPromiseCache.has(cacheKey)) {
|
||||
return apiPromiseCache.get(cacheKey);
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('bearerToken');
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
};
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const requestPromise = (async () => {
|
||||
try {
|
||||
const response = await fetch(url, { ...options, headers });
|
||||
|
||||
if (response.status === 401) {
|
||||
// On auth error, always clear caches for this URL.
|
||||
if (cacheKey) apiPromiseCache.delete(cacheKey);
|
||||
// ... (rest of the 401 logic is correct)
|
||||
localStorage.removeItem('bearerToken');
|
||||
if (window.location.pathname !== '/login') {
|
||||
window.location.href = '/login?error=会话已过期,请重新登录。';
|
||||
}
|
||||
throw new APIClientError('Unauthorized', 401, 'UNAUTHORIZED', 'Session expired or token is invalid.');
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
let errorData = null;
|
||||
let rawMessage = '';
|
||||
try {
|
||||
// This is the ONLY place the body is consumed in the failure path.
|
||||
rawMessage = await response.text();
|
||||
if(rawMessage) { // Avoid parsing empty string
|
||||
errorData = JSON.parse(rawMessage);
|
||||
}
|
||||
} catch (e) {
|
||||
errorData = { error: { code: 'UNKNOWN_FORMAT', message: rawMessage || response.statusText } };
|
||||
}
|
||||
|
||||
const code = errorData?.error?.code || 'UNKNOWN_ERROR';
|
||||
const messageFromServer = errorData?.error?.message || rawMessage || 'No message provided by server.';
|
||||
|
||||
const error = new APIClientError(
|
||||
`API request failed: ${response.status}`,
|
||||
response.status,
|
||||
code,
|
||||
messageFromServer
|
||||
);
|
||||
|
||||
// Throwing the error will cause this promise to reject.
|
||||
throw error;
|
||||
}
|
||||
|
||||
// On success, the promise resolves with the PRISTINE response object.
|
||||
return response;
|
||||
|
||||
} catch (error) {
|
||||
// If an error occurred (either thrown by us or a network error),
|
||||
// ensure the promise cache is cleared for this key before re-throwing.
|
||||
if (cacheKey) apiPromiseCache.delete(cacheKey);
|
||||
throw error; // Re-throw to propagate the failure.
|
||||
}
|
||||
})();
|
||||
|
||||
// If we are caching, store the promise.
|
||||
if (cacheKey) {
|
||||
apiPromiseCache.set(cacheKey, requestPromise);
|
||||
}
|
||||
|
||||
return requestPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* [CORRECTED & CACHE-AWARE] High-level wrapper that expects a JSON response.
|
||||
* It leverages apiFetch and is ONLY responsible for calling .json() on a successful response.
|
||||
*/
|
||||
export async function apiFetchJson(url, options = {}) {
|
||||
try {
|
||||
// 1. Get the raw response from apiFetch. It's either fresh or from the promise cache.
|
||||
// If it fails, apiFetch will throw, and this function will propagate the error.
|
||||
const response = await apiFetch(url, options);
|
||||
|
||||
// 2. We have a successful response. We need to clone it before reading the body.
|
||||
// This is CRITICAL because the original response in the promise cache MUST remain unread.
|
||||
const clonedResponse = response.clone();
|
||||
|
||||
// 3. Now we can safely consume the body of the CLONE.
|
||||
const jsonData = await clonedResponse.json();
|
||||
|
||||
return jsonData;
|
||||
|
||||
} catch (error) {
|
||||
// Just propagate the detailed error from apiFetch.
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
39
frontend/js/services/errorHandler.js
Normal file
39
frontend/js/services/errorHandler.js
Normal file
@@ -0,0 +1,39 @@
|
||||
// Filename: frontend/js/services/errorHandler.js
|
||||
|
||||
/**
|
||||
* This module provides a centralized place for handling application-wide errors,
|
||||
* particularly those originating from API calls. It promotes a consistent user
|
||||
* experience for error notifications.
|
||||
*/
|
||||
|
||||
// Step 1: Define the single, authoritative map for all client-side error messages.
|
||||
// This is the "dictionary" that translates API error codes into user-friendly text.
|
||||
export const ERROR_MESSAGES = {
|
||||
'STATE_CONFLICT_MASTER_REVOKED': '操作失败:无法激活一个已被永久吊销(Revoked)的Key。',
|
||||
'NOT_FOUND': '操作失败:目标资源不存在或已从本组移除。列表将自动刷新。',
|
||||
'NO_KEYS_MATCH_FILTER': '没有找到任何符合当前过滤条件的Key可供操作。',
|
||||
// You can add many more specific codes here as your application grows.
|
||||
|
||||
'DEFAULT': '操作失败,请稍后重试或联系管理员。'
|
||||
};
|
||||
|
||||
/**
|
||||
* A universal API error handler function.
|
||||
* It inspects an error object, determines the best message to show,
|
||||
* and displays it using the provided toastManager.
|
||||
*
|
||||
* @param {Error|APIClientError} error - The error object caught in a try...catch block.
|
||||
* @param {object} toastManager - The toastManager instance to display notifications.
|
||||
* @param {object} [options={}] - Optional parameters for customization.
|
||||
* @param {string} [options.prefix=''] - A string to prepend to the error message (e.g., "任务启动失败: ").
|
||||
*/
|
||||
export function handleApiError(error, toastManager, options = {}) {
|
||||
const prefix = options.prefix || '';
|
||||
|
||||
// Use the exact same robust logic we developed before.
|
||||
const errorCode = error?.code || 'DEFAULT';
|
||||
const displayMessage = ERROR_MESSAGES[errorCode] || error.rawMessageFromServer || error.message || ERROR_MESSAGES['DEFAULT'];
|
||||
|
||||
toastManager.show(`${prefix}${displayMessage}`, 'error');
|
||||
}
|
||||
|
||||
123
frontend/js/utils/utils.js
Normal file
123
frontend/js/utils/utils.js
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* @file utils/utils.js
|
||||
* @description Provides a collection of common, reusable helper functions.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A debounce utility to delay function execution, now with a cancel method.
|
||||
* @param {Function} func The function to debounce.
|
||||
* @param {number} wait The delay in milliseconds.
|
||||
* @returns {Function} The new debounced function with a `.cancel()` method attached.
|
||||
*/
|
||||
export function debounce(func, wait) {
|
||||
let timeout;
|
||||
|
||||
// create a named function expression here instead of returning an anonymous one directly.
|
||||
const debounced = function(...args) {
|
||||
// Store the context of 'this' in case it's needed inside the debounced function.
|
||||
const context = this;
|
||||
|
||||
// The core logic remains the same.
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
// Use .apply() to preserve the original 'this' context.
|
||||
func.apply(context, args);
|
||||
};
|
||||
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
|
||||
// Attach a 'cancel' method to the debounced function.
|
||||
// This allows us to abort a pending execution.
|
||||
debounced.cancel = () => {
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
|
||||
return debounced;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Asynchronously copies a given string of text to the user's clipboard.
|
||||
* @param {string} text The text to be copied.
|
||||
* @returns {Promise<void>} A promise that resolves on success, rejects on failure.
|
||||
*/
|
||||
export function copyToClipboard(text) {
|
||||
// Use the modern Clipboard API if available
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
return navigator.clipboard.writeText(text);
|
||||
}
|
||||
// Fallback for older browsers
|
||||
return new Promise((resolve, reject) => {
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = text;
|
||||
|
||||
// Make the textarea invisible and prevent scrolling
|
||||
textArea.style.position = "fixed";
|
||||
textArea.style.top = "-9999px";
|
||||
textArea.style.left = "-9999px";
|
||||
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
|
||||
try {
|
||||
const successful = document.execCommand("copy");
|
||||
if (successful) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error("Fallback: Unable to copy text to clipboard."));
|
||||
}
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
} finally {
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a single API Key against a list of known formats.
|
||||
* @param {string} key - The API key string to validate.
|
||||
* @returns {boolean} - True if the key matches any of the known formats.
|
||||
*/
|
||||
export function isValidApiKeyFormat(key) {
|
||||
// [核心] 这是一个正则表达式列表。未来支持新的Key格式,只需在此处添加新的正则即可。
|
||||
const patterns = [
|
||||
// Google Gemini API Key: AIzaSy + 33 characters (alphanumeric, _, -)
|
||||
/^AIzaSy[\w-]{33}$/,
|
||||
// OpenAI API Key (新格式): sk- + 48 alphanumeric characters
|
||||
/^sk-[\w]{48}$/,
|
||||
// Google AI Studio Key: gsk_ + alphanumeric & hyphens
|
||||
/^gsk_[\w-]{40,}$/,
|
||||
// Anthropic API Key (示例): sk-ant-api03- + long string
|
||||
/^sk-ant-api\d{2}-[\w-]{80,}$/,
|
||||
// Fallback for other potential "sk-" keys with a reasonable length
|
||||
/^sk-[\w-]{20,}$/
|
||||
];
|
||||
// 使用 .some() 方法,只要key匹配列表中的任意一个模式,就返回true。
|
||||
return patterns.some(pattern => pattern.test(key));
|
||||
}
|
||||
|
||||
/**
|
||||
* [NEW] A simple utility to escape HTML special characters from a string.
|
||||
* This is a critical security function to prevent XSS attacks when using .innerHTML.
|
||||
* @param {any} str The input string to escape. If not a string, it's returned as is.
|
||||
* @returns {string} The escaped, HTML-safe string.
|
||||
*/
|
||||
export function escapeHTML(str) {
|
||||
if (typeof str !== 'string') {
|
||||
return str;
|
||||
}
|
||||
return str.replace(/[&<>"']/g, function(match) {
|
||||
return {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}[match];
|
||||
});
|
||||
}
|
||||
// ... 其他未来可能添加的工具函数 ...
|
||||
3378
frontend/js/vendor/sortable.esm.js
vendored
Normal file
3378
frontend/js/vendor/sortable.esm.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
7
gemini-balancer.code-workspace
Normal file
7
gemini-balancer.code-workspace
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": "."
|
||||
}
|
||||
]
|
||||
}
|
||||
83
go.mod
Normal file
83
go.mod
Normal file
@@ -0,0 +1,83 @@
|
||||
module gemini-balancer
|
||||
|
||||
go 1.24.5
|
||||
|
||||
require (
|
||||
github.com/flosch/pongo2/v6 v6.0.0
|
||||
github.com/gin-contrib/cors v1.7.6
|
||||
github.com/gin-gonic/gin v1.10.1
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/go-co-op/gocron v1.37.0
|
||||
github.com/go-sql-driver/mysql v1.9.3
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jackc/pgx/v5 v5.7.5
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
github.com/redis/go-redis/v9 v9.3.0
|
||||
github.com/sirupsen/logrus v1.4.2
|
||||
github.com/spf13/viper v1.20.1
|
||||
go.uber.org/dig v1.19.0
|
||||
golang.org/x/net v0.42.0
|
||||
gorm.io/datatypes v1.0.5
|
||||
gorm.io/driver/mysql v1.6.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/gorm v1.30.1
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/bytedance/sonic v1.14.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.2 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.31 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||
github.com/sagikazarmark/locafero v0.10.0 // indirect
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||
github.com/spf13/afero v1.14.0 // indirect
|
||||
github.com/spf13/cast v1.9.2 // indirect
|
||||
github.com/spf13/pflag v1.0.7 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
go.uber.org/atomic v1.9.0 // indirect
|
||||
golang.org/x/arch v0.19.0 // indirect
|
||||
golang.org/x/crypto v0.41.0 // indirect
|
||||
golang.org/x/sync v0.16.0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
golang.org/x/text v0.28.0 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
gorm.io/driver/sqlite v1.6.0 // indirect
|
||||
gorm.io/driver/sqlserver v1.6.1 // indirect
|
||||
modernc.org/libc v1.22.5 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/memory v1.5.0 // indirect
|
||||
modernc.org/sqlite v1.23.1 // indirect
|
||||
)
|
||||
357
go.sum
Normal file
357
go.sum
Normal file
@@ -0,0 +1,357 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.0/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.1/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1/go.mod h1:a6xsAQUZg+VsS3TJ05SRp524Hs4pZ/AeFSr5ENf0Yjo=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.3.1/go.mod h1:uE9zaUfEQT/nbQjVi2IblCG9iaLtZsuYZ8ne+PuQ02M=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0/go.mod h1:okt5dMMTOFjX/aovMlrjvvXoPMBVSPzk9185BT0+eZM=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.2/go.mod h1:yInRyqWXAuaPrgI7p70+lDDgh3mlBohis29jGMISnmc=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0/go.mod h1:4OG6tQ9EOP/MT0NMjDlRzWoVFxfu9rN9B2X+tlSVktg=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.1/go.mod h1:GpPjLhVR9dnUoJMyHWSPy71xY9/lcmpzIPZXmF0FCVY=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0/go.mod h1:bTSOgj05NGRuHHhQwAdPnYr9TOdNmKlZTgGLL6nyAdI=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.1.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
|
||||
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
|
||||
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/yU9ko=
|
||||
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/flosch/pongo2/v6 v6.0.0 h1:lsGru8IAzHgIAw6H2m4PCyleO58I40ow6apih0WprMU=
|
||||
github.com/flosch/pongo2/v6 v6.0.0/go.mod h1:CuDpFm47R0uGGE7z13/tTlt1Y6zdxvr2RLT5LJhsHEU=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
|
||||
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
|
||||
github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY=
|
||||
github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
|
||||
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
||||
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||
github.com/go-co-op/gocron v1.37.0 h1:ZYDJGtQ4OMhTLKOKMIch+/CY70Brbb1dGdooLEhh7b0=
|
||||
github.com/go-co-op/gocron v1.37.0/go.mod h1:3L/n6BkO7ABj+TrfSVXLRzsP26zmikL4ISkLQ0O8iNY=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
||||
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
|
||||
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
||||
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
|
||||
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
||||
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
|
||||
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs=
|
||||
github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
|
||||
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
|
||||
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
|
||||
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
|
||||
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.3/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.2 h1:DB17ag19krx9CFsz4o3enTrPXyIXCl+2iCXH/aMAp9s=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.31 h1:ldt6ghyPJsokUIlksH63gWZkG6qVGeEAu4zLeS4aVZM=
|
||||
github.com/mattn/go-sqlite3 v1.14.31/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/microsoft/go-mssqldb v1.8.2 h1:236sewazvC8FvG6Dr3bszrVhMkAl4KYImryLkRMCd0I=
|
||||
github.com/microsoft/go-mssqldb v1.8.2/go.mod h1:vp38dT33FGfVotRiTmDo3bFyaHq+p3LektQrjTULowo=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8=
|
||||
github.com/montanaflynn/stats v0.7.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/redis/go-redis/v9 v9.3.0 h1:RiVDjmig62jIWp7Kk4XVLs0hzV6pI3PyTnnL0cnn0u0=
|
||||
github.com/redis/go-redis/v9 v9.3.0/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||
github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
||||
github.com/sagikazarmark/locafero v0.10.0 h1:FM8Cv6j2KqIhM2ZK7HZjm4mpj9NBktLgowT1aN9q5Cc=
|
||||
github.com/sagikazarmark/locafero v0.10.0/go.mod h1:Ieo3EUsjifvQu4NZwV5sPd4dwvu0OCgEQV7vjc9yDjw=
|
||||
github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4=
|
||||
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
||||
github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
|
||||
github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo=
|
||||
github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE=
|
||||
github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M=
|
||||
github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
|
||||
github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
|
||||
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4=
|
||||
go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE=
|
||||
golang.org/x/arch v0.19.0 h1:LmbDQUodHThXE+htjrnmVD73M//D9GTH6wFZjyDkjyU=
|
||||
golang.org/x/arch v0.19.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
||||
golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
|
||||
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
||||
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
|
||||
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.13.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA=
|
||||
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
||||
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
|
||||
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
||||
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o=
|
||||
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
|
||||
golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
|
||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/datatypes v1.0.5 h1:3vHCfg4Bz8SDx83zE+ASskF+g/j0kWrcKrY9jFUyAl0=
|
||||
gorm.io/datatypes v1.0.5/go.mod h1:acG/OHGwod+1KrbwPL1t+aavb7jOBOETeyl5M8K5VQs=
|
||||
gorm.io/driver/mysql v1.2.2/go.mod h1:qsiz+XcAyMrS6QY+X3M9R6b/lKM1imKmcuK9kac5LTo=
|
||||
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
|
||||
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
|
||||
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||
gorm.io/driver/sqlserver v1.6.1 h1:XWISFsu2I2pqd1KJhhTZNJMx1jNQ+zVL/Q8ovDcUjtY=
|
||||
gorm.io/driver/sqlserver v1.6.1/go.mod h1:VZeNn7hqX1aXoN5TPAFGWvxWG90xtA8erGn2gQmpc6U=
|
||||
gorm.io/gorm v1.22.4/go.mod h1:1aeVC+pe9ZmvKZban/gW4QPra7PRoTEssyc922qCAkk=
|
||||
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
|
||||
gorm.io/gorm v1.30.1 h1:lSHg33jJTBxs2mgJRfRZeLDG+WZaHYCk3Wtfl6Ngzo4=
|
||||
gorm.io/gorm v1.30.1/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
|
||||
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
||||
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
||||
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
||||
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
|
||||
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
|
||||
142
internal/app/app.go
Normal file
142
internal/app/app.go
Normal file
@@ -0,0 +1,142 @@
|
||||
// Filename: internal/app/app.go
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"gemini-balancer/internal/config"
|
||||
"gemini-balancer/internal/crypto"
|
||||
"gemini-balancer/internal/db/migrations"
|
||||
"gemini-balancer/internal/db/seeder"
|
||||
"gemini-balancer/internal/scheduler"
|
||||
"gemini-balancer/internal/service"
|
||||
"gemini-balancer/internal/settings"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sirupsen/logrus"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// App
|
||||
type App struct {
|
||||
Config *config.Config
|
||||
Router *gin.Engine
|
||||
DB *gorm.DB
|
||||
Logger *logrus.Logger
|
||||
CryptoService *crypto.Service
|
||||
|
||||
// 拥有独立生命周期的后台服务
|
||||
ResourceService *service.ResourceService
|
||||
APIKeyService *service.APIKeyService
|
||||
DBLogWriter *service.DBLogWriterService
|
||||
AnalyticsService *service.AnalyticsService
|
||||
HealthCheckService *service.HealthCheckService
|
||||
SettingsManager *settings.SettingsManager
|
||||
GroupManager *service.GroupManager
|
||||
TokenManager *service.TokenManager
|
||||
Scheduler *scheduler.Scheduler
|
||||
}
|
||||
|
||||
// NewApp
|
||||
func NewApp(
|
||||
cfg *config.Config,
|
||||
router *gin.Engine,
|
||||
db *gorm.DB,
|
||||
logger *logrus.Logger,
|
||||
cryptoService *crypto.Service,
|
||||
resourceService *service.ResourceService,
|
||||
apiKeyService *service.APIKeyService,
|
||||
dbLogWriter *service.DBLogWriterService,
|
||||
analyticsService *service.AnalyticsService,
|
||||
healthCheckService *service.HealthCheckService,
|
||||
settingsManager *settings.SettingsManager,
|
||||
groupManager *service.GroupManager,
|
||||
tokenManager *service.TokenManager,
|
||||
scheduler *scheduler.Scheduler,
|
||||
) *App {
|
||||
return &App{
|
||||
Config: cfg,
|
||||
Router: router,
|
||||
DB: db,
|
||||
Logger: logger,
|
||||
CryptoService: cryptoService,
|
||||
ResourceService: resourceService,
|
||||
APIKeyService: apiKeyService,
|
||||
DBLogWriter: dbLogWriter,
|
||||
AnalyticsService: analyticsService,
|
||||
HealthCheckService: healthCheckService,
|
||||
SettingsManager: settingsManager,
|
||||
GroupManager: groupManager,
|
||||
TokenManager: tokenManager,
|
||||
Scheduler: scheduler,
|
||||
}
|
||||
}
|
||||
|
||||
// Run 启动流程现在由App主动编排,而非被动接受
|
||||
func (a *App) Run() error {
|
||||
// --- 阶段一: (数据库设置) ---
|
||||
a.Logger.Info("* [SYSTEM] * Preparing: Database Setup ...")
|
||||
|
||||
// 步骤 1: (运行基础迁移,确保表存在)
|
||||
if err := migrations.RunMigrations(a.DB, a.Logger); err != nil {
|
||||
return fmt.Errorf("initial database migration failed: %w", err)
|
||||
}
|
||||
|
||||
// 步骤 2: (运行所有版本化的数据迁移)
|
||||
if err := migrations.RunVersionedMigrations(a.DB, a.Config, a.Logger); err != nil {
|
||||
return fmt.Errorf("failed to run versioned migrations: %w", err)
|
||||
}
|
||||
|
||||
// 步骤 3: (数据播种)
|
||||
seeder.RunSeeder(a.DB, a.CryptoService, a.Logger)
|
||||
a.Logger.Info("* [SYSTEM] * All Uitls READY. ---")
|
||||
|
||||
// --- 阶段二: (启动后台服务) ---
|
||||
a.Logger.Info("* [SYSTEM] * Starting main: Background Services ")
|
||||
a.APIKeyService.Start()
|
||||
a.DBLogWriter.Start()
|
||||
a.AnalyticsService.Start()
|
||||
a.HealthCheckService.Start()
|
||||
a.Scheduler.Start()
|
||||
a.Logger.Info("* [SYSTEM] * All Background Services are RUNNING. ")
|
||||
|
||||
// --- 阶段三: (优雅关机) ---
|
||||
defer a.Scheduler.Stop()
|
||||
defer a.HealthCheckService.Stop()
|
||||
defer a.AnalyticsService.Stop()
|
||||
defer a.APIKeyService.Stop()
|
||||
defer a.DBLogWriter.Stop()
|
||||
defer a.SettingsManager.Stop()
|
||||
defer a.TokenManager.Stop()
|
||||
|
||||
// --- 阶段四: (HTTP服务器) ---
|
||||
serverAddr := fmt.Sprintf("0.0.0.0:%s", a.Config.Server.Port)
|
||||
srv := &http.Server{
|
||||
Addr: serverAddr,
|
||||
Handler: a.Router,
|
||||
}
|
||||
go func() {
|
||||
a.Logger.Infof("* [SYSTEM] * HTTP Server Now Listening on %s ", serverAddr)
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
a.Logger.Fatalf("HTTP server listen error: %s\n", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// --- 阶段五: (处理关机信号) ---
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
a.Logger.Info("* [SYSTEM] * Shutdown signal received. Executing strategic retreat...")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(ctx); err != nil {
|
||||
a.Logger.Fatal("Server forced to shutdown:", err)
|
||||
}
|
||||
a.Logger.Info("* [SYSTEM] * All units have ceased operations. Mission complete. ")
|
||||
return nil
|
||||
}
|
||||
47
internal/channel/channel.go
Normal file
47
internal/channel/channel.go
Normal file
@@ -0,0 +1,47 @@
|
||||
// Filename: internal/channel/channel.go
|
||||
package channel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"gemini-balancer/internal/errors"
|
||||
"gemini-balancer/internal/models"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ChannelProxy 是所有协议通道必须实现的统一接口
|
||||
type ChannelProxy interface {
|
||||
// RewritePath 路径重写的核心接口,负责将客户端路径转换为上游期望的路径
|
||||
RewritePath(basePath, originalPath string) string
|
||||
|
||||
TransformRequest(c *gin.Context, requestBody []byte) (newBody []byte, modelName string, err error)
|
||||
// IsStreamRequest 检查请求是否为流式
|
||||
IsStreamRequest(c *gin.Context, bodyBytes []byte) bool
|
||||
// IsOpenAICompatibleRequest 检查请求是否使用了OpenAI兼容路径
|
||||
IsOpenAICompatibleRequest(c *gin.Context) bool
|
||||
// ExtractModel 从请求中提取模型名称
|
||||
ExtractModel(c *gin.Context, bodyBytes []byte) string
|
||||
// ValidateKey 验证API Key的有效性
|
||||
ValidateKey(ctx context.Context, apiKey *models.APIKey, targetURL string, timeout time.Duration) *errors.APIError
|
||||
// ModifyRequest 在将请求发往上游前对其进行修改(如添加认证)
|
||||
ModifyRequest(req *http.Request, apiKey *models.APIKey)
|
||||
// ProcessSmartStreamRequest 处理核心的流式代理请求
|
||||
ProcessSmartStreamRequest(c *gin.Context, params SmartRequestParams)
|
||||
// 其他非流式处理方法等...
|
||||
}
|
||||
|
||||
// SmartRequestParams 是一个参数容器,用于将所有高层依赖,一次性、干净地传递到底层。
|
||||
type SmartRequestParams struct {
|
||||
CorrelationID string
|
||||
APIKey *models.APIKey
|
||||
UpstreamURL string
|
||||
RequestBody []byte
|
||||
OriginalRequest models.GeminiRequest
|
||||
EventLogger *models.RequestFinishedEvent
|
||||
MaxRetries int
|
||||
RetryDelay time.Duration
|
||||
LogTruncationLimit int
|
||||
StreamingRetryPrompt string // <--- 传递续传指令
|
||||
}
|
||||
434
internal/channel/gemini_channel.go
Normal file
434
internal/channel/gemini_channel.go
Normal file
@@ -0,0 +1,434 @@
|
||||
// Filename: internal/channel/gemini_channel.go
|
||||
package channel
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
CustomErrors "gemini-balancer/internal/errors"
|
||||
"gemini-balancer/internal/models"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const SmartRetryPrompt = "Continue exactly where you left off..."
|
||||
|
||||
var _ ChannelProxy = (*GeminiChannel)(nil)
|
||||
|
||||
type GeminiChannel struct {
|
||||
logger *logrus.Logger
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// 用于安全提取信息的本地结构体
|
||||
type requestMetadata struct {
|
||||
Model string `json:"model"`
|
||||
Stream bool `json:"stream"`
|
||||
}
|
||||
|
||||
func NewGeminiChannel(logger *logrus.Logger, cfg *models.SystemSettings) *GeminiChannel {
|
||||
transport := &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
MaxIdleConns: cfg.TransportMaxIdleConns,
|
||||
MaxIdleConnsPerHost: cfg.TransportMaxIdleConnsPerHost,
|
||||
IdleConnTimeout: time.Duration(cfg.TransportIdleConnTimeoutSecs) * time.Second,
|
||||
TLSHandshakeTimeout: time.Duration(cfg.TransportTLSHandshakeTimeout) * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
}
|
||||
return &GeminiChannel{
|
||||
logger: logger,
|
||||
httpClient: &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TransformRequest
|
||||
func (ch *GeminiChannel) TransformRequest(c *gin.Context, requestBody []byte) (newBody []byte, modelName string, err error) {
|
||||
var p struct {
|
||||
Model string `json:"model"`
|
||||
}
|
||||
_ = json.Unmarshal(requestBody, &p)
|
||||
modelName = strings.TrimPrefix(p.Model, "models/")
|
||||
|
||||
if modelName == "" {
|
||||
modelName = ch.extractModelFromPath(c.Request.URL.Path)
|
||||
}
|
||||
return requestBody, modelName, nil
|
||||
}
|
||||
|
||||
func (ch *GeminiChannel) extractModelFromPath(path string) string {
|
||||
parts := strings.Split(path, "/")
|
||||
for _, part := range parts {
|
||||
if strings.HasPrefix(part, "gemini-") || strings.HasPrefix(part, "text-") || strings.HasPrefix(part, "embedding-") {
|
||||
modelPart := strings.Split(part, ":")[0]
|
||||
return modelPart
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (ch *GeminiChannel) IsOpenAICompatibleRequest(c *gin.Context) bool {
|
||||
path := c.Request.URL.Path
|
||||
return strings.Contains(path, "/v1/chat/completions") || strings.Contains(path, "/v1/embeddings")
|
||||
}
|
||||
|
||||
func (ch *GeminiChannel) ValidateKey(
|
||||
ctx context.Context,
|
||||
apiKey *models.APIKey,
|
||||
targetURL string,
|
||||
timeout time.Duration,
|
||||
) *CustomErrors.APIError {
|
||||
client := &http.Client{
|
||||
Timeout: timeout,
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", targetURL, nil)
|
||||
if err != nil {
|
||||
return CustomErrors.NewAPIError(CustomErrors.ErrInternalServer, "failed to create validation request: "+err.Error())
|
||||
}
|
||||
|
||||
ch.ModifyRequest(req, apiKey)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return CustomErrors.NewAPIError(CustomErrors.ErrBadGateway, "failed to send validation request: "+err.Error())
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
return nil
|
||||
}
|
||||
errorBody, _ := io.ReadAll(resp.Body)
|
||||
parsedMessage := CustomErrors.ParseUpstreamError(errorBody)
|
||||
return &CustomErrors.APIError{
|
||||
HTTPStatus: resp.StatusCode,
|
||||
Code: fmt.Sprintf("UPSTREAM_%d", resp.StatusCode),
|
||||
Message: parsedMessage,
|
||||
}
|
||||
}
|
||||
|
||||
func (ch *GeminiChannel) ModifyRequest(req *http.Request, apiKey *models.APIKey) {
|
||||
// TODO: [Future Refactoring] Decouple auth logic from URL path.
|
||||
// The authentication method (e.g., Bearer token vs. API key in query) should ideally be a property
|
||||
// of the UpstreamEndpoint or a new "AuthProfile" entity, rather than being hardcoded based on URL patterns.
|
||||
// This would make the channel more generic and adaptable to new upstream provider types.
|
||||
if strings.Contains(req.URL.Path, "/v1beta/openai/") {
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey.APIKey)
|
||||
} else {
|
||||
req.Header.Del("Authorization")
|
||||
q := req.URL.Query()
|
||||
q.Set("key", apiKey.APIKey)
|
||||
req.URL.RawQuery = q.Encode()
|
||||
}
|
||||
}
|
||||
|
||||
func (ch *GeminiChannel) IsStreamRequest(c *gin.Context, bodyBytes []byte) bool {
|
||||
if strings.HasSuffix(c.Request.URL.Path, ":streamGenerateContent") {
|
||||
return true
|
||||
}
|
||||
var meta requestMetadata
|
||||
if err := json.Unmarshal(bodyBytes, &meta); err == nil {
|
||||
return meta.Stream
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (ch *GeminiChannel) ExtractModel(c *gin.Context, bodyBytes []byte) string {
|
||||
_, modelName, _ := ch.TransformRequest(c, bodyBytes)
|
||||
return modelName
|
||||
}
|
||||
|
||||
func (ch *GeminiChannel) RewritePath(basePath, originalPath string) string {
|
||||
tempCtx := &gin.Context{Request: &http.Request{URL: &url.URL{Path: originalPath}}}
|
||||
var rewrittenSegment string
|
||||
if ch.IsOpenAICompatibleRequest(tempCtx) {
|
||||
var apiEndpoint string
|
||||
v1Index := strings.LastIndex(originalPath, "/v1/")
|
||||
if v1Index != -1 {
|
||||
apiEndpoint = originalPath[v1Index+len("/v1/"):]
|
||||
} else {
|
||||
apiEndpoint = strings.TrimPrefix(originalPath, "/")
|
||||
}
|
||||
rewrittenSegment = "v1beta/openai/" + apiEndpoint
|
||||
} else {
|
||||
tempPath := originalPath
|
||||
if strings.HasPrefix(tempPath, "/v1/") {
|
||||
tempPath = "/v1beta/" + strings.TrimPrefix(tempPath, "/v1/")
|
||||
}
|
||||
rewrittenSegment = strings.TrimPrefix(tempPath, "/")
|
||||
}
|
||||
trimmedBasePath := strings.TrimSuffix(basePath, "/")
|
||||
pathToJoin := rewrittenSegment
|
||||
|
||||
versionPrefixes := []string{"v1beta", "v1"}
|
||||
for _, prefix := range versionPrefixes {
|
||||
if strings.HasSuffix(trimmedBasePath, "/"+prefix) && strings.HasPrefix(pathToJoin, prefix+"/") {
|
||||
pathToJoin = strings.TrimPrefix(pathToJoin, prefix+"/")
|
||||
break
|
||||
}
|
||||
}
|
||||
finalPath, err := url.JoinPath(trimmedBasePath, pathToJoin)
|
||||
if err != nil {
|
||||
return trimmedBasePath + "/" + strings.TrimPrefix(pathToJoin, "/")
|
||||
}
|
||||
return finalPath
|
||||
}
|
||||
|
||||
func (ch *GeminiChannel) ModifyResponse(resp *http.Response) error {
|
||||
// 这是一个桩实现,暂时不需要任何逻辑。
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ch *GeminiChannel) HandleError(c *gin.Context, err error) {
|
||||
// 这是一个桩实现,暂时不需要任何逻辑。
|
||||
}
|
||||
|
||||
// ==========================================================
|
||||
// ================== “智能路由”的核心引擎 ===================
|
||||
// ==========================================================
|
||||
func (ch *GeminiChannel) ProcessSmartStreamRequest(c *gin.Context, params SmartRequestParams) {
|
||||
log := ch.logger.WithField("correlation_id", params.CorrelationID)
|
||||
|
||||
targetURL, err := url.Parse(params.UpstreamURL)
|
||||
if err != nil {
|
||||
log.WithError(err).Error("Failed to parse upstream URL")
|
||||
errToJSON(c, CustomErrors.NewAPIError(CustomErrors.ErrInternalServer, "Invalid upstream URL format"))
|
||||
return
|
||||
}
|
||||
targetURL.Path = c.Request.URL.Path
|
||||
targetURL.RawQuery = c.Request.URL.RawQuery
|
||||
initialReq, err := http.NewRequestWithContext(c.Request.Context(), "POST", targetURL.String(), bytes.NewReader(params.RequestBody))
|
||||
if err != nil {
|
||||
log.WithError(err).Error("Failed to create initial smart request")
|
||||
errToJSON(c, CustomErrors.NewAPIError(CustomErrors.ErrInternalServer, err.Error()))
|
||||
return
|
||||
}
|
||||
ch.ModifyRequest(initialReq, params.APIKey)
|
||||
initialReq.Header.Del("Authorization")
|
||||
resp, err := ch.httpClient.Do(initialReq)
|
||||
if err != nil {
|
||||
log.WithError(err).Error("Initial smart request failed")
|
||||
errToJSON(c, CustomErrors.NewAPIError(CustomErrors.ErrBadGateway, err.Error()))
|
||||
return
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
log.Warnf("Initial request received non-200 status: %d", resp.StatusCode)
|
||||
standardizedResp := ch.standardizeError(resp, params.LogTruncationLimit, log)
|
||||
c.Writer.WriteHeader(standardizedResp.StatusCode)
|
||||
for key, values := range standardizedResp.Header {
|
||||
for _, value := range values {
|
||||
c.Writer.Header().Add(key, value)
|
||||
}
|
||||
}
|
||||
io.Copy(c.Writer, standardizedResp.Body)
|
||||
params.EventLogger.IsSuccess = false
|
||||
params.EventLogger.StatusCode = resp.StatusCode
|
||||
return
|
||||
}
|
||||
ch.processStreamAndRetry(c, initialReq.Header, resp.Body, params, log)
|
||||
}
|
||||
|
||||
func (ch *GeminiChannel) processStreamAndRetry(
|
||||
c *gin.Context, initialRequestHeaders http.Header, initialReader io.ReadCloser, params SmartRequestParams, log *logrus.Entry,
|
||||
) {
|
||||
defer initialReader.Close()
|
||||
c.Writer.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
|
||||
c.Writer.Header().Set("Cache-Control", "no-cache")
|
||||
c.Writer.Header().Set("Connection", "keep-alive")
|
||||
flusher, _ := c.Writer.(http.Flusher)
|
||||
var accumulatedText strings.Builder
|
||||
consecutiveRetryCount := 0
|
||||
currentReader := initialReader
|
||||
maxRetries := params.MaxRetries
|
||||
retryDelay := params.RetryDelay
|
||||
log.Infof("Starting smart stream session. Max retries: %d", maxRetries)
|
||||
for {
|
||||
var interruptionReason string
|
||||
scanner := bufio.NewScanner(currentReader)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(c.Writer, "%s\n\n", line)
|
||||
flusher.Flush()
|
||||
if !strings.HasPrefix(line, "data: ") {
|
||||
continue
|
||||
}
|
||||
data := strings.TrimPrefix(line, "data: ")
|
||||
var payload models.GeminiSSEPayload
|
||||
if err := json.Unmarshal([]byte(data), &payload); err != nil {
|
||||
continue
|
||||
}
|
||||
if len(payload.Candidates) > 0 {
|
||||
candidate := payload.Candidates[0]
|
||||
if candidate.Content != nil && len(candidate.Content.Parts) > 0 {
|
||||
accumulatedText.WriteString(candidate.Content.Parts[0].Text)
|
||||
}
|
||||
if candidate.FinishReason == "STOP" {
|
||||
log.Info("Stream finished successfully with STOP reason.")
|
||||
params.EventLogger.IsSuccess = true
|
||||
return
|
||||
}
|
||||
if candidate.FinishReason != "" {
|
||||
log.Warnf("Stream interrupted with abnormal finish reason: %s", candidate.FinishReason)
|
||||
interruptionReason = candidate.FinishReason
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
currentReader.Close()
|
||||
if interruptionReason == "" {
|
||||
if err := scanner.Err(); err != nil {
|
||||
log.WithError(err).Warn("Stream scanner encountered an error.")
|
||||
interruptionReason = "SCANNER_ERROR"
|
||||
} else {
|
||||
log.Warn("Stream dropped unexpectedly without a finish reason.")
|
||||
interruptionReason = "CONNECTION_DROP"
|
||||
}
|
||||
}
|
||||
if consecutiveRetryCount >= maxRetries {
|
||||
log.Errorf("Retry limit exceeded. Last interruption: %s. Sending final error.", interruptionReason)
|
||||
errData, _ := json.Marshal(map[string]interface{}{"error": map[string]interface{}{"code": http.StatusGatewayTimeout, "status": "DEADLINE_EXCEEDED", "message": fmt.Sprintf("Proxy retry limit exceeded. Last interruption: %s.", interruptionReason)}})
|
||||
fmt.Fprintf(c.Writer, "event: error\ndata: %s\n\n", string(errData))
|
||||
flusher.Flush()
|
||||
return
|
||||
}
|
||||
consecutiveRetryCount++
|
||||
params.EventLogger.Retries = consecutiveRetryCount
|
||||
log.Infof("Stream interrupted. Attempting retry %d/%d after %v.", consecutiveRetryCount, maxRetries, retryDelay)
|
||||
time.Sleep(retryDelay)
|
||||
retryBody, _ := buildRetryRequestBody(params.OriginalRequest, accumulatedText.String())
|
||||
retryBodyBytes, _ := json.Marshal(retryBody)
|
||||
|
||||
retryReq, _ := http.NewRequestWithContext(c.Request.Context(), "POST", params.UpstreamURL, bytes.NewReader(retryBodyBytes))
|
||||
retryReq.Header = initialRequestHeaders
|
||||
ch.ModifyRequest(retryReq, params.APIKey)
|
||||
retryReq.Header.Del("Authorization")
|
||||
|
||||
retryResp, err := ch.httpClient.Do(retryReq)
|
||||
if err != nil || retryResp.StatusCode != http.StatusOK || retryResp.Body == nil {
|
||||
if err != nil {
|
||||
log.WithError(err).Errorf("Retry request failed.")
|
||||
} else {
|
||||
log.Errorf("Retry request received non-200 status: %d", retryResp.StatusCode)
|
||||
if retryResp.Body != nil {
|
||||
retryResp.Body.Close()
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
currentReader = retryResp.Body
|
||||
}
|
||||
}
|
||||
|
||||
func buildRetryRequestBody(originalBody models.GeminiRequest, accumulatedText string) (models.GeminiRequest, error) {
|
||||
retryBody := originalBody
|
||||
lastUserIndex := -1
|
||||
for i := len(retryBody.Contents) - 1; i >= 0; i-- {
|
||||
if retryBody.Contents[i].Role == "user" {
|
||||
lastUserIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
history := []models.GeminiContent{
|
||||
{Role: "model", Parts: []models.Part{{Text: accumulatedText}}},
|
||||
{Role: "user", Parts: []models.Part{{Text: SmartRetryPrompt}}},
|
||||
}
|
||||
if lastUserIndex != -1 {
|
||||
newContents := make([]models.GeminiContent, 0, len(retryBody.Contents)+2)
|
||||
newContents = append(newContents, retryBody.Contents[:lastUserIndex+1]...)
|
||||
newContents = append(newContents, history...)
|
||||
newContents = append(newContents, retryBody.Contents[lastUserIndex+1:]...)
|
||||
retryBody.Contents = newContents
|
||||
} else {
|
||||
retryBody.Contents = append(retryBody.Contents, history...)
|
||||
}
|
||||
return retryBody, nil
|
||||
}
|
||||
|
||||
// ===============================================
|
||||
// ========= 辅助函数区 (继承并强化) =========
|
||||
// ===============================================
|
||||
|
||||
type googleAPIError struct {
|
||||
Error struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Status string `json:"status"`
|
||||
Details []interface{} `json:"details,omitempty"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
func statusToGoogleStatus(code int) string {
|
||||
switch code {
|
||||
case 400:
|
||||
return "INVALID_ARGUMENT"
|
||||
case 401:
|
||||
return "UNAUTHENTICATED"
|
||||
case 403:
|
||||
return "PERMISSION_DENIED"
|
||||
case 404:
|
||||
return "NOT_FOUND"
|
||||
case 429:
|
||||
return "RESOURCE_EXHAUSTED"
|
||||
case 500:
|
||||
return "INTERNAL"
|
||||
case 503:
|
||||
return "UNAVAILABLE"
|
||||
case 504:
|
||||
return "DEADLINE_EXCEEDED"
|
||||
default:
|
||||
return "UNKNOWN"
|
||||
}
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if n > 0 && len(s) > n {
|
||||
return fmt.Sprintf("%s... [truncated %d chars]", s[:n], len(s)-n)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// standardizeError
|
||||
func (ch *GeminiChannel) standardizeError(resp *http.Response, truncateLimit int, log *logrus.Entry) *http.Response {
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
log.WithError(err).Error("Failed to read upstream error body")
|
||||
bodyBytes = []byte("Failed to read upstream error body: " + err.Error())
|
||||
}
|
||||
resp.Body.Close()
|
||||
log.Errorf("Upstream error body: %s", truncate(string(bodyBytes), truncateLimit))
|
||||
var standardizedPayload googleAPIError
|
||||
if json.Unmarshal(bodyBytes, &standardizedPayload) != nil || standardizedPayload.Error.Code == 0 {
|
||||
standardizedPayload.Error.Code = resp.StatusCode
|
||||
standardizedPayload.Error.Message = http.StatusText(resp.StatusCode)
|
||||
standardizedPayload.Error.Status = statusToGoogleStatus(resp.StatusCode)
|
||||
standardizedPayload.Error.Details = []interface{}{map[string]string{
|
||||
"@type": "proxy.upstream.error",
|
||||
"body": truncate(string(bodyBytes), truncateLimit),
|
||||
}}
|
||||
}
|
||||
newBodyBytes, _ := json.Marshal(standardizedPayload)
|
||||
newResp := &http.Response{
|
||||
StatusCode: resp.StatusCode,
|
||||
Status: resp.Status,
|
||||
Header: http.Header{},
|
||||
Body: io.NopCloser(bytes.NewReader(newBodyBytes)),
|
||||
}
|
||||
newResp.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||
newResp.Header.Set("Access-Control-Allow-Origin", "*")
|
||||
return newResp
|
||||
}
|
||||
|
||||
// errToJSON
|
||||
func errToJSON(c *gin.Context, apiErr *CustomErrors.APIError) {
|
||||
c.JSON(apiErr.HTTPStatus, gin.H{"error": apiErr})
|
||||
}
|
||||
81
internal/config/config.go
Normal file
81
internal/config/config.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// Config 应用所有配置的集合
|
||||
type Config struct {
|
||||
Database DatabaseConfig
|
||||
Server ServerConfig
|
||||
Log LogConfig
|
||||
Redis RedisConfig `mapstructure:"redis"`
|
||||
SessionSecret string `mapstructure:"session_secret"`
|
||||
EncryptionKey string `mapstructure:"encryption_key"`
|
||||
}
|
||||
|
||||
// DatabaseConfig 存储数据库连接信息
|
||||
type DatabaseConfig struct {
|
||||
DSN string `mapstructure:"dsn"`
|
||||
MaxIdleConns int `mapstructure:"max_idle_conns"`
|
||||
MaxOpenConns int `mapstructure:"max_open_conns"`
|
||||
ConnMaxLifetime time.Duration `mapstructure:"conn_max_lifetime"`
|
||||
}
|
||||
|
||||
// ServerConfig 存储HTTP服务器配置
|
||||
type ServerConfig struct {
|
||||
Port string `mapstructure:"port"`
|
||||
}
|
||||
|
||||
// LogConfig 存储日志配置
|
||||
type LogConfig struct {
|
||||
Level string `mapstructure:"level" json:"level"`
|
||||
Format string `mapstructure:"format" json:"format"`
|
||||
EnableFile bool `mapstructure:"enable_file" json:"enable_file"`
|
||||
FilePath string `mapstructure:"file_path" json:"file_path"`
|
||||
}
|
||||
|
||||
type RedisConfig struct {
|
||||
DSN string `mapstructure:"dsn"`
|
||||
}
|
||||
|
||||
// LoadConfig 从文件和环境变量加载配置
|
||||
func LoadConfig() (*Config, error) {
|
||||
// 设置配置文件名和路径
|
||||
viper.SetConfigName("config")
|
||||
viper.SetConfigType("yaml")
|
||||
viper.AddConfigPath(".")
|
||||
|
||||
// 允许从环境变量读取
|
||||
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
||||
viper.AutomaticEnv()
|
||||
|
||||
// 设置默认值
|
||||
viper.SetDefault("server.port", "8080")
|
||||
viper.SetDefault("log.level", "info")
|
||||
viper.SetDefault("log.format", "text")
|
||||
viper.SetDefault("log.enable_file", false)
|
||||
viper.SetDefault("log.file_path", "logs/gemini-balancer.log")
|
||||
viper.SetDefault("database.type", "sqlite")
|
||||
viper.SetDefault("database.dsn", "gemini-balancer.db")
|
||||
viper.SetDefault("database.max_idle_conns", 10)
|
||||
viper.SetDefault("database.max_open_conns", 100)
|
||||
viper.SetDefault("database.conn_max_lifetime", "1h")
|
||||
viper.SetDefault("encryption_key", "")
|
||||
|
||||
// 读取配置文件
|
||||
if err := viper.ReadInConfig(); err != nil {
|
||||
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
|
||||
return nil, fmt.Errorf("error reading config file: %w", err)
|
||||
}
|
||||
}
|
||||
var cfg Config
|
||||
if err := viper.Unmarshal(&cfg); err != nil {
|
||||
return nil, fmt.Errorf("unable to decode config into struct: %w", err)
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
123
internal/container/container.go
Normal file
123
internal/container/container.go
Normal file
@@ -0,0 +1,123 @@
|
||||
// Filename: internal/container/container.go
|
||||
package container
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gemini-balancer/internal/app"
|
||||
"gemini-balancer/internal/channel"
|
||||
"gemini-balancer/internal/config"
|
||||
"gemini-balancer/internal/crypto"
|
||||
"gemini-balancer/internal/db"
|
||||
"gemini-balancer/internal/db/dialect"
|
||||
"gemini-balancer/internal/db/migrations"
|
||||
"gemini-balancer/internal/domain/proxy"
|
||||
"gemini-balancer/internal/domain/upstream"
|
||||
"gemini-balancer/internal/handlers"
|
||||
"gemini-balancer/internal/logging"
|
||||
"gemini-balancer/internal/models"
|
||||
"gemini-balancer/internal/repository"
|
||||
"gemini-balancer/internal/router"
|
||||
"gemini-balancer/internal/scheduler"
|
||||
"gemini-balancer/internal/service"
|
||||
"gemini-balancer/internal/settings"
|
||||
"gemini-balancer/internal/store"
|
||||
"gemini-balancer/internal/syncer"
|
||||
"gemini-balancer/internal/task"
|
||||
"gemini-balancer/internal/webhandlers"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"go.uber.org/dig"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func BuildContainer() (*dig.Container, error) {
|
||||
container := dig.New()
|
||||
|
||||
// =========== 阶段一: 基础设施层 (Infrastructure) ===========
|
||||
container.Provide(config.LoadConfig)
|
||||
|
||||
container.Provide(func(cfg *config.Config, logger *logrus.Logger) (*gorm.DB, dialect.DialectAdapter, error) {
|
||||
gormDB, adapter, err := db.NewDB(cfg, logger)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// 迁移运行逻辑
|
||||
if err := migrations.RunVersionedMigrations(gormDB, cfg, logger); err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to run versioned migrations: %w", err)
|
||||
}
|
||||
return gormDB, adapter, nil
|
||||
})
|
||||
container.Provide(store.NewStore)
|
||||
container.Provide(logging.NewLogger)
|
||||
container.Provide(crypto.NewService)
|
||||
container.Provide(repository.NewAuthTokenRepository)
|
||||
container.Provide(repository.NewGroupRepository)
|
||||
container.Provide(repository.NewKeyRepository)
|
||||
// Repository 接口绑定
|
||||
//container.Provide(func(r *repository.gormKeyRepository) repository.KeyRepository { return r })
|
||||
//container.Provide(func(r *repository.GormGroupRepository) repository.GroupRepository { return r })
|
||||
|
||||
// SettingsManager.
|
||||
container.Provide(settings.NewSettingsManager)
|
||||
// 基于SettingsManager, 提供一个标准的、安全的“数据插座” 让模块只依赖所需的数据,而非整个管理器。
|
||||
container.Provide(func(sm *settings.SettingsManager) *models.SystemSettings { return sm.GetSettings() })
|
||||
|
||||
// =========== 阶段二: 核心服务层 (Services) ===========
|
||||
container.Provide(service.NewDBLogWriterService)
|
||||
container.Provide(service.NewSecurityService)
|
||||
container.Provide(crypto.NewService)
|
||||
container.Provide(service.NewKeyImportService)
|
||||
container.Provide(service.NewKeyValidationService)
|
||||
container.Provide(service.NewTokenManager)
|
||||
container.Provide(service.NewAPIKeyService)
|
||||
container.Provide(service.NewGroupManager)
|
||||
container.Provide(service.NewResourceService)
|
||||
container.Provide(service.NewAnalyticsService)
|
||||
container.Provide(service.NewLogService)
|
||||
container.Provide(service.NewHealthCheckService)
|
||||
container.Provide(service.NewStatsService)
|
||||
container.Provide(service.NewDashboardQueryService)
|
||||
container.Provide(scheduler.NewScheduler)
|
||||
container.Provide(task.NewTask)
|
||||
|
||||
// --- Task Reporter ---
|
||||
container.Provide(func(t *task.Task) task.Reporter { return t })
|
||||
// --- Syncer & Loader for GroupManager ---
|
||||
container.Provide(service.NewGroupManagerLoader)
|
||||
// 为GroupManager配置Syncer
|
||||
container.Provide(func(loader syncer.LoaderFunc[service.GroupManagerCacheData], store store.Store, logger *logrus.Logger) (*syncer.CacheSyncer[service.GroupManagerCacheData], error) {
|
||||
const groupUpdateChannel = "groups:cache_invalidation"
|
||||
return syncer.NewCacheSyncer(loader, store, groupUpdateChannel)
|
||||
})
|
||||
|
||||
// =========== 阶段三: 适配器与处理器层 (Handlers & Adapters) ===========
|
||||
|
||||
// 为Channel提供依赖 (Logger 和 *models.SystemSettings 数据插座)
|
||||
container.Provide(channel.NewGeminiChannel)
|
||||
container.Provide(func(ch *channel.GeminiChannel) channel.ChannelProxy { return ch })
|
||||
|
||||
// --- API Handlers ---
|
||||
container.Provide(handlers.NewAPIKeyHandler)
|
||||
container.Provide(handlers.NewKeyGroupHandler)
|
||||
container.Provide(handlers.NewTokensHandler)
|
||||
container.Provide(handlers.NewLogHandler)
|
||||
container.Provide(handlers.NewSettingHandler)
|
||||
container.Provide(handlers.NewDashboardHandler)
|
||||
container.Provide(handlers.NewAPIAuthHandler)
|
||||
container.Provide(handlers.NewProxyHandler)
|
||||
container.Provide(handlers.NewTaskHandler)
|
||||
|
||||
// --- Domain Modules ---
|
||||
container.Provide(upstream.NewModule)
|
||||
container.Provide(proxy.NewModule)
|
||||
|
||||
// --- Web Page Handlers ---
|
||||
container.Provide(webhandlers.NewWebAuthHandler)
|
||||
container.Provide(webhandlers.NewPageHandler)
|
||||
|
||||
// =========== 顶层应用层 (Application) ===========
|
||||
container.Provide(router.NewRouter)
|
||||
container.Provide(app.NewApp)
|
||||
|
||||
return container, nil
|
||||
}
|
||||
76
internal/crypto/crypto.go
Normal file
76
internal/crypto/crypto.go
Normal file
@@ -0,0 +1,76 @@
|
||||
// Filename: internal/crypto/crypto.go
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"gemini-balancer/internal/config" // [NEW] Crypto service now depends on Config
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
gcm cipher.AEAD
|
||||
}
|
||||
|
||||
func NewService(cfg *config.Config) (*Service, error) {
|
||||
keyHex := cfg.EncryptionKey
|
||||
if keyHex == "" {
|
||||
// Fallback to environment variable if not in config file
|
||||
keyHex = os.Getenv("ENCRYPTION_KEY")
|
||||
if keyHex == "" {
|
||||
return nil, fmt.Errorf("encryption key is not configured: please set 'encryption_key' in config.yaml or the ENCRYPTION_KEY environment variable")
|
||||
}
|
||||
}
|
||||
key, err := hex.DecodeString(keyHex)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode encryption key from hex: %w", err)
|
||||
}
|
||||
if len(key) != 32 {
|
||||
return nil, fmt.Errorf("invalid encryption key length: must be 32 bytes (64 hex characters), got %d bytes", len(key))
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil { // ... (rest is the same)
|
||||
return nil, fmt.Errorf("failed to create AES cipher block: %w", err)
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create GCM block cipher: %w", err)
|
||||
}
|
||||
return &Service{gcm: gcm}, nil
|
||||
}
|
||||
|
||||
// Encrypt encrypts plaintext and returns hex-encoded ciphertext.
|
||||
func (s *Service) Encrypt(plaintext string) (string, error) {
|
||||
nonce := make([]byte, s.gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return "", fmt.Errorf("failed to generate nonce: %w", err)
|
||||
}
|
||||
|
||||
ciphertext := s.gcm.Seal(nonce, nonce, []byte(plaintext), nil)
|
||||
return hex.EncodeToString(ciphertext), nil
|
||||
}
|
||||
|
||||
// Decrypt decrypts a hex-encoded ciphertext and returns the plaintext.
|
||||
func (s *Service) Decrypt(hexCiphertext string) (string, error) {
|
||||
ciphertext, err := hex.DecodeString(hexCiphertext)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("ciphertext hex decode error: %w", err)
|
||||
}
|
||||
|
||||
nonceSize := s.gcm.NonceSize()
|
||||
if len(ciphertext) < nonceSize {
|
||||
return "", fmt.Errorf("invalid ciphertext: too short")
|
||||
}
|
||||
|
||||
nonce, encryptedMessage := ciphertext[:nonceSize], ciphertext[nonceSize:]
|
||||
plaintext, err := s.gcm.Open(nil, nonce, encryptedMessage, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to decrypt: %w", err)
|
||||
}
|
||||
|
||||
return string(plaintext), nil
|
||||
}
|
||||
88
internal/db/db.go
Normal file
88
internal/db/db.go
Normal file
@@ -0,0 +1,88 @@
|
||||
// Filename: internal/db/db.go
|
||||
package db
|
||||
|
||||
import (
|
||||
"gemini-balancer/internal/config"
|
||||
"gemini-balancer/internal/db/dialect"
|
||||
stdlog "log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/sirupsen/logrus"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func NewDB(cfg *config.Config, appLogger *logrus.Logger) (*gorm.DB, dialect.DialectAdapter, error) {
|
||||
Logger := appLogger.WithField("component", "db")
|
||||
Logger.Info("Initializing database connection and dialect adapter...")
|
||||
dbConfig := cfg.Database
|
||||
dsn := dbConfig.DSN
|
||||
var gormLogger logger.Interface
|
||||
if cfg.Log.Level == "debug" {
|
||||
gormLogger = logger.New(
|
||||
stdlog.New(os.Stdout, "\r\n", stdlog.LstdFlags),
|
||||
logger.Config{
|
||||
SlowThreshold: 1 * time.Second,
|
||||
LogLevel: logger.Info,
|
||||
IgnoreRecordNotFoundError: true,
|
||||
Colorful: true,
|
||||
},
|
||||
)
|
||||
Logger.Info("Debug mode enabled, GORM SQL logging is active.")
|
||||
}
|
||||
|
||||
var dialector gorm.Dialector
|
||||
var adapter dialect.DialectAdapter
|
||||
switch {
|
||||
case strings.HasPrefix(dsn, "postgres://"), strings.HasPrefix(dsn, "postgresql://"):
|
||||
Logger.Info("Detected PostgreSQL database.")
|
||||
dialector = postgres.Open(dsn)
|
||||
adapter = dialect.NewPostgresAdapter()
|
||||
case strings.Contains(dsn, "@tcp"):
|
||||
Logger.Info("Detected MySQL database.")
|
||||
if !strings.Contains(dsn, "parseTime=true") {
|
||||
if strings.Contains(dsn, "?") {
|
||||
dsn += "&parseTime=true"
|
||||
} else {
|
||||
dsn += "?parseTime=true"
|
||||
}
|
||||
}
|
||||
dialector = mysql.Open(dsn)
|
||||
adapter = dialect.NewPostgresAdapter()
|
||||
default:
|
||||
Logger.Info("Using SQLite database.")
|
||||
if err := os.MkdirAll(filepath.Dir(dsn), 0755); err != nil {
|
||||
Logger.Errorf("Failed to create SQLite directory: %v", err)
|
||||
return nil, nil, err
|
||||
}
|
||||
dialector = sqlite.Open(dsn + "?_busy_timeout=5000")
|
||||
adapter = dialect.NewSQLiteAdapter()
|
||||
}
|
||||
db, err := gorm.Open(dialector, &gorm.Config{
|
||||
Logger: gormLogger,
|
||||
PrepareStmt: true,
|
||||
})
|
||||
if err != nil {
|
||||
Logger.Errorf("Failed to open database connection: %v", err)
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
Logger.Errorf("Failed to get underlying sql.DB: %v", err)
|
||||
return nil, nil, err
|
||||
}
|
||||
sqlDB.SetMaxIdleConns(dbConfig.MaxIdleConns)
|
||||
sqlDB.SetMaxOpenConns(dbConfig.MaxOpenConns)
|
||||
sqlDB.SetConnMaxLifetime(dbConfig.ConnMaxLifetime)
|
||||
Logger.Infof("Connection pool configured: MaxIdle=%d, MaxOpen=%d, MaxLifetime=%v",
|
||||
dbConfig.MaxIdleConns, dbConfig.MaxOpenConns, dbConfig.ConnMaxLifetime)
|
||||
Logger.Info("Database connection established successfully.")
|
||||
return db, adapter, nil
|
||||
}
|
||||
14
internal/db/dialect/dialect.go
Normal file
14
internal/db/dialect/dialect.go
Normal file
@@ -0,0 +1,14 @@
|
||||
// Filename: internal/db/dialect/dialect.go
|
||||
package dialect
|
||||
|
||||
import (
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// “通用语言”接口。
|
||||
type DialectAdapter interface {
|
||||
// OnConflictUpdateAll 生成一个完整的、适用于当前数据库的 "ON CONFLICT DO UPDATE" 子句。
|
||||
// conflictColumns: 唯一的约束列,例如 ["time", "group_id", "model_name"]
|
||||
// updateColumns: 需要累加更新的列,例如 ["request_count", "success_count", ...]
|
||||
OnConflictUpdateAll(conflictColumns []string, updateColumns []string) clause.Expression
|
||||
}
|
||||
30
internal/db/dialect/mysql_adapter.go
Normal file
30
internal/db/dialect/mysql_adapter.go
Normal file
@@ -0,0 +1,30 @@
|
||||
// Filename: internal/db/mysql_adapter.go
|
||||
package dialect
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type mysqlAdapter struct{}
|
||||
|
||||
func NewMySQLAdapter() DialectAdapter {
|
||||
return &mysqlAdapter{}
|
||||
}
|
||||
|
||||
func (a *mysqlAdapter) OnConflictUpdateAll(conflictColumns []string, updateColumns []string) clause.Expression {
|
||||
conflictCols := make([]clause.Column, len(conflictColumns))
|
||||
for i, col := range conflictColumns {
|
||||
conflictCols[i] = clause.Column{Name: col}
|
||||
}
|
||||
|
||||
assignments := make(map[string]interface{})
|
||||
for _, col := range updateColumns {
|
||||
assignments[col] = gorm.Expr(col + " + VALUES(" + col + ")")
|
||||
}
|
||||
|
||||
return clause.OnConflict{
|
||||
Columns: conflictCols,
|
||||
DoUpdates: clause.Assignments(assignments),
|
||||
}
|
||||
}
|
||||
30
internal/db/dialect/postgres_adapter.go
Normal file
30
internal/db/dialect/postgres_adapter.go
Normal file
@@ -0,0 +1,30 @@
|
||||
// Filename: internal/db/dialect/postgres_adapter.go (全新文件)
|
||||
package dialect
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type postgresAdapter struct{}
|
||||
|
||||
func NewPostgresAdapter() DialectAdapter {
|
||||
return &postgresAdapter{}
|
||||
}
|
||||
|
||||
func (a *postgresAdapter) OnConflictUpdateAll(conflictColumns []string, updateColumns []string) clause.Expression {
|
||||
conflictCols := make([]clause.Column, len(conflictColumns))
|
||||
for i, col := range conflictColumns {
|
||||
conflictCols[i] = clause.Column{Name: col}
|
||||
}
|
||||
|
||||
assignments := make(map[string]interface{})
|
||||
for _, col := range updateColumns {
|
||||
assignments[col] = gorm.Expr(col + " + excluded." + col)
|
||||
}
|
||||
|
||||
return clause.OnConflict{
|
||||
Columns: conflictCols,
|
||||
DoUpdates: clause.Assignments(assignments),
|
||||
}
|
||||
}
|
||||
30
internal/db/dialect/sqlite_adapter.go
Normal file
30
internal/db/dialect/sqlite_adapter.go
Normal file
@@ -0,0 +1,30 @@
|
||||
// Filename: internal/db/sqlite_adapter.go (全新文件 - 最终版)
|
||||
package dialect
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type sqliteAdapter struct{}
|
||||
|
||||
func NewSQLiteAdapter() DialectAdapter {
|
||||
return &sqliteAdapter{}
|
||||
}
|
||||
|
||||
func (a *sqliteAdapter) OnConflictUpdateAll(conflictColumns []string, updateColumns []string) clause.Expression {
|
||||
conflictCols := make([]clause.Column, len(conflictColumns))
|
||||
for i, col := range conflictColumns {
|
||||
conflictCols[i] = clause.Column{Name: col}
|
||||
}
|
||||
|
||||
assignments := make(map[string]interface{})
|
||||
for _, col := range updateColumns {
|
||||
assignments[col] = gorm.Expr(col + " + excluded." + col)
|
||||
}
|
||||
|
||||
return clause.OnConflict{
|
||||
Columns: conflictCols,
|
||||
DoUpdates: clause.Assignments(assignments),
|
||||
}
|
||||
}
|
||||
36
internal/db/migrations/migrations.go
Normal file
36
internal/db/migrations/migrations.go
Normal file
@@ -0,0 +1,36 @@
|
||||
// Filename: internal/db/migrations/migrations.go (全新)
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"gemini-balancer/internal/models"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// RunMigrations 负责执行所有的数据库模式迁移。
|
||||
func RunMigrations(db *gorm.DB, logger *logrus.Logger) error {
|
||||
log := logger.WithField("component", "migrations")
|
||||
log.Info("Running database schema migrations...")
|
||||
// 集中管理所有需要被创建或更新的表。
|
||||
err := db.AutoMigrate(
|
||||
&models.UpstreamEndpoint{},
|
||||
&models.ProxyConfig{},
|
||||
&models.APIKey{},
|
||||
&models.KeyGroup{},
|
||||
&models.GroupModelMapping{},
|
||||
&models.AuthToken{},
|
||||
&models.RequestLog{},
|
||||
&models.StatsHourly{},
|
||||
&models.FileRecord{},
|
||||
&models.Setting{},
|
||||
&models.GroupSettings{},
|
||||
&models.GroupAPIKeyMapping{},
|
||||
)
|
||||
if err != nil {
|
||||
log.Errorf("Database schema migration failed: %v", err)
|
||||
return err
|
||||
}
|
||||
log.Info("Database schema migrations completed successfully.")
|
||||
return nil
|
||||
}
|
||||
62
internal/db/migrations/versioned_migrations.go
Normal file
62
internal/db/migrations/versioned_migrations.go
Normal file
@@ -0,0 +1,62 @@
|
||||
// Filename: internal/db/migrations/versioned_migrations.go
|
||||
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gemini-balancer/internal/config"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// RunVersionedMigrations 负责运行所有已注册的版本化迁移。
|
||||
func RunVersionedMigrations(db *gorm.DB, cfg *config.Config, logger *logrus.Logger) error {
|
||||
log := logger.WithField("component", "versioned_migrations")
|
||||
log.Info("Checking for versioned database migrations...")
|
||||
|
||||
if err := db.AutoMigrate(&MigrationHistory{}); err != nil {
|
||||
log.Errorf("Failed to create migration history table: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
var executedMigrations []MigrationHistory
|
||||
db.Find(&executedMigrations)
|
||||
executedVersions := make(map[string]bool)
|
||||
for _, m := range executedMigrations {
|
||||
executedVersions[m.Version] = true
|
||||
}
|
||||
|
||||
for _, migration := range migrationRegistry {
|
||||
if !executedVersions[migration.Version] {
|
||||
log.Infof("Running migration %s: %s", migration.Version, migration.Description)
|
||||
if err := migration.Migrate(db, cfg, log); err != nil {
|
||||
log.Errorf("Migration %s failed: %v", migration.Version, err)
|
||||
return fmt.Errorf("migration %s failed: %w", migration.Version, err)
|
||||
}
|
||||
db.Create(&MigrationHistory{Version: migration.Version})
|
||||
log.Infof("Migration %s completed successfully.", migration.Version)
|
||||
}
|
||||
}
|
||||
|
||||
log.Info("All versioned migrations are up to date.")
|
||||
return nil
|
||||
}
|
||||
|
||||
type MigrationFunc func(db *gorm.DB, cfg *config.Config, logger *logrus.Entry) error
|
||||
type VersionedMigration struct {
|
||||
Version string
|
||||
Description string
|
||||
Migrate MigrationFunc
|
||||
}
|
||||
type MigrationHistory struct {
|
||||
Version string `gorm:"primaryKey"`
|
||||
}
|
||||
|
||||
var migrationRegistry = []VersionedMigration{
|
||||
/*{
|
||||
Version: "20250828_encrypt_existing_auth_tokens",
|
||||
Description: "Encrypt plaintext tokens and populate new crypto columns in auth_tokens table.",
|
||||
Migrate: MigrateAuthTokenEncryption,
|
||||
},*/
|
||||
}
|
||||
87
internal/db/seeder/seeder.go
Normal file
87
internal/db/seeder/seeder.go
Normal file
@@ -0,0 +1,87 @@
|
||||
// Filename: internal/db/seeder/seeder.go
|
||||
package seeder
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"gemini-balancer/internal/crypto"
|
||||
"gemini-balancer/internal/models"
|
||||
"gemini-balancer/internal/repository"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// RunSeeder now requires the crypto service to create the initial admin token securely.
|
||||
func RunSeeder(db *gorm.DB, cryptoService *crypto.Service, logger *logrus.Logger) {
|
||||
log := logger.WithField("component", "seeder")
|
||||
log.Info("Running database seeder...")
|
||||
// [REFACTORED] Admin token seeding is now crypto-aware.
|
||||
var count int64
|
||||
db.Model(&models.AuthToken{}).Where("is_admin = ?", true).Count(&count)
|
||||
if count == 0 {
|
||||
log.Info("No admin token found, attempting to seed one...")
|
||||
const adminTokenPlaintext = "admin-secret-token" // The default token
|
||||
// 1. Encrypt and Hash the token
|
||||
encryptedToken, err := cryptoService.Encrypt(adminTokenPlaintext)
|
||||
if err != nil {
|
||||
log.Fatalf("FATAL: Failed to encrypt default admin token during seeding: %v. Server cannot start.", err)
|
||||
return
|
||||
}
|
||||
hash := sha256.Sum256([]byte(adminTokenPlaintext))
|
||||
tokenHash := hex.EncodeToString(hash[:])
|
||||
// 2. Use the repository to seed the token
|
||||
// Note: We create a temporary repository instance here just for the seeder.
|
||||
repo := repository.NewAuthTokenRepository(db, cryptoService, logger)
|
||||
if err := repo.SeedAdminToken(encryptedToken, tokenHash); err != nil {
|
||||
log.Warnf("Failed to seed admin token using repository: %v", err)
|
||||
} else {
|
||||
log.Infof("Default admin token has been seeded successfully. Please use '%s' for your first login.", adminTokenPlaintext)
|
||||
}
|
||||
} else {
|
||||
log.Info("Admin token already exists, seeder skipped.")
|
||||
}
|
||||
|
||||
// This functionality should be replaced by a proper user/token management UI in the future.
|
||||
linkAllKeysToDefaultGroup(db, log)
|
||||
}
|
||||
|
||||
// linkAllKeysToDefaultGroup ensures every key belongs to at least one group.
|
||||
func linkAllKeysToDefaultGroup(db *gorm.DB, logger *logrus.Entry) {
|
||||
logger.Info("Linking existing API keys to the default group as a fallback...")
|
||||
// 1. Find a default group (the first one for simplicity)
|
||||
var defaultGroup models.KeyGroup
|
||||
if err := db.Order("id asc").First(&defaultGroup).Error; err != nil {
|
||||
logger.Warnf("Seeder: Could not find a default key group to link keys to: %v", err)
|
||||
return
|
||||
}
|
||||
// 2. Find all "orphan keys" that don't belong to any group
|
||||
var orphanKeys []*models.APIKey
|
||||
err := db.Raw(`
|
||||
SELECT * FROM api_keys
|
||||
WHERE id NOT IN (SELECT DISTINCT api_key_id FROM group_api_key_mappings)
|
||||
AND deleted_at IS NULL
|
||||
`).Scan(&orphanKeys).Error
|
||||
if err != nil {
|
||||
logger.Errorf("Seeder: Failed to query for orphan keys: %v", err)
|
||||
return
|
||||
}
|
||||
if len(orphanKeys) == 0 {
|
||||
logger.Info("Seeder: No orphan API keys found to link.")
|
||||
return
|
||||
}
|
||||
// 3. Create GroupAPIKeyMapping records manually
|
||||
logger.Infof("Seeder: Found %d orphan keys. Creating mappings for them in group '%s' (ID: %d)...", len(orphanKeys), defaultGroup.Name, defaultGroup.ID)
|
||||
var newMappings []models.GroupAPIKeyMapping
|
||||
for _, key := range orphanKeys {
|
||||
newMappings = append(newMappings, models.GroupAPIKeyMapping{
|
||||
KeyGroupID: defaultGroup.ID,
|
||||
APIKeyID: key.ID,
|
||||
})
|
||||
}
|
||||
if err := db.Create(&newMappings).Error; err != nil {
|
||||
logger.Errorf("Seeder: Failed to create key mappings for orphan keys: %v", err)
|
||||
} else {
|
||||
logger.Info("Successfully created mappings for orphan API keys.")
|
||||
}
|
||||
}
|
||||
8
internal/domain/proxy/errors.go
Normal file
8
internal/domain/proxy/errors.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package proxy
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrNoActiveProxies = errors.New("no active proxies available in the pool")
|
||||
ErrTaskConflict = errors.New("a sync task is already in progress for proxies")
|
||||
)
|
||||
269
internal/domain/proxy/handler.go
Normal file
269
internal/domain/proxy/handler.go
Normal file
@@ -0,0 +1,269 @@
|
||||
// Filename: internal/domain/proxy/handler.go
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"gemini-balancer/internal/errors"
|
||||
"gemini-balancer/internal/models"
|
||||
"gemini-balancer/internal/response"
|
||||
"gemini-balancer/internal/settings"
|
||||
"gemini-balancer/internal/store"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type handler struct {
|
||||
db *gorm.DB
|
||||
manager *manager
|
||||
store store.Store
|
||||
settings *settings.SettingsManager
|
||||
}
|
||||
|
||||
func newHandler(db *gorm.DB, m *manager, s store.Store, sp *settings.SettingsManager) *handler {
|
||||
return &handler{
|
||||
db: db,
|
||||
manager: m,
|
||||
store: s,
|
||||
settings: sp,
|
||||
}
|
||||
}
|
||||
|
||||
// === 领域暴露的公共API ===
|
||||
|
||||
func (h *handler) registerRoutes(rg *gin.RouterGroup) {
|
||||
proxyRoutes := rg.Group("/proxies")
|
||||
{
|
||||
proxyRoutes.PUT("/sync", h.SyncProxies)
|
||||
proxyRoutes.POST("/check", h.CheckSingleProxy)
|
||||
proxyRoutes.POST("/check-all", h.CheckAllProxies)
|
||||
|
||||
proxyRoutes.POST("/", h.CreateProxyConfig)
|
||||
proxyRoutes.GET("/", h.ListProxyConfigs)
|
||||
proxyRoutes.GET("/:id", h.GetProxyConfig)
|
||||
proxyRoutes.PUT("/:id", h.UpdateProxyConfig)
|
||||
proxyRoutes.DELETE("/:id", h.DeleteProxyConfig)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// --- 请求 DTO ---
|
||||
type CreateProxyConfigRequest struct {
|
||||
Address string `json:"address" binding:"required"`
|
||||
Protocol string `json:"protocol" binding:"required,oneof=http https socks5"`
|
||||
Status string `json:"status" binding:"omitempty,oneof=active inactive"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type UpdateProxyConfigRequest struct {
|
||||
Address *string `json:"address"`
|
||||
Protocol *string `json:"protocol" binding:"omitempty,oneof=http https socks5"`
|
||||
Status *string `json:"status" binding:"omitempty,oneof=active inactive"`
|
||||
Description *string `json:"description"`
|
||||
}
|
||||
|
||||
// 单个检测的请求体 (与前端JS对齐)
|
||||
type CheckSingleProxyRequest struct {
|
||||
Proxy string `json:"proxy" binding:"required"`
|
||||
}
|
||||
|
||||
// 批量检测的请求体
|
||||
type CheckAllProxiesRequest struct {
|
||||
Proxies []string `json:"proxies" binding:"required"`
|
||||
}
|
||||
|
||||
// --- Handler 方法 ---
|
||||
|
||||
func (h *handler) CreateProxyConfig(c *gin.Context) {
|
||||
var req CreateProxyConfigRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrInvalidJSON, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if req.Status == "" {
|
||||
req.Status = "active" // 默认状态
|
||||
}
|
||||
|
||||
proxyConfig := models.ProxyConfig{
|
||||
Address: req.Address,
|
||||
Protocol: req.Protocol,
|
||||
Status: req.Status,
|
||||
Description: req.Description,
|
||||
}
|
||||
|
||||
if err := h.db.Create(&proxyConfig).Error; err != nil {
|
||||
response.Error(c, errors.ParseDBError(err))
|
||||
return
|
||||
}
|
||||
// 写操作后,发布事件并使缓存失效
|
||||
h.publishAndInvalidate(proxyConfig.ID, "created")
|
||||
response.Created(c, proxyConfig)
|
||||
}
|
||||
|
||||
func (h *handler) ListProxyConfigs(c *gin.Context) {
|
||||
var proxyConfigs []models.ProxyConfig
|
||||
if err := h.db.Find(&proxyConfigs).Error; err != nil {
|
||||
response.Error(c, errors.ParseDBError(err))
|
||||
return
|
||||
}
|
||||
response.Success(c, proxyConfigs)
|
||||
}
|
||||
|
||||
func (h *handler) GetProxyConfig(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrBadRequest, "Invalid ID format"))
|
||||
return
|
||||
}
|
||||
|
||||
var proxyConfig models.ProxyConfig
|
||||
if err := h.db.First(&proxyConfig, id).Error; err != nil {
|
||||
response.Error(c, errors.ParseDBError(err))
|
||||
return
|
||||
}
|
||||
response.Success(c, proxyConfig)
|
||||
}
|
||||
|
||||
func (h *handler) UpdateProxyConfig(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrBadRequest, "Invalid ID format"))
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateProxyConfigRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrInvalidJSON, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
var proxyConfig models.ProxyConfig
|
||||
if err := h.db.First(&proxyConfig, id).Error; err != nil {
|
||||
response.Error(c, errors.ParseDBError(err))
|
||||
return
|
||||
}
|
||||
|
||||
if req.Address != nil {
|
||||
proxyConfig.Address = *req.Address
|
||||
}
|
||||
if req.Protocol != nil {
|
||||
proxyConfig.Protocol = *req.Protocol
|
||||
}
|
||||
if req.Status != nil {
|
||||
proxyConfig.Status = *req.Status
|
||||
}
|
||||
if req.Description != nil {
|
||||
proxyConfig.Description = *req.Description
|
||||
}
|
||||
|
||||
if err := h.db.Save(&proxyConfig).Error; err != nil {
|
||||
response.Error(c, errors.ParseDBError(err))
|
||||
return
|
||||
}
|
||||
|
||||
h.publishAndInvalidate(uint(id), "updated")
|
||||
response.Success(c, proxyConfig)
|
||||
}
|
||||
|
||||
func (h *handler) DeleteProxyConfig(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrBadRequest, "Invalid ID format"))
|
||||
return
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := h.db.Model(&models.APIKey{}).Where("proxy_id = ?", id).Count(&count).Error; err != nil {
|
||||
response.Error(c, errors.ParseDBError(err))
|
||||
return
|
||||
}
|
||||
if count > 0 {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrDuplicateResource, "Cannot delete proxy config that is still in use by API keys"))
|
||||
return
|
||||
}
|
||||
|
||||
result := h.db.Delete(&models.ProxyConfig{}, id)
|
||||
if result.Error != nil {
|
||||
response.Error(c, errors.ParseDBError(result.Error))
|
||||
return
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
response.Error(c, errors.ErrResourceNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
h.publishAndInvalidate(uint(id), "deleted")
|
||||
response.NoContent(c)
|
||||
}
|
||||
|
||||
// publishAndInvalidate 统一事件发布和缓存失效逻辑
|
||||
func (h *handler) publishAndInvalidate(proxyID uint, action string) {
|
||||
go h.manager.invalidate()
|
||||
go func() {
|
||||
event := models.ProxyStatusChangedEvent{ProxyID: proxyID, Action: action}
|
||||
eventData, _ := json.Marshal(event)
|
||||
_ = h.store.Publish(models.TopicProxyStatusChanged, eventData)
|
||||
}()
|
||||
}
|
||||
|
||||
// 新的 Handler 方法和 DTO
|
||||
type SyncProxiesRequest struct {
|
||||
Proxies []string `json:"proxies"`
|
||||
}
|
||||
|
||||
func (h *handler) SyncProxies(c *gin.Context) {
|
||||
var req SyncProxiesRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrInvalidJSON, err.Error()))
|
||||
return
|
||||
}
|
||||
taskStatus, err := h.manager.SyncProxiesInBackground(req.Proxies)
|
||||
if err != nil {
|
||||
|
||||
if errors.Is(err, ErrTaskConflict) {
|
||||
|
||||
response.Error(c, errors.NewAPIError(errors.ErrTaskInProgress, err.Error()))
|
||||
} else {
|
||||
|
||||
response.Error(c, errors.NewAPIError(errors.ErrInternalServer, err.Error()))
|
||||
}
|
||||
return
|
||||
}
|
||||
response.Success(c, gin.H{
|
||||
"message": "Proxy synchronization task started.",
|
||||
"task": taskStatus,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *handler) CheckSingleProxy(c *gin.Context) {
|
||||
var req CheckSingleProxyRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrInvalidJSON, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
cfg := h.settings.GetSettings()
|
||||
timeout := time.Duration(cfg.ProxyCheckTimeoutSeconds) * time.Second
|
||||
result := h.manager.CheckSingleProxy(req.Proxy, timeout)
|
||||
response.Success(c, result)
|
||||
}
|
||||
|
||||
func (h *handler) CheckAllProxies(c *gin.Context) {
|
||||
var req CheckAllProxiesRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrInvalidJSON, err.Error()))
|
||||
return
|
||||
}
|
||||
cfg := h.settings.GetSettings()
|
||||
timeout := time.Duration(cfg.ProxyCheckTimeoutSeconds) * time.Second
|
||||
|
||||
concurrency := cfg.ProxyCheckConcurrency
|
||||
if concurrency <= 0 {
|
||||
concurrency = 5 // 如果配置不合法,提供一个安全的默认值
|
||||
}
|
||||
results := h.manager.CheckMultipleProxies(req.Proxies, timeout, concurrency)
|
||||
response.Success(c, results)
|
||||
}
|
||||
315
internal/domain/proxy/manager.go
Normal file
315
internal/domain/proxy/manager.go
Normal file
@@ -0,0 +1,315 @@
|
||||
// Filename: internal/domain/proxy/manager.go
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gemini-balancer/internal/models"
|
||||
"gemini-balancer/internal/store"
|
||||
"gemini-balancer/internal/syncer"
|
||||
"gemini-balancer/internal/task"
|
||||
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sirupsen/logrus"
|
||||
"golang.org/x/net/proxy"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
TaskTypeProxySync = "proxy_sync"
|
||||
proxyChunkSize = 200 // 代理同步的批量大小
|
||||
)
|
||||
|
||||
type ProxyCheckResult struct {
|
||||
Proxy string `json:"proxy"`
|
||||
IsAvailable bool `json:"is_available"`
|
||||
ResponseTime float64 `json:"response_time"`
|
||||
ErrorMessage string `json:"error_message"`
|
||||
}
|
||||
|
||||
// managerCacheData
|
||||
type managerCacheData struct {
|
||||
ActiveProxies []*models.ProxyConfig
|
||||
ProxiesByID map[uint]*models.ProxyConfig
|
||||
}
|
||||
|
||||
// manager结构体
|
||||
type manager struct {
|
||||
db *gorm.DB
|
||||
syncer *syncer.CacheSyncer[managerCacheData]
|
||||
task task.Reporter
|
||||
store store.Store
|
||||
logger *logrus.Entry
|
||||
}
|
||||
|
||||
func newManagerLoader(db *gorm.DB) syncer.LoaderFunc[managerCacheData] {
|
||||
return func() (managerCacheData, error) {
|
||||
var activeProxies []*models.ProxyConfig
|
||||
if err := db.Where("status = ?", "active").Order("assigned_keys_count asc").Find(&activeProxies).Error; err != nil {
|
||||
return managerCacheData{}, fmt.Errorf("failed to load active proxies for cache: %w", err)
|
||||
}
|
||||
|
||||
proxiesByID := make(map[uint]*models.ProxyConfig, len(activeProxies))
|
||||
for _, proxy := range activeProxies {
|
||||
p := *proxy
|
||||
proxiesByID[p.ID] = &p
|
||||
}
|
||||
|
||||
return managerCacheData{
|
||||
ActiveProxies: activeProxies,
|
||||
ProxiesByID: proxiesByID,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func newManager(db *gorm.DB, syncer *syncer.CacheSyncer[managerCacheData], taskReporter task.Reporter, store store.Store, logger *logrus.Entry) *manager {
|
||||
return &manager{
|
||||
db: db,
|
||||
syncer: syncer,
|
||||
task: taskReporter,
|
||||
store: store,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *manager) SyncProxiesInBackground(proxyStrings []string) (*task.Status, error) {
|
||||
resourceID := "global_proxy_sync"
|
||||
taskStatus, err := m.task.StartTask(0, TaskTypeProxySync, resourceID, len(proxyStrings), 0)
|
||||
if err != nil {
|
||||
return nil, ErrTaskConflict
|
||||
}
|
||||
go m.runProxySyncTask(taskStatus.ID, proxyStrings)
|
||||
return taskStatus, nil
|
||||
}
|
||||
|
||||
func (m *manager) runProxySyncTask(taskID string, finalProxyStrings []string) {
|
||||
resourceID := "global_proxy_sync"
|
||||
var allProxies []models.ProxyConfig
|
||||
if err := m.db.Find(&allProxies).Error; err != nil {
|
||||
m.task.EndTaskByID(taskID, resourceID, nil, fmt.Errorf("failed to fetch current proxies: %w", err))
|
||||
return
|
||||
}
|
||||
currentProxyMap := make(map[string]uint)
|
||||
for _, p := range allProxies {
|
||||
fullString := fmt.Sprintf("%s://%s", p.Protocol, p.Address)
|
||||
currentProxyMap[fullString] = p.ID
|
||||
}
|
||||
finalProxyMap := make(map[string]bool)
|
||||
for _, ps := range finalProxyStrings {
|
||||
finalProxyMap[strings.TrimSpace(ps)] = true
|
||||
}
|
||||
var idsToDelete []uint
|
||||
var proxiesToAdd []models.ProxyConfig
|
||||
for proxyString, id := range currentProxyMap {
|
||||
if !finalProxyMap[proxyString] {
|
||||
idsToDelete = append(idsToDelete, id)
|
||||
}
|
||||
}
|
||||
for proxyString := range finalProxyMap {
|
||||
if _, exists := currentProxyMap[proxyString]; !exists {
|
||||
parsed := parseProxyString(proxyString)
|
||||
if parsed != nil {
|
||||
proxiesToAdd = append(proxiesToAdd, models.ProxyConfig{
|
||||
Protocol: parsed.Protocol, Address: parsed.Address, Status: "active",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(idsToDelete) > 0 {
|
||||
if err := m.bulkDeleteByIDs(idsToDelete); err != nil {
|
||||
m.task.EndTaskByID(taskID, resourceID, nil, fmt.Errorf("failed during proxy deletion: %w", err))
|
||||
return
|
||||
}
|
||||
}
|
||||
if len(proxiesToAdd) > 0 {
|
||||
if err := m.bulkAdd(proxiesToAdd); err != nil {
|
||||
m.task.EndTaskByID(taskID, resourceID, nil, fmt.Errorf("failed during proxy addition: %w", err))
|
||||
return
|
||||
}
|
||||
}
|
||||
result := gin.H{"added": len(proxiesToAdd), "deleted": len(idsToDelete), "final_total": len(finalProxyMap)}
|
||||
m.task.EndTaskByID(taskID, resourceID, result, nil)
|
||||
m.publishChangeEvent("proxies_synced")
|
||||
go m.invalidate()
|
||||
}
|
||||
|
||||
type parsedProxy struct{ Protocol, Address string }
|
||||
|
||||
func parseProxyString(proxyStr string) *parsedProxy {
|
||||
proxyStr = strings.TrimSpace(proxyStr)
|
||||
u, err := url.Parse(proxyStr)
|
||||
if err != nil || !strings.Contains(proxyStr, "://") {
|
||||
if strings.Contains(proxyStr, "@") {
|
||||
parts := strings.Split(proxyStr, "@")
|
||||
if len(parts) == 2 {
|
||||
proxyStr = "socks5://" + proxyStr
|
||||
u, err = url.Parse(proxyStr)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
protocol := strings.ToLower(u.Scheme)
|
||||
if protocol != "http" && protocol != "https" && protocol != "socks5" {
|
||||
return nil
|
||||
}
|
||||
address := u.Host
|
||||
if u.User != nil {
|
||||
address = u.User.String() + "@" + u.Host
|
||||
}
|
||||
return &parsedProxy{Protocol: protocol, Address: address}
|
||||
}
|
||||
|
||||
func (m *manager) bulkDeleteByIDs(ids []uint) error {
|
||||
for i := 0; i < len(ids); i += proxyChunkSize {
|
||||
end := i + proxyChunkSize
|
||||
if end > len(ids) {
|
||||
end = len(ids)
|
||||
}
|
||||
chunk := ids[i:end]
|
||||
if err := m.db.Where("id IN ?", chunk).Delete(&models.ProxyConfig{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (m *manager) bulkAdd(proxies []models.ProxyConfig) error {
|
||||
return m.db.CreateInBatches(proxies, proxyChunkSize).Error
|
||||
}
|
||||
|
||||
func (m *manager) publishChangeEvent(reason string) {
|
||||
event := models.ProxyStatusChangedEvent{Action: reason}
|
||||
eventData, _ := json.Marshal(event)
|
||||
_ = m.store.Publish(models.TopicProxyStatusChanged, eventData)
|
||||
}
|
||||
|
||||
func (m *manager) assignProxyIfNeeded(apiKey *models.APIKey) (*models.ProxyConfig, error) {
|
||||
cacheData := m.syncer.Get()
|
||||
if cacheData.ActiveProxies == nil {
|
||||
return nil, ErrNoActiveProxies
|
||||
}
|
||||
if apiKey.ProxyID != nil {
|
||||
if proxy, ok := cacheData.ProxiesByID[*apiKey.ProxyID]; ok {
|
||||
return proxy, nil
|
||||
}
|
||||
}
|
||||
if len(cacheData.ActiveProxies) == 0 {
|
||||
return nil, ErrNoActiveProxies
|
||||
}
|
||||
bestProxy := cacheData.ActiveProxies[0]
|
||||
txErr := m.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(apiKey).Update("proxy_id", bestProxy.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(bestProxy).Update("assigned_keys_count", gorm.Expr("assigned_keys_count + 1")).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if txErr != nil {
|
||||
return nil, txErr
|
||||
}
|
||||
go m.invalidate()
|
||||
return bestProxy, nil
|
||||
}
|
||||
|
||||
func (m *manager) invalidate() error {
|
||||
m.logger.Info("Proxy cache invalidation triggered.")
|
||||
return m.syncer.Invalidate()
|
||||
}
|
||||
|
||||
func (m *manager) stop() {
|
||||
m.syncer.Stop()
|
||||
}
|
||||
|
||||
func (m *manager) CheckSingleProxy(proxyURL string, timeout time.Duration) *ProxyCheckResult {
|
||||
parsed := parseProxyString(proxyURL)
|
||||
if parsed == nil {
|
||||
return &ProxyCheckResult{Proxy: proxyURL, IsAvailable: false, ErrorMessage: "Invalid URL format"}
|
||||
}
|
||||
|
||||
proxyCfg := &models.ProxyConfig{Protocol: parsed.Protocol, Address: parsed.Address}
|
||||
|
||||
startTime := time.Now()
|
||||
isAlive := m.checkProxyConnectivity(proxyCfg, timeout)
|
||||
latency := time.Since(startTime).Seconds()
|
||||
result := &ProxyCheckResult{
|
||||
Proxy: proxyURL,
|
||||
IsAvailable: isAlive,
|
||||
ResponseTime: latency,
|
||||
}
|
||||
if !isAlive {
|
||||
result.ErrorMessage = "Connection failed or timed out"
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (m *manager) CheckMultipleProxies(proxies []string, timeout time.Duration, concurrency int) []*ProxyCheckResult {
|
||||
var wg sync.WaitGroup
|
||||
jobs := make(chan string, len(proxies))
|
||||
resultsChan := make(chan *ProxyCheckResult, len(proxies))
|
||||
for i := 0; i < concurrency; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for proxyURL := range jobs {
|
||||
resultsChan <- m.CheckSingleProxy(proxyURL, timeout)
|
||||
}
|
||||
}()
|
||||
}
|
||||
for _, p := range proxies {
|
||||
jobs <- p
|
||||
}
|
||||
close(jobs)
|
||||
wg.Wait()
|
||||
close(resultsChan)
|
||||
finalResults := make([]*ProxyCheckResult, 0, len(proxies))
|
||||
for res := range resultsChan {
|
||||
finalResults = append(finalResults, res)
|
||||
}
|
||||
return finalResults
|
||||
}
|
||||
|
||||
func (m *manager) checkProxyConnectivity(proxyCfg *models.ProxyConfig, timeout time.Duration) bool {
|
||||
const ProxyCheckTargetURL = "https://www.google.com/generate_204"
|
||||
transport := &http.Transport{}
|
||||
switch proxyCfg.Protocol {
|
||||
case "http", "https":
|
||||
proxyUrl, err := url.Parse(proxyCfg.Protocol + "://" + proxyCfg.Address)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
transport.Proxy = http.ProxyURL(proxyUrl)
|
||||
case "socks5":
|
||||
dialer, err := proxy.SOCKS5("tcp", proxyCfg.Address, nil, proxy.Direct)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return dialer.Dial(network, addr)
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
client := &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: timeout,
|
||||
}
|
||||
resp, err := client.Get(ProxyCheckTargetURL)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
return true
|
||||
}
|
||||
45
internal/domain/proxy/module.go
Normal file
45
internal/domain/proxy/module.go
Normal file
@@ -0,0 +1,45 @@
|
||||
// Filename: internal/domain/proxy/module.go
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"gemini-balancer/internal/models"
|
||||
"gemini-balancer/internal/settings"
|
||||
"gemini-balancer/internal/store"
|
||||
"gemini-balancer/internal/syncer"
|
||||
"gemini-balancer/internal/task"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sirupsen/logrus"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Module struct {
|
||||
manager *manager
|
||||
handler *handler
|
||||
}
|
||||
|
||||
func NewModule(gormDB *gorm.DB, store store.Store, settingsManager *settings.SettingsManager, taskReporter task.Reporter, logger *logrus.Logger) (*Module, error) {
|
||||
loader := newManagerLoader(gormDB)
|
||||
cacheSyncer, err := syncer.NewCacheSyncer(loader, store, "proxies:cache_invalidation")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
manager := newManager(gormDB, cacheSyncer, taskReporter, store, logger.WithField("domain", "proxy"))
|
||||
handler := newHandler(gormDB, manager, store, settingsManager)
|
||||
return &Module{
|
||||
manager: manager,
|
||||
handler: handler,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *Module) AssignProxyIfNeeded(apiKey *models.APIKey) (*models.ProxyConfig, error) {
|
||||
return m.manager.assignProxyIfNeeded(apiKey)
|
||||
}
|
||||
|
||||
func (m *Module) RegisterRoutes(router *gin.RouterGroup) {
|
||||
m.handler.registerRoutes(router)
|
||||
}
|
||||
|
||||
func (m *Module) Stop() {
|
||||
m.manager.stop()
|
||||
}
|
||||
167
internal/domain/upstream/handler.go
Normal file
167
internal/domain/upstream/handler.go
Normal file
@@ -0,0 +1,167 @@
|
||||
// Filename: internal/domain/upstream/handler.go
|
||||
package upstream
|
||||
|
||||
import (
|
||||
"gemini-balancer/internal/errors"
|
||||
"gemini-balancer/internal/models"
|
||||
"gemini-balancer/internal/response"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
service *Service
|
||||
}
|
||||
|
||||
func NewHandler(service *Service) *Handler {
|
||||
return &Handler{service: service}
|
||||
}
|
||||
|
||||
// ------ DTOs and Validation ------
|
||||
type CreateUpstreamRequest struct {
|
||||
URL string `json:"url" binding:"required"`
|
||||
Weight int `json:"weight" binding:"omitempty,gte=1,lte=1000"`
|
||||
Status string `json:"status" binding:"omitempty,oneof=active inactive"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
type UpdateUpstreamRequest struct {
|
||||
URL *string `json:"url"`
|
||||
Weight *int `json:"weight" binding:"omitempty,gte=1,lte=1000"`
|
||||
Status *string `json:"status" binding:"omitempty,oneof=active inactive"`
|
||||
Description *string `json:"description"`
|
||||
}
|
||||
|
||||
func isValidURL(rawURL string) bool {
|
||||
u, err := url.ParseRequestURI(rawURL)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return u.Scheme == "http" || u.Scheme == "https"
|
||||
}
|
||||
|
||||
// --- Handler ---
|
||||
|
||||
func (h *Handler) CreateUpstream(c *gin.Context) {
|
||||
var req CreateUpstreamRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrInvalidJSON, err.Error()))
|
||||
return
|
||||
}
|
||||
if !isValidURL(req.URL) {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrValidation, "Invalid URL format"))
|
||||
return
|
||||
}
|
||||
|
||||
upstream := models.UpstreamEndpoint{
|
||||
URL: req.URL,
|
||||
Weight: req.Weight,
|
||||
Status: req.Status,
|
||||
Description: req.Description,
|
||||
}
|
||||
|
||||
if err := h.service.Create(&upstream); err != nil {
|
||||
response.Error(c, errors.ParseDBError(err))
|
||||
return
|
||||
}
|
||||
response.Created(c, upstream)
|
||||
}
|
||||
|
||||
func (h *Handler) ListUpstreams(c *gin.Context) {
|
||||
upstreams, err := h.service.List()
|
||||
if err != nil {
|
||||
response.Error(c, errors.ParseDBError(err))
|
||||
return
|
||||
}
|
||||
response.Success(c, upstreams)
|
||||
}
|
||||
|
||||
func (h *Handler) GetUpstream(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrBadRequest, "Invalid ID format"))
|
||||
return
|
||||
}
|
||||
|
||||
upstream, err := h.service.GetByID(id)
|
||||
if err != nil {
|
||||
response.Error(c, errors.ParseDBError(err))
|
||||
return
|
||||
}
|
||||
response.Success(c, upstream)
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateUpstream(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrBadRequest, "Invalid ID format"))
|
||||
return
|
||||
}
|
||||
var req UpdateUpstreamRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrInvalidJSON, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
upstream, err := h.service.GetByID(id)
|
||||
if err != nil {
|
||||
response.Error(c, errors.ParseDBError(err))
|
||||
return
|
||||
}
|
||||
|
||||
if req.URL != nil {
|
||||
if !isValidURL(*req.URL) {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrValidation, "Invalid URL format"))
|
||||
return
|
||||
}
|
||||
upstream.URL = *req.URL
|
||||
}
|
||||
if req.Weight != nil {
|
||||
upstream.Weight = *req.Weight
|
||||
}
|
||||
if req.Status != nil {
|
||||
upstream.Status = *req.Status
|
||||
}
|
||||
if req.Description != nil {
|
||||
upstream.Description = *req.Description
|
||||
}
|
||||
|
||||
if err := h.service.Update(upstream); err != nil {
|
||||
response.Error(c, errors.ParseDBError(err))
|
||||
return
|
||||
}
|
||||
response.Success(c, upstream)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteUpstream(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrBadRequest, "Invalid ID format"))
|
||||
return
|
||||
}
|
||||
|
||||
rowsAffected, err := h.service.Delete(id)
|
||||
if err != nil {
|
||||
response.Error(c, errors.ParseDBError(err))
|
||||
return
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
response.Error(c, errors.ErrResourceNotFound)
|
||||
return
|
||||
}
|
||||
response.NoContent(c)
|
||||
}
|
||||
|
||||
// RegisterRoutes
|
||||
|
||||
func (h *Handler) RegisterRoutes(rg *gin.RouterGroup) {
|
||||
upstreamRoutes := rg.Group("/upstreams")
|
||||
{
|
||||
upstreamRoutes.POST("/", h.CreateUpstream)
|
||||
upstreamRoutes.GET("/", h.ListUpstreams)
|
||||
upstreamRoutes.GET("/:id", h.GetUpstream)
|
||||
upstreamRoutes.PUT("/:id", h.UpdateUpstream)
|
||||
upstreamRoutes.DELETE("/:id", h.DeleteUpstream)
|
||||
}
|
||||
}
|
||||
36
internal/domain/upstream/module.go
Normal file
36
internal/domain/upstream/module.go
Normal file
@@ -0,0 +1,36 @@
|
||||
// Filename: internal/domain/upstream/module.go
|
||||
package upstream
|
||||
|
||||
import (
|
||||
"gemini-balancer/internal/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Module struct {
|
||||
service *Service
|
||||
handler *Handler
|
||||
}
|
||||
|
||||
func NewModule(db *gorm.DB) *Module {
|
||||
service := NewService(db)
|
||||
handler := NewHandler(service)
|
||||
|
||||
return &Module{
|
||||
service: service,
|
||||
handler: handler,
|
||||
}
|
||||
}
|
||||
|
||||
// === 领域暴露的公共API ===
|
||||
|
||||
// SelectActiveWeighted
|
||||
|
||||
func (m *Module) SelectActiveWeighted(upstreams []*models.UpstreamEndpoint) (*models.UpstreamEndpoint, error) {
|
||||
return m.service.SelectActiveWeighted(upstreams)
|
||||
}
|
||||
|
||||
func (m *Module) RegisterRoutes(router *gin.RouterGroup) {
|
||||
m.handler.RegisterRoutes(router)
|
||||
}
|
||||
84
internal/domain/upstream/service.go
Normal file
84
internal/domain/upstream/service.go
Normal file
@@ -0,0 +1,84 @@
|
||||
// Filename: internal/domain/upstream/service.go
|
||||
package upstream
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"gemini-balancer/internal/models"
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewService(db *gorm.DB) *Service {
|
||||
rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
return &Service{db: db}
|
||||
}
|
||||
|
||||
func (s *Service) SelectActiveWeighted(upstreams []*models.UpstreamEndpoint) (*models.UpstreamEndpoint, error) {
|
||||
activeUpstreams := make([]*models.UpstreamEndpoint, 0)
|
||||
totalWeight := 0
|
||||
for _, u := range upstreams {
|
||||
if u.Status == "active" {
|
||||
activeUpstreams = append(activeUpstreams, u)
|
||||
totalWeight += u.Weight
|
||||
}
|
||||
}
|
||||
if len(activeUpstreams) == 0 {
|
||||
return nil, errors.New("no active upstream endpoints available")
|
||||
}
|
||||
if totalWeight <= 0 || len(activeUpstreams) == 1 {
|
||||
return activeUpstreams[0], nil
|
||||
}
|
||||
randomWeight := rand.Intn(totalWeight)
|
||||
for _, u := range activeUpstreams {
|
||||
randomWeight -= u.Weight
|
||||
if randomWeight < 0 {
|
||||
return u, nil
|
||||
}
|
||||
}
|
||||
return activeUpstreams[len(activeUpstreams)-1], nil
|
||||
}
|
||||
|
||||
// CRUD,供Handler调用
|
||||
|
||||
func (s *Service) Create(upstream *models.UpstreamEndpoint) error {
|
||||
if upstream.Weight == 0 {
|
||||
upstream.Weight = 100 // 默认权重
|
||||
}
|
||||
if upstream.Status == "" {
|
||||
upstream.Status = "active" // 默认状态
|
||||
}
|
||||
return s.db.Create(upstream).Error
|
||||
}
|
||||
|
||||
// List Service层只做数据库查询
|
||||
func (s *Service) List() ([]models.UpstreamEndpoint, error) {
|
||||
var upstreams []models.UpstreamEndpoint
|
||||
err := s.db.Find(&upstreams).Error
|
||||
return upstreams, err
|
||||
}
|
||||
|
||||
// GetByID Service层只做数据库查询
|
||||
func (s *Service) GetByID(id int) (*models.UpstreamEndpoint, error) {
|
||||
var upstream models.UpstreamEndpoint
|
||||
if err := s.db.First(&upstream, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &upstream, nil
|
||||
}
|
||||
|
||||
// Update Service层只做数据库更新
|
||||
func (s *Service) Update(upstream *models.UpstreamEndpoint) error {
|
||||
return s.db.Save(upstream).Error
|
||||
}
|
||||
|
||||
// Delete Service层只做数据库删除
|
||||
func (s *Service) Delete(id int) (int64, error) {
|
||||
result := s.db.Delete(&models.UpstreamEndpoint{}, id)
|
||||
return result.RowsAffected, result.Error
|
||||
}
|
||||
101
internal/errors/api_error.go
Normal file
101
internal/errors/api_error.go
Normal file
@@ -0,0 +1,101 @@
|
||||
// Filename: internal/error/api_error.go
|
||||
package errors
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-sql-driver/mysql"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// APIError defines a standard error structure for API responses.
|
||||
type APIError struct {
|
||||
HTTPStatus int
|
||||
Code string
|
||||
Message string
|
||||
}
|
||||
|
||||
// Error implements the error interface.
|
||||
func (e *APIError) Error() string {
|
||||
return e.Message
|
||||
}
|
||||
|
||||
// Predefined API errors
|
||||
var (
|
||||
ErrBadRequest = &APIError{HTTPStatus: http.StatusBadRequest, Code: "BAD_REQUEST", Message: "Invalid request parameters"}
|
||||
ErrInvalidJSON = &APIError{HTTPStatus: http.StatusBadRequest, Code: "INVALID_JSON", Message: "Invalid JSON format"}
|
||||
ErrValidation = &APIError{HTTPStatus: http.StatusBadRequest, Code: "VALIDATION_FAILED", Message: "Input validation failed"}
|
||||
ErrDuplicateResource = &APIError{HTTPStatus: http.StatusConflict, Code: "DUPLICATE_RESOURCE", Message: "Resource already exists"}
|
||||
ErrResourceNotFound = &APIError{HTTPStatus: http.StatusNotFound, Code: "NOT_FOUND", Message: "Resource not found"}
|
||||
ErrInternalServer = &APIError{HTTPStatus: http.StatusInternalServerError, Code: "INTERNAL_SERVER_ERROR", Message: "An unexpected error occurred"}
|
||||
ErrDatabase = &APIError{HTTPStatus: http.StatusInternalServerError, Code: "DATABASE_ERROR", Message: "Database operation failed"}
|
||||
ErrUnauthorized = &APIError{HTTPStatus: http.StatusUnauthorized, Code: "UNAUTHORIZED", Message: "Authentication failed"}
|
||||
ErrForbidden = &APIError{HTTPStatus: http.StatusForbidden, Code: "FORBIDDEN", Message: "You do not have permission to access this resource"}
|
||||
ErrTaskInProgress = &APIError{HTTPStatus: http.StatusConflict, Code: "TASK_IN_PROGRESS", Message: "A task is already in progress"}
|
||||
ErrBadGateway = &APIError{HTTPStatus: http.StatusBadGateway, Code: "BAD_GATEWAY", Message: "Upstream service error"}
|
||||
ErrNoActiveKeys = &APIError{HTTPStatus: http.StatusServiceUnavailable, Code: "NO_ACTIVE_KEYS", Message: "No active API keys available for this group"}
|
||||
ErrMaxRetriesExceeded = &APIError{HTTPStatus: http.StatusBadGateway, Code: "MAX_RETRIES_EXCEEDED", Message: "Request failed after maximum retries"}
|
||||
ErrNoKeysAvailable = &APIError{HTTPStatus: http.StatusServiceUnavailable, Code: "NO_KEYS_AVAILABLE", Message: "No API keys available to process the request"}
|
||||
|
||||
ErrStateConflict = &APIError{HTTPStatus: http.StatusConflict, Code: "STATE_CONFLICT", Message: "The operation cannot be completed due to the current state of the resource."}
|
||||
ErrGroupNotFound = &APIError{HTTPStatus: http.StatusNotFound, Code: "GROUP_NOT_FOUND", Message: "The specified group was not found."}
|
||||
ErrPermissionDenied = &APIError{HTTPStatus: http.StatusForbidden, Code: "PERMISSION_DENIED", Message: "Permission denied for this operation."}
|
||||
ErrConfigurationError = &APIError{HTTPStatus: http.StatusInternalServerError, Code: "CONFIGURATION_ERROR", Message: "A configuration error prevents this request from being processed."}
|
||||
|
||||
ErrStateConflictMasterRevoked = &APIError{HTTPStatus: http.StatusConflict, Code: "STATE_CONFLICT_MASTER_REVOKED", Message: "Cannot perform this operation on a revoked key."}
|
||||
ErrNotFound = &APIError{HTTPStatus: http.StatusNotFound, Code: "NOT_FOUND", Message: "Resource not found"}
|
||||
ErrNoKeysMatchFilter = &APIError{HTTPStatus: http.StatusBadRequest, Code: "NO_KEYS_MATCH_FILTER", Message: "No keys were found that match the provided filter criteria."}
|
||||
)
|
||||
|
||||
// NewAPIError creates a new APIError with a custom message.
|
||||
func NewAPIError(base *APIError, message string) *APIError {
|
||||
return &APIError{
|
||||
HTTPStatus: base.HTTPStatus,
|
||||
Code: base.Code,
|
||||
Message: message,
|
||||
}
|
||||
}
|
||||
|
||||
// NewAPIErrorWithUpstream creates a new APIError specifically for wrapping raw upstream errors.
|
||||
func NewAPIErrorWithUpstream(statusCode int, code string, upstreamMessage string) *APIError {
|
||||
return &APIError{
|
||||
HTTPStatus: statusCode,
|
||||
Code: code,
|
||||
Message: upstreamMessage,
|
||||
}
|
||||
}
|
||||
|
||||
// ParseDBError intelligently converts a GORM error into a standard APIError.
|
||||
func ParseDBError(err error) *APIError {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrResourceNotFound
|
||||
}
|
||||
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) {
|
||||
if pgErr.Code == "23505" { // unique_violation
|
||||
return ErrDuplicateResource
|
||||
}
|
||||
}
|
||||
|
||||
var mysqlErr *mysql.MySQLError
|
||||
if errors.As(err, &mysqlErr) {
|
||||
if mysqlErr.Number == 1062 { // Duplicate entry
|
||||
return ErrDuplicateResource
|
||||
}
|
||||
}
|
||||
|
||||
// Generic check for SQLite
|
||||
if strings.Contains(strings.ToLower(err.Error()), "unique constraint failed") {
|
||||
return ErrDuplicateResource
|
||||
}
|
||||
|
||||
return ErrDatabase
|
||||
}
|
||||
19
internal/errors/errors.go
Normal file
19
internal/errors/errors.go
Normal file
@@ -0,0 +1,19 @@
|
||||
// Filename: internal/errors/errors.go
|
||||
|
||||
package errors
|
||||
|
||||
import (
|
||||
std_errors "errors" // 为标准库errors包指定别名
|
||||
)
|
||||
|
||||
func Is(err, target error) bool {
|
||||
return std_errors.Is(err, target)
|
||||
}
|
||||
|
||||
func As(err error, target any) bool {
|
||||
return std_errors.As(err, target)
|
||||
}
|
||||
|
||||
func Unwrap(err error) error {
|
||||
return std_errors.Unwrap(err)
|
||||
}
|
||||
111
internal/errors/upstream_errors.go
Normal file
111
internal/errors/upstream_errors.go
Normal file
@@ -0,0 +1,111 @@
|
||||
// Filename: internal/errors/upstream_errors.go
|
||||
package errors
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// TODO: [Future Evolution] This file establishes the new, granular error classification framework.
|
||||
// The next step is to refactor the handleKeyUsageEvent method in APIKeyService to utilize these new
|
||||
// classifiers and implement the corresponding actions:
|
||||
//
|
||||
// 1. On IsPermanentUpstreamError:
|
||||
// - Set mapping status to models.StatusBanned.
|
||||
// - Set the master APIKey's status to models.MasterStatusRevoked.
|
||||
// - This is a "one-strike, you're out" policy for definitively invalid keys.
|
||||
//
|
||||
// 2. On IsTemporaryUpstreamError:
|
||||
// - Increment mapping.ConsecutiveErrorCount.
|
||||
// - Check against the blacklist threshold to potentially set status to models.StatusDisabled.
|
||||
// - This is for recoverable errors that are the key's fault (e.g., quota limits).
|
||||
//
|
||||
// 3. On ALL other upstream errors (that are not Permanent or Temporary):
|
||||
// - These are treated as "Truly Ignorable" from the key's perspective (e.g., 503 Service Unavailable).
|
||||
// - Do NOT increment the error count. Only update LastUsedAt.
|
||||
// - This prevents good keys from being punished for upstream service instability.
|
||||
|
||||
// --- 1. Permanent Errors ---
|
||||
// Errors that indicate the API Key itself is permanently invalid.
|
||||
// Action: Ban mapping, Revoke Master Key.
|
||||
var permanentErrorSubstrings = []string{
|
||||
"invalid api key",
|
||||
"api key not valid",
|
||||
"api key suspended",
|
||||
"API Key not found",
|
||||
"api key expired",
|
||||
"permission denied", // Often indicates the key lacks permissions for the target model/service.
|
||||
"permission_denied", // Catches the 'status' field in Google's JSON error, e.g., "status": "PERMISSION_DENIED".
|
||||
"service_disabled", // Catches the 'reason' field for disabled APIs, e.g., "reason": "SERVICE_DISABLED".
|
||||
"api has not been used",
|
||||
}
|
||||
|
||||
// --- 2. Temporary Errors ---
|
||||
// Errors that are attributable to the key's state but are recoverable over time.
|
||||
// Action: Increment consecutive error count, potentially disable the key.
|
||||
var temporaryErrorSubstrings = []string{
|
||||
"quota",
|
||||
"limit reached",
|
||||
"insufficient",
|
||||
"billing",
|
||||
"exceeded",
|
||||
"too many requests",
|
||||
}
|
||||
|
||||
// --- 3. Unretryable Request Errors ---
|
||||
// Errors indicating a problem with the user's request, not the key. Retrying with a new key is pointless.
|
||||
// Action: Abort the retry loop immediately in ProxyHandler.
|
||||
var unretryableRequestErrorSubstrings = []string{
|
||||
"invalid content",
|
||||
"invalid argument",
|
||||
"malformed",
|
||||
"unsupported",
|
||||
"invalid model",
|
||||
}
|
||||
|
||||
// --- 4. Ignorable Client/Network Errors ---
|
||||
// Network-level errors, typically caused by the client disconnecting.
|
||||
// Action: Ignore for logging and metrics purposes.
|
||||
var clientNetworkErrorSubstrings = []string{
|
||||
"context canceled",
|
||||
"connection reset by peer",
|
||||
"broken pipe",
|
||||
"use of closed network connection",
|
||||
"request canceled",
|
||||
}
|
||||
|
||||
// IsPermanentUpstreamError checks if an upstream error indicates the key is permanently invalid.
|
||||
func IsPermanentUpstreamError(msg string) bool {
|
||||
return containsSubstring(msg, permanentErrorSubstrings)
|
||||
}
|
||||
|
||||
// IsTemporaryUpstreamError checks if an upstream error is due to temporary, key-specific limits.
|
||||
func IsTemporaryUpstreamError(msg string) bool {
|
||||
return containsSubstring(msg, temporaryErrorSubstrings)
|
||||
}
|
||||
|
||||
// IsUnretryableRequestError checks if an upstream error is due to a malformed user request.
|
||||
func IsUnretryableRequestError(msg string) bool {
|
||||
return containsSubstring(msg, unretryableRequestErrorSubstrings)
|
||||
}
|
||||
|
||||
// IsClientNetworkError checks if an error is a common, ignorable client-side network issue.
|
||||
func IsClientNetworkError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
return containsSubstring(err.Error(), clientNetworkErrorSubstrings)
|
||||
}
|
||||
|
||||
// containsSubstring is a helper function to avoid code repetition.
|
||||
func containsSubstring(s string, substrings []string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
lowerS := strings.ToLower(s)
|
||||
for _, sub := range substrings {
|
||||
if strings.Contains(lowerS, sub) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
79
internal/errors/upstream_parser.go
Normal file
79
internal/errors/upstream_parser.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package errors
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// maxErrorBodyLength defines the maximum length of an error message to be stored or returned.
|
||||
maxErrorBodyLength = 2048
|
||||
)
|
||||
|
||||
// standardErrorResponse matches formats like: {"error": {"message": "..."}}
|
||||
type standardErrorResponse struct {
|
||||
Error struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
// vendorErrorResponse matches formats like: {"error_msg": "..."}
|
||||
type vendorErrorResponse struct {
|
||||
ErrorMsg string `json:"error_msg"`
|
||||
}
|
||||
|
||||
// simpleErrorResponse matches formats like: {"error": "..."}
|
||||
type simpleErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// rootMessageErrorResponse matches formats like: {"message": "..."}
|
||||
type rootMessageErrorResponse struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// ParseUpstreamError attempts to parse a structured error message from an upstream response body
|
||||
func ParseUpstreamError(body []byte) string {
|
||||
// 1. Attempt to parse the standard OpenAI/Gemini format.
|
||||
var stdErr standardErrorResponse
|
||||
if err := json.Unmarshal(body, &stdErr); err == nil {
|
||||
if msg := strings.TrimSpace(stdErr.Error.Message); msg != "" {
|
||||
return truncateString(msg, maxErrorBodyLength)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Attempt to parse vendor-specific format (e.g., Baidu).
|
||||
var vendorErr vendorErrorResponse
|
||||
if err := json.Unmarshal(body, &vendorErr); err == nil {
|
||||
if msg := strings.TrimSpace(vendorErr.ErrorMsg); msg != "" {
|
||||
return truncateString(msg, maxErrorBodyLength)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Attempt to parse simple error format.
|
||||
var simpleErr simpleErrorResponse
|
||||
if err := json.Unmarshal(body, &simpleErr); err == nil {
|
||||
if msg := strings.TrimSpace(simpleErr.Error); msg != "" {
|
||||
return truncateString(msg, maxErrorBodyLength)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Attempt to parse root-level message format.
|
||||
var rootMsgErr rootMessageErrorResponse
|
||||
if err := json.Unmarshal(body, &rootMsgErr); err == nil {
|
||||
if msg := strings.TrimSpace(rootMsgErr.Message); msg != "" {
|
||||
return truncateString(msg, maxErrorBodyLength)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Graceful Degradation: If all parsing fails, return the raw (but safe) body.
|
||||
return truncateString(string(body), maxErrorBodyLength)
|
||||
}
|
||||
|
||||
// truncateString ensures a string does not exceed a maximum length.
|
||||
func truncateString(s string, maxLength int) string {
|
||||
if len(s) > maxLength {
|
||||
return s[:maxLength]
|
||||
}
|
||||
return s
|
||||
}
|
||||
50
internal/handlers/api_auth_handler.go
Normal file
50
internal/handlers/api_auth_handler.go
Normal file
@@ -0,0 +1,50 @@
|
||||
// Filename: internal/handlers/api_auth_handler.go
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"gemini-balancer/internal/middleware"
|
||||
"gemini-balancer/internal/service"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type APIAuthHandler struct {
|
||||
securityService *service.SecurityService
|
||||
}
|
||||
|
||||
func NewAPIAuthHandler(securityService *service.SecurityService) *APIAuthHandler {
|
||||
return &APIAuthHandler{securityService: securityService}
|
||||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
Token string `json:"token" binding:"required"`
|
||||
}
|
||||
|
||||
type LoginResponse struct {
|
||||
Token string `json:"token"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func (h *APIAuthHandler) HandleLogin(c *gin.Context) {
|
||||
var req LoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求格式错误: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
authToken, err := h.securityService.AuthenticateToken(req.Token)
|
||||
// 同时检查token是否有效,以及是否是管理员
|
||||
if err != nil || !authToken.IsAdmin {
|
||||
h.securityService.RecordFailedLoginAttempt(c.Request.Context(), c.ClientIP())
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "无效或非管理员Token"})
|
||||
return
|
||||
}
|
||||
|
||||
middleware.SetAdminSessionCookie(c, authToken.Token)
|
||||
|
||||
c.JSON(http.StatusOK, LoginResponse{
|
||||
Token: authToken.Token,
|
||||
Message: "登录成功,欢迎管理员!",
|
||||
})
|
||||
}
|
||||
408
internal/handlers/apikey_handler.go
Normal file
408
internal/handlers/apikey_handler.go
Normal file
@@ -0,0 +1,408 @@
|
||||
// Filename: internal/handlers/apikey_handler.go
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"gemini-balancer/internal/errors"
|
||||
"gemini-balancer/internal/models"
|
||||
"gemini-balancer/internal/response"
|
||||
"gemini-balancer/internal/service"
|
||||
"gemini-balancer/internal/task"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type APIKeyHandler struct {
|
||||
apiKeyService *service.APIKeyService
|
||||
db *gorm.DB
|
||||
keyImportService *service.KeyImportService
|
||||
keyValidationService *service.KeyValidationService
|
||||
}
|
||||
|
||||
func NewAPIKeyHandler(apiKeyService *service.APIKeyService, db *gorm.DB, keyImportService *service.KeyImportService, keyValidationService *service.KeyValidationService) *APIKeyHandler {
|
||||
return &APIKeyHandler{
|
||||
apiKeyService: apiKeyService,
|
||||
db: db,
|
||||
keyImportService: keyImportService,
|
||||
keyValidationService: keyValidationService,
|
||||
}
|
||||
}
|
||||
|
||||
// DTOs for API requests
|
||||
type BulkAddKeysToGroupRequest struct {
|
||||
KeyGroupID uint `json:"key_group_id" binding:"required"`
|
||||
Keys string `json:"keys" binding:"required"`
|
||||
ValidateOnImport bool `json:"validate_on_import"` // OmitEmpty/default is false
|
||||
}
|
||||
|
||||
type BulkUnlinkKeysFromGroupRequest struct {
|
||||
KeyGroupID uint `json:"key_group_id" binding:"required"`
|
||||
Keys string `json:"keys" binding:"required"`
|
||||
}
|
||||
|
||||
type BulkHardDeleteKeysRequest struct {
|
||||
Keys string `json:"keys" binding:"required"`
|
||||
}
|
||||
|
||||
type BulkRestoreKeysRequest struct {
|
||||
Keys string `json:"keys" binding:"required"`
|
||||
}
|
||||
|
||||
type UpdateAPIKeyRequest struct {
|
||||
Status *string `json:"status" binding:"omitempty,oneof=ACTIVE,PENDING_VALIDATION,COOLDOWN,DISABLED,BANNED"`
|
||||
}
|
||||
|
||||
type UpdateMappingRequest struct {
|
||||
Status models.APIKeyStatus `json:"status" binding:"required,oneof=ACTIVE PENDING_VALIDATION COOLDOWN DISABLED BANNED"`
|
||||
}
|
||||
|
||||
type BulkTestKeysRequest struct {
|
||||
KeyGroupID uint `json:"key_group_id" binding:"required"`
|
||||
Keys string `json:"keys" binding:"required"`
|
||||
}
|
||||
|
||||
type RestoreKeysRequest struct {
|
||||
KeyIDs []uint `json:"key_ids" binding:"required,gt=0"`
|
||||
}
|
||||
type BulkTestKeysForGroupRequest struct {
|
||||
Keys string `json:"keys" binding:"required"`
|
||||
}
|
||||
|
||||
type BulkActionFilter struct {
|
||||
Status []string `json:"status"` // Changed to slice to accept multiple statuses
|
||||
}
|
||||
type BulkActionRequest struct {
|
||||
Action string `json:"action" binding:"required,oneof=revalidate set_status delete"`
|
||||
NewStatus string `json:"new_status" binding:"omitempty,oneof=active disabled cooldown banned"` // For 'set_status' action
|
||||
Filter BulkActionFilter `json:"filter" binding:"required"`
|
||||
}
|
||||
|
||||
// --- Handler Methods ---
|
||||
|
||||
// AddMultipleKeysToGroup handles adding/linking multiple keys to a specific group.
|
||||
func (h *APIKeyHandler) AddMultipleKeysToGroup(c *gin.Context) {
|
||||
var req BulkAddKeysToGroupRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrInvalidJSON, err.Error()))
|
||||
return
|
||||
}
|
||||
taskStatus, err := h.keyImportService.StartAddKeysTask(req.KeyGroupID, req.Keys, req.ValidateOnImport)
|
||||
if err != nil {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrTaskInProgress, err.Error()))
|
||||
return
|
||||
}
|
||||
response.Success(c, taskStatus)
|
||||
}
|
||||
|
||||
// UnlinkMultipleKeysFromGroup handles unlinking multiple keys from a specific group.
|
||||
func (h *APIKeyHandler) UnlinkMultipleKeysFromGroup(c *gin.Context) {
|
||||
var req BulkUnlinkKeysFromGroupRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrInvalidJSON, err.Error()))
|
||||
return
|
||||
}
|
||||
taskStatus, err := h.keyImportService.StartUnlinkKeysTask(req.KeyGroupID, req.Keys)
|
||||
if err != nil {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrTaskInProgress, err.Error()))
|
||||
return
|
||||
}
|
||||
response.Success(c, taskStatus)
|
||||
}
|
||||
|
||||
// HardDeleteMultipleKeys handles globally deleting multiple key entities.
|
||||
func (h *APIKeyHandler) HardDeleteMultipleKeys(c *gin.Context) {
|
||||
var req BulkHardDeleteKeysRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrInvalidJSON, err.Error()))
|
||||
return
|
||||
}
|
||||
taskStatus, err := h.keyImportService.StartHardDeleteKeysTask(req.Keys)
|
||||
if err != nil {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrTaskInProgress, err.Error()))
|
||||
return
|
||||
}
|
||||
response.Success(c, taskStatus)
|
||||
}
|
||||
|
||||
// RestoreMultipleKeys handles restoring multiple keys to ACTIVE status globally.
|
||||
func (h *APIKeyHandler) RestoreMultipleKeys(c *gin.Context) {
|
||||
var req BulkRestoreKeysRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrInvalidJSON, err.Error()))
|
||||
return
|
||||
}
|
||||
taskStatus, err := h.keyImportService.StartRestoreKeysTask(req.Keys)
|
||||
if err != nil {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrTaskInProgress, err.Error()))
|
||||
return
|
||||
}
|
||||
response.Success(c, taskStatus)
|
||||
}
|
||||
|
||||
func (h *APIKeyHandler) TestMultipleKeys(c *gin.Context) {
|
||||
var req BulkTestKeysRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrInvalidJSON, err.Error()))
|
||||
return
|
||||
}
|
||||
taskStatus, err := h.keyValidationService.StartTestKeysTask(req.KeyGroupID, req.Keys)
|
||||
if err != nil {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrTaskInProgress, err.Error()))
|
||||
return
|
||||
}
|
||||
response.Success(c, taskStatus)
|
||||
}
|
||||
|
||||
func (h *APIKeyHandler) ListAPIKeys(c *gin.Context) {
|
||||
var params models.APIKeyQueryParams
|
||||
if err := c.ShouldBindQuery(¶ms); err != nil {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrBadRequest, err.Error()))
|
||||
return
|
||||
}
|
||||
if params.Page <= 0 {
|
||||
params.Page = 1
|
||||
}
|
||||
if params.PageSize <= 0 {
|
||||
params.PageSize = 20
|
||||
}
|
||||
result, err := h.apiKeyService.ListAPIKeys(¶ms)
|
||||
if err != nil {
|
||||
response.Error(c, errors.ParseDBError(err))
|
||||
return
|
||||
}
|
||||
response.Success(c, result)
|
||||
}
|
||||
|
||||
// ListKeysForGroup handles the GET /keygroups/:id/keys request.
|
||||
func (h *APIKeyHandler) ListKeysForGroup(c *gin.Context) {
|
||||
// 1. Manually handle the path parameter.
|
||||
groupID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrBadRequest, "Invalid group ID format"))
|
||||
return
|
||||
}
|
||||
// 2. Bind query parameters using the correctly tagged struct.
|
||||
var params models.APIKeyQueryParams
|
||||
if err := c.ShouldBindQuery(¶ms); err != nil {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrBadRequest, err.Error()))
|
||||
return
|
||||
}
|
||||
// 3. Set server-side defaults and the path parameter.
|
||||
if params.Page <= 0 {
|
||||
params.Page = 1
|
||||
}
|
||||
if params.PageSize <= 0 {
|
||||
params.PageSize = 20
|
||||
}
|
||||
params.KeyGroupID = uint(groupID)
|
||||
// 4. Call the service layer.
|
||||
paginatedResult, err := h.apiKeyService.ListAPIKeys(¶ms)
|
||||
if err != nil {
|
||||
response.Error(c, errors.ParseDBError(err))
|
||||
return
|
||||
}
|
||||
|
||||
// 5. [THE FIX] Return a successful response using the standard `response.Success`
|
||||
// and a gin.H map, as confirmed to exist in your project.
|
||||
response.Success(c, gin.H{
|
||||
"items": paginatedResult.Items,
|
||||
"total": paginatedResult.Total,
|
||||
"page": paginatedResult.Page,
|
||||
"pages": paginatedResult.TotalPages,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *APIKeyHandler) TestKeysForGroup(c *gin.Context) {
|
||||
// Group ID is now correctly sourced from the URL path.
|
||||
groupID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrBadRequest, "Invalid group ID format"))
|
||||
return
|
||||
}
|
||||
// The request body is now simpler, only needing the keys.
|
||||
var req BulkTestKeysForGroupRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrInvalidJSON, err.Error()))
|
||||
return
|
||||
}
|
||||
// Call the same underlying service, but with unambiguous context.
|
||||
taskStatus, err := h.keyValidationService.StartTestKeysTask(uint(groupID), req.Keys)
|
||||
if err != nil {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrTaskInProgress, err.Error()))
|
||||
return
|
||||
}
|
||||
response.Success(c, taskStatus)
|
||||
}
|
||||
|
||||
// UpdateAPIKey is DEPRECATED. Status is now contextual to a group.
|
||||
func (h *APIKeyHandler) UpdateAPIKey(c *gin.Context) {
|
||||
err := errors.NewAPIError(errors.ErrBadRequest, "This endpoint is deprecated. Use 'PUT /keygroups/:id/apikeys/:keyId' to update key status within a group context.")
|
||||
response.Error(c, err)
|
||||
}
|
||||
|
||||
// UpdateGroupAPIKeyMapping handles updating a key's status within a specific group.
|
||||
// Route: PUT /keygroups/:id/apikeys/:keyId
|
||||
func (h *APIKeyHandler) UpdateGroupAPIKeyMapping(c *gin.Context) {
|
||||
groupID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrBadRequest, "Invalid Group ID format"))
|
||||
return
|
||||
}
|
||||
keyID, err := strconv.ParseUint(c.Param("keyId"), 10, 32)
|
||||
if err != nil {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrBadRequest, "Invalid Key ID format"))
|
||||
return
|
||||
}
|
||||
var req UpdateMappingRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrInvalidJSON, err.Error()))
|
||||
return
|
||||
}
|
||||
// Directly use the service to handle the logic
|
||||
updatedMapping, err := h.apiKeyService.UpdateMappingStatus(uint(groupID), uint(keyID), req.Status)
|
||||
if err != nil {
|
||||
var apiErr *errors.APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
response.Error(c, apiErr)
|
||||
} else {
|
||||
response.Error(c, errors.ParseDBError(err))
|
||||
}
|
||||
return
|
||||
}
|
||||
response.Success(c, updatedMapping)
|
||||
}
|
||||
|
||||
// HardDeleteAPIKey handles globally deleting a single key entity.
|
||||
func (h *APIKeyHandler) HardDeleteAPIKey(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrBadRequest, "Invalid ID format"))
|
||||
return
|
||||
}
|
||||
if err := h.apiKeyService.HardDeleteAPIKeyByID(uint(id)); err != nil {
|
||||
response.Error(c, errors.ParseDBError(err))
|
||||
return
|
||||
}
|
||||
response.Success(c, gin.H{"message": "API key globally deleted successfully"})
|
||||
}
|
||||
|
||||
// RestoreKeysInGroup 恢复指定Key的接口
|
||||
// POST /keygroups/:id/apikeys/restore
|
||||
func (h *APIKeyHandler) RestoreKeysInGroup(c *gin.Context) {
|
||||
groupID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrBadRequest, "Invalid Group ID format"))
|
||||
return
|
||||
}
|
||||
var req RestoreKeysRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrInvalidJSON, err.Error()))
|
||||
return
|
||||
}
|
||||
taskStatus, err := h.apiKeyService.StartRestoreKeysTask(uint(groupID), req.KeyIDs)
|
||||
if err != nil {
|
||||
var apiErr *errors.APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
response.Error(c, apiErr)
|
||||
} else {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrInternalServer, err.Error()))
|
||||
}
|
||||
return
|
||||
}
|
||||
response.Success(c, taskStatus)
|
||||
}
|
||||
|
||||
// RestoreAllBannedInGroup 一键恢复所有Banned Key的接口
|
||||
// POST /keygroups/:id/apikeys/restore-all-banned
|
||||
func (h *APIKeyHandler) RestoreAllBannedInGroup(c *gin.Context) {
|
||||
groupID, err := strconv.ParseUint(c.Param("groupId"), 10, 32)
|
||||
if err != nil {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrBadRequest, "Invalid Group ID format"))
|
||||
return
|
||||
}
|
||||
taskStatus, err := h.apiKeyService.StartRestoreAllBannedTask(uint(groupID))
|
||||
if err != nil {
|
||||
var apiErr *errors.APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
response.Error(c, apiErr)
|
||||
} else {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrInternalServer, err.Error()))
|
||||
}
|
||||
return
|
||||
}
|
||||
response.Success(c, taskStatus)
|
||||
}
|
||||
|
||||
// HandleBulkAction handles generic bulk actions on a key group based on server-side filters.
|
||||
// Route: POST /keygroups/:id/bulk-actions
|
||||
func (h *APIKeyHandler) HandleBulkAction(c *gin.Context) {
|
||||
// 1. Parse GroupID from URL
|
||||
groupID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrBadRequest, "Invalid Group ID format"))
|
||||
return
|
||||
}
|
||||
// 2. Bind the JSON payload to our new DTO
|
||||
var req BulkActionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrInvalidJSON, err.Error()))
|
||||
return
|
||||
}
|
||||
// 3. Central logic: based on the action, call the appropriate service method.
|
||||
var task *task.Status
|
||||
var apiErr *errors.APIError
|
||||
switch req.Action {
|
||||
case "revalidate":
|
||||
// Assume keyValidationService has a method that accepts a filter
|
||||
task, err = h.keyValidationService.StartTestKeysByFilterTask(uint(groupID), req.Filter.Status)
|
||||
case "set_status":
|
||||
if req.NewStatus == "" {
|
||||
apiErr = errors.NewAPIError(errors.ErrBadRequest, "new_status is required for set_status action")
|
||||
break
|
||||
}
|
||||
// Assume apiKeyService has a method to update status by filter
|
||||
targetStatus := models.APIKeyStatus(req.NewStatus) // Convert string to your model's type
|
||||
task, err = h.apiKeyService.StartUpdateStatusByFilterTask(uint(groupID), req.Filter.Status, targetStatus)
|
||||
case "delete":
|
||||
// Assume keyImportService has a method to unlink by filter
|
||||
task, err = h.keyImportService.StartUnlinkKeysByFilterTask(uint(groupID), req.Filter.Status)
|
||||
default:
|
||||
apiErr = errors.NewAPIError(errors.ErrBadRequest, "Unsupported action: "+req.Action)
|
||||
}
|
||||
// 4. Handle errors from the switch block
|
||||
if apiErr != nil {
|
||||
response.Error(c, apiErr)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
// Attempt to parse it as a known APIError, otherwise, wrap it.
|
||||
var parsedErr *errors.APIError
|
||||
if errors.As(err, &parsedErr) {
|
||||
response.Error(c, parsedErr)
|
||||
} else {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrInternalServer, err.Error()))
|
||||
}
|
||||
return
|
||||
}
|
||||
// 5. Return the task status on success
|
||||
response.Success(c, task)
|
||||
}
|
||||
|
||||
// ExportKeysForGroup handles requests to export all keys for a group based on status filters.
|
||||
// Route: GET /keygroups/:id/apikeys/export
|
||||
func (h *APIKeyHandler) ExportKeysForGroup(c *gin.Context) {
|
||||
groupID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrBadRequest, "Invalid Group ID format"))
|
||||
return
|
||||
}
|
||||
// Use QueryArray to correctly parse `status[]=active&status[]=cooldown`
|
||||
statuses := c.QueryArray("status")
|
||||
keyStrings, err := h.apiKeyService.GetAPIKeyStringsForExport(uint(groupID), statuses)
|
||||
if err != nil {
|
||||
response.Error(c, errors.ParseDBError(err))
|
||||
return
|
||||
}
|
||||
response.Success(c, keyStrings)
|
||||
}
|
||||
62
internal/handlers/dashboard_handler.go
Normal file
62
internal/handlers/dashboard_handler.go
Normal file
@@ -0,0 +1,62 @@
|
||||
// Filename: internal/handlers/dashboard_handler.go
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"gemini-balancer/internal/errors"
|
||||
"gemini-balancer/internal/service"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// DashboardHandler 负责处理全局仪表盘相关的API请求
|
||||
type DashboardHandler struct {
|
||||
queryService *service.DashboardQueryService
|
||||
}
|
||||
|
||||
func NewDashboardHandler(qs *service.DashboardQueryService) *DashboardHandler {
|
||||
return &DashboardHandler{queryService: qs}
|
||||
}
|
||||
|
||||
// GetOverview 获取仪表盘的全局统计卡片数据
|
||||
func (h *DashboardHandler) GetOverview(c *gin.Context) {
|
||||
stats, err := h.queryService.GetDashboardOverviewData()
|
||||
if err != nil {
|
||||
apiErr := errors.NewAPIError(errors.ErrInternalServer, err.Error())
|
||||
c.JSON(apiErr.HTTPStatus, apiErr)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, stats)
|
||||
}
|
||||
|
||||
// GetChart 获取仪表盘的图表数据
|
||||
func (h *DashboardHandler) GetChart(c *gin.Context) {
|
||||
var groupID *uint
|
||||
if groupIDStr := c.Query("groupId"); groupIDStr != "" {
|
||||
if id, err := strconv.Atoi(groupIDStr); err == nil {
|
||||
uid := uint(id)
|
||||
groupID = &uid
|
||||
}
|
||||
}
|
||||
|
||||
chartData, err := h.queryService.QueryHistoricalChart(groupID)
|
||||
if err != nil {
|
||||
apiErr := errors.NewAPIError(errors.ErrDatabase, err.Error())
|
||||
c.JSON(apiErr.HTTPStatus, apiErr)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, chartData)
|
||||
}
|
||||
|
||||
// GetRequestStats 处理对“期间调用概览”的请求
|
||||
func (h *DashboardHandler) GetRequestStats(c *gin.Context) {
|
||||
period := c.Param("period") // 从 URL 路径中获取 period
|
||||
stats, err := h.queryService.GetRequestStatsForPeriod(period)
|
||||
if err != nil {
|
||||
apiErr := errors.NewAPIError(errors.ErrBadRequest, err.Error())
|
||||
c.JSON(apiErr.HTTPStatus, apiErr)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, stats)
|
||||
}
|
||||
369
internal/handlers/keygroup_handler.go
Normal file
369
internal/handlers/keygroup_handler.go
Normal file
@@ -0,0 +1,369 @@
|
||||
// Filename: internal/handlers/keygroup_handler.go
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gemini-balancer/internal/errors"
|
||||
"gemini-balancer/internal/models"
|
||||
"gemini-balancer/internal/response"
|
||||
"gemini-balancer/internal/service"
|
||||
"gemini-balancer/internal/store"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
)
|
||||
|
||||
type KeyGroupHandler struct {
|
||||
groupManager *service.GroupManager
|
||||
store store.Store
|
||||
queryService *service.DashboardQueryService
|
||||
}
|
||||
|
||||
func NewKeyGroupHandler(gm *service.GroupManager, s store.Store, qs *service.DashboardQueryService) *KeyGroupHandler {
|
||||
return &KeyGroupHandler{
|
||||
groupManager: gm,
|
||||
queryService: qs,
|
||||
store: s,
|
||||
}
|
||||
}
|
||||
|
||||
// DTOs & 辅助函数
|
||||
func isValidGroupName(name string) bool {
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
match, _ := regexp.MatchString("^[a-z0-9_-]{3,30}$", name)
|
||||
return match
|
||||
}
|
||||
|
||||
// KeyGroupOperationalSettings defines the shared operational settings for a key group.
|
||||
type KeyGroupOperationalSettings struct {
|
||||
EnableKeyCheck *bool `json:"enable_key_check"`
|
||||
KeyCheckIntervalMinutes *int `json:"key_check_interval_minutes"`
|
||||
KeyBlacklistThreshold *int `json:"key_blacklist_threshold"`
|
||||
KeyCooldownMinutes *int `json:"key_cooldown_minutes"`
|
||||
KeyCheckConcurrency *int `json:"key_check_concurrency"`
|
||||
KeyCheckEndpoint *string `json:"key_check_endpoint"`
|
||||
KeyCheckModel *string `json:"key_check_model"`
|
||||
MaxRetries *int `json:"max_retries"`
|
||||
EnableSmartGateway *bool `json:"enable_smart_gateway"`
|
||||
}
|
||||
|
||||
type CreateKeyGroupRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description"`
|
||||
PollingStrategy string `json:"polling_strategy" binding:"omitempty,oneof=sequential random weighted"`
|
||||
EnableProxy bool `json:"enable_proxy"`
|
||||
ChannelType string `json:"channel_type"`
|
||||
|
||||
// Embed shared operational settings
|
||||
KeyGroupOperationalSettings
|
||||
}
|
||||
|
||||
type UpdateKeyGroupRequest struct {
|
||||
Name *string `json:"name"`
|
||||
DisplayName *string `json:"display_name"`
|
||||
Description *string `json:"description"`
|
||||
PollingStrategy *string `json:"polling_strategy" binding:"omitempty,oneof=sequential random weighted"`
|
||||
EnableProxy *bool `json:"enable_proxy"`
|
||||
ChannelType *string `json:"channel_type"`
|
||||
|
||||
// Embed shared operational settings
|
||||
KeyGroupOperationalSettings
|
||||
|
||||
// M:N associations
|
||||
AllowedUpstreams []string `json:"allowed_upstreams"`
|
||||
AllowedModels []string `json:"allowed_models"`
|
||||
}
|
||||
|
||||
type KeyGroupResponse struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description"`
|
||||
PollingStrategy models.PollingStrategy `json:"polling_strategy"`
|
||||
ChannelType string `json:"channel_type"`
|
||||
EnableProxy bool `json:"enable_proxy"`
|
||||
APIKeysCount int64 `json:"api_keys_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Order int `json:"order"`
|
||||
AllowedModels []string `json:"allowed_models"`
|
||||
AllowedUpstreams []string `json:"allowed_upstreams"`
|
||||
}
|
||||
|
||||
// [NEW] Define the detailed response structure for a single group.
|
||||
type KeyGroupDetailsResponse struct {
|
||||
KeyGroupResponse
|
||||
Settings *models.GroupSettings `json:"settings,omitempty"`
|
||||
RequestConfig *models.RequestConfig `json:"request_config,omitempty"`
|
||||
}
|
||||
|
||||
// transformModelsToStrings converts a slice of GroupModelMapping pointers to a slice of model names.
|
||||
func transformModelsToStrings(mappings []*models.GroupModelMapping) []string {
|
||||
modelNames := make([]string, 0, len(mappings))
|
||||
for _, mapping := range mappings {
|
||||
if mapping != nil { // Safety check
|
||||
modelNames = append(modelNames, mapping.ModelName)
|
||||
}
|
||||
}
|
||||
return modelNames
|
||||
}
|
||||
|
||||
// transformUpstreamsToStrings converts a slice of UpstreamEndpoint pointers to a slice of URLs.
|
||||
func transformUpstreamsToStrings(upstreams []*models.UpstreamEndpoint) []string {
|
||||
urls := make([]string, 0, len(upstreams))
|
||||
for _, upstream := range upstreams {
|
||||
if upstream != nil { // Safety check
|
||||
urls = append(urls, upstream.URL)
|
||||
}
|
||||
}
|
||||
return urls
|
||||
}
|
||||
|
||||
func (h *KeyGroupHandler) newKeyGroupResponse(group *models.KeyGroup, keyCount int64) KeyGroupResponse {
|
||||
return KeyGroupResponse{
|
||||
ID: group.ID,
|
||||
Name: group.Name,
|
||||
DisplayName: group.DisplayName,
|
||||
Description: group.Description,
|
||||
PollingStrategy: group.PollingStrategy,
|
||||
ChannelType: group.ChannelType,
|
||||
EnableProxy: group.EnableProxy,
|
||||
APIKeysCount: keyCount,
|
||||
CreatedAt: group.CreatedAt,
|
||||
UpdatedAt: group.UpdatedAt,
|
||||
Order: group.Order,
|
||||
AllowedModels: transformModelsToStrings(group.AllowedModels), // Call the new helper
|
||||
AllowedUpstreams: transformUpstreamsToStrings(group.AllowedUpstreams), // Call the new helper
|
||||
}
|
||||
}
|
||||
|
||||
// packGroupSettings is a helper to convert request-level operational settings
|
||||
// into the model-level settings struct.
|
||||
func packGroupSettings(settings KeyGroupOperationalSettings) *models.KeyGroupSettings {
|
||||
return &models.KeyGroupSettings{
|
||||
EnableKeyCheck: settings.EnableKeyCheck,
|
||||
KeyCheckIntervalMinutes: settings.KeyCheckIntervalMinutes,
|
||||
KeyBlacklistThreshold: settings.KeyBlacklistThreshold,
|
||||
KeyCooldownMinutes: settings.KeyCooldownMinutes,
|
||||
KeyCheckConcurrency: settings.KeyCheckConcurrency,
|
||||
KeyCheckEndpoint: settings.KeyCheckEndpoint,
|
||||
KeyCheckModel: settings.KeyCheckModel,
|
||||
MaxRetries: settings.MaxRetries,
|
||||
EnableSmartGateway: settings.EnableSmartGateway,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *KeyGroupHandler) getGroupFromContext(c *gin.Context) (*models.KeyGroup, *errors.APIError) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
return nil, errors.NewAPIError(errors.ErrBadRequest, "Invalid ID format")
|
||||
}
|
||||
group, ok := h.groupManager.GetGroupByID(uint(id))
|
||||
if !ok {
|
||||
return nil, errors.NewAPIError(errors.ErrResourceNotFound, "Group not found")
|
||||
}
|
||||
return group, nil
|
||||
}
|
||||
|
||||
func applyUpdateRequestToGroup(req *UpdateKeyGroupRequest, group *models.KeyGroup) {
|
||||
if req.Name != nil {
|
||||
group.Name = *req.Name
|
||||
}
|
||||
p := bluemonday.StripTagsPolicy()
|
||||
if req.DisplayName != nil {
|
||||
group.DisplayName = p.Sanitize(*req.DisplayName)
|
||||
}
|
||||
if req.Description != nil {
|
||||
group.Description = p.Sanitize(*req.Description)
|
||||
}
|
||||
if req.PollingStrategy != nil {
|
||||
group.PollingStrategy = models.PollingStrategy(*req.PollingStrategy)
|
||||
}
|
||||
if req.EnableProxy != nil {
|
||||
group.EnableProxy = *req.EnableProxy
|
||||
}
|
||||
if req.ChannelType != nil {
|
||||
group.ChannelType = *req.ChannelType
|
||||
}
|
||||
}
|
||||
|
||||
// publishGroupChangeEvent encapsulates the logic for marshaling and publishing a group change event.
|
||||
func (h *KeyGroupHandler) publishGroupChangeEvent(groupID uint, reason string) {
|
||||
go func() {
|
||||
event := models.KeyStatusChangedEvent{GroupID: groupID, ChangeReason: reason}
|
||||
eventData, _ := json.Marshal(event)
|
||||
h.store.Publish(models.TopicKeyStatusChanged, eventData)
|
||||
}()
|
||||
}
|
||||
|
||||
// --- Handler 方法 ---
|
||||
|
||||
func (h *KeyGroupHandler) CreateKeyGroup(c *gin.Context) {
|
||||
var req CreateKeyGroupRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrInvalidJSON, err.Error()))
|
||||
return
|
||||
}
|
||||
if !isValidGroupName(req.Name) {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrValidation, "Invalid group name. Must be 3-30 characters, lowercase letters, numbers, hyphens, or underscores."))
|
||||
return
|
||||
}
|
||||
|
||||
// The core logic remains, as it's specific to creation.
|
||||
p := bluemonday.StripTagsPolicy()
|
||||
sanitizedDisplayName := p.Sanitize(req.DisplayName)
|
||||
sanitizedDescription := p.Sanitize(req.Description)
|
||||
keyGroup := &models.KeyGroup{
|
||||
Name: req.Name,
|
||||
DisplayName: sanitizedDisplayName,
|
||||
Description: sanitizedDescription,
|
||||
PollingStrategy: models.PollingStrategy(req.PollingStrategy),
|
||||
EnableProxy: req.EnableProxy,
|
||||
ChannelType: req.ChannelType,
|
||||
}
|
||||
if keyGroup.PollingStrategy == "" {
|
||||
keyGroup.PollingStrategy = models.StrategySequential
|
||||
}
|
||||
if keyGroup.ChannelType == "" {
|
||||
keyGroup.ChannelType = "gemini"
|
||||
}
|
||||
|
||||
groupSettings := packGroupSettings(req.KeyGroupOperationalSettings)
|
||||
if err := h.groupManager.CreateKeyGroup(keyGroup, groupSettings); err != nil {
|
||||
response.Error(c, errors.ParseDBError(err))
|
||||
return
|
||||
}
|
||||
h.publishGroupChangeEvent(keyGroup.ID, "group_created")
|
||||
response.Created(c, h.newKeyGroupResponse(keyGroup, 0))
|
||||
}
|
||||
|
||||
// 统一的处理器可以处理两种情况:
|
||||
// 1. GET /keygroups - 返回所有组的列表
|
||||
// 2. GET /keygroups/:id - 返回指定ID的单个组
|
||||
func (h *KeyGroupHandler) GetKeyGroups(c *gin.Context) {
|
||||
// Case 1: Get a single group
|
||||
if idStr := c.Param("id"); idStr != "" {
|
||||
group, apiErr := h.getGroupFromContext(c)
|
||||
if apiErr != nil {
|
||||
response.Error(c, apiErr)
|
||||
return
|
||||
}
|
||||
keyCount := h.groupManager.GetKeyCount(group.ID)
|
||||
baseResponse := h.newKeyGroupResponse(group, keyCount)
|
||||
detailedResponse := KeyGroupDetailsResponse{
|
||||
KeyGroupResponse: baseResponse,
|
||||
Settings: group.Settings,
|
||||
RequestConfig: group.RequestConfig,
|
||||
}
|
||||
response.Success(c, detailedResponse)
|
||||
return
|
||||
}
|
||||
// Case 2: Get all groups
|
||||
allGroups := h.groupManager.GetAllGroups()
|
||||
responses := make([]KeyGroupResponse, 0, len(allGroups))
|
||||
for _, group := range allGroups {
|
||||
keyCount := h.groupManager.GetKeyCount(group.ID)
|
||||
responses = append(responses, h.newKeyGroupResponse(group, keyCount))
|
||||
}
|
||||
response.Success(c, responses)
|
||||
}
|
||||
|
||||
// UpdateKeyGroup
|
||||
func (h *KeyGroupHandler) UpdateKeyGroup(c *gin.Context) {
|
||||
group, apiErr := h.getGroupFromContext(c)
|
||||
if apiErr != nil {
|
||||
response.Error(c, apiErr)
|
||||
return
|
||||
}
|
||||
var req UpdateKeyGroupRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrInvalidJSON, err.Error()))
|
||||
return
|
||||
}
|
||||
if req.Name != nil && !isValidGroupName(*req.Name) {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrValidation, "Invalid group name format."))
|
||||
return
|
||||
}
|
||||
applyUpdateRequestToGroup(&req, group)
|
||||
groupSettings := packGroupSettings(req.KeyGroupOperationalSettings)
|
||||
err := h.groupManager.UpdateKeyGroup(group, groupSettings, req.AllowedUpstreams, req.AllowedModels)
|
||||
if err != nil {
|
||||
response.Error(c, errors.ParseDBError(err))
|
||||
return
|
||||
}
|
||||
h.publishGroupChangeEvent(group.ID, "group_updated")
|
||||
freshGroup, _ := h.groupManager.GetGroupByID(group.ID)
|
||||
keyCount := h.groupManager.GetKeyCount(freshGroup.ID)
|
||||
response.Success(c, h.newKeyGroupResponse(freshGroup, keyCount))
|
||||
}
|
||||
|
||||
// DeleteKeyGroup
|
||||
func (h *KeyGroupHandler) DeleteKeyGroup(c *gin.Context) {
|
||||
group, apiErr := h.getGroupFromContext(c)
|
||||
if apiErr != nil {
|
||||
response.Error(c, apiErr)
|
||||
return
|
||||
}
|
||||
groupName := group.Name
|
||||
if err := h.groupManager.DeleteKeyGroup(group.ID); err != nil {
|
||||
response.Error(c, errors.ParseDBError(err))
|
||||
return
|
||||
}
|
||||
h.publishGroupChangeEvent(group.ID, "group_deleted")
|
||||
response.Success(c, gin.H{"message": fmt.Sprintf("Group '%s' and its associated keys deleted successfully", groupName)})
|
||||
}
|
||||
|
||||
// GetKeyGroupStats
|
||||
func (h *KeyGroupHandler) GetKeyGroupStats(c *gin.Context) {
|
||||
group, apiErr := h.getGroupFromContext(c)
|
||||
if apiErr != nil {
|
||||
response.Error(c, apiErr)
|
||||
return
|
||||
}
|
||||
stats, err := h.queryService.GetGroupStats(group.ID)
|
||||
if err != nil {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrDatabase, err.Error()))
|
||||
return
|
||||
}
|
||||
response.Success(c, stats)
|
||||
}
|
||||
|
||||
func (h *KeyGroupHandler) CloneKeyGroup(c *gin.Context) {
|
||||
group, apiErr := h.getGroupFromContext(c)
|
||||
if apiErr != nil {
|
||||
response.Error(c, apiErr)
|
||||
return
|
||||
}
|
||||
clonedGroup, err := h.groupManager.CloneKeyGroup(group.ID)
|
||||
if err != nil {
|
||||
response.Error(c, errors.ParseDBError(err))
|
||||
return
|
||||
}
|
||||
keyCount := int64(len(clonedGroup.Mappings))
|
||||
response.Created(c, h.newKeyGroupResponse(clonedGroup, keyCount))
|
||||
}
|
||||
|
||||
// 更新分组排序
|
||||
func (h *KeyGroupHandler) UpdateKeyGroupOrder(c *gin.Context) {
|
||||
var payload []service.UpdateOrderPayload
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrInvalidJSON, err.Error()))
|
||||
return
|
||||
}
|
||||
if len(payload) == 0 {
|
||||
response.Success(c, gin.H{"message": "No order data to update."})
|
||||
return
|
||||
}
|
||||
if err := h.groupManager.UpdateOrder(payload); err != nil {
|
||||
response.Error(c, errors.ParseDBError(err))
|
||||
return
|
||||
}
|
||||
response.Success(c, gin.H{"message": "Group order updated successfully."})
|
||||
}
|
||||
33
internal/handlers/log_handler.go
Normal file
33
internal/handlers/log_handler.go
Normal file
@@ -0,0 +1,33 @@
|
||||
// Filename: internal/handlers/log_handler.go
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"gemini-balancer/internal/errors"
|
||||
"gemini-balancer/internal/models"
|
||||
"gemini-balancer/internal/response"
|
||||
"gemini-balancer/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// LogHandler 负责处理与日志相关的HTTP请求
|
||||
type LogHandler struct {
|
||||
logService *service.LogService
|
||||
}
|
||||
|
||||
func NewLogHandler(logService *service.LogService) *LogHandler {
|
||||
return &LogHandler{logService: logService}
|
||||
}
|
||||
|
||||
func (h *LogHandler) GetLogs(c *gin.Context) {
|
||||
// 直接将Gin的上下文传递给Service层,让Service自己去解析查询参数
|
||||
logs, err := h.logService.GetLogs(c)
|
||||
if err != nil {
|
||||
response.Error(c, errors.ErrDatabase)
|
||||
return
|
||||
}
|
||||
if logs == nil {
|
||||
logs = []models.RequestLog{}
|
||||
}
|
||||
response.Success(c, logs)
|
||||
}
|
||||
581
internal/handlers/proxy_handler.go
Normal file
581
internal/handlers/proxy_handler.go
Normal file
@@ -0,0 +1,581 @@
|
||||
// Filename: internal/handlers/proxy_handler.go
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gemini-balancer/internal/channel"
|
||||
"gemini-balancer/internal/errors"
|
||||
"gemini-balancer/internal/middleware"
|
||||
"gemini-balancer/internal/models"
|
||||
"gemini-balancer/internal/service"
|
||||
"gemini-balancer/internal/settings"
|
||||
"gemini-balancer/internal/store"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/sirupsen/logrus"
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
type proxyErrorKey int
|
||||
|
||||
const proxyErrKey proxyErrorKey = 0
|
||||
|
||||
type ProxyHandler struct {
|
||||
resourceService *service.ResourceService
|
||||
store store.Store
|
||||
settingsManager *settings.SettingsManager
|
||||
groupManager *service.GroupManager
|
||||
channel channel.ChannelProxy
|
||||
logger *logrus.Entry
|
||||
transparentProxy *httputil.ReverseProxy
|
||||
}
|
||||
|
||||
func NewProxyHandler(
|
||||
resourceService *service.ResourceService,
|
||||
store store.Store,
|
||||
sm *settings.SettingsManager,
|
||||
gm *service.GroupManager,
|
||||
channel channel.ChannelProxy,
|
||||
logger *logrus.Logger,
|
||||
) *ProxyHandler {
|
||||
ph := &ProxyHandler{
|
||||
resourceService: resourceService,
|
||||
store: store,
|
||||
settingsManager: sm,
|
||||
groupManager: gm,
|
||||
channel: channel,
|
||||
logger: logger.WithField("component", "ProxyHandler"),
|
||||
transparentProxy: &httputil.ReverseProxy{},
|
||||
}
|
||||
ph.transparentProxy.Transport = &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 30 * time.Second,
|
||||
KeepAlive: 60 * time.Second,
|
||||
}).DialContext,
|
||||
MaxIdleConns: 100,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
}
|
||||
ph.transparentProxy.ErrorHandler = ph.transparentProxyErrorHandler
|
||||
ph.transparentProxy.BufferPool = &bufferPool{}
|
||||
return ph
|
||||
}
|
||||
|
||||
func (h *ProxyHandler) HandleProxy(c *gin.Context) {
|
||||
if c.Request.Method == "GET" && (strings.HasSuffix(c.Request.URL.Path, "/models") || strings.HasSuffix(c.Request.URL.Path, "/models/")) {
|
||||
h.handleListModelsRequest(c)
|
||||
return
|
||||
}
|
||||
requestBody, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
errToJSON(c, uuid.New().String(), errors.NewAPIError(errors.ErrBadRequest, "Failed to read request body"))
|
||||
return
|
||||
}
|
||||
c.Request.Body = io.NopCloser(bytes.NewReader(requestBody))
|
||||
c.Request.ContentLength = int64(len(requestBody))
|
||||
modelName := h.channel.ExtractModel(c, requestBody)
|
||||
groupName := c.Param("group_name")
|
||||
isPreciseRouting := groupName != ""
|
||||
if !isPreciseRouting && modelName == "" {
|
||||
errToJSON(c, uuid.New().String(), errors.NewAPIError(errors.ErrBadRequest, "Model not specified in the request body or URL"))
|
||||
return
|
||||
}
|
||||
initialResources, err := h.getResourcesForRequest(c, modelName, groupName, isPreciseRouting)
|
||||
if err != nil {
|
||||
if apiErr, ok := err.(*errors.APIError); ok {
|
||||
errToJSON(c, uuid.New().String(), apiErr)
|
||||
} else {
|
||||
errToJSON(c, uuid.New().String(), errors.NewAPIError(errors.ErrNoKeysAvailable, err.Error()))
|
||||
}
|
||||
return
|
||||
}
|
||||
finalOpConfig, err := h.groupManager.BuildOperationalConfig(initialResources.KeyGroup)
|
||||
if err != nil {
|
||||
h.logger.WithError(err).Error("Failed to build operational config.")
|
||||
errToJSON(c, uuid.New().String(), errors.NewAPIError(errors.ErrInternalServer, "Failed to build operational configuration"))
|
||||
return
|
||||
}
|
||||
|
||||
isOpenAICompatible := h.channel.IsOpenAICompatibleRequest(c)
|
||||
if isOpenAICompatible {
|
||||
h.serveTransparentProxy(c, requestBody, initialResources, finalOpConfig, modelName, groupName, isPreciseRouting)
|
||||
return
|
||||
}
|
||||
isStream := h.channel.IsStreamRequest(c, requestBody)
|
||||
systemSettings := h.settingsManager.GetSettings()
|
||||
useSmartGateway := finalOpConfig.EnableSmartGateway != nil && *finalOpConfig.EnableSmartGateway
|
||||
if useSmartGateway && isStream && systemSettings.EnableStreamingRetry {
|
||||
h.serveSmartStream(c, requestBody, initialResources, isPreciseRouting)
|
||||
} else {
|
||||
h.serveTransparentProxy(c, requestBody, initialResources, finalOpConfig, modelName, groupName, isPreciseRouting)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ProxyHandler) serveTransparentProxy(c *gin.Context, requestBody []byte, initialResources *service.RequestResources, finalOpConfig *models.KeyGroupSettings, modelName, groupName string, isPreciseRouting bool) {
|
||||
startTime := time.Now()
|
||||
correlationID := uuid.New().String()
|
||||
var finalRecorder *httptest.ResponseRecorder
|
||||
var lastUsedResources *service.RequestResources
|
||||
var finalProxyErr *errors.APIError
|
||||
var isSuccess bool
|
||||
var finalPromptTokens, finalCompletionTokens int
|
||||
var actualRetries int = 0
|
||||
defer func() {
|
||||
if lastUsedResources == nil {
|
||||
return
|
||||
}
|
||||
finalEvent := h.createLogEvent(c, startTime, correlationID, modelName, lastUsedResources, models.LogTypeFinal, isPreciseRouting)
|
||||
finalEvent.LatencyMs = int(time.Since(startTime).Milliseconds())
|
||||
finalEvent.IsSuccess = isSuccess
|
||||
finalEvent.Retries = actualRetries
|
||||
if isSuccess {
|
||||
finalEvent.PromptTokens = finalPromptTokens
|
||||
finalEvent.CompletionTokens = finalCompletionTokens
|
||||
}
|
||||
if finalRecorder != nil {
|
||||
finalEvent.StatusCode = finalRecorder.Code
|
||||
}
|
||||
if !isSuccess {
|
||||
if finalProxyErr != nil {
|
||||
finalEvent.Error = finalProxyErr
|
||||
finalEvent.ErrorCode = finalProxyErr.Code
|
||||
finalEvent.ErrorMessage = finalProxyErr.Message
|
||||
} else if finalRecorder != nil {
|
||||
apiErr := errors.NewAPIErrorWithUpstream(finalRecorder.Code, "PROXY_ERROR", "Request failed after all retries.")
|
||||
finalEvent.Error = apiErr
|
||||
finalEvent.ErrorCode = apiErr.Code
|
||||
finalEvent.ErrorMessage = apiErr.Message
|
||||
}
|
||||
}
|
||||
eventData, _ := json.Marshal(finalEvent)
|
||||
_ = h.store.Publish(models.TopicRequestFinished, eventData)
|
||||
}()
|
||||
var maxRetries int
|
||||
if isPreciseRouting {
|
||||
// For precise routing, use the group's setting. If not set, fall back to the global setting.
|
||||
if finalOpConfig.MaxRetries != nil {
|
||||
maxRetries = *finalOpConfig.MaxRetries
|
||||
} else {
|
||||
maxRetries = h.settingsManager.GetSettings().MaxRetries
|
||||
}
|
||||
} else {
|
||||
// For BasePool (intelligent aggregation), *always* use the global setting.
|
||||
maxRetries = h.settingsManager.GetSettings().MaxRetries
|
||||
}
|
||||
totalAttempts := maxRetries + 1
|
||||
for attempt := 1; attempt <= totalAttempts; attempt++ {
|
||||
if c.Request.Context().Err() != nil {
|
||||
h.logger.WithField("id", correlationID).Info("Client disconnected, aborting retry loop.")
|
||||
if finalProxyErr == nil {
|
||||
finalProxyErr = errors.NewAPIError(errors.ErrBadRequest, "Client connection closed")
|
||||
}
|
||||
break
|
||||
}
|
||||
var currentResources *service.RequestResources
|
||||
var err error
|
||||
if attempt == 1 {
|
||||
currentResources = initialResources
|
||||
} else {
|
||||
actualRetries = attempt - 1
|
||||
h.logger.WithField("id", correlationID).Infof("Retrying... getting new resources for attempt %d.", attempt)
|
||||
currentResources, err = h.getResourcesForRequest(c, modelName, groupName, isPreciseRouting)
|
||||
if err != nil {
|
||||
h.logger.WithField("id", correlationID).Errorf("Failed to get new resources for retry, aborting: %v", err)
|
||||
finalProxyErr = errors.NewAPIError(errors.ErrNoKeysAvailable, "Failed to get new resources for retry")
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
finalRequestConfig := h.buildFinalRequestConfig(h.settingsManager.GetSettings(), currentResources.RequestConfig)
|
||||
currentResources.RequestConfig = finalRequestConfig
|
||||
lastUsedResources = currentResources
|
||||
h.logger.WithField("id", correlationID).Infof("Attempt %d/%d with KeyID %d...", attempt, totalAttempts, currentResources.APIKey.ID)
|
||||
var attemptErr *errors.APIError
|
||||
var attemptIsSuccess bool
|
||||
recorder := httptest.NewRecorder()
|
||||
attemptStartTime := time.Now()
|
||||
connectTimeout := time.Duration(h.settingsManager.GetSettings().ConnectTimeoutSeconds) * time.Second
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), connectTimeout)
|
||||
defer cancel()
|
||||
attemptReq := c.Request.Clone(ctx)
|
||||
attemptReq.Body = io.NopCloser(bytes.NewReader(requestBody))
|
||||
if currentResources.UpstreamEndpoint == nil || currentResources.UpstreamEndpoint.URL == "" {
|
||||
h.logger.WithField("id", correlationID).Errorf("Attempt %d failed: no upstream URL in resources.", attempt)
|
||||
isSuccess = false
|
||||
finalProxyErr = errors.NewAPIError(errors.ErrInternalServer, "No upstream URL configured for the selected resource")
|
||||
continue
|
||||
}
|
||||
h.transparentProxy.Director = func(req *http.Request) {
|
||||
targetURL, _ := url.Parse(currentResources.UpstreamEndpoint.URL)
|
||||
req.URL.Scheme = targetURL.Scheme
|
||||
req.URL.Host = targetURL.Host
|
||||
req.Host = targetURL.Host
|
||||
var pureClientPath string
|
||||
if isPreciseRouting {
|
||||
proxyPrefix := "/proxy/" + groupName
|
||||
pureClientPath = strings.TrimPrefix(req.URL.Path, proxyPrefix)
|
||||
} else {
|
||||
pureClientPath = req.URL.Path
|
||||
}
|
||||
finalPath := h.channel.RewritePath(targetURL.Path, pureClientPath)
|
||||
req.URL.Path = finalPath
|
||||
h.logger.WithFields(logrus.Fields{
|
||||
"correlation_id": correlationID,
|
||||
"attempt": attempt,
|
||||
"key_id": currentResources.APIKey.ID,
|
||||
"base_upstream_url": currentResources.UpstreamEndpoint.URL,
|
||||
"final_request_url": req.URL.String(),
|
||||
}).Infof("Director constructed final upstream request URL.")
|
||||
req.Header.Del("Authorization")
|
||||
h.channel.ModifyRequest(req, currentResources.APIKey)
|
||||
req.Header.Set("X-Correlation-ID", correlationID)
|
||||
*req = *req.WithContext(context.WithValue(req.Context(), proxyErrKey, &attemptErr))
|
||||
}
|
||||
transport := h.transparentProxy.Transport.(*http.Transport)
|
||||
if currentResources.ProxyConfig != nil {
|
||||
proxyURLStr := fmt.Sprintf("%s://%s", currentResources.ProxyConfig.Protocol, currentResources.ProxyConfig.Address)
|
||||
proxyURL, err := url.Parse(proxyURLStr)
|
||||
if err == nil {
|
||||
transport.Proxy = http.ProxyURL(proxyURL)
|
||||
}
|
||||
} else {
|
||||
transport.Proxy = http.ProxyFromEnvironment
|
||||
}
|
||||
h.transparentProxy.ModifyResponse = func(resp *http.Response) error {
|
||||
defer resp.Body.Close()
|
||||
var reader io.ReadCloser
|
||||
var err error
|
||||
isGzipped := resp.Header.Get("Content-Encoding") == "gzip"
|
||||
if isGzipped {
|
||||
reader, err = gzip.NewReader(resp.Body)
|
||||
if err != nil {
|
||||
h.logger.WithError(err).Error("Failed to create gzip reader")
|
||||
reader = resp.Body
|
||||
} else {
|
||||
resp.Header.Del("Content-Encoding")
|
||||
}
|
||||
defer reader.Close()
|
||||
} else {
|
||||
reader = resp.Body
|
||||
}
|
||||
bodyBytes, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
attemptErr = errors.NewAPIError(errors.ErrBadGateway, "Failed to read upstream response: "+err.Error())
|
||||
resp.Body = io.NopCloser(bytes.NewReader([]byte(attemptErr.Message)))
|
||||
return nil
|
||||
}
|
||||
if resp.StatusCode < 400 {
|
||||
attemptIsSuccess = true
|
||||
finalPromptTokens, finalCompletionTokens = extractUsage(bodyBytes)
|
||||
} else {
|
||||
parsedMsg := errors.ParseUpstreamError(bodyBytes)
|
||||
attemptErr = errors.NewAPIErrorWithUpstream(resp.StatusCode, fmt.Sprintf("UPSTREAM_%d", resp.StatusCode), parsedMsg)
|
||||
}
|
||||
resp.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||
return nil
|
||||
}
|
||||
h.transparentProxy.ServeHTTP(recorder, attemptReq)
|
||||
finalRecorder = recorder
|
||||
finalProxyErr = attemptErr
|
||||
isSuccess = attemptIsSuccess
|
||||
h.resourceService.ReportRequestResult(currentResources, isSuccess, finalProxyErr)
|
||||
if isSuccess {
|
||||
break
|
||||
}
|
||||
isUnretryableError := false
|
||||
if finalProxyErr != nil {
|
||||
if errors.IsUnretryableRequestError(finalProxyErr.Message) {
|
||||
isUnretryableError = true
|
||||
h.logger.WithField("id", correlationID).Warnf("Attempt %d failed with unretryable request error. Aborting retries. Message: %s", attempt, finalProxyErr.Message)
|
||||
}
|
||||
}
|
||||
if attempt >= totalAttempts || isUnretryableError {
|
||||
break
|
||||
}
|
||||
retryEvent := h.createLogEvent(c, startTime, correlationID, modelName, currentResources, models.LogTypeRetry, isPreciseRouting)
|
||||
retryEvent.LatencyMs = int(time.Since(attemptStartTime).Milliseconds())
|
||||
retryEvent.IsSuccess = false
|
||||
retryEvent.StatusCode = recorder.Code
|
||||
retryEvent.Retries = actualRetries
|
||||
if attemptErr != nil {
|
||||
retryEvent.Error = attemptErr
|
||||
retryEvent.ErrorCode = attemptErr.Code
|
||||
retryEvent.ErrorMessage = attemptErr.Message
|
||||
}
|
||||
eventData, _ := json.Marshal(retryEvent)
|
||||
_ = h.store.Publish(models.TopicRequestFinished, eventData)
|
||||
}
|
||||
if finalRecorder != nil {
|
||||
bodyBytes := finalRecorder.Body.Bytes()
|
||||
c.Writer.Header().Set("Content-Length", fmt.Sprintf("%d", len(bodyBytes)))
|
||||
for k, v := range finalRecorder.Header() {
|
||||
if strings.ToLower(k) != "content-length" {
|
||||
c.Writer.Header()[k] = v
|
||||
}
|
||||
}
|
||||
c.Writer.WriteHeader(finalRecorder.Code)
|
||||
c.Writer.Write(finalRecorder.Body.Bytes())
|
||||
} else {
|
||||
errToJSON(c, correlationID, finalProxyErr)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ProxyHandler) serveSmartStream(c *gin.Context, requestBody []byte, resources *service.RequestResources, isPreciseRouting bool) {
|
||||
startTime := time.Now()
|
||||
correlationID := uuid.New().String()
|
||||
log := h.logger.WithField("id", correlationID)
|
||||
log.Info("Smart Gateway activated for streaming request.")
|
||||
var originalRequest models.GeminiRequest
|
||||
if err := json.Unmarshal(requestBody, &originalRequest); err != nil {
|
||||
errToJSON(c, correlationID, errors.NewAPIError(errors.ErrInvalidJSON, "Smart Gateway failed: Request body is not a valid Gemini native format. Error: "+err.Error()))
|
||||
return
|
||||
}
|
||||
systemSettings := h.settingsManager.GetSettings()
|
||||
modelName := h.channel.ExtractModel(c, requestBody)
|
||||
requestFinishedEvent := h.createLogEvent(c, startTime, correlationID, modelName, resources, models.LogTypeFinal, isPreciseRouting)
|
||||
defer func() {
|
||||
requestFinishedEvent.LatencyMs = int(time.Since(startTime).Milliseconds())
|
||||
if c.Writer.Status() > 0 {
|
||||
requestFinishedEvent.StatusCode = c.Writer.Status()
|
||||
}
|
||||
eventData, _ := json.Marshal(requestFinishedEvent)
|
||||
_ = h.store.Publish(models.TopicRequestFinished, eventData)
|
||||
}()
|
||||
params := channel.SmartRequestParams{
|
||||
CorrelationID: correlationID,
|
||||
APIKey: resources.APIKey,
|
||||
UpstreamURL: resources.UpstreamEndpoint.URL,
|
||||
RequestBody: requestBody,
|
||||
OriginalRequest: originalRequest,
|
||||
EventLogger: requestFinishedEvent,
|
||||
MaxRetries: systemSettings.MaxStreamingRetries,
|
||||
RetryDelay: time.Duration(systemSettings.StreamingRetryDelayMs) * time.Millisecond,
|
||||
LogTruncationLimit: systemSettings.LogTruncationLimit,
|
||||
StreamingRetryPrompt: systemSettings.StreamingRetryPrompt,
|
||||
}
|
||||
h.channel.ProcessSmartStreamRequest(c, params)
|
||||
}
|
||||
|
||||
func (h *ProxyHandler) transparentProxyErrorHandler(rw http.ResponseWriter, r *http.Request, err error) {
|
||||
correlationID := r.Header.Get("X-Correlation-ID")
|
||||
h.logger.WithField("id", correlationID).Errorf("Transparent proxy error: %v", err)
|
||||
proxyErrPtr, exists := r.Context().Value(proxyErrKey).(**errors.APIError)
|
||||
if !exists || proxyErrPtr == nil {
|
||||
h.logger.WithField("id", correlationID).Error("FATAL: proxyErrorKey not found in context for error handler.")
|
||||
return
|
||||
}
|
||||
if errors.IsClientNetworkError(err) {
|
||||
*proxyErrPtr = errors.NewAPIError(errors.ErrBadRequest, "Client connection closed")
|
||||
} else {
|
||||
*proxyErrPtr = errors.NewAPIError(errors.ErrBadGateway, err.Error())
|
||||
}
|
||||
if _, ok := rw.(*httptest.ResponseRecorder); ok {
|
||||
return
|
||||
}
|
||||
if writer, ok := rw.(interface{ Written() bool }); ok {
|
||||
if writer.Written() {
|
||||
return
|
||||
}
|
||||
}
|
||||
rw.WriteHeader((*proxyErrPtr).HTTPStatus)
|
||||
}
|
||||
|
||||
func (h *ProxyHandler) createLogEvent(c *gin.Context, startTime time.Time, corrID, modelName string, res *service.RequestResources, logType models.LogType, isPreciseRouting bool) *models.RequestFinishedEvent {
|
||||
event := &models.RequestFinishedEvent{
|
||||
RequestLog: models.RequestLog{
|
||||
RequestTime: startTime,
|
||||
ModelName: modelName,
|
||||
RequestPath: c.Request.URL.Path,
|
||||
UserAgent: c.Request.UserAgent(),
|
||||
CorrelationID: corrID,
|
||||
LogType: logType,
|
||||
Metadata: make(datatypes.JSONMap),
|
||||
},
|
||||
CorrelationID: corrID,
|
||||
IsPreciseRouting: isPreciseRouting,
|
||||
}
|
||||
if _, exists := c.Get(middleware.RedactedBodyKey); exists {
|
||||
event.RequestLog.Metadata["request_body_present"] = true
|
||||
}
|
||||
if redactedAuth, exists := c.Get(middleware.RedactedAuthHeaderKey); exists {
|
||||
event.RequestLog.Metadata["authorization_header"] = redactedAuth.(string)
|
||||
}
|
||||
if authTokenValue, exists := c.Get("authToken"); exists {
|
||||
if authToken, ok := authTokenValue.(*models.AuthToken); ok {
|
||||
event.AuthTokenID = &authToken.ID
|
||||
}
|
||||
}
|
||||
if res != nil {
|
||||
event.KeyID = res.APIKey.ID
|
||||
event.GroupID = res.KeyGroup.ID
|
||||
if res.UpstreamEndpoint != nil {
|
||||
event.UpstreamID = &res.UpstreamEndpoint.ID
|
||||
event.UpstreamURL = &res.UpstreamEndpoint.URL
|
||||
}
|
||||
if res.ProxyConfig != nil {
|
||||
event.ProxyID = &res.ProxyConfig.ID
|
||||
}
|
||||
}
|
||||
return event
|
||||
}
|
||||
|
||||
func (h *ProxyHandler) getResourcesForRequest(c *gin.Context, modelName string, groupName string, isPreciseRouting bool) (*service.RequestResources, error) {
|
||||
authTokenValue, exists := c.Get("authToken")
|
||||
if !exists {
|
||||
return nil, errors.NewAPIError(errors.ErrUnauthorized, "Auth token not found in context")
|
||||
}
|
||||
authToken, ok := authTokenValue.(*models.AuthToken)
|
||||
if !ok {
|
||||
return nil, errors.NewAPIError(errors.ErrInternalServer, "Invalid auth token type in context")
|
||||
}
|
||||
if isPreciseRouting {
|
||||
return h.resourceService.GetResourceFromGroup(authToken, groupName)
|
||||
} else {
|
||||
return h.resourceService.GetResourceFromBasePool(authToken, modelName)
|
||||
}
|
||||
}
|
||||
|
||||
func errToJSON(c *gin.Context, corrID string, apiErr *errors.APIError) {
|
||||
c.JSON(apiErr.HTTPStatus, gin.H{
|
||||
"error": apiErr,
|
||||
"correlation_id": corrID,
|
||||
})
|
||||
}
|
||||
|
||||
type bufferPool struct{}
|
||||
|
||||
func (b *bufferPool) Get() []byte { return make([]byte, 32*1024) }
|
||||
func (b *bufferPool) Put(bytes []byte) {}
|
||||
|
||||
func extractUsage(body []byte) (promptTokens int, completionTokens int) {
|
||||
var data struct {
|
||||
UsageMetadata struct {
|
||||
PromptTokenCount int `json:"promptTokenCount"`
|
||||
CandidatesTokenCount int `json:"candidatesTokenCount"`
|
||||
} `json:"usageMetadata"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &data); err == nil {
|
||||
return data.UsageMetadata.PromptTokenCount, data.UsageMetadata.CandidatesTokenCount
|
||||
}
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
func (h *ProxyHandler) buildFinalRequestConfig(globalSettings *models.SystemSettings, groupConfig *models.RequestConfig) *models.RequestConfig {
|
||||
customHeadersJSON, _ := json.Marshal(globalSettings.CustomHeaders)
|
||||
var customHeadersMap datatypes.JSONMap
|
||||
_ = json.Unmarshal(customHeadersJSON, &customHeadersMap)
|
||||
finalConfig := &models.RequestConfig{
|
||||
CustomHeaders: customHeadersMap,
|
||||
EnableStreamOptimizer: globalSettings.EnableStreamOptimizer,
|
||||
StreamMinDelay: globalSettings.StreamMinDelay,
|
||||
StreamMaxDelay: globalSettings.StreamMaxDelay,
|
||||
StreamShortTextThresh: globalSettings.StreamShortTextThresh,
|
||||
StreamLongTextThresh: globalSettings.StreamLongTextThresh,
|
||||
StreamChunkSize: globalSettings.StreamChunkSize,
|
||||
EnableFakeStream: globalSettings.EnableFakeStream,
|
||||
FakeStreamInterval: globalSettings.FakeStreamInterval,
|
||||
}
|
||||
if groupConfig == nil {
|
||||
return finalConfig
|
||||
}
|
||||
groupConfigJSON, err := json.Marshal(groupConfig)
|
||||
if err != nil {
|
||||
h.logger.WithError(err).Error("Failed to marshal group request config for merging.")
|
||||
return finalConfig
|
||||
}
|
||||
if err := json.Unmarshal(groupConfigJSON, finalConfig); err != nil {
|
||||
h.logger.WithError(err).Error("Failed to unmarshal group request config for merging.")
|
||||
return finalConfig
|
||||
}
|
||||
return finalConfig
|
||||
}
|
||||
|
||||
func (h *ProxyHandler) handleListModelsRequest(c *gin.Context) {
|
||||
authTokenValue, exists := c.Get("authToken")
|
||||
if !exists {
|
||||
errToJSON(c, uuid.New().String(), errors.NewAPIError(errors.ErrUnauthorized, "Auth token not found in context"))
|
||||
return
|
||||
}
|
||||
authToken, ok := authTokenValue.(*models.AuthToken)
|
||||
if !ok {
|
||||
errToJSON(c, uuid.New().String(), errors.NewAPIError(errors.ErrInternalServer, "Invalid auth token type in context"))
|
||||
return
|
||||
}
|
||||
modelNames := h.resourceService.GetAllowedModelsForToken(authToken)
|
||||
if strings.Contains(c.Request.URL.Path, "/v1beta/") {
|
||||
h.respondWithGeminiFormat(c, modelNames)
|
||||
} else {
|
||||
h.respondWithOpenAIFormat(c, modelNames)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ProxyHandler) respondWithOpenAIFormat(c *gin.Context, modelNames []string) {
|
||||
type ModelEntry struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Created int64 `json:"created"`
|
||||
OwnedBy string `json:"owned_by"`
|
||||
}
|
||||
type ModelListResponse struct {
|
||||
Object string `json:"object"`
|
||||
Data []ModelEntry `json:"data"`
|
||||
}
|
||||
data := make([]ModelEntry, len(modelNames))
|
||||
for i, name := range modelNames {
|
||||
data[i] = ModelEntry{
|
||||
ID: name,
|
||||
Object: "model",
|
||||
Created: time.Now().Unix(),
|
||||
OwnedBy: "gemini-balancer",
|
||||
}
|
||||
}
|
||||
response := ModelListResponse{
|
||||
Object: "list",
|
||||
Data: data,
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (h *ProxyHandler) respondWithGeminiFormat(c *gin.Context, modelNames []string) {
|
||||
type GeminiModelEntry struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Description string `json:"description"`
|
||||
SupportedGenerationMethods []string `json:"supportedGenerationMethods"`
|
||||
InputTokenLimit int `json:"inputTokenLimit"`
|
||||
OutputTokenLimit int `json:"outputTokenLimit"`
|
||||
}
|
||||
type GeminiModelListResponse struct {
|
||||
Models []GeminiModelEntry `json:"models"`
|
||||
}
|
||||
models := make([]GeminiModelEntry, len(modelNames))
|
||||
for i, name := range modelNames {
|
||||
models[i] = GeminiModelEntry{
|
||||
Name: fmt.Sprintf("models/%s", name),
|
||||
Version: "1.0.0",
|
||||
DisplayName: name,
|
||||
Description: "Served by Gemini Balancer",
|
||||
SupportedGenerationMethods: []string{"generateContent", "streamGenerateContent"},
|
||||
InputTokenLimit: 8192,
|
||||
OutputTokenLimit: 2048,
|
||||
}
|
||||
}
|
||||
response := GeminiModelListResponse{Models: models}
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
46
internal/handlers/setting_handler.go
Normal file
46
internal/handlers/setting_handler.go
Normal file
@@ -0,0 +1,46 @@
|
||||
// file: gemini-balancer\internal\handlers\setting_handler.go
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"gemini-balancer/internal/errors"
|
||||
"gemini-balancer/internal/response"
|
||||
"gemini-balancer/internal/settings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type SettingHandler struct {
|
||||
settingsManager *settings.SettingsManager
|
||||
}
|
||||
|
||||
func NewSettingHandler(settingsManager *settings.SettingsManager) *SettingHandler {
|
||||
return &SettingHandler{settingsManager: settingsManager}
|
||||
}
|
||||
func (h *SettingHandler) GetSettings(c *gin.Context) {
|
||||
settings := h.settingsManager.GetSettings()
|
||||
response.Success(c, settings)
|
||||
}
|
||||
func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
var newSettingsMap map[string]interface{}
|
||||
if err := c.ShouldBindJSON(&newSettingsMap); err != nil {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrInvalidJSON, err.Error()))
|
||||
return
|
||||
}
|
||||
if err := h.settingsManager.UpdateSettings(newSettingsMap); err != nil {
|
||||
// TODO 可以根据错误类型返回更具体的错误
|
||||
response.Error(c, errors.NewAPIError(errors.ErrInternalServer, err.Error()))
|
||||
return
|
||||
}
|
||||
response.Success(c, gin.H{"message": "Settings update request processed successfully."})
|
||||
|
||||
}
|
||||
|
||||
// ResetSettingsToDefaults resets all settings to their default values
|
||||
func (h *SettingHandler) ResetSettingsToDefaults(c *gin.Context) {
|
||||
defaultSettings, err := h.settingsManager.ResetAndSaveSettings()
|
||||
if err != nil {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrInternalServer, "Failed to reset settings: "+err.Error()))
|
||||
return
|
||||
}
|
||||
response.Success(c, defaultSettings)
|
||||
}
|
||||
51
internal/handlers/task_handler.go
Normal file
51
internal/handlers/task_handler.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"gemini-balancer/internal/errors"
|
||||
"gemini-balancer/internal/response"
|
||||
"gemini-balancer/internal/task"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type TaskHandler struct {
|
||||
taskService *task.Task
|
||||
logger *logrus.Entry
|
||||
}
|
||||
|
||||
func NewTaskHandler(taskService *task.Task, logger *logrus.Logger) *TaskHandler {
|
||||
return &TaskHandler{
|
||||
taskService: taskService,
|
||||
logger: logger.WithField("component", "TaskHandler📦"),
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// GetTaskStatus
|
||||
// GET /admin/tasks/:id
|
||||
func (h *TaskHandler) GetTaskStatus(c *gin.Context) {
|
||||
taskID := c.Param("id")
|
||||
if taskID == "" {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrBadRequest, "task ID is required"))
|
||||
return
|
||||
}
|
||||
|
||||
taskStatus, err := h.taskService.GetStatus(taskID)
|
||||
if err != nil {
|
||||
// TODO 可以根据 service 层返回的具体错误类型进行更精细的处理
|
||||
response.Error(c, errors.NewAPIError(errors.ErrResourceNotFound, err.Error()))
|
||||
return
|
||||
}
|
||||
// [探針] 在返回給前端前,打印從存儲中讀取並解析後的 status 對象
|
||||
loggerWithTaskID := h.logger.WithField("task_id", taskID)
|
||||
loggerWithTaskID.Debugf("Status read from store, ABOUT TO BE SENT to frontend: %+v", taskStatus)
|
||||
// [探針] 手動序列化並打印
|
||||
if h.logger.Logger.IsLevelEnabled(logrus.DebugLevel) {
|
||||
jsonData, _ := json.Marshal(taskStatus)
|
||||
loggerWithTaskID.Debugf("Manually marshalled JSON to be sent to frontend: %s", string(jsonData))
|
||||
}
|
||||
response.Success(c, taskStatus)
|
||||
}
|
||||
51
internal/handlers/tokens_handler.go
Normal file
51
internal/handlers/tokens_handler.go
Normal file
@@ -0,0 +1,51 @@
|
||||
// Filename: internal/handlers/tokens_handler.go
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"gemini-balancer/internal/errors"
|
||||
"gemini-balancer/internal/models"
|
||||
"gemini-balancer/internal/response"
|
||||
"gemini-balancer/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type TokensHandler struct {
|
||||
db *gorm.DB
|
||||
tokenManager *service.TokenManager
|
||||
}
|
||||
|
||||
func NewTokensHandler(db *gorm.DB, tm *service.TokenManager) *TokensHandler {
|
||||
return &TokensHandler{
|
||||
db: db,
|
||||
tokenManager: tm,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *TokensHandler) GetAllTokens(c *gin.Context) {
|
||||
tokensFromCache := h.tokenManager.GetAllTokens()
|
||||
//TODO 可以像KeyGroupResponse一样,创建一个TokenResponse DTO来整理数据
|
||||
response.Success(c, tokensFromCache)
|
||||
}
|
||||
|
||||
func (h *TokensHandler) UpdateTokens(c *gin.Context) {
|
||||
var incomingTokens []*models.TokenUpdateRequest
|
||||
if err := c.ShouldBindJSON(&incomingTokens); err != nil {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrInvalidJSON, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.tokenManager.BatchUpdateTokens(incomingTokens); err != nil {
|
||||
response.Error(c, errors.NewAPIError(errors.ErrInternalServer, "Failed to update tokens: "+err.Error()))
|
||||
return
|
||||
}
|
||||
response.Success(c, gin.H{"message": "Tokens updated successfully."})
|
||||
}
|
||||
|
||||
// [TODO]
|
||||
// func (h *TokensHandler) CreateToken(c *gin.Context) {
|
||||
// ... 数据库写操作 ...
|
||||
// h.tokenManager.Invalidate() // 写后,立即让缓存失效
|
||||
// }
|
||||
74
internal/logging/logging.go
Normal file
74
internal/logging/logging.go
Normal file
@@ -0,0 +1,74 @@
|
||||
// Filename: internal/logging/logging.go
|
||||
|
||||
package logging
|
||||
|
||||
import (
|
||||
"gemini-balancer/internal/config"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func NewLogger(cfg *config.Config) *logrus.Logger {
|
||||
logger := logrus.New()
|
||||
|
||||
// 1. 设置日志级别
|
||||
level, err := logrus.ParseLevel(cfg.Log.Level)
|
||||
if err != nil {
|
||||
logger.WithField("configured_level", cfg.Log.Level).Warn("Invalid log level specified, defaulting to 'info'.")
|
||||
level = logrus.InfoLevel
|
||||
}
|
||||
logger.SetLevel(level)
|
||||
|
||||
// 2. 设置日志格式
|
||||
if cfg.Log.Format == "json" {
|
||||
logger.SetFormatter(&logrus.JSONFormatter{
|
||||
TimestampFormat: "2006-01-02T15:04:05.000Z07:00",
|
||||
FieldMap: logrus.FieldMap{
|
||||
logrus.FieldKeyTime: "timestamp",
|
||||
logrus.FieldKeyLevel: "level",
|
||||
logrus.FieldKeyMsg: "message",
|
||||
},
|
||||
})
|
||||
} else {
|
||||
logger.SetFormatter(&logrus.TextFormatter{
|
||||
FullTimestamp: true,
|
||||
TimestampFormat: "2006-01-02 15:04:05",
|
||||
})
|
||||
}
|
||||
|
||||
// 3. 设置日志输出
|
||||
if cfg.Log.EnableFile {
|
||||
if cfg.Log.FilePath == "" {
|
||||
logger.Warn("Log file is enabled but no file path is specified. Logging to console only.")
|
||||
logger.SetOutput(os.Stdout)
|
||||
return logger
|
||||
}
|
||||
|
||||
logDir := filepath.Dir(cfg.Log.FilePath)
|
||||
if err := os.MkdirAll(logDir, 0755); err != nil {
|
||||
logger.WithError(err).Warn("Failed to create log directory. Logging to console only.")
|
||||
logger.SetOutput(os.Stdout)
|
||||
return logger
|
||||
}
|
||||
|
||||
logFile, err := os.OpenFile(cfg.Log.FilePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
|
||||
if err != nil {
|
||||
logger.WithError(err).Warn("Failed to open log file. Logging to console only.")
|
||||
logger.SetOutput(os.Stdout)
|
||||
return logger
|
||||
}
|
||||
|
||||
// 同时输出到控制台和文件
|
||||
logger.SetOutput(io.MultiWriter(os.Stdout, logFile))
|
||||
logger.WithField("log_file_path", cfg.Log.FilePath).Info("Logging is now configured to output to both console and file.")
|
||||
} else {
|
||||
// 仅输出到控制台
|
||||
logger.SetOutput(os.Stdout)
|
||||
}
|
||||
|
||||
logger.Info("Root logger initialized.")
|
||||
return logger
|
||||
}
|
||||
82
internal/middleware/auth.go
Normal file
82
internal/middleware/auth.go
Normal file
@@ -0,0 +1,82 @@
|
||||
// Filename: internal/middleware/auth.go
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"gemini-balancer/internal/service"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// === API Admin 认证管道 (/admin/* API路由) ===
|
||||
|
||||
func APIAdminAuthMiddleware(securityService *service.SecurityService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tokenValue := extractBearerToken(c)
|
||||
if tokenValue == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorization token is missing"})
|
||||
return
|
||||
}
|
||||
authToken, err := securityService.AuthenticateToken(tokenValue)
|
||||
if err != nil || !authToken.IsAdmin {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid or non-admin token"})
|
||||
return
|
||||
}
|
||||
c.Set("adminUser", authToken)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// === /v1 Proxy 认证 ===
|
||||
|
||||
func ProxyAuthMiddleware(securityService *service.SecurityService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tokenValue := extractProxyToken(c)
|
||||
if tokenValue == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "API key is missing from request"})
|
||||
return
|
||||
}
|
||||
authToken, err := securityService.AuthenticateToken(tokenValue)
|
||||
if err != nil {
|
||||
// 通用信息,避免泄露过多信息
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid or inactive token provided"})
|
||||
return
|
||||
}
|
||||
c.Set("authToken", authToken)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func extractProxyToken(c *gin.Context) string {
|
||||
if key := c.Query("key"); key != "" {
|
||||
return key
|
||||
}
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader != "" {
|
||||
if strings.HasPrefix(authHeader, "Bearer ") {
|
||||
return strings.TrimPrefix(authHeader, "Bearer ")
|
||||
}
|
||||
}
|
||||
if key := c.GetHeader("X-Api-Key"); key != "" {
|
||||
return key
|
||||
}
|
||||
if key := c.GetHeader("X-Goog-Api-Key"); key != "" {
|
||||
return key
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// === 辅助函数 ===
|
||||
|
||||
func extractBearerToken(c *gin.Context) string {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Split(authHeader, " ")
|
||||
if len(parts) == 2 && parts[0] == "Bearer" {
|
||||
return parts[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
84
internal/middleware/log.go
Normal file
84
internal/middleware/log.go
Normal file
@@ -0,0 +1,84 @@
|
||||
// Filename: internal/middleware/log_redaction.go
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const RedactedBodyKey = "redactedBody"
|
||||
const RedactedAuthHeaderKey = "redactedAuthHeader"
|
||||
const RedactedValue = `"[REDACTED]"`
|
||||
|
||||
func RedactionMiddleware() gin.HandlerFunc {
|
||||
// Pre-compile regex for efficiency
|
||||
jsonKeyPattern := regexp.MustCompile(`("api_key"|"keys")\s*:\s*"[^"]*"`)
|
||||
bearerTokenPattern := regexp.MustCompile(`^(Bearer\s+)\S+$`)
|
||||
return func(c *gin.Context) {
|
||||
// --- 1. Redact Request Body ---
|
||||
if c.Request.Method == "POST" || c.Request.Method == "PUT" || c.Request.Method == "DELETE" {
|
||||
if bodyBytes, err := io.ReadAll(c.Request.Body); err == nil {
|
||||
|
||||
c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
|
||||
|
||||
bodyString := string(bodyBytes)
|
||||
|
||||
redactedBody := jsonKeyPattern.ReplaceAllString(bodyString, `$1:`+RedactedValue)
|
||||
|
||||
c.Set(RedactedBodyKey, redactedBody)
|
||||
}
|
||||
}
|
||||
// --- 2. Redact Authorization Header ---
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader != "" {
|
||||
if bearerTokenPattern.MatchString(authHeader) {
|
||||
redactedHeader := bearerTokenPattern.ReplaceAllString(authHeader, `${1}[REDACTED]`)
|
||||
c.Set(RedactedAuthHeaderKey, redactedHeader)
|
||||
}
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// LogrusLogger is a Gin middleware that logs requests using a Logrus logger.
|
||||
// It consumes redacted data prepared by the RedactionMiddleware.
|
||||
func LogrusLogger(logger *logrus.Logger) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
path := c.Request.URL.Path
|
||||
|
||||
// Process request
|
||||
c.Next()
|
||||
|
||||
// After request, gather data and log
|
||||
latency := time.Since(start)
|
||||
statusCode := c.Writer.Status()
|
||||
|
||||
entry := logger.WithFields(logrus.Fields{
|
||||
"status_code": statusCode,
|
||||
"latency_ms": latency.Milliseconds(),
|
||||
"client_ip": c.ClientIP(),
|
||||
"method": c.Request.Method,
|
||||
"path": path,
|
||||
})
|
||||
|
||||
if redactedBody, exists := c.Get(RedactedBodyKey); exists {
|
||||
entry = entry.WithField("body", redactedBody)
|
||||
}
|
||||
|
||||
if redactedAuth, exists := c.Get(RedactedAuthHeaderKey); exists {
|
||||
entry = entry.WithField("authorization", redactedAuth)
|
||||
}
|
||||
|
||||
if len(c.Errors) > 0 {
|
||||
entry.Error(c.Errors.String())
|
||||
} else {
|
||||
entry.Info("request handled")
|
||||
}
|
||||
}
|
||||
}
|
||||
31
internal/middleware/security.go
Normal file
31
internal/middleware/security.go
Normal file
@@ -0,0 +1,31 @@
|
||||
// Filename: internal/middleware/security.go
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"gemini-balancer/internal/service"
|
||||
"gemini-balancer/internal/settings"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func IPBanMiddleware(securityService *service.SecurityService, settingsManager *settings.SettingsManager) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if !settingsManager.IsIPBanEnabled() {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
ip := c.ClientIP()
|
||||
isBanned, err := securityService.IsIPBanned(c.Request.Context(), ip)
|
||||
if err != nil {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
if isBanned {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "您的IP已被暂时封禁,请稍后再试"})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
54
internal/middleware/web.go
Normal file
54
internal/middleware/web.go
Normal file
@@ -0,0 +1,54 @@
|
||||
// Filename: internal/middleware/web.go
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"gemini-balancer/internal/service"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
AdminSessionCookie = "gemini_admin_session"
|
||||
)
|
||||
|
||||
func SetAdminSessionCookie(c *gin.Context, adminToken string) {
|
||||
c.SetCookie(AdminSessionCookie, adminToken, 3600*24*7, "/", "", false, true)
|
||||
}
|
||||
|
||||
func ClearAdminSessionCookie(c *gin.Context) {
|
||||
c.SetCookie(AdminSessionCookie, "", -1, "/", "", false, true)
|
||||
}
|
||||
|
||||
func ExtractTokenFromCookie(c *gin.Context) string {
|
||||
cookie, err := c.Cookie(AdminSessionCookie)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return cookie
|
||||
}
|
||||
|
||||
func WebAdminAuthMiddleware(authService *service.SecurityService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
cookie := ExtractTokenFromCookie(c)
|
||||
log.Printf("[WebAuth_Guard] Intercepting request for: %s", c.Request.URL.Path)
|
||||
log.Printf("[WebAuth_Guard] Found session cookie value: '%s'", cookie)
|
||||
authToken, err := authService.AuthenticateToken(cookie)
|
||||
if err != nil {
|
||||
log.Printf("[WebAuth_Guard] FATAL: AuthenticateToken FAILED. Error: %v. Redirecting to /login.", err)
|
||||
} else if !authToken.IsAdmin {
|
||||
log.Printf("[WebAuth_Guard] FATAL: Token validated, but IsAdmin is FALSE. Redirecting to /login.")
|
||||
} else {
|
||||
log.Printf("[WebAuth_Guard] SUCCESS: Token validated and IsAdmin is TRUE. Allowing access.")
|
||||
}
|
||||
if err != nil || !authToken.IsAdmin {
|
||||
ClearAdminSessionCookie(c)
|
||||
c.Redirect(http.StatusFound, "/login")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Set("adminUser", authToken)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
78
internal/models/dto.go
Normal file
78
internal/models/dto.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// ========= ViewModel / DTOs for Dashboard API =========
|
||||
type StatCard struct {
|
||||
Value float64 `json:"value"`
|
||||
SubValue any `json:"sub_value,omitempty"`
|
||||
SubValueTip string `json:"sub_value_tip,omitempty"`
|
||||
Trend float64 `json:"trend"`
|
||||
TrendIsGrowth bool `json:"trend_is_growth"`
|
||||
}
|
||||
type DashboardStatsResponse struct {
|
||||
KeyCount StatCard `json:"key_count"`
|
||||
RPM StatCard `json:"rpm"`
|
||||
RequestCount24h StatCard `json:"request_count_24h"`
|
||||
ErrorRate24h StatCard `json:"error_rate_24h"`
|
||||
KeyStatusCount map[APIKeyStatus]int64 `json:"key_status_count"`
|
||||
MasterStatusCount map[MasterAPIKeyStatus]int64 `json:"master_status_count"`
|
||||
TokenCount map[string]any `json:"token_count"`
|
||||
UpstreamHealthStatus map[string]string `json:"upstream_health_status,omitempty"`
|
||||
RequestCounts map[string]int64 `json:"request_counts"`
|
||||
}
|
||||
type ChartDataset struct {
|
||||
Label string `json:"label"`
|
||||
Data []int64 `json:"data"`
|
||||
Color string `json:"color"`
|
||||
}
|
||||
type ChartData struct {
|
||||
Labels []string `json:"labels"`
|
||||
Datasets []ChartDataset `json:"datasets"`
|
||||
}
|
||||
|
||||
// TokenUpdateRequest DTO for binding the PUT /admin/tokens request
|
||||
type TokenUpdateRequest struct {
|
||||
ID uint `json:"ID"`
|
||||
Token string `json:"Token"`
|
||||
Description string `json:"Description"`
|
||||
Tag string `json:"Tag"`
|
||||
Status string `json:"Status"`
|
||||
IsAdmin bool `json:"IsAdmin"`
|
||||
AllowedGroupIDs []uint `json:"AllowedGroupIDs"`
|
||||
}
|
||||
|
||||
// 数据传输对象(DTO),表示单个Key的测试结果。
|
||||
// ===================================================================
|
||||
type KeyTestResult struct {
|
||||
Key string `json:"key"`
|
||||
Status string `json:"status"` // "valid", "invalid", "error"
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// APIKeyQueryParams defines the parameters for listing/searching keys.
|
||||
type APIKeyQueryParams struct {
|
||||
KeyGroupID uint `form:"-"`
|
||||
Page int `form:"page"`
|
||||
PageSize int `form:"limit"`
|
||||
Status string `form:"status"`
|
||||
Keyword string `form:"keyword"`
|
||||
}
|
||||
|
||||
// APIKeyDetails is a DTO that combines APIKey info with its contextual status from the mapping.
|
||||
type APIKeyDetails struct {
|
||||
// Embedded APIKey fields
|
||||
ID uint `json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
APIKey string `json:"api_key"`
|
||||
MasterStatus MasterAPIKeyStatus `json:"master_status"`
|
||||
|
||||
// Mapping-specific fields
|
||||
Status APIKeyStatus `json:"status"`
|
||||
LastError string `json:"last_error"`
|
||||
ConsecutiveErrorCount int `json:"consecutive_error_count"`
|
||||
LastUsedAt *time.Time `json:"last_used_at"`
|
||||
CooldownUntil *time.Time `json:"cooldown_until"`
|
||||
EncryptedKey string
|
||||
}
|
||||
69
internal/models/events.go
Normal file
69
internal/models/events.go
Normal file
@@ -0,0 +1,69 @@
|
||||
// Filename: internal/models/events.go
|
||||
package models
|
||||
|
||||
import (
|
||||
"gemini-balancer/internal/errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Topic 定义事件总线主题名称
|
||||
const (
|
||||
TopicRequestFinished = "events:request_finished"
|
||||
TopicKeyStatusChanged = "events:key_status_changed"
|
||||
TopicUpstreamHealthChanged = "events:upstream_health_changed"
|
||||
TopicMasterKeyStatusChanged = "master_key_status_changed"
|
||||
TopicImportGroupCompleted = "events:import_group_completed"
|
||||
)
|
||||
|
||||
type RequestFinishedEvent struct {
|
||||
RequestLog
|
||||
KeyID uint
|
||||
GroupID uint
|
||||
IsSuccess bool
|
||||
StatusCode int
|
||||
Error *errors.APIError
|
||||
CorrelationID string `json:"correlation_id,omitempty"`
|
||||
UpstreamID *uint `json:"upstream_id"`
|
||||
UpstreamURL *string `json:"upstream_url,omitempty"`
|
||||
IsPreciseRouting bool `json:"is_precise_routing"`
|
||||
}
|
||||
|
||||
type KeyStatusChangedEvent struct {
|
||||
KeyID uint
|
||||
GroupID uint
|
||||
OldStatus APIKeyStatus
|
||||
NewStatus APIKeyStatus
|
||||
ChangeReason string `json:"change_reason"`
|
||||
ChangedAt time.Time `json:"changed_at"`
|
||||
}
|
||||
|
||||
type UpstreamHealthChangedEvent struct {
|
||||
UpstreamID uint `json:"upstream_id"`
|
||||
UpstreamURL string `json:"upstream_url"`
|
||||
OldStatus string `json:"old_status"` // e.g., "healthy", "unhealthy"
|
||||
NewStatus string `json:"new_status"`
|
||||
Latency time.Duration `json:"latency_ms"` // 延迟时间(毫秒)
|
||||
Reason string `json:"reason"` // 状态变更原因,如 "timeout", "status_503"
|
||||
CheckedAt time.Time `json:"checked_at"`
|
||||
}
|
||||
|
||||
const TopicProxyStatusChanged = "proxy:status_changed"
|
||||
|
||||
type ProxyStatusChangedEvent struct {
|
||||
ProxyID uint `json:"proxy_id"`
|
||||
Action string `json:"action"` // "created", "updated", "deleted"
|
||||
}
|
||||
|
||||
type MasterKeyStatusChangedEvent struct {
|
||||
KeyID uint `json:"key_id"`
|
||||
OldMasterStatus MasterAPIKeyStatus `json:"old_master_status"`
|
||||
NewMasterStatus MasterAPIKeyStatus `json:"new_master_status"`
|
||||
ChangeReason string `json:"change_reason"`
|
||||
ChangedAt time.Time `json:"changed_at"`
|
||||
}
|
||||
|
||||
type ImportGroupCompletedEvent struct {
|
||||
GroupID uint `json:"group_id"`
|
||||
KeyIDs []uint `json:"key_ids"`
|
||||
CompletedAt time.Time `json:"completed_at"`
|
||||
}
|
||||
246
internal/models/models.go
Normal file
246
internal/models/models.go
Normal file
@@ -0,0 +1,246 @@
|
||||
// Filename: internal/models/models.go
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ========= 自定义类型和常量 =========
|
||||
type APIKeyStatus string
|
||||
type MasterAPIKeyStatus string
|
||||
type PollingStrategy string
|
||||
type FileProcessingState string
|
||||
type LogType string
|
||||
|
||||
const (
|
||||
// --- 运营状态 (在中间表中使用) ---
|
||||
StatusPendingValidation APIKeyStatus = "PENDING_VALIDATION"
|
||||
StatusActive APIKeyStatus = "ACTIVE"
|
||||
StatusCooldown APIKeyStatus = "COOLDOWN"
|
||||
StatusDisabled APIKeyStatus = "DISABLED"
|
||||
StatusBanned APIKeyStatus = "BANNED"
|
||||
|
||||
// --- 身份状态 (在APIKey实体中使用) ---
|
||||
MasterStatusActive MasterAPIKeyStatus = "ACTIVE" // 有效
|
||||
MasterStatusRevoked MasterAPIKeyStatus = "REVOKED" // 永久吊销
|
||||
MasterStatusManuallyDisabled MasterAPIKeyStatus = "MANUALLY_DISABLED" // 手动全局禁用
|
||||
|
||||
StrategyWeighted PollingStrategy = "weighted"
|
||||
StrategySequential PollingStrategy = "sequential"
|
||||
StrategyRandom PollingStrategy = "random"
|
||||
FileProcessing FileProcessingState = "PROCESSING"
|
||||
FileActive FileProcessingState = "ACTIVE"
|
||||
FileFailed FileProcessingState = "FAILED"
|
||||
|
||||
LogTypeFinal LogType = "FINAL" // Represents the final outcome of a request, including all retries.
|
||||
LogTypeRetry LogType = "RETRY" // Represents a single, failed attempt that triggered a retry.
|
||||
)
|
||||
|
||||
// ========= 核心数据库模型 =========
|
||||
type KeyGroup struct {
|
||||
ID uint `gorm:"primarykey"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
Name string `gorm:"type:varchar(100);unique;not null"`
|
||||
DisplayName string `gorm:"type:varchar(255)"`
|
||||
Description string `gorm:"type:text"`
|
||||
PollingStrategy PollingStrategy `gorm:"type:varchar(20);not null;default:sequential"`
|
||||
EnableProxy bool `gorm:"not null;default:false"`
|
||||
Sort int `gorm:"default:0"` // 用于业务逻辑排序 (保留)
|
||||
Order int `gorm:"default:0"` // 专用于UI拖拽排序
|
||||
LastValidatedAt *time.Time
|
||||
Mappings []*GroupAPIKeyMapping `gorm:"foreignKey:KeyGroupID"`
|
||||
AllowedModels []*GroupModelMapping `gorm:"foreignKey:GroupID"`
|
||||
AllowedUpstreams []*UpstreamEndpoint `gorm:"many2many:group_upstream_access;"`
|
||||
ChannelType string `gorm:"type:varchar(50);not null;default:'gemini'"`
|
||||
Settings *GroupSettings `gorm:"foreignKey:GroupID"`
|
||||
RequestConfigID *uint `json:"request_config_id"`
|
||||
RequestConfig *RequestConfig `gorm:"foreignKey:RequestConfigID"`
|
||||
}
|
||||
|
||||
type APIKey struct {
|
||||
ID uint `gorm:"primarykey"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
DeletedAt gorm.DeletedAt `gorm:"index"`
|
||||
EncryptedKey string `gorm:"type:text;not null"`
|
||||
APIKeyHash string `gorm:"type:varchar(64);unique;not null;index"`
|
||||
MaskedKey string `gorm:"type:varchar(50);index"`
|
||||
MasterStatus MasterAPIKeyStatus `gorm:"type:varchar(25);not null;default:ACTIVE;index"`
|
||||
ProxyID *uint `gorm:"index"`
|
||||
Mappings []*GroupAPIKeyMapping `gorm:"foreignKey:APIKeyID"`
|
||||
APIKey string `gorm:"-"`
|
||||
}
|
||||
|
||||
// GroupAPIKeyMapping 承载 “运营状态”
|
||||
type GroupAPIKeyMapping struct {
|
||||
KeyGroupID uint `gorm:"primaryKey"`
|
||||
APIKeyID uint `gorm:"primaryKey"`
|
||||
Status APIKeyStatus `gorm:"type:varchar(25);not null;default:PENDING_VALIDATION;index"`
|
||||
LastError string `gorm:"type:text"`
|
||||
ConsecutiveErrorCount int `gorm:"not null;default:0"`
|
||||
LastUsedAt *time.Time
|
||||
CooldownUntil *time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
APIKey *APIKey `gorm:"foreignKey:APIKeyID"`
|
||||
KeyGroup *KeyGroup `gorm:"foreignKey:KeyGroupID"`
|
||||
}
|
||||
|
||||
type RequestLog struct {
|
||||
ID uint `gorm:"primarykey"`
|
||||
RequestTime time.Time `gorm:"index"`
|
||||
LatencyMs int
|
||||
IsSuccess bool
|
||||
StatusCode int
|
||||
ModelName string `gorm:"type:varchar(100);index"`
|
||||
GroupID *uint `gorm:"index"`
|
||||
KeyID *uint `gorm:"index"`
|
||||
AuthTokenID *uint
|
||||
UpstreamID *uint
|
||||
ProxyID *uint
|
||||
Retries int `gorm:"not null;default:0"`
|
||||
ErrorCode string `gorm:"type:varchar(50);index"`
|
||||
ErrorMessage string `gorm:"type:text"`
|
||||
RequestPath string `gorm:"type:varchar(500)"`
|
||||
UserAgent string `gorm:"type:varchar(512)"`
|
||||
PromptTokens int `gorm:"not null;default:0"`
|
||||
CompletionTokens int `gorm:"not null;default:0"`
|
||||
CorrelationID string `gorm:"type:varchar(36);index"`
|
||||
LogType LogType `gorm:"type:varchar(20);index"`
|
||||
Metadata datatypes.JSONMap `gorm:"type:json"`
|
||||
}
|
||||
|
||||
// GroupModelMapping 模型关系表
|
||||
type GroupModelMapping struct {
|
||||
ID uint `gorm:"primarykey"`
|
||||
GroupID uint `gorm:"index;uniqueIndex:idx_group_model_unique"`
|
||||
ModelName string `gorm:"type:varchar(100);not null;uniqueIndex:idx_group_model_unique"`
|
||||
}
|
||||
|
||||
// AuthToken
|
||||
type AuthToken struct {
|
||||
ID uint `gorm:"primarykey"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
EncryptedToken string `gorm:"type:text;not null"`
|
||||
TokenHash string `gorm:"type:varchar(64);unique;not null;index"`
|
||||
Token string `gorm:"-"`
|
||||
Description string `gorm:"type:text"`
|
||||
Tag string `gorm:"type:varchar(100);index"`
|
||||
IsAdmin bool `gorm:"not null;default:false"`
|
||||
HasUnrestrictedAccess bool `gorm:"not null;default:false"`
|
||||
Status string `gorm:"type:varchar(20);not null;default:active"`
|
||||
AllowedGroups []*KeyGroup `gorm:"many2many:token_group_access;"`
|
||||
}
|
||||
|
||||
// FileRecord
|
||||
type FileRecord struct {
|
||||
ID uint `gorm:"primarykey"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
KeyID uint `gorm:"not null"`
|
||||
Name string `gorm:"type:varchar(255);unique;not null"`
|
||||
DisplayName string `gorm:"type:varchar(255)"`
|
||||
MimeType string `gorm:"type:varchar(100);not null"`
|
||||
SizeBytes int64 `gorm:"not null"`
|
||||
Sha256Hash string `gorm:"type:varchar(64)"`
|
||||
State FileProcessingState `gorm:"type:varchar(20);not null;default:PROCESSING"`
|
||||
Uri string `gorm:"type:varchar(500);not null"`
|
||||
ExpirationTime time.Time `gorm:"not null"`
|
||||
}
|
||||
|
||||
// StatsHourly 长期历史数据仓库,为趋势分析提供高效查询
|
||||
type StatsHourly struct {
|
||||
ID uint `gorm:"primarykey"`
|
||||
Time time.Time `gorm:"uniqueIndex:idx_stats_hourly_unique"`
|
||||
GroupID uint `gorm:"uniqueIndex:idx_stats_hourly_unique"`
|
||||
ModelName string `gorm:"type:varchar(100);uniqueIndex:idx_stats_hourly_unique"`
|
||||
RequestCount int64 `gorm:"not null;default:0"`
|
||||
SuccessCount int64 `gorm:"not null;default:0"`
|
||||
PromptTokens int64 `gorm:"not null;default:0"`
|
||||
CompletionTokens int64 `gorm:"not null;default:0"`
|
||||
}
|
||||
|
||||
type UpstreamEndpoint struct {
|
||||
ID uint `gorm:"primarykey"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
URL string `gorm:"type:varchar(500);unique;not null"`
|
||||
Weight int `gorm:"not null;default:100;index"`
|
||||
Status string `gorm:"type:varchar(20);not null;default:'active';index"`
|
||||
Description string `gorm:"type:text"`
|
||||
}
|
||||
type ProxyConfig struct {
|
||||
ID uint `gorm:"primarykey"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
Address string `gorm:"type:varchar(255);unique;not null"`
|
||||
Protocol string `gorm:"type:varchar(10);not null"`
|
||||
Status string `gorm:"type:varchar(20);not null;default:'active';index"`
|
||||
AssignedKeysCount int `gorm:"not null;default:0;index"`
|
||||
Description string `gorm:"type:text"`
|
||||
}
|
||||
type Setting struct {
|
||||
Key string `gorm:"primarykey;type:varchar(100)" json:"key"`
|
||||
Value string `gorm:"type:text" json:"value"`
|
||||
Name string `gorm:"type:varchar(100)" json:"name"`
|
||||
Description string `gorm:"type:varchar(255)" json:"description"`
|
||||
Type string `gorm:"type:varchar(20)" json:"type"`
|
||||
Category string `gorm:"type:varchar(50);index" json:"category"`
|
||||
DefaultValue string `gorm:"type:text" json:"default_value"`
|
||||
}
|
||||
|
||||
// GroupSettings 用于存储特定于Group的配置覆盖
|
||||
type GroupSettings struct {
|
||||
GroupID uint `gorm:"primaryKey"`
|
||||
SettingsJSON datatypes.JSON `gorm:"type:json"` // 将 KeyGroupSettings 序列化后存入此字段
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// KeyGroupSettings 定义可以被分组覆盖的所有**运营**配置项
|
||||
// 不会直接映射到数据库表,作为 JSON 对象存储在 GroupSettings.SettingsJSON
|
||||
type KeyGroupSettings struct {
|
||||
// 健康检查相关配置
|
||||
EnableKeyCheck *bool `json:"enable_key_check,omitempty"`
|
||||
KeyCheckModel *string `json:"key_check_model,omitempty"`
|
||||
KeyCheckEndpoint *string `json:"key_check_endpoint,omitempty"`
|
||||
KeyCheckConcurrency *int `json:"key_check_concurrency,omitempty"`
|
||||
KeyCheckIntervalMinutes *int `json:"key_check_interval_minutes,omitempty"`
|
||||
// 惩罚机制相关配置
|
||||
KeyBlacklistThreshold *int `json:"key_blacklist_threshold,omitempty"`
|
||||
KeyCooldownMinutes *int `json:"key_cooldown_minutes,omitempty"`
|
||||
MaxRetries *int `json:"max_retries,omitempty"`
|
||||
// Smart Gateway
|
||||
EnableSmartGateway *bool `json:"enable_smart_gateway,omitempty"`
|
||||
}
|
||||
|
||||
// RequestConfig 封装了所有直接影响对上游API请求的参数,作为一个独立的数据库模型
|
||||
type RequestConfig struct {
|
||||
ID uint `gorm:"primarykey"`
|
||||
//Custom Headers
|
||||
CustomHeaders datatypes.JSONMap `gorm:"type:json" json:"custom_headers"`
|
||||
|
||||
// Streaming Optimization
|
||||
EnableStreamOptimizer bool `json:"enable_stream_optimizer"`
|
||||
StreamMinDelay int `json:"stream_min_delay"`
|
||||
StreamMaxDelay int `json:"stream_max_delay"`
|
||||
StreamShortTextThresh int `json:"stream_short_text_thresh"`
|
||||
StreamLongTextThresh int `json:"stream_long_text_thresh"`
|
||||
StreamChunkSize int `json:"stream_chunk_size"`
|
||||
EnableFakeStream bool `json:"enable_fake_stream"`
|
||||
FakeStreamInterval int `json:"fake_stream_interval"`
|
||||
|
||||
// Model and Safety Settings
|
||||
ModelSettings datatypes.JSON `gorm:"type:json" json:"model_settings"`
|
||||
|
||||
// Generic Overrides for parameters not explicitly defined
|
||||
ConfigOverrides datatypes.JSONMap `gorm:"type:json" json:"config_overrides"`
|
||||
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
64
internal/models/request.go
Normal file
64
internal/models/request.go
Normal file
@@ -0,0 +1,64 @@
|
||||
// Filename: internal/models/request.go
|
||||
package models
|
||||
|
||||
// GeminiRequest 对应客户端发来的JSON请求体
|
||||
type GeminiRequest struct {
|
||||
Contents []GeminiContent `json:"contents"`
|
||||
GenerationConfig GenerationConfig `json:"generationConfig,omitempty"`
|
||||
SafetySettings []SafetySetting `json:"safetySettings,omitempty"`
|
||||
Tools []Tool `json:"tools,omitempty"`
|
||||
}
|
||||
|
||||
// GeminiContent 包含角色和内容部分
|
||||
type GeminiContent struct {
|
||||
Role string `json:"role,omitempty"`
|
||||
Parts []Part `json:"parts"`
|
||||
}
|
||||
|
||||
// Part 代表内容的一个组成部分 (文本或内联数据)
|
||||
type Part struct {
|
||||
Text string `json:"text,omitempty"`
|
||||
InlineData *InlineData `json:"inlineData,omitempty"`
|
||||
}
|
||||
|
||||
// InlineData 用于多模态输入,如图像
|
||||
type InlineData struct {
|
||||
MimeType string `json:"mimeType"`
|
||||
Data string `json:"data"` // Base64-encoded data
|
||||
}
|
||||
|
||||
// GenerationConfig 控制模型的生成行为
|
||||
type GenerationConfig struct {
|
||||
Temperature float32 `json:"temperature,omitempty"`
|
||||
TopP float32 `json:"topP,omitempty"`
|
||||
TopK int `json:"topK,omitempty"`
|
||||
MaxOutputTokens int `json:"maxOutputTokens,omitempty"`
|
||||
StopSequences []string `json:"stopSequences,omitempty"`
|
||||
}
|
||||
|
||||
// SafetySetting 定义安全过滤的阈值
|
||||
type SafetySetting struct {
|
||||
Category string `json:"category"`
|
||||
Threshold string `json:"threshold"`
|
||||
}
|
||||
|
||||
// Tool 定义模型可以调用的外部工具
|
||||
type Tool struct {
|
||||
FunctionDeclarations any `json:"functionDeclarations,omitempty"`
|
||||
}
|
||||
|
||||
// ========= 用于智能网关流式响应解析的模型 =========
|
||||
// GeminiSSEPayload 是用于解析SSE(Server-Sent Events)事件中data字段的结构体
|
||||
// 它代表了从上游接收到的一个数据块。
|
||||
type GeminiSSEPayload struct {
|
||||
Candidates []*Candidate `json:"candidates"`
|
||||
}
|
||||
|
||||
// Candidate 包含了模型生成的内容和会话的结束原因
|
||||
type Candidate struct {
|
||||
// Content 里面包含了本次返回的具体文本内容
|
||||
Content *GeminiContent `json:"content"`
|
||||
// FinishReason 告知我们流结束的原因,例如 "STOP", "MAX_TOKENS" 等。
|
||||
// 这是我们智能重试逻辑判断的核心依据。
|
||||
FinishReason string `json:"finishReason"`
|
||||
}
|
||||
117
internal/models/runtime.go
Normal file
117
internal/models/runtime.go
Normal file
@@ -0,0 +1,117 @@
|
||||
// internal\models\runtime.go
|
||||
package models
|
||||
|
||||
// ========= 运行时配置 (非数据库模型并提供默认值) =========
|
||||
type SystemSettings struct {
|
||||
DefaultUpstreamURL string `json:"default_upstream_url" default:"https://generativelanguage.googleapis.com/v1beta" name:"全局默认上游URL" category:"请求设置" desc:"当密钥组未指定任何专属上游时,将使用此URL作为最终的兜底。"`
|
||||
RequestLogRetentionDays int `json:"request_log_retention_days" default:"7" name:"日志保留天数" category:"基础设置" desc:"请求日志在数据库中的保留天数。"`
|
||||
RequestTimeoutSeconds int `json:"request_timeout_seconds" default:"600" name:"请求超时(秒)" category:"请求设置" desc:"转发请求的完整生命周期超时(秒)。"`
|
||||
ConnectTimeoutSeconds int `json:"connect_timeout_seconds" default:"15" name:"连接超时(秒)" category:"请求设置" desc:"与上游服务建立新连接的超时时间(秒)。"`
|
||||
MaxRetries int `json:"max_retries" default:"3" name:"最大重试次数" category:"请求设置" desc:"单个请求使用不同Key的最大重试次数。"`
|
||||
BlacklistThreshold int `json:"blacklist_threshold" default:"3" name:"拉黑阈值" category:"密钥设置" desc:"一个Key连续失败多少次后进入冷却状态。"`
|
||||
KeyCooldownMinutes int `json:"key_cooldown_minutes" default:"10" name:"密钥冷却时长(分钟)" category:"密钥设置" desc:"一个Key进入冷却状态后需要等待的时间,单位为分钟。"`
|
||||
LogFlushIntervalSeconds int `json:"log_flush_interval_seconds" default:"10" name:"日志刷新间隔(秒)" category:"日志设置" desc:"异步日志写入数据库的间隔时间(秒)。"`
|
||||
|
||||
PollingStrategy PollingStrategy `json:"polling_strategy" default:"random" name:"全局轮询策略" category:"调度设置" desc:"智能聚合模式下,从所有可用密钥中选择一个的默认策略。可选值: sequential(顺序), random(随机), weighted(加权)。"`
|
||||
|
||||
// HealthCheckIntervalSeconds is DEPRECATED. Use specific intervals below.
|
||||
|
||||
UpstreamCheckIntervalSeconds int `json:"upstream_check_interval_seconds" default:"300" name:"上游检查周期(秒)" category:"健康检查" desc:"对所有上游服务进行健康检查的周期。"`
|
||||
ProxyCheckIntervalSeconds int `json:"proxy_check_interval_seconds" default:"600" name:"代理检查周期(秒)" category:"健康检查" desc:"对所有代理服务进行健康检查的周期。"`
|
||||
|
||||
EnableBaseKeyCheck bool `json:"enable_base_key_check" default:"true" name:"启用全局基础Key检查" category:"健康检查" desc:"是否启用全局的、长周期的Key身份状态检查。"`
|
||||
KeyCheckTimeoutSeconds int `json:"key_check_timeout_seconds" default:"20" name:"Key检查超时(秒)" category:"健康检查" desc:"对单个API Key进行有效性验证时的网络超时时间(全局与分组检查共用)。"`
|
||||
BaseKeyCheckIntervalMinutes int `json:"base_key_check_interval_minutes" default:"1440" name:"全局Key检查周期(分钟)" category:"健康检查" desc:"对所有ACTIVE状态的Key进行身份检查的周期,建议设置较长时间,例如1天(1440分钟)。"`
|
||||
BaseKeyCheckConcurrency int `json:"base_key_check_concurrency" default:"5" name:"全局Key检查并发数" category:"健康检查" desc:"执行全局Key身份检查时的并发请求数量。"`
|
||||
BaseKeyCheckEndpoint string `json:"base_key_check_endpoint" default:"https://generativelanguage.googleapis.com/v1beta/models" name:"全局Key检查端点" category:"健康检查" desc:"用于全局Key身份检查的目标URL。"`
|
||||
BaseKeyCheckModel string `json:"base_key_check_model" default:"gemini-1.5-flash" name:"默认Key检查模型" category:"健康检查" desc:"用于分组健康检查和手动密钥测试时的默认回退模型。"`
|
||||
|
||||
EnableUpstreamCheck bool `json:"enable_upstream_check" default:"true" name:"启用上游检查" category:"健康检查" desc:"是否启用对上游服务(Upstream)的健康检查。"`
|
||||
UpstreamCheckTimeoutSeconds int `json:"upstream_check_timeout_seconds" default:"20" name:"上游检查超时(秒)" category:"健康检查" desc:"对单个上游服务进行健康检查时的网络超时时间。"`
|
||||
|
||||
EnableProxyCheck bool `json:"enable_proxy_check" default:"true" name:"启用代理检查" category:"健康检查" desc:"是否启用对代理(Proxy)的健康检查。"`
|
||||
ProxyCheckTimeoutSeconds int `json:"proxy_check_timeout_seconds" default:"20" name:"代理检查超时(秒)" category:"健康检查" desc:"通过代理进行连通性测试时的网络超时时间。"`
|
||||
ProxyCheckConcurrency int `json:"proxy_check_concurrency" default:"5" name:"代理测试并发数" category:"健康检查" desc:"后台手动批量测试代理时的默认并发请求数量。"`
|
||||
UseProxyHash bool `json:"use_proxy_hash" default:"false" name:"是否开启固定代理策略" category:"API配置" desc:"开启后,对于每一个API_KEY将根据算法从代理列表中选取同一个代理IP,防止一个API_KEY同时被多个IP访问,也同时防止了一个IP访问了过多的API_KEY。"`
|
||||
|
||||
AnalyticsFlushIntervalSeconds int `json:"analytics_flush_interval_seconds" default:"60" name:"分析数据落盘间隔(秒)" category:"高级设置" desc:"内存中的统计数据多久写入数据库一次。"`
|
||||
|
||||
// 安全设置
|
||||
EnableIPBanning bool `json:"enable_ip_banning" default:"false" name:"启用IP封禁功能" category:"安全设置" desc:"当一个IP连续多次登录失败后,是否自动将其封禁一段时间。"`
|
||||
MaxLoginAttempts int `json:"max_login_attempts" default:"5" name:"最大登录失败次数" category:"安全设置" desc:"在一个IP被封禁前,允许的连续登录失败次数。"`
|
||||
IPBanDurationMinutes int `json:"ip_ban_duration_minutes" default:"15" name:"IP封禁时长(分钟)" category:"安全设置" desc:"IP被封禁的时长,单位为分钟。"`
|
||||
|
||||
//智能网关
|
||||
LogTruncationLimit int `json:"log_truncation_limit" default:"8000" name:"日志截断长度" category:"日志设置" desc:"在日志中记录上游响应或错误时,保留的最大字符数。0表示不截断。"`
|
||||
EnableSmartGateway bool `json:"enable_smart_gateway" default:"false" name:"启用智能网关" category:"代理设置" desc:"开启后,系统将对流式请求进行智能中断续传、错误标准化等优化。关闭后,系统将作为一个纯净、无干扰的透明代理。"`
|
||||
EnableStreamingRetry bool `json:"enable_streaming_retry" default:"true" name:"启用流式重试" category:"代理设置" desc:"当智能网关开启时,是否对流式请求进行智能中断续传。"`
|
||||
MaxStreamingRetries int `json:"max_streaming_retries" default:"2" name:"最大流式重试次数" category:"代理设置" desc:"对单个流式会话,允许的最大连续重试次数。"`
|
||||
StreamingRetryDelayMs int `json:"streaming_retry_delay_ms" default:"750" name:"流式重试延迟(毫秒)" category:"代理设置" desc:"流式会话重试之间的等待时间,单位为毫秒。"`
|
||||
|
||||
// 智能网关底层HTTP Transport配置
|
||||
TransportMaxIdleConns int `json:"transport_max_idle_conns" default:"200" name:"最大空闲连接数(总)" category:"高级设置" desc:"HTTP客户端Transport的最大总空闲连接数。"`
|
||||
TransportMaxIdleConnsPerHost int `json:"transport_max_idle_conns_per_host" default:"100" name:"最大空-闲连接数(单主机)" category:"高级设置" desc:"HTTP客户端Transport对单个主机的最大空闲连接数。"`
|
||||
TransportIdleConnTimeoutSecs int `json:"transport_idle_conn_timeout_secs" default:"90" name:"空闲连接超时(秒)" category:"高级设置" desc:"HTTP客户端Transport中空闲连接被关闭前的等待时间。"`
|
||||
TransportTLSHandshakeTimeout int `json:"transport_tls_handshake_timeout" default:"10" name:"TLS握手超时(秒)" category:"高级设置" desc:"TLS握手的超时时间。"`
|
||||
|
||||
// 智能续传的自定义Prompt
|
||||
StreamingRetryPrompt string `json:"streaming_retry_prompt" default:"Continue exactly where you left off, providing the final answer without repeating the previous thinking steps." name:"智能续传提示词" category:"代理设置" desc:"在进行智能中断续传时,向模型发送的指令。"`
|
||||
|
||||
// 日志服务相关配置
|
||||
LogLevel string `json:"log_level" default:"INFO" name:"日志级别" category:"日志配置"`
|
||||
AutoDeleteErrorLogsEnabled bool `json:"auto_delete_error_logs_enabled" default:"false" name:"自动删除错误日志" category:"日志配置"`
|
||||
AutoDeleteRequestLogsEnabled bool `json:"auto_delete_request_logs_enabled" default:"false" name:"自动删除请求日志" category:"日志配置"`
|
||||
LogBufferCapacity int `json:"log_buffer_capacity" default:"1000" name:"日志缓冲区容量" category:"日志设置" desc:"内存中日志缓冲区的最大容量,超过则可能丢弃日志。"`
|
||||
LogFlushBatchSize int `json:"log_flush_batch_size" default:"100" name:"日志刷新批次大小" category:"日志设置" desc:"每次向数据库批量写入日志的最大数量。"`
|
||||
|
||||
// --- API配置 ---
|
||||
CustomHeaders map[string]string `json:"custom_headers" name:"自定义Headers" category:"API配置" ` // 默认为nil
|
||||
|
||||
// --- TTS 配置 (模块化预留) ---
|
||||
TTSModel string `json:"tts_model" name:"TTS模型" category:"TTS配置"`
|
||||
TTSVoiceName string `json:"tts_voice_name" name:"TTS语音名称" category:"TTS配置"`
|
||||
TTSSpeed string `json:"tts_speed" name:"TTS语速" category:"TTS配置"`
|
||||
|
||||
// --- 图像生成配置 (模块化预留) ---
|
||||
PaidKey string `json:"paid_key" name:"付费API密钥" category:"图像生成"`
|
||||
CreateImageModel string `json:"create_image_model" name:"图像生成模型" category:"图像生成"`
|
||||
UploadProvider string `json:"upload_provider" name:"上传提供商" category:"图像生成"`
|
||||
SmmsSecretToken string `json:"smms_secret_token" name:"SM.MS密钥" category:"图像生成"`
|
||||
PicgoAPIKey string `json:"picgo_api_key" name:"PicGo API密钥" category:"图像生成"`
|
||||
CloudflareImgbedURL string `json:"cloudflare_imgbed_url" name:"Cloudflare图床URL" category:"图像生成"`
|
||||
CloudflareImgbedAuthCode string `json:"cloudflare_imgbed_auth_code" name:"Cloudflare认证码" category:"图像生成"`
|
||||
CloudflareImgbedUploadFolder string `json:"cloudflare_imgbed_upload_folder" name:"Cloudflare上传文件夹" category:"图像生成"`
|
||||
// --- 流式输出配置 (模块化预留) ---
|
||||
EnableStreamOptimizer bool `json:"enable_stream_optimizer" default:"false" name:"启用流式输出优化" category:"流式输出"`
|
||||
StreamMinDelay int `json:"stream_min_delay" default:"16" name:"最小延迟(秒)" category:"流式输出"`
|
||||
StreamMaxDelay int `json:"stream_max_delay" default:"24" name:"最大延迟(秒)" category:"流式输出"`
|
||||
StreamShortTextThresh int `json:"stream_short_text_thresh" default:"10" name:"短文本阈值" category:"流式输出"`
|
||||
StreamLongTextThresh int `json:"stream_long_text_thresh" default:"50" name:"长文本阈值" category:"流式输出"`
|
||||
StreamChunkSize int `json:"stream_chunk_size" default:"5" name:"分块大小" category:"流式输出"`
|
||||
EnableFakeStream bool `json:"enable_fake_stream" default:"false" name:"启用假流式输出" category:"流式输出"`
|
||||
FakeStreamInterval int `json:"fake_stream_interval" default:"5" name:"假流式空数据发送间隔(秒)" category:"流式输出"`
|
||||
|
||||
// --- 定时任务配置 ---
|
||||
Timezone string `json:"timezone" default:"Asia/Shanghai" name:"时区" category:"定时任务"`
|
||||
|
||||
// --- [短期冻结] 为了UI兼容性而保留的“幻影”字段 ---
|
||||
AllowedTokens []string `json:"-"` // 不参与JSON序列化
|
||||
Proxies []string `json:"-"` // 不参与JSON序列化
|
||||
|
||||
ModelSettings ModelSettings `json:"model_settings" name:"模型配置"`
|
||||
}
|
||||
|
||||
// ModelSettings
|
||||
type ModelSettings struct {
|
||||
ImageModels []string `json:"image_models" name:"图像模型列表"`
|
||||
SearchModels []string `json:"search_models" name:"搜索模型列表"`
|
||||
FilteredModels []string `json:"filtered_models" name:"过滤模型列表"`
|
||||
EnableCodeExecutor bool `json:"enable_code_executor" default:"false" name:"启用代码执行工具"`
|
||||
EnableURLContext bool `json:"enable_url_context" default:"false" name:"启用网址上下文"`
|
||||
URLContextModels []string `json:"url_context_models" name:"网址上下文模型列表"`
|
||||
ShowSearchLink bool `json:"show_search_link" default:"false" name:"显示搜索链接"`
|
||||
ShowThinking bool `json:"show_thinking" default:"false" name:"显示思考过程"`
|
||||
ThinkingModels []string `json:"thinking_models" name:"思考模型列表"`
|
||||
ThinkingBudgetMap map[string]int `json:"thinking_budget_map" name:"思考模型预算映射"`
|
||||
SafetySettings []SafetySetting `json:"safety_settings" name:"安全设置"`
|
||||
}
|
||||
81
internal/pkg/reflectutil/structs.go
Normal file
81
internal/pkg/reflectutil/structs.go
Normal file
@@ -0,0 +1,81 @@
|
||||
// Filename: internal/pkg/reflectutil/structs.go
|
||||
package reflectutil
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// SetFieldFromString sets a struct field's value from a string.
|
||||
func SetFieldFromString(field reflect.Value, value string) error {
|
||||
if !field.CanSet() {
|
||||
return fmt.Errorf("cannot set field")
|
||||
}
|
||||
switch field.Kind() {
|
||||
case reflect.Int, reflect.Int64:
|
||||
intVal, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
field.SetInt(intVal)
|
||||
case reflect.String:
|
||||
field.SetString(value)
|
||||
case reflect.Bool:
|
||||
boolVal, err := strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
field.SetBool(boolVal)
|
||||
default:
|
||||
return fmt.Errorf("unsupported field type: %s", field.Type())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FieldTypeToString converts a reflect.Type to a simple string representation.
|
||||
func FieldTypeToString(t reflect.Type) string {
|
||||
switch t.Kind() {
|
||||
case reflect.Int, reflect.Int64:
|
||||
return "int"
|
||||
case reflect.String:
|
||||
return "string"
|
||||
case reflect.Bool:
|
||||
return "bool"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// MergeNilFields uses reflection to merge non-nil pointer fields from 'override' into 'base'.
|
||||
// Both base and override must be pointers to structs of the same type.
|
||||
func MergeNilFields(base, override interface{}) error {
|
||||
baseVal := reflect.ValueOf(base)
|
||||
overrideVal := reflect.ValueOf(override)
|
||||
|
||||
if baseVal.Kind() != reflect.Ptr || overrideVal.Kind() != reflect.Ptr {
|
||||
return fmt.Errorf("base and override must be pointers")
|
||||
}
|
||||
|
||||
baseElem := baseVal.Elem()
|
||||
overrideElem := overrideVal.Elem()
|
||||
|
||||
if baseElem.Kind() != reflect.Struct || overrideElem.Kind() != reflect.Struct {
|
||||
return fmt.Errorf("base and override must be pointers to structs")
|
||||
}
|
||||
|
||||
if baseElem.Type() != overrideElem.Type() {
|
||||
return fmt.Errorf("base and override must be of the same struct type")
|
||||
}
|
||||
|
||||
for i := 0; i < overrideElem.NumField(); i++ {
|
||||
overrideField := overrideElem.Field(i)
|
||||
if overrideField.Kind() == reflect.Ptr && !overrideField.IsNil() {
|
||||
baseField := baseElem.Field(i)
|
||||
if baseField.CanSet() {
|
||||
baseField.Set(overrideField)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
57
internal/pkg/stringutil/string.go
Normal file
57
internal/pkg/stringutil/string.go
Normal file
@@ -0,0 +1,57 @@
|
||||
// Filename: internal/pkg/stringutil/string.go
|
||||
package stringutil
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MaskAPIKey masks an API key for safe logging.
|
||||
func MaskAPIKey(key string) string {
|
||||
length := len(key)
|
||||
if length <= 8 {
|
||||
return key
|
||||
}
|
||||
return fmt.Sprintf("%s****%s", key[:4], key[length-4:])
|
||||
}
|
||||
|
||||
// TruncateString shortens a string to a maximum length.
|
||||
func TruncateString(s string, maxLength int) string {
|
||||
if len(s) > maxLength {
|
||||
return s[:maxLength]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// SplitAndTrim splits a string by a separator
|
||||
func SplitAndTrim(s string, sep string) []string {
|
||||
if s == "" {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
parts := strings.Split(s, sep)
|
||||
result := make([]string, 0, len(parts))
|
||||
|
||||
for _, part := range parts {
|
||||
trimmed := strings.TrimSpace(part)
|
||||
if trimmed != "" {
|
||||
result = append(result, trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// StringToSet converts a separator-delimited string into a set
|
||||
func StringToSet(s string, sep string) map[string]struct{} {
|
||||
parts := SplitAndTrim(s, sep)
|
||||
if len(parts) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
set := make(map[string]struct{}, len(parts))
|
||||
for _, part := range parts {
|
||||
set[part] = struct{}{}
|
||||
}
|
||||
return set
|
||||
}
|
||||
101
internal/pongo/renderer.go
Normal file
101
internal/pongo/renderer.go
Normal file
@@ -0,0 +1,101 @@
|
||||
// Filename: internal/pongo/renderer.go
|
||||
|
||||
package pongo
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/flosch/pongo2/v6"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/render"
|
||||
)
|
||||
|
||||
type Renderer struct {
|
||||
Context pongo2.Context
|
||||
tplSet *pongo2.TemplateSet
|
||||
}
|
||||
|
||||
func New(directory string, isDebug bool) *Renderer {
|
||||
loader := pongo2.MustNewLocalFileSystemLoader(directory)
|
||||
tplSet := pongo2.NewSet("gin-pongo-templates", loader)
|
||||
tplSet.Debug = isDebug
|
||||
return &Renderer{Context: make(pongo2.Context), tplSet: tplSet}
|
||||
}
|
||||
|
||||
// Instance returns a new render.HTML instance for a single request.
|
||||
func (p *Renderer) Instance(name string, data interface{}) render.Render {
|
||||
var glob pongo2.Context
|
||||
if p.Context != nil {
|
||||
glob = p.Context
|
||||
}
|
||||
|
||||
var context pongo2.Context
|
||||
if data != nil {
|
||||
if ginContext, ok := data.(gin.H); ok {
|
||||
context = pongo2.Context(ginContext)
|
||||
} else if pongoContext, ok := data.(pongo2.Context); ok {
|
||||
context = pongoContext
|
||||
} else if m, ok := data.(map[string]interface{}); ok {
|
||||
context = m
|
||||
} else {
|
||||
context = make(pongo2.Context)
|
||||
}
|
||||
} else {
|
||||
context = make(pongo2.Context)
|
||||
}
|
||||
|
||||
for k, v := range glob {
|
||||
if _, ok := context[k]; !ok {
|
||||
context[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
tpl, err := p.tplSet.FromCache(name)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("Failed to load template '%s': %v", name, err))
|
||||
}
|
||||
|
||||
return &HTML{
|
||||
p: p,
|
||||
Template: tpl,
|
||||
Name: name,
|
||||
Data: context,
|
||||
}
|
||||
}
|
||||
|
||||
type HTML struct {
|
||||
p *Renderer
|
||||
Template *pongo2.Template
|
||||
Name string
|
||||
Data pongo2.Context
|
||||
}
|
||||
|
||||
func (h *HTML) Render(w http.ResponseWriter) error {
|
||||
h.WriteContentType(w)
|
||||
bytes, err := h.Template.ExecuteBytes(h.Data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = w.Write(bytes)
|
||||
return err
|
||||
}
|
||||
|
||||
func (h *HTML) WriteContentType(w http.ResponseWriter) {
|
||||
header := w.Header()
|
||||
if val := header["Content-Type"]; len(val) == 0 {
|
||||
header["Content-Type"] = []string{"text/html; charset=utf-8"}
|
||||
}
|
||||
}
|
||||
|
||||
func C(ctx *gin.Context) pongo2.Context {
|
||||
p, exists := ctx.Get("pongo2")
|
||||
if exists {
|
||||
if pCtx, ok := p.(pongo2.Context); ok {
|
||||
return pCtx
|
||||
}
|
||||
}
|
||||
pCtx := make(pongo2.Context)
|
||||
ctx.Set("pongo2", pCtx)
|
||||
return pCtx
|
||||
}
|
||||
206
internal/repository/auth_token.go
Normal file
206
internal/repository/auth_token.go
Normal file
@@ -0,0 +1,206 @@
|
||||
// Filename: internal/repository/auth_token.go
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"gemini-balancer/internal/crypto"
|
||||
"gemini-balancer/internal/models"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AuthTokenRepository defines the interface for AuthToken data access.
|
||||
type AuthTokenRepository interface {
|
||||
GetAllTokensWithGroups() ([]*models.AuthToken, error)
|
||||
BatchUpdateTokens(updates []*models.TokenUpdateRequest) error
|
||||
GetTokenByHashedValue(tokenHash string) (*models.AuthToken, error) // <-- Add this line
|
||||
SeedAdminToken(encryptedToken, tokenHash string) error // <-- And this line for the seeder
|
||||
}
|
||||
|
||||
type gormAuthTokenRepository struct {
|
||||
db *gorm.DB
|
||||
crypto *crypto.Service
|
||||
logger *logrus.Entry
|
||||
}
|
||||
|
||||
func NewAuthTokenRepository(db *gorm.DB, crypto *crypto.Service, logger *logrus.Logger) AuthTokenRepository {
|
||||
return &gormAuthTokenRepository{
|
||||
db: db,
|
||||
crypto: crypto,
|
||||
logger: logger.WithField("component", "repository.authToken🔐"),
|
||||
}
|
||||
}
|
||||
|
||||
// GetAllTokensWithGroups fetches all tokens and decrypts them for use in services.
|
||||
func (r *gormAuthTokenRepository) GetAllTokensWithGroups() ([]*models.AuthToken, error) {
|
||||
var tokens []*models.AuthToken
|
||||
if err := r.db.Preload("AllowedGroups").Find(&tokens).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// [CRITICAL] Decrypt all tokens before returning them.
|
||||
if err := r.decryptTokens(tokens); err != nil {
|
||||
// Log the error but return the partially decrypted data, as some might be usable.
|
||||
r.logger.WithError(err).Error("Batch decryption failed for some auth tokens.")
|
||||
}
|
||||
return tokens, nil
|
||||
}
|
||||
|
||||
// BatchUpdateTokens provides a transactional way to update all tokens, handling encryption.
|
||||
func (r *gormAuthTokenRepository) BatchUpdateTokens(updates []*models.TokenUpdateRequest) error {
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
// 1. Separate admin and user tokens from the request
|
||||
var adminUpdate *models.TokenUpdateRequest
|
||||
var userUpdates []*models.TokenUpdateRequest
|
||||
for _, u := range updates {
|
||||
if u.IsAdmin {
|
||||
adminUpdate = u
|
||||
} else {
|
||||
userUpdates = append(userUpdates, u)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Handle Admin Token Update
|
||||
if adminUpdate != nil && adminUpdate.Token != "" {
|
||||
encryptedToken, err := r.crypto.Encrypt(adminUpdate.Token)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt admin token: %w", err)
|
||||
}
|
||||
hash := sha256.Sum256([]byte(adminUpdate.Token))
|
||||
tokenHash := hex.EncodeToString(hash[:])
|
||||
|
||||
// Update both encrypted value and the hash
|
||||
updateData := map[string]interface{}{
|
||||
"encrypted_token": encryptedToken,
|
||||
"token_hash": tokenHash,
|
||||
}
|
||||
if err := tx.Model(&models.AuthToken{}).Where("is_admin = ?", true).Updates(updateData).Error; err != nil {
|
||||
return fmt.Errorf("failed to update admin token in db: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Handle User Tokens Upsert
|
||||
var existingTokens []*models.AuthToken
|
||||
if err := tx.Where("is_admin = ?", false).Find(&existingTokens).Error; err != nil {
|
||||
return fmt.Errorf("failed to fetch existing user tokens: %w", err)
|
||||
}
|
||||
existingTokenMap := make(map[uint]bool)
|
||||
for _, t := range existingTokens {
|
||||
existingTokenMap[t.ID] = true
|
||||
}
|
||||
|
||||
var tokensToUpsert []models.AuthToken
|
||||
for _, req := range userUpdates {
|
||||
if req.Token == "" {
|
||||
continue // Skip tokens with empty values
|
||||
}
|
||||
encryptedToken, err := r.crypto.Encrypt(req.Token)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt token for upsert (ID: %d): %w", req.ID, err)
|
||||
}
|
||||
hash := sha256.Sum256([]byte(req.Token))
|
||||
tokenHash := hex.EncodeToString(hash[:])
|
||||
|
||||
var groups []*models.KeyGroup
|
||||
if len(req.AllowedGroupIDs) > 0 {
|
||||
if err := tx.Find(&groups, req.AllowedGroupIDs).Error; err != nil {
|
||||
return fmt.Errorf("failed to find key groups for token %d: %w", req.ID, err)
|
||||
}
|
||||
}
|
||||
tokensToUpsert = append(tokensToUpsert, models.AuthToken{
|
||||
ID: req.ID,
|
||||
EncryptedToken: encryptedToken,
|
||||
TokenHash: tokenHash,
|
||||
Description: req.Description,
|
||||
Tag: req.Tag,
|
||||
Status: req.Status,
|
||||
IsAdmin: false,
|
||||
AllowedGroups: groups,
|
||||
})
|
||||
}
|
||||
if len(tokensToUpsert) > 0 {
|
||||
if err := tx.Save(&tokensToUpsert).Error; err != nil {
|
||||
return fmt.Errorf("failed to upsert user tokens: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Handle Deletions
|
||||
incomingUserTokenIDs := make(map[uint]bool)
|
||||
for _, u := range userUpdates {
|
||||
if u.ID != 0 {
|
||||
incomingUserTokenIDs[u.ID] = true
|
||||
}
|
||||
}
|
||||
var idsToDelete []uint
|
||||
for id := range existingTokenMap {
|
||||
if !incomingUserTokenIDs[id] {
|
||||
idsToDelete = append(idsToDelete, id)
|
||||
}
|
||||
}
|
||||
if len(idsToDelete) > 0 {
|
||||
if err := tx.Model(&models.AuthToken{}).Where("id IN ?", idsToDelete).Association("AllowedGroups").Clear(); err != nil {
|
||||
return fmt.Errorf("failed to clear associations for tokens to be deleted: %w", err)
|
||||
}
|
||||
if err := tx.Where("id IN ?", idsToDelete).Delete(&models.AuthToken{}).Error; err != nil {
|
||||
return fmt.Errorf("failed to delete user tokens: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// --- Crypto Helper Functions ---
|
||||
|
||||
func (r *gormAuthTokenRepository) decryptToken(token *models.AuthToken) error {
|
||||
if token == nil || token.EncryptedToken == "" || token.Token != "" {
|
||||
return nil // Nothing to decrypt or already done
|
||||
}
|
||||
plaintext, err := r.crypto.Decrypt(token.EncryptedToken)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decrypt auth token ID %d: %w", token.ID, err)
|
||||
}
|
||||
token.Token = plaintext
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *gormAuthTokenRepository) decryptTokens(tokens []*models.AuthToken) error {
|
||||
for i := range tokens {
|
||||
if err := r.decryptToken(tokens[i]); err != nil {
|
||||
r.logger.Error(err) // Log error but continue for other tokens
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetTokenByHashedValue finds a token by its SHA256 hash for authentication.
|
||||
func (r *gormAuthTokenRepository) GetTokenByHashedValue(tokenHash string) (*models.AuthToken, error) {
|
||||
var authToken models.AuthToken
|
||||
// Find the active token by its hash. This is the core of our secure authentication.
|
||||
err := r.db.Where("token_hash = ? AND status = 'active'", tokenHash).Preload("AllowedGroups").First(&authToken).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// [CRITICAL] Decrypt the token before returning it to the service layer.
|
||||
// This ensures that subsequent logic (like in ResourceService) gets the full, usable object.
|
||||
if err := r.decryptToken(&authToken); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &authToken, nil
|
||||
}
|
||||
|
||||
// SeedAdminToken is a special-purpose function for the seeder to insert the initial admin token.
|
||||
func (r *gormAuthTokenRepository) SeedAdminToken(encryptedToken, tokenHash string) error {
|
||||
adminToken := models.AuthToken{
|
||||
EncryptedToken: encryptedToken,
|
||||
TokenHash: tokenHash,
|
||||
Description: "Default Administrator Token",
|
||||
Tag: "SYSTEM_ADMIN",
|
||||
IsAdmin: true,
|
||||
Status: "active", // Ensure the seeded token is active
|
||||
}
|
||||
// Using FirstOrCreate to be idempotent. If an admin token already exists, it does nothing.
|
||||
return r.db.Where(models.AuthToken{IsAdmin: true}).FirstOrCreate(&adminToken).Error
|
||||
}
|
||||
37
internal/repository/group_repository.go
Normal file
37
internal/repository/group_repository.go
Normal file
@@ -0,0 +1,37 @@
|
||||
// Filename: internal/repository/group_repository.go
|
||||
package repository
|
||||
|
||||
import (
|
||||
"gemini-balancer/internal/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func (r *gormGroupRepository) GetGroupByName(name string) (*models.KeyGroup, error) {
|
||||
var group models.KeyGroup
|
||||
if err := r.db.Where("name = ?", name).First(&group).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &group, nil
|
||||
}
|
||||
|
||||
func (r *gormGroupRepository) GetAllGroups() ([]*models.KeyGroup, error) {
|
||||
var groups []*models.KeyGroup
|
||||
if err := r.db.Order("\"order\" asc, id desc").Find(&groups).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
// 更新group排序
|
||||
func (r *gormGroupRepository) UpdateOrderInTransaction(orders map[uint]int) error {
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
for id, order := range orders {
|
||||
result := tx.Model(&models.KeyGroup{}).Where("id = ?", id).Update("order", order)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
204
internal/repository/key_cache.go
Normal file
204
internal/repository/key_cache.go
Normal file
@@ -0,0 +1,204 @@
|
||||
// Filename: internal/repository/key_cache.go
|
||||
package repository
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gemini-balancer/internal/models"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
const (
|
||||
KeyGroup = "group:%d:keys:active"
|
||||
KeyDetails = "key:%d:details"
|
||||
KeyMapping = "mapping:%d:%d"
|
||||
KeyGroupSequential = "group:%d:keys:sequential"
|
||||
KeyGroupLRU = "group:%d:keys:lru"
|
||||
KeyGroupRandomMain = "group:%d:keys:random:main"
|
||||
KeyGroupRandomCooldown = "group:%d:keys:random:cooldown"
|
||||
BasePoolSequential = "basepool:%s:keys:sequential"
|
||||
BasePoolLRU = "basepool:%s:keys:lru"
|
||||
BasePoolRandomMain = "basepool:%s:keys:random:main"
|
||||
BasePoolRandomCooldown = "basepool:%s:keys:random:cooldown"
|
||||
)
|
||||
|
||||
func (r *gormKeyRepository) LoadAllKeysToStore() error {
|
||||
r.logger.Info("Starting to load all keys and associations into cache, including polling structures...")
|
||||
var allMappings []*models.GroupAPIKeyMapping
|
||||
if err := r.db.Preload("APIKey").Find(&allMappings).Error; err != nil {
|
||||
return fmt.Errorf("failed to load all mappings with APIKeys from DB: %w", err)
|
||||
}
|
||||
|
||||
keyMap := make(map[uint]*models.APIKey)
|
||||
for _, m := range allMappings {
|
||||
if m.APIKey != nil {
|
||||
keyMap[m.APIKey.ID] = m.APIKey
|
||||
}
|
||||
}
|
||||
keysToDecrypt := make([]models.APIKey, 0, len(keyMap))
|
||||
for _, k := range keyMap {
|
||||
keysToDecrypt = append(keysToDecrypt, *k)
|
||||
}
|
||||
if err := r.decryptKeys(keysToDecrypt); err != nil {
|
||||
r.logger.WithError(err).Error("Critical error during cache preload: batch decryption failed.")
|
||||
}
|
||||
decryptedKeyMap := make(map[uint]models.APIKey)
|
||||
for _, k := range keysToDecrypt {
|
||||
decryptedKeyMap[k.ID] = k
|
||||
}
|
||||
|
||||
activeKeysByGroup := make(map[uint][]*models.GroupAPIKeyMapping)
|
||||
pipe := r.store.Pipeline()
|
||||
detailsToSet := make(map[string][]byte)
|
||||
var allGroups []*models.KeyGroup
|
||||
if err := r.db.Find(&allGroups).Error; err == nil {
|
||||
for _, group := range allGroups {
|
||||
pipe.Del(
|
||||
fmt.Sprintf(KeyGroup, group.ID),
|
||||
fmt.Sprintf(KeyGroupSequential, group.ID),
|
||||
fmt.Sprintf(KeyGroupLRU, group.ID),
|
||||
fmt.Sprintf(KeyGroupRandomMain, group.ID),
|
||||
fmt.Sprintf(KeyGroupRandomCooldown, group.ID),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
r.logger.WithError(err).Error("Failed to get all groups for cache cleanup")
|
||||
}
|
||||
|
||||
for _, mapping := range allMappings {
|
||||
if mapping.APIKey == nil {
|
||||
continue
|
||||
}
|
||||
decryptedKey, ok := decryptedKeyMap[mapping.APIKeyID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
keyJSON, _ := json.Marshal(decryptedKey)
|
||||
detailsToSet[fmt.Sprintf(KeyDetails, decryptedKey.ID)] = keyJSON
|
||||
mappingJSON, _ := json.Marshal(mapping)
|
||||
detailsToSet[fmt.Sprintf(KeyMapping, mapping.KeyGroupID, decryptedKey.ID)] = mappingJSON
|
||||
if mapping.Status == models.StatusActive {
|
||||
activeKeysByGroup[mapping.KeyGroupID] = append(activeKeysByGroup[mapping.KeyGroupID], mapping)
|
||||
}
|
||||
}
|
||||
|
||||
for groupID, activeMappings := range activeKeysByGroup {
|
||||
if len(activeMappings) == 0 {
|
||||
continue
|
||||
}
|
||||
var activeKeyIDs []interface{}
|
||||
lruMembers := make(map[string]float64)
|
||||
for _, mapping := range activeMappings {
|
||||
keyIDStr := strconv.FormatUint(uint64(mapping.APIKeyID), 10)
|
||||
activeKeyIDs = append(activeKeyIDs, keyIDStr)
|
||||
var score float64
|
||||
if mapping.LastUsedAt != nil {
|
||||
score = float64(mapping.LastUsedAt.UnixMilli())
|
||||
}
|
||||
lruMembers[keyIDStr] = score
|
||||
}
|
||||
pipe.SAdd(fmt.Sprintf(KeyGroup, groupID), activeKeyIDs...)
|
||||
pipe.LPush(fmt.Sprintf(KeyGroupSequential, groupID), activeKeyIDs...)
|
||||
pipe.SAdd(fmt.Sprintf(KeyGroupRandomMain, groupID), activeKeyIDs...)
|
||||
go r.store.ZAdd(fmt.Sprintf(KeyGroupLRU, groupID), lruMembers)
|
||||
}
|
||||
|
||||
if err := pipe.Exec(); err != nil {
|
||||
return fmt.Errorf("failed to execute pipeline for cache rebuild: %w", err)
|
||||
}
|
||||
for key, value := range detailsToSet {
|
||||
if err := r.store.Set(key, value, 0); err != nil {
|
||||
r.logger.WithError(err).Warnf("Failed to set key detail in cache: %s", key)
|
||||
}
|
||||
}
|
||||
|
||||
r.logger.Info("Cache rebuild complete, including all polling structures.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *gormKeyRepository) updateStoreCacheForKey(key *models.APIKey) error {
|
||||
if err := r.decryptKey(key); err != nil {
|
||||
return fmt.Errorf("failed to decrypt key %d for cache update: %w", key.ID, err)
|
||||
}
|
||||
keyJSON, err := json.Marshal(key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal key %d for cache update: %w", key.ID, err)
|
||||
}
|
||||
return r.store.Set(fmt.Sprintf(KeyDetails, key.ID), keyJSON, 0)
|
||||
}
|
||||
|
||||
func (r *gormKeyRepository) removeStoreCacheForKey(key *models.APIKey) error {
|
||||
groupIDs, err := r.GetGroupsForKey(key.ID)
|
||||
if err != nil {
|
||||
r.logger.Warnf("failed to get groups for key %d to clean up cache lists: %v", key.ID, err)
|
||||
}
|
||||
|
||||
pipe := r.store.Pipeline()
|
||||
pipe.Del(fmt.Sprintf(KeyDetails, key.ID))
|
||||
|
||||
for _, groupID := range groupIDs {
|
||||
pipe.Del(fmt.Sprintf(KeyMapping, groupID, key.ID))
|
||||
|
||||
keyIDStr := strconv.FormatUint(uint64(key.ID), 10)
|
||||
pipe.SRem(fmt.Sprintf(KeyGroup, groupID), keyIDStr)
|
||||
pipe.LRem(fmt.Sprintf(KeyGroupSequential, groupID), 0, keyIDStr)
|
||||
pipe.SRem(fmt.Sprintf(KeyGroupRandomMain, groupID), keyIDStr)
|
||||
pipe.SRem(fmt.Sprintf(KeyGroupRandomCooldown, groupID), keyIDStr)
|
||||
go r.store.ZRem(fmt.Sprintf(KeyGroupLRU, groupID), keyIDStr)
|
||||
}
|
||||
return pipe.Exec()
|
||||
}
|
||||
|
||||
func (r *gormKeyRepository) updateStoreCacheForMapping(mapping *models.GroupAPIKeyMapping) error {
|
||||
pipe := r.store.Pipeline()
|
||||
activeKeyListKey := fmt.Sprintf("group:%d:keys:active", mapping.KeyGroupID)
|
||||
pipe.LRem(activeKeyListKey, 0, mapping.APIKeyID)
|
||||
if mapping.Status == models.StatusActive {
|
||||
pipe.LPush(activeKeyListKey, mapping.APIKeyID)
|
||||
}
|
||||
return pipe.Exec()
|
||||
}
|
||||
|
||||
func (r *gormKeyRepository) HandleCacheUpdateEventBatch(mappings []*models.GroupAPIKeyMapping) error {
|
||||
if len(mappings) == 0 {
|
||||
return nil
|
||||
}
|
||||
groupUpdates := make(map[uint]struct {
|
||||
ToAdd []interface{}
|
||||
ToRemove []interface{}
|
||||
})
|
||||
for _, mapping := range mappings {
|
||||
keyIDStr := strconv.FormatUint(uint64(mapping.APIKeyID), 10)
|
||||
update, ok := groupUpdates[mapping.KeyGroupID]
|
||||
if !ok {
|
||||
update = struct {
|
||||
ToAdd []interface{}
|
||||
ToRemove []interface{}
|
||||
}{}
|
||||
}
|
||||
if mapping.Status == models.StatusActive {
|
||||
update.ToRemove = append(update.ToRemove, keyIDStr)
|
||||
update.ToAdd = append(update.ToAdd, keyIDStr)
|
||||
} else {
|
||||
update.ToRemove = append(update.ToRemove, keyIDStr)
|
||||
}
|
||||
groupUpdates[mapping.KeyGroupID] = update
|
||||
}
|
||||
pipe := r.store.Pipeline()
|
||||
var pipelineError error
|
||||
for groupID, updates := range groupUpdates {
|
||||
activeKeyListKey := fmt.Sprintf("group:%d:keys:active", groupID)
|
||||
if len(updates.ToRemove) > 0 {
|
||||
for _, keyID := range updates.ToRemove {
|
||||
pipe.LRem(activeKeyListKey, 0, keyID)
|
||||
}
|
||||
}
|
||||
if len(updates.ToAdd) > 0 {
|
||||
pipe.LPush(activeKeyListKey, updates.ToAdd...)
|
||||
}
|
||||
}
|
||||
if err := pipe.Exec(); err != nil {
|
||||
pipelineError = fmt.Errorf("redis pipeline execution failed: %w", err)
|
||||
}
|
||||
return pipelineError
|
||||
}
|
||||
280
internal/repository/key_crud.go
Normal file
280
internal/repository/key_crud.go
Normal file
@@ -0,0 +1,280 @@
|
||||
// Filename: internal/repository/key_crud.go
|
||||
package repository
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"gemini-balancer/internal/models"
|
||||
|
||||
"math/rand"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
func (r *gormKeyRepository) AddKeys(keys []models.APIKey) ([]models.APIKey, error) {
|
||||
if len(keys) == 0 {
|
||||
return []models.APIKey{}, nil
|
||||
}
|
||||
keyHashes := make([]string, len(keys))
|
||||
keyValueToHashMap := make(map[string]string)
|
||||
for i, k := range keys {
|
||||
// All incoming keys must have plaintext APIKey
|
||||
if k.APIKey == "" {
|
||||
return nil, fmt.Errorf("cannot add key at index %d: plaintext APIKey is empty", i)
|
||||
}
|
||||
hash := sha256.Sum256([]byte(k.APIKey))
|
||||
hashStr := hex.EncodeToString(hash[:])
|
||||
keyHashes[i] = hashStr
|
||||
keyValueToHashMap[k.APIKey] = hashStr
|
||||
}
|
||||
var finalKeys []models.APIKey
|
||||
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||
var existingKeys []models.APIKey
|
||||
// [MODIFIED] Query by hash to find existing keys.
|
||||
if err := tx.Unscoped().Where("api_key_hash IN ?", keyHashes).Find(&existingKeys).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
existingKeyHashMap := make(map[string]models.APIKey)
|
||||
for _, k := range existingKeys {
|
||||
existingKeyHashMap[k.APIKeyHash] = k
|
||||
}
|
||||
var keysToCreate []models.APIKey
|
||||
var keysToRestore []uint
|
||||
for _, keyObj := range keys {
|
||||
keyVal := keyObj.APIKey
|
||||
hash := keyValueToHashMap[keyVal]
|
||||
if ek, found := existingKeyHashMap[hash]; found {
|
||||
if ek.DeletedAt.Valid {
|
||||
keysToRestore = append(keysToRestore, ek.ID)
|
||||
}
|
||||
} else {
|
||||
encryptedKey, err := r.crypto.Encrypt(keyVal)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt key '%s...': %w", keyVal[:min(4, len(keyVal))], err)
|
||||
}
|
||||
keysToCreate = append(keysToCreate, models.APIKey{
|
||||
EncryptedKey: encryptedKey,
|
||||
APIKeyHash: hash,
|
||||
})
|
||||
}
|
||||
}
|
||||
if len(keysToRestore) > 0 {
|
||||
if err := tx.Model(&models.APIKey{}).Unscoped().Where("id IN ?", keysToRestore).Update("deleted_at", nil).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(keysToCreate) > 0 {
|
||||
// [MODIFIED] Create now only provides encrypted data and hash.
|
||||
if err := tx.Clauses(clause.OnConflict{DoNothing: true}, clause.Returning{}).Create(&keysToCreate).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// [MODIFIED] Final select uses hashes to retrieve all relevant keys.
|
||||
if err := tx.Where("api_key_hash IN ?", keyHashes).Find(&finalKeys).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// [CRITICAL] Decrypt all keys before returning them to the service layer.
|
||||
return r.decryptKeys(finalKeys)
|
||||
})
|
||||
return finalKeys, err
|
||||
}
|
||||
|
||||
func (r *gormKeyRepository) Update(key *models.APIKey) error {
|
||||
// [CRITICAL] Before saving, check if the plaintext APIKey field was populated.
|
||||
// This indicates a potential change that needs to be re-encrypted.
|
||||
if key.APIKey != "" {
|
||||
encryptedKey, err := r.crypto.Encrypt(key.APIKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to re-encrypt key on update for ID %d: %w", key.ID, err)
|
||||
}
|
||||
key.EncryptedKey = encryptedKey
|
||||
// Recalculate hash as a defensive measure.
|
||||
hash := sha256.Sum256([]byte(key.APIKey))
|
||||
key.APIKeyHash = hex.EncodeToString(hash[:])
|
||||
}
|
||||
err := r.executeTransactionWithRetry(func(tx *gorm.DB) error {
|
||||
// GORM automatically ignores `key.APIKey` because of the `gorm:"-"` tag.
|
||||
return tx.Save(key).Error
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// For the cache update, we need the plaintext. Decrypt if it's not already populated.
|
||||
if err := r.decryptKey(key); err != nil {
|
||||
r.logger.Warnf("DB updated key ID %d, but decryption for cache failed: %v", key.ID, err)
|
||||
return nil // Continue without cache update if decryption fails.
|
||||
}
|
||||
if err := r.updateStoreCacheForKey(key); err != nil {
|
||||
r.logger.Warnf("DB updated key ID %d, but cache update failed: %v", key.ID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *gormKeyRepository) HardDeleteByID(id uint) error {
|
||||
key, err := r.GetKeyByID(id) // This now returns a decrypted key
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = r.executeTransactionWithRetry(func(tx *gorm.DB) error {
|
||||
return tx.Unscoped().Delete(&models.APIKey{}, id).Error
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.removeStoreCacheForKey(key); err != nil {
|
||||
r.logger.Warnf("DB deleted key ID %d, but cache removal failed: %v", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *gormKeyRepository) HardDeleteByValues(keyValues []string) (int64, error) {
|
||||
if len(keyValues) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
hashes := make([]string, len(keyValues))
|
||||
for i, v := range keyValues {
|
||||
hash := sha256.Sum256([]byte(v))
|
||||
hashes[i] = hex.EncodeToString(hash[:])
|
||||
}
|
||||
// Find the full key objects first to update the cache later.
|
||||
var keysToDelete []models.APIKey
|
||||
// [MODIFIED] Find by hash.
|
||||
if err := r.db.Where("api_key_hash IN ?", hashes).Find(&keysToDelete).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(keysToDelete) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
// Decrypt them to ensure cache has plaintext if needed.
|
||||
if err := r.decryptKeys(keysToDelete); err != nil {
|
||||
r.logger.Warnf("Decryption failed for keys before hard delete, cache removal may be impacted: %v", err)
|
||||
}
|
||||
var deletedCount int64
|
||||
err := r.executeTransactionWithRetry(func(tx *gorm.DB) error {
|
||||
ids := pluckIDs(keysToDelete)
|
||||
result := tx.Unscoped().Where("id IN ?", ids).Delete(&models.APIKey{})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
deletedCount = result.RowsAffected
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for i := range keysToDelete {
|
||||
if err := r.removeStoreCacheForKey(&keysToDelete[i]); err != nil {
|
||||
r.logger.Warnf("DB deleted key ID %d, but cache removal failed: %v", keysToDelete[i].ID, err)
|
||||
}
|
||||
}
|
||||
return deletedCount, nil
|
||||
}
|
||||
|
||||
func (r *gormKeyRepository) GetKeyByID(id uint) (*models.APIKey, error) {
|
||||
var key models.APIKey
|
||||
if err := r.db.First(&key, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.decryptKey(&key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &key, nil
|
||||
}
|
||||
|
||||
func (r *gormKeyRepository) GetKeysByIDs(ids []uint) ([]models.APIKey, error) {
|
||||
if len(ids) == 0 {
|
||||
return []models.APIKey{}, nil
|
||||
}
|
||||
var keys []models.APIKey
|
||||
err := r.db.Where("id IN ?", ids).Find(&keys).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// [CRITICAL] Decrypt before returning.
|
||||
return keys, r.decryptKeys(keys)
|
||||
}
|
||||
|
||||
func (r *gormKeyRepository) GetKeyByValue(keyValue string) (*models.APIKey, error) {
|
||||
hash := sha256.Sum256([]byte(keyValue))
|
||||
hashStr := hex.EncodeToString(hash[:])
|
||||
var key models.APIKey
|
||||
if err := r.db.Where("api_key_hash = ?", hashStr).First(&key).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key.APIKey = keyValue
|
||||
return &key, nil
|
||||
}
|
||||
|
||||
func (r *gormKeyRepository) GetKeysByValues(keyValues []string) ([]models.APIKey, error) {
|
||||
if len(keyValues) == 0 {
|
||||
return []models.APIKey{}, nil
|
||||
}
|
||||
hashes := make([]string, len(keyValues))
|
||||
for i, v := range keyValues {
|
||||
hash := sha256.Sum256([]byte(v))
|
||||
hashes[i] = hex.EncodeToString(hash[:])
|
||||
}
|
||||
var keys []models.APIKey
|
||||
err := r.db.Where("api_key_hash IN ?", hashes).Find(&keys).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return keys, r.decryptKeys(keys)
|
||||
}
|
||||
|
||||
func (r *gormKeyRepository) GetKeysByGroup(groupID uint) ([]models.APIKey, error) {
|
||||
var keys []models.APIKey
|
||||
err := r.db.Joins("JOIN group_api_key_mappings on group_api_key_mappings.api_key_id = api_keys.id").
|
||||
Where("group_api_key_mappings.key_group_id = ?", groupID).
|
||||
Find(&keys).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return keys, r.decryptKeys(keys)
|
||||
}
|
||||
|
||||
func (r *gormKeyRepository) CountByGroup(groupID uint) (int64, error) {
|
||||
var count int64
|
||||
err := r.db.Model(&models.APIKey{}).
|
||||
Joins("JOIN group_api_key_mappings on group_api_key_mappings.api_key_id = api_keys.id").
|
||||
Where("group_api_key_mappings.key_group_id = ?", groupID).
|
||||
Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
func (r *gormKeyRepository) executeTransactionWithRetry(operation func(tx *gorm.DB) error) error {
|
||||
const maxRetries = 3
|
||||
const baseDelay = 50 * time.Millisecond
|
||||
const maxJitter = 150 * time.Millisecond
|
||||
var err error
|
||||
|
||||
for i := 0; i < maxRetries; i++ {
|
||||
err = r.db.Transaction(operation)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if strings.Contains(err.Error(), "database is locked") {
|
||||
jitter := time.Duration(rand.Intn(int(maxJitter)))
|
||||
totalDelay := baseDelay + jitter
|
||||
r.logger.Debugf("Database is locked, retrying in %v... (attempt %d/%d)", totalDelay, i+1, maxRetries)
|
||||
time.Sleep(totalDelay)
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func pluckIDs(keys []models.APIKey) []uint {
|
||||
ids := make([]uint, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
ids = append(ids, key.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
62
internal/repository/key_crypto.go
Normal file
62
internal/repository/key_crypto.go
Normal file
@@ -0,0 +1,62 @@
|
||||
// Filename: internal/repository/key_crypto.go
|
||||
package repository
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gemini-balancer/internal/models"
|
||||
)
|
||||
|
||||
func (r *gormKeyRepository) decryptKey(key *models.APIKey) error {
|
||||
if key == nil || key.EncryptedKey == "" {
|
||||
return nil // Nothing to decrypt
|
||||
}
|
||||
// Avoid re-decrypting if plaintext already exists
|
||||
if key.APIKey != "" {
|
||||
return nil
|
||||
}
|
||||
plaintext, err := r.crypto.Decrypt(key.EncryptedKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decrypt key ID %d: %w", key.ID, err)
|
||||
}
|
||||
key.APIKey = plaintext
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *gormKeyRepository) decryptKeys(keys []models.APIKey) error {
|
||||
for i := range keys {
|
||||
if err := r.decryptKey(&keys[i]); err != nil {
|
||||
// In a batch operation, we log the error but allow the rest to proceed.
|
||||
r.logger.Errorf("Batch decrypt error for key index %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Decrypt 实现了 KeyRepository 接口
|
||||
func (r *gormKeyRepository) Decrypt(key *models.APIKey) error {
|
||||
if key == nil || len(key.EncryptedKey) == 0 {
|
||||
return nil // Nothing to decrypt
|
||||
}
|
||||
// Avoid re-decrypting if plaintext already exists
|
||||
if key.APIKey != "" {
|
||||
return nil
|
||||
}
|
||||
plaintext, err := r.crypto.Decrypt(key.EncryptedKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decrypt key ID %d: %w", key.ID, err)
|
||||
}
|
||||
key.APIKey = plaintext
|
||||
return nil
|
||||
}
|
||||
|
||||
// DecryptBatch 实现了 KeyRepository 接口
|
||||
func (r *gormKeyRepository) DecryptBatch(keys []models.APIKey) error {
|
||||
for i := range keys {
|
||||
// This delegates to the robust single-key decryption logic.
|
||||
if err := r.Decrypt(&keys[i]); err != nil {
|
||||
// In a batch operation, we log the error but allow the rest to proceed.
|
||||
r.logger.Errorf("Batch decrypt error for key index %d (ID: %d): %v", i, keys[i].ID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
169
internal/repository/key_maintenance.go
Normal file
169
internal/repository/key_maintenance.go
Normal file
@@ -0,0 +1,169 @@
|
||||
// Filename: internal/repository/key_maintenance.go
|
||||
package repository
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"gemini-balancer/internal/models"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func (r *gormKeyRepository) StreamKeysToWriter(groupID uint, statusFilter string, writer io.Writer) error {
|
||||
query := r.db.Model(&models.APIKey{}).
|
||||
Joins("JOIN group_api_key_mappings on group_api_key_mappings.api_key_id = api_keys.id").
|
||||
Where("group_api_key_mappings.key_group_id = ?", groupID)
|
||||
|
||||
if statusFilter != "" && statusFilter != "all" {
|
||||
query = query.Where("group_api_key_mappings.status = ?", statusFilter)
|
||||
}
|
||||
var batchKeys []models.APIKey
|
||||
return query.FindInBatches(&batchKeys, 1000, func(tx *gorm.DB, batch int) error {
|
||||
if err := r.decryptKeys(batchKeys); err != nil {
|
||||
r.logger.Errorf("Failed to decrypt batch %d for streaming: %v", batch, err)
|
||||
}
|
||||
for _, key := range batchKeys {
|
||||
if key.APIKey != "" {
|
||||
if _, err := writer.Write([]byte(key.APIKey + "\n")); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (r *gormKeyRepository) UpdateMasterStatusByValues(keyValues []string, newStatus models.MasterAPIKeyStatus) (int64, error) {
|
||||
if len(keyValues) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
hashes := make([]string, len(keyValues))
|
||||
for i, v := range keyValues {
|
||||
hash := sha256.Sum256([]byte(v))
|
||||
hashes[i] = hex.EncodeToString(hash[:])
|
||||
}
|
||||
var result *gorm.DB
|
||||
err := r.executeTransactionWithRetry(func(tx *gorm.DB) error {
|
||||
result = tx.Model(&models.APIKey{}).
|
||||
Where("api_key_hash IN ?", hashes).
|
||||
Update("master_status", newStatus)
|
||||
return result.Error
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected, nil
|
||||
}
|
||||
|
||||
func (r *gormKeyRepository) UpdateMasterStatusByID(keyID uint, newStatus models.MasterAPIKeyStatus) error {
|
||||
return r.executeTransactionWithRetry(func(tx *gorm.DB) error {
|
||||
result := tx.Model(&models.APIKey{}).
|
||||
Where("id = ?", keyID).
|
||||
Update("master_status", newStatus)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
// This ensures that if the key ID doesn't exist, we return a standard "not found" error.
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (r *gormKeyRepository) DeleteOrphanKeys() (int64, error) {
|
||||
var deletedCount int64
|
||||
err := r.executeTransactionWithRetry(func(tx *gorm.DB) error {
|
||||
count, err := r.deleteOrphanKeysLogic(tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
deletedCount = count
|
||||
return nil
|
||||
})
|
||||
return deletedCount, err
|
||||
}
|
||||
|
||||
func (r *gormKeyRepository) DeleteOrphanKeysTx(tx *gorm.DB) (int64, error) {
|
||||
return r.deleteOrphanKeysLogic(tx)
|
||||
}
|
||||
|
||||
func (r *gormKeyRepository) deleteOrphanKeysLogic(db *gorm.DB) (int64, error) {
|
||||
var orphanKeyIDs []uint
|
||||
err := db.Raw(`
|
||||
SELECT api_keys.id FROM api_keys
|
||||
LEFT JOIN group_api_key_mappings ON api_keys.id = group_api_key_mappings.api_key_id
|
||||
WHERE group_api_key_mappings.api_key_id IS NULL`).Scan(&orphanKeyIDs).Error
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if len(orphanKeyIDs) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
var keysToDelete []models.APIKey
|
||||
if err := db.Where("id IN ?", orphanKeyIDs).Find(&keysToDelete).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
result := db.Delete(&models.APIKey{}, orphanKeyIDs)
|
||||
//result := db.Unscoped().Delete(&models.APIKey{}, orphanKeyIDs)
|
||||
if result.Error != nil {
|
||||
return 0, result.Error
|
||||
}
|
||||
|
||||
for i := range keysToDelete {
|
||||
if err := r.removeStoreCacheForKey(&keysToDelete[i]); err != nil {
|
||||
r.logger.Warnf("DB deleted orphan key ID %d, but cache removal failed: %v", keysToDelete[i].ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
return result.RowsAffected, nil
|
||||
}
|
||||
|
||||
func (r *gormKeyRepository) HardDeleteSoftDeletedBefore(date time.Time) (int64, error) {
|
||||
result := r.db.Unscoped().Where("deleted_at < ?", date).Delete(&models.APIKey{})
|
||||
return result.RowsAffected, result.Error
|
||||
}
|
||||
|
||||
func (r *gormKeyRepository) GetActiveMasterKeys() ([]*models.APIKey, error) {
|
||||
var keys []*models.APIKey
|
||||
err := r.db.Where("master_status = ?", models.MasterStatusActive).Find(&keys).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, key := range keys {
|
||||
if err := r.decryptKey(key); err != nil {
|
||||
r.logger.Warnf("Failed to decrypt key ID %d during GetActiveMasterKeys: %v", key.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
func (r *gormKeyRepository) UpdateAPIKeyStatus(keyID uint, status models.MasterAPIKeyStatus) error {
|
||||
err := r.executeTransactionWithRetry(func(tx *gorm.DB) error {
|
||||
result := tx.Model(&models.APIKey{}).
|
||||
Where("id = ?", keyID).
|
||||
Update("master_status", status)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err == nil {
|
||||
r.logger.Infof("MasterStatus for key ID %d changed, triggering a full cache reload.", keyID)
|
||||
go func() {
|
||||
if err := r.LoadAllKeysToStore(); err != nil {
|
||||
r.logger.Errorf("Failed to reload cache after MasterStatus change for key ID %d: %v", keyID, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
return err
|
||||
}
|
||||
289
internal/repository/key_mapping.go
Normal file
289
internal/repository/key_mapping.go
Normal file
@@ -0,0 +1,289 @@
|
||||
// Filename: internal/repository/key_mapping.go
|
||||
package repository
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"gemini-balancer/internal/models"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
func (r *gormKeyRepository) LinkKeysToGroup(groupID uint, keyIDs []uint) error {
|
||||
if len(keyIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
var mappings []models.GroupAPIKeyMapping
|
||||
for _, keyID := range keyIDs {
|
||||
mappings = append(mappings, models.GroupAPIKeyMapping{
|
||||
KeyGroupID: groupID,
|
||||
APIKeyID: keyID,
|
||||
})
|
||||
}
|
||||
|
||||
err := r.executeTransactionWithRetry(func(tx *gorm.DB) error {
|
||||
return tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&mappings).Error
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, keyID := range keyIDs {
|
||||
r.store.SAdd(fmt.Sprintf("key:%d:groups", keyID), groupID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *gormKeyRepository) UnlinkKeysFromGroup(groupID uint, keyIDs []uint) (int64, error) {
|
||||
if len(keyIDs) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
var unlinkedCount int64
|
||||
err := r.executeTransactionWithRetry(func(tx *gorm.DB) error {
|
||||
result := tx.Table("group_api_key_mappings").
|
||||
Where("key_group_id = ? AND api_key_id IN ?", groupID, keyIDs).
|
||||
Delete(nil)
|
||||
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
unlinkedCount = result.RowsAffected
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
activeKeyListKey := fmt.Sprintf("group:%d:keys:active", groupID)
|
||||
for _, keyID := range keyIDs {
|
||||
r.store.SRem(fmt.Sprintf("key:%d:groups", keyID), groupID)
|
||||
r.store.LRem(activeKeyListKey, 0, strconv.Itoa(int(keyID)))
|
||||
}
|
||||
|
||||
return unlinkedCount, nil
|
||||
}
|
||||
|
||||
func (r *gormKeyRepository) GetGroupsForKey(keyID uint) ([]uint, error) {
|
||||
cacheKey := fmt.Sprintf("key:%d:groups", keyID)
|
||||
strGroupIDs, err := r.store.SMembers(cacheKey)
|
||||
if err != nil || len(strGroupIDs) == 0 {
|
||||
var groupIDs []uint
|
||||
dbErr := r.db.Table("group_api_key_mappings").Where("api_key_id = ?", keyID).Pluck("key_group_id", &groupIDs).Error
|
||||
if dbErr != nil {
|
||||
return nil, dbErr
|
||||
}
|
||||
if len(groupIDs) > 0 {
|
||||
var interfaceSlice []interface{}
|
||||
for _, id := range groupIDs {
|
||||
interfaceSlice = append(interfaceSlice, id)
|
||||
}
|
||||
r.store.SAdd(cacheKey, interfaceSlice...)
|
||||
}
|
||||
return groupIDs, nil
|
||||
}
|
||||
|
||||
var groupIDs []uint
|
||||
for _, strID := range strGroupIDs {
|
||||
id, _ := strconv.Atoi(strID)
|
||||
groupIDs = append(groupIDs, uint(id))
|
||||
}
|
||||
return groupIDs, nil
|
||||
}
|
||||
|
||||
func (r *gormKeyRepository) GetMapping(groupID, keyID uint) (*models.GroupAPIKeyMapping, error) {
|
||||
var mapping models.GroupAPIKeyMapping
|
||||
err := r.db.Where("key_group_id = ? AND api_key_id = ?", groupID, keyID).First(&mapping).Error
|
||||
return &mapping, err
|
||||
}
|
||||
|
||||
func (r *gormKeyRepository) UpdateMapping(mapping *models.GroupAPIKeyMapping) error {
|
||||
err := r.executeTransactionWithRetry(func(tx *gorm.DB) error {
|
||||
return tx.Save(mapping).Error
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return r.updateStoreCacheForMapping(mapping)
|
||||
}
|
||||
|
||||
// [MODIFIED & FINAL] This is the final version for the core refactoring.
|
||||
func (r *gormKeyRepository) GetPaginatedKeysAndMappingsByGroup(params *models.APIKeyQueryParams) ([]*models.APIKeyDetails, int64, error) {
|
||||
items := make([]*models.APIKeyDetails, 0)
|
||||
var total int64
|
||||
|
||||
query := r.db.Table("api_keys").
|
||||
Select(`
|
||||
api_keys.id, api_keys.created_at, api_keys.updated_at,
|
||||
api_keys.encrypted_key, -- Select encrypted key to be scanned into APIKeyDetails.EncryptedKey
|
||||
api_keys.master_status,
|
||||
m.status, m.last_error, m.consecutive_error_count, m.last_used_at, m.cooldown_until
|
||||
`).
|
||||
Joins("JOIN group_api_key_mappings as m ON m.api_key_id = api_keys.id")
|
||||
|
||||
if params.KeyGroupID <= 0 {
|
||||
return nil, 0, errors.New("KeyGroupID is required for this query")
|
||||
}
|
||||
query = query.Where("m.key_group_id = ?", params.KeyGroupID)
|
||||
|
||||
if params.Status != "" {
|
||||
query = query.Where("LOWER(m.status) = LOWER(?)", params.Status)
|
||||
}
|
||||
|
||||
// Keyword search is now handled by the service layer.
|
||||
if params.Keyword != "" {
|
||||
r.logger.Warn("DB query is ignoring keyword; service layer will perform in-memory filtering.")
|
||||
}
|
||||
|
||||
countQuery := query.Model(&models.APIKey{}) // Use model for count to avoid GORM issues
|
||||
err := countQuery.Count(&total).Error
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if total == 0 {
|
||||
return items, 0, nil
|
||||
}
|
||||
|
||||
offset := (params.Page - 1) * params.PageSize
|
||||
err = query.Order("api_keys.id DESC").Limit(params.PageSize).Offset(offset).Scan(&items).Error
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// Decrypt all results before returning. This loop is now valid.
|
||||
for i := range items {
|
||||
if items[i].EncryptedKey != "" {
|
||||
plaintext, err := r.crypto.Decrypt(items[i].EncryptedKey)
|
||||
if err == nil {
|
||||
items[i].APIKey = plaintext
|
||||
} else {
|
||||
items[i].APIKey = "[DECRYPTION FAILED]"
|
||||
r.logger.Errorf("Failed to decrypt key ID %d for pagination: %v", items[i].ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
// [MODIFIED & FINAL] Uses hashes for lookup.
|
||||
func (r *gormKeyRepository) GetKeysByValuesAndGroupID(values []string, groupID uint) ([]models.APIKey, error) {
|
||||
if len(values) == 0 {
|
||||
return []models.APIKey{}, nil
|
||||
}
|
||||
hashes := make([]string, len(values))
|
||||
for i, v := range values {
|
||||
hash := sha256.Sum256([]byte(v))
|
||||
hashes[i] = hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
var keys []models.APIKey
|
||||
err := r.db.Joins("JOIN group_api_key_mappings ON group_api_key_mappings.api_key_id = api_keys.id").
|
||||
Where("group_api_key_mappings.key_group_id = ?", groupID).
|
||||
Where("api_keys.api_key_hash IN ?", hashes).
|
||||
Find(&keys).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return keys, r.decryptKeys(keys)
|
||||
}
|
||||
|
||||
// [MODIFIED & FINAL] Fetches full objects, decrypts, then extracts strings.
|
||||
func (r *gormKeyRepository) FindKeyValuesByStatus(groupID uint, statuses []string) ([]string, error) {
|
||||
var keys []models.APIKey
|
||||
query := r.db.Table("api_keys").
|
||||
Select("api_keys.*").
|
||||
Joins("JOIN group_api_key_mappings as m ON m.api_key_id = api_keys.id").
|
||||
Where("m.key_group_id = ?", groupID)
|
||||
|
||||
if len(statuses) > 0 && !(len(statuses) == 1 && statuses[0] == "all") {
|
||||
lowerStatuses := make([]string, len(statuses))
|
||||
for i, s := range statuses {
|
||||
lowerStatuses[i] = strings.ToLower(s)
|
||||
}
|
||||
query = query.Where("LOWER(m.status) IN (?)", lowerStatuses)
|
||||
}
|
||||
|
||||
if err := query.Find(&keys).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.decryptKeys(keys); err != nil {
|
||||
return nil, fmt.Errorf("decryption failed during FindKeyValuesByStatus: %w", err)
|
||||
}
|
||||
|
||||
keyValues := make([]string, len(keys))
|
||||
for i, key := range keys {
|
||||
keyValues[i] = key.APIKey
|
||||
}
|
||||
return keyValues, nil
|
||||
}
|
||||
|
||||
// [MODIFIED & FINAL] Consistent with the new pattern.
|
||||
func (r *gormKeyRepository) GetKeyStringsByGroupAndStatus(groupID uint, statuses []string) ([]string, error) {
|
||||
var keys []models.APIKey
|
||||
query := r.db.Table("api_keys").
|
||||
Select("api_keys.*").
|
||||
Joins("JOIN group_api_key_mappings ON group_api_key_mappings.api_key_id = api_keys.id").
|
||||
Where("group_api_key_mappings.key_group_id = ?", groupID)
|
||||
|
||||
if len(statuses) > 0 {
|
||||
isAll := false
|
||||
for _, s := range statuses {
|
||||
if strings.ToLower(s) == "all" {
|
||||
isAll = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !isAll {
|
||||
lowerStatuses := make([]string, len(statuses))
|
||||
for i, s := range statuses {
|
||||
lowerStatuses[i] = strings.ToLower(s)
|
||||
}
|
||||
query = query.Where("LOWER(group_api_key_mappings.status) IN ?", lowerStatuses)
|
||||
}
|
||||
}
|
||||
|
||||
if err := query.Find(&keys).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.decryptKeys(keys); err != nil {
|
||||
return nil, fmt.Errorf("decryption failed during GetKeyStringsByGroupAndStatus: %w", err)
|
||||
}
|
||||
|
||||
keyStrings := make([]string, len(keys))
|
||||
for i, key := range keys {
|
||||
keyStrings[i] = key.APIKey
|
||||
}
|
||||
return keyStrings, nil
|
||||
}
|
||||
|
||||
// FindKeyIDsByStatus remains unchanged as it does not deal with key values.
|
||||
func (r *gormKeyRepository) FindKeyIDsByStatus(groupID uint, statuses []string) ([]uint, error) {
|
||||
var keyIDs []uint
|
||||
query := r.db.Table("group_api_key_mappings").
|
||||
Select("api_key_id").
|
||||
Where("key_group_id = ?", groupID)
|
||||
if len(statuses) > 0 && !(len(statuses) == 1 && statuses[0] == "all") {
|
||||
lowerStatuses := make([]string, len(statuses))
|
||||
for i, s := range statuses {
|
||||
lowerStatuses[i] = strings.ToLower(s)
|
||||
}
|
||||
query = query.Where("LOWER(status) IN (?)", lowerStatuses)
|
||||
}
|
||||
if err := query.Pluck("api_key_id", &keyIDs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return keyIDs, nil
|
||||
}
|
||||
|
||||
func (r *gormKeyRepository) UpdateMappingWithoutCache(mapping *models.GroupAPIKeyMapping) error {
|
||||
return r.executeTransactionWithRetry(func(tx *gorm.DB) error {
|
||||
return tx.Save(mapping).Error
|
||||
})
|
||||
}
|
||||
276
internal/repository/key_selector.go
Normal file
276
internal/repository/key_selector.go
Normal file
@@ -0,0 +1,276 @@
|
||||
// Filename: internal/repository/key_selector.go
|
||||
package repository
|
||||
|
||||
import (
|
||||
"crypto/sha1"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"gemini-balancer/internal/models"
|
||||
"gemini-balancer/internal/store"
|
||||
"io"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
CacheTTL = 5 * time.Minute
|
||||
EmptyPoolPlaceholder = "EMPTY_POOL"
|
||||
EmptyCacheTTL = 1 * time.Minute
|
||||
)
|
||||
|
||||
// SelectOneActiveKey 根据指定的轮询策略,从缓存中高效地选取一个可用的API密钥。
|
||||
|
||||
func (r *gormKeyRepository) SelectOneActiveKey(group *models.KeyGroup) (*models.APIKey, *models.GroupAPIKeyMapping, error) {
|
||||
var keyIDStr string
|
||||
var err error
|
||||
|
||||
switch group.PollingStrategy {
|
||||
case models.StrategySequential:
|
||||
sequentialKey := fmt.Sprintf(KeyGroupSequential, group.ID)
|
||||
keyIDStr, err = r.store.Rotate(sequentialKey)
|
||||
|
||||
case models.StrategyWeighted:
|
||||
lruKey := fmt.Sprintf(KeyGroupLRU, group.ID)
|
||||
results, zerr := r.store.ZRange(lruKey, 0, 0)
|
||||
if zerr == nil && len(results) > 0 {
|
||||
keyIDStr = results[0]
|
||||
}
|
||||
err = zerr
|
||||
|
||||
case models.StrategyRandom:
|
||||
mainPoolKey := fmt.Sprintf(KeyGroupRandomMain, group.ID)
|
||||
cooldownPoolKey := fmt.Sprintf(KeyGroupRandomCooldown, group.ID)
|
||||
keyIDStr, err = r.store.PopAndCycleSetMember(mainPoolKey, cooldownPoolKey)
|
||||
|
||||
default: // 默认或未指定策略时,使用基础的随机策略
|
||||
activeKeySetKey := fmt.Sprintf(KeyGroup, group.ID)
|
||||
keyIDStr, err = r.store.SRandMember(activeKeySetKey)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) || errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
r.logger.WithError(err).Errorf("Failed to select key for group %d with strategy %s", group.ID, group.PollingStrategy)
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if keyIDStr == "" {
|
||||
return nil, nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
|
||||
keyID, _ := strconv.ParseUint(keyIDStr, 10, 64)
|
||||
|
||||
apiKey, mapping, err := r.getKeyDetailsFromCache(uint(keyID), group.ID)
|
||||
if err != nil {
|
||||
r.logger.WithError(err).Warnf("Cache inconsistency: Failed to get details for selected key ID %d", keyID)
|
||||
// TODO 可以在此加入重试逻辑,再次调用 SelectOneActiveKey(group)
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if group.PollingStrategy == models.StrategyWeighted {
|
||||
go r.UpdateKeyUsageTimestamp(group.ID, uint(keyID))
|
||||
}
|
||||
|
||||
return apiKey, mapping, nil
|
||||
}
|
||||
|
||||
// SelectOneActiveKeyFromBasePool 为智能聚合模式设计的全新轮询器。
|
||||
func (r *gormKeyRepository) SelectOneActiveKeyFromBasePool(pool *BasePool) (*models.APIKey, *models.KeyGroup, error) {
|
||||
// 生成唯一的池ID,确保不同请求组合的轮询状态相互隔离
|
||||
poolID := generatePoolID(pool.CandidateGroups)
|
||||
log := r.logger.WithField("pool_id", poolID)
|
||||
|
||||
if err := r.ensureBasePoolCacheExists(pool, poolID); err != nil {
|
||||
log.WithError(err).Error("Failed to ensure BasePool cache exists.")
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
var keyIDStr string
|
||||
var err error
|
||||
|
||||
switch pool.PollingStrategy {
|
||||
case models.StrategySequential:
|
||||
sequentialKey := fmt.Sprintf(BasePoolSequential, poolID)
|
||||
keyIDStr, err = r.store.Rotate(sequentialKey)
|
||||
case models.StrategyWeighted:
|
||||
lruKey := fmt.Sprintf(BasePoolLRU, poolID)
|
||||
results, zerr := r.store.ZRange(lruKey, 0, 0)
|
||||
if zerr == nil && len(results) > 0 {
|
||||
keyIDStr = results[0]
|
||||
}
|
||||
err = zerr
|
||||
case models.StrategyRandom:
|
||||
mainPoolKey := fmt.Sprintf(BasePoolRandomMain, poolID)
|
||||
cooldownPoolKey := fmt.Sprintf(BasePoolRandomCooldown, poolID)
|
||||
keyIDStr, err = r.store.PopAndCycleSetMember(mainPoolKey, cooldownPoolKey)
|
||||
default: // 默认策略,应该在 ensureCache 中处理,但作为降级方案
|
||||
log.Warnf("Default polling strategy triggered inside selection. This should be rare.")
|
||||
|
||||
sequentialKey := fmt.Sprintf(BasePoolSequential, poolID)
|
||||
keyIDStr, err = r.store.LIndex(sequentialKey, 0)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return nil, nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
log.WithError(err).Errorf("Failed to select key from BasePool with strategy %s", pool.PollingStrategy)
|
||||
return nil, nil, err
|
||||
}
|
||||
if keyIDStr == "" {
|
||||
return nil, nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
keyID, _ := strconv.ParseUint(keyIDStr, 10, 64)
|
||||
|
||||
for _, group := range pool.CandidateGroups {
|
||||
apiKey, mapping, cacheErr := r.getKeyDetailsFromCache(uint(keyID), group.ID)
|
||||
if cacheErr == nil && apiKey != nil && mapping != nil {
|
||||
|
||||
if pool.PollingStrategy == models.StrategyWeighted {
|
||||
|
||||
go r.updateKeyUsageTimestampForPool(poolID, uint(keyID))
|
||||
}
|
||||
return apiKey, group, nil
|
||||
}
|
||||
}
|
||||
|
||||
log.Errorf("Cache inconsistency: Selected KeyID %d from BasePool but could not find its origin group.", keyID)
|
||||
return nil, nil, errors.New("cache inconsistency: selected key has no origin group")
|
||||
}
|
||||
|
||||
// ensureBasePoolCacheExists 动态创建 BasePool 的 Redis 结构
|
||||
func (r *gormKeyRepository) ensureBasePoolCacheExists(pool *BasePool, poolID string) error {
|
||||
// 使用 LIST 键作为存在性检查的标志
|
||||
listKey := fmt.Sprintf(BasePoolSequential, poolID)
|
||||
exists, err := r.store.Exists(listKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
|
||||
val, err := r.store.LIndex(listKey, 0)
|
||||
if err == nil && val == EmptyPoolPlaceholder {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
r.logger.Infof("BasePool cache for pool_id '%s' not found. Building now...", poolID)
|
||||
|
||||
var allActiveKeyIDs []string
|
||||
lruMembers := make(map[string]float64)
|
||||
for _, group := range pool.CandidateGroups {
|
||||
activeKeySetKey := fmt.Sprintf(KeyGroup, group.ID)
|
||||
groupKeyIDs, err := r.store.SMembers(activeKeySetKey)
|
||||
if err != nil {
|
||||
r.logger.WithError(err).Warnf("Failed to get active keys for group %d during BasePool build", group.ID)
|
||||
continue
|
||||
}
|
||||
allActiveKeyIDs = append(allActiveKeyIDs, groupKeyIDs...)
|
||||
|
||||
for _, keyIDStr := range groupKeyIDs {
|
||||
keyID, _ := strconv.ParseUint(keyIDStr, 10, 64)
|
||||
_, mapping, err := r.getKeyDetailsFromCache(uint(keyID), group.ID)
|
||||
if err == nil && mapping != nil {
|
||||
var score float64
|
||||
if mapping.LastUsedAt != nil {
|
||||
score = float64(mapping.LastUsedAt.UnixMilli())
|
||||
}
|
||||
lruMembers[keyIDStr] = score
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(allActiveKeyIDs) == 0 {
|
||||
pipe := r.store.Pipeline()
|
||||
pipe.LPush(listKey, EmptyPoolPlaceholder)
|
||||
pipe.Expire(listKey, EmptyCacheTTL)
|
||||
if err := pipe.Exec(); err != nil {
|
||||
r.logger.WithError(err).Errorf("Failed to set empty pool placeholder for pool_id '%s'", poolID)
|
||||
}
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
// 使用管道填充所有轮询结构
|
||||
pipe := r.store.Pipeline()
|
||||
// 1. 顺序
|
||||
pipe.LPush(fmt.Sprintf(BasePoolSequential, poolID), toInterfaceSlice(allActiveKeyIDs)...)
|
||||
// 2. 随机
|
||||
pipe.SAdd(fmt.Sprintf(BasePoolRandomMain, poolID), toInterfaceSlice(allActiveKeyIDs)...)
|
||||
|
||||
// 设置合理的过期时间,例如5分钟,以防止孤儿数据
|
||||
pipe.Expire(fmt.Sprintf(BasePoolSequential, poolID), CacheTTL)
|
||||
pipe.Expire(fmt.Sprintf(BasePoolRandomMain, poolID), CacheTTL)
|
||||
pipe.Expire(fmt.Sprintf(BasePoolRandomCooldown, poolID), CacheTTL)
|
||||
pipe.Expire(fmt.Sprintf(BasePoolLRU, poolID), CacheTTL)
|
||||
|
||||
if err := pipe.Exec(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(lruMembers) > 0 {
|
||||
r.store.ZAdd(fmt.Sprintf(BasePoolLRU, poolID), lruMembers)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateKeyUsageTimestampForPool 更新 BasePool 的 LUR ZSET
|
||||
func (r *gormKeyRepository) updateKeyUsageTimestampForPool(poolID string, keyID uint) {
|
||||
lruKey := fmt.Sprintf(BasePoolLRU, poolID)
|
||||
r.store.ZAdd(lruKey, map[string]float64{
|
||||
strconv.FormatUint(uint64(keyID), 10): nowMilli(),
|
||||
})
|
||||
}
|
||||
|
||||
// generatePoolID 根据候选组ID列表生成一个稳定的、唯一的字符串ID
|
||||
func generatePoolID(groups []*models.KeyGroup) string {
|
||||
ids := make([]int, len(groups))
|
||||
for i, g := range groups {
|
||||
ids[i] = int(g.ID)
|
||||
}
|
||||
sort.Ints(ids)
|
||||
|
||||
h := sha1.New()
|
||||
io.WriteString(h, fmt.Sprintf("%v", ids))
|
||||
return fmt.Sprintf("%x", h.Sum(nil))
|
||||
}
|
||||
|
||||
// toInterfaceSlice 类型转换辅助函数
|
||||
func toInterfaceSlice(slice []string) []interface{} {
|
||||
result := make([]interface{}, len(slice))
|
||||
for i, v := range slice {
|
||||
result[i] = v
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// nowMilli 返回当前的Unix毫秒时间戳,用于LRU/Weighted策略
|
||||
func nowMilli() float64 {
|
||||
return float64(time.Now().UnixMilli())
|
||||
}
|
||||
|
||||
// getKeyDetailsFromCache 从缓存中获取Key和Mapping的JSON数据。
|
||||
func (r *gormKeyRepository) getKeyDetailsFromCache(keyID, groupID uint) (*models.APIKey, *models.GroupAPIKeyMapping, error) {
|
||||
apiKeyJSON, err := r.store.Get(fmt.Sprintf(KeyDetails, keyID))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to get key details for key %d: %w", keyID, err)
|
||||
}
|
||||
var apiKey models.APIKey
|
||||
if err := json.Unmarshal(apiKeyJSON, &apiKey); err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to unmarshal api key %d: %w", keyID, err)
|
||||
}
|
||||
|
||||
mappingJSON, err := r.store.Get(fmt.Sprintf(KeyMapping, groupID, keyID))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to get mapping details for key %d in group %d: %w", keyID, groupID, err)
|
||||
}
|
||||
var mapping models.GroupAPIKeyMapping
|
||||
if err := json.Unmarshal(mappingJSON, &mapping); err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to unmarshal mapping for key %d in group %d: %w", keyID, groupID, err)
|
||||
}
|
||||
|
||||
return &apiKey, &mapping, nil
|
||||
}
|
||||
77
internal/repository/key_writer.go
Normal file
77
internal/repository/key_writer.go
Normal file
@@ -0,0 +1,77 @@
|
||||
// Filename: internal/repository/key_writer.go
|
||||
package repository
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gemini-balancer/internal/errors"
|
||||
"gemini-balancer/internal/models"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (r *gormKeyRepository) UpdateKeyUsageTimestamp(groupID, keyID uint) {
|
||||
lruKey := fmt.Sprintf(KeyGroupLRU, groupID)
|
||||
timestamp := float64(time.Now().UnixMilli())
|
||||
|
||||
members := map[string]float64{
|
||||
strconv.FormatUint(uint64(keyID), 10): timestamp,
|
||||
}
|
||||
|
||||
if err := r.store.ZAdd(lruKey, members); err != nil {
|
||||
r.logger.WithError(err).Warnf("Failed to update usage timestamp for key %d in group %d", keyID, groupID)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *gormKeyRepository) SyncKeyStatusInPollingCaches(groupID, keyID uint, newStatus models.APIKeyStatus) {
|
||||
r.logger.Infof("SYNC: Directly updating polling caches for G:%d K:%d -> %s", groupID, keyID, newStatus)
|
||||
r.updatePollingCachesLogic(groupID, keyID, newStatus)
|
||||
}
|
||||
|
||||
func (r *gormKeyRepository) HandleCacheUpdateEvent(groupID, keyID uint, newStatus models.APIKeyStatus) {
|
||||
r.logger.Infof("EVENT: Updating polling caches for G:%d K:%d -> %s from an event", groupID, keyID, newStatus)
|
||||
r.updatePollingCachesLogic(groupID, keyID, newStatus)
|
||||
}
|
||||
|
||||
func (r *gormKeyRepository) updatePollingCachesLogic(groupID, keyID uint, newStatus models.APIKeyStatus) {
|
||||
keyIDStr := strconv.FormatUint(uint64(keyID), 10)
|
||||
sequentialKey := fmt.Sprintf(KeyGroupSequential, groupID)
|
||||
lruKey := fmt.Sprintf(KeyGroupLRU, groupID)
|
||||
mainPoolKey := fmt.Sprintf(KeyGroupRandomMain, groupID)
|
||||
cooldownPoolKey := fmt.Sprintf(KeyGroupRandomCooldown, groupID)
|
||||
|
||||
_ = r.store.LRem(sequentialKey, 0, keyIDStr)
|
||||
_ = r.store.ZRem(lruKey, keyIDStr)
|
||||
_ = r.store.SRem(mainPoolKey, keyIDStr)
|
||||
_ = r.store.SRem(cooldownPoolKey, keyIDStr)
|
||||
|
||||
if newStatus == models.StatusActive {
|
||||
if err := r.store.LPush(sequentialKey, keyIDStr); err != nil {
|
||||
r.logger.WithError(err).Warnf("Failed to add key %d to sequential list for group %d", keyID, groupID)
|
||||
}
|
||||
members := map[string]float64{keyIDStr: 0}
|
||||
if err := r.store.ZAdd(lruKey, members); err != nil {
|
||||
r.logger.WithError(err).Warnf("Failed to add key %d to LRU zset for group %d", keyID, groupID)
|
||||
}
|
||||
if err := r.store.SAdd(mainPoolKey, keyIDStr); err != nil {
|
||||
r.logger.WithError(err).Warnf("Failed to add key %d to random main pool for group %d", keyID, groupID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateKeyStatusAfterRequest is the new central hub for handling feedback.
|
||||
func (r *gormKeyRepository) UpdateKeyStatusAfterRequest(group *models.KeyGroup, key *models.APIKey, success bool, apiErr *errors.APIError) {
|
||||
if success {
|
||||
if group.PollingStrategy == models.StrategyWeighted {
|
||||
go r.UpdateKeyUsageTimestamp(group.ID, key.ID)
|
||||
}
|
||||
return
|
||||
}
|
||||
if apiErr == nil {
|
||||
r.logger.Warnf("Request failed for KeyID %d in GroupID %d but no specific API error was provided.", key.ID, group.ID)
|
||||
return
|
||||
}
|
||||
r.logger.Warnf("Request failed for KeyID %d in GroupID %d with error: %s. Temporarily removing from active polling caches.", key.ID, group.ID, apiErr.Message)
|
||||
|
||||
// This call is correct. It uses the synchronous, direct method.
|
||||
r.SyncKeyStatusInPollingCaches(group.ID, key.ID, models.StatusCooldown)
|
||||
}
|
||||
107
internal/repository/repository.go
Normal file
107
internal/repository/repository.go
Normal file
@@ -0,0 +1,107 @@
|
||||
// Filename: internal/repository/repository.go
|
||||
package repository
|
||||
|
||||
import (
|
||||
"gemini-balancer/internal/crypto"
|
||||
"gemini-balancer/internal/errors"
|
||||
"gemini-balancer/internal/models"
|
||||
"gemini-balancer/internal/store"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// BasePool 虚拟的临时资源池,用于智能聚合模式。
|
||||
type BasePool struct {
|
||||
CandidateGroups []*models.KeyGroup
|
||||
PollingStrategy models.PollingStrategy
|
||||
}
|
||||
|
||||
type KeyRepository interface {
|
||||
// --- 核心选取与调度 --- key_selector
|
||||
SelectOneActiveKey(group *models.KeyGroup) (*models.APIKey, *models.GroupAPIKeyMapping, error)
|
||||
SelectOneActiveKeyFromBasePool(pool *BasePool) (*models.APIKey, *models.KeyGroup, error)
|
||||
|
||||
// --- 加密与解密 --- key_crud
|
||||
Decrypt(key *models.APIKey) error
|
||||
DecryptBatch(keys []models.APIKey) error
|
||||
|
||||
// --- 基础增删改查 --- key_crud
|
||||
AddKeys(keys []models.APIKey) ([]models.APIKey, error)
|
||||
Update(key *models.APIKey) error
|
||||
HardDeleteByID(id uint) error
|
||||
HardDeleteByValues(keyValues []string) (int64, error)
|
||||
GetKeyByID(id uint) (*models.APIKey, error)
|
||||
GetKeyByValue(keyValue string) (*models.APIKey, error)
|
||||
GetKeysByValues(keyValues []string) ([]models.APIKey, error)
|
||||
GetKeysByIDs(ids []uint) ([]models.APIKey, error) // [新增] 根据一组主键ID批量获取Key
|
||||
GetKeysByGroup(groupID uint) ([]models.APIKey, error)
|
||||
CountByGroup(groupID uint) (int64, error)
|
||||
|
||||
// --- 多对多关系管理 --- key_mapping
|
||||
LinkKeysToGroup(groupID uint, keyIDs []uint) error
|
||||
UnlinkKeysFromGroup(groupID uint, keyIDs []uint) (unlinkedCount int64, err error)
|
||||
GetGroupsForKey(keyID uint) ([]uint, error)
|
||||
GetMapping(groupID, keyID uint) (*models.GroupAPIKeyMapping, error)
|
||||
UpdateMapping(mapping *models.GroupAPIKeyMapping) error
|
||||
GetPaginatedKeysAndMappingsByGroup(params *models.APIKeyQueryParams) ([]*models.APIKeyDetails, int64, error)
|
||||
GetKeysByValuesAndGroupID(values []string, groupID uint) ([]models.APIKey, error)
|
||||
FindKeyValuesByStatus(groupID uint, statuses []string) ([]string, error)
|
||||
FindKeyIDsByStatus(groupID uint, statuses []string) ([]uint, error)
|
||||
GetKeyStringsByGroupAndStatus(groupID uint, statuses []string) ([]string, error)
|
||||
UpdateMappingWithoutCache(mapping *models.GroupAPIKeyMapping) error
|
||||
|
||||
// --- 缓存管理 --- key_cache
|
||||
LoadAllKeysToStore() error
|
||||
HandleCacheUpdateEventBatch(mappings []*models.GroupAPIKeyMapping) error
|
||||
|
||||
// --- 维护与后台任务 --- key_maintenance
|
||||
StreamKeysToWriter(groupID uint, statusFilter string, writer io.Writer) error
|
||||
UpdateMasterStatusByValues(keyValues []string, newStatus models.MasterAPIKeyStatus) (int64, error)
|
||||
UpdateMasterStatusByID(keyID uint, newStatus models.MasterAPIKeyStatus) error
|
||||
DeleteOrphanKeys() (int64, error)
|
||||
DeleteOrphanKeysTx(tx *gorm.DB) (int64, error)
|
||||
GetActiveMasterKeys() ([]*models.APIKey, error)
|
||||
UpdateAPIKeyStatus(keyID uint, status models.MasterAPIKeyStatus) error
|
||||
HardDeleteSoftDeletedBefore(date time.Time) (int64, error)
|
||||
|
||||
// --- 轮询策略的"写"操作 --- key_writer
|
||||
UpdateKeyUsageTimestamp(groupID, keyID uint)
|
||||
// 同步更新缓存,供核心业务使用
|
||||
SyncKeyStatusInPollingCaches(groupID, keyID uint, newStatus models.APIKeyStatus)
|
||||
// 异步更新缓存,供事件订阅者使用
|
||||
HandleCacheUpdateEvent(groupID, keyID uint, newStatus models.APIKeyStatus)
|
||||
UpdateKeyStatusAfterRequest(group *models.KeyGroup, key *models.APIKey, success bool, apiErr *errors.APIError)
|
||||
}
|
||||
|
||||
type GroupRepository interface {
|
||||
GetGroupByName(name string) (*models.KeyGroup, error)
|
||||
GetAllGroups() ([]*models.KeyGroup, error)
|
||||
UpdateOrderInTransaction(orders map[uint]int) error
|
||||
}
|
||||
|
||||
type gormKeyRepository struct {
|
||||
db *gorm.DB
|
||||
store store.Store
|
||||
logger *logrus.Entry
|
||||
crypto *crypto.Service
|
||||
}
|
||||
|
||||
type gormGroupRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewKeyRepository(db *gorm.DB, s store.Store, logger *logrus.Logger, crypto *crypto.Service) KeyRepository {
|
||||
return &gormKeyRepository{
|
||||
db: db,
|
||||
store: s,
|
||||
logger: logger.WithField("component", "repository.key🔗"),
|
||||
crypto: crypto,
|
||||
}
|
||||
}
|
||||
|
||||
func NewGroupRepository(db *gorm.DB) GroupRepository {
|
||||
return &gormGroupRepository{db: db}
|
||||
}
|
||||
47
internal/response/response.go
Normal file
47
internal/response/response.go
Normal file
@@ -0,0 +1,47 @@
|
||||
// Filename: internal/response/response.go
|
||||
package response
|
||||
|
||||
import (
|
||||
"gemini-balancer/internal/errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type SuccessResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data interface{} `json:"data"`
|
||||
}
|
||||
|
||||
type ErrorResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Error gin.H `json:"error"`
|
||||
}
|
||||
|
||||
func Success(c *gin.Context, data interface{}) {
|
||||
c.JSON(http.StatusOK, SuccessResponse{
|
||||
Success: true,
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
|
||||
func Created(c *gin.Context, data interface{}) {
|
||||
c.JSON(http.StatusCreated, SuccessResponse{
|
||||
Success: true,
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
|
||||
func NoContent(c *gin.Context) {
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func Error(c *gin.Context, err *errors.APIError) {
|
||||
c.JSON(err.HTTPStatus, ErrorResponse{
|
||||
Success: false,
|
||||
Error: gin.H{
|
||||
"code": err.Code,
|
||||
"message": err.Message,
|
||||
},
|
||||
})
|
||||
}
|
||||
210
internal/router/router.go
Normal file
210
internal/router/router.go
Normal file
@@ -0,0 +1,210 @@
|
||||
// Filename: internal/router/router.go
|
||||
package router
|
||||
|
||||
import (
|
||||
"gemini-balancer/internal/config"
|
||||
"gemini-balancer/internal/domain/proxy"
|
||||
"gemini-balancer/internal/domain/upstream"
|
||||
"gemini-balancer/internal/handlers"
|
||||
"gemini-balancer/internal/middleware"
|
||||
"gemini-balancer/internal/pongo"
|
||||
"gemini-balancer/internal/service"
|
||||
"gemini-balancer/internal/settings"
|
||||
"gemini-balancer/internal/webhandlers"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func NewRouter(
|
||||
// Core Services
|
||||
cfg *config.Config,
|
||||
securityService *service.SecurityService,
|
||||
settingsManager *settings.SettingsManager,
|
||||
// Core Handlers
|
||||
proxyHandler *handlers.ProxyHandler,
|
||||
apiAuthHandler *handlers.APIAuthHandler,
|
||||
// Admin API Handlers
|
||||
keyGroupHandler *handlers.KeyGroupHandler,
|
||||
apiKeyHandler *handlers.APIKeyHandler,
|
||||
tokensHandler *handlers.TokensHandler,
|
||||
logHandler *handlers.LogHandler,
|
||||
settingHandler *handlers.SettingHandler,
|
||||
dashboardHandler *handlers.DashboardHandler,
|
||||
taskHandler *handlers.TaskHandler,
|
||||
// Web Page Handlers
|
||||
webAuthHandler *webhandlers.WebAuthHandler,
|
||||
pageHandler *webhandlers.PageHandler,
|
||||
// === Domain Modules ===
|
||||
upstreamModule *upstream.Module,
|
||||
proxyModule *proxy.Module,
|
||||
) *gin.Engine {
|
||||
if cfg.Log.Level != "debug" {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
router := gin.Default()
|
||||
|
||||
router.Static("/static", "./web/static")
|
||||
// CORS 配置
|
||||
config := cors.Config{
|
||||
// 允许前端的来源。在生产环境中,需改为实际域名
|
||||
AllowOrigins: []string{"http://localhost:9000"},
|
||||
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
||||
AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization"},
|
||||
ExposeHeaders: []string{"Content-Length"},
|
||||
AllowCredentials: true,
|
||||
MaxAge: 12 * time.Hour,
|
||||
}
|
||||
router.Use(cors.New(config))
|
||||
isDebug := gin.Mode() != gin.ReleaseMode
|
||||
router.HTMLRender = pongo.New("web/templates", isDebug)
|
||||
|
||||
// --- 基础设施 ---
|
||||
router.GET("/", func(c *gin.Context) { c.Redirect(http.StatusMovedPermanently, "/dashboard") })
|
||||
router.GET("/health", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) })
|
||||
// --- 统一的认证管道 ---
|
||||
apiAdminAuth := middleware.APIAdminAuthMiddleware(securityService)
|
||||
webAdminAuth := middleware.WebAdminAuthMiddleware(securityService)
|
||||
|
||||
router.Use(gin.RecoveryWithWriter(os.Stdout))
|
||||
// --- 将正确的依赖和中间件管道传递下去 ---
|
||||
registerProxyRoutes(router, proxyHandler, securityService)
|
||||
registerAdminRoutes(router, apiAdminAuth, keyGroupHandler, tokensHandler, apiKeyHandler, logHandler, settingHandler, dashboardHandler, taskHandler, upstreamModule, proxyModule)
|
||||
registerPublicAPIRoutes(router, apiAuthHandler, securityService, settingsManager)
|
||||
registerWebRoutes(router, webAdminAuth, webAuthHandler, pageHandler)
|
||||
return router
|
||||
}
|
||||
|
||||
func registerProxyRoutes(
|
||||
router *gin.Engine, proxyHandler *handlers.ProxyHandler, securityService *service.SecurityService,
|
||||
) {
|
||||
// 通用的代理认证中间件
|
||||
proxyAuthMiddleware := middleware.ProxyAuthMiddleware(securityService)
|
||||
// --- 模式一: 智能聚合模式 (根路径) ---
|
||||
// /v1 和 /v1beta 路径作为默认入口,服务于 BasePool 聚合逻辑
|
||||
v1 := router.Group("/v1")
|
||||
v1.Use(proxyAuthMiddleware)
|
||||
{
|
||||
v1.Any("/*path", proxyHandler.HandleProxy)
|
||||
}
|
||||
v1beta := router.Group("/v1beta")
|
||||
v1beta.Use(proxyAuthMiddleware)
|
||||
{
|
||||
v1beta.Any("/*path", proxyHandler.HandleProxy)
|
||||
}
|
||||
// --- 模式二: 精确路由模式 (/proxy/:group_name) ---
|
||||
// 创建一个新的、物理隔离的路由组,用于按组名精确路由
|
||||
proxyGroup := router.Group("/proxy/:group_name")
|
||||
proxyGroup.Use(proxyAuthMiddleware)
|
||||
{
|
||||
// 捕获所有子路径 (例如 /v1/chat/completions),并全部交给同一个 ProxyHandler。
|
||||
proxyGroup.Any("/*path", proxyHandler.HandleProxy)
|
||||
}
|
||||
}
|
||||
|
||||
// registerAdminRoutes
|
||||
func registerAdminRoutes(
|
||||
router *gin.Engine,
|
||||
authMiddleware gin.HandlerFunc,
|
||||
keyGroupHandler *handlers.KeyGroupHandler,
|
||||
tokensHandler *handlers.TokensHandler,
|
||||
apiKeyHandler *handlers.APIKeyHandler,
|
||||
logHandler *handlers.LogHandler,
|
||||
settingHandler *handlers.SettingHandler,
|
||||
dashboardHandler *handlers.DashboardHandler,
|
||||
taskHandler *handlers.TaskHandler,
|
||||
upstreamModule *upstream.Module,
|
||||
proxyModule *proxy.Module,
|
||||
) {
|
||||
admin := router.Group("/admin", authMiddleware)
|
||||
{
|
||||
// --- KeyGroup Base Routes ---
|
||||
admin.POST("/keygroups", keyGroupHandler.CreateKeyGroup)
|
||||
admin.GET("/keygroups", keyGroupHandler.GetKeyGroups)
|
||||
admin.PUT("/keygroups/order", keyGroupHandler.UpdateKeyGroupOrder)
|
||||
// --- KeyGroup Specific Routes (by :id) ---
|
||||
admin.GET("/keygroups/:id", keyGroupHandler.GetKeyGroups)
|
||||
admin.PUT("/keygroups/:id", keyGroupHandler.UpdateKeyGroup)
|
||||
admin.DELETE("/keygroups/:id", keyGroupHandler.DeleteKeyGroup)
|
||||
admin.POST("/keygroups/:id/clone", keyGroupHandler.CloneKeyGroup)
|
||||
admin.GET("/keygroups/:id/stats", keyGroupHandler.GetKeyGroupStats)
|
||||
admin.POST("/keygroups/:id/bulk-actions", apiKeyHandler.HandleBulkAction)
|
||||
// --- APIKey Sub-resource Routes under a KeyGroup ---
|
||||
keyGroupAPIKeys := admin.Group("/keygroups/:id/apikeys")
|
||||
{
|
||||
keyGroupAPIKeys.GET("", apiKeyHandler.ListKeysForGroup)
|
||||
keyGroupAPIKeys.GET("/export", apiKeyHandler.ExportKeysForGroup)
|
||||
keyGroupAPIKeys.POST("/bulk", apiKeyHandler.AddMultipleKeysToGroup)
|
||||
keyGroupAPIKeys.DELETE("/bulk", apiKeyHandler.UnlinkMultipleKeysFromGroup)
|
||||
keyGroupAPIKeys.POST("/test", apiKeyHandler.TestKeysForGroup)
|
||||
keyGroupAPIKeys.PUT("/:keyId", apiKeyHandler.UpdateGroupAPIKeyMapping)
|
||||
}
|
||||
|
||||
// Global key operations
|
||||
admin.GET("/apikeys", apiKeyHandler.ListAPIKeys)
|
||||
// admin.PUT("/apikeys/:id", apiKeyHandler.UpdateAPIKey) // DEPRECATED: Status is now contextual
|
||||
admin.POST("/apikeys/test", apiKeyHandler.TestMultipleKeys) // Test keys globally
|
||||
admin.DELETE("/apikeys/:id", apiKeyHandler.HardDeleteAPIKey) // Hard delete a single key
|
||||
admin.DELETE("/apikeys/bulk", apiKeyHandler.HardDeleteMultipleKeys) // Hard delete multiple keys
|
||||
admin.PUT("/apikeys/bulk/restore", apiKeyHandler.RestoreMultipleKeys) // Restore multiple keys globally
|
||||
|
||||
// --- Global Routes ---
|
||||
admin.GET("/tokens", tokensHandler.GetAllTokens)
|
||||
admin.PUT("/tokens", tokensHandler.UpdateTokens)
|
||||
admin.GET("/logs", logHandler.GetLogs)
|
||||
admin.GET("/settings", settingHandler.GetSettings)
|
||||
admin.PUT("/settings", settingHandler.UpdateSettings)
|
||||
admin.PUT("/settings/reset", settingHandler.ResetSettingsToDefaults)
|
||||
|
||||
// 用于查询异步任务的状态
|
||||
admin.GET("/tasks/:id", taskHandler.GetTaskStatus)
|
||||
|
||||
// 领域模块
|
||||
upstreamModule.RegisterRoutes(admin)
|
||||
proxyModule.RegisterRoutes(admin)
|
||||
// --- 全局仪表盘路由 ---
|
||||
dashboard := admin.Group("/dashboard")
|
||||
{
|
||||
dashboard.GET("/overview", dashboardHandler.GetOverview)
|
||||
dashboard.GET("/chart", dashboardHandler.GetChart)
|
||||
dashboard.GET("/stats/:period", dashboardHandler.GetRequestStats) // 点击详情
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// registerWebRoutes
|
||||
func registerWebRoutes(
|
||||
router *gin.Engine,
|
||||
authMiddleware gin.HandlerFunc,
|
||||
webAuthHandler *webhandlers.WebAuthHandler,
|
||||
pageHandler *webhandlers.PageHandler,
|
||||
) {
|
||||
router.GET("/login", webAuthHandler.ShowLoginPage)
|
||||
router.POST("/login", webAuthHandler.HandleLogin)
|
||||
router.GET("/logout", webAuthHandler.HandleLogout)
|
||||
// For Test only router.Run("127.0.0.1:9000")
|
||||
// 受保护的Admin Web界面
|
||||
webGroup := router.Group("/", authMiddleware)
|
||||
webGroup.Use(authMiddleware)
|
||||
{
|
||||
webGroup.GET("/keys", pageHandler.ShowKeysPage)
|
||||
webGroup.GET("/settings", pageHandler.ShowConfigEditorPage)
|
||||
webGroup.GET("/logs", pageHandler.ShowErrorLogsPage)
|
||||
webGroup.GET("/dashboard", pageHandler.ShowDashboardPage)
|
||||
webGroup.GET("/tasks", pageHandler.ShowTasksPage)
|
||||
webGroup.GET("/chat", pageHandler.ShowChatPage)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// registerPublicAPIRoutes 无需后台登录的公共API路由
|
||||
func registerPublicAPIRoutes(router *gin.Engine, apiAuthHandler *handlers.APIAuthHandler, securityService *service.SecurityService, settingsManager *settings.SettingsManager) {
|
||||
ipBanMiddleware := middleware.IPBanMiddleware(securityService, settingsManager)
|
||||
publicAPIGroup := router.Group("/api")
|
||||
{
|
||||
publicAPIGroup.POST("/login", ipBanMiddleware, apiAuthHandler.HandleLogin)
|
||||
}
|
||||
}
|
||||
90
internal/scheduler/scheduler.go
Normal file
90
internal/scheduler/scheduler.go
Normal file
@@ -0,0 +1,90 @@
|
||||
// Filename: internal/scheduler/scheduler.go
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"gemini-balancer/internal/repository"
|
||||
"gemini-balancer/internal/service"
|
||||
"time"
|
||||
|
||||
"github.com/go-co-op/gocron"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type Scheduler struct {
|
||||
gocronScheduler *gocron.Scheduler
|
||||
logger *logrus.Entry
|
||||
statsService *service.StatsService
|
||||
keyRepo repository.KeyRepository
|
||||
// healthCheckService *service.HealthCheckService // 健康检查任务预留
|
||||
}
|
||||
|
||||
func NewScheduler(statsSvc *service.StatsService, keyRepo repository.KeyRepository, logger *logrus.Logger) *Scheduler {
|
||||
s := gocron.NewScheduler(time.UTC)
|
||||
s.TagsUnique()
|
||||
return &Scheduler{
|
||||
gocronScheduler: s,
|
||||
logger: logger.WithField("component", "Scheduler📆"),
|
||||
statsService: statsSvc,
|
||||
keyRepo: keyRepo,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) Start() {
|
||||
s.logger.Info("Starting scheduler and registering jobs...")
|
||||
|
||||
// --- 任务注册 ---
|
||||
// 使用CRON表达式,精确定义“每小时的第5分钟”执行
|
||||
_, err := s.gocronScheduler.Cron("5 * * * *").Tag("stats-aggregation").Do(func() {
|
||||
s.logger.Info("Executing hourly request stats aggregation...")
|
||||
if err := s.statsService.AggregateHourlyStats(); err != nil {
|
||||
s.logger.WithError(err).Error("Hourly stats aggregation failed.")
|
||||
} else {
|
||||
s.logger.Info("Hourly stats aggregation completed successfully.")
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Errorf("Failed to schedule [stats-aggregation]: %v", err)
|
||||
}
|
||||
|
||||
// 任务二:(预留) 自动健康检查 (例如:每10分钟一次)
|
||||
/*
|
||||
_, err = s.gocronScheduler.Every(10).Minutes().Tag("auto-health-check").Do(func() {
|
||||
s.logger.Info("Executing periodic health check for all groups...")
|
||||
// s.healthCheckService.StartGlobalCheckTask() // 伪代码
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Errorf("Failed to schedule [auto-health-check]: %v", err)
|
||||
}
|
||||
*/
|
||||
// [NEW] --- 任务三: 清理软删除的API Keys ---
|
||||
// Executes once daily at 3:15 AM UTC.
|
||||
_, err = s.gocronScheduler.Cron("15 3 * * *").Tag("cleanup-soft-deleted-keys").Do(func() {
|
||||
s.logger.Info("Executing daily cleanup of soft-deleted API keys...")
|
||||
|
||||
// Let's assume a retention period of 7 days for now.
|
||||
// In a real scenario, this should come from settings.
|
||||
const retentionDays = 7
|
||||
|
||||
count, err := s.keyRepo.HardDeleteSoftDeletedBefore(time.Now().AddDate(0, 0, -retentionDays))
|
||||
if err != nil {
|
||||
s.logger.WithError(err).Error("Daily cleanup of soft-deleted keys failed.")
|
||||
} else if count > 0 {
|
||||
s.logger.Infof("Daily cleanup completed: Permanently deleted %d expired soft-deleted keys.", count)
|
||||
} else {
|
||||
s.logger.Info("Daily cleanup completed: No expired soft-deleted keys found to delete.")
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Errorf("Failed to schedule [cleanup-soft-deleted-keys]: %v", err)
|
||||
}
|
||||
// --- 任务注册结束 ---
|
||||
|
||||
s.gocronScheduler.StartAsync() // 异步启动,不阻塞应用主线程
|
||||
s.logger.Info("Scheduler started.")
|
||||
}
|
||||
|
||||
func (s *Scheduler) Stop() {
|
||||
s.logger.Info("Stopping scheduler...")
|
||||
s.gocronScheduler.Stop()
|
||||
s.logger.Info("Scheduler stopped.")
|
||||
}
|
||||
197
internal/service/analytics_service.go
Normal file
197
internal/service/analytics_service.go
Normal file
@@ -0,0 +1,197 @@
|
||||
// Filename: internal/service/analytics_service.go
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gemini-balancer/internal/db/dialect"
|
||||
"gemini-balancer/internal/models"
|
||||
"gemini-balancer/internal/store"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
flushLoopInterval = 1 * time.Minute
|
||||
)
|
||||
|
||||
type AnalyticsServiceLogger struct{ *logrus.Entry }
|
||||
|
||||
type AnalyticsService struct {
|
||||
db *gorm.DB
|
||||
store store.Store
|
||||
logger *logrus.Entry
|
||||
stopChan chan struct{}
|
||||
wg sync.WaitGroup
|
||||
dialect dialect.DialectAdapter
|
||||
}
|
||||
|
||||
func NewAnalyticsService(db *gorm.DB, s store.Store, logger *logrus.Logger, d dialect.DialectAdapter) *AnalyticsService {
|
||||
return &AnalyticsService{
|
||||
db: db,
|
||||
store: s,
|
||||
logger: logger.WithField("component", "Analytics📊"),
|
||||
stopChan: make(chan struct{}),
|
||||
dialect: d,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AnalyticsService) Start() {
|
||||
s.wg.Add(2) // 2 (flushLoop, eventListener)
|
||||
go s.flushLoop()
|
||||
go s.eventListener()
|
||||
s.logger.Info("AnalyticsService (Command Side) started.")
|
||||
}
|
||||
|
||||
func (s *AnalyticsService) Stop() {
|
||||
close(s.stopChan)
|
||||
s.wg.Wait()
|
||||
s.logger.Info("AnalyticsService stopped. Performing final data flush...")
|
||||
s.flushToDB() // 停止前刷盘
|
||||
s.logger.Info("AnalyticsService final data flush completed.")
|
||||
}
|
||||
|
||||
func (s *AnalyticsService) eventListener() {
|
||||
defer s.wg.Done()
|
||||
sub, err := s.store.Subscribe(models.TopicRequestFinished)
|
||||
if err != nil {
|
||||
s.logger.Fatalf("Failed to subscribe to topic %s: %v", models.TopicRequestFinished, err)
|
||||
return
|
||||
}
|
||||
defer sub.Close()
|
||||
s.logger.Info("AnalyticsService subscribed to request events.")
|
||||
|
||||
for {
|
||||
select {
|
||||
case msg := <-sub.Channel():
|
||||
var event models.RequestFinishedEvent
|
||||
if err := json.Unmarshal(msg.Payload, &event); err != nil {
|
||||
s.logger.Errorf("Failed to unmarshal analytics event: %v", err)
|
||||
continue
|
||||
}
|
||||
s.handleAnalyticsEvent(&event)
|
||||
case <-s.stopChan:
|
||||
s.logger.Info("AnalyticsService stopping event listener.")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AnalyticsService) handleAnalyticsEvent(event *models.RequestFinishedEvent) {
|
||||
if event.GroupID == 0 {
|
||||
return
|
||||
}
|
||||
key := fmt.Sprintf("analytics:hourly:%s", time.Now().UTC().Format("2006-01-02T15"))
|
||||
fieldPrefix := fmt.Sprintf("%d:%s", event.GroupID, event.ModelName)
|
||||
|
||||
pipe := s.store.Pipeline()
|
||||
pipe.HIncrBy(key, fieldPrefix+":requests", 1)
|
||||
if event.IsSuccess {
|
||||
pipe.HIncrBy(key, fieldPrefix+":success", 1)
|
||||
}
|
||||
if event.PromptTokens > 0 {
|
||||
pipe.HIncrBy(key, fieldPrefix+":prompt", int64(event.PromptTokens))
|
||||
}
|
||||
if event.CompletionTokens > 0 {
|
||||
pipe.HIncrBy(key, fieldPrefix+":completion", int64(event.CompletionTokens))
|
||||
}
|
||||
|
||||
if err := pipe.Exec(); err != nil {
|
||||
s.logger.Warnf("[%s] Failed to record analytics event to store for group %d: %v", event.CorrelationID, event.GroupID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AnalyticsService) flushLoop() {
|
||||
defer s.wg.Done()
|
||||
ticker := time.NewTicker(flushLoopInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
s.flushToDB()
|
||||
case <-s.stopChan:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AnalyticsService) flushToDB() {
|
||||
now := time.Now().UTC()
|
||||
keysToFlush := []string{
|
||||
fmt.Sprintf("analytics:hourly:%s", now.Add(-1*time.Hour).Format("2006-01-02T15")),
|
||||
fmt.Sprintf("analytics:hourly:%s", now.Format("2006-01-02T15")),
|
||||
}
|
||||
|
||||
for _, key := range keysToFlush {
|
||||
data, err := s.store.HGetAll(key)
|
||||
if err != nil || len(data) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
statsToFlush, parsedFields := s.parseStatsFromHash(now.Truncate(time.Hour), data)
|
||||
|
||||
if len(statsToFlush) > 0 {
|
||||
upsertClause := s.dialect.OnConflictUpdateAll(
|
||||
[]string{"time", "group_id", "model_name"}, // conflict columns
|
||||
[]string{"request_count", "success_count", "prompt_tokens", "completion_tokens"}, // update columns
|
||||
)
|
||||
err := s.db.Clauses(upsertClause).Create(&statsToFlush).Error
|
||||
if err != nil {
|
||||
s.logger.Errorf("Failed to flush analytics data for key %s: %v", key, err)
|
||||
} else {
|
||||
s.logger.Infof("Successfully flushed %d records from key %s.", len(statsToFlush), key)
|
||||
_ = s.store.HDel(key, parsedFields...)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AnalyticsService) parseStatsFromHash(t time.Time, data map[string]string) ([]models.StatsHourly, []string) {
|
||||
tempAggregator := make(map[string]*models.StatsHourly)
|
||||
var parsedFields []string
|
||||
for field, valueStr := range data {
|
||||
parts := strings.Split(field, ":")
|
||||
if len(parts) != 3 {
|
||||
continue
|
||||
}
|
||||
groupIDStr, modelName, counterType := parts[0], parts[1], parts[2]
|
||||
|
||||
aggKey := groupIDStr + ":" + modelName
|
||||
if _, ok := tempAggregator[aggKey]; !ok {
|
||||
gid, err := strconv.Atoi(groupIDStr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
tempAggregator[aggKey] = &models.StatsHourly{
|
||||
Time: t,
|
||||
GroupID: uint(gid),
|
||||
ModelName: modelName,
|
||||
}
|
||||
}
|
||||
val, _ := strconv.ParseInt(valueStr, 10, 64)
|
||||
switch counterType {
|
||||
case "requests":
|
||||
tempAggregator[aggKey].RequestCount = val
|
||||
case "success":
|
||||
tempAggregator[aggKey].SuccessCount = val
|
||||
case "prompt":
|
||||
tempAggregator[aggKey].PromptTokens = val
|
||||
case "completion":
|
||||
tempAggregator[aggKey].CompletionTokens = val
|
||||
}
|
||||
parsedFields = append(parsedFields, field)
|
||||
}
|
||||
var result []models.StatsHourly
|
||||
for _, stats := range tempAggregator {
|
||||
if stats.RequestCount > 0 {
|
||||
result = append(result, *stats)
|
||||
}
|
||||
}
|
||||
return result, parsedFields
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user