This commit is contained in:
2026-02-12 23:29:20 +08:00
parent ba756db9a8
commit 2140e99fb8
28 changed files with 1735 additions and 43 deletions

4
.env
View File

@@ -1,5 +1,5 @@
# Database Configuration
DATABASE_URL=mysql://root:114514@localhost:3306/astarcupLocal
DATABASE_URL=mysql://root:114514@localhost:3306/astarcupDev
# OAuth Configuration
OSU_CLIENT_ID =
@@ -12,7 +12,7 @@ JWT_EXPIRY_HOURS=24
# Server Configuration
PORT=3001
HOST=0.0.0.0
HOST=localhost
LOG_LEVEL=info
# CORS Configuration

68
Cargo.lock generated
View File

@@ -105,6 +105,7 @@ dependencies = [
"bcrypt",
"chrono",
"config",
"decimal",
"dotenv",
"futures",
"jsonwebtoken",
@@ -121,6 +122,7 @@ dependencies = [
"tower-http",
"tracing",
"tracing-subscriber",
"urlencoding",
"utoipa",
"utoipa-swagger-ui",
"uuid",
@@ -328,6 +330,12 @@ dependencies = [
"serde",
]
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.10.0"
@@ -722,6 +730,20 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "decimal"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a8ab77e91baeb15034c3be91e87bff4665c9036216148e4996d9a9f5792114d"
dependencies = [
"bitflags 1.3.2",
"cc",
"libc",
"ord_subset",
"rustc-serialize",
"serde",
]
[[package]]
name = "der"
version = "0.7.10"
@@ -1669,7 +1691,7 @@ version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616"
dependencies = [
"bitflags",
"bitflags 2.10.0",
"libc",
"redox_syscall 0.7.0",
]
@@ -1911,7 +1933,7 @@ version = "0.10.75"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
dependencies = [
"bitflags",
"bitflags 2.10.0",
"cfg-if",
"foreign-types",
"libc",
@@ -1955,6 +1977,12 @@ dependencies = [
"vcpkg",
]
[[package]]
name = "ord_subset"
version = "3.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdcf5505c0f054ce51fa0fa74142738930a45d5ac1faacae4dd4e2f54afe00fa"
[[package]]
name = "ordered-float"
version = "4.6.0"
@@ -2452,7 +2480,7 @@ version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags",
"bitflags 2.10.0",
]
[[package]]
@@ -2461,7 +2489,7 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27"
dependencies = [
"bitflags",
"bitflags 2.10.0",
]
[[package]]
@@ -2591,7 +2619,7 @@ version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd490c5b18261893f14449cbd28cb9c0b637aebf161cd77900bfdedaff21ec32"
dependencies = [
"bitflags",
"bitflags 2.10.0",
"once_cell",
"serde",
"serde_derive",
@@ -2685,6 +2713,12 @@ version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
[[package]]
name = "rustc-serialize"
version = "0.3.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe834bc780604f4674073badbad26d7219cadfb4a2275802db12cbae17498401"
[[package]]
name = "rustc_version"
version = "0.4.1"
@@ -2700,7 +2734,7 @@ version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
dependencies = [
"bitflags",
"bitflags 2.10.0",
"errno",
"libc",
"linux-raw-sys",
@@ -2919,7 +2953,7 @@ version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
dependencies = [
"bitflags",
"bitflags 2.10.0",
"core-foundation 0.9.4",
"core-foundation-sys",
"libc",
@@ -2932,7 +2966,7 @@ version = "3.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef"
dependencies = [
"bitflags",
"bitflags 2.10.0",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
@@ -3267,7 +3301,7 @@ dependencies = [
"atoi",
"base64",
"bigdecimal",
"bitflags",
"bitflags 2.10.0",
"byteorder",
"bytes",
"chrono",
@@ -3314,7 +3348,7 @@ dependencies = [
"atoi",
"base64",
"bigdecimal",
"bitflags",
"bitflags 2.10.0",
"byteorder",
"chrono",
"crc",
@@ -3464,7 +3498,7 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags",
"bitflags 2.10.0",
"core-foundation 0.9.4",
"system-configuration-sys",
]
@@ -3745,7 +3779,7 @@ version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
dependencies = [
"bitflags",
"bitflags 2.10.0",
"bytes",
"futures-util",
"http",
@@ -3919,6 +3953,12 @@ dependencies = [
"serde",
]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -4163,7 +4203,7 @@ version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags",
"bitflags 2.10.0",
"hashbrown 0.15.5",
"indexmap",
"semver",
@@ -4642,7 +4682,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags",
"bitflags 2.10.0",
"indexmap",
"log",
"serde",

View File

@@ -51,6 +51,8 @@ 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"] }
decimal = "2.1.0"
urlencoding = "2.1.3"
[dev-dependencies]
# Testing

View File

@@ -1,6 +1,25 @@
use axum::{Router, routing::get};
use axum::{
Json, Router,
extract::{Query, State},
http::StatusCode,
response::Redirect,
routing::get,
};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::env;
use uuid::Uuid;
pub fn routes() -> Router {
use crate::{
dto::{
ApiResponse,
user::{CreateUserRequest, OsuUserData},
},
error::Result,
service::{ServiceState, user::UserService},
};
pub fn routes() -> Router<ServiceState> {
Router::new()
.route("/osu", get(osu_auth))
.route("/callback/osu", get(osu_callback))
@@ -8,18 +27,198 @@ pub fn routes() -> Router {
.route("/logout", get(logout))
}
async fn osu_auth() -> &'static str {
"osu! OAuth 认证"
#[derive(Debug, Deserialize)]
struct OsuCallbackQuery {
code: String,
state: String,
}
async fn osu_callback() -> &'static str {
"osu! OAuth 回调"
#[derive(Debug, Deserialize)]
struct OsuTokenResponse {
access_token: String,
token_type: String,
expires_in: u32,
refresh_token: String,
scope: String,
}
async fn refresh_token() -> &'static str {
"刷新Token"
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct AuthResponse {
access_token: String,
refresh_token: String,
user: crate::dto::user::UserResponse,
}
async fn logout() -> &'static str {
"登出"
#[utoipa::path(
get,
path = "/api/auth/osu",
responses(
(status = 302, description = "重定向到OSU授权页面"),
),
tag = "auth"
)]
async fn osu_auth() -> Redirect {
let client_id = env::var("OSU_CLIENT_ID").expect("OSU_CLIENT_ID not set");
let redirect_uri = env::var("OSU_REDIRECT_URI").expect("OSU_REDIRECT_URI not set");
let state = Uuid::new_v4().to_string();
let auth_url = format!(
"https://osu.ppy.sh/oauth/authorize?client_id={}&redirect_uri={}&response_type=code&scope=identify%20public&state={}",
client_id,
urlencoding::encode(&redirect_uri),
state
);
Redirect::to(&auth_url)
}
#[utoipa::path(
get,
path = "/api/auth/callback/osu",
params(
("code" = String, Query, description = "OSU OAuth回调代码"),
("state" = String, Query, description = "状态参数"),
),
responses(
(status = 200, description = "OAuth回调成功"),
(status = 400, description = "请求参数错误"),
(status = 500, description = "内部服务器错误"),
),
tag = "auth"
)]
async fn osu_callback(
Query(params): Query<OsuCallbackQuery>,
State(state): State<ServiceState>,
) -> Result<Json<ApiResponse<AuthResponse>>> {
let client_id = env::var("OSU_CLIENT_ID").expect("OSU_CLIENT_ID not set");
let client_secret = env::var("OSU_CLIENT_SECRET").expect("OSU_CLIENT_SECRET not set");
let redirect_uri = env::var("OSU_REDIRECT_URI").expect("OSU_REDIRECT_URI not set");
// 获取访问令牌
let client = Client::new();
let token_response = client
.post("https://osu.ppy.sh/oauth/token")
.json(&serde_json::json!({
"client_id": client_id,
"client_secret": client_secret,
"code": params.code,
"grant_type": "authorization_code",
"redirect_uri": redirect_uri
}))
.send()
.await
.map_err(|e| {
crate::error::AppError::ExternalApiError(format!("Failed to get token: {}", e))
})?
.json::<OsuTokenResponse>()
.await
.map_err(|e| {
crate::error::AppError::ExternalApiError(format!(
"Failed to parse token response: {}",
e
))
})?;
// 获取用户信息
let user_data = client
.get("https://osu.ppy.sh/api/v2/me")
.header(
"Authorization",
format!("Bearer {}", token_response.access_token),
)
.send()
.await
.map_err(|e| {
crate::error::AppError::ExternalApiError(format!("Failed to get user info: {}", e))
})?
.json::<OsuUserData>()
.await
.map_err(|e| {
crate::error::AppError::ExternalApiError(format!("Failed to parse user info: {}", e))
})?;
// 处理用户数据
let user_service = UserService::new(state.db);
let existing_user = user_service.find_by_osu_id(&user_data.id).await?;
let user = match existing_user {
Some(mut existing) => {
// 更新现有用户
let update_request = crate::dto::user::UpdateUserRequest {
username: Some(user_data.username),
avatar_url: user_data.avatar_url,
pp: user_data.statistics.as_ref().and_then(|s| s.pp),
global_rank: user_data.statistics.as_ref().and_then(|s| s.global_rank),
country: user_data.country.as_ref().map(|c| c.code.clone()),
country_rank: user_data.statistics.as_ref().and_then(|s| s.country_rank),
approved: None,
season: None,
};
user_service.update(existing.id, update_request).await?
}
None => {
// 创建新用户
let create_request = CreateUserRequest {
username: user_data.username,
avatar_url: user_data.avatar_url,
pp: user_data.statistics.as_ref().and_then(|s| s.pp),
global_rank: user_data.statistics.as_ref().and_then(|s| s.global_rank),
country: user_data.country.as_ref().map(|c| c.code.clone()),
country_rank: user_data.statistics.as_ref().and_then(|s| s.country_rank),
};
user_service.create(user_data.id, create_request).await?
}
};
// 生成JWT令牌
let osu_id = user.osu_id.clone();
let user_group_str = match user.user_group {
crate::entity::sea_orm_active_enums::UserGroup::Player => "player",
crate::entity::sea_orm_active_enums::UserGroup::Admin => "admin",
};
let jwt_token =
crate::utils::jwt::generate_token(osu_id, user_group_str.to_string()).map_err(|e| {
crate::error::AppError::InternalError(format!("Failed to generate token: {}", e))
})?;
let auth_response = AuthResponse {
access_token: jwt_token,
refresh_token: token_response.refresh_token,
user: crate::dto::user::UserResponse::from(user),
};
Ok(Json(ApiResponse::success(auth_response)))
}
#[utoipa::path(
get,
path = "/api/auth/refresh",
responses(
(status = 200, description = "令牌刷新成功"),
(status = 401, description = "未授权"),
(status = 500, description = "内部服务器错误"),
),
tag = "auth"
)]
async fn refresh_token() -> Result<Json<ApiResponse<AuthResponse>>> {
// 实现令牌刷新逻辑
// 这里需要从请求中获取refresh_token然后调用OSU API刷新令牌
// 暂时返回未实现
Err(crate::error::AppError::NotImplemented(
"Token refresh not implemented".into(),
))
}
#[utoipa::path(
get,
path = "/api/auth/logout",
responses(
(status = 200, description = "登出成功"),
),
tag = "auth"
)]
async fn logout() -> Result<Json<ApiResponse<()>>> {
// 实现登出逻辑
// 这里需要清除客户端的令牌
Ok(Json(ApiResponse::success_with_message((), "登出成功")))
}

View File

@@ -14,26 +14,36 @@ pub fn configure_routes() -> Router<ServiceState> {
.route("/health", get(|| async { "OK" }))
.nest("/api/auth", auth::routes())
.nest("/api/users", user::routes())
.with_state(())
}
#[derive(OpenApi)]
#[openapi(
paths(
auth::osu_auth,
auth::osu_callback,
auth::refresh_token,
auth::logout,
user::get_users,
user::get_user,
user::update_user,
user::update_user_group,
user::delete_user,
user::update_registration_status,
user::update_season,
user::update_approved,
),
components(
schemas(
crate::dto::user::UserResponse,
crate::dto::user::UpdateUserRequest,
crate::dto::user::UpdateUserGroupRequest,
crate::dto::user::UpdateRegistrationStatusRequest,
crate::dto::user::UpdateSeasonRequest,
crate::dto::user::UpdateApprovedRequest,
crate::dto::user::UserQueryParams,
crate::dto::ApiResponse<crate::dto::user::UserResponse>,
crate::dto::PaginatedResponse<crate::dto::user::UserResponse>,
crate::dto::ApiResponseSchema<crate::dto::user::UserResponse>,
crate::dto::ApiResponseSchema<crate::api::auth::AuthResponse>,
crate::dto::PaginatedResponseSchema<crate::dto::user::UserResponse>,
user::PaginatedUserResponse,
)
),

View File

@@ -9,7 +9,7 @@ use crate::{
dto::{
ApiResponse, PaginatedResponse,
user::{
CreateUserRequest, UpdateUserGroupRequest, UpdateUserRequest, UserQueryParams,
CreateUserRequest, UpdateApprovedRequest, UpdateRegistrationStatusRequest, UpdateSeasonRequest, UpdateUserGroupRequest, UpdateUserRequest, UserQueryParams,
UserResponse,
},
},
@@ -25,6 +25,9 @@ pub fn routes() -> Router<ServiceState> {
.route("/{id}", put(update_user))
.route("/{id}", delete(delete_user))
.route("/{id}/user-group", put(update_user_group))
.route("/{id}/registration-status", put(update_registration_status))
.route("/{id}/season", put(update_season))
.route("/{id}/approve", put(update_approved))
}
#[utoipa::path(
@@ -173,6 +176,88 @@ async fn delete_user(
Ok(Json(ApiResponse::success_with_message((), "用户删除成功")))
}
#[utoipa::path(
put,
path = "/api/users/{id}/registration-status",
params(
("id" = i32, Path, description = "用户ID")
),
request_body = UpdateRegistrationStatusRequest,
responses(
(status = 200, description = "更新注册状态成功", body = UserResponse),
(status = 400, description = "请求参数错误"),
(status = 401, description = "未授权"),
(status = 404, description = "用户不存在"),
),
tag = "users"
)]
async fn update_registration_status(
State(state): State<ServiceState>,
Path(id): Path<i32>,
Json(data): Json<UpdateRegistrationStatusRequest>,
) -> Result<Json<ApiResponse<UserResponse>>> {
let service = UserService::new(state.db);
let user = service.update_registration_status(id, data).await?;
Ok(Json(ApiResponse::success(UserResponse::from(user))))
}
#[utoipa::path(
put,
path = "/api/users/{id}/season",
params(
("id" = i32, Path, description = "用户ID")
),
request_body = UpdateSeasonRequest,
responses(
(status = 200, description = "更新赛季成功", body = UserResponse),
(status = 400, description = "请求参数错误"),
(status = 401, description = "未授权"),
(status = 404, description = "用户不存在"),
),
tag = "users"
)]
async fn update_season(
State(state): State<ServiceState>,
Path(id): Path<i32>,
Json(data): Json<UpdateSeasonRequest>,
) -> Result<Json<ApiResponse<UserResponse>>> {
let service = UserService::new(state.db);
let user = service.update_season(id, data).await?;
Ok(Json(ApiResponse::success(UserResponse::from(user))))
}
#[utoipa::path(
put,
path = "/api/users/{id}/approve",
params(
("id" = i32, Path, description = "用户ID")
),
request_body = UpdateApprovedRequest,
responses(
(status = 200, description = "更新审批状态成功", body = UserResponse),
(status = 400, description = "请求参数错误"),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
(status = 404, description = "用户不存在"),
),
tag = "users"
)]
async fn update_approved(
State(state): State<ServiceState>,
Path(id): Path<i32>,
Json(data): Json<UpdateApprovedRequest>,
) -> Result<Json<ApiResponse<UserResponse>>> {
let service = UserService::new(state.db);
// 这里需要从认证中间件获取用户信息,检查是否为管理员
// 暂时硬编码为true实际项目中需要从JWT令牌中解析
let is_admin = true;
let user = service.update_approved(id, data, is_admin).await?;
Ok(Json(ApiResponse::success(UserResponse::from(user))))
}
#[derive(ToSchema)]
pub struct PaginatedUserResponse {
items: Vec<UserResponse>,

View File

@@ -0,0 +1,23 @@
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Debug, Deserialize, ToSchema)]
pub struct CreateMapCommentRequest {
pub map_selection_id: i32,
pub osu_id: String,
pub username: String,
pub comment: Option<String>,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct UpdateMapCommentRequest {
pub comment: Option<String>,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct MapCommentQueryParams {
pub page: Option<u64>,
pub page_size: Option<u64>,
pub map_selection_id: Option<i32>,
pub osu_id: Option<String>,
}

View File

@@ -0,0 +1,68 @@
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Debug, Deserialize, ToSchema)]
pub struct CreateMapSelectionRequest {
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,
pub star_rating: String,
pub bpm: String,
pub total_length: i32,
pub max_combo: i32,
pub ar: String,
pub cs: String,
pub od: String,
pub hp: String,
pub selected_mods: String,
pub mod_position: i32,
pub comment: Option<String>,
pub selected_by: String,
pub season: String,
pub category: String,
pub url: String,
pub cover_url: Option<String>,
pub approved: Option<i32>,
pub padding: Option<i32>,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct UpdateMapSelectionRequest {
pub title: Option<String>,
pub title_unicode: Option<String>,
pub artist: Option<String>,
pub artist_unicode: Option<String>,
pub version: Option<String>,
pub creator: Option<String>,
pub star_rating: Option<String>,
pub bpm: Option<String>,
pub total_length: Option<i32>,
pub max_combo: Option<i32>,
pub ar: Option<String>,
pub cs: Option<String>,
pub od: Option<String>,
pub hp: Option<String>,
pub selected_mods: Option<String>,
pub mod_position: Option<i32>,
pub comment: Option<String>,
pub season: Option<String>,
pub category: Option<String>,
pub url: Option<String>,
pub cover_url: Option<String>,
pub approved: Option<i32>,
pub padding: Option<i32>,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct MapSelectionQueryParams {
pub page: Option<u64>,
pub page_size: Option<u64>,
pub season: Option<String>,
pub category: Option<String>,
pub selected_by: Option<String>,
}

View File

@@ -0,0 +1,37 @@
use sea_orm::prelude::{Date, Time};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Debug, Deserialize, ToSchema)]
pub struct CreateMatchRoomRequest {
pub room_name: String,
pub round_number: i32,
#[schema(value_type = String)]
pub match_date: Date,
#[schema(value_type = String)]
pub match_time: Time,
pub match_number: i32,
pub max_participants: Option<i32>,
pub created_by: String,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct UpdateMatchRoomRequest {
pub room_name: Option<String>,
pub round_number: Option<i32>,
#[schema(value_type = Option<String>)]
pub match_date: Option<Date>,
#[schema(value_type = Option<String>)]
pub match_time: Option<Time>,
pub match_number: Option<i32>,
pub max_participants: Option<i32>,
pub status: Option<String>,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct MatchRoomQueryParams {
pub page: Option<u64>,
pub page_size: Option<u64>,
pub round_number: Option<i32>,
pub status: Option<String>,
}

View File

@@ -0,0 +1,30 @@
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Debug, Deserialize, ToSchema)]
pub struct CreateMatchScheduleRequest {
pub room_id: i32,
pub player1_osu_id: String,
pub player1_username: String,
pub player2_osu_id: String,
pub player2_username: String,
pub created_by: String,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct UpdateMatchScheduleRequest {
pub red_score: Option<i32>,
pub blue_score: Option<i32>,
pub status: Option<String>,
pub replay_link: Option<String>,
pub match_link: Option<String>,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct MatchScheduleQueryParams {
pub page: Option<u64>,
pub page_size: Option<u64>,
pub room_id: Option<i32>,
pub status: Option<String>,
pub player_osu_id: Option<String>,
}

View File

@@ -0,0 +1,31 @@
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Debug, Deserialize, ToSchema)]
pub struct CreateMessageRequest {
pub sender_osu_id: String,
pub sender_username: String,
pub receiver_osu_id: String,
pub receiver_username: String,
pub r#type: Option<String>,
pub title: String,
pub content: String,
pub related_matchup_id: Option<i32>,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct UpdateMessageRequest {
pub title: Option<String>,
pub content: Option<String>,
pub status: Option<String>,
pub response_action: Option<String>,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct MessageQueryParams {
pub page: Option<u64>,
pub page_size: Option<u64>,
pub sender_osu_id: Option<String>,
pub receiver_osu_id: Option<String>,
pub status: Option<String>,
}

View File

@@ -9,15 +9,15 @@ pub mod tournament_settings;
pub mod user;
/// 通用响应结构
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
pub struct ApiResponse<T: utoipa::ToSchema> {
#[derive(Debug, serde::Serialize)]
pub struct ApiResponse<T> {
pub success: bool,
pub data: Option<T>,
pub message: Option<String>,
pub error: Option<String>,
}
impl<T: utoipa::ToSchema> ApiResponse<T> {
impl<T> ApiResponse<T> {
pub fn success(data: T) -> Self {
Self {
success: true,
@@ -55,6 +55,14 @@ impl<T: utoipa::ToSchema> ApiResponse<T> {
}
}
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
pub struct ApiResponseSchema<T: utoipa::ToSchema> {
pub success: bool,
pub data: Option<T>,
pub message: Option<String>,
pub error: Option<String>,
}
/// 分页请求参数
#[derive(Debug, serde::Deserialize, utoipa::IntoParams)]
pub struct PaginationParams {
@@ -72,8 +80,8 @@ fn default_page_size() -> u64 {
20
}
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
pub struct PaginatedResponse<T: utoipa::ToSchema> {
#[derive(Debug, serde::Serialize)]
pub struct PaginatedResponse<T> {
pub items: Vec<T>,
pub total: u64,
pub page: u64,
@@ -81,7 +89,7 @@ pub struct PaginatedResponse<T: utoipa::ToSchema> {
pub total_pages: u64,
}
impl<T: utoipa::ToSchema> PaginatedResponse<T> {
impl<T> 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 {
@@ -93,3 +101,12 @@ impl<T: utoipa::ToSchema> PaginatedResponse<T> {
}
}
}
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
pub struct PaginatedResponseSchema<T: utoipa::ToSchema> {
pub items: Vec<T>,
pub total: u64,
pub page: u64,
pub page_size: u64,
pub total_pages: u64,
}

View File

@@ -0,0 +1,24 @@
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Debug, Deserialize, ToSchema)]
pub struct CreatePlayerMatchupRequest {
pub player1_osu_id: String,
pub player1_username: String,
pub player2_osu_id: String,
pub player2_username: String,
pub created_by: String,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct UpdatePlayerMatchupRequest {
pub status: Option<String>,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct PlayerMatchupQueryParams {
pub page: Option<u64>,
pub page_size: Option<u64>,
pub status: Option<String>,
pub player_osu_id: Option<String>,
}

View File

@@ -0,0 +1,29 @@
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Debug, Deserialize, ToSchema)]
pub struct CreateTournamentSettingRequest {
pub tournament_name: String,
pub max_pp_for_registration: Option<f32>,
pub min_pp_for_registration: Option<f32>,
pub current_season: Option<String>,
pub current_season_stage: Option<String>,
pub mappool_visible: Option<i32>,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct UpdateTournamentSettingRequest {
pub tournament_name: Option<String>,
pub max_pp_for_registration: Option<f32>,
pub min_pp_for_registration: Option<f32>,
pub current_season: Option<String>,
pub current_season_stage: Option<String>,
pub mappool_visible: Option<i32>,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct TournamentSettingQueryParams {
pub page: Option<u64>,
pub page_size: Option<u64>,
pub current_season: Option<String>,
}

View File

@@ -54,6 +54,7 @@ pub struct UserResponse {
pub approved: Option<i32>,
pub user_group: String,
pub season: Option<i32>,
pub registration_status: Option<String>,
pub gu_server_user_id: Option<i32>,
pub gu_server_username: Option<String>,
pub created_at: String,
@@ -75,6 +76,9 @@ impl From<UserModel> for UserResponse {
approved: user.approved,
user_group: format!("{:?}", user.user_group),
season: user.season,
registration_status: user
.registration_status
.map(|status| format!("{:?}", status)),
gu_server_user_id: user.gu_server_user_id,
gu_server_username: user.gu_server_username,
created_at: user.created_at.to_rfc3339(),
@@ -88,7 +92,23 @@ pub struct UpdateUserGroupRequest {
pub user_group: String,
}
#[derive(Debug, Deserialize, Validate, utoipa::IntoParams)]
#[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct UpdateRegistrationStatusRequest {
pub registration_status: String,
}
#[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct UpdateSeasonRequest {
pub season: i32,
}
#[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct UpdateApprovedRequest {
pub approved: i32,
}
#[derive(Debug, Deserialize, Validate, utoipa::IntoParams, utoipa::ToSchema)]
#[schema(description = "用户查询参数")]
pub struct UserQueryParams {
pub username: Option<String>,
pub country: Option<String>,

View File

@@ -30,3 +30,14 @@ pub enum UserGroup {
#[sea_orm(string_value = "admin")]
Admin,
}
#[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum)]
#[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "registration_status")]
pub enum RegistrationStatus {
#[sea_orm(string_value = "pending")]
Pending,
#[sea_orm(string_value = "confirmed")]
Confirmed,
#[sea_orm(string_value = "rejected")]
Rejected,
}

View File

@@ -1,6 +1,6 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
use super::sea_orm_active_enums::UserGroup;
use super::sea_orm_active_enums::{RegistrationStatus, UserGroup};
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
@@ -22,6 +22,7 @@ pub struct Model {
pub approved: Option<i32>,
pub user_group: UserGroup,
pub season: Option<i32>,
pub registration_status: Option<RegistrationStatus>,
pub gu_server_user_id: Option<i32>,
pub gu_server_username: Option<String>,
pub created_at: DateTimeUtc,

View File

@@ -36,6 +36,9 @@ pub enum AppError {
#[error("内部服务器错误: {0}")]
InternalError(String),
#[error("功能未实现: {0}")]
NotImplemented(String),
}
impl AppError {
@@ -48,7 +51,7 @@ impl AppError {
AppError::BusinessError(_) => StatusCode::BAD_REQUEST,
AppError::ConfigError(_) => StatusCode::INTERNAL_SERVER_ERROR,
AppError::ExternalApiError(_) => StatusCode::BAD_GATEWAY,
AppError::DatabaseError(_) | AppError::InternalError(_) => {
AppError::DatabaseError(_) | AppError::InternalError(_) | AppError::NotImplemented(_) => {
StatusCode::INTERNAL_SERVER_ERROR
}
}
@@ -65,6 +68,7 @@ impl AppError {
AppError::ConfigError(msg) => format!("配置错误: {}", msg),
AppError::ExternalApiError(msg) => format!("外部API错误: {}", msg),
AppError::InternalError(msg) => format!("内部服务器错误: {}", msg),
AppError::NotImplemented(msg) => format!("功能未实现: {}", msg),
}
}
}

View File

@@ -39,11 +39,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("数据库连接成功");
// 创建服务状态
let service_state = Arc::new(ServiceState::new(db));
let service_state = ServiceState::new(db);
// 配置应用
let app = api::configure_routes()
.layer(middleware::configure_middleware())
.with_state(service_state);
// 启动服务器

View File

@@ -0,0 +1,97 @@
use sea_orm::{
ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter,
QueryOrder, Set,
};
use crate::{
dto::map_comments::{CreateMapCommentRequest, MapCommentQueryParams, UpdateMapCommentRequest},
entity::map_comments,
error::{AppError, Result},
};
pub struct MapCommentService {
db: DatabaseConnection,
}
impl MapCommentService {
pub fn new(db: DatabaseConnection) -> Self {
Self { db }
}
pub async fn find_all(
&self,
params: MapCommentQueryParams,
) -> Result<(Vec<map_comments::Model>, u64)> {
let mut query = map_comments::Entity::find();
if let Some(map_selection_id) = params.map_selection_id {
query = query.filter(map_comments::Column::MapSelectionId.eq(map_selection_id));
}
if let Some(osu_id) = params.osu_id {
query = query.filter(map_comments::Column::OsuId.eq(osu_id));
}
query = query.order_by_desc(map_comments::Column::CreatedAt);
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 map_comments = query
.paginate(&self.db, page_size)
.fetch_page(page - 1)
.await?;
Ok((map_comments, total))
}
pub async fn find_by_id(&self, id: i32) -> Result<map_comments::Model> {
map_comments::Entity::find_by_id(id)
.one(&self.db)
.await?
.ok_or_else(|| AppError::NotFound(format!("地图评论 {} 不存在", id)))
}
pub async fn create(&self, data: CreateMapCommentRequest) -> Result<map_comments::Model> {
let now = chrono::Utc::now();
let map_comment = map_comments::ActiveModel {
map_selection_id: Set(data.map_selection_id),
osu_id: Set(data.osu_id),
username: Set(data.username),
comment: Set(data.comment),
created_at: Set(now),
updated_at: Set(now),
..Default::default()
};
Ok(map_comment.insert(&self.db).await?)
}
pub async fn update(
&self,
id: i32,
data: UpdateMapCommentRequest,
) -> Result<map_comments::Model> {
let mut map_comment: map_comments::ActiveModel = self.find_by_id(id).await?.into();
if let Some(comment) = data.comment {
map_comment.comment = Set(Some(comment));
}
map_comment.updated_at = Set(chrono::Utc::now());
Ok(map_comment.update(&self.db).await?)
}
pub async fn delete(&self, id: i32) -> Result<()> {
let result = map_comments::Entity::delete_by_id(id)
.exec(&self.db)
.await?;
if result.rows_affected == 0 {
return Err(AppError::NotFound(format!("地图评论 {} 不存在", id)));
}
Ok(())
}
}

View File

@@ -0,0 +1,215 @@
use sea_orm::prelude::Decimal;
use sea_orm::{
ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter,
QueryOrder, Set,
};
use crate::{
dto::map_selections::{
CreateMapSelectionRequest, MapSelectionQueryParams, UpdateMapSelectionRequest,
},
entity::map_selections,
error::{AppError, Result},
};
pub struct MapSelectionService {
db: DatabaseConnection,
}
impl MapSelectionService {
pub fn new(db: DatabaseConnection) -> Self {
Self { db }
}
pub async fn find_all(
&self,
params: MapSelectionQueryParams,
) -> Result<(Vec<map_selections::Model>, u64)> {
let mut query = map_selections::Entity::find();
if let Some(season) = params.season {
query = query.filter(map_selections::Column::Season.eq(season));
}
if let Some(category) = params.category {
query = query.filter(map_selections::Column::Category.eq(category));
}
if let Some(selected_by) = params.selected_by {
query = query.filter(map_selections::Column::SelectedBy.eq(selected_by));
}
query = query.order_by_desc(map_selections::Column::CreatedAt);
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 map_selections = query
.paginate(&self.db, page_size)
.fetch_page(page - 1)
.await?;
Ok((map_selections, total))
}
pub async fn find_by_id(&self, id: i32) -> Result<map_selections::Model> {
map_selections::Entity::find_by_id(id)
.one(&self.db)
.await?
.ok_or_else(|| AppError::NotFound(format!("地图选择 {} 不存在", id)))
}
pub async fn create(&self, data: CreateMapSelectionRequest) -> Result<map_selections::Model> {
let now = chrono::Utc::now();
let map_selection = map_selections::ActiveModel {
beatmap_id: Set(data.beatmap_id),
beatmapset_id: Set(data.beatmapset_id),
title: Set(data.title),
title_unicode: Set(data.title_unicode),
artist: Set(data.artist),
artist_unicode: Set(data.artist_unicode),
version: Set(data.version),
creator: Set(data.creator),
star_rating: Set(Decimal::from(data.star_rating.parse::<i64>().unwrap())),
bpm: Set(Decimal::from(data.bpm.parse::<i64>().unwrap())),
total_length: Set(data.total_length),
max_combo: Set(data.max_combo),
ar: Set(Decimal::from(data.ar.parse::<i64>().unwrap())),
cs: Set(Decimal::from(data.cs.parse::<i64>().unwrap())),
od: Set(Decimal::from(data.od.parse::<i64>().unwrap())),
hp: Set(Decimal::from(data.hp.parse::<i64>().unwrap())),
selected_mods: Set(data.selected_mods),
mod_position: Set(data.mod_position),
comment: Set(data.comment),
selected_by: Set(data.selected_by),
selected_at: Set(now),
season: Set(data.season),
category: Set(data.category),
url: Set(data.url),
cover_url: Set(data.cover_url),
approved: Set(data.approved),
padding: Set(data.padding),
created_at: Set(now),
updated_at: Set(now),
..Default::default()
};
Ok(map_selection.insert(&self.db).await?)
}
pub async fn update(
&self,
id: i32,
data: UpdateMapSelectionRequest,
) -> Result<map_selections::Model> {
let mut map_selection: map_selections::ActiveModel = self.find_by_id(id).await?.into();
if let Some(title) = data.title {
map_selection.title = Set(title);
}
if let Some(title_unicode) = data.title_unicode {
map_selection.title_unicode = Set(Some(title_unicode));
}
if let Some(artist) = data.artist {
map_selection.artist = Set(artist);
}
if let Some(artist_unicode) = data.artist_unicode {
map_selection.artist_unicode = Set(Some(artist_unicode));
}
if let Some(version) = data.version {
map_selection.version = Set(version);
}
if let Some(creator) = data.creator {
map_selection.creator = Set(creator);
}
if let Some(star_rating) = data.star_rating {
map_selection.star_rating = Set(Decimal::from(star_rating.parse::<i64>().unwrap()));
}
if let Some(bpm) = data.bpm {
map_selection.bpm = Set(Decimal::from(bpm.parse::<i64>().unwrap()));
}
if let Some(total_length) = data.total_length {
map_selection.total_length = Set(total_length);
}
if let Some(max_combo) = data.max_combo {
map_selection.max_combo = Set(max_combo);
}
if let Some(ar) = data.ar {
map_selection.ar = Set(Decimal::from(ar.parse::<i64>().unwrap()));
}
if let Some(cs) = data.cs {
map_selection.cs = Set(Decimal::from(cs.parse::<i64>().unwrap()));
}
if let Some(od) = data.od {
map_selection.od = Set(Decimal::from(od.parse::<i64>().unwrap()));
}
if let Some(hp) = data.hp {
map_selection.hp = Set(Decimal::from(hp.parse::<i64>().unwrap()));
}
if let Some(selected_mods) = data.selected_mods {
map_selection.selected_mods = Set(selected_mods);
}
if let Some(mod_position) = data.mod_position {
map_selection.mod_position = Set(mod_position);
}
if let Some(comment) = data.comment {
map_selection.comment = Set(Some(comment));
}
if let Some(season) = data.season {
map_selection.season = Set(season);
}
if let Some(category) = data.category {
map_selection.category = Set(category);
}
if let Some(url) = data.url {
map_selection.url = Set(url);
}
if let Some(cover_url) = data.cover_url {
map_selection.cover_url = Set(Some(cover_url));
}
if let Some(approved) = data.approved {
map_selection.approved = Set(Some(approved));
}
if let Some(padding) = data.padding {
map_selection.padding = Set(Some(padding));
}
map_selection.updated_at = Set(chrono::Utc::now());
Ok(map_selection.update(&self.db).await?)
}
pub async fn delete(&self, id: i32) -> Result<()> {
let result = map_selections::Entity::delete_by_id(id)
.exec(&self.db)
.await?;
if result.rows_affected == 0 {
return Err(AppError::NotFound(format!("地图选择 {} 不存在", id)));
}
Ok(())
}
}

View File

@@ -0,0 +1,136 @@
use sea_orm::{
ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter,
QueryOrder, Set,
prelude::{Date, Time},
};
use crate::{
dto::match_rooms::{CreateMatchRoomRequest, MatchRoomQueryParams, UpdateMatchRoomRequest},
entity::{match_rooms, sea_orm_active_enums::Status},
error::{AppError, Result},
};
pub struct MatchRoomService {
db: DatabaseConnection,
}
impl MatchRoomService {
pub fn new(db: DatabaseConnection) -> Self {
Self { db }
}
pub async fn find_all(
&self,
params: MatchRoomQueryParams,
) -> Result<(Vec<match_rooms::Model>, u64)> {
let mut query = match_rooms::Entity::find();
if let Some(round_number) = params.round_number {
query = query.filter(match_rooms::Column::RoundNumber.eq(round_number));
}
if let Some(status) = params.status {
let status_enum = match status.as_str() {
"available" => Status::Available,
"scheduled" => Status::Scheduled,
"completed" => Status::Completed,
_ => return Err(AppError::ValidationError("无效的状态".into())),
};
query = query.filter(match_rooms::Column::Status.eq(Some(status_enum)));
}
query = query.order_by_asc(match_rooms::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 match_rooms = query
.paginate(&self.db, page_size)
.fetch_page(page - 1)
.await?;
Ok((match_rooms, total))
}
pub async fn find_by_id(&self, id: i32) -> Result<match_rooms::Model> {
match_rooms::Entity::find_by_id(id)
.one(&self.db)
.await?
.ok_or_else(|| AppError::NotFound(format!("匹配房间 {} 不存在", id)))
}
pub async fn create(&self, data: CreateMatchRoomRequest) -> Result<match_rooms::Model> {
let now = chrono::Utc::now();
let match_room = match_rooms::ActiveModel {
room_name: Set(data.room_name),
round_number: Set(data.round_number),
match_date: Set(data.match_date),
match_time: Set(data.match_time),
match_number: Set(data.match_number),
max_participants: Set(data.max_participants),
status: Set(Some(Status::Available)),
created_by: Set(data.created_by),
created_at: Set(now),
updated_at: Set(now),
..Default::default()
};
Ok(match_room.insert(&self.db).await?)
}
pub async fn update(
&self,
id: i32,
data: UpdateMatchRoomRequest,
) -> Result<match_rooms::Model> {
let mut match_room: match_rooms::ActiveModel = self.find_by_id(id).await?.into();
if let Some(room_name) = data.room_name {
match_room.room_name = Set(room_name);
}
if let Some(round_number) = data.round_number {
match_room.round_number = Set(round_number);
}
if let Some(match_date) = data.match_date {
match_room.match_date = Set(match_date);
}
if let Some(match_time) = data.match_time {
match_room.match_time = Set(match_time);
}
if let Some(match_number) = data.match_number {
match_room.match_number = Set(match_number);
}
if let Some(max_participants) = data.max_participants {
match_room.max_participants = Set(Some(max_participants));
}
if let Some(status) = data.status {
let status_enum = match status.as_str() {
"available" => Status::Available,
"scheduled" => Status::Scheduled,
"completed" => Status::Completed,
_ => return Err(AppError::ValidationError("无效的状态".into())),
};
match_room.status = Set(Some(status_enum));
}
match_room.updated_at = Set(chrono::Utc::now());
Ok(match_room.update(&self.db).await?)
}
pub async fn delete(&self, id: i32) -> Result<()> {
let result = match_rooms::Entity::delete_by_id(id).exec(&self.db).await?;
if result.rows_affected == 0 {
return Err(AppError::NotFound(format!("匹配房间 {} 不存在", id)));
}
Ok(())
}
}

View File

@@ -0,0 +1,138 @@
use sea_orm::{
ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter,
QueryOrder, Set,
};
use crate::{
dto::match_schedules::{
CreateMatchScheduleRequest, MatchScheduleQueryParams, UpdateMatchScheduleRequest,
},
entity::{match_schedules, sea_orm_active_enums::Status},
error::{AppError, Result},
};
pub struct MatchScheduleService {
db: DatabaseConnection,
}
impl MatchScheduleService {
pub fn new(db: DatabaseConnection) -> Self {
Self { db }
}
pub async fn find_all(
&self,
params: MatchScheduleQueryParams,
) -> Result<(Vec<match_schedules::Model>, u64)> {
let mut query = match_schedules::Entity::find();
if let Some(room_id) = params.room_id {
query = query.filter(match_schedules::Column::RoomId.eq(room_id));
}
if let Some(status) = params.status {
let status_enum = match status.as_str() {
"available" => Status::Available,
"scheduled" => Status::Scheduled,
"completed" => Status::Completed,
_ => return Err(AppError::ValidationError("无效的状态".into())),
};
query = query.filter(match_schedules::Column::Status.eq(Some(status_enum)));
}
if let Some(player_osu_id) = params.player_osu_id {
query = query.filter(
match_schedules::Column::Player1OsuId
.eq(&player_osu_id)
.or(match_schedules::Column::Player2OsuId.eq(&player_osu_id)),
);
}
query = query.order_by_desc(match_schedules::Column::CreatedAt);
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 match_schedules = query
.paginate(&self.db, page_size)
.fetch_page(page - 1)
.await?;
Ok((match_schedules, total))
}
pub async fn find_by_id(&self, id: i32) -> Result<match_schedules::Model> {
match_schedules::Entity::find_by_id(id)
.one(&self.db)
.await?
.ok_or_else(|| AppError::NotFound(format!("匹配赛程 {} 不存在", id)))
}
pub async fn create(&self, data: CreateMatchScheduleRequest) -> Result<match_schedules::Model> {
let now = chrono::Utc::now();
let match_schedule = match_schedules::ActiveModel {
room_id: Set(data.room_id),
player1_osu_id: Set(data.player1_osu_id),
player1_username: Set(data.player1_username),
player2_osu_id: Set(data.player2_osu_id),
player2_username: Set(data.player2_username),
status: Set(Some(Status::Available)),
created_by: Set(data.created_by),
created_at: Set(now),
updated_at: Set(now),
..Default::default()
};
Ok(match_schedule.insert(&self.db).await?)
}
pub async fn update(
&self,
id: i32,
data: UpdateMatchScheduleRequest,
) -> Result<match_schedules::Model> {
let mut match_schedule: match_schedules::ActiveModel = self.find_by_id(id).await?.into();
if let Some(red_score) = data.red_score {
match_schedule.red_score = Set(Some(red_score));
}
if let Some(blue_score) = data.blue_score {
match_schedule.blue_score = Set(Some(blue_score));
}
if let Some(status) = data.status {
let status_enum = match status.as_str() {
"available" => Status::Available,
"scheduled" => Status::Scheduled,
"completed" => Status::Completed,
_ => return Err(AppError::ValidationError("无效的状态".into())),
};
match_schedule.status = Set(Some(status_enum));
}
if let Some(replay_link) = data.replay_link {
match_schedule.replay_link = Set(Some(replay_link));
}
if let Some(match_link) = data.match_link {
match_schedule.match_link = Set(Some(match_link));
}
match_schedule.updated_at = Set(chrono::Utc::now());
Ok(match_schedule.update(&self.db).await?)
}
pub async fn delete(&self, id: i32) -> Result<()> {
let result = match_schedules::Entity::delete_by_id(id)
.exec(&self.db)
.await?;
if result.rows_affected == 0 {
return Err(AppError::NotFound(format!("匹配赛程 {} 不存在", id)));
}
Ok(())
}
}

View File

@@ -0,0 +1,137 @@
use sea_orm::{
ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter,
QueryOrder, Set,
};
use crate::{
dto::messages::{CreateMessageRequest, MessageQueryParams, UpdateMessageRequest},
entity::{
messages,
sea_orm_active_enums::{Status, Type},
},
error::{AppError, Result},
};
pub struct MessageService {
db: DatabaseConnection,
}
impl MessageService {
pub fn new(db: DatabaseConnection) -> Self {
Self { db }
}
pub async fn find_all(
&self,
params: MessageQueryParams,
) -> Result<(Vec<messages::Model>, u64)> {
let mut query = messages::Entity::find();
if let Some(sender_osu_id) = params.sender_osu_id {
query = query.filter(messages::Column::SenderOsuId.eq(sender_osu_id));
}
if let Some(receiver_osu_id) = params.receiver_osu_id {
query = query.filter(messages::Column::ReceiverOsuId.eq(receiver_osu_id));
}
if let Some(status) = params.status {
let status_enum = match status.as_str() {
"available" => Status::Available,
"scheduled" => Status::Scheduled,
"completed" => Status::Completed,
_ => return Err(AppError::ValidationError("无效的状态".into())),
};
query = query.filter(messages::Column::Status.eq(Some(status_enum)));
}
query = query.order_by_desc(messages::Column::CreatedAt);
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 messages = query
.paginate(&self.db, page_size)
.fetch_page(page - 1)
.await?;
Ok((messages, total))
}
pub async fn find_by_id(&self, id: i32) -> Result<messages::Model> {
messages::Entity::find_by_id(id)
.one(&self.db)
.await?
.ok_or_else(|| AppError::NotFound(format!("消息 {} 不存在", id)))
}
pub async fn create(&self, data: CreateMessageRequest) -> Result<messages::Model> {
let now = chrono::Utc::now();
let message_type = data.r#type.map(|t| match t.as_str() {
"system" => Type::System,
"match_invitation" => Type::MatchInvitation,
"match_response" => Type::MatchResponse,
_ => panic!("无效的消息类型"),
});
let message = messages::ActiveModel {
sender_osu_id: Set(data.sender_osu_id),
sender_username: Set(data.sender_username),
receiver_osu_id: Set(data.receiver_osu_id),
receiver_username: Set(data.receiver_username),
r#type: Set(message_type),
title: Set(data.title),
content: Set(data.content),
related_matchup_id: Set(data.related_matchup_id),
status: Set(Some(Status::Available)),
created_at: Set(now),
updated_at: Set(now),
..Default::default()
};
Ok(message.insert(&self.db).await?)
}
pub async fn update(&self, id: i32, data: UpdateMessageRequest) -> Result<messages::Model> {
let mut message: messages::ActiveModel = self.find_by_id(id).await?.into();
if let Some(title) = data.title {
message.title = Set(title);
}
if let Some(content) = data.content {
message.content = Set(content);
}
if let Some(status) = data.status {
let status_enum = match status.as_str() {
"available" => Status::Available,
"scheduled" => Status::Scheduled,
"completed" => Status::Completed,
_ => return Err(AppError::ValidationError("无效的状态".into())),
};
message.status = Set(Some(status_enum));
if status == "completed" {
message.response_time = Set(Some(chrono::Utc::now()));
}
}
if let Some(response_action) = data.response_action {
message.response_action = Set(Some(response_action));
}
message.updated_at = Set(chrono::Utc::now());
Ok(message.update(&self.db).await?)
}
pub async fn delete(&self, id: i32) -> Result<()> {
let result = messages::Entity::delete_by_id(id).exec(&self.db).await?;
if result.rows_affected == 0 {
return Err(AppError::NotFound(format!("消息 {} 不存在", id)));
}
Ok(())
}
}

View File

@@ -0,0 +1,117 @@
use sea_orm::{
ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter,
QueryOrder, Set,
};
use crate::{
dto::player_matchups::{
CreatePlayerMatchupRequest, PlayerMatchupQueryParams, UpdatePlayerMatchupRequest,
},
entity::{player_matchups, sea_orm_active_enums::Status},
error::{AppError, Result},
};
pub struct PlayerMatchupService {
db: DatabaseConnection,
}
impl PlayerMatchupService {
pub fn new(db: DatabaseConnection) -> Self {
Self { db }
}
pub async fn find_all(
&self,
params: PlayerMatchupQueryParams,
) -> Result<(Vec<player_matchups::Model>, u64)> {
let mut query = player_matchups::Entity::find();
if let Some(status) = params.status {
let status_enum = match status.as_str() {
"available" => Status::Available,
"scheduled" => Status::Scheduled,
"completed" => Status::Completed,
_ => return Err(AppError::ValidationError("无效的状态".into())),
};
query = query.filter(player_matchups::Column::Status.eq(Some(status_enum)));
}
if let Some(player_osu_id) = params.player_osu_id {
query = query.filter(
player_matchups::Column::Player1OsuId
.eq(&player_osu_id)
.or(player_matchups::Column::Player2OsuId.eq(&player_osu_id)),
);
}
query = query.order_by_desc(player_matchups::Column::CreatedAt);
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 player_matchups = query
.paginate(&self.db, page_size)
.fetch_page(page - 1)
.await?;
Ok((player_matchups, total))
}
pub async fn find_by_id(&self, id: i32) -> Result<player_matchups::Model> {
player_matchups::Entity::find_by_id(id)
.one(&self.db)
.await?
.ok_or_else(|| AppError::NotFound(format!("玩家对战 {} 不存在", id)))
}
pub async fn create(&self, data: CreatePlayerMatchupRequest) -> Result<player_matchups::Model> {
let now = chrono::Utc::now();
let player_matchup = player_matchups::ActiveModel {
player1_osu_id: Set(data.player1_osu_id),
player1_username: Set(data.player1_username),
player2_osu_id: Set(data.player2_osu_id),
player2_username: Set(data.player2_username),
status: Set(Some(Status::Available)),
created_by: Set(data.created_by),
created_at: Set(now),
updated_at: Set(now),
..Default::default()
};
Ok(player_matchup.insert(&self.db).await?)
}
pub async fn update(
&self,
id: i32,
data: UpdatePlayerMatchupRequest,
) -> Result<player_matchups::Model> {
let mut player_matchup: player_matchups::ActiveModel = self.find_by_id(id).await?.into();
if let Some(status) = data.status {
let status_enum = match status.as_str() {
"available" => Status::Available,
"scheduled" => Status::Scheduled,
"completed" => Status::Completed,
_ => return Err(AppError::ValidationError("无效的状态".into())),
};
player_matchup.status = Set(Some(status_enum));
}
player_matchup.updated_at = Set(chrono::Utc::now());
Ok(player_matchup.update(&self.db).await?)
}
pub async fn delete(&self, id: i32) -> Result<()> {
let result = player_matchups::Entity::delete_by_id(id)
.exec(&self.db)
.await?;
if result.rows_affected == 0 {
return Err(AppError::NotFound(format!("玩家对战 {} 不存在", id)));
}
Ok(())
}
}

View File

@@ -0,0 +1,130 @@
use sea_orm::{
ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter,
QueryOrder, Set,
};
use crate::{
dto::tournament_settings::{
CreateTournamentSettingRequest, TournamentSettingQueryParams,
UpdateTournamentSettingRequest,
},
entity::tournament_settings,
error::{AppError, Result},
};
pub struct TournamentSettingService {
db: DatabaseConnection,
}
impl TournamentSettingService {
pub fn new(db: DatabaseConnection) -> Self {
Self { db }
}
pub async fn find_all(
&self,
params: TournamentSettingQueryParams,
) -> Result<(Vec<tournament_settings::Model>, u64)> {
let mut query = tournament_settings::Entity::find();
if let Some(current_season) = params.current_season {
query =
query.filter(tournament_settings::Column::CurrentSeason.eq(Some(current_season)));
}
query = query.order_by_desc(tournament_settings::Column::CreatedAt);
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 tournament_settings = query
.paginate(&self.db, page_size)
.fetch_page(page - 1)
.await?;
Ok((tournament_settings, total))
}
pub async fn find_by_id(&self, id: i32) -> Result<tournament_settings::Model> {
tournament_settings::Entity::find_by_id(id)
.one(&self.db)
.await?
.ok_or_else(|| AppError::NotFound(format!("锦标赛设置 {} 不存在", id)))
}
pub async fn create(
&self,
data: CreateTournamentSettingRequest,
) -> Result<tournament_settings::Model> {
let now = chrono::Utc::now();
let tournament_setting = tournament_settings::ActiveModel {
tournament_name: Set(data.tournament_name),
max_pp_for_registration: Set(data.max_pp_for_registration),
min_pp_for_registration: Set(data.min_pp_for_registration),
current_season: Set(data.current_season),
current_season_stage: Set(data.current_season_stage),
mappool_visible: Set(data.mappool_visible),
created_at: Set(now),
updated_at: Set(now),
..Default::default()
};
Ok(tournament_setting.insert(&self.db).await?)
}
pub async fn update(
&self,
id: i32,
data: UpdateTournamentSettingRequest,
) -> Result<tournament_settings::Model> {
let mut tournament_setting: tournament_settings::ActiveModel =
self.find_by_id(id).await?.into();
if let Some(tournament_name) = data.tournament_name {
tournament_setting.tournament_name = Set(tournament_name);
}
if let Some(max_pp_for_registration) = data.max_pp_for_registration {
tournament_setting.max_pp_for_registration = Set(Some(max_pp_for_registration));
}
if let Some(min_pp_for_registration) = data.min_pp_for_registration {
tournament_setting.min_pp_for_registration = Set(Some(min_pp_for_registration));
}
if let Some(current_season) = data.current_season {
tournament_setting.current_season = Set(Some(current_season));
}
if let Some(current_season_stage) = data.current_season_stage {
tournament_setting.current_season_stage = Set(Some(current_season_stage));
}
if let Some(mappool_visible) = data.mappool_visible {
tournament_setting.mappool_visible = Set(Some(mappool_visible));
}
tournament_setting.updated_at = Set(chrono::Utc::now());
Ok(tournament_setting.update(&self.db).await?)
}
pub async fn delete(&self, id: i32) -> Result<()> {
let result = tournament_settings::Entity::delete_by_id(id)
.exec(&self.db)
.await?;
if result.rows_affected == 0 {
return Err(AppError::NotFound(format!("锦标赛设置 {} 不存在", id)));
}
Ok(())
}
pub async fn get_current_settings(&self) -> Result<Option<tournament_settings::Model>> {
Ok(tournament_settings::Entity::find()
.order_by_desc(tournament_settings::Column::CreatedAt)
.one(&self.db)
.await?)
}
}

View File

@@ -4,8 +4,8 @@ use sea_orm::{
};
use crate::{
dto::user::{CreateUserRequest, UpdateUserGroupRequest, UpdateUserRequest, UserQueryParams},
entity::{sea_orm_active_enums::UserGroup, user},
dto::user::{CreateUserRequest, UpdateApprovedRequest, UpdateRegistrationStatusRequest, UpdateSeasonRequest, UpdateUserGroupRequest, UpdateUserRequest, UserQueryParams},
entity::{sea_orm_active_enums::{RegistrationStatus, UserGroup}, user},
error::{AppError, Result},
};
@@ -81,6 +81,7 @@ impl UserService {
approved: Set(None),
user_group: Set(UserGroup::Player),
season: Set(None),
registration_status: Set(Some(RegistrationStatus::Pending)),
gu_server_user_id: Set(None),
gu_server_username: Set(None),
created_at: Set(now),
@@ -163,4 +164,47 @@ impl UserService {
.count(&self.db)
.await?)
}
pub async fn update_registration_status(&self, id: i32, data: UpdateRegistrationStatusRequest) -> Result<user::Model> {
let status_enum = match data.registration_status.as_str() {
"pending" => RegistrationStatus::Pending,
"confirmed" => RegistrationStatus::Confirmed,
"rejected" => RegistrationStatus::Rejected,
_ => return Err(AppError::ValidationError("无效的注册状态".into())),
};
let mut user: user::ActiveModel = self.find_by_id(id).await?.into();
user.registration_status = Set(Some(status_enum));
user.updated_at = Set(chrono::Utc::now());
Ok(user.update(&self.db).await?)
}
pub async fn update_season(&self, id: i32, data: UpdateSeasonRequest) -> Result<user::Model> {
let mut user: user::ActiveModel = self.find_by_id(id).await?.into();
user.season = Set(Some(data.season));
user.updated_at = Set(chrono::Utc::now());
Ok(user.update(&self.db).await?)
}
pub async fn update_approved(&self, id: i32, data: UpdateApprovedRequest, is_admin: bool) -> Result<user::Model> {
if !is_admin {
return Err(AppError::Forbidden("只有管理员可以更新审批状态".into()));
}
let mut user: user::ActiveModel = self.find_by_id(id).await?.into();
user.approved = Set(Some(data.approved));
user.updated_at = Set(chrono::Utc::now());
Ok(user.update(&self.db).await?)
}
pub async fn update_season_from_settings(&self, id: i32, season: i32) -> Result<user::Model> {
let mut user: user::ActiveModel = self.find_by_id(id).await?.into();
user.season = Set(Some(season));
user.updated_at = Set(chrono::Utc::now());
Ok(user.update(&self.db).await?)
}
}

View File

@@ -0,0 +1,48 @@
use chrono;
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode};
use serde::{Deserialize, Serialize};
use std::env;
#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
pub sub: String,
pub user_group: String,
pub exp: usize,
}
/// 生成JWT令牌
pub fn generate_token(
osu_id: String,
user_group: String,
) -> Result<String, jsonwebtoken::errors::Error> {
let secret = env::var("JWT_SECRET").unwrap_or_else(|_| "secret_key".to_string());
let expiration = chrono::Utc::now()
.checked_add_signed(chrono::Duration::days(7))
.expect("valid timestamp")
.timestamp() as usize;
let claims = Claims {
sub: osu_id,
user_group,
exp: expiration,
};
let header = Header::new(Algorithm::HS256);
encode(
&header,
&claims,
&EncodingKey::from_secret(secret.as_bytes()),
)
}
/// 验证JWT令牌
pub fn verify_token(token: &str) -> Result<Claims, jsonwebtoken::errors::Error> {
let secret = env::var("JWT_SECRET").unwrap_or_else(|_| "secret_key".to_string());
let validation = Validation::new(Algorithm::HS256);
decode::<Claims>(
token,
&DecodingKey::from_secret(secret.as_bytes()),
&validation,
)
.map(|data| data.claims)
}