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
81 changes: 78 additions & 3 deletions Cargo.lock

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

5 changes: 4 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "aitokenpool"
version = "0.6.1"
version = "0.6.2"
edition = "2021"
description = "AI Token 共享池 — 企业 key 池 + 公共共享市场"
license = "MIT"
Expand Down Expand Up @@ -47,3 +47,6 @@ sha2 = "0.10"
hex = "0.4"
rand = "0.8"
clap = { version = "4", features = ["derive"] }

# 注册邮箱验证码发信(rant 2026-08-19T14:36:19:方案 B 完整验证码)
lettre = { version = "0.11", default-features = false, features = ["builder", "smtp-transport", "rustls-tls", "hostname", "pool"] }
19 changes: 16 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ AITokenPool 是一个开源的 **AI Token 共享平台**:企业版(内部 ke
- ✅ **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 登录页/设置页移除演示账号预填与提示
- ✅ **v0.6.1**:**首次启动自动创建初始管理员(rant 2026-08-19T14:35:05)**——空库启动时创建 `admin@aitokenpool.local` + 随机 16 位密码(打印到启动日志,仅首次)+ quotas 账户(balance=0),幂等不重复;新增 `POST /api/auth/change-password` 改密端点(旧密码校验 + argon2 更新);不再需要手工插库
- ✅ **v0.6.2**:**用户自助注册 + 邮箱验证(rant 2026-08-19T14:36:19 方案 B)**——`POST /api/auth/register` + `verify` + `resend-code`;6 位数字验证码(10 分钟有效、5 次错误失效、60 秒重发限频);未验证邮箱不可登录(403);登录页注册表单 + 验证码页(中英 i18n);SMTP 发信(`[mail]` 配置,未配置时 dev 模式验证码打日志/响应)

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

Expand Down Expand Up @@ -85,13 +86,25 @@ open http://localhost:8080/ # 浏览器访问
> "INSERT INTO users (email, password_hash, name, role) VALUES ('admin@example.com', '<argon2-hash>', '管理员', 'admin');
> INSERT INTO quotas (user_id, balance) VALUES (1, 0);"
> ```
>

### ④ 注册账号(普通用户自助注册,v0.6.2)

除内置管理员外,普通用户**自助注册**(登录页底部「注册」):

1. 点击登录页「注册」→ 填昵称(可选)/ 邮箱 / 密码(≥8 位)→ 提交
2. 邮箱收到 **6 位验证码**(10 分钟有效)→ 输入验证码完成激活
3. 激活后回登录页用新账号登录(首次进钱包自动获得每日赠送 1 点)

> **邮件服务配置**(生产必配):`config/config.example.toml` 的 `[mail]` 段
> (smtp_host / smtp_port / smtp_user / smtp_password / from / from_name)。
> **未配置 SMTP 时为 dev 模式**:验证码直接打印到服务端日志(并随注册响应返回 `dev_code`),仅适合本地试用。

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

## API 端点(Bearer 认证)

- `GET /healthz` → `{"status":"ok","version":"0.6.1"}`
- `POST /api/auth/login` → `{api_key}`;`POST /api/auth/change-password`(改密);`GET /api/me` → `{id,email,name,role}`
- `GET /healthz` → `{"status":"ok","version":"0.6.2"}`
- `POST /api/auth/login` → `{api_key}`;`POST /api/auth/change-password`(改密);`POST /api/auth/register|verify|resend-code`(注册+邮箱验证);`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 兼容模型列表,认证可选)
- `GET /api/models`(模型市场)
Expand Down
14 changes: 14 additions & 0 deletions config/config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,20 @@ db_path = "data/aitokenpool.db"
# 所有已上架 key 将解密失败(503)。生成:openssl rand -hex 32
# master_key = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"

# ============================================================
# 0.1 邮件服务(注册验证码,rant 2026-08-19T14:36:19 方案 B)
# 未配置时进入 dev 模式:验证码打印到日志/响应(便于本地测试)。
# ⚠️ 生产部署必须配置 SMTP(STARTTLS,默认 587),否则验证码邮件不会真正送达。
# ============================================================
# [mail]
# smtp_host = "smtp.example.com"
# smtp_port = 587
# smtp_user = "noreply@example.com"
# smtp_password = "your-smtp-password"
# from = "noreply@example.com"
# from_name = "AITokenPool"
# verify_subject = "AITokenPool 邮箱验证码"

# ============================================================
# 1. 点数规则(账本层的锚)
# ============================================================
Expand Down
35 changes: 35 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,47 @@ impl Default for Server {
}
}

/// 邮件服务(注册验证码,rant 2026-08-19T14:36:19 方案 B)。
/// 未配置(smtp_host 为空)时进入 dev 模式:验证码打印到日志/响应,便于本地测试;
/// 生产部署必须配置 SMTP,否则注册验证码不真正送达(安全风险由部署方承担)。
#[derive(Debug, Clone, Default, Deserialize)]
pub struct Mail {
#[serde(default)]
pub smtp_host: String,
#[serde(default = "default_smtp_port")]
pub smtp_port: u16,
#[serde(default)]
pub smtp_user: String,
#[serde(default)]
pub smtp_password: String,
#[serde(default)]
pub from: String,
#[serde(default)]
pub from_name: String,
#[serde(default)]
pub verify_subject: String,
}

