This commit is contained in:
2026-02-13 00:33:09 +08:00
parent 2140e99fb8
commit f713bc8f4e
13 changed files with 437 additions and 13 deletions

5
.env
View File

@@ -6,6 +6,11 @@ OSU_CLIENT_ID =
OSU_CLIENT_SECRET =
OSU_REDIRECT_URI = http://localhost:3000/api/auth/callback/osu
# OAuth Configuration for g0v0.top server
GU_CLIENT_ID = 13
GU_CLIENT_SECRET = fdcab562f6cfd5c62c0345e40791e7273ab8d4ed5459a4bac690d91f5c3f3b3f
GU_REDIRECT_URI = http://localhost:3000/api/auth/callback/gu
# JWT Configuration
JWT_SECRET=457ceac798afbc2e305c4833844a2736
JWT_EXPIRY_HOURS=24

1
Cargo.lock generated
View File

@@ -100,6 +100,7 @@ version = "0.0.1"
dependencies = [
"anyhow",
"argon2",
"async-trait",
"axum",
"axum-extra",
"bcrypt",

View File

@@ -28,6 +28,7 @@ jsonwebtoken = "10.3"
bcrypt = "0.18"
argon2 = "0.5"
rand = "0.10"
async-trait = "0.1"
# HTTP client for OAuth
reqwest = { version = "0.13", features = ["json"] }

View File

@@ -23,6 +23,8 @@ pub fn routes() -> Router<ServiceState> {
Router::new()
.route("/osu", get(osu_auth))
.route("/callback/osu", get(osu_callback))
.route("/gu", get(gu_auth))
.route("/callback/gu", get(gu_callback))
.route("/refresh", get(refresh_token))
.route("/logout", get(logout))
}
@@ -72,6 +74,29 @@ async fn osu_auth() -> Redirect {
Redirect::to(&auth_url)
}
#[utoipa::path(
get,
path = "/api/auth/gu",
responses(
(status = 302, description = "重定向到GU授权页面"),
),
tag = "auth"
)]
async fn gu_auth() -> Redirect {
let client_id = env::var("GU_CLIENT_ID").expect("GU_CLIENT_ID not set");
let redirect_uri = env::var("GU_REDIRECT_URI").expect("GU_REDIRECT_URI not set");
let state = Uuid::new_v4().to_string();
let auth_url = format!(
"https://lazer-api.g0v0.top/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",
@@ -222,3 +247,139 @@ async fn logout() -> Result<Json<ApiResponse<()>>> {
// 这里需要清除客户端的令牌
Ok(Json(ApiResponse::success_with_message((), "登出成功")))
}
#[utoipa::path(
get,
path = "/api/auth/callback/gu",
params(
("code" = String, Query, description = "GU OAuth回调代码"),
("state" = String, Query, description = "状态参数"),
),
responses(
(status = 200, description = "OAuth回调成功"),
(status = 400, description = "请求参数错误"),
(status = 500, description = "内部服务器错误"),
),
tag = "auth"
)]
async fn gu_callback(
Query(params): Query<OsuCallbackQuery>,
State(state): State<ServiceState>,
) -> Result<Json<ApiResponse<AuthResponse>>> {
let client_id = env::var("GU_CLIENT_ID").expect("GU_CLIENT_ID not set");
let client_secret = env::var("GU_CLIENT_SECRET").expect("GU_CLIENT_SECRET not set");
let redirect_uri = env::var("GU_REDIRECT_URI").expect("GU_REDIRECT_URI not set");
// 获取访问令牌
let client = Client::new();
let token_response = client
.post("https://lazer-api.g0v0.top/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://lazer-api.g0v0.top/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_id = user_data.id.clone();
let username = user_data.username.clone();
let user = match existing_user {
Some(existing) => {
// 更新现有用户包括GU服务器信息
let update_request = crate::dto::user::UpdateUserRequest {
username: Some(username.clone()),
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,
};
let updated_user = user_service.update(existing.id, update_request).await?;
// 更新用户的gu_server_user_id和gu_server_username
let gu_user_id = user_id.parse::<i32>().unwrap_or(0);
let updated_user_with_gu_info = user_service
.update_gu_server_info(updated_user.id, gu_user_id, username.clone())
.await?;
updated_user_with_gu_info
}
None => {
// 创建新用户
let create_request = CreateUserRequest {
username: username.clone(),
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),
};
let new_user = user_service.create(user_id.clone(), create_request).await?;
// 设置用户的gu_server_user_id和gu_server_username
let gu_user_id = user_id.parse::<i32>().unwrap_or(0);
let new_user_with_gu_info = user_service
.update_gu_server_info(new_user.id, gu_user_id, username.clone())
.await?;
new_user_with_gu_info
}
};
// 生成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)))
}

