This commit is contained in:
2026-02-12 17:43:10 +08:00
parent 879cba6a9f
commit ba756db9a8
57 changed files with 1475 additions and 141 deletions

187
Cargo.lock generated
View File

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

View File

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

212
IMPLEMENTATION_PLAN.md Normal file
View File

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

0
README.md Normal file
View File

25
src/api/auth.rs Normal file
View File

@@ -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 {
"登出"
}

0
src/api/map_comments.rs Normal file
View File

View File

0
src/api/match_rooms.rs Normal file
View File

View File

0
src/api/messages.rs Normal file
View File

45
src/api/mod.rs Normal file
View File

@@ -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<ServiceState> {
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::user::UserResponse>,
crate::dto::PaginatedResponse<crate::dto::user::UserResponse>,
user::PaginatedUserResponse,
)
),
tags(
(name = "auth", description = "认证相关API"),
(name = "users", description = "用户管理API"),
)
)]
pub struct ApiDoc;

View File

View File

183
src/api/user.rs Normal file
View File

@@ -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<ServiceState> {
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<ServiceState>,
Query(params): Query<UserQueryParams>,
) -> Result<Json<ApiResponse<PaginatedResponse<UserResponse>>>> {
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<ServiceState>,
Path(id): Path<i32>,
) -> Result<Json<ApiResponse<UserResponse>>> {
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<Json<ApiResponse<UserResponse>>> {
// 用户创建将通过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<ServiceState>,
Path(id): Path<i32>,
Json(data): Json<UpdateUserRequest>,
) -> Result<Json<ApiResponse<UserResponse>>> {
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<ServiceState>,
Path(id): Path<i32>,
Json(data): Json<UpdateUserGroupRequest>,
) -> Result<Json<ApiResponse<UserResponse>>> {
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<ServiceState>,
Path(id): Path<i32>,
) -> Result<Json<ApiResponse<()>>> {
let service = UserService::new(state.db);
service.delete(id).await?;
Ok(Json(ApiResponse::success_with_message((), "用户删除成功")))
}
#[derive(ToSchema)]
pub struct PaginatedUserResponse {
items: Vec<UserResponse>,
total: u64,
page: u64,
page_size: u64,
total_pages: u64,
}

80
src/config/mod.rs Normal file
View File

@@ -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<Self, crate::error::AppError> {
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)
}
}

0
src/dto/auth.rs Normal file
View File

0
src/dto/map_comments.rs Normal file
View File

View File

0
src/dto/match_rooms.rs Normal file
View File

View File

0
src/dto/messages.rs Normal file
View File

95
src/dto/mod.rs Normal file
View File

@@ -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<T: utoipa::ToSchema> {
pub success: bool,
pub data: Option<T>,
pub message: Option<String>,
pub error: Option<String>,
}
impl<T: utoipa::ToSchema> ApiResponse<T> {
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<T: utoipa::ToSchema> {
pub items: Vec<T>,
pub total: u64,
pub page: u64,
pub page_size: u64,
pub total_pages: u64,
}
impl<T: utoipa::ToSchema> PaginatedResponse<T> {
pub fn new(items: Vec<T>, 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,
}
}
}

View File

View File

119
src/dto/user.rs Normal file
View File

