from typing import Annotated, Any from app.auth import validate_username from app.config import settings from app.database import User from app.database.events import Event, EventType from app.database.user_preference import ( DEFAULT_ORDER, BeatmapCardSize, BeatmapDownload, ScoringMode, UserListFilter, UserListSort, UserListView, UserPreference, ) from app.dependencies.cache import UserCacheService from app.dependencies.database import Database from app.dependencies.user import ClientUser from app.models.score import GameMode from app.models.user import Page from app.models.userpage import ( UpdateUserpageRequest, UpdateUserpageResponse, UserpageError, ValidateBBCodeRequest, ValidateBBCodeResponse, ) from app.service.bbcode_service import bbcode_service from app.utils import hex_to_hue, utcnow from .router import router from fastapi import Body, HTTPException from pydantic import BaseModel, Field from sqlmodel import exists, select @router.post("/rename", name="修改用户名", tags=["用户", "g0v0 API"]) async def user_rename( session: Database, new_name: Annotated[str, Body(..., description="新的用户名")], current_user: ClientUser, cache_service: UserCacheService, ): """修改用户名 为指定用户修改用户名,并将原用户名添加到历史用户名列表中 错误情况: - 404: 找不到指定用户 - 409: 新用户名已被占用 返回: - 成功: None """ if await current_user.is_restricted(session): # https://github.com/ppy/osu-web/blob/cae2fdf03cfb8c30c8e332cfb142e03188ceffef/app/Libraries/ChangeUsername.php#L48-L49 raise HTTPException(403, "Your account is restricted and cannot perform this action.") samename_user = (await session.exec(select(exists()).where(User.username == new_name))).first() if samename_user: raise HTTPException(409, "Username Exists") errors = validate_username(new_name) if errors: raise HTTPException(403, "\n".join(errors)) previous_username = [] previous_username.extend(current_user.previous_usernames) previous_username.append(current_user.username) current_user.username = new_name current_user.previous_usernames = previous_username rename_event = Event( created_at=utcnow(), type=EventType.USERNAME_CHANGE, user_id=current_user.id, user=current_user, ) rename_event.event_payload["user"] = { "username": new_name, "url": settings.web_url + "users/" + str(current_user.id), "previous_username": current_user.previous_usernames[-1], } session.add(rename_event) await session.commit() await cache_service.invalidate_user_cache(current_user.id) return None @router.put( "/user/page", response_model=UpdateUserpageResponse, name="更新用户页面", description="更新指定用户的个人页面内容(支持BBCode)。匹配官方osu-web API格式。", tags=["用户", "g0v0 API"], ) async def update_userpage( request: UpdateUserpageRequest, session: Database, current_user: ClientUser, cache_service: UserCacheService, ): """更新用户页面内容""" if await current_user.is_restricted(session): raise HTTPException(403, "Your account is restricted and cannot perform this action.") try: # 处理BBCode内容 processed_page = bbcode_service.process_userpage_content(request.body) # 更新数据库 - 直接更新用户对象 current_user.page = Page(html=processed_page["html"], raw=processed_page["raw"]) session.add(current_user) await session.commit() await session.refresh(current_user) await cache_service.invalidate_user_cache(current_user.id) # 返回官方格式的响应:只包含html return UpdateUserpageResponse(html=processed_page["html"]) except UserpageError as e: # 使用官方格式的错误响应:{'error': message} raise HTTPException(status_code=422, detail={"error": e.message}) except Exception: raise HTTPException(status_code=500, detail={"error": "Failed to update user page"}) @router.post( "/user/validate-bbcode", response_model=ValidateBBCodeResponse, name="验证BBCode", description="验证BBCode语法并返回预览。", tags=["用户", "g0v0 API"], ) async def validate_bbcode( request: ValidateBBCodeRequest, ): """验证BBCode语法""" try: # 验证BBCode语法 errors = bbcode_service.validate_bbcode(request.content) # 生成预览(如果没有严重错误) if len(errors) == 0: preview = bbcode_service.process_userpage_content(request.content) else: preview = {"raw": request.content, "html": ""} return ValidateBBCodeResponse(valid=len(errors) == 0, errors=errors, preview=preview) except UserpageError as e: return ValidateBBCodeResponse(valid=False, errors=[e.message], preview={"raw": request.content, "html": ""}) except Exception: raise HTTPException(status_code=500, detail={"error": "Failed to validate BBCode"}) class Preferences(BaseModel): theme: str | None = None language: str | None = None audio_autoplay: bool | None = None audio_muted: bool | None = None audio_volume: float | None = Field(None, ge=0.0, le=1.0) beatmapset_card_size: BeatmapCardSize | None = None beatmap_download: BeatmapDownload | None = None beatmapset_show_nsfw: bool | None = None profile_order: list[str] | None = None legacy_score_only: bool | None = None profile_cover_expanded: bool | None = None scoring_mode: ScoringMode | None = None user_list_filter: UserListFilter | None = None user_list_sort: UserListSort | None = None user_list_view: UserListView | None = None extra: dict[str, Any] | None = None # in User playmode: GameMode | None = None interests: str | None = None location: str | None = None occupation: str | None = None twitter: str | None = None website: str | None = None discord: str | None = None profile_colour: str | None = None @staticmethod async def clear(current_user: User, fields: list[str]): await current_user.awaitable_attrs.user_preference user_pref: UserPreference | None = current_user.user_preference if user_pref is None: return if len(fields) == 0: fields = [ *PREFERENCE_FIELDS, *USER_PROFILE_FIELDS_WITH_WEBSITE, "profile_order", "extra", "playmode", "profile_colour", ] for field in fields: if field in PREFERENCE_FIELDS: setattr(user_pref, field, UserPreference.model_fields[field].default) elif field == "profile_order": user_pref.extras_order = DEFAULT_ORDER elif field == "extra": user_pref.extra = {} for field in fields: if field in USER_PROFILE_FIELDS_WITH_WEBSITE: setattr(current_user, field, None) elif field == "playmode": current_user.playmode = GameMode.OSU current_user.g0v0_playmode = GameMode.OSU elif field == "profile_colour": current_user.profile_colour = None current_user.profile_hue = None PREFERENCE_FIELDS = { "theme", "language", "audio_autoplay", "audio_muted", "audio_volume", "beatmapset_card_size", "beatmap_download", "beatmapset_show_nsfw", "legacy_score_only", "profile_cover_expanded", "scoring_mode", "user_list_filter", "user_list_sort", "user_list_view", } USER_PROFILE_FIELDS = { "interests", "location", "occupation", "twitter", "discord", } USER_PROFILE_FIELDS_WITH_WEBSITE = USER_PROFILE_FIELDS | {"website"} @router.get( "/user/preferences", name="获取用户偏好设置", description="获取当前登录用户的偏好设置", tags=["用户", "g0v0 API"], response_model=Preferences, ) async def get_user_preference( current_user: ClientUser, ): await current_user.awaitable_attrs.user_preference user_pref: UserPreference | None = current_user.user_preference if user_pref is None: user_pref = UserPreference(user_id=current_user.id) return Preferences( theme=user_pref.theme, language=user_pref.language, audio_autoplay=user_pref.audio_autoplay, audio_muted=user_pref.audio_muted, audio_volume=user_pref.audio_volume, beatmapset_card_size=user_pref.beatmapset_card_size, beatmap_download=user_pref.beatmap_download, beatmapset_show_nsfw=user_pref.beatmapset_show_nsfw, profile_order=user_pref.extras_order or DEFAULT_ORDER, legacy_score_only=user_pref.legacy_score_only, profile_cover_expanded=user_pref.profile_cover_expanded, scoring_mode=user_pref.scoring_mode, user_list_filter=user_pref.user_list_filter, user_list_sort=user_pref.user_list_sort, user_list_view=user_pref.user_list_view, extra=user_pref.extra or {}, playmode=current_user.g0v0_playmode, interests=current_user.interests, location=current_user.location, occupation=current_user.occupation, twitter=current_user.twitter, website=current_user.website, discord=current_user.discord, profile_colour="#" + current_user.profile_colour if current_user.profile_colour else None, ) @router.patch( "/user/preferences", name="修改用户偏好设置", description="修改当前登录用户的偏好设置", tags=["用户", "g0v0 API"], status_code=204, ) async def change_user_preference( request: Preferences, session: Database, current_user: ClientUser, cache_service: UserCacheService, ): if await current_user.is_restricted(session): raise HTTPException(403, "Your account is restricted and cannot perform this action.") await current_user.awaitable_attrs.user_preference user_pref: UserPreference | None = current_user.user_preference if user_pref is None: user_pref = UserPreference(user_id=current_user.id) session.add(user_pref) for field, value in request.model_dump(include=PREFERENCE_FIELDS, exclude_none=True).items(): setattr(user_pref, field, value) if request.profile_order is not None: if set(request.profile_order) != set(DEFAULT_ORDER): raise HTTPException(400, "Invalid profile order") user_pref.extras_order = request.profile_order if request.extra is not None: user_pref.extra = (user_pref.extra or {}) | request.extra if request.playmode is not None: if request.playmode.is_official(): current_user.playmode = request.playmode.to_base_ruleset() current_user.g0v0_playmode = request.playmode for field, value in request.model_dump(include=USER_PROFILE_FIELDS, exclude_none=True).items(): setattr(current_user, field, value or None) if request.website is not None: if request.website == "": current_user.website = None elif not (request.website.startswith("http://") or request.website.startswith("https://")): current_user.website = "https://" + request.website if request.profile_colour is not None: current_user.profile_colour = request.profile_colour.removeprefix("#") try: current_user.profile_hue = hex_to_hue(request.profile_colour) except ValueError: raise HTTPException(400, "Invalid profile colour hex value") await cache_service.invalidate_user_cache(current_user.id) await session.commit() @router.put( "/user/preferences", name="覆盖用户偏好设置", description="使用提供的数据完整覆盖当前登录用户的偏好设置,未提供的字段将被重置为默认值。", tags=["用户", "g0v0 API"], status_code=204, ) async def overwrite_user_preference( request: Preferences, session: Database, current_user: ClientUser, cache_service: UserCacheService, ): if await current_user.is_restricted(session): raise HTTPException(403, "Your account is restricted and cannot perform this action.") await Preferences.clear(current_user, []) await change_user_preference(request, session, current_user, cache_service) await cache_service.invalidate_user_cache(current_user.id) await session.commit() @router.delete( "/user/preferences", name="删除用户偏好设置", description="删除当前登录用户的偏好设置,恢复为默认值\n\n如果未指定字段,则删除所有可删除的偏好设置", tags=["用户", "g0v0 API"], status_code=204, ) async def delete_user_preference( session: Database, current_user: ClientUser, fields: list[str], cache_service: UserCacheService, ): if await current_user.is_restricted(session): raise HTTPException(403, "Your account is restricted and cannot perform this action.") await Preferences.clear(current_user, fields) await cache_service.invalidate_user_cache(current_user.id) await session.commit()