diff --git a/backend/deploy/local/redis.conf b/backend/deploy/local/redis.conf new file mode 100644 index 0000000..dea6ca8 --- /dev/null +++ b/backend/deploy/local/redis.conf @@ -0,0 +1,30 @@ +# Redis 基础配置 +bind 0.0.0.0 +port 6379 +tcp-backlog 511 +timeout 0 +tcp-keepalive 300 + +# 持久化配置 +save 900 1 +save 300 10 +save 60 10000 +stop-writes-on-bgsave-error yes +rdbcompression yes +rdbchecksum yes +dbfilename dump.rdb +dir /data + +# 内存管理 +maxmemory 256mb +maxmemory-policy allkeys-lru + +# 日志 +loglevel notice + +# 安全 +protected-mode no + +# 性能优化 +hz 10 +dynamic-hz yes diff --git a/backend/gateway/.dockerignore b/backend/gateway/.dockerignore new file mode 100644 index 0000000..7d13148 --- /dev/null +++ b/backend/gateway/.dockerignore @@ -0,0 +1,5 @@ +logs/ +ssl/*.pem +ssl/*.key +*.log +.DS_Store diff --git a/backend/gateway/Dockerfile b/backend/gateway/Dockerfile new file mode 100644 index 0000000..e248ba9 --- /dev/null +++ b/backend/gateway/Dockerfile @@ -0,0 +1,27 @@ +FROM nginx:1.25-alpine + +# 安装必要工具 +RUN apk add --no-cache curl ca-certificates + +# 创建日志目录 +RUN mkdir -p /var/log/nginx /var/www/certbot + +# 复制配置 +COPY nginx/nginx.conf /etc/nginx/nginx.conf +COPY nginx/conf.d/ /etc/nginx/conf.d/ + +# 创建自签名证书(仅用于开发,生产环境应挂载真实证书) +RUN apk add --no-cache openssl && \ + openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ + -keyout /etc/nginx/ssl/key.pem \ + -out /etc/nginx/ssl/cert.pem \ + -subj "/CN=api.example.com" && \ + apk del openssl + +# 健康检查 +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD curl -f http://localhost/health || exit 1 + +EXPOSE 80 443 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/backend/gateway/nginx/conf.d/default.conf b/backend/gateway/nginx/conf.d/default.conf new file mode 100644 index 0000000..29b9ec8 --- /dev/null +++ b/backend/gateway/nginx/conf.d/default.conf @@ -0,0 +1,84 @@ +# 默认服务器 - 拒绝直接IP访问 +server { + listen 80 default_server; + listen [::]:80 default_server; + server_name _; + + return 444; +} + +# HTTP 重定向到 HTTPS +server { + listen 80; + listen [::]:80; + server_name api.example.com; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://$server_name$request_uri; + } +} + +# API 网关主配置 +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name api.example.com; + + # SSL 证书配置 + ssl_certificate /etc/nginx/ssl/cert.pem; + ssl_certificate_key /etc/nginx/ssl/key.pem; + ssl_session_timeout 1d; + ssl_session_cache shared:SSL:50m; + ssl_session_tickets off; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_prefer_server_ciphers off; + + # 客户端请求大小限制 + client_max_body_size 50M; + client_body_buffer_size 16k; + + # 超时配置 + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + + # 安全响应头 + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # 根路径 - 健康检查 + location / { + return 200 '{"status":"ok","service":"api-gateway","timestamp":"$time_iso8601"}\n'; + add_header Content-Type application/json; + } + + # 健康检查端点 + location /health { + access_log off; + return 200 '{"status":"healthy","timestamp":"$time_iso8601"}\n'; + add_header Content-Type application/json; + } + + # 包含各服务路由配置 + include /etc/nginx/conf.d/services/*.conf; + + # 错误处理 + error_page 404 /404.json; + location = /404.json { + return 404 '{"error":"Not Found","message":"The requested resource was not found","code":404}\n'; + add_header Content-Type application/json; + } + + error_page 500 502 503 504 /50x.json; + location = /50x.json { + return 500 '{"error":"Internal Server Error","message":"Something went wrong","code":500}\n'; + add_header Content-Type application/json; + } +} diff --git a/backend/gateway/nginx/conf.d/services/order-service.conf b/backend/gateway/nginx/conf.d/services/order-service.conf new file mode 100644 index 0000000..ba9215d --- /dev/null +++ b/backend/gateway/nginx/conf.d/services/order-service.conf @@ -0,0 +1,29 @@ +# 订单服务路由 +location /api/v1/orders { + limit_req zone=general burst=30 nodelay; + limit_conn addr 10; + + proxy_pass http://order_service; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Request-ID $request_id; +} + +# 购物车接口 +location /api/v1/cart { + limit_req zone=general burst=20 nodelay; + limit_conn addr 10; + + proxy_pass http://order_service; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Request-ID $request_id; +} diff --git a/backend/gateway/nginx/conf.d/services/payment-service.conf b/backend/gateway/nginx/conf.d/services/payment-service.conf new file mode 100644 index 0000000..d16c9f7 --- /dev/null +++ b/backend/gateway/nginx/conf.d/services/payment-service.conf @@ -0,0 +1,37 @@ +# 支付服务路由(更严格的限流) +location /api/v1/payments { + limit_req zone=api_strict burst=10 nodelay; + limit_conn addr 5; + + proxy_pass http://payment_service; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Request-ID $request_id; + + # 支付接口需要更长的超时时间 + proxy_read_timeout 120s; + proxy_connect_timeout 120s; + proxy_send_timeout 120s; +} + +# 支付回调接口(通常由第三方调用) +location /api/v1/webhooks/payment { + # 放宽限流,允许第三方服务调用 + limit_req zone=general burst=50 nodelay; + + proxy_pass http://payment_service; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Request-ID $request_id; + + # 记录详细的访问日志以便审计 + access_log /var/log/nginx/payment-webhook.log main; +} diff --git a/backend/gateway/nginx/conf.d/services/user-service.conf b/backend/gateway/nginx/conf.d/services/user-service.conf new file mode 100644 index 0000000..6b7e18b --- /dev/null +++ b/backend/gateway/nginx/conf.d/services/user-service.conf @@ -0,0 +1,39 @@ +# 用户服务路由 +location /api/v1/users { + # 限流 + limit_req zone=general burst=20 nodelay; + limit_conn addr 10; + + # 代理设置 + proxy_pass http://user_service; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Request-ID $request_id; + + # WebSocket 支持(如果需要) + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + # 缓存控制 + proxy_cache_bypass $http_upgrade; + proxy_no_cache 1; +} + +# 认证相关接口(严格限流) +location /api/v1/auth { + limit_req zone=api_strict burst=5 nodelay; + limit_conn addr 3; + + proxy_pass http://user_service; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Request-ID $request_id; +} diff --git a/backend/gateway/nginx/nginx.conf b/backend/gateway/nginx/nginx.conf new file mode 100644 index 0000000..ab0c8a5 --- /dev/null +++ b/backend/gateway/nginx/nginx.conf @@ -0,0 +1,68 @@ +user nginx; +worker_processes auto; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 4096; + use epoll; + multi_accept on; +} + +http { + # 基础配置 + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # 日志格式 + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for" ' + 'rt=$request_time uct="$upstream_connect_time" ' + 'uht="$upstream_header_time" urt="$upstream_response_time"'; + + access_log /var/log/nginx/access.log main; + + # 性能优化 + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + + # 压缩 + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml application/atom+xml image/svg+xml; + + # 限流配置 + limit_req_zone $binary_remote_addr zone=general:10m rate=100r/s; + limit_req_zone $binary_remote_addr zone=api_strict:10m rate=10r/s; + + # 连接限制 + limit_conn_zone $binary_remote_addr zone=addr:10m; + + # 上游服务 + upstream user_service { + least_conn; + server user-service:8080 max_fails=3 fail_timeout=30s; + keepalive 32; + } + + upstream order_service { + least_conn; + server order-service:8080 max_fails=3 fail_timeout=30s; + keepalive 32; + } + + upstream payment_service { + least_conn; + server payment-service:8080 max_fails=3 fail_timeout=30s; + keepalive 32; + } + + # 包含子配置 + include /etc/nginx/conf.d/*.conf; +} diff --git a/backend/scripts/gateway.sh b/backend/scripts/gateway.sh new file mode 100755 index 0000000..537b65e --- /dev/null +++ b/backend/scripts/gateway.sh @@ -0,0 +1,150 @@ +#!/bin/bash + +# 网关管理脚本 + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" +GATEWAY_DIR="$PROJECT_ROOT/gateway" +NGINX_CONF_DIR="$GATEWAY_DIR/nginx" + +cd "$PROJECT_ROOT" + +# 颜色输出 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +log_info() { + echo -e "${GREEN}[INFO]${NC} $1" +} + +log_warn() { + echo -e "${YELLOW}[WARN]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# 测试 nginx 配置 +test_config() { + log_info "Testing nginx configuration..." + + docker run --rm \ + -v "$NGINX_CONF_DIR/nginx.conf:/etc/nginx/nginx.conf:ro" \ + -v "$NGINX_CONF_DIR/conf.d:/etc/nginx/conf.d:ro" \ + nginx:1.25-alpine \ + nginx -t + + if [ $? -eq 0 ]; then + log_info "Configuration test passed!" + else + log_error "Configuration test failed!" + exit 1 + fi +} + +# 重新加载配置(热重载) +reload_config() { + log_info "Reloading nginx configuration..." + + CONTAINER_ID=$(docker ps -q -f name=api-gateway) + + if [ -z "$CONTAINER_ID" ]; then + log_error "Gateway container is not running" + exit 1 + fi + + docker exec "$CONTAINER_ID" nginx -s reload + log_info "Configuration reloaded successfully" +} + +# 查看网关日志 +view_logs() { + log_info "Viewing gateway logs..." + + if [ "$1" == "follow" ] || [ "$1" == "-f" ]; then + tail -f "$NGINX_CONF_DIR/logs/"*.log 2>/dev/null || docker logs -f api-gateway 2>/dev/null + else + tail -n 100 "$NGINX_CONF_DIR/logs/"*.log 2>/dev/null || docker logs --tail 100 api-gateway 2>/dev/null + fi +} + +# 生成自签名证书(开发用) +generate_certs() { + log_info "Generating self-signed certificates..." + + CERT_DIR="$NGINX_CONF_DIR/ssl" + mkdir -p "$CERT_DIR" + + openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ + -keyout "$CERT_DIR/key.pem" \ + -out "$CERT_DIR/cert.pem" \ + -subj "/CN=api.example.com" \ + -addext "subjectAltName=DNS:api.example.com,DNS:localhost,IP:127.0.0.1" + + log_info "Certificates generated in $CERT_DIR" +} + +# 显示状态 +status() { + log_info "Gateway Status:" + + CONTAINER_ID=$(docker ps -q -f name=api-gateway) + + if [ -n "$CONTAINER_ID" ]; then + echo " Container: Running ($CONTAINER_ID)" + docker exec "$CONTAINER_ID" nginx -V 2>/dev/null | head -1 + else + echo " Container: Not running" + fi + + echo "" + echo " Configuration files:" + ls -la "$NGINX_CONF_DIR/conf.d/" +} + +# 使用说明 +usage() { + echo "Gateway Management Script" + echo "" + echo "Usage: $0 " + echo "" + echo "Commands:" + echo " test Test nginx configuration" + echo " reload Reload configuration (hot reload)" + echo " logs View logs (use 'logs follow' for real-time)" + echo " certs Generate self-signed certificates (dev only)" + echo " status Show gateway status" + echo " help Show this help message" +} + +# 主逻辑 +case "${1:-help}" in + test) + test_config + ;; + reload) + reload_config + ;; + logs) + view_logs "$2" + ;; + certs) + generate_certs + ;; + status) + status + ;; + help|--help|-h) + usage + ;; + *) + log_error "Unknown command: $1" + usage + exit 1 + ;; +esac diff --git a/backend/scripts/init-multiple-databases.sh b/backend/scripts/init-multiple-databases.sh new file mode 100644 index 0000000..3597f4f --- /dev/null +++ b/backend/scripts/init-multiple-databases.sh @@ -0,0 +1,27 @@ +#!/bin/bash +set -e +set -u + +function create_database() { + local database=$1 + echo "Creating database '$database'" + psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" <<-EOSQL + SELECT 1 FROM pg_database WHERE datname = '$database'; + DO \$\$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = '$database') THEN + CREATE DATABASE $database; + END IF; + END + \$\$; + GRANT ALL PRIVILEGES ON DATABASE $database TO $POSTGRES_USER; +EOSQL +} + +if [ -n "$POSTGRES_MULTIPLE_DATABASES" ]; then + echo "Multiple database creation requested: $POSTGRES_MULTIPLE_DATABASES" + for db in $(echo "$POSTGRES_MULTIPLE_DATABASES" | tr ',' ' '); do + create_database "$db" + done + echo "Multiple databases created" +fi diff --git a/backend/services/user-service/Dockerfile b/backend/services/user-service/Dockerfile new file mode 100644 index 0000000..98d52fd --- /dev/null +++ b/backend/services/user-service/Dockerfile @@ -0,0 +1,49 @@ +# 构建阶段 +FROM rust:1.94.1-alpine3.23 AS builder + +# 安装构建依赖 +RUN apk add --no-cache musl-dev openssl-dev openssl-libs-static + +# 创建工作目录 +WORKDIR /app + +# 先复制共享代码和 Cargo 文件以利用缓存 +COPY shared /app/shared +COPY services/user-service/Cargo.toml services/user-service/Cargo.lock* ./ + +# 创建虚拟 main.rs 来缓存依赖 +RUN mkdir -p src && echo 'fn main() {}' > src/main.rs +RUN cargo build --release && rm -rf src + +# 复制真实源代码 +COPY services/user-service/src ./src + +# 构建(使用 touch 确保重新编译) +RUN touch src/main.rs && cargo build --release + +# 运行阶段 +FROM alpine:3.23 AS runtime + +# 安装运行依赖 +RUN apk add --no-cache ca-certificates tzdata + +# 创建非 root 用户 +RUN addgroup -g 1000 appuser && adduser -D -u 1000 -G appuser appuser + +WORKDIR /app + +# 从构建阶段复制二进制文件 +COPY --from=builder /app/target/release/user-service /app/user-service + +# 设置权限 +RUN chown -R appuser:appuser /app + +USER appuser + +# 健康检查 +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1 + +EXPOSE 8080 + +CMD ["./user-service"] diff --git a/backend/services/user-service/docker-compose.yml b/backend/services/user-service/docker-compose.yml new file mode 100644 index 0000000..03dd965 --- /dev/null +++ b/backend/services/user-service/docker-compose.yml @@ -0,0 +1,102 @@ +version: "3.8" + +services: + user-login: + build: + context: ../.. + dockerfile: services/user-service/user-login/Dockerfile + container_name: user-login + 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 + - SERVICE_PORT=8080 + - JWT_SECRET=${JWT_SECRET:-dev-secret-key} + ports: + - "8001: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: + build: + context: ../.. + dockerfile: services/user-service/user-register/Dockerfile + container_name: user-register + 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 + - SERVICE_PORT=8080 + ports: + - "8002: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 + environment: + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres + - POSTGRES_DB=user_db + volumes: + - user_postgres_data:/var/lib/postgresql/data + - ./migrations:/docker-entrypoint-initdb.d:ro + ports: + - "5432:5432" + networks: + - user-network + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d user_db"] + interval: 10s + timeout: 5s + retries: 5 + + user-redis: + image: redis:8.6.2-alpine + container_name: user-redis + volumes: + - user_redis_data:/data + ports: + - "6379:6379" + networks: + - user-network + restart: unless-stopped + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + +networks: + user-network: + driver: bridge + +volumes: + user_postgres_data: + user_redis_data: diff --git a/backend/services/user-service/migrations/001_init.sql b/backend/services/user-service/migrations/001_init.sql new file mode 100644 index 0000000..5bb9ba3 --- /dev/null +++ b/backend/services/user-service/migrations/001_init.sql @@ -0,0 +1,15 @@ +-- 用户表初始化 +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; diff --git a/backend/services/user-service/user-login/Cargo.toml b/backend/services/user-service/user-login/Cargo.toml new file mode 100644 index 0000000..c9c4a35 --- /dev/null +++ b/backend/services/user-service/user-login/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "user-login" +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"] } + +# 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/backend/services/user-service/user-login/Dockerfile b/backend/services/user-service/user-login/Dockerfile new file mode 100644 index 0000000..638c9d1 --- /dev/null +++ b/backend/services/user-service/user-login/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 代码 +COPY services/user-service/user-login/Cargo.toml services/user-service/user-login/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/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 /app/user-login + +RUN chown -R appuser:appuser /app + +USER appuser + +EXPOSE 8080 + +CMD ["./user-login"] diff --git a/backend/services/user-service/user-login/src/main.rs b/backend/services/user-service/user-login/src/main.rs new file mode 100644 index 0000000..d1c9eb9 --- /dev/null +++ b/backend/services/user-service/user-login/src/main.rs @@ -0,0 +1,182 @@ +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 { + username: 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 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 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 user: {}", payload.username); + + // 查询用户 + let user: Option<(String,)> = sqlx::query_as( + "SELECT password_hash FROM users WHERE username = $1" + ) + .bind(&payload.username) + .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!("User {} logged in successfully", payload.username); + + // 生成 JWT + let token = generate_token(&payload.username, &state.jwt_secret); + + ( + StatusCode::OK, + Json(LoginResponse { + success: true, + token: Some(token), + message: "Login successful".to_string(), + }), + ) + } + Ok(false) => { + warn!("Invalid password for user {}", payload.username); + ( + 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!("User not found: {}", payload.username); + ( + 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(username: &str, secret: &str) -> String { + let now = Utc::now(); + let exp = now + Duration::hours(24); + + let claims = Claims { + sub: username.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/backend/services/user-service/user-register/Cargo.toml b/backend/services/user-service/user-register/Cargo.toml new file mode 100644 index 0000000..bad26bb --- /dev/null +++ b/backend/services/user-service/user-register/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "user-register" +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"] } +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/backend/services/user-service/user-register/Dockerfile b/backend/services/user-service/user-register/Dockerfile new file mode 100644 index 0000000..c39b3bb --- /dev/null +++ b/backend/services/user-service/user-register/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/Cargo.toml services/user-service/user-register/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/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 /app/user-register + +RUN chown -R appuser:appuser /app + +USER appuser + +EXPOSE 8080 + +CMD ["./user-register"] diff --git a/backend/services/user-service/user-register/src/main.rs b/backend/services/user-service/user-register/src/main.rs new file mode 100644 index 0000000..7927a23 --- /dev/null +++ b/backend/services/user-service/user-register/src/main.rs @@ -0,0 +1,185 @@ +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, + 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>, + Json(payload): Json, +) -> (StatusCode, Json) { + 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" +}