清空ai写的,我想自己写

This commit is contained in:
2026-02-21 18:15:53 +08:00
parent 0127c3a191
commit 0a3b9877bf
88 changed files with 489 additions and 6311 deletions

27
.env
View File

@@ -1,27 +0,0 @@
# Database Configuration
DATABASE_URL=mysql://root:114514@localhost:3306/astarcupDev
# OAuth Configuration
OSU_CLIENT_ID = 43674
OSU_CLIENT_SECRET = 1HAJGGeerfrXficVtVWN5wD0KbyKnbeiyhCCXLwp
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
# Server Configuration
PORT=3001
HOST=localhost
LOG_LEVEL=info
# CORS Configuration
CORS_ORIGIN=http://localhost:3000
# Redis Configuration (Optional)
REDIS_URL=redis://localhost:6379

View File

@@ -1,71 +0,0 @@
[package]
name = "astracup-backend"
version = "0.0.1"
edition = "2024"
authors = ["AstraCup Team"]
description = "AstraCup tournament management backend"
license = "MIT"
repository = "https://github.com/AECBanana/astracup-web"
[dependencies]
# Web framework
axum = { version = "0.8.8", features = ["macros"] }
tokio = { version = "1.49", features = ["full"] }
tower = "0.5.3"
tower-http = { version = "0.6.8", features = ["cors", "trace"] }
# Database
sea-orm = { version = "1.1.19", features = ["sqlx-mysql", "runtime-tokio-native-tls", "macros"] }
sqlx = { version = "0.8.6", features = ["mysql", "runtime-tokio-native-tls"] }
dotenv = "0.15"
# Serialization
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
# Authentication and security
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"] }
# Configuration
config = "0.15"
anyhow = "1.0"
thiserror = "2.0"
# Logging and tracing
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Validation
validator = { version = "0.20", features = ["derive"] }
# Utilities
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"] }
decimal = "2.1.0"
urlencoding = "2.1.3"
[dev-dependencies]
# Testing
tokio = { version = "1.0", features = ["full"] }
reqwest = { version = "0.13", features = ["json"] }
mockall = "0.14"
[profile.dev]
opt-level = 0
debug = true
[profile.release]
opt-level = 3
lto = true
codegen-units = 1

View File

@@ -1,212 +0,0 @@
# 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文档

View File

@@ -17,6 +17,3 @@ LOG_LEVEL=info
# CORS Configuration
CORS_ORIGIN=http://localhost:3000
# Redis Configuration (Optional)
REDIS_URL=redis://localhost:6379

File diff suppressed because it is too large Load Diff

31
astarcup/Cargo.toml Normal file
View File

@@ -0,0 +1,31 @@
[package]
name = "astarcup"
version = "0.1.0"
authors = ["anonymous <foo@bar.com>"]
edition = "2021"
[dependencies]
dotenv = "0.15.0"
serde_json = "1.0.48"
thiserror = "1.0.38"
tracing = "0.1.37"
[dependencies.axum]
features = []
version = "0.6.18"
[dependencies.serde]
features = ["derive"]
version = "1.0.105"
[dependencies.tokio]
features = ["full"]
version = "1.28.2"
[dependencies.tower-http]
features = ["cors", "trace"]
version = "0.3.5"
[dependencies.sea-orm]
features = ["sqlx-postgres","macros","chrono"]
version = "1.1.19"

View File

@@ -18,8 +18,8 @@ features = [
# View the list of supported features at https://www.sea-ql.org/SeaORM/docs/install-and-config/database-and-async-runtime.
# e.g.
# "runtime-tokio-rustls", # `ASYNC_RUNTIME` feature
# "sqlx-postgres", # `DATABASE_DRIVER` feature
"sqlx-mysql",
"sqlx-postgres",
# "sqlx-mysql",
"runtime-tokio-native-tls",
]
version = "2.0.0-rc.31"

View File

