diff --git a/app/graphql/types.py b/app/graphql/types.py index d37ecbb..42fd2cc 100644 --- a/app/graphql/types.py +++ b/app/graphql/types.py @@ -4,15 +4,19 @@ from datetime import datetime from strawberry.types import Info from app.utils.format import get_image_url +from app.models.user import User @strawberry.type class UserType: id: str email: str name: str - avatar: str handle: Optional[str] + @strawberry.field + def avatar(self, root: User) -> Optional[str]: + return root.avatar_url + # 互动统计字段 following_count: int follower_count: int diff --git a/app/main.py b/app/main.py index f2c64bc..72f7709 100644 --- a/app/main.py +++ b/app/main.py @@ -6,6 +6,7 @@ from strawberry.fastapi import GraphQLRouter from dotenv import load_dotenv from auth import router as auth_router +from app.media.router import router as media_router from app.db import engine, SessionLocal from app.models import Base, User @@ -44,6 +45,7 @@ graphql_router = GraphQLRouter(schema, context_getter=get_context) # 挂载路由 app.include_router(auth_router) # 保留现有的 auth 路由供 Google OAuth 回调使用 +app.include_router(media_router) # 挂载媒体路由,提供头像代理访问等等 app.include_router(graphql_router, prefix="/graphql") # 挂载 GraphQL 终点,前端以后只需要请求 /graphql # 打印路由测试 diff --git a/app/media/router.py b/app/media/router.py new file mode 100644 index 0000000..8a08ff2 --- /dev/null +++ b/app/media/router.py @@ -0,0 +1,45 @@ +from fastapi import APIRouter, HTTPException +from fastapi.responses import StreamingResponse +import oss2 +from app.utils.s3 import bucket + +router = APIRouter(prefix="/v1/media", tags=["Media"]) + +@router.get("/{file_path:path}") +async def get_avatar(file_path: str): + """ + 通过流式传输代理阿里云 OSS 中的图片 + file_path 接收 avatars/xxx/avatar.jpg 等任何相对路径 + """ + s3_key = file_path.lstrip("/") + + if not s3_key: + raise HTTPException(status_code=400, detail="Invalid file path") + + try: + # 从阿里云 OSS 获取对象 + # oss2 的 get_object 是同步阻塞的 + # 初期可以直接用,后续高并发可以放入 run_in_executor + oss_object = bucket.get_object(s3_key) + + # 获取文件的 Content-Type + content_type = oss_object.headers.get("Content-Type", "image/jpeg") + + # 创建一个生成器来分块读取 OSS 数据,防止大文件时撑爆内存 + def image_stream(): + while True: + chunk = oss_object.read(1024 * 64) # 每次读 64KB + if not chunk: + break + yield chunk + + # 流式返回给前端 + return StreamingResponse(image_stream(), media_type=content_type) + + except oss2.exceptions.NoSuchKey: + # 阿里云 OSS 专属的 404 异常 + raise HTTPException(status_code=404, detail="Image not found in storage") + except Exception as e: + import traceback + traceback.print_exc() + raise HTTPException(status_code=500, detail="Internal storage service error") \ No newline at end of file diff --git a/app/models/user.py b/app/models/user.py index ab8aaaa..66bca90 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -17,6 +17,19 @@ class User(Base, AutoID): handle = Column(String, unique=True, index=True, nullable=True) avatar = Column(String) + @property + def avatar_url(self) -> str | None: + if not self.avatar: + return None + + # 如果是第三方(如未迁移成功时降级的 Google 原始链接),直接返回 + if self.avatar.startswith(("http://", "https://")): + return self.avatar + + path = self.avatar.lstrip("/") + + return f"/v1/media/{path}" + # ---- 互动统计字段 ---- following_count = Column(Integer, default=0, nullable=False, server_default="0") # 关注了多少人 follower_count = Column(Integer, default=0, nullable=False, server_default="0") # 粉丝数