清理历史兼容代码和废弃同步入口
This commit is contained in:
@@ -149,7 +149,6 @@ git push origin HEAD:<当前分支>
|
|||||||
## 课程小结迁移维护
|
## 课程小结迁移维护
|
||||||
|
|
||||||
- VPS 是 `classnotes.txt` 和 `学生课时账户.md` 的唯一正式写入方。
|
- VPS 是 `classnotes.txt` 和 `学生课时账户.md` 的唯一正式写入方。
|
||||||
- 本机 `com.xsk.education-management.sync` 常驻同步迁移后应停止,避免旧本地数据覆盖 VPS。
|
|
||||||
- 本机课程小结采集脚本用 `XSK_INGEST_URL` 和 `XSK_INGEST_TOKEN` 推送批次;失败批次保存在本机 `推送失败队列/`。
|
- 本机课程小结采集脚本用 `XSK_INGEST_URL` 和 `XSK_INGEST_TOKEN` 推送批次;失败批次保存在本机 `推送失败队列/`。
|
||||||
- 管理后台的“课程小结审核”处理低置信或冲突小结;“操作记录”追踪接收、自动入账、重复、失败、审核批准和驳回。
|
- 管理后台的“课程小结审核”处理低置信或冲突小结;“操作记录”追踪接收、自动入账、重复、失败、审核批准和驳回。
|
||||||
- 历史小结导入使用 `scripts/import_course_summaries.py`,历史 `classnotes缺失.txt` 只生成审核任务,不自动扣课时。
|
- 历史小结导入使用 `scripts/import_course_summaries.py`,历史 `classnotes缺失.txt` 只生成审核任务,不自动扣课时。
|
||||||
|
|||||||
@@ -10,11 +10,8 @@
|
|||||||
- `scripts/deploy_to_vps.py`:部署新时空教务管理系统到 VPS。
|
- `scripts/deploy_to_vps.py`:部署新时空教务管理系统到 VPS。
|
||||||
- `scripts/migrate_text_to_sqlite.py`:一次性把旧纯文本、JSON、JSONL 和课程小结 Markdown 迁移进 SQLite。
|
- `scripts/migrate_text_to_sqlite.py`:一次性把旧纯文本、JSON、JSONL 和课程小结 Markdown 迁移进 SQLite。
|
||||||
- `scripts/import_course_summaries.py`:一次性导入历史课程小结 Markdown。
|
- `scripts/import_course_summaries.py`:一次性导入历史课程小结 Markdown。
|
||||||
- `scripts/sync_to_vps.py`:旧版正式数据同步脚本,迁移后不要继续常驻运行。
|
|
||||||
- `scripts/smoke_test.js`:轻量前端行为烟测。
|
- `scripts/smoke_test.js`:轻量前端行为烟测。
|
||||||
- `scripts/install_gitea_backup_hook.py`:安装提交后自动推送到 Gitea 的 Git hook。
|
- `scripts/install_gitea_backup_hook.py`:安装提交后自动推送到 Gitea 的 Git hook。
|
||||||
- `scripts/install_launch_agent.py`:安装 Mac 开机常驻同步任务。
|
|
||||||
- `launchd/com.xsk.education-management.sync.plist.template`:LaunchAgent 模板。
|
|
||||||
|
|
||||||
## 维护入口
|
## 维护入口
|
||||||
|
|
||||||
@@ -70,27 +67,6 @@ http://121.199.172.246:18080/
|
|||||||
XSK_PYTHON_IMAGE='python:3.12-slim'
|
XSK_PYTHON_IMAGE='python:3.12-slim'
|
||||||
```
|
```
|
||||||
|
|
||||||
## 手动同步数据
|
|
||||||
|
|
||||||
迁移完成后不要再用本机 `classnotes.txt` 和 `学生课时账户.md` 覆盖 VPS。SQLite 数据库 `/data/xsk_education.db` 是唯一事实源;运行时生成的 `/data/runtime_text_cache/` 只是兼容旧业务逻辑的缓存。下面命令只保留给迁移前或灾难恢复时使用,日常新增课程小结应走 `POST /api/ingest/course-summaries`。
|
|
||||||
|
|
||||||
```bash
|
|
||||||
XSK_USE_SSHPASS=1 \
|
|
||||||
XSK_SSH_PASSWORD='填写SSH密码' \
|
|
||||||
python3 scripts/sync_to_vps.py --once --use-sshpass
|
|
||||||
```
|
|
||||||
|
|
||||||
同步文件:
|
|
||||||
|
|
||||||
- `/Users/yangdawei/Desktop/新时空业务源数据/新时空课程记录与课时账户/classnotes.txt`
|
|
||||||
- `/Users/yangdawei/Desktop/新时空业务源数据/新时空课程记录与课时账户/学生课时账户.md`
|
|
||||||
|
|
||||||
远端数据目录:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/root/新时空教务管理系统/data/xsk_education.db
|
|
||||||
```
|
|
||||||
|
|
||||||
## SQLite 迁移
|
## SQLite 迁移
|
||||||
|
|
||||||
首次迁移先 dry-run:
|
首次迁移先 dry-run:
|
||||||
@@ -188,34 +164,6 @@ cp /root/新时空教务管理系统/data/backups/<备份目录>/学生课时账
|
|||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
## 安装自动同步
|
|
||||||
|
|
||||||
```bash
|
|
||||||
XSK_USE_SSHPASS=1 \
|
|
||||||
XSK_SSH_PASSWORD='填写SSH密码' \
|
|
||||||
python3 scripts/install_launch_agent.py --use-sshpass
|
|
||||||
```
|
|
||||||
|
|
||||||
日志位置:
|
|
||||||
|
|
||||||
```text
|
|
||||||
~/Library/Logs/xsk-education-management/sync.log
|
|
||||||
~/Library/Logs/xsk-education-management/sync.err.log
|
|
||||||
```
|
|
||||||
|
|
||||||
查看任务:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
launchctl list | grep com.xsk.education-management.sync
|
|
||||||
```
|
|
||||||
|
|
||||||
卸载任务:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
launchctl unload ~/Library/LaunchAgents/com.xsk.education-management.sync.plist
|
|
||||||
rm ~/Library/LaunchAgents/com.xsk.education-management.sync.plist
|
|
||||||
```
|
|
||||||
|
|
||||||
## 更换网页访问密码
|
## 更换网页访问密码
|
||||||
|
|
||||||
登录 VPS 后修改 `/root/新时空教务管理系统/app/.env` 中的 `BASIC_AUTH_PASSWORD`,然后重启:
|
登录 VPS 后修改 `/root/新时空教务管理系统/app/.env` 中的 `BASIC_AUTH_PASSWORD`,然后重启:
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from pydantic import ValidationError
|
|||||||
from .config import ACCOUNTS_PATH, CLASSNOTES_PATH, TEACHERS_PATH, USE_SQLITE_SOURCE
|
from .config import ACCOUNTS_PATH, CLASSNOTES_PATH, TEACHERS_PATH, USE_SQLITE_SOURCE
|
||||||
from .data import Account, Payment, Teacher, read_accounts, read_classnotes, read_teachers
|
from .data import Account, Payment, Teacher, read_accounts, read_classnotes, read_teachers
|
||||||
from .repository import ensure_runtime_cache
|
from .repository import ensure_runtime_cache
|
||||||
from .schemas import AccountPayload, RegisterLinesPayload, TeacherPayload
|
from .schemas import RegisterLinesPayload, StudentProfilePayload, TeacherPayload
|
||||||
|
|
||||||
|
|
||||||
async def read_register_payload(request: Request) -> RegisterLinesPayload:
|
async def read_register_payload(request: Request) -> RegisterLinesPayload:
|
||||||
@@ -46,7 +46,7 @@ def load_records():
|
|||||||
return read_classnotes(CLASSNOTES_PATH)
|
return read_classnotes(CLASSNOTES_PATH)
|
||||||
|
|
||||||
|
|
||||||
def load_accounts():
|
def load_student_profiles():
|
||||||
if USE_SQLITE_SOURCE:
|
if USE_SQLITE_SOURCE:
|
||||||
ensure_runtime_cache()
|
ensure_runtime_cache()
|
||||||
if not ACCOUNTS_PATH.exists():
|
if not ACCOUNTS_PATH.exists():
|
||||||
@@ -74,7 +74,7 @@ def file_meta(path: Path) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def payload_to_account(payload: AccountPayload, student_id: str | None = None) -> Account:
|
def payload_to_student_profile(payload: StudentProfilePayload, student_id: str | None = None) -> Account:
|
||||||
return Account(
|
return Account(
|
||||||
student_id=student_id if student_id is not None else payload.student_id,
|
student_id=student_id if student_id is not None else payload.student_id,
|
||||||
student=payload.student,
|
student=payload.student,
|
||||||
|
|||||||
+1
-8
@@ -77,13 +77,6 @@ def is_admin_authenticated(
|
|||||||
) or has_valid_basic_auth(credentials, ADMIN_AUTH_PASSWORD)
|
) 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(
|
def verify_records_auth(
|
||||||
request: Request,
|
request: Request,
|
||||||
credentials: HTTPBasicCredentials | None = Depends(security),
|
credentials: HTTPBasicCredentials | None = Depends(security),
|
||||||
@@ -110,7 +103,7 @@ def verify_admin_auth(
|
|||||||
return "admin"
|
return "admin"
|
||||||
|
|
||||||
|
|
||||||
def verify_accounts_auth(
|
def verify_student_profiles_auth(
|
||||||
request: Request,
|
request: Request,
|
||||||
credentials: HTTPBasicCredentials | None = Depends(security),
|
credentials: HTTPBasicCredentials | None = Depends(security),
|
||||||
) -> str:
|
) -> str:
|
||||||
|
|||||||
-180
@@ -484,12 +484,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 write_accounts(path: Path, accounts: list[Account]) -> None:
|
|
||||||
original_text = path.read_text(encoding="utf-8")
|
|
||||||
accounts_by_id = {account.student_id: account for account in accounts}
|
|
||||||
atomic_write_text(path, replace_account_lines(original_text, accounts_by_id))
|
|
||||||
|
|
||||||
|
|
||||||
def next_teacher_id(teachers: list[Teacher]) -> str:
|
def next_teacher_id(teachers: list[Teacher]) -> str:
|
||||||
values = []
|
values = []
|
||||||
for teacher in teachers:
|
for teacher in teachers:
|
||||||
@@ -499,22 +493,6 @@ def next_teacher_id(teachers: list[Teacher]) -> str:
|
|||||||
return f"T{(max(values) if values else 0) + 1:03d}"
|
return f"T{(max(values) if values else 0) + 1:03d}"
|
||||||
|
|
||||||
|
|
||||||
def replace_teacher_lines(original_text: str, teachers_by_id: dict[str, Teacher]) -> str:
|
|
||||||
lines = original_text.splitlines()
|
|
||||||
output: list[str] = []
|
|
||||||
for line in lines:
|
|
||||||
stripped = line.strip()
|
|
||||||
compact = stripped.strip("|").replace(" ", "").replace("|", "")
|
|
||||||
if stripped.startswith("|") and not set(compact) <= {"-"} and "教师ID" not in stripped:
|
|
||||||
parts = [part.strip() for part in stripped.strip("|").split("|")]
|
|
||||||
if parts and parts[0] in teachers_by_id:
|
|
||||||
output.append(format_teacher_row(teachers_by_id[parts[0]]))
|
|
||||||
continue
|
|
||||||
output.append(line)
|
|
||||||
trailing_newline = "\n" if original_text.endswith("\n") else ""
|
|
||||||
return "\n".join(output) + trailing_newline
|
|
||||||
|
|
||||||
|
|
||||||
def append_teacher_line(original_text: str, teacher: Teacher) -> str:
|
def append_teacher_line(original_text: str, teacher: Teacher) -> str:
|
||||||
if not original_text.strip():
|
if not original_text.strip():
|
||||||
original_text = (
|
original_text = (
|
||||||
@@ -553,12 +531,6 @@ def replace_single_teacher_line(original_text: str, old_teacher_id: str, teacher
|
|||||||
return "\n".join(output) + trailing_newline
|
return "\n".join(output) + trailing_newline
|
||||||
|
|
||||||
|
|
||||||
def write_teachers(path: Path, teachers: list[Teacher]) -> None:
|
|
||||||
original_text = path.read_text(encoding="utf-8") if path.exists() else ""
|
|
||||||
teachers_by_id = {teacher.teacher_id: teacher for teacher in teachers}
|
|
||||||
atomic_write_text(path, replace_teacher_lines(original_text, teachers_by_id))
|
|
||||||
|
|
||||||
|
|
||||||
def create_teacher(path: Path, teacher: Teacher) -> dict:
|
def create_teacher(path: Path, teacher: Teacher) -> dict:
|
||||||
teachers = read_teachers(path)
|
teachers = read_teachers(path)
|
||||||
teacher = validate_teacher(replace(teacher, teacher_id=next_teacher_id(teachers)))
|
teacher = validate_teacher(replace(teacher, teacher_id=next_teacher_id(teachers)))
|
||||||
@@ -615,16 +587,6 @@ def teacher_alias_map(teachers: list[Teacher]) -> dict[str, str]:
|
|||||||
return mapping
|
return mapping
|
||||||
|
|
||||||
|
|
||||||
def teacher_name_map(teachers: list[Teacher]) -> dict[str, Teacher]:
|
|
||||||
mapping: dict[str, Teacher] = {}
|
|
||||||
for teacher in teachers:
|
|
||||||
mapping[teacher.name] = teacher
|
|
||||||
if teacher.alias:
|
|
||||||
mapping[teacher.alias] = teacher
|
|
||||||
mapping[teacher.teacher_id] = teacher
|
|
||||||
return mapping
|
|
||||||
|
|
||||||
|
|
||||||
def create_account(path: Path, account: Account, classnotes_path: Path | None = None) -> dict:
|
def create_account(path: Path, account: Account, classnotes_path: Path | None = None) -> dict:
|
||||||
accounts = read_accounts(path)
|
accounts = read_accounts(path)
|
||||||
records = read_classnotes(classnotes_path) if classnotes_path and classnotes_path.exists() else []
|
records = read_classnotes(classnotes_path) if classnotes_path and classnotes_path.exists() else []
|
||||||
@@ -663,17 +625,6 @@ def update_account(path: Path, old_student_id: str, account: Account, classnotes
|
|||||||
return {"account": account_to_dict(account), "backup_id": backup_dir.name, "operation": "修改学生档案"}
|
return {"account": account_to_dict(account), "backup_id": backup_dir.name, "operation": "修改学生档案"}
|
||||||
|
|
||||||
|
|
||||||
def render_accounts_text(path: Path, accounts: list[Account]) -> str:
|
|
||||||
original_text = path.read_text(encoding="utf-8")
|
|
||||||
accounts_by_id = {account.student_id: account for account in accounts}
|
|
||||||
return replace_account_lines(original_text, accounts_by_id)
|
|
||||||
|
|
||||||
|
|
||||||
def render_accounts_text_from_text(original_text: str, accounts: list[Account]) -> str:
|
|
||||||
accounts_by_id = {account.student_id: account for account in accounts}
|
|
||||||
return replace_account_lines(original_text, accounts_by_id)
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_lines(lines: list[str] | None = None, line: str | None = None) -> list[str]:
|
def normalize_lines(lines: list[str] | None = None, line: str | None = None) -> list[str]:
|
||||||
values: list[str] = []
|
values: list[str] = []
|
||||||
if line is not None:
|
if line is not None:
|
||||||
@@ -781,115 +732,6 @@ def register_payment_lines(
|
|||||||
return {"registered": len(registered), "lines": registered, "backup_id": backup_dir.name, "operation": "登记缴费记录"}
|
return {"registered": len(registered), "lines": registered, "backup_id": backup_dir.name, "operation": "登记缴费记录"}
|
||||||
|
|
||||||
|
|
||||||
def normalize_classnote_durations_and_accounts(
|
|
||||||
classnotes_path: Path,
|
|
||||||
accounts_path: Path,
|
|
||||||
operation_logs_path: Path | None = None,
|
|
||||||
) -> dict:
|
|
||||||
original_classnotes = classnotes_path.read_text(encoding="utf-8")
|
|
||||||
original_accounts = accounts_path.read_text(encoding="utf-8")
|
|
||||||
accounts = read_accounts(accounts_path)
|
|
||||||
updated_accounts = list(accounts)
|
|
||||||
account_deltas: defaultdict[str, float] = defaultdict(float)
|
|
||||||
changed_lines: list[str] = []
|
|
||||||
output_lines: list[str] = []
|
|
||||||
skipped_lines: list[str] = []
|
|
||||||
|
|
||||||
for line_number, raw_line in enumerate(original_classnotes.splitlines(), start=1):
|
|
||||||
stripped = raw_line.strip()
|
|
||||||
if not stripped or stripped.startswith("#"):
|
|
||||||
output_lines.append(raw_line)
|
|
||||||
continue
|
|
||||||
match = CLASSNOTE_RE.fullmatch(stripped)
|
|
||||||
if not match:
|
|
||||||
output_lines.append(raw_line)
|
|
||||||
skipped_lines.append(f"{line_number}: {stripped}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
old_minutes = int(round(parse_hours_text(match.group("duration")) * 60))
|
|
||||||
true_minutes = parse_time_range_minutes(match.group("time"))
|
|
||||||
true_duration = duration_text_from_minutes(true_minutes)
|
|
||||||
if true_minutes == old_minutes and match.group("duration") == true_duration:
|
|
||||||
output_lines.append(raw_line)
|
|
||||||
continue
|
|
||||||
|
|
||||||
student = canonical_name(match.group("student"))
|
|
||||||
account_index = find_account_index(updated_accounts, student)
|
|
||||||
delta_hours = round((old_minutes - true_minutes) / 60.0, 2)
|
|
||||||
if delta_hours:
|
|
||||||
updated_accounts[account_index] = update_account_remaining(updated_accounts[account_index], delta_hours)
|
|
||||||
account_deltas[updated_accounts[account_index].student_id] += delta_hours
|
|
||||||
|
|
||||||
new_line = (
|
|
||||||
f"{match.group('date')}-{match.group('weekday')}-{match.group('time')}-"
|
|
||||||
f"{student}-{true_duration}-{match.group('teacher').strip()}-{match.group('subject').strip()}"
|
|
||||||
)
|
|
||||||
output_lines.append(new_line)
|
|
||||||
changed_lines.append(f"{line_number}: {stripped} => {new_line}")
|
|
||||||
|
|
||||||
if not changed_lines:
|
|
||||||
return {
|
|
||||||
"updated_records": 0,
|
|
||||||
"updated_accounts": 0,
|
|
||||||
"account_deltas": {},
|
|
||||||
"skipped_lines": skipped_lines,
|
|
||||||
"backup_id": "",
|
|
||||||
}
|
|
||||||
|
|
||||||
trailing_newline = "\n" if original_classnotes.endswith("\n") else ""
|
|
||||||
new_classnotes = "\n".join(output_lines) + trailing_newline
|
|
||||||
changed_account_ids = {account_id for account_id, delta in account_deltas.items() if round(delta, 2)}
|
|
||||||
updated_accounts_by_id = {
|
|
||||||
account.student_id: account
|
|
||||||
for account in updated_accounts
|
|
||||||
if account.student_id in changed_account_ids
|
|
||||||
}
|
|
||||||
new_accounts = replace_account_lines(original_accounts, updated_accounts_by_id)
|
|
||||||
backup_dir = create_data_backup(
|
|
||||||
"normalize-classnote-durations",
|
|
||||||
{
|
|
||||||
accounts_path: original_accounts,
|
|
||||||
classnotes_path: original_classnotes,
|
|
||||||
},
|
|
||||||
changed_lines,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
atomic_write_text(classnotes_path, new_classnotes)
|
|
||||||
atomic_write_text(accounts_path, new_accounts)
|
|
||||||
except Exception:
|
|
||||||
atomic_write_text(classnotes_path, original_classnotes)
|
|
||||||
atomic_write_text(accounts_path, original_accounts)
|
|
||||||
raise
|
|
||||||
try:
|
|
||||||
prune_data_backups(backup_dir.parent)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
result = {
|
|
||||||
"updated_records": len(changed_lines),
|
|
||||||
"updated_accounts": len(updated_accounts_by_id),
|
|
||||||
"account_deltas": {
|
|
||||||
account_id: round(delta, 2)
|
|
||||||
for account_id, delta in sorted(account_deltas.items())
|
|
||||||
if round(delta, 2)
|
|
||||||
},
|
|
||||||
"skipped_lines": skipped_lines,
|
|
||||||
"backup_id": backup_dir.name,
|
|
||||||
}
|
|
||||||
if operation_logs_path is not None:
|
|
||||||
append_operation_log(
|
|
||||||
operation_logs_path,
|
|
||||||
"历史上课记录真实时长迁移",
|
|
||||||
"完成",
|
|
||||||
backup_id=backup_dir.name,
|
|
||||||
updated_records=result["updated_records"],
|
|
||||||
updated_accounts=result["updated_accounts"],
|
|
||||||
account_deltas=result["account_deltas"],
|
|
||||||
skipped_lines_count=len(skipped_lines),
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def default_admin_tasks() -> dict:
|
def default_admin_tasks() -> dict:
|
||||||
return {"version": 1, "next_id": 1, "items": []}
|
return {"version": 1, "next_id": 1, "items": []}
|
||||||
|
|
||||||
@@ -4813,28 +4655,6 @@ def has_filter_condition(spec: QuerySpec) -> bool:
|
|||||||
return bool(spec.start_date or spec.end_date or spec.students or spec.teachers or spec.subjects)
|
return bool(spec.start_date or spec.end_date or spec.students or spec.teachers or spec.subjects)
|
||||||
|
|
||||||
|
|
||||||
def query_records(records: list[ClassRecord], query: str, limit: int = 200) -> dict:
|
|
||||||
spec = build_query_spec(query, records)
|
|
||||||
matched = filter_records(records, spec) if has_filter_condition(spec) else []
|
|
||||||
shown, normalized_offset, has_more = paginate_items(matched, 0, limit)
|
|
||||||
return {
|
|
||||||
"query": {
|
|
||||||
"raw_query": spec.raw_query,
|
|
||||||
"date_range": format_date_range(spec),
|
|
||||||
"students": spec.students,
|
|
||||||
"teachers": spec.teachers,
|
|
||||||
"subjects": spec.subjects,
|
|
||||||
},
|
|
||||||
"summary": summarize_records(matched),
|
|
||||||
"records": [record_to_dict(record) for record in shown],
|
|
||||||
"total_records": len(matched),
|
|
||||||
"shown_records": len(shown),
|
|
||||||
"offset": normalized_offset,
|
|
||||||
"limit": limit,
|
|
||||||
"has_more": has_more,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def query_public_records(
|
def query_public_records(
|
||||||
records: list[ClassRecord],
|
records: list[ClassRecord],
|
||||||
teachers: list[Teacher],
|
teachers: list[Teacher],
|
||||||
|
|||||||
+2
-2
@@ -6,7 +6,7 @@ from fastapi.responses import JSONResponse
|
|||||||
from .config import OPERATION_LOGS_PATH, USE_SQLITE_SOURCE, write_lock
|
from .config import OPERATION_LOGS_PATH, USE_SQLITE_SOURCE, write_lock
|
||||||
from .data import migrate_operation_log_labels
|
from .data import migrate_operation_log_labels
|
||||||
from .repository import ensure_runtime_cache
|
from .repository import ensure_runtime_cache
|
||||||
from .routers import accounts, admin, ai_register, health, ingest, pages, records
|
from .routers import admin, ai_register, health, ingest, pages, records, student_profiles
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="新时空教务管理系统", version="1.0.0")
|
app = FastAPI(title="新时空教务管理系统", version="1.0.0")
|
||||||
@@ -28,7 +28,7 @@ async def value_error_handler(_request: Request, exc: ValueError):
|
|||||||
app.include_router(pages.router)
|
app.include_router(pages.router)
|
||||||
app.include_router(health.router)
|
app.include_router(health.router)
|
||||||
app.include_router(records.router)
|
app.include_router(records.router)
|
||||||
app.include_router(accounts.router)
|
app.include_router(student_profiles.router)
|
||||||
app.include_router(admin.router)
|
app.include_router(admin.router)
|
||||||
app.include_router(ai_register.router)
|
app.include_router(ai_register.router)
|
||||||
app.include_router(ingest.router)
|
app.include_router(ingest.router)
|
||||||
|
|||||||
+20
-37
@@ -71,34 +71,6 @@ def _record_key(record: ClassRecord) -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _class_record_to_line(record: ClassRecord) -> str:
|
|
||||||
return f"{record.date}-{record.weekday}-{record.time}-{record.student}-{record.duration}-{record.teacher}-{record.subject}"
|
|
||||||
|
|
||||||
|
|
||||||
def _format_number(value: float) -> str:
|
|
||||||
if float(value).is_integer():
|
|
||||||
return str(int(value))
|
|
||||||
return f"{value:.2f}".rstrip("0").rstrip(".")
|
|
||||||
|
|
||||||
|
|
||||||
def _format_payment(payment: Payment) -> str:
|
|
||||||
return f"{payment.date}:{_format_number(payment.hours)}"
|
|
||||||
|
|
||||||
|
|
||||||
def _account_status(stored_status: str, remaining: float) -> str:
|
|
||||||
if stored_status in {"结课", "退费"}:
|
|
||||||
return stored_status
|
|
||||||
if remaining < 0:
|
|
||||||
return "欠费"
|
|
||||||
if remaining < 10:
|
|
||||||
return "预警"
|
|
||||||
return "正常"
|
|
||||||
|
|
||||||
|
|
||||||
def _duration_text_from_minutes(minutes: int) -> str:
|
|
||||||
return f"{minutes // 60}小时{minutes % 60}分"
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_course_summary_blocks(root: Path) -> list[dict]:
|
def _parse_course_summary_blocks(root: Path) -> list[dict]:
|
||||||
from .data import iter_course_summary_markdown
|
from .data import iter_course_summary_markdown
|
||||||
|
|
||||||
@@ -160,6 +132,8 @@ def _insert_sources(
|
|||||||
*,
|
*,
|
||||||
allow_balance_adjustments: bool,
|
allow_balance_adjustments: bool,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
|
from .data import class_record_to_line
|
||||||
|
|
||||||
accounts: list[Account] = sources["accounts"]
|
accounts: list[Account] = sources["accounts"]
|
||||||
records: list[ClassRecord] = sources["records"]
|
records: list[ClassRecord] = sources["records"]
|
||||||
teachers: list[Teacher] = sources["teachers"]
|
teachers: list[Teacher] = sources["teachers"]
|
||||||
@@ -206,7 +180,7 @@ def _insert_sources(
|
|||||||
for index, record in enumerate(records):
|
for index, record in enumerate(records):
|
||||||
key = _record_key(record)
|
key = _record_key(record)
|
||||||
if key in seen_record_keys:
|
if key in seen_record_keys:
|
||||||
raise ValueError(f"课程记录重复: {_class_record_to_line(record)}")
|
raise ValueError(f"课程记录重复: {class_record_to_line(record)}")
|
||||||
seen_record_keys.add(key)
|
seen_record_keys.add(key)
|
||||||
account = account_by_student.get(record.student)
|
account = account_by_student.get(record.student)
|
||||||
if account is None:
|
if account is None:
|
||||||
@@ -229,7 +203,7 @@ def _insert_sources(
|
|||||||
record.student,
|
record.student,
|
||||||
record.teacher,
|
record.teacher,
|
||||||
record.subject,
|
record.subject,
|
||||||
_class_record_to_line(record),
|
class_record_to_line(record),
|
||||||
index,
|
index,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -472,7 +446,19 @@ def _accounts_from_db(conn: sqlite3.Connection) -> list[Account]:
|
|||||||
payments = payment_map.get(student_id, [])
|
payments = payment_map.get(student_id, [])
|
||||||
paid = round(sum(payment.hours for payment in payments), 2)
|
paid = round(sum(payment.hours for payment in payments), 2)
|
||||||
remaining = round(paid - used_map.get(student_id, 0.0), 2)
|
remaining = round(paid - used_map.get(student_id, 0.0), 2)
|
||||||
status = _account_status(str(row["account_status"]), remaining)
|
from .data import recalc_account_status
|
||||||
|
|
||||||
|
status = recalc_account_status(
|
||||||
|
Account(
|
||||||
|
student_id=student_id,
|
||||||
|
student=str(row["student"]),
|
||||||
|
payments=payments,
|
||||||
|
remaining=remaining,
|
||||||
|
account_status=str(row["account_status"]),
|
||||||
|
primary_entry_year=row["primary_entry_year"],
|
||||||
|
note=str(row["note"] or ""),
|
||||||
|
)
|
||||||
|
)
|
||||||
accounts.append(
|
accounts.append(
|
||||||
Account(
|
Account(
|
||||||
student_id=student_id,
|
student_id=student_id,
|
||||||
@@ -543,14 +529,11 @@ def _summary_state_from_db(conn: sqlite3.Connection) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def _write_accounts(path: Path, accounts: list[Account]) -> None:
|
def _write_accounts(path: Path, accounts: list[Account]) -> None:
|
||||||
|
from .data import format_account_row
|
||||||
|
|
||||||
lines = [ACCOUNTS_HEADER.rstrip("\n")]
|
lines = [ACCOUNTS_HEADER.rstrip("\n")]
|
||||||
for account in accounts:
|
for account in accounts:
|
||||||
payments = ",".join(_format_payment(payment) for payment in account.payments)
|
lines.append(format_account_row(account))
|
||||||
primary_entry_year = str(account.primary_entry_year or "")
|
|
||||||
lines.append(
|
|
||||||
f"| {account.student_id} | {account.student} | {primary_entry_year} | {payments} | "
|
|
||||||
f"{_format_number(account.remaining)} | {account.account_status} | {account.note} |"
|
|
||||||
)
|
|
||||||
atomic_write_text(path, "\n".join(lines).rstrip() + "\n")
|
atomic_write_text(path, "\n".join(lines).rstrip() + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
from ..api_utils import file_meta, load_accounts, load_records, load_teachers
|
from ..api_utils import file_meta, load_records, load_student_profiles, load_teachers
|
||||||
from ..auth import verify_records_auth
|
from ..auth import verify_records_auth
|
||||||
from ..config import (
|
from ..config import (
|
||||||
ACCOUNTS_PATH,
|
ACCOUNTS_PATH,
|
||||||
@@ -24,7 +24,7 @@ router = APIRouter()
|
|||||||
@router.get("/api/health")
|
@router.get("/api/health")
|
||||||
def health(_user: str = Depends(verify_records_auth)):
|
def health(_user: str = Depends(verify_records_auth)):
|
||||||
records = load_records()
|
records = load_records()
|
||||||
accounts = load_accounts()
|
accounts = load_student_profiles()
|
||||||
teachers = load_teachers()
|
teachers = load_teachers()
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
|
|||||||
@@ -4,8 +4,15 @@ from datetime import date
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
|
|
||||||
from ..api_utils import file_meta, load_accounts, load_teachers, payload_to_account, payload_to_teacher, read_register_payload
|
from ..api_utils import (
|
||||||
from ..auth import verify_accounts_auth, verify_admin_auth
|
file_meta,
|
||||||
|
load_student_profiles,
|
||||||
|
load_teachers,
|
||||||
|
payload_to_student_profile,
|
||||||
|
payload_to_teacher,
|
||||||
|
read_register_payload,
|
||||||
|
)
|
||||||
|
from ..auth import verify_admin_auth, verify_student_profiles_auth
|
||||||
from ..config import (
|
from ..config import (
|
||||||
ACCOUNTS_PATH,
|
ACCOUNTS_PATH,
|
||||||
ADMIN_TASKS_PATH,
|
ADMIN_TASKS_PATH,
|
||||||
@@ -23,13 +30,13 @@ from ..data import (
|
|||||||
ACCOUNT_STATUSES,
|
ACCOUNT_STATUSES,
|
||||||
TEACHER_STATUSES,
|
TEACHER_STATUSES,
|
||||||
DuplicateRecordError,
|
DuplicateRecordError,
|
||||||
account_summary,
|
account_summary as student_profile_summary,
|
||||||
account_to_dict,
|
account_to_dict as student_profile_to_dict,
|
||||||
append_operation_log,
|
append_operation_log,
|
||||||
create_account,
|
create_account as create_student_profile,
|
||||||
create_teacher,
|
create_teacher,
|
||||||
duration_text_from_hours,
|
duration_text_from_hours,
|
||||||
filter_accounts,
|
filter_accounts as filter_student_profiles,
|
||||||
iter_course_summary_markdown,
|
iter_course_summary_markdown,
|
||||||
parse_class_record_line,
|
parse_class_record_line,
|
||||||
read_classnotes,
|
read_classnotes,
|
||||||
@@ -37,10 +44,10 @@ from ..data import (
|
|||||||
register_course_summary_texts,
|
register_course_summary_texts,
|
||||||
register_payment_lines,
|
register_payment_lines,
|
||||||
teacher_to_dict,
|
teacher_to_dict,
|
||||||
update_account,
|
update_account as update_student_profile,
|
||||||
update_teacher,
|
update_teacher,
|
||||||
)
|
)
|
||||||
from ..schemas import AccountPayload, TeacherPayload
|
from ..schemas import StudentProfilePayload, TeacherPayload
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -121,8 +128,8 @@ async def register_course_summaries(request: Request, _user: str = Depends(verif
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/api/student-health")
|
@router.get("/api/student-health")
|
||||||
def student_health(_user: str = Depends(verify_accounts_auth)):
|
def student_health(_user: str = Depends(verify_student_profiles_auth)):
|
||||||
accounts = load_accounts()
|
student_profiles = load_student_profiles()
|
||||||
teachers = load_teachers()
|
teachers = load_teachers()
|
||||||
records = read_classnotes(CLASSNOTES_PATH) if CLASSNOTES_PATH.exists() else []
|
records = read_classnotes(CLASSNOTES_PATH) if CLASSNOTES_PATH.exists() else []
|
||||||
current_month_prefix = date.today().strftime("%Y.%m.")
|
current_month_prefix = date.today().strftime("%Y.%m.")
|
||||||
@@ -142,14 +149,14 @@ def student_health(_user: str = Depends(verify_accounts_auth)):
|
|||||||
"source_mode": "sqlite" if USE_SQLITE_SOURCE else "text",
|
"source_mode": "sqlite" if USE_SQLITE_SOURCE else "text",
|
||||||
"text_root": str(LEGACY_TEXT_ROOT),
|
"text_root": str(LEGACY_TEXT_ROOT),
|
||||||
},
|
},
|
||||||
"students_count": len(accounts),
|
"students_count": len(student_profiles),
|
||||||
"teachers_count": len(teachers),
|
"teachers_count": len(teachers),
|
||||||
"active_teachers_count": sum(1 for teacher in teachers if teacher.status == "在岗"),
|
"active_teachers_count": sum(1 for teacher in teachers if teacher.status == "在岗"),
|
||||||
"records_count": len(records),
|
"records_count": len(records),
|
||||||
"course_summaries_count": course_summaries_count,
|
"course_summaries_count": course_summaries_count,
|
||||||
"current_month_hours": current_month_hours,
|
"current_month_hours": current_month_hours,
|
||||||
"current_month_duration": compact_duration_text(current_month_hours),
|
"current_month_duration": compact_duration_text(current_month_hours),
|
||||||
"student_summary": account_summary(accounts),
|
"student_summary": student_profile_summary(student_profiles),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -157,22 +164,22 @@ def student_health(_user: str = Depends(verify_accounts_auth)):
|
|||||||
def students(
|
def students(
|
||||||
q: str = Query("", description="学生姓名或学生ID"),
|
q: str = Query("", description="学生姓名或学生ID"),
|
||||||
status_filter: str = Query("", alias="status", description="学生档案状态"),
|
status_filter: str = Query("", alias="status", description="学生档案状态"),
|
||||||
_user: str = Depends(verify_accounts_auth),
|
_user: str = Depends(verify_student_profiles_auth),
|
||||||
):
|
):
|
||||||
all_accounts = load_accounts()
|
all_student_profiles = load_student_profiles()
|
||||||
rows = filter_accounts(all_accounts, keyword=q, status=status_filter)
|
rows = filter_student_profiles(all_student_profiles, keyword=q, status=status_filter)
|
||||||
return {
|
return {
|
||||||
"summary": account_summary(all_accounts),
|
"summary": student_profile_summary(all_student_profiles),
|
||||||
"count": len(rows),
|
"count": len(rows),
|
||||||
"students": [account_to_dict(account) for account in rows],
|
"students": [student_profile_to_dict(student_profile) for student_profile in rows],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/students/{student}")
|
@router.get("/api/students/{student}")
|
||||||
def student_detail(student: str, _user: str = Depends(verify_accounts_auth)):
|
def student_detail(student: str, _user: str = Depends(verify_student_profiles_auth)):
|
||||||
for account in load_accounts():
|
for student_profile in load_student_profiles():
|
||||||
if account.student == student or account.student_id == student:
|
if student_profile.student == student or student_profile.student_id == student:
|
||||||
return account_to_dict(account)
|
return student_profile_to_dict(student_profile)
|
||||||
raise HTTPException(status_code=404, detail=f"未找到学生档案: {student}")
|
raise HTTPException(status_code=404, detail=f"未找到学生档案: {student}")
|
||||||
|
|
||||||
|
|
||||||
@@ -236,10 +243,10 @@ def admin_update_teacher(teacher_id: str, payload: TeacherPayload, _user: str =
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/api/admin/students")
|
@router.post("/api/admin/students")
|
||||||
def admin_create_student(payload: AccountPayload, _user: str = Depends(verify_admin_auth)):
|
def admin_create_student(payload: StudentProfilePayload, _user: str = Depends(verify_admin_auth)):
|
||||||
try:
|
try:
|
||||||
with write_lock:
|
with write_lock:
|
||||||
result = create_account(ACCOUNTS_PATH, payload_to_account(payload), CLASSNOTES_PATH)
|
result = create_student_profile(ACCOUNTS_PATH, payload_to_student_profile(payload), CLASSNOTES_PATH)
|
||||||
log_operation = str(result.get("operation") or "新增学生档案")
|
log_operation = str(result.get("operation") or "新增学生档案")
|
||||||
append_operation_log(
|
append_operation_log(
|
||||||
OPERATION_LOGS_PATH,
|
OPERATION_LOGS_PATH,
|
||||||
@@ -255,10 +262,10 @@ def admin_create_student(payload: AccountPayload, _user: str = Depends(verify_ad
|
|||||||
|
|
||||||
|
|
||||||
@router.put("/api/admin/students/{student_id}")
|
@router.put("/api/admin/students/{student_id}")
|
||||||
def admin_update_student(student_id: str, payload: AccountPayload, _user: str = Depends(verify_admin_auth)):
|
def admin_update_student(student_id: str, payload: StudentProfilePayload, _user: str = Depends(verify_admin_auth)):
|
||||||
try:
|
try:
|
||||||
with write_lock:
|
with write_lock:
|
||||||
result = update_account(ACCOUNTS_PATH, student_id, payload_to_account(payload), CLASSNOTES_PATH)
|
result = update_student_profile(ACCOUNTS_PATH, student_id, payload_to_student_profile(payload), CLASSNOTES_PATH)
|
||||||
log_operation = str(result.get("operation") or "修改学生档案")
|
log_operation = str(result.get("operation") or "修改学生档案")
|
||||||
append_operation_log(
|
append_operation_log(
|
||||||
OPERATION_LOGS_PATH,
|
OPERATION_LOGS_PATH,
|
||||||
+1
-1
@@ -21,7 +21,7 @@ class PaymentPayload(BaseModel):
|
|||||||
hours: float
|
hours: float
|
||||||
|
|
||||||
|
|
||||||
class AccountPayload(BaseModel):
|
class StudentProfilePayload(BaseModel):
|
||||||
student_id: str = ""
|
student_id: str = ""
|
||||||
student: str
|
student: str
|
||||||
payments: list[PaymentPayload] = Field(default_factory=list)
|
payments: list[PaymentPayload] = Field(default_factory=list)
|
||||||
|
|||||||
@@ -238,8 +238,6 @@
|
|||||||
<div id="reviewPager" class="pager" hidden></div>
|
<div id="reviewPager" class="pager" hidden></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="summariesPanel" class="panel admin-panel" hidden></section>
|
|
||||||
|
|
||||||
<div id="summaryReviewDrawerBackdrop" class="drawer-backdrop" hidden>
|
<div id="summaryReviewDrawerBackdrop" class="drawer-backdrop" hidden>
|
||||||
<aside class="summary-review-drawer" role="dialog" aria-modal="true" aria-labelledby="summaryReviewDrawerTitle">
|
<aside class="summary-review-drawer" role="dialog" aria-modal="true" aria-labelledby="summaryReviewDrawerTitle">
|
||||||
<div class="drawer-head">
|
<div class="drawer-head">
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ const panels = {
|
|||||||
students: document.querySelector("#studentsPanel"),
|
students: document.querySelector("#studentsPanel"),
|
||||||
teachers: document.querySelector("#teachersPanel"),
|
teachers: document.querySelector("#teachersPanel"),
|
||||||
reviews: document.querySelector("#reviewsPanel"),
|
reviews: document.querySelector("#reviewsPanel"),
|
||||||
summaries: document.querySelector("#summariesPanel"),
|
|
||||||
summarySearch: document.querySelector("#summarySearchPanel"),
|
summarySearch: document.querySelector("#summarySearchPanel"),
|
||||||
logs: document.querySelector("#logsPanel"),
|
logs: document.querySelector("#logsPanel"),
|
||||||
register: document.querySelector("#registerPanel"),
|
register: document.querySelector("#registerPanel"),
|
||||||
@@ -476,7 +475,7 @@ async function fetchJson(url, options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setActiveTab(tabName) {
|
function setActiveTab(tabName) {
|
||||||
const activeTabName = tabName === "summaries" ? "summarySearch" : tabName;
|
const activeTabName = tabName;
|
||||||
let activeButton = null;
|
let activeButton = null;
|
||||||
document.querySelectorAll("[data-admin-tab]").forEach((button) => {
|
document.querySelectorAll("[data-admin-tab]").forEach((button) => {
|
||||||
const isActive = button.dataset.adminTab === activeTabName;
|
const isActive = button.dataset.adminTab === activeTabName;
|
||||||
|
|||||||
@@ -1286,7 +1286,6 @@ table {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.admin-panel table,
|
.admin-panel table,
|
||||||
.accounts-panel table,
|
|
||||||
.records-panel table {
|
.records-panel table {
|
||||||
min-width: 820px;
|
min-width: 820px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>Label</key>
|
|
||||||
<string>com.xsk.education-management.sync</string>
|
|
||||||
<key>ProgramArguments</key>
|
|
||||||
<array>
|
|
||||||
<string>/usr/bin/python3</string>
|
|
||||||
<string>/Users/yangdawei/Desktop/新时空业务源数据/tools/xsk-education-management/scripts/sync_to_vps.py</string>
|
|
||||||
</array>
|
|
||||||
<key>RunAtLoad</key>
|
|
||||||
<true/>
|
|
||||||
<key>KeepAlive</key>
|
|
||||||
<true/>
|
|
||||||
<key>StandardOutPath</key>
|
|
||||||
<string>/Users/yangdawei/Library/Logs/xsk-education-management/sync.log</string>
|
|
||||||
<key>StandardErrorPath</key>
|
|
||||||
<string>/Users/yangdawei/Library/Logs/xsk-education-management/sync.err.log</string>
|
|
||||||
<key>EnvironmentVariables</key>
|
|
||||||
<dict>
|
|
||||||
<key>PATH</key>
|
|
||||||
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
import plistlib
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
|
|
||||||
PROJECT_DIR = Path(__file__).resolve().parents[1]
|
|
||||||
LABEL = "com.xsk.education-management.sync"
|
|
||||||
OLD_LABEL = "com.xsk.records.sync"
|
|
||||||
PLIST_PATH = Path.home() / "Library" / "LaunchAgents" / f"{LABEL}.plist"
|
|
||||||
OLD_PLIST_PATH = Path.home() / "Library" / "LaunchAgents" / f"{OLD_LABEL}.plist"
|
|
||||||
LOG_DIR = Path.home() / "Library" / "Logs" / "xsk-education-management"
|
|
||||||
|
|
||||||
|
|
||||||
def parse_args() -> argparse.Namespace:
|
|
||||||
parser = argparse.ArgumentParser(description="安装新时空教务管理系统同步 LaunchAgent")
|
|
||||||
parser.add_argument("--use-sshpass", action="store_true", default=os.getenv("XSK_USE_SSHPASS", "") == "1")
|
|
||||||
parser.add_argument("--ssh-password", default=os.getenv("XSK_SSH_PASSWORD"))
|
|
||||||
return parser.parse_args()
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
args = parse_args()
|
|
||||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
PLIST_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
program_arguments = [
|
|
||||||
"/usr/bin/python3",
|
|
||||||
str(PROJECT_DIR / "scripts" / "sync_to_vps.py"),
|
|
||||||
]
|
|
||||||
if args.use_sshpass:
|
|
||||||
program_arguments.append("--use-sshpass")
|
|
||||||
|
|
||||||
environment = {
|
|
||||||
"PATH": "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin",
|
|
||||||
}
|
|
||||||
if args.use_sshpass and args.ssh_password:
|
|
||||||
environment["XSK_SSH_PASSWORD"] = args.ssh_password
|
|
||||||
|
|
||||||
plist = {
|
|
||||||
"Label": LABEL,
|
|
||||||
"ProgramArguments": program_arguments,
|
|
||||||
"RunAtLoad": True,
|
|
||||||
"KeepAlive": True,
|
|
||||||
"StandardOutPath": str(LOG_DIR / "sync.log"),
|
|
||||||
"StandardErrorPath": str(LOG_DIR / "sync.err.log"),
|
|
||||||
"EnvironmentVariables": environment,
|
|
||||||
}
|
|
||||||
with PLIST_PATH.open("wb") as handle:
|
|
||||||
plistlib.dump(plist, handle)
|
|
||||||
|
|
||||||
subprocess.run(["launchctl", "unload", str(OLD_PLIST_PATH)], check=False, capture_output=True)
|
|
||||||
OLD_PLIST_PATH.unlink(missing_ok=True)
|
|
||||||
subprocess.run(["launchctl", "unload", str(PLIST_PATH)], check=False, capture_output=True)
|
|
||||||
subprocess.run(["launchctl", "load", str(PLIST_PATH)], check=True)
|
|
||||||
print(f"已安装并启动 LaunchAgent: {PLIST_PATH}")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
import shlex
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_LOCAL_DIR = Path("/Users/yangdawei/Desktop/新时空业务源数据/新时空课程记录与课时账户")
|
|
||||||
DEFAULT_REMOTE_HOST = "121.199.172.246"
|
|
||||||
DEFAULT_REMOTE_USER = "root"
|
|
||||||
DEFAULT_REMOTE_PORT = 22222
|
|
||||||
DEFAULT_REMOTE_DIR = "/root/新时空教务管理系统"
|
|
||||||
DEFAULT_IDENTITY_FILE = Path.home() / ".ssh" / "xsk_records_vps_ed25519"
|
|
||||||
SYNC_FILES = ("classnotes.txt", "学生课时账户.md")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class Config:
|
|
||||||
local_dir: Path
|
|
||||||
remote_host: str
|
|
||||||
remote_user: str
|
|
||||||
remote_port: int
|
|
||||||
remote_dir: str
|
|
||||||
identity_file: Path | None
|
|
||||||
interval: float
|
|
||||||
use_sshpass: bool
|
|
||||||
|
|
||||||
|
|
||||||
def parse_args() -> argparse.Namespace:
|
|
||||||
parser = argparse.ArgumentParser(description="同步新时空课程记录数据到 VPS")
|
|
||||||
parser.add_argument("--once", action="store_true", help="只同步一次后退出")
|
|
||||||
parser.add_argument("--interval", type=float, default=float(os.getenv("XSK_SYNC_INTERVAL", "3")))
|
|
||||||
parser.add_argument("--local-dir", default=os.getenv("XSK_LOCAL_DIR", str(DEFAULT_LOCAL_DIR)))
|
|
||||||
parser.add_argument("--remote-host", default=os.getenv("XSK_REMOTE_HOST", DEFAULT_REMOTE_HOST))
|
|
||||||
parser.add_argument("--remote-user", default=os.getenv("XSK_REMOTE_USER", DEFAULT_REMOTE_USER))
|
|
||||||
parser.add_argument("--remote-port", type=int, default=int(os.getenv("XSK_REMOTE_PORT", str(DEFAULT_REMOTE_PORT))))
|
|
||||||
parser.add_argument("--remote-dir", default=os.getenv("XSK_REMOTE_DIR", DEFAULT_REMOTE_DIR))
|
|
||||||
parser.add_argument(
|
|
||||||
"--identity-file",
|
|
||||||
default=os.getenv("XSK_IDENTITY_FILE", str(DEFAULT_IDENTITY_FILE) if DEFAULT_IDENTITY_FILE.exists() else ""),
|
|
||||||
help="SSH 私钥路径;默认兼容使用历史文件 ~/.ssh/xsk_records_vps_ed25519(如果存在)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--use-sshpass",
|
|
||||||
action="store_true",
|
|
||||||
default=os.getenv("XSK_USE_SSHPASS", "") == "1",
|
|
||||||
help="从 XSK_SSH_PASSWORD 读取密码并通过 sshpass 连接;推荐改用 SSH key",
|
|
||||||
)
|
|
||||||
return parser.parse_args()
|
|
||||||
|
|
||||||
|
|
||||||
def log(message: str) -> None:
|
|
||||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
||||||
print(f"[{timestamp}] {message}", flush=True)
|
|
||||||
|
|
||||||
|
|
||||||
def run_command(command: list[str]) -> subprocess.CompletedProcess[str]:
|
|
||||||
env = os.environ.copy()
|
|
||||||
if env.get("XSK_SSH_PASSWORD") and not env.get("SSHPASS"):
|
|
||||||
env["SSHPASS"] = env["XSK_SSH_PASSWORD"]
|
|
||||||
result = subprocess.run(command, text=True, capture_output=True, check=False, env=env)
|
|
||||||
if result.returncode != 0:
|
|
||||||
safe_command = " ".join(shlex.quote(part) for part in command if part != os.getenv("XSK_SSH_PASSWORD", ""))
|
|
||||||
raise RuntimeError(
|
|
||||||
f"命令失败({result.returncode}): {safe_command}\n"
|
|
||||||
f"STDOUT: {result.stdout.strip()}\nSTDERR: {result.stderr.strip()}"
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def base_remote(config: Config) -> str:
|
|
||||||
return f"{config.remote_user}@{config.remote_host}"
|
|
||||||
|
|
||||||
|
|
||||||
def ssh_options(config: Config) -> list[str]:
|
|
||||||
options = [
|
|
||||||
"-p",
|
|
||||||
str(config.remote_port),
|
|
||||||
"-o",
|
|
||||||
"StrictHostKeyChecking=accept-new",
|
|
||||||
]
|
|
||||||
if config.identity_file:
|
|
||||||
options.extend(["-i", str(config.identity_file), "-o", "IdentitiesOnly=yes"])
|
|
||||||
return options
|
|
||||||
|
|
||||||
|
|
||||||
def ssh_prefix(config: Config) -> list[str]:
|
|
||||||
command: list[str] = []
|
|
||||||
if config.use_sshpass:
|
|
||||||
password = os.getenv("XSK_SSH_PASSWORD")
|
|
||||||
if not password:
|
|
||||||
raise RuntimeError("启用 --use-sshpass 时必须设置 XSK_SSH_PASSWORD")
|
|
||||||
command.extend(["sshpass", "-e"])
|
|
||||||
command.extend(["ssh", *ssh_options(config), base_remote(config)])
|
|
||||||
return command
|
|
||||||
|
|
||||||
|
|
||||||
def rsync_prefix(config: Config) -> list[str]:
|
|
||||||
command: list[str] = []
|
|
||||||
if config.use_sshpass:
|
|
||||||
password = os.getenv("XSK_SSH_PASSWORD")
|
|
||||||
if not password:
|
|
||||||
raise RuntimeError("启用 --use-sshpass 时必须设置 XSK_SSH_PASSWORD")
|
|
||||||
command.extend(["sshpass", "-e"])
|
|
||||||
ssh_command = " ".join(shlex.quote(part) for part in ["ssh", *ssh_options(config)])
|
|
||||||
command.extend(
|
|
||||||
[
|
|
||||||
"rsync",
|
|
||||||
"-az",
|
|
||||||
"-e",
|
|
||||||
ssh_command,
|
|
||||||
]
|
|
||||||
)
|
|
||||||
return command
|
|
||||||
|
|
||||||
|
|
||||||
def remote_shell_quote(value: str) -> str:
|
|
||||||
return shlex.quote(value)
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_remote_dirs(config: Config) -> None:
|
|
||||||
data_dir = f"{config.remote_dir.rstrip('/')}/data"
|
|
||||||
run_command(ssh_prefix(config) + [f"mkdir -p {remote_shell_quote(data_dir)}"])
|
|
||||||
|
|
||||||
|
|
||||||
def sync_once(config: Config) -> None:
|
|
||||||
ensure_remote_dirs(config)
|
|
||||||
data_dir = f"{config.remote_dir.rstrip('/')}/data"
|
|
||||||
for filename in SYNC_FILES:
|
|
||||||
source = config.local_dir / filename
|
|
||||||
if not source.exists():
|
|
||||||
raise FileNotFoundError(f"本地文件不存在: {source}")
|
|
||||||
temp_name = f".{filename}.tmp"
|
|
||||||
remote_temp = f"{base_remote(config)}:{data_dir}/{temp_name}"
|
|
||||||
run_command(rsync_prefix(config) + [str(source), remote_temp])
|
|
||||||
run_command(
|
|
||||||
ssh_prefix(config)
|
|
||||||
+ [
|
|
||||||
"mv "
|
|
||||||
f"{remote_shell_quote(data_dir + '/' + temp_name)} "
|
|
||||||
f"{remote_shell_quote(data_dir + '/' + filename)}"
|
|
||||||
]
|
|
||||||
)
|
|
||||||
log(f"已同步 {source.name}")
|
|
||||||
|
|
||||||
|
|
||||||
def file_signature(path: Path) -> tuple[int, int]:
|
|
||||||
stat = path.stat()
|
|
||||||
return stat.st_mtime_ns, stat.st_size
|
|
||||||
|
|
||||||
|
|
||||||
def current_signatures(local_dir: Path) -> dict[str, tuple[int, int]]:
|
|
||||||
return {filename: file_signature(local_dir / filename) for filename in SYNC_FILES}
|
|
||||||
|
|
||||||
|
|
||||||
def watch(config: Config) -> None:
|
|
||||||
log("启动新时空教务管理系统数据同步监听")
|
|
||||||
signatures: dict[str, tuple[int, int]] = {}
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
next_signatures = current_signatures(config.local_dir)
|
|
||||||
if next_signatures != signatures:
|
|
||||||
time.sleep(0.4)
|
|
||||||
sync_once(config)
|
|
||||||
signatures = current_signatures(config.local_dir)
|
|
||||||
except Exception as exc:
|
|
||||||
log(f"同步失败: {exc}")
|
|
||||||
time.sleep(config.interval)
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
args = parse_args()
|
|
||||||
config = Config(
|
|
||||||
local_dir=Path(args.local_dir).expanduser(),
|
|
||||||
remote_host=args.remote_host,
|
|
||||||
remote_user=args.remote_user,
|
|
||||||
remote_port=args.remote_port,
|
|
||||||
remote_dir=args.remote_dir,
|
|
||||||
identity_file=Path(args.identity_file).expanduser() if args.identity_file else None,
|
|
||||||
interval=args.interval,
|
|
||||||
use_sshpass=args.use_sshpass,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
if args.once:
|
|
||||||
sync_once(config)
|
|
||||||
else:
|
|
||||||
watch(config)
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
return 130
|
|
||||||
except Exception as exc:
|
|
||||||
log(f"退出: {exc}")
|
|
||||||
return 1
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
Reference in New Issue
Block a user