Compare commits

..

9 Commits

Author SHA1 Message Date
fish
f66b6221fd 统一请求格式为 device/language/data 结构 2026-04-13 20:56:07 +08:00
fish
7ddba306e8 统一接口返回格式为 success/message/data 结构 2026-04-13 20:51:26 +08:00
fish
124dbd019a 移除未使用的错误响应结构体 2026-04-13 20:47:43 +08:00
fish
581e736f1b 移除注册接口中未使用的邮箱字段 2026-04-13 20:42:19 +08:00
fish
7031411b3d 移除迁移脚本中的测试数据 2026-04-13 20:29:45 +08:00
fish
e95bc4d196 用户表 ID 改为 UUID V7 并由业务层生成 2026-04-13 20:26:29 +08:00
fish
49af914f9b 用户表拆分为主从模式并调整登录注册逻辑 2026-04-13 20:23:35 +08:00
fish
e3550fedac 调整项目结构 2026-04-13 20:15:27 +08:00
fish
738fa72ec0 统一 Docker 命名规范,使用连字符替代下划线 2026-04-11 23:10:47 +08:00
27 changed files with 311 additions and 780 deletions

548
.gitignore vendored
View File

@@ -1,548 +0,0 @@
# ---> Go
# If you prefer the allow list template instead of the deny list, see community template:
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
#
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work
go.work.sum
# env file
.env
# ---> Python
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
# ---> Rust
# Generated by Cargo
# will have compiled files and executables
debug/
target/
# These are backup files generated by rustfmt
**/*.rs.bk
# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb
# RustRover
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# ---> Node
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# ---> Xcode
## User settings
xcuserdata/
# ---> macOS
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# 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
# ---> Flutter
# Miscellaneous
*.class
*.lock
*.log
*.pyc
*.swp
.buildlog/
.history
# Flutter repo-specific
/bin/cache/
/bin/internal/bootstrap.bat
/bin/internal/bootstrap.sh
/bin/mingit/
/dev/benchmarks/mega_gallery/
/dev/bots/.recipe_deps
/dev/bots/android_tools/
/dev/devicelab/ABresults*.json
/dev/docs/doc/
/dev/docs/flutter.docs.zip
/dev/docs/lib/
/dev/docs/pubspec.yaml
/dev/integration_tests/**/xcuserdata
/dev/integration_tests/**/Pods
/packages/flutter/coverage/
version
analysis_benchmark.json
# packages file containing multi-root paths
.packages.generated
# Flutter/Dart/Pub related
**/doc/api/
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
**/generated_plugin_registrant.dart
.packages
.pub-preload-cache/
.pub/
build/
flutter_*.png
linked_*.ds
unlinked.ds
unlinked_spec.ds
# Android related
**/android/**/gradle-wrapper.jar
.gradle/
**/android/captures/
**/android/gradlew
**/android/gradlew.bat
**/android/local.properties
**/android/**/GeneratedPluginRegistrant.java
**/android/key.properties
*.jks
# iOS/XCode related
**/ios/**/*.mode1v3
**/ios/**/*.mode2v3
**/ios/**/*.moved-aside
**/ios/**/*.pbxuser
**/ios/**/*.perspectivev3
**/ios/**/*sync/
**/ios/**/.sconsign.dblite
**/ios/**/.tags*
**/ios/**/.vagrant/
**/ios/**/DerivedData/
**/ios/**/Icon?
**/ios/**/Pods/
**/ios/**/.symlinks/
**/ios/**/profile
**/ios/**/xcuserdata
**/ios/.generated/
**/ios/Flutter/.last_build_id
**/ios/Flutter/App.framework
**/ios/Flutter/Flutter.framework
**/ios/Flutter/Flutter.podspec
**/ios/Flutter/Generated.xcconfig
**/ios/Flutter/ephemeral
**/ios/Flutter/app.flx
**/ios/Flutter/app.zip
**/ios/Flutter/flutter_assets/
**/ios/Flutter/flutter_export_environment.sh
**/ios/ServiceDefinitions.json
**/ios/Runner/GeneratedPluginRegistrant.*
# macOS
**/Flutter/ephemeral/
**/Pods/
**/macos/Flutter/GeneratedPluginRegistrant.swift
**/macos/Flutter/ephemeral
**/xcuserdata/
# Windows
**/windows/flutter/generated_plugin_registrant.cc
**/windows/flutter/generated_plugin_registrant.h
**/windows/flutter/generated_plugins.cmake
# Linux
**/linux/flutter/generated_plugin_registrant.cc
**/linux/flutter/generated_plugin_registrant.h
**/linux/flutter/generated_plugins.cmake
# Coverage
coverage/
# Symbols
app.*.symbols
# Exceptions to above rules.
!**/ios/**/default.mode1v3
!**/ios/**/default.mode2v3
!**/ios/**/default.pbxuser
!**/ios/**/default.perspectivev3
!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages
!/dev/ci/**/Gemfile.lock
# ---> Android
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Log/OS Files
*.log
# Android Studio generated files and folders
captures/
.externalNativeBuild/
.cxx/
*.apk
output.json
# IntelliJ
*.iml
.idea/
misc.xml
deploymentTargetDropDown.xml
render.experimental.xml
# Keystore files
*.jks
*.keystore
# Google Services (e.g. APIs or Firebase)
google-services.json
# Android Profiling
*.hprof
backend/desc.md

