feat: ingest course summaries on vps

This commit is contained in:
yangdawei
2026-06-15 11:22:52 +08:00
parent b2913dbd8a
commit a39f01b1e3
12 changed files with 1226 additions and 13 deletions
+538
View File
@@ -46,6 +46,10 @@ BACKUP_DIR_RE = re.compile(r"^\d{8}-\d{6}-\d{6}-")
BACKUP_KEEP_COUNT = 50
ACCOUNT_STATUSES = {"正常", "预警", "欠费", "结课", "退费"}
WEEKDAYS = ["星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"]
UNKNOWN_TEACHERS = {"", "待核对老师", "未知老师"}
UNKNOWN_SUBJECTS = {"", "待核对科目", "未知科目"}
HIGH_CONFIDENCE_VALUES = {"high", "", "高置信", "true", "1", "yes"}
AUTO_RECOGNITION_SOURCES = {"keyword", "rule", "model", "model_high_confidence", "大模型高置信识别", "关键词"}
ROLE_WORDS = {
"student": ("学生", "学员", "孩子", "同学"),
"teacher": ("老师", "教师"),
@@ -847,6 +851,536 @@ def approve_correction_task(tasks_path: Path, classnotes_path: Path, task_id: in
return {"task": task_to_dict(task), "backup_id": backup_dir.name}
def safe_filename_part(value: object) -> str:
text = str(value or "").strip()
text = re.sub(r"[\\/:*?\"<>|\s]+", "_", text)
return text.strip("._") or "未命名"
def sha1_text(value: str, length: int = 16) -> str:
return hashlib.sha1(value.encode("utf-8")).hexdigest()[:length]
def payload_bool(value: object) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return value != 0
text = str(value or "").strip().lower()
return text in {"1", "true", "yes", "y", "", "高置信", "可信"}
def normalize_summary_date(value: object) -> str:
text = str(value or "").strip().replace(".", "-")
if not text:
raise ValueError("课程小结缺少日期")
parsed = datetime.strptime(text, "%Y-%m-%d").date()
return parsed.isoformat()
def normalize_time_range_text(value: object) -> str:
text = str(value or "").strip()
if not text:
return ""
match = TIME_RANGE_RE.fullmatch(text)
if not match:
raise ValueError(f"课程小结时间段格式错误: {text}")
start_hour = int(match.group("sh"))
start_minute = int(match.group("sm"))
end_hour = int(match.group("eh"))
end_minute = int(match.group("em"))
if start_hour > 23 or end_hour > 23 or start_minute > 59 or end_minute > 59:
raise ValueError(f"课程小结时间段超出范围: {text}")
if end_hour * 60 + end_minute <= start_hour * 60 + start_minute:
raise ValueError(f"课程小结结束时间必须晚于开始时间: {text}")
return f"{start_hour:02d}:{start_minute:02d}-{end_hour:02d}:{end_minute:02d}"
def duration_text_from_minutes(minutes: int) -> str:
return f"{minutes // 60}小时{minutes % 60}"
def duration_minutes_from_time_range(time_range: str) -> int | None:
if not time_range:
return None
match = TIME_RANGE_RE.fullmatch(time_range)
if not match:
return None
start = int(match.group("sh")) * 60 + int(match.group("sm"))
end = int(match.group("eh")) * 60 + int(match.group("em"))
return end - start if end > start else None
def duration_minutes_from_summary(summary: dict) -> int | None:
raw_duration = summary.get("duration") or summary.get("duration_text") or ""
if raw_duration:
return int(round(parse_hours_text(str(raw_duration)) * 60))
for key in ("duration_minutes", "minutes"):
value = summary.get(key)
if value not in (None, ""):
return int(round(float(value)))
for key in ("duration_hours", "hours"):
value = summary.get(key)
if value not in (None, ""):
return int(round(float(value) * 60))
return duration_minutes_from_time_range(str(summary.get("time_range") or ""))
def normalize_course_summary(raw: dict) -> dict:
student = canonical_name(str(raw.get("student") or "").strip())
teacher = canonical_name(str(raw.get("teacher") or "").strip())
subject = parse_subject_code(str(raw.get("subject") or "").strip())
body = str(raw.get("body") or raw.get("content") or "").strip()
if not student:
raise ValueError("课程小结缺少学生")
if not body:
raise ValueError("课程小结缺少正文")
date_iso = normalize_summary_date(raw.get("date_iso") or raw.get("date") or raw.get("class_date"))
time_range = normalize_time_range_text(raw.get("time_range") or raw.get("raw_time") or raw.get("time") or "")
minutes = duration_minutes_from_summary({**raw, "time_range": time_range})
if time_range and minutes is not None:
time_minutes = duration_minutes_from_time_range(time_range)
if time_minutes is not None and abs(time_minutes - minutes) > 1:
raise ValueError(f"课程小结时间段和时长不一致: {time_range} / {duration_text_from_minutes(minutes)}")
source_id = str(raw.get("source_id") or "").strip()
if not source_id:
source_parts = [
str(raw.get("db") or ""),
str(raw.get("local_id") or ""),
student,
date_iso,
teacher,
subject,
time_range,
body[:200],
]
source_id = sha1_text("|".join(source_parts), 24)
summary = {
"source_id": source_id,
"student": student,
"date_iso": date_iso,
"time_range": time_range,
"duration_minutes": minutes,
"duration": duration_text_from_minutes(minutes) if minutes is not None else "",
"teacher": teacher,
"subject": subject,
"group": str(raw.get("group") or "").strip(),
"sender": str(raw.get("sender") or raw.get("sender_name") or "").strip(),
"sender_id": str(raw.get("sender_id") or "").strip(),
"message_time": str(raw.get("message_time") or "").strip(),
"message_date": str(raw.get("message_date") or "").strip(),
"db": str(raw.get("db") or "").strip(),
"local_id": str(raw.get("local_id") or "").strip(),
"title": str(raw.get("title") or "").strip(),
"body": body,
"recognition_source": str(raw.get("recognition_source") or raw.get("source") or "").strip(),
"confidence": str(raw.get("confidence") or "").strip(),
"teacher_trusted": payload_bool(raw.get("teacher_trusted") or raw.get("sender_teacher_trusted")),
"remark": str(raw.get("remark") or "").strip(),
}
if not summary["message_date"] and len(summary["message_time"]) >= 10:
summary["message_date"] = summary["message_time"][:10]
return summary
def course_summary_semantic_key(summary: dict) -> str:
body_digest = sha1_text(re.sub(r"\s+", "", str(summary.get("body") or "")), 12)
parts = [
summary.get("student", ""),
summary.get("date_iso", ""),
summary.get("teacher", ""),
normalize_subject(str(summary.get("subject") or "")),
summary.get("time_range", ""),
str(summary.get("duration_minutes") or ""),
body_digest,
]
return "|".join(str(part) for part in parts)
def default_course_summary_state() -> dict:
return {"version": 1, "seen_source_ids": [], "seen_semantic_keys": [], "batches": []}
def read_course_summary_state(path: Path) -> dict:
if not path.exists():
return default_course_summary_state()
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
raise ValueError(f"课程小结状态文件 JSON 格式错误: {path}") from exc
if not isinstance(payload, dict):
raise ValueError("课程小结状态文件必须是 JSON 对象")
payload.setdefault("version", 1)
payload.setdefault("seen_source_ids", [])
payload.setdefault("seen_semantic_keys", [])
payload.setdefault("batches", [])
return payload
def write_course_summary_state(path: Path, state: dict) -> None:
atomic_write_text(path, json.dumps(state, ensure_ascii=False, indent=2) + "\n")
def append_operation_log(path: Path, operation: str, status: str, **fields: object) -> str:
path.parent.mkdir(parents=True, exist_ok=True)
now = datetime.now().isoformat(timespec="seconds")
log_id = f"{datetime.now().strftime('%Y%m%d%H%M%S')}-{sha1_text(json.dumps(fields, ensure_ascii=False, sort_keys=True), 8)}"
row = {
"id": log_id,
"created_at": now,
"operation": operation,
"status": status,
**fields,
}
with path.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(row, ensure_ascii=False, sort_keys=True) + "\n")
return log_id
def list_operation_logs(path: Path, limit: int = 100, operation: str = "", status_filter: str = "", student: str = "") -> dict:
rows: list[dict] = []
if path.exists():
for raw_line in path.read_text(encoding="utf-8").splitlines():
if not raw_line.strip():
continue
try:
item = json.loads(raw_line)
except json.JSONDecodeError:
continue
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 course_summary_path(root: Path, summary: dict) -> Path:
student = safe_filename_part(summary["student"])
teacher = safe_filename_part(summary["teacher"] or "待核对老师")
subject = safe_filename_part(summary["subject"] or "待核对科目")
return root / student / f"{student}_{teacher}_{subject}.md"
def course_summary_heading(summary: dict, existing_headings: set[str]) -> str:
subject = normalize_subject(str(summary.get("subject") or "待核对科目"))
time_range = str(summary.get("time_range") or "").strip()
base = f"{summary['date_iso']} {time_range + ' ' if time_range else ''}{subject}课堂小结"
if base not in existing_headings:
return base
return f"{base}{sha1_text(str(summary.get('source_id') or '') + str(summary.get('body') or ''), 8)}"
def save_course_summary_markdown(root: Path, summary: dict) -> dict:
path = course_summary_path(root, summary)
existing = path.read_text(encoding="utf-8") if path.exists() else ""
existing_headings = set(re.findall(r"^###\s+(.+)$", existing, flags=re.M))
existing_compact = re.sub(r"\s+", "", existing)
body = str(summary.get("body") or "").rstrip()
body_compact = re.sub(r"\s+", "", body)
if body_compact and body_compact in existing_compact:
return {"path": str(path), "added": False}
group_name = str(summary.get("group") or summary["student"])
heading = course_summary_heading(summary, existing_headings)
lines: list[str] = []
if not existing.strip():
lines.extend([f"# {path.stem}", "", f"## {group_name}", ""])
elif f"## {group_name}" not in existing:
lines.extend(["", f"## {group_name}", ""])
lines.extend(
[
f"### {heading}",
"",
f"> 来源ID`{summary['source_id']}`",
f"> 发送时间:`{summary.get('message_time') or ''}`",
f"> 发送者:`{summary.get('sender') or ''}`",
"",
body,
"",
]
)
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a", encoding="utf-8") as handle:
if existing and not existing.endswith("\n"):
handle.write("\n")
handle.write("\n".join(lines).rstrip() + "\n")
return {"path": str(path), "added": True, "heading": heading}
def message_date_after_class_date(summary: dict) -> bool:
message_date = str(summary.get("message_date") or "")
if len(message_date) != 10:
return False
try:
return date.fromisoformat(str(summary["date_iso"])) > date.fromisoformat(message_date)
except ValueError:
return False
def course_summary_to_class_record_line(summary: dict) -> str:
date_iso = normalize_summary_date(summary.get("date_iso"))
record_date = date_iso.replace("-", ".")
weekday = WEEKDAYS[date.fromisoformat(date_iso).weekday()]
time_range = normalize_time_range_text(summary.get("time_range"))
minutes = summary.get("duration_minutes")
if minutes is None:
raise ValueError("课程小结缺少时长")
duration = duration_text_from_minutes(int(minutes))
return (
f"{record_date}-{weekday}-{time_range}-{summary['student']}-"
f"{duration}-{summary['teacher']}-{normalize_subject(str(summary['subject']))}"
)
def auto_register_reasons(summary: dict, classnotes_path: Path, accounts_path: Path) -> tuple[list[str], str]:
reasons: list[str] = []
confidence = str(summary.get("confidence") or "").lower()
recognition_source = str(summary.get("recognition_source") or "")
if confidence not in HIGH_CONFIDENCE_VALUES and recognition_source not in AUTO_RECOGNITION_SOURCES:
reasons.append("识别置信度不足")
if not summary.get("teacher_trusted"):
reasons.append("发送者老师映射未确认")
if summary.get("teacher") in UNKNOWN_TEACHERS:
reasons.append("老师待核对")
if normalize_subject(str(summary.get("subject") or "")) in UNKNOWN_SUBJECTS:
reasons.append("科目待核对")
if not summary.get("time_range"):
reasons.append("时间段缺失")
if summary.get("duration_minutes") is None:
reasons.append("时长缺失")
if message_date_after_class_date(summary):
reasons.append("课程日期晚于消息发送日期")
line = ""
try:
line = course_summary_to_class_record_line(summary)
parse_class_record_line(line)
except ValueError as exc:
reasons.append(str(exc))
try:
find_account_index(read_accounts(accounts_path), str(summary["student"]))
except ValueError as exc:
reasons.append(str(exc))
if line:
existing_lines = {raw.strip() for raw in classnotes_path.read_text(encoding="utf-8").splitlines()}
if line in existing_lines:
reasons.append("classnotes 已存在同一条上课记录")
return reasons, line
def create_course_summary_review_task(
tasks_path: Path,
summary: dict,
proposed_line: str,
reasons: list[str],
saved_path: str = "",
) -> dict:
tasks = read_admin_tasks(tasks_path)
now = datetime.now().isoformat(timespec="seconds")
task = {
"id": int(tasks["next_id"]),
"type": "course_summary_review",
"status": "pending",
"created_at": now,
"updated_at": now,
"source_id": summary["source_id"],
"student": summary["student"],
"summary": summary,
"proposed_line": proposed_line,
"reasons": reasons,
"saved_path": saved_path,
}
tasks["next_id"] = int(tasks["next_id"]) + 1
tasks["items"].append(task)
write_admin_tasks(tasks_path, tasks)
return task_to_dict(task)
def approve_course_summary_task(
tasks_path: Path,
classnotes_path: Path,
accounts_path: Path,
task_id: int,
) -> dict:
tasks = read_admin_tasks(tasks_path)
task = find_admin_task(tasks, task_id)
if task.get("type") != "course_summary_review":
raise ValueError("该任务不是课程小结审核")
if task.get("status") not in {"pending", "conflict"}:
raise ValueError("该任务已处理,不能重复批准")
proposed_line = str(task.get("proposed_line") or "").strip()
if not proposed_line:
summary = dict(task.get("summary") or {})
proposed_line = course_summary_to_class_record_line(summary)
try:
result = register_class_record_lines(classnotes_path, accounts_path, line=proposed_line)
except ValueError as exc:
task["status"] = "conflict"
task["updated_at"] = datetime.now().isoformat(timespec="seconds")
task["message"] = str(exc)
write_admin_tasks(tasks_path, tasks)
raise
task["status"] = "approved"
task["updated_at"] = datetime.now().isoformat(timespec="seconds")
task["reviewed_at"] = task["updated_at"]
task["registered_line"] = proposed_line
task["backup_id"] = result.get("backup_id", "")
write_admin_tasks(tasks_path, tasks)
return {"task": task_to_dict(task), "backup_id": result.get("backup_id", "")}
def approve_admin_task(
tasks_path: Path,
classnotes_path: Path,
accounts_path: Path,
task_id: int,
) -> dict:
task = find_admin_task(read_admin_tasks(tasks_path), task_id)
if task.get("type") == "class_record_correction":
return approve_correction_task(tasks_path, classnotes_path, task_id)
if task.get("type") == "course_summary_review":
return approve_course_summary_task(tasks_path, classnotes_path, accounts_path, task_id)
raise ValueError("不支持的审核任务类型")
def ingest_course_summaries(
*,
classnotes_path: Path,
accounts_path: Path,
tasks_path: Path,
summaries_root: Path,
state_path: Path,
operation_logs_path: Path,
batch_id: str,
window: dict,
students: list[str],
summaries: list[dict],
) -> dict:
state = read_course_summary_state(state_path)
seen_source_ids = set(str(item) for item in state.get("seen_source_ids", []))
seen_semantic_keys = set(str(item) for item in state.get("seen_semantic_keys", []))
result = {
"received": len(summaries),
"saved": 0,
"auto_registered": 0,
"review_pending": 0,
"duplicates": 0,
"rejected": 0,
"operation_log_ids": [],
"items": [],
}
for raw in summaries:
normalized: dict | None = None
try:
normalized = normalize_course_summary(raw)
source_id = normalized["source_id"]
semantic_key = course_summary_semantic_key(normalized)
if source_id in seen_source_ids or semantic_key in seen_semantic_keys:
result["duplicates"] += 1
log_id = append_operation_log(
operation_logs_path,
"course_summary_ingest",
"duplicate",
batch_id=batch_id,
source_id=source_id,
student=normalized["student"],
)
result["operation_log_ids"].append(log_id)
result["items"].append({"source_id": source_id, "status": "duplicate"})
continue
saved = save_course_summary_markdown(summaries_root, normalized)
result["saved"] += 1 if saved.get("added") else 0
reasons, proposed_line = auto_register_reasons(normalized, classnotes_path, accounts_path)
if reasons:
task = create_course_summary_review_task(
tasks_path,
normalized,
proposed_line,
reasons,
saved_path=str(saved.get("path") or ""),
)
result["review_pending"] += 1
status_value = "review"
task_id = task.get("id")
backup_id = ""
else:
register_result = register_class_record_lines(classnotes_path, accounts_path, line=proposed_line)
result["auto_registered"] += 1
status_value = "auto_registered"
task_id = None
backup_id = str(register_result.get("backup_id") or "")
seen_source_ids.add(source_id)
seen_semantic_keys.add(semantic_key)
log_id = append_operation_log(
operation_logs_path,
"course_summary_ingest",
status_value,
batch_id=batch_id,
source_id=source_id,
student=normalized["student"],
teacher=normalized.get("teacher", ""),
subject=normalized.get("subject", ""),
proposed_line=proposed_line,
reasons=reasons,
task_id=task_id,
backup_id=backup_id,
saved_path=str(saved.get("path") or ""),
)
result["operation_log_ids"].append(log_id)
result["items"].append(
{
"source_id": source_id,
"status": status_value,
"task_id": task_id,
"backup_id": backup_id,
"reasons": reasons,
}
)
except Exception as exc:
result["rejected"] += 1
source_id = str((normalized or raw).get("source_id") or "")
log_id = append_operation_log(
operation_logs_path,
"course_summary_ingest",
"rejected",
batch_id=batch_id,
source_id=source_id,
student=str((normalized or raw).get("student") or ""),
error=str(exc),
)
result["operation_log_ids"].append(log_id)
result["items"].append({"source_id": source_id, "status": "rejected", "error": str(exc)})
state["seen_source_ids"] = sorted(seen_source_ids)
state["seen_semantic_keys"] = sorted(seen_semantic_keys)
state.setdefault("batches", []).append(
{
"batch_id": batch_id,
"received_at": datetime.now().isoformat(timespec="seconds"),
"window": window,
"students": students,
"result": {key: result[key] for key in ("received", "saved", "auto_registered", "review_pending", "duplicates", "rejected")},
}
)
state["batches"] = state["batches"][-200:]
write_course_summary_state(state_path, state)
return result
def parse_record_date(text: str) -> date:
return datetime.strptime(text, "%Y.%m.%d").date()
@@ -1065,6 +1599,10 @@ def parse_subject_code(text: str) -> str:
return "".join(result)
def normalize_subject(text: str) -> str:
return parse_subject_code(text).strip()
def detect_subjects(query: str) -> list[str]:
subjects = {subject for subject in SUBJECTS if subject in query}
for alias, subject in SUBJECT_ALIASES.items():
+117 -3
View File
@@ -9,7 +9,7 @@ from pathlib import Path
import threading
from urllib.parse import parse_qs, quote
from fastapi import Depends, FastAPI, HTTPException, Query, Request, status
from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request, status
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse, Response
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from pydantic import BaseModel, Field, ValidationError
@@ -19,11 +19,14 @@ from .data import (
Account,
DuplicateRecordError,
Payment,
approve_correction_task,
append_operation_log,
approve_admin_task,
account_summary,
account_to_dict,
create_account,
filter_accounts,
ingest_course_summaries,
list_operation_logs,
list_admin_tasks,
query_records,
read_accounts,
@@ -41,9 +44,13 @@ STATIC_DIR = APP_DIR / "static"
CLASSNOTES_PATH = Path(os.getenv("CLASSNOTES_PATH", "/data/classnotes.txt"))
ACCOUNTS_PATH = Path(os.getenv("ACCOUNTS_PATH", "/data/学生课时账户.md"))
ADMIN_TASKS_PATH = Path(os.getenv("ADMIN_TASKS_PATH", "/data/admin_tasks.json"))
COURSE_SUMMARIES_ROOT = Path(os.getenv("COURSE_SUMMARIES_ROOT", "/data/course_summaries"))
COURSE_SUMMARY_STATE_PATH = Path(os.getenv("COURSE_SUMMARY_STATE_PATH", "/data/course_summary_state.json"))
OPERATION_LOGS_PATH = Path(os.getenv("OPERATION_LOGS_PATH", "/data/operation_logs.jsonl"))
BASIC_AUTH_PASSWORD = os.getenv("BASIC_AUTH_PASSWORD", "")
ACCOUNTS_AUTH_PASSWORD = os.getenv("ACCOUNTS_AUTH_PASSWORD") or os.getenv("ACCOUNT_AUTH_PASSWORD", "")
ADMIN_AUTH_PASSWORD = os.getenv("ADMIN_AUTH_PASSWORD") or ACCOUNTS_AUTH_PASSWORD
INGEST_AUTH_TOKEN = os.getenv("INGEST_AUTH_TOKEN", "")
RECORDS_SESSION_COOKIE = "xsk_records_session"
ACCOUNTS_SESSION_COOKIE = "xsk_accounts_session"
ADMIN_SESSION_COOKIE = "xsk_admin_session"
@@ -82,6 +89,42 @@ class CorrectionSubmitPayload(BaseModel):
items: list[CorrectionItemPayload]
class CourseSummaryPayload(BaseModel):
source_id: str = ""
student: str
date_iso: str = ""
date: str = ""
time_range: str = ""
raw_time: str = ""
duration: str = ""
duration_hours: float | None = None
duration_minutes: int | None = None
teacher: str = ""
subject: str = ""
group: str = ""
sender: str = ""
sender_name: str = ""
sender_id: str = ""
message_time: str = ""
message_date: str = ""
db: str = ""
local_id: str | int | None = ""
title: str = ""
body: str
recognition_source: str = ""
confidence: str = ""
teacher_trusted: bool = False
sender_teacher_trusted: bool = False
remark: str = ""
class CourseSummaryIngestPayload(BaseModel):
batch_id: str
window: dict = Field(default_factory=dict)
students: list[str] = Field(default_factory=list)
summaries: list[CourseSummaryPayload] = Field(default_factory=list)
async def read_register_payload(request: Request) -> RegisterLinesPayload:
body = await request.body()
if not body.strip():
@@ -217,6 +260,16 @@ def verify_any_auth(
)
def verify_ingest_token(x_ingest_token: str = Header(default="")) -> str:
configured_password("课程小结推送", INGEST_AUTH_TOKEN)
if not hmac.compare_digest(x_ingest_token, INGEST_AUTH_TOKEN):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="课程小结推送 token 不正确",
)
return "ingest"
def safe_next_path(value: str | None) -> str:
if not value or not value.startswith("/") or value.startswith("//"):
return "/"
@@ -634,6 +687,27 @@ async def register_payments(request: Request, _user: str = Depends(verify_admin_
return {"ok": True, **result}
@app.post("/api/ingest/course-summaries")
def ingest_course_summary_batch(payload: CourseSummaryIngestPayload, _user: str = Depends(verify_ingest_token)):
try:
with write_lock:
result = ingest_course_summaries(
classnotes_path=CLASSNOTES_PATH,
accounts_path=ACCOUNTS_PATH,
tasks_path=ADMIN_TASKS_PATH,
summaries_root=COURSE_SUMMARIES_ROOT,
state_path=COURSE_SUMMARY_STATE_PATH,
operation_logs_path=OPERATION_LOGS_PATH,
batch_id=payload.batch_id,
window=payload.window,
students=payload.students,
summaries=[item.dict() for item in payload.summaries],
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, **result}
@app.get("/api/health")
def health(_user: str = Depends(verify_records_auth)):
records = load_records()
@@ -642,6 +716,9 @@ def health(_user: str = Depends(verify_records_auth)):
"ok": True,
"classnotes": file_meta(CLASSNOTES_PATH),
"accounts": file_meta(ACCOUNTS_PATH),
"course_summaries": file_meta(COURSE_SUMMARIES_ROOT),
"course_summary_state": file_meta(COURSE_SUMMARY_STATE_PATH),
"operation_logs": file_meta(OPERATION_LOGS_PATH),
"records_count": len(records),
"accounts_count": len(accounts),
"account_summary": account_summary(accounts),
@@ -748,11 +825,39 @@ def admin_tasks(
raise HTTPException(status_code=500, detail=str(exc)) from exc
@app.get("/api/admin/operation-logs")
def admin_operation_logs(
limit: int = Query(100, ge=1, le=500),
operation: str = Query(""),
status_filter: str = Query("", alias="status"),
student: str = Query(""),
_user: str = Depends(verify_admin_auth),
):
return list_operation_logs(
OPERATION_LOGS_PATH,
limit=limit,
operation=operation,
status_filter=status_filter,
student=student,
)
@app.post("/api/admin/tasks/{task_id}/approve")
def admin_approve_task(task_id: int, _user: str = Depends(verify_admin_auth)):
try:
with write_lock:
result = approve_correction_task(ADMIN_TASKS_PATH, CLASSNOTES_PATH, task_id)
result = approve_admin_task(ADMIN_TASKS_PATH, CLASSNOTES_PATH, ACCOUNTS_PATH, task_id)
task = result.get("task", {})
append_operation_log(
OPERATION_LOGS_PATH,
"admin_task_approve",
"approved",
task_id=task_id,
task_type=str(task.get("type") or ""),
student=str(task.get("student") or task.get("corrected", {}).get("student") or ""),
source_id=str(task.get("source_id") or ""),
backup_id=str(result.get("backup_id") or task.get("backup_id") or ""),
)
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
return {"ok": True, **result}
@@ -763,6 +868,15 @@ def admin_reject_task(task_id: int, _user: str = Depends(verify_admin_auth)):
try:
with write_lock:
task = reject_admin_task(ADMIN_TASKS_PATH, task_id)
append_operation_log(
OPERATION_LOGS_PATH,
"admin_task_reject",
"rejected",
task_id=task_id,
task_type=str(task.get("type") or ""),
student=str(task.get("student") or task.get("corrected", {}).get("student") or ""),
source_id=str(task.get("source_id") or ""),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, "task": task}
+76 -2
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>管理后台</title>
<link rel="stylesheet" href="/static/styles.css?v=20260613-admin-review" />
<link rel="stylesheet" href="/static/styles.css?v=20260615-summary-ingest" />
</head>
<body>
<header class="topbar">
@@ -23,6 +23,8 @@
<nav class="admin-tabs" aria-label="管理后台功能">
<button class="admin-tab is-active" data-admin-tab="accounts" type="button">课时账户</button>
<button class="admin-tab" data-admin-tab="reviews" type="button">纠错审核</button>
<button class="admin-tab" data-admin-tab="summaries" type="button">课程小结审核</button>
<button class="admin-tab" data-admin-tab="logs" type="button">操作记录</button>
<button class="admin-tab" data-admin-tab="register" type="button">登记</button>
</nav>
@@ -136,6 +138,78 @@
</div>
</section>
<section id="summariesPanel" class="panel admin-panel" hidden>
<div class="section-head">
<h2>课程小结审核</h2>
<select id="summaryReviewStatus" aria-label="课程小结审核状态筛选">
<option value="pending">待审核</option>
<option value="conflict">冲突</option>
<option value="approved">已批准</option>
<option value="rejected">已驳回</option>
<option value="">全部状态</option>
</select>
</div>
<div id="summaryReviewMeta" class="summary-grid"></div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>编号</th>
<th>状态</th>
<th>课程信息</th>
<th>候选记录</th>
<th>小结原文</th>
<th>原因</th>
<th>操作</th>
</tr>
</thead>
<tbody id="summaryReviewRows"></tbody>
</table>
</div>
</section>
<section id="logsPanel" class="panel admin-panel" hidden>
<div class="section-head">
<h2>操作记录</h2>
<div class="quick-actions">
<select id="logOperation" aria-label="操作类型筛选">
<option value="">全部操作</option>
<option value="course_summary_ingest">小结接收</option>
<option value="admin_task_approve">审核批准</option>
<option value="admin_task_reject">审核驳回</option>
</select>
<select id="logStatus" aria-label="操作结果筛选">
<option value="">全部结果</option>
<option value="auto_registered">自动入账</option>
<option value="review">待审核</option>
<option value="duplicate">重复</option>
<option value="rejected">失败/驳回</option>
<option value="approved">已批准</option>
</select>
</div>
</div>
<form id="logFilterForm" class="search-row log-search-row">
<input id="logStudent" autocomplete="off" placeholder="按学生筛选" />
<button type="submit">筛选</button>
</form>
<div id="logMeta" class="summary-grid"></div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>时间</th>
<th>操作</th>
<th>结果</th>
<th>学生</th>
<th>批次/任务</th>
<th>详情</th>
</tr>
</thead>
<tbody id="logRows"></tbody>
</table>
</div>
</section>
<section id="registerPanel" class="panel admin-panel" hidden>
<div class="section-head">
<h2>登记</h2>
@@ -161,6 +235,6 @@
</section>
</main>
<script src="/static/admin.js?v=20260613-admin-review"></script>
<script src="/static/admin.js?v=20260615-summary-ingest"></script>
</body>
</html>
+137 -2
View File
@@ -3,6 +3,8 @@ const refreshBtn = document.querySelector("#refreshBtn");
const panels = {
accounts: document.querySelector("#accountsPanel"),
reviews: document.querySelector("#reviewsPanel"),
summaries: document.querySelector("#summariesPanel"),
logs: document.querySelector("#logsPanel"),
register: document.querySelector("#registerPanel"),
};
const accountForm = document.querySelector("#accountForm");
@@ -25,6 +27,15 @@ const editNote = document.querySelector("#editNote");
const reviewStatus = document.querySelector("#reviewStatus");
const reviewMeta = document.querySelector("#reviewMeta");
const reviewRows = document.querySelector("#reviewRows");
const summaryReviewStatus = document.querySelector("#summaryReviewStatus");
const summaryReviewMeta = document.querySelector("#summaryReviewMeta");
const summaryReviewRows = document.querySelector("#summaryReviewRows");
const logOperation = document.querySelector("#logOperation");
const logStatus = document.querySelector("#logStatus");
const logFilterForm = document.querySelector("#logFilterForm");
const logStudent = document.querySelector("#logStudent");
const logMeta = document.querySelector("#logMeta");
const logRows = document.querySelector("#logRows");
const classRegisterForm = document.querySelector("#classRegisterForm");
const classRegisterLines = document.querySelector("#classRegisterLines");
const classRegisterStatus = document.querySelector("#classRegisterStatus");
@@ -64,6 +75,13 @@ function statusClass(status) {
return "closed";
}
function taskStatusClass(status) {
if (status === "approved" || status === "auto_registered") return "normal";
if (status === "rejected" || status === "duplicate") return "closed";
if (status === "conflict") return "debt";
return "warning";
}
function renderPayments(payments) {
if (!payments.length) return "暂无";
return payments.map((item) => `${escapeHtml(item.date)}${fmtHours(item.hours)} 小时`).join("<br>");
@@ -93,6 +111,8 @@ function setActiveTab(tabName) {
});
if (tabName === "accounts") loadAccounts();
if (tabName === "reviews") loadReviews();
if (tabName === "summaries") loadSummaryReviews();
if (tabName === "logs") loadOperationLogs();
}
async function loadAdminHealth() {
@@ -270,13 +290,113 @@ async function loadReviews() {
}
}
async function reviewTask(taskId, action) {
function renderSummaryInfo(summary) {
const parts = [
summary.student,
summary.date_iso,
summary.time_range,
summary.teacher,
summary.subject,
].filter(Boolean);
return `${parts.map(escapeHtml).join("<br>")}<br><small>${escapeHtml(summary.group || "")}</small>`;
}
function renderSummaryBody(summary) {
const body = String(summary.body || "");
const preview = body.length > 260 ? `${body.slice(0, 260)}...` : body;
return `<div class="summary-body">${escapeHtml(preview)}</div>`;
}
async function loadSummaryReviews() {
summaryReviewRows.innerHTML = `<tr><td colspan="7" class="empty">正在读取</td></tr>`;
const params = new URLSearchParams({ type: "course_summary_review" });
if (summaryReviewStatus.value) params.set("status", summaryReviewStatus.value);
try {
const data = await fetchJson(`/api/admin/tasks?${params.toString()}`);
summaryReviewMeta.innerHTML = [metric("当前结果", `${data.count}`)].join("");
summaryReviewRows.innerHTML = data.items
.map((item) => {
const canReview = item.status === "pending" || item.status === "conflict";
const summary = item.summary || {};
const reasons = Array.isArray(item.reasons) ? item.reasons : [];
return `<tr>
<td>#${escapeHtml(item.id)}</td>
<td><span class="status ${taskStatusClass(item.status)}">${escapeHtml(item.status)}</span>${item.message ? `<br><small>${escapeHtml(item.message)}</small>` : ""}</td>
<td>${renderSummaryInfo(summary)}</td>
<td>${item.proposed_line ? renderReviewLine(item.proposed_line) : "<span class=\"muted\">暂无</span>"}</td>
<td>${renderSummaryBody(summary)}</td>
<td>${reasons.map(escapeHtml).join("<br>") || "待人工复核"}</td>
<td class="record-action-cell">
<div class="record-actions">
<button class="small-button summary-approve" type="button" data-task-id="${escapeHtml(item.id)}" ${canReview ? "" : "disabled"}>批准</button>
<button class="small-button summary-reject" type="button" data-task-id="${escapeHtml(item.id)}" ${canReview ? "" : "disabled"}>驳回</button>
</div>
</td>
</tr>`;
})
.join("");
if (!data.items.length) {
summaryReviewRows.innerHTML = `<tr><td colspan="7" class="empty">没有符合条件的课程小结审核项</td></tr>`;
}
} catch (error) {
summaryReviewRows.innerHTML = `<tr><td colspan="7" class="empty">读取失败:${escapeHtml(error.message)}</td></tr>`;
}
}
function renderLogDetail(item) {
const details = [];
if (item.source_id) details.push(`来源:${item.source_id}`);
if (item.teacher || item.subject) details.push(`老师/科目:${item.teacher || ""} ${item.subject || ""}`.trim());
if (item.proposed_line) details.push(`记录:${item.proposed_line}`);
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.saved_path) details.push(`文件:${item.saved_path}`);
return `<div class="log-detail">${details.map(escapeHtml).join("<br>") || "暂无详情"}</div>`;
}
async function loadOperationLogs() {
logRows.innerHTML = `<tr><td colspan="6" 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);
if (logStudent.value.trim()) params.set("student", logStudent.value.trim());
try {
const data = await fetchJson(`/api/admin/operation-logs?${params.toString()}`);
logMeta.innerHTML = [metric("当前结果", `${data.count}`)].join("");
logRows.innerHTML = data.items
.map((item) => `<tr>
<td>${escapeHtml(item.created_at || "")}</td>
<td>${escapeHtml(item.operation || "")}</td>
<td><span class="status ${taskStatusClass(item.status)}">${escapeHtml(item.status || "")}</span></td>
<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>
</tr>`)
.join("");
if (!data.items.length) {
logRows.innerHTML = `<tr><td colspan="6" class="empty">没有符合条件的操作记录</td></tr>`;
}
} catch (error) {
logRows.innerHTML = `<tr><td colspan="6" class="empty">读取失败:${escapeHtml(error.message)}</td></tr>`;
}
}
async function runTaskAction(taskId, action, reload) {
try {
await fetchJson(`/api/admin/tasks/${encodeURIComponent(taskId)}/${action}`, { method: "POST" });
} catch (error) {
alert(error.message);
}
loadReviews();
reload();
}
async function reviewTask(taskId, action) {
runTaskAction(taskId, action, loadReviews);
}
async function summaryReviewTask(taskId, action) {
runTaskAction(taskId, action, loadSummaryReviews);
}
function linesFromTextarea(textarea) {
@@ -330,6 +450,19 @@ reviewRows.addEventListener("click", (event) => {
if (approve) reviewTask(approve.dataset.taskId, "approve");
if (reject) reviewTask(reject.dataset.taskId, "reject");
});
summaryReviewStatus.addEventListener("change", loadSummaryReviews);
summaryReviewRows.addEventListener("click", (event) => {
const approve = event.target.closest(".summary-approve");
const reject = event.target.closest(".summary-reject");
if (approve) summaryReviewTask(approve.dataset.taskId, "approve");
if (reject) summaryReviewTask(reject.dataset.taskId, "reject");
});
logOperation.addEventListener("change", loadOperationLogs);
logStatus.addEventListener("change", loadOperationLogs);
logFilterForm.addEventListener("submit", (event) => {
event.preventDefault();
loadOperationLogs();
});
classRegisterForm.addEventListener("submit", (event) => {
submitRegister(event, classRegisterLines, classRegisterStatus, "/api/register/class-records");
});
@@ -340,6 +473,8 @@ refreshBtn.addEventListener("click", () => {
loadAdminHealth();
if (!panels.accounts.hidden) loadAccounts();
if (!panels.reviews.hidden) loadReviews();
if (!panels.summaries.hidden) loadSummaryReviews();
if (!panels.logs.hidden) loadOperationLogs();
});
loadAdminHealth();
+14
View File
@@ -379,6 +379,20 @@ textarea:focus {
line-height: 1.4;
}
.summary-body,
.log-detail {
max-width: 360px;
white-space: pre-wrap;
overflow-wrap: anywhere;
color: #344054;
font-size: 13px;
line-height: 1.45;
}
.muted {
color: var(--muted);
}
.correction-toolbar {
display: flex;
align-items: center;