chore(merge): merge branch 'main' into feat/multiplayer-api

This commit is contained in:
MingxuanGame
2025-07-30 06:34:29 +00:00
34 changed files with 1971 additions and 1017 deletions

View File

@@ -4,6 +4,7 @@ import asyncio
import hashlib
import json
from app.calculator import calculate_beatmap_attribute
from app.database import (
Beatmap,
BeatmapResp,
@@ -20,7 +21,6 @@ from app.models.score import (
INT_TO_MODE,
GameMode,
)
from app.utils import calculate_beatmap_attribute
from .api_router import router
@@ -157,7 +157,7 @@ async def get_beatmap_attributes(
return BeatmapAttributes.model_validate_json(redis.get(key)) # pyright: ignore[reportArgumentType]
try:
resp = await fetcher.get_beatmap_raw(beatmap)
resp = await fetcher.get_or_fetch_beatmap_raw(redis, beatmap)
try:
attr = await asyncio.get_event_loop().run_in_executor(
None, calculate_beatmap_attribute, resp, ruleset, mods_

View File

@@ -8,6 +8,7 @@ from app.dependencies.user import get_current_user
from .api_router import router
from fastapi import Depends, HTTPException, Query, Request
from sqlalchemy.orm import joinedload
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
@@ -25,7 +26,9 @@ async def get_relationship(
else RelationshipType.BLOCK
)
relationships = await db.exec(
select(Relationship).where(
select(Relationship)
.options(joinedload(Relationship.target).options(*DBUser.all_select_option())) # pyright: ignore[reportArgumentType]
.where(
Relationship.user_id == current_user.id,
Relationship.type == relationship_type,
)
@@ -67,7 +70,8 @@ async def add_relationship(
type=relationship_type,
)
db.add(relationship)
if relationship.type == RelationshipType.BLOCK:
origin_type = relationship.type
if origin_type == RelationshipType.BLOCK:
target_relationship = (
await db.exec(
select(Relationship).where(
@@ -78,9 +82,22 @@ async def add_relationship(
).first()
if target_relationship and target_relationship.type == RelationshipType.FOLLOW:
await db.delete(target_relationship)
current_user_id = current_user.id
await db.commit()
await db.refresh(relationship)
if relationship.type == RelationshipType.FOLLOW:
if origin_type == RelationshipType.FOLLOW:
relationship = (
await db.exec(
select(Relationship)
.where(
Relationship.user_id == current_user_id,
Relationship.target_id == target,
)
.options(
joinedload(Relationship.target).options(*DBUser.all_select_option()) # pyright: ignore[reportArgumentType]
)
)
).first()
assert relationship, "Relationship should exist after commit"
return await RelationshipResp.from_db(db, relationship)

View File

@@ -1,18 +1,18 @@
from __future__ import annotations
import datetime
from app.database import (
User as DBUser,
)
from app.database.score import Score, ScoreResp
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
from app.dependencies.database import get_db, get_redis
from app.dependencies.fetcher import get_fetcher
from app.dependencies.user import get_current_user
from app.models.beatmap import BeatmapRankStatus
from app.models.score import (
INT_TO_MODE,
GameMode,
HitResult,
Rank,
SoloScoreSubmissionInfo,
)
@@ -21,6 +21,7 @@ from .api_router import router
from fastapi import Depends, Form, HTTPException, Query
from pydantic import BaseModel
from redis import Redis
from sqlalchemy.orm import joinedload
from sqlmodel import col, select, true
from sqlmodel.ext.asyncio.session import AsyncSession
@@ -70,8 +71,10 @@ async def get_beatmap_scores(
).first()
return BeatmapScores(
scores=[await ScoreResp.from_db(db, score) for score in all_scores],
userScore=await ScoreResp.from_db(db, user_score) if user_score else None,
scores=[await ScoreResp.from_db(db, score, score.user) for score in all_scores],
userScore=await ScoreResp.from_db(db, user_score, user_score.user)
if user_score
else None,
)
@@ -100,7 +103,7 @@ async def get_user_beatmap_score(
)
user_score = (
await db.exec(
Score.select_clause()
Score.select_clause(True)
.where(
Score.gamemode == mode if mode is not None else True,
Score.beatmap_id == beatmap,
@@ -117,7 +120,7 @@ async def get_user_beatmap_score(
else:
return BeatmapUserScore(
position=user_score.position if user_score.position is not None else 0,
score=await ScoreResp.from_db(db, user_score),
score=await ScoreResp.from_db(db, user_score, user_score.user),
)
@@ -150,7 +153,9 @@ async def get_user_all_beatmap_scores(
)
).all()
return [await ScoreResp.from_db(db, score) for score in all_user_scores]
return [
await ScoreResp.from_db(db, score, current_user) for score in all_user_scores
]
@router.post(
@@ -187,6 +192,8 @@ async def submit_solo_score(
info: SoloScoreSubmissionInfo,
current_user: DBUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
redis: Redis = Depends(get_redis),
fetcher=Depends(get_fetcher),
):
if not info.passed:
info.rank = Rank.F
@@ -214,40 +221,33 @@ async def submit_solo_score(
if not score:
raise HTTPException(status_code=404, detail="Score not found")
else:
score = Score(
accuracy=info.accuracy,
max_combo=info.max_combo,
# maximum_statistics=info.maximum_statistics,
mods=info.mods,
passed=info.passed,
rank=info.rank,
total_score=info.total_score,
total_score_without_mods=info.total_score_without_mods,
beatmap_id=beatmap,
ended_at=datetime.datetime.now(datetime.UTC),
gamemode=INT_TO_MODE[info.ruleset_id],
started_at=score_token.created_at,
user_id=current_user.id,
preserve=info.passed,
map_md5=score_token.beatmap.checksum,
has_replay=False,
pp=info.pp,
type="solo",
n300=info.statistics.get(HitResult.GREAT, 0),
n100=info.statistics.get(HitResult.OK, 0),
n50=info.statistics.get(HitResult.MEH, 0),
nmiss=info.statistics.get(HitResult.MISS, 0),
ngeki=info.statistics.get(HitResult.PERFECT, 0),
nkatu=info.statistics.get(HitResult.GOOD, 0),
beatmap_status = (
await db.exec(
select(Beatmap.beatmap_status).where(Beatmap.id == beatmap)
)
).first()
if beatmap_status is None:
raise HTTPException(status_code=404, detail="Beatmap not found")
ranked = beatmap_status in {
BeatmapRankStatus.RANKED,
BeatmapRankStatus.APPROVED,
}
score = await process_score(
current_user,
beatmap,
ranked,
score_token,
info,
fetcher,
db,
redis,
)
db.add(score)
await db.commit()
await db.refresh(score)
await db.refresh(current_user)
score_id = score.id
score_token.score_id = score_id
await db.commit()
await process_user(db, current_user, score, ranked)
score = (
await db.exec(Score.select_clause().where(Score.id == score_id))
).first()
assert score is not None
return await ScoreResp.from_db(db, score)
return await ScoreResp.from_db(db, score, current_user)

View File

@@ -2,57 +2,51 @@ from __future__ import annotations
from typing import Literal
from app.database import (
User as DBUser,
)
from app.dependencies import get_current_user
from app.database import User as DBUser
from app.dependencies.database import get_db
from app.models.score import INT_TO_MODE
from app.models.user import (
User as ApiUser,
)
from app.models.user import User as ApiUser
from app.utils import convert_db_user_to_api_user
from .api_router import router
from fastapi import Depends, HTTPException, Query
from pydantic import BaseModel
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from sqlmodel.sql.expression import col
@router.get("/users/{user}/{ruleset}", response_model=ApiUser)
@router.get("/users/{user}", response_model=ApiUser)
async def get_user_info_default(
user: str,
ruleset: Literal["osu", "taiko", "fruits", "mania"] = "osu",
current_user: DBUser = Depends(get_current_user),
session: AsyncSession = Depends(get_db),
):
searched_user = (
await session.exec(
DBUser.all_select_clause().where(
DBUser.id == int(user)
if user.isdigit()
else DBUser.name == user.removeprefix("@")
)
)
).first()
if not searched_user:
raise HTTPException(404, detail="User not found")
return await convert_db_user_to_api_user(searched_user, ruleset=ruleset)
# ---------- Shared Utility ----------
async def get_user_by_lookup(
db: AsyncSession, lookup: str, key: str = "id"
) -> DBUser | None:
"""根据查找方式获取用户"""
if key == "id":
try:
user_id = int(lookup)
result = await db.exec(select(DBUser).where(DBUser.id == user_id))
return result.first()
except ValueError:
return None
elif key == "username":
result = await db.exec(select(DBUser).where(DBUser.name == lookup))
return result.first()
else:
return None
# ---------- Batch Users ----------
class BatchUserResponse(BaseModel):
users: list[ApiUser]
@router.get("/users", response_model=BatchUserResponse)
@router.get("/users/lookup", response_model=BatchUserResponse)
@router.get("/users/lookup/", response_model=BatchUserResponse)
async def get_users(
user_ids: list[int] = Query(default_factory=list, alias="ids[]"),
include_variant_statistics: bool = Query(default=False), # TODO
current_user: DBUser = Depends(get_current_user),
include_variant_statistics: bool = Query(default=False), # TODO: future use
session: AsyncSession = Depends(get_db),
):
if user_ids:
@@ -68,8 +62,64 @@ async def get_users(
return BatchUserResponse(
users=[
await convert_db_user_to_api_user(
searched_user, ruleset=INT_TO_MODE[current_user.preferred_mode].value
searched_user, ruleset=INT_TO_MODE[searched_user.preferred_mode].value
)
for searched_user in searched_users
]
)
# # ---------- Individual User ----------
# @router.get("/users/{user_lookup}/{mode}", response_model=ApiUser)
# @router.get("/users/{user_lookup}/{mode}/", response_model=ApiUser)
# async def get_user_with_mode(
# user_lookup: str,
# mode: Literal["osu", "taiko", "fruits", "mania"],
# key: Literal["id", "username"] = Query("id"),
# current_user: DBUser = Depends(get_current_user),
# db: AsyncSession = Depends(get_db),
# ):
# """获取指定游戏模式的用户信息"""
# user = await get_user_by_lookup(db, user_lookup, key)
# if not user:
# raise HTTPException(status_code=404, detail="User not found")
# return await convert_db_user_to_api_user(user, mode)
# @router.get("/users/{user_lookup}", response_model=ApiUser)
# @router.get("/users/{user_lookup}/", response_model=ApiUser)
# async def get_user_default(
# user_lookup: str,
# key: Literal["id", "username"] = Query("id"),
# current_user: DBUser = Depends(get_current_user),
# db: AsyncSession = Depends(get_db),
# ):
# """获取用户信息默认使用osu模式但包含所有模式的统计信息"""
# user = await get_user_by_lookup(db, user_lookup, key)
# if not user:
# raise HTTPException(status_code=404, detail="User not found")
# return await convert_db_user_to_api_user(user, "osu")
@router.get("/users/{user}/{ruleset}", response_model=ApiUser)
@router.get("/users/{user}/", response_model=ApiUser)
@router.get("/users/{user}", response_model=ApiUser)
async def get_user_info(
user: str,
ruleset: Literal["osu", "taiko", "fruits", "mania"] = "osu",
session: AsyncSession = Depends(get_db),
):
searched_user = (
await session.exec(
DBUser.all_select_clause().where(
DBUser.id == int(user)
if user.isdigit()
else DBUser.name == user.removeprefix("@")
)
)
).first()
if not searched_user:
raise HTTPException(404, detail="User not found")
return await convert_db_user_to_api_user(searched_user, ruleset=ruleset)