18
LICENSE
View File

@@ -1,18 +0,0 @@
MIT License
Copyright (c) 2026 fish
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -1,2 +0,0 @@
# asset_helper

View File

View File

View File

@@ -1,15 +0,0 @@
-- 用户表初始化
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
email VARCHAR(100) UNIQUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- 插入测试用户(密码: 123456
-- bcrypt hash: $2b$12$REwMlLDCbzR4UpL6MWnzE.AacihwpFvQhGs7vDKTwwyNMb1qBWOTm
INSERT INTO users (username, password_hash, email)
VALUES ('admin', '$2b$12$REwMlLDCbzR4UpL6MWnzE.AacihwpFvQhGs7vDKTwwyNMb1qBWOTm', 'admin@example.com')
ON CONFLICT (username) DO NOTHING;

View File

@@ -1,185 +0,0 @@
use axum::{
extract::State,
http::StatusCode,
response::Json,
routing::post,
Router,
};
use bcrypt::hash;
use chrono::Utc;
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use std::env;
use std::sync::Arc;
use tracing::{info, warn};
use validator::Validate;
#[derive(Clone)]
struct AppState {
db: PgPool,
}
#[derive(Deserialize, Validate)]
struct RegisterRequest {
#[validate(length(min = 3, max = 50))]
username: String,
#[validate(length(min = 6))]
password: String,
#[validate(email)]
email: String,
}
#[derive(Serialize)]
struct RegisterResponse {
success: bool,
user_id: Option<i32>,
message: String,
}
#[derive(Serialize)]
struct ErrorResponse {
error: String,
}
#[tokio::main]
async fn main() {
tracing_subscriber::fmt::init();
info!("Starting user-register service...");
let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let pool = sqlx::postgres::PgPool::connect(&database_url)
.await
.expect("Failed to connect to database");
info!("Database connected");
let state = Arc::new(AppState { db: pool });
let app = Router::new()
.route("/register", post(register_handler))
.route("/health", axum::routing::get(health_handler))
.with_state(state);
let port = env::var("SERVICE_PORT").unwrap_or_else(|_| "8080".to_string());
let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", port))
.await
.unwrap();
info!("User-register service listening on port {}", port);
axum::serve(listener, app).await.unwrap();
}
async fn register_handler(
State(state): State<Arc<AppState>>,
Json(payload): Json<RegisterRequest>,
) -> (StatusCode, Json<RegisterResponse>) {
info!("Registration attempt for user: {}", payload.username);
// 参数校验
if let Err(e) = payload.validate() {
return (
StatusCode::BAD_REQUEST,
Json(RegisterResponse {
success: false,
user_id: None,
message: format!("Validation error: {}", e),
}),
);
}
// 检查用户名是否存在
let existing: Option<(i32,)> = sqlx::query_as("SELECT id FROM users WHERE username = $1")
.bind(&payload.username)
.fetch_optional(&state.db)
.await
.unwrap_or(None);
if existing.is_some() {
return (
StatusCode::CONFLICT,
Json(RegisterResponse {
success: false,
user_id: None,
message: "Username already exists".to_string(),
}),
);
}
// 检查邮箱是否存在
let existing_email: Option<(i32,)> = sqlx::query_as("SELECT id FROM users WHERE email = $1")
.bind(&payload.email)
.fetch_optional(&state.db)
.await
.unwrap_or(None);
if existing_email.is_some() {
return (
StatusCode::CONFLICT,
Json(RegisterResponse {
success: false,
user_id: None,
message: "Email already exists".to_string(),
}),
);
}
// 密码哈希
let password_hash = match hash(&payload.password, bcrypt::DEFAULT_COST) {
Ok(h) => h,
Err(e) => {
warn!("Password hashing failed: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(RegisterResponse {
success: false,
user_id: None,
message: "Internal error".to_string(),
}),
);
}
};
// 插入用户
let result = sqlx::query_as::<_, (i32,)>(
"INSERT INTO users (username, password_hash, email, created_at, updated_at)
VALUES ($1, $2, $3, $4, $4)
RETURNING id"
)
.bind(&payload.username)
.bind(&password_hash)
.bind(&payload.email)
.bind(Utc::now())
.fetch_one(&state.db)
.await;
match result {
Ok((user_id,)) => {
info!("User {} registered with id {}", payload.username, user_id);
(
StatusCode::CREATED,
Json(RegisterResponse {
success: true,
user_id: Some(user_id),
message: "User registered successfully".to_string(),
}),
)
}
Err(e) => {
warn!("Registration failed: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(RegisterResponse {
success: false,
user_id: None,
message: "Registration failed".to_string(),
}),
)
}
}
}
async fn health_handler() -> &'static str {
"OK"
}