@@ -25,27 +25,34 @@ impl MigrationTrait for Migration {
.not_null()
.unique_key(),
)
.col(
ColumnDef::new(User::RegisteredAt)
.timestamp()
.not_null()
.default(Expr::current_timestamp()),
)
.col(ColumnDef::new(User::AvatarUrl).string().null())
.col(ColumnDef::new(User::CoverUrl).string().null())
.col(ColumnDef::new(User::Pp).float().null())
.col(ColumnDef::new(User::GlobalRank).integer().null())
.col(ColumnDef::new(User::Country).string().null())
.col(ColumnDef::new(User::CountryCode).string().null())
.col(ColumnDef::new(User::CountryRank).integer().null())
.col(ColumnDef::new(User::Approved).integer().null())
.col(
ColumnDef::new(User::UserState)
.enumeration(
"userstate",
vec!["active", "approved", "banned", "ignored"],
)
.not_null()
.default("active"),
)
.col(
ColumnDef::new(User::UserGroup)
.enumeration("usergroup", vec!["player", "admin"])
.enumeration(
"usergroup",
vec!["player", "admin", "mappooler", "tester"],
)
.not_null()
.default("player"),
)
.col(ColumnDef::new(User::Season).integer().null())
.col(ColumnDef::new(User::GuServerUserId).integer().null())
.col(ColumnDef::new(User::GuServerUsername).string().null())
.col(ColumnDef::new(User::GuServerBindAt).timestamp().null())
.col(
ColumnDef::new(User::CreatedAt)
.timestamp()
@@ -58,6 +65,7 @@ impl MigrationTrait for Migration {
.not_null()
.default(Expr::current_timestamp()),
)
.col(ColumnDef::new(User::CreatedAt).timestamp().null())
.to_owned(),
)
.await?;
@@ -78,13 +86,11 @@ enum User {
Id,
OsuId,
Username,
RegisteredAt,
CoverUrl,
AvatarUrl,
Pp,
GlobalRank,
Country,
CountryRank,
Approved,
UserState,
UserGroup,
Season,
GuServerUserId,

View File

@@ -24,47 +24,36 @@ impl MigrationTrait for Migration {
.not_null(),
)
.col(
ColumnDef::new(MapSelections::BeatmapsetId)
ColumnDef::new(MapSelections::BeatmapSetId)
.integer()
.not_null(),
)
.col(ColumnDef::new(MapSelections::Title).string().not_null())
.col(ColumnDef::new(MapSelections::Title).string().null())
.col(ColumnDef::new(MapSelections::TitleUnicode).string().null())
.col(ColumnDef::new(MapSelections::Artist).string().not_null())
.col(ColumnDef::new(MapSelections::Artist).string().null())
.col(ColumnDef::new(MapSelections::ArtistUnicode).string().null())
.col(ColumnDef::new(MapSelections::Version).string().not_null())
.col(ColumnDef::new(MapSelections::Creator).string().not_null())
.col(ColumnDef::new(MapSelections::Version).string().null())
.col(ColumnDef::new(MapSelections::Creator).string().null())
.col(ColumnDef::new(MapSelections::StarRating).decimal().null())
.col(ColumnDef::new(MapSelections::Bpm).decimal().null())
.col(ColumnDef::new(MapSelections::TotalLength).integer().null())
.col(ColumnDef::new(MapSelections::MaxCombo).integer().null())
.col(ColumnDef::new(MapSelections::Ar).decimal().null())
.col(ColumnDef::new(MapSelections::Cs).decimal().null())
.col(ColumnDef::new(MapSelections::Od).decimal().null())
.col(ColumnDef::new(MapSelections::Hp).decimal().null())
.col(ColumnDef::new(MapSelections::SelectedMods).json().null())
.col(
ColumnDef::new(MapSelections::StarRating)
.decimal()
.not_null(),
)
.col(ColumnDef::new(MapSelections::Bpm).decimal().not_null())
.col(
ColumnDef::new(MapSelections::TotalLength)
ColumnDef::new(MapSelections::SelectedModPosition)
.integer()
.not_null(),
)
.col(ColumnDef::new(MapSelections::MaxCombo).integer().not_null())
.col(ColumnDef::new(MapSelections::Ar).decimal().not_null())
.col(ColumnDef::new(MapSelections::Cs).decimal().not_null())
.col(ColumnDef::new(MapSelections::Od).decimal().not_null())
.col(ColumnDef::new(MapSelections::Hp).decimal().not_null())
.col(
ColumnDef::new(MapSelections::SelectedMods)
.string()
.not_null(),
)
.col(
ColumnDef::new(MapSelections::ModPosition)
.integer()
.not_null(),
.null(),
)
.col(ColumnDef::new(MapSelections::Comment).text().null())
.col(
ColumnDef::new(MapSelections::SelectedBy)
.string()
.not_null(),
.col(ColumnDef::new(MapSelections::SelectedBy).integer().null())
.foreign_key(
ForeignKey::create()
.from(MapSelections::Table, MapSelections::SelectedBy)
.to(Users::Table, Users::Id),
)
.col(
ColumnDef::new(MapSelections::SelectedAt)
@@ -73,10 +62,19 @@ impl MigrationTrait for Migration {
)
.col(ColumnDef::new(MapSelections::Season).string().not_null())
.col(ColumnDef::new(MapSelections::Category).string().not_null())
.col(ColumnDef::new(MapSelections::Url).text().not_null())
.col(ColumnDef::new(MapSelections::CoverUrl).text().null())
.col(ColumnDef::new(MapSelections::Approved).integer().null())
.col(ColumnDef::new(MapSelections::Padding).integer().null())
.col(
ColumnDef::new(MapSelections::IsApproved)
.boolean()
.null()
.default(Expr::bool(false)),
)
.col(
ColumnDef::new(MapSelections::IsNeedTest)
.boolean()
.null()
.default(Expr::bool(false)),
)
.col(
ColumnDef::new(MapSelections::CreatedAt)
.timestamp()

View File

@@ -18,38 +18,17 @@ impl MigrationTrait for Migration {
.auto_increment()
.primary_key(),
)
.col(
ColumnDef::new(MatchRooms::RoomName)
.string()
.not_null(),
)
.col(
ColumnDef::new(MatchRooms::RoundNumber)
.integer()
.not_null(),
)
.col(
ColumnDef::new(MatchRooms::MatchDate)
.date()
.not_null(),
)
.col(
ColumnDef::new(MatchRooms::MatchTime)
.time()
.not_null(),
)
.col(
ColumnDef::new(MatchRooms::MatchNumber)
.integer()
.not_null(),
)
.col(ColumnDef::new(MatchRooms::RoundNumber).integer().not_null())
.col(ColumnDef::new(MatchRooms::MatchDate).date().not_null())
.col(ColumnDef::new(MatchRooms::MatchTime).time().not_null())
.col(ColumnDef::new(MatchRooms::MatchNumber).integer().not_null())
.col(ColumnDef::new(MatchRooms::MaxParticipants).integer().null())
.col(ColumnDef::new(MatchRooms::Status).enumeration("status", vec!["available", "scheduled", "completed"]).null())
.col(
ColumnDef::new(MatchRooms::CreatedBy)
.string()
.not_null(),
ColumnDef::new(MatchRooms::Status)
.enumeration("status", vec!["available", "scheduled", "completed"])
.null(),
)
.col(ColumnDef::new(MatchRooms::CreatedBy).string().not_null())
.col(
ColumnDef::new(MatchRooms::CreatedAt)
.timestamp()

View File

@@ -19,13 +19,7 @@ impl MigrationTrait for Migration {
.primary_key(),
)
.col(ColumnDef::new(Messages::SenderOsuId).string().not_null())
.col(ColumnDef::new(Messages::SenderUsername).string().not_null())
.col(ColumnDef::new(Messages::ReceiverOsuId).string().not_null())
.col(
ColumnDef::new(Messages::ReceiverUsername)
.string()
.not_null(),
)
.col(
ColumnDef::new(Messages::Type)
.enumeration(

58
astarcup/src/main.rs Normal file
View File

@@ -0,0 +1,58 @@
mod extensions;
mod middlewares;
mod routes;
mod utils;
use sea_orm::{sqlx::database, Database, DatabaseConnection, DbErr};
use std::net::SocketAddr;
#[tokio::main]
async fn main() {
dotenv::dotenv().ok();
let port = std::env::var("PORT")
.ok()
.map(|e| e.parse().ok())
.flatten()
.unwrap_or(8080);
let host: std::net::IpAddr = std::env::var("HOST")
.ok()
.map(|e| e.parse().ok())
.flatten()
.unwrap_or("127.0.0.1".parse().unwrap());
let app = routes::root::router::get_router().await;
// Run app on local server
let address = SocketAddr::from((host, port));
print_banner();
println!(
"Server running on http://{}:{}",
&address.ip(),
&address.port()
);
// start server
connect_database().await.unwrap();
axum::Server::bind(&address)
.serve(app.into_make_service())
.await
.unwrap();
}
fn print_banner() {
println!(r" _ _ ____ ");
println!(r" / \ ___| |_ __ _ _ __ / ____ _ _ __ ");
println!(r" / _ \ / __| __/ _` | '__| | | | | | '_ \ ");
println!(r" / ___ \\__ \ || (_| | | | |__| |_| | |_) |");
println!(r" /_/ \_\___/\__\__,_|_| \____\__,_| .__/ ");
println!(r" |_| ");
println!(r"==============================================");
}
async fn connect_database() -> Result<DatabaseConnection, DbErr> {
let database_url = std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "postgres://root:pass@localhost:5432/db".to_string());
let database = Database::connect(&database_url).await?;
Ok(database)
}

View File

@@ -0,0 +1,2 @@
pub(crate) mod root;
pub(crate) mod user;

View File

@@ -0,0 +1,6 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HealthReponse {
pub server_ok: bool,
}

View File

@@ -0,0 +1 @@
pub(crate) mod health_response;

View File

@@ -0,0 +1,4 @@
pub(crate) mod dtos;
pub(crate) mod router;
pub(crate) mod service;
pub(crate) mod tests;

View File

@@ -0,0 +1,50 @@
use std::time::Duration;
use axum::body::{Body, BoxBody};
use axum::Json;
use axum::{
http::{Request, Response},
response::IntoResponse,
routing::get,
Router,
};
use tower_http::trace::TraceLayer;
use tracing::Span;
pub(crate) async fn get_router() -> Router {
let trace = TraceLayer::new_for_http()
.on_request(|request: &Request<Body>, _span: &Span| {
println!("{} {} started", request.method(), request.uri().path());
println!("request: {request:?}",);
})
.on_response(
|response: &Response<BoxBody>, latency: Duration, _span: &Span| {
println!("response generated in {latency:?}",);
println!("response: {response:?}",);
},
);
// Root Routing
let router = Router::new()
// .route("/", get(index))
.route("/health", get(health))
.nest("/user", crate::routes::user::router::get_router().await)
// Append the new route here. (don't delete this comment)
.layer(trace);
router
}
// async fn index() -> Html<&'static str> {
// Html("<h1>Hello, World!</h1>")
// }
use super::service::RootService;
async fn health() -> impl IntoResponse {
let service = RootService::new();
let response = service.get_health();
Json(response).into_response()
}

View File

@@ -0,0 +1,15 @@
use super::dtos::health_response::HealthReponse;
pub struct RootService {}
impl RootService {
pub fn new() -> Self {
Self {}
}
pub fn get_health(&self) -> HealthReponse {
let server_ok = true;
HealthReponse { server_ok }
}
}

View File

@@ -0,0 +1 @@
pub(crate) mod service;

View File

@@ -0,0 +1,12 @@
#![cfg(test)]
use crate::routes::root::service::RootService;
#[test]
pub fn get_health_check() {
let service = RootService::new();
let response = service.get_health();
assert_eq!(response.server_ok, true);
}

View File

@@ -0,0 +1 @@
pub(crate) mod user;

View File

@@ -0,0 +1,7 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub struct User {
pub user_id: i32,
pub user_name: String,
}

View File

@@ -0,0 +1,4 @@
pub(crate) mod dtos;
pub(crate) mod router;
pub(crate) mod service;
pub(crate) mod tests;

View File

@@ -0,0 +1,25 @@
use axum::{extract::Path, response::IntoResponse, routing::get, Json, Router};
use super::service::UserService;
pub(crate) async fn get_router() -> Router {
Router::new()
.route("/", get(get_user_list))
.route("/:user_id", get(find_user_by_id))
}
async fn get_user_list() -> impl IntoResponse {
let service = UserService::new();
let response = service.find_user_list();
Json(response).into_response()
}
async fn find_user_by_id(Path(user_id): Path<i32>) -> impl IntoResponse {
let service = UserService::new();
let response = service.find_user_by_id(user_id);
Json(response).into_response()
}

View File

@@ -0,0 +1,33 @@
use super::dtos::user::User;
pub struct UserService {}
impl UserService {
pub fn new() -> Self {
Self {}
}
pub fn find_user_list(&self) -> Vec<User> {
let user_list = vec![
User {
user_id: 1,
user_name: "test".to_string(),
},
User {
user_id: 2,
user_name: "test2".to_string(),
},
];
user_list
}
pub fn find_user_by_id(&self, id: i32) -> User {
let user = User {
user_id: id,
user_name: "test".to_string(),
};
user
}
}

View File

@@ -0,0 +1 @@
pub(crate) mod service;

View File

@@ -0,0 +1,24 @@
#![cfg(test)]
use crate::routes::user::service::UserService;
#[test]
pub fn get_user_by_id() {
let service = UserService::new();
let user = service.find_user_by_id(10);
assert_eq!(user.user_id, 10);
}
#[test]
pub fn get_user_list() {
let service = UserService::new();
let list = service.find_user_list();
let mut other = list.clone();
other.sort_by(|lhs, rhs| lhs.user_id.cmp(&rhs.user_id));
assert_eq!(list, other);
}

View File

@@ -0,0 +1 @@

View File

@@ -1,568 +0,0 @@
use crate::middleware::auth::AuthUser;
use axum::{
Json, Router,
extract::{Query, State},
response::Redirect,
routing::get,
};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::env;
use uuid::Uuid;
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))
.route("/gu", get(gu_auth))
.route("/callback/gu", get(gu_callback))
.route("/refresh", get(refresh_token))
.route("/logout", get(logout))
}
#[derive(Debug, Deserialize)]
struct OsuCallbackQuery {
code: String,
state: String,
}
#[derive(Debug, Deserialize)]
struct RefreshTokenQuery {
server: Option<String>,
}
#[derive(Debug, Deserialize)]
struct OsuTokenResponse {
access_token: String,
token_type: String,
expires_in: u32,
refresh_token: String,
scope: String,
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct AuthResponse {
access_token: String,
refresh_token: String,
user: crate::dto::user::UserResponse,
}
#[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/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",
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(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,
};
let updated_user = user_service.update(existing.id, update_request).await?;
// 保存 OSU refresh_token
user_service
.update_osu_refresh_token(updated_user.id, token_response.refresh_token.clone())
.await?;
updated_user
}
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),
};
let new_user = user_service.create(user_data.id, create_request).await?;
// 保存 OSU refresh_token
user_service
.update_osu_refresh_token(new_user.id, token_response.refresh_token.clone())
.await?;
new_user
}
};
// 生成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",
params(
("server" = Option<String>, Query, description = "服务器类型可选值osu, gu默认为osu"),
),
responses(
(status = 200, description = "令牌刷新成功"),
(status = 400, description = "请求参数错误"),
(status = 401, description = "未授权或refresh_token过期"),
(status = 500, description = "内部服务器错误"),
),
tag = "auth"
)]
async fn refresh_token(
auth_user: AuthUser,
Query(params): Query<RefreshTokenQuery>,
State(state): State<ServiceState>,
) -> Result<Json<ApiResponse<AuthResponse>>> {
let server = params.server.unwrap_or_else(|| "osu".to_string());
if server != "osu" && server != "gu" {
return Err(crate::error::AppError::ValidationError(
"server参数必须是'osu'或'gu'".into(),
));
}
let user_service = UserService::new(state.db);
// 根据服务器类型获取对应的refresh_token
let refresh_token = match server.as_str() {
"osu" => user_service.get_osu_refresh_token(auth_user.id).await?,
"gu" => user_service.get_gu_refresh_token(auth_user.id).await?,
_ => unreachable!(),
};
let refresh_token = refresh_token.ok_or_else(|| {
crate::error::AppError::Unauthorized(format!("{} refresh_token不存在", server))
})?;
// 根据服务器类型调用相应的API刷新令牌
let (client_id, client_secret, token_url, api_url) = match server.as_str() {
"osu" => (
env::var("OSU_CLIENT_ID").expect("OSU_CLIENT_ID not set"),
env::var("OSU_CLIENT_SECRET").expect("OSU_CLIENT_SECRET not set"),
"https://osu.ppy.sh/oauth/token",
"https://osu.ppy.sh/api/v2/me",
),
"gu" => (
env::var("GU_CLIENT_ID").expect("GU_CLIENT_ID not set"),
env::var("GU_CLIENT_SECRET").expect("GU_CLIENT_SECRET not set"),
"https://lazer-api.g0v0.top/oauth/token",
"https://lazer-api.g0v0.top/api/v2/me",
),
_ => unreachable!(),
};
let client = Client::new();
// 使用refresh_token获取新的访问令牌
let token_response = client
.post(token_url)
.json(&serde_json::json!({
"client_id": client_id,
"client_secret": client_secret,
"refresh_token": refresh_token,
"grant_type": "refresh_token"
}))
.send()
.await
.map_err(|e| {
// 如果refresh_token过期返回401提示重新登录
if e.status() == Some(reqwest::StatusCode::UNAUTHORIZED) {
crate::error::AppError::Unauthorized(format!(
"{} refresh_token已过期请重新登录",
server
))
} else {
crate::error::AppError::ExternalApiError(format!("Failed to refresh token: {}", e))
}
})?
.json::<OsuTokenResponse>()
.await
.map_err(|e| {
crate::error::AppError::ExternalApiError(format!(
"Failed to parse token response: {}",
e
))
})?;
// 获取用户信息
let user_data = client
.get(api_url)
.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 update_request = crate::dto::user::UpdateUserRequest {
username: Some(user_data.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 user = user_service.update(auth_user.id, update_request).await?;
// 更新数据库中的refresh_token
match server.as_str() {
"osu" => {
user_service
.update_osu_refresh_token(auth_user.id, token_response.refresh_token.clone())
.await?;
}
"gu" => {
user_service
.update_gu_refresh_token(auth_user.id, token_response.refresh_token.clone())
.await?;
}
_ => unreachable!(),
}
// 生成新的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/logout",
responses(
(status = 200, description = "登出成功"),
(status = 401, description = "未授权"),
),
tag = "auth"
)]
async fn logout(
auth_user: AuthUser,
State(state): State<ServiceState>,
) -> Result<Json<ApiResponse<()>>> {
// 清除用户的refresh_token
let user_service = UserService::new(state.db);
// 清除所有refresh_tokenOSU和GU
user_service.clear_all_refresh_tokens(auth_user.id).await?;
// 返回成功消息
Ok(Json(ApiResponse::success_with_message(
(),
"登出成功已清除OSU和GU refresh_token",
)))
}
#[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?;
// 保存 GU refresh_token
user_service
.update_gu_refresh_token(
updated_user_with_gu_info.id,
token_response.refresh_token.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?;
// 保存 GU refresh_token
user_service
.update_gu_refresh_token(
new_user_with_gu_info.id,
token_response.refresh_token.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

View File

View File

@@ -1,65 +0,0 @@
pub mod auth;
pub mod tournament_settings;
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())
.nest("/api/tournament-settings", tournament_settings::routes())
}
#[derive(OpenApi)]
#[openapi(
paths(
auth::osu_auth,
auth::osu_callback,
auth::refresh_token,
auth::logout,
user::get_users,
user::get_user,
user::get_current_user,
user::update_user,
user::update_user_group,
user::delete_user,
user::update_registration_status,
user::update_season,
user::update_approved,
tournament_settings::get_tournament_settings,
tournament_settings::update_tournament_settings,
),
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::ApiResponseSchema<crate::dto::user::UserResponse>,
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

@@ -1,136 +0,0 @@
use axum::{
Json, Router,
extract::{Query, State},
routing::{get, put},
};
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);
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);
// 检查是否存在设置,如果不存在则初始化
let settings = match service.get_current_settings_cached().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