@@ -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<String>,
pub pp: Option<f32>,
pub global_rank: Option<i32>,
pub country: Option<String>,
pub country_rank: Option<i32>,
}
#[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct UpdateUserRequest {
#[validate(length(min = 3, max = 50))]
pub username: Option<String>,
pub avatar_url: Option<String>,
pub pp: Option<f32>,
pub global_rank: Option<i32>,
pub country: Option<String>,
pub country_rank: Option<i32>,
pub approved: Option<i32>,
pub season: Option<i32>,
}
#[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<String>,
pub pp: Option<f32>,
pub global_rank: Option<i32>,
pub country: Option<String>,
pub country_rank: Option<i32>,
pub approved: Option<i32>,
pub user_group: String,
pub season: Option<i32>,
pub gu_server_user_id: Option<i32>,
pub gu_server_username: Option<String>,
pub created_at: String,
pub updated_at: String,
}
impl From<UserModel> 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<String>,
pub country: Option<String>,
pub user_group: Option<String>,
pub page: Option<u64>,
pub page_size: Option<u64>,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct OsuUserData {
pub id: String,
pub username: String,
pub avatar_url: Option<String>,
pub statistics: Option<OsuStatistics>,
pub country: Option<OsuCountry>,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct OsuStatistics {
pub pp: Option<f32>,
pub global_rank: Option<i32>,
pub country_rank: Option<i32>,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct OsuCountry {
pub code: String,
}

View File

@@ -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<String>,
#[sea_orm(column_name = "createdAt")]
pub created_at: Option<DateTimeUtc>,
#[sea_orm(column_name = "updatedAt")]
pub updated_at: Option<DateTimeUtc>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -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<String>,
pub artist: String,
pub artist_unicode: Option<String>,
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<String>,
#[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<String>,
pub approved: Option<i8>,
pub padding: Option<i8>,
#[sea_orm(column_name = "createdAt")]
pub created_at: Option<DateTimeUtc>,
#[sea_orm(column_name = "updatedAt")]
pub updated_at: Option<DateTimeUtc>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -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<String>,
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<super::map_selections::Entity> for Entity {
fn to() -> RelationDef {
Relation::MapSelections.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -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<String>,
pub artist: String,
pub artist_unicode: Option<String>,
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<String>,
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<String>,
pub approved: Option<i32>,
pub padding: Option<i32>,
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<super::map_comments::Entity> for Entity {
fn to() -> RelationDef {
Relation::MapComments.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -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<i32>,
pub status: Option<Status>,
pub created_by: String,
pub created_at: Option<DateTimeUtc>,
pub updated_at: Option<DateTimeUtc>,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

View File

@@ -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<i32>,
@@ -23,8 +21,8 @@ pub struct Model {
#[sea_orm(column_type = "Text", nullable)]
pub match_link: Option<String>,
pub created_by: String,
pub created_at: Option<DateTimeUtc>,
pub updated_at: Option<DateTimeUtc>,
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,
}

View File

@@ -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<Type>,
@@ -22,9 +20,9 @@ pub struct Model {
pub related_matchup_id: Option<i32>,
pub status: Option<Status>,
pub response_action: Option<String>,
pub response_time: Option<DateTime>,
pub created_at: Option<DateTimeUtc>,
pub updated_at: Option<DateTimeUtc>,
pub response_time: Option<DateTimeUtc>,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

View File

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

View File

@@ -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<Status>,
pub created_by: String,
pub created_at: Option<DateTimeUtc>,
pub updated_at: Option<DateTimeUtc>,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

View File

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

View File

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

View File

@@ -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<f32>,
pub current_season: Option<String>,
pub current_season_stage: Option<String>,
pub mappool_visible: Option<i8>,
pub created_at: Option<DateTimeUtc>,
pub updated_at: Option<DateTimeUtc>,
pub mappool_visible: Option<i32>,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

View File

@@ -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<String>,
#[sea_orm(column_type = "Float", nullable)]
pub pp: Option<f32>,
pub global_rank: Option<i32>,
pub country: Option<String>,
pub country_rank: Option<i32>,
pub approved: Option<i8>,
#[sea_orm(column_name = "userGroup")]
pub approved: Option<i32>,
pub user_group: UserGroup,
pub season: Option<i32>,
#[sea_orm(column_name = "createdAt")]
pub created_at: Option<DateTimeUtc>,
#[sea_orm(column_name = "updatedAt")]
pub updated_at: Option<DateTimeUtc>,
pub gu_server_user_id: Option<i32>,
pub gu_server_username: Option<String>,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

105
src/error/mod.rs Normal file
View File

@@ -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<T> = std::result::Result<T, AppError>;
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())
}

View File

@@ -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<dyn std::error::Error>> {
// 初始化日志
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()
}
}

0
src/middleware/auth.rs Normal file
View File

8
src/middleware/mod.rs Normal file
View File

@@ -0,0 +1,8 @@
pub mod auth;
use tower::ServiceBuilder;
use tower_http::trace::TraceLayer;
pub fn configure_middleware() -> impl tower::Layer<axum::Router> + Clone {
ServiceBuilder::new().layer(TraceLayer::new_for_http())
}

0
src/service/auth.rs Normal file
View File

View File

View File

View File

View File

View File

0
src/service/messages.rs Normal file
View File

24
src/service/mod.rs Normal file
View File

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

View File

View File

166
src/service/user.rs Normal file
View File

@@ -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<user::Model>, 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::Model> {
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<Option<user::Model>> {
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<user::Model> {
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<user::Model> {
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<user::Model> {
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<u64> {
Ok(user::Entity::find()
.filter(user::Column::UserGroup.eq(UserGroup::Admin))
.count(&self.db)
.await?)
}
}

0
src/utils/jwt.rs Normal file
View File

3
src/utils/mod.rs Normal file
View File

@@ -0,0 +1,3 @@
pub mod jwt;
pub mod password;
pub mod validation;

0
src/utils/password.rs Normal file
View File

0
src/utils/validation.rs Normal file
View File