Add admin operation log rollback

This commit is contained in:
Codex
2026-06-16 16:11:20 +08:00
parent 4875a0ee77
commit 69644b4ae8
4 changed files with 280 additions and 13 deletions
+210 -9
View File
@@ -28,7 +28,7 @@ from .domain import (
UNKNOWN_TEACHERS, UNKNOWN_TEACHERS,
WEEKDAYS, 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( CLASSNOTE_RE = re.compile(
@@ -1511,6 +1511,7 @@ OPERATION_LABELS = {
"admin-approve-deletion": "审核批准上课记录删除", "admin-approve-deletion": "审核批准上课记录删除",
"admin-update-course-summary-time": "课程小结补齐时间", "admin-update-course-summary-time": "课程小结补齐时间",
"admin-delete-course-summary": "课程小结删除", "admin-delete-course-summary": "课程小结删除",
"rollback-operation": "撤回操作",
} }
STATUS_LABELS = { STATUS_LABELS = {
@@ -1524,6 +1525,7 @@ STATUS_LABELS = {
"review": "待审核", "review": "待审核",
"conflict": "冲突", "conflict": "冲突",
"pending": "待处理", "pending": "待处理",
"rolled_back": "已撤回",
} }
TYPE_LABELS = { TYPE_LABELS = {
@@ -1571,7 +1573,7 @@ def migrate_operation_log_labels(path: Path) -> dict:
return {"updated": changed, "path": str(path)} 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] = [] rows: list[dict] = []
if path.exists(): if path.exists():
for raw_line in path.read_text(encoding="utf-8").splitlines(): 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) item = json.loads(raw_line)
except json.JSONDecodeError: except json.JSONDecodeError:
continue 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) 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 = rows[-limit:]
rows.reverse() rows.reverse()
return {"count": len(rows), "items": rows} 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: def normalize_filter_date(value: str) -> str:
text = value.strip().replace(".", "-") text = value.strip().replace(".", "-")
if not text: if not text:
+33
View File
@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from ..auth import verify_admin_auth from ..auth import verify_admin_auth
@@ -20,6 +22,7 @@ from ..data import (
migrate_operation_log_labels, migrate_operation_log_labels,
query_course_summaries, query_course_summaries,
reject_admin_task, reject_admin_task,
rollback_operation_log,
update_course_summary_time, update_course_summary_time,
) )
@@ -27,6 +30,16 @@ from ..data import (
router = APIRouter() 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") @router.get("/api/admin/tasks")
def admin_tasks( def admin_tasks(
status_filter: str = Query("", alias="status"), status_filter: str = Query("", alias="status"),
@@ -54,9 +67,29 @@ def admin_operation_logs(
operation=operation, operation=operation,
status_filter=status_filter, status_filter=status_filter,
student=student, 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") @router.get("/api/admin/course-summaries")
def admin_course_summaries( def admin_course_summaries(
q: str = Query(""), q: str = Query(""),
+4 -1
View File
@@ -342,6 +342,7 @@
<option value="审核驳回">审核驳回</option> <option value="审核驳回">审核驳回</option>
<option value="课程小结补齐时间">课程小结补齐时间</option> <option value="课程小结补齐时间">课程小结补齐时间</option>
<option value="课程小结删除">课程小结删除</option> <option value="课程小结删除">课程小结删除</option>
<option value="撤回操作">撤回操作</option>
</select> </select>
<select id="logStatus" aria-label="操作结果筛选"> <select id="logStatus" aria-label="操作结果筛选">
<option value="">全部结果</option> <option value="">全部结果</option>
@@ -353,6 +354,7 @@
<option value="已批准">已批准</option> <option value="已批准">已批准</option>
<option value="已更新">已更新</option> <option value="已更新">已更新</option>
<option value="已删除">已删除</option> <option value="已删除">已删除</option>
<option value="已撤回">已撤回</option>
</select> </select>
</div> </div>
</div> </div>
@@ -371,6 +373,7 @@
<th>学生</th> <th>学生</th>
<th>批次/任务</th> <th>批次/任务</th>
<th>详情</th> <th>详情</th>
<th>操作</th>
</tr> </tr>
</thead> </thead>
<tbody id="logRows"></tbody> <tbody id="logRows"></tbody>
@@ -417,6 +420,6 @@
</section> </section>
</main> </main>
<script src="/static/admin.js?v=20260616-duration-text"></script> <script src="/static/admin.js?v=20260616-rollback-log"></script>
</body> </body>
</html> </html>
+33 -3
View File
@@ -722,12 +722,25 @@ function renderLogDetail(item) {
if (Array.isArray(item.reasons) && item.reasons.length) details.push(`原因:${item.reasons.join("")}`); if (Array.isArray(item.reasons) && item.reasons.length) details.push(`原因:${item.reasons.join("")}`);
if (item.error) details.push(`错误:${item.error}`); if (item.error) details.push(`错误:${item.error}`);
if (item.backup_id) details.push(`备份:${item.backup_id}`); 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}`); if (item.saved_path) details.push(`文件:${item.saved_path}`);
return `<div class="log-detail">${details.map(escapeHtml).join("<br>") || "暂无详情"}</div>`; 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() { 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" }); const params = new URLSearchParams({ limit: "200" });
if (logOperation.value) params.set("operation", logOperation.value); if (logOperation.value) params.set("operation", logOperation.value);
if (logStatus.value) params.set("status", logStatus.value); if (logStatus.value) params.set("status", logStatus.value);
@@ -743,13 +756,26 @@ async function loadOperationLogs() {
<td>${escapeHtml(item.student || "")}</td> <td>${escapeHtml(item.student || "")}</td>
<td>${escapeHtml(item.batch_id || "")}${item.task_id ? `<br><small>任务 #${escapeHtml(item.task_id)}</small>` : ""}</td> <td>${escapeHtml(item.batch_id || "")}${item.task_id ? `<br><small>任务 #${escapeHtml(item.task_id)}</small>` : ""}</td>
<td>${renderLogDetail(item)}</td> <td>${renderLogDetail(item)}</td>
<td class="record-action-cell">${renderLogActions(item)}</td>
</tr>`) </tr>`)
.join(""); .join("");
if (!data.items.length) { 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) { } 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(); event.preventDefault();
loadOperationLogs(); loadOperationLogs();
}); });
logRows.addEventListener("click", (event) => {
const rollback = event.target.closest(".log-rollback");
if (rollback) rollbackOperationLog(rollback.dataset.logId);
});
classRegisterForm.addEventListener("submit", (event) => { classRegisterForm.addEventListener("submit", (event) => {
previewRegister(event, "class_record", linesFromTextarea(classRegisterLines), classRegisterStatus); previewRegister(event, "class_record", linesFromTextarea(classRegisterLines), classRegisterStatus);
}); });