From e1b32ea024f6c5b69a76efb827f90abf88ba4682 Mon Sep 17 00:00:00 2001 From: chengma Date: Mon, 10 Aug 2026 17:52:48 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=9B=BA=E5=AE=9A=E5=88=9B=E5=BB=BA?= =?UTF-8?q?=E7=9C=9F=E5=AE=9E=E9=87=87=E8=B4=AD=E4=BB=BB=E5=8A=A1=20(#112)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 +- admin/handler/web/others.go | 24 +++++++++++----- admin/handler/web/others_test.go | 22 +++++++++++++++ admin/main_test.go | 7 +++++ admin/repository/client.go | 12 ++++++++ admin/service/client_test.go | 30 ++++++++++++++++++++ admin/service/purchase_workflow.go | 10 +++++++ admin/service/purchase_workflow_test.go | 20 +++++++++++++ admin/service/service.go | 18 ++++++++++++ admin/static/js/app.js | 37 ++++++------------------- admin/templates/syb/list.html | 27 ++++++------------ client/AGENTS.md | 2 +- docs/admin/01-requirements.md | 16 ++++++----- docs/admin/04-client-api.md | 2 +- docs/admin/05-ui-specification.md | 16 +++++++---- docs/client/01-requirements.md | 2 +- docs/client/02-architecture.md | 8 +++--- docs/client/04-admin-api-contract.md | 2 +- docs/client/05-ui-specification.md | 6 ++-- docs/client/06-quality-security.md | 4 +-- 20 files changed, 185 insertions(+), 82 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f838657..5144255 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ 需要突破其中任何一条时,**停下来问用户**,不要先给方案、不要先评估成本。 1. **Qt 绑定固定 PyQt5。** 不得改用 PyQt6 / PySide2 / PySide6,也不得安装其他绑定对应的 Fluent Widgets 包。 -2. **真实下单开关默认关闭。** 不得通过调试参数、默认配置或界面误操作打开;未通过采购安全门禁前一律走演练模式。 +2. **Admin 新建采购任务固定为真实下单(不支付)。** 创建时必须通过明确的风险确认,且只能分配给已授权并声明 live 能力的 Client;Client 仍须通过设备绑定、商品/规格/数量/价格复核、不可逆标记和单次提交门禁。 3. **不绕过拼多多的验证码、风控和安全机制**,不自动注册登录,不自动付款。 4. **凭据(token、Cookie、密码)不得写入** `data/`、日志、数据库、Gitea 工单和 `docs/task`。 5. **任务一旦进入不可逆阶段**(`task_runs.irreversible_action_at` 有值),**只准核对订单,绝不重新下单**。 diff --git a/admin/handler/web/others.go b/admin/handler/web/others.go index b80f259..1431027 100644 --- a/admin/handler/web/others.go +++ b/admin/handler/web/others.go @@ -66,7 +66,7 @@ func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw, "读取顺运宝同步记录失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。") return } - assignableClients, err := service.ListAssignableClients(h.db, currentUser(c), h.onlineThreshold) + assignableClients, err := service.ListLivePurchaseClients(h.db, currentUser(c), h.onlineThreshold) if err != nil { fail(c, http.StatusInternalServerError, "读取可分配客户端失败,数据没有被改动。") return @@ -630,12 +630,10 @@ func (h *Handler) SybCreateTask(c *gin.Context) { } requests = append(requests, service.PurchaseTaskRequest{SybID: sybID, MaxPriceCent: cent}) } - result, err := service.CreatePurchaseTasksWithOptions(h.db, currentUser(c), requests, service.PurchaseTaskOptions{ - ClientID: c.PostForm("client_id"), - ExecutionMode: model.TaskExecutionMode(c.PostForm("execution_mode")), - LiveAcknowledged: c.PostForm("live_acknowledged") == "1", - LiveConfirmation: c.PostForm("live_confirmation"), - }) + result, err := service.CreatePurchaseTasksWithOptions( + h.db, currentUser(c), requests, + purchaseTaskOptionsFromForm(c, time.Now().UTC().Add(-h.onlineThreshold).Format(model.TimeLayout)), + ) if err != nil { h.sybRedirect(c, "采购任务没有创建:"+err.Error()) return @@ -644,6 +642,18 @@ func (h *Handler) SybCreateTask(c *gin.Context) { h.sybRedirect(c, purchaseTaskResultMessage(result)) } +func purchaseTaskOptionsFromForm(c *gin.Context, clientSeenAfter string) service.PurchaseTaskOptions { + return service.PurchaseTaskOptions{ + ClientID: c.PostForm("client_id"), + // Admin 新建采购任务固定为 live。不能相信浏览器提交的 execution_mode, + // 否则篡改表单就能绕过真实采购确认和审计。 + ExecutionMode: model.TaskExecutionLive, + LiveAcknowledged: c.PostForm("live_acknowledged") == "1", + LiveConfirmation: c.PostForm("live_confirmation"), + ClientSeenAfter: clientSeenAfter, + } +} + func purchaseTaskResultMessage(result service.PurchaseTaskResult) string { parts := []string{fmt.Sprintf("已创建 %d 个采购任务", result.Created)} if len(result.Failures) > 0 { diff --git a/admin/handler/web/others_test.go b/admin/handler/web/others_test.go index 0aa41d0..94f8ae6 100644 --- a/admin/handler/web/others_test.go +++ b/admin/handler/web/others_test.go @@ -8,6 +8,8 @@ import ( "testing" "github.com/gin-gonic/gin" + + "cmautobuy/admin/model" ) func sybPostContext(t *testing.T, values url.Values) (*gin.Context, *httptest.ResponseRecorder) { @@ -147,3 +149,23 @@ func TestSybHistoryPartialPagination_翻页继续使用片段路由(t *testing.T } } } + +func TestPurchaseTaskOptionsFromForm_忽略篡改模式并固定Live(t *testing.T) { + context, _ := sybPostContext(t, url.Values{ + "client_id": {"CLIENT-LIVE"}, + "execution_mode": {"dry_run"}, + "live_acknowledged": {"1"}, + "live_confirmation": {"创建未付款订单"}, + }) + + options := purchaseTaskOptionsFromForm(context, "2026-08-10T09:00:00Z") + if options.ExecutionMode != model.TaskExecutionLive { + t.Fatalf("篡改 execution_mode 不得创建演练任务,实际 %q", options.ExecutionMode) + } + if options.ClientID != "CLIENT-LIVE" || !options.LiveAcknowledged || options.LiveConfirmation != "创建未付款订单" { + t.Fatalf("真实采购确认字段读取错误: %+v", options) + } + if options.ClientSeenAfter != "2026-08-10T09:00:00Z" { + t.Fatalf("在线截止时间未传给服务层: %+v", options) + } +} diff --git a/admin/main_test.go b/admin/main_test.go index bb0dd79..e5c1bb7 100644 --- a/admin/main_test.go +++ b/admin/main_test.go @@ -139,6 +139,8 @@ func TestMainPagesReturnOK(t *testing.T) { `name="date_from"`, `name="date_to"`, `name="stage"`, "处理阶段", "下一步", `data-detail-url=`, "按顺运宝货运单创建日期(UTC+8)同步", + "真实下单安全确认", "确认创建真实采购任务", + `name="live_acknowledged"`, `name="live_confirmation"`, } { if !strings.Contains(sybResponse.Body.String(), want) { t.Errorf("顺运宝页面缺少 %q", want) @@ -147,6 +149,11 @@ func TestMainPagesReturnOK(t *testing.T) { if strings.Contains(sybResponse.Body.String(), "指定日期同步") { t.Error("顺运宝页面不应再显示旧的指定日期同步入口") } + for _, removed := range []string{`name="execution_mode"`, "采购演练", "仅演练", "支持真实下单"} { + if strings.Contains(sybResponse.Body.String(), removed) { + t.Errorf("固定真实采购弹窗不应显示 %q", removed) + } + } _, err = repository.UpsertSybOrder(db, model.SybOrder{ SybID: "SYB-PDD-ID-INPUT", OrderNo: "ORDER-PDD-ID-INPUT", Title: "输入测试商品", diff --git a/admin/repository/client.go b/admin/repository/client.go index 09111ca..b2ca370 100644 --- a/admin/repository/client.go +++ b/admin/repository/client.go @@ -22,6 +22,18 @@ func ClientPurchaseMode(q Execer, clientID string) (string, error) { return ParseClientPurchaseMode(raw.String), nil } +// ClientSeenAfter 判断客户端是否在指定 UTC 时间之后上报过活动。 +func ClientSeenAfter(q Execer, clientID, cutoff string) (bool, error) { + var online bool + if err := q.QueryRow( + `SELECT EXISTS(SELECT 1 FROM clients WHERE client_id = ? AND last_seen_at >= ?)`, + clientID, cutoff, + ).Scan(&online); err != nil { + return false, fmt.Errorf("检查客户端 %s 在线状态失败: %w", clientID, err) + } + return online, nil +} + // ParseClientPurchaseMode 把登记能力转成安全的固定值,供列表和创建校验共用。 func ParseClientPurchaseMode(raw string) string { var capabilities struct { diff --git a/admin/service/client_test.go b/admin/service/client_test.go index 2c83944..a96edfa 100644 --- a/admin/service/client_test.go +++ b/admin/service/client_test.go @@ -325,6 +325,36 @@ func TestClientAssignment_一人多客户端并按采购员隔离列表(t *testi } } +func TestListLivePurchaseClients_只返回可见在线且声明Live的客户端(t *testing.T) { + db := newTestDB(t) + admin, buyerA, _ := prepareClientAssignmentUsers(t, db) + now := time.Now().UTC() + clients := []model.Client{ + {ClientID: "live-online", Name: "真实在线", LastSeenAt: now.Format(model.TimeLayout), Capabilities: `{"purchase_mode":"live"}`}, + {ClientID: "dry-online", Name: "演练在线", LastSeenAt: now.Format(model.TimeLayout), Capabilities: `{"purchase_mode":"dry_run"}`}, + {ClientID: "live-offline", Name: "真实离线", LastSeenAt: now.Add(-time.Hour).Format(model.TimeLayout), Capabilities: `{"purchase_mode":"live"}`}, + } + for _, client := range clients { + if err := RegisterClient(db, client, true); err != nil { + t.Fatalf("登记客户端失败: %v", err) + } + if _, _, err := AssignClient(db, admin, client.ClientID, buyerA.UserID, now); err != nil { + t.Fatalf("绑定客户端失败: %v", err) + } + } + if _, err := db.Exec(`UPDATE clients SET last_seen_at=? WHERE client_id=?`, now.Add(-time.Hour).Format(model.TimeLayout), "live-offline"); err != nil { + t.Fatal(err) + } + + rows, err := ListLivePurchaseClients(db, buyerA, time.Minute) + if err != nil { + t.Fatalf("读取真实采购客户端失败: %v", err) + } + if len(rows) != 1 || rows[0].ClientID != "live-online" { + t.Fatalf("只应返回在线 live 客户端,实际 %+v", rows) + } +} + func TestClientAssignment_转交解绑保留审计且不改任务(t *testing.T) { db := newTestDB(t) admin, buyerA, buyerB := prepareClientAssignmentUsers(t, db) diff --git a/admin/service/purchase_workflow.go b/admin/service/purchase_workflow.go index 68fd3c0..cbafe28 100644 --- a/admin/service/purchase_workflow.go +++ b/admin/service/purchase_workflow.go @@ -170,6 +170,7 @@ type PurchaseTaskOptions struct { ExecutionMode model.TaskExecutionMode LiveAcknowledged bool LiveConfirmation string + ClientSeenAfter string } // PurchaseTaskResult 同时返回已创建数量和每条无法创建的原因。 @@ -212,6 +213,15 @@ func CreatePurchaseTasksWithOptions(db *sql.DB, actor *model.User, requests []Pu if !visible { return result, fmt.Errorf("所选客户端不存在或不在当前账号可见范围") } + if options.ClientSeenAfter != "" { + online, err := repository.ClientSeenAfter(tx, clientID, options.ClientSeenAfter) + if err != nil { + return result, err + } + if !online { + return result, fmt.Errorf("所选客户端已离线,请刷新页面后选择在线客户端") + } + } executionMode := options.ExecutionMode if executionMode == "" { executionMode = model.TaskExecutionDryRun diff --git a/admin/service/purchase_workflow_test.go b/admin/service/purchase_workflow_test.go index 0fef5ff..ff3e165 100644 --- a/admin/service/purchase_workflow_test.go +++ b/admin/service/purchase_workflow_test.go @@ -426,6 +426,26 @@ func TestCreatePurchaseTasksWithOptions_真实模式安全门禁(t *testing.T) { } } +func TestCreatePurchaseTasksWithOptions_页面打开后客户端离线会被拒绝(t *testing.T) { + db := newTestDB(t) + key := seedPurchasableWorkflow(t, db, "SYB-OFFLINE") + SaveSybMapping(db, "SYB-OFFLINE", key, "USR-1") + admin, _, _ := prepareClientAssignmentUsers(t, db) + RegisterClient(db, model.Client{ClientID: "CLIENT-OFFLINE", Capabilities: `{"purchase_mode":"live"}`}, true) + if _, err := db.Exec(`UPDATE clients SET last_seen_at=? WHERE client_id=?`, "2026-08-10T08:00:00Z", "CLIENT-OFFLINE"); err != nil { + t.Fatal(err) + } + + _, err := CreatePurchaseTasksWithOptions(db, admin, + []PurchaseTaskRequest{{SybID: "SYB-OFFLINE", MaxPriceCent: 4200}}, + PurchaseTaskOptions{ClientID: "CLIENT-OFFLINE", ExecutionMode: model.TaskExecutionLive, + LiveAcknowledged: true, LiveConfirmation: LivePurchaseConfirmation, + ClientSeenAfter: "2026-08-10T09:00:00Z"}) + if err == nil || !strings.Contains(err.Error(), "已离线") { + t.Fatalf("离线客户端必须在创建事务内被拒绝: %v", err) + } +} + func TestParsePriceYuanToCent_精确转换(t *testing.T) { for raw, want := range map[string]int64{"39.90": 3990, "1": 100, "0.01": 1, "12.3": 1230} { got, err := ParsePriceYuanToCent(raw) diff --git a/admin/service/service.go b/admin/service/service.go index 5340b65..ce9ba06 100644 --- a/admin/service/service.go +++ b/admin/service/service.go @@ -205,6 +205,24 @@ func ListAssignableClients(db *sql.DB, actor *model.User, threshold time.Duratio return ListClientViewsForUser(db, actor, "", threshold) } +// ListLivePurchaseClients 返回当前用户能分配真实采购任务的在线客户端。 +// +// 页面过滤只用于减少误选;创建任务时仍会在事务内重新校验可见范围和 live 能力。 +func ListLivePurchaseClients(db *sql.DB, actor *model.User, threshold time.Duration) ([]ClientView, error) { + clients, err := ListClientViewsForUser(db, actor, "", threshold) + if err != nil { + return nil, err + } + result := make([]ClientView, 0, len(clients)) + for _, client := range clients { + if client.Status != "在线" || client.PurchaseMode != string(model.TaskExecutionLive) { + continue + } + result = append(result, client) + } + return result, nil +} + // ListActivePurchasers 返回管理员可选择的绑定目标。 func ListActivePurchasers(db *sql.DB, actor *model.User) ([]model.User, error) { if actor == nil || !actor.IsAdmin() { diff --git a/admin/static/js/app.js b/admin/static/js/app.js index 39aca16..701216d 100644 --- a/admin/static/js/app.js +++ b/admin/static/js/app.js @@ -140,33 +140,9 @@ var openButtons = document.querySelectorAll("[data-purchase-open]"); var form = document.querySelector("[data-purchase-form]"); if (!openButtons.length || !form) return; - var modeInputs = form.querySelectorAll("[data-execution-mode]"); - var livePanel = form.querySelector("[data-live-confirm-panel]"); var liveAcknowledged = form.querySelector("[data-live-acknowledged]"); var liveConfirmation = form.querySelector("[data-live-confirmation]"); - var clientSelect = form.querySelector("select[name=client_id]"); - function updateExecutionMode() { - var selected = form.querySelector("[data-execution-mode]:checked"); - var isLive = selected && selected.value === "live"; - if (livePanel) livePanel.hidden = !isLive; - if (liveAcknowledged) liveAcknowledged.required = isLive; - if (liveConfirmation) liveConfirmation.required = isLive; - if (clientSelect) { - clientSelect.querySelectorAll("option[data-purchase-mode]").forEach(function (option) { - option.disabled = isLive && option.getAttribute("data-purchase-mode") !== "live"; - }); - if (clientSelect.selectedOptions.length && clientSelect.selectedOptions[0].disabled) { - clientSelect.value = ""; - } - } - form.setAttribute("data-confirm-submit", isLive - ? "确认创建真实的未付款订单?系统不会自动支付。" - : "确认创建采购演练任务?任务创建后将等待所选客户端领取。"); - } - modeInputs.forEach(function (input) { - input.addEventListener("change", updateExecutionMode); - }); - updateExecutionMode(); + var submitButton = form.querySelector("[data-purchase-submit]"); openButtons.forEach(function (openButton) { openButton.addEventListener("click", function () { var selected = {}; @@ -190,14 +166,17 @@ var empty = form.querySelector("[data-purchase-empty]"); if (empty) empty.hidden = shown > 0; - /* 每次打开都回到演练模式,避免上次关闭弹窗后遗留 live 选择。 */ - var dryRun = form.querySelector("[data-execution-mode][value=dry_run]"); - if (dryRun) dryRun.checked = true; + /* 每次打开都清空真实采购确认,不能沿用上一次未提交的确认。 */ if (liveAcknowledged) liveAcknowledged.checked = false; if (liveConfirmation) liveConfirmation.value = ""; - updateExecutionMode(); }); }); + form.addEventListener("submit", function (event) { + if (event.defaultPrevented || !submitButton) return; + submitButton.disabled = true; + submitButton.setAttribute("aria-busy", "true"); + submitButton.textContent = "创建中…"; + }); } /* PDD 批量采集弹窗只同步已选数量并提供提交反馈。 diff --git a/admin/templates/syb/list.html b/admin/templates/syb/list.html index cda092d..d3441c8 100644 --- a/admin/templates/syb/list.html +++ b/admin/templates/syb/list.html @@ -163,7 +163,7 @@
+ data-confirm-submit="确认创建真实的未付款订单?系统不会自动支付。"> @@ -172,32 +172,21 @@
- {{if not .AssignableClients}}当前账号没有可用客户端,请先让管理员完成客户端绑定。{{end}} + {{if not .AssignableClients}}当前没有可执行真实采购的在线客户端。请检查客户端绑定、在线状态和真实采购授权。{{end}}
-
- 执行模式 - - -
-
diff --git a/client/AGENTS.md b/client/AGENTS.md index 92ac881..8cc3de1 100644 --- a/client/AGENTS.md +++ b/client/AGENTS.md @@ -60,7 +60,7 @@ ## 采购安全 -- 真实下单开关默认关闭;未满足质量文档中的采购安全门禁不得启用。 +- Admin 新建采购任务固定下发 `live`;Client 本地真实采购授权仍默认关闭,未满足质量文档中的设备绑定和采购安全门禁不得声明 live 能力或领取任务。 - 高风险点击前必须使用最新页面状态重新校验商品、规格、数量、价格和目标坐标。 - 进入不可逆下单阶段前先持久化执行步骤。 - 不可逆阶段发生崩溃或结果不确定时,只能核对订单或转人工处理,禁止自动重新下单。 diff --git a/docs/admin/01-requirements.md b/docs/admin/01-requirements.md index f0813fd..c9f6e19 100644 --- a/docs/admin/01-requirements.md +++ b/docs/admin/01-requirements.md @@ -234,8 +234,9 @@ PDD 商品之所以单独一个模块,是因为它在数据上就是**独立 - 规则引擎只对可购买候选做 A/B/C/冲突分层和稳定排序;只有唯一、无额外维度歧义的 A 级才预选。 - 预选不会自动保存或创建采购任务。页面必须显示推荐理由,采购员点击保存后才写映射和只追加的决策审计。 -**创建采购任务:** 只有映射仍有效且没有进行中采购任务的行可勾选。确认弹窗要求 -选择当前账号可见的客户端,并逐行确认人民币价格上限;服务端仍按 §5 全量复核。 +**创建采购任务:** 只有映射仍有效且没有进行中采购任务的行可勾选。确认弹窗只列出 +当前账号可见、在线且声明 live 能力的客户端,并逐行确认人民币价格上限。新任务固定 +为真实下单(不支付),弹窗始终要求风险复选和确认短语;服务端仍按 §5 全量复核。 **底部状态条:** 最近同步时间、待匹配条数。 @@ -351,15 +352,16 @@ PDD 商品之所以单独一个模块,是因为它在数据上就是**独立 4. 数量大于 0; 5. **价格上限已填且大于 0** —— 默认从当前映射的 PDD 规格价格带出,操作员可改,但不允许为空; 6. 已选择分配的客户端; -7. 所选客户端存在且在当前账号可见范围内; -8. 同一顺运宝明细没有 `pending` / `assigned` / `claimed` 的采购任务。 +7. 所选客户端存在、在线、在当前账号可见范围内,且声明 `purchase_mode=live`; +8. 已勾选真实下单风险确认并输入“创建未付款订单”; +9. 同一顺运宝明细没有 `pending` / `assigned` / `claimed` 的采购任务。 -采购任务创建时必须选择不可变的执行模式: +Admin 新建采购任务固定使用不可变的 `execution_mode=live`,不向采购员提供模式选择: -- `dry_run`(采购演练)是默认值,历史任务也按此处理; - `live` 会创建真实未付款订单,管理员和其他正常状态用户均可创建,但必须勾选风险确认并输入“创建未付款订单”; -- `live` 必须显式指定当前账号可见、且最后登记能力为 `purchase_mode=live` 的 Client; +- 必须显式指定当前账号可见、在线且最后登记能力为 `purchase_mode=live` 的 Client; - 创建时记录确认用户和确认时间。取消、重派和结果回传均不得修改执行模式。 +- 数据库和 API 继续保留 `dry_run/live`,历史任务和缺省兼容值仍按 `dry_run`,不得批量升级历史任务。 第 5 条是硬要求:Client 契约规定采购任务必须带明确的价格保护 (见 [Client 契约](../client/04-admin-api-contract.md) §4),没有它 Client 会拒绝执行。 diff --git a/docs/admin/04-client-api.md b/docs/admin/04-client-api.md index f012151..860fb8b 100644 --- a/docs/admin/04-client-api.md +++ b/docs/admin/04-client-api.md @@ -110,7 +110,7 @@ UPDATE tasks `[必须]` **响应里不含租约**,也不含 Admin 侧状态。Client 不关心这些。 -`execution_mode` 只允许 `dry_run` / `live`,历史任务默认 `dry_run`。领取查询必须按 +`execution_mode` 只允许 `dry_run` / `live`;Admin Web 新建采购任务固定为 `live`,历史任务和接口缺省兼容值仍为 `dry_run`。领取查询必须按 Client 上报的 `capabilities.purchase_mode` 过滤:`dry_run` Client 只能看到演练任务, `live` Client 可以看到两种;过滤只决定能否领取,绝不改变任务自身模式。 diff --git a/docs/admin/05-ui-specification.md b/docs/admin/05-ui-specification.md index 0b2b9f0..1b41fa7 100644 --- a/docs/admin/05-ui-specification.md +++ b/docs/admin/05-ui-specification.md @@ -555,7 +555,9 @@ HTML 片段,只替换弹窗内部的表格、总数和分页。主货运单表 ### 6.4 创建采购任务 只有“可创建采购任务”的行可勾选。勾选后打开服务端渲染的确认弹窗:选择当前账号 -可见的客户端,并逐行确认人民币价格上限。默认值来自映射规格的 PDD 采集价;没有价格时留空强制填写。 +可见、在线且声明 live 能力的客户端,并逐行确认人民币价格上限。默认值来自映射规格 +的 PDD 采集价;没有价格时留空强制填写。新任务固定为真实下单(不支付),不显示执行 +模式选择;风险说明、复选确认和“创建未付款订单”确认短语始终显示。 `[必须]` 校验不过的**不要静默跳过**,要列出来告诉操作员缺什么: @@ -569,14 +571,16 @@ HTML 片段,只替换弹窗内部的表格、总数和分页。主货运单表 · SO-...-009 规格未匹配 ``` -`[必须]` 创建前弹出确认框,让操作员选**分配给哪个客户端**,并确认价格上限。 +`[必须]` 创建前弹出确认框,让操作员选**分配给哪个 live 在线客户端**,并确认价格上限。 价格上限默认从当前映射的 PDD 规格采集价带出,可改,**不允许为空**。 可采购行的“下一步”显示“创建采购任务”。点击后复用同一个确认弹窗并只带入当前明细, -不改变表格复选框状态;顶部批量入口仍按已勾选明细打开。每次打开确认弹窗都默认回到 -“采购演练”,不得保留上一次未提交的真实下单选择。已有未结束任务的行显示 -“查看采购任务”并进入“采集采购”页,不得再次创建。服务端仍必须重新校验映射、数量、 -人民币价格上限、客户端权限、执行模式和重复任务,不能信任列表页的旧状态。 +不改变表格复选框状态;顶部批量入口仍按已勾选明细打开。每次打开确认弹窗都清空 +上一次未提交的风险复选和确认短语。客户端选项只显示名称和 Client ID,不显示“在线、 +仅演练/支持真实下单”等模式文字;没有符合条件的 Client 时禁用提交并说明原因。已有未 +结束任务的行显示“查看采购任务”并进入“采集采购”页,不得再次创建。服务端仍必须重新 +校验映射、数量、人民币价格上限、客户端权限、固定 live 模式、确认信息和重复任务,不能 +信任列表页的旧状态。 ## 7. 采集采购页 diff --git a/docs/client/01-requirements.md b/docs/client/01-requirements.md index 01ca618..0e67576 100644 --- a/docs/client/01-requirements.md +++ b/docs/client/01-requirements.md @@ -275,7 +275,6 @@ CMAutoBuy/ 整个文件夹拷到任何机器都能用 | # | 待确认什么 | 临时默认值(先这么做) | 定下来之前会卡住什么 | |---|---|---|---| | 1 | Admin 的认证方式和 Client 注册方式 | Mock Gateway 不做认证;HTTP 实现预留 `Authorization: Bearer` 请求头,token 从设置读 | 只卡真实 HTTP 联调,不卡 MVP | -| 2 | 真实采购启用条件和人工确认策略 | 真实下单开关**保持关闭**,一律走演练模式 | 卡真实下单,不卡 MVP | | 3 | 价格允许偏差 | 默认 0 分(即实际价格必须等于预期价格才继续),可在设置页改 | 不卡。先按最严的来 | | 4 | 是否存在颜色、尺码之外的第三个规格维度 | **按"可能有任意多个维度"实现**,用 `dimensions` 数组,不要写死两个 | 不卡。写死才会返工 | @@ -283,6 +282,7 @@ CMAutoBuy/ 整个文件夹拷到任何机器都能用 **已定案(不再是待确认项):** +- Admin 新建采购任务固定为 `execution_mode=live`,不再提供演练选择;Client 本地授权仍默认关闭,只有绑定 Client ID、Android 设备且通过安全门禁后才声明 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 92bd74c..b187864 100644 --- a/docs/client/02-architecture.md +++ b/docs/client/02-architecture.md @@ -356,9 +356,9 @@ Client 与 Admin 的任务交互只有三种调用:领一个任务、提交结 `TaskDispatcher` 是能力声明的唯一入口。没有可用采购 Adapter、没有已保存 Android 设备或本地持久化未准备好时,只声明 `collect`;条件满足时才声明 -`collect,purchase`。真实下单授权默认关闭;只有本地授权同时匹配当前 Client ID、 -已保存 Android 设备且 live Adapter 就绪时才声明 `purchase_mode=live`,否则声明 -`dry_run`。领取响应必须先完整校验并 +`collect,purchase`。Admin 新建采购任务固定为 live,但 Client 本地授权仍默认关闭; +只有本地授权同时匹配当前 Client ID、已保存 Android 设备且 live Adapter 就绪时才声明 +`purchase_mode=live`,否则声明 `dry_run` 且不能领取新建的 live 采购任务。领取响应必须先完整校验并 写入 SQLite,Repository 提交成功后才能分派,避免任务已在 Admin 领取却在本地丢失。 中途 Admin 是否取消了这个任务、是否重派给了别人,Client 不查也不管,做完照样提交—— @@ -437,7 +437,7 @@ PDD 页面可能出现登录失效、验证码、控件树不完整、A/B 页面 4. 使用 Outbox 保证结果最终提交,并隔离“提交重试”与“业务重做”。 5. MVP 单设备串行执行,不并行控制多个设备。 6. 采集规格采用通用维度和 SKU 组合结构,不把模型锁死为颜色与尺码两个数组。 -7. MVP 采购默认演练模式,真实下单必须通过独立安全验收。 +7. Admin 新建采购任务固定为真实下单(不支付);Client 只有通过独立安全验收和本地设备绑定后才声明 live 能力。 ## 11. 相关文档 diff --git a/docs/client/04-admin-api-contract.md b/docs/client/04-admin-api-contract.md index ace08d9..f2362ef 100644 --- a/docs/client/04-admin-api-contract.md +++ b/docs/client/04-admin-api-contract.md @@ -233,7 +233,7 @@ Client 的 `HttpAdminGateway.claim_next` 已实现本接口,并原样序列化 - `[必须]` 领取必须在 Admin 内部原子完成,一次调用最多返回一个任务。 - `[必须]` Admin 不得向只声明 `dry_run` 的 Client 分配要求真实下单的任务。 -- `[必须]` `execution_mode` 只允许 `dry_run`、`live`。历史任务和缺省值都是 `dry_run`;Client 不得自行把模式从演练提升为真实。 +- `[必须]` `execution_mode` 只允许 `dry_run`、`live`。Admin Web 新建采购任务固定为 `live`;历史任务和接口缺省兼容值仍是 `dry_run`,Client 不得自行把模式从演练提升为真实。 - `[必须]` 声明 `dry_run` 的 Client 只能领取 `dry_run`;声明 `live` 的 Client 可以领取两种模式。能力只缩小可领取范围,不修改任务模式。 - `[必须]` 响应**不包含**租约。Client 拿到任务就开始做,做完再来领下一个。 diff --git a/docs/client/05-ui-specification.md b/docs/client/05-ui-specification.md index 47ddcb5..47572fc 100644 --- a/docs/client/05-ui-specification.md +++ b/docs/client/05-ui-specification.md @@ -293,12 +293,12 @@ class TaskTableModel(QAbstractTableModel): - 最大重试次数; - 演练模式开关。 -### 真实下单(不支付) +### 真实采购授权(不支付) -- 默认关闭,并明确显示当前状态、绑定 Client、绑定 Android 设备和确认时间; +- Admin 新建采购任务固定为 live;Client 本地授权默认关闭,并明确显示当前状态、绑定 Client、绑定 Android 设备和确认时间; - 只有输入完整确认文字“创建未付款订单”后才能启用,不能通过环境变量或调试参数开启; - 启用和关闭在后台线程保存,执行期间禁用重复操作,失败时保留原状态并说明恢复办法; -- 启用仅代表允许创建一笔未付款订单,不允许自动付款;设备或 Client 绑定变化后自动降级为演练; +- 启用仅代表允许创建一笔未付款订单,不允许自动付款;设备或 Client 绑定变化后撤销 live 能力并停止领取新采购任务; - 关闭操作始终可见,状态不能只用颜色表达。 ### 安全与诊断 diff --git a/docs/client/06-quality-security.md b/docs/client/06-quality-security.md index 4cb86f7..50647c8 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 能力并领取真实采购任务: 1. Admin 原子领取和结果幂等已经通过联合测试。 2. Outbox 断网和重启恢复测试通过。 @@ -81,7 +81,7 @@ PDD 解析测试优先使用脱敏的 XML 固件,不要求每次连接真实 7. 演练模式在代表性商品和设备上通过验收。 8. 操作人员明确确认真实下单范围和安全设置。 -真实下单不得通过调试参数、默认配置或界面误操作意外开启。当前版本仅在设置页 +Client 本地 live 授权不得通过调试参数、默认配置或界面误操作意外开启。当前版本仅在设置页 精确输入确认文字后保存授权,并绑定 Client ID 和已保存 Android 设备;任一绑定 不一致都降级为 `purchase_mode=dry_run`。演练 Adapter 不提供提交方法,live Adapter 只提供一次性提交方法,两者都不提供付款或取消订单方法。开发者不能把自动化测试