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.2.2"
version = "0.3.0"
edition = "2021"
description = "AI Token 共享池 — 企业 key 池 + 公共共享市场"
license = "MIT"
Expand Down
171 changes: 159 additions & 12 deletions src/billing.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//! 计量账本(architecture §4.3/4.4)
//! 计量账本(architecture §4.3/4.4 + P1 点数规则细化)
//!
//! P0-B(rant 2026-08-18T09:55:57):
//! - 成本 = prompt_tokens × input_per_m/1e6 + completion_tokens × output_per_m/1e6
Expand All @@ -8,6 +8,10 @@
//! - 分享者(key 属主)得 90%(平台抽成 10%),写 transactions(consume / earn)
//! - 写 usage_records;更新 keys.used += tokens
//! - 调用+记账事务性处理:上游失败不入账(settle 只在成功响应后调用)
//!
//! P1(rant 2026-08-18T11:03:02):
//! - 可用余额 = gift_balance + balance(预检与 settle 一致)
//! - 扣减顺序:先扣最早到期的赠送点数(gift_grants 按 expires_at ASC),不足再扣永久 balance

use anyhow::Result;
use rusqlite::Connection;
Expand Down Expand Up @@ -67,15 +71,17 @@ pub struct SettleParams {
pub cost: f64,
}

