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
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "aitokenpool"
version = "0.3.3"
version = "0.3.4"
edition = "2021"
description = "AI Token 共享池 — 企业 key 池 + 公共共享市场"
license = "MIT"
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion config/config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

# ============================================================
Expand Down
3 changes: 3 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
34 changes: 29 additions & 5 deletions src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<i64> = conn
Expand Down Expand Up @@ -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");
Expand Down
97 changes: 97 additions & 0 deletions src/gateway.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AppState>,
_auth: AuthUser,
) -> Result<Json<Vec<serde_json::Value>>, ApiErr> {
let list: Vec<serde_json::Value> = 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::<Vec<_>>(),
})
})
.collect();
Ok(Json(list))
}

#[cfg(test)]
mod tests {
use super::*;
Expand All @@ -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,
Expand Down Expand Up @@ -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::Value> = 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(
Expand Down
1 change: 1 addition & 0 deletions src/routes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ pub fn router() -> Router<AppState> {
.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))
Expand Down
22 changes: 15 additions & 7 deletions ui/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 : "");
}
Expand Down Expand Up @@ -727,21 +728,23 @@
// 表单下拉(厂商 → 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 = '<option value="">选择厂商</option>' + planProviders
.map((p) => '<option value="' + p + '">' + (D.PROVIDER_LABELS[p] || p) + "</option>").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 = '<option value="">选择模型</option>' + D.MODELS.filter((m) => !p || m.provider === p)
.map((m) => '<option value="' + m.model + '">' + m.model + "</option>").join("");
showPriceHint(selM.value);
};
const fillPlans = () => {
const p = selP.value;
selPlan.innerHTML = '<option value="">选择 Plan</option>' + D.PLANS.filter((pl) => pl.provider === p)
selPlan.innerHTML = '<option value="">选择 Plan</option>' + plans.filter((pl) => pl.provider === p)
.map((pl) => '<option value="' + pl.id + '">' + esc(pl.name) + "</option>").join("");
showPlanHint("");
fillModels();
Expand Down Expand Up @@ -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();
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down
31 changes: 16 additions & 15 deletions ui/js/data.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: "按量计价" },
],

// 厂商显示名(上架表单 / 共享列表)
Expand Down
Loading