改造为 SQLite 原生读写

This commit is contained in:
Codex
2026-07-06 16:51:14 +08:00
parent 9884e91612
commit 73d7d766eb
23 changed files with 2974 additions and 893 deletions
-10
View File
@@ -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
+4 -9
View File
@@ -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` 只生成审核任务,不自动扣课时。
+1
View File
@@ -12,6 +12,7 @@ check:
smoke:
node scripts/smoke_test.js
python3 scripts/db_smoke_test.py
data-hash:
sha256sum $(DATA_FILES)
+7 -15
View File
@@ -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
```
+2 -3
View File
@@ -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 []
+16 -18
View File
@@ -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:
+1 -54
View File
@@ -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()
+25 -1
View File
@@ -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),),
+3 -8
View File
@@ -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)
+2644 -145
View File
File diff suppressed because it is too large Load Diff
+23 -218
View File
@@ -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}
+2 -34
View File
@@ -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()
+7 -23
View File
@@ -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}
+7 -123
View File
@@ -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",
}
+25 -177
View File
@@ -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}
+2 -2
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>管理后台</title>
<link rel="stylesheet" href="/static/styles.css?v=20260620-dashboard" />
<link rel="stylesheet" href="/static/styles.css?v=20260706-sqlite-native" />
</head>
<body>
<header class="topbar">
@@ -493,6 +493,6 @@
</section>
</main>
<script src="/static/admin.js?v=20260620-dashboard"></script>
<script src="/static/admin.js?v=20260706-sqlite-native"></script>
</body>
</html>
+10 -10
View File
@@ -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 `<div class="record-summary-item duplicate-summary-candidate">
<div class="duplicate-summary-title-row">
@@ -961,7 +961,7 @@ function renderSummaryDuplicateConflicts(item) {
parts.push(`<div class="summary-conflict-heading">命中的已有小结</div>`);
parts.push(conflicts.map((candidate) => renderDuplicateCandidate(candidate, false, { showDelete: false })).join(""));
} else {
parts.push("<span class=\"muted\">状态文件命中重复,但正式小结未找到对应小结。</span>");
parts.push("<span class=\"muted\">状态命中重复,但课程小结未找到对应小结。</span>");
}
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("<br>") || "暂无来源信息";
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),
+1 -1
View File
@@ -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}`;
}
+3 -3
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>新时空教务管理系统</title>
<link rel="stylesheet" href="/static/styles.css?v=20260704-summary-full" />
<link rel="stylesheet" href="/static/styles.css?v=20260706-sqlite-native" />
</head>
<body>
<header class="topbar">
@@ -132,7 +132,7 @@
<p id="deleteError" class="correction-error" hidden></p>
<div class="correction-preview">
<span>审核说明</span>
<code>确认后会提交后台审核;审核通过才会删除 classnotes 中的记录,并恢复对应学生课时。</code>
<code>确认后会提交后台审核;审核通过才会删除数据库中的上课记录,并恢复对应学生课时。</code>
</div>
<div class="modal-actions">
<button id="cancelDeleteBtn" class="secondary-button" type="button">取消</button>
@@ -167,6 +167,6 @@
</section>
</div>
<script src="/static/app.js?v=20260704-summary-full"></script>
<script src="/static/app.js?v=20260706-sqlite-native"></script>
</body>
</html>
-9
View File
@@ -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"
+72
View File
@@ -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()
+1 -2
View File
@@ -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",
"",
]
)
+118 -28
View File
@@ -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} 条。"
)