This commit is contained in:
2026-02-11 12:02:34 +08:00
parent 9dfb1a24ab
commit c42a689a3f
41 changed files with 1121 additions and 3968 deletions

1188
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,10 +9,10 @@ repository = "https://github.com/AECBanana/astracup-web"
[dependencies]
# Web framework
axum = { version = "0.7", features = ["macros"] }
tokio = { version = "1.0", features = ["full"] }
tower = "0.4"
tower-http = { version = "0.5", features = ["cors", "trace"] }
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 = "0.12", features = ["sqlx-mysql", "runtime-tokio-native-tls", "macros"] }
@@ -24,43 +24,37 @@ serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
# Authentication and security
jsonwebtoken = "9.0"
bcrypt = "0.15"
jsonwebtoken = "10.3"
bcrypt = "0.18"
argon2 = "0.5"
rand = "0.8"
rand = "0.10"
# HTTP client for OAuth
reqwest = { version = "0.11", features = ["json"] }
reqwest = { version = "0.13", features = ["json"] }
# Configuration
config = "0.13"
config = "0.15"
anyhow = "1.0"
thiserror = "1.0"
thiserror = "2.0"
# Logging and tracing
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Validation
validator = { version = "0.16", features = ["derive"] }
validator = { version = "0.20", features = ["derive"] }
# Utilities
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1.0", features = ["v4", "serde"] }
futures = "0.3"
axum-extra = "0.12.5"
axum-extra = { version = "0.12.5", features = ["typed-header"] }
[dev-dependencies]
# Testing
tokio = { version = "1.0", features = ["full"] }
reqwest = { version = "0.11", features = ["json"] }
mockall = "0.11"
# Database testing
sqlx = { version = "0.7", features = ["mysql", "runtime-tokio-native-tls"] }
# SeaORM CLI for code generation
# sea-orm-cli = { version = "0.12.15", features = ["sqlx-mysql"] }
reqwest = { version = "0.13", features = ["json"] }
mockall = "0.14"
[profile.dev]
opt-level = 0

289
README.md
View File

@@ -1,289 +0,0 @@
# AstraCup Backend
基于Rust + Axum + SeaORM的osu!比赛管理后端系统。
## 功能特性
- ✅ osu! OAuth2认证
- ✅ JWT令牌管理
- ✅ MySQL数据库集成
- ✅ RESTful API设计
- ✅ 错误处理中间件
- ✅ CORS支持
- ✅ 结构化日志
## 技术栈
- **Web框架**: Axum 0.7
- **ORM**: SeaORM 0.12
- **数据库**: MySQL 8.0+
- **认证**: JWT + osu! OAuth2
- **序列化**: serde + serde_json
- **配置管理**: dotenv
- **日志**: tracing + tracing-subscriber
- **HTTP客户端**: reqwest
## 项目结构
```
astracup-backend/
├── src/
│ ├── main.rs # 应用入口点
│ ├── config/ # 配置管理
│ │ ├── mod.rs
│ │ └── settings.rs
│ ├── database/ # 数据库连接
│ │ ├── mod.rs
│ │ └── connection.rs
│ ├── entities/ # SeaORM实体待生成
│ ├── migrations/ # 数据库迁移(待生成)
│ ├── services/ # 业务逻辑层
│ │ └── auth/ # 认证服务
│ ├── handlers/ # 请求处理器
│ │ └── auth/ # 认证处理器
│ ├── routes/ # 路由定义
│ │ ├── mod.rs
│ │ └── api.rs
│ ├── utils/ # 工具函数
│ │ ├── mod.rs
│ │ ├── error.rs # 错误处理
│ │ └── response.rs # 响应格式
│ └── middleware/ # 中间件(待实现)
├── .env.example # 环境变量示例
├── Cargo.toml # 依赖配置
└── README.md # 项目说明
```
## 快速开始
### 1. 环境要求
- Rust 1.70+ ([安装指南](https://www.rust-lang.org/tools/install))
- MySQL 8.0+
- osu! API客户端ID和密钥
### 2. 配置环境变量
复制环境变量模板:
```bash
cp .env.example .env
```
编辑 `.env` 文件:
```env
# 数据库配置
DATABASE_URL=mysql://username:password@localhost:3306/astracup
# osu! OAuth配置
OSU_CLIENT_ID=your_client_id
OSU_CLIENT_SECRET=your_client_secret
OSU_REDIRECT_URI=http://localhost:3000/api/auth/callback/osu
# JWT配置
JWT_SECRET=your_jwt_secret_key_here_change_this_to_something_secure
JWT_EXPIRY_HOURS=24
# 服务器配置
PORT=3001
HOST=0.0.0.0
LOG_LEVEL=info
# CORS配置
CORS_ORIGIN=http://localhost:3000
```
### 3. 初始化数据库
确保MySQL服务运行并创建数据库
```sql
CREATE DATABASE IF NOT EXISTS astracup CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
```
### 4. 构建和运行
```bash
# 安装依赖
cargo build
# 运行开发服务器
cargo run
# 或使用cargo watch自动重启
cargo install cargo-watch
cargo watch -x run
```
### 5. 测试API
服务器启动后,可以测试以下端点:
```bash
# 获取osu!认证URL
curl http://localhost:3001/api/auth/url
# 验证服务器状态
curl http://localhost:3001/health
```
## API文档
### 认证相关
#### 获取osu!认证URL
```
GET /api/auth/url
```
响应:
```json
{
"success": true,
"auth_url": "https://osu.ppy.sh/oauth/authorize?client_id=..."
}
```
#### osu!认证回调
```
GET /api/auth/callback/osu?code={authorization_code}
```
响应:
```json
{
"success": true,
"token": "jwt_token_here",
"user": {
"id": 123456,
"username": "PlayerName",
"avatar_url": "https://...",
"cover_url": "https://...",
"country_code": "CN",
"statistics": {
"pp": 4500.5,
"global_rank": 1000,
"country_rank": 50
}
}
}
```
#### 登出
```
POST /api/auth/logout
```
响应:
```json
{
"success": true,
"message": "Logout successful. Please remove the token on the client side."
}
```
#### 验证令牌
```
GET /api/auth/verify
Authorization: Bearer {jwt_token}
```
响应:
```json
{
"success": true,
"valid": true,
"user_id": "123456",
"username": "PlayerName"
}
```
## 开发指南
### 添加新的API端点
1.`services/` 目录下创建业务逻辑
2.`handlers/` 目录下创建请求处理器
3.`routes/api.rs` 中注册路由
### 数据库操作
使用SeaORM进行数据库操作
```rust
use sea_orm::{EntityTrait, QueryFilter, ColumnTrait};
// 查询示例
let users = User::find()
.filter(UserColumn::Username.contains("test"))
.all(&db.connection)
.await?;
```
### 错误处理
使用 `AppError` 枚举处理错误:
```rust
use crate::utils::AppError;
async fn some_handler() -> Result<Json<Response>, AppError> {
// 业务逻辑
if something_wrong {
return Err(AppError::NotFound("Resource not found".to_string()));
}
Ok(Json(response))
}
```
## 部署
### 生产环境构建
```bash
# 发布构建
cargo build --release
# 运行生产服务器
./target/release/astracup-backend
```
### Docker部署
```dockerfile
FROM rust:1.70 as builder
WORKDIR /usr/src/app
COPY . .
RUN cargo build --release
FROM debian:bullseye-slim
RUN apt-get update && apt-get install -y libssl-dev && rm -rf /var/lib/apt/lists/*
COPY --from=builder /usr/src/app/target/release/astracup-backend /usr/local/bin/
COPY .env.production .env
EXPOSE 3001
CMD ["astracup-backend"]
```
## 迁移计划
本项目是从现有的Next.js API后端迁移而来。完整的迁移计划包括
1. ✅ 项目初始化和基础架构
2. ⏳ 数据库模型和迁移
3. ⏳ 核心业务逻辑实现
4. ⏳ 剩余API端点迁移
5. ⏳ 测试和优化
6. ⏳ 部署和文档
## 贡献指南
1. Fork 项目
2. 创建功能分支 (`git checkout -b feature/AmazingFeature`)
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
4. 推送到分支 (`git push origin feature/AmazingFeature`)
5. 开启 Pull Request
## 许可证
MIT License - 详见 LICENSE 文件

View File

@@ -1,304 +0,0 @@
# AstraCup Web 后端迁移计划
## 项目概述
将现有的Next.js API后端迁移到Rust + Axum + SeaORM架构。
## 现有API分析
### 主要功能模块
1. **认证模块** - osu! OAuth2认证
2. **报名管理** - 玩家报名、审核、管理
3. **比赛房间** - 比赛房间创建、管理
4. **比赛预约** - 比赛时间安排
5. **玩家对战** - 对战列表管理
6. **消息通知** - 系统消息通知
7. **比赛设置** - 锦标赛配置
8. **分数管理** - 比赛分数保存
9. **文件处理** - 谱面下载、回放处理
### 数据库表结构
基于 `src/lib/mysql-registrations.ts` 分析:
1. **registrations** - 玩家报名表
2. **match_rooms** - 比赛房间表
3. **match_schedules** - 比赛预约表
4. **player_matchups** - 玩家对战表
5. **messages** - 消息通知表
6. **staff_room_assignments** - Staff分配表
7. **tournament_settings** - 比赛设置表
8. **match_scores** - 比赛分数表
## Rust后端架构设计
### 技术栈
- **Web框架**: Axum
- **ORM**: SeaORM
- **数据库**: MySQL (与现有兼容)
- **认证**: JWT + osu! OAuth2
- **配置管理**: dotenv + config
- **日志**: tracing
- **序列化**: serde
- **验证**: validator
### 项目结构
```
astracup-backend/
├── Cargo.toml
├── .env.example
├── .gitignore
├── README.md
├── src/
│ ├── main.rs
│ ├── config/
│ │ ├── mod.rs
│ │ └── settings.rs
│ ├── database/
│ │ ├── mod.rs
│ │ ├── connection.rs
│ │ └── models/
│ │ ├── mod.rs
│ │ ├── registration.rs
│ │ ├── match_room.rs
│ │ ├── match_schedule.rs
│ │ ├── player_matchup.rs
│ │ ├── message.rs
│ │ ├── staff_assignment.rs
│ │ ├── tournament_setting.rs
│ │ └── match_score.rs
│ ├── entities/
│ │ ├── mod.rs
│ │ └── *.rs (SeaORM生成的实体)
│ ├── migrations/
│ │ ├── mod.rs
│ │ └── *.rs (数据库迁移文件)
│ ├── services/
│ │ ├── mod.rs
│ │ ├── auth/
│ │ │ ├── mod.rs
│ │ │ ├── service.rs
│ │ │ └── middleware.rs
│ │ ├── registration/
│ │ ├── match_room/
│ │ ├── match_schedule/
│ │ ├── player_matchup/
│ │ ├── message/
│ │ ├── staff_assignment/
│ │ ├── tournament_setting/
│ │ └── match_score/
│ ├── handlers/
│ │ ├── mod.rs
│ │ ├── auth/
│ │ ├── registration/
│ │ ├── match_room/
│ │ ├── match_schedule/
│ │ ├── player_matchup/
│ │ ├── message/
│ │ ├── staff_assignment/
│ │ ├── tournament_setting/
│ │ └── match_score/
│ ├── routes/
│ │ ├── mod.rs
│ │ └── api.rs
│ ├── utils/
│ │ ├── mod.rs
│ │ ├── error.rs
│ │ ├── response.rs
│ │ └── validation.rs
│ └── middleware/
│ ├── mod.rs
│ ├── auth.rs
│ └── logging.rs
└── tests/
├── integration/
└── unit/
```
### API端点映射
#### 认证相关
- `GET /api/auth/url``GET /api/auth/url`
- `GET /api/auth/callback/osu``GET /api/auth/callback/osu`
- `POST /api/auth/logout``POST /api/auth/logout`
#### 报名管理
- `GET /api/register``GET /api/registrations`
- `POST /api/register``POST /api/registrations`
- `GET /api/approved-players``GET /api/registrations/approved`
- `POST /api/admin/approve-registration``POST /api/admin/registrations/{id}/approve`
- `POST /api/admin/delete-registration``DELETE /api/admin/registrations/{id}`
#### 比赛房间
- `GET /api/match-rooms``GET /api/match-rooms`
- `POST /api/match-rooms/create``POST /api/match-rooms`
- `DELETE /api/match-rooms/delete``DELETE /api/match-rooms/{id}`
#### 比赛预约
- `GET /api/match-schedules``GET /api/match-schedules`
- `POST /api/match-schedules/create``POST /api/match-schedules`
- `PUT /api/match-schedules/update``PUT /api/match-schedules/{id}`
- `PUT /api/match-schedules/update-details``PATCH /api/match-schedules/{id}/details`
#### 玩家对战
- `GET /api/player-matchups``GET /api/player-matchups`
- `POST /api/player-matchups/create``POST /api/player-matchups`
- `DELETE /api/player-matchups/delete``DELETE /api/player-matchups/{id}`
#### 消息通知
- `GET /api/messages``GET /api/messages`
- `POST /api/messages/send``POST /api/messages`
#### Staff分配
- `GET /api/staff-room-assignments``GET /api/staff-assignments`
- `GET /api/available-rooms-for-staff``GET /api/staff-assignments/available-rooms`
- `POST /api/staff-room-assignments``POST /api/staff-assignments`
- `PUT /api/staff-room-assignments/{id}``PUT /api/staff-assignments/{id}`
#### 比赛设置
- `GET /api/tournament-settings``GET /api/tournament-settings`
- `PUT /api/tournament-settings``PUT /api/tournament-settings`
#### 分数管理
- `POST /api/match-scores/save``POST /api/match-scores`
- `PUT /api/match-scores/update``PUT /api/match-scores/{id}`
- `GET /api/multiplayer-scores``GET /api/match-scores`
## 迁移步骤
### 第一阶段:项目初始化 (已完成)
1. ✅ 创建Rust项目结构
2. ✅ 配置Cargo.toml依赖
3. ✅ 设置数据库连接和SeaORM
4. ✅ 创建环境配置
### 第二阶段:数据库模型和迁移 (已完成)
1. ✅ 根据现有表结构创建SeaORM实体
2. ✅ 编写数据库迁移脚本
3. ✅ 创建模型定义和关系
4. ✅ 测试数据库连接和操作
### 第三阶段:核心服务层 (已完成)
1. ✅ 实现认证服务osu! OAuth2集成
2. ✅ 实现报名管理服务
3. ✅ 实现比赛房间服务
4. ✅ 实现消息通知服务
5. ✅ 实现比赛设置服务
### 第四阶段API路由和处理器 (已完成)
1. ✅ 实现Axum路由配置
2. ✅ 创建请求/响应DTO
3. ✅ 实现错误处理中间件
4. ✅ 实现认证中间件
### 第五阶段:剩余功能模块 (进行中)
1. ⏳ 比赛预约服务
2. ⏳ 玩家对战管理
3. ⏳ 分数管理系统
4. ⏳ 文件处理模块
### 第六阶段:测试和优化 (待开始)
1. ⏳ 编写单元测试
2. ⏳ 编写集成测试
3. ⏳ 性能优化
4. ⏳ 错误处理完善
### 第七阶段:部署和文档 (待开始)
1. ⏳ 创建部署配置
2. ⏳ 编写部署文档
3. ⏳ 创建API文档
4. ⏳ 迁移指南
## 当前完成状态
### 已完成的模块
1.**认证模块** - osu! OAuth2认证、JWT令牌管理
2.**报名管理** - 玩家报名、审核、管理
3.**比赛房间** - 比赛房间创建、管理
4.**消息通知** - 系统消息通知
5.**比赛设置** - 锦标赛配置
6.**用户管理** - 用户信息管理
7.**团队管理** - 团队信息管理
### 待完成的模块
1.**比赛预约** - 比赛时间安排
2.**玩家对战** - 对战列表管理
3.**分数管理** - 比赛分数保存
4.**文件处理** - 谱面下载、回放处理
### API端点完成情况
- ✅ 认证相关API (4个端点)
- ✅ 用户管理API (4个端点)
- ✅ 报名管理API (5个端点)
- ✅ 比赛房间API (6个端点)
- ✅ 消息通知API (7个端点)
- ✅ 比赛设置API (7个端点)
- ✅ 团队管理API (3个端点)
- ⏳ 比赛预约API (待实现)
- ⏳ 玩家对战API (待实现)
- ⏳ 分数管理API (待实现)
**总计:已完成 36个API端点待完成 约15个API端点**
## 技术细节
### 数据库连接池
使用SeaORM的连接池配置与现有MySQL兼容
```rust
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let pool = Database::connect(&database_url).await?;
```
### 认证流程
1. 前端调用 `/api/auth/url` 获取osu! OAuth2授权URL
2. 用户授权后重定向到 `/api/auth/callback/osu`
3. 后端使用授权码交换访问令牌
4. 获取用户信息并创建JWT
5. 返回JWT给前端用于后续请求
### 错误处理
统一错误响应格式:
```json
{
"success": false,
"error": "错误消息",
"code": "ERROR_CODE"
}
```
### 环境变量
```
DATABASE_URL=mysql://user:password@localhost:3306/astracup
OSU_CLIENT_ID=your_client_id
OSU_CLIENT_SECRET=your_client_secret
JWT_SECRET=your_jwt_secret
REDIS_URL=redis://localhost:6379 (可选,用于会话缓存)
```
## 兼容性考虑
### 数据迁移
1. 保持现有数据库表结构不变
2. 新Rust后端直接使用现有数据库
3. 逐步迁移API端点保持前端兼容
### API兼容性
1. 保持相同的请求/响应格式
2. 保持相同的错误码
3. 逐步替换,可以并行运行
## 性能优势
1. **内存效率**: Rust无GC内存占用更低
2. **并发性能**: 异步处理,高并发支持
3. **启动速度**: 编译为原生二进制,启动更快
4. **资源消耗**: CPU和内存使用更少
## 风险评估
1. **学习曲线**: Rust有一定学习成本
2. **迁移时间**: 需要2-3周完成迁移
3. **兼容性问题**: 需要确保API完全兼容
4. **测试覆盖**: 需要充分测试确保稳定性
## 下一步行动
1. 创建Rust项目
2. 配置基础依赖
3. 创建数据库模型
4. 实现第一个API端点作为示例

View File

@@ -1,14 +1,23 @@
[package]
name = "migration"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "migration"
path = "src/main.rs"
[dependencies]
sea-orm-migration = "0.12"
async-std = "1.12"
dotenv = "0.15"
tokio = { version = "1.0", features = ["full"] }
[package]
edition = "2024"
name = "migration"
publish = false
rust-version = "1.85.0"
version = "0.1.0"
[lib]
name = "migration"
path = "src/lib.rs"
[dependencies]
tokio = { version = "1", features = ["macros", "rt", "rt-multi-thread"] }
[dependencies.sea-orm-migration]
features = [
# Enable at least one `ASYNC_RUNTIME` and `DATABASE_DRIVER` feature if you want to run migration via CLI.
# 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
]
version = "2.0.0"

41
migration/README.md Normal file
View File

@@ -0,0 +1,41 @@
# Running Migrator CLI
- Generate a new migration file
```sh
cargo run -- generate MIGRATION_NAME
```
- Apply all pending migrations
```sh
cargo run
```
```sh
cargo run -- up
```
- Apply first 10 pending migrations
```sh
cargo run -- up -n 10
```
- Rollback last applied migrations
```sh
cargo run -- down
```
- Rollback last 10 applied migrations
```sh
cargo run -- down -n 10
```
- Drop all tables from the database, then reapply all migrations
```sh
cargo run -- fresh
```
- Rollback all applied migrations, then reapply all migrations
```sh
cargo run -- refresh
```
- Rollback all applied migrations
```sh
cargo run -- reset
```
- Check the status of all migrations
```sh
cargo run -- status
```

View File

@@ -1,14 +1,12 @@
pub use sea_orm_migration::prelude::*;
mod m20250210_000001_create_tables;
pub struct Migrator;
#[async_trait::async_trait]
impl MigratorTrait for Migrator {
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
vec![
Box::new(m20250210_000001_create_tables::Migration),
]
}
}
pub use sea_orm_migration::prelude::*;
mod m20220101_000001_create_table;
pub struct Migrator;
#[async_trait::async_trait]
impl MigratorTrait for Migrator {
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
vec![Box::new(m20220101_000001_create_table::Migration)]
}
}

