feat: 增加真实采购任务安全模式 (#98)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user