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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

All notable changes are recorded here. Versions follow [SemVer](https://semver.org/).

## Unreleased

**schema 17 → 18**;新增可选 `[github]` 段(`client_id` / `client_secret`,留空 = 未启用,端点答 503「未启用」而非 500);**GitHub 登录**(设备码流程 + 网页登录页「使用 GitHub 登录」+ 身份即账号,rant 2026-11-09T14:43:55);`cargo test` 590 → 605。

## v0.7.33 (2026-10-15)

自 v0.7.32 起累计 1 个提交(#409)。**无 schema 变更(仍 16)**;**无行为变更** —— 唯一的改动是两条保留门禁的模块注释(`tx_retention_gate.rs` 与 `usage_retention_gate.rs`)。
Expand Down
21 changes: 21 additions & 0 deletions config/config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,27 @@ enabled = true # 是否启用(默认 true)
batch = 2_000 # 每轮最多删多少行(越小 → 单次持库时间越短)
interval_secs = 60 # 扫描间隔(秒)

# ============================================================
# 0.6 GitHub 登录(rant 2026-11-09T14:43:55):设备码流程(桌面端 / CLI / agent)
# 与网页「使用 GitHub 登录」共用同一套身份 —— 首次登录即注册,之后同一 GitHub
# 账号永远回到同一账号。
# 配置项**只有两个**:OAuth App 的 client id / secret。**留空 = 未启用**,
# 此时 /api/auth/github/* 返回 503「未启用」而不是 500。
# ⚠️ 该 OAuth App **必须开启 Device Flow**:GitHub → Settings → Developer settings
# → OAuth Apps → 该应用 → 勾选 "Enable Device Flow"。未开启时设备码端点答
# device_flow_disabled(网页授权码流程不受影响)。
# scope 不请求 user:email —— 邮箱一律用 GitHub 公布的 noreply 地址
# <id>+<login>@users.noreply.github.com,故拿不到公开邮箱也能注册成功。
# ============================================================
[github]
# 配置项只有这两个;留空 = GitHub 登录未启用(端点答 503「未启用」,不是 500)。
# OAuth App 里要开两样东西,否则两条流程各有一半走不通:
# · 勾上 "Enable Device Flow" —— 否则设备码端点(桌面端 / CLI / agent)拿不到码;
# · Authorization callback URL 填 server.public_url 本身(本行下面的 8080 就是默认值),
# 网页登录页的「使用 GitHub 登录」按它回跳。
client_id = "" # OAuth App 的 Client ID;留空 = GitHub 登录未启用
client_secret = "" # OAuth App 的 Client Secret;与 client_id 成对出现

# ============================================================
# 1. 点数规则(账本层的锚)
# ============================================================
Expand Down
29 changes: 29 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -340,12 +340,41 @@ pub struct Billing {
pub holidays: Vec<chrono::NaiveDate>,
}

/// GitHub 登录(rant 2026-11-09T14:43:55):OAuth App 的两个凭据,**仅此两项**。
///
/// 留空 = 未启用:`/api/auth/github/*` 两个端点返回 **503 + 明确说「未启用」**,而不是
/// 500 —— 「没配」是部署状态,不是内部故障。
///
/// ⚠️ 该 OAuth App **必须开启 Device Flow**(GitHub → Settings → Developer settings →
/// OAuth Apps → 该应用 → 勾选 "Enable Device Flow")。未开启时 GitHub 对
/// `POST /login/device/code` 一律答 `device_flow_disabled`,没有浏览器回调的客户端
///(桌面端 / CLI / agent)就走不通;网页授权码流程不受此开关影响。
#[derive(Debug, Clone, Default, Deserialize)]
pub struct Github {
#[serde(default)]
pub client_id: String,
/// 设备码流程同样要 secret:GitHub 换取 access_token 时要求 client_secret 一并提交
#[serde(default)]
pub client_secret: String,
}

impl Github {
/// 两个凭据都在才算启用。只配一半按**未启用**处理:半份配置换不到 token,
/// 报「未启用」比让用户对着 github.com 的 400 猜要好。
pub fn enabled(&self) -> bool {
!self.client_id.trim().is_empty() && !self.client_secret.trim().is_empty()
}
}

/// 顶层配置
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
#[serde(default)]
pub server: Server,
/// GitHub 登录(可选;客户端凭据留空 = 未启用)
#[serde(default)]
pub github: Github,
/// 邮件服务(可选;未配置时注册验证码走 dev 日志模式)
#[serde(default)]
pub mail: Mail,
Expand Down
51 changes: 51 additions & 0 deletions src/dao.rs
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,57 @@ pub fn activate_user(conn: &Connection, email: &str) -> Result<()> {
Ok(())
}

/// GitHub 的 noreply 邮箱:`<id>+<login>@users.noreply.github.com`
///
/// 这是 GitHub **公布**的地址形式(寄到它会进该账号的收件箱),不是随手造的占位串;
/// 用它让 GitHub 账号照样满足 `users.email NOT NULL UNIQUE`,而**不必**向 GitHub 索取
/// `user:email` 权限 —— 验收 3 要求「拿不到公开邮箱也必须注册成功」。
pub fn github_noreply_email(github_id: &str, login: &str) -> String {
format!("{github_id}+{login}@users.noreply.github.com")
}

/// GitHub 身份 → 本平台账号(rant 2026-11-09T14:43:55 验收 2):首次登录即注册。
///
/// 身份键是 `github_id`(GitHub 的稳定数字 id),**不是** login —— login 可以改名,
/// 拿它当键会在改名那天建出第二个账号。返回 `(user_id, 是否本次新建)`。
///
/// 新行的 `email` 取 [`github_noreply_email`](理由与「为什么加列而不重建表」见
/// `db::migrate` 的 v18 段),`password_hash` 留空 ⇒ 这类账号不能用邮箱 + 口令登录。
/// 新行 `verified = 1`:GitHub 身份本身就是已验证身份,不走邮箱验证码。
///
/// 登录改名时**只更新 `github_login`**,不重写 `email`:那个地址只是占位(没有读者依赖
/// 它),而 `users.email` 有 UNIQUE 约束 ⇒ 跟着改名去 UPDATE 只是给自己加一条可能撞唯一键
/// 的写入。
pub fn upsert_github_user(conn: &Connection, github_id: &str, login: &str) -> Result<(i64, bool)> {
let existing: Option<i64> = conn
.query_row(
"SELECT id FROM users WHERE github_id = ?1",
[github_id],
|r| r.get(0),
)
.ok();
if let Some(id) = existing {
conn.execute(
"UPDATE users SET github_login = ?1 WHERE id = ?2 AND github_login <> ?1",
rusqlite::params![login, id],
)?;
return Ok((id, false));
}
let email = github_noreply_email(github_id, login);
conn.execute(
"INSERT INTO users (email, password_hash, name, role, verified, github_id, github_login) \
VALUES (?1, '', ?2, 'user', 1, ?3, ?4)",
rusqlite::params![email, login, github_id, login],
)?;
let id = conn.last_insert_rowid();
// 可用余额账户随建号创建(验收 4)——与邮箱注册在 verify 里做的是同一件事
conn.execute(
"INSERT OR IGNORE INTO quotas (user_id, balance, gift_balance) VALUES (?1, 0, 0)",
[id],
)?;
Ok((id, true))
}

/// 存储验证码(6 位数字的 sha256 hex);同一邮箱重复注册/重发 → 覆盖旧码。
/// 过期时间由 SQL 自身计算(`datetime('now', '+10 minutes')`):`find_valid_verification` 的谓词是
/// `expires_at > datetime('now')`,写入方必须写入**该谓词能解释**的值——把字面量 `'+10 minutes'`
Expand Down
29 changes: 28 additions & 1 deletion 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;
use std::collections::HashMap;

pub const SCHEMA_VERSION: i64 = 17;
pub const SCHEMA_VERSION: i64 = 18;

/// 打开(或创建)数据库并执行幂等迁移(生产标准:空库只建表,不种任何假数据)
pub fn open(path: &str) -> Result<Connection> {
Expand Down Expand Up @@ -446,6 +446,33 @@ pub fn migrate(conn: &Connection) -> Result<()> {
CREATE INDEX IF NOT EXISTS idx_transactions_rollup_type_bucket
ON transactions_rollup(type, bucket);",
)?;
// v18(rant 2026-11-09T14:43:55):GitHub 身份即账号 —— `users` 补两列 + 一个部分唯一索引。
//
// 为什么是「加列」而不是重建表:`email` / `password_hash` 是 NOT NULL,重建能让它们可空,
// 但那样全仓**每一个** `SELECT ... email FROM users` 的读取点都得改成容忍 NULL(管理端
// 用户列表、运营报表…),一处迁移会扩散成跨模块重写。GitHub 账号因此仍然写一个 `email`,
// 取值是 GitHub **公布**的 noreply 地址 `<id>+<login>@users.noreply.github.com`(寄到它会进
// 该账号收件箱,不是编造的占位串),口令列写 '' —— `auth::verify_password` 对空 PHC 串恒
// 返回 false,故这类账号不可能用邮箱 + 口令登录。
//
// 身份认 `github_id`(GitHub 的稳定数字 id),**不认 login**:login 可以改名,拿它当键会在
// 改名那天建出第二个账号。部分唯一索引把「非 GitHub 账号」排除在外(它们的值是 '')。
ensure_column(
conn,
"users",
"github_id",
"github_id TEXT NOT NULL DEFAULT ''",
)?;
ensure_column(
conn,
"users",
"github_login",
"github_login TEXT NOT NULL DEFAULT ''",
)?;
conn.execute_batch(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_users_github_id
ON users(github_id) WHERE github_id <> '';",
)?;
// schema_version 记录**最高的**已迁移版本。⚠️ 必须用 MAX 读:该表没有唯一约束,
// 而 `INSERT OR REPLACE` 在无冲突时就是普通 INSERT —— 只读第一行的话
// `v < SCHEMA_VERSION` 会永远为真,每次启动都追加一行(`atp-data/aitokenpool.db`
Expand Down
Loading
Loading