diff --git a/config/config.example.toml b/config/config.example.toml index deb13e9..005c2f3 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -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 端点。 @@ -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 diff --git a/src/billing.rs b/src/billing.rs index fb384ba..561b30e 100644 --- a/src/billing.rs +++ b/src/billing.rs @@ -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) -> 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, 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) } @@ -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 { + // 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 { 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] diff --git a/src/config.rs b/src/config.rs index 78238f2..5e8703e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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, +} + /// 顶层配置 #[derive(Debug, Clone, Deserialize)] pub struct Config { @@ -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, pub plans: Vec, @@ -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::("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 解耦) diff --git a/src/gateway.rs b/src/gateway.rs index 9df2092..abe2aa9 100644 --- a/src/gateway.rs +++ b/src/gateway.rs @@ -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,