View File

@@ -1,4 +1,5 @@
pub mod auth;
pub mod tournament_settings;
pub mod user;
use axum::{Router, routing::get};
@@ -14,6 +15,7 @@ pub fn configure_routes() -> Router<ServiceState> {
.route("/health", get(|| async { "OK" }))
.nest("/api/auth", auth::routes())
.nest("/api/users", user::routes())
.nest("/api/tournament-settings", tournament_settings::routes())
}
#[derive(OpenApi)]
@@ -31,6 +33,8 @@ pub fn configure_routes() -> Router<ServiceState> {
user::update_registration_status,
user::update_season,
user::update_approved,
tournament_settings::get_tournament_settings,
tournament_settings::update_tournament_settings,
),
components(
schemas(
@@ -45,11 +49,16 @@ pub fn configure_routes() -> Router<ServiceState> {
crate::dto::ApiResponseSchema<crate::api::auth::AuthResponse>,
crate::dto::PaginatedResponseSchema<crate::dto::user::UserResponse>,
user::PaginatedUserResponse,
crate::dto::tournament_settings::TournamentSettingResponse,
crate::dto::tournament_settings::UpdateTournamentSettingRequest,
crate::dto::tournament_settings::TournamentSettingQueryParams,
crate::api::tournament_settings::PaginatedTournamentSettingsResponse,
)
),
tags(
(name = "auth", description = "认证相关API"),
(name = "users", description = "用户管理API"),
(name = "tournament-settings", description = "比赛设置API"),
)
)]
pub struct ApiDoc;

View File

@@ -0,0 +1,137 @@
use axum::{
Json, Router,
extract::{Query, State},
routing::{get, put},
};
use utoipa::ToSchema;
use crate::{
dto::{
ApiResponse,
tournament_settings::{
TournamentSettingQueryParams, TournamentSettingResponse, UpdateTournamentSettingRequest,
},
},
error::Result,
middleware::auth::{AuthUser, require_admin},
service::{ServiceState, tournament_settings::TournamentSettingService},
};
pub fn routes() -> Router<ServiceState> {
Router::new()
.route("/", get(get_tournament_settings))
.route("/", put(update_tournament_settings))
}
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
pub struct PaginatedTournamentSettingsResponse {
pub items: Vec<TournamentSettingResponse>,
pub total: u64,
pub page: u64,
pub page_size: u64,
pub total_pages: u64,
}
#[utoipa::path(
get,
path = "/api/tournament-settings",
params(TournamentSettingQueryParams),
responses(
(status = 200, description = "获取比赛设置列表成功", body = PaginatedTournamentSettingsResponse),
(status = 401, description = "未授权"),
(status = 500, description = "内部服务器错误"),
),
tag = "tournament-settings"
)]
async fn get_tournament_settings(
State(state): State<ServiceState>,
Query(params): Query<TournamentSettingQueryParams>,
) -> Result<Json<ApiResponse<PaginatedTournamentSettingsResponse>>> {
let service = TournamentSettingService::new(state.db);
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20);
let (settings, total) = service.find_all(params).await?;
let response = PaginatedTournamentSettingsResponse {
items: settings
.into_iter()
.map(|s| TournamentSettingResponse {
id: s.id,
tournament_name: s.tournament_name,
max_pp_for_registration: s.max_pp_for_registration,
min_pp_for_registration: s.min_pp_for_registration,
current_season: s.current_season,
current_season_stage: s.current_season_stage,
mappool_visible: s.mappool_visible,
created_at: s.created_at.to_rfc3339(),
updated_at: s.updated_at.to_rfc3339(),
})
.collect(),
total,
page,
page_size,
total_pages: (total as f64 / page_size as f64).ceil() as u64,
};
Ok(Json(ApiResponse::success(response)))
}
#[utoipa::path(
put,
path = "/api/tournament-settings",
request_body = UpdateTournamentSettingRequest,
responses(
(status = 200, description = "更新比赛设置成功", body = TournamentSettingResponse),
(status = 400, description = "请求参数错误"),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足,需要管理员权限"),
(status = 500, description = "内部服务器错误"),
),
tag = "tournament-settings"
)]
async fn update_tournament_settings(
State(state): State<ServiceState>,
auth_user: AuthUser,
Json(data): Json<UpdateTournamentSettingRequest>,
) -> Result<Json<ApiResponse<TournamentSettingResponse>>> {
// 验证用户是否为admin
require_admin(&auth_user)?;
let service = TournamentSettingService::new(state.db);
// 检查是否存在设置,如果不存在则初始化
let settings = match service.get_current_settings().await? {
Some(existing) => {
// 更新现有设置
service.update(existing.id, data).await?
}
None => {
// 初始化新设置
let create_data = crate::dto::tournament_settings::CreateTournamentSettingRequest {
tournament_name: data
.tournament_name
.unwrap_or_else(|| "Default Tournament".to_string()),
max_pp_for_registration: data.max_pp_for_registration,
min_pp_for_registration: data.min_pp_for_registration,
current_season: data.current_season,
current_season_stage: data.current_season_stage,
mappool_visible: data.mappool_visible,
};
service.create(create_data).await?
}
};
let response = TournamentSettingResponse {
id: settings.id,
tournament_name: settings.tournament_name,
max_pp_for_registration: settings.max_pp_for_registration,
min_pp_for_registration: settings.min_pp_for_registration,
current_season: settings.current_season,
current_season_stage: settings.current_season_stage,
mappool_visible: settings.mappool_visible,
created_at: settings.created_at.to_rfc3339(),
updated_at: settings.updated_at.to_rfc3339(),
};
Ok(Json(ApiResponse::success(response)))
}

