From 545fc9e0c624aa78a27b3ed75e192f65d8a8980c Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Fri, 22 Aug 2025 11:00:36 +0000 Subject: [PATCH] fix(recent_activity): fix `rank` & `achievement` event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 运行 tools/fix_user_rank_event.py 修复现存的 event --- app/database/__init__.py | 2 + app/database/achievement.py | 2 +- app/database/events.py | 28 ++--- app/models/score.py | 12 +++ app/router/v2/score.py | 9 +- app/router/v2/user.py | 41 +++++--- tools/fix_user_rank_event.py | 199 +++++++++++++++++++++++++++++++++++ 7 files changed, 259 insertions(+), 34 deletions(-) create mode 100644 tools/fix_user_rank_event.py diff --git a/app/database/__init__.py b/app/database/__init__.py index 27ea100..d64f1c9 100644 --- a/app/database/__init__.py +++ b/app/database/__init__.py @@ -24,6 +24,7 @@ from .counts import ( ) from .daily_challenge import DailyChallengeStats, DailyChallengeStatsResp from .email_verification import EmailVerification, LoginSession +from .events import Event from .favourite_beatmapset import FavouriteBeatmapset from .lazer_user import ( User, @@ -83,6 +84,7 @@ __all__ = [ "DailyChallengeStats", "DailyChallengeStatsResp", "EmailVerification", + "Event", "FavouriteBeatmapset", "ItemAttemptsCount", "ItemAttemptsResp", diff --git a/app/database/achievement.py b/app/database/achievement.py index d26f29b..9c048ab 100644 --- a/app/database/achievement.py +++ b/app/database/achievement.py @@ -77,7 +77,7 @@ async def process_achievements(session: AsyncSession, redis: Redis, score_id: in type=EventType.ACHIEVEMENT, user_id=score.user_id, event_payload={ - "achievement": {"achievement_id": r.id, "achieved_at": now.isoformat()}, + "achievement": {"slug": r.assets_id, "name": r.name}, "user": { "username": score.user.username, "url": settings.web_url + "users/" + str(score.user.id), diff --git a/app/database/events.py b/app/database/events.py index 980396c..19ac878 100644 --- a/app/database/events.py +++ b/app/database/events.py @@ -2,6 +2,9 @@ from datetime import UTC, datetime from enum import Enum from typing import TYPE_CHECKING +from app.models.model import UTCBaseModel + +from pydantic import model_serializer from sqlmodel import ( JSON, BigInteger, @@ -34,26 +37,25 @@ class EventType(str, Enum): USERNAME_CHANGE = "username_change" -class EventBase(SQLModel): +class Event(UTCBaseModel, SQLModel, table=True): + __tablename__: str = "user_events" id: int = Field(default=None, primary_key=True) created_at: datetime = Field(sa_column=Column(DateTime(timezone=True), default=datetime.now(UTC))) type: EventType event_payload: dict = Field(exclude=True, default_factory=dict, sa_column=Column(JSON)) - - -class Event(EventBase, table=True): - __tablename__: str = "user_events" user_id: int | None = Field( default=None, sa_column=Column(BigInteger, ForeignKey("lazer_users.id"), index=True), ) user: "User" = Relationship(back_populates="events") - -class EventResp(EventBase): - def merge_payload(self) -> "EventResp": - for key, value in self.event_payload.items(): - setattr(self, key, value) - return self - - pass + @model_serializer + def serialize(self) -> dict: + d = { + "id": self.id, + "createdAt": self.created_at.replace(tzinfo=UTC).isoformat(), + "type": self.type.value, + } + for k, v in self.event_payload.items(): + d[k] = v + return d diff --git a/app/models/score.py b/app/models/score.py index a6adb53..9ea1a4d 100644 --- a/app/models/score.py +++ b/app/models/score.py @@ -71,6 +71,18 @@ class GameMode(str, Enum): 7: GameMode.FRUITSRX, }[v] + def readable(self) -> str: + return { + GameMode.OSU: "osu!", + GameMode.TAIKO: "osu!taiko", + GameMode.FRUITS: "osu!catch", + GameMode.MANIA: "osu!mania", + GameMode.OSURX: "osu!relax", + GameMode.OSUAP: "osu!autopilot", + GameMode.TAIKORX: "taiko relax", + GameMode.FRUITSRX: "catch relax", + }[self] + def to_special_mode(self, mods: list[APIMod] | list[str]) -> "GameMode": if self not in (GameMode.OSU, GameMode.TAIKO, GameMode.FRUITS): return self diff --git a/app/router/v2/score.py b/app/router/v2/score.py index 9007bb6..33fcf69 100644 --- a/app/router/v2/score.py +++ b/app/router/v2/score.py @@ -172,10 +172,13 @@ async def submit_score( user=score.user, ) rank_event.event_payload = { - "scorerank": str(score.rank), + "scorerank": score.rank.value, "rank": resp.rank_global, - "mode": str(resp.beatmap.mode), # pyright: ignore[reportOptionalMemberAccess] - "beatmap": {"title": resp.beatmap.version, "url": resp.beatmap.url}, # pyright: ignore[reportOptionalMemberAccess] + "mode": resp.beatmap.mode.readable(), # pyright: ignore[reportOptionalMemberAccess] + "beatmap": { + "title": f"{resp.beatmap.beatmapset.artist} - {resp.beatmap.beatmapset.title} [{resp.beatmap.version}]", # pyright: ignore[reportOptionalMemberAccess] + "url": resp.beatmap.url, # pyright: ignore[reportOptionalMemberAccess] + }, "user": { "username": score.user.username, "url": settings.web_url + "users/" + str(score.user.id), diff --git a/app/router/v2/user.py b/app/router/v2/user.py index 501bce0..5f6ff8a 100644 --- a/app/router/v2/user.py +++ b/app/router/v2/user.py @@ -12,7 +12,7 @@ from app.database import ( User, UserResp, ) -from app.database.events import EventResp +from app.database.events import Event from app.database.lazer_user import SEARCH_INCLUDED from app.database.pp_best_score import PPBestScore from app.database.score import Score, ScoreResp @@ -100,6 +100,29 @@ async def get_users( return BatchUserResponse(users=users) +@router.get("/users/{user}/recent_activity", tags=["用户"], response_model=list[Event]) +async def get_user_events( + session: Database, + user: int, + limit: int | None = Query(None), + offset: int | None = Query(None), # TODO: 搞清楚并且添加这个奇怪的分页偏移 +): + db_user = await session.get(User, user) + if db_user is None or db_user.id == BANCHOBOT_ID: + raise HTTPException(404, "User Not found") + events = ( + await session.exec( + select(Event) + .where(Event.user_id == db_user.id) + .order_by(col(Event.created_at).desc()) + .limit(limit) + .offset(offset) + ) + ).all() + print(events) + return events + + @router.get( "/users/{user_id}/{ruleset}", response_model=UserResp, @@ -341,19 +364,3 @@ async def get_user_scores( ) return score_responses - - -@router.get("/users/{user}/recent_activity", tags=["用户"], response_model=list[EventResp]) -async def get_user_events( - session: Database, - user: int, - limit: int | None = Query(None), - offset: str | None = Query(None), # TODO: 搞清楚并且添加这个奇怪的分页偏移 -): - db_user = await session.get(User, user) - if db_user is None or db_user.id == BANCHOBOT_ID: - raise HTTPException(404, "User Not found") - events = await db_user.awaitable_attrs.events - if limit is not None: - events = events[:limit] - return [EventResp(**event.model_dump()).merge_payload() for event in events] diff --git a/tools/fix_user_rank_event.py b/tools/fix_user_rank_event.py new file mode 100644 index 0000000..45b098a --- /dev/null +++ b/tools/fix_user_rank_event.py @@ -0,0 +1,199 @@ +from __future__ import annotations + +import os + +""" +Fix user rank events in the database. + +This script fixes the format of RANK type events by: +1. Removing 'Rank.' prefix from scorerank values (e.g., 'Rank.X' -> 'X') +2. Converting mode values from enum format to string format (e.g., 'GameMode.OSU' -> 'osu') + +Usage: + python tools/fix_user_rank_event.py [--dry-run] + +Options: + --dry-run Show what would be changed without making actual changes +""" + +from argparse import ArgumentParser +import asyncio +import sys + +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from app.database.events import Event, EventType +from app.dependencies.database import engine +from app.log import logger + +from sqlmodel import select +from sqlmodel.ext.asyncio.session import AsyncSession + +logger.remove() + + +def fix_scorerank(payload: dict) -> tuple[dict, bool]: + """ + Fix scorerank field by removing 'Rank.' prefix. + + Returns: + tuple: (fixed_payload, was_changed) + """ + fixed_payload = payload.copy() + changed = False + + if "scorerank" in fixed_payload: + scorerank = str(fixed_payload["scorerank"]) + if scorerank.startswith("Rank."): + fixed_payload["scorerank"] = scorerank.replace("Rank.", "") + changed = True + + return fixed_payload, changed + + +def fix_mode(payload: dict) -> tuple[dict, bool]: + """ + Fix mode field by converting from enum format to string format. + + Returns: + tuple: (fixed_payload, was_changed) + """ + fixed_payload = payload.copy() + changed = False + + if "mode" in fixed_payload: + mode = str(fixed_payload["mode"]) + # Map GameMode enum strings to their values + mode_mapping = { + "GameMode.OSU": "osu!", + "GameMode.TAIKO": "osu!taiko", + "GameMode.FRUITS": "osu!catch", + "GameMode.MANIA": "osu!mania", + "GameMode.OSURX": "osu!relax", + "GameMode.OSUAP": "osu!autopilot", + "GameMode.TAIKORX": "taiko relax", + "GameMode.FRUITSRX": "catch relax", + } + + if mode in mode_mapping: + fixed_payload["mode"] = mode_mapping[mode] + changed = True + + return fixed_payload, changed + + +def fix_event_payload(payload: dict) -> tuple[dict, bool]: + """ + Fix both scorerank and mode fields in event payload. + + Returns: + tuple: (fixed_payload, was_changed) + """ + fixed_payload = payload.copy() + total_changed = False + + # Fix scorerank + fixed_payload, scorerank_changed = fix_scorerank(fixed_payload) + + # Fix mode + fixed_payload, mode_changed = fix_mode(fixed_payload) + + total_changed = scorerank_changed or mode_changed + + return fixed_payload, total_changed + + +async def get_rank_events(session: AsyncSession) -> list[Event]: + """Get all RANK type events from the database.""" + result = await session.exec(select(Event).where(Event.type == EventType.RANK)) + return list(result.all()) + + +async def update_event(session: AsyncSession, event: Event, new_payload: dict) -> None: + """Update an event's payload in the database.""" + + +async def main(): + parser = ArgumentParser(description="Fix user rank events in the database") + parser.add_argument( + "--dry-run", action="store_true", help="Show what would be changed without making actual changes" + ) + args = parser.parse_args() + + print("🔍 Fetching RANK events from database...") + + async with AsyncSession(engine) as session: + events = await get_rank_events(session) + print(f"📊 Found {len(events)} RANK events") + + if not events: + print("✅ No RANK events found. Nothing to fix.") + return + + events_to_fix = [] + + # Analyze events + for event in events: + try: + payload = event.event_payload + if not isinstance(payload, dict): + print(f"⚠️ Event {event.id}: payload is not a dict, skipping") + continue + + fixed_payload, needs_fix = fix_event_payload(payload) + + if needs_fix: + events_to_fix.append((event, fixed_payload, payload)) + + except Exception as e: + print(f"❌ Error processing event {event.id}: {e}") + continue + + print(f"🔧 Found {len(events_to_fix)} events that need fixing") + + if not events_to_fix: + print("✅ All RANK events are already in correct format!") + return + + # Show changes + for event, fixed_payload, original_payload in events_to_fix: + print(f"\n📝 Event {event.id}:") + print(f" Original: {original_payload}") + print(f" Fixed: {fixed_payload}") + + # Show specific changes + changes = [] + if "scorerank" in original_payload and "scorerank" in fixed_payload: + if original_payload["scorerank"] != fixed_payload["scorerank"]: + changes.append(f"scorerank: {original_payload['scorerank']} → {fixed_payload['scorerank']}") + + if "mode" in original_payload and "mode" in fixed_payload: + if original_payload["mode"] != fixed_payload["mode"]: + changes.append(f"mode: {original_payload['mode']} → {fixed_payload['mode']}") + + if changes: + print(f" Changes: {', '.join(changes)}") + + if args.dry_run: + print(f"\n🧪 DRY RUN: Would fix {len(events_to_fix)} events") + print(" Run without --dry-run to apply changes") + return + + # Apply changes + print(f"\n💾 Applying fixes to {len(events_to_fix)} events...") + + try: + for event, fixed_payload, _ in events_to_fix: + event.event_payload = fixed_payload + + await session.commit() + print(f"✅ Successfully fixed {len(events_to_fix)} events!") + + except Exception as e: + await session.rollback() + print(f"❌ Error applying fixes: {e}") + sys.exit(1) + + +if __name__ == "__main__": + asyncio.run(main())