@@ -1,360 +0,0 @@
use axum::{
Json, Router,
extract::{Path, Query, State},
routing::{delete, get, post, put},
};
use utoipa::ToSchema;
use crate::{
dto::{
ApiResponse, PaginatedResponse,
user::{
CreateUserRequest, UpdateApprovedRequest, UpdateRegistrationStatusRequest,
UpdateSeasonRequest, UpdateUserGroupRequest, UpdateUserRequest, UserQueryParams,
UserResponse,
},
},
error::Result,
middleware::auth::{AuthUser, require_admin},
service::{ServiceState, user::UserService},
};
pub fn routes() -> Router<ServiceState> {
Router::new()
.route("/", get(get_users))
.route("/", post(create_user))
.route("/me", get(get_current_user))
.route("/{osu_id}", get(get_user))
.route("/{osu_id}", put(update_user))
.route("/{osu_id}", delete(delete_user))
.route("/{osu_id}/user-group", put(update_user_group))
.route(
"/{osu_id}/registration-status",
put(update_registration_status),
)
.route("/{osu_id}/season", put(update_season))
.route("/{osu_id}/approve", put(update_approved))
}
#[utoipa::path(
get,
path = "/api/users",
params(UserQueryParams),
responses(
(status = 200, description = "获取osu玩家列表成功", 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/{osu_id}",
params(
("osu_id" = String, Path, description = "osuID")
),
responses(
(status = 200, description = "获取osu玩家成功", body = UserResponse),
(status = 404, description = "osu玩家不存在"),
),
tag = "users"
)]
async fn get_user(
State(state): State<ServiceState>,
Path(osu_id): Path<String>,
) -> Result<Json<ApiResponse<UserResponse>>> {
let service = UserService::new(state.db);
let user = service
.find_by_osu_id(&osu_id)
.await?
.ok_or_else(|| crate::error::AppError::NotFound(format!("osu玩家 {} 不存在", osu_id)))?;
Ok(Json(ApiResponse::success(UserResponse::from(user))))
}
#[utoipa::path(
get,
path = "/api/users/me",
responses(
(status = 200, description = "获取当前玩家信息成功", body = UserResponse),
(status = 401, description = "未授权"),
(status = 404, description = "玩家不存在"),
),
tag = "users"
)]
async fn get_current_user(
State(state): State<ServiceState>,
auth_user: AuthUser,
) -> Result<Json<ApiResponse<UserResponse>>> {
let service = UserService::new(state.db);
let user = service
.find_by_osu_id(&auth_user.osu_id)
.await?
.ok_or_else(|| {
crate::error::AppError::NotFound(format!("玩家 {} 不存在", auth_user.osu_id))
})?;
Ok(Json(ApiResponse::success(UserResponse::from(user))))
}
#[utoipa::path(
post,
path = "/api/users",
request_body = CreateUserRequest,
responses(
(status = 201, description = "创建osu玩家成功", body = UserResponse),
(status = 400, description = "请求参数错误"),
(status = 401, description = "未授权"),
),
tag = "users"
)]
async fn create_user() -> Result<Json<ApiResponse<UserResponse>>> {
// osu玩家创建将通过OAuth回调处理
Err(crate::error::AppError::BusinessError(
"请通过OAuth注册osu玩家".into(),
))
}
#[utoipa::path(
put,
path = "/api/users/{osu_id}",
params(
("osu_id" = String, Path, description = "osu玩家ID")
),
request_body = UpdateUserRequest,
responses(
(status = 200, description = "更新osu玩家成功", body = UserResponse),
(status = 400, description = "请求参数错误"),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
(status = 404, description = "osu玩家不存在"),
),
tag = "users"
)]
#[axum::debug_handler]
async fn update_user(
State(state): State<ServiceState>,
Path(osu_id): Path<String>,
auth_user: AuthUser,
Json(data): Json<UpdateUserRequest>,
) -> Result<Json<ApiResponse<UserResponse>>> {
let service = UserService::new(state.db);
// 获取要更新的osu玩家信息
let target_user = service
.find_by_osu_id(&osu_id)
.await?
.ok_or_else(|| crate::error::AppError::NotFound(format!("osu玩家 {} 不存在", osu_id)))?;
// 检查权限管理员或osu玩家本人可以更新
let is_admin = auth_user.user_group == "admin";
let is_self = auth_user.osu_id == target_user.osu_id;
if !is_admin && !is_self {
return Err(crate::error::AppError::Forbidden("权限错误".into()));
}
let user = service.update(target_user.id, data).await?;
Ok(Json(ApiResponse::success(UserResponse::from(user))))
}
#[utoipa::path(
put,
path = "/api/users/{osu_id}/user-group",
params(
("osu_id" = String, Path, description = "osuID")
),
request_body = UpdateUserGroupRequest,
responses(
(status = 200, description = "更新osu玩家组成功", body = UserResponse),
(status = 400, description = "请求参数错误"),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
(status = 404, description = "osu玩家不存在"),
),
tag = "users"
)]
#[axum::debug_handler]
async fn update_user_group(
State(state): State<ServiceState>,
Path(osu_id): Path<String>,
user: AuthUser,
Json(data): Json<UpdateUserGroupRequest>,
) -> Result<Json<ApiResponse<UserResponse>>> {
// 检查是否为管理员
require_admin(&user)?;
let service = UserService::new(state.db);
let target_user = service
.find_by_osu_id(&osu_id)
.await?
.ok_or_else(|| crate::error::AppError::NotFound(format!("osu玩家 {} 不存在", osu_id)))?;
let user = service.update_user_group(target_user.id, data).await?;
Ok(Json(ApiResponse::success(UserResponse::from(user))))
}
#[utoipa::path(
delete,
path = "/api/users/{osu_id}",
params(
("osu_id" = String, Path, description = "osuID")
),
responses(
(status = 200, description = "删除osu玩家成功"),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
(status = 404, description = "osu玩家不存在"),
),
tag = "users"
)]
async fn delete_user(
State(state): State<ServiceState>,
Path(osu_id): Path<String>,
user: AuthUser,
) -> Result<Json<ApiResponse<()>>> {
// 检查是否为管理员
require_admin(&user)?;
let service = UserService::new(state.db);
let target_user = service
.find_by_osu_id(&osu_id)
.await?
.ok_or_else(|| crate::error::AppError::NotFound(format!("osu玩家 {} 不存在", osu_id)))?;
service.delete(target_user.id).await?;
Ok(Json(ApiResponse::success_with_message(
(),
"osu玩家删除成功",
)))
}
#[utoipa::path(
put,
path = "/api/users/{osu_id}/registration-status",
params(
("osu_id" = String, Path, description = "osuID")
),
request_body = UpdateRegistrationStatusRequest,
responses(
(status = 200, description = "更新注册状态成功", body = UserResponse),
(status = 400, description = "请求参数错误"),
(status = 401, description = "未授权"),
(status = 404, description = "osu玩家不存在"),
),
tag = "users"
)]
async fn update_registration_status(
State(state): State<ServiceState>,
Path(osu_id): Path<String>,
Json(data): Json<UpdateRegistrationStatusRequest>,
) -> Result<Json<ApiResponse<UserResponse>>> {
let service = UserService::new(state.db);
let target_user = service
.find_by_osu_id(&osu_id)
.await?
.ok_or_else(|| crate::error::AppError::NotFound(format!("osu玩家 {} 不存在", osu_id)))?;
let user = service
.update_registration_status(target_user.id, data)
.await?;
Ok(Json(ApiResponse::success(UserResponse::from(user))))
}
#[utoipa::path(
put,
path = "/api/users/{osu_id}/season",
params(
("osu_id" = String, Path, description = "osuID")
),
request_body = UpdateSeasonRequest,
responses(
(status = 200, description = "更新赛季成功", body = UserResponse),
(status = 400, description = "请求参数错误"),
(status = 401, description = "未授权"),
(status = 404, description = "osu玩家不存在"),
),
tag = "users"
)]
async fn update_season(
State(state): State<ServiceState>,
Path(osu_id): Path<String>,
Json(data): Json<UpdateSeasonRequest>,
) -> Result<Json<ApiResponse<UserResponse>>> {
let service = UserService::new(state.db);
let target_user = service
.find_by_osu_id(&osu_id)
.await?
.ok_or_else(|| crate::error::AppError::NotFound(format!("osu玩家 {} 不存在", osu_id)))?;
let user = service.update_season(target_user.id, data).await?;
Ok(Json(ApiResponse::success(UserResponse::from(user))))
}
#[utoipa::path(
put,
path = "/api/users/{osu_id}/approve",
params(
("osu_id" = String, Path, description = "osuID")
),
request_body = UpdateApprovedRequest,
responses(
(status = 200, description = "更新审批状态成功", body = UserResponse),
(status = 400, description = "请求参数错误"),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
(status = 404, description = "osu玩家不存在"),
),
tag = "users"
)]
#[axum::debug_handler]
async fn update_approved(
State(state): State<ServiceState>,
Path(osu_id): Path<String>,
user: AuthUser,
Json(data): Json<UpdateApprovedRequest>,
) -> Result<Json<ApiResponse<UserResponse>>> {
// 检查是否为管理员
require_admin(&user)?;
let service = UserService::new(state.db);
let target_user = service
.find_by_osu_id(&osu_id)
.await?
.ok_or_else(|| crate::error::AppError::NotFound(format!("osu玩家 {} 不存在", osu_id)))?;
let user = service.update_approved(target_user.id, data, true).await?;
Ok(Json(ApiResponse::success(UserResponse::from(user))))
}
#[derive(ToSchema)]
pub struct PaginatedUserResponse {
items: Vec<UserResponse>,
total: u64,
page: u64,
page_size: u64,
total_pages: u64,
}

