Files
gaokao-volunteer-system/data/orders/deletion_service.py
Hermes Agent 7c14384fb2 feat(intake): 扩展 IntakePayload 字段集 + candidate_province 必填
IntakePayload 新增 14 个字段覆盖完整志愿填报偏好:
- 学校地域/类型偏好 (school_region_preferences / school_preference_types)
- 目标学校/不喜欢的专业 (target_schools / disliked_majors)
- 优先策略/毕业计划/学费偏好/就业地区偏好
- 家庭背景/行业资源/额外备注
- 兴趣评估三字段 (type/result/notes)
- candidate_province 提交时必填(与 portal 表单对齐)

public_flow / deletion_service 同步更新字段引用
requirements-dev: 补 httpx2 (HTTP/2 测试支持)
2026-06-25 09:26:31 +08:00

283 lines
9.4 KiB
Python

from __future__ import annotations
import json
import sqlite3
from dataclasses import dataclass
from pathlib import Path
from data.orders.dao import OrderNotFound, OrdersDAO
from data.orders.models import utc_now_iso
from data.orders.schema import apply_schema
AUDIT_SCHEMA_SQL = """
CREATE TABLE IF NOT EXISTS order_deletion_audits (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id TEXT NOT NULL,
action TEXT NOT NULL CHECK(action IN ('delete','anonymize')),
actor TEXT,
reason TEXT,
files_deleted INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_order_deletion_audits_order_id ON order_deletion_audits(order_id);
"""
# 状态 → 触发保留期门禁的子集。
# pending 视为未付款,可由后台直接清理(避免未支付订单卡在 retention 周期里);
# paid/serving/delivered/completed/refunded 才进入"服务完成后 180 天"口径。
# 实际抛 BusinessError 的 gate 在 admin/routes/orders.py 路由层 (避免 data.orders 反向依赖 admin)。
RETENTION_GUARDED_STATUSES = frozenset({
"paid",
"serving",
"delivered",
"completed",
"refunded",
})
DEFAULT_RETENTION_DAYS = 180
_DEFAULT_TRUSTED_PORTAL_UPLOAD_ROOT = Path("data/portal_uploads").resolve()
_DEFAULT_TRUSTED_ARTIFACT_ROOTS = (
(Path("data/portal_uploads").resolve().parent / "order_artifacts").resolve(),
Path("data/examples").resolve(),
Path("data/share/reports").resolve(),
)
def _is_within_roots(path: Path, roots: tuple[Path, ...]) -> bool:
return any(root == path or root in path.parents for root in roots)
@dataclass
class DeletionResult:
order_id: str
action: str
files_deleted: int = 0
class OrderDeletionService:
def __init__(
self,
conn: sqlite3.Connection,
*,
portal_upload_root: Path = _DEFAULT_TRUSTED_PORTAL_UPLOAD_ROOT,
artifact_roots: tuple[Path, ...] = _DEFAULT_TRUSTED_ARTIFACT_ROOTS,
) -> None:
self._conn = conn
self._portal_upload_root = portal_upload_root.resolve()
self._artifact_roots = tuple(root.resolve() for root in artifact_roots)
self._conn.executescript(AUDIT_SCHEMA_SQL)
self._conn.commit()
@classmethod
def for_db(
cls,
db_path: str | Path,
*,
portal_upload_root: Path = _DEFAULT_TRUSTED_PORTAL_UPLOAD_ROOT,
artifact_roots: tuple[Path, ...] = _DEFAULT_TRUSTED_ARTIFACT_ROOTS,
) -> "OrderDeletionService":
conn = apply_schema(db_path)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
return cls(
conn,
portal_upload_root=portal_upload_root,
artifact_roots=artifact_roots,
)
def close(self) -> None:
self._conn.close()
def delete_order(self, order_id: str, *, actor: str, reason: str) -> DeletionResult:
with OrdersDAO(self._conn) as dao:
try:
order = dao.get(order_id)
except OrderNotFound:
raise
intake_payload = self._get_intake_payload(order_id)
files_deleted = self._delete_order_files(
order_id,
intake_payload=intake_payload,
artifact_paths=(order.audit_report, order.pdf_path),
)
deleted = dao.delete(order_id)
if not deleted:
raise OrderNotFound(f"订单不存在: {order_id}")
self._insert_audit(
order_id=order_id,
action="delete",
actor=actor,
reason=reason,
files_deleted=files_deleted,
)
self._conn.commit()
return DeletionResult(
order_id=order_id, action="deleted", files_deleted=files_deleted
)
def anonymize_order(
self, order_id: str, *, actor: str, reason: str
) -> DeletionResult:
with OrdersDAO(self._conn) as dao:
try:
dao.get(order_id)
except OrderNotFound:
raise
intake_payload = self._get_intake_payload(order_id)
files_deleted = self._delete_order_files(
order_id,
intake_payload=intake_payload,
artifact_paths=(),
)
now = utc_now_iso()
self._conn.execute(
"""
UPDATE orders
SET customer_name=?,
customer_phone_enc=NULL,
customer_phone_hash=NULL,
customer_wechat=NULL,
customer_email=NULL,
candidate_name=?,
candidate_id_card_enc=NULL,
candidate_interests=NULL,
candidate_strong_subjects=NULL,
candidate_weak_subjects=NULL,
candidate_family=NULL,
notes=?,
status_updated_at=?
WHERE id=?
""",
(
"已匿名化",
"匿名考生",
f"[ANONYMIZED] {reason}",
now,
order_id,
),
)
if self._table_exists("order_intakes"):
self._conn.execute(
"UPDATE order_intakes SET payload_json='{}', updated_at=?, status='submitted' WHERE order_id=?",
(now, order_id),
)
if self._table_exists("payments"):
self._conn.execute(
"UPDATE payments SET callback_payload=NULL, checkout_token=NULL WHERE order_id=?",
(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",
actor=actor,
reason=reason,
files_deleted=files_deleted,
)
self._conn.commit()
return DeletionResult(
order_id=order_id,
action="anonymized",
files_deleted=files_deleted,
)
def audit_count(self, order_id: str) -> int:
row = self._conn.execute(
"SELECT COUNT(*) FROM order_deletion_audits WHERE order_id=?",
(order_id,),
).fetchone()
return int(row[0] if row is not None else 0)
def _insert_audit(
self,
*,
order_id: str,
action: str,
actor: str,
reason: str,
files_deleted: int,
) -> None:
self._conn.execute(
"INSERT INTO order_deletion_audits(order_id, action, actor, reason, files_deleted, created_at) VALUES (?, ?, ?, ?, ?, ?)",
(order_id, action, actor, reason, files_deleted, utc_now_iso()),
)
def _get_intake_payload(self, order_id: str) -> dict[str, object]:
if not self._table_exists("order_intakes"):
return {}
row = self._conn.execute(
"SELECT payload_json FROM order_intakes WHERE order_id=?",
(order_id,),
).fetchone()
if row is None:
return {}
raw = row[0] or "{}"
try:
parsed = json.loads(str(raw))
except Exception:
return {}
return parsed if isinstance(parsed, dict) else {}
def _delete_order_files(
self,
order_id: str,
*,
intake_payload: dict[str, object],
artifact_paths: tuple[str | None, ...],
) -> int:
deleted = self._delete_artifacts(*artifact_paths)
deleted += self._delete_portal_attachments(order_id, intake_payload)
return deleted
def _delete_portal_attachments(
self, order_id: str, intake_payload: dict[str, object]
) -> int:
attachments = intake_payload.get("attachments")
if not isinstance(attachments, list):
return 0
deleted = 0
parent_dirs: set[Path] = set()
for item in attachments:
if not isinstance(item, dict):
continue
raw_path = item.get("storage_path")
if not raw_path:
continue
path = Path(str(raw_path)).expanduser().resolve()
if not _is_within_roots(path, (self._portal_upload_root,)):
continue
if path.is_file():
path.unlink()
deleted += 1
parent_dirs.add(path.parent)
order_dir = self._portal_upload_root / order_id
if order_dir.exists() and order_dir.is_dir() and not any(order_dir.iterdir()):
order_dir.rmdir()
return deleted
def _table_exists(self, table_name: str) -> bool:
row = self._conn.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=? LIMIT 1",
(table_name,),
).fetchone()
return row is not None
def _delete_artifacts(self, *paths: str | None) -> int:
deleted = 0
for raw_path in paths:
if not raw_path:
continue
path = Path(raw_path).expanduser().resolve()
if not _is_within_roots(path, self._artifact_roots):
continue
if path.is_file():
path.unlink()
deleted += 1
return deleted