feat: implement dfguide-registrar project with Postgres user registration functionality
This commit is contained in:
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+13
@@ -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"
|
||||
@@ -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/
|
||||
@@ -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} 未设置"))
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user