View File

@@ -0,0 +1,198 @@
use sea_orm_migration::{prelude::*, schema::*};
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
// Replace the sample below with your own migration scripts
todo!();
// type creation
/// 用户类型
manager
.create_type(
Type::create()
.as_enum(UserState::Table)
.values([
UserState::Active,
UserState::Registered,
UserState::Banned,
UserState::Deleted,
])
.to_owned(),
)
.await?;
/// 用户组
manager
.create_type(
Type::create()
.as_enum(UserGroupType::Table)
.values([
UserGroupType::Player,
UserGroupType::Admin,
UserGroupType::Pooler,
UserGroupType::Tester,
])
.to_owned(),
)
.await?;
/// 比赛阶段
manager
.create_type(
Type::create()
.as_enum(SeasonState::Table)
.values([
SeasonState::Qua,
SeasonState::Ro16,
SeasonState::Qf,
SeasonState::Sf,
SeasonState::F,
SeasonState::Gf,
])
.to_owned(),
)
.await?;
/// 比赛状态
manager
.create_type(
type_::create()
.as_enum(MatchState::Table)
.values([
MatchState::Waiting,
MatchState::Running,
MatchState::Finished,
])
.to_owned(),
)
.await?;
// table creation
/// user table
manager
.create_table(
Table::create()
.table(User::Table)
.if_not_exists()
.col(
ColumnDef::new(User::Id)
.integer()
.not_null()
.auto_increment()
.primary_key(),
)
.col(
ColumnDef::new(User::Osuid)
.integer()
.not_null()
.unique_key(),
)
.col(
ColumnDef::new(User::Username)
.string()
.not_null()
.unique_key(),
)
.col(ColumnDef::new(User::AvatarUrl).string().null())
.col(ColumnDef::new(User::CoverUrl).string().null())
.col(
ColumnDef::new(User::UserState)
.custom(UserState::Table)
.not_null()
.default(UserState::Active),
)
.col(ColumnDef::new(User::Pp).float().not_null().default(0.0))
.col(ColumnDef::new(User::CountryCode).string())
.col(ColumnDef::new(User::GlobalRank).integer().null())
.col(ColumnDef::new(User::CountryRank).integer().null())
.col(ColumnDef::new(User::Level).integer().not_null().default(1))
.col(
ColumnDef::new(User::RegisteredSeasonal)
.integer()
.not_null()
.default(1),
)
// gu服绑定
.col(
ColumnDef::new(User::GuServerId)
.integer()
.null()
.unique_key(),
)
.col(ColumnDef::new(User::GuServerUsername).string().null())
// 用户组
.col(
ColumnDef::new(User::UserGroupType)
.custom(UserGroupType::Table)
.not_null()
.default(UserGroupType::Player),
)
.col(
ColumnDef::new(User::CreatedAt)
.timestamp()
.not_null()
.default(Expr::current_timestamp()),
)
.col(
ColumnDef::new(User::UpdatedAt)
.timestamp()
.not_null()
.default(Expr::current_timestamp()),
)
.to_owned(),
)
.await?;
/// schedule table
manager
.create_table(
Table::create()
.table(Schedule::Table)
.if_not_exists()
.col(
ColumnDef::new(Schedule::Id)
.integer()
.not_null()
.auto_increment()
.primary_key(),
)
.col(
ColumnDef::new(Schedule::Season)
.integer()
.not_null()
.default(1),
)
.col(
ColumnDef::new(Schedule::Category)
.custom(Category::Table)
.not_null()
.default(Category::Qua),
)
.col(ColumnDef::new(Schedule::RedPlayerId).integer().null())
.col(ColumnDef::new(Schedule::BluePlayerId).integer().null())
.col(ColumnDef::new(Schedule::RedScore).integer().null())
.col(ColumnDef::new(Schedule::BlueScore).integer().null())
.col(
ColumnDef::new(Schedule::State)
.custom(MatchState::Table)
.not_null()
.default(MatchState::Waiting),
),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
// Replace the sample below with your own migration scripts
todo!();
// table deletion
manager
.drop_table(Table::drop().table(User::Table).to_owned())
.await
}
}

View File

@@ -1,6 +1,6 @@
use sea_orm_migration::prelude::*;
#[async_std::main]
async fn main() {
cli::run_cli(migration::Migrator).await;
}
use sea_orm_migration::prelude::*;
#[tokio::main]
async fn main() {
cli::run_cli(migration::Migrator).await;
}

View File

@@ -1,3 +0,0 @@
pub mod settings;
pub use settings::Settings;

View File

