feat: implement dfguide-registrar project with Postgres user registration functionality

This commit is contained in:
siujamo
2026-04-30 16:39:10 +08:00
commit 449cab6c06
9 changed files with 2633 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
PG_HOST=127.0.0.1
PG_PORT=5432
PG_USER=postgres
PG_PASSWORD=change-me
PG_DATABASE=dfguide
# 如果你更喜欢使用单个连接串,也可以改用:
# PG_URL=postgres://postgres:change-me@127.0.0.1:5432/dfguide
+106
View File
@@ -0,0 +1,106 @@
### macOS
# General
.DS_Store
.AppleDouble
.LSOverride
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
### Linux
*~
# temporary files which can be created if a process still has a handle open of a deleted file
.fuse_hidden*
# Metadata left by Dolphin file manager, which comes with KDE Plasma
.directory
# Linux trash folder which might appear on any partition or disk
.Trash-*
# .nfs files are created when an open file is removed but is still being accessed
.nfs*
# Log files created by default by the nohup command
nohup.out
### Windows
# Windows thumbnail cache files
Thumbs.db
Thumbs.db:encryptable
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp
# Windows shortcuts
*.lnk
### JetBrains IDE
# Covers JetBrains IDEs: IntelliJ, GoLand, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff
.idea
# Gradle and Maven with auto-import
*.iml
*.ipr
# File-based project format
*.iws
# IntelliJ
out/
# JIRA plugin
atlassian-ide-plugin.xml
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
# Editor-based HTTP Client
http-client.private.env.json
### Project Files
# Ignore all .env files
.env*
!.env.example
/target
Generated
+2199
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "dfguide-registrar"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = "1"
bcrypt = "0.16"
dialoguer = "0.12"
dotenvy = "0.15"
sqlx = { version = "0.8", features = ["postgres", "runtime-tokio-rustls"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
validator = "0.20"
+45
View File
@@ -0,0 +1,45 @@
# dfguide-registrar
一个交互式 Rust CLI,用于向 [Delta Force Guide] 的 Postgres 数据库写入注册用户。
## 功能
-`.env` 文件或系统环境变量读取 Postgres 连接信息
- 交互式输入用户名、电子邮箱、密码
-`provider = LOCAL` 时,使用 Spring Security 兼容的 BCrypt 哈希保存密码
- 以单个数据库事务写入 `app_user``app_user_credential`
- 在写入前检查用户名和邮箱是否已存在
## 环境变量
支持两种配置方式:
1. 设置完整连接串 `PG_URL`
2. 或分别设置以下变量:
- `PG_HOST`
- `PG_PORT`(可选,默认 `5432`
- `PG_USER`
- `PG_PASSWORD`
- `PG_DATABASE`
可以复制 `.env.example``.env` 后修改:
```powershell
Copy-Item .env.example .env
```
## 运行
```powershell
cargo run
```
程序会按顺序提示输入用户名、邮箱和密码。
## 测试
```powershell
cargo test
```
[Delta Force Guide]:https://dfguide.onixbyte.cn/
+43
View File
@@ -0,0 +1,43 @@
use std::{env, str::FromStr};
use anyhow::{Context, Result};
use sqlx::postgres::PgConnectOptions;
#[derive(Debug, Clone)]
pub struct AppConfig {
pub connect_options: PgConnectOptions,
}
impl AppConfig {
pub fn from_env() -> Result<Self> {
if let Ok(url) = env::var("PG_URL") {
let connect_options = PgConnectOptions::from_str(&url)
.context("PG_URL 格式不正确,示例:postgres://user:password@host:5432/database")?;
return Ok(Self { connect_options });
}
let host = required_var("PG_HOST")?;
let user = required_var("PG_USER")?;
let password = required_var("PG_PASSWORD")?;
let database = required_var("PG_DATABASE")?;
let port = env::var("PG_PORT")
.ok()
.map(|value| value.parse::<u16>().context("PG_PORT 必须是合法的端口号"))
.transpose()?
.unwrap_or(5432);
let connect_options = PgConnectOptions::new()
.host(&host)
.port(port)
.username(&user)
.password(&password)
.database(&database);
Ok(Self { connect_options })
}
}
fn required_var(key: &str) -> Result<String> {
env::var(key).with_context(|| format!("环境变量 {key} 未设置"))
}
+96
View File
@@ -0,0 +1,96 @@
use anyhow::{Context, Result, bail};
use bcrypt::hash;
use sqlx::{PgPool, Postgres, Transaction, postgres::PgPoolOptions};
use crate::{config::AppConfig, validation};
const LOCAL_PROVIDER: &str = "LOCAL";
const SPRING_SECURITY_BCRYPT_COST: u32 = 10;
#[derive(Debug, Clone)]
pub struct RegistrationInput {
pub username: String,
pub email: String,
pub password: String,
}
pub async fn connect(config: &AppConfig) -> Result<PgPool> {
PgPoolOptions::new()
.max_connections(1)
.connect_with(config.connect_options.clone())
.await
.context("无法连接到 Postgres,请检查数据库地址、账号和密码")
}
pub async fn register_local_user(pool: &PgPool, input: RegistrationInput) -> Result<i64> {
validation::validate_username(&input.username).map_err(anyhow::Error::msg)?;
validation::validate_email(&input.email).map_err(anyhow::Error::msg)?;
validation::validate_password(&input.password).map_err(anyhow::Error::msg)?;
let mut transaction = pool.begin().await.context("无法开启数据库事务")?;
ensure_username_is_available(&mut transaction, &input.username).await?;
ensure_email_is_available(&mut transaction, &input.email).await?;
let user_id = sqlx::query_scalar::<_, i64>(
"INSERT INTO app_user (username, email) VALUES ($1, $2) RETURNING id",
)
.bind(&input.username)
.bind(&input.email)
.fetch_one(&mut *transaction)
.await
.context("写入 app_user 失败,请确认表结构已创建")?;
let credential =
hash(&input.password, SPRING_SECURITY_BCRYPT_COST).context("使用 BCrypt 处理密码失败")?;
sqlx::query(
"INSERT INTO app_user_credential (user_id, provider, credential) VALUES ($1, $2, $3)",
)
.bind(user_id)
.bind(LOCAL_PROVIDER)
.bind(credential)
.execute(&mut *transaction)
.await
.context("写入 app_user_credential 失败,请确认表结构已创建")?;
transaction.commit().await.context("提交注册事务失败")?;
Ok(user_id)
}
async fn ensure_username_is_available(
transaction: &mut Transaction<'_, Postgres>,
username: &str,
) -> Result<()> {
let exists =
sqlx::query_scalar::<_, bool>("SELECT EXISTS(SELECT 1 FROM app_user WHERE username = $1)")
.bind(username)
.fetch_one(&mut **transaction)
.await
.context("检查用户名是否存在时失败")?;
if exists {
bail!("用户名已存在:{username}");
}
Ok(())
}
async fn ensure_email_is_available(
transaction: &mut Transaction<'_, Postgres>,
email: &str,
) -> Result<()> {
let exists =
sqlx::query_scalar::<_, bool>("SELECT EXISTS(SELECT 1 FROM app_user WHERE email = $1)")
.bind(email)
.fetch_one(&mut **transaction)
.await
.context("检查电子邮箱是否存在时失败")?;
if exists {
bail!("电子邮箱已存在:{email}");
}
Ok(())
}
+52
View File
@@ -0,0 +1,52 @@
mod config;
mod db;
mod validation;
use anyhow::Result;
use dialoguer::{Input, Password, theme::ColorfulTheme};
use dotenvy::dotenv;
use crate::db::RegistrationInput;
#[tokio::main]
async fn main() -> Result<()> {
dotenv().ok();
let config = config::AppConfig::from_env()?;
let pool = db::connect(&config).await?;
println!("Delta Force Guide 用户注册");
println!("请依次输入用户名、邮箱和密码。\n");
let registration = prompt_registration()?;
let user_id = db::register_local_user(&pool, registration).await?;
println!("\n注册成功,user_id = {user_id}");
Ok(())
}
fn prompt_registration() -> Result<RegistrationInput> {
let theme = ColorfulTheme::default();
let username = Input::<String>::with_theme(&theme)
.with_prompt("用户名")
.validate_with(|input: &String| validation::validate_username(input).map_err(str::to_owned))
.interact_text()?;
let email = Input::<String>::with_theme(&theme)
.with_prompt("电子邮箱")
.validate_with(|input: &String| validation::validate_email(input).map_err(str::to_owned))
.interact_text()?;
let password = Password::with_theme(&theme)
.with_prompt("密码")
.with_confirmation("再次输入密码", "两次输入的密码不一致")
.validate_with(|input: &String| validation::validate_password(input).map_err(str::to_owned))
.interact()?;
Ok(RegistrationInput {
username: validation::normalize_username(&username)?,
email: validation::normalize_email(&email)?,
password,
})
}
+70
View File
@@ -0,0 +1,70 @@
use anyhow::{Result, anyhow};
use validator::ValidateEmail;
pub fn validate_username(input: &str) -> Result<(), &'static str> {
if input.trim().is_empty() {
return Err("用户名不能为空");
}
Ok(())
}
pub fn normalize_username(input: &str) -> Result<String> {
validate_username(input).map_err(|message| anyhow!(message))?;
Ok(input.trim().to_owned())
}
pub fn validate_email(input: &str) -> Result<(), &'static str> {
let email = input.trim();
if email.is_empty() {
return Err("电子邮箱不能为空");
}
if !email.validate_email() {
return Err("电子邮箱格式不正确");
}
Ok(())
}
pub fn normalize_email(input: &str) -> Result<String> {
validate_email(input).map_err(|message| anyhow!(message))?;
Ok(input.trim().to_lowercase())
}
pub fn validate_password(input: &str) -> Result<(), &'static str> {
if input.is_empty() {
return Err("密码不能为空");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{normalize_email, normalize_username, validate_email, validate_password};
#[test]
fn username_is_trimmed() {
let normalized = normalize_username(" delta-player ").expect("username should normalize");
assert_eq!(normalized, "delta-player");
}
#[test]
fn invalid_email_is_rejected() {
assert!(validate_email("invalid-email").is_err());
}
#[test]
fn email_is_normalized_to_lowercase() {
let normalized = normalize_email(" Pilot@Example.COM ").expect("email should normalize");
assert_eq!(normalized, "pilot@example.com");
}
#[test]
fn password_cannot_be_empty() {
assert!(validate_password("").is_err());
assert!(validate_password("secret-password").is_ok());
}
}