fix: align retention cleanup across notifications logs and share telemetry
This commit is contained in:
@@ -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 == "{}"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
真相边界:当前保留期作业执行的是后台匿名化,不应误报成“用户删除请求流程已经完整上线”。
|
||||
真相边界:当前保留期作业执行的是后台匿名化,并同步修剪删除申请日志/分享访问遥测;这仍不应误报成“用户删除请求流程已经完整上线”。
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user