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