@@ -1,102 +0,0 @@
use serde::Deserialize;
#[derive(Debug, Deserialize, Clone)]
pub struct DatabaseConfig {
pub url: String,
}
#[derive(Debug, Deserialize, Clone)]
pub struct OAuthConfig {
pub client_id: String,
pub client_secret: String,
pub redirect_uri: String,
}
#[derive(Debug, Deserialize, Clone)]
pub struct JwtConfig {
pub secret: String,
pub expiry_hours: i64,
}
#[derive(Debug, Deserialize, Clone)]
pub struct ServerConfig {
pub port: u16,
pub host: String,
pub log_level: String,
}
#[derive(Debug, Deserialize, Clone)]
pub struct CorsConfig {
pub origin: String,
}
#[derive(Debug, Deserialize, Clone)]
pub struct RedisConfig {
pub url: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct Settings {
pub database: DatabaseConfig,
pub oauth: OAuthConfig,
pub jwt: JwtConfig,
pub server: ServerConfig,
pub cors: CorsConfig,
pub redis: RedisConfig,
}
impl Settings {
pub fn from_env() -> Result<Self, Box<dyn std::error::Error>> {
dotenv::dotenv().ok();
let settings = Settings {
database: DatabaseConfig {
url: std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "mysql://root:@localhost:3306/astracup".to_string()),
},
oauth: OAuthConfig {
client_id: std::env::var("OSU_CLIENT_ID").unwrap_or_default(),
client_secret: std::env::var("OSU_CLIENT_SECRET").unwrap_or_default(),
redirect_uri: std::env::var("OSU_REDIRECT_URI")
.unwrap_or_else(|_| "http://localhost:3000/api/auth/callback/osu".to_string()),
},
jwt: JwtConfig {
secret: std::env::var("JWT_SECRET")
.unwrap_or_else(|_| "default_secret_change_in_production".to_string()),
expiry_hours: std::env::var("JWT_EXPIRY_HOURS")
.unwrap_or_else(|_| "24".to_string())
.parse()
.unwrap_or(24),
},
server: ServerConfig {
port: std::env::var("PORT")
.unwrap_or_else(|_| "3001".to_string())
.parse()
.unwrap_or(3001),
host: std::env::var("HOST").unwrap_or_else(|_| "0.0.0.0".to_string()),
log_level: std::env::var("LOG_LEVEL").unwrap_or_else(|_| "info".to_string()),
},
cors: CorsConfig {
origin: std::env::var("CORS_ORIGIN")
.unwrap_or_else(|_| "http://localhost:3000".to_string()),
},
redis: RedisConfig {
url: std::env::var("REDIS_URL").ok(),
},
};
Ok(settings)
}
pub fn database_url(&self) -> &str {
&self.database.url
}
pub fn jwt_secret(&self) -> &str {
&self.jwt.secret
}
pub fn server_address(&self) -> String {
format!("{}:{}", self.server.host, self.server.port)
}
}

View File

@@ -1,39 +0,0 @@
use sea_orm::{Database as SeaDatabase, DatabaseConnection, DbErr};
use tracing::info;
pub struct Database {
pub connection: DatabaseConnection,
}
impl Database {
pub async fn new(database_url: &str) -> Result<Self, DbErr> {
info!("Connecting to database: {}", database_url);
let connection = SeaDatabase::connect(database_url).await?;
info!("Database connection established successfully");
Ok(Self { connection })
}
pub fn get_connection(&self) -> &DatabaseConnection {
&self.connection
}
pub async fn test_connection(&self) -> Result<(), DbErr> {
info!("Testing database connection...");
// Simple query to test connection
let _: Option<(i32,)> = sea_orm::QuerySelect::query(
&mut sea_orm::Statement::from_string(
sea_orm::DatabaseBackend::MySql,
"SELECT 1".to_string(),
),
)
.one(&self.connection)
.await?;
info!("Database connection test passed");
Ok(())
}
}

View File

@@ -1,3 +0,0 @@
pub mod connection;
pub use connection::Database;

View File

