From 0e44e70df9aa0ef4e414cdf52a11dd3e475925ef Mon Sep 17 00:00:00 2001 From: argszero Date: Sun, 13 Sep 2026 14:26:24 +0800 Subject: [PATCH] fix(sharing): reject an onboarding pair the platform cannot price MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/sharings stored whatever `provider` / `model` the caller sent, and every call is then priced with `dao::get_model_price(&conn, &key.provider, model)`. When no `models` row matches, `settle_usage` falls back to `(0.0, 0.0)`: the gateway still serves the request (200, the upstream tokens are really spent) but the consumer is not charged, the sharer earns nothing and `usage_records.cost` stays 0 — a silently free call. Validate the pair against the `models` table before the INSERT and answer 400 when no priceable row exists, so an unpriceable key can never be onboarded. The check is the `models` row, not membership in `[[providers]]`: openai / anthropic / google / xai only exist in `[[models]]`, so a providers-table check would reject legitimate keys. Zero-pricing for a key whose model is deleted later is unchanged — that is the documented behaviour, not this path. Tests: a provider outside the catalog -> 400, a mistyped model -> 400, neither row is stored (the check runs before the INSERT), and a catalog pair -> 200 as the positive control. --- src/routes/sharing.rs | 75 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/src/routes/sharing.rs b/src/routes/sharing.rs index 980c06e..8696eba 100644 --- a/src/routes/sharing.rs +++ b/src/routes/sharing.rs @@ -91,6 +91,23 @@ pub async fn create( None => (String::new(), String::new(), String::new()), }; let conn = st.db.lock().map_err(|_| internal("db lock poisoned"))?; + // 只能上架平台**能计价**的 (provider, model):计费按 `keys.provider` + model 查 `models` 行的价 + // (`dao::get_model_price`:`WHERE provider = ?1 AND model = ?2`),查不到即 0 计费 —— + // 一次真实调用会**静默变成免费**(消费者不扣点、分享者无收益)。校验对象是 `models` 行而不是 + // `[[providers]]` 表:openai / anthropic / google / xai 只有 `[[models]]` 行、没有 provider 行。 + if conn + .query_row( + "SELECT 1 FROM models WHERE provider = ?1 AND model = ?2", + params![req.provider, req.model], + |_| Ok(()), + ) + .is_err() + { + return Err(( + axum::http::StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "provider 与 model 不在模型目录中,无法计价" })), + )); + } conn.execute( "INSERT INTO keys (provider, plan, model, status, owner_id, encrypted_key, quota, available_days, available_start, available_end, note) \ VALUES (?1, ?2, ?3, 'on', ?4, ?5, ?6, ?7, ?8, ?9, ?10)", @@ -556,4 +573,62 @@ mod tests { let v: serde_json::Value = serde_json::from_str(&body).unwrap(); assert_eq!(v["key"], "aa-****中中中中", "非 ASCII key 应正常脱敏"); } + + /// 上架只接受平台**能计价**的 (provider, model)(C2065)。 + /// + /// 计费按 `keys.provider` + model 查 `models` 行的价(`dao::get_model_price`),查不到 + /// `settle_usage` 取 `None => (0.0, 0.0)` ⇒ 一次真实调用会**静默变成免费**:消费者不扣点、 + /// 分享者无收益、`usage_records.cost=0`,而调用本身 200 正常返回。故入口拒绝。 + /// + /// 本用例锁三件事:① 目录外的 provider → 400;② 同 provider 下拼错的 model → 400; + /// ③ **400 时不得落库**(校验必须在 `INSERT` 之前);④ 阳性对照:目录中的组合 → 200 且落库 + /// (证明上面的 400 来自这一条规则,不是「上架坏了」)。 + #[tokio::test] + async fn create_rejects_unpriceable_provider_model() { + let st = test_state("unpriceable"); + let key = login(st.clone()).await; + let count = |st: &crate::routes::AppState| -> i64 { + let conn = st.db.lock().unwrap(); + conn.query_row("SELECT COUNT(*) FROM keys", [], |r| r.get(0)) + .unwrap() + }; + let before = count(&st); + + // ① provider 不在模型目录(model 是真实存在的) + let (s, body) = send( + st.clone(), + "POST", + "/api/sharings", + Some(r#"{"provider":"WRONG","plan":"deepseek-paygo","model":"deepseek-v4-flash","key":"sk-wrong1234","quota":100}"#), + &key, + ) + .await; + assert_eq!(s, axum::http::StatusCode::BAD_REQUEST, "body: {body}"); + + // ② 同一 provider、model 拼错 + let (s, body) = send( + st.clone(), + "POST", + "/api/sharings", + Some(r#"{"provider":"deepseek","plan":"deepseek-paygo","model":"deepseek-v4-flsh","key":"sk-typo12345","quota":100}"#), + &key, + ) + .await; + assert_eq!(s, axum::http::StatusCode::BAD_REQUEST, "body: {body}"); + + // ③ 两次拒绝都不得写入(校验在 INSERT 之前) + assert_eq!(count(&st), before, "被拒绝的上架不得落库"); + + // ④ 阳性对照:模型目录中的组合 → 200 且落库 + let (s, body) = send( + st.clone(), + "POST", + "/api/sharings", + Some(r#"{"provider":"deepseek","plan":"deepseek-paygo","model":"deepseek-v4-flash","key":"sk-control1234","quota":100}"#), + &key, + ) + .await; + assert_eq!(s, axum::http::StatusCode::OK, "body: {body}"); + assert_eq!(count(&st), before + 1, "合法上架应落库一行"); + } }