diff --git a/AGENTS.md b/AGENTS.md index 5144255..0d05bd1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ 需要突破其中任何一条时,**停下来问用户**,不要先给方案、不要先评估成本。 1. **Qt 绑定固定 PyQt5。** 不得改用 PyQt6 / PySide2 / PySide6,也不得安装其他绑定对应的 Fluent Widgets 包。 -2. **Admin 新建采购任务固定为真实下单(不支付)。** 创建时必须通过明确的风险确认,且只能分配给已授权并声明 live 能力的 Client;Client 仍须通过设备绑定、商品/规格/数量/价格复核、不可逆标记和单次提交门禁。 +2. **Admin 新建采购任务固定为真实下单(不支付)。** 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 dcd0b3f..f1813cb 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.ListLivePurchaseClients(h.db, currentUser(c), h.onlineThreshold) + purchaseClients, err := service.ListPurchaseClientOptions(h.db, currentUser(c), h.onlineThreshold) if err != nil { fail(c, http.StatusInternalServerError, "读取可分配客户端失败,数据没有被改动。") return @@ -181,9 +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": assignableClients, + "Pagination": service.NewPaginationView(result.Page, result.TotalPages, values.Encode()), + "DetailURL": "/syb/detail?" + detailValues.Encode(), + "AssignableClients": purchaseClients.Rows, + "LivePurchaseClientCount": purchaseClients.SelectableCount, })) } @@ -643,7 +644,7 @@ func (h *Handler) SybCreateTask(c *gin.Context) { } result, err := service.CreatePurchaseTasksWithOptions( h.db, currentUser(c), requests, - purchaseTaskOptionsFromForm(c, time.Now().UTC().Add(-h.onlineThreshold).Format(model.TimeLayout)), + purchaseTaskOptionsFromForm(c), ) if err != nil { h.sybRedirect(c, "采购任务没有创建:"+err.Error()) @@ -653,15 +654,12 @@ func (h *Handler) SybCreateTask(c *gin.Context) { h.sybRedirect(c, purchaseTaskResultMessage(result)) } -func purchaseTaskOptionsFromForm(c *gin.Context, clientSeenAfter string) service.PurchaseTaskOptions { +func purchaseTaskOptionsFromForm(c *gin.Context) 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, + // 否则篡改表单就能改变任务的不可变执行模式。 + ExecutionMode: model.TaskExecutionLive, } } diff --git a/admin/handler/web/others_test.go b/admin/handler/web/others_test.go index 94f8ae6..4197261 100644 --- a/admin/handler/web/others_test.go +++ b/admin/handler/web/others_test.go @@ -152,20 +152,15 @@ 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": {"创建未付款订单"}, + "client_id": {"CLIENT-LIVE"}, + "execution_mode": {"dry_run"}, }) - options := purchaseTaskOptionsFromForm(context, "2026-08-10T09:00:00Z") + options := purchaseTaskOptionsFromForm(context) 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) + if options.ClientID != "CLIENT-LIVE" { + t.Fatalf("采购客户端读取错误: %+v", options) } } diff --git a/admin/main_test.go b/admin/main_test.go index e5c1bb7..368a2c7 100644 --- a/admin/main_test.go +++ b/admin/main_test.go @@ -128,6 +128,18 @@ func TestMainPagesReturnOK(t *testing.T) { if usersResponse.Code != http.StatusForbidden { t.Fatalf("采购员 GET /users = %d,期望 403", usersResponse.Code) } + for _, client := range []model.Client{ + {ClientID: "CLIENT-LIVE", Name: "真实采购机", Capabilities: `{"purchase_mode":"live"}`}, + {ClientID: "CLIENT-DRY", Name: "未授权机", Capabilities: `{"purchase_mode":"dry_run"}`}, + } { + if err := service.RegisterClient(db, client, true); err != nil { + t.Fatalf("准备采购客户端失败: %v", err) + } + } + if _, err := db.Exec(`UPDATE clients SET last_seen_at=? WHERE client_id=?`, + time.Now().UTC().Add(-time.Hour).Format(model.TimeLayout), "CLIENT-LIVE"); err != nil { + t.Fatalf("准备离线 live 客户端失败: %v", err) + } sybRequest := httptest.NewRequest(http.MethodGet, "/syb", nil) addAuth(sybRequest) @@ -139,17 +151,27 @@ 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) } } + for _, want := range []string{ + `value="CLIENT-LIVE" selected`, + `value="CLIENT-DRY" disabled`, + "未授权机(CLIENT-DRY) — 未启用真实采购", + "离线客户端也可以提前指派", + } { + if !strings.Contains(sybResponse.Body.String(), want) { + t.Errorf("采购客户端候选缺少 %q", want) + } + } if strings.Contains(sybResponse.Body.String(), "指定日期同步") { t.Error("顺运宝页面不应再显示旧的指定日期同步入口") } - for _, removed := range []string{`name="execution_mode"`, "采购演练", "仅演练", "支持真实下单"} { + for _, removed := range []string{`name="execution_mode"`, "采购演练", "仅演练", "支持真实下单", + "真实下单安全确认", `name="live_acknowledged"`, `name="live_confirmation"`, `data-confirm-submit=`} { if strings.Contains(sybResponse.Body.String(), removed) { t.Errorf("固定真实采购弹窗不应显示 %q", removed) } diff --git a/admin/repository/client.go b/admin/repository/client.go index b2ca370..09111ca 100644 --- a/admin/repository/client.go +++ b/admin/repository/client.go @@ -22,18 +22,6 @@ 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 a96edfa..6f70942 100644 --- a/admin/service/client_test.go +++ b/admin/service/client_test.go @@ -325,7 +325,7 @@ func TestClientAssignment_一人多客户端并按采购员隔离列表(t *testi } } -func TestListLivePurchaseClients_只返回可见在线且声明Live的客户端(t *testing.T) { +func TestListPurchaseClientOptions_返回可见客户端且离线Live仍可选(t *testing.T) { db := newTestDB(t) admin, buyerA, _ := prepareClientAssignmentUsers(t, db) now := time.Now().UTC() @@ -346,12 +346,15 @@ func TestListLivePurchaseClients_只返回可见在线且声明Live的客户端( t.Fatal(err) } - rows, err := ListLivePurchaseClients(db, buyerA, time.Minute) + options, err := ListPurchaseClientOptions(db, buyerA, time.Minute) if err != nil { - t.Fatalf("读取真实采购客户端失败: %v", err) + t.Fatalf("读取采购客户端候选失败: %v", err) } - if len(rows) != 1 || rows[0].ClientID != "live-online" { - t.Fatalf("只应返回在线 live 客户端,实际 %+v", rows) + if len(options.Rows) != 3 || options.SelectableCount != 2 { + t.Fatalf("应显示全部可见客户端且两个 live 可选,实际 %+v", options) + } + if options.Rows[2].ClientID != "live-offline" || options.Rows[2].Status != "离线" { + t.Fatalf("离线 live 客户端必须保留在候选中,实际 %+v", options.Rows) } } diff --git a/admin/service/purchase_workflow.go b/admin/service/purchase_workflow.go index 23490ad..0ecc4df 100644 --- a/admin/service/purchase_workflow.go +++ b/admin/service/purchase_workflow.go @@ -163,16 +163,11 @@ type PurchaseTaskRequest struct { ContextVersion string } -const LivePurchaseConfirmation = "创建未付款订单" - // PurchaseTaskOptions 是一次批量创建共用的执行门禁。 // ExecutionMode 留空表示 dry_run,确保旧调用和普通操作都保持安全默认值。 type PurchaseTaskOptions struct { - ClientID string - ExecutionMode model.TaskExecutionMode - LiveAcknowledged bool - LiveConfirmation string - ClientSeenAfter string + ClientID string + ExecutionMode model.TaskExecutionMode } // PurchaseTaskResult 同时返回已创建数量和每条无法创建的原因。 @@ -215,15 +210,6 @@ 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 @@ -233,9 +219,6 @@ func CreatePurchaseTasksWithOptions(db *sql.DB, actor *model.User, requests []Pu } confirmedBy, confirmedAt := "", "" if executionMode == model.TaskExecutionLive { - if !options.LiveAcknowledged || strings.TrimSpace(options.LiveConfirmation) != LivePurchaseConfirmation { - return result, fmt.Errorf("真实下单必须勾选风险确认并输入“%s”", LivePurchaseConfirmation) - } purchaseMode, err := repository.ClientPurchaseMode(tx, clientID) if err != nil { return result, err diff --git a/admin/service/purchase_workflow_test.go b/admin/service/purchase_workflow_test.go index e5c92b5..3bfd5bb 100644 --- a/admin/service/purchase_workflow_test.go +++ b/admin/service/purchase_workflow_test.go @@ -389,8 +389,7 @@ func TestCreatePurchaseTasksWithOptions_正常采购员可创建真实任务(t * result, err := CreatePurchaseTasksWithOptions(db, buyer, []PurchaseTaskRequest{{SybID: "SYB-LIVE", MaxPriceCent: 4200}}, - PurchaseTaskOptions{ClientID: "CLIENT-LIVE", ExecutionMode: model.TaskExecutionLive, - LiveAcknowledged: true, LiveConfirmation: LivePurchaseConfirmation}) + PurchaseTaskOptions{ClientID: "CLIENT-LIVE", ExecutionMode: model.TaskExecutionLive}) if err != nil || result.Created != 1 { t.Fatalf("正常采购员创建真实任务失败: result=%+v err=%v", result, err) } @@ -404,7 +403,7 @@ func TestCreatePurchaseTasksWithOptions_正常采购员可创建真实任务(t * } } -func TestCreatePurchaseTasksWithOptions_真实模式安全门禁(t *testing.T) { +func TestCreatePurchaseTasksWithOptions_真实模式保留账号和Client能力门禁(t *testing.T) { db := newTestDB(t) key := seedPurchasableWorkflow(t, db, "SYB-GATE") SaveSybMapping(db, "SYB-GATE", key, "USR-1") @@ -414,16 +413,9 @@ func TestCreatePurchaseTasksWithOptions_真实模式安全门禁(t *testing.T) { if _, err := CreatePurchaseTasksWithOptions(db, admin, request, PurchaseTaskOptions{ ClientID: "CLIENT-DRY", ExecutionMode: model.TaskExecutionLive, - LiveAcknowledged: true, LiveConfirmation: LivePurchaseConfirmation, }); err == nil || !strings.Contains(err.Error(), "未声明 live") { t.Fatalf("dry_run 客户端必须被拒绝: %v", err) } - if _, err := CreatePurchaseTasksWithOptions(db, admin, request, PurchaseTaskOptions{ - ClientID: "CLIENT-DRY", ExecutionMode: model.TaskExecutionLive, - LiveAcknowledged: true, LiveConfirmation: "错误短语", - }); err == nil || !strings.Contains(err.Error(), "必须勾选") { - t.Fatalf("错误确认短语必须被拒绝: %v", err) - } disabled := *admin disabled.Status = model.UserDisabled if _, err := CreatePurchaseTasks(db, &disabled, request, "CLIENT-DRY"); err == nil || !strings.Contains(err.Error(), "不是正常状态") { @@ -431,7 +423,7 @@ func TestCreatePurchaseTasksWithOptions_真实模式安全门禁(t *testing.T) { } } -func TestCreatePurchaseTasksWithOptions_页面打开后客户端离线会被拒绝(t *testing.T) { +func TestCreatePurchaseTasksWithOptions_离线Live客户端可提前指派(t *testing.T) { db := newTestDB(t) key := seedPurchasableWorkflow(t, db, "SYB-OFFLINE") SaveSybMapping(db, "SYB-OFFLINE", key, "USR-1") @@ -441,13 +433,11 @@ func TestCreatePurchaseTasksWithOptions_页面打开后客户端离线会被拒 t.Fatal(err) } - _, err := CreatePurchaseTasksWithOptions(db, admin, + result, 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) + PurchaseTaskOptions{ClientID: "CLIENT-OFFLINE", ExecutionMode: model.TaskExecutionLive}) + if err != nil || result.Created != 1 { + t.Fatalf("离线 live 客户端应可提前指派: result=%+v err=%v", result, err) } } @@ -468,8 +458,7 @@ func TestCreatePurchaseTasksWithOptions_拒绝弹窗打开后变化的规格映 result, err := CreatePurchaseTasksWithOptions(db, admin, []PurchaseTaskRequest{{SybID: "SYB-STALE", MaxPriceCent: 3990, MappingOptionKey: blackKey, ContextVersion: oldVersion}}, - PurchaseTaskOptions{ClientID: "CLIENT-LIVE", ExecutionMode: model.TaskExecutionLive, - LiveAcknowledged: true, LiveConfirmation: LivePurchaseConfirmation}) + PurchaseTaskOptions{ClientID: "CLIENT-LIVE", ExecutionMode: model.TaskExecutionLive}) if err != nil || result.Created != 0 || len(result.Failures) != 1 || !strings.Contains(result.Failures[0].Reason, "已变化") { t.Fatalf("旧弹窗必须因映射变化被拒绝: result=%+v err=%v", result, err) diff --git a/admin/service/service.go b/admin/service/service.go index ce9ba06..7b90291 100644 --- a/admin/service/service.go +++ b/admin/service/service.go @@ -205,20 +205,25 @@ 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) { +// PurchaseClientOptions 是采购弹窗的客户端候选和可选数量。 +// 非 live Client 也返回给页面显示禁用原因;创建时仍会在事务内复核能力。 +type PurchaseClientOptions struct { + Rows []ClientView + SelectableCount int +} + +// ListPurchaseClientOptions 返回当前用户可见的采购客户端。 +// live Client 即使暂时离线也可提前指派,等它上线后再领取任务。 +func ListPurchaseClientOptions(db *sql.DB, actor *model.User, threshold time.Duration) (*PurchaseClientOptions, error) { clients, err := ListClientViewsForUser(db, actor, "", threshold) if err != nil { return nil, err } - result := make([]ClientView, 0, len(clients)) + result := &PurchaseClientOptions{Rows: clients} for _, client := range clients { - if client.Status != "在线" || client.PurchaseMode != string(model.TaskExecutionLive) { - continue + if client.PurchaseMode == string(model.TaskExecutionLive) { + result.SelectableCount++ } - result = append(result, client) } return result, nil } diff --git a/admin/static/js/app.js b/admin/static/js/app.js index 701216d..64a18a5 100644 --- a/admin/static/js/app.js +++ b/admin/static/js/app.js @@ -140,8 +140,6 @@ var openButtons = document.querySelectorAll("[data-purchase-open]"); var form = document.querySelector("[data-purchase-form]"); if (!openButtons.length || !form) return; - var liveAcknowledged = form.querySelector("[data-live-acknowledged]"); - var liveConfirmation = form.querySelector("[data-live-confirmation]"); var submitButton = form.querySelector("[data-purchase-submit]"); openButtons.forEach(function (openButton) { openButton.addEventListener("click", function () { @@ -165,10 +163,6 @@ }); var empty = form.querySelector("[data-purchase-empty]"); if (empty) empty.hidden = shown > 0; - - /* 每次打开都清空真实采购确认,不能沿用上一次未提交的确认。 */ - if (liveAcknowledged) liveAcknowledged.checked = false; - if (liveConfirmation) liveConfirmation.value = ""; }); }); form.addEventListener("submit", function (event) { diff --git a/admin/templates/syb/list.html b/admin/templates/syb/list.html index ccdf9a3..1b0ffd0 100644 --- a/admin/templates/syb/list.html +++ b/admin/templates/syb/list.html @@ -162,8 +162,7 @@