View File

@@ -1,96 +0,0 @@
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 gu_client_id: String,
pub gu_client_secret: String,
pub gu_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())
})?,
gu_client_id: env::var("GU_CLIENT_ID")
.map_err(|_| crate::error::AppError::ConfigError("GU_CLIENT_ID未设置".into()))?,
gu_client_secret: env::var("GU_CLIENT_SECRET").map_err(|_| {
crate::error::AppError::ConfigError("GU_CLIENT_SECRET未设置".into())
})?,
gu_redirect_uri: env::var("GU_REDIRECT_URI")
.map_err(|_| crate::error::AppError::ConfigError("GU_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)
}
}

View File

View File

@@ -1,23 +0,0 @@
use serde::Deserialize;
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

@@ -1,68 +0,0 @@
use serde::Deserialize;
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

@@ -1,37 +0,0 @@
use sea_orm::prelude::{Date, Time};
use serde::Deserialize;
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

@@ -1,30 +0,0 @@
use serde::Deserialize;
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

@@ -1,31 +0,0 @@
use serde::Deserialize;
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

@@ -1,112 +0,0 @@
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)]
pub struct ApiResponse<T> {
pub success: bool,
pub data: Option<T>,
pub message: Option<String>,
pub error: Option<String>,
}
impl<T> 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::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 {
#[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)]
pub struct PaginatedResponse<T> {
pub items: Vec<T>,
pub total: u64,
pub page: u64,
pub page_size: u64,
pub total_pages: u64,
}
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 {
items,
total,
page,
page_size,
total_pages,
}
}
}
#[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

