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