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. 领取一个任务,只会拿到分配给这个客户端的 // 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 { if err != nil {
log.Printf("task_claim_failed client_id=%s err=%v", clientID, err) log.Printf("task_claim_failed client_id=%s err=%v", clientID, err)
apiError(c, http.StatusInternalServerError, "TASK_CLAIM_FAILED", apiError(c, http.StatusInternalServerError, "TASK_CLAIM_FAILED",
@@ -171,13 +171,14 @@ func taskPayload(t *model.Task) gin.H {
} }
return gin.H{ return gin.H{
"id": t.TaskID, "id": t.TaskID,
"type": t.TaskType, "type": t.TaskType,
"version": t.Version, "execution_mode": t.ExecutionMode,
"priority": t.Priority, "version": t.Version,
"payload": payload, "priority": t.Priority,
"created_at": t.CreatedAt, "payload": payload,
"updated_at": t.UpdatedAt, "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}) 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 { if err != nil {
h.sybRedirect(c, "采购任务没有创建:"+err.Error()) h.sybRedirect(c, "采购任务没有创建:"+err.Error())
return return
+18 -5
View File
@@ -313,17 +313,27 @@ const (
TaskCancelled TaskStatus = "cancelled" // 已取消 TaskCancelled TaskStatus = "cancelled" // 已取消
) )
// TaskExecutionMode 区分只演练流程和会在拼多多创建未付款订单的真实流程。
// 模式在任务创建时确定,后续取消、重派和结果回传都不得修改。
type TaskExecutionMode string
const (
TaskExecutionDryRun TaskExecutionMode = "dry_run"
TaskExecutionLive TaskExecutionMode = "live"
)
// Task 是发给 Client 执行的一个任务。 // Task 是发给 Client 执行的一个任务。
// //
// PddGoodsURL 必填——Client 那边是 NOT NULL,空了它执行不了。 // PddGoodsURL 必填——Client 那边是 NOT NULL,空了它执行不了。
// 采购任务的 Quantity 和 MaxPriceCent 也必填,这是价格保护, // 采购任务的 Quantity 和 MaxPriceCent 也必填,这是价格保护,
// 见 docs/client/04-admin-api-contract.md §4。 // 见 docs/client/04-admin-api-contract.md §4。
type Task struct { type Task struct {
TaskID string TaskID string
TaskType TaskType TaskType TaskType
Status TaskStatus Status TaskStatus
Version int ExecutionMode TaskExecutionMode
Priority int Version int
Priority int
AssignedClient string AssignedClient string
ClaimedAt string ClaimedAt string
@@ -344,6 +354,9 @@ type Task struct {
ErrorMessage string ErrorMessage string
FinishedAt string FinishedAt string
LiveConfirmedBy string
LiveConfirmedAt string
CreatedAt string CreatedAt string
UpdatedAt string UpdatedAt string
} }
+24
View File
@@ -2,6 +2,7 @@ package repository
import ( import (
"database/sql" "database/sql"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
@@ -9,6 +10,29 @@ import (
"cmautobuy/admin/model" "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 ( var (
ErrClientNotFound = errors.New("客户端不存在") ErrClientNotFound = errors.New("客户端不存在")
ErrPurchaserNotActive = errors.New("采购员不存在或已禁用") ErrPurchaserNotActive = errors.New("采购员不存在或已禁用")
+136 -2
View File
@@ -19,7 +19,7 @@ import (
"cmautobuy/admin/spec" "cmautobuy/admin/spec"
) )
const mysqlSchemaVersion = 4 const mysqlSchemaVersion = 5
// OpenMySQL 打开生产 MySQL 8 数据库。错误信息绝不包含完整 DSN 或密码。 // OpenMySQL 打开生产 MySQL 8 数据库。错误信息绝不包含完整 DSN 或密码。
func OpenMySQL(cfg config.DatabaseConfig) (*sql.DB, error) { 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 { 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) 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) 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 { 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"} 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 { 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 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 { func backfillSybSpecKeys(db *sql.DB) error {
const batchSize = 500 const batchSize = 500
cursor := "" cursor := ""
@@ -729,7 +805,65 @@ func CheckMySQLSchema(db *sql.DB) error {
if err := checkMySQLV3Shape(db); err != nil { if err := checkMySQLV3Shape(db); err != nil {
return err 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 { 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 { func openMySQLMigrationTestDB(t *testing.T) *sql.DB {
t.Helper() t.Helper()
if os.Getenv("CMAUTOBUY_MYSQL_TEST") != "1" { 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')`) 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) { func mustExec(t *testing.T, db *sql.DB, query string, args ...any) {
t.Helper() t.Helper()
if _, err := db.Exec(query, args...); err != nil { if _, err := db.Exec(query, args...); err != nil {
+45 -10
View File
@@ -30,10 +30,17 @@ import (
// //
// InnoDB 事务用 FOR UPDATE SKIP LOCKED 锁住一条候选任务。领取状态和 // InnoDB 事务用 FOR UPDATE SKIP LOCKED 锁住一条候选任务。领取状态和
// task_claims 历史在同一个事务提交,避免只改了状态却没留下领取凭据。 // 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 == "" { if clientID == "" {
return nil, fmt.Errorf("client_id 不能为空") 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() ctx := context.Background()
tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}) tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
if err != nil { if err != nil {
@@ -45,6 +52,11 @@ func ClaimNextTask(db *sql.DB, clientID string, supportedTypes []string) (*model
WHERE ( (assigned_client = ? AND status = 'assigned') WHERE ( (assigned_client = ? AND status = 'assigned')
OR (assigned_client IS NULL AND status = 'pending') )` OR (assigned_client IS NULL AND status = 'pending') )`
args := []any{clientID} args := []any{clientID}
// dry_run 客户端永远看不到真实采购任务。声明 live 的客户端仍可执行
// 演练任务,避免它在没有真实任务时闲置。
if purchaseMode != string(model.TaskExecutionLive) {
query += ` AND execution_mode = 'dry_run'`
}
// 客户端只声明支持某些类型时,不要给它别的类型 // 客户端只声明支持某些类型时,不要给它别的类型
if len(supportedTypes) > 0 { if len(supportedTypes) > 0 {
@@ -101,23 +113,26 @@ func GetTask(db *sql.DB, taskID string) (*model.Task, error) {
var t model.Task var t model.Task
var assigned, claimedAt, sybID, orderNo, goodsID, skuID sql.NullString var assigned, claimedAt, sybID, orderNo, goodsID, skuID sql.NullString
var pddGoodsID, pddOptions, resultData, errCode, errMsg, finishedAt sql.NullString var pddGoodsID, pddOptions, resultData, errCode, errMsg, finishedAt sql.NullString
var liveConfirmedBy, liveConfirmedAt sql.NullString
var quantity, maxPrice sql.NullInt64 var quantity, maxPrice sql.NullInt64
err := db.QueryRow(` 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, assigned_client, claimed_at,
syb_id, order_no, goods_id, shopee_sku_id, syb_id, order_no, goods_id, shopee_sku_id,
pdd_goods_url, pdd_goods_id, pdd_options, pdd_goods_url, pdd_goods_id, pdd_options,
quantity, max_price_cent, quantity, max_price_cent,
result_data, error_code, error_message, finished_at, result_data, error_code, error_message, finished_at,
live_confirmed_by, live_confirmed_at,
created_at, updated_at created_at, updated_at
FROM tasks WHERE task_id = ?`, taskID).Scan( 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, &assigned, &claimedAt,
&sybID, &orderNo, &goodsID, &skuID, &sybID, &orderNo, &goodsID, &skuID,
&t.PddGoodsURL, &pddGoodsID, &pddOptions, &t.PddGoodsURL, &pddGoodsID, &pddOptions,
&quantity, &maxPrice, &quantity, &maxPrice,
&resultData, &errCode, &errMsg, &finishedAt, &resultData, &errCode, &errMsg, &finishedAt,
&liveConfirmedBy, &liveConfirmedAt,
&t.CreatedAt, &t.UpdatedAt) &t.CreatedAt, &t.UpdatedAt)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, nil return nil, nil
@@ -140,6 +155,8 @@ func GetTask(db *sql.DB, taskID string) (*model.Task, error) {
t.ErrorCode = errCode.String t.ErrorCode = errCode.String
t.ErrorMessage = errMsg.String t.ErrorMessage = errMsg.String
t.FinishedAt = finishedAt.String t.FinishedAt = finishedAt.String
t.LiveConfirmedBy = liveConfirmedBy.String
t.LiveConfirmedAt = liveConfirmedAt.String
return &t, nil return &t, nil
} }
@@ -269,6 +286,7 @@ type TaskListRow struct {
TaskID string TaskID string
TaskType model.TaskType TaskType model.TaskType
Status model.TaskStatus Status model.TaskStatus
ExecutionMode model.TaskExecutionMode
AssignedClient string // 空表示无主任务 AssignedClient string // 空表示无主任务
OrderNo string OrderNo string
@@ -323,7 +341,7 @@ func taskFilterClause(filter TaskFilter) (string, []any) {
func ListTasks(q Execer, filter TaskFilter, limit, offset int) ([]TaskListRow, error) { func ListTasks(q Execer, filter TaskFilter, limit, offset int) ([]TaskListRow, error) {
where, args := taskFilterClause(filter) where, args := taskFilterClause(filter)
sqlText := ` 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.order_no, t.pdd_goods_id, t.pdd_options, t.quantity, t.max_price_cent,
t.updated_at, p.title t.updated_at, p.title
FROM tasks t FROM tasks t
@@ -345,7 +363,7 @@ func ListTasks(q Execer, filter TaskFilter, limit, offset int) ([]TaskListRow, e
var quantity, maxPrice sql.NullInt64 var quantity, maxPrice sql.NullInt64
if err := rows.Scan( 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, &orderNo, &pddGoodsID, &pddOptions, &quantity, &maxPrice,
&r.UpdatedAt, &title, &r.UpdatedAt, &title,
); err != nil { ); err != nil {
@@ -465,17 +483,34 @@ func InsertPurchaseTask(q Execer, task model.Task) error {
task.Quantity <= 0 || task.MaxPriceCent <= 0 { task.Quantity <= 0 || task.MaxPriceCent <= 0 {
return fmt.Errorf("采购任务缺少客户端、商品、规格、数量或人民币价格上限") 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() now := model.NowISO()
liveConfirmedBy := sql.NullString{String: task.LiveConfirmedBy, Valid: task.LiveConfirmedBy != ""}
liveConfirmedAt := sql.NullString{String: task.LiveConfirmedAt, Valid: task.LiveConfirmedAt != ""}
_, err := q.Exec(` _, err := q.Exec(`
INSERT INTO tasks 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, syb_id, order_no, goods_id, shopee_sku_id,
pdd_goods_url, pdd_goods_id, pdd_options, pdd_goods_url, pdd_goods_id, pdd_options,
quantity, max_price_cent, created_at, updated_at) quantity, max_price_cent, live_confirmed_by, live_confirmed_at,
VALUES (?, 'purchase', 'assigned', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, created_at, updated_at)
task.TaskID, task.AssignedClient, task.SybID, task.OrderNo, VALUES (?, 'purchase', 'assigned', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
task.TaskID, task.ExecutionMode, task.AssignedClient, task.SybID, task.OrderNo,
task.GoodsID, task.ShopeeSKUID, task.PddGoodsURL, task.PddGoodsID, 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 { if err != nil {
return fmt.Errorf("创建顺运宝明细 %s 的采购任务失败: %w", task.SybID, err) 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 MaxPriceCent int64
} }
const LivePurchaseConfirmation = "创建未付款订单"
// PurchaseTaskOptions 是一次批量创建共用的执行门禁。
// ExecutionMode 留空表示 dry_run,确保旧调用和普通操作都保持安全默认值。
type PurchaseTaskOptions struct {
ClientID string
ExecutionMode model.TaskExecutionMode
LiveAcknowledged bool
LiveConfirmation string
}
// PurchaseTaskResult 同时返回已创建数量和每条无法创建的原因。 // PurchaseTaskResult 同时返回已创建数量和每条无法创建的原因。
type PurchaseTaskResult struct { type PurchaseTaskResult struct {
Created int Created int
Failures []TaskCreateError Failures []TaskCreateError
} }
// CreatePurchaseTasks 校验并创建采购任务。业务校验失败按明细返回,数据库错误整体回滚。 // CreatePurchaseTasks 保留旧调用入口;未传执行模式时必须安全地创建演练任务。
func CreatePurchaseTasks(db *sql.DB, actor *model.User, requests []PurchaseTaskRequest, clientID string) (PurchaseTaskResult, error) { 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 var result PurchaseTaskResult
if actor == nil {
return result, ErrUnauthenticated
}
if actor.Status != model.UserActive {
return result, fmt.Errorf("当前账号不是正常状态,不能创建采购任务")
}
visibleUserID, err := visibleClientUserID(actor) visibleUserID, err := visibleClientUserID(actor)
if err != nil { if err != nil {
return result, err return result, err
} }
clientID = strings.TrimSpace(clientID) clientID := strings.TrimSpace(options.ClientID)
if clientID == "" { if clientID == "" {
return result, fmt.Errorf("请选择执行采购任务的客户端") return result, fmt.Errorf("请选择执行采购任务的客户端")
} }
@@ -190,6 +212,27 @@ func CreatePurchaseTasks(db *sql.DB, actor *model.User, requests []PurchaseTaskR
if !visible { if !visible {
return result, fmt.Errorf("所选客户端不存在或不在当前账号可见范围") 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{} seen := map[string]bool{}
for _, request := range requests { 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{ if err := repository.InsertPurchaseTask(tx, model.Task{
TaskID: newPurchaseTaskID(), AssignedClient: clientID, TaskID: newPurchaseTaskID(), AssignedClient: clientID,
ExecutionMode: executionMode, LiveConfirmedBy: confirmedBy, LiveConfirmedAt: confirmedAt,
SybID: context.Order.SybID, OrderNo: context.Order.OrderNo, SybID: context.Order.SybID, OrderNo: context.Order.OrderNo,
GoodsID: context.Order.ShopeeGoodsID, GoodsID: context.Order.ShopeeGoodsID,
PddGoodsURL: context.PddGoodsURL, PddGoodsID: context.PddGoodsID, PddGoodsURL: context.PddGoodsURL, PddGoodsID: context.PddGoodsID,
+57
View File
@@ -3,6 +3,7 @@ package service
import ( import (
"database/sql" "database/sql"
"errors" "errors"
"strings"
"testing" "testing"
"time" "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) { func TestParsePriceYuanToCent_精确转换(t *testing.T) {
for raw, want := range map[string]int64{"39.90": 3990, "1": 100, "0.01": 1, "12.3": 1230} { for raw, want := range map[string]int64{"39.90": 3990, "1": 100, "0.01": 1, "12.3": 1230} {
got, err := ParsePriceYuanToCent(raw) got, err := ParsePriceYuanToCent(raw)
+7 -3
View File
@@ -92,6 +92,7 @@ func TouchClient(db *sql.DB, clientID string) error {
type ClientView struct { type ClientView struct {
model.Client model.Client
Status string Status string
PurchaseMode string
AssignedUserID string AssignedUserID string
AssignedUsername string AssignedUsername string
} }
@@ -136,6 +137,7 @@ func listClientViews(db *sql.DB, keyword, visibleUserID string, threshold time.D
views = append(views, ClientView{ views = append(views, ClientView{
Client: c.Client, Client: c.Client,
Status: c.StatusText(now, threshold), Status: c.StatusText(now, threshold),
PurchaseMode: repository.ParseClientPurchaseMode(c.Capabilities),
AssignedUserID: c.AssignedUserID, AssignedUserID: c.AssignedUserID,
AssignedUsername: c.AssignedUsername, AssignedUsername: c.AssignedUsername,
}) })
@@ -185,7 +187,9 @@ func ListClientPageForUser(db *sql.DB, actor *model.User, keyword string, thresh
views := make([]ClientView, 0, len(rows)) views := make([]ClientView, 0, len(rows))
for _, client := range rows { for _, client := range rows {
views = append(views, ClientView{ 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, AssignedUserID: client.AssignedUserID, AssignedUsername: client.AssignedUsername,
}) })
} }
@@ -252,6 +256,6 @@ func DeleteClients(db *sql.DB, clientIDs []string) (int64, error) {
// ClaimNextTask 为客户端领取一个任务,没有可领的返回 (nil, nil)。 // ClaimNextTask 为客户端领取一个任务,没有可领的返回 (nil, nil)。
// //
// 调用方拿到 nil 要返回 204 No Content,**不是 200 加空对象**。 // 调用方拿到 nil 要返回 204 No Content,**不是 200 加空对象**。
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) {
return repository.ClaimNextTask(db, clientID, supportedTypes) return repository.ClaimNextTask(db, clientID, supportedTypes, purchaseModes...)
} }
+42 -28
View File
@@ -58,6 +58,16 @@ func taskTypeText(t model.TaskType) string {
return string(t) return string(t)
} }
func taskExecutionModeText(taskType model.TaskType, mode model.TaskExecutionMode) string {
if taskType != model.TaskPurchase {
return "采集"
}
if mode == model.TaskExecutionLive {
return "真实下单(不支付)"
}
return "采购演练"
}
// ---------- 界面文字:状态 ---------- // ---------- 界面文字:状态 ----------
// taskStatusOrder 定下状态在筛选框和底部状态条里的显示顺序, // taskStatusOrder 定下状态在筛选框和底部状态条里的显示顺序,
@@ -222,13 +232,14 @@ func priceLimitText(cent int64) string {
// TaskView 是列表页一行要显示的全部内容,全部已经是字符串。 // TaskView 是列表页一行要显示的全部内容,全部已经是字符串。
type TaskView struct { type TaskView struct {
TaskID string TaskID string
TypeText string TypeText string
Target string ExecutionModeText string
StatusText string Target string
IsWarn bool // 失败 / 需人工,标黄提醒 StatusText string
ClientText string IsWarn bool // 失败 / 需人工,标黄提醒
UpdatedAt string ClientText string
UpdatedAt string
} }
// TaskListResult 是列表页要的全部数据。 // TaskListResult 是列表页要的全部数据。
@@ -274,13 +285,14 @@ func ListTasksView(db *sql.DB, filter repository.TaskFilter, requestedPage int)
} }
for _, r := range rows { for _, r := range rows {
result.Rows = append(result.Rows, TaskView{ result.Rows = append(result.Rows, TaskView{
TaskID: r.TaskID, TaskID: r.TaskID,
TypeText: taskTypeText(r.TaskType), TypeText: taskTypeText(r.TaskType),
Target: buildTarget(r), ExecutionModeText: taskExecutionModeText(r.TaskType, r.ExecutionMode),
StatusText: taskStatusText(r.Status), Target: buildTarget(r),
IsWarn: isTaskWarn(r.Status), StatusText: taskStatusText(r.Status),
ClientText: clientText(r.AssignedClient), IsWarn: isTaskWarn(r.Status),
UpdatedAt: formatLocalTime(r.UpdatedAt), ClientText: clientText(r.AssignedClient),
UpdatedAt: formatLocalTime(r.UpdatedAt),
}) })
} }
return result, nil return result, nil
@@ -316,12 +328,13 @@ const resultDataLimit = 4000
// //
// `[必须]` 只读——本工单不做改派、重试、取消,弹窗里不应该有对应的表单。 // `[必须]` 只读——本工单不做改派、重试、取消,弹窗里不应该有对应的表单。
type TaskDetailView struct { type TaskDetailView struct {
TaskID string TaskID string
TypeText string TypeText string
StatusText string ExecutionModeText string
ClientText string StatusText string
ClaimedAt string ClientText string
FinishedAt string ClaimedAt string
FinishedAt string
PddGoodsURL string PddGoodsURL string
@@ -350,14 +363,15 @@ func GetTaskDetail(db *sql.DB, taskID string) (*TaskDetailView, error) {
} }
v := &TaskDetailView{ v := &TaskDetailView{
TaskID: t.TaskID, TaskID: t.TaskID,
TypeText: taskTypeText(t.TaskType), TypeText: taskTypeText(t.TaskType),
StatusText: taskStatusText(t.Status), ExecutionModeText: taskExecutionModeText(t.TaskType, t.ExecutionMode),
ClientText: clientText(t.AssignedClient), StatusText: taskStatusText(t.Status),
ClaimedAt: formatLocalTime(t.ClaimedAt), ClientText: clientText(t.AssignedClient),
FinishedAt: formatLocalTime(t.FinishedAt), ClaimedAt: formatLocalTime(t.ClaimedAt),
PddGoodsURL: t.PddGoodsURL, FinishedAt: formatLocalTime(t.FinishedAt),
IsPurchase: t.TaskType == model.TaskPurchase, PddGoodsURL: t.PddGoodsURL,
IsPurchase: t.TaskType == model.TaskPurchase,
} }
if v.IsPurchase { if v.IsPurchase {
v.SpecText = specText(t.PddOptions) v.SpecText = specText(t.PddOptions)
+21
View File
@@ -274,6 +274,27 @@ tr.empty small { color: #aaa; }
border-radius: 6px; border-radius: 6px;
} }
.purchase-confirm-row label { display: grid; gap: 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) { @media (max-width: 760px) {
.purchase-confirm-row { grid-template-columns: 1fr; } .purchase-confirm-row { grid-template-columns: 1fr; }
} }
+28
View File
@@ -140,6 +140,33 @@
var openButton = document.querySelector("[data-purchase-open]"); var openButton = document.querySelector("[data-purchase-open]");
var form = document.querySelector("[data-purchase-form]"); var form = document.querySelector("[data-purchase-form]");
if (!openButton || !form) return; 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 () { openButton.addEventListener("click", function () {
var selected = {}; var selected = {};
document.querySelectorAll("tbody input[name=ids]:checked").forEach(function (box) { document.querySelectorAll("tbody input[name=ids]:checked").forEach(function (box) {
@@ -156,6 +183,7 @@
}); });
var empty = form.querySelector("[data-purchase-empty]"); var empty = form.querySelector("[data-purchase-empty]");
if (empty) empty.hidden = shown > 0; if (empty) empty.hidden = shown > 0;
updateExecutionMode();
}); });
} }
+23 -1
View File
@@ -154,10 +154,32 @@
<label for="purchase-client">执行客户端</label> <label for="purchase-client">执行客户端</label>
<select id="purchase-client" name="client_id" required autofocus> <select id="purchase-client" name="client_id" required autofocus>
<option value="">请选择当前账号可见的客户端</option> <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> </select>
{{if not .AssignableClients}}<small class="missing">当前账号没有可用客户端,请先让管理员完成客户端绑定。</small>{{end}} {{if not .AssignableClients}}<small class="missing">当前账号没有可用客户端,请先让管理员完成客户端绑定。</small>{{end}}
</div> </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> <p class="hint">价格上限单位是人民币元,默认取已映射 PDD 规格的采集价;不会使用顺运宝的台币售价。</p>
{{range .Rows}} {{range .Rows}}
<div class="purchase-confirm-row" data-purchase-row="{{.SybID}}" hidden> <div class="purchase-confirm-row" data-purchase-row="{{.SybID}}" hidden>
+1
View File
@@ -14,6 +14,7 @@
<div class="modal-body"> <div class="modal-body">
<dl class="detail"> <dl class="detail">
<dt>类型</dt><dd>{{.TypeText}}</dd> <dt>类型</dt><dd>{{.TypeText}}</dd>
<dt>执行模式</dt><dd>{{.ExecutionModeText}}</dd>
<dt>状态</dt><dd>{{.StatusText}}</dd> <dt>状态</dt><dd>{{.StatusText}}</dd>
<dt>分配客户端</dt><dd>{{.ClientText}}</dd> <dt>分配客户端</dt><dd>{{.ClientText}}</dd>
<dt>领取时间</dt><dd>{{.ClaimedAt}}</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 class="col-check"><input type="checkbox" data-check-all></th>
<th>任务编号</th> <th>任务编号</th>
<th>类型</th> <th>类型</th>
<th>执行模式</th>
<th>目标</th> <th>目标</th>
<th>状态</th> <th>状态</th>
<th>客户端</th> <th>客户端</th>
@@ -69,6 +70,7 @@
<td>{{.TaskID}}</td> <td>{{.TaskID}}</td>
{{/* 类型是中文文字,不能只靠颜色区分 */}} {{/* 类型是中文文字,不能只靠颜色区分 */}}
<td>{{.TypeText}}</td> <td>{{.TypeText}}</td>
<td>{{.ExecutionModeText}}</td>
<td class="truncate" title="{{.Target}}">{{.Target}}</td> <td class="truncate" title="{{.Target}}">{{.Target}}</td>
<td>{{.StatusText}}</td> <td>{{.StatusText}}</td>
<td>{{.ClientText}}</td> <td>{{.ClientText}}</td>
@@ -77,7 +79,7 @@
{{else}} {{else}}
{{/* 空状态要分情况:从没建过任务 和 筛选没结果,下一步动作完全不同 */}} {{/* 空状态要分情况:从没建过任务 和 筛选没结果,下一步动作完全不同 */}}
<tr class="empty"> <tr class="empty">
<td colspan="7"> <td colspan="8">
{{if .IsFiltered}} {{if .IsFiltered}}
当前筛选条件下没有任务。<br> 当前筛选条件下没有任务。<br>
<small>换个类型、状态或关键词再试。<a href="/tasks">查看全部</a></small> <small>换个类型、状态或关键词再试。<a href="/tasks">查看全部</a></small>
+7
View File
@@ -344,6 +344,13 @@ PDD 商品之所以单独一个模块,是因为它在数据上就是**独立
7. 所选客户端存在且在当前账号可见范围内; 7. 所选客户端存在且在当前账号可见范围内;
8. 同一顺运宝明细没有 `pending` / `assigned` / `claimed` 的采购任务。 8. 同一顺运宝明细没有 `pending` / `assigned` / `claimed` 的采购任务。
采购任务创建时必须选择不可变的执行模式:
- `dry_run`(采购演练)是默认值,历史任务也按此处理;
- `live` 会创建真实未付款订单,管理员和其他正常状态用户均可创建,但必须勾选风险确认并输入“创建未付款订单”;
- `live` 必须显式指定当前账号可见、且最后登记能力为 `purchase_mode=live` 的 Client;
- 创建时记录确认用户和确认时间。取消、重派和结果回传均不得修改执行模式。
第 5 条是硬要求:Client 契约规定采购任务必须带明确的价格保护 第 5 条是硬要求:Client 契约规定采购任务必须带明确的价格保护
(见 [Client 契约](../client/04-admin-api-contract.md) §4),没有它 Client 会拒绝执行。 (见 [Client 契约](../client/04-admin-api-contract.md) §4),没有它 Client 会拒绝执行。
+15
View File
@@ -134,6 +134,10 @@ MySQL 迁移 v3(工单 #88)是追加式迁移:新增 `spec_mappings`,为
再转换能找到顺运宝商品与规格的映射。复跑、字段已建但约束未建等中断状态必须自动收敛; 再转换能找到顺运宝商品与规格的映射。复跑、字段已建但约束未建等中断状态必须自动收敛;
启动自检除表名外还校验 v3 主键、长度、二进制排序规则和 `source` 取值约束。 启动自检除表名外还校验 v3 主键、长度、二进制排序规则和 `source` 取值约束。
MySQL 迁移 v5(工单 #98)为 `tasks` 追加 `execution_mode`、`live_confirmed_by`
和 `live_confirmed_at`,并增加模式/审计 CHECK 与领取索引。旧任务通过数据库默认值
统一成为 `dry_run`;每条 DDL 可重放,列、约束和索引全部通过自检后才记录 v5。
## 3. 蝦皮数据 ## 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); 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: `[必须]` 两条硬约束,来自 [Client 契约](../client/04-admin-api-contract.md) §4:
1. **`pdd_goods_url` 不能为空**,否则 Client 无法执行(它那边是 `NOT NULL`)。 1. **`pdd_goods_url` 不能为空**,否则 Client 无法执行(它那边是 `NOT NULL`)。
+5
View File
@@ -92,6 +92,7 @@ UPDATE tasks
"task": { "task": {
"id": "PDD-20260806-0001", "id": "PDD-20260806-0001",
"type": "purchase", "type": "purchase",
"execution_mode": "dry_run",
"version": 1, "version": 1,
"priority": 10, "priority": 10,
"payload": { "payload": {
@@ -109,6 +110,10 @@ UPDATE tasks
`[必须]` **响应里不含租约**,也不含 Admin 侧状态。Client 不关心这些。 `[必须]` **响应里不含租约**,也不含 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` 必须有值。 `[必须]` `payload.goods_url` 必须有值;采购任务的 `quantity` 和 `max_price_cent` 必须有值。
这些在建任务时就该校验住(见 [01 需求](01-requirements.md) §5), 这些在建任务时就该校验住(见 [01 需求](01-requirements.md) §5),
不要等到这里才发现发不出去。 不要等到这里才发现发不出去。
+5 -4
View File
@@ -569,12 +569,12 @@ placeholder 写「任务编号 / 订单号 / 商品 ID」,**不要写全「PDD
### 7.2 表格列 ### 7.2 表格列
☐ / 任务编号 / 类型 / **目标** / 状态 / 客户端 / 更新时间 ☐ / 任务编号 / 类型 / **执行模式** / **目标** / 状态 / 客户端 / 更新时间
```text ```text
☐ │ 任务编号 │ 类型 │ 目标 │ 状态 │ 客户端 │ 更新时间 ☐ │ 任务编号 │ 类型 │ 执行模式 │ 目标 │ 状态 │ 客户端 │ 更新时间
☐ │ PDD-20260807-01 │ 采集 │ PDD 737116531267 │ 已领取 │ 办公室-01 │ 15:20 ☐ │ PDD-20260807-01 │ 采集 │ 采集 │ PDD 737116531267 │ 已领取 │ 办公室-01 │ 15:20
☐ │ PDD-20260807-02 │ 采购 │ SO-001 · 黑色/M · 2件 · ≤¥42.00 │ 待领取 │ — │ 15:22 ☐ │ PDD-20260807-02 │ 采购 │ 真实下单(不支付)│ SO-001 · 黑色/M · 2件 · ≤¥42.00 │ 待领取 │ 办公室-02 │ 15:22
``` ```
- `[必须]` **目标列**:采集显示 `PDD <pdd_goods_id>`(能 join 到未删除商品 - `[必须]` **目标列**:采集显示 `PDD <pdd_goods_id>`(能 join 到未删除商品
@@ -596,6 +596,7 @@ placeholder 写「任务编号 / 订单号 / 商品 ID」,**不要写全「PDD
│ 任务 PDD-20260807-01 │ │ 任务 PDD-20260807-01 │
├──────────────────────────────────────────────┤ ├──────────────────────────────────────────────┤
│ 类型 采集 │ │ 类型 采集 │
│ 执行模式 采集 │
│ 状态 已领取 │ │ 状态 已领取 │
│ 分配客户端 办公室-01 │ │ 分配客户端 办公室-01 │
│ 领取时间 2026-08-07 15:20:31 │ │ 领取时间 2026-08-07 15:20:31 │
+17 -1
View File
@@ -193,7 +193,17 @@ POST /api/v1/client/tasks/claim
```json ```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 内部原子完成,一次调用最多返回一个任务。
- `[必须]` Admin 不得向只声明 `dry_run` 的 Client 分配要求真实下单的任务。 - `[必须]` Admin 不得向只声明 `dry_run` 的 Client 分配要求真实下单的任务。
- `[必须]` `execution_mode` 只允许 `dry_run`、`live`。历史任务和缺省值都是 `dry_run`;Client 不得自行把模式从演练提升为真实。
- `[必须]` 声明 `dry_run` 的 Client 只能领取 `dry_run`;声明 `live` 的 Client 可以领取两种模式。能力只缩小可领取范围,不修改任务模式。
- `[必须]` 响应**不包含**租约。Client 拿到任务就开始做,做完再来领下一个。 - `[必须]` 响应**不包含**租约。Client 拿到任务就开始做,做完再来领下一个。
### 5.1 Admin 侧的分配语义 ### 5.1 Admin 侧的分配语义
@@ -233,6 +245,10 @@ Client 的 `HttpAdminGateway.claim_next` 已实现本接口,并原样序列化
| **指定分配** | 某个 Client 编号 | `assigned` | 只有那个 Client | | **指定分配** | 某个 Client 编号 | `assigned` | 只有那个 Client |
| **无主** | 空 | `pending` | **谁先抢到算谁的**,领取时才记下领取者 | | **无主** | 空 | `pending` | **谁先抢到算谁的**,领取时才记下领取者 |
领取还必须同时满足执行能力:`dry_run` Client 永远看不到 `live` 任务;
`live` Client 可以领取 `dry_run` 和 `live`。Admin 当前只允许真实采购任务显式指定
一个已经登记并声明 `purchase_mode: "live"` 的 Client,因此真实任务不会进入无主任务池。
`[必须]` **指定给本机的优先于无主的。** 显式分配是人为决定,应当先兑现; `[必须]` **指定给本机的优先于无主的。** 显式分配是人为决定,应当先兑现;
无主任务谁抢都一样,可以等。 无主任务谁抢都一样,可以等。