diff --git a/CLAUDE.md b/CLAUDE.md index 141cb34..99bf153 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 记录 | diff --git a/README.md b/README.md index 25e0894..9f9f947 100644 --- a/README.md +++ b/README.md @@ -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) 许可证。 ### 核心要求 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index de38c3d..9eef60d 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -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 类许可证。 diff --git a/api/admin/v1/dashboard.go b/api/admin/v1/dashboard.go index ca17aed..fd13e38 100644 --- a/api/admin/v1/dashboard.go +++ b/api/admin/v1/dashboard.go @@ -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 { diff --git a/docs/reference/多项目渠道调度对比.md b/docs/reference/多项目渠道调度对比.md deleted file mode 100644 index f26d9c9..0000000 --- a/docs/reference/多项目渠道调度对比.md +++ /dev/null @@ -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:` 维护该渠道绑定的所有 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` | diff --git a/docs/relaykit迁移工作状态.md b/docs/relaykit迁移工作状态.md deleted file mode 100644 index eeec54d..0000000 --- a/docs/relaykit迁移工作状态.md +++ /dev/null @@ -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` 步骤链,响应用直连转换器(保真度高)。
后修订: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)不受影响(本就字面优先) diff --git a/docs/screenshot/tenant_usage_detail.png b/docs/screenshot/tenant_usage_detail.png deleted file mode 100644 index e486507..0000000 Binary files a/docs/screenshot/tenant_usage_detail.png and /dev/null differ diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go index 8be7637..a3da38e 100644 --- a/internal/cmd/cmd.go +++ b/internal/cmd/cmd.go @@ -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() { diff --git a/internal/logic/admin/dashboard.go b/internal/logic/admin/dashboard.go index a781032..6450569 100644 --- a/internal/logic/admin/dashboard.go +++ b/internal/logic/admin/dashboard.go @@ -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 diff --git a/internal/logic/admin/settings.go b/internal/logic/admin/settings.go index e1be102..3286c28 100644 --- a/internal/logic/admin/settings.go +++ b/internal/logic/admin/settings.go @@ -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 diff --git a/internal/logic/common/ip_blacklist.go b/internal/logic/common/ip_blacklist.go new file mode 100644 index 0000000..7cc2366 --- /dev/null +++ b/internal/logic/common/ip_blacklist.go @@ -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 +} diff --git a/internal/logic/common/ip_blacklist_test.go b/internal/logic/common/ip_blacklist_test.go new file mode 100644 index 0000000..caffe97 --- /dev/null +++ b/internal/logic/common/ip_blacklist_test.go @@ -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) + } + } +} diff --git a/internal/logic/common/settings_registry.go b/internal/logic/common/settings_registry.go index 46e18e4..68f8c18 100644 --- a/internal/logic/common/settings_registry.go +++ b/internal/logic/common/settings_registry.go @@ -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", diff --git a/internal/middleware/ip_blacklist.go b/internal/middleware/ip_blacklist.go new file mode 100644 index 0000000..d3a4a53 --- /dev/null +++ b/internal/middleware/ip_blacklist.go @@ -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() +} diff --git a/web/admin/src/views/DashboardPage.vue b/web/admin/src/views/DashboardPage.vue index 15e10aa..b6f31a9 100644 --- a/web/admin/src/views/DashboardPage.vue +++ b/web/admin/src/views/DashboardPage.vue @@ -29,6 +29,7 @@ const modelDist = ref([]) const channelHealth = ref([]) const recentAlerts = ref([]) const modelHourly = ref(null) +const ipBlacklist = computed(() => dashboard.value?.ip_blacklist) // === Charts === let trendChart: echarts.ECharts | null = null @@ -560,6 +561,26 @@ onBeforeUnmount(() => { + + + + +
+
+ 累计拦截 {{ formatNumber(ipBlacklist.total) }} 次 +
+
+
+ {{ item.ip }} + {{ formatNumber(item.count) }} 次 +
+
+
+ +
@@ -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; diff --git a/web/admin/src/views/system/settings/SecurityTab.vue b/web/admin/src/views/system/settings/SecurityTab.vue index 4ff78ec..db7fe1a 100644 --- a/web/admin/src/views/system/settings/SecurityTab.vue +++ b/web/admin/src/views/system/settings/SecurityTab.vue @@ -1,6 +1,27 @@ diff --git a/web/tenant/src/components/common/AnnouncementBanner.vue b/web/tenant/src/components/common/AnnouncementBanner.vue deleted file mode 100644 index a9e14e8..0000000 --- a/web/tenant/src/components/common/AnnouncementBanner.vue +++ /dev/null @@ -1,292 +0,0 @@ - - - - - - - diff --git a/web/tenant/src/components/layout/AuthLayout.vue b/web/tenant/src/components/layout/AuthLayout.vue index b90da29..58e3ac8 100644 --- a/web/tenant/src/components/layout/AuthLayout.vue +++ b/web/tenant/src/components/layout/AuthLayout.vue @@ -1,12 +1,5 @@