From 858fc132ef16d9d1bdce99c6ce93c233632f1c9a Mon Sep 17 00:00:00 2001 From: db_huang <18006480240@163.com> Date: Wed, 4 Nov 2026 10:21:58 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=96=87=E4=BB=B6=E5=AD=98?= =?UTF-8?q?=E5=82=A8=E8=B7=AF=E5=BE=84=E9=94=99=E8=AF=AF=E7=9A=84BUG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- backend/.env.example | 5 ++- backend/app/screenshot_storage.py | 19 +++++++- .../scripts/migrate_screenshots_to_files.py | 14 +++--- backend/tests/test_screenshot_storage.py | 44 +++++++++++++++++++ 5 files changed, 74 insertions(+), 10 deletions(-) create mode 100644 backend/tests/test_screenshot_storage.py diff --git a/README.md b/README.md index 9a75b54..4b2ffdb 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ DINGTALK_SUPER_ADMIN_UNION_IDS= `DINGTALK_CLIENT_ID/SECRET` 填应用的 AppKey/AppSecret;`DINGTALK_CORP_ID` 填企业 CorpId;`DINGTALK_AGENT_ID` 用于标识工作台应用。当前免登接口不使用浏览器 OAuth 回调,但保留回调配置兼容旧入口。未配置免登参数时,普通浏览器仍可使用账号密码登录。 -MySQL 建表脚本位于 `backend/schema.sql`。未配置 `DATABASE_URL` 时,开发环境会自动使用 `backend/data/app.db` 运行,便于本地快速验收;上传截图保存在 `backend/data/uploads/screenshots`。 +MySQL 建表脚本位于 `backend/schema.sql`。未配置 `DATABASE_URL` 时,开发环境会自动使用 `backend/data/app.db` 运行,便于本地快速验收。上传截图只保存在服务器文件夹中,不在数据库保留副本。服务器部署时必须通过 `SCREENSHOT_STORAGE_DIR` 指向代码发布目录之外的持久化绝对路径,例如 `/www/wwwroot/wendajia_shared/screenshots`,并对该目录单独备份。目录不存在或不可写时,上传接口会返回明确的存储错误。 ## 问答助手大模型配置 diff --git a/backend/.env.example b/backend/.env.example index 0603085..91dd288 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,5 +1,6 @@ -DATABASE_URL=mysql+pymysql://ai_agent_test:WGzc6mKawk@49.233.179.206:3306/wendajia -SCREENSHOT_STORAGE_DIR=data/uploads/screenshots +DATABASE_URL=mysql+pymysql://user:password@127.0.0.1:3306/wendajia +# 服务器建议填写代码目录之外、不会被发布覆盖的绝对路径,并单独备份。 +SCREENSHOT_STORAGE_DIR=/www/wwwroot/wendajia_shared/screenshots SECRET_KEY=replace-with-a-long-random-secret COOKIE_SECURE=false SESSION_HOURS=12 diff --git a/backend/app/screenshot_storage.py b/backend/app/screenshot_storage.py index 71c58ca..5152e0b 100644 --- a/backend/app/screenshot_storage.py +++ b/backend/app/screenshot_storage.py @@ -1,4 +1,5 @@ import hashlib +import logging import mimetypes import uuid from pathlib import Path @@ -7,6 +8,9 @@ from fastapi import HTTPException, Response from .config import settings +logger = logging.getLogger(__name__) + + def _safe_extension(filename: str, content_type: str) -> str: extension = Path(filename or "").suffix.lower() if extension in {".jpg", ".jpeg", ".png", ".webp"}: @@ -40,10 +44,23 @@ def store_screenshot(data: bytes, filename: str, content_type: str, category: st def screenshot_fields(data: bytes, filename: str, content_type: str, category: str) -> dict: + try: + file_path = store_screenshot(data, filename, content_type, category) + except (OSError, ValueError) as exc: + logger.exception( + "Screenshot file write failed: category=%s filename=%s storage_dir=%s", + category, + filename, + settings.screenshot_storage_dir, + ) + raise HTTPException( + status_code=503, + detail="图片存储目录不可用,请联系管理员检查目录配置和写入权限", + ) from exc return { "filename": filename, "content_type": content_type, - "file_path": store_screenshot(data, filename, content_type, category), + "file_path": file_path, "image_data": None, } diff --git a/backend/scripts/migrate_screenshots_to_files.py b/backend/scripts/migrate_screenshots_to_files.py index deb7e94..c0c23ef 100644 --- a/backend/scripts/migrate_screenshots_to_files.py +++ b/backend/scripts/migrate_screenshots_to_files.py @@ -5,8 +5,8 @@ Run from the project root with:: python backend/scripts/migrate_screenshots_to_files.py Each row is committed only after the written file matches the source byte -length and SHA-256 digest. Re-running is safe: rows with a valid file_path and -an empty image_data value are skipped. +length and SHA-256 digest. After verification, the database BLOB is cleared so +screenshots exist only in the configured server storage directory. """ import hashlib @@ -19,7 +19,7 @@ BACKEND_ROOT = PROJECT_ROOT / "backend" if str(BACKEND_ROOT) not in sys.path: sys.path.insert(0, str(BACKEND_ROOT)) -from sqlalchemy import inspect, select, text # noqa: E402 +from sqlalchemy import inspect, or_, select, text # noqa: E402 from app.config import settings # noqa: E402 from app.database import SessionLocal, engine # noqa: E402 @@ -32,8 +32,6 @@ TARGETS = ( (AskAssignmentBatchScreenshot, "ask_assignment_batch_screenshots", "ask-batches"), (AskAssignmentScreenshot, "ask_assignment_screenshots", "ask-assignments"), ) - - def ensure_columns() -> None: for _model, table, _category in TARGETS: columns = {column["name"]: column for column in inspect(engine).get_columns(table)} @@ -57,7 +55,10 @@ def migrate() -> None: with SessionLocal() as db: image = db.scalar( select(model) - .where(model.image_data.is_not(None)) + .where( + model.image_data.is_not(None), + or_(model.file_path.is_(None), model.file_path == ""), + ) .order_by(model.id) .limit(1) ) @@ -81,6 +82,7 @@ def migrate() -> None: print(f"{table}: complete rows={table_rows} bytes={table_bytes}", flush=True) print(f"TOTAL rows={migrated_rows} bytes={migrated_bytes}", flush=True) print(f"FILES {settings.screenshot_storage_dir.resolve()}", flush=True) + print("DATABASE_COPIES CLEARED", flush=True) if __name__ == "__main__": diff --git a/backend/tests/test_screenshot_storage.py b/backend/tests/test_screenshot_storage.py new file mode 100644 index 0000000..845803c --- /dev/null +++ b/backend/tests/test_screenshot_storage.py @@ -0,0 +1,44 @@ +from types import SimpleNamespace + +from app import screenshot_storage + + +def test_new_screenshot_is_stored_only_as_server_file(tmp_path, monkeypatch): + monkeypatch.setattr(screenshot_storage.settings, "screenshot_storage_dir", tmp_path) + + fields = screenshot_storage.screenshot_fields( + b"image-content", "proof.png", "image/png", "answers" + ) + + assert fields["image_data"] is None + assert (tmp_path / fields["file_path"]).read_bytes() == b"image-content" + + +def test_missing_file_falls_back_to_database_copy(tmp_path, monkeypatch): + monkeypatch.setattr(screenshot_storage.settings, "screenshot_storage_dir", tmp_path) + image = SimpleNamespace( + file_path="answers/missing.png", + image_data=b"database-backup", + content_type="image/png", + ) + + assert screenshot_storage.screenshot_bytes(image) == b"database-backup" + + +def test_file_write_failure_returns_clear_service_error(tmp_path, monkeypatch): + monkeypatch.setattr(screenshot_storage.settings, "screenshot_storage_dir", tmp_path) + monkeypatch.setattr( + screenshot_storage, + "store_screenshot", + lambda *args, **kwargs: (_ for _ in ()).throw(PermissionError("read-only")), + ) + + try: + screenshot_storage.screenshot_fields( + b"image-content", "proof.png", "image/png", "answers" + ) + except screenshot_storage.HTTPException as exc: + assert exc.status_code == 503 + assert "图片存储目录不可用" in exc.detail + else: + raise AssertionError("expected screenshot storage error")