diff --git a/Cargo.lock b/Cargo.lock index 4eb160b..f436466 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "ahash" version = "0.7.8" @@ -55,6 +61,15 @@ version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "argon2" version = "0.5.3" @@ -106,6 +121,8 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", + "utoipa", + "utoipa-swagger-ui", "uuid", "validator", ] @@ -630,6 +647,15 @@ version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-queue" version = "0.3.12" @@ -717,6 +743,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -874,6 +911,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "miniz_oxide", + "zlib-rs", +] + [[package]] name = "flume" version = "0.11.1" @@ -1707,6 +1754,26 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.1.1" @@ -2552,6 +2619,40 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rust-embed" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "syn 2.0.114", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" +dependencies = [ + "sha2", + "walkdir", +] + [[package]] name = "rust-ini" version = "0.21.3" @@ -2998,6 +3099,12 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + [[package]] name = "simdutf8" version = "0.1.5" @@ -3749,6 +3856,12 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-bidi" version = "0.3.18" @@ -3812,6 +3925,48 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utoipa" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fcc29c80c21c31608227e0912b2d7fddba57ad76b606890627ba8ee7964e993" +dependencies = [ + "indexmap", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d79d08d92ab8af4c5e8a6da20c47ae3f61a0f1dabc1997cdf2d082b757ca08b" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn 2.0.114", +] + +[[package]] +name = "utoipa-swagger-ui" +version = "9.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d047458f1b5b65237c2f6dc6db136945667f40a7668627b3490b9513a3d43a55" +dependencies = [ + "axum", + "base64", + "mime_guess", + "regex", + "rust-embed", + "serde", + "serde_json", + "url", + "utoipa", + "zip", +] + [[package]] name = "uuid" version = "1.20.0" @@ -4652,8 +4807,40 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "zip" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12598812502ed0105f607f941c386f43d441e00148fce9dec3ca5ffb0bde9308" +dependencies = [ + "arbitrary", + "crc32fast", + "flate2", + "indexmap", + "memchr", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7948af682ccbc3342b6e9420e8c51c1fe5d7bf7756002b4a3c6cabfe96a7e3c" + [[package]] name = "zmij" version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4de98dfa5d5b7fef4ee834d0073d560c9ca7b6c46a71d058c48db7960f8cfaf7" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/Cargo.toml b/Cargo.toml index 07dc643..42a9bc4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,6 +49,8 @@ chrono = { version = "0.4", features = ["serde"] } uuid = { version = "1.20", features = ["v4", "serde"] } futures = "0.3" axum-extra = { version = "0.12.5", features = ["typed-header"] } +utoipa = { version = "5.4.0", features = ["axum_extras"] } +utoipa-swagger-ui = { version = "9.0.2", features = ["axum"] } [dev-dependencies] # Testing @@ -63,4 +65,4 @@ debug = true [profile.release] opt-level = 3 lto = true -codegen-units = 1 +codegen-units = 1 \ No newline at end of file diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..53c450c --- /dev/null +++ b/IMPLEMENTATION_PLAN.md @@ -0,0 +1,212 @@ +# AstraCup Backend - CRUD 实施计划 + +## 项目概述 +为AstraCup比赛管理后端生成完整的CRUD API,包括认证、授权和系统初始化功能。 + +## 技术栈 +- **框架**: Axum 0.8.8 +- **数据库**: SeaORM 1.1.19 + MySQL +- **认证**: JWT + osu! OAuth 2.0 +- **文档**: utoipa (OpenAPI/Swagger) +- **验证**: validator 0.20 +- **配置**: dotenv + config + +## 实施阶段 + +### 第一阶段:基础架构搭建 ✅ +- [x] 创建项目目录结构 +- [x] 实现数据库连接池 +- [x] 配置管理系统 +- [x] 错误处理系统 +- [x] 基础DTO和Service模板 + +### 第二阶段:认证系统 🔄 +- [ ] JWT认证中间件 +- [ ] osu! OAuth集成(含refresh token存储) +- [ ] 用户注册/登录流程 +- [ ] 权限中间件(基于UserGroup) + +### 第三阶段:核心CRUD +- [ ] User API完整实现 +- [ ] 管理员设置功能 +- [ ] 系统初始化逻辑 +- [ ] TournamentSettings API + +### 第四阶段:其他Entity CRUD +- [ ] MatchRooms API +- [ ] MatchSchedules API +- [ ] PlayerMatchups API +- [ ] MapSelections API +- [ ] MapComments API +- [ ] Messages API + +### 第五阶段:文档和优化 +- [ ] OpenAPI文档生成 +- [ ] 测试编写 +- [ ] 性能优化 +- [ ] 部署配置 + +## 详细任务分解 + +### 第一阶段任务 +1. **目录结构创建** + - [x] 创建 `src/api/` 目录和模块 + - [x] 创建 `src/service/` 目录和模块 + - [x] 创建 `src/dto/` 目录和模块 + - [x] 创建 `src/error/` 目录和模块 + - [x] 创建 `src/config/` 目录和模块 + - [x] 创建 `src/middleware/` 目录和模块 + - [x] 创建 `src/utils/` 目录和模块 + +2. **数据库连接** + - [x] 实现Database配置 + - [x] 创建连接池管理 + - [x] 添加数据库迁移支持 + +3. **错误处理** + - [x] 定义AppError枚举 + - [x] 实现From trait用于错误转换 + - [x] 创建统一的错误响应格式 + +4. **配置管理** + - [x] 从.env读取配置 + - [x] 创建Config结构体 + - [x] 实现配置验证 + +### 第二阶段任务 +1. **JWT认证** + - [ ] 创建JWT工具函数 + - [ ] 实现Token生成和验证 + - [ ] 创建认证中间件 + +2. **osu! OAuth集成** + - [ ] 添加OAuth相关字段到User entity + - [ ] 实现OAuth授权流程 + - [ ] 处理OAuth回调 + - [ ] 存储access/refresh token + +3. **权限管理** + - [ ] 创建权限检查中间件 + - [ ] 实现基于UserGroup的权限控制 + - [ ] 管理员权限特殊处理 + +### 第三阶段任务 +1. **User API** + - [ ] 创建User DTOs + - [ ] 实现UserService + - [ ] 创建User API路由 + - [ ] 添加数据验证 + +2. **系统初始化** + - [ ] 检查tournament_settings表 + - [ ] 创建默认比赛设置 + - [ ] 管理员用户初始化逻辑 + +3. **TournamentSettings API** + - [ ] 创建TournamentSettings DTOs + - [ ] 实现TournamentSettingsService + - [ ] 创建API路由 + +### 第四阶段任务 +1. **MatchRooms API** + - [ ] 创建MatchRooms DTOs + - [ ] 实现MatchRoomsService + - [ ] 创建API路由 + +2. **其他Entity API**(类似模式) + - [ ] MatchSchedules + - [ ] PlayerMatchups + - [ ] MapSelections + - [ ] MapComments + - [ ] Messages + +### 第五阶段任务 +1. **文档生成** + - [ ] 集成utoipa + - [ ] 为所有API添加OpenAPI注解 + - [ ] 设置Swagger UI路由 + +2. **测试** + - [ ] 单元测试 + - [ ] 集成测试 + - [ ] API测试 + +3. **优化** + - [ ] 性能优化 + - [ ] 错误处理优化 + - [ ] 日志优化 + +## 数据库变更 + +### User表新增字段 +```sql +ALTER TABLE user +ADD COLUMN osu_access_token VARCHAR(512) NULL, +ADD COLUMN osu_refresh_token VARCHAR(512) NULL, +ADD COLUMN osu_token_expires_at TIMESTAMP NULL; +``` + +## API端点设计 + +### 认证相关 +- `GET /api/auth/osu` - 重定向到osu! OAuth +- `GET /api/auth/callback/osu` - OAuth回调 +- `POST /api/auth/refresh` - 刷新JWT token +- `POST /api/auth/logout` - 登出 + +### 用户管理 +- `GET /api/users` - 获取用户列表(管理员) +- `GET /api/users/{id}` - 获取用户详情 +- `PUT /api/users/{id}` - 更新用户信息 +- `PUT /api/users/{id}/user-group` - 修改用户组(管理员) +- `DELETE /api/users/{id}` - 删除用户(管理员) + +### 比赛管理 +- `GET /api/match-rooms` - 获取比赛房间列表 +- `POST /api/match-rooms` - 创建比赛房间 +- `GET /api/match-rooms/{id}` - 获取比赛房间详情 +- `PUT /api/match-rooms/{id}` - 更新比赛房间 +- `DELETE /api/match-rooms/{id}` - 删除比赛房间 + +## 权限规则 + +### 用户角色 +1. **玩家 (Player)** + - 查看公开信息 + - 管理自己的数据 + - 参与比赛 + +2. **管理员 (Admin)** + - 所有玩家权限 + - 管理所有用户 + - 修改系统设置 + - 管理比赛 + +### 权限矩阵 +| 操作 | 玩家 | 管理员 | +|------|------|--------| +| 查看用户列表 | ❌ | ✅ | +| 修改用户组 | ❌ | ✅ | +| 创建比赛房间 | ✅ | ✅ | +| 删除比赛房间 | 仅自己 | ✅ | +| 修改系统设置 | ❌ | ✅ | + +## 开发注意事项 + +1. **安全性** + - 所有密码/Token加密存储 + - JWT secret从环境变量读取 + - 输入验证和清理 + - SQL注入防护 + +2. **性能** + - 数据库连接池 + - 查询优化 + - 缓存策略 + +3. **可维护性** + - 清晰的错误信息 + - 完整的日志记录 + - 统一的代码风格 + - 详细的API文档 + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/src/api/auth.rs b/src/api/auth.rs new file mode 100644 index 0000000..acffa6d --- /dev/null +++ b/src/api/auth.rs @@ -0,0 +1,25 @@ +use axum::{Router, routing::get}; + +pub fn routes() -> Router { + Router::new() + .route("/osu", get(osu_auth)) + .route("/callback/osu", get(osu_callback)) + .route("/refresh", get(refresh_token)) + .route("/logout", get(logout)) +} + +async fn osu_auth() -> &'static str { + "osu! OAuth 认证" +} + +async fn osu_callback() -> &'static str { + "osu! OAuth 回调" +} + +async fn refresh_token() -> &'static str { + "刷新Token" +} + +async fn logout() -> &'static str { + "登出" +} diff --git a/src/api/map_comments.rs b/src/api/map_comments.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/api/map_selections.rs b/src/api/map_selections.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/api/match_rooms.rs b/src/api/match_rooms.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/api/match_schedules.rs b/src/api/match_schedules.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/api/messages.rs b/src/api/messages.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/api/mod.rs b/src/api/mod.rs new file mode 100644 index 0000000..96e48c8 --- /dev/null +++ b/src/api/mod.rs @@ -0,0 +1,45 @@ +pub mod auth; +pub mod user; + +use axum::{Router, routing::get}; +use utoipa::OpenApi; +use utoipa_swagger_ui::SwaggerUi; + +use crate::service::ServiceState; + +pub fn configure_routes() -> Router { + Router::new() + // Swagger UI文档 + .merge(SwaggerUi::new("/api/docs").url("/api-docs/openapi.json", ApiDoc::openapi())) + .route("/health", get(|| async { "OK" })) + .nest("/api/auth", auth::routes()) + .nest("/api/users", user::routes()) + .with_state(()) +} + +#[derive(OpenApi)] +#[openapi( + paths( + user::get_users, + user::get_user, + user::update_user, + user::update_user_group, + user::delete_user, + ), + components( + schemas( + crate::dto::user::UserResponse, + crate::dto::user::UpdateUserRequest, + crate::dto::user::UpdateUserGroupRequest, + crate::dto::user::UserQueryParams, + crate::dto::ApiResponse, + crate::dto::PaginatedResponse, + user::PaginatedUserResponse, + ) + ), + tags( + (name = "auth", description = "认证相关API"), + (name = "users", description = "用户管理API"), + ) +)] +pub struct ApiDoc; diff --git a/src/api/player_matchups.rs b/src/api/player_matchups.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/api/tournament_settings.rs b/src/api/tournament_settings.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/api/user.rs b/src/api/user.rs new file mode 100644 index 0000000..b1bceb8 --- /dev/null +++ b/src/api/user.rs @@ -0,0 +1,183 @@ +use axum::{ + Json, Router, + extract::{Path, Query, State}, + routing::{delete, get, post, put}, +}; +use utoipa::ToSchema; + +use crate::{ + dto::{ + ApiResponse, PaginatedResponse, + user::{ + CreateUserRequest, UpdateUserGroupRequest, UpdateUserRequest, UserQueryParams, + UserResponse, + }, + }, + error::Result, + service::{ServiceState, user::UserService}, +}; + +pub fn routes() -> Router { + Router::new() + .route("/", get(get_users)) + .route("/", post(create_user)) + .route("/{id}", get(get_user)) + .route("/{id}", put(update_user)) + .route("/{id}", delete(delete_user)) + .route("/{id}/user-group", put(update_user_group)) +} + +#[utoipa::path( + get, + path = "/api/users", + params(UserQueryParams), + responses( + (status = 200, description = "获取用户列表成功", body = PaginatedUserResponse), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + ), + tag = "users" +)] +async fn get_users( + State(state): State, + Query(params): Query, +) -> Result>>> { + let service = UserService::new(state.db); + let (users, total) = service.find_all(params).await?; + + let response = PaginatedResponse::new( + users.into_iter().map(UserResponse::from).collect(), + total, + 1, + 20, + ); + + Ok(Json(ApiResponse::success(response))) +} + +#[utoipa::path( + get, + path = "/api/users/{id}", + params( + ("id" = i32, Path, description = "用户ID") + ), + responses( + (status = 200, description = "获取用户成功", body = UserResponse), + (status = 404, description = "用户不存在"), + ), + tag = "users" +)] +async fn get_user( + State(state): State, + Path(id): Path, +) -> Result>> { + let service = UserService::new(state.db); + let user = service.find_by_id(id).await?; + + Ok(Json(ApiResponse::success(UserResponse::from(user)))) +} + +#[utoipa::path( + post, + path = "/api/users", + request_body = CreateUserRequest, + responses( + (status = 201, description = "创建用户成功", body = UserResponse), + (status = 400, description = "请求参数错误"), + (status = 401, description = "未授权"), + ), + tag = "users" +)] +async fn create_user() -> Result>> { + // 用户创建将通过OAuth回调处理 + Err(crate::error::AppError::BusinessError( + "请通过OAuth注册用户".into(), + )) +} + +#[utoipa::path( + put, + path = "/api/users/{id}", + params( + ("id" = i32, Path, description = "用户ID") + ), + request_body = UpdateUserRequest, + responses( + (status = 200, description = "更新用户成功", body = UserResponse), + (status = 400, description = "请求参数错误"), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + (status = 404, description = "用户不存在"), + ), + tag = "users" +)] +async fn update_user( + State(state): State, + Path(id): Path, + Json(data): Json, +) -> Result>> { + let service = UserService::new(state.db); + let user = service.update(id, data).await?; + + Ok(Json(ApiResponse::success(UserResponse::from(user)))) +} + +#[utoipa::path( + put, + path = "/api/users/{id}/user-group", + params( + ("id" = i32, Path, description = "用户ID") + ), + request_body = UpdateUserGroupRequest, + responses( + (status = 200, description = "更新用户组成功", body = UserResponse), + (status = 400, description = "请求参数错误"), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + (status = 404, description = "用户不存在"), + ), + tag = "users" +)] +async fn update_user_group( + State(state): State, + Path(id): Path, + Json(data): Json, +) -> Result>> { + let service = UserService::new(state.db); + let user = service.update_user_group(id, data).await?; + + Ok(Json(ApiResponse::success(UserResponse::from(user)))) +} + +#[utoipa::path( + delete, + path = "/api/users/{id}", + params( + ("id" = i32, Path, description = "用户ID") + ), + responses( + (status = 200, description = "删除用户成功"), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + (status = 404, description = "用户不存在"), + ), + tag = "users" +)] +async fn delete_user( + State(state): State, + Path(id): Path, +) -> Result>> { + let service = UserService::new(state.db); + service.delete(id).await?; + + Ok(Json(ApiResponse::success_with_message((), "用户删除成功"))) +} + +#[derive(ToSchema)] +pub struct PaginatedUserResponse { + items: Vec, + total: u64, + page: u64, + page_size: u64, + total_pages: u64, +} diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 0000000..cecf634 --- /dev/null +++ b/src/config/mod.rs @@ -0,0 +1,80 @@ +use std::env; + +use serde::Deserialize; +use validator::Validate; + +#[derive(Debug, Clone, Deserialize, Validate)] +pub struct Config { + #[validate(length(min = 1))] + pub database_url: String, + + #[validate(range(min = 1, max = 65535))] + pub port: u16, + + pub host: String, + + #[validate(length(min = 1))] + pub jwt_secret: String, + + pub jwt_expiry_hours: u64, + + pub osu_client_id: String, + + pub osu_client_secret: String, + + pub osu_redirect_uri: String, + + pub cors_origin: String, + + pub log_level: String, +} + +impl Config { + pub fn from_env() -> Result { + dotenv::dotenv().ok(); + + let config = Config { + database_url: env::var("DATABASE_URL") + .map_err(|_| crate::error::AppError::ConfigError("DATABASE_URL未设置".into()))?, + + port: env::var("PORT") + .unwrap_or_else(|_| "3000".to_string()) + .parse() + .map_err(|_| crate::error::AppError::ConfigError("PORT格式错误".into()))?, + + host: env::var("HOST").unwrap_or_else(|_| "0.0.0.0".to_string()), + + jwt_secret: env::var("JWT_SECRET") + .map_err(|_| crate::error::AppError::ConfigError("JWT_SECRET未设置".into()))?, + + jwt_expiry_hours: env::var("JWT_EXPIRY_HOURS") + .unwrap_or_else(|_| "24".to_string()) + .parse() + .map_err(|_| { + crate::error::AppError::ConfigError("JWT_EXPIRY_HOURS格式错误".into()) + })?, + + osu_client_id: env::var("OSU_CLIENT_ID") + .map_err(|_| crate::error::AppError::ConfigError("OSU_CLIENT_ID未设置".into()))?, + + osu_client_secret: env::var("OSU_CLIENT_SECRET").map_err(|_| { + crate::error::AppError::ConfigError("OSU_CLIENT_SECRET未设置".into()) + })?, + + osu_redirect_uri: env::var("OSU_REDIRECT_URI").map_err(|_| { + crate::error::AppError::ConfigError("OSU_REDIRECT_URI未设置".into()) + })?, + + cors_origin: env::var("CORS_ORIGIN") + .unwrap_or_else(|_| "http://localhost:3000".to_string()), + + log_level: env::var("LOG_LEVEL").unwrap_or_else(|_| "info".to_string()), + }; + + config + .validate() + .map_err(|e| crate::error::AppError::ConfigError(format!("配置验证失败: {}", e)))?; + + Ok(config) + } +} diff --git a/src/dto/auth.rs b/src/dto/auth.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/dto/map_comments.rs b/src/dto/map_comments.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/dto/map_selections.rs b/src/dto/map_selections.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/dto/match_rooms.rs b/src/dto/match_rooms.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/dto/match_schedules.rs b/src/dto/match_schedules.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/dto/messages.rs b/src/dto/messages.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/dto/mod.rs b/src/dto/mod.rs new file mode 100644 index 0000000..c65d87a --- /dev/null +++ b/src/dto/mod.rs @@ -0,0 +1,95 @@ +pub mod auth; +pub mod map_comments; +pub mod map_selections; +pub mod match_rooms; +pub mod match_schedules; +pub mod messages; +pub mod player_matchups; +pub mod tournament_settings; +pub mod user; + +/// 通用响应结构 +#[derive(Debug, serde::Serialize, utoipa::ToSchema)] +pub struct ApiResponse { + pub success: bool, + pub data: Option, + pub message: Option, + pub error: Option, +} + +impl ApiResponse { + pub fn success(data: T) -> Self { + Self { + success: true, + data: Some(data), + message: None, + error: None, + } + } + + pub fn success_with_message(data: T, message: &str) -> Self { + Self { + success: true, + data: Some(data), + message: Some(message.to_string()), + error: None, + } + } + + pub fn error(error: &str) -> Self { + Self { + success: false, + data: None, + message: None, + error: Some(error.to_string()), + } + } + + pub fn error_with_message(error: &str, message: &str) -> Self { + Self { + success: false, + data: None, + message: Some(message.to_string()), + error: Some(error.to_string()), + } + } +} + +/// 分页请求参数 +#[derive(Debug, serde::Deserialize, utoipa::IntoParams)] +pub struct PaginationParams { + #[serde(default = "default_page")] + pub page: u64, + #[serde(default = "default_page_size")] + pub page_size: u64, +} + +fn default_page() -> u64 { + 1 +} + +fn default_page_size() -> u64 { + 20 +} + +#[derive(Debug, serde::Serialize, utoipa::ToSchema)] +pub struct PaginatedResponse { + pub items: Vec, + pub total: u64, + pub page: u64, + pub page_size: u64, + pub total_pages: u64, +} + +impl PaginatedResponse { + pub fn new(items: Vec, total: u64, page: u64, page_size: u64) -> Self { + let total_pages = (total as f64 / page_size as f64).ceil() as u64; + Self { + items, + total, + page, + page_size, + total_pages, + } + } +} diff --git a/src/dto/player_matchups.rs b/src/dto/player_matchups.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/dto/tournament_settings.rs b/src/dto/tournament_settings.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/dto/user.rs b/src/dto/user.rs new file mode 100644 index 0000000..72722d4 --- /dev/null +++ b/src/dto/user.rs @@ -0,0 +1,119 @@ +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use validator::Validate; + +use crate::entity::user::Model as UserModel; + +#[derive(Debug, Deserialize, Validate, ToSchema)] +pub struct CreateUserRequest { + #[validate(length(min = 3, max = 50))] + pub username: String, + + pub avatar_url: Option, + + pub pp: Option, + + pub global_rank: Option, + + pub country: Option, + + pub country_rank: Option, +} + +#[derive(Debug, Deserialize, Validate, ToSchema)] +pub struct UpdateUserRequest { + #[validate(length(min = 3, max = 50))] + pub username: Option, + + pub avatar_url: Option, + + pub pp: Option, + + pub global_rank: Option, + + pub country: Option, + + pub country_rank: Option, + + pub approved: Option, + + pub season: Option, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct UserResponse { + pub id: i32, + pub osu_id: String, + pub username: String, + pub registered_at: String, + pub avatar_url: Option, + pub pp: Option, + pub global_rank: Option, + pub country: Option, + pub country_rank: Option, + pub approved: Option, + pub user_group: String, + pub season: Option, + pub gu_server_user_id: Option, + pub gu_server_username: Option, + pub created_at: String, + pub updated_at: String, +} + +impl From for UserResponse { + fn from(user: UserModel) -> Self { + Self { + id: user.id, + osu_id: user.osu_id, + username: user.username, + registered_at: user.registered_at.to_rfc3339(), + avatar_url: user.avatar_url, + pp: user.pp, + global_rank: user.global_rank, + country: user.country, + country_rank: user.country_rank, + approved: user.approved, + user_group: format!("{:?}", user.user_group), + season: user.season, + gu_server_user_id: user.gu_server_user_id, + gu_server_username: user.gu_server_username, + created_at: user.created_at.to_rfc3339(), + updated_at: user.updated_at.to_rfc3339(), + } + } +} + +#[derive(Debug, Deserialize, Validate, ToSchema)] +pub struct UpdateUserGroupRequest { + pub user_group: String, +} + +#[derive(Debug, Deserialize, Validate, utoipa::IntoParams)] +pub struct UserQueryParams { + pub username: Option, + pub country: Option, + pub user_group: Option, + pub page: Option, + pub page_size: Option, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct OsuUserData { + pub id: String, + pub username: String, + pub avatar_url: Option, + pub statistics: Option, + pub country: Option, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct OsuStatistics { + pub pp: Option, + pub global_rank: Option, + pub country_rank: Option, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct OsuCountry { + pub code: String, +} diff --git a/src/entities/map_comments.rs b/src/entities/map_comments.rs deleted file mode 100644 index 009fc39..0000000 --- a/src/entities/map_comments.rs +++ /dev/null @@ -1,26 +0,0 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19 - -use sea_orm::entity::prelude::*; - -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] -#[sea_orm(table_name = "map_comments")] -pub struct Model { - #[sea_orm(primary_key)] - pub id: i32, - #[sea_orm(column_name = "mapSelectionId")] - pub map_selection_id: i32, - #[sea_orm(column_name = "osuId")] - pub osu_id: String, - pub username: String, - #[sea_orm(column_type = "Text", nullable)] - pub comment: Option, - #[sea_orm(column_name = "createdAt")] - pub created_at: Option, - #[sea_orm(column_name = "updatedAt")] - pub updated_at: Option, -} - -#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] -pub enum Relation {} - -impl ActiveModelBehavior for ActiveModel {} diff --git a/src/entities/map_selections.rs b/src/entities/map_selections.rs deleted file mode 100644 index 9c7b680..0000000 --- a/src/entities/map_selections.rs +++ /dev/null @@ -1,63 +0,0 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19 - -use sea_orm::entity::prelude::*; - -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] -#[sea_orm(table_name = "map_selections")] -pub struct Model { - #[sea_orm(primary_key)] - pub id: i32, - #[sea_orm(column_name = "beatmapId")] - pub beatmap_id: i32, - #[sea_orm(column_name = "beatmapsetId")] - pub beatmapset_id: i32, - pub title: String, - pub title_unicode: Option, - pub artist: String, - pub artist_unicode: Option, - pub version: String, - pub creator: String, - #[sea_orm(column_name = "starRating", column_type = "Decimal(Some((5, 2)))")] - pub star_rating: Decimal, - #[sea_orm(column_type = "Decimal(Some((6, 2)))")] - pub bpm: Decimal, - #[sea_orm(column_name = "totalLength")] - pub total_length: i32, - #[sea_orm(column_name = "maxCombo")] - pub max_combo: i32, - #[sea_orm(column_type = "Decimal(Some((4, 2)))")] - pub ar: Decimal, - #[sea_orm(column_type = "Decimal(Some((4, 2)))")] - pub cs: Decimal, - #[sea_orm(column_type = "Decimal(Some((4, 2)))")] - pub od: Decimal, - #[sea_orm(column_type = "Decimal(Some((4, 2)))")] - pub hp: Decimal, - #[sea_orm(column_name = "selectedMods")] - pub selected_mods: String, - #[sea_orm(column_name = "modPosition")] - pub mod_position: i32, - #[sea_orm(column_type = "Text", nullable)] - pub comment: Option, - #[sea_orm(column_name = "selectedBy")] - pub selected_by: String, - #[sea_orm(column_name = "selectedAt")] - pub selected_at: DateTime, - pub season: String, - pub category: String, - #[sea_orm(column_type = "Text")] - pub url: String, - #[sea_orm(column_name = "coverUrl", column_type = "Text", nullable)] - pub cover_url: Option, - pub approved: Option, - pub padding: Option, - #[sea_orm(column_name = "createdAt")] - pub created_at: Option, - #[sea_orm(column_name = "updatedAt")] - pub updated_at: Option, -} - -#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] -pub enum Relation {} - -impl ActiveModelBehavior for ActiveModel {} diff --git a/src/entity/map_comments.rs b/src/entity/map_comments.rs new file mode 100644 index 0000000..e0a1e20 --- /dev/null +++ b/src/entity/map_comments.rs @@ -0,0 +1,37 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0 + +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "map_comments")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub map_selection_id: i32, + pub osu_id: String, + pub username: String, + #[sea_orm(column_type = "Text", nullable)] + pub comment: Option, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::map_selections::Entity", + from = "Column::MapSelectionId", + to = "super::map_selections::Column::Id", + on_update = "NoAction", + on_delete = "NoAction" + )] + MapSelections, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::MapSelections.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/entity/map_selections.rs b/src/entity/map_selections.rs new file mode 100644 index 0000000..536d8c2 --- /dev/null +++ b/src/entity/map_selections.rs @@ -0,0 +1,62 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0 + +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "map_selections")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub beatmap_id: i32, + pub beatmapset_id: i32, + pub title: String, + pub title_unicode: Option, + pub artist: String, + pub artist_unicode: Option, + pub version: String, + pub creator: String, + #[sea_orm(column_type = "Decimal(Some((10, 0)))")] + pub star_rating: Decimal, + #[sea_orm(column_type = "Decimal(Some((10, 0)))")] + pub bpm: Decimal, + pub total_length: i32, + pub max_combo: i32, + #[sea_orm(column_type = "Decimal(Some((10, 0)))")] + pub ar: Decimal, + #[sea_orm(column_type = "Decimal(Some((10, 0)))")] + pub cs: Decimal, + #[sea_orm(column_type = "Decimal(Some((10, 0)))")] + pub od: Decimal, + #[sea_orm(column_type = "Decimal(Some((10, 0)))")] + pub hp: Decimal, + pub selected_mods: String, + pub mod_position: i32, + #[sea_orm(column_type = "Text", nullable)] + pub comment: Option, + pub selected_by: String, + pub selected_at: DateTimeUtc, + pub season: String, + pub category: String, + #[sea_orm(column_type = "Text")] + pub url: String, + #[sea_orm(column_type = "Text", nullable)] + pub cover_url: Option, + pub approved: Option, + pub padding: Option, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm(has_many = "super::map_comments::Entity")] + MapComments, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::MapComments.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/entities/match_rooms.rs b/src/entity/match_rooms.rs similarity index 79% rename from src/entities/match_rooms.rs rename to src/entity/match_rooms.rs index 78a5d5c..e7d8197 100644 --- a/src/entities/match_rooms.rs +++ b/src/entity/match_rooms.rs @@ -1,9 +1,9 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19 +//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0 use super::sea_orm_active_enums::Status; use sea_orm::entity::prelude::*; -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] #[sea_orm(table_name = "match_rooms")] pub struct Model { #[sea_orm(primary_key)] @@ -16,8 +16,8 @@ pub struct Model { pub max_participants: Option, pub status: Option, pub created_by: String, - pub created_at: Option, - pub updated_at: Option, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/src/entities/match_schedules.rs b/src/entity/match_schedules.rs similarity index 77% rename from src/entities/match_schedules.rs rename to src/entity/match_schedules.rs index 8ce7ff0..866cf51 100644 --- a/src/entities/match_schedules.rs +++ b/src/entity/match_schedules.rs @@ -1,18 +1,16 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19 +//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0 use super::sea_orm_active_enums::Status; use sea_orm::entity::prelude::*; -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] #[sea_orm(table_name = "match_schedules")] pub struct Model { #[sea_orm(primary_key)] pub id: i32, pub room_id: i32, - #[sea_orm(column_name = "player1_osuId")] pub player1_osu_id: String, pub player1_username: String, - #[sea_orm(column_name = "player2_osuId")] pub player2_osu_id: String, pub player2_username: String, pub red_score: Option, @@ -23,8 +21,8 @@ pub struct Model { #[sea_orm(column_type = "Text", nullable)] pub match_link: Option, pub created_by: String, - pub created_at: Option, - pub updated_at: Option, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] @@ -34,7 +32,7 @@ pub enum Relation { from = "Column::RoomId", to = "super::match_rooms::Column::Id", on_update = "NoAction", - on_delete = "Cascade" + on_delete = "NoAction" )] MatchRooms, } diff --git a/src/entities/messages.rs b/src/entity/messages.rs similarity index 67% rename from src/entities/messages.rs rename to src/entity/messages.rs index 4dff759..f68403d 100644 --- a/src/entities/messages.rs +++ b/src/entity/messages.rs @@ -1,18 +1,16 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19 +//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0 use super::sea_orm_active_enums::Status; use super::sea_orm_active_enums::Type; use sea_orm::entity::prelude::*; -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] #[sea_orm(table_name = "messages")] pub struct Model { #[sea_orm(primary_key)] pub id: i32, - #[sea_orm(column_name = "sender_osuId")] pub sender_osu_id: String, pub sender_username: String, - #[sea_orm(column_name = "receiver_osuId")] pub receiver_osu_id: String, pub receiver_username: String, pub r#type: Option, @@ -22,9 +20,9 @@ pub struct Model { pub related_matchup_id: Option, pub status: Option, pub response_action: Option, - pub response_time: Option, - pub created_at: Option, - pub updated_at: Option, + pub response_time: Option, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/src/entities/mod.rs b/src/entity/mod.rs similarity index 79% rename from src/entities/mod.rs rename to src/entity/mod.rs index 5127346..a61f5c6 100644 --- a/src/entities/mod.rs +++ b/src/entity/mod.rs @@ -1,4 +1,4 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19 +//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0 pub mod prelude; diff --git a/src/entities/player_matchups.rs b/src/entity/player_matchups.rs similarity index 63% rename from src/entities/player_matchups.rs rename to src/entity/player_matchups.rs index f2f9b4f..a8f6110 100644 --- a/src/entities/player_matchups.rs +++ b/src/entity/player_matchups.rs @@ -1,23 +1,21 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19 +//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0 use super::sea_orm_active_enums::Status; use sea_orm::entity::prelude::*; -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] #[sea_orm(table_name = "player_matchups")] pub struct Model { #[sea_orm(primary_key)] pub id: i32, - #[sea_orm(column_name = "player1_osuId")] pub player1_osu_id: String, pub player1_username: String, - #[sea_orm(column_name = "player2_osuId")] pub player2_osu_id: String, pub player2_username: String, pub status: Option, pub created_by: String, - pub created_at: Option, - pub updated_at: Option, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/src/entities/prelude.rs b/src/entity/prelude.rs similarity index 87% rename from src/entities/prelude.rs rename to src/entity/prelude.rs index 9c51535..776553a 100644 --- a/src/entities/prelude.rs +++ b/src/entity/prelude.rs @@ -1,4 +1,4 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19 +//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0 pub use super::map_comments::Entity as MapComments; pub use super::map_selections::Entity as MapSelections; diff --git a/src/entities/sea_orm_active_enums.rs b/src/entity/sea_orm_active_enums.rs similarity index 93% rename from src/entities/sea_orm_active_enums.rs rename to src/entity/sea_orm_active_enums.rs index a07a146..b64bca3 100644 --- a/src/entities/sea_orm_active_enums.rs +++ b/src/entity/sea_orm_active_enums.rs @@ -1,4 +1,4 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19 +//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0 use sea_orm::entity::prelude::*; @@ -23,7 +23,7 @@ pub enum Type { System, } #[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum)] -#[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "userGroup")] +#[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "user_group")] pub enum UserGroup { #[sea_orm(string_value = "player")] Player, diff --git a/src/entities/tournament_settings.rs b/src/entity/tournament_settings.rs similarity index 78% rename from src/entities/tournament_settings.rs rename to src/entity/tournament_settings.rs index 512ddbf..2a4242f 100644 --- a/src/entities/tournament_settings.rs +++ b/src/entity/tournament_settings.rs @@ -1,4 +1,4 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19 +//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0 use sea_orm::entity::prelude::*; @@ -14,9 +14,9 @@ pub struct Model { pub min_pp_for_registration: Option, pub current_season: Option, pub current_season_stage: Option, - pub mappool_visible: Option, - pub created_at: Option, - pub updated_at: Option, + pub mappool_visible: Option, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/src/entities/user.rs b/src/entity/user.rs similarity index 59% rename from src/entities/user.rs rename to src/entity/user.rs index 5677fc4..e851e9e 100644 --- a/src/entities/user.rs +++ b/src/entity/user.rs @@ -1,4 +1,4 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19 +//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0 use super::sea_orm_active_enums::UserGroup; use sea_orm::entity::prelude::*; @@ -8,26 +8,24 @@ use sea_orm::entity::prelude::*; pub struct Model { #[sea_orm(primary_key)] pub id: i32, - #[sea_orm(column_name = "osuId", unique)] + #[sea_orm(unique)] pub osu_id: String, + #[sea_orm(unique)] pub username: String, - #[sea_orm(column_name = "registeredAt")] - pub registered_at: DateTime, - #[sea_orm(column_type = "Text", nullable)] + pub registered_at: DateTimeUtc, pub avatar_url: Option, #[sea_orm(column_type = "Float", nullable)] pub pp: Option, pub global_rank: Option, pub country: Option, pub country_rank: Option, - pub approved: Option, - #[sea_orm(column_name = "userGroup")] + pub approved: Option, pub user_group: UserGroup, pub season: Option, - #[sea_orm(column_name = "createdAt")] - pub created_at: Option, - #[sea_orm(column_name = "updatedAt")] - pub updated_at: Option, + pub gu_server_user_id: Option, + pub gu_server_username: Option, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/src/error/mod.rs b/src/error/mod.rs new file mode 100644 index 0000000..d96346e --- /dev/null +++ b/src/error/mod.rs @@ -0,0 +1,105 @@ +use axum::{ + Json, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use sea_orm::DbErr; +use serde_json::json; +use thiserror::Error; + +/// 应用程序错误枚举 +#[derive(Error, Debug)] +pub enum AppError { + #[error("数据库错误: {0}")] + DatabaseError(#[from] DbErr), + + #[error("未找到资源: {0}")] + NotFound(String), + + #[error("认证失败: {0}")] + Unauthorized(String), + + #[error("权限不足: {0}")] + Forbidden(String), + + #[error("验证失败: {0}")] + ValidationError(String), + + #[error("业务错误: {0}")] + BusinessError(String), + + #[error("配置错误: {0}")] + ConfigError(String), + + #[error("外部API错误: {0}")] + ExternalApiError(String), + + #[error("内部服务器错误: {0}")] + InternalError(String), +} + +impl AppError { + pub fn status_code(&self) -> StatusCode { + match self { + AppError::NotFound(_) => StatusCode::NOT_FOUND, + AppError::Unauthorized(_) => StatusCode::UNAUTHORIZED, + AppError::Forbidden(_) => StatusCode::FORBIDDEN, + AppError::ValidationError(_) => StatusCode::BAD_REQUEST, + AppError::BusinessError(_) => StatusCode::BAD_REQUEST, + AppError::ConfigError(_) => StatusCode::INTERNAL_SERVER_ERROR, + AppError::ExternalApiError(_) => StatusCode::BAD_GATEWAY, + AppError::DatabaseError(_) | AppError::InternalError(_) => { + StatusCode::INTERNAL_SERVER_ERROR + } + } + } + + pub fn error_message(&self) -> String { + match self { + AppError::DatabaseError(err) => format!("数据库错误: {}", err), + AppError::NotFound(msg) => format!("未找到资源: {}", msg), + AppError::Unauthorized(msg) => format!("认证失败: {}", msg), + AppError::Forbidden(msg) => format!("权限不足: {}", msg), + AppError::ValidationError(msg) => format!("验证失败: {}", msg), + AppError::BusinessError(msg) => format!("业务错误: {}", msg), + AppError::ConfigError(msg) => format!("配置错误: {}", msg), + AppError::ExternalApiError(msg) => format!("外部API错误: {}", msg), + AppError::InternalError(msg) => format!("内部服务器错误: {}", msg), + } + } +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let status = self.status_code(); + let error_response = json!({ + "success": false, + "error": self.error_message(), + "status_code": status.as_u16(), + }); + + (status, Json(error_response)).into_response() + } +} + +pub type Result = std::result::Result; + +pub fn not_found(msg: &str) -> AppError { + AppError::NotFound(msg.to_string()) +} + +pub fn unauthorized(msg: &str) -> AppError { + AppError::Unauthorized(msg.to_string()) +} + +pub fn forbidden(msg: &str) -> AppError { + AppError::Forbidden(msg.to_string()) +} + +pub fn validation_error(msg: &str) -> AppError { + AppError::ValidationError(msg.to_string()) +} + +pub fn business_error(msg: &str) -> AppError { + AppError::BusinessError(msg.to_string()) +} diff --git a/src/main.rs b/src/main.rs index feeb2d5..b50ef9d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,12 +1,90 @@ -use axum::{Router, routing::get}; +use std::sync::Arc; + +use axum::Router; +use config::Config; +use sea_orm::Database; +use service::ServiceState; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +mod api; +mod config; +mod dto; +mod entity; +mod error; +mod middleware; +mod service; +mod utils; #[tokio::main] -async fn main() { - // build our application with a single route - let app = Router::new().route("/", get(|| async { "Hello, World!" })); +async fn main() -> Result<(), Box> { + // 初始化日志 + tracing_subscriber::registry() + .with(tracing_subscriber::EnvFilter::new( + std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into()), + )) + .with(tracing_subscriber::fmt::layer()) + .init(); - // run our app with hyper, listening globally on port 3000 - let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); - println!("Server listening on http://localhost:3000"); - axum::serve(listener, app).await.unwrap(); + // 加载配置 + let config = Config::from_env()?; + + print_banner(); + println!("启动 AstraCup 后端服务..."); + println!("配置加载成功"); + println!("监听地址: {}:{}", config.host, config.port); + println!("数据库: {}", mask_database_url(&config.database_url)); + + println!("连接数据库..."); + let db = Database::connect(&config.database_url).await?; + println!("数据库连接成功"); + + // 创建服务状态 + let service_state = Arc::new(ServiceState::new(db)); + + // 配置应用 + let app = api::configure_routes() + .layer(middleware::configure_middleware()) + .with_state(service_state); + + // 启动服务器 + let listener = tokio::net::TcpListener::bind(format!("{}:{}", config.host, config.port)) + .await + .unwrap(); + + println!("服务器启动成功"); + println!("API文档: http://{}:{}/api/docs", config.host, config.port); + println!("健康检查: http://{}:{}/health", config.host, config.port); + + axum::serve(listener, app).await?; + + Ok(()) +} + +fn print_banner() { + println!(r" _ _ ____ "); + println!(r" / \ ___| |_ __ _ _ __ / ____ _ _ __ "); + println!(r" / _ \ / __| __/ _` | '__| | | | | | '_ \ "); + println!(r" / ___ \\__ \ || (_| | | | |__| |_| | |_) |"); + println!(r" /_/ \_\___/\__\__,_|_| \____\__,_| .__/ "); + println!(r" |_| "); + println!(r"=============================================="); +} + +fn mask_database_url(url: &str) -> String { + if let Some(pos) = url.find('@') { + let mut masked = url.to_string(); + if let Some(start) = url.find("://") { + let end = pos; + if end > start + 3 { + let credentials = &url[start + 3..end]; + if let Some(colon_pos) = credentials.find(':') { + let username = &credentials[..colon_pos]; + masked.replace_range(start + 3..end, &format!("{}:******", username)); + } + } + } + masked + } else { + url.to_string() + } } diff --git a/src/middleware/auth.rs b/src/middleware/auth.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/middleware/mod.rs b/src/middleware/mod.rs new file mode 100644 index 0000000..080065f --- /dev/null +++ b/src/middleware/mod.rs @@ -0,0 +1,8 @@ +pub mod auth; + +use tower::ServiceBuilder; +use tower_http::trace::TraceLayer; + +pub fn configure_middleware() -> impl tower::Layer + Clone { + ServiceBuilder::new().layer(TraceLayer::new_for_http()) +} diff --git a/src/service/auth.rs b/src/service/auth.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/service/map_comments.rs b/src/service/map_comments.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/service/map_selections.rs b/src/service/map_selections.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/service/match_rooms.rs b/src/service/match_rooms.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/service/match_schedules.rs b/src/service/match_schedules.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/service/match_schedulesrs b/src/service/match_schedulesrs new file mode 100644 index 0000000..e69de29 diff --git a/src/service/messages.rs b/src/service/messages.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/service/mod.rs b/src/service/mod.rs new file mode 100644 index 0000000..384f5be --- /dev/null +++ b/src/service/mod.rs @@ -0,0 +1,24 @@ +pub mod auth; +pub mod map_comments; +pub mod map_selections; +pub mod match_rooms; +pub mod match_schedules; +pub mod messages; +pub mod player_matchups; +pub mod tournament_settings; +pub mod user; + +use sea_orm::DatabaseConnection; + +/// 服务层共享状态 +#[derive(Clone)] +pub struct ServiceState { + pub db: DatabaseConnection, +} + +impl ServiceState { + /// 创建新的服务状态 + pub fn new(db: DatabaseConnection) -> Self { + Self { db } + } +} diff --git a/src/service/player_matchups.rs b/src/service/player_matchups.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/service/tournament_settings.rs b/src/service/tournament_settings.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/service/user.rs b/src/service/user.rs new file mode 100644 index 0000000..d25648c --- /dev/null +++ b/src/service/user.rs @@ -0,0 +1,166 @@ +use sea_orm::{ + ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter, + QueryOrder, Set, +}; + +use crate::{ + dto::user::{CreateUserRequest, UpdateUserGroupRequest, UpdateUserRequest, UserQueryParams}, + entity::{sea_orm_active_enums::UserGroup, user}, + error::{AppError, Result}, +}; + +pub struct UserService { + db: DatabaseConnection, +} + +impl UserService { + pub fn new(db: DatabaseConnection) -> Self { + Self { db } + } + + pub async fn find_all(&self, params: UserQueryParams) -> Result<(Vec, u64)> { + let mut query = user::Entity::find(); + + if let Some(username) = params.username { + query = query.filter(user::Column::Username.contains(username)); + } + + if let Some(country) = params.country { + query = query.filter(user::Column::Country.eq(country)); + } + + if let Some(user_group) = params.user_group { + let group_enum = match user_group.as_str() { + "player" => UserGroup::Player, + "admin" => UserGroup::Admin, + _ => return Err(AppError::ValidationError("无效的用户组".into())), + }; + query = query.filter(user::Column::UserGroup.eq(group_enum)); + } + + query = query.order_by_asc(user::Column::Id); + + let page = params.page.unwrap_or(1); + let page_size = params.page_size.unwrap_or(20); + + let total = query.clone().count(&self.db).await?; + let users = query + .paginate(&self.db, page_size) + .fetch_page(page - 1) + .await?; + + Ok((users, total)) + } + + pub async fn find_by_id(&self, id: i32) -> Result { + user::Entity::find_by_id(id) + .one(&self.db) + .await? + .ok_or_else(|| AppError::NotFound(format!("用户 {} 不存在", id))) + } + + pub async fn find_by_osu_id(&self, osu_id: &str) -> Result> { + Ok(user::Entity::find() + .filter(user::Column::OsuId.eq(osu_id)) + .one(&self.db) + .await?) + } + + pub async fn create(&self, osu_id: String, data: CreateUserRequest) -> Result { + let now = chrono::Utc::now(); + + let user = user::ActiveModel { + osu_id: Set(osu_id), + username: Set(data.username), + registered_at: Set(now), + avatar_url: Set(data.avatar_url), + pp: Set(data.pp), + global_rank: Set(data.global_rank), + country: Set(data.country), + country_rank: Set(data.country_rank), + approved: Set(None), + user_group: Set(UserGroup::Player), + season: Set(None), + gu_server_user_id: Set(None), + gu_server_username: Set(None), + created_at: Set(now), + updated_at: Set(now), + ..Default::default() + }; + + Ok(user.insert(&self.db).await?) + } + + pub async fn update(&self, id: i32, data: UpdateUserRequest) -> Result { + let mut user: user::ActiveModel = self.find_by_id(id).await?.into(); + + if let Some(username) = data.username { + user.username = Set(username); + } + + if let Some(avatar_url) = data.avatar_url { + user.avatar_url = Set(Some(avatar_url)); + } + + if let Some(pp) = data.pp { + user.pp = Set(Some(pp)); + } + + if let Some(global_rank) = data.global_rank { + user.global_rank = Set(Some(global_rank)); + } + + if let Some(country) = data.country { + user.country = Set(Some(country)); + } + + if let Some(country_rank) = data.country_rank { + user.country_rank = Set(Some(country_rank)); + } + + if let Some(approved) = data.approved { + user.approved = Set(Some(approved)); + } + + if let Some(season) = data.season { + user.season = Set(Some(season)); + } + + user.updated_at = Set(chrono::Utc::now()); + + Ok(user.update(&self.db).await?) + } + + pub async fn update_user_group( + &self, + id: i32, + data: UpdateUserGroupRequest, + ) -> Result { + let group_enum = match data.user_group.as_str() { + "player" => UserGroup::Player, + "admin" => UserGroup::Admin, + _ => return Err(AppError::ValidationError("无效的用户组".into())), + }; + + let mut user: user::ActiveModel = self.find_by_id(id).await?.into(); + user.user_group = Set(group_enum); + user.updated_at = Set(chrono::Utc::now()); + + Ok(user.update(&self.db).await?) + } + + pub async fn delete(&self, id: i32) -> Result<()> { + let result = user::Entity::delete_by_id(id).exec(&self.db).await?; + if result.rows_affected == 0 { + return Err(AppError::NotFound(format!("用户 {} 不存在", id))); + } + Ok(()) + } + + pub async fn count_admins(&self) -> Result { + Ok(user::Entity::find() + .filter(user::Column::UserGroup.eq(UserGroup::Admin)) + .count(&self.db) + .await?) + } +} diff --git a/src/utils/jwt.rs b/src/utils/jwt.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/utils/mod.rs b/src/utils/mod.rs new file mode 100644 index 0000000..f19d30b --- /dev/null +++ b/src/utils/mod.rs @@ -0,0 +1,3 @@ +pub mod jwt; +pub mod password; +pub mod validation; diff --git a/src/utils/password.rs b/src/utils/password.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/utils/validation.rs b/src/utils/validation.rs new file mode 100644 index 0000000..e69de29