diff --git a/aidoc/implementation_plan.md b/aidoc/implementation_plan.md new file mode 100644 index 000000000000..1db49d57c227 --- /dev/null +++ b/aidoc/implementation_plan.md @@ -0,0 +1,158 @@ +# New-API 二次开发实施方案(v2) + +## 背景 + +基于 new-api(Go/Gin + React 前端)进行二次开发,新增 6 大功能模块 + 补充增强功能。 + +--- + +## 需求清单(含用户反馈更新) + +### 原始需求(6 项) + +| # | 需求 | 核心要点 | +|---|------|----------| +| 1 | 慢请求监控告警 | ✅ **更新**:整合进调度管理模块,提供可配置选项给管理员 | +| 2 | 调度功能 | 定时任务引擎,管理员可自定义配置 | +| 3 | 停机维护提示 | 503 + 预告 + 白名单 | +| 4 | 用户并发限制 | ✅ **更新**:充值用户默认 10 并发,可单独为某个用户调整并发数 | +| 5 | 渠道兜底策略 | 链式 Failover a→b→c | +| 6 | 一键导入 Codex / Claude Code | 生成配置文件 | + +### 新增需求(用户反馈) + +| # | 需求 | 说明 | +|---|------|------| +| 7 | 不活跃账户额度清理 | 一周内未使用且从未充值的用户,自动清理额度 | + +### 我补充的建议功能(8-14) + +| # | 补充需求 | 说明 | 理由 | +|---|----------|------|------| +| 8 | 渠道健康度自动评分 | 基于成功率/延迟/错误率对渠道打分 | 与兜底策略联动,低分渠道自动降权 | +| 9 | Token 用量日报推送 | 每日推送用户/渠道用量摘要 | 运营必需,及时发现异常用量 | +| 10 | IP 白名单 / 黑名单 | 支持 IP 级别的访问控制 | 安全加固,防滥用 | +| 11 | 请求重放 / 调试 | 记录完整请求,支持回放调试 | 排查问题必备 | +| 12 | 渠道自动禁用与恢复 | 连续失败 N 次自动禁用,定期探活恢复 | 避免人工干预 | +| 13 | 用户公告系统 | 在用户面板展示系统公告 | 通知 API 变更、模型下线等 | +| 14 | 操作审计日志 | 管理员操作可追溯 | 多管理员场景安全审计 | + +--- + +## 架构总览 + +```mermaid +graph TB + subgraph "Gin 中间件链" + M1[维护检查] --> M2[IP 控制] + M2 --> M3[并发限制] + M3 --> M4[请求监控采集] + M4 --> M5[渠道选择 + 兜底] + end + + subgraph "后台引擎" + S1[调度引擎 Cron] + S2[告警引擎] + S3[渠道健康评分] + S4[账户清理任务] + end + + subgraph "存储" + R[Redis - 并发计数/滑动窗口/缓存] + DB[MySQL - 配置/日志/任务] + end + + M4 --> R + M3 --> R + S1 --> DB + S2 --> R + S3 --> DB +``` + +--- + +## 各模块详细设计(分文件) + +> [!NOTE] +> 每个模块的详细技术设计在单独文件中,便于逐个评审和实施。 + +| 模块文件 | 内容 | +|----------|------| +| [module_1_monitor.md](./modules/module_1_monitor.md) | 慢请求监控(整合调度模块) | +| [module_2_scheduler.md](./modules/module_2_scheduler.md) | 调度管理引擎 | +| [module_3_maintenance.md](./modules/module_3_maintenance.md) | 停机维护提示 | +| [module_4_concurrency.md](./modules/module_4_concurrency.md) | 用户并发限制(含用户级配置) | +| [module_5_fallback.md](./modules/module_5_fallback.md) | 渠道兜底策略 | +| [module_6_import.md](./modules/module_6_import.md) | 一键导入 Codex / Claude Code | +| [module_7_cleanup.md](./modules/module_7_cleanup.md) | 不活跃账户清理 | +| [module_8_extras.md](./modules/module_8_extras.md) | 补充建议功能(8-14) | + +--- + +## 并发限制策略(更新版) + +| 用户类型 | 默认并发数 | 可调整 | +|----------|-----------|--------| +| 免费用户(未充值) | 3 | 管理员可在用户组配置中调整 | +| 充值用户(已充值) | 10 | 管理员可单独为某用户设置 | +| VIP 用户 | 50 | 可配置 | +| 管理员 | 不限制 | - | +| 单用户自定义 | 自定义值 | 管理员在用户详情页单独设置 | + +--- + +## 开发优先级与里程碑 + +### Sprint 1(基础设施 + 核心)— 5 天 + +| 天数 | 任务 | +|------|------| +| D1 | Redis 集成 + 数据库迁移框架 + 所有新表 DDL | +| D2 | 模块五:渠道兜底引擎(核心 relay 改动) | +| D3 | 模块四:并发限制中间件 + 用户级并发配置 | +| D4 | 模块二:调度引擎框架 + 内置任务注册 | +| D5 | 模块一:慢请求监控(作为调度任务 + 可配置选项) | + +### Sprint 2(运维功能)— 4 天 + +| 天数 | 任务 | +|------|------| +| D6 | 模块三:停机维护提示 + 模块七:不活跃账户清理任务 | +| D7 | 告警引擎(钉钉/企微/Telegram)+ 渠道自动禁用恢复 | +| D8 | 模块六:一键导入 Codex / Claude Code | +| D9 | 渠道健康评分 + Token 用量日报 | + +### Sprint 3(前端 + 测试)— 4 天 + +| 天数 | 任务 | +|------|------| +| D10-11 | 前端页面(调度管理、监控面板、兜底配置、并发配置) | +| D12 | 前端页面(维护管理、一键导入、公告系统) | +| D13 | 集成测试 + 压力测试 + 部署文档 | + +**总计约 13 个工作日(约 2.5 周)** + +--- + +## Open Questions + +> [!IMPORTANT] +> 1. **你用的是哪个版本的 new-api?** 请提供 GitHub 仓库链接或本地路径 +> 2. **是否已有 Redis?** 当前部署中是否包含 Redis +> 3. **告警通道偏好?** 钉钉/企微/Telegram/邮件,你主要用哪个 +> 4. **补充功能(8-14)** 你觉得哪些需要,哪些先不做 +> 5. **是否需要克隆 new-api 到 workspace 直接改代码?** + +--- + +## Verification Plan + +### 自动化测试 +- 并发限制:goroutine 并发压测 +- 兜底引擎:mock 渠道故障,验证链式切换 +- 账户清理:mock 不活跃数据,验证清理逻辑 + +### 集成测试 +- 各 API 端点 curl 验证 +- 慢请求告警触发验证 +- 一键导入配置在 Codex / Claude Code 中验证连通性 diff --git a/aidoc/implementation_plan_revised.md b/aidoc/implementation_plan_revised.md new file mode 100644 index 000000000000..4c1c8816d18c --- /dev/null +++ b/aidoc/implementation_plan_revised.md @@ -0,0 +1,931 @@ +# New-API 二次开发修订实施方案(贴合当前仓库) + +## 1. 结论 + +当前 `aidoc/` 里的方案有业务价值,但不能按原文直接开工。 + +主要原因: + +- 当前项目必须同时兼容 SQLite / MySQL / PostgreSQL,不适合直接采用大量 MySQL 风格 DDL。 +- 当前项目已存在若干后台任务、通知、公告、通道自动禁用/恢复等基础能力,应该复用,而不是平行再造一套。 +- 当前 relay 主链路已经包含鉴权、分发、重试、预扣费、退款、日志记录,渠道兜底不能作为一个独立小功能插入。 +- 当前项目大量模型使用 `int64` Unix 时间戳,而不是 `DATETIME` 风格字段;新功能应延续现有数据风格。 + +因此,本修订版的核心原则是: + +1. 先做低风险、高收益、与当前代码最贴合的模块。 +2. 复用已有 `service` 后台循环、`option/config` 配置、`logs` 统计、`NotifyRootUser/NotifyUser` 通知能力。 +3. 对高风险模块先做“最小可上线版本”,不要一开始设计成通用平台。 +4. 所有新表优先使用 GORM 模型驱动迁移,字段使用跨库安全类型。 + +--- + +## 2. 当前仓库约束 + +### 2.1 必须遵守 + +- JSON 编解码统一走 `common/json.go` 中的封装。 +- 数据库必须兼容 SQLite / MySQL / PostgreSQL。 +- 新功能应优先使用 GORM,不直接写依赖数据库方言的 DDL。 +- 新增 JSON 结构数据优先存为 `TEXT` 字段中的 JSON 字符串,避免直接依赖数据库 `JSON/JSONB` 类型。 +- 新模型的时间字段优先使用当前项目风格: + - `created_at int64` + - `updated_at int64` + - 业务时间字段也尽量使用 Unix 秒级时间戳 + +### 2.2 已有能力应复用 + +- Redis 已集成,可用于并发计数与短期状态缓存。 +- 已有后台循环任务模式: + - 订阅额度重置 + - Codex 凭证自动刷新 + - 渠道自动测试 +- 已有日志表 `logs`,可用于慢请求统计、日报聚合、不活跃判断。 +- 已有管理员通知能力: + - `NotifyRootUser` + - `NotifyUser` +- 已有公告配置能力: + - `console_setting.announcements` + - 控制台已有公告展示组件 +- 已有渠道自动禁用/自动恢复基础能力: + - 自动禁用判断 + - 渠道巡检恢复 + +--- + +## 3. 模块结论 + +| 模块 | 结论 | 建议 | +|------|------|------| +| 模块 1 慢请求监控 | 可做 | 一期做,先基于 `logs.use_time` 聚合,不强依赖 Redis ZSet | +| 模块 2 调度管理 | 可做,但要重设计 | 二期做,先实现“轻量持久化任务”而非通用任务平台 | +| 模块 3 维护模式 | 可做 | 一期做简化版,先支持即时维护与预告;排期维护放二期 | +| 模块 4 用户并发限制 | 可做 | 一期做,Redis 原子计数,先覆盖 relay 请求 | +| 模块 5 渠道兜底 | 可做,但风险最高 | 三期做,必须与现有重试/计费/流式链路一起设计 | +| 模块 6 一键导出 Codex / Claude Code | 最适合先做 | 一期做,低风险高收益 | +| 模块 7 不活跃账户清理 | 可做,但需重定义数据口径 | 一期做,基于 `logs/top_ups/subscription_orders` 判断 | +| 模块 8 建议功能 | 拆分处理 | 只保留与现有能力强相关的部分优先做 | + +--- + +## 4. 推荐分期 + +## Phase 1:两周内可落地版本 + +目标:先交付对业务最有价值、对主链路改动较小的功能。 + +包含: + +- 模块 6 一键导出 Codex / Claude Code 配置 +- 模块 3 维护模式 V1(即时维护 + 预告 Banner) +- 时间动态倍率 +- 模块 1 慢请求监控 V1 +- 模块 7 不活跃账户清理 V1 +- 模块 4 用户并发限制 + +不包含: + +- 通用调度平台 UI +- 渠道兜底 +- 请求重放 +- 独立公告系统数据库化 + +## Phase 2:运维增强 + +包含: + +- 模块 2 轻量调度管理 +- 模块 3 排期维护 +- 模块 1 慢请求监控高级配置 +- Token 用量日报 +- 渠道健康评分 + +## Phase 3:高风险主链路能力 + +包含: + +- 模块 5 渠道兜底 +- 与兜底联动的健康度降权 +- 更细粒度的自动禁用与恢复 + +## 暂缓 + +- 请求重放 / 调试 +- 独立 `notification_channels` 配置中心 +- 全量审计日志平台 +- 完整 IP 白黑名单系统 + +--- + +## 5. 架构修订 + +## 5.1 后台任务不要新起一套完全独立架构 + +建议延续当前项目已有模式: + +- 在 `main.go` 启动后台任务 +- 在 `service/` 中实现任务循环和单次执行函数 +- 用 `sync.Once + atomic.Bool` 防重复运行 +- 只在 `common.IsMasterNode` 上运行 + +这与当前已有任务保持一致,维护成本最低。 + +## 5.2 调度功能先做“轻量持久化任务” + +不要一开始就设计成“任意任务 + 任意 Schema + 任意通知通道”的通用平台。 + +推荐先支持有限内置任务: + +- `slow_request_check` +- `inactive_cleanup` +- `usage_report` +- `log_cleanup` + +每个任务: + +- 有固定 `TaskType` +- 有固定参数结构 +- 参数存在 `TEXT` 字段中,内容为 JSON 字符串 +- 后端按 `TaskType` 路由到具体 handler + +## 5.3 慢请求监控优先复用 `logs` + +当前 `logs` 已记录: + +- `created_at` +- `channel_id` +- `model_name` +- `use_time` +- `request_id` + +因此 V1 建议直接从 `logs` 聚合慢请求: + +- 优点:无额外采集链路风险 +- 优点:兼容已有日志与统计能力 +- 优点:部署不依赖 Redis 特性 + +Redis ZSet 版本可作为 Phase 2 优化项。 + +## 5.4 告警通道优先复用现有通知体系 + +不建议一开始就上 `notification_channels` 全局配置表。 + +V1 做法: + +- 管理员告警统一走 `NotifyRootUser` +- 或对已启用通知的管理员广播 `NotifyUser` +- 通知方式复用已有用户设置: + - email + - webhook + - bark + - gotify + +这样能显著减少新表、新页面和配置管理复杂度。 + +## 5.5 公告系统不重做 + +当前项目已经有: + +- `console_setting.announcements` +- 控制台公告展示面板 + +因此“用户公告系统”不建议单独再建表作为一期能力。 + +建议先做: + +- 公告编辑体验增强 +- 维护预告与系统公告联动 + +## 5.6 结构化配置优先使用 `config.GlobalConfig.Register()` + +对于新增的结构化配置,不建议优先落到单个 `option` JSON 字符串中。 + +更推荐的方式: + +- 在 `setting/operation_setting/` 或 `setting/system_setting/` 下新增配置结构体 +- 使用 `config.GlobalConfig.Register()` 注册 +- 通过现有配置持久化链路读写数据库 + +适合这样做的配置包括: + +- 时间动态倍率 +- 并发默认配置 +- 维护模式配置 + +这样做的好处: + +- 与当前仓库风格一致 +- 字段更清晰,类型更安全 +- 后续前端和接口扩展时更容易维护 + +--- + +## 6. 新增数据模型建议 + +以下为推荐新增模型,不要求第一期全部落地。 + +## 6.1 `ScheduledTask` + +用于 Phase 2 的轻量调度。 + +```go +type ScheduledTask struct { + Id int `json:"id"` + Name string `json:"name" gorm:"type:varchar(100);index"` + TaskType string `json:"task_type" gorm:"type:varchar(50);index"` + CronExpr string `json:"cron_expr" gorm:"type:varchar(100)"` + Params string `json:"params" gorm:"type:text"` + Enabled bool `json:"enabled" gorm:"default:true;index"` + LastStatus string `json:"last_status" gorm:"type:varchar(20);default:'idle'"` + LastOutput string `json:"last_output" gorm:"type:text"` + LastRunAt int64 `json:"last_run_at" gorm:"bigint;default:0"` + NextRunAt int64 `json:"next_run_at" gorm:"bigint;default:0"` + CreatedBy int `json:"created_by" gorm:"index"` + CreatedAt int64 `json:"created_at" gorm:"bigint;index"` + UpdatedAt int64 `json:"updated_at" gorm:"bigint"` +} +``` + +说明: + +- `Params` 使用 `TEXT` 保存 JSON 字符串。 +- 读写统一使用 `common.Marshal` / `common.UnmarshalJsonStr`。 + +## 6.2 `ScheduledTaskExecution` + +```go +type ScheduledTaskExecution struct { + Id int `json:"id"` + TaskId int `json:"task_id" gorm:"index"` + Status string `json:"status" gorm:"type:varchar(20);index"` + Output string `json:"output" gorm:"type:text"` + DurationMs int64 `json:"duration_ms" gorm:"bigint"` + StartedAt int64 `json:"started_at" gorm:"bigint;index"` + FinishedAt int64 `json:"finished_at" gorm:"bigint"` +} +``` + +## 6.3 `UserConcurrencyOverride` + +只保留用户级覆盖,组级默认先放在 `options` 中。 + +```go +type UserConcurrencyOverride struct { + Id int `json:"id"` + UserId int `json:"user_id" gorm:"uniqueIndex"` + MaxConcurrent int `json:"max_concurrent"` + Reason string `json:"reason" gorm:"type:varchar(255)"` + SetBy int `json:"set_by" gorm:"index"` + CreatedAt int64 `json:"created_at" gorm:"bigint"` + UpdatedAt int64 `json:"updated_at" gorm:"bigint"` +} +``` + +配套 `option` 键: + +- `concurrency.free_default` +- `concurrency.paid_default` +- `concurrency.group_defaults` + +其中 `concurrency.group_defaults` 存 JSON 字符串,例如: + +```json +{ + "default": 3, + "vip": 50, + "premium": 100 +} +``` + +## 6.4 `QuotaCleanupLog` + +```go +type QuotaCleanupLog struct { + Id int `json:"id"` + UserId int `json:"user_id" gorm:"index"` + QuotaBefore int `json:"quota_before"` + QuotaAfter int `json:"quota_after"` + CleanupType string `json:"cleanup_type" gorm:"type:varchar(50);index"` + TaskId int `json:"task_id" gorm:"index"` + Remark string `json:"remark" gorm:"type:text"` + CreatedAt int64 `json:"created_at" gorm:"bigint;index"` +} +``` + +## 6.5 `ChannelFallbackRule` + +仅在 Phase 3 引入。 + +```go +type ChannelFallbackRule struct { + Id int `json:"id"` + PrimaryChannelId int `json:"primary_channel_id" gorm:"uniqueIndex"` + FallbackChain string `json:"fallback_chain" gorm:"type:text"` + TriggerStatusCodes string `json:"trigger_status_codes" gorm:"type:text"` + TriggerKeywords string `json:"trigger_keywords" gorm:"type:text"` + TriggerOnTimeout bool `json:"trigger_on_timeout" gorm:"default:true"` + TimeoutSeconds int `json:"timeout_seconds" gorm:"default:30"` + Enabled bool `json:"enabled" gorm:"default:true;index"` + CreatedAt int64 `json:"created_at" gorm:"bigint"` + UpdatedAt int64 `json:"updated_at" gorm:"bigint"` +} +``` + +--- + +## 7. 模块级实施设计 + +## 7.1 模块 6:一键导出 Codex / Claude Code 配置 + +### 目标 + +在用户 Token 页面生成配置片段,不直接写用户本地文件。 + +### 后端 + +新增接口: + +- `GET /api/token/:id/export?tool=codex` +- `GET /api/token/:id/export?tool=claude_code` +- `GET /api/token/:id/export?tool=cursor` +- `GET /api/token/:id/export?tool=continue` + +原因: + +- 当前 token 相关接口已在 `/api/token` 下,保持路由风格一致。 +- 由当前用户访问自己的 token,更符合现有权限模型。 + +返回内容: + +- 环境变量方式 +- 配置文件片段 +- 测试命令 +- 注意事项 + +### 设计注意 + +- 只做“文本生成”,不做客户端下载或远端安装。 +- `baseURL` 应来自当前服务地址配置,而不是简单拼接请求来源头。 +- 输出内容需要按最新官方工具配置方式校验后再固定。 + +### 可行性 + +高,可作为首批交付功能。 + +--- + +## 7.2 模块 3:维护模式 V1 + +### V1 目标 + +先支持: + +- 即时开启维护 +- 即时关闭维护 +- 预告信息展示 +- 管理员 / root 放行 + +### V1 存储建议 + +一期不先建 `maintenance_schedules` 表。 + +先用 `config.GlobalConfig.Register()` 注册维护配置,并持久化到数据库;多实例部署时,有 Redis 则优先使用 Redis 作为实时状态源。 + +建议配置字段例如: + +- `maintenance.enabled` +- `maintenance.title` +- `maintenance.message` +- `maintenance.notice_start_at` +- `maintenance.start_at` +- `maintenance.end_at` +- `maintenance.whitelist_user_ids` + +### 多实例部署建议 + +维护模式在多实例下不能只依赖数据库轮询同步。 + +建议规则: + +- 有 Redis: + - 管理端修改维护状态后,立即写 Redis + - 请求链路优先读 Redis 中的当前维护状态 + - 数据库中的配置作为持久化和兜底来源 +- 无 Redis: + - 回退为数据库配置 + 本地缓存 + - 接受短暂的配置同步延迟 + +这样可以避免多实例下因 `SyncOptions` 周期造成的维护状态切换延迟。 + +### 中间件挂载建议 + +挂在 relay 路径和关键 API 路径前面,但不能简单依赖 `/api/admin` 前缀判断。 + +建议规则: + +- root / admin 用户放行 +- 特定公开接口可放行 +- 其余 API 与 relay 请求返回 503 + +### Phase 2 + +如确实需要“多个未来维护计划”,再引入 `MaintenancePlan` 表和管理页。 + +### 可行性 + +中高,适合一期。 + +--- + +## 7.3 模块 1:慢请求监控 V1 + +### V1 目标 + +先做慢请求统计与管理员告警,不做复杂多通道告警配置。 + +### 实现路径 + +直接聚合 `logs`: + +- `type in (consume,error)` +- `use_time >= threshold` +- `created_at >= now - window` +- 可按 `channel_id` / `model_name` 过滤 + +### 执行方式 + +Phase 1 可以先做固定后台循环: + +- 每 3 分钟检查一次 +- 阈值与窗口放在 `option/config` + +Phase 2 再纳入轻量调度平台。 + +### 告警方式 + +复用: + +- `NotifyRootUser` +- 或管理员广播通知 + +### 不建议的做法 + +- 一期直接上 `notification_channels` 配置中心 +- 一期在主链路额外写一份慢请求 Redis 结构 + +### 可行性 + +高。 + +--- + +## 7.4 模块 7:不活跃账户清理 V1 + +### 原方案问题 + +原文依赖: + +- `users.is_charged` +- `request_logs` + +这两者在当前仓库中都不存在。 + +### V1 口径建议 + +“从未充值”定义为: + +- 在 `top_ups` 中无成功充值记录 +- 且在 `subscription_orders` / `user_subscriptions` 中无有效订阅购买记录 + +“不活跃”定义为: + +- 在 `logs` 中最近 N 天无消费/错误请求记录 + +“有剩余额度”定义为: + +- `users.quota > 0` + +### 执行方式 + +Phase 1 先做固定后台循环,默认每天凌晨执行。 + +### 安全机制 + +- 默认 dry-run 一次 +- 必须记录 `QuotaCleanupLog` +- 支持白名单用户 ID +- 默认排除管理员和 root + +### 可行性 + +中高,但查询逻辑要按现有真实表重写。 + +--- + +## 7.5 模块 4:用户并发限制 + +### 目标 + +限制同一用户同时进行的 relay 请求数。 + +### 挂载位置 + +建议挂载在 relay 入口的 `TokenAuth()` 之后、`Distribute()` 之前。 + +原因: + +- 这时已经拿到用户身份 +- 这时尚未进入渠道选择和上游调用 +- 可以尽早失败,降低系统成本 + +### 实现方式 + +Redis Lua 原子计数方案可保留。 + +优先级建议: + +1. 用户级覆盖表 +2. 用户组默认值(来自 `config.GlobalConfig.Register()` 的结构化配置) +3. 系统默认值 + +### “充值用户”判定建议 + +不要用 `quota > 0` 判断。 + +更合理口径: + +- 有成功 `top_up` +- 或有有效订阅 +- 或由管理员显式归类到特定用户组 + +若一期赶工,可先按用户组判断,不自动推断“是否充值”。 + +### Redis 宕机降级策略 + +并发限制不能采用 Redis 故障即全拒绝的策略。 + +推荐策略: + +- Redis 正常:执行原子计数限制 +- Redis 异常: + - 记录告警和错误日志 + - 本次请求降级放行 + - 不影响主链路可用性 + +理由: + +- 并发限制属于保护性能力,不应成为系统单点故障源 +- Redis 故障时,优先保证请求可用,再由运维介入恢复 + +### 配置落点建议 + +并发配置建议新增单独 setting,例如: + +- `ConcurrencySetting` +- `config.GlobalConfig.Register("concurrency_setting", &concurrencySetting)` + +字段可包括: + +- `free_default` +- `paid_default` +- `group_defaults` +- `redis_fail_open` + +### 可行性 + +中高,但需要谨慎处理异常退出时的 Redis 计数回收。 + +--- + +## 7.5A 时间动态倍率 + +### 建议纳入 Sprint A + +这是一个低风险、高收益、与现有计费体系贴合度较高的需求,建议直接纳入 Sprint A。 + +### 目标 + +支持按时间段对指定模型、分组或全局倍率进行动态调整,用于: + +- 高峰期涨价 +- 低峰期促销 +- 临时活动策略 + +### 范围控制 + +一期只影响计费计算,不改动以下能力: + +- 模型同步 +- 公开倍率同步接口 +- 模型可用性判断 +- 上游渠道选择逻辑 + +也就是说,V1 只在最终扣费倍率阶段生效。 + +Sprint A 明确不做: + +- 按渠道时间动态倍率 + +原因: + +- 现有 relay 重试链路可能在失败后切换渠道 +- 若倍率绑定渠道,会出现“预扣按原渠道、结算按重试渠道”的口径复杂度 +- 该能力应放到后续增强版本,届时与重试重算、补差、日志展示一起设计 + +### 配置方式 + +建议新增结构化配置,例如: + +```go +type TimeDynamicRatioSetting struct { + Enabled bool `json:"enabled"` + Rules []TimeDynamicRatioRule `json:"rules"` +} + +type TimeDynamicRatioRule struct { + Name string `json:"name"` + StartTime string `json:"start_time"` // HH:MM + EndTime string `json:"end_time"` // HH:MM + Weekdays []int `json:"weekdays"` + Groups []string `json:"groups"` + Models []string `json:"models"` + Multiplier float64 `json:"multiplier"` + Enabled bool `json:"enabled"` +} +``` + +并注册到: + +- `config.GlobalConfig.Register("time_dynamic_ratio_setting", &timeDynamicRatioSetting)` + +后端配置文件建议放在: + +- `setting/operation_setting/time_dynamic_ratio.go` + +### 生效位置 + +建议固定在现有价格计算主入口: + +- `relay/helper/price.go` +- `ModelPriceHelper()` + +具体方式: + +- 在 `ModelPriceHelper()` 中解析命中的时间动态倍率规则 +- 将倍率以 `PriceData.OtherRatios["time_dynamic_multiplier"]` 方式一次注入 +- 让下游文本计费、任务计费等路径自动复用现有 `OtherRatios` 机制生效 + +不建议 Sprint A 将逻辑分散写入: + +- `service/text_quota.go` +- `service/quota.go` + +### Sprint A 实施建议 + +V1 先支持: + +- 全局时段倍率 +- 按用户组时段倍率 +- 按模型时段倍率 +- 简单 weekday + 时间区间 + +先不支持: + +- 按渠道时段倍率 +- 节假日规则 +- 多规则复杂优先级 +- 与促销系统联动 + +### 前端放置建议 + +按当前产品归类,前端入口放在: + +- 运营设置 Tab + +不放到定价设置 Tab。 + +--- + +## 7.6 模块 2:轻量调度管理 + +### 不建议直接照原方案做的点 + +- 不建议一开始做 JSON Schema 动态表单平台 +- 不建议一开始支持过多任务类型 +- 不建议把所有后台任务都强行迁入调度器 + +### 推荐最小版本 + +先支持: + +- 任务列表 +- 创建 / 编辑 / 启停 +- 手动执行一次 +- 最近执行日志 + +先支持的任务类型: + +- `slow_request_check` +- `inactive_cleanup` +- `usage_report` +- `log_cleanup` + +### 代码组织建议 + +- `model/scheduled_task.go` +- `service/scheduled_task_runner.go` +- `service/scheduled_task_handlers.go` +- `controller/scheduled_task.go` + +### 路由建议 + +- `GET /api/scheduled_task` +- `POST /api/scheduled_task` +- `PUT /api/scheduled_task/:id` +- `DELETE /api/scheduled_task/:id` +- `POST /api/scheduled_task/:id/run` +- `POST /api/scheduled_task/:id/toggle` +- `GET /api/scheduled_task/:id/executions` + +### 可行性 + +中等,建议二期。 + +--- + +## 7.7 模块 5:渠道兜底 + +### 这是全案中风险最高的模块 + +原因: + +- 当前 relay 已有重试机制 +- 当前 relay 已有预扣费/退款逻辑 +- 当前 relay 包含流式和非流式两套行为 +- 当前请求体会复用 body storage,多次转发需要严格处理 +- 当前渠道分发逻辑并非简单“按 channel_id 直接转发” + +### 推荐做法 + +不要新增一个与当前 relay 并行的 `RelayWithFallback` 主流程。 + +应在现有链路上增强: + +1. 先拿到主渠道 +2. 判断是否存在 fallback rule +3. 在“可重试且可切换渠道”的错误场景下,显式指定候选渠道重试 +4. 确保每次切换渠道时: + - 请求体可重放 + - 账单上下文一致 + - 错误日志与使用日志正确归属 + - 流式请求不会多次向客户端写入冲突数据 + +### 先决条件 + +在做兜底前,建议先梳理: + +- `controller/relay.go` +- `middleware/Distribute()` +- 账单预扣与退款逻辑 +- 渠道自动禁用逻辑 + +### 可行性 + +中等偏低,但不是不能做;建议单独成一期。 + +--- + +## 8. 对补充功能(8-14)的修订建议 + +## 8.1 渠道健康评分 + +建议保留,但放在 Phase 2。 + +输入数据来源: + +- `logs.use_time` +- `logs.type` +- 渠道测试结果 + +用途: + +- 后台展示排名 +- 后续为兜底与降权提供参考 + +## 8.2 Token 用量日报 + +建议保留,Phase 2。 + +数据来源可直接基于现有 `logs` 聚合,无需新表。 + +## 8.3 IP 白黑名单 + +建议暂缓。 + +因为: + +- 会影响公开 API、relay、管理员登录等多类入口 +- CIDR、代理头、反代部署、白名单优先级都容易出错 + +## 8.4 请求重放 / 调试 + +建议暂缓。 + +原因: + +- 涉及请求体和响应体完整落盘 +- 可能包含敏感密钥、图片、文件、隐私数据 +- 还会显著增加存储和性能压力 + +## 8.5 渠道自动禁用与恢复 + +当前已有基础能力,不应重做。 + +建议在现有能力上增强: + +- 增加更清晰的失败次数窗口统计 +- 增加后台展示 +- 与健康评分联动 + +## 8.6 用户公告系统 + +当前已有基础能力,不建议单独新建 `announcements` 表作为一期。 + +建议先增强现有 `console_setting.announcements` 的编辑和展示。 + +## 8.7 操作审计日志 + +建议二期后半段再做。 + +原因: + +- 涉及所有管理写操作 +- 改动面广 +- 需要定义统一埋点口径 + +--- + +## 9. 推荐开发顺序 + +## Sprint A + +- 模块 6:导出 Codex / Claude Code 配置 +- 模块 3:维护模式 V1 + +## Sprint B + +- 模块 1:慢请求监控 V1 +- 模块 7:不活跃账户清理 V1 + +## Sprint C + +- 模块 4:用户并发限制 + +## Sprint D + +- 模块 2:轻量调度管理 +- 模块 8 中的日报与健康评分 + +## Sprint E + +- 模块 5:渠道兜底 + +--- + +## 10. 粗略工期评估 + +| 阶段 | 工期 | +|------|------| +| Sprint A | 2-3 天 | +| Sprint B | 3-4 天 | +| Sprint C | 2-3 天 | +| Sprint D | 4-6 天 | +| Sprint E | 5-8 天 | + +合计: + +- 仅 Phase 1:约 7-10 个工作日 +- 到 Phase 2:约 11-16 个工作日 +- 包含兜底:约 16-24 个工作日 + +这比原先“13 个工作日全做完”的估计更接近实际。 + +--- + +## 11. 最终建议 + +如果目的是尽快交付一批能上线的功能,推荐立刻开始的范围是: + +1. 模块 6 一键导出配置 +2. 模块 3 维护模式 V1 +3. 模块 1 慢请求监控 V1 +4. 模块 7 不活跃账户清理 V1 +5. 模块 4 用户并发限制 + +如果目的是做成一套完整运维平台,再进入第二阶段: + +1. 轻量调度管理 +2. 用量日报 +3. 渠道健康评分 + +渠道兜底必须放到最后单独设计和实现,不建议作为前几天的核心任务直接切入。 diff --git a/aidoc/implementation_playbook.md b/aidoc/implementation_playbook.md new file mode 100644 index 000000000000..a38e34793970 --- /dev/null +++ b/aidoc/implementation_playbook.md @@ -0,0 +1,1102 @@ +# New-API 二次开发落地实施方案 + +## 文档目的 + +本文档用于指导后续实际开发、联调、测试与上线。 + +它基于当前仓库真实结构,而不是基于原始 `aidoc/` 草案的理想化设计。 + +本文档重点解决三类问题: + +1. 做什么 +2. 在当前仓库里放到哪里做 +3. 以什么顺序和方式做,风险最低 + +--- + +## 1. 项目目标与范围 + +### 1.1 目标 + +基于当前 `new-api` 仓库,在不破坏现有主链路稳定性的前提下,分阶段落地以下能力: + +- 模块 6:一键导出 Codex / Claude Code 配置 +- 模块 3:维护模式 V1 +- 时间动态倍率 +- 模块 1:慢请求监控 V1 +- 模块 7:不活跃账户清理 V1 +- 模块 4:用户并发限制 +- 模块 2:轻量调度管理 +- 模块 5:渠道兜底 +- 运维增强能力:用量日报、健康评分、自动禁用增强 + +### 1.2 一期范围 + +优先落地 Sprint A: + +1. 模块 6:一键导出配置 +2. 模块 3:维护模式 V1 +3. 时间动态倍率 + +原因: + +- 改动面相对集中 +- 运维与用户价值都高 +- 不会直接切入最复杂的 relay 失败重试逻辑 + +### 1.3 非目标 + +以下能力不进入 Sprint A: + +- 独立调度平台 UI +- 渠道兜底 +- 请求重放 / 调试 +- 全量审计日志平台 +- 完整 IP 白黑名单系统 +- 独立数据库化公告中心 + +--- + +## 2. 当前仓库工程约束 + +### 2.1 必须遵守的规则 + +- JSON 编解码统一使用 `common/json.go` +- 数据库必须同时兼容 SQLite / MySQL / PostgreSQL +- 优先使用 GORM,不依赖数据库方言特性 +- 新增结构化配置优先使用 `config.GlobalConfig.Register()` +- 新增表字段中的复杂 JSON 数据优先存为 `TEXT` 字符串 +- 新模型时间字段优先使用 `int64` Unix 时间戳 + +### 2.2 必须复用的现有能力 + +- Redis:`common.RDB` / `common.RedisEnabled` +- 配置体系: + - `common.OptionMap` + - `config.GlobalConfig` +- 后台任务模式: + - `main.go` 启动 + - `service/*task.go` + - `sync.Once + atomic.Bool` +- 通知能力: + - `service.NotifyRootUser` + - `service.NotifyUser` +- 控制台配置与面板: + - `console_setting` + - `controller.GetStatus` +- 日志能力: + - `model.Log` + - `logs.use_time` + - `logs.request_id` +- 通道巡检与自动禁用: + - `controller/channel-test.go` + - `service/channel.go` + +### 2.3 当前设计基线 + +当前仓库已经不是“空白 new-api”,而是一个已扩展过的工程。 + +因此开发策略必须是: + +- 增量增强 +- 不平行造轮子 +- 先把新增能力挂在现有链路上 +- 能复用已有配置、通知、日志、后台任务的地方不另起一套 + +--- + +## 3. 总体分期 + +## Sprint A + +- 模块 6:一键导出配置 +- 模块 3:维护模式 V1 +- 时间动态倍率 + +## Sprint B + +- 模块 1:慢请求监控 V1 +- 模块 7:不活跃账户清理 V1 + +## Sprint C + +- 模块 4:用户并发限制 + +## Sprint D + +- 模块 2:轻量调度管理 +- Token 用量日报 +- 渠道健康评分 + +## Sprint E + +- 模块 5:渠道兜底 + +--- + +## 4. 配置与状态设计总原则 + +### 4.1 结构化配置统一方案 + +新增的业务配置统一按以下模式实现: + +1. 在 `setting/operation_setting/`、`setting/system_setting/` 或 `setting/ratio_setting/` 下新增配置文件 +2. 定义结构体 +3. `config.GlobalConfig.Register("xxx_setting", &xxxSetting)` +4. 通过现有 `option` 持久化机制读写数据库 +5. 前端仍通过 `/api/option` 读写配置键 + +这样做的好处: + +- 与现有仓库一致 +- 类型安全 +- 不需要为每个配置单独造表 +- 前端可以继续沿用现有设置页更新方式 + +### 4.2 实时状态与持久状态分离 + +以下状态建议采用“双层设计”: + +- 持久层:数据库 / option 配置 +- 实时层:Redis + +适合这样设计的能力: + +- 维护模式 +- 并发计数 +- 后续的慢请求实时采样 + +### 4.3 Redis 故障策略 + +所有依赖 Redis 的保护性能力必须遵守: + +- Redis 正常:按设计执行 +- Redis 故障:优先保证主链路可用 + +对应策略: + +- 并发限制:Redis 故障时 fail-open 放行 +- 维护模式:Redis 故障时回退数据库配置 +- 慢请求监控:Redis 故障时回退日志聚合或不触发实时统计 + +--- + +## 5. Sprint A 详细实施 + +## 5.1 模块 6:一键导出 Codex / Claude Code 配置 + +### 5.1.1 目标 + +在用户 Token 管理界面,为每个 Token 提供“导出接入配置”的能力。 + +能力范围: + +- 生成 Codex 配置片段 +- 生成 Claude Code 配置片段 +- 生成 Cursor / Continue 通用接入片段 +- 提供测试命令 + +不做: + +- 自动写入用户本地文件 +- 下载脚本 +- 自动安装 CLI + +### 5.1.2 路由设计 + +新增接口: + +- `GET /api/token/:id/export?tool=codex` +- `GET /api/token/:id/export?tool=claude_code` +- `GET /api/token/:id/export?tool=cursor` +- `GET /api/token/:id/export?tool=continue` + +鉴权: + +- `middleware.UserAuth()` +- 仅允许访问自己的 token + +### 5.1.3 返回结构建议 + +```json +{ + "tool": "codex", + "display_name": "Codex", + "env_script": "export ...", + "config_file": "~/.codex/config.toml", + "config_content": "...", + "test_command": "curl ...", + "notes": [ + "说明1", + "说明2" + ] +} +``` + +### 5.1.4 后端文件落点 + +新增: + +- `controller/token_export.go` + +可选新增: + +- `service/token_export.go` + +修改: + +- `router/api-router.go` + +复用: + +- `model.GetTokenByIds` +- `system_setting.ServerAddress` +- `controller/token.go` 的 token 权限模式 + +### 5.1.5 实现要点 + +- 统一从 `system_setting.ServerAddress` 取服务地址 +- 若服务地址为空,可回退为当前请求推导地址,但文档中标记为兜底逻辑 +- 输出的 token key 必须使用真实 key,不使用掩码 +- 需要考虑不同客户端要求: + - Codex:环境变量 + config 片段 + - Claude Code:环境变量 / gateway 模式片段 + - Cursor / Continue:OpenAI-compatible 片段 + +### 5.1.6 前端文件落点 + +可能涉及: + +- `web/src/components/table/tokens/TokensColumnDefs.jsx` +- `web/src/components/table/tokens/modals/TokenExportConfigModal.jsx` 新增 +- `web/src/hooks/tokens/useTokensData.jsx` 可能新增调用逻辑 +- `web/src/i18n/locales/*.json` + +### 5.1.7 UI 设计建议 + +在 token 列表的操作下拉中新增: + +- 导出 Codex 配置 +- 导出 Claude Code 配置 +- 导出 Cursor 配置 +- 导出 Continue 配置 + +点击后弹出模态框,内容分区展示: + +- 环境变量方式 +- 配置文件方式 +- 测试命令 +- 注意事项 + +### 5.1.8 测试 + +后端: + +- token 不属于当前用户时返回 403 +- 无效 tool 参数返回 400 +- 返回内容字段完整 +- 服务地址为空时的兜底逻辑 + +前端: + +- 弹窗打开与关闭 +- 配置片段复制 +- 移动端展示不溢出 + +### 5.1.9 验收标准 + +- 用户能在 token 列表中直接打开导出配置弹窗 +- 导出的内容能被复制 +- 配置片段与当前服务地址、token 对应正确 +- 不泄露其他用户 token + +--- + +## 5.2 模块 3:维护模式 V1 + +### 5.2.1 目标 + +提供可控的全站维护能力,支持: + +- 即时开启维护 +- 即时关闭维护 +- 维护预告 +- 多实例部署实时生效 +- 管理员放行 + +### 5.2.2 一期范围 + +Sprint A 只做 V1: + +- 单一当前维护状态 +- 不做多条未来排期计划 +- 不做复杂时间编排 UI + +### 5.2.3 配置结构 + +新增: + +- `setting/system_setting/maintenance.go` + +```go +type MaintenanceSetting struct { + Enabled bool `json:"enabled"` + Title string `json:"title"` + Message string `json:"message"` + NoticeEnabled bool `json:"notice_enabled"` + NoticeStartAt int64 `json:"notice_start_at"` + StartAt int64 `json:"start_at"` + EndAt int64 `json:"end_at"` + WhitelistUserIds []int `json:"whitelist_user_ids"` + AllowAdminPass bool `json:"allow_admin_pass"` +} +``` + +注册: + +- `config.GlobalConfig.Register("maintenance_setting", &maintenanceSetting)` + +### 5.2.4 Redis 实时状态设计 + +键名建议: + +- `maintenance:current` + +值内容: + +- 使用 JSON 字符串保存完整维护状态 + +行为规则: + +- 管理端更新维护状态时: + - 先写数据库持久化配置 + - 有 Redis 时同步写 Redis +- 请求链路读取时: + - 优先读 Redis + - Redis 不可用时回退配置 + +### 5.2.5 中间件设计 + +新增: + +- `middleware/maintenance.go` + +核心逻辑: + +1. 读取当前维护状态 +2. 若未启用则直接放行 +3. 判断当前是否处于预告期 +4. 判断当前是否处于维护中 +5. root/admin/白名单用户可放行 +6. 其余请求返回 503 + +### 5.2.6 挂载位置 + +建议新增中间件: + +- relay 路由 +- video 路由 +- API 路由中的核心业务接口 + +不建议简单以 URL 前缀判断“管理接口是否放行”。 + +应该以鉴权角色判断。 + +### 5.2.7 API 设计 + +新增: + +- `GET /api/maintenance` +- `PUT /api/maintenance` +- `POST /api/maintenance/disable` + +权限建议: + +- `middleware.RootAuth()` + +理由: + +- 该操作影响全站 +- 风险高于普通管理员编辑单业务数据 + +### 5.2.8 与状态接口联动 + +修改: + +- `controller/misc.go` + +在 `GetStatus()` 中新增: + +- `maintenance` + +建议结构: + +```json +{ + "enabled": true, + "notice_enabled": true, + "title": "系统维护", + "message": "预计 30 分钟恢复", + "notice_start_at": 0, + "start_at": 0, + "end_at": 0 +} +``` + +### 5.2.9 前端改动 + +新增设置页建议: + +- `web/src/pages/Setting/Operation/SettingsMaintenance.jsx` + +接入: + +- `web/src/components/settings/OperationSetting.jsx` + +控制台展示: + +- 用户侧顶部 banner +- 维护中时,可在部分页面显示明显提示 + +### 5.2.10 测试 + +后端: + +- Redis 可用时状态即时生效 +- Redis 不可用时回退 option/config +- 预告期正常放行且带状态 +- 维护期普通用户被拦截 +- root/admin 放行 +- 白名单用户放行 + +前端: + +- 设置页保存成功 +- 用户端 banner 正确展示 + +### 5.2.11 验收标准 + +- 多实例部署下,维护开关在数秒内生效 +- Redis 宕机时仍可通过数据库配置正常工作 +- 普通用户在维护期收到一致的 503 响应 +- 管理员不被维护模式误伤 + +--- + +## 5.3 时间动态倍率 + +### 5.3.1 目标 + +支持按时间段动态调整计费倍率,用于高峰限流、低峰促销、活动运营。 + +### 5.3.2 一期范围 + +Sprint A 只做 V1: + +- 全局倍率规则 +- 按用户组倍率规则 +- 按模型倍率规则 +- 星期 + 时间区间匹配 + +不做: + +- 按渠道倍率规则 +- 节假日规则 +- 日期范围配置 +- 多层复杂优先级系统 +- 与前端营销系统联动 + +补充说明: + +- 按渠道时间动态倍率在技术上可行,但不建议进入 Sprint A +- 原因是 relay 重试时可能切换渠道,会引入“预扣按原渠道、结算按重试渠道”的计费口径复杂度 +- 该能力建议放入后续增强版本,与重试重算、补差、日志展示一起设计 + +### 5.3.3 配置结构 + +新增: + +- `setting/operation_setting/time_dynamic_ratio.go` + +```go +type TimeDynamicRatioSetting struct { + Enabled bool `json:"enabled"` + Rules []TimeDynamicRatioRule `json:"rules"` +} + +type TimeDynamicRatioRule struct { + Name string `json:"name"` + Enabled bool `json:"enabled"` + StartTime string `json:"start_time"` // HH:MM + EndTime string `json:"end_time"` // HH:MM + Weekdays []int `json:"weekdays"` + Groups []string `json:"groups"` + Models []string `json:"models"` + Multiplier float64 `json:"multiplier"` +} +``` + +注册: + +- `config.GlobalConfig.Register("time_dynamic_ratio_setting", &timeDynamicRatioSetting)` + +### 5.3.4 匹配策略 + +建议优先级: + +1. 模型 + 组同时匹配 +2. 仅模型匹配 +3. 仅组匹配 +4. 全局匹配 + +一期可以采用: + +- 命中第一条即生效 + +后续若需要更复杂规则,再扩展。 + +### 5.3.5 生效位置 + +核心集成点固定为: + +- `relay/helper/price.go` +- `ModelPriceHelper()` + +原因: + +- 当前价格计算主入口已经在这里统一汇总 `PriceData` +- `PriceData.OtherRatios` 已被下游文本计费、任务计费等路径消费 +- 在这里一次注入倍率,下游会自动生效,改动面最小 + +可新增辅助函数,例如: + +- `ResolveTimeDynamicMultiplier(...)` +- `MatchTimeDynamicRatioRule(...)` + +但最终注入动作应收口在 `ModelPriceHelper()` + +### 5.3.6 集成点 + +Sprint A 不建议把时间动态倍率逻辑分散写入多个计费文件。 + +推荐方式: + +- 在 `relay/helper/price.go` 的 `ModelPriceHelper()` 中注入 `OtherRatios` +- 保持 `service/text_quota.go` +- 保持 `service/task_billing.go` +- 保持其他现有计费路径按原有 `OtherRatios` 消费逻辑运行 + +这样可以避免在多个结算入口重复维护同一套时间规则。 + +### 5.3.7 实现建议 + +优先复用现有 `PriceData.OtherRatios`: + +- key 建议:`time_dynamic_multiplier` + +这样做的好处: + +- 日志可追踪 +- 与现有附加倍率机制兼容 +- 减少额外上下文传递改动 + +### 5.3.8 计费原则 + +V1 只影响最终额度计算。 + +不影响: + +- 模型发现 +- 模型可用性 +- 模型公开倍率同步接口 +- 通道路由选择 + +### 5.3.9 前端 + +新增设置页建议: + +- `web/src/pages/Setting/Operation/SettingsTimeDynamicRatio.jsx` + +接入: + +- `web/src/components/settings/OperationSetting.jsx` + +说明: + +- 尽管该能力本质上属于定价策略,但按当前产品归类,前端放到“运营设置” +- Sprint A 不放到“分组与模型定价设置” + +展示建议: + +- 开关 +- 规则表 +- 新增/编辑规则 modal + +### 5.3.10 测试 + +- 指定时间命中规则 +- 跨午夜区间规则 +- 模型匹配、组匹配、全局匹配 +- 未命中时倍率为 1 +- `ModelPriceHelper()` 注入后的文本与任务链路都能生效 +- 重试切换渠道时不引入按渠道倍率差异 + +### 5.3.11 验收标准 + +- 在配置时间窗口内,计费结果按预期变化 +- 日志中可看到动态倍率信息 +- 未命中规则时不影响现有计费结果 + +--- + +## 6. Sprint B 实施 + +## 6.1 模块 1:慢请求监控 V1 + +### 目标 + +基于现有 `logs` 聚合慢请求,不增加主链路写入复杂度。 + +### 核心设计 + +- 使用后台固定循环 +- 周期默认 3 分钟 +- 统计窗口默认 5 分钟 +- 从 `logs` 表按 `use_time` 聚合 +- 告警默认发给 root 或启用通知的管理员 + +### 配置建议 + +新增: + +- `setting/operation_setting/slow_request_setting.go` + +字段建议: + +- `enabled` +- `threshold_seconds` +- `window_minutes` +- `alert_count` +- `cooldown_minutes` +- `notify_admin_only` + +### 文件落点 + +- `service/slow_request_monitor_task.go` +- `setting/operation_setting/slow_request_setting.go` +- `controller/slow_request.go` 可选 + +### 测试 + +- 聚合逻辑正确 +- 冷却时间生效 +- 无日志时不误报 + +--- + +## 6.2 模块 7:不活跃账户清理 V1 + +### 目标 + +清理长期不活跃且无充值/订阅历史的用户额度。 + +### 判断口径 + +不活跃: + +- 最近 N 天在 `logs` 中没有消费或错误请求 + +未充值: + +- `top_ups` 无成功充值记录 +- `subscription_orders` / `user_subscriptions` 无有效订阅记录 + +### 实现路径 + +新增: + +- `model/quota_cleanup_log.go` +- `service/inactive_cleanup_task.go` +- `setting/operation_setting/inactive_cleanup_setting.go` + +### 注意点 + +- 一期先做 dry-run 模式 +- 实际执行前必须记录清理日志 +- 白名单、管理员、root 默认不处理 + +--- + +## 7. Sprint C 实施 + +## 7.1 模块 4:用户并发限制 + +### 目标 + +限制同一用户在 relay 主链路上的并发请求数。 + +### 挂载位置 + +建议加在: + +- `TokenAuth()` 之后 +- `Distribute()` 之前 + +对应路由: + +- `router/relay-router.go` +- `router/video-router.go` +- 任务相关 relay 入口也要覆盖 + +### 设计原则 + +- Redis 正常时原子计数 +- Redis 故障时 fail-open 放行 +- 请求结束后释放计数 +- 异常中断依赖 TTL 自动回收 + +### 配置结构 + +新增: + +- `setting/operation_setting/concurrency_setting.go` + +字段建议: + +- `enabled` +- `free_default` +- `paid_default` +- `group_defaults` +- `redis_fail_open` +- `counter_ttl_seconds` + +### 数据模型 + +新增: + +- `model/user_concurrency_override.go` + +只做用户级覆盖,不单独建组配置表。 + +### 中间件 + +新增: + +- `middleware/concurrency_limit.go` + +### 注意点 + +- “已充值用户”不建议用 `quota > 0` 判断 +- 一期可优先按用户组区分 + +--- + +## 8. Sprint D 实施 + +## 8.1 模块 2:轻量调度管理 + +### 目标 + +不是做通用平台,而是做“有限内置任务的持久化编排器”。 + +### 支持任务类型 + +- `slow_request_check` +- `inactive_cleanup` +- `usage_report` +- `log_cleanup` + +### 数据模型 + +新增: + +- `model/scheduled_task.go` +- `model/scheduled_task_execution.go` + +### 后端 + +新增: + +- `service/scheduled_task_runner.go` +- `service/scheduled_task_handlers.go` +- `controller/scheduled_task.go` + +### 路由 + +- `GET /api/scheduled_task` +- `POST /api/scheduled_task` +- `PUT /api/scheduled_task/:id` +- `DELETE /api/scheduled_task/:id` +- `POST /api/scheduled_task/:id/run` +- `POST /api/scheduled_task/:id/toggle` +- `GET /api/scheduled_task/:id/executions` + +### UI + +新增设置页或管理页: + +- 任务列表 +- 启停 +- 手动执行 +- 最近执行日志 + +--- + +## 8.2 渠道健康评分 + +### 目标 + +基于现有日志和巡检结果给渠道打分。 + +### 数据来源 + +- `logs.use_time` +- `logs.type` +- 自动巡检结果 + +### 输出 + +- 后台排行 +- 后续供兜底和自动禁用增强使用 + +--- + +## 8.3 Token 用量日报 + +### 目标 + +每天推送用量汇总给管理员。 + +### 数据来源 + +- `logs` + +### 输出内容 + +- 总请求数 +- 总额度消耗 +- Top 用户 +- Top 模型 +- Top 渠道 + +--- + +## 9. Sprint E 实施 + +## 9.1 模块 5:渠道兜底 + +### 这是最高风险模块 + +原因: + +- 当前 relay 已有重试 +- 当前 relay 有预扣费与退款 +- 流式与非流式逻辑不同 +- body storage 需要多次重放 +- 错误日志与使用日志都已嵌入主链路 + +### 实施原则 + +不要新建一个平行的 relay 主流程。 + +应在现有: + +- `controller/relay.go` +- `middleware/Distribute()` + +基础上增强“候选渠道重试能力”。 + +### 数据模型 + +新增: + +- `model/channel_fallback_rule.go` + +### 关键要求 + +- 每次 fallback 都能重放请求体 +- 预扣费与退款口径一致 +- 流式场景不出现重复写响应 +- 错误日志能看出 fallback 链路 + +### 建议先决工作 + +先补齐: + +- 渠道健康评分 +- 自动禁用增强 +- 错误分类清晰化 + +--- + +## 10. 数据模型总清单 + +建议新增的模型文件: + +- `model/user_concurrency_override.go` +- `model/quota_cleanup_log.go` +- `model/scheduled_task.go` +- `model/scheduled_task_execution.go` +- `model/channel_fallback_rule.go` + +不建议 Sprint A 新增业务表。 + +Sprint A 以配置与接口为主。 + +--- + +## 11. 路由与文件修改清单 + +## Sprint A + +后端新增: + +- `controller/token_export.go` +- `middleware/maintenance.go` +- `service/maintenance_state.go` +- `setting/system_setting/maintenance.go` +- `setting/operation_setting/time_dynamic_ratio.go` + +后端修改: + +- `router/api-router.go` +- `router/relay-router.go` +- `router/video-router.go` +- `controller/misc.go` +- `relay/helper/price.go` +- `service/task_billing.go` + +前端新增: + +- `web/src/components/table/tokens/modals/TokenExportConfigModal.jsx` +- `web/src/pages/Setting/Operation/SettingsMaintenance.jsx` +- `web/src/pages/Setting/Operation/SettingsTimeDynamicRatio.jsx` + +前端修改: + +- `web/src/components/table/tokens/TokensColumnDefs.jsx` +- `web/src/components/settings/OperationSetting.jsx` +- 仪表盘或全局通知展示组件 +- `web/src/i18n/locales/*.json` + +--- + +## 12. 测试策略 + +### 12.1 单元测试 + +重点覆盖: + +- 导出配置生成逻辑 +- 维护状态判定逻辑 +- Redis 失效回退逻辑 +- 动态倍率匹配与 `ModelPriceHelper()` 注入逻辑 +- 慢请求聚合逻辑 +- 清理目标筛选逻辑 +- 并发限制 Redis fail-open + +### 12.2 集成测试 + +重点覆盖: + +- `GET /api/token/:id/export` +- `GET/PUT /api/maintenance` +- 维护期 relay 返回 503 +- GetStatus 返回维护信息 +- 动态倍率影响实际计费 + +### 12.3 手工验证 + +必须进行: + +- 多实例维护切换验证 +- Redis 下线验证 +- 用户端 banner 验证 +- Codex / Claude Code 配置连通性验证 + +--- + +## 13. 上线策略 + +### 13.1 配置默认值 + +所有新增能力默认关闭: + +- maintenance: disabled +- time dynamic ratio: disabled +- slow request monitor: disabled +- inactive cleanup: dry-run / disabled +- concurrency limit: disabled + +### 13.2 上线顺序 + +推荐: + +1. 先发布后端 +2. 再发布前端 +3. 再逐项开启功能开关 + +### 13.3 风险控制 + +- 任何 Redis 依赖能力都不能阻断主链路 +- 维护模式必须先在测试环境验证多实例同步 +- 动态倍率必须先在测试组或单模型试运行 + +--- + +## 14. 各模块验收清单 + +## Sprint A 验收 + +- Token 导出配置接口可用 +- 前端可展示导出弹窗 +- 维护模式可即时开启关闭 +- 多实例下维护状态同步正常 +- Redis 异常时维护模式能回退数据库配置 +- 时间动态倍率能对计费结果生效 +- 日志中可看到动态倍率信息 + +## Sprint B 验收 + +- 慢请求监控可告警 +- 清理任务可 dry-run +- 清理任务执行有日志 + +## Sprint C 验收 + +- 并发数限制生效 +- Redis 故障时自动放行 +- 用户级覆盖可配置 + +## Sprint D 验收 + +- 调度任务可创建、执行、查看日志 +- 日报可发送 +- 健康评分可展示 + +## Sprint E 验收 + +- 主渠道失败时能切换备用渠道 +- 不影响账单口径 +- 不破坏流式响应 + +--- + +## 15. 最终建议 + +开发时请始终按以下顺序判断: + +1. 能否复用现有能力 +2. 能否先做配置版 / 简化版 +3. 是否会碰 relay 主链路 +4. Redis 故障时是否仍可用 +5. 多实例部署是否一致 + +对于当前项目,最稳妥的开发顺序是: + +1. 先做 Sprint A +2. Sprint A 验收通过后再做慢请求和清理 +3. 再做并发限制 +4. 最后再进入调度与兜底 + +这份方案可以直接作为后续开发、拆任务、测试和上线的基线文档。 diff --git a/aidoc/module_1_monitor.md b/aidoc/module_1_monitor.md new file mode 100644 index 000000000000..74818ebf9a3c --- /dev/null +++ b/aidoc/module_1_monitor.md @@ -0,0 +1,145 @@ +# 模块一:慢请求监控告警 + +## 设计变更 + +> **用户反馈**:慢请求监控整合进调度管理模块,提供可配置选项给管理员。 + +不再作为独立的后台轮询,而是作为**调度任务的一种内置类型**,管理员可以在调度管理页面中: +- 创建/编辑慢请求监控任务 +- 配置阈值、窗口、触发条件 +- 选择告警通道 + +--- + +## 管理员可配置选项 + +在调度模块创建「慢请求监控」任务时,提供以下配置项: + +``` +┌─ 创建监控任务 ─────────────────────────────────┐ +│ │ +│ 任务名称:[慢请求监控 - 全渠道 ] │ +│ 执行频率:[*/3 * * * *] (每3分钟) │ +│ │ +│ ── 监控参数 ── │ +│ 慢请求阈值: [10] 秒 ▼ (5/10/15/30/60) │ +│ 监控窗口: [5 ] 分钟 ▼ (3/5/10/15/30) │ +│ 触发数量: [10] 个 ▼ (5/10/20/50) │ +│ 告警冷却: [15] 分钟 ▼ (5/15/30/60) │ +│ │ +│ ── 监控范围 ── │ +│ ○ 全部渠道 │ +│ ● 指定渠道:[✓ 渠道A] [✓ 渠道B] [ 渠道C] │ +│ 指定模型: [全部 ▼] │ +│ │ +│ ── 告警通道 ── │ +│ [✓] 钉钉机器人 Webhook: [https://...] │ +│ [✓] Telegram Bot Token: [xxx] Chat: [xxx] │ +│ [ ] 企业微信 │ +│ [ ] 邮件 │ +│ │ +│ [取消] [保存并启用] │ +└──────────────────────────────────────────────────┘ +``` + +--- + +## 后端实现 + +### 任务类型注册 + +```go +// scheduler/tasks/slow_request_check.go + +type SlowRequestCheckParams struct { + ThresholdSeconds int `json:"threshold_seconds"` // 慢请求阈值 + WindowMinutes int `json:"window_minutes"` // 监控窗口 + AlertCount int `json:"alert_count"` // 触发数量 + CooldownMinutes int `json:"cooldown_minutes"` // 冷却时间 + ChannelIDs []int `json:"channel_ids"` // 指定渠道(空=全部) + Models []string `json:"models"` // 指定模型(空=全部) + AlertChannels []string `json:"alert_channels"` // 告警通道 +} + +func (t *SlowRequestCheckTask) Execute(params json.RawMessage) error { + var p SlowRequestCheckParams + json.Unmarshal(params, &p) + + // 1. 从 Redis 滑动窗口获取慢请求计数 + windowStart := time.Now().Add(-time.Duration(p.WindowMinutes) * time.Minute) + count := redis.ZCount(ctx, "slow_requests", + strconv.FormatInt(windowStart.Unix(), 10), "+inf") + + // 2. 判断是否触发告警 + if count >= int64(p.AlertCount) { + // 3. 检查冷却期 + cooldownKey := "alert_cooldown:slow_request" + if redis.Exists(ctx, cooldownKey) == 0 { + // 4. 发送告警 + SendAlert(p.AlertChannels, fmt.Sprintf( + "⚠️ 慢请求告警\n窗口: %d分钟\n慢请求数: %d (阈值>%ds)\n请检查渠道状态", + p.WindowMinutes, count, p.ThresholdSeconds)) + + // 设置冷却 + redis.Set(ctx, cooldownKey, "1", + time.Duration(p.CooldownMinutes)*time.Minute) + } + } + return nil +} +``` + +### 数据采集(在 relay 中间件,始终运行) + +```go +// middleware/monitor.go +// 这部分是被动采集,不依赖调度,只要有请求就记录 +func MonitorMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + start := time.Now() + c.Next() + + duration := time.Since(start) + + // 获取当前全局慢请求阈值(从配置缓存读取) + threshold := GetSlowThreshold() // 默认 10 秒 + + if duration > time.Duration(threshold)*time.Second { + // 写入 Redis Sorted Set,供调度任务检查 + redis.ZAdd(ctx, "slow_requests", &redis.Z{ + Score: float64(time.Now().Unix()), + Member: buildSlowRequestEntry(c, duration), + }) + // 维护窗口大小,清除过期数据 + redis.ZRemRangeByScore(ctx, "slow_requests", "-inf", + strconv.FormatInt(time.Now().Add(-30*time.Minute).Unix(), 10)) + } + } +} +``` + +--- + +## 数据库 + +```sql +-- 告警通道配置表(全局共用) +CREATE TABLE notification_channels ( + id INT PRIMARY KEY AUTO_INCREMENT, + name VARCHAR(100) NOT NULL, + type VARCHAR(30) NOT NULL, -- 'dingtalk'/'wechat'/'telegram'/'email'/'webhook' + config JSON NOT NULL, -- 渠道特定配置 + enabled TINYINT(1) DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- 告警历史 +CREATE TABLE alert_history ( + id INT PRIMARY KEY AUTO_INCREMENT, + task_id INT, -- 关联调度任务 + alert_type VARCHAR(50), -- 'slow_request' / 'channel_down' / 'quota_low' + message TEXT, + notification_ids JSON, -- 使用了哪些通知渠道 + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +``` diff --git a/aidoc/module_2_scheduler.md b/aidoc/module_2_scheduler.md new file mode 100644 index 000000000000..c747210d2926 --- /dev/null +++ b/aidoc/module_2_scheduler.md @@ -0,0 +1,173 @@ +# 模块二:调度管理引擎 + +## 核心定位 + +调度管理是一个**统一的定时任务平台**,所有需要周期性执行的功能(慢请求监控、账户清理、渠道探活等)都注册为调度任务类型,管理员在前端统一配置和管理。 + +--- + +## 内置任务类型(管理员选择创建) + +| 任务类型 | 标识 | 参数配置 | 默认频率 | +|----------|------|----------|----------| +| 慢请求监控 | `slow_request_check` | 阈值/窗口/数量/告警通道 | `*/5 * * * *` | +| 渠道可用性检测 | `channel_test` | 测试模型/超时时间 | `*/5 * * * *` | +| 渠道余额检查 | `channel_balance` | 最低余额阈值/告警通道 | `0 */6 * * *` | +| **不活跃账户清理** | `inactive_cleanup` | 不活跃天数/排除充值用户 | `0 3 * * *` | +| 日志清理 | `log_cleanup` | 保留天数 | `0 4 * * *` | +| 统计聚合 | `stats_aggregate` | 聚合粒度 | `0 * * * *` | +| Token 用量日报 | `usage_report` | 报告范围/推送通道 | `0 9 * * *` | +| 渠道健康评分更新 | `health_score` | 评分算法参数 | `*/10 * * * *` | + +--- + +## 管理员配置界面 + +### 任务列表页 + +``` +┌─ 调度任务管理 ─────────────────────────────────────────────┐ +│ [+ 新建任务] │ +│ ┌──────────────────────────────────────────────────────────┐│ +│ │ 状态 │ 名称 │ 类型 │ 频率 │ 上次执行 │ 操作 ││ +│ │──────│──────────────────│──────────────│───────────│──────────│────────││ +│ │ 🟢 │ 全渠道慢请求监控 │ 慢请求监控 │ 每3分钟 │ 2分钟前 │ ⏸ 📝 🗑 ││ +│ │ 🟢 │ 渠道可用性巡检 │ 渠道检测 │ 每5分钟 │ 3分钟前 │ ⏸ 📝 🗑 ││ +│ │ 🟢 │ 不活跃账户清理 │ 账户清理 │ 每天3:00 │ 21小时前 │ ⏸ 📝 🗑 ││ +│ │ ⏸ │ 日志清理 │ 日志清理 │ 每天4:00 │ 已暂停 │ ▶ 📝 🗑 ││ +│ └──────────────────────────────────────────────────────────┘│ +│ │ +│ 📊 最近执行日志 │ +│ ├─ [09:15] 全渠道慢请求监控 ✅ 成功 (45ms) │ +│ ├─ [09:10] 渠道可用性巡检 ✅ 成功,3/3 渠道正常 (2.1s) │ +│ └─ [03:00] 不活跃账户清理 ✅ 清理 12 个用户额度 (156ms) │ +└──────────────────────────────────────────────────────────────┘ +``` + +### 创建任务流程 + +``` +Step 1: 选择任务类型 +┌─────────────────────────────────────────┐ +│ 请选择任务类型: │ +│ │ +│ 📊 慢请求监控 - 检测响应过慢的请求 │ +│ 🔍 渠道可用性检测 - 定期测试渠道连通性 │ +│ 💰 渠道余额检查 - 监控渠道余额 │ +│ 🧹 不活跃账户清理 - 清理闲置用户额度 │ +│ 📁 日志清理 - 清除过期日志数据 │ +│ 📈 统计聚合 - 汇总请求统计数据 │ +│ 📮 用量日报 - 推送每日用量报告 │ +│ ❤️ 渠道健康评分 - 更新渠道健康度 │ +└─────────────────────────────────────────┘ + +Step 2: 配置参数(根据类型动态渲染表单) + +Step 3: 设置执行频率(Cron 表达式 + 可视化选择) +┌──────────────────────────────────┐ +│ 快捷选择: │ +│ [每分钟] [每5分钟] [每小时] │ +│ [每天指定时间] [每周] [自定义] │ +│ │ +│ Cron 表达式:*/5 * * * * │ +│ 说明:每 5 分钟执行一次 │ +└──────────────────────────────────┘ +``` + +--- + +## 后端实现 + +### 调度引擎 + +```go +// scheduler/engine.go +type SchedulerEngine struct { + cron *cron.Cron + registry map[string]TaskHandler // 任务类型 → 处理器 + mu sync.RWMutex +} + +type TaskHandler interface { + // 返回任务类型标识 + Type() string + // 返回参数的 JSON Schema(前端动态渲染用) + ParamSchema() json.RawMessage + // 执行任务 + Execute(ctx context.Context, params json.RawMessage) (*TaskResult, error) +} + +type TaskResult struct { + Success bool `json:"success"` + Output string `json:"output"` +} + +// 启动时注册所有内置任务类型 +func (e *SchedulerEngine) RegisterBuiltinTasks() { + e.Register(&SlowRequestCheckTask{}) + e.Register(&ChannelTestTask{}) + e.Register(&InactiveCleanupTask{}) + e.Register(&LogCleanupTask{}) + e.Register(&StatsAggregateTask{}) + e.Register(&UsageReportTask{}) + e.Register(&HealthScoreTask{}) + e.Register(&ChannelBalanceTask{}) +} + +// 从数据库加载已配置的任务并启动 +func (e *SchedulerEngine) LoadAndStart() error { + tasks, _ := model.GetEnabledTasks() + for _, task := range tasks { + handler := e.registry[task.TaskType] + e.cron.AddFunc(task.CronExpr, func() { + e.executeTask(task, handler) + }) + } + e.cron.Start() + return nil +} +``` + +### 数据库 + +```sql +CREATE TABLE scheduled_tasks ( + id INT PRIMARY KEY AUTO_INCREMENT, + name VARCHAR(100) NOT NULL, + task_type VARCHAR(50) NOT NULL, -- 对应 TaskHandler.Type() + cron_expr VARCHAR(100) NOT NULL, + task_params JSON, -- 任务参数 + enabled TINYINT(1) DEFAULT 1, + last_run_at DATETIME, + next_run_at DATETIME, + last_status VARCHAR(20) DEFAULT 'idle', -- idle/running/success/failed + last_output TEXT, + created_by INT, -- 创建者 + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +); + +CREATE TABLE task_execution_logs ( + id INT PRIMARY KEY AUTO_INCREMENT, + task_id INT NOT NULL, + status VARCHAR(20) NOT NULL, + output TEXT, + duration_ms INT, + started_at DATETIME, + finished_at DATETIME, + INDEX idx_task_time (task_id, started_at) +); +``` + +### API 端点 + +``` +POST /api/scheduler/tasks -- 创建任务 +GET /api/scheduler/tasks -- 列表 +PUT /api/scheduler/tasks/:id -- 更新 +DELETE /api/scheduler/tasks/:id -- 删除 +POST /api/scheduler/tasks/:id/run -- 手动执行 +POST /api/scheduler/tasks/:id/toggle -- 启停 +GET /api/scheduler/tasks/:id/logs -- 执行日志 +GET /api/scheduler/task-types -- 获取可用任务类型和参数 Schema +``` diff --git a/aidoc/module_3_maintenance.md b/aidoc/module_3_maintenance.md new file mode 100644 index 000000000000..54105ca7fb1a --- /dev/null +++ b/aidoc/module_3_maintenance.md @@ -0,0 +1,115 @@ +# 模块三:停机维护提示 + +## 核心逻辑 + +三阶段维护模式:**预告 → 维护中 → 自动恢复** + +```mermaid +stateDiagram-v2 + [*] --> Scheduled: 管理员创建维护计划 + Scheduled --> Notice: 进入预告期(提前N小时) + Notice --> Active: 到达维护开始时间 + Active --> Completed: 到达维护结束时间 + Completed --> [*]: 自动恢复 + + Scheduled --> Cancelled: 管理员取消 + Active --> Cancelled: 管理员提前结束 +``` + +--- + +## 中间件实现 + +```go +// middleware/maintenance.go +func MaintenanceMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + // 从 Redis 缓存读取维护状态(避免每次查库) + maintenance := GetCachedMaintenanceStatus() + + if maintenance == nil || !maintenance.Enabled { + c.Next() + return + } + + now := time.Now() + + // ---- 预告期 ---- + noticeStart := maintenance.StartTime.Add( + -time.Duration(maintenance.NoticeHours) * time.Hour) + if now.After(noticeStart) && now.Before(maintenance.StartTime) { + // 正常处理,但在响应头加预告 + c.Header("X-Maintenance-Scheduled", maintenance.StartTime.Format(time.RFC3339)) + c.Header("X-Maintenance-Message", maintenance.Title) + c.Next() + return + } + + // ---- 维护中 ---- + if now.After(maintenance.StartTime) && now.Before(maintenance.EndTime) { + // 白名单用户放行 + userID := c.GetInt("user_id") + if isMaintenanceWhitelisted(userID, maintenance) { + c.Next() + return + } + + // 管理端 API 放行 + if strings.HasPrefix(c.Request.URL.Path, "/api/admin") { + c.Next() + return + } + + c.JSON(http.StatusServiceUnavailable, gin.H{ + "error": gin.H{ + "message": maintenance.Message, + "type": "system_maintenance", + "title": maintenance.Title, + "start_time": maintenance.StartTime, + "end_time": maintenance.EndTime, + "estimated_end": maintenance.EndTime.Format("15:04"), + }, + }) + c.Abort() + return + } + + c.Next() + } +} +``` + +--- + +## 数据库 + +```sql +CREATE TABLE maintenance_schedules ( + id INT PRIMARY KEY AUTO_INCREMENT, + title VARCHAR(200) NOT NULL, + message TEXT NOT NULL, -- 用户看到的提示信息 + start_time DATETIME NOT NULL, + end_time DATETIME NOT NULL, + notice_hours INT DEFAULT 24, -- 提前通知小时数 + whitelist_users JSON DEFAULT '[]', -- 白名单用户 ID + status VARCHAR(20) DEFAULT 'scheduled', + created_by INT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +``` + +## API 端点 + +``` +POST /api/admin/maintenance -- 创建维护计划 +GET /api/admin/maintenance -- 列表 +PUT /api/admin/maintenance/:id -- 更新 +DELETE /api/admin/maintenance/:id -- 删除 +POST /api/admin/maintenance/instant -- 即时开启/关闭维护 +GET /api/maintenance/status -- 公开接口:查询当前维护状态 +``` + +## 前端 + +- **管理端**:维护计划 CRUD + 即时开关按钮 +- **用户端**:顶部 Banner 展示维护预告(黄色)或维护中(红色) diff --git a/aidoc/module_4_concurrency.md b/aidoc/module_4_concurrency.md new file mode 100644 index 000000000000..852cddba12c8 --- /dev/null +++ b/aidoc/module_4_concurrency.md @@ -0,0 +1,205 @@ +# 模块四:用户并发限制 + +## 设计更新(用户反馈) + +- 充值用户默认并发 **10**(而非不限制) +- 管理员可**单独给某个用户**设置并发数量 +- 并发数优先级:用户自定义 > 用户组默认 > 系统默认 + +--- + +## 并发限制层级 + +``` +优先级(高 → 低): +┌─────────────────────────────────────────┐ +│ 1. 用户级自定义 (user_concurrency_override) │ ← 管理员单独设置 +│ 2. 用户组默认 (group_concurrency_config) │ ← 按组设置 +│ 3. 系统默认 (system_config) │ ← 全局兜底 +└─────────────────────────────────────────┘ +``` + +| 用户类型 | 默认并发 | 说明 | +|----------|----------|------| +| 免费用户 | 3 | 未充值、`quota <= 0` | +| 充值用户 | **10** | `quota > 0` 或 `is_charged = true` | +| VIP 用户 | 50 | `group = "vip"` | +| 管理员 | 不限制 | `role >= admin` | +| 自定义用户 | X | 管理员在用户详情页单独设置 | + +--- + +## 中间件实现 + +```go +// middleware/concurrency.go +func ConcurrencyLimitMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + userID := c.GetInt("user_id") + + // 获取该用户的并发限制值 + limit := resolveUserConcurrencyLimit(userID) + if limit <= 0 { + // 不限制(管理员等) + c.Next() + return + } + + concurrencyKey := fmt.Sprintf("concurrent:%d", userID) + + // Lua 脚本保证原子性:检查 + 递增 + result, err := redis.Eval(ctx, luaIncrIfUnder, []string{concurrencyKey}, limit, 300).Int64() + if err != nil || result == 0 { + c.JSON(429, gin.H{ + "error": gin.H{ + "message": fmt.Sprintf( + "并发请求已达上限 (%d),请稍后再试。升级套餐可提升并发限制。", limit), + "type": "concurrent_limit_exceeded", + "current_limit": limit, + }, + }) + c.Abort() + return + } + + // 请求结束后递减 + defer redis.Decr(ctx, concurrencyKey) + + c.Next() + } +} + +// Lua 脚本:原子检查 + 递增(避免超发) +const luaIncrIfUnder = ` +local key = KEYS[1] +local limit = tonumber(ARGV[1]) +local ttl = tonumber(ARGV[2]) +local current = tonumber(redis.call('GET', key) or '0') +if current < limit then + redis.call('INCR', key) + redis.call('EXPIRE', key, ttl) + return 1 +end +return 0 +` + +// 解析用户实际并发限制 +func resolveUserConcurrencyLimit(userID int) int { + // 1. 检查用户级自定义 + override, exists := GetUserConcurrencyOverride(userID) + if exists { + return override // 管理员为该用户单独设置的值 + } + + // 2. 检查用户类型 + user := GetUser(userID) + + // 管理员不限制 + if user.Role >= model.RoleAdmin { + return -1 // -1 表示不限制 + } + + // 3. 检查用户组配置 + groupConfig := GetGroupConcurrencyConfig(user.Group) + if groupConfig != nil { + return groupConfig.MaxConcurrent + } + + // 4. 按充值状态返回系统默认 + if isChargedUser(user) { + return GetSystemConfig("charged_user_concurrent", 10) + } + return GetSystemConfig("free_user_concurrent", 3) +} +``` + +--- + +## 管理员操作界面 + +### 用户详情页 - 并发设置 + +``` +┌─ 用户详情:张三 (ID: 42) ──────────────────────┐ +│ │ +│ 基本信息: │ +│ 用户组:default 余额:50000 角色:普通用户 │ +│ │ +│ ── 并发限制设置 ── │ +│ 当前生效值:10 (来源:充值用户默认) │ +│ │ +│ [ ] 为该用户设置自定义并发限制 │ +│ 自定义并发数:[ ] │ +│ 说明:设置后将覆盖用户组默认值 │ +│ │ +│ 实时并发:3 / 10 │ +│ │ +│ [保存] │ +└──────────────────────────────────────────────────┘ +``` + +### 系统设置页 - 并发默认值 + +``` +┌─ 并发限制全局设置 ──────────────────────────────┐ +│ │ +│ 免费用户默认并发: [3 ] │ +│ 充值用户默认并发: [10 ] │ +│ VIP 用户默认并发: [50 ] │ +│ │ +│ ── 用户组并发配置 ── │ +│ │ 组名 │ 最大并发 │ 操作 │ │ +│ │ default │ 3 │ 📝 │ │ +│ │ charged │ 10 │ 📝 │ │ +│ │ vip │ 50 │ 📝 │ │ +│ │ premium │ 100 │ 📝 │ │ +│ │ +│ [保存] │ +└──────────────────────────────────────────────────┘ +``` + +--- + +## 数据库 + +```sql +-- 用户组并发配置 +CREATE TABLE group_concurrency_configs ( + id INT PRIMARY KEY AUTO_INCREMENT, + group_name VARCHAR(50) NOT NULL UNIQUE, + max_concurrent INT NOT NULL DEFAULT 3, + description VARCHAR(200), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +); + +-- 用户级并发覆盖 +CREATE TABLE user_concurrency_overrides ( + id INT PRIMARY KEY AUTO_INCREMENT, + user_id INT NOT NULL UNIQUE, + max_concurrent INT NOT NULL, + reason VARCHAR(200), -- 调整原因 + set_by INT, -- 由哪个管理员设置 + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_user (user_id) +); + +-- 系统默认配置(复用已有的 options 表或新建) +INSERT INTO options (key, value) VALUES + ('free_user_concurrent', '3'), + ('charged_user_concurrent', '10'); +``` + +## API 端点 + +``` +GET /api/admin/concurrency/config -- 获取全局并发配置 +PUT /api/admin/concurrency/config -- 更新全局配置 +GET /api/admin/concurrency/groups -- 获取组并发配置 +PUT /api/admin/concurrency/groups/:name -- 更新组配置 +GET /api/admin/users/:id/concurrency -- 获取用户并发设置 +PUT /api/admin/users/:id/concurrency -- 设置用户自定义并发 +DELETE /api/admin/users/:id/concurrency -- 删除用户自定义(回退到组默认) +GET /api/admin/concurrency/realtime -- 实时并发统计 +``` diff --git a/aidoc/module_5_fallback.md b/aidoc/module_5_fallback.md new file mode 100644 index 000000000000..2d4371263f26 --- /dev/null +++ b/aidoc/module_5_fallback.md @@ -0,0 +1,178 @@ +# 模块五:渠道兜底策略 + +## 核心机制 + +每个渠道可独立配置兜底链:`主渠道A → 兜底B → 兜底C`,当主渠道请求失败且满足触发条件时,自动切换到下一个兜底渠道。 + +```mermaid +graph LR + REQ[用户请求] --> A[渠道 A
主渠道] + A -->|成功| RES[返回结果] + A -->|失败/超时/429| B[渠道 B
一级兜底] + B -->|成功| RES + B -->|失败| C[渠道 C
二级兜底] + C -->|成功| RES + C -->|失败| ERR[返回错误
所有渠道耗尽] +``` + +--- + +## 兜底触发条件(每个渠道可独立配置) + +| 条件 | 默认值 | 说明 | +|------|--------|------| +| HTTP 状态码 | `[500, 502, 503, 429]` | 上游返回这些状态码触发兜底 | +| 超时 | `true`,30s | 请求超时触发 | +| 错误关键词 | `["rate_limit", "overloaded", "insufficient_quota"]` | 响应体包含关键词触发 | +| 渠道被禁用 | `true` | 渠道被自动禁用时直接跳过 | + +--- + +## 后端实现 + +```go +// relay/fallback.go +func RelayWithFallback(c *gin.Context, primaryChannelID int, modelName string) error { + fallbackConfig := GetChannelFallback(primaryChannelID) + + // 构建执行链 + var chain []int + if fallbackConfig != nil && fallbackConfig.Enabled { + chain = append([]int{primaryChannelID}, fallbackConfig.FallbackChain...) + } else { + chain = []int{primaryChannelID} + } + + var lastErr error + for level, channelID := range chain { + channel, err := model.GetChannelByID(channelID) + if err != nil || channel.Status == model.ChannelStatusDisabled { + continue // 渠道不存在或已禁用,跳过 + } + + // 检查渠道是否支持该模型 + if !channel.SupportsModel(modelName) { + continue + } + + if level > 0 { + log.Warnf("[兜底] 渠道 %d(%s) 失败 → 切换到渠道 %d(%s) (第%d级兜底)", + chain[level-1], getChannelName(chain[level-1]), + channelID, channel.Name, level) + } + + // 执行 relay + err = doRelay(c, channel) + if err == nil { + // 成功 + if level > 0 { + recordFallbackLog(primaryChannelID, channelID, level, true, "") + } + return nil + } + + lastErr = err + + // 判断是否应该兜底 + if !shouldTriggerFallback(err, fallbackConfig) { + // 不满足兜底条件(如 400 参数错误),直接返回 + return err + } + + recordFallbackLog(primaryChannelID, channelID, level, false, err.Error()) + } + + return fmt.Errorf("所有渠道(%d级)已耗尽: %v", len(chain), lastErr) +} + +func shouldTriggerFallback(err error, config *ChannelFallback) bool { + if config == nil { + return false + } + + relayErr, ok := err.(*RelayError) + if !ok { + return config.TriggerOnTimeout // 非 relay 错误按超时处理 + } + + // 检查状态码 + for _, code := range config.TriggerStatusCodes { + if relayErr.StatusCode == code { + return true + } + } + + // 检查错误关键词 + for _, keyword := range config.TriggerKeywords { + if strings.Contains(relayErr.Message, keyword) { + return true + } + } + + return false +} +``` + +--- + +## 管理员配置界面 + +``` +┌─ 渠道兜底配置:OpenAI 官方 (渠道 #1) ─────────┐ +│ │ +│ [✓] 启用兜底策略 │ +│ │ +│ ── 兜底链 ──(拖拽排序) │ +│ 1️⃣ Azure OpenAI (渠道 #3) [✕ 移除] │ +│ 2️⃣ 中转站 A (渠道 #5) [✕ 移除] │ +│ 3️⃣ 中转站 B (渠道 #7) [✕ 移除] │ +│ [+ 添加兜底渠道] │ +│ │ +│ ── 触发条件 ── │ +│ 状态码:[✓]500 [✓]502 [✓]503 [✓]429 [ ]400 │ +│ 超时触发:[✓] 超时阈值:[30] 秒 │ +│ 关键词:rate_limit, overloaded, insufficient │ +│ │ +│ [取消] [保存] │ +└──────────────────────────────────────────────────┘ +``` + +--- + +## 数据库 + +```sql +CREATE TABLE channel_fallbacks ( + id INT PRIMARY KEY AUTO_INCREMENT, + channel_id INT NOT NULL UNIQUE, + fallback_chain JSON NOT NULL DEFAULT '[]', + trigger_status_codes JSON DEFAULT '[500,502,503,429]', + trigger_on_timeout TINYINT(1) DEFAULT 1, + timeout_seconds INT DEFAULT 30, + trigger_keywords JSON DEFAULT '["rate_limit","overloaded","insufficient_quota"]', + enabled TINYINT(1) DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +); + +CREATE TABLE fallback_logs ( + id INT PRIMARY KEY AUTO_INCREMENT, + primary_channel INT NOT NULL, + fallback_channel INT NOT NULL, + fallback_level INT NOT NULL, + model VARCHAR(100), + success TINYINT(1), + error_message TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + INDEX idx_primary_time (primary_channel, created_at) +); +``` + +## API 端点 + +``` +GET /api/admin/channels/:id/fallback -- 获取渠道兜底配置 +PUT /api/admin/channels/:id/fallback -- 更新兜底配置 +GET /api/admin/fallback/logs -- 兜底日志查询 +GET /api/admin/fallback/stats -- 兜底统计(各渠道触发次数/成功率) +``` diff --git a/aidoc/module_6_import.md b/aidoc/module_6_import.md new file mode 100644 index 000000000000..b048f6f16aa6 --- /dev/null +++ b/aidoc/module_6_import.md @@ -0,0 +1,166 @@ +# 模块六:一键导入 Codex / Claude Code + +## 功能说明 + +在用户的 Token 管理页面,提供一键生成配置的能力,让用户快速将 new-api 地址和 Token 配置到 Codex CLI 或 Claude Code 中。 + +--- + +## 支持的工具 + +| 工具 | 配置方式 | 关键环境变量 | +|------|----------|-------------| +| **Codex CLI** | `~/.codex/config.toml` 或环境变量 | `OPENAI_BASE_URL` + `OPENAI_API_KEY` | +| **Claude Code** | `~/.claude/settings.json` 或环境变量 | `ANTHROPIC_BASE_URL` + `ANTHROPIC_API_KEY` | +| Cursor | Settings UI 或环境变量 | `OPENAI_BASE_URL` + `OPENAI_API_KEY` | +| Continue | `~/.continue/config.json` | `apiBase` 字段 | + +--- + +## 后端实现 + +```go +// controller/export.go + +// GET /api/user/export/config?token_id=xxx&tool=codex +func ExportToolConfig(c *gin.Context) { + tokenID, _ := strconv.Atoi(c.Query("token_id")) + tool := c.Query("tool") // codex / claudecode / cursor / generic + + token, err := model.GetTokenByID(tokenID) + if err != nil || token.UserID != c.GetInt("user_id") { + c.JSON(403, gin.H{"error": "无权访问该 Token"}) + return + } + + // 获取服务器地址 + baseURL := getServerBaseURL(c) // 如 https://api.example.com + + var result map[string]interface{} + + switch tool { + case "codex": + result = generateCodexConfig(baseURL, token.Key) + case "claudecode": + result = generateClaudeCodeConfig(baseURL, token.Key) + case "cursor": + result = generateCursorConfig(baseURL, token.Key) + default: + result = generateGenericConfig(baseURL, token.Key) + } + + c.JSON(200, result) +} + +func generateCodexConfig(baseURL, tokenKey string) map[string]interface{} { + return map[string]interface{}{ + "tool": "Codex CLI", + // 方式一:环境变量(推荐) + "env_script": fmt.Sprintf( + "export OPENAI_BASE_URL=\"%s/v1\"\nexport OPENAI_API_KEY=\"%s\"", + baseURL, tokenKey), + // 方式二:配置文件 + "config_file": "~/.codex/config.toml", + "config_content": fmt.Sprintf( + "# NewAPI 自动生成配置\nopenai_base_url = \"%s/v1\"\n", baseURL), + // 测试命令 + "test_command": fmt.Sprintf( + "curl %s/v1/models -H \"Authorization: Bearer %s\"", baseURL, tokenKey), + // 使用说明 + "instructions": []string{ + "方式一(推荐):复制下方命令到终端执行,设置环境变量", + "方式二:将配置内容写入 ~/.codex/config.toml", + "设置完成后运行测试命令验证连通性", + }, + } +} + +func generateClaudeCodeConfig(baseURL, tokenKey string) map[string]interface{} { + settingsJSON := map[string]interface{}{ + "env": map[string]string{ + "ANTHROPIC_BASE_URL": baseURL, + "ANTHROPIC_API_KEY": tokenKey, + }, + } + jsonBytes, _ := json.MarshalIndent(settingsJSON, "", " ") + + return map[string]interface{}{ + "tool": "Claude Code", + "env_script": fmt.Sprintf( + "export ANTHROPIC_BASE_URL=\"%s\"\nexport ANTHROPIC_API_KEY=\"%s\"", + baseURL, tokenKey), + "config_file": "~/.claude/settings.json", + "config_content": string(jsonBytes), + "test_command": fmt.Sprintf( + "curl %s/v1/messages -H \"x-api-key: %s\" -H \"anthropic-version: 2023-06-01\" "+ + "-d '{\"model\":\"claude-sonnet-4-20250514\",\"max_tokens\":10,\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}'", + baseURL, tokenKey), + "instructions": []string{ + "方式一(推荐):复制下方命令到终端执行", + "方式二:将 JSON 内容写入 ~/.claude/settings.json", + "注意:设置 ANTHROPIC_BASE_URL 后 MCP 工具搜索默认禁用", + }, + } +} +``` + +--- + +## 前端交互 + +### Token 列表页增加操作菜单 + +``` +Token 列表: +┌────────────────────────────────────────────────────────┐ +│ Token 名称 │ Token Key │ 余额 │ 操作 │ +│──────────────│──────────────────│───────│──────────│ +│ 日常开发 │ sk-xxxx...xxxx │ 50000 │ 📋 🔧 ▼ │ +│ │ │ │ │ +│ 下拉菜单: │ +│ ┌──────────────────────────┐ │ +│ │ 🖥 配置 Codex CLI │ │ +│ │ 🤖 配置 Claude Code │ │ +│ │ 📝 配置 Cursor │ │ +│ │ ⚙️ 通用 OpenAI 配置 │ │ +│ └──────────────────────────┘ │ +└────────────────────────────────────────────────────────┘ +``` + +### 配置弹窗 + +``` +┌─ 配置 Codex CLI ────────────────────────────────┐ +│ │ +│ ⚡ 快速配置(复制到终端执行) │ +│ ┌──────────────────────────────────────────┐ │ +│ │ export OPENAI_BASE_URL="https://api..." │ │ +│ │ export OPENAI_API_KEY="sk-xxxxx" │ │ +│ └──────────────────────────────────────────┘ │ +│ [📋 一键复制] │ +│ │ +│ 📂 配置文件方式 │ +│ 文件路径:~/.codex/config.toml │ +│ ┌──────────────────────────────────────────┐ │ +│ │ openai_base_url = "https://api..." │ │ +│ └──────────────────────────────────────────┘ │ +│ [📋 复制] │ +│ │ +│ 🧪 测试连通性 │ +│ ┌──────────────────────────────────────────┐ │ +│ │ curl https://api.../v1/models ... │ │ +│ └──────────────────────────────────────────┘ │ +│ [📋 复制] │ +│ │ +│ [关闭] │ +└──────────────────────────────────────────────────┘ +``` + +## API 端点 + +``` +GET /api/user/export/config?token_id=xxx&tool=codex -- Codex 配置 +GET /api/user/export/config?token_id=xxx&tool=claudecode -- Claude Code 配置 +GET /api/user/export/config?token_id=xxx&tool=cursor -- Cursor 配置 +GET /api/user/export/config?token_id=xxx&tool=generic -- 通用配置 +``` diff --git a/aidoc/module_7_cleanup.md b/aidoc/module_7_cleanup.md new file mode 100644 index 000000000000..8243cae2e06a --- /dev/null +++ b/aidoc/module_7_cleanup.md @@ -0,0 +1,155 @@ +# 模块七:不活跃账户额度清理 + +## 需求描述 + +> 一周内从没有使用过的且从未充值过用户的额度清理 + +作为**调度任务的内置类型**,管理员在调度管理中配置。 + +--- + +## 清理规则 + +| 条件 | 说明 | +|------|------| +| 最近 N 天无请求记录 | 默认 7 天,管理员可配置 | +| 从未充值 | `is_charged = false` 且无充值记录 | +| 有剩余额度 | `quota > 0`(否则无需清理) | +| 非管理员 | `role < admin` | +| 非白名单用户 | 管理员可设置免清理白名单 | + +--- + +## 后端实现 + +```go +// scheduler/tasks/inactive_cleanup.go + +type InactiveCleanupParams struct { + InactiveDays int `json:"inactive_days"` // 不活跃天数,默认 7 + ExcludeCharged bool `json:"exclude_charged"` // 排除充值用户,默认 true + ExcludeUserIDs []int `json:"exclude_user_ids"` // 白名单用户 + DryRun bool `json:"dry_run"` // 试运行模式(只统计不清理) + NotifyUsers bool `json:"notify_users"` // 是否通知被清理用户 + AlertOnComplete bool `json:"alert_on_complete"` // 完成后告警通知管理员 +} + +func (t *InactiveCleanupTask) Execute(ctx context.Context, params json.RawMessage) (*TaskResult, error) { + var p InactiveCleanupParams + json.Unmarshal(params, &p) + if p.InactiveDays == 0 { + p.InactiveDays = 7 + } + + cutoffTime := time.Now().AddDate(0, 0, -p.InactiveDays) + + // 查询不活跃用户 + // 条件:最后请求时间 < cutoffTime 且从未充值 且有余额 + users, err := model.GetInactiveUsersWithQuota(cutoffTime, p.ExcludeCharged, p.ExcludeUserIDs) + if err != nil { + return nil, err + } + + if p.DryRun { + return &TaskResult{ + Success: true, + Output: fmt.Sprintf("[试运行] 发现 %d 个不活跃用户待清理,总额度: %d", + len(users), sumQuota(users)), + }, nil + } + + // 执行清理 + cleaned := 0 + totalQuota := 0 + for _, user := range users { + totalQuota += user.Quota + + // 记录清理日志(便于追溯) + model.CreateCleanupLog(user.ID, user.Quota, "inactive_cleanup") + + // 清零额度 + model.UpdateUserQuota(user.ID, 0) + cleaned++ + } + + output := fmt.Sprintf("清理完成:%d 个不活跃用户,回收额度 %d", cleaned, totalQuota) + + if p.AlertOnComplete && cleaned > 0 { + SendAlert(nil, fmt.Sprintf("🧹 不活跃账户清理\n清理用户数: %d\n回收额度: %d\n不活跃标准: %d 天", + cleaned, totalQuota, p.InactiveDays)) + } + + return &TaskResult{Success: true, Output: output}, nil +} +``` + +### 查询不活跃用户的 SQL + +```sql +-- 查找不活跃用户 +SELECT u.id, u.username, u.quota, u.is_charged, + MAX(l.created_at) as last_request_time +FROM users u +LEFT JOIN request_logs l ON u.id = l.user_id +WHERE u.quota > 0 -- 有余额 + AND u.role < 10 -- 非管理员 + AND (u.is_charged = 0 OR u.is_charged IS NULL) -- 从未充值 + AND u.id NOT IN (?) -- 排除白名单 +GROUP BY u.id +HAVING last_request_time IS NULL -- 从未使用 + OR last_request_time < ? -- 超过 N 天未使用 +``` + +--- + +## 管理员配置界面 + +在调度模块创建「不活跃账户清理」任务时: + +``` +┌─ 创建清理任务 ──────────────────────────────────┐ +│ │ +│ 任务名称:[不活跃账户额度清理 ] │ +│ 执行频率:[0 3 * * *] (每天凌晨3点) │ +│ │ +│ ── 清理参数 ── │ +│ 不活跃天数: [7 ] 天 ▼ (3/7/14/30) │ +│ [✓] 排除曾经充值的用户 │ +│ [✓] 完成后通知管理员 │ +│ [ ] 通知被清理的用户 │ +│ │ +│ ── 安全选项 ── │ +│ [✓] 首次执行使用试运行模式(只统计不清理) │ +│ 白名单用户:[输入用户ID,逗号分隔] │ +│ │ +│ [取消] [保存并启用] │ +└──────────────────────────────────────────────────┘ +``` + +--- + +## 数据库 + +```sql +-- 额度清理日志(审计追溯) +CREATE TABLE quota_cleanup_logs ( + id INT PRIMARY KEY AUTO_INCREMENT, + user_id INT NOT NULL, + quota_before INT NOT NULL, -- 清理前额度 + cleanup_type VARCHAR(50) NOT NULL, -- 'inactive_cleanup' / 'manual' / 'expired' + task_id INT, -- 关联的调度任务 ID + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + INDEX idx_user (user_id), + INDEX idx_time (created_at) +); +``` + +## 安全机制 + +| 措施 | 说明 | +|------|------| +| 试运行模式 | 首次执行只统计不清理,管理员确认后关闭 | +| 清理日志 | 所有清理操作记录在 `quota_cleanup_logs`,可追溯恢复 | +| 白名单 | 重要用户可加入白名单免清理 | +| 排除充值用户 | 默认排除所有曾充值过的用户 | +| 管理员通知 | 清理完成后推送通知 | diff --git a/aidoc/module_8_extras.md b/aidoc/module_8_extras.md new file mode 100644 index 000000000000..637d970a474c --- /dev/null +++ b/aidoc/module_8_extras.md @@ -0,0 +1,164 @@ +# 模块八:补充建议功能(8-14) + +> 以下功能为建议增加项,可根据优先级选择性开发。 + +--- + +## 8. 渠道健康度自动评分 + +**目的**:与兜底策略联动,低分渠道自动降权或告警。 + +```go +// 健康评分算法 +type ChannelHealthScore struct { + ChannelID int + SuccessRate float64 // 成功率 (0-100) + AvgLatency float64 // 平均延迟 (ms) + ErrorRate float64 // 错误率 + Score int // 综合评分 (0-100) + UpdatedAt time.Time +} + +// 评分公式: +// Score = SuccessRate * 0.5 + (1 - min(AvgLatency/10000, 1)) * 100 * 0.3 + (1 - ErrorRate) * 100 * 0.2 +``` + +作为调度任务 `health_score`,每 10 分钟更新。管理后台展示渠道健康度排行。 + +--- + +## 9. Token 用量日报推送 + +**目的**:每日推送用量摘要,及时发现异常。 + +报告内容: +- 昨日总请求数 / 总消耗额度 +- Top 5 用户用量排行 +- Top 5 模型调用排行 +- 渠道用量分布 +- 与前一天对比的变化趋势 + +作为调度任务 `usage_report`,每天上午 9 点推送到告警通道。 + +--- + +## 10. IP 白名单 / 黑名单 + +```sql +CREATE TABLE ip_rules ( + id INT PRIMARY KEY AUTO_INCREMENT, + ip_pattern VARCHAR(50) NOT NULL, -- 支持 CIDR,如 192.168.1.0/24 + rule_type VARCHAR(10) NOT NULL, -- 'allow' / 'deny' + scope VARCHAR(20) DEFAULT 'global', -- 'global' / 'user:123' + reason VARCHAR(200), + expires_at DATETIME, -- 到期自动失效 + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +``` + +在 Gin 中间件链最前端检查 IP。 + +--- + +## 11. 请求重放 / 调试 + +对指定 Token 或用户启用**完整请求记录**(默认关闭,性能影响大): +- 记录完整的请求体和响应体 +- 支持一键重放 +- 排查问题时临时开启 + +```sql +CREATE TABLE request_recordings ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + user_id INT, + token_id INT, + channel_id INT, + model VARCHAR(100), + request_body MEDIUMTEXT, + response_body MEDIUMTEXT, + status_code INT, + duration_ms INT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +``` + +--- + +## 12. 渠道自动禁用与恢复 + +与渠道健康评分联动: +- 连续失败 N 次(默认 5 次)→ 自动禁用渠道 +- 禁用后,调度任务定期探活(发送测试请求) +- 探活成功 → 自动恢复渠道 +- 所有状态变更记录日志并告警 + +```go +// 在 relay 失败后调用 +func HandleChannelFailure(channelID int) { + key := fmt.Sprintf("channel_failures:%d", channelID) + count := redis.Incr(ctx, key) + redis.Expire(ctx, key, 10*time.Minute) + + if count >= GetAutoDisableThreshold() { + model.DisableChannel(channelID, "连续失败自动禁用") + SendAlert(nil, fmt.Sprintf("⛔ 渠道 #%d 已自动禁用(连续失败 %d 次)", channelID, count)) + } +} +``` + +--- + +## 13. 用户公告系统 + +```sql +CREATE TABLE announcements ( + id INT PRIMARY KEY AUTO_INCREMENT, + title VARCHAR(200) NOT NULL, + content TEXT NOT NULL, + type VARCHAR(20) DEFAULT 'info', -- 'info'/'warning'/'urgent' + target VARCHAR(20) DEFAULT 'all', -- 'all'/'users'/'admins' + pinned TINYINT(1) DEFAULT 0, + published_at DATETIME, + expires_at DATETIME, + created_by INT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +``` + +用户登录面板时顶部展示公告 Banner,支持已读标记和置顶。 + +--- + +## 14. 操作审计日志 + +```sql +CREATE TABLE audit_logs ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + operator_id INT NOT NULL, -- 操作人 + action VARCHAR(100) NOT NULL, -- 'channel.create'/'user.update'/'token.delete' + target_type VARCHAR(50), -- 'channel'/'user'/'token' + target_id INT, + detail JSON, -- 操作详情 + ip_address VARCHAR(50), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + INDEX idx_operator (operator_id), + INDEX idx_action (action), + INDEX idx_time (created_at) +); +``` + +在所有管理端写操作后记录审计日志,支持按操作人/操作类型/时间范围查询。 + +--- + +## 开发优先级建议 + +| 优先级 | 功能 | 理由 | +|--------|------|------| +| 建议第一批 | #12 渠道自动禁用恢复 | 与兜底策略强关联 | +| 建议第一批 | #8 渠道健康评分 | 运维核心指标 | +| 建议第二批 | #9 用量日报 | 运营需要 | +| 建议第二批 | #13 公告系统 | 用户体验 | +| 可选 | #10 IP 控制 | 安全加固 | +| 可选 | #14 审计日志 | 多管理员场景 | +| 可选 | #11 请求重放 | 调试场景 | diff --git "a/aidoc/newapiplus\350\207\252\345\256\232\344\271\211\345\274\200\345\217\221\345\256\236\346\226\275\346\226\271\346\241\210.md" "b/aidoc/newapiplus\350\207\252\345\256\232\344\271\211\345\274\200\345\217\221\345\256\236\346\226\275\346\226\271\346\241\210.md" new file mode 100644 index 000000000000..544127e5f19b --- /dev/null +++ "b/aidoc/newapiplus\350\207\252\345\256\232\344\271\211\345\274\200\345\217\221\345\256\236\346\226\275\346\226\271\346\241\210.md" @@ -0,0 +1,1038 @@ +# newapiplus 自定义开发实施方案 + +## 文档说明 + +本文档是当前 `new-api-plus` 项目的统一落地实施方案,用于直接指导后续开发、联调、测试与上线。 + +它合并了此前的两份文档: + +- `aidoc/implementation_plan_revised.md` +- `aidoc/implementation_playbook.md` + +并吸收了后续评审结论与最终拍板,尤其包括: + +- 时间动态倍率纳入 Sprint A +- 时间动态倍率核心集成点固定在 `relay/helper/price.go` +- 时间动态倍率前端入口放在“运营设置” +- 时间字段改为 `"HH:MM"` 字符串 +- Sprint A 不做“按渠道时间动态倍率” +- 维护模式考虑多实例部署,Redis 优先,数据库兜底 +- 并发限制在 Redis 故障时 fail-open 放行 +- 结构化配置优先采用 `config.GlobalConfig.Register()` + +--- + +## 1. 总体结论 + +当前 `aidoc/` 原始方案有业务价值,但不能按原稿直接开发。 + +原因主要有: + +- 当前项目必须同时兼容 SQLite / MySQL / PostgreSQL +- 当前项目已经存在较完整的 relay 主链路、日志、通知、巡检、自动禁用等能力 +- 现有工程不是空白项目,开发策略必须是“增量增强”,不能平行重做 +- 高风险能力必须分期落地,尤其不能把渠道兜底直接塞进现有重试链路 + +因此本次实施采用以下核心策略: + +1. 先做低风险、高价值、与当前仓库贴合度高的功能 +2. 尽量复用已有配置体系、日志体系、通知体系、后台任务模式 +3. 高风险模块先做最小可上线版本,不做过度设计 +4. 所有设计必须围绕当前真实代码结构展开,而不是围绕理想化架构展开 + +--- + +## 2. 当前仓库约束 + +### 2.1 必须遵守 + +- JSON 编解码统一使用 `common/json.go` +- 数据库必须同时兼容 SQLite / MySQL / PostgreSQL +- 业务层优先使用 GORM,不依赖数据库方言特性 +- 新增结构化配置优先使用 `config.GlobalConfig.Register()` +- 新增复杂结构数据优先存为 `TEXT` 字段中的 JSON 字符串 +- 新模型时间字段优先使用 `int64` Unix 时间戳 + +### 2.2 必须复用的现有能力 + +- Redis:`common.RDB`、`common.RedisEnabled` +- 全局配置:`common.OptionMap`、`config.GlobalConfig` +- 后台任务模式:`main.go` 启动、`service/*task.go`、`sync.Once + atomic.Bool` +- 通知能力:`service.NotifyRootUser`、`service.NotifyUser` +- 日志能力:`model.Log`、`logs.use_time`、`logs.request_id` +- 控制台设置:`console_setting` +- 状态接口:`controller.GetStatus` +- 渠道巡检与自动禁用:现有 `channel` 相关逻辑 + +### 2.3 关键工程原则 + +- 不平行造轮子 +- 不新起独立配置中心 +- 不在 Sprint A 引入复杂通用任务平台 +- 不在 Sprint A 修改高风险重试主链路语义 + +--- + +## 3. 模块结论 + +| 模块 | 结论 | 分期建议 | +|------|------|----------| +| 模块 6 一键导出配置 | 最适合先做 | Sprint A | +| 模块 3 维护模式 V1 | 可做 | Sprint A | +| 时间动态倍率 | 可做,且建议纳入 | Sprint A | +| 模块 1 慢请求监控 | 可做 | Sprint B | +| 模块 7 不活跃账户清理 | 可做 | Sprint B | +| 模块 4 用户并发限制 | 可做 | Sprint C | +| 模块 2 轻量调度管理 | 可做,但要重设计 | Sprint D | +| Token 用量日报 | 可做 | Sprint D | +| 渠道健康评分 | 可做 | Sprint D | +| 模块 5 渠道兜底 | 可做,但风险最高 | Sprint E | + +暂缓: + +- 请求重放 / 调试 +- 完整审计日志平台 +- 完整 IP 白黑名单系统 +- 独立数据库化公告系统 + +--- + +## 4. 总体分期 + +## Sprint A + +- 模块 6:一键导出 Codex / Claude Code 配置 +- 模块 3:维护模式 V1 +- 时间动态倍率 + +## Sprint B + +- 模块 1:慢请求监控 V1 +- 模块 7:不活跃账户清理 V1 + +## Sprint C + +- 模块 4:用户并发限制 + +## Sprint D + +- 模块 2:轻量调度管理 +- Token 用量日报 +- 渠道健康评分 + +## Sprint E + +- 模块 5:渠道兜底 + +--- + +## 5. 统一设计原则 + +### 5.1 结构化配置统一方案 + +新增业务配置统一采用以下模式: + +1. 在 `setting/operation_setting/`、`setting/system_setting/` 或 `setting/ratio_setting/` 下新增配置文件 +2. 定义结构体 +3. 使用 `config.GlobalConfig.Register("xxx_setting", &xxxSetting)` +4. 通过现有持久化链路写入数据库 +5. 前端继续沿用现有设置页接口读写配置 + +适合采用这种方式的配置包括: + +- 时间动态倍率 +- 并发限制配置 +- 维护模式配置 +- 慢请求监控配置 + +### 5.2 实时状态与持久状态分离 + +对于有“配置状态”和“实时生效状态”之分的能力,采用双层设计: + +- 持久层:数据库配置 +- 实时层:Redis + +适用模块: + +- 维护模式 +- 并发限制 +- 后续慢请求实时采样 + +### 5.3 Redis 故障策略 + +所有依赖 Redis 的保护性能力必须遵守: + +- Redis 正常时按设计执行 +- Redis 异常时优先保证主链路可用 + +具体要求: + +- 并发限制:fail-open 放行 +- 维护模式:回退数据库配置 +- 慢请求监控:允许降级为日志聚合或临时不做实时统计 + +### 5.4 后台任务统一模式 + +延续当前项目已有模式: + +- 在 `main.go` 中注册并启动 +- 在 `service/` 中实现单次执行函数和循环函数 +- 用 `sync.Once + atomic.Bool` 防止重复启动 +- 只在 `common.IsMasterNode` 上运行 + +### 5.5 告警与通知复用现有体系 + +一期不新建 `notification_channels` 配置中心。 + +统一复用: + +- `NotifyRootUser` +- `NotifyUser` + +并沿用现有用户通知方式: + +- email +- webhook +- bark +- gotify + +--- + +## 6. Sprint A 详细实施 + +## 6.1 模块 6:一键导出 Codex / Claude Code 配置 + +### 目标 + +在用户 Token 管理界面,为每个 Token 提供“导出接入配置”的能力。 + +支持: + +- Codex +- Claude Code +- Cursor +- Continue + +不做: + +- 自动写入用户本地文件 +- 自动安装 CLI +- 下载脚本执行 + +### 路由设计 + +新增接口: + +- `GET /api/token/:id/export?tool=codex` +- `GET /api/token/:id/export?tool=claude_code` +- `GET /api/token/:id/export?tool=cursor` +- `GET /api/token/:id/export?tool=continue` + +鉴权要求: + +- 走 `middleware.UserAuth()` +- 仅允许当前用户访问自己的 token + +### 返回结构建议 + +```json +{ + "tool": "codex", + "display_name": "Codex", + "env_script": "export ...", + "config_file": "~/.codex/config.toml", + "config_content": "...", + "test_command": "curl ...", + "notes": [ + "说明1", + "说明2" + ] +} +``` + +### 后端文件落点 + +新增: + +- `controller/token_export.go` +- `service/token_export.go` 可选 + +修改: + +- `router/api-router.go` + +复用: + +- `model.GetTokenByIds` +- `system_setting.ServerAddress` +- token 权限校验逻辑 + +### 实现要点 + +- 服务地址优先从 `system_setting.ServerAddress` 获取 +- 若为空,可回退为当前请求地址推导,但只作为兜底 +- 返回真实 token key,不使用掩码 +- 不同工具生成不同格式片段 + +### 前端落点 + +- `web/src/components/table/tokens/TokensColumnDefs.jsx` +- `web/src/components/table/tokens/modals/TokenExportConfigModal.jsx` +- `web/src/hooks/tokens/useTokensData.jsx` 视情况改动 +- `web/src/i18n/locales/*.json` + +### 验收标准 + +- 用户可以在 token 列表中直接打开导出弹窗 +- 配置片段可复制 +- 工具类型和内容对应正确 +- 不会泄露其他用户 token + +--- + +## 6.2 模块 3:维护模式 V1 + +### 目标 + +提供可控的全站维护能力,支持: + +- 即时开启维护 +- 即时关闭维护 +- 维护预告 +- 多实例部署实时生效 +- 管理员放行 + +### 一期范围 + +Sprint A 只做 V1: + +- 单一当前维护状态 +- 单一维护预告信息 +- 不做复杂排期系统 + +### 配置结构 + +新增: + +- `setting/system_setting/maintenance.go` + +配置建议: + +```go +type MaintenanceSetting struct { + Enabled bool `json:"enabled"` + Title string `json:"title"` + Content string `json:"content"` + StartAt int64 `json:"start_at"` + EndAt int64 `json:"end_at"` + AllowAdminAccess bool `json:"allow_admin_access"` + BannerEnabled bool `json:"banner_enabled"` + BannerTitle string `json:"banner_title"` + BannerContent string `json:"banner_content"` +} +``` + +注册: + +- `config.GlobalConfig.Register("maintenance_setting", &maintenanceSetting)` + +### 多实例设计 + +采用“双层状态”: + +- 数据库持久配置作为基线 +- Redis 作为实时同步层 + +推荐逻辑: + +1. 管理员修改维护配置 +2. 先写数据库 +3. Redis 可用时同步写 Redis +4. 读取时优先 Redis +5. Redis 不可用时回退数据库 + +这样可以兼顾: + +- 多实例快速生效 +- Redis 异常时仍可工作 + +### 中间件设计 + +新增: + +- `middleware/maintenance.go` + +挂载建议: + +- 挂在 relay 入口前 +- 挂在关键 API 前 +- 对管理员请求按配置放行 +- 对登录页、状态页、必要静态资源保留白名单 + +### API 设计 + +建议新增: + +- `GET /api/maintenance` +- `PUT /api/maintenance` + +前台状态联动: + +- 在 `GetStatus` 返回当前维护状态或预告信息 + +### 前端落点 + +- `web/src/pages/Setting/Operation/SettingsMaintenance.jsx` +- `web/src/components/settings/OperationSetting.jsx` +- 仪表盘或全局通知展示组件 +- `web/src/i18n/locales/*.json` + +### 验收标准 + +- 维护开关能立即生效 +- 多实例环境能通过 Redis 快速同步 +- Redis 异常时可回退数据库读取 +- 管理员可按配置放行 +- 前端能展示维护预告 + +--- + +## 6.3 时间动态倍率 + +### 目标 + +支持按时间段动态调整计费倍率,用于: + +- 高峰期涨价 +- 低峰期促销 +- 临时活动策略 + +### Sprint A 范围 + +Sprint A 只做: + +- 全局倍率规则 +- 按用户组倍率规则 +- 按模型倍率规则 +- 星期 + 时间区间匹配 + +Sprint A 不做: + +- 按渠道时间动态倍率 +- 节假日规则 +- 日期范围规则 +- 多层复杂优先级系统 +- 与营销系统联动 + +### 为什么 Sprint A 不做按渠道 + +按渠道时间动态倍率技术上可行,但不建议放进 Sprint A。 + +原因: + +- 当前渠道在定价前已经选出,理论上可以拿到 `channel_id` +- 但 relay 失败重试时可能切换渠道 +- 如果倍率绑定渠道,会出现“预扣按原渠道、结算按重试渠道”的计费口径复杂度 +- 这类能力应该与重试重算、补差、日志展示一起设计,放入后续增强版本更稳妥 + +### 配置结构 + +新增: + +- `setting/operation_setting/time_dynamic_ratio.go` + +建议结构: + +```go +type TimeDynamicRatioSetting struct { + Enabled bool `json:"enabled"` + Rules []TimeDynamicRatioRule `json:"rules"` +} + +type TimeDynamicRatioRule struct { + Name string `json:"name"` + Enabled bool `json:"enabled"` + StartTime string `json:"start_time"` // HH:MM + EndTime string `json:"end_time"` // HH:MM + Weekdays []int `json:"weekdays"` + Groups []string `json:"groups"` + Models []string `json:"models"` + Multiplier float64 `json:"multiplier"` +} +``` + +注册: + +- `config.GlobalConfig.Register("time_dynamic_ratio_setting", &timeDynamicRatioSetting)` + +### 匹配策略 + +建议优先级: + +1. 模型 + 分组同时匹配 +2. 仅模型匹配 +3. 仅分组匹配 +4. 全局匹配 + +一期策略: + +- 命中第一条即生效 + +### 核心集成点 + +时间动态倍率的核心集成点固定为: + +- `relay/helper/price.go` +- `ModelPriceHelper()` + +原因: + +- 这里是当前价格计算的统一入口 +- `PriceData` 会在这里统一产出 +- `PriceData.OtherRatios` 已被现有下游结算链路消费 + +### 具体实现方式 + +推荐做法: + +1. 在 `ModelPriceHelper()` 中解析当前命中的时间动态倍率规则 +2. 计算倍率 +3. 将倍率写入 `PriceData.OtherRatios["time_dynamic_multiplier"]` +4. 让下游文本、任务等计费路径自动复用现有 `OtherRatios` 生效 + +不建议 Sprint A 将该逻辑分散写入: + +- `service/text_quota.go` +- `service/quota.go` + +### 生效原则 + +V1 只影响最终计费倍率。 + +不影响: + +- 模型发现 +- 模型可用性 +- 模型公开倍率同步接口 +- 渠道路由选择 + +### 前端位置 + +前端入口明确放在: + +- `web/src/components/settings/OperationSetting.jsx` +- `web/src/pages/Setting/Operation/SettingsTimeDynamicRatio.jsx` + +按当前产品归类,放在“运营设置”,不放到“分组与模型定价设置”。 + +### 测试重点 + +- 指定时间命中规则 +- 跨午夜区间匹配 +- 全局 / 分组 / 模型规则匹配 +- 未命中时倍率为 1 +- `ModelPriceHelper()` 注入后文本与任务链路自动生效 +- 重试切换渠道时不会引入“按渠道倍率差异” + +### 验收标准 + +- 在配置时间窗口内计费结果按预期变化 +- 日志中可看到动态倍率相关信息 +- 未命中时不影响现有计费结果 + +--- + +## 7. Sprint B 实施 + +## 7.1 模块 1:慢请求监控 V1 + +### 目标 + +基于现有 `logs` 表聚合慢请求,不增加主链路写入复杂度。 + +### 核心方案 + +- 使用后台固定循环任务 +- 默认每 3 分钟执行一次 +- 统计最近 5 分钟窗口 +- 从 `logs` 表按 `use_time` 聚合 +- 达到阈值后通知管理员 + +### 配置建议 + +新增: + +- `setting/operation_setting/slow_request_setting.go` + +字段建议: + +- `enabled` +- `threshold_seconds` +- `window_minutes` +- `alert_count` +- `cooldown_minutes` +- `notify_admin_only` + +### 文件落点 + +- `service/slow_request_monitor_task.go` +- `setting/operation_setting/slow_request_setting.go` +- `controller/slow_request.go` 可选 + +### 注意点 + +- V1 不强依赖 Redis ZSet +- 优先复用 `logs` +- 冷却锁优先存 Redis +- Redis 异常时允许降级 + +--- + +## 7.2 模块 7:不活跃账户清理 V1 + +### 目标 + +清理长期不活跃且无充值 / 无订阅历史的用户额度。 + +### 判断口径 + +不活跃: + +- 最近 N 天在 `logs` 中没有成功消费或错误请求 + +无充值: + +- `top_ups` 无成功充值记录 + +无订阅: + +- `subscription_orders` 无有效记录 + +### 实现方式 + +- 通过后台任务定期扫描 +- 先生成清理目标 +- 默认提供 dry-run +- 实际清理前记录结果日志 + +### 安全机制 + +- 首版必须支持 dry-run +- 建议先只处理长时间不活跃用户 +- 结果通知管理员 + +--- + +## 8. Sprint C 实施 + +## 8.1 模块 4:用户并发限制 + +### 目标 + +限制用户并发中的 relay 请求数量,降低滥用与资源争抢。 + +### 挂载位置 + +建议挂在: + +- 鉴权之后 +- 渠道分发之前 +- 进入实际 relay 之前 + +原因: + +- 这时已能拿到用户身份 +- 尚未进入上游调用 +- 失败时不需要回滚上游状态 + +### 实现方式 + +优先采用 Redis 原子计数: + +- 请求进入时 `INCR` +- 请求结束时 `DECR` +- 配合 TTL 兜底防止异常泄漏 + +### Redis 故障策略 + +必须明确: + +- Redis 正常时按限制执行 +- Redis 故障时 fail-open 放行 + +不能因为 Redis 宕机导致全站请求被拒。 + +### 配置建议 + +新增: + +- `setting/operation_setting/concurrency_setting.go` + +字段建议: + +- `enabled` +- `free_default` +- `paid_default` +- `group_defaults` +- `redis_fail_open` + +### 可选数据模型 + +若需要用户级覆盖,可在后续加入: + +- `model/user_concurrency_override.go` + +但 Sprint C 可先不引入新表。 + +--- + +## 9. Sprint D 实施 + +## 9.1 模块 2:轻量调度管理 + +### 原则 + +不做“任意任务 + 任意 JSON Schema + 任意执行器”的通用调度平台。 + +Sprint D 只做轻量持久化任务。 + +### 支持的任务类型 + +- `slow_request_check` +- `inactive_cleanup` +- `usage_report` +- `log_cleanup` + +### 推荐模型 + +建议新增: + +- `ScheduledTask` +- `ScheduledTaskExecution` + +其中 `Params` 使用 `TEXT` 存 JSON 字符串。 + +### 文件落点 + +- `model/scheduled_task.go` +- `service/scheduled_task_runner.go` +- `service/scheduled_task_handlers.go` +- `controller/scheduled_task.go` + +### 路由建议 + +- `GET /api/scheduled-task` +- `POST /api/scheduled-task` +- `PUT /api/scheduled-task/:id` +- `POST /api/scheduled-task/:id/run` +- `POST /api/scheduled-task/:id/toggle` + +--- + +## 9.2 渠道健康评分 + +### 目标 + +基于现有日志和巡检结果,对渠道给出健康评分。 + +### 数据来源 + +- 请求成功率 +- 平均耗时 +- 错误率 +- 自动禁用记录 +- 巡检结果 + +### 输出 + +- 渠道总分 +- Top 渠道 +- 差评渠道 +- 建议是否降权 / 禁用 + +--- + +## 9.3 Token 用量日报 + +### 目标 + +按日给管理员输出 token 用量摘要。 + +### 数据来源 + +- `logs` +- 充值记录 +- 订阅消耗记录 + +### 输出内容 + +- 总请求数 +- 总 token / quota 消耗 +- 热门模型 +- 热门分组 +- 异常高消耗用户 + +--- + +## 10. Sprint E 实施 + +## 10.1 模块 5:渠道兜底 + +### 这是最高风险模块 + +该模块必须放到最后做。 + +原因: + +- 当前 relay 主链路已包含分发、预扣费、退款、重试、流式处理 +- 兜底能力会直接影响重试语义 +- 若设计不当,容易引入重复扣费、错误退款、流式异常、中间状态不一致 + +### 实施原则 + +1. 不重写现有分发逻辑 +2. 在现有重试链路上增强“候选渠道重试能力” +3. 每次切换渠道时都必须同步上下文 +4. 保证计费口径、日志口径、错误口径一致 + +### 先决条件 + +在做渠道兜底前,建议先完成: + +- 渠道健康评分 +- 更稳定的自动禁用 / 恢复 +- 更清晰的重试日志 + +### 可选数据模型 + +- `ChannelFallbackRule` + +但不建议在 Sprint A~D 提前引入复杂兜底表设计。 + +--- + +## 11. 数据模型总清单 + +按阶段建议如下: + +### Sprint A + +不建议新增业务表,以配置为主。 + +### Sprint B + +可不新增表。 + +### Sprint C + +可选: + +- `UserConcurrencyOverride` + +### Sprint D + +建议新增: + +- `ScheduledTask` +- `ScheduledTaskExecution` + +### Sprint E + +可选: + +- `ChannelFallbackRule` + +### 其他可选模型 + +若需要保留清理记录,可增加: + +- `QuotaCleanupLog` + +--- + +## 12. 路由与文件修改清单 + +## Sprint A + +### 后端新增 + +- `controller/token_export.go` +- `middleware/maintenance.go` +- `service/maintenance_state.go` +- `setting/system_setting/maintenance.go` +- `setting/operation_setting/time_dynamic_ratio.go` + +### 后端修改 + +- `router/api-router.go` +- `router/relay-router.go` +- `router/video-router.go` +- `controller/misc.go` +- `relay/helper/price.go` + +### 前端新增 + +- `web/src/components/table/tokens/modals/TokenExportConfigModal.jsx` +- `web/src/pages/Setting/Operation/SettingsMaintenance.jsx` +- `web/src/pages/Setting/Operation/SettingsTimeDynamicRatio.jsx` + +### 前端修改 + +- `web/src/components/table/tokens/TokensColumnDefs.jsx` +- `web/src/components/settings/OperationSetting.jsx` +- 仪表盘或全局通知展示组件 +- `web/src/i18n/locales/*.json` + +## Sprint B + +### 后端新增 / 修改 + +- `service/slow_request_monitor_task.go` +- `setting/operation_setting/slow_request_setting.go` +- 不活跃账户清理相关 service + +## Sprint C + +### 后端新增 / 修改 + +- 并发限制中间件 +- `setting/operation_setting/concurrency_setting.go` + +## Sprint D + +### 后端新增 / 修改 + +- `model/scheduled_task.go` +- `service/scheduled_task_runner.go` +- `service/scheduled_task_handlers.go` +- `controller/scheduled_task.go` + +## Sprint E + +### 后端新增 / 修改 + +- 兜底规则与重试增强相关代码 + +--- + +## 13. 测试策略 + +### 13.1 单元测试 + +重点覆盖: + +- 导出配置生成逻辑 +- 维护状态判定逻辑 +- Redis 失效回退逻辑 +- 时间动态倍率匹配与 `ModelPriceHelper()` 注入逻辑 +- 慢请求聚合逻辑 +- 清理目标筛选逻辑 +- 并发限制 Redis fail-open + +### 13.2 集成测试 + +重点覆盖: + +- `GET /api/token/:id/export` +- `GET /api/maintenance` +- `PUT /api/maintenance` +- 维护期开启后 relay 返回维护响应 +- `GetStatus` 返回维护信息 +- 时间动态倍率影响实际计费 + +### 13.3 手工验证 + +重点验证: + +- 多实例维护模式同步 +- 维护预告展示 +- Token 导出内容复制与使用 +- 时间动态倍率跨午夜规则 +- Redis 宕机时并发限制降级 + +--- + +## 14. 上线策略 + +### 14.1 默认值策略 + +新增能力上线时默认应尽量“关闭”或“保守”: + +- 维护模式默认关闭 +- 时间动态倍率默认关闭 +- 慢请求监控默认低频执行 +- 并发限制默认关闭或设置宽松值 + +### 14.2 上线顺序 + +建议严格按以下顺序: + +1. Sprint A +2. Sprint A 验收通过后再做 Sprint B +3. Sprint B 稳定后再做 Sprint C +4. Sprint C 稳定后再做 Sprint D +5. 最后单独推进 Sprint E + +### 14.3 风险控制 + +- 高风险能力独立发布 +- 对保护性能力设置 Redis 降级策略 +- 不在同一版本中同时改大量 relay 关键路径 +- 对维护模式、并发限制、时间倍率提供可快速关闭的配置开关 + +--- + +## 15. 各 Sprint 验收清单 + +## Sprint A 验收 + +- Token 导出配置功能可用 +- 维护模式能即时启停 +- 多实例维护模式可通过 Redis 快速同步 +- Redis 异常时维护模式可回退数据库 +- 时间动态倍率能对计费结果生效 +- 时间动态倍率放在运营设置中可配置 + +## Sprint B 验收 + +- 能按窗口识别慢请求 +- 告警可发送给管理员 +- 不活跃用户筛选口径正确 + +## Sprint C 验收 + +- 并发限制能拦截超限请求 +- 异常退出不长期泄漏计数 +- Redis 异常时可降级放行 + +## Sprint D 验收 + +- 可创建、编辑、启停、手动执行轻量任务 +- 可生成 token 用量日报 +- 可展示渠道健康评分 + +## Sprint E 验收 + +- 主渠道失败时可切换备用渠道 +- 不出现重复扣费或退款异常 +- 流式与非流式链路都可稳定工作 + +--- + +## 16. 最终开发建议 + +建议按以下顺序直接开工: + +1. 模块 6:一键导出配置 +2. 模块 3:维护模式 V1 +3. 时间动态倍率 + +其中时间动态倍率的最终落地结论已经明确: + +- 放在“运营设置” +- 核心集成点在 `relay/helper/price.go` +- 用 `PriceData.OtherRatios` 一次注入 +- Sprint A 不做按渠道规则 +- 时间区间字段使用 `"HH:MM"` + +如果后续要继续推进编码,当前这份文档已经可以作为唯一开发依据使用。 diff --git a/controller/maintenance.go b/controller/maintenance.go new file mode 100644 index 000000000000..a0e239cc3109 --- /dev/null +++ b/controller/maintenance.go @@ -0,0 +1,175 @@ +package controller + +import ( + "net/http" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/middleware" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/setting/config" + "github.com/QuantumNous/new-api/setting/system_setting" + + "github.com/gin-gonic/gin" +) + +// GetMaintenanceStatus 获取当前维护配置 +func GetMaintenanceStatus(c *gin.Context) { + setting := system_setting.GetMaintenanceSetting() + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": setting, + }) +} + +// UpdateMaintenanceRequest 更新维护配置请求体 +type UpdateMaintenanceRequest struct { + Enabled bool `json:"enabled"` + Title string `json:"title"` + Message string `json:"message"` + NoticeEnabled bool `json:"notice_enabled"` + NoticeStartAt int64 `json:"notice_start_at"` + StartAt int64 `json:"start_at"` + EndAt int64 `json:"end_at"` + WhitelistUserIds string `json:"whitelist_user_ids"` + AllowAdminPass bool `json:"allow_admin_pass"` +} + +// UpdateMaintenanceStatus 更新维护配置 +func UpdateMaintenanceStatus(c *gin.Context) { + var req UpdateMaintenanceRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无效的请求参数: " + err.Error(), + }) + return + } + + // 构造新配置 + newSetting := system_setting.MaintenanceSetting{ + Enabled: req.Enabled, + Title: req.Title, + Message: req.Message, + NoticeEnabled: req.NoticeEnabled, + NoticeStartAt: req.NoticeStartAt, + StartAt: req.StartAt, + EndAt: req.EndAt, + WhitelistUserIds: req.WhitelistUserIds, + AllowAdminPass: req.AllowAdminPass, + } + + // 默认值处理 + if newSetting.WhitelistUserIds == "" { + newSetting.WhitelistUserIds = "[]" + } + + // 更新内存配置 + system_setting.UpdateMaintenanceSetting(newSetting) + + // 持久化到数据库 + if err := saveMaintenanceToDb(); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "保存配置失败: " + err.Error(), + }) + return + } + + // 同步到 Redis + if err := middleware.SetMaintenanceToRedis(&newSetting); err != nil { + // Redis 写入失败不影响主流程,只记录日志 + common.SysError("同步维护状态到 Redis 失败: " + err.Error()) + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "维护配置已更新", + }) +} + +// DisableMaintenance 快速关闭维护模式 +func DisableMaintenance(c *gin.Context) { + setting := system_setting.GetMaintenanceSetting() + setting.Enabled = false + setting.NoticeEnabled = false + system_setting.UpdateMaintenanceSetting(*setting) + + // 持久化到数据库 + if err := saveMaintenanceToDb(); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "保存配置失败: " + err.Error(), + }) + return + } + + // 同步到 Redis + if err := middleware.SetMaintenanceToRedis(setting); err != nil { + common.SysError("同步维护状态到 Redis 失败: " + err.Error()) + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "维护模式已关闭", + }) +} + +// saveMaintenanceToDb 将维护配置持久化到数据库 +func saveMaintenanceToDb() error { + return config.GlobalConfig.SaveToDB(func(key, value string) error { + // 只保存 maintenance_setting 前缀的配置 + if len(key) > 21 && key[:21] == "maintenance_setting." { + return model.UpdateOption(key, value) + } + return nil + }) +} + +// checkMaintenanceLoginAllowed 检查维护期间是否允许该用户登录 +// 返回 true 表示允许登录,false 表示已拒绝(已写入 HTTP 响应) +func checkMaintenanceLoginAllowed(user *model.User, c *gin.Context) bool { + setting := system_setting.GetMaintenanceSetting() + if !setting.Enabled { + return true + } + + // 检查维护时间窗口 + now := time.Now().Unix() + if setting.StartAt > 0 && now < setting.StartAt { + return true // 维护尚未开始 + } + if setting.EndAt > 0 && now > setting.EndAt { + return true // 维护已结束 + } + + // root 用户始终放行 + if user.Role >= common.RoleRootUser { + return true + } + + // admin 用户根据配置放行 + if user.Role >= common.RoleAdminUser && setting.AllowAdminPass { + return true + } + + // 白名单用户放行 + whitelistIds := system_setting.GetWhitelistUserIds() + for _, wid := range whitelistIds { + if wid == user.Id { + return true + } + } + + // 拒绝登录 + c.JSON(http.StatusServiceUnavailable, gin.H{ + "success": false, + "message": setting.Message, + "data": gin.H{ + "title": setting.Title, + "end_at": setting.EndAt, + }, + }) + return false +} diff --git a/controller/misc.go b/controller/misc.go index 519caed57b81..75fab6f99ff0 100644 --- a/controller/misc.go +++ b/controller/misc.go @@ -117,6 +117,7 @@ func GetStatus(c *gin.Context) { "user_agreement_enabled": legalSetting.UserAgreement != "", "privacy_policy_enabled": legalSetting.PrivacyPolicy != "", "checkin_enabled": operation_setting.GetCheckinSetting().Enabled, + "maintenance": system_setting.GetMaintenancePublicInfo(), } // 根据启用状态注入可选内容 diff --git a/controller/relay.go b/controller/relay.go index 10dfd502fbd0..67863138f5e7 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -155,6 +155,11 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) { return } + // 时间动态倍率响应 Header(仅在命中规则时返回) + if priceData.TimeDynamicMultiplier != 0 && priceData.TimeDynamicMultiplier != 1.0 { + c.Header("X-New-Api-Time-Dynamic-Multiplier", fmt.Sprintf("%.4f", priceData.TimeDynamicMultiplier)) + } + // common.SetContextKey(c, constant.ContextKeyTokenCountMeta, meta) if priceData.FreeModel { @@ -576,12 +581,13 @@ func RelayTask(c *gin.Context) { task.PrivateData.SubscriptionId = relayInfo.SubscriptionId task.PrivateData.TokenId = relayInfo.TokenId task.PrivateData.BillingContext = &model.TaskBillingContext{ - ModelPrice: relayInfo.PriceData.ModelPrice, - GroupRatio: relayInfo.PriceData.GroupRatioInfo.GroupRatio, - ModelRatio: relayInfo.PriceData.ModelRatio, - OtherRatios: relayInfo.PriceData.OtherRatios, - OriginModelName: relayInfo.OriginModelName, - PerCallBilling: common.StringsContains(constant.TaskPricePatches, relayInfo.OriginModelName), + ModelPrice: relayInfo.PriceData.ModelPrice, + GroupRatio: relayInfo.PriceData.GroupRatioInfo.GroupRatio, + ModelRatio: relayInfo.PriceData.ModelRatio, + OtherRatios: relayInfo.PriceData.OtherRatios, + TimeDynamicMultiplier: relayInfo.PriceData.TimeDynamicMultiplier, + OriginModelName: relayInfo.OriginModelName, + PerCallBilling: common.StringsContains(constant.TaskPricePatches, relayInfo.OriginModelName), } task.Quota = result.Quota task.Data = result.TaskData diff --git a/controller/time_dynamic_ratio.go b/controller/time_dynamic_ratio.go new file mode 100644 index 000000000000..52c06e0b7ea0 --- /dev/null +++ b/controller/time_dynamic_ratio.go @@ -0,0 +1,77 @@ +package controller + +import ( + "encoding/json" + "net/http" + "strconv" + + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/setting/operation_setting" + "github.com/gin-gonic/gin" +) + +// GetTimeDynamicRatio 获取时间动态倍率配置 +func GetTimeDynamicRatio(c *gin.Context) { + setting := operation_setting.GetTimeDynamicRatioSetting() + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": setting, + }) +} + +// UpdateTimeDynamicRatio 更新时间动态倍率配置 +func UpdateTimeDynamicRatio(c *gin.Context) { + var req operation_setting.TimeDynamicRatioSetting + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "请求参数无效: " + err.Error(), + }) + return + } + + // 校验规则合法性 + if errMsg := operation_setting.ValidateTimeDynamicRatioRules(req.Rules); errMsg != "" { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": errMsg, + }) + return + } + + // 使用 ConfigManager 的前缀格式保存 (time_dynamic_ratio_setting.enabled / time_dynamic_ratio_setting.rules) + // 这样 loadOptionsFromDatabase → handleConfigUpdate 可以正确识别并反序列化到内存结构 + enabledStr := strconv.FormatBool(req.Enabled) + rulesBytes, err := json.Marshal(req.Rules) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "序列化规则失败: " + err.Error(), + }) + return + } + + err = model.UpdateOption("time_dynamic_ratio_setting.enabled", enabledStr) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "保存全局开关失败: " + err.Error(), + }) + return + } + + err = model.UpdateOption("time_dynamic_ratio_setting.rules", string(rulesBytes)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "保存规则失败: " + err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "保存成功", + }) +} diff --git a/controller/token_export.go b/controller/token_export.go new file mode 100644 index 000000000000..077bb57c62e6 --- /dev/null +++ b/controller/token_export.go @@ -0,0 +1,160 @@ +package controller + +import ( + "fmt" + "strconv" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/setting/system_setting" + + "github.com/gin-gonic/gin" +) + +type tokenExportResponse struct { + Tool string `json:"tool"` + DisplayName string `json:"display_name"` + EnvScript string `json:"env_script"` + ConfigFile string `json:"config_file"` + ConfigContent string `json:"config_content"` + TestCommand string `json:"test_command"` + Notes []string `json:"notes"` +} + +func ExportTokenConfig(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + userId := c.GetInt("id") + if err != nil { + common.ApiError(c, err) + return + } + + token, err := model.GetTokenByIds(id, userId) + if err != nil { + common.ApiError(c, err) + return + } + + baseURL := getTokenExportBaseURL(c) + tool := strings.TrimSpace(strings.ToLower(c.Query("tool"))) + if tool == "" { + common.ApiErrorMsg(c, "tool 参数不能为空") + return + } + + payload, err := buildTokenExportResponse(tool, baseURL, token.GetFullKey()) + if err != nil { + common.ApiError(c, err) + return + } + + common.ApiSuccess(c, payload) +} + +func getTokenExportBaseURL(c *gin.Context) string { + serverAddress := strings.TrimSpace(system_setting.ServerAddress) + if serverAddress != "" { + return strings.TrimRight(serverAddress, "/") + } + + scheme := "http" + if c.Request != nil && c.Request.TLS != nil { + scheme = "https" + } + if forwardedProto := strings.TrimSpace(c.GetHeader("X-Forwarded-Proto")); forwardedProto != "" { + scheme = strings.ToLower(strings.Split(forwardedProto, ",")[0]) + } + + host := "" + if c.Request != nil { + host = strings.TrimSpace(c.Request.Host) + } + if forwardedHost := strings.TrimSpace(c.GetHeader("X-Forwarded-Host")); forwardedHost != "" { + host = strings.TrimSpace(strings.Split(forwardedHost, ",")[0]) + } + if host == "" { + return "" + } + return fmt.Sprintf("%s://%s", scheme, host) +} + +func buildTokenExportResponse(tool string, baseURL string, tokenKey string) (*tokenExportResponse, error) { + baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/") + if baseURL == "" { + return nil, fmt.Errorf("无法确定服务地址,请先配置 server_address") + } + openAIBaseURL := joinURL(baseURL, "/v1") + anthropicBaseURL := joinURL(baseURL, "/anthropic") + quotedToken := shellQuote(tokenKey) + + switch tool { + case "codex": + return &tokenExportResponse{ + Tool: "codex", + DisplayName: "Codex", + EnvScript: fmt.Sprintf( + "export OPENAI_BASE_URL=%s\nexport OPENAI_API_KEY=%s", + shellQuote(openAIBaseURL), + quotedToken, + ), + ConfigFile: ".codex/config.toml", + ConfigContent: fmt.Sprintf( + "model_provider = \"openai\"\nmodel = \"gpt-4.1\"\n\n[model_providers.openai]\nname = \"OpenAI Compatible\"\nbase_url = \"%s\"\nenv_key = \"OPENAI_API_KEY\"\n", + openAIBaseURL, + ), + TestCommand: fmt.Sprintf( + "curl %s -H \"Authorization: Bearer %s\"", + shellQuote(joinURL(openAIBaseURL, "/models")), + tokenKey, + ), + Notes: []string{ + "将环境变量复制到终端后即可让 Codex CLI 通过当前站点访问模型。", + "如果使用配置文件方式,请把 OPENAI_API_KEY 保留在环境变量中。", + }, + }, nil + case "claude_code": + return &tokenExportResponse{ + Tool: "claude_code", + DisplayName: "Claude Code", + EnvScript: fmt.Sprintf( + "export ANTHROPIC_BASE_URL=%s\nexport ANTHROPIC_AUTH_TOKEN=%s", + shellQuote(anthropicBaseURL), + quotedToken, + ), + ConfigFile: ".claude/settings.json", + ConfigContent: fmt.Sprintf( + "{\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"%s\",\n \"ANTHROPIC_AUTH_TOKEN\": \"%s\"\n }\n}\n", + anthropicBaseURL, + tokenKey, + ), + TestCommand: fmt.Sprintf( + "curl %s -H \"Authorization: Bearer %s\" -H \"Content-Type: application/json\" -d '{\"model\":\"claude-3-5-sonnet-20241022\",\"max_tokens\":16,\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}'", + shellQuote(joinURL(anthropicBaseURL, "/v1/messages")), + tokenKey, + ), + Notes: []string{ + "Claude Code 通过 Anthropic 兼容网关访问时,需要使用 ANTHROPIC_BASE_URL 和认证令牌。", + "如果你的本地环境已有同名变量,请先确认是否会覆盖现有配置。", + }, + }, nil + default: + return nil, fmt.Errorf("不支持的导出工具: %s", tool) + } +} + +func joinURL(baseURL string, path string) string { + baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/") + path = strings.TrimSpace(path) + if path == "" { + return baseURL + } + if baseURL == "" { + return path + } + return baseURL + "/" + strings.TrimLeft(path, "/") +} + +func shellQuote(value string) string { + return strconv.Quote(value) +} diff --git a/controller/user.go b/controller/user.go index 8229d0d2c2bc..283c0990b6c9 100644 --- a/controller/user.go +++ b/controller/user.go @@ -59,6 +59,11 @@ func Login(c *gin.Context) { return } + // 维护模式检查:密码验证通过后,根据用户角色决定是否允许登录 + if !checkMaintenanceLoginAllowed(&user, c) { + return + } + // 检查是否启用2FA if model.IsTwoFAEnabled(user.Id) { // 设置pending session,等待2FA验证 diff --git a/electron/build.sh b/electron/build.sh old mode 100755 new mode 100644 diff --git a/middleware/maintenance.go b/middleware/maintenance.go new file mode 100644 index 000000000000..4f4ef0c42125 --- /dev/null +++ b/middleware/maintenance.go @@ -0,0 +1,160 @@ +package middleware + +import ( + "context" + "encoding/json" + "net/http" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/logger" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/setting/system_setting" + + "github.com/gin-gonic/gin" +) + +const maintenanceRedisKey = "maintenance:current" +const maintenanceRedisTTL = 5 * time.Minute + +// MaintenanceCheck 维护模式检查中间件 +// 在 relay 和核心 API 路由上挂载,用于拦截维护期间的用户请求 +func MaintenanceCheck() gin.HandlerFunc { + return func(c *gin.Context) { + setting := getMaintenanceState() + + // 未启用维护模式,直接放行 + if !setting.Enabled { + // 检查是否在预告期,如果是则注入 header + if setting.NoticeEnabled { + now := time.Now().Unix() + if setting.NoticeStartAt > 0 && now >= setting.NoticeStartAt { + c.Header("X-Maintenance-Notice", "true") + } + } + c.Next() + return + } + + now := time.Now().Unix() + + // 维护尚未开始(还在预告期) + if setting.StartAt > 0 && now < setting.StartAt { + c.Header("X-Maintenance-Notice", "true") + c.Next() + return + } + + // 维护已结束 + if setting.EndAt > 0 && now > setting.EndAt { + c.Next() + return + } + + // ---- 维护进行中,判断是否放行 ---- + + // 1. 检查 session 认证的用户角色(来自 authHelper 设置的 context) + if role, exists := c.Get("role"); exists { + roleInt, ok := role.(int) + if ok { + // root 用户始终放行 + if roleInt >= common.RoleRootUser { + c.Next() + return + } + // admin 用户根据配置放行 + if roleInt >= common.RoleAdminUser && setting.AllowAdminPass { + c.Next() + return + } + } + } + + // 2. 检查 token 认证的用户(来自 TokenAuth 设置的 context) + userId := c.GetInt("id") + if userId > 0 { + // 检查是否为 admin/root + if model.IsAdmin(userId) { + c.Next() + return + } + + // 检查白名单 + whitelistIds := system_setting.GetWhitelistUserIds() + for _, wid := range whitelistIds { + if wid == userId { + c.Next() + return + } + } + } + + // 拦截请求,返回 503 + c.JSON(http.StatusServiceUnavailable, gin.H{ + "success": false, + "message": setting.Message, + "data": gin.H{ + "title": setting.Title, + "end_at": setting.EndAt, + "start_at": setting.StartAt, + }, + }) + c.Abort() + } +} + +// getMaintenanceState 获取维护状态 +// 优先从 Redis 读取,失败则回退到配置 +func getMaintenanceState() *system_setting.MaintenanceSetting { + if common.RedisEnabled { + setting, err := getMaintenanceFromRedis() + if err == nil && setting != nil { + return setting + } + // Redis 读取失败,回退到配置 + if err != nil { + logger.LogError(context.Background(), "从 Redis 读取维护状态失败,回退到配置: "+err.Error()) + } + } + return system_setting.GetMaintenanceSetting() +} + +// getMaintenanceFromRedis 从 Redis 读取维护状态 +func getMaintenanceFromRedis() (*system_setting.MaintenanceSetting, error) { + ctx := context.Background() + val, err := common.RDB.Get(ctx, maintenanceRedisKey).Result() + if err != nil { + return nil, err + } + + var setting system_setting.MaintenanceSetting + err = json.Unmarshal([]byte(val), &setting) + if err != nil { + return nil, err + } + return &setting, nil +} + +// SetMaintenanceToRedis 将维护状态写入 Redis +func SetMaintenanceToRedis(setting *system_setting.MaintenanceSetting) error { + if !common.RedisEnabled { + return nil + } + + ctx := context.Background() + data, err := json.Marshal(setting) + if err != nil { + return err + } + + return common.RDB.Set(ctx, maintenanceRedisKey, string(data), maintenanceRedisTTL).Err() +} + +// DeleteMaintenanceFromRedis 删除 Redis 中的维护状态 +func DeleteMaintenanceFromRedis() error { + if !common.RedisEnabled { + return nil + } + ctx := context.Background() + return common.RDB.Del(ctx, maintenanceRedisKey).Err() +} diff --git a/model/task.go b/model/task.go index 2fbd3fd666b1..2f7aa9e54434 100644 --- a/model/task.go +++ b/model/task.go @@ -109,12 +109,13 @@ type TaskPrivateData struct { // TaskBillingContext 记录任务提交时的计费参数,以便轮询阶段可以重新计算额度。 type TaskBillingContext struct { - ModelPrice float64 `json:"model_price,omitempty"` // 模型单价 - GroupRatio float64 `json:"group_ratio,omitempty"` // 分组倍率 - ModelRatio float64 `json:"model_ratio,omitempty"` // 模型倍率 - OtherRatios map[string]float64 `json:"other_ratios,omitempty"` // 附加倍率(时长、分辨率等) - OriginModelName string `json:"origin_model_name,omitempty"` // 模型名称,必须为OriginModelName - PerCallBilling bool `json:"per_call_billing,omitempty"` // 按次计费:跳过轮询阶段的差额结算 + ModelPrice float64 `json:"model_price,omitempty"` // 模型单价 + GroupRatio float64 `json:"group_ratio,omitempty"` // 分组倍率 + ModelRatio float64 `json:"model_ratio,omitempty"` // 模型倍率 + OtherRatios map[string]float64 `json:"other_ratios,omitempty"` // 附加倍率(时长、分辨率等) + TimeDynamicMultiplier float64 `json:"time_dynamic_multiplier,omitempty"` // 时间动态倍率 + OriginModelName string `json:"origin_model_name,omitempty"` // 模型名称,必须为OriginModelName + PerCallBilling bool `json:"per_call_billing,omitempty"` // 按次计费:跳过轮询阶段的差额结算 } // GetUpstreamTaskID 获取上游真实 task ID(用于与 provider 通信) diff --git a/relay/helper/price.go b/relay/helper/price.go index f109040da0ed..60dfb1b7d002 100644 --- a/relay/helper/price.go +++ b/relay/helper/price.go @@ -2,6 +2,7 @@ package helper import ( "fmt" + "time" "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/logger" @@ -50,6 +51,11 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens groupRatioInfo := HandleGroupRatio(c, info) + // 计算时间动态倍率(独立于 GroupRatio,不修改原有倍率) + tdMultiplier := operation_setting.ResolveTimeDynamicMultiplier( + info.OriginModelName, info.UserGroup, time.Now(), + ) + var preConsumedQuota int var modelRatio float64 var completionRatio float64 @@ -88,12 +94,12 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens audioRatio = ratio_setting.GetAudioRatio(info.OriginModelName) audioCompletionRatio = ratio_setting.GetAudioCompletionRatio(info.OriginModelName) ratio := modelRatio * groupRatioInfo.GroupRatio - preConsumedQuota = int(float64(preConsumedTokens) * ratio) + preConsumedQuota = int(float64(preConsumedTokens) * ratio * tdMultiplier) } else { if meta.ImagePriceRatio != 0 { modelPrice = modelPrice * meta.ImagePriceRatio } - preConsumedQuota = int(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio) + preConsumedQuota = int(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio * tdMultiplier) } // check if free model pre-consume is disabled @@ -116,20 +122,21 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens } priceData := types.PriceData{ - FreeModel: freeModel, - ModelPrice: modelPrice, - ModelRatio: modelRatio, - CompletionRatio: completionRatio, - GroupRatioInfo: groupRatioInfo, - UsePrice: usePrice, - CacheRatio: cacheRatio, - ImageRatio: imageRatio, - AudioRatio: audioRatio, - AudioCompletionRatio: audioCompletionRatio, - CacheCreationRatio: cacheCreationRatio, - CacheCreation5mRatio: cacheCreationRatio5m, - CacheCreation1hRatio: cacheCreationRatio1h, - QuotaToPreConsume: preConsumedQuota, + FreeModel: freeModel, + ModelPrice: modelPrice, + ModelRatio: modelRatio, + CompletionRatio: completionRatio, + GroupRatioInfo: groupRatioInfo, + UsePrice: usePrice, + CacheRatio: cacheRatio, + ImageRatio: imageRatio, + AudioRatio: audioRatio, + AudioCompletionRatio: audioCompletionRatio, + CacheCreationRatio: cacheCreationRatio, + CacheCreation5mRatio: cacheCreationRatio5m, + CacheCreation1hRatio: cacheCreationRatio1h, + QuotaToPreConsume: preConsumedQuota, + TimeDynamicMultiplier: tdMultiplier, } if common.DebugEnabled { @@ -143,6 +150,11 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) (types.PriceData, error) { groupRatioInfo := HandleGroupRatio(c, info) + // 计算时间动态倍率 + tdMultiplier := operation_setting.ResolveTimeDynamicMultiplier( + info.OriginModelName, info.UserGroup, time.Now(), + ) + modelPrice, success := ratio_setting.GetModelPrice(info.OriginModelName, true) // 如果没有配置价格,检查模型倍率配置 if !success { @@ -166,7 +178,7 @@ func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) (types } } - quota := int(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio) + quota := int(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio * tdMultiplier) // 免费模型检测(与 ModelPriceHelper 对齐) freeModel := false @@ -178,10 +190,11 @@ func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) (types } priceData := types.PriceData{ - FreeModel: freeModel, - ModelPrice: modelPrice, - Quota: quota, - GroupRatioInfo: groupRatioInfo, + FreeModel: freeModel, + ModelPrice: modelPrice, + Quota: quota, + GroupRatioInfo: groupRatioInfo, + TimeDynamicMultiplier: tdMultiplier, } return priceData, nil } diff --git a/router/api-router.go b/router/api-router.go index 35d113768be7..940dc4e66bc0 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -176,6 +176,23 @@ func SetApiRouter(router *gin.Engine) { optionRoute.POST("/migrate_console_setting", controller.MigrateConsoleSetting) // 用于迁移检测的旧键,下个版本会删除 } + // 维护模式管理路由(仅 root 可操作) + maintenanceRoute := apiRouter.Group("/maintenance") + maintenanceRoute.Use(middleware.RootAuth()) + { + maintenanceRoute.GET("/", controller.GetMaintenanceStatus) + maintenanceRoute.PUT("/", controller.UpdateMaintenanceStatus) + maintenanceRoute.POST("/disable", controller.DisableMaintenance) + } + + // 时间动态倍率管理路由(仅 root 可操作) + timeDynamicRatioRoute := apiRouter.Group("/time-dynamic-ratio") + timeDynamicRatioRoute.Use(middleware.RootAuth()) + { + timeDynamicRatioRoute.GET("/", controller.GetTimeDynamicRatio) + timeDynamicRatioRoute.PUT("/", controller.UpdateTimeDynamicRatio) + } + // Custom OAuth provider management (root only) customOAuthRoute := apiRouter.Group("/custom-oauth-provider") customOAuthRoute.Use(middleware.RootAuth()) diff --git a/router/relay-router.go b/router/relay-router.go index 17a13cad7fd6..76e5e234ab61 100644 --- a/router/relay-router.go +++ b/router/relay-router.go @@ -62,7 +62,7 @@ func SetRelayRouter(router *gin.Engine) { playgroundRouter := router.Group("/pg") playgroundRouter.Use(middleware.RouteTag("relay")) playgroundRouter.Use(middleware.SystemPerformanceCheck()) - playgroundRouter.Use(middleware.UserAuth(), middleware.Distribute()) + playgroundRouter.Use(middleware.UserAuth(), middleware.MaintenanceCheck(), middleware.Distribute()) { playgroundRouter.POST("/chat/completions", controller.Playground) } @@ -70,6 +70,7 @@ func SetRelayRouter(router *gin.Engine) { relayV1Router.Use(middleware.RouteTag("relay")) relayV1Router.Use(middleware.SystemPerformanceCheck()) relayV1Router.Use(middleware.TokenAuth()) + relayV1Router.Use(middleware.MaintenanceCheck()) relayV1Router.Use(middleware.ModelRequestRateLimit()) { // WebSocket 路由(统一到 Relay) @@ -179,7 +180,7 @@ func SetRelayRouter(router *gin.Engine) { relaySunoRouter := router.Group("/suno") relaySunoRouter.Use(middleware.RouteTag("relay")) relaySunoRouter.Use(middleware.SystemPerformanceCheck()) - relaySunoRouter.Use(middleware.TokenAuth(), middleware.Distribute()) + relaySunoRouter.Use(middleware.TokenAuth(), middleware.MaintenanceCheck(), middleware.Distribute()) { relaySunoRouter.POST("/submit/:action", controller.RelayTask) relaySunoRouter.POST("/fetch", controller.RelayTaskFetch) @@ -190,6 +191,7 @@ func SetRelayRouter(router *gin.Engine) { relayGeminiRouter.Use(middleware.RouteTag("relay")) relayGeminiRouter.Use(middleware.SystemPerformanceCheck()) relayGeminiRouter.Use(middleware.TokenAuth()) + relayGeminiRouter.Use(middleware.MaintenanceCheck()) relayGeminiRouter.Use(middleware.ModelRequestRateLimit()) relayGeminiRouter.Use(middleware.Distribute()) { @@ -202,7 +204,7 @@ func SetRelayRouter(router *gin.Engine) { func registerMjRouterGroup(relayMjRouter *gin.RouterGroup) { relayMjRouter.GET("/image/:id", relay.RelayMidjourneyImage) - relayMjRouter.Use(middleware.TokenAuth(), middleware.Distribute()) + relayMjRouter.Use(middleware.TokenAuth(), middleware.MaintenanceCheck(), middleware.Distribute()) { relayMjRouter.POST("/submit/action", controller.RelayMidjourney) relayMjRouter.POST("/submit/shorten", controller.RelayMidjourney) diff --git a/router/video-router.go b/router/video-router.go index 461451104520..15698571795c 100644 --- a/router/video-router.go +++ b/router/video-router.go @@ -18,7 +18,7 @@ func SetVideoRouter(router *gin.Engine) { videoV1Router := router.Group("/v1") videoV1Router.Use(middleware.RouteTag("relay")) - videoV1Router.Use(middleware.TokenAuth(), middleware.Distribute()) + videoV1Router.Use(middleware.TokenAuth(), middleware.MaintenanceCheck(), middleware.Distribute()) { videoV1Router.POST("/video/generations", controller.RelayTask) videoV1Router.GET("/video/generations/:task_id", controller.RelayTaskFetch) @@ -33,7 +33,7 @@ func SetVideoRouter(router *gin.Engine) { klingV1Router := router.Group("/kling/v1") klingV1Router.Use(middleware.RouteTag("relay")) - klingV1Router.Use(middleware.KlingRequestConvert(), middleware.TokenAuth(), middleware.Distribute()) + klingV1Router.Use(middleware.KlingRequestConvert(), middleware.TokenAuth(), middleware.MaintenanceCheck(), middleware.Distribute()) { klingV1Router.POST("/videos/text2video", controller.RelayTask) klingV1Router.POST("/videos/image2video", controller.RelayTask) @@ -44,7 +44,7 @@ func SetVideoRouter(router *gin.Engine) { // Jimeng official API routes - direct mapping to official API format jimengOfficialGroup := router.Group("jimeng") jimengOfficialGroup.Use(middleware.RouteTag("relay")) - jimengOfficialGroup.Use(middleware.JimengRequestConvert(), middleware.TokenAuth(), middleware.Distribute()) + jimengOfficialGroup.Use(middleware.JimengRequestConvert(), middleware.TokenAuth(), middleware.MaintenanceCheck(), middleware.Distribute()) { // Maps to: /?Action=CVSync2AsyncSubmitTask&Version=2022-08-31 and /?Action=CVSync2AsyncGetResult&Version=2022-08-31 jimengOfficialGroup.POST("/", controller.RelayTask) diff --git a/service/log_info_generate.go b/service/log_info_generate.go index 75e6fb1d4908..e5b8b0d1c914 100644 --- a/service/log_info_generate.go +++ b/service/log_info_generate.go @@ -36,6 +36,9 @@ func GenerateTextOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, m other := make(map[string]interface{}) other["model_ratio"] = modelRatio other["group_ratio"] = groupRatio + if relayInfo.PriceData.TimeDynamicMultiplier != 0 { + other["time_dynamic_multiplier"] = relayInfo.PriceData.TimeDynamicMultiplier + } other["completion_ratio"] = completionRatio other["cache_tokens"] = cacheTokens other["cache_ratio"] = cacheRatio @@ -256,6 +259,9 @@ func GenerateMjOtherInfo(relayInfo *relaycommon.RelayInfo, priceData types.Price other := make(map[string]interface{}) other["model_price"] = priceData.ModelPrice other["group_ratio"] = priceData.GroupRatioInfo.GroupRatio + if priceData.TimeDynamicMultiplier != 0 { + other["time_dynamic_multiplier"] = priceData.TimeDynamicMultiplier + } if priceData.GroupRatioInfo.HasSpecialRatio { other["user_group_ratio"] = priceData.GroupRatioInfo.GroupSpecialRatio } diff --git a/service/task_billing.go b/service/task_billing.go index b887f6682502..7ae76ca7c93f 100644 --- a/service/task_billing.go +++ b/service/task_billing.go @@ -4,12 +4,14 @@ import ( "context" "fmt" "strings" + "time" "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/model" relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/setting/operation_setting" "github.com/QuantumNous/new-api/setting/ratio_setting" "github.com/gin-gonic/gin" ) @@ -118,6 +120,9 @@ func taskBillingOther(task *model.Task) map[string]interface{} { if bc := task.PrivateData.BillingContext; bc != nil { other["model_price"] = bc.ModelPrice other["group_ratio"] = bc.GroupRatio + if bc.TimeDynamicMultiplier != 0 { + other["time_dynamic_multiplier"] = bc.TimeDynamicMultiplier + } if len(bc.OtherRatios) > 0 { for k, v := range bc.OtherRatios { other[k] = v @@ -268,6 +273,8 @@ func RecalculateTaskQuotaByTokens(ctx context.Context, task *model.Task, totalTo } groupRatio := ratio_setting.GetGroupRatio(group) + // 异步回调时,仍对当前时间匹配时间动态倍率 + tdMultiplier := operation_setting.ResolveTimeDynamicMultiplier(modelName, group, time.Now()) userGroupRatio, hasUserGroupRatio := ratio_setting.GetGroupGroupRatio(group, group) var finalGroupRatio float64 @@ -277,9 +284,9 @@ func RecalculateTaskQuotaByTokens(ctx context.Context, task *model.Task, totalTo finalGroupRatio = groupRatio } - // 计算实际应扣费额度: totalTokens * modelRatio * groupRatio - actualQuota := int(float64(totalTokens) * modelRatio * finalGroupRatio) + // 计算实际应扣费额度: totalTokens * modelRatio * groupRatio * tdMultiplier + actualQuota := int(float64(totalTokens) * modelRatio * finalGroupRatio * tdMultiplier) - reason := fmt.Sprintf("token重算:tokens=%d, modelRatio=%.2f, groupRatio=%.2f", totalTokens, modelRatio, finalGroupRatio) + reason := fmt.Sprintf("token重算:tokens=%d, modelRatio=%.2f, groupRatio=%.2f, tdMultiplier=%.2f", totalTokens, modelRatio, finalGroupRatio, tdMultiplier) RecalculateTaskQuota(ctx, task, actualQuota, reason) } diff --git a/service/text_quota.go b/service/text_quota.go index 8caee8f28799..e1c2109d35e8 100644 --- a/service/text_quota.go +++ b/service/text_quota.go @@ -247,6 +247,11 @@ func calculateTextQuotaSummary(ctx *gin.Context, relayInfo *relaycommon.RelayInf quotaCalculateDecimal = quotaCalculateDecimal.Add(audioInputQuota) quotaCalculateDecimal = quotaCalculateDecimal.Add(dImageGenerationCallQuota) + // 时间动态倍率(影响全部费用,在 OtherRatios 之前) + if relayInfo.PriceData.TimeDynamicMultiplier != 0 && relayInfo.PriceData.TimeDynamicMultiplier != 1.0 { + quotaCalculateDecimal = quotaCalculateDecimal.Mul(decimal.NewFromFloat(relayInfo.PriceData.TimeDynamicMultiplier)) + } + if len(relayInfo.PriceData.OtherRatios) > 0 { for _, otherRatio := range relayInfo.PriceData.OtherRatios { quotaCalculateDecimal = quotaCalculateDecimal.Mul(decimal.NewFromFloat(otherRatio)) @@ -264,6 +269,12 @@ func calculateTextQuotaSummary(ctx *gin.Context, relayInfo *relaycommon.RelayInf quotaCalculateDecimal = quotaCalculateDecimal.Add(dFileSearchQuota) quotaCalculateDecimal = quotaCalculateDecimal.Add(audioInputQuota) quotaCalculateDecimal = quotaCalculateDecimal.Add(dImageGenerationCallQuota) + + // 时间动态倍率(影响全部费用,在 OtherRatios 之前) + if relayInfo.PriceData.TimeDynamicMultiplier != 0 && relayInfo.PriceData.TimeDynamicMultiplier != 1.0 { + quotaCalculateDecimal = quotaCalculateDecimal.Mul(decimal.NewFromFloat(relayInfo.PriceData.TimeDynamicMultiplier)) + } + if len(relayInfo.PriceData.OtherRatios) > 0 { for _, otherRatio := range relayInfo.PriceData.OtherRatios { quotaCalculateDecimal = quotaCalculateDecimal.Mul(decimal.NewFromFloat(otherRatio)) diff --git a/setting/operation_setting/time_dynamic_ratio.go b/setting/operation_setting/time_dynamic_ratio.go new file mode 100644 index 000000000000..5a157f493955 --- /dev/null +++ b/setting/operation_setting/time_dynamic_ratio.go @@ -0,0 +1,224 @@ +package operation_setting + +import ( + "fmt" + "sort" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/setting/config" +) + +// TimeDynamicRatioRule 单条时间动态倍率规则 +type TimeDynamicRatioRule struct { + ID string `json:"id"` // 规则唯一标识(UUID) + Name string `json:"name"` // 规则名称(运营可读) + Enabled bool `json:"enabled"` // 规则独立开关 + Priority int `json:"priority"` // 优先级,越小越优先 + StartTime string `json:"start_time"` // 开始时间 HH:MM + EndTime string `json:"end_time"` // 结束时间 HH:MM(支持跨午夜) + Weekdays []int `json:"weekdays"` // 生效星期 [1=周一..7=周日],空=每天 + Groups []string `json:"groups"` // 匹配分组,空=全部分组 + Models []string `json:"models"` // 匹配模型(支持前缀通配符 gpt-4*),空=全部 + Multiplier float64 `json:"multiplier"` // 倍率乘数,必须 > 0 +} + +// TimeDynamicRatioSetting 时间动态倍率全局设置 +type TimeDynamicRatioSetting struct { + Enabled bool `json:"enabled"` // 全局开关 + Rules []TimeDynamicRatioRule `json:"rules"` // 规则列表(按 Priority 升序) +} + +var timeDynamicRatioSetting TimeDynamicRatioSetting + +func init() { + timeDynamicRatioSetting = TimeDynamicRatioSetting{ + Enabled: false, + Rules: []TimeDynamicRatioRule{}, + } + config.GlobalConfig.Register("time_dynamic_ratio_setting", &timeDynamicRatioSetting) +} + +// GetTimeDynamicRatioSetting 获取当前配置(供管理 API 使用) +func GetTimeDynamicRatioSetting() *TimeDynamicRatioSetting { + return &timeDynamicRatioSetting +} + +// ResolveTimeDynamicMultiplier 根据模型名、用户分组和当前时间,匹配规则并返回倍率。 +// 未命中任何规则或功能未启用时返回 1.0。 +func ResolveTimeDynamicMultiplier(modelName, userGroup string, now time.Time) float64 { + if !timeDynamicRatioSetting.Enabled { + return 1.0 + } + + rules := timeDynamicRatioSetting.Rules + if len(rules) == 0 { + return 1.0 + } + + // 按优先级排序(Priority 越小越优先) + sorted := make([]TimeDynamicRatioRule, len(rules)) + copy(sorted, rules) + sort.Slice(sorted, func(i, j int) bool { + return sorted[i].Priority < sorted[j].Priority + }) + + weekday := isoWeekday(now) + currentMinutes := now.Hour()*60 + now.Minute() + + for _, rule := range sorted { + if !rule.Enabled { + continue + } + if !matchWeekday(rule.Weekdays, weekday) { + continue + } + if !matchTimeRange(rule.StartTime, rule.EndTime, currentMinutes) { + continue + } + if !matchGroup(rule.Groups, userGroup) { + continue + } + if !matchModel(rule.Models, modelName) { + continue + } + + // 命中!返回倍率(兜底防护:不允许 <= 0) + if rule.Multiplier <= 0 { + return 1.0 + } + common.SysLog(fmt.Sprintf("[TimeDynamic] rule=%s matched, model=%s group=%s multiplier=%.4f", + rule.Name, modelName, userGroup, rule.Multiplier)) + return rule.Multiplier + } + + return 1.0 +} + +// isoWeekday 返回 ISO 星期编号:周一=1, 周日=7 +func isoWeekday(t time.Time) int { + wd := int(t.Weekday()) + if wd == 0 { + return 7 // 周日 + } + return wd +} + +// matchWeekday 检查当前星期是否在规则指定的星期列表中。空列表=每天匹配。 +func matchWeekday(weekdays []int, current int) bool { + if len(weekdays) == 0 { + return true + } + for _, wd := range weekdays { + if wd == current { + return true + } + } + return false +} + +// matchTimeRange 检查当前时间(分钟数)是否在 [start, end) 范围内。 +// 支持跨午夜,如 22:00→06:00。 +func matchTimeRange(startStr, endStr string, currentMinutes int) bool { + startMinutes := parseTimeToMinutes(startStr) + endMinutes := parseTimeToMinutes(endStr) + + if startMinutes < 0 || endMinutes < 0 { + return false // 时间格式无效,不匹配 + } + + if startMinutes <= endMinutes { + // 不跨午夜:如 09:00→18:00 + return currentMinutes >= startMinutes && currentMinutes < endMinutes + } + // 跨午夜:如 22:00→06:00,等价于 [22:00, 24:00) ∪ [00:00, 06:00) + return currentMinutes >= startMinutes || currentMinutes < endMinutes +} + +// parseTimeToMinutes 将 "HH:MM" 解析为当日分钟数。失败返回 -1。 +func parseTimeToMinutes(timeStr string) int { + if len(timeStr) < 4 || len(timeStr) > 5 { + return -1 + } + parts := strings.Split(timeStr, ":") + if len(parts) != 2 { + return -1 + } + hour := 0 + minute := 0 + for _, c := range parts[0] { + if c < '0' || c > '9' { + return -1 + } + hour = hour*10 + int(c-'0') + } + for _, c := range parts[1] { + if c < '0' || c > '9' { + return -1 + } + minute = minute*10 + int(c-'0') + } + if hour < 0 || hour > 23 || minute < 0 || minute > 59 { + return -1 + } + return hour*60 + minute +} + +// matchGroup 检查用户分组是否在规则的分组列表中。空列表=全部分组。 +func matchGroup(groups []string, userGroup string) bool { + if len(groups) == 0 { + return true + } + for _, g := range groups { + if g == userGroup { + return true + } + } + return false +} + +// matchModel 检查模型名是否在规则的模型列表中。空列表=全部模型。 +// 支持前缀通配符:如 "gpt-4*" 匹配 "gpt-4o-mini"。 +func matchModel(models []string, modelName string) bool { + if len(models) == 0 { + return true + } + for _, pattern := range models { + if pattern == modelName { + return true + } + // 前缀通配符匹配 + if strings.HasSuffix(pattern, "*") { + prefix := strings.TrimSuffix(pattern, "*") + if strings.HasPrefix(modelName, prefix) { + return true + } + } + } + return false +} + +// ValidateTimeDynamicRatioRules 校验规则列表合法性 +func ValidateTimeDynamicRatioRules(rules []TimeDynamicRatioRule) string { + for _, rule := range rules { + if rule.Name == "" { + return "规则名称不能为空" + } + if rule.Multiplier <= 0 { + return "规则「" + rule.Name + "」的倍率必须大于 0" + } + if parseTimeToMinutes(rule.StartTime) < 0 { + return "规则「" + rule.Name + "」的开始时间格式无效,应为 HH:MM" + } + if parseTimeToMinutes(rule.EndTime) < 0 { + return "规则「" + rule.Name + "」的结束时间格式无效,应为 HH:MM" + } + for _, wd := range rule.Weekdays { + if wd < 1 || wd > 7 { + return "规则「" + rule.Name + "」的星期值无效,应为 1-7" + } + } + } + return "" +} diff --git a/setting/system_setting/maintenance.go b/setting/system_setting/maintenance.go new file mode 100644 index 000000000000..7fe7f3ebf724 --- /dev/null +++ b/setting/system_setting/maintenance.go @@ -0,0 +1,84 @@ +package system_setting + +import ( + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/setting/config" +) + +// MaintenanceSetting 维护模式配置 +type MaintenanceSetting struct { + Enabled bool `json:"enabled"` // 是否处于维护中 + Title string `json:"title"` // 维护标题 + Message string `json:"message"` // 维护说明 + NoticeEnabled bool `json:"notice_enabled"` // 是否启用预告 + NoticeStartAt int64 `json:"notice_start_at"` // 预告开始时间(Unix 秒) + StartAt int64 `json:"start_at"` // 维护开始时间 + EndAt int64 `json:"end_at"` // 维护结束时间(0=不限) + WhitelistUserIds string `json:"whitelist_user_ids"` // 白名单用户ID(JSON数组字符串,如 "[1,2,3]") + AllowAdminPass bool `json:"allow_admin_pass"` // 是否放行管理员(默认 true) +} + +// MaintenancePublicInfo 对外公开的维护信息(不含白名单等敏感数据) +type MaintenancePublicInfo struct { + Enabled bool `json:"enabled"` + NoticeEnabled bool `json:"notice_enabled"` + Title string `json:"title"` + Message string `json:"message"` + StartAt int64 `json:"start_at"` + EndAt int64 `json:"end_at"` +} + +// 默认配置:维护关闭,管理员默认放行 +var maintenanceSetting = MaintenanceSetting{ + Enabled: false, + Title: "系统维护中", + Message: "系统正在维护,请稍后再试", + NoticeEnabled: false, + NoticeStartAt: 0, + StartAt: 0, + EndAt: 0, + WhitelistUserIds: "[]", + AllowAdminPass: true, +} + +func init() { + // 注册到全局配置管理器 + config.GlobalConfig.Register("maintenance_setting", &maintenanceSetting) +} + +// GetMaintenanceSetting 获取完整维护配置 +func GetMaintenanceSetting() *MaintenanceSetting { + return &maintenanceSetting +} + +// GetMaintenancePublicInfo 获取对外公开的维护信息 +func GetMaintenancePublicInfo() *MaintenancePublicInfo { + return &MaintenancePublicInfo{ + Enabled: maintenanceSetting.Enabled, + NoticeEnabled: maintenanceSetting.NoticeEnabled, + Title: maintenanceSetting.Title, + Message: maintenanceSetting.Message, + StartAt: maintenanceSetting.StartAt, + EndAt: maintenanceSetting.EndAt, + } +} + +// IsMaintenanceEnabled 是否处于维护模式 +func IsMaintenanceEnabled() bool { + return maintenanceSetting.Enabled +} + +// UpdateMaintenanceSetting 更新维护配置(用于从控制器调用) +func UpdateMaintenanceSetting(newSetting MaintenanceSetting) { + maintenanceSetting = newSetting +} + +// GetWhitelistUserIds 解析白名单用户ID列表 +func GetWhitelistUserIds() []int { + var ids []int + err := common.UnmarshalJsonStr(maintenanceSetting.WhitelistUserIds, &ids) + if err != nil { + return []int{} + } + return ids +} diff --git a/types/price_data.go b/types/price_data.go index 93bc6ae8d168..7f93ba8463d4 100644 --- a/types/price_data.go +++ b/types/price_data.go @@ -9,22 +9,23 @@ type GroupRatioInfo struct { } type PriceData struct { - FreeModel bool - ModelPrice float64 - ModelRatio float64 - CompletionRatio float64 - CacheRatio float64 - CacheCreationRatio float64 - CacheCreation5mRatio float64 - CacheCreation1hRatio float64 - ImageRatio float64 - AudioRatio float64 - AudioCompletionRatio float64 - OtherRatios map[string]float64 - UsePrice bool - Quota int // 按次计费的最终额度(MJ / Task) - QuotaToPreConsume int // 按量计费的预消耗额度 - GroupRatioInfo GroupRatioInfo + FreeModel bool + ModelPrice float64 + ModelRatio float64 + CompletionRatio float64 + CacheRatio float64 + CacheCreationRatio float64 + CacheCreation5mRatio float64 + CacheCreation1hRatio float64 + ImageRatio float64 + AudioRatio float64 + AudioCompletionRatio float64 + OtherRatios map[string]float64 + UsePrice bool + Quota int // 按次计费的最终额度(MJ / Task) + QuotaToPreConsume int // 按量计费的预消耗额度 + GroupRatioInfo GroupRatioInfo + TimeDynamicMultiplier float64 // 时间动态倍率(默认1.0,独立于GroupRatio,仅用于计费和日志) } func (p *PriceData) AddOtherRatio(key string, ratio float64) { diff --git a/web/src/components/layout/MaintenanceBanner.jsx b/web/src/components/layout/MaintenanceBanner.jsx new file mode 100644 index 000000000000..d9b90d5a6766 --- /dev/null +++ b/web/src/components/layout/MaintenanceBanner.jsx @@ -0,0 +1,164 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useContext, useMemo } from 'react'; +import { Banner, Typography } from '@douyinfe/semi-ui'; +import { IconAlertTriangle, IconInfoCircle } from '@douyinfe/semi-icons'; +import { StatusContext } from '../../context/Status'; +import { UserContext } from '../../context/User'; +import { useTranslation } from 'react-i18next'; + +/** + * 维护模式 Banner + * 1. 维护中 → 非管理员看到红色警告 Banner + * 2. 预告期 → 所有用户看到蓝色提示 Banner + * 3. 管理员在维护期间看到橙色提示 Banner(提示系统正在维护,但你仍可使用) + */ +export default function MaintenanceBanner () { + const { t } = useTranslation(); + const [statusState] = useContext(StatusContext); + const [userState] = useContext(UserContext); + + const maintenance = statusState?.status?.maintenance; + + // 判断用户是否为管理员(role >= 10) + const isAdmin = useMemo(() => { + const user = userState?.user; + return user && user.role >= 10; + }, [userState?.user]); + + if (!maintenance) return null; + + const { enabled, title, message, notice_enabled, start_at, end_at } = maintenance; + + // 检查维护时间窗口 + const now = Math.floor(Date.now() / 1000); + const isInMaintenanceWindow = + enabled && + (start_at === 0 || now >= start_at) && + (end_at === 0 || now <= end_at); + + // 预告期:enabled=true 但尚未到开始时间,或者 notice_enabled=true + const isNotice = + (enabled && start_at > 0 && now < start_at) || + (notice_enabled && !isInMaintenanceWindow); + + // 格式化时间显示 + const formatTime = (ts) => { + if (!ts || ts === 0) return ''; + return new Date(ts * 1000).toLocaleString(); + }; + + // 维护进行中 + if (isInMaintenanceWindow) { + if (isAdmin) { + // 管理员看到提示性 Banner + return ( + } + closeIcon={null} + description={ + + {title || t('系统维护中')} + {' — '} + {t('当前处于维护模式,普通用户无法访问。')} + {end_at > 0 && ` ${t('预计恢复时间')}: ${formatTime(end_at)}`} + + } + style={{ + borderRadius: 0, + position: 'sticky', + top: 0, + zIndex: 1000, + }} + /> + ); + } + + // 普通用户看到全屏维护页面 + return ( +
+ + + {title || t('系统维护中')} + + + {message || t('系统正在维护,请稍后再试')} + + {end_at > 0 && ( + + {t('预计恢复时间')}: {formatTime(end_at)} + + )} +
+ ); + } + + // 预告期 + if (isNotice) { + return ( + } + description={ + + {t('维护预告')} + {' — '} + {title || t('系统维护中')} + {start_at > 0 && `. ${t('维护开始时间')}: ${formatTime(start_at)}`} + {end_at > 0 && `, ${t('预计恢复时间')}: ${formatTime(end_at)}`} + + } + style={{ + borderRadius: 0, + position: 'sticky', + top: 0, + zIndex: 1000, + }} + /> + ); + } + + return null; +} diff --git a/web/src/components/layout/PageLayout.jsx b/web/src/components/layout/PageLayout.jsx index 51666b5ef392..b6f10e2d1ece 100644 --- a/web/src/components/layout/PageLayout.jsx +++ b/web/src/components/layout/PageLayout.jsx @@ -22,6 +22,7 @@ import { Layout } from '@douyinfe/semi-ui'; import SiderBar from './SiderBar'; import App from '../../App'; import FooterBar from './Footer'; +import MaintenanceBanner from './MaintenanceBanner'; import { ToastContainer } from 'react-toastify'; import React, { useContext, useEffect, useState } from 'react'; import { useIsMobile } from '../../hooks/common/useIsMobile'; @@ -168,6 +169,7 @@ const PageLayout = () => { drawerOpen={drawerOpen} /> + { @@ -103,7 +105,7 @@ const OperationSetting = () => { showError(message); } }; - async function onRefresh() { + async function onRefresh () { try { setLoading(true); await getOptions(); @@ -150,6 +152,14 @@ const OperationSetting = () => { + {/* 维护模式设置 */} + + + + {/* 时间动态倍率 */} + + + {/* 签到设置 */} diff --git a/web/src/components/table/tokens/modals/TokenExportConfigModal.jsx b/web/src/components/table/tokens/modals/TokenExportConfigModal.jsx new file mode 100644 index 000000000000..492232c3e0e7 --- /dev/null +++ b/web/src/components/table/tokens/modals/TokenExportConfigModal.jsx @@ -0,0 +1,105 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import { Modal, Button, Space, Typography } from '@douyinfe/semi-ui'; + +const { TextArea, Text } = Typography; + +const TokenExportConfigModal = ({ + visible, + onCancel, + payload, + t, + onCopy, +}) => { + const notes = Array.isArray(payload?.notes) ? payload.notes : []; + + return ( + + + + } + > + +
+
+ {t('环境变量脚本')} + +
+