/// 事务性入账:扣消费者 → 加分享者 90% → 两条 transactions → usage_records → keys.used
/// 任一步失败整体回滚(调用方只在成功响应后调用,天然满足「失败不入账」)
/// 事务性入账:扣消费者(先赠送后永久)→ 加分享者 90% → 两条 transactions →
/// usage_records → keys.used。任一步失败整体回滚(调用方只在成功响应后调用,
/// 天然满足「失败不入账」)
pub fn settle(conn: &mut Connection, p: &SettleParams) -> Result<()> {
let tx = conn.transaction()?;

// 消费者扣 balance(余额允许为负——预检已拦截 ≤0 的请求,负余额由后续充值覆盖)
// 消费者扣减:先扣最早到期的赠送点数,剩余从永久 balance 扣
let remaining = crate::gift::deduct_gift_first(&tx, p.consumer_id, p.pts)?;
tx.execute(
"UPDATE quotas SET balance = balance - ?1, updated_at = datetime('now') WHERE user_id = ?2",
rusqlite::params![p.pts, p.consumer_id],
rusqlite::params![remaining, p.consumer_id],
)?;

// 分享者加 90%(平台抽成 10%)
Expand Down Expand Up @@ -190,11 +196,11 @@ mod tests {
let (mut conn, p) = tmp_db("settle");
// 属主用户(user_id=2)与 key
conn.execute(
"INSERT INTO users (id, email, password_hash, name, role) VALUES (2, 'owner@t.local', 'x', '分享者', 'user')",
"INSERT INTO users (id, email, password_hash, name, role) VALUES (100, 'owner@t.local', 'x', '分享者', 'user')",
[],
)
.unwrap();
conn.execute("INSERT INTO quotas (user_id, balance) VALUES (2, 0)", [])
conn.execute("INSERT INTO quotas (user_id, balance) VALUES (100, 0)", [])
.unwrap();
conn.execute(
"INSERT INTO keys (id, provider, plan, model, status, owner_id, encrypted_key, quota, used) \
Expand All @@ -214,7 +220,7 @@ mod tests {
consumer_id: 1,
api_key_id: Some(3),
key_id: 9,
owner_id: 2,
owner_id: 100,
model: "test-model".into(),
tokens: 150.0,
pts: 2.0,
Expand All @@ -231,7 +237,7 @@ mod tests {
assert!((bal_c - (12471.0 - 2.0)).abs() < 1e-9);
// 分享者加 1.8(90%)
let bal_o: f64 = conn
.query_row("SELECT balance FROM quotas WHERE user_id = 2", [], |r| {
.query_row("SELECT balance FROM quotas WHERE user_id = 100", [], |r| {
r.get(0)
})
.unwrap();
Expand All @@ -248,9 +254,11 @@ mod tests {
.unwrap();
assert_eq!(t_consume, "consume");
let t_earn: String = conn
.query_row("SELECT type FROM transactions WHERE user_id = 2", [], |r| {
r.get(0)
})
.query_row(
"SELECT type FROM transactions WHERE user_id = 100",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(t_earn, "earn");
// usage_records 一条
Expand Down Expand Up @@ -297,4 +305,143 @@ mod tests {
drop(conn);
let _ = std::fs::remove_file(p);
}

#[test]
fn settle_deducts_gift_first_then_permanent() {
let (mut conn, p) = tmp_db("settle_gift");
// 消费者 user_id=1:赠送 1 点(当天 23:59:59 过期)+ 永久 10 点
conn.execute(
"INSERT INTO gift_grants (user_id, amount, granted_at, expires_at, status) \
VALUES (1, 1, '2026-08-18 10:00:00', '2026-08-18 23:59:59', 'active')",
[],
)
.unwrap();
conn.execute(
"UPDATE quotas SET gift_balance = 1, balance = 10 WHERE user_id = 1",
[],
)
.unwrap();
// 分享者 user_id=2 与 key
conn.execute(
"INSERT INTO users (id, email, password_hash, name, role) VALUES (100, 'owner2@t.local', 'x', '分享者', 'user')",
[],
)
.unwrap();
conn.execute("INSERT INTO quotas (user_id, balance) VALUES (100, 0)", [])
.unwrap();
conn.execute(
"INSERT INTO keys (id, provider, plan, model, status, owner_id, encrypted_key, quota, used) \
VALUES (8, 'test', 'test-plan', 'test-model', 'on', 2, 'sk-test', 1000, 0)",
[],
)
.unwrap();

let params = SettleParams {
consumer_id: 1,
api_key_id: Some(3),
key_id: 8,
owner_id: 100,
model: "test-model".into(),
tokens: 100.0,
pts: 3.0,
cost: 0.003,
};
settle(&mut conn, &params).unwrap();

// 赠送 1 点全部花掉(used)+ 永久扣 2 点
let gift: f64 = conn
.query_row(
"SELECT gift_balance FROM quotas WHERE user_id = 1",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(gift, 0.0, "赠送先扣光");
let g_status: String = conn
.query_row(
"SELECT status FROM gift_grants WHERE user_id = 1",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(g_status, "used");
let bal: f64 = conn
.query_row("SELECT balance FROM quotas WHERE user_id = 1", [], |r| {
r.get(0)
})
.unwrap();
assert!((bal - 8.0).abs() < 1e-9, "永久扣 2 点: {bal}");
// 分享者照常 90%
let owner: f64 = conn
.query_row("SELECT balance FROM quotas WHERE user_id = 100", [], |r| {
r.get(0)
})
.unwrap();
assert!((owner - 2.7).abs() < 1e-9, "分享者 3×0.9=2.7: {owner}");
// 两条 transactions
let n: i64 = conn
.query_row("SELECT COUNT(*) FROM transactions", [], |r| r.get(0))
.unwrap();
assert_eq!(n, 2);

drop(conn);
let _ = std::fs::remove_file(p);
}

#[test]
fn settle_expired_gift_not_consumed() {
let (mut conn, p) = tmp_db("settle_expired");
// 一笔已过期(昨天)的赠送:settle 前惰性清理 → 只扣永久
conn.execute(
"INSERT INTO gift_grants (user_id, amount, granted_at, expires_at, status) \
VALUES (1, 1, '2026-08-17 10:00:00', '2026-08-17 23:59:59', 'active')",
[],
)
.unwrap();
conn.execute(
"UPDATE quotas SET gift_balance = 1, balance = 10 WHERE user_id = 1",
[],
)
.unwrap();
let params = SettleParams {
consumer_id: 1,
api_key_id: None,
key_id: 1, // seed 里的 demo key
owner_id: 1,
model: "m".into(),
tokens: 10.0,
pts: 2.0,
cost: 0.002,
};
settle(&mut conn, &params).unwrap();
let gift: f64 = conn
.query_row(
"SELECT gift_balance FROM quotas WHERE user_id = 1",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(gift, 0.0, "过期赠送不参与扣减");
let g_status: String = conn
.query_row(
"SELECT status FROM gift_grants WHERE user_id = 1",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(g_status, "expired", "惰性标记 expired");
let bal: f64 = conn
.query_row("SELECT balance FROM quotas WHERE user_id = 1", [], |r| {
r.get(0)
})
.unwrap();
// 10 - 2(消费)+ 1.8(同属主 90% 分成)= 9.8
assert!(
(bal - 9.8).abs() < 1e-9,
"过期赠送不扣,全部从永久扣: {bal}"
);

drop(conn);
let _ = std::fs::remove_file(p);
}
}
26 changes: 17 additions & 9 deletions src/dao.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,14 @@ pub fn list_api_keys(conn: &Connection, user_id: i64) -> Result<Vec<serde_json::
Ok(out)
}

/// Bearer 认证:按 key 查归属用户 + api_key id → Some((user_id, api_key_id))
pub fn find_api_key_user_and_id(conn: &Connection, key: &str) -> Option<(i64, i64)> {
/// Bearer 认证:按 key 查归属用户 + api_key id + 角色 → Some((user_id, api_key_id, role))
pub fn find_api_key_user_and_id(conn: &Connection, key: &str) -> Option<(i64, i64, String)> {
conn.query_row(
"SELECT user_id, id FROM api_keys WHERE key_value = ?1 AND status = 'active'",
"SELECT a.user_id, a.id, u.role FROM api_keys a \
JOIN users u ON u.id = a.user_id \
WHERE a.key_value = ?1 AND a.status = 'active'",
[key],
|r| Ok((r.get(0)?, r.get(1)?)),
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.ok()
}
Expand Down Expand Up @@ -111,14 +113,20 @@ pub fn find_keys_by_model(conn: &Connection, model: &str) -> Result<Vec<KeyRow>>
Ok(out)
}

/// 用户点数余额(无账户按 0)
pub fn get_balance(conn: &Connection, user_id: i64) -> f64 {
/// 用户可用余额拆分 → (permanent, gift);可用总额 = 两者之和
pub fn get_balances(conn: &Connection, user_id: i64) -> (f64, f64) {
conn.query_row(
"SELECT balance FROM quotas WHERE user_id = ?1",
"SELECT balance, gift_balance FROM quotas WHERE user_id = ?1",
[user_id],
|r| r.get(0),
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap_or(0.0)
.unwrap_or((0.0, 0.0))
}

/// 用户可用余额(赠送 + 永久)——网关预检口径
pub fn get_available_balance(conn: &Connection, user_id: i64) -> f64 {
let (permanent, gift) = get_balances(conn, user_id);
permanent + gift
}

/// 模型单价(按 provider+model)→ (input_per_m, output_per_m, currency)
Expand Down
51 changes: 49 additions & 2 deletions src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
use anyhow::{Context, Result};
use rusqlite::Connection;

pub const SCHEMA_VERSION: i64 = 2;
pub const SCHEMA_VERSION: i64 = 3;

/// 打开(或创建)数据库并执行幂等迁移 + dev 种子
pub fn open(path: &str) -> Result<Connection> {
Expand Down Expand Up @@ -79,8 +79,17 @@ pub fn migrate(conn: &Connection) -> Result<()> {
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL UNIQUE REFERENCES users(id),
balance REAL NOT NULL DEFAULT 0,
gift_balance REAL NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS gift_grants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
amount REAL NOT NULL DEFAULT 0,
granted_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'active'
);
CREATE TABLE IF NOT EXISTS transactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
Expand Down Expand Up @@ -127,6 +136,23 @@ pub fn migrate(conn: &Connection) -> Result<()> {
"available_end TEXT NOT NULL DEFAULT ''",
)?;
ensure_column(conn, "keys", "note", "note TEXT NOT NULL DEFAULT ''")?;
// v3(P1):点数账户拆分——gift_balance(当前有效赠送点数)+ gift_grants 明细表
ensure_column(
conn,
"quotas",
"gift_balance",
"gift_balance REAL NOT NULL DEFAULT 0",
)?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS gift_grants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
amount REAL NOT NULL DEFAULT 0,
granted_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'active'
);",
)?;
// schema_version:INSERT OR REPLACE 保证幂等
let v: i64 = conn
.query_row("SELECT version FROM schema_version", [], |r| r.get(0))
Expand Down Expand Up @@ -212,6 +238,27 @@ pub fn seed(conn: &Connection) -> Result<()> {
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
.query_row(
"SELECT id FROM users WHERE email = ?1",
["admin@aitokenpool.local"],
|r| r.get(0),
)
.ok();
if admin_id.is_none() {
let hash = hash_password("admin1234")?;
conn.execute(
"INSERT INTO users (email, password_hash, name, role) VALUES (?1, ?2, '管理员', 'admin')",
rusqlite::params!["admin@aitokenpool.local", hash],
)?;
let id = conn.last_insert_rowid();
conn.execute(
"INSERT OR IGNORE INTO quotas (user_id, balance) VALUES (?1, 0)",
[id],
)?;
}
Ok(())
}

Expand Down Expand Up @@ -391,7 +438,7 @@ mod tests {
let v: i64 = conn
.query_row("SELECT version FROM schema_version", [], |r| r.get(0))
.unwrap();
assert_eq!(v, 2, "旧库迁移后版本应为 2");
assert_eq!(v, SCHEMA_VERSION, "旧库迁移后版本应为 {SCHEMA_VERSION}");
drop(conn);
let _ = std::fs::remove_file(p);
}
Expand Down
Loading
Loading