修复文件存储路径错误的BUG

This commit is contained in:
db_huang 2026-11-04 10:21:58 +08:00
parent 9a4dd3ed0f
commit 858fc132ef
5 changed files with 74 additions and 10 deletions

View File

@ -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`,并对该目录单独备份。目录不存在或不可写时,上传接口会返回明确的存储错误。
## 问答助手大模型配置

View File

@ -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

View File

@ -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,
}

View File

@ -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__":

View File

@ -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")