From 73d7d766eb12f9f08bfbfb301813adc9ced98a3e Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 6 Jul 2026 16:51:14 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B9=E9=80=A0=E4=B8=BA=20SQLite=20?= =?UTF-8?q?=E5=8E=9F=E7=94=9F=E8=AF=BB=E5=86=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/.env.example | 10 - app/MAINTENANCE.md | 13 +- app/Makefile | 1 + app/README.md | 22 +- app/app/ai_register.py | 5 +- app/app/api_utils.py | 34 +- app/app/config.py | 55 +- app/app/db.py | 26 +- app/app/main.py | 11 +- app/app/repository.py | 2789 ++++++++++++++++++++++-- app/app/routers/admin.py | 241 +- app/app/routers/health.py | 36 +- app/app/routers/ingest.py | 30 +- app/app/routers/records.py | 130 +- app/app/routers/student_profiles.py | 202 +- app/app/static/admin.html | 4 +- app/app/static/admin.js | 20 +- app/app/static/app.js | 2 +- app/app/static/index.html | 6 +- app/docker-compose.yml | 9 - app/scripts/db_smoke_test.py | 72 + app/scripts/deploy_to_vps.py | 3 +- app/scripts/import_course_summaries.py | 146 +- 23 files changed, 2974 insertions(+), 893 deletions(-) create mode 100644 app/scripts/db_smoke_test.py diff --git a/app/.env.example b/app/.env.example index fd76662..e5d3067 100644 --- a/app/.env.example +++ b/app/.env.example @@ -8,15 +8,5 @@ BASIC_AUTH_PASSWORD=change-me ADMIN_AUTH_PASSWORD=change-me INGEST_AUTH_TOKEN=change-this-ingest-token -USE_SQLITE_SOURCE=1 SQLITE_DB_PATH=/data/xsk_education.db -RUNTIME_DATA_ROOT=/data/runtime_text_cache LEGACY_TEXT_ROOT=/data - -CLASSNOTES_PATH=/data/classnotes.txt -ACCOUNTS_PATH=/data/学生课时账户.md -TEACHERS_PATH=/data/教师档案.md -ADMIN_TASKS_PATH=/data/admin_tasks.json -COURSE_SUMMARIES_ROOT=/data/course_summaries -COURSE_SUMMARY_STATE_PATH=/data/course_summary_state.json -OPERATION_LOGS_PATH=/data/operation_logs.jsonl diff --git a/app/MAINTENANCE.md b/app/MAINTENANCE.md index 39f3e33..a288ac3 100644 --- a/app/MAINTENANCE.md +++ b/app/MAINTENANCE.md @@ -41,7 +41,7 @@ - `make check`:检查前端 JS 语法和后端 Python 编译。 - `make smoke`:运行轻量前端行为烟测,覆盖排序按钮、组内排序和纠错后排序。 -- `make data-hash`:输出 `classnotes.txt`、`学生课时账户.md`,以及存在时的 `admin_tasks.json`、`course_summary_state.json` 的 SHA-256。 +- `make data-hash`:输出 SQLite 数据库、迁移报告和归档校验文件的 SHA-256。 - `make build`:构建 Docker 镜像。 - `make up`:重启 Docker Compose 服务。 - `make health`:带认证访问 `/api/health`。 @@ -104,15 +104,10 @@ git push origin HEAD:<当前分支> 业务数据文件位于: ```text -/root/新时空教务管理系统/data/classnotes.txt -/root/新时空教务管理系统/data/学生课时账户.md -/root/新时空教务管理系统/data/admin_tasks.json -/root/新时空教务管理系统/data/course_summaries/ -/root/新时空教务管理系统/data/course_summary_state.json -/root/新时空教务管理系统/data/operation_logs.jsonl +/root/新时空教务管理系统/data/xsk_education.db ``` -普通前端和查询类改动不应该改变这些文件;课程小结查询页是只读功能,也不应该改变这些文件。部署前后 `make data-hash` 输出应一致;如果涉及登记 API、学生档案编辑、课程小结自动入账或审核批准,先确认自动备份目录: +普通前端和查询类改动不应该改变数据库;课程小结查询页是只读功能,也不应该改变数据库。部署前后 `make data-hash` 输出应一致;如果涉及登记 API、学生档案编辑、课程小结自动入账或审核批准,先确认自动备份目录: ```text /root/新时空教务管理系统/data/backups/ @@ -148,7 +143,7 @@ git push origin HEAD:<当前分支> ## 课程小结迁移维护 -- VPS 是 `classnotes.txt` 和 `学生课时账户.md` 的唯一正式写入方。 +- VPS 的 `/data/xsk_education.db` 是唯一正式业务数据源。 - 本机课程小结采集脚本用 `XSK_INGEST_URL` 和 `XSK_INGEST_TOKEN` 推送批次;失败批次保存在本机 `推送失败队列/`。 - 管理后台的“课程小结审核”处理低置信或冲突小结;“操作记录”追踪接收、自动入账、重复、失败、审核批准和驳回。 - 历史小结导入使用 `scripts/import_course_summaries.py`,历史 `classnotes缺失.txt` 只生成审核任务,不自动扣课时。 diff --git a/app/Makefile b/app/Makefile index df59718..3a82664 100644 --- a/app/Makefile +++ b/app/Makefile @@ -12,6 +12,7 @@ check: smoke: node scripts/smoke_test.js + python3 scripts/db_smoke_test.py data-hash: sha256sum $(DATA_FILES) diff --git a/app/README.md b/app/README.md index 1492d73..21b1990 100644 --- a/app/README.md +++ b/app/README.md @@ -91,7 +91,7 @@ make migrate-sqlite /root/新时空教务管理系统/archives/text-source-before-sqlite-<时间>.tar.gz.sha256 ``` -学生表包含 `primary_entry_year` 字段,用于维护“小学一年级入学年份”。排课系统只读该字段并按课程日期动态推算年级。管理后台的学生档案编辑页可以维护该字段;运行时 `学生课时账户.md` 缓存会导出为 7 列格式,同时仍兼容旧 6 列缓存导入。 +学生表包含 `primary_entry_year` 字段,用于维护“小学一年级入学年份”。排课系统只读该字段并按课程日期动态推算年级。管理后台的学生档案编辑页可以维护该字段;旧 6/7 列 `学生课时账户.md` 仅作为一次性迁移输入兼容。 ## 课程小结推送 @@ -119,26 +119,19 @@ python3 /Users/yangdawei/Desktop/新时空业务源数据/新时空课程记录 ## 历史小结导入 -把本机历史课程小结目录同步或上传到 VPS 后,可在容器内执行一次性导入。历史导入只重建小结库和状态;历史 `classnotes缺失.txt` 默认转为审核任务,不自动扣课时。 +把本机历史课程小结目录同步或上传到 VPS 后,可在容器内执行一次性导入。历史导入直接写入 SQLite 课程小结表;历史 `classnotes缺失.txt` 默认转为审核任务,不自动扣课时。 ```bash cd /root/新时空教务管理系统/app docker compose exec xsk-education-management python scripts/import_course_summaries.py \ --source /data/import/课程小结采集 \ - --target /data/course_summaries \ - --state /data/course_summary_state.json \ - --tasks /data/admin_tasks.json \ - --operation-logs /data/operation_logs.jsonl \ + --db-path /data/xsk_education.db \ --missing-table /data/import/课程小结采集/classnotes缺失.txt ``` ## 数据备份 -通过登记 API、课程小结自动入账或审核批准修改正式课时数据时,服务会以 SQLite 事务写入 `/data/xsk_education.db`。旧纯文本事实源已封存为归档包;运行时文本缓存位于: - -```text -/root/新时空教务管理系统/data/runtime_text_cache/ -``` +通过登记 API、课程小结自动入账或审核批准修改正式课时数据时,服务会以 SQLite 事务写入 `/data/xsk_education.db`。旧纯文本事实源已封存为归档包,不再作为运行时缓存参与读写。 备份目录位于: @@ -146,7 +139,7 @@ docker compose exec xsk-education-management python scripts/import_course_summar /root/新时空教务管理系统/data/backups/ ``` -每次登记生成一个事务备份目录,目录内包含变更前的业务文件副本和 `metadata.json`。系统自动保留最近 50 次备份,超过后删除最旧备份。 +每次写业务数据前生成一个 SQLite 快照备份目录,目录内包含变更前的数据库快照和 `metadata.json`。系统自动保留最近 50 次备份,超过后删除最旧备份。 查看备份: @@ -154,13 +147,12 @@ docker compose exec xsk-education-management python scripts/import_course_summar ls -lt /root/新时空教务管理系统/data/backups/ ``` -恢复某次备份时,先停止服务,再把对应备份目录里的文件复制回数据目录,最后重启服务: +恢复某次备份优先使用后台“操作记录”里的撤回按钮。手工恢复时先停止服务,再把对应备份目录里的 `xsk_education.db` 作为数据库恢复源,最后重启服务: ```bash cd /root/新时空教务管理系统/app docker compose stop -cp /root/新时空教务管理系统/data/backups/<备份目录>/classnotes.txt /root/新时空教务管理系统/data/classnotes.txt 2>/dev/null || true -cp /root/新时空教务管理系统/data/backups/<备份目录>/学生课时账户.md /root/新时空教务管理系统/data/学生课时账户.md 2>/dev/null || true +cp /root/新时空教务管理系统/data/backups/<备份目录>/xsk_education.db /root/新时空教务管理系统/data/xsk_education.db docker compose up -d ``` diff --git a/app/app/ai_register.py b/app/app/ai_register.py index 38f2847..2544071 100644 --- a/app/app/ai_register.py +++ b/app/app/ai_register.py @@ -5,7 +5,6 @@ import re import secrets from typing import Any -from .config import ACCOUNTS_PATH from .data import ( SUBJECTS, canonical_teacher_name, @@ -16,8 +15,8 @@ from .data import ( normalize_lines, parse_class_record_line, parse_payment_line, - read_accounts, ) +from .repository import list_student_names REGISTER_TYPES = {"class_record", "payment", "course_summary"} @@ -152,7 +151,7 @@ def _questions_for_missing(register_type: str, missing_fields: list[str]) -> lis def _known_students() -> list[str]: try: - return [account.student for account in read_accounts(ACCOUNTS_PATH)] + return list_student_names() except Exception: return [] diff --git a/app/app/api_utils.py b/app/app/api_utils.py index 89fc239..f5c258b 100644 --- a/app/app/api_utils.py +++ b/app/app/api_utils.py @@ -6,9 +6,10 @@ from pathlib import Path from fastapi import HTTPException, Request from pydantic import ValidationError -from .config import ACCOUNTS_PATH, CLASSNOTES_PATH, TEACHERS_PATH, USE_SQLITE_SOURCE -from .data import Account, Payment, Teacher, read_accounts, read_classnotes, read_teachers -from .repository import ensure_runtime_cache +from .data import Account, Payment, Teacher +from .repository import load_records as load_records_from_db +from .repository import load_student_profiles as load_student_profiles_from_db +from .repository import load_teachers as load_teachers_from_db from .schemas import RegisterLinesPayload, StudentProfilePayload, TeacherPayload @@ -39,27 +40,24 @@ async def read_register_payload(request: Request) -> RegisterLinesPayload: def load_records(): - if USE_SQLITE_SOURCE: - ensure_runtime_cache() - if not CLASSNOTES_PATH.exists(): - raise HTTPException(status_code=503, detail=f"课程记录文件不存在: {CLASSNOTES_PATH}") - return read_classnotes(CLASSNOTES_PATH) + try: + return load_records_from_db() + except ValueError as exc: + raise HTTPException(status_code=503, detail=str(exc)) from exc def load_student_profiles(): - if USE_SQLITE_SOURCE: - ensure_runtime_cache() - if not ACCOUNTS_PATH.exists(): - raise HTTPException(status_code=503, detail=f"学生档案数据文件不存在: {ACCOUNTS_PATH}") - return read_accounts(ACCOUNTS_PATH) + try: + return load_student_profiles_from_db() + except ValueError as exc: + raise HTTPException(status_code=503, detail=str(exc)) from exc def load_teachers(): - if USE_SQLITE_SOURCE: - ensure_runtime_cache() - if not TEACHERS_PATH.exists(): - return [] - return read_teachers(TEACHERS_PATH) + try: + return load_teachers_from_db() + except ValueError as exc: + raise HTTPException(status_code=503, detail=str(exc)) from exc def file_meta(path: Path) -> dict: diff --git a/app/app/config.py b/app/app/config.py index 34c0b7f..4ca7c31 100644 --- a/app/app/config.py +++ b/app/app/config.py @@ -9,34 +9,7 @@ APP_DIR = Path(__file__).resolve().parent STATIC_DIR = APP_DIR / "static" SQLITE_DB_PATH = Path(os.getenv("SQLITE_DB_PATH", "/data/xsk_education.db")) -RUNTIME_DATA_ROOT = Path(os.getenv("RUNTIME_DATA_ROOT", "/data/runtime_text_cache")) LEGACY_TEXT_ROOT = Path(os.getenv("LEGACY_TEXT_ROOT", "/data")) -USE_SQLITE_SOURCE = os.getenv("USE_SQLITE_SOURCE", "1").strip().lower() not in {"0", "false", "no"} - -if USE_SQLITE_SOURCE: - CLASSNOTES_PATH = RUNTIME_DATA_ROOT / "classnotes.txt" - ACCOUNTS_PATH = RUNTIME_DATA_ROOT / "学生课时账户.md" - TEACHERS_PATH = RUNTIME_DATA_ROOT / "教师档案.md" - ADMIN_TASKS_PATH = RUNTIME_DATA_ROOT / "admin_tasks.json" - COURSE_SUMMARIES_ROOT = RUNTIME_DATA_ROOT / "course_summaries" - COURSE_SUMMARY_STATE_PATH = RUNTIME_DATA_ROOT / "course_summary_state.json" - OPERATION_LOGS_PATH = RUNTIME_DATA_ROOT / "operation_logs.jsonl" -else: - CLASSNOTES_PATH = Path(os.getenv("CLASSNOTES_PATH", "/data/classnotes.txt")) - ACCOUNTS_PATH = Path(os.getenv("ACCOUNTS_PATH", "/data/学生课时账户.md")) - TEACHERS_PATH = Path(os.getenv("TEACHERS_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")) - -LEGACY_CLASSNOTES_PATH = Path(os.getenv("LEGACY_CLASSNOTES_PATH", str(LEGACY_TEXT_ROOT / "classnotes.txt"))) -LEGACY_ACCOUNTS_PATH = Path(os.getenv("LEGACY_ACCOUNTS_PATH", str(LEGACY_TEXT_ROOT / "学生课时账户.md"))) -LEGACY_TEACHERS_PATH = Path(os.getenv("LEGACY_TEACHERS_PATH", str(LEGACY_TEXT_ROOT / "教师档案.md"))) -LEGACY_ADMIN_TASKS_PATH = Path(os.getenv("LEGACY_ADMIN_TASKS_PATH", str(LEGACY_TEXT_ROOT / "admin_tasks.json"))) -LEGACY_COURSE_SUMMARIES_ROOT = Path(os.getenv("LEGACY_COURSE_SUMMARIES_ROOT", str(LEGACY_TEXT_ROOT / "course_summaries"))) -LEGACY_COURSE_SUMMARY_STATE_PATH = Path(os.getenv("LEGACY_COURSE_SUMMARY_STATE_PATH", str(LEGACY_TEXT_ROOT / "course_summary_state.json"))) -LEGACY_OPERATION_LOGS_PATH = Path(os.getenv("LEGACY_OPERATION_LOGS_PATH", str(LEGACY_TEXT_ROOT / "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", "") @@ -48,30 +21,4 @@ ACCOUNTS_SESSION_COOKIE = "xsk_accounts_session" ADMIN_SESSION_COOKIE = "xsk_admin_session" SESSION_MAX_AGE = 60 * 60 * 24 * 30 -class SQLiteBackedWriteLock: - def __init__(self) -> None: - self._lock = threading.Lock() - - def __enter__(self): - self._lock.acquire() - if USE_SQLITE_SOURCE: - from .repository import export_database_to_runtime_cache - - export_database_to_runtime_cache() - return self - - def __exit__(self, exc_type, exc, tb) -> bool: - try: - if USE_SQLITE_SOURCE: - from .repository import export_database_to_runtime_cache, sync_runtime_cache_to_database - - if exc_type is None: - sync_runtime_cache_to_database() - else: - export_database_to_runtime_cache() - finally: - self._lock.release() - return False - - -write_lock = SQLiteBackedWriteLock() if USE_SQLITE_SOURCE else threading.Lock() +write_lock = threading.Lock() diff --git a/app/app/db.py b/app/app/db.py index fd37da8..cb28bf0 100644 --- a/app/app/db.py +++ b/app/app/db.py @@ -8,7 +8,7 @@ from typing import Iterator from .config import SQLITE_DB_PATH -SCHEMA_VERSION = 2 +SCHEMA_VERSION = 3 def connect(db_path: Path | None = None) -> sqlite3.Connection: @@ -134,11 +134,21 @@ def initialize_schema(conn: sqlite3.Connection) -> None: subject TEXT NOT NULL DEFAULT '', date_iso TEXT NOT NULL DEFAULT '', time_range TEXT NOT NULL DEFAULT '', + duration_minutes INTEGER, message_time TEXT NOT NULL DEFAULT '', + message_date TEXT NOT NULL DEFAULT '', sender TEXT NOT NULL DEFAULT '', + sender_id TEXT NOT NULL DEFAULT '', source_id TEXT NOT NULL DEFAULT '', + source_db TEXT NOT NULL DEFAULT '', + local_id TEXT NOT NULL DEFAULT '', + recognition_source TEXT NOT NULL DEFAULT '', + confidence TEXT NOT NULL DEFAULT '', + teacher_trusted INTEGER NOT NULL DEFAULT 0, + remark TEXT NOT NULL DEFAULT '', semantic_key TEXT NOT NULL DEFAULT '', content_hash TEXT NOT NULL, + payload_json TEXT NOT NULL DEFAULT '{}', sort_order INTEGER NOT NULL DEFAULT 0 ); @@ -164,11 +174,25 @@ def initialize_schema(conn: sqlite3.Connection) -> None: CREATE INDEX IF NOT EXISTS idx_class_records_student_date ON class_records(student, record_date); CREATE INDEX IF NOT EXISTS idx_class_records_teacher ON class_records(teacher); + CREATE INDEX IF NOT EXISTS idx_class_records_record_date ON class_records(record_date); CREATE INDEX IF NOT EXISTS idx_course_summaries_identity ON course_summaries(student, teacher, subject, date_iso, time_range); + CREATE INDEX IF NOT EXISTS idx_course_summaries_source_id ON course_summaries(source_id); + CREATE INDEX IF NOT EXISTS idx_course_summaries_semantic_key ON course_summaries(semantic_key); + CREATE INDEX IF NOT EXISTS idx_admin_tasks_status_type ON admin_tasks(status, task_type); CREATE INDEX IF NOT EXISTS idx_operation_logs_created_at ON operation_logs(created_at); """ ) ensure_column(conn, "students", "primary_entry_year", "INTEGER") + ensure_column(conn, "course_summaries", "duration_minutes", "INTEGER") + ensure_column(conn, "course_summaries", "message_date", "TEXT NOT NULL DEFAULT ''") + ensure_column(conn, "course_summaries", "sender_id", "TEXT NOT NULL DEFAULT ''") + ensure_column(conn, "course_summaries", "source_db", "TEXT NOT NULL DEFAULT ''") + ensure_column(conn, "course_summaries", "local_id", "TEXT NOT NULL DEFAULT ''") + ensure_column(conn, "course_summaries", "recognition_source", "TEXT NOT NULL DEFAULT ''") + ensure_column(conn, "course_summaries", "confidence", "TEXT NOT NULL DEFAULT ''") + ensure_column(conn, "course_summaries", "teacher_trusted", "INTEGER NOT NULL DEFAULT 0") + ensure_column(conn, "course_summaries", "remark", "TEXT NOT NULL DEFAULT ''") + ensure_column(conn, "course_summaries", "payload_json", "TEXT NOT NULL DEFAULT '{}'") conn.execute( "INSERT OR REPLACE INTO metadata(key, value) VALUES('schema_version', ?)", (str(SCHEMA_VERSION),), diff --git a/app/app/main.py b/app/app/main.py index a7902a0..a777e9d 100644 --- a/app/app/main.py +++ b/app/app/main.py @@ -3,9 +3,7 @@ from __future__ import annotations from fastapi import FastAPI, Request from fastapi.responses import JSONResponse -from .config import OPERATION_LOGS_PATH, USE_SQLITE_SOURCE, write_lock -from .data import migrate_operation_log_labels -from .repository import ensure_runtime_cache +from .repository import initialize_runtime_database from .routers import admin, ai_register, health, ingest, pages, records, student_profiles @@ -13,11 +11,8 @@ app = FastAPI(title="新时空教务管理系统", version="1.0.0") @app.on_event("startup") -def prepare_sqlite_runtime_cache() -> None: - if USE_SQLITE_SOURCE: - ensure_runtime_cache() - with write_lock: - migrate_operation_log_labels(OPERATION_LOGS_PATH) +def prepare_sqlite_runtime() -> None: + initialize_runtime_database() @app.exception_handler(ValueError) diff --git a/app/app/repository.py b/app/app/repository.py index 86ef67f..a1f69aa 100644 --- a/app/app/repository.py +++ b/app/app/repository.py @@ -4,26 +4,16 @@ from collections import defaultdict from datetime import date, datetime import hashlib import json -import os from pathlib import Path +import re import shutil import sqlite3 from typing import Iterable -from .config import ( - ACCOUNTS_PATH, - ADMIN_TASKS_PATH, - CLASSNOTES_PATH, - COURSE_SUMMARIES_ROOT, - COURSE_SUMMARY_STATE_PATH, - OPERATION_LOGS_PATH, - RUNTIME_DATA_ROOT, - SQLITE_DB_PATH, - TEACHERS_PATH, -) -from .db import connect, initialize_schema, transaction -from .domain import Account, ClassRecord, Payment, Teacher -from .storage import atomic_write_text +from .config import SQLITE_DB_PATH +from .db import SCHEMA_VERSION, connect, initialize_schema, transaction +from .domain import Account, ClassRecord, DuplicateRecordError, Payment, Teacher +from .storage import BACKUP_DIR_RE, atomic_write_text, create_backup_directory, prune_data_backups CLASSNOTES_HEADER = """# 课程记录 @@ -381,28 +371,6 @@ def replace_database_from_paths( return _insert_sources(conn, sources, allow_balance_adjustments=allow_balance_adjustments) -def sync_runtime_cache_to_database() -> dict: - with transaction(SQLITE_DB_PATH) as conn: - return replace_database_from_paths( - conn, - classnotes_path=CLASSNOTES_PATH, - accounts_path=ACCOUNTS_PATH, - teachers_path=TEACHERS_PATH, - tasks_path=ADMIN_TASKS_PATH, - summaries_root=COURSE_SUMMARIES_ROOT, - state_path=COURSE_SUMMARY_STATE_PATH, - operation_logs_path=OPERATION_LOGS_PATH, - allow_balance_adjustments=True, - ) - - -def _class_records_from_db(conn: sqlite3.Connection) -> list[str]: - rows = conn.execute( - "SELECT raw_line FROM class_records ORDER BY sort_order, id" - ).fetchall() - return [str(row["raw_line"]) for row in rows] - - def _payments_by_student(conn: sqlite3.Connection) -> dict[str, list[Payment]]: rows = conn.execute( """ @@ -499,11 +467,15 @@ def _teachers_from_db(conn: sqlite3.Connection) -> list[Teacher]: def _tasks_from_db(conn: sqlite3.Connection) -> dict: state_rows = conn.execute("SELECT key, value FROM admin_task_state").fetchall() state = {str(row["key"]): str(row["value"]) for row in state_rows} - task_rows = conn.execute("SELECT payload_json FROM admin_tasks ORDER BY sort_order, id").fetchall() + task_rows = conn.execute("SELECT id, payload_json FROM admin_tasks ORDER BY sort_order, id").fetchall() + items = [json.loads(str(row["payload_json"])) for row in task_rows] + next_id = int(state.get("next_id") or 0) + if next_id <= 0: + next_id = max([int(row["id"]) for row in task_rows] or [0]) + 1 return { "version": int(state.get("version") or 1), - "next_id": int(state.get("next_id") or 1), - "items": [json.loads(str(row["payload_json"])) for row in task_rows], + "next_id": next_id, + "items": items, } @@ -512,111 +484,6 @@ def _operation_logs_from_db(conn: sqlite3.Connection) -> list[dict]: return [json.loads(str(row["payload_json"])) for row in rows] -def _summary_state_from_db(conn: sqlite3.Connection) -> dict: - source_ids = [ - str(row["value"]) - for row in conn.execute("SELECT value FROM course_summary_seen_keys WHERE kind = 'source_id' ORDER BY value") - ] - semantic_keys = [ - str(row["value"]) - for row in conn.execute("SELECT value FROM course_summary_seen_keys WHERE kind = 'semantic_key' ORDER BY value") - ] - batches = [ - json.loads(str(row["payload_json"])) - for row in conn.execute("SELECT payload_json FROM ingest_batches ORDER BY sort_order, received_at, batch_id") - ] - return {"version": 1, "seen_source_ids": source_ids, "seen_semantic_keys": semantic_keys, "batches": batches[-200:]} - - -def _write_accounts(path: Path, accounts: list[Account]) -> None: - from .data import format_account_row - - lines = [ACCOUNTS_HEADER.rstrip("\n")] - for account in accounts: - lines.append(format_account_row(account)) - atomic_write_text(path, "\n".join(lines).rstrip() + "\n") - - -def _write_teachers(path: Path, teachers: list[Teacher]) -> None: - lines = [TEACHERS_HEADER.rstrip("\n")] - for teacher in teachers: - lines.append( - f"| {teacher.teacher_id} | {teacher.name} | {teacher.alias} | " - f"{'、'.join(teacher.subjects)} | {teacher.status} | {teacher.note} |" - ) - atomic_write_text(path, "\n".join(lines).rstrip() + "\n") - - -def _write_course_summaries(conn: sqlite3.Connection, root: Path) -> None: - if root.exists(): - shutil.rmtree(root) - root.mkdir(parents=True, exist_ok=True) - grouped: dict[str, list[sqlite3.Row]] = defaultdict(list) - for row in conn.execute("SELECT * FROM course_summaries ORDER BY relative_path, sort_order"): - relative_path = str(row["relative_path"] or "未归档/未命名.md") - grouped[relative_path].append(row) - for relative_path, rows in grouped.items(): - target = root / relative_path - target.parent.mkdir(parents=True, exist_ok=True) - first = rows[0] - heading = f"# {first['student']} {first['teacher']}".strip() - group = str(first["group_name"] or "") - blocks = [heading, ""] - if group: - blocks.extend([f"## {group}", ""]) - for row in rows: - blocks.append(f"### {row['title']}") - blocks.append("") - raw_body = str(row["raw_body"] or "").strip() - body = str(row["body"] or "").strip() - if raw_body: - blocks.append(raw_body) - else: - meta_lines = [] - if row["source_id"]: - meta_lines.append(f"> 来源ID:`{row['source_id']}`") - if row["message_time"]: - meta_lines.append(f"> 发送时间:`{row['message_time']}`") - if row["sender"]: - meta_lines.append(f"> 发送者:`{row['sender']}`") - if meta_lines: - blocks.extend(meta_lines) - blocks.append("") - blocks.append(body) - blocks.append("") - atomic_write_text(target, "\n".join(blocks).rstrip() + "\n") - - -def export_database_to_runtime_cache() -> dict: - if not SQLITE_DB_PATH.exists(): - return {"ok": False, "reason": "database_missing", "path": str(SQLITE_DB_PATH)} - RUNTIME_DATA_ROOT.mkdir(parents=True, exist_ok=True) - with connect(SQLITE_DB_PATH) as conn: - initialize_schema(conn) - class_lines = _class_records_from_db(conn) - atomic_write_text(CLASSNOTES_PATH, CLASSNOTES_HEADER + "\n".join(class_lines).rstrip() + ("\n" if class_lines else "")) - _write_accounts(ACCOUNTS_PATH, _accounts_from_db(conn)) - _write_teachers(TEACHERS_PATH, _teachers_from_db(conn)) - atomic_write_text(ADMIN_TASKS_PATH, json.dumps(_tasks_from_db(conn), ensure_ascii=False, indent=2) + "\n") - logs = _operation_logs_from_db(conn) - atomic_write_text( - OPERATION_LOGS_PATH, - "".join(json.dumps(item, ensure_ascii=False, sort_keys=True) + "\n" for item in logs), - ) - atomic_write_text(COURSE_SUMMARY_STATE_PATH, json.dumps(_summary_state_from_db(conn), ensure_ascii=False, indent=2) + "\n") - _write_course_summaries(conn, COURSE_SUMMARIES_ROOT) - return { - "ok": True, - "records": len(class_lines), - "accounts": len(_accounts_from_db(conn)), - "operation_logs": len(logs), - } - - -def ensure_runtime_cache() -> dict: - return export_database_to_runtime_cache() - - def source_file_hashes(paths: Iterable[Path]) -> dict[str, str]: hashes: dict[str, str] = {} for path in paths: @@ -624,3 +491,2635 @@ def source_file_hashes(paths: Iterable[Path]) -> dict[str, str]: continue hashes[str(path)] = hashlib.sha256(path.read_bytes()).hexdigest() return hashes + + +# SQLite-native runtime API ------------------------------------------------- + +SNAPSHOT_DB_NAME = "xsk_education.db" +RESTORED_TABLES = [ + "students", + "account_transactions", + "class_records", + "teachers", + "teacher_subjects", + "admin_task_state", + "admin_tasks", + "course_summaries", + "course_summary_seen_keys", + "ingest_batches", +] + + +def initialize_runtime_database() -> None: + with connect(SQLITE_DB_PATH) as conn: + initialize_schema(conn) + _backfill_course_summary_payloads(conn) + conn.commit() + + +def _data(): + from . import data as data_mod + + return data_mod + + +def _backfill_course_summary_payloads(conn: sqlite3.Connection) -> None: + rows = conn.execute("SELECT * FROM course_summaries WHERE payload_json = '{}' OR payload_json = ''").fetchall() + if not rows: + return + data_mod = _data() + for row in rows: + payload = _summary_payload_from_row(row) + duration_minutes = payload.get("duration_minutes") + if duration_minutes is None and row["time_range"]: + duration_minutes = data_mod.duration_minutes_from_time_range(str(row["time_range"])) + conn.execute( + """ + UPDATE course_summaries + SET payload_json = ?, duration_minutes = ?, message_date = ?, sender_id = ?, + source_db = ?, local_id = ?, recognition_source = ?, confidence = ?, + teacher_trusted = ?, remark = ? + WHERE id = ? + """, + ( + json.dumps(payload, ensure_ascii=False, sort_keys=True), + duration_minutes, + str(payload.get("message_date") or ""), + str(payload.get("sender_id") or ""), + str(payload.get("db") or ""), + str(payload.get("local_id") or ""), + str(payload.get("recognition_source") or ""), + str(payload.get("confidence") or ""), + 1 if payload.get("teacher_trusted") else 0, + str(payload.get("remark") or ""), + str(row["id"]), + ), + ) + + +def _summary_payload_from_row(row: sqlite3.Row) -> dict: + payload_text = "{}" + try: + payload_text = str(row["payload_json"] or "{}") + except (KeyError, IndexError): + payload_text = "{}" + try: + payload = json.loads(payload_text) + except json.JSONDecodeError: + payload = {} + if not isinstance(payload, dict): + payload = {} + duration_minutes = None + try: + duration_minutes = row["duration_minutes"] + except (KeyError, IndexError): + duration_minutes = payload.get("duration_minutes") + return { + "source_id": str(payload.get("source_id") or row["source_id"] or ""), + "student": str(payload.get("student") or row["student"] or ""), + "date_iso": str(payload.get("date_iso") or row["date_iso"] or ""), + "time_range": str(payload.get("time_range") or row["time_range"] or ""), + "duration_minutes": duration_minutes, + "teacher": str(payload.get("teacher") or row["teacher"] or ""), + "subject": str(payload.get("subject") or row["subject"] or ""), + "group": str(payload.get("group") or row["group_name"] or ""), + "sender": str(payload.get("sender") or row["sender"] or ""), + "sender_id": str(payload.get("sender_id") or ""), + "message_time": str(payload.get("message_time") or row["message_time"] or ""), + "message_date": str(payload.get("message_date") or ""), + "db": str(payload.get("db") or ""), + "local_id": str(payload.get("local_id") or ""), + "title": str(payload.get("title") or row["title"] or ""), + "body": str(payload.get("body") or row["body"] or ""), + "recognition_source": str(payload.get("recognition_source") or ""), + "confidence": str(payload.get("confidence") or ""), + "teacher_trusted": bool(payload.get("teacher_trusted")), + "remark": str(payload.get("remark") or ""), + } + + +def _class_record_from_row(row: sqlite3.Row) -> ClassRecord: + data_mod = _data() + minutes = int(row["duration_minutes"] or 0) + return ClassRecord( + date=str(row["record_date"]), + weekday=str(row["weekday"]), + time=str(row["time_range"]), + student=str(row["student"]), + duration=data_mod.duration_text_from_minutes(minutes), + duration_hours=round(minutes / 60.0, 2), + teacher=str(row["teacher"]), + subject=str(row["subject"]), + ) + + +def _class_records_from_conn(conn: sqlite3.Connection) -> list[ClassRecord]: + rows = conn.execute("SELECT * FROM class_records ORDER BY sort_order, id").fetchall() + return [_class_record_from_row(row) for row in rows] + + +def load_records() -> list[ClassRecord]: + with connect(SQLITE_DB_PATH) as conn: + initialize_schema(conn) + return _class_records_from_conn(conn) + + +def load_student_profiles() -> list[Account]: + with connect(SQLITE_DB_PATH) as conn: + initialize_schema(conn) + return _accounts_from_db(conn) + + +def load_teachers() -> list[Teacher]: + with connect(SQLITE_DB_PATH) as conn: + initialize_schema(conn) + return _teachers_from_db(conn) + + +def list_student_names() -> list[str]: + with connect(SQLITE_DB_PATH) as conn: + initialize_schema(conn) + rows = conn.execute("SELECT student FROM students ORDER BY sort_order, student_id").fetchall() + return [str(row["student"]) for row in rows] + + +def _next_sort_order(conn: sqlite3.Connection, table: str) -> int: + row = conn.execute(f"SELECT COALESCE(MAX(sort_order), -1) + 1 AS next_order FROM {table}").fetchone() + return int(row["next_order"] or 0) + + +def _student_id_for_name(conn: sqlite3.Connection, student: str) -> str: + row = conn.execute("SELECT student_id FROM students WHERE student = ?", (student,)).fetchone() + if row is None: + raise ValueError(f"上课学生没有学生档案: {student}") + return str(row["student_id"]) + + +def _insert_class_record(conn: sqlite3.Connection, record: ClassRecord) -> str: + data_mod = _data() + record_line = data_mod.class_record_to_line(record) + key = _record_key(record) + if conn.execute("SELECT 1 FROM class_records WHERE record_key = ?", (key,)).fetchone(): + raise DuplicateRecordError(f"上课记录已存在: {record_line}") + student_id = _student_id_for_name(conn, record.student) + conn.execute( + """ + INSERT INTO class_records(record_key, record_date, weekday, time_range, duration_minutes, + student_id, student, teacher, subject, raw_line, sort_order) + VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + key, + record.date, + record.weekday, + record.time, + int(round(record.duration_hours * 60)), + student_id, + record.student, + record.teacher, + record.subject, + record_line, + _next_sort_order(conn, "class_records"), + ), + ) + return record_line + + +def _delete_class_record(conn: sqlite3.Connection, record: ClassRecord) -> None: + key = _record_key(record) + cursor = conn.execute("DELETE FROM class_records WHERE record_key = ?", (key,)) + if cursor.rowcount < 1: + raise ValueError("原上课记录在正式数据库中不存在,可能已被修改") + + +def _replace_class_record(conn: sqlite3.Connection, original: ClassRecord, corrected: ClassRecord) -> str: + data_mod = _data() + original_key = _record_key(original) + corrected_key = _record_key(corrected) + corrected_line = data_mod.class_record_to_line(corrected) + row = conn.execute("SELECT id, sort_order FROM class_records WHERE record_key = ?", (original_key,)).fetchone() + if row is None: + raise ValueError("原上课记录在正式数据库中不存在,可能已被修改") + duplicate = conn.execute( + "SELECT id FROM class_records WHERE record_key = ? AND id != ?", + (corrected_key, int(row["id"])), + ).fetchone() + if duplicate: + raise ValueError("修改后的上课记录已存在,不能重复写入") + student_id = _student_id_for_name(conn, corrected.student) + conn.execute( + """ + UPDATE class_records + SET record_key = ?, record_date = ?, weekday = ?, time_range = ?, duration_minutes = ?, + student_id = ?, student = ?, teacher = ?, subject = ?, raw_line = ? + WHERE id = ? + """, + ( + corrected_key, + corrected.date, + corrected.weekday, + corrected.time, + int(round(corrected.duration_hours * 60)), + student_id, + corrected.student, + corrected.teacher, + corrected.subject, + corrected_line, + int(row["id"]), + ), + ) + return corrected_line + + +def _tasks_from_conn(conn: sqlite3.Connection) -> dict: + return _tasks_from_db(conn) + + +def _write_tasks_to_conn(conn: sqlite3.Connection, tasks: dict) -> None: + conn.execute("DELETE FROM admin_tasks") + conn.execute("DELETE FROM admin_task_state") + conn.execute("INSERT INTO admin_task_state(key, value) VALUES('version', ?)", (str(tasks.get("version", 1)),)) + conn.execute("INSERT INTO admin_task_state(key, value) VALUES('next_id', ?)", (str(tasks.get("next_id", 1)),)) + for index, task in enumerate(tasks.get("items") or []): + conn.execute( + """ + INSERT INTO admin_tasks(id, task_type, status, created_at, updated_at, student, source_id, payload_json, sort_order) + VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + int(task.get("id") or index + 1), + str(task.get("type") or ""), + str(task.get("status") or ""), + str(task.get("created_at") or ""), + str(task.get("updated_at") or ""), + str(task.get("student") or task.get("corrected", {}).get("student") or ""), + str(task.get("source_id") or ""), + json.dumps(task, ensure_ascii=False, sort_keys=True), + index, + ), + ) + + +def _find_task(tasks: dict, task_id: int) -> dict: + data_mod = _data() + return data_mod.find_admin_task(tasks, task_id) + + +def _task_to_dict(task: dict) -> dict: + return _data().task_to_dict(task) + + +def _task_to_dict_with_context(task: dict, records: list[ClassRecord] | None = None) -> dict: + return _data().task_to_dict_with_context(task, records) + + +def _append_operation_log(conn: sqlite3.Connection, operation: str, status: str, **fields: object) -> str: + data_mod = _data() + now = datetime.now().isoformat(timespec="seconds") + seed = json.dumps(fields, ensure_ascii=False, sort_keys=True) + base_id = f"{datetime.now().strftime('%Y%m%d%H%M%S')}-{sha1_text(seed, 8)}" + log_id = base_id + for index in range(1000): + if index: + log_id = f"{base_id}-{index}" + if conn.execute("SELECT 1 FROM operation_logs WHERE log_id = ?", (log_id,)).fetchone() is None: + break + row = data_mod.localize_operation_log_item( + { + "id": log_id, + "created_at": now, + "operation": operation, + "status": status, + **fields, + } + ) + conn.execute( + """ + INSERT INTO operation_logs(log_id, created_at, operation, status, student, payload_json, sort_order) + VALUES(?, ?, ?, ?, ?, ?, ?) + """, + ( + log_id, + now, + str(row.get("operation") or ""), + str(row.get("status") or ""), + str(row.get("student") or ""), + json.dumps(row, ensure_ascii=False, sort_keys=True), + _next_sort_order(conn, "operation_logs"), + ), + ) + return log_id + + +def _backup_root() -> Path: + return SQLITE_DB_PATH.parent / "backups" + + +def _sqlite_snapshot_dirs() -> list[Path]: + root = _backup_root() + if not root.exists(): + return [] + return sorted(path for path in root.iterdir() if path.is_dir() and BACKUP_DIR_RE.match(path.name)) + + +def _read_snapshot_metadata(backup_dir: Path) -> dict: + path = backup_dir / "metadata.json" + if not path.exists(): + raise ValueError(f"备份元数据不存在: {backup_dir.name}") + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise ValueError(f"备份元数据 JSON 格式错误: {backup_dir.name}") from exc + if not isinstance(payload, dict): + raise ValueError("备份元数据必须是 JSON 对象") + payload.setdefault("backup_id", backup_dir.name) + return payload + + +def create_sqlite_snapshot(operation: str, submitted_lines: list[str] | None = None) -> str: + initialize_runtime_database() + root = _backup_root() + backup_dir = create_backup_directory(root, operation) + backup_db = backup_dir / SNAPSHOT_DB_NAME + try: + source = sqlite3.connect(SQLITE_DB_PATH) + target = sqlite3.connect(backup_db) + try: + source.backup(target) + finally: + target.close() + source.close() + submitted = "\n".join(submitted_lines or []) + metadata = { + "kind": "sqlite_snapshot", + "backup_id": backup_dir.name, + "created_at": datetime.now().isoformat(timespec="microseconds"), + "operation": operation, + "schema_version": SCHEMA_VERSION, + "db_path": str(SQLITE_DB_PATH), + "submitted_lines_count": len(submitted_lines or []), + "submitted_lines_sha256": hashlib.sha256(submitted.encode("utf-8")).hexdigest(), + "files": [ + { + "name": SNAPSHOT_DB_NAME, + "source_path": str(SQLITE_DB_PATH), + "size": backup_db.stat().st_size, + "mtime": SQLITE_DB_PATH.stat().st_mtime if SQLITE_DB_PATH.exists() else None, + } + ], + } + atomic_write_text(backup_dir / "metadata.json", json.dumps(metadata, ensure_ascii=False, indent=2) + "\n") + prune_data_backups(root) + return backup_dir.name + except Exception: + shutil.rmtree(backup_dir, ignore_errors=True) + raise + + +def _write_transaction(operation: str, submitted_lines: list[str], callback): + from .config import write_lock + + with write_lock: + backup_id = create_sqlite_snapshot(operation, submitted_lines) + try: + with transaction(SQLITE_DB_PATH) as conn: + initialize_schema(conn) + return callback(conn, backup_id) + except Exception: + shutil.rmtree(_backup_root() / backup_id, ignore_errors=True) + raise + + +def _restore_snapshot_tables(conn: sqlite3.Connection, backup_db: Path) -> None: + conn.execute("ATTACH DATABASE ? AS snapshot", (str(backup_db),)) + try: + for table in [ + "teacher_subjects", + "account_transactions", + "class_records", + "admin_tasks", + "admin_task_state", + "course_summaries", + "course_summary_seen_keys", + "ingest_batches", + "students", + "teachers", + ]: + conn.execute(f"DELETE FROM {table}") + for table in RESTORED_TABLES: + columns = [str(row["name"]) for row in conn.execute(f"PRAGMA main.table_info({table})").fetchall()] + snapshot_columns = { + str(row["name"]) + for row in conn.execute(f"PRAGMA snapshot.table_info({table})").fetchall() + } + common = [column for column in columns if column in snapshot_columns] + if not common: + continue + column_sql = ", ".join(common) + conn.execute(f"INSERT INTO {table}({column_sql}) SELECT {column_sql} FROM snapshot.{table}") + finally: + try: + conn.execute("DETACH DATABASE snapshot") + except sqlite3.OperationalError: + pass + + +def _summary_storage_key(summary: dict) -> str: + data_mod = _data() + student = data_mod.safe_filename_part(summary.get("student") or "") + teacher = data_mod.safe_filename_part(summary.get("teacher") or "待核对老师") + subject = data_mod.safe_filename_part(summary.get("subject") or "待核对科目") + return f"{student}/{student}_{teacher}_{subject}.md" + + +def _summary_title(conn: sqlite3.Connection, summary: dict, summary_id: str | None = None) -> str: + data_mod = _data() + subject = data_mod.normalize_subject(str(summary.get("subject") or "待核对科目")) or "待核对科目" + time_range = str(summary.get("time_range") or "").strip() + base = f"{summary['date_iso']} {time_range + ' ' if time_range else ''}{subject}课堂小结" + title = str(summary.get("title") or "").strip() or base + rows = conn.execute( + "SELECT id, title FROM course_summaries WHERE student = ? AND teacher = ? AND subject = ?", + (str(summary.get("student") or ""), str(summary.get("teacher") or ""), subject), + ).fetchall() + existing = {str(row["title"]) for row in rows if summary_id is None or str(row["id"]) != summary_id} + if title not in existing: + return title + return f"{title}({sha1_text(str(summary.get('source_id') or '') + str(summary.get('body') or ''), 8)})" + + +def _summary_id(summary: dict) -> str: + seed = "|".join( + [ + str(summary.get("source_id") or ""), + str(summary.get("student") or ""), + str(summary.get("date_iso") or ""), + str(summary.get("teacher") or ""), + str(summary.get("subject") or ""), + str(summary.get("time_range") or ""), + str(summary.get("body") or "")[:200], + ] + ) + return sha1_text(seed, 20) + + +def _summary_item_from_row(row: sqlite3.Row) -> dict: + payload = _summary_payload_from_row(row) + body = str(row["body"] or payload.get("body") or "") + item = { + **payload, + "id": str(row["id"]), + "title": str(row["title"] or payload.get("title") or ""), + "body": body, + "raw_body": str(row["raw_body"] or body), + "group": str(row["group_name"] or payload.get("group") or ""), + "student": str(row["student"] or payload.get("student") or ""), + "teacher": str(row["teacher"] or payload.get("teacher") or ""), + "subject": str(row["subject"] or payload.get("subject") or ""), + "date_iso": str(row["date_iso"] or payload.get("date_iso") or ""), + "time_range": str(row["time_range"] or payload.get("time_range") or ""), + "duration_minutes": row["duration_minutes"], + "message_time": str(row["message_time"] or payload.get("message_time") or ""), + "message_date": str(row["message_date"] or payload.get("message_date") or ""), + "sender": str(row["sender"] or payload.get("sender") or ""), + "sender_id": str(row["sender_id"] or payload.get("sender_id") or ""), + "source_id": str(row["source_id"] or payload.get("source_id") or ""), + "semantic_key": str(row["semantic_key"] or ""), + "storage_key": str(row["relative_path"] or ""), + "relative_path": str(row["relative_path"] or ""), + "source_path": "", + } + item["body_preview"] = body[:260] + ("..." if len(body) > 260 else "") + return item + + +def _summary_items(conn: sqlite3.Connection) -> list[dict]: + rows = conn.execute("SELECT * FROM course_summaries ORDER BY sort_order, id").fetchall() + return [_summary_item_from_row(row) for row in rows] + + +def _save_course_summary(conn: sqlite3.Connection, summary: dict, *, allow_same_source_id: bool = False) -> dict: + data_mod = _data() + normalized = data_mod.normalize_course_summary(summary) + source_id = str(normalized.get("source_id") or "") + semantic_key = data_mod.course_summary_semantic_key(normalized) + body = str(normalized.get("body") or "") + if source_id and not allow_same_source_id: + row = conn.execute("SELECT id, title, relative_path FROM course_summaries WHERE source_id = ?", (source_id,)).fetchone() + if row: + return {"summary_id": str(row["id"]), "storage_key": str(row["relative_path"] or ""), "added": False, "heading": str(row["title"] or "")} + existing = conn.execute( + """ + SELECT id, title, relative_path + FROM course_summaries + WHERE student = ? AND teacher = ? AND subject = ? AND date_iso = ? AND time_range = ? + AND REPLACE(REPLACE(body, char(10), ''), ' ', '') = ? + """, + ( + normalized["student"], + normalized.get("teacher", ""), + data_mod.normalize_subject(str(normalized.get("subject") or "")), + normalized["date_iso"], + normalized.get("time_range", ""), + re.sub(r"\s+", "", body), + ), + ).fetchone() + if existing: + return {"summary_id": str(existing["id"]), "storage_key": str(existing["relative_path"] or ""), "added": False, "heading": str(existing["title"] or "")} + summary_id = _summary_id(normalized) + suffix = 1 + while conn.execute("SELECT 1 FROM course_summaries WHERE id = ?", (summary_id,)).fetchone(): + suffix += 1 + summary_id = sha1_text(f"{_summary_id(normalized)}|{suffix}", 20) + title = _summary_title(conn, normalized) + storage_key = _summary_storage_key(normalized) + payload = {**normalized, "title": title} + conn.execute( + """ + INSERT INTO course_summaries(id, relative_path, title, body, raw_body, group_name, + student, teacher, subject, date_iso, time_range, duration_minutes, + message_time, message_date, sender, sender_id, source_id, source_db, + local_id, recognition_source, confidence, teacher_trusted, remark, + semantic_key, content_hash, payload_json, sort_order) + VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + summary_id, + storage_key, + title, + body, + body, + str(normalized.get("group") or ""), + normalized["student"], + str(normalized.get("teacher") or ""), + data_mod.normalize_subject(str(normalized.get("subject") or "")), + normalized["date_iso"], + str(normalized.get("time_range") or ""), + normalized.get("duration_minutes"), + str(normalized.get("message_time") or ""), + str(normalized.get("message_date") or ""), + str(normalized.get("sender") or ""), + str(normalized.get("sender_id") or ""), + source_id, + str(normalized.get("db") or ""), + str(normalized.get("local_id") or ""), + str(normalized.get("recognition_source") or ""), + str(normalized.get("confidence") or ""), + 1 if normalized.get("teacher_trusted") else 0, + str(normalized.get("remark") or ""), + semantic_key, + sha1_text(f"{storage_key}|{title}|{body}"), + json.dumps(payload, ensure_ascii=False, sort_keys=True), + _next_sort_order(conn, "course_summaries"), + ), + ) + return {"summary_id": summary_id, "storage_key": storage_key, "added": True, "heading": title} + + +def _summary_seen_sets(conn: sqlite3.Connection) -> tuple[set[str], set[str]]: + source_ids = { + str(row["value"]) + for row in conn.execute("SELECT value FROM course_summary_seen_keys WHERE kind = 'source_id'").fetchall() + } + semantic_keys = { + str(row["value"]) + for row in conn.execute("SELECT value FROM course_summary_seen_keys WHERE kind = 'semantic_key'").fetchall() + } + return source_ids, semantic_keys + + +def _add_summary_seen(conn: sqlite3.Connection, source_id: str, semantic_key: str) -> None: + if source_id: + conn.execute("INSERT OR IGNORE INTO course_summary_seen_keys(kind, value) VALUES('source_id', ?)", (source_id,)) + if semantic_key: + conn.execute("INSERT OR IGNORE INTO course_summary_seen_keys(kind, value) VALUES('semantic_key', ?)", (semantic_key,)) + + +def _add_ingest_batch(conn: sqlite3.Connection, batch: dict) -> None: + batch_id = str(batch.get("batch_id") or f"batch-{datetime.now().strftime('%Y%m%d%H%M%S')}-{sha1_text(json.dumps(batch, ensure_ascii=False), 8)}") + conn.execute( + """ + INSERT OR REPLACE INTO ingest_batches(batch_id, received_at, payload_json, sort_order) + VALUES(?, ?, ?, ?) + """, + ( + batch_id, + str(batch.get("received_at") or datetime.now().isoformat(timespec="seconds")), + json.dumps({**batch, "batch_id": batch_id}, ensure_ascii=False, sort_keys=True), + _next_sort_order(conn, "ingest_batches"), + ), + ) + rows = conn.execute("SELECT batch_id FROM ingest_batches ORDER BY sort_order, received_at, batch_id").fetchall() + overflow = [str(row["batch_id"]) for row in rows[:-200]] + for old_batch_id in overflow: + conn.execute("DELETE FROM ingest_batches WHERE batch_id = ?", (old_batch_id,)) + + +def _summary_index_for_records( + conn: sqlite3.Connection, + records: list[ClassRecord] | None = None, +) -> dict[tuple[str, str, str, str, str], list[dict]]: + data_mod = _data() + index: dict[tuple[str, str, str, str, str], list[dict]] = defaultdict(list) + records_by_identity: defaultdict[tuple[str, str, str, str], list[ClassRecord]] = defaultdict(list) + for record in records or []: + records_by_identity[data_mod.class_record_identity_key(record)].append(record) + for item in _summary_items(conn): + time_range = str(item.get("time_range") or "") + if not time_range: + continue + key = data_mod.course_summary_record_key( + str(item.get("student") or ""), + str(item.get("teacher") or ""), + str(item.get("subject") or ""), + str(item.get("date_iso") or ""), + time_range, + ) + close_record = data_mod.close_time_binding_record( + item, + records_by_identity.get(data_mod.course_summary_identity_key(item), []), + ) + if close_record is not None: + key = data_mod.class_record_binding_key(close_record) + index[key].append(item) + for key, values in list(index.items()): + values.sort(key=lambda item: (str(item.get("title") or ""), str(item.get("id") or ""))) + if len(values) > 1: + index[key] = [max(values, key=data_mod.course_summary_quality_score)] + return index + + +def query_public_records( + query: str, + limit: int = 200, + offset: int = 0, +) -> dict: + data_mod = _data() + with connect(SQLITE_DB_PATH) as conn: + initialize_schema(conn) + records = _class_records_from_conn(conn) + teachers = _teachers_from_db(conn) + spec = data_mod.build_public_query_spec(query, records, teachers) + matched = data_mod.filter_records(records, spec) if data_mod.has_filter_condition(spec) else [] + shown, normalized_offset, has_more = data_mod.paginate_items(matched, offset, limit) + display_names = data_mod.teacher_alias_map(teachers) + summary_index = _summary_index_for_records(conn, records) + return { + "query": { + "raw_query": spec.raw_query, + "date_range": data_mod.format_date_range(spec), + "students": spec.students, + "teachers": [display_names.get(teacher, teacher) for teacher in spec.teachers], + "subjects": spec.subjects, + }, + "summary": data_mod.summarize_public_records(matched, teachers), + "records": [data_mod.public_record_to_dict(record, teachers, summary_index) for record in shown], + "total_records": len(matched), + "shown_records": len(shown), + "offset": normalized_offset, + "limit": limit, + "has_more": has_more, + } + + +def students_payload(q: str = "", status_filter: str = "") -> dict: + data_mod = _data() + with connect(SQLITE_DB_PATH) as conn: + initialize_schema(conn) + accounts = _accounts_from_db(conn) + rows = data_mod.filter_accounts(accounts, keyword=q, status=status_filter) + return { + "summary": data_mod.account_summary(accounts), + "count": len(rows), + "students": [data_mod.account_to_dict(account) for account in rows], + } + + +def student_detail_payload(student: str) -> dict: + data_mod = _data() + for account in load_student_profiles(): + if account.student == student or account.student_id == student: + return data_mod.account_to_dict(account) + raise ValueError(f"未找到学生档案: {student}") + + +def teachers_payload() -> dict: + data_mod = _data() + return {"teachers": [data_mod.teacher_to_dict(teacher) for teacher in load_teachers()]} + + +def _database_counts(conn: sqlite3.Connection) -> dict: + return { + "records_count": int(conn.execute("SELECT COUNT(*) FROM class_records").fetchone()[0]), + "accounts_count": int(conn.execute("SELECT COUNT(*) FROM students").fetchone()[0]), + "teachers_count": int(conn.execute("SELECT COUNT(*) FROM teachers").fetchone()[0]), + "course_summaries_count": int(conn.execute("SELECT COUNT(*) FROM course_summaries").fetchone()[0]), + "admin_tasks_count": int(conn.execute("SELECT COUNT(*) FROM admin_tasks").fetchone()[0]), + "operation_logs_count": int(conn.execute("SELECT COUNT(*) FROM operation_logs").fetchone()[0]), + } + + +def database_runtime_meta() -> dict: + from .db import database_meta + + meta = database_meta(SQLITE_DB_PATH) + meta["source_mode"] = "sqlite" + return meta + + +def health_payload() -> dict: + data_mod = _data() + with connect(SQLITE_DB_PATH) as conn: + initialize_schema(conn) + records = _class_records_from_conn(conn) + accounts = _accounts_from_db(conn) + teachers = _teachers_from_db(conn) + counts = _database_counts(conn) + meta = database_runtime_meta() + return { + "ok": True, + "database": meta, + "updated_at": meta.get("mtime", 0), + **counts, + "students_count": counts["accounts_count"], + "active_teachers_count": sum(1 for teacher in teachers if teacher.status == "在岗"), + "current_month_hours": round( + sum(record.duration_hours for record in records if record.date.startswith(date.today().strftime("%Y.%m."))), + 2, + ), + "current_month_duration": data_mod.duration_text_from_hours( + sum(record.duration_hours for record in records if record.date.startswith(date.today().strftime("%Y.%m."))) + ), + "account_summary": data_mod.account_summary(accounts), + "student_summary": data_mod.account_summary(accounts), + "legacy_archive": {"source_mode": "sqlite", "text_root": ""}, + } + + +def _dashboard_course_summary_statuses(conn: sqlite3.Connection, records: list[ClassRecord]) -> dict: + data_mod = _data() + items = _summary_items(conn) + record_keys = {data_mod.class_record_binding_key(record): record for record in records} + statuses: defaultdict[str, int] = defaultdict(int) + for item in items: + binding = data_mod.course_summary_binding_status(item, record_keys, records) + statuses[str(binding.get("status") or "unchecked")] += 1 + return { + "total": len(items), + "matched": statuses.get("matched", 0), + "missing_time": statuses.get("missing_time", 0), + "mismatch": statuses.get("mismatch", 0), + "unmatched": statuses.get("unmatched", 0), + } + + +def admin_dashboard(period: str = "month") -> dict: + data_mod = _data() + with connect(SQLITE_DB_PATH) as conn: + initialize_schema(conn) + normalized_period, start, end = data_mod.dashboard_period_bounds(period) + records = _class_records_from_conn(conn) + period_records = data_mod.records_in_period(records, start, end) + accounts = _accounts_from_db(conn) + teachers = _teachers_from_db(conn) + tasks = _tasks_from_conn(conn) + logs = list_operation_logs(limit=10).get("items", []) + summary = data_mod.summarize_records(period_records) + daily: defaultdict[str, float] = defaultdict(float) + for record in period_records: + daily[record.date.replace(".", "-")] += record.duration_hours + active_students = [account for account in accounts if account.account_status not in {"结课", "退费"}] + low_remaining = sorted(active_students, key=lambda account: (account.remaining, account.student))[:8] + task_counts: defaultdict[str, int] = defaultdict(int) + for task in tasks.get("items") or []: + task_counts[str(task.get("status") or "")] += 1 + return { + "period": { + "value": normalized_period, + "start": start.isoformat() if start else "", + "end": end.isoformat() if end else "", + }, + "overview": { + "records": summary["count"], + "hours": summary["total_hours"], + "duration": summary["total_duration"], + "students": len(summary["students"]), + "teachers": len(summary["teachers"]), + "subjects": len(summary["subjects"]), + }, + "teaching": { + "daily": [ + {"date": key, "hours": round(value, 2), "duration": data_mod.duration_text_from_hours(value)} + for key, value in sorted(daily.items()) + ], + "teachers": data_mod.top_duration_items(summary["teachers"]), + "subjects": data_mod.top_duration_items(summary["subjects"]), + "students": data_mod.top_duration_items(summary["students"]), + }, + "students": { + "summary": data_mod.account_summary(accounts), + "low_remaining": [ + { + "student": account.student, + "student_id": account.student_id, + "remaining": account.remaining, + "remaining_duration": data_mod.duration_text_from_hours(account.remaining), + "status": account.account_status, + } + for account in low_remaining + ], + }, + "teachers": { + "total": len(teachers), + "active": sum(1 for teacher in teachers if teacher.status == "在岗"), + "inactive": sum(1 for teacher in teachers if teacher.status != "在岗"), + }, + "course_summaries": _dashboard_course_summary_statuses(conn, records), + "tasks": { + "total": len(tasks.get("items") or []), + "pending": task_counts.get("pending", 0), + "conflict": task_counts.get("conflict", 0), + "approved": task_counts.get("approved", 0), + "rejected": task_counts.get("rejected", 0), + }, + "logs": logs, + } + + +def list_admin_tasks( + status_filter: str = "", + task_type: str = "", + offset: int = 0, + limit: int = 200, +) -> dict: + with connect(SQLITE_DB_PATH) as conn: + initialize_schema(conn) + tasks = _tasks_from_conn(conn) + records = _class_records_from_conn(conn) + items = tasks["items"] + if status_filter: + items = [item for item in items if item.get("status") == status_filter] + task_types = [item.strip() for item in task_type.split(",") if item.strip()] + if task_types: + items = [item for item in items if str(item.get("type") or "") in task_types] + type_counts: dict[str, int] = defaultdict(int) + for item in items: + type_counts[str(item.get("type") or "")] += 1 + sorted_items = sorted(items, key=lambda item: int(item.get("id", 0)), reverse=True) + shown, normalized_offset, has_more = _data().paginate_items(sorted_items, offset, limit) + return { + "version": tasks["version"], + "next_id": tasks["next_id"], + "count": len(items), + "returned": len(shown), + "offset": normalized_offset, + "limit": limit, + "has_more": has_more, + "type_counts": dict(type_counts), + "items": [_task_to_dict_with_context(item, records) for item in shown], + } + + +def _rollback_state_for_log(item: dict, rows: list[dict]) -> 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: + if str(row.get("operation") or "") == "撤回操作" and str(row.get("target_log_id") or "") == log_id: + return {"can_rollback": False, "rollback_block_reason": "已撤回"} + backup_dir = _backup_root() / backup_id + if not backup_dir.is_dir(): + return {"can_rollback": False, "rollback_block_reason": f"备份不存在或已清理: {backup_id}"} + try: + metadata = _read_snapshot_metadata(backup_dir) + except ValueError as exc: + return {"can_rollback": False, "rollback_block_reason": str(exc)} + if metadata.get("kind") != "sqlite_snapshot" or not (backup_dir / SNAPSHOT_DB_NAME).exists(): + return {"can_rollback": False, "rollback_block_reason": "备份不是 SQLite 快照"} + for other_dir in _sqlite_snapshot_dirs(): + if other_dir.name > backup_id: + return {"can_rollback": False, "rollback_block_reason": f"已有后续备份 {other_dir.name}"} + return {"can_rollback": True, "rollback_block_reason": ""} + + +def list_operation_logs( + limit: int = 100, + offset: int = 0, + operation: str = "", + status_filter: str = "", + student: str = "", +) -> dict: + data_mod = _data() + with connect(SQLITE_DB_PATH) as conn: + initialize_schema(conn) + raw_rows = _operation_logs_from_db(conn) + rows: list[dict] = [] + localized_rows = [data_mod.localize_operation_log_item(item) for item in raw_rows] + for item in localized_rows: + item.update(_rollback_state_for_log(item, localized_rows)) + 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.reverse() + shown, normalized_offset, has_more = data_mod.paginate_items(rows, offset, limit) + return { + "count": len(rows), + "returned": len(shown), + "offset": normalized_offset, + "limit": limit, + "has_more": has_more, + "items": shown, + } + + +def _next_student_id(conn: sqlite3.Connection) -> str: + accounts = _accounts_from_db(conn) + return _data().next_student_id(accounts) + + +def _next_teacher_id(conn: sqlite3.Connection) -> str: + teachers = _teachers_from_db(conn) + return _data().next_teacher_id(teachers) + + +def create_teacher(teacher: Teacher) -> dict: + data_mod = _data() + + def work(conn: sqlite3.Connection, backup_id: str) -> dict: + candidate = data_mod.validate_teacher( + Teacher( + teacher_id=_next_teacher_id(conn), + name=teacher.name, + alias=teacher.alias, + subjects=teacher.subjects, + status=teacher.status, + note=teacher.note, + ) + ) + if conn.execute("SELECT 1 FROM teachers WHERE teacher_id = ? OR name = ?", (candidate.teacher_id, candidate.name)).fetchone(): + raise ValueError(f"教师已存在: {candidate.teacher_id} / {candidate.name}") + conn.execute( + "INSERT INTO teachers(teacher_id, name, alias, status, note, sort_order) VALUES(?, ?, ?, ?, ?, ?)", + (candidate.teacher_id, candidate.name, candidate.alias, candidate.status, candidate.note, _next_sort_order(conn, "teachers")), + ) + for index, subject in enumerate(candidate.subjects): + conn.execute( + "INSERT INTO teacher_subjects(teacher_id, subject, sort_order) VALUES(?, ?, ?)", + (candidate.teacher_id, subject, index), + ) + _append_operation_log( + conn, + "新增老师档案", + "完成", + teacher_id=candidate.teacher_id, + teacher=candidate.name, + backup_id=backup_id, + ) + return {"teacher": data_mod.teacher_to_dict(candidate), "backup_id": backup_id, "operation": "新增老师档案"} + + return _write_transaction("admin-create-teacher", [teacher.name], work) + + +def update_teacher(old_teacher_id: str, teacher: Teacher) -> dict: + data_mod = _data() + + def work(conn: sqlite3.Connection, backup_id: str) -> dict: + old_id = old_teacher_id.strip() + if conn.execute("SELECT 1 FROM teachers WHERE teacher_id = ?", (old_id,)).fetchone() is None: + raise ValueError(f"未找到教师档案: {old_id}") + candidate = data_mod.validate_teacher(teacher) + duplicate = conn.execute( + "SELECT 1 FROM teachers WHERE (teacher_id = ? OR name = ?) AND teacher_id != ?", + (candidate.teacher_id, candidate.name, old_id), + ).fetchone() + if duplicate: + raise ValueError(f"教师ID或姓名已存在: {candidate.teacher_id} / {candidate.name}") + conn.execute("DELETE FROM teacher_subjects WHERE teacher_id = ?", (old_id,)) + conn.execute( + "UPDATE teachers SET teacher_id = ?, name = ?, alias = ?, status = ?, note = ? WHERE teacher_id = ?", + (candidate.teacher_id, candidate.name, candidate.alias, candidate.status, candidate.note, old_id), + ) + for index, subject in enumerate(candidate.subjects): + conn.execute( + "INSERT INTO teacher_subjects(teacher_id, subject, sort_order) VALUES(?, ?, ?)", + (candidate.teacher_id, subject, index), + ) + _append_operation_log( + conn, + "修改老师档案", + "完成", + teacher_id=candidate.teacher_id, + teacher=candidate.name, + backup_id=backup_id, + ) + return {"teacher": data_mod.teacher_to_dict(candidate), "backup_id": backup_id, "operation": "修改老师档案"} + + return _write_transaction("admin-update-teacher", [old_teacher_id, teacher.name], work) + + +def create_student_profile(account: Account) -> dict: + data_mod = _data() + + def work(conn: sqlite3.Connection, backup_id: str) -> dict: + candidate = data_mod.validate_account( + Account( + student_id=_next_student_id(conn), + student=account.student, + payments=account.payments, + remaining=0, + account_status=account.account_status, + primary_entry_year=account.primary_entry_year, + note=account.note, + ) + ) + if conn.execute("SELECT 1 FROM students WHERE student_id = ? OR student = ?", (candidate.student_id, candidate.student)).fetchone(): + raise ValueError(f"学生ID或姓名已存在: {candidate.student_id} / {candidate.student}") + conn.execute( + """ + INSERT INTO students(student_id, student, account_status, primary_entry_year, note, source_remaining, sort_order) + VALUES(?, ?, ?, ?, ?, 0, ?) + """, + ( + candidate.student_id, + candidate.student, + candidate.account_status, + candidate.primary_entry_year, + candidate.note, + _next_sort_order(conn, "students"), + ), + ) + for index, payment in enumerate(candidate.payments): + conn.execute( + """ + INSERT INTO account_transactions(student_id, tx_date, hours, tx_type, source, sort_order) + VALUES(?, ?, ?, ?, ?, ?) + """, + ( + candidate.student_id, + payment.date, + float(payment.hours), + "payment" if payment.hours >= 0 else "refund", + "admin_student_profile", + index, + ), + ) + saved = next(item for item in _accounts_from_db(conn) if item.student_id == candidate.student_id) + _append_operation_log( + conn, + "新增学生档案", + "完成", + student_id=saved.student_id, + student=saved.student, + backup_id=backup_id, + ) + return {"account": data_mod.account_to_dict(saved), "backup_id": backup_id, "operation": "新增学生档案"} + + return _write_transaction("admin-create-account", [account.student], work) + + +def update_student_profile(old_student_id: str, account: Account) -> dict: + data_mod = _data() + + def work(conn: sqlite3.Connection, backup_id: str) -> dict: + old_id = old_student_id.strip() + if conn.execute("SELECT 1 FROM students WHERE student_id = ?", (old_id,)).fetchone() is None: + raise ValueError(f"未找到学生档案: {old_id}") + candidate = data_mod.validate_account(account) + duplicate = conn.execute( + "SELECT 1 FROM students WHERE (student_id = ? OR student = ?) AND student_id != ?", + (candidate.student_id, candidate.student, old_id), + ).fetchone() + if duplicate: + raise ValueError(f"学生ID或姓名已存在: {candidate.student_id} / {candidate.student}") + old_row = conn.execute("SELECT sort_order FROM students WHERE student_id = ?", (old_id,)).fetchone() + if candidate.student_id == old_id: + conn.execute( + """ + UPDATE students + SET student = ?, account_status = ?, primary_entry_year = ?, note = ? + WHERE student_id = ? + """, + ( + candidate.student, + candidate.account_status, + candidate.primary_entry_year, + candidate.note, + old_id, + ), + ) + else: + conn.execute( + """ + INSERT INTO students(student_id, student, account_status, primary_entry_year, note, source_remaining, sort_order) + VALUES(?, ?, ?, ?, ?, 0, ?) + """, + ( + candidate.student_id, + candidate.student, + candidate.account_status, + candidate.primary_entry_year, + candidate.note, + int(old_row["sort_order"] or 0) if old_row else _next_sort_order(conn, "students"), + ), + ) + conn.execute("UPDATE account_transactions SET student_id = ? WHERE student_id = ?", (candidate.student_id, old_id)) + conn.execute("UPDATE class_records SET student_id = ? WHERE student_id = ?", (candidate.student_id, old_id)) + conn.execute("DELETE FROM students WHERE student_id = ?", (old_id,)) + conn.execute("DELETE FROM account_transactions WHERE student_id = ?", (candidate.student_id,)) + for index, payment in enumerate(candidate.payments): + conn.execute( + """ + INSERT INTO account_transactions(student_id, tx_date, hours, tx_type, source, sort_order) + VALUES(?, ?, ?, ?, ?, ?) + """, + ( + candidate.student_id, + payment.date, + float(payment.hours), + "payment" if payment.hours >= 0 else "refund", + "admin_student_profile", + index, + ), + ) + saved = next(item for item in _accounts_from_db(conn) if item.student_id == candidate.student_id) + _append_operation_log( + conn, + "修改学生档案", + "完成", + student_id=saved.student_id, + student=saved.student, + backup_id=backup_id, + ) + return {"account": data_mod.account_to_dict(saved), "backup_id": backup_id, "operation": "修改学生档案"} + + return _write_transaction("admin-update-account", [old_student_id, account.student], work) + + +def register_class_record_lines(lines: list[str] | None = None, line: str | None = None) -> dict: + data_mod = _data() + input_lines = data_mod.normalize_lines(lines=lines, line=line) + records = [data_mod.parse_class_record_line(item) for item in input_lines] + record_lines = [data_mod.class_record_to_line(record) for record in records] + if len(record_lines) != len(set(record_lines)): + raise DuplicateRecordError("本次提交包含重复上课记录") + + def work(conn: sqlite3.Connection, backup_id: str) -> dict: + registered: list[str] = [] + for record in records: + registered.append(_insert_class_record(conn, record)) + for record_line in registered: + record = data_mod.parse_class_record_line(record_line) + _append_operation_log( + conn, + "登记上课记录", + "完成", + student=record.student, + proposed_line=record_line, + backup_id=backup_id, + ) + return {"registered": len(registered), "lines": registered, "backup_id": backup_id, "operation": "登记上课记录"} + + return _write_transaction("register-class-records", record_lines, work) + + +def register_payment_lines(lines: list[str] | None = None, line: str | None = None) -> dict: + data_mod = _data() + input_lines = data_mod.normalize_lines(lines=lines, line=line) + payments = [data_mod.parse_payment_line(item) for item in input_lines] + submitted = [f"{student}-{data_mod.format_payment(payment)}" for student, payment in payments] + + def work(conn: sqlite3.Connection, backup_id: str) -> dict: + registered: list[str] = [] + for student, payment in payments: + student_name = data_mod.canonical_name(student) + student_id = _student_id_for_name(conn, student_name) + conn.execute( + """ + INSERT INTO account_transactions(student_id, tx_date, hours, tx_type, source, sort_order) + VALUES(?, ?, ?, ?, ?, ?) + """, + ( + student_id, + payment.date, + float(payment.hours), + "payment" if payment.hours >= 0 else "refund", + "register_payment", + _next_sort_order(conn, "account_transactions"), + ), + ) + registered_line = f"{student_name}-{data_mod.format_payment(payment)}" + registered.append(registered_line) + _append_operation_log( + conn, + "登记缴费记录", + "完成", + student=student_name, + proposed_line=registered_line, + backup_id=backup_id, + ) + return {"registered": len(registered), "lines": registered, "backup_id": backup_id, "operation": "登记缴费记录"} + + return _write_transaction("register-payments", submitted, work) + + +def submit_public_correction_tasks(items: list[dict]) -> dict: + data_mod = _data() + + def work(conn: sqlite3.Connection, _backup_id: str) -> dict: + records = _class_records_from_conn(conn) + teachers = _teachers_from_db(conn) + tasks = _tasks_from_conn(conn) + now = datetime.now().isoformat(timespec="seconds") + created: list[dict] = [] + if not items: + raise ValueError("提交审核的纠错记录不能为空") + for item in items: + original = data_mod.find_record_by_identity(records, str(item.get("record_id") or "")) + corrected = data_mod.class_record_from_public_item(original, item, teachers) + original_line = data_mod.class_record_to_line(original) + corrected_line = data_mod.class_record_to_line(corrected) + if original_line == corrected_line: + raise ValueError("原记录和修改后记录相同,无需提交审核") + task = { + "id": int(tasks["next_id"]), + "type": "class_record_correction", + "status": "pending", + "created_at": now, + "updated_at": now, + "original_line": original_line, + "corrected_line": corrected_line, + "original": data_mod.record_to_dict(original), + "corrected": data_mod.record_to_dict(corrected), + } + tasks["next_id"] = int(tasks["next_id"]) + 1 + tasks["items"].append(task) + created.append(task) + _write_tasks_to_conn(conn, tasks) + return {"submitted": len(created), "items": [_task_to_dict(task) for task in created]} + + return _write_transaction("submit-corrections", [json.dumps(items, ensure_ascii=False)], work) + + +def submit_public_deletion_tasks(items: list[dict]) -> dict: + data_mod = _data() + + def work(conn: sqlite3.Connection, _backup_id: str) -> dict: + records = _class_records_from_conn(conn) + tasks = _tasks_from_conn(conn) + now = datetime.now().isoformat(timespec="seconds") + created: list[dict] = [] + if not items: + raise ValueError("提交审核的删除记录不能为空") + for item in items: + original = data_mod.find_record_by_identity(records, str(item.get("record_id") or "")) + original_line = data_mod.class_record_to_line(original) + task = { + "id": int(tasks["next_id"]), + "type": "class_record_deletion", + "status": "pending", + "created_at": now, + "updated_at": now, + "original_line": original_line, + "original": data_mod.record_to_dict(original), + "student": original.student, + "reasons": ["申请删除课程记录"], + } + tasks["next_id"] = int(tasks["next_id"]) + 1 + tasks["items"].append(task) + created.append(task) + _write_tasks_to_conn(conn, tasks) + return {"submitted": len(created), "items": [_task_to_dict(task) for task in created]} + + return _write_transaction("submit-deletions", [json.dumps(items, ensure_ascii=False)], work) + + +def _summary_duplicate_candidate(item: dict) -> dict: + return _data().course_summary_duplicate_candidate(item) + + +def _summary_duplicate_conflicts( + conn: sqlite3.Connection, + summary: dict, + *, + source_id_duplicate: bool, + semantic_duplicate: bool, +) -> list[dict]: + data_mod = _data() + conflicts: list[dict] = [] + seen_ids: set[str] = set() + summary_source_id = str(summary.get("source_id") or "") + summary_body_key = data_mod.normalize_semantic_key_text(str(summary.get("body") or "")) + try: + summary_key = data_mod.course_summary_duplicate_key(summary) + except ValueError: + summary_key = ("", "", "", "", "") + for item in _summary_items(conn): + matched = False + if source_id_duplicate and summary_source_id and str(item.get("source_id") or "") == summary_source_id: + matched = True + if semantic_duplicate: + item_body_key = data_mod.normalize_semantic_key_text(str(item.get("body") or "")) + try: + item_key = data_mod.course_summary_duplicate_key(item) + except ValueError: + item_key = ("", "", "", "", "") + matched = matched or (item_key == summary_key and item_body_key == summary_body_key) + if not matched: + continue + candidate = _summary_duplicate_candidate(item) + candidate_id = str(candidate.get("id") or candidate.get("source_id") or "") + if candidate_id in seen_ids: + continue + seen_ids.add(candidate_id) + conflicts.append(candidate) + return conflicts + + +def _summary_duplicate_review_context( + conn: sqlite3.Connection, + summary: dict, + seen_source_ids: set[str], + seen_semantic_keys: set[str], +) -> tuple[list[str], list[dict], bool, bool]: + data_mod = _data() + source_id = str(summary.get("source_id") or "") + semantic_key = data_mod.course_summary_semantic_key(summary) + source_id_duplicate = source_id in seen_source_ids + semantic_duplicate = semantic_key in seen_semantic_keys + reasons: list[str] = [] + if source_id_duplicate: + reasons.append("来源ID重复,新增课程小结需人工复核") + if semantic_duplicate: + reasons.append("学生、日期、老师、科目、时间和正文均重复,新增课程小结需人工复核") + conflicts = _summary_duplicate_conflicts( + conn, + summary, + source_id_duplicate=source_id_duplicate, + semantic_duplicate=semantic_duplicate, + ) + if not conflicts: + if source_id_duplicate: + reasons.append("状态表中已存在相同来源ID,但课程小结表未找到对应记录") + if semantic_duplicate: + reasons.append("状态表中已存在相同语义指纹,但课程小结表未找到对应记录") + return reasons, conflicts, source_id_duplicate, semantic_duplicate + + +def _summary_duplicate_groups(conn: sqlite3.Connection) -> list[dict]: + data_mod = _data() + grouped: dict[tuple[str, str, str, str, str], list[dict]] = defaultdict(list) + for item in _summary_items(conn): + if not item.get("date_iso") or not item.get("time_range"): + continue + try: + key = data_mod.course_summary_duplicate_key(item) + except ValueError: + continue + if all(key): + grouped[key].append(item) + groups: list[dict] = [] + for key, items in grouped.items(): + if len(items) <= 1: + continue + candidates = [_summary_duplicate_candidate(item) for item in sorted(items, key=lambda item: str(item.get("id") or ""))] + groups.append( + { + "duplicate_group_id": data_mod.course_summary_duplicate_group_id(key, [candidate["id"] for candidate in candidates]), + "key": { + "student": key[0], + "teacher": key[1], + "subject": key[2], + "date_iso": key[3], + "time_range": key[4], + }, + "candidates": candidates, + } + ) + groups.sort( + key=lambda group: ( + str(group["key"].get("date_iso") or ""), + str(group["key"].get("student") or ""), + str(group["key"].get("teacher") or ""), + str(group["key"].get("subject") or ""), + str(group["key"].get("time_range") or ""), + ) + ) + return groups + + +def _create_course_summary_review_task( + conn: sqlite3.Connection, + summary: dict, + proposed_line: str, + reasons: list[str], + saved_path: str = "", + extra_fields: dict | None = None, +) -> dict: + tasks = _tasks_from_conn(conn) + 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, + "storage_key": saved_path, + } + if extra_fields: + task.update(extra_fields) + tasks["next_id"] = int(tasks["next_id"]) + 1 + tasks["items"].append(task) + _write_tasks_to_conn(conn, tasks) + return _task_to_dict(task) + + +def _create_incomplete_course_summary_review_task(conn: sqlite3.Connection, raw: dict, reasons: list[str]) -> dict: + data_mod = _data() + body = str(raw.get("body") or raw.get("content") or "").strip() + source_id = str(raw.get("source_id") or sha1_text(json.dumps(raw, ensure_ascii=False, sort_keys=True), 24)) + summary = { + "source_id": source_id, + "student": data_mod.canonical_name(str(raw.get("student") or "").strip()), + "date_iso": str(raw.get("date_iso") or raw.get("date") or raw.get("class_date") or "").strip(), + "time_range": str(raw.get("time_range") or raw.get("raw_time") or raw.get("time") or "").strip(), + "duration_minutes": raw.get("duration_minutes"), + "duration": str(raw.get("duration") or "").strip(), + "teacher": data_mod.canonical_teacher_name(str(raw.get("teacher") or "").strip()), + "subject": str(raw.get("subject") or "").strip(), + "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": data_mod.payload_bool(raw.get("teacher_trusted") or raw.get("sender_teacher_trusted")), + "remark": str(raw.get("remark") or "").strip(), + } + return _create_course_summary_review_task(conn, summary, "", reasons) + + +def _create_duplicate_review_tasks( + conn: sqlite3.Connection, + target_key: tuple[str, str, str, str, str] | None = None, +) -> dict: + data_mod = _data() + tasks = _tasks_from_conn(conn) + active_tasks = { + str(task.get("duplicate_group_id") or ""): task + for task in tasks.get("items", []) + if task.get("type") == "course_summary_duplicate_review" and task.get("status") in {"pending", "conflict"} + } + groups = _summary_duplicate_groups(conn) + if target_key is not None: + groups = [ + group + for group in groups + if data_mod.course_summary_record_key( + str((group.get("key") or {}).get("student") or ""), + str((group.get("key") or {}).get("teacher") or ""), + str((group.get("key") or {}).get("subject") or ""), + str((group.get("key") or {}).get("date_iso") or ""), + str((group.get("key") or {}).get("time_range") or ""), + ) + == target_key + ] + now = datetime.now().isoformat(timespec="seconds") + created: list[dict] = [] + refreshed = 0 + changed = False + for group in groups: + group_id = str(group.get("duplicate_group_id") or "") + if not group_id: + continue + if group_id in active_tasks: + active_tasks[group_id]["duplicate_candidates"] = group.get("candidates") or [] + active_tasks[group_id]["updated_at"] = now + refreshed += 1 + changed = True + continue + task = data_mod.duplicate_review_task_from_group(int(tasks["next_id"]), group, now) + tasks["next_id"] = int(tasks["next_id"]) + 1 + tasks["items"].append(task) + active_tasks[group_id] = task + created.append(task) + if created or changed: + _write_tasks_to_conn(conn, tasks) + records = _class_records_from_conn(conn) + return { + "scanned": len(groups), + "created": len(created), + "refreshed": refreshed, + "items": [_task_to_dict_with_context(task, records) for task in created], + } + + +def _auto_register_reasons(conn: sqlite3.Connection, summary: dict) -> tuple[list[str], str]: + data_mod = _data() + reasons: list[str] = [] + confidence = str(summary.get("confidence") or "").lower() + recognition_source = str(summary.get("recognition_source") or "") + if confidence not in data_mod.HIGH_CONFIDENCE_VALUES and recognition_source not in data_mod.AUTO_RECOGNITION_SOURCES: + reasons.append("识别置信度不足") + if not summary.get("teacher_trusted"): + reasons.append("发送者老师映射未确认") + if summary.get("teacher") in data_mod.UNKNOWN_TEACHERS: + reasons.append("老师待核对") + if data_mod.normalize_subject(str(summary.get("subject") or "")) in data_mod.UNKNOWN_SUBJECTS: + reasons.append("科目待核对") + if not summary.get("time_range"): + reasons.append("时间段缺失") + if summary.get("duration_minutes") is None: + reasons.append("时长缺失") + if data_mod.message_date_after_class_date(summary): + reasons.append("课程日期晚于消息发送日期") + line = "" + try: + line = data_mod.course_summary_to_class_record_line(summary) + data_mod.parse_class_record_line(line) + except ValueError as exc: + reasons.append(str(exc)) + if conn.execute("SELECT 1 FROM students WHERE student = ?", (str(summary.get("student") or ""),)).fetchone() is None: + reasons.append(f"未找到学生档案: {summary.get('student') or ''}") + if line: + try: + record = data_mod.parse_class_record_line(line) + if conn.execute("SELECT 1 FROM class_records WHERE record_key = ?", (_record_key(record),)).fetchone(): + reasons.append("classnotes 已存在同一条上课记录") + except ValueError: + pass + return reasons, line + + +def _auto_bound_class_record_context(conn: sqlite3.Connection, summary: dict) -> dict | None: + data_mod = _data() + if not summary.get("time_range"): + return None + record = data_mod.close_time_binding_record(summary, _class_records_from_conn(conn)) + if record is None: + return None + summary_time = data_mod.normalize_time_range_text(summary.get("time_range")) + return { + "record": record, + "record_line": data_mod.class_record_to_line(record), + "summary_time_range": summary_time, + "record_time_range": record.time, + "status": "自动绑定", + "reason": "只有时间段不一致,起止时间差均在20分钟内,已自动绑定", + } + + +def query_course_summaries( + q: str = "", + student: str = "", + teacher: str = "", + subject: str = "", + date_from: str = "", + date_to: str = "", + missing_time: bool = False, + binding_status: str = "", + has_candidate: str = "", + limit: int = 200, + offset: int = 0, +) -> dict: + data_mod = _data() + normalized_from = data_mod.normalize_filter_date(date_from) + normalized_to = data_mod.normalize_filter_date(date_to) + if normalized_from and normalized_to and normalized_from > normalized_to: + raise ValueError("开始日期不能晚于结束日期") + normalized_binding_status = binding_status.strip() + if normalized_binding_status not in {"", "matched", "unmatched", "missing_time", "mismatch"}: + raise ValueError("绑定状态筛选无效") + normalized_has_candidate = has_candidate.strip().lower() + if normalized_has_candidate not in {"", "true", "false"}: + raise ValueError("候选记录筛选无效") + keyword = q.strip() + with connect(SQLITE_DB_PATH) as conn: + initialize_schema(conn) + matched = [ + item + for item in _summary_items(conn) + if data_mod.course_summary_matches( + item, + keyword, + data_mod.canonical_name(student.strip()), + data_mod.canonical_teacher_name(teacher.strip()), + subject.strip(), + normalized_from, + normalized_to, + ) + and (not missing_time or not str(item.get("time_range") or "")) + ] + records = _class_records_from_conn(conn) + record_keys = {data_mod.class_record_binding_key(record): record for record in records} + for item in matched: + item["matched_fields"] = data_mod.course_summary_matched_fields(item, keyword) + binding = data_mod.course_summary_binding_status(item, record_keys, records) + item["binding"] = binding + item["matched_record"] = binding["status"] == "matched" + if normalized_binding_status: + matched = [item for item in matched if str((item.get("binding") or {}).get("status") or "") == normalized_binding_status] + if normalized_has_candidate: + expect_candidate = normalized_has_candidate == "true" + matched = [item for item in matched if bool((item.get("binding") or {}).get("candidates") or []) == expect_candidate] + matched.sort( + key=lambda item: ( + str(item.get("date_iso") or "0000-00-00"), + str(item.get("student") or ""), + str(item.get("teacher") or ""), + str(item.get("subject") or ""), + str(item.get("title") or ""), + ), + reverse=True, + ) + limited, normalized_offset, has_more = data_mod.paginate_items(matched, offset, limit) + return { + "count": len(matched), + "returned": len(limited), + "offset": normalized_offset, + "limit": limit, + "has_more": has_more, + "items": limited, + } + + +def supplement_course_summary(record_id: str, body: str) -> dict: + data_mod = _data() + clean_body = body.strip() + if not clean_body: + raise ValueError("课程小结正文不能为空") + + def work(conn: sqlite3.Connection, backup_id: str) -> dict: + record = data_mod.find_record_by_identity(_class_records_from_conn(conn), record_id) + date_iso = record.date.replace(".", "-") + source_id = f"supplement:{record_id}:{sha1_text(clean_body, 12)}" + summary = { + "source_id": source_id, + "student": record.student, + "date_iso": date_iso, + "time_range": record.time, + "duration_minutes": data_mod.duration_minutes_from_time_range(record.time), + "teacher": record.teacher, + "subject": record.subject, + "group": "", + "sender": "课程记录页补充", + "message_time": "", + "body": clean_body, + "recognition_source": "manual_supplement", + "confidence": "manual", + "teacher_trusted": True, + } + semantic_key = data_mod.course_summary_semantic_key(summary) + seen_source_ids, seen_semantic_keys = _summary_seen_sets(conn) + if source_id in seen_source_ids or semantic_key in seen_semantic_keys: + log_id = _append_operation_log( + conn, + "课程小结补充", + "重复", + record_id=record_id, + student=record.student, + teacher=record.teacher, + subject=record.subject, + source_id=source_id, + backup_id=backup_id, + ) + return {"ok": True, "log_id": log_id, "submitted": 0, "status": "duplicate"} + saved = _save_course_summary(conn, summary) + _add_summary_seen(conn, source_id, semantic_key) + _add_ingest_batch( + conn, + { + "batch_id": f"supplement-{record_id}-{sha1_text(clean_body, 8)}", + "received_at": datetime.now().isoformat(timespec="seconds"), + "window": {"source": "records_page_supplement"}, + "students": [record.student], + "result": { + "received": 1, + "saved": 1 if saved.get("added") else 0, + "auto_registered": 0, + "review_pending": 0, + "duplicates": 0 if saved.get("added") else 1, + "rejected": 0, + }, + }, + ) + status_value = "完成" if saved.get("added") else "重复" + log_id = _append_operation_log( + conn, + "课程小结补充", + status_value, + record_id=record_id, + student=record.student, + teacher=record.teacher, + subject=record.subject, + source_id=source_id, + summary_id=str(saved.get("summary_id") or ""), + storage_key=str(saved.get("storage_key") or ""), + heading=str(saved.get("heading") or ""), + backup_id=backup_id, + ) + return { + "ok": True, + "log_id": log_id, + "submitted": 1 if status_value == "完成" else 0, + "status": "saved" if status_value == "完成" else "duplicate", + } + + return _write_transaction("course-summary-supplement", [record_id, clean_body], work) + + +def _process_normalized_summary( + conn: sqlite3.Connection, + normalized: dict, + *, + operation: str, + batch_id: str, + result: dict, + ai_questions: list[str] | None = None, + raw: dict | None = None, + backup_id: str = "", +) -> None: + data_mod = _data() + source_id = normalized["source_id"] + semantic_key = data_mod.course_summary_semantic_key(normalized) + seen_source_ids, seen_semantic_keys = _summary_seen_sets(conn) + if source_id in seen_source_ids or semantic_key in seen_semantic_keys: + duplicate_reasons, duplicate_conflicts, source_id_duplicate, semantic_duplicate = _summary_duplicate_review_context( + conn, + normalized, + seen_source_ids, + seen_semantic_keys, + ) + reasons, proposed_line = _auto_register_reasons(conn, normalized) + duplicate_reason = "来源ID重复,已转入审核" if source_id_duplicate else "课程内容与已有课程小结重复,已转入审核" + review_reasons = [duplicate_reason, *duplicate_reasons] + review_reasons.extend(reason for reason in reasons if reason not in review_reasons) + task = _create_course_summary_review_task( + conn, + normalized, + proposed_line, + review_reasons, + _summary_storage_key(normalized), + extra_fields={ + "duplicate_source": data_mod.summary_as_duplicate_candidate(normalized), + "duplicate_reasons": duplicate_reasons, + "duplicate_conflicts": duplicate_conflicts, + "duplicate_source_id": source_id_duplicate, + "duplicate_semantic_key": semantic_duplicate, + "semantic_key": semantic_key, + "pending_summary_save": True, + }, + ) + _add_summary_seen(conn, source_id, semantic_key) + result["review_pending"] += 1 + result["duplicates"] += 1 + log_id = _append_operation_log( + conn, + operation, + "待审核", + batch_id=batch_id, + source_id=source_id, + student=normalized["student"], + teacher=normalized.get("teacher", ""), + subject=normalized.get("subject", ""), + proposed_line=proposed_line, + reasons=review_reasons, + task_id=task.get("id"), + storage_key=_summary_storage_key(normalized), + duplicate_source_id=source_id_duplicate, + duplicate_semantic_key=semantic_duplicate, + backup_id=backup_id, + ) + result["operation_log_ids"].append(log_id) + result["items"].append( + { + "source_id": source_id, + "status": "待审核" if operation == "课程小结接收" else "review_pending", + "task_id": task.get("id"), + "backup_id": backup_id, + "reasons": review_reasons, + "duplicate_conflicts": duplicate_conflicts, + } + ) + return + + saved = _save_course_summary(conn, normalized) + duplicate_tasks = _create_duplicate_review_tasks(conn, target_key=data_mod.course_summary_duplicate_key(normalized)) + result["saved"] += 1 if saved.get("added") else 0 + reasons, proposed_line = _auto_register_reasons(conn, normalized) + for question in ai_questions or []: + reason = f"脚本追问:{question}" + if reason not in reasons: + reasons.append(reason) + bound_context = _auto_bound_class_record_context(conn, normalized) + if bound_context is not None and not reasons: + _add_summary_seen(conn, source_id, semantic_key) + result["auto_registered"] += 1 + log_id = _append_operation_log( + conn, + operation, + "自动绑定", + batch_id=batch_id, + source_id=source_id, + student=normalized["student"], + teacher=normalized.get("teacher", ""), + subject=normalized.get("subject", ""), + proposed_line=str(bound_context.get("record_line") or ""), + summary_id=str(saved.get("summary_id") or ""), + storage_key=str(saved.get("storage_key") or ""), + summary_time_range=str(bound_context.get("summary_time_range") or ""), + record_time_range=str(bound_context.get("record_time_range") or ""), + reasons=[str(bound_context.get("reason") or "")], + backup_id=backup_id, + ) + result["operation_log_ids"].append(log_id) + if duplicate_tasks["created"]: + scan_log_id = _append_operation_log( + conn, + "重复小结扫描", + "待审核", + batch_id=batch_id, + source_id=source_id, + student=normalized["student"], + created_tasks=duplicate_tasks["created"], + backup_id=backup_id, + ) + result["operation_log_ids"].append(scan_log_id) + result["items"].append( + { + "source_id": source_id, + "status": "自动绑定", + "task_id": None, + "backup_id": backup_id, + "reasons": [str(bound_context.get("reason") or "")], + "record_line": str(bound_context.get("record_line") or ""), + } + ) + return + if reasons: + task = _create_course_summary_review_task( + conn, + normalized, + proposed_line, + reasons, + str(saved.get("storage_key") or ""), + ) + result["review_pending"] += 1 + status_value = "待审核" + task_id = task.get("id") + else: + record = data_mod.parse_class_record_line(proposed_line) + _insert_class_record(conn, record) + result["auto_registered"] += 1 + status_value = "自动入账" + task_id = None + _add_summary_seen(conn, source_id, semantic_key) + log_id = _append_operation_log( + conn, + operation, + 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, + ai_used=bool((raw or {}).get("ai_used")), + ai_summary=str((raw or {}).get("ai_summary") or ""), + task_id=task_id, + backup_id=backup_id, + summary_id=str(saved.get("summary_id") or ""), + storage_key=str(saved.get("storage_key") or ""), + ) + result["operation_log_ids"].append(log_id) + if duplicate_tasks["created"]: + scan_log_id = _append_operation_log( + conn, + "重复小结扫描", + "待审核", + batch_id=batch_id, + source_id=source_id, + student=normalized["student"], + created_tasks=duplicate_tasks["created"], + backup_id=backup_id, + ) + result["operation_log_ids"].append(scan_log_id) + result["items"].append( + { + "source_id": source_id, + "status": status_value, + "task_id": task_id, + "backup_id": backup_id, + "reasons": reasons, + } + ) + + +def register_course_summary_texts(lines: list[str] | None = None, line: str | None = None) -> dict: + data_mod = _data() + texts = data_mod.normalize_lines(lines=lines, line=line) + + def work(conn: sqlite3.Connection, backup_id: str) -> dict: + known_students = [account.student for account in _accounts_from_db(conn)] + result = { + "received": len(texts), + "saved": 0, + "auto_registered": 0, + "review_pending": 0, + "duplicates": 0, + "rejected": 0, + "operation_log_ids": [], + "items": [], + } + now = datetime.now().isoformat(timespec="seconds") + for index, text in enumerate(texts): + raw: dict | None = None + normalized: dict | None = None + try: + raw = data_mod.extract_course_summary_from_text(text, index, known_students=known_students) + normalized = data_mod.normalize_course_summary(raw) + _process_normalized_summary( + conn, + normalized, + operation="课程小结登记", + batch_id=f"manual-{datetime.now().strftime('%Y%m%d%H%M%S')}-{sha1_text(normalized['source_id'], 8)}", + result=result, + raw=raw, + backup_id=backup_id, + ) + _add_ingest_batch( + conn, + { + "batch_id": f"manual-{datetime.now().strftime('%Y%m%d%H%M%S')}-{sha1_text(normalized['source_id'], 8)}", + "received_at": now, + "window": {"source": "admin_register", "submitted_at": now}, + "students": [normalized["student"]], + "result": { + "received": 1, + "saved": result["saved"], + "auto_registered": result["auto_registered"], + "review_pending": result["review_pending"], + "duplicates": result["duplicates"], + "rejected": result["rejected"], + }, + }, + ) + except ValueError as exc: + source_id = str((normalized or raw or {}).get("source_id") or f"manual:{sha1_text(text, 24)}") + student = str((normalized or raw or {}).get("student") or "") + log_id = _append_operation_log( + conn, + "课程小结登记", + "已驳回", + source_id=source_id, + student=student, + error=str(exc), + backup_id=backup_id, + ) + result["rejected"] += 1 + result["operation_log_ids"].append(log_id) + result["items"].append({"source_id": source_id, "status": "rejected", "error": str(exc)}) + return result + + return _write_transaction("course-summary-manual-register", texts, work) + + +def ingest_course_summaries( + *, + batch_id: str, + window: dict, + students: list[str], + summaries: list[dict], +) -> dict: + data_mod = _data() + + def work(conn: sqlite3.Connection, backup_id: str) -> dict: + 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 = data_mod.normalize_course_summary(raw) + ai_questions = [str(item).strip() for item in (raw.get("ai_questions") or []) if str(item).strip()] + _process_normalized_summary( + conn, + normalized, + operation="课程小结接收", + batch_id=batch_id, + result=result, + ai_questions=ai_questions, + raw=raw, + backup_id=backup_id, + ) + except ValueError as exc: + ai_questions = [str(item).strip() for item in (raw.get("ai_questions") or []) if str(item).strip()] + if ai_questions: + reasons = [f"脚本追问:{question}" for question in ai_questions] + reasons.append(str(exc)) + task = _create_incomplete_course_summary_review_task(conn, raw, reasons) + result["review_pending"] += 1 + source_id = str(raw.get("source_id") or task.get("source_id") or "") + log_id = _append_operation_log( + conn, + "课程小结接收", + "待审核", + batch_id=batch_id, + source_id=source_id, + student=str(raw.get("student") or ""), + reasons=reasons, + ai_used=bool(raw.get("ai_used")), + ai_summary=str(raw.get("ai_summary") or ""), + task_id=task.get("id"), + backup_id=backup_id, + ) + result["operation_log_ids"].append(log_id) + result["items"].append( + { + "source_id": source_id, + "status": "待审核", + "task_id": task.get("id"), + "backup_id": backup_id, + "reasons": reasons, + } + ) + continue + result["rejected"] += 1 + source_id = str((normalized or raw).get("source_id") or "") + log_id = _append_operation_log( + conn, + "课程小结接收", + "已驳回", + batch_id=batch_id, + source_id=source_id, + student=str((normalized or raw).get("student") or ""), + error=str(exc), + backup_id=backup_id, + ) + result["operation_log_ids"].append(log_id) + result["items"].append({"source_id": source_id, "status": "rejected", "error": str(exc)}) + _add_ingest_batch( + conn, + { + "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") + }, + }, + ) + return result + + return _write_transaction("course-summary-ingest", [batch_id, json.dumps(window, ensure_ascii=False)], work) + + +def _mark_task(conn: sqlite3.Connection, task_id: int, status: str, message: str = "") -> dict: + tasks = _tasks_from_conn(conn) + task = _find_task(tasks, task_id) + if task.get("status") not in {"pending", "conflict"}: + raise ValueError("该任务已处理,不能重复操作") + now = datetime.now().isoformat(timespec="seconds") + task["status"] = status + task["updated_at"] = now + task["reviewed_at"] = now + if message: + task["message"] = message + _write_tasks_to_conn(conn, tasks) + return _task_to_dict(task) + + +def reject_admin_task(task_id: int) -> dict: + def work(conn: sqlite3.Connection, backup_id: str) -> dict: + task = _mark_task(conn, task_id, "rejected") + _append_operation_log( + conn, + "审核驳回", + "已驳回", + 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=backup_id, + ) + return task + + return _write_transaction("admin-task-reject", [str(task_id)], work) + + +def approve_admin_task(task_id: int) -> dict: + data_mod = _data() + + def work(conn: sqlite3.Connection, backup_id: str) -> dict: + tasks = _tasks_from_conn(conn) + task = _find_task(tasks, task_id) + if task.get("status") not in {"pending", "conflict"}: + raise ValueError("该任务已处理,不能重复批准") + task_type = str(task.get("type") or "") + now = datetime.now().isoformat(timespec="seconds") + if task_type == "class_record_correction": + original = data_mod.parse_class_record_line(str(task.get("original_line") or "")) + corrected = data_mod.parse_class_record_line(str(task.get("corrected_line") or "")) + corrected_line = _replace_class_record(conn, original, corrected) + task["status"] = "approved" + task["updated_at"] = now + task["reviewed_at"] = now + task["original"] = data_mod.record_to_dict(original) + task["corrected_line"] = corrected_line + task["corrected"] = data_mod.record_to_dict(corrected) + _write_tasks_to_conn(conn, tasks) + _append_operation_log( + conn, + "审核批准", + "已批准", + task_id=task_id, + task_type=task_type, + student=str(task.get("corrected", {}).get("student") or ""), + backup_id=backup_id, + ) + return {"task": _task_to_dict(task), "backup_id": backup_id} + if task_type == "class_record_deletion": + original = data_mod.parse_class_record_line(str(task.get("original_line") or "")) + _delete_class_record(conn, original) + task["status"] = "approved" + task["updated_at"] = now + task["reviewed_at"] = now + task["deleted_line"] = data_mod.class_record_to_line(original) + task["restored_hours"] = original.duration_hours + _write_tasks_to_conn(conn, tasks) + _append_operation_log( + conn, + "审核批准", + "已批准", + task_id=task_id, + task_type=task_type, + student=original.student, + backup_id=backup_id, + ) + return {"task": _task_to_dict(task), "backup_id": backup_id} + if task_type == "course_summary_review": + summary = task.get("summary") or {} + proposed_line = str(task.get("proposed_line") or "").strip() + if not proposed_line: + proposed_line = data_mod.course_summary_to_class_record_line(summary) + if task.get("pending_summary_save"): + saved = _save_course_summary(conn, summary, allow_same_source_id=True) + task["saved_path"] = str(saved.get("storage_key") or "") + task["storage_key"] = str(saved.get("storage_key") or "") + task["summary_id"] = str(saved.get("summary_id") or "") + record = data_mod.parse_class_record_line(proposed_line) + record_line = data_mod.class_record_to_line(record) + if conn.execute("SELECT 1 FROM class_records WHERE record_key = ?", (_record_key(record),)).fetchone(): + raise ValueError(f"上课记录已存在: {record_line}") + _insert_class_record(conn, record) + task["status"] = "approved" + task["updated_at"] = now + task["reviewed_at"] = now + task["proposed_line"] = record_line + task["registered_line"] = record_line + task["backup_id"] = backup_id + _write_tasks_to_conn(conn, tasks) + _append_operation_log( + conn, + "审核批准", + "已批准", + task_id=task_id, + task_type=task_type, + student=str(task.get("student") or ""), + source_id=str(task.get("source_id") or ""), + proposed_line=record_line, + backup_id=backup_id, + summary_id=str(task.get("summary_id") or ""), + storage_key=str(task.get("storage_key") or task.get("saved_path") or ""), + ) + return {"task": _task_to_dict(task), "backup_id": backup_id} + raise ValueError("不支持的审核任务类型") + + return _write_transaction("admin-task-approve", [str(task_id)], work) + + +def update_course_summary_review_task(task_id: int, updates: dict) -> dict: + data_mod = _data() + + def work(conn: sqlite3.Connection, backup_id: str) -> dict: + tasks = _tasks_from_conn(conn) + task = _find_task(tasks, task_id) + if task.get("type") != "course_summary_review": + raise ValueError("该任务不是课程小结审核") + if task.get("status") not in {"pending", "conflict"}: + raise ValueError("该任务已处理,不能修改") + summary = task.get("summary") or {} + updated_summary = data_mod.editable_course_summary_payload(summary, updates) + reasons, proposed_line = _auto_register_reasons(conn, updated_summary) + if data_mod.is_duplicate_course_summary_review_task(task): + source_ids, semantic_keys = _summary_seen_sets(conn) + source_ids.discard(str(summary.get("source_id") or "")) + if task.get("semantic_key"): + semantic_keys.discard(str(task.get("semantic_key") or "")) + semantic_keys.discard(data_mod.course_summary_semantic_key(summary)) + duplicate_reasons, duplicate_conflicts, source_id_duplicate, semantic_duplicate = _summary_duplicate_review_context( + conn, + updated_summary, + source_ids, + semantic_keys, + ) + merged_reasons = [*duplicate_reasons] + merged_reasons.extend(reason for reason in reasons if reason not in merged_reasons) + reasons = merged_reasons + task["duplicate_reasons"] = duplicate_reasons + task["duplicate_conflicts"] = duplicate_conflicts + task["duplicate_source_id"] = source_id_duplicate + task["duplicate_semantic_key"] = semantic_duplicate + task["duplicate_source"] = data_mod.summary_as_duplicate_candidate(updated_summary, _summary_storage_key(updated_summary)) + task["semantic_key"] = data_mod.course_summary_semantic_key(updated_summary) + task["pending_summary_save"] = True + task["saved_path"] = _summary_storage_key(updated_summary) + task["storage_key"] = _summary_storage_key(updated_summary) + _add_summary_seen(conn, str(updated_summary.get("source_id") or ""), task["semantic_key"]) + now = datetime.now().isoformat(timespec="seconds") + task["status"] = "pending" + task["updated_at"] = now + task.pop("message", None) + task["student"] = updated_summary["student"] + task["summary"] = updated_summary + task["proposed_line"] = proposed_line + task["reasons"] = reasons + _write_tasks_to_conn(conn, tasks) + _append_operation_log( + conn, + "课程小结审核修正", + "已更新", + task_id=task_id, + task_type=str(task.get("type") or ""), + student=str(task.get("student") or ""), + source_id=str(task.get("source_id") or ""), + proposed_line=proposed_line, + backup_id=backup_id, + ) + return {"task": _task_to_dict(task)} + + return _write_transaction("admin-course-summary-review-update", [str(task_id), json.dumps(updates, ensure_ascii=False)], work) + + +def link_existing_course_summary_task(task_id: int) -> dict: + data_mod = _data() + + def work(conn: sqlite3.Connection, backup_id: str) -> dict: + tasks = _tasks_from_conn(conn) + task = _find_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: + raise ValueError("课程小结缺少候选登记行,不能关联已有记录") + record = data_mod.parse_class_record_line(proposed_line) + if conn.execute("SELECT 1 FROM class_records WHERE record_key = ?", (_record_key(record),)).fetchone() is None: + raise ValueError("候选登记行在上课记录中不存在,不能关联已有记录") + now = datetime.now().isoformat(timespec="seconds") + task["status"] = "approved" + task["updated_at"] = now + task["reviewed_at"] = now + task["linked_record_line"] = proposed_line + task["message"] = "已关联已有上课记录,未重复扣课时" + _write_tasks_to_conn(conn, tasks) + _append_operation_log( + conn, + "课程小结关联已有记录", + "已批准", + task_id=task_id, + task_type=str(task.get("type") or ""), + student=str(task.get("student") or ""), + source_id=str(task.get("source_id") or ""), + proposed_line=proposed_line, + backup_id=backup_id, + ) + return {"task": _task_to_dict(task), "linked_record_line": proposed_line} + + return _write_transaction("admin-course-summary-link-existing", [str(task_id)], work) + + +def scan_duplicate_course_summaries() -> dict: + def work(conn: sqlite3.Connection, backup_id: str) -> dict: + result = _create_duplicate_review_tasks(conn) + _append_operation_log( + conn, + "重复小结扫描", + "待审核" if result.get("created") else "完成", + scanned_groups=int(result.get("scanned") or 0), + created_tasks=int(result.get("created") or 0), + backup_id=backup_id, + ) + return result + + return _write_transaction("admin-course-summary-duplicate-scan", [], work) + + +def _find_summary_row(conn: sqlite3.Connection, summary_id: str) -> sqlite3.Row: + row = conn.execute("SELECT * FROM course_summaries WHERE id = ?", (summary_id,)).fetchone() + if row is None: + raise ValueError("未找到课程小结") + return row + + +def update_course_summary_time(summary_id: str, time_range: str) -> dict: + data_mod = _data() + + def work(conn: sqlite3.Connection, backup_id: str) -> dict: + row = _find_summary_row(conn, summary_id) + item = _summary_item_from_row(row) + normalized_time = data_mod.normalize_time_range_text(time_range) + subject = data_mod.normalize_subject(str(item.get("subject") or "待核对科目")) or "待核对科目" + date_iso = str(item.get("date_iso") or "") + if not date_iso: + raise ValueError("课程小结缺少日期,不能补齐时间") + new_title = re.sub( + r"^\d{4}[.-]\d{1,2}[.-]\d{1,2}(?:\s+\d{1,2}:\d{2}-\d{1,2}:\d{2})?", + f"{date_iso} {normalized_time}", + str(item.get("title") or ""), + ) + if new_title == str(item.get("title") or ""): + new_title = f"{date_iso} {normalized_time} {subject}课堂小结" + payload = {**_summary_payload_from_row(row), "time_range": normalized_time, "title": new_title} + duration_minutes = data_mod.duration_minutes_from_time_range(normalized_time) + payload["duration_minutes"] = duration_minutes + semantic_key = data_mod.course_summary_semantic_key(payload) + conn.execute( + """ + UPDATE course_summaries + SET title = ?, time_range = ?, duration_minutes = ?, semantic_key = ?, payload_json = ? + WHERE id = ? + """, + (new_title, normalized_time, duration_minutes, semantic_key, json.dumps(payload, ensure_ascii=False, sort_keys=True), summary_id), + ) + _add_summary_seen(conn, str(payload.get("source_id") or ""), semantic_key) + _append_operation_log(conn, "课程小结补齐时间", "已更新", summary_id=summary_id, time_range=normalized_time, backup_id=backup_id) + return {"id": summary_id, "title": str(item.get("title") or ""), "new_title": new_title, "summary_id": summary_id, "backup_id": backup_id} + + return _write_transaction("admin-update-course-summary-time", [summary_id, time_range], work) + + +def update_course_summary_body(summary_id: str, body: str) -> dict: + new_body = body.strip() + if not new_body: + raise ValueError("课程小结正文不能为空") + data_mod = _data() + + def work(conn: sqlite3.Connection, backup_id: str) -> dict: + row = _find_summary_row(conn, summary_id) + payload = {**_summary_payload_from_row(row), "body": new_body} + semantic_key = data_mod.course_summary_semantic_key(payload) + conn.execute( + """ + UPDATE course_summaries + SET body = ?, raw_body = ?, semantic_key = ?, content_hash = ?, payload_json = ? + WHERE id = ? + """, + ( + new_body, + new_body, + semantic_key, + sha1_text(f"{row['relative_path']}|{row['title']}|{new_body}"), + json.dumps(payload, ensure_ascii=False, sort_keys=True), + summary_id, + ), + ) + _add_summary_seen(conn, str(payload.get("source_id") or ""), semantic_key) + _append_operation_log(conn, "课程小结修改正文", "已更新", summary_id=summary_id, backup_id=backup_id) + return {"id": summary_id, "new_id": summary_id, "summary_id": summary_id, "body": new_body, "backup_id": backup_id} + + return _write_transaction("admin-update-course-summary-body", [summary_id], work) + + +def update_course_summary_identity(summary_id: str, student: str, teacher: str, subject: str = "") -> dict: + data_mod = _data() + new_student = data_mod.canonical_name(str(student or "").strip()) + if not new_student: + raise ValueError("学生不能为空") + + def work(conn: sqlite3.Connection, backup_id: str) -> dict: + row = _find_summary_row(conn, summary_id) + item = _summary_item_from_row(row) + new_teacher = data_mod.resolve_existing_teacher_input(str(teacher or ""), _teachers_from_db(conn)) + new_subject = data_mod.normalize_subject(str(subject or "").strip()) or data_mod.normalize_subject(str(item.get("subject") or "")) + if not new_subject: + raise ValueError("科目不能为空") + old_student = str(item.get("student") or "") + old_teacher = str(item.get("teacher") or "") + old_subject = data_mod.normalize_subject(str(item.get("subject") or "")) + if new_student == old_student and new_teacher == old_teacher and new_subject == old_subject: + raise ValueError("学生、老师和科目没有变化") + payload = { + **_summary_payload_from_row(row), + "student": new_student, + "teacher": new_teacher, + "subject": new_subject, + } + title = str(row["title"] or "") + if new_subject != old_subject: + title = title.replace(old_subject, new_subject, 1) if old_subject else title + if title == str(row["title"] or ""): + time_range = str(item.get("time_range") or "") + date_iso = str(item.get("date_iso") or "") + title = f"{date_iso} {time_range + ' ' if time_range else ''}{new_subject}课堂小结".strip() + payload["title"] = title + storage_key = _summary_storage_key(payload) + semantic_key = data_mod.course_summary_semantic_key(payload) + conn.execute( + """ + UPDATE course_summaries + SET relative_path = ?, title = ?, student = ?, teacher = ?, subject = ?, + semantic_key = ?, content_hash = ?, payload_json = ? + WHERE id = ? + """, + ( + storage_key, + title, + new_student, + new_teacher, + new_subject, + semantic_key, + sha1_text(f"{storage_key}|{title}|{payload.get('body') or ''}"), + json.dumps(payload, ensure_ascii=False, sort_keys=True), + summary_id, + ), + ) + _add_summary_seen(conn, str(payload.get("source_id") or ""), semantic_key) + _append_operation_log( + conn, + "课程小结修改归属", + "已更新", + summary_id=summary_id, + new_summary_id=summary_id, + student=new_student, + old_student=old_student, + teacher=new_teacher, + old_teacher=old_teacher, + subject=new_subject, + old_subject=old_subject, + storage_key=storage_key, + backup_id=backup_id, + ) + return { + "id": summary_id, + "new_id": summary_id, + "old_student": old_student, + "old_teacher": old_teacher, + "old_subject": old_subject, + "student": new_student, + "teacher": new_teacher, + "subject": new_subject, + "storage_key": storage_key, + "heading": title, + "backup_id": backup_id, + } + + return _write_transaction("admin-update-course-summary-identity", [summary_id, new_student, teacher, subject], work) + + +def delete_course_summary(summary_id: str) -> dict: + def work(conn: sqlite3.Connection, backup_id: str) -> dict: + row = _find_summary_row(conn, summary_id) + title = str(row["title"] or "") + conn.execute("DELETE FROM course_summaries WHERE id = ?", (summary_id,)) + _append_operation_log(conn, "课程小结删除", "已删除", summary_id=summary_id, backup_id=backup_id) + return {"id": summary_id, "summary_id": summary_id, "title": title, "deleted": True, "backup_id": backup_id} + + return _write_transaction("admin-delete-course-summary", [summary_id], work) + + +def resolve_duplicate_course_summary_task(task_id: int, delete_summary_id: str) -> dict: + target_id = delete_summary_id.strip() + if not target_id: + raise ValueError("请选择要删除的课程小结") + + def work(conn: sqlite3.Connection, backup_id: str) -> dict: + tasks = _tasks_from_conn(conn) + task = _find_task(tasks, task_id) + if task.get("type") != "course_summary_duplicate_review": + raise ValueError("该任务不是重复小结审核") + if task.get("status") not in {"pending", "conflict"}: + raise ValueError("该任务已处理,不能重复批准") + groups = {str(group.get("duplicate_group_id") or ""): group for group in _summary_duplicate_groups(conn)} + group_id = str(task.get("duplicate_group_id") or "") + current_group = groups.get(group_id) + if current_group is None: + task["status"] = "conflict" + task["updated_at"] = datetime.now().isoformat(timespec="seconds") + task["message"] = "重复小结已不存在或已被处理" + _write_tasks_to_conn(conn, tasks) + raise ValueError(task["message"]) + candidates = current_group.get("candidates") or [] + candidate_ids = {str(candidate.get("id") or "") for candidate in candidates} + if target_id not in candidate_ids: + task["status"] = "conflict" + task["updated_at"] = datetime.now().isoformat(timespec="seconds") + task["duplicate_candidates"] = candidates + task["message"] = "选择的小结已不存在,请重新选择" + _write_tasks_to_conn(conn, tasks) + raise ValueError(task["message"]) + if len(candidates) <= 1: + task["status"] = "conflict" + task["updated_at"] = datetime.now().isoformat(timespec="seconds") + task["duplicate_candidates"] = candidates + task["message"] = "当前重复组已不足两条,无需删除" + _write_tasks_to_conn(conn, tasks) + raise ValueError(task["message"]) + row = _find_summary_row(conn, target_id) + conn.execute("DELETE FROM course_summaries WHERE id = ?", (target_id,)) + now = datetime.now().isoformat(timespec="seconds") + task["status"] = "approved" + task["updated_at"] = now + task["reviewed_at"] = now + task["deleted_summary_id"] = target_id + task["deleted_summary_title"] = str(row["title"] or "") + task["backup_id"] = backup_id + task["duplicate_candidates"] = candidates + _write_tasks_to_conn(conn, tasks) + _append_operation_log( + conn, + "重复小结删除", + "已删除", + task_id=task_id, + task_type=str(task.get("type") or ""), + student=str(task.get("student") or ""), + summary_id=target_id, + backup_id=backup_id, + ) + return { + "task": _task_to_dict(task), + "deleted_summary_id": target_id, + "backup_id": backup_id, + "deleted": {"id": target_id, "title": str(row["title"] or ""), "deleted": True}, + } + + return _write_transaction("admin-resolve-duplicate-summary", [str(task_id), target_id], work) + + +def rollback_operation_log(log_id: str) -> dict: + from .config import write_lock + + with write_lock: + with connect(SQLITE_DB_PATH) as check_conn: + initialize_schema(check_conn) + rows = [_data().localize_operation_log_item(item) for item in _operation_logs_from_db(check_conn)] + target = next((row for row in rows if str(row.get("id") or "") == log_id), None) + if target is None: + raise ValueError(f"未找到操作记录: {log_id}") + state = _rollback_state_for_log(target, rows) + 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 = _backup_root() / target_backup_id + backup_db = backup_dir / SNAPSHOT_DB_NAME + rollback_backup_id = create_sqlite_snapshot("rollback-operation", [log_id, target_backup_id]) + with transaction(SQLITE_DB_PATH) as conn: + initialize_schema(conn) + _restore_snapshot_tables(conn, backup_db) + _append_operation_log( + conn, + "撤回操作", + "已撤回", + target_log_id=log_id, + target_backup_id=target_backup_id, + backup_id=rollback_backup_id, + rollback_mode="sqlite_snapshot", + ) + with connect(SQLITE_DB_PATH) as conn: + conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") + return { + "target_log_id": log_id, + "target_backup_id": target_backup_id, + "backup_id": rollback_backup_id, + "rollback_mode": "sqlite_snapshot", + "restored_tables": RESTORED_TABLES, + } diff --git a/app/app/routers/admin.py b/app/app/routers/admin.py index 44ebb99..9a05711 100644 --- a/app/app/routers/admin.py +++ b/app/app/routers/admin.py @@ -1,25 +1,11 @@ from __future__ import annotations -from pathlib import Path - from fastapi import APIRouter, Depends, HTTPException, Query from ..auth import verify_admin_auth -from ..config import ( - ACCOUNTS_PATH, - ADMIN_TASKS_PATH, - CLASSNOTES_PATH, - COURSE_SUMMARIES_ROOT, - COURSE_SUMMARY_STATE_PATH, - OPERATION_LOGS_PATH, - TEACHERS_PATH, - write_lock, -) -from ..data import ( - admin_dashboard_summary, - append_operation_log, +from ..repository import ( + admin_dashboard, approve_admin_task, - create_course_summary_duplicate_review_tasks, delete_course_summary, link_existing_course_summary_task, list_admin_tasks, @@ -28,6 +14,7 @@ from ..data import ( reject_admin_task, resolve_duplicate_course_summary_task, rollback_operation_log, + scan_duplicate_course_summaries, update_course_summary_body, update_course_summary_identity, update_course_summary_review_task, @@ -38,28 +25,10 @@ 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/dashboard") -def admin_dashboard(period: str = Query("month"), _user: str = Depends(verify_admin_auth)): +def admin_dashboard_endpoint(period: str = Query("month"), _user: str = Depends(verify_admin_auth)): try: - return admin_dashboard_summary( - classnotes_path=CLASSNOTES_PATH, - accounts_path=ACCOUNTS_PATH, - teachers_path=TEACHERS_PATH, - tasks_path=ADMIN_TASKS_PATH, - summaries_root=COURSE_SUMMARIES_ROOT, - operation_logs_path=OPERATION_LOGS_PATH, - period=period, - ) + return admin_dashboard(period=period) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @@ -72,17 +41,7 @@ def admin_tasks( offset: int = Query(0, ge=0), _user: str = Depends(verify_admin_auth), ): - try: - return list_admin_tasks( - ADMIN_TASKS_PATH, - status_filter=status_filter, - task_type=task_type, - classnotes_path=CLASSNOTES_PATH, - offset=offset, - limit=limit, - ) - except ValueError as exc: - raise HTTPException(status_code=500, detail=str(exc)) from exc + return list_admin_tasks(status_filter=status_filter, task_type=task_type, offset=offset, limit=limit) @router.get("/api/admin/operation-logs") @@ -94,34 +53,13 @@ def admin_operation_logs( student: str = Query(""), _user: str = Depends(verify_admin_auth), ): - return list_operation_logs( - OPERATION_LOGS_PATH, - limit=limit, - offset=offset, - operation=operation, - status_filter=status_filter, - student=student, - backup_paths=rollback_backup_paths(), - ) + return list_operation_logs(limit=limit, offset=offset, operation=operation, status_filter=status_filter, student=student) @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 [], - rollback_mode=str(result.get("rollback_mode") or ""), - removed_lines=result.get("removed_lines") or [], - restored_hours=result.get("restored_hours") or "", - ) + result = rollback_operation_log(log_id) except ValueError as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc return {"ok": True, **result} @@ -144,8 +82,6 @@ def admin_course_summaries( ): try: return query_course_summaries( - COURSE_SUMMARIES_ROOT, - CLASSNOTES_PATH, q=q, student=student, teacher=teacher, @@ -165,20 +101,7 @@ def admin_course_summaries( @router.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_admin_task(ADMIN_TASKS_PATH, COURSE_SUMMARIES_ROOT, CLASSNOTES_PATH, ACCOUNTS_PATH, task_id) - task = result.get("task", {}) - append_operation_log( - OPERATION_LOGS_PATH, - "审核批准", - "已批准", - 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 ""), - saved_path=str(task.get("saved_path") or ""), - ) + result = approve_admin_task(task_id) except ValueError as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc return {"ok": True, **result} @@ -186,26 +109,8 @@ def admin_approve_task(task_id: int, _user: str = Depends(verify_admin_auth)): @router.post("/api/admin/tasks/{task_id}/resolve-duplicate-summary") def admin_resolve_duplicate_summary(task_id: int, payload: dict, _user: str = Depends(verify_admin_auth)): - delete_summary_id = str(payload.get("delete_summary_id") or "") try: - with write_lock: - result = resolve_duplicate_course_summary_task( - ADMIN_TASKS_PATH, - COURSE_SUMMARIES_ROOT, - task_id, - delete_summary_id, - ) - task = result.get("task", {}) - append_operation_log( - OPERATION_LOGS_PATH, - "重复小结删除", - "已删除", - task_id=task_id, - task_type=str(task.get("type") or ""), - student=str(task.get("student") or ""), - summary_id=str(result.get("deleted_summary_id") or ""), - backup_id=str(result.get("backup_id") or ""), - ) + result = resolve_duplicate_course_summary_task(task_id, str(payload.get("delete_summary_id") or "")) except ValueError as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc return {"ok": True, **result} @@ -214,27 +119,7 @@ def admin_resolve_duplicate_summary(task_id: int, payload: dict, _user: str = De @router.post("/api/admin/tasks/{task_id}/course-summary-review") def admin_update_course_summary_review(task_id: int, payload: dict, _user: str = Depends(verify_admin_auth)): try: - with write_lock: - result = update_course_summary_review_task( - ADMIN_TASKS_PATH, - COURSE_SUMMARIES_ROOT, - COURSE_SUMMARY_STATE_PATH, - CLASSNOTES_PATH, - ACCOUNTS_PATH, - task_id, - payload, - ) - task = result.get("task", {}) - append_operation_log( - OPERATION_LOGS_PATH, - "课程小结审核修正", - "已更新", - task_id=task_id, - task_type=str(task.get("type") or ""), - student=str(task.get("student") or ""), - source_id=str(task.get("source_id") or ""), - proposed_line=str(task.get("proposed_line") or ""), - ) + result = update_course_summary_review_task(task_id, payload) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc return {"ok": True, **result} @@ -243,19 +128,7 @@ def admin_update_course_summary_review(task_id: int, payload: dict, _user: str = @router.post("/api/admin/tasks/{task_id}/link-existing-course-summary") def admin_link_existing_course_summary(task_id: int, _user: str = Depends(verify_admin_auth)): try: - with write_lock: - result = link_existing_course_summary_task(ADMIN_TASKS_PATH, CLASSNOTES_PATH, task_id) - task = result.get("task", {}) - append_operation_log( - OPERATION_LOGS_PATH, - "课程小结关联已有记录", - "已批准", - task_id=task_id, - task_type=str(task.get("type") or ""), - student=str(task.get("student") or ""), - source_id=str(task.get("source_id") or ""), - proposed_line=str(result.get("linked_record_line") or ""), - ) + result = link_existing_course_summary_task(task_id) except ValueError as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc return {"ok": True, **result} @@ -264,17 +137,7 @@ def admin_link_existing_course_summary(task_id: int, _user: str = Depends(verify @router.post("/api/admin/tasks/{task_id}/reject") 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, - "审核驳回", - "已驳回", - 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 ""), - ) + task = reject_admin_task(task_id) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc return {"ok": True, "task": task} @@ -283,19 +146,7 @@ def admin_reject_task(task_id: int, _user: str = Depends(verify_admin_auth)): @router.post("/api/admin/course-summaries/duplicate-scan") def admin_scan_duplicate_course_summaries(_user: str = Depends(verify_admin_auth)): try: - with write_lock: - result = create_course_summary_duplicate_review_tasks( - ADMIN_TASKS_PATH, - COURSE_SUMMARIES_ROOT, - classnotes_path=CLASSNOTES_PATH, - ) - append_operation_log( - OPERATION_LOGS_PATH, - "重复小结扫描", - "待审核" if result.get("created") else "完成", - scanned_groups=int(result.get("scanned") or 0), - created_tasks=int(result.get("created") or 0), - ) + result = scan_duplicate_course_summaries() except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc return {"ok": True, **result} @@ -304,16 +155,7 @@ def admin_scan_duplicate_course_summaries(_user: str = Depends(verify_admin_auth @router.post("/api/admin/course-summaries/{summary_id}/time") def admin_update_course_summary_time(summary_id: str, payload: dict, _user: str = Depends(verify_admin_auth)): try: - with write_lock: - result = update_course_summary_time(COURSE_SUMMARIES_ROOT, summary_id, str(payload.get("time_range") or "")) - append_operation_log( - OPERATION_LOGS_PATH, - "课程小结补齐时间", - "已更新", - summary_id=summary_id, - time_range=str(payload.get("time_range") or ""), - backup_id=str(result.get("backup_id") or ""), - ) + result = update_course_summary_time(summary_id, str(payload.get("time_range") or "")) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc return {"ok": True, **result} @@ -322,15 +164,7 @@ def admin_update_course_summary_time(summary_id: str, payload: dict, _user: str @router.post("/api/admin/course-summaries/{summary_id}/body") def admin_update_course_summary_body(summary_id: str, payload: dict, _user: str = Depends(verify_admin_auth)): try: - with write_lock: - result = update_course_summary_body(COURSE_SUMMARIES_ROOT, summary_id, str(payload.get("body") or "")) - append_operation_log( - OPERATION_LOGS_PATH, - "课程小结修改正文", - "已更新", - summary_id=summary_id, - backup_id=str(result.get("backup_id") or ""), - ) + result = update_course_summary_body(summary_id, str(payload.get("body") or "")) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc return {"ok": True, **result} @@ -339,33 +173,12 @@ def admin_update_course_summary_body(summary_id: str, payload: dict, _user: str @router.post("/api/admin/course-summaries/{summary_id}/identity") def admin_update_course_summary_identity(summary_id: str, payload: dict, _user: str = Depends(verify_admin_auth)): try: - with write_lock: - result = update_course_summary_identity( - COURSE_SUMMARIES_ROOT, - COURSE_SUMMARY_STATE_PATH, - TEACHERS_PATH, - summary_id, - str(payload.get("student") or ""), - str(payload.get("teacher") or ""), - str(payload.get("subject") or ""), - ) - append_operation_log( - OPERATION_LOGS_PATH, - "课程小结修改归属", - "已更新", - summary_id=summary_id, - new_summary_id=str(result.get("new_id") or ""), - student=str(result.get("student") or ""), - old_student=str(result.get("old_student") or ""), - teacher=str(result.get("teacher") or ""), - old_teacher=str(result.get("old_teacher") or ""), - subject=str(result.get("subject") or ""), - old_subject=str(result.get("old_subject") or ""), - old_path=str(result.get("old_path") or ""), - new_path=str(result.get("new_path") or ""), - heading=str(result.get("heading") or ""), - backup_id=str(result.get("backup_id") or ""), - ) + result = update_course_summary_identity( + summary_id, + str(payload.get("student") or ""), + str(payload.get("teacher") or ""), + str(payload.get("subject") or ""), + ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc return {"ok": True, **result} @@ -374,15 +187,7 @@ def admin_update_course_summary_identity(summary_id: str, payload: dict, _user: @router.delete("/api/admin/course-summaries/{summary_id}") def admin_delete_course_summary(summary_id: str, _user: str = Depends(verify_admin_auth)): try: - with write_lock: - result = delete_course_summary(COURSE_SUMMARIES_ROOT, summary_id) - append_operation_log( - OPERATION_LOGS_PATH, - "课程小结删除", - "已删除", - summary_id=summary_id, - backup_id=str(result.get("backup_id") or ""), - ) + result = delete_course_summary(summary_id) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc return {"ok": True, **result} diff --git a/app/app/routers/health.py b/app/app/routers/health.py index 821d6e8..22509f3 100644 --- a/app/app/routers/health.py +++ b/app/app/routers/health.py @@ -2,20 +2,8 @@ from __future__ import annotations from fastapi import APIRouter, Depends -from ..api_utils import file_meta, load_records, load_student_profiles, load_teachers from ..auth import verify_records_auth -from ..config import ( - ACCOUNTS_PATH, - CLASSNOTES_PATH, - COURSE_SUMMARIES_ROOT, - COURSE_SUMMARY_STATE_PATH, - LEGACY_TEXT_ROOT, - OPERATION_LOGS_PATH, - TEACHERS_PATH, - USE_SQLITE_SOURCE, -) -from ..db import database_meta -from ..data import account_summary +from ..repository import health_payload router = APIRouter() @@ -23,24 +11,4 @@ router = APIRouter() @router.get("/api/health") def health(_user: str = Depends(verify_records_auth)): - records = load_records() - accounts = load_student_profiles() - teachers = load_teachers() - return { - "ok": True, - "classnotes": file_meta(CLASSNOTES_PATH), - "accounts": file_meta(ACCOUNTS_PATH), - "teachers": file_meta(TEACHERS_PATH), - "course_summaries": file_meta(COURSE_SUMMARIES_ROOT), - "course_summary_state": file_meta(COURSE_SUMMARY_STATE_PATH), - "operation_logs": file_meta(OPERATION_LOGS_PATH), - "database": database_meta(), - "legacy_archive": { - "source_mode": "sqlite" if USE_SQLITE_SOURCE else "text", - "text_root": str(LEGACY_TEXT_ROOT), - }, - "records_count": len(records), - "accounts_count": len(accounts), - "teachers_count": len(teachers), - "account_summary": account_summary(accounts), - } + return health_payload() diff --git a/app/app/routers/ingest.py b/app/app/routers/ingest.py index 1f0666a..585ad39 100644 --- a/app/app/routers/ingest.py +++ b/app/app/routers/ingest.py @@ -4,16 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException from ..auth import verify_ingest_token from ..ai_register import preview_register -from ..config import ( - ACCOUNTS_PATH, - ADMIN_TASKS_PATH, - CLASSNOTES_PATH, - COURSE_SUMMARIES_ROOT, - COURSE_SUMMARY_STATE_PATH, - OPERATION_LOGS_PATH, - write_lock, -) -from ..data import ingest_course_summaries +from ..repository import ingest_course_summaries from ..schemas import CourseSummaryIngestPayload @@ -97,19 +88,12 @@ def _preprocess_summaries_locally(summaries: list[dict]) -> list[dict]: def ingest_course_summary_batch(payload: CourseSummaryIngestPayload, _user: str = Depends(verify_ingest_token)): try: summaries = _preprocess_summaries_locally([item.dict() for item in payload.summaries]) - 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=summaries, - ) + result = ingest_course_summaries( + batch_id=payload.batch_id, + window=payload.window, + students=payload.students, + summaries=summaries, + ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc return {"ok": True, **result} diff --git a/app/app/routers/records.py b/app/app/routers/records.py index d264865..2b06a1a 100644 --- a/app/app/routers/records.py +++ b/app/app/routers/records.py @@ -1,30 +1,13 @@ from __future__ import annotations -from datetime import datetime - from fastapi import APIRouter, Depends, HTTPException, Query -from ..api_utils import load_records, load_teachers from ..auth import verify_records_auth -from ..config import ( - ADMIN_TASKS_PATH, - COURSE_SUMMARIES_ROOT, - COURSE_SUMMARY_STATE_PATH, - OPERATION_LOGS_PATH, - write_lock, -) -from ..data import ( - append_operation_log, - course_summary_semantic_key, - duration_minutes_from_time_range, - find_record_by_identity, +from ..repository import ( query_public_records, - read_course_summary_state, - save_course_summary_markdown, - sha1_text, submit_public_correction_tasks, submit_public_deletion_tasks, - write_course_summary_state, + supplement_course_summary, ) from ..schemas import CorrectionSubmitPayload, CourseSummarySupplementPayload, DeletionSubmitPayload @@ -42,28 +25,13 @@ def records( ): effective_limit = 0 if show_all else limit effective_offset = 0 if show_all else offset - return query_public_records( - load_records(), - load_teachers(), - q, - limit=effective_limit, - offset=effective_offset, - summaries_root=COURSE_SUMMARIES_ROOT, - ) + return query_public_records(q, limit=effective_limit, offset=effective_offset) @router.post("/api/corrections") def submit_corrections(payload: CorrectionSubmitPayload, _user: str = Depends(verify_records_auth)): try: - records = load_records() - teachers = load_teachers() - with write_lock: - result = submit_public_correction_tasks( - ADMIN_TASKS_PATH, - records, - teachers, - [item.dict() for item in payload.items], - ) + result = submit_public_correction_tasks([item.dict() for item in payload.items]) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc return {"ok": True, **result} @@ -72,99 +40,15 @@ def submit_corrections(payload: CorrectionSubmitPayload, _user: str = Depends(ve @router.post("/api/deletions") def submit_deletions(payload: DeletionSubmitPayload, _user: str = Depends(verify_records_auth)): try: - records = load_records() - with write_lock: - result = submit_public_deletion_tasks( - ADMIN_TASKS_PATH, - records, - [item.dict() for item in payload.items], - ) + result = submit_public_deletion_tasks([item.dict() for item in payload.items]) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc return {"ok": True, **result} @router.post("/api/course-summaries/supplement") -def supplement_course_summary(payload: CourseSummarySupplementPayload, _user: str = Depends(verify_records_auth)): - body = payload.body.strip() - if not body: - raise HTTPException(status_code=400, detail="课程小结正文不能为空") +def supplement_course_summary_endpoint(payload: CourseSummarySupplementPayload, _user: str = Depends(verify_records_auth)): try: - records = load_records() - record = find_record_by_identity(records, payload.record_id) - date_iso = record.date.replace(".", "-") - source_id = f"supplement:{payload.record_id}:{sha1_text(body, 12)}" - summary = { - "source_id": source_id, - "student": record.student, - "date_iso": date_iso, - "time_range": record.time, - "duration_minutes": duration_minutes_from_time_range(record.time), - "teacher": record.teacher, - "subject": record.subject, - "group": "", - "sender": "课程记录页补充", - "message_time": "", - "body": body, - } - semantic_key = course_summary_semantic_key(summary) - with write_lock: - state = read_course_summary_state(COURSE_SUMMARY_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", [])) - if source_id in seen_source_ids or semantic_key in seen_semantic_keys: - log_id = append_operation_log( - OPERATION_LOGS_PATH, - "课程小结补充", - "重复", - record_id=payload.record_id, - student=record.student, - teacher=record.teacher, - subject=record.subject, - source_id=source_id, - ) - return {"ok": True, "log_id": log_id, "submitted": 0, "status": "duplicate"} - saved = save_course_summary_markdown(COURSE_SUMMARIES_ROOT, summary) - status_value = "完成" if saved.get("added") else "重复" - seen_source_ids.add(source_id) - seen_semantic_keys.add(semantic_key) - state["seen_source_ids"] = sorted(seen_source_ids) - state["seen_semantic_keys"] = sorted(seen_semantic_keys) - state.setdefault("batches", []).append( - { - "batch_id": f"supplement-{payload.record_id}-{sha1_text(body, 8)}", - "received_at": datetime.now().isoformat(timespec="seconds"), - "window": {"source": "records_page_supplement"}, - "students": [record.student], - "result": { - "received": 1, - "saved": 1 if saved.get("added") else 0, - "auto_registered": 0, - "review_pending": 0, - "duplicates": 0 if saved.get("added") else 1, - "rejected": 0, - }, - } - ) - state["batches"] = state["batches"][-200:] - write_course_summary_state(COURSE_SUMMARY_STATE_PATH, state) - log_id = append_operation_log( - OPERATION_LOGS_PATH, - "课程小结补充", - status_value, - record_id=payload.record_id, - student=record.student, - teacher=record.teacher, - subject=record.subject, - source_id=source_id, - saved_path=str(saved.get("path") or ""), - heading=str(saved.get("heading") or ""), - ) + return supplement_course_summary(payload.record_id, payload.body) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc - return { - "ok": True, - "log_id": log_id, - "submitted": 1 if status_value == "完成" else 0, - "status": "saved" if status_value == "完成" else "duplicate", - } diff --git a/app/app/routers/student_profiles.py b/app/app/routers/student_profiles.py index afe991b..4074c94 100644 --- a/app/app/routers/student_profiles.py +++ b/app/app/routers/student_profiles.py @@ -1,50 +1,21 @@ from __future__ import annotations -from datetime import date - from fastapi import APIRouter, Depends, HTTPException, Query, Request -from ..api_utils import ( - file_meta, - load_student_profiles, - load_teachers, - payload_to_student_profile, - payload_to_teacher, - read_register_payload, -) +from ..api_utils import payload_to_student_profile, payload_to_teacher, read_register_payload from ..auth import verify_admin_auth, verify_any_auth, verify_student_profiles_auth -from ..config import ( - ACCOUNTS_PATH, - ADMIN_TASKS_PATH, - CLASSNOTES_PATH, - COURSE_SUMMARIES_ROOT, - COURSE_SUMMARY_STATE_PATH, - LEGACY_TEXT_ROOT, - OPERATION_LOGS_PATH, - TEACHERS_PATH, - USE_SQLITE_SOURCE, - write_lock, -) -from ..db import database_meta -from ..data import ( - ACCOUNT_STATUSES, - TEACHER_STATUSES, - DuplicateRecordError, - account_summary as student_profile_summary, - account_to_dict as student_profile_to_dict, - append_operation_log, - create_account as create_student_profile, +from ..data import ACCOUNT_STATUSES, TEACHER_STATUSES +from ..repository import ( + create_student_profile, create_teacher, - duration_text_from_hours, - filter_accounts as filter_student_profiles, - iter_course_summary_markdown, - parse_class_record_line, - read_classnotes, + health_payload, register_class_record_lines, register_course_summary_texts, register_payment_lines, - teacher_to_dict, - update_account as update_student_profile, + student_detail_payload, + students_payload, + teachers_payload, + update_student_profile, update_teacher, ) from ..schemas import StudentProfilePayload, TeacherPayload @@ -53,36 +24,14 @@ from ..schemas import StudentProfilePayload, TeacherPayload router = APIRouter() -def compact_duration_text(hours: float) -> str: - text = duration_text_from_hours(hours) - return text[:-2] if text.endswith("小时0分") else text - - @router.post("/api/register/class-records") async def register_class_records(request: Request, _user: str = Depends(verify_admin_auth)): try: payload = await read_register_payload(request) - with write_lock: - result = register_class_record_lines( - CLASSNOTES_PATH, - ACCOUNTS_PATH, - lines=payload.lines, - line=payload.line, - ) - for line in result.get("lines", []): - record = parse_class_record_line(str(line)) - append_operation_log( - OPERATION_LOGS_PATH, - str(result.get("operation") or "登记上课记录"), - "完成", - student=record.student, - proposed_line=str(line), - backup_id=str(result.get("backup_id") or ""), - ) - except DuplicateRecordError as exc: - raise HTTPException(status_code=409, detail=str(exc)) from exc + result = register_class_record_lines(lines=payload.lines, line=payload.line) except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc + status_code = 409 if exc.__class__.__name__ == "DuplicateRecordError" else 400 + raise HTTPException(status_code=status_code, detail=str(exc)) from exc return {"ok": True, **result} @@ -90,18 +39,7 @@ async def register_class_records(request: Request, _user: str = Depends(verify_a async def register_payments(request: Request, _user: str = Depends(verify_admin_auth)): try: payload = await read_register_payload(request) - with write_lock: - result = register_payment_lines(ACCOUNTS_PATH, lines=payload.lines, line=payload.line) - for line in result.get("lines", []): - student = str(line).split("-", 1)[0] - append_operation_log( - OPERATION_LOGS_PATH, - str(result.get("operation") or "登记缴费记录"), - "完成", - student=student, - proposed_line=str(line), - backup_id=str(result.get("backup_id") or ""), - ) + result = register_payment_lines(lines=payload.lines, line=payload.line) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc return {"ok": True, **result} @@ -111,17 +49,7 @@ async def register_payments(request: Request, _user: str = Depends(verify_admin_ async def register_course_summaries(request: Request, _user: str = Depends(verify_admin_auth)): try: payload = await read_register_payload(request) - with write_lock: - result = register_course_summary_texts( - 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, - lines=payload.lines, - line=payload.line, - ) + result = register_course_summary_texts(lines=payload.lines, line=payload.line) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc return {"ok": True, **result} @@ -129,35 +57,7 @@ async def register_course_summaries(request: Request, _user: str = Depends(verif @router.get("/api/student-health") def student_health(_user: str = Depends(verify_student_profiles_auth)): - student_profiles = load_student_profiles() - teachers = load_teachers() - records = read_classnotes(CLASSNOTES_PATH) if CLASSNOTES_PATH.exists() else [] - current_month_prefix = date.today().strftime("%Y.%m.") - current_month_hours = round( - sum(record.duration_hours for record in records if record.date.startswith(current_month_prefix)), - 2, - ) - course_summaries_count = sum(1 for _item in iter_course_summary_markdown(COURSE_SUMMARIES_ROOT)) - return { - "ok": True, - "students": file_meta(ACCOUNTS_PATH), - "teachers": file_meta(TEACHERS_PATH), - "classnotes": file_meta(CLASSNOTES_PATH), - "course_summaries": file_meta(COURSE_SUMMARIES_ROOT), - "database": database_meta(), - "legacy_archive": { - "source_mode": "sqlite" if USE_SQLITE_SOURCE else "text", - "text_root": str(LEGACY_TEXT_ROOT), - }, - "students_count": len(student_profiles), - "teachers_count": len(teachers), - "active_teachers_count": sum(1 for teacher in teachers if teacher.status == "在岗"), - "records_count": len(records), - "course_summaries_count": course_summaries_count, - "current_month_hours": current_month_hours, - "current_month_duration": compact_duration_text(current_month_hours), - "student_summary": student_profile_summary(student_profiles), - } + return health_payload() @router.get("/api/students") @@ -166,21 +66,15 @@ def students( status_filter: str = Query("", alias="status", description="学生档案状态"), _user: str = Depends(verify_student_profiles_auth), ): - all_student_profiles = load_student_profiles() - rows = filter_student_profiles(all_student_profiles, keyword=q, status=status_filter) - return { - "summary": student_profile_summary(all_student_profiles), - "count": len(rows), - "students": [student_profile_to_dict(student_profile) for student_profile in rows], - } + return students_payload(q=q, status_filter=status_filter) @router.get("/api/students/{student}") def student_detail(student: str, _user: str = Depends(verify_any_auth)): - for student_profile in load_student_profiles(): - if student_profile.student == student or student_profile.student_id == student: - return student_profile_to_dict(student_profile) - raise HTTPException(status_code=404, detail=f"未找到学生档案: {student}") + try: + return student_detail_payload(student) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc @router.get("/api/admin/statuses") @@ -195,25 +89,13 @@ def admin_teacher_statuses(_user: str = Depends(verify_admin_auth)): @router.get("/api/admin/teachers") def admin_teachers(_user: str = Depends(verify_admin_auth)): - return { - "teachers": [teacher_to_dict(teacher) for teacher in load_teachers()], - } + return teachers_payload() @router.post("/api/admin/teachers") def admin_create_teacher(payload: TeacherPayload, _user: str = Depends(verify_admin_auth)): try: - with write_lock: - result = create_teacher(TEACHERS_PATH, payload_to_teacher(payload)) - log_operation = str(result.get("operation") or "新增老师档案") - append_operation_log( - OPERATION_LOGS_PATH, - log_operation, - "完成", - teacher_id=str(result.get("teacher", {}).get("teacher_id") or ""), - teacher=str(result.get("teacher", {}).get("name") or ""), - backup_id=str(result.get("backup_id") or ""), - ) + result = create_teacher(payload_to_teacher(payload)) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc return {"ok": True, **result} @@ -222,21 +104,7 @@ def admin_create_teacher(payload: TeacherPayload, _user: str = Depends(verify_ad @router.put("/api/admin/teachers/{teacher_id}") def admin_update_teacher(teacher_id: str, payload: TeacherPayload, _user: str = Depends(verify_admin_auth)): try: - with write_lock: - result = update_teacher( - TEACHERS_PATH, - teacher_id, - payload_to_teacher(payload, teacher_id=payload.teacher_id.strip() or teacher_id), - ) - log_operation = str(result.get("operation") or "修改老师档案") - append_operation_log( - OPERATION_LOGS_PATH, - log_operation, - "完成", - teacher_id=str(result.get("teacher", {}).get("teacher_id") or ""), - teacher=str(result.get("teacher", {}).get("name") or ""), - backup_id=str(result.get("backup_id") or ""), - ) + result = update_teacher(teacher_id, payload_to_teacher(payload, teacher_id=payload.teacher_id.strip() or teacher_id)) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc return {"ok": True, **result} @@ -245,17 +113,7 @@ def admin_update_teacher(teacher_id: str, payload: TeacherPayload, _user: str = @router.post("/api/admin/students") def admin_create_student(payload: StudentProfilePayload, _user: str = Depends(verify_admin_auth)): try: - with write_lock: - result = create_student_profile(ACCOUNTS_PATH, payload_to_student_profile(payload), CLASSNOTES_PATH) - log_operation = str(result.get("operation") or "新增学生档案") - append_operation_log( - OPERATION_LOGS_PATH, - log_operation, - "完成", - student_id=str(result.get("account", {}).get("student_id") or ""), - student=str(result.get("account", {}).get("student") or ""), - backup_id=str(result.get("backup_id") or ""), - ) + result = create_student_profile(payload_to_student_profile(payload)) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc return {"ok": True, **result} @@ -264,17 +122,7 @@ def admin_create_student(payload: StudentProfilePayload, _user: str = Depends(ve @router.put("/api/admin/students/{student_id}") def admin_update_student(student_id: str, payload: StudentProfilePayload, _user: str = Depends(verify_admin_auth)): try: - with write_lock: - result = update_student_profile(ACCOUNTS_PATH, student_id, payload_to_student_profile(payload), CLASSNOTES_PATH) - log_operation = str(result.get("operation") or "修改学生档案") - append_operation_log( - OPERATION_LOGS_PATH, - log_operation, - "完成", - student_id=str(result.get("account", {}).get("student_id") or ""), - student=str(result.get("account", {}).get("student") or ""), - backup_id=str(result.get("backup_id") or ""), - ) + result = update_student_profile(student_id, payload_to_student_profile(payload)) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc return {"ok": True, **result} diff --git a/app/app/static/admin.html b/app/app/static/admin.html index 3404e35..6f103a8 100644 --- a/app/app/static/admin.html +++ b/app/app/static/admin.html @@ -4,7 +4,7 @@ 管理后台 - +
@@ -493,6 +493,6 @@ - + diff --git a/app/app/static/admin.js b/app/app/static/admin.js index 369fa7b..343d341 100644 --- a/app/app/static/admin.js +++ b/app/app/static/admin.js @@ -918,8 +918,8 @@ function renderDuplicateCandidate(candidate, canReview, options = {}) { candidate.source_id ? `来源ID:${candidate.source_id}` : "", candidate.message_time ? `发送时间:${candidate.message_time}` : "", candidate.sender ? `发送者:${candidate.sender}` : "", - candidate.relative_path ? `文件:${candidate.relative_path}` : "", - candidate.source_path && !candidate.relative_path ? `文件:${candidate.source_path}` : "", + candidate.storage_key || candidate.relative_path ? `存储标识:${candidate.storage_key || candidate.relative_path}` : "", + candidate.id ? `小结ID:${candidate.id}` : "", ].filter(Boolean); return `
@@ -961,7 +961,7 @@ function renderSummaryDuplicateConflicts(item) { parts.push(`
命中的已有小结
`); parts.push(conflicts.map((candidate) => renderDuplicateCandidate(candidate, false, { showDelete: false })).join("")); } else { - parts.push("状态文件命中重复,但正式小结库未找到对应小结。"); + parts.push("状态表命中重复,但课程小结表未找到对应小结。"); } return parts.join(""); } @@ -1097,7 +1097,7 @@ function openSummaryReviewDrawer(taskId) { summaryReviewDrawerConflicts.innerHTML = duplicateContext ? renderSummaryDuplicateConflicts(item) : ""; summaryReviewDrawerSource.innerHTML = [ item.source_id ? `来源ID:${escapeHtml(item.source_id)}` : "", - item.saved_path ? `${duplicateContext || pendingSummarySave ? "目标文件" : "保存文件"}:${escapeHtml(item.saved_path)}` : "", + (item.storage_key || item.saved_path) ? `${duplicateContext || pendingSummarySave ? "目标标识" : "存储标识"}:${escapeHtml(item.storage_key || item.saved_path)}` : "", item.batch_id ? `批次:${escapeHtml(item.batch_id)}` : "", ].filter(Boolean).join("
") || "暂无来源信息"; summaryReviewDrawerSave.hidden = duplicateReview; @@ -1639,7 +1639,7 @@ async function linkExistingSummaryReview() { } async function resolveDuplicateSummary(taskId, summaryId) { - if (!confirm("确认删除这条重复课程小结?系统会先备份来源文件。")) return; + if (!confirm("确认删除这条重复课程小结?系统会先备份数据库快照。")) return; await fetchJson(`/api/admin/tasks/${encodeURIComponent(taskId)}/resolve-duplicate-summary`, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -1755,15 +1755,15 @@ function renderLogDetail(item) { logField("时间段", item.time_range), logField("上课记录", item.proposed_line), ]), - renderLogDetailGroup("文件与备份", [ - logField("保存文件", item.saved_path), - logField("原文件", item.old_path), - logField("新文件", item.new_path), + renderLogDetailGroup("存储与备份", [ + logField("存储标识", item.storage_key), + logField("小结ID", item.summary_id), + logField("新小结ID", item.new_summary_id), logField("备份ID", item.backup_id), logField("撤回记录", item.target_log_id), logField("撤回备份", item.target_backup_id), logField("撤回方式", item.rollback_mode), - logArrayField("恢复文件", item.restored_files), + logArrayField("恢复表", item.restored_tables), ]), renderLogDetailGroup("结果与原因", [ logArrayField("原因", item.reasons), diff --git a/app/app/static/app.js b/app/app/static/app.js index 5fa6b07..049af63 100644 --- a/app/app/static/app.js +++ b/app/app/static/app.js @@ -735,7 +735,7 @@ async function fetchJson(url, options = {}) { async function loadHealth() { try { const data = await fetchJson("/api/health"); - healthText.textContent = `数据更新时间 ${fmtTime(data.classnotes.mtime)}`; + healthText.textContent = `数据更新时间 ${fmtTime(data.updated_at || (data.database || {}).mtime || 0)}`; } catch (error) { healthText.textContent = `读取失败:${error.message}`; } diff --git a/app/app/static/index.html b/app/app/static/index.html index 2293a5f..f953165 100644 --- a/app/app/static/index.html +++ b/app/app/static/index.html @@ -4,7 +4,7 @@ 新时空教务管理系统 - +
@@ -132,7 +132,7 @@
审核说明 - 确认后会提交后台审核;审核通过才会删除 classnotes 中的记录,并恢复对应学生课时。 + 确认后会提交后台审核;审核通过才会删除数据库中的上课记录,并恢复对应学生课时。
- + diff --git a/app/docker-compose.yml b/app/docker-compose.yml index 156265b..66adb09 100644 --- a/app/docker-compose.yml +++ b/app/docker-compose.yml @@ -10,17 +10,8 @@ services: - .env environment: TZ: ${TZ:-Asia/Shanghai} - USE_SQLITE_SOURCE: ${USE_SQLITE_SOURCE:-1} SQLITE_DB_PATH: ${SQLITE_DB_PATH:-/data/xsk_education.db} - RUNTIME_DATA_ROOT: ${RUNTIME_DATA_ROOT:-/data/runtime_text_cache} LEGACY_TEXT_ROOT: ${LEGACY_TEXT_ROOT:-/data} - CLASSNOTES_PATH: ${CLASSNOTES_PATH:-/data/classnotes.txt} - ACCOUNTS_PATH: ${ACCOUNTS_PATH:-/data/学生课时账户.md} - TEACHERS_PATH: ${TEACHERS_PATH:-/data/教师档案.md} - ADMIN_TASKS_PATH: ${ADMIN_TASKS_PATH:-/data/admin_tasks.json} - COURSE_SUMMARIES_ROOT: ${COURSE_SUMMARIES_ROOT:-/data/course_summaries} - COURSE_SUMMARY_STATE_PATH: ${COURSE_SUMMARY_STATE_PATH:-/data/course_summary_state.json} - OPERATION_LOGS_PATH: ${OPERATION_LOGS_PATH:-/data/operation_logs.jsonl} INGEST_AUTH_TOKEN: ${INGEST_AUTH_TOKEN:-} ports: - "${APP_PORT:-18080}:8000" diff --git a/app/scripts/db_smoke_test.py b/app/scripts/db_smoke_test.py new file mode 100644 index 0000000..da28e34 --- /dev/null +++ b/app/scripts/db_smoke_test.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +from pathlib import Path +import sys +import tempfile + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from app import repository +from app.domain import Account, Payment, Teacher + + +def assert_equal(name: str, actual, expected) -> None: + if actual != expected: + raise AssertionError(f"{name}: got {actual!r}, expected {expected!r}") + + +def main() -> None: + with tempfile.TemporaryDirectory() as temp_dir: + repository.SQLITE_DB_PATH = Path(temp_dir) / "xsk_test.db" + repository.initialize_runtime_database() + + student = repository.create_student_profile( + Account("", "甲", [Payment("2026-07-01", 10)], 0, "正常", 2020, "") + ) + assert_equal("新增学生", student["account"]["student"], "甲") + + teacher = repository.create_teacher(Teacher("", "王老师", "", ["英语"], "在岗", "")) + assert_equal("新增教师", teacher["teacher"]["name"], "王老师") + + record = repository.register_class_record_lines( + line="2026.07.06-星期一-10:00-11:00-甲-1小时0分-王老师-英语" + ) + assert_equal("登记上课", record["registered"], 1) + + repository.register_payment_lines(line="甲-2026-07-02:2") + students = repository.students_payload() + assert_equal("学生数量", students["count"], 1) + assert_equal("余额重算", students["students"][0]["remaining"], 11) + + queried = repository.query_public_records("甲7月英语") + assert_equal("查询记录", queried["total_records"], 1) + record_id = queried["records"][0]["record_id"] + + correction = repository.submit_public_correction_tasks([{"record_id": record_id, "time": "11:00-12:00"}]) + approved = repository.approve_admin_task(int(correction["items"][0]["id"])) + assert_equal("批准纠错", approved["task"]["status"], "approved") + assert_equal("纠错生效", repository.query_public_records("甲7月英语")["records"][0]["time"], "11:00-12:00") + + supplement = repository.supplement_course_summary( + repository.query_public_records("甲7月英语")["records"][0]["record_id"], + "课堂表现很好", + ) + assert_equal("补充小结", supplement["status"], "saved") + summaries = repository.query_course_summaries(q="课堂表现") + assert_equal("小结查询", summaries["count"], 1) + summary_id = summaries["items"][0]["id"] + repository.update_course_summary_body(summary_id, "更新后的课堂表现") + assert_equal("小结正文更新", repository.query_course_summaries(q="更新后")["count"], 1) + + latest = repository.list_operation_logs()["items"][0] + rollback = repository.rollback_operation_log(str(latest["id"])) + assert_equal("撤回模式", rollback["rollback_mode"], "sqlite_snapshot") + assert_equal("撤回日志", repository.list_operation_logs()["items"][0]["operation"], "撤回操作") + + print("db smoke test passed") + + +if __name__ == "__main__": + main() diff --git a/app/scripts/deploy_to_vps.py b/app/scripts/deploy_to_vps.py index e22c201..f65ae5c 100755 --- a/app/scripts/deploy_to_vps.py +++ b/app/scripts/deploy_to_vps.py @@ -115,8 +115,7 @@ def write_env(args: argparse.Namespace) -> Path: f"PYTHON_IMAGE={args.python_image}", f"BASIC_AUTH_USERNAME={args.auth_user}", f"BASIC_AUTH_PASSWORD={args.auth_password}", - "CLASSNOTES_PATH=/data/classnotes.txt", - "ACCOUNTS_PATH=/data/学生课时账户.md", + "SQLITE_DB_PATH=/data/xsk_education.db", "", ] ) diff --git a/app/scripts/import_course_summaries.py b/app/scripts/import_course_summaries.py index 2041402..a6ebbf4 100644 --- a/app/scripts/import_course_summaries.py +++ b/app/scripts/import_course_summaries.py @@ -11,17 +11,13 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) +from app import repository # noqa: E402 from app.data import ( # noqa: E402 - append_operation_log, course_summary_to_class_record_line, course_summary_semantic_key, - create_course_summary_review_task, normalize_course_summary, - read_admin_tasks, - read_course_summary_state, safe_filename_part, sha1_text, - write_course_summary_state, ) @@ -41,12 +37,13 @@ EXCLUDE_PREFIXES = ( def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description="导入历史课程小结 Markdown 到 VPS 数据目录") + parser = argparse.ArgumentParser(description="导入历史课程小结 Markdown 到 SQLite") parser.add_argument("--source", required=True, type=Path, help="本机历史课程小结采集目录") - parser.add_argument("--target", default=Path("/data/course_summaries"), type=Path, help="VPS 课程小结正式目录") - parser.add_argument("--state", default=Path("/data/course_summary_state.json"), type=Path, help="课程小结状态文件") - parser.add_argument("--tasks", default=Path("/data/admin_tasks.json"), type=Path, help="管理任务文件") - parser.add_argument("--operation-logs", default=Path("/data/operation_logs.jsonl"), type=Path, help="操作日志文件") + parser.add_argument("--db-path", default=Path("/data/xsk_education.db"), type=Path, help="SQLite 数据库路径") + parser.add_argument("--target", type=Path, help=argparse.SUPPRESS) + parser.add_argument("--state", type=Path, help=argparse.SUPPRESS) + parser.add_argument("--tasks", type=Path, help=argparse.SUPPRESS) + parser.add_argument("--operation-logs", type=Path, help=argparse.SUPPRESS) parser.add_argument("--missing-table", type=Path, help="历史 classnotes缺失.txt;不传则尝试 source/classnotes缺失.txt") parser.add_argument("--dry-run", action="store_true", help="只统计,不写入") return parser.parse_args() @@ -292,30 +289,123 @@ def import_missing_tasks(source: Path, missing_table: Path, tasks_path: Path, dr return created +def collect_history_entries(source: Path) -> tuple[int, list[dict], int]: + scanned = 0 + skipped = 0 + entries: list[dict] = [] + for path in sorted(source.rglob("*.md")): + if not should_import_markdown(path, source): + continue + scanned += 1 + for entry in iter_markdown_entries(path): + try: + entries.append(normalize_course_summary(entry)) + except ValueError: + skipped += 1 + return scanned, entries, skipped + + +def collect_missing_review_tasks(source: Path, missing_table: Path) -> list[tuple[dict, str]]: + rows = parse_missing_table(missing_table) + tasks: list[tuple[dict, str]] = [] + for row in rows: + source_seed = "|".join(str(row.get(key, "")) for key in ("日期", "学生", "老师", "科目", "来源文件", "备注")) + source_id = f"history-missing:{sha1_text(source_seed, 20)}" + summary = { + "source_id": source_id, + "student": row.get("学生", ""), + "date_iso": str(row.get("日期", "")).replace(".", "-"), + "time_range": row.get("时间段", ""), + "duration": row.get("时长", ""), + "teacher": row.get("老师", ""), + "subject": row.get("科目", ""), + "group": row.get("群聊", ""), + "title": "历史 classnotes 缺失", + "body": f"历史 classnotes 缺失项:{source_seed}", + "recognition_source": "history_missing_table", + "confidence": "review", + "teacher_trusted": False, + "remark": row.get("备注", ""), + } + summary.update({key: value for key, value in source_summary_for_missing_row(source, row).items() if value not in ("", None)}) + proposed_line = "" + try: + proposed_line = course_summary_to_class_record_line(summary) + except ValueError: + pass + tasks.append((summary, proposed_line)) + return tasks + + def main() -> None: args = parse_args() source = args.source.resolve() - target = args.target.resolve() + repository.SQLITE_DB_PATH = args.db_path.resolve() missing_table = args.missing_table or (source / "classnotes缺失.txt") - scanned, copied = copy_markdown_files(source, target, args.dry_run) - imported, skipped = rebuild_state_from_markdown(target if target.exists() else source, args.state, args.dry_run) - review_tasks = import_missing_tasks(source, missing_table, args.tasks, args.dry_run) + scanned, entries, skipped = collect_history_entries(source) + missing_tasks = collect_missing_review_tasks(source, missing_table) + batch_id = f"history-import-{datetime.now().strftime('%Y%m%d%H%M%S')}" if not args.dry_run: - append_operation_log( - args.operation_logs, - "history_course_summary_import", - "completed", - source=str(source), - target=str(target), - scanned_files=scanned, - copied_files=copied, - indexed_summaries=imported, - skipped_summaries=skipped, - review_tasks=review_tasks, - ) + def work(conn, backup_id): + imported = 0 + duplicates = 0 + for summary in entries: + saved = repository._save_course_summary(conn, summary) + repository._add_summary_seen(conn, summary["source_id"], course_summary_semantic_key(summary)) + imported += 1 if saved.get("added") else 0 + duplicates += 0 if saved.get("added") else 1 + created_tasks = 0 + for summary, proposed_line in missing_tasks: + repository._create_course_summary_review_task( + conn, + summary, + proposed_line, + ["历史 classnotes缺失导入,默认只进入审核,不自动扣课时"], + saved_path=str(summary.get("remark") or ""), + ) + created_tasks += 1 + repository._add_ingest_batch( + conn, + { + "batch_id": batch_id, + "received_at": datetime.now().isoformat(timespec="seconds"), + "window": {"mode": "历史导入", "source": str(source)}, + "students": [], + "result": { + "received": len(entries), + "saved": imported, + "auto_registered": 0, + "review_pending": created_tasks, + "duplicates": duplicates, + "rejected": skipped, + }, + }, + ) + repository._append_operation_log( + conn, + "历史课程小结导入", + "完成", + source=str(source), + scanned_files=scanned, + indexed_summaries=imported, + skipped_summaries=skipped, + review_tasks=created_tasks, + backup_id=backup_id, + ) + return {"imported": imported, "duplicates": duplicates, "review_tasks": created_tasks} + + result = repository._write_transaction("history-course-summary-import", [str(source), batch_id], work) + imported = int(result.get("imported") or 0) + duplicates = int(result.get("duplicates") or 0) + review_tasks = int(result.get("review_tasks") or 0) + else: + imported = len(entries) + duplicates = 0 + review_tasks = len(missing_tasks) print( - f"历史小结导入完成:扫描 Markdown {scanned} 个,复制 {copied} 个," - f"索引小结 {imported} 条,跳过 {skipped} 条,生成审核任务 {review_tasks} 条。" + f"历史小结导入完成:扫描 Markdown {scanned} 个," + f"写入/待写入小结 {imported} 条,重复 {duplicates} 条," + f"跳过 {skipped} 条,生成审核任务 {review_tasks} 条。" )