Files
broccoliandClaude Fable 5 edfb216561 激光材料数据平台 1.3.0
公开数据看板、四位 Key 录入面板、管理后台与三级权限、动态字段配置、
PostgreSQL/SQLite 双支持、数据库备份,以及本版新增的成品图上传与预览。

图片相关:
- 录入表单支持相册选图与手机端直接拍照,每条记录最多 6 张
- 浏览器内压缩到最长边 1600 并剥离 EXIF,入库统一为 JPG/PNG
- 二进制直接入库,现有备份自动覆盖图片,部署无需新增卷
- 详情页缩略图宫格与全屏 lightbox,公开看板同样可见

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 22:06:02 +08:00

94 lines
3.6 KiB
Python

from __future__ import annotations
import hashlib
import shutil
import sqlite3
import subprocess
from datetime import datetime, timedelta
from pathlib import Path
from sqlalchemy.orm import Session
from .config import settings
from .models import BackupRecord
def file_sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def postgres_cli_url(url: str) -> str:
"""PostgreSQL CLI 不识别 SQLAlchemy 的 +psycopg 驱动标记。"""
return url.replace("postgresql+psycopg://", "postgresql://", 1)
def create_backup(db: Session, user_id: int | None = None) -> BackupRecord:
stamp = datetime.now().astimezone().strftime("%Y%m%d-%H%M%S")
url = settings.database_url
if url.startswith("sqlite"):
filename = f"database-{stamp}.sqlite3"
target = settings.backup_dir / filename
source_path = Path(url.split("///", 1)[1])
if not source_path.is_absolute():
source_path = Path.cwd() / source_path
source = sqlite3.connect(str(source_path))
destination = sqlite3.connect(str(target))
try:
source.backup(destination)
finally:
destination.close()
source.close()
database_type = "sqlite"
elif url.startswith(("postgresql", "postgres")):
filename = f"database-{stamp}.dump"
target = settings.backup_dir / filename
result = subprocess.run(["pg_dump", "--format=custom", "--file", str(target), postgres_cli_url(url)], capture_output=True, text=True)
if result.returncode:
raise RuntimeError(f"pg_dump 失败:{result.stderr.strip()}")
database_type = "postgresql"
else:
raise RuntimeError("当前数据库类型暂不支持自动备份")
record = BackupRecord(filename=filename, database_type=database_type, size_bytes=target.stat().st_size, sha256=file_sha256(target), created_by=user_id)
db.add(record)
db.commit()
db.refresh(record)
cleanup_old_backups(db)
return record
def cleanup_old_backups(db: Session) -> None:
cutoff = datetime.now().astimezone() - timedelta(days=settings.backup_keep_days)
for record in db.query(BackupRecord).filter(BackupRecord.created_at < cutoff).all():
path = settings.backup_dir / record.filename
if path.exists():
path.unlink()
db.delete(record)
db.commit()
def restore_backup(path: Path) -> None:
if not path.resolve().is_relative_to(settings.backup_dir.resolve()):
raise ValueError("备份文件必须位于 backups 目录")
url = settings.database_url
if url.startswith("sqlite"):
source = sqlite3.connect(str(path))
raw_path = Path(url.split("///", 1)[1])
target_path = raw_path if raw_path.is_absolute() else Path.cwd() / raw_path
temporary = target_path.with_suffix(".restore.tmp")
destination = sqlite3.connect(str(temporary))
try:
source.backup(destination)
finally:
destination.close()
source.close()
shutil.move(temporary, target_path)
elif url.startswith(("postgresql", "postgres")):
result = subprocess.run(["pg_restore", "--clean", "--if-exists", "--no-owner", "--dbname", postgres_cli_url(url), str(path)], capture_output=True, text=True)
if result.returncode:
raise RuntimeError(f"pg_restore 失败:{result.stderr.strip()}")
else:
raise RuntimeError("当前数据库类型暂不支持恢复")