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.5.2"
version = "0.6.0"
edition = "2021"
description = "AI Token 共享池 — 企业 key 池 + 公共共享市场"
license = "MIT"
Expand Down
23 changes: 15 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ AITokenPool 是一个开源的 **AI Token 共享平台**:企业版(内部 ke

## 状态

后端 + 前端已全部实现,当前版本 **v0.5.2**(102/102 测试全绿):
后端 + 前端已全部实现,当前版本 **v0.6.0**(102/102 测试全绿):

- ✅ **P0-A(v0.2.0)**:后端骨架 + TOML 配置(`Config::validate`:points_per_unit>0 / plan→provider 存在 / endpoints≥1 / protocol 枚举)+ SQLite 数据层(幂等迁移 v4 + demo/admin/ops 种子)+ 认证(argon2 + Bearer API Key)+ API Key 端点
- ✅ **P0-A(v0.2.0)**:后端骨架 + TOML 配置(`Config::validate`:points_per_unit>0 / plan→provider 存在 / endpoints≥1 / protocol 枚举)+ SQLite 数据层(幂等迁移,**生产空库不种任何种子数据**)+ 认证(argon2 + Bearer API Key)+ API Key 端点
- ✅ **P0-B(v0.2.1)**:网关转发(OpenAI Chat Completions + Anthropic Messages)+ 路由故障转移(粘性 / 健康冷却 / 3 次切换上限)+ 计量账本(点数计算、90/10 分成、事务性 settle)
- ✅ **P0-C(v0.2.2)**:SSE 流式转发(流尾 usage 入账、断连不入账)+ 上游 key AES-256-GCM 加密 + 共享 / 钱包 / 交易 API
- ✅ **P1(v0.3.0)**:点数规则细化——新人每日赠送(10 天窗口·当日有效)+ 先扣最早到期赠送再扣永久 + 管理员充值 API(role=admin)
Expand All @@ -31,6 +31,7 @@ AITokenPool 是一个开源的 **AI Token 共享平台**:企业版(内部 ke
- ✅ **P3-B(v0.5.0)**:流式 SSE 跨协议转换——openai/anthropic/responses 任意协议的 `stream:true` 请求可转发到任意协议上游,响应流事件实时互转(openai delta ↔ anthropic content_block_delta ↔ responses output_text.delta,含工具调用/thinking 增量与流内 usage 计量;responses→anthropic 流式暂延后)
- ✅ **发布(v0.5.1)**:GitHub Actions Docker 发布——`main` push / `v*` tag 自动构建推送 GHCR 镜像 `ghcr.io/argszero/aitokenpool`(buildx + gha 缓存)
- ✅ **v0.5.2(bugfix)**:修复 3 个时间敏感测试——测试硬编码日期(2026-08-18)跨天后导致赠送过期断言失败,改为 SQLite 动态日期(`datetime('now')` / `strftime('%Y-%m-%d 23:59:59', 'now')`),测试不再随日期推移周期失败
- ✅ **v0.6.0**:**移除所有 demo 种子数据(rant 2026-08-19T10:41:03)**——首次部署 = 干净空库(只建表),不再预置 demo/admin/ops 假账号、假余额、占位 key;测试改用 `#[cfg(test)]` 专用 `seed_test_users` 辅助(生产构建不含);UI 登录页/设置页移除演示账号预填与提示

`ui/` 已由纯静态原型升级为**对接真实 API**(登录、钱包、市场、共享、交易、设置、管理、运营全部真实数据),由后端 `ServeDir` 静态托管,无需单独部署前端。

Expand Down Expand Up @@ -67,15 +68,21 @@ open http://localhost:8080/ # 浏览器访问

### ③ 登录账号

| 角色 | 账号 | 密码 | 说明 |
|------|------|------|------|
| 普通用户 | `demo@aitokenpool.local` | `demo1234` | 市场/共享/钱包/设置 |
| 管理员 | `admin@aitokenpool.local` | `admin1234` | + 管理视图(成员/充值/用量/部门/加额审批) |
| 运营者 | `ops@aitokenpool.local` | `ops1234` | + 运营视图(运行概览/成员充值) |
首次部署为**干净空库**(只建表,不预置任何账号)。首个管理员需手动创建:

```bash
# 先启动一次服务生成数据目录与表结构,然后:
# 密码哈希由程序生成(argon2);示例仅为占位
sqlite3 data/aitokenpool.db \
"INSERT INTO users (email, password_hash, name, role) VALUES ('admin@example.com', '<argon2-hash>', '管理员', 'admin');
INSERT INTO quotas (user_id, balance) VALUES (1, 0);"
```

> 后续账号通过注册/邀请流程创建(当前版本未提供注册端点,可直接操作 SQLite)。

## API 端点(Bearer 认证)

- `GET /healthz` → `{"status":"ok","version":"0.5.2"}`
- `GET /healthz` → `{"status":"ok","version":"0.6.0"}`
- `POST /api/auth/login` → `{api_key}`;`GET /api/me` → `{id,email,name,role}`
- `POST|GET /api/api-keys`(key 脱敏 `atk_live_****xxxx`);`DELETE /api/api-keys/:id`(撤销)
- `POST /v1/chat/completions` / `POST /anthropic/v1/messages` / `POST /v1/responses`(网关,三协议互转,非流式 + 流式 SSE 跨协议转换);`GET /v1/models`(OpenAI 兼容模型列表,认证可选)
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@
### 4.6 管理台
- 企业:key 管理、员工点数、用量报表
- 公共:市场浏览、钱包(点数余额)、交易记录
- **管理员 API(P1 已实现,`src/routes/admin.rs`)**:角色经 `AuthUser.role` 判定(Bearer key 关联 users.role),非 admin → 403;种子账号 `admin@aitokenpool.local / admin1234`(demo 账号 `demo@aitokenpool.local / demo1234`,余额 12471)
- **管理员 API(P1 已实现,`src/routes/admin.rs`)**:角色经 `AuthUser.role` 判定(Bearer key 关联 users.role),非 admin → 403;**v0.6.0 起生产库不预置任何种子账号**(首次部署 = 干净空库,测试用 demo/admin/ops 账号仅存在于 `#[cfg(test)]` 的 `seed_test_users`)
- role=ops 端点(平台运营者)留 P2,暂与 admin 合并权限位

## 5. 数据库设计(实表,`src/db.rs` 迁移 v3)
Expand Down
10 changes: 7 additions & 3 deletions src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,18 @@
//! - POST /api/auth/login:email+password → argon2 校验 → 返回该用户有效 API Key(无则生成)
//! - Bearer <api_key> 认证:查 api_keys 表 → 注入用户身份;无效 401

// 仅测试使用(hash_password 为 cfg(test);生产仅需 verify)
#[cfg(test)]
use anyhow::{anyhow, Result};
use argon2::password_hash::{
rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString,
};
#[cfg(test)]
use argon2::password_hash::{rand_core::OsRng, PasswordHasher, SaltString};
use argon2::password_hash::{PasswordHash, PasswordVerifier};
use argon2::Argon2;
use rand::RngCore;

/// argon2 口令哈希(OWASP 默认参数:m=19MiB, t=2, p=1)
/// v0.6.0 起仅测试使用(生产空库不种账号,注册端点未实现前无生产调用点)
#[cfg(test)]
pub fn hash_password(pw: &str) -> Result<String> {
let salt = SaltString::generate(&mut OsRng);
Ok(Argon2::default()
Expand Down
1 change: 1 addition & 0 deletions src/billing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ mod tests {
let p = std::env::temp_dir().join(format!("atp_bill_{}_{}.db", std::process::id(), tag));
let _ = std::fs::remove_file(&p);
let conn = db::open(p.to_str().unwrap()).expect("open tmp db");
db::seed_test_users(&conn).expect("seed test users");
(conn, p)
}

Expand Down
76 changes: 39 additions & 37 deletions src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use rusqlite::Connection;

pub const SCHEMA_VERSION: i64 = 5;

/// 打开(或创建)数据库并执行幂等迁移 + dev 种子
/// 打开(或创建)数据库并执行幂等迁移(生产标准:空库只建表,不种任何假数据)
pub fn open(path: &str) -> Result<Connection> {
if let Some(dir) = std::path::Path::new(path).parent() {
if !dir.as_os_str().is_empty() {
Expand All @@ -22,7 +22,6 @@ pub fn open(path: &str) -> Result<Connection> {
}
let conn = Connection::open(path).with_context(|| format!("打开数据库失败: {}", path))?;
migrate(&conn)?;
seed(&conn)?;
Ok(conn)
}

Expand Down Expand Up @@ -249,8 +248,10 @@ pub fn migrate_key_encryption(conn: &Connection, crypto: &crate::crypto::Crypto)
Ok(n)
}

/// dev 种子:demo 用户(demo@aitokenpool.local / demo1234,argon2)+ 点数账户 + 示例上游 key
pub fn seed(conn: &Connection) -> Result<()> {
/// 测试专用:自建测试用户(demo/admin/ops + 配额 + 占位 key),复刻旧 seed() 行为。
/// 生产 open() 不再种任何数据(rant 2026-08-19T10:41:03:移除所有 demo 种子数据,一律生产标准)。
#[cfg(test)]
pub(crate) fn seed_test_users(conn: &Connection) -> Result<()> {
use crate::auth::hash_password;

let demo_id: Option<i64> = conn
Expand All @@ -276,9 +277,7 @@ pub fn seed(conn: &Connection) -> Result<()> {
"INSERT OR IGNORE INTO quotas (user_id, balance) VALUES (?1, 12471)",
[demo_id],
)?;
// 示例上游 key(不真实可用的占位:占位密钥 + deepseek paygo plan)
// 幂等:仅当 keys 表为空时插入一次(否则每次启动都会新增一条脏占位 key,
// 干扰路由随机选择;rant 2026-08-18T16:14:21 Bug 2)
// 示例上游 key(占位密钥 + deepseek paygo plan;仅 keys 表为空时插入一次)
let key_count: i64 = conn.query_row("SELECT COUNT(*) FROM keys", [], |r| r.get(0))?;
if key_count == 0 {
conn.execute(
Expand All @@ -288,7 +287,7 @@ pub fn seed(conn: &Connection) -> Result<()> {
)?;
}

// 管理员账号(P1):admin@aitokenpool.local / admin1234,role=admin
// 管理员账号:admin@aitokenpool.local / admin1234,role=admin
let admin_id: Option<i64> = conn
.query_row(
"SELECT id FROM users WHERE email = ?1",
Expand All @@ -309,7 +308,7 @@ pub fn seed(conn: &Connection) -> Result<()> {
)?;
}

// 运营者账号(P2-C):ops@aitokenpool.local / ops1234,role=ops
// 运营者账号:ops@aitokenpool.local / ops1234,role=ops
let ops_id: Option<i64> = conn
.query_row(
"SELECT id FROM users WHERE email = ?1",
Expand Down Expand Up @@ -423,46 +422,47 @@ mod tests {
}

#[test]
fn seed_demo_user_and_quota() {
let (conn, p) = tmp_db("seed");
let u: i64 = conn
fn empty_db_has_no_seeded_users() {
// rant 2026-08-19T10:41:03:生产标准空库——migrate 后无任何种子用户/配额/占位 key
let (conn, p) = tmp_db("empty");
let users: i64 = conn
.query_row("SELECT COUNT(*) FROM users", [], |r| r.get(0))
.unwrap();
assert_eq!(users, 0, "空库无用户");
let keys: i64 = conn
.query_row("SELECT COUNT(*) FROM keys", [], |r| r.get(0))
.unwrap();
assert_eq!(keys, 0, "空库无占位 key");
let quotas: i64 = conn
.query_row("SELECT COUNT(*) FROM quotas", [], |r| r.get(0))
.unwrap();
assert_eq!(quotas, 0, "空库无配额");
let demo: i64 = conn
.query_row(
"SELECT id FROM users WHERE email = 'demo@aitokenpool.local'",
"SELECT COUNT(*) FROM users WHERE email = 'demo@aitokenpool.local'",
[],
|r| r.get(0),
)
.expect("demo 用户已种子");
let bal: f64 = conn
.query_row("SELECT balance FROM quotas WHERE user_id = ?1", [u], |r| {
r.get(0)
})
.expect("demo 点数账户已种子");
assert_eq!(bal, 12471.0);
let n: i64 = conn
.query_row("SELECT COUNT(*) FROM keys WHERE owner_id = ?1", [u], |r| {
r.get(0)
})
.unwrap();
assert!(n >= 1, "示例上游 key 已种子");
assert_eq!(demo, 0, "无 demo 账号");
drop(conn);
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
fn seed_test_users_is_idempotent() {
// 测试辅助函数多次调用不重复插入(幂等性验证)
let (conn, p) = tmp_db("testseed");
seed_test_users(&conn).expect("seed 1st");
seed_test_users(&conn).expect("seed 2nd");
let users: i64 = conn
.query_row("SELECT COUNT(*) FROM users", [], |r| r.get(0))
.unwrap();
assert_eq!(users, 3, "demo/admin/ops 各一个,共 3 用户");
let keys: i64 = conn
.query_row("SELECT COUNT(*) FROM keys", [], |r| r.get(0))
.unwrap();
assert_eq!(after, 1, "连续 seed 3 次 keys 仍只有 1 条占位 key");
assert_eq!(keys, 1, "占位 key 只插一次");
drop(conn);
let _ = std::fs::remove_file(p);
}
Expand Down Expand Up @@ -544,6 +544,8 @@ mod tests {
fn key_encryption_migration_encrypts_plaintext() {
let crypto = crate::crypto::Crypto::new([11u8; 32]);
let (conn, p) = tmp_db("kenc");
// 需要一个属主用户(FK);生产空库无种子,这里用测试辅助建 demo(id=1)
seed_test_users(&conn).expect("seed test users");
// 手工插入明文占位 key(模拟 v1 遗留)
conn.execute(
"INSERT INTO keys (provider, plan, model, status, owner_id, encrypted_key) VALUES ('t','p','m','on',1,'sk-placeholder-encrypted')",
Expand Down
1 change: 1 addition & 0 deletions src/gateway.rs
Original file line number Diff line number Diff line change
Expand Up @@ -763,6 +763,7 @@ mod tests {
let p = std::env::temp_dir().join(format!("atp_gw_{}_{}.db", std::process::id(), tag));
let _ = std::fs::remove_file(&p);
let conn = crate::db::open(p.to_str().unwrap()).expect("open tmp db");
crate::db::seed_test_users(&conn).expect("seed test users");
// demo 注册时间拨到赠送窗口外(2020 年)→ 网关测试不触发每日赠送,
// 消费扣减断言确定(赠送路径由 gift/routes/billing 测试覆盖)
conn.execute(
Expand Down
1 change: 1 addition & 0 deletions src/routes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ mod tests {
let p = std::env::temp_dir().join(format!("atp_route_{}_{}.db", std::process::id(), tag));
let _ = std::fs::remove_file(&p);
let conn = crate::db::open(p.to_str().unwrap()).expect("open tmp db");
crate::db::seed_test_users(&conn).expect("seed test users");
let cfg = crate::config::Config::load("config/config.example.toml").unwrap();
crate::db::seed_models(&conn, &cfg).expect("seed models");
let crypto = crate::crypto::Crypto::new([9u8; 32]);
Expand Down
1 change: 1 addition & 0 deletions src/routes/sharing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@ mod tests {
let p = std::env::temp_dir().join(format!("atp_share_{}_{}.db", std::process::id(), tag));
let _ = std::fs::remove_file(&p);
let conn = crate::db::open(p.to_str().unwrap()).expect("open tmp db");
crate::db::seed_test_users(&conn).expect("seed test users");
let cfg = crate::config::Config::load("config/config.example.toml").unwrap();
crate::db::seed_models(&conn, &cfg).expect("seed models");
let crypto = crate::crypto::Crypto::new([13u8; 32]);
Expand Down
1 change: 1 addition & 0 deletions src/routes/wallet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ mod tests {
let p = std::env::temp_dir().join(format!("atp_wallet_{}_{}.db", std::process::id(), tag));
let _ = std::fs::remove_file(&p);
let conn = crate::db::open(p.to_str().unwrap()).expect("open tmp db");
crate::db::seed_test_users(&conn).expect("seed test users");
let cfg = crate::config::Config::load("config/config.example.toml").unwrap();
crate::db::seed_models(&conn, &cfg).expect("seed models");
let crypto = crate::crypto::Crypto::new([17u8; 32]);
Expand Down
7 changes: 3 additions & 4 deletions ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,13 @@ <h1>AITokenPool</h1>
</div>
<form id="login-form" class="login-form" novalidate>
<label><span data-i18n="login.email">邮箱 / 账号</span>
<input type="email" id="login-email" value="demo@aitokenpool.local" placeholder="you@company.com">
<input type="email" id="login-email" placeholder="you@company.com">
</label>
<label><span data-i18n="login.password">密码</span>
<input type="password" id="login-pass" value="demo1234" placeholder="••••••••">
<input type="password" id="login-pass" placeholder="••••••••">
</label>
<div class="login-remember">
<label class="checkbox-line"><input type="checkbox" id="login-remember"> <span data-i18n="login.remember">记住我</span></label>
<span class="demo-hint" data-i18n="login.demo">演示账号:demo@aitokenpool.local / demo1234 · 管理员 admin@aitokenpool.local / admin1234</span>
</div>
<button type="submit" class="btn btn-primary btn-block" data-i18n="login.enter">进入平台</button>
</form>
Expand Down Expand Up @@ -348,7 +347,7 @@ <h3 data-i18n="settings.apikey">API Key</h3>
<h3 data-i18n="settings.account">账户</h3>
<div class="form">
<label><span data-i18n="settings.account.nickname">昵称</span> <input class="input" value="阿零"></label>
<label><span data-i18n="settings.account.email">邮箱</span> <input class="input" value="demo@aitokenpool.local"></label>
<label><span data-i18n="settings.account.email">邮箱</span> <input class="input" placeholder="you@company.com"></label>
<button class="btn btn-primary" data-i18n="common.save">保存</button>
</div>
</div>
Expand Down
Loading