refactor: split app structure
This commit is contained in:
+1
-1
@@ -8,7 +8,7 @@ DATA_FILES := ../data/classnotes.txt ../data/学生课时账户.md $(wildcard ..
|
|||||||
check:
|
check:
|
||||||
node --check app/static/app.js
|
node --check app/static/app.js
|
||||||
node --check app/static/admin.js
|
node --check app/static/admin.js
|
||||||
python3 -m py_compile app/main.py app/data.py
|
python3 -m compileall -q app
|
||||||
|
|
||||||
smoke:
|
smoke:
|
||||||
node scripts/smoke_test.js
|
node scripts/smoke_test.js
|
||||||
|
|||||||
@@ -15,6 +15,18 @@
|
|||||||
- `scripts/install_launch_agent.py`:安装 Mac 开机常驻同步任务。
|
- `scripts/install_launch_agent.py`:安装 Mac 开机常驻同步任务。
|
||||||
- `launchd/com.xsk.records.sync.plist.template`:LaunchAgent 模板。
|
- `launchd/com.xsk.records.sync.plist.template`:LaunchAgent 模板。
|
||||||
|
|
||||||
|
## 后端结构
|
||||||
|
|
||||||
|
- `app/main.py`:FastAPI 应用入口,只负责注册路由和全局异常处理。
|
||||||
|
- `app/routers/`:按业务入口拆分 API 和页面路由,包括登录页面、课程记录、课时账户、管理后台、课程小结推送和健康检查。
|
||||||
|
- `app/config.py`:路径、环境变量、Cookie 名称和进程内写锁。
|
||||||
|
- `app/auth.py`:网页登录、Basic Auth、管理后台和课程小结推送鉴权。
|
||||||
|
- `app/schemas.py`:请求体 Pydantic 模型。
|
||||||
|
- `app/api_utils.py`:API 层通用读取、文件元信息和请求体转换。
|
||||||
|
- `app/domain.py`:领域数据结构、学生/科目别名、状态和识别常量。
|
||||||
|
- `app/storage.py`:原子写入、权限继承和业务数据备份。
|
||||||
|
- `app/data.py`:业务数据解析、登记、审核、课程小结入库和查询逻辑。
|
||||||
|
|
||||||
## 首次部署
|
## 首次部署
|
||||||
|
|
||||||
推荐先配置 SSH key。若临时使用密码,可通过环境变量传入,不要写入仓库文件。
|
推荐先配置 SSH key。若临时使用密码,可通过环境变量传入,不要写入仓库文件。
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import HTTPException, Request
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from .config import ACCOUNTS_PATH, CLASSNOTES_PATH
|
||||||
|
from .data import Account, Payment, read_accounts, read_classnotes
|
||||||
|
from .schemas import AccountPayload, RegisterLinesPayload
|
||||||
|
|
||||||
|
|
||||||
|
async def read_register_payload(request: Request) -> RegisterLinesPayload:
|
||||||
|
body = await request.body()
|
||||||
|
if not body.strip():
|
||||||
|
return RegisterLinesPayload()
|
||||||
|
|
||||||
|
content_type = request.headers.get("content-type", "").lower()
|
||||||
|
if "application/json" in content_type:
|
||||||
|
try:
|
||||||
|
data = json.loads(body)
|
||||||
|
if isinstance(data, str):
|
||||||
|
return RegisterLinesPayload(line=data)
|
||||||
|
if isinstance(data, list):
|
||||||
|
return RegisterLinesPayload(lines=data)
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return RegisterLinesPayload(**data)
|
||||||
|
except (json.JSONDecodeError, ValidationError) as exc:
|
||||||
|
raise ValueError("登记 JSON 格式错误") from exc
|
||||||
|
raise ValueError("登记 JSON 必须是字符串、字符串数组,或包含 line/lines 的对象")
|
||||||
|
|
||||||
|
try:
|
||||||
|
text = body.decode("utf-8")
|
||||||
|
except UnicodeDecodeError as exc:
|
||||||
|
raise ValueError("登记内容必须使用 UTF-8 编码") from exc
|
||||||
|
return RegisterLinesPayload(lines=text.splitlines())
|
||||||
|
|
||||||
|
|
||||||
|
def load_records():
|
||||||
|
if not CLASSNOTES_PATH.exists():
|
||||||
|
raise HTTPException(status_code=503, detail=f"课程记录文件不存在: {CLASSNOTES_PATH}")
|
||||||
|
return read_classnotes(CLASSNOTES_PATH)
|
||||||
|
|
||||||
|
|
||||||
|
def load_accounts():
|
||||||
|
if not ACCOUNTS_PATH.exists():
|
||||||
|
raise HTTPException(status_code=503, detail=f"课时账户文件不存在: {ACCOUNTS_PATH}")
|
||||||
|
return read_accounts(ACCOUNTS_PATH)
|
||||||
|
|
||||||
|
|
||||||
|
def file_meta(path: Path) -> dict:
|
||||||
|
if not path.exists():
|
||||||
|
return {"exists": False, "path": str(path)}
|
||||||
|
stat = path.stat()
|
||||||
|
return {
|
||||||
|
"exists": True,
|
||||||
|
"path": str(path),
|
||||||
|
"size": stat.st_size,
|
||||||
|
"mtime": stat.st_mtime,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def payload_to_account(payload: AccountPayload, student_id: str | None = None) -> Account:
|
||||||
|
return Account(
|
||||||
|
student_id=student_id if student_id is not None else payload.student_id,
|
||||||
|
student=payload.student,
|
||||||
|
payments=[Payment(date=item.date, hours=item.hours) for item in payload.payments],
|
||||||
|
remaining=payload.remaining,
|
||||||
|
account_status=payload.account_status,
|
||||||
|
note=payload.note,
|
||||||
|
)
|
||||||
+139
@@ -0,0 +1,139 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
|
||||||
|
from fastapi import Depends, Header, HTTPException, Request, status
|
||||||
|
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
||||||
|
|
||||||
|
from .config import (
|
||||||
|
ACCOUNTS_AUTH_PASSWORD,
|
||||||
|
ACCOUNTS_SESSION_COOKIE,
|
||||||
|
ADMIN_AUTH_PASSWORD,
|
||||||
|
ADMIN_SESSION_COOKIE,
|
||||||
|
BASIC_AUTH_PASSWORD,
|
||||||
|
INGEST_AUTH_TOKEN,
|
||||||
|
RECORDS_SESSION_COOKIE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
security = HTTPBasic(auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
|
def configured_password(label: str, password: str) -> str:
|
||||||
|
if not password:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail=f"服务未配置{label}访问密码",
|
||||||
|
)
|
||||||
|
return password
|
||||||
|
|
||||||
|
|
||||||
|
def session_token(password: str, purpose: bytes) -> str:
|
||||||
|
return hmac.new(
|
||||||
|
password.encode("utf-8"),
|
||||||
|
purpose,
|
||||||
|
hashlib.sha256,
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def has_valid_session(request: Request, cookie_name: str, password: str, purpose: bytes) -> bool:
|
||||||
|
token = request.cookies.get(cookie_name, "")
|
||||||
|
return bool(password and token) and hmac.compare_digest(token, session_token(password, purpose))
|
||||||
|
|
||||||
|
|
||||||
|
def has_valid_basic_auth(credentials: HTTPBasicCredentials | None, password: str) -> bool:
|
||||||
|
if credentials is None:
|
||||||
|
return False
|
||||||
|
return bool(password) and hmac.compare_digest(credentials.password, password)
|
||||||
|
|
||||||
|
|
||||||
|
def is_records_authenticated(
|
||||||
|
request: Request,
|
||||||
|
credentials: HTTPBasicCredentials | None = None,
|
||||||
|
) -> bool:
|
||||||
|
return has_valid_session(
|
||||||
|
request,
|
||||||
|
RECORDS_SESSION_COOKIE,
|
||||||
|
BASIC_AUTH_PASSWORD,
|
||||||
|
b"xsk-records-web-session-v1",
|
||||||
|
) or has_valid_basic_auth(credentials, BASIC_AUTH_PASSWORD)
|
||||||
|
|
||||||
|
|
||||||
|
def is_admin_authenticated(
|
||||||
|
request: Request,
|
||||||
|
credentials: HTTPBasicCredentials | None = None,
|
||||||
|
) -> bool:
|
||||||
|
return has_valid_session(
|
||||||
|
request,
|
||||||
|
ADMIN_SESSION_COOKIE,
|
||||||
|
ADMIN_AUTH_PASSWORD,
|
||||||
|
b"xsk-admin-web-session-v1",
|
||||||
|
) or has_valid_session(
|
||||||
|
request,
|
||||||
|
ACCOUNTS_SESSION_COOKIE,
|
||||||
|
ADMIN_AUTH_PASSWORD,
|
||||||
|
b"xsk-accounts-web-session-v1",
|
||||||
|
) or has_valid_basic_auth(credentials, ADMIN_AUTH_PASSWORD)
|
||||||
|
|
||||||
|
|
||||||
|
def is_accounts_authenticated(
|
||||||
|
request: Request,
|
||||||
|
credentials: HTTPBasicCredentials | None = None,
|
||||||
|
) -> bool:
|
||||||
|
return is_admin_authenticated(request, credentials)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_records_auth(
|
||||||
|
request: Request,
|
||||||
|
credentials: HTTPBasicCredentials | None = Depends(security),
|
||||||
|
) -> str:
|
||||||
|
configured_password("课程记录", BASIC_AUTH_PASSWORD)
|
||||||
|
if not is_records_authenticated(request, credentials):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="请先登录",
|
||||||
|
)
|
||||||
|
return "records"
|
||||||
|
|
||||||
|
|
||||||
|
def verify_admin_auth(
|
||||||
|
request: Request,
|
||||||
|
credentials: HTTPBasicCredentials | None = Depends(security),
|
||||||
|
) -> str:
|
||||||
|
configured_password("管理后台", ADMIN_AUTH_PASSWORD)
|
||||||
|
if not is_admin_authenticated(request, credentials):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="请先登录管理后台",
|
||||||
|
)
|
||||||
|
return "admin"
|
||||||
|
|
||||||
|
|
||||||
|
def verify_accounts_auth(
|
||||||
|
request: Request,
|
||||||
|
credentials: HTTPBasicCredentials | None = Depends(security),
|
||||||
|
) -> str:
|
||||||
|
return verify_admin_auth(request, credentials)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_any_auth(
|
||||||
|
request: Request,
|
||||||
|
credentials: HTTPBasicCredentials | None = Depends(security),
|
||||||
|
) -> str:
|
||||||
|
if is_records_authenticated(request, credentials) or is_admin_authenticated(request, credentials):
|
||||||
|
return "authenticated"
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="请先登录",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_ingest_token(x_ingest_token: str = Header(default="")) -> str:
|
||||||
|
configured_password("课程小结推送", INGEST_AUTH_TOKEN)
|
||||||
|
if not hmac.compare_digest(x_ingest_token, INGEST_AUTH_TOKEN):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="课程小结推送 token 不正确",
|
||||||
|
)
|
||||||
|
return "ingest"
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import threading
|
||||||
|
|
||||||
|
|
||||||
|
APP_DIR = Path(__file__).resolve().parent
|
||||||
|
STATIC_DIR = APP_DIR / "static"
|
||||||
|
|
||||||
|
CLASSNOTES_PATH = Path(os.getenv("CLASSNOTES_PATH", "/data/classnotes.txt"))
|
||||||
|
ACCOUNTS_PATH = Path(os.getenv("ACCOUNTS_PATH", "/data/学生课时账户.md"))
|
||||||
|
ADMIN_TASKS_PATH = Path(os.getenv("ADMIN_TASKS_PATH", "/data/admin_tasks.json"))
|
||||||
|
COURSE_SUMMARIES_ROOT = Path(os.getenv("COURSE_SUMMARIES_ROOT", "/data/course_summaries"))
|
||||||
|
COURSE_SUMMARY_STATE_PATH = Path(os.getenv("COURSE_SUMMARY_STATE_PATH", "/data/course_summary_state.json"))
|
||||||
|
OPERATION_LOGS_PATH = Path(os.getenv("OPERATION_LOGS_PATH", "/data/operation_logs.jsonl"))
|
||||||
|
|
||||||
|
BASIC_AUTH_PASSWORD = os.getenv("BASIC_AUTH_PASSWORD", "")
|
||||||
|
ACCOUNTS_AUTH_PASSWORD = os.getenv("ACCOUNTS_AUTH_PASSWORD") or os.getenv("ACCOUNT_AUTH_PASSWORD", "")
|
||||||
|
ADMIN_AUTH_PASSWORD = os.getenv("ADMIN_AUTH_PASSWORD") or ACCOUNTS_AUTH_PASSWORD
|
||||||
|
INGEST_AUTH_TOKEN = os.getenv("INGEST_AUTH_TOKEN", "")
|
||||||
|
|
||||||
|
RECORDS_SESSION_COOKIE = "xsk_records_session"
|
||||||
|
ACCOUNTS_SESSION_COOKIE = "xsk_accounts_session"
|
||||||
|
ADMIN_SESSION_COOKIE = "xsk_admin_session"
|
||||||
|
SESSION_MAX_AGE = 60 * 60 * 24 * 30
|
||||||
|
|
||||||
|
write_lock = threading.Lock()
|
||||||
+20
-193
@@ -1,36 +1,34 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from dataclasses import dataclass, replace
|
from dataclasses import replace
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import shutil
|
|
||||||
import tempfile
|
|
||||||
from typing import Iterable
|
from typing import Iterable
|
||||||
|
|
||||||
|
from .domain import (
|
||||||
|
ACCOUNT_STATUSES,
|
||||||
|
AUTO_RECOGNITION_SOURCES,
|
||||||
|
Account,
|
||||||
|
ClassRecord,
|
||||||
|
DuplicateRecordError,
|
||||||
|
FALLBACK_ALIASES,
|
||||||
|
HIGH_CONFIDENCE_VALUES,
|
||||||
|
Payment,
|
||||||
|
QuerySpec,
|
||||||
|
ROLE_WORDS,
|
||||||
|
SUBJECTS,
|
||||||
|
SUBJECT_ALIASES,
|
||||||
|
UNKNOWN_SUBJECTS,
|
||||||
|
UNKNOWN_TEACHERS,
|
||||||
|
WEEKDAYS,
|
||||||
|
)
|
||||||
|
from .storage import atomic_write_text, create_data_backup, prune_data_backups
|
||||||
|
|
||||||
|
|
||||||
SUBJECTS = ["数学", "语文", "英语", "物理", "化学", "生物", "历史", "地理", "政治", "道法"]
|
|
||||||
SUBJECT_ALIASES = {
|
|
||||||
"数": "数学",
|
|
||||||
"语": "语文",
|
|
||||||
"英": "英语",
|
|
||||||
"物": "物理",
|
|
||||||
"化": "化学",
|
|
||||||
"生": "生物",
|
|
||||||
"史": "历史",
|
|
||||||
"地": "地理",
|
|
||||||
"政": "政治",
|
|
||||||
}
|
|
||||||
FALLBACK_ALIASES = {
|
|
||||||
"施亿涵": "施忆涵",
|
|
||||||
"施凯其": "施凯萁",
|
|
||||||
"宇浩": "黄宇澔",
|
|
||||||
"肖恒罄": "肖恒馨",
|
|
||||||
}
|
|
||||||
CLASSNOTE_RE = re.compile(
|
CLASSNOTE_RE = re.compile(
|
||||||
r"^(?P<date>\d{4}\.\d{2}\.\d{2})-"
|
r"^(?P<date>\d{4}\.\d{2}\.\d{2})-"
|
||||||
r"(?P<weekday>星期[一二三四五六日])-"
|
r"(?P<weekday>星期[一二三四五六日])-"
|
||||||
@@ -44,18 +42,6 @@ PAYMENT_LINE_RE = re.compile(r"^(?P<student>.+?)-(?P<date>\d{4}-\d{2}-\d{2}):(?P
|
|||||||
TIME_RANGE_RE = re.compile(r"^(?P<sh>\d{1,2}):(?P<sm>\d{2})-(?P<eh>\d{1,2}):(?P<em>\d{2})$")
|
TIME_RANGE_RE = re.compile(r"^(?P<sh>\d{1,2}):(?P<sm>\d{2})-(?P<eh>\d{1,2}):(?P<em>\d{2})$")
|
||||||
COURSE_SUMMARY_HEADING_RE = re.compile(r"^###\s+(?P<title>.+)$", re.M)
|
COURSE_SUMMARY_HEADING_RE = re.compile(r"^###\s+(?P<title>.+)$", re.M)
|
||||||
COURSE_SUMMARY_DATE_RE = re.compile(r"(?P<date>\d{4}[.-]\d{1,2}[.-]\d{1,2})")
|
COURSE_SUMMARY_DATE_RE = re.compile(r"(?P<date>\d{4}[.-]\d{1,2}[.-]\d{1,2})")
|
||||||
BACKUP_DIR_RE = re.compile(r"^\d{8}-\d{6}-\d{6}-")
|
|
||||||
BACKUP_KEEP_COUNT = 50
|
|
||||||
ACCOUNT_STATUSES = {"正常", "预警", "欠费", "结课", "退费"}
|
|
||||||
WEEKDAYS = ["星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"]
|
|
||||||
UNKNOWN_TEACHERS = {"", "待核对老师", "未知老师"}
|
|
||||||
UNKNOWN_SUBJECTS = {"", "待核对科目", "未知科目"}
|
|
||||||
HIGH_CONFIDENCE_VALUES = {"high", "高", "高置信", "true", "1", "yes"}
|
|
||||||
AUTO_RECOGNITION_SOURCES = {"keyword", "rule", "model", "model_high_confidence", "大模型高置信识别", "关键词"}
|
|
||||||
ROLE_WORDS = {
|
|
||||||
"student": ("学生", "学员", "孩子", "同学"),
|
|
||||||
"teacher": ("老师", "教师"),
|
|
||||||
}
|
|
||||||
DATE_RANGE_SEPARATOR = r"(?:到|至|-|-|~|—|–)"
|
DATE_RANGE_SEPARATOR = r"(?:到|至|-|-|~|—|–)"
|
||||||
CHINESE_DATE_RANGE_RE = re.compile(
|
CHINESE_DATE_RANGE_RE = re.compile(
|
||||||
rf"(?:(?P<sy>\d{{4}})\s*年\s*)?"
|
rf"(?:(?P<sy>\d{{4}})\s*年\s*)?"
|
||||||
@@ -77,48 +63,6 @@ NUMERIC_DATE_RANGE_RE = re.compile(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class Payment:
|
|
||||||
date: str
|
|
||||||
hours: float
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class Account:
|
|
||||||
student_id: str
|
|
||||||
student: str
|
|
||||||
payments: list[Payment]
|
|
||||||
remaining: float
|
|
||||||
account_status: str
|
|
||||||
note: str
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ClassRecord:
|
|
||||||
date: str
|
|
||||||
weekday: str
|
|
||||||
time: str
|
|
||||||
student: str
|
|
||||||
duration: str
|
|
||||||
duration_hours: float
|
|
||||||
teacher: str
|
|
||||||
subject: str
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class QuerySpec:
|
|
||||||
raw_query: str
|
|
||||||
start_date: date | None
|
|
||||||
end_date: date | None
|
|
||||||
students: list[str]
|
|
||||||
teachers: list[str]
|
|
||||||
subjects: list[str]
|
|
||||||
|
|
||||||
|
|
||||||
class DuplicateRecordError(ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def canonical_name(name: str) -> str:
|
def canonical_name(name: str) -> str:
|
||||||
text = name.strip()
|
text = name.strip()
|
||||||
return FALLBACK_ALIASES.get(text, text)
|
return FALLBACK_ALIASES.get(text, text)
|
||||||
@@ -408,123 +352,6 @@ def next_student_id(accounts: list[Account]) -> str:
|
|||||||
return f"XS{(max(values) if values else 0) + 1:03d}"
|
return f"XS{(max(values) if values else 0) + 1:03d}"
|
||||||
|
|
||||||
|
|
||||||
def atomic_write_text(path: Path, text: str) -> None:
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
original_stat = path.stat() if path.exists() else None
|
|
||||||
temp_path: Path | None = None
|
|
||||||
try:
|
|
||||||
with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, delete=False) as handle:
|
|
||||||
temp_path = Path(handle.name)
|
|
||||||
handle.write(text)
|
|
||||||
handle.flush()
|
|
||||||
os.fsync(handle.fileno())
|
|
||||||
if original_stat is not None:
|
|
||||||
os.chmod(temp_path, original_stat.st_mode & 0o7777)
|
|
||||||
try:
|
|
||||||
os.chown(temp_path, original_stat.st_uid, original_stat.st_gid)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
os.replace(temp_path, path)
|
|
||||||
temp_path = None
|
|
||||||
try:
|
|
||||||
dir_fd = os.open(path.parent, os.O_DIRECTORY)
|
|
||||||
try:
|
|
||||||
os.fsync(dir_fd)
|
|
||||||
finally:
|
|
||||||
os.close(dir_fd)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
finally:
|
|
||||||
if temp_path is not None and temp_path.exists():
|
|
||||||
temp_path.unlink()
|
|
||||||
|
|
||||||
|
|
||||||
def apply_source_permissions(target: Path, source: Path) -> None:
|
|
||||||
if not source.exists():
|
|
||||||
return
|
|
||||||
source_stat = source.stat()
|
|
||||||
os.chmod(target, source_stat.st_mode & 0o7777)
|
|
||||||
try:
|
|
||||||
os.chown(target, source_stat.st_uid, source_stat.st_gid)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def data_backup_root(paths: Iterable[Path]) -> Path:
|
|
||||||
path_list = list(paths)
|
|
||||||
if not path_list:
|
|
||||||
raise ValueError("备份文件不能为空")
|
|
||||||
return path_list[0].parent / "backups"
|
|
||||||
|
|
||||||
|
|
||||||
def create_backup_directory(backup_root: Path, operation: str) -> Path:
|
|
||||||
backup_root.mkdir(parents=True, exist_ok=True)
|
|
||||||
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S-%f")
|
|
||||||
base_name = f"{timestamp}-{operation}"
|
|
||||||
for index in range(1000):
|
|
||||||
suffix = "" if index == 0 else f"-{index}"
|
|
||||||
backup_dir = backup_root / f"{base_name}{suffix}"
|
|
||||||
try:
|
|
||||||
backup_dir.mkdir()
|
|
||||||
return backup_dir
|
|
||||||
except FileExistsError:
|
|
||||||
continue
|
|
||||||
raise RuntimeError("无法创建唯一备份目录")
|
|
||||||
|
|
||||||
|
|
||||||
def create_data_backup(
|
|
||||||
operation: str,
|
|
||||||
file_contents: dict[Path, str],
|
|
||||||
submitted_lines: list[str],
|
|
||||||
) -> Path:
|
|
||||||
backup_root = data_backup_root(file_contents.keys())
|
|
||||||
backup_dir = create_backup_directory(backup_root, operation)
|
|
||||||
try:
|
|
||||||
submitted_text = "\n".join(submitted_lines)
|
|
||||||
metadata = {
|
|
||||||
"backup_id": backup_dir.name,
|
|
||||||
"created_at": datetime.now().isoformat(timespec="microseconds"),
|
|
||||||
"operation": operation,
|
|
||||||
"submitted_lines_count": len(submitted_lines),
|
|
||||||
"submitted_lines_sha256": hashlib.sha256(submitted_text.encode("utf-8")).hexdigest(),
|
|
||||||
"files": [],
|
|
||||||
}
|
|
||||||
for source_path, content in file_contents.items():
|
|
||||||
target_path = backup_dir / source_path.name
|
|
||||||
atomic_write_text(target_path, content)
|
|
||||||
apply_source_permissions(target_path, source_path)
|
|
||||||
source_stat = source_path.stat()
|
|
||||||
metadata["files"].append(
|
|
||||||
{
|
|
||||||
"name": source_path.name,
|
|
||||||
"source_path": str(source_path),
|
|
||||||
"size": len(content.encode("utf-8")),
|
|
||||||
"mtime": source_stat.st_mtime,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
metadata_path = backup_dir / "metadata.json"
|
|
||||||
atomic_write_text(
|
|
||||||
metadata_path,
|
|
||||||
json.dumps(metadata, ensure_ascii=False, indent=2) + "\n",
|
|
||||||
)
|
|
||||||
return backup_dir
|
|
||||||
except Exception:
|
|
||||||
shutil.rmtree(backup_dir, ignore_errors=True)
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
def prune_data_backups(backup_root: Path, keep: int = BACKUP_KEEP_COUNT) -> None:
|
|
||||||
if keep < 1 or not backup_root.exists():
|
|
||||||
return
|
|
||||||
backup_dirs = sorted(
|
|
||||||
path
|
|
||||||
for path in backup_root.iterdir()
|
|
||||||
if path.is_dir() and BACKUP_DIR_RE.match(path.name)
|
|
||||||
)
|
|
||||||
for backup_dir in backup_dirs[:-keep]:
|
|
||||||
shutil.rmtree(backup_dir)
|
|
||||||
|
|
||||||
|
|
||||||
def write_accounts(path: Path, accounts: list[Account]) -> None:
|
def write_accounts(path: Path, accounts: list[Account]) -> None:
|
||||||
original_text = path.read_text(encoding="utf-8")
|
original_text = path.read_text(encoding="utf-8")
|
||||||
accounts_by_id = {account.student_id: account for account in accounts}
|
accounts_by_id = {account.student_id: account for account in accounts}
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
|
||||||
|
SUBJECTS = ["数学", "语文", "英语", "物理", "化学", "生物", "历史", "地理", "政治", "道法"]
|
||||||
|
SUBJECT_ALIASES = {
|
||||||
|
"数": "数学",
|
||||||
|
"语": "语文",
|
||||||
|
"英": "英语",
|
||||||
|
"物": "物理",
|
||||||
|
"化": "化学",
|
||||||
|
"生": "生物",
|
||||||
|
"史": "历史",
|
||||||
|
"地": "地理",
|
||||||
|
"政": "政治",
|
||||||
|
}
|
||||||
|
FALLBACK_ALIASES = {
|
||||||
|
"施亿涵": "施忆涵",
|
||||||
|
"施凯其": "施凯萁",
|
||||||
|
"宇浩": "黄宇澔",
|
||||||
|
"肖恒罄": "肖恒馨",
|
||||||
|
}
|
||||||
|
|
||||||
|
ACCOUNT_STATUSES = {"正常", "预警", "欠费", "结课", "退费"}
|
||||||
|
WEEKDAYS = ["星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"]
|
||||||
|
UNKNOWN_TEACHERS = {"", "待核对老师", "未知老师"}
|
||||||
|
UNKNOWN_SUBJECTS = {"", "待核对科目", "未知科目"}
|
||||||
|
HIGH_CONFIDENCE_VALUES = {"high", "高", "高置信", "true", "1", "yes"}
|
||||||
|
AUTO_RECOGNITION_SOURCES = {"keyword", "rule", "model", "model_high_confidence", "大模型高置信识别", "关键词"}
|
||||||
|
ROLE_WORDS = {
|
||||||
|
"student": ("学生", "学员", "孩子", "同学"),
|
||||||
|
"teacher": ("老师", "教师"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Payment:
|
||||||
|
date: str
|
||||||
|
hours: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Account:
|
||||||
|
student_id: str
|
||||||
|
student: str
|
||||||
|
payments: list[Payment]
|
||||||
|
remaining: float
|
||||||
|
account_status: str
|
||||||
|
note: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ClassRecord:
|
||||||
|
date: str
|
||||||
|
weekday: str
|
||||||
|
time: str
|
||||||
|
student: str
|
||||||
|
duration: str
|
||||||
|
duration_hours: float
|
||||||
|
teacher: str
|
||||||
|
subject: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class QuerySpec:
|
||||||
|
raw_query: str
|
||||||
|
start_date: date | None
|
||||||
|
end_date: date | None
|
||||||
|
students: list[str]
|
||||||
|
teachers: list[str]
|
||||||
|
subjects: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class DuplicateRecordError(ValueError):
|
||||||
|
pass
|
||||||
+9
-896
@@ -1,498 +1,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
from fastapi import FastAPI, Request
|
||||||
import html
|
from fastapi.responses import JSONResponse
|
||||||
import hmac
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
import threading
|
|
||||||
from urllib.parse import parse_qs, quote
|
|
||||||
|
|
||||||
from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request, status
|
from .routers import accounts, admin, health, ingest, pages, records
|
||||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse, Response
|
|
||||||
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
|
||||||
from pydantic import BaseModel, Field, ValidationError
|
|
||||||
|
|
||||||
from .data import (
|
|
||||||
ACCOUNT_STATUSES,
|
|
||||||
Account,
|
|
||||||
DuplicateRecordError,
|
|
||||||
Payment,
|
|
||||||
append_operation_log,
|
|
||||||
approve_admin_task,
|
|
||||||
account_summary,
|
|
||||||
account_to_dict,
|
|
||||||
create_account,
|
|
||||||
filter_accounts,
|
|
||||||
ingest_course_summaries,
|
|
||||||
list_operation_logs,
|
|
||||||
list_admin_tasks,
|
|
||||||
query_course_summaries,
|
|
||||||
query_records,
|
|
||||||
read_accounts,
|
|
||||||
read_classnotes,
|
|
||||||
register_class_record_lines,
|
|
||||||
register_payment_lines,
|
|
||||||
reject_admin_task,
|
|
||||||
submit_correction_tasks,
|
|
||||||
update_account,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
APP_DIR = Path(__file__).resolve().parent
|
|
||||||
STATIC_DIR = APP_DIR / "static"
|
|
||||||
CLASSNOTES_PATH = Path(os.getenv("CLASSNOTES_PATH", "/data/classnotes.txt"))
|
|
||||||
ACCOUNTS_PATH = Path(os.getenv("ACCOUNTS_PATH", "/data/学生课时账户.md"))
|
|
||||||
ADMIN_TASKS_PATH = Path(os.getenv("ADMIN_TASKS_PATH", "/data/admin_tasks.json"))
|
|
||||||
COURSE_SUMMARIES_ROOT = Path(os.getenv("COURSE_SUMMARIES_ROOT", "/data/course_summaries"))
|
|
||||||
COURSE_SUMMARY_STATE_PATH = Path(os.getenv("COURSE_SUMMARY_STATE_PATH", "/data/course_summary_state.json"))
|
|
||||||
OPERATION_LOGS_PATH = Path(os.getenv("OPERATION_LOGS_PATH", "/data/operation_logs.jsonl"))
|
|
||||||
BASIC_AUTH_PASSWORD = os.getenv("BASIC_AUTH_PASSWORD", "")
|
|
||||||
ACCOUNTS_AUTH_PASSWORD = os.getenv("ACCOUNTS_AUTH_PASSWORD") or os.getenv("ACCOUNT_AUTH_PASSWORD", "")
|
|
||||||
ADMIN_AUTH_PASSWORD = os.getenv("ADMIN_AUTH_PASSWORD") or ACCOUNTS_AUTH_PASSWORD
|
|
||||||
INGEST_AUTH_TOKEN = os.getenv("INGEST_AUTH_TOKEN", "")
|
|
||||||
RECORDS_SESSION_COOKIE = "xsk_records_session"
|
|
||||||
ACCOUNTS_SESSION_COOKIE = "xsk_accounts_session"
|
|
||||||
ADMIN_SESSION_COOKIE = "xsk_admin_session"
|
|
||||||
SESSION_MAX_AGE = 60 * 60 * 24 * 30
|
|
||||||
|
|
||||||
app = FastAPI(title="新时空教务管家", version="1.0.0")
|
app = FastAPI(title="新时空教务管家", version="1.0.0")
|
||||||
security = HTTPBasic(auto_error=False)
|
|
||||||
write_lock = threading.Lock()
|
|
||||||
|
|
||||||
|
|
||||||
class RegisterLinesPayload(BaseModel):
|
|
||||||
line: str | None = Field(default=None, description="单条原始登记文本")
|
|
||||||
lines: list[str] | None = Field(default=None, description="多条原始登记文本")
|
|
||||||
|
|
||||||
|
|
||||||
class PaymentPayload(BaseModel):
|
|
||||||
date: str
|
|
||||||
hours: float
|
|
||||||
|
|
||||||
|
|
||||||
class AccountPayload(BaseModel):
|
|
||||||
student_id: str = ""
|
|
||||||
student: str
|
|
||||||
payments: list[PaymentPayload] = Field(default_factory=list)
|
|
||||||
remaining: float = 0
|
|
||||||
account_status: str = "正常"
|
|
||||||
note: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
class CorrectionItemPayload(BaseModel):
|
|
||||||
original_line: str
|
|
||||||
corrected_line: str
|
|
||||||
|
|
||||||
|
|
||||||
class CorrectionSubmitPayload(BaseModel):
|
|
||||||
items: list[CorrectionItemPayload]
|
|
||||||
|
|
||||||
|
|
||||||
class CourseSummaryPayload(BaseModel):
|
|
||||||
source_id: str = ""
|
|
||||||
student: str
|
|
||||||
date_iso: str = ""
|
|
||||||
date: str = ""
|
|
||||||
time_range: str = ""
|
|
||||||
raw_time: str = ""
|
|
||||||
duration: str = ""
|
|
||||||
duration_hours: float | None = None
|
|
||||||
duration_minutes: int | None = None
|
|
||||||
teacher: str = ""
|
|
||||||
subject: str = ""
|
|
||||||
group: str = ""
|
|
||||||
sender: str = ""
|
|
||||||
sender_name: str = ""
|
|
||||||
sender_id: str = ""
|
|
||||||
message_time: str = ""
|
|
||||||
message_date: str = ""
|
|
||||||
db: str = ""
|
|
||||||
local_id: str | int | None = ""
|
|
||||||
title: str = ""
|
|
||||||
body: str
|
|
||||||
recognition_source: str = ""
|
|
||||||
confidence: str = ""
|
|
||||||
teacher_trusted: bool = False
|
|
||||||
sender_teacher_trusted: bool = False
|
|
||||||
remark: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
class CourseSummaryIngestPayload(BaseModel):
|
|
||||||
batch_id: str
|
|
||||||
window: dict = Field(default_factory=dict)
|
|
||||||
students: list[str] = Field(default_factory=list)
|
|
||||||
summaries: list[CourseSummaryPayload] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
async def read_register_payload(request: Request) -> RegisterLinesPayload:
|
|
||||||
body = await request.body()
|
|
||||||
if not body.strip():
|
|
||||||
return RegisterLinesPayload()
|
|
||||||
|
|
||||||
content_type = request.headers.get("content-type", "").lower()
|
|
||||||
if "application/json" in content_type:
|
|
||||||
try:
|
|
||||||
data = json.loads(body)
|
|
||||||
if isinstance(data, str):
|
|
||||||
return RegisterLinesPayload(line=data)
|
|
||||||
if isinstance(data, list):
|
|
||||||
return RegisterLinesPayload(lines=data)
|
|
||||||
if isinstance(data, dict):
|
|
||||||
return RegisterLinesPayload(**data)
|
|
||||||
except (json.JSONDecodeError, ValidationError) as exc:
|
|
||||||
raise ValueError("登记 JSON 格式错误") from exc
|
|
||||||
raise ValueError("登记 JSON 必须是字符串、字符串数组,或包含 line/lines 的对象")
|
|
||||||
|
|
||||||
try:
|
|
||||||
text = body.decode("utf-8")
|
|
||||||
except UnicodeDecodeError as exc:
|
|
||||||
raise ValueError("登记内容必须使用 UTF-8 编码") from exc
|
|
||||||
return RegisterLinesPayload(lines=text.splitlines())
|
|
||||||
|
|
||||||
|
|
||||||
def configured_password(label: str, password: str) -> str:
|
|
||||||
if not password:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
||||||
detail=f"服务未配置{label}访问密码",
|
|
||||||
)
|
|
||||||
return password
|
|
||||||
|
|
||||||
|
|
||||||
def session_token(password: str, purpose: bytes) -> str:
|
|
||||||
return hmac.new(
|
|
||||||
password.encode("utf-8"),
|
|
||||||
purpose,
|
|
||||||
hashlib.sha256,
|
|
||||||
).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def has_valid_session(request: Request, cookie_name: str, password: str, purpose: bytes) -> bool:
|
|
||||||
token = request.cookies.get(cookie_name, "")
|
|
||||||
return bool(password and token) and hmac.compare_digest(token, session_token(password, purpose))
|
|
||||||
|
|
||||||
|
|
||||||
def has_valid_basic_auth(credentials: HTTPBasicCredentials | None, password: str) -> bool:
|
|
||||||
if credentials is None:
|
|
||||||
return False
|
|
||||||
return bool(password) and hmac.compare_digest(credentials.password, password)
|
|
||||||
|
|
||||||
|
|
||||||
def is_records_authenticated(
|
|
||||||
request: Request,
|
|
||||||
credentials: HTTPBasicCredentials | None = None,
|
|
||||||
) -> bool:
|
|
||||||
return has_valid_session(
|
|
||||||
request,
|
|
||||||
RECORDS_SESSION_COOKIE,
|
|
||||||
BASIC_AUTH_PASSWORD,
|
|
||||||
b"xsk-records-web-session-v1",
|
|
||||||
) or has_valid_basic_auth(credentials, BASIC_AUTH_PASSWORD)
|
|
||||||
|
|
||||||
|
|
||||||
def is_admin_authenticated(
|
|
||||||
request: Request,
|
|
||||||
credentials: HTTPBasicCredentials | None = None,
|
|
||||||
) -> bool:
|
|
||||||
return has_valid_session(
|
|
||||||
request,
|
|
||||||
ADMIN_SESSION_COOKIE,
|
|
||||||
ADMIN_AUTH_PASSWORD,
|
|
||||||
b"xsk-admin-web-session-v1",
|
|
||||||
) or has_valid_session(
|
|
||||||
request,
|
|
||||||
ACCOUNTS_SESSION_COOKIE,
|
|
||||||
ADMIN_AUTH_PASSWORD,
|
|
||||||
b"xsk-accounts-web-session-v1",
|
|
||||||
) or has_valid_basic_auth(credentials, ADMIN_AUTH_PASSWORD)
|
|
||||||
|
|
||||||
|
|
||||||
def is_accounts_authenticated(
|
|
||||||
request: Request,
|
|
||||||
credentials: HTTPBasicCredentials | None = None,
|
|
||||||
) -> bool:
|
|
||||||
return is_admin_authenticated(request, credentials)
|
|
||||||
|
|
||||||
|
|
||||||
def verify_records_auth(
|
|
||||||
request: Request,
|
|
||||||
credentials: HTTPBasicCredentials | None = Depends(security),
|
|
||||||
) -> str:
|
|
||||||
configured_password("课程记录", BASIC_AUTH_PASSWORD)
|
|
||||||
if not is_records_authenticated(request, credentials):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="请先登录",
|
|
||||||
)
|
|
||||||
return "records"
|
|
||||||
|
|
||||||
|
|
||||||
def verify_admin_auth(
|
|
||||||
request: Request,
|
|
||||||
credentials: HTTPBasicCredentials | None = Depends(security),
|
|
||||||
) -> str:
|
|
||||||
configured_password("管理后台", ADMIN_AUTH_PASSWORD)
|
|
||||||
if not is_admin_authenticated(request, credentials):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="请先登录管理后台",
|
|
||||||
)
|
|
||||||
return "admin"
|
|
||||||
|
|
||||||
|
|
||||||
def verify_accounts_auth(
|
|
||||||
request: Request,
|
|
||||||
credentials: HTTPBasicCredentials | None = Depends(security),
|
|
||||||
) -> str:
|
|
||||||
return verify_admin_auth(request, credentials)
|
|
||||||
|
|
||||||
|
|
||||||
def verify_any_auth(
|
|
||||||
request: Request,
|
|
||||||
credentials: HTTPBasicCredentials | None = Depends(security),
|
|
||||||
) -> str:
|
|
||||||
if is_records_authenticated(request, credentials) or is_admin_authenticated(request, credentials):
|
|
||||||
return "authenticated"
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="请先登录",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def verify_ingest_token(x_ingest_token: str = Header(default="")) -> str:
|
|
||||||
configured_password("课程小结推送", INGEST_AUTH_TOKEN)
|
|
||||||
if not hmac.compare_digest(x_ingest_token, INGEST_AUTH_TOKEN):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="课程小结推送 token 不正确",
|
|
||||||
)
|
|
||||||
return "ingest"
|
|
||||||
|
|
||||||
|
|
||||||
def safe_next_path(value: str | None) -> str:
|
|
||||||
if not value or not value.startswith("/") or value.startswith("//"):
|
|
||||||
return "/"
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def render_login_page(
|
|
||||||
title: str,
|
|
||||||
action: str,
|
|
||||||
next_path: str = "/",
|
|
||||||
has_error: bool = False,
|
|
||||||
) -> str:
|
|
||||||
escaped_title = html.escape(title)
|
|
||||||
escaped_action = html.escape(action, quote=True)
|
|
||||||
escaped_next = html.escape(next_path, quote=True)
|
|
||||||
error_html = (
|
|
||||||
'<p class="error">密码不正确,请重新输入。</p>'
|
|
||||||
if has_error
|
|
||||||
else '<p class="hint">请输入访问密码。</p>'
|
|
||||||
)
|
|
||||||
return f"""<!doctype html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<title>{escaped_title}</title>
|
|
||||||
<style>
|
|
||||||
:root {{
|
|
||||||
color-scheme: light;
|
|
||||||
--bg: #f6f7f9;
|
|
||||||
--panel: #ffffff;
|
|
||||||
--text: #18202a;
|
|
||||||
--muted: #687487;
|
|
||||||
--line: #d9dee7;
|
|
||||||
--accent: #0f766e;
|
|
||||||
--accent-strong: #0b5d57;
|
|
||||||
--danger: #b42318;
|
|
||||||
font-family: Arial, "Songti SC", SimSun, sans-serif;
|
|
||||||
}}
|
|
||||||
* {{ box-sizing: border-box; }}
|
|
||||||
body {{
|
|
||||||
margin: 0;
|
|
||||||
min-height: 100vh;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
padding: 20px;
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--text);
|
|
||||||
}}
|
|
||||||
main {{
|
|
||||||
width: min(100%, 380px);
|
|
||||||
padding: 28px;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--panel);
|
|
||||||
box-shadow: 0 12px 28px rgba(20, 31, 46, 0.08);
|
|
||||||
}}
|
|
||||||
h1 {{
|
|
||||||
margin: 0 0 10px;
|
|
||||||
font-size: 22px;
|
|
||||||
line-height: 1.25;
|
|
||||||
letter-spacing: 0;
|
|
||||||
}}
|
|
||||||
p {{
|
|
||||||
margin: 0 0 18px;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 14px;
|
|
||||||
}}
|
|
||||||
.error {{ color: var(--danger); }}
|
|
||||||
label {{
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
color: #344054;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 700;
|
|
||||||
}}
|
|
||||||
input {{
|
|
||||||
width: 100%;
|
|
||||||
height: 44px;
|
|
||||||
padding: 0 12px;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 6px;
|
|
||||||
color: var(--text);
|
|
||||||
font: inherit;
|
|
||||||
outline: none;
|
|
||||||
}}
|
|
||||||
.password-field {{
|
|
||||||
position: relative;
|
|
||||||
}}
|
|
||||||
.password-field input {{
|
|
||||||
padding-right: 48px;
|
|
||||||
}}
|
|
||||||
input:focus {{
|
|
||||||
border-color: var(--accent);
|
|
||||||
box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.15);
|
|
||||||
}}
|
|
||||||
button {{
|
|
||||||
width: 100%;
|
|
||||||
min-height: 44px;
|
|
||||||
margin-top: 14px;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: var(--accent);
|
|
||||||
color: #fff;
|
|
||||||
cursor: pointer;
|
|
||||||
font: inherit;
|
|
||||||
font-weight: 700;
|
|
||||||
}}
|
|
||||||
button:hover {{ background: var(--accent-strong); }}
|
|
||||||
.password-toggle {{
|
|
||||||
position: absolute;
|
|
||||||
top: 1px;
|
|
||||||
right: 1px;
|
|
||||||
width: 42px;
|
|
||||||
min-height: 42px;
|
|
||||||
margin-top: 0;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 0 6px 6px 0;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--muted);
|
|
||||||
}}
|
|
||||||
.password-toggle:hover {{
|
|
||||||
background: #eef2f6;
|
|
||||||
color: var(--accent-strong);
|
|
||||||
}}
|
|
||||||
.password-toggle svg {{
|
|
||||||
display: block;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
margin: 0 auto;
|
|
||||||
fill: none;
|
|
||||||
stroke: currentColor;
|
|
||||||
stroke-width: 2;
|
|
||||||
stroke-linecap: round;
|
|
||||||
stroke-linejoin: round;
|
|
||||||
}}
|
|
||||||
.password-toggle .eye-open {{
|
|
||||||
display: none;
|
|
||||||
}}
|
|
||||||
.password-toggle.is-visible .eye-open {{
|
|
||||||
display: block;
|
|
||||||
}}
|
|
||||||
.password-toggle.is-visible .eye-closed {{
|
|
||||||
display: none;
|
|
||||||
}}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<main>
|
|
||||||
<h1>{escaped_title}</h1>
|
|
||||||
{error_html}
|
|
||||||
<form method="post" action="{escaped_action}" autocomplete="off">
|
|
||||||
<input type="hidden" name="next" value="{escaped_next}" />
|
|
||||||
<label for="password">访问密码</label>
|
|
||||||
<div class="password-field">
|
|
||||||
<input id="password" name="password" type="password" autocomplete="current-password" autofocus required />
|
|
||||||
<button id="togglePassword" class="password-toggle" type="button" aria-label="显示密码" title="显示密码">
|
|
||||||
<svg class="eye-open" viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z" />
|
|
||||||
<circle cx="12" cy="12" r="3" />
|
|
||||||
</svg>
|
|
||||||
<svg class="eye-closed" viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<path d="M17.94 17.94A10.8 10.8 0 0 1 12 19C5.5 19 2 12 2 12a18.4 18.4 0 0 1 5.06-5.94" />
|
|
||||||
<path d="M9.9 4.24A10.9 10.9 0 0 1 12 4.05C18.5 4.05 22 12 22 12a18.5 18.5 0 0 1-2.16 3.19" />
|
|
||||||
<path d="M14.12 14.12A3 3 0 0 1 9.88 9.88" />
|
|
||||||
<path d="M3 3l18 18" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<button type="submit">进入</button>
|
|
||||||
</form>
|
|
||||||
</main>
|
|
||||||
<script>
|
|
||||||
const passwordInput = document.querySelector("#password");
|
|
||||||
const togglePassword = document.querySelector("#togglePassword");
|
|
||||||
togglePassword.addEventListener("click", () => {{
|
|
||||||
const isVisible = passwordInput.type === "text";
|
|
||||||
passwordInput.type = isVisible ? "password" : "text";
|
|
||||||
togglePassword.classList.toggle("is-visible", !isVisible);
|
|
||||||
const label = isVisible ? "显示密码" : "隐藏密码";
|
|
||||||
togglePassword.setAttribute("aria-label", label);
|
|
||||||
togglePassword.setAttribute("title", label);
|
|
||||||
passwordInput.focus();
|
|
||||||
}});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>"""
|
|
||||||
|
|
||||||
|
|
||||||
def load_records():
|
|
||||||
if not CLASSNOTES_PATH.exists():
|
|
||||||
raise HTTPException(status_code=503, detail=f"课程记录文件不存在: {CLASSNOTES_PATH}")
|
|
||||||
return read_classnotes(CLASSNOTES_PATH)
|
|
||||||
|
|
||||||
|
|
||||||
def load_accounts():
|
|
||||||
if not ACCOUNTS_PATH.exists():
|
|
||||||
raise HTTPException(status_code=503, detail=f"课时账户文件不存在: {ACCOUNTS_PATH}")
|
|
||||||
return read_accounts(ACCOUNTS_PATH)
|
|
||||||
|
|
||||||
|
|
||||||
def file_meta(path: Path) -> dict:
|
|
||||||
if not path.exists():
|
|
||||||
return {"exists": False, "path": str(path)}
|
|
||||||
stat = path.stat()
|
|
||||||
return {
|
|
||||||
"exists": True,
|
|
||||||
"path": str(path),
|
|
||||||
"size": stat.st_size,
|
|
||||||
"mtime": stat.st_mtime,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def payload_to_account(payload: AccountPayload, student_id: str | None = None) -> Account:
|
|
||||||
return Account(
|
|
||||||
student_id=student_id if student_id is not None else payload.student_id,
|
|
||||||
student=payload.student,
|
|
||||||
payments=[Payment(date=item.date, hours=item.hours) for item in payload.payments],
|
|
||||||
remaining=payload.remaining,
|
|
||||||
account_status=payload.account_status,
|
|
||||||
note=payload.note,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.exception_handler(ValueError)
|
@app.exception_handler(ValueError)
|
||||||
@@ -500,410 +14,9 @@ async def value_error_handler(_request: Request, exc: ValueError):
|
|||||||
return JSONResponse(status_code=500, content={"detail": str(exc)})
|
return JSONResponse(status_code=500, content={"detail": str(exc)})
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
app.include_router(pages.router)
|
||||||
def index(
|
app.include_router(health.router)
|
||||||
request: Request,
|
app.include_router(records.router)
|
||||||
credentials: HTTPBasicCredentials | None = Depends(security),
|
app.include_router(accounts.router)
|
||||||
):
|
app.include_router(admin.router)
|
||||||
if not is_records_authenticated(request, credentials):
|
app.include_router(ingest.router)
|
||||||
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
|
|
||||||
return FileResponse(STATIC_DIR / "index.html")
|
|
||||||
|
|
||||||
|
|
||||||
@app.head("/")
|
|
||||||
def index_head(
|
|
||||||
request: Request,
|
|
||||||
credentials: HTTPBasicCredentials | None = Depends(security),
|
|
||||||
):
|
|
||||||
if not is_records_authenticated(request, credentials):
|
|
||||||
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
|
|
||||||
return Response(status_code=status.HTTP_200_OK)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/login")
|
|
||||||
def login_page(
|
|
||||||
request: Request,
|
|
||||||
credentials: HTTPBasicCredentials | None = Depends(security),
|
|
||||||
):
|
|
||||||
next_path = safe_next_path(request.query_params.get("next"))
|
|
||||||
has_error = request.query_params.get("error") == "1"
|
|
||||||
if is_records_authenticated(request, credentials):
|
|
||||||
return RedirectResponse(url=next_path, status_code=status.HTTP_303_SEE_OTHER)
|
|
||||||
return HTMLResponse(
|
|
||||||
render_login_page(
|
|
||||||
title="新时空教务管家",
|
|
||||||
action="/login",
|
|
||||||
next_path=next_path,
|
|
||||||
has_error=has_error,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/login")
|
|
||||||
async def login_submit(request: Request):
|
|
||||||
body = (await request.body()).decode("utf-8")
|
|
||||||
form = parse_qs(body, keep_blank_values=True)
|
|
||||||
password = form.get("password", [""])[0]
|
|
||||||
next_path = safe_next_path(form.get("next", ["/"])[0])
|
|
||||||
records_password = configured_password("课程记录", BASIC_AUTH_PASSWORD)
|
|
||||||
if hmac.compare_digest(password, records_password):
|
|
||||||
response = RedirectResponse(url=next_path, status_code=status.HTTP_303_SEE_OTHER)
|
|
||||||
response.set_cookie(
|
|
||||||
RECORDS_SESSION_COOKIE,
|
|
||||||
session_token(records_password, b"xsk-records-web-session-v1"),
|
|
||||||
max_age=SESSION_MAX_AGE,
|
|
||||||
httponly=True,
|
|
||||||
samesite="lax",
|
|
||||||
)
|
|
||||||
return response
|
|
||||||
error_url = f"/login?error=1&next={quote(next_path)}"
|
|
||||||
return RedirectResponse(url=error_url, status_code=status.HTTP_303_SEE_OTHER)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/logout")
|
|
||||||
def logout():
|
|
||||||
response = RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
|
|
||||||
response.delete_cookie(RECORDS_SESSION_COOKIE)
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/accounts")
|
|
||||||
def accounts_index(
|
|
||||||
):
|
|
||||||
return RedirectResponse(url="/admin", status_code=status.HTTP_303_SEE_OTHER)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/admin")
|
|
||||||
def admin_index(
|
|
||||||
request: Request,
|
|
||||||
credentials: HTTPBasicCredentials | None = Depends(security),
|
|
||||||
):
|
|
||||||
if not is_admin_authenticated(request, credentials):
|
|
||||||
return RedirectResponse(url="/admin/login?next=/admin", status_code=status.HTTP_303_SEE_OTHER)
|
|
||||||
return FileResponse(STATIC_DIR / "admin.html")
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/admin/login")
|
|
||||||
def admin_login_page(
|
|
||||||
request: Request,
|
|
||||||
credentials: HTTPBasicCredentials | None = Depends(security),
|
|
||||||
):
|
|
||||||
next_path = safe_next_path(request.query_params.get("next") or "/admin")
|
|
||||||
has_error = request.query_params.get("error") == "1"
|
|
||||||
if is_admin_authenticated(request, credentials):
|
|
||||||
return RedirectResponse(url=next_path, status_code=status.HTTP_303_SEE_OTHER)
|
|
||||||
return HTMLResponse(
|
|
||||||
render_login_page(
|
|
||||||
title="管理后台",
|
|
||||||
action="/admin/login",
|
|
||||||
next_path=next_path,
|
|
||||||
has_error=has_error,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/admin/login")
|
|
||||||
async def admin_login_submit(request: Request):
|
|
||||||
body = (await request.body()).decode("utf-8")
|
|
||||||
form = parse_qs(body, keep_blank_values=True)
|
|
||||||
password = form.get("password", [""])[0]
|
|
||||||
next_path = safe_next_path(form.get("next", ["/admin"])[0])
|
|
||||||
admin_password = configured_password("管理后台", ADMIN_AUTH_PASSWORD)
|
|
||||||
if hmac.compare_digest(password, admin_password):
|
|
||||||
response = RedirectResponse(url=next_path, status_code=status.HTTP_303_SEE_OTHER)
|
|
||||||
response.set_cookie(
|
|
||||||
ADMIN_SESSION_COOKIE,
|
|
||||||
session_token(admin_password, b"xsk-admin-web-session-v1"),
|
|
||||||
max_age=SESSION_MAX_AGE,
|
|
||||||
httponly=True,
|
|
||||||
samesite="lax",
|
|
||||||
)
|
|
||||||
return response
|
|
||||||
error_url = f"/admin/login?error=1&next={quote(next_path)}"
|
|
||||||
return RedirectResponse(url=error_url, status_code=status.HTTP_303_SEE_OTHER)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/admin/logout")
|
|
||||||
def admin_logout():
|
|
||||||
response = RedirectResponse(url="/admin/login", status_code=status.HTTP_303_SEE_OTHER)
|
|
||||||
response.delete_cookie(ADMIN_SESSION_COOKIE)
|
|
||||||
response.delete_cookie(ACCOUNTS_SESSION_COOKIE)
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/accounts/login")
|
|
||||||
def accounts_login_page(
|
|
||||||
):
|
|
||||||
return RedirectResponse(url="/admin/login?next=/admin", status_code=status.HTTP_303_SEE_OTHER)
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/accounts/login")
|
|
||||||
async def accounts_login_submit(request: Request):
|
|
||||||
return await admin_login_submit(request)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/accounts/logout")
|
|
||||||
def accounts_logout():
|
|
||||||
response = RedirectResponse(url="/admin/login", status_code=status.HTTP_303_SEE_OTHER)
|
|
||||||
response.delete_cookie(ACCOUNTS_SESSION_COOKIE)
|
|
||||||
response.delete_cookie(ADMIN_SESSION_COOKIE)
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/static/{asset_path:path}")
|
|
||||||
def static_asset(asset_path: str, _user: str = Depends(verify_any_auth)):
|
|
||||||
target = (STATIC_DIR / asset_path).resolve()
|
|
||||||
static_root = STATIC_DIR.resolve()
|
|
||||||
if not target.is_file() or static_root not in target.parents:
|
|
||||||
raise HTTPException(status_code=404, detail="静态资源不存在")
|
|
||||||
return FileResponse(target)
|
|
||||||
|
|
||||||
|
|
||||||
@app.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,
|
|
||||||
)
|
|
||||||
except DuplicateRecordError as exc:
|
|
||||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
||||||
return {"ok": True, **result}
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/register/payments")
|
|
||||||
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)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
||||||
return {"ok": True, **result}
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/ingest/course-summaries")
|
|
||||||
def ingest_course_summary_batch(payload: CourseSummaryIngestPayload, _user: str = Depends(verify_ingest_token)):
|
|
||||||
try:
|
|
||||||
with write_lock:
|
|
||||||
result = ingest_course_summaries(
|
|
||||||
classnotes_path=CLASSNOTES_PATH,
|
|
||||||
accounts_path=ACCOUNTS_PATH,
|
|
||||||
tasks_path=ADMIN_TASKS_PATH,
|
|
||||||
summaries_root=COURSE_SUMMARIES_ROOT,
|
|
||||||
state_path=COURSE_SUMMARY_STATE_PATH,
|
|
||||||
operation_logs_path=OPERATION_LOGS_PATH,
|
|
||||||
batch_id=payload.batch_id,
|
|
||||||
window=payload.window,
|
|
||||||
students=payload.students,
|
|
||||||
summaries=[item.dict() for item in payload.summaries],
|
|
||||||
)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
||||||
return {"ok": True, **result}
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
|
||||||
def health(_user: str = Depends(verify_records_auth)):
|
|
||||||
records = load_records()
|
|
||||||
accounts = load_accounts()
|
|
||||||
return {
|
|
||||||
"ok": True,
|
|
||||||
"classnotes": file_meta(CLASSNOTES_PATH),
|
|
||||||
"accounts": file_meta(ACCOUNTS_PATH),
|
|
||||||
"course_summaries": file_meta(COURSE_SUMMARIES_ROOT),
|
|
||||||
"course_summary_state": file_meta(COURSE_SUMMARY_STATE_PATH),
|
|
||||||
"operation_logs": file_meta(OPERATION_LOGS_PATH),
|
|
||||||
"records_count": len(records),
|
|
||||||
"accounts_count": len(accounts),
|
|
||||||
"account_summary": account_summary(accounts),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/records")
|
|
||||||
def records(
|
|
||||||
q: str = Query(..., min_length=1, description="自然语言查询,例如:王鑫鹏5月数学课"),
|
|
||||||
limit: int = Query(200, ge=1, le=1000),
|
|
||||||
_user: str = Depends(verify_records_auth),
|
|
||||||
):
|
|
||||||
return query_records(load_records(), q, limit=limit)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/student-account/{student}")
|
|
||||||
def record_student_account(student: str, _user: str = Depends(verify_records_auth)):
|
|
||||||
for account in load_accounts():
|
|
||||||
if account.student == student or account.student_id == student:
|
|
||||||
return account_to_dict(account)
|
|
||||||
raise HTTPException(status_code=404, detail=f"未找到学生账户: {student}")
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/account-health")
|
|
||||||
def account_health(_user: str = Depends(verify_accounts_auth)):
|
|
||||||
accounts = load_accounts()
|
|
||||||
return {
|
|
||||||
"ok": True,
|
|
||||||
"accounts": file_meta(ACCOUNTS_PATH),
|
|
||||||
"accounts_count": len(accounts),
|
|
||||||
"account_summary": account_summary(accounts),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/accounts")
|
|
||||||
def accounts(
|
|
||||||
q: str = Query("", description="学生姓名或学生ID"),
|
|
||||||
status_filter: str = Query("", alias="status", description="账户状态"),
|
|
||||||
_user: str = Depends(verify_accounts_auth),
|
|
||||||
):
|
|
||||||
all_accounts = load_accounts()
|
|
||||||
rows = filter_accounts(all_accounts, keyword=q, status=status_filter)
|
|
||||||
return {
|
|
||||||
"summary": account_summary(all_accounts),
|
|
||||||
"count": len(rows),
|
|
||||||
"accounts": [account_to_dict(account) for account in rows],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/accounts/{student}")
|
|
||||||
def account_detail(student: str, _user: str = Depends(verify_accounts_auth)):
|
|
||||||
for account in load_accounts():
|
|
||||||
if account.student == student or account.student_id == student:
|
|
||||||
return account_to_dict(account)
|
|
||||||
raise HTTPException(status_code=404, detail=f"未找到学生账户: {student}")
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/admin/statuses")
|
|
||||||
def admin_statuses(_user: str = Depends(verify_admin_auth)):
|
|
||||||
return {"account_statuses": sorted(ACCOUNT_STATUSES)}
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/admin/accounts")
|
|
||||||
def admin_create_account(payload: AccountPayload, _user: str = Depends(verify_admin_auth)):
|
|
||||||
try:
|
|
||||||
with write_lock:
|
|
||||||
result = create_account(ACCOUNTS_PATH, payload_to_account(payload))
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
||||||
return {"ok": True, **result}
|
|
||||||
|
|
||||||
|
|
||||||
@app.put("/api/admin/accounts/{student_id}")
|
|
||||||
def admin_update_account(student_id: str, payload: AccountPayload, _user: str = Depends(verify_admin_auth)):
|
|
||||||
try:
|
|
||||||
with write_lock:
|
|
||||||
result = update_account(ACCOUNTS_PATH, student_id, payload_to_account(payload))
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
||||||
return {"ok": True, **result}
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/corrections")
|
|
||||||
def submit_corrections(payload: CorrectionSubmitPayload, _user: str = Depends(verify_records_auth)):
|
|
||||||
try:
|
|
||||||
result = submit_correction_tasks(
|
|
||||||
ADMIN_TASKS_PATH,
|
|
||||||
[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}
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/admin/tasks")
|
|
||||||
def admin_tasks(
|
|
||||||
status_filter: str = Query("", alias="status"),
|
|
||||||
task_type: str = Query("", alias="type"),
|
|
||||||
_user: str = Depends(verify_admin_auth),
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
return list_admin_tasks(ADMIN_TASKS_PATH, status_filter=status_filter, task_type=task_type)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/admin/operation-logs")
|
|
||||||
def admin_operation_logs(
|
|
||||||
limit: int = Query(100, ge=1, le=500),
|
|
||||||
operation: str = Query(""),
|
|
||||||
status_filter: str = Query("", alias="status"),
|
|
||||||
student: str = Query(""),
|
|
||||||
_user: str = Depends(verify_admin_auth),
|
|
||||||
):
|
|
||||||
return list_operation_logs(
|
|
||||||
OPERATION_LOGS_PATH,
|
|
||||||
limit=limit,
|
|
||||||
operation=operation,
|
|
||||||
status_filter=status_filter,
|
|
||||||
student=student,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/admin/course-summaries")
|
|
||||||
def admin_course_summaries(
|
|
||||||
q: str = Query(""),
|
|
||||||
student: str = Query(""),
|
|
||||||
teacher: str = Query(""),
|
|
||||||
subject: str = Query(""),
|
|
||||||
date_from: str = Query(""),
|
|
||||||
date_to: str = Query(""),
|
|
||||||
limit: int = Query(200, ge=1, le=1000),
|
|
||||||
_user: str = Depends(verify_admin_auth),
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
return query_course_summaries(
|
|
||||||
COURSE_SUMMARIES_ROOT,
|
|
||||||
q=q,
|
|
||||||
student=student,
|
|
||||||
teacher=teacher,
|
|
||||||
subject=subject,
|
|
||||||
date_from=date_from,
|
|
||||||
date_to=date_to,
|
|
||||||
limit=limit,
|
|
||||||
)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/admin/tasks/{task_id}/approve")
|
|
||||||
def admin_approve_task(task_id: int, _user: str = Depends(verify_admin_auth)):
|
|
||||||
try:
|
|
||||||
with write_lock:
|
|
||||||
result = approve_admin_task(ADMIN_TASKS_PATH, CLASSNOTES_PATH, ACCOUNTS_PATH, task_id)
|
|
||||||
task = result.get("task", {})
|
|
||||||
append_operation_log(
|
|
||||||
OPERATION_LOGS_PATH,
|
|
||||||
"admin_task_approve",
|
|
||||||
"approved",
|
|
||||||
task_id=task_id,
|
|
||||||
task_type=str(task.get("type") or ""),
|
|
||||||
student=str(task.get("student") or task.get("corrected", {}).get("student") or ""),
|
|
||||||
source_id=str(task.get("source_id") or ""),
|
|
||||||
backup_id=str(result.get("backup_id") or task.get("backup_id") or ""),
|
|
||||||
)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
||||||
return {"ok": True, **result}
|
|
||||||
|
|
||||||
|
|
||||||
@app.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,
|
|
||||||
"admin_task_reject",
|
|
||||||
"rejected",
|
|
||||||
task_id=task_id,
|
|
||||||
task_type=str(task.get("type") or ""),
|
|
||||||
student=str(task.get("student") or task.get("corrected", {}).get("student") or ""),
|
|
||||||
source_id=str(task.get("source_id") or ""),
|
|
||||||
)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
||||||
return {"ok": True, "task": task}
|
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
|
|
||||||
|
from ..api_utils import file_meta, load_accounts, payload_to_account, read_register_payload
|
||||||
|
from ..auth import verify_accounts_auth, verify_admin_auth
|
||||||
|
from ..config import ACCOUNTS_PATH, CLASSNOTES_PATH, write_lock
|
||||||
|
from ..data import (
|
||||||
|
ACCOUNT_STATUSES,
|
||||||
|
DuplicateRecordError,
|
||||||
|
account_summary,
|
||||||
|
account_to_dict,
|
||||||
|
create_account,
|
||||||
|
filter_accounts,
|
||||||
|
register_class_record_lines,
|
||||||
|
register_payment_lines,
|
||||||
|
update_account,
|
||||||
|
)
|
||||||
|
from ..schemas import AccountPayload
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@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,
|
||||||
|
)
|
||||||
|
except DuplicateRecordError as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
return {"ok": True, **result}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/register/payments")
|
||||||
|
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)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
return {"ok": True, **result}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/account-health")
|
||||||
|
def account_health(_user: str = Depends(verify_accounts_auth)):
|
||||||
|
accounts = load_accounts()
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"accounts": file_meta(ACCOUNTS_PATH),
|
||||||
|
"accounts_count": len(accounts),
|
||||||
|
"account_summary": account_summary(accounts),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/accounts")
|
||||||
|
def accounts(
|
||||||
|
q: str = Query("", description="学生姓名或学生ID"),
|
||||||
|
status_filter: str = Query("", alias="status", description="账户状态"),
|
||||||
|
_user: str = Depends(verify_accounts_auth),
|
||||||
|
):
|
||||||
|
all_accounts = load_accounts()
|
||||||
|
rows = filter_accounts(all_accounts, keyword=q, status=status_filter)
|
||||||
|
return {
|
||||||
|
"summary": account_summary(all_accounts),
|
||||||
|
"count": len(rows),
|
||||||
|
"accounts": [account_to_dict(account) for account in rows],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/accounts/{student}")
|
||||||
|
def account_detail(student: str, _user: str = Depends(verify_accounts_auth)):
|
||||||
|
for account in load_accounts():
|
||||||
|
if account.student == student or account.student_id == student:
|
||||||
|
return account_to_dict(account)
|
||||||
|
raise HTTPException(status_code=404, detail=f"未找到学生账户: {student}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/admin/statuses")
|
||||||
|
def admin_statuses(_user: str = Depends(verify_admin_auth)):
|
||||||
|
return {"account_statuses": sorted(ACCOUNT_STATUSES)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/admin/accounts")
|
||||||
|
def admin_create_account(payload: AccountPayload, _user: str = Depends(verify_admin_auth)):
|
||||||
|
try:
|
||||||
|
with write_lock:
|
||||||
|
result = create_account(ACCOUNTS_PATH, payload_to_account(payload))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
return {"ok": True, **result}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/api/admin/accounts/{student_id}")
|
||||||
|
def admin_update_account(student_id: str, payload: AccountPayload, _user: str = Depends(verify_admin_auth)):
|
||||||
|
try:
|
||||||
|
with write_lock:
|
||||||
|
result = update_account(ACCOUNTS_PATH, student_id, payload_to_account(payload))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
return {"ok": True, **result}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
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,
|
||||||
|
OPERATION_LOGS_PATH,
|
||||||
|
write_lock,
|
||||||
|
)
|
||||||
|
from ..data import (
|
||||||
|
append_operation_log,
|
||||||
|
approve_admin_task,
|
||||||
|
list_admin_tasks,
|
||||||
|
list_operation_logs,
|
||||||
|
query_course_summaries,
|
||||||
|
reject_admin_task,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/admin/tasks")
|
||||||
|
def admin_tasks(
|
||||||
|
status_filter: str = Query("", alias="status"),
|
||||||
|
task_type: str = Query("", alias="type"),
|
||||||
|
_user: str = Depends(verify_admin_auth),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return list_admin_tasks(ADMIN_TASKS_PATH, status_filter=status_filter, task_type=task_type)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/admin/operation-logs")
|
||||||
|
def admin_operation_logs(
|
||||||
|
limit: int = Query(100, ge=1, le=500),
|
||||||
|
operation: str = Query(""),
|
||||||
|
status_filter: str = Query("", alias="status"),
|
||||||
|
student: str = Query(""),
|
||||||
|
_user: str = Depends(verify_admin_auth),
|
||||||
|
):
|
||||||
|
return list_operation_logs(
|
||||||
|
OPERATION_LOGS_PATH,
|
||||||
|
limit=limit,
|
||||||
|
operation=operation,
|
||||||
|
status_filter=status_filter,
|
||||||
|
student=student,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/admin/course-summaries")
|
||||||
|
def admin_course_summaries(
|
||||||
|
q: str = Query(""),
|
||||||
|
student: str = Query(""),
|
||||||
|
teacher: str = Query(""),
|
||||||
|
subject: str = Query(""),
|
||||||
|
date_from: str = Query(""),
|
||||||
|
date_to: str = Query(""),
|
||||||
|
limit: int = Query(200, ge=1, le=1000),
|
||||||
|
_user: str = Depends(verify_admin_auth),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return query_course_summaries(
|
||||||
|
COURSE_SUMMARIES_ROOT,
|
||||||
|
q=q,
|
||||||
|
student=student,
|
||||||
|
teacher=teacher,
|
||||||
|
subject=subject,
|
||||||
|
date_from=date_from,
|
||||||
|
date_to=date_to,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@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, CLASSNOTES_PATH, ACCOUNTS_PATH, task_id)
|
||||||
|
task = result.get("task", {})
|
||||||
|
append_operation_log(
|
||||||
|
OPERATION_LOGS_PATH,
|
||||||
|
"admin_task_approve",
|
||||||
|
"approved",
|
||||||
|
task_id=task_id,
|
||||||
|
task_type=str(task.get("type") or ""),
|
||||||
|
student=str(task.get("student") or task.get("corrected", {}).get("student") or ""),
|
||||||
|
source_id=str(task.get("source_id") or ""),
|
||||||
|
backup_id=str(result.get("backup_id") or task.get("backup_id") or ""),
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
|
return {"ok": True, **result}
|
||||||
|
|
||||||
|
|
||||||
|
@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,
|
||||||
|
"admin_task_reject",
|
||||||
|
"rejected",
|
||||||
|
task_id=task_id,
|
||||||
|
task_type=str(task.get("type") or ""),
|
||||||
|
student=str(task.get("student") or task.get("corrected", {}).get("student") or ""),
|
||||||
|
source_id=str(task.get("source_id") or ""),
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
return {"ok": True, "task": task}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
|
from ..api_utils import file_meta, load_accounts, load_records
|
||||||
|
from ..auth import verify_records_auth
|
||||||
|
from ..config import (
|
||||||
|
ACCOUNTS_PATH,
|
||||||
|
CLASSNOTES_PATH,
|
||||||
|
COURSE_SUMMARIES_ROOT,
|
||||||
|
COURSE_SUMMARY_STATE_PATH,
|
||||||
|
OPERATION_LOGS_PATH,
|
||||||
|
)
|
||||||
|
from ..data import account_summary
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/health")
|
||||||
|
def health(_user: str = Depends(verify_records_auth)):
|
||||||
|
records = load_records()
|
||||||
|
accounts = load_accounts()
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"classnotes": file_meta(CLASSNOTES_PATH),
|
||||||
|
"accounts": file_meta(ACCOUNTS_PATH),
|
||||||
|
"course_summaries": file_meta(COURSE_SUMMARIES_ROOT),
|
||||||
|
"course_summary_state": file_meta(COURSE_SUMMARY_STATE_PATH),
|
||||||
|
"operation_logs": file_meta(OPERATION_LOGS_PATH),
|
||||||
|
"records_count": len(records),
|
||||||
|
"accounts_count": len(accounts),
|
||||||
|
"account_summary": account_summary(accounts),
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
|
from ..auth import verify_ingest_token
|
||||||
|
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 ..schemas import CourseSummaryIngestPayload
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/ingest/course-summaries")
|
||||||
|
def ingest_course_summary_batch(payload: CourseSummaryIngestPayload, _user: str = Depends(verify_ingest_token)):
|
||||||
|
try:
|
||||||
|
with write_lock:
|
||||||
|
result = ingest_course_summaries(
|
||||||
|
classnotes_path=CLASSNOTES_PATH,
|
||||||
|
accounts_path=ACCOUNTS_PATH,
|
||||||
|
tasks_path=ADMIN_TASKS_PATH,
|
||||||
|
summaries_root=COURSE_SUMMARIES_ROOT,
|
||||||
|
state_path=COURSE_SUMMARY_STATE_PATH,
|
||||||
|
operation_logs_path=OPERATION_LOGS_PATH,
|
||||||
|
batch_id=payload.batch_id,
|
||||||
|
window=payload.window,
|
||||||
|
students=payload.students,
|
||||||
|
summaries=[item.dict() for item in payload.summaries],
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
return {"ok": True, **result}
|
||||||
@@ -0,0 +1,360 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hmac
|
||||||
|
import html
|
||||||
|
from urllib.parse import parse_qs, quote
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||||
|
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse, Response
|
||||||
|
from fastapi.security import HTTPBasicCredentials
|
||||||
|
|
||||||
|
from ..auth import (
|
||||||
|
configured_password,
|
||||||
|
is_admin_authenticated,
|
||||||
|
is_records_authenticated,
|
||||||
|
security,
|
||||||
|
session_token,
|
||||||
|
verify_any_auth,
|
||||||
|
)
|
||||||
|
from ..config import (
|
||||||
|
ACCOUNTS_SESSION_COOKIE,
|
||||||
|
ADMIN_AUTH_PASSWORD,
|
||||||
|
ADMIN_SESSION_COOKIE,
|
||||||
|
BASIC_AUTH_PASSWORD,
|
||||||
|
RECORDS_SESSION_COOKIE,
|
||||||
|
SESSION_MAX_AGE,
|
||||||
|
STATIC_DIR,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def safe_next_path(value: str | None) -> str:
|
||||||
|
if not value or not value.startswith("/") or value.startswith("//"):
|
||||||
|
return "/"
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def render_login_page(title: str, action: str, next_path: str = "/", has_error: bool = False) -> str:
|
||||||
|
escaped_title = html.escape(title)
|
||||||
|
escaped_action = html.escape(action, quote=True)
|
||||||
|
escaped_next = html.escape(next_path, quote=True)
|
||||||
|
error_html = '<p class="error">密码不正确,请重新输入。</p>' if has_error else '<p class="hint">请输入访问密码。</p>'
|
||||||
|
return f"""<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>{escaped_title}</title>
|
||||||
|
<style>
|
||||||
|
:root {{
|
||||||
|
color-scheme: light;
|
||||||
|
--bg: #f6f7f9;
|
||||||
|
--panel: #ffffff;
|
||||||
|
--text: #18202a;
|
||||||
|
--muted: #687487;
|
||||||
|
--line: #d9dee7;
|
||||||
|
--accent: #0f766e;
|
||||||
|
--accent-strong: #0b5d57;
|
||||||
|
--danger: #b42318;
|
||||||
|
font-family: Arial, "Songti SC", SimSun, sans-serif;
|
||||||
|
}}
|
||||||
|
* {{ box-sizing: border-box; }}
|
||||||
|
body {{
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 20px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
}}
|
||||||
|
main {{
|
||||||
|
width: min(100%, 380px);
|
||||||
|
padding: 28px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--panel);
|
||||||
|
box-shadow: 0 12px 28px rgba(20, 31, 46, 0.08);
|
||||||
|
}}
|
||||||
|
h1 {{
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1.25;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}}
|
||||||
|
p {{
|
||||||
|
margin: 0 0 18px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 14px;
|
||||||
|
}}
|
||||||
|
.error {{ color: var(--danger); }}
|
||||||
|
label {{
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: #344054;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
}}
|
||||||
|
input {{
|
||||||
|
width: 100%;
|
||||||
|
height: 44px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
outline: none;
|
||||||
|
}}
|
||||||
|
.password-field {{
|
||||||
|
position: relative;
|
||||||
|
}}
|
||||||
|
.password-field input {{
|
||||||
|
padding-right: 48px;
|
||||||
|
}}
|
||||||
|
input:focus {{
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.15);
|
||||||
|
}}
|
||||||
|
button {{
|
||||||
|
width: 100%;
|
||||||
|
min-height: 44px;
|
||||||
|
margin-top: 14px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
}}
|
||||||
|
button:hover {{ background: var(--accent-strong); }}
|
||||||
|
.password-toggle {{
|
||||||
|
position: absolute;
|
||||||
|
top: 1px;
|
||||||
|
right: 1px;
|
||||||
|
width: 42px;
|
||||||
|
min-height: 42px;
|
||||||
|
margin-top: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0 6px 6px 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
}}
|
||||||
|
.password-toggle:hover {{
|
||||||
|
background: #eef2f6;
|
||||||
|
color: var(--accent-strong);
|
||||||
|
}}
|
||||||
|
.password-toggle svg {{
|
||||||
|
display: block;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
margin: 0 auto;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 2;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}}
|
||||||
|
.password-toggle .eye-open {{
|
||||||
|
display: none;
|
||||||
|
}}
|
||||||
|
.password-toggle.is-visible .eye-open {{
|
||||||
|
display: block;
|
||||||
|
}}
|
||||||
|
.password-toggle.is-visible .eye-closed {{
|
||||||
|
display: none;
|
||||||
|
}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>{escaped_title}</h1>
|
||||||
|
{error_html}
|
||||||
|
<form method="post" action="{escaped_action}" autocomplete="off">
|
||||||
|
<input type="hidden" name="next" value="{escaped_next}" />
|
||||||
|
<label for="password">访问密码</label>
|
||||||
|
<div class="password-field">
|
||||||
|
<input id="password" name="password" type="password" autocomplete="current-password" autofocus required />
|
||||||
|
<button id="togglePassword" class="password-toggle" type="button" aria-label="显示密码" title="显示密码">
|
||||||
|
<svg class="eye-open" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z" />
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
</svg>
|
||||||
|
<svg class="eye-closed" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M17.94 17.94A10.8 10.8 0 0 1 12 19C5.5 19 2 12 2 12a18.4 18.4 0 0 1 5.06-5.94" />
|
||||||
|
<path d="M9.9 4.24A10.9 10.9 0 0 1 12 4.05C18.5 4.05 22 12 22 12a18.5 18.5 0 0 1-2.16 3.19" />
|
||||||
|
<path d="M14.12 14.12A3 3 0 0 1 9.88 9.88" />
|
||||||
|
<path d="M3 3l18 18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button type="submit">进入</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
const passwordInput = document.querySelector("#password");
|
||||||
|
const togglePassword = document.querySelector("#togglePassword");
|
||||||
|
togglePassword.addEventListener("click", () => {{
|
||||||
|
const isVisible = passwordInput.type === "text";
|
||||||
|
passwordInput.type = isVisible ? "password" : "text";
|
||||||
|
togglePassword.classList.toggle("is-visible", !isVisible);
|
||||||
|
const label = isVisible ? "显示密码" : "隐藏密码";
|
||||||
|
togglePassword.setAttribute("aria-label", label);
|
||||||
|
togglePassword.setAttribute("title", label);
|
||||||
|
passwordInput.focus();
|
||||||
|
}});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
def index(
|
||||||
|
request: Request,
|
||||||
|
credentials: HTTPBasicCredentials | None = Depends(security),
|
||||||
|
):
|
||||||
|
if not is_records_authenticated(request, credentials):
|
||||||
|
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
return FileResponse(STATIC_DIR / "index.html")
|
||||||
|
|
||||||
|
|
||||||
|
@router.head("/")
|
||||||
|
def index_head(
|
||||||
|
request: Request,
|
||||||
|
credentials: HTTPBasicCredentials | None = Depends(security),
|
||||||
|
):
|
||||||
|
if not is_records_authenticated(request, credentials):
|
||||||
|
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
return Response(status_code=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/login")
|
||||||
|
def login_page(
|
||||||
|
request: Request,
|
||||||
|
credentials: HTTPBasicCredentials | None = Depends(security),
|
||||||
|
):
|
||||||
|
next_path = safe_next_path(request.query_params.get("next"))
|
||||||
|
has_error = request.query_params.get("error") == "1"
|
||||||
|
if is_records_authenticated(request, credentials):
|
||||||
|
return RedirectResponse(url=next_path, status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
return HTMLResponse(
|
||||||
|
render_login_page(
|
||||||
|
title="新时空教务管家",
|
||||||
|
action="/login",
|
||||||
|
next_path=next_path,
|
||||||
|
has_error=has_error,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login")
|
||||||
|
async def login_submit(request: Request):
|
||||||
|
body = (await request.body()).decode("utf-8")
|
||||||
|
form = parse_qs(body, keep_blank_values=True)
|
||||||
|
password = form.get("password", [""])[0]
|
||||||
|
next_path = safe_next_path(form.get("next", ["/"])[0])
|
||||||
|
records_password = configured_password("课程记录", BASIC_AUTH_PASSWORD)
|
||||||
|
if hmac.compare_digest(password, records_password):
|
||||||
|
response = RedirectResponse(url=next_path, status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
response.set_cookie(
|
||||||
|
RECORDS_SESSION_COOKIE,
|
||||||
|
session_token(records_password, b"xsk-records-web-session-v1"),
|
||||||
|
max_age=SESSION_MAX_AGE,
|
||||||
|
httponly=True,
|
||||||
|
samesite="lax",
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
error_url = f"/login?error=1&next={quote(next_path)}"
|
||||||
|
return RedirectResponse(url=error_url, status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/logout")
|
||||||
|
def logout():
|
||||||
|
response = RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
response.delete_cookie(RECORDS_SESSION_COOKIE)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/accounts")
|
||||||
|
def accounts_index():
|
||||||
|
return RedirectResponse(url="/admin", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin")
|
||||||
|
def admin_index(
|
||||||
|
request: Request,
|
||||||
|
credentials: HTTPBasicCredentials | None = Depends(security),
|
||||||
|
):
|
||||||
|
if not is_admin_authenticated(request, credentials):
|
||||||
|
return RedirectResponse(url="/admin/login?next=/admin", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
return FileResponse(STATIC_DIR / "admin.html")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/login")
|
||||||
|
def admin_login_page(
|
||||||
|
request: Request,
|
||||||
|
credentials: HTTPBasicCredentials | None = Depends(security),
|
||||||
|
):
|
||||||
|
next_path = safe_next_path(request.query_params.get("next") or "/admin")
|
||||||
|
has_error = request.query_params.get("error") == "1"
|
||||||
|
if is_admin_authenticated(request, credentials):
|
||||||
|
return RedirectResponse(url=next_path, status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
return HTMLResponse(render_login_page("管理后台", "/admin/login", next_path, has_error))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/admin/login")
|
||||||
|
async def admin_login_submit(request: Request):
|
||||||
|
body = (await request.body()).decode("utf-8")
|
||||||
|
form = parse_qs(body, keep_blank_values=True)
|
||||||
|
password = form.get("password", [""])[0]
|
||||||
|
next_path = safe_next_path(form.get("next", ["/admin"])[0])
|
||||||
|
admin_password = configured_password("管理后台", ADMIN_AUTH_PASSWORD)
|
||||||
|
if hmac.compare_digest(password, admin_password):
|
||||||
|
response = RedirectResponse(url=next_path, status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
response.set_cookie(
|
||||||
|
ADMIN_SESSION_COOKIE,
|
||||||
|
session_token(admin_password, b"xsk-admin-web-session-v1"),
|
||||||
|
max_age=SESSION_MAX_AGE,
|
||||||
|
httponly=True,
|
||||||
|
samesite="lax",
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
error_url = f"/admin/login?error=1&next={quote(next_path)}"
|
||||||
|
return RedirectResponse(url=error_url, status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/logout")
|
||||||
|
def admin_logout():
|
||||||
|
response = RedirectResponse(url="/admin/login", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
response.delete_cookie(ADMIN_SESSION_COOKIE)
|
||||||
|
response.delete_cookie(ACCOUNTS_SESSION_COOKIE)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/accounts/login")
|
||||||
|
def accounts_login_page():
|
||||||
|
return RedirectResponse(url="/admin/login?next=/admin", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/accounts/login")
|
||||||
|
async def accounts_login_submit(request: Request):
|
||||||
|
return await admin_login_submit(request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/accounts/logout")
|
||||||
|
def accounts_logout():
|
||||||
|
response = RedirectResponse(url="/admin/login", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
response.delete_cookie(ACCOUNTS_SESSION_COOKIE)
|
||||||
|
response.delete_cookie(ADMIN_SESSION_COOKIE)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/static/{asset_path:path}")
|
||||||
|
def static_asset(asset_path: str, _user: str = Depends(verify_any_auth)):
|
||||||
|
target = (STATIC_DIR / asset_path).resolve()
|
||||||
|
static_root = STATIC_DIR.resolve()
|
||||||
|
if not target.is_file() or static_root not in target.parents:
|
||||||
|
raise HTTPException(status_code=404, detail="静态资源不存在")
|
||||||
|
return FileResponse(target)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
|
||||||
|
from ..api_utils import load_accounts, load_records
|
||||||
|
from ..auth import verify_records_auth
|
||||||
|
from ..config import ADMIN_TASKS_PATH
|
||||||
|
from ..data import account_to_dict, query_records, submit_correction_tasks
|
||||||
|
from ..schemas import CorrectionSubmitPayload
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/records")
|
||||||
|
def records(
|
||||||
|
q: str = Query(..., min_length=1, description="自然语言查询,例如:王鑫鹏5月数学课"),
|
||||||
|
limit: int = Query(200, ge=1, le=1000),
|
||||||
|
_user: str = Depends(verify_records_auth),
|
||||||
|
):
|
||||||
|
return query_records(load_records(), q, limit=limit)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/student-account/{student}")
|
||||||
|
def record_student_account(student: str, _user: str = Depends(verify_records_auth)):
|
||||||
|
for account in load_accounts():
|
||||||
|
if account.student == student or account.student_id == student:
|
||||||
|
return account_to_dict(account)
|
||||||
|
raise HTTPException(status_code=404, detail=f"未找到学生账户: {student}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/corrections")
|
||||||
|
def submit_corrections(payload: CorrectionSubmitPayload, _user: str = Depends(verify_records_auth)):
|
||||||
|
try:
|
||||||
|
result = submit_correction_tasks(
|
||||||
|
ADMIN_TASKS_PATH,
|
||||||
|
[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}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterLinesPayload(BaseModel):
|
||||||
|
line: str | None = Field(default=None, description="单条原始登记文本")
|
||||||
|
lines: list[str] | None = Field(default=None, description="多条原始登记文本")
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentPayload(BaseModel):
|
||||||
|
date: str
|
||||||
|
hours: float
|
||||||
|
|
||||||
|
|
||||||
|
class AccountPayload(BaseModel):
|
||||||
|
student_id: str = ""
|
||||||
|
student: str
|
||||||
|
payments: list[PaymentPayload] = Field(default_factory=list)
|
||||||
|
remaining: float = 0
|
||||||
|
account_status: str = "正常"
|
||||||
|
note: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class CorrectionItemPayload(BaseModel):
|
||||||
|
original_line: str
|
||||||
|
corrected_line: str
|
||||||
|
|
||||||
|
|
||||||
|
class CorrectionSubmitPayload(BaseModel):
|
||||||
|
items: list[CorrectionItemPayload]
|
||||||
|
|
||||||
|
|
||||||
|
class CourseSummaryPayload(BaseModel):
|
||||||
|
source_id: str = ""
|
||||||
|
student: str
|
||||||
|
date_iso: str = ""
|
||||||
|
date: str = ""
|
||||||
|
time_range: str = ""
|
||||||
|
raw_time: str = ""
|
||||||
|
duration: str = ""
|
||||||
|
duration_hours: float | None = None
|
||||||
|
duration_minutes: int | None = None
|
||||||
|
teacher: str = ""
|
||||||
|
subject: str = ""
|
||||||
|
group: str = ""
|
||||||
|
sender: str = ""
|
||||||
|
sender_name: str = ""
|
||||||
|
sender_id: str = ""
|
||||||
|
message_time: str = ""
|
||||||
|
message_date: str = ""
|
||||||
|
db: str = ""
|
||||||
|
local_id: str | int | None = ""
|
||||||
|
title: str = ""
|
||||||
|
body: str
|
||||||
|
recognition_source: str = ""
|
||||||
|
confidence: str = ""
|
||||||
|
teacher_trusted: bool = False
|
||||||
|
sender_teacher_trusted: bool = False
|
||||||
|
remark: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class CourseSummaryIngestPayload(BaseModel):
|
||||||
|
batch_id: str
|
||||||
|
window: dict = Field(default_factory=dict)
|
||||||
|
students: list[str] = Field(default_factory=list)
|
||||||
|
summaries: list[CourseSummaryPayload] = Field(default_factory=list)
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
|
||||||
|
BACKUP_DIR_RE = re.compile(r"^\d{8}-\d{6}-\d{6}-")
|
||||||
|
BACKUP_KEEP_COUNT = 50
|
||||||
|
|
||||||
|
|
||||||
|
def atomic_write_text(path: Path, text: str) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
original_stat = path.stat() if path.exists() else None
|
||||||
|
temp_path: Path | None = None
|
||||||
|
try:
|
||||||
|
with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, delete=False) as handle:
|
||||||
|
temp_path = Path(handle.name)
|
||||||
|
handle.write(text)
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
if original_stat is not None:
|
||||||
|
os.chmod(temp_path, original_stat.st_mode & 0o7777)
|
||||||
|
try:
|
||||||
|
os.chown(temp_path, original_stat.st_uid, original_stat.st_gid)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
os.replace(temp_path, path)
|
||||||
|
temp_path = None
|
||||||
|
try:
|
||||||
|
dir_fd = os.open(path.parent, os.O_DIRECTORY)
|
||||||
|
try:
|
||||||
|
os.fsync(dir_fd)
|
||||||
|
finally:
|
||||||
|
os.close(dir_fd)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
if temp_path is not None and temp_path.exists():
|
||||||
|
temp_path.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
def apply_source_permissions(target: Path, source: Path) -> None:
|
||||||
|
if not source.exists():
|
||||||
|
return
|
||||||
|
source_stat = source.stat()
|
||||||
|
os.chmod(target, source_stat.st_mode & 0o7777)
|
||||||
|
try:
|
||||||
|
os.chown(target, source_stat.st_uid, source_stat.st_gid)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def data_backup_root(paths: Iterable[Path]) -> Path:
|
||||||
|
path_list = list(paths)
|
||||||
|
if not path_list:
|
||||||
|
raise ValueError("备份文件不能为空")
|
||||||
|
return path_list[0].parent / "backups"
|
||||||
|
|
||||||
|
|
||||||
|
def create_backup_directory(backup_root: Path, operation: str) -> Path:
|
||||||
|
backup_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S-%f")
|
||||||
|
base_name = f"{timestamp}-{operation}"
|
||||||
|
for index in range(1000):
|
||||||
|
suffix = "" if index == 0 else f"-{index}"
|
||||||
|
backup_dir = backup_root / f"{base_name}{suffix}"
|
||||||
|
try:
|
||||||
|
backup_dir.mkdir()
|
||||||
|
return backup_dir
|
||||||
|
except FileExistsError:
|
||||||
|
continue
|
||||||
|
raise RuntimeError("无法创建唯一备份目录")
|
||||||
|
|
||||||
|
|
||||||
|
def create_data_backup(
|
||||||
|
operation: str,
|
||||||
|
file_contents: dict[Path, str],
|
||||||
|
submitted_lines: list[str],
|
||||||
|
) -> Path:
|
||||||
|
backup_root = data_backup_root(file_contents.keys())
|
||||||
|
backup_dir = create_backup_directory(backup_root, operation)
|
||||||
|
try:
|
||||||
|
submitted_text = "\n".join(submitted_lines)
|
||||||
|
metadata = {
|
||||||
|
"backup_id": backup_dir.name,
|
||||||
|
"created_at": datetime.now().isoformat(timespec="microseconds"),
|
||||||
|
"operation": operation,
|
||||||
|
"submitted_lines_count": len(submitted_lines),
|
||||||
|
"submitted_lines_sha256": hashlib.sha256(submitted_text.encode("utf-8")).hexdigest(),
|
||||||
|
"files": [],
|
||||||
|
}
|
||||||
|
for source_path, content in file_contents.items():
|
||||||
|
target_path = backup_dir / source_path.name
|
||||||
|
atomic_write_text(target_path, content)
|
||||||
|
apply_source_permissions(target_path, source_path)
|
||||||
|
source_stat = source_path.stat()
|
||||||
|
metadata["files"].append(
|
||||||
|
{
|
||||||
|
"name": source_path.name,
|
||||||
|
"source_path": str(source_path),
|
||||||
|
"size": len(content.encode("utf-8")),
|
||||||
|
"mtime": source_stat.st_mtime,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
metadata_path = backup_dir / "metadata.json"
|
||||||
|
atomic_write_text(
|
||||||
|
metadata_path,
|
||||||
|
json.dumps(metadata, ensure_ascii=False, indent=2) + "\n",
|
||||||
|
)
|
||||||
|
return backup_dir
|
||||||
|
except Exception:
|
||||||
|
shutil.rmtree(backup_dir, ignore_errors=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def prune_data_backups(backup_root: Path, keep: int = BACKUP_KEEP_COUNT) -> None:
|
||||||
|
if keep < 1 or not backup_root.exists():
|
||||||
|
return
|
||||||
|
backup_dirs = sorted(
|
||||||
|
path
|
||||||
|
for path in backup_root.iterdir()
|
||||||
|
if path.is_dir() and BACKUP_DIR_RE.match(path.name)
|
||||||
|
)
|
||||||
|
for backup_dir in backup_dirs[:-keep]:
|
||||||
|
shutil.rmtree(backup_dir)
|
||||||
Reference in New Issue
Block a user