init
This commit is contained in:
22
.env.example
Normal file
22
.env.example
Normal file
@@ -0,0 +1,22 @@
|
||||
# Database Configuration
|
||||
DATABASE_URL=mysql://username:password@localhost:3306/astracup
|
||||
|
||||
# OAuth Configuration
|
||||
OSU_CLIENT_ID=your_osu_client_id
|
||||
OSU_CLIENT_SECRET=your_osu_client_secret
|
||||
OSU_REDIRECT_URI=http://localhost:3000/api/auth/callback/osu
|
||||
|
||||
# JWT Configuration
|
||||
JWT_SECRET=your_jwt_secret_key_here_change_this_to_something_secure
|
||||
JWT_EXPIRY_HOURS=24
|
||||
|
||||
# Server Configuration
|
||||
PORT=3001
|
||||
HOST=0.0.0.0
|
||||
LOG_LEVEL=info
|
||||
|
||||
# CORS Configuration
|
||||
CORS_ORIGIN=http://localhost:3000
|
||||
|
||||
# Redis Configuration (Optional)
|
||||
REDIS_URL=redis://localhost:6379
|
||||
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
target
|
||||
4169
Cargo.lock
generated
Normal file
4169
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
71
Cargo.toml
Normal file
71
Cargo.toml
Normal file
@@ -0,0 +1,71 @@
|
||||
[package]
|
||||
name = "astracup-backend"
|
||||
version = "0.1.0"
|
||||
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.7", features = ["macros"] }
|
||||
tokio = { version = "1.0", features = ["full"] }
|
||||
tower = "0.4"
|
||||
tower-http = { version = "0.5", features = ["cors", "trace"] }
|
||||
|
||||
# Database
|
||||
sea-orm = { version = "0.12", features = ["sqlx-mysql", "runtime-tokio-native-tls", "macros"] }
|
||||
sqlx = { version = "0.7", features = ["mysql", "runtime-tokio-native-tls"] }
|
||||
dotenv = "0.15"
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
# Authentication and security
|
||||
jsonwebtoken = "9.0"
|
||||
bcrypt = "0.15"
|
||||
argon2 = "0.5"
|
||||
rand = "0.8"
|
||||
|
||||
# HTTP client for OAuth
|
||||
reqwest = { version = "0.11", features = ["json"] }
|
||||
|
||||
# Configuration
|
||||
config = "0.13"
|
||||
anyhow = "1.0"
|
||||
thiserror = "1.0"
|
||||
|
||||
# Logging and tracing
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
# Validation
|
||||
validator = { version = "0.16", features = ["derive"] }
|
||||
|
||||
# Utilities
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
uuid = { version = "1.0", features = ["v4", "serde"] }
|
||||
futures = "0.3"
|
||||
|
||||
[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", features = ["sqlx-mysql"] }
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 0
|
||||
debug = true
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
289
README.md
Normal file
289
README.md
Normal file
@@ -0,0 +1,289 @@
|
||||
# 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 文件
|
||||
304
RUST_MIGRATION_PLAN.md
Normal file
304
RUST_MIGRATION_PLAN.md
Normal file
@@ -0,0 +1,304 @@
|
||||
# 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端点作为示例
|
||||
14
migration/Cargo.toml
Normal file
14
migration/Cargo.toml
Normal file
@@ -0,0 +1,14 @@
|
||||
[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"] }
|
||||
14
migration/src/lib.rs
Normal file
14
migration/src/lib.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
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),
|
||||
]
|
||||
}
|
||||
}
|
||||
734
migration/src/m20250210_000001_create_tables.rs
Normal file
734
migration/src/m20250210_000001_create_tables.rs
Normal file
@@ -0,0 +1,734 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
// 创建枚举类型
|
||||
manager
|
||||
.create_type(
|
||||
Type::create()
|
||||
.as_enum(BeatmapSelectMod::Table)
|
||||
.values([
|
||||
BeatmapSelectMod::Nm,
|
||||
BeatmapSelectMod::Hd,
|
||||
BeatmapSelectMod::Hr,
|
||||
BeatmapSelectMod::Dt,
|
||||
BeatmapSelectMod::Lz,
|
||||
BeatmapSelectMod::Tb,
|
||||
])
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_type(
|
||||
Type::create()
|
||||
.as_enum(MultiplayerRoomType::Table)
|
||||
.values([
|
||||
MultiplayerRoomType::Solo,
|
||||
MultiplayerRoomType::TeamVs,
|
||||
])
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_type(
|
||||
Type::create()
|
||||
.as_enum(TeamColor::Table)
|
||||
.values([
|
||||
TeamColor::BlueTeam,
|
||||
TeamColor::RedTeam,
|
||||
])
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_type(
|
||||
Type::create()
|
||||
.as_enum(TeamState::Table)
|
||||
.values([
|
||||
TeamState::Active,
|
||||
TeamState::Unapproved,
|
||||
TeamState::Approved,
|
||||
TeamState::Banned,
|
||||
])
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_type(
|
||||
Type::create()
|
||||
.as_enum(UserState::Table)
|
||||
.values([
|
||||
UserState::Active,
|
||||
UserState::Registered,
|
||||
UserState::Abandoned,
|
||||
UserState::Banned,
|
||||
])
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_type(
|
||||
Type::create()
|
||||
.as_enum(UserGroupType::Table)
|
||||
.values([
|
||||
UserGroupType::Host,
|
||||
UserGroupType::Admin,
|
||||
UserGroupType::Pooler,
|
||||
UserGroupType::Streamer,
|
||||
UserGroupType::Tester,
|
||||
UserGroupType::Gfx,
|
||||
UserGroupType::Sheeter,
|
||||
UserGroupType::Commentator,
|
||||
UserGroupType::Mapper,
|
||||
UserGroupType::Referee,
|
||||
UserGroupType::Player,
|
||||
])
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_type(
|
||||
Type::create()
|
||||
.as_enum(Season::Table)
|
||||
.values([
|
||||
Season::S1,
|
||||
Season::S2,
|
||||
])
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_type(
|
||||
Type::create()
|
||||
.as_enum(Category::Table)
|
||||
.values([
|
||||
Category::Qua,
|
||||
Category::Ro16,
|
||||
Category::Qf,
|
||||
Category::Sf,
|
||||
Category::F,
|
||||
Category::Gf,
|
||||
])
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 创建用户表
|
||||
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::UserState).custom(UserState::Table).not_null().default("ACTIVE"))
|
||||
.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::CountryCode).string().not_null())
|
||||
.col(ColumnDef::new(User::Pp).double().not_null().default(0.0))
|
||||
.col(ColumnDef::new(User::GlobalRank).integer().not_null().default(0))
|
||||
.col(ColumnDef::new(User::CountryRank).integer().not_null().default(0))
|
||||
.col(ColumnDef::new(User::Approved).boolean().not_null().default(false))
|
||||
.col(ColumnDef::new(User::Seed).integer().not_null().default(0))
|
||||
.col(ColumnDef::new(User::Seasonal).custom(Season::Table).not_null().default("S1"))
|
||||
.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?;
|
||||
|
||||
// 创建队伍表
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Team::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(Team::Id)
|
||||
.integer()
|
||||
.not_null()
|
||||
.auto_increment()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(Team::Name).string().not_null())
|
||||
.col(ColumnDef::new(Team::BannerUrl).string().not_null())
|
||||
.col(ColumnDef::new(Team::TeamColor).custom(TeamColor::Table).not_null())
|
||||
.col(ColumnDef::new(Team::TeamState).custom(TeamState::Table).not_null().default("ACTIVE"))
|
||||
.col(ColumnDef::new(Team::CreatedAt).timestamp().not_null().default(Expr::current_timestamp()))
|
||||
.col(ColumnDef::new(Team::UpdatedAt).timestamp().not_null().default(Expr::current_timestamp()))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 创建比赛配置表
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(TournamentConfig::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(TournamentConfig::Id)
|
||||
.integer()
|
||||
.not_null()
|
||||
.auto_increment()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(TournamentConfig::TournamentName).string().not_null().default("AstarCup"))
|
||||
.col(ColumnDef::new(TournamentConfig::MaxPpForRegistration).double().not_null().default(0.0))
|
||||
.col(ColumnDef::new(TournamentConfig::MinPpForRegistration).double().not_null().default(0.0))
|
||||
.col(ColumnDef::new(TournamentConfig::CurrentSeasonal).custom(Season::Table).not_null().default("S1"))
|
||||
.col(ColumnDef::new(TournamentConfig::CurrentCategory).custom(Category::Table).not_null().default("QUA"))
|
||||
.col(ColumnDef::new(TournamentConfig::CanRegister).boolean().not_null().default(false))
|
||||
.col(ColumnDef::new(TournamentConfig::CreatedAt).timestamp().not_null().default(Expr::current_timestamp()))
|
||||
.col(ColumnDef::new(TournamentConfig::UpdatedAt).timestamp().not_null().default(Expr::current_timestamp()))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 创建多人游戏房间表
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(MultiplayerRoom::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(MultiplayerRoom::RoomId)
|
||||
.integer()
|
||||
.not_null()
|
||||
.auto_increment()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(MultiplayerRoom::Type).custom(MultiplayerRoomType::Table).not_null().default("SOLO"))
|
||||
.col(ColumnDef::new(MultiplayerRoom::Season).custom(Season::Table).not_null().default("S1"))
|
||||
.col(ColumnDef::new(MultiplayerRoom::Category).custom(Category::Table).not_null().default("QUA"))
|
||||
.col(ColumnDef::new(MultiplayerRoom::MultiplayerSoloRoomRoomId).integer().null())
|
||||
.col(ColumnDef::new(MultiplayerRoom::MultiplayerTeamvsRoomRoomId).integer().null())
|
||||
.col(ColumnDef::new(MultiplayerRoom::ScoreRed).integer().not_null().default(0))
|
||||
.col(ColumnDef::new(MultiplayerRoom::ScoreBlue).integer().not_null().default(0))
|
||||
.col(ColumnDef::new(MultiplayerRoom::StartTime).timestamp().not_null())
|
||||
.col(ColumnDef::new(MultiplayerRoom::EndTime).timestamp().not_null())
|
||||
.col(ColumnDef::new(MultiplayerRoom::CreatedAt).timestamp().not_null().default(Expr::current_timestamp()))
|
||||
.col(ColumnDef::new(MultiplayerRoom::UpdatedAt).timestamp().not_null().default(Expr::current_timestamp()))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 创建单人游戏房间表
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(MultiplayerSoloRoom::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(MultiplayerSoloRoom::RoomId)
|
||||
.integer()
|
||||
.not_null()
|
||||
.auto_increment()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(MultiplayerSoloRoom::PlayerRedId).integer().not_null().unique_key())
|
||||
.col(ColumnDef::new(MultiplayerSoloRoom::PlayerBlueId).integer().not_null().unique_key())
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 创建团队对战房间表
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(MultiplayerTeamvsRoom::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(MultiplayerTeamvsRoom::RoomId)
|
||||
.integer()
|
||||
.not_null()
|
||||
.auto_increment()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(MultiplayerTeamvsRoom::TeamRedId).integer().not_null().unique_key())
|
||||
.col(ColumnDef::new(MultiplayerTeamvsRoom::TeamBlueId).integer().not_null().unique_key())
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 创建消息表
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Message::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(Message::Id)
|
||||
.integer()
|
||||
.not_null()
|
||||
.auto_increment()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(Message::Title).string().not_null())
|
||||
.col(ColumnDef::new(Message::Content).text().not_null())
|
||||
.col(ColumnDef::new(Message::SenderId).integer().not_null())
|
||||
.col(ColumnDef::new(Message::ReceiverId).integer().not_null())
|
||||
.col(ColumnDef::new(Message::RoomId).integer().null())
|
||||
.col(ColumnDef::new(Message::IsRead).boolean().not_null().default(false))
|
||||
.col(ColumnDef::new(Message::IsDeleted).boolean().not_null().default(false))
|
||||
.col(ColumnDef::new(Message::IsSystemMessage).boolean().not_null().default(false))
|
||||
.col(ColumnDef::new(Message::IsAccepted).boolean().not_null().default(false))
|
||||
.col(ColumnDef::new(Message::CreatedAt).timestamp().not_null().default(Expr::current_timestamp()))
|
||||
.col(ColumnDef::new(Message::UpdatedAt).timestamp().not_null().default(Expr::current_timestamp()))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 创建外键约束
|
||||
manager
|
||||
.create_foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_multiplayer_room_solo_room")
|
||||
.from(MultiplayerRoom::Table, MultiplayerRoom::MultiplayerSoloRoomRoomId)
|
||||
.to(MultiplayerSoloRoom::Table, MultiplayerSoloRoom::RoomId)
|
||||
.on_delete(ForeignKeyAction::SetNull)
|
||||
.on_update(ForeignKeyAction::Cascade)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_multiplayer_room_teamvs_room")
|
||||
.from(MultiplayerRoom::Table, MultiplayerRoom::MultiplayerTeamvsRoomRoomId)
|
||||
.to(MultiplayerTeamvsRoom::Table, MultiplayerTeamvsRoom::RoomId)
|
||||
.on_delete(ForeignKeyAction::SetNull)
|
||||
.on_update(ForeignKeyAction::Cascade)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_multiplayer_solo_room_player_red")
|
||||
.from(MultiplayerSoloRoom::Table, MultiplayerSoloRoom::PlayerRedId)
|
||||
.to(User::Table, User::Osuid)
|
||||
.on_delete(ForeignKeyAction::Restrict)
|
||||
.on_update(ForeignKeyAction::Cascade)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_multiplayer_solo_room_player_blue")
|
||||
.from(MultiplayerSoloRoom::Table, MultiplayerSoloRoom::PlayerBlueId)
|
||||
.to(User::Table, User::Osuid)
|
||||
.on_delete(ForeignKeyAction::Restrict)
|
||||
.on_update(ForeignKeyAction::Cascade)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_multiplayer_teamvs_room_team_red")
|
||||
.from(MultiplayerTeamvsRoom::Table, MultiplayerTeamvsRoom::TeamRedId)
|
||||
.to(Team::Table, Team::Id)
|
||||
.on_delete(ForeignKeyAction::Restrict)
|
||||
.on_update(ForeignKeyAction::Cascade)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_multiplayer_teamvs_room_team_blue")
|
||||
.from(MultiplayerTeamvsRoom::Table, MultiplayerTeamvsRoom::TeamBlueId)
|
||||
.to(Team::Table, Team::Id)
|
||||
.on_delete(ForeignKeyAction::Restrict)
|
||||
.on_update(ForeignKeyAction::Cascade)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_message_sender")
|
||||
.from(Message::Table, Message::SenderId)
|
||||
.to(User::Table, User::Osuid)
|
||||
.on_delete(ForeignKeyAction::Restrict)
|
||||
.on_update(ForeignKeyAction::Cascade)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_message_receiver")
|
||||
.from(Message::Table, Message::ReceiverId)
|
||||
.to(User::Table, User::Osuid)
|
||||
.on_delete(ForeignKeyAction::Restrict)
|
||||
.on_update(ForeignKeyAction::Cascade)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_message_room")
|
||||
.from(Message::Table, Message::RoomId)
|
||||
.to(MultiplayerRoom::Table, MultiplayerRoom::RoomId)
|
||||
.on_delete(ForeignKeyAction::SetNull)
|
||||
.on_update(ForeignKeyAction::Cascade)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
// 删除外键约束
|
||||
manager
|
||||
.drop_foreign_key(
|
||||
ForeignKey::drop()
|
||||
.name("fk_message_room")
|
||||
.table(Message::Table)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.drop_foreign_key(
|
||||
ForeignKey::drop()
|
||||
.name("fk_message_receiver")
|
||||
.table(Message::Table)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.drop_foreign_key(
|
||||
ForeignKey::drop()
|
||||
.name("fk_message_sender")
|
||||
.table(Message::Table)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.drop_foreign_key(
|
||||
ForeignKey::drop()
|
||||
.name("fk_multiplayer_teamvs_room_team_blue")
|
||||
.table(MultiplayerTeamvsRoom::Table)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.drop_foreign_key(
|
||||
ForeignKey::drop()
|
||||
.name("fk_multiplayer_teamvs_room_team_red")
|
||||
.table(MultiplayerTeamvsRoom::Table)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.drop_foreign_key(
|
||||
ForeignKey::drop()
|
||||
.name("fk_multiplayer_solo_room_player_blue")
|
||||
.table(MultiplayerSoloRoom::Table)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.drop_foreign_key(
|
||||
ForeignKey::drop()
|
||||
.name("fk_multiplayer_solo_room_player_red")
|
||||
.table(MultiplayerSoloRoom::Table)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.drop_foreign_key(
|
||||
ForeignKey::drop()
|
||||
.name("fk_multiplayer_room_teamvs_room")
|
||||
.table(MultiplayerRoom::Table)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.drop_foreign_key(
|
||||
ForeignKey::drop()
|
||||
.name("fk_multiplayer_room_solo_room")
|
||||
.table(MultiplayerRoom::Table)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 删除表
|
||||
manager
|
||||
.drop_table(Table::drop().table(Message::Table).to_owned())
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.drop_table(Table::drop().table(MultiplayerTeamvsRoom::Table).to_owned())
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.drop_table(Table::drop().table(MultiplayerSoloRoom::Table).to_owned())
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.drop_table(Table::drop().table(MultiplayerRoom::Table).to_owned())
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.drop_table(Table::drop().table(TournamentConfig::Table).to_owned())
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.drop_table(Table::drop().table(Team::Table).to_owned())
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.drop_table(Table::drop().table(User::Table).to_owned())
|
||||
.await?;
|
||||
|
||||
// 删除枚举类型
|
||||
manager
|
||||
.drop_type(Type::drop().name(Category::Table).to_owned())
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.drop_type(Type::drop().name(Season::Table).to_owned())
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.drop_type(Type::drop().name(UserGroupType::Table).to_owned())
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.drop_type(Type::drop().name(UserState::Table).to_owned())
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.drop_type(Type::drop().name(TeamState::Table).to_owned())
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.drop_type(Type::drop().name(TeamColor::Table).to_owned())
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.drop_type(Type::drop().name(MultiplayerRoomType::Table).to_owned())
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.drop_type(Type::drop().name(BeatmapSelectMod::Table).to_owned())
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// 定义表名枚举
|
||||
#[derive(Iden)]
|
||||
enum User {
|
||||
Table,
|
||||
Id,
|
||||
UserState,
|
||||
Osuid,
|
||||
Username,
|
||||
AvatarUrl,
|
||||
CoverUrl,
|
||||
CountryCode,
|
||||
Pp,
|
||||
GlobalRank,
|
||||
CountryRank,
|
||||
Approved,
|
||||
Seed,
|
||||
Seasonal,
|
||||
CreatedAt,
|
||||
UpdatedAt,
|
||||
}
|
||||
|
||||
#[derive(Iden)]
|
||||
enum Team {
|
||||
Table,
|
||||
Id,
|
||||
Name,
|
||||
BannerUrl,
|
||||
TeamColor,
|
||||
TeamState,
|
||||
CreatedAt,
|
||||
UpdatedAt,
|
||||
}
|
||||
|
||||
#[derive(Iden)]
|
||||
enum TournamentConfig {
|
||||
Table,
|
||||
Id,
|
||||
TournamentName,
|
||||
MaxPpForRegistration,
|
||||
MinPpForRegistration,
|
||||
CurrentSeasonal,
|
||||
CurrentCategory,
|
||||
CanRegister,
|
||||
CreatedAt,
|
||||
UpdatedAt,
|
||||
}
|
||||
|
||||
#[derive(Iden)]
|
||||
enum MultiplayerRoom {
|
||||
Table,
|
||||
RoomId,
|
||||
Type,
|
||||
Season,
|
||||
Category,
|
||||
MultiplayerSoloRoomRoomId,
|
||||
MultiplayerTeamvsRoomRoomId,
|
||||
ScoreRed,
|
||||
ScoreBlue,
|
||||
StartTime,
|
||||
EndTime,
|
||||
CreatedAt,
|
||||
UpdatedAt,
|
||||
}
|
||||
|
||||
#[derive(Iden)]
|
||||
enum MultiplayerSoloRoom {
|
||||
Table,
|
||||
RoomId,
|
||||
PlayerRedId,
|
||||
PlayerBlueId,
|
||||
}
|
||||
|
||||
#[derive(Iden)]
|
||||
enum MultiplayerTeamvsRoom {
|
||||
Table,
|
||||
RoomId,
|
||||
TeamRedId,
|
||||
TeamBlueId,
|
||||
}
|
||||
|
||||
#[derive(Iden)]
|
||||
enum Message {
|
||||
Table,
|
||||
Id,
|
||||
Title,
|
||||
Content,
|
||||
SenderId,
|
||||
ReceiverId,
|
||||
RoomId,
|
||||
IsRead,
|
||||
IsDeleted,
|
||||
IsSystemMessage,
|
||||
IsAccepted,
|
||||
CreatedAt,
|
||||
UpdatedAt,
|
||||
}
|
||||
|
||||
// 定义枚举类型名
|
||||
#[derive(Iden)]
|
||||
enum BeatmapSelectMod {
|
||||
Table,
|
||||
Nm,
|
||||
Hd,
|
||||
Hr,
|
||||
Dt,
|
||||
Lz,
|
||||
Tb,
|
||||
}
|
||||
|
||||
#[derive(Iden)]
|
||||
enum MultiplayerRoomType {
|
||||
Table,
|
||||
Solo,
|
||||
TeamVs,
|
||||
}
|
||||
|
||||
#[derive(Iden)]
|
||||
enum TeamColor {
|
||||
Table,
|
||||
BlueTeam,
|
||||
RedTeam,
|
||||
}
|
||||
|
||||
#[derive(Iden)]
|
||||
enum TeamState {
|
||||
Table,
|
||||
Active,
|
||||
Unapproved,
|
||||
Approved,
|
||||
Banned,
|
||||
}
|
||||
|
||||
#[derive(Iden)]
|
||||
enum UserState {
|
||||
Table,
|
||||
Active,
|
||||
Registered,
|
||||
Abandoned,
|
||||
Banned,
|
||||
}
|
||||
|
||||
#[derive(Iden)]
|
||||
enum UserGroupType {
|
||||
Table,
|
||||
Host,
|
||||
Admin,
|
||||
Pooler,
|
||||
Streamer,
|
||||
Tester,
|
||||
Gfx,
|
||||
Sheeter,
|
||||
Commentator,
|
||||
Mapper,
|
||||
Referee,
|
||||
Player,
|
||||
}
|
||||
|
||||
#[derive(Iden)]
|
||||
enum Season {
|
||||
Table,
|
||||
S1,
|
||||
S2,
|
||||
}
|
||||
|
||||
#[derive(Iden)]
|
||||
enum Category {
|
||||
Table,
|
||||
Qua,
|
||||
Ro16,
|
||||
Qf,
|
||||
Sf,
|
||||
F,
|
||||
Gf,
|
||||
}
|
||||
6
migration/src/main.rs
Normal file
6
migration/src/main.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[async_std::main]
|
||||
async fn main() {
|
||||
cli::run_cli(migration::Migrator).await;
|
||||
}
|
||||
3
src/config/mod.rs
Normal file
3
src/config/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod settings;
|
||||
|
||||
pub use settings::Settings;
|
||||
102
src/config/settings.rs
Normal file
102
src/config/settings.rs
Normal file
@@ -0,0 +1,102 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
39
src/database/connection.rs
Normal file
39
src/database/connection.rs
Normal file
@@ -0,0 +1,39 @@
|
||||
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(())
|
||||
}
|
||||
}
|
||||
3
src/database/mod.rs
Normal file
3
src/database/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod connection;
|
||||
|
||||
pub use connection::Database;
|
||||
76
src/entities/message.rs
Normal file
76
src/entities/message.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
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 {}
|
||||
17
src/entities/mod.rs
Normal file
17
src/entities/mod.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
pub mod prelude;
|
||||
|
||||
pub use prelude::*;
|
||||
|
||||
// 实体定义 - 基于Prisma模型(完全迁移)
|
||||
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;
|
||||
90
src/entities/multiplayer_room.rs
Normal file
90
src/entities/multiplayer_room.rs
Normal file
@@ -0,0 +1,90 @@
|
||||
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 {}
|
||||
53
src/entities/multiplayer_solo_room.rs
Normal file
53
src/entities/multiplayer_solo_room.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
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 {}
|
||||
53
src/entities/multiplayer_teamvs_room.rs
Normal file
53
src/entities/multiplayer_teamvs_room.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
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 {}
|
||||
17
src/entities/prelude.rs
Normal file
17
src/entities/prelude.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
//! 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,
|
||||
};
|
||||
66
src/entities/team.rs
Normal file
66
src/entities/team.rs
Normal file
@@ -0,0 +1,66 @@
|
||||
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 {}
|
||||
142
src/entities/tournament_config.rs
Normal file
142
src/entities/tournament_config.rs
Normal file
@@ -0,0 +1,142 @@
|
||||
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 {}
|
||||
106
src/entities/user.rs
Normal file
106
src/entities/user.rs
Normal file
@@ -0,0 +1,106 @@
|
||||
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 {}
|
||||
65
src/entities/user_group.rs
Normal file
65
src/entities/user_group.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
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 {}
|
||||
113
src/handlers/auth/handlers.rs
Normal file
113
src/handlers/auth/handlers.rs
Normal file
@@ -0,0 +1,113 @@
|
||||
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,
|
||||
})),
|
||||
}
|
||||
}
|
||||
3
src/handlers/auth/mod.rs
Normal file
3
src/handlers/auth/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod handlers;
|
||||
|
||||
pub use handlers::*;
|
||||
19
src/handlers/health.rs
Normal file
19
src/handlers/health.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
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)
|
||||
}
|
||||
294
src/handlers/match_room.rs
Normal file
294
src/handlers/match_room.rs
Normal file
@@ -0,0 +1,294 @@
|
||||
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))
|
||||
}
|
||||
317
src/handlers/message.rs
Normal file
317
src/handlers/message.rs
Normal file
@@ -0,0 +1,317 @@
|
||||
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))
|
||||
}
|
||||
8
src/handlers/mod.rs
Normal file
8
src/handlers/mod.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
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;
|
||||
222
src/handlers/registration.rs
Normal file
222
src/handlers/registration.rs
Normal file
@@ -0,0 +1,222 @@
|
||||
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))
|
||||
}
|
||||
128
src/handlers/team.rs
Normal file
128
src/handlers/team.rs
Normal file
@@ -0,0 +1,128 @@
|
||||
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))
|
||||
}
|
||||
310
src/handlers/tournament_config.rs
Normal file
310
src/handlers/tournament_config.rs
Normal file
@@ -0,0 +1,310 @@
|
||||
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))
|
||||
}
|
||||
166
src/handlers/user.rs
Normal file
166
src/handlers/user.rs
Normal file
@@ -0,0 +1,166 @@
|
||||
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)))
|
||||
}
|
||||
58
src/main.rs
Normal file
58
src/main.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
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;
|
||||
|
||||
#[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)?;
|
||||
|
||||
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(())
|
||||
}
|
||||
114
src/routes/api.rs
Normal file
114
src/routes/api.rs
Normal file
@@ -0,0 +1,114 @@
|
||||
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
|
||||
}
|
||||
3
src/routes/mod.rs
Normal file
3
src/routes/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod api;
|
||||
|
||||
pub use api::create_router;
|
||||
4
src/services/auth/mod.rs
Normal file
4
src/services/auth/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod service;
|
||||
pub mod middleware;
|
||||
|
||||
pub use service::AuthService;
|
||||
151
src/services/auth/service.rs
Normal file
151
src/services/auth/service.rs
Normal file
@@ -0,0 +1,151 @@
|
||||
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(¶ms)
|
||||
.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)
|
||||
}
|
||||
}
|
||||
111
src/utils/error.rs
Normal file
111
src/utils/error.rs
Normal file
@@ -0,0 +1,111 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
6
src/utils/mod.rs
Normal file
6
src/utils/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
pub mod error;
|
||||
pub mod response;
|
||||
pub mod validation;
|
||||
|
||||
pub use error::AppError;
|
||||
pub use response::ApiResponse;
|
||||
97
src/utils/response.rs
Normal file
97
src/utils/response.rs
Normal file
@@ -0,0 +1,97 @@
|
||||
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()),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user