@@ -1,24 +0,0 @@
use serde::Deserialize;
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

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

@@ -1,139 +0,0 @@
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 registration_status: Option<String>,
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,
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(),
updated_at: user.updated_at.to_rfc3339(),
}
}
}
#[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct UpdateUserGroupRequest {
pub user_group: String,
}
#[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>,
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,37 +0,0 @@
//! `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

@@ -1,62 +0,0 @@
//! `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,35 +0,0 @@
//! `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, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "match_rooms")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub room_name: String,
pub round_number: i32,
pub match_date: Date,
pub match_time: Time,
pub match_number: i32,
pub max_participants: Option<i32>,
pub status: Option<Status>,
pub created_by: String,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::match_schedules::Entity")]
MatchSchedules,
}
impl Related<super::match_schedules::Entity> for Entity {
fn to() -> RelationDef {
Relation::MatchSchedules.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -1,46 +0,0 @@
//! `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, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "match_schedules")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub room_id: i32,
pub player1_osu_id: String,
pub player1_username: String,
pub player2_osu_id: String,
pub player2_username: String,
pub red_score: Option<i32>,
pub blue_score: Option<i32>,
pub status: Option<Status>,
#[sea_orm(column_type = "Text", nullable)]
pub replay_link: Option<String>,
#[sea_orm(column_type = "Text", nullable)]
pub match_link: Option<String>,
pub created_by: String,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::match_rooms::Entity",
from = "Column::RoomId",
to = "super::match_rooms::Column::Id",
on_update = "NoAction",
on_delete = "NoAction"
)]
MatchRooms,
}
impl Related<super::match_rooms::Entity> for Entity {
fn to() -> RelationDef {
Relation::MatchRooms.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -1,31 +0,0 @@
//! `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, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "messages")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub sender_osu_id: String,
pub sender_username: String,
pub receiver_osu_id: String,
pub receiver_username: String,
pub r#type: Option<Type>,
pub title: String,
#[sea_orm(column_type = "Text")]
pub content: String,
pub related_matchup_id: Option<i32>,
pub status: Option<Status>,
pub response_action: Option<String>,
pub response_time: Option<DateTimeUtc>,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -1,13 +0,0 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
pub mod prelude;
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 sea_orm_active_enums;
pub mod tournament_settings;
pub mod user;

View File

@@ -1,24 +0,0 @@
//! `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, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "player_matchups")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub player1_osu_id: String,
pub player1_username: String,
pub player2_osu_id: String,
pub player2_username: String,
pub status: Option<Status>,
pub created_by: String,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -1,10 +0,0 @@
//! `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;
pub use super::match_rooms::Entity as MatchRooms;
pub use super::match_schedules::Entity as MatchSchedules;
pub use super::messages::Entity as Messages;
pub use super::player_matchups::Entity as PlayerMatchups;
pub use super::tournament_settings::Entity as TournamentSettings;
pub use super::user::Entity as User;

View File

@@ -1,43 +0,0 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
use sea_orm::entity::prelude::*;
#[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum)]
#[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "status")]
pub enum Status {
#[sea_orm(string_value = "available")]
Available,
#[sea_orm(string_value = "scheduled")]
Scheduled,
#[sea_orm(string_value = "completed")]
Completed,
}
#[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum)]
#[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "type")]
pub enum Type {
#[sea_orm(string_value = "match_invitation")]
MatchInvitation,
#[sea_orm(string_value = "match_response")]
MatchResponse,
#[sea_orm(string_value = "system")]
System,
}
#[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum)]
#[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "user_group")]
pub enum UserGroup {
#[sea_orm(string_value = "player")]
Player,
#[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,25 +0,0 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "tournament_settings")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub tournament_name: String,
#[sea_orm(column_type = "Float", nullable)]
pub max_pp_for_registration: Option<f32>,
#[sea_orm(column_type = "Float", nullable)]
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: DateTimeUtc,
pub updated_at: DateTimeUtc,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -1,37 +0,0 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
use super::sea_orm_active_enums::{RegistrationStatus, UserGroup};
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "user")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[sea_orm(unique)]
pub osu_id: String,
#[sea_orm(unique)]
pub username: String,
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<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 osu_refresh_token: Option<String>,
pub gu_refresh_token: Option<String>,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -1,109 +0,0 @@
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),
#[error("功能未实现: {0}")]
NotImplemented(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(_) | AppError::NotImplemented(_) => {
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),
AppError::NotImplemented(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,87 +0,0 @@
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() -> 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();
// 加载配置
let config = Config::from_env()?;
print_banner();
println!("启动 AstraCup 后端服务...");
println!("连接数据库...");
let db = Database::connect(&config.database_url).await?;
println!("数据库连接成功");
// 创建服务状态
let service_state = ServiceState::new(db);
// 配置应用
let app = api::configure_routes().with_state(service_state);
println!("配置加载成功");
// 启动服务器
let listener = tokio::net::TcpListener::bind(format!("{}:{}", config.host, config.port))
.await
.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);
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()
}
}

