Compare commits

..

8 Commits

15 changed files with 1013 additions and 47 deletions

261
AGENTS.md Normal file
View File

@@ -0,0 +1,261 @@
# AGENTS.md
本文件为 AI Agent 提供项目背景、结构说明和开发规范。
## 项目概述
这是一个基于 **Rust** 的微服务后端项目,采用 **Axum + Tokio** 技术栈,使用 **Nginx** 作为 API 网关,**PostgreSQL** 作为数据库,**Redis** 作为缓存。服务以 Docker 容器形式部署,每个核心功能拆分为独立的微服务二进制文件。
## 技术栈
| 层级 | 技术 |
|------|------|
| 语言 | Rust 2024 Edition |
| Web 框架 | axum 0.8, tokio 1.x, tower 0.5 |
| 数据库 | PostgreSQL 18.3 (sqlx 0.8) |
| 缓存 | Redis 8.6.2 (redis 0.29) |
| 网关 | Nginx 1.25 (Alpine) |
| 部署 | Docker, Docker Compose |
| 其他 | bcrypt, jsonwebtoken, uuid v7, chrono, tracing, validator |
## 项目结构
```
backend/
├── services/ # 微服务目录
│ └── user-service/ # 用户服务(当前唯一实现的服务域)
│ ├── user-login-account/ # 账号登录服务 (port 8001)
│ ├── user-register-account/ # 账号注册服务 (port 8002)
│ ├── user-login-email/ # 邮箱登录服务 (port 8003)
│ ├── user-register-email/ # 邮箱注册服务 (port 8004)
│ ├── migrations/ # 数据库初始化 SQL
│ ├── docker-compose.yml # 用户服务本地编排
│ └── Dockerfile # 通用/遗留构建文件
├── gateway/ # API 网关
│ ├── Dockerfile
│ └── nginx/
│ ├── nginx.conf
│ ├── conf.d/default.conf
│ └── conf.d/services/ # 各服务路由配置
├── shared/ # 共享代码库(当前为空,待扩展)
├── deploy/
│ └── local/redis.conf # 本地 Redis 配置
├── scripts/
│ ├── gateway.sh # 网关管理脚本(测试/重载/日志/证书)
│ └── init-multiple-databases.sh # Postgres 多库初始化
└── README.md
```
## 微服务架构说明
### 服务拆分原则
每个用户功能(登录/注册)按**认证方式**拆分为独立服务:
- `user-login-account`: 账号密码登录,签发 JWT
- `user-login-email`: 邮箱密码登录,签发 JWT
- `user-register-account`: 账号注册,写入 `user_main` / `user_login_account` / `user_login_password`
- `user-register-email`: 邮箱注册,写入 `user_main` / `user_login_email` / `user_login_password`
每个服务都是独立的 Rust Crate拥有独立的 `Cargo.toml``src/main.rs``Dockerfile`
### 数据库模型
核心表结构(见 `services/user-service/migrations/001_init.sql`
- `user_main(id UUID PK, deleted BOOLEAN, create_date, modify_date)`
- `user_login_account(id UUID PK, user_id FK, account VARCHAR)`
- `user_login_email(id UUID PK, user_id FK, email VARCHAR)`
- `user_login_password(id UUID PK, user_id FK, password VARCHAR)`
采用**软删除**设计(`deleted` 字段),账号/邮箱通过部分索引保证唯一性:
```sql
CREATE UNIQUE INDEX ... ON user_login_account(account) WHERE deleted = FALSE;
```
## 开发规范
### 1. API 公共约定
项目中存在两类接口风格,新增服务时请遵循对应场景的约定:
#### 注册/业务类接口(使用统一包装)
**请求包装格式:**
```json
{
"device": 1,
"language": 1,
"data": {
// 业务字段
}
}
```
- `device`: 设备类型标识(`i32`
- `1` = iOS
- `2` = Android
- `3` = Web
- `4` = iPad
- `5` = macOS
- `6` = Windows
- `7` = Linux
- `language`: 语言标识(`i32`
- `1` = 简体中文
- `2` = 繁体中文
- `3` = 英文
- `data`: 实际业务请求体
**响应包装格式:**
```json
{
"success": true,
"message": "User registered successfully",
"data": {
// 业务返回数据,失败时为 null
}
}
```
- `success`: 布尔值,表示业务是否成功
- `message`: 可读的状态描述或错误信息
- `data`: 业务数据,`Option<T>`,失败时返回 `null`
#### 登录/认证类接口(扁平响应)
**请求格式:** 直接携带凭证字段(如 `username`/`email` + `password`)。
**响应格式:**
```json
{
"success": true,
"token": "eyJhbGciOiJIUzI1NiIs...",
"message": "Login successful"
}
```
- `success`: 布尔值
- `token`: JWT Token认证失败或错误时为 `null`
- `message`: 状态描述
#### 健康检查
所有服务必须暴露 `GET /health`,成功时返回 HTTP 200
```text
OK
```
#### 错误响应HTTP 非 200
网关层返回统一 JSON 错误:
```json
{
"error": "Not Found",
"message": "The requested resource was not found",
"code": 404
}
```
### 2. 代码风格
- 使用 **Rust 2024 Edition**
- 注释使用**中文**。
- 服务状态通过 `Arc<AppState>` 注入到 Axum Handler 中。
- 注册类服务统一使用包装请求/响应格式:
```rust
struct ApiRequest<T> { device: i32, language: i32, data: T }
struct ApiResponse<T> { success: bool, message: String, data: Option<T> }
```
### 3. 时间字段约定
所有表中的 `create_date` 和 `modify_date` **必须由业务层生成并传入**数据库Schema中**不设置** `DEFAULT CURRENT_TIMESTAMP`,也不使用触发器自动更新。
- 建表时:
```sql
create_date TIMESTAMP WITH TIME ZONE NOT NULL,
modify_date TIMESTAMP WITH TIME ZONE NOT NULL
```
- Rust 代码中使用 `chrono::Utc::now()` 生成时间戳,统一在事务开始前创建 `let now = Utc::now();`,确保同一笔业务中各表时间一致。
- `modify_date` 更新时同样需要在业务代码中显式传入 `Utc::now()`。
#### 时区策略
项目采用**数据库存 UTC、查询按东八区显示**的策略:
- 业务层始终使用 `chrono::Utc::now()` 生成 UTC 时间写入数据库。
- 每个服务在建立数据库连接池后,执行 `SET TIME ZONE 'Asia/Shanghai';`,确保 `TIMESTAMP WITH TIME ZONE` 字段在查询时以东八区格式返回。
- 如需在 Rust 代码中做东八区展示转换,使用 `chrono::FixedOffset::east_opt(8 * 3600)` 处理。
### 4. 环境变量
所有服务通过环境变量读取配置:
- `DATABASE_URL` — PostgreSQL 连接串(必需)
- `REDIS_URL` — Redis 连接串
- `SERVICE_PORT` — 服务监听端口(默认 8080
- `JWT_SECRET` — JWT 签名密钥
- `RUST_LOG` — 日志级别
### 5. Docker 构建
- 各微服务 Dockerfile 的构建上下文为**项目根目录**`docker-compose.yml` 中使用 `context: ../..`)。
- 构建采用多阶段builder + runtime基于 `rust:1.94.1-alpine3.23` 编译,最终运行在 `alpine:3.23`。
- 共享代码更新时,需确保 `shared/` 目录在 Dockerfile 中被正确复制。
### 6. 网关与路由
- Nginx 监听 80/443开发环境使用自签名证书。
- 路由前缀约定:
- `/api/v1/users` → 用户服务通用接口
- `/api/v1/auth` → 认证接口(更严格限流)
- 新增服务时,需在 `gateway/nginx/conf.d/services/` 下创建对应 `.conf` 文件,并在 `nginx.conf` 中添加上游 `upstream`。
## 常用命令
### 启动用户服务(本地开发)
```bash
cd services/user-service
docker-compose up --build
```
### 网关管理
```bash
# 测试配置
./scripts/gateway.sh test
# 生成开发证书
./scripts/gateway.sh certs
# 查看状态
./scripts/gateway.sh status
# 热重载(容器运行中)
./scripts/gateway.sh reload
```
### 本地编译单个服务
```bash
cd services/user-service/user-login-account
cargo run
```
## 扩展指南
### 新增微服务
1. 在 `services/<service-domain>/` 下创建新目录,如 `services/order-service/order-create/`。
2. 编写独立的 `Cargo.toml`、`src/main.rs`、`Dockerfile`。
3. 在 `gateway/nginx/conf.d/services/` 添加路由配置。
4. 在 `gateway/nginx/nginx.conf` 添加 `upstream`。
5. 如需新数据库表,在对应服务域的 `migrations/` 目录添加 SQL 文件。
### 共享代码提取
当前 `shared/` 目录为空。当多个服务需要共用模型、中间件或工具函数时:
1. 在 `shared/` 下创建子模块(如 `shared/models`、`shared/middleware`)。
2. 将共享 crate 以 path dependency 引入各微服务:
```toml
[dependencies]
shared = { path = "../../shared" }
```
3. 更新各 Dockerfile确保 `COPY shared /app/shared` 在依赖缓存步骤之前执行。
## 注意事项
- `services/user-service/Dockerfile` 是一个通用构建文件,但当前各微服务使用自己的 Dockerfile。修改时请确认影响范围。
- 当前 `shared/` 为空Agent 在修改代码时若发现重复逻辑,可提议提取到 `shared/`。
- 网关配置文件中的 `api.example.com` 为占位域名,本地开发需配置 hosts 或使用 `localhost`。

View File

@@ -1,16 +1,16 @@
version: "3.8" version: "3.8"
services: services:
user-login: user-login-account:
build: build:
context: ../.. context: ../..
dockerfile: services/user-service/user-login/Dockerfile dockerfile: services/user-service/user-login-account/Dockerfile
container_name: user-login container_name: user-login-account
environment: environment:
- RUST_LOG=info - 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 - REDIS_URL=redis://user-redis:6379/0
- SERVICE_NAME=user-login - SERVICE_NAME=user-login-account
- SERVICE_PORT=8080 - SERVICE_PORT=8080
- JWT_SECRET=${JWT_SECRET:-dev-secret-key} - JWT_SECRET=${JWT_SECRET:-dev-secret-key}
ports: ports:
@@ -29,16 +29,16 @@ services:
timeout: 10s timeout: 10s
retries: 3 retries: 3
user-register: user-register-account:
build: build:
context: ../.. context: ../..
dockerfile: services/user-service/user-register/Dockerfile dockerfile: services/user-service/user-register-account/Dockerfile
container_name: user-register container_name: user-register-account
environment: environment:
- RUST_LOG=info - 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 - REDIS_URL=redis://user-redis:6379/0
- SERVICE_NAME=user-register - SERVICE_NAME=user-register-account
- SERVICE_PORT=8080 - SERVICE_PORT=8080
ports: ports:
- "8002:8080" - "8002:8080"
@@ -56,6 +56,61 @@ services:
timeout: 10s timeout: 10s
retries: 3 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: user-db:
image: postgres:18.3-alpine3.23 image: postgres:18.3-alpine3.23
container_name: user-db container_name: user-db

View File

@@ -2,8 +2,8 @@
CREATE TABLE IF NOT EXISTS user_main ( CREATE TABLE IF NOT EXISTS user_main (
id UUID PRIMARY KEY, id UUID PRIMARY KEY,
deleted BOOLEAN NOT NULL DEFAULT FALSE, deleted BOOLEAN NOT NULL DEFAULT FALSE,
createdate TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, create_date TIMESTAMP WITH TIME ZONE NOT NULL,
modifydate TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP modify_date TIMESTAMP WITH TIME ZONE NOT NULL
); );
-- 用户登录账号表 -- 用户登录账号表
@@ -12,8 +12,8 @@ CREATE TABLE IF NOT EXISTS user_login_account (
user_id UUID NOT NULL, user_id UUID NOT NULL,
account VARCHAR(100) NOT NULL, account VARCHAR(100) NOT NULL,
deleted BOOLEAN NOT NULL DEFAULT FALSE, deleted BOOLEAN NOT NULL DEFAULT FALSE,
createdate TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, create_date TIMESTAMP WITH TIME ZONE NOT NULL,
modifydate TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, modify_date TIMESTAMP WITH TIME ZONE NOT NULL,
CONSTRAINT fk_user_login_account_user_main FOREIGN KEY (user_id) REFERENCES user_main(id) CONSTRAINT fk_user_login_account_user_main FOREIGN KEY (user_id) REFERENCES user_main(id)
); );
@@ -27,10 +27,25 @@ CREATE TABLE IF NOT EXISTS user_login_password (
user_id UUID NOT NULL, user_id UUID NOT NULL,
password VARCHAR(255) NOT NULL, password VARCHAR(255) NOT NULL,
deleted BOOLEAN NOT NULL DEFAULT FALSE, deleted BOOLEAN NOT NULL DEFAULT FALSE,
createdate TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, create_date TIMESTAMP WITH TIME ZONE NOT NULL,
modifydate TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, modify_date TIMESTAMP WITH TIME ZONE NOT NULL,
CONSTRAINT fk_user_login_password_user_main FOREIGN KEY (user_id) REFERENCES user_main(id) 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 CREATE INDEX IF NOT EXISTS idx_user_login_password_user_id
ON 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,
create_date TIMESTAMP WITH TIME ZONE NOT NULL,
modify_date TIMESTAMP WITH TIME ZONE NOT NULL,
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;

View File

@@ -0,0 +1,45 @@
[package]
name = "user-login-account"
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

View File

@@ -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-account 代码
COPY services/user-service/user-login-account/Cargo.toml services/user-service/user-login-account/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-account/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-account /app/user-login-account
RUN chown -R appuser:appuser /app
USER appuser
EXPOSE 8080
CMD ["./user-login-account"]

View File

@@ -28,12 +28,18 @@ struct LoginRequest {
password: String, password: String,
} }
// 登录响应 // 统一响应包装
#[derive(Serialize)] #[derive(Serialize)]
struct LoginResponse { struct ApiResponse<T> {
success: bool, success: bool,
token: Option<String>,
message: String, message: String,
data: Option<T>,
}
// 登录业务数据
#[derive(Serialize)]
struct LoginData {
token: String,
} }
// JWT Claims // JWT Claims
@@ -49,7 +55,7 @@ async fn main() {
// 初始化日志 // 初始化日志
tracing_subscriber::fmt::init(); tracing_subscriber::fmt::init();
info!("Starting user-login service..."); info!("Starting user-login-account service...");
// 数据库连接 // 数据库连接
let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set"); let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
@@ -57,6 +63,11 @@ async fn main() {
.await .await
.expect("Failed to connect to database"); .expect("Failed to connect to database");
sqlx::query("SET TIME ZONE 'Asia/Shanghai'")
.execute(&pool)
.await
.expect("Failed to set timezone");
info!("Database connected"); info!("Database connected");
// JWT 密钥 // JWT 密钥
@@ -87,12 +98,12 @@ async fn main() {
async fn login_handler( async fn login_handler(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Json(payload): Json<LoginRequest>, Json(payload): Json<LoginRequest>,
) -> (StatusCode, Json<LoginResponse>) { ) -> (StatusCode, Json<ApiResponse<LoginData>>) {
info!("Login attempt for user: {}", payload.username); info!("Login attempt for user: {}", payload.username);
// 查询用户账号与密码 // 查询用户账号与密码
let user: Option<(String,)> = sqlx::query_as( let user: Option<(uuid::Uuid, String)> = sqlx::query_as(
"SELECT p.password \ "SELECT a.user_id, p.password \
FROM user_login_account a \ FROM user_login_account a \
JOIN user_login_password p ON a.user_id = p.user_id \ JOIN user_login_password p ON a.user_id = p.user_id \
WHERE a.account = $1 AND a.deleted = FALSE AND p.deleted = FALSE" WHERE a.account = $1 AND a.deleted = FALSE AND p.deleted = FALSE"
@@ -103,7 +114,7 @@ async fn login_handler(
.unwrap_or(None); .unwrap_or(None);
match user { match user {
Some((password_hash,)) => { Some((user_id, password_hash)) => {
// 验证密码 // 验证密码
tracing::debug!("Verifying password: input_len={}, hash_len={}", payload.password.len(), password_hash.len()); tracing::debug!("Verifying password: input_len={}, hash_len={}", payload.password.len(), password_hash.len());
match verify(&payload.password, &password_hash) { match verify(&payload.password, &password_hash) {
@@ -111,14 +122,14 @@ async fn login_handler(
info!("User {} logged in successfully", payload.username); info!("User {} logged in successfully", payload.username);
// 生成 JWT // 生成 JWT
let token = generate_token(&payload.username, &state.jwt_secret); let token = generate_token(&user_id.to_string(), &state.jwt_secret);
( (
StatusCode::OK, StatusCode::OK,
Json(LoginResponse { Json(ApiResponse {
success: true, success: true,
token: Some(token),
message: "Login successful".to_string(), message: "Login successful".to_string(),
data: Some(LoginData { token }),
}), }),
) )
} }
@@ -126,10 +137,10 @@ async fn login_handler(
warn!("Invalid password for user {}", payload.username); warn!("Invalid password for user {}", payload.username);
( (
StatusCode::UNAUTHORIZED, StatusCode::UNAUTHORIZED,
Json(LoginResponse { Json(ApiResponse {
success: false, success: false,
token: None,
message: "Invalid credentials".to_string(), message: "Invalid credentials".to_string(),
data: None,
}), }),
) )
} }
@@ -137,10 +148,10 @@ async fn login_handler(
warn!("Password verification error: {:?}", e); warn!("Password verification error: {:?}", e);
( (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
Json(LoginResponse { Json(ApiResponse {
success: false, success: false,
token: None,
message: "Internal error".to_string(), message: "Internal error".to_string(),
data: None,
}), }),
) )
} }
@@ -166,12 +177,12 @@ async fn health_handler() -> &'static str {
} }
// 生成 JWT Token // 生成 JWT Token
fn generate_token(username: &str, secret: &str) -> String { fn generate_token(sub: &str, secret: &str) -> String {
let now = Utc::now(); let now = Utc::now();
let exp = now + Duration::hours(24); let exp = now + Duration::days(7);
let claims = Claims { let claims = Claims {
sub: username.to_string(), sub: sub.to_string(),
iat: now.timestamp() as usize, iat: now.timestamp() as usize,
exp: exp.timestamp() as usize, exp: exp.timestamp() as usize,
}; };

View File

@@ -1,5 +1,5 @@
[package] [package]
name = "user-login" name = "user-login-email"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"

View File

@@ -5,8 +5,8 @@ RUN apk add --no-cache musl-dev openssl-dev openssl-libs-static pkgconfig
WORKDIR /app WORKDIR /app
# 复制 user-login 代码 # 复制 user-login-email 代码
COPY services/user-service/user-login/Cargo.toml services/user-service/user-login/Cargo.lock* ./ 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 mkdir -p src && echo 'fn main() {}' > src/main.rs
@@ -14,7 +14,7 @@ RUN cargo build --release 2>/dev/null || true
RUN rm -rf src RUN rm -rf src
# 复制真实源码 # 复制真实源码
COPY services/user-service/user-login/src ./src COPY services/user-service/user-login-email/src ./src
# 重新构建 # 重新构建
RUN touch src/main.rs && cargo build --release RUN touch src/main.rs && cargo build --release
@@ -28,7 +28,7 @@ RUN addgroup -g 1000 appuser && adduser -D -u 1000 -G appuser appuser
WORKDIR /app WORKDIR /app
COPY --from=builder /app/target/release/user-login /app/user-login COPY --from=builder /app/target/release/user-login-email /app/user-login-email
RUN chown -R appuser:appuser /app RUN chown -R appuser:appuser /app
@@ -36,4 +36,4 @@ USER appuser
EXPOSE 8080 EXPOSE 8080
CMD ["./user-login"] CMD ["./user-login-email"]

View File

@@ -0,0 +1,196 @@
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<Postgres>,
jwt_secret: String,
}
// 登录请求
#[derive(Deserialize)]
struct LoginRequest {
email: String,
password: String,
}
// 统一响应包装
#[derive(Serialize)]
struct ApiResponse<T> {
success: bool,
message: String,
data: Option<T>,
}
// 登录业务数据
#[derive(Serialize)]
struct LoginData {
token: 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");
sqlx::query("SET TIME ZONE 'Asia/Shanghai'")
.execute(&pool)
.await
.expect("Failed to set timezone");
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<Arc<AppState>>,
Json(payload): Json<LoginRequest>,
) -> (StatusCode, Json<ApiResponse<LoginData>>) {
info!("Login attempt for email: {}", payload.email);
// 查询用户邮箱与密码
let user: Option<(uuid::Uuid, String)> = sqlx::query_as(
"SELECT e.user_id, 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((user_id, 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(&user_id.to_string(), &state.jwt_secret);
(
StatusCode::OK,
Json(ApiResponse {
success: true,
message: "Login successful".to_string(),
data: Some(LoginData { token }),
}),
)
}
Ok(false) => {
warn!("Invalid password for email {}", payload.email);
(
StatusCode::UNAUTHORIZED,
Json(ApiResponse {
success: false,
message: "Invalid credentials".to_string(),
data: None,
}),
)
}
Err(e) => {
warn!("Password verification error: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiResponse {
success: false,
message: "Internal error".to_string(),
data: None,
}),
)
}
}
}
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(sub: &str, secret: &str) -> String {
let now = Utc::now();
let exp = now + Duration::days(7);
let claims = Claims {
sub: sub.to_string(),
iat: now.timestamp() as usize,
exp: exp.timestamp() as usize,
};
encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(secret.as_bytes()),
)
.unwrap()
}

View File

@@ -0,0 +1,33 @@
[package]
name = "user-register-account"
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

View File

@@ -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-account/Cargo.toml services/user-service/user-register-account/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-account/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-account /app/user-register-account
RUN chown -R appuser:appuser /app
USER appuser
EXPOSE 8080
CMD ["./user-register-account"]

View File

@@ -10,6 +10,7 @@ use serde::{Deserialize, Serialize};
use sqlx::PgPool; use sqlx::PgPool;
use std::env; use std::env;
use std::sync::Arc; use std::sync::Arc;
use chrono::Utc;
use tracing::{info, warn}; use tracing::{info, warn};
use uuid::Uuid; use uuid::Uuid;
use validator::Validate; use validator::Validate;
@@ -50,13 +51,18 @@ struct RegisterData {
async fn main() { async fn main() {
tracing_subscriber::fmt::init(); tracing_subscriber::fmt::init();
info!("Starting user-register service..."); info!("Starting user-register-account service...");
let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set"); let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let pool = sqlx::postgres::PgPool::connect(&database_url) let pool = sqlx::postgres::PgPool::connect(&database_url)
.await .await
.expect("Failed to connect to database"); .expect("Failed to connect to database");
sqlx::query("SET TIME ZONE 'Asia/Shanghai'")
.execute(&pool)
.await
.expect("Failed to set timezone");
info!("Database connected"); info!("Database connected");
let state = Arc::new(AppState { db: pool }); let state = Arc::new(AppState { db: pool });
@@ -149,12 +155,15 @@ async fn register_handler(
} }
}; };
let now = Utc::now();
let user_id = Uuid::now_v7(); let user_id = Uuid::now_v7();
if let Err(e) = sqlx::query( if let Err(e) = sqlx::query(
"INSERT INTO user_main (id) VALUES ($1)" "INSERT INTO user_main (id, create_date, modify_date) VALUES ($1, $2, $3)"
) )
.bind(user_id) .bind(user_id)
.bind(now)
.bind(now)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
{ {
@@ -172,11 +181,13 @@ async fn register_handler(
let account_id = Uuid::now_v7(); let account_id = Uuid::now_v7();
if let Err(e) = sqlx::query( if let Err(e) = sqlx::query(
"INSERT INTO user_login_account (id, user_id, account) VALUES ($1, $2, $3)" "INSERT INTO user_login_account (id, user_id, account, create_date, modify_date) VALUES ($1, $2, $3, $4, $5)"
) )
.bind(account_id) .bind(account_id)
.bind(user_id) .bind(user_id)
.bind(&req.data.username) .bind(&req.data.username)
.bind(now)
.bind(now)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
{ {
@@ -194,11 +205,13 @@ async fn register_handler(
let password_id = Uuid::now_v7(); let password_id = Uuid::now_v7();
if let Err(e) = sqlx::query( if let Err(e) = sqlx::query(
"INSERT INTO user_login_password (id, user_id, password) VALUES ($1, $2, $3)" "INSERT INTO user_login_password (id, user_id, password, create_date, modify_date) VALUES ($1, $2, $3, $4, $5)"
) )
.bind(password_id) .bind(password_id)
.bind(user_id) .bind(user_id)
.bind(&password_hash) .bind(&password_hash)
.bind(now)
.bind(now)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
{ {

View File

@@ -1,5 +1,5 @@
[package] [package]
name = "user-register" name = "user-register-email"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"

View File

@@ -4,13 +4,13 @@ RUN apk add --no-cache musl-dev openssl-dev openssl-libs-static pkgconfig
WORKDIR /app WORKDIR /app
COPY services/user-service/user-register/Cargo.toml services/user-service/user-register/Cargo.lock* ./ 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 mkdir -p src && echo 'fn main() {}' > src/main.rs
RUN cargo build --release 2>/dev/null || true RUN cargo build --release 2>/dev/null || true
RUN rm -rf src RUN rm -rf src
COPY services/user-service/user-register/src ./src COPY services/user-service/user-register-email/src ./src
RUN touch src/main.rs && cargo build --release RUN touch src/main.rs && cargo build --release
@@ -22,7 +22,7 @@ RUN addgroup -g 1000 appuser && adduser -D -u 1000 -G appuser appuser
WORKDIR /app WORKDIR /app
COPY --from=builder /app/target/release/user-register /app/user-register COPY --from=builder /app/target/release/user-register-email /app/user-register-email
RUN chown -R appuser:appuser /app RUN chown -R appuser:appuser /app
@@ -30,4 +30,4 @@ USER appuser
EXPOSE 8080 EXPOSE 8080
CMD ["./user-register"] CMD ["./user-register-email"]

View File

@@ -0,0 +1,265 @@
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 chrono::Utc;
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<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-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");
sqlx::query("SET TIME ZONE 'Asia/Shanghai'")
.execute(&pool)
.await
.expect("Failed to set timezone");
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<Arc<AppState>>,
Json(req): Json<ApiRequest<RegisterRequest>>,
) -> (StatusCode, Json<ApiResponse<RegisterData>>) {
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 now = Utc::now();
let user_id = Uuid::now_v7();
if let Err(e) = sqlx::query(
"INSERT INTO user_main (id, create_date, modify_date) VALUES ($1, $2, $3)"
)
.bind(user_id)
.bind(now)
.bind(now)
.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, create_date, modify_date) VALUES ($1, $2, $3, $4, $5)"
)
.bind(email_id)
.bind(user_id)
.bind(&req.data.email)
.bind(now)
.bind(now)
.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, create_date, modify_date) VALUES ($1, $2, $3, $4, $5)"
)
.bind(password_id)
.bind(user_id)
.bind(&password_hash)
.bind(now)
.bind(now)
.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<ApiResponse<()>>) {
(
StatusCode::OK,
Json(ApiResponse {
success: true,
message: "OK".to_string(),
data: None,
}),
)
}