Add admin operation log rollback
This commit is contained in:
+210
-9
@@ -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:
|
||||
|
||||
@@ -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(""),
|
||||
|
||||
@@ -342,6 +342,7 @@
|
||||
<option value="审核驳回">审核驳回</option>
|
||||
<option value="课程小结补齐时间">课程小结补齐时间</option>
|
||||
<option value="课程小结删除">课程小结删除</option>
|
||||
<option value="撤回操作">撤回操作</option>
|
||||
</select>
|
||||
<select id="logStatus" aria-label="操作结果筛选">
|
||||
<option value="">全部结果</option>
|
||||
@@ -353,6 +354,7 @@
|
||||
<option value="已批准">已批准</option>
|
||||
<option value="已更新">已更新</option>
|
||||
<option value="已删除">已删除</option>
|
||||
<option value="已撤回">已撤回</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -371,6 +373,7 @@
|
||||
<th>学生</th>
|
||||
<th>批次/任务</th>
|
||||
<th>详情</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="logRows"></tbody>
|
||||
@@ -417,6 +420,6 @@
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/static/admin.js?v=20260616-duration-text"></script>
|
||||
<script src="/static/admin.js?v=20260616-rollback-log"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+33
-3
@@ -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 `<div class="log-detail">${details.map(escapeHtml).join("<br>") || "暂无详情"}</div>`;
|
||||
}
|
||||
|
||||
function renderLogActions(item) {
|
||||
if (item.can_rollback) {
|
||||
return `<button class="small-button log-rollback" type="button" data-log-id="${escapeHtml(item.id)}">撤回</button>`;
|
||||
}
|
||||
if (item.backup_id && item.rollback_block_reason) {
|
||||
return `<small class="muted">${escapeHtml(item.rollback_block_reason)}</small>`;
|
||||
}
|
||||
return `<span class="muted">-</span>`;
|
||||
}
|
||||
|
||||
async function loadOperationLogs() {
|
||||
logRows.innerHTML = `<tr><td colspan="6" class="empty">正在读取</td></tr>`;
|
||||
logRows.innerHTML = `<tr><td colspan="7" class="empty">正在读取</td></tr>`;
|
||||
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() {
|
||||
<td>${escapeHtml(item.student || "")}</td>
|
||||
<td>${escapeHtml(item.batch_id || "")}${item.task_id ? `<br><small>任务 #${escapeHtml(item.task_id)}</small>` : ""}</td>
|
||||
<td>${renderLogDetail(item)}</td>
|
||||
<td class="record-action-cell">${renderLogActions(item)}</td>
|
||||
</tr>`)
|
||||
.join("");
|
||||
if (!data.items.length) {
|
||||
logRows.innerHTML = `<tr><td colspan="6" class="empty">没有符合条件的操作记录</td></tr>`;
|
||||
logRows.innerHTML = `<tr><td colspan="7" class="empty">没有符合条件的操作记录</td></tr>`;
|
||||
}
|
||||
} catch (error) {
|
||||
logRows.innerHTML = `<tr><td colspan="6" class="empty">读取失败:${escapeHtml(error.message)}</td></tr>`;
|
||||
logRows.innerHTML = `<tr><td colspan="7" class="empty">读取失败:${escapeHtml(error.message)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user