提交代码

This commit is contained in:
fish
2026-04-25 21:36:56 +08:00
parent 189df47fcc
commit ce36fff9ef
32 changed files with 2238 additions and 569 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

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`。

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

Submodule backend deleted from e359a32bed

30
deploy/local/redis.conf Normal file
View File

@@ -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

View File

5
gateway/.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
logs/
ssl/*.pem
ssl/*.key
*.log
.DS_Store

27
gateway/Dockerfile Normal file
View File

@@ -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;"]

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

68
gateway/nginx/nginx.conf Normal file
View File

@@ -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;
}

150
scripts/gateway.sh Executable file
View File

@@ -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 <command>"
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

View File

@@ -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

View File

@@ -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"]

View File

@@ -0,0 +1,159 @@
version: "3.8"
services:
user-login-account:
build:
context: ../..
dockerfile: services/user-service/user-login-account/Dockerfile
container_name: user-login-account
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-account
- 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-account:
build:
context: ../..
dockerfile: services/user-service/user-register-account/Dockerfile
container_name: user-register-account
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-account
- 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-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
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:
name: user-postgres-data
user-redis-data:
name: user-redis-data

View File

@@ -0,0 +1,51 @@
-- 用户主表
CREATE TABLE IF NOT EXISTS user_main (
id UUID PRIMARY KEY,
deleted BOOLEAN NOT NULL DEFAULT FALSE,
create_date TIMESTAMP WITH TIME ZONE NOT NULL,
modify_date TIMESTAMP WITH TIME ZONE NOT NULL
);
-- 用户登录账号表
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,
create_date TIMESTAMP WITH TIME ZONE NOT NULL,
modify_date TIMESTAMP WITH TIME ZONE NOT NULL,
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,
create_date TIMESTAMP WITH TIME ZONE NOT NULL,
modify_date TIMESTAMP WITH TIME ZONE NOT NULL,
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);
-- 用户登录邮箱表
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

@@ -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 {
username: 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-account 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 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 user: {}", payload.username);
// 查询用户账号与密码
let user: Option<(uuid::Uuid, String)> = sqlx::query_as(
"SELECT a.user_id, 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)
.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!("User {} logged in successfully", payload.username);
// 生成 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 user {}", payload.username);
(
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!("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(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,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

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-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"]

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

@@ -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(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-account 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 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 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 account_id = Uuid::now_v7();
if let Err(e) = sqlx::query(
"INSERT INTO user_login_account (id, user_id, account, create_date, modify_date) VALUES ($1, $2, $3, $4, $5)"
)
.bind(account_id)
.bind(user_id)
.bind(&req.data.username)
.bind(now)
.bind(now)
.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, 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!("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,
}),
)
}

View File

@@ -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

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-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"]

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,
}),
)
}