fix: align retention cleanup across notifications logs and share telemetry

This commit is contained in:
Hermes Agent
2026-06-18 15:20:16 +08:00
parent 9a8ad91216
commit a82c5b3cca
7 changed files with 287 additions and 18 deletions

View File

@@ -220,3 +220,41 @@ def test_admin_anonymize_order_removes_portal_attachments(
intake_store.close()
assert intake is not None
assert intake.payload == {}
def test_admin_anonymize_order_scrubs_delivery_notifications_payload(
client, auth_headers, settings
):
order = _seed_order(settings.orders_db_path, order_id="GKO-20260618-ANON-NOTIFY")
_mark_paid(settings, order)
notification_service = DeliveryNotificationService.for_db(settings.orders_db_path)
try:
notification_service.notify_event(
order.id,
event_type="report_ready",
channel="station",
payload_json='{"customer_email":"parent@example.com","report_path":"/tmp/report.pdf"}',
)
notification_service.mark_delivered(
order.id,
channel="station",
payload_json='{"customer_email":"parent@example.com","report_path":"/tmp/report.pdf"}',
)
finally:
notification_service.close()
resp = client.delete(
f"/api/orders/{order.id}?mode=anonymize&reason=retention_expired",
headers=auth_headers,
)
assert resp.status_code == 200, resp.text
notification_service = DeliveryNotificationService.for_db(settings.orders_db_path)
try:
events = notification_service.list_events(order.id)
finally:
notification_service.close()
assert len(events) == 1
assert events[0].payload_json == "{}"

View File

@@ -262,6 +262,48 @@ def test_portal_deletion_request_is_logged_and_visible_in_admin(client, auth_hea
assert "parent@example.com" in admin_page.text
def test_deletion_request_log_is_purged_by_retention_cleanup(settings):
from pathlib import Path
from data.orders.retention_cleanup import run_cleanup
order = Order(
id="GKO-20250101-DELETE-LOG",
source="web",
service_version="standard",
amount_cents=9900,
status="completed",
customer_name="张家长",
customer_phone="13800138000",
candidate_name="张三",
candidate_province="湖南",
created_at="2025-01-01T00:00:00+00:00",
completed_at="2025-01-02T00:00:00+00:00",
status_updated_at="2025-01-02T00:00:00+00:00",
)
with OrdersDAO.connect(settings.orders_db_path) as dao:
dao.create(order, actor="test", reason="seed_old_completed")
log_path = Path(settings.deletion_request_log_path)
log_path.parent.mkdir(parents=True, exist_ok=True)
log_path.write_text(
'{"created_at":"2025-01-02T00:00:00Z","order_id":"GKO-20250101-DELETE-LOG","requester_contact":"parent@example.com"}\n'
'{"created_at":"2026-01-02T00:00:00Z","order_id":"GKO-KEEP-LOG","requester_contact":"keep@example.com"}\n',
encoding="utf-8",
)
result = run_cleanup(
settings.orders_db_path,
cutoff_iso="2025-06-30T00:00:00+00:00",
apply=True,
)
assert result.anonymized == 1
remaining = log_path.read_text(encoding="utf-8")
assert "GKO-20250101-DELETE-LOG" not in remaining
assert "GKO-KEEP-LOG" in remaining
def test_order_info_page_uses_checkbox_checked_state_for_submit(client, settings):
order = _seed_order(settings.orders_db_path, order_id="GKO-20260615-INFO-CHECKED")
_mark_paid(settings, order)

View File

@@ -125,6 +125,11 @@ class OrderDeletionService:
"UPDATE order_intakes SET payload_json='{}', updated_at=? WHERE order_id=?",
(now, order_id),
)
if self._table_exists("delivery_notifications"):
self._conn.execute(
"UPDATE delivery_notifications SET payload_json='{}' WHERE order_id=?",
(order_id,),
)
self._insert_audit(
order_id=order_id,
action="anonymize",

View File

@@ -4,6 +4,7 @@ import argparse
import json
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from data.orders.dao import OrdersDAO
from data.orders.deletion_service import OrderDeletionService
@@ -16,6 +17,8 @@ class RetentionCleanupResult:
scanned: int = 0
candidates: int = 0
anonymized: int = 0
deletion_logs_pruned: int = 0
share_events_pruned: int = 0
_TERMINAL_STATUSES = {"completed", "refunded"}
@@ -55,6 +58,54 @@ def _iter_candidates(db_path: str, cutoff_iso: str) -> tuple[int, list[str]]:
return scanned, candidates
def _prune_deletion_request_log(log_path: str, candidate_ids: list[str]) -> int:
path = Path(log_path)
if not path.exists() or not candidate_ids:
return 0
keep: list[str] = []
pruned = 0
for line in path.read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
try:
item = json.loads(line)
except json.JSONDecodeError:
keep.append(line)
continue
if str(item.get("order_id") or "") in candidate_ids:
pruned += 1
continue
keep.append(json.dumps(item, ensure_ascii=False))
path.write_text("\n".join(keep) + ("\n" if keep else ""), encoding="utf-8")
return pruned
def _prune_share_access_events(share_db_path: str, candidate_ids: list[str]) -> int:
if not candidate_ids:
return 0
from data.share.short_link import ShortLinkService
service = ShortLinkService(db_path=share_db_path)
with service._connect() as conn:
rows = conn.execute(
"SELECT code FROM share_links WHERE report_id IN ({})".format(
",".join("?" for _ in candidate_ids)
),
tuple(candidate_ids),
).fetchall()
codes = [str(row[0]) for row in rows]
if not codes:
return 0
conn.execute(
"DELETE FROM share_link_access_events WHERE code IN ({})".format(
",".join("?" for _ in codes)
),
tuple(codes),
)
conn.commit()
return int(conn.total_changes)
def resolve_cutoff_iso(
*, cutoff_iso: str | None = None, retention_days: int | None = None
) -> str:
@@ -68,8 +119,24 @@ def resolve_cutoff_iso(
def run_cleanup(
db_path: str, *, cutoff_iso: str, apply: bool
db_path: str,
*,
cutoff_iso: str,
apply: bool,
deletion_request_log_path: str | None = None,
share_db_path: str | None = None,
) -> RetentionCleanupResult:
if deletion_request_log_path is None or share_db_path is None:
try:
from admin.config import load_settings
settings = load_settings()
except Exception:
settings = None
if deletion_request_log_path is None and settings is not None:
deletion_request_log_path = settings.deletion_request_log_path
if share_db_path is None and settings is not None:
share_db_path = settings.share_db_path
scanned, candidate_ids = _iter_candidates(db_path, cutoff_iso)
result = RetentionCleanupResult(
cutoff_iso=cutoff_iso,
@@ -88,6 +155,14 @@ def run_cleanup(
result.anonymized += 1
finally:
service.close()
if deletion_request_log_path:
result.deletion_logs_pruned = _prune_deletion_request_log(
deletion_request_log_path, candidate_ids
)
if share_db_path:
result.share_events_pruned = _prune_share_access_events(
share_db_path, candidate_ids
)
return result
@@ -110,10 +185,11 @@ def build_parser() -> argparse.ArgumentParser:
def main(argv: list[str] | None = None, *, db_path: str | None = None) -> int:
if db_path is None:
from admin.config import load_settings
from admin.config import load_settings
db_path = load_settings().orders_db_path
settings = load_settings()
if db_path is None:
db_path = settings.orders_db_path
parser = build_parser()
args = parser.parse_args(argv)
try:
@@ -123,7 +199,13 @@ def main(argv: list[str] | None = None, *, db_path: str | None = None) -> int:
)
except ValueError as exc:
parser.error(str(exc))
result = run_cleanup(db_path, cutoff_iso=cutoff_iso, apply=not args.dry_run)
result = run_cleanup(
db_path,
cutoff_iso=cutoff_iso,
apply=not args.dry_run,
deletion_request_log_path=getattr(settings, "deletion_request_log_path", None),
share_db_path=getattr(settings, "share_db_path", None),
)
print(json.dumps(result.__dict__, ensure_ascii=False, indent=2))
return 0

View File

@@ -8,15 +8,17 @@
## 2. 数据分类与建议保留期
| 数据类型 | 示例 | 建议保留期 | 删除方式 |
| ------------ | --------------------------- | ----------------------- | -------------------------- |
| 订单主数据 | orders 表、状态、金额、套餐 | 服务完成后 180 天 | 逻辑受理 + 物理删除/匿名化 |
| 资料提交数据 | `order_intakes` payload | 服务完成后 180 天 | 与订单一起删除 |
| 报告交付文件 | HTML/PDF 报告 | 服务完成后 180 天 | 删除文件并清理路径 |
| 通知审计 | `delivery_notifications` | 服务完成后 180 天 | 随订单删除或单独清理 |
| 支付审计 | 支付单/回调记录 | 至少 180 天,争议中延长 | 审计期结束后清理 |
| 系统日志 | 服务日志/错误日志 | 30~90 天 | 日志轮转清理 |
| 脱敏案例数据 | 仅用于内部复盘 | 长期,但必须脱敏 | 仅保留脱敏版 |
| 数据类型 | 示例 | 建议保留期 | 删除方式 |
| -------------- | ------------------------------------ | ----------------------- | -------------------------------------- |
| 订单主数据 | orders 表、状态、金额、套餐 | 服务完成后 180 天 | 逻辑受理 + 物理删除/匿名化 |
| 资料提交数据 | `order_intakes` payload | 服务完成后 180 天 | 与订单一起删除 |
| 报告交付文件 | HTML/PDF 报告 | 服务完成后 180 天 | 删除文件并清理路径 |
| 通知审计 | `delivery_notifications` | 服务完成后 180 天 | 删除时物理清除;匿名化时 payload 置空 |
| 支付审计 | 支付单/回调记录 | 至少 180 天,争议中延长 | 审计期结束后清理 |
| 删除申请日志 | `deletion-requests.jsonl` | 随订单 retention 同步 | retention 作业按候选订单移除日志项 |
| 分享访问遥测 | `share_link_access_events` | 随订单 retention 同步 | retention 作业删除对应 report_id 事件 |
| 系统日志 | 服务日志/错误日志 | 30~90 天 | 日志轮转清理 |
| 脱敏案例数据 | 仅用于内部复盘 | 长期,但必须脱敏 | 仅保留脱敏版 |
## 3. 删除请求处理原则
@@ -32,6 +34,7 @@
- 删除资料提交记录
- 删除报告 HTML/PDF 文件
- 删除关联通知记录
- 删除申请日志 / 分享访问遥测中的对应订单旁路记录
4. 在审计记录中留下“已删除/已匿名化”的最小操作痕迹
## 4. 当前系统能力与缺口
@@ -41,9 +44,11 @@
- `OrdersDAO.delete()` 删除订单能力
- ON DELETE CASCADE 清理部分关联表
- 后台 `DELETE /api/orders/{id}?mode=delete|anonymize&reason=...` 最小执行入口
- 删除时自动清理 HTML/PDF 报告文件
- 删除时自动清理 HTML/PDF 报告文件与 portal 上传附件
- 匿名化时同步清空 `delivery_notifications.payload_json`
- `order_deletion_audits` 最小审计表
- `scripts/gaokao_retention_cleanup.py` / `scripts/gaokao-retention-cleanup.py` 保留期清理入口
- retention 作业会同步修剪 `deletion-requests.jsonl``share_link_access_events`
- `scripts/gaokao-retention-cleanup.py --retention-days 180` 最小定时调度入口
- `docs/DELIVERY_RETENTION_OPS_RUNBOOK.md`
- `deploy/systemd/gaokao-retention-cleanup.{service,timer}`
@@ -51,7 +56,7 @@
尚缺:
- 前台/客服删除工单流程
- 前台/客服删除工单流程仍是 portal 提交 + 后台处理,不是全自动闭环
- 更细粒度的匿名化策略(如案例长期保留脱敏版)
- 目标生产主机上的实际安装/值班留痕(仓库内只有调度样例,不代表已部署)
@@ -73,4 +78,4 @@
- runbook`docs/DELIVERY_RETENTION_OPS_RUNBOOK.md`
- systemd/cron 样例:`deploy/systemd/gaokao-retention-cleanup.{service,timer}` / `deploy/cron/gaokao-jobs.crontab`
真相边界:当前保留期作业执行的是后台匿名化,不应误报成“用户删除请求流程已经完整上线”。
真相边界:当前保留期作业执行的是后台匿名化,并同步修剪删除申请日志/分享访问遥测;这仍不应误报成“用户删除请求流程已经完整上线”。

View File

@@ -42,7 +42,8 @@
- 当前清理动作是 **匿名化**`anonymize_order`),不是物理删除。
- 只处理 `completed/refunded` 且锚点时间早于 cutoff 的订单。
- 它会清理订单侧敏感字段并写审计,但不等于“前台删除工单流程已上线”
- 它会清理订单侧敏感字段、清空 `delivery_notifications.payload_json`,并修剪 `deletion-requests.jsonl` / `share_link_access_events` 中对应订单的旁路记录
- 它不等于“前台删除工单流程已上线”。
## 2. 统一环境配置
@@ -76,6 +77,13 @@ python3 scripts/gaokao-retention-cleanup.py --cutoff 2025-12-31T00:00:00+00:00 -
python3 scripts/gaokao-retention-cleanup.py --retention-days 180
```
执行后至少抽查:
- 候选订单已匿名化
- 对应 `delivery_notifications.payload_json` 已变成 `{}`
- `deletion-requests.jsonl` 中对应订单记录已被修剪
- `share_link_access_events` 中对应 report_id 的访问事件已被修剪
退出码:
- dispatcher`0` = 脚本执行完成(结果看 JSON

View File

@@ -329,3 +329,92 @@ def test_delivery_watchdog_exits_nonzero_when_failures_detected(settings, tmp_pa
assert proc.returncode == 2, proc.stdout + proc.stderr
assert '"failed": 1' in proc.stdout
def test_retention_cleanup_scrubs_notification_payload_for_expired_orders(settings):
from data.orders.retention_cleanup import run_cleanup
order = Order(
id="GKO-20250101-RETENTION-NOTIFY",
source="web",
service_version="standard",
amount_cents=9900,
status="completed",
customer_name="张家长",
customer_phone="13800138000",
candidate_name="张三",
candidate_province="湖南",
created_at="2025-01-01T00:00:00+00:00",
completed_at="2025-01-02T00:00:00+00:00",
status_updated_at="2025-01-02T00:00:00+00:00",
)
with OrdersDAO.connect(settings.orders_db_path) as dao:
dao.create(order, actor="test", reason="seed_old_completed")
service = DeliveryNotificationService.for_db(settings.orders_db_path)
try:
service.notify_event(
order.id,
event_type="report_ready",
channel="email",
payload_json='{"customer_email":"parent@example.com","detail":"keep"}',
)
finally:
service.close()
result = run_cleanup(
settings.orders_db_path,
cutoff_iso="2025-06-30T00:00:00+00:00",
apply=True,
)
assert result.anonymized == 1
service = DeliveryNotificationService.for_db(settings.orders_db_path)
try:
events = service.list_events(order.id)
finally:
service.close()
assert len(events) == 1
assert events[0].payload_json == "{}"
def test_share_access_events_have_retention_cleanup(settings):
from data.orders.retention_cleanup import run_cleanup
from data.share.short_link import ShortLinkService
order = Order(
id="GKO-20250101-RETENTION-SHARE",
source="web",
service_version="standard",
amount_cents=9900,
status="completed",
customer_name="张家长",
customer_phone="13800138000",
candidate_name="张三",
candidate_province="湖南",
created_at="2025-01-01T00:00:00+00:00",
completed_at="2025-01-02T00:00:00+00:00",
status_updated_at="2025-01-02T00:00:00+00:00",
)
with OrdersDAO.connect(settings.orders_db_path) as dao:
dao.create(order, actor="test", reason="seed_old_completed")
share = ShortLinkService(db_path=settings.share_db_path)
link = share.create(report_id=order.id)
share.resolve(link.code, visitor_token="visitor-1")
result = run_cleanup(
settings.orders_db_path,
cutoff_iso="2025-06-30T00:00:00+00:00",
apply=True,
share_db_path=settings.share_db_path,
)
assert result.anonymized == 1
with share._connect() as conn:
row = conn.execute(
"SELECT COUNT(*) FROM share_link_access_events WHERE code=?",
(link.code,),
).fetchone()
assert row is not None
assert int(row[0]) == 0