View File

View File

@@ -8,7 +8,7 @@ services:
container_name: user-login
environment:
- RUST_LOG=info
- DATABASE_URL=postgres://postgres:postgres@user-db:5432/user_db
- DATABASE_URL=postgres://postgres:postgres@user-db:5432/user-db
- REDIS_URL=redis://user-redis:6379/0
- SERVICE_NAME=user-login
- SERVICE_PORT=8080
@@ -36,7 +36,7 @@ services:
container_name: user-register
environment:
- RUST_LOG=info
- DATABASE_URL=postgres://postgres:postgres@user-db:5432/user_db
- DATABASE_URL=postgres://postgres:postgres@user-db:5432/user-db
- REDIS_URL=redis://user-redis:6379/0
- SERVICE_NAME=user-register
- SERVICE_PORT=8080
@@ -62,9 +62,9 @@ services:
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_DB=user_db
- POSTGRES_DB=user-db
volumes:
- user_postgres_data:/var/lib/postgresql/data
- user-postgres-data:/var/lib/postgresql/data
- ./migrations:/docker-entrypoint-initdb.d:ro
ports:
- "5432:5432"
@@ -72,7 +72,7 @@ services:
- user-network
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d user_db"]
test: ["CMD-SHELL", "pg_isready -U postgres -d user-db"]
interval: 10s
timeout: 5s
retries: 5
@@ -81,7 +81,7 @@ services:
image: redis:8.6.2-alpine
container_name: user-redis
volumes:
- user_redis_data:/data
- user-redis-data:/data
ports:
- "6379:6379"
networks:
@@ -98,5 +98,7 @@ networks:
driver: bridge
volumes:
user_postgres_data:
user_redis_data:
user-postgres-data:
name: user-postgres-data
user-redis-data:
name: user-redis-data

