feat: 实现受控真实下单安全边界 (#99)

This commit is contained in:
chengma
2026-08-10 16:35:38 +08:00
parent 7106f6b024
commit e960cab1fb
30 changed files with 1562 additions and 66 deletions
+10 -2
View File
@@ -56,8 +56,8 @@ class ClaimCapabilities:
raise ValueError("supported_types 不能为空")
if any(not isinstance(value, TaskType) for value in self.supported_types):
raise ValueError("supported_types 必须使用 TaskType")
if self.purchase_mode != "dry_run":
raise ValueError("当前版本只允许 purchase_mode=dry_run")
if self.purchase_mode not in {"dry_run", "live"}:
raise ValueError("purchase_mode 只允许 dry_run 或 live")
if not self.schema_versions or any(
version <= 0 for version in self.schema_versions
):
@@ -75,6 +75,7 @@ class AdminTask:
payload: Mapping[str, Any] = field(default_factory=dict)
created_at: str = ""
updated_at: str = ""
execution_mode: str = "dry_run"
def __post_init__(self) -> None:
if not self.task_id.strip():
@@ -83,6 +84,13 @@ class AdminTask:
raise ValueError("task_type 必须使用 TaskType")
if self.version <= 0:
raise ValueError("version 必须大于 0")
if self.execution_mode not in {"dry_run", "live"}:
raise ValueError("execution_mode 只允许 dry_run 或 live")
if (
self.task_type is not TaskType.PURCHASE
and self.execution_mode != "dry_run"
):
raise ValueError("只有采购任务允许 execution_mode=live")
if not isinstance(self.payload, Mapping):
raise ValueError("payload 必须是对象")
+10 -1
View File
@@ -4,7 +4,7 @@
``MIGRATIONS`` 末尾增加版本,不能修改已经发布的迁移。
"""
SCHEMA_VERSION = 2
SCHEMA_VERSION = 3
MIGRATION_1 = (
@@ -134,7 +134,16 @@ MIGRATION_2 = (
)
MIGRATION_3 = (
"""
ALTER TABLE pdd_tasks ADD COLUMN execution_mode TEXT NOT NULL DEFAULT 'dry_run'
CHECK (execution_mode IN ('dry_run', 'live'))
""",
)
MIGRATIONS = {
1: MIGRATION_1,
2: MIGRATION_2,
3: MIGRATION_3,
}
+13
View File
@@ -361,6 +361,7 @@ class HttpAdminGateway(AdminGateway):
raw_type = task.get("type")
version = task.get("version")
priority = task.get("priority")
execution_mode = task.get("execution_mode", "dry_run")
task_payload = task.get("payload")
created_at = task.get("created_at")
updated_at = task.get("updated_at")
@@ -373,6 +374,7 @@ class HttpAdminGateway(AdminGateway):
and version > 0
and isinstance(priority, int)
and not isinstance(priority, bool)
and isinstance(execution_mode, str)
and isinstance(task_payload, Mapping)
and isinstance(created_at, str)
and isinstance(updated_at, str)
@@ -395,6 +397,16 @@ class HttpAdminGateway(AdminGateway):
request_id,
) from exc
if execution_mode not in {"dry_run", "live"} or (
task_type is not TaskType.PURCHASE and execution_mode != "dry_run"
):
raise AdminGatewayError(
"ADMIN_INVALID_RESPONSE",
"Admin 返回了不支持的任务执行模式",
False,
request_id,
)
cls._validate_claim_payload(task_type, task_payload, request_id)
return AdminTask(
@@ -402,6 +414,7 @@ class HttpAdminGateway(AdminGateway):
task_type=task_type,
version=version,
priority=priority,
execution_mode=execution_mode,
payload=dict(task_payload),
created_at=created_at,
updated_at=updated_at,
+126
View File
@@ -0,0 +1,126 @@
"""真实下单的本地授权状态。
授权只来自设置页的明确确认,并绑定当前 Client ID 和 Android 设备。
缺少、损坏或不匹配的设置一律按关闭处理。
"""
from dataclasses import dataclass
from datetime import datetime, timezone
from .settings_repository import SettingsRepository
LIVE_ENABLED_KEY = "purchase.live_enabled"
LIVE_CLIENT_ID_KEY = "purchase.live_client_id"
LIVE_DEVICE_SERIAL_KEY = "purchase.live_device_serial"
LIVE_CONFIRMED_AT_KEY = "purchase.live_confirmed_at"
LIVE_CONFIRMATION_TEXT = "创建未付款订单"
def _utc_now_iso() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds").replace(
"+00:00", "Z"
)
@dataclass(frozen=True)
class LivePurchaseAuthorization:
"""设置页展示和运行时核对使用的授权快照。"""
enabled: bool = False
client_id: str = ""
device_serial: str = ""
confirmed_at: str = ""
def matches(self, client_id: str, device_serial: str) -> bool:
"""只有完整授权和两项绑定完全一致时才返回 True。"""
return (
self.enabled
and bool(self.confirmed_at)
and self.client_id == str(client_id or "").strip()
and self.device_serial == str(device_serial or "").strip()
)
class LivePurchaseAuthorizationService:
"""保存、关闭并校验本机真实下单授权。"""
def __init__(self, repository: SettingsRepository):
self._repository = repository
def load(self) -> LivePurchaseAuthorization:
"""读取授权;任何字段异常都安全降级为关闭。"""
enabled = self._repository.get(LIVE_ENABLED_KEY, False)
client_id = self._repository.get(LIVE_CLIENT_ID_KEY, "")
serial = self._repository.get(LIVE_DEVICE_SERIAL_KEY, "")
confirmed_at = self._repository.get(LIVE_CONFIRMED_AT_KEY, "")
if enabled is not True:
return LivePurchaseAuthorization()
if not all(
isinstance(value, str)
for value in (client_id, serial, confirmed_at)
):
return LivePurchaseAuthorization()
normalized = LivePurchaseAuthorization(
True,
client_id.strip(),
serial.strip(),
confirmed_at.strip(),
)
if (
not normalized.client_id
or not normalized.device_serial
or not normalized.confirmed_at
):
return LivePurchaseAuthorization()
return normalized
def enable(
self,
client_id: str,
device_serial: str,
confirmation_text: str,
) -> LivePurchaseAuthorization:
"""精确核对确认文字后,原子保存绑定和确认时间。"""
checked_client = str(client_id or "").strip()
checked_serial = str(device_serial or "").strip()
if not checked_client:
raise ValueError("请先保存当前 Client 设备号")
if not checked_serial:
raise ValueError("请先选择并保存 Android 设备")
if confirmation_text.strip() != LIVE_CONFIRMATION_TEXT:
raise ValueError(f"请输入“{LIVE_CONFIRMATION_TEXT}”确认")
confirmed_at = _utc_now_iso()
self._repository.set_many(
{
LIVE_ENABLED_KEY: True,
LIVE_CLIENT_ID_KEY: checked_client,
LIVE_DEVICE_SERIAL_KEY: checked_serial,
LIVE_CONFIRMED_AT_KEY: confirmed_at,
}
)
return LivePurchaseAuthorization(
True, checked_client, checked_serial, confirmed_at
)
def disable(self) -> LivePurchaseAuthorization:
"""立即关闭能力;历史绑定不用于重新启用。"""
self._repository.set(LIVE_ENABLED_KEY, False)
return LivePurchaseAuthorization()
def purchase_mode_for(
self,
client_id: str,
device_serial: str,
*,
live_adapter_ready: bool,
) -> str:
"""集中计算对 Admin 声明的能力,默认永远是 dry_run。"""
if live_adapter_ready and self.load().matches(client_id, device_serial):
return "live"
return "dry_run"
+5
View File
@@ -143,6 +143,11 @@ class MockAdminGateway(AdminGateway):
continue
if item.task.task_type not in capabilities.supported_types:
continue
if (
item.task.execution_mode == "live"
and capabilities.purchase_mode != "live"
):
continue
item.claimed = True
self._claimed_task_ids.add(item.task.task_id)
+10
View File
@@ -21,6 +21,8 @@ class PurchasePageState:
quantity: int = 0
price_cent: int = 0
candidate_count: int = 1
in_stock: bool = True
submit_candidate_count: int = 0
class PddPurchaseError(RuntimeError):
@@ -73,3 +75,11 @@ class PddPurchaseAdapter(ABC):
@abstractmethod
def close(self) -> None:
"""释放设备会话;不得在此方法中产生页面点击。"""
class PddLivePurchaseAdapter(PddPurchaseAdapter):
"""受控真实采购接口;只增加一次性提交,不提供付款或取消。"""
@abstractmethod
def submit_order_once(self) -> None:
"""用最新页面状态确认唯一按钮并单击一次;调用后禁止重试。"""
+94 -3
View File
@@ -1,7 +1,7 @@
"""uiautomator2 采购演练 Adapter。
"""uiautomator2 采购 Adapter。
本模块只到 PDD 最终提交订单按钮前。代码中没有点击提交订单
或付款的方法。
演练 factory 返回没有提交方法的窄接口;live factory 单独返回只允许一次提交的
接口。两条路径都不提供付款、取消订单或绕过安全校验的方法。
"""
from __future__ import annotations
@@ -19,6 +19,7 @@ from .pdd_device_service import (
PddDeviceService,
)
from .pdd_purchase_adapter import (
PddLivePurchaseAdapter,
PddPurchaseAdapter,
PddPurchaseError,
PurchasePageState,
@@ -36,6 +37,7 @@ _CAPTCHA_MARKERS = ("请完成验证", "拖动滑块", "安全验证", "点击
_RISK_MARKERS = ("操作频繁", "异常请求", "风险提示", "账号异常")
_PAYMENT_MARKERS = ("输入支付密码", "立即支付", "支付成功", "支付失败")
_FINAL_SUBMIT_MARKERS = ("提交订单", "现在买,仅", "确认购买")
_OUT_OF_STOCK_MARKERS = ("已售罄", "暂时缺货", "库存不足", "该商品已售罄")
_SUPPORTED_OPTION_KEYS = frozenset({"color", "size"})
@@ -200,6 +202,33 @@ def _price_cent(root: ET.Element) -> int:
return max(parsed_nodes)[2]
def _final_submit_targets(root: ET.Element) -> list[Bounds]:
"""返回底部可见、启用且文字明确的唯一提交按钮坐标。"""
screen_bottom = 0
for node in root.iter("node"):
bounds = _parse_bounds(node.get("bounds", ""))
if bounds is not None:
screen_bottom = max(screen_bottom, bounds[3])
if screen_bottom <= 0:
return []
targets = set()
for node in root.iter("node"):
label = _label(node)
bounds = _parse_bounds(node.get("bounds", ""))
if not label or bounds is None:
continue
if not any(marker in label for marker in _FINAL_SUBMIT_MARKERS):
continue
if node.get("visible-to-user") != "true" or node.get("enabled") != "true":
continue
if (bounds[1] + bounds[3]) // 2 < screen_bottom * 0.6:
continue
targets.add(bounds)
return sorted(targets)
class U2PddPurchaseAdapter(PddPurchaseAdapter):
"""PDD 真机采购演练会话,不提供真实下单能力。"""
@@ -229,6 +258,7 @@ class U2PddPurchaseAdapter(PddPurchaseAdapter):
self._device = None
self._goods_id = ""
self._requested_options: dict[str, str] = {}
self._submit_attempted = False
def open_goods(self, goods_url: str) -> None:
self._goods_id = _goods_id_from_url(goods_url)
@@ -268,6 +298,8 @@ class U2PddPurchaseAdapter(PddPurchaseAdapter):
xml_data = self._dump_hierarchy()
root = _parse_xml(xml_data)
kind = _page_kind(root, str(current.get("package") or ""))
labels = _labels(root)
submit_targets = _final_submit_targets(root)
selected = {
key: value
for key, value in self._requested_options.items()
@@ -280,6 +312,11 @@ class U2PddPurchaseAdapter(PddPurchaseAdapter):
quantity=_quantity(root),
price_cent=_price_cent(root),
candidate_count=1 if kind != "unknown" else 0,
in_stock=not any(
marker in " ".join(labels)
for marker in _OUT_OF_STOCK_MARKERS
),
submit_candidate_count=len(submit_targets),
)
except PddPurchaseError:
raise
@@ -512,3 +549,57 @@ def create_u2_purchase_adapter(
device_service=_PURCHASE_DEVICE_SERVICE,
cancelled=cancelled,
)
class U2PddLivePurchaseAdapter(U2PddPurchaseAdapter, PddLivePurchaseAdapter):
"""只允许一次最终提交点击的真机 Adapter,不包含付款路径。"""
def submit_order_once(self) -> None:
if self._submit_attempted:
raise PddPurchaseError(
"PURCHASE_SUBMIT_ALREADY_ATTEMPTED",
"本次采购已经尝试提交,禁止再次点击",
step="purchase_submit_once",
)
self._check_cancelled("purchase_submit_once")
device = self._require_device()
try:
current = device.app_current()
root = _parse_xml(self._dump_hierarchy())
kind = _page_kind(root, str(current.get("package") or ""))
if kind in {"captcha", "login_required", "risk_control", "payment"}:
self._raise_special_page(kind)
if kind != "order_confirmation":
raise PddPurchaseError(
"PURCHASE_CONFIRMATION_LOST",
"最终提交前页面已经变化,禁止提交订单",
step="purchase_submit_once",
)
targets = _final_submit_targets(root)
if len(targets) != 1:
raise PddPurchaseError(
"PURCHASE_SUBMIT_TARGET_AMBIGUOUS",
"最终提交按钮不是唯一可靠目标,禁止提交订单",
step="purchase_submit_once",
diagnostics={"candidate_count": len(targets)},
)
left, top, right, bottom = targets[0]
self._submit_attempted = True
device.click((left + right) // 2, (top + bottom) // 2)
except PddPurchaseError:
raise
except Exception as exc:
self._submit_attempted = True
self._raise_device_or_page_error(exc, "purchase_submit_once")
def create_u2_live_purchase_adapter(
device_address: str, cancelled: Callable[[], bool]
) -> PddLivePurchaseAdapter:
"""为已通过本地绑定授权的任务创建一次 live 会话。"""
return U2PddLivePurchaseAdapter(
device_address,
device_service=_PURCHASE_DEVICE_SERVICE,
cancelled=cancelled,
)
+34
View File
@@ -46,6 +46,8 @@ from .current_client_service import CurrentClientService
from .http_admin_gateway import DEFAULT_ADMIN_BASE_URL, HttpAdminGateway
from .pdd_ui import PDDTaskPage, TaskRow
from .purchase_task_service import PurchaseAdapterFactory
from .purchase_task_service import LivePurchaseAdapterFactory
from .live_purchase_authorization import LivePurchaseAuthorizationService
from .purchase_reconcile_service import PurchaseReconcileFactory
from .selected_android_device_service import SelectedAndroidDeviceService
from .settings_repository import SettingsRepository
@@ -125,6 +127,12 @@ class ClaimTaskWorker(QObject):
android_device_service: SelectedAndroidDeviceService,
collect_service_factory: Optional[CollectServiceFactory] = None,
purchase_adapter_factory: Optional[PurchaseAdapterFactory] = None,
live_purchase_adapter_factory: Optional[
LivePurchaseAdapterFactory
] = None,
live_authorization_service: Optional[
LivePurchaseAuthorizationService
] = None,
purchase_reconcile_factory: Optional[PurchaseReconcileFactory] = None,
selected_task_id: str = "",
device_connection_checker: Optional[Callable[[str], None]] = None,
@@ -137,6 +145,8 @@ class ClaimTaskWorker(QObject):
self._cancelled = False
self._collect_service_factory = collect_service_factory
self._purchase_adapter_factory = purchase_adapter_factory
self._live_purchase_adapter_factory = live_purchase_adapter_factory
self._live_authorization_service = live_authorization_service
self._purchase_reconcile_factory = purchase_reconcile_factory
self._selected_task_id = selected_task_id
self._device_connection_checker = (
@@ -179,6 +189,17 @@ class ClaimTaskWorker(QObject):
)
result = service.execute_selected(self._selected_task_id)
else:
purchase_mode = "dry_run"
if self._live_authorization_service is not None:
purchase_mode = (
self._live_authorization_service.purchase_mode_for(
client.client_id,
android_serial or "",
live_adapter_ready=(
self._live_purchase_adapter_factory is not None
),
)
)
dispatcher = TaskDispatcher(
self._gateway,
self._task_repository,
@@ -186,6 +207,10 @@ class ClaimTaskWorker(QObject):
android_serial or "",
collect_service_factory=self._collect_service_factory,
purchase_adapter_factory=self._purchase_adapter_factory,
live_purchase_adapter_factory=(
self._live_purchase_adapter_factory
),
purchase_mode=purchase_mode,
purchase_reconcile_factory=self._purchase_reconcile_factory,
cancelled=lambda: self._cancelled,
device_connection_checker=self._device_connection_checker,
@@ -308,6 +333,9 @@ class PDDTaskPageEvent(QObject):
settings_repository: Optional[SettingsRepository] = None,
collect_service_factory: Optional[CollectServiceFactory] = None,
purchase_adapter_factory: Optional[PurchaseAdapterFactory] = None,
live_purchase_adapter_factory: Optional[
LivePurchaseAdapterFactory
] = None,
purchase_reconcile_factory: Optional[PurchaseReconcileFactory] = None,
device_connection_checker: Optional[Callable[[str], None]] = None,
next_task_delay_ms: int = 500,
@@ -337,6 +365,7 @@ class PDDTaskPageEvent(QObject):
self._detail_windows: Dict[str, TaskDetailWindow] = {}
self._collect_service_factory = collect_service_factory
self._purchase_adapter_factory = purchase_adapter_factory
self._live_purchase_adapter_factory = live_purchase_adapter_factory
self._purchase_reconcile_factory = purchase_reconcile_factory
self._device_connection_checker = (
device_connection_checker
@@ -360,6 +389,9 @@ class PDDTaskPageEvent(QObject):
pass
settings = settings_repository or SettingsRepository()
self._live_authorization_service = LivePurchaseAuthorizationService(
settings
)
self._client_service = CurrentClientService(settings)
self._selected_android_device_service = SelectedAndroidDeviceService(
settings
@@ -788,6 +820,8 @@ class PDDTaskPageEvent(QObject):
android_device_service=self._selected_android_device_service,
collect_service_factory=self._collect_service_factory,
purchase_adapter_factory=self._purchase_adapter_factory,
live_purchase_adapter_factory=self._live_purchase_adapter_factory,
live_authorization_service=self._live_authorization_service,
purchase_reconcile_factory=self._purchase_reconcile_factory,
device_connection_checker=self._device_connection_checker,
)
+113 -4
View File
@@ -1,7 +1,7 @@
"""一条本地采购任务的安全演练流程。
"""一条本地采购任务的安全执行流程。
本模块固定为 ``dry_run``。它会核对商品、动态规格、数量和价格,并停在最终
提交订单之前;代码中没有提交订单或付款入口。
``dry_run`` 始终停在提交前;``live`` 只在独立 Adapter 就绪时允许一次提交,
并且必须先持久化不可逆标记。任何路径都不包含付款。
"""
from __future__ import annotations
@@ -12,6 +12,7 @@ from typing import Callable, Mapping
from .admin_gateway import AdminGateway, AdminGatewayError, ClientInfo
from .pdd_purchase_adapter import (
PddLivePurchaseAdapter,
PddPurchaseAdapter,
PddPurchaseError,
PurchasePageState,
@@ -51,6 +52,9 @@ class PurchaseTaskOutcome:
PurchaseAdapterFactory = Callable[
[str, Callable[[], bool]], PddPurchaseAdapter
]
LivePurchaseAdapterFactory = Callable[
[str, Callable[[], bool]], PddLivePurchaseAdapter
]
class PurchaseTaskService:
@@ -87,7 +91,7 @@ class PurchaseTaskService:
return self.execute_selected(task.remote_task_id)
def execute_selected(self, remote_task_id: str) -> PurchaseTaskOutcome:
"""对指定的本地采购任务执行一次演练。"""
"""按任务不可变执行模式安全执行一次采购。"""
if not self._device_address:
raise ValueError("请先在设置页选择并保存 Android 设备")
@@ -104,6 +108,15 @@ class PurchaseTaskService:
try:
target = self._target_from_task(started.task)
adapter = self._factory(self._device_address, self._cancelled)
execution_mode = started.task.execution_mode
if execution_mode == "live" and not isinstance(
adapter, PddLivePurchaseAdapter
):
raise PddPurchaseError(
"PURCHASE_LIVE_ADAPTER_UNAVAILABLE",
"真实采购执行器未就绪,未进入商品页面",
step="purchase_prepare",
)
step = "purchase_open_goods"
self._enter_step(remote_task_id, started.attempt_id, step)
@@ -150,6 +163,15 @@ class PurchaseTaskService:
)
self._validate_checkout_values(state, target)
if execution_mode == "live":
assert isinstance(adapter, PddLivePurchaseAdapter)
return self._submit_live_once(
remote_task_id,
started.attempt_id,
target,
adapter,
)
step = "purchase_dry_run_stopped"
self._enter_step(remote_task_id, started.attempt_id, step)
adapter.stop_before_submit()
@@ -182,6 +204,64 @@ class PurchaseTaskService:
adapter.close()
return self._submit(event)
def _submit_live_once(
self,
remote_task_id: str,
attempt_id: str,
target: PurchaseTarget,
adapter: PddLivePurchaseAdapter,
) -> PurchaseTaskOutcome:
"""最终复核、先落不可逆标记、单击一次,然后只转核单。"""
step = "purchase_live_final_check"
self._enter_step(remote_task_id, attempt_id, step)
state = adapter.read_state()
self._validate_live_confirmation(state, target)
if self._cancelled():
raise PddPurchaseError(
"PURCHASE_CANCELLED",
"用户已在不可逆标记写入前停止真实采购",
step=step,
)
irreversible_at = self._repository.mark_purchase_irreversible(
remote_task_id, attempt_id
)
submitted_at = None
message = "订单提交点击已执行,结果待只读核对;绝不重新下单"
try:
adapter.submit_order_once()
submitted_at = utc_now_iso()
except Exception as exc:
message = (
"订单提交点击结果不确定,已转只读核对;"
f"绝不重新下单:{str(exc) or type(exc).__name__}"
)
try:
self._repository.move_purchase_to_reconcile(
remote_task_id,
attempt_id,
order_submitted_at=submitted_at,
message=message,
)
except Exception as exc:
# 不可逆标记已经提交成功。这里绝不能保存成普通失败或再次执行;
# 重启恢复会根据 irreversible_action_at 强制进入只读核单。
return PurchaseTaskOutcome(
"manual_review",
(
f"任务 {remote_task_id} 已在 {irreversible_at} 进入不可逆阶段,"
f"但核单状态保存失败:{exc};请关闭自动获取并重启,绝不重下"
),
remote_task_id,
)
return PurchaseTaskOutcome(
"manual_review",
f"任务 {remote_task_id} {message}",
remote_task_id,
)
def _enter_step(
self, remote_task_id: str, attempt_id: str, step: str
) -> None:
@@ -328,6 +408,35 @@ class PurchaseTaskService:
step="purchase_verify_quantity_price",
)
@staticmethod
def _validate_live_confirmation(
state: PurchasePageState, target: PurchaseTarget
) -> None:
"""在不可逆标记前核对最新确认页和唯一提交目标。"""
if state.page_kind != "order_confirmation":
raise PddPurchaseError(
"PURCHASE_CONFIRMATION_LOST",
"最终提交前确认页已变化,禁止真实下单",
step="purchase_live_final_check",
)
PurchaseTaskService._validate_checkout_values(state, target)
if not state.in_stock:
raise PddPurchaseError(
"PURCHASE_OUT_OF_STOCK",
"当前规格库存不足或已售罄,禁止真实下单",
step="purchase_live_final_check",
)
if state.submit_candidate_count != 1:
raise PddPurchaseError(
"PURCHASE_SUBMIT_TARGET_AMBIGUOUS",
"最终提交按钮不是唯一可靠目标,禁止真实下单",
step="purchase_live_final_check",
diagnostics={
"candidate_count": state.submit_candidate_count
},
)
@staticmethod
def _validate_common_state(state: PurchasePageState) -> None:
if state.page_kind == "captcha":
+99
View File
@@ -212,6 +212,7 @@ class SettingsPage(QWidget):
android_device_service=None,
update_service=None,
update_credential_store=None,
live_purchase_adapter_ready: bool = False,
):
super().__init__(parent)
self.setObjectName("settingsPage")
@@ -227,6 +228,7 @@ class SettingsPage(QWidget):
android_device_service=android_device_service,
update_service=update_service,
update_credential_store=update_credential_store,
live_purchase_adapter_ready=live_purchase_adapter_ready,
)
def _build_ui(self) -> None:
@@ -296,6 +298,34 @@ class SettingsPage(QWidget):
self.pddAppStatusLabel.setWordWrap(True)
self.androidDeviceCard = self._build_android_device_card()
self.livePurchaseStatusLabel = CaptionLabel(
"真实下单默认关闭,仅允许创建未付款订单", self
)
self.livePurchaseStatusLabel.setAccessibleName("真实下单授权状态")
self.livePurchaseStatusLabel.setWordWrap(True)
self.livePurchaseClientLabel = CaptionLabel("—", self)
self.livePurchaseDeviceLabel = CaptionLabel("—", self)
self.livePurchaseConfirmedAtLabel = CaptionLabel("—", self)
self.livePurchaseConfirmationInput = LineEdit(self)
self.livePurchaseConfirmationInput.setPlaceholderText(
"请输入“创建未付款订单”"
)
self.livePurchaseConfirmationInput.setClearButtonEnabled(True)
self.livePurchaseConfirmationInput.setAccessibleName(
"真实下单确认文字"
)
self.livePurchaseEnableButton = PushButton(
FIF.ACCEPT, "启用真实下单", self
)
self.livePurchaseEnableButton.setAccessibleName(
"为当前 Client 和 Android 设备启用真实下单"
)
self.livePurchaseDisableButton = PushButton(
FIF.CANCEL, "关闭真实下单", self
)
self.livePurchaseDisableButton.setAccessibleName("关闭真实下单")
self.livePurchaseCard = self._build_live_purchase_card()
self.currentVersionLabel = CaptionLabel(__version__, self)
self.currentVersionLabel.setAccessibleName("当前软件版本")
self.updateManifestUrlInput = LineEdit(self)
@@ -331,6 +361,7 @@ class SettingsPage(QWidget):
contentLayout.addWidget(TitleLabel("设置", content))
contentLayout.addWidget(self.currentDeviceCard)
contentLayout.addWidget(self.androidDeviceCard)
contentLayout.addWidget(self.livePurchaseCard)
contentLayout.addWidget(self.softwareUpdateCard)
contentLayout.addStretch(1)
@@ -455,6 +486,52 @@ class SettingsPage(QWidget):
layout.addLayout(commandLayout)
return card
def _build_live_purchase_card(self) -> CardWidget:
card = CardWidget(self)
card.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
layout = QVBoxLayout(card)
layout.setContentsMargins(24, 20, 24, 22)
layout.setSpacing(12)
titleLayout = QHBoxLayout()
titleLayout.setSpacing(12)
titleLayout.addWidget(SubtitleLabel("真实下单(不支付)", card))
titleLayout.addStretch(1)
titleLayout.addWidget(self.livePurchaseStatusLabel, 1)
layout.addLayout(titleLayout)
form = QFormLayout()
form.setHorizontalSpacing(16)
form.setVerticalSpacing(12)
form.addRow(
CaptionLabel("绑定 Client", card), self.livePurchaseClientLabel
)
form.addRow(
CaptionLabel("绑定设备", card), self.livePurchaseDeviceLabel
)
form.addRow(
CaptionLabel("确认时间", card), self.livePurchaseConfirmedAtLabel
)
confirmationLabel = CaptionLabel("确认文字", card)
confirmationLabel.setBuddy(self.livePurchaseConfirmationInput)
form.addRow(confirmationLabel, self.livePurchaseConfirmationInput)
layout.addLayout(form)
warning = CaptionLabel(
"启用后只允许提交一次订单并停在支付前;验证码、风控、登录失效、"
"规格或价格不一致时立即停止。",
card,
)
warning.setWordWrap(True)
layout.addWidget(warning)
commandLayout = QHBoxLayout()
commandLayout.addStretch(1)
commandLayout.addWidget(self.livePurchaseDisableButton)
commandLayout.addWidget(self.livePurchaseEnableButton)
layout.addLayout(commandLayout)
return card
def set_client_info(self, device_id: str, device_name: str) -> None:
"""显示后续设备身份服务提供的当前客户端信息。"""
@@ -482,6 +559,28 @@ class SettingsPage(QWidget):
self.updateStatusLabel.setText(message)
def set_live_purchase_authorization(
self,
*,
enabled: bool,
client_id: str = "",
device_serial: str = "",
confirmed_at: str = "",
message: str = "",
) -> None:
"""显示真实下单授权,不通过颜色单独表达状态。"""
self.livePurchaseClientLabel.setText(client_id or "—")
self.livePurchaseDeviceLabel.setText(device_serial or "—")
self.livePurchaseConfirmedAtLabel.setText(confirmed_at or "—")
if message:
status = message
elif enabled:
status = "已启用:仅限绑定 Client 和设备,且不会自动支付"
else:
status = "已关闭:所有采购任务只允许演练"
self.livePurchaseStatusLabel.setText(status)
def set_saved_android_device(self, serial: str) -> None:
"""显示已经保存并实际用于自动化的 Android 设备。"""
+274 -1
View File
@@ -35,8 +35,14 @@ from .current_client_service import (
)
from .http_admin_gateway import DEFAULT_ADMIN_BASE_URL, HttpAdminGateway
from .selected_android_device_service import SelectedAndroidDeviceService
from .live_purchase_authorization import (
LIVE_CONFIRMATION_TEXT,
LivePurchaseAuthorization,
LivePurchaseAuthorizationService,
)
from .settings_repository import SettingsRepository
from .settings_ui import AndroidDeviceRow
from .task_models import TaskType
from .update_ui_event import UpdateUiEventBinder
DEVICE_ID_PLACEHOLDER = "待生成"
@@ -294,6 +300,85 @@ class AndroidDeviceSettingWorker(QObject):
self.completed.emit()
class LivePurchaseAuthorizationWorker(QObject):
"""后台保存 live 授权,并尽力把最新能力登记到 Admin。"""
saved = pyqtSignal(object)
failed = pyqtSignal(str)
registrationFailed = pyqtSignal(str)
completed = pyqtSignal()
def __init__(
self,
service: LivePurchaseAuthorizationService,
gateway: Optional[ClientRegistrationGateway],
client_id: str,
client_name: str,
device_serial: str,
action: str,
confirmation_text: str,
gateway_error: str = "",
) -> None:
super().__init__()
self._service = service
self._gateway = gateway
self._client_id = str(client_id or "").strip()
self._client_name = str(client_name or "").strip()
self._device_serial = device_serial
self._action = action
self._confirmation_text = confirmation_text
self._gateway_error = gateway_error
@pyqtSlot()
def run(self) -> None:
try:
try:
if self._action == "enable":
authorization = self._service.enable(
self._client_id,
self._device_serial,
self._confirmation_text,
)
else:
authorization = self._service.disable()
except Exception as exc:
self.failed.emit(str(exc) or "真实下单授权保存失败")
return
self.saved.emit(authorization)
if not self._client_id:
return
if self._gateway is None:
self.registrationFailed.emit(
self._gateway_error or "Admin Gateway 尚未配置"
)
return
device = (
AndroidDeviceInfo(self._device_serial)
if self._device_serial
else None
)
supported_types = (
(TaskType.COLLECT, TaskType.PURCHASE)
if device is not None
else (TaskType.COLLECT,)
)
capabilities = ClaimCapabilities(
device=device,
supported_types=supported_types,
purchase_mode="live" if authorization.enabled else "dry_run",
)
try:
self._gateway.register_client(
ClientInfo(self._client_id, self._client_name),
capabilities,
)
except Exception as exc:
self.registrationFailed.emit(str(exc) or "Admin 登记失败")
finally:
self.completed.emit()
class SettingsPageEventBinder(QObject):
"""绑定设备管理控件,并向应用层发出稳定事件。"""
@@ -302,6 +387,7 @@ class SettingsPageEventBinder(QObject):
currentDeviceSaveRequested = pyqtSignal(str, str)
saveRequested = pyqtSignal(str, str)
deleteRequested = pyqtSignal(str)
liveAuthorizationRequested = pyqtSignal(str, str)
def __init__(
self,
@@ -311,6 +397,7 @@ class SettingsPageEventBinder(QObject):
android_device_service: Optional[AndroidDeviceService] = None,
update_service=None,
update_credential_store=None,
live_purchase_adapter_ready: bool = False,
):
super().__init__(page)
self._page = page
@@ -341,6 +428,14 @@ class SettingsPageEventBinder(QObject):
self._android_setting_thread: Optional[QThread] = None
self._android_setting_worker: Optional[AndroidDeviceSettingWorker] = None
self._saved_android_serial = ""
self._live_purchase_busy = False
self._live_purchase_thread: Optional[QThread] = None
self._live_purchase_worker: Optional[
LivePurchaseAuthorizationWorker
] = None
self._live_purchase_adapter_ready = bool(
live_purchase_adapter_ready
)
self._android_device_service = (
android_device_service or AndroidDeviceService()
)
@@ -354,6 +449,10 @@ class SettingsPageEventBinder(QObject):
parent=self,
)
self._client_service = CurrentClientService(repository)
self._live_purchase_service = LivePurchaseAuthorizationService(
repository
)
self._live_authorization = self._live_purchase_service.load()
self._selected_android_device_service = SelectedAndroidDeviceService(
repository
)
@@ -384,6 +483,18 @@ class SettingsPageEventBinder(QObject):
page.saveButton.clicked.connect(self._request_save)
self.saveRequested.connect(self._start_save_android_device)
page.deleteButton.clicked.connect(self._request_delete)
page.livePurchaseEnableButton.clicked.connect(
self._request_enable_live_purchase
)
page.livePurchaseDisableButton.clicked.connect(
self._request_disable_live_purchase
)
page.livePurchaseConfirmationInput.textChanged.connect(
self._sync_button_state
)
self.liveAuthorizationRequested.connect(
self._start_live_authorization
)
self.deleteRequested.connect(self._start_delete_android_device)
page.deviceTableModel.checkedDeviceChanged.connect(
self._on_checked_device_changed
@@ -395,6 +506,7 @@ class SettingsPageEventBinder(QObject):
self._load_current_client()
self._load_selected_android_device()
self._show_live_authorization(self._live_authorization)
self._sync_button_state()
if self._saved_android_serial:
QTimer.singleShot(0, self._request_restore_saved_android_device)
@@ -430,7 +542,22 @@ class SettingsPageEventBinder(QObject):
try:
device = self._selected_android_device()
capabilities = ClaimCapabilities(device=device)
displayed_client_id = self._page.deviceIdInput.text().strip()
purchase_mode = self._live_purchase_service.purchase_mode_for(
displayed_client_id,
device.address if device is not None else "",
live_adapter_ready=self._live_purchase_adapter_ready,
)
supported_types = (
(TaskType.COLLECT, TaskType.PURCHASE)
if device is not None and self._live_purchase_adapter_ready
else (TaskType.COLLECT,)
)
capabilities = ClaimCapabilities(
device=device,
supported_types=supported_types,
purchase_mode=purchase_mode,
)
except ValueError as exc:
self._page.set_current_device_status(f"保存失败:{exc}")
return
@@ -463,6 +590,128 @@ class SettingsPageEventBinder(QObject):
self._worker = worker
thread.start()
@pyqtSlot()
def _request_enable_live_purchase(self) -> None:
if self._live_purchase_busy:
return
if not self._live_purchase_adapter_ready:
self._page.set_live_purchase_authorization(
enabled=False,
message="真实下单执行器未就绪,只允许采购演练",
)
return
confirmation = self._page.livePurchaseConfirmationInput.text()
self.liveAuthorizationRequested.emit("enable", confirmation)
@pyqtSlot()
def _request_disable_live_purchase(self) -> None:
if not self._live_purchase_busy:
self.liveAuthorizationRequested.emit("disable", "")
@pyqtSlot(str, str)
def _start_live_authorization(
self, action: str, confirmation_text: str
) -> None:
if self._closing or self._live_purchase_busy:
return
current = self._client_service.load()
serial = self._selected_android_device_service.load()
if action == "enable" and (
not current.client_id or not serial
):
self._page.set_live_purchase_authorization(
enabled=False,
message="请先保存当前 Client 和 Android 设备",
)
return
self._live_purchase_busy = True
self._sync_button_state()
self._page.set_live_purchase_authorization(
enabled=self._live_authorization.enabled,
client_id=self._live_authorization.client_id,
device_serial=self._live_authorization.device_serial,
confirmed_at=self._live_authorization.confirmed_at,
message=(
"正在启用真实下单…"
if action == "enable"
else "正在关闭真实下单…"
),
)
thread = QThread(self)
worker = LivePurchaseAuthorizationWorker(
self._live_purchase_service,
self._admin_gateway,
current.client_id,
current.client_name,
serial,
action,
confirmation_text,
self._gateway_error,
)
worker.moveToThread(thread)
thread.started.connect(worker.run)
worker.saved.connect(self._on_live_authorization_saved)
worker.failed.connect(self._on_live_authorization_failed)
worker.registrationFailed.connect(
self._on_live_registration_failed
)
worker.completed.connect(thread.quit)
worker.completed.connect(worker.deleteLater)
thread.finished.connect(self._on_live_authorization_finished)
thread.finished.connect(thread.deleteLater)
self._live_purchase_thread = thread
self._live_purchase_worker = worker
thread.start()
@pyqtSlot(object)
def _on_live_authorization_saved(
self, authorization: LivePurchaseAuthorization
) -> None:
if self._closing:
return
self._live_authorization = authorization
self._page.livePurchaseConfirmationInput.clear()
self._show_live_authorization(authorization)
@pyqtSlot(str)
def _on_live_authorization_failed(self, message: str) -> None:
if not self._closing:
self._show_live_authorization(
self._live_authorization,
f"授权修改失败:{message};原设置未改变",
)
@pyqtSlot(str)
def _on_live_registration_failed(self, message: str) -> None:
if not self._closing:
state = "已启用" if self._live_authorization.enabled else "已关闭"
self._show_live_authorization(
self._live_authorization,
f"本地{state},Admin 登记失败:{message};领取时会再次声明能力",
)
@pyqtSlot()
def _on_live_authorization_finished(self) -> None:
self._live_purchase_worker = None
self._live_purchase_thread = None
self._live_purchase_busy = False
if not self._closing:
self._sync_button_state()
def _show_live_authorization(
self,
authorization: LivePurchaseAuthorization,
message: str = "",
) -> None:
self._page.set_live_purchase_authorization(
enabled=authorization.enabled,
client_id=authorization.client_id,
device_serial=authorization.device_serial,
confirmed_at=authorization.confirmed_at,
message=message,
)
@pyqtSlot()
def _request_search(self) -> None:
if (
@@ -871,6 +1120,25 @@ class SettingsPageEventBinder(QObject):
self._page.deleteButton.setEnabled(
device_commands_enabled and bool(self._saved_android_serial)
)
live_commands_enabled = (
not self._closing
and not self._busy
and not self._current_device_busy
and not self._live_purchase_busy
)
confirmation_matches = (
self._page.livePurchaseConfirmationInput.text().strip()
== LIVE_CONFIRMATION_TEXT
)
self._page.livePurchaseEnableButton.setEnabled(
live_commands_enabled
and self._live_purchase_adapter_ready
and not self._live_authorization.enabled
and confirmation_matches
)
self._page.livePurchaseDisableButton.setEnabled(
live_commands_enabled and self._live_authorization.enabled
)
def set_busy(self, busy: bool, message: str = "") -> None:
"""切换界面忙碌状态,防止用户重复提交设备命令。"""
@@ -1178,6 +1446,11 @@ class SettingsPageEventBinder(QObject):
thread.quit()
thread.wait(4000)
live_thread = self._live_purchase_thread
if live_thread is not None and live_thread.isRunning():
live_thread.quit()
live_thread.wait(4000)
search_worker = self._search_worker
search_thread = self._search_thread
if search_worker is not None:
+3
View File
@@ -68,6 +68,9 @@ CURRENT_STEP_TEXT = {
"purchase_verify_quantity_price": "正在核对数量和价格",
"purchase_enter_confirmation": "正在进入提交前确认页",
"purchase_verify_confirmation": "正在核对提交前确认页",
"purchase_live_final_check": "正在执行真实下单最终安全核对",
"purchase_irreversible_step_entered": "已进入不可逆阶段,禁止重新下单",
"purchase_submit_once": "已单击一次提交订单",
"purchase_dry_run_stopped": "采购演练已在提交前停止",
"purchase_recovery_ready": "上次演练中断,已安全等待恢复",
"reconcile_purchase": "只允许核对订单",
+55 -12
View File
@@ -19,6 +19,7 @@ from .android_device_service import (
AndroidDeviceService,
)
from .purchase_task_service import (
LivePurchaseAdapterFactory,
PurchaseAdapterFactory,
PurchaseTaskService,
)
@@ -95,6 +96,7 @@ def admin_task_to_new_claimed_task(task: AdminTask) -> NewClaimedTask:
original_task = {
"id": task.task_id,
"type": task.task_type.value,
"execution_mode": task.execution_mode,
"version": task.version,
"priority": task.priority,
"payload": payload,
@@ -105,6 +107,7 @@ def admin_task_to_new_claimed_task(task: AdminTask) -> NewClaimedTask:
remote_task_id=task.task_id,
task_type=task.task_type,
goods_url=goods_url.strip(),
execution_mode=task.execution_mode,
goods_id=goods_id.strip() if isinstance(goods_id, str) else None,
target_color=target_color,
target_size=target_size,
@@ -128,6 +131,10 @@ class TaskDispatcher:
*,
collect_service_factory: Optional[CollectServiceFactory] = None,
purchase_adapter_factory: Optional[PurchaseAdapterFactory] = None,
live_purchase_adapter_factory: Optional[
LivePurchaseAdapterFactory
] = None,
purchase_mode: str = "dry_run",
purchase_reconcile_factory: Optional[PurchaseReconcileFactory] = None,
cancelled: Callable[[], bool] = lambda: False,
device_connection_checker: Optional[Callable[[str], None]] = None,
@@ -138,6 +145,10 @@ class TaskDispatcher:
self._device_address = str(device_address or "").strip()
self._collect_factory = collect_service_factory
self._purchase_factory = purchase_adapter_factory
self._live_purchase_factory = live_purchase_adapter_factory
if purchase_mode not in {"dry_run", "live"}:
raise ValueError("purchase_mode 只允许 dry_run 或 live")
self._purchase_mode = purchase_mode
self._reconcile_factory = purchase_reconcile_factory
self._cancelled = cancelled
self._device_connection_checker = (
@@ -167,24 +178,38 @@ class TaskDispatcher:
return ClaimCapabilities(
device=device,
supported_types=tuple(supported),
purchase_mode="dry_run",
purchase_mode=(
"live"
if self.purchase_ready
and self._purchase_mode == "live"
and self._live_purchase_factory is not None
else "dry_run"
),
schema_versions=(1,),
)
def execute_one(self) -> TaskDispatchOutcome:
"""严格按 Outbox、本地任务、Admin 新任务的顺序处理。"""
"""不可逆采购优先核单,其余按 Outbox、本地任务、Admin 顺序处理。"""
pending = self._repository.next_pending_outbox()
if pending is not None:
return self._submit_pending(pending)
if not self._device_address:
raise AndroidDeviceSearchError(
"请先在设置页选择并保存 Android 设备"
unresolved_reader = getattr(
self._repository, "unresolved_irreversible_purchase", None
)
unresolved = (
unresolved_reader() if unresolved_reader is not None else None
)
if unresolved is not None:
raise RuntimeError(
f"任务 {unresolved.remote_task_id} 已进入不可逆阶段但尚未转入核单;"
"已停止所有采购,绝不重新下单"
)
self._device_connection_checker(self._device_address)
reconcile_task = self._repository.next_purchase_reconcile_task()
if reconcile_task is not None:
if not self._device_address:
raise AndroidDeviceSearchError(
"存在只允许核对的采购任务;请先连接并保存原 Android 设备"
)
self._device_connection_checker(self._device_address)
if self._reconcile_factory is None:
raise RuntimeError(
f"任务 {reconcile_task.remote_task_id} 只允许核对订单,"
@@ -200,6 +225,15 @@ class TaskDispatcher:
outcome.kind, outcome.message, outcome.task_id
)
pending = self._repository.next_pending_outbox()
if pending is not None:
return self._submit_pending(pending)
if not self._device_address:
raise AndroidDeviceSearchError(
"请先在设置页选择并保存 Android 设备"
)
self._device_connection_checker(self._device_address)
if not self.purchase_ready:
pending_purchase = self._repository.next_purchase_task()
if pending_purchase is not None:
@@ -244,16 +278,25 @@ class TaskDispatcher:
)
outcome = service.execute_selected(task.remote_task_id)
else:
if self._purchase_factory is None:
factory = self._purchase_factory
if task.execution_mode == "live":
if self.claim_capabilities().purchase_mode != "live":
raise RuntimeError(
f"真实采购任务 {task.remote_task_id} 的本地授权已关闭或绑定不一致;"
"任务保持待执行,不会降级为演练"
)
factory = self._live_purchase_factory
if factory is None:
raise RuntimeError(
"采购演练执行器未就绪,已停止领取以避免任务卡住"
f"采购任务 {task.remote_task_id} 的 {task.execution_mode} "
"执行器未就绪,已停止且不会改变任务模式"
)
purchase_service = PurchaseTaskService(
self._gateway,
self._repository,
self._client,
self._device_address,
self._purchase_factory,
factory,
cancelled=self._cancelled,
)
outcome = purchase_service.execute_selected(task.remote_task_id)
+9
View File
@@ -62,6 +62,7 @@ class NewClaimedTask:
remote_task_id: str
task_type: TaskType
goods_url: str
execution_mode: str = "dry_run"
goods_id: Optional[str] = None
title: Optional[str] = None
target_color: Optional[str] = None
@@ -79,6 +80,13 @@ class NewClaimedTask:
raise ValueError("remote_task_id 不能为空")
if not self.goods_url.strip():
raise ValueError("goods_url 不能为空")
if self.execution_mode not in {"dry_run", "live"}:
raise ValueError("execution_mode 只允许 dry_run 或 live")
if (
self.task_type is not TaskType.PURCHASE
and self.execution_mode != "dry_run"
):
raise ValueError("只有采购任务允许 execution_mode=live")
if self.price_cent is not None and self.price_cent < 0:
raise ValueError("price_cent 不能小于 0")
if self.quantity is not None and self.quantity <= 0:
@@ -141,6 +149,7 @@ class TaskDetail:
finished_at: Optional[str]
created_at: str
updated_at: str
execution_mode: str = "dry_run"
@dataclass(frozen=True)
+117 -2
View File
@@ -70,14 +70,16 @@ class TaskRepository:
with connection:
cursor = connection.execute(
"INSERT INTO pdd_tasks ("
" remote_task_id, task_type, goods_id, goods_url, title,"
" 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
task.remote_task_id.strip(),
task.task_type.value,
task.execution_mode,
task.goods_id,
task.goods_url.strip(),
task.title,
@@ -298,6 +300,22 @@ class TaskRepository:
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]:
@@ -583,6 +601,102 @@ class TaskRepository:
finally:
connection.close()
def mark_purchase_irreversible(
self, remote_task_id: str, attempt_id: str
) -> str:
"""事务写入不可逆时间;成功返回后才允许点击提交订单。"""
now = utc_now_iso()
step = "purchase_irreversible_step_entered"
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 = ?, updated_at = ?"
" WHERE task_id = ? AND attempt_id = ?"
" AND run_status = 'running'"
" AND irreversible_action_at IS NULL",
(now, step, 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,
@@ -1369,6 +1483,7 @@ class TaskRepository:
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"],
+6 -1
View File
@@ -29,7 +29,10 @@ from qfluentwidgets import (
from .pdd_ui import PDDTaskPage
from .pdd_ui_event import PDDTaskPageEvent
from .pdd_u2_purchase_adapter import create_u2_purchase_adapter
from .pdd_u2_purchase_adapter import (
create_u2_live_purchase_adapter,
create_u2_purchase_adapter,
)
from .settings_ui import SettingsPage
from .task_repository import TaskRepository
from .update_service import mark_current_version_healthy
@@ -51,12 +54,14 @@ class MainWindow(FluentWindow):
self,
settings_repository=settings_repository,
admin_gateway=admin_gateway,
live_purchase_adapter_ready=True,
)
self.pddTaskPageEvent = PDDTaskPageEvent(
self.pddTaskPage,
task_repository or TaskRepository(),
self,
purchase_adapter_factory=create_u2_purchase_adapter,
live_purchase_adapter_factory=create_u2_live_purchase_adapter,
)
self.pddTaskPage.openSettingsRequested.connect(