diff --git a/.env.example b/.env.example index 9615669..1f9ee5c 100644 --- a/.env.example +++ b/.env.example @@ -117,3 +117,8 @@ STORAGE_SERVICE="local" # "s3_bucket_name": "your_s3_bucket_name", # "s3_region_name": "us-east-1", # "s3_public_url_base": "https://your-custom + +# 启用资源代理功能 +ENABLE_ASSET_PROXY=true +# 自定义资源域名 +CUSTOM_ASSET_DOMAIN=assets-ppy.g0v0.top \ No newline at end of file diff --git a/README.md b/README.md index 1f64fe2..bd072d8 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,11 @@ docker-compose -f docker-compose-osurx.yml up -d 参考[数据库迁移指南](https://github.com/GooGuTeam/g0v0-server/wiki/Migrate-Database) +## 资源文件反向代理 + +服务器支持资源文件反向代理功能,可以将 osu! 官方的资源链接(头像、谱面封面、音频等)替换为自定义域名。 + + ## 许可证 MIT License diff --git a/app/config.py b/app/config.py index 5e0fd1a..123e734 100644 --- a/app/config.py +++ b/app/config.py @@ -167,6 +167,13 @@ class Settings(BaseSettings): user_cache_max_preload_users: int = 200 # 最多预加载的用户数量 user_cache_concurrent_limit: int = 10 # 并发缓存用户的限制 + # 资源代理设置 + enable_asset_proxy: bool = True # 启用资源代理功能 + custom_asset_domain: str = "g0v0.top" # 自定义资源域名 + asset_proxy_prefix: str = "assets-ppy" # assets.ppy.sh的自定义前缀 + avatar_proxy_prefix: str = "a-ppy" # a.ppy.sh的自定义前缀 + beatmap_proxy_prefix: str = "b-ppy" # b.ppy.sh的自定义前缀 + # 反作弊设置 suspicious_score_check: bool = True banned_name: list[str] = [ diff --git a/app/database/lazer_user.py b/app/database/lazer_user.py index c6e2b98..2a7c94f 100644 --- a/app/database/lazer_user.py +++ b/app/database/lazer_user.py @@ -89,7 +89,7 @@ class UserBase(UTCBaseModel, SQLModel): is_restricted: bool = False # blocks cover: UserProfileCover = Field( - default=UserProfileCover(url="https://assets.ppy.sh/user-profile-covers/default.jpeg"), + default=UserProfileCover(url=""), sa_column=Column(JSON), ) beatmap_playcounts_count: int = 0 @@ -292,9 +292,9 @@ class UserResp(UserBase): redis = get_redis() u.is_online = await redis.exists(f"metadata:online:{obj.id}") u.cover_url = ( - obj.cover.get("url", "https://assets.ppy.sh/user-profile-covers/default.jpeg") + obj.cover.get("url", "") if obj.cover - else "https://assets.ppy.sh/user-profile-covers/default.jpeg" + else "" ) if "friends" in include: diff --git a/app/router/v2/beatmapset.py b/app/router/v2/beatmapset.py index c0f50df..be8dd63 100644 --- a/app/router/v2/beatmapset.py +++ b/app/router/v2/beatmapset.py @@ -14,6 +14,7 @@ from app.dependencies.user import get_client_user, get_current_user from app.fetcher import Fetcher from app.models.beatmap import SearchQueryModel from app.service.beatmap_download_service import BeatmapDownloadService +from app.service.asset_proxy_helper import process_response_assets from .router import router @@ -96,9 +97,12 @@ async def search_beatmapset( try: sets = await fetcher.search_beatmapset(query, cursor, redis) background_tasks.add_task(_save_to_db, sets) + + # 处理资源代理 + processed_sets = await process_response_assets(sets, request) + return processed_sets except HTTPError as e: raise HTTPException(status_code=500, detail=str(e)) - return sets @router.get( @@ -110,13 +114,17 @@ async def search_beatmapset( ) async def lookup_beatmapset( db: Database, + request: Request, beatmap_id: int = Query(description="谱面 ID"), current_user: User = Security(get_current_user, scopes=["public"]), fetcher: Fetcher = Depends(get_fetcher), ): beatmap = await Beatmap.get_or_fetch(db, fetcher, bid=beatmap_id) resp = await BeatmapsetResp.from_db(beatmap.beatmapset, session=db, user=current_user) - return resp + + # 处理资源代理 + processed_resp = await process_response_assets(resp, request) + return processed_resp @router.get( diff --git a/app/router/v2/user.py b/app/router/v2/user.py index ec8bbd7..b87c1e9 100644 --- a/app/router/v2/user.py +++ b/app/router/v2/user.py @@ -22,11 +22,12 @@ from app.log import logger from app.models.score import GameMode from app.models.user import BeatmapsetType from app.service.user_cache_service import get_user_cache_service +from app.service.asset_proxy_helper import process_response_assets from app.utils import utcnow from .router import router -from fastapi import BackgroundTasks, HTTPException, Path, Query, Security +from fastapi import BackgroundTasks, HTTPException, Path, Query, Request, Security from pydantic import BaseModel from sqlmodel import exists, false, select from sqlmodel.sql.expression import col @@ -47,6 +48,7 @@ class BatchUserResponse(BaseModel): @router.get("/users/lookup/", response_model=BatchUserResponse, include_in_schema=False) async def get_users( session: Database, + request: Request, background_task: BackgroundTasks, user_ids: list[int] = Query(default_factory=list, alias="ids[]", description="要查询的用户 ID 列表"), # current_user: User = Security(get_current_user, scopes=["public"]), @@ -83,7 +85,10 @@ async def get_users( # 异步缓存,不阻塞响应 background_task.add_task(cache_service.cache_user, user_resp) - return BatchUserResponse(users=cached_users) + # 处理资源代理 + response = BatchUserResponse(users=cached_users) + processed_response = await process_response_assets(response, request) + return processed_response else: searched_users = (await session.exec(select(User).limit(50))).all() users = [] @@ -98,7 +103,10 @@ async def get_users( # 异步缓存 background_task.add_task(cache_service.cache_user, user_resp) - return BatchUserResponse(users=users) + # 处理资源代理 + response = BatchUserResponse(users=users) + processed_response = await process_response_assets(response, request) + return processed_response @router.get("/users/{user}/recent_activity", tags=["用户"], response_model=list[Event]) @@ -182,6 +190,7 @@ async def get_user_info_ruleset( async def get_user_info( background_task: BackgroundTasks, session: Database, + request: Request, user_id: str = Path(description="用户 ID 或用户名"), # current_user: User = Security(get_current_user, scopes=["public"]), ): @@ -193,7 +202,9 @@ async def get_user_info( user_id_int = int(user_id) cached_user = await cache_service.get_user_from_cache(user_id_int) if cached_user: - return cached_user + # 处理资源代理 + processed_user = await process_response_assets(cached_user, request) + return processed_user searched_user = ( await session.exec( @@ -214,7 +225,9 @@ async def get_user_info( # 异步缓存结果 background_task.add_task(cache_service.cache_user, user_resp) - return user_resp + # 处理资源代理 + processed_user = await process_response_assets(user_resp, request) + return processed_user @router.get( diff --git a/app/service/asset_proxy_helper.py b/app/service/asset_proxy_helper.py new file mode 100644 index 0000000..6834106 --- /dev/null +++ b/app/service/asset_proxy_helper.py @@ -0,0 +1,76 @@ +""" +资源代理辅助函数和中间件 +""" + +from __future__ import annotations + +from typing import Any +from fastapi import Request +from app.config import settings +from app.service.asset_proxy_service import get_asset_proxy_service + + +async def process_response_assets(data: Any, request: Request) -> Any: + """ + 根据配置处理响应数据中的资源URL + + Args: + data: API响应数据 + request: FastAPI请求对象 + + Returns: + 处理后的数据 + """ + if not settings.enable_asset_proxy: + return data + + asset_service = get_asset_proxy_service() + + # 仅URL替换模式 + return await asset_service.replace_asset_urls(data) + + +def should_process_asset_proxy(path: str) -> bool: + """ + 判断路径是否需要处理资源代理 + """ + # 只对特定的API端点处理资源代理 + asset_proxy_endpoints = [ + "/api/v1/users/", + "/api/v2/users/", + "/api/v1/me/", + "/api/v2/me/", + "/api/v2/beatmapsets/search", + "/api/v2/beatmapsets/lookup", + "/api/v2/beatmaps/", + "/api/v1/beatmaps/", + "/api/v2/beatmapsets/", + # 可以根据需要添加更多端点 + ] + + return any(path.startswith(endpoint) for endpoint in asset_proxy_endpoints) + + +# 响应处理装饰器 +def asset_proxy_response(func): + """ + 装饰器:自动处理响应中的资源URL + """ + async def wrapper(*args, **kwargs): + # 获取request对象 + request = None + for arg in args: + if isinstance(arg, Request): + request = arg + break + + # 执行原函数 + result = await func(*args, **kwargs) + + # 如果有request对象且启用了资源代理,则处理响应 + if request and settings.enable_asset_proxy and should_process_asset_proxy(request.url.path): + result = await process_response_assets(result, request) + + return result + + return wrapper diff --git a/app/service/asset_proxy_service.py b/app/service/asset_proxy_service.py new file mode 100644 index 0000000..facfca6 --- /dev/null +++ b/app/service/asset_proxy_service.py @@ -0,0 +1,92 @@ +""" +资源文件代理服务 +提供URL替换方案:将osu!官方资源URL替换为自定义域名 +""" + +from __future__ import annotations + +import re +from typing import Any +from app.config import settings +from app.log import logger + + +class AssetProxyService: + """资源代理服务 - 仅URL替换模式""" + + def __init__(self): + # 从配置获取自定义assets域名和前缀 + self.custom_asset_domain = settings.custom_asset_domain + self.asset_proxy_prefix = settings.asset_proxy_prefix + self.avatar_proxy_prefix = settings.avatar_proxy_prefix + self.beatmap_proxy_prefix = settings.beatmap_proxy_prefix + + async def replace_asset_urls(self, data: Any) -> Any: + """ + 递归替换数据中的osu!资源URL为自定义域名 + """ + # 处理Pydantic模型 + if hasattr(data, 'model_dump'): + # 转换为字典,处理后再转换回模型 + data_dict = data.model_dump() + processed_dict = await self.replace_asset_urls(data_dict) + # 尝试从字典重新创建模型 + try: + return data.__class__(**processed_dict) + except Exception: + # 如果重新创建失败,返回字典 + return processed_dict + elif isinstance(data, dict): + result = {} + for key, value in data.items(): + result[key] = await self.replace_asset_urls(value) + return result + elif isinstance(data, list): + return [await self.replace_asset_urls(item) for item in data] + elif isinstance(data, str): + # 替换各种osu!资源域名 + result = data + + # 替换 assets.ppy.sh (用户头像、封面、奖章等) + result = re.sub( + r"https://assets\.ppy\.sh/", + f"https://{self.asset_proxy_prefix}.{self.custom_asset_domain}/", + result + ) + + # 替换 b.ppy.sh 预览音频 (保持//前缀) + result = re.sub( + r"//b\.ppy\.sh/", + f"//{self.beatmap_proxy_prefix}.{self.custom_asset_domain}/", + result + ) + + # 替换 https://b.ppy.sh 预览音频 (转换为//前缀) + result = re.sub( + r"https://b\.ppy\.sh/", + f"//{self.beatmap_proxy_prefix}.{self.custom_asset_domain}/", + result + ) + + # 替换 a.ppy.sh 头像 + result = re.sub( + r"https://a\.ppy\.sh/", + f"https://{self.avatar_proxy_prefix}.{self.custom_asset_domain}/", + result + ) + + return result + else: + return data + + +# 全局实例 +_asset_proxy_service: AssetProxyService | None = None + + +def get_asset_proxy_service() -> AssetProxyService: + """获取资源代理服务实例""" + global _asset_proxy_service + if _asset_proxy_service is None: + _asset_proxy_service = AssetProxyService() + return _asset_proxy_service diff --git a/app/service/ranking_cache_service.py b/app/service/ranking_cache_service.py index 89f1b37..efa4def 100644 --- a/app/service/ranking_cache_service.py +++ b/app/service/ranking_cache_service.py @@ -15,6 +15,7 @@ from app.database.statistics import UserStatistics, UserStatisticsResp from app.log import logger from app.models.score import GameMode from app.utils import utcnow +from app.service.asset_proxy_service import get_asset_proxy_service from redis.asyncio import Redis from sqlmodel import col, select @@ -283,6 +284,15 @@ class RankingCacheService: ranking_data = [] for statistics in statistics_data: user_stats_resp = await UserStatisticsResp.from_db(statistics, session, None, include) + + # 应用资源代理处理 + if settings.enable_asset_proxy: + try: + asset_proxy_service = get_asset_proxy_service() + user_stats_resp = await asset_proxy_service.replace_asset_urls(user_stats_resp) + except Exception as e: + logger.warning(f"Asset proxy processing failed for ranking cache: {e}") + # 将 UserStatisticsResp 转换为字典,处理所有序列化问题 user_dict = json.loads(user_stats_resp.model_dump_json()) ranking_data.append(user_dict) diff --git a/app/service/user_cache_service.py b/app/service/user_cache_service.py index 7b01be4..e6ac6af 100644 --- a/app/service/user_cache_service.py +++ b/app/service/user_cache_service.py @@ -16,6 +16,7 @@ from app.database.lazer_user import SEARCH_INCLUDED from app.database.score import ScoreResp from app.log import logger from app.models.score import GameMode +from app.service.asset_proxy_service import get_asset_proxy_service from redis.asyncio import Redis from sqlmodel import col, select @@ -298,6 +299,15 @@ class UserCacheService: """缓存单个用户""" try: user_resp = await UserResp.from_db(user, session, include=SEARCH_INCLUDED) + + # 应用资源代理处理 + if settings.enable_asset_proxy: + try: + asset_proxy_service = get_asset_proxy_service() + user_resp = await asset_proxy_service.replace_asset_urls(user_resp) + except Exception as e: + logger.warning(f"Asset proxy processing failed for user cache {user.id}: {e}") + await self.cache_user(user_resp) except Exception as e: logger.error(f"Error caching single user {user.id}: {e}") diff --git a/main.py b/main.py index 5c3c97b..96e11cb 100644 --- a/main.py +++ b/main.py @@ -66,6 +66,11 @@ async def lifespan(app: FastAPI): start_stats_scheduler() # 启动统计调度器 schedule_online_status_maintenance() # 启动在线状态维护任务 load_achievements() + + # 显示资源代理状态 + if settings.enable_asset_proxy: + logger.info(f"Asset Proxy enabled - Domain: {settings.custom_asset_domain}") + # on shutdown yield bg_tasks.stop()