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
+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)