feat: harden T12 backup and portal workflows
Some checks failed
CI / pytest (Python 3.10) (push) Has been cancelled
CI / pytest (Python 3.11) (push) Has been cancelled
CI / pytest (Python 3.12) (push) Has been cancelled

This commit is contained in:
Hermes Agent
2026-06-14 23:12:18 +08:00
parent 9bb066f69c
commit 7fe4ddcbc4
33 changed files with 1486 additions and 87 deletions

View File

@@ -162,19 +162,33 @@ GAOKAO_SKIP_INSTALL=1 bash scripts/dev-verify.sh
- 执行器:`data.notifications.dispatcher.DeliveryDispatcher`
- CLI`python3 scripts/gaokao-delivery-dispatch.py --channel station`
- watchdog`python3 scripts/gaokao-delivery-watchdog.py --channel station`
- runbook`docs/DELIVERY_RETENTION_OPS_RUNBOOK.md`
- systemd/cron 样例:`deploy/systemd/gaokao-delivery-*.{service,timer}` / `deploy/cron/gaokao-jobs.crontab`
最小语义:
- 交付物齐全HTML/PDF 存在)→ `ready -> sent`
- 交付物缺失 → `failed`,并写入 `failure_reason`
- watchdog 遇到失败事件返回 exit code `2`
- 当前 `sent` 仍表示“站内交付物校验通过”,不是外部渠道真实发送成功
### 数据保留期清理
- dry-run`python3 scripts/gaokao-retention-cleanup.py --cutoff <ISO8601> --dry-run`
- apply`python3 scripts/gaokao-retention-cleanup.py --cutoff <ISO8601>`
- 定时模式:`python3 scripts/gaokao-retention-cleanup.py --retention-days 180`
- 兼容别名:`python3 scripts/gaokao_retention_cleanup.py --retention-days 180 --dry-run`
- runbook`docs/DELIVERY_RETENTION_OPS_RUNBOOK.md`
- 当前语义:对超过保留期的 `completed/refunded` 订单执行匿名化清理
### 备份 / 恢复最小入口
- 生成快照:`bash scripts/backup_snapshot.sh /tmp/gaokao-backups`
- 校验快照:`bash scripts/backup_verify.sh --from-backup /tmp/gaokao-backups/backup-<UTC_TIMESTAMP>`
- 直接对当前 live 数据做一次恢复演练:`bash scripts/backup_verify.sh`
- runbook`docs/BACKUP_AND_RECOVERY_PLAN.md`
- 定时接入口径:`ops/cron/gaokao-backup.crontab.example``ops/systemd/gaokao-backup*.service|timer`
### crowd_db 质量汇总
- `python3 -m data.crowd_db.quality_summary --human`

View File

