Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 44eb310bf8 | |||
| f1dd0f4a3d | |||
| 7dee829cfc | |||
| 6003d91fc1 |
@@ -54,13 +54,11 @@ def _app_status(db_status: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _record_out(fb) -> FeedbackRecordOut:
|
def _record_out(fb) -> FeedbackRecordOut:
|
||||||
images = fb.images or []
|
|
||||||
return FeedbackRecordOut(
|
return FeedbackRecordOut(
|
||||||
id=fb.id,
|
id=fb.id,
|
||||||
content=fb.content,
|
content=fb.content,
|
||||||
scene=getattr(fb, "scene", None),
|
scene=getattr(fb, "scene", None),
|
||||||
images=images,
|
images=fb.images or [],
|
||||||
image_thumbnails=[media.feedback_thumbnail_url(url) for url in images],
|
|
||||||
status=_app_status(fb.status),
|
status=_app_status(fb.status),
|
||||||
reject_reason=getattr(fb, "reject_reason", None),
|
reject_reason=getattr(fb, "reject_reason", None),
|
||||||
reward_coins=getattr(fb, "reward_coins", None),
|
reward_coins=getattr(fb, "reward_coins", None),
|
||||||
@@ -86,7 +84,7 @@ async def submit_feedback(
|
|||||||
device_model: str = Form(default=""),
|
device_model: str = Form(default=""),
|
||||||
rom_name: str = Form(default=""),
|
rom_name: str = Form(default=""),
|
||||||
android_version: str = Form(default=""),
|
android_version: str = Form(default=""),
|
||||||
images: list[UploadFile] = File(default=[]), # noqa: B008 - FastAPI dependency declaration
|
images: list[UploadFile] = File(default=[]),
|
||||||
) -> FeedbackOut:
|
) -> FeedbackOut:
|
||||||
content = content.strip()
|
content = content.strip()
|
||||||
contact = contact.strip()
|
contact = contact.strip()
|
||||||
|
|||||||
+2
-90
@@ -11,7 +11,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -19,17 +18,8 @@ from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from PIL import Image, ImageOps
|
|
||||||
|
|
||||||
from app.core.config import settings
|
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):
|
class MediaError(Exception):
|
||||||
"""上传文件不合法(类型/大小)。调用方转 400。"""
|
"""上传文件不合法(类型/大小)。调用方转 400。"""
|
||||||
@@ -78,86 +68,8 @@ def save_avatar(user_id: int, data: bytes) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def save_feedback_image(user_id: int, data: bytes) -> str:
|
def save_feedback_image(user_id: int, data: bytes) -> str:
|
||||||
"""保存反馈截图并预生成历史页缩略图,返回原图相对 URL。"""
|
"""保存反馈截图,返回相对 URL(`/media/feedback/<file>`)。"""
|
||||||
url = _save_image(_FEEDBACK_DIR, user_id, data)
|
return _save_image("feedback", 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:
|
def save_report_image(user_id: int, data: bytes) -> str:
|
||||||
|
|||||||
+2
-32
@@ -10,7 +10,7 @@ from collections.abc import AsyncIterator
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
@@ -44,7 +44,6 @@ from app.api.v1.tasks import router as tasks_router
|
|||||||
from app.api.v1.user import router as user_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.wallet import router as wallet_router
|
||||||
from app.api.v1.wxpay import router as wxpay_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.config import settings
|
||||||
from app.core.cps_reconcile_worker import (
|
from app.core.cps_reconcile_worker import (
|
||||||
start_cps_reconcile_worker,
|
start_cps_reconcile_worker,
|
||||||
@@ -83,19 +82,6 @@ setup_logging(debug=settings.APP_DEBUG)
|
|||||||
logger = logging.getLogger("shagua.main")
|
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
|
@asynccontextmanager
|
||||||
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||||
# 提示而非强制建表:生产用 alembic upgrade head,本地 dev 也建议先跑一次 migration。
|
# 提示而非强制建表:生产用 alembic upgrade head,本地 dev 也建议先跑一次 migration。
|
||||||
@@ -226,24 +212,8 @@ 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(
|
app.mount(
|
||||||
settings.MEDIA_URL_PREFIX,
|
settings.MEDIA_URL_PREFIX,
|
||||||
FeedbackMediaStaticFiles(directory=str(_media_root)),
|
StaticFiles(directory=str(_media_root)),
|
||||||
name="media",
|
name="media",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -33,8 +33,6 @@ class FeedbackRecordOut(BaseModel):
|
|||||||
# 比价反馈的问题场景(找错商品/优惠不对…);普通反馈为 None
|
# 比价反馈的问题场景(找错商品/优惠不对…);普通反馈为 None
|
||||||
scene: str | None = None
|
scene: str | None = None
|
||||||
images: list[str] = Field(default_factory=list)
|
images: list[str] = Field(default_factory=list)
|
||||||
# 与 images 下标一一对应;生成失败时该项回退原图 URL,兼容历史数据。
|
|
||||||
image_thumbnails: list[str] = Field(default_factory=list)
|
|
||||||
status: str
|
status: str
|
||||||
reject_reason: str | None = None
|
reject_reason: str | None = None
|
||||||
reward_coins: int | None = None
|
reward_coins: int | None = None
|
||||||
|
|||||||
@@ -35,9 +35,6 @@ dependencies = [
|
|||||||
# multipart form (FastAPI 表单上传依赖)
|
# multipart form (FastAPI 表单上传依赖)
|
||||||
"python-multipart>=0.0.9",
|
"python-multipart>=0.0.9",
|
||||||
|
|
||||||
# 用户反馈截图缩略图,避免 App 历史页为 48dp 小图下载数 MB 原图
|
|
||||||
"pillow>=11.0.0",
|
|
||||||
|
|
||||||
# admin 后台账号密码 hash(用户侧是手机号+验证码登录,不需要密码;admin 才用)
|
# admin 后台账号密码 hash(用户侧是手机号+验证码登录,不需要密码;admin 才用)
|
||||||
"bcrypt>=4.0.0",
|
"bcrypt>=4.0.0",
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user