激光材料数据平台 1.3.0
公开数据看板、四位 Key 录入面板、管理后台与三级权限、动态字段配置、 PostgreSQL/SQLite 双支持、数据库备份,以及本版新增的成品图上传与预览。 图片相关: - 录入表单支持相册选图与手机端直接拍照,每条记录最多 6 张 - 浏览器内压缩到最长边 1600 并剥离 EXIF,入库统一为 JPG/PNG - 二进制直接入库,现有备份自动覆盖图片,部署无需新增卷 - 详情页缩略图宫格与全屏 lightbox,公开看板同样可见 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
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("当前数据库类型暂不支持恢复")
|
||||
Reference in New Issue
Block a user