feat: 完善采购任务安全恢复 (#73)

This commit is contained in:
chengma
2026-08-10 00:27:52 +08:00
parent d6339a810f
commit c86c7482d9
15 changed files with 786 additions and 12 deletions
+215 -2
View File
@@ -23,6 +23,7 @@ from .task_models import (
TaskStatus,
TaskSummary,
TaskType,
TaskRunRecord,
)
@@ -156,7 +157,7 @@ class TaskRepository:
return self._to_detail(row) if row is not None else None
def recover_interrupted_work(self) -> None:
"""恢复上次异常退出留下的可重试状态。"""
"""恢复上次异常退出留下的任务,不猜测内存状态。"""
now = utc_now_iso()
connection = open_database(self._db_path)
@@ -193,6 +194,64 @@ class TaskRepository:
" AND task_id IN (SELECT id FROM pdd_tasks WHERE task_type = 'collect')",
(now, now),
)
purchase_runs = connection.execute(
"SELECT t.id AS task_id, r.irreversible_action_at"
" FROM pdd_tasks t JOIN task_runs r ON r.task_id = t.id"
" WHERE t.task_type = 'purchase' AND t.status = 'running'"
" AND r.run_status = 'running'"
" ORDER BY r.attempt_no DESC"
).fetchall()
recovered_task_ids = set()
for run in purchase_runs:
task_id = int(run["task_id"])
if task_id in recovered_task_ids:
continue
recovered_task_ids.add(task_id)
irreversible = connection.execute(
"SELECT 1 FROM task_runs WHERE task_id = ?"
" AND run_status = 'running'"
" AND irreversible_action_at IS NOT NULL LIMIT 1",
(task_id,),
).fetchone()
if irreversible is not None:
message = (
"上次采购在不可逆阶段中断,"
"只允许核对订单"
)
connection.execute(
"UPDATE task_runs SET run_status = 'manual_review',"
" current_step = 'reconcile_purchase',"
" error_code = 'PURCHASE_OUTCOME_UNKNOWN',"
" error_message = ?, finished_at = ?, updated_at = ?"
" WHERE task_id = ? AND run_status = 'running'",
(message, now, now, task_id),
)
connection.execute(
"UPDATE pdd_tasks SET status = 'manual_review',"
" current_step = 'reconcile_purchase',"
" last_error_code = 'PURCHASE_OUTCOME_UNKNOWN',"
" last_error_message = ?, finished_at = ?, updated_at = ?"
" WHERE id = ?",
(message, now, now, task_id),
)
else:
message = "采购演练上次执行中断,已关闭旧执行记录"
connection.execute(
"UPDATE task_runs SET run_status = 'failed',"
" error_code = 'CLIENT_INTERRUPTED',"
" error_message = ?, finished_at = ?, updated_at = ?"
" WHERE task_id = ? AND run_status = 'running'",
(message, now, now, task_id),
)
connection.execute(
"UPDATE pdd_tasks SET status = 'claimed',"
" current_step = 'purchase_recovery_ready',"
" retry_count = retry_count + 1,"
" last_error_code = 'CLIENT_INTERRUPTED',"
" last_error_message = ?, finished_at = NULL,"
" updated_at = ? WHERE id = ?",
(message, now, task_id),
)
finally:
connection.close()
@@ -224,6 +283,39 @@ class TaskRepository:
connection.close()
return self._to_detail(row) if row is not None else None
def next_purchase_reconcile_task(self) -> Optional[TaskDetail]:
"""返回最早一条只允许读取核对的采购任务。"""
connection = open_database(self._db_path)
try:
row = connection.execute(
"SELECT * FROM pdd_tasks WHERE task_type = 'purchase'"
" AND status = 'manual_review'"
" AND current_step = 'reconcile_purchase'"
" ORDER BY received_at ASC, id ASC LIMIT 1"
).fetchone()
finally:
connection.close()
return self._to_detail(row) if row is not None else None
def latest_task_run(
self, remote_task_id: str
) -> Optional[TaskRunRecord]:
"""返回任务最新的执行记录。"""
connection = open_database(self._db_path)
try:
row = connection.execute(
"SELECT r.* FROM task_runs r"
" JOIN pdd_tasks t ON t.id = r.task_id"
" WHERE t.remote_task_id = ?"
" ORDER BY r.attempt_no DESC LIMIT 1",
(remote_task_id,),
).fetchone()
finally:
connection.close()
return self._to_task_run(row) if row is not None else None
def next_runnable_task(
self, *, include_purchase: bool
) -> Optional[TaskDetail]:
@@ -479,6 +571,92 @@ class TaskRepository:
finally:
connection.close()
def save_purchase_reconciliation(
self,
remote_task_id: str,
attempt_id: str,
match_status: str,
diagnostics: Dict[str, object],
) -> None:
"""保存一次只读订单核对结果,任务仍留给人工确认。"""
allowed = {"matched", "not_found", "ambiguous", "unknown"}
if match_status not in allowed:
raise ValueError("采购核对结果无效")
now = utc_now_iso()
step = (
"reconcile_completed"
if match_status == "matched"
else "reconcile_manual_review"
)
messages = {
"matched": "只读核对发现唯一候选订单,请人工确认",
"not_found": "只读核对未找到订单,不得重新下单",
"ambiguous": "只读核对发现多个候选订单,请人工确认",
"unknown": "无法确定采购结果,不得重新下单",
}
message = messages[match_status]
saved_diagnostics = dict(diagnostics)
saved_diagnostics["match_status"] = match_status
connection = open_database(self._db_path)
try:
with connection:
task = connection.execute(
"SELECT id, task_type, status, current_step FROM pdd_tasks"
" WHERE remote_task_id = ?",
(remote_task_id,),
).fetchone()
if task is None:
raise ValueError(f"任务 {remote_task_id} 不存在")
if task["task_type"] != TaskType.PURCHASE.value:
raise ValueError("当前任务不是采购任务")
if (
task["status"] != TaskStatus.MANUAL_REVIEW.value
or task["current_step"] != "reconcile_purchase"
):
raise ValueError("采购任务当前不在待核对状态")
run = connection.execute(
"SELECT irreversible_action_at, run_status, current_step"
" FROM task_runs"
" WHERE task_id = ? AND attempt_id = ?",
(task["id"], attempt_id),
).fetchone()
if run is None or run["irreversible_action_at"] is None:
raise ValueError("只有已进入不可逆阶段的运行才能核对")
if (
run["run_status"] != RunStatus.MANUAL_REVIEW.value
or run["current_step"] != "reconcile_purchase"
):
raise ValueError("采购执行记录已经核对或状态已变更")
run_cursor = connection.execute(
"UPDATE task_runs SET run_status = 'manual_review',"
" current_step = ?, error_code = 'PURCHASE_RECONCILED',"
" error_message = ?, diagnostics_json = ?, updated_at = ?"
" WHERE task_id = ? AND attempt_id = ?",
(
step,
message,
json.dumps(saved_diagnostics, ensure_ascii=False),
now,
task["id"],
attempt_id,
),
)
if run_cursor.rowcount != 1:
raise ValueError("采购执行记录核对保存失败")
task_cursor = connection.execute(
"UPDATE pdd_tasks SET status = 'manual_review',"
" current_step = ?, last_error_code = 'PURCHASE_RECONCILED',"
" last_error_message = ?, updated_at = ? WHERE id = ?"
" AND status = 'manual_review'"
" AND current_step = 'reconcile_purchase'",
(step, message, now, task["id"]),
)
if task_cursor.rowcount != 1:
raise ValueError("采购任务状态已变更,核对结果未保存")
finally:
connection.close()
def save_purchase_result(
self,
remote_task_id: str,
@@ -628,7 +806,8 @@ class TaskRepository:
}.get(status, RunStatus.FAILED)
cursor = connection.execute(
"UPDATE task_runs SET run_status = ?, current_step = ?,"
" error_code = ?, error_message = ?, finished_at = ?,"
" error_code = ?, error_message = ?, diagnostics_json = ?,"
" finished_at = ?,"
" updated_at = ? WHERE task_id = ? AND attempt_id = ?"
" AND run_status = 'running'",
(
@@ -636,6 +815,7 @@ class TaskRepository:
checked_step,
error_code,
error_message,
json.dumps(diagnostics or {}, ensure_ascii=False),
now,
now,
task["id"],
@@ -935,6 +1115,39 @@ class TaskRepository:
sent_at=row["sent_at"],
)
@staticmethod
def _to_task_run(row: sqlite3.Row) -> TaskRunRecord:
diagnostics = (
TaskRepository._load_json_object(row["diagnostics_json"])
if row["diagnostics_json"] is not None
else None
)
result_data = (
TaskRepository._load_json_object(row["result_data"])
if row["result_data"] is not None
else None
)
return TaskRunRecord(
id=row["id"],
task_id=row["task_id"],
attempt_id=row["attempt_id"],
attempt_no=row["attempt_no"],
device_address=row["device_address"],
run_status=RunStatus(row["run_status"]),
current_step=row["current_step"],
started_at=row["started_at"],
finished_at=row["finished_at"],
irreversible_action_at=row["irreversible_action_at"],
order_submitted_at=row["order_submitted_at"],
error_code=row["error_code"],
error_message=row["error_message"],
diagnostics_json=diagnostics,
result_data=result_data,
artifact_directory=row["artifact_directory"],
created_at=row["created_at"],
updated_at=row["updated_at"],
)
@staticmethod
def _validate_page(limit: int, offset: int) -> None:
if not 1 <= limit <= MAX_PAGE_SIZE: