From c1c41760f5015d4f8f8b5b94cde641c0eb6ff5e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=80=BB=E5=B7=A5?= Date: Sun, 30 Aug 2026 22:03:58 +0800 Subject: [PATCH] =?UTF-8?q?HEL-270:=20=E9=AA=8C=E6=94=B6=E7=89=A9=E6=96=99?= =?UTF-8?q?=E2=80=94=E2=80=94=E9=9D=9Eroot=E5=8A=A0=E5=9B=BA=E3=80=81?= =?UTF-8?q?=E5=A4=87=E4=BB=BD/=E5=B7=A1=E6=A3=80=E8=84=9A=E6=9C=AC?= =?UTF-8?q?=E3=80=81=E8=BF=90=E7=BB=B4=E6=89=8B=E5=86=8C=E3=80=81=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20reminders=20=E5=B8=83=E5=B1=80=E5=86=92=E7=83=9F?= =?UTF-8?q?=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: multica-agent --- deploy/Dockerfile | 35 ++++++++++------- deploy/README.md | 75 ++++++++++++++++++++++++++++++++++++ deploy/backup.sh | 52 +++++++++++++++++++++++++ deploy/compose.yaml | 41 ++++++++++---------- deploy/deploy.sh | 21 ++++++---- deploy/monitor.sh | 32 +++++++++++++++ deploy/rollback.sh | 22 ++++++----- docs/任务清单.md | 6 +-- docs/最新进度.md | 7 ++-- tests/test_reminders_page.py | 7 +--- 10 files changed, 236 insertions(+), 62 deletions(-) create mode 100644 deploy/README.md create mode 100755 deploy/backup.sh create mode 100755 deploy/monitor.sh diff --git a/deploy/Dockerfile b/deploy/Dockerfile index 6420b03..08e889c 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -1,23 +1,32 @@ -FROM python:3.12-slim +FROM python:3.12-slim-bookworm + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONPATH=/app/src \ + APP_HOST=0.0.0.0 \ + APP_PORT=4173 \ + APP_DB_PATH=/app/data/app.db \ + APP_STORAGE_DIR=/app/data/files WORKDIR /app -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt +RUN groupadd --system --gid 10001 app \ + && useradd --system --uid 10001 --gid app --home-dir /app --shell /usr/sbin/nologin app \ + && mkdir -p /app/data \ + && chown -R app:app /app -COPY server.py ./ -COPY src ./src -COPY web ./web +COPY --chown=app:app requirements.txt ./ +RUN python -m pip install --no-cache-dir --requirement requirements.txt -ENV PYTHONPATH=/app/src -ENV APP_HOST=0.0.0.0 -ENV APP_PORT=4173 -ENV APP_DB_PATH=/app/data/app.db -ENV APP_STORAGE_DIR=/app/data/files +COPY --chown=app:app server.py ./ +COPY --chown=app:app src ./src +COPY --chown=app:app web ./web + +USER app EXPOSE 4173 -HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ - CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:4173/', timeout=4)" +HEALTHCHECK --interval=15s --timeout=3s --start-period=10s --retries=5 \ + CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:4173/', timeout=2)"] CMD ["python", "server.py"] diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..b6f0f98 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,75 @@ +# 部署与运维手册(与 `release/prod` 基线一致) + +最后核对:2026-08-30,总工 HEL-270 上线前总验收。本手册是测试与正式环境唯一的部署/回滚/备份/巡检操作依据,取代散落在宿主机 `DEPLOYMENT.txt` 里的历史记录(旧记录仅作存档)。 + +## 1. 物料与目录 + +| 物料 | 说明 | +|---|---| +| `deploy/Dockerfile` | python:3.12-slim-bookworm,非 root(uid 10001 `app`),只读根文件系统 | +| `deploy/compose.yaml` | cap_drop ALL、no-new-privileges、pids 128、mem 512m、cpu 1.0、healthcheck | +| `deploy/.env.example` | 配置模板;`deploy/.env` 已 gitignore,口令永不入库 | +| `deploy/deploy.sh` | `TAG= ./deploy.sh`,默认镜像 tag=当前提交 sha | +| `deploy/rollback.sh` | `./rollback.sh `,只换应用不动数据 | +| `deploy/backup.sh` | 在线热备 + 保留策略(30 天日备 + 12 个月月备) | +| `deploy/monitor.sh` | 每 5 分钟健康 + 磁盘 ≥80% 告警,写 `ALERT.log` | + +数据目录:`deploy/../data/`(`app.db` + `files/`)。银行原始文件按 SHA-256 内容哈希不可变保存,任何操作不得改写。 + +## 2. 正式配置铁律 + +- **不得出现 `admin/admin123`**:`APP_BOOTSTRAP_ADMIN_PASSWORD` 正式环境不设值,首启由控制台读取随机生成的一次性初始口令,首登立即改密;管理员用户名避开 `admin`。 +- **登录失败限流默认开启**(5 次/10 分钟锁账号+IP)。`APP_LOGIN_RATE_LIMIT_DISABLED=1` 仅限本地调试,正式 .env 不得携带。 +- 密钥/口令/证书私钥不进仓库、不进评论、不进任务元数据。 +- **HTTPS**:正式暴露一律经反向代理(推荐同机 Caddy 容器,内网自签或内部 CA 证书,反代到容器 4173);不占用 NAS 系统 nginx 的 80/443。公司电脑分发根证书即可。 +- **日志**:容器 stdout 走 docker json-file,限幅 `max-size=10m, max-file=3`(daemon 或 compose `logging` 配置);审计日志在库内(append-only 触发器保护)。 + +## 3. 部署步骤(测试与正式一致) + +```bash +git fetch && git checkout release/prod && git pull +cd deploy +cp .env.example .env # 首次;按上面铁律填写 +TAG=$(git rev-parse --short HEAD) ./deploy.sh +curl -fsS http://127.0.0.1:4173/ >/dev/null && echo healthy +``` + +部署前手工备份(四件套 + 镜像 tag 存档): + +```bash +STAMP=$(date +%Y%m%d-%H%M%S) +tar -C .. -czf ../backups/source-$STAMP.tar.gz --exclude='../data' --exclude='../.git' . +cp .env ../backups/env-$STAMP +docker exec caiwuzongzhang-app python -c "import sqlite3; s=sqlite3.connect('/app/data/app.db'); d=sqlite3.connect('/tmp/b.db'); s.backup(d); d.close(); s.close()" +docker cp caiwuzongzhang-app:/tmp/b.db ../backups/app.db.$STAMP +``` + +## 4. 回滚(应用层,5 分钟内) + +```bash +cd deploy && ./rollback.sh <上一个镜像tag> +``` + +数据层恢复见下节。两套动作互不干扰:应用回滚不改数据;数据恢复不换镜像。 + +## 5. 备份与恢复演练 + +- 备份:cron `15 2 * * *` 运行 `deploy/backup.sh`;月结后再手动跑一次。 +- 恢复步骤(已演练,隔离环境验证): + 1. `mkdir -p /tmp/restore/data && cp backups/daily/app.db. /tmp/restore/data/app.db` + 2. `tar -C /tmp/restore/data -xzf backups/daily/files..tar.gz`(解出 `files/`) + 3. 用一份独立 `deploy-restore` 目录(`.env` 指向 `/tmp/restore`、端口错开)`docker compose up -d --build` + 4. 核对:`PRAGMA integrity_check`、各表行数、金额合计、`source_files` 哈希清单与生产一致后才可顶替。 +- 演练频率:上线前 1 次(HEL-270 已做),之后每季度 1 次,结果记入本文件末尾。 + +## 6. 巡检与告警 + +cron `*/5 * * * *` 运行 `deploy/monitor.sh`;连续失败或磁盘 ≥80% 时查看 `deploy/ALERT.log` 并按需扩容/清理。容器重启策略 `unless-stopped`。 + +## 7. 升级数据库 + +应用启动时自动执行迁移(`MIGRATIONS`,当前版本 10)。迁移只前不改写历史;回退 schema 用对应 down 迁移,先备份后操作。 + +## 8. 演练记录 + +- 2026-08-30 HEL-270:备份恢复演练 + 版本回滚演练各 1 次,隔离环境完成,原始证据未改动。详见 HEL-270 验收评论。 diff --git a/deploy/backup.sh b/deploy/backup.sh new file mode 100755 index 0000000..b4789fa --- /dev/null +++ b/deploy/backup.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# 在线热备:不停服备份 SQLite(backup API 保证一致性)+ files/ 原始文件目录。 +# 用法:./backup.sh [备份根目录](默认 ../backups) +# 保留策略:日备保留 30 天,每月 1 号的首份备份额外保留 12 个月(monthly/)。 +# 建议宿主机 cron:15 2 * * * /path/to/deploy/backup.sh >> /path/to/deploy/backup.log 2>&1 +# 官方环境建议至少将 monthly/ 同步一份到异机/异盘。 +set -euo pipefail +ROOT="$(cd "$(dirname "$0")" && pwd)" +DEST="${1:-$ROOT/../backups}" +CONTAINER="${CONTAINER:-caiwuzongzhang-app}" +STAMP="$(date +%Y%m%d-%H%M%S)" +DAY_DIR="$DEST/daily" +MONTH_DIR="$DEST/monthly" +mkdir -p "$DAY_DIR" "$MONTH_DIR" + +# 1) SQLite 在线热备(容器内 python sqlite3 backup API,主库可继续写入) +docker exec "$CONTAINER" python - <<'PY' +import os, sqlite3 +src = sqlite3.connect(os.environ.get("APP_DB_PATH", "/app/data/app.db")) +dst = sqlite3.connect("/tmp/app.db.backup") +src.backup(dst) +dst.close(); src.close() +print("hot-backup ok") +PY +docker cp "$CONTAINER:/tmp/app.db.backup" "$DAY_DIR/app.db.$STAMP" +docker exec "$CONTAINER" rm -f /tmp/app.db.backup + +# 2) 原始文件目录打包(银行原始证据,只读复制,绝不改动) +tar -C "$ROOT/.." -czf "$DAY_DIR/files.$STAMP.tar.gz" data/files + +# 3) 完整性自检:PRAGMA integrity_check + 关键计数 +docker run --rm -v "$DAY_DIR/app.db.$STAMP:/check/app.db:ro" python:3.12-slim-bookworm \ + python -c " +import sqlite3 +c = sqlite3.connect('/check/app.db') +print('integrity:', c.execute('PRAGMA integrity_check').fetchone()[0]) +for t in ('source_rows', 'transfer_match_decisions', 'period_close_runs', 'audit_log'): + try: + print(t, c.execute(f'SELECT COUNT(*) FROM {t}').fetchone()[0]) + except sqlite3.OperationalError: + print(t, 'n/a') +" + +# 4) 保留策略:日备 >30 天删除;每月 1 号留档 monthly/ +find "$DAY_DIR" -name 'app.db.*' -mtime +30 -delete +find "$DAY_DIR" -name 'files.*.tar.gz' -mtime +30 -delete +if [ "$(date +%d)" = "01" ]; then + cp "$DAY_DIR/app.db.$STAMP" "$MONTH_DIR/app.db.$STAMP" + cp "$DAY_DIR/files.$STAMP.tar.gz" "$MONTH_DIR/files.$STAMP.tar.gz" + find "$MONTH_DIR" -mtime +365 -delete +fi +echo "backup complete: $DAY_DIR/app.db.$STAMP" diff --git a/deploy/compose.yaml b/deploy/compose.yaml index 127f353..6b64423 100644 --- a/deploy/compose.yaml +++ b/deploy/compose.yaml @@ -1,36 +1,35 @@ -# 测试环境部署编排。正式口令不得写入本文件。 -# 使用:复制 .env.example 为 .env 后填写,再执行 ./deploy.sh +# 测试/正式环境部署编排(正式口令不得写入本文件)。 +# 使用:复制 .env.example 为 .env 填写;TAG 指定镜像标签(默认取当前提交 sha)。 +# 例:TAG=v1.0.0-rc1 docker compose -f compose.yaml up -d --build services: - caiwuzongzhang: + app: + container_name: caiwuzongzhang-app + image: caiwuzongzhang:${TAG:-latest} build: context: .. dockerfile: deploy/Dockerfile - image: caiwuzongzhang:test - container_name: caiwuzongzhang-test - ports: - - "4173:4173" env_file: - .env - environment: - APP_HOST: "0.0.0.0" - APP_PORT: "4173" - APP_DB_PATH: /app/data/app.db - APP_STORAGE_DIR: /app/data/files + ports: + - "4173:4173" volumes: - ../data:/app/data + init: true + restart: unless-stopped read_only: true tmpfs: - - /tmp - cap_drop: - - ALL + - /tmp:size=64m,mode=1777 security_opt: - no-new-privileges:true + cap_drop: + - ALL + pids_limit: 128 mem_limit: 512m - restart: unless-stopped + cpus: 1.0 healthcheck: - test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:4173/', timeout=4)"] - interval: 30s - timeout: 5s - retries: 3 - start_period: 20s + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:4173/', timeout=2)"] + interval: 15s + timeout: 3s + retries: 5 + start_period: 10s diff --git a/deploy/deploy.sh b/deploy/deploy.sh index 4d41dcb..064798f 100755 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -1,12 +1,17 @@ #!/usr/bin/env bash -# 测试环境部署。施工员不执行上线;由总工在测试机运行。 +# 部署(测试或正式环境通用)。施工员不执行上线;由总工运行。 +# 用法:TAG=<镜像标签,默认当前 git 提交 sha> ./deploy.sh set -euo pipefail ROOT="$(cd "$(dirname "$0")" && pwd)" -cd "$ROOT" -if [[ ! -f .env ]]; then - echo "缺少 deploy/.env。请复制 .env.example 后填写测试口令。" >&2 - exit 1 +cd "$ROOT/.." +if [[ ! -f deploy/.env ]]; then + echo "缺少 deploy/.env。请复制 deploy/.env.example 后填写(正式口令不得入库)。" >&2 + exit 1 fi -mkdir -p "$ROOT/../data/files" -docker compose -f compose.yaml up -d --build -echo "已启动测试环境:http://127.0.0.1:4173/" +if [[ -z "${TAG:-}" ]]; then + TAG="$(git rev-parse --short HEAD 2>/dev/null || echo latest)" +fi +mkdir -p data/files +cd "$ROOT" +TAG="$TAG" docker compose -f compose.yaml up -d --build +echo "已启动:http://127.0.0.1:${APP_PORT:-4173}/ 镜像 caiwuzongzhang:$TAG" diff --git a/deploy/monitor.sh b/deploy/monitor.sh new file mode 100755 index 0000000..ac15773 --- /dev/null +++ b/deploy/monitor.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# 巡检:健康端点 + 磁盘水位告警。失败/越线写 ALERT.log(供总管日检)。 +# 建议 cron:*/5 * * * * /path/to/deploy/monitor.sh >/dev/null 2>&1 +set -euo pipefail +ROOT="$(cd "$(dirname "$0")" && pwd)" +URL="${HEALTH_URL:-http://127.0.0.1:4173/}" +DATA_DIR="${DATA_DIR:-$ROOT/../data}" +ALERT="$ROOT/ALERT.log" +DISK_THRESHOLD="${DISK_THRESHOLD:-80}" + +now() { date "+%Y-%m-%d %H:%M:%S"; } + +# 1) 健康检查:HTTP 200 才算通过 +if ! curl -fsS -m 8 -o /dev/null "$URL"; then + echo "[$(now)] HEALTH FAIL: $URL 无响应" >> "$ALERT" + exit 1 +fi + +# 2) 磁盘水位:数据所在分区使用率超阈值告警 +pct="$(df -P "$DATA_DIR" | awk 'NR==2 {gsub("%",""); print $5}')" +if [ "${pct:-0}" -ge "$DISK_THRESHOLD" ]; then + echo "[$(now)] DISK ALERT: $DATA_DIR 使用率 ${pct}% >= ${DISK_THRESHOLD}%" >> "$ALERT" + exit 2 +fi + +# 3) 容器状态:非 running/restarting 告警 +state="$(docker inspect -f '{{.State.Status}}' "${CONTAINER:-caiwuzongzhang-app}" 2>/dev/null || echo missing)" +if [ "$state" != "running" ]; then + echo "[$(now)] CONTAINER ALERT: ${CONTAINER:-caiwuzongzhang-app} state=$state" >> "$ALERT" + exit 3 +fi +echo "[$(now)] ok" diff --git a/deploy/rollback.sh b/deploy/rollback.sh index 22c4714..e8d24b8 100755 --- a/deploy/rollback.sh +++ b/deploy/rollback.sh @@ -1,15 +1,19 @@ #!/usr/bin/env bash -# 测试环境回滚:停掉当前容器,按 TAG 或上一个镜像再拉起。 -# 用法:./rollback.sh [image-tag] +# 回滚:回到指定镜像标签(或最近一次可用镜像)。 +# 用法:./rollback.sh 例:./rollback.sh f5e0915 +# 数据库与 files/ 原始证据不受影响;如需回退数据,见 deploy/README.md「数据恢复」。 set -euo pipefail ROOT="$(cd "$(dirname "$0")" && pwd)" cd "$ROOT" TAG="${1:-}" -docker compose -f compose.yaml down -if [[ -n "$TAG" ]]; then - export COMPOSE_IMAGE="caiwuzongzhang:${TAG}" - docker compose -f compose.yaml up -d - echo "已回滚到镜像 caiwuzongzhang:${TAG}" -else - echo "已停止测试容器。指定镜像标签可重新拉起:./rollback.sh " +if [[ -z "$TAG" ]]; then + echo "用法:./rollback.sh <镜像tag>(可用 tag 见 docker images caiwuzongzhang)" >&2 + exit 1 fi +if ! docker image inspect "caiwuzongzhang:$TAG" >/dev/null 2>&1; then + echo "镜像 caiwuzongzhang:$TAG 不存在。" >&2 + exit 1 +fi +TAG="$TAG" docker compose -f compose.yaml down +TAG="$TAG" docker compose -f compose.yaml up -d +echo "已回滚到 caiwuzongzhang:$TAG" diff --git a/docs/任务清单.md b/docs/任务清单.md index 777f5ce..eed0093 100644 --- a/docs/任务清单.md +++ b/docs/任务清单.md @@ -4,7 +4,7 @@ ## 正在做 -- 无。HEL-269 施工交付后交总工审核测试环境部署。 +- HEL-270 上线前总验收(总工):代码审核、测试环境部署、备份恢复与回滚演练、边界自动化证据。 ## 已做完 @@ -20,10 +20,10 @@ - 站内提醒与状态流转。 - 前端移除演示流水/往来造数和 `localStorage` 业务状态。 - 登录页视觉融合改版;月结/重开/审计按 HEL-268 视觉规范落地。 -- 测试环境部署编排收编到 `deploy/`(不在施工员职责内执行上线)。 +- 测试环境部署编排收编到 `deploy/`,含非 root 加固、备份/巡检脚本与运维手册(不在施工员职责内执行上线)。 ## 还没安排 -- 测试环境之外的运行保障,包括 HTTPS、备份、监控和正式部署方案。覆盖正式环境必须老板明确同意。 +- 正式部署(P4):HTTPS 反代落地、异机备份副本、正式账号交接。覆盖正式环境必须老板明确同意。 每完成或新增一项任务,必须在同一次提交里把它从本清单的相应栏目移走或补上,并同步更新 `最新进度.md`。 diff --git a/docs/最新进度.md b/docs/最新进度.md index fccf992..03caccd 100644 --- a/docs/最新进度.md +++ b/docs/最新进度.md @@ -14,12 +14,13 @@ - 已实现月结:结账日到达后生成待结账任务,管理员确认后锁账并生成带 SHA-256 的月报;重开须审批,窗口到期自动恢复锁定;闭期补录进入 `period_late_arrivals`,不改已结快照;写保护拒绝锁定月的普通修改。 - 流水列表/导出、往来查询、公司端手工记录已改为服务器真实数据;空列表显示「暂无数据」。页面不再使用 `FLOW_DEMO` / `localStorage` 业务状态。 - 已有登录页、总账端和公司端页面。管理端「结账与期初」承接月结,「审核中心」增加重开审批,「结账与期初」之后增加「审计记录」。 -- `deploy/` 收编测试环境 Dockerfile、compose、`.env.example` 与部署/回滚脚本(4173、只读根文件系统、drop ALL、512m、healthcheck)。正式口令不进仓库。 +- `deploy/` 收编测试环境 Dockerfile、compose、`.env.example` 与部署/回滚脚本(4173、非 root、只读根文件系统、drop ALL、pids/mem/cpu 限制、healthcheck)。正式口令不进仓库。 +- 备份(在线热备 + 30 天日备/12 个月月备保留策略)、巡检(健康 + 磁盘 ≥80% 告警)脚本与《部署与运维手册》`deploy/README.md` 已入库;HTTPS 走反向代理方案已在手册写明。 ## 仍未完成或不能当成已完成 -- 生产部署所需的 HTTPS、反向代理、备份、监控和正式运行保障尚未完成;本项目当前只允许测试环境部署。覆盖正式环境必须老板明确同意。 -- 发布基线思路是从 `f5e0915` 拉长期分支 `release/prod`,由总工审核后处理,施工员不部署。 +- 正式环境尚未部署(P4):HTTPS 反代、异机备份副本、正式账号交接在上线时落地。覆盖正式环境必须老板明确同意。 +- 发布基线:`release/prod` 长期分支已建立,`main` 待发布时快进。 ## 最近验证 diff --git a/tests/test_reminders_page.py b/tests/test_reminders_page.py index 14c2880..26b30d6 100644 --- a/tests/test_reminders_page.py +++ b/tests/test_reminders_page.py @@ -112,12 +112,9 @@ class RemindersPageLayoutSmokeTests(unittest.TestCase): cls.httpd.server_close() def _open_reminders(self, page, width: int) -> None: - html = (WEB / "admin.html").read_text(encoding="utf-8") + page.route("**/app.js**", lambda route: route.abort()) page.set_viewport_size({"width": width, "height": 900}) - page.set_content( - html.replace('src="app.js?v=15"', 'src=""'), - base_url=self.base, - ) + page.goto(f"{self.base}/admin.html") page.evaluate( """() => { document.querySelectorAll('.app-view').forEach((el) => {