refactor: split app structure

This commit is contained in:
Codex
2026-06-15 12:24:49 +08:00
parent df076f6951
commit 5112f7ab4e
17 changed files with 1263 additions and 1090 deletions
+2
View File
@@ -0,0 +1,2 @@
from __future__ import annotations
+110
View File
@@ -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}
+119
View File
@@ -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}
+34
View File
@@ -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),
}
+40
View File
@@ -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}
+360
View File
@@ -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)
+41
View File
@@ -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}