清空ai写的,我想自己写
This commit is contained in:
27
.env
27
.env
@@ -1,27 +0,0 @@
|
||||
# Database Configuration
|
||||
DATABASE_URL=mysql://root:114514@localhost:3306/astarcupDev
|
||||
|
||||
# OAuth Configuration
|
||||
OSU_CLIENT_ID = 43674
|
||||
OSU_CLIENT_SECRET = 1HAJGGeerfrXficVtVWN5wD0KbyKnbeiyhCCXLwp
|
||||
OSU_REDIRECT_URI = http://localhost:3000/api/auth/callback/osu
|
||||
|
||||
# OAuth Configuration for g0v0.top server
|
||||
GU_CLIENT_ID = 13
|
||||
GU_CLIENT_SECRET = fdcab562f6cfd5c62c0345e40791e7273ab8d4ed5459a4bac690d91f5c3f3b3f
|
||||
GU_REDIRECT_URI = http://localhost:3000/api/auth/callback/gu
|
||||
|
||||
# JWT Configuration
|
||||
JWT_SECRET=457ceac798afbc2e305c4833844a2736
|
||||
JWT_EXPIRY_HOURS=24
|
||||
|
||||
# Server Configuration
|
||||
PORT=3001
|
||||
HOST=localhost
|
||||
LOG_LEVEL=info
|
||||
|
||||
# CORS Configuration
|
||||
CORS_ORIGIN=http://localhost:3000
|
||||
|
||||
# Redis Configuration (Optional)
|
||||
REDIS_URL=redis://localhost:6379
|
||||
71
Cargo.toml
71
Cargo.toml
@@ -1,71 +0,0 @@
|
||||
[package]
|
||||
name = "astracup-backend"
|
||||
version = "0.0.1"
|
||||
edition = "2024"
|
||||
authors = ["AstraCup Team"]
|
||||
description = "AstraCup tournament management backend"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/AECBanana/astracup-web"
|
||||
|
||||
[dependencies]
|
||||
# Web framework
|
||||
axum = { version = "0.8.8", features = ["macros"] }
|
||||
tokio = { version = "1.49", features = ["full"] }
|
||||
tower = "0.5.3"
|
||||
tower-http = { version = "0.6.8", features = ["cors", "trace"] }
|
||||
|
||||
# Database
|
||||
sea-orm = { version = "1.1.19", features = ["sqlx-mysql", "runtime-tokio-native-tls", "macros"] }
|
||||
sqlx = { version = "0.8.6", features = ["mysql", "runtime-tokio-native-tls"] }
|
||||
dotenv = "0.15"
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
# Authentication and security
|
||||
jsonwebtoken = "10.3"
|
||||
bcrypt = "0.18"
|
||||
argon2 = "0.5"
|
||||
rand = "0.10"
|
||||
async-trait = "0.1"
|
||||
|
||||
# HTTP client for OAuth
|
||||
reqwest = { version = "0.13", features = ["json"] }
|
||||
|
||||
# Configuration
|
||||
config = "0.15"
|
||||
anyhow = "1.0"
|
||||
thiserror = "2.0"
|
||||
|
||||
# Logging and tracing
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
# Validation
|
||||
validator = { version = "0.20", features = ["derive"] }
|
||||
|
||||
# Utilities
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
uuid = { version = "1.20", features = ["v4", "serde"] }
|
||||
futures = "0.3"
|
||||
axum-extra = { version = "0.12.5", features = ["typed-header"] }
|
||||
utoipa = { version = "5.4.0", features = ["axum_extras"] }
|
||||
utoipa-swagger-ui = { version = "9.0.2", features = ["axum"] }
|
||||
decimal = "2.1.0"
|
||||
urlencoding = "2.1.3"
|
||||
|
||||
[dev-dependencies]
|
||||
# Testing
|
||||
tokio = { version = "1.0", features = ["full"] }
|
||||
reqwest = { version = "0.13", features = ["json"] }
|
||||
mockall = "0.14"
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 0
|
||||
debug = true
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
@@ -1,212 +0,0 @@
|
||||
# AstraCup Backend - CRUD 实施计划
|
||||
|
||||
## 项目概述
|
||||
为AstraCup比赛管理后端生成完整的CRUD API,包括认证、授权和系统初始化功能。
|
||||
|
||||
## 技术栈
|
||||
- **框架**: Axum 0.8.8
|
||||
- **数据库**: SeaORM 1.1.19 + MySQL
|
||||
- **认证**: JWT + osu! OAuth 2.0
|
||||
- **文档**: utoipa (OpenAPI/Swagger)
|
||||
- **验证**: validator 0.20
|
||||
- **配置**: dotenv + config
|
||||
|
||||
## 实施阶段
|
||||
|
||||
### 第一阶段:基础架构搭建 ✅
|
||||
- [x] 创建项目目录结构
|
||||
- [x] 实现数据库连接池
|
||||
- [x] 配置管理系统
|
||||
- [x] 错误处理系统
|
||||
- [x] 基础DTO和Service模板
|
||||
|
||||
### 第二阶段:认证系统 🔄
|
||||
- [ ] JWT认证中间件
|
||||
- [ ] osu! OAuth集成(含refresh token存储)
|
||||
- [ ] 用户注册/登录流程
|
||||
- [ ] 权限中间件(基于UserGroup)
|
||||
|
||||
### 第三阶段:核心CRUD
|
||||
- [ ] User API完整实现
|
||||
- [ ] 管理员设置功能
|
||||
- [ ] 系统初始化逻辑
|
||||
- [ ] TournamentSettings API
|
||||
|
||||
### 第四阶段:其他Entity CRUD
|
||||
- [ ] MatchRooms API
|
||||
- [ ] MatchSchedules API
|
||||
- [ ] PlayerMatchups API
|
||||
- [ ] MapSelections API
|
||||
- [ ] MapComments API
|
||||
- [ ] Messages API
|
||||
|
||||
### 第五阶段:文档和优化
|
||||
- [ ] OpenAPI文档生成
|
||||
- [ ] 测试编写
|
||||
- [ ] 性能优化
|
||||
- [ ] 部署配置
|
||||
|
||||
## 详细任务分解
|
||||
|
||||
### 第一阶段任务
|
||||
1. **目录结构创建**
|
||||
- [x] 创建 `src/api/` 目录和模块
|
||||
- [x] 创建 `src/service/` 目录和模块
|
||||
- [x] 创建 `src/dto/` 目录和模块
|
||||
- [x] 创建 `src/error/` 目录和模块
|
||||
- [x] 创建 `src/config/` 目录和模块
|
||||
- [x] 创建 `src/middleware/` 目录和模块
|
||||
- [x] 创建 `src/utils/` 目录和模块
|
||||
|
||||
2. **数据库连接**
|
||||
- [x] 实现Database配置
|
||||
- [x] 创建连接池管理
|
||||
- [x] 添加数据库迁移支持
|
||||
|
||||
3. **错误处理**
|
||||
- [x] 定义AppError枚举
|
||||
- [x] 实现From trait用于错误转换
|
||||
- [x] 创建统一的错误响应格式
|
||||
|
||||
4. **配置管理**
|
||||
- [x] 从.env读取配置
|
||||
- [x] 创建Config结构体
|
||||
- [x] 实现配置验证
|
||||
|
||||
### 第二阶段任务
|
||||
1. **JWT认证**
|
||||
- [ ] 创建JWT工具函数
|
||||
- [ ] 实现Token生成和验证
|
||||
- [ ] 创建认证中间件
|
||||
|
||||
2. **osu! OAuth集成**
|
||||
- [ ] 添加OAuth相关字段到User entity
|
||||
- [ ] 实现OAuth授权流程
|
||||
- [ ] 处理OAuth回调
|
||||
- [ ] 存储access/refresh token
|
||||
|
||||
3. **权限管理**
|
||||
- [ ] 创建权限检查中间件
|
||||
- [ ] 实现基于UserGroup的权限控制
|
||||
- [ ] 管理员权限特殊处理
|
||||
|
||||
### 第三阶段任务
|
||||
1. **User API**
|
||||
- [ ] 创建User DTOs
|
||||
- [ ] 实现UserService
|
||||
- [ ] 创建User API路由
|
||||
- [ ] 添加数据验证
|
||||
|
||||
2. **系统初始化**
|
||||
- [ ] 检查tournament_settings表
|
||||
- [ ] 创建默认比赛设置
|
||||
- [ ] 管理员用户初始化逻辑
|
||||
|
||||
3. **TournamentSettings API**
|
||||
- [ ] 创建TournamentSettings DTOs
|
||||
- [ ] 实现TournamentSettingsService
|
||||
- [ ] 创建API路由
|
||||
|
||||
### 第四阶段任务
|
||||
1. **MatchRooms API**
|
||||
- [ ] 创建MatchRooms DTOs
|
||||
- [ ] 实现MatchRoomsService
|
||||
- [ ] 创建API路由
|
||||
|
||||
2. **其他Entity API**(类似模式)
|
||||
- [ ] MatchSchedules
|
||||
- [ ] PlayerMatchups
|
||||
- [ ] MapSelections
|
||||
- [ ] MapComments
|
||||
- [ ] Messages
|
||||
|
||||
### 第五阶段任务
|
||||
1. **文档生成**
|
||||
- [ ] 集成utoipa
|
||||
- [ ] 为所有API添加OpenAPI注解
|
||||
- [ ] 设置Swagger UI路由
|
||||
|
||||
2. **测试**
|
||||
- [ ] 单元测试
|
||||
- [ ] 集成测试
|
||||
- [ ] API测试
|
||||
|
||||
3. **优化**
|
||||
- [ ] 性能优化
|
||||
- [ ] 错误处理优化
|
||||
- [ ] 日志优化
|
||||
|
||||
## 数据库变更
|
||||
|
||||
### User表新增字段
|
||||
```sql
|
||||
ALTER TABLE user
|
||||
ADD COLUMN osu_access_token VARCHAR(512) NULL,
|
||||
ADD COLUMN osu_refresh_token VARCHAR(512) NULL,
|
||||
ADD COLUMN osu_token_expires_at TIMESTAMP NULL;
|
||||
```
|
||||
|
||||
## API端点设计
|
||||
|
||||
### 认证相关
|
||||
- `GET /api/auth/osu` - 重定向到osu! OAuth
|
||||
- `GET /api/auth/callback/osu` - OAuth回调
|
||||
- `POST /api/auth/refresh` - 刷新JWT token
|
||||
- `POST /api/auth/logout` - 登出
|
||||
|
||||
### 用户管理
|
||||
- `GET /api/users` - 获取用户列表(管理员)
|
||||
- `GET /api/users/{id}` - 获取用户详情
|
||||
- `PUT /api/users/{id}` - 更新用户信息
|
||||
- `PUT /api/users/{id}/user-group` - 修改用户组(管理员)
|
||||
- `DELETE /api/users/{id}` - 删除用户(管理员)
|
||||
|
||||
### 比赛管理
|
||||
- `GET /api/match-rooms` - 获取比赛房间列表
|
||||
- `POST /api/match-rooms` - 创建比赛房间
|
||||
- `GET /api/match-rooms/{id}` - 获取比赛房间详情
|
||||
- `PUT /api/match-rooms/{id}` - 更新比赛房间
|
||||
- `DELETE /api/match-rooms/{id}` - 删除比赛房间
|
||||
|
||||
## 权限规则
|
||||
|
||||
### 用户角色
|
||||
1. **玩家 (Player)**
|
||||
- 查看公开信息
|
||||
- 管理自己的数据
|
||||
- 参与比赛
|
||||
|
||||
2. **管理员 (Admin)**
|
||||
- 所有玩家权限
|
||||
- 管理所有用户
|
||||
- 修改系统设置
|
||||
- 管理比赛
|
||||
|
||||
### 权限矩阵
|
||||
| 操作 | 玩家 | 管理员 |
|
||||
|------|------|--------|
|
||||
| 查看用户列表 | ❌ | ✅ |
|
||||
| 修改用户组 | ❌ | ✅ |
|
||||
| 创建比赛房间 | ✅ | ✅ |
|
||||
| 删除比赛房间 | 仅自己 | ✅ |
|
||||
| 修改系统设置 | ❌ | ✅ |
|
||||
|
||||
## 开发注意事项
|
||||
|
||||
1. **安全性**
|
||||
- 所有密码/Token加密存储
|
||||
- JWT secret从环境变量读取
|
||||
- 输入验证和清理
|
||||
- SQL注入防护
|
||||
|
||||
2. **性能**
|
||||
- 数据库连接池
|
||||
- 查询优化
|
||||
- 缓存策略
|
||||
|
||||
3. **可维护性**
|
||||
- 清晰的错误信息
|
||||
- 完整的日志记录
|
||||
- 统一的代码风格
|
||||
- 详细的API文档
|
||||
|
||||
@@ -17,6 +17,3 @@ LOG_LEVEL=info
|
||||
|
||||
# CORS Configuration
|
||||
CORS_ORIGIN=http://localhost:3000
|
||||
|
||||
# Redis Configuration (Optional)
|
||||
REDIS_URL=redis://localhost:6379
|
||||
2296
Cargo.lock → astarcup/Cargo.lock
generated
2296
Cargo.lock → astarcup/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
31
astarcup/Cargo.toml
Normal file
31
astarcup/Cargo.toml
Normal file
@@ -0,0 +1,31 @@
|
||||
[package]
|
||||
name = "astarcup"
|
||||
version = "0.1.0"
|
||||
authors = ["anonymous <foo@bar.com>"]
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
dotenv = "0.15.0"
|
||||
serde_json = "1.0.48"
|
||||
thiserror = "1.0.38"
|
||||
tracing = "0.1.37"
|
||||
|
||||
[dependencies.axum]
|
||||
features = []
|
||||
version = "0.6.18"
|
||||
|
||||
[dependencies.serde]
|
||||
features = ["derive"]
|
||||
version = "1.0.105"
|
||||
|
||||
[dependencies.tokio]
|
||||
features = ["full"]
|
||||
version = "1.28.2"
|
||||
|
||||
[dependencies.tower-http]
|
||||
features = ["cors", "trace"]
|
||||
version = "0.3.5"
|
||||
|
||||
[dependencies.sea-orm]
|
||||
features = ["sqlx-postgres","macros","chrono"]
|
||||
version = "1.1.19"
|
||||
@@ -18,8 +18,8 @@ features = [
|
||||
# View the list of supported features at https://www.sea-ql.org/SeaORM/docs/install-and-config/database-and-async-runtime.
|
||||
# e.g.
|
||||
# "runtime-tokio-rustls", # `ASYNC_RUNTIME` feature
|
||||
# "sqlx-postgres", # `DATABASE_DRIVER` feature
|
||||
"sqlx-mysql",
|
||||
"sqlx-postgres",
|
||||
# "sqlx-mysql",
|
||||
"runtime-tokio-native-tls",
|
||||
]
|
||||
version = "2.0.0-rc.31"
|
||||
@@ -25,27 +25,34 @@ impl MigrationTrait for Migration {
|
||||
.not_null()
|
||||
.unique_key(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(User::RegisteredAt)
|
||||
.timestamp()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(ColumnDef::new(User::AvatarUrl).string().null())
|
||||
.col(ColumnDef::new(User::CoverUrl).string().null())
|
||||
.col(ColumnDef::new(User::Pp).float().null())
|
||||
.col(ColumnDef::new(User::GlobalRank).integer().null())
|
||||
.col(ColumnDef::new(User::Country).string().null())
|
||||
.col(ColumnDef::new(User::CountryCode).string().null())
|
||||
.col(ColumnDef::new(User::CountryRank).integer().null())
|
||||
.col(ColumnDef::new(User::Approved).integer().null())
|
||||
.col(
|
||||
ColumnDef::new(User::UserState)
|
||||
.enumeration(
|
||||
"userstate",
|
||||
vec!["active", "approved", "banned", "ignored"],
|
||||
)
|
||||
.not_null()
|
||||
.default("active"),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(User::UserGroup)
|
||||
.enumeration("usergroup", vec!["player", "admin"])
|
||||
.enumeration(
|
||||
"usergroup",
|
||||
vec!["player", "admin", "mappooler", "tester"],
|
||||
)
|
||||
.not_null()
|
||||
.default("player"),
|
||||
)
|
||||
.col(ColumnDef::new(User::Season).integer().null())
|
||||
.col(ColumnDef::new(User::GuServerUserId).integer().null())
|
||||
.col(ColumnDef::new(User::GuServerUsername).string().null())
|
||||
.col(ColumnDef::new(User::GuServerBindAt).timestamp().null())
|
||||
.col(
|
||||
ColumnDef::new(User::CreatedAt)
|
||||
.timestamp()
|
||||
@@ -58,6 +65,7 @@ impl MigrationTrait for Migration {
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(ColumnDef::new(User::CreatedAt).timestamp().null())
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
@@ -78,13 +86,11 @@ enum User {
|
||||
Id,
|
||||
OsuId,
|
||||
Username,
|
||||
RegisteredAt,
|
||||
CoverUrl,
|
||||
AvatarUrl,
|
||||
Pp,
|
||||
GlobalRank,
|
||||
Country,
|
||||
CountryRank,
|
||||
Approved,
|
||||
UserState,
|
||||
UserGroup,
|
||||
Season,
|
||||
GuServerUserId,
|
||||
@@ -24,47 +24,36 @@ impl MigrationTrait for Migration {
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(MapSelections::BeatmapsetId)
|
||||
ColumnDef::new(MapSelections::BeatmapSetId)
|
||||
.integer()
|
||||
.not_null(),
|
||||
)
|
||||
.col(ColumnDef::new(MapSelections::Title).string().not_null())
|
||||
.col(ColumnDef::new(MapSelections::Title).string().null())
|
||||
.col(ColumnDef::new(MapSelections::TitleUnicode).string().null())
|
||||
.col(ColumnDef::new(MapSelections::Artist).string().not_null())
|
||||
.col(ColumnDef::new(MapSelections::Artist).string().null())
|
||||
.col(ColumnDef::new(MapSelections::ArtistUnicode).string().null())
|
||||
.col(ColumnDef::new(MapSelections::Version).string().not_null())
|
||||
.col(ColumnDef::new(MapSelections::Creator).string().not_null())
|
||||
.col(ColumnDef::new(MapSelections::Version).string().null())
|
||||
.col(ColumnDef::new(MapSelections::Creator).string().null())
|
||||
.col(ColumnDef::new(MapSelections::StarRating).decimal().null())
|
||||
.col(ColumnDef::new(MapSelections::Bpm).decimal().null())
|
||||
.col(ColumnDef::new(MapSelections::TotalLength).integer().null())
|
||||
.col(ColumnDef::new(MapSelections::MaxCombo).integer().null())
|
||||
.col(ColumnDef::new(MapSelections::Ar).decimal().null())
|
||||
.col(ColumnDef::new(MapSelections::Cs).decimal().null())
|
||||
.col(ColumnDef::new(MapSelections::Od).decimal().null())
|
||||
.col(ColumnDef::new(MapSelections::Hp).decimal().null())
|
||||
.col(ColumnDef::new(MapSelections::SelectedMods).json().null())
|
||||
.col(
|
||||
ColumnDef::new(MapSelections::StarRating)
|
||||
.decimal()
|
||||
.not_null(),
|
||||
)
|
||||
.col(ColumnDef::new(MapSelections::Bpm).decimal().not_null())
|
||||
.col(
|
||||
ColumnDef::new(MapSelections::TotalLength)
|
||||
ColumnDef::new(MapSelections::SelectedModPosition)
|
||||
.integer()
|
||||
.not_null(),
|
||||
)
|
||||
.col(ColumnDef::new(MapSelections::MaxCombo).integer().not_null())
|
||||
.col(ColumnDef::new(MapSelections::Ar).decimal().not_null())
|
||||
.col(ColumnDef::new(MapSelections::Cs).decimal().not_null())
|
||||
.col(ColumnDef::new(MapSelections::Od).decimal().not_null())
|
||||
.col(ColumnDef::new(MapSelections::Hp).decimal().not_null())
|
||||
.col(
|
||||
ColumnDef::new(MapSelections::SelectedMods)
|
||||
.string()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(MapSelections::ModPosition)
|
||||
.integer()
|
||||
.not_null(),
|
||||
.null(),
|
||||
)
|
||||
.col(ColumnDef::new(MapSelections::Comment).text().null())
|
||||
.col(
|
||||
ColumnDef::new(MapSelections::SelectedBy)
|
||||
.string()
|
||||
.not_null(),
|
||||
.col(ColumnDef::new(MapSelections::SelectedBy).integer().null())
|
||||
.foreign_key(
|
||||
ForeignKey::create()
|
||||
.from(MapSelections::Table, MapSelections::SelectedBy)
|
||||
.to(Users::Table, Users::Id),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(MapSelections::SelectedAt)
|
||||
@@ -73,10 +62,19 @@ impl MigrationTrait for Migration {
|
||||
)
|
||||
.col(ColumnDef::new(MapSelections::Season).string().not_null())
|
||||
.col(ColumnDef::new(MapSelections::Category).string().not_null())
|
||||
.col(ColumnDef::new(MapSelections::Url).text().not_null())
|
||||
.col(ColumnDef::new(MapSelections::CoverUrl).text().null())
|
||||
.col(ColumnDef::new(MapSelections::Approved).integer().null())
|
||||
.col(ColumnDef::new(MapSelections::Padding).integer().null())
|
||||
.col(
|
||||
ColumnDef::new(MapSelections::IsApproved)
|
||||
.boolean()
|
||||
.null()
|
||||
.default(Expr::bool(false)),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(MapSelections::IsNeedTest)
|
||||
.boolean()
|
||||
.null()
|
||||
.default(Expr::bool(false)),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(MapSelections::CreatedAt)
|
||||
.timestamp()
|
||||
@@ -18,38 +18,17 @@ impl MigrationTrait for Migration {
|
||||
.auto_increment()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(MatchRooms::RoomName)
|
||||
.string()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(MatchRooms::RoundNumber)
|
||||
.integer()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(MatchRooms::MatchDate)
|
||||
.date()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(MatchRooms::MatchTime)
|
||||
.time()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(MatchRooms::MatchNumber)
|
||||
.integer()
|
||||
.not_null(),
|
||||
)
|
||||
.col(ColumnDef::new(MatchRooms::RoundNumber).integer().not_null())
|
||||
.col(ColumnDef::new(MatchRooms::MatchDate).date().not_null())
|
||||
.col(ColumnDef::new(MatchRooms::MatchTime).time().not_null())
|
||||
.col(ColumnDef::new(MatchRooms::MatchNumber).integer().not_null())
|
||||
.col(ColumnDef::new(MatchRooms::MaxParticipants).integer().null())
|
||||
.col(ColumnDef::new(MatchRooms::Status).enumeration("status", vec!["available", "scheduled", "completed"]).null())
|
||||
.col(
|
||||
ColumnDef::new(MatchRooms::CreatedBy)
|
||||
.string()
|
||||
.not_null(),
|
||||
ColumnDef::new(MatchRooms::Status)
|
||||
.enumeration("status", vec!["available", "scheduled", "completed"])
|
||||
.null(),
|
||||
)
|
||||
.col(ColumnDef::new(MatchRooms::CreatedBy).string().not_null())
|
||||
.col(
|
||||
ColumnDef::new(MatchRooms::CreatedAt)
|
||||
.timestamp()
|
||||
@@ -19,13 +19,7 @@ impl MigrationTrait for Migration {
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(Messages::SenderOsuId).string().not_null())
|
||||
.col(ColumnDef::new(Messages::SenderUsername).string().not_null())
|
||||
.col(ColumnDef::new(Messages::ReceiverOsuId).string().not_null())
|
||||
.col(
|
||||
ColumnDef::new(Messages::ReceiverUsername)
|
||||
.string()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Messages::Type)
|
||||
.enumeration(
|
||||
58
astarcup/src/main.rs
Normal file
58
astarcup/src/main.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
mod extensions;
|
||||
mod middlewares;
|
||||
mod routes;
|
||||
mod utils;
|
||||
|
||||
use sea_orm::{sqlx::database, Database, DatabaseConnection, DbErr};
|
||||
use std::net::SocketAddr;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
dotenv::dotenv().ok();
|
||||
|
||||
let port = std::env::var("PORT")
|
||||
.ok()
|
||||
.map(|e| e.parse().ok())
|
||||
.flatten()
|
||||
.unwrap_or(8080);
|
||||
|
||||
let host: std::net::IpAddr = std::env::var("HOST")
|
||||
.ok()
|
||||
.map(|e| e.parse().ok())
|
||||
.flatten()
|
||||
.unwrap_or("127.0.0.1".parse().unwrap());
|
||||
|
||||
let app = routes::root::router::get_router().await;
|
||||
|
||||
// Run app on local server
|
||||
let address = SocketAddr::from((host, port));
|
||||
print_banner();
|
||||
|
||||
println!(
|
||||
"Server running on http://{}:{}",
|
||||
&address.ip(),
|
||||
&address.port()
|
||||
);
|
||||
// start server
|
||||
connect_database().await.unwrap();
|
||||
axum::Server::bind(&address)
|
||||
.serve(app.into_make_service())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
fn print_banner() {
|
||||
println!(r" _ _ ____ ");
|
||||
println!(r" / \ ___| |_ __ _ _ __ / ____ _ _ __ ");
|
||||
println!(r" / _ \ / __| __/ _` | '__| | | | | | '_ \ ");
|
||||
println!(r" / ___ \\__ \ || (_| | | | |__| |_| | |_) |");
|
||||
println!(r" /_/ \_\___/\__\__,_|_| \____\__,_| .__/ ");
|
||||
println!(r" |_| ");
|
||||
println!(r"==============================================");
|
||||
}
|
||||
async fn connect_database() -> Result<DatabaseConnection, DbErr> {
|
||||
let database_url = std::env::var("DATABASE_URL")
|
||||
.unwrap_or_else(|_| "postgres://root:pass@localhost:5432/db".to_string());
|
||||
|
||||
let database = Database::connect(&database_url).await?;
|
||||
Ok(database)
|
||||
}
|
||||
2
astarcup/src/routes/mod.rs
Normal file
2
astarcup/src/routes/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub(crate) mod root;
|
||||
pub(crate) mod user;
|
||||
6
astarcup/src/routes/root/dtos/health_response.rs
Normal file
6
astarcup/src/routes/root/dtos/health_response.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HealthReponse {
|
||||
pub server_ok: bool,
|
||||
}
|
||||
1
astarcup/src/routes/root/dtos/mod.rs
Normal file
1
astarcup/src/routes/root/dtos/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub(crate) mod health_response;
|
||||
4
astarcup/src/routes/root/mod.rs
Normal file
4
astarcup/src/routes/root/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub(crate) mod dtos;
|
||||
pub(crate) mod router;
|
||||
pub(crate) mod service;
|
||||
pub(crate) mod tests;
|
||||
50
astarcup/src/routes/root/router.rs
Normal file
50
astarcup/src/routes/root/router.rs
Normal file
@@ -0,0 +1,50 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::body::{Body, BoxBody};
|
||||
use axum::Json;
|
||||
use axum::{
|
||||
http::{Request, Response},
|
||||
response::IntoResponse,
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use tower_http::trace::TraceLayer;
|
||||
use tracing::Span;
|
||||
|
||||
pub(crate) async fn get_router() -> Router {
|
||||
let trace = TraceLayer::new_for_http()
|
||||
.on_request(|request: &Request<Body>, _span: &Span| {
|
||||
println!("{} {} started", request.method(), request.uri().path());
|
||||
println!("request: {request:?}",);
|
||||
})
|
||||
.on_response(
|
||||
|response: &Response<BoxBody>, latency: Duration, _span: &Span| {
|
||||
println!("response generated in {latency:?}",);
|
||||
println!("response: {response:?}",);
|
||||
},
|
||||
);
|
||||
|
||||
// Root Routing
|
||||
let router = Router::new()
|
||||
// .route("/", get(index))
|
||||
.route("/health", get(health))
|
||||
.nest("/user", crate::routes::user::router::get_router().await)
|
||||
// Append the new route here. (don't delete this comment)
|
||||
.layer(trace);
|
||||
|
||||
router
|
||||
}
|
||||
|
||||
// async fn index() -> Html<&'static str> {
|
||||
// Html("<h1>Hello, World!</h1>")
|
||||
// }
|
||||
|
||||
use super::service::RootService;
|
||||
|
||||
async fn health() -> impl IntoResponse {
|
||||
let service = RootService::new();
|
||||
|
||||
let response = service.get_health();
|
||||
|
||||
Json(response).into_response()
|
||||
}
|
||||
15
astarcup/src/routes/root/service.rs
Normal file
15
astarcup/src/routes/root/service.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
use super::dtos::health_response::HealthReponse;
|
||||
|
||||
pub struct RootService {}
|
||||
|
||||
impl RootService {
|
||||
pub fn new() -> Self {
|
||||
Self {}
|
||||
}
|
||||
|
||||
pub fn get_health(&self) -> HealthReponse {
|
||||
let server_ok = true;
|
||||
|
||||
HealthReponse { server_ok }
|
||||
}
|
||||
}
|
||||
1
astarcup/src/routes/root/tests/mod.rs
Normal file
1
astarcup/src/routes/root/tests/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub(crate) mod service;
|
||||
12
astarcup/src/routes/root/tests/service.rs
Normal file
12
astarcup/src/routes/root/tests/service.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
#![cfg(test)]
|
||||
|
||||
use crate::routes::root::service::RootService;
|
||||
|
||||
#[test]
|
||||
pub fn get_health_check() {
|
||||
let service = RootService::new();
|
||||
|
||||
let response = service.get_health();
|
||||
|
||||
assert_eq!(response.server_ok, true);
|
||||
}
|
||||
1
astarcup/src/routes/user/dtos/mod.rs
Normal file
1
astarcup/src/routes/user/dtos/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub(crate) mod user;
|
||||
7
astarcup/src/routes/user/dtos/user.rs
Normal file
7
astarcup/src/routes/user/dtos/user.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct User {
|
||||
pub user_id: i32,
|
||||
pub user_name: String,
|
||||
}
|
||||
4
astarcup/src/routes/user/mod.rs
Normal file
4
astarcup/src/routes/user/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub(crate) mod dtos;
|
||||
pub(crate) mod router;
|
||||
pub(crate) mod service;
|
||||
pub(crate) mod tests;
|
||||
25
astarcup/src/routes/user/router.rs
Normal file
25
astarcup/src/routes/user/router.rs
Normal file
@@ -0,0 +1,25 @@
|
||||
use axum::{extract::Path, response::IntoResponse, routing::get, Json, Router};
|
||||
|
||||
use super::service::UserService;
|
||||
|
||||
pub(crate) async fn get_router() -> Router {
|
||||
Router::new()
|
||||
.route("/", get(get_user_list))
|
||||
.route("/:user_id", get(find_user_by_id))
|
||||
}
|
||||
|
||||
async fn get_user_list() -> impl IntoResponse {
|
||||
let service = UserService::new();
|
||||
|
||||
let response = service.find_user_list();
|
||||
|
||||
Json(response).into_response()
|
||||
}
|
||||
|
||||
async fn find_user_by_id(Path(user_id): Path<i32>) -> impl IntoResponse {
|
||||
let service = UserService::new();
|
||||
|
||||
let response = service.find_user_by_id(user_id);
|
||||
|
||||
Json(response).into_response()
|
||||
}
|
||||
33
astarcup/src/routes/user/service.rs
Normal file
33
astarcup/src/routes/user/service.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
use super::dtos::user::User;
|
||||
|
||||
pub struct UserService {}
|
||||
|
||||
impl UserService {
|
||||
pub fn new() -> Self {
|
||||
Self {}
|
||||
}
|
||||
|
||||
pub fn find_user_list(&self) -> Vec<User> {
|
||||
let user_list = vec![
|
||||
User {
|
||||
user_id: 1,
|
||||
user_name: "test".to_string(),
|
||||
},
|
||||
User {
|
||||
user_id: 2,
|
||||
user_name: "test2".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
user_list
|
||||
}
|
||||
|
||||
pub fn find_user_by_id(&self, id: i32) -> User {
|
||||
let user = User {
|
||||
user_id: id,
|
||||
user_name: "test".to_string(),
|
||||
};
|
||||
|
||||
user
|
||||
}
|
||||
}
|
||||
1
astarcup/src/routes/user/tests/mod.rs
Normal file
1
astarcup/src/routes/user/tests/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub(crate) mod service;
|
||||
24
astarcup/src/routes/user/tests/service.rs
Normal file
24
astarcup/src/routes/user/tests/service.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
#![cfg(test)]
|
||||
|
||||
use crate::routes::user::service::UserService;
|
||||
|
||||
#[test]
|
||||
pub fn get_user_by_id() {
|
||||
let service = UserService::new();
|
||||
|
||||
let user = service.find_user_by_id(10);
|
||||
|
||||
assert_eq!(user.user_id, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn get_user_list() {
|
||||
let service = UserService::new();
|
||||
|
||||
let list = service.find_user_list();
|
||||
|
||||
let mut other = list.clone();
|
||||
other.sort_by(|lhs, rhs| lhs.user_id.cmp(&rhs.user_id));
|
||||
|
||||
assert_eq!(list, other);
|
||||
}
|
||||
1
astarcup/src/utils/mod.rs
Normal file
1
astarcup/src/utils/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
568
src/api/auth.rs
568
src/api/auth.rs
@@ -1,568 +0,0 @@
|
||||
use crate::middleware::auth::AuthUser;
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Query, State},
|
||||
response::Redirect,
|
||||
routing::get,
|
||||
};
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::env;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
dto::{
|
||||
ApiResponse,
|
||||
user::{CreateUserRequest, OsuUserData},
|
||||
},
|
||||
error::Result,
|
||||
service::{ServiceState, user::UserService},
|
||||
};
|
||||
|
||||
pub fn routes() -> Router<ServiceState> {
|
||||
Router::new()
|
||||
.route("/osu", get(osu_auth))
|
||||
.route("/callback/osu", get(osu_callback))
|
||||
.route("/gu", get(gu_auth))
|
||||
.route("/callback/gu", get(gu_callback))
|
||||
.route("/refresh", get(refresh_token))
|
||||
.route("/logout", get(logout))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OsuCallbackQuery {
|
||||
code: String,
|
||||
state: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RefreshTokenQuery {
|
||||
server: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OsuTokenResponse {
|
||||
access_token: String,
|
||||
token_type: String,
|
||||
expires_in: u32,
|
||||
refresh_token: String,
|
||||
scope: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
pub struct AuthResponse {
|
||||
access_token: String,
|
||||
refresh_token: String,
|
||||
user: crate::dto::user::UserResponse,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/auth/osu",
|
||||
responses(
|
||||
(status = 302, description = "重定向到OSU授权页面"),
|
||||
),
|
||||
tag = "auth"
|
||||
)]
|
||||
async fn osu_auth() -> Redirect {
|
||||
let client_id = env::var("OSU_CLIENT_ID").expect("OSU_CLIENT_ID not set");
|
||||
let redirect_uri = env::var("OSU_REDIRECT_URI").expect("OSU_REDIRECT_URI not set");
|
||||
let state = Uuid::new_v4().to_string();
|
||||
|
||||
let auth_url = format!(
|
||||
"https://osu.ppy.sh/oauth/authorize?client_id={}&redirect_uri={}&response_type=code&scope=identify%20public&state={}",
|
||||
client_id,
|
||||
urlencoding::encode(&redirect_uri),
|
||||
state
|
||||
);
|
||||
|
||||
Redirect::to(&auth_url)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/auth/gu",
|
||||
responses(
|
||||
(status = 302, description = "重定向到GU授权页面"),
|
||||
),
|
||||
tag = "auth"
|
||||
)]
|
||||
async fn gu_auth() -> Redirect {
|
||||
let client_id = env::var("GU_CLIENT_ID").expect("GU_CLIENT_ID not set");
|
||||
let redirect_uri = env::var("GU_REDIRECT_URI").expect("GU_REDIRECT_URI not set");
|
||||
let state = Uuid::new_v4().to_string();
|
||||
|
||||
let auth_url = format!(
|
||||
"https://lazer-api.g0v0.top/oauth/authorize?client_id={}&redirect_uri={}&response_type=code&scope=identify%20public&state={}",
|
||||
client_id,
|
||||
urlencoding::encode(&redirect_uri),
|
||||
state
|
||||
);
|
||||
|
||||
Redirect::to(&auth_url)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/auth/callback/osu",
|
||||
params(
|
||||
("code" = String, Query, description = "OSU OAuth回调代码"),
|
||||
("state" = String, Query, description = "状态参数"),
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "OAuth回调成功"),
|
||||
(status = 400, description = "请求参数错误"),
|
||||
(status = 500, description = "内部服务器错误"),
|
||||
),
|
||||
tag = "auth"
|
||||
)]
|
||||
async fn osu_callback(
|
||||
Query(params): Query<OsuCallbackQuery>,
|
||||
State(state): State<ServiceState>,
|
||||
) -> Result<Json<ApiResponse<AuthResponse>>> {
|
||||
let client_id = env::var("OSU_CLIENT_ID").expect("OSU_CLIENT_ID not set");
|
||||
let client_secret = env::var("OSU_CLIENT_SECRET").expect("OSU_CLIENT_SECRET not set");
|
||||
let redirect_uri = env::var("OSU_REDIRECT_URI").expect("OSU_REDIRECT_URI not set");
|
||||
|
||||
// 获取访问令牌
|
||||
let client = Client::new();
|
||||
let token_response = client
|
||||
.post("https://osu.ppy.sh/oauth/token")
|
||||
.json(&serde_json::json!({
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"code": params.code,
|
||||
"grant_type": "authorization_code",
|
||||
"redirect_uri": redirect_uri
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
crate::error::AppError::ExternalApiError(format!("Failed to get token: {}", e))
|
||||
})?
|
||||
.json::<OsuTokenResponse>()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
crate::error::AppError::ExternalApiError(format!(
|
||||
"Failed to parse token response: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
// 获取用户信息
|
||||
let user_data = client
|
||||
.get("https://osu.ppy.sh/api/v2/me")
|
||||
.header(
|
||||
"Authorization",
|
||||
format!("Bearer {}", token_response.access_token),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
crate::error::AppError::ExternalApiError(format!("Failed to get user info: {}", e))
|
||||
})?
|
||||
.json::<OsuUserData>()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
crate::error::AppError::ExternalApiError(format!("Failed to parse user info: {}", e))
|
||||
})?;
|
||||
|
||||
// 处理用户数据
|
||||
let user_service = UserService::new(state.db);
|
||||
let existing_user = user_service.find_by_osu_id(&user_data.id).await?;
|
||||
|
||||
let user = match existing_user {
|
||||
Some(existing) => {
|
||||
// 更新现有用户
|
||||
let update_request = crate::dto::user::UpdateUserRequest {
|
||||
username: Some(user_data.username),
|
||||
avatar_url: user_data.avatar_url,
|
||||
pp: user_data.statistics.as_ref().and_then(|s| s.pp),
|
||||
global_rank: user_data.statistics.as_ref().and_then(|s| s.global_rank),
|
||||
country: user_data.country.as_ref().map(|c| c.code.clone()),
|
||||
country_rank: user_data.statistics.as_ref().and_then(|s| s.country_rank),
|
||||
approved: None,
|
||||
season: None,
|
||||
};
|
||||
let updated_user = user_service.update(existing.id, update_request).await?;
|
||||
|
||||
// 保存 OSU refresh_token
|
||||
user_service
|
||||
.update_osu_refresh_token(updated_user.id, token_response.refresh_token.clone())
|
||||
.await?;
|
||||
updated_user
|
||||
}
|
||||
None => {
|
||||
// 创建新用户
|
||||
let create_request = CreateUserRequest {
|
||||
username: user_data.username,
|
||||
avatar_url: user_data.avatar_url,
|
||||
pp: user_data.statistics.as_ref().and_then(|s| s.pp),
|
||||
global_rank: user_data.statistics.as_ref().and_then(|s| s.global_rank),
|
||||
country: user_data.country.as_ref().map(|c| c.code.clone()),
|
||||
country_rank: user_data.statistics.as_ref().and_then(|s| s.country_rank),
|
||||
};
|
||||
let new_user = user_service.create(user_data.id, create_request).await?;
|
||||
|
||||
// 保存 OSU refresh_token
|
||||
user_service
|
||||
.update_osu_refresh_token(new_user.id, token_response.refresh_token.clone())
|
||||
.await?;
|
||||
new_user
|
||||
}
|
||||
};
|
||||
|
||||
// 生成JWT令牌
|
||||
let osu_id = user.osu_id.clone();
|
||||
let user_group_str = match user.user_group {
|
||||
crate::entity::sea_orm_active_enums::UserGroup::Player => "player",
|
||||
crate::entity::sea_orm_active_enums::UserGroup::Admin => "admin",
|
||||
};
|
||||
let jwt_token =
|
||||
crate::utils::jwt::generate_token(osu_id, user_group_str.to_string()).map_err(|e| {
|
||||
crate::error::AppError::InternalError(format!("Failed to generate token: {}", e))
|
||||
})?;
|
||||
|
||||
let auth_response = AuthResponse {
|
||||
access_token: jwt_token,
|
||||
refresh_token: token_response.refresh_token,
|
||||
user: crate::dto::user::UserResponse::from(user),
|
||||
};
|
||||
|
||||
Ok(Json(ApiResponse::success(auth_response)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/auth/refresh",
|
||||
params(
|
||||
("server" = Option<String>, Query, description = "服务器类型,可选值:osu, gu,默认为osu"),
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "令牌刷新成功"),
|
||||
(status = 400, description = "请求参数错误"),
|
||||
(status = 401, description = "未授权或refresh_token过期"),
|
||||
(status = 500, description = "内部服务器错误"),
|
||||
),
|
||||
tag = "auth"
|
||||
)]
|
||||
async fn refresh_token(
|
||||
auth_user: AuthUser,
|
||||
Query(params): Query<RefreshTokenQuery>,
|
||||
State(state): State<ServiceState>,
|
||||
) -> Result<Json<ApiResponse<AuthResponse>>> {
|
||||
let server = params.server.unwrap_or_else(|| "osu".to_string());
|
||||
|
||||
if server != "osu" && server != "gu" {
|
||||
return Err(crate::error::AppError::ValidationError(
|
||||
"server参数必须是'osu'或'gu'".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let user_service = UserService::new(state.db);
|
||||
|
||||
// 根据服务器类型获取对应的refresh_token
|
||||
let refresh_token = match server.as_str() {
|
||||
"osu" => user_service.get_osu_refresh_token(auth_user.id).await?,
|
||||
"gu" => user_service.get_gu_refresh_token(auth_user.id).await?,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
let refresh_token = refresh_token.ok_or_else(|| {
|
||||
crate::error::AppError::Unauthorized(format!("{} refresh_token不存在", server))
|
||||
})?;
|
||||
|
||||
// 根据服务器类型调用相应的API刷新令牌
|
||||
let (client_id, client_secret, token_url, api_url) = match server.as_str() {
|
||||
"osu" => (
|
||||
env::var("OSU_CLIENT_ID").expect("OSU_CLIENT_ID not set"),
|
||||
env::var("OSU_CLIENT_SECRET").expect("OSU_CLIENT_SECRET not set"),
|
||||
"https://osu.ppy.sh/oauth/token",
|
||||
"https://osu.ppy.sh/api/v2/me",
|
||||
),
|
||||
"gu" => (
|
||||
env::var("GU_CLIENT_ID").expect("GU_CLIENT_ID not set"),
|
||||
env::var("GU_CLIENT_SECRET").expect("GU_CLIENT_SECRET not set"),
|
||||
"https://lazer-api.g0v0.top/oauth/token",
|
||||
"https://lazer-api.g0v0.top/api/v2/me",
|
||||
),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
let client = Client::new();
|
||||
|
||||
// 使用refresh_token获取新的访问令牌
|
||||
let token_response = client
|
||||
.post(token_url)
|
||||
.json(&serde_json::json!({
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"refresh_token": refresh_token,
|
||||
"grant_type": "refresh_token"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
// 如果refresh_token过期,返回401提示重新登录
|
||||
if e.status() == Some(reqwest::StatusCode::UNAUTHORIZED) {
|
||||
crate::error::AppError::Unauthorized(format!(
|
||||
"{} refresh_token已过期,请重新登录",
|
||||
server
|
||||
))
|
||||
} else {
|
||||
crate::error::AppError::ExternalApiError(format!("Failed to refresh token: {}", e))
|
||||
}
|
||||
})?
|
||||
.json::<OsuTokenResponse>()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
crate::error::AppError::ExternalApiError(format!(
|
||||
"Failed to parse token response: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
// 获取用户信息
|
||||
let user_data = client
|
||||
.get(api_url)
|
||||
.header(
|
||||
"Authorization",
|
||||
format!("Bearer {}", token_response.access_token),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
crate::error::AppError::ExternalApiError(format!("Failed to get user info: {}", e))
|
||||
})?
|
||||
.json::<OsuUserData>()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
crate::error::AppError::ExternalApiError(format!("Failed to parse user info: {}", e))
|
||||
})?;
|
||||
|
||||
// 更新用户信息
|
||||
let update_request = crate::dto::user::UpdateUserRequest {
|
||||
username: Some(user_data.username.clone()),
|
||||
avatar_url: user_data.avatar_url,
|
||||
pp: user_data.statistics.as_ref().and_then(|s| s.pp),
|
||||
global_rank: user_data.statistics.as_ref().and_then(|s| s.global_rank),
|
||||
country: user_data.country.as_ref().map(|c| c.code.clone()),
|
||||
country_rank: user_data.statistics.as_ref().and_then(|s| s.country_rank),
|
||||
approved: None,
|
||||
season: None,
|
||||
};
|
||||
|
||||
let user = user_service.update(auth_user.id, update_request).await?;
|
||||
|
||||
// 更新数据库中的refresh_token
|
||||
match server.as_str() {
|
||||
"osu" => {
|
||||
user_service
|
||||
.update_osu_refresh_token(auth_user.id, token_response.refresh_token.clone())
|
||||
.await?;
|
||||
}
|
||||
"gu" => {
|
||||
user_service
|
||||
.update_gu_refresh_token(auth_user.id, token_response.refresh_token.clone())
|
||||
.await?;
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
|
||||
// 生成新的JWT令牌
|
||||
let osu_id = user.osu_id.clone();
|
||||
let user_group_str = match user.user_group {
|
||||
crate::entity::sea_orm_active_enums::UserGroup::Player => "player",
|
||||
crate::entity::sea_orm_active_enums::UserGroup::Admin => "admin",
|
||||
};
|
||||
let jwt_token =
|
||||
crate::utils::jwt::generate_token(osu_id, user_group_str.to_string()).map_err(|e| {
|
||||
crate::error::AppError::InternalError(format!("Failed to generate token: {}", e))
|
||||
})?;
|
||||
|
||||
let auth_response = AuthResponse {
|
||||
access_token: jwt_token,
|
||||
refresh_token: token_response.refresh_token,
|
||||
user: crate::dto::user::UserResponse::from(user),
|
||||
};
|
||||
|
||||
Ok(Json(ApiResponse::success(auth_response)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/auth/logout",
|
||||
responses(
|
||||
(status = 200, description = "登出成功"),
|
||||
(status = 401, description = "未授权"),
|
||||
),
|
||||
tag = "auth"
|
||||
)]
|
||||
async fn logout(
|
||||
auth_user: AuthUser,
|
||||
State(state): State<ServiceState>,
|
||||
) -> Result<Json<ApiResponse<()>>> {
|
||||
// 清除用户的refresh_token
|
||||
let user_service = UserService::new(state.db);
|
||||
|
||||
// 清除所有refresh_token(OSU和GU)
|
||||
user_service.clear_all_refresh_tokens(auth_user.id).await?;
|
||||
|
||||
// 返回成功消息
|
||||
Ok(Json(ApiResponse::success_with_message(
|
||||
(),
|
||||
"登出成功,已清除OSU和GU refresh_token",
|
||||
)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/auth/callback/gu",
|
||||
params(
|
||||
("code" = String, Query, description = "GU OAuth回调代码"),
|
||||
("state" = String, Query, description = "状态参数"),
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "OAuth回调成功"),
|
||||
(status = 400, description = "请求参数错误"),
|
||||
(status = 500, description = "内部服务器错误"),
|
||||
),
|
||||
tag = "auth"
|
||||
)]
|
||||
async fn gu_callback(
|
||||
Query(params): Query<OsuCallbackQuery>,
|
||||
State(state): State<ServiceState>,
|
||||
) -> Result<Json<ApiResponse<AuthResponse>>> {
|
||||
let client_id = env::var("GU_CLIENT_ID").expect("GU_CLIENT_ID not set");
|
||||
let client_secret = env::var("GU_CLIENT_SECRET").expect("GU_CLIENT_SECRET not set");
|
||||
let redirect_uri = env::var("GU_REDIRECT_URI").expect("GU_REDIRECT_URI not set");
|
||||
|
||||
// 获取访问令牌
|
||||
let client = Client::new();
|
||||
let token_response = client
|
||||
.post("https://lazer-api.g0v0.top/oauth/token")
|
||||
.json(&serde_json::json!({
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"code": params.code,
|
||||
"grant_type": "authorization_code",
|
||||
"redirect_uri": redirect_uri
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
crate::error::AppError::ExternalApiError(format!("Failed to get token: {}", e))
|
||||
})?
|
||||
.json::<OsuTokenResponse>()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
crate::error::AppError::ExternalApiError(format!(
|
||||
"Failed to parse token response: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
// 获取用户信息
|
||||
let user_data = client
|
||||
.get("https://lazer-api.g0v0.top/api/v2/me")
|
||||
.header(
|
||||
"Authorization",
|
||||
format!("Bearer {}", token_response.access_token),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
crate::error::AppError::ExternalApiError(format!("Failed to get user info: {}", e))
|
||||
})?
|
||||
.json::<OsuUserData>()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
crate::error::AppError::ExternalApiError(format!("Failed to parse user info: {}", e))
|
||||
})?;
|
||||
|
||||
// 处理用户数据
|
||||
let user_service = UserService::new(state.db);
|
||||
let existing_user = user_service.find_by_osu_id(&user_data.id).await?;
|
||||
|
||||
// 提前复制需要的值,避免移动后借用
|
||||
let user_id = user_data.id.clone();
|
||||
let username = user_data.username.clone();
|
||||
|
||||
let user = match existing_user {
|
||||
Some(existing) => {
|
||||
// 更新现有用户,包括GU服务器信息
|
||||
let update_request = crate::dto::user::UpdateUserRequest {
|
||||
username: Some(username.clone()),
|
||||
avatar_url: user_data.avatar_url,
|
||||
pp: user_data.statistics.as_ref().and_then(|s| s.pp),
|
||||
global_rank: user_data.statistics.as_ref().and_then(|s| s.global_rank),
|
||||
country: user_data.country.as_ref().map(|c| c.code.clone()),
|
||||
country_rank: user_data.statistics.as_ref().and_then(|s| s.country_rank),
|
||||
approved: None,
|
||||
season: None,
|
||||
};
|
||||
let updated_user = user_service.update(existing.id, update_request).await?;
|
||||
|
||||
// 更新用户的gu_server_user_id和gu_server_username
|
||||
let gu_user_id = user_id.parse::<i32>().unwrap_or(0);
|
||||
let updated_user_with_gu_info = user_service
|
||||
.update_gu_server_info(updated_user.id, gu_user_id, username.clone())
|
||||
.await?;
|
||||
|
||||
// 保存 GU refresh_token
|
||||
user_service
|
||||
.update_gu_refresh_token(
|
||||
updated_user_with_gu_info.id,
|
||||
token_response.refresh_token.clone(),
|
||||
)
|
||||
.await?;
|
||||
updated_user_with_gu_info
|
||||
}
|
||||
None => {
|
||||
// 创建新用户
|
||||
let create_request = CreateUserRequest {
|
||||
username: username.clone(),
|
||||
avatar_url: user_data.avatar_url,
|
||||
pp: user_data.statistics.as_ref().and_then(|s| s.pp),
|
||||
global_rank: user_data.statistics.as_ref().and_then(|s| s.global_rank),
|
||||
country: user_data.country.as_ref().map(|c| c.code.clone()),
|
||||
country_rank: user_data.statistics.as_ref().and_then(|s| s.country_rank),
|
||||
};
|
||||
let new_user = user_service.create(user_id.clone(), create_request).await?;
|
||||
|
||||
// 设置用户的gu_server_user_id和gu_server_username
|
||||
let gu_user_id = user_id.parse::<i32>().unwrap_or(0);
|
||||
let new_user_with_gu_info = user_service
|
||||
.update_gu_server_info(new_user.id, gu_user_id, username.clone())
|
||||
.await?;
|
||||
|
||||
// 保存 GU refresh_token
|
||||
user_service
|
||||
.update_gu_refresh_token(
|
||||
new_user_with_gu_info.id,
|
||||
token_response.refresh_token.clone(),
|
||||
)
|
||||
.await?;
|
||||
new_user_with_gu_info
|
||||
}
|
||||
};
|
||||
|
||||
// 生成JWT令牌
|
||||
let osu_id = user.osu_id.clone();
|
||||
let user_group_str = match user.user_group {
|
||||
crate::entity::sea_orm_active_enums::UserGroup::Player => "player",
|
||||
crate::entity::sea_orm_active_enums::UserGroup::Admin => "admin",
|
||||
};
|
||||
let jwt_token =
|
||||
crate::utils::jwt::generate_token(osu_id, user_group_str.to_string()).map_err(|e| {
|
||||
crate::error::AppError::InternalError(format!("Failed to generate token: {}", e))
|
||||
})?;
|
||||
|
||||
let auth_response = AuthResponse {
|
||||
access_token: jwt_token,
|
||||
refresh_token: token_response.refresh_token,
|
||||
user: crate::dto::user::UserResponse::from(user),
|
||||
};
|
||||
|
||||
Ok(Json(ApiResponse::success(auth_response)))
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
pub mod auth;
|
||||
pub mod tournament_settings;
|
||||
pub mod user;
|
||||
|
||||
use axum::{Router, routing::get};
|
||||
use utoipa::OpenApi;
|
||||
use utoipa_swagger_ui::SwaggerUi;
|
||||
|
||||
use crate::service::ServiceState;
|
||||
|
||||
pub fn configure_routes() -> Router<ServiceState> {
|
||||
Router::new()
|
||||
// Swagger UI文档
|
||||
.merge(SwaggerUi::new("/api/docs").url("/api-docs/openapi.json", ApiDoc::openapi()))
|
||||
.route("/health", get(|| async { "OK" }))
|
||||
.nest("/api/auth", auth::routes())
|
||||
.nest("/api/users", user::routes())
|
||||
.nest("/api/tournament-settings", tournament_settings::routes())
|
||||
}
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
paths(
|
||||
auth::osu_auth,
|
||||
auth::osu_callback,
|
||||
auth::refresh_token,
|
||||
auth::logout,
|
||||
user::get_users,
|
||||
user::get_user,
|
||||
user::get_current_user,
|
||||
user::update_user,
|
||||
user::update_user_group,
|
||||
user::delete_user,
|
||||
user::update_registration_status,
|
||||
user::update_season,
|
||||
user::update_approved,
|
||||
tournament_settings::get_tournament_settings,
|
||||
tournament_settings::update_tournament_settings,
|
||||
),
|
||||
components(
|
||||
schemas(
|
||||
crate::dto::user::UserResponse,
|
||||
crate::dto::user::UpdateUserRequest,
|
||||
crate::dto::user::UpdateUserGroupRequest,
|
||||
crate::dto::user::UpdateRegistrationStatusRequest,
|
||||
crate::dto::user::UpdateSeasonRequest,
|
||||
crate::dto::user::UpdateApprovedRequest,
|
||||
crate::dto::user::UserQueryParams,
|
||||
crate::dto::ApiResponseSchema<crate::dto::user::UserResponse>,
|
||||
crate::dto::ApiResponseSchema<crate::api::auth::AuthResponse>,
|
||||
crate::dto::PaginatedResponseSchema<crate::dto::user::UserResponse>,
|
||||
user::PaginatedUserResponse,
|
||||
crate::dto::tournament_settings::TournamentSettingResponse,
|
||||
crate::dto::tournament_settings::UpdateTournamentSettingRequest,
|
||||
crate::dto::tournament_settings::TournamentSettingQueryParams,
|
||||
crate::api::tournament_settings::PaginatedTournamentSettingsResponse,
|
||||
)
|
||||
),
|
||||
tags(
|
||||
(name = "auth", description = "认证相关API"),
|
||||
(name = "users", description = "用户管理API"),
|
||||
(name = "tournament-settings", description = "比赛设置API"),
|
||||
)
|
||||
)]
|
||||
pub struct ApiDoc;
|
||||
@@ -1,136 +0,0 @@
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Query, State},
|
||||
routing::{get, put},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
dto::{
|
||||
ApiResponse,
|
||||
tournament_settings::{
|
||||
TournamentSettingQueryParams, TournamentSettingResponse, UpdateTournamentSettingRequest,
|
||||
},
|
||||
},
|
||||
error::Result,
|
||||
middleware::auth::{AuthUser, require_admin},
|
||||
service::{ServiceState, tournament_settings::TournamentSettingService},
|
||||
};
|
||||
|
||||
pub fn routes() -> Router<ServiceState> {
|
||||
Router::new()
|
||||
.route("/", get(get_tournament_settings))
|
||||
.route("/", put(update_tournament_settings))
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
|
||||
pub struct PaginatedTournamentSettingsResponse {
|
||||
pub items: Vec<TournamentSettingResponse>,
|
||||
pub total: u64,
|
||||
pub page: u64,
|
||||
pub page_size: u64,
|
||||
pub total_pages: u64,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/tournament-settings",
|
||||
params(TournamentSettingQueryParams),
|
||||
responses(
|
||||
(status = 200, description = "获取比赛设置列表成功", body = PaginatedTournamentSettingsResponse),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 500, description = "内部服务器错误"),
|
||||
),
|
||||
tag = "tournament-settings"
|
||||
)]
|
||||
async fn get_tournament_settings(
|
||||
State(state): State<ServiceState>,
|
||||
Query(params): Query<TournamentSettingQueryParams>,
|
||||
) -> Result<Json<ApiResponse<PaginatedTournamentSettingsResponse>>> {
|
||||
let service = TournamentSettingService::new(state);
|
||||
let page = params.page.unwrap_or(1);
|
||||
let page_size = params.page_size.unwrap_or(20);
|
||||
let (settings, total) = service.find_all(params).await?;
|
||||
|
||||
let response = PaginatedTournamentSettingsResponse {
|
||||
items: settings
|
||||
.into_iter()
|
||||
.map(|s| TournamentSettingResponse {
|
||||
id: s.id,
|
||||
tournament_name: s.tournament_name,
|
||||
max_pp_for_registration: s.max_pp_for_registration,
|
||||
min_pp_for_registration: s.min_pp_for_registration,
|
||||
current_season: s.current_season,
|
||||
current_season_stage: s.current_season_stage,
|
||||
mappool_visible: s.mappool_visible,
|
||||
created_at: s.created_at.to_rfc3339(),
|
||||
updated_at: s.updated_at.to_rfc3339(),
|
||||
})
|
||||
.collect(),
|
||||
total,
|
||||
page,
|
||||
page_size,
|
||||
total_pages: (total as f64 / page_size as f64).ceil() as u64,
|
||||
};
|
||||
|
||||
Ok(Json(ApiResponse::success(response)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/api/tournament-settings",
|
||||
request_body = UpdateTournamentSettingRequest,
|
||||
responses(
|
||||
(status = 200, description = "更新比赛设置成功", body = TournamentSettingResponse),
|
||||
(status = 400, description = "请求参数错误"),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足,需要管理员权限"),
|
||||
(status = 500, description = "内部服务器错误"),
|
||||
),
|
||||
tag = "tournament-settings"
|
||||
)]
|
||||
async fn update_tournament_settings(
|
||||
State(state): State<ServiceState>,
|
||||
auth_user: AuthUser,
|
||||
Json(data): Json<UpdateTournamentSettingRequest>,
|
||||
) -> Result<Json<ApiResponse<TournamentSettingResponse>>> {
|
||||
// 验证用户是否为admin
|
||||
require_admin(&auth_user)?;
|
||||
|
||||
let service = TournamentSettingService::new(state);
|
||||
|
||||
// 检查是否存在设置,如果不存在则初始化
|
||||
let settings = match service.get_current_settings_cached().await? {
|
||||
Some(existing) => {
|
||||
// 更新现有设置
|
||||
service.update(existing.id, data).await?
|
||||
}
|
||||
None => {
|
||||
// 初始化新设置
|
||||
let create_data = crate::dto::tournament_settings::CreateTournamentSettingRequest {
|
||||
tournament_name: data
|
||||
.tournament_name
|
||||
.unwrap_or_else(|| "Default Tournament".to_string()),
|
||||
max_pp_for_registration: data.max_pp_for_registration,
|
||||
min_pp_for_registration: data.min_pp_for_registration,
|
||||
current_season: data.current_season,
|
||||
current_season_stage: data.current_season_stage,
|
||||
mappool_visible: data.mappool_visible,
|
||||
};
|
||||
service.create(create_data).await?
|
||||
}
|
||||
};
|
||||
|
||||
let response = TournamentSettingResponse {
|
||||
id: settings.id,
|
||||
tournament_name: settings.tournament_name,
|
||||
max_pp_for_registration: settings.max_pp_for_registration,
|
||||
min_pp_for_registration: settings.min_pp_for_registration,
|
||||
current_season: settings.current_season,
|
||||
current_season_stage: settings.current_season_stage,
|
||||
mappool_visible: settings.mappool_visible,
|
||||
created_at: settings.created_at.to_rfc3339(),
|
||||
updated_at: settings.updated_at.to_rfc3339(),
|
||||
};
|
||||
|
||||
Ok(Json(ApiResponse::success(response)))
|
||||
}
|
||||
360
src/api/user.rs
360
src/api/user.rs
@@ -1,360 +0,0 @@
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Path, Query, State},
|
||||
routing::{delete, get, post, put},
|
||||
};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::{
|
||||
dto::{
|
||||
ApiResponse, PaginatedResponse,
|
||||
user::{
|
||||
CreateUserRequest, UpdateApprovedRequest, UpdateRegistrationStatusRequest,
|
||||
UpdateSeasonRequest, UpdateUserGroupRequest, UpdateUserRequest, UserQueryParams,
|
||||
UserResponse,
|
||||
},
|
||||
},
|
||||
error::Result,
|
||||
middleware::auth::{AuthUser, require_admin},
|
||||
service::{ServiceState, user::UserService},
|
||||
};
|
||||
|
||||
pub fn routes() -> Router<ServiceState> {
|
||||
Router::new()
|
||||
.route("/", get(get_users))
|
||||
.route("/", post(create_user))
|
||||
.route("/me", get(get_current_user))
|
||||
.route("/{osu_id}", get(get_user))
|
||||
.route("/{osu_id}", put(update_user))
|
||||
.route("/{osu_id}", delete(delete_user))
|
||||
.route("/{osu_id}/user-group", put(update_user_group))
|
||||
.route(
|
||||
"/{osu_id}/registration-status",
|
||||
put(update_registration_status),
|
||||
)
|
||||
.route("/{osu_id}/season", put(update_season))
|
||||
.route("/{osu_id}/approve", put(update_approved))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/users",
|
||||
params(UserQueryParams),
|
||||
responses(
|
||||
(status = 200, description = "获取osu玩家列表成功", body = PaginatedUserResponse),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
),
|
||||
tag = "users"
|
||||
)]
|
||||
async fn get_users(
|
||||
State(state): State<ServiceState>,
|
||||
Query(params): Query<UserQueryParams>,
|
||||
) -> Result<Json<ApiResponse<PaginatedResponse<UserResponse>>>> {
|
||||
let service = UserService::new(state.db);
|
||||
let (users, total) = service.find_all(params).await?;
|
||||
|
||||
let response = PaginatedResponse::new(
|
||||
users.into_iter().map(UserResponse::from).collect(),
|
||||
total,
|
||||
1,
|
||||
20,
|
||||
);
|
||||
|
||||
Ok(Json(ApiResponse::success(response)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/users/{osu_id}",
|
||||
params(
|
||||
("osu_id" = String, Path, description = "osuID")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "获取osu玩家成功", body = UserResponse),
|
||||
(status = 404, description = "osu玩家不存在"),
|
||||
),
|
||||
tag = "users"
|
||||
)]
|
||||
async fn get_user(
|
||||
State(state): State<ServiceState>,
|
||||
Path(osu_id): Path<String>,
|
||||
) -> Result<Json<ApiResponse<UserResponse>>> {
|
||||
let service = UserService::new(state.db);
|
||||
let user = service
|
||||
.find_by_osu_id(&osu_id)
|
||||
.await?
|
||||
.ok_or_else(|| crate::error::AppError::NotFound(format!("osu玩家 {} 不存在", osu_id)))?;
|
||||
|
||||
Ok(Json(ApiResponse::success(UserResponse::from(user))))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/users/me",
|
||||
responses(
|
||||
(status = 200, description = "获取当前玩家信息成功", body = UserResponse),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 404, description = "玩家不存在"),
|
||||
),
|
||||
tag = "users"
|
||||
)]
|
||||
async fn get_current_user(
|
||||
State(state): State<ServiceState>,
|
||||
auth_user: AuthUser,
|
||||
) -> Result<Json<ApiResponse<UserResponse>>> {
|
||||
let service = UserService::new(state.db);
|
||||
let user = service
|
||||
.find_by_osu_id(&auth_user.osu_id)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::error::AppError::NotFound(format!("玩家 {} 不存在", auth_user.osu_id))
|
||||
})?;
|
||||
|
||||
Ok(Json(ApiResponse::success(UserResponse::from(user))))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/users",
|
||||
request_body = CreateUserRequest,
|
||||
responses(
|
||||
(status = 201, description = "创建osu玩家成功", body = UserResponse),
|
||||
(status = 400, description = "请求参数错误"),
|
||||
(status = 401, description = "未授权"),
|
||||
),
|
||||
tag = "users"
|
||||
)]
|
||||
async fn create_user() -> Result<Json<ApiResponse<UserResponse>>> {
|
||||
// osu玩家创建将通过OAuth回调处理
|
||||
Err(crate::error::AppError::BusinessError(
|
||||
"请通过OAuth注册osu玩家".into(),
|
||||
))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/api/users/{osu_id}",
|
||||
params(
|
||||
("osu_id" = String, Path, description = "osu玩家ID")
|
||||
),
|
||||
request_body = UpdateUserRequest,
|
||||
responses(
|
||||
(status = 200, description = "更新osu玩家成功", body = UserResponse),
|
||||
(status = 400, description = "请求参数错误"),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
(status = 404, description = "osu玩家不存在"),
|
||||
),
|
||||
tag = "users"
|
||||
)]
|
||||
#[axum::debug_handler]
|
||||
async fn update_user(
|
||||
State(state): State<ServiceState>,
|
||||
Path(osu_id): Path<String>,
|
||||
auth_user: AuthUser,
|
||||
Json(data): Json<UpdateUserRequest>,
|
||||
) -> Result<Json<ApiResponse<UserResponse>>> {
|
||||
let service = UserService::new(state.db);
|
||||
|
||||
// 获取要更新的osu玩家信息
|
||||
let target_user = service
|
||||
.find_by_osu_id(&osu_id)
|
||||
.await?
|
||||
.ok_or_else(|| crate::error::AppError::NotFound(format!("osu玩家 {} 不存在", osu_id)))?;
|
||||
|
||||
// 检查权限:管理员或osu玩家本人可以更新
|
||||
let is_admin = auth_user.user_group == "admin";
|
||||
let is_self = auth_user.osu_id == target_user.osu_id;
|
||||
|
||||
if !is_admin && !is_self {
|
||||
return Err(crate::error::AppError::Forbidden("权限错误".into()));
|
||||
}
|
||||
|
||||
let user = service.update(target_user.id, data).await?;
|
||||
|
||||
Ok(Json(ApiResponse::success(UserResponse::from(user))))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/api/users/{osu_id}/user-group",
|
||||
params(
|
||||
("osu_id" = String, Path, description = "osuID")
|
||||
),
|
||||
request_body = UpdateUserGroupRequest,
|
||||
responses(
|
||||
(status = 200, description = "更新osu玩家组成功", body = UserResponse),
|
||||
(status = 400, description = "请求参数错误"),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
(status = 404, description = "osu玩家不存在"),
|
||||
),
|
||||
tag = "users"
|
||||
)]
|
||||
#[axum::debug_handler]
|
||||
async fn update_user_group(
|
||||
State(state): State<ServiceState>,
|
||||
Path(osu_id): Path<String>,
|
||||
user: AuthUser,
|
||||
Json(data): Json<UpdateUserGroupRequest>,
|
||||
) -> Result<Json<ApiResponse<UserResponse>>> {
|
||||
// 检查是否为管理员
|
||||
require_admin(&user)?;
|
||||
|
||||
let service = UserService::new(state.db);
|
||||
let target_user = service
|
||||
.find_by_osu_id(&osu_id)
|
||||
.await?
|
||||
.ok_or_else(|| crate::error::AppError::NotFound(format!("osu玩家 {} 不存在", osu_id)))?;
|
||||
|
||||
let user = service.update_user_group(target_user.id, data).await?;
|
||||
|
||||
Ok(Json(ApiResponse::success(UserResponse::from(user))))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/users/{osu_id}",
|
||||
params(
|
||||
("osu_id" = String, Path, description = "osuID")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "删除osu玩家成功"),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
(status = 404, description = "osu玩家不存在"),
|
||||
),
|
||||
tag = "users"
|
||||
)]
|
||||
async fn delete_user(
|
||||
State(state): State<ServiceState>,
|
||||
Path(osu_id): Path<String>,
|
||||
user: AuthUser,
|
||||
) -> Result<Json<ApiResponse<()>>> {
|
||||
// 检查是否为管理员
|
||||
require_admin(&user)?;
|
||||
|
||||
let service = UserService::new(state.db);
|
||||
let target_user = service
|
||||
.find_by_osu_id(&osu_id)
|
||||
.await?
|
||||
.ok_or_else(|| crate::error::AppError::NotFound(format!("osu玩家 {} 不存在", osu_id)))?;
|
||||
|
||||
service.delete(target_user.id).await?;
|
||||
|
||||
Ok(Json(ApiResponse::success_with_message(
|
||||
(),
|
||||
"osu玩家删除成功",
|
||||
)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/api/users/{osu_id}/registration-status",
|
||||
params(
|
||||
("osu_id" = String, Path, description = "osuID")
|
||||
),
|
||||
request_body = UpdateRegistrationStatusRequest,
|
||||
responses(
|
||||
(status = 200, description = "更新注册状态成功", body = UserResponse),
|
||||
(status = 400, description = "请求参数错误"),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 404, description = "osu玩家不存在"),
|
||||
),
|
||||
tag = "users"
|
||||
)]
|
||||
async fn update_registration_status(
|
||||
State(state): State<ServiceState>,
|
||||
Path(osu_id): Path<String>,
|
||||
Json(data): Json<UpdateRegistrationStatusRequest>,
|
||||
) -> Result<Json<ApiResponse<UserResponse>>> {
|
||||
let service = UserService::new(state.db);
|
||||
let target_user = service
|
||||
.find_by_osu_id(&osu_id)
|
||||
.await?
|
||||
.ok_or_else(|| crate::error::AppError::NotFound(format!("osu玩家 {} 不存在", osu_id)))?;
|
||||
|
||||
let user = service
|
||||
.update_registration_status(target_user.id, data)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::success(UserResponse::from(user))))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/api/users/{osu_id}/season",
|
||||
params(
|
||||
("osu_id" = String, Path, description = "osuID")
|
||||
),
|
||||
request_body = UpdateSeasonRequest,
|
||||
responses(
|
||||
(status = 200, description = "更新赛季成功", body = UserResponse),
|
||||
(status = 400, description = "请求参数错误"),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 404, description = "osu玩家不存在"),
|
||||
),
|
||||
tag = "users"
|
||||
)]
|
||||
async fn update_season(
|
||||
State(state): State<ServiceState>,
|
||||
Path(osu_id): Path<String>,
|
||||
Json(data): Json<UpdateSeasonRequest>,
|
||||
) -> Result<Json<ApiResponse<UserResponse>>> {
|
||||
let service = UserService::new(state.db);
|
||||
let target_user = service
|
||||
.find_by_osu_id(&osu_id)
|
||||
.await?
|
||||
.ok_or_else(|| crate::error::AppError::NotFound(format!("osu玩家 {} 不存在", osu_id)))?;
|
||||
|
||||
let user = service.update_season(target_user.id, data).await?;
|
||||
|
||||
Ok(Json(ApiResponse::success(UserResponse::from(user))))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/api/users/{osu_id}/approve",
|
||||
params(
|
||||
("osu_id" = String, Path, description = "osuID")
|
||||
),
|
||||
request_body = UpdateApprovedRequest,
|
||||
responses(
|
||||
(status = 200, description = "更新审批状态成功", body = UserResponse),
|
||||
(status = 400, description = "请求参数错误"),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
(status = 404, description = "osu玩家不存在"),
|
||||
),
|
||||
tag = "users"
|
||||
)]
|
||||
#[axum::debug_handler]
|
||||
async fn update_approved(
|
||||
State(state): State<ServiceState>,
|
||||
Path(osu_id): Path<String>,
|
||||
user: AuthUser,
|
||||
Json(data): Json<UpdateApprovedRequest>,
|
||||
) -> Result<Json<ApiResponse<UserResponse>>> {
|
||||
// 检查是否为管理员
|
||||
require_admin(&user)?;
|
||||
|
||||
let service = UserService::new(state.db);
|
||||
let target_user = service
|
||||
.find_by_osu_id(&osu_id)
|
||||
.await?
|
||||
.ok_or_else(|| crate::error::AppError::NotFound(format!("osu玩家 {} 不存在", osu_id)))?;
|
||||
|
||||
let user = service.update_approved(target_user.id, data, true).await?;
|
||||
|
||||
Ok(Json(ApiResponse::success(UserResponse::from(user))))
|
||||
}
|
||||
|
||||
#[derive(ToSchema)]
|
||||
pub struct PaginatedUserResponse {
|
||||
items: Vec<UserResponse>,
|
||||
total: u64,
|
||||
page: u64,
|
||||
page_size: u64,
|
||||
total_pages: u64,
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
use std::env;
|
||||
|
||||
use serde::Deserialize;
|
||||
use validator::Validate;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Validate)]
|
||||
pub struct Config {
|
||||
#[validate(length(min = 1))]
|
||||
pub database_url: String,
|
||||
|
||||
#[validate(range(min = 1, max = 65535))]
|
||||
pub port: u16,
|
||||
|
||||
pub host: String,
|
||||
|
||||
#[validate(length(min = 1))]
|
||||
pub jwt_secret: String,
|
||||
|
||||
pub jwt_expiry_hours: u64,
|
||||
|
||||
pub osu_client_id: String,
|
||||
|
||||
pub osu_client_secret: String,
|
||||
|
||||
pub osu_redirect_uri: String,
|
||||
|
||||
pub gu_client_id: String,
|
||||
|
||||
pub gu_client_secret: String,
|
||||
|
||||
pub gu_redirect_uri: String,
|
||||
|
||||
pub cors_origin: String,
|
||||
|
||||
pub log_level: String,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_env() -> Result<Self, crate::error::AppError> {
|
||||
dotenv::dotenv().ok();
|
||||
|
||||
let config = Config {
|
||||
database_url: env::var("DATABASE_URL")
|
||||
.map_err(|_| crate::error::AppError::ConfigError("DATABASE_URL未设置".into()))?,
|
||||
|
||||
port: env::var("PORT")
|
||||
.unwrap_or_else(|_| "3000".to_string())
|
||||
.parse()
|
||||
.map_err(|_| crate::error::AppError::ConfigError("PORT格式错误".into()))?,
|
||||
|
||||
host: env::var("HOST").unwrap_or_else(|_| "0.0.0.0".to_string()),
|
||||
|
||||
jwt_secret: env::var("JWT_SECRET")
|
||||
.map_err(|_| crate::error::AppError::ConfigError("JWT_SECRET未设置".into()))?,
|
||||
|
||||
jwt_expiry_hours: env::var("JWT_EXPIRY_HOURS")
|
||||
.unwrap_or_else(|_| "24".to_string())
|
||||
.parse()
|
||||
.map_err(|_| {
|
||||
crate::error::AppError::ConfigError("JWT_EXPIRY_HOURS格式错误".into())
|
||||
})?,
|
||||
|
||||
osu_client_id: env::var("OSU_CLIENT_ID")
|
||||
.map_err(|_| crate::error::AppError::ConfigError("OSU_CLIENT_ID未设置".into()))?,
|
||||
|
||||
osu_client_secret: env::var("OSU_CLIENT_SECRET").map_err(|_| {
|
||||
crate::error::AppError::ConfigError("OSU_CLIENT_SECRET未设置".into())
|
||||
})?,
|
||||
|
||||
osu_redirect_uri: env::var("OSU_REDIRECT_URI").map_err(|_| {
|
||||
crate::error::AppError::ConfigError("OSU_REDIRECT_URI未设置".into())
|
||||
})?,
|
||||
|
||||
gu_client_id: env::var("GU_CLIENT_ID")
|
||||
.map_err(|_| crate::error::AppError::ConfigError("GU_CLIENT_ID未设置".into()))?,
|
||||
|
||||
gu_client_secret: env::var("GU_CLIENT_SECRET").map_err(|_| {
|
||||
crate::error::AppError::ConfigError("GU_CLIENT_SECRET未设置".into())
|
||||
})?,
|
||||
|
||||
gu_redirect_uri: env::var("GU_REDIRECT_URI")
|
||||
.map_err(|_| crate::error::AppError::ConfigError("GU_REDIRECT_URI未设置".into()))?,
|
||||
|
||||
cors_origin: env::var("CORS_ORIGIN")
|
||||
.unwrap_or_else(|_| "http://localhost:3000".to_string()),
|
||||
|
||||
log_level: env::var("LOG_LEVEL").unwrap_or_else(|_| "info".to_string()),
|
||||
};
|
||||
|
||||
config
|
||||
.validate()
|
||||
.map_err(|e| crate::error::AppError::ConfigError(format!("配置验证失败: {}", e)))?;
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
use serde::Deserialize;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct CreateMapCommentRequest {
|
||||
pub map_selection_id: i32,
|
||||
pub osu_id: String,
|
||||
pub username: String,
|
||||
pub comment: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct UpdateMapCommentRequest {
|
||||
pub comment: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct MapCommentQueryParams {
|
||||
pub page: Option<u64>,
|
||||
pub page_size: Option<u64>,
|
||||
pub map_selection_id: Option<i32>,
|
||||
pub osu_id: Option<String>,
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
use serde::Deserialize;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct CreateMapSelectionRequest {
|
||||
pub beatmap_id: i32,
|
||||
pub beatmapset_id: i32,
|
||||
pub title: String,
|
||||
pub title_unicode: Option<String>,
|
||||
pub artist: String,
|
||||
pub artist_unicode: Option<String>,
|
||||
pub version: String,
|
||||
pub creator: String,
|
||||
pub star_rating: String,
|
||||
pub bpm: String,
|
||||
pub total_length: i32,
|
||||
pub max_combo: i32,
|
||||
pub ar: String,
|
||||
pub cs: String,
|
||||
pub od: String,
|
||||
pub hp: String,
|
||||
pub selected_mods: String,
|
||||
pub mod_position: i32,
|
||||
pub comment: Option<String>,
|
||||
pub selected_by: String,
|
||||
pub season: String,
|
||||
pub category: String,
|
||||
pub url: String,
|
||||
pub cover_url: Option<String>,
|
||||
pub approved: Option<i32>,
|
||||
pub padding: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct UpdateMapSelectionRequest {
|
||||
pub title: Option<String>,
|
||||
pub title_unicode: Option<String>,
|
||||
pub artist: Option<String>,
|
||||
pub artist_unicode: Option<String>,
|
||||
pub version: Option<String>,
|
||||
pub creator: Option<String>,
|
||||
pub star_rating: Option<String>,
|
||||
pub bpm: Option<String>,
|
||||
pub total_length: Option<i32>,
|
||||
pub max_combo: Option<i32>,
|
||||
pub ar: Option<String>,
|
||||
pub cs: Option<String>,
|
||||
pub od: Option<String>,
|
||||
pub hp: Option<String>,
|
||||
pub selected_mods: Option<String>,
|
||||
pub mod_position: Option<i32>,
|
||||
pub comment: Option<String>,
|
||||
pub season: Option<String>,
|
||||
pub category: Option<String>,
|
||||
pub url: Option<String>,
|
||||
pub cover_url: Option<String>,
|
||||
pub approved: Option<i32>,
|
||||
pub padding: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct MapSelectionQueryParams {
|
||||
pub page: Option<u64>,
|
||||
pub page_size: Option<u64>,
|
||||
pub season: Option<String>,
|
||||
pub category: Option<String>,
|
||||
pub selected_by: Option<String>,
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
use sea_orm::prelude::{Date, Time};
|
||||
use serde::Deserialize;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct CreateMatchRoomRequest {
|
||||
pub room_name: String,
|
||||
pub round_number: i32,
|
||||
#[schema(value_type = String)]
|
||||
pub match_date: Date,
|
||||
#[schema(value_type = String)]
|
||||
pub match_time: Time,
|
||||
pub match_number: i32,
|
||||
pub max_participants: Option<i32>,
|
||||
pub created_by: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct UpdateMatchRoomRequest {
|
||||
pub room_name: Option<String>,
|
||||
pub round_number: Option<i32>,
|
||||
#[schema(value_type = Option<String>)]
|
||||
pub match_date: Option<Date>,
|
||||
#[schema(value_type = Option<String>)]
|
||||
pub match_time: Option<Time>,
|
||||
pub match_number: Option<i32>,
|
||||
pub max_participants: Option<i32>,
|
||||
pub status: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct MatchRoomQueryParams {
|
||||
pub page: Option<u64>,
|
||||
pub page_size: Option<u64>,
|
||||
pub round_number: Option<i32>,
|
||||
pub status: Option<String>,
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
use serde::Deserialize;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct CreateMatchScheduleRequest {
|
||||
pub room_id: i32,
|
||||
pub player1_osu_id: String,
|
||||
pub player1_username: String,
|
||||
pub player2_osu_id: String,
|
||||
pub player2_username: String,
|
||||
pub created_by: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct UpdateMatchScheduleRequest {
|
||||
pub red_score: Option<i32>,
|
||||
pub blue_score: Option<i32>,
|
||||
pub status: Option<String>,
|
||||
pub replay_link: Option<String>,
|
||||
pub match_link: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct MatchScheduleQueryParams {
|
||||
pub page: Option<u64>,
|
||||
pub page_size: Option<u64>,
|
||||
pub room_id: Option<i32>,
|
||||
pub status: Option<String>,
|
||||
pub player_osu_id: Option<String>,
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
use serde::Deserialize;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct CreateMessageRequest {
|
||||
pub sender_osu_id: String,
|
||||
pub sender_username: String,
|
||||
pub receiver_osu_id: String,
|
||||
pub receiver_username: String,
|
||||
pub r#type: Option<String>,
|
||||
pub title: String,
|
||||
pub content: String,
|
||||
pub related_matchup_id: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct UpdateMessageRequest {
|
||||
pub title: Option<String>,
|
||||
pub content: Option<String>,
|
||||
pub status: Option<String>,
|
||||
pub response_action: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct MessageQueryParams {
|
||||
pub page: Option<u64>,
|
||||
pub page_size: Option<u64>,
|
||||
pub sender_osu_id: Option<String>,
|
||||
pub receiver_osu_id: Option<String>,
|
||||
pub status: Option<String>,
|
||||
}
|
||||
112
src/dto/mod.rs
112
src/dto/mod.rs
@@ -1,112 +0,0 @@
|
||||
pub mod auth;
|
||||
pub mod map_comments;
|
||||
pub mod map_selections;
|
||||
pub mod match_rooms;
|
||||
pub mod match_schedules;
|
||||
pub mod messages;
|
||||
pub mod player_matchups;
|
||||
pub mod tournament_settings;
|
||||
pub mod user;
|
||||
|
||||
/// 通用响应结构
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct ApiResponse<T> {
|
||||
pub success: bool,
|
||||
pub data: Option<T>,
|
||||
pub message: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl<T> ApiResponse<T> {
|
||||
pub fn success(data: T) -> Self {
|
||||
Self {
|
||||
success: true,
|
||||
data: Some(data),
|
||||
message: None,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn success_with_message(data: T, message: &str) -> Self {
|
||||
Self {
|
||||
success: true,
|
||||
data: Some(data),
|
||||
message: Some(message.to_string()),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error(error: &str) -> Self {
|
||||
Self {
|
||||
success: false,
|
||||
data: None,
|
||||
message: None,
|
||||
error: Some(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error_with_message(error: &str, message: &str) -> Self {
|
||||
Self {
|
||||
success: false,
|
||||
data: None,
|
||||
message: Some(message.to_string()),
|
||||
error: Some(error.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
|
||||
pub struct ApiResponseSchema<T: utoipa::ToSchema> {
|
||||
pub success: bool,
|
||||
pub data: Option<T>,
|
||||
pub message: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// 分页请求参数
|
||||
#[derive(Debug, serde::Deserialize, utoipa::IntoParams)]
|
||||
pub struct PaginationParams {
|
||||
#[serde(default = "default_page")]
|
||||
pub page: u64,
|
||||
#[serde(default = "default_page_size")]
|
||||
pub page_size: u64,
|
||||
}
|
||||
|
||||
fn default_page() -> u64 {
|
||||
1
|
||||
}
|
||||
|
||||
fn default_page_size() -> u64 {
|
||||
20
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct PaginatedResponse<T> {
|
||||
pub items: Vec<T>,
|
||||
pub total: u64,
|
||||
pub page: u64,
|
||||
pub page_size: u64,
|
||||
pub total_pages: u64,
|
||||
}
|
||||
|
||||
impl<T> PaginatedResponse<T> {
|
||||
pub fn new(items: Vec<T>, total: u64, page: u64, page_size: u64) -> Self {
|
||||
let total_pages = (total as f64 / page_size as f64).ceil() as u64;
|
||||
Self {
|
||||
items,
|
||||
total,
|
||||
page,
|
||||
page_size,
|
||||
total_pages,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
|
||||
pub struct PaginatedResponseSchema<T: utoipa::ToSchema> {
|
||||
pub items: Vec<T>,
|
||||
pub total: u64,
|
||||
pub page: u64,
|
||||
pub page_size: u64,
|
||||
pub total_pages: u64,
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
use serde::Deserialize;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct CreatePlayerMatchupRequest {
|
||||
pub player1_osu_id: String,
|
||||
pub player1_username: String,
|
||||
pub player2_osu_id: String,
|
||||
pub player2_username: String,
|
||||
pub created_by: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct UpdatePlayerMatchupRequest {
|
||||
pub status: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct PlayerMatchupQueryParams {
|
||||
pub page: Option<u64>,
|
||||
pub page_size: Option<u64>,
|
||||
pub status: Option<String>,
|
||||
pub player_osu_id: Option<String>,
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct CreateTournamentSettingRequest {
|
||||
pub tournament_name: String,
|
||||
pub max_pp_for_registration: Option<f32>,
|
||||
pub min_pp_for_registration: Option<f32>,
|
||||
pub current_season: Option<String>,
|
||||
pub current_season_stage: Option<String>,
|
||||
pub mappool_visible: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct UpdateTournamentSettingRequest {
|
||||
pub tournament_name: Option<String>,
|
||||
pub max_pp_for_registration: Option<f32>,
|
||||
pub min_pp_for_registration: Option<f32>,
|
||||
pub current_season: Option<String>,
|
||||
pub current_season_stage: Option<String>,
|
||||
pub mappool_visible: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema, utoipa::IntoParams)]
|
||||
pub struct TournamentSettingQueryParams {
|
||||
pub page: Option<u64>,
|
||||
pub page_size: Option<u64>,
|
||||
pub current_season: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct TournamentSettingResponse {
|
||||
pub id: i32,
|
||||
pub tournament_name: String,
|
||||
pub max_pp_for_registration: Option<f32>,
|
||||
pub min_pp_for_registration: Option<f32>,
|
||||
pub current_season: Option<String>,
|
||||
pub current_season_stage: Option<String>,
|
||||
pub mappool_visible: Option<i32>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
139
src/dto/user.rs
139
src/dto/user.rs
@@ -1,139 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use validator::Validate;
|
||||
|
||||
use crate::entity::user::Model as UserModel;
|
||||
|
||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||
pub struct CreateUserRequest {
|
||||
#[validate(length(min = 3, max = 50))]
|
||||
pub username: String,
|
||||
|
||||
pub avatar_url: Option<String>,
|
||||
|
||||
pub pp: Option<f32>,
|
||||
|
||||
pub global_rank: Option<i32>,
|
||||
|
||||
pub country: Option<String>,
|
||||
|
||||
pub country_rank: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||
pub struct UpdateUserRequest {
|
||||
#[validate(length(min = 3, max = 50))]
|
||||
pub username: Option<String>,
|
||||
|
||||
pub avatar_url: Option<String>,
|
||||
|
||||
pub pp: Option<f32>,
|
||||
|
||||
pub global_rank: Option<i32>,
|
||||
|
||||
pub country: Option<String>,
|
||||
|
||||
pub country_rank: Option<i32>,
|
||||
|
||||
pub approved: Option<i32>,
|
||||
|
||||
pub season: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct UserResponse {
|
||||
pub id: i32,
|
||||
pub osu_id: String,
|
||||
pub username: String,
|
||||
pub registered_at: String,
|
||||
pub avatar_url: Option<String>,
|
||||
pub pp: Option<f32>,
|
||||
pub global_rank: Option<i32>,
|
||||
pub country: Option<String>,
|
||||
pub country_rank: Option<i32>,
|
||||
pub approved: Option<i32>,
|
||||
pub user_group: String,
|
||||
pub season: Option<i32>,
|
||||
pub registration_status: Option<String>,
|
||||
pub gu_server_user_id: Option<i32>,
|
||||
pub gu_server_username: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
impl From<UserModel> for UserResponse {
|
||||
fn from(user: UserModel) -> Self {
|
||||
Self {
|
||||
id: user.id,
|
||||
osu_id: user.osu_id,
|
||||
username: user.username,
|
||||
registered_at: user.registered_at.to_rfc3339(),
|
||||
avatar_url: user.avatar_url,
|
||||
pp: user.pp,
|
||||
global_rank: user.global_rank,
|
||||
country: user.country,
|
||||
country_rank: user.country_rank,
|
||||
approved: user.approved,
|
||||
user_group: format!("{:?}", user.user_group),
|
||||
season: user.season,
|
||||
registration_status: user
|
||||
.registration_status
|
||||
.map(|status| format!("{:?}", status)),
|
||||
gu_server_user_id: user.gu_server_user_id,
|
||||
gu_server_username: user.gu_server_username,
|
||||
created_at: user.created_at.to_rfc3339(),
|
||||
updated_at: user.updated_at.to_rfc3339(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||
pub struct UpdateUserGroupRequest {
|
||||
pub user_group: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||
pub struct UpdateRegistrationStatusRequest {
|
||||
pub registration_status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||
pub struct UpdateSeasonRequest {
|
||||
pub season: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||
pub struct UpdateApprovedRequest {
|
||||
pub approved: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate, utoipa::IntoParams, utoipa::ToSchema)]
|
||||
#[schema(description = "用户查询参数")]
|
||||
pub struct UserQueryParams {
|
||||
pub username: Option<String>,
|
||||
pub country: Option<String>,
|
||||
pub user_group: Option<String>,
|
||||
pub page: Option<u64>,
|
||||
pub page_size: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct OsuUserData {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
pub avatar_url: Option<String>,
|
||||
pub statistics: Option<OsuStatistics>,
|
||||
pub country: Option<OsuCountry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct OsuStatistics {
|
||||
pub pp: Option<f32>,
|
||||
pub global_rank: Option<i32>,
|
||||
pub country_rank: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct OsuCountry {
|
||||
pub code: String,
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "map_comments")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
pub map_selection_id: i32,
|
||||
pub osu_id: String,
|
||||
pub username: String,
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub comment: Option<String>,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::map_selections::Entity",
|
||||
from = "Column::MapSelectionId",
|
||||
to = "super::map_selections::Column::Id",
|
||||
on_update = "NoAction",
|
||||
on_delete = "NoAction"
|
||||
)]
|
||||
MapSelections,
|
||||
}
|
||||
|
||||
impl Related<super::map_selections::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::MapSelections.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
@@ -1,62 +0,0 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "map_selections")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
pub beatmap_id: i32,
|
||||
pub beatmapset_id: i32,
|
||||
pub title: String,
|
||||
pub title_unicode: Option<String>,
|
||||
pub artist: String,
|
||||
pub artist_unicode: Option<String>,
|
||||
pub version: String,
|
||||
pub creator: String,
|
||||
#[sea_orm(column_type = "Decimal(Some((10, 0)))")]
|
||||
pub star_rating: Decimal,
|
||||
#[sea_orm(column_type = "Decimal(Some((10, 0)))")]
|
||||
pub bpm: Decimal,
|
||||
pub total_length: i32,
|
||||
pub max_combo: i32,
|
||||
#[sea_orm(column_type = "Decimal(Some((10, 0)))")]
|
||||
pub ar: Decimal,
|
||||
#[sea_orm(column_type = "Decimal(Some((10, 0)))")]
|
||||
pub cs: Decimal,
|
||||
#[sea_orm(column_type = "Decimal(Some((10, 0)))")]
|
||||
pub od: Decimal,
|
||||
#[sea_orm(column_type = "Decimal(Some((10, 0)))")]
|
||||
pub hp: Decimal,
|
||||
pub selected_mods: String,
|
||||
pub mod_position: i32,
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub comment: Option<String>,
|
||||
pub selected_by: String,
|
||||
pub selected_at: DateTimeUtc,
|
||||
pub season: String,
|
||||
pub category: String,
|
||||
#[sea_orm(column_type = "Text")]
|
||||
pub url: String,
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub cover_url: Option<String>,
|
||||
pub approved: Option<i32>,
|
||||
pub padding: Option<i32>,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(has_many = "super::map_comments::Entity")]
|
||||
MapComments,
|
||||
}
|
||||
|
||||
impl Related<super::map_comments::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::MapComments.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
@@ -1,35 +0,0 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
|
||||
|
||||
use super::sea_orm_active_enums::Status;
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "match_rooms")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
pub room_name: String,
|
||||
pub round_number: i32,
|
||||
pub match_date: Date,
|
||||
pub match_time: Time,
|
||||
pub match_number: i32,
|
||||
pub max_participants: Option<i32>,
|
||||
pub status: Option<Status>,
|
||||
pub created_by: String,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(has_many = "super::match_schedules::Entity")]
|
||||
MatchSchedules,
|
||||
}
|
||||
|
||||
impl Related<super::match_schedules::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::MatchSchedules.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
@@ -1,46 +0,0 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
|
||||
|
||||
use super::sea_orm_active_enums::Status;
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "match_schedules")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
pub room_id: i32,
|
||||
pub player1_osu_id: String,
|
||||
pub player1_username: String,
|
||||
pub player2_osu_id: String,
|
||||
pub player2_username: String,
|
||||
pub red_score: Option<i32>,
|
||||
pub blue_score: Option<i32>,
|
||||
pub status: Option<Status>,
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub replay_link: Option<String>,
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub match_link: Option<String>,
|
||||
pub created_by: String,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::match_rooms::Entity",
|
||||
from = "Column::RoomId",
|
||||
to = "super::match_rooms::Column::Id",
|
||||
on_update = "NoAction",
|
||||
on_delete = "NoAction"
|
||||
)]
|
||||
MatchRooms,
|
||||
}
|
||||
|
||||
impl Related<super::match_rooms::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::MatchRooms.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
@@ -1,31 +0,0 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
|
||||
|
||||
use super::sea_orm_active_enums::Status;
|
||||
use super::sea_orm_active_enums::Type;
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "messages")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
pub sender_osu_id: String,
|
||||
pub sender_username: String,
|
||||
pub receiver_osu_id: String,
|
||||
pub receiver_username: String,
|
||||
pub r#type: Option<Type>,
|
||||
pub title: String,
|
||||
#[sea_orm(column_type = "Text")]
|
||||
pub content: String,
|
||||
pub related_matchup_id: Option<i32>,
|
||||
pub status: Option<Status>,
|
||||
pub response_action: Option<String>,
|
||||
pub response_time: Option<DateTimeUtc>,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
@@ -1,13 +0,0 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
|
||||
|
||||
pub mod prelude;
|
||||
|
||||
pub mod map_comments;
|
||||
pub mod map_selections;
|
||||
pub mod match_rooms;
|
||||
pub mod match_schedules;
|
||||
pub mod messages;
|
||||
pub mod player_matchups;
|
||||
pub mod sea_orm_active_enums;
|
||||
pub mod tournament_settings;
|
||||
pub mod user;
|
||||
@@ -1,24 +0,0 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
|
||||
|
||||
use super::sea_orm_active_enums::Status;
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "player_matchups")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
pub player1_osu_id: String,
|
||||
pub player1_username: String,
|
||||
pub player2_osu_id: String,
|
||||
pub player2_username: String,
|
||||
pub status: Option<Status>,
|
||||
pub created_by: String,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
@@ -1,10 +0,0 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
|
||||
|
||||
pub use super::map_comments::Entity as MapComments;
|
||||
pub use super::map_selections::Entity as MapSelections;
|
||||
pub use super::match_rooms::Entity as MatchRooms;
|
||||
pub use super::match_schedules::Entity as MatchSchedules;
|
||||
pub use super::messages::Entity as Messages;
|
||||
pub use super::player_matchups::Entity as PlayerMatchups;
|
||||
pub use super::tournament_settings::Entity as TournamentSettings;
|
||||
pub use super::user::Entity as User;
|
||||
@@ -1,43 +0,0 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum)]
|
||||
#[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "status")]
|
||||
pub enum Status {
|
||||
#[sea_orm(string_value = "available")]
|
||||
Available,
|
||||
#[sea_orm(string_value = "scheduled")]
|
||||
Scheduled,
|
||||
#[sea_orm(string_value = "completed")]
|
||||
Completed,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum)]
|
||||
#[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "type")]
|
||||
pub enum Type {
|
||||
#[sea_orm(string_value = "match_invitation")]
|
||||
MatchInvitation,
|
||||
#[sea_orm(string_value = "match_response")]
|
||||
MatchResponse,
|
||||
#[sea_orm(string_value = "system")]
|
||||
System,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum)]
|
||||
#[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "user_group")]
|
||||
pub enum UserGroup {
|
||||
#[sea_orm(string_value = "player")]
|
||||
Player,
|
||||
#[sea_orm(string_value = "admin")]
|
||||
Admin,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum)]
|
||||
#[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "registration_status")]
|
||||
pub enum RegistrationStatus {
|
||||
#[sea_orm(string_value = "pending")]
|
||||
Pending,
|
||||
#[sea_orm(string_value = "confirmed")]
|
||||
Confirmed,
|
||||
#[sea_orm(string_value = "rejected")]
|
||||
Rejected,
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "tournament_settings")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
pub tournament_name: String,
|
||||
#[sea_orm(column_type = "Float", nullable)]
|
||||
pub max_pp_for_registration: Option<f32>,
|
||||
#[sea_orm(column_type = "Float", nullable)]
|
||||
pub min_pp_for_registration: Option<f32>,
|
||||
pub current_season: Option<String>,
|
||||
pub current_season_stage: Option<String>,
|
||||
pub mappool_visible: Option<i32>,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
@@ -1,37 +0,0 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
|
||||
|
||||
use super::sea_orm_active_enums::{RegistrationStatus, UserGroup};
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "user")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
#[sea_orm(unique)]
|
||||
pub osu_id: String,
|
||||
#[sea_orm(unique)]
|
||||
pub username: String,
|
||||
pub registered_at: DateTimeUtc,
|
||||
pub avatar_url: Option<String>,
|
||||
#[sea_orm(column_type = "Float", nullable)]
|
||||
pub pp: Option<f32>,
|
||||
pub global_rank: Option<i32>,
|
||||
pub country: Option<String>,
|
||||
pub country_rank: Option<i32>,
|
||||
pub approved: Option<i32>,
|
||||
pub user_group: UserGroup,
|
||||
pub season: Option<i32>,
|
||||
pub registration_status: Option<RegistrationStatus>,
|
||||
pub gu_server_user_id: Option<i32>,
|
||||
pub gu_server_username: Option<String>,
|
||||
pub osu_refresh_token: Option<String>,
|
||||
pub gu_refresh_token: Option<String>,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
109
src/error/mod.rs
109
src/error/mod.rs
@@ -1,109 +0,0 @@
|
||||
use axum::{
|
||||
Json,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use sea_orm::DbErr;
|
||||
use serde_json::json;
|
||||
use thiserror::Error;
|
||||
|
||||
/// 应用程序错误枚举
|
||||
#[derive(Error, Debug)]
|
||||
pub enum AppError {
|
||||
#[error("数据库错误: {0}")]
|
||||
DatabaseError(#[from] DbErr),
|
||||
|
||||
#[error("未找到资源: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
#[error("认证失败: {0}")]
|
||||
Unauthorized(String),
|
||||
|
||||
#[error("权限不足: {0}")]
|
||||
Forbidden(String),
|
||||
|
||||
#[error("验证失败: {0}")]
|
||||
ValidationError(String),
|
||||
|
||||
#[error("业务错误: {0}")]
|
||||
BusinessError(String),
|
||||
|
||||
#[error("配置错误: {0}")]
|
||||
ConfigError(String),
|
||||
|
||||
#[error("外部API错误: {0}")]
|
||||
ExternalApiError(String),
|
||||
|
||||
#[error("内部服务器错误: {0}")]
|
||||
InternalError(String),
|
||||
|
||||
#[error("功能未实现: {0}")]
|
||||
NotImplemented(String),
|
||||
}
|
||||
|
||||
impl AppError {
|
||||
pub fn status_code(&self) -> StatusCode {
|
||||
match self {
|
||||
AppError::NotFound(_) => StatusCode::NOT_FOUND,
|
||||
AppError::Unauthorized(_) => StatusCode::UNAUTHORIZED,
|
||||
AppError::Forbidden(_) => StatusCode::FORBIDDEN,
|
||||
AppError::ValidationError(_) => StatusCode::BAD_REQUEST,
|
||||
AppError::BusinessError(_) => StatusCode::BAD_REQUEST,
|
||||
AppError::ConfigError(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
AppError::ExternalApiError(_) => StatusCode::BAD_GATEWAY,
|
||||
AppError::DatabaseError(_) | AppError::InternalError(_) | AppError::NotImplemented(_) => {
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error_message(&self) -> String {
|
||||
match self {
|
||||
AppError::DatabaseError(err) => format!("数据库错误: {}", err),
|
||||
AppError::NotFound(msg) => format!("未找到资源: {}", msg),
|
||||
AppError::Unauthorized(msg) => format!("认证失败: {}", msg),
|
||||
AppError::Forbidden(msg) => format!("权限不足: {}", msg),
|
||||
AppError::ValidationError(msg) => format!("验证失败: {}", msg),
|
||||
AppError::BusinessError(msg) => format!("业务错误: {}", msg),
|
||||
AppError::ConfigError(msg) => format!("配置错误: {}", msg),
|
||||
AppError::ExternalApiError(msg) => format!("外部API错误: {}", msg),
|
||||
AppError::InternalError(msg) => format!("内部服务器错误: {}", msg),
|
||||
AppError::NotImplemented(msg) => format!("功能未实现: {}", msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response {
|
||||
let status = self.status_code();
|
||||
let error_response = json!({
|
||||
"success": false,
|
||||
"error": self.error_message(),
|
||||
"status_code": status.as_u16(),
|
||||
});
|
||||
|
||||
(status, Json(error_response)).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, AppError>;
|
||||
|
||||
pub fn not_found(msg: &str) -> AppError {
|
||||
AppError::NotFound(msg.to_string())
|
||||
}
|
||||
|
||||
pub fn unauthorized(msg: &str) -> AppError {
|
||||
AppError::Unauthorized(msg.to_string())
|
||||
}
|
||||
|
||||
pub fn forbidden(msg: &str) -> AppError {
|
||||
AppError::Forbidden(msg.to_string())
|
||||
}
|
||||
|
||||
pub fn validation_error(msg: &str) -> AppError {
|
||||
AppError::ValidationError(msg.to_string())
|
||||
}
|
||||
|
||||
pub fn business_error(msg: &str) -> AppError {
|
||||
AppError::BusinessError(msg.to_string())
|
||||
}
|
||||
87
src/main.rs
87
src/main.rs
@@ -1,87 +0,0 @@
|
||||
|
||||
use config::Config;
|
||||
use sea_orm::Database;
|
||||
use service::ServiceState;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
mod api;
|
||||
mod config;
|
||||
mod dto;
|
||||
mod entity;
|
||||
mod error;
|
||||
mod middleware;
|
||||
mod service;
|
||||
mod utils;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// 初始化日志
|
||||
tracing_subscriber::registry()
|
||||
.with(tracing_subscriber::EnvFilter::new(
|
||||
std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into()),
|
||||
))
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.init();
|
||||
|
||||
// 加载配置
|
||||
let config = Config::from_env()?;
|
||||
|
||||
print_banner();
|
||||
println!("启动 AstraCup 后端服务...");
|
||||
|
||||
println!("连接数据库...");
|
||||
let db = Database::connect(&config.database_url).await?;
|
||||
println!("数据库连接成功");
|
||||
|
||||
// 创建服务状态
|
||||
let service_state = ServiceState::new(db);
|
||||
|
||||
// 配置应用
|
||||
let app = api::configure_routes().with_state(service_state);
|
||||
|
||||
println!("配置加载成功");
|
||||
|
||||
// 启动服务器
|
||||
let listener = tokio::net::TcpListener::bind(format!("{}:{}", config.host, config.port))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
println!("服务器启动成功");
|
||||
println!("监听地址: {}:{}", config.host, config.port);
|
||||
println!("数据库: {}", mask_database_url(&config.database_url));
|
||||
println!("API文档: http://{}:{}/api/docs", config.host, config.port);
|
||||
println!("健康检查: http://{}:{}/health", config.host, config.port);
|
||||
|
||||
axum::serve(listener, app).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_banner() {
|
||||
println!(r" _ _ ____ ");
|
||||
println!(r" / \ ___| |_ __ _ _ __ / ____ _ _ __ ");
|
||||
println!(r" / _ \ / __| __/ _` | '__| | | | | | '_ \ ");
|
||||
println!(r" / ___ \\__ \ || (_| | | | |__| |_| | |_) |");
|
||||
println!(r" /_/ \_\___/\__\__,_|_| \____\__,_| .__/ ");
|
||||
println!(r" |_| ");
|
||||
println!(r"==============================================");
|
||||
}
|
||||
|
||||
fn mask_database_url(url: &str) -> String {
|
||||
if let Some(pos) = url.find('@') {
|
||||
let mut masked = url.to_string();
|
||||
if let Some(start) = url.find("://") {
|
||||
let end = pos;
|
||||
if end > start + 3 {
|
||||
let credentials = &url[start + 3..end];
|
||||
if let Some(colon_pos) = credentials.find(':') {
|
||||
let username = &credentials[..colon_pos];
|
||||
masked.replace_range(start + 3..end, &format!("{}:******", username));
|
||||
}
|
||||
}
|
||||
}
|
||||
masked
|
||||
} else {
|
||||
url.to_string()
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
use axum::extract::FromRequestParts;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AuthUser {
|
||||
pub id: i32,
|
||||
pub osu_id: String,
|
||||
pub username: String,
|
||||
pub user_group: String,
|
||||
}
|
||||
|
||||
impl<S> FromRequestParts<S> for AuthUser
|
||||
where
|
||||
S: Send + Sync + Clone + 'static,
|
||||
{
|
||||
type Rejection = AppError;
|
||||
|
||||
fn from_request_parts(
|
||||
parts: &mut axum::http::request::Parts,
|
||||
_state: &S,
|
||||
) -> impl std::future::Future<Output = std::result::Result<Self, Self::Rejection>> + Send {
|
||||
async move {
|
||||
let auth_header = parts
|
||||
.headers
|
||||
.get("Authorization")
|
||||
.and_then(|header| header.to_str().ok())
|
||||
.ok_or_else(|| AppError::Unauthorized("Authorization header missing".into()))?;
|
||||
|
||||
if !auth_header.starts_with("Bearer ") {
|
||||
return Err(AppError::Unauthorized(
|
||||
"Invalid authorization format".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let token = &auth_header[7..];
|
||||
|
||||
let claims = crate::utils::jwt::verify_token(token)
|
||||
.map_err(|_| AppError::Unauthorized("Invalid or expired token".into()))?;
|
||||
|
||||
Ok(AuthUser {
|
||||
id: 0,
|
||||
osu_id: claims.sub,
|
||||
username: String::new(),
|
||||
user_group: claims.user_group,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn require_admin(user: &AuthUser) -> std::result::Result<(), AppError> {
|
||||
if user.user_group != "admin" {
|
||||
return Err(AppError::Forbidden("只有管理员才能执行此操作".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
pub mod auth;
|
||||
|
||||
use tower::ServiceBuilder;
|
||||
use tower_http::trace::TraceLayer;
|
||||
|
||||
pub fn configure_middleware() -> impl tower::Layer<axum::Router> + Clone {
|
||||
ServiceBuilder::new().layer(TraceLayer::new_for_http())
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter,
|
||||
QueryOrder, Set,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
dto::map_comments::{CreateMapCommentRequest, MapCommentQueryParams, UpdateMapCommentRequest},
|
||||
entity::map_comments,
|
||||
error::{AppError, Result},
|
||||
};
|
||||
|
||||
pub struct MapCommentService {
|
||||
db: DatabaseConnection,
|
||||
}
|
||||
|
||||
impl MapCommentService {
|
||||
pub fn new(db: DatabaseConnection) -> Self {
|
||||
Self { db }
|
||||
}
|
||||
|
||||
pub async fn find_all(
|
||||
&self,
|
||||
params: MapCommentQueryParams,
|
||||
) -> Result<(Vec<map_comments::Model>, u64)> {
|
||||
let mut query = map_comments::Entity::find();
|
||||
|
||||
if let Some(map_selection_id) = params.map_selection_id {
|
||||
query = query.filter(map_comments::Column::MapSelectionId.eq(map_selection_id));
|
||||
}
|
||||
|
||||
if let Some(osu_id) = params.osu_id {
|
||||
query = query.filter(map_comments::Column::OsuId.eq(osu_id));
|
||||
}
|
||||
|
||||
query = query.order_by_desc(map_comments::Column::CreatedAt);
|
||||
|
||||
let page = params.page.unwrap_or(1);
|
||||
let page_size = params.page_size.unwrap_or(20);
|
||||
|
||||
let total = query.clone().count(&self.db).await?;
|
||||
let map_comments = query
|
||||
.paginate(&self.db, page_size)
|
||||
.fetch_page(page - 1)
|
||||
.await?;
|
||||
|
||||
Ok((map_comments, total))
|
||||
}
|
||||
|
||||
pub async fn find_by_id(&self, id: i32) -> Result<map_comments::Model> {
|
||||
map_comments::Entity::find_by_id(id)
|
||||
.one(&self.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("地图评论 {} 不存在", id)))
|
||||
}
|
||||
|
||||
pub async fn create(&self, data: CreateMapCommentRequest) -> Result<map_comments::Model> {
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
let map_comment = map_comments::ActiveModel {
|
||||
map_selection_id: Set(data.map_selection_id),
|
||||
osu_id: Set(data.osu_id),
|
||||
username: Set(data.username),
|
||||
comment: Set(data.comment),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
Ok(map_comment.insert(&self.db).await?)
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
&self,
|
||||
id: i32,
|
||||
data: UpdateMapCommentRequest,
|
||||
) -> Result<map_comments::Model> {
|
||||
let mut map_comment: map_comments::ActiveModel = self.find_by_id(id).await?.into();
|
||||
|
||||
if let Some(comment) = data.comment {
|
||||
map_comment.comment = Set(Some(comment));
|
||||
}
|
||||
|
||||
map_comment.updated_at = Set(chrono::Utc::now());
|
||||
|
||||
Ok(map_comment.update(&self.db).await?)
|
||||
}
|
||||
|
||||
pub async fn delete(&self, id: i32) -> Result<()> {
|
||||
let result = map_comments::Entity::delete_by_id(id)
|
||||
.exec(&self.db)
|
||||
.await?;
|
||||
if result.rows_affected == 0 {
|
||||
return Err(AppError::NotFound(format!("地图评论 {} 不存在", id)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
use sea_orm::prelude::Decimal;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter,
|
||||
QueryOrder, Set,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
dto::map_selections::{
|
||||
CreateMapSelectionRequest, MapSelectionQueryParams, UpdateMapSelectionRequest,
|
||||
},
|
||||
entity::map_selections,
|
||||
error::{AppError, Result},
|
||||
};
|
||||
|
||||
pub struct MapSelectionService {
|
||||
db: DatabaseConnection,
|
||||
}
|
||||
|
||||
impl MapSelectionService {
|
||||
pub fn new(db: DatabaseConnection) -> Self {
|
||||
Self { db }
|
||||
}
|
||||
|
||||
pub async fn find_all(
|
||||
&self,
|
||||
params: MapSelectionQueryParams,
|
||||
) -> Result<(Vec<map_selections::Model>, u64)> {
|
||||
let mut query = map_selections::Entity::find();
|
||||
|
||||
if let Some(season) = params.season {
|
||||
query = query.filter(map_selections::Column::Season.eq(season));
|
||||
}
|
||||
|
||||
if let Some(category) = params.category {
|
||||
query = query.filter(map_selections::Column::Category.eq(category));
|
||||
}
|
||||
|
||||
if let Some(selected_by) = params.selected_by {
|
||||
query = query.filter(map_selections::Column::SelectedBy.eq(selected_by));
|
||||
}
|
||||
|
||||
query = query.order_by_desc(map_selections::Column::CreatedAt);
|
||||
|
||||
let page = params.page.unwrap_or(1);
|
||||
let page_size = params.page_size.unwrap_or(20);
|
||||
|
||||
let total = query.clone().count(&self.db).await?;
|
||||
let map_selections = query
|
||||
.paginate(&self.db, page_size)
|
||||
.fetch_page(page - 1)
|
||||
.await?;
|
||||
|
||||
Ok((map_selections, total))
|
||||
}
|
||||
|
||||
pub async fn find_by_id(&self, id: i32) -> Result<map_selections::Model> {
|
||||
map_selections::Entity::find_by_id(id)
|
||||
.one(&self.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("地图选择 {} 不存在", id)))
|
||||
}
|
||||
|
||||
pub async fn create(&self, data: CreateMapSelectionRequest) -> Result<map_selections::Model> {
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
let map_selection = map_selections::ActiveModel {
|
||||
beatmap_id: Set(data.beatmap_id),
|
||||
beatmapset_id: Set(data.beatmapset_id),
|
||||
title: Set(data.title),
|
||||
title_unicode: Set(data.title_unicode),
|
||||
artist: Set(data.artist),
|
||||
artist_unicode: Set(data.artist_unicode),
|
||||
version: Set(data.version),
|
||||
creator: Set(data.creator),
|
||||
star_rating: Set(Decimal::from(data.star_rating.parse::<i64>().unwrap())),
|
||||
bpm: Set(Decimal::from(data.bpm.parse::<i64>().unwrap())),
|
||||
total_length: Set(data.total_length),
|
||||
max_combo: Set(data.max_combo),
|
||||
ar: Set(Decimal::from(data.ar.parse::<i64>().unwrap())),
|
||||
cs: Set(Decimal::from(data.cs.parse::<i64>().unwrap())),
|
||||
od: Set(Decimal::from(data.od.parse::<i64>().unwrap())),
|
||||
hp: Set(Decimal::from(data.hp.parse::<i64>().unwrap())),
|
||||
selected_mods: Set(data.selected_mods),
|
||||
mod_position: Set(data.mod_position),
|
||||
comment: Set(data.comment),
|
||||
selected_by: Set(data.selected_by),
|
||||
selected_at: Set(now),
|
||||
season: Set(data.season),
|
||||
category: Set(data.category),
|
||||
url: Set(data.url),
|
||||
cover_url: Set(data.cover_url),
|
||||
approved: Set(data.approved),
|
||||
padding: Set(data.padding),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
Ok(map_selection.insert(&self.db).await?)
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
&self,
|
||||
id: i32,
|
||||
data: UpdateMapSelectionRequest,
|
||||
) -> Result<map_selections::Model> {
|
||||
let mut map_selection: map_selections::ActiveModel = self.find_by_id(id).await?.into();
|
||||
|
||||
if let Some(title) = data.title {
|
||||
map_selection.title = Set(title);
|
||||
}
|
||||
|
||||
if let Some(title_unicode) = data.title_unicode {
|
||||
map_selection.title_unicode = Set(Some(title_unicode));
|
||||
}
|
||||
|
||||
if let Some(artist) = data.artist {
|
||||
map_selection.artist = Set(artist);
|
||||
}
|
||||
|
||||
if let Some(artist_unicode) = data.artist_unicode {
|
||||
map_selection.artist_unicode = Set(Some(artist_unicode));
|
||||
}
|
||||
|
||||
if let Some(version) = data.version {
|
||||
map_selection.version = Set(version);
|
||||
}
|
||||
|
||||
if let Some(creator) = data.creator {
|
||||
map_selection.creator = Set(creator);
|
||||
}
|
||||
|
||||
if let Some(star_rating) = data.star_rating {
|
||||
map_selection.star_rating = Set(Decimal::from(star_rating.parse::<i64>().unwrap()));
|
||||
}
|
||||
|
||||
if let Some(bpm) = data.bpm {
|
||||
map_selection.bpm = Set(Decimal::from(bpm.parse::<i64>().unwrap()));
|
||||
}
|
||||
|
||||
if let Some(total_length) = data.total_length {
|
||||
map_selection.total_length = Set(total_length);
|
||||
}
|
||||
|
||||
if let Some(max_combo) = data.max_combo {
|
||||
map_selection.max_combo = Set(max_combo);
|
||||
}
|
||||
|
||||
if let Some(ar) = data.ar {
|
||||
map_selection.ar = Set(Decimal::from(ar.parse::<i64>().unwrap()));
|
||||
}
|
||||
|
||||
if let Some(cs) = data.cs {
|
||||
map_selection.cs = Set(Decimal::from(cs.parse::<i64>().unwrap()));
|
||||
}
|
||||
|
||||
if let Some(od) = data.od {
|
||||
map_selection.od = Set(Decimal::from(od.parse::<i64>().unwrap()));
|
||||
}
|
||||
|
||||
if let Some(hp) = data.hp {
|
||||
map_selection.hp = Set(Decimal::from(hp.parse::<i64>().unwrap()));
|
||||
}
|
||||
|
||||
if let Some(selected_mods) = data.selected_mods {
|
||||
map_selection.selected_mods = Set(selected_mods);
|
||||
}
|
||||
|
||||
if let Some(mod_position) = data.mod_position {
|
||||
map_selection.mod_position = Set(mod_position);
|
||||
}
|
||||
|
||||
if let Some(comment) = data.comment {
|
||||
map_selection.comment = Set(Some(comment));
|
||||
}
|
||||
|
||||
if let Some(season) = data.season {
|
||||
map_selection.season = Set(season);
|
||||
}
|
||||
|
||||
if let Some(category) = data.category {
|
||||
map_selection.category = Set(category);
|
||||
}
|
||||
|
||||
if let Some(url) = data.url {
|
||||
map_selection.url = Set(url);
|
||||
}
|
||||
|
||||
if let Some(cover_url) = data.cover_url {
|
||||
map_selection.cover_url = Set(Some(cover_url));
|
||||
}
|
||||
|
||||
if let Some(approved) = data.approved {
|
||||
map_selection.approved = Set(Some(approved));
|
||||
}
|
||||
|
||||
if let Some(padding) = data.padding {
|
||||
map_selection.padding = Set(Some(padding));
|
||||
}
|
||||
|
||||
map_selection.updated_at = Set(chrono::Utc::now());
|
||||
|
||||
Ok(map_selection.update(&self.db).await?)
|
||||
}
|
||||
|
||||
pub async fn delete(&self, id: i32) -> Result<()> {
|
||||
let result = map_selections::Entity::delete_by_id(id)
|
||||
.exec(&self.db)
|
||||
.await?;
|
||||
if result.rows_affected == 0 {
|
||||
return Err(AppError::NotFound(format!("地图选择 {} 不存在", id)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter,
|
||||
QueryOrder, Set,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
dto::match_rooms::{CreateMatchRoomRequest, MatchRoomQueryParams, UpdateMatchRoomRequest},
|
||||
entity::{match_rooms, sea_orm_active_enums::Status},
|
||||
error::{AppError, Result},
|
||||
};
|
||||
|
||||
pub struct MatchRoomService {
|
||||
db: DatabaseConnection,
|
||||
}
|
||||
|
||||
impl MatchRoomService {
|
||||
pub fn new(db: DatabaseConnection) -> Self {
|
||||
Self { db }
|
||||
}
|
||||
|
||||
pub async fn find_all(
|
||||
&self,
|
||||
params: MatchRoomQueryParams,
|
||||
) -> Result<(Vec<match_rooms::Model>, u64)> {
|
||||
let mut query = match_rooms::Entity::find();
|
||||
|
||||
if let Some(round_number) = params.round_number {
|
||||
query = query.filter(match_rooms::Column::RoundNumber.eq(round_number));
|
||||
}
|
||||
|
||||
if let Some(status) = params.status {
|
||||
let status_enum = match status.as_str() {
|
||||
"available" => Status::Available,
|
||||
"scheduled" => Status::Scheduled,
|
||||
"completed" => Status::Completed,
|
||||
_ => return Err(AppError::ValidationError("无效的状态".into())),
|
||||
};
|
||||
query = query.filter(match_rooms::Column::Status.eq(Some(status_enum)));
|
||||
}
|
||||
|
||||
query = query.order_by_asc(match_rooms::Column::Id);
|
||||
|
||||
let page = params.page.unwrap_or(1);
|
||||
let page_size = params.page_size.unwrap_or(20);
|
||||
|
||||
let total = query.clone().count(&self.db).await?;
|
||||
let match_rooms = query
|
||||
.paginate(&self.db, page_size)
|
||||
.fetch_page(page - 1)
|
||||
.await?;
|
||||
|
||||
Ok((match_rooms, total))
|
||||
}
|
||||
|
||||
pub async fn find_by_id(&self, id: i32) -> Result<match_rooms::Model> {
|
||||
match_rooms::Entity::find_by_id(id)
|
||||
.one(&self.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("匹配房间 {} 不存在", id)))
|
||||
}
|
||||
|
||||
pub async fn create(&self, data: CreateMatchRoomRequest) -> Result<match_rooms::Model> {
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
let match_room = match_rooms::ActiveModel {
|
||||
room_name: Set(data.room_name),
|
||||
round_number: Set(data.round_number),
|
||||
match_date: Set(data.match_date),
|
||||
match_time: Set(data.match_time),
|
||||
match_number: Set(data.match_number),
|
||||
max_participants: Set(data.max_participants),
|
||||
status: Set(Some(Status::Available)),
|
||||
created_by: Set(data.created_by),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
Ok(match_room.insert(&self.db).await?)
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
&self,
|
||||
id: i32,
|
||||
data: UpdateMatchRoomRequest,
|
||||
) -> Result<match_rooms::Model> {
|
||||
let mut match_room: match_rooms::ActiveModel = self.find_by_id(id).await?.into();
|
||||
|
||||
if let Some(room_name) = data.room_name {
|
||||
match_room.room_name = Set(room_name);
|
||||
}
|
||||
|
||||
if let Some(round_number) = data.round_number {
|
||||
match_room.round_number = Set(round_number);
|
||||
}
|
||||
|
||||
if let Some(match_date) = data.match_date {
|
||||
match_room.match_date = Set(match_date);
|
||||
}
|
||||
|
||||
if let Some(match_time) = data.match_time {
|
||||
match_room.match_time = Set(match_time);
|
||||
}
|
||||
|
||||
if let Some(match_number) = data.match_number {
|
||||
match_room.match_number = Set(match_number);
|
||||
}
|
||||
|
||||
if let Some(max_participants) = data.max_participants {
|
||||
match_room.max_participants = Set(Some(max_participants));
|
||||
}
|
||||
|
||||
if let Some(status) = data.status {
|
||||
let status_enum = match status.as_str() {
|
||||
"available" => Status::Available,
|
||||
"scheduled" => Status::Scheduled,
|
||||
"completed" => Status::Completed,
|
||||
_ => return Err(AppError::ValidationError("无效的状态".into())),
|
||||
};
|
||||
match_room.status = Set(Some(status_enum));
|
||||
}
|
||||
|
||||
match_room.updated_at = Set(chrono::Utc::now());
|
||||
|
||||
Ok(match_room.update(&self.db).await?)
|
||||
}
|
||||
|
||||
pub async fn delete(&self, id: i32) -> Result<()> {
|
||||
let result = match_rooms::Entity::delete_by_id(id).exec(&self.db).await?;
|
||||
if result.rows_affected == 0 {
|
||||
return Err(AppError::NotFound(format!("匹配房间 {} 不存在", id)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter,
|
||||
QueryOrder, Set,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
dto::match_schedules::{
|
||||
CreateMatchScheduleRequest, MatchScheduleQueryParams, UpdateMatchScheduleRequest,
|
||||
},
|
||||
entity::{match_schedules, sea_orm_active_enums::Status},
|
||||
error::{AppError, Result},
|
||||
};
|
||||
|
||||
pub struct MatchScheduleService {
|
||||
db: DatabaseConnection,
|
||||
}
|
||||
|
||||
impl MatchScheduleService {
|
||||
pub fn new(db: DatabaseConnection) -> Self {
|
||||
Self { db }
|
||||
}
|
||||
|
||||
pub async fn find_all(
|
||||
&self,
|
||||
params: MatchScheduleQueryParams,
|
||||
) -> Result<(Vec<match_schedules::Model>, u64)> {
|
||||
let mut query = match_schedules::Entity::find();
|
||||
|
||||
if let Some(room_id) = params.room_id {
|
||||
query = query.filter(match_schedules::Column::RoomId.eq(room_id));
|
||||
}
|
||||
|
||||
if let Some(status) = params.status {
|
||||
let status_enum = match status.as_str() {
|
||||
"available" => Status::Available,
|
||||
"scheduled" => Status::Scheduled,
|
||||
"completed" => Status::Completed,
|
||||
_ => return Err(AppError::ValidationError("无效的状态".into())),
|
||||
};
|
||||
query = query.filter(match_schedules::Column::Status.eq(Some(status_enum)));
|
||||
}
|
||||
|
||||
if let Some(player_osu_id) = params.player_osu_id {
|
||||
query = query.filter(
|
||||
match_schedules::Column::Player1OsuId
|
||||
.eq(&player_osu_id)
|
||||
.or(match_schedules::Column::Player2OsuId.eq(&player_osu_id)),
|
||||
);
|
||||
}
|
||||
|
||||
query = query.order_by_desc(match_schedules::Column::CreatedAt);
|
||||
|
||||
let page = params.page.unwrap_or(1);
|
||||
let page_size = params.page_size.unwrap_or(20);
|
||||
|
||||
let total = query.clone().count(&self.db).await?;
|
||||
let match_schedules = query
|
||||
.paginate(&self.db, page_size)
|
||||
.fetch_page(page - 1)
|
||||
.await?;
|
||||
|
||||
Ok((match_schedules, total))
|
||||
}
|
||||
|
||||
pub async fn find_by_id(&self, id: i32) -> Result<match_schedules::Model> {
|
||||
match_schedules::Entity::find_by_id(id)
|
||||
.one(&self.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("匹配赛程 {} 不存在", id)))
|
||||
}
|
||||
|
||||
pub async fn create(&self, data: CreateMatchScheduleRequest) -> Result<match_schedules::Model> {
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
let match_schedule = match_schedules::ActiveModel {
|
||||
room_id: Set(data.room_id),
|
||||
player1_osu_id: Set(data.player1_osu_id),
|
||||
player1_username: Set(data.player1_username),
|
||||
player2_osu_id: Set(data.player2_osu_id),
|
||||
player2_username: Set(data.player2_username),
|
||||
status: Set(Some(Status::Available)),
|
||||
created_by: Set(data.created_by),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
Ok(match_schedule.insert(&self.db).await?)
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
&self,
|
||||
id: i32,
|
||||
data: UpdateMatchScheduleRequest,
|
||||
) -> Result<match_schedules::Model> {
|
||||
let mut match_schedule: match_schedules::ActiveModel = self.find_by_id(id).await?.into();
|
||||
|
||||
if let Some(red_score) = data.red_score {
|
||||
match_schedule.red_score = Set(Some(red_score));
|
||||
}
|
||||
|
||||
if let Some(blue_score) = data.blue_score {
|
||||
match_schedule.blue_score = Set(Some(blue_score));
|
||||
}
|
||||
|
||||
if let Some(status) = data.status {
|
||||
let status_enum = match status.as_str() {
|
||||
"available" => Status::Available,
|
||||
"scheduled" => Status::Scheduled,
|
||||
"completed" => Status::Completed,
|
||||
_ => return Err(AppError::ValidationError("无效的状态".into())),
|
||||
};
|
||||
match_schedule.status = Set(Some(status_enum));
|
||||
}
|
||||
|
||||
if let Some(replay_link) = data.replay_link {
|
||||
match_schedule.replay_link = Set(Some(replay_link));
|
||||
}
|
||||
|
||||
if let Some(match_link) = data.match_link {
|
||||
match_schedule.match_link = Set(Some(match_link));
|
||||
}
|
||||
|
||||
match_schedule.updated_at = Set(chrono::Utc::now());
|
||||
|
||||
Ok(match_schedule.update(&self.db).await?)
|
||||
}
|
||||
|
||||
pub async fn delete(&self, id: i32) -> Result<()> {
|
||||
let result = match_schedules::Entity::delete_by_id(id)
|
||||
.exec(&self.db)
|
||||
.await?;
|
||||
if result.rows_affected == 0 {
|
||||
return Err(AppError::NotFound(format!("匹配赛程 {} 不存在", id)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter,
|
||||
QueryOrder, Set,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
dto::messages::{CreateMessageRequest, MessageQueryParams, UpdateMessageRequest},
|
||||
entity::{
|
||||
messages,
|
||||
sea_orm_active_enums::{Status, Type},
|
||||
},
|
||||
error::{AppError, Result},
|
||||
};
|
||||
|
||||
pub struct MessageService {
|
||||
db: DatabaseConnection,
|
||||
}
|
||||
|
||||
impl MessageService {
|
||||
pub fn new(db: DatabaseConnection) -> Self {
|
||||
Self { db }
|
||||
}
|
||||
|
||||
pub async fn find_all(
|
||||
&self,
|
||||
params: MessageQueryParams,
|
||||
) -> Result<(Vec<messages::Model>, u64)> {
|
||||
let mut query = messages::Entity::find();
|
||||
|
||||
if let Some(sender_osu_id) = params.sender_osu_id {
|
||||
query = query.filter(messages::Column::SenderOsuId.eq(sender_osu_id));
|
||||
}
|
||||
|
||||
if let Some(receiver_osu_id) = params.receiver_osu_id {
|
||||
query = query.filter(messages::Column::ReceiverOsuId.eq(receiver_osu_id));
|
||||
}
|
||||
|
||||
if let Some(status) = params.status {
|
||||
let status_enum = match status.as_str() {
|
||||
"available" => Status::Available,
|
||||
"scheduled" => Status::Scheduled,
|
||||
"completed" => Status::Completed,
|
||||
_ => return Err(AppError::ValidationError("无效的状态".into())),
|
||||
};
|
||||
query = query.filter(messages::Column::Status.eq(Some(status_enum)));
|
||||
}
|
||||
|
||||
query = query.order_by_desc(messages::Column::CreatedAt);
|
||||
|
||||
let page = params.page.unwrap_or(1);
|
||||
let page_size = params.page_size.unwrap_or(20);
|
||||
|
||||
let total = query.clone().count(&self.db).await?;
|
||||
let messages = query
|
||||
.paginate(&self.db, page_size)
|
||||
.fetch_page(page - 1)
|
||||
.await?;
|
||||
|
||||
Ok((messages, total))
|
||||
}
|
||||
|
||||
pub async fn find_by_id(&self, id: i32) -> Result<messages::Model> {
|
||||
messages::Entity::find_by_id(id)
|
||||
.one(&self.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("消息 {} 不存在", id)))
|
||||
}
|
||||
|
||||
pub async fn create(&self, data: CreateMessageRequest) -> Result<messages::Model> {
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
let message_type = data.r#type.map(|t| match t.as_str() {
|
||||
"system" => Type::System,
|
||||
"match_invitation" => Type::MatchInvitation,
|
||||
"match_response" => Type::MatchResponse,
|
||||
_ => panic!("无效的消息类型"),
|
||||
});
|
||||
|
||||
let message = messages::ActiveModel {
|
||||
sender_osu_id: Set(data.sender_osu_id),
|
||||
sender_username: Set(data.sender_username),
|
||||
receiver_osu_id: Set(data.receiver_osu_id),
|
||||
receiver_username: Set(data.receiver_username),
|
||||
r#type: Set(message_type),
|
||||
title: Set(data.title),
|
||||
content: Set(data.content),
|
||||
related_matchup_id: Set(data.related_matchup_id),
|
||||
status: Set(Some(Status::Available)),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
Ok(message.insert(&self.db).await?)
|
||||
}
|
||||
|
||||
pub async fn update(&self, id: i32, data: UpdateMessageRequest) -> Result<messages::Model> {
|
||||
let mut message: messages::ActiveModel = self.find_by_id(id).await?.into();
|
||||
|
||||
if let Some(title) = data.title {
|
||||
message.title = Set(title);
|
||||
}
|
||||
|
||||
if let Some(content) = data.content {
|
||||
message.content = Set(content);
|
||||
}
|
||||
|
||||
if let Some(status) = data.status {
|
||||
let status_enum = match status.as_str() {
|
||||
"available" => Status::Available,
|
||||
"scheduled" => Status::Scheduled,
|
||||
"completed" => Status::Completed,
|
||||
_ => return Err(AppError::ValidationError("无效的状态".into())),
|
||||
};
|
||||
message.status = Set(Some(status_enum));
|
||||
if status == "completed" {
|
||||
message.response_time = Set(Some(chrono::Utc::now()));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(response_action) = data.response_action {
|
||||
message.response_action = Set(Some(response_action));
|
||||
}
|
||||
|
||||
message.updated_at = Set(chrono::Utc::now());
|
||||
|
||||
Ok(message.update(&self.db).await?)
|
||||
}
|
||||
|
||||
pub async fn delete(&self, id: i32) -> Result<()> {
|
||||
let result = messages::Entity::delete_by_id(id).exec(&self.db).await?;
|
||||
if result.rows_affected == 0 {
|
||||
return Err(AppError::NotFound(format!("消息 {} 不存在", id)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
pub mod auth;
|
||||
pub mod map_comments;
|
||||
pub mod map_selections;
|
||||
pub mod match_rooms;
|
||||
pub mod match_schedules;
|
||||
pub mod messages;
|
||||
pub mod player_matchups;
|
||||
pub mod tournament_settings;
|
||||
pub mod user;
|
||||
|
||||
use sea_orm::DatabaseConnection;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// 服务层共享状态
|
||||
#[derive(Clone)]
|
||||
pub struct ServiceState {
|
||||
pub db: DatabaseConnection,
|
||||
pub tournament_settings_cache: Arc<RwLock<Option<crate::entity::tournament_settings::Model>>>,
|
||||
}
|
||||
|
||||
/// 确保 ServiceState 实现了 Send + Sync traits
|
||||
unsafe impl Send for ServiceState {}
|
||||
unsafe impl Sync for ServiceState {}
|
||||
|
||||
impl ServiceState {
|
||||
/// 创建新的服务状态
|
||||
pub fn new(db: DatabaseConnection) -> Self {
|
||||
Self {
|
||||
db,
|
||||
tournament_settings_cache: Arc::new(RwLock::new(None)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter,
|
||||
QueryOrder, Set,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
dto::player_matchups::{
|
||||
CreatePlayerMatchupRequest, PlayerMatchupQueryParams, UpdatePlayerMatchupRequest,
|
||||
},
|
||||
entity::{player_matchups, sea_orm_active_enums::Status},
|
||||
error::{AppError, Result},
|
||||
};
|
||||
|
||||
pub struct PlayerMatchupService {
|
||||
db: DatabaseConnection,
|
||||
}
|
||||
|
||||
impl PlayerMatchupService {
|
||||
pub fn new(db: DatabaseConnection) -> Self {
|
||||
Self { db }
|
||||
}
|
||||
|
||||
pub async fn find_all(
|
||||
&self,
|
||||
params: PlayerMatchupQueryParams,
|
||||
) -> Result<(Vec<player_matchups::Model>, u64)> {
|
||||
let mut query = player_matchups::Entity::find();
|
||||
|
||||
if let Some(status) = params.status {
|
||||
let status_enum = match status.as_str() {
|
||||
"available" => Status::Available,
|
||||
"scheduled" => Status::Scheduled,
|
||||
"completed" => Status::Completed,
|
||||
_ => return Err(AppError::ValidationError("无效的状态".into())),
|
||||
};
|
||||
query = query.filter(player_matchups::Column::Status.eq(Some(status_enum)));
|
||||
}
|
||||
|
||||
if let Some(player_osu_id) = params.player_osu_id {
|
||||
query = query.filter(
|
||||
player_matchups::Column::Player1OsuId
|
||||
.eq(&player_osu_id)
|
||||
.or(player_matchups::Column::Player2OsuId.eq(&player_osu_id)),
|
||||
);
|
||||
}
|
||||
|
||||
query = query.order_by_desc(player_matchups::Column::CreatedAt);
|
||||
|
||||
let page = params.page.unwrap_or(1);
|
||||
let page_size = params.page_size.unwrap_or(20);
|
||||
|
||||
let total = query.clone().count(&self.db).await?;
|
||||
let player_matchups = query
|
||||
.paginate(&self.db, page_size)
|
||||
.fetch_page(page - 1)
|
||||
.await?;
|
||||
|
||||
Ok((player_matchups, total))
|
||||
}
|
||||
|
||||
pub async fn find_by_id(&self, id: i32) -> Result<player_matchups::Model> {
|
||||
player_matchups::Entity::find_by_id(id)
|
||||
.one(&self.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("玩家对战 {} 不存在", id)))
|
||||
}
|
||||
|
||||
pub async fn create(&self, data: CreatePlayerMatchupRequest) -> Result<player_matchups::Model> {
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
let player_matchup = player_matchups::ActiveModel {
|
||||
player1_osu_id: Set(data.player1_osu_id),
|
||||
player1_username: Set(data.player1_username),
|
||||
player2_osu_id: Set(data.player2_osu_id),
|
||||
player2_username: Set(data.player2_username),
|
||||
status: Set(Some(Status::Available)),
|
||||
created_by: Set(data.created_by),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
Ok(player_matchup.insert(&self.db).await?)
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
&self,
|
||||
id: i32,
|
||||
data: UpdatePlayerMatchupRequest,
|
||||
) -> Result<player_matchups::Model> {
|
||||
let mut player_matchup: player_matchups::ActiveModel = self.find_by_id(id).await?.into();
|
||||
|
||||
if let Some(status) = data.status {
|
||||
let status_enum = match status.as_str() {
|
||||
"available" => Status::Available,
|
||||
"scheduled" => Status::Scheduled,
|
||||
"completed" => Status::Completed,
|
||||
_ => return Err(AppError::ValidationError("无效的状态".into())),
|
||||
};
|
||||
player_matchup.status = Set(Some(status_enum));
|
||||
}
|
||||
|
||||
player_matchup.updated_at = Set(chrono::Utc::now());
|
||||
|
||||
Ok(player_matchup.update(&self.db).await?)
|
||||
}
|
||||
|
||||
pub async fn delete(&self, id: i32) -> Result<()> {
|
||||
let result = player_matchups::Entity::delete_by_id(id)
|
||||
.exec(&self.db)
|
||||
.await?;
|
||||
if result.rows_affected == 0 {
|
||||
return Err(AppError::NotFound(format!("玩家对战 {} 不存在", id)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter,
|
||||
QueryOrder, Set,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::{
|
||||
dto::tournament_settings::{
|
||||
CreateTournamentSettingRequest, TournamentSettingQueryParams,
|
||||
UpdateTournamentSettingRequest,
|
||||
},
|
||||
entity::tournament_settings,
|
||||
error::{AppError, Result},
|
||||
service::ServiceState,
|
||||
};
|
||||
|
||||
pub struct TournamentSettingService {
|
||||
db: DatabaseConnection,
|
||||
current_settings_cache: Arc<RwLock<Option<tournament_settings::Model>>>,
|
||||
}
|
||||
|
||||
impl TournamentSettingService {
|
||||
pub fn new(state: ServiceState) -> Self {
|
||||
Self {
|
||||
db: state.db,
|
||||
current_settings_cache: state.tournament_settings_cache,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn find_all(
|
||||
&self,
|
||||
params: TournamentSettingQueryParams,
|
||||
) -> Result<(Vec<tournament_settings::Model>, u64)> {
|
||||
let mut query = tournament_settings::Entity::find();
|
||||
|
||||
if let Some(current_season) = params.current_season {
|
||||
query =
|
||||
query.filter(tournament_settings::Column::CurrentSeason.eq(Some(current_season)));
|
||||
}
|
||||
|
||||
query = query.order_by_desc(tournament_settings::Column::CreatedAt);
|
||||
|
||||
let page = params.page.unwrap_or(1);
|
||||
let page_size = params.page_size.unwrap_or(20);
|
||||
|
||||
let total = query.clone().count(&self.db).await?;
|
||||
let tournament_settings = query
|
||||
.paginate(&self.db, page_size)
|
||||
.fetch_page(page - 1)
|
||||
.await?;
|
||||
|
||||
Ok((tournament_settings, total))
|
||||
}
|
||||
|
||||
pub async fn find_by_id(&self, id: i32) -> Result<tournament_settings::Model> {
|
||||
tournament_settings::Entity::find_by_id(id)
|
||||
.one(&self.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("比赛设置 {} 不存在", id)))
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
&self,
|
||||
data: CreateTournamentSettingRequest,
|
||||
) -> Result<tournament_settings::Model> {
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
let tournament_setting = tournament_settings::ActiveModel {
|
||||
tournament_name: Set(data.tournament_name),
|
||||
max_pp_for_registration: Set(data.max_pp_for_registration),
|
||||
min_pp_for_registration: Set(data.min_pp_for_registration),
|
||||
current_season: Set(data.current_season),
|
||||
current_season_stage: Set(data.current_season_stage),
|
||||
mappool_visible: Set(data.mappool_visible),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let result = tournament_setting.insert(&self.db).await?;
|
||||
|
||||
// 更新缓存
|
||||
let mut cache = self.current_settings_cache.write().await;
|
||||
*cache = Some(result.clone());
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
&self,
|
||||
id: i32,
|
||||
data: UpdateTournamentSettingRequest,
|
||||
) -> Result<tournament_settings::Model> {
|
||||
let mut tournament_setting: tournament_settings::ActiveModel =
|
||||
self.find_by_id(id).await?.into();
|
||||
|
||||
if let Some(tournament_name) = data.tournament_name {
|
||||
tournament_setting.tournament_name = Set(tournament_name);
|
||||
}
|
||||
|
||||
if let Some(max_pp_for_registration) = data.max_pp_for_registration {
|
||||
tournament_setting.max_pp_for_registration = Set(Some(max_pp_for_registration));
|
||||
}
|
||||
|
||||
if let Some(min_pp_for_registration) = data.min_pp_for_registration {
|
||||
tournament_setting.min_pp_for_registration = Set(Some(min_pp_for_registration));
|
||||
}
|
||||
|
||||
if let Some(current_season) = data.current_season {
|
||||
tournament_setting.current_season = Set(Some(current_season));
|
||||
}
|
||||
|
||||
if let Some(current_season_stage) = data.current_season_stage {
|
||||
tournament_setting.current_season_stage = Set(Some(current_season_stage));
|
||||
}
|
||||
|
||||
if let Some(mappool_visible) = data.mappool_visible {
|
||||
tournament_setting.mappool_visible = Set(Some(mappool_visible));
|
||||
}
|
||||
|
||||
tournament_setting.updated_at = Set(chrono::Utc::now());
|
||||
|
||||
let result = tournament_setting.update(&self.db).await?;
|
||||
|
||||
// 更新缓存
|
||||
let mut cache = self.current_settings_cache.write().await;
|
||||
*cache = Some(result.clone());
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn delete(&self, id: i32) -> Result<()> {
|
||||
// 检查缓存中是否有这个项目
|
||||
{
|
||||
let cache = self.current_settings_cache.read().await;
|
||||
if let Some(cached_settings) = cache.as_ref() {
|
||||
if cached_settings.id == id {
|
||||
// 删除的是缓存的项目,清除缓存
|
||||
drop(cache); // 释放读锁
|
||||
let mut cache = self.current_settings_cache.write().await;
|
||||
*cache = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let result = tournament_settings::Entity::delete_by_id(id)
|
||||
.exec(&self.db)
|
||||
.await?;
|
||||
if result.rows_affected == 0 {
|
||||
return Err(AppError::NotFound(format!("比赛设置 {} 不存在", id)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_current_settings(&self) -> Result<Option<tournament_settings::Model>> {
|
||||
Ok(tournament_settings::Entity::find()
|
||||
.order_by_desc(tournament_settings::Column::CreatedAt)
|
||||
.one(&self.db)
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn get_current_settings_cached(&self) -> Result<Option<tournament_settings::Model>> {
|
||||
// 首先尝试从缓存读取
|
||||
{
|
||||
let cache = self.current_settings_cache.read().await;
|
||||
if let Some(settings) = cache.as_ref() {
|
||||
return Ok(Some(settings.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
// 缓存未命中,从数据库读取
|
||||
let settings = tournament_settings::Entity::find()
|
||||
.order_by_desc(tournament_settings::Column::CreatedAt)
|
||||
.one(&self.db)
|
||||
.await?;
|
||||
|
||||
// 更新缓存
|
||||
if let Some(ref settings) = settings {
|
||||
let mut cache = self.current_settings_cache.write().await;
|
||||
*cache = Some(settings.clone());
|
||||
}
|
||||
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
pub async fn invalidate_cache(&self) {
|
||||
let mut cache = self.current_settings_cache.write().await;
|
||||
*cache = None;
|
||||
}
|
||||
}
|
||||
@@ -1,298 +0,0 @@
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter,
|
||||
QueryOrder, Set,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
dto::user::{
|
||||
CreateUserRequest, UpdateApprovedRequest, UpdateRegistrationStatusRequest,
|
||||
UpdateSeasonRequest, UpdateUserGroupRequest, UpdateUserRequest, UserQueryParams,
|
||||
},
|
||||
entity::{
|
||||
sea_orm_active_enums::{RegistrationStatus, UserGroup},
|
||||
user,
|
||||
},
|
||||
error::{AppError, Result},
|
||||
};
|
||||
|
||||
pub struct UserService {
|
||||
db: DatabaseConnection,
|
||||
}
|
||||
|
||||
impl UserService {
|
||||
pub fn new(db: DatabaseConnection) -> Self {
|
||||
Self { db }
|
||||
}
|
||||
|
||||
pub async fn find_all(&self, params: UserQueryParams) -> Result<(Vec<user::Model>, u64)> {
|
||||
let mut query = user::Entity::find();
|
||||
|
||||
if let Some(username) = params.username {
|
||||
query = query.filter(user::Column::Username.contains(username));
|
||||
}
|
||||
|
||||
if let Some(country) = params.country {
|
||||
query = query.filter(user::Column::Country.eq(country));
|
||||
}
|
||||
|
||||
if let Some(user_group) = params.user_group {
|
||||
let group_enum = match user_group.as_str() {
|
||||
"player" => UserGroup::Player,
|
||||
"admin" => UserGroup::Admin,
|
||||
_ => return Err(AppError::ValidationError("无效的用户组".into())),
|
||||
};
|
||||
query = query.filter(user::Column::UserGroup.eq(group_enum));
|
||||
}
|
||||
|
||||
query = query.order_by_asc(user::Column::Id);
|
||||
|
||||
let page = params.page.unwrap_or(1);
|
||||
let page_size = params.page_size.unwrap_or(20);
|
||||
|
||||
let total = query.clone().count(&self.db).await?;
|
||||
let users = query
|
||||
.paginate(&self.db, page_size)
|
||||
.fetch_page(page - 1)
|
||||
.await?;
|
||||
|
||||
Ok((users, total))
|
||||
}
|
||||
|
||||
pub async fn find_by_id(&self, id: i32) -> Result<user::Model> {
|
||||
user::Entity::find_by_id(id)
|
||||
.one(&self.db)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("用户 {} 不存在", id)))
|
||||
}
|
||||
|
||||
pub async fn find_by_osu_id(&self, osu_id: &str) -> Result<Option<user::Model>> {
|
||||
Ok(user::Entity::find()
|
||||
.filter(user::Column::OsuId.eq(osu_id))
|
||||
.one(&self.db)
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn create(&self, osu_id: String, data: CreateUserRequest) -> Result<user::Model> {
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
let user = user::ActiveModel {
|
||||
osu_id: Set(osu_id),
|
||||
username: Set(data.username),
|
||||
registered_at: Set(now),
|
||||
avatar_url: Set(data.avatar_url),
|
||||
pp: Set(data.pp),
|
||||
global_rank: Set(data.global_rank),
|
||||
country: Set(data.country),
|
||||
country_rank: Set(data.country_rank),
|
||||
approved: Set(None),
|
||||
user_group: Set(UserGroup::Player),
|
||||
season: Set(None),
|
||||
registration_status: Set(Some(RegistrationStatus::Pending)),
|
||||
gu_server_user_id: Set(None),
|
||||
gu_server_username: Set(None),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
Ok(user.insert(&self.db).await?)
|
||||
}
|
||||
|
||||
pub async fn update(&self, id: i32, data: UpdateUserRequest) -> Result<user::Model> {
|
||||
let mut user: user::ActiveModel = self.find_by_id(id).await?.into();
|
||||
|
||||
if let Some(username) = data.username {
|
||||
user.username = Set(username);
|
||||
}
|
||||
|
||||
if let Some(avatar_url) = data.avatar_url {
|
||||
user.avatar_url = Set(Some(avatar_url));
|
||||
}
|
||||
|
||||
if let Some(pp) = data.pp {
|
||||
user.pp = Set(Some(pp));
|
||||
}
|
||||
|
||||
if let Some(global_rank) = data.global_rank {
|
||||
user.global_rank = Set(Some(global_rank));
|
||||
}
|
||||
|
||||
if let Some(country) = data.country {
|
||||
user.country = Set(Some(country));
|
||||
}
|
||||
|
||||
if let Some(country_rank) = data.country_rank {
|
||||
user.country_rank = Set(Some(country_rank));
|
||||
}
|
||||
|
||||
if let Some(approved) = data.approved {
|
||||
user.approved = Set(Some(approved));
|
||||
}
|
||||
|
||||
if let Some(season) = data.season {
|
||||
user.season = Set(Some(season));
|
||||
}
|
||||
|
||||
user.updated_at = Set(chrono::Utc::now());
|
||||
|
||||
Ok(user.update(&self.db).await?)
|
||||
}
|
||||
|
||||
pub async fn update_user_group(
|
||||
&self,
|
||||
id: i32,
|
||||
data: UpdateUserGroupRequest,
|
||||
) -> Result<user::Model> {
|
||||
let group_enum = match data.user_group.as_str() {
|
||||
"player" => UserGroup::Player,
|
||||
"admin" => UserGroup::Admin,
|
||||
_ => return Err(AppError::ValidationError("无效的用户组".into())),
|
||||
};
|
||||
|
||||
let mut user: user::ActiveModel = self.find_by_id(id).await?.into();
|
||||
user.user_group = Set(group_enum);
|
||||
user.updated_at = Set(chrono::Utc::now());
|
||||
|
||||
Ok(user.update(&self.db).await?)
|
||||
}
|
||||
|
||||
pub async fn delete(&self, id: i32) -> Result<()> {
|
||||
let result = user::Entity::delete_by_id(id).exec(&self.db).await?;
|
||||
if result.rows_affected == 0 {
|
||||
return Err(AppError::NotFound(format!("用户 {} 不存在", id)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn count_admins(&self) -> Result<u64> {
|
||||
Ok(user::Entity::find()
|
||||
.filter(user::Column::UserGroup.eq(UserGroup::Admin))
|
||||
.count(&self.db)
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn update_registration_status(
|
||||
&self,
|
||||
id: i32,
|
||||
data: UpdateRegistrationStatusRequest,
|
||||
) -> Result<user::Model> {
|
||||
let status_enum = match data.registration_status.as_str() {
|
||||
"pending" => RegistrationStatus::Pending,
|
||||
"confirmed" => RegistrationStatus::Confirmed,
|
||||
"rejected" => RegistrationStatus::Rejected,
|
||||
_ => return Err(AppError::ValidationError("无效的注册状态".into())),
|
||||
};
|
||||
|
||||
let mut user: user::ActiveModel = self.find_by_id(id).await?.into();
|
||||
user.registration_status = Set(Some(status_enum));
|
||||
user.updated_at = Set(chrono::Utc::now());
|
||||
|
||||
Ok(user.update(&self.db).await?)
|
||||
}
|
||||
|
||||
pub async fn update_season(&self, id: i32, data: UpdateSeasonRequest) -> Result<user::Model> {
|
||||
let mut user: user::ActiveModel = self.find_by_id(id).await?.into();
|
||||
user.season = Set(Some(data.season));
|
||||
user.updated_at = Set(chrono::Utc::now());
|
||||
|
||||
Ok(user.update(&self.db).await?)
|
||||
}
|
||||
|
||||
pub async fn update_approved(
|
||||
&self,
|
||||
id: i32,
|
||||
data: UpdateApprovedRequest,
|
||||
is_admin: bool,
|
||||
) -> Result<user::Model> {
|
||||
if !is_admin {
|
||||
return Err(AppError::Forbidden("只有管理员可以更新审批状态".into()));
|
||||
}
|
||||
|
||||
let mut user: user::ActiveModel = self.find_by_id(id).await?.into();
|
||||
user.approved = Set(Some(data.approved));
|
||||
user.updated_at = Set(chrono::Utc::now());
|
||||
|
||||
Ok(user.update(&self.db).await?)
|
||||
}
|
||||
|
||||
pub async fn update_season_from_settings(&self, id: i32, season: i32) -> Result<user::Model> {
|
||||
let mut user: user::ActiveModel = self.find_by_id(id).await?.into();
|
||||
user.season = Set(Some(season));
|
||||
user.updated_at = Set(chrono::Utc::now());
|
||||
|
||||
Ok(user.update(&self.db).await?)
|
||||
}
|
||||
|
||||
pub async fn update_gu_server_info(
|
||||
&self,
|
||||
id: i32,
|
||||
gu_user_id: i32,
|
||||
gu_username: String,
|
||||
) -> Result<user::Model> {
|
||||
let mut user: user::ActiveModel = self.find_by_id(id).await?.into();
|
||||
user.gu_server_user_id = Set(Some(gu_user_id));
|
||||
user.gu_server_username = Set(Some(gu_username));
|
||||
user.updated_at = Set(chrono::Utc::now());
|
||||
|
||||
Ok(user.update(&self.db).await?)
|
||||
}
|
||||
|
||||
pub async fn update_osu_refresh_token(
|
||||
&self,
|
||||
id: i32,
|
||||
refresh_token: String,
|
||||
) -> Result<user::Model> {
|
||||
let mut user: user::ActiveModel = self.find_by_id(id).await?.into();
|
||||
user.osu_refresh_token = Set(Some(refresh_token));
|
||||
user.updated_at = Set(chrono::Utc::now());
|
||||
|
||||
Ok(user.update(&self.db).await?)
|
||||
}
|
||||
|
||||
pub async fn update_gu_refresh_token(
|
||||
&self,
|
||||
id: i32,
|
||||
refresh_token: String,
|
||||
) -> Result<user::Model> {
|
||||
let mut user: user::ActiveModel = self.find_by_id(id).await?.into();
|
||||
user.gu_refresh_token = Set(Some(refresh_token));
|
||||
user.updated_at = Set(chrono::Utc::now());
|
||||
|
||||
Ok(user.update(&self.db).await?)
|
||||
}
|
||||
|
||||
pub async fn get_osu_refresh_token(&self, id: i32) -> Result<Option<String>> {
|
||||
let user = self.find_by_id(id).await?;
|
||||
Ok(user.osu_refresh_token)
|
||||
}
|
||||
|
||||
pub async fn get_gu_refresh_token(&self, id: i32) -> Result<Option<String>> {
|
||||
let user = self.find_by_id(id).await?;
|
||||
Ok(user.gu_refresh_token)
|
||||
}
|
||||
|
||||
pub async fn clear_osu_refresh_token(&self, id: i32) -> Result<user::Model> {
|
||||
let mut user: user::ActiveModel = self.find_by_id(id).await?.into();
|
||||
user.osu_refresh_token = Set(None);
|
||||
user.updated_at = Set(chrono::Utc::now());
|
||||
|
||||
Ok(user.update(&self.db).await?)
|
||||
}
|
||||
|
||||
pub async fn clear_gu_refresh_token(&self, id: i32) -> Result<user::Model> {
|
||||
let mut user: user::ActiveModel = self.find_by_id(id).await?.into();
|
||||
user.gu_refresh_token = Set(None);
|
||||
user.updated_at = Set(chrono::Utc::now());
|
||||
|
||||
Ok(user.update(&self.db).await?)
|
||||
}
|
||||
|
||||
pub async fn clear_all_refresh_tokens(&self, id: i32) -> Result<user::Model> {
|
||||
let mut user: user::ActiveModel = self.find_by_id(id).await?.into();
|
||||
user.osu_refresh_token = Set(None);
|
||||
user.gu_refresh_token = Set(None);
|
||||
user.updated_at = Set(chrono::Utc::now());
|
||||
|
||||
Ok(user.update(&self.db).await?)
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
use chrono;
|
||||
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::env;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Claims {
|
||||
pub sub: String,
|
||||
pub user_group: String,
|
||||
pub exp: usize,
|
||||
}
|
||||
|
||||
/// 生成JWT令牌
|
||||
pub fn generate_token(
|
||||
osu_id: String,
|
||||
user_group: String,
|
||||
) -> Result<String, jsonwebtoken::errors::Error> {
|
||||
let secret = env::var("JWT_SECRET").unwrap_or_else(|_| "secret_key".to_string());
|
||||
let expiration = chrono::Utc::now()
|
||||
.checked_add_signed(chrono::Duration::days(7))
|
||||
.expect("valid timestamp")
|
||||
.timestamp() as usize;
|
||||
|
||||
let claims = Claims {
|
||||
sub: osu_id,
|
||||
user_group,
|
||||
exp: expiration,
|
||||
};
|
||||
|
||||
let header = Header::new(Algorithm::HS256);
|
||||
encode(
|
||||
&header,
|
||||
&claims,
|
||||
&EncodingKey::from_secret(secret.as_bytes()),
|
||||
)
|
||||
}
|
||||
|
||||
/// 验证JWT令牌
|
||||
pub fn verify_token(token: &str) -> Result<Claims, jsonwebtoken::errors::Error> {
|
||||
let secret = env::var("JWT_SECRET").unwrap_or_else(|_| "secret_key".to_string());
|
||||
let validation = Validation::new(Algorithm::HS256);
|
||||
decode::<Claims>(
|
||||
token,
|
||||
&DecodingKey::from_secret(secret.as_bytes()),
|
||||
&validation,
|
||||
)
|
||||
.map(|data| data.claims)
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
pub mod jwt;
|
||||
pub mod password;
|
||||
pub mod validation;
|
||||
Reference in New Issue
Block a user