From 9da92580bed65b7fc05fd1df076c1cc0a8d650d8 Mon Sep 17 00:00:00 2001 From: fish Date: Mon, 13 Apr 2026 21:07:01 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E9=82=AE=E4=BB=B6=E6=B3=A8?= =?UTF-8?q?=E5=86=8C=E5=92=8C=E7=99=BB=E5=BD=95=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/user-service/docker-compose.yml | 55 ++++ services/user-service/migrations/001_init.sql | 15 ++ .../user-service/user-login-email/Cargo.toml | 45 ++++ .../user-service/user-login-email/Dockerfile | 39 +++ .../user-service/user-login-email/src/main.rs | 185 +++++++++++++ .../user-register-email/Cargo.toml | 33 +++ .../user-register-email/Dockerfile | 33 +++ .../user-register-email/src/main.rs | 252 ++++++++++++++++++ 8 files changed, 657 insertions(+) create mode 100644 services/user-service/user-login-email/Cargo.toml create mode 100644 services/user-service/user-login-email/Dockerfile create mode 100644 services/user-service/user-login-email/src/main.rs create mode 100644 services/user-service/user-register-email/Cargo.toml create mode 100644 services/user-service/user-register-email/Dockerfile create mode 100644 services/user-service/user-register-email/src/main.rs diff --git a/services/user-service/docker-compose.yml b/services/user-service/docker-compose.yml index a453bcd..77ec314 100644 --- a/services/user-service/docker-compose.yml +++ b/services/user-service/docker-compose.yml @@ -56,6 +56,61 @@ services: timeout: 10s retries: 3 + user-login-email: + build: + context: ../.. + dockerfile: services/user-service/user-login-email/Dockerfile + container_name: user-login-email + environment: + - RUST_LOG=info + - DATABASE_URL=postgres://postgres:postgres@user-db:5432/user-db + - REDIS_URL=redis://user-redis:6379/0 + - SERVICE_NAME=user-login-email + - SERVICE_PORT=8080 + - JWT_SECRET=${JWT_SECRET:-dev-secret-key} + ports: + - "8003:8080" + depends_on: + user-db: + condition: service_healthy + user-redis: + condition: service_healthy + networks: + - user-network + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/health"] + interval: 30s + timeout: 10s + retries: 3 + + user-register-email: + build: + context: ../.. + dockerfile: services/user-service/user-register-email/Dockerfile + container_name: user-register-email + environment: + - RUST_LOG=info + - DATABASE_URL=postgres://postgres:postgres@user-db:5432/user-db + - REDIS_URL=redis://user-redis:6379/0 + - SERVICE_NAME=user-register-email + - SERVICE_PORT=8080 + ports: + - "8004:8080" + depends_on: + user-db: + condition: service_healthy + user-redis: + condition: service_healthy + networks: + - user-network + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/health"] + interval: 30s + timeout: 10s + retries: 3 + user-db: image: postgres:18.3-alpine3.23 container_name: user-db diff --git a/services/user-service/migrations/001_init.sql b/services/user-service/migrations/001_init.sql index a3ae2d4..e59559a 100644 --- a/services/user-service/migrations/001_init.sql +++ b/services/user-service/migrations/001_init.sql @@ -34,3 +34,18 @@ CREATE TABLE IF NOT EXISTS user_login_password ( CREATE INDEX IF NOT EXISTS idx_user_login_password_user_id ON user_login_password(user_id); + +-- 用户登录邮箱表 +CREATE TABLE IF NOT EXISTS user_login_email ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL, + email 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_email_user_main FOREIGN KEY (user_id) REFERENCES user_main(id) +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_user_login_email_active + ON user_login_email(email) + WHERE deleted = FALSE; diff --git a/services/user-service/user-login-email/Cargo.toml b/services/user-service/user-login-email/Cargo.toml new file mode 100644 index 0000000..d3be127 --- /dev/null +++ b/services/user-service/user-login-email/Cargo.toml @@ -0,0 +1,45 @@ +[package] +name = "user-login-email" +version = "0.1.0" +edition = "2024" + +[dependencies] +# Web 框架 +axum = "0.8" +tokio = { version = "1", features = ["full"] } +tower = "0.5" + +# 序列化 +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +# 数据库 +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"] } + +# 密码哈希(bcrypt) +bcrypt = "0.17" + +# JWT +jsonwebtoken = "9.3" + +# 时间和日志 +chrono = { version = "0.4", features = ["serde"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +# 环境变量 +dotenvy = "0.15" + +# 错误处理 +thiserror = "2.0" + +[profile.release] +opt-level = 3 +lto = true +strip = true diff --git a/services/user-service/user-login-email/Dockerfile b/services/user-service/user-login-email/Dockerfile new file mode 100644 index 0000000..79d9632 --- /dev/null +++ b/services/user-service/user-login-email/Dockerfile @@ -0,0 +1,39 @@ +# 构建阶段 +FROM rust:1.94.1-alpine3.23 AS builder + +RUN apk add --no-cache musl-dev openssl-dev openssl-libs-static pkgconfig + +WORKDIR /app + +# 复制 user-login-email 代码 +COPY services/user-service/user-login-email/Cargo.toml services/user-service/user-login-email/Cargo.lock* ./ + +# 缓存依赖 +RUN mkdir -p src && echo 'fn main() {}' > src/main.rs +RUN cargo build --release 2>/dev/null || true +RUN rm -rf src + +# 复制真实源码 +COPY services/user-service/user-login-email/src ./src + +# 重新构建 +RUN touch src/main.rs && cargo build --release + +# 运行阶段 +FROM alpine:3.23 AS runtime + +RUN apk add --no-cache ca-certificates + +RUN addgroup -g 1000 appuser && adduser -D -u 1000 -G appuser appuser + +WORKDIR /app + +COPY --from=builder /app/target/release/user-login-email /app/user-login-email + +RUN chown -R appuser:appuser /app + +USER appuser + +EXPOSE 8080 + +CMD ["./user-login-email"] diff --git a/services/user-service/user-login-email/src/main.rs b/services/user-service/user-login-email/src/main.rs new file mode 100644 index 0000000..896c13b --- /dev/null +++ b/services/user-service/user-login-email/src/main.rs @@ -0,0 +1,185 @@ +use axum::{ + extract::State, + http::StatusCode, + response::Json, + routing::post, + Router, +}; +use bcrypt::verify; +use chrono::{Duration, Utc}; +use jsonwebtoken::{encode, EncodingKey, Header}; +use serde::{Deserialize, Serialize}; +use sqlx::{Pool, Postgres}; +use std::env; +use std::sync::Arc; +use tracing::{info, warn}; + +// 应用状态 +#[derive(Clone)] +struct AppState { + db: Pool, + jwt_secret: String, +} + +// 登录请求 +#[derive(Deserialize)] +struct LoginRequest { + email: String, + password: String, +} + +// 登录响应 +#[derive(Serialize)] +struct LoginResponse { + success: bool, + token: Option, + message: String, +} + +// JWT Claims +#[derive(Serialize, Deserialize)] +struct Claims { + sub: String, + exp: usize, + iat: usize, +} + +#[tokio::main] +async fn main() { + // 初始化日志 + tracing_subscriber::fmt::init(); + + info!("Starting user-login-email 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"); + + // JWT 密钥 + let jwt_secret = env::var("JWT_SECRET").unwrap_or_else(|_| "dev-secret".to_string()); + + let state = Arc::new(AppState { + db: pool, + jwt_secret, + }); + + // 路由 + let app = Router::new() + .route("/login", post(login_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-login-email service listening on port {}", port); + + axum::serve(listener, app).await.unwrap(); +} + +// 登录处理 +async fn login_handler( + State(state): State>, + Json(payload): Json, +) -> (StatusCode, Json) { + info!("Login attempt for email: {}", payload.email); + + // 查询用户邮箱与密码 + let user: Option<(String,)> = sqlx::query_as( + "SELECT p.password \ + FROM user_login_email e \ + JOIN user_login_password p ON e.user_id = p.user_id \ + WHERE e.email = $1 AND e.deleted = FALSE AND p.deleted = FALSE" + ) + .bind(&payload.email) + .fetch_optional(&state.db) + .await + .unwrap_or(None); + + match user { + Some((password_hash,)) => { + // 验证密码 + tracing::debug!("Verifying password: input_len={}, hash_len={}", payload.password.len(), password_hash.len()); + match verify(&payload.password, &password_hash) { + Ok(true) => { + info!("Email {} logged in successfully", payload.email); + + // 生成 JWT + let token = generate_token(&payload.email, &state.jwt_secret); + + ( + StatusCode::OK, + Json(LoginResponse { + success: true, + token: Some(token), + message: "Login successful".to_string(), + }), + ) + } + Ok(false) => { + warn!("Invalid password for email {}", payload.email); + ( + StatusCode::UNAUTHORIZED, + Json(LoginResponse { + success: false, + token: None, + message: "Invalid credentials".to_string(), + }), + ) + } + Err(e) => { + warn!("Password verification error: {:?}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(LoginResponse { + success: false, + token: None, + message: "Internal error".to_string(), + }), + ) + } + } + } + None => { + warn!("Email not found: {}", payload.email); + ( + StatusCode::UNAUTHORIZED, + Json(LoginResponse { + success: false, + token: None, + message: "Invalid credentials".to_string(), + }), + ) + } + } +} + +// 健康检查 +async fn health_handler() -> &'static str { + "OK" +} + +// 生成 JWT Token +fn generate_token(email: &str, secret: &str) -> String { + let now = Utc::now(); + let exp = now + Duration::hours(24); + + let claims = Claims { + sub: email.to_string(), + iat: now.timestamp() as usize, + exp: exp.timestamp() as usize, + }; + + encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(secret.as_bytes()), + ) + .unwrap() +} diff --git a/services/user-service/user-register-email/Cargo.toml b/services/user-service/user-register-email/Cargo.toml new file mode 100644 index 0000000..4e37e10 --- /dev/null +++ b/services/user-service/user-register-email/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "user-register-email" +version = "0.1.0" +edition = "2024" + +[dependencies] +axum = "0.8" +tokio = { version = "1", features = ["full"] } +tower = "0.5" + +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +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" + +chrono = { version = "0.4", features = ["serde"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +dotenvy = "0.15" +thiserror = "2.0" +validator = { version = "0.20", features = ["derive"] } + +[profile.release] +opt-level = 3 +lto = true +strip = true diff --git a/services/user-service/user-register-email/Dockerfile b/services/user-service/user-register-email/Dockerfile new file mode 100644 index 0000000..8a24809 --- /dev/null +++ b/services/user-service/user-register-email/Dockerfile @@ -0,0 +1,33 @@ +FROM rust:1.94.1-alpine3.23 AS builder + +RUN apk add --no-cache musl-dev openssl-dev openssl-libs-static pkgconfig + +WORKDIR /app + +COPY services/user-service/user-register-email/Cargo.toml services/user-service/user-register-email/Cargo.lock* ./ + +RUN mkdir -p src && echo 'fn main() {}' > src/main.rs +RUN cargo build --release 2>/dev/null || true +RUN rm -rf src + +COPY services/user-service/user-register-email/src ./src + +RUN touch src/main.rs && cargo build --release + +FROM alpine:3.23 AS runtime + +RUN apk add --no-cache ca-certificates + +RUN addgroup -g 1000 appuser && adduser -D -u 1000 -G appuser appuser + +WORKDIR /app + +COPY --from=builder /app/target/release/user-register-email /app/user-register-email + +RUN chown -R appuser:appuser /app + +USER appuser + +EXPOSE 8080 + +CMD ["./user-register-email"] diff --git a/services/user-service/user-register-email/src/main.rs b/services/user-service/user-register-email/src/main.rs new file mode 100644 index 0000000..211e62c --- /dev/null +++ b/services/user-service/user-register-email/src/main.rs @@ -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(email)] + email: String, + #[validate(length(min = 6))] + password: String, +} + +#[derive(Deserialize)] +struct ApiRequest { + device: i32, + language: i32, + data: T, +} + +#[derive(Serialize)] +struct ApiResponse { + success: bool, + message: String, + data: Option, +} + +#[derive(Serialize)] +struct RegisterData { + user_id: Uuid, +} + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt::init(); + + info!("Starting user-register-email 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-email service listening on port {}", port); + + axum::serve(listener, app).await.unwrap(); +} + +async fn register_handler( + State(state): State>, + Json(req): Json>, +) -> (StatusCode, Json>) { + info!( + "Email registration attempt for: {}, device: {}, language: {}", + req.data.email, 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_email WHERE email = $1 AND deleted = FALSE" + ) + .bind(&req.data.email) + .fetch_optional(&state.db) + .await + .unwrap_or(None); + + if existing.is_some() { + return ( + StatusCode::CONFLICT, + Json(ApiResponse { + success: false, + message: "Email 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 email_id = Uuid::now_v7(); + if let Err(e) = sqlx::query( + "INSERT INTO user_login_email (id, user_id, email) VALUES ($1, $2, $3)" + ) + .bind(email_id) + .bind(user_id) + .bind(&req.data.email) + .execute(&mut *tx) + .await + { + warn!("Insert user_login_email 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!("Email {} registered with id {}", req.data.email, 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>) { + ( + StatusCode::OK, + Json(ApiResponse { + success: true, + message: "OK".to_string(), + data: None, + }), + ) +}