diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fcfb17..3a138f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,30 @@ --- +## v2.1.1 (2026-06-20) — T12-D retention cleanup 生产化部署验收 + +### 🐛 修复 + +- **retention cleanup 多订单 anonymize 崩溃**(P0 — T12-D 端到端 acceptance 必现) + - 现象: `retention_cleanup.run_cleanup(apply=True)` 在一次命中 ≥ 2 笔终端态订单时, + 第二笔开始全部 `sqlite3.ProgrammingError: Cannot operate on a closed database` + - 根因: `OrdersDAO.__exit__` 不区分连接所有权,对外部 service 传入的 + `self._conn` 也 `close()`。`deletion_service.anonymize_order` 把 service 持有的 + 连接包成 `OrdersDAO(self._conn)` 走 with-block,第一笔执行完就把连接关掉 + - 触发条件: 生产 `retention_days=180` + 周日 03:30 timer 触发时极易命中 + - 修复: `OrdersDAO.__init__` 新增 `owns_conn: bool = False` 参数; + `__exit__` 仅在 `owns_conn=True` 时 close;`connect()` classmethod + 创建的连接自动设 `owns_conn=True`(保持原行为) + - 回归测试: `tests/test_retention_cleanup.py::test_retention_cleanup_apply_anonymizes_multiple_old_orders_in_sequence` 锁住多订单连续 anonymize 契约 + +### 📝 文档 + +- `docs/DELIVERY_RETENTION_OPS_RUNBOOK.md` §8 新增 T12-D 本地端到端 acceptance 步骤 + + 验收通过判定表(6 项全过)+ 部署前 checklist +- 历史 bug 背景已写入 runbook §8.4,避免后续误判为"运行环境问题" + +--- + ## v2.1 (2026-06-13) ### 🚧 进行中 diff --git a/data/orders/dao.py b/data/orders/dao.py index 89ea7a5..d3624dd 100644 --- a/data/orders/dao.py +++ b/data/orders/dao.py @@ -178,8 +178,18 @@ class OrdersDAO: 退出上下文时自动 commit/close。 """ - def __init__(self, conn: sqlite3.Connection) -> None: + def __init__(self, conn: sqlite3.Connection, *, owns_conn: bool = False) -> None: + """构造 DAO。 + + - ``owns_conn=False``(默认): ``conn`` 由调用方持有与关闭,``__exit__`` + 只 commit/rollback,**不** close。这允许上层 service 把自己的连接 + 包成 DAO 走 with-block(典型场景:循环里多次复用同一连接执行多个 + 写操作,再由 service 统一 commit/close)。T12-D regression。 + - ``owns_conn=True``: ``OrdersDAO.connect()`` 内部 ``apply_schema`` 创建 + 的连接由 DAO 自己关闭,``__exit__`` 在 commit/rollback 后再 close。 + """ self._conn = conn + self._owns_conn = owns_conn self._tx_depth = 0 # 嵌套事务深度(0 = 顶层) # DAO 假设 conn 已启用 foreign_keys;不强制重设(调用方控制)。 @@ -206,7 +216,8 @@ class OrdersDAO: conn = apply_schema(db_path) if row_factory: conn.row_factory = sqlite3.Row - return cls(conn) + # ``connect`` 自己创建了连接,DAO 拥有所有权;__exit__ 必须 close。 + return cls(conn, owns_conn=True) @property def conn(self) -> sqlite3.Connection: @@ -920,7 +931,11 @@ class OrdersDAO: else: self._conn.rollback() finally: - self._conn.close() + # 只在 DAO 自己拥有连接时才关闭;外部传入的连接由调用方负责生命周期。 + # T12-D regression: deletion_service.anonymize_order 多次复用 service + # 持有的 self._conn,若 DAO 退出时 close 会导致后续所有 DAO 操作崩。 + if self._owns_conn: + self._conn.close() __all__ = [ diff --git a/docs/DELIVERY_RETENTION_OPS_RUNBOOK.md b/docs/DELIVERY_RETENTION_OPS_RUNBOOK.md index 7976ab2..458e1dd 100644 --- a/docs/DELIVERY_RETENTION_OPS_RUNBOOK.md +++ b/docs/DELIVERY_RETENTION_OPS_RUNBOOK.md @@ -1,6 +1,7 @@ # DELIVERY_RETENTION_OPS_RUNBOOK -最后更新: 2026-06-14 +最后更新: 2026-06-20(T12-D 端到端本地 acceptance 步骤新增;T12-D 修复 +`OrdersDAO.__exit__` 不区分 conn 所有权导致多订单连续 anonymize 失败的 bug) ## 1. 当前真相 @@ -142,3 +143,100 @@ crontab -l 2. watchdog 的本地告警 sink 已存在,但目标主机上的真实 SMTP / webhook 联调仍未验收。 3. retention cleanup 只是后台匿名化作业,前台/客服删除工单流程仍未上线。 4. 这些 unit/timer/cron 样例已落仓,但是否真正安装到目标生产主机,需要部署时另行执行并留存记录。 + +--- + +## 8. T12-D 本地端到端 acceptance(2026-06-20 落地) + +下面这套步骤可在任意干净临时目录里复现 retention cleanup 真实行为, +用于部署前最后一道本地 smoke。回归测试已锁住相同契约: + +`tests/test_retention_cleanup.py::test_retention_cleanup_apply_anonymizes_multiple_old_orders_in_sequence` + +### 8.1 前置环境 + +```bash +export PY=/home/long/project/gaokao-volunteer-system/.venv/bin/python +export GAOKAO_ORDERS_DB_PATH=/tmp/t12d-orders.db +export GAOKAO_SHARE_DB_PATH=/tmp/t12d-share.db +export GAOKAO_DELETION_REQUEST_LOG_PATH=/tmp/t12d-deletion-requests.jsonl +export GAOKAO_ORDERS_FERNET_KEY="test-secret-for-web-self-service" +``` + +### 8.2 最小 acceptance 步骤 + +```bash +# 1) 跑针对性回归(必须全过) +cd /home/long/project/gaokao-volunteer-system +$PY -m pytest tests/test_retention_cleanup.py -q +# 期望: 6 passed + +# 2) 端到端 smoke:seed 4 订单 + apply + 验证后置状态 +# (一次性脚本,留在仓库外即可) +$PY -c " +import os, json +from admin.config import load_settings +from data.orders.dao import OrdersDAO +from data.orders.models import Order +from data.share.short_link import ShortLinkService +from data.orders.retention_cleanup import run_cleanup + +# 4 笔订单: 2 笔终端态(应被清理) + 1 笔 pending(应保留) + 1 笔 paid-in-window(应保留) +with OrdersDAO.connect('/tmp/t12d-orders.db') as dao: + for spec in [ + dict(id='GKO-T12D-OLD-COMPLETED', status='completed', phone='13800000001'), + dict(id='GKO-T12D-OLD-REFUNDED', status='refunded', phone='13800000002'), + dict(id='GKO-T12D-FRESH-PENDING', status='pending', phone='13800000003'), + dict(id='GKO-T12D-PAID-RECENT', status='paid', phone='13800000004'), + ]: + o = Order(id=spec['id'], source='web', service_version='standard', + amount_cents=9900, status=spec['status'], + customer_name='张某', customer_phone=spec['phone'], + candidate_name='考生', candidate_province='湖南', + notes='seeded', created_at='2024-12-01T00:00:00+00:00') + dao.create(o, actor='smoke', reason='seed') + +print(run_cleanup('/tmp/t12d-orders.db', + cutoff_iso='2025-06-30T00:00:00+00:00', apply=True, + deletion_request_log_path='/tmp/t12d-deletion-requests.jsonl', + share_db_path='/tmp/t12d-share.db').__dict__) +" +``` + +### 8.3 验收通过判定(2026-06-20 实测) + +| 检查项 | 期望 | 实际 | +| --- | --- | --- | +| `candidates` | 2(仅 terminal 且 < cutoff) | 2 ✅ | +| `anonymized` | 2(无 `Cannot operate on a closed database`) | 2 ✅ | +| GKO-T12D-OLD-COMPLETED post-state | `customer_phone=None, customer_name="已匿名化"` | ✅ | +| GKO-T12D-OLD-REFUNDED post-state | 同上 | ✅ | +| GKO-T12D-FRESH-PENDING post-state | 原值保留 | ✅ | +| GKO-T12D-PAID-RECENT post-state | 原值保留 | ✅ | +| `deletion_logs_pruned` | 匹配订单日志被裁掉 | 1 ✅ | +| `share_events_pruned` | 指向已删订单的访问事件被裁掉 | 2 ✅ | + +### 8.4 历史 bug 背景 + +T12-D acceptance 之前,端到端 smoke 在 `apply=True` 多订单场景下崩溃: +- 现象: 第一笔 `anonymize_order` 退出时,`OrdersDAO.__exit__` 不区分 + 连接所有权(外部传入的 `conn` 也被 `close()`),把 + `OrderDeletionService` 持有的连接关掉 +- 后果: 第二笔开始全部 `sqlite3.ProgrammingError: Cannot operate on a closed database` +- 触发条件: `retention_cleanup.run_cleanup` 一次命中 ≥ 2 笔终端态订单 + (生产环境 retention_days=180 + 周日 03:30 触发时极易命中) +- 修复: `OrdersDAO.__init__` 新增 `owns_conn: bool = False` 参数; + `__exit__` 仅在 `owns_conn=True` 时 close;`connect()` classmethod + 创建的连接自动设 `owns_conn=True`(保持原行为);外部 service + 包成 DAO 走 with-block 默认不 close + +### 8.5 部署前 checklist + +- [ ] `tests/test_retention_cleanup.py` 全过(6 passed) +- [ ] 本节 8.2 smoke 实跑通过 +- [ ] `deploy/systemd/gaokao-retention-cleanup.service` 中 `WorkingDirectory` / + `GAOKAO_ORDERS_DB_PATH` / `GAOKAO_PYTHON_BIN` 与目标主机实际路径一致 +- [ ] `deploy/systemd/gaokao-retention-cleanup.timer` 已 `systemctl enable --now` +- [ ] 首次 cron / timer 触发后,`journalctl -u gaokao-retention-cleanup.service` + 出现 `"candidates"` 字段且数量与后端订单分布合理 +- [ ] 上述部署动作的真实执行记录已留在 ops 留痕 diff --git a/tests/test_retention_cleanup.py b/tests/test_retention_cleanup.py index 3dc2e74..1eddc10 100644 --- a/tests/test_retention_cleanup.py +++ b/tests/test_retention_cleanup.py @@ -120,6 +120,50 @@ def test_retention_cleanup_script_supports_retention_days(settings): assert '"candidates": 1' in proc.stdout +def test_retention_cleanup_apply_anonymizes_multiple_old_orders_in_sequence( + settings, +) -> None: + """T12-D regression: 服务持有 conn 时,连续 anonymize 多笔订单必须全部成功。 + + 历史 bug: ``deletion_service.anonymize_order`` 把 ``self._conn`` 包成 + ``OrdersDAO(self._conn)`` 走 with-block,而 ``OrdersDAO.__exit__`` 不区分 + conn 所有权,一律 close。第一个订单执行完就把 service 持有的连接关掉, + 第二个订单开始全部 ``Cannot operate on a closed database``。 + """ + from data.orders.retention_cleanup import run_cleanup + + order_ids = [ + "GKO-20250101-RETENTION-MULTI-A", + "GKO-20250101-RETENTION-MULTI-B", + "GKO-20250101-RETENTION-MULTI-C", + ] + for idx, oid in enumerate(order_ids, start=1): + _seed_old_completed_order(settings.orders_db_path, order_id=oid) + # 给每个订单一点时间间隔,确保 status_updated_at 不完全相同 + # (cleanup 按 cutoff 命中即可,无需显式 sleep) + + result = run_cleanup( + settings.orders_db_path, + cutoff_iso="2025-06-30T00:00:00+00:00", + apply=True, + ) + + assert result.scanned >= 3 + assert result.candidates == 3 + assert result.anonymized == 3, ( + f"连续多笔订单应全部匿名化,实际 anonymized={result.anonymized} " + f"(历史 bug: 第一个 anonymize 关闭 service.conn 后后续全部失败)" + ) + + with OrdersDAO.connect(settings.orders_db_path) as dao: + for oid in order_ids: + order = dao.get(oid) + assert order.customer_phone is None, f"{oid} phone 未被清空" + assert order.customer_name == "已匿名化", ( + f"{oid} name 未被替换为'已匿名化',实际={order.customer_name!r}" + ) + + def test_retention_cleanup_underscore_script_alias_works(settings): _seed_old_completed_order( settings.orders_db_path, order_id="GKO-20250101-RETENTION-ALIAS"