Compare commits

...

14 Commits

Author SHA1 Message Date
fish
b5cb9daad7 用户服务 4 个 crate 合并为单一 user-service,按 DDD 限界上下文聚合
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 16:36:04 +08:00
fish
4e004f5a85 全栈 docker compose 编排上移到根目录,简化部署流程
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 15:40:50 +08:00
fish
6eb0b3ac3f 修复前端开发环境登录 502 问题
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 15:24:37 +08:00
fish
83d9a08b97 打通前后端联调链路
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 15:15:19 +08:00
fish
91226fa976 登录页彻底禁止页面滑动
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 14:45:21 +08:00
fish
c91e038953 前端项目初始化,登录页支持暗色主题与禁止滑动
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 14:40:55 +08:00
fish
bd258e19c2 统一后端端口规划 2026-04-26 13:38:37 +08:00
fish
cf6ae5ea45 忽略 nginx 教程案例目录 2026-04-26 13:17:53 +08:00
fish
4b41e7f2dd 添加 Docker 镜像推送脚本 2026-04-25 22:16:51 +08:00
fish
807857618f 全栈项目初始化各端 CLAUDE.md 项目指南
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 22:05:35 +08:00
fish
255c13c1f5 添加 .claude/ 到 .gitignore,移除已跟踪的配置文件 2026-04-25 21:48:48 +08:00
fish
0da44f22db 重构项目结构:统一目录层级
- 移除 backend/.git 工作树配置
- 将原根目录文件归入 backend/ 目录
- 新增 app/、frontend/ 等模块
- 保留文件历史(自动识别重命名)
2026-04-25 21:47:28 +08:00
fish
ce36fff9ef 提交代码 2026-04-25 21:36:56 +08:00
fish
189df47fcc 提交代码 2026-04-25 21:36:30 +08:00
74 changed files with 2717 additions and 1074 deletions

27
.env.example Normal file
View File

@@ -0,0 +1,27 @@
# asset_helper —— 编排环境变量示例
#
# 使用cp .env.example .env按需填写后由 docker compose 自动加载
# 同一份 .env 同时被 docker-compose.yml 和 docker-compose.dev.yml 读取,
# 测试环境对必填项有默认兜底值,正式环境强制要求 JWT_SECRET / POSTGRES_PASSWORD。
# ===== 必填(正式环境)=====
JWT_SECRET=please-change-me-to-a-long-random-string
POSTGRES_PASSWORD=please-change-me
# ===== 可选 =====
POSTGRES_USER=postgres
RUST_LOG=info
# ===== 端口(默认值见 docker-compose.yml / docker-compose.dev.yml=====
# 正式环境
# GATEWAY_HTTP_PORT=80
# GATEWAY_HTTPS_PORT=443
# ADMIN_WEB_PORT=20080
# 测试环境
# GATEWAY_HTTP_PORT=18080
# GATEWAY_HTTPS_PORT=18443
# ADMIN_WEB_PORT=18888
# USER_POSTGRES_PORT=20101
# USER_REDIS_PORT=20103
# USER_SERVICE_PORT=20110

557
.gitignore vendored Normal file
View File

@@ -0,0 +1,557 @@
# ---> 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
# Claude Code
.claude/
# Registry credentials
.env.registry
# Nginx setup tutorial case
tmp-nginx-setup/

93
CLAUDE.md Normal file
View File

