From 9ce99398abce3bf7c4bd05246b0d30f958576b21 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Wed, 30 Jul 2025 16:17:09 +0000 Subject: [PATCH] refactor(user): refactor user database MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Breaking Change** 用户表变为 lazer_users 建议删除与用户关联的表进行迁移 --- README.md | 423 +++++++------- app/auth.py | 8 +- app/database/__init__.py | 64 +-- app/database/achievement.py | 40 ++ app/database/auth.py | 8 +- app/database/beatmap.py | 3 +- app/database/beatmapset.py | 3 +- app/database/best_score.py | 4 +- app/database/daily_challenge.py | 58 ++ app/database/lazer_user.py | 300 ++++++++++ app/database/legacy.py | 94 ---- app/database/relationship.py | 26 +- app/database/score.py | 41 +- app/database/score_token.py | 11 +- app/database/statistics.py | 95 ++++ app/database/team.py | 10 +- app/database/user.py | 527 ------------------ app/database/user_account_history.py | 45 ++ app/dependencies/user.py | 13 +- app/models/model.py | 15 + app/models/room.py | 18 +- app/models/user.py | 130 +---- app/router/auth.py | 139 ++--- app/router/beatmap.py | 15 +- app/router/beatmapset.py | 4 +- app/router/me.py | 40 +- app/router/relationship.py | 11 +- app/router/score.py | 19 +- app/router/user.py | 111 ++-- app/signalr/hub/metadata.py | 2 +- app/signalr/hub/spectator.py | 7 +- app/signalr/router.py | 4 +- app/utils.py | 459 --------------- create_sample_data.py | 242 -------- main.py | 4 - ...c71791_score_remove_best_id_in_database.py | 38 -- ..._score_add_nlarge_tick_hit_nsmall_tick_.py | 36 -- 37 files changed, 994 insertions(+), 2073 deletions(-) create mode 100644 app/database/achievement.py create mode 100644 app/database/daily_challenge.py create mode 100644 app/database/lazer_user.py delete mode 100644 app/database/legacy.py create mode 100644 app/database/statistics.py delete mode 100644 app/database/user.py create mode 100644 app/database/user_account_history.py create mode 100644 app/models/model.py delete mode 100644 create_sample_data.py delete mode 100644 migrations/versions/78be13c71791_score_remove_best_id_in_database.py delete mode 100644 migrations/versions/dc4d25c428c7_score_add_nlarge_tick_hit_nsmall_tick_.py diff --git a/README.md b/README.md index 267e2b5..a4e1e22 100644 --- a/README.md +++ b/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 -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 +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! diff --git a/app/auth.py b/app/auth.py index 4c690f8..4762662 100644 --- a/app/auth.py +++ b/app/auth.py @@ -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) diff --git a/app/database/__init__.py b/app/database/__init__.py index 191a193..91bc7cc 100644 --- a/app/database/__init__.py +++ b/app/database/__init__.py @@ -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] diff --git a/app/database/achievement.py b/app/database/achievement.py new file mode 100644 index 0000000..4be587f --- /dev/null +++ b/app/database/achievement.py @@ -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) diff --git a/app/database/auth.py b/app/database/auth.py index ae49676..554dced 100644 --- a/app/database/auth.py +++ b/app/database/auth.py @@ -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) diff --git a/app/database/beatmap.py b/app/database/beatmap.py index 48e7fa0..751bc5c 100644 --- a/app/database/beatmap.py +++ b/app/database/beatmap.py @@ -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 diff --git a/app/database/beatmapset.py b/app/database/beatmapset.py index 1e6ba27..2ef6280 100644 --- a/app/database/beatmapset.py +++ b/app/database/beatmapset.py @@ -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) diff --git a/app/database/best_score.py b/app/database/best_score.py index 313da3e..9993b63 100644 --- a/app/database/best_score.py +++ b/app/database/best_score.py @@ -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) diff --git a/app/database/daily_challenge.py b/app/database/daily_challenge.py new file mode 100644 index 0000000..abf874f --- /dev/null +++ b/app/database/daily_challenge.py @@ -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) diff --git a/app/database/lazer_user.py b/app/database/lazer_user.py new file mode 100644 index 0000000..9627015 --- /dev/null +++ b/app/database/lazer_user.py @@ -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 diff --git a/app/database/legacy.py b/app/database/legacy.py deleted file mode 100644 index ff1e957..0000000 --- a/app/database/legacy.py +++ /dev/null @@ -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() diff --git a/app/database/relationship.py b/app/database/relationship.py index 61dc109..07daa25 100644 --- a/app/database/relationship.py +++ b/app/database/relationship.py @@ -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, ) diff --git a/app/database/score.py b/app/database/score.py index 046f83c..c805563 100644 --- a/app/database/score.py +++ b/app/database/score.py @@ -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, diff --git a/app/database/score_token.py b/app/database/score_token.py index 6a6edb3..4467b8b 100644 --- a/app/database/score_token.py +++ b/app/database/score_token.py @@ -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): diff --git a/app/database/statistics.py b/app/database/statistics.py new file mode 100644 index 0000000..cac2971 --- /dev/null +++ b/app/database/statistics.py @@ -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 diff --git a/app/database/team.py b/app/database/team.py index 360e805..146ca9f 100644 --- a/app/database/team.py +++ b/app/database/team.py @@ -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) diff --git a/app/database/user.py b/app/database/user.py deleted file mode 100644 index a188497..0000000 --- a/app/database/user.py +++ /dev/null @@ -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") diff --git a/app/database/user_account_history.py b/app/database/user_account_history.py new file mode 100644 index 0000000..217c8eb --- /dev/null +++ b/app/database/user_account_history.py @@ -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) diff --git a/app/dependencies/user.py b/app/dependencies/user.py index 0c8f8bc..769247c 100644 --- a/app/dependencies/user.py +++ b/app/dependencies/user.py @@ -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 diff --git a/app/models/model.py b/app/models/model.py new file mode 100644 index 0000000..bc00585 --- /dev/null +++ b/app/models/model.py @@ -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 diff --git a/app/models/room.py b/app/models/room.py index 9ca24d2..85aae24 100644 --- a/app/models/room.py +++ b/app/models/room.py @@ -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 diff --git a/app/models/user.py b/app/models/user.py index dd90e47..3052eef 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -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] = [] diff --git a/app/router/auth.py b/app/router/auth.py index 0f41b32..7a2a14d 100644 --- a/app/router/auth.py +++ b/app/router/auth.py @@ -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, diff --git a/app/router/beatmap.py b/app/router/beatmap.py index 71d554f..4af9c9a 100644 --- a/app/router/beatmap.py +++ b/app/router/beatmap.py @@ -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), diff --git a/app/router/beatmapset.py b/app/router/beatmapset.py index db2dd77..e551727 100644 --- a/app/router/beatmapset.py +++ b/app/router/beatmapset.py @@ -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), ): diff --git a/app/router/me.py b/app/router/me.py index 93dcbdc..e3aa734 100644 --- a/app/router/me.py +++ b/app/router/me.py @@ -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, + ) diff --git a/app/router/relationship.py b/app/router/relationship.py index 9ed5b0f..9e39e8b 100644 --- a/app/router/relationship.py +++ b/app/router/relationship.py @@ -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"]) diff --git a/app/router/score.py b/app/router/score.py index cc38dcc..baab3a2 100644 --- a/app/router/score.py +++ b/app/router/score.py @@ -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), diff --git a/app/router/user.py b/app/router/user.py index 6e169c3..cfe136c 100644 --- a/app/router/user.py +++ b/app/router/user.py @@ -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, + ) diff --git a/app/signalr/hub/metadata.py b/app/signalr/hub/metadata.py index 821d831..2712883 100644 --- a/app/signalr/hub/metadata.py +++ b/app/signalr/hub/metadata.py @@ -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 diff --git a/app/signalr/hub/spectator.py b/app/signalr/hub/spectator.py index 0d0899e..f388c92 100644 --- a/app/signalr/hub/spectator.py +++ b/app/signalr/hub/spectator.py @@ -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 diff --git a/app/signalr/router.py b/app/signalr/router.py index 237a575..72b22ac 100644 --- a/app/signalr/router.py +++ b/app/signalr/router.py @@ -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()}" diff --git a/app/utils.py b/app/utils.py index 9008706..09e8fdc 100644 --- a/app/utils.py +++ b/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") diff --git a/create_sample_data.py b/create_sample_data.py deleted file mode 100644 index 5dcd79a..0000000 --- a/create_sample_data.py +++ /dev/null @@ -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()) diff --git a/main.py b/main.py index 526d593..92d4402 100644 --- a/main.py +++ b/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): diff --git a/migrations/versions/78be13c71791_score_remove_best_id_in_database.py b/migrations/versions/78be13c71791_score_remove_best_id_in_database.py deleted file mode 100644 index d0cab2b..0000000 --- a/migrations/versions/78be13c71791_score_remove_best_id_in_database.py +++ /dev/null @@ -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 ### diff --git a/migrations/versions/dc4d25c428c7_score_add_nlarge_tick_hit_nsmall_tick_.py b/migrations/versions/dc4d25c428c7_score_add_nlarge_tick_hit_nsmall_tick_.py deleted file mode 100644 index d90ec3d..0000000 --- a/migrations/versions/dc4d25c428c7_score_add_nlarge_tick_hit_nsmall_tick_.py +++ /dev/null @@ -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 ###