Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ AITokenPool 是一个开源的 **AI Token 共享平台**:企业版(内部 ke

## 状态

后端 + 前端已全部实现,当前版本 **v0.7.2**(config.toml 模型目录唯一真源):
后端 + 前端已全部实现,当前版本 **v0.7.3**(config.toml 模型目录唯一真源):

- ✅ **P0-A(v0.2.0)**:后端骨架 + TOML 配置(`Config::validate`:points_per_unit>0 / plan→provider 存在 / endpoints≥1 / protocol 枚举)+ SQLite 数据层(幂等迁移,**生产空库不种任何种子数据**)+ 认证(argon2 + Bearer API Key)+ API Key 端点
- ✅ **P0-B(v0.2.1)**:网关转发(OpenAI Chat Completions + Anthropic Messages)+ 路由故障转移(粘性 / 健康冷却 / 3 次切换上限)+ 计量账本(点数计算、90/10 分成、事务性 settle)
Expand All @@ -40,6 +40,7 @@ AITokenPool 是一个开源的 **AI Token 共享平台**:企业版(内部 ke
- ✅ **v0.6.6**:**统一数据目录(rant 2026-08-19T20:53:23)**——`ATP_DATA_DIR`(默认 `./data`;`--data-dir` > env > 默认)下放 config.toml(首次自动复制示例)+ aitokenpool.db + logs/,目录自动创建;数据库路径统一由 data-dir 决定(config `db_path` 忽略);Docker 单卷挂载 `./atp-data:/data`(镜像内置示例配置,首启自动复制);.gitignore/.dockerignore 补 atp-data/;旧 data/ 迁移说明
- ✅ **v0.6.7**:**日志系统(rant 2026-08-19T20:54:26)**——log4rs 替换 env_logger:日志落盘 `<data-dir>/logs/aitokenpool.log` + stdout 双写;按大小滚动(`[log].max_file_size`,默认 10MB)+ `max_backups`(默认 7)自动清理旧日志;`[log]` 配置段(dir / level / file_pattern);Docker 日志随统一目录持久化
- ✅ **v0.7.0**:**缓存命中/未命中分开计费(rant 2026-08-20T10:17:27)**——usage 解析拆分缓存 token(openai `prompt_tokens_details.cached_tokens` / anthropic `cache_read_input_tokens` / responses `input_tokens_details.cached_tokens`);计费 = 未命中 × input_per_m + 命中 × cache_hit_input_per_m(缺省 0 = 命中免费);usage_records 新增 `cached_tokens` 列(迁移 v8);DeepSeek 官方 CNY 定价(deepseek-v4-flash 1.5/0.05/4.5、deepseek-v4-pro 4.5/0.15/13.5,空闲价);管理端模型表单可配缓存命中输入价;流式(SSE 转换 + 透传)同拆
- ✅ **v0.7.3**:**DeepSeek 高峰时段计价(rant 2026-08-20T11:58:40)**——models 表 + config `[[models]]` 新增三个可选高峰价字段 `peak_input_per_m / peak_output_per_m / peak_cache_hit_input_per_m`(缺省 0 = 不启用高峰计费,沿用空闲价);计费按**北京时间**判定高峰(9:00-12:00、14:00-18:00,周一至周日,固定 Asia/Shanghai 不依赖服务器时区)命中则用高峰价;DeepSeek 官方高峰价写入示例配置(flash 3.0/0.10/9.0、pro 9.0/0.30/27.0 元/M);市场页标注「高峰 ×N」+ 展开详情显示高峰价;管理端模型表单可配高峰价;迁移 v9 幂等;128 passed
- ✅ **v0.7.2**:**seed 同步删除(rant 2026-08-20T11:58:33)**——启动 `seed_models` 在 upsert 之外同步删除 config 中已移除的模型行(config `[[models]]` 全量权威;市场不再显示幽灵模型;admin 运行时增删改下次启动被 config 覆盖/清掉,持久化自定义模型请写入 config.toml);新增测试 `seed_models_deletes_config_removed_rows`(125 passed)
- ✅ **v0.7.1**:**简化模型配置(rant 2026-08-20T10:27:13)**——模型(厂商/价格/上下文/读图等)全部在 config.toml `[[models]]` 段定义(唯一真源),启动 upsert 进 DB;**移除 data/models.example.json 与 price_overrides 双层机制**(不再有「json 聚合源 + 官方价覆盖」设想);10 个模型迁入 config(DeepSeek 官方 CNY 价 flash 1.5/0.05/4.5、pro 4.5/0.15/13.5);data.js 游客兜底价对齐 config

Expand Down Expand Up @@ -139,7 +140,7 @@ open http://localhost:8080/ # 浏览器访问
- `POST /api/auth/login` → `{api_key}`;`POST /api/auth/change-password`(改密);`POST /api/auth/register|verify|resend-code`(注册+邮箱验证);`GET /api/me` → `{id,email,name,role}`;`GET /api/config` → `{public_url}`(接入端点 base,rant 2026-08-19T20:37:37)
- `POST|GET /api/api-keys`(key 脱敏 `atk_live_****xxxx`);`DELETE /api/api-keys/:id`(撤销)
- `POST /v1/chat/completions` / `POST /anthropic/v1/messages` / `POST /v1/responses`(网关,三协议互转,非流式 + 流式 SSE 跨协议转换);`GET /v1/models`(OpenAI 兼容模型列表,认证可选)
- `GET /api/models`(模型市场,含 context_length / max_output / vision / cache_hit_input_per_m)
- `GET /api/models`(模型市场,含 context_length / max_output / vision / cache_hit_input_per_m / peak_input_per_m / peak_output_per_m / peak_cache_hit_input_per_m)
- `POST|GET /api/sharings` + `PATCH /api/sharings/:id`(上架 / 列表 / 暂停 / 恢复 / 删除)
- `GET /api/wallet` / `GET /api/transactions?type=` / `GET /api/dashboard`(钱包 / 交易 / 仪表盘)
- 管理员:`POST /api/admin/credits` / `GET /api/admin/users` + `PATCH /api/admin/users/:id` / `GET /api/admin/usage`
Expand Down
14 changes: 13 additions & 1 deletion config/config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,11 @@ endpoints = [
# 模型(厂商/名称/价格/上下文/读图等)全部在此定义;启动时 upsert 进 DB。
# 单位:每百万 tokens(currency 对应币种);input_per_m = 缓存未命中输入价,
# cache_hit_input_per_m = 缓存命中输入价(缺省 0 = 命中免费)。
# DeepSeek 为官方价(2026-08-20 浏览器核实,空闲时段;高峰 9-12/14-18 北京时翻倍)。
# 高峰时段价(rant 2026-08-20T11:58:40):peak_input_per_m / peak_output_per_m /
# peak_cache_hit_input_per_m 三个可选字段,缺省 0 = 不启用高峰计费(沿用空闲价)。
# 命中高峰时段(北京时间 9-12、14-18,周一至周日)时按高峰价扣点。
# DeepSeek 为官方价(2026-08-20 浏览器核实):空闲价即高峰价的一半,
# 高峰 9-12/14-18 北京时翻倍(flash 3.0/0.10/9.0,pro 9.0/0.30/27.0 元/M)。
# ============================================================

[[models]]
Expand All @@ -256,6 +260,10 @@ currency = "CNY"
input_per_m = 1.5
cache_hit_input_per_m = 0.05
output_per_m = 4.5
# 高峰时段价(北京 9-12 / 14-18 翻倍;官方价)
peak_input_per_m = 3.0
peak_cache_hit_input_per_m = 0.10
peak_output_per_m = 9.0
context_length = 1048576
max_output = 384000
vision = false
Expand All @@ -267,6 +275,10 @@ currency = "CNY"
input_per_m = 4.5
cache_hit_input_per_m = 0.15
output_per_m = 13.5
# 高峰时段价(北京 9-12 / 14-18 翻倍;官方价)
peak_input_per_m = 9.0
peak_cache_hit_input_per_m = 0.30
peak_output_per_m = 27.0
context_length = 1048576
max_output = 384000
vision = false
Expand Down
3 changes: 2 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@
### 4.3 计量引擎(P0-B 已实现,`src/billing.rs`)
- 每次调用记录:用户、模型、输入/输出 token、成本(`usage_records`)
- **点数计算**:`成本 = 输入token×input_per_m/1e6 + 输出token×output_per_m/1e6`(models 表单价,config.toml `[[models]]` 唯一真源)→ 折算到锚定货币(USD/CNY,`CNY_PER_USD=7.2`)→ × `points_per_unit`(默认 1000 点/USD)
- **高峰时段计价(v0.7.3)**:`[[models]]` 可选 `peak_input_per_m / peak_output_per_m / peak_cache_hit_input_per_m`(缺省 0 = 不启用);按北京时间(固定 Asia/Shanghai,UTC+8 无夏令时)判定高峰 9:00-12:00、14:00-18:00(周一至周日),命中则用高峰价(DeepSeek 官方高峰价 = 空闲价 ×2)
- **入账(事务性)**:`settle` 在成功响应后调用——扣消费者 → 加分享者 90% → 两条 transactions(consume/earn)→ usage_records → keys.used;任一步失败整体回滚(失败不入账)

### 4.4 账本层(P1 点数规则已落地)
Expand Down Expand Up @@ -122,7 +123,7 @@
users — 用户(email/password_hash[argon2]/name/role/created_at)
keys — 上游 key(provider/plan/model/状态/属主/加密密文/额度/已用/可用时间段/备注)
api_keys — 分发 key(绑定用户、atk_live_ 前缀、状态、last_used)
models — 模型价格(provider/model/currency/input_per_m/output_per_m)
models — 模型价格(provider/model/currency/input_per_m/output_per_m/cache_hit_input_per_m/peak_input_per_m/peak_output_per_m/peak_cache_hit_input_per_m)
quotas — 点数账户(balance 永久 + gift_balance 有效赠送)
gift_grants — 赠送明细(amount/granted_at/expires_at/status: active|used|expired)
transactions — 交易(counterpart/key_id/model/tokens/pts/type: consume|earn|topup/status/time)
Expand Down
157 changes: 157 additions & 0 deletions src/billing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,48 @@ pub fn calc_points(
to_anchor(cost, model_currency, anchor) * points_per_unit as f64
}

/// 北京时间高峰时段判定(rant 2026-08-20T11:58:40:DeepSeek 高峰 9:00-12:00、14:00-18:00,
/// 周一至周日,其余为空闲时段)。固定 Asia/Shanghai(UTC+8,无夏令时),不依赖服务器时区。
pub fn is_peak_hour(utc: &chrono::DateTime<chrono::Utc>) -> bool {
use chrono::Timelike;
let sh = utc.with_timezone(&chrono::FixedOffset::east_opt(8 * 3600).expect("+08:00 合法"));
let h = sh.hour();
(9..12).contains(&h) || (14..18).contains(&h)
}

/// 按是否高峰挑选生效价格:高峰时段命中且配置了高峰价(> 0)→ 用高峰价;
/// 高峰价缺省 0 → 沿用空闲价(不启用高峰计费的模型行为不变)。
pub fn effective_prices(
peak: bool,
input_per_m: f64,
cache_hit_input_per_m: f64,
output_per_m: f64,
peak_input_per_m: f64,
peak_cache_hit_input_per_m: f64,
peak_output_per_m: f64,
) -> (f64, f64, f64) {
if !peak {
return (input_per_m, cache_hit_input_per_m, output_per_m);
}
(
if peak_input_per_m > 0.0 {
peak_input_per_m
} else {
input_per_m
},
if peak_cache_hit_input_per_m > 0.0 {
peak_cache_hit_input_per_m
} else {
cache_hit_input_per_m
},
if peak_output_per_m > 0.0 {
peak_output_per_m
} else {
output_per_m
},
)
}

/// 入账参数
pub struct SettleParams {
/// 消费者(调用者)user_id
Expand Down Expand Up @@ -230,6 +272,121 @@ mod tests {
assert!((pts_zero - expect_zero).abs() < 1e-9, "pts_zero={pts_zero}");
}

#[test]
fn is_peak_hour_beijing_boundaries() {
// rant 2026-08-20T11:58:40:高峰 = 北京时 9:00-12:00、14:00-18:00(周一至周日)。
// 边界测试:8:59 vs 9:00、11:59 vs 12:00、13:59 vs 14:00、17:59 vs 18:00(北京时 = UTC+8)
fn beijing(h: u32, mi: u32) -> chrono::DateTime<chrono::Utc> {
use chrono::TimeZone;
let dt = chrono::FixedOffset::east_opt(8 * 3600)
.unwrap()
.with_ymd_and_hms(2026, 8, 20, h, mi, 0)
.unwrap();
dt.with_timezone(&chrono::Utc)
}
// 9:00 高峰开始
assert!(is_peak_hour(&beijing(9, 0)), "北京 9:00 应高峰");
assert!(!is_peak_hour(&beijing(8, 59)), "北京 8:59 应空闲");
// 12:00 高峰结束
assert!(!is_peak_hour(&beijing(12, 0)), "北京 12:00 应空闲");
assert!(is_peak_hour(&beijing(11, 59)), "北京 11:59 应高峰");
// 14:00 第二段高峰开始
assert!(is_peak_hour(&beijing(14, 0)), "北京 14:00 应高峰");
assert!(!is_peak_hour(&beijing(13, 59)), "北京 13:59 应空闲");
// 18:00 第二段高峰结束
assert!(!is_peak_hour(&beijing(18, 0)), "北京 18:00 应空闲");
assert!(is_peak_hour(&beijing(17, 59)), "北京 17:59 应高峰");
// 周日同样高峰(周一至周日全覆盖)
use chrono::TimeZone;
let sun = chrono::FixedOffset::east_opt(8 * 3600)
.unwrap()
.with_ymd_and_hms(2026, 8, 23, 10, 0, 0)
.unwrap()
.with_timezone(&chrono::Utc);
assert!(is_peak_hour(&sun), "周日 10:00 应高峰(含周末)");
// 夜间空闲
assert!(!is_peak_hour(&beijing(0, 0)), "北京 0:00 应空闲");
assert!(!is_peak_hour(&beijing(23, 0)), "北京 23:00 应空闲");
}

#[test]
fn effective_prices_selects_peak_or_falls_back() {
// 空闲时段 → 一律空闲价
assert_eq!(
effective_prices(false, 1.5, 0.05, 4.5, 3.0, 0.10, 9.0),
(1.5, 0.05, 4.5)
);
// 高峰时段 + 全量高峰价 → 用高峰价
assert_eq!(
effective_prices(true, 1.5, 0.05, 4.5, 3.0, 0.10, 9.0),
(3.0, 0.10, 9.0)
);
// 高峰时段 + 高峰价缺省 0 → 沿用空闲价(不启用高峰计费的模型行为不变)
assert_eq!(
effective_prices(true, 1.5, 0.05, 4.5, 0.0, 0.0, 0.0),
(1.5, 0.05, 4.5)
);
// 高峰时段 + 部分缺省 → 缺省项沿用空闲价
assert_eq!(
effective_prices(true, 1.5, 0.05, 4.5, 3.0, 0.0, 9.0),
(3.0, 0.05, 9.0)
);
}

#[test]
fn calc_points_uses_peak_price_in_peak_hour() {
// 高峰 1M 输入 × 3.0 CNY/M = 3.0 CNY → USD 锚定 ÷7.2 ×1000 = 416.67 点;空闲 1.5 → 208.33 点
let peak = effective_prices(true, 1.5, 0.05, 4.5, 3.0, 0.10, 9.0);
let off = effective_prices(false, 1.5, 0.05, 4.5, 3.0, 0.10, 9.0);
let pts_peak = calc_points(
1_000_000.0,
0.0,
0.0,
peak.0,
peak.1,
peak.2,
1000,
"CNY",
"USD",
);
let pts_off = calc_points(
1_000_000.0,
0.0,
0.0,
off.0,
off.1,
off.2,
1000,
"CNY",
"USD",
);
assert!(
(pts_peak - 3.0 / 7.2 * 1000.0).abs() < 1e-9,
"高峰 1M 输入 = 416.67 点: {pts_peak}"
);
assert!(
(pts_off - 1.5 / 7.2 * 1000.0).abs() < 1e-9,
"空闲 1M 输入 = 208.33 点: {pts_off}"
);
// 未配置高峰价 → 高峰/空闲点数一致
let no_peak = effective_prices(true, 1.5, 0.05, 4.5, 0.0, 0.0, 0.0);
let pts_no = calc_points(
1_000_000.0,
0.0,
0.0,
no_peak.0,
no_peak.1,
no_peak.2,
1000,
"CNY",
"USD",
);
assert!(
(pts_no - 1.5 / 7.2 * 1000.0).abs() < 1e-9,
"未配置高峰价 → 208.33 点: {pts_no}"
);
}

#[test]
fn settle_splits_90_10_and_writes_ledger() {
let (mut conn, p) = tmp_db("settle");
Expand Down
19 changes: 19 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,16 @@ pub struct Model {
pub cache_hit_input_per_m: f64,
/// 每百万 tokens 输出价
pub output_per_m: f64,
/// 高峰时段输入价(rant 2026-08-20T11:58:40:DeepSeek 高峰 9-12/14-18 北京时翻倍;
/// 缺省 0 = 不启用高峰计费,沿用 input_per_m)
#[serde(default)]
pub peak_input_per_m: f64,
/// 高峰时段缓存命中输入价(缺省 0)
#[serde(default)]
pub peak_cache_hit_input_per_m: f64,
/// 高峰时段输出价(缺省 0)
#[serde(default)]
pub peak_output_per_m: f64,
#[serde(default)]
pub context_length: i64,
#[serde(default)]
Expand Down Expand Up @@ -328,13 +338,22 @@ mod tests {
assert_eq!(dv.input_per_m, 4.5);
assert_eq!(dv.cache_hit_input_per_m, 0.15);
assert_eq!(dv.currency, "CNY");
// 高峰价(rant 2026-08-20T11:58:40):pro 9.0 / 0.30 / 27.0
assert_eq!(dv.peak_input_per_m, 9.0);
assert_eq!(dv.peak_cache_hit_input_per_m, 0.30);
assert_eq!(dv.peak_output_per_m, 27.0);
let flash = cfg
.models
.iter()
.find(|m| m.model == "deepseek-v4-flash")
.unwrap();
assert_eq!(flash.input_per_m, 1.5);
assert_eq!(flash.cache_hit_input_per_m, 0.05);
assert_eq!(flash.peak_input_per_m, 3.0);
assert_eq!(flash.peak_output_per_m, 9.0);
// 未配置高峰价的模型 → 缺省 0(不启用高峰计费)
let zhipu = cfg.models.iter().find(|m| m.provider == "zhipu").unwrap();
assert_eq!(zhipu.peak_input_per_m, 0.0, "无高峰价字段 → 缺省 0");
// server 默认值
assert_eq!(cfg.server.addr, "0.0.0.0:8080");
assert_eq!(cfg.server.db_path, "data/aitokenpool.db");
Expand Down
Loading
Loading