Files
cmautobuy/client/src/task_repository.py
T

1917 lines
80 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""PDD 任务的 SQLite Repository。
Repository 是数据库访问入口。界面和自动化代码不应自行拼接任务 SQL。
"""
import json
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, Iterable, List, Optional, Tuple, Union
from uuid import uuid4
from .db import initialize_database, open_database
from .task_models import (
NewClaimedTask,
OutboxEventRecord,
OutboxEventType,
OutboxStatus,
RunStatus,
StartedTaskRun,
TaskDetail,
TaskFilters,
TaskRerunPlan,
TaskStatus,
TaskSummary,
TaskType,
TaskRunRecord,
)
PathValue = Union[str, Path]
MAX_PAGE_SIZE = 500
class DuplicateTaskError(ValueError):
"""相同远程任务编号已经存在,不能覆盖。"""
class CollectRerunError(ValueError):
"""当前任务不满足重新采集条件。"""
class PurchaseRerunError(ValueError):
"""当前任务不满足安全重新采购条件。"""
class TaskRemovalError(ValueError):
"""勾选任务不满足从普通列表移除的安全条件。"""
def utc_now_iso() -> str:
"""返回精确到秒的 UTC ISO 8601 时间。"""
return datetime.now(timezone.utc).isoformat(timespec="seconds").replace(
"+00:00", "Z"
)
class TaskRepository:
"""保存和查询本机已经领取的 PDD 任务。"""
def __init__(self, db_path: Optional[PathValue] = None):
self._db_path = initialize_database(db_path)
def add_claimed_task(
self, task: NewClaimedTask, received_at: Optional[str] = None
) -> int:
"""写入一条新领取任务,返回本地自增编号。
相同 ``remote_task_id`` 已存在时抛出 ``DuplicateTaskError``,
不覆盖已经保存的本地状态。
"""
now = received_at or utc_now_iso()
payload = json.dumps(task.admin_payload, ensure_ascii=False)
connection = open_database(self._db_path)
try:
try:
with connection:
cursor = connection.execute(
"INSERT INTO pdd_tasks ("
" remote_task_id, task_type, execution_mode, goods_id,"
" goods_url, title,"
" target_color, target_size, price_cent, quantity, status,"
" priority, version, admin_payload, received_at, created_at,"
" updated_at"
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
task.remote_task_id.strip(),
task.task_type.value,
task.execution_mode,
task.goods_id,
task.goods_url.strip(),
task.title,
task.target_color,
task.target_size,
task.price_cent,
task.quantity,
TaskStatus.CLAIMED.value,
task.priority,
task.version,
payload,
now,
now,
now,
),
)
return int(cursor.lastrowid)
except sqlite3.IntegrityError as exc:
if "pdd_tasks.remote_task_id" in str(exc):
raise DuplicateTaskError(
f"任务 {task.remote_task_id} 已经存在"
) from exc
raise
finally:
connection.close()
def list_tasks(
self,
filters: Optional[TaskFilters] = None,
limit: int = 50,
offset: int = 0,
) -> List[TaskSummary]:
"""分页查询任务摘要,默认按更新时间和本地编号倒序。"""
self._validate_page(limit, offset)
where_sql, parameters = self._build_where(filters or TaskFilters())
parameters.extend((limit, offset))
connection = open_database(self._db_path)
try:
rows = connection.execute(
"SELECT pdd_tasks.id, remote_task_id, task_type, goods_id, title,"
" shop_name, target_color, target_size, price_cent, quantity,"
" status, pdd_tasks.updated_at, latest_run.run_status AS latest_run_status,"
" CASE WHEN latest_run.run_status = 'running' THEN NULL"
" WHEN latest_run.started_at IS NOT NULL AND latest_run.finished_at IS NOT NULL"
" THEN MAX(0, CAST(strftime('%s', latest_run.finished_at)"
" - strftime('%s', latest_run.started_at) AS INTEGER))"
" ELSE NULL END AS duration_seconds"
" FROM pdd_tasks"
" LEFT JOIN task_runs AS latest_run ON latest_run.id = ("
" SELECT id FROM task_runs WHERE task_id = pdd_tasks.id"
" ORDER BY attempt_no DESC LIMIT 1)"
f"{where_sql}"
" ORDER BY pdd_tasks.updated_at DESC, pdd_tasks.id DESC"
" LIMIT ? OFFSET ?",
parameters,
).fetchall()
finally:
connection.close()
return [self._to_summary(row) for row in rows]
def count_tasks(self, filters: Optional[TaskFilters] = None) -> int:
"""返回符合筛选条件的任务总数。"""
where_sql, parameters = self._build_where(filters or TaskFilters())
connection = open_database(self._db_path)
try:
row = connection.execute(
f"SELECT COUNT(*) FROM pdd_tasks{where_sql}", parameters
).fetchone()
finally:
connection.close()
return int(row[0])
def remove_tasks_from_list(self, remote_task_ids: Iterable[str]) -> int:
"""把符合安全条件的终态任务从普通列表中软移除。
全部任务会在同一个事务中完成校验和更新;任一任务不安全时,
所有任务都保持原样。执行记录和 Outbox 不会被删除或修改。
"""
task_ids = tuple(
dict.fromkeys(
str(value).strip()
for value in remote_task_ids
if value is not None and str(value).strip()
)
)
if not task_ids:
raise TaskRemovalError("没有可删除的任务,请重新勾选。")
placeholders = ", ".join("?" for _ in task_ids)
terminal_statuses = {
TaskStatus.SUCCEEDED.value,
TaskStatus.FAILED.value,
TaskStatus.CANCELLED.value,
}
now = utc_now_iso()
connection = open_database(self._db_path)
try:
with connection:
rows = connection.execute(
"SELECT id, remote_task_id, status, removed_at FROM pdd_tasks"
f" WHERE remote_task_id IN ({placeholders})",
task_ids,
).fetchall()
found = {row["remote_task_id"]: row for row in rows}
missing = [task_id for task_id in task_ids if task_id not in found]
if missing:
raise TaskRemovalError(
f"本地找不到任务 {missing[0]},请刷新列表后重试。"
)
removed = next(
(row for row in rows if row["removed_at"] is not None), None
)
if removed is not None:
raise TaskRemovalError(
f"任务 {removed['remote_task_id']} 已不在普通列表,请刷新后重试。"
)
non_terminal = next(
(row for row in rows if row["status"] not in terminal_statuses),
None,
)
if non_terminal is not None:
raise TaskRemovalError(
f"任务 {non_terminal['remote_task_id']} 尚未结束,不能删除。"
)
irreversible = connection.execute(
"SELECT t.remote_task_id FROM task_runs r"
" JOIN pdd_tasks t ON t.id = r.task_id"
f" WHERE t.remote_task_id IN ({placeholders})"
" AND r.irreversible_action_at IS NOT NULL LIMIT 1",
task_ids,
).fetchone()
if irreversible is not None:
raise TaskRemovalError(
f"任务 {irreversible['remote_task_id']} 已进入不可逆阶段,"
"必须保留在列表中核对订单。"
)
unsent = connection.execute(
"SELECT t.remote_task_id, o.status FROM outbox_events o"
" JOIN pdd_tasks t ON t.id = o.task_id"
f" WHERE t.remote_task_id IN ({placeholders})"
" AND o.status <> 'sent' LIMIT 1",
task_ids,
).fetchone()
if unsent is not None:
raise TaskRemovalError(
f"任务 {unsent['remote_task_id']} 还有未发送完成的数据,"
"请先重新上报。"
)
cursor = connection.execute(
"UPDATE pdd_tasks SET removed_at = ?, updated_at = ?"
f" WHERE remote_task_id IN ({placeholders})"
" AND removed_at IS NULL",
(now, now, *task_ids),
)
if cursor.rowcount != len(task_ids):
raise TaskRemovalError("任务列表已发生变化,请刷新后重试。")
finally:
connection.close()
return len(task_ids)
def get_task(self, remote_task_id: str) -> Optional[TaskDetail]:
"""按稳定远程编号读取完整任务;不存在时返回 None。"""
connection = open_database(self._db_path)
try:
row = connection.execute(
"SELECT * FROM pdd_tasks WHERE remote_task_id = ?",
(remote_task_id,),
).fetchone()
finally:
connection.close()
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)
try:
with connection:
connection.execute(
"UPDATE outbox_events SET status = 'pending', updated_at = ?"
" WHERE status = 'sending'",
(now,),
)
# #35 之前,Admin 的失败响应缺少 result_id。Admin 实际已经
# 接收,但旧 Client 把这类 2xx 响应误标为永久失败。恢复为
# pending 后使用原幂等键重试,不会重复写入业务结果。
connection.execute(
"UPDATE outbox_events SET status = 'pending', updated_at = ?"
" WHERE status = 'failed'"
" AND last_error = 'Admin 提交响应字段不完整'",
(now,),
)
connection.execute(
"UPDATE pdd_tasks SET status = 'retry_wait',"
" current_step = 'interrupted', retry_count = retry_count + 1,"
" last_error_code = 'CLIENT_INTERRUPTED',"
" last_error_message = '客户端上次执行期间退出', updated_at = ?"
" WHERE status = 'running' AND task_type = 'collect'",
(now,),
)
connection.execute(
"UPDATE task_runs SET run_status = 'failed',"
" error_code = 'CLIENT_INTERRUPTED',"
" error_message = '客户端上次执行期间退出',"
" finished_at = ?, updated_at = ?"
" WHERE run_status = 'running' AND irreversible_action_at IS NULL"
" 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()
def next_collect_task(self) -> Optional[TaskDetail]:
"""返回最早的本地待执行采集任务。"""
connection = open_database(self._db_path)
try:
row = connection.execute(
"SELECT * FROM pdd_tasks"
" WHERE task_type = 'collect' AND status IN ('claimed', 'retry_wait')"
" 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 next_purchase_task(self) -> Optional[TaskDetail]:
"""返回最早的一条本地待执行采购任务。"""
connection = open_database(self._db_path)
try:
row = connection.execute(
"SELECT * FROM pdd_tasks"
" WHERE task_type = 'purchase' AND status = 'claimed'"
" 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 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 unresolved_irreversible_purchase(self) -> Optional[TaskDetail]:
"""查找仍在运行且已有不可逆标记的采购,防止继续控制设备。"""
connection = open_database(self._db_path)
try:
row = connection.execute(
"SELECT t.* 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'"
" AND r.irreversible_action_at IS NOT NULL"
" ORDER BY r.attempt_no DESC 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]:
"""按领取顺序返回当前执行器能够处理的最早本地任务。"""
if include_purchase:
where = (
"((task_type = 'collect'"
" AND status IN ('claimed', 'retry_wait'))"
" OR (task_type = 'purchase' AND status = 'claimed'))"
)
else:
where = (
"task_type = 'collect'"
" AND status IN ('claimed', 'retry_wait')"
)
connection = open_database(self._db_path)
try:
row = connection.execute(
f"SELECT * FROM pdd_tasks WHERE {where}"
" 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 validate_collect_rerun(self, remote_task_id: str) -> TaskDetail:
"""校验任务能否重新采集,成功时返回任务详情。"""
connection = open_database(self._db_path)
try:
row = connection.execute(
"SELECT * FROM pdd_tasks WHERE remote_task_id = ?",
(remote_task_id,),
).fetchone()
if row is None:
raise CollectRerunError(f"任务 {remote_task_id} 不存在")
self._check_collect_rerun(connection, row)
finally:
connection.close()
return self._to_detail(row)
def plan_rerun_batch(
self, remote_task_ids: Iterable[str], target_type: TaskType
) -> TaskRerunPlan:
"""批量预检并分类:可执行、类型不符、被安全条件阻止。"""
stable_ids = tuple(dict.fromkeys(str(value) for value in remote_task_ids if value))
eligible: List[str] = []
filtered: List[str] = []
blocked: List[Tuple[str, str]] = []
connection = open_database(self._db_path)
try:
for task_id in stable_ids:
row = connection.execute(
"SELECT * FROM pdd_tasks WHERE remote_task_id = ?", (task_id,)
).fetchone()
if row is None:
blocked.append((task_id, "任务不存在"))
continue
if row["task_type"] != target_type.value:
filtered.append(task_id)
continue
try:
if target_type is TaskType.COLLECT:
self._check_collect_rerun(connection, row)
else:
self._check_purchase_rerun(connection, row)
except (CollectRerunError, PurchaseRerunError) as exc:
blocked.append((task_id, str(exc)))
else:
eligible.append(task_id)
finally:
connection.close()
return TaskRerunPlan(
target_type, len(stable_ids), tuple(eligible), tuple(filtered), tuple(blocked)
)
def prepare_collect_rerun(self, remote_task_id: str) -> TaskDetail:
"""事务内把已结束的采集任务恢复为待执行,保留旧结果。"""
now = utc_now_iso()
connection = open_database(self._db_path)
try:
with connection:
row = connection.execute(
"SELECT * FROM pdd_tasks WHERE remote_task_id = ?",
(remote_task_id,),
).fetchone()
if row is None:
raise CollectRerunError(f"任务 {remote_task_id} 不存在")
self._check_collect_rerun(connection, row)
connection.execute(
"UPDATE pdd_tasks SET status = 'claimed',"
" current_step = 'rerun_requested', finished_at = NULL,"
" last_error_code = NULL, last_error_message = NULL,"
" updated_at = ? WHERE id = ?",
(now, row["id"]),
)
finally:
connection.close()
task = self.get_task(remote_task_id)
assert task is not None
return task
@staticmethod
def _check_collect_rerun(
connection: sqlite3.Connection, row: sqlite3.Row
) -> None:
"""检查重新采集的类型、终态和 Outbox 约束。"""
if row["task_type"] != TaskType.COLLECT.value:
raise CollectRerunError("采购任务不能重新执行,以免重复下单")
allowed_statuses = {
TaskStatus.CLAIMED.value,
TaskStatus.RETRY_WAIT.value,
TaskStatus.SUCCEEDED.value,
TaskStatus.FAILED.value,
TaskStatus.CANCELLED.value,
TaskStatus.MANUAL_REVIEW.value,
}
if row["status"] not in allowed_statuses:
status_name = {
TaskStatus.CLAIMED.value: "待执行",
TaskStatus.RUNNING.value: "执行中",
TaskStatus.RESULT_PENDING.value: "结果待提交",
}.get(row["status"], row["status"])
raise CollectRerunError(f"任务当前为“{status_name}”,不能重新采集")
unsent = connection.execute(
"SELECT event_type, status, last_error FROM outbox_events"
" WHERE task_id = ? AND status != 'sent'"
" ORDER BY id DESC LIMIT 1",
(row["id"],),
).fetchone()
if unsent is not None and unsent["status"] == OutboxStatus.FAILED.value:
reason = unsent["last_error"] or "Admin 未接收上次结果"
event_name = (
"失败信息"
if unsent["event_type"] == OutboxEventType.TASK_FAILURE.value
else "结果"
)
raise CollectRerunError(
f"任务上次{event_name}提交失败:{reason};"
"请先勾选任务点击“重新上报”"
)
if unsent is not None:
if unsent["event_type"] == OutboxEventType.TASK_FAILURE.value:
raise CollectRerunError(
"任务仍有未上报的失败信息,请先勾选任务点击“重新上报”"
)
raise CollectRerunError(
"任务仍有未发送的结果,请先勾选任务点击“重新上报”"
)
def validate_purchase_rerun(self, remote_task_id: str) -> TaskDetail:
"""校验采购任务能否重新采购;历史上已下单的任务永久拒绝。"""
connection = open_database(self._db_path)
try:
row = connection.execute(
"SELECT * FROM pdd_tasks WHERE remote_task_id = ?", (remote_task_id,)
).fetchone()
if row is None:
raise PurchaseRerunError(f"任务 {remote_task_id} 不存在")
self._check_purchase_rerun(connection, row)
finally:
connection.close()
return self._to_detail(row)
def prepare_purchase_rerun(self, remote_task_id: str) -> TaskDetail:
"""事务内恢复安全采购任务;不会清除任何历史执行记录。"""
now = utc_now_iso()
connection = open_database(self._db_path)
try:
with connection:
row = connection.execute(
"SELECT * FROM pdd_tasks WHERE remote_task_id = ?", (remote_task_id,)
).fetchone()
if row is None:
raise PurchaseRerunError(f"任务 {remote_task_id} 不存在")
self._check_purchase_rerun(connection, row)
connection.execute(
"UPDATE pdd_tasks SET status = 'claimed',"
" current_step = 'purchase_rerun_requested', finished_at = NULL,"
" last_error_code = NULL, last_error_message = NULL, updated_at = ?"
" WHERE id = ?",
(now, row["id"]),
)
finally:
connection.close()
task = self.get_task(remote_task_id)
assert task is not None
return task
@staticmethod
def _check_purchase_rerun(
connection: sqlite3.Connection, row: sqlite3.Row
) -> None:
if row["task_type"] != TaskType.PURCHASE.value:
raise PurchaseRerunError("采集任务不能重新采购")
irreversible = connection.execute(
"SELECT 1 FROM task_runs WHERE task_id = ?"
" AND irreversible_action_at IS NOT NULL LIMIT 1",
(row["id"],),
).fetchone()
if irreversible is not None:
raise PurchaseRerunError("任务历史上已进入下单阶段,只准核对订单,绝不重新下单")
allowed = {
TaskStatus.CLAIMED.value,
TaskStatus.RETRY_WAIT.value,
TaskStatus.SUCCEEDED.value,
TaskStatus.FAILED.value,
TaskStatus.CANCELLED.value,
TaskStatus.MANUAL_REVIEW.value,
}
if row["status"] not in allowed:
raise PurchaseRerunError(f"任务当前状态 {row['status']} 不能重新采购")
unsent = connection.execute(
"SELECT 1 FROM outbox_events WHERE task_id = ? AND status != 'sent' LIMIT 1",
(row["id"],),
).fetchone()
if unsent is not None:
raise PurchaseRerunError("任务仍有未发送数据,请先点击“重新上报”")
def start_collect_run(
self, remote_task_id: str, device_address: str
) -> StartedTaskRun:
"""原子地把待执行任务改为执行中,并创建一次运行记录。"""
now = utc_now_iso()
attempt_id = str(uuid4())
connection = open_database(self._db_path)
try:
with connection:
row = connection.execute(
"SELECT * FROM pdd_tasks WHERE remote_task_id = ?",
(remote_task_id,),
).fetchone()
if row is None:
raise ValueError(f"任务 {remote_task_id} 不存在")
if row["task_type"] != TaskType.COLLECT.value:
raise ValueError("当前只能执行采集任务")
if row["status"] not in {
TaskStatus.CLAIMED.value,
TaskStatus.RETRY_WAIT.value,
}:
raise ValueError(f"任务状态 {row['status']} 不能开始采集")
attempt_no = int(
connection.execute(
"SELECT COALESCE(MAX(attempt_no), 0) + 1"
" FROM task_runs WHERE task_id = ?",
(row["id"],),
).fetchone()[0]
)
connection.execute(
"UPDATE pdd_tasks SET status = 'running',"
" current_step = 'collecting', started_at = COALESCE(started_at, ?),"
" last_error_code = NULL, last_error_message = NULL, updated_at = ?"
" WHERE id = ?",
(now, now, row["id"]),
)
connection.execute(
"INSERT INTO task_runs (task_id, attempt_id, attempt_no,"
" device_address, run_status, current_step, started_at,"
" created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
row["id"], attempt_id, attempt_no, device_address,
RunStatus.RUNNING.value, "collecting", now, now, now,
),
)
task = self.get_task(remote_task_id)
assert task is not None
return StartedTaskRun(task, attempt_id, attempt_no)
finally:
connection.close()
def start_purchase_run(
self, remote_task_id: str, device_address: str
) -> StartedTaskRun:
"""原子地开始一次采购演练并创建独立运行记录。"""
now = utc_now_iso()
attempt_id = str(uuid4())
connection = open_database(self._db_path)
try:
with connection:
row = connection.execute(
"SELECT * FROM pdd_tasks WHERE remote_task_id = ?",
(remote_task_id,),
).fetchone()
if row is None:
raise ValueError(f"任务 {remote_task_id} 不存在")
if row["task_type"] != TaskType.PURCHASE.value:
raise ValueError("当前任务不是采购任务")
if row["status"] != TaskStatus.CLAIMED.value:
raise ValueError(
f"采购任务状态 {row['status']} 不能开始演练"
)
attempt_no = int(
connection.execute(
"SELECT COALESCE(MAX(attempt_no), 0) + 1"
" FROM task_runs WHERE task_id = ?",
(row["id"],),
).fetchone()[0]
)
connection.execute(
"UPDATE pdd_tasks SET status = 'running',"
" current_step = 'purchase_prepare',"
" started_at = COALESCE(started_at, ?),"
" last_error_code = NULL, last_error_message = NULL,"
" updated_at = ? WHERE id = ?",
(now, now, row["id"]),
)
connection.execute(
"INSERT INTO task_runs (task_id, attempt_id, attempt_no,"
" device_address, run_status, current_step, started_at,"
" created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
row["id"],
attempt_id,
attempt_no,
device_address,
RunStatus.RUNNING.value,
"purchase_prepare",
now,
now,
now,
),
)
task = self.get_task(remote_task_id)
assert task is not None
return StartedTaskRun(task, attempt_id, attempt_no)
finally:
connection.close()
def update_purchase_step(
self, remote_task_id: str, attempt_id: str, step: str
) -> None:
"""在进入采购关键步骤前,同时持久化任务和本次运行的步骤。"""
checked_step = str(step or "").strip()
if not checked_step:
raise ValueError("采购步骤不能为空")
now = utc_now_iso()
connection = open_database(self._db_path)
try:
with connection:
task = connection.execute(
"SELECT id, task_type, status 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.RUNNING.value:
raise ValueError("采购任务当前不在执行中")
cursor = connection.execute(
"UPDATE task_runs SET current_step = ?, updated_at = ?"
" WHERE task_id = ? AND attempt_id = ?"
" AND run_status = 'running'",
(checked_step, now, task["id"], attempt_id),
)
if cursor.rowcount != 1:
raise ValueError("采购执行记录不存在或已经结束")
connection.execute(
"UPDATE pdd_tasks SET current_step = ?, updated_at = ?"
" WHERE id = ?",
(checked_step, now, task["id"]),
)
finally:
connection.close()
def mark_purchase_irreversible(
self,
remote_task_id: str,
attempt_id: str,
final_confirmation: Optional[Dict[str, object]] = None,
) -> str:
"""同一事务保存最终确认快照和不可逆时间,再允许提交订单。"""
now = utc_now_iso()
step = "purchase_irreversible_step_entered"
diagnostics_json = json.dumps(
{"final_confirmation": dict(final_confirmation or {})},
ensure_ascii=False,
)
connection = open_database(self._db_path)
try:
with connection:
task = connection.execute(
"SELECT id, task_type, status, execution_mode 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["execution_mode"] != "live":
raise ValueError("演练任务不能进入不可逆阶段")
if task["status"] != TaskStatus.RUNNING.value:
raise ValueError("采购任务当前不在执行中")
cursor = connection.execute(
"UPDATE task_runs SET irreversible_action_at = ?,"
" current_step = ?, diagnostics_json = ?, updated_at = ?"
" WHERE task_id = ? AND attempt_id = ?"
" AND run_status = 'running'"
" AND irreversible_action_at IS NULL",
(
now,
step,
diagnostics_json,
now,
task["id"],
attempt_id,
),
)
if cursor.rowcount != 1:
raise ValueError("不可逆标记写入失败或已经存在")
connection.execute(
"UPDATE pdd_tasks SET current_step = ?, updated_at = ?"
" WHERE id = ?",
(step, now, task["id"]),
)
return now
finally:
connection.close()
def move_purchase_to_reconcile(
self,
remote_task_id: str,
attempt_id: str,
*,
order_submitted_at: Optional[str] = None,
message: str = "订单提交结果待核对,绝不重新下单",
) -> None:
"""不可逆点击后转入只读核单状态,不创建可重试下单路径。"""
now = utc_now_iso()
connection = open_database(self._db_path)
try:
with connection:
task = connection.execute(
"SELECT id, task_type, status 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("当前任务不是采购任务")
cursor = connection.execute(
"UPDATE task_runs SET run_status = 'manual_review',"
" current_step = 'reconcile_purchase',"
" order_submitted_at = COALESCE(order_submitted_at, ?),"
" error_code = 'PURCHASE_OUTCOME_UNKNOWN',"
" error_message = ?, finished_at = ?, updated_at = ?"
" WHERE task_id = ? AND attempt_id = ?"
" AND run_status = 'running'"
" AND irreversible_action_at IS NOT NULL",
(
order_submitted_at,
message,
now,
now,
task["id"],
attempt_id,
),
)
if cursor.rowcount != 1:
raise ValueError("不可逆采购执行记录不存在或已经结束")
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"]),
)
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": "订单字段不完整、不一致或读取失败,不得重新下单",
}
error_codes = {
"matched": "PURCHASE_RECONCILED",
"not_found": "ORDER_NOT_FOUND",
"ambiguous": "AMBIGUOUS_ORDER_MATCH",
"unknown": "ORDER_MATCH_UNCERTAIN",
}
message = messages[match_status]
error_code = error_codes[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,"
" diagnostics_json"
" 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_diagnostics = (
self._load_json_object(run["diagnostics_json"])
if run["diagnostics_json"]
else {}
)
run_diagnostics["reconciliation"] = saved_diagnostics
run_cursor = connection.execute(
"UPDATE task_runs SET run_status = 'manual_review',"
" current_step = ?, error_code = ?,"
" error_message = ?, diagnostics_json = ?, updated_at = ?"
" WHERE task_id = ? AND attempt_id = ?",
(
step,
error_code,
message,
json.dumps(run_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 = ?,"
" last_error_message = ?, updated_at = ? WHERE id = ?"
" AND status = 'manual_review'"
" AND current_step = 'reconcile_purchase'",
(step, error_code, message, now, task["id"]),
)
if task_cursor.rowcount != 1:
raise ValueError("采购任务状态已变更,核对结果未保存")
finally:
connection.close()
def save_matched_purchase_reconciliation(
self,
remote_task_id: str,
attempt_id: str,
pdd_data: Dict[str, object],
diagnostics: Dict[str, object],
) -> OutboxEventRecord:
"""唯一未付款订单核对成功后,原子保存结果并创建幂等 Outbox。"""
purchase = pdd_data.get("purchase")
if not isinstance(purchase, dict) or (
purchase.get("mode") != "live"
or purchase.get("match_status") != "matched"
or purchase.get("order_submitted") is not True
or purchase.get("payment_attempted") is not False
or purchase.get("payment_status") != "unpaid"
or not str(purchase.get("order_no") or "").strip()
):
raise ValueError("真实采购核对结果结构无效")
now = utc_now_iso()
idempotency_key = f"{remote_task_id}:{attempt_id}:result-v1"
connection = open_database(self._db_path)
try:
with connection:
existing = connection.execute(
"SELECT id FROM outbox_events WHERE idempotency_key = ?",
(idempotency_key,),
).fetchone()
if existing is not None:
event_id = int(existing["id"])
else:
task = connection.execute(
"SELECT id, version, 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 run_status, current_step, diagnostics_json,"
" irreversible_action_at 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
or run["run_status"] != RunStatus.MANUAL_REVIEW.value
or run["current_step"] != "reconcile_purchase"
):
raise ValueError("不可逆采购执行记录不在待核对状态")
run_diagnostics = (
self._load_json_object(run["diagnostics_json"])
if run["diagnostics_json"]
else {}
)
run_diagnostics["reconciliation"] = dict(diagnostics)
result_json = json.dumps(pdd_data, ensure_ascii=False)
payload = {
"task_version": task["version"],
"attempt_id": attempt_id,
"result_type": "purchase",
"completed_at": now,
"pdd_data": pdd_data,
}
task_cursor = connection.execute(
"UPDATE pdd_tasks SET status = 'result_pending',"
" current_step = 'purchase_order_matched_pending_report',"
" pdd_data = ?, shop_name = ?,"
" price_cent = ?, last_error_code = NULL,"
" last_error_message = NULL, finished_at = ?, updated_at = ?"
" WHERE id = ? AND status = 'manual_review'"
" AND current_step = 'reconcile_purchase'",
(
result_json,
self._summary_shop_name(pdd_data),
self._purchase_result_price(pdd_data),
now,
now,
task["id"],
),
)
if task_cursor.rowcount != 1:
raise ValueError("采购任务状态已变化,未保存核对结果")
run_cursor = connection.execute(
"UPDATE task_runs SET run_status = 'succeeded',"
" current_step = 'purchase_order_matched_pending_report',"
" result_data = ?,"
" diagnostics_json = ?, error_code = NULL,"
" error_message = NULL, finished_at = ?, updated_at = ?"
" WHERE task_id = ? AND attempt_id = ?"
" AND run_status = 'manual_review'"
" AND current_step = 'reconcile_purchase'"
" AND irreversible_action_at IS NOT NULL",
(
result_json,
json.dumps(run_diagnostics, ensure_ascii=False),
now,
now,
task["id"],
attempt_id,
),
)
if run_cursor.rowcount != 1:
raise ValueError("采购执行记录状态已变化,未保存核对结果")
event_cursor = connection.execute(
"INSERT INTO outbox_events (task_id, event_type,"
" idempotency_key, payload_json, status, created_at,"
" updated_at)"
" VALUES (?, 'purchase_result', ?, ?, 'pending', ?, ?)",
(
task["id"],
idempotency_key,
json.dumps(payload, ensure_ascii=False),
now,
now,
),
)
event_id = int(event_cursor.lastrowid)
event = self.get_outbox_event(event_id)
assert event is not None
return event
finally:
connection.close()
def save_purchase_result(
self,
remote_task_id: str,
attempt_id: str,
pdd_data: Dict[str, object],
) -> OutboxEventRecord:
"""原子保存采购演练结果,并创建采购结果 Outbox。"""
now = utc_now_iso()
connection = open_database(self._db_path)
try:
with connection:
task = connection.execute(
"SELECT id, version, task_type 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("当前任务不是采购任务")
payload = {
"task_version": task["version"],
"attempt_id": attempt_id,
"result_type": "purchase",
"completed_at": now,
"pdd_data": pdd_data,
}
result_json = json.dumps(pdd_data, ensure_ascii=False)
idempotency_key = (
f"{remote_task_id}:{attempt_id}:result-v1"
)
connection.execute(
"UPDATE pdd_tasks SET status = 'result_pending',"
" current_step = 'submit_result', pdd_data = ?, shop_name = ?,"
" price_cent = ?, finished_at = ?, updated_at = ?"
" WHERE id = ?",
(
result_json,
self._summary_shop_name(pdd_data),
self._purchase_result_price(pdd_data),
now,
now,
task["id"],
),
)
cursor = connection.execute(
"UPDATE task_runs SET run_status = 'succeeded',"
" current_step = 'submit_result', result_data = ?,"
" finished_at = ?, updated_at = ?"
" WHERE task_id = ? AND attempt_id = ?"
" AND run_status = 'running'",
(
result_json,
now,
now,
task["id"],
attempt_id,
),
)
if cursor.rowcount != 1:
raise ValueError("采购执行记录不存在或已经结束")
event_cursor = connection.execute(
"INSERT INTO outbox_events (task_id, event_type,"
" idempotency_key, payload_json, status, created_at, updated_at)"
" VALUES (?, 'purchase_result', ?, ?, 'pending', ?, ?)",
(
task["id"],
idempotency_key,
json.dumps(payload, ensure_ascii=False),
now,
now,
),
)
event_id = int(event_cursor.lastrowid)
event = self.get_outbox_event(event_id)
assert event is not None
return event
finally:
connection.close()
def save_purchase_failure(
self,
remote_task_id: str,
attempt_id: str,
status: TaskStatus,
error_code: str,
error_message: str,
retryable: bool,
step: str,
diagnostics: Optional[Dict[str, object]] = None,
) -> OutboxEventRecord:
"""原子保存采购演练失败,并创建失败 Outbox。"""
if status not in {
TaskStatus.RETRY_WAIT,
TaskStatus.MANUAL_REVIEW,
TaskStatus.FAILED,
TaskStatus.CANCELLED,
}:
raise ValueError("失败状态无效")
checked_step = str(step or "purchase_prepare").strip()
now = utc_now_iso()
connection = open_database(self._db_path)
try:
with connection:
task = connection.execute(
"SELECT id, version, task_type 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("当前任务不是采购任务")
payload = {
"task_version": task["version"],
"attempt_id": attempt_id,
"status": status.value,
"error": {
"code": error_code,
"message": error_message,
"retryable": retryable,
"step": checked_step,
},
"diagnostics": diagnostics or {"artifacts": []},
"reported_at": now,
}
connection.execute(
"UPDATE pdd_tasks SET status = ?, current_step = ?,"
" retry_count = retry_count + ?, last_error_code = ?,"
" last_error_message = ?, finished_at = ?, updated_at = ?"
" WHERE id = ?",
(
status.value,
"failed",
1 if status is TaskStatus.RETRY_WAIT else 0,
error_code,
error_message,
now,
now,
task["id"],
),
)
run_status = {
TaskStatus.CANCELLED: RunStatus.CANCELLED,
TaskStatus.MANUAL_REVIEW: RunStatus.MANUAL_REVIEW,
}.get(status, RunStatus.FAILED)
cursor = connection.execute(
"UPDATE task_runs SET run_status = ?, current_step = ?,"
" error_code = ?, error_message = ?, diagnostics_json = ?,"
" finished_at = ?,"
" updated_at = ? WHERE task_id = ? AND attempt_id = ?"
" AND run_status = 'running'",
(
run_status.value,
checked_step,
error_code,
error_message,
json.dumps(diagnostics or {}, ensure_ascii=False),
now,
now,
task["id"],
attempt_id,
),
)
if cursor.rowcount != 1:
raise ValueError("采购执行记录不存在或已经结束")
idempotency_key = (
f"{remote_task_id}:{attempt_id}:failure-v1"
)
event_cursor = connection.execute(
"INSERT INTO outbox_events (task_id, event_type,"
" idempotency_key, payload_json, status, created_at, updated_at)"
" VALUES (?, 'task_failure', ?, ?, 'pending', ?, ?)",
(
task["id"],
idempotency_key,
json.dumps(payload, ensure_ascii=False),
now,
now,
),
)
event_id = int(event_cursor.lastrowid)
event = self.get_outbox_event(event_id)
assert event is not None
return event
finally:
connection.close()
def save_collect_result(
self,
remote_task_id: str,
attempt_id: str,
pdd_data: Dict[str, object],
) -> OutboxEventRecord:
"""在一个事务中保存采集结果并创建待提交事件。"""
now = utc_now_iso()
connection = open_database(self._db_path)
try:
with connection:
task = connection.execute(
"SELECT id, version FROM pdd_tasks WHERE remote_task_id = ?",
(remote_task_id,),
).fetchone()
if task is None:
raise ValueError(f"任务 {remote_task_id} 不存在")
payload = {
"task_version": task["version"],
"attempt_id": attempt_id,
"result_type": "collect",
"completed_at": now,
"pdd_data": pdd_data,
}
idempotency_key = f"{remote_task_id}:{attempt_id}:result-v1"
result_json = json.dumps(pdd_data, ensure_ascii=False)
connection.execute(
"UPDATE pdd_tasks SET status = 'result_pending',"
" current_step = 'submit_result', pdd_data = ?, goods_id = ?,"
" title = ?, shop_name = ?, price_cent = ?,"
" finished_at = ?, updated_at = ?"
" WHERE id = ?",
(
result_json,
pdd_data.get("goods_id"), pdd_data.get("title"),
self._summary_shop_name(pdd_data),
self._summary_price(pdd_data), now, now, task["id"],
),
)
connection.execute(
"UPDATE task_runs SET run_status = 'succeeded',"
" current_step = 'submit_result', result_data = ?,"
" finished_at = ?, updated_at = ?"
" WHERE attempt_id = ?",
(result_json, now, now, attempt_id),
)
cursor = connection.execute(
"INSERT INTO outbox_events (task_id, event_type, idempotency_key,"
" payload_json, status, created_at, updated_at)"
" VALUES (?, 'collect_result', ?, ?, 'pending', ?, ?)",
(
task["id"], idempotency_key,
json.dumps(payload, ensure_ascii=False), now, now,
),
)
event_id = int(cursor.lastrowid)
event = self.get_outbox_event(event_id)
assert event is not None
return event
finally:
connection.close()
def save_collect_failure(
self,
remote_task_id: str,
attempt_id: str,
status: TaskStatus,
error_code: str,
error_message: str,
retryable: bool,
diagnostics: Optional[Dict[str, object]] = None,
) -> OutboxEventRecord:
"""保存结构化失败,并可靠排队提交 Admin。"""
if status not in {
TaskStatus.RETRY_WAIT, TaskStatus.MANUAL_REVIEW,
TaskStatus.FAILED, TaskStatus.CANCELLED,
}:
raise ValueError("失败状态无效")
now = utc_now_iso()
connection = open_database(self._db_path)
try:
with connection:
task = connection.execute(
"SELECT id, version FROM pdd_tasks WHERE remote_task_id = ?",
(remote_task_id,),
).fetchone()
if task is None:
raise ValueError(f"任务 {remote_task_id} 不存在")
payload = {
"task_version": task["version"],
"attempt_id": attempt_id,
"status": status.value,
"error": {
"code": error_code,
"message": error_message,
"retryable": retryable,
"step": "collecting",
},
"diagnostics": diagnostics or {"artifacts": []},
"reported_at": now,
}
idempotency_key = f"{remote_task_id}:{attempt_id}:failure-v1"
connection.execute(
"UPDATE pdd_tasks SET status = ?, current_step = 'failed',"
" retry_count = retry_count + ?, last_error_code = ?,"
" last_error_message = ?, finished_at = ?, updated_at = ?"
" WHERE id = ?",
(
status.value, 1 if status is TaskStatus.RETRY_WAIT else 0,
error_code, error_message, now, now, task["id"],
),
)
run_status = {
TaskStatus.CANCELLED: RunStatus.CANCELLED,
TaskStatus.MANUAL_REVIEW: RunStatus.MANUAL_REVIEW,
}.get(status, RunStatus.FAILED)
connection.execute(
"UPDATE task_runs SET run_status = ?, current_step = 'failed',"
" error_code = ?, error_message = ?, finished_at = ?, updated_at = ?"
" WHERE attempt_id = ?",
(run_status.value, error_code, error_message, now, now, attempt_id),
)
cursor = connection.execute(
"INSERT INTO outbox_events (task_id, event_type, idempotency_key,"
" payload_json, status, created_at, updated_at)"
" VALUES (?, 'task_failure', ?, ?, 'pending', ?, ?)",
(
task["id"], idempotency_key,
json.dumps(payload, ensure_ascii=False), now, now,
),
)
event_id = int(cursor.lastrowid)
event = self.get_outbox_event(event_id)
assert event is not None
return event
finally:
connection.close()
def next_pending_outbox(self) -> Optional[OutboxEventRecord]:
connection = open_database(self._db_path)
try:
row = connection.execute(
"SELECT o.* FROM outbox_events o JOIN pdd_tasks t ON t.id = o.task_id"
" WHERE o.status = 'pending'"
" AND (o.next_retry_at IS NULL OR o.next_retry_at <= ?)"
" ORDER BY o.id ASC LIMIT 1",
(utc_now_iso(),),
).fetchone()
finally:
connection.close()
return self._to_outbox(row) if row is not None else None
def latest_result_outbox(
self, remote_task_id: str
) -> Optional[OutboxEventRecord]:
"""读取任务最新的成功结果事件,不返回失败上报事件。"""
connection = open_database(self._db_path)
try:
row = connection.execute(
"SELECT o.* FROM outbox_events o"
" JOIN pdd_tasks t ON t.id = o.task_id"
" WHERE t.remote_task_id = ?"
" AND o.event_type IN ('collect_result', 'purchase_result')"
" ORDER BY o.id DESC LIMIT 1",
(remote_task_id,),
).fetchone()
finally:
connection.close()
return self._to_outbox(row) if row is not None else None
def outbox_for_resubmit(
self, remote_task_id: str
) -> Optional[OutboxEventRecord]:
"""返回手工重新上报应发送的事件。
先返回最早一条尚未发送的事件,保证失败信息不会被旧结果跳过;
全部事件都已发送时,才返回最新的成功结果用于再次确认。
"""
connection = open_database(self._db_path)
try:
row = connection.execute(
"SELECT o.* FROM outbox_events o"
" JOIN pdd_tasks t ON t.id = o.task_id"
" WHERE t.remote_task_id = ? AND o.status != 'sent'"
" ORDER BY o.id ASC LIMIT 1",
(remote_task_id,),
).fetchone()
if row is None:
row = connection.execute(
"SELECT o.* FROM outbox_events o"
" JOIN pdd_tasks t ON t.id = o.task_id"
" WHERE t.remote_task_id = ?"
" AND o.event_type IN ('collect_result', 'purchase_result')"
" ORDER BY o.id DESC LIMIT 1",
(remote_task_id,),
).fetchone()
finally:
connection.close()
return self._to_outbox(row) if row is not None else None
def get_outbox_event(self, event_id: int) -> Optional[OutboxEventRecord]:
connection = open_database(self._db_path)
try:
row = connection.execute(
"SELECT * FROM outbox_events WHERE id = ?", (event_id,)
).fetchone()
finally:
connection.close()
return self._to_outbox(row) if row is not None else None
def outbox_task_id(self, event_id: int) -> str:
connection = open_database(self._db_path)
try:
row = connection.execute(
"SELECT t.remote_task_id FROM outbox_events o"
" JOIN pdd_tasks t ON t.id = o.task_id WHERE o.id = ?",
(event_id,),
).fetchone()
finally:
connection.close()
if row is None:
raise ValueError(f"Outbox {event_id} 不存在")
return str(row[0])
def mark_outbox_sending(self, event_id: int) -> None:
self._update_outbox(event_id, "sending", None)
def mark_outbox_retry(self, event_id: int, message: str) -> None:
self._update_outbox(event_id, "pending", message, increment=True)
def mark_outbox_failed(self, event_id: int, message: str) -> None:
self._update_outbox(event_id, "failed", message, increment=True)
def mark_outbox_sent(self, event_id: int) -> None:
"""标记事件已发送,并只按当前最新执行更新任务状态。
已经发送过的历史结果再次上报时,不得覆盖任务的最新状态。
最新失败信息发送成功时会恢复对应失败状态,用于修复旧版本
产生的“表格显示已完成、实际最新执行失败”记录。
"""
now = utc_now_iso()
connection = open_database(self._db_path)
try:
with connection:
row = connection.execute(
"SELECT task_id, event_type, payload_json, sent_at"
" FROM outbox_events WHERE id = ?",
(event_id,),
).fetchone()
if row is None:
raise ValueError(f"Outbox {event_id} 不存在")
was_already_sent = row["sent_at"] is not None
connection.execute(
"UPDATE outbox_events SET status = 'sent', sent_at = ?,"
" updated_at = ?, attempt_count = attempt_count + 1"
" WHERE id = ?",
(now, now, event_id),
)
payload = self._load_outbox_payload(row["payload_json"])
if row["event_type"] == OutboxEventType.TASK_FAILURE.value:
self._restore_current_failure_state(
connection, row["task_id"], payload, now
)
elif not was_already_sent and row["event_type"] in {
OutboxEventType.COLLECT_RESULT.value,
OutboxEventType.PURCHASE_RESULT.value,
}:
self._complete_current_result(
connection, row["task_id"], payload, now
)
finally:
connection.close()
@staticmethod
def _load_outbox_payload(payload_json: str) -> Dict[str, object]:
"""读取本地 Outbox JSON;损坏时返回空对象,不猜测任务状态。"""
try:
payload = json.loads(payload_json)
except (TypeError, ValueError):
return {}
return payload if isinstance(payload, dict) else {}
@staticmethod
def _latest_run_for_task(
connection: sqlite3.Connection, task_id: int
) -> Optional[sqlite3.Row]:
return connection.execute(
"SELECT attempt_id, finished_at FROM task_runs"
" WHERE task_id = ? ORDER BY attempt_no DESC LIMIT 1",
(task_id,),
).fetchone()
@classmethod
def _complete_current_result(
cls,
connection: sqlite3.Connection,
task_id: int,
payload: Dict[str, object],
now: str,
) -> None:
"""只有最新执行的待提交结果可以把任务改成已完成。"""
latest_run = cls._latest_run_for_task(connection, task_id)
if (
latest_run is None
or payload.get("attempt_id") != latest_run["attempt_id"]
):
return
connection.execute(
"UPDATE pdd_tasks SET status = 'succeeded',"
" current_step = 'completed', updated_at = ?"
" WHERE id = ? AND status = 'result_pending'",
(now, task_id),
)
@classmethod
def _restore_current_failure_state(
cls,
connection: sqlite3.Connection,
task_id: int,
payload: Dict[str, object],
now: str,
) -> None:
"""最新失败事件上报后恢复任务失败状态,旧事件不覆盖新状态。"""
latest_run = cls._latest_run_for_task(connection, task_id)
if (
latest_run is None
or payload.get("attempt_id") != latest_run["attempt_id"]
):
return
status = payload.get("status")
allowed_statuses = {
TaskStatus.RETRY_WAIT.value,
TaskStatus.MANUAL_REVIEW.value,
TaskStatus.FAILED.value,
TaskStatus.CANCELLED.value,
}
if status not in allowed_statuses:
return
error = payload.get("error")
error = error if isinstance(error, dict) else {}
finished_at = (
latest_run["finished_at"] or payload.get("reported_at") or now
)
connection.execute(
"UPDATE pdd_tasks SET status = ?, current_step = 'failed',"
" last_error_code = ?, last_error_message = ?,"
" finished_at = ?, updated_at = ? WHERE id = ?",
(
status,
error.get("code"),
error.get("message"),
finished_at,
now,
task_id,
),
)
def _update_outbox(
self, event_id: int, status: str, message: Optional[str], increment: bool = False
) -> None:
connection = open_database(self._db_path)
try:
with connection:
cursor = connection.execute(
"UPDATE outbox_events SET status = ?, last_error = ?, updated_at = ?,"
f" attempt_count = attempt_count + {1 if increment else 0} WHERE id = ?",
(status, message, utc_now_iso(), event_id),
)
if cursor.rowcount != 1:
raise ValueError(f"Outbox {event_id} 不存在")
finally:
connection.close()
@staticmethod
def _summary_price(pdd_data: Dict[str, object]) -> Optional[int]:
skus = pdd_data.get("skus")
if not isinstance(skus, list):
return None
prices = [
item.get("price_cent") for item in skus
if isinstance(item, dict) and isinstance(item.get("price_cent"), int)
]
return min(prices) if prices else None
@staticmethod
def _purchase_result_price(
pdd_data: Dict[str, object]
) -> Optional[int]:
purchase = pdd_data.get("purchase")
if not isinstance(purchase, dict):
return None
confirmed = purchase.get("confirmed")
if not isinstance(confirmed, dict):
return None
price = confirmed.get("unit_price_cent")
if isinstance(price, bool) or not isinstance(price, int):
return None
return price
@staticmethod
def _to_outbox(row: sqlite3.Row) -> OutboxEventRecord:
return OutboxEventRecord(
id=row["id"], task_id=row["task_id"],
event_type=OutboxEventType(row["event_type"]),
idempotency_key=row["idempotency_key"],
payload_json=TaskRepository._load_json_object(row["payload_json"]),
status=OutboxStatus(row["status"]), attempt_count=row["attempt_count"],
next_retry_at=row["next_retry_at"], last_error=row["last_error"],
created_at=row["created_at"], updated_at=row["updated_at"],
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:
raise ValueError(f"limit 必须在 1 到 {MAX_PAGE_SIZE} 之间")
if offset < 0:
raise ValueError("offset 不能小于 0")
@staticmethod
def _build_where(filters: TaskFilters) -> Tuple[str, List[object]]:
clauses = ["removed_at IS NULL"]
parameters: List[object] = []
if filters.task_type is not None:
clauses.append("task_type = ?")
parameters.append(filters.task_type.value)
if filters.status is not None:
clauses.append("status = ?")
parameters.append(filters.status.value)
if filters.keyword.strip():
keyword = TaskRepository._escape_like(filters.keyword.strip())
pattern = f"%{keyword}%"
clauses.append(
"(remote_task_id LIKE ? ESCAPE '\\'"
" OR COALESCE(goods_id, '') LIKE ? ESCAPE '\\'"
" OR COALESCE(title, '') LIKE ? ESCAPE '\\')"
)
parameters.extend((pattern, pattern, pattern))
return (" WHERE " + " AND ".join(clauses) if clauses else "", parameters)
@staticmethod
def _escape_like(value: str) -> str:
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
@staticmethod
def _to_summary(row: sqlite3.Row) -> TaskSummary:
return TaskSummary(
id=row["id"],
remote_task_id=row["remote_task_id"],
task_type=TaskType(row["task_type"]),
goods_id=row["goods_id"],
title=row["title"],
shop_name=row["shop_name"],
target_color=row["target_color"],
target_size=row["target_size"],
price_cent=row["price_cent"],
quantity=row["quantity"],
status=TaskStatus(row["status"]),
latest_run_status=(
RunStatus(row["latest_run_status"])
if row["latest_run_status"] is not None
else None
),
duration_seconds=row["duration_seconds"],
updated_at=row["updated_at"],
)
@staticmethod
def _summary_shop_name(pdd_data: Dict[str, object]) -> Optional[str]:
"""提取列表需要的店铺名,空值不写入摘要列。"""
value = pdd_data.get("shop_name")
if not isinstance(value, str):
return None
value = value.strip()
return value or None
@staticmethod
def _to_detail(row: sqlite3.Row) -> TaskDetail:
admin_payload = TaskRepository._load_json_object(row["admin_payload"])
pdd_data = (
TaskRepository._load_json_object(row["pdd_data"])
if row["pdd_data"] is not None
else None
)
return TaskDetail(
id=row["id"],
remote_task_id=row["remote_task_id"],
task_type=TaskType(row["task_type"]),
execution_mode=row["execution_mode"],
goods_id=row["goods_id"],
goods_url=row["goods_url"],
title=row["title"],
target_color=row["target_color"],
target_size=row["target_size"],
price_cent=row["price_cent"],
quantity=row["quantity"],
status=TaskStatus(row["status"]),
current_step=row["current_step"],
priority=row["priority"],
version=row["version"],
admin_payload=admin_payload,
pdd_data=pdd_data,
retry_count=row["retry_count"],
last_error_code=row["last_error_code"],
last_error_message=row["last_error_message"],
received_at=row["received_at"],
started_at=row["started_at"],
finished_at=row["finished_at"],
created_at=row["created_at"],
updated_at=row["updated_at"],
)
@staticmethod
def _load_json_object(text: str) -> Dict[str, object]:
value = json.loads(text)
if not isinstance(value, dict):
raise ValueError("数据库 JSON 字段必须是对象")
return value