View File

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

@@ -1,8 +0,0 @@
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())
}

View File

View File

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

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

@@ -1,135 +0,0 @@
use sea_orm::{
ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter,
QueryOrder, Set,
};
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

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

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

@@ -1,34 +0,0 @@
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;
use std::sync::Arc;
use tokio::sync::RwLock;
/// 服务层共享状态
#[derive(Clone)]
pub struct ServiceState {
pub db: DatabaseConnection,
pub tournament_settings_cache: Arc<RwLock<Option<crate::entity::tournament_settings::Model>>>,
}
/// 确保 ServiceState 实现了 Send + Sync traits
unsafe impl Send for ServiceState {}
unsafe impl Sync for ServiceState {}
impl ServiceState {
/// 创建新的服务状态
pub fn new(db: DatabaseConnection) -> Self {
Self {
db,
tournament_settings_cache: Arc::new(RwLock::new(None)),
}
}
}

View File

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

@@ -1,191 +0,0 @@
use sea_orm::{
ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter,
QueryOrder, Set,
};
use std::sync::Arc;
use tokio::sync::RwLock;
use crate::{
dto::tournament_settings::{
CreateTournamentSettingRequest, TournamentSettingQueryParams,
UpdateTournamentSettingRequest,
},
entity::tournament_settings,
error::{AppError, Result},
service::ServiceState,
};
pub struct TournamentSettingService {
db: DatabaseConnection,
current_settings_cache: Arc<RwLock<Option<tournament_settings::Model>>>,
}
impl TournamentSettingService {
pub fn new(state: ServiceState) -> Self {
Self {
db: state.db,
current_settings_cache: state.tournament_settings_cache,
}
}
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()
};
let result = tournament_setting.insert(&self.db).await?;
// 更新缓存
let mut cache = self.current_settings_cache.write().await;
*cache = Some(result.clone());
Ok(result)
}
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());
let result = tournament_setting.update(&self.db).await?;
// 更新缓存
let mut cache = self.current_settings_cache.write().await;
*cache = Some(result.clone());
Ok(result)
}
pub async fn delete(&self, id: i32) -> Result<()> {
// 检查缓存中是否有这个项目
{
let cache = self.current_settings_cache.read().await;
if let Some(cached_settings) = cache.as_ref() {
if cached_settings.id == id {
// 删除的是缓存的项目,清除缓存
drop(cache); // 释放读锁
let mut cache = self.current_settings_cache.write().await;
*cache = None;
}
}
}
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?)
}
pub async fn get_current_settings_cached(&self) -> Result<Option<tournament_settings::Model>> {
// 首先尝试从缓存读取
{
let cache = self.current_settings_cache.read().await;
if let Some(settings) = cache.as_ref() {
return Ok(Some(settings.clone()));
}
}
// 缓存未命中,从数据库读取
let settings = tournament_settings::Entity::find()
.order_by_desc(tournament_settings::Column::CreatedAt)
.one(&self.db)
.await?;
// 更新缓存
if let Some(ref settings) = settings {
let mut cache = self.current_settings_cache.write().await;
*cache = Some(settings.clone());
}
Ok(settings)
}
pub async fn invalidate_cache(&self) {
let mut cache = self.current_settings_cache.write().await;
*cache = None;
}
}

