wendajia/backend/scripts/migrate_screenshots_to_files.py
2026-11-04 10:21:58 +08:00

90 lines
3.6 KiB
Python

"""Move legacy screenshot BLOBs to server files.
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. After verification, the database BLOB is cleared so
screenshots exist only in the configured server storage directory.
"""
import hashlib
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
BACKEND_ROOT = PROJECT_ROOT / "backend"
if str(BACKEND_ROOT) not in sys.path:
sys.path.insert(0, str(BACKEND_ROOT))
from sqlalchemy import inspect, or_, select, text # noqa: E402
from app.config import settings # noqa: E402
from app.database import SessionLocal, engine # noqa: E402
from app.models import AnswerScreenshot, AskAssignmentBatchScreenshot, AskAssignmentScreenshot # noqa: E402
from app.screenshot_storage import file_digest, store_screenshot # noqa: E402
TARGETS = (
(AnswerScreenshot, "answer_screenshots", "answers"),
(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)}
if "file_path" not in columns:
with engine.begin() as connection:
connection.execute(text(f"ALTER TABLE {table} ADD COLUMN file_path VARCHAR(1000) NULL"))
if engine.dialect.name == "mysql" and not columns["image_data"].get("nullable", False):
with engine.begin() as connection:
connection.execute(text(f"ALTER TABLE {table} MODIFY COLUMN image_data MEDIUMBLOB NULL"))
def migrate() -> None:
ensure_columns()
settings.screenshot_storage_dir.mkdir(parents=True, exist_ok=True)
migrated_rows = 0
migrated_bytes = 0
for model, table, category in TARGETS:
table_rows = 0
table_bytes = 0
while True:
with SessionLocal() as db:
image = db.scalar(
select(model)
.where(
model.image_data.is_not(None),
or_(model.file_path.is_(None), model.file_path == ""),
)
.order_by(model.id)
.limit(1)
)
if image is None:
break
source = bytes(image.image_data)
source_digest = hashlib.sha256(source).hexdigest()
relative_path = store_screenshot(source, image.filename, image.content_type, category)
stored_size, stored_digest = file_digest(relative_path)
if stored_size != len(source) or stored_digest != source_digest:
raise RuntimeError(f"{table}#{image.id} 文件校验失败")
image.file_path = relative_path
image.image_data = None
db.commit()
table_rows += 1
table_bytes += len(source)
if table_rows % 100 == 0:
print(f"{table}: migrated {table_rows} rows", flush=True)
migrated_rows += table_rows
migrated_bytes += table_bytes
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__":
migrate()