refactor(user): refactor user database
**Breaking Change** 用户表变为 lazer_users 建议删除与用户关联的表进行迁移
This commit is contained in:
423
README.md
423
README.md
@@ -1,218 +1,205 @@
|
||||
# osu! API 模拟服务器
|
||||
|
||||
这是一个使用 FastAPI + MySQL + Redis 实现的 osu! API 模拟服务器,提供了完整的用户认证和数据管理功能。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- **OAuth 2.0 认证**: 支持密码流和刷新令牌流
|
||||
- **用户数据管理**: 完整的用户信息、统计数据、成就等
|
||||
- **多游戏模式支持**: osu!, taiko, fruits, mania
|
||||
- **数据库持久化**: MySQL 存储用户数据
|
||||
- **缓存支持**: Redis 缓存令牌和会话信息
|
||||
- **容器化部署**: Docker 和 Docker Compose 支持
|
||||
|
||||
## API 端点
|
||||
|
||||
### 认证端点
|
||||
- `POST /oauth/token` - OAuth 令牌获取/刷新
|
||||
|
||||
### 用户端点
|
||||
- `GET /api/v2/me/{ruleset}` - 获取当前用户信息
|
||||
|
||||
### 其他端点
|
||||
- `GET /` - 根端点
|
||||
- `GET /health` - 健康检查
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 使用 Docker Compose (推荐)
|
||||
|
||||
1. 克隆项目
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd osu_lazer_api
|
||||
```
|
||||
|
||||
2. 启动服务
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
3. 创建示例数据
|
||||
```bash
|
||||
docker-compose exec api python create_sample_data.py
|
||||
```
|
||||
|
||||
4. 测试 API
|
||||
```bash
|
||||
# 获取访问令牌
|
||||
curl -X POST http://localhost:8000/oauth/token \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d "grant_type=password&username=Googujiang&password=password123&client_id=5&client_secret=FGc9GAtyHzeQDshWP5Ah7dega8hJACAJpQtw6OXk&scope=*"
|
||||
|
||||
# 使用令牌获取用户信息
|
||||
curl -X GET http://localhost:8000/api/v2/me/osu \
|
||||
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
|
||||
```
|
||||
|
||||
### 本地开发
|
||||
|
||||
1. 安装依赖
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
2. 配置环境变量
|
||||
```bash
|
||||
# 复制服务器配置文件
|
||||
cp .env .env.local
|
||||
|
||||
# 复制客户端配置文件(用于测试脚本)
|
||||
cp .env.client .env.client.local
|
||||
```
|
||||
|
||||
3. 启动 MySQL 和 Redis
|
||||
```bash
|
||||
# 使用 Docker
|
||||
docker run -d --name mysql -e MYSQL_ROOT_PASSWORD=password -e MYSQL_DATABASE=osu_api -p 3306:3306 mysql:8.0
|
||||
docker run -d --name redis -p 6379:6379 redis:7-alpine
|
||||
```
|
||||
|
||||
4. 创建示例数据
|
||||
```bash
|
||||
python create_sample_data.py
|
||||
```
|
||||
|
||||
5. 启动应用
|
||||
```bash
|
||||
uvicorn main:app --reload
|
||||
```
|
||||
|
||||
6. 测试 API
|
||||
```bash
|
||||
# 使用测试脚本(会自动加载 .env 文件)
|
||||
python test_api.py
|
||||
|
||||
# 或使用原始示例脚本
|
||||
python osu_api_example.py
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
osu_lazer_api/
|
||||
├── app/
|
||||
│ ├── __init__.py
|
||||
│ ├── models.py # Pydantic 数据模型
|
||||
│ ├── database.py # SQLAlchemy 数据库模型
|
||||
│ ├── config.py # 配置设置
|
||||
│ ├── dependencies.py # 依赖注入
|
||||
│ ├── auth.py # 认证和令牌管理
|
||||
│ └── utils.py # 工具函数
|
||||
├── main.py # FastAPI 应用主文件
|
||||
├── create_sample_data.py # 示例数据创建脚本
|
||||
├── requirements.txt # Python 依赖
|
||||
├── .env # 环境变量配置
|
||||
├── docker-compose.yml # Docker Compose 配置
|
||||
├── Dockerfile # Docker 镜像配置
|
||||
└── README.md # 项目说明
|
||||
```
|
||||
|
||||
## 示例用户
|
||||
|
||||
创建示例数据后,您可以使用以下凭据进行测试:
|
||||
|
||||
- **用户名**: `Googujiang`
|
||||
- **密码**: `password123`
|
||||
- **用户ID**: `15651670`
|
||||
|
||||
## 环境变量配置
|
||||
|
||||
项目包含两个环境配置文件:
|
||||
|
||||
### 服务器配置 (`.env`)
|
||||
用于配置 FastAPI 服务器的运行参数:
|
||||
|
||||
| 变量名 | 描述 | 默认值 |
|
||||
|--------|------|--------|
|
||||
| `DATABASE_URL` | MySQL 数据库连接字符串 | `mysql+pymysql://root:password@localhost:3306/osu_api` |
|
||||
| `REDIS_URL` | Redis 连接字符串 | `redis://localhost:6379/0` |
|
||||
| `SECRET_KEY` | JWT 签名密钥 | `your-secret-key-here` |
|
||||
| `ACCESS_TOKEN_EXPIRE_MINUTES` | 访问令牌过期时间(分钟) | `1440` |
|
||||
| `OSU_CLIENT_ID` | OAuth 客户端 ID | `5` |
|
||||
| `OSU_CLIENT_SECRET` | OAuth 客户端密钥 | `FGc9GAtyHzeQDshWP5Ah7dega8hJACAJpQtw6OXk` |
|
||||
| `HOST` | 服务器监听地址 | `0.0.0.0` |
|
||||
| `PORT` | 服务器监听端口 | `8000` |
|
||||
| `DEBUG` | 调试模式 | `True` |
|
||||
|
||||
### 客户端配置 (`.env.client`)
|
||||
用于配置客户端脚本的 API 连接参数:
|
||||
|
||||
| 变量名 | 描述 | 默认值 |
|
||||
|--------|------|--------|
|
||||
| `OSU_CLIENT_ID` | OAuth 客户端 ID | `5` |
|
||||
| `OSU_CLIENT_SECRET` | OAuth 客户端密钥 | `FGc9GAtyHzeQDshWP5Ah7dega8hJACAJpQtw6OXk` |
|
||||
| `OSU_API_URL` | API 服务器地址 | `http://localhost:8000` |
|
||||
|
||||
> **注意**: 在生产环境中,请务必更改默认的密钥和密码!
|
||||
|
||||
## API 使用示例
|
||||
|
||||
### 获取访问令牌
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/oauth/token \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d "grant_type=password&username=Googujiang&password=password123&client_id=5&client_secret=FGc9GAtyHzeQDshWP5Ah7dega8hJACAJpQtw6OXk&scope=*"
|
||||
```
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 86400,
|
||||
"refresh_token": "abc123...",
|
||||
"scope": "*"
|
||||
}
|
||||
```
|
||||
|
||||
### 获取用户信息
|
||||
|
||||
```bash
|
||||
curl -X GET http://localhost:8000/api/v2/me/osu \
|
||||
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
|
||||
```
|
||||
|
||||
### 刷新令牌
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/oauth/token \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d "grant_type=refresh_token&refresh_token=YOUR_REFRESH_TOKEN&client_id=5&client_secret=FGc9GAtyHzeQDshWP5Ah7dega8hJACAJpQtw6OXk"
|
||||
```
|
||||
|
||||
## 开发
|
||||
|
||||
### 添加新用户
|
||||
|
||||
您可以通过修改 `create_sample_data.py` 文件来添加更多示例用户,或者扩展 API 来支持用户注册功能。
|
||||
|
||||
### 扩展功能
|
||||
|
||||
- 添加更多 API 端点(排行榜、谱面信息等)
|
||||
- 实现实时功能(WebSocket)
|
||||
- 添加管理面板
|
||||
- 实现数据导入/导出功能
|
||||
|
||||
### 迁移数据库
|
||||
|
||||
参考[数据库迁移指南](./MIGRATE_GUIDE.md)
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT License
|
||||
|
||||
## 贡献
|
||||
|
||||
欢迎提交 Issue 和 Pull Request!
|
||||
# osu! API 模拟服务器
|
||||
|
||||
这是一个使用 FastAPI + MySQL + Redis 实现的 osu! API 模拟服务器,提供了完整的用户认证和数据管理功能。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- **OAuth 2.0 认证**: 支持密码流和刷新令牌流
|
||||
- **用户数据管理**: 完整的用户信息、统计数据、成就等
|
||||
- **多游戏模式支持**: osu!, taiko, fruits, mania
|
||||
- **数据库持久化**: MySQL 存储用户数据
|
||||
- **缓存支持**: Redis 缓存令牌和会话信息
|
||||
- **容器化部署**: Docker 和 Docker Compose 支持
|
||||
|
||||
## API 端点
|
||||
|
||||
### 认证端点
|
||||
- `POST /oauth/token` - OAuth 令牌获取/刷新
|
||||
|
||||
### 用户端点
|
||||
- `GET /api/v2/me/{ruleset}` - 获取当前用户信息
|
||||
|
||||
### 其他端点
|
||||
- `GET /` - 根端点
|
||||
- `GET /health` - 健康检查
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 使用 Docker Compose (推荐)
|
||||
|
||||
1. 克隆项目
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd osu_lazer_api
|
||||
```
|
||||
|
||||
2. 启动服务
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
3. 创建示例数据
|
||||
```bash
|
||||
docker-compose exec api python create_sample_data.py
|
||||
```
|
||||
|
||||
4. 测试 API
|
||||
```bash
|
||||
# 获取访问令牌
|
||||
curl -X POST http://localhost:8000/oauth/token \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d "grant_type=password&username=Googujiang&password=password123&client_id=5&client_secret=FGc9GAtyHzeQDshWP5Ah7dega8hJACAJpQtw6OXk&scope=*"
|
||||
|
||||
# 使用令牌获取用户信息
|
||||
curl -X GET http://localhost:8000/api/v2/me/osu \
|
||||
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
|
||||
```
|
||||
|
||||
### 本地开发
|
||||
|
||||
1. 安装依赖
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
2. 配置环境变量
|
||||
```bash
|
||||
# 复制服务器配置文件
|
||||
cp .env .env.local
|
||||
|
||||
# 复制客户端配置文件(用于测试脚本)
|
||||
cp .env.client .env.client.local
|
||||
```
|
||||
|
||||
3. 启动 MySQL 和 Redis
|
||||
```bash
|
||||
# 使用 Docker
|
||||
docker run -d --name mysql -e MYSQL_ROOT_PASSWORD=password -e MYSQL_DATABASE=osu_api -p 3306:3306 mysql:8.0
|
||||
docker run -d --name redis -p 6379:6379 redis:7-alpine
|
||||
```
|
||||
|
||||
|
||||
4. 启动应用
|
||||
```bash
|
||||
uvicorn main:app --reload
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
osu_lazer_api/
|
||||
├── app/
|
||||
│ ├── __init__.py
|
||||
│ ├── models.py # Pydantic 数据模型
|
||||
│ ├── database.py # SQLAlchemy 数据库模型
|
||||
│ ├── config.py # 配置设置
|
||||
│ ├── dependencies.py # 依赖注入
|
||||
│ ├── auth.py # 认证和令牌管理
|
||||
│ └── utils.py # 工具函数
|
||||
├── main.py # FastAPI 应用主文件
|
||||
├── create_sample_data.py # 示例数据创建脚本
|
||||
├── requirements.txt # Python 依赖
|
||||
├── .env # 环境变量配置
|
||||
├── docker-compose.yml # Docker Compose 配置
|
||||
├── Dockerfile # Docker 镜像配置
|
||||
└── README.md # 项目说明
|
||||
```
|
||||
|
||||
## 示例用户
|
||||
|
||||
创建示例数据后,您可以使用以下凭据进行测试:
|
||||
|
||||
- **用户名**: `Googujiang`
|
||||
- **密码**: `password123`
|
||||
- **用户ID**: `15651670`
|
||||
|
||||
## 环境变量配置
|
||||
|
||||
项目包含两个环境配置文件:
|
||||
|
||||
### 服务器配置 (`.env`)
|
||||
用于配置 FastAPI 服务器的运行参数:
|
||||
|
||||
| 变量名 | 描述 | 默认值 |
|
||||
|--------|------|--------|
|
||||
| `DATABASE_URL` | MySQL 数据库连接字符串 | `mysql+pymysql://root:password@localhost:3306/osu_api` |
|
||||
| `REDIS_URL` | Redis 连接字符串 | `redis://localhost:6379/0` |
|
||||
| `SECRET_KEY` | JWT 签名密钥 | `your-secret-key-here` |
|
||||
| `ACCESS_TOKEN_EXPIRE_MINUTES` | 访问令牌过期时间(分钟) | `1440` |
|
||||
| `OSU_CLIENT_ID` | OAuth 客户端 ID | `5` |
|
||||
| `OSU_CLIENT_SECRET` | OAuth 客户端密钥 | `FGc9GAtyHzeQDshWP5Ah7dega8hJACAJpQtw6OXk` |
|
||||
| `HOST` | 服务器监听地址 | `0.0.0.0` |
|
||||
| `PORT` | 服务器监听端口 | `8000` |
|
||||
| `DEBUG` | 调试模式 | `True` |
|
||||
|
||||
### 客户端配置 (`.env.client`)
|
||||
用于配置客户端脚本的 API 连接参数:
|
||||
|
||||
| 变量名 | 描述 | 默认值 |
|
||||
|--------|------|--------|
|
||||
| `OSU_CLIENT_ID` | OAuth 客户端 ID | `5` |
|
||||
| `OSU_CLIENT_SECRET` | OAuth 客户端密钥 | `FGc9GAtyHzeQDshWP5Ah7dega8hJACAJpQtw6OXk` |
|
||||
| `OSU_API_URL` | API 服务器地址 | `http://localhost:8000` |
|
||||
|
||||
> **注意**: 在生产环境中,请务必更改默认的密钥和密码!
|
||||
|
||||
## API 使用示例
|
||||
|
||||
### 获取访问令牌
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/oauth/token \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d "grant_type=password&username=Googujiang&password=password123&client_id=5&client_secret=FGc9GAtyHzeQDshWP5Ah7dega8hJACAJpQtw6OXk&scope=*"
|
||||
```
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 86400,
|
||||
"refresh_token": "abc123...",
|
||||
"scope": "*"
|
||||
}
|
||||
```
|
||||
|
||||
### 获取用户信息
|
||||
|
||||
```bash
|
||||
curl -X GET http://localhost:8000/api/v2/me/osu \
|
||||
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
|
||||
```
|
||||
|
||||
### 刷新令牌
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/oauth/token \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d "grant_type=refresh_token&refresh_token=YOUR_REFRESH_TOKEN&client_id=5&client_secret=FGc9GAtyHzeQDshWP5Ah7dega8hJACAJpQtw6OXk"
|
||||
```
|
||||
|
||||
## 开发
|
||||
|
||||
### 添加新用户
|
||||
|
||||
您可以通过修改 `create_sample_data.py` 文件来添加更多示例用户,或者扩展 API 来支持用户注册功能。
|
||||
|
||||
### 扩展功能
|
||||
|
||||
- 添加更多 API 端点(排行榜、谱面信息等)
|
||||
- 实现实时功能(WebSocket)
|
||||
- 添加管理面板
|
||||
- 实现数据导入/导出功能
|
||||
|
||||
### 迁移数据库
|
||||
|
||||
参考[数据库迁移指南](./MIGRATE_GUIDE.md)
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT License
|
||||
|
||||
## 贡献
|
||||
|
||||
欢迎提交 Issue 和 Pull Request!
|
||||
|
||||
@@ -8,7 +8,7 @@ import string
|
||||
from app.config import settings
|
||||
from app.database import (
|
||||
OAuthToken,
|
||||
User as DBUser,
|
||||
User,
|
||||
)
|
||||
from app.log import logger
|
||||
|
||||
@@ -74,7 +74,7 @@ def get_password_hash(password: str) -> str:
|
||||
|
||||
async def authenticate_user_legacy(
|
||||
db: AsyncSession, name: str, password: str
|
||||
) -> DBUser | None:
|
||||
) -> User | None:
|
||||
"""
|
||||
验证用户身份 - 使用类似 from_login 的逻辑
|
||||
"""
|
||||
@@ -82,7 +82,7 @@ async def authenticate_user_legacy(
|
||||
pw_md5 = hashlib.md5(password.encode()).hexdigest()
|
||||
|
||||
# 2. 根据用户名查找用户
|
||||
statement = select(DBUser).where(DBUser.name == name)
|
||||
statement = select(User).where(User.username == name)
|
||||
user = (await db.exec(statement)).first()
|
||||
if not user:
|
||||
return None
|
||||
@@ -113,7 +113,7 @@ async def authenticate_user_legacy(
|
||||
|
||||
async def authenticate_user(
|
||||
db: AsyncSession, username: str, password: str
|
||||
) -> DBUser | None:
|
||||
) -> User | None:
|
||||
"""验证用户身份"""
|
||||
return await authenticate_user_legacy(db, username, password)
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from .achievement import UserAchievement, UserAchievementResp
|
||||
from .auth import OAuthToken
|
||||
from .beatmap import (
|
||||
Beatmap as Beatmap,
|
||||
@@ -8,7 +9,11 @@ from .beatmapset import (
|
||||
BeatmapsetResp as BeatmapsetResp,
|
||||
)
|
||||
from .best_score import BestScore
|
||||
from .legacy import LegacyOAuthToken, LegacyUserStatistics
|
||||
from .daily_challenge import DailyChallengeStats, DailyChallengeStatsResp
|
||||
from .lazer_user import (
|
||||
User,
|
||||
UserResp,
|
||||
)
|
||||
from .relationship import Relationship, RelationshipResp, RelationshipType
|
||||
from .score import (
|
||||
Score,
|
||||
@@ -17,29 +22,17 @@ from .score import (
|
||||
ScoreStatistics,
|
||||
)
|
||||
from .score_token import ScoreToken, ScoreTokenResp
|
||||
from .statistics import (
|
||||
UserStatistics,
|
||||
UserStatisticsResp,
|
||||
)
|
||||
from .team import Team, TeamMember
|
||||
from .user import (
|
||||
DailyChallengeStats,
|
||||
LazerUserAchievement,
|
||||
LazerUserBadge,
|
||||
LazerUserBanners,
|
||||
LazerUserCountry,
|
||||
LazerUserCounts,
|
||||
LazerUserKudosu,
|
||||
LazerUserMonthlyPlaycounts,
|
||||
LazerUserPreviousUsername,
|
||||
LazerUserProfile,
|
||||
LazerUserProfileSections,
|
||||
LazerUserReplaysWatched,
|
||||
LazerUserStatistics,
|
||||
RankHistory,
|
||||
User,
|
||||
UserAchievement,
|
||||
UserAvatar,
|
||||
from .user_account_history import (
|
||||
UserAccountHistory,
|
||||
UserAccountHistoryResp,
|
||||
UserAccountHistoryType,
|
||||
)
|
||||
|
||||
BeatmapsetResp.model_rebuild()
|
||||
BeatmapResp.model_rebuild()
|
||||
__all__ = [
|
||||
"Beatmap",
|
||||
"BeatmapResp",
|
||||
@@ -47,22 +40,8 @@ __all__ = [
|
||||
"BeatmapsetResp",
|
||||
"BestScore",
|
||||
"DailyChallengeStats",
|
||||
"LazerUserAchievement",
|
||||
"LazerUserBadge",
|
||||
"LazerUserBanners",
|
||||
"LazerUserCountry",
|
||||
"LazerUserCounts",
|
||||
"LazerUserKudosu",
|
||||
"LazerUserMonthlyPlaycounts",
|
||||
"LazerUserPreviousUsername",
|
||||
"LazerUserProfile",
|
||||
"LazerUserProfileSections",
|
||||
"LazerUserReplaysWatched",
|
||||
"LazerUserStatistics",
|
||||
"LegacyOAuthToken",
|
||||
"LegacyUserStatistics",
|
||||
"DailyChallengeStatsResp",
|
||||
"OAuthToken",
|
||||
"RankHistory",
|
||||
"Relationship",
|
||||
"RelationshipResp",
|
||||
"RelationshipType",
|
||||
@@ -75,6 +54,17 @@ __all__ = [
|
||||
"Team",
|
||||
"TeamMember",
|
||||
"User",
|
||||
"UserAccountHistory",
|
||||
"UserAccountHistoryResp",
|
||||
"UserAccountHistoryType",
|
||||
"UserAchievement",
|
||||
"UserAvatar",
|
||||
"UserAchievement",
|
||||
"UserAchievementResp",
|
||||
"UserResp",
|
||||
"UserStatistics",
|
||||
"UserStatisticsResp",
|
||||
]
|
||||
|
||||
for i in __all__:
|
||||
if i.endswith("Resp"):
|
||||
globals()[i].model_rebuild() # type: ignore[call-arg]
|
||||
|
||||
40
app/database/achievement.py
Normal file
40
app/database/achievement.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from app.models.model import UTCBaseModel
|
||||
|
||||
from sqlmodel import (
|
||||
BigInteger,
|
||||
Column,
|
||||
DateTime,
|
||||
Field,
|
||||
ForeignKey,
|
||||
Relationship,
|
||||
SQLModel,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .lazer_user import User
|
||||
|
||||
|
||||
class UserAchievementBase(SQLModel, UTCBaseModel):
|
||||
achievement_id: int = Field(primary_key=True)
|
||||
achieved_at: datetime = Field(
|
||||
default=datetime.now(UTC), sa_column=Column(DateTime(timezone=True))
|
||||
)
|
||||
|
||||
|
||||
class UserAchievement(UserAchievementBase, table=True):
|
||||
__tablename__ = "lazer_user_achievements" # pyright: ignore[reportAssignmentType]
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True, index=True)
|
||||
user_id: int = Field(
|
||||
sa_column=Column(BigInteger, ForeignKey("lazer_users.id")), exclude=True
|
||||
)
|
||||
user: "User" = Relationship(back_populates="achievement")
|
||||
|
||||
|
||||
class UserAchievementResp(UserAchievementBase):
|
||||
@classmethod
|
||||
def from_db(cls, db_model: UserAchievement) -> "UserAchievementResp":
|
||||
return cls.model_validate(db_model)
|
||||
@@ -1,19 +1,21 @@
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from app.models.model import UTCBaseModel
|
||||
|
||||
from sqlalchemy import Column, DateTime
|
||||
from sqlmodel import BigInteger, Field, ForeignKey, Relationship, SQLModel
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .user import User
|
||||
from .lazer_user import User
|
||||
|
||||
|
||||
class OAuthToken(SQLModel, table=True):
|
||||
class OAuthToken(UTCBaseModel, SQLModel, table=True):
|
||||
__tablename__ = "oauth_tokens" # pyright: ignore[reportAssignmentType]
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True, index=True)
|
||||
user_id: int = Field(
|
||||
sa_column=Column(BigInteger, ForeignKey("users.id"), index=True)
|
||||
sa_column=Column(BigInteger, ForeignKey("lazer_users.id"), index=True)
|
||||
)
|
||||
access_token: str = Field(max_length=500, unique=True)
|
||||
refresh_token: str = Field(max_length=500, unique=True)
|
||||
|
||||
@@ -2,6 +2,7 @@ from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from app.models.beatmap import BeatmapRankStatus
|
||||
from app.models.model import UTCBaseModel
|
||||
from app.models.score import MODE_TO_INT, GameMode
|
||||
|
||||
from .beatmapset import Beatmapset, BeatmapsetResp
|
||||
@@ -20,7 +21,7 @@ class BeatmapOwner(SQLModel):
|
||||
username: str
|
||||
|
||||
|
||||
class BeatmapBase(SQLModel):
|
||||
class BeatmapBase(SQLModel, UTCBaseModel):
|
||||
# Beatmap
|
||||
url: str
|
||||
mode: GameMode
|
||||
|
||||
@@ -2,6 +2,7 @@ from datetime import datetime
|
||||
from typing import TYPE_CHECKING, TypedDict, cast
|
||||
|
||||
from app.models.beatmap import BeatmapRankStatus, Genre, Language
|
||||
from app.models.model import UTCBaseModel
|
||||
from app.models.score import GameMode
|
||||
|
||||
from pydantic import BaseModel, model_serializer
|
||||
@@ -82,7 +83,7 @@ class BeatmapTranslationText(BaseModel):
|
||||
id: int | None = None
|
||||
|
||||
|
||||
class BeatmapsetBase(SQLModel):
|
||||
class BeatmapsetBase(SQLModel, UTCBaseModel):
|
||||
# Beatmapset
|
||||
artist: str = Field(index=True)
|
||||
artist_unicode: str = Field(index=True)
|
||||
|
||||
@@ -2,7 +2,7 @@ from typing import TYPE_CHECKING
|
||||
|
||||
from app.models.score import GameMode
|
||||
|
||||
from .user import User
|
||||
from .lazer_user import User
|
||||
|
||||
from sqlmodel import (
|
||||
BigInteger,
|
||||
@@ -22,7 +22,7 @@ if TYPE_CHECKING:
|
||||
class BestScore(SQLModel, table=True):
|
||||
__tablename__ = "best_scores" # pyright: ignore[reportAssignmentType]
|
||||
user_id: int = Field(
|
||||
sa_column=Column(BigInteger, ForeignKey("users.id"), index=True)
|
||||
sa_column=Column(BigInteger, ForeignKey("lazer_users.id"), index=True)
|
||||
)
|
||||
score_id: int = Field(
|
||||
sa_column=Column(BigInteger, ForeignKey("scores.id"), primary_key=True)
|
||||
|
||||
58
app/database/daily_challenge.py
Normal file
58
app/database/daily_challenge.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from app.models.model import UTCBaseModel
|
||||
|
||||
from sqlmodel import (
|
||||
BigInteger,
|
||||
Column,
|
||||
DateTime,
|
||||
Field,
|
||||
ForeignKey,
|
||||
Relationship,
|
||||
SQLModel,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .lazer_user import User
|
||||
|
||||
|
||||
class DailyChallengeStatsBase(SQLModel, UTCBaseModel):
|
||||
daily_streak_best: int = Field(default=0)
|
||||
daily_streak_current: int = Field(default=0)
|
||||
last_update: datetime | None = Field(default=None, sa_column=Column(DateTime))
|
||||
last_weekly_streak: datetime | None = Field(
|
||||
default=None, sa_column=Column(DateTime)
|
||||
)
|
||||
playcount: int = Field(default=0)
|
||||
top_10p_placements: int = Field(default=0)
|
||||
top_50p_placements: int = Field(default=0)
|
||||
weekly_streak_best: int = Field(default=0)
|
||||
weekly_streak_current: int = Field(default=0)
|
||||
|
||||
|
||||
class DailyChallengeStats(DailyChallengeStatsBase, table=True):
|
||||
__tablename__ = "daily_challenge_stats" # pyright: ignore[reportAssignmentType]
|
||||
|
||||
user_id: int | None = Field(
|
||||
default=None,
|
||||
sa_column=Column(
|
||||
BigInteger,
|
||||
ForeignKey("lazer_users.id"),
|
||||
unique=True,
|
||||
index=True,
|
||||
primary_key=True,
|
||||
),
|
||||
)
|
||||
user: "User" = Relationship(back_populates="daily_challenge_stats")
|
||||
|
||||
|
||||
class DailyChallengeStatsResp(DailyChallengeStatsBase):
|
||||
user_id: int
|
||||
|
||||
@classmethod
|
||||
def from_db(
|
||||
cls,
|
||||
obj: DailyChallengeStats,
|
||||
) -> "DailyChallengeStatsResp":
|
||||
return cls.model_validate(obj)
|
||||
300
app/database/lazer_user.py
Normal file
300
app/database/lazer_user.py
Normal file
@@ -0,0 +1,300 @@
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, NotRequired, TypedDict
|
||||
|
||||
from app.models.model import UTCBaseModel
|
||||
from app.models.score import GameMode
|
||||
from app.models.user import Country, Page, RankHistory
|
||||
|
||||
from .achievement import UserAchievement, UserAchievementResp
|
||||
from .daily_challenge import DailyChallengeStats, DailyChallengeStatsResp
|
||||
from .statistics import UserStatistics, UserStatisticsResp
|
||||
from .team import Team, TeamMember
|
||||
from .user_account_history import UserAccountHistory, UserAccountHistoryResp
|
||||
|
||||
from sqlalchemy.orm import joinedload, selectinload
|
||||
from sqlmodel import (
|
||||
JSON,
|
||||
BigInteger,
|
||||
Column,
|
||||
DateTime,
|
||||
Field,
|
||||
Relationship,
|
||||
SQLModel,
|
||||
func,
|
||||
select,
|
||||
)
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.database.relationship import RelationshipResp
|
||||
|
||||
|
||||
class Kudosu(TypedDict):
|
||||
available: int
|
||||
total: int
|
||||
|
||||
|
||||
class RankHighest(TypedDict):
|
||||
rank: int
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class UserProfileCover(TypedDict):
|
||||
url: str
|
||||
custom_url: NotRequired[str]
|
||||
id: NotRequired[str]
|
||||
|
||||
|
||||
Badge = TypedDict(
|
||||
"Badge",
|
||||
{
|
||||
"awarded_at": datetime,
|
||||
"description": str,
|
||||
"image@2x_url": str,
|
||||
"image_url": str,
|
||||
"url": str,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class UserBase(UTCBaseModel, SQLModel):
|
||||
avatar_url: str = ""
|
||||
country_code: str = Field(default="CN", max_length=2, index=True)
|
||||
# ? default_group: str|None
|
||||
is_active: bool = True
|
||||
is_bot: bool = False
|
||||
is_supporter: bool = False
|
||||
last_visit: datetime = Field(
|
||||
default=datetime.now(UTC), sa_column=Column(DateTime(timezone=True))
|
||||
)
|
||||
pm_friends_only: bool = False
|
||||
profile_colour: str | None = None
|
||||
username: str = Field(max_length=32, unique=True, index=True)
|
||||
page: Page = Field(sa_column=Column(JSON), default=Page(html="", raw=""))
|
||||
previous_usernames: list[str] = Field(default_factory=list, sa_column=Column(JSON))
|
||||
# TODO: replays_watched_counts
|
||||
support_level: int = 0
|
||||
badges: list[Badge] = Field(default_factory=list, sa_column=Column(JSON))
|
||||
|
||||
# optional
|
||||
is_restricted: bool = False
|
||||
# blocks
|
||||
cover: UserProfileCover = Field(
|
||||
default=UserProfileCover(
|
||||
url="https://assets.ppy.sh/user-profile-covers/default.jpeg"
|
||||
),
|
||||
sa_column=Column(JSON),
|
||||
)
|
||||
beatmap_playcounts_count: int = 0
|
||||
# kudosu
|
||||
|
||||
# UserExtended
|
||||
playmode: GameMode = GameMode.OSU
|
||||
discord: str | None = None
|
||||
has_supported: bool = False
|
||||
interests: str | None = None
|
||||
join_date: datetime = Field(default=datetime.now(UTC))
|
||||
location: str | None = None
|
||||
max_blocks: int = 50
|
||||
max_friends: int = 500
|
||||
occupation: str | None = None
|
||||
playstyle: list[str] = Field(default_factory=list, sa_column=Column(JSON))
|
||||
# TODO: post_count
|
||||
profile_hue: int | None = None
|
||||
profile_order: list[str] = Field(
|
||||
default_factory=lambda: [
|
||||
"me",
|
||||
"recent_activity",
|
||||
"top_ranks",
|
||||
"medals",
|
||||
"historical",
|
||||
"beatmaps",
|
||||
"kudosu",
|
||||
],
|
||||
sa_column=Column(JSON),
|
||||
)
|
||||
title: str | None = None
|
||||
title_url: str | None = None
|
||||
twitter: str | None = None
|
||||
website: str | None = None
|
||||
|
||||
# undocumented
|
||||
comments_count: int = 0
|
||||
post_count: int = 0
|
||||
is_admin: bool = False
|
||||
is_gmt: bool = False
|
||||
is_qat: bool = False
|
||||
is_bng: bool = False
|
||||
|
||||
|
||||
class User(UserBase, table=True):
|
||||
__tablename__ = "lazer_users" # pyright: ignore[reportAssignmentType]
|
||||
|
||||
id: int | None = Field(
|
||||
default=None,
|
||||
sa_column=Column(BigInteger, primary_key=True, autoincrement=True, index=True),
|
||||
)
|
||||
account_history: list[UserAccountHistory] = Relationship()
|
||||
statistics: list[UserStatistics] = Relationship()
|
||||
achievement: list[UserAchievement] = Relationship(back_populates="user")
|
||||
team_membership: TeamMember | None = Relationship(back_populates="user")
|
||||
daily_challenge_stats: DailyChallengeStats | None = Relationship(
|
||||
back_populates="user"
|
||||
)
|
||||
|
||||
email: str = Field(max_length=254, unique=True, index=True, exclude=True)
|
||||
priv: int = Field(default=1, exclude=True)
|
||||
pw_bcrypt: str = Field(max_length=60, exclude=True)
|
||||
silence_end_at: datetime | None = Field(
|
||||
default=None, sa_column=Column(DateTime(timezone=True)), exclude=True
|
||||
)
|
||||
donor_end_at: datetime | None = Field(
|
||||
default=None, sa_column=Column(DateTime(timezone=True)), exclude=True
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def all_select_option(cls):
|
||||
return (
|
||||
selectinload(cls.account_history), # pyright: ignore[reportArgumentType]
|
||||
selectinload(cls.statistics), # pyright: ignore[reportArgumentType]
|
||||
selectinload(cls.achievement), # pyright: ignore[reportArgumentType]
|
||||
joinedload(cls.team_membership).joinedload(TeamMember.team), # pyright: ignore[reportArgumentType]
|
||||
joinedload(cls.daily_challenge_stats), # pyright: ignore[reportArgumentType]
|
||||
)
|
||||
|
||||
|
||||
class UserResp(UserBase):
|
||||
id: int | None = None
|
||||
is_online: bool = True # TODO
|
||||
groups: list = [] # TODO
|
||||
country: Country = Field(default_factory=lambda: Country(code="CN", name="China"))
|
||||
favourite_beatmapset_count: int = 0 # TODO
|
||||
graveyard_beatmapset_count: int = 0 # TODO
|
||||
guest_beatmapset_count: int = 0 # TODO
|
||||
loved_beatmapset_count: int = 0 # TODO
|
||||
mapping_follower_count: int = 0 # TODO
|
||||
nominated_beatmapset_count: int = 0 # TODO
|
||||
pending_beatmapset_count: int = 0 # TODO
|
||||
ranked_beatmapset_count: int = 0 # TODO
|
||||
follow_user_mapping: list[int] = Field(default_factory=list)
|
||||
follower_count: int = 0
|
||||
friends: list["RelationshipResp"] | None = None
|
||||
scores_best_count: int = 0
|
||||
scores_first_count: int = 0
|
||||
scores_recent_count: int = 0
|
||||
scores_pinned_count: int = 0
|
||||
account_history: list[UserAccountHistoryResp] = []
|
||||
active_tournament_banners: list[dict] = [] # TODO
|
||||
kudosu: Kudosu = Field(default_factory=lambda: Kudosu(available=0, total=0)) # TODO
|
||||
monthly_playcounts: list = Field(default_factory=list) # TODO
|
||||
unread_pm_count: int = 0 # TODO
|
||||
rank_history: RankHistory | None = None # TODO
|
||||
rank_highest: RankHighest | None = None # TODO
|
||||
statistics: UserStatisticsResp | None = None
|
||||
statistics_rulesets: dict[str, UserStatisticsResp] | None = None
|
||||
user_achievements: list[UserAchievementResp] = Field(default_factory=list)
|
||||
cover_url: str = "" # deprecated
|
||||
team: Team | None = None
|
||||
session_verified: bool = True
|
||||
daily_challenge_user_stats: DailyChallengeStatsResp | None = None # TODO
|
||||
|
||||
# TODO: monthly_playcounts, unread_pm_count, rank_history, user_preferences
|
||||
|
||||
@classmethod
|
||||
async def from_db(
|
||||
cls,
|
||||
obj: User,
|
||||
session: AsyncSession,
|
||||
include: list[str] = [],
|
||||
ruleset: GameMode | None = None,
|
||||
) -> "UserResp":
|
||||
from .best_score import BestScore
|
||||
from .relationship import Relationship, RelationshipResp, RelationshipType
|
||||
|
||||
u = cls.model_validate(obj.model_dump())
|
||||
u.id = obj.id
|
||||
u.follower_count = (
|
||||
await session.exec(
|
||||
select(func.count())
|
||||
.select_from(Relationship)
|
||||
.where(
|
||||
Relationship.target_id == obj.id,
|
||||
Relationship.type == RelationshipType.FOLLOW,
|
||||
)
|
||||
)
|
||||
).one()
|
||||
u.scores_best_count = (
|
||||
await session.exec(
|
||||
select(func.count())
|
||||
.select_from(BestScore)
|
||||
.where(
|
||||
BestScore.user_id == obj.id,
|
||||
)
|
||||
.limit(200)
|
||||
)
|
||||
).one()
|
||||
u.cover_url = (
|
||||
obj.cover.get(
|
||||
"url", "https://assets.ppy.sh/user-profile-covers/default.jpeg"
|
||||
)
|
||||
if obj.cover
|
||||
else "https://assets.ppy.sh/user-profile-covers/default.jpeg"
|
||||
)
|
||||
|
||||
if "friends" in include:
|
||||
u.friends = [
|
||||
await RelationshipResp.from_db(session, r)
|
||||
for r in (
|
||||
await session.exec(
|
||||
select(Relationship)
|
||||
.options(
|
||||
joinedload(Relationship.target).options( # pyright: ignore[reportArgumentType]
|
||||
*User.all_select_option()
|
||||
)
|
||||
)
|
||||
.where(
|
||||
Relationship.user_id == obj.id,
|
||||
Relationship.type == RelationshipType.FOLLOW,
|
||||
)
|
||||
)
|
||||
).all()
|
||||
]
|
||||
|
||||
if "team" in include:
|
||||
if obj.team_membership:
|
||||
u.team = obj.team_membership.team
|
||||
|
||||
if "account_history" in include:
|
||||
u.account_history = [
|
||||
UserAccountHistoryResp.from_db(ah) for ah in obj.account_history
|
||||
]
|
||||
|
||||
if "daily_challenge_user_stats":
|
||||
if obj.daily_challenge_stats:
|
||||
u.daily_challenge_user_stats = DailyChallengeStatsResp.from_db(
|
||||
obj.daily_challenge_stats
|
||||
)
|
||||
|
||||
if "statistics" in include:
|
||||
current_stattistics = None
|
||||
for i in obj.statistics:
|
||||
if i.mode == (ruleset or obj.playmode):
|
||||
current_stattistics = i
|
||||
break
|
||||
u.statistics = (
|
||||
UserStatisticsResp.from_db(current_stattistics)
|
||||
if current_stattistics
|
||||
else None
|
||||
)
|
||||
|
||||
if "statistics_rulesets" in include:
|
||||
u.statistics_rulesets = {
|
||||
i.mode.value: UserStatisticsResp.from_db(i) for i in obj.statistics
|
||||
}
|
||||
|
||||
if "achievements" in include:
|
||||
u.user_achievements = [
|
||||
UserAchievementResp.from_db(ua) for ua in obj.achievement
|
||||
]
|
||||
|
||||
return u
|
||||
@@ -1,94 +0,0 @@
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import JSON, Column, DateTime
|
||||
from sqlalchemy.orm import Mapped
|
||||
from sqlmodel import BigInteger, Field, ForeignKey, Relationship, SQLModel
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .user import User
|
||||
# ============================================
|
||||
# 旧的兼容性表模型(保留以便向后兼容)
|
||||
# ============================================
|
||||
|
||||
|
||||
class LegacyUserStatistics(SQLModel, table=True):
|
||||
__tablename__ = "user_statistics" # pyright: ignore[reportAssignmentType]
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True, index=True)
|
||||
user_id: int = Field(sa_column=Column(BigInteger, ForeignKey("users.id")))
|
||||
mode: str = Field(max_length=10) # osu, taiko, fruits, mania
|
||||
|
||||
# 基本统计
|
||||
count_100: int = Field(default=0)
|
||||
count_300: int = Field(default=0)
|
||||
count_50: int = Field(default=0)
|
||||
count_miss: int = Field(default=0)
|
||||
|
||||
# 等级信息
|
||||
level_current: int = Field(default=1)
|
||||
level_progress: int = Field(default=0)
|
||||
|
||||
# 排名信息
|
||||
global_rank: int | None = Field(default=None)
|
||||
global_rank_exp: int | None = Field(default=None)
|
||||
country_rank: int | None = Field(default=None)
|
||||
|
||||
# PP 和分数
|
||||
pp: float = Field(default=0.0)
|
||||
pp_exp: float = Field(default=0.0)
|
||||
ranked_score: int = Field(default=0)
|
||||
hit_accuracy: float = Field(default=0.0)
|
||||
total_score: int = Field(default=0)
|
||||
total_hits: int = Field(default=0)
|
||||
maximum_combo: int = Field(default=0)
|
||||
|
||||
# 游戏统计
|
||||
play_count: int = Field(default=0)
|
||||
play_time: int = Field(default=0)
|
||||
replays_watched_by_others: int = Field(default=0)
|
||||
is_ranked: bool = Field(default=False)
|
||||
|
||||
# 成绩等级计数
|
||||
grade_ss: int = Field(default=0)
|
||||
grade_ssh: int = Field(default=0)
|
||||
grade_s: int = Field(default=0)
|
||||
grade_sh: int = Field(default=0)
|
||||
grade_a: int = Field(default=0)
|
||||
|
||||
# 最高排名记录
|
||||
rank_highest: int | None = Field(default=None)
|
||||
rank_highest_updated_at: datetime | None = Field(
|
||||
default=None, sa_column=Column(DateTime)
|
||||
)
|
||||
|
||||
created_at: datetime = Field(
|
||||
default_factory=datetime.utcnow, sa_column=Column(DateTime)
|
||||
)
|
||||
updated_at: datetime = Field(
|
||||
default_factory=datetime.utcnow, sa_column=Column(DateTime)
|
||||
)
|
||||
|
||||
# 关联关系
|
||||
user: Mapped["User"] = Relationship(back_populates="statistics")
|
||||
|
||||
|
||||
class LegacyOAuthToken(SQLModel, table=True):
|
||||
__tablename__ = "legacy_oauth_tokens" # pyright: ignore[reportAssignmentType]
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
user_id: int = Field(sa_column=Column(BigInteger, ForeignKey("users.id")))
|
||||
access_token: str = Field(max_length=255, index=True)
|
||||
refresh_token: str = Field(max_length=255, index=True)
|
||||
expires_at: datetime = Field(sa_column=Column(DateTime))
|
||||
created_at: datetime = Field(
|
||||
default_factory=datetime.utcnow, sa_column=Column(DateTime)
|
||||
)
|
||||
updated_at: datetime = Field(
|
||||
default_factory=datetime.utcnow, sa_column=Column(DateTime)
|
||||
)
|
||||
previous_usernames: list = Field(default_factory=list, sa_column=Column(JSON))
|
||||
replays_watched_counts: list = Field(default_factory=list, sa_column=Column(JSON))
|
||||
|
||||
# 用户关系
|
||||
user: "User" = Relationship()
|
||||
@@ -1,8 +1,6 @@
|
||||
from enum import Enum
|
||||
|
||||
from app.models.user import User as APIUser
|
||||
|
||||
from .user import User as DBUser
|
||||
from .lazer_user import User, UserResp
|
||||
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import (
|
||||
@@ -28,7 +26,7 @@ class Relationship(SQLModel, table=True):
|
||||
default=None,
|
||||
sa_column=Column(
|
||||
BigInteger,
|
||||
ForeignKey("users.id"),
|
||||
ForeignKey("lazer_users.id"),
|
||||
primary_key=True,
|
||||
index=True,
|
||||
),
|
||||
@@ -37,20 +35,20 @@ class Relationship(SQLModel, table=True):
|
||||
default=None,
|
||||
sa_column=Column(
|
||||
BigInteger,
|
||||
ForeignKey("users.id"),
|
||||
ForeignKey("lazer_users.id"),
|
||||
primary_key=True,
|
||||
index=True,
|
||||
),
|
||||
)
|
||||
type: RelationshipType = Field(default=RelationshipType.FOLLOW, nullable=False)
|
||||
target: DBUser = SQLRelationship(
|
||||
target: User = SQLRelationship(
|
||||
sa_relationship_kwargs={"foreign_keys": "[Relationship.target_id]"}
|
||||
)
|
||||
|
||||
|
||||
class RelationshipResp(BaseModel):
|
||||
target_id: int
|
||||
target: APIUser
|
||||
target: UserResp
|
||||
mutual: bool = False
|
||||
type: RelationshipType
|
||||
|
||||
@@ -58,8 +56,6 @@ class RelationshipResp(BaseModel):
|
||||
async def from_db(
|
||||
cls, session: AsyncSession, relationship: Relationship
|
||||
) -> "RelationshipResp":
|
||||
from app.utils import convert_db_user_to_api_user
|
||||
|
||||
target_relationship = (
|
||||
await session.exec(
|
||||
select(Relationship).where(
|
||||
@@ -75,7 +71,17 @@ class RelationshipResp(BaseModel):
|
||||
)
|
||||
return cls(
|
||||
target_id=relationship.target_id,
|
||||
target=await convert_db_user_to_api_user(relationship.target),
|
||||
target=await UserResp.from_db(
|
||||
relationship.target,
|
||||
session,
|
||||
include=[
|
||||
"team",
|
||||
"daily_challenge_user_stats",
|
||||
"statistics",
|
||||
"statistics_rulesets",
|
||||
"achievements",
|
||||
],
|
||||
),
|
||||
mutual=mutual,
|
||||
type=relationship.type,
|
||||
)
|
||||
|
||||
@@ -12,9 +12,8 @@ from app.calculator import (
|
||||
calculate_weighted_pp,
|
||||
clamp,
|
||||
)
|
||||
from app.database.score_token import ScoreToken
|
||||
from app.database.user import LazerUserStatistics, User
|
||||
from app.models.beatmap import BeatmapRankStatus
|
||||
from app.models.model import UTCBaseModel
|
||||
from app.models.mods import APIMod, mods_can_get_pp
|
||||
from app.models.score import (
|
||||
INT_TO_MODE,
|
||||
@@ -26,11 +25,12 @@ from app.models.score import (
|
||||
ScoreStatistics,
|
||||
SoloScoreSubmissionInfo,
|
||||
)
|
||||
from app.models.user import User as APIUser
|
||||
|
||||
from .beatmap import Beatmap, BeatmapResp
|
||||
from .beatmapset import Beatmapset, BeatmapsetResp
|
||||
from .best_score import BestScore
|
||||
from .lazer_user import User, UserResp
|
||||
from .score_token import ScoreToken
|
||||
|
||||
from redis import Redis
|
||||
from sqlalchemy import Column, ColumnExpressionArgument, DateTime
|
||||
@@ -54,7 +54,7 @@ if TYPE_CHECKING:
|
||||
from app.fetcher import Fetcher
|
||||
|
||||
|
||||
class ScoreBase(SQLModel):
|
||||
class ScoreBase(SQLModel, UTCBaseModel):
|
||||
# 基本字段
|
||||
accuracy: float
|
||||
map_md5: str = Field(max_length=32, index=True)
|
||||
@@ -94,7 +94,7 @@ class Score(ScoreBase, table=True):
|
||||
default=None,
|
||||
sa_column=Column(
|
||||
BigInteger,
|
||||
ForeignKey("users.id"),
|
||||
ForeignKey("lazer_users.id"),
|
||||
index=True,
|
||||
),
|
||||
)
|
||||
@@ -112,8 +112,8 @@ class Score(ScoreBase, table=True):
|
||||
gamemode: GameMode = Field(index=True)
|
||||
|
||||
# optional
|
||||
beatmap: "Beatmap" = Relationship()
|
||||
user: "User" = Relationship()
|
||||
beatmap: Beatmap = Relationship()
|
||||
user: User = Relationship()
|
||||
|
||||
@property
|
||||
def is_perfect_combo(self) -> bool:
|
||||
@@ -173,7 +173,7 @@ class ScoreResp(ScoreBase):
|
||||
ruleset_id: int | None = None
|
||||
beatmap: BeatmapResp | None = None
|
||||
beatmapset: BeatmapsetResp | None = None
|
||||
user: APIUser | None = None
|
||||
user: UserResp | None = None
|
||||
statistics: ScoreStatistics | None = None
|
||||
maximum_statistics: ScoreStatistics | None = None
|
||||
rank_global: int | None = None
|
||||
@@ -183,8 +183,6 @@ class ScoreResp(ScoreBase):
|
||||
async def from_db(
|
||||
cls, session: AsyncSession, score: Score, user: User | None = None
|
||||
) -> "ScoreResp":
|
||||
from app.utils import convert_db_user_to_api_user
|
||||
|
||||
s = cls.model_validate(score.model_dump())
|
||||
assert score.id
|
||||
s.beatmap = BeatmapResp.from_db(score.beatmap)
|
||||
@@ -221,7 +219,12 @@ class ScoreResp(ScoreBase):
|
||||
HitResult.GREAT: score.beatmap.max_combo,
|
||||
}
|
||||
if user:
|
||||
s.user = await convert_db_user_to_api_user(user)
|
||||
s.user = await UserResp.from_db(
|
||||
user,
|
||||
session,
|
||||
include=["statistics", "team", "daily_challenge_user_stats"],
|
||||
ruleset=score.gamemode,
|
||||
)
|
||||
s.rank_global = (
|
||||
await get_score_position_by_id(
|
||||
session,
|
||||
@@ -494,21 +497,20 @@ async def get_user_best_pp(
|
||||
async def process_user(
|
||||
session: AsyncSession, user: User, score: Score, ranked: bool = False
|
||||
):
|
||||
assert user.id
|
||||
previous_score_best = await get_user_best_score_in_beatmap(
|
||||
session, score.beatmap_id, user.id, score.gamemode
|
||||
)
|
||||
statistics = None
|
||||
add_to_db = False
|
||||
for i in user.lazer_statistics:
|
||||
for i in user.statistics:
|
||||
if i.mode == score.gamemode.value:
|
||||
statistics = i
|
||||
break
|
||||
if statistics is None:
|
||||
statistics = LazerUserStatistics(
|
||||
mode=score.gamemode.value,
|
||||
user_id=user.id,
|
||||
raise ValueError(
|
||||
f"User {user.id} does not have statistics for mode {score.gamemode.value}"
|
||||
)
|
||||
add_to_db = True
|
||||
|
||||
# pc, pt, tth, tts
|
||||
statistics.total_score += score.total_score
|
||||
@@ -546,6 +548,10 @@ async def process_user(
|
||||
statistics.maximum_combo = max(statistics.maximum_combo, score.max_combo)
|
||||
statistics.play_count += 1
|
||||
statistics.play_time += int((score.ended_at - score.started_at).total_seconds())
|
||||
statistics.count_100 += score.n100 + score.nkatu
|
||||
statistics.count_300 += score.n300 + score.ngeki
|
||||
statistics.count_50 += score.n50
|
||||
statistics.count_miss += score.nmiss
|
||||
statistics.total_hits += (
|
||||
score.n300 + score.n100 + score.n50 + score.ngeki + score.nkatu
|
||||
)
|
||||
@@ -564,8 +570,6 @@ async def process_user(
|
||||
statistics.pp = pp_sum
|
||||
statistics.hit_accuracy = acc_sum
|
||||
|
||||
statistics.updated_at = datetime.now(UTC)
|
||||
|
||||
if add_to_db:
|
||||
session.add(statistics)
|
||||
await session.commit()
|
||||
@@ -582,6 +586,7 @@ async def process_score(
|
||||
session: AsyncSession,
|
||||
redis: Redis,
|
||||
) -> Score:
|
||||
assert user.id
|
||||
can_get_pp = info.passed and ranked and mods_can_get_pp(info.ruleset_id, info.mods)
|
||||
score = Score(
|
||||
accuracy=info.accuracy,
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
from datetime import datetime
|
||||
|
||||
from app.models.model import UTCBaseModel
|
||||
from app.models.score import GameMode
|
||||
|
||||
from .beatmap import Beatmap
|
||||
from .user import User
|
||||
from .lazer_user import User
|
||||
|
||||
from sqlalchemy import Column, DateTime, Index
|
||||
from sqlmodel import BigInteger, Field, ForeignKey, Relationship, SQLModel
|
||||
|
||||
|
||||
class ScoreTokenBase(SQLModel):
|
||||
class ScoreTokenBase(SQLModel, UTCBaseModel):
|
||||
score_id: int | None = Field(sa_column=Column(BigInteger), default=None)
|
||||
ruleset_id: GameMode
|
||||
playlist_item_id: int | None = Field(default=None) # playlist
|
||||
@@ -34,10 +35,10 @@ class ScoreToken(ScoreTokenBase, table=True):
|
||||
autoincrement=True,
|
||||
),
|
||||
)
|
||||
user_id: int = Field(sa_column=Column(BigInteger, ForeignKey("users.id")))
|
||||
user_id: int = Field(sa_column=Column(BigInteger, ForeignKey("lazer_users.id")))
|
||||
beatmap_id: int = Field(foreign_key="beatmaps.id")
|
||||
user: "User" = Relationship()
|
||||
beatmap: "Beatmap" = Relationship()
|
||||
user: User = Relationship()
|
||||
beatmap: Beatmap = Relationship()
|
||||
|
||||
|
||||
class ScoreTokenResp(ScoreTokenBase):
|
||||
|
||||
95
app/database/statistics.py
Normal file
95
app/database/statistics.py
Normal file
@@ -0,0 +1,95 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from app.models.score import GameMode
|
||||
|
||||
from sqlmodel import (
|
||||
BigInteger,
|
||||
Column,
|
||||
Field,
|
||||
ForeignKey,
|
||||
Relationship,
|
||||
SQLModel,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .lazer_user import User
|
||||
|
||||
|
||||
class UserStatisticsBase(SQLModel):
|
||||
mode: GameMode
|
||||
count_100: int = Field(default=0, sa_column=Column(BigInteger))
|
||||
count_300: int = Field(default=0, sa_column=Column(BigInteger))
|
||||
count_50: int = Field(default=0, sa_column=Column(BigInteger))
|
||||
count_miss: int = Field(default=0, sa_column=Column(BigInteger))
|
||||
|
||||
global_rank: int | None = Field(default=None)
|
||||
country_rank: int | None = Field(default=None)
|
||||
|
||||
pp: float = Field(default=0.0)
|
||||
ranked_score: int = Field(default=0)
|
||||
hit_accuracy: float = Field(default=0.00)
|
||||
total_score: int = Field(default=0, sa_column=Column(BigInteger))
|
||||
total_hits: int = Field(default=0, sa_column=Column(BigInteger))
|
||||
maximum_combo: int = Field(default=0)
|
||||
|
||||
play_count: int = Field(default=0)
|
||||
play_time: int = Field(default=0, sa_column=Column(BigInteger))
|
||||
replays_watched_by_others: int = Field(default=0)
|
||||
is_ranked: bool = Field(default=True)
|
||||
|
||||
|
||||
class UserStatistics(UserStatisticsBase, table=True):
|
||||
__tablename__ = "lazer_user_statistics" # pyright: ignore[reportAssignmentType]
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
user_id: int = Field(
|
||||
default=None,
|
||||
sa_column=Column(
|
||||
BigInteger,
|
||||
ForeignKey("lazer_users.id"),
|
||||
index=True,
|
||||
),
|
||||
)
|
||||
grade_ss: int = Field(default=0)
|
||||
grade_ssh: int = Field(default=0)
|
||||
grade_s: int = Field(default=0)
|
||||
grade_sh: int = Field(default=0)
|
||||
grade_a: int = Field(default=0)
|
||||
|
||||
level_current: int = Field(default=1)
|
||||
level_progress: int = Field(default=0)
|
||||
|
||||
user: "User" = Relationship(back_populates="statistics") # type: ignore[valid-type]
|
||||
|
||||
|
||||
class UserStatisticsResp(UserStatisticsBase):
|
||||
grade_counts: dict[str, int] = Field(
|
||||
default_factory=lambda: {
|
||||
"ss": 0,
|
||||
"ssh": 0,
|
||||
"s": 0,
|
||||
"sh": 0,
|
||||
"a": 0,
|
||||
}
|
||||
)
|
||||
level: dict[str, int] = Field(
|
||||
default_factory=lambda: {
|
||||
"current": 1,
|
||||
"progress": 0,
|
||||
}
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_db(cls, obj: UserStatistics) -> "UserStatisticsResp":
|
||||
s = cls.model_validate(obj)
|
||||
s.grade_counts = {
|
||||
"ss": obj.grade_ss,
|
||||
"ssh": obj.grade_ssh,
|
||||
"s": obj.grade_s,
|
||||
"sh": obj.grade_sh,
|
||||
"a": obj.grade_a,
|
||||
}
|
||||
s.level = {
|
||||
"current": obj.level_current,
|
||||
"progress": obj.level_progress,
|
||||
}
|
||||
return s
|
||||
@@ -1,14 +1,16 @@
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from app.models.model import UTCBaseModel
|
||||
|
||||
from sqlalchemy import Column, DateTime
|
||||
from sqlmodel import BigInteger, Field, ForeignKey, Relationship, SQLModel
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .user import User
|
||||
from .lazer_user import User
|
||||
|
||||
|
||||
class Team(SQLModel, table=True):
|
||||
class Team(SQLModel, UTCBaseModel, table=True):
|
||||
__tablename__ = "teams" # pyright: ignore[reportAssignmentType]
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True, index=True)
|
||||
@@ -22,11 +24,11 @@ class Team(SQLModel, table=True):
|
||||
members: list["TeamMember"] = Relationship(back_populates="team")
|
||||
|
||||
|
||||
class TeamMember(SQLModel, table=True):
|
||||
class TeamMember(SQLModel, UTCBaseModel, table=True):
|
||||
__tablename__ = "team_members" # pyright: ignore[reportAssignmentType]
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True, index=True)
|
||||
user_id: int = Field(sa_column=Column(BigInteger, ForeignKey("users.id")))
|
||||
user_id: int = Field(sa_column=Column(BigInteger, ForeignKey("lazer_users.id")))
|
||||
team_id: int = Field(foreign_key="teams.id")
|
||||
joined_at: datetime = Field(
|
||||
default_factory=datetime.utcnow, sa_column=Column(DateTime)
|
||||
|
||||
@@ -1,527 +0,0 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from .legacy import LegacyUserStatistics
|
||||
from .team import TeamMember
|
||||
|
||||
from sqlalchemy import DECIMAL, JSON, Column, Date, DateTime, Text
|
||||
from sqlalchemy.dialects.mysql import VARCHAR
|
||||
from sqlalchemy.orm import joinedload, selectinload
|
||||
from sqlmodel import BigInteger, Field, ForeignKey, Relationship, SQLModel, select
|
||||
|
||||
|
||||
class User(SQLModel, table=True):
|
||||
__tablename__ = "users" # pyright: ignore[reportAssignmentType]
|
||||
|
||||
# 主键
|
||||
id: int = Field(
|
||||
default=None, sa_column=Column(BigInteger, primary_key=True, index=True)
|
||||
)
|
||||
|
||||
# 基本信息(匹配 migrations_old 中的结构)
|
||||
name: str = Field(max_length=32, unique=True, index=True) # 用户名
|
||||
safe_name: str = Field(max_length=32, unique=True, index=True) # 安全用户名
|
||||
email: str = Field(max_length=254, unique=True, index=True)
|
||||
priv: int = Field(default=1) # 权限
|
||||
pw_bcrypt: str = Field(max_length=60) # bcrypt 哈希密码
|
||||
country: str = Field(default="CN", max_length=2) # 国家代码
|
||||
|
||||
# 状态和时间
|
||||
silence_end: int = Field(default=0)
|
||||
donor_end: int = Field(default=0)
|
||||
creation_time: int = Field(default=0) # Unix 时间戳
|
||||
latest_activity: int = Field(default=0) # Unix 时间戳
|
||||
|
||||
# 游戏相关
|
||||
preferred_mode: int = Field(default=0) # 偏好游戏模式
|
||||
play_style: int = Field(default=0) # 游戏风格
|
||||
|
||||
# 扩展信息
|
||||
clan_id: int = Field(default=0)
|
||||
clan_priv: int = Field(default=0)
|
||||
custom_badge_name: str | None = Field(default=None, max_length=16)
|
||||
custom_badge_icon: str | None = Field(default=None, max_length=64)
|
||||
userpage_content: str | None = Field(default=None, max_length=2048)
|
||||
api_key: str | None = Field(default=None, max_length=36, unique=True)
|
||||
|
||||
# 虚拟字段用于兼容性
|
||||
@property
|
||||
def username(self):
|
||||
return self.name
|
||||
|
||||
@property
|
||||
def country_code(self):
|
||||
return self.country
|
||||
|
||||
@property
|
||||
def join_date(self):
|
||||
creation_time = getattr(self, "creation_time", 0)
|
||||
return (
|
||||
datetime.fromtimestamp(creation_time)
|
||||
if creation_time > 0
|
||||
else datetime.utcnow()
|
||||
)
|
||||
|
||||
@property
|
||||
def last_visit(self):
|
||||
latest_activity = getattr(self, "latest_activity", 0)
|
||||
return datetime.fromtimestamp(latest_activity) if latest_activity > 0 else None
|
||||
|
||||
@property
|
||||
def is_supporter(self):
|
||||
return self.lazer_profile.is_supporter if self.lazer_profile else False
|
||||
|
||||
# 关联关系
|
||||
lazer_profile: Optional["LazerUserProfile"] = Relationship(back_populates="user")
|
||||
lazer_statistics: list["LazerUserStatistics"] = Relationship(back_populates="user")
|
||||
lazer_counts: Optional["LazerUserCounts"] = Relationship(back_populates="user")
|
||||
lazer_achievements: list["LazerUserAchievement"] = Relationship(
|
||||
back_populates="user"
|
||||
)
|
||||
lazer_profile_sections: list["LazerUserProfileSections"] = Relationship(
|
||||
back_populates="user"
|
||||
)
|
||||
statistics: list["LegacyUserStatistics"] = Relationship(back_populates="user")
|
||||
team_membership: Optional["TeamMember"] = Relationship(back_populates="user")
|
||||
daily_challenge_stats: Optional["DailyChallengeStats"] = Relationship(
|
||||
back_populates="user"
|
||||
)
|
||||
rank_history: list["RankHistory"] = Relationship(back_populates="user")
|
||||
avatar: Optional["UserAvatar"] = Relationship(back_populates="user")
|
||||
active_banners: list["LazerUserBanners"] = Relationship(back_populates="user")
|
||||
lazer_badges: list["LazerUserBadge"] = Relationship(back_populates="user")
|
||||
lazer_monthly_playcounts: list["LazerUserMonthlyPlaycounts"] = Relationship(
|
||||
back_populates="user"
|
||||
)
|
||||
lazer_previous_usernames: list["LazerUserPreviousUsername"] = Relationship(
|
||||
back_populates="user"
|
||||
)
|
||||
lazer_replays_watched: list["LazerUserReplaysWatched"] = Relationship(
|
||||
back_populates="user"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def all_select_option(cls):
|
||||
return (
|
||||
joinedload(cls.lazer_profile), # pyright: ignore[reportArgumentType]
|
||||
joinedload(cls.lazer_counts), # pyright: ignore[reportArgumentType]
|
||||
joinedload(cls.daily_challenge_stats), # pyright: ignore[reportArgumentType]
|
||||
joinedload(cls.avatar), # pyright: ignore[reportArgumentType]
|
||||
selectinload(cls.lazer_statistics), # pyright: ignore[reportArgumentType]
|
||||
selectinload(cls.lazer_achievements), # pyright: ignore[reportArgumentType]
|
||||
selectinload(cls.lazer_profile_sections), # pyright: ignore[reportArgumentType]
|
||||
selectinload(cls.statistics), # pyright: ignore[reportArgumentType]
|
||||
joinedload(cls.team_membership), # pyright: ignore[reportArgumentType]
|
||||
selectinload(cls.rank_history), # pyright: ignore[reportArgumentType]
|
||||
selectinload(cls.active_banners), # pyright: ignore[reportArgumentType]
|
||||
selectinload(cls.lazer_badges), # pyright: ignore[reportArgumentType]
|
||||
selectinload(cls.lazer_monthly_playcounts), # pyright: ignore[reportArgumentType]
|
||||
selectinload(cls.lazer_previous_usernames), # pyright: ignore[reportArgumentType]
|
||||
selectinload(cls.lazer_replays_watched), # pyright: ignore[reportArgumentType]
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def all_select_clause(cls):
|
||||
return select(cls).options(*cls.all_select_option())
|
||||
|
||||
|
||||
# ============================================
|
||||
# Lazer API 专用表模型
|
||||
# ============================================
|
||||
|
||||
|
||||
class LazerUserProfile(SQLModel, table=True):
|
||||
__tablename__ = "lazer_user_profiles" # pyright: ignore[reportAssignmentType]
|
||||
|
||||
user_id: int = Field(
|
||||
default=None,
|
||||
sa_column=Column(
|
||||
BigInteger,
|
||||
ForeignKey("users.id"),
|
||||
primary_key=True,
|
||||
),
|
||||
)
|
||||
|
||||
# 基本状态字段
|
||||
is_active: bool = Field(default=True)
|
||||
is_bot: bool = Field(default=False)
|
||||
is_deleted: bool = Field(default=False)
|
||||
is_online: bool = Field(default=True)
|
||||
is_supporter: bool = Field(default=False)
|
||||
is_restricted: bool = Field(default=False)
|
||||
session_verified: bool = Field(default=False)
|
||||
has_supported: bool = Field(default=False)
|
||||
pm_friends_only: bool = Field(default=False)
|
||||
|
||||
# 基本资料字段
|
||||
default_group: str = Field(default="default", max_length=50)
|
||||
last_visit: datetime | None = Field(default=None, sa_column=Column(DateTime))
|
||||
join_date: datetime | None = Field(default=None, sa_column=Column(DateTime))
|
||||
profile_colour: str | None = Field(default=None, max_length=7)
|
||||
profile_hue: int | None = Field(default=None)
|
||||
|
||||
# 社交媒体和个人资料字段
|
||||
avatar_url: str | None = Field(default=None, max_length=500)
|
||||
cover_url: str | None = Field(default=None, max_length=500)
|
||||
discord: str | None = Field(default=None, max_length=100)
|
||||
twitter: str | None = Field(default=None, max_length=100)
|
||||
website: str | None = Field(default=None, max_length=500)
|
||||
title: str | None = Field(default=None, max_length=100)
|
||||
title_url: str | None = Field(default=None, max_length=500)
|
||||
interests: str | None = Field(default=None, sa_column=Column(Text))
|
||||
location: str | None = Field(default=None, max_length=100)
|
||||
|
||||
occupation: str | None = Field(default=None) # 职业字段,默认为 None
|
||||
|
||||
# 游戏相关字段
|
||||
playmode: str = Field(default="osu", max_length=10)
|
||||
support_level: int = Field(default=0)
|
||||
max_blocks: int = Field(default=100)
|
||||
max_friends: int = Field(default=500)
|
||||
post_count: int = Field(default=0)
|
||||
|
||||
# 页面内容
|
||||
page_html: str | None = Field(default=None, sa_column=Column(Text))
|
||||
page_raw: str | None = Field(default=None, sa_column=Column(Text))
|
||||
|
||||
profile_order: str = Field(
|
||||
default="me,recent_activity,top_ranks,medals,historical,beatmaps,kudosu"
|
||||
)
|
||||
|
||||
# 关联关系
|
||||
user: "User" = Relationship(back_populates="lazer_profile")
|
||||
|
||||
|
||||
class LazerUserProfileSections(SQLModel, table=True):
|
||||
__tablename__ = "lazer_user_profile_sections" # pyright: ignore[reportAssignmentType]
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
user_id: int = Field(sa_column=Column(BigInteger, ForeignKey("users.id")))
|
||||
section_name: str = Field(sa_column=Column(VARCHAR(50)))
|
||||
display_order: int | None = Field(default=None)
|
||||
|
||||
created_at: datetime = Field(
|
||||
default_factory=datetime.utcnow, sa_column=Column(DateTime)
|
||||
)
|
||||
updated_at: datetime = Field(
|
||||
default_factory=datetime.utcnow, sa_column=Column(DateTime)
|
||||
)
|
||||
|
||||
user: "User" = Relationship(back_populates="lazer_profile_sections")
|
||||
|
||||
|
||||
class LazerUserCountry(SQLModel, table=True):
|
||||
__tablename__ = "lazer_user_countries" # pyright: ignore[reportAssignmentType]
|
||||
|
||||
user_id: int = Field(
|
||||
default=None,
|
||||
sa_column=Column(
|
||||
BigInteger,
|
||||
ForeignKey("users.id"),
|
||||
primary_key=True,
|
||||
),
|
||||
)
|
||||
code: str = Field(max_length=2)
|
||||
name: str = Field(max_length=100)
|
||||
|
||||
created_at: datetime = Field(
|
||||
default_factory=datetime.utcnow, sa_column=Column(DateTime)
|
||||
)
|
||||
updated_at: datetime = Field(
|
||||
default_factory=datetime.utcnow, sa_column=Column(DateTime)
|
||||
)
|
||||
|
||||
|
||||
class LazerUserKudosu(SQLModel, table=True):
|
||||
__tablename__ = "lazer_user_kudosu" # pyright: ignore[reportAssignmentType]
|
||||
|
||||
user_id: int = Field(
|
||||
default=None,
|
||||
sa_column=Column(
|
||||
BigInteger,
|
||||
ForeignKey("users.id"),
|
||||
primary_key=True,
|
||||
),
|
||||
)
|
||||
available: int = Field(default=0)
|
||||
total: int = Field(default=0)
|
||||
|
||||
created_at: datetime = Field(
|
||||
default_factory=datetime.utcnow, sa_column=Column(DateTime)
|
||||
)
|
||||
updated_at: datetime = Field(
|
||||
default_factory=datetime.utcnow, sa_column=Column(DateTime)
|
||||
)
|
||||
|
||||
|
||||
class LazerUserCounts(SQLModel, table=True):
|
||||
__tablename__ = "lazer_user_counts" # pyright: ignore[reportAssignmentType]
|
||||
|
||||
user_id: int = Field(
|
||||
default=None,
|
||||
sa_column=Column(
|
||||
BigInteger,
|
||||
ForeignKey("users.id"),
|
||||
primary_key=True,
|
||||
),
|
||||
)
|
||||
|
||||
# 统计计数字段
|
||||
beatmap_playcounts_count: int = Field(default=0)
|
||||
comments_count: int = Field(default=0)
|
||||
favourite_beatmapset_count: int = Field(default=0)
|
||||
follower_count: int = Field(default=0)
|
||||
graveyard_beatmapset_count: int = Field(default=0)
|
||||
guest_beatmapset_count: int = Field(default=0)
|
||||
loved_beatmapset_count: int = Field(default=0)
|
||||
mapping_follower_count: int = Field(default=0)
|
||||
nominated_beatmapset_count: int = Field(default=0)
|
||||
pending_beatmapset_count: int = Field(default=0)
|
||||
ranked_beatmapset_count: int = Field(default=0)
|
||||
ranked_and_approved_beatmapset_count: int = Field(default=0)
|
||||
unranked_beatmapset_count: int = Field(default=0)
|
||||
scores_best_count: int = Field(default=0)
|
||||
scores_first_count: int = Field(default=0)
|
||||
scores_pinned_count: int = Field(default=0)
|
||||
scores_recent_count: int = Field(default=0)
|
||||
|
||||
created_at: datetime = Field(
|
||||
default_factory=datetime.utcnow, sa_column=Column(DateTime)
|
||||
)
|
||||
updated_at: datetime = Field(
|
||||
default_factory=datetime.utcnow, sa_column=Column(DateTime)
|
||||
)
|
||||
|
||||
# 关联关系
|
||||
user: "User" = Relationship(back_populates="lazer_counts")
|
||||
|
||||
|
||||
class LazerUserStatistics(SQLModel, table=True):
|
||||
__tablename__ = "lazer_user_statistics" # pyright: ignore[reportAssignmentType]
|
||||
|
||||
user_id: int = Field(
|
||||
default=None,
|
||||
sa_column=Column(
|
||||
BigInteger,
|
||||
ForeignKey("users.id"),
|
||||
primary_key=True,
|
||||
),
|
||||
)
|
||||
mode: str = Field(default="osu", max_length=10, primary_key=True)
|
||||
|
||||
# 基本命中统计
|
||||
count_100: int = Field(default=0)
|
||||
count_300: int = Field(default=0)
|
||||
count_50: int = Field(default=0)
|
||||
count_miss: int = Field(default=0)
|
||||
|
||||
# 等级信息
|
||||
level_current: int = Field(default=1)
|
||||
level_progress: int = Field(default=0)
|
||||
|
||||
# 排名信息
|
||||
global_rank: int | None = Field(default=None)
|
||||
global_rank_exp: int | None = Field(default=None)
|
||||
country_rank: int | None = Field(default=None)
|
||||
|
||||
# PP 和分数
|
||||
pp: float = Field(default=0.00, sa_column=Column(DECIMAL(10, 2)))
|
||||
pp_exp: float = Field(default=0.00, sa_column=Column(DECIMAL(10, 2)))
|
||||
ranked_score: int = Field(default=0, sa_column=Column(BigInteger))
|
||||
hit_accuracy: float = Field(default=0.00, sa_column=Column(DECIMAL(5, 2)))
|
||||
total_score: int = Field(default=0, sa_column=Column(BigInteger))
|
||||
total_hits: int = Field(default=0, sa_column=Column(BigInteger))
|
||||
maximum_combo: int = Field(default=0)
|
||||
|
||||
# 游戏统计
|
||||
play_count: int = Field(default=0)
|
||||
play_time: int = Field(default=0) # 秒
|
||||
replays_watched_by_others: int = Field(default=0)
|
||||
is_ranked: bool = Field(default=False)
|
||||
|
||||
# 成绩等级计数
|
||||
grade_ss: int = Field(default=0)
|
||||
grade_ssh: int = Field(default=0)
|
||||
grade_s: int = Field(default=0)
|
||||
grade_sh: int = Field(default=0)
|
||||
grade_a: int = Field(default=0)
|
||||
|
||||
# 最高排名记录
|
||||
rank_highest: int | None = Field(default=None)
|
||||
rank_highest_updated_at: datetime | None = Field(
|
||||
default=None, sa_column=Column(DateTime)
|
||||
)
|
||||
|
||||
created_at: datetime = Field(
|
||||
default_factory=datetime.utcnow, sa_column=Column(DateTime)
|
||||
)
|
||||
updated_at: datetime = Field(
|
||||
default_factory=datetime.utcnow, sa_column=Column(DateTime)
|
||||
)
|
||||
|
||||
# 关联关系
|
||||
user: "User" = Relationship(back_populates="lazer_statistics")
|
||||
|
||||
|
||||
class LazerUserBanners(SQLModel, table=True):
|
||||
__tablename__ = "lazer_user_tournament_banners" # pyright: ignore[reportAssignmentType]
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
user_id: int = Field(sa_column=Column(BigInteger, ForeignKey("users.id")))
|
||||
tournament_id: int
|
||||
image_url: str = Field(sa_column=Column(VARCHAR(500)))
|
||||
is_active: bool | None = Field(default=None)
|
||||
|
||||
# 修正user关系的back_populates值
|
||||
user: "User" = Relationship(back_populates="active_banners")
|
||||
|
||||
|
||||
class LazerUserAchievement(SQLModel, table=True):
|
||||
__tablename__ = "lazer_user_achievements" # pyright: ignore[reportAssignmentType]
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True, index=True)
|
||||
user_id: int = Field(sa_column=Column(BigInteger, ForeignKey("users.id")))
|
||||
achievement_id: int
|
||||
achieved_at: datetime = Field(
|
||||
default_factory=datetime.utcnow, sa_column=Column(DateTime)
|
||||
)
|
||||
|
||||
user: "User" = Relationship(back_populates="lazer_achievements")
|
||||
|
||||
|
||||
class LazerUserBadge(SQLModel, table=True):
|
||||
__tablename__ = "lazer_user_badges" # pyright: ignore[reportAssignmentType]
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True, index=True)
|
||||
user_id: int = Field(sa_column=Column(BigInteger, ForeignKey("users.id")))
|
||||
badge_id: int
|
||||
awarded_at: datetime | None = Field(default=None, sa_column=Column(DateTime))
|
||||
description: str | None = Field(default=None, sa_column=Column(Text))
|
||||
image_url: str | None = Field(default=None, max_length=500)
|
||||
url: str | None = Field(default=None, max_length=500)
|
||||
|
||||
created_at: datetime = Field(
|
||||
default_factory=datetime.utcnow, sa_column=Column(DateTime)
|
||||
)
|
||||
updated_at: datetime = Field(
|
||||
default_factory=datetime.utcnow, sa_column=Column(DateTime)
|
||||
)
|
||||
|
||||
user: "User" = Relationship(back_populates="lazer_badges")
|
||||
|
||||
|
||||
class LazerUserMonthlyPlaycounts(SQLModel, table=True):
|
||||
__tablename__ = "lazer_user_monthly_playcounts" # pyright: ignore[reportAssignmentType]
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True, index=True)
|
||||
user_id: int = Field(sa_column=Column(BigInteger, ForeignKey("users.id")))
|
||||
start_date: datetime = Field(sa_column=Column(Date))
|
||||
play_count: int = Field(default=0)
|
||||
|
||||
created_at: datetime = Field(
|
||||
default_factory=datetime.utcnow, sa_column=Column(DateTime)
|
||||
)
|
||||
updated_at: datetime = Field(
|
||||
default_factory=datetime.utcnow, sa_column=Column(DateTime)
|
||||
)
|
||||
|
||||
user: "User" = Relationship(back_populates="lazer_monthly_playcounts")
|
||||
|
||||
|
||||
class LazerUserPreviousUsername(SQLModel, table=True):
|
||||
__tablename__ = "lazer_user_previous_usernames" # pyright: ignore[reportAssignmentType]
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True, index=True)
|
||||
user_id: int = Field(sa_column=Column(BigInteger, ForeignKey("users.id")))
|
||||
username: str = Field(max_length=32)
|
||||
changed_at: datetime = Field(sa_column=Column(DateTime))
|
||||
|
||||
created_at: datetime = Field(
|
||||
default_factory=datetime.utcnow, sa_column=Column(DateTime)
|
||||
)
|
||||
updated_at: datetime = Field(
|
||||
default_factory=datetime.utcnow, sa_column=Column(DateTime)
|
||||
)
|
||||
|
||||
user: "User" = Relationship(back_populates="lazer_previous_usernames")
|
||||
|
||||
|
||||
class LazerUserReplaysWatched(SQLModel, table=True):
|
||||
__tablename__ = "lazer_user_replays_watched" # pyright: ignore[reportAssignmentType]
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True, index=True)
|
||||
user_id: int = Field(sa_column=Column(BigInteger, ForeignKey("users.id")))
|
||||
start_date: datetime = Field(sa_column=Column(Date))
|
||||
count: int = Field(default=0)
|
||||
|
||||
created_at: datetime = Field(
|
||||
default_factory=datetime.utcnow, sa_column=Column(DateTime)
|
||||
)
|
||||
updated_at: datetime = Field(
|
||||
default_factory=datetime.utcnow, sa_column=Column(DateTime)
|
||||
)
|
||||
|
||||
user: "User" = Relationship(back_populates="lazer_replays_watched")
|
||||
|
||||
|
||||
# 类型转换用的 UserAchievement(不是 SQLAlchemy 模型)
|
||||
@dataclass
|
||||
class UserAchievement:
|
||||
achieved_at: datetime
|
||||
achievement_id: int
|
||||
|
||||
|
||||
class DailyChallengeStats(SQLModel, table=True):
|
||||
__tablename__ = "daily_challenge_stats" # pyright: ignore[reportAssignmentType]
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True, index=True)
|
||||
user_id: int = Field(
|
||||
sa_column=Column(BigInteger, ForeignKey("users.id"), unique=True)
|
||||
)
|
||||
|
||||
daily_streak_best: int = Field(default=0)
|
||||
daily_streak_current: int = Field(default=0)
|
||||
last_update: datetime | None = Field(default=None, sa_column=Column(DateTime))
|
||||
last_weekly_streak: datetime | None = Field(
|
||||
default=None, sa_column=Column(DateTime)
|
||||
)
|
||||
playcount: int = Field(default=0)
|
||||
top_10p_placements: int = Field(default=0)
|
||||
top_50p_placements: int = Field(default=0)
|
||||
weekly_streak_best: int = Field(default=0)
|
||||
weekly_streak_current: int = Field(default=0)
|
||||
|
||||
user: "User" = Relationship(back_populates="daily_challenge_stats")
|
||||
|
||||
|
||||
class RankHistory(SQLModel, table=True):
|
||||
__tablename__ = "rank_history" # pyright: ignore[reportAssignmentType]
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True, index=True)
|
||||
user_id: int = Field(sa_column=Column(BigInteger, ForeignKey("users.id")))
|
||||
mode: str = Field(max_length=10)
|
||||
rank_data: list = Field(sa_column=Column(JSON)) # Array of ranks
|
||||
date_recorded: datetime = Field(
|
||||
default_factory=datetime.utcnow, sa_column=Column(DateTime)
|
||||
)
|
||||
|
||||
user: "User" = Relationship(back_populates="rank_history")
|
||||
|
||||
|
||||
class UserAvatar(SQLModel, table=True):
|
||||
__tablename__ = "user_avatars" # pyright: ignore[reportAssignmentType]
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True, index=True)
|
||||
user_id: int = Field(sa_column=Column(BigInteger, ForeignKey("users.id")))
|
||||
filename: str = Field(max_length=255)
|
||||
original_filename: str = Field(max_length=255)
|
||||
file_size: int
|
||||
mime_type: str = Field(max_length=100)
|
||||
is_active: bool = Field(default=True)
|
||||
created_at: int = Field(default_factory=lambda: int(datetime.now().timestamp()))
|
||||
updated_at: int = Field(default_factory=lambda: int(datetime.now().timestamp()))
|
||||
r2_original_url: str | None = Field(default=None, max_length=500)
|
||||
r2_game_url: str | None = Field(default=None, max_length=500)
|
||||
|
||||
user: "User" = Relationship(back_populates="avatar")
|
||||
45
app/database/user_account_history.py
Normal file
45
app/database/user_account_history.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from datetime import UTC, datetime
|
||||
from enum import Enum
|
||||
|
||||
from app.models.model import UTCBaseModel
|
||||
|
||||
from sqlmodel import BigInteger, Column, Field, ForeignKey, Integer, SQLModel
|
||||
|
||||
|
||||
class UserAccountHistoryType(str, Enum):
|
||||
NOTE = "note"
|
||||
RESTRICTION = "restriction"
|
||||
SLIENCE = "silence"
|
||||
TOURNAMENT_BAN = "tournament_ban"
|
||||
|
||||
|
||||
class UserAccountHistoryBase(SQLModel, UTCBaseModel):
|
||||
description: str | None = None
|
||||
length: int
|
||||
permanent: bool = False
|
||||
timestamp: datetime = Field(default=datetime.now(UTC))
|
||||
type: UserAccountHistoryType
|
||||
|
||||
|
||||
class UserAccountHistory(UserAccountHistoryBase, table=True):
|
||||
__tablename__ = "user_account_history" # pyright: ignore[reportAssignmentType]
|
||||
|
||||
id: int | None = Field(
|
||||
sa_column=Column(
|
||||
Integer,
|
||||
autoincrement=True,
|
||||
index=True,
|
||||
primary_key=True,
|
||||
)
|
||||
)
|
||||
user_id: int = Field(
|
||||
sa_column=Column(BigInteger, ForeignKey("lazer_users.id"), index=True)
|
||||
)
|
||||
|
||||
|
||||
class UserAccountHistoryResp(UserAccountHistoryBase):
|
||||
id: int | None = None
|
||||
|
||||
@classmethod
|
||||
def from_db(cls, db_model: UserAccountHistory) -> "UserAccountHistoryResp":
|
||||
return cls.model_validate(db_model)
|
||||
@@ -1,14 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.auth import get_token_by_access_token
|
||||
from app.database import (
|
||||
User as DBUser,
|
||||
)
|
||||
from app.database import User
|
||||
|
||||
from .database import get_db
|
||||
|
||||
from fastapi import Depends, HTTPException
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
security = HTTPBearer()
|
||||
@@ -17,7 +16,7 @@ security = HTTPBearer()
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> DBUser:
|
||||
) -> User:
|
||||
"""获取当前认证用户"""
|
||||
token = credentials.credentials
|
||||
|
||||
@@ -27,13 +26,15 @@ async def get_current_user(
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_user_by_token(token: str, db: AsyncSession) -> DBUser | None:
|
||||
async def get_current_user_by_token(token: str, db: AsyncSession) -> User | None:
|
||||
token_record = await get_token_by_access_token(db, token)
|
||||
if not token_record:
|
||||
return None
|
||||
user = (
|
||||
await db.exec(
|
||||
DBUser.all_select_clause().where(DBUser.id == token_record.user_id)
|
||||
select(User)
|
||||
.options(*User.all_select_option())
|
||||
.where(User.id == token_record.user_id)
|
||||
)
|
||||
).first()
|
||||
return user
|
||||
|
||||
15
app/models/model.py
Normal file
15
app/models/model.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from pydantic import BaseModel, field_serializer
|
||||
|
||||
|
||||
class UTCBaseModel(BaseModel):
|
||||
@field_serializer("*", when_used="json")
|
||||
def serialize_datetime(self, v, _info):
|
||||
if isinstance(v, datetime):
|
||||
if v.tzinfo is None:
|
||||
v = v.replace(tzinfo=UTC)
|
||||
return v.astimezone(UTC).isoformat()
|
||||
return v
|
||||
@@ -3,11 +3,13 @@ from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
from app.database import User
|
||||
from app.database.beatmap import Beatmap
|
||||
from app.database.user import User
|
||||
from app.models.mods import APIMod
|
||||
|
||||
from pydantic import BaseModel
|
||||
from .model import UTCBaseModel
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class RoomCategory(str, Enum):
|
||||
@@ -40,15 +42,15 @@ class RoomStatus(str, Enum):
|
||||
PLAYING = "playing"
|
||||
|
||||
|
||||
class PlaylistItem(BaseModel):
|
||||
class PlaylistItem(UTCBaseModel):
|
||||
id: int | None
|
||||
owner_id: int
|
||||
ruleset_id: int
|
||||
expired: bool
|
||||
playlist_order: int | None
|
||||
played_at: datetime | None
|
||||
allowed_mods: list[APIMod] = []
|
||||
required_mods: list[APIMod] = []
|
||||
allowed_mods: list[APIMod] = Field(default_factory=list)
|
||||
required_mods: list[APIMod] = Field(default_factory=list)
|
||||
beatmap_id: int
|
||||
beatmap: Beatmap | None
|
||||
freestyle: bool
|
||||
@@ -75,7 +77,7 @@ class PlaylistAggregateScore(BaseModel):
|
||||
playlist_item_attempts: list[ItemAttemptsCount]
|
||||
|
||||
|
||||
class Room(BaseModel):
|
||||
class Room(UTCBaseModel):
|
||||
id: int | None
|
||||
name: str = ""
|
||||
password: str | None
|
||||
@@ -86,9 +88,9 @@ class Room(BaseModel):
|
||||
starts_at: datetime | None
|
||||
ends_at: datetime | None
|
||||
participant_count: int = 0
|
||||
recent_participants: list[User] = []
|
||||
recent_participants: list[User] = Field(default_factory=list)
|
||||
max_attempts: int | None
|
||||
playlist: list[PlaylistItem] = []
|
||||
playlist: list[PlaylistItem] = Field(default_factory=list)
|
||||
playlist_item_stats: RoomPlaylistItemStats | None
|
||||
difficulty_range: RoomDifficultyRange | None
|
||||
type: MatchType = MatchType.PLAYLISTS
|
||||
|
||||
@@ -2,15 +2,11 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .score import GameMode
|
||||
from .model import UTCBaseModel
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.database import LazerUserAchievement, Team
|
||||
|
||||
|
||||
class PlayStyle(str, Enum):
|
||||
MOUSE = "mouse"
|
||||
@@ -77,24 +73,7 @@ class MonthlyPlaycount(BaseModel):
|
||||
count: int
|
||||
|
||||
|
||||
class UserAchievement(BaseModel):
|
||||
achieved_at: datetime
|
||||
achievement_id: int
|
||||
|
||||
# 添加数据库模型转换方法
|
||||
def to_db_model(self, user_id: int) -> "LazerUserAchievement":
|
||||
from app.database import (
|
||||
LazerUserAchievement,
|
||||
)
|
||||
|
||||
return LazerUserAchievement(
|
||||
user_id=user_id,
|
||||
achievement_id=self.achievement_id,
|
||||
achieved_at=self.achieved_at,
|
||||
)
|
||||
|
||||
|
||||
class RankHighest(BaseModel):
|
||||
class RankHighest(UTCBaseModel):
|
||||
rank: int
|
||||
updated_at: datetime
|
||||
|
||||
@@ -104,111 +83,6 @@ class RankHistory(BaseModel):
|
||||
data: list[int]
|
||||
|
||||
|
||||
class DailyChallengeStats(BaseModel):
|
||||
daily_streak_best: int = 0
|
||||
daily_streak_current: int = 0
|
||||
last_update: datetime | None = None
|
||||
last_weekly_streak: datetime | None = None
|
||||
playcount: int = 0
|
||||
top_10p_placements: int = 0
|
||||
top_50p_placements: int = 0
|
||||
user_id: int
|
||||
weekly_streak_best: int = 0
|
||||
weekly_streak_current: int = 0
|
||||
|
||||
|
||||
class Page(BaseModel):
|
||||
html: str = ""
|
||||
raw: str = ""
|
||||
|
||||
|
||||
class User(BaseModel):
|
||||
# 基本信息
|
||||
id: int
|
||||
username: str
|
||||
avatar_url: str
|
||||
country_code: str
|
||||
default_group: str = "default"
|
||||
is_active: bool = True
|
||||
is_bot: bool = False
|
||||
is_deleted: bool = False
|
||||
is_online: bool = True
|
||||
is_supporter: bool = False
|
||||
is_restricted: bool = False
|
||||
last_visit: datetime | None = None
|
||||
pm_friends_only: bool = False
|
||||
profile_colour: str | None = None
|
||||
|
||||
# 个人资料
|
||||
cover_url: str | None = None
|
||||
discord: str | None = None
|
||||
has_supported: bool = False
|
||||
interests: str | None = None
|
||||
join_date: datetime
|
||||
location: str | None = None
|
||||
max_blocks: int = 100
|
||||
max_friends: int = 500
|
||||
occupation: str | None = None
|
||||
playmode: GameMode = GameMode.OSU
|
||||
playstyle: list[PlayStyle] = []
|
||||
post_count: int = 0
|
||||
profile_hue: int | None = None
|
||||
profile_order: list[str] = [
|
||||
"me",
|
||||
"recent_activity",
|
||||
"top_ranks",
|
||||
"medals",
|
||||
"historical",
|
||||
"beatmaps",
|
||||
"kudosu",
|
||||
]
|
||||
title: str | None = None
|
||||
title_url: str | None = None
|
||||
twitter: str | None = None
|
||||
website: str | None = None
|
||||
session_verified: bool = False
|
||||
support_level: int = 0
|
||||
|
||||
# 关联对象
|
||||
country: Country
|
||||
cover: Cover
|
||||
kudosu: Kudosu
|
||||
statistics: Statistics
|
||||
statistics_rulesets: dict[str, Statistics]
|
||||
|
||||
# 计数信息
|
||||
beatmap_playcounts_count: int = 0
|
||||
comments_count: int = 0
|
||||
favourite_beatmapset_count: int = 0
|
||||
follower_count: int = 0
|
||||
graveyard_beatmapset_count: int = 0
|
||||
guest_beatmapset_count: int = 0
|
||||
loved_beatmapset_count: int = 0
|
||||
mapping_follower_count: int = 0
|
||||
nominated_beatmapset_count: int = 0
|
||||
pending_beatmapset_count: int = 0
|
||||
ranked_beatmapset_count: int = 0
|
||||
ranked_and_approved_beatmapset_count: int = 0
|
||||
unranked_beatmapset_count: int = 0
|
||||
scores_best_count: int = 0
|
||||
scores_first_count: int = 0
|
||||
scores_pinned_count: int = 0
|
||||
scores_recent_count: int = 0
|
||||
|
||||
# 历史数据
|
||||
account_history: list[dict] = []
|
||||
active_tournament_banner: dict | None = None
|
||||
active_tournament_banners: list[dict] = []
|
||||
badges: list[dict] = []
|
||||
current_season_stats: dict | None = None
|
||||
daily_challenge_user_stats: DailyChallengeStats | None = None
|
||||
groups: list[dict] = []
|
||||
monthly_playcounts: list[MonthlyPlaycount] = []
|
||||
page: Page = Page()
|
||||
previous_usernames: list[str] = []
|
||||
rank_highest: RankHighest | None = None
|
||||
rank_history: RankHistory | None = None
|
||||
rankHistory: RankHistory | None = None # 兼容性别名
|
||||
replays_watched_counts: list[dict] = []
|
||||
team: "Team | None" = None
|
||||
user_achievements: list[UserAchievement] = []
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import re
|
||||
|
||||
from app.auth import (
|
||||
@@ -12,17 +12,21 @@ from app.auth import (
|
||||
store_token,
|
||||
)
|
||||
from app.config import settings
|
||||
from app.database import User as DBUser
|
||||
from app.database import DailyChallengeStats, User
|
||||
from app.database.statistics import UserStatistics
|
||||
from app.dependencies import get_db
|
||||
from app.log import logger
|
||||
from app.models.oauth import (
|
||||
OAuthErrorResponse,
|
||||
RegistrationRequestErrors,
|
||||
TokenResponse,
|
||||
UserRegistrationErrors,
|
||||
)
|
||||
from app.models.score import GameMode
|
||||
|
||||
from fastapi import APIRouter, Depends, Form
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy import text
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
@@ -110,12 +114,12 @@ async def register_user(
|
||||
email_errors = validate_email(user_email)
|
||||
password_errors = validate_password(user_password)
|
||||
|
||||
result = await db.exec(select(DBUser).where(DBUser.name == user_username))
|
||||
result = await db.exec(select(User).where(User.username == user_username))
|
||||
existing_user = result.first()
|
||||
if existing_user:
|
||||
username_errors.append("Username is already taken")
|
||||
|
||||
result = await db.exec(select(DBUser).where(DBUser.email == user_email))
|
||||
result = await db.exec(select(User).where(User.email == user_email))
|
||||
existing_email = result.first()
|
||||
if existing_email:
|
||||
email_errors.append("Email is already taken")
|
||||
@@ -135,119 +139,41 @@ async def register_user(
|
||||
|
||||
try:
|
||||
# 创建新用户
|
||||
from datetime import datetime
|
||||
import time
|
||||
# 确保 AUTO_INCREMENT 值从3开始(ID=1是BanchoBot,ID=2预留给ppy)
|
||||
result = await db.execute( # pyright: ignore[reportDeprecated]
|
||||
text(
|
||||
"SELECT AUTO_INCREMENT FROM information_schema.TABLES "
|
||||
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'lazer_users'"
|
||||
)
|
||||
)
|
||||
next_id = result.one()[0]
|
||||
if next_id <= 2:
|
||||
await db.execute(text("ALTER TABLE lazer_users AUTO_INCREMENT = 3"))
|
||||
await db.commit()
|
||||
|
||||
new_user = DBUser(
|
||||
name=user_username,
|
||||
safe_name=user_username.lower(), # 安全用户名(小写)
|
||||
new_user = User(
|
||||
username=user_username,
|
||||
email=user_email,
|
||||
pw_bcrypt=get_password_hash(user_password),
|
||||
priv=1, # 普通用户权限
|
||||
country="CN", # 默认国家
|
||||
creation_time=int(time.time()),
|
||||
latest_activity=int(time.time()),
|
||||
preferred_mode=0, # 默认模式
|
||||
play_style=0, # 默认游戏风格
|
||||
country_code="CN", # 默认国家
|
||||
join_date=datetime.now(UTC),
|
||||
last_visit=datetime.now(UTC),
|
||||
)
|
||||
|
||||
db.add(new_user)
|
||||
await db.commit()
|
||||
await db.refresh(new_user)
|
||||
|
||||
# 保存用户ID,因为会话可能会关闭
|
||||
user_id = new_user.id
|
||||
|
||||
if user_id <= 2:
|
||||
await db.rollback()
|
||||
try:
|
||||
from sqlalchemy import text
|
||||
|
||||
# 确保 AUTO_INCREMENT 值从3开始(ID=1是BanchoBot,ID=2预留给ppy)
|
||||
await db.execute(text("ALTER TABLE users AUTO_INCREMENT = 3"))
|
||||
await db.commit()
|
||||
|
||||
# 重新创建用户
|
||||
new_user = DBUser(
|
||||
name=user_username,
|
||||
safe_name=user_username.lower(),
|
||||
email=user_email,
|
||||
pw_bcrypt=get_password_hash(user_password),
|
||||
priv=1,
|
||||
country="CN",
|
||||
creation_time=int(time.time()),
|
||||
latest_activity=int(time.time()),
|
||||
preferred_mode=0,
|
||||
play_style=0,
|
||||
)
|
||||
|
||||
db.add(new_user)
|
||||
await db.commit()
|
||||
await db.refresh(new_user)
|
||||
user_id = new_user.id
|
||||
|
||||
# 最终检查ID是否有效
|
||||
if user_id <= 2:
|
||||
await db.rollback()
|
||||
errors = RegistrationRequestErrors(
|
||||
message=(
|
||||
"Failed to create account with valid ID. "
|
||||
"Please contact support."
|
||||
)
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=500, content={"form_error": errors.model_dump()}
|
||||
)
|
||||
|
||||
except Exception as fix_error:
|
||||
await db.rollback()
|
||||
print(f"Failed to fix AUTO_INCREMENT: {fix_error}")
|
||||
errors = RegistrationRequestErrors(
|
||||
message="Failed to create account with valid ID. Please try again."
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=500, content={"form_error": errors.model_dump()}
|
||||
)
|
||||
|
||||
# 创建默认的 lazer_profile
|
||||
from app.database.user import LazerUserProfile
|
||||
|
||||
lazer_profile = LazerUserProfile(
|
||||
user_id=user_id,
|
||||
is_active=True,
|
||||
is_bot=False,
|
||||
is_deleted=False,
|
||||
is_online=True,
|
||||
is_supporter=False,
|
||||
is_restricted=False,
|
||||
session_verified=False,
|
||||
has_supported=False,
|
||||
pm_friends_only=False,
|
||||
default_group="default",
|
||||
join_date=datetime.utcnow(),
|
||||
playmode="osu",
|
||||
support_level=0,
|
||||
max_blocks=50,
|
||||
max_friends=250,
|
||||
post_count=0,
|
||||
)
|
||||
|
||||
db.add(lazer_profile)
|
||||
assert new_user.id is not None, "New user ID should not be None"
|
||||
for i in GameMode:
|
||||
statistics = UserStatistics(mode=i, user_id=new_user.id)
|
||||
db.add(statistics)
|
||||
daily_challenge_user_stats = DailyChallengeStats(user_id=new_user.id)
|
||||
db.add(daily_challenge_user_stats)
|
||||
await db.commit()
|
||||
|
||||
# 返回成功响应
|
||||
return JSONResponse(
|
||||
status_code=201,
|
||||
content={"message": "Account created successfully", "user_id": user_id},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
# 打印详细错误信息用于调试
|
||||
print(f"Registration error: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
logger.exception(f"Registration error for user {user_username}")
|
||||
|
||||
# 返回通用错误
|
||||
errors = RegistrationRequestErrors(
|
||||
@@ -323,6 +249,7 @@ async def oauth_token(
|
||||
refresh_token_str = generate_refresh_token()
|
||||
|
||||
# 存储令牌
|
||||
assert user.id
|
||||
await store_token(
|
||||
db,
|
||||
user.id,
|
||||
|
||||
@@ -5,12 +5,7 @@ import hashlib
|
||||
import json
|
||||
|
||||
from app.calculator import calculate_beatmap_attribute
|
||||
from app.database import (
|
||||
Beatmap,
|
||||
BeatmapResp,
|
||||
User as DBUser,
|
||||
)
|
||||
from app.database.beatmapset import Beatmapset
|
||||
from app.database import Beatmap, BeatmapResp, Beatmapset, User
|
||||
from app.dependencies.database import get_db, get_redis
|
||||
from app.dependencies.fetcher import get_fetcher
|
||||
from app.dependencies.user import get_current_user
|
||||
@@ -39,7 +34,7 @@ async def lookup_beatmap(
|
||||
id: int | None = Query(default=None, alias="id"),
|
||||
md5: str | None = Query(default=None, alias="checksum"),
|
||||
filename: str | None = Query(default=None, alias="filename"),
|
||||
current_user: DBUser = Depends(get_current_user),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
fetcher: Fetcher = Depends(get_fetcher),
|
||||
):
|
||||
@@ -62,7 +57,7 @@ async def lookup_beatmap(
|
||||
@router.get("/beatmaps/{bid}", tags=["beatmap"], response_model=BeatmapResp)
|
||||
async def get_beatmap(
|
||||
bid: int,
|
||||
current_user: DBUser = Depends(get_current_user),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
fetcher: Fetcher = Depends(get_fetcher),
|
||||
):
|
||||
@@ -81,7 +76,7 @@ class BatchGetResp(BaseModel):
|
||||
@router.get("/beatmaps/", tags=["beatmap"], response_model=BatchGetResp)
|
||||
async def batch_get_beatmaps(
|
||||
b_ids: list[int] = Query(alias="id", default_factory=list),
|
||||
current_user: DBUser = Depends(get_current_user),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if not b_ids:
|
||||
@@ -126,7 +121,7 @@ async def batch_get_beatmaps(
|
||||
)
|
||||
async def get_beatmap_attributes(
|
||||
beatmap: int,
|
||||
current_user: DBUser = Depends(get_current_user),
|
||||
current_user: User = Depends(get_current_user),
|
||||
mods: list[str] = Query(default_factory=list),
|
||||
ruleset: GameMode | None = Query(default=None),
|
||||
ruleset_id: int | None = Query(default=None),
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
from app.database import (
|
||||
Beatmapset,
|
||||
BeatmapsetResp,
|
||||
User as DBUser,
|
||||
User,
|
||||
)
|
||||
from app.dependencies.database import get_db
|
||||
from app.dependencies.fetcher import get_fetcher
|
||||
@@ -22,7 +22,7 @@ from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
@router.get("/beatmapsets/{sid}", tags=["beatmapset"], response_model=BeatmapsetResp)
|
||||
async def get_beatmapset(
|
||||
sid: int,
|
||||
current_user: DBUser = Depends(get_current_user),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
fetcher: Fetcher = Depends(get_fetcher),
|
||||
):
|
||||
|
||||
@@ -1,28 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from app.database import (
|
||||
User as DBUser,
|
||||
)
|
||||
from app.database import User, UserResp
|
||||
from app.dependencies import get_current_user
|
||||
from app.models.user import (
|
||||
User as ApiUser,
|
||||
)
|
||||
from app.utils import convert_db_user_to_api_user
|
||||
from app.dependencies.database import get_db
|
||||
from app.models.score import GameMode
|
||||
|
||||
from .api_router import router
|
||||
|
||||
from fastapi import Depends
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
|
||||
@router.get("/me/{ruleset}", response_model=ApiUser)
|
||||
@router.get("/me/", response_model=ApiUser)
|
||||
@router.get("/me/{ruleset}", response_model=UserResp)
|
||||
@router.get("/me/", response_model=UserResp)
|
||||
async def get_user_info_default(
|
||||
ruleset: Literal["osu", "taiko", "fruits", "mania"] = "osu",
|
||||
current_user: DBUser = Depends(get_current_user),
|
||||
ruleset: GameMode | None = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取当前用户信息(默认使用osu模式)"""
|
||||
# 默认使用osu模式
|
||||
api_user = await convert_db_user_to_api_user(current_user, ruleset)
|
||||
return api_user
|
||||
return await UserResp.from_db(
|
||||
current_user,
|
||||
session,
|
||||
[
|
||||
"friends",
|
||||
"team",
|
||||
"account_history",
|
||||
"daily_challenge_user_stats",
|
||||
"statistics",
|
||||
"statistics_rulesets",
|
||||
"achievements",
|
||||
],
|
||||
ruleset,
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ from app.dependencies.user import get_current_user
|
||||
from .api_router import router
|
||||
|
||||
from fastapi import Depends, HTTPException, Query, Request
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import joinedload
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
@@ -36,7 +37,11 @@ async def get_relationship(
|
||||
return [await RelationshipResp.from_db(db, rel) for rel in relationships]
|
||||
|
||||
|
||||
@router.post("/friends", tags=["relationship"], response_model=RelationshipResp)
|
||||
class AddFriendResp(BaseModel):
|
||||
user_relation: RelationshipResp
|
||||
|
||||
|
||||
@router.post("/friends", tags=["relationship"], response_model=AddFriendResp)
|
||||
@router.post("/blocks", tags=["relationship"])
|
||||
async def add_relationship(
|
||||
request: Request,
|
||||
@@ -98,7 +103,9 @@ async def add_relationship(
|
||||
)
|
||||
).first()
|
||||
assert relationship, "Relationship should exist after commit"
|
||||
return await RelationshipResp.from_db(db, relationship)
|
||||
return AddFriendResp(
|
||||
user_relation=await RelationshipResp.from_db(db, relationship)
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/friends/{target}", tags=["relationship"])
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.database import (
|
||||
User as DBUser,
|
||||
)
|
||||
from app.database.beatmap import Beatmap
|
||||
from app.database.score import Score, ScoreResp, process_score, process_user
|
||||
from app.database.score_token import ScoreToken, ScoreTokenResp
|
||||
from app.database import Beatmap, Score, ScoreResp, ScoreToken, ScoreTokenResp, User
|
||||
from app.database.score import process_score, process_user
|
||||
from app.dependencies.database import get_db, get_redis
|
||||
from app.dependencies.fetcher import get_fetcher
|
||||
from app.dependencies.user import get_current_user
|
||||
@@ -41,7 +37,7 @@ async def get_beatmap_scores(
|
||||
mode: GameMode | None = Query(None),
|
||||
# mods: List[APIMod] = Query(None), # TODO:加入指定MOD的查询
|
||||
type: str = Query(None),
|
||||
current_user: DBUser = Depends(get_current_user),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if legacy_only:
|
||||
@@ -94,7 +90,7 @@ async def get_user_beatmap_score(
|
||||
legacy_only: bool = Query(None),
|
||||
mode: str = Query(None),
|
||||
mods: str = Query(None), # TODO:添加mods筛选
|
||||
current_user: DBUser = Depends(get_current_user),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if legacy_only:
|
||||
@@ -134,7 +130,7 @@ async def get_user_all_beatmap_scores(
|
||||
user: int,
|
||||
legacy_only: bool = Query(None),
|
||||
ruleset: str = Query(None),
|
||||
current_user: DBUser = Depends(get_current_user),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if legacy_only:
|
||||
@@ -166,9 +162,10 @@ async def create_solo_score(
|
||||
version_hash: str = Form(""),
|
||||
beatmap_hash: str = Form(),
|
||||
ruleset_id: int = Form(..., ge=0, le=3),
|
||||
current_user: DBUser = Depends(get_current_user),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
assert current_user.id
|
||||
async with db:
|
||||
score_token = ScoreToken(
|
||||
user_id=current_user.id,
|
||||
@@ -190,7 +187,7 @@ async def submit_solo_score(
|
||||
beatmap: int,
|
||||
token: int,
|
||||
info: SoloScoreSubmissionInfo,
|
||||
current_user: DBUser = Depends(get_current_user),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
redis: Redis = Depends(get_redis),
|
||||
fetcher=Depends(get_fetcher),
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from app.database import User as DBUser
|
||||
from app.database import User, UserResp
|
||||
from app.dependencies.database import get_db
|
||||
from app.models.score import INT_TO_MODE
|
||||
from app.models.user import User as ApiUser
|
||||
from app.utils import convert_db_user_to_api_user
|
||||
from app.models.score import GameMode
|
||||
|
||||
from .api_router import router
|
||||
|
||||
@@ -17,28 +13,17 @@ from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
from sqlmodel.sql.expression import col
|
||||
|
||||
|
||||
# ---------- Shared Utility ----------
|
||||
async def get_user_by_lookup(
|
||||
db: AsyncSession, lookup: str, key: str = "id"
|
||||
) -> DBUser | None:
|
||||
"""根据查找方式获取用户"""
|
||||
if key == "id":
|
||||
try:
|
||||
user_id = int(lookup)
|
||||
result = await db.exec(select(DBUser).where(DBUser.id == user_id))
|
||||
return result.first()
|
||||
except ValueError:
|
||||
return None
|
||||
elif key == "username":
|
||||
result = await db.exec(select(DBUser).where(DBUser.name == lookup))
|
||||
return result.first()
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
# ---------- Batch Users ----------
|
||||
class BatchUserResponse(BaseModel):
|
||||
users: list[ApiUser]
|
||||
users: list[UserResp]
|
||||
|
||||
|
||||
SEARCH_INCLUDE = [
|
||||
"team",
|
||||
"daily_challenge_user_stats",
|
||||
"statistics",
|
||||
"statistics_rulesets",
|
||||
"achievements",
|
||||
]
|
||||
|
||||
|
||||
@router.get("/users", response_model=BatchUserResponse)
|
||||
@@ -52,74 +37,54 @@ async def get_users(
|
||||
if user_ids:
|
||||
searched_users = (
|
||||
await session.exec(
|
||||
DBUser.all_select_clause().limit(50).where(col(DBUser.id).in_(user_ids))
|
||||
select(User)
|
||||
.options(*User.all_select_option())
|
||||
.limit(50)
|
||||
.where(col(User.id).in_(user_ids))
|
||||
)
|
||||
).all()
|
||||
else:
|
||||
searched_users = (
|
||||
await session.exec(DBUser.all_select_clause().limit(50))
|
||||
await session.exec(
|
||||
select(User).options(*User.all_select_option()).limit(50)
|
||||
)
|
||||
).all()
|
||||
return BatchUserResponse(
|
||||
users=[
|
||||
await convert_db_user_to_api_user(
|
||||
searched_user, ruleset=INT_TO_MODE[searched_user.preferred_mode].value
|
||||
await UserResp.from_db(
|
||||
searched_user,
|
||||
session,
|
||||
include=SEARCH_INCLUDE,
|
||||
)
|
||||
for searched_user in searched_users
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
# # ---------- Individual User ----------
|
||||
# @router.get("/users/{user_lookup}/{mode}", response_model=ApiUser)
|
||||
# @router.get("/users/{user_lookup}/{mode}/", response_model=ApiUser)
|
||||
# async def get_user_with_mode(
|
||||
# user_lookup: str,
|
||||
# mode: Literal["osu", "taiko", "fruits", "mania"],
|
||||
# key: Literal["id", "username"] = Query("id"),
|
||||
# current_user: DBUser = Depends(get_current_user),
|
||||
# db: AsyncSession = Depends(get_db),
|
||||
# ):
|
||||
# """获取指定游戏模式的用户信息"""
|
||||
# user = await get_user_by_lookup(db, user_lookup, key)
|
||||
# if not user:
|
||||
# raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# return await convert_db_user_to_api_user(user, mode)
|
||||
|
||||
|
||||
# @router.get("/users/{user_lookup}", response_model=ApiUser)
|
||||
# @router.get("/users/{user_lookup}/", response_model=ApiUser)
|
||||
# async def get_user_default(
|
||||
# user_lookup: str,
|
||||
# key: Literal["id", "username"] = Query("id"),
|
||||
# current_user: DBUser = Depends(get_current_user),
|
||||
# db: AsyncSession = Depends(get_db),
|
||||
# ):
|
||||
# """获取用户信息(默认使用osu模式,但包含所有模式的统计信息)"""
|
||||
# user = await get_user_by_lookup(db, user_lookup, key)
|
||||
# if not user:
|
||||
# raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# return await convert_db_user_to_api_user(user, "osu")
|
||||
|
||||
|
||||
@router.get("/users/{user}/{ruleset}", response_model=ApiUser)
|
||||
@router.get("/users/{user}/", response_model=ApiUser)
|
||||
@router.get("/users/{user}", response_model=ApiUser)
|
||||
@router.get("/users/{user}/{ruleset}", response_model=UserResp)
|
||||
@router.get("/users/{user}/", response_model=UserResp)
|
||||
@router.get("/users/{user}", response_model=UserResp)
|
||||
async def get_user_info(
|
||||
user: str,
|
||||
ruleset: Literal["osu", "taiko", "fruits", "mania"] = "osu",
|
||||
ruleset: GameMode | None = None,
|
||||
session: AsyncSession = Depends(get_db),
|
||||
):
|
||||
searched_user = (
|
||||
await session.exec(
|
||||
DBUser.all_select_clause().where(
|
||||
DBUser.id == int(user)
|
||||
select(User)
|
||||
.options(*User.all_select_option())
|
||||
.where(
|
||||
User.id == int(user)
|
||||
if user.isdigit()
|
||||
else DBUser.name == user.removeprefix("@")
|
||||
else User.username == user.removeprefix("@")
|
||||
)
|
||||
)
|
||||
).first()
|
||||
if not searched_user:
|
||||
raise HTTPException(404, detail="User not found")
|
||||
return await convert_db_user_to_api_user(searched_user, ruleset=ruleset)
|
||||
return await UserResp.from_db(
|
||||
searched_user,
|
||||
session,
|
||||
include=SEARCH_INCLUDE,
|
||||
ruleset=ruleset,
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ import asyncio
|
||||
from collections.abc import Coroutine
|
||||
from typing import override
|
||||
|
||||
from app.database.relationship import Relationship, RelationshipType
|
||||
from app.database import Relationship, RelationshipType
|
||||
from app.dependencies.database import engine
|
||||
from app.models.metadata_hub import MetadataClientState, OnlineStatus, UserActivity
|
||||
|
||||
|
||||
@@ -7,10 +7,9 @@ import struct
|
||||
import time
|
||||
from typing import override
|
||||
|
||||
from app.database import Beatmap
|
||||
from app.database import Beatmap, User
|
||||
from app.database.score import Score
|
||||
from app.database.score_token import ScoreToken
|
||||
from app.database.user import User
|
||||
from app.dependencies.database import engine
|
||||
from app.models.beatmap import BeatmapRankStatus
|
||||
from app.models.mods import mods_to_int
|
||||
@@ -197,7 +196,7 @@ class SpectatorHub(Hub[StoreClientState]):
|
||||
).first()
|
||||
if not user:
|
||||
return
|
||||
name = user.name
|
||||
name = user.username
|
||||
store.state = state
|
||||
store.beatmap_status = beatmap.beatmap_status
|
||||
store.checksum = beatmap.checksum
|
||||
@@ -339,7 +338,7 @@ class SpectatorHub(Hub[StoreClientState]):
|
||||
async with AsyncSession(engine) as session:
|
||||
async with session.begin():
|
||||
username = (
|
||||
await session.exec(select(User.name).where(User.id == user_id))
|
||||
await session.exec(select(User.username).where(User.id == user_id))
|
||||
).first()
|
||||
if not username:
|
||||
return
|
||||
|
||||
@@ -6,7 +6,7 @@ import time
|
||||
from typing import Literal
|
||||
import uuid
|
||||
|
||||
from app.database import User as DBUser
|
||||
from app.database import User
|
||||
from app.dependencies import get_current_user
|
||||
from app.dependencies.database import get_db
|
||||
from app.dependencies.user import get_current_user_by_token
|
||||
@@ -25,7 +25,7 @@ router = APIRouter()
|
||||
async def negotiate(
|
||||
hub: Literal["spectator", "multiplayer", "metadata"],
|
||||
negotiate_version: int = Query(1, alias="negotiateVersion"),
|
||||
user: DBUser = Depends(get_current_user),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
connectionId = str(user.id)
|
||||
connectionToken = f"{connectionId}:{uuid.uuid4()}"
|
||||
|
||||
459
app/utils.py
459
app/utils.py
@@ -1,465 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from app.database import (
|
||||
LazerUserCounts,
|
||||
LazerUserProfile,
|
||||
LazerUserStatistics,
|
||||
User as DBUser,
|
||||
)
|
||||
from app.models.user import (
|
||||
Country,
|
||||
Cover,
|
||||
DailyChallengeStats,
|
||||
GradeCounts,
|
||||
Kudosu,
|
||||
Level,
|
||||
Page,
|
||||
RankHighest,
|
||||
RankHistory,
|
||||
Statistics,
|
||||
User,
|
||||
UserAchievement,
|
||||
)
|
||||
|
||||
|
||||
def unix_timestamp_to_windows(timestamp: int) -> int:
|
||||
"""Convert a Unix timestamp to a Windows timestamp."""
|
||||
return (timestamp + 62135596800) * 10_000_000
|
||||
|
||||
|
||||
async def convert_db_user_to_api_user(db_user: DBUser, ruleset: str = "osu") -> User:
|
||||
"""将数据库用户模型转换为API用户模型(使用 Lazer 表)"""
|
||||
|
||||
# 从db_user获取基本字段值
|
||||
user_id = getattr(db_user, "id")
|
||||
user_name = getattr(db_user, "name")
|
||||
user_country = getattr(db_user, "country")
|
||||
user_country_code = user_country # 在User模型中,country字段就是country_code
|
||||
|
||||
# 获取 Lazer 用户资料
|
||||
profile = db_user.lazer_profile
|
||||
if not profile:
|
||||
# 如果没有 lazer 资料,使用默认值
|
||||
profile = LazerUserProfile(
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
# 获取 Lazer 用户计数 - 使用正确的 lazer_counts 关系
|
||||
lzrcnt = db_user.lazer_counts
|
||||
|
||||
if not lzrcnt:
|
||||
# 如果没有 lazer 计数,使用默认值
|
||||
lzrcnt = LazerUserCounts(user_id=user_id)
|
||||
|
||||
# 获取指定模式的统计信息
|
||||
user_stats = None
|
||||
if db_user.lazer_statistics:
|
||||
for stat in db_user.lazer_statistics:
|
||||
if stat.mode == ruleset:
|
||||
user_stats = stat
|
||||
break
|
||||
|
||||
if not user_stats:
|
||||
# 如果没有找到指定模式的统计,创建默认统计
|
||||
user_stats = LazerUserStatistics(user_id=user_id)
|
||||
|
||||
# 获取国家信息
|
||||
country_code = db_user.country_code if db_user.country_code is not None else "XX"
|
||||
|
||||
country = Country(code=str(country_code), name=get_country_name(str(country_code)))
|
||||
|
||||
# 获取 Kudosu 信息
|
||||
kudosu = Kudosu(available=0, total=0)
|
||||
|
||||
# 获取计数信息
|
||||
# counts = LazerUserCounts(user_id=user_id)
|
||||
|
||||
# 转换统计信息
|
||||
statistics = Statistics(
|
||||
count_100=user_stats.count_100,
|
||||
count_300=user_stats.count_300,
|
||||
count_50=user_stats.count_50,
|
||||
count_miss=user_stats.count_miss,
|
||||
level=Level(
|
||||
current=user_stats.level_current, progress=user_stats.level_progress
|
||||
),
|
||||
global_rank=user_stats.global_rank,
|
||||
global_rank_exp=user_stats.global_rank_exp,
|
||||
pp=float(user_stats.pp) if user_stats.pp else 0.0,
|
||||
pp_exp=float(user_stats.pp_exp) if user_stats.pp_exp else 0.0,
|
||||
ranked_score=user_stats.ranked_score,
|
||||
hit_accuracy=float(user_stats.hit_accuracy) if user_stats.hit_accuracy else 0.0,
|
||||
play_count=user_stats.play_count,
|
||||
play_time=user_stats.play_time,
|
||||
total_score=user_stats.total_score,
|
||||
total_hits=user_stats.total_hits,
|
||||
maximum_combo=user_stats.maximum_combo,
|
||||
replays_watched_by_others=user_stats.replays_watched_by_others,
|
||||
is_ranked=user_stats.is_ranked,
|
||||
grade_counts=GradeCounts(
|
||||
ss=user_stats.grade_ss,
|
||||
ssh=user_stats.grade_ssh,
|
||||
s=user_stats.grade_s,
|
||||
sh=user_stats.grade_sh,
|
||||
a=user_stats.grade_a,
|
||||
),
|
||||
country_rank=user_stats.country_rank,
|
||||
rank={"country": user_stats.country_rank} if user_stats.country_rank else None,
|
||||
)
|
||||
|
||||
# 转换所有模式的统计信息
|
||||
statistics_rulesets = {}
|
||||
if db_user.lazer_statistics:
|
||||
for stat in db_user.lazer_statistics:
|
||||
statistics_rulesets[stat.mode] = Statistics(
|
||||
count_100=stat.count_100,
|
||||
count_300=stat.count_300,
|
||||
count_50=stat.count_50,
|
||||
count_miss=stat.count_miss,
|
||||
level=Level(current=stat.level_current, progress=stat.level_progress),
|
||||
global_rank=stat.global_rank,
|
||||
global_rank_exp=stat.global_rank_exp,
|
||||
pp=float(stat.pp) if stat.pp else 0.0,
|
||||
pp_exp=float(stat.pp_exp) if stat.pp_exp else 0.0,
|
||||
ranked_score=stat.ranked_score,
|
||||
hit_accuracy=float(stat.hit_accuracy) if stat.hit_accuracy else 0.0,
|
||||
play_count=stat.play_count,
|
||||
play_time=stat.play_time,
|
||||
total_score=stat.total_score,
|
||||
total_hits=stat.total_hits,
|
||||
maximum_combo=stat.maximum_combo,
|
||||
replays_watched_by_others=stat.replays_watched_by_others,
|
||||
is_ranked=stat.is_ranked,
|
||||
grade_counts=GradeCounts(
|
||||
ss=stat.grade_ss,
|
||||
ssh=stat.grade_ssh,
|
||||
s=stat.grade_s,
|
||||
sh=stat.grade_sh,
|
||||
a=stat.grade_a,
|
||||
),
|
||||
country_rank=stat.country_rank,
|
||||
rank={"country": stat.country_rank} if stat.country_rank else None,
|
||||
)
|
||||
|
||||
# 转换国家信息
|
||||
country = Country(code=user_country_code, name=get_country_name(user_country_code))
|
||||
|
||||
# 转换封面信息
|
||||
cover_url = (
|
||||
profile.cover_url
|
||||
if profile and profile.cover_url
|
||||
else "https://assets.ppy.sh/user-profile-covers/default.jpeg"
|
||||
)
|
||||
cover = Cover(
|
||||
custom_url=profile.cover_url if profile else None, url=str(cover_url), id=None
|
||||
)
|
||||
|
||||
# 转换 Kudosu 信息
|
||||
kudosu = Kudosu(available=0, total=0)
|
||||
|
||||
# 转换成就信息
|
||||
user_achievements = []
|
||||
if db_user.lazer_achievements:
|
||||
for achievement in db_user.lazer_achievements:
|
||||
user_achievements.append(
|
||||
UserAchievement(
|
||||
achieved_at=achievement.achieved_at,
|
||||
achievement_id=achievement.achievement_id,
|
||||
)
|
||||
)
|
||||
|
||||
# 转换排名历史
|
||||
rank_history = None
|
||||
rank_history_data = None
|
||||
for rh in db_user.rank_history:
|
||||
if rh.mode == ruleset:
|
||||
rank_history_data = rh.rank_data
|
||||
break
|
||||
|
||||
if rank_history_data:
|
||||
rank_history = RankHistory(mode=ruleset, data=rank_history_data)
|
||||
|
||||
# 转换每日挑战统计
|
||||
# daily_challenge_stats = None
|
||||
# if db_user.daily_challenge_stats:
|
||||
# dcs = db_user.daily_challenge_stats
|
||||
# daily_challenge_stats = DailyChallengeStats(
|
||||
# daily_streak_best=dcs.daily_streak_best,
|
||||
# daily_streak_current=dcs.daily_streak_current,
|
||||
# last_update=dcs.last_update,
|
||||
# last_weekly_streak=dcs.last_weekly_streak,
|
||||
# playcount=dcs.playcount,
|
||||
# top_10p_placements=dcs.top_10p_placements,
|
||||
# top_50p_placements=dcs.top_50p_placements,
|
||||
# user_id=dcs.user_id,
|
||||
# weekly_streak_best=dcs.weekly_streak_best,
|
||||
# weekly_streak_current=dcs.weekly_streak_current,
|
||||
# )
|
||||
|
||||
# 转换最高排名
|
||||
rank_highest = None
|
||||
if user_stats.rank_highest:
|
||||
rank_highest = RankHighest(
|
||||
rank=user_stats.rank_highest,
|
||||
updated_at=user_stats.rank_highest_updated_at or datetime.utcnow(),
|
||||
)
|
||||
|
||||
# 转换团队信息
|
||||
team = None
|
||||
if db_user.team_membership:
|
||||
team_member = db_user.team_membership # 假设用户只属于一个团队
|
||||
team = team_member.team
|
||||
|
||||
# 创建用户对象
|
||||
# 从db_user获取基本字段值
|
||||
user_id = getattr(db_user, "id")
|
||||
user_name = getattr(db_user, "name")
|
||||
user_country = getattr(db_user, "country")
|
||||
|
||||
# 获取用户头像URL
|
||||
avatar_url = None
|
||||
|
||||
# 首先检查 profile 中的 avatar_url
|
||||
if profile and hasattr(profile, "avatar_url") and profile.avatar_url:
|
||||
avatar_url = str(profile.avatar_url)
|
||||
|
||||
# 然后检查是否有关联的头像记录
|
||||
if avatar_url is None and hasattr(db_user, "avatar") and db_user.avatar is not None:
|
||||
if db_user.avatar.r2_game_url:
|
||||
# 优先使用游戏用的头像URL
|
||||
avatar_url = str(db_user.avatar.r2_game_url)
|
||||
elif db_user.avatar.r2_original_url:
|
||||
# 其次使用原始头像URL
|
||||
avatar_url = str(db_user.avatar.r2_original_url)
|
||||
|
||||
# 如果还是没有找到,通过查询获取
|
||||
# if db_session and avatar_url is None:
|
||||
# try:
|
||||
# # 导入UserAvatar模型
|
||||
|
||||
# # 尝试查找用户的头像记录
|
||||
# statement = select(UserAvatar).where(
|
||||
# UserAvatar.user_id == user_id, UserAvatar.is_active == True
|
||||
# )
|
||||
# avatar_record = db_session.exec(statement).first()
|
||||
# if avatar_record is not None:
|
||||
# if avatar_record.r2_game_url is not None:
|
||||
# # 优先使用游戏用的头像URL
|
||||
# avatar_url = str(avatar_record.r2_game_url)
|
||||
# elif avatar_record.r2_original_url is not None:
|
||||
# # 其次使用原始头像URL
|
||||
# avatar_url = str(avatar_record.r2_original_url)
|
||||
# except Exception as e:
|
||||
# print(f"获取用户头像时出错: {e}")
|
||||
# print(f"最终头像URL: {avatar_url}")
|
||||
# 如果仍然没有找到头像URL,则使用默认URL
|
||||
if avatar_url is None:
|
||||
avatar_url = "https://a.gu-osu.gmoe.cc/api/users/avatar/1"
|
||||
|
||||
# 处理 profile_order 列表排序
|
||||
profile_order = [
|
||||
"me",
|
||||
"recent_activity",
|
||||
"top_ranks",
|
||||
"medals",
|
||||
"historical",
|
||||
"beatmaps",
|
||||
"kudosu",
|
||||
]
|
||||
if profile and profile.profile_order:
|
||||
profile_order = profile.profile_order.split(",")
|
||||
|
||||
# 在convert_db_user_to_api_user函数中添加active_tournament_banners处理
|
||||
active_tournament_banners = []
|
||||
if db_user.active_banners:
|
||||
for banner in db_user.active_banners:
|
||||
active_tournament_banners.append(
|
||||
{
|
||||
"tournament_id": banner.tournament_id,
|
||||
"image_url": banner.image_url,
|
||||
"is_active": banner.is_active,
|
||||
}
|
||||
)
|
||||
|
||||
# 在convert_db_user_to_api_user函数中添加badges处理
|
||||
badges = []
|
||||
if db_user.lazer_badges:
|
||||
for badge in db_user.lazer_badges:
|
||||
badges.append(
|
||||
{
|
||||
"badge_id": badge.badge_id,
|
||||
"awarded_at": badge.awarded_at,
|
||||
"description": badge.description,
|
||||
"image_url": badge.image_url,
|
||||
"url": badge.url,
|
||||
}
|
||||
)
|
||||
|
||||
# 在convert_db_user_to_api_user函数中添加monthly_playcounts处理
|
||||
monthly_playcounts = []
|
||||
if db_user.lazer_monthly_playcounts:
|
||||
for playcount in db_user.lazer_monthly_playcounts:
|
||||
monthly_playcounts.append(
|
||||
{
|
||||
"start_date": playcount.start_date.isoformat()
|
||||
if playcount.start_date
|
||||
else None,
|
||||
"play_count": playcount.play_count,
|
||||
}
|
||||
)
|
||||
|
||||
# 在convert_db_user_to_api_user函数中添加previous_usernames处理
|
||||
previous_usernames = []
|
||||
if db_user.lazer_previous_usernames:
|
||||
for username in db_user.lazer_previous_usernames:
|
||||
previous_usernames.append(
|
||||
{
|
||||
"username": username.username,
|
||||
"changed_at": username.changed_at.isoformat()
|
||||
if username.changed_at
|
||||
else None,
|
||||
}
|
||||
)
|
||||
|
||||
# 在convert_db_user_to_api_user函数中添加replays_watched_counts处理
|
||||
replays_watched_counts = []
|
||||
if hasattr(db_user, "lazer_replays_watched") and db_user.lazer_replays_watched:
|
||||
for replay in db_user.lazer_replays_watched:
|
||||
replays_watched_counts.append(
|
||||
{
|
||||
"start_date": replay.start_date.isoformat()
|
||||
if replay.start_date
|
||||
else None,
|
||||
"count": replay.count,
|
||||
}
|
||||
)
|
||||
|
||||
# 创建用户对象
|
||||
user = User(
|
||||
id=user_id,
|
||||
username=user_name,
|
||||
avatar_url=avatar_url,
|
||||
country_code=str(country_code),
|
||||
default_group=profile.default_group if profile else "default",
|
||||
is_active=profile.is_active,
|
||||
is_bot=profile.is_bot,
|
||||
is_deleted=profile.is_deleted,
|
||||
is_online=profile.is_online,
|
||||
is_supporter=profile.is_supporter,
|
||||
is_restricted=profile.is_restricted,
|
||||
last_visit=db_user.last_visit,
|
||||
pm_friends_only=profile.pm_friends_only,
|
||||
profile_colour=profile.profile_colour,
|
||||
cover_url=profile.cover_url
|
||||
if profile and profile.cover_url
|
||||
else "https://assets.ppy.sh/user-profile-covers/default.jpeg",
|
||||
discord=profile.discord if profile else None,
|
||||
has_supported=profile.has_supported if profile else False,
|
||||
interests=profile.interests if profile else None,
|
||||
join_date=profile.join_date if profile.join_date else datetime.now(UTC),
|
||||
location=profile.location if profile else None,
|
||||
max_blocks=profile.max_blocks if profile and profile.max_blocks else 100,
|
||||
max_friends=profile.max_friends if profile and profile.max_friends else 500,
|
||||
post_count=profile.post_count if profile and profile.post_count else 0,
|
||||
profile_hue=profile.profile_hue if profile and profile.profile_hue else None,
|
||||
profile_order=profile_order, # 使用排序后的 profile_order
|
||||
title=profile.title if profile else None,
|
||||
title_url=profile.title_url if profile else None,
|
||||
twitter=profile.twitter if profile else None,
|
||||
website=profile.website if profile else None,
|
||||
session_verified=True,
|
||||
support_level=profile.support_level if profile else 0,
|
||||
country=country,
|
||||
cover=cover,
|
||||
kudosu=kudosu,
|
||||
statistics=statistics,
|
||||
statistics_rulesets=statistics_rulesets,
|
||||
beatmap_playcounts_count=lzrcnt.beatmap_playcounts_count if lzrcnt else 0,
|
||||
comments_count=lzrcnt.comments_count if lzrcnt else 0,
|
||||
favourite_beatmapset_count=lzrcnt.favourite_beatmapset_count if lzrcnt else 0,
|
||||
follower_count=lzrcnt.follower_count if lzrcnt else 0,
|
||||
graveyard_beatmapset_count=lzrcnt.graveyard_beatmapset_count if lzrcnt else 0,
|
||||
guest_beatmapset_count=lzrcnt.guest_beatmapset_count if lzrcnt else 0,
|
||||
loved_beatmapset_count=lzrcnt.loved_beatmapset_count if lzrcnt else 0,
|
||||
mapping_follower_count=lzrcnt.mapping_follower_count if lzrcnt else 0,
|
||||
nominated_beatmapset_count=lzrcnt.nominated_beatmapset_count if lzrcnt else 0,
|
||||
pending_beatmapset_count=lzrcnt.pending_beatmapset_count if lzrcnt else 0,
|
||||
ranked_beatmapset_count=lzrcnt.ranked_beatmapset_count if lzrcnt else 0,
|
||||
ranked_and_approved_beatmapset_count=lzrcnt.ranked_and_approved_beatmapset_count
|
||||
if lzrcnt
|
||||
else 0,
|
||||
unranked_beatmapset_count=lzrcnt.unranked_beatmapset_count if lzrcnt else 0,
|
||||
scores_best_count=lzrcnt.scores_best_count if lzrcnt else 0,
|
||||
scores_first_count=lzrcnt.scores_first_count if lzrcnt else 0,
|
||||
scores_pinned_count=lzrcnt.scores_pinned_count,
|
||||
scores_recent_count=lzrcnt.scores_recent_count if lzrcnt else 0,
|
||||
account_history=[], # TODO: 获取用户历史账户信息
|
||||
# active_tournament_banner=len(active_tournament_banners),
|
||||
active_tournament_banners=active_tournament_banners,
|
||||
badges=badges,
|
||||
current_season_stats=None,
|
||||
daily_challenge_user_stats=DailyChallengeStats(
|
||||
user_id=user_id,
|
||||
daily_streak_best=db_user.daily_challenge_stats.daily_streak_best
|
||||
if db_user.daily_challenge_stats
|
||||
else 0,
|
||||
daily_streak_current=db_user.daily_challenge_stats.daily_streak_current
|
||||
if db_user.daily_challenge_stats
|
||||
else 0,
|
||||
last_update=db_user.daily_challenge_stats.last_update
|
||||
if db_user.daily_challenge_stats
|
||||
else None,
|
||||
last_weekly_streak=db_user.daily_challenge_stats.last_weekly_streak
|
||||
if db_user.daily_challenge_stats
|
||||
else None,
|
||||
playcount=db_user.daily_challenge_stats.playcount
|
||||
if db_user.daily_challenge_stats
|
||||
else 0,
|
||||
top_10p_placements=db_user.daily_challenge_stats.top_10p_placements
|
||||
if db_user.daily_challenge_stats
|
||||
else 0,
|
||||
top_50p_placements=db_user.daily_challenge_stats.top_50p_placements
|
||||
if db_user.daily_challenge_stats
|
||||
else 0,
|
||||
weekly_streak_best=db_user.daily_challenge_stats.weekly_streak_best
|
||||
if db_user.daily_challenge_stats
|
||||
else 0,
|
||||
weekly_streak_current=db_user.daily_challenge_stats.weekly_streak_current
|
||||
if db_user.daily_challenge_stats
|
||||
else 0,
|
||||
),
|
||||
groups=[],
|
||||
monthly_playcounts=monthly_playcounts,
|
||||
page=Page(html=profile.page_html or "", raw=profile.page_raw or "")
|
||||
if profile.page_html or profile.page_raw
|
||||
else Page(),
|
||||
previous_usernames=previous_usernames,
|
||||
rank_highest=rank_highest,
|
||||
rank_history=rank_history,
|
||||
rankHistory=rank_history,
|
||||
replays_watched_counts=replays_watched_counts,
|
||||
team=team,
|
||||
user_achievements=user_achievements,
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
def get_country_name(country_code: str) -> str:
|
||||
"""根据国家代码获取国家名称"""
|
||||
country_names = {
|
||||
"CN": "China",
|
||||
"JP": "Japan",
|
||||
"US": "United States",
|
||||
"GB": "United Kingdom",
|
||||
"DE": "Germany",
|
||||
"FR": "France",
|
||||
"KR": "South Korea",
|
||||
"CA": "Canada",
|
||||
"AU": "Australia",
|
||||
"BR": "Brazil",
|
||||
# 可以添加更多国家
|
||||
}
|
||||
return country_names.get(country_code, "Unknown")
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
osu! API 模拟服务器的示例数据填充脚本
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
import random
|
||||
|
||||
from app.auth import get_password_hash
|
||||
from app.database import (
|
||||
User,
|
||||
)
|
||||
from app.database.beatmap import Beatmap
|
||||
from app.database.beatmapset import Beatmapset
|
||||
from app.database.score import Score
|
||||
from app.dependencies.database import create_tables, engine
|
||||
from app.models.beatmap import BeatmapRankStatus, Genre, Language
|
||||
from app.models.mods import APIMod
|
||||
from app.models.score import GameMode, Rank
|
||||
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
|
||||
async def create_sample_user():
|
||||
"""创建示例用户数据"""
|
||||
async with AsyncSession(engine) as session:
|
||||
async with session.begin():
|
||||
# 检查用户是否已存在
|
||||
result = await session.exec(select(User).where(User.name == "Googujiang"))
|
||||
result2 = await session.exec(
|
||||
select(User).where(User.name == "MingxuanGame")
|
||||
)
|
||||
existing_user = result.first()
|
||||
existing_user2 = result2.first()
|
||||
if existing_user is not None and existing_user2 is not None:
|
||||
print("示例用户已存在,跳过创建")
|
||||
return
|
||||
|
||||
# 当前时间戳
|
||||
# current_timestamp = int(time.time())
|
||||
join_timestamp = int(datetime(2019, 11, 29, 17, 23, 13).timestamp())
|
||||
last_visit_timestamp = int(datetime(2025, 7, 18, 16, 31, 29).timestamp())
|
||||
|
||||
# 创建用户
|
||||
user = User(
|
||||
name="Googujiang",
|
||||
safe_name="googujiang", # 安全用户名(小写)
|
||||
email="googujiang@example.com",
|
||||
priv=1, # 默认权限
|
||||
pw_bcrypt=get_password_hash("password123"), # 使用新的哈希方式
|
||||
country="JP",
|
||||
silence_end=0,
|
||||
donor_end=0,
|
||||
creation_time=join_timestamp,
|
||||
latest_activity=last_visit_timestamp,
|
||||
clan_id=0,
|
||||
clan_priv=0,
|
||||
preferred_mode=0, # 0 = osu!
|
||||
play_style=0,
|
||||
custom_badge_name=None,
|
||||
custom_badge_icon=None,
|
||||
userpage_content="「世界に忘れられた」",
|
||||
api_key=None,
|
||||
)
|
||||
user2 = User(
|
||||
name="MingxuanGame",
|
||||
safe_name="mingxuangame", # 安全用户名(小写)
|
||||
email="mingxuangame@example.com",
|
||||
priv=1, # 默认权限
|
||||
pw_bcrypt=get_password_hash("password123"), # 使用新的哈希方式
|
||||
country="US",
|
||||
silence_end=0,
|
||||
donor_end=0,
|
||||
creation_time=join_timestamp,
|
||||
latest_activity=last_visit_timestamp,
|
||||
clan_id=0,
|
||||
clan_priv=0,
|
||||
preferred_mode=0, # 0 = osu!
|
||||
play_style=0,
|
||||
custom_badge_name=None,
|
||||
custom_badge_icon=None,
|
||||
userpage_content="For love and fun!",
|
||||
api_key=None,
|
||||
)
|
||||
|
||||
session.add(user)
|
||||
session.add(user2)
|
||||
print(f"成功创建示例用户: {user.name} (ID: {user.id})")
|
||||
print(f"安全用户名: {user.safe_name}")
|
||||
print(f"邮箱: {user.email}")
|
||||
print(f"国家: {user.country}")
|
||||
print(f"成功创建示例用户: {user2.name} (ID: {user2.id})")
|
||||
print(f"安全用户名: {user2.safe_name}")
|
||||
print(f"邮箱: {user2.email}")
|
||||
print(f"国家: {user2.country}")
|
||||
|
||||
|
||||
async def create_sample_beatmap_data():
|
||||
"""创建示例谱面数据"""
|
||||
async with AsyncSession(engine) as session:
|
||||
async with session.begin():
|
||||
user_id = random.randint(1, 1000)
|
||||
# 检查谱面集是否已存在
|
||||
statement = select(Beatmapset).where(Beatmapset.id == 1)
|
||||
result = await session.exec(statement)
|
||||
existing_beatmapset = result.first()
|
||||
if existing_beatmapset:
|
||||
print("示例谱面集已存在,跳过创建")
|
||||
return existing_beatmapset
|
||||
|
||||
# 创建谱面集
|
||||
beatmapset = Beatmapset(
|
||||
id=1,
|
||||
artist="Example Artist",
|
||||
artist_unicode="Example Artist",
|
||||
covers=None,
|
||||
creator="Googujiang",
|
||||
favourite_count=0,
|
||||
hype_current=0,
|
||||
hype_required=0,
|
||||
nsfw=False,
|
||||
play_count=0,
|
||||
preview_url="",
|
||||
source="",
|
||||
spotlight=False,
|
||||
title="Example Song",
|
||||
title_unicode="Example Song",
|
||||
user_id=user_id,
|
||||
video=False,
|
||||
availability_info=None,
|
||||
download_disabled=False,
|
||||
bpm=180.0,
|
||||
can_be_hyped=False,
|
||||
discussion_locked=False,
|
||||
last_updated=datetime.now(),
|
||||
ranked_date=datetime.now(),
|
||||
storyboard=False,
|
||||
submitted_date=datetime.now(),
|
||||
current_nominations=[],
|
||||
beatmap_status=BeatmapRankStatus.RANKED,
|
||||
beatmap_genre=Genre.ANY, # 使用整数表示Genre枚举
|
||||
beatmap_language=Language.ANY, # 使用整数表示Language枚举
|
||||
nominations_required=0,
|
||||
nominations_current=0,
|
||||
pack_tags=[],
|
||||
ratings=[],
|
||||
)
|
||||
session.add(beatmapset)
|
||||
|
||||
# 创建谱面
|
||||
beatmap = Beatmap(
|
||||
id=1,
|
||||
url="",
|
||||
mode=GameMode.OSU,
|
||||
beatmapset_id=1,
|
||||
difficulty_rating=5.5,
|
||||
beatmap_status=BeatmapRankStatus.RANKED,
|
||||
total_length=195,
|
||||
user_id=user_id,
|
||||
version="Example Difficulty",
|
||||
checksum="example_checksum",
|
||||
current_user_playcount=0,
|
||||
max_combo=1200,
|
||||
ar=9.0,
|
||||
cs=4.0,
|
||||
drain=5.0,
|
||||
accuracy=8.0,
|
||||
bpm=180.0,
|
||||
count_circles=1000,
|
||||
count_sliders=200,
|
||||
count_spinners=1,
|
||||
deleted_at=None,
|
||||
hit_length=180,
|
||||
last_updated=datetime.now(),
|
||||
passcount=10,
|
||||
playcount=50,
|
||||
)
|
||||
session.add(beatmap)
|
||||
|
||||
# 创建成绩
|
||||
score = Score(
|
||||
id=1,
|
||||
accuracy=0.9876,
|
||||
map_md5="example_checksum",
|
||||
user_id=1,
|
||||
best_id=1,
|
||||
build_id=None,
|
||||
classic_total_score=1234567,
|
||||
ended_at=datetime.now(),
|
||||
has_replay=True,
|
||||
max_combo=1100,
|
||||
mods=[
|
||||
APIMod(acronym="HD", settings={}),
|
||||
APIMod(acronym="DT", settings={}),
|
||||
],
|
||||
passed=True,
|
||||
playlist_item_id=None,
|
||||
pp=250.5,
|
||||
preserve=True,
|
||||
rank=Rank.S,
|
||||
room_id=None,
|
||||
gamemode=GameMode.OSU,
|
||||
started_at=datetime.now(),
|
||||
total_score=1234567,
|
||||
type="solo_score",
|
||||
position=None,
|
||||
beatmap_id=1,
|
||||
n300=950,
|
||||
n100=30,
|
||||
n50=20,
|
||||
nmiss=5,
|
||||
ngeki=150,
|
||||
nkatu=50,
|
||||
nlarge_tick_miss=None,
|
||||
nslider_tail_hit=None,
|
||||
)
|
||||
session.add(score)
|
||||
|
||||
print(f"成功创建示例谱面集: {beatmapset.title} (ID: {beatmapset.id})")
|
||||
print(f"成功创建示例谱面: {beatmap.version} (ID: {beatmap.id})")
|
||||
print(f"成功创建示例成绩: ID {score.id}")
|
||||
return beatmapset
|
||||
|
||||
|
||||
async def main():
|
||||
print("开始创建示例数据...")
|
||||
await create_tables()
|
||||
await create_sample_user()
|
||||
await create_sample_beatmap_data()
|
||||
print("示例数据创建完成!")
|
||||
# print(f"用户名: {user.name}")
|
||||
# print("密码: password123")
|
||||
# print("现在您可以使用这些凭据来测试API了。")
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
4
main.py
4
main.py
@@ -4,16 +4,12 @@ from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
|
||||
from app.config import settings
|
||||
from app.database import Team # noqa: F401
|
||||
from app.dependencies.database import create_tables, engine
|
||||
from app.dependencies.fetcher import get_fetcher
|
||||
from app.models.user import User
|
||||
from app.router import api_router, auth_router, fetcher_router, signalr_router
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
User.model_rebuild()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
"""score: remove best_id in database
|
||||
|
||||
Revision ID: 78be13c71791
|
||||
Revises: dc4d25c428c7
|
||||
Create Date: 2025-07-29 07:57:33.764517
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import mysql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "78be13c71791"
|
||||
down_revision: str | Sequence[str] | None = "dc4d25c428c7"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column("scores", "best_id")
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column(
|
||||
"scores",
|
||||
sa.Column("best_id", mysql.INTEGER(), autoincrement=False, nullable=True),
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
@@ -1,36 +0,0 @@
|
||||
"""score: add nlarge_tick_hit & nsmall_tick_hit for pp calculator
|
||||
|
||||
Revision ID: dc4d25c428c7
|
||||
Revises:
|
||||
Create Date: 2025-07-29 01:43:40.221070
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "dc4d25c428c7"
|
||||
down_revision: str | Sequence[str] | None = None
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column("scores", sa.Column("nlarge_tick_hit", sa.Integer(), nullable=True))
|
||||
op.add_column("scores", sa.Column("nsmall_tick_hit", sa.Integer(), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column("scores", "nsmall_tick_hit")
|
||||
op.drop_column("scores", "nlarge_tick_hit")
|
||||
# ### end Alembic commands ###
|
||||
Reference in New Issue
Block a user