chore: add maintenance workflow

This commit is contained in:
Codex
2026-06-13 23:26:51 +08:00
parent ad77a19fbd
commit 5f90abf6a8
4 changed files with 244 additions and 0 deletions
+98
View File
@@ -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` 或本文件补充维护说明。
+40
View File
@@ -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
+3
View File
@@ -5,8 +5,11 @@
## 目录 ## 目录
- `app/`:FastAPI 后端和内置前端页面。 - `app/`:FastAPI 后端和内置前端页面。
- `MAINTENANCE.md`:日常检查、部署、数据保护和回滚流程。
- `Makefile`:常用维护命令入口。
- `scripts/deploy_to_vps.py`:部署网页服务到 VPS。 - `scripts/deploy_to_vps.py`:部署网页服务到 VPS。
- `scripts/sync_to_vps.py`:同步正式数据文件到 VPS。 - `scripts/sync_to_vps.py`:同步正式数据文件到 VPS。
- `scripts/smoke_test.js`:轻量前端行为烟测。
- `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 模板。
+103
View File
@@ -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");