diff --git a/app/MAINTENANCE.md b/app/MAINTENANCE.md new file mode 100644 index 0000000..9ab923f --- /dev/null +++ b/app/MAINTENANCE.md @@ -0,0 +1,98 @@ +# 维护手册 + +本项目长期维护时,目标是每次更新都能做到可检查、可部署、可回滚,并且不误改业务数据。 + +## 日常更新流程 + +1. 查看当前改动: + + ```bash + git status --short + ``` + +2. 修改代码后先跑本地检查: + + ```bash + make check + make smoke + ``` + +3. 部署前记录业务数据哈希: + + ```bash + make data-hash + ``` + +4. 构建、重启、健康检查和部署后哈希复核: + + ```bash + make deploy + ``` + +5. 确认容器运行状态: + + ```bash + make ps + ``` + +## 常用命令 + +- `make check`:检查前端 JS 语法和后端 Python 编译。 +- `make smoke`:运行轻量前端行为烟测,覆盖排序按钮、组内排序和纠错后排序。 +- `make data-hash`:输出 `classnotes.txt` 和 `学生课时账户.md` 的 SHA-256。 +- `make build`:构建 Docker 镜像。 +- `make up`:重启 Docker Compose 服务。 +- `make health`:带认证访问 `/api/health`。 +- `make deploy`:按推荐顺序执行检查、构建、重启、健康检查和哈希复核。 +- `make logs`:查看最近服务日志。 + +## 数据保护 + +业务数据文件位于: + +```text +/root/新时空数据/data/classnotes.txt +/root/新时空数据/data/学生课时账户.md +``` + +普通前端和查询类改动不应该改变这两个文件。部署前后 `make data-hash` 输出应一致;如果涉及登记 API 或数据写入功能,先确认自动备份目录: + +```text +/root/新时空数据/data/backups/ +``` + +恢复备份时先停止服务,把备份文件复制回数据目录,再重新启动服务。 + +## 回滚建议 + +如果部署后页面异常: + +1. 查看容器和日志: + + ```bash + make ps + make logs + ``` + +2. 回到上一个可用 Git 版本后重新部署: + + ```bash + git status --short + git log --oneline -5 + ``` + +3. 重新执行: + + ```bash + make deploy + ``` + +如果业务数据哈希异常,先不要继续写入数据,优先从 `../data/backups/` 或外部备份恢复。 + +## 提交前检查清单 + +- `make check` 通过。 +- `make smoke` 通过。 +- 查询类或前端类改动部署前后业务数据哈希一致。 +- `make health` 正常返回 `ok: true`。 +- 重要功能改动已在 `README.md` 或本文件补充维护说明。 diff --git a/app/Makefile b/app/Makefile new file mode 100644 index 0000000..f1c989f --- /dev/null +++ b/app/Makefile @@ -0,0 +1,40 @@ +SHELL := /bin/bash + +APP_PORT ?= 18080 +DATA_FILES := ../data/classnotes.txt ../data/学生课时账户.md + +.PHONY: check smoke data-hash build up ps health deploy logs + +check: + node --check app/static/app.js + python3 -m py_compile app/main.py app/data.py + +smoke: + node scripts/smoke_test.js + +data-hash: + sha256sum $(DATA_FILES) + +build: + docker compose build + +up: + docker compose up -d + +ps: + docker compose ps + +health: + set -a; . ./.env; curl --fail --silent --show-error --retry 10 --retry-delay 1 --retry-connrefused --retry-all-errors -u "records:$${BASIC_AUTH_PASSWORD}" "http://127.0.0.1:$${APP_PORT:-$(APP_PORT)}/api/health"; echo + +deploy: check smoke + $(MAKE) --no-print-directory data-hash + $(MAKE) --no-print-directory build + $(MAKE) --no-print-directory up + sleep 2 + $(MAKE) --no-print-directory health + $(MAKE) --no-print-directory data-hash + $(MAKE) --no-print-directory ps + +logs: + docker compose logs --tail=120 xsk-records-web diff --git a/app/README.md b/app/README.md index 1133e1f..86c6c11 100644 --- a/app/README.md +++ b/app/README.md @@ -5,8 +5,11 @@ ## 目录 - `app/`:FastAPI 后端和内置前端页面。 +- `MAINTENANCE.md`:日常检查、部署、数据保护和回滚流程。 +- `Makefile`:常用维护命令入口。 - `scripts/deploy_to_vps.py`:部署网页服务到 VPS。 - `scripts/sync_to_vps.py`:同步正式数据文件到 VPS。 +- `scripts/smoke_test.js`:轻量前端行为烟测。 - `scripts/install_launch_agent.py`:安装 Mac 开机常驻同步任务。 - `launchd/com.xsk.records.sync.plist.template`:LaunchAgent 模板。 diff --git a/app/scripts/smoke_test.js b/app/scripts/smoke_test.js new file mode 100644 index 0000000..f774cd9 --- /dev/null +++ b/app/scripts/smoke_test.js @@ -0,0 +1,103 @@ +const fs = require("fs"); +const vm = require("vm"); + +const elements = new Map(); + +function element(id) { + if (!elements.has(id)) { + elements.set(id, { + id, + textContent: "", + innerHTML: "", + hidden: false, + disabled: false, + value: "", + dataset: {}, + classList: { toggle() {} }, + setAttribute(name, value) { + this[name] = value; + }, + addEventListener() {}, + focus() {}, + }); + } + return elements.get(id); +} + +const documentStub = { + querySelector(selector) { + return element(selector.replace(/^#/, "")); + }, + querySelectorAll() { + return []; + }, + addEventListener() {}, + createElement(tag) { + return element(`created-${tag}-${elements.size}`); + }, + body: { appendChild() {} }, + execCommand() { + return true; + }, +}; + +const context = { + console, + document: documentStub, + navigator: {}, + fetch: async () => ({ ok: true, json: async () => ({ classnotes: { mtime: 0 } }) }), + Date, + JSON, + Map, + Number, + String, +}; + +function assertEqual(name, actual, expected) { + if (actual !== expected) { + throw new Error(`${name}: got ${actual}, expected ${expected}`); + } +} + +function value(expression) { + return vm.runInContext(expression, context); +} + +vm.createContext(context); +vm.runInContext(fs.readFileSync("app/static/app.js", "utf8"), context); + +vm.runInContext( + ` +currentRecords = [ + { date: "2026.06.12", weekday: "星期五", time: "10:00-11:00", student: "甲", duration: "1小时0分", duration_hours: 1, teacher: "王老师", subject: "英语", _recordIndex: 0, _recordKey: "a" }, + { date: "2026.06.12", weekday: "星期五", time: "08:00-09:00", student: "乙", duration: "1小时0分", duration_hours: 1, teacher: "王老师", subject: "英语", _recordIndex: 1, _recordKey: "b" }, + { date: "2026.06.13", weekday: "星期六", time: "09:00-10:00", student: "丙", duration: "1小时0分", duration_hours: 1, teacher: "王老师", subject: "英语", _recordIndex: 2, _recordKey: "c" }, + { date: "2026.06.12", weekday: "星期五", time: "07:00-08:00", student: "丁", duration: "1小时0分", duration_hours: 1, teacher: "李老师", subject: "数学", _recordIndex: 3, _recordKey: "d" }, +]; +renderGroupedRecords(currentRecords); +`, + context, +); + +assertEqual("初始日期按钮文案", value("dateSortBtn.textContent"), "日期 升序排列"); +assertEqual("初始时间按钮文案", value("timeSortBtn.textContent"), "时间 升序排列"); +assertEqual("默认组内排序", value("currentRecordOrder.join('')"), "bacd"); + +vm.runInContext("toggleRecordSort('date')", context); +assertEqual("日期降序按钮文案", value("dateSortBtn.textContent"), "日期 降序排列"); +assertEqual("日期降序组内排序", value("currentRecordOrder.join('')"), "cbad"); + +vm.runInContext("toggleRecordSort('date'); toggleRecordSort('time')", context); +assertEqual("时间降序按钮文案", value("timeSortBtn.textContent"), "时间 降序排列"); +assertEqual("日期升序时间降序组内排序", value("currentRecordOrder.join('')"), "abcd"); + +vm.runInContext( + ` +correctedRecords.set("a", { ...currentRecords[0], date: "2026.06.11", weekday: "星期四", time: "07:00-08:00" }); +renderGroupedRecords(currentRecords); +`, + context, +); +assertEqual("纠错后排序仍使用显示记录", value("currentRecordOrder.join('')"), "abcd"); + +console.log("smoke test passed");