fn default_smtp_port() -> u16 {
587
}

impl Mail {
/// 是否配置了真实 SMTP(生产模式)
pub fn configured(&self) -> bool {
!self.smtp_host.is_empty()
}
}

/// 顶层配置
#[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)] // P0-A 仅用 server;points/providers/plans 由后续 P0 网关/定价阶段消费(parse 测试已校验)
pub struct Config {
#[serde(default)]
pub server: Server,
/// 邮件服务(可选;未配置时注册验证码走 dev 日志模式)
#[serde(default)]
pub mail: Mail,
pub points: Points,
pub providers: Vec<Provider>,
pub plans: Vec<Plan>,
Expand Down
104 changes: 104 additions & 0 deletions src/dao.rs
Original file line number Diff line number Diff line change
Expand Up @@ -233,3 +233,107 @@ pub fn verify_user_password(conn: &Connection, email: &str, pw: &str) -> Result<
Err(anyhow!("口令错误"))
}
}

/* ---- 注册 / 邮箱验证(rant 2026-08-19T14:36:19 方案 B)---- */

/// 邮箱是否已注册(含未验证用户)
pub fn email_taken(conn: &Connection, email: &str) -> bool {
conn.query_row("SELECT 1 FROM users WHERE email = ?1", [email], |_| Ok(()))
.is_ok()
}

/// 创建未验证用户(role='user',verified=0)→ 返回 user_id;不建 quotas(验证通过后建)
pub fn create_unverified_user(
conn: &Connection,
email: &str,
name: &str,
password_hash: &str,
) -> Result<i64> {
conn.execute(
"INSERT INTO users (email, password_hash, name, role, verified) VALUES (?1, ?2, ?3, 'user', 0)",
rusqlite::params![email, password_hash, name],
)?;
Ok(conn.last_insert_rowid())
}

/// 用户是否已验证(users.verified == 1)
pub fn user_verified(conn: &Connection, user_id: i64) -> bool {
conn.query_row("SELECT verified FROM users WHERE id = ?1", [user_id], |r| {
r.get::<_, i64>(0)
})
.map(|v| v == 1)
.unwrap_or(false)
}

/// 验证通过后激活用户 + 建 quotas 账户(balance=0, gift_balance=0)
pub fn activate_user(conn: &Connection, email: &str) -> Result<()> {
conn.execute("UPDATE users SET verified = 1 WHERE email = ?1", [email])?;
let id: i64 = conn.query_row("SELECT id FROM users WHERE email = ?1", [email], |r| {
r.get(0)
})?;
conn.execute(
"INSERT OR IGNORE INTO quotas (user_id, balance, gift_balance) VALUES (?1, 0, 0)",
[id],
)?;
Ok(())
}

/// 存储验证码(6 位数字的 sha256 hex);同一邮箱重复注册/重发 → 覆盖旧码
pub fn store_verification_code(
conn: &Connection,
email: &str,
code_hash: &str,
expires_at: &str,
) -> Result<()> {
conn.execute(
"INSERT OR REPLACE INTO email_verifications (email, code_hash, expires_at, attempts) \
VALUES (?1, ?2, ?3, 0)",
rusqlite::params![email, code_hash, expires_at],
)?;
Ok(())
}

/// 取验证记录 → Some((code_hash, attempts));过期或不存在 → None
pub fn find_valid_verification(conn: &Connection, email: &str) -> Option<(String, i64)> {
conn.query_row(
"SELECT code_hash, attempts FROM email_verifications \
WHERE email = ?1 AND expires_at > datetime('now')",
[email],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.ok()
}

/// 最近一次发送时间距现在是否 < 60 秒(重发限频)
pub fn resend_too_soon(conn: &Connection, email: &str) -> bool {
conn.query_row(
"SELECT 1 FROM email_verifications WHERE email = ?1 AND created_at > datetime('now', '-60 seconds')",
[email],
|_| Ok(()),
)
.is_ok()
}

/// 校验失败计数 +1;返回失败后是否已达上限(≥5 → 删除记录)
pub fn bump_verification_attempt(conn: &Connection, email: &str) -> Result<bool> {
conn.execute(
"UPDATE email_verifications SET attempts = attempts + 1 WHERE email = ?1",
[email],
)?;
let attempts: i64 = conn.query_row(
"SELECT attempts FROM email_verifications WHERE email = ?1",
[email],
|r| r.get(0),
)?;
if attempts >= 5 {
conn.execute("DELETE FROM email_verifications WHERE email = ?1", [email])?;
return Ok(true);
}
Ok(false)
}

/// 验证成功后删除验证码记录
pub fn clear_verification(conn: &Connection, email: &str) -> Result<()> {
conn.execute("DELETE FROM email_verifications WHERE email = ?1", [email])?;
Ok(())
}
Loading
Loading