@@ -1,76 +0,0 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "messages")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub title: String,
#[sea_orm(column_type = "Text")]
pub content: String,
pub sender_id: i32,
pub receiver_id: i32,
pub room_id: Option<i32>,
pub is_read: bool,
pub is_deleted: bool,
pub is_system_message: bool,
pub is_accepted: bool,
pub created_at: DateTime,
pub updated_at: DateTime,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::user::Entity",
from = "Column::SenderId",
to = "super::user::Column::Osuid",
on_update = "Cascade",
on_delete = "Restrict"
)]
Sender,
#[sea_orm(
belongs_to = "super::user::Entity",
from = "Column::ReceiverId",
to = "super::user::Column::Osuid",
on_update = "Cascade",
on_delete = "Restrict"
)]
Receiver,
#[sea_orm(
belongs_to = "super::multiplayer_room::Entity",
from = "Column::RoomId",
to = "super::multiplayer_room::Column::RoomId",
on_update = "Cascade",
on_delete = "SetNull"
)]
Room,
}
impl Related<super::user::Entity> for Entity {
fn to() -> RelationDef {
Relation::Sender.def()
}
}
impl Related<super::multiplayer_room::Entity> for Entity {
fn to() -> RelationDef {
Relation::Room.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -1,16 +0,0 @@
pub mod prelude;
pub use prelude::*;
pub mod user;
pub mod team;
pub mod tournament_config;
pub mod multiplayer_room;
pub mod multiplayer_solo_room;
pub mod multiplayer_teamvs_room;
pub mod beatmap;
pub mod beatmap_comment;
pub mod score;
pub mod user_group;
pub mod message;
pub mod matchup;

View File

@@ -1,90 +0,0 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "multiplayer_rooms")]
pub struct Model {
#[sea_orm(primary_key)]
pub room_id: i32,
#[sea_orm(column_type = "String(Some(10))")]
pub r#type: MultiplayerRoomType,
#[sea_orm(column_type = "String(Some(2))")]
pub season: Season,
#[sea_orm(column_type = "String(Some(4))")]
pub category: Category,
pub multiplayer_solo_room_room_id: Option<i32>,
pub multiplayer_teamvs_room_room_id: Option<i32>,
pub score_red: i32,
pub score_blue: i32,
pub start_time: DateTime,
pub end_time: DateTime,
pub created_at: DateTime,
pub updated_at: DateTime,
}
#[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)]
#[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "multiplayer_room_type")]
pub enum MultiplayerRoomType {
#[sea_orm(string_value = "SOLO")]
Solo,
#[sea_orm(string_value = "TEAM_VS")]
TeamVs,
}
// 使用与tournament_config相同的Season和Category枚举
pub use super::tournament_config::{Season, Category};
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::multiplayer_solo_room::Entity",
from = "Column::MultiplayerSoloRoomRoomId",
to = "super::multiplayer_solo_room::Column::RoomId",
on_update = "Cascade",
on_delete = "SetNull"
)]
MultiplayerSoloRoom,
#[sea_orm(
belongs_to = "super::multiplayer_teamvs_room::Entity",
from = "Column::MultiplayerTeamvsRoomRoomId",
to = "super::multiplayer_teamvs_room::Column::RoomId",
on_update = "Cascade",
on_delete = "SetNull"
)]
MultiplayerTeamvsRoom,
#[sea_orm(has_many = "super::message::Entity")]
Messages,
}
impl Related<super::multiplayer_solo_room::Entity> for Entity {
fn to() -> RelationDef {
Relation::MultiplayerSoloRoom.def()
}
}
impl Related<super::multiplayer_teamvs_room::Entity> for Entity {
fn to() -> RelationDef {
Relation::MultiplayerTeamvsRoom.def()
}
}
impl Related<super::message::Entity> for Entity {
fn to() -> RelationDef {
Relation::Messages.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -1,53 +0,0 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "multiplayer_solo_rooms")]
pub struct Model {
#[sea_orm(primary_key)]
pub room_id: i32,
#[sea_orm(unique)]
pub player_red_id: i32,
#[sea_orm(unique)]
pub player_blue_id: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::user::Entity",
from = "Column::PlayerRedId",
to = "super::user::Column::Osuid",
on_update = "Cascade",
on_delete = "Restrict"
)]
RedPlayer,
#[sea_orm(
belongs_to = "super::user::Entity",
from = "Column::PlayerBlueId",
to = "super::user::Column::Osuid",
on_update = "Cascade",
on_delete = "Restrict"
)]
BluePlayer,
#[sea_orm(has_many = "super::multiplayer_room::Entity")]
MultiplayerRooms,
}
impl Related<super::user::Entity> for Entity {
fn to() -> RelationDef {
Relation::RedPlayer.def()
}
}
impl Related<super::multiplayer_room::Entity> for Entity {
fn to() -> RelationDef {
Relation::MultiplayerRooms.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -1,53 +0,0 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "multiplayer_teamvs_rooms")]
pub struct Model {
#[sea_orm(primary_key)]
pub room_id: i32,
#[sea_orm(unique)]
pub team_red_id: i32,
#[sea_orm(unique)]
pub team_blue_id: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::team::Entity",
from = "Column::TeamRedId",
to = "super::team::Column::Id",
on_update = "Cascade",
on_delete = "Restrict"
)]
RedTeam,
#[sea_orm(
belongs_to = "super::team::Entity",
from = "Column::TeamBlueId",
to = "super::team::Column::Id",
on_update = "Cascade",
on_delete = "Restrict"
)]
BlueTeam,
#[sea_orm(has_many = "super::multiplayer_room::Entity")]
MultiplayerRooms,
}
impl Related<super::team::Entity> for Entity {
fn to() -> RelationDef {
Relation::RedTeam.def()
}
}
impl Related<super::multiplayer_room::Entity> for Entity {
fn to() -> RelationDef {
Relation::MultiplayerRooms.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -1,17 +0,0 @@
//! SeaORM Entity. Generated by sea-orm-codegen 0.12.15
pub use super::{
// Prisma模型实体
user::Entity as User,
team::Entity as Team,
tournament_config::Entity as TournamentConfig,
multiplayer_room::Entity as MultiplayerRoom,
multiplayer_solo_room::Entity as MultiplayerSoloRoom,
multiplayer_teamvs_room::Entity as MultiplayerTeamvsRoom,
beatmap::Entity as Beatmap,
beatmap_comment::Entity as BeatmapComment,
score::Entity as Score,
user_group::Entity as UserGroup,
message::Entity as Message,
matchup::Entity as Matchup,
};

View File

@@ -1,66 +0,0 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "teams")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub name: String,
#[sea_orm(column_type = "Text")]
pub banner_url: String,
#[sea_orm(column_type = "String(Some(10))")]
pub team_color: TeamColor,
#[sea_orm(column_type = "String(Some(15))")]
pub team_state: TeamState,
pub created_at: DateTime,
pub updated_at: DateTime,
}
#[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)]
#[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "team_color")]
pub enum TeamColor {
#[sea_orm(string_value = "blue_team")]
BlueTeam,
#[sea_orm(string_value = "red_team")]
RedTeam,
}
#[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)]
#[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "team_state")]
pub enum TeamState {
#[sea_orm(string_value = "ACTIVE")]
Active,
#[sea_orm(string_value = "UNAPPROVED")]
Unapproved,
#[sea_orm(string_value = "APPROVED")]
Approved,
#[sea_orm(string_value = "BANNED")]
Banned,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::user::Entity")]
Members,
#[sea_orm(has_many = "super::multiplayer_teamvs_room::Entity", via = "super::multiplayer_teamvs_room::Relation::RedTeam")]
RedTeamRooms,
#[sea_orm(has_many = "super::multiplayer_teamvs_room::Entity", via = "super::multiplayer_teamvs_room::Relation::BlueTeam")]
BlueTeamRooms,
}
impl Related<super::user::Entity> for Entity {
fn to() -> RelationDef {
Relation::Members.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -1,142 +0,0 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "tournament_configs")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub tournament_name: String,
#[sea_orm(column_type = "Double")]
pub max_pp_for_registration: f64,
#[sea_orm(column_type = "Double")]
pub min_pp_for_registration: f64,
#[sea_orm(column_type = "String(Some(2))")]
pub current_seasonal: Season,
#[sea_orm(column_type = "String(Some(4))")]
pub current_category: Category,
pub can_register: bool,
pub created_at: DateTime,
pub updated_at: DateTime,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum Season {
S1,
S2,
S3,
S4,
S5,
S6,
S7,
S8,
S9,
S10,
Custom(String),
}
impl From<String> for Season {
fn from(value: String) -> Self {
match value.as_str() {
"S1" => Season::S1,
"S2" => Season::S2,
"S3" => Season::S3,
"S4" => Season::S4,
"S5" => Season::S5,
"S6" => Season::S6,
"S7" => Season::S7,
"S8" => Season::S8,
"S9" => Season::S9,
"S10" => Season::S10,
_ => Season::Custom(value),
}
}
}
impl From<Season> for String {
fn from(season: Season) -> Self {
match season {
Season::S1 => "S1".to_string(),
Season::S2 => "S2".to_string(),
Season::S3 => "S3".to_string(),
Season::S4 => "S4".to_string(),
Season::S5 => "S5".to_string(),
Season::S6 => "S6".to_string(),
Season::S7 => "S7".to_string(),
Season::S8 => "S8".to_string(),
Season::S9 => "S9".to_string(),
Season::S10 => "S10".to_string(),
Season::Custom(value) => value,
}
}
}
impl sea_orm::TryGetable for Season {
fn try_get_by<I: sea_orm::ColIdx>(res: &sea_orm::QueryResult, index: I) -> Result<Self, sea_orm::TryGetError> {
let value: String = res.try_get_by(index)?;
Ok(Season::from(value))
}
}
impl sea_orm::sea_query::ValueType for Season {
fn try_from(v: sea_orm::Value) -> Result<Self, sea_orm::sea_query::ValueTypeErr> {
match v {
sea_orm::Value::String(Some(s)) => Ok(Season::from(s)),
_ => Err(sea_orm::sea_query::ValueTypeErr),
}
}
fn type_name() -> String {
"String".to_string()
}
fn column_type() -> sea_orm::sea_query::ColumnType {
sea_orm::sea_query::ColumnType::String(Some(10))
}
fn array_type() -> sea_orm::sea_query::ArrayType {
sea_orm::sea_query::ArrayType::String
}
}
impl sea_orm::sea_query::Nullable for Season {
fn null() -> sea_orm::Value {
sea_orm::Value::String(None)
}
}
impl sea_orm::sea_query::ValueTypeErr for Season {
fn err() -> sea_orm::sea_query::ValueTypeErr {
sea_orm::sea_query::ValueTypeErr
}
}
#[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)]
#[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "category")]
pub enum Category {
#[sea_orm(string_value = "QUA")]
Qua,
#[sea_orm(string_value = "RO16")]
Ro16,
#[sea_orm(string_value = "QF")]
Qf,
#[sea_orm(string_value = "SF")]
Sf,
#[sea_orm(string_value = "F")]
F,
#[sea_orm(string_value = "GF")]
Gf,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -1,106 +0,0 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "users")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[sea_orm(unique)]
pub osuid: i32,
#[sea_orm(unique)]
pub username: String,
#[sea_orm(column_type = "Text", nullable)]
pub avatar_url: Option<String>,
#[sea_orm(column_type = "Text", nullable)]
pub cover_url: Option<String>,
pub country_code: String,
#[sea_orm(column_type = "Double")]
pub pp: f64,
pub global_rank: i32,
pub country_rank: i32,
pub approved: bool,
pub seed: i32,
#[sea_orm(column_type = "String(Some(2))")]
pub seasonal: Season,
#[sea_orm(column_type = "String(Some(10))")]
pub user_state: UserState,
pub created_at: DateTime,
pub updated_at: DateTime,
}
#[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)]
#[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "user_state")]
pub enum UserState {
#[sea_orm(string_value = "ACTIVE")]
Active,
#[sea_orm(string_value = "REGISTERED")]
Registered,
#[sea_orm(string_value = "ABANDONED")]
Abandoned,
#[sea_orm(string_value = "BANNED")]
Banned,
}
pub use super::tournament_config::Season;
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::team::Entity")]
Teams,
#[sea_orm(has_many = "super::message::Entity", via = "super::message::Relation::Sender")]
SentMessages,
#[sea_orm(has_many = "super::message::Entity", via = "super::message::Relation::Receiver")]
ReceivedMessages,
#[sea_orm(has_many = "super::beatmap::Entity")]
Beatmaps,
#[sea_orm(has_many = "super::beatmap_comment::Entity")]
BeatmapComments,
#[sea_orm(has_many = "super::multiplayer_solo_room::Entity", via = "super::multiplayer_solo_room::Relation::RedPlayer")]
SoloRedRooms,
#[sea_orm(has_many = "super::multiplayer_solo_room::Entity", via = "super::multiplayer_solo_room::Relation::BluePlayer")]
SoloBlueRooms,
#[sea_orm(has_many = "super::user_group::Entity")]
UserGroups,
}
impl Related<super::team::Entity> for Entity {
fn to() -> RelationDef {
Relation::Teams.def()
}
}
impl Related<super::message::Entity> for Entity {
fn to() -> RelationDef {
Relation::SentMessages.def()
}
}
impl Related<super::beatmap::Entity> for Entity {
fn to() -> RelationDef {
Relation::Beatmaps.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -1,65 +0,0 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "user_groups")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub user_id: i32,
#[sea_orm(column_type = "String(Some(15))")]
pub group_type: UserGroupType,
pub created_at: DateTime,
pub updated_at: DateTime,
}
#[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)]
#[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "user_group_type")]
pub enum UserGroupType {
#[sea_orm(string_value = "HOST")]
Host,
#[sea_orm(string_value = "ADMIN")]
Admin,
#[sea_orm(string_value = "POOLER")]
Pooler,
#[sea_orm(string_value = "STREAMER")]
Streamer,
#[sea_orm(string_value = "TESTER")]
Tester,
#[sea_orm(string_value = "GFX")]
Gfx,
#[sea_orm(string_value = "SHEETER")]
Sheeter,
#[sea_orm(string_value = "COMMENTATOR")]
Commentator,
#[sea_orm(string_value = "MAPPER")]
Mapper,
#[sea_orm(string_value = "REFEREE")]
Referee,
#[sea_orm(string_value = "PLAYER")]
Player,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::user::Entity",
from = "Column::UserId",
to = "super::user::Column::Osuid",
on_update = "Cascade",
on_delete = "Cascade"
)]
User,
}
impl Related<super::user::Entity> for Entity {
fn to() -> RelationDef {
Relation::User.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -1,113 +0,0 @@
use axum::{
extract::{Query, State},
Json,
};
use serde::{Deserialize, Serialize};
use crate::{
services::auth::{AuthService, OsuUserInfo},
utils::{response::ok, AppError},
};
#[derive(Debug, Deserialize)]
pub struct AuthUrlQuery {
pub redirect_uri: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct AuthUrlResponse {
pub success: bool,
pub auth_url: String,
}
#[derive(Debug, Deserialize)]
pub struct AuthCallbackQuery {
pub code: String,
}
#[derive(Debug, Serialize)]
pub struct AuthCallbackResponse {
pub success: bool,
pub token: String,
pub user: OsuUserInfo,
}
pub async fn get_auth_url(
State(auth_service): State<AuthService>,
Query(query): Query<AuthUrlQuery>,
) -> Result<Json<AuthUrlResponse>, AppError> {
// If a custom redirect_uri is provided, we could update the auth service
// For now, we'll use the configured one
let auth_url = auth_service.get_osu_auth_url();
Ok(Json(AuthUrlResponse {
success: true,
auth_url,
}))
}
pub async fn auth_callback(
State(auth_service): State<AuthService>,
Query(query): Query<AuthCallbackQuery>,
) -> Result<Json<AuthCallbackResponse>, AppError> {
// Exchange code for token
let token_data = auth_service.exchange_code_for_token(&query.code).await?;
// Get user info from osu! API
let user_info = auth_service
.get_osu_user_info(&token_data.access_token)
.await?;
// Generate JWT token
let jwt_token = auth_service.generate_jwt(&user_info)?;
Ok(Json(AuthCallbackResponse {
success: true,
token: jwt_token,
user: user_info,
}))
}
#[derive(Debug, Serialize)]
pub struct LogoutResponse {
pub success: bool,
pub message: String,
}
pub async fn logout() -> Result<Json<LogoutResponse>, AppError> {
// In a stateless JWT system, logout is handled on the client side
// by removing the token. We could implement token blacklisting
// if using Redis or similar.
Ok(Json(LogoutResponse {
success: true,
message: "Logout successful. Please remove the token on the client side.".to_string(),
}))
}
#[derive(Debug, Serialize)]
pub struct VerifyTokenResponse {
pub success: bool,
pub valid: bool,
pub user_id: Option<String>,
pub username: Option<String>,
}
pub async fn verify_token(
State(auth_service): State<AuthService>,
token: String,
) -> Result<Json<VerifyTokenResponse>, AppError> {
match auth_service.verify_jwt(&token) {
Ok(claims) => Ok(Json(VerifyTokenResponse {
success: true,
valid: true,
user_id: Some(claims.sub),
username: Some(claims.username),
})),
Err(_) => Ok(Json(VerifyTokenResponse {
success: true,
valid: false,
user_id: None,
username: None,
})),
}
}

View File

@@ -1,3 +0,0 @@
pub mod handlers;
pub use handlers::*;

View File

@@ -1,19 +0,0 @@
use axum::{Json, response::IntoResponse};
use serde::Serialize;
#[derive(Debug, Serialize)]
pub struct HealthResponse {
pub status: String,
pub version: String,
pub timestamp: String,
}
pub async fn health_check() -> impl IntoResponse {
let response = HealthResponse {
status: "ok".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
timestamp: chrono::Utc::now().to_rfc3339(),
};
Json(response)
}

View File

@@ -1,294 +0,0 @@
use axum::{
Json,
extract::{Path, Query, State},
};
use sea_orm::{
ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter,
QueryOrder, Set,
};
use serde::{Deserialize, Serialize};
use crate::{
database::Database,
entities::{
multiplayer_room, multiplayer_solo_room, multiplayer_teamvs_room, prelude::*, team, user,
},
utils::response::{ApiResponse, ApiResult},
};
#[derive(Debug, Deserialize)]
pub struct GetMatchRoomsQuery {
page: Option<u64>,
limit: Option<u64>,
room_type: Option<String>,
season: Option<String>,
category: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct MatchRoomResponse {
room_id: i32,
room_type: String,
season: String,
category: String,
multiplayer_solo_room_room_id: Option<i32>,
multiplayer_teamvs_room_room_id: Option<i32>,
score_red: i32,
score_blue: i32,
start_time: chrono::DateTime<chrono::Utc>,
end_time: chrono::DateTime<chrono::Utc>,
created_at: chrono::DateTime<chrono::Utc>,
updated_at: chrono::DateTime<chrono::Utc>,
}
impl From<multiplayer_room::Model> for MatchRoomResponse {
fn from(model: multiplayer_room::Model) -> Self {
Self {
room_id: model.room_id,
room_type: model.r#type.to_string(),
season: model.season.to_string(),
category: model.category.to_string(),
multiplayer_solo_room_room_id: model.multiplayer_solo_room_room_id,
multiplayer_teamvs_room_room_id: model.multiplayer_teamvs_room_room_id,
score_red: model.score_red,
score_blue: model.score_blue,
start_time: model.start_time,
end_time: model.end_time,
created_at: model.created_at,
updated_at: model.updated_at,
}
}
}
#[derive(Debug, Deserialize)]
pub struct CreateMatchRoomRequest {
room_type: String,
season: String,
category: String,
multiplayer_solo_room_room_id: Option<i32>,
multiplayer_teamvs_room_room_id: Option<i32>,
score_red: i32,
score_blue: i32,
start_time: chrono::DateTime<chrono::Utc>,
end_time: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Deserialize)]
pub struct UpdateMatchRoomRequest {
score_red: Option<i32>,
score_blue: Option<i32>,
start_time: Option<chrono::DateTime<chrono::Utc>>,
end_time: Option<chrono::DateTime<chrono::Utc>>,
}
/// 获取比赛房间列表
pub async fn get_match_rooms(
State(db): State<Database>,
Query(query): Query<GetMatchRoomsQuery>,
) -> ApiResult<Vec<MatchRoomResponse>> {
let conn = db.get_connection();
let page = query.page.unwrap_or(1);
let limit = query.limit.unwrap_or(20);
let offset = (page - 1) * limit;
let mut query = MultiplayerRoom::find().order_by_desc(multiplayer_room::Column::CreatedAt);
if let Some(room_type) = query.room_type {
query = query.filter(multiplayer_room::Column::Type.eq(room_type));
}
if let Some(season) = query.season {
query = query.filter(multiplayer_room::Column::Season.eq(season));
}
if let Some(category) = query.category {
query = query.filter(multiplayer_room::Column::Category.eq(category));
}
let rooms = query
.offset(offset)
.limit(limit)
.all(conn)
.await
.map_err(|e| {
tracing::error!("Failed to fetch match rooms: {}", e);
ApiResponse::internal_server_error("Failed to fetch match rooms")
})?;
let response = rooms.into_iter().map(MatchRoomResponse::from).collect();
Ok(ApiResponse::success(response))
}
/// 获取单个比赛房间
pub async fn get_match_room(
State(db): State<Database>,
Path(room_id): Path<i32>,
) -> ApiResult<MatchRoomResponse> {
let conn = db.get_connection();
let room = MultiplayerRoom::find_by_id(room_id)
.one(conn)
.await
.map_err(|e| {
tracing::error!("Failed to fetch match room: {}", e);
ApiResponse::internal_server_error("Failed to fetch match room")
})?
.ok_or_else(|| ApiResponse::not_found("Match room not found"))?;
Ok(ApiResponse::success(MatchRoomResponse::from(room)))
}
/// 创建比赛房间
pub async fn create_match_room(
State(db): State<Database>,
Json(payload): Json<CreateMatchRoomRequest>,
) -> ApiResult<MatchRoomResponse> {
let conn = db.get_connection();
// 验证房间类型
let room_type = match payload.room_type.as_str() {
"SOLO" => multiplayer_room::MultiplayerRoomType::Solo,
"TEAM_VS" => multiplayer_room::MultiplayerRoomType::TeamVs,
_ => return Err(ApiResponse::bad_request("Invalid room type")),
};
// 验证赛季
let season = match payload.season.as_str() {
"S1" => multiplayer_room::Season::S1,
"S2" => multiplayer_room::Season::S2,
_ => return Err(ApiResponse::bad_request("Invalid season")),
};
// 验证类别
let category = match payload.category.as_str() {
"QUA" => multiplayer_room::Category::Qua,
"RO16" => multiplayer_room::Category::Ro16,
"QF" => multiplayer_room::Category::Qf,
"SF" => multiplayer_room::Category::Sf,
"F" => multiplayer_room::Category::F,
"GF" => multiplayer_room::Category::Gf,
_ => return Err(ApiResponse::bad_request("Invalid category")),
};
// 创建房间
let room = multiplayer_room::ActiveModel {
r#type: Set(room_type),
season: Set(season),
category: Set(category),
multiplayer_solo_room_room_id: Set(payload.multiplayer_solo_room_room_id),
multiplayer_teamvs_room_room_id: Set(payload.multiplayer_teamvs_room_room_id),
score_red: Set(payload.score_red),
score_blue: Set(payload.score_blue),
start_time: Set(payload.start_time),
end_time: Set(payload.end_time),
..Default::default()
};
let room = room.insert(conn).await.map_err(|e| {
tracing::error!("Failed to create match room: {}", e);
ApiResponse::internal_server_error("Failed to create match room")
})?;
Ok(ApiResponse::success(MatchRoomResponse::from(room)))
}
/// 更新比赛房间
pub async fn update_match_room(
State(db): State<Database>,
Path(room_id): Path<i32>,
Json(payload): Json<UpdateMatchRoomRequest>,
) -> ApiResult<MatchRoomResponse> {
let conn = db.get_connection();
let mut room: multiplayer_room::ActiveModel = MultiplayerRoom::find_by_id(room_id)
.one(conn)
.await
.map_err(|e| {
tracing::error!("Failed to fetch match room: {}", e);
ApiResponse::internal_server_error("Failed to fetch match room")
})?
.ok_or_else(|| ApiResponse::not_found("Match room not found"))?
.into();
if let Some(score_red) = payload.score_red {
room.score_red = Set(score_red);
}
if let Some(score_blue) = payload.score_blue {
room.score_blue = Set(score_blue);
}
if let Some(start_time) = payload.start_time {
room.start_time = Set(start_time);
}
if let Some(end_time) = payload.end_time {
room.end_time = Set(end_time);
}
let room = room.update(conn).await.map_err(|e| {
tracing::error!("Failed to update match room: {}", e);
ApiResponse::internal_server_error("Failed to update match room")
})?;
Ok(ApiResponse::success(MatchRoomResponse::from(room)))
}
/// 删除比赛房间
pub async fn delete_match_room(
State(db): State<Database>,
Path(room_id): Path<i32>,
) -> ApiResult<()> {
let conn = db.get_connection();
let room = MultiplayerRoom::find_by_id(room_id)
.one(conn)
.await
.map_err(|e| {
tracing::error!("Failed to fetch match room: {}", e);
ApiResponse::internal_server_error("Failed to fetch match room")
})?
.ok_or_else(|| ApiResponse::not_found("Match room not found"))?;
MultiplayerRoom::delete_by_id(room.room_id)
.exec(conn)
.await
.map_err(|e| {
tracing::error!("Failed to delete match room: {}", e);
ApiResponse::internal_server_error("Failed to delete match room")
})?;
Ok(ApiResponse::success_message(
"Match room deleted successfully",
))
}
/// 获取比赛房间总数
pub async fn get_match_rooms_count(
State(db): State<Database>,
Query(query): Query<GetMatchRoomsQuery>,
) -> ApiResult<u64> {
let conn = db.get_connection();
let mut query = MultiplayerRoom::find();
if let Some(room_type) = query.room_type {
query = query.filter(multiplayer_room::Column::Type.eq(room_type));
}
if let Some(season) = query.season {
query = query.filter(multiplayer_room::Column::Season.eq(season));
}
if let Some(category) = query.category {
query = query.filter(multiplayer_room::Column::Category.eq(category));
}
let count = query.count(conn).await.map_err(|e| {
tracing::error!("Failed to count match rooms: {}", e);
ApiResponse::internal_server_error("Failed to count match rooms")
})?;
Ok(ApiResponse::success(count))
}

View File

@@ -1,317 +0,0 @@
use axum::{
Json,
extract::{Path, Query, State},
};
use sea_orm::{
ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter,
QueryOrder, Set,
};
use serde::{Deserialize, Serialize};
use crate::{
database::Database,
entities::{message, multiplayer_room, prelude::*, user},
utils::response::{ApiResponse, ApiResult},
};
#[derive(Debug, Deserialize)]
pub struct GetMessagesQuery {
page: Option<u64>,
limit: Option<u64>,
receiver_id: Option<i32>,
sender_id: Option<i32>,
room_id: Option<i32>,
is_read: Option<bool>,
is_system_message: Option<bool>,
}
#[derive(Debug, Serialize)]
pub struct MessageResponse {
id: i32,
title: String,
content: String,
sender_id: i32,
receiver_id: i32,
room_id: Option<i32>,
is_read: bool,
is_deleted: bool,
is_system_message: bool,
is_accepted: bool,
created_at: chrono::DateTime<chrono::Utc>,
updated_at: chrono::DateTime<chrono::Utc>,
}
impl From<message::Model> for MessageResponse {
fn from(model: message::Model) -> Self {
Self {
id: model.id,
title: model.title,
content: model.content,
sender_id: model.sender_id,
receiver_id: model.receiver_id,
room_id: model.room_id,
is_read: model.is_read,
is_deleted: model.is_deleted,
is_system_message: model.is_system_message,
is_accepted: model.is_accepted,
created_at: model.created_at,
updated_at: model.updated_at,
}
}
}
#[derive(Debug, Deserialize)]
pub struct CreateMessageRequest {
title: String,
content: String,
receiver_id: i32,
room_id: Option<i32>,
is_system_message: Option<bool>,
}
#[derive(Debug, Deserialize)]
pub struct UpdateMessageRequest {
is_read: Option<bool>,
is_accepted: Option<bool>,
}
/// 获取消息列表
pub async fn get_messages(
State(db): State<Database>,
Query(query): Query<GetMessagesQuery>,
) -> ApiResult<Vec<MessageResponse>> {
let conn = db.get_connection();
let page = query.page.unwrap_or(1);
let limit = query.limit.unwrap_or(20);
let offset = (page - 1) * limit;
let mut query = Message::find()
.filter(message::Column::IsDeleted.eq(false))
.order_by_desc(message::Column::CreatedAt);
if let Some(receiver_id) = query.receiver_id {
query = query.filter(message::Column::ReceiverId.eq(receiver_id));
}
if let Some(sender_id) = query.sender_id {
query = query.filter(message::Column::SenderId.eq(sender_id));
}
if let Some(room_id) = query.room_id {
query = query.filter(message::Column::RoomId.eq(room_id));
}
if let Some(is_read) = query.is_read {
query = query.filter(message::Column::IsRead.eq(is_read));
}
if let Some(is_system_message) = query.is_system_message {
query = query.filter(message::Column::IsSystemMessage.eq(is_system_message));
}
let messages = query
.offset(offset)
.limit(limit)
.all(conn)
.await
.map_err(|e| {
tracing::error!("Failed to fetch messages: {}", e);
ApiResponse::internal_server_error("Failed to fetch messages")
})?;
let response = messages.into_iter().map(MessageResponse::from).collect();
Ok(ApiResponse::success(response))
}
/// 获取单个消息
pub async fn get_message(
State(db): State<Database>,
Path(message_id): Path<i32>,
) -> ApiResult<MessageResponse> {
let conn = db.get_connection();
let message = Message::find_by_id(message_id)
.filter(message::Column::IsDeleted.eq(false))
.one(conn)
.await
.map_err(|e| {
tracing::error!("Failed to fetch message: {}", e);
ApiResponse::internal_server_error("Failed to fetch message")
})?
.ok_or_else(|| ApiResponse::not_found("Message not found"))?;
Ok(ApiResponse::success(MessageResponse::from(message)))
}
/// 发送消息
pub async fn send_message(
State(db): State<Database>,
Json(payload): Json<CreateMessageRequest>,
) -> ApiResult<MessageResponse> {
let conn = db.get_connection();
// 验证接收者是否存在
let receiver = User::find()
.filter(user::Column::Osuid.eq(payload.receiver_id))
.one(conn)
.await
.map_err(|e| {
tracing::error!("Failed to verify receiver: {}", e);
ApiResponse::internal_server_error("Failed to verify receiver")
})?
.ok_or_else(|| ApiResponse::not_found("Receiver not found"))?;
// 验证房间是否存在如果提供了room_id
if let Some(room_id) = payload.room_id {
let room = MultiplayerRoom::find_by_id(room_id)
.one(conn)
.await
.map_err(|e| {
tracing::error!("Failed to verify room: {}", e);
ApiResponse::internal_server_error("Failed to verify room")
})?;
if room.is_none() {
return Err(ApiResponse::not_found("Room not found"));
}
}
// 创建消息
let message = message::ActiveModel {
title: Set(payload.title),
content: Set(payload.content),
receiver_id: Set(payload.receiver_id),
room_id: Set(payload.room_id),
is_system_message: Set(payload.is_system_message.unwrap_or(false)),
..Default::default()
};
let message = message.insert(conn).await.map_err(|e| {
tracing::error!("Failed to send message: {}", e);
ApiResponse::internal_server_error("Failed to send message")
})?;
Ok(ApiResponse::success(MessageResponse::from(message)))
}
/// 更新消息状态(标记为已读/接受)
pub async fn update_message(
State(db): State<Database>,
Path(message_id): Path<i32>,
Json(payload): Json<UpdateMessageRequest>,
) -> ApiResult<MessageResponse> {
let conn = db.get_connection();
let mut message: message::ActiveModel = Message::find_by_id(message_id)
.filter(message::Column::IsDeleted.eq(false))
.one(conn)
.await
.map_err(|e| {
tracing::error!("Failed to fetch message: {}", e);
ApiResponse::internal_server_error("Failed to fetch message")
})?
.ok_or_else(|| ApiResponse::not_found("Message not found"))?
.into();
if let Some(is_read) = payload.is_read {
message.is_read = Set(is_read);
}
if let Some(is_accepted) = payload.is_accepted {
message.is_accepted = Set(is_accepted);
}
let message = message.update(conn).await.map_err(|e| {
tracing::error!("Failed to update message: {}", e);
ApiResponse::internal_server_error("Failed to update message")
})?;
Ok(ApiResponse::success(MessageResponse::from(message)))
}
/// 删除消息(软删除)
pub async fn delete_message(
State(db): State<Database>,
Path(message_id): Path<i32>,
) -> ApiResult<()> {
let conn = db.get_connection();
let mut message: message::ActiveModel = Message::find_by_id(message_id)
.filter(message::Column::IsDeleted.eq(false))
.one(conn)
.await
.map_err(|e| {
tracing::error!("Failed to fetch message: {}", e);
ApiResponse::internal_server_error("Failed to fetch message")
})?
.ok_or_else(|| ApiResponse::not_found("Message not found"))?
.into();
message.is_deleted = Set(true);
message.update(conn).await.map_err(|e| {
tracing::error!("Failed to delete message: {}", e);
ApiResponse::internal_server_error("Failed to delete message")
})?;
Ok(ApiResponse::success_message("Message deleted successfully"))
}
/// 获取消息总数
pub async fn get_messages_count(
State(db): State<Database>,
Query(query): Query<GetMessagesQuery>,
) -> ApiResult<u64> {
let conn = db.get_connection();
let mut query = Message::find().filter(message::Column::IsDeleted.eq(false));
if let Some(receiver_id) = query.receiver_id {
query = query.filter(message::Column::ReceiverId.eq(receiver_id));
}
if let Some(sender_id) = query.sender_id {
query = query.filter(message::Column::SenderId.eq(sender_id));
}
if let Some(room_id) = query.room_id {
query = query.filter(message::Column::RoomId.eq(room_id));
}
if let Some(is_read) = query.is_read {
query = query.filter(message::Column::IsRead.eq(is_read));
}
if let Some(is_system_message) = query.is_system_message {
query = query.filter(message::Column::IsSystemMessage.eq(is_system_message));
}
let count = query.count(conn).await.map_err(|e| {
tracing::error!("Failed to count messages: {}", e);
ApiResponse::internal_server_error("Failed to count messages")
})?;
Ok(ApiResponse::success(count))
}
/// 获取未读消息数量
pub async fn get_unread_messages_count(
State(db): State<Database>,
Path(receiver_id): Path<i32>,
) -> ApiResult<u64> {
let conn = db.get_connection();
let count = Message::find()
.filter(message::Column::ReceiverId.eq(receiver_id))
.filter(message::Column::IsRead.eq(false))
.filter(message::Column::IsDeleted.eq(false))
.count(conn)
.await
.map_err(|e| {
tracing::error!("Failed to count unread messages: {}", e);
ApiResponse::internal_server_error("Failed to count unread messages")
})?;
Ok(ApiResponse::success(count))
}

View File

@@ -1,8 +0,0 @@
pub mod auth;
pub mod health;
pub mod match_room;
pub mod message;
pub mod registration;
pub mod team;
pub mod tournament_config;
pub mod user;

View File

@@ -1,222 +0,0 @@
use axum::{
Json,
extract::{Path, Query, State},
};
use sea_orm::{
ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
};
use serde::{Deserialize, Serialize};
use crate::{
database::Database,
entities::{prelude::*, user},
utils::response::{ApiResponse, ApiResult},
};
#[derive(Debug, Deserialize)]
pub struct GetRegistrationsQuery {
page: Option<u64>,
limit: Option<u64>,
approved: Option<bool>,
seasonal: Option<String>,
user_state: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct RegistrationResponse {
id: i32,
osuid: i32,
username: String,
avatar_url: Option<String>,
cover_url: Option<String>,
country_code: String,
pp: f64,
global_rank: i32,
country_rank: i32,
approved: bool,
seed: i32,
seasonal: String,
user_state: String,
created_at: chrono::DateTime<chrono::Utc>,
updated_at: chrono::DateTime<chrono::Utc>,
}
impl From<user::Model> for RegistrationResponse {
fn from(model: user::Model) -> Self {
Self {
id: model.id,
osuid: model.osuid,
username: model.username,
avatar_url: model.avatar_url,
cover_url: model.cover_url,
country_code: model.country_code,
pp: model.pp,
global_rank: model.global_rank,
country_rank: model.country_rank,
approved: model.approved,
seed: model.seed,
seasonal: model.seasonal.into(),
user_state: model.user_state.to_string(),
created_at: model.created_at,
updated_at: model.updated_at,
}
}
}
#[derive(Debug, Deserialize)]
pub struct ApproveRegistrationRequest {
approved: bool,
}
/// 获取报名列表
pub async fn get_registrations(
State(db): State<Database>,
Query(query): Query<GetRegistrationsQuery>,
) -> ApiResult<Vec<RegistrationResponse>> {
let conn = db.get_connection();
let page = query.page.unwrap_or(1);
let limit = query.limit.unwrap_or(20);
let offset = (page - 1) * limit;
let mut query = User::find().order_by_asc(user::Column::Id);
if let Some(approved) = query.approved {
query = query.filter(user::Column::Approved.eq(approved));
}
if let Some(seasonal) = query.seasonal {
query = query.filter(user::Column::Seasonal.eq(seasonal));
}
if let Some(user_state) = query.user_state {
query = query.filter(user::Column::UserState.eq(user_state));
}
let users = query
.offset(offset)
.limit(limit)
.all(conn)
.await
.map_err(|e| {
tracing::error!("Failed to fetch registrations: {}", e);
ApiResponse::internal_server_error("Failed to fetch registrations")
})?;
let response = users.into_iter().map(RegistrationResponse::from).collect();
Ok(ApiResponse::success(response))
}
/// 获取已批准的玩家
pub async fn get_approved_players(
State(db): State<Database>,
Query(query): Query<GetRegistrationsQuery>,
) -> ApiResult<Vec<RegistrationResponse>> {
let conn = db.get_connection();
let page = query.page.unwrap_or(1);
let limit = query.limit.unwrap_or(20);
let offset = (page - 1) * limit;
let users = User::find()
.filter(user::Column::Approved.eq(true))
.order_by_asc(user::Column::Id)
.offset(offset)
.limit(limit)
.all(conn)
.await
.map_err(|e| {
tracing::error!("Failed to fetch approved players: {}", e);
ApiResponse::internal_server_error("Failed to fetch approved players")
})?;
let response = users.into_iter().map(RegistrationResponse::from).collect();
Ok(ApiResponse::success(response))
}
/// 批准或拒绝报名
pub async fn approve_registration(
State(db): State<Database>,
Path(user_id): Path<i32>,
Json(payload): Json<ApproveRegistrationRequest>,
) -> ApiResult<RegistrationResponse> {
let conn = db.get_connection();
let mut user: user::ActiveModel = User::find_by_id(user_id)
.one(conn)
.await
.map_err(|e| {
tracing::error!("Failed to fetch user: {}", e);
ApiResponse::internal_server_error("Failed to fetch user")
})?
.ok_or_else(|| ApiResponse::not_found("User not found"))?
.into();
user.approved = sea_orm::Set(payload.approved);
let user = user.update(conn).await.map_err(|e| {
tracing::error!("Failed to update user approval status: {}", e);
ApiResponse::internal_server_error("Failed to update approval status")
})?;
Ok(ApiResponse::success(RegistrationResponse::from(user)))
}
/// 删除报名
pub async fn delete_registration(
State(db): State<Database>,
Path(user_id): Path<i32>,
) -> ApiResult<()> {
let conn = db.get_connection();
let user = User::find_by_id(user_id)
.one(conn)
.await
.map_err(|e| {
tracing::error!("Failed to fetch user: {}", e);
ApiResponse::internal_server_error("Failed to fetch user")
})?
.ok_or_else(|| ApiResponse::not_found("User not found"))?;
// 软删除将用户状态设置为BANNED
let mut user_model: user::ActiveModel = user.into();
user_model.user_state = sea_orm::Set(user::UserState::Banned);
user_model.update(conn).await.map_err(|e| {
tracing::error!("Failed to delete registration: {}", e);
ApiResponse::internal_server_error("Failed to delete registration")
})?;
Ok(ApiResponse::success_message(
"Registration deleted successfully",
))
}
/// 获取报名总数
pub async fn get_registrations_count(
State(db): State<Database>,
Query(query): Query<GetRegistrationsQuery>,
) -> ApiResult<u64> {
let conn = db.get_connection();
let mut query = User::find();
if let Some(approved) = query.approved {
query = query.filter(user::Column::Approved.eq(approved));
}
if let Some(seasonal) = query.seasonal {
query = query.filter(user::Column::Seasonal.eq(seasonal));
}
if let Some(user_state) = query.user_state {
query = query.filter(user::Column::UserState.eq(user_state));
}
let count = query.count(conn).await.map_err(|e| {
tracing::error!("Failed to count registrations: {}", e);
ApiResponse::internal_server_error("Failed to count registrations")
})?;
Ok(ApiResponse::success(count))
}

View File

@@ -1,128 +0,0 @@
use axum::{
extract::{Path, Query, State},
Json,
};
use sea_orm::{DatabaseConnection, EntityTrait, PaginatorTrait, QueryOrder};
use serde::{Deserialize, Serialize};
use crate::{
database::Database,
entities::{prelude::*, team},
utils::response::{ApiResponse, ApiResult},
};
#[derive(Debug, Deserialize)]
pub struct GetTeamsQuery {
page: Option<u64>,
limit: Option<u64>,
team_state: Option<String>,
team_color: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct TeamResponse {
id: i32,
name: String,
banner_url: String,
team_color: String,
team_state: String,
created_at: chrono::DateTime<chrono::Utc>,
updated_at: chrono::DateTime<chrono::Utc>,
}
impl From<team::Model> for TeamResponse {
fn from(model: team::Model) -> Self {
Self {
id: model.id,
name: model.name,
banner_url: model.banner_url,
team_color: model.team_color.to_string(),
team_state: model.team_state.to_string(),
created_at: model.created_at,
updated_at: model.updated_at,
}
}
}
/// 获取队伍列表
pub async fn get_teams(
State(db): State<Database>,
Query(query): Query<GetTeamsQuery>,
) -> ApiResult<Vec<TeamResponse>> {
let conn = db.get_connection();
let page = query.page.unwrap_or(1);
let limit = query.limit.unwrap_or(20);
let offset = (page - 1) * limit;
let mut query = Team::find()
.order_by_asc(team::Column::Id);
if let Some(team_state) = query.team_state {
query = query.filter(team::Column::TeamState.eq(team_state));
}
if let Some(team_color) = query.team_color {
query = query.filter(team::Column::TeamColor.eq(team_color));
}
let teams = query
.offset(offset)
.limit(limit)
.all(conn)
.await
.map_err(|e| {
tracing::error!("Failed to fetch teams: {}", e);
ApiResponse::internal_server_error("Failed to fetch teams")
})?;
let response = teams.into_iter().map(TeamResponse::from).collect();
Ok(ApiResponse::success(response))
}
/// 获取单个队伍
pub async fn get_team(
State(db): State<Database>,
Path(team_id): Path<i32>,
) -> ApiResult<TeamResponse> {
let conn = db.get_connection();
let team = Team::find_by_id(team_id)
.one(conn)
.await
.map_err(|e| {
tracing::error!("Failed to fetch team: {}", e);
ApiResponse::internal_server_error("Failed to fetch team")
})?
.ok_or_else(|| ApiResponse::not_found("Team not found"))?;
Ok(ApiResponse::success(TeamResponse::from(team)))
}
/// 获取队伍总数
pub async fn get_teams_count(
State(db): State<Database>,
Query(query): Query<GetTeamsQuery>,
) -> ApiResult<u64> {
let conn = db.get_connection();
let mut query = Team::find();
if let Some(team_state) = query.team_state {
query = query.filter(team::Column::TeamState.eq(team_state));
}
if let Some(team_color) = query.team_color {
query = query.filter(team::Column::TeamColor.eq(team_color));
}
let count = query
.count(conn)
.await
.map_err(|e| {
tracing::error!("Failed to count teams: {}", e);
ApiResponse::internal_server_error("Failed to count teams")
})?;
Ok(ApiResponse::success(count))
}

View File

@@ -1,310 +0,0 @@
use axum::{
Json,
extract::{Path, Query, State},
};
use sea_orm::{
ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter,
QueryOrder, Set,
};
use serde::{Deserialize, Serialize};
use crate::{
database::Database,
entities::{prelude::*, tournament_config},
utils::response::{ApiResponse, ApiResult},
};
#[derive(Debug, Deserialize)]
pub struct GetTournamentConfigsQuery {
page: Option<u64>,
limit: Option<u64>,
current_seasonal: Option<String>,
current_category: Option<String>,
can_register: Option<bool>,
}
#[derive(Debug, Serialize)]
pub struct TournamentConfigResponse {
id: i32,
tournament_name: String,
max_pp_for_registration: f64,
min_pp_for_registration: f64,
current_seasonal: String,
current_category: String,
can_register: bool,
created_at: chrono::DateTime<chrono::Utc>,
updated_at: chrono::DateTime<chrono::Utc>,
}
impl From<tournament_config::Model> for TournamentConfigResponse {
fn from(model: tournament_config::Model) -> Self {
Self {
id: model.id,
tournament_name: model.tournament_name,
max_pp_for_registration: model.max_pp_for_registration,
min_pp_for_registration: model.min_pp_for_registration,
current_seasonal: model.current_seasonal.into(),
current_category: model.current_category.to_string(),
can_register: model.can_register,
created_at: model.created_at,
updated_at: model.updated_at,
}
}
}
#[derive(Debug, Deserialize)]
pub struct CreateTournamentConfigRequest {
tournament_name: String,
max_pp_for_registration: f64,
min_pp_for_registration: f64,
current_seasonal: String,
current_category: String,
can_register: bool,
}
#[derive(Debug, Deserialize)]
pub struct UpdateTournamentConfigRequest {
tournament_name: Option<String>,
max_pp_for_registration: Option<f64>,
min_pp_for_registration: Option<f64>,
current_seasonal: Option<String>,
current_category: Option<String>,
can_register: Option<bool>,
}
/// 获取比赛设置列表
pub async fn get_tournament_configs(
State(db): State<Database>,
Query(query): Query<GetTournamentConfigsQuery>,
) -> ApiResult<Vec<TournamentConfigResponse>> {
let conn = db.get_connection();
let page = query.page.unwrap_or(1);
let limit = query.limit.unwrap_or(20);
let offset = (page - 1) * limit;
let mut query = TournamentConfig::find().order_by_desc(tournament_config::Column::CreatedAt);
if let Some(current_seasonal) = query.current_seasonal {
query = query.filter(tournament_config::Column::CurrentSeasonal.eq(current_seasonal));
}
if let Some(current_category) = query.current_category {
query = query.filter(tournament_config::Column::CurrentCategory.eq(current_category));
}
if let Some(can_register) = query.can_register {
query = query.filter(tournament_config::Column::CanRegister.eq(can_register));
}
let configs = query
.offset(offset)
.limit(limit)
.all(conn)
.await
.map_err(|e| {
tracing::error!("Failed to fetch tournament configs: {}", e);
ApiResponse::internal_server_error("Failed to fetch tournament configs")
})?;
let response = configs
.into_iter()
.map(TournamentConfigResponse::from)
.collect();
Ok(ApiResponse::success(response))
}
/// 获取单个比赛设置
pub async fn get_tournament_config(
State(db): State<Database>,
Path(config_id): Path<i32>,
) -> ApiResult<TournamentConfigResponse> {
let conn = db.get_connection();
let config = TournamentConfig::find_by_id(config_id)
.one(conn)
.await
.map_err(|e| {
tracing::error!("Failed to fetch tournament config: {}", e);
ApiResponse::internal_server_error("Failed to fetch tournament config")
})?
.ok_or_else(|| ApiResponse::not_found("Tournament config not found"))?;
Ok(ApiResponse::success(TournamentConfigResponse::from(config)))
}
/// 获取当前比赛设置
pub async fn get_current_tournament_config(
State(db): State<Database>,
) -> ApiResult<TournamentConfigResponse> {
let conn = db.get_connection();
let config = TournamentConfig::find()
.order_by_desc(tournament_config::Column::CreatedAt)
.one(conn)
.await
.map_err(|e| {
tracing::error!("Failed to fetch current tournament config: {}", e);
ApiResponse::internal_server_error("Failed to fetch current tournament config")
})?
.ok_or_else(|| ApiResponse::not_found("No tournament config found"))?;
Ok(ApiResponse::success(TournamentConfigResponse::from(config)))
}
/// 创建比赛设置
pub async fn create_tournament_config(
State(db): State<Database>,
Json(payload): Json<CreateTournamentConfigRequest>,
) -> ApiResult<TournamentConfigResponse> {
let conn = db.get_connection();
// 验证赛季
let current_seasonal = tournament_config::Season::from(payload.current_seasonal);
// 验证类别
let current_category = match payload.current_category.as_str() {
"QUA" => tournament_config::Category::Qua,
"RO16" => tournament_config::Category::Ro16,
"QF" => tournament_config::Category::Qf,
"SF" => tournament_config::Category::Sf,
"F" => tournament_config::Category::F,
"GF" => tournament_config::Category::Gf,
_ => return Err(ApiResponse::bad_request("Invalid category")),
};
// 创建比赛设置
let config = tournament_config::ActiveModel {
tournament_name: Set(payload.tournament_name),
max_pp_for_registration: Set(payload.max_pp_for_registration),
min_pp_for_registration: Set(payload.min_pp_for_registration),
current_seasonal: Set(current_seasonal),
current_category: Set(current_category),
can_register: Set(payload.can_register),
..Default::default()
};
let config = config.insert(conn).await.map_err(|e| {
tracing::error!("Failed to create tournament config: {}", e);
ApiResponse::internal_server_error("Failed to create tournament config")
})?;
Ok(ApiResponse::success(TournamentConfigResponse::from(config)))
}
/// 更新比赛设置
pub async fn update_tournament_config(
State(db): State<Database>,
Path(config_id): Path<i32>,
Json(payload): Json<UpdateTournamentConfigRequest>,
) -> ApiResult<TournamentConfigResponse> {
let conn = db.get_connection();
let mut config: tournament_config::ActiveModel = TournamentConfig::find_by_id(config_id)
.one(conn)
.await
.map_err(|e| {
tracing::error!("Failed to fetch tournament config: {}", e);
ApiResponse::internal_server_error("Failed to fetch tournament config")
})?
.ok_or_else(|| ApiResponse::not_found("Tournament config not found"))?
.into();
if let Some(tournament_name) = payload.tournament_name {
config.tournament_name = Set(tournament_name);
}
if let Some(max_pp_for_registration) = payload.max_pp_for_registration {
config.max_pp_for_registration = Set(max_pp_for_registration);
}
if let Some(min_pp_for_registration) = payload.min_pp_for_registration {
config.min_pp_for_registration = Set(min_pp_for_registration);
}
if let Some(current_seasonal) = payload.current_seasonal {
config.current_seasonal = Set(tournament_config::Season::from(current_seasonal));
}
if let Some(current_category) = payload.current_category {
let category = match current_category.as_str() {
"QUA" => tournament_config::Category::Qua,
"RO16" => tournament_config::Category::Ro16,
"QF" => tournament_config::Category::Qf,
"SF" => tournament_config::Category::Sf,
"F" => tournament_config::Category::F,
"GF" => tournament_config::Category::Gf,
_ => return Err(ApiResponse::bad_request("Invalid category")),
};
config.current_category = Set(category);
}
if let Some(can_register) = payload.can_register {
config.can_register = Set(can_register);
}
let config = config.update(conn).await.map_err(|e| {
tracing::error!("Failed to update tournament config: {}", e);
ApiResponse::internal_server_error("Failed to update tournament config")
})?;
Ok(ApiResponse::success(TournamentConfigResponse::from(config)))
}
/// 删除比赛设置
pub async fn delete_tournament_config(
State(db): State<Database>,
Path(config_id): Path<i32>,
) -> ApiResult<()> {
let conn = db.get_connection();
let config = TournamentConfig::find_by_id(config_id)
.one(conn)
.await
.map_err(|e| {
tracing::error!("Failed to fetch tournament config: {}", e);
ApiResponse::internal_server_error("Failed to fetch tournament config")
})?
.ok_or_else(|| ApiResponse::not_found("Tournament config not found"))?;
TournamentConfig::delete_by_id(config.id)
.exec(conn)
.await
.map_err(|e| {
tracing::error!("Failed to delete tournament config: {}", e);
ApiResponse::internal_server_error("Failed to delete tournament config")
})?;
Ok(ApiResponse::success_message(
"Tournament config deleted successfully",
))
}
/// 获取比赛设置总数
pub async fn get_tournament_configs_count(
State(db): State<Database>,
Query(query): Query<GetTournamentConfigsQuery>,
) -> ApiResult<u64> {
let conn = db.get_connection();
let mut query = TournamentConfig::find();
if let Some(current_seasonal) = query.current_seasonal {
query = query.filter(tournament_config::Column::CurrentSeasonal.eq(current_seasonal));
}
if let Some(current_category) = query.current_category {
query = query.filter(tournament_config::Column::CurrentCategory.eq(current_category));
}
if let Some(can_register) = query.can_register {
query = query.filter(tournament_config::Column::CanRegister.eq(can_register));
}
let count = query.count(conn).await.map_err(|e| {
tracing::error!("Failed to count tournament configs: {}", e);
ApiResponse::internal_server_error("Failed to count tournament configs")
})?;
Ok(ApiResponse::success(count))
}

View File

@@ -1,166 +0,0 @@
use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
Json,
};
use sea_orm::{DatabaseConnection, EntityTrait, PaginatorTrait, QueryOrder};
use serde::{Deserialize, Serialize};
use crate::{
database::Database,
entities::{prelude::*, user},
utils::response::{ApiResponse, ApiResult},
};
#[derive(Debug, Deserialize)]
pub struct GetUsersQuery {
page: Option<u64>,
limit: Option<u64>,
approved: Option<bool>,
seasonal: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct UserResponse {
id: i32,
osuid: i32,
username: String,
avatar_url: Option<String>,
cover_url: Option<String>,
country_code: String,
pp: f64,
global_rank: i32,
country_rank: i32,
approved: bool,
seed: i32,
seasonal: String,
user_state: String,
created_at: chrono::DateTime<chrono::Utc>,
updated_at: chrono::DateTime<chrono::Utc>,
}
impl From<user::Model> for UserResponse {
fn from(model: user::Model) -> Self {
Self {
id: model.id,
osuid: model.osuid,
username: model.username,
avatar_url: model.avatar_url,
cover_url: model.cover_url,
country_code: model.country_code,
pp: model.pp,
global_rank: model.global_rank,
country_rank: model.country_rank,
approved: model.approved,
seed: model.seed,
seasonal: model.seasonal.into(),
user_state: model.user_state.to_string(),
created_at: model.created_at,
updated_at: model.updated_at,
}
}
}
/// 获取用户列表
pub async fn get_users(
State(db): State<Database>,
Query(query): Query<GetUsersQuery>,
) -> ApiResult<Vec<UserResponse>> {
let conn = db.get_connection();
let page = query.page.unwrap_or(1);
let limit = query.limit.unwrap_or(20);
let offset = (page - 1) * limit;
let mut query = User::find()
.order_by_asc(user::Column::Id);
if let Some(approved) = query.approved {
query = query.filter(user::Column::Approved.eq(approved));
}
if let Some(seasonal) = query.seasonal {
query = query.filter(user::Column::Seasonal.eq(seasonal));
}
let users = query
.offset(offset)
.limit(limit)
.all(conn)
.await
.map_err(|e| {
tracing::error!("Failed to fetch users: {}", e);
ApiResponse::internal_server_error("Failed to fetch users")
})?;
let response = users.into_iter().map(UserResponse::from).collect();
Ok(ApiResponse::success(response))
}
/// 获取单个用户
pub async fn get_user(
State(db): State<Database>,
Path(user_id): Path<i32>,
) -> ApiResult<UserResponse> {
let conn = db.get_connection();
let user = User::find_by_id(user_id)
.one(conn)
.await
.map_err(|e| {
tracing::error!("Failed to fetch user: {}", e);
ApiResponse::internal_server_error("Failed to fetch user")
})?
.ok_or_else(|| ApiResponse::not_found("User not found"))?;
Ok(ApiResponse::success(UserResponse::from(user)))
}
/// 获取用户总数
pub async fn get_users_count(
State(db): State<Database>,
Query(query): Query<GetUsersQuery>,
) -> ApiResult<u64> {
let conn = db.get_connection();
let mut query = User::find();
if let Some(approved) = query.approved {
query = query.filter(user::Column::Approved.eq(approved));
}
if let Some(seasonal) = query.seasonal {
query = query.filter(user::Column::Seasonal.eq(seasonal));
}
let count = query
.count(conn)
.await
.map_err(|e| {
tracing::error!("Failed to count users: {}", e);
ApiResponse::internal_server_error("Failed to count users")
})?;
Ok(ApiResponse::success(count))
}
/// 通过osu ID获取用户
pub async fn get_user_by_osuid(
State(db): State<Database>,
Path(osuid): Path<i32>,
) -> ApiResult<UserResponse> {
let conn = db.get_connection();
let user = User::find()
.filter(user::Column::Osuid.eq(osuid))
.one(conn)
.await
.map_err(|e| {
tracing::error!("Failed to fetch user by osuid: {}", e);
ApiResponse::internal_server_error("Failed to fetch user")
})?
.ok_or_else(|| ApiResponse::not_found("User not found"))?;
Ok(ApiResponse::success(UserResponse::from(user)))
}

View File

@@ -1,58 +1,11 @@
mod config;
mod database;
mod handlers;
mod routes;
mod services;
mod utils;
use axum::{
http::{HeaderValue, Method},
Router,
};
use config::Settings;
use database::Database;
use routes::create_router;
use std::net::SocketAddr;
use tower_http::cors::CorsLayer;
use tracing::{info, Level};
use tracing_subscriber::FmtSubscriber;
use axum::{Router, routing::get};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Initialize logging
let subscriber = FmtSubscriber::builder()
.with_max_level(Level::INFO)
.finish();
tracing::subscriber::set_global_default(subscriber)?;
async fn main() {
// build our application with a single route
let app = Router::new().route("/", get(|| async { "Hello, World!" }));
info!("Starting AstraCup backend server...");
// Load configuration
let settings = Settings::from_env()?;
info!("Configuration loaded successfully");
// Initialize database connection
let database = Database::new(settings.database_url()).await?;
database.test_connection().await?;
info!("Database connection established");
// Create CORS layer
let cors = CorsLayer::new()
.allow_origin(settings.cors.origin.parse::<HeaderValue>()?)
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE, Method::PATCH])
.allow_headers(tower_http::cors::Any);
// Create application router
let app = create_router(database, settings.clone())
.layer(cors);
// Start server
let addr: SocketAddr = settings.server_address().parse()?;
info!("Server listening on {}", addr);
axum::Server::bind(&addr)
.serve(app.into_make_service())
.await?;
Ok(())
}
// run our app with hyper, listening globally on port 3000
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}

View File

@@ -1,114 +0,0 @@
use axum::{
Router,
extract::State,
response::IntoResponse,
routing::{delete, get, post, put},
};
use axum_extra::TypedHeader;
use axum_extra::headers::Authorization;
use axum_extra::headers::authorization::Bearer;
use crate::{
config::Settings,
database::Database,
handlers::{
auth::{auth_callback, get_auth_url, logout, verify_token},
health::health_check,
match_room::{
create_match_room, delete_match_room, get_match_room, get_match_rooms,
get_match_rooms_count, update_match_room,
},
message::{
delete_message, get_message, get_messages, get_messages_count,
get_unread_messages_count, send_message, update_message,
},
registration::{
approve_registration, delete_registration, get_approved_players, get_registrations,
get_registrations_count,
},
team::{get_team, get_teams, get_teams_count},
tournament_config::{
create_tournament_config, delete_tournament_config, get_current_tournament_config,
get_tournament_config, get_tournament_configs, get_tournament_configs_count,
update_tournament_config,
},
user::{get_user, get_user_by_osuid, get_users, get_users_count},
},
services::auth::AuthService,
};
pub fn create_router(database: Database, settings: Settings) -> Router {
let auth_service = AuthService::new(settings.clone());
Router::new()
// Health check
.route("/health", get(health_check))
// Auth routes
.route("/api/auth/url", get(get_auth_url))
.route("/api/auth/callback/osu", get(auth_callback))
.route("/api/auth/logout", post(logout))
.route("/api/auth/verify", get(verify_token_handler))
// User routes
.route("/api/users", get(get_users))
.route("/api/users/count", get(get_users_count))
.route("/api/users/:id", get(get_user))
.route("/api/users/osuid/:osuid", get(get_user_by_osuid))
// Registration routes
.route("/api/registrations", get(get_registrations))
.route("/api/registrations/count", get(get_registrations_count))
.route("/api/registrations/approved", get(get_approved_players))
.route(
"/api/admin/registrations/:id/approve",
post(approve_registration),
)
.route("/api/admin/registrations/:id", delete(delete_registration))
// Match room routes
.route("/api/match-rooms", get(get_match_rooms))
.route("/api/match-rooms/count", get(get_match_rooms_count))
.route("/api/match-rooms/:id", get(get_match_room))
.route("/api/match-rooms", post(create_match_room))
.route("/api/match-rooms/:id", put(update_match_room))
.route("/api/match-rooms/:id", delete(delete_match_room))
// Message routes
.route("/api/messages", get(get_messages))
.route("/api/messages/count", get(get_messages_count))
.route("/api/messages/:id", get(get_message))
.route("/api/messages", post(send_message))
.route("/api/messages/:id", put(update_message))
.route("/api/messages/:id", delete(delete_message))
.route(
"/api/messages/unread/:receiver_id",
get(get_unread_messages_count),
)
// Tournament config routes
.route("/api/tournament-configs", get(get_tournament_configs))
.route(
"/api/tournament-configs/count",
get(get_tournament_configs_count),
)
.route(
"/api/tournament-configs/current",
get(get_current_tournament_config),
)
.route("/api/tournament-configs/:id", get(get_tournament_config))
.route("/api/tournament-configs", post(create_tournament_config))
.route("/api/tournament-configs/:id", put(update_tournament_config))
.route(
"/api/tournament-configs/:id",
delete(delete_tournament_config),
)
// Team routes
.route("/api/teams", get(get_teams))
.route("/api/teams/count", get(get_teams_count))
.route("/api/teams/:id", get(get_team))
.with_state(auth_service)
.with_state(database)
}
// Handler for token verification with header extraction
async fn verify_token_handler(
TypedHeader(auth_header): TypedHeader<Authorization<Bearer>>,
State(auth_service): State<AuthService>,
) -> impl IntoResponse {
verify_token(State(auth_service), auth_header.token().to_string()).await
}

View File

@@ -1,3 +0,0 @@
pub mod api;
pub use api::create_router;

View File

@@ -1,4 +0,0 @@
pub mod service;
pub mod middleware;
pub use service::AuthService;

View File

@@ -1,151 +0,0 @@
use crate::config::Settings;
use crate::utils::AppError;
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug, Serialize, Deserialize)]
pub struct OsuUserInfo {
pub id: u64,
pub username: String,
pub avatar_url: String,
pub cover_url: String,
pub country_code: String,
pub statistics: Option<OsuStatistics>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct OsuStatistics {
pub pp: f64,
pub global_rank: Option<u64>,
pub country_rank: Option<u64>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct OsuTokenResponse {
pub access_token: String,
pub refresh_token: String,
pub expires_in: u64,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct JwtClaims {
pub sub: String, // osu! user ID
pub username: String,
pub exp: usize, // Expiration time
pub iat: usize, // Issued at
}
pub struct AuthService {
client: Client,
settings: Settings,
}
impl AuthService {
pub fn new(settings: Settings) -> Self {
Self {
client: Client::new(),
settings,
}
}
pub fn get_osu_auth_url(&self) -> String {
let params = vec![
("client_id", self.settings.oauth.client_id.as_str()),
("redirect_uri", self.settings.oauth.redirect_uri.as_str()),
("response_type", "code"),
("scope", "identify public"),
];
let query_string = params
.iter()
.map(|(k, v)| format!("{}={}", k, v))
.collect::<Vec<_>>()
.join("&");
format!("https://osu.ppy.sh/oauth/authorize?{}", query_string)
}
pub async fn exchange_code_for_token(&self, code: &str) -> Result<OsuTokenResponse, AppError> {
let params = [
("client_id", self.settings.oauth.client_id.as_str()),
("client_secret", self.settings.oauth.client_secret.as_str()),
("code", code),
("grant_type", "authorization_code"),
("redirect_uri", self.settings.oauth.redirect_uri.as_str()),
];
let response = self
.client
.post("https://osu.ppy.sh/oauth/token")
.form(&params)
.send()
.await?;
if !response.status().is_success() {
let error_text = response.text().await?;
return Err(AppError::Auth(format!(
"Failed to exchange code for token: {}",
error_text
)));
}
let token_data: OsuTokenResponse = response.json().await?;
Ok(token_data)
}
pub async fn get_osu_user_info(&self, access_token: &str) -> Result<OsuUserInfo, AppError> {
let response = self
.client
.get("https://osu.ppy.sh/api/v2/me")
.header("Authorization", format!("Bearer {}", access_token))
.send()
.await?;
if !response.status().is_success() {
let error_text = response.text().await?;
return Err(AppError::Auth(format!(
"Failed to get user info: {}",
error_text
)));
}
let user_info: OsuUserInfo = response.json().await?;
Ok(user_info)
}
pub fn generate_jwt(&self, user_info: &OsuUserInfo) -> Result<String, AppError> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs() as usize;
let expiry = now + (self.settings.jwt.expiry_hours as usize * 3600);
let claims = JwtClaims {
sub: user_info.id.to_string(),
username: user_info.username.clone(),
exp: expiry,
iat: now,
};
let token = encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(self.settings.jwt.secret.as_bytes()),
)?;
Ok(token)
}
pub fn verify_jwt(&self, token: &str) -> Result<JwtClaims, AppError> {
let token_data = decode::<JwtClaims>(
token,
&DecodingKey::from_secret(self.settings.jwt.secret.as_bytes()),
&Validation::default(),
)?;
Ok(token_data.claims)
}
}

View File

@@ -1,111 +0,0 @@
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde::Serialize;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum AppError {
#[error("Database error: {0}")]
Database(#[from] sea_orm::DbErr),
#[error("Validation error: {0}")]
Validation(String),
#[error("Authentication error: {0}")]
Auth(String),
#[error("Not found: {0}")]
NotFound(String),
#[error("Internal server error: {0}")]
Internal(String),
#[error("Bad request: {0}")]
BadRequest(String),
#[error("Forbidden: {0}")]
Forbidden(String),
#[error("Conflict: {0}")]
Conflict(String),
}
impl AppError {
pub fn status_code(&self) -> StatusCode {
match self {
AppError::Database(_) | AppError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
AppError::Validation(_) | AppError::BadRequest(_) => StatusCode::BAD_REQUEST,
AppError::Auth(_) => StatusCode::UNAUTHORIZED,
AppError::NotFound(_) => StatusCode::NOT_FOUND,
AppError::Forbidden(_) => StatusCode::FORBIDDEN,
AppError::Conflict(_) => StatusCode::CONFLICT,
}
}
pub fn error_code(&self) -> &'static str {
match self {
AppError::Database(_) => "DATABASE_ERROR",
AppError::Validation(_) => "VALIDATION_ERROR",
AppError::Auth(_) => "AUTH_ERROR",
AppError::NotFound(_) => "NOT_FOUND",
AppError::Internal(_) => "INTERNAL_ERROR",
AppError::BadRequest(_) => "BAD_REQUEST",
AppError::Forbidden(_) => "FORBIDDEN",
AppError::Conflict(_) => "CONFLICT",
}
}
}
#[derive(Serialize)]
pub struct ErrorResponse {
pub success: bool,
pub error: String,
pub code: String,
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let status = self.status_code();
let error_response = ErrorResponse {
success: false,
error: self.to_string(),
code: self.error_code().to_string(),
};
(status, Json(error_response)).into_response()
}
}
// Convert common errors to AppError
impl From<validator::ValidationErrors> for AppError {
fn from(err: validator::ValidationErrors) -> Self {
AppError::Validation(err.to_string())
}
}
impl From<jsonwebtoken::errors::Error> for AppError {
fn from(err: jsonwebtoken::errors::Error) -> Self {
AppError::Auth(err.to_string())
}
}
impl From<reqwest::Error> for AppError {
fn from(err: reqwest::Error) -> Self {
AppError::Internal(err.to_string())
}
}
impl From<std::env::VarError> for AppError {
fn from(err: std::env::VarError) -> Self {
AppError::Internal(err.to_string())
}
}
impl From<serde_json::Error> for AppError {
fn from(err: serde_json::Error) -> Self {
AppError::Internal(err.to_string())
}
}