View File

@@ -9,11 +9,13 @@ use crate::{
dto::{
ApiResponse, PaginatedResponse,
user::{
CreateUserRequest, UpdateApprovedRequest, UpdateRegistrationStatusRequest, UpdateSeasonRequest, UpdateUserGroupRequest, UpdateUserRequest, UserQueryParams,
CreateUserRequest, UpdateApprovedRequest, UpdateRegistrationStatusRequest,
UpdateSeasonRequest, UpdateUserGroupRequest, UpdateUserRequest, UserQueryParams,
UserResponse,
},
},
error::Result,
middleware::auth::{AuthUser, require_admin},
service::{ServiceState, user::UserService},
};
@@ -114,11 +116,25 @@ async fn create_user() -> Result<Json<ApiResponse<UserResponse>>> {
),
tag = "users"
)]
#[axum::debug_handler]
async fn update_user(
State(state): State<ServiceState>,
Path(id): Path<i32>,
auth_user: AuthUser,
Json(data): Json<UpdateUserRequest>,
) -> Result<Json<ApiResponse<UserResponse>>> {
// 检查是否为管理员,或者是否更新自己的信息
// 注意这里需要从数据库获取用户的osu_id来匹配
// 暂时简化处理,实际项目中需要查询数据库
let is_admin = auth_user.user_group == "admin";
let is_self = auth_user.osu_id == id.to_string(); // 这里需要根据实际情况调整
if !is_admin && !is_self {
return Err(crate::error::AppError::Forbidden(
"只有管理员或用户本人可以更新用户信息".into(),
));
}
let service = UserService::new(state.db);
let user = service.update(id, data).await?;
@@ -141,11 +157,16 @@ async fn update_user(
),
tag = "users"
)]
#[axum::debug_handler]
async fn update_user_group(
State(state): State<ServiceState>,
Path(id): Path<i32>,
user: AuthUser,
Json(data): Json<UpdateUserGroupRequest>,
) -> Result<Json<ApiResponse<UserResponse>>> {
// 检查是否为管理员
require_admin(&user)?;
let service = UserService::new(state.db);
let user = service.update_user_group(id, data).await?;
@@ -169,7 +190,11 @@ async fn update_user_group(
async fn delete_user(
State(state): State<ServiceState>,
Path(id): Path<i32>,
user: AuthUser,
) -> Result<Json<ApiResponse<()>>> {
// 检查是否为管理员
require_admin(&user)?;
let service = UserService::new(state.db);
service.delete(id).await?;
@@ -244,16 +269,18 @@ async fn update_season(
),
tag = "users"
)]
#[axum::debug_handler]
async fn update_approved(
State(state): State<ServiceState>,
Path(id): Path<i32>,
user: AuthUser,
Json(data): Json<UpdateApprovedRequest>,
) -> Result<Json<ApiResponse<UserResponse>>> {
// 检查是否为管理员
require_admin(&user)?;
let service = UserService::new(state.db);
// 这里需要从认证中间件获取用户信息,检查是否为管理员
// 暂时硬编码为true实际项目中需要从JWT令牌中解析
let is_admin = true;
let user = service.update_approved(id, data, is_admin).await?;
let user = service.update_approved(id, data, true).await?;
Ok(Json(ApiResponse::success(UserResponse::from(user))))
}

View File

@@ -21,9 +21,22 @@ pub struct UpdateTournamentSettingRequest {
pub mappool_visible: Option<i32>,
}
#[derive(Debug, Deserialize, ToSchema)]
#[derive(Debug, Deserialize, ToSchema, utoipa::IntoParams)]
pub struct TournamentSettingQueryParams {
pub page: Option<u64>,
pub page_size: Option<u64>,
pub current_season: Option<String>,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct TournamentSettingResponse {
pub id: i32,
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>,
pub created_at: String,
pub updated_at: String,
}

View File

@@ -30,9 +30,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
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?;
@@ -42,8 +39,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let service_state = ServiceState::new(db);
// 配置应用
let app = api::configure_routes()
.with_state(service_state);
let app = api::configure_routes().with_state(service_state);
println!("配置加载成功");
// 启动服务器
let listener = tokio::net::TcpListener::bind(format!("{}:{}", config.host, config.port))
@@ -51,6 +49,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.unwrap();
println!("服务器启动成功");
println!("监听地址: {}:{}", config.host, config.port);
println!("数据库: {}", mask_database_url(&config.database_url));
println!("API文档: http://{}:{}/api/docs", config.host, config.port);
println!("健康检查: http://{}:{}/health", config.host, config.port);

View File

@@ -0,0 +1,57 @@
use axum::extract::FromRequestParts;
use serde::{Deserialize, Serialize};
use crate::error::AppError;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthUser {
pub id: i32,
pub osu_id: String,
pub username: String,
pub user_group: String,
}
impl<S> FromRequestParts<S> for AuthUser
where
S: Send + Sync + Clone + 'static,
{
type Rejection = AppError;
fn from_request_parts(
parts: &mut axum::http::request::Parts,
_state: &S,
) -> impl std::future::Future<Output = std::result::Result<Self, Self::Rejection>> + Send {
async move {
let auth_header = parts
.headers
.get("Authorization")
.and_then(|header| header.to_str().ok())
.ok_or_else(|| AppError::Unauthorized("Authorization header missing".into()))?;
if !auth_header.starts_with("Bearer ") {
return Err(AppError::Unauthorized(
"Invalid authorization format".into(),
));
}
let token = &auth_header[7..];
let claims = crate::utils::jwt::verify_token(token)
.map_err(|_| AppError::Unauthorized("Invalid or expired token".into()))?;
Ok(AuthUser {
id: 0,
osu_id: claims.sub,
username: String::new(),
user_group: claims.user_group,
})
}
}
}
pub fn require_admin(user: &AuthUser) -> std::result::Result<(), AppError> {
if user.user_group != "admin" {
return Err(AppError::Forbidden("只有管理员才能执行此操作".into()));
}
Ok(())
}

View File

@@ -16,6 +16,10 @@ pub struct ServiceState {
pub db: DatabaseConnection,
}
/// 确保 ServiceState 实现了 Send + Sync traits
unsafe impl Send for ServiceState {}
unsafe impl Sync for ServiceState {}
impl ServiceState {
/// 创建新的服务状态
pub fn new(db: DatabaseConnection) -> Self {

View File

@@ -50,7 +50,7 @@ impl TournamentSettingService {
tournament_settings::Entity::find_by_id(id)
.one(&self.db)
.await?
.ok_or_else(|| AppError::NotFound(format!("锦标赛设置 {} 不存在", id)))
.ok_or_else(|| AppError::NotFound(format!("赛设置 {} 不存在", id)))
}
pub async fn create(
@@ -116,7 +116,7 @@ impl TournamentSettingService {
.exec(&self.db)
.await?;
if result.rows_affected == 0 {
return Err(AppError::NotFound(format!("锦标赛设置 {} 不存在", id)));
return Err(AppError::NotFound(format!("赛设置 {} 不存在", id)));
}
Ok(())
}

View File

@@ -207,4 +207,13 @@ impl UserService {
Ok(user.update(&self.db).await?)
}
pub async fn update_gu_server_info(&self, id: i32, gu_user_id: i32, gu_username: String) -> Result<user::Model> {
let mut user: user::ActiveModel = self.find_by_id(id).await?.into();
user.gu_server_user_id = Set(Some(gu_user_id));
user.gu_server_username = Set(Some(gu_username));
user.updated_at = Set(chrono::Utc::now());
Ok(user.update(&self.db).await?)
}
}