From fe846802a52f84c68917e544f2403fdda5b72c8e Mon Sep 17 00:00:00 2001 From: argszero Date: Wed, 19 Aug 2026 10:51:02 +0800 Subject: [PATCH] =?UTF-8?q?refactor(db):=20remove=20demo=20seed=20data=20?= =?UTF-8?q?=E2=80=94=20clean=20empty=20DB=20on=20first=20deploy=20(v0.6.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rant 2026-08-19T10:41:03: remove all demo seed data; production standard (no seed_demo flag, no fake accounts/balances/placeholder keys). - db::open() no longer seeds; first deploy = clean empty DB (tables only) - old seed() -> #[cfg(test)] seed_test_users() helper (test fixtures only, excluded from production builds) - test_state/tmp_db helpers in routes, gateway, billing now call the helper - db tests assert empty DB + seed helper idempotency - auth::hash_password -> #[cfg(test)] (no production callers yet) - UI: login prefill, demo-hint, settings email placeholder cleared - README/docs: login section documents manual first-admin creation - 102/102 tests, clippy -D warnings, fmt clean --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 23 ++++++++----- docs/architecture.md | 2 +- src/auth.rs | 10 ++++-- src/billing.rs | 1 + src/db.rs | 76 ++++++++++++++++++++++--------------------- src/gateway.rs | 1 + src/routes/mod.rs | 1 + src/routes/sharing.rs | 1 + src/routes/wallet.rs | 1 + ui/index.html | 7 ++-- 12 files changed, 72 insertions(+), 55 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 89be089..ef67cfc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -60,7 +60,7 @@ dependencies = [ [[package]] name = "aitokenpool" -version = "0.5.2" +version = "0.6.0" dependencies = [ "aes-gcm", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 38c1ede..8e059e1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "aitokenpool" -version = "0.5.2" +version = "0.6.0" edition = "2021" description = "AI Token 共享池 — 企业 key 池 + 公共共享市场" license = "MIT" diff --git a/README.md b/README.md index f3e35a6..b5f139d 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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` 静态托管,无需单独部署前端。 @@ -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', '', '管理员', '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 兼容模型列表,认证可选) diff --git a/docs/architecture.md b/docs/architecture.md index 6dc5825..f66d941 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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) diff --git a/src/auth.rs b/src/auth.rs index 0d6c022..2b724d0 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -4,14 +4,18 @@ //! - POST /api/auth/login:email+password → argon2 校验 → 返回该用户有效 API Key(无则生成) //! - Bearer 认证:查 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 { let salt = SaltString::generate(&mut OsRng); Ok(Argon2::default() diff --git a/src/billing.rs b/src/billing.rs index 87cf43f..0b3774f 100644 --- a/src/billing.rs +++ b/src/billing.rs @@ -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) } diff --git a/src/db.rs b/src/db.rs index 020645c..05e9f5b 100644 --- a/src/db.rs +++ b/src/db.rs @@ -12,7 +12,7 @@ use rusqlite::Connection; pub const SCHEMA_VERSION: i64 = 5; -/// 打开(或创建)数据库并执行幂等迁移 + dev 种子 +/// 打开(或创建)数据库并执行幂等迁移(生产标准:空库只建表,不种任何假数据) pub fn open(path: &str) -> Result { if let Some(dir) = std::path::Path::new(path).parent() { if !dir.as_os_str().is_empty() { @@ -22,7 +22,6 @@ pub fn open(path: &str) -> Result { } let conn = Connection::open(path).with_context(|| format!("打开数据库失败: {}", path))?; migrate(&conn)?; - seed(&conn)?; Ok(conn) } @@ -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 = conn @@ -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( @@ -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 = conn .query_row( "SELECT id FROM users WHERE email = ?1", @@ -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 = conn .query_row( "SELECT id FROM users WHERE email = ?1", @@ -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); } @@ -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')", diff --git a/src/gateway.rs b/src/gateway.rs index 3b2bbc5..3a5a533 100644 --- a/src/gateway.rs +++ b/src/gateway.rs @@ -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( diff --git a/src/routes/mod.rs b/src/routes/mod.rs index b5d0668..4b90a41 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -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]); diff --git a/src/routes/sharing.rs b/src/routes/sharing.rs index 3605811..b23c13a 100644 --- a/src/routes/sharing.rs +++ b/src/routes/sharing.rs @@ -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]); diff --git a/src/routes/wallet.rs b/src/routes/wallet.rs index 6179f17..019ac53 100644 --- a/src/routes/wallet.rs +++ b/src/routes/wallet.rs @@ -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]); diff --git a/ui/index.html b/ui/index.html index 5dccf95..a26c7b0 100644 --- a/ui/index.html +++ b/ui/index.html @@ -21,14 +21,13 @@

AITokenPool

@@ -348,7 +347,7 @@

API Key

账户

- +