From 69644b4ae83c886fca849fefd81d2ae54d19f7fd Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 16 Jun 2026 16:11:20 +0800 Subject: [PATCH] Add admin operation log rollback --- app/app/data.py | 219 ++++++++++++++++++++++++++++++++++++-- app/app/routers/admin.py | 33 ++++++ app/app/static/admin.html | 5 +- app/app/static/admin.js | 36 ++++++- 4 files changed, 280 insertions(+), 13 deletions(-) diff --git a/app/app/data.py b/app/app/data.py index 88e22be..4a90813 100644 --- a/app/app/data.py +++ b/app/app/data.py @@ -28,7 +28,7 @@ from .domain import ( UNKNOWN_TEACHERS, WEEKDAYS, ) -from .storage import atomic_write_text, create_data_backup, prune_data_backups +from .storage import BACKUP_DIR_RE, atomic_write_text, create_data_backup, prune_data_backups CLASSNOTE_RE = re.compile( @@ -1511,6 +1511,7 @@ OPERATION_LABELS = { "admin-approve-deletion": "审核批准上课记录删除", "admin-update-course-summary-time": "课程小结补齐时间", "admin-delete-course-summary": "课程小结删除", + "rollback-operation": "撤回操作", } STATUS_LABELS = { @@ -1524,6 +1525,7 @@ STATUS_LABELS = { "review": "待审核", "conflict": "冲突", "pending": "待处理", + "rolled_back": "已撤回", } TYPE_LABELS = { @@ -1571,7 +1573,7 @@ def migrate_operation_log_labels(path: Path) -> dict: return {"updated": changed, "path": str(path)} -def list_operation_logs(path: Path, limit: int = 100, operation: str = "", status_filter: str = "", student: str = "") -> dict: +def read_operation_log_rows(path: Path) -> list[dict]: rows: list[dict] = [] if path.exists(): for raw_line in path.read_text(encoding="utf-8").splitlines(): @@ -1581,19 +1583,218 @@ def list_operation_logs(path: Path, limit: int = 100, operation: str = "", statu item = json.loads(raw_line) except json.JSONDecodeError: continue - item = localize_operation_log_item(item) - if operation and item.get("operation") != operation: - continue - if status_filter and item.get("status") != status_filter: - continue - if student and student not in str(item.get("student", "")): - continue rows.append(item) + return rows + + +def backup_search_dirs(paths: list[Path]) -> list[Path]: + result: list[Path] = [] + seen: set[str] = set() + + def add(path: Path) -> None: + key = str(path) + if key not in seen: + seen.add(key) + result.append(path) + + for path in paths: + if path.name == "backups": + add(path) + elif path.suffix: + add(path.parent / "backups") + else: + add(path / "backups") + if path.exists(): + for nested in path.rglob("backups"): + if nested.is_dir(): + add(nested) + return result + + +def backup_context(search_paths: list[Path]) -> tuple[list[Path], list[tuple[Path, dict]]]: + backup_dirs = backup_search_dirs(search_paths) + metadata_items: list[tuple[Path, dict]] = [] + for backup_root in backup_dirs: + if not backup_root.exists(): + continue + for backup_dir in sorted( + path + for path in backup_root.iterdir() + if path.is_dir() and BACKUP_DIR_RE.match(path.name) + ): + try: + metadata_items.append((backup_dir, read_backup_metadata(backup_dir))) + except ValueError: + continue + return backup_dirs, metadata_items + + +def find_backup_dir(backup_id: str, search_paths: list[Path] | None = None, backup_dirs: list[Path] | None = None) -> Path: + value = backup_id.strip() + if not BACKUP_DIR_RE.match(value) or "/" in value or "\\" in value: + raise ValueError("备份ID格式错误") + for backup_root in backup_dirs if backup_dirs is not None else backup_search_dirs(search_paths or []): + candidate = backup_root / value + if candidate.is_dir(): + return candidate + raise ValueError(f"备份不存在或已清理: {backup_id}") + + +def read_backup_metadata(backup_dir: Path) -> dict: + metadata_path = backup_dir / "metadata.json" + if not metadata_path.exists(): + raise ValueError(f"备份元数据不存在: {backup_dir.name}") + try: + metadata = json.loads(metadata_path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise ValueError(f"备份元数据 JSON 格式错误: {backup_dir.name}") from exc + if not isinstance(metadata, dict): + raise ValueError("备份元数据必须是 JSON 对象") + metadata.setdefault("backup_id", backup_dir.name) + metadata.setdefault("files", []) + return metadata + + +def backup_source_path(backup_dir: Path, file_meta: dict) -> Path: + raw_path = str(file_meta.get("source_path") or "").strip() + if raw_path: + path = Path(raw_path) + if path.is_absolute(): + return path + return (backup_dir / path).resolve() + name = str(file_meta.get("name") or "").strip() + if not name: + raise ValueError("备份文件缺少 source_path/name") + return backup_dir.parent.parent / name + + +def backup_source_paths(backup_dir: Path, metadata: dict) -> set[str]: + paths: set[str] = set() + for file_meta in metadata.get("files") or []: + if isinstance(file_meta, dict): + paths.add(str(backup_source_path(backup_dir, file_meta))) + return paths + + +def operation_log_rollback_state( + item: dict, + rows: list[dict], + search_paths: list[Path], + backup_dirs: list[Path] | None = None, + metadata_items: list[tuple[Path, dict]] | None = None, +) -> dict: + backup_id = str(item.get("backup_id") or "").strip() + log_id = str(item.get("id") or "").strip() + operation = str(item.get("operation") or "") + if not backup_id: + return {"can_rollback": False, "rollback_block_reason": "没有备份"} + if operation == "撤回操作": + return {"can_rollback": False, "rollback_block_reason": "撤回记录不能再次撤回"} + for row in rows: + localized = localize_operation_log_item(row) + if localized.get("operation") == "撤回操作" and str(localized.get("target_log_id") or "") == log_id: + return {"can_rollback": False, "rollback_block_reason": "已撤回"} + try: + backup_dir = find_backup_dir(backup_id, search_paths, backup_dirs) + metadata = read_backup_metadata(backup_dir) + target_sources = backup_source_paths(backup_dir, metadata) + except ValueError as exc: + return {"can_rollback": False, "rollback_block_reason": str(exc)} + if not target_sources: + return {"can_rollback": False, "rollback_block_reason": "备份没有文件"} + if metadata_items is None: + _backup_dirs, metadata_items = backup_context(search_paths) + for other_dir, other_metadata in metadata_items: + if other_dir.name <= backup_dir.name: + continue + if target_sources & backup_source_paths(other_dir, other_metadata): + return { + "can_rollback": False, + "rollback_block_reason": f"已有后续备份 {other_dir.name}", + } + return {"can_rollback": True, "rollback_block_reason": ""} + + +def list_operation_logs( + path: Path, + limit: int = 100, + operation: str = "", + status_filter: str = "", + student: str = "", + backup_paths: list[Path] | None = None, +) -> dict: + raw_rows = read_operation_log_rows(path) + rows: list[dict] = [] + backup_dirs: list[Path] | None = None + metadata_items: list[tuple[Path, dict]] | None = None + if backup_paths is not None: + backup_dirs, metadata_items = backup_context(backup_paths) + for item in raw_rows: + item = localize_operation_log_item(item) + if backup_paths is not None: + item.update(operation_log_rollback_state(item, raw_rows, backup_paths, backup_dirs, metadata_items)) + if operation and item.get("operation") != operation: + continue + if status_filter and item.get("status") != status_filter: + continue + if student and student not in str(item.get("student", "")): + continue + rows.append(item) rows = rows[-limit:] rows.reverse() return {"count": len(rows), "items": rows} +def rollback_operation_log( + operation_logs_path: Path, + log_id: str, + backup_paths: list[Path], +) -> dict: + rows = read_operation_log_rows(operation_logs_path) + target: dict | None = None + for row in rows: + if str(row.get("id") or "") == log_id: + target = localize_operation_log_item(row) + break + if target is None: + raise ValueError(f"未找到操作记录: {log_id}") + state = operation_log_rollback_state(target, rows, backup_paths) + if not state.get("can_rollback"): + raise ValueError(str(state.get("rollback_block_reason") or "该记录不能撤回")) + + target_backup_id = str(target.get("backup_id") or "").strip() + backup_dir = find_backup_dir(target_backup_id, backup_paths) + metadata = read_backup_metadata(backup_dir) + file_contents: dict[Path, str] = {} + restore_contents: list[tuple[Path, str]] = [] + for file_meta in metadata.get("files") or []: + if not isinstance(file_meta, dict): + continue + source_path = backup_source_path(backup_dir, file_meta) + backup_file = backup_dir / str(file_meta.get("name") or source_path.name) + if not backup_file.exists(): + raise ValueError(f"备份文件不存在: {backup_file.name}") + file_contents[source_path] = source_path.read_text(encoding="utf-8") if source_path.exists() else "" + restore_contents.append((source_path, backup_file.read_text(encoding="utf-8"))) + if not restore_contents: + raise ValueError("备份没有可恢复文件") + + rollback_backup = create_data_backup( + "rollback-operation", + file_contents, + [log_id, target_backup_id], + ) + for source_path, content in restore_contents: + atomic_write_text(source_path, content) + + return { + "target_log_id": log_id, + "target_backup_id": target_backup_id, + "backup_id": rollback_backup.name, + "restored_files": [str(path) for path, _content in restore_contents], + } + + def normalize_filter_date(value: str) -> str: text = value.strip().replace(".", "-") if not text: diff --git a/app/app/routers/admin.py b/app/app/routers/admin.py index 5bcf0c0..7a2a752 100644 --- a/app/app/routers/admin.py +++ b/app/app/routers/admin.py @@ -1,5 +1,7 @@ from __future__ import annotations +from pathlib import Path + from fastapi import APIRouter, Depends, HTTPException, Query from ..auth import verify_admin_auth @@ -20,6 +22,7 @@ from ..data import ( migrate_operation_log_labels, query_course_summaries, reject_admin_task, + rollback_operation_log, update_course_summary_time, ) @@ -27,6 +30,16 @@ from ..data import ( router = APIRouter() +def rollback_backup_paths() -> list[Path]: + return [ + CLASSNOTES_PATH, + ACCOUNTS_PATH, + ADMIN_TASKS_PATH, + COURSE_SUMMARIES_ROOT, + OPERATION_LOGS_PATH, + ] + + @router.get("/api/admin/tasks") def admin_tasks( status_filter: str = Query("", alias="status"), @@ -54,9 +67,29 @@ def admin_operation_logs( operation=operation, status_filter=status_filter, student=student, + backup_paths=rollback_backup_paths(), ) +@router.post("/api/admin/operation-logs/{log_id}/rollback") +def admin_rollback_operation_log(log_id: str, _user: str = Depends(verify_admin_auth)): + try: + with write_lock: + result = rollback_operation_log(OPERATION_LOGS_PATH, log_id, rollback_backup_paths()) + append_operation_log( + OPERATION_LOGS_PATH, + "撤回操作", + "已撤回", + target_log_id=log_id, + target_backup_id=str(result.get("target_backup_id") or ""), + backup_id=str(result.get("backup_id") or ""), + restored_files=result.get("restored_files") or [], + ) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + return {"ok": True, **result} + + @router.get("/api/admin/course-summaries") def admin_course_summaries( q: str = Query(""), diff --git a/app/app/static/admin.html b/app/app/static/admin.html index b8cb2c1..5c38e53 100644 --- a/app/app/static/admin.html +++ b/app/app/static/admin.html @@ -342,6 +342,7 @@ + @@ -371,6 +373,7 @@ 学生 批次/任务 详情 + 操作 @@ -417,6 +420,6 @@ - + diff --git a/app/app/static/admin.js b/app/app/static/admin.js index a67304f..e3a3319 100644 --- a/app/app/static/admin.js +++ b/app/app/static/admin.js @@ -722,12 +722,25 @@ function renderLogDetail(item) { if (Array.isArray(item.reasons) && item.reasons.length) details.push(`原因:${item.reasons.join(";")}`); if (item.error) details.push(`错误:${item.error}`); if (item.backup_id) details.push(`备份:${item.backup_id}`); + if (item.target_log_id) details.push(`撤回记录:${item.target_log_id}`); + if (item.target_backup_id) details.push(`撤回备份:${item.target_backup_id}`); + if (Array.isArray(item.restored_files) && item.restored_files.length) details.push(`恢复文件:${item.restored_files.join(";")}`); if (item.saved_path) details.push(`文件:${item.saved_path}`); return `
${details.map(escapeHtml).join("
") || "暂无详情"}
`; } +function renderLogActions(item) { + if (item.can_rollback) { + return ``; + } + if (item.backup_id && item.rollback_block_reason) { + return `${escapeHtml(item.rollback_block_reason)}`; + } + return `-`; +} + async function loadOperationLogs() { - logRows.innerHTML = `正在读取`; + logRows.innerHTML = `正在读取`; const params = new URLSearchParams({ limit: "200" }); if (logOperation.value) params.set("operation", logOperation.value); if (logStatus.value) params.set("status", logStatus.value); @@ -743,13 +756,26 @@ async function loadOperationLogs() { ${escapeHtml(item.student || "")} ${escapeHtml(item.batch_id || "")}${item.task_id ? `
任务 #${escapeHtml(item.task_id)}` : ""} ${renderLogDetail(item)} + ${renderLogActions(item)} `) .join(""); if (!data.items.length) { - logRows.innerHTML = `没有符合条件的操作记录`; + logRows.innerHTML = `没有符合条件的操作记录`; } } catch (error) { - logRows.innerHTML = `读取失败:${escapeHtml(error.message)}`; + logRows.innerHTML = `读取失败:${escapeHtml(error.message)}`; + } +} + +async function rollbackOperationLog(logId) { + if (!confirm("确认撤回这条操作记录?系统只会在没有后续相关改动时恢复备份。")) return; + try { + await fetchJson(`/api/admin/operation-logs/${encodeURIComponent(logId)}/rollback`, { method: "POST" }); + await loadOperationLogs(); + await loadAdminHealth(); + await loadAccounts(); + } catch (error) { + alert(error.message); } } @@ -1007,6 +1033,10 @@ logFilterForm.addEventListener("submit", (event) => { event.preventDefault(); loadOperationLogs(); }); +logRows.addEventListener("click", (event) => { + const rollback = event.target.closest(".log-rollback"); + if (rollback) rollbackOperationLog(rollback.dataset.logId); +}); classRegisterForm.addEventListener("submit", (event) => { previewRegister(event, "class_record", linesFromTextarea(classRegisterLines), classRegisterStatus); });