feat: 增加真实采购任务安全模式 (#98)

This commit is contained in:
chengma
2026-08-10 15:11:38 +08:00
parent ba6710a2a2
commit 3aaa40cfe0
23 changed files with 642 additions and 66 deletions
+9 -8
View File
@@ -123,7 +123,7 @@ func (h *Handler) Claim(c *gin.Context) {
}
// 2. 领取一个任务,只会拿到分配给这个客户端的
task, err := service.ClaimNextTask(h.db, clientID, req.SupportedTypes)
task, err := service.ClaimNextTask(h.db, clientID, req.SupportedTypes, req.Capabilities.PurchaseMode)
if err != nil {
log.Printf("task_claim_failed client_id=%s err=%v", clientID, err)
apiError(c, http.StatusInternalServerError, "TASK_CLAIM_FAILED",
@@ -171,13 +171,14 @@ func taskPayload(t *model.Task) gin.H {
}
return gin.H{
"id": t.TaskID,
"type": t.TaskType,
"version": t.Version,
"priority": t.Priority,
"payload": payload,
"created_at": t.CreatedAt,
"updated_at": t.UpdatedAt,
"id": t.TaskID,
"type": t.TaskType,
"execution_mode": t.ExecutionMode,
"version": t.Version,
"priority": t.Priority,
"payload": payload,
"created_at": t.CreatedAt,
"updated_at": t.UpdatedAt,
}
}
+14
View File
@@ -0,0 +1,14 @@
package api
import (
"testing"
"cmautobuy/admin/model"
)
func TestTaskPayload_包含不可变执行模式(t *testing.T) {
payload := taskPayload(&model.Task{TaskID: "T-1", TaskType: model.TaskPurchase, ExecutionMode: model.TaskExecutionLive})
if payload["execution_mode"] != model.TaskExecutionLive {
t.Fatalf("execution_mode=%v", payload["execution_mode"])
}
}
+6 -1
View File
@@ -630,7 +630,12 @@ func (h *Handler) SybCreateTask(c *gin.Context) {
}
requests = append(requests, service.PurchaseTaskRequest{SybID: sybID, MaxPriceCent: cent})
}
result, err := service.CreatePurchaseTasks(h.db, currentUser(c), requests, c.PostForm("client_id"))
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"),
})
if err != nil {
h.sybRedirect(c, "采购任务没有创建:"+err.Error())
return
+18 -5
View File
@@ -313,17 +313,27 @@ const (
TaskCancelled TaskStatus = "cancelled" // 已取消
)
// TaskExecutionMode 区分只演练流程和会在拼多多创建未付款订单的真实流程。
// 模式在任务创建时确定,后续取消、重派和结果回传都不得修改。
type TaskExecutionMode string
const (
TaskExecutionDryRun TaskExecutionMode = "dry_run"
TaskExecutionLive TaskExecutionMode = "live"
)
// Task 是发给 Client 执行的一个任务。
//
// PddGoodsURL 必填——Client 那边是 NOT NULL,空了它执行不了。
// 采购任务的 Quantity 和 MaxPriceCent 也必填,这是价格保护,
// 见 docs/client/04-admin-api-contract.md §4。
type Task struct {
TaskID string
TaskType TaskType
Status TaskStatus
Version int
Priority int
TaskID string
TaskType TaskType
Status TaskStatus
ExecutionMode TaskExecutionMode
Version int
Priority int
AssignedClient string
ClaimedAt string
@@ -344,6 +354,9 @@ type Task struct {
ErrorMessage string
FinishedAt string
LiveConfirmedBy string
LiveConfirmedAt string
CreatedAt string
UpdatedAt string
}
+24
View File
@@ -2,6 +2,7 @@ package repository
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
@@ -9,6 +10,29 @@ import (
"cmautobuy/admin/model"
)
// ClientPurchaseMode 返回客户端最后一次登记时声明的采购能力。
// 旧数据、空值或无法解析的 JSON 一律按 dry_run 处理,这是安全默认值。
func ClientPurchaseMode(q Execer, clientID string) (string, error) {
var raw sql.NullString
if err := q.QueryRow(`SELECT capabilities FROM clients WHERE client_id = ?`, clientID).Scan(&raw); errors.Is(err, sql.ErrNoRows) {
return "", ErrClientNotFound
} else if err != nil {
return "", fmt.Errorf("读取客户端 %s 的采购能力失败: %w", clientID, err)
}
return ParseClientPurchaseMode(raw.String), nil
}
// ParseClientPurchaseMode 把登记能力转成安全的固定值,供列表和创建校验共用。
func ParseClientPurchaseMode(raw string) string {
var capabilities struct {
PurchaseMode string `json:"purchase_mode"`
}
if json.Unmarshal([]byte(raw), &capabilities) == nil && capabilities.PurchaseMode == string(model.TaskExecutionLive) {
return capabilities.PurchaseMode
}
return string(model.TaskExecutionDryRun)
}
var (
ErrClientNotFound = errors.New("客户端不存在")
ErrPurchaserNotActive = errors.New("采购员不存在或已禁用")
+136 -2
View File
@@ -19,7 +19,7 @@ import (
"cmautobuy/admin/spec"
)
const mysqlSchemaVersion = 4
const mysqlSchemaVersion = 5
// OpenMySQL 打开生产 MySQL 8 数据库。错误信息绝不包含完整 DSN 或密码。
func OpenMySQL(cfg config.DatabaseConfig) (*sql.DB, error) {
@@ -471,10 +471,77 @@ func MigrateMySQL(db *sql.DB) error {
if _, err := db.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`, 4, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
return fmt.Errorf("记录 MySQL schema v4 失败: %w", err)
}
current = 4
}
if current < 5 {
if err := migrateMySQLV5(db); err != nil {
return fmt.Errorf("执行 MySQL schema v5 失败: %w", err)
}
if err := checkMySQLV5Shape(db); err != nil {
return fmt.Errorf("MySQL schema v5 自检失败,未记录版本: %w", err)
}
if _, err := db.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`, 5, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
return fmt.Errorf("记录 MySQL schema v5 失败: %w", err)
}
}
return CheckMySQLSchema(db)
}
// migrateMySQLV5 给任务增加不可变执行模式和真实下单创建审计。
// 每条 DDL 都先检查存在性,MySQL 在任意一步隐式提交后都可以安全重放。
func migrateMySQLV5(db *sql.DB) error {
columns := []struct {
name string
ddl string
}{
{"execution_mode", `ALTER TABLE tasks ADD COLUMN execution_mode VARCHAR(16) COLLATE utf8mb4_bin NOT NULL DEFAULT 'dry_run' AFTER status`},
{"live_confirmed_by", `ALTER TABLE tasks ADD COLUMN live_confirmed_by VARCHAR(191) COLLATE utf8mb4_bin NULL AFTER finished_at`},
{"live_confirmed_at", `ALTER TABLE tasks ADD COLUMN live_confirmed_at VARCHAR(35) NULL AFTER live_confirmed_by`},
}
for _, column := range columns {
exists, err := mysqlColumnExists(db, "tasks", column.name)
if err != nil {
return err
}
if !exists {
if _, err := db.Exec(column.ddl); err != nil {
return fmt.Errorf("增加 tasks.%s 失败: %w", column.name, err)
}
}
}
if _, err := db.Exec(`UPDATE tasks SET execution_mode='dry_run' WHERE execution_mode IS NULL OR execution_mode=''`); err != nil {
return fmt.Errorf("回填任务执行模式失败: %w", err)
}
constraints := []struct {
name string
ddl string
}{
{"chk_tasks_execution_mode", `ALTER TABLE tasks ADD CONSTRAINT chk_tasks_execution_mode CHECK (execution_mode IN ('dry_run','live'))`},
{"chk_tasks_live_confirmation", `ALTER TABLE tasks ADD CONSTRAINT chk_tasks_live_confirmation CHECK ((execution_mode='dry_run' AND live_confirmed_by IS NULL AND live_confirmed_at IS NULL) OR (execution_mode='live' AND live_confirmed_by IS NOT NULL AND live_confirmed_by<>'' AND live_confirmed_at IS NOT NULL AND live_confirmed_at<>''))`},
}
for _, constraint := range constraints {
exists, err := mysqlConstraintExists(db, "tasks", constraint.name)
if err != nil {
return err
}
if !exists {
if _, err := db.Exec(constraint.ddl); err != nil {
return fmt.Errorf("增加 tasks.%s 失败: %w", constraint.name, err)
}
}
}
indexExists, err := mysqlIndexExists(db, "tasks", "idx_tasks_claim_mode")
if err != nil {
return err
}
if !indexExists {
if _, err := db.Exec(`ALTER TABLE tasks ADD INDEX idx_tasks_claim_mode (assigned_client, status, execution_mode, priority DESC, created_at)`); err != nil {
return fmt.Errorf("增加任务模式领取索引失败: %w", err)
}
}
return nil
}
func checkMySQLSchemaV3(db *sql.DB) error {
tables := []string{"shopee_products", "shopee_skus", "pdd_products", "syb_orders", "spec_mappings", "tasks", "clients", "idempotency_keys", "task_claims", "syb_session", "syb_sync_state", "users", "web_sessions", "client_user_assignments", "syb_sync_runs", "admin_initialization_lock"}
if err := checkMySQLSchema(db, tables); err != nil {
@@ -563,6 +630,15 @@ func mysqlConstraintExists(db *sql.DB, table, constraint string) (bool, error) {
return count == 1, nil
}
func mysqlIndexExists(db *sql.DB, table, index string) (bool, error) {
var count int
if err := db.QueryRow(`SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics
WHERE table_schema=DATABASE() AND table_name=? AND index_name=?`, table, index).Scan(&count); err != nil {
return false, fmt.Errorf("检查 MySQL 索引 %s.%s 失败: %w", table, index, err)
}
return count == 1, nil
}
func backfillSybSpecKeys(db *sql.DB) error {
const batchSize = 500
cursor := ""
@@ -729,7 +805,65 @@ func CheckMySQLSchema(db *sql.DB) error {
if err := checkMySQLV3Shape(db); err != nil {
return err
}
return checkMySQLV4Shape(db)
if err := checkMySQLV4Shape(db); err != nil {
return err
}
return checkMySQLV5Shape(db)
}
func checkMySQLV5Shape(db *sql.DB) error {
if err := checkMySQLVarcharColumn(db, "tasks", "execution_mode", 16, false, "utf8mb4_bin", "dry_run"); err != nil {
return err
}
if err := checkMySQLVarcharColumn(db, "tasks", "live_confirmed_by", 191, true, "utf8mb4_bin", ""); err != nil {
return err
}
if err := checkMySQLNullDefault(db, "tasks", "live_confirmed_by"); err != nil {
return err
}
if err := checkMySQLVarcharColumn(db, "tasks", "live_confirmed_at", 35, true, "utf8mb4_0900_ai_ci", ""); err != nil {
return err
}
if err := checkMySQLNullDefault(db, "tasks", "live_confirmed_at"); err != nil {
return err
}
for _, check := range []struct {
name string
required []string
}{
{"chk_tasks_execution_mode", []string{"execution_modein'dry_run','live'"}},
{"chk_tasks_live_confirmation", []string{"execution_mode='dry_run'", "live_confirmed_byisnull", "live_confirmed_atisnull", "execution_mode='live'", "live_confirmed_byisnotnull", "live_confirmed_by<>''", "live_confirmed_atisnotnull", "live_confirmed_at<>''"}},
} {
var enforced, clause string
if err := db.QueryRow(`SELECT tc.enforced,cc.check_clause FROM information_schema.table_constraints tc JOIN information_schema.check_constraints cc ON cc.constraint_schema=tc.constraint_schema AND cc.constraint_name=tc.constraint_name WHERE tc.constraint_schema=DATABASE() AND tc.table_name='tasks' AND tc.constraint_name=? AND tc.constraint_type='CHECK'`, check.name).Scan(&enforced, &clause); err != nil {
return fmt.Errorf("任务 CHECK %s 缺失或不可读: %w", check.name, err)
}
normalized := strings.NewReplacer("`", "", " ", "", "(", "", ")", "", "_utf8mb4", "", `\`, "").Replace(strings.ToLower(clause))
if enforced != "YES" {
return fmt.Errorf("任务 CHECK %s 未启用", check.name)
}
for _, fragment := range check.required {
if !strings.Contains(normalized, fragment) {
return fmt.Errorf("任务 CHECK %s 表达式不正确", check.name)
}
}
}
var cols string
if err := db.QueryRow(`SELECT GROUP_CONCAT(CONCAT(column_name,':',collation) ORDER BY seq_in_index) FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='tasks' AND index_name='idx_tasks_claim_mode'`).Scan(&cols); err != nil || cols != "assigned_client:A,status:A,execution_mode:A,priority:D,created_at:A" {
return fmt.Errorf("任务模式领取索引不正确")
}
return nil
}
func checkMySQLNullDefault(db *sql.DB, table, column string) error {
var defaultValue sql.NullString
if err := db.QueryRow(`SELECT column_default FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name=? AND column_name=?`, table, column).Scan(&defaultValue); err != nil {
return fmt.Errorf("检查 MySQL 列 %s.%s 默认值失败: %w", table, column, err)
}
if defaultValue.Valid {
return fmt.Errorf("MySQL 列 %s.%s 默认值应为 NULL", table, column)
}
return nil
}
func checkMySQLV4Shape(db *sql.DB) error {
@@ -232,6 +232,60 @@ func TestMySQLMigrate_V4形状错误不记版本(t *testing.T) {
}
}
func TestMySQLMigrate_V4升级V5且断点重跑(t *testing.T) {
db := openMySQLMigrationTestDB(t)
defer db.Close()
cleanMySQLTestSchema(t, db)
defer cleanMySQLTestSchema(t, db)
prepareMySQLV4(t, db)
// 模拟全部 DDL 已完成但版本尚未记录。
if err := migrateMySQLV5(db); err != nil {
t.Fatal(err)
}
if err := MigrateMySQL(db); err != nil {
t.Fatal(err)
}
if err := MigrateMySQL(db); err != nil {
t.Fatalf("v5 重跑失败: %v", err)
}
var versions int
if err := db.QueryRow(`SELECT COUNT(*) FROM schema_migrations WHERE version=5`).Scan(&versions); err != nil || versions != 1 {
t.Fatalf("v5=%d err=%v", versions, err)
}
now := "2026-08-10T00:00:00Z"
mustExec(t, db, `INSERT INTO tasks(task_id,task_type,status,pdd_goods_url,created_at,updated_at) VALUES('OLD','collect','pending','https://example.invalid',?,?)`, now, now)
var mode string
if err := db.QueryRow(`SELECT execution_mode FROM tasks WHERE task_id='OLD'`).Scan(&mode); err != nil || mode != "dry_run" {
t.Fatalf("历史/缺省任务模式=%q err=%v", mode, err)
}
if _, err := db.Exec(`UPDATE tasks SET execution_mode='invalid' WHERE task_id='OLD'`); err == nil {
t.Fatal("执行模式 CHECK 必须拒绝非法值")
}
if _, err := db.Exec(`UPDATE tasks SET execution_mode='live' WHERE task_id='OLD'`); err == nil {
t.Fatal("缺少确认审计的 live 必须被拒绝")
}
if _, err := db.Exec(`UPDATE tasks SET live_confirmed_by='U',live_confirmed_at=? WHERE task_id='OLD'`, now); err == nil {
t.Fatal("dry_run 不得携带 live 确认审计")
}
}
func TestMySQLMigrate_V5形状错误不记版本(t *testing.T) {
db := openMySQLMigrationTestDB(t)
defer db.Close()
cleanMySQLTestSchema(t, db)
defer cleanMySQLTestSchema(t, db)
prepareMySQLV4(t, db)
mustExec(t, db, `ALTER TABLE tasks ADD COLUMN execution_mode VARCHAR(8) NOT NULL DEFAULT 'dry_run'`)
if err := MigrateMySQL(db); err == nil {
t.Fatal("错误 execution_mode 形状必须阻止 v5")
}
var count int
db.QueryRow(`SELECT COUNT(*) FROM schema_migrations WHERE version=5`).Scan(&count)
if count != 0 {
t.Fatal("v5 自检失败不得记录版本")
}
}
func openMySQLMigrationTestDB(t *testing.T) *sql.DB {
t.Helper()
if os.Getenv("CMAUTOBUY_MYSQL_TEST") != "1" {
@@ -263,6 +317,16 @@ func prepareMySQLV2(t *testing.T, db *sql.DB) {
mustExec(t, db, `INSERT INTO schema_migrations(version,applied_at) VALUES (1,'2026-08-10T00:00:00Z'),(2,'2026-08-10T00:00:00Z')`)
}
func prepareMySQLV4(t *testing.T, db *sql.DB) {
t.Helper()
prepareMySQLV2(t, db)
if err := migrateMySQLV3(db); err != nil {
t.Fatal(err)
}
mustExec(t, db, mysqlSchemaV4Decisions)
mustExec(t, db, `INSERT INTO schema_migrations(version,applied_at) VALUES (3,'2026-08-10T00:00:00Z'),(4,'2026-08-10T00:00:00Z')`)
}
func mustExec(t *testing.T, db *sql.DB, query string, args ...any) {
t.Helper()
if _, err := db.Exec(query, args...); err != nil {
+45 -10
View File
@@ -30,10 +30,17 @@ import (
//
// InnoDB 事务用 FOR UPDATE SKIP LOCKED 锁住一条候选任务。领取状态和
// task_claims 历史在同一个事务提交,避免只改了状态却没留下领取凭据。
func ClaimNextTask(db *sql.DB, clientID string, supportedTypes []string) (*model.Task, error) {
func ClaimNextTask(db *sql.DB, clientID string, supportedTypes []string, purchaseModes ...string) (*model.Task, error) {
if clientID == "" {
return nil, fmt.Errorf("client_id 不能为空")
}
purchaseMode := string(model.TaskExecutionDryRun)
if len(purchaseModes) > 0 {
purchaseMode = purchaseModes[0]
}
if purchaseMode != string(model.TaskExecutionDryRun) && purchaseMode != string(model.TaskExecutionLive) {
return nil, fmt.Errorf("purchase_mode 必须是 dry_run 或 live")
}
ctx := context.Background()
tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
if err != nil {
@@ -45,6 +52,11 @@ func ClaimNextTask(db *sql.DB, clientID string, supportedTypes []string) (*model
WHERE ( (assigned_client = ? AND status = 'assigned')
OR (assigned_client IS NULL AND status = 'pending') )`
args := []any{clientID}
// dry_run 客户端永远看不到真实采购任务。声明 live 的客户端仍可执行
// 演练任务,避免它在没有真实任务时闲置。
if purchaseMode != string(model.TaskExecutionLive) {
query += ` AND execution_mode = 'dry_run'`
}
// 客户端只声明支持某些类型时,不要给它别的类型
if len(supportedTypes) > 0 {
@@ -101,23 +113,26 @@ func GetTask(db *sql.DB, taskID string) (*model.Task, error) {
var t model.Task
var assigned, claimedAt, sybID, orderNo, goodsID, skuID sql.NullString
var pddGoodsID, pddOptions, resultData, errCode, errMsg, finishedAt sql.NullString
var liveConfirmedBy, liveConfirmedAt sql.NullString
var quantity, maxPrice sql.NullInt64
err := db.QueryRow(`
SELECT task_id, task_type, status, version, priority,
SELECT task_id, task_type, status, execution_mode, version, priority,
assigned_client, claimed_at,
syb_id, order_no, goods_id, shopee_sku_id,
pdd_goods_url, pdd_goods_id, pdd_options,
quantity, max_price_cent,
result_data, error_code, error_message, finished_at,
live_confirmed_by, live_confirmed_at,
created_at, updated_at
FROM tasks WHERE task_id = ?`, taskID).Scan(
&t.TaskID, &t.TaskType, &t.Status, &t.Version, &t.Priority,
&t.TaskID, &t.TaskType, &t.Status, &t.ExecutionMode, &t.Version, &t.Priority,
&assigned, &claimedAt,
&sybID, &orderNo, &goodsID, &skuID,
&t.PddGoodsURL, &pddGoodsID, &pddOptions,
&quantity, &maxPrice,
&resultData, &errCode, &errMsg, &finishedAt,
&liveConfirmedBy, &liveConfirmedAt,
&t.CreatedAt, &t.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil
@@ -140,6 +155,8 @@ func GetTask(db *sql.DB, taskID string) (*model.Task, error) {
t.ErrorCode = errCode.String
t.ErrorMessage = errMsg.String
t.FinishedAt = finishedAt.String
t.LiveConfirmedBy = liveConfirmedBy.String
t.LiveConfirmedAt = liveConfirmedAt.String
return &t, nil
}
@@ -269,6 +286,7 @@ type TaskListRow struct {
TaskID string
TaskType model.TaskType
Status model.TaskStatus
ExecutionMode model.TaskExecutionMode
AssignedClient string // 空表示无主任务
OrderNo string
@@ -323,7 +341,7 @@ func taskFilterClause(filter TaskFilter) (string, []any) {
func ListTasks(q Execer, filter TaskFilter, limit, offset int) ([]TaskListRow, error) {
where, args := taskFilterClause(filter)
sqlText := `
SELECT t.task_id, t.task_type, t.status, t.assigned_client,
SELECT t.task_id, t.task_type, t.status, t.execution_mode, t.assigned_client,
t.order_no, t.pdd_goods_id, t.pdd_options, t.quantity, t.max_price_cent,
t.updated_at, p.title
FROM tasks t
@@ -345,7 +363,7 @@ func ListTasks(q Execer, filter TaskFilter, limit, offset int) ([]TaskListRow, e
var quantity, maxPrice sql.NullInt64
if err := rows.Scan(
&r.TaskID, &r.TaskType, &r.Status, &assigned,
&r.TaskID, &r.TaskType, &r.Status, &r.ExecutionMode, &assigned,
&orderNo, &pddGoodsID, &pddOptions, &quantity, &maxPrice,
&r.UpdatedAt, &title,
); err != nil {
@@ -465,17 +483,34 @@ func InsertPurchaseTask(q Execer, task model.Task) error {
task.Quantity <= 0 || task.MaxPriceCent <= 0 {
return fmt.Errorf("采购任务缺少客户端、商品、规格、数量或人民币价格上限")
}
if task.ExecutionMode == "" {
task.ExecutionMode = model.TaskExecutionDryRun
}
if task.ExecutionMode != model.TaskExecutionDryRun && task.ExecutionMode != model.TaskExecutionLive {
return fmt.Errorf("采购任务执行模式无效")
}
if task.ExecutionMode == model.TaskExecutionLive &&
(strings.TrimSpace(task.LiveConfirmedBy) == "" || strings.TrimSpace(task.LiveConfirmedAt) == "") {
return fmt.Errorf("真实采购任务缺少创建确认审计信息")
}
if task.ExecutionMode == model.TaskExecutionDryRun {
task.LiveConfirmedBy = ""
task.LiveConfirmedAt = ""
}
now := model.NowISO()
liveConfirmedBy := sql.NullString{String: task.LiveConfirmedBy, Valid: task.LiveConfirmedBy != ""}
liveConfirmedAt := sql.NullString{String: task.LiveConfirmedAt, Valid: task.LiveConfirmedAt != ""}
_, err := q.Exec(`
INSERT INTO tasks
(task_id, task_type, status, assigned_client,
(task_id, task_type, status, execution_mode, assigned_client,
syb_id, order_no, goods_id, shopee_sku_id,
pdd_goods_url, pdd_goods_id, pdd_options,
quantity, max_price_cent, created_at, updated_at)
VALUES (?, 'purchase', 'assigned', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
task.TaskID, task.AssignedClient, task.SybID, task.OrderNo,
quantity, max_price_cent, live_confirmed_by, live_confirmed_at,
created_at, updated_at)
VALUES (?, 'purchase', 'assigned', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
task.TaskID, task.ExecutionMode, task.AssignedClient, task.SybID, task.OrderNo,
task.GoodsID, task.ShopeeSKUID, task.PddGoodsURL, task.PddGoodsID,
task.PddOptions, task.Quantity, task.MaxPriceCent, now, now)
task.PddOptions, task.Quantity, task.MaxPriceCent, liveConfirmedBy, liveConfirmedAt, now, now)
if err != nil {
return fmt.Errorf("创建顺运宝明细 %s 的采购任务失败: %w", task.SybID, err)
}
+49
View File
@@ -0,0 +1,49 @@
package service
import (
"database/sql"
"testing"
"cmautobuy/admin/model"
)
func insertLiveTask(t *testing.T, db *sql.DB, taskID, clientID string) {
t.Helper()
now := model.NowISO()
_, err := db.Exec(`INSERT INTO tasks
(task_id,task_type,status,execution_mode,assigned_client,pdd_goods_url,
pdd_goods_id,pdd_options,quantity,max_price_cent,live_confirmed_by,
live_confirmed_at,created_at,updated_at)
VALUES (?,'purchase','assigned','live',?,'https://example.invalid/1','1',
'{"color":"黑色"}',1,100,'USER-1',?,?,?)`, taskID, clientID, now, now, now)
if err != nil {
t.Fatal(err)
}
}
func TestClaimNextTask_按客户端真实采购能力隔离(t *testing.T) {
db := newTestDB(t)
insertLiveTask(t, db, "TASK-LIVE", "CLIENT-1")
if task, err := ClaimNextTask(db, "CLIENT-1", []string{"purchase"}, "dry_run"); err != nil || task != nil {
t.Fatalf("dry_run 客户端不应看见 live 任务: task=%+v err=%v", task, err)
}
task, err := ClaimNextTask(db, "CLIENT-1", []string{"purchase"}, "live")
if err != nil || task == nil || task.ExecutionMode != model.TaskExecutionLive {
t.Fatalf("live 客户端应领到 live 任务: task=%+v err=%v", task, err)
}
}
func TestTaskExecutionMode_取消和重派不改变模式(t *testing.T) {
db := newTestDB(t)
insertLiveTask(t, db, "TASK-IMMUTABLE", "CLIENT-1")
if _, err := db.Exec(`UPDATE tasks SET status='cancelled',updated_at=? WHERE task_id='TASK-IMMUTABLE'`, model.NowISO()); err != nil {
t.Fatal(err)
}
if _, err := db.Exec(`UPDATE tasks SET status='assigned',assigned_client='CLIENT-2',updated_at=? WHERE task_id='TASK-IMMUTABLE'`, model.NowISO()); err != nil {
t.Fatal(err)
}
var mode string
if err := db.QueryRow(`SELECT execution_mode FROM tasks WHERE task_id='TASK-IMMUTABLE'`).Scan(&mode); err != nil || mode != "live" {
t.Fatalf("取消/重派后模式=%q err=%v", mode, err)
}
}
+46 -2
View File
@@ -161,20 +161,42 @@ type PurchaseTaskRequest struct {
MaxPriceCent int64
}
const LivePurchaseConfirmation = "创建未付款订单"
// PurchaseTaskOptions 是一次批量创建共用的执行门禁。
// ExecutionMode 留空表示 dry_run,确保旧调用和普通操作都保持安全默认值。
type PurchaseTaskOptions struct {
ClientID string
ExecutionMode model.TaskExecutionMode
LiveAcknowledged bool
LiveConfirmation string
}
// PurchaseTaskResult 同时返回已创建数量和每条无法创建的原因。
type PurchaseTaskResult struct {
Created int
Failures []TaskCreateError
}
// CreatePurchaseTasks 校验并创建采购任务。业务校验失败按明细返回,数据库错误整体回滚。
// CreatePurchaseTasks 保留旧调用入口;未传执行模式时必须安全地创建演练任务。
func CreatePurchaseTasks(db *sql.DB, actor *model.User, requests []PurchaseTaskRequest, clientID string) (PurchaseTaskResult, error) {
return CreatePurchaseTasksWithOptions(db, actor, requests, PurchaseTaskOptions{ClientID: clientID})
}
// CreatePurchaseTasksWithOptions 校验并创建采购任务。业务校验失败按明细返回,数据库错误整体回滚。
func CreatePurchaseTasksWithOptions(db *sql.DB, actor *model.User, requests []PurchaseTaskRequest, options PurchaseTaskOptions) (PurchaseTaskResult, error) {
var result PurchaseTaskResult
if actor == nil {
return result, ErrUnauthenticated
}
if actor.Status != model.UserActive {
return result, fmt.Errorf("当前账号不是正常状态,不能创建采购任务")
}
visibleUserID, err := visibleClientUserID(actor)
if err != nil {
return result, err
}
clientID = strings.TrimSpace(clientID)
clientID := strings.TrimSpace(options.ClientID)
if clientID == "" {
return result, fmt.Errorf("请选择执行采购任务的客户端")
}
@@ -190,6 +212,27 @@ func CreatePurchaseTasks(db *sql.DB, actor *model.User, requests []PurchaseTaskR
if !visible {
return result, fmt.Errorf("所选客户端不存在或不在当前账号可见范围")
}
executionMode := options.ExecutionMode
if executionMode == "" {
executionMode = model.TaskExecutionDryRun
}
if executionMode != model.TaskExecutionDryRun && executionMode != model.TaskExecutionLive {
return result, fmt.Errorf("执行模式无效,请选择采购演练或真实下单")
}
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
}
if purchaseMode != string(model.TaskExecutionLive) {
return result, fmt.Errorf("所选客户端未声明 live 能力,不能执行真实采购任务")
}
confirmedBy, confirmedAt = actor.UserID, model.NowISO()
}
seen := map[string]bool{}
for _, request := range requests {
@@ -211,6 +254,7 @@ func CreatePurchaseTasks(db *sql.DB, actor *model.User, requests []PurchaseTaskR
}
if err := repository.InsertPurchaseTask(tx, model.Task{
TaskID: newPurchaseTaskID(), AssignedClient: clientID,
ExecutionMode: executionMode, LiveConfirmedBy: confirmedBy, LiveConfirmedAt: confirmedAt,
SybID: context.Order.SybID, OrderNo: context.Order.OrderNo,
GoodsID: context.Order.ShopeeGoodsID,
PddGoodsURL: context.PddGoodsURL, PddGoodsID: context.PddGoodsID,
+57
View File
@@ -3,6 +3,7 @@ package service
import (
"database/sql"
"errors"
"strings"
"testing"
"time"
@@ -369,6 +370,62 @@ func TestCreatePurchaseTasks_部分业务失败不影响有效明细(t *testing.
}
}
func TestCreatePurchaseTasksWithOptions_正常采购员可创建真实任务(t *testing.T) {
db := newTestDB(t)
key := seedPurchasableWorkflow(t, db, "SYB-LIVE")
SaveSybMapping(db, "SYB-LIVE", key, "USR-1")
admin, buyer, _ := prepareClientAssignmentUsers(t, db)
if err := RegisterClient(db, model.Client{ClientID: "CLIENT-LIVE", Capabilities: `{"purchase_mode":"live"}`}, true); err != nil {
t.Fatal(err)
}
if _, _, err := AssignClient(db, admin, "CLIENT-LIVE", buyer.UserID, time.Now()); err != nil {
t.Fatal(err)
}
result, err := CreatePurchaseTasksWithOptions(db, buyer,
[]PurchaseTaskRequest{{SybID: "SYB-LIVE", MaxPriceCent: 4200}},
PurchaseTaskOptions{ClientID: "CLIENT-LIVE", ExecutionMode: model.TaskExecutionLive,
LiveAcknowledged: true, LiveConfirmation: LivePurchaseConfirmation})
if err != nil || result.Created != 1 {
t.Fatalf("正常采购员创建真实任务失败: result=%+v err=%v", result, err)
}
var mode, confirmedBy string
var confirmedAt sql.NullString
if err := db.QueryRow(`SELECT execution_mode,live_confirmed_by,live_confirmed_at FROM tasks WHERE syb_id='SYB-LIVE'`).Scan(&mode, &confirmedBy, &confirmedAt); err != nil {
t.Fatal(err)
}
if mode != "live" || confirmedBy != buyer.UserID || !confirmedAt.Valid {
t.Fatalf("真实任务审计不完整: mode=%s by=%s at=%+v", mode, confirmedBy, confirmedAt)
}
}
func TestCreatePurchaseTasksWithOptions_真实模式安全门禁(t *testing.T) {
db := newTestDB(t)
key := seedPurchasableWorkflow(t, db, "SYB-GATE")
SaveSybMapping(db, "SYB-GATE", key, "USR-1")
admin, _, _ := prepareClientAssignmentUsers(t, db)
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{
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(), "不是正常状态") {
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)
+7 -3
View File
@@ -92,6 +92,7 @@ func TouchClient(db *sql.DB, clientID string) error {
type ClientView struct {
model.Client
Status string
PurchaseMode string
AssignedUserID string
AssignedUsername string
}
@@ -136,6 +137,7 @@ func listClientViews(db *sql.DB, keyword, visibleUserID string, threshold time.D
views = append(views, ClientView{
Client: c.Client,
Status: c.StatusText(now, threshold),
PurchaseMode: repository.ParseClientPurchaseMode(c.Capabilities),
AssignedUserID: c.AssignedUserID,
AssignedUsername: c.AssignedUsername,
})
@@ -185,7 +187,9 @@ func ListClientPageForUser(db *sql.DB, actor *model.User, keyword string, thresh
views := make([]ClientView, 0, len(rows))
for _, client := range rows {
views = append(views, ClientView{
Client: client.Client, Status: client.StatusText(now, threshold),
Client: client.Client,
Status: client.StatusText(now, threshold),
PurchaseMode: repository.ParseClientPurchaseMode(client.Capabilities),
AssignedUserID: client.AssignedUserID, AssignedUsername: client.AssignedUsername,
})
}
@@ -252,6 +256,6 @@ func DeleteClients(db *sql.DB, clientIDs []string) (int64, error) {
// ClaimNextTask 为客户端领取一个任务,没有可领的返回 (nil, nil)。
//
// 调用方拿到 nil 要返回 204 No Content,**不是 200 加空对象**。
func ClaimNextTask(db *sql.DB, clientID string, supportedTypes []string) (*model.Task, error) {
return repository.ClaimNextTask(db, clientID, supportedTypes)
func ClaimNextTask(db *sql.DB, clientID string, supportedTypes []string, purchaseModes ...string) (*model.Task, error) {
return repository.ClaimNextTask(db, clientID, supportedTypes, purchaseModes...)
}
+42 -28
View File
@@ -58,6 +58,16 @@ func taskTypeText(t model.TaskType) string {
return string(t)
}
func taskExecutionModeText(taskType model.TaskType, mode model.TaskExecutionMode) string {
if taskType != model.TaskPurchase {
return "采集"
}
if mode == model.TaskExecutionLive {
return "真实下单(不支付)"
}
return "采购演练"
}
// ---------- 界面文字:状态 ----------
// taskStatusOrder 定下状态在筛选框和底部状态条里的显示顺序,
@@ -222,13 +232,14 @@ func priceLimitText(cent int64) string {
// TaskView 是列表页一行要显示的全部内容,全部已经是字符串。
type TaskView struct {
TaskID string
TypeText string
Target string
StatusText string
IsWarn bool // 失败 / 需人工,标黄提醒
ClientText string
UpdatedAt string
TaskID string
TypeText string
ExecutionModeText string
Target string
StatusText string
IsWarn bool // 失败 / 需人工,标黄提醒
ClientText string
UpdatedAt string
}
// TaskListResult 是列表页要的全部数据。
@@ -274,13 +285,14 @@ func ListTasksView(db *sql.DB, filter repository.TaskFilter, requestedPage int)
}
for _, r := range rows {
result.Rows = append(result.Rows, TaskView{
TaskID: r.TaskID,
TypeText: taskTypeText(r.TaskType),
Target: buildTarget(r),
StatusText: taskStatusText(r.Status),
IsWarn: isTaskWarn(r.Status),
ClientText: clientText(r.AssignedClient),
UpdatedAt: formatLocalTime(r.UpdatedAt),
TaskID: r.TaskID,
TypeText: taskTypeText(r.TaskType),
ExecutionModeText: taskExecutionModeText(r.TaskType, r.ExecutionMode),
Target: buildTarget(r),
StatusText: taskStatusText(r.Status),
IsWarn: isTaskWarn(r.Status),
ClientText: clientText(r.AssignedClient),
UpdatedAt: formatLocalTime(r.UpdatedAt),
})
}
return result, nil
@@ -316,12 +328,13 @@ const resultDataLimit = 4000
//
// `[必须]` 只读——本工单不做改派、重试、取消,弹窗里不应该有对应的表单。
type TaskDetailView struct {
TaskID string
TypeText string
StatusText string
ClientText string
ClaimedAt string
FinishedAt string
TaskID string
TypeText string
ExecutionModeText string
StatusText string
ClientText string
ClaimedAt string
FinishedAt string
PddGoodsURL string
@@ -350,14 +363,15 @@ func GetTaskDetail(db *sql.DB, taskID string) (*TaskDetailView, error) {
}
v := &TaskDetailView{
TaskID: t.TaskID,
TypeText: taskTypeText(t.TaskType),
StatusText: taskStatusText(t.Status),
ClientText: clientText(t.AssignedClient),
ClaimedAt: formatLocalTime(t.ClaimedAt),
FinishedAt: formatLocalTime(t.FinishedAt),
PddGoodsURL: t.PddGoodsURL,
IsPurchase: t.TaskType == model.TaskPurchase,
TaskID: t.TaskID,
TypeText: taskTypeText(t.TaskType),
ExecutionModeText: taskExecutionModeText(t.TaskType, t.ExecutionMode),
StatusText: taskStatusText(t.Status),
ClientText: clientText(t.AssignedClient),
ClaimedAt: formatLocalTime(t.ClaimedAt),
FinishedAt: formatLocalTime(t.FinishedAt),
PddGoodsURL: t.PddGoodsURL,
IsPurchase: t.TaskType == model.TaskPurchase,
}
if v.IsPurchase {
v.SpecText = specText(t.PddOptions)
+21
View File
@@ -274,6 +274,27 @@ tr.empty small { color: #aaa; }
border-radius: 6px;
}
.purchase-confirm-row label { display: grid; gap: 6px; }
.execution-mode-fieldset {
display: grid;
gap: 10px;
margin: 0;
padding: 12px;
border: 1px solid #d0d5dd;
border-radius: 6px;
}
.choice-row { display: flex; align-items: flex-start; gap: 8px; }
.choice-row span { display: grid; gap: 2px; }
.choice-row small { color: #667085; font-weight: 400; }
.live-confirm-panel {
display: grid;
gap: 10px;
padding: 12px;
border: 2px solid #b42318;
border-radius: 6px;
background: #fff5f3;
}
.live-confirm-panel[hidden] { display: none; }
.live-confirm-panel p { margin: 0; }
@media (max-width: 760px) {
.purchase-confirm-row { grid-template-columns: 1fr; }
}
+28
View File
@@ -140,6 +140,33 @@
var openButton = document.querySelector("[data-purchase-open]");
var form = document.querySelector("[data-purchase-form]");
if (!openButton || !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();
openButton.addEventListener("click", function () {
var selected = {};
document.querySelectorAll("tbody input[name=ids]:checked").forEach(function (box) {
@@ -156,6 +183,7 @@
});
var empty = form.querySelector("[data-purchase-empty]");
if (empty) empty.hidden = shown > 0;
updateExecutionMode();
});
}
+23 -1
View File
@@ -154,10 +154,32 @@
<label for="purchase-client">执行客户端</label>
<select id="purchase-client" name="client_id" required autofocus>
<option value="">请选择当前账号可见的客户端</option>
{{range .AssignableClients}}<option value="{{.ClientID}}">{{.Name}}({{.ClientID}},{{.Status}})</option>{{end}}
{{range .AssignableClients}}<option value="{{.ClientID}}" data-purchase-mode="{{.PurchaseMode}}">{{.Name}}({{.ClientID}},{{.Status}},{{if eq .PurchaseMode "live"}}支持真实下单{{else}}仅演练{{end}})</option>{{end}}
</select>
{{if not .AssignableClients}}<small class="missing">当前账号没有可用客户端,请先让管理员完成客户端绑定。</small>{{end}}
</div>
<fieldset class="execution-mode-fieldset">
<legend>执行模式</legend>
<label class="choice-row">
<input type="radio" name="execution_mode" value="dry_run" checked data-execution-mode>
<span><strong>采购演练</strong><small>只验证采购流程,不在拼多多创建订单。</small></span>
</label>
<label class="choice-row">
<input type="radio" name="execution_mode" value="live" data-execution-mode>
<span><strong>真实下单(不支付)</strong><small>会在拼多多创建真实的未付款订单。</small></span>
</label>
</fieldset>
<div class="live-confirm-panel" data-live-confirm-panel hidden>
<strong>真实下单安全确认</strong>
<p>这会产生真实未付款订单。必须选择声明 live 能力的客户端;系统不会自动支付。</p>
<label class="choice-row">
<input type="checkbox" name="live_acknowledged" value="1" data-live-acknowledged>
<span>我确认本次操作会创建真实未付款订单</span>
</label>
<label for="live-confirmation">输入“创建未付款订单”继续</label>
<input id="live-confirmation" name="live_confirmation" type="text"
autocomplete="off" data-live-confirmation>
</div>
<p class="hint">价格上限单位是人民币元,默认取已映射 PDD 规格的采集价;不会使用顺运宝的台币售价。</p>
{{range .Rows}}
<div class="purchase-confirm-row" data-purchase-row="{{.SybID}}" hidden>
+1
View File
@@ -14,6 +14,7 @@
<div class="modal-body">
<dl class="detail">
<dt>类型</dt><dd>{{.TypeText}}</dd>
<dt>执行模式</dt><dd>{{.ExecutionModeText}}</dd>
<dt>状态</dt><dd>{{.StatusText}}</dd>
<dt>分配客户端</dt><dd>{{.ClientText}}</dd>
<dt>领取时间</dt><dd>{{.ClaimedAt}}</dd>
+3 -1
View File
@@ -53,6 +53,7 @@
<th class="col-check"><input type="checkbox" data-check-all></th>
<th>任务编号</th>
<th>类型</th>
<th>执行模式</th>
<th>目标</th>
<th>状态</th>
<th>客户端</th>
@@ -69,6 +70,7 @@
<td>{{.TaskID}}</td>
{{/* 类型是中文文字,不能只靠颜色区分 */}}
<td>{{.TypeText}}</td>
<td>{{.ExecutionModeText}}</td>
<td class="truncate" title="{{.Target}}">{{.Target}}</td>
<td>{{.StatusText}}</td>
<td>{{.ClientText}}</td>
@@ -77,7 +79,7 @@
{{else}}
{{/* 空状态要分情况:从没建过任务 和 筛选没结果,下一步动作完全不同 */}}
<tr class="empty">
<td colspan="7">
<td colspan="8">
{{if .IsFiltered}}
当前筛选条件下没有任务。<br>
<small>换个类型、状态或关键词再试。<a href="/tasks">查看全部</a></small>
+7
View File
@@ -344,6 +344,13 @@ PDD 商品之所以单独一个模块,是因为它在数据上就是**独立
7. 所选客户端存在且在当前账号可见范围内;
8. 同一顺运宝明细没有 `pending` / `assigned` / `claimed` 的采购任务。
采购任务创建时必须选择不可变的执行模式:
- `dry_run`(采购演练)是默认值,历史任务也按此处理;
- `live` 会创建真实未付款订单,管理员和其他正常状态用户均可创建,但必须勾选风险确认并输入“创建未付款订单”;
- `live` 必须显式指定当前账号可见、且最后登记能力为 `purchase_mode=live` 的 Client;
- 创建时记录确认用户和确认时间。取消、重派和结果回传均不得修改执行模式。
第 5 条是硬要求:Client 契约规定采购任务必须带明确的价格保护
(见 [Client 契约](../client/04-admin-api-contract.md) §4),没有它 Client 会拒绝执行。
+15
View File
@@ -134,6 +134,10 @@ MySQL 迁移 v3(工单 #88)是追加式迁移:新增 `spec_mappings`,为
再转换能找到顺运宝商品与规格的映射。复跑、字段已建但约束未建等中断状态必须自动收敛;
启动自检除表名外还校验 v3 主键、长度、二进制排序规则和 `source` 取值约束。
MySQL 迁移 v5(工单 #98)为 `tasks` 追加 `execution_mode`、`live_confirmed_by`
和 `live_confirmed_at`,并增加模式/审计 CHECK 与领取索引。旧任务通过数据库默认值
统一成为 `dry_run`;每条 DDL 可重放,列、约束和索引全部通过自检后才记录 v5。
## 3. 蝦皮数据
蝦皮报表**一个文件里混了两层数据**,所以拆成两张表。
@@ -677,6 +681,17 @@ CREATE INDEX idx_tasks_list ON tasks(updated_at DESC, task_id DESC);
CREATE INDEX idx_tasks_order ON tasks(order_no);
```
生产 MySQL v5 在 `tasks` 追加以下安全字段(历史 SQLite 结构不改):
| 字段 | 含义 |
|---|---|
| `execution_mode` | `dry_run` / `live`,非空且默认 `dry_run`;创建后不可修改 |
| `live_confirmed_by` | 创建真实任务的正常状态 Admin 用户;演练任务必须为空 |
| `live_confirmed_at` | 真实任务阻断确认时间;演练任务必须为空 |
`chk_tasks_execution_mode` 和 `chk_tasks_live_confirmation` 在数据库层阻止非法模式及
缺少审计信息的真实任务;`idx_tasks_claim_mode` 支持按客户端能力领取。
`[必须]` 两条硬约束,来自 [Client 契约](../client/04-admin-api-contract.md) §4:
1. **`pdd_goods_url` 不能为空**,否则 Client 无法执行(它那边是 `NOT NULL`)。
+5
View File
@@ -92,6 +92,7 @@ UPDATE tasks
"task": {
"id": "PDD-20260806-0001",
"type": "purchase",
"execution_mode": "dry_run",
"version": 1,
"priority": 10,
"payload": {
@@ -109,6 +110,10 @@ UPDATE tasks
`[必须]` **响应里不含租约**,也不含 Admin 侧状态。Client 不关心这些。
`execution_mode` 只允许 `dry_run` / `live`,历史任务默认 `dry_run`。领取查询必须按
Client 上报的 `capabilities.purchase_mode` 过滤:`dry_run` Client 只能看到演练任务,
`live` Client 可以看到两种;过滤只决定能否领取,绝不改变任务自身模式。
`[必须]` `payload.goods_url` 必须有值;采购任务的 `quantity` 和 `max_price_cent` 必须有值。
这些在建任务时就该校验住(见 [01 需求](01-requirements.md) §5),
不要等到这里才发现发不出去。
+5 -4
View File
@@ -569,12 +569,12 @@ placeholder 写「任务编号 / 订单号 / 商品 ID」,**不要写全「PDD
### 7.2 表格列
☐ / 任务编号 / 类型 / **目标** / 状态 / 客户端 / 更新时间
☐ / 任务编号 / 类型 / **执行模式** / **目标** / 状态 / 客户端 / 更新时间
```text
☐ │ 任务编号 │ 类型 │ 目标 │ 状态 │ 客户端 │ 更新时间
☐ │ PDD-20260807-01 │ 采集 │ PDD 737116531267 │ 已领取 │ 办公室-01 │ 15:20
☐ │ PDD-20260807-02 │ 采购 │ SO-001 · 黑色/M · 2件 · ≤¥42.00 │ 待领取 │ — │ 15:22
☐ │ 任务编号 │ 类型 │ 执行模式 │ 目标 │ 状态 │ 客户端 │ 更新时间
☐ │ PDD-20260807-01 │ 采集 │ 采集 │ PDD 737116531267 │ 已领取 │ 办公室-01 │ 15:20
☐ │ PDD-20260807-02 │ 采购 │ 真实下单(不支付)│ SO-001 · 黑色/M · 2件 · ≤¥42.00 │ 待领取 │ 办公室-02 │ 15:22
```
- `[必须]` **目标列**:采集显示 `PDD <pdd_goods_id>`(能 join 到未删除商品
@@ -596,6 +596,7 @@ placeholder 写「任务编号 / 订单号 / 商品 ID」,**不要写全「PDD
│ 任务 PDD-20260807-01 │
├──────────────────────────────────────────────┤
│ 类型 采集 │
│ 执行模式 采集 │
│ 状态 已领取 │
│ 分配客户端 办公室-01 │
│ 领取时间 2026-08-07 15:20:31 │
+17 -1
View File
@@ -193,7 +193,17 @@ POST /api/v1/client/tasks/claim
```json
{
"task": {}
"task": {
"id": "COLLECT-20260810-0001",
"type": "collect",
"execution_mode": "dry_run",
"version": 1,
"priority": 10,
"payload": {
"goods_id": "737116531267",
"goods_url": "https://mobile.yangkeduo.com/goods.html?goods_id=737116531267"
}
}
}
```
@@ -222,6 +232,8 @@ Client 的 `HttpAdminGateway.claim_next` 已实现本接口,并原样序列化
- `[必须]` 领取必须在 Admin 内部原子完成,一次调用最多返回一个任务。
- `[必须]` Admin 不得向只声明 `dry_run` 的 Client 分配要求真实下单的任务。
- `[必须]` `execution_mode` 只允许 `dry_run`、`live`。历史任务和缺省值都是 `dry_run`;Client 不得自行把模式从演练提升为真实。
- `[必须]` 声明 `dry_run` 的 Client 只能领取 `dry_run`;声明 `live` 的 Client 可以领取两种模式。能力只缩小可领取范围,不修改任务模式。
- `[必须]` 响应**不包含**租约。Client 拿到任务就开始做,做完再来领下一个。
### 5.1 Admin 侧的分配语义
@@ -233,6 +245,10 @@ Client 的 `HttpAdminGateway.claim_next` 已实现本接口,并原样序列化
| **指定分配** | 某个 Client 编号 | `assigned` | 只有那个 Client |
| **无主** | 空 | `pending` | **谁先抢到算谁的**,领取时才记下领取者 |
领取还必须同时满足执行能力:`dry_run` Client 永远看不到 `live` 任务;
`live` Client 可以领取 `dry_run` 和 `live`。Admin 当前只允许真实采购任务显式指定
一个已经登记并声明 `purchase_mode: "live"` 的 Client,因此真实任务不会进入无主任务池。
`[必须]` **指定给本机的优先于无主的。** 显式分配是人为决定,应当先兑现;
无主任务谁抢都一样,可以等。