View File

@@ -0,0 +1,36 @@
-- 用户主表
CREATE TABLE IF NOT EXISTS user_main (
id UUID PRIMARY KEY,
deleted BOOLEAN NOT NULL DEFAULT FALSE,
createdate TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
modifydate TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- 用户登录账号表
CREATE TABLE IF NOT EXISTS user_login_account (
id UUID PRIMARY KEY,
user_id UUID NOT NULL,
account VARCHAR(100) NOT NULL,
deleted BOOLEAN NOT NULL DEFAULT FALSE,
createdate TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
modifydate TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_user_login_account_user_main FOREIGN KEY (user_id) REFERENCES user_main(id)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_login_account_active
ON user_login_account(account)
WHERE deleted = FALSE;
-- 用户密码表
CREATE TABLE IF NOT EXISTS user_login_password (
id UUID PRIMARY KEY,
user_id UUID NOT NULL,
password VARCHAR(255) NOT NULL,
deleted BOOLEAN NOT NULL DEFAULT FALSE,
createdate TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
modifydate TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_user_login_password_user_main FOREIGN KEY (user_id) REFERENCES user_main(id)
);
CREATE INDEX IF NOT EXISTS idx_user_login_password_user_id
ON user_login_password(user_id);

View File

@@ -14,7 +14,10 @@ serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
# 数据库
sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "chrono"] }
sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "chrono", "uuid"] }
# UUID
uuid = { version = "1", features = ["v7", "serde"] }
# Redis
redis = { version = "0.29", features = ["tokio-comp"] }

View File

@@ -90,9 +90,12 @@ async fn login_handler(
) -> (StatusCode, Json<LoginResponse>) {
info!("Login attempt for user: {}", payload.username);
// 查询用户
// 查询用户账号与密码
let user: Option<(String,)> = sqlx::query_as(
"SELECT password_hash FROM users WHERE username = $1"
"SELECT p.password \
FROM user_login_account a \
JOIN user_login_password p ON a.user_id = p.user_id \
WHERE a.account = $1 AND a.deleted = FALSE AND p.deleted = FALSE"
)
.bind(&payload.username)
.fetch_optional(&state.db)

View File

@@ -11,7 +11,10 @@ tower = "0.5"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "chrono"] }
sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "chrono", "uuid"] }
# UUID
uuid = { version = "1", features = ["v7", "serde"] }
redis = { version = "0.29", features = ["tokio-comp"] }
bcrypt = "0.17"

View File

