diff --git a/app/api/v1/feedback.py b/app/api/v1/feedback.py index 48b8dcd..3391648 100644 --- a/app/api/v1/feedback.py +++ b/app/api/v1/feedback.py @@ -54,11 +54,13 @@ def _app_status(db_status: str) -> str: def _record_out(fb) -> FeedbackRecordOut: + images = fb.images or [] return FeedbackRecordOut( id=fb.id, content=fb.content, scene=getattr(fb, "scene", None), - images=fb.images or [], + images=images, + image_thumbnails=[media.feedback_thumbnail_url(url) for url in images], status=_app_status(fb.status), reject_reason=getattr(fb, "reject_reason", None), reward_coins=getattr(fb, "reward_coins", None), @@ -84,7 +86,7 @@ async def submit_feedback( device_model: str = Form(default=""), rom_name: str = Form(default=""), android_version: str = Form(default=""), - images: list[UploadFile] = File(default=[]), + images: list[UploadFile] = File(default=[]), # noqa: B008 - FastAPI dependency declaration ) -> FeedbackOut: content = content.strip() contact = contact.strip() diff --git a/app/core/media.py b/app/core/media.py index 597e6d4..185c02d 100644 --- a/app/core/media.py +++ b/app/core/media.py @@ -11,6 +11,7 @@ from __future__ import annotations import json +import logging import os import secrets import subprocess @@ -18,8 +19,17 @@ from decimal import ROUND_HALF_UP, Decimal, InvalidOperation from pathlib import Path from typing import Any +from PIL import Image, ImageOps + from app.core.config import settings +logger = logging.getLogger("shagua.media") + +_FEEDBACK_DIR = "feedback" +_FEEDBACK_THUMB_DIR = "feedback_thumbs" +_FEEDBACK_THUMB_MAX_PX = 256 +_FEEDBACK_THUMB_QUALITY = 78 + class MediaError(Exception): """上传文件不合法(类型/大小)。调用方转 400。""" @@ -68,8 +78,86 @@ def save_avatar(user_id: int, data: bytes) -> str: def save_feedback_image(user_id: int, data: bytes) -> str: - """保存反馈截图,返回相对 URL(`/media/feedback/`)。""" - return _save_image("feedback", user_id, data) + """保存反馈截图并预生成历史页缩略图,返回原图相对 URL。""" + url = _save_image(_FEEDBACK_DIR, user_id, data) + # 缩略图失败不影响反馈受理;读取缩略图 URL 时会按需重试并回退原图。 + ensure_feedback_thumbnail(url) + return url + + +def feedback_thumbnail_url(image_url: str) -> str: + """把反馈原图 URL 映射成确定的缩略图 URL,不在 records 接口内做图片解码。 + + 上传文件名由服务端生成且不会覆盖;旧数据在客户端真正请求可见图片时按需补图。 + """ + paths = _feedback_thumbnail_paths(image_url) + return paths[2] if paths is not None else image_url + + +def _feedback_thumbnail_paths(image_url: str) -> tuple[Path, Path, str] | None: + prefix = f"{settings.MEDIA_URL_PREFIX}/{_FEEDBACK_DIR}/" + if not image_url.startswith(prefix): + return None + + filename = image_url.removeprefix(prefix) + # 只接受当前目录下的单个文件名,避免数据库脏数据造成路径穿越。 + if not filename or Path(filename).name != filename: + return None + + source = _media_dir(_FEEDBACK_DIR) / filename + thumb_name = f"{Path(filename).stem}.jpg" + thumb = _media_dir(_FEEDBACK_THUMB_DIR) / thumb_name + thumb_url = f"{settings.MEDIA_URL_PREFIX}/{_FEEDBACK_THUMB_DIR}/{thumb_name}" + return source, thumb, thumb_url + + +def ensure_feedback_thumbnail(image_url: str) -> Path | None: + """确保缩略图存在并返回文件;生成失败时回退原图,供动态缩略图路由使用。""" + paths = _feedback_thumbnail_paths(image_url) + if paths is None: + return None + source, thumb, _ = paths + if thumb.is_file(): + return thumb + if not source.is_file(): + return None + + temp = thumb.with_name(f".{thumb.name}.{secrets.token_hex(4)}.tmp") + try: + with Image.open(source) as opened: + image = ImageOps.exif_transpose(opened) + image.thumbnail( + (_FEEDBACK_THUMB_MAX_PX, _FEEDBACK_THUMB_MAX_PX), + Image.Resampling.LANCZOS, + ) + if image.mode != "RGB": + image = image.convert("RGB") + image.save( + temp, + format="JPEG", + quality=_FEEDBACK_THUMB_QUALITY, + optimize=True, + ) + os.replace(temp, thumb) + return thumb + except (Image.DecompressionBombError, OSError, ValueError): + logger.warning("生成反馈缩略图失败: %s", source, exc_info=True) + return source + finally: + temp.unlink(missing_ok=True) + + +def feedback_thumbnail_file(filename: str) -> Path | None: + """由缩略图文件名找到原反馈图并按需生成,非法/不存在返回 None。""" + if not filename or Path(filename).name != filename or Path(filename).suffix.lower() != ".jpg": + return None + stem = Path(filename).stem + for ext in (".jpg", ".png", ".webp"): + original = _media_dir(_FEEDBACK_DIR) / f"{stem}{ext}" + if original.is_file(): + original_url = f"{settings.MEDIA_URL_PREFIX}/{_FEEDBACK_DIR}/{original.name}" + return ensure_feedback_thumbnail(original_url) + return None def save_report_image(user_id: int, data: bytes) -> str: diff --git a/app/main.py b/app/main.py index b93fe69..7d2a0cc 100644 --- a/app/main.py +++ b/app/main.py @@ -10,7 +10,7 @@ from collections.abc import AsyncIterator from contextlib import asynccontextmanager from pathlib import Path -from fastapi import FastAPI +from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles @@ -44,6 +44,7 @@ from app.api.v1.tasks import router as tasks_router from app.api.v1.user import router as user_router from app.api.v1.wallet import router as wallet_router from app.api.v1.wxpay import router as wxpay_router +from app.core import media from app.core.config import settings from app.core.cps_reconcile_worker import ( start_cps_reconcile_worker, @@ -82,6 +83,19 @@ setup_logging(debug=settings.APP_DEBUG) logger = logging.getLogger("shagua.main") +class FeedbackMediaStaticFiles(StaticFiles): + """反馈原图/缩略图文件名不可变,可长期缓存,避免列表反复回源。""" + + async def get_response(self, path: str, scope): + response = await super().get_response(path, scope) + media_path = path.replace("\\", "/").lstrip("/") + if response.status_code == 200 and media_path.startswith( + ("feedback/", "feedback_thumbs/") + ): + response.headers["Cache-Control"] = "public, max-age=31536000, immutable" + return response + + @asynccontextmanager async def lifespan(_: FastAPI) -> AsyncIterator[None]: # 提示而非强制建表:生产用 alembic upgrade head,本地 dev 也建议先跑一次 migration。 @@ -212,8 +226,24 @@ def download_apk() -> FileResponse: ) +@app.get( + f"{settings.MEDIA_URL_PREFIX}/feedback_thumbs/{{filename}}", + tags=["feedback"], + include_in_schema=False, +) +def feedback_thumbnail(filename: str) -> FileResponse: + """旧反馈图按首次可见请求补缩略图;新图上传时已预生成。""" + path = media.feedback_thumbnail_file(filename) + if path is None: + raise HTTPException(status_code=404, detail="图片不存在") + return FileResponse( + path, + headers={"Cache-Control": "public, max-age=31536000, immutable"}, + ) + + app.mount( settings.MEDIA_URL_PREFIX, - StaticFiles(directory=str(_media_root)), + FeedbackMediaStaticFiles(directory=str(_media_root)), name="media", ) diff --git a/app/schemas/feedback.py b/app/schemas/feedback.py index 0007155..c95c231 100644 --- a/app/schemas/feedback.py +++ b/app/schemas/feedback.py @@ -33,6 +33,8 @@ class FeedbackRecordOut(BaseModel): # 比价反馈的问题场景(找错商品/优惠不对…);普通反馈为 None scene: str | None = None images: list[str] = Field(default_factory=list) + # 与 images 下标一一对应;生成失败时该项回退原图 URL,兼容历史数据。 + image_thumbnails: list[str] = Field(default_factory=list) status: str reject_reason: str | None = None reward_coins: int | None = None diff --git a/pyproject.toml b/pyproject.toml index 3ab9552..2e1711d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,9 @@ dependencies = [ # multipart form (FastAPI 表单上传依赖) "python-multipart>=0.0.9", + # 用户反馈截图缩略图,避免 App 历史页为 48dp 小图下载数 MB 原图 + "pillow>=11.0.0", + # admin 后台账号密码 hash(用户侧是手机号+验证码登录,不需要密码;admin 才用) "bcrypt>=4.0.0",