From 10bb892e6c6f2cc0a411c40edfb2618276c4d241 Mon Sep 17 00:00:00 2001 From: chengma Date: Mon, 10 Aug 2026 18:37:35 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=A7=BB=E9=99=A4=20Client=20=E7=9C=9F?= =?UTF-8?q?=E5=AE=9E=E9=87=87=E8=B4=AD=E6=89=8B=E5=B7=A5=E6=8E=88=E6=9D=83?= =?UTF-8?q?=20(#114)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 +- admin/AGENTS.md | 2 + admin/handler/web/others.go | 8 +- admin/main_test.go | 12 +- admin/service/client_test.go | 8 +- admin/service/purchase_workflow.go | 7 - admin/service/purchase_workflow_test.go | 9 +- admin/service/service.go | 6 +- admin/templates/syb/list.html | 10 +- client/AGENTS.md | 2 +- client/src/live_purchase_authorization.py | 126 -------- client/src/pdd_ui_event.py | 27 +- client/src/settings_ui.py | 97 ------- client/src/settings_ui_event.py | 273 +----------------- client/src/task_dispatcher.py | 2 +- .../test/test_live_purchase_authorization.py | 87 ------ client/test/test_pdd_ui_event.py | 21 ++ client/test/test_settings_ui_event.py | 41 +-- client/test/test_task_dispatcher.py | 4 +- docs/admin/01-requirements.md | 8 +- docs/admin/04-client-api.md | 4 +- docs/admin/05-ui-specification.md | 10 +- docs/admin/07-设备登记联调手册.md | 2 +- docs/client/01-requirements.md | 2 +- docs/client/02-architecture.md | 6 +- docs/client/03-data-model.md | 6 +- docs/client/04-admin-api-contract.md | 11 +- docs/client/05-ui-specification.md | 11 +- docs/client/06-quality-security.md | 22 +- 29 files changed, 124 insertions(+), 702 deletions(-) delete mode 100644 client/src/live_purchase_authorization.py delete mode 100644 client/test/test_live_purchase_authorization.py diff --git a/AGENTS.md b/AGENTS.md index 0d05bd1..17f0f00 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ 需要突破其中任何一条时,**停下来问用户**,不要先给方案、不要先评估成本。 1. **Qt 绑定固定 PyQt5。** 不得改用 PyQt6 / PySide2 / PySide6,也不得安装其他绑定对应的 Fluent Widgets 包。 -2. **Admin 新建采购任务固定为真实下单(不支付)。** Admin 提交创建表单即表示创建真实采购任务,不再重复要求风险复选框或确认短语;任务只能分配给已授权并声明 live 能力的 Client。Client 仍须通过设备绑定、商品/规格/数量/价格复核、不可逆标记和单次提交门禁。 +2. **Admin 新建采购任务固定为真实下单(不支付)。** Admin 提交创建表单即表示创建真实采购任务,不再重复要求风险复选框或确认短语;当前账号可见的全部 Client 都允许被指派,不得增加 Client 手工真实采购启用请求。Client 只有在身份、已选 Android 设备和真实采购执行器就绪时才自动声明 live 并领取任务,且仍须通过商品/规格/数量/价格复核、不可逆标记和单次提交门禁。 3. **不绕过拼多多的验证码、风控和安全机制**,不自动注册登录,不自动付款。 4. **凭据(token、Cookie、密码)不得写入** `data/`、日志、数据库、Gitea 工单和 `docs/task`。 5. **任务一旦进入不可逆阶段**(`task_runs.irreversible_action_at` 有值),**只准核对订单,绝不重新下单**。 diff --git a/admin/AGENTS.md b/admin/AGENTS.md index 43b3936..b2534d4 100644 --- a/admin/AGENTS.md +++ b/admin/AGENTS.md @@ -88,6 +88,8 @@ ## 数据与接口 +- Admin 新建采购任务固定为真实下单(不支付),当前账号可见的全部 Client 都允许被指派;创建时不得按 Client 的 `purchase_mode` 禁用候选或拒绝任务。`purchase_mode` 只用于领取期判断 Client 的自动运行就绪能力。 + - 表结构以 [03 数据模型](../docs/admin/03-data-model.md) 为准。 - 给 Client 的接口以 [docs/client/04-admin-api-contract.md](../docs/client/04-admin-api-contract.md) 为准。 - `[必须]` **金额一律用整数存**,人民币用分、台币用分,字段名带单位后缀(`_cent`)。 diff --git a/admin/handler/web/others.go b/admin/handler/web/others.go index f1813cb..c366bd9 100644 --- a/admin/handler/web/others.go +++ b/admin/handler/web/others.go @@ -181,10 +181,10 @@ func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw, keyword, dateFrom, dateTo), "HistoryFetchPagination": sybHistoryPartialPagination(history.Page, history.TotalPages, keyword, dateFrom, dateTo), - "Pagination": service.NewPaginationView(result.Page, result.TotalPages, values.Encode()), - "DetailURL": "/syb/detail?" + detailValues.Encode(), - "AssignableClients": purchaseClients.Rows, - "LivePurchaseClientCount": purchaseClients.SelectableCount, + "Pagination": service.NewPaginationView(result.Page, result.TotalPages, values.Encode()), + "DetailURL": "/syb/detail?" + detailValues.Encode(), + "AssignableClients": purchaseClients.Rows, + "PurchaseClientCount": purchaseClients.SelectableCount, })) } diff --git a/admin/main_test.go b/admin/main_test.go index 3c32e57..8946b0a 100644 --- a/admin/main_test.go +++ b/admin/main_test.go @@ -159,7 +159,7 @@ func TestMainPagesReturnOK(t *testing.T) { } for _, client := range []model.Client{ {ClientID: "CLIENT-LIVE", Name: "真实采购机", Capabilities: `{"purchase_mode":"live"}`}, - {ClientID: "CLIENT-DRY", Name: "未授权机", Capabilities: `{"purchase_mode":"dry_run"}`}, + {ClientID: "CLIENT-DRY", Name: "未就绪机", Capabilities: `{"purchase_mode":"dry_run"}`}, } { if err := service.RegisterClient(db, client, true); err != nil { t.Fatalf("准备采购客户端失败: %v", err) @@ -180,17 +180,17 @@ func TestMainPagesReturnOK(t *testing.T) { `name="date_from"`, `name="date_to"`, `name="stage"`, "处理阶段", "下一步", `data-detail-url=`, "按顺运宝货运单创建日期(UTC+8)同步", - "创建真实采购任务", "未启用真实采购", + "创建真实采购任务", "所有当前账号可见的客户端均可指派", } { if !strings.Contains(sybResponse.Body.String(), want) { t.Errorf("顺运宝页面缺少 %q", want) } } for _, want := range []string{ - `value="CLIENT-LIVE" selected`, - `value="CLIENT-DRY" disabled`, - "未授权机(CLIENT-DRY) — 未启用真实采购", - "离线客户端也可以提前指派", + `value="CLIENT-LIVE"`, + `value="CLIENT-DRY"`, + "未就绪机(CLIENT-DRY) — 在线", + "未就绪或离线客户端会等待其就绪后领取", } { if !strings.Contains(sybResponse.Body.String(), want) { t.Errorf("采购客户端候选缺少 %q", want) diff --git a/admin/service/client_test.go b/admin/service/client_test.go index 6f70942..183d1cc 100644 --- a/admin/service/client_test.go +++ b/admin/service/client_test.go @@ -325,7 +325,7 @@ func TestClientAssignment_一人多客户端并按采购员隔离列表(t *testi } } -func TestListPurchaseClientOptions_返回可见客户端且离线Live仍可选(t *testing.T) { +func TestListPurchaseClientOptions_全部可见客户端均可选(t *testing.T) { db := newTestDB(t) admin, buyerA, _ := prepareClientAssignmentUsers(t, db) now := time.Now().UTC() @@ -350,11 +350,11 @@ func TestListPurchaseClientOptions_返回可见客户端且离线Live仍可选(t if err != nil { t.Fatalf("读取采购客户端候选失败: %v", err) } - if len(options.Rows) != 3 || options.SelectableCount != 2 { - t.Fatalf("应显示全部可见客户端且两个 live 可选,实际 %+v", options) + if len(options.Rows) != 3 || options.SelectableCount != 3 { + t.Fatalf("应显示全部可见客户端且全部可选,实际 %+v", options) } if options.Rows[2].ClientID != "live-offline" || options.Rows[2].Status != "离线" { - t.Fatalf("离线 live 客户端必须保留在候选中,实际 %+v", options.Rows) + t.Fatalf("离线客户端必须保留在候选中,实际 %+v", options.Rows) } } diff --git a/admin/service/purchase_workflow.go b/admin/service/purchase_workflow.go index 0ecc4df..0447b73 100644 --- a/admin/service/purchase_workflow.go +++ b/admin/service/purchase_workflow.go @@ -219,13 +219,6 @@ func CreatePurchaseTasksWithOptions(db *sql.DB, actor *model.User, requests []Pu } confirmedBy, confirmedAt := "", "" if executionMode == model.TaskExecutionLive { - purchaseMode, err := repository.ClientPurchaseMode(tx, clientID) - if err != nil { - return result, err - } - if purchaseMode != string(model.TaskExecutionLive) { - return result, fmt.Errorf("所选客户端未声明 live 能力,不能执行真实采购任务") - } confirmedBy, confirmedAt = actor.UserID, model.NowISO() } diff --git a/admin/service/purchase_workflow_test.go b/admin/service/purchase_workflow_test.go index 3bfd5bb..82dbc84 100644 --- a/admin/service/purchase_workflow_test.go +++ b/admin/service/purchase_workflow_test.go @@ -403,7 +403,7 @@ func TestCreatePurchaseTasksWithOptions_正常采购员可创建真实任务(t * } } -func TestCreatePurchaseTasksWithOptions_真实模式保留账号和Client能力门禁(t *testing.T) { +func TestCreatePurchaseTasksWithOptions_真实模式不要求Client预先声明Live(t *testing.T) { db := newTestDB(t) key := seedPurchasableWorkflow(t, db, "SYB-GATE") SaveSybMapping(db, "SYB-GATE", key, "USR-1") @@ -411,10 +411,11 @@ func TestCreatePurchaseTasksWithOptions_真实模式保留账号和Client能力 RegisterClient(db, model.Client{ClientID: "CLIENT-DRY", Capabilities: `{"purchase_mode":"dry_run"}`}, true) request := []PurchaseTaskRequest{{SybID: "SYB-GATE", MaxPriceCent: 4200}} - if _, err := CreatePurchaseTasksWithOptions(db, admin, request, PurchaseTaskOptions{ + result, err := CreatePurchaseTasksWithOptions(db, admin, request, PurchaseTaskOptions{ ClientID: "CLIENT-DRY", ExecutionMode: model.TaskExecutionLive, - }); err == nil || !strings.Contains(err.Error(), "未声明 live") { - t.Fatalf("dry_run 客户端必须被拒绝: %v", err) + }) + if err != nil || result.Created != 1 { + t.Fatalf("当前账号可见客户端不应被历史能力状态阻止: result=%+v err=%v", result, err) } disabled := *admin disabled.Status = model.UserDisabled diff --git a/admin/service/service.go b/admin/service/service.go index 7b90291..a470c0e 100644 --- a/admin/service/service.go +++ b/admin/service/service.go @@ -220,11 +220,7 @@ func ListPurchaseClientOptions(db *sql.DB, actor *model.User, threshold time.Dur return nil, err } result := &PurchaseClientOptions{Rows: clients} - for _, client := range clients { - if client.PurchaseMode == string(model.TaskExecutionLive) { - result.SelectableCount++ - } - } + result.SelectableCount = len(clients) return result, nil } diff --git a/admin/templates/syb/list.html b/admin/templates/syb/list.html index 1b0ffd0..fb8264c 100644 --- a/admin/templates/syb/list.html +++ b/admin/templates/syb/list.html @@ -171,10 +171,10 @@
- {{if eq .LivePurchaseClientCount 0}}当前没有可执行真实采购的客户端。请先在 Client 设置页启用真实采购并重新登记。{{else}}离线客户端也可以提前指派,待其上线后领取。{{end}} + + {{range .AssignableClients}}{{end}} + + {{if eq .PurchaseClientCount 0}}当前账号没有可见的客户端,请先联系管理员绑定客户端。{{else}}所有当前账号可见的客户端均可指派;未就绪或离线客户端会等待其就绪后领取。{{end}}

价格上限单位是人民币元,默认取已映射 PDD 规格的采集价;不会使用顺运宝的台币售价。

{{range .Rows}} @@ -194,7 +194,7 @@ diff --git a/client/AGENTS.md b/client/AGENTS.md index 8cc3de1..b23c03c 100644 --- a/client/AGENTS.md +++ b/client/AGENTS.md @@ -60,7 +60,7 @@ ## 采购安全 -- Admin 新建采购任务固定下发 `live`;Client 本地真实采购授权仍默认关闭,未满足质量文档中的设备绑定和采购安全门禁不得声明 live 能力或领取任务。 +- Admin 新建采购任务固定下发 `live`;Client 不提供手工启用或关闭真实采购的请求、设置或调试开关。Client 身份、已选 Android 设备和真实采购执行器就绪时自动声明 live;任一项未就绪时不得领取真实采购任务。 - 高风险点击前必须使用最新页面状态重新校验商品、规格、数量、价格和目标坐标。 - 进入不可逆下单阶段前先持久化执行步骤。 - 不可逆阶段发生崩溃或结果不确定时,只能核对订单或转人工处理,禁止自动重新下单。 diff --git a/client/src/live_purchase_authorization.py b/client/src/live_purchase_authorization.py deleted file mode 100644 index a92daef..0000000 --- a/client/src/live_purchase_authorization.py +++ /dev/null @@ -1,126 +0,0 @@ -"""真实下单的本地授权状态。 - -授权只来自设置页的明确确认,并绑定当前 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" diff --git a/client/src/pdd_ui_event.py b/client/src/pdd_ui_event.py index b99a461..c3cff9c 100644 --- a/client/src/pdd_ui_event.py +++ b/client/src/pdd_ui_event.py @@ -52,7 +52,6 @@ from .pdd_device_service import ( ) 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 @@ -140,9 +139,6 @@ class ClaimTaskWorker(QObject): 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, @@ -156,7 +152,6 @@ class ClaimTaskWorker(QObject): 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 = ( @@ -222,18 +217,12 @@ class ClaimTaskWorker(QObject): ) result = service.execute_selected(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 - ), - ) - ) + purchase_mode = ( + "live" + if android_serial + and self._live_purchase_adapter_factory is not None + else "dry_run" + ) dispatcher = TaskDispatcher( self._gateway, self._task_repository, @@ -496,9 +485,6 @@ 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 @@ -1024,7 +1010,6 @@ class PDDTaskPageEvent(QObject): 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, ) diff --git a/client/src/settings_ui.py b/client/src/settings_ui.py index 78085bf..b45ff82 100644 --- a/client/src/settings_ui.py +++ b/client/src/settings_ui.py @@ -298,34 +298,6 @@ 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) @@ -361,7 +333,6 @@ 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) @@ -486,52 +457,6 @@ 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: """显示后续设备身份服务提供的当前客户端信息。""" @@ -559,28 +484,6 @@ 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 设备。""" diff --git a/client/src/settings_ui_event.py b/client/src/settings_ui_event.py index dc86c68..45667cd 100644 --- a/client/src/settings_ui_event.py +++ b/client/src/settings_ui_event.py @@ -35,11 +35,6 @@ 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 @@ -300,85 +295,6 @@ 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): """绑定设备管理控件,并向应用层发出稳定事件。""" @@ -387,7 +303,6 @@ class SettingsPageEventBinder(QObject): currentDeviceSaveRequested = pyqtSignal(str, str) saveRequested = pyqtSignal(str, str) deleteRequested = pyqtSignal(str) - liveAuthorizationRequested = pyqtSignal(str, str) androidDeviceConfigurationChanged = pyqtSignal(str) def __init__( @@ -429,11 +344,7 @@ 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._refresh_registration_after_android_setting = False self._live_purchase_adapter_ready = bool( live_purchase_adapter_ready ) @@ -450,10 +361,6 @@ 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 ) @@ -484,18 +391,6 @@ 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 @@ -507,7 +402,6 @@ 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) @@ -543,11 +437,10 @@ class SettingsPageEventBinder(QObject): try: device = self._selected_android_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, + purchase_mode = ( + "live" + if device is not None and self._live_purchase_adapter_ready + else "dry_run" ) supported_types = ( (TaskType.COLLECT, TaskType.PURCHASE) @@ -591,128 +484,6 @@ 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 ( @@ -1121,25 +892,6 @@ 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: """切换界面忙碌状态,防止用户重复提交设备命令。""" @@ -1182,7 +934,7 @@ class SettingsPageEventBinder(QObject): ) def _selected_android_device(self) -> Optional[AndroidDeviceInfo]: - serial = self._page.deviceTableModel.checked_serial.strip() + serial = self._selected_android_device_service.load().strip() return AndroidDeviceInfo(serial) if serial else None @pyqtSlot(object) @@ -1364,6 +1116,10 @@ class SettingsPageEventBinder(QObject): "当前使用设备:未选择(本地配置已删除)" ) self.androidDeviceConfigurationChanged.emit("") + displayed_client_id = self._page.deviceIdInput.text().strip() + self._refresh_registration_after_android_setting = bool( + displayed_client_id and displayed_client_id != DEVICE_ID_PLACEHOLDER + ) @pyqtSlot(str, str) def _on_android_device_setting_failed( @@ -1375,6 +1131,7 @@ class SettingsPageEventBinder(QObject): self._page.deviceStatusLabel.setText( f"{action_text}失败:{message};原设备配置未更改,可重试" ) + self._refresh_registration_after_android_setting = False @pyqtSlot() def _on_android_device_setting_finished(self) -> None: @@ -1383,6 +1140,9 @@ class SettingsPageEventBinder(QObject): self._android_setting_busy = False if not self._closing: self._sync_button_state() + if self._refresh_registration_after_android_setting: + self._refresh_registration_after_android_setting = False + QTimer.singleShot(0, self._request_save_current_device) @pyqtSlot(str, str) def _on_local_saved(self, client_id: str, client_name: str) -> None: @@ -1449,11 +1209,6 @@ 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: diff --git a/client/src/task_dispatcher.py b/client/src/task_dispatcher.py index 8af708f..341fe40 100644 --- a/client/src/task_dispatcher.py +++ b/client/src/task_dispatcher.py @@ -310,7 +310,7 @@ class TaskDispatcher: if task.execution_mode == "live": if self.claim_capabilities().purchase_mode != "live": raise RuntimeError( - f"真实采购任务 {task.remote_task_id} 的本地授权已关闭或绑定不一致;" + f"真实采购任务 {task.remote_task_id} 的设备或真实采购执行器未就绪;" "任务保持待执行,不会降级为演练" ) factory = self._live_purchase_factory diff --git a/client/test/test_live_purchase_authorization.py b/client/test/test_live_purchase_authorization.py deleted file mode 100644 index df0c4fb..0000000 --- a/client/test/test_live_purchase_authorization.py +++ /dev/null @@ -1,87 +0,0 @@ -"""真实下单授权服务测试;不连接 Admin 或手机。""" - -import os -import tempfile -import unittest -from pathlib import Path - -from src.live_purchase_authorization import ( - LIVE_CONFIRMATION_TEXT, - LIVE_ENABLED_KEY, - LivePurchaseAuthorizationService, -) -from src.settings_repository import SettingsRepository - - -class LivePurchaseAuthorizationTest(unittest.TestCase): - def setUp(self) -> None: - self.temporary_directory = tempfile.TemporaryDirectory() - repository = SettingsRepository( - Path(self.temporary_directory.name) / "client.db" - ) - self.repository = repository - self.service = LivePurchaseAuthorizationService(repository) - - def tearDown(self) -> None: - self.temporary_directory.cleanup() - - def test_missing_setting_and_environment_always_default_to_dry_run(self): - os.environ["CMAUTOBUY_PURCHASE_MODE"] = "live" - try: - self.assertFalse(self.service.load().enabled) - self.assertEqual( - self.service.purchase_mode_for( - "CLIENT-1", "USB-1", live_adapter_ready=True - ), - "dry_run", - ) - finally: - os.environ.pop("CMAUTOBUY_PURCHASE_MODE", None) - - def test_enable_requires_exact_confirmation_and_binds_identity(self): - with self.assertRaisesRegex(ValueError, LIVE_CONFIRMATION_TEXT): - self.service.enable("CLIENT-1", "USB-1", "确认") - self.assertIsNone(self.repository.get(LIVE_ENABLED_KEY)) - - authorization = self.service.enable( - "CLIENT-1", "USB-1", LIVE_CONFIRMATION_TEXT - ) - - self.assertTrue(authorization.enabled) - self.assertTrue(authorization.matches("CLIENT-1", "USB-1")) - self.assertEqual( - self.service.purchase_mode_for( - "CLIENT-1", "USB-1", live_adapter_ready=True - ), - "live", - ) - - def test_binding_mismatch_or_missing_adapter_stays_dry_run(self): - self.service.enable("CLIENT-1", "USB-1", LIVE_CONFIRMATION_TEXT) - - self.assertEqual( - self.service.purchase_mode_for( - "CLIENT-2", "USB-1", live_adapter_ready=True - ), - "dry_run", - ) - self.assertEqual( - self.service.purchase_mode_for( - "CLIENT-1", "USB-2", live_adapter_ready=True - ), - "dry_run", - ) - self.assertEqual( - self.service.purchase_mode_for( - "CLIENT-1", "USB-1", live_adapter_ready=False - ), - "dry_run", - ) - - def test_disable_is_immediate_and_does_not_require_confirmation(self): - self.service.enable("CLIENT-1", "USB-1", LIVE_CONFIRMATION_TEXT) - - authorization = self.service.disable() - - self.assertFalse(authorization.enabled) - self.assertFalse(self.service.load().enabled) diff --git a/client/test/test_pdd_ui_event.py b/client/test/test_pdd_ui_event.py index 0d5a019..d6cce28 100644 --- a/client/test/test_pdd_ui_event.py +++ b/client/test/test_pdd_ui_event.py @@ -1249,6 +1249,27 @@ class PDDTaskPageEventTest(unittest.TestCase): events.shutdown() page.deleteLater() + def test_ready_runtime_automatically_declares_live_capability(self): + gateway = RecordingClaimGateway(None) + page = PDDTaskPage() + events = PDDTaskPageEvent( + page, + self.repository, + claim_gateway=gateway, + settings_repository=self._saved_settings(), + purchase_adapter_factory=fake_purchase_factory, + live_purchase_adapter_factory=lambda *_args: None, + ) + + page.autoFetchRequested.emit() + + self.assertTrue(wait_until(self.app, lambda: not events._claim_busy)) + _, capabilities = gateway.calls[0] + self.assertEqual(capabilities.purchase_mode, "live") + self.assertIn(TaskType.PURCHASE, capabilities.supported_types) + events.shutdown() + page.deleteLater() + def test_no_task_schedules_next_claim_and_stop_cancels_timer(self): gateway = RecordingClaimGateway(None) page = PDDTaskPage() diff --git a/client/test/test_settings_ui_event.py b/client/test/test_settings_ui_event.py index ed050fa..798736c 100644 --- a/client/test/test_settings_ui_event.py +++ b/client/test/test_settings_ui_event.py @@ -19,13 +19,10 @@ from src.android_device_service import ( AndroidWifiConversionResult, ) from src.mock_admin_gateway import MockAdminGateway -from src.live_purchase_authorization import ( - LIVE_CONFIRMATION_TEXT, - LIVE_ENABLED_KEY, -) from src.selected_android_device_service import SELECTED_ANDROID_SERIAL_KEY from src.settings_repository import SettingsRepository from src.settings_ui import AndroidDeviceRow, SettingsPage +from src.task_models import TaskType class SlowMockAdminGateway(MockAdminGateway): @@ -219,23 +216,20 @@ class SettingsPageEventTest(unittest.TestCase): page.eventBinder.shutdown() page.deleteLater() - def test_live_purchase_defaults_off_and_requires_exact_confirmation(self): + def test_settings_page_has_no_manual_live_purchase_authorization(self): page = SettingsPage( settings_repository=self.repository, admin_gateway=MockAdminGateway(), live_purchase_adapter_ready=True, ) - self.assertIn("已关闭", page.livePurchaseStatusLabel.text()) - self.assertFalse(page.livePurchaseEnableButton.isEnabled()) - page.livePurchaseConfirmationInput.setText("确认") - self.assertFalse(page.livePurchaseEnableButton.isEnabled()) - page.livePurchaseConfirmationInput.setText(LIVE_CONFIRMATION_TEXT) - self.assertTrue(page.livePurchaseEnableButton.isEnabled()) + self.assertFalse(hasattr(page, "livePurchaseCard")) + self.assertFalse(hasattr(page, "livePurchaseEnableButton")) + self.assertFalse(hasattr(page, "livePurchaseConfirmationInput")) page.eventBinder.shutdown() page.deleteLater() - def test_enable_and_disable_live_purchase_updates_admin_capability(self): + def test_ready_client_automatically_registers_live_capability(self): self.repository.set_many( { "admin.client_id": "CLIENT-LIVE", @@ -252,27 +246,12 @@ class SettingsPageEventTest(unittest.TestCase): live_purchase_adapter_ready=True, ) self._wait_until(lambda: page.eventBinder._search_thread is None) - page.livePurchaseConfirmationInput.setText(LIVE_CONFIRMATION_TEXT) - - page.livePurchaseEnableButton.click() - self._wait_until( - lambda: page.eventBinder._live_purchase_thread is None - ) - - self.assertIs(self.repository.get(LIVE_ENABLED_KEY), True) - self.assertIn("已启用", page.livePurchaseStatusLabel.text()) + page.currentDeviceSaveButton.click() + self._wait_until(lambda: gateway.registration_count == 1) + self._wait_until(lambda: page.eventBinder._thread is None) registered = gateway.registered_client("CLIENT-LIVE") self.assertEqual(registered[1].purchase_mode, "live") - - page.livePurchaseDisableButton.click() - self._wait_until( - lambda: page.eventBinder._live_purchase_thread is None - ) - - self.assertIs(self.repository.get(LIVE_ENABLED_KEY), False) - self.assertIn("已关闭", page.livePurchaseStatusLabel.text()) - registered = gateway.registered_client("CLIENT-LIVE") - self.assertEqual(registered[1].purchase_mode, "dry_run") + self.assertIn(TaskType.PURCHASE, registered[1].supported_types) page.eventBinder.shutdown() page.deleteLater() diff --git a/client/test/test_task_dispatcher.py b/client/test/test_task_dispatcher.py index a267049..2c9e785 100644 --- a/client/test/test_task_dispatcher.py +++ b/client/test/test_task_dispatcher.py @@ -220,11 +220,11 @@ class TaskDispatcherTest(unittest.TestCase): authorized.claim_capabilities().purchase_mode, "live" ) - def test_local_live_task_never_runs_after_authorization_is_disabled(self): + def test_local_live_task_never_runs_when_runtime_is_not_ready(self): task = purchase_task(execution_mode="live") self.repository.add_claimed_task(admin_task_to_new_claimed_task(task)) - with self.assertRaisesRegex(RuntimeError, "授权已关闭"): + with self.assertRaisesRegex(RuntimeError, "设备或真实采购执行器未就绪"): self._dispatcher( purchase_ready=True, live_ready=True, diff --git a/docs/admin/01-requirements.md b/docs/admin/01-requirements.md index f303d77..8acd79a 100644 --- a/docs/admin/01-requirements.md +++ b/docs/admin/01-requirements.md @@ -235,8 +235,8 @@ PDD 商品之所以单独一个模块,是因为它在数据上就是**独立 - 预选不会自动保存或创建采购任务。页面必须显示推荐理由,采购员点击保存后才写映射和只追加的决策审计。 **创建采购任务:** 只有映射仍有效且没有进行中采购任务的行可勾选。确认弹窗列出 -当前账号可见的客户端:声明 live 的客户端可选择,暂时离线也可提前指派;非 live -客户端显示但禁用。新任务固定为真实下单(不支付),提交表单即表示创建真实采购任务, +当前账号可见的全部客户端都可选择,暂时离线或尚未就绪也可提前指派并等待其就绪后领取。 +新任务固定为真实下单(不支付),提交表单即表示创建真实采购任务, 不再额外要求风险复选框或确认短语;服务端仍按 §5 全量复核。 **底部状态条:** 最近同步时间、待匹配条数。 @@ -353,13 +353,13 @@ PDD 商品之所以单独一个模块,是因为它在数据上就是**独立 4. 数量大于 0; 5. **价格上限已填且大于 0** —— 默认从当前映射的 PDD 规格价格带出,操作员可改,但不允许为空; 6. 已选择分配的客户端; -7. 所选客户端存在、在当前账号可见范围内,且声明 `purchase_mode=live`;暂时离线只影响领取时间,不阻止提前指派; +7. 所选客户端存在且在当前账号可见范围内;其在线状态和 `purchase_mode` 只影响何时领取,不阻止提前指派; 8. 同一顺运宝明细没有 `pending` / `assigned` / `claimed` 的采购任务。 Admin 新建采购任务固定使用不可变的 `execution_mode=live`,不向采购员提供模式选择: - `live` 会创建真实未付款订单,管理员和其他正常状态用户均可创建;点击明确标注的创建按钮并提交表单即表示创建,不再要求额外复选框或确认短语; -- 必须显式指定当前账号可见且最后登记能力为 `purchase_mode=live` 的 Client;离线 live Client 可提前指派并在上线后领取; +- 必须显式指定当前账号可见的 Client;不按最后登记的 `purchase_mode` 限制创建,未就绪或离线 Client 等待就绪后领取; - 创建时记录操作用户和操作时间。取消、重派和结果回传均不得修改执行模式。 - 数据库和 API 继续保留 `dry_run/live`,历史任务和缺省兼容值仍按 `dry_run`,不得批量升级历史任务。 diff --git a/docs/admin/04-client-api.md b/docs/admin/04-client-api.md index 860fb8b..0ff8931 100644 --- a/docs/admin/04-client-api.md +++ b/docs/admin/04-client-api.md @@ -113,6 +113,8 @@ UPDATE tasks `execution_mode` 只允许 `dry_run` / `live`;Admin Web 新建采购任务固定为 `live`,历史任务和接口缺省兼容值仍为 `dry_run`。领取查询必须按 Client 上报的 `capabilities.purchase_mode` 过滤:`dry_run` Client 只能看到演练任务, `live` Client 可以看到两种;过滤只决定能否领取,绝不改变任务自身模式。 +Admin Web 可以把 live 任务预先指派给当前账号可见的任意 Client,不在创建时按该字段 +拒绝;尚未就绪的指定 Client 不会领取,待其设备与真实采购执行器就绪并自动上报 live 后领取。 `[必须]` `payload.goods_url` 必须有值;采购任务的 `quantity` 和 `max_price_cent` 必须有值。 这些在建任务时就该校验住(见 [01 需求](01-requirements.md) §5), @@ -310,7 +312,7 @@ Client 契约里散落的 Admin 侧硬要求,汇总在这里,**可以直接 - [ ] 响应不含租约、不含 Admin 侧状态 - [ ] `payload.goods_url` 必有值 - [ ] 采购任务的 `quantity`、`max_price_cent` 必有值,且是人民币分整数 -- [ ] 不向只声明 `dry_run` 的客户端分配需要真实下单的任务 +- [ ] 可以预先指派 live 任务给可见 Client,但领取时不向只声明 `dry_run` 的客户端返回 live 任务 - [ ] `result` / `failure` 幂等:同键同内容返回同结果 - [ ] 同键不同内容返回 `409` - [ ] **任务已取消,仍接受结果** diff --git a/docs/admin/05-ui-specification.md b/docs/admin/05-ui-specification.md index e9ed5f8..efd0710 100644 --- a/docs/admin/05-ui-specification.md +++ b/docs/admin/05-ui-specification.md @@ -555,8 +555,8 @@ HTML 片段,只替换弹窗内部的表格、总数和分页。主货运单表 ### 6.4 创建采购任务 只有“可创建采购任务”的行可勾选。勾选后打开服务端渲染的确认弹窗:选择当前账号 -可见且声明 live 能力的客户端,并逐行确认人民币价格上限。离线 live Client 也可提前 -指派,待其上线后领取;非 live Client 显示为禁用项并说明原因。默认价格来自映射规格 +可见的任意客户端,并逐行确认人民币价格上限。离线或尚未就绪的 Client 也可提前 +指派,待其就绪后领取;选项继续显示在线状态但不因能力状态禁用。默认价格来自映射规格 的 PDD 采集价;没有价格时留空强制填写。新任务固定为真实下单(不支付),不显示执行 模式选择,也不再要求额外风险复选框、确认短语或浏览器二次确认。 @@ -578,13 +578,13 @@ grid 样式不得覆盖 `hidden`,关闭后改变勾选再打开也不得残留 · SO-...-009 规格未匹配 ``` -`[必须]` 创建前弹出确认框,让操作员选**分配给哪个 live Client**,并确认价格上限。 +`[必须]` 创建前弹出确认框,让操作员选**分配给哪个当前账号可见的 Client**,并确认价格上限。 价格上限默认从当前映射的 PDD 规格采集价带出,可改,**不允许为空**。 可采购行的“下一步”显示“创建采购任务”。点击后复用同一个确认弹窗并只带入当前明细, 不改变表格复选框状态;顶部批量入口仍按已勾选明细打开。客户端选项显示名称和 Client -ID;live Client 可选择且只有一个时自动选中,非 live Client 显示“未启用真实采购”并 -禁用。没有符合条件的 Client 时禁用提交并说明如何在 Client 设置页启用。已有未 +ID 和在线状态;所有可见 Client 都可选择且只有一个时自动选中。没有可见 Client 时禁用 +提交并说明需要管理员先绑定客户端。已有未 结束任务的行显示“查看采购任务”并进入“采集采购”页,不得再次创建。服务端仍必须重新 校验映射、数量、人民币价格上限、客户端权限、固定 live 模式、创建审计和重复任务,不能 信任列表页的旧状态。 diff --git a/docs/admin/07-设备登记联调手册.md b/docs/admin/07-设备登记联调手册.md index d35febf..ba4cf99 100644 --- a/docs/admin/07-设备登记联调手册.md +++ b/docs/admin/07-设备登记联调手册.md @@ -91,7 +91,7 @@ Admin 使用它作为 `clients.client_id` 主键。设备号变化会产生一 - `supported_types` 必须非空,只允许 `collect`、`purchase`; - `device` 可选,提供时当前只支持 `android`; -- `purchase_mode` 只允许 `dry_run`、`live`,MVP 固定 `dry_run`; +- `purchase_mode` 只允许 `dry_run`、`live`;Client 未选择设备或真实采购执行器未就绪时为 `dry_run`,就绪后自动为 `live`; - `schema_versions` 只包含正整数,当前使用 `[1]`。 --- diff --git a/docs/client/01-requirements.md b/docs/client/01-requirements.md index 0e67576..5839b9a 100644 --- a/docs/client/01-requirements.md +++ b/docs/client/01-requirements.md @@ -282,7 +282,7 @@ CMAutoBuy/ 整个文件夹拷到任何机器都能用 **已定案(不再是待确认项):** -- Admin 新建采购任务固定为 `execution_mode=live`,不再提供演练选择;Client 本地授权仍默认关闭,只有绑定 Client ID、Android 设备且通过安全门禁后才声明 live 能力和领取任务。 +- Admin 新建采购任务固定为 `execution_mode=live`,不再提供演练选择;Client 不提供手工 live 授权,身份、已选 Android 设备和真实采购 Adapter 就绪时自动声明 live 并领取任务。 - Admin 只分配任务给指定 Client,Client 不读全局任务池。见 [04](04-admin-api-contract.md) §5.1。 - **没有租约、没有心跳、没有状态回查。** 任务流程只有领取、提交结果、提交失败三个调用;设置页另有无任务副作用的幂等 Client 登记调用。见 [04](04-admin-api-contract.md) §1、§4.1。 - 本地只存已领取任务,已完成任务**永久保留**。诊断产物(截图、XML、日志)仍按 `diagnostics.retention_days` 清理,两者不是一回事。见 [03](03-data-model.md) §3.1。 diff --git a/docs/client/02-architecture.md b/docs/client/02-architecture.md index 1d5fa9e..9286815 100644 --- a/docs/client/02-architecture.md +++ b/docs/client/02-architecture.md @@ -367,8 +367,8 @@ Client 与 Admin 的任务交互只有三种调用:领一个任务、提交结 `TaskDispatcher` 是能力声明的唯一入口。没有可用采购 Adapter、没有已保存 Android 设备或本地持久化未准备好时,只声明 `collect`;条件满足时才声明 -`collect,purchase`。Admin 新建采购任务固定为 live,但 Client 本地授权仍默认关闭; -只有本地授权同时匹配当前 Client ID、已保存 Android 设备且 live Adapter 就绪时才声明 +`collect,purchase`。Admin 新建采购任务固定为 live;Client 不再读取手工授权,只有当前 +Client 身份有效、已保存 Android 设备且 live Adapter 就绪时才自动声明 `purchase_mode=live`,否则声明 `dry_run` 且不能领取新建的 live 采购任务。领取响应必须先完整校验并 写入 SQLite,Repository 提交成功后才能分派,避免任务已在 Admin 领取却在本地丢失。 @@ -449,7 +449,7 @@ PDD 页面可能出现登录失效、验证码、控件树不完整、A/B 页面 4. 使用 Outbox 保证结果最终提交,并隔离“提交重试”与“业务重做”。 5. MVP 单设备串行执行,不并行控制多个设备。 6. 采集规格采用通用维度和 SKU 组合结构,不把模型锁死为颜色与尺码两个数组。 -7. Admin 新建采购任务固定为真实下单(不支付);Client 只有通过独立安全验收和本地设备绑定后才声明 live 能力。 +7. Admin 新建采购任务固定为真实下单(不支付);Client 身份、已选设备和 live Adapter 就绪后自动声明 live,真实设备结果仍按独立工单验收。 ## 11. 相关文档 diff --git a/docs/client/03-data-model.md b/docs/client/03-data-model.md index e3a1547..972b1ec 100644 --- a/docs/client/03-data-model.md +++ b/docs/client/03-data-model.md @@ -422,17 +422,13 @@ CREATE TABLE app_settings ( - `automation.poll_interval_seconds` - `automation.max_retries` - `automation.dry_run` -- `purchase.live_enabled` -- `purchase.live_client_id` -- `purchase.live_device_serial` -- `purchase.live_confirmed_at` - `safety.max_quantity` - `safety.price_tolerance_cent` - `diagnostics.artifact_directory` - `diagnostics.retention_days` 访问令牌、密码和 Cookie 不得存入本表。 -`purchase.live_*` 只保存非敏感的本地安全门禁状态;缺失、损坏或绑定不一致时一律按关闭处理。 +历史版本可能遗留 `purchase.live_*` 设置;当前版本不再读取或写入这些键,也不依赖它们决定领取能力。 ## 7. 任务状态转换 diff --git a/docs/client/04-admin-api-contract.md b/docs/client/04-admin-api-contract.md index f2362ef..65edcec 100644 --- a/docs/client/04-admin-api-contract.md +++ b/docs/client/04-admin-api-contract.md @@ -211,8 +211,8 @@ POST /api/v1/client/tasks/claim Client 的 `HttpAdminGateway.claim_next` 已实现本接口,并原样序列化应用层已经 安全确认的 `ClaimCapabilities`。没有采购 Adapter 或设备未准备好时只声明 -`supported_types: ["collect"]`;条件满足时声明 `collect,purchase`。只有设置页 -授权同时匹配当前 Client ID、已保存 Android 设备且 live Adapter 就绪时才声明 +`supported_types: ["collect"]`;条件满足时声明 `collect,purchase`。Client 不发送手工 +真实采购启用请求;当前 Client 身份有效、已保存 Android 设备且 live Adapter 就绪时自动声明 `purchase_mode: "live"`,其他情况均为 `dry_run`。一次调用最多领取一个任务,结果或失败通过 本页 §6 或 §7 提交,完整请求会先进入本地 Outbox。 @@ -232,7 +232,7 @@ Client 的 `HttpAdminGateway.claim_next` 已实现本接口,并原样序列化 规则: - `[必须]` 领取必须在 Admin 内部原子完成,一次调用最多返回一个任务。 -- `[必须]` Admin 不得向只声明 `dry_run` 的 Client 分配要求真实下单的任务。 +- `[必须]` Admin 可以把真实采购任务预先指派给当前账号可见的任意 Client;领取时仍不得把 live 任务返回给只声明 `dry_run` 的 Client。 - `[必须]` `execution_mode` 只允许 `dry_run`、`live`。Admin Web 新建采购任务固定为 `live`;历史任务和接口缺省兼容值仍是 `dry_run`,Client 不得自行把模式从演练提升为真实。 - `[必须]` 声明 `dry_run` 的 Client 只能领取 `dry_run`;声明 `live` 的 Client 可以领取两种模式。能力只缩小可领取范围,不修改任务模式。 - `[必须]` 响应**不包含**租约。Client 拿到任务就开始做,做完再来领下一个。 @@ -247,8 +247,9 @@ Client 的 `HttpAdminGateway.claim_next` 已实现本接口,并原样序列化 | **无主** | 空 | `pending` | **谁先抢到算谁的**,领取时才记下领取者 | 领取还必须同时满足执行能力:`dry_run` Client 永远看不到 `live` 任务; -`live` Client 可以领取 `dry_run` 和 `live`。Admin 当前只允许真实采购任务显式指定 -一个已经登记并声明 `purchase_mode: "live"` 的 Client,因此真实任务不会进入无主任务池。 +`live` Client 可以领取 `dry_run` 和 `live`。Admin Web 创建真实采购任务时显式指定 +当前账号可见的 Client,不要求创建时已经声明 live;未就绪或离线 Client 的任务保持 +`assigned`,直到该 Client 自动声明 live 后领取,因此真实任务不会进入无主任务池。 `[必须]` **指定给本机的优先于无主的。** 显式分配是人为决定,应当先兑现; 无主任务谁抢都一样,可以等。 diff --git a/docs/client/05-ui-specification.md b/docs/client/05-ui-specification.md index 47572fc..e78053b 100644 --- a/docs/client/05-ui-specification.md +++ b/docs/client/05-ui-specification.md @@ -293,13 +293,12 @@ class TaskTableModel(QAbstractTableModel): - 最大重试次数; - 演练模式开关。 -### 真实采购授权(不支付) +### 真实采购运行就绪(不支付) -- Admin 新建采购任务固定为 live;Client 本地授权默认关闭,并明确显示当前状态、绑定 Client、绑定 Android 设备和确认时间; -- 只有输入完整确认文字“创建未付款订单”后才能启用,不能通过环境变量或调试参数开启; -- 启用和关闭在后台线程保存,执行期间禁用重复操作,失败时保留原状态并说明恢复办法; -- 启用仅代表允许创建一笔未付款订单,不允许自动付款;设备或 Client 绑定变化后撤销 live 能力并停止领取新采购任务; -- 关闭操作始终可见,状态不能只用颜色表达。 +- 设置页不显示真实采购授权、确认文字或启用/关闭按钮; +- Client 身份和 Android 设备保存后,真实采购 Adapter 就绪的 Client 自动登记 live 能力; +- 身份、设备或 Adapter 任一未就绪时不领取真实采购任务,页面继续通过现有设备状态说明缺失项; +- 自动就绪只允许创建未付款订单,不允许自动付款。 ### 安全与诊断 diff --git a/docs/client/06-quality-security.md b/docs/client/06-quality-security.md index 2a7b6f0..1ddc422 100644 --- a/docs/client/06-quality-security.md +++ b/docs/client/06-quality-security.md @@ -70,7 +70,7 @@ PDD 解析测试优先使用脱敏的 XML 固件,不要求每次连接真实 ## 3. 采购安全门禁 -Admin 新建采购任务固定为真实下单(不支付);Client 本地 live 授权仍默认关闭。全部满足后才能声明 live 能力并领取真实采购任务: +Admin 新建采购任务固定为真实下单(不支付),Client 不再提供手工 live 授权。Client 身份有效、已选择 Android 设备且真实采购 Adapter 就绪时自动声明 live;领取并执行后仍必须满足下列安全门禁: 1. Admin 原子领取和结果幂等已经通过联合测试。 2. Outbox 断网和重启恢复测试通过。 @@ -78,14 +78,10 @@ Admin 新建采购任务固定为真实下单(不支付);Client 本地 liv 4. 最终下单前后均有持久化步骤标记。 5. 进程在不可逆阶段退出后只执行订单核对,不会重新下单。 6. 订单匹配能识别唯一候选;多个候选进入人工处理。 -7. 演练模式在代表性商品和设备上通过验收。 -8. 操作人员明确确认真实下单范围和安全设置。 +7. 历史演练模式和真实模式在代表性商品与设备上通过自动化验收。 +8. 真实设备首单按独立验收工单执行,并明确停止在支付前。 -Client 本地 live 授权不得通过调试参数、默认配置或界面误操作意外开启。当前版本仅在设置页 -精确输入确认文字后保存授权,并绑定 Client ID 和已保存 Android 设备;任一绑定 -不一致都降级为 `purchase_mode=dry_run`。演练 Adapter 不提供提交方法,live Adapter -只提供一次性提交方法,两者都不提供付款或取消订单方法。开发者不能把自动化测试 -通过当成真机门禁放行;仍需项目负责人和实际操作人员共同确认。 +Client 不保存或读取 `purchase.live_*` 手工授权设置。`purchase_mode` 只表达本次注册或领取时的运行就绪能力:身份、设备或真实采购 Adapter 任一缺失都声明 `dry_run`,全部就绪才自动声明 `live`。演练 Adapter 不提供提交方法,live Adapter 只提供一次性提交方法,两者都不提供付款或取消订单方法。开发者不能把自动化测试通过当成真机验收完成。 ### 3.1 当前自动化安全检查 @@ -103,7 +99,7 @@ Client 本地 live 授权不得通过调试参数、默认配置或界面误操 演练的 `enter_confirmation` 与 `stop_before_submit` 均为只读检查。 - 单元测试必须断言演练没有最终点击、live 只有一次最终点击,且模糊目标不会点击。 -上述只证明本地安全边界;真实设备联调仍须按独立验收工单获得当次明确授权。 +上述只证明本地安全边界;真实设备联调仍须按独立验收工单记录结果,且不得进入付款操作。 ## 4. 自动化防护 @@ -127,6 +123,12 @@ Client 本地 live 授权不得通过调试参数、默认配置或界面误操 商品失效。打开前后的商品页控件树签名相同则视为旧页面,不能据此执行规格选择或 下单;采购进入不可逆阶段前仍须按商品编号等业务字段再次确认目标身份。 +页面轮询不得在每一轮依赖 `app_current()`:部分设备的前台查询会阻塞十秒以上, +还可能把已经显示 PDD 的页面报告为系统设置。连接时可以读取一次前台状态;后续 +商品页和规格面板判断优先使用最新控件树中的包名与组合语义。单次设备调用即使跨过 +截止时间,也要先分析刚返回的最新控件树,再决定成功或超时。规格面板超时必须记录 +最后页面类型、控件树读取次数和脱敏 XML 引用,不保存商品、账号或收货相关原文。 + > **运营提醒(不是代码规则):** Admin 超时重派可能导致同一任务被两台 Client 各下一单, > 产生重复的未付款订单。人工审核时取消多余订单即可。但未付款订单长期堆积可能触发平台风控, > 需要操作人员留意,不要放着不管。 @@ -276,7 +278,7 @@ Artifact 写入前应脱敏,数据库只保存引用。保留周期由设置 | 8 | 开源和商业许可证已确认 | 新增依赖的许可证是否允许本项目的使用方式 | **项目负责人**(不是开发者自己判断) | | 9 | 真实下单版本额外满足采购安全门禁 | 逐条核对 §3 的 8 项 | **项目负责人 + 操作人员共同确认** | -第 8、9 项开发者**不要自己判断放行**。新增依赖时把包名和许可证类型报给项目负责人;真实下单开关必须由项目负责人和实际操作的人一起确认,见 §3。 +第 8、9 项开发者**不要自己判断验收通过**。新增依赖时把包名和许可证类型报给项目负责人;真实设备下单验收必须由项目负责人和实际操作的人共同确认,见 §3。 ## 11. 任务完成定义