@@ -0,0 +1,252 @@
use axum::{
extract::State,
http::StatusCode,
response::Json,
routing::{get, post},
Router,
};
use bcrypt::hash;
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use std::env;
use std::sync::Arc;
use tracing::{info, warn};
use uuid::Uuid;
use validator::Validate;
#[derive(Clone)]
struct AppState {
db: PgPool,
}
#[derive(Deserialize, Validate)]
struct RegisterRequest {
#[validate(length(min = 3, max = 50))]
username: String,
#[validate(length(min = 6))]
password: String,
}
#[derive(Deserialize)]
struct ApiRequest<T> {
device: i32,
language: i32,
data: T,
}
#[derive(Serialize)]
struct ApiResponse<T> {
success: bool,
message: String,
data: Option<T>,
}
#[derive(Serialize)]
struct RegisterData {
user_id: Uuid,
}
#[tokio::main]
async fn main() {
tracing_subscriber::fmt::init();
info!("Starting user-register service...");
let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let pool = sqlx::postgres::PgPool::connect(&database_url)
.await
.expect("Failed to connect to database");
info!("Database connected");
let state = Arc::new(AppState { db: pool });
let app = Router::new()
.route("/register", post(register_handler))
.route("/health", get(health_handler))
.with_state(state);
let port = env::var("SERVICE_PORT").unwrap_or_else(|_| "8080".to_string());
let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", port))
.await
.unwrap();
info!("User-register service listening on port {}", port);
axum::serve(listener, app).await.unwrap();
}
async fn register_handler(
State(state): State<Arc<AppState>>,
Json(req): Json<ApiRequest<RegisterRequest>>,
) -> (StatusCode, Json<ApiResponse<RegisterData>>) {
info!(
"Registration attempt for user: {}, device: {}, language: {}",
req.data.username, req.device, req.language
);
// 参数校验
if let Err(e) = req.data.validate() {
return (
StatusCode::BAD_REQUEST,
Json(ApiResponse {
success: false,
message: format!("Validation error: {}", e),
data: None,
}),
);
}
// 检查账号是否已存在
let existing: Option<(Uuid,)> = sqlx::query_as(
"SELECT id FROM user_login_account WHERE account = $1 AND deleted = FALSE"
)
.bind(&req.data.username)
.fetch_optional(&state.db)
.await
.unwrap_or(None);
if existing.is_some() {
return (
StatusCode::CONFLICT,
Json(ApiResponse {
success: false,
message: "Username already exists".to_string(),
data: None,
}),
);
}
// 密码哈希
let password_hash = match hash(&req.data.password, bcrypt::DEFAULT_COST) {
Ok(h) => h,
Err(e) => {
warn!("Password hashing failed: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiResponse {
success: false,
message: "Internal error".to_string(),
data: None,
}),
);
}
};
// 插入用户(主从表事务)
let mut tx = match state.db.begin().await {
Ok(t) => t,
Err(e) => {
warn!("Transaction start failed: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiResponse {
success: false,
message: "Internal error".to_string(),
data: None,
}),
);
}
};
let user_id = Uuid::now_v7();
if let Err(e) = sqlx::query(
"INSERT INTO user_main (id) VALUES ($1)"
)
.bind(user_id)
.execute(&mut *tx)
.await
{
warn!("Insert user_main failed: {}", e);
let _ = tx.rollback().await;
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiResponse {
success: false,
message: "Registration failed".to_string(),
data: None,
}),
);
}
let account_id = Uuid::now_v7();
if let Err(e) = sqlx::query(
"INSERT INTO user_login_account (id, user_id, account) VALUES ($1, $2, $3)"
)
.bind(account_id)
.bind(user_id)
.bind(&req.data.username)
.execute(&mut *tx)
.await
{
warn!("Insert user_login_account failed: {}", e);
let _ = tx.rollback().await;
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiResponse {
success: false,
message: "Registration failed".to_string(),
data: None,
}),
);
}
let password_id = Uuid::now_v7();
if let Err(e) = sqlx::query(
"INSERT INTO user_login_password (id, user_id, password) VALUES ($1, $2, $3)"
)
.bind(password_id)
.bind(user_id)
.bind(&password_hash)
.execute(&mut *tx)
.await
{
warn!("Insert user_login_password failed: {}", e);
let _ = tx.rollback().await;
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiResponse {
success: false,
message: "Registration failed".to_string(),
data: None,
}),
);
}
match tx.commit().await {
Ok(()) => {
info!("User {} registered with id {}", req.data.username, user_id);
(
StatusCode::CREATED,
Json(ApiResponse {
success: true,
message: "User registered successfully".to_string(),
data: Some(RegisterData { user_id }),
}),
)
}
Err(e) => {
warn!("Registration failed: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiResponse {
success: false,
message: "Registration failed".to_string(),
data: None,
}),
)
}
}
}
async fn health_handler() -> (StatusCode, Json<ApiResponse<()>>) {
(
StatusCode::OK,
Json(ApiResponse {
success: true,
message: "OK".to_string(),
data: None,
}),
)
}