View File

@@ -1,298 +0,0 @@
use sea_orm::{
ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter,
QueryOrder, Set,
};
use crate::{
dto::user::{
CreateUserRequest, UpdateApprovedRequest, UpdateRegistrationStatusRequest,
UpdateSeasonRequest, UpdateUserGroupRequest, UpdateUserRequest, UserQueryParams,
},
entity::{
sea_orm_active_enums::{RegistrationStatus, 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),
registration_status: Set(Some(RegistrationStatus::Pending)),
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?)
}
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?)
}
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?)
}
pub async fn update_osu_refresh_token(
&self,
id: i32,
refresh_token: String,
) -> Result<user::Model> {
let mut user: user::ActiveModel = self.find_by_id(id).await?.into();
user.osu_refresh_token = Set(Some(refresh_token));
user.updated_at = Set(chrono::Utc::now());
Ok(user.update(&self.db).await?)
}
pub async fn update_gu_refresh_token(
&self,
id: i32,
refresh_token: String,
) -> Result<user::Model> {
let mut user: user::ActiveModel = self.find_by_id(id).await?.into();
user.gu_refresh_token = Set(Some(refresh_token));
user.updated_at = Set(chrono::Utc::now());
Ok(user.update(&self.db).await?)
}
pub async fn get_osu_refresh_token(&self, id: i32) -> Result<Option<String>> {
let user = self.find_by_id(id).await?;
Ok(user.osu_refresh_token)
}
pub async fn get_gu_refresh_token(&self, id: i32) -> Result<Option<String>> {
let user = self.find_by_id(id).await?;
Ok(user.gu_refresh_token)
}
pub async fn clear_osu_refresh_token(&self, id: i32) -> Result<user::Model> {
let mut user: user::ActiveModel = self.find_by_id(id).await?.into();
user.osu_refresh_token = Set(None);
user.updated_at = Set(chrono::Utc::now());
Ok(user.update(&self.db).await?)
}
pub async fn clear_gu_refresh_token(&self, id: i32) -> Result<user::Model> {
let mut user: user::ActiveModel = self.find_by_id(id).await?.into();
user.gu_refresh_token = Set(None);
user.updated_at = Set(chrono::Utc::now());
Ok(user.update(&self.db).await?)
}
pub async fn clear_all_refresh_tokens(&self, id: i32) -> Result<user::Model> {
let mut user: user::ActiveModel = self.find_by_id(id).await?.into();
user.osu_refresh_token = Set(None);
user.gu_refresh_token = Set(None);
user.updated_at = Set(chrono::Utc::now());
Ok(user.update(&self.db).await?)
}
}

View File

@@ -1,48 +0,0 @@
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)
}

View File

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

View File