View File

@@ -1,6 +0,0 @@
pub mod error;
pub mod response;
pub mod validation;
pub use error::AppError;
pub use response::ApiResponse;

View File

@@ -1,97 +0,0 @@
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde::Serialize;
#[derive(Serialize)]
pub struct ApiResponse<T> {
pub success: bool,
pub data: Option<T>,
pub message: Option<String>,
}
impl<T> ApiResponse<T>
where
T: Serialize,
{
pub fn success(data: T) -> Self {
Self {
success: true,
data: Some(data),
message: None,
}
}
pub fn success_with_message(data: T, message: &str) -> Self {
Self {
success: true,
data: Some(data),
message: Some(message.to_string()),
}
}
pub fn empty_success() -> Self {
Self {
success: true,
data: None,
message: None,
}
}
pub fn success_message(message: &str) -> Self {
Self {
success: true,
data: None,
message: Some(message.to_string()),
}
}
}
impl<T> IntoResponse for ApiResponse<T>
where
T: Serialize,
{
fn into_response(self) -> Response {
let status = if self.success {
StatusCode::OK
} else {
StatusCode::BAD_REQUEST
};
(status, Json(self)).into_response()
}
}
// Helper functions for common responses
pub fn created<T>(data: T) -> (StatusCode, Json<ApiResponse<T>>)
where
T: Serialize,
{
(
StatusCode::CREATED,
Json(ApiResponse::success_with_message(data, "Created successfully")),
)
}
pub fn ok<T>(data: T) -> (StatusCode, Json<ApiResponse<T>>)
where
T: Serialize,
{
(StatusCode::OK, Json(ApiResponse::success(data)))
}
pub fn ok_message(message: &str) -> (StatusCode, Json<ApiResponse<()>>) {
(
StatusCode::OK,
Json(ApiResponse::success_message(message)),
)
}
pub fn no_content() -> (StatusCode, Json<ApiResponse<()>>) {
(
StatusCode::NO_CONTENT,
Json(ApiResponse::empty_success()),
)
}