From eede0f0966525a33313a28afe3d4f2e2e9fb20cd Mon Sep 17 00:00:00 2001 From: argszero Date: Sun, 13 Sep 2026 08:08:09 +0800 Subject: [PATCH] fix(admin): reject negative cache_hit_input_per_m in the model validator validate_common checked 5 of the model's 6 price fields for non-negativity but skipped cache_hit_input_per_m (the non-peak sibling of the peak variant it does check), so POST/PATCH /api/admin/models accepted a negative cache-hit price. That value reaches billing::raw_cost unclamped, and billing::settle does balance - pts, so a negative pts credits the consumer and makes the sharing owner's earn negative. Add the missing check so all six price fields follow one rule. --- src/routes/admin_models.rs | 5 +++ src/routes/mod.rs | 66 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/src/routes/admin_models.rs b/src/routes/admin_models.rs index 16d9220..9409a10 100644 --- a/src/routes/admin_models.rs +++ b/src/routes/admin_models.rs @@ -69,11 +69,13 @@ pub struct ModelPatch { } /// 校验通用字段:价格 ≥ 0、vision ∈ {0,1}、currency 枚举 +#[allow(clippy::too_many_arguments)] // 价格字段平铺(6 个价格 + currency + vision),保持与请求结构体同序 fn validate_common( currency: &str, input_per_m: f64, output_per_m: f64, vision: i64, + cache_hit_input_per_m: f64, peak_input_per_m: f64, peak_output_per_m: f64, peak_cache_hit_input_per_m: f64, @@ -86,6 +88,7 @@ fn validate_common( } if input_per_m < 0.0 || output_per_m < 0.0 + || cache_hit_input_per_m < 0.0 || peak_input_per_m < 0.0 || peak_output_per_m < 0.0 || peak_cache_hit_input_per_m < 0.0 @@ -162,6 +165,7 @@ pub async fn create( req.input_per_m, req.output_per_m, req.vision, + req.cache_hit_input_per_m, req.peak_input_per_m, req.peak_output_per_m, req.peak_cache_hit_input_per_m, @@ -297,6 +301,7 @@ pub async fn patch( input_per_m, output_per_m, vision, + cache_hit_input_per_m, peak_input_per_m, peak_output_per_m, peak_cache_hit_input_per_m, diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 5b99035..599271e 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -1087,6 +1087,72 @@ mod tests { assert_eq!(s, StatusCode::NOT_FOUND, "重复删除应 404"); } + #[tokio::test] + async fn admin_models_rejects_negative_cache_hit_price() { + // 负数缓存命中价会一路无钳制进入结算(pts 变负 ⇒ 消费者被充值、分享者被倒扣), + // 故必须与其余 5 个价格字段一样在入口被拒。修复前 CREATE/PATCH 均返回 200 并落库。 + let st = test_state("admnegcache"); + let admin_bearer = login_bearer(&st, "admin@aitokenpool.local", "admin1234").await; + + // CREATE:负数 cache_hit_input_per_m → 400 + let (s, body) = post( + st.clone(), + "/api/admin/models", + r#"{"provider":"neg","model":"neg-cache","currency":"USD","input_per_m":1.0,"output_per_m":2.0,"cache_hit_input_per_m":-3.0}"#, + Some(&admin_bearer), + ) + .await; + assert_eq!(s, StatusCode::BAD_REQUEST, "负数缓存命中价应 400: {body}"); + + // 阳性对照:0 是合法值(= 命中部分免费),必须放行 + let (s, body) = post( + st.clone(), + "/api/admin/models", + r#"{"provider":"neg","model":"zero-cache","currency":"USD","input_per_m":1.0,"output_per_m":2.0,"cache_hit_input_per_m":0.0}"#, + Some(&admin_bearer), + ) + .await; + assert_eq!(s, StatusCode::OK, "0 缓存命中价应 200: {body}"); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + let new_id = v["id"].as_i64().unwrap(); + + // PATCH:改成负数 → 400,且库中该值不被写入 + let (s, body) = patch( + st.clone(), + &format!("/api/admin/models/{new_id}"), + r#"{"cache_hit_input_per_m":-9.0}"#, + Some(&admin_bearer), + ) + .await; + assert_eq!( + s, + StatusCode::BAD_REQUEST, + "PATCH 负数缓存命中价应 400: {body}" + ); + let (_, body) = get(st.clone(), "/api/admin/models", Some(&admin_bearer)).await; + let arr: serde_json::Value = serde_json::from_str(&body).unwrap(); + let row = arr + .as_array() + .unwrap() + .iter() + .find(|m| m["id"] == new_id) + .expect("模型仍在"); + assert_eq!( + row["cache_hit_input_per_m"], 0.0, + "被拒的 PATCH 不得改库: {body}" + ); + + // 兄弟字段(高峰变体)此前已被校验,须保持 400 + let (s, _) = post( + st.clone(), + "/api/admin/models", + r#"{"provider":"neg","model":"neg-peak","currency":"USD","input_per_m":1.0,"peak_cache_hit_input_per_m":-3.0}"#, + Some(&admin_bearer), + ) + .await; + assert_eq!(s, StatusCode::BAD_REQUEST, "高峰缓存命中价负数仍应 400"); + } + #[tokio::test] async fn market_models_include_new_fields() { // GET /api/models 响应补 context_length/max_output/vision/cache_hit_input_per_m