diff --git a/Cargo.lock b/Cargo.lock index fd6a206..bfef15b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -60,7 +60,7 @@ dependencies = [ [[package]] name = "aitokenpool" -version = "0.3.3" +version = "0.3.4" dependencies = [ "aes-gcm", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 8d9ce1c..a19b9d1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "aitokenpool" -version = "0.3.3" +version = "0.3.4" edition = "2021" description = "AI Token 共享池 — 企业 key 池 + 公共共享市场" license = "MIT" diff --git a/README.md b/README.md index 37285ba..fc541e4 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,10 @@ cp config/config.example.toml config/config.toml # 首次 cargo run # http://localhost:8080/ ``` +> ⚠️ **首次启动即应配置主密钥**(用于加密上游 key):`export ATP_MASTER_KEY=$(openssl rand -hex 32)` +> (或取消 config/config.toml `[server].master_key` 注释)。未配置时使用随机 dev 密钥, +> **重启后已上架的 key 密文无法解密 → 全部 503**(rant 2026-08-18T16:14:21 Bug 3)。 + ### ② Docker 部署 ```bash diff --git a/config/config.example.toml b/config/config.example.toml index 7726421..1b852b3 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -13,7 +13,8 @@ addr = "0.0.0.0:8080" db_path = "data/aitokenpool.db" # 上游 key 主密钥(P0-C 起):hex 32 字节;env ATP_MASTER_KEY 优先级更高。 -# 生产必须显式配置,否则使用随机 dev 密钥(重启后旧密文不可解)。 +# ⚠️ 生产必须显式配置(env 或此处),否则使用随机 dev 密钥 —— 重启后旧密文不可解, +# 所有已上架 key 将解密失败(503)。生成:openssl rand -hex 32 # master_key = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" # ============================================================ diff --git a/src/config.rs b/src/config.rs index c16fb94..7324391 100644 --- a/src/config.rs +++ b/src/config.rs @@ -80,6 +80,9 @@ pub struct Provider { pub struct Plan { pub id: String, pub provider: String, + /// 显示名(可选;为空时 /api/plans 按 type 推导) + #[serde(default)] + pub name: String, /// paygo | token | coding #[serde(rename = "type")] pub type_: String, diff --git a/src/db.rs b/src/db.rs index 6076fdc..32a915e 100644 --- a/src/db.rs +++ b/src/db.rs @@ -269,11 +269,16 @@ pub fn seed(conn: &Connection) -> Result<()> { [demo_id], )?; // 示例上游 key(不真实可用的占位:占位密钥 + deepseek paygo plan) - conn.execute( - "INSERT OR IGNORE INTO keys (provider, plan, model, status, owner_id, encrypted_key, quota, used) \ - VALUES ('deepseek', 'deepseek-paygo', 'deepseek-v4-flash', 'on', ?1, 'sk-placeholder-encrypted', 1000, 0)", - [demo_id], - )?; + // 幂等:仅当 keys 表为空时插入一次(否则每次启动都会新增一条脏占位 key, + // 干扰路由随机选择;rant 2026-08-18T16:14:21 Bug 2) + let key_count: i64 = conn.query_row("SELECT COUNT(*) FROM keys", [], |r| r.get(0))?; + if key_count == 0 { + conn.execute( + "INSERT INTO keys (provider, plan, model, status, owner_id, encrypted_key, quota, used) \ + VALUES ('deepseek', 'deepseek-paygo', 'deepseek-v4-flash', 'on', ?1, 'sk-placeholder-encrypted', 1000, 0)", + [demo_id], + )?; + } // 管理员账号(P1):admin@aitokenpool.local / admin1234,role=admin let admin_id: Option = conn @@ -427,6 +432,25 @@ mod tests { let _ = std::fs::remove_file(p); } + #[test] + fn seed_placeholder_key_is_idempotent() { + // rant 2026-08-18T16:14:21 Bug 2:占位 key 只允许在 keys 表为空时插入一次, + // 连续 seed(模拟多次启动)keys 数量不增长。 + let p = std::env::temp_dir().join(format!("atp_test_seed_idem_{}.db", std::process::id())); + let _ = std::fs::remove_file(&p); + let conn = Connection::open(&p).expect("open raw db"); + migrate(&conn).expect("migrate"); + seed(&conn).expect("seed 1st"); + seed(&conn).expect("seed 2nd"); + seed(&conn).expect("seed 3rd"); + let after: i64 = conn + .query_row("SELECT COUNT(*) FROM keys", [], |r| r.get(0)) + .unwrap(); + assert_eq!(after, 1, "连续 seed 3 次 keys 仍只有 1 条占位 key"); + drop(conn); + let _ = std::fs::remove_file(p); + } + #[test] fn seed_models_upserts_from_example_and_overrides() { let (conn, p) = tmp_db("models"); diff --git a/src/gateway.rs b/src/gateway.rs index 6e606da..8930c64 100644 --- a/src/gateway.rs +++ b/src/gateway.rs @@ -542,6 +542,44 @@ pub async fn models( Ok(Json(list)) } +/// GET /api/plans(上架表单数据源:config [[plans]] 单一真源,需认证) +/// 返回 id / provider / name(config 无 name 时按 type 推导显示名)/ type / key_prefix / endpoints。 +pub async fn plans( + State(st): State, + _auth: AuthUser, +) -> Result>, ApiErr> { + let list: Vec = st + .cfg + .plans + .iter() + .map(|p| { + let name = if p.name.is_empty() { + match p.type_.as_str() { + "paygo" => "API(按量)".to_string(), + "token" => "Token Plan".to_string(), + "coding" => "Coding Plan".to_string(), + _ => p.id.clone(), + } + } else { + p.name.clone() + }; + serde_json::json!({ + "id": p.id, + "provider": p.provider, + "name": name, + "type": p.type_, + "key_prefix": p.key_prefix, + "interactive_only": p.interactive_only, + "endpoints": p.endpoints.iter().map(|e| serde_json::json!({ + "protocol": e.protocol, + "base_url": e.base_url, + })).collect::>(), + }) + }) + .collect(); + Ok(Json(list)) +} + #[cfg(test)] mod tests { use super::*; @@ -567,6 +605,7 @@ mod tests { cfg.plans.push(crate::config::Plan { id: plan_id.to_string(), provider: "test".to_string(), + name: String::new(), type_: "paygo".to_string(), key_prefix: "sk-".to_string(), interactive_only: false, @@ -881,6 +920,64 @@ mod tests { assert_eq!(resp2.status(), StatusCode::UNAUTHORIZED); } + #[tokio::test] + async fn plans_endpoint_lists_config_plans() { + // rant 2026-08-18T16:14:21 Bug 1:上架表单 Plan 数据源 = config [[plans]](单一真源) + let st = test_state("plans", "test-plan", "http://127.0.0.1:9"); + let key = login_key(st.clone()).await; + let resp = router() + .with_state(st.clone()) + .oneshot( + Request::builder() + .method("GET") + .uri("/api/plans") + .header("authorization", format!("Bearer {key}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let bytes = axum::body::to_bytes(resp.into_body(), 4 * 1024 * 1024) + .await + .unwrap(); + let arr: Vec = serde_json::from_slice(&bytes).unwrap(); + // config.example.toml 的 [[plans]] 至少包含 deepseek-paygo / zhipu-coding / zhipu-paygo + let ids: Vec<&str> = arr + .iter() + .filter_map(|p| p.get("id").and_then(|v| v.as_str())) + .collect(); + assert!( + ids.contains(&"deepseek-paygo"), + "plans 来自 config [[plans]],包含 deepseek-paygo,got {ids:?}" + ); + assert!(ids.contains(&"zhipu-coding")); + assert!(ids.contains(&"zhipu-paygo")); + // 字段结构:id / provider / name / type / key_prefix / endpoints + let dp = arr + .iter() + .find(|p| p.get("id") == Some(&serde_json::json!("deepseek-paygo"))) + .unwrap(); + assert_eq!(dp["provider"], "deepseek"); + assert_eq!(dp["type"], "paygo"); + assert_eq!(dp["key_prefix"], "sk-"); + assert_eq!(dp["name"], "API(按量)"); + assert!(dp["endpoints"].is_array() && !dp["endpoints"].as_array().unwrap().is_empty()); + // 无认证 → 401 + let resp2 = router() + .with_state(st) + .oneshot( + Request::builder() + .method("GET") + .uri("/api/plans") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp2.status(), StatusCode::UNAUTHORIZED); + } + /// 假上游:SSE 流式(openai 风格,尾部带 usage + [DONE]) async fn fake_sse_upstream(listener: tokio::net::TcpListener) { let app = axum::Router::new().route( diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 353d5c1..03202e7 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -174,6 +174,7 @@ pub fn router() -> Router { .route("/v1/chat/completions", post(gateway::chat_completions)) .route("/anthropic/v1/messages", post(gateway::anthropic_messages)) .route("/api/models", get(gateway::models)) + .route("/api/plans", get(gateway::plans)) // P0-C:共享 / 钱包 / 交易 / 仪表盘 .route("/api/sharings", post(sharing::create).get(sharing::list)) .route("/api/sharings/:id", axum::routing::patch(sharing::patch)) diff --git a/ui/js/app.js b/ui/js/app.js index 518bc55..f40eb91 100644 --- a/ui/js/app.js +++ b/ui/js/app.js @@ -371,13 +371,14 @@ } } - // Plan 提示:按量/订阅 + key 前缀 + 专属端点说明(来自 PLANS) + // Plan 提示:按量/订阅 + key 前缀 + 专属端点说明(来自 PLANS;登录后为 /api/plans) function showPlanHint(planId) { const el = $("#sf-plan-hint"); if (!el) return; - const pl = D.PLANS.find((x) => x.id === planId); + const plans = Live.plans || D.PLANS; + const pl = plans.find((x) => x.id === planId); if (!pl) { el.textContent = ""; return; } - el.textContent = (pl.type === "api" ? "按量计价的 key" : "订阅 Plan") + + el.textContent = (pl.type === "paygo" ? "按量计价的 key" : "订阅 Plan") + (pl.keyPrefix ? " · 建议 key 前缀 " + pl.keyPrefix : "") + (pl.note ? " · " + pl.note : ""); } @@ -727,13 +728,15 @@ // 表单下拉(厂商 → Plan → 模型 三级联动;Plan 中「API」= 按量计价的 key) const selP = $("#sf-provider"); if (!selP.dataset.init) { - const planProviders = [...new Set(D.PLANS.map((pl) => pl.provider))]; + // Bug 1 修复:优先用 /api/plans(后端 config [[plans]]),未登录/失败降级 data.js 对齐清单 + const plans = Live.plans || D.PLANS; + const planProviders = [...new Set(plans.map((pl) => pl.provider))]; selP.innerHTML = '' + planProviders .map((p) => '").join(""); const selPlan = $("#sf-plan"); const selM = $("#sf-model"); const fillModels = () => { - const plan = D.PLANS.find((pl) => pl.id === selPlan.value); + const plan = plans.find((pl) => pl.id === selPlan.value); const p = plan ? plan.provider : selP.value; selM.innerHTML = '' + D.MODELS.filter((m) => !p || m.provider === p) .map((m) => '").join(""); @@ -741,7 +744,7 @@ }; const fillPlans = () => { const p = selP.value; - selPlan.innerHTML = '' + D.PLANS.filter((pl) => pl.provider === p) + selPlan.innerHTML = '' + plans.filter((pl) => pl.provider === p) .map((pl) => '").join(""); showPlanHint(""); fillModels(); @@ -776,6 +779,10 @@ try { await liveLoad("sharings", "/api/sharings"); } catch (e) { /* 降级 mock */ } + try { + // Bug 1 修复:上架表单 Plan 数据源改真实后端(config [[plans]] 单一真源) + await liveLoad("plans", "/api/plans"); + } catch (e) { Live.plans = null; /* 降级 data.js 对齐清单 */ } renderSharing(); } @@ -2188,6 +2195,7 @@ // 各视图真实数据缓存:登录且加载成功后使用;游客 / 失败降级 mock const Live = { models: null, // GET /api/models 原始数组 + plans: null, // GET /api/plans 原始数组(上架表单数据源;rant 16:14:21 Bug 1) sharings: null, // GET /api/sharings 原始数组 transactions: null, // GET /api/transactions → {items,total,...} wallet: null, // GET /api/wallet @@ -2451,7 +2459,7 @@ const done = () => { const model = $("#sf-model").value; const planId = $("#sf-plan").value; - const plan = D.PLANS.find((pl) => pl.id === planId); + const plan = (Live.plans || D.PLANS).find((pl) => pl.id === planId); const quota = Number($("#sf-quota").value || 0); const key = $("#sf-key").value.trim(); const note = $("#sf-note").value.trim(); diff --git a/ui/js/data.js b/ui/js/data.js index 426756d..a24fd49 100644 --- a/ui/js/data.js +++ b/ui/js/data.js @@ -43,22 +43,23 @@ PROVIDERS: [...new Set(MODELS.map((m) => m.provider))], - // Plan 清单(内置国内已知 Plan,源自 docs/plan-api-matrix.md 与 config/config.example.toml) - // type: api=按量计价 key | coding=编码订阅 | token=统一计量订阅(Credits) - // 每个厂商至少含一个 type=api 的「API(按量)」plan;models 按 provider 关联 MODELS + // Plan 清单(与 config/config.example.toml 的 [[plans]] id 对齐 —— 上架表单 payload.plan + // 直接提交给后端,id 必须一致;rant 2026-08-18T16:14:21 Bug 1) + // type: paygo=按量计价 key | coding=编码订阅 | token=统一计量订阅(Credits) + // 登录后 renderSharing 优先用 GET /api/plans 覆盖此清单(后端单一真源) PLANS: [ - { id: "aliyun-api", provider: "aliyun-bailian", name: "API(按量)", type: "api", keyPrefix: "sk-", note: "按量计价" }, - { id: "aliyun-token", provider: "aliyun-bailian", name: "Token Plan", type: "token", keyPrefix: "sk-sp-", note: "Credits 统一计量,专属 key sk-sp-,多工具通用" }, - { id: "zhipu-api", provider: "zhipu", name: "API(按量)", type: "api", keyPrefix: "sk-", note: "open.bigmodel.cn 按量" }, - { id: "zhipu-coding", provider: "zhipu", name: "GLM Coding Plan", type: "coding", keyPrefix: "sk-", note: "专属端点 /api/coding/paas/v4、/api/anthropic" }, - { id: "ark-api", provider: "bytedance-ark", name: "API(按量)", type: "api", keyPrefix: "sk-", note: "按量计价" }, - { id: "ark-coding", provider: "bytedance-ark", name: "Coding Plan", type: "coding", keyPrefix: "sk-", note: "/api/coding/v3、/api/coding" }, - { id: "ark-agent", provider: "bytedance-ark", name: "Agent Plan", type: "token", keyPrefix: "sk-", note: "/api/plan/v3、/api/plan" }, - { id: "kimi-api", provider: "moonshot", name: "API(按量)", type: "api", keyPrefix: "sk-", note: "api.kimi.com 按量" }, - { id: "kimi-code", provider: "moonshot", name: "Kimi Code 会员", type: "coding", keyPrefix: "sk-", note: "api.kimi.com/coding/v1、/coding" }, - { id: "minimax-api", provider: "minimax", name: "API(按量)", type: "api", keyPrefix: "sk-", note: "按量计价" }, - { id: "minimax-coding",provider: "minimax", name: "Coding Plan", type: "coding", keyPrefix: "sk-cp-", note: "专属 key sk-cp-,/anthropic、/v1" }, - { id: "deepseek-api", provider: "deepseek", name: "API(按量)", type: "api", keyPrefix: "sk-", note: "无订阅 plan,仅按量;OpenAI/Anthropic/Responses 端点" }, + { id: "deepseek-paygo", provider: "deepseek", name: "API(按量)", type: "paygo", keyPrefix: "sk-", note: "无订阅 plan,仅按量;OpenAI/Anthropic/Responses 端点" }, + { id: "zhipu-coding", provider: "zhipu", name: "GLM Coding Plan", type: "coding", keyPrefix: "", note: "专属端点 /api/coding/paas/v4、/api/anthropic" }, + { id: "zhipu-paygo", provider: "zhipu", name: "API(按量)", type: "paygo", keyPrefix: "sk-", note: "open.bigmodel.cn 按量" }, + { id: "aliyun-token-plan", provider: "aliyun-bailian", name: "Token Plan", type: "token", keyPrefix: "sk-sp-", note: "Credits 统一计量,专属 key sk-sp-,多工具通用" }, + { id: "aliyun-bailian-paygo",provider: "aliyun-bailian", name: "API(按量)", type: "paygo", keyPrefix: "sk-", note: "dashscope 兼容模式按量" }, + { id: "ark-coding", provider: "bytedance-ark", name: "Coding Plan", type: "coding", keyPrefix: "", note: "/api/coding/v3、/api/coding" }, + { id: "ark-agent", provider: "bytedance-ark", name: "Agent Plan", type: "token", keyPrefix: "", note: "/api/plan/v3、/api/plan" }, + { id: "ark-paygo", provider: "bytedance-ark", name: "API(按量)", type: "paygo", keyPrefix: "sk-", note: "按量计价" }, + { id: "kimi-code", provider: "moonshot", name: "Kimi Code 会员", type: "coding", keyPrefix: "", note: "api.kimi.com/coding/v1、/coding" }, + { id: "moonshot-paygo", provider: "moonshot", name: "API(按量)", type: "paygo", keyPrefix: "sk-", note: "api.moonshot.cn 按量" }, + { id: "minimax-coding", provider: "minimax", name: "Coding Plan", type: "coding", keyPrefix: "sk-cp-", note: "专属 key sk-cp-,/anthropic、/v1" }, + { id: "minimax-paygo", provider: "minimax", name: "API(按量)", type: "paygo", keyPrefix: "sk-", note: "按量计价" }, ], // 厂商显示名(上架表单 / 共享列表)