refactor(user): refactor user database

**Breaking Change**

用户表变为 lazer_users

建议删除与用户关联的表进行迁移
This commit is contained in:
MingxuanGame
2025-07-30 16:17:09 +00:00
parent 3900babe3d
commit 9ce99398ab
37 changed files with 994 additions and 2073 deletions

423
README.md
View File

@@ -1,218 +1,205 @@
# osu! API 模拟服务器 # osu! API 模拟服务器
这是一个使用 FastAPI + MySQL + Redis 实现的 osu! API 模拟服务器,提供了完整的用户认证和数据管理功能。 这是一个使用 FastAPI + MySQL + Redis 实现的 osu! API 模拟服务器,提供了完整的用户认证和数据管理功能。
## 功能特性 ## 功能特性
- **OAuth 2.0 认证**: 支持密码流和刷新令牌流 - **OAuth 2.0 认证**: 支持密码流和刷新令牌流
- **用户数据管理**: 完整的用户信息、统计数据、成就等 - **用户数据管理**: 完整的用户信息、统计数据、成就等
- **多游戏模式支持**: osu!, taiko, fruits, mania - **多游戏模式支持**: osu!, taiko, fruits, mania
- **数据库持久化**: MySQL 存储用户数据 - **数据库持久化**: MySQL 存储用户数据
- **缓存支持**: Redis 缓存令牌和会话信息 - **缓存支持**: Redis 缓存令牌和会话信息
- **容器化部署**: Docker 和 Docker Compose 支持 - **容器化部署**: Docker 和 Docker Compose 支持
## API 端点 ## API 端点
### 认证端点 ### 认证端点
- `POST /oauth/token` - OAuth 令牌获取/刷新 - `POST /oauth/token` - OAuth 令牌获取/刷新
### 用户端点 ### 用户端点
- `GET /api/v2/me/{ruleset}` - 获取当前用户信息 - `GET /api/v2/me/{ruleset}` - 获取当前用户信息
### 其他端点 ### 其他端点
- `GET /` - 根端点 - `GET /` - 根端点
- `GET /health` - 健康检查 - `GET /health` - 健康检查
## 快速开始 ## 快速开始
### 使用 Docker Compose (推荐) ### 使用 Docker Compose (推荐)
1. 克隆项目 1. 克隆项目
```bash ```bash
git clone <repository-url> git clone <repository-url>
cd osu_lazer_api cd osu_lazer_api
``` ```
2. 启动服务 2. 启动服务
```bash ```bash
docker-compose up -d docker-compose up -d
``` ```
3. 创建示例数据 3. 创建示例数据
```bash ```bash
docker-compose exec api python create_sample_data.py docker-compose exec api python create_sample_data.py
``` ```
4. 测试 API 4. 测试 API
```bash ```bash
# 获取访问令牌 # 获取访问令牌
curl -X POST http://localhost:8000/oauth/token \ curl -X POST http://localhost:8000/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \ -H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=password&username=Googujiang&password=password123&client_id=5&client_secret=FGc9GAtyHzeQDshWP5Ah7dega8hJACAJpQtw6OXk&scope=*" -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 \ curl -X GET http://localhost:8000/api/v2/me/osu \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" -H "Authorization: Bearer YOUR_ACCESS_TOKEN"
``` ```
### 本地开发 ### 本地开发
1. 安装依赖 1. 安装依赖
```bash ```bash
pip install -r requirements.txt pip install -r requirements.txt
``` ```
2. 配置环境变量 2. 配置环境变量
```bash ```bash
# 复制服务器配置文件 # 复制服务器配置文件
cp .env .env.local cp .env .env.local
# 复制客户端配置文件(用于测试脚本) # 复制客户端配置文件(用于测试脚本)
cp .env.client .env.client.local cp .env.client .env.client.local
``` ```
3. 启动 MySQL 和 Redis 3. 启动 MySQL 和 Redis
```bash ```bash
# 使用 Docker # 使用 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 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 docker run -d --name redis -p 6379:6379 redis:7-alpine
``` ```
4. 创建示例数据
```bash 4. 启动应用
python create_sample_data.py ```bash
``` uvicorn main:app --reload
```
5. 启动应用
```bash ## 项目结构
uvicorn main:app --reload
``` ```
osu_lazer_api/
6. 测试 API ├── app/
```bash │ ├── __init__.py
# 使用测试脚本(会自动加载 .env 文件) │ ├── models.py # Pydantic 数据模型
python test_api.py │ ├── database.py # SQLAlchemy 数据库模型
│ ├── config.py # 配置设置
# 或使用原始示例脚本 │ ├── dependencies.py # 依赖注入
python osu_api_example.py │ ├── auth.py # 认证和令牌管理
``` │ └── utils.py # 工具函数
├── main.py # FastAPI 应用主文件
## 项目结构 ├── create_sample_data.py # 示例数据创建脚本
├── requirements.txt # Python 依赖
``` ├── .env # 环境变量配置
osu_lazer_api/ ├── docker-compose.yml # Docker Compose 配置
├── app/ ├── Dockerfile # Docker 镜像配置
│ ├── __init__.py └── README.md # 项目说明
│ ├── models.py # Pydantic 数据模型 ```
│ ├── database.py # SQLAlchemy 数据库模型
│ ├── config.py # 配置设置 ## 示例用户
│ ├── dependencies.py # 依赖注入
│ ├── auth.py # 认证和令牌管理 创建示例数据后,您可以使用以下凭据进行测试:
│ └── utils.py # 工具函数
├── main.py # FastAPI 应用主文件 - **用户名**: `Googujiang`
├── create_sample_data.py # 示例数据创建脚本 - **密码**: `password123`
├── requirements.txt # Python 依赖 - **用户ID**: `15651670`
├── .env # 环境变量配置
├── docker-compose.yml # Docker Compose 配置 ## 环境变量配置
├── Dockerfile # Docker 镜像配置
└── README.md # 项目说明 项目包含两个环境配置文件:
```
### 服务器配置 (`.env`)
## 示例用户 用于配置 FastAPI 服务器的运行参数:
创建示例数据后,您可以使用以下凭据进行测试: | 变量名 | 描述 | 默认值 |
|--------|------|--------|
- **用户名**: `Googujiang` | `DATABASE_URL` | MySQL 数据库连接字符串 | `mysql+pymysql://root:password@localhost:3306/osu_api` |
- **密码**: `password123` | `REDIS_URL` | Redis 连接字符串 | `redis://localhost:6379/0` |
- **用户ID**: `15651670` | `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` |
### 服务器配置 (`.env`) | `DEBUG` | 调试模式 | `True` |
用于配置 FastAPI 服务器的运行参数:
### 客户端配置 (`.env.client`)
| 变量名 | 描述 | 默认值 | 用于配置客户端脚本的 API 连接参数:
|--------|------|--------|
| `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` | | `OSU_CLIENT_ID` | OAuth 客户端 ID | `5` |
| `ACCESS_TOKEN_EXPIRE_MINUTES` | 访问令牌过期时间(分钟) | `1440` | | `OSU_CLIENT_SECRET` | OAuth 客户端密钥 | `FGc9GAtyHzeQDshWP5Ah7dega8hJACAJpQtw6OXk` |
| `OSU_CLIENT_ID` | OAuth 客户端 ID | `5` | | `OSU_API_URL` | API 服务器地址 | `http://localhost:8000` |
| `OSU_CLIENT_SECRET` | OAuth 客户端密钥 | `FGc9GAtyHzeQDshWP5Ah7dega8hJACAJpQtw6OXk` |
| `HOST` | 服务器监听地址 | `0.0.0.0` | > **注意**: 在生产环境中,请务必更改默认的密钥和密码!
| `PORT` | 服务器监听端口 | `8000` |
| `DEBUG` | 调试模式 | `True` | ## API 使用示例
### 客户端配置 (`.env.client`) ### 获取访问令牌
用于配置客户端脚本的 API 连接参数:
```bash
| 变量名 | 描述 | 默认值 | curl -X POST http://localhost:8000/oauth/token \
|--------|------|--------| -H "Content-Type: application/x-www-form-urlencoded" \
| `OSU_CLIENT_ID` | OAuth 客户端 ID | `5` | -d "grant_type=password&username=Googujiang&password=password123&client_id=5&client_secret=FGc9GAtyHzeQDshWP5Ah7dega8hJACAJpQtw6OXk&scope=*"
| `OSU_CLIENT_SECRET` | OAuth 客户端密钥 | `FGc9GAtyHzeQDshWP5Ah7dega8hJACAJpQtw6OXk` | ```
| `OSU_API_URL` | API 服务器地址 | `http://localhost:8000` |
响应:
> **注意**: 在生产环境中,请务必更改默认的密钥和密码! ```json
{
## API 使用示例 "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
"token_type": "Bearer",
### 获取访问令牌 "expires_in": 86400,
"refresh_token": "abc123...",
```bash "scope": "*"
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=*"
``` ### 获取用户信息
响应: ```bash
```json curl -X GET http://localhost:8000/api/v2/me/osu \
{ -H "Authorization: Bearer YOUR_ACCESS_TOKEN"
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...", ```
"token_type": "Bearer",
"expires_in": 86400, ### 刷新令牌
"refresh_token": "abc123...",
"scope": "*" ```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"
### 获取用户信息 ```
```bash ## 开发
curl -X GET http://localhost:8000/api/v2/me/osu \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" ### 添加新用户
```
您可以通过修改 `create_sample_data.py` 文件来添加更多示例用户,或者扩展 API 来支持用户注册功能。
### 刷新令牌
### 扩展功能
```bash
curl -X POST http://localhost:8000/oauth/token \ - 添加更多 API 端点(排行榜、谱面信息等)
-H "Content-Type: application/x-www-form-urlencoded" \ - 实现实时功能WebSocket
-d "grant_type=refresh_token&refresh_token=YOUR_REFRESH_TOKEN&client_id=5&client_secret=FGc9GAtyHzeQDshWP5Ah7dega8hJACAJpQtw6OXk" - 添加管理面板
``` - 实现数据导入/导出功能
## 开发 ### 迁移数据库
### 添加新用户 参考[数据库迁移指南](./MIGRATE_GUIDE.md)
您可以通过修改 `create_sample_data.py` 文件来添加更多示例用户,或者扩展 API 来支持用户注册功能。 ## 许可证
### 扩展功能 MIT License
- 添加更多 API 端点(排行榜、谱面信息等) ## 贡献
- 实现实时功能WebSocket
- 添加管理面板 欢迎提交 Issue 和 Pull Request
- 实现数据导入/导出功能
### 迁移数据库
参考[数据库迁移指南](./MIGRATE_GUIDE.md)
## 许可证
MIT License
## 贡献
欢迎提交 Issue 和 Pull Request

