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
19 changes: 18 additions & 1 deletion config/config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,22 @@ points_per_unit = 1
display_name = "点数"
symbol = "P"

# ============================================================
# 1.1 计费规则(rant 2026-10-16T14:08:13):高峰时段判定里的「哪几天」
# DeepSeek 官方口径(api-docs.deepseek.com,2026-10-16 核实):
# 高峰 = 北京时间**周一至周五(不含中国法定节假日)** 9:00-12:00、14:00-18:00;
# 其余时段 —— **包括周末及中国法定节假日全天** —— 均为空闲时段
# (空闲价 = 高峰价的一半)。
# 周末由代码判定;法定节假日靠下表(全仓没有内置日历,也不按「调休上班」反推)。
# 表按官方年度放假安排维护(国务院办公厅每年发布),日期为北京时间的 YYYY-MM-DD。
# ⚠️ 未列出即不豁免:跨年忘记更新只会少豁免几天,不会算错价、不会启动失败;
# 写错格式(不是日期)在启动解析配置时报错。
# ============================================================
[billing]
# 法定节假日(北京时间的日期);留空 = 只豁免周末。
holidays = []
# 例:holidays = ["2026-01-01", "2026-05-01"]

# ============================================================
# 2. 提供商(providers)
# 一个 provider = 一家模型厂商,可有多套 plan 端点。
Expand Down Expand Up @@ -270,7 +286,8 @@ endpoints = [
# cache_hit_input_per_m = 缓存命中输入价(缺省 0 = 命中免费)。
# 高峰时段价(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,周一至周日)时按高峰价扣点。
# 命中高峰时段(北京时间 9-12、14-18,**周一至周五**且不在 [billing] holidays 内)
# 时按高峰价扣点(rant 2026-10-16T14:08:13:周末与法定节假日全天按空闲价)。
# DeepSeek 为官方价(2026-09-14 浏览器核实,https://api-docs.deepseek.com/zh-cn/quick_start/pricing):
# 空闲价即高峰价的一半,高峰 9-12/14-18 北京时翻倍(flash 2.0/0.04/8.0,pro 9.0/0.30/27.0 元/M)。
# 模型名同样以官方文档为准:V4.1-Flash 的模型名是 `deepseek-flash`(V4 Flash 与 V4 Flash-Vision-Exp
Expand Down
104 changes: 70 additions & 34 deletions src/billing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,22 @@ pub fn calc_points(
round5(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;
/// 北京时间高峰时段判定(rant 2026-08-20T11:58:40 的高峰窗 + rant 2026-10-16T14:08:13 的
/// 星期与节假日豁免):高峰 = **周一至周五(不含 `holidays` 列出的法定节假日)**
/// 的 9:00-12:00、14:00-18:00;其余时段(含周末与法定节假日**全天**)一律空闲。
/// 固定 Asia/Shanghai(UTC+8,无夏令时),不依赖服务器时区。
/// `holidays` 是**北京时间**日期(config `[billing]` holidays;空表 = 只豁免周末)。
pub fn is_peak_hour(utc: &chrono::DateTime<chrono::Utc>, holidays: &[chrono::NaiveDate]) -> bool {
use chrono::{Datelike, Timelike};
let sh = utc.with_timezone(&chrono::FixedOffset::east_opt(8 * 3600).expect("+08:00 合法"));
let date = sh.date_naive();
// 周末全天空闲:官方口径把「周末」整类列为空闲,故调休上班的周六周日也不按高峰计。
if matches!(date.weekday(), chrono::Weekday::Sat | chrono::Weekday::Sun) {
return false;
}
if holidays.contains(&date) {
return false;
}
let h = sh.hour();
(9..12).contains(&h) || (14..18).contains(&h)
}
Expand Down Expand Up @@ -295,39 +306,64 @@ mod tests {

#[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> {
// rant 2026-08-20T11:58:40:高峰 = 北京时 9:00-12:00、14:00-18:00。
// rant 2026-10-16T14:08:13:只有**周一至周五(不含法定节假日)**才可能是高峰 ——
// 周末与法定节假日全天空闲。
// 边界:8:59 vs 9:00、11:59 vs 12:00、13:59 vs 14:00、17:59 vs 18:00(北京时 = UTC+8)。
// 基准日 2026-08-17 是周一(2026-08-21 周五、08-22 周六、08-23 周日)。
fn beijing(y: i32, m: u32, d: u32, h: u32, mi: u32) -> chrono::DateTime<chrono::Utc> {
use chrono::TimeZone;
let dt = chrono::FixedOffset::east_opt(8 * 3600)
chrono::FixedOffset::east_opt(8 * 3600)
.unwrap()
.with_ymd_and_hms(2026, 8, 20, h, mi, 0)
.unwrap();
dt.with_timezone(&chrono::Utc)
.with_ymd_and_hms(y, m, d, h, mi, 0)
.unwrap()
.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 应空闲");
let mon = |h: u32, mi: u32| beijing(2026, 8, 17, h, mi);
// 周一:9:00 高峰开始 / 8:59 空闲
assert!(is_peak_hour(&mon(9, 0), &[]), "周一 9:00 应高峰");
assert!(!is_peak_hour(&mon(8, 59), &[]), "周一 8:59 应空闲");
// 周一:12:00 第一段高峰结束 / 11:59 仍是高峰
assert!(!is_peak_hour(&mon(12, 0), &[]), "周一 12:00 应空闲");
assert!(is_peak_hour(&mon(11, 59), &[]), "周一 11:59 应高峰");
// 周一:14:00 第二段高峰开始 / 13:59 空闲
assert!(is_peak_hour(&mon(14, 0), &[]), "周一 14:00 应高峰");
assert!(!is_peak_hour(&mon(13, 59), &[]), "周一 13:59 应空闲");
// 周一:18:00 第二段高峰结束 / 17:59 仍是高峰
assert!(!is_peak_hour(&mon(18, 0), &[]), "周一 18:00 应空闲");
assert!(is_peak_hour(&mon(17, 59), &[]), "周一 17:59 应高峰");
// 「周一至周五」的右边界:周五同样是高峰日
assert!(
is_peak_hour(&beijing(2026, 8, 21, 10, 0), &[]),
"周五 10:00 应高峰"
);
// 夜间空闲(高峰窗之外)
assert!(!is_peak_hour(&mon(0, 0), &[]), "周一 0:00 应空闲");
assert!(!is_peak_hour(&mon(23, 0), &[]), "周一 23:00 应空闲");
// 周末全天空闲(rant 2026-10-16T14:08:13):周六 / 周日即便落在 9-12 也不是高峰
assert!(
!is_peak_hour(&beijing(2026, 8, 22, 10, 0), &[]),
"周六 10:00 应空闲"
);
assert!(
!is_peak_hour(&beijing(2026, 8, 23, 10, 0), &[]),
"周日 10:00 应空闲"
);
// 法定节假日:豁免的是**列出来**的那一天,表外的邻居不受影响
let holidays = [chrono::NaiveDate::from_ymd_opt(2026, 8, 18).unwrap()];
assert!(
!is_peak_hour(&beijing(2026, 8, 18, 10, 0), &holidays),
"节假日 10:00 应空闲"
);
assert!(
is_peak_hour(&beijing(2026, 8, 19, 10, 0), &holidays),
"表外的工作日 10:00 应高峰"
);
// 空表 = 只豁免周末(运维没填 [billing] holidays 时的行为)
assert!(
is_peak_hour(&beijing(2026, 8, 18, 10, 0), &[]),
"空表下工作日 10:00 应高峰"
);
}

#[test]
Expand Down
39 changes: 39 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,19 @@ impl Default for Rollup {
}
}

/// 计费规则(rant 2026-10-16T14:08:13:高峰时段判定要看星期与法定节假日)
///
/// 只影响 `billing::is_peak_hour` 选高峰价还是空闲价:**周末**由代码判定,
/// **法定节假日**靠这张表(口径见 config.example.toml 的 `[billing]`)。
#[derive(Debug, Clone, Default, Deserialize)]
pub struct Billing {
/// 法定节假日(**北京时间**日期,`YYYY-MM-DD`):这些日子全天按空闲价计费。
/// 空表 = 只豁免周末。按官方年度放假安排维护(国务院办公厅每年发布);
/// 跨年忘记更新只是不豁免,不会算错价。写错格式(非日期)在启动解析时就报错。
#[serde(default)]
pub holidays: Vec<chrono::NaiveDate>,
}

/// 顶层配置
#[derive(Debug, Clone, Deserialize)]
pub struct Config {
Expand All @@ -340,6 +353,9 @@ pub struct Config {
/// 用量明细保留(rant 2026-10-09T12:28:58 的独立余项:第二张只增不减的明细表)
#[serde(default)]
pub usage_retention: UsageRetention,
/// 计费规则(rant 2026-10-16T14:08:13:高峰时段判定要看星期与法定节假日)
#[serde(default)]
pub billing: Billing,
pub points: Points,
pub providers: Vec<Provider>,
pub plans: Vec<Plan>,
Expand Down Expand Up @@ -682,6 +698,29 @@ mod tests {
assert_eq!(cfg.usage_retention.interval_secs, 60);
}

#[test]
fn billing_holidays_default_to_empty_and_parse_dates() {
// rant 2026-10-16T14:08:13:法定节假日表按**北京时间日期**(YYYY-MM-DD)填写。
let b: Billing = toml::from_str("holidays = [\"2026-10-01\"]").unwrap();
assert_eq!(
b.holidays,
vec![chrono::NaiveDate::from_ymd_opt(2026, 10, 1).unwrap()]
);
// 未配置 [billing] 段(或段内不写 holidays)⇒ 空表 = 只豁免周末
let none: Billing = toml::from_str("").unwrap();
assert!(none.holidays.is_empty(), "缺省即空表");
// 写错格式(不是日期)⇒ 启动解析即失败,不静默判成「不过节」
assert!(toml::from_str::<Billing>("holidays = [\"2026-13-99\"]").is_err());
}

#[test]
fn billing_example_section_matches_defaults() {
// config.example.toml 的 [billing] 段样例值 = 缺省值(节假日表由运维按官方年度安排填)
let cfg = Config::load("config/config.example.toml").unwrap();
assert_eq!(cfg.billing.holidays, Billing::default().holidays);
assert!(cfg.billing.holidays.is_empty());
}

#[test]
fn server_public_url_defaults_to_localhost() {
// 未配置 public_url 时缺省 http://localhost:8080(dev 默认,与 addr 解耦)
Expand Down
6 changes: 4 additions & 2 deletions src/gateway.rs
Original file line number Diff line number Diff line change
Expand Up @@ -183,8 +183,10 @@ fn settle_usage(
let (pts, cost) = match price {
Some((i_per_m, o_per_m, cache_hit_per_m, p_in, p_out, p_cache, currency)) => {
// rant 2026-08-20T11:58:40:DeepSeek 高峰时段(北京 9-12/14-18)价格翻倍——
// 按当前北京时间选高峰/空闲价;未配置高峰价的模型不受影响
let peak = billing::is_peak_hour(&chrono::Utc::now());
// 按当前北京时间选高峰/空闲价;未配置高峰价的模型不受影响。
// rant 2026-10-16T14:08:13:高峰只落在**周一至周五**,且不含 [billing] holidays
// 列出的法定节假日 —— 周末与节假日全天按空闲价。
let peak = billing::is_peak_hour(&chrono::Utc::now(), &st.cfg.billing.holidays);
let (i2, c2, o2) = billing::effective_prices(
peak,
i_per_m,
Expand Down
Loading