From ce36fff9efae105905115e0bf8342f2d0af542ad Mon Sep 17 00:00:00 2001 From: fish Date: Sat, 25 Apr 2026 21:36:56 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8F=90=E4=BA=A4=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 548 ------------------ AGENTS.md | 261 +++++++++ LICENSE | 18 - README.md | 2 - app/README.md | 0 backend | 1 - deploy/local/redis.conf | 30 + frontend/README.md | 0 gateway/.dockerignore | 5 + gateway/Dockerfile | 27 + gateway/nginx/conf.d/default.conf | 84 +++ .../nginx/conf.d/services/order-service.conf | 29 + .../conf.d/services/payment-service.conf | 37 ++ .../nginx/conf.d/services/user-service.conf | 39 ++ gateway/nginx/nginx.conf | 68 +++ scripts/gateway.sh | 150 +++++ scripts/init-multiple-databases.sh | 27 + services/user-service/Dockerfile | 49 ++ services/user-service/docker-compose.yml | 159 +++++ services/user-service/migrations/001_init.sql | 51 ++ .../user-login-account/Cargo.toml | 45 ++ .../user-login-account/Dockerfile | 39 ++ .../user-login-account/src/main.rs | 196 +++++++ .../user-service/user-login-email/Cargo.toml | 45 ++ .../user-service/user-login-email/Dockerfile | 39 ++ .../user-service/user-login-email/src/main.rs | 196 +++++++ .../user-register-account/Cargo.toml | 33 ++ .../user-register-account/Dockerfile | 33 ++ .../user-register-account/src/main.rs | 265 +++++++++ .../user-register-email/Cargo.toml | 33 ++ .../user-register-email/Dockerfile | 33 ++ .../user-register-email/src/main.rs | 265 +++++++++ 32 files changed, 2238 insertions(+), 569 deletions(-) delete mode 100644 .gitignore create mode 100644 AGENTS.md delete mode 100644 LICENSE delete mode 100644 app/README.md delete mode 160000 backend create mode 100644 deploy/local/redis.conf delete mode 100644 frontend/README.md create mode 100644 gateway/.dockerignore create mode 100644 gateway/Dockerfile create mode 100644 gateway/nginx/conf.d/default.conf create mode 100644 gateway/nginx/conf.d/services/order-service.conf create mode 100644 gateway/nginx/conf.d/services/payment-service.conf create mode 100644 gateway/nginx/conf.d/services/user-service.conf create mode 100644 gateway/nginx/nginx.conf create mode 100755 scripts/gateway.sh create mode 100644 scripts/init-multiple-databases.sh create mode 100644 services/user-service/Dockerfile create mode 100644 services/user-service/docker-compose.yml create mode 100644 services/user-service/migrations/001_init.sql create mode 100644 services/user-service/user-login-account/Cargo.toml create mode 100644 services/user-service/user-login-account/Dockerfile create mode 100644 services/user-service/user-login-account/src/main.rs create mode 100644 services/user-service/user-login-email/Cargo.toml create mode 100644 services/user-service/user-login-email/Dockerfile create mode 100644 services/user-service/user-login-email/src/main.rs create mode 100644 services/user-service/user-register-account/Cargo.toml create mode 100644 services/user-service/user-register-account/Dockerfile create mode 100644 services/user-service/user-register-account/src/main.rs create mode 100644 services/user-service/user-register-email/Cargo.toml create mode 100644 services/user-service/user-register-email/Dockerfile create mode 100644 services/user-service/user-register-email/src/main.rs diff --git a/.gitignore b/.gitignore deleted file mode 100644 index fc44fc4..0000000 --- a/.gitignore +++ /dev/null @@ -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 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c8eaa4e --- /dev/null +++ b/AGENTS.md @@ -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`,失败时返回 `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` 注入到 Axum Handler 中。 +- 注册类服务统一使用包装请求/响应格式: + ```rust + struct ApiRequest { device: i32, language: i32, data: T } + struct ApiResponse { success: bool, message: String, data: Option } + ``` + +### 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//` 下创建新目录,如 `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`。 diff --git a/LICENSE b/LICENSE deleted file mode 100644 index d6be502..0000000 --- a/LICENSE +++ /dev/null @@ -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. diff --git a/README.md b/README.md index be22c99..e69de29 100644 --- a/README.md +++ b/README.md @@ -1,2 +0,0 @@ -# asset_helper - diff --git a/app/README.md b/app/README.md deleted file mode 100644 index e69de29..0000000 diff --git a/backend b/backend deleted file mode 160000 index e359a32..0000000 --- a/backend +++ /dev/null @@ -1 +0,0 @@ -Subproject commit e359a32bed8919ef9256c6e3439d4b46e549e149 diff --git a/deploy/local/redis.conf b/deploy/local/redis.conf new file mode 100644 index 0000000..dea6ca8 --- /dev/null +++ b/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/frontend/README.md b/frontend/README.md deleted file mode 100644 index e69de29..0000000 diff --git a/gateway/.dockerignore b/gateway/.dockerignore new file mode 100644 index 0000000..7d13148 --- /dev/null +++ b/gateway/.dockerignore @@ -0,0 +1,5 @@ +logs/ +ssl/*.pem +ssl/*.key +*.log +.DS_Store diff --git a/gateway/Dockerfile b/gateway/Dockerfile new file mode 100644 index 0000000..e248ba9 --- /dev/null +++ b/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/gateway/nginx/conf.d/default.conf b/gateway/nginx/conf.d/default.conf new file mode 100644 index 0000000..29b9ec8 --- /dev/null +++ b/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/gateway/nginx/conf.d/services/order-service.conf b/gateway/nginx/conf.d/services/order-service.conf new file mode 100644 index 0000000..ba9215d --- /dev/null +++ b/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/gateway/nginx/conf.d/services/payment-service.conf b/gateway/nginx/conf.d/services/payment-service.conf new file mode 100644 index 0000000..d16c9f7 --- /dev/null +++ b/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/gateway/nginx/conf.d/services/user-service.conf b/gateway/nginx/conf.d/services/user-service.conf new file mode 100644 index 0000000..6b7e18b --- /dev/null +++ b/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/gateway/nginx/nginx.conf b/gateway/nginx/nginx.conf new file mode 100644 index 0000000..ab0c8a5 --- /dev/null +++ b/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/scripts/gateway.sh b/scripts/gateway.sh new file mode 100755 index 0000000..537b65e --- /dev/null +++ b/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/scripts/init-multiple-databases.sh b/scripts/init-multiple-databases.sh new file mode 100644 index 0000000..3597f4f --- /dev/null +++ b/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/services/user-service/Dockerfile b/services/user-service/Dockerfile new file mode 100644 index 0000000..98d52fd --- /dev/null +++ b/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/services/user-service/docker-compose.yml b/services/user-service/docker-compose.yml new file mode 100644 index 0000000..77ec314 --- /dev/null +++ b/services/user-service/docker-compose.yml @@ -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 diff --git a/services/user-service/migrations/001_init.sql b/services/user-service/migrations/001_init.sql new file mode 100644 index 0000000..2fdf07d --- /dev/null +++ b/services/user-service/migrations/001_init.sql @@ -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; diff --git a/services/user-service/user-login-account/Cargo.toml b/services/user-service/user-login-account/Cargo.toml new file mode 100644 index 0000000..47f3d1d --- /dev/null +++ b/services/user-service/user-login-account/Cargo.toml @@ -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 diff --git a/services/user-service/user-login-account/Dockerfile b/services/user-service/user-login-account/Dockerfile new file mode 100644 index 0000000..9b1434d --- /dev/null +++ b/services/user-service/user-login-account/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-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"] diff --git a/services/user-service/user-login-account/src/main.rs b/services/user-service/user-login-account/src/main.rs new file mode 100644 index 0000000..b8203f5 --- /dev/null +++ b/services/user-service/user-login-account/src/main.rs @@ -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, + jwt_secret: String, +} + +// 登录请求 +#[derive(Deserialize)] +struct LoginRequest { + username: String, + password: String, +} + +// 统一响应包装 +#[derive(Serialize)] +struct ApiResponse { + success: bool, + message: String, + data: Option, +} + +// 登录业务数据 +#[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>, + Json(payload): Json, +) -> (StatusCode, Json>) { + 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() +} diff --git a/services/user-service/user-login-email/Cargo.toml b/services/user-service/user-login-email/Cargo.toml new file mode 100644 index 0000000..d3be127 --- /dev/null +++ b/services/user-service/user-login-email/Cargo.toml @@ -0,0 +1,45 @@ +[package] +name = "user-login-email" +version = "0.1.0" +edition = "2024" + +[dependencies] +# Web 框架 +axum = "0.8" +tokio = { version = "1", features = ["full"] } +tower = "0.5" + +# 序列化 +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +# 数据库 +sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "chrono", "uuid"] } + +# UUID +uuid = { version = "1", features = ["v7", "serde"] } + +# Redis +redis = { version = "0.29", features = ["tokio-comp"] } + +# 密码哈希(bcrypt) +bcrypt = "0.17" + +# JWT +jsonwebtoken = "9.3" + +# 时间和日志 +chrono = { version = "0.4", features = ["serde"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +# 环境变量 +dotenvy = "0.15" + +# 错误处理 +thiserror = "2.0" + +[profile.release] +opt-level = 3 +lto = true +strip = true diff --git a/services/user-service/user-login-email/Dockerfile b/services/user-service/user-login-email/Dockerfile new file mode 100644 index 0000000..79d9632 --- /dev/null +++ b/services/user-service/user-login-email/Dockerfile @@ -0,0 +1,39 @@ +# 构建阶段 +FROM rust:1.94.1-alpine3.23 AS builder + +RUN apk add --no-cache musl-dev openssl-dev openssl-libs-static pkgconfig + +WORKDIR /app + +# 复制 user-login-email 代码 +COPY services/user-service/user-login-email/Cargo.toml services/user-service/user-login-email/Cargo.lock* ./ + +# 缓存依赖 +RUN mkdir -p src && echo 'fn main() {}' > src/main.rs +RUN cargo build --release 2>/dev/null || true +RUN rm -rf src + +# 复制真实源码 +COPY services/user-service/user-login-email/src ./src + +# 重新构建 +RUN touch src/main.rs && cargo build --release + +# 运行阶段 +FROM alpine:3.23 AS runtime + +RUN apk add --no-cache ca-certificates + +RUN addgroup -g 1000 appuser && adduser -D -u 1000 -G appuser appuser + +WORKDIR /app + +COPY --from=builder /app/target/release/user-login-email /app/user-login-email + +RUN chown -R appuser:appuser /app + +USER appuser + +EXPOSE 8080 + +CMD ["./user-login-email"] diff --git a/services/user-service/user-login-email/src/main.rs b/services/user-service/user-login-email/src/main.rs new file mode 100644 index 0000000..9006ba2 --- /dev/null +++ b/services/user-service/user-login-email/src/main.rs @@ -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, + jwt_secret: String, +} + +// 登录请求 +#[derive(Deserialize)] +struct LoginRequest { + email: String, + password: String, +} + +// 统一响应包装 +#[derive(Serialize)] +struct ApiResponse { + success: bool, + message: String, + data: Option, +} + +// 登录业务数据 +#[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>, + Json(payload): Json, +) -> (StatusCode, Json>) { + 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() +} diff --git a/services/user-service/user-register-account/Cargo.toml b/services/user-service/user-register-account/Cargo.toml new file mode 100644 index 0000000..65df885 --- /dev/null +++ b/services/user-service/user-register-account/Cargo.toml @@ -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 diff --git a/services/user-service/user-register-account/Dockerfile b/services/user-service/user-register-account/Dockerfile new file mode 100644 index 0000000..fde8928 --- /dev/null +++ b/services/user-service/user-register-account/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-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"] diff --git a/services/user-service/user-register-account/src/main.rs b/services/user-service/user-register-account/src/main.rs new file mode 100644 index 0000000..a7905b2 --- /dev/null +++ b/services/user-service/user-register-account/src/main.rs @@ -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 { + device: i32, + language: i32, + data: T, +} + +#[derive(Serialize)] +struct ApiResponse { + success: bool, + message: String, + data: Option, +} + +#[derive(Serialize)] +struct RegisterData { + user_id: Uuid, +} + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt::init(); + + info!("Starting user-register-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>, + Json(req): Json>, +) -> (StatusCode, Json>) { + 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>) { + ( + StatusCode::OK, + Json(ApiResponse { + success: true, + message: "OK".to_string(), + data: None, + }), + ) +} diff --git a/services/user-service/user-register-email/Cargo.toml b/services/user-service/user-register-email/Cargo.toml new file mode 100644 index 0000000..4e37e10 --- /dev/null +++ b/services/user-service/user-register-email/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "user-register-email" +version = "0.1.0" +edition = "2024" + +[dependencies] +axum = "0.8" +tokio = { version = "1", features = ["full"] } +tower = "0.5" + +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "chrono", "uuid"] } + +# UUID +uuid = { version = "1", features = ["v7", "serde"] } +redis = { version = "0.29", features = ["tokio-comp"] } + +bcrypt = "0.17" + +chrono = { version = "0.4", features = ["serde"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +dotenvy = "0.15" +thiserror = "2.0" +validator = { version = "0.20", features = ["derive"] } + +[profile.release] +opt-level = 3 +lto = true +strip = true diff --git a/services/user-service/user-register-email/Dockerfile b/services/user-service/user-register-email/Dockerfile new file mode 100644 index 0000000..8a24809 --- /dev/null +++ b/services/user-service/user-register-email/Dockerfile @@ -0,0 +1,33 @@ +FROM rust:1.94.1-alpine3.23 AS builder + +RUN apk add --no-cache musl-dev openssl-dev openssl-libs-static pkgconfig + +WORKDIR /app + +COPY services/user-service/user-register-email/Cargo.toml services/user-service/user-register-email/Cargo.lock* ./ + +RUN mkdir -p src && echo 'fn main() {}' > src/main.rs +RUN cargo build --release 2>/dev/null || true +RUN rm -rf src + +COPY services/user-service/user-register-email/src ./src + +RUN touch src/main.rs && cargo build --release + +FROM alpine:3.23 AS runtime + +RUN apk add --no-cache ca-certificates + +RUN addgroup -g 1000 appuser && adduser -D -u 1000 -G appuser appuser + +WORKDIR /app + +COPY --from=builder /app/target/release/user-register-email /app/user-register-email + +RUN chown -R appuser:appuser /app + +USER appuser + +EXPOSE 8080 + +CMD ["./user-register-email"] diff --git a/services/user-service/user-register-email/src/main.rs b/services/user-service/user-register-email/src/main.rs new file mode 100644 index 0000000..055a2d7 --- /dev/null +++ b/services/user-service/user-register-email/src/main.rs @@ -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 { + device: i32, + language: i32, + data: T, +} + +#[derive(Serialize)] +struct ApiResponse { + success: bool, + message: String, + data: Option, +} + +#[derive(Serialize)] +struct RegisterData { + user_id: Uuid, +} + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt::init(); + + info!("Starting user-register-email service..."); + + let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set"); + let pool = sqlx::postgres::PgPool::connect(&database_url) + .await + .expect("Failed to connect to database"); + + 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>, + Json(req): Json>, +) -> (StatusCode, Json>) { + info!( + "Email registration attempt for: {}, device: {}, language: {}", + req.data.email, req.device, req.language + ); + + // 参数校验 + if let Err(e) = req.data.validate() { + return ( + StatusCode::BAD_REQUEST, + Json(ApiResponse { + success: false, + message: format!("Validation error: {}", e), + data: None, + }), + ); + } + + // 检查邮箱是否已存在 + let existing: Option<(Uuid,)> = sqlx::query_as( + "SELECT id FROM user_login_email WHERE email = $1 AND deleted = FALSE" + ) + .bind(&req.data.email) + .fetch_optional(&state.db) + .await + .unwrap_or(None); + + if existing.is_some() { + return ( + StatusCode::CONFLICT, + Json(ApiResponse { + success: false, + message: "Email already exists".to_string(), + data: None, + }), + ); + } + + // 密码哈希 + let password_hash = match hash(&req.data.password, bcrypt::DEFAULT_COST) { + Ok(h) => h, + Err(e) => { + warn!("Password hashing failed: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiResponse { + success: false, + message: "Internal error".to_string(), + data: None, + }), + ); + } + }; + + // 插入用户(主从表事务) + let mut tx = match state.db.begin().await { + Ok(t) => t, + Err(e) => { + warn!("Transaction start failed: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiResponse { + success: false, + message: "Internal error".to_string(), + data: None, + }), + ); + } + }; + + let 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>) { + ( + StatusCode::OK, + Json(ApiResponse { + success: true, + message: "OK".to_string(), + data: None, + }), + ) +}