View File

@@ -8,7 +8,7 @@ import string
from app.config import settings from app.config import settings
from app.database import ( from app.database import (
OAuthToken, OAuthToken,
User as DBUser, User,
) )
from app.log import logger from app.log import logger
@@ -74,7 +74,7 @@ def get_password_hash(password: str) -> str:
async def authenticate_user_legacy( async def authenticate_user_legacy(
db: AsyncSession, name: str, password: str db: AsyncSession, name: str, password: str
) -> DBUser | None: ) -> User | None:
""" """
验证用户身份 - 使用类似 from_login 的逻辑 验证用户身份 - 使用类似 from_login 的逻辑
""" """
@@ -82,7 +82,7 @@ async def authenticate_user_legacy(
pw_md5 = hashlib.md5(password.encode()).hexdigest() pw_md5 = hashlib.md5(password.encode()).hexdigest()
# 2. 根据用户名查找用户 # 2. 根据用户名查找用户
statement = select(DBUser).where(DBUser.name == name) statement = select(User).where(User.username == name)
user = (await db.exec(statement)).first() user = (await db.exec(statement)).first()
if not user: if not user:
return None return None
@@ -113,7 +113,7 @@ async def authenticate_user_legacy(
async def authenticate_user( async def authenticate_user(
db: AsyncSession, username: str, password: str db: AsyncSession, username: str, password: str
) -> DBUser | None: ) -> User | None:
"""验证用户身份""" """验证用户身份"""
return await authenticate_user_legacy(db, username, password) return await authenticate_user_legacy(db, username, password)

View File

@@ -1,3 +1,4 @@
from .achievement import UserAchievement, UserAchievementResp
from .auth import OAuthToken from .auth import OAuthToken
from .beatmap import ( from .beatmap import (
Beatmap as Beatmap, Beatmap as Beatmap,
@@ -8,7 +9,11 @@ from .beatmapset import (
BeatmapsetResp as BeatmapsetResp, BeatmapsetResp as BeatmapsetResp,
) )
from .best_score import BestScore 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 .relationship import Relationship, RelationshipResp, RelationshipType
from .score import ( from .score import (
Score, Score,
@@ -17,29 +22,17 @@ from .score import (
ScoreStatistics, ScoreStatistics,
) )
from .score_token import ScoreToken, ScoreTokenResp from .score_token import ScoreToken, ScoreTokenResp
from .statistics import (
UserStatistics,
UserStatisticsResp,
)
from .team import Team, TeamMember from .team import Team, TeamMember
from .user import ( from .user_account_history import (
DailyChallengeStats, UserAccountHistory,
LazerUserAchievement, UserAccountHistoryResp,
LazerUserBadge, UserAccountHistoryType,
LazerUserBanners,
LazerUserCountry,
LazerUserCounts,
LazerUserKudosu,
LazerUserMonthlyPlaycounts,
LazerUserPreviousUsername,
LazerUserProfile,
LazerUserProfileSections,
LazerUserReplaysWatched,
LazerUserStatistics,
RankHistory,
User,
UserAchievement,
UserAvatar,
) )
BeatmapsetResp.model_rebuild()
BeatmapResp.model_rebuild()
__all__ = [ __all__ = [
"Beatmap", "Beatmap",
"BeatmapResp", "BeatmapResp",
@@ -47,22 +40,8 @@ __all__ = [
"BeatmapsetResp", "BeatmapsetResp",
"BestScore", "BestScore",
"DailyChallengeStats", "DailyChallengeStats",
"LazerUserAchievement", "DailyChallengeStatsResp",
"LazerUserBadge",
"LazerUserBanners",
"LazerUserCountry",
"LazerUserCounts",
"LazerUserKudosu",
"LazerUserMonthlyPlaycounts",
"LazerUserPreviousUsername",
"LazerUserProfile",
"LazerUserProfileSections",
"LazerUserReplaysWatched",
"LazerUserStatistics",
"LegacyOAuthToken",
"LegacyUserStatistics",
"OAuthToken", "OAuthToken",
"RankHistory",
"Relationship", "Relationship",
"RelationshipResp", "RelationshipResp",
"RelationshipType", "RelationshipType",
@@ -75,6 +54,17 @@ __all__ = [
"Team", "Team",
"TeamMember", "TeamMember",
"User", "User",
"UserAccountHistory",
"UserAccountHistoryResp",
"UserAccountHistoryType",
"UserAchievement", "UserAchievement",
"UserAvatar", "UserAchievement",
"UserAchievementResp",
"UserResp",
"UserStatistics",
"UserStatisticsResp",
] ]
for i in __all__:
if i.endswith("Resp"):
globals()[i].model_rebuild() # type: ignore[call-arg]

View File

@@ -0,0 +1,40 @@
from datetime import UTC, datetime
from typing import TYPE_CHECKING
from app.models.model import UTCBaseModel
from sqlmodel import (
BigInteger,
Column,
DateTime,
Field,
ForeignKey,
Relationship,
SQLModel,
)
if TYPE_CHECKING:
from .lazer_user import User
class UserAchievementBase(SQLModel, UTCBaseModel):
achievement_id: int = Field(primary_key=True)
achieved_at: datetime = Field(
default=datetime.now(UTC), sa_column=Column(DateTime(timezone=True))
)
class UserAchievement(UserAchievementBase, table=True):
__tablename__ = "lazer_user_achievements" # pyright: ignore[reportAssignmentType]
id: int | None = Field(default=None, primary_key=True, index=True)
user_id: int = Field(
sa_column=Column(BigInteger, ForeignKey("lazer_users.id")), exclude=True
)
user: "User" = Relationship(back_populates="achievement")
class UserAchievementResp(UserAchievementBase):
@classmethod
def from_db(cls, db_model: UserAchievement) -> "UserAchievementResp":
return cls.model_validate(db_model)

View File

@@ -1,19 +1,21 @@
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from app.models.model import UTCBaseModel
from sqlalchemy import Column, DateTime from sqlalchemy import Column, DateTime
from sqlmodel import BigInteger, Field, ForeignKey, Relationship, SQLModel from sqlmodel import BigInteger, Field, ForeignKey, Relationship, SQLModel
if TYPE_CHECKING: 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] __tablename__ = "oauth_tokens" # pyright: ignore[reportAssignmentType]
id: int | None = Field(default=None, primary_key=True, index=True) id: int | None = Field(default=None, primary_key=True, index=True)
user_id: int = Field( 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) access_token: str = Field(max_length=500, unique=True)
refresh_token: str = Field(max_length=500, unique=True) refresh_token: str = Field(max_length=500, unique=True)

View File

@@ -2,6 +2,7 @@ from datetime import datetime
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from app.models.beatmap import BeatmapRankStatus from app.models.beatmap import BeatmapRankStatus
from app.models.model import UTCBaseModel
from app.models.score import MODE_TO_INT, GameMode from app.models.score import MODE_TO_INT, GameMode
from .beatmapset import Beatmapset, BeatmapsetResp from .beatmapset import Beatmapset, BeatmapsetResp
@@ -20,7 +21,7 @@ class BeatmapOwner(SQLModel):
username: str username: str
class BeatmapBase(SQLModel): class BeatmapBase(SQLModel, UTCBaseModel):
# Beatmap # Beatmap
url: str url: str
mode: GameMode mode: GameMode

View File

@@ -2,6 +2,7 @@ from datetime import datetime
from typing import TYPE_CHECKING, TypedDict, cast from typing import TYPE_CHECKING, TypedDict, cast
from app.models.beatmap import BeatmapRankStatus, Genre, Language from app.models.beatmap import BeatmapRankStatus, Genre, Language
from app.models.model import UTCBaseModel
from app.models.score import GameMode from app.models.score import GameMode
from pydantic import BaseModel, model_serializer from pydantic import BaseModel, model_serializer
@@ -82,7 +83,7 @@ class BeatmapTranslationText(BaseModel):
id: int | None = None id: int | None = None
class BeatmapsetBase(SQLModel): class BeatmapsetBase(SQLModel, UTCBaseModel):
# Beatmapset # Beatmapset
artist: str = Field(index=True) artist: str = Field(index=True)
artist_unicode: str = Field(index=True) artist_unicode: str = Field(index=True)

View File

@@ -2,7 +2,7 @@ from typing import TYPE_CHECKING
from app.models.score import GameMode from app.models.score import GameMode
from .user import User from .lazer_user import User
from sqlmodel import ( from sqlmodel import (
BigInteger, BigInteger,
@@ -22,7 +22,7 @@ if TYPE_CHECKING:
class BestScore(SQLModel, table=True): class BestScore(SQLModel, table=True):
__tablename__ = "best_scores" # pyright: ignore[reportAssignmentType] __tablename__ = "best_scores" # pyright: ignore[reportAssignmentType]
user_id: int = Field( 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( score_id: int = Field(
sa_column=Column(BigInteger, ForeignKey("scores.id"), primary_key=True) sa_column=Column(BigInteger, ForeignKey("scores.id"), primary_key=True)

View File

@@ -0,0 +1,58 @@
from datetime import datetime
from typing import TYPE_CHECKING
from app.models.model import UTCBaseModel
from sqlmodel import (
BigInteger,
Column,
DateTime,
Field,
ForeignKey,
Relationship,
SQLModel,
)
if TYPE_CHECKING:
from .lazer_user import User
class DailyChallengeStatsBase(SQLModel, UTCBaseModel):
daily_streak_best: int = Field(default=0)
daily_streak_current: int = Field(default=0)
last_update: datetime | None = Field(default=None, sa_column=Column(DateTime))
last_weekly_streak: datetime | None = Field(
default=None, sa_column=Column(DateTime)
)
playcount: int = Field(default=0)
top_10p_placements: int = Field(default=0)
top_50p_placements: int = Field(default=0)
weekly_streak_best: int = Field(default=0)
weekly_streak_current: int = Field(default=0)
class DailyChallengeStats(DailyChallengeStatsBase, table=True):
__tablename__ = "daily_challenge_stats" # pyright: ignore[reportAssignmentType]
user_id: int | None = Field(
default=None,
sa_column=Column(
BigInteger,
ForeignKey("lazer_users.id"),
unique=True,
index=True,
primary_key=True,
),
)
user: "User" = Relationship(back_populates="daily_challenge_stats")
class DailyChallengeStatsResp(DailyChallengeStatsBase):
user_id: int
@classmethod
def from_db(
cls,
obj: DailyChallengeStats,
) -> "DailyChallengeStatsResp":
return cls.model_validate(obj)

300
app/database/lazer_user.py Normal file
View File

@@ -0,0 +1,300 @@
from datetime import UTC, datetime
from typing import TYPE_CHECKING, NotRequired, TypedDict
from app.models.model import UTCBaseModel
from app.models.score import GameMode
from app.models.user import Country, Page, RankHistory
from .achievement import UserAchievement, UserAchievementResp
from .daily_challenge import DailyChallengeStats, DailyChallengeStatsResp
from .statistics import UserStatistics, UserStatisticsResp
from .team import Team, TeamMember
from .user_account_history import UserAccountHistory, UserAccountHistoryResp
from sqlalchemy.orm import joinedload, selectinload
from sqlmodel import (
JSON,
BigInteger,
Column,
DateTime,
Field,
Relationship,
SQLModel,
func,
select,
)
from sqlmodel.ext.asyncio.session import AsyncSession
if TYPE_CHECKING:
from app.database.relationship import RelationshipResp
class Kudosu(TypedDict):
available: int
total: int
class RankHighest(TypedDict):
rank: int
updated_at: datetime
class UserProfileCover(TypedDict):
url: str
custom_url: NotRequired[str]
id: NotRequired[str]
Badge = TypedDict(
"Badge",
{
"awarded_at": datetime,
"description": str,
"image@2x_url": str,
"image_url": str,
"url": str,
},
)
class UserBase(UTCBaseModel, SQLModel):
avatar_url: str = ""
country_code: str = Field(default="CN", max_length=2, index=True)
# ? default_group: str|None
is_active: bool = True
is_bot: bool = False
is_supporter: bool = False
last_visit: datetime = Field(
default=datetime.now(UTC), sa_column=Column(DateTime(timezone=True))
)
pm_friends_only: bool = False
profile_colour: str | None = None
username: str = Field(max_length=32, unique=True, index=True)
page: Page = Field(sa_column=Column(JSON), default=Page(html="", raw=""))
previous_usernames: list[str] = Field(default_factory=list, sa_column=Column(JSON))
# TODO: replays_watched_counts
support_level: int = 0
badges: list[Badge] = Field(default_factory=list, sa_column=Column(JSON))
# optional
is_restricted: bool = False
# blocks
cover: UserProfileCover = Field(
default=UserProfileCover(
url="https://assets.ppy.sh/user-profile-covers/default.jpeg"
),
sa_column=Column(JSON),
)
beatmap_playcounts_count: int = 0
# kudosu
# UserExtended
playmode: GameMode = GameMode.OSU
discord: str | None = None
has_supported: bool = False
interests: str | None = None
join_date: datetime = Field(default=datetime.now(UTC))
location: str | None = None
max_blocks: int = 50
max_friends: int = 500
occupation: str | None = None
playstyle: list[str] = Field(default_factory=list, sa_column=Column(JSON))
# TODO: post_count
profile_hue: int | None = None
profile_order: list[str] = Field(
default_factory=lambda: [
"me",
"recent_activity",
"top_ranks",
"medals",
"historical",
"beatmaps",
"kudosu",
],
sa_column=Column(JSON),
)
title: str | None = None
title_url: str | None = None
twitter: str | None = None
website: str | None = None
# undocumented
comments_count: int = 0
post_count: int = 0
is_admin: bool = False
is_gmt: bool = False
is_qat: bool = False
is_bng: bool = False
class User(UserBase, table=True):
__tablename__ = "lazer_users" # pyright: ignore[reportAssignmentType]
id: int | None = Field(
default=None,
sa_column=Column(BigInteger, primary_key=True, autoincrement=True, index=True),
)
account_history: list[UserAccountHistory] = Relationship()
statistics: list[UserStatistics] = Relationship()
achievement: list[UserAchievement] = Relationship(back_populates="user")
team_membership: TeamMember | None = Relationship(back_populates="user")
daily_challenge_stats: DailyChallengeStats | None = Relationship(
back_populates="user"
)
email: str = Field(max_length=254, unique=True, index=True, exclude=True)
priv: int = Field(default=1, exclude=True)
pw_bcrypt: str = Field(max_length=60, exclude=True)
silence_end_at: datetime | None = Field(
default=None, sa_column=Column(DateTime(timezone=True)), exclude=True
)
donor_end_at: datetime | None = Field(
default=None, sa_column=Column(DateTime(timezone=True)), exclude=True
)
@classmethod
def all_select_option(cls):
return (
selectinload(cls.account_history), # pyright: ignore[reportArgumentType]
selectinload(cls.statistics), # pyright: ignore[reportArgumentType]
selectinload(cls.achievement), # pyright: ignore[reportArgumentType]
joinedload(cls.team_membership).joinedload(TeamMember.team), # pyright: ignore[reportArgumentType]
joinedload(cls.daily_challenge_stats), # pyright: ignore[reportArgumentType]
)
class UserResp(UserBase):
id: int | None = None
is_online: bool = True # TODO
groups: list = [] # TODO
country: Country = Field(default_factory=lambda: Country(code="CN", name="China"))
favourite_beatmapset_count: int = 0 # TODO
graveyard_beatmapset_count: int = 0 # TODO
guest_beatmapset_count: int = 0 # TODO
loved_beatmapset_count: int = 0 # TODO
mapping_follower_count: int = 0 # TODO
nominated_beatmapset_count: int = 0 # TODO
pending_beatmapset_count: int = 0 # TODO
ranked_beatmapset_count: int = 0 # TODO
follow_user_mapping: list[int] = Field(default_factory=list)
follower_count: int = 0
friends: list["RelationshipResp"] | None = None
scores_best_count: int = 0
scores_first_count: int = 0
scores_recent_count: int = 0
scores_pinned_count: int = 0
account_history: list[UserAccountHistoryResp] = []
active_tournament_banners: list[dict] = [] # TODO
kudosu: Kudosu = Field(default_factory=lambda: Kudosu(available=0, total=0)) # TODO
monthly_playcounts: list = Field(default_factory=list) # TODO
unread_pm_count: int = 0 # TODO
rank_history: RankHistory | None = None # TODO
rank_highest: RankHighest | None = None # TODO
statistics: UserStatisticsResp | None = None
statistics_rulesets: dict[str, UserStatisticsResp] | None = None
user_achievements: list[UserAchievementResp] = Field(default_factory=list)
cover_url: str = "" # deprecated
team: Team | None = None
session_verified: bool = True
daily_challenge_user_stats: DailyChallengeStatsResp | None = None # TODO
# TODO: monthly_playcounts, unread_pm_count rank_history, user_preferences
@classmethod
async def from_db(
cls,
obj: User,
session: AsyncSession,
include: list[str] = [],
ruleset: GameMode | None = None,
) -> "UserResp":
from .best_score import BestScore
from .relationship import Relationship, RelationshipResp, RelationshipType
u = cls.model_validate(obj.model_dump())
u.id = obj.id
u.follower_count = (
await session.exec(
select(func.count())
.select_from(Relationship)
.where(
Relationship.target_id == obj.id,
Relationship.type == RelationshipType.FOLLOW,
)
)
).one()
u.scores_best_count = (
await session.exec(
select(func.count())
.select_from(BestScore)
.where(
BestScore.user_id == obj.id,
)
.limit(200)
)
).one()
u.cover_url = (
obj.cover.get(
"url", "https://assets.ppy.sh/user-profile-covers/default.jpeg"
)
if obj.cover
else "https://assets.ppy.sh/user-profile-covers/default.jpeg"
)
if "friends" in include:
u.friends = [
await RelationshipResp.from_db(session, r)
for r in (
await session.exec(
select(Relationship)
.options(
joinedload(Relationship.target).options( # pyright: ignore[reportArgumentType]
*User.all_select_option()
)
)
.where(
Relationship.user_id == obj.id,
Relationship.type == RelationshipType.FOLLOW,
)
)
).all()
]
if "team" in include:
if obj.team_membership:
u.team = obj.team_membership.team
if "account_history" in include:
u.account_history = [
UserAccountHistoryResp.from_db(ah) for ah in obj.account_history
]
if "daily_challenge_user_stats":
if obj.daily_challenge_stats:
u.daily_challenge_user_stats = DailyChallengeStatsResp.from_db(
obj.daily_challenge_stats
)
if "statistics" in include:
current_stattistics = None
for i in obj.statistics:
if i.mode == (ruleset or obj.playmode):
current_stattistics = i
break
u.statistics = (
UserStatisticsResp.from_db(current_stattistics)
if current_stattistics
else None
)
if "statistics_rulesets" in include:
u.statistics_rulesets = {
i.mode.value: UserStatisticsResp.from_db(i) for i in obj.statistics
}
if "achievements" in include:
u.user_achievements = [
UserAchievementResp.from_db(ua) for ua in obj.achievement
]
return u

View File

@@ -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()

View File

@@ -1,8 +1,6 @@
from enum import Enum from enum import Enum
from app.models.user import User as APIUser from .lazer_user import User, UserResp
from .user import User as DBUser
from pydantic import BaseModel from pydantic import BaseModel
from sqlmodel import ( from sqlmodel import (
@@ -28,7 +26,7 @@ class Relationship(SQLModel, table=True):
default=None, default=None,
sa_column=Column( sa_column=Column(
BigInteger, BigInteger,
ForeignKey("users.id"), ForeignKey("lazer_users.id"),
primary_key=True, primary_key=True,
index=True, index=True,
), ),
@@ -37,20 +35,20 @@ class Relationship(SQLModel, table=True):
default=None, default=None,
sa_column=Column( sa_column=Column(
BigInteger, BigInteger,
ForeignKey("users.id"), ForeignKey("lazer_users.id"),
primary_key=True, primary_key=True,
index=True, index=True,
), ),
) )
type: RelationshipType = Field(default=RelationshipType.FOLLOW, nullable=False) type: RelationshipType = Field(default=RelationshipType.FOLLOW, nullable=False)
target: DBUser = SQLRelationship( target: User = SQLRelationship(
sa_relationship_kwargs={"foreign_keys": "[Relationship.target_id]"} sa_relationship_kwargs={"foreign_keys": "[Relationship.target_id]"}
) )
class RelationshipResp(BaseModel): class RelationshipResp(BaseModel):
target_id: int target_id: int
target: APIUser target: UserResp
mutual: bool = False mutual: bool = False
type: RelationshipType type: RelationshipType
@@ -58,8 +56,6 @@ class RelationshipResp(BaseModel):
async def from_db( async def from_db(
cls, session: AsyncSession, relationship: Relationship cls, session: AsyncSession, relationship: Relationship
) -> "RelationshipResp": ) -> "RelationshipResp":
from app.utils import convert_db_user_to_api_user
target_relationship = ( target_relationship = (
await session.exec( await session.exec(
select(Relationship).where( select(Relationship).where(
@@ -75,7 +71,17 @@ class RelationshipResp(BaseModel):
) )
return cls( return cls(
target_id=relationship.target_id, 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, mutual=mutual,
type=relationship.type, type=relationship.type,
) )

View File

@@ -12,9 +12,8 @@ from app.calculator import (
calculate_weighted_pp, calculate_weighted_pp,
clamp, clamp,
) )
from app.database.score_token import ScoreToken
from app.database.user import LazerUserStatistics, User
from app.models.beatmap import BeatmapRankStatus 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.mods import APIMod, mods_can_get_pp
from app.models.score import ( from app.models.score import (
INT_TO_MODE, INT_TO_MODE,
@@ -26,11 +25,12 @@ from app.models.score import (
ScoreStatistics, ScoreStatistics,
SoloScoreSubmissionInfo, SoloScoreSubmissionInfo,
) )
from app.models.user import User as APIUser
from .beatmap import Beatmap, BeatmapResp from .beatmap import Beatmap, BeatmapResp
from .beatmapset import Beatmapset, BeatmapsetResp from .beatmapset import Beatmapset, BeatmapsetResp
from .best_score import BestScore from .best_score import BestScore
from .lazer_user import User, UserResp
from .score_token import ScoreToken
from redis import Redis from redis import Redis
from sqlalchemy import Column, ColumnExpressionArgument, DateTime from sqlalchemy import Column, ColumnExpressionArgument, DateTime
@@ -54,7 +54,7 @@ if TYPE_CHECKING:
from app.fetcher import Fetcher from app.fetcher import Fetcher
class ScoreBase(SQLModel): class ScoreBase(SQLModel, UTCBaseModel):
# 基本字段 # 基本字段
accuracy: float accuracy: float
map_md5: str = Field(max_length=32, index=True) map_md5: str = Field(max_length=32, index=True)
@@ -94,7 +94,7 @@ class Score(ScoreBase, table=True):
default=None, default=None,
sa_column=Column( sa_column=Column(
BigInteger, BigInteger,
ForeignKey("users.id"), ForeignKey("lazer_users.id"),
index=True, index=True,
), ),
) )
@@ -112,8 +112,8 @@ class Score(ScoreBase, table=True):
gamemode: GameMode = Field(index=True) gamemode: GameMode = Field(index=True)
# optional # optional
beatmap: "Beatmap" = Relationship() beatmap: Beatmap = Relationship()
user: "User" = Relationship() user: User = Relationship()
@property @property
def is_perfect_combo(self) -> bool: def is_perfect_combo(self) -> bool:
@@ -173,7 +173,7 @@ class ScoreResp(ScoreBase):
ruleset_id: int | None = None ruleset_id: int | None = None
beatmap: BeatmapResp | None = None beatmap: BeatmapResp | None = None
beatmapset: BeatmapsetResp | None = None beatmapset: BeatmapsetResp | None = None
user: APIUser | None = None user: UserResp | None = None
statistics: ScoreStatistics | None = None statistics: ScoreStatistics | None = None
maximum_statistics: ScoreStatistics | None = None maximum_statistics: ScoreStatistics | None = None
rank_global: int | None = None rank_global: int | None = None
@@ -183,8 +183,6 @@ class ScoreResp(ScoreBase):
async def from_db( async def from_db(
cls, session: AsyncSession, score: Score, user: User | None = None cls, session: AsyncSession, score: Score, user: User | None = None
) -> "ScoreResp": ) -> "ScoreResp":
from app.utils import convert_db_user_to_api_user
s = cls.model_validate(score.model_dump()) s = cls.model_validate(score.model_dump())
assert score.id assert score.id
s.beatmap = BeatmapResp.from_db(score.beatmap) s.beatmap = BeatmapResp.from_db(score.beatmap)
@@ -221,7 +219,12 @@ class ScoreResp(ScoreBase):
HitResult.GREAT: score.beatmap.max_combo, HitResult.GREAT: score.beatmap.max_combo,
} }
if user: 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 = ( s.rank_global = (
await get_score_position_by_id( await get_score_position_by_id(
session, session,
@@ -494,21 +497,20 @@ async def get_user_best_pp(
async def process_user( async def process_user(
session: AsyncSession, user: User, score: Score, ranked: bool = False session: AsyncSession, user: User, score: Score, ranked: bool = False
): ):
assert user.id
previous_score_best = await get_user_best_score_in_beatmap( previous_score_best = await get_user_best_score_in_beatmap(
session, score.beatmap_id, user.id, score.gamemode session, score.beatmap_id, user.id, score.gamemode
) )
statistics = None statistics = None
add_to_db = False add_to_db = False
for i in user.lazer_statistics: for i in user.statistics:
if i.mode == score.gamemode.value: if i.mode == score.gamemode.value:
statistics = i statistics = i
break break
if statistics is None: if statistics is None:
statistics = LazerUserStatistics( raise ValueError(
mode=score.gamemode.value, f"User {user.id} does not have statistics for mode {score.gamemode.value}"
user_id=user.id,
) )
add_to_db = True
# pc, pt, tth, tts # pc, pt, tth, tts
statistics.total_score += score.total_score 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.maximum_combo = max(statistics.maximum_combo, score.max_combo)
statistics.play_count += 1 statistics.play_count += 1
statistics.play_time += int((score.ended_at - score.started_at).total_seconds()) 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 += ( statistics.total_hits += (
score.n300 + score.n100 + score.n50 + score.ngeki + score.nkatu score.n300 + score.n100 + score.n50 + score.ngeki + score.nkatu
) )
@@ -564,8 +570,6 @@ async def process_user(
statistics.pp = pp_sum statistics.pp = pp_sum
statistics.hit_accuracy = acc_sum statistics.hit_accuracy = acc_sum
statistics.updated_at = datetime.now(UTC)
if add_to_db: if add_to_db:
session.add(statistics) session.add(statistics)
await session.commit() await session.commit()
@@ -582,6 +586,7 @@ async def process_score(
session: AsyncSession, session: AsyncSession,
redis: Redis, redis: Redis,
) -> Score: ) -> Score:
assert user.id
can_get_pp = info.passed and ranked and mods_can_get_pp(info.ruleset_id, info.mods) can_get_pp = info.passed and ranked and mods_can_get_pp(info.ruleset_id, info.mods)
score = Score( score = Score(
accuracy=info.accuracy, accuracy=info.accuracy,

View File

@@ -1,15 +1,16 @@
from datetime import datetime from datetime import datetime
from app.models.model import UTCBaseModel
from app.models.score import GameMode from app.models.score import GameMode
from .beatmap import Beatmap from .beatmap import Beatmap
from .user import User from .lazer_user import User
from sqlalchemy import Column, DateTime, Index from sqlalchemy import Column, DateTime, Index
from sqlmodel import BigInteger, Field, ForeignKey, Relationship, SQLModel 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) score_id: int | None = Field(sa_column=Column(BigInteger), default=None)
ruleset_id: GameMode ruleset_id: GameMode
playlist_item_id: int | None = Field(default=None) # playlist playlist_item_id: int | None = Field(default=None) # playlist
@@ -34,10 +35,10 @@ class ScoreToken(ScoreTokenBase, table=True):
autoincrement=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") beatmap_id: int = Field(foreign_key="beatmaps.id")
user: "User" = Relationship() user: User = Relationship()
beatmap: "Beatmap" = Relationship() beatmap: Beatmap = Relationship()
class ScoreTokenResp(ScoreTokenBase): class ScoreTokenResp(ScoreTokenBase):

View File

@@ -0,0 +1,95 @@
from typing import TYPE_CHECKING
from app.models.score import GameMode
from sqlmodel import (
BigInteger,
Column,
Field,
ForeignKey,
Relationship,
SQLModel,
)
if TYPE_CHECKING:
from .lazer_user import User
class UserStatisticsBase(SQLModel):
mode: GameMode
count_100: int = Field(default=0, sa_column=Column(BigInteger))
count_300: int = Field(default=0, sa_column=Column(BigInteger))
count_50: int = Field(default=0, sa_column=Column(BigInteger))
count_miss: int = Field(default=0, sa_column=Column(BigInteger))
global_rank: int | None = Field(default=None)
country_rank: int | None = Field(default=None)
pp: float = Field(default=0.0)
ranked_score: int = Field(default=0)
hit_accuracy: float = Field(default=0.00)
total_score: int = Field(default=0, sa_column=Column(BigInteger))
total_hits: int = Field(default=0, sa_column=Column(BigInteger))
maximum_combo: int = Field(default=0)
play_count: int = Field(default=0)
play_time: int = Field(default=0, sa_column=Column(BigInteger))
replays_watched_by_others: int = Field(default=0)
is_ranked: bool = Field(default=True)
class UserStatistics(UserStatisticsBase, table=True):
__tablename__ = "lazer_user_statistics" # pyright: ignore[reportAssignmentType]
id: int | None = Field(default=None, primary_key=True)
user_id: int = Field(
default=None,
sa_column=Column(
BigInteger,
ForeignKey("lazer_users.id"),
index=True,
),
)
grade_ss: int = Field(default=0)
grade_ssh: int = Field(default=0)
grade_s: int = Field(default=0)
grade_sh: int = Field(default=0)
grade_a: int = Field(default=0)
level_current: int = Field(default=1)
level_progress: int = Field(default=0)
user: "User" = Relationship(back_populates="statistics") # type: ignore[valid-type]
class UserStatisticsResp(UserStatisticsBase):
grade_counts: dict[str, int] = Field(
default_factory=lambda: {
"ss": 0,
"ssh": 0,
"s": 0,
"sh": 0,
"a": 0,
}
)
level: dict[str, int] = Field(
default_factory=lambda: {
"current": 1,
"progress": 0,
}
)
@classmethod
def from_db(cls, obj: UserStatistics) -> "UserStatisticsResp":
s = cls.model_validate(obj)
s.grade_counts = {
"ss": obj.grade_ss,
"ssh": obj.grade_ssh,
"s": obj.grade_s,
"sh": obj.grade_sh,
"a": obj.grade_a,
}
s.level = {
"current": obj.level_current,
"progress": obj.level_progress,
}
return s

View File

@@ -1,14 +1,16 @@
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from app.models.model import UTCBaseModel
from sqlalchemy import Column, DateTime from sqlalchemy import Column, DateTime
from sqlmodel import BigInteger, Field, ForeignKey, Relationship, SQLModel from sqlmodel import BigInteger, Field, ForeignKey, Relationship, SQLModel
if TYPE_CHECKING: 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] __tablename__ = "teams" # pyright: ignore[reportAssignmentType]
id: int | None = Field(default=None, primary_key=True, index=True) 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") members: list["TeamMember"] = Relationship(back_populates="team")
class TeamMember(SQLModel, table=True): class TeamMember(SQLModel, UTCBaseModel, table=True):
__tablename__ = "team_members" # pyright: ignore[reportAssignmentType] __tablename__ = "team_members" # pyright: ignore[reportAssignmentType]
id: int | None = Field(default=None, primary_key=True, index=True) 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") team_id: int = Field(foreign_key="teams.id")
joined_at: datetime = Field( joined_at: datetime = Field(
default_factory=datetime.utcnow, sa_column=Column(DateTime) default_factory=datetime.utcnow, sa_column=Column(DateTime)

View File

@@ -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")

View File

@@ -0,0 +1,45 @@
from datetime import UTC, datetime
from enum import Enum
from app.models.model import UTCBaseModel
from sqlmodel import BigInteger, Column, Field, ForeignKey, Integer, SQLModel
class UserAccountHistoryType(str, Enum):
NOTE = "note"
RESTRICTION = "restriction"
SLIENCE = "silence"
TOURNAMENT_BAN = "tournament_ban"
class UserAccountHistoryBase(SQLModel, UTCBaseModel):
description: str | None = None
length: int
permanent: bool = False
timestamp: datetime = Field(default=datetime.now(UTC))
type: UserAccountHistoryType
class UserAccountHistory(UserAccountHistoryBase, table=True):
__tablename__ = "user_account_history" # pyright: ignore[reportAssignmentType]
id: int | None = Field(
sa_column=Column(
Integer,
autoincrement=True,
index=True,
primary_key=True,
)
)
user_id: int = Field(
sa_column=Column(BigInteger, ForeignKey("lazer_users.id"), index=True)
)
class UserAccountHistoryResp(UserAccountHistoryBase):
id: int | None = None
@classmethod
def from_db(cls, db_model: UserAccountHistory) -> "UserAccountHistoryResp":
return cls.model_validate(db_model)

View File

@@ -1,14 +1,13 @@
from __future__ import annotations from __future__ import annotations
from app.auth import get_token_by_access_token from app.auth import get_token_by_access_token
from app.database import ( from app.database import User
User as DBUser,
)
from .database import get_db from .database import get_db
from fastapi import Depends, HTTPException from fastapi import Depends, HTTPException
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
security = HTTPBearer() security = HTTPBearer()
@@ -17,7 +16,7 @@ security = HTTPBearer()
async def get_current_user( async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security), credentials: HTTPAuthorizationCredentials = Depends(security),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> DBUser: ) -> User:
"""获取当前认证用户""" """获取当前认证用户"""
token = credentials.credentials token = credentials.credentials
@@ -27,13 +26,15 @@ async def get_current_user(
return 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) token_record = await get_token_by_access_token(db, token)
if not token_record: if not token_record:
return None return None
user = ( user = (
await db.exec( 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() ).first()
return user return user

15
app/models/model.py Normal file
View File

@@ -0,0 +1,15 @@
from __future__ import annotations
from datetime import UTC, datetime
from pydantic import BaseModel, field_serializer
class UTCBaseModel(BaseModel):
@field_serializer("*", when_used="json")
def serialize_datetime(self, v, _info):
if isinstance(v, datetime):
if v.tzinfo is None:
v = v.replace(tzinfo=UTC)
return v.astimezone(UTC).isoformat()
return v

View File

@@ -3,11 +3,13 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from enum import Enum from enum import Enum
from app.database import User
from app.database.beatmap import Beatmap from app.database.beatmap import Beatmap
from app.database.user import User
from app.models.mods import APIMod from app.models.mods import APIMod
from pydantic import BaseModel from .model import UTCBaseModel
from pydantic import BaseModel, Field
class RoomCategory(str, Enum): class RoomCategory(str, Enum):
@@ -40,15 +42,15 @@ class RoomStatus(str, Enum):
PLAYING = "playing" PLAYING = "playing"
class PlaylistItem(BaseModel): class PlaylistItem(UTCBaseModel):
id: int | None id: int | None
owner_id: int owner_id: int
ruleset_id: int ruleset_id: int
expired: bool expired: bool
playlist_order: int | None playlist_order: int | None
played_at: datetime | None played_at: datetime | None
allowed_mods: list[APIMod] = [] allowed_mods: list[APIMod] = Field(default_factory=list)
required_mods: list[APIMod] = [] required_mods: list[APIMod] = Field(default_factory=list)
beatmap_id: int beatmap_id: int
beatmap: Beatmap | None beatmap: Beatmap | None
freestyle: bool freestyle: bool
@@ -75,7 +77,7 @@ class PlaylistAggregateScore(BaseModel):
playlist_item_attempts: list[ItemAttemptsCount] playlist_item_attempts: list[ItemAttemptsCount]
class Room(BaseModel): class Room(UTCBaseModel):
id: int | None id: int | None
name: str = "" name: str = ""
password: str | None password: str | None
@@ -86,9 +88,9 @@ class Room(BaseModel):
starts_at: datetime | None starts_at: datetime | None
ends_at: datetime | None ends_at: datetime | None
participant_count: int = 0 participant_count: int = 0
recent_participants: list[User] = [] recent_participants: list[User] = Field(default_factory=list)
max_attempts: int | None max_attempts: int | None
playlist: list[PlaylistItem] = [] playlist: list[PlaylistItem] = Field(default_factory=list)
playlist_item_stats: RoomPlaylistItemStats | None playlist_item_stats: RoomPlaylistItemStats | None
difficulty_range: RoomDifficultyRange | None difficulty_range: RoomDifficultyRange | None
type: MatchType = MatchType.PLAYLISTS type: MatchType = MatchType.PLAYLISTS

View File

@@ -2,15 +2,11 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from enum import Enum from enum import Enum
from typing import TYPE_CHECKING
from .score import GameMode from .model import UTCBaseModel
from pydantic import BaseModel from pydantic import BaseModel
if TYPE_CHECKING:
from app.database import LazerUserAchievement, Team
class PlayStyle(str, Enum): class PlayStyle(str, Enum):
MOUSE = "mouse" MOUSE = "mouse"
@@ -77,24 +73,7 @@ class MonthlyPlaycount(BaseModel):
count: int count: int
class UserAchievement(BaseModel): class RankHighest(UTCBaseModel):
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):
rank: int rank: int
updated_at: datetime updated_at: datetime
@@ -104,111 +83,6 @@ class RankHistory(BaseModel):
data: list[int] 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): class Page(BaseModel):
html: str = "" html: str = ""
raw: 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] = []

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import timedelta from datetime import UTC, datetime, timedelta
import re import re
from app.auth import ( from app.auth import (
@@ -12,17 +12,21 @@ from app.auth import (
store_token, store_token,
) )
from app.config import settings 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.dependencies import get_db
from app.log import logger
from app.models.oauth import ( from app.models.oauth import (
OAuthErrorResponse, OAuthErrorResponse,
RegistrationRequestErrors, RegistrationRequestErrors,
TokenResponse, TokenResponse,
UserRegistrationErrors, UserRegistrationErrors,
) )
from app.models.score import GameMode
from fastapi import APIRouter, Depends, Form from fastapi import APIRouter, Depends, Form
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from sqlalchemy import text
from sqlmodel import select from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
@@ -110,12 +114,12 @@ async def register_user(
email_errors = validate_email(user_email) email_errors = validate_email(user_email)
password_errors = validate_password(user_password) 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() existing_user = result.first()
if existing_user: if existing_user:
username_errors.append("Username is already taken") 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() existing_email = result.first()
if existing_email: if existing_email:
email_errors.append("Email is already taken") email_errors.append("Email is already taken")
@@ -135,119 +139,41 @@ async def register_user(
try: try:
# 创建新用户 # 创建新用户
from datetime import datetime # 确保 AUTO_INCREMENT 值从3开始ID=1是BanchoBotID=2预留给ppy
import time 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( new_user = User(
name=user_username, username=user_username,
safe_name=user_username.lower(), # 安全用户名(小写)
email=user_email, email=user_email,
pw_bcrypt=get_password_hash(user_password), pw_bcrypt=get_password_hash(user_password),
priv=1, # 普通用户权限 priv=1, # 普通用户权限
country="CN", # 默认国家 country_code="CN", # 默认国家
creation_time=int(time.time()), join_date=datetime.now(UTC),
latest_activity=int(time.time()), last_visit=datetime.now(UTC),
preferred_mode=0, # 默认模式
play_style=0, # 默认游戏风格
) )
db.add(new_user) db.add(new_user)
await db.commit() await db.commit()
await db.refresh(new_user) await db.refresh(new_user)
assert new_user.id is not None, "New user ID should not be None"
# 保存用户ID因为会话可能会关闭 for i in GameMode:
user_id = new_user.id statistics = UserStatistics(mode=i, user_id=new_user.id)
db.add(statistics)
if user_id <= 2: daily_challenge_user_stats = DailyChallengeStats(user_id=new_user.id)
await db.rollback() db.add(daily_challenge_user_stats)
try:
from sqlalchemy import text
# 确保 AUTO_INCREMENT 值从3开始ID=1是BanchoBotID=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)
await db.commit() await db.commit()
except Exception:
# 返回成功响应
return JSONResponse(
status_code=201,
content={"message": "Account created successfully", "user_id": user_id},
)
except Exception as e:
await db.rollback() await db.rollback()
# 打印详细错误信息用于调试 # 打印详细错误信息用于调试
print(f"Registration error: {e}") logger.exception(f"Registration error for user {user_username}")
import traceback
traceback.print_exc()
# 返回通用错误 # 返回通用错误
errors = RegistrationRequestErrors( errors = RegistrationRequestErrors(
@@ -323,6 +249,7 @@ async def oauth_token(
refresh_token_str = generate_refresh_token() refresh_token_str = generate_refresh_token()
# 存储令牌 # 存储令牌
assert user.id
await store_token( await store_token(
db, db,
user.id, user.id,

View File

@@ -5,12 +5,7 @@ import hashlib
import json import json
from app.calculator import calculate_beatmap_attribute from app.calculator import calculate_beatmap_attribute
from app.database import ( from app.database import Beatmap, BeatmapResp, Beatmapset, User
Beatmap,
BeatmapResp,
User as DBUser,
)
from app.database.beatmapset import Beatmapset
from app.dependencies.database import get_db, get_redis from app.dependencies.database import get_db, get_redis
from app.dependencies.fetcher import get_fetcher from app.dependencies.fetcher import get_fetcher
from app.dependencies.user import get_current_user from app.dependencies.user import get_current_user
@@ -39,7 +34,7 @@ async def lookup_beatmap(
id: int | None = Query(default=None, alias="id"), id: int | None = Query(default=None, alias="id"),
md5: str | None = Query(default=None, alias="checksum"), md5: str | None = Query(default=None, alias="checksum"),
filename: str | None = Query(default=None, alias="filename"), 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), db: AsyncSession = Depends(get_db),
fetcher: Fetcher = Depends(get_fetcher), fetcher: Fetcher = Depends(get_fetcher),
): ):
@@ -62,7 +57,7 @@ async def lookup_beatmap(
@router.get("/beatmaps/{bid}", tags=["beatmap"], response_model=BeatmapResp) @router.get("/beatmaps/{bid}", tags=["beatmap"], response_model=BeatmapResp)
async def get_beatmap( async def get_beatmap(
bid: int, bid: int,
current_user: DBUser = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
fetcher: Fetcher = Depends(get_fetcher), fetcher: Fetcher = Depends(get_fetcher),
): ):
@@ -81,7 +76,7 @@ class BatchGetResp(BaseModel):
@router.get("/beatmaps/", tags=["beatmap"], response_model=BatchGetResp) @router.get("/beatmaps/", tags=["beatmap"], response_model=BatchGetResp)
async def batch_get_beatmaps( async def batch_get_beatmaps(
b_ids: list[int] = Query(alias="id", default_factory=list), 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), db: AsyncSession = Depends(get_db),
): ):
if not b_ids: if not b_ids:
@@ -126,7 +121,7 @@ async def batch_get_beatmaps(
) )
async def get_beatmap_attributes( async def get_beatmap_attributes(
beatmap: int, beatmap: int,
current_user: DBUser = Depends(get_current_user), current_user: User = Depends(get_current_user),
mods: list[str] = Query(default_factory=list), mods: list[str] = Query(default_factory=list),
ruleset: GameMode | None = Query(default=None), ruleset: GameMode | None = Query(default=None),
ruleset_id: int | None = Query(default=None), ruleset_id: int | None = Query(default=None),

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
from app.database import ( from app.database import (
Beatmapset, Beatmapset,
BeatmapsetResp, BeatmapsetResp,
User as DBUser, User,
) )
from app.dependencies.database import get_db from app.dependencies.database import get_db
from app.dependencies.fetcher import get_fetcher 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) @router.get("/beatmapsets/{sid}", tags=["beatmapset"], response_model=BeatmapsetResp)
async def get_beatmapset( async def get_beatmapset(
sid: int, sid: int,
current_user: DBUser = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
fetcher: Fetcher = Depends(get_fetcher), fetcher: Fetcher = Depends(get_fetcher),
): ):

View File

@@ -1,28 +1,34 @@
from __future__ import annotations from __future__ import annotations
from typing import Literal from app.database import User, UserResp
from app.database import (
User as DBUser,
)
from app.dependencies import get_current_user from app.dependencies import get_current_user
from app.models.user import ( from app.dependencies.database import get_db
User as ApiUser, from app.models.score import GameMode
)
from app.utils import convert_db_user_to_api_user
from .api_router import router from .api_router import router
from fastapi import Depends from fastapi import Depends
from sqlmodel.ext.asyncio.session import AsyncSession
@router.get("/me/{ruleset}", response_model=ApiUser) @router.get("/me/{ruleset}", response_model=UserResp)
@router.get("/me/", response_model=ApiUser) @router.get("/me/", response_model=UserResp)
async def get_user_info_default( async def get_user_info_default(
ruleset: Literal["osu", "taiko", "fruits", "mania"] = "osu", ruleset: GameMode | None = None,
current_user: DBUser = Depends(get_current_user), current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_db),
): ):
"""获取当前用户信息默认使用osu模式""" return await UserResp.from_db(
# 默认使用osu模式 current_user,
api_user = await convert_db_user_to_api_user(current_user, ruleset) session,
return api_user [
"friends",
"team",
"account_history",
"daily_challenge_user_stats",
"statistics",
"statistics_rulesets",
"achievements",
],
ruleset,
)

View File

@@ -8,6 +8,7 @@ from app.dependencies.user import get_current_user
from .api_router import router from .api_router import router
from fastapi import Depends, HTTPException, Query, Request from fastapi import Depends, HTTPException, Query, Request
from pydantic import BaseModel
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
from sqlmodel import select from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession 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] 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"]) @router.post("/blocks", tags=["relationship"])
async def add_relationship( async def add_relationship(
request: Request, request: Request,
@@ -98,7 +103,9 @@ async def add_relationship(
) )
).first() ).first()
assert relationship, "Relationship should exist after commit" 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"]) @router.delete("/friends/{target}", tags=["relationship"])

View File

@@ -1,11 +1,7 @@
from __future__ import annotations from __future__ import annotations
from app.database import ( from app.database import Beatmap, Score, ScoreResp, ScoreToken, ScoreTokenResp, User
User as DBUser, from app.database.score import process_score, process_user
)
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.dependencies.database import get_db, get_redis from app.dependencies.database import get_db, get_redis
from app.dependencies.fetcher import get_fetcher from app.dependencies.fetcher import get_fetcher
from app.dependencies.user import get_current_user from app.dependencies.user import get_current_user
@@ -41,7 +37,7 @@ async def get_beatmap_scores(
mode: GameMode | None = Query(None), mode: GameMode | None = Query(None),
# mods: List[APIMod] = Query(None), # TODO:加入指定MOD的查询 # mods: List[APIMod] = Query(None), # TODO:加入指定MOD的查询
type: str = Query(None), type: str = Query(None),
current_user: DBUser = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
if legacy_only: if legacy_only:
@@ -94,7 +90,7 @@ async def get_user_beatmap_score(
legacy_only: bool = Query(None), legacy_only: bool = Query(None),
mode: str = Query(None), mode: str = Query(None),
mods: str = Query(None), # TODO:添加mods筛选 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), db: AsyncSession = Depends(get_db),
): ):
if legacy_only: if legacy_only:
@@ -134,7 +130,7 @@ async def get_user_all_beatmap_scores(
user: int, user: int,
legacy_only: bool = Query(None), legacy_only: bool = Query(None),
ruleset: str = 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), db: AsyncSession = Depends(get_db),
): ):
if legacy_only: if legacy_only:
@@ -166,9 +162,10 @@ async def create_solo_score(
version_hash: str = Form(""), version_hash: str = Form(""),
beatmap_hash: str = Form(), beatmap_hash: str = Form(),
ruleset_id: int = Form(..., ge=0, le=3), 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), db: AsyncSession = Depends(get_db),
): ):
assert current_user.id
async with db: async with db:
score_token = ScoreToken( score_token = ScoreToken(
user_id=current_user.id, user_id=current_user.id,
@@ -190,7 +187,7 @@ async def submit_solo_score(
beatmap: int, beatmap: int,
token: int, token: int,
info: SoloScoreSubmissionInfo, info: SoloScoreSubmissionInfo,
current_user: DBUser = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
redis: Redis = Depends(get_redis), redis: Redis = Depends(get_redis),
fetcher=Depends(get_fetcher), fetcher=Depends(get_fetcher),

View File

@@ -1,12 +1,8 @@
from __future__ import annotations from __future__ import annotations
from typing import Literal from app.database import User, UserResp
from app.database import User as DBUser
from app.dependencies.database import get_db from app.dependencies.database import get_db
from app.models.score import INT_TO_MODE from app.models.score import GameMode
from app.models.user import User as ApiUser
from app.utils import convert_db_user_to_api_user
from .api_router import router from .api_router import router
@@ -17,28 +13,17 @@ from sqlmodel.ext.asyncio.session import AsyncSession
from sqlmodel.sql.expression import col 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): 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) @router.get("/users", response_model=BatchUserResponse)
@@ -52,74 +37,54 @@ async def get_users(
if user_ids: if user_ids:
searched_users = ( searched_users = (
await session.exec( 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() ).all()
else: else:
searched_users = ( searched_users = (
await session.exec(DBUser.all_select_clause().limit(50)) await session.exec(
select(User).options(*User.all_select_option()).limit(50)
)
).all() ).all()
return BatchUserResponse( return BatchUserResponse(
users=[ users=[
await convert_db_user_to_api_user( await UserResp.from_db(
searched_user, ruleset=INT_TO_MODE[searched_user.preferred_mode].value searched_user,
session,
include=SEARCH_INCLUDE,
) )
for searched_user in searched_users for searched_user in searched_users
] ]
) )
# # ---------- Individual User ---------- @router.get("/users/{user}/{ruleset}", response_model=UserResp)
# @router.get("/users/{user_lookup}/{mode}", response_model=ApiUser) @router.get("/users/{user}/", response_model=UserResp)
# @router.get("/users/{user_lookup}/{mode}/", response_model=ApiUser) @router.get("/users/{user}", response_model=UserResp)
# 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)
async def get_user_info( async def get_user_info(
user: str, user: str,
ruleset: Literal["osu", "taiko", "fruits", "mania"] = "osu", ruleset: GameMode | None = None,
session: AsyncSession = Depends(get_db), session: AsyncSession = Depends(get_db),
): ):
searched_user = ( searched_user = (
await session.exec( await session.exec(
DBUser.all_select_clause().where( select(User)
DBUser.id == int(user) .options(*User.all_select_option())
.where(
User.id == int(user)
if user.isdigit() if user.isdigit()
else DBUser.name == user.removeprefix("@") else User.username == user.removeprefix("@")
) )
) )
).first() ).first()
if not searched_user: if not searched_user:
raise HTTPException(404, detail="User not found") 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,
)

View File

@@ -4,7 +4,7 @@ import asyncio
from collections.abc import Coroutine from collections.abc import Coroutine
from typing import override 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.dependencies.database import engine
from app.models.metadata_hub import MetadataClientState, OnlineStatus, UserActivity from app.models.metadata_hub import MetadataClientState, OnlineStatus, UserActivity

View File

@@ -7,10 +7,9 @@ import struct
import time import time
from typing import override 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 import Score
from app.database.score_token import ScoreToken from app.database.score_token import ScoreToken
from app.database.user import User
from app.dependencies.database import engine from app.dependencies.database import engine
from app.models.beatmap import BeatmapRankStatus from app.models.beatmap import BeatmapRankStatus
from app.models.mods import mods_to_int from app.models.mods import mods_to_int
@@ -197,7 +196,7 @@ class SpectatorHub(Hub[StoreClientState]):
).first() ).first()
if not user: if not user:
return return
name = user.name name = user.username
store.state = state store.state = state
store.beatmap_status = beatmap.beatmap_status store.beatmap_status = beatmap.beatmap_status
store.checksum = beatmap.checksum store.checksum = beatmap.checksum
@@ -339,7 +338,7 @@ class SpectatorHub(Hub[StoreClientState]):
async with AsyncSession(engine) as session: async with AsyncSession(engine) as session:
async with session.begin(): async with session.begin():
username = ( username = (
await session.exec(select(User.name).where(User.id == user_id)) await session.exec(select(User.username).where(User.id == user_id))
).first() ).first()
if not username: if not username:
return return

View File

@@ -6,7 +6,7 @@ import time
from typing import Literal from typing import Literal
import uuid import uuid
from app.database import User as DBUser from app.database import User
from app.dependencies import get_current_user from app.dependencies import get_current_user
from app.dependencies.database import get_db from app.dependencies.database import get_db
from app.dependencies.user import get_current_user_by_token from app.dependencies.user import get_current_user_by_token
@@ -25,7 +25,7 @@ router = APIRouter()
async def negotiate( async def negotiate(
hub: Literal["spectator", "multiplayer", "metadata"], hub: Literal["spectator", "multiplayer", "metadata"],
negotiate_version: int = Query(1, alias="negotiateVersion"), negotiate_version: int = Query(1, alias="negotiateVersion"),
user: DBUser = Depends(get_current_user), user: User = Depends(get_current_user),
): ):
connectionId = str(user.id) connectionId = str(user.id)
connectionToken = f"{connectionId}:{uuid.uuid4()}" connectionToken = f"{connectionId}:{uuid.uuid4()}"

View File

@@ -1,465 +1,6 @@
from __future__ import annotations 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: def unix_timestamp_to_windows(timestamp: int) -> int:
"""Convert a Unix timestamp to a Windows timestamp.""" """Convert a Unix timestamp to a Windows timestamp."""
return (timestamp + 62135596800) * 10_000_000 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")

View File

@@ -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())

View File

@@ -4,16 +4,12 @@ from contextlib import asynccontextmanager
from datetime import datetime from datetime import datetime
from app.config import settings from app.config import settings
from app.database import Team # noqa: F401
from app.dependencies.database import create_tables, engine from app.dependencies.database import create_tables, engine
from app.dependencies.fetcher import get_fetcher 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 app.router import api_router, auth_router, fetcher_router, signalr_router
from fastapi import FastAPI from fastapi import FastAPI
User.model_rebuild()
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):

View File

@@ -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 ###

View File

@@ -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 ###