@@ -104,11 +104,26 @@ def create_public_order_endpoint(
payload: PublicOrderCreate,
settings: Settings = Depends(get_settings_dep),
) -> PublicOrderCreated:
try:
payment_service = _payment_service(settings)
except PaymentError as exc:
raise HTTPException(
status_code=503,
detail=f"payment provider unavailable: {exc}",
) from exc
with OrdersDAO.connect(settings.orders_db_path) as dao:
order = create_public_order(dao, payload)
portal_token = issue_portal_token(order.id, settings.jwt_secret)
payment_service = _payment_service(settings)
checkout = payment_service.create_checkout(order.id, portal_token=portal_token)
try:
checkout = payment_service.create_checkout(order.id, portal_token=portal_token)
except PaymentError as exc:
with OrdersDAO.connect(settings.orders_db_path) as dao:
dao.delete(order.id)
raise HTTPException(
status_code=503,
detail=f"payment checkout unavailable: {exc}",
) from exc
return PublicOrderCreated(
order_id=order.id,
source=order.source,
@@ -743,11 +758,12 @@ def _render_info_page(
def _render_status_page(token: str, context: dict[str, Any]) -> str:
order = context["order"]
report_links = ""
if context["report_html_ready"]:
can_access_delivery = context["stage"] in {"report_ready", "completed"}
if can_access_delivery and context["report_html_ready"]:
report_links += (
f'<li><a href="/portal/{escape(token)}/report">查看在线报告</a></li>'
)
if context["report_pdf_ready"]:
if can_access_delivery and context["report_pdf_ready"]:
report_links += (
f'<li><a href="/portal/{escape(token)}/report.pdf">下载 PDF</a></li>'
)

View File

@@ -110,3 +110,40 @@ def test_delivered_without_artifacts_stays_processing(client, settings):
report_page = client.get(f"/portal/{token}/report")
assert report_page.status_code == 409
def test_partial_artifacts_do_not_expose_delivery_links_before_report_ready(
client, settings, tmp_path: Path
):
order = _seed_order(settings.orders_db_path, order_id="GKO-20260614-STATUS-PARTIAL")
_mark_paid(settings, order)
IntakeStore.for_db(settings.orders_db_path).save(
order_id=order.id,
payload={"candidate_score": 578, "candidate_subjects": ["物理"]},
submit=True,
)
report_path = tmp_path / "partial-report.html"
report_path.write_text("<h1>partial</h1>", encoding="utf-8")
with OrdersDAO.connect(settings.orders_db_path) as dao:
dao.update(
order.id,
{"audit_report": str(report_path)},
actor="test",
reason="attach_partial_report",
)
dao.transition_status(order.id, "serving", actor="test", reason="processing")
dao.transition_status(
order.id, "delivered", actor="test", reason="report_ready_without_pdf"
)
token = issue_portal_token(order.id, settings.jwt_secret)
status_page = client.get(f"/portal/{token}/status")
assert status_page.status_code == 200, status_page.text
assert "处理中" in status_page.text
assert "查看在线报告" not in status_page.text
assert "下载 PDF" not in status_page.text
assert "报告生成中" in status_page.text
report_page = client.get(f"/portal/{token}/report")
assert report_page.status_code == 409

View File

@@ -2,6 +2,10 @@
from __future__ import annotations
from fastapi.testclient import TestClient
from data.orders.dao import OrdersDAO
def test_public_landing_page_served(client):
resp = client.get("/")
@@ -129,3 +133,106 @@ def test_payment_return_redirects_to_portal_status(client):
resp = client.get(f"/portal/payment-return?token={token}", follow_redirects=False)
assert resp.status_code == 303, resp.text
assert resp.headers["location"] == f"/portal/{token}/status"
def test_public_create_order_returns_503_without_creating_orphan_order_when_provider_unavailable(
tmp_path, monkeypatch
):
admin_db = tmp_path / "admin.db"
orders_db = tmp_path / "orders.db"
share_db = tmp_path / "share.db"
share_reports = tmp_path / "share_reports"
share_reports.mkdir()
monkeypatch.setenv("GAOKAO_ENV", "dev")
monkeypatch.setenv("GAOKAO_DB_PATH", str(admin_db))
monkeypatch.setenv("GAOKAO_ORDERS_DB_PATH", str(orders_db))
monkeypatch.setenv("GAOKAO_SHARE_DB_PATH", str(share_db))
monkeypatch.setenv("GAOKAO_SHARE_REPORT_DIR", str(share_reports))
monkeypatch.setenv("GAOKAO_ORDERS_FERNET_KEY", "test-secret-for-web-self-service")
monkeypatch.setenv("GAOKAO_JWT_SECRET", "x" * 64)
monkeypatch.setenv("GAOKAO_JWT_EXP_MIN", "5")
monkeypatch.setenv("GAOKAO_ADMIN_USER", "admin")
monkeypatch.setenv("GAOKAO_ADMIN_PASS", "test-pass-123")
monkeypatch.setenv("GAOKAO_PAYMENT_PROVIDER", "alipay")
monkeypatch.setenv("GAOKAO_PAYMENT_BASE_URL", "http://testserver")
monkeypatch.setenv("GAOKAO_PAYMENT_WEBHOOK_SECRET", "missing-real-provider-env")
from admin.app import create_app
from admin.config import load_settings
settings = load_settings()
app = create_app(settings)
with TestClient(app) as client:
resp = client.post(
"/api/public/orders",
json={
"service_version": "standard",
"amount_cents": 9900,
"customer_name": "张家长",
"customer_phone": "13800138000",
"candidate_province": "湖南",
},
)
assert resp.status_code == 503, resp.text
assert "payment provider unavailable" in resp.text
with OrdersDAO.connect(settings.orders_db_path) as dao:
assert dao.count() == 0
def test_public_create_order_returns_503_without_creating_orphan_order_when_checkout_fails(
tmp_path, monkeypatch
):
admin_db = tmp_path / "admin.db"
orders_db = tmp_path / "orders.db"
share_db = tmp_path / "share.db"
share_reports = tmp_path / "share_reports"
share_reports.mkdir()
monkeypatch.setenv("GAOKAO_ENV", "dev")
monkeypatch.setenv("GAOKAO_DB_PATH", str(admin_db))
monkeypatch.setenv("GAOKAO_ORDERS_DB_PATH", str(orders_db))
monkeypatch.setenv("GAOKAO_SHARE_DB_PATH", str(share_db))
monkeypatch.setenv("GAOKAO_SHARE_REPORT_DIR", str(share_reports))
monkeypatch.setenv("GAOKAO_ORDERS_FERNET_KEY", "test-secret-for-web-self-service")
monkeypatch.setenv("GAOKAO_JWT_SECRET", "x" * 64)
monkeypatch.setenv("GAOKAO_JWT_EXP_MIN", "5")
monkeypatch.setenv("GAOKAO_ADMIN_USER", "admin")
monkeypatch.setenv("GAOKAO_ADMIN_PASS", "test-pass-123")
monkeypatch.setenv("GAOKAO_PAYMENT_PROVIDER", "mock")
monkeypatch.setenv("GAOKAO_PAYMENT_BASE_URL", "http://testserver")
monkeypatch.setenv("GAOKAO_PAYMENT_WEBHOOK_SECRET", "test-payment-secret")
from admin.app import create_app
from admin.config import load_settings
from data.payments.service import PaymentError, PaymentService
original = PaymentService.create_checkout
def _boom(self, order_id: str, *, portal_token: str):
raise PaymentError("checkout transport failed")
monkeypatch.setattr(PaymentService, "create_checkout", _boom)
settings = load_settings()
app = create_app(settings)
with TestClient(app) as client:
resp = client.post(
"/api/public/orders",
json={
"service_version": "standard",
"amount_cents": 9900,
"customer_name": "张家长",
"customer_phone": "13800138000",
"candidate_province": "湖南",
},
)
monkeypatch.setattr(PaymentService, "create_checkout", original)
assert resp.status_code == 503, resp.text
assert "payment checkout unavailable" in resp.text
with OrdersDAO.connect(settings.orders_db_path) as dao:
assert dao.count() == 0

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
import argparse
import json
from dataclasses import dataclass
from datetime import datetime
from datetime import datetime, timedelta, timezone
from data.orders.dao import OrdersDAO
from data.orders.deletion_service import OrderDeletionService
@@ -11,6 +11,8 @@ from data.orders.deletion_service import OrderDeletionService
@dataclass
class RetentionCleanupResult:
cutoff_iso: str
dry_run: bool
scanned: int = 0
candidates: int = 0
anonymized: int = 0
@@ -53,11 +55,28 @@ def _iter_candidates(db_path: str, cutoff_iso: str) -> tuple[int, list[str]]:
return scanned, candidates
def resolve_cutoff_iso(
*, cutoff_iso: str | None = None, retention_days: int | None = None
) -> str:
if cutoff_iso is not None:
datetime.fromisoformat(cutoff_iso)
return cutoff_iso
if retention_days is None or retention_days <= 0:
raise ValueError("retention_days must be > 0 when cutoff is not provided")
cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days)
return cutoff.replace(microsecond=0).isoformat()
def run_cleanup(
db_path: str, *, cutoff_iso: str, apply: bool
) -> RetentionCleanupResult:
scanned, candidate_ids = _iter_candidates(db_path, cutoff_iso)
result = RetentionCleanupResult(scanned=scanned, candidates=len(candidate_ids))
result = RetentionCleanupResult(
cutoff_iso=cutoff_iso,
dry_run=not apply,
scanned=scanned,
candidates=len(candidate_ids),
)
if not apply:
return result
service = OrderDeletionService.for_db(db_path)
@@ -76,11 +95,16 @@ def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Run retention cleanup for old completed/refunded orders"
)
parser.add_argument(
cutoff_group = parser.add_mutually_exclusive_group(required=True)
cutoff_group.add_argument(
"--cutoff",
required=True,
help="ISO8601 cutoff; orders on or before this anchor are candidates",
)
cutoff_group.add_argument(
"--retention-days",
type=int,
help="Retention window in days; cutoff is computed as now - retention_days",
)
parser.add_argument("--dry-run", action="store_true")
return parser
@@ -92,7 +116,14 @@ def main(argv: list[str] | None = None, *, db_path: str | None = None) -> int:
db_path = load_settings().orders_db_path
parser = build_parser()
args = parser.parse_args(argv)
result = run_cleanup(db_path, cutoff_iso=args.cutoff, apply=not args.dry_run)
try:
cutoff_iso = resolve_cutoff_iso(
cutoff_iso=args.cutoff,
retention_days=args.retention_days,
)
except ValueError as exc:
parser.error(str(exc))
result = run_cleanup(db_path, cutoff_iso=cutoff_iso, apply=not args.dry_run)
print(json.dumps(result.__dict__, ensure_ascii=False, indent=2))
return 0

View File

@@ -0,0 +1,13 @@
SHELL=/bin/bash
PATH=/usr/local/bin:/usr/bin:/bin
GAOKAO_ENV=prod
GAOKAO_ORDERS_DB_PATH=/home/long/project/gaokao-volunteer-system/data/orders.db
GAOKAO_PYTHON_BIN=/home/long/project/gaokao-volunteer-system/.venv/bin/python
GAOKAO_DELIVERY_CHANNEL=station
GAOKAO_DELIVERY_LIMIT=100
GAOKAO_RETENTION_DAYS=180
*/5 * * * * cd /home/long/project/gaokao-volunteer-system && "$GAOKAO_PYTHON_BIN" scripts/gaokao-delivery-dispatch.py --channel "$GAOKAO_DELIVERY_CHANNEL" --limit "$GAOKAO_DELIVERY_LIMIT" >> /var/log/gaokao-volunteer-system/delivery-dispatch.log 2>&1
*/10 * * * * cd /home/long/project/gaokao-volunteer-system && "$GAOKAO_PYTHON_BIN" scripts/gaokao-delivery-watchdog.py --channel "$GAOKAO_DELIVERY_CHANNEL" --limit "$GAOKAO_DELIVERY_LIMIT" >> /var/log/gaokao-volunteer-system/delivery-watchdog.log 2>&1
30 3 * * 0 cd /home/long/project/gaokao-volunteer-system && "$GAOKAO_PYTHON_BIN" scripts/gaokao-retention-cleanup.py --retention-days "$GAOKAO_RETENTION_DAYS" >> /var/log/gaokao-volunteer-system/retention-cleanup.log 2>&1

View File

@@ -0,0 +1,12 @@
[Unit]
Description=Gaokao delivery dispatcher (report_ready -> sent/failed)
After=network.target
[Service]
Type=oneshot
WorkingDirectory=/home/long/project/gaokao-volunteer-system
Environment=GAOKAO_PYTHON_BIN=/usr/bin/python3
Environment=GAOKAO_DELIVERY_CHANNEL=station
Environment=GAOKAO_DELIVERY_LIMIT=100
EnvironmentFile=-/home/long/project/gaokao-volunteer-system/deploy/systemd/gaokao-jobs.env
ExecStart=/bin/sh -lc 'exec "$GAOKAO_PYTHON_BIN" scripts/gaokao-delivery-dispatch.py --channel "$GAOKAO_DELIVERY_CHANNEL" --limit "$GAOKAO_DELIVERY_LIMIT"'

View File

@@ -0,0 +1,11 @@
[Unit]
Description=Run gaokao delivery dispatcher every 5 minutes
[Timer]
OnCalendar=*:0/5
Persistent=true
RandomizedDelaySec=30
Unit=gaokao-delivery-dispatch.service
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,12 @@
[Unit]
Description=Gaokao delivery watchdog (non-zero when dispatch sees failures)
After=network.target
[Service]
Type=oneshot
WorkingDirectory=/home/long/project/gaokao-volunteer-system
Environment=GAOKAO_PYTHON_BIN=/usr/bin/python3
Environment=GAOKAO_DELIVERY_CHANNEL=station
Environment=GAOKAO_DELIVERY_LIMIT=100
EnvironmentFile=-/home/long/project/gaokao-volunteer-system/deploy/systemd/gaokao-jobs.env
ExecStart=/bin/sh -lc 'exec "$GAOKAO_PYTHON_BIN" scripts/gaokao-delivery-watchdog.py --channel "$GAOKAO_DELIVERY_CHANNEL" --limit "$GAOKAO_DELIVERY_LIMIT"'

View File

@@ -0,0 +1,11 @@
[Unit]
Description=Run gaokao delivery watchdog every 10 minutes
[Timer]
OnCalendar=*:0/10
Persistent=true
RandomizedDelaySec=60
Unit=gaokao-delivery-watchdog.service
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,10 @@
# 复制为 deploy/systemd/gaokao-jobs.env 后再按实际环境覆盖。
# 目标:给 dispatcher / watchdog / retention cleanup 三个定时作业提供统一环境。
GAOKAO_ENV=prod
GAOKAO_ORDERS_DB_PATH=/home/long/project/gaokao-volunteer-system/data/orders.db
GAOKAO_PYTHON_BIN=/home/long/project/gaokao-volunteer-system/.venv/bin/python
GAOKAO_DELIVERY_CHANNEL=station
GAOKAO_DELIVERY_LIMIT=100
GAOKAO_RETENTION_DAYS=180
PYTHONUNBUFFERED=1

View File

@@ -0,0 +1,11 @@
[Unit]
Description=Gaokao retention cleanup (anonymize expired completed/refunded orders)
After=network.target
[Service]
Type=oneshot
WorkingDirectory=/home/long/project/gaokao-volunteer-system
Environment=GAOKAO_PYTHON_BIN=/usr/bin/python3
Environment=GAOKAO_RETENTION_DAYS=180
EnvironmentFile=-/home/long/project/gaokao-volunteer-system/deploy/systemd/gaokao-jobs.env
ExecStart=/bin/sh -lc 'exec "$GAOKAO_PYTHON_BIN" scripts/gaokao-retention-cleanup.py --retention-days "$GAOKAO_RETENTION_DAYS"'

View File

@@ -0,0 +1,11 @@
[Unit]
Description=Run gaokao retention cleanup every Sunday at 03:30 UTC
[Timer]
OnCalendar=Sun 03:30
Persistent=true
RandomizedDelaySec=300
Unit=gaokao-retention-cleanup.service
[Install]
WantedBy=timers.target

View File

@@ -34,26 +34,34 @@
#### A-1 用户端 Web 自助闭环未完成
严重度: P0面向 Web 自助产品时)
当前状态: 未解决
当前状态: 部分解决,仍未整体收口
范围归属: T12后续阶段
已完成:
- 前台公开入口 `/` / `/pricing` / `/checkout/{service_version}`
- 公开下单与 portal token 链路
- 支付后资料填写草稿/提交
- 站内状态页 / 报告查看 / PDF 下载最小闭环
- 后台订单列表/详情可见资料提交状态
仍缺:
- 前台套餐页完整交互
- 真实支付接入与回调验签
- 前台资料填写闭环
- 站内交付 / 订单状态页
- 自助购买后自动处理主链路
- 前台套餐页完整交互与产品化打磨
- 真实支付接入与回调验签的线上 acceptance
- `info_submitted -> serving` 自动处理主链
- 完整站内通知/交付产品化
- 自助购买后自动处理主链路的生产化闭环
说明:
- 这是“当前仍然有效的问题”,但**不应再作为对 v2.1 已完成范围的否定证据**
- 正确表述应为:`v2.1 完成了人工服务运营闭环T12 承接 Web 自助闭环`
- 当前不是“完全没做 T12”而是“本地 MVP 主链大部分已落地,但整体仍未到线上可验收完成”
- 正确表述应为:`v2.1 完成了人工服务运营闭环T12 已形成本地 MVP 主链,但线上商业闭环仍未完成`
建议动作:
- 以 T12 为唯一主线推进,不在旧报告中分散描述
- 为 T12 单独维护实施计划、验收标准和完成报告
- 后续优先收 `自动处理主链``真实支付 acceptance`
#### A-2 支付、退款、对账闭环未完成
@@ -100,11 +108,12 @@
- 站内查看 + PDF 下载最小交付闭环
- `DeliveryDispatcher` + `scripts/gaokao-delivery-dispatch.py`
- `ready -> sent` / 缺文件 `-> failed` 的最小执行链
- dispatcher/watchdog runbook + systemd/cron 样例(`docs/DELIVERY_RETENTION_OPS_RUNBOOK.md` / `deploy/systemd` / `deploy/cron`
仍缺:
- 真实邮件/站内通知发送执行器
- 自动重试调度 / 告警链
- 自动重试调度 / 告警链watchdog/systemd/cron 样例已补,生产安装未完成)
- 面向用户的独立通知审计页
- 对账/退款与交付失败补偿联动
@@ -137,12 +146,12 @@
- 正式法务审定版本
- 前台/客服删除工单流程
- 数据保留期自动清理定时任务(脚本已具备,调度未接入
- 目标生产主机上的自动清理实际安装/留痕(仓库已提供 runbook + systemd/cron 样例
- 合规文本上线前最终校对
说明:
- 当前不再是“完全没有合规基线”,而是“文档基线 + 后台最小执行入口 + retention cleanup 脚本已建,前台流程与自动调度仍待完成”。
- 当前不再是“完全没有合规基线”,而是“文档基线 + 后台最小执行入口 + retention cleanup 脚本与调度样例已建,前台流程与生产安装留痕仍待完成”。
#### A-5 业务数据备份/恢复/密钥托管已形成基线,但生产化仍未完结
@@ -154,17 +163,22 @@
- `docs/BACKUP_AND_RECOVERY_PLAN.md`
- `docs/KEY_MANAGEMENT_BASELINE.md`
- `scripts/backup_verify.sh` 本地恢复验证入口
- `docs/plans/P1-8-backup-restore-drill.md`
- `scripts/backup_snapshot.sh` 最小目录快照入口
- `scripts/backup_verify.sh` 本地恢复校验入口
- `scripts/backup_restore_smoke.py` 恢复副本最小服务 smoke
- `ops/cron/gaokao-backup.crontab.example`
- `ops/systemd/gaokao-backup*.service|timer` 定时接入口径示例
仍缺:
- 异机/异地备份落地
- 定时备份与恢复演练自动化
- 目标主机上的 cron/systemd 实际安装、日志与告警验收
- 密钥轮换的真实执行记录
说明:
- 当前不再是“没有备份恢复方案”,而是“已有本地方案与验证脚本,但生产化运维链未闭环”。
- 当前不再是“只有文档没有验证”,而是“已有本地快照、完整性校验、restore smoke 和定时接入口径样例,但生产化运维链未闭环”。
---

View File

@@ -1,7 +1,7 @@
# BACKUP_AND_RECOVERY_PLAN
最后更新: 2026-06-14
状态: 最小恢复基线(非生产级高可用
状态: 最小恢复基线(已具备本地快照 + 完整性校验 + restore smoke
## 1. 目标
@@ -11,77 +11,150 @@
- 报告 HTML/PDF 文件
- 上传/分享产物
- 运行所需关键配置与密钥映射关系
- 本地可执行的备份、校验、恢复演练入口
## 2. RPO / RTO 建议
- RPO: 24 小时内
- RTO: 4 小时内
说明:当前是轻量单机 MVP不承诺秒级恢复。
说明:当前是轻量单机 MVP不承诺秒级恢复,也不等同于生产级多机容灾
## 3. 需要备份的对象
1. 数据库
- 管理库
- 订单库
- 分享库
- 管理库 `data/orders/admin.db`
- 订单库 `data/orders.db`
- 分享库 `data/share/short_links.db`
2. 文件目录
- 报告输出目录
- 分享报告目录
- 未来上传文件目录
- 分享/公开报告目录
- 报告 HTML/PDF 产物
- `data/examples` 中的样例交付物(用于恢复 smoke
3. 配置与运维资料
- `.env` 实际部署值(不入 Git
- systemd/docker-compose 部署配置
- systemd / docker-compose 部署配置
4. 密钥资料
- JWT secret
- Fernet key
- 支付相关密钥/证书
## 4. 最小备份策略
## 4. 当前已落地产物
- `scripts/backup_snapshot.sh`
- 生成时间戳快照目录
- 复制 DB / 文件 / 可选 config / 可选 secrets
- 生成 `manifest.json`
-`GAOKAO_BACKUP_KEEP` 保留最近 N 份
- `scripts/backup_verify.sh`
- 可校验 live staging 或已有快照
- 校验 SQLite 可读性
- 校验 `manifest.json` 哈希完整性
- 调用 restore smoke
- `scripts/backup_restore_smoke.py`
- 在恢复副本上启动 FastAPI `TestClient`
-`/health`、portal 状态页、报告 HTML、PDF 下载最小链路
- 只改临时恢复副本,不改原始快照
- 定时接入口径示例
- `ops/cron/gaokao-backup.crontab.example`
- `ops/systemd/gaokao-backup.service`
- `ops/systemd/gaokao-backup.timer`
- `ops/systemd/gaokao-backup-verify.service`
- `ops/systemd/gaokao-backup-verify.timer`
## 5. 最小备份策略
### 数据库
- 每日一次离线或冷备
- 备份文件名包含时间戳
- 保留最近 7 份日备
- 每日一次目录快照
- 文件名包含 UTC 时间戳
- 保留最近 7 份日备(可由 `GAOKAO_BACKUP_KEEP` 调整)
### 文件目录
- 每日一次目录归档
- 保留最近 7 份日备
- 与数据库同批次快照
- 报告/分享目录和样例报告分开保存,避免后续排障时混淆
### 密钥与配置
### 配置与密钥
- 单独存放,不与业务数据库混
- `.env`、部署配置、密钥目录不与业务数据库混写到同一路径
- 通过环境变量显式声明:
- `GAOKAO_BACKUP_ENV_FILE`
- `GAOKAO_BACKUP_CONFIG_DIR`
- `GAOKAO_BACKUP_SECRETS_DIR`
- 只允许受控人员访问
## 5. 恢复步骤(最小版
## 6. 最小 runbook本地可执行
1. 准备干净工作目录
2. 恢复 SQLite 数据库文件
3. 恢复报告/分享文件目录
4. 恢复环境变量与密钥
5. 启动服务
6. 验证:
- 后台能读订单
- portal 状态页可打开
- 已交付报告可下载
### 6.1 生成快照
## 6. 当前缺口
```bash
bash scripts/backup_snapshot.sh /tmp/gaokao-backups
```
- 尚无自动化备份脚本
- 尚无定时任务
- 尚无备份完整性校验
可选环境变量:
## 7. MVP 上线前最低要求
```bash
export GAOKAO_BACKUP_KEEP=7
export GAOKAO_BACKUP_ENV_FILE=/etc/gaokao/.env
export GAOKAO_BACKUP_CONFIG_DIR=/etc/gaokao
export GAOKAO_BACKUP_SECRETS_DIR=/var/lib/gaokao/secrets
```
1. 至少完成一次手工备份演练
2. 至少在临时目录恢复一次可读数据库
### 6.2 校验最近一份快照
```bash
bash scripts/backup_verify.sh --from-backup /tmp/gaokao-backups/backup-<UTC_TIMESTAMP>
```
校验内容:
1. `manifest.json` 哈希一致
2. SQLite 文件可打开、表可枚举
3. 恢复副本可跑最小服务 smoke
- `/health`
- `/portal/{token}/status`
- `/portal/{token}/report`
- `/portal/{token}/report.pdf`
### 6.3 从 live 数据直接做一次恢复演练
```bash
bash scripts/backup_verify.sh
```
该命令会把当前 live DB / 文件复制到临时目录后执行同样的校验链。
## 7. 定时接入口径
### cron 示例
见:`ops/cron/gaokao-backup.crontab.example`
- 每天 02:30 生成快照
- 每周一 03:00 对最近一份快照执行 restore smoke
### systemd timer 示例
见:
- `ops/systemd/gaokao-backup.service`
- `ops/systemd/gaokao-backup.timer`
- `ops/systemd/gaokao-backup-verify.service`
- `ops/systemd/gaokao-backup-verify.timer`
说明:这些文件只是仓库内口径样例,不代表目标主机已经安装启用。
## 8. MVP 上线前最低要求
1. 至少完成一次真实快照生成
2. 至少完成一次 `backup_verify.sh --from-backup ...` 演练
3. 明确密钥存放位置和负责人
4. 不得再使用“Git 备份即可恢复系统”作为对外或内部结论
## 8. 当前已落地产物
## 9. 当前仍未闭环的外部/后续缺口
- `scripts/backup_verify.sh`
- 可复制当前 SQLite 数据库与示例报告到临时目录
- 可验证复制后的数据库可读、表可枚举、报告文件可见
- 异机 / 异地备份尚未落地
- 目标主机上的 cron / systemd timer 尚未实际安装验收
- 备份失败告警链(邮件 / IM / 监控)未接入
- 密钥轮换与应急恢复仍缺真实执行记录
- 当前 restore smoke 证明的是“最小服务链可用”,不是完整生产级全链恢复

View File

@@ -68,14 +68,14 @@
未完成主链路包括:
- 用户端落地页 / 套餐页完整交互
- 真实支付接入与回调验签
- 前台资料填写闭环
- 站内交付 / 前台订单状态页完整闭环
- 真实支付接入与回调验签的线上 acceptance
- `info_submitted -> serving` 自动处理主链
- 完整交付通知/发送器与调度
- 前台删除工单流程与更完整的产品化交互
因此:
- Web 自助购买场景 = 未完成
- Web 自助购买场景 = **本地 MVP 主链已大部分落地,但线上闭环未完成**
- 完整教育 SaaS 化 = 未完成
---
@@ -107,7 +107,7 @@
- 用户端 Web 自助支付闭环缺失
- 邮件 / 站内自动交付尚未形成当前主系统标准能力
- 上传入口仍以后台 / CLI / 内部入口为主,用户前台入口不足
- 业务数据备份 / 恢复 / 密钥托管方案仍不足
- 业务数据备份 / 恢复 / 密钥托管已有本地基线与验证,但异机备份和生产接入仍不足
- 隐私政策 / 服务协议 / 监护人同意 / 数据保留与删除流程未形成正式产品闭环
### P2 工程改进

View File

@@ -44,12 +44,16 @@
- 删除时自动清理 HTML/PDF 报告文件
- `order_deletion_audits` 最小审计表
- `scripts/gaokao_retention_cleanup.py` / `scripts/gaokao-retention-cleanup.py` 保留期清理入口
- `scripts/gaokao-retention-cleanup.py --retention-days 180` 最小定时调度入口
- `docs/DELIVERY_RETENTION_OPS_RUNBOOK.md`
- `deploy/systemd/gaokao-retention-cleanup.{service,timer}`
- `deploy/cron/gaokao-jobs.crontab`
尚缺:
- 前台/客服删除工单流程
- 数据保留期自动清理定时任务
- 更细粒度的匿名化策略(如案例长期保留脱敏版)
- 目标生产主机上的实际安装/值班留痕(仓库内只有调度样例,不代表已部署)
## 5. MVP 最低执行要求
@@ -59,3 +63,14 @@
2. 内部 runbook 明确谁来删、删哪些表和文件
3. 删除后要复核文件路径已不存在
4. 不能把“仅删数据库”误报成“数据已全部删除”
## 6. 最小调度与 runbook
仓库当前给出的最小执行口径:
- 人工指定 cutoff`python3 scripts/gaokao-retention-cleanup.py --cutoff <ISO8601> --dry-run`
- 定时调度:`python3 scripts/gaokao-retention-cleanup.py --retention-days 180`
- runbook`docs/DELIVERY_RETENTION_OPS_RUNBOOK.md`
- systemd/cron 样例:`deploy/systemd/gaokao-retention-cleanup.{service,timer}` / `deploy/cron/gaokao-jobs.crontab`
真相边界:当前保留期作业执行的是后台匿名化,不应误报成“用户删除请求流程已经完整上线”。

View File

@@ -0,0 +1,130 @@
# DELIVERY_RETENTION_OPS_RUNBOOK
最后更新: 2026-06-14
## 1. 当前真相
### delivery dispatcher / watchdog
当前已落仓并可接定时任务的事实:
- 执行脚本:`scripts/gaokao-delivery-dispatch.py`
- 巡检脚本:`scripts/gaokao-delivery-watchdog.py`
- systemd 样例:`deploy/systemd/gaokao-delivery-dispatch.{service,timer}` / `deploy/systemd/gaokao-delivery-watchdog.{service,timer}`
- cron 样例:`deploy/cron/gaokao-jobs.crontab`
注意边界:
- 当前 `sent` 仍表示“站内交付物校验通过并推进事件状态”,**不是** 邮件/微信等外部渠道真实发送成功。
- dispatcher 每次会处理 `ready``failed` 事件;如果缺少 HTML/PDF会把事件记为 `failed`,并递增 `attempt_count`
- watchdog 复用同一 dispatch 路径,但只要本轮出现失败事件就返回 exit code `2`,适合接 systemd `OnFailure=` 或外部告警。
### retention cleanup
当前已落仓并可接定时任务的事实:
- 手工入口:`scripts/gaokao-retention-cleanup.py --cutoff <ISO8601> [--dry-run]`
- 定时入口:`scripts/gaokao-retention-cleanup.py --retention-days 180 [--dry-run]`
- 兼容别名:`scripts/gaokao_retention_cleanup.py`
- systemd 样例:`deploy/systemd/gaokao-retention-cleanup.{service,timer}`
- cron 样例:`deploy/cron/gaokao-jobs.crontab`
注意边界:
- 当前清理动作是 **匿名化**`anonymize_order`),不是物理删除。
- 只处理 `completed/refunded` 且锚点时间早于 cutoff 的订单。
- 它会清理订单侧敏感字段并写审计,但不等于“前台删除工单流程已上线”。
## 2. 统一环境配置
建议先复制:
```bash
cp deploy/systemd/gaokao-jobs.env.example deploy/systemd/gaokao-jobs.env
```
最少确认以下变量:
- `GAOKAO_ORDERS_DB_PATH`:生产订单 DB 路径
- `GAOKAO_PYTHON_BIN`:建议指向项目 `.venv/bin/python`
- `GAOKAO_DELIVERY_CHANNEL`:当前默认 `station`
- `GAOKAO_DELIVERY_LIMIT`:单次 dispatch/watchdog 扫描上限
- `GAOKAO_RETENTION_DAYS`:默认 `180`
## 3. 手工执行口径
```bash
# 主动推进 ready/failed 事件
python3 scripts/gaokao-delivery-dispatch.py --channel station --limit 100
# 巡检:有失败时返回 2
python3 scripts/gaokao-delivery-watchdog.py --channel station --limit 100
# retention dry-run人工指定 cutoff
python3 scripts/gaokao-retention-cleanup.py --cutoff 2025-12-31T00:00:00+00:00 --dry-run
# retention 定时模式(自动计算 cutoff
python3 scripts/gaokao-retention-cleanup.py --retention-days 180
```
退出码:
- dispatcher`0` = 脚本执行完成(结果看 JSON
- watchdog`0` = 本轮无失败;`2` = 本轮发现失败事件
- retention cleanup`0` = 脚本执行完成;异常参数或运行错误会非 0 退出
## 4. systemd 安装口径
```bash
sudo cp deploy/systemd/gaokao-*.service /etc/systemd/system/
sudo cp deploy/systemd/gaokao-*.timer /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now gaokao-delivery-dispatch.timer
sudo systemctl enable --now gaokao-delivery-watchdog.timer
sudo systemctl enable --now gaokao-retention-cleanup.timer
```
验证:
```bash
systemctl list-timers 'gaokao-*'
systemctl start gaokao-delivery-dispatch.service
systemctl status gaokao-delivery-watchdog.service --no-pager
journalctl -u gaokao-retention-cleanup.service -n 50 --no-pager
```
说明:
- watchdog 的 exit code `2` 会让对应 service 进入 failed便于接 `OnFailure=` 或宿主机监控。
- 如果部署路径不是当前仓库路径,先修改 unit 里的 `WorkingDirectory``EnvironmentFile`
## 5. cron 备用口径
`deploy/cron/gaokao-jobs.crontab` 给出最小样例,导入前先确保:
1. `/var/log/gaokao-volunteer-system/` 已创建且可写;
2. `GAOKAO_PYTHON_BIN` 指向安装完依赖的解释器;
3. crontab 中仓库路径与真实部署一致。
导入示例:
```bash
crontab deploy/cron/gaokao-jobs.crontab
crontab -l
```
## 6. 推荐节奏
- dispatcher每 5 分钟
- watchdog每 10 分钟
- retention cleanup每周一次周日 03:30 UTC
## 7. 仍未收口的缺口
这些仍然没有被本次 runbook 伪装成“已完成”:
1. `sent` 语义仍偏向“站内交付校验完成”,不是外部渠道真实投递成功。
2. 尚无邮件/微信真实发送执行器。
3. watchdog 只有失败退出码,还没有仓库内置的告警推送集成。
4. retention cleanup 只是后台匿名化作业,前台/客服删除工单流程仍未上线。
5. 这些 unit/timer/cron 样例已落仓,但是否真正安装到目标生产主机,需要部署时另行执行并留存记录。

View File

@@ -54,13 +54,17 @@
- `delivered` 但无交付物时不再误报 `report_ready`
- `data.notifications.dispatcher.DeliveryDispatcher`
- `scripts/gaokao-delivery-dispatch.py`
- `scripts/gaokao-delivery-watchdog.py`(失败返回 exit code `2`
- `ready -> sent` / `缺文件 -> failed` / `attempt_count` 递增
- `docs/DELIVERY_RETENTION_OPS_RUNBOOK.md`
- `deploy/systemd/gaokao-delivery-{dispatch,watchdog}.{service,timer}`
- `deploy/cron/gaokao-jobs.crontab`
未完成:
- 独立 `delivery_job` / `delivery_attempt` 模型
- 邮件/渠道真实发送执行器
- 自动重试调度与告警链
- 告警推送集成(当前只有 watchdog 非 0 退出码)
- 多通道统一投递状态页
## 6. 当前最短闭环
@@ -72,10 +76,21 @@
5. `report_ready` 事件落库且幂等
6. `gaokao-delivery-dispatch.py --channel station` 可把 ready 事件推进到 sent
7. 缺失交付物时事件转 failed并记录 `failure_reason`
8. 可通过 systemd timer / cron 周期执行 dispatcher并用 watchdog 暴露失败退出码
## 7. 下一步实施建议
1. 把 dispatcher 接入定时任务或 systemd timer
2. 增加邮件或站内通知真实发送器(二选一先闭环)
3. 增加失败重试阈值与告警
1. 增加邮件或站内通知真实发送器(二选一先闭环)
2. 增加失败重试阈值与告警推送
3. `sent` 语义拆成“可投递校验通过”与“真实渠道已发送”
4. 再决定是否扩到多通道
## 8. 生产化接入口径
仓库内已提供最小 runbook 与调度样例:
- runbook`docs/DELIVERY_RETENTION_OPS_RUNBOOK.md`
- systemd`deploy/systemd/gaokao-delivery-dispatch.{service,timer}` / `deploy/systemd/gaokao-delivery-watchdog.{service,timer}`
- cron`deploy/cron/gaokao-jobs.crontab`
注意:当前 watchdog 仍是“带失败退出码的 dispatch 巡检”,并不等于独立通知告警系统。

View File

@@ -66,7 +66,7 @@ T12 的验收对象不是后台运营链路,而是用户端 Web 自助主链
1. 真实支付 provider 未完成且目标口径要求真实支付
2. 交付事件仅停留在路由级补丁,未形成稳定主链能力
3. 前台仅有文案,无同意字段落库
4. 备份恢复只有文档,没有最小验证
4. 备份恢复仍没有最小快照+restore smoke或虽有脚本但未形成可验证基线
---

View File

@@ -0,0 +1,61 @@
# P1-8 backup restore drill
最后更新: 2026-06-14
状态: 本地最小演练方案已落地,生产主机安装/告警仍待外部收口
## 1. 目标
把“只能证明文件可复制”的状态,提升到“可对快照执行 restore smoke验证最小服务链可用”。
## 2. 本次落地范围
- 新增 `scripts/backup_snapshot.sh`
- 扩展 `scripts/backup_verify.sh`
- 新增 `scripts/backup_restore_smoke.py`
-`ops/cron``ops/systemd` 示例接入口径
- 补 pytest 验证用例 `tests/test_backup_workflow.py`
## 3. 演练步骤
### 3.1 生成快照
```bash
bash scripts/backup_snapshot.sh /tmp/gaokao-backups
```
### 3.2 对快照执行恢复校验
```bash
bash scripts/backup_verify.sh --from-backup /tmp/gaokao-backups/backup-<UTC_TIMESTAMP>
```
### 3.3 校验成功标准
- `manifest_ok` 输出存在
- SQLite 文件输出 `sqlite_ok`
- restore smoke JSON 至少包含:
- `health_status = 200`
- `portal_status = 200`
- `portal_report = 200`
- `portal_pdf = 200`
## 4. 设计约束
1. restore smoke 只在临时恢复副本上写入测试订单,不改原始快照
2. 若仓库内不存在现成交付物smoke 允许在恢复副本下生成极小 HTML/PDF 占位物
3. 本方案不宣称“完整生产恢复完成”,只证明最小服务链可被恢复副本支撑
## 5. 非目标
- 异地备份
- 主机级定时器安装
- 失败告警链
- 密钥轮换真实执行记录
- 完整支付/退款/交付全链恢复
## 6. 后续外部动作
- 在目标主机安装 cron 或 systemd timer
- 将备份日志接入监控/通知
- 形成季度恢复演练记录
- 补密钥轮换 runbook 与演练记录

View File

@@ -0,0 +1,19 @@
# 安装前请替换仓库路径、日志路径和备份根目录。
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
GAOKAO_BACKUP_ROOT=/var/backups/gaokao
GAOKAO_BACKUP_KEEP=7
GAOKAO_DB_PATH=/var/lib/gaokao/admin.db
GAOKAO_ORDERS_DB_PATH=/var/lib/gaokao/orders.db
GAOKAO_SHARE_DB_PATH=/var/lib/gaokao/share/short_links.db
GAOKAO_SHARE_REPORT_DIR=/var/lib/gaokao/share/reports
GAOKAO_BACKUP_ENV_FILE=/etc/gaokao/.env
GAOKAO_BACKUP_CONFIG_DIR=/etc/gaokao
GAOKAO_BACKUP_SECRETS_DIR=/var/lib/gaokao/secrets
# 每天 02:30 生成一份目录快照
30 2 * * * cd /srv/gaokao-volunteer-system && bash scripts/backup_snapshot.sh >> /var/log/gaokao-backup.log 2>&1
# 每周一 03:00 对最近一份快照执行 restore smoke在临时目录不改原快照
0 3 * * 1 cd /srv/gaokao-volunteer-system && latest=$(find "$GAOKAO_BACKUP_ROOT" -mindepth 1 -maxdepth 1 -type d -name 'backup-*' | sort | tail -n 1) && test -n "$latest" && bash scripts/backup_verify.sh --from-backup "$latest" >> /var/log/gaokao-backup-verify.log 2>&1

View File

@@ -0,0 +1,10 @@
[Unit]
Description=Gaokao volunteer system backup restore smoke
Wants=network-online.target
After=network-online.target gaokao-backup.service
[Service]
Type=oneshot
WorkingDirectory=/srv/gaokao-volunteer-system
Environment=GAOKAO_BACKUP_ROOT=/var/backups/gaokao
ExecStart=/bin/bash -lc 'latest=$(find "$GAOKAO_BACKUP_ROOT" -mindepth 1 -maxdepth 1 -type d -name "backup-*" | sort | tail -n 1); test -n "$latest"; bash scripts/backup_verify.sh --from-backup "$latest"'

View File

@@ -0,0 +1,10 @@
[Unit]
Description=Run gaokao backup restore smoke weekly
[Timer]
OnCalendar=Mon 03:00:00
Persistent=true
Unit=gaokao-backup-verify.service
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,18 @@
[Unit]
Description=Gaokao volunteer system backup snapshot
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
WorkingDirectory=/srv/gaokao-volunteer-system
Environment=GAOKAO_BACKUP_ROOT=/var/backups/gaokao
Environment=GAOKAO_BACKUP_KEEP=7
Environment=GAOKAO_DB_PATH=/var/lib/gaokao/admin.db
Environment=GAOKAO_ORDERS_DB_PATH=/var/lib/gaokao/orders.db
Environment=GAOKAO_SHARE_DB_PATH=/var/lib/gaokao/share/short_links.db
Environment=GAOKAO_SHARE_REPORT_DIR=/var/lib/gaokao/share/reports
Environment=GAOKAO_BACKUP_ENV_FILE=/etc/gaokao/.env
Environment=GAOKAO_BACKUP_CONFIG_DIR=/etc/gaokao
Environment=GAOKAO_BACKUP_SECRETS_DIR=/var/lib/gaokao/secrets
ExecStart=/bin/bash scripts/backup_snapshot.sh

View File

@@ -0,0 +1,10 @@
[Unit]
Description=Run gaokao backup snapshot daily
[Timer]
OnCalendar=*-*-* 02:30:00
Persistent=true
Unit=gaokao-backup.service
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,230 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import os
import sys
import uuid
from contextlib import contextmanager
from pathlib import Path
from typing import Iterator
ROOT_DIR = Path(__file__).resolve().parents[1]
if str(ROOT_DIR) not in sys.path:
sys.path.insert(0, str(ROOT_DIR))
DEFAULT_JWT_SECRET = "backup-restore-smoke-secret-0123456789abcdef0123456789abcdef"
DEFAULT_FERNET_SECRET = "backup-restore-smoke-fernet-secret"
DEFAULT_ADMIN_PASSWORD = "backup-restore-pass-123"
def _choose_restore_file(backup_dir: Path, suffix: str) -> Path | None:
candidates = sorted(
path
for path in (backup_dir / "files").rglob(f"*{suffix}")
if path.is_file() and "__pycache__" not in path.parts
)
return candidates[0] if candidates else None
def _ensure_smoke_artifacts(backup_dir: Path) -> tuple[Path, Path]:
smoke_dir = backup_dir / "files" / "_restore_smoke"
smoke_dir.mkdir(parents=True, exist_ok=True)
html = _choose_restore_file(backup_dir, ".html")
pdf = _choose_restore_file(backup_dir, ".pdf")
if html is None:
html = smoke_dir / "restore-smoke-report.html"
html.write_text(
"<h1>备份恢复演练报告</h1><p>restore smoke generated</p>",
encoding="utf-8",
)
if pdf is None:
pdf = smoke_dir / "restore-smoke-report.pdf"
pdf.write_bytes(b"%PDF-1.4\nrestore-smoke\n")
return html, pdf
@contextmanager
def _patched_env(overrides: dict[str, str]) -> Iterator[None]:
original = {key: os.environ.get(key) for key in overrides}
try:
for key, value in overrides.items():
os.environ[key] = value
yield
finally:
for key, old_value in original.items():
if old_value is None:
os.environ.pop(key, None)
else:
os.environ[key] = old_value
def run_restore_smoke(backup_dir: str | Path) -> dict[str, object]:
from fastapi.testclient import TestClient
from admin.app import create_app
from admin.config import load_settings
from data.customer_portal.token import issue_portal_token
from data.orders.dao import OrdersDAO
from data.orders.models import Order
from data.payments.service import PaymentService
root = Path(backup_dir).resolve()
if not root.is_dir():
raise FileNotFoundError(f"backup dir not found: {root}")
db_dir = root / "db"
admin_db = db_dir / "admin.db"
orders_db = db_dir / "orders.db"
share_db = db_dir / "short_links.db"
share_report_dir = root / "files" / "reports"
if not share_report_dir.exists():
share_report_dir = root / "files"
html_path, pdf_path = _ensure_smoke_artifacts(root)
order_id = f"GKO-BACKUP-SMOKE-{uuid.uuid4().hex[:8].upper()}"
overrides = {
"GAOKAO_ENV": os.environ.get("GAOKAO_ENV", "dev"),
"GAOKAO_DB_PATH": str(admin_db),
"GAOKAO_ORDERS_DB_PATH": str(orders_db),
"GAOKAO_SHARE_DB_PATH": str(share_db),
"GAOKAO_SHARE_REPORT_DIR": str(share_report_dir),
"GAOKAO_ORDERS_FERNET_KEY": os.environ.get(
"GAOKAO_ORDERS_FERNET_KEY", DEFAULT_FERNET_SECRET
),
"GAOKAO_JWT_SECRET": os.environ.get("GAOKAO_JWT_SECRET", DEFAULT_JWT_SECRET),
"GAOKAO_ADMIN_USER": os.environ.get("GAOKAO_ADMIN_USER", "admin"),
"GAOKAO_ADMIN_PASS": os.environ.get(
"GAOKAO_ADMIN_PASS", DEFAULT_ADMIN_PASSWORD
),
"GAOKAO_PAYMENT_PROVIDER": "mock",
"GAOKAO_PAYMENT_BASE_URL": "http://testserver",
"GAOKAO_PAYMENT_WEBHOOK_SECRET": os.environ.get(
"GAOKAO_PAYMENT_WEBHOOK_SECRET", "backup-restore-smoke-payment-secret"
),
}
with _patched_env(overrides):
settings = load_settings()
app = create_app(settings)
with TestClient(app) as client:
health = client.get("/health")
if health.status_code != 200:
raise RuntimeError(f"health failed: {health.status_code} {health.text}")
order = Order(
id=order_id,
source="web",
service_version="standard",
amount_cents=9900,
status="pending",
customer_name="备份演练家长",
customer_phone="13800138000",
candidate_name="恢复演练考生",
candidate_province="湖南",
)
with OrdersDAO.connect(settings.orders_db_path) as dao:
dao.create(
order, actor="backup_restore_smoke", reason="seed_pending_order"
)
payment_service = PaymentService.for_db(
settings.orders_db_path,
base_url=settings.payment_base_url,
webhook_secret=settings.payment_webhook_secret,
)
checkout = payment_service.create_checkout(
order.id, portal_token="backup-smoke-token"
)
payload, headers = payment_service.provider.build_webhook_request(
payment_id=checkout.payment_id,
amount_cents=order.amount_cents,
provider_trade_no=f"SMOKE-{order.id}",
)
payment_service.handle_webhook(payload, headers.get("X-Mock-Signature", ""))
with OrdersDAO.connect(settings.orders_db_path) as dao:
dao.update(
order.id,
{"audit_report": str(html_path), "pdf_path": str(pdf_path)},
actor="backup_restore_smoke",
reason="attach_restore_artifacts",
)
dao.transition_status(
order.id,
"serving",
actor="backup_restore_smoke",
reason="processing",
)
dao.transition_status(
order.id,
"delivered",
actor="backup_restore_smoke",
reason="report_ready",
)
token = issue_portal_token(order.id, settings.jwt_secret)
status_page = client.get(f"/portal/{token}/status")
if status_page.status_code != 200 or "报告已就绪" not in status_page.text:
raise RuntimeError(
f"portal status failed: {status_page.status_code} {status_page.text}"
)
report_page = client.get(f"/portal/{token}/report")
if report_page.status_code != 200:
raise RuntimeError(
f"portal report failed: {report_page.status_code} {report_page.text}"
)
pdf_resp = client.get(f"/portal/{token}/report.pdf")
if pdf_resp.status_code != 200:
raise RuntimeError(
f"portal pdf failed: {pdf_resp.status_code} {pdf_resp.text}"
)
if not pdf_resp.headers.get("content-type", "").startswith(
"application/pdf"
):
raise RuntimeError(
f"portal pdf content-type invalid: {pdf_resp.headers.get('content-type')}"
)
if not pdf_resp.content.startswith(b"%PDF-"):
raise RuntimeError("portal pdf payload is not a PDF header")
result = {
"backup_dir": str(root),
"admin_db": str(admin_db),
"orders_db": str(orders_db),
"report_html": str(html_path),
"report_pdf": str(pdf_path),
"smoke_order_id": order_id,
"health_status": 200,
"portal_status": 200,
"portal_report": 200,
"portal_pdf": 200,
"pdf_bytes": len(pdf_resp.content),
}
return result
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="Run restore smoke test against a staged backup directory"
)
parser.add_argument(
"--backup-dir",
required=True,
help="staged backup directory containing db/ and files/",
)
args = parser.parse_args(argv)
result = run_restore_smoke(args.backup_dir)
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())

152
scripts/backup_snapshot.sh Normal file
View File

@@ -0,0 +1,152 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
BACKUP_ROOT="${1:-${GAOKAO_BACKUP_ROOT:-${ROOT_DIR}/var/backups}}"
TIMESTAMP="$(date -u +%Y%m%dT%H%M%SZ)"
SNAPSHOT_DIR="${BACKUP_ROOT}/backup-${TIMESTAMP}"
KEEP_COUNT="${GAOKAO_BACKUP_KEEP:-7}"
ADMIN_DB="${GAOKAO_DB_PATH:-${ROOT_DIR}/data/orders/admin.db}"
ORDERS_DB="${GAOKAO_ORDERS_DB_PATH:-${ROOT_DIR}/data/orders.db}"
SHARE_DB="${GAOKAO_SHARE_DB_PATH:-${ROOT_DIR}/data/share/short_links.db}"
SHARE_REPORT_DIR="${GAOKAO_SHARE_REPORT_DIR:-${ROOT_DIR}/data/share/reports}"
EXAMPLE_REPORT_DIR="${ROOT_DIR}/data/examples"
ENV_FILE="${GAOKAO_BACKUP_ENV_FILE:-}"
CONFIG_DIR="${GAOKAO_BACKUP_CONFIG_DIR:-}"
SECRETS_DIR="${GAOKAO_BACKUP_SECRETS_DIR:-}"
log() {
printf '[backup-snapshot] %s\n' "$1"
}
copy_file_to() {
local src="$1"
local dest="$2"
if [[ -n "$src" && -f "$src" ]]; then
mkdir -p "$(dirname "$dest")"
cp "$src" "$dest"
log "copied file: $src -> $dest"
else
log "skip missing file: ${src:-<empty>}"
fi
}
copy_dir_to() {
local src="$1"
local dest="$2"
if [[ -n "$src" && -d "$src" ]]; then
mkdir -p "$dest"
cp -R "$src/." "$dest/"
log "copied directory: $src -> $dest"
else
log "skip missing directory: ${src:-<empty>}"
fi
}
write_manifest() {
ADMIN_DB="$ADMIN_DB" \
ORDERS_DB="$ORDERS_DB" \
SHARE_DB="$SHARE_DB" \
SHARE_REPORT_DIR="$SHARE_REPORT_DIR" \
EXAMPLE_REPORT_DIR="$EXAMPLE_REPORT_DIR" \
ENV_FILE="$ENV_FILE" \
CONFIG_DIR="$CONFIG_DIR" \
SECRETS_DIR="$SECRETS_DIR" \
python3 - "$SNAPSHOT_DIR" <<'PY'
from __future__ import annotations
import hashlib
import json
import os
import sys
from pathlib import Path
root = Path(sys.argv[1]).resolve()
manifest_path = root / "manifest.json"
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as fh:
for chunk in iter(lambda: fh.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
manifest = {
"generated_at": root.name.removeprefix("backup-"),
"snapshot_dir": str(root),
"source_paths": {
"admin_db": os.environ.get("ADMIN_DB", ""),
"orders_db": os.environ.get("ORDERS_DB", ""),
"share_db": os.environ.get("SHARE_DB", ""),
"share_report_dir": os.environ.get("SHARE_REPORT_DIR", ""),
"example_report_dir": os.environ.get("EXAMPLE_REPORT_DIR", ""),
"env_file": os.environ.get("ENV_FILE", ""),
"config_dir": os.environ.get("CONFIG_DIR", ""),
"secrets_dir": os.environ.get("SECRETS_DIR", ""),
},
"files": [],
}
for path in sorted(root.rglob("*")):
if not path.is_file() or path == manifest_path:
continue
manifest["files"].append(
{
"path": str(path.relative_to(root)),
"size_bytes": path.stat().st_size,
"sha256": sha256_file(path),
}
)
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
print(manifest_path)
PY
}
prune_old_snapshots() {
local backup_root="$1"
local keep_count="$2"
if ! [[ "$keep_count" =~ ^[0-9]+$ ]] || (( keep_count < 1 )); then
log "invalid GAOKAO_BACKUP_KEEP=$keep_count, skip prune"
return
fi
mapfile -t snapshots < <(find "$backup_root" -mindepth 1 -maxdepth 1 -type d -name 'backup-*' | sort)
local total="${#snapshots[@]}"
if (( total <= keep_count )); then
return
fi
local prune_count=$((total - keep_count))
local i
for ((i = 0; i < prune_count; i++)); do
rm -rf "${snapshots[$i]}"
log "pruned old snapshot: ${snapshots[$i]}"
done
}
main() {
mkdir -p "$SNAPSHOT_DIR"
log "snapshot dir: $SNAPSHOT_DIR"
copy_file_to "$ADMIN_DB" "$SNAPSHOT_DIR/db/$(basename "$ADMIN_DB")"
copy_file_to "$ORDERS_DB" "$SNAPSHOT_DIR/db/$(basename "$ORDERS_DB")"
copy_file_to "$SHARE_DB" "$SNAPSHOT_DIR/db/$(basename "$SHARE_DB")"
copy_dir_to "$SHARE_REPORT_DIR" "$SNAPSHOT_DIR/files/reports"
copy_dir_to "$EXAMPLE_REPORT_DIR" "$SNAPSHOT_DIR/files/examples"
copy_file_to "$ENV_FILE" "$SNAPSHOT_DIR/config/.env"
copy_dir_to "$CONFIG_DIR" "$SNAPSHOT_DIR/config/deploy"
copy_dir_to "$SECRETS_DIR" "$SNAPSHOT_DIR/secrets"
local manifest_path
manifest_path="$(write_manifest)"
log "manifest written: $manifest_path"
prune_old_snapshots "$BACKUP_ROOT" "$KEEP_COUNT"
log "backup snapshot finished"
printf '%s\n' "$SNAPSHOT_DIR"
}
main "$@"

View File

@@ -2,13 +2,28 @@
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
VERIFY_DIR="${1:-$(mktemp -d /tmp/gaokao-backup-verify-XXXXXX)}"
SOURCE_BACKUP_DIR=""
VERIFY_DIR=""
SKIP_SMOKE="0"
ADMIN_DB="${GAOKAO_DB_PATH:-${ROOT_DIR}/data/orders/admin.db}"
ORDERS_DB="${GAOKAO_ORDERS_DB_PATH:-${ROOT_DIR}/data/orders.db}"
SHARE_DB="${GAOKAO_SHARE_DB_PATH:-${ROOT_DIR}/data/share/short_links.db}"
SHARE_REPORT_DIR="${GAOKAO_SHARE_REPORT_DIR:-${ROOT_DIR}/data/share/reports}"
EXAMPLE_REPORT_DIR="${ROOT_DIR}/data/examples"
usage() {
cat <<'EOF'
Usage:
bash scripts/backup_verify.sh [verify_dir]
bash scripts/backup_verify.sh --from-backup /path/to/backup-dir [--skip-smoke]
Default mode copies live SQLite/files into a temporary verify dir, then validates:
- SQLite files are readable
- manifest checksums (if manifest.json exists)
- restore smoke via FastAPI TestClient (unless --skip-smoke)
EOF
}
log() {
printf '[backup-verify] %s\n' "$1"
}
@@ -30,13 +45,69 @@ copy_dir_if_exists() {
local dest_dir="$2"
if [[ -d "$src" ]]; then
mkdir -p "$dest_dir"
cp -R "$src" "$dest_dir/"
cp -R "$src/." "$dest_dir/"
log "copied directory: $src"
else
log "skip missing directory: $src"
fi
}
stage_live_sources() {
log "verify dir: $VERIFY_DIR"
copy_if_exists "$ADMIN_DB" "$VERIFY_DIR/db"
copy_if_exists "$ORDERS_DB" "$VERIFY_DIR/db"
copy_if_exists "$SHARE_DB" "$VERIFY_DIR/db"
copy_dir_if_exists "$SHARE_REPORT_DIR" "$VERIFY_DIR/files/reports"
copy_dir_if_exists "$EXAMPLE_REPORT_DIR" "$VERIFY_DIR/files/examples"
}
stage_existing_backup() {
if [[ ! -d "$SOURCE_BACKUP_DIR" ]]; then
printf 'backup dir not found: %s\n' "$SOURCE_BACKUP_DIR" >&2
exit 1
fi
log "staging backup dir: $SOURCE_BACKUP_DIR -> $VERIFY_DIR"
mkdir -p "$VERIFY_DIR"
cp -R "$SOURCE_BACKUP_DIR/." "$VERIFY_DIR/"
}
verify_manifest_if_present() {
local manifest_path="$1"
if [[ ! -f "$manifest_path" ]]; then
log "skip missing manifest: $manifest_path"
return
fi
log "verifying manifest checksums"
python3 - "$manifest_path" <<'PY'
from __future__ import annotations
import hashlib
import json
import sys
from pathlib import Path
manifest_path = Path(sys.argv[1]).resolve()
root = manifest_path.parent
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
errors: list[str] = []
for item in manifest.get("files", []):
rel = item["path"]
target = root / rel
if not target.is_file():
errors.append(f"missing file: {rel}")
continue
digest = hashlib.sha256(target.read_bytes()).hexdigest()
if digest != item["sha256"]:
errors.append(f"sha256 mismatch: {rel}")
if errors:
for err in errors:
print(err)
raise SystemExit(1)
print(f"manifest_ok files={len(manifest.get('files', []))}")
PY
}
verify_sqlite_file() {
local db_path="$1"
if [[ ! -f "$db_path" ]]; then
@@ -46,6 +117,7 @@ verify_sqlite_file() {
import sqlite3
import sys
from pathlib import Path
p = Path(sys.argv[1])
conn = sqlite3.connect(p)
try:
@@ -57,16 +129,7 @@ finally:
PY
}
main() {
mkdir -p "$VERIFY_DIR"
log "verify dir: $VERIFY_DIR"
copy_if_exists "$ADMIN_DB" "$VERIFY_DIR/db"
copy_if_exists "$ORDERS_DB" "$VERIFY_DIR/db"
copy_if_exists "$SHARE_DB" "$VERIFY_DIR/db"
copy_dir_if_exists "$SHARE_REPORT_DIR" "$VERIFY_DIR/files"
copy_dir_if_exists "$EXAMPLE_REPORT_DIR" "$VERIFY_DIR/files"
verify_artifacts() {
log "verifying copied sqlite files"
if [[ -d "$VERIFY_DIR/db" ]]; then
while IFS= read -r -d '' db_file; do
@@ -76,9 +139,61 @@ main() {
log "verifying copied report artifacts"
if [[ -d "$VERIFY_DIR/files" ]]; then
find "$VERIFY_DIR/files" -type f | sort | sed 's#^#[backup-verify] artifact: #'
find "$VERIFY_DIR/files" -type f | sort | sed 's#^#[backup-verify] artifact: #'
fi
}
run_restore_smoke() {
if [[ "$SKIP_SMOKE" == "1" ]]; then
log "skip restore smoke"
return
fi
log "running restore smoke"
python3 "$ROOT_DIR/scripts/backup_restore_smoke.py" --backup-dir "$VERIFY_DIR"
}
parse_args() {
while (( $# > 0 )); do
case "$1" in
--from-backup)
SOURCE_BACKUP_DIR="$2"
shift 2
;;
--skip-smoke)
SKIP_SMOKE="1"
shift
;;
-h|--help)
usage
exit 0
;;
*)
if [[ -n "$VERIFY_DIR" ]]; then
printf 'unexpected argument: %s\n' "$1" >&2
exit 1
fi
VERIFY_DIR="$1"
shift
;;
esac
done
}
main() {
parse_args "$@"
VERIFY_DIR="${VERIFY_DIR:-$(mktemp -d /tmp/gaokao-backup-verify-XXXXXX)}"
if [[ -n "$SOURCE_BACKUP_DIR" ]]; then
stage_existing_backup
else
mkdir -p "$VERIFY_DIR"
stage_live_sources
fi
verify_manifest_if_present "$VERIFY_DIR/manifest.json"
verify_artifacts
run_restore_smoke
log "backup verification finished"
}

View File

@@ -0,0 +1,14 @@
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
if __name__ == "__main__":
from data.orders.retention_cleanup import main
raise SystemExit(main())

View File

@@ -0,0 +1,122 @@
from __future__ import annotations
import json
import os
import sqlite3
import subprocess
from pathlib import Path
from admin.db import ensure_schema
from data.orders.schema import apply_schema
PROJECT_ROOT = Path(__file__).resolve().parents[1]
def _bootstrap_share_db(db_path: str) -> None:
conn = sqlite3.connect(db_path)
try:
conn.execute("CREATE TABLE IF NOT EXISTS short_links (id INTEGER PRIMARY KEY)")
conn.commit()
finally:
conn.close()
def _prepare_backup_sources(settings, tmp_path: Path) -> dict[str, str]:
ensure_schema(settings.db_path)
apply_schema(settings.orders_db_path).close()
_bootstrap_share_db(settings.share_db_path)
share_report_dir = Path(settings.share_report_dir)
share_report_dir.mkdir(parents=True, exist_ok=True)
(share_report_dir / "sample-report.json").write_text("{}", encoding="utf-8")
env_file = tmp_path / ".env.runtime"
env_file.write_text("GAOKAO_ENV=dev\n", encoding="utf-8")
config_dir = tmp_path / "deploy-config"
config_dir.mkdir(parents=True, exist_ok=True)
(config_dir / "docker-compose.yml").write_text("services: {}\n", encoding="utf-8")
secrets_dir = tmp_path / "secrets"
secrets_dir.mkdir(parents=True, exist_ok=True)
(secrets_dir / "jwt.secret").write_text("secret", encoding="utf-8")
return {
"GAOKAO_DB_PATH": settings.db_path,
"GAOKAO_ORDERS_DB_PATH": settings.orders_db_path,
"GAOKAO_SHARE_DB_PATH": settings.share_db_path,
"GAOKAO_SHARE_REPORT_DIR": settings.share_report_dir,
"GAOKAO_BACKUP_ENV_FILE": str(env_file),
"GAOKAO_BACKUP_CONFIG_DIR": str(config_dir),
"GAOKAO_BACKUP_SECRETS_DIR": str(secrets_dir),
}
def _run_snapshot(env: dict[str, str], backup_root: Path) -> Path:
proc = subprocess.run(
["bash", "scripts/backup_snapshot.sh", str(backup_root)],
cwd=PROJECT_ROOT,
text=True,
capture_output=True,
env=env,
check=False,
)
assert proc.returncode == 0, proc.stderr
return Path(proc.stdout.strip().splitlines()[-1])
def test_backup_snapshot_creates_manifest_and_prunes_old_backups(settings, tmp_path):
env = {
**os.environ,
**_prepare_backup_sources(settings, tmp_path),
"GAOKAO_BACKUP_KEEP": "2",
}
backup_root = tmp_path / "backups"
(backup_root / "backup-20240101T000000Z").mkdir(parents=True, exist_ok=True)
(backup_root / "backup-20240102T000000Z").mkdir(parents=True, exist_ok=True)
snapshot_dir = _run_snapshot(env, backup_root)
assert snapshot_dir.is_dir()
manifest_path = snapshot_dir / "manifest.json"
assert manifest_path.is_file()
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest_paths = {item["path"] for item in manifest["files"]}
assert "db/admin.db" in manifest_paths
assert "db/orders.db" in manifest_paths
assert "db/short_links.db" in manifest_paths
assert "config/.env" in manifest_paths
assert "config/deploy/docker-compose.yml" in manifest_paths
assert "secrets/jwt.secret" in manifest_paths
assert any(path.startswith("files/examples/") for path in manifest_paths)
remaining = sorted(path.name for path in backup_root.iterdir() if path.is_dir())
assert len(remaining) == 2
assert "backup-20240101T000000Z" not in remaining
assert snapshot_dir.name in remaining
def test_backup_verify_runs_restore_smoke_on_snapshot(settings, tmp_path):
env = {
**os.environ,
**_prepare_backup_sources(settings, tmp_path),
"GAOKAO_PAYMENT_PROVIDER": "alipay",
}
backup_root = tmp_path / "backups"
snapshot_dir = _run_snapshot(env, backup_root)
proc = subprocess.run(
["bash", "scripts/backup_verify.sh", "--from-backup", str(snapshot_dir)],
cwd=PROJECT_ROOT,
text=True,
capture_output=True,
env=env,
check=False,
)
assert proc.returncode == 0, proc.stderr
assert "manifest_ok files=" in proc.stdout
assert '"portal_status": 200' in proc.stdout
assert '"portal_report": 200' in proc.stdout
assert '"portal_pdf": 200' in proc.stdout
assert "backup verification finished" in proc.stdout

View File

@@ -0,0 +1,45 @@
from __future__ import annotations
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
def test_delivery_retention_ops_artifacts_exist_and_reference_scripts():
runbook = PROJECT_ROOT / "docs/DELIVERY_RETENTION_OPS_RUNBOOK.md"
dispatch_service = PROJECT_ROOT / "deploy/systemd/gaokao-delivery-dispatch.service"
dispatch_timer = PROJECT_ROOT / "deploy/systemd/gaokao-delivery-dispatch.timer"
watchdog_service = PROJECT_ROOT / "deploy/systemd/gaokao-delivery-watchdog.service"
watchdog_timer = PROJECT_ROOT / "deploy/systemd/gaokao-delivery-watchdog.timer"
retention_service = PROJECT_ROOT / "deploy/systemd/gaokao-retention-cleanup.service"
retention_timer = PROJECT_ROOT / "deploy/systemd/gaokao-retention-cleanup.timer"
cron_file = PROJECT_ROOT / "deploy/cron/gaokao-jobs.crontab"
env_example = PROJECT_ROOT / "deploy/systemd/gaokao-jobs.env.example"
for path in (
runbook,
dispatch_service,
dispatch_timer,
watchdog_service,
watchdog_timer,
retention_service,
retention_timer,
cron_file,
env_example,
):
assert path.is_file(), path
assert "gaokao-delivery-dispatch.py" in dispatch_service.read_text(encoding="utf-8")
assert "gaokao-delivery-watchdog.py" in watchdog_service.read_text(encoding="utf-8")
assert "--retention-days" in retention_service.read_text(encoding="utf-8")
cron_text = cron_file.read_text(encoding="utf-8")
assert "gaokao-delivery-dispatch.py" in cron_text
assert "gaokao-delivery-watchdog.py" in cron_text
assert "gaokao-retention-cleanup.py --retention-days" in cron_text
runbook_text = runbook.read_text(encoding="utf-8")
assert "systemd" in runbook_text
assert "cron" in runbook_text
assert "gaokao-retention-cleanup.py --retention-days 180" in runbook_text

View File

@@ -89,4 +89,54 @@ def test_retention_cleanup_script_prints_summary(settings):
check=False,
)
assert proc.returncode == 0, proc.stderr
assert '"cutoff_iso": "2025-06-30T00:00:00+00:00"' in proc.stdout
assert '"dry_run": true' in proc.stdout
assert '"candidates": 1' in proc.stdout
def test_retention_cleanup_script_supports_retention_days(settings):
_seed_old_completed_order(
settings.orders_db_path, order_id="GKO-20250101-RETENTION-DAYS"
)
env = {**os.environ, "GAOKAO_ORDERS_DB_PATH": settings.orders_db_path}
proc = subprocess.run(
[
"python3",
"scripts/gaokao-retention-cleanup.py",
"--retention-days",
"180",
"--dry-run",
],
cwd=PROJECT_ROOT,
text=True,
capture_output=True,
env=env,
check=False,
)
assert proc.returncode == 0, proc.stderr
assert '"cutoff_iso":' in proc.stdout
assert '"dry_run": true' in proc.stdout
assert '"candidates": 1' in proc.stdout
def test_retention_cleanup_underscore_script_alias_works(settings):
_seed_old_completed_order(
settings.orders_db_path, order_id="GKO-20250101-RETENTION-ALIAS"
)
env = {**os.environ, "GAOKAO_ORDERS_DB_PATH": settings.orders_db_path}
proc = subprocess.run(
[
"python3",
"scripts/gaokao_retention_cleanup.py",
"--cutoff",
"2025-06-30T00:00:00+00:00",
"--dry-run",
],
cwd=PROJECT_ROOT,
text=True,
capture_output=True,
env=env,
check=False,
)
assert proc.returncode == 0, proc.stderr
assert '"candidates": 1' in proc.stdout