@@ -0,0 +1,93 @@
# asset_helper — Claude Code 项目指南
本文件为 Claude Code及其它 AI Agent提供项目总览。**进入子目录工作时,请优先参考该子目录下的 `CLAUDE.md`。**
## 项目定位
`asset_helper` 是一个全栈资产管理工具,由三个独立领域组成,共享同一套后端 API
```
asset_helper/
├── backend/ # Rust 微服务后端 + Nginx 网关 + Postgres + Redis
├── frontend/ # Web 前端(技术栈待定)
└── app/ # 移动端 / 桌面端(技术栈待定)
```
## 三端定位
| 目录 | 角色 | 技术栈 | 详细规范 |
|------|------|--------|---------|
| `backend/` | 微服务后端 + 网关 | Rust 2024 / Axum / sqlx / Nginx | [backend/CLAUDE.md](backend/CLAUDE.md) |
| `frontend/` | Web 客户端 | 待定 | [frontend/CLAUDE.md](frontend/CLAUDE.md) |
| `app/` | 移动 / 桌面客户端 | 待定 | [app/CLAUDE.md](app/CLAUDE.md) |
## 跨端契约(所有客户端必须遵守)
后端按**两类风格**定义接口,详见 [backend/CLAUDE.md](backend/CLAUDE.md#1-api-公共约定)。客户端调用时:
### 注册/业务类接口 — 包装请求/响应
请求:
```json
{ "device": <int>, "language": <int>, "data": { ... } }
```
响应:
```json
{ "success": true, "message": "...", "data": { ... } }
```
### 登录/认证类接口 — 扁平响应
```json
{ "success": true, "token": "<JWT>", "message": "..." }
```
### 公共编码(必须与后端保持一致)
**device**`1` iOS、`2` Android、`3` Web、`4` iPad、`5` macOS、`6` Windows、`7` Linux
**language**`1` 简中、`2` 繁中、`3` 英文
### 错误响应
HTTP 非 200 时网关统一返回 `{ "error", "message", "code" }`
## 通用约定
- **注释、提交信息使用中文**(与后端保持一致)
- **类型定义跨端对齐**:客户端的数据模型与 backend Rust 结构体一一对应,避免字段漂移
- **时间字段**:后端写 UTC、查询按东八区返回客户端展示如需时区转换按需处理
- 修改跨端契约device/language 编码、API 包装格式等)时,**必须同步更新所有四份 CLAUDE.md**
## 工作流提示
- 改后端服务:`cd backend && claude`,自动加载 [backend/CLAUDE.md](backend/CLAUDE.md)
- 改前端:`cd frontend && claude`
- 改移动端:`cd app && claude`
- 跨端联调或修改公共契约:在项目根目录启动,本文件提供总览
## 部署
项目使用根目录的两份 docker compose 文件做整体编排,**不再使用各子目录下的独立 compose**
| 文件 | 用途 | 启动命令 |
|------|------|---------|
| [docker-compose.yml](docker-compose.yml) | 正式环境 | `docker compose up -d --build` |
| [docker-compose.dev.yml](docker-compose.dev.yml) | 测试/开发环境 | `docker compose -f docker-compose.dev.yml up -d --build` |
**首次部署:**
1. `cp .env.example .env`
2. 填入 `JWT_SECRET``POSTGRES_PASSWORD`(正式环境必需)
3. 执行上方启动命令
**核心差异:**
- 正式:仅暴露网关 80/443、前端 20080数据卷 `user-postgres-data` / `user-redis-data`
- 测试:全部端口暴露便于调试;网关 18080/18443、前端 18888数据卷加 `-dev` 后缀,与正式完全隔离
- 两套环境可同机并存
## 项目当前进展
-`backend/` — 用户服务(账号/邮箱 登录/注册已搭起雏形Nginx 网关 + Postgres + Redis 编排就绪
- ✅ 全栈一键编排(根目录 docker-compose.yml / docker-compose.dev.yml
-`frontend/` — 未启动
-`app/` — 未启动

18
LICENSE Normal file
View File

@@ -0,0 +1,18 @@
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

@@ -0,0 +1,2 @@
# asset_helper

121
app/CLAUDE.md Normal file
View File

@@ -0,0 +1,121 @@
# App — Claude Code 项目指南
本文件为 Claude Code及其它 AI Agent提供移动/桌面端项目的背景、结构说明和开发规范。
## 项目状态
> **当前状态:尚未初始化代码。** 技术栈待定,目录除本文件外为空。
> 在选定技术栈后,请补全本文件中标记为「⚠️ 待补充」的章节。
## 定位
`app/` 是 asset_helper 的 **移动端/桌面端客户端**,调用后端 API默认通过 Nginx 网关 `https://api.example.com` 暴露)。
-`backend/` 通过 HTTP/JSON 交互,遵循根目录 [CLAUDE.md](../CLAUDE.md) 中定义的跨端契约
-`frontend/`Web 端共享后端UI 实现独立
- 一套代码可能同时输出 iOS / Android / 桌面端,需根据运行平台动态设置 `device` 字段
## 技术栈
> ⚠️ 待补充:选定后请填写。建议候选:
>
> | 维度 | 候选 |
> |------|------|
> | 框架 | Flutter / React Native / Tauri |
> | 语言 | Dart / TypeScript / Rust |
> | 状态管理 | Riverpod / Provider / Redux Toolkit |
> | HTTP | dio / axios / reqwest |
> | 本地存储 | shared_preferences / AsyncStorage / tauri-plugin-store |
## 与后端的协作约定
以下约定来自 [backend/CLAUDE.md](../backend/CLAUDE.md),调用时**必须遵守**。
### 1. 请求包装(注册/业务类接口)
```
{
"device": <平台编码>,
"language": <语言编码>,
"data": { ... }
}
```
### 2. device 编码(按运行平台动态设置)
| 编码 | 平台 |
|------|------|
| `1` | iOS |
| `2` | Android |
| `3` | Web |
| `4` | iPad |
| `5` | macOS |
| `6` | Windows |
| `7` | Linux |
**实现要点:** 在 HTTP 客户端的拦截器/中间件中**自动注入** `device`,根据运行时平台判定,业务代码不直接关心。
### 3. language 编码
- `1` = 简体中文
- `2` = 繁体中文
- `3` = 英文
跟随系统 locale 或用户设置。
### 4. 响应/错误格式
详见 [frontend/CLAUDE.md](../frontend/CLAUDE.md#与后端的协作约定) 或 [backend/CLAUDE.md](../backend/CLAUDE.md),与 Web 前端保持一致。
### 5. JWT 存储
- iOSKeychain
- AndroidEncryptedSharedPreferences / Keystore
- 桌面端操作系统凭证存储macOS Keychain / Windows Credential Manager / libsecret
**禁止**直接存明文文件或 SharedPreferences/UserDefaults 未加密区域。
## 代码风格
- 注释使用**中文**(与后端、前端保持一致)
- 类型定义与后端 Rust 结构体一一对齐
- HTTP 客户端集中封装,统一处理 device/language 注入、错误转换、JWT 注入
## 目录结构
> ⚠️ 待补充技术栈选定后填写。常见骨架Flutter 示例):
>
> ```
> app/
> ├── lib/
> │ ├── api/ # API 客户端
> │ ├── models/ # 与后端对齐的数据模型
> │ ├── pages/ # 页面
> │ ├── widgets/ # 可复用组件
> │ ├── providers/ # 状态管理
> │ └── utils/ # device 平台判定等工具
> ├── ios/
> ├── android/
> ├── pubspec.yaml
> └── ...
> ```
## 常用命令
> ⚠️ 待补充:技术栈选定后填写(如 `flutter run` / `flutter build apk`)。
## 开发环境
- 真机/模拟器需要能访问后端网关。本地开发可通过:
- iOS 模拟器:`localhost` 直连
- Android 模拟器:`10.0.2.2` 指代宿主机
- 真机:与宿主机同网段,访问宿主机 IP或通过隧道ngrok/frp
## 扩展指南
新增功能时:
1.`models/` 定义与后端对齐的数据模型
2.`api/` 添加调用方法(通过统一封装注入 `device`/`language`/`token`
3. 再开发页面/组件
涉及平台差异时(如文件存储路径、相机权限),用平台判定 + 抽象层屏蔽。

0
app/README.md Normal file
View File

View File

@@ -1,10 +1,10 @@
# AGENTS.md # Backend — Claude Code 项目指南
本文件为 AI Agent 提供项目背景、结构说明和开发规范。 本文件为 Claude Code及其它 AI Agent提供后端项目背景、结构说明和开发规范。
## 项目概述 ## 项目概述
这是一个基于 **Rust** 的微服务后端项目,采用 **Axum + Tokio** 技术栈,使用 **Nginx** 作为 API 网关,**PostgreSQL** 作为数据库,**Redis** 作为缓存。服务以 Docker 容器形式部署,每个核心功能拆分为独立的微服务二进制文件 这是一个基于 **Rust** 的微服务后端项目,采用 **Axum + Tokio** 技术栈,使用 **Nginx** 作为 API 网关,**PostgreSQL** 作为数据库,**Redis** 作为缓存。服务以 Docker 容器形式部署,**DDD 限界上下文Bounded Context** 划分服务边界——一个领域对外是一个微服务,内部由 Rust 模块组织
## 技术栈 ## 技术栈
@@ -23,14 +23,24 @@
``` ```
backend/ backend/
├── services/ # 微服务目录 ├── services/ # 微服务目录
│ └── user-service/ # 用户服务(当前唯一实现的服务域 │ └── user-service/ # 用户服务(DDD 用户限界上下文,单一服务对外
│ ├── user-login-account/ # 账号登录服务 (port 8001) │ ├── Cargo.toml # 单 crate
│ ├── user-register-account/ # 账号注册服务 (port 8002) │ ├── Dockerfile # 单镜像
│ ├── user-login-email/ # 邮箱登录服务 (port 8003) │ ├── migrations/ # 数据库初始化 SQL
├── user-register-email/ # 邮箱注册服务 (port 8004) │ └── 001_init.sql
── migrations/ # 数据库初始化 SQL ── src/
├── docker-compose.yml # 用户服务本地编排 ├── main.rs # 装配 Router、连接池、AppState
└── Dockerfile # 通用/遗留构建文件 ├── state.rs # AppState { db, jwt_secret }
│ ├── api.rs # 通用 ApiRequest<T> / ApiResponse<T>
│ ├── jwt.rs # JWT Claims + generate_token
│ ├── auth/ # 认证模块
│ │ ├── mod.rs
│ │ ├── login_account.rs
│ │ └── login_email.rs
│ └── register/ # 注册模块
│ ├── mod.rs
│ ├── account.rs
│ └── email.rs
├── gateway/ # API 网关 ├── gateway/ # API 网关
│ ├── Dockerfile │ ├── Dockerfile
│ └── nginx/ │ └── nginx/
@@ -46,17 +56,27 @@ backend/
└── README.md └── README.md
``` ```
> 编排已统一上移到项目根目录的 `docker-compose.yml` / `docker-compose.dev.yml`,本目录不再存放 compose 文件。
## 微服务架构说明 ## 微服务架构说明
### 服务拆分原则 ### 服务拆分原则
每个用户功能(登录/注册)按**认证方式**拆分为独立服务: **DDD 限界上下文Bounded Context** 划分:每个独立的业务域对外暴露为单一微服务,内部细分通过 Rust 模块和 axum Router 组合实现,**不再按操作粒度(登录/注册)拆 crate**。
- `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` **user-service**(用户域,单一服务)对外提供:
| 方法 | 网关路径 | 下游路径 | 用途 |
|------|---------|---------|------|
| POST | `/api/v1/auth/login/account` | `/auth/login/account` | 账号密码登录,签发 JWT |
| POST | `/api/v1/auth/login/email` | `/auth/login/email` | 邮箱密码登录,签发 JWT |
| POST | `/api/v1/users/register/account` | `/users/register/account` | 账号注册(写 user_main / user_login_account / user_login_password |
| POST | `/api/v1/users/register/email` | `/users/register/email` | 邮箱注册(写 user_main / user_login_email / user_login_password |
| GET | `/health` | `/health` | 健康检查 |
> 网关 nginx 通过 `rewrite ^/api/v1(/.*)$ $1 break;` 统一去除 `/api/v1` 前缀。
服务内部模块化布局见 [项目结构](#项目结构)`auth/` 子模块负责登录认证,`register/` 子模块负责账号注册,共享 `state.rs` / `api.rs` / `jwt.rs`
### 数据库模型 ### 数据库模型
@@ -156,7 +176,7 @@ OK
- 使用 **Rust 2024 Edition** - 使用 **Rust 2024 Edition**
- 注释使用**中文**。 - 注释使用**中文**。
- 服务状态通过 `Arc<AppState>` 注入到 Axum Handler 中。 - 服务状态通过 `Arc<AppState>` 注入到 Axum Handler 中。
- 注册类服务统一使用包装请求/响应格式: - 注册类接口统一使用包装请求/响应格式:
```rust ```rust
struct ApiRequest<T> { device: i32, language: i32, data: T } struct ApiRequest<T> { device: i32, language: i32, data: T }
struct ApiResponse<T> { success: bool, message: String, data: Option<T> } struct ApiResponse<T> { success: bool, message: String, data: Option<T> }
@@ -192,7 +212,7 @@ OK
### 5. Docker 构建 ### 5. Docker 构建
- 各微服务 Dockerfile 的构建上下文为**项目根目录**`docker-compose.yml` 中使用 `context: ../..`)。 - 各微服务 Dockerfile 的构建上下文为 **`backend/` 目录**根目录 `docker-compose.yml` 中使用 `context: ./backend`)。
- 构建采用多阶段builder + runtime基于 `rust:1.94.1-alpine3.23` 编译,最终运行在 `alpine:3.23` - 构建采用多阶段builder + runtime基于 `rust:1.94.1-alpine3.23` 编译,最终运行在 `alpine:3.23`
- 共享代码更新时,需确保 `shared/` 目录在 Dockerfile 中被正确复制。 - 共享代码更新时,需确保 `shared/` 目录在 Dockerfile 中被正确复制。
@@ -206,10 +226,21 @@ OK
## 常用命令 ## 常用命令
### 启动用户服务(本地开发 ### 启动整套后端(含网关 + 数据库 + 缓存
后端不再单独编排,由项目根目录的 docker compose 一并启动。详见 [根目录 CLAUDE.md](../CLAUDE.md#部署)。
```bash ```bash
cd services/user-service # 在项目根目录
docker-compose up --build docker compose -f docker-compose.dev.yml up -d --build # 测试
docker compose up -d --build # 正式
```
如需仅启动后端栈(不含前端)做联调:
```bash
docker compose -f docker-compose.dev.yml up -d --build \
user-db user-redis user-service gateway
``` ```
### 网关管理 ### 网关管理
@@ -227,25 +258,37 @@ docker-compose up --build
./scripts/gateway.sh reload ./scripts/gateway.sh reload
``` ```
### 本地编译单个服务 ### 本地编译运行 user-service
```bash ```bash
cd services/user-service/user-login-account cd services/user-service
cargo run cargo run
``` ```
## 扩展指南 ## 扩展指南
### 新增微服务 ### 在已有领域内新增功能(推荐)
1. 在 `services/<service-domain>/` 下创建新目录,如 `services/order-service/order-create/` 属于同一限界上下文(如新增"用户资料修改"接口)时,**不要**新建 crate 或服务,而是在现有 `user-service` 下新增模块:
2. 编写独立的 `Cargo.toml``src/main.rs``Dockerfile`
3. 在 `gateway/nginx/conf.d/services/` 添加路由配置 1. 在 `services/user-service/src/` 下新增模块文件,或扩展现有 `auth/` / `register/` 子模块
4. 在 `gateway/nginx/nginx.conf` 添加 `upstream` 2. 在子模块的 `mod.rs` 中通过 `Router::new().route(...)` 注册新路由
5. 如需新数据库表,在对应服务域的 `migrations/` 目录添加 SQL 文件 3. `gateway/nginx/conf.d/services/user-service.conf` 中追加对应 `location` 块(路径仍以 `/api/v1/...` 起始)
4. 如需新数据库表或字段,在 `services/user-service/migrations/` 下追加 SQL 文件。
### 新增服务域(新限界上下文)
当业务边界明显独立(如订单 `order-service`、支付 `payment-service`)时再新建独立服务:
1. 在 `services/<service-domain>/` 下创建独立 crate参考 `services/user-service/` 的目录布局:单 `Cargo.toml` + 单 `Dockerfile` + 模块化的 `src/`)。
2. 在 `gateway/nginx/conf.d/services/` 添加路由配置文件。
3. 在 `gateway/nginx/nginx.conf` 添加对应 `upstream`
4. 在根目录 `docker-compose.yml` / `docker-compose.dev.yml` 中追加服务定义。
5. 在 [PORT_ALLOCATION.md](PORT_ALLOCATION.md) 申请新的百位段端口并更新分配表。
### 共享代码提取 ### 共享代码提取
当前 `shared/` 目录为空。当多个服务需要共用模型、中间件或工具函数时: 当前 `shared/` 目录为空。当多个服务需要共用模型、中间件或工具函数时:
1. 在 `shared/` 下创建子模块(如 `shared/models``shared/middleware`)。 1. 在 `shared/` 下创建子模块(如 `shared/models``shared/middleware`)。
2. 将共享 crate 以 path dependency 引入各微服务: 2. 将共享 crate 以 path dependency 引入各微服务:
```toml ```toml
@@ -256,6 +299,5 @@ cargo run
## 注意事项 ## 注意事项
- `services/user-service/Dockerfile` 是一个通用构建文件,但当前各微服务使用自己的 Dockerfile。修改时请确认影响范围 - 当前 `shared/` 为空Agent 在修改代码时若发现跨服务域重复逻辑,可提议提取到 `shared/`;同一服务内部的重复逻辑直接抽到模块即可,无需走 `shared/`
- 当前 `shared/` 为空Agent 在修改代码时若发现重复逻辑,可提议提取到 `shared/`
- 网关配置文件中的 `api.example.com` 为占位域名,本地开发需配置 hosts 或使用 `localhost` - 网关配置文件中的 `api.example.com` 为占位域名,本地开发需配置 hosts 或使用 `localhost`

View File

@@ -0,0 +1,51 @@
# 后端端口分配规范
## 规划原则
- **起始端口**`20000`远离系统端口和常见开发端口3000、5000、8000、8080 等)
- **百位分段**:每个服务域独占一个百位段(`20xxx`),单域最多容纳 100 个端口
- **子段细分**:每段内部再按功能分层,便于快速定位
## 全局分配表
| 端口段 | 用途 | 说明 |
|--------|------|------|
| `20000-20099` | 基础设施 | Nginx 网关、监控、日志、管理后台等 |
| `20100-20199` | 用户服务 | `user-service`:账号/邮箱的登录、注册、用户管理 |
| `20200-20999` | 预留扩展 | 未来新增服务域 |
## 用户服务段20100-20199细分
| 子段 | 用途 | 已分配端口 |
|------|------|-----------|
| `20100-20109` | 数据层 | `20101` Postgres、`20103` Redis |
| `20110-20149` | 用户业务服务 | `20110` user-service合并后单一服务 |
| `20150-20189` | 预留扩展 | 预留 |
| `20190-20199` | 预留/调试 | 预留 |
### user-service 端口明细
| 服务名 | 宿主机端口 | 容器端口 | 说明 |
|--------|-----------|---------|------|
| user-postgres | `20101` | `5432` | PostgreSQL |
| user-redis | `20103` | `6379` | Redis 缓存 |
| user-service | `20110` | `8080` | 用户域统一服务(含账号/邮箱 登录/注册) |
> **历史端口(已回收,请勿复用)**`20111` `20112` `20113` `20114`
> 曾分别用于 `user-login-account` / `user-register-account` / `user-login-email` / `user-register-email` 四个独立微服务,现已合并为单一 `user-service:20110`,按 DDD 限界上下文聚合,内部由 axum Router 模块化拆分。
## 使用方式
1. **新增服务前先查表**:确认目标服务域的百位段是否还有空余子段
2. **`.env` 覆盖**`docker-compose.yml` 中端口使用 `${VAR:-default}` 语法,本地冲突时修改 `.env`,不动 compose 文件
3. **及时更新本文档**:分配新端口后,同步修改上表并提交
## 示例 .env
```bash
# 数据层
USER_POSTGRES_PORT=20101
USER_REDIS_PORT=20103
# 用户业务服务(合并后)
USER_SERVICE_PORT=20110
```

0
backend/README.md Normal file
View File

View File

@@ -10,8 +10,9 @@ RUN mkdir -p /var/log/nginx /var/www/certbot
COPY nginx/nginx.conf /etc/nginx/nginx.conf COPY nginx/nginx.conf /etc/nginx/nginx.conf
COPY nginx/conf.d/ /etc/nginx/conf.d/ COPY nginx/conf.d/ /etc/nginx/conf.d/
# 创建自签名证书(仅用于开发,生产环境应挂载真实证书) # 创建 SSL 目录并生成自签名证书(仅用于开发,生产环境应挂载真实证书)
RUN apk add --no-cache openssl && \ RUN mkdir -p /etc/nginx/ssl && \
apk add --no-cache openssl && \
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout /etc/nginx/ssl/key.pem \ -keyout /etc/nginx/ssl/key.pem \
-out /etc/nginx/ssl/cert.pem \ -out /etc/nginx/ssl/cert.pem \

View File

@@ -3,25 +3,48 @@ server {
listen 80 default_server; listen 80 default_server;
listen [::]:80 default_server; listen [::]:80 default_server;
server_name _; server_name _;
return 444; return 444;
} }
# HTTP 重定向到 HTTPS # HTTP 重定向到 HTTPS(生产域名)
server { server {
listen 80; listen 80;
listen [::]:80; listen [::]:80;
server_name api.example.com; server_name api.example.com;
location /.well-known/acme-challenge/ { location /.well-known/acme-challenge/ {
root /var/www/certbot; root /var/www/certbot;
} }
location / { location / {
return 301 https://$server_name$request_uri; return 301 https://$server_name$request_uri;
} }
} }
# 开发环境 - 直接代理,不重定向到 HTTPS
server {
listen 80;
listen [::]:80;
server_name localhost api-gateway host.docker.internal;
# 开发环境直接代理,不强制 HTTPS
include /etc/nginx/conf.d/services/*.conf;
# 健康检查
location /health {
access_log off;
return 200 '{"status":"healthy","timestamp":"$time_iso8601"}\n';
add_header Content-Type application/json;
}
# 根路径
location / {
return 200 '{"status":"ok","service":"api-gateway","timestamp":"$time_iso8601"}\n';
add_header Content-Type application/json;
}
}
# API 网关主配置 # API 网关主配置
server { server {
listen 443 ssl http2; listen 443 ssl http2;

View File

@@ -0,0 +1,68 @@
# 用户服务路由
#
# 全部接口统一打到 user_service upstream合并后的单一服务
# rewrite 统一去掉 /api/v1 前缀,下游按 /auth/* 和 /users/* 组织路由
# 账号登录(严格限流)
location /api/v1/auth/login/account {
limit_req zone=api_strict burst=5 nodelay;
limit_conn addr 3;
rewrite ^/api/v1(/.*)$ $1 break;
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;
}
# 邮箱登录(严格限流)
location /api/v1/auth/login/email {
limit_req zone=api_strict burst=5 nodelay;
limit_conn addr 3;
rewrite ^/api/v1(/.*)$ $1 break;
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;
}
# 账号注册(通用限流)
location /api/v1/users/register/account {
limit_req zone=general burst=20 nodelay;
limit_conn addr 10;
rewrite ^/api/v1(/.*)$ $1 break;
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;
}
# 邮箱注册(通用限流)
location /api/v1/users/register/email {
limit_req zone=general burst=20 nodelay;
limit_conn addr 10;
rewrite ^/api/v1(/.*)$ $1 break;
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;
}

View File

@@ -44,22 +44,23 @@ http {
# 连接限制 # 连接限制
limit_conn_zone $binary_remote_addr zone=addr:10m; limit_conn_zone $binary_remote_addr zone=addr:10m;
# 上游服务 # 上游服务 —— 通过 Docker 内部 DNS服务名访问统一由根目录 docker-compose 编排
upstream user_service { upstream user_service {
least_conn; least_conn;
server user-service:8080 max_fails=3 fail_timeout=30s; server user-service:8080 max_fails=3 fail_timeout=30s;
keepalive 32; keepalive 32;
} }
# 以下服务尚未实现,临时标记为 down避免启动时 DNS 解析失败
upstream order_service { upstream order_service {
least_conn; least_conn;
server order-service:8080 max_fails=3 fail_timeout=30s; server 127.0.0.1:9999 down;
keepalive 32; keepalive 32;
} }
upstream payment_service { upstream payment_service {
least_conn; least_conn;
server payment-service:8080 max_fails=3 fail_timeout=30s; server 127.0.0.1:9999 down;
keepalive 32; keepalive 32;
} }

37
backend/scripts/push-image.sh Executable file
View File

@@ -0,0 +1,37 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="${SCRIPT_DIR}/../.env.registry"
REGISTRY="registry.fishestlife.com"
if [[ ! -f "$ENV_FILE" ]]; then
echo "错误:找不到 ${ENV_FILE}"
echo "请从模板创建并填入账号密码"
exit 1
fi
# shellcheck source=/dev/null
source "$ENV_FILE"
if [[ -z "${REGISTRY_USER:-}" || -z "${REGISTRY_PASS:-}" ]]; then
echo "错误REGISTRY_USER 或 REGISTRY_PASS 未设置"
echo "请编辑 ${ENV_FILE} 填入凭证"
exit 1
fi
echo "登录镜像仓库 ${REGISTRY} ..."
echo "$REGISTRY_PASS" | docker login "$REGISTRY" -u "$REGISTRY_USER" --password-stdin
# 推送参数:镜像名:标签
IMAGE="${1:-}"
if [[ -z "$IMAGE" ]]; then
echo "用法:$0 <镜像名:标签>"
echo "示例:$0 user-service:latest"
exit 1
fi
echo "推送镜像 ${IMAGE} ..."
docker push "${REGISTRY}/${IMAGE}"
echo "完成:${REGISTRY}/${IMAGE}"

View File

@@ -1,5 +1,5 @@
[package] [package]
name = "user-login-email" name = "user-service"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"
@@ -19,10 +19,10 @@ sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres",
# UUID # UUID
uuid = { version = "1", features = ["v7", "serde"] } uuid = { version = "1", features = ["v7", "serde"] }
# Redis # Redis(预留:当前未使用,待引入限流/会话等场景)
redis = { version = "0.29", features = ["tokio-comp"] } redis = { version = "0.29", features = ["tokio-comp"] }
# 密码哈希bcrypt # 密码哈希
bcrypt = "0.17" bcrypt = "0.17"
# JWT # JWT
@@ -39,6 +39,9 @@ dotenvy = "0.15"
# 错误处理 # 错误处理
thiserror = "2.0" thiserror = "2.0"
# 参数校验
validator = { version = "0.20", features = ["derive"] }
[profile.release] [profile.release]
opt-level = 3 opt-level = 3
lto = true lto = true

View File

@@ -2,18 +2,18 @@
FROM rust:1.94.1-alpine3.23 AS builder FROM rust:1.94.1-alpine3.23 AS builder
# 安装构建依赖 # 安装构建依赖
RUN apk add --no-cache musl-dev openssl-dev openssl-libs-static RUN apk add --no-cache musl-dev openssl-dev openssl-libs-static pkgconfig
# 创建工作目录 # 创建工作目录
WORKDIR /app WORKDIR /app
# 先复制共享代码和 Cargo 文件以利用缓存 # 先复制 Cargo 文件以利用依赖缓存
COPY shared /app/shared
COPY services/user-service/Cargo.toml services/user-service/Cargo.lock* ./ COPY services/user-service/Cargo.toml services/user-service/Cargo.lock* ./
# 创建虚拟 main.rs 来缓存依赖 # 创建虚拟 main.rs 来缓存依赖
RUN mkdir -p src && echo 'fn main() {}' > src/main.rs RUN mkdir -p src && echo 'fn main() {}' > src/main.rs
RUN cargo build --release && rm -rf src RUN cargo build --release 2>/dev/null || true
RUN rm -rf src
# 复制真实源代码 # 复制真实源代码
COPY services/user-service/src ./src COPY services/user-service/src ./src

View File

@@ -0,0 +1,18 @@
// 注册/业务类接口的统一请求/响应包装格式
// 与 backend/CLAUDE.md 中的 API 公共约定保持一致
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
pub struct ApiRequest<T> {
pub device: i32,
pub language: i32,
pub data: T,
}
#[derive(Serialize)]
pub struct ApiResponse<T> {
pub success: bool,
pub message: String,
pub data: Option<T>,
}

View File

@@ -0,0 +1,88 @@
// 账号密码登录
// 验证 user_login_account.account + user_login_password.password签发 JWT
use axum::{Json, extract::State, http::StatusCode};
use bcrypt::verify;
use serde::Deserialize;
use std::sync::Arc;
use tracing::{info, warn};
use crate::jwt::generate_token;
use crate::state::AppState;
use super::LoginResponse;
#[derive(Deserialize)]
pub struct LoginRequest {
username: String,
password: String,
}
pub async fn handle(
State(state): State<Arc<AppState>>,
Json(payload): Json<LoginRequest>,
) -> (StatusCode, Json<LoginResponse>) {
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)) => match verify(&payload.password, &password_hash) {
Ok(true) => {
info!("User {} logged in successfully", payload.username);
let token = generate_token(&user_id.to_string(), &state.jwt_secret);
(
StatusCode::OK,
Json(LoginResponse {
success: true,
token: Some(token),
message: "Login successful".to_string(),
}),
)
}
Ok(false) => {
warn!("Invalid password for user {}", payload.username);
(
StatusCode::UNAUTHORIZED,
Json(LoginResponse {
success: false,
token: None,
message: "Invalid credentials".to_string(),
}),
)
}
Err(e) => {
warn!("Password verification error: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(LoginResponse {
success: false,
token: None,
message: "Internal error".to_string(),
}),
)
}
},
None => {
warn!("User not found: {}", payload.username);
(
StatusCode::UNAUTHORIZED,
Json(LoginResponse {
success: false,
token: None,
message: "Invalid credentials".to_string(),
}),
)
}
}
}

View File

@@ -0,0 +1,88 @@
// 邮箱密码登录
// 验证 user_login_email.email + user_login_password.password签发 JWT
use axum::{Json, extract::State, http::StatusCode};
use bcrypt::verify;
use serde::Deserialize;
use std::sync::Arc;
use tracing::{info, warn};
use crate::jwt::generate_token;
use crate::state::AppState;
use super::LoginResponse;
#[derive(Deserialize)]
pub struct LoginRequest {
email: String,
password: String,
}
pub async fn handle(
State(state): State<Arc<AppState>>,
Json(payload): Json<LoginRequest>,
) -> (StatusCode, Json<LoginResponse>) {
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)) => match verify(&payload.password, &password_hash) {
Ok(true) => {
info!("Email {} logged in successfully", payload.email);
let token = generate_token(&user_id.to_string(), &state.jwt_secret);
(
StatusCode::OK,
Json(LoginResponse {
success: true,
token: Some(token),
message: "Login successful".to_string(),
}),
)
}
Ok(false) => {
warn!("Invalid password for email {}", payload.email);
(
StatusCode::UNAUTHORIZED,
Json(LoginResponse {
success: false,
token: None,
message: "Invalid credentials".to_string(),
}),
)
}
Err(e) => {
warn!("Password verification error: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(LoginResponse {
success: false,
token: None,
message: "Internal error".to_string(),
}),
)
}
},
None => {
warn!("Email not found: {}", payload.email);
(
StatusCode::UNAUTHORIZED,
Json(LoginResponse {
success: false,
token: None,
message: "Invalid credentials".to_string(),
}),
)
}
}
}

View File

@@ -0,0 +1,25 @@
// auth 模块:登录/认证相关接口
// 路由:/auth/login/account, /auth/login/email
use axum::{Router, routing::post};
use serde::Serialize;
use std::sync::Arc;
use crate::state::AppState;
mod login_account;
mod login_email;
// 登录/认证类接口扁平响应(与前端约定对齐)
#[derive(Serialize)]
pub struct LoginResponse {
pub success: bool,
pub token: Option<String>,
pub message: String,
}
pub fn router() -> Router<Arc<AppState>> {
Router::new()
.route("/auth/login/account", post(login_account::handle))
.route("/auth/login/email", post(login_email::handle))
}

View File

@@ -0,0 +1,31 @@
// JWT Claims 定义与签发
// 当前账号登录、邮箱登录共用,未来登出/刷新等也走这里
use chrono::{Duration, Utc};
use jsonwebtoken::{EncodingKey, Header, encode};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
pub struct Claims {
pub sub: String,
pub exp: usize,
pub iat: usize,
}
pub 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,63 @@
// user-service 装配入口
// 合并旧 4 个微服务user-login-account / user-register-account / user-login-email / user-register-email
use axum::{Router, routing::get};
use std::env;
use std::sync::Arc;
use tracing::info;
mod api;
mod auth;
mod jwt;
mod register;
mod state;
use state::AppState;
#[tokio::main]
async fn main() {
tracing_subscriber::fmt::init();
info!("Starting user-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,
});
// 路由:合并 auth + register 子路由 + 健康检查
let app = Router::new()
.merge(auth::router())
.merge(register::router())
.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-service listening on port {}", port);
axum::serve(listener, app).await.unwrap();
}
async fn health_handler() -> &'static str {
"OK"
}

View File

@@ -1,88 +1,29 @@
use axum::{ // 账号注册
extract::State, // 写入 user_main / user_login_account / user_login_password 三表事务
http::StatusCode,
response::Json, use axum::{Json, extract::State, http::StatusCode};
routing::{get, post},
Router,
};
use bcrypt::hash; use bcrypt::hash;
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use std::env;
use std::sync::Arc;
use chrono::Utc; use chrono::Utc;
use serde::Deserialize;
use std::sync::Arc;
use tracing::{info, warn}; use tracing::{info, warn};
use uuid::Uuid; use uuid::Uuid;
use validator::Validate; use validator::Validate;
#[derive(Clone)] use crate::api::{ApiRequest, ApiResponse};
struct AppState { use crate::state::AppState;
db: PgPool,
} use super::RegisterData;
#[derive(Deserialize, Validate)] #[derive(Deserialize, Validate)]
struct RegisterRequest { pub struct RegisterRequest {
#[validate(length(min = 3, max = 50))] #[validate(length(min = 3, max = 50))]
username: String, username: String,
#[validate(length(min = 6))] #[validate(length(min = 6))]
password: String, password: String,
} }
#[derive(Deserialize)] pub async fn handle(
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>>, State(state): State<Arc<AppState>>,
Json(req): Json<ApiRequest<RegisterRequest>>, Json(req): Json<ApiRequest<RegisterRequest>>,
) -> (StatusCode, Json<ApiResponse<RegisterData>>) { ) -> (StatusCode, Json<ApiResponse<RegisterData>>) {
@@ -105,12 +46,12 @@ async fn register_handler(
// 检查账号是否已存在 // 检查账号是否已存在
let existing: Option<(Uuid,)> = sqlx::query_as( let existing: Option<(Uuid,)> = sqlx::query_as(
"SELECT id FROM user_login_account WHERE account = $1 AND deleted = FALSE" "SELECT id FROM user_login_account WHERE account = $1 AND deleted = FALSE",
) )
.bind(&req.data.username) .bind(&req.data.username)
.fetch_optional(&state.db) .fetch_optional(&state.db)
.await .await
.unwrap_or(None); .unwrap_or(None);
if existing.is_some() { if existing.is_some() {
return ( return (
@@ -159,7 +100,7 @@ async fn register_handler(
let user_id = Uuid::now_v7(); let user_id = Uuid::now_v7();
if let Err(e) = sqlx::query( if let Err(e) = sqlx::query(
"INSERT INTO user_main (id, create_date, modify_date) VALUES ($1, $2, $3)" "INSERT INTO user_main (id, create_date, modify_date) VALUES ($1, $2, $3)",
) )
.bind(user_id) .bind(user_id)
.bind(now) .bind(now)
@@ -181,7 +122,7 @@ async fn register_handler(
let account_id = Uuid::now_v7(); let account_id = Uuid::now_v7();
if let Err(e) = sqlx::query( if let Err(e) = sqlx::query(
"INSERT INTO user_login_account (id, user_id, account, create_date, modify_date) VALUES ($1, $2, $3, $4, $5)" "INSERT INTO user_login_account (id, user_id, account, create_date, modify_date) VALUES ($1, $2, $3, $4, $5)",
) )
.bind(account_id) .bind(account_id)
.bind(user_id) .bind(user_id)
@@ -205,7 +146,7 @@ async fn register_handler(
let password_id = Uuid::now_v7(); let password_id = Uuid::now_v7();
if let Err(e) = sqlx::query( if let Err(e) = sqlx::query(
"INSERT INTO user_login_password (id, user_id, password, create_date, modify_date) VALUES ($1, $2, $3, $4, $5)" "INSERT INTO user_login_password (id, user_id, password, create_date, modify_date) VALUES ($1, $2, $3, $4, $5)",
) )
.bind(password_id) .bind(password_id)
.bind(user_id) .bind(user_id)
@@ -252,14 +193,3 @@ async fn register_handler(
} }
} }
} }
async fn health_handler() -> (StatusCode, Json<ApiResponse<()>>) {
(
StatusCode::OK,
Json(ApiResponse {
success: true,
message: "OK".to_string(),
data: None,
}),
)
}

View File

@@ -1,88 +1,29 @@
use axum::{ // 邮箱注册
extract::State, // 写入 user_main / user_login_email / user_login_password 三表事务
http::StatusCode,
response::Json, use axum::{Json, extract::State, http::StatusCode};
routing::{get, post},
Router,
};
use bcrypt::hash; use bcrypt::hash;
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use std::env;
use std::sync::Arc;
use chrono::Utc; use chrono::Utc;
use serde::Deserialize;
use std::sync::Arc;
use tracing::{info, warn}; use tracing::{info, warn};
use uuid::Uuid; use uuid::Uuid;
use validator::Validate; use validator::Validate;
#[derive(Clone)] use crate::api::{ApiRequest, ApiResponse};
struct AppState { use crate::state::AppState;
db: PgPool,
} use super::RegisterData;
#[derive(Deserialize, Validate)] #[derive(Deserialize, Validate)]
struct RegisterRequest { pub struct RegisterRequest {
#[validate(email)] #[validate(email)]
email: String, email: String,
#[validate(length(min = 6))] #[validate(length(min = 6))]
password: String, password: String,
} }
#[derive(Deserialize)] pub async fn handle(
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>>, State(state): State<Arc<AppState>>,
Json(req): Json<ApiRequest<RegisterRequest>>, Json(req): Json<ApiRequest<RegisterRequest>>,
) -> (StatusCode, Json<ApiResponse<RegisterData>>) { ) -> (StatusCode, Json<ApiResponse<RegisterData>>) {
@@ -105,12 +46,12 @@ async fn register_handler(
// 检查邮箱是否已存在 // 检查邮箱是否已存在
let existing: Option<(Uuid,)> = sqlx::query_as( let existing: Option<(Uuid,)> = sqlx::query_as(
"SELECT id FROM user_login_email WHERE email = $1 AND deleted = FALSE" "SELECT id FROM user_login_email WHERE email = $1 AND deleted = FALSE",
) )
.bind(&req.data.email) .bind(&req.data.email)
.fetch_optional(&state.db) .fetch_optional(&state.db)
.await .await
.unwrap_or(None); .unwrap_or(None);
if existing.is_some() { if existing.is_some() {
return ( return (
@@ -159,7 +100,7 @@ async fn register_handler(
let user_id = Uuid::now_v7(); let user_id = Uuid::now_v7();
if let Err(e) = sqlx::query( if let Err(e) = sqlx::query(
"INSERT INTO user_main (id, create_date, modify_date) VALUES ($1, $2, $3)" "INSERT INTO user_main (id, create_date, modify_date) VALUES ($1, $2, $3)",
) )
.bind(user_id) .bind(user_id)
.bind(now) .bind(now)
@@ -181,7 +122,7 @@ async fn register_handler(
let email_id = Uuid::now_v7(); let email_id = Uuid::now_v7();
if let Err(e) = sqlx::query( 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)" "INSERT INTO user_login_email (id, user_id, email, create_date, modify_date) VALUES ($1, $2, $3, $4, $5)",
) )
.bind(email_id) .bind(email_id)
.bind(user_id) .bind(user_id)
@@ -205,7 +146,7 @@ async fn register_handler(
let password_id = Uuid::now_v7(); let password_id = Uuid::now_v7();
if let Err(e) = sqlx::query( if let Err(e) = sqlx::query(
"INSERT INTO user_login_password (id, user_id, password, create_date, modify_date) VALUES ($1, $2, $3, $4, $5)" "INSERT INTO user_login_password (id, user_id, password, create_date, modify_date) VALUES ($1, $2, $3, $4, $5)",
) )
.bind(password_id) .bind(password_id)
.bind(user_id) .bind(user_id)
@@ -252,14 +193,3 @@ async fn register_handler(
} }
} }
} }
async fn health_handler() -> (StatusCode, Json<ApiResponse<()>>) {
(
StatusCode::OK,
Json(ApiResponse {
success: true,
message: "OK".to_string(),
data: None,
}),
)
}

View File

@@ -0,0 +1,24 @@
// register 模块:账号/邮箱注册接口
// 路由:/users/register/account, /users/register/email
use axum::{Router, routing::post};
use serde::Serialize;
use std::sync::Arc;
use uuid::Uuid;
use crate::state::AppState;
mod account;
mod email;
// 注册成功返回的业务数据(账号/邮箱共用)
#[derive(Serialize)]
pub struct RegisterData {
pub user_id: Uuid,
}
pub fn router() -> Router<Arc<AppState>> {
Router::new()
.route("/users/register/account", post(account::handle))
.route("/users/register/email", post(email::handle))
}

View File

@@ -0,0 +1,10 @@
// 应用全局状态:数据库连接池 + JWT 密钥
// 由 main.rs 在启动时构造,通过 Arc<AppState> 注入到各 handler
use sqlx::PgPool;
#[derive(Clone)]
pub struct AppState {
pub db: PgPool,
pub jwt_secret: String,
}

121
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,121 @@
# asset_helper —— 测试环境一键编排
#
# 使用:
# docker compose -f docker-compose.dev.yml up -d --build
#
# 与正式环境的差异:
# 1. 项目名 / 容器名 / 网络 / 数据卷 全部带 -dev 后缀,与正式完全隔离
# 2. 微服务、Postgres、Redis 端口全部暴露宿主机,便于调试
# 3. 网关、前端使用不同对外端口,可与正式环境同机并存
# 4. 敏感值JWT_SECRET、POSTGRES_PASSWORD提供默认值方便快速启动
name: asset-helper-dev
services:
# ============ 数据层 ============
user-db:
image: postgres:18.3-alpine3.23
container_name: user-db-dev
environment:
- POSTGRES_USER=${POSTGRES_USER:-postgres}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-postgres}
- POSTGRES_DB=user-db
volumes:
- user-postgres-data-dev:/var/lib/postgresql/data
- ./backend/services/user-service/migrations:/docker-entrypoint-initdb.d:ro
ports:
- "${USER_POSTGRES_PORT:-20101}:5432"
networks:
- asset-helper-dev
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d user-db"]
interval: 10s
timeout: 5s
retries: 5
user-redis:
image: redis:8.6.2-alpine
container_name: user-redis-dev
volumes:
- user-redis-data-dev:/data
ports:
- "${USER_REDIS_PORT:-20103}:6379"
networks:
- asset-helper-dev
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
# ============ 用户微服务 ============
user-service:
build:
context: ./backend
dockerfile: services/user-service/Dockerfile
container_name: user-service-dev
environment:
- RUST_LOG=${RUST_LOG:-debug}
- DATABASE_URL=postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@user-db:5432/user-db
- REDIS_URL=redis://user-redis:6379/0
- SERVICE_NAME=user-service
- SERVICE_PORT=8080
- JWT_SECRET=${JWT_SECRET:-dev-secret-key}
ports:
- "${USER_SERVICE_PORT:-20110}:8080"
depends_on:
user-db:
condition: service_healthy
user-redis:
condition: service_healthy
networks:
- asset-helper-dev
restart: unless-stopped
# ============ API 网关 ============
gateway:
build:
context: ./backend/gateway
dockerfile: Dockerfile
container_name: api-gateway-dev
ports:
- "${GATEWAY_HTTP_PORT:-18080}:80"
- "${GATEWAY_HTTPS_PORT:-18443}:443"
depends_on:
- user-service
networks:
- asset-helper-dev
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost/health"]
interval: 30s
timeout: 3s
start_period: 5s
retries: 3
# ============ 前端管理后台 ============
admin-web:
build:
context: ./frontend
dockerfile: docker/Dockerfile
container_name: asset-helper-admin-dev
ports:
- "${ADMIN_WEB_PORT:-18888}:80"
depends_on:
- gateway
networks:
- asset-helper-dev
restart: unless-stopped
networks:
asset-helper-dev:
name: asset-helper-dev
driver: bridge
volumes:
user-postgres-data-dev:
name: user-postgres-data-dev
user-redis-data-dev:
name: user-redis-data-dev

115
docker-compose.yml Normal file
View File

@@ -0,0 +1,115 @@
# asset_helper —— 正式环境一键编排
#
# 使用:
# 1. 复制 .env.example 为 .env填入 JWT_SECRET 等敏感值
# 2. docker compose up -d --build
#
# 暴露端口(默认):
# - 80/443 网关(对外)
# - 20080 前端管理后台(对外)
# 微服务、Postgres、Redis 仅在内部网络可达,不暴露宿主机端口。
name: asset-helper
services:
# ============ 数据层 ============
user-db:
image: postgres:18.3-alpine3.23
container_name: user-db
environment:
- POSTGRES_USER=${POSTGRES_USER:-postgres}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?need POSTGRES_PASSWORD in .env}
- POSTGRES_DB=user-db
volumes:
- user-postgres-data:/var/lib/postgresql/data
- ./backend/services/user-service/migrations:/docker-entrypoint-initdb.d:ro
networks:
- asset-helper
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-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
networks:
- asset-helper
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
# ============ 用户微服务 ============
user-service:
build:
context: ./backend
dockerfile: services/user-service/Dockerfile
container_name: user-service
environment:
- RUST_LOG=${RUST_LOG:-info}
- DATABASE_URL=postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD}@user-db:5432/user-db
- REDIS_URL=redis://user-redis:6379/0
- SERVICE_NAME=user-service
- SERVICE_PORT=8080
- JWT_SECRET=${JWT_SECRET:?need JWT_SECRET in .env}
depends_on:
user-db:
condition: service_healthy
user-redis:
condition: service_healthy
networks:
- asset-helper
restart: unless-stopped
# ============ API 网关 ============
gateway:
build:
context: ./backend/gateway
dockerfile: Dockerfile
container_name: api-gateway
ports:
- "${GATEWAY_HTTP_PORT:-80}:80"
- "${GATEWAY_HTTPS_PORT:-443}:443"
depends_on:
- user-service
networks:
- asset-helper
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost/health"]
interval: 30s
timeout: 3s
start_period: 5s
retries: 3
# ============ 前端管理后台 ============
admin-web:
build:
context: ./frontend
dockerfile: docker/Dockerfile
container_name: asset-helper-admin
ports:
- "${ADMIN_WEB_PORT:-20080}:80"
depends_on:
- gateway
networks:
- asset-helper
restart: unless-stopped
networks:
asset-helper:
name: asset-helper
driver: bridge
volumes:
user-postgres-data:
name: user-postgres-data
user-redis-data:
name: user-redis-data

12
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,12 @@
node_modules
npm-debug.log
Dockerfile*
docker-compose*
.dockerignore
dist
.git
.gitignore
README.md
.vscode
.idea
*.md

172
frontend/CLAUDE.md Normal file
View File

@@ -0,0 +1,172 @@
# Frontend — Claude Code 项目指南
本文件为 Claude Code及其它 AI Agent提供前端项目的背景、结构说明和开发规范。
## 项目状态
**技术栈已确定,项目已初始化。**
| 维度 | 选型 |
|------|------|
| 框架 | React 18 + TypeScript 5 |
| 构建 | Vite 6 |
| UI 库 | Ant Design 5 + @ant-design/icons |
| 路由 | React Router 6 |
| 状态管理 | Zustand客户端状态 |
| HTTP 客户端 | AxiosAPI 封装在 `src/api/` |
## 定位
`frontend/` 是 asset_helper 的 **Web 管理后台**,调用后端 API通过 Nginx 网关暴露)。
-`backend/` 通过 HTTP/JSON 交互,遵循根目录 [CLAUDE.md](../CLAUDE.md) 中定义的跨端契约
-`app/`(移动/桌面端)共享后端,但 UI 实现独立
- 当前为后台管理系统,后续可扩展为面向用户的 Web 端
## Docker 部署
前端**不再单独编排**,统一由项目根目录的 docker compose 一并启动。详见 [根目录 CLAUDE.md](../CLAUDE.md#部署)。
| 环境 | 启动命令(在项目根目录执行) | 访问地址 |
|------|------|---------|
| 正式 | `docker compose up -d --build` | `http://localhost:20080` |
| 测试 | `docker compose -f docker-compose.dev.yml up -d --build` | `http://localhost:18888` |
两套环境前端均为多阶段构建Node 构建 → Nginx 静态托管),通过 Nginx 的 `location /api/` 反代到网关容器(服务名 `gateway`,同 Docker 网络内可达)。
## 与后端的协作约定
以下约定来自 [backend/CLAUDE.md](../backend/CLAUDE.md),前端调用时**必须遵守**
### 1. 请求包装(注册/业务类接口)
```ts
interface ApiRequest<T> {
device: number; // 前端固定使用 Device.Web = 3
language: number; // 默认 Language.SimplifiedChinese = 1
data: T; // 业务字段
}
```
**device 编码:**
- `1` = iOS、`2` = Android、`3` = Web ← **前端使用此值**
- `4` = iPad、`5` = macOS、`6` = Windows、`7` = Linux
**language 编码:**
- `1` = 简体中文(默认)、`2` = 繁体中文、`3` = 英文
### 2. 响应包装
```ts
interface ApiResponse<T> {
success: boolean;
message: string;
data: T | null; // 失败时为 null
}
```
### 3. 登录/认证类接口(扁平响应)
```ts
interface LoginResponse {
success: boolean;
token: string | null; // JWT
message: string;
}
```
**JWT 存储策略**:存于 Zustand 内存中(页面刷新丢失,需重新登录)。如需持久化,可改为 localStorage但需注意 XSS 风险。
### 4. 错误响应HTTP 非 200
```ts
interface ErrorResponse {
error: string;
message: string;
code: number;
}
```
## 目录结构
```
frontend/
├── docker/
│ ├── Dockerfile # 多阶段构建Node 构建 + Nginx 静态托管)
│ └── nginx.conf # 生产 Nginx SPA 配置 + /api 反代到网关
├── src/
│ ├── api/
│ │ ├── client.ts # Axios 封装device/language 注入、JWT、错误处理
│ │ └── auth.ts # 认证相关 API
│ ├── components/ # 可复用 UI 组件(待扩展)
│ ├── hooks/ # 自定义 Hooks待扩展
│ ├── layouts/
│ │ └── MainLayout.tsx # 后台主布局(侧边栏 + 头部 + 内容区)
│ ├── pages/
│ │ ├── LoginPage.tsx # 登录页(账号/邮箱切换)
│ │ ├── DashboardPage.tsx # 仪表盘首页
│ │ └── NotFoundPage.tsx # 404
│ ├── router/
│ │ └── index.tsx # 路由配置(登录守卫 + 受保护路由)
│ ├── stores/
│ │ └── auth.ts # Zustand 认证状态token + login/logout
│ ├── types/
│ │ ├── api.ts # 通用 API 类型device/language 枚举、包装类型)
│ │ └── auth.ts # 认证相关类型
│ ├── utils/
│ │ └── storage.ts # localStorage 封装(带前缀隔离)
│ ├── App.tsx # 根组件ConfigProvider + RouterProvider
│ └── main.tsx # 入口
├── index.html
├── package.json
├── tsconfig.json / tsconfig.app.json / tsconfig.node.json
└── vite.config.ts
```
> 编排文件已统一上移到项目根目录的 `docker-compose.yml` / `docker-compose.dev.yml`。
## API 调用规范
**必须使用封装函数,禁止直接 fetch/axios**
```ts
// ✅ 业务接口(自动注入 device/language
import { apiPost, apiGet } from '@/api/client'
const data = await apiPost<RequestType, ResponseType>('/api/v1/users/xxx', payload)
// ✅ 认证接口(扁平格式,不包装)
import { loginAccount } from '@/api/auth'
const result = await loginAccount({ account: 'xxx', password: 'xxx' })
```
## 代码风格
- 注释使用**中文**(与后端保持一致)
- TypeScript 类型与后端 Rust 结构体一一对齐,禁止 `any`
- API 调用集中在 `src/api/`,不在组件中直接写 axios
- 路由守卫在 `src/router/index.tsx` 中统一配置
## 常用命令
| 命令 | 说明 |
|------|------|
| `npm install` | 安装依赖(开发容器内自动执行) |
| `npm run dev` | 启动开发服务器(容器内) |
| `npm run build` | 生产构建 |
| `npm run preview` | 预览生产构建 |
## 开发环境
- 推荐:在项目根目录用 `docker compose -f docker-compose.dev.yml up -d --build` 启动整套测试环境,前端为 Nginx 静态托管,访问 `http://localhost:18888`
- 如需快速调试前端而不构建镜像,本目录下 `npm run dev` 可启动 Vite dev server监听 `0.0.0.0:5173`),需自行确保后端网关在宿主机可达;可通过 `VITE_API_BASE_URL` 覆盖 proxy 目标
## 扩展指南
新增页面/功能时:
1. 先在 `types/` 定义与后端对齐的类型
2.`api/` 添加调用封装(务必通过统一封装注入 `device`/`language`
3.`pages/` 创建页面组件
4.`router/index.tsx` 添加路由
5. 如需加入侧边栏菜单,在 `layouts/MainLayout.tsx``menuItems` 中配置
发现重复逻辑时优先抽到 `utils/` 或自定义 Hook。

0
frontend/README.md Normal file
View File

View File

@@ -0,0 +1,28 @@
# 生产环境 Dockerfile — 多阶段构建
# Stage 1: 构建
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: 运行
FROM nginx:1.25-alpine
# 复制自定义 Nginx 配置
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
# 复制构建产物
COPY --from=builder /app/dist /usr/share/nginx/html
# 健康检查
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --quiet --tries=1 --spider http://localhost:80/ || exit 1
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -0,0 +1,42 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Gzip 压缩
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;
# 静态资源缓存
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# 前端路由支持SPA
location / {
try_files $uri $uri/ /index.html;
}
# API 代理到后端网关(生产环境)
location /api/ {
proxy_pass http://gateway:80/;
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_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;
}

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Asset Helper 管理后台</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

28
frontend/package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "asset-helper-admin",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
},
"dependencies": {
"@ant-design/icons": "^5.6.1",
"antd": "^5.24.6",
"axios": "^1.8.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.30.0",
"zustand": "^5.0.3"
},
"devDependencies": {
"@types/react": "^18.3.20",
"@types/react-dom": "^18.3.6",
"@vitejs/plugin-react": "^4.4.1",
"typescript": "~5.7.3",
"vite": "^6.3.3"
}
}

20
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,20 @@
import { RouterProvider } from 'react-router-dom'
import { ConfigProvider, theme } from 'antd'
import zhCN from 'antd/locale/zh_CN'
import { router } from './router'
import { useThemeStore } from './stores/theme'
export default function App() {
const isDark = useThemeStore((s) => s.isDark)
return (
<ConfigProvider
locale={zhCN}
theme={{
algorithm: isDark ? theme.darkAlgorithm : theme.defaultAlgorithm,
}}
>
<RouterProvider router={router} />
</ConfigProvider>
)
}

23
frontend/src/api/auth.ts Normal file
View File

@@ -0,0 +1,23 @@
import { rawPost } from './client'
import type { LoginResponse } from '@/types/api'
import type { LoginAccountPayload, LoginEmailPayload, RegisterAccountPayload, RegisterEmailPayload } from '@/types/auth'
/** 账号密码登录 */
export function loginAccount(payload: LoginAccountPayload) {
return rawPost<LoginAccountPayload, LoginResponse>('/api/v1/auth/login/account', payload)
}
/** 邮箱密码登录 */
export function loginEmail(payload: LoginEmailPayload) {
return rawPost<LoginEmailPayload, LoginResponse>('/api/v1/auth/login/email', payload)
}
/** 账号注册 */
export function registerAccount(payload: RegisterAccountPayload) {
return rawPost<RegisterAccountPayload, LoginResponse>('/api/v1/users/register/account', payload)
}
/** 邮箱注册 */
export function registerEmail(payload: RegisterEmailPayload) {
return rawPost<RegisterEmailPayload, LoginResponse>('/api/v1/users/register/email', payload)
}

View File

@@ -0,0 +1,95 @@
import axios, { AxiosError, type AxiosInstance, type AxiosRequestConfig } from 'axios'
import { Device, Language, type ApiRequest, type ApiResponse, type ErrorResponse } from '@/types/api'
import { useAuthStore } from '@/stores/auth'
// 根据环境选择基础地址
// Docker 开发环境Vite proxy 会将 /api 转发到后端
// 生产环境Nginx 代理 /api 到后端网关
const baseURL = import.meta.env.DEV ? '/' : '/api'
const client: AxiosInstance = axios.create({
baseURL,
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
})
// 请求拦截器:注入 JWT
client.interceptors.request.use((config) => {
const token = useAuthStore.getState().token
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// 响应拦截器:统一错误处理
client.interceptors.response.use(
(response) => response,
(error: AxiosError<ErrorResponse>) => {
const status = error.response?.status
const message = error.response?.data?.message || error.message || '请求失败'
if (status === 401) {
useAuthStore.getState().logout()
window.location.href = '/login'
}
return Promise.reject(new Error(message))
}
)
/** 包装业务请求(注册/业务类接口) */
export async function apiPost<TReq, TRes>(
url: string,
data: TReq,
config?: AxiosRequestConfig
): Promise<TRes> {
const body: ApiRequest<TReq> = {
device: Device.Web,
language: Language.SimplifiedChinese,
data,
}
const response = await client.post<ApiResponse<TRes>>(url, body, config)
const result = response.data
if (!result.success) {
throw new Error(result.message)
}
if (result.data === null) {
throw new Error('接口返回数据为空')
}
return result.data
}
/** 原始 POST登录/认证类接口,不包装) */
export async function rawPost<TReq, TRes>(
url: string,
data: TReq,
config?: AxiosRequestConfig
): Promise<TRes> {
const response = await client.post<TRes>(url, data, config)
return response.data
}
/** 通用 GET */
export async function apiGet<TRes>(url: string, config?: AxiosRequestConfig): Promise<TRes> {
const response = await client.get<ApiResponse<TRes>>(url, config)
const result = response.data
if (!result.success) {
throw new Error(result.message)
}
if (result.data === null) {
throw new Error('接口返回数据为空')
}
return result.data
}
export default client

View File

@@ -0,0 +1,104 @@
import { useState } from 'react'
import { Outlet, useNavigate } from 'react-router-dom'
import { Layout, Menu, Button, theme, Dropdown, Avatar, Space } from 'antd'
import {
DashboardOutlined,
LogoutOutlined,
UserOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
} from '@ant-design/icons'
import { useAuthStore } from '@/stores/auth'
const { Header, Sider, Content } = Layout
export default function MainLayout() {
const [collapsed, setCollapsed] = useState(false)
const logout = useAuthStore((s) => s.logout)
const navigate = useNavigate()
const {
token: { colorBgContainer, borderRadiusLG },
} = theme.useToken()
const menuItems = [
{
key: '/',
icon: <DashboardOutlined />,
label: '仪表盘',
},
]
const userMenuItems = [
{
key: 'logout',
icon: <LogoutOutlined />,
label: '退出登录',
danger: true,
onClick: () => {
logout()
navigate('/login')
},
},
]
return (
<Layout style={{ minHeight: '100vh' }}>
<Sider trigger={null} collapsible collapsed={collapsed} theme="light">
<div
style={{
height: 64,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: 'bold',
fontSize: collapsed ? 14 : 18,
borderBottom: '1px solid #f0f0f0',
}}
>
{collapsed ? 'AH' : 'Asset Helper'}
</div>
<Menu
mode="inline"
defaultSelectedKeys={['/']}
items={menuItems}
onClick={({ key }) => navigate(key)}
/>
</Sider>
<Layout>
<Header
style={{
padding: '0 24px',
background: colorBgContainer,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Button
type="text"
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
onClick={() => setCollapsed(!collapsed)}
/>
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
<Space style={{ cursor: 'pointer' }}>
<Avatar icon={<UserOutlined />} />
<span></span>
</Space>
</Dropdown>
</Header>
<Content
style={{
margin: 24,
padding: 24,
background: colorBgContainer,
borderRadius: borderRadiusLG,
overflow: 'auto',
}}
>
<Outlet />
</Content>
</Layout>
</Layout>
)
}

10
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './styles/global.css'
import App from './App'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
)

View File

@@ -0,0 +1,35 @@
import { Card, Statistic, Row, Col } from 'antd'
import { UserOutlined, AppstoreOutlined } from '@ant-design/icons'
export default function DashboardPage() {
return (
<div>
<h2 style={{ marginBottom: 24 }}></h2>
<Row gutter={16}>
<Col span={8}>
<Card>
<Statistic
title="用户总数"
value={0}
prefix={<UserOutlined />}
/>
</Card>
</Col>
<Col span={8}>
<Card>
<Statistic
title="资产总数"
value={0}
prefix={<AppstoreOutlined />}
/>
</Card>
</Col>
<Col span={8}>
<Card>
<Statistic title="今日新增" value={0} />
</Card>
</Col>
</Row>
</div>
)
}

View File

@@ -0,0 +1,125 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Card, Form, Input, Button, Tabs, message } from 'antd'
import { UserOutlined, LockOutlined, MailOutlined, MoonOutlined, SunOutlined } from '@ant-design/icons'
import { loginAccount, loginEmail } from '@/api/auth'
import { useAuthStore } from '@/stores/auth'
import { useThemeStore } from '@/stores/theme'
import type { LoginAccountPayload, LoginEmailPayload } from '@/types/auth'
type LoginType = 'account' | 'email'
export default function LoginPage() {
const [loginType, setLoginType] = useState<LoginType>('account')
const [loading, setLoading] = useState(false)
const login = useAuthStore((s) => s.login)
const isDark = useThemeStore((s) => s.isDark)
const toggleTheme = useThemeStore((s) => s.toggle)
const navigate = useNavigate()
useEffect(() => {
const htmlOverflow = document.documentElement.style.overflow
const bodyOverflow = document.body.style.overflow
const bodyTouchAction = document.body.style.touchAction
document.documentElement.style.overflow = 'hidden'
document.body.style.overflow = 'hidden'
document.body.style.touchAction = 'none'
return () => {
document.documentElement.style.overflow = htmlOverflow
document.body.style.overflow = bodyOverflow
document.body.style.touchAction = bodyTouchAction
}
}, [])
async function handleSubmit(values: LoginAccountPayload | LoginEmailPayload) {
setLoading(true)
try {
const response =
loginType === 'account'
? await loginAccount(values as LoginAccountPayload)
: await loginEmail(values as LoginEmailPayload)
if (response.success && response.token) {
login(response.token)
message.success('登录成功')
navigate('/')
} else {
message.error(response.message || '登录失败')
}
} catch (err) {
message.error(err instanceof Error ? err.message : '登录失败')
} finally {
setLoading(false)
}
}
return (
<div
style={{
height: '100vh',
overflow: 'hidden',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: isDark ? '#141414' : '#f0f2f5',
transition: 'background 0.3s ease',
}}
>
<Card
title="Asset Helper 管理后台"
extra={
<Button
type="text"
icon={isDark ? <SunOutlined /> : <MoonOutlined />}
onClick={toggleTheme}
/>
}
style={{ width: 400 }}
styles={{ header: { textAlign: 'center', fontSize: 18 } }}
>
<Tabs
centered
activeKey={loginType}
onChange={(key) => setLoginType(key as LoginType)}
items={[
{ key: 'account', label: '账号登录' },
{ key: 'email', label: '邮箱登录' },
]}
/>
<Form onFinish={handleSubmit} autoComplete="off">
{loginType === 'account' ? (
<Form.Item
name="account"
rules={[{ required: true, message: '请输入账号' }]}
>
<Input prefix={<UserOutlined />} placeholder="账号" />
</Form.Item>
) : (
<Form.Item
name="email"
rules={[
{ required: true, message: '请输入邮箱' },
{ type: 'email', message: '邮箱格式不正确' },
]}
>
<Input prefix={<MailOutlined />} placeholder="邮箱" />
</Form.Item>
)}
<Form.Item
name="password"
rules={[{ required: true, message: '请输入密码' }]}
>
<Input.Password prefix={<LockOutlined />} placeholder="密码" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" block loading={loading}>
</Button>
</Form.Item>
</Form>
</Card>
</div>
)
}

View File

@@ -0,0 +1,19 @@
import { Button, Result } from 'antd'
import { useNavigate } from 'react-router-dom'
export default function NotFoundPage() {
const navigate = useNavigate()
return (
<Result
status="404"
title="404"
subTitle="抱歉,您访问的页面不存在"
extra={
<Button type="primary" onClick={() => navigate('/')}>
</Button>
}
/>
)
}

View File

@@ -0,0 +1,38 @@
import { createBrowserRouter, Navigate, Outlet } from 'react-router-dom'
import { useAuthStore } from '@/stores/auth'
import LoginPage from '@/pages/LoginPage'
import DashboardPage from '@/pages/DashboardPage'
import NotFoundPage from '@/pages/NotFoundPage'
import MainLayout from '@/layouts/MainLayout'
/** 路由守卫:已登录则放行,未登录跳转登录页 */
function RequireAuth() {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
return isAuthenticated ? <Outlet /> : <Navigate to="/login" replace />
}
/** 登录页守卫:已登录则跳转首页 */
function LoginGuard() {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
return isAuthenticated ? <Navigate to="/" replace /> : <LoginPage />
}
export const router = createBrowserRouter([
{
path: '/login',
element: <LoginGuard />,
},
{
element: <RequireAuth />,
children: [
{
path: '/',
element: <MainLayout />,
children: [
{ index: true, element: <DashboardPage /> },
{ path: '*', element: <NotFoundPage /> },
],
},
],
},
])

View File

@@ -0,0 +1,25 @@
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
interface AuthState {
token: string | null
isAuthenticated: boolean
login: (token: string) => void
logout: () => void
}
export const useAuthStore = create<AuthState>()(
devtools(
(set) => ({
token: null,
isAuthenticated: false,
login: (token: string) =>
set({ token, isAuthenticated: true }, false, 'login'),
logout: () =>
set({ token: null, isAuthenticated: false }, false, 'logout'),
}),
{ name: 'AuthStore' }
)
)

View File

@@ -0,0 +1,20 @@
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
interface ThemeState {
isDark: boolean
toggle: () => void
}
export const useThemeStore = create<ThemeState>()(
devtools(
persist(
(set) => ({
isDark: false,
toggle: () => set((state) => ({ isDark: !state.isDark }), false, 'toggleTheme'),
}),
{ name: 'theme-storage' }
),
{ name: 'ThemeStore' }
)
)

View File

@@ -0,0 +1,5 @@
body {
margin: 0;
padding: 0;
transition: background-color 0.3s ease;
}

47
frontend/src/types/api.ts Normal file
View File

@@ -0,0 +1,47 @@
// 与后端对齐的通用 API 类型
/** device 编码 */
export enum Device {
IOS = 1,
Android = 2,
Web = 3,
IPad = 4,
MacOS = 5,
Windows = 6,
Linux = 7,
}
/** language 编码 */
export enum Language {
SimplifiedChinese = 1,
TraditionalChinese = 2,
English = 3,
}
/** 注册/业务类接口请求包装 */
export interface ApiRequest<T> {
device: number
language: number
data: T
}
/** 注册/业务类接口响应包装 */
export interface ApiResponse<T> {
success: boolean
message: string
data: T | null
}
/** 登录/认证类接口响应 */
export interface LoginResponse {
success: boolean
token: string | null
message: string
}
/** 网关错误响应HTTP 非 200 */
export interface ErrorResponse {
error: string
message: string
code: number
}

View File

@@ -0,0 +1,27 @@
// 认证相关类型
export interface LoginAccountPayload {
account: string
password: string
}
export interface LoginEmailPayload {
email: string
password: string
}
export interface RegisterAccountPayload {
account: string
password: string
}
export interface RegisterEmailPayload {
email: string
password: string
}
export interface UserProfile {
id: string
account?: string
email?: string
}

View File

@@ -0,0 +1,29 @@
// 轻量存储封装JWT 存内存,其他持久化数据用 localStorage
const PREFIX = 'asset_helper_admin:'
export const storage = {
get<T>(key: string): T | null {
try {
const raw = localStorage.getItem(PREFIX + key)
return raw ? (JSON.parse(raw) as T) : null
} catch {
return null
}
},
set<T>(key: string, value: T): void {
localStorage.setItem(PREFIX + key, JSON.stringify(value))
},
remove(key: string): void {
localStorage.removeItem(PREFIX + key)
},
clear(): void {
// 仅清除本应用前缀的 key
Object.keys(localStorage)
.filter((k) => k.startsWith(PREFIX))
.forEach((k) => localStorage.removeItem(k))
},
}

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"]
}

7
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

32
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,32 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig(({ mode }) => ({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
host: '0.0.0.0',
port: 5173,
strictPort: true,
watch: {
usePolling: true,
},
proxy: {
'/api': {
// 开发环境:通过 Docker 网络直接访问网关容器
target: process.env.VITE_API_BASE_URL || 'http://api-gateway',
changeOrigin: true,
secure: false,
},
},
},
build: {
outDir: 'dist',
sourcemap: mode !== 'production',
},
}))

View File

@@ -1,39 +0,0 @@
# 用户服务路由
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;
}

View File

@@ -1,159 +0,0 @@
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

@@ -1,45 +0,0 @@
[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

@@ -1,39 +0,0 @@
# 构建阶段
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

@@ -1,196 +0,0 @@
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

@@ -1,39 +0,0 @@
# 构建阶段
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

@@ -1,196 +0,0 @@
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

@@ -1,33 +0,0 @@
[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

@@ -1,33 +0,0 @@
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

@@ -1,33 +0,0 @@
[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

@@ -1,33 +0,0 @@
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"]