Merge pull request #31 from qianfree/dev

UI优化
This commit is contained in:
Qian authored and GitHub committed 2026-09-24 22:52:55 +08:00
commit b4c8cfd14a
23 files changed
+1942 -1536

No files matched your search

+53 -80
View File
@@ -28,77 +28,50 @@ team-api/
│ ├── docs/v1/ # OpenAPI 文档 API
│ ├── open/v1/ # 开放平台 API
│ ├── payment/v1/ # 支付回调 API
│ └── settings/v1/ # 公开设置 API(IsPublic 设置项、公开公告,无需认证)
├── hack/
│ └── config.yaml # gf CLI 脚手架配置(gen dao 等工具的数据库连接和生成规则)
├── internal/
│ ├── cmd/ # 入口命令、服务启动、路由注册
│ │ ├── cmd.go # main 包注册,路由分组定义
│ │ └── cmd_reset_pwd.go # 密码重置命令
│ ├── consts/ # 常量定义(业务状态码、枚举等)
│ ├── controller/ # 控制器(由 gf gen ctrl 从 api/ 自动生成,禁止手动修改)
│ │ ├── admin/ # 管理后台控制器
│ │ ├── tenant/ # 租户控制台控制器
│ │ ├── captcha/ # 验证码控制器
│ │ ├── docs/ # 文档控制器
│ │ ├── open/ # 开放平台控制器
│ ├── handler/ # 特殊端点处理器(不走 Controller 链路,见下方说明)
│ │ ├── relay/ # AI 代理端点处理器
│ │ ├── public/ # 支付回调端点处理器
│ │ └── setup/ # 系统初始化向导处理器
│ ├── logic/ # 业务逻辑实现(核心代码所在)
│ │ ├── admin/ # 管理后台业务逻辑
│ │ ├── tenant/ # 租户控制台业务逻辑
│ │ ├── billing/ # 计费引擎
│ │ ├── common/ # 公共逻辑(缓存、配置、安全、邮件、验证码、JWT 等)
│ │ ├── docs/ # OpenAPI 文档生成
│ │ ├── monitor/ # 监控告警引擎
│ │ ├── notification/ # 通知服务
│ │ ├── open/ # 开放平台业务逻辑
│ │ ├── payment/ # 支付逻辑(回调处理、订单履约)
│ │ ├── relay/ # Relay 业务逻辑(亲和性、健康检查、缓存)
│ │ └── task/ # 异步任务框架
│ ├── model/ # 数据模型(由 gf gen dao 自动生成,禁止手动修改)
│ │ ├── do/ # Domain Objects(查询条件、输入结构体)
│ │ └── entity/ # Entity(与数据库表 1:1 映射的数据实体)
│ ├── packed/ # 打包资源(内嵌静态文件、编译时生成)
│ ├── dao/ # 数据访问对象(由 gf gen dao 自动生成,禁止手动修改)
│ ├── dispatchadapter/ # 渠道调度适配层(桥接 relaykit/dispatch 与宿主:渠道目录、调度配置、Redis/本地健康状态)
│ ├── handler/ # 特殊端点处理器(不走 Controller 链路,见下方说明)
│ ├── logic/ # 业务逻辑实现(核心代码所在)
│ ├── middleware/ # 中间件
│ ├── model/ # 数据模型(由 gf gen dao 自动生成,禁止手动修改)
│ ├── packed/ # 打包资源(内嵌静态文件、编译时生成)
│ ├── plugin/ # 插件框架(接口定义、注册表、生命周期、钩子、引导)
│ ├── response/ # 统一响应工具包(Success/Error 封装)
│ ├── service/ # 业务接口定义(由 gf gen service 从 logic/ 自动生成,禁止手动修改)
│ ├── middleware/ # 中间件
│ ├── testutil/ # 测试工具(GoFrame 测试服务器封装)
│ └── utility/ # 工具函数
│ ├── crypto/ # 加密工具
│ ├── export/ # 数据导出
│ ├── totp/ # TOTP 两步验证
│ └── turnstile/ # Cloudflare Turnstile 验证
├── relay/ # AI 模型代理层(顶级模块,独立于 GoFrame 脚手架生成目录)
│ ├── channel/ # 供应商适配器(registry.go + 24 个子目录,每个供应商一个)
│ │ └── openai/ claude/ gemini/ ...
│ ├── channel/ # 供应商适配器(registry.go + 23 个子目录,每个供应商一个)
│ │ └── openai/ claude/ gemini/ vertex/ codex/ ali/ ...
│ ├── common/ # Relay 层共享类型和工具
│ ├── constant/ # Relay 层常量(供应商类型、模式、错误码)
│ ├── dto/ # 数据传输对象(OpenAI/Claude/Gemini/Realtime/Task 等格式定义)
│ ├── handler/ # Relay 请求处理器(chat/claude/gemini/audio/rerank/task/realtime 等)
│ ├── helper/ # Relay 辅助函数(流式处理、状态码映射、系统提示词、thinking 处理)
│ ├── dto/ # 兼容层 DTO(主体已迁 relaykit/dto,余 Responses DTO 与 relaykit 类型桥接)
│ ├── handler/ # Relay 请求处理器(chat/claude/gemini/audio/rerank/task/realtime/videos/responses/passthrough 等)
│ ├── helper/ # Relay 辅助函数(错误信息、状态码映射、模型名处理、供应商原生格式判定、脱敏)
│ ├── override/ # 请求/响应覆盖(Header 改写、参数覆盖)
│ ├── scheduler/ # 渠道调度器(调度、亲和性、重试)
│ └── taskchannel/ # 异步任务渠道适配器
│ ├── registry.go # 任务渠道注册表
│ ├── kling/ # 可灵视频生成
│ ├── midjourney/ # Midjourney 图像生成
│ ├── sora/ # Sora 视频生成
│ └── suno/ # Suno 音乐生成
├── manifest/
│ ├── config/ # 应用运行时配置(GoFrame 标准路径)
│ ├── deploy/ # 部署配置(K8s/Compose 等)
│ ├── docker/ # Docker 构建配置
│ ├── i18n/ # 国际化资源文件
│ └── protobuf/ # Protobuf 定义文件
│ ├── relaykit_bridge/ # relaykit 协议转换桥(格式路由分发、响应/流式回接)
│ └── taskchannel/ # 异步任务渠道适配器(registry.go + helpers.go + 8 个子目录)
│ └── kling/ midjourney/ sora/ suno/ ali/ gemini/ minimax/ volcengine/
├── relaykit/ # 协议转换 + 渠道调度(独立 Go module,不依赖 GoFrame)
├── plugins/ # 插件实现目录(registry.go 汇总注册,当前含 example 示例插件)
├── scripts/ # 辅助脚本(wrk 压测、Turnstile 测试、渠道测试缓存清理)
├── tests/
│ └── integration/ # 集成测试(admin/ tenant/,起真实服务做端到端断言)
├── manifest/ # GoFrame相关配置
├── migrations/ # goose 数据库迁移脚本(六位序号编号:000001_xxx.sql)
├── web/ # 前端
│ ├── admin/ # 管理后台
│ └── tenant/ # 租户控制台
│ ├── tenant/ # 租户控制台
│ ├── embed.go # 前端打包产物内嵌(embedweb 构建标签)
│ └── embed_stub.go # 非 embedweb 构建的空实现
├── docs/ # 项目文档
│ ├── 开发计划-v2/ # 分周期开发计划(周期一~六)
│ └── 协议文档/ # 协议相关文档
├── main.go # 应用入口
├── go.mod
└── Makefile
@@ -107,11 +80,17 @@ team-api/
### Relay 层设计说明
Relay 层作为顶级 `relay/` 模块独立于 GoFrame 脚手架生成目录(`internal/` 下的 `controller/service/dao/model`),原因:
- **文件量大**:24 个供应商适配器 + 4 个异步任务适配器 + 共享模块,放在 `internal/logic/` 下层级过深
- **文件量大**:23 个供应商适配器 + 8 个异步任务适配器 + 共享模块,放在 `internal/logic/` 下层级过深
- **职责独立**:Relay 是纯粹的代理转发层,不依赖 GoFrame ORM/DAO,与 `internal/` 下的业务逻辑解耦
- **参考对齐**:目录结构与 new-api 的 `relay/` 保持一致,降低移植和理解成本
`internal/logic/` 中的业务逻辑(如计费、渠道调度)通过 Go import 调用 `relay/` 包,而非将 relay 嵌入 logic 内部。
`internal/logic/` 中的业务逻辑(如计费)通过 Go import 调用 `relay/` 包,而非将 relay 嵌入 logic 内部。
### relaykit 独立模块(协议转换 + 渠道调度)
`relaykit/` 是自带 go.mod 的独立 Go module(`github.com/qianfree/team-api/relaykit`,不依赖 GoFrame),承载两块核心能力,`relay/` 下已不再有 scheduler:
- **协议转换**(`relayconvert/`):12 方向协议互转的注册表 + 引擎,`relay/handler` 经 `relay/relaykit_bridge` 调用;原 `relay/dto` 主体已迁入 `relaykit/dto`
- **渠道调度**(`dispatch/`):渠道选择(HRW 一致性哈希)、错误分类、熔断、健康分、会话亲和;`internal/dispatchadapter` 负责桥接宿主的渠道目录/调度配置/Redis 健康状态
### GoFrame 代码生成与手动编写分界
@@ -133,6 +112,10 @@ Relay 层作为顶级 `relay/` 模块独立于 GoFrame 脚手架生成目录(`
| `internal/utility/` | 手动编写 | 是(工具函数:加密、导出、TOTP、Turnstile) |
| `manifest/config/` | 手动编写 | 是(运行时配置) |
| `relay/` | 手动编写 | 是(AI 模型代理层,独立于 GoFrame 脚手架) |
| `relaykit/` | 手动编写 | 是(独立 Go module:协议转换 + 渠道调度) |
| `internal/dispatchadapter/` | 手动编写 | 是(渠道调度适配层,桥接 relaykit/dispatch) |
| `internal/plugin/` `plugins/` | 手动编写 | 是(插件框架与插件实现) |
| `internal/testutil/` `scripts/` `tests/` | 手动编写 | 是(测试与辅助脚本) |
### internal/handler/ 目录说明
@@ -143,6 +126,8 @@ Relay 层作为顶级 `relay/` 模块独立于 GoFrame 脚手架生成目录(`
| `handler/relay/` | `/v1/*` AI 代理端点(OpenAI/Claude/Gemini 兼容格式)- 需要直接操作 `http.ResponseWriter` 进行流式转发 |
| `handler/public/` | `/api/payment/*` 支付回调端点 - 需要返回非 JSON 格式的响应(如支付宝的表单重定向) |
| `handler/setup/` | `/api/setup/*` 系统初始化向导端点 - 首次部署时的系统配置页面和初始化接口 |
| `handler/admin/` | `/api/admin/files/*` 管理后台文件服务 - 应用层代理下载(local 存储无预签名 URL 时),直写二进制流 |
| `handler/landing/` | `/` 后端根路径落地页 - 非 embedweb 构建时返回项目介绍页,引导进入两套控制台 |
**为什么不用 Controller**:
- **流式响应**:AI 代理端点需要实时转发上游的 SSE 流,Controller 的响应包装会破坏流式传输
@@ -194,28 +179,18 @@ cmd(路由注册)
← controller 格式化响应(Res 结构体)
```
## 参考项目(只读,禁止修改)
## 参考项目(本地可选,当前未放置)
项目根目录下有 `new-api/` 和 `sub2api/` 两个参考项目,它们是大模型网关领域的成熟实现。
大多数情况下不需要查看这2个目录下的代码,除非我主动提及或者涉及到大模型相关的实现逻辑。
`new-api/` 和 `sub2api/` 是大模型网关领域的成熟实现,曾作为只读参考放在项目根目录(gitignore 未跟踪,不随仓库分发),**当前本地已移除**;需要参考时自行克隆回来即可。三项目渠道调度的横向对比结论沉淀在 `docs/reference/多项目渠道调度对比.md`(注意:其 team-api 侧描述为调度重写前的旧实现,现行调度以 `relaykit/dispatch` 为准)。
### new-api 重点参考
### 参考要点
- **Relay 代理层架构**:`controller/relay/`,适配器模式、请求转发、响应解析
- **供应商适配器**:`relay/channel/` 下 40+ 供应商的适配器实现,优先复用
- **协议转换**:OpenAI ↔ Claude ↔ Gemini 消息格式互转
- **渠道调度**:优先级/权重选择、自动重试、Key 轮询
- **计费模型**:预扣/结算/退款、梯度定价、Token 计量
### sub2api 重点参考
- **渠道亲和性**:用户+模型→渠道映射缓存策略
- **运维监控**:仪表盘设计、健康监控页面
- **用户体系**:前端布局和组件设计
- **new-api**:Relay 代理层架构(适配器模式、请求转发、响应解析)、40+ 供应商适配器、协议转换、渠道调度(优先级/权重选择、自动重试、Key 轮询)、计费模型(预扣/结算/退款、梯度定价、Token 计量)
- **sub2api**:渠道亲和性(用户+模型→渠道映射缓存策略)、运维监控(仪表盘设计、健康监控页面)、用户体系(前端布局和组件设计)
### 参考原则
参考不等于照搬。new-api 使用 Gin + GORM,我们需要适配到 GoFrame 框架。接口设计和核心算法可以参考,但代码需要重新组织以符合 GoFrame 标准结构(`api → controller → service → logic → dao` 分层)和项目的多租户架构。注意 GoFrame 的 `model/do/`、`model/entity/`、`dao/`、`service/`、`controller/` 均由脚手架自动生成,业务代码只写在 `api/`、`logic/`、`cmd/`、`handler/`、`middleware/`、`response/`、`utility/`、`consts/` 和顶级 `relay/` 中。
参考不等于照搬。new-api 使用 Gin + GORM,需适配到 GoFrame 框架,代码重新组织以符合 GoFrame 标准结构(`api → controller → service → logic → dao` 分层)和项目的多租户架构。注意 GoFrame 的 `model/do/`、`model/entity/`、`dao/`、`service/`、`controller/` 均由脚手架自动生成,业务代码只写在 `api/`、`logic/`、`cmd/`、`handler/`、`middleware/`、`response/`、`utility/`、`consts/`、顶级 `relay/` 和独立模块 `relaykit/` 中;协议转换与渠道调度已沉淀为自有实现,优先复用 `relaykit/`。
## 核心架构决策
@@ -267,7 +242,7 @@ cmd(路由注册)
### 钱包状态:Redis 权威 + DB 物化(强约束)
钱包资金状态采用 **Redis 唯一实时权威 + DB 滞后物化视图** 架构(详见 [`docs/钱包Redis权威化设计.md`](docs/钱包Redis权威化设计.md)):
钱包资金状态采用 **Redis 唯一实时权威 + DB 滞后物化视图** 架构:
- **Redis 权威**:`wallet:v2:{tenant_id}` hash(balance / frozen_balance / total_consumed / ver,整数 micro-USD)是余额的唯一实时真相。所有资金变动(预扣冻结、结算扣款、解冻、充值/退款/调账)一律通过 `internal/logic/billing/wallet.go` 的 Lua 脚本原子完成,**任何 Go 代码不得直接读改写 Redis 钱包 hash 或从 DB 反向重建它**。
- **累计消费(total_consumed)只随结算事件变动**:`settleClaimLua` 与扣款同一原子 `HINCRBY` 递增;结算记流水失败的补偿逆转走专用 `ReverseConsumeRedis`(余额与计数同一原子回退)。充值/兑换/调账/退款/预扣/解冻一律**不得**触碰该计数(`CreditWalletRedis`/`DebitWalletRedis` 不感知计数)。字段基线由三层补种保证(boot 种子 `SeedWalletTotalConsumed` / 灾备重建 `rebuildWalletFromDB` / 物化器自愈 `materializeOneWallet`),存量历史从 `bil_transactions(type=consume)` 回填(幂等可重放)。
@@ -374,7 +349,7 @@ cmd(路由注册)
### Go 后端
- **缩进统一使用 tab**:所有 Go 代码的缩进必须使用 tab,与 `go fmt` 自动格式化保持一致,禁止混用空格和 tab 缩进
- **金额用 `decimal.Decimal`,不用 `float64`(强约束)**:所有资金列(`NUMERIC(20,10)`)在 Go 层映射为 `github.com/shopspring/decimal` 的 `decimal.Decimal`(gf 全局 `typeMapping: numeric → decimal.Decimal`)。**多步金额运算(加/减/乘/除、循环累加、倍率×折扣链)一律用 decimal**,禁止用 float64 做中间计算——IEEE double 会在链式运算中累积漂移。工具函数在 `internal/logic/billing/money.go`(`AddMoney`/`SubtractMoney`/`MultiplyMoney`/`DivideMoney`/`RoundMoney`/`Max`/`Min`/`IsPositive` 等),比较用 `.GreaterThan(billing.Zero)`/`.IsZero()`/`.Equal()`,不要用 `== 0`。判断非金额小数(评分/利用率/延迟/告警阈值)保持 `float64`——判据见 [`docs/float64-decimal-field-classification.md`](docs/float64-decimal-field-classification.md)(「这个数最终会不会变成钱」)。
- **金额用 `decimal.Decimal`,不用 `float64`(强约束)**:所有资金列(`NUMERIC(20,10)`)在 Go 层映射为 `github.com/shopspring/decimal` 的 `decimal.Decimal`(gf 全局 `typeMapping: numeric → decimal.Decimal`)。**多步金额运算(加/减/乘/除、循环累加、倍率×折扣链)一律用 decimal**,禁止用 float64 做中间计算——IEEE double 会在链式运算中累积漂移。工具函数在 `internal/logic/billing/money.go`(`AddMoney`/`SubtractMoney`/`MultiplyMoney`/`DivideMoney`/`RoundMoney`/`Max`/`Min`/`IsPositive` 等),比较用 `.GreaterThan(billing.Zero)`/`.IsZero()`/`.Equal()`,不要用 `== 0`。判断非金额小数(评分/利用率/延迟/告警阈值)保持 `float64`——判据是「这个数最终会不会变成钱」。
- **边界转换(只在出入口调用,禁止用于计算链路)**:
- **出口 → float64**:API `Res` 响应、内存缓存结构体、遗留 float64 接口,用 `billing.InexactFloat64(d)`(前端 JSON number 契约不变)。
- **入口 ← float64**:API `Req` 里的 float64 金额,用 `billing.NewFromFloat(f)` 转进 decimal 再运算。
@@ -466,12 +441,10 @@ cmd(路由注册)
| 文档 | 路径 | 用途 |
|------|------|------|
| 产品需求文档 | `docs/team-api产品需求文档.md` | 功能需求的最终依据 |
| 原始开发计划 | `docs/team-api开发计划.md` | S01-S26 迭代拆分 |
| new-api 调研 | `docs/new-api调研报告.md` | 参考项目能力盘点 |
| sub2api 调研 | `docs/sub2api调研报告.md` | 参考项目能力盘点 |
| 对比分析 | `docs/new-api-vs-sub2api对比分析报告.md` | 两者差异 |
| 大模型实施方案 | `docs/大模型完整实施方案-v2.1.md` | 大模型代理层设计 |
| relaykit 迁移状态 | `docs/relaykit迁移工作状态.md` | relay/ → relaykit 协议转换迁移进度、覆盖矩阵与守卫说明 |
| 渠道调度对比 | `docs/reference/多项目渠道调度对比.md` | new-api/sub2api/team-api 调度方案对比(team-api 侧描述已过时,以 relaykit/dispatch 为准) |
| Docker 部署 | `docs/deployment-docker.md` | Docker 部署指引 |
| 协议文档 | `docs/协议文档/` | 各供应商官方协议参考(OpenAI/Claude/Gemini/智谱/豆包/阿里等系列) |
| API 格式参考 | `docs/reference/api-format-reference.md` | JSON 示例、SSE 格式、错误映射、中间件差异 |
| 协议转换语料采集 | `docs/协议转换语料采集手册.md` | `corpus-extract` 命令用法:从渠道调试日志提取真实流量语料喂给协议转换测试 |
| GoFrame 使用规范 | `docs/reference/goframe-conventions.md` | 框架使用规范 + 已修复的框架 bug 记录 |
+9 -9
View File
@@ -4,7 +4,7 @@
**多租户 大模型 API 网关 SaaS 平台**
统一接入 OpenAI、Claude、Gemini 等 25+ 大模型供应商,提供计费、限流、监控和多租户管理能力。
统一接入 OpenAI、Claude、Gemini 等大模型供应商,提供计费、限流、监控和多租户管理能力。
[![Go](https://img.shields.io/badge/Go-1.25-00ADD8?style=flat-square&logo=go)](https://go.dev/)
[![GoFrame](https://img.shields.io/badge/GoFrame-v2.10-blue?style=flat-square)](https://goframe.org/)
@@ -20,18 +20,18 @@
## 功能特性
- **多租户架构** — 行级租户隔离,管理后台与租户控制台双独立用户体系
- **25+ 大模型供应商** — OpenAI、Claude、Gemini、DeepSeek、通义千问、智谱、Ollama 等
- **适配多家大模型供应商** — OpenAI、Claude、Gemini、DeepSeek、通义千问、智谱、Ollama 等
- **OpenAI 兼容 API** — 无缝替换 OpenAI API,支持对话补全、向量嵌入、图像生成、语音、实时通信等
- **智能渠道调度** — 优先级/权重路由、自动故障转移、健康监控、渠道亲和性
- **五层额度模型** — 租户钱包 → 套餐额度 → 成员额度 → 项目预算 → Key 额度
- **三层额度模型** — 租户钱包 → 项目预算/成员额度 → Key 额度
- **实时计费引擎** — 预扣 → 转发 → 结算 → 退款,Redis 原子操作保证并发安全
- **双控制台** — 管理后台(Naive UI)用于平台运营 + 租户控制台(TailwindCSS)面向终端用户
- **双控制台** — 管理后台用于平台运营 + 租户控制台面向终端用户
- **全链路可观测** — 请求日志、操作审计、监控告警,Request ID 贯穿全链路
> 在线演示地址:
>
> 管理端:https://team-api.net/admin/, 为做好数据安全措施,暂不公开登录方式
> 管理端:https://team-api.net/admin/, 未做好数据安全措施,暂不公开登录方式
>
> 用户端:https://team-api.net, 用户名:liu@163.com, 密码:Demo123456
@@ -383,8 +383,8 @@ make migrate-status # 查看迁移状态
1. ~~在线升级(已完成)~~
2. ~~支付模块(Easy Pay 已支持)~~
3. ~~图片生成同步转异步~~
4. 完善大模型支持,特别是图像和视频的支持
5. 角色管理,方便运营团队内部协作,预设角色(管理员,销售,技术支持)
4. ~~完善大模型支持,特别是图像和视频的支持~~
5. ~~角色管理,方便运营团队内部协作,预设角色(管理员,销售,技术支持)~~
6. 插件功能(定制功能通过插件实现,确保不与主线代码冲突)
7. 开放平台(对接OA的能力,方便企业对接管理)
@@ -399,7 +399,7 @@ make migrate-status # 查看迁移状态
请遵循项目现有代码风格和 [Conventional Commits](https://www.conventionalcommits.org/zh-hans/) 提交规范。
项目当前由 qianfree 单独维护。未来如接受外部贡献,将在合并贡献前提供贡献者许可协议(CLA),以确保 AGPL 与商业授权的权利链清晰完整。
项目当前由 qianfree 进行维护。未来如接受外部贡献,将在合并贡献前提供贡献者许可协议(CLA),以确保 AGPL 与商业授权的权利链清晰完整。
## 在线交流
欢迎加QQ群聊天吹水:1095286563
@@ -409,7 +409,7 @@ make migrate-status # 查看迁移状态
## 许可证
Copyright (C) 2026 qianfree。本项目社区版采用 [AGPL v3.0 或更高版本](LICENSE) 许可证。
Copyright (C) 2026 qianfree。 本项目社区版采用 [AGPL v3.0 或更高版本](LICENSE) 许可证。
### 核心要求
-2
View File
@@ -176,5 +176,3 @@ go list -m -f "{{if not .Main}}{{.Path}}|{{.Version}}|{{.Dir}}{{end}}" all
# 前端依赖(在各 web/* 目录下):
# bunx license-checker --summary / npx license-checker --json
```
> ⚠️ 本文件列出版本为生成时快照。每次发布商业版本前应重新扫描,重点核对**新增依赖**是否引入 copyleft 类许可证。
+19 -6
View File
@@ -14,12 +14,25 @@ type AdminDashboardReq struct {
}
type AdminDashboardRes struct {
Tenants int `json:"tenants"`
Members int `json:"members"`
ActiveChannels int `json:"active_channels"`
Today *DayStats `json:"today"`
Yesterday *DayStats `json:"yesterday"`
Month *MonthStats `json:"month"`
Tenants int `json:"tenants"`
Members int `json:"members"`
ActiveChannels int `json:"active_channels"`
Today *DayStats `json:"today"`
Yesterday *DayStats `json:"yesterday"`
Month *MonthStats `json:"month"`
IpBlacklist *IpBlacklistStats `json:"ip_blacklist"`
}
// IpBlacklistStats IP 黑名单拦截统计(数据存 Redis 缓存,不落库;读取失败时仅回 enabled)
type IpBlacklistStats struct {
Enabled bool `json:"enabled"` // 黑名单当前是否启用
Total int64 `json:"total"` // 累计拦截总次数
List []IpBlacklistBlockedItem `json:"list"` // 按拦截次数倒序,最多 TOP 20
}
type IpBlacklistBlockedItem struct {
Ip string `json:"ip"`
Count int64 `json:"count"`
}
type DayStats struct {
@@ -1,183 +0,0 @@
# 渠道调度策略:三项目对比(new-api / sub2api / team-api)
> **用途**:在 [`new-api-channel-scheduling-analysis.md`](./new-api-channel-scheduling-analysis.md) 与 [`sub2api-channel-scheduling-analysis.md`](./sub2api-channel-scheduling-analysis.md) 两份单独分析的基础上,补充 team-api 自身的实现剖析,并给出三者横向对比与 team-api 的改进建议。
>
> **路径约定**:
> - team-api 引用相对于**本项目根目录**(`D:\github\team-api`)
> - new-api 引用相对于 `new-api/` 参考项目根
> - sub2api 引用相对于 `sub2api/backend/`
>
> **分析时间**:2026-07-30。
> ⚠️ **team-api 部分已过时(2026-08-27 标注)**:本文描述的是渠道调度重写**之前**的实现,
> 文中引用的 `relay/scheduler/scheduler.go`、`internal/logic/relay/health.go` 均已删除。
> 特别注意第 5 节的「4 因子加权健康度」公式(成功率×0.40 + 延迟分×0.25 + 稳定性×0.20 +
> 连续失败分×0.15)**已废止**,现行健康体系为:Redis 渠道×模型 EWMA 为唯一真相,
> 健康分 = `avg(succ_ewma)^α × 100`,调度权重见 `relaykit/dispatch/score.go` 的
> `EffectiveWeight`,落盘见 `internal/dispatchadapter/maintenance.go`。
> new-api / sub2api 两侧的分析不受影响,横向对比结论仍有参考价值。
---
## 目录
1. [team-api 当前调度实现](#一team-api-当前调度实现)
2. [三项目对比总表](#二三项目对比总表)
3. [关键差异深度对比](#三关键差异深度对比)
4. [team-api 现状评估与改进建议](#四team-api-现状评估与改进建议)
---
## 一、team-api 当前调度实现
team-api 的调度核心在两处:算法层 `relay/scheduler/scheduler.go`,编排层 `internal/logic/relay/provider.go` + `relay/handler/relay_handler.go`。
### 1. 调度入口与候选构建
`DataProviderImpl.GetChannelForModel`(`internal/logic/relay/provider.go:98`)→ `selectChannelFromDB`(`provider.go:120`):
- 从 `chn_abilities`(模型→渠道映射)JOIN `chn_channels` + `chn_health_scores` 拉候选,过滤 `enabled=true`、`status='active'`、租户渠道范围、排除已失败渠道(`excludeChannelIDs`)。
- 有 feature flag **`channel_scheduler_v2_enabled`** 控制两套路径:
- **关闭(legacy)**:按 `priority DESC, weight DESC` 排序,取第一个能取到上游 key 的渠道(`provider.go:162-195`)。纯优先级首选,无随机、无健康度。
- **开启(v2)**:构建 `[]scheduler.ChannelCandidate`,交给调度引擎(`provider.go:197-279`)。
### 2. 优先级 + 权重 + 健康度选择算法(`relay/scheduler/scheduler.go`)
`Select`(`scheduler.go:49`)算法注释明确:
1. 排除 `Weight <= 0` 的渠道;
2. 排除 `HealthScore < 20` 的渠道(若全不健康则保留全部,避免饿死);
3. 按优先级分组,**取最高优先级组**(`groupByPriority` 升序排,`groups[len-1]` 即数值**大**优先——与 new-api 一致,与 sub2api 相反);
4. 组内**加权随机**(`weightedRandomSelect`)。
**健康度降权**(`effectiveWeight`,`scheduler.go:185`):健康度 50-79 权重减半,20-49 降至 1/4,最低保 1。即不健康渠道不直接剔除,而是降低被选概率。
**`SelectStable`**(`scheduler.go:65`):在最高优先级组内,**优先用亲和渠道**(`preferredChannelID` 命中即直接返回),否则用 **Rendezvous 一致性哈希**(`weightedRendezvousSelect`,`scheduler.go:164`)做加权选择——相同 seed + 候选集在**不同进程/实例得到相同结果**,适合多实例部署的冷请求。seed = `tenant:user:apikey:model` + 时间桶(桶长 = 亲和 TTL)。
### 3. 渠道亲和性(`internal/logic/relay/affinity.go`)
- **key**:`sha256(tenantID:userID:apiKeyID:modelName)` → Redis 存 `channelID`,TTL 默认 **1800s**(`channel_affinity_ttl_seconds` 可配)。
- **反向索引**:`relay:affinity:v2:channel:<channelID>` 维护该渠道绑定的所有 key 集合,渠道失效时 `InvalidateChannelAffinities`(`affinity.go:80`)用 Lua 脚本批量清除——避免脏亲和。
- **feature flag** `channel_affinity_enabled` 控制。
- **流程**:`selectChannelFromDB` 先 `getAffinity` 拿 preferredChannelID → `SelectStable` 在最高优先级组内优先命中;成功后 `SetChannelAffinity` 写回;硬失败 `DeleteChannelAffinity`(`provider.go:222-275`)。
- **关键限制**:亲和**只在最高优先级组内生效**。若亲和渠道不在当前最高优先级组,会被降级处理(`preferredChannelID=0` 或删除亲和,`provider.go:239-246`)。
### 4. 失败重试(`relay/handler/relay_handler.go:417`)
```
maxRetries = 3 // 即最多 4 次尝试(attempt 0..3)
for attempt := 0; attempt <= maxRetries; attempt++ {
selection = GetChannelForModel(..., excludeChannelIDs) // 排除已失败渠道
...转发...
失败 → excludeChannelIDs = append(..., selection.ChannelID) → continue
}
```
- **跨渠道重试**:失败渠道累积进 `excludeChannelIDs`,下次选号排除(`relay_handler.go:472/499/561`)。
- **容量溢出不消耗重试次数**:`acquireChannelLease` 失败时 `attempt--` 再 continue(`relay_handler.go:473`)。
- **重试触发判定**:
- DoRequest 失败用 `IsRetryableForRequest`(`relay/constant/errors.go`):状态码 **429/500/502/503/504** 或网络瞬时错误;**昂贵非幂等生成(图片/视频)对「模糊送达」错误不重试**,避免上游重复生成 + 重复计费。
- DoResponse 失败:流式 / 不可重试 / `attempt>=maxRetries` / **`IsResponseWritten`(响应已写给客户端)** 任一满足即终止(`relay_handler.go:569`)——防止成功体追加到已写错误体后造成响应污染。
- **每次失败副作用**:`UpdateChannelHealth(false)` + `IncrementConsecutiveFailure` + `DeleteChannelAffinity`。
### 5. 健康度模型(`internal/logic/relay/health.go`)
- **综合健康度公式**(`calcHealthScore`,`health.go:96`):
`健康度 = 成功率×0.40 + 延迟分×0.25 + 稳定性×0.20 + 连续失败分×0.15`(0-100)
- **EWMA 平滑**:成功率 α=0.1(`new=old×0.9+sample×0.1`),延迟 α=0.3(`new=old×0.7+sample×0.3`)。
- **连续失败**:失败 +1,成功归零;连续失败分 = `100 - consecutiveFailures×20`。
- **自动禁用**:`checkAutoDisable`(`health.go:91`)连续失败达阈值自动禁用渠道。
- 这是个**混合模型**:EWMA 平滑的成功率/延迟 + 离散的连续失败计数,比 new-api 精细,但比 sub2api 的纯 EWMA(错误率 + TTFT)多了离散阈值。
### 6. 容量控制(`relay/handler/channel_lease.go`)
- `AcquireChannelSlot`:Redis 原子占并发槽,`MaxConcurrency` 超限则失败(Redis 异常降级放行)。
- `channelLease`:长请求 30s ticker 刷新租约(`refreshLoop`),结束 `Release`。
---
## 二、三项目对比总表
| 维度 | new-api | sub2api | **team-api** |
|------|---------|---------|--------------|
| 调度单位 | channel | account(订阅账户) | channel |
| 框架 | Gin + GORM | Gin + ent | GoFrame v2 + gdb |
| 优先级语义 | 数值**大**优先,严格分层 | 数值**小**优先;通用严格分层 / OpenAI 软因子 | 数值**大**优先,严格分层(v2) |
| 层内分配 | 加权随机(weight) | 通用 LRU;OpenAI Top-K 加权随机 | 加权随机 / Rendezvous 一致性哈希 |
| 重试计数 vs 优先级 | retry 当优先级层下标(耦合) | 完全解耦 | 完全解耦(excludeChannelIDs 独立) |
| 健康感知 | 仅 auto-ban(401 禁用) | **EWMA 错误率 + TTFT**,纳入评分 + 粘性逃逸 | **4 因子加权健康度**(成功率/延迟/稳定性/连续失败)+ 降权 + 自动禁用 |
| 亲和性定位 | 规则引擎(model/path/UA + KeySource),**为 prompt cache** | session hash + previous_response_id,**为对话 KV**,带健康度逃逸 | `tenant:user:apikey:model → channel`,Redis + 反向索引,**为路由稳定** |
| 亲和作用域 | 全局规则匹配 | 会话级(可跨优先级,带逃逸) | **仅最高优先级组内** |
| 重试上限 | `RetryTimes`(默认 **0**!) | 换号 **10**(Gemini 3)+ 同号 3 + HTTP 5 次/10s | **`maxRetries=3`**(4 次尝试) |
| 重试触发码 | 可配状态码范围(默认 4xx 除 400/408、5xx 除 504/524) | 401/403/429/529/≥500 换号 | 429/500/502/503/504 + 网络瞬时 |
| 流式安全 | 无特殊处理 | 流式已写禁止 failover | `ResponseWritten` 禁止重试 |
| 容量/并发控制 | 无显式 | 并发槽 + 等待队列 + wait plan | Redis 并发槽 + 租约刷新 |
| 跨实例稳定性 | 内存缓存(每实例独立) | 确定性种子 RNG | **Rendezvous 一致性哈希**(多实例对齐) |
| 非幂等保护 | 无 | 无显式 | **昂贵生成模糊送达不重试**(防重复计费) |
| 失败渠道处理 | auto-ban 移出缓存 | 临时封禁 + 换号 | 健康度降权 + 连续失败自动禁用 |
---
## 三、关键差异深度对比
### 1. 优先级模型:严格分层 vs 软因子
- **new-api / team-api**:严格分层——最高优先级组独占流量,层内才看权重/健康。设计简单、主备语义清晰。
- **sub2api(OpenAI 路径)**:优先级只是多维评分里的一个归一化因子,和负载/错误率/延迟平等竞争。更灵活(高优先级但高负载的渠道会被降级),但行为更难预测。
- **team-api 特点**:严格分层 + Rendezvous 哈希,是三者中**唯一原生支持多实例确定性选择**的(new-api 靠每实例内存缓存、sub2api 靠确定性种子,team-api 用一致性哈希更优雅)。
### 2. 健康感知:三者代际差异明显
- **new-api**:最粗糙——只有 401 auto-ban,无实时健康度。
- **team-api**:中间代——4 因子加权健康度 + EWMA 平滑 + 降权(不剔除)+ 连续失败自动禁用。粒度合理。
- **sub2api**:最精细——纯 EWMA(错误率 + TTFT)实时反馈,且**健康度参与粘性逃逸决策**(健康恶化主动跳船),这是 team-api 当前缺的能力。
### 3. 亲和性定位完全不同
- **new-api**:为 **prompt cache 命中**(绑定上游 KV 缓存),规则引擎匹配,全局生效。
- **sub2api**:为 **对话状态/KV**(订阅账户状态私有),会话级 + previous_response_id,**带健康度逃逸**。
- **team-api**:为 **路由稳定**(让同一租户/用户/key/模型尽量落同一渠道),简单直接,但**只限最高优先级组内**——若渠道分布在多优先级层,亲和效果会被优先级分层削弱。
### 4. 重试与 failover 的层次
- **new-api**:单层 `retry`,且默认 `RetryTimes=0`(不重试),耦合优先级层下标。
- **sub2api**:三层独立有界(同号 HTTP 5/10s → 同号业务 3 → 跨号 10),最细致,有流式安全阀。
- **team-api**:单层 `maxRetries=3`,但有三个优秀细节——容量溢出不消耗重试次数、`ResponseWritten` 禁止重试、昂贵非幂等生成的模糊送达不重试。整体介于 new-api 和 sub2api 之间。
---
## 四、team-api 现状评估与改进建议
### 现状评分
team-api 的调度实现**整体处于三者中游偏上**:
- ✅ **优于 new-api**:有真实健康度模型、响应已写安全阀、非幂等保护、跨实例一致性哈希、Redis 并发控制。
- ✅ **优于 sub2api**:多实例确定性(Rendezvous)、非幂等生成的重复计费保护。
- ⚠️ **弱于 sub2api**:缺粘性逃逸、重试层次单一、亲和受限于最高优先级组。
- ⚠️ **共性短板**:legacy/v2 双路径并存(`channel_scheduler_v2_enabled` 关闭时退化成纯优先级无健康度),需确保生产开 v2。
### 可落地的改进建议(按优先级)
| # | 建议 | 借鉴自 | 预期收益 |
|---|------|--------|---------|
| 1 | **亲和性加健康度逃逸**:亲和渠道 `HealthScore < 阈值` 时主动放弃亲和、降级选号,避免坏渠道粘死会话 | sub2api `shouldEscapeStickyAccount` | 防止一个故障渠道拖垮一批固定用户 |
| 2 | **亲和性作用域放宽**:允许亲和渠道跨优先级层命中(带降级权重),而非仅限最高优先级组 | sub2api 会话粘性跨优先级 | 提升亲和命中率,prompt cache/路由稳定收益更大 |
| 3 | **重试分层**:把「同渠道瞬时重试」与「跨渠道 failover」拆成两层独立计数,同渠道先短退避重试 1-2 次(429/503 常是瞬时的) | sub2api 三层 failover | 减少不必要的换渠道,降低抖动 |
| 4 | **健康度引入 TTFT**:在 4 因子里加首 token 延迟(流式场景比平均延迟更影响体感),或提高延迟权重 | sub2api EWMA TTFT | 流式体验优化 |
| 5 | **退避策略**:当前重试无退避(立即换渠道),高频 429 风暴下可能放大上游压力;加指数退避 | sub2api `retryBackoffDelay` / new-api | 保护上游、降低连环失败 |
| 6 | **下线 legacy 路径**:`channel_scheduler_v2_enabled` 关闭时退化为无健康度调度,建议确认生产已开并择机移除双路径 | — | 降低维护成本、避免误配置 |
### 关键源码索引(team-api)
| 关注点 | 文件:函数 |
|--------|----------|
| 调度算法入口 | `relay/scheduler/scheduler.go:49` — `Select`;`:65` — `SelectStable` |
| 候选过滤 + 最高优先级组 | `relay/scheduler/scheduler.go:83` — `highestPriorityCandidates` |
| 健康度降权 | `relay/scheduler/scheduler.go:185` — `effectiveWeight` |
| Rendezvous 一致性哈希 | `relay/scheduler/scheduler.go:164` — `weightedRendezvousSelect` |
| 选号编排(候选构建 + 亲和) | `internal/logic/relay/provider.go:120` — `selectChannelFromDB` |
| 亲和性读写 | `internal/logic/relay/affinity.go` — `getAffinity`/`setAffinity`/`InvalidateChannelAffinities` |
| 重试主循环 | `relay/handler/relay_handler.go:417` — `for attempt` |
| 重试判定 | `relay/constant/errors.go` — `IsRetryable`/`IsRetryableForRequest`/`IsResponseWritten` |
| 健康度计算 | `internal/logic/relay/health.go:22` — `UpdateHealthScore`;`:96` — `calcHealthScore` |
| 容量租约 | `relay/handler/channel_lease.go` — `acquireChannelLease`/`channelLease` |
-305
View File
@@ -1,305 +0,0 @@
# relay → relaykit 协议转换迁移 · 工作状态
> 记录时间:2026-09-10(上一版 2026-09-09)
> 分支:`feat/relaykit`(基于 main,领先 2 个已提交 commit + 大量未提交工作区改动)
> 状态:**未提交**。全仓 `go build ./...` + `go vet` 通过;relaykit 模块测试全绿;
> relay 侧仅剩既有失败(见 §四)。§5.1 已修,另发现并修复 **2 个本次迁移引入的回归**(见 §3.6)。
---
## 一、本次任务目标
把 relay 层残留的「纯协议转换」代码全部迁进独立模块 `relaykit/`,让 relaykit 成为唯一转换路径,
宿主 `relay/` 只保留 HTTP/SSE/计费/调度等宿主职责。
## 二、开工前定下的 4 个决策(用户已拍板)
| 决策点 | 选定方案 |
|--------|----------|
| 注册表改造 | **允许单侧注册**(请求侧或响应侧可单独为空),反向方向才注册得进去 |
| 跨原生方向表达 | **中枢链式 + 直连覆盖**:请求走 `A→OpenAI→B` 步骤链,响应用直连转换器(保真度高)。<br>后修订:Claude→Gemini 请求侧改为直连(见 §六.11) |
| legacy 兜底策略 | **立即 hard-fail**:不再"解析失败回退旧实现",legacy 随迁随删 |
| 迁移范围 | 全选:4 个跨原生响应桥 + openai/converter.go 反向方向 + o2c/o2g 双实现清理 + 小方向补全 |
## 三、已完成的工作
### 3.1 relaykit 注册表基建(新增/改造)
| 文件 | 说明 |
|------|------|
| `relaykit/relayconvert/text_converter_registry.go` | 放宽双侧非空约束 → 单侧注册(两侧都空才 panic),只把已配置的一侧写进对应注册表 |
| `relaykit/relayconvert/engine.go`(新增) | `ConvertRequestByID` / `ExecuteRequestConversion`:统一执行直连转换器与 StepConverters 步骤链,调用方无需区分 |
| `relaykit/relayconvert/stream_event.go`(新增) | `StreamEvent{Event, Data, Usage *dto.UsageWithDetails}`:非 OpenAI 客户端方向的通用流式帧封装(Event 非空=事件帧,空=纯 data 帧) |
| `relaykit/relayconvert/errors.go`(新增) | `ErrStatefulResponsesUnsupported` 哨兵(顶层包,宿主可 `errors.Is`) |
| `relaykit/relayconvert/convmeta/responses_stash.go`(新增) | `ResponsesStash` 能力接口:Responses 请求快照 stash/读取 |
| `request_registry.go` / `response_registry.go` | 补齐全部方向的 converter ID 常量(含跨原生、反向、Responses 双向、流式) |
**注意**:`StreamEvent.Usage` 中途从 `*dto.Usage` 升级为 `*dto.UsageWithDetails`(瘦类型会丢缓存明细 → 影响计费),
所有转换器已按新契约适配。
### 3.2 转换器迁移(4 个并行子任务,全部完成且测试通过)
| 子任务 | 产出包 | 内容 |
|--------|--------|------|
| A | `relaykit/relayconvert/internal/oai_chat/` | Claude 入站方向:`ClaudeToOpenAIRequestConverter`、`OpenAIToClaudeResponseConverter`、`OpenAIToClaudeStreamConverter` |
| B | `relaykit/relayconvert/internal/oai_gemini/` | Gemini 入站方向:`GeminiToOpenAIRequestConverter`、`OpenAIToGeminiResponseConverter`、`OpenAIToGeminiStreamConverter` |
| C | `relaykit/relayconvert/internal/oai_responses/`(新包)+ `relaykit/dto/openai_responses.go`(新) | Responses 双向 6 个转换器;Responses DTO 下沉到 relaykit,`relay/dto/openai_responses.go` 改为纯别名文件 |
| D | `internal/claude_gemini/` + `internal/native_responses/`(新包) | 8 个跨原生响应转换器(Claude↔Gemini、Claude/Gemini→Responses,各含流式) |
### 3.3 注册接线
`relaykit/relayconvert/register/register.go` 现注册 **12 个方向**:
- OpenAI 入站 → Claude / Gemini / Ollama(chat)(原有)
- 反向:Claude / Gemini / Responses 入站 → OpenAI 上游(新)
- OpenAI chat → Responses 上游(ChatViaResponses 桥接,新)
- 跨原生:Claude→Gemini、Gemini→Claude、Responses→Claude、Responses→Gemini
(新;响应侧全部直连,请求侧 Claude→Gemini 已改直连,其余仍为步骤链)
### 3.4 宿主桥接层改造
| 文件 | 说明 |
|------|------|
| `relay/relaykit_bridge/route.go`(**新增**) | **唯一权威方向矩阵**:`KitFormat`(宿主↔relaykit 格式名映射,Responses 命名不同需显式转)、`EffectiveUpstreamFormat`(含 UseResponsesAPI / UpstreamSpeaksResponses 覆盖)、`RequestConverterIDForRoute`、`ResponseConverterIDForRoute`。另在此包 blank-import `register` 触发注册(放桥接包而非某调用方,保证所有 channel 包及其单测都能拿到注册结果) |
| `relay/relaykit_bridge/response.go` | 签名改 4 值 `(converted, usage, handled, err)`,hard-fail 语义;按格式解析上游响应(新增 OpenAI/Responses 两种) |
| `relay/relaykit_bridge/stream.go` | chunkWriter 支持 `StreamEvent`(事件帧/data 帧分派 + usage 明细捕获);**收尾策略按客户端格式**:OpenAI/Gemini 客户端写 `[DONE]`(OpenAI 另补终止 chunk),Claude/Responses 客户端不写(终止语义由 message_stop / response.completed 承载) |
| `relay/handler/relaykit_bridge.go` | 重写:走统一矩阵、按入站格式解析请求、hard-fail、OpenAI 上游后处理(stream_options / reasoning_effort 注入,均"客户端未显式设置时"语义)、哨兵错误映射 |
| `relay/handler/relay_handler.go` | `convertRequestBody` 改三态处理(handled+err / handled / 未接管走 adaptor) |
| `relay/common/relay_info.go` | `*RelayInfo` 实现 `convmeta.ResponsesStash` |
### 3.5 宿主 legacy 删除(hard-fail 接线)
已改为纯桥接外壳并删除旧转换实现:
- `relay/channel/claude/response.go`(handleNonStreamToOpenAI / handleStreamToOpenAI,删约 270 行流式循环)
- `relay/channel/gemini/response.go`(同上,删约 300 行)
- `relay/channel/ollama/adaptor.go`(chat 非流式+流式,删约 150 行)
- `relay/channel/openai/claude_response.go`(重写为桥接外壳)
- `relay/channel/openai/gemini_response.go`(重写为桥接外壳,保留 `writeOpenAIErrorAsGemini` 等错误映射)
- 4 个跨原生桥 `claude/gemini_bridge.go`、`claude/responses_bridge.go`、`gemini/claude_bridge.go`、`gemini/responses_bridge.go`(由子任务改造,**见待办**)
---
## 四、当前验证状态
```
GOTOOLCHAIN=go1.25.8 go build ./... ✅ 通过
GOTOOLCHAIN=go1.25.8 go vet ./relay/... ✅ 通过
cd relaykit && GOWORK=off go test -count=1 ./... ✅ 全绿(14 个包)
GOTOOLCHAIN=go1.25.8 go test ./relay/... ✅ 仅 relay/helper 既有失败(见 §六.7)
GOTOOLCHAIN=go1.25.8 go test ./internal/... ⚠️ 3 个既有失败,与本次迁移无关:
· internal/logic/admin TestBuiltinRoleDefaultsMatchMigration
—— 找不到 000020_admin_role_management.sql(迁移已重编号为 000020_v0_2_17_version.sql)
· internal/dispatchadapter TestProbeFailure_DoesNotDecayHealthEwma —— 健康分 EWMA
· internal/logic/billing TestGenerateBillingSummary_TimeRule —— 账单文案时段行
(三处工作区均无改动,勿误当回归)
gofmt -l relay/ relaykit/ ⚠️ 剩 10 个**存量**未格式化文件
(ali/volcengine converter_test、relaykit dto/audio、goldentest、benchmark_test 等,
本次迁移触及的 5 个文件已 gofmt -w)
```
---
## 五、重启后的待办
### 5.1 ~~修复 3 个流式错误传播测试~~ ✅ 已完成(2026-09-10)
**上一版留下的坑**:这 3 个测试当时是被「反转断言」改绿的——把「应返回错误」改成
「不返回错误、只查 StreamStatus」。但 `StreamEndReasonError` **不在** `IsPartialStreamEnd()`
集合内(`relay/common/stream_status.go:79`),于是 `DoResponse` 返回 `(usage, nil)`,
落到 `relay_handler.go` 的**成功路径**:`sess.Finish(true)` 让调度 FSM 收到「成功」、
熔断/健康分拿不到上游故障信号,并按正常成功结算(usage 近零)。旧实现是返回 error 的。
**已按原文档倾向的方案 1 修正**:
- `TryConvertStreamViaRelaykit` 签名改三态 `(*common.Usage, bool, error)`;
`ok=true,err!=nil` = 已写出 SSE 但流以错误/中断结束,调用方原样 `return usage, err`。
- 新增 `streamConvertError`(`relay/relaykit_bridge/stream.go`)做错误分类,
一律置 `ResponseWritten`(SSE 头与 200 已提交,不得改状态码/换渠道重试)。
- 客户端断开分支直接透出 `common.ErrStreamInterrupted`,9 个调用点不再各自
读 `IsPartialStreamEnd()` 判定,统一收敛到桥接层。
- 3 个测试断言改回「应返回错误」,并加断分类与收尾事件。
**顺带修正的分类错误**:安全拦截(Gemini `promptFeedback.blockReason`)原先会被映射成
502 上游错误 → 正常渠道因用户发违规提示词被扣健康分乃至熔断。现新增
`relaykit/relayconvert.ErrContentBlocked` 哨兵(6 个 Gemini 出站转换器 wrap 返回),
宿主 `errors.Is` 识别后映射为请求类 4xx。非流式侧同理,新增
`relaykit_bridge.ResponseConvertError` 供 3 个 Gemini 上游非流式站点统一分类。
6 个方向(→Claude / →Responses / →OpenAI × 流式/非流式)均已有测试覆盖。
### 5.2 ~~核对子任务 D 的跨原生桥接线成果~~ ✅ 已核对(结论与担心相反)
`geminiToClaudeResponse` / `buildResponsesBodyFromGemini` **仍在且不能删**:
它们是 Code Assist 强制流式聚合路径(`gemini/adaptor.go:514,520`
`handleCodeAssistAggregatedStream`)复用的非流式装配函数,各文件头注释已写明。
计费 usage 已确认仍从**原始上游响应体**提取。
### 5.3 ~~收尾清理~~ ✅ 已完成(2026-09-10,legacy 删除落地)
删除过程中发现并补齐了 2 处迁移遗漏(见 §七的「删除时补的接线」)。
### 5.4 提交前检查
- `gofmt -l` 本次触及文件已清空(存量 10 个未动,避免污染本次 diff)
- `go vet ./relay/... ./internal/...` ✅
- 提交信息不得包含 AI 工具身份标识(项目规范)
- 建议拆 commit:注册表基建 / 转换器迁移 / 宿主接线 / legacy 删除 /
**本次新增:流式错误传播 + 安全拦截分类 / Anthropic 端点与 provider 后处理回归修复**
---
## 3.6 本次新发现并修复的 2 个回归(重点,评审必看)
两者同一个根因:**relaykit 的转换矩阵按「协议格式」裁决、与供应商无关**,
命中后 `adaptor.ConvertRequest` 整个不再被调用。而 §3.3 新增的「非 OpenAI 入站 →
OpenAI 上游」三个反向方向,把原本归 adaptor 的路径整片接管走了。
### 回归 A:Anthropic 兼容端点渠道的 Claude 入站双向断链(严重)
ali / zhipu / deepseek / moonshot / volcengine / minimax 这 6 家主协议是 OpenAI,
但对 `RelayModeClaudeMessages` **另挂独立的 Anthropic 兼容端点**
(`/anthropic/v1/messages`、`/apps/anthropic/v1/messages`、`/api/coding/v1/messages`),
Claude 入站时上游**实际说 Claude**:请求体原样发、响应体也是 Claude
(其 `DoResponse` 正是委托给 `claude.Adaptor`)。
但 `helper.ProviderNativeFormat` 只按渠道类型返回单一格式(这几家 → openai),
且 `inboundMatchesChannelNative` 因此判 false 使 `canPassThrough` 也不兜底,于是:
- 请求侧:Claude 体被 relaykit 转成 OpenAI chat,POST 到 Anthropic 端点 → 上游 400;
- 响应侧:返回的 Claude 响应体被当 OpenAI 解析 → 转换失败。
**修复**:新增 `constant.HasNativeClaudeEndpoint(providerType)`,在
`relaykit_bridge.EffectiveUpstreamFormat` 与 `handler.inboundMatchesChannelNative`
两处叠加判定 —— Claude 入站 + 该供应商 ⇒ 有效上游格式 = Claude ⇒ 同格式、矩阵返回空串
⇒ 不转换,回到 adaptor / 直连原路径。
> 登记判据:`GetRequestURL` 对 `ClaudeMessages` 返回了与 chat 端点**不同**的地址。
> 共用同一端点的(aws / baidu_v2 / mistral / openai / xai / xunfei)上游只说 OpenAI,
> 不属于本集合,Claude 入站仍须转换。新增此类供应商时必须同步登记。
### 回归 B:5 个供应商的私有请求后处理被绕过
xai / baidu_v2 / zhipu / ali / deepseek 在格式转换之外还有私有请求适配,
relaykit 只补了通用的 `model` / `stream_options` / `reasoning_effort`:
| provider | 丢失的后处理 | 后果 |
|---|---|---|
| xai | `-search`/`-high`/`-low` 后缀剥离 + `search_parameters`/`reasoning_effort` | 未配映射时 `grok-4-search` 原样发上游 → 400 |
| baidu_v2 | `-search` 后缀剥离 + `web_search` 注入 | 同上 |
| zhipu | `applyGLMCompatibility`(top_p 裁剪、图片前缀剥离)+ 思考参数方言 | GLM 参数越界、思考失效 |
| ali | DashScope 按上游模型剥离 `thinking_budget` | 上游拒绝不支持该参数的模型 |
| deepseek | `thinking` 对象(V3 `type` + R1 `reasoning_effort:"none"`) | `-thinking`/`-nothinking` 静默失效 |
模型名那条最实际:未配映射时 `UpstreamModel = row.ModelName`
(`internal/dispatchadapter/catalog.go:443`)**带着后缀**,relaykit 无条件写
`GetUpstreamModelName()`,而 adaptor 本来会剥掉后缀再写回。
**修复**:新增可选能力接口 `common.RequestPostProcessor`
(`PostProcessConvertedRequest(ctx, info, body) ([]byte, error)`),
`handler.convertRequestBody` **仅在 relaykit 接管时**调用(走 adaptor 那条路径的
`ConvertRequest` 内部已含同一段,重复执行会双写)。5 家各把自己 `ConvertRequest`
的尾段抽成 `postProcessRequest`,与钩子共用同一实现,杜绝两份漂移。
已核对**不受影响**:minimax / moonshot 的 Claude 入站分支只做模型名映射(relaykit 已覆盖);
其余约 15 个 OpenAI 兼容 provider 的 `ConvertRequest` 无格式外后处理。
---
## 七、legacy 删除完成记录(2026-09-10 已全部落地)
净删约 **6400 行**(git diff --stat:+530 / -6907)。逐项:
| 项 | 结果 |
|----|------|
| `openai/converter.go` o2r 段(`ConvertOpenAIToResponses` + c2r 助手) | ✅ 删除(chat→Responses 请求转换由 relaykit `ConverterOpenAIChatToOpenAIResponses` 接管,handler 桥注入 reasoning_effort) |
| `openai/converter.go` c2o/g2o/r2o(`ConvertClaudeToOpenAI` 等) | ⚠️ **保留**:Ollama 渠道的 Claude/Gemini/Responses 入站走 `ConvertToOpenAI`→OpenAI→Ollama 链(矩阵未注册 Claude→Ollama 方向,属「未迁方向保留」设计) |
| `openai/responses.go` `handleResponsesInboundStream`/`NonStream`(约 600 行) | ✅ 改为 relaykit 桥接壳(**这是迁移漏掉的接线**——转换器早注册了但 openai adaptor 一直在调 legacy 实现);孤儿助手 `chatCompletionToResponsesResponse`/`extractStreamEmbeddedError` 删除,`extractResponsesRequestEcho` 保留(`BuildResponsesObjectMap` 导出依赖) |
| `claude/converter.go`(446 行)+ `converter_test.go` | ✅ 整文件删除(o2c 请求侧全套) |
| `gemini/converter.go`(686 行)+ `converter_test.go` | ✅ 整文件删除(o2g 请求侧全套) |
| 18 个 adaptor 的 `ConvertToOpenAI` 守卫 | ✅ 删 17 个(死代码:非 OpenAI 入站→OpenAI 上游已全由矩阵接管);**ollama 保留**(见上) |
| claude/gemini/openai adaptor 的入站格式 switch | ✅ 删除,改为同格式路径 + **hard-fail 守卫**(矩阵意外未接管时显式报错,不静默错格式透传) |
| `deepseek` `convertResponsesToChatForDeepSeek` | ✅ 删除,分支改 hard-fail(relaykit Responses→OpenAI + PostProcess 钩子接管) |
| stale 注释(passthrough/bridge 头注释中的旧函数名) | ✅ 更新为 relaykit 表述 |
**删除时补的接线**(不做这两步直接删会断链):
1. **openai adaptor responses 入站桥接**:迁移只注册了转换器、没改 adaptor 调用点。已把两个 inbound 处理器改为调 `TryConvertStream/ResponseViaRelaykit`(非 200 透传语义保留,计费仍取上游原始 chat 体口径)。
2. **Responses 客户端流错误收尾**:桥接层在转换器以错误退出且未产出任何终止态事件时补发 `response.failed`(否则 200 SSE 静默结束、codex 等客户端挂起);转换器已自行收尾的(如 Gemini SAFETY 发 completed 拒答)不追加第二终止事件。相关测试已按新契约改写。
**验证**:`go build ./...` ✅、`go vet ./relay/... ./internal/...` ✅、relaykit 模块全绿 ✅、
relay 测试仅剩 §六.7 既有失败 ✅、触及文件 gofmt 干净 ✅。
## 六、重要设计约束备忘(重启后容易忘)
1. **格式名两侧不同**:宿主 `constant.RelayFormatResponses = "responses"`,relaykit `types.RelayFormatOpenAIResponses = "openai_responses"`。
跨模块必须走 `relaykit_bridge.KitFormat()`,**不能直接强转**(其余格式字符串值相同可强转)。
2. **计费 usage 一律取上游原始口径**,不从转换后的响应体取。OpenAI 上游方向需置
`CacheIncludedInPrompt = true`(prompt 含 cached,计费按明细扣减,避免双重计费)。
3. **转换器只能依赖 `convmeta.Meta` 接口**,禁止 import 宿主 `relay/...` 或 GoFrame。
需要宿主状态时走能力接口(如 `ResponsesStash`)+ 类型断言,断言失败静默降级。
4. **`relay/dto` 是 `relaykit/dto` 的类型别名**(`relaykit_types.go` + `openai_responses.go`),两侧同型,边界零转换。
5. **转换器 ID 元数据方法(ID/From/To/Quality)与注册用的配对 spec ID 不是一回事**:
注册以 `RegisterTextConverter` 传入的 spec.ID 为准,元数据方法只是自描述。
6. relaykit 是独立 module:测试要 `cd relaykit && GOWORK=off go test ./...`;
宿主构建要 `GOTOOLCHAIN=go1.25.8`(否则 stdlib 重编译失败)。
7. `relay/helper` 的 `TestSafeUpstreamErrorMessage_*` 失败是**既有问题**(中英文案期望不一致),
与本次迁移无关,不要误当回归去修。
8. **矩阵按格式裁决、与供应商无关**——这是本次两个回归的共同根因。新增/修改方向时必须自问:
① 该供应商对这个 relay mode 是否另有原生端点(→ `constant.HasNativeClaudeEndpoint` 类判定)?
② 该供应商的 `ConvertRequest` 里除格式转换外是否还有私有适配
(→ 实现 `common.RequestPostProcessor`)?漏掉任一条都是**静默**降级或断链。
9. **`ProviderNativeFormat` 是「按渠道类型的单一格式」,不是全部真相**:多协议供应商的
有效上游格式取决于 (供应商, relay mode),权威计算在 `relaykit_bridge.EffectiveUpstreamFormat`,
`handler.inboundMatchesChannelNative`(直连判定)必须与它保持同步,否则直连与转换两条路会对同一
请求给出不同格式结论。
10. **provider 后处理钩子只在 relaykit 路径调用**:adaptor 路径的 `ConvertRequest` 内部已含同一段,
两处都调会双写参数。5 家的实现均把尾段抽成 `postProcessRequest` 与钩子共用,改动时不要只改一处。
11. **跨原生请求侧优先直连,不要新增中枢步骤链**:Claude→Gemini 已由
`internal/claude_gemini/ClaudeToGeminiRequestConverter` 直连。链式(`StepConverters`)会让
中间格式承载不了的字段静默消失——实测丢 thinking 签名、精确 thinking budget(5000 被放大成 8192)、
`top_k`、`tool_use.id` 四项,见 `claude_to_gemini_request_test.go` 的
`TestClaudeToGeminiRequest_PreservesWhatChainLoses`。新增跨原生方向时先写直连转换器,
链式只作过渡手段。Gemini 的 JSON Schema 关键字白名单在 `internal/shared.CleanGeminiToolParams`,
OpenAI→Gemini 与 Claude→Gemini 两条路径共用一处,改白名单是两边同时生效。
---
## 八、模型名后缀(虚拟模型)机制移除(2026-09-10)
经用量核实无任何带后缀模型(`-thinking`/`-nothinking`/`-none`/`-minimal`/`-low`/`-medium`/
`-high`/`-xhigh`/`-max`/`-search`)在用,整套「模型名后缀 → 虚拟模型」机制作为技术债务移除。
思考/搜索能力此后一律由**客户端请求参数**表达(`thinking` 对象、`reasoning_effort`、
`reasoning.effort` 等),网关只做协议转换不做能力注入。
### 删除清单
**宿主侧:**
- `relay/helper/thinking.go`(`ParseThinkingSuffix` + `ThinkingInfo`)整文件删
- `resolveRelayModel` 简化为纯目录校验(不再「字面优先 + 剥后缀回退」两段查找)
- `RelayInfo` 删 `ThinkingEnabled`/`ThinkingDisabled`/`ReasoningEffort` 三字段
及 `Get/SetReasoningEffort`(convmeta.Meta 相应方法一并删,本就零调用方)
- 各注入器删除:claude `injectClaudeThinking/Effort`、gemini `injectGeminiThinking`、
openai `injectReasoningEffort/injectResponsesReasoning`、deepseek 三个 inject* +
`isDeepSeekV4Model`、zhipu `injectThinkingParams`
- `canPassThrough` 删 thinking 后缀排除条件;handler 桥删两处 effort 注入(stream_options 保留)
- xai/baidu_v2 的 `-search`/`-high`/`-low` 剥离删除,两家 + deepseek 的
`RequestPostProcessor` 钩子实现删除(剩余职责 relaykit 已覆盖);**钩子接口保留**,
现存实现方仅 zhipu(GLM 参数兼容)与 ali(DashScope 参数裁剪)
**relaykit 侧:**
- `internal/shared/thinking_adapter.go`、`reasoning/` 包整体删除
- `convmeta.Options` 删 `PreserveThinkingSuffix`;Claude/Gemini Options 删
`ThinkingAdapter*` 字段(宿主侧写死 TODO 的配置一并清)
- oai_chat 请求转换器不再解析上游模型名后缀
### 保留(勿误删)
- **客户端参数转换线**:`c2oConvertThinkingToReasoningEffort` / `g2oConvertThinkingConfig`
(Claude thinking 对象 / Gemini thinkingConfig → chat `reasoning_effort` 字段)——
这是协议转换不是后缀注入,宿主 converter.go 与 relaykit oai_chat 各一份
- `bil_usage_logs.reasoning_effort` 列与 `UsageRecord.ReasoningEffort` 字段保留
(dao 生成 + 历史数据),此后恒为空串
- `RelayInfo.BaseModelName` 字段保留(responses 路由记录在用),现恒等于 OriginModelName
### 行为变化
- 请求带旧后缀名(如 `gpt-4o-thinking`)→ 目录查不到 → **404 model not found**
(原为剥后缀回退命中 + 注入思考参数)
- 名字本身含 `-max` 等字样的真实目录模型(qwen-max)不受影响(本就字面优先)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

+4
View File
@@ -184,6 +184,10 @@ var (
s.Use(middleware.RequestId)
s.Use(middleware.ServiceName)
// IP 黑名单:业务链路第一道闸门(Recovery/RequestId/ServiceName 就绪之后、
// setup 守卫与所有路由组之前),命中黑名单的请求在任何路由之前直接 403
s.Use(middleware.IpBlacklist)
// Setup mode guard: block all requests until initialization is complete
s.Use(func(r *ghttp.Request) {
if !setupHandler.IsSetupMode() {
+31
View File
@@ -3,6 +3,7 @@ package admin
import (
"context"
"fmt"
"sort"
"strings"
"time"
@@ -144,9 +145,39 @@ func (s *sAdmin) GetDashboardStats(ctx context.Context, req *v1.AdminDashboardRe
TotalCost: monthRow.TotalCost,
Revenue: revenue.Total,
},
IpBlacklist: buildIpBlacklistStats(ctx),
}, nil
}
// buildIpBlacklistStats 汇总 IP 黑名单拦截统计(缓存数据):
// Redis 读取失败时降级为仅返回启用状态,不影响仪表盘其余统计的正常返回。
func buildIpBlacklistStats(ctx context.Context) *v1.IpBlacklistStats {
stats := &v1.IpBlacklistStats{
Enabled: common.Config().GetBool(ctx, common.OptionKeyIpBlacklistEnabled),
List: []v1.IpBlacklistBlockedItem{},
}
counts, err := common.GetIpBlacklistBlockStats(ctx)
if err != nil {
g.Log().Warningf(ctx, "buildIpBlacklistStats: read blocked stats failed: %v", err)
return stats
}
items := make([]v1.IpBlacklistBlockedItem, 0, len(counts))
for ip, count := range counts {
stats.Total += count
items = append(items, v1.IpBlacklistBlockedItem{Ip: ip, Count: count})
}
sort.Slice(items, func(i, j int) bool { return items[i].Count > items[j].Count })
// 看板只展示 TOP 20,长尾 IP 的拦截量对运营无观测价值,避免极端脏数据撑爆响应
const topN = 20
if len(items) > topN {
items = items[:topN]
}
stats.List = items
return stats
}
// GetDashboardTrends returns daily revenue and request trends for the past N days.
func (s *sAdmin) GetDashboardTrends(ctx context.Context, req *v1.AdminDashboardTrendsReq) (*v1.AdminDashboardTrendsRes, error) {
days := req.Days
+8 -1
View File
@@ -111,8 +111,15 @@ func (s *sAdmin) validateGeneralSettings(_ context.Context, values map[string]st
return nil
}
// validateSecuritySettings 校验安全配置项之间的依赖:启用 Turnstile 前必须已配置密钥。
// validateSecuritySettings 校验安全配置项之间的依赖:启用 Turnstile 前必须已配置密钥;
// IP 黑名单每个条目必须是合法 IP / CIDR(坏条目入库后中间件会静默跳过,必须保存时拦下)。
func (s *sAdmin) validateSecuritySettings(ctx context.Context, values map[string]string) error {
if raw, ok := values["ip_blacklist_list"]; ok && strings.TrimSpace(raw) != "" {
if err := common.ValidateIpBlacklistRaw(raw); err != nil {
return common.NewBadRequestError(err.Error())
}
}
enabled, ok := values["turnstile_enabled"]
if !ok || (enabled != "true" && enabled != "1") {
return nil
+135
View File
@@ -0,0 +1,135 @@
package common
import (
"context"
"encoding/json"
"net"
"strings"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
)
// IP 黑名单:配置存 sys_options(安全分类,复用通用设置读写链路,无独立表),
// 拦截计数存 Redis hash(field=IP,value=累计拦截次数)。计数仅作运营观测,
// 不落库,随 Redis 数据清空而丢失——这是有意为之的简化(需求明确缓存即可)。
const (
OptionKeyIpBlacklistEnabled = "ip_blacklist_enabled"
OptionKeyIpBlacklistList = "ip_blacklist_list"
ipBlacklistBlockedRedisKey = "ip_blacklist:blocked"
)
// IpBlacklistEntry 单条黑名单条目:精确 IP 或 CIDR 网段。
type IpBlacklistEntry struct {
Raw string
ip net.IP // 精确 IP(CIDR 条目为 nil)
cidr *net.IPNet // CIDR 网段(精确 IP 条目为 nil)
}
// Contains 判断客户端 IP 是否命中该条目。
func (e *IpBlacklistEntry) Contains(ip net.IP) bool {
if ip == nil {
return false
}
if e.ip != nil {
return e.ip.Equal(ip)
}
if e.cidr != nil {
return e.cidr.Contains(ip)
}
return false
}
// splitIpBlacklistRaw 拆分黑名单配置值为候选条目:主体格式为 JSON 字符串数组
// (设置页保存),容错支持逗号分隔的裸文本(如手工改库后的脏格式)。
func splitIpBlacklistRaw(raw string) ([]string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
if strings.HasPrefix(raw, "[") {
var arr []string
if err := json.Unmarshal([]byte(raw), &arr); err != nil {
return nil, gerror.New("IP 黑名单必须是 JSON 字符串数组")
}
return arr, nil
}
return strings.Split(raw, ","), nil
}
// ParseIpBlacklistEntries 解析黑名单配置值为条目列表(去重、去空、跳过非法条目)。
// 非法条目静默跳过:保存入口已做格式校验,此处兜底脏数据,保证中间件永不 panic。
func ParseIpBlacklistEntries(raw string) []IpBlacklistEntry {
parts, err := splitIpBlacklistRaw(raw)
if err != nil {
return nil
}
entries := make([]IpBlacklistEntry, 0, len(parts))
seen := make(map[string]struct{}, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
if _, dup := seen[p]; dup {
continue
}
if ip := net.ParseIP(p); ip != nil {
seen[p] = struct{}{}
entries = append(entries, IpBlacklistEntry{Raw: p, ip: ip})
continue
}
if _, cidr, cidrErr := net.ParseCIDR(p); cidrErr == nil {
seen[p] = struct{}{}
entries = append(entries, IpBlacklistEntry{Raw: p, cidr: cidr})
}
}
return entries
}
// ValidateIpBlacklistRaw 校验黑名单配置值:所有条目必须是合法 IP 或 CIDR。
// 坏条目入库后中间件会静默跳过,运营侧无从感知,必须在保存入口拦下。
func ValidateIpBlacklistRaw(raw string) error {
parts, err := splitIpBlacklistRaw(raw)
if err != nil {
return err
}
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
if net.ParseIP(p) == nil {
if _, _, cidrErr := net.ParseCIDR(p); cidrErr != nil {
return gerror.Newf("非法的 IP 或网段:%s", p)
}
}
}
return nil
}
// IncrIpBlacklistBlocked 黑名单 IP 被拦截时累加计数(best-effort):
// 拦截决策已定,统计失败静默丢弃——不重试、不刷告警日志,避免 Redis 抖动放大成日志风暴。
func IncrIpBlacklistBlocked(ctx context.Context, ip string) {
_, _ = g.Redis().Do(ctx, "HINCRBY", ipBlacklistBlockedRedisKey, ip, 1)
}
// GetIpBlacklistBlockStats 读取拦截统计:返回 {ip: 累计拦截次数}。
// Redis 异常时返回错误,由调用方降级(看板仍展示其余统计数据)。
func GetIpBlacklistBlockStats(ctx context.Context) (map[string]int64, error) {
result, err := g.Redis().Do(ctx, "HGETALL", ipBlacklistBlockedRedisKey)
if err != nil {
return nil, err
}
if result.IsNil() || result.IsEmpty() {
return map[string]int64{}, nil
}
counts := make(map[string]int64)
for ip, v := range result.MapStrVar() {
counts[ip] = v.Int64()
}
return counts, nil
}
+115
View File
@@ -0,0 +1,115 @@
package common
import (
"net"
"testing"
)
func mustParseIP(t *testing.T, s string) net.IP {
t.Helper()
ip := net.ParseIP(s)
if ip == nil {
t.Fatalf("测试用例的 IP 非法: %s", s)
}
return ip
}
func TestParseIpBlacklistEntries(t *testing.T) {
tests := []struct {
name string
raw string
wantRaw []string // 期望解析出的条目顺序
}{
{"空串", "", nil},
{"JSON 空数组", "[]", nil},
{"JSON 数组含精确 IP 与 CIDR", `["1.2.3.4", "10.0.0.0/8", "2001:db8::/32"]`,
[]string{"1.2.3.4", "10.0.0.0/8", "2001:db8::/32"}},
{"JSON 数组去重去空", `["1.2.3.4", "1.2.3.4", "", " 5.6.7.8 "]`, []string{"1.2.3.4", "5.6.7.8"}},
{"裸文本逗号分隔(手工改库容错)", "1.2.3.4,10.0.0.0/8", []string{"1.2.3.4", "10.0.0.0/8"}},
{"非法条目静默跳过", `["1.2.3.4", "not-an-ip", "300.1.2.3"]`, []string{"1.2.3.4"}},
{"JSON 语法错误返回空", `["1.2.3.4"`, nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
entries := ParseIpBlacklistEntries(tt.raw)
if len(entries) != len(tt.wantRaw) {
t.Fatalf("条目数不符: got %d (%v), want %d", len(entries), entries, len(tt.wantRaw))
}
for i, e := range entries {
if e.Raw != tt.wantRaw[i] {
t.Errorf("条目[%d] = %q, want %q", i, e.Raw, tt.wantRaw[i])
}
}
})
}
}
func TestIpBlacklistEntryContains(t *testing.T) {
entries := ParseIpBlacklistEntries(`["1.2.3.4", "10.0.0.0/8", "2001:db8::/32"]`)
if len(entries) != 3 {
t.Fatalf("前置解析失败: %v", entries)
}
tests := []struct {
name string
ip string
want bool
}{
{"精确 IPv4 命中", "1.2.3.4", true},
{"精确 IPv4 相邻不命中", "1.2.3.5", false},
{"CIDR 内命中", "10.1.2.3", true},
{"CIDR 外不命中", "11.0.0.1", false},
{"IPv6 CIDR 命中", "2001:db8::1234", true},
{"IPv6 CIDR 外不命中", "2001:db9::1", false},
{"IPv4-mapped IPv6 命中 v4 CIDR", "::ffff:10.2.3.4", true},
{"空 IP 不命中", "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// 空串对应 net.ParseIP 返回 nil 的场景(客户端 IP 解析失败),验证 Contains(nil) 安全返回 false
var ip net.IP
if tt.ip != "" {
ip = mustParseIP(t, tt.ip)
}
got := false
for i := range entries {
if entries[i].Contains(ip) {
got = true
break
}
}
if got != tt.want {
t.Errorf("Contains(%s) = %v, want %v", tt.ip, got, tt.want)
}
})
}
}
func TestValidateIpBlacklistRaw(t *testing.T) {
valid := []string{
"",
"[]",
`["1.2.3.4"]`,
`["1.2.3.4", "10.0.0.0/8"]`,
`["2001:db8::/32", "::1"]`,
}
for _, raw := range valid {
if err := ValidateIpBlacklistRaw(raw); err != nil {
t.Errorf("ValidateIpBlacklistRaw(%q) = %v, want nil", raw, err)
}
}
invalid := []string{
`["1.2.3.4", "not-an-ip"]`,
`["300.1.2.3"]`,
`["10.0.0.0/33"]`,
`["1.2.3.4"`,
}
for _, raw := range invalid {
if err := ValidateIpBlacklistRaw(raw); err == nil {
t.Errorf("ValidateIpBlacklistRaw(%q) = nil, want error", raw)
}
}
}
@@ -130,6 +130,10 @@ var Registry = []SettingDef{
Label: "验证码有效期(秒)", Validation: "min:60,max:600"},
{Key: "register_forbidden_words", Type: SettingTypeString, Default: "admin,system,root,api,test,administrator,管理员,系统", Category: "security",
Label: "注册禁用词", Description: "组织名称、组织代码、用户名中包含这些词时禁止注册(不区分大小写),多个禁用词用英文逗号分隔"},
{Key: "ip_blacklist_enabled", Type: SettingTypeBool, Default: "false", Category: "security",
Label: "启用 IP 黑名单", Description: "开启后,命中黑名单的 IP 访问任何端点(控制台 / AI 代理)都会被直接拒绝,保存后即时生效"},
{Key: "ip_blacklist_list", Type: SettingTypeJSON, Default: "[]", Category: "security",
Label: "IP 黑名单列表", Description: "JSON 字符串数组,支持精确 IP 与 CIDR 网段,如 [\"1.2.3.4\", \"10.0.0.0/8\"];拦截次数统计在仪表盘展示(存于缓存,不落库)"},
// ── Audit ──
{Key: "audit_level", Type: SettingTypeString, Default: "full", Category: "audit",
+113
View File
@@ -0,0 +1,113 @@
package middleware
import (
"encoding/json"
"net"
"net/http"
"strings"
"sync/atomic"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
"github.com/qianfree/team-api/internal/consts"
"github.com/qianfree/team-api/internal/logic/common"
)
// ipBlacklistMatcher 按配置原始串编译好的黑名单条目快照。
// 配置经 ConfigService 的 L1 内存缓存读取本身够快,但 JSON 反解析不值得每请求重复,
// 以原始串为版本号做原子快照:配置变更(Pub/Sub 失效缓存)后的首个请求重新编译。
type ipBlacklistMatcher struct {
raw string
entries []common.IpBlacklistEntry
}
var ipBlacklistMatcherSnapshot atomic.Value
// loadIpBlacklistMatcher 取黑名单快照,配置串未变化时零开销复用。
// 并发下可能重复编译一次,原子覆盖无锁竞争,结果一致,无需消重。
func loadIpBlacklistMatcher(raw string) *ipBlacklistMatcher {
if m, ok := ipBlacklistMatcherSnapshot.Load().(*ipBlacklistMatcher); ok && m.raw == raw {
return m
}
m := &ipBlacklistMatcher{raw: raw, entries: common.ParseIpBlacklistEntries(raw)}
ipBlacklistMatcherSnapshot.Store(m)
return m
}
func (m *ipBlacklistMatcher) match(ip net.IP) bool {
if ip == nil || len(m.entries) == 0 {
return false
}
for i := range m.entries {
if m.entries[i].Contains(ip) {
return true
}
}
return false
}
// IpBlacklist 全局中间件:命中黑名单 IP 的请求在任何业务路由之前直接拒绝(403)。
// 挂在 Recovery/RequestId/ServiceName 之后(保证 panic 兜底与 request_id 就绪)、
// setup 守卫与所有路由组之前,是请求进入业务链路的第一道闸门。
// /api/health 豁免:存活探针来自 LB/编排器,探针 IP 一旦被误拉黑会引发实例被
// 连锁摘流的级联故障,而健康端点本身无业务价值,不值得拦。
func IpBlacklist(r *ghttp.Request) {
if r.URL.Path == "/api/health" {
r.Middleware.Next()
return
}
ctx := r.Context()
if !common.Config().GetBool(ctx, common.OptionKeyIpBlacklistEnabled) {
r.Middleware.Next()
return
}
m := loadIpBlacklistMatcher(common.Config().GetString(ctx, common.OptionKeyIpBlacklistList))
// GetClientIp 与全站限流/审计同一信任模型(取 X-Forwarded-For 等代理头),
// 部署必须保证代理层覆写这些头,否则客户端可伪造绕过
ipText := r.GetClientIp()
if !m.match(net.ParseIP(ipText)) {
r.Middleware.Next()
return
}
// 拦截计数 best-effort:决策已定,统计失败不影响响应
common.IncrIpBlacklistBlocked(ctx, ipText)
writeIpBlacklistedResponse(r)
}
// writeIpBlacklistedResponse 按端点协议返回拒绝响应:
// AI 代理端点(/v1 /v1beta /v2 /suno)保持供应商原生错误格式(/v1/messages 用 Claude 格式,
// 其余用 OpenAI 格式),管理类端点走平台统一 JSON 格式——与 ApiMaintenance 的分流方式一致。
func writeIpBlacklistedResponse(r *ghttp.Request) {
const message = "IP 已被列入黑名单,禁止访问"
path := r.URL.Path
r.Response.Header().Set("Content-Type", "application/json")
r.Response.WriteHeader(http.StatusForbidden)
switch {
case strings.HasPrefix(path, "/v1/messages"):
body, _ := json.Marshal(map[string]any{
"type": "error",
"error": map[string]string{"type": "forbidden", "message": message},
})
r.Response.Write(body)
case strings.HasPrefix(path, "/v1/") || strings.HasPrefix(path, "/v1beta/") ||
strings.HasPrefix(path, "/v2/") || strings.HasPrefix(path, "/suno/"):
body, _ := json.Marshal(map[string]any{
"error": map[string]string{"type": "forbidden", "message": message},
})
r.Response.Write(body)
default:
r.Response.WriteJson(g.Map{
"code": consts.CodeForbidden,
"message": message,
"data": nil,
"request_id": r.GetCtxVar("RequestId"),
})
}
r.Exit()
}
+39
View File
@@ -29,6 +29,7 @@ const modelDist = ref<any[]>([])
const channelHealth = ref<any[]>([])
const recentAlerts = ref<any[]>([])
const modelHourly = ref<any>(null)
const ipBlacklist = computed(() => dashboard.value?.ip_blacklist)
// === Charts ===
let trendChart: echarts.ECharts | null = null
@@ -560,6 +561,26 @@ onBeforeUnmount(() => {
<AEmpty v-else description="暂无告警" />
</ACard>
</div>
<!-- Row 5: IP 黑名单拦截统计(数据存 Redis 缓存) -->
<ACard :bordered="false" class="mb-6" title="IP 黑名单拦截">
<template #extra>
<ATag v-if="ipBlacklist?.enabled" color="red" size="small">黑名单已启用</ATag>
<ATag v-else color="gray" size="small">黑名单未启用</ATag>
</template>
<div v-if="ipBlacklist && (ipBlacklist.total > 0 || (ipBlacklist.list && ipBlacklist.list.length > 0))">
<div class="ipbl-total">
累计拦截 <strong>{{ formatNumber(ipBlacklist.total) }}</strong> 次
</div>
<div class="info-list">
<div v-for="item in ipBlacklist.list" :key="item.ip" class="info-item">
<span class="ipbl-ip">{{ item.ip }}</span>
<ATag color="red" size="small">{{ formatNumber(item.count) }} 次</ATag>
</div>
</div>
</div>
<AEmpty v-else description="暂无拦截记录" />
</ACard>
</div>
</ASpin>
</template>
@@ -669,6 +690,24 @@ onBeforeUnmount(() => {
white-space: nowrap;
}
.ipbl-total {
font-size: 13px;
color: var(--color-text-2);
margin-bottom: 14px;
}
.ipbl-total strong {
font-size: 18px;
color: var(--color-text-1);
margin: 0 2px;
}
.ipbl-ip {
font-family: monospace;
font-size: 12px;
color: var(--color-text-1);
}
@keyframes cardAppear {
from {
opacity: 0;
@@ -1,6 +1,27 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useFormValues } from './useSettings'
const values = useFormValues()
// 黑名单列表在表单里以「每行一个 IP/网段」编辑,存库为 JSON 字符串数组
const ipBlacklistText = computed<string>({
get: () => {
const raw = (values['ip_blacklist_list'] as string) || '[]'
try {
const arr = JSON.parse(raw)
return Array.isArray(arr) ? arr.join('\n') : String(raw)
} catch {
return String(raw)
}
},
set: (v: string) => {
const arr = v
.split(/\r?\n|,/)
.map(s => s.trim())
.filter(Boolean)
values['ip_blacklist_list'] = JSON.stringify(arr)
},
})
</script>
<template>
@@ -85,6 +106,32 @@ const values = useFormValues()
</AFormItem>
</div>
</div>
<!-- IP 黑名单 -->
<div class="section">
<div class="section-title">IP 黑名单</div>
<div class="section-desc">
开启后,命中黑名单的 IP 访问任何端点(管理后台 / 租户控制台 / AI 代理)都会被直接拒绝,保存后即时生效。
支持精确 IP 与 CIDR 网段;拦截次数统计在仪表盘展示(存于缓存,不落库)
</div>
<div class="section-grid">
<AFormItem label="启用 IP 黑名单">
<ASwitch
:model-value="!!values['ip_blacklist_enabled']"
@change="(v: string | number | boolean) => values['ip_blacklist_enabled'] = v"
/>
</AFormItem>
</div>
<div class="section-grid" style="grid-template-columns: 1fr">
<AFormItem label="黑名单列表">
<ATextarea
v-model="ipBlacklistText"
:placeholder="'1.2.3.4\n10.0.0.0/8\n2001:db8::/32'"
:auto-size="{ minRows: 4, maxRows: 12 }"
/>
</AFormItem>
</div>
</div>
</div>
</template>
@@ -1,292 +0,0 @@
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { marked } from 'marked'
import Icon from '@/components/common/Icon.vue'
// Configure marked to allow HTML
marked.setOptions({
breaks: true,
gfm: true,
})
function renderMarkdown(content: string): string {
return marked.parse(content) as string
}
const props = defineProps<{
announcements: {
id: number
title: string
type: string
content: string
is_pinned: number
created_at: string
}[]
}>()
const currentIndex = ref(0)
const showDetail = ref(false)
const STORAGE_KEY = 'tenant_dismissed_announcements'
function loadDismissedIds(): Set<number> {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (raw) return new Set(JSON.parse(raw))
} catch { /* ignore */ }
return new Set()
}
function saveDismissedIds(ids: Set<number>) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify([...ids]))
} catch { /* ignore */ }
}
const dismissedIds = ref<Set<number>>(loadDismissedIds())
let autoTimer: ReturnType<typeof setInterval> | null = null
const visibleItems = computed(() => {
const activeIds = new Set(props.announcements.map(a => a.id))
// 清理已不存在的 dismissed ID
const changed = [...dismissedIds.value].some(id => !activeIds.has(id))
if (changed) {
for (const id of [...dismissedIds.value]) {
if (!activeIds.has(id)) dismissedIds.value.delete(id)
}
saveDismissedIds(dismissedIds.value)
}
return props.announcements.filter(a => !dismissedIds.value.has(a.id))
})
const currentItem = computed(() => {
return visibleItems.value[currentIndex.value] || null
})
const hasMultiple = computed(() => visibleItems.value.length > 1)
const typeConfig: Record<string, { bg: string; border: string; icon: string }> = {
info: { bg: 'bg-primary-500/90', border: 'border-primary-400', icon: 'infoCircle' },
warning: { bg: 'bg-amber-500/90', border: 'border-amber-400', icon: 'exclamationTriangle' },
important: { bg: 'bg-red-500/90', border: 'border-red-400', icon: 'xCircle' },
}
function getTypeConfig(type: string) {
return typeConfig[type] || typeConfig.info
}
function dismissCurrent() {
if (!currentItem.value) return
if (currentItem.value.type === 'important') return
dismissedIds.value.add(currentItem.value.id)
saveDismissedIds(dismissedIds.value)
if (currentIndex.value >= visibleItems.value.length) {
currentIndex.value = 0
}
}
function next() {
if (visibleItems.value.length <= 1) return
currentIndex.value = (currentIndex.value + 1) % visibleItems.value.length
}
function prev() {
if (visibleItems.value.length <= 1) return
currentIndex.value = (currentIndex.value - 1 + visibleItems.value.length) % visibleItems.value.length
}
function openDetail() {
if (!currentItem.value) return
showDetail.value = true
}
function startAutoRotate() {
stopAutoRotate()
if (visibleItems.value.length > 1) {
autoTimer = setInterval(() => {
next()
}, 6000)
}
}
function stopAutoRotate() {
if (autoTimer) {
clearInterval(autoTimer)
autoTimer = null
}
}
watch(() => props.announcements, () => {
currentIndex.value = 0
startAutoRotate()
})
onMounted(() => {
startAutoRotate()
})
onBeforeUnmount(() => {
stopAutoRotate()
})
</script>
<template>
<Transition name="slide-down">
<div v-if="currentItem" :class="['border-b', getTypeConfig(currentItem.type).bg, getTypeConfig(currentItem.type).border]">
<div class="flex items-center justify-center gap-2 px-4 py-2.5 text-sm text-white">
<Icon :name="getTypeConfig(currentItem.type).icon" size="sm" class="flex-shrink-0" />
<!-- Navigation (prev) -->
<button v-if="hasMultiple" @click="prev" class="flex-shrink-0 rounded-lg p-0.5 hover:bg-white/20 transition-colors">
<Icon name="chevronLeft" size="xs" />
</button>
<!-- Content -->
<button class="flex-1 text-center truncate hover:underline cursor-pointer" @click="openDetail">
<span :class="{ 'font-semibold': currentItem.is_pinned }">{{ currentItem.title }}</span>
<span v-if="hasMultiple" class="ml-2 text-white/70">{{ currentIndex + 1 }}/{{ visibleItems.length }}</span>
</button>
<!-- Navigation (next) -->
<button v-if="hasMultiple" @click="next" class="flex-shrink-0 rounded-lg p-0.5 hover:bg-white/20 transition-colors">
<Icon name="chevronRight" size="xs" />
</button>
<!-- Dismiss -->
<button
v-if="currentItem.type !== 'important'"
@click="dismissCurrent"
class="flex-shrink-0 rounded-lg p-0.5 hover:bg-white/20 transition-colors"
>
<Icon name="x" size="sm" />
</button>
</div>
</div>
</Transition>
<!-- Detail Modal -->
<Teleport to="body">
<Transition name="fade">
<div v-if="showDetail && currentItem" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm" @click.self="showDetail = false">
<div class="w-full max-w-3xl rounded-2xl bg-white shadow-2xl border border-gray-200 animate-scale-in" @click.stop>
<!-- Header -->
<div class="flex items-start gap-3 px-6 py-4 border-b border-gray-100">
<div :class="['flex-shrink-0 mt-0.5 h-8 w-8 rounded-lg flex items-center justify-center', getTypeConfig(currentItem.type).bg]">
<Icon :name="getTypeConfig(currentItem.type).icon" size="sm" class="text-white" />
</div>
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-gray-900">{{ currentItem.title }}</h3>
<p class="text-xs text-gray-500 mt-0.5">{{ currentItem.created_at }}</p>
</div>
<button @click="showDetail = false" class="flex-shrink-0 rounded-lg p-1 text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition-colors">
<Icon name="x" size="md" />
</button>
</div>
<!-- Body -->
<div class="px-6 py-5 max-h-[60vh] overflow-y-auto">
<div class="announcement-content prose prose-sm max-w-none text-gray-700" v-html="renderMarkdown(currentItem.content)"></div>
</div>
<!-- Footer -->
<div class="px-6 py-3 border-t border-gray-100 flex justify-end">
<button @click="showDetail = false" class="btn btn-secondary btn-sm">关闭</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.slide-down-enter-active,
.slide-down-leave-active {
transition: all 0.3s ease-out;
}
.slide-down-enter-from,
.slide-down-leave-to {
opacity: 0;
transform: translateY(-100%);
}
.fade-enter-active { transition: opacity 0.2s ease-out; }
.fade-leave-active { transition: opacity 0.15s ease-in; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
.animate-scale-in {
animation: scaleIn 0.25s ease-out;
}
@keyframes scaleIn {
from { transform: scale(0.95); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
</style>
<style>
.announcement-content {
font-size: 14px;
line-height: 1.75;
color: #374151;
}
.announcement-content h1 { font-size: 1.5em; font-weight: 700; margin: 0.8em 0 0.4em; }
.announcement-content h2 { font-size: 1.3em; font-weight: 600; margin: 0.8em 0 0.4em; }
.announcement-content h3 { font-size: 1.15em; font-weight: 600; margin: 0.6em 0 0.3em; }
.announcement-content p { margin: 0.5em 0; }
.announcement-content ul, .announcement-content ol { padding-left: 1.5em; margin: 0.5em 0; }
.announcement-content li { margin: 0.2em 0; }
.announcement-content blockquote {
border-left: 3px solid var(--color-primary-500);
padding: 0.5em 1em;
margin: 0.5em 0;
background: var(--color-primary-50);
border-radius: 0 8px 8px 0;
}
.announcement-content code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.9em;
background: #f3f4f6;
padding: 0.15em 0.4em;
border-radius: 4px;
}
.announcement-content pre {
background: #1e293b;
color: #e2e8f0;
padding: 1em;
border-radius: 12px;
overflow-x: auto;
margin: 0.5em 0;
}
.announcement-content pre code {
background: transparent;
padding: 0;
color: inherit;
}
.announcement-content a {
color: #6d80f0;
text-decoration: underline;
}
.announcement-content img {
max-width: 100%;
border-radius: 8px;
margin: 0.5em 0;
}
.announcement-content table {
width: 100%;
border-collapse: collapse;
margin: 0.5em 0;
}
.announcement-content th, .announcement-content td {
border: 1px solid #e5e7eb;
padding: 0.5em 0.75em;
text-align: left;
}
.announcement-content th {
background: #f9fafb;
font-weight: 600;
}
.announcement-content hr {
border: none;
border-top: 1px solid #e5e7eb;
margin: 1em 0;
}
.announcement-content strong { font-weight: 600; }
.announcement-content em { font-style: italic; }
</style>
@@ -1,12 +1,5 @@
<template>
<div class="auth-layout relative min-h-[100dvh] overflow-x-hidden">
<!-- Announcement Banner (teleported to body, fixed top) -->
<Teleport to="body">
<div v-if="announcements.length" class="fixed top-0 left-0 right-0 z-50">
<AnnouncementBanner :announcements="announcements" />
</div>
</Teleport>
<div class="pointer-events-none fixed inset-0 bg-mesh-gradient"></div>
<div class="relative z-10 flex min-h-[100dvh] flex-col">
@@ -45,35 +38,11 @@
</template>
<script setup lang="ts">
import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
import AnnouncementBanner from '../common/AnnouncementBanner.vue'
import request from '@/utils/request'
import { computed } from 'vue'
import { usePublicSettings } from '@/composables/usePublicSettings'
import { createPoller } from '@/composables/usePolling'
const { settings: publicSettings } = usePublicSettings()
const siteName = computed(() => publicSettings.value.site_name || 'Team-API')
const announcements = ref<any[]>([])
// 登录页公告轮询:页面隐藏时暂停,恢复可见时按剩余时间续排
const announcementPoller = createPoller(fetchAnnouncements, 30 * 60 * 1000)
async function fetchAnnouncements() {
try {
const res = await request.get('/settings/announcements', { params: { position: 'login' }, _suppressErrorMsg: true } as any)
announcements.value = res.data?.data?.list || []
} catch {
// silently ignore
}
}
onMounted(() => {
fetchAnnouncements()
announcementPoller.start()
})
onBeforeUnmount(() => {
announcementPoller.stop()
})
</script>
<style scoped>
+4 -4
View File
@@ -13,9 +13,9 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/landing/LandingPage.vue'),
meta: {
requiresAuth: false,
title: 'Team-API — 企业级多租户大模型 API 网关平台 | 开源自托管',
description: '开源自托管的企业级多租户大模型 API 网关平台,聚合 40+ 供应商,内置计费引擎、团队管理与渠道调度。',
keywords: 'Team-API, 大模型网关, API Gateway, 多租户, OpenAI, Claude, 开源',
title: 'Team-API — 团队统一充值、按人分额、用量实时入账 | 企业级多租户 AI 网关',
description: '开源自托管的企业级多租户 AI 网关:团队统一充值、按成员与项目分配额度、用量实时入账,预算触线自动熔断。完全兼容 OpenAI SDK,一个 Key 接入所有大模型。',
keywords: 'Team-API, 大模型网关, API Gateway, 多租户, 额度管控, 预算熔断, 团队管理, 用量计费, RBAC, 开源, OpenAI 兼容',
},
},
...tenantRoutes,
@@ -42,7 +42,7 @@ router.beforeEach(async (to) => {
if (to.meta.title) {
await fetchSettings()
const siteName = publicSettings.value.site_name || 'Team-API'
document.title = to.name === 'TenantHome' ? `${siteName} — 企业级多租户大模型 API 网关平台 | 开源自托管` : `${to.meta.title} — ${siteName}`
document.title = to.name === 'TenantHome' ? `${siteName} — 团队统一充值、按人分额、用量实时入账 | 企业级多租户 AI 网关` : `${to.meta.title} — ${siteName}`
}
// Auth pages — always allow
+10
View File
@@ -57,6 +57,7 @@
--animate-scale-in: scale-in 0.2s ease-out;
--animate-shimmer: shimmer 2s linear infinite;
--animate-glow: glow 2s ease-in-out infinite alternate;
--animate-swing: swing 2.4s ease-in-out infinite;
@keyframes fade-in {
from { opacity: 0; }
@@ -86,6 +87,15 @@
from { box-shadow: 0 0 20px rgba(20, 184, 166, 0.22); }
to { box-shadow: 0 0 30px rgba(20, 184, 166, 0.38); }
}
/* 喇叭图标摇摆:静止过半周期后快速晃动几下,用于未读公告提醒 */
@keyframes swing {
0%, 55%, 100% { transform: rotate(0deg); }
60% { transform: rotate(-14deg); }
66% { transform: rotate(11deg); }
72% { transform: rotate(-8deg); }
78% { transform: rotate(5deg); }
84% { transform: rotate(0deg); }
}
}
/* ============ 基础样式 ============ */
+5 -10
View File
@@ -11,7 +11,6 @@ import { formatBilling } from '@/composables/useCurrency'
import { toast } from '@/utils/toast'
import Icon from '@/components/common/Icon.vue'
import MaintenanceBanner from '@/components/common/MaintenanceBanner.vue'
import AnnouncementBanner from '@/components/common/AnnouncementBanner.vue'
import RouteErrorBoundary from '@/components/common/RouteErrorBoundary.vue'
import { marked } from 'marked'
import request from '@/utils/request'
@@ -264,7 +263,6 @@ onBeforeUnmount(() => {
<div class="tenant-layout min-h-screen overflow-x-clip">
<!-- Maintenance Banner -->
<MaintenanceBanner />
<AnnouncementBanner v-if="consoleAnnouncements.length" :announcements="consoleAnnouncements" />
<!-- Background Decoration -->
<div class="pointer-events-none fixed inset-0 bg-mesh-gradient"></div>
@@ -410,17 +408,13 @@ onBeforeUnmount(() => {
class="topbar-icon-button relative flex h-10 w-10 items-center justify-center rounded-full border border-white/80 bg-white/65 text-slate-400 shadow-sm transition-all hover:-translate-y-0.5 hover:bg-white hover:text-primary-600"
title="平台公告"
>
<Icon name="megaphone" size="md" />
<Icon name="megaphone" size="md" :class="{ 'motion-safe:animate-swing': announceUnreadCount > 0 }" />
<!-- 存在未读公告时喇叭摇摆 + 红点标记 -->
<span
v-if="announceUnreadCount > 0"
class="absolute -top-0.5 -right-0.5 h-2.5 w-2.5 rounded-full bg-red-500 ring-2 ring-white"
></span>
</button>
<!-- Pulse animation layer -->
<div
v-if="announceUnreadCount > 0"
class="absolute inset-0 rounded-xl animate-pulse-soft pointer-events-none"
></div>
<!-- Announcement Dropdown Panel -->
<transition name="fade">
@@ -583,7 +577,7 @@ onBeforeUnmount(() => {
<Teleport to="body">
<Transition name="fade">
<div v-if="announceDetailItem" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm" @click.self="closeAnnouncementDetail">
<div class="w-full max-w-3xl rounded-2xl bg-white shadow-2xl border border-gray-200 animate-scale-in" @click.stop>
<div class="w-full max-w-3xl flex min-h-[24rem] flex-col rounded-2xl bg-white shadow-2xl border border-gray-200 animate-scale-in" @click.stop>
<div class="flex items-start gap-3 px-6 py-4 border-b border-gray-100">
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-gray-900">{{ announceDetailItem.title }}</h3>
@@ -593,7 +587,8 @@ onBeforeUnmount(() => {
<Icon name="x" size="md" />
</button>
</div>
<div class="px-6 py-5 max-h-[60vh] overflow-y-auto">
<!-- 内容区 flex-1 撑满弹窗最小高度,长内容超过 60vh 后内部滚动 -->
<div class="flex-1 min-h-0 px-6 py-5 max-h-[60vh] overflow-y-auto">
<div class="announcement-content prose prose-sm max-w-none text-gray-700" v-html="renderMarkdown(announceDetailItem.content)"></div>
</div>
<div class="px-6 py-3 border-t border-gray-100 flex justify-end">
+10 -22
View File
@@ -61,20 +61,15 @@ function formatReset(seconds: number): string {
return `约 ${Math.ceil(seconds / 3600)} 小时`
}
const rateLimitNotice = computed<{ type: 'blocked' | 'info'; text: string } | null>(() => {
// 仅在注册被限流(次数耗尽)时提示,平时不展示剩余次数
const rateLimitNotice = computed<string | null>(() => {
const s = rateLimit.value
if (!s) return null
if (s.daily_limit > 0 && s.daily_remaining <= 0) {
return { type: 'blocked', text: `今日注册次数已达上限(每日 ${s.daily_limit} 次),${formatReset(s.daily_reset_seconds)}后重置` }
return `今日注册次数已达上限(每日 ${s.daily_limit} 次),${formatReset(s.daily_reset_seconds)}后重置`
}
if (s.hourly_limit > 0 && s.hourly_remaining <= 0) {
return { type: 'blocked', text: `注册已达每小时上限(${s.hourly_limit} 次/小时),${formatReset(s.hourly_reset_seconds)}后重置` }
}
if (s.hourly_limit > 0 || s.daily_limit > 0) {
const parts: string[] = []
if (s.hourly_limit > 0) parts.push(`本小时 ${s.hourly_remaining}/${s.hourly_limit} 次`)
if (s.daily_limit > 0) parts.push(`今日 ${s.daily_remaining}/${s.daily_limit} 次`)
return { type: 'info', text: `剩余注册次数:${parts.join(',')}` }
return `注册已达每小时上限(${s.hourly_limit} 次/小时),${formatReset(s.hourly_reset_seconds)}后重置`
}
return null
})
@@ -218,7 +213,7 @@ async function handleRegister() {
proceedAfterRegister()
} catch (err: any) {
captchaRef.value?.resetCaptcha()
// 本次尝试已消耗限流计数,刷新剩余次数提示
// 本次尝试已消耗限流计数,刷新限流状态
fetchRateLimit()
const apiErr = extractApiError(err)
const msg = apiErr?.message || '注册失败'
@@ -261,20 +256,13 @@ async function handleRegister() {
<p class="mt-1.5 text-sm text-gray-500">几分钟即可开始使用,团队功能可后续开启</p>
</div>
<!-- 注册限流提示 -->
<!-- 注册限流提示(仅被限流时展示) -->
<div
v-if="rateLimitNotice"
class="mb-4 flex items-start gap-2 rounded-xl border px-4 py-2.5 text-xs"
:class="rateLimitNotice.type === 'blocked'
? 'border-red-200 bg-red-50 text-red-600'
: 'border-amber-200 bg-amber-50 text-amber-700'"
class="mb-4 flex items-start gap-2 rounded-xl border border-red-200 bg-red-50 px-4 py-2.5 text-xs text-red-600"
>
<Icon
:name="rateLimitNotice.type === 'blocked' ? 'exclamationTriangle' : 'infoCircle'"
size="sm"
class="mt-0.5 shrink-0"
/>
<span>{{ rateLimitNotice.text }}</span>
<Icon name="exclamationTriangle" size="sm" class="mt-0.5 shrink-0" />
<span>{{ rateLimitNotice }}</span>
</div>
<form @submit.prevent="handleRegister" class="space-y-4">
@@ -382,7 +370,7 @@ async function handleRegister() {
</div>
<!-- Submit -->
<button type="submit" :disabled="loading || !userForm.agreed || rateLimitNotice?.type === 'blocked'" class="btn btn-primary btn-lg w-full disabled:opacity-50 disabled:cursor-not-allowed">
<button type="submit" :disabled="loading || !userForm.agreed || !!rateLimitNotice" class="btn btn-primary btn-lg w-full disabled:opacity-50 disabled:cursor-not-allowed">
<div v-if="loading" class="spinner h-4 w-4 border-white"></div>
{{ loading ? '创建中...' : '创建账号' }}
</button>
File diff suppressed because it is too large. Load diff