feat: 按创建人隔离采集采购任务 (#127)
This commit is contained in:
@@ -576,17 +576,17 @@ func (r CollectTaskResult) Skipped() int {
|
||||
// 返回的跳过分类必须显示给操作员。静默跳过的话,
|
||||
// 操作员会以为任务建好了,等半天没动静也不知道为什么。
|
||||
func CreatePddCollectTasks(db *sql.DB, goodsIDs []string) (CollectTaskResult, error) {
|
||||
return createPddCollectTasks(db, goodsIDs, "", "")
|
||||
return createPddCollectTasks(db, goodsIDs, "", "", "")
|
||||
}
|
||||
|
||||
// CreatePddCollectTasksForUser 为 PDD 批量页面创建可选客户端的采集任务。
|
||||
// 即使浏览器伪造 clientID,也必须重新按当前登录账号校验可见范围。
|
||||
func CreatePddCollectTasksForUser(db *sql.DB, actor *model.User, goodsIDs []string, clientID string) (CollectTaskResult, error) {
|
||||
visibleUserID, err := visibleClientUserID(actor)
|
||||
visibleUserID, err := taskActorScope(actor)
|
||||
if err != nil {
|
||||
return CollectTaskResult{}, err
|
||||
}
|
||||
return createPddCollectTasks(db, goodsIDs, strings.TrimSpace(clientID), visibleUserID)
|
||||
return createPddCollectTasks(db, goodsIDs, strings.TrimSpace(clientID), visibleUserID, actor.UserID)
|
||||
}
|
||||
|
||||
// RecollectPddProductForUser 为详情页一次明确的重新采集创建单商品任务。
|
||||
@@ -600,7 +600,7 @@ func RecollectPddProductForUser(db *sql.DB, actor *model.User, goodsID, clientID
|
||||
return result, invalidInput("商品 ID 不能为空")
|
||||
}
|
||||
|
||||
visibleUserID, err := visibleClientUserID(actor)
|
||||
visibleUserID, err := taskActorScope(actor)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
@@ -647,8 +647,8 @@ func RecollectPddProductForUser(db *sql.DB, actor *model.User, goodsID, clientID
|
||||
return result, nil
|
||||
}
|
||||
|
||||
if err := repository.InsertCollectTaskForClient(
|
||||
tx, newCollectTaskID(), p.GoodsID, p.URL, clientID); err != nil {
|
||||
if err := repository.InsertCollectTaskForClientAndUser(
|
||||
tx, newCollectTaskID(), p.GoodsID, p.URL, clientID, actor.UserID); err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.Created = 1
|
||||
@@ -658,7 +658,7 @@ func RecollectPddProductForUser(db *sql.DB, actor *model.User, goodsID, clientID
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func createPddCollectTasks(db *sql.DB, goodsIDs []string, clientID, visibleUserID string) (CollectTaskResult, error) {
|
||||
func createPddCollectTasks(db *sql.DB, goodsIDs []string, clientID, visibleUserID, createdByUserID string) (CollectTaskResult, error) {
|
||||
var result CollectTaskResult
|
||||
|
||||
goodsIDs = dedupe(goodsIDs)
|
||||
@@ -725,8 +725,8 @@ func createPddCollectTasks(db *sql.DB, goodsIDs []string, clientID, visibleUserI
|
||||
continue
|
||||
}
|
||||
|
||||
if err := repository.InsertCollectTaskForClient(
|
||||
tx, newCollectTaskID(), p.GoodsID, p.URL, clientID); err != nil {
|
||||
if err := repository.InsertCollectTaskForClientAndUser(
|
||||
tx, newCollectTaskID(), p.GoodsID, p.URL, clientID, createdByUserID); err != nil {
|
||||
return CollectTaskResult{}, err
|
||||
}
|
||||
result.Created++
|
||||
|
||||
@@ -666,6 +666,10 @@ func TestCreatePddCollectTasksForUser_指定客户端时只等待该客户端领
|
||||
if !ok || status != "assigned" || assigned != "client-chosen" {
|
||||
t.Fatalf("任务初始状态或客户端不对: status=%q assigned=%q ok=%t", status, assigned, ok)
|
||||
}
|
||||
var creator string
|
||||
if err := db.QueryRow(`SELECT created_by_user_id FROM tasks WHERE pdd_goods_id='737116531267'`).Scan(&creator); err != nil || creator != admin.UserID {
|
||||
t.Fatalf("采集任务创建人=%q err=%v,期望 %q", creator, err, admin.UserID)
|
||||
}
|
||||
otherTask, err := ClaimNextTask(db, "client-other", []string{"collect"})
|
||||
if err != nil || otherTask != nil {
|
||||
t.Fatalf("其他客户端不应领到指定任务: task=%+v err=%v", otherTask, err)
|
||||
|
||||
@@ -190,7 +190,7 @@ func CreatePurchaseTasksWithOptions(db *sql.DB, actor *model.User, requests []Pu
|
||||
if actor.Status != model.UserActive {
|
||||
return result, fmt.Errorf("当前账号不是正常状态,不能创建采购任务")
|
||||
}
|
||||
visibleUserID, err := visibleClientUserID(actor)
|
||||
visibleUserID, err := taskActorScope(actor)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
@@ -242,7 +242,8 @@ func CreatePurchaseTasksWithOptions(db *sql.DB, actor *model.User, requests []Pu
|
||||
}
|
||||
if err := repository.InsertPurchaseTask(tx, model.Task{
|
||||
TaskID: newPurchaseTaskID(), AssignedClient: clientID,
|
||||
ExecutionMode: executionMode, LiveConfirmedBy: confirmedBy, LiveConfirmedAt: confirmedAt,
|
||||
CreatedByUserID: actor.UserID,
|
||||
ExecutionMode: executionMode, LiveConfirmedBy: confirmedBy, LiveConfirmedAt: confirmedAt,
|
||||
SybID: context.Order.SybID, OrderNo: context.Order.OrderNo,
|
||||
GoodsID: context.Order.ShopeeGoodsID,
|
||||
PddGoodsURL: context.PddGoodsURL, PddGoodsID: context.PddGoodsID,
|
||||
|
||||
@@ -393,14 +393,17 @@ func TestCreatePurchaseTasksWithOptions_正常采购员可创建真实任务(t *
|
||||
if err != nil || result.Created != 1 {
|
||||
t.Fatalf("正常采购员创建真实任务失败: result=%+v err=%v", result, err)
|
||||
}
|
||||
var mode, confirmedBy string
|
||||
var mode, confirmedBy, createdBy 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 {
|
||||
if err := db.QueryRow(`SELECT execution_mode,live_confirmed_by,live_confirmed_at,created_by_user_id FROM tasks WHERE syb_id='SYB-LIVE'`).Scan(&mode, &confirmedBy, &confirmedAt, &createdBy); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mode != "live" || confirmedBy != buyer.UserID || !confirmedAt.Valid {
|
||||
t.Fatalf("真实任务审计不完整: mode=%s by=%s at=%+v", mode, confirmedBy, confirmedAt)
|
||||
}
|
||||
if createdBy != buyer.UserID {
|
||||
t.Fatalf("采购任务创建人=%q,期望 %q", createdBy, buyer.UserID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePurchaseTasksWithOptions_真实模式不要求Client预先声明Live(t *testing.T) {
|
||||
|
||||
@@ -21,6 +21,9 @@ import (
|
||||
// 补完实现后要把对应的返回删掉,不要留着假装能用。
|
||||
var ErrNotImplemented = errors.New("功能尚未实现")
|
||||
|
||||
// ErrTaskNotVisible 同时表示任务不存在和不在当前账号范围,避免泄露他人任务。
|
||||
var ErrTaskNotVisible = errors.New("任务不存在或不在当前账号可见范围")
|
||||
|
||||
// ---------- 蝦皮 Excel 导入 ----------
|
||||
//
|
||||
// ImportResult / ImportShopeeExcel / ParseSpec 的实现见 shopee_import.go,
|
||||
@@ -125,6 +128,17 @@ func visibleClientUserID(actor *model.User) (string, error) {
|
||||
return visibleUserID, nil
|
||||
}
|
||||
|
||||
// taskActorScope 校验可创建/管理任务的网页登录账号,并返回客户端/任务可见范围。
|
||||
func taskActorScope(actor *model.User) (string, error) {
|
||||
if actor == nil {
|
||||
return "", ErrUnauthenticated
|
||||
}
|
||||
if actor.Status != model.UserActive {
|
||||
return "", fmt.Errorf("当前账号不是正常状态,不能操作任务")
|
||||
}
|
||||
return visibleClientUserID(actor)
|
||||
}
|
||||
|
||||
func listClientViews(db *sql.DB, keyword, visibleUserID string, threshold time.Duration) ([]ClientView, error) {
|
||||
clients, err := repository.ListClientsForUser(db, keyword, visibleUserID)
|
||||
if err != nil {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"cmautobuy/admin/model"
|
||||
"cmautobuy/admin/repository"
|
||||
)
|
||||
|
||||
@@ -96,6 +97,21 @@ func CreateShopeePddCollectTask(db *sql.DB, shopeeGoodsID string) (CollectTaskRe
|
||||
return CreatePddCollectTasks(db, []string{product.PddGoodsID})
|
||||
}
|
||||
|
||||
// CreateShopeePddCollectTaskForUser 为网页登录入口记录任务创建人。
|
||||
func CreateShopeePddCollectTaskForUser(db *sql.DB, actor *model.User, shopeeGoodsID string) (CollectTaskResult, error) {
|
||||
product, err := repository.GetShopeeProductByGoodsID(db, strings.TrimSpace(shopeeGoodsID))
|
||||
if err != nil {
|
||||
return CollectTaskResult{}, err
|
||||
}
|
||||
if product == nil {
|
||||
return CollectTaskResult{}, fmt.Errorf("蝦皮商品不存在")
|
||||
}
|
||||
if product.PddGoodsID == "" {
|
||||
return CollectTaskResult{}, fmt.Errorf("请先关联 PDD 商品")
|
||||
}
|
||||
return CreatePddCollectTasksForUser(db, actor, []string{product.PddGoodsID}, "")
|
||||
}
|
||||
|
||||
// AssociateSybPdd 从顺运宝明细解析出可信的蝦皮商品,再复用统一关联逻辑。
|
||||
func AssociateSybPdd(db *sql.DB, sybID, rawURL string, confirmReplace bool) (string, error) {
|
||||
context, err := repository.GetSybOrderContext(db, strings.TrimSpace(sybID))
|
||||
@@ -129,3 +145,15 @@ func CreateSybPddCollectTask(db *sql.DB, sybID string) (CollectTaskResult, error
|
||||
}
|
||||
return CreateShopeePddCollectTask(db, context.Order.ShopeeGoodsID)
|
||||
}
|
||||
|
||||
// CreateSybPddCollectTaskForUser 为网页登录入口记录任务创建人。
|
||||
func CreateSybPddCollectTaskForUser(db *sql.DB, actor *model.User, sybID string) (CollectTaskResult, error) {
|
||||
context, err := repository.GetSybOrderContext(db, strings.TrimSpace(sybID))
|
||||
if err != nil {
|
||||
return CollectTaskResult{}, err
|
||||
}
|
||||
if context == nil {
|
||||
return CollectTaskResult{}, fmt.Errorf("顺运宝明细不存在")
|
||||
}
|
||||
return CreateShopeePddCollectTaskForUser(db, actor, context.Order.ShopeeGoodsID)
|
||||
}
|
||||
|
||||
@@ -101,3 +101,18 @@ func TestCreateShopeePddCollectTask_按当前关联创建(t *testing.T) {
|
||||
t.Fatal("没有创建采集任务")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateShopeePddCollectTaskForUser_记录当前用户(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
admin, _, _ := insertTaskUsers(t, db)
|
||||
seedShopeeProduct(t, db, "1001", "商品一")
|
||||
AssociateShopeePdd(db, "1001", pddURLA, false)
|
||||
result, err := CreateShopeePddCollectTaskForUser(db, admin, "1001")
|
||||
if err != nil || result.Created != 1 {
|
||||
t.Fatalf("创建结果 = %+v, err=%v", result, err)
|
||||
}
|
||||
var creator string
|
||||
if err := db.QueryRow(`SELECT created_by_user_id FROM tasks WHERE pdd_goods_id='737116531267'`).Scan(&creator); err != nil || creator != admin.UserID {
|
||||
t.Fatalf("蝦皮入口任务创建人=%q err=%v", creator, err)
|
||||
}
|
||||
}
|
||||
|
||||
+108
-1
@@ -239,9 +239,41 @@ type TaskView struct {
|
||||
StatusText string
|
||||
IsWarn bool // 失败 / 需人工,标黄提醒
|
||||
ClientText string
|
||||
CreatorText string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
const TaskCreatorHistoryValue = "__history__"
|
||||
|
||||
// TaskCreatorOption 是管理员创建人筛选的一项。
|
||||
type TaskCreatorOption struct {
|
||||
Value string
|
||||
Text string
|
||||
}
|
||||
|
||||
// TaskCreatorOptions 返回全部账号(含已禁用)以及存量历史任务选项。
|
||||
func TaskCreatorOptions(db *sql.DB, actor *model.User) ([]TaskCreatorOption, error) {
|
||||
if actor == nil {
|
||||
return nil, ErrUnauthenticated
|
||||
}
|
||||
if !actor.IsAdmin() {
|
||||
return nil, nil
|
||||
}
|
||||
users, err := repository.ListTaskCreatorUsers(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
options := []TaskCreatorOption{{Value: "", Text: "全部创建人"}, {Value: TaskCreatorHistoryValue, Text: "历史任务"}}
|
||||
for _, user := range users {
|
||||
text := user.Username
|
||||
if user.Status == model.UserDisabled {
|
||||
text += "(已禁用)"
|
||||
}
|
||||
options = append(options, TaskCreatorOption{Value: user.UserID, Text: text})
|
||||
}
|
||||
return options, nil
|
||||
}
|
||||
|
||||
// TaskListResult 是列表页要的全部数据。
|
||||
type TaskListResult struct {
|
||||
Rows []TaskView
|
||||
@@ -281,7 +313,7 @@ func ListTasksView(db *sql.DB, filter repository.TaskFilter, requestedPage int)
|
||||
Counts: counts,
|
||||
Total: total, Page: page, TotalPages: totalPages,
|
||||
IsFiltered: filter.Type != "" || filter.Status != "" ||
|
||||
strings.TrimSpace(filter.Keyword) != "",
|
||||
strings.TrimSpace(filter.Keyword) != "" || filter.CreatorUserID != "" || filter.CreatorHistory,
|
||||
}
|
||||
for _, r := range rows {
|
||||
result.Rows = append(result.Rows, TaskView{
|
||||
@@ -292,12 +324,40 @@ func ListTasksView(db *sql.DB, filter repository.TaskFilter, requestedPage int)
|
||||
StatusText: taskStatusText(r.Status),
|
||||
IsWarn: isTaskWarn(r.Status),
|
||||
ClientText: clientText(r.AssignedClient),
|
||||
CreatorText: taskCreatorText(r.CreatedByUsername),
|
||||
UpdatedAt: formatLocalTime(r.UpdatedAt),
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func taskCreatorText(username string) string {
|
||||
if strings.TrimSpace(username) == "" {
|
||||
return "历史任务"
|
||||
}
|
||||
return username
|
||||
}
|
||||
|
||||
// ListTasksViewForUser 在服务端固定当前账号范围,忽略采购员伪造的创建人筛选。
|
||||
func ListTasksViewForUser(db *sql.DB, actor *model.User, filter repository.TaskFilter, creatorValue string, requestedPage int) (*TaskListResult, error) {
|
||||
visibleUserID, err := visibleClientUserID(actor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
filter.VisibleUserID = visibleUserID
|
||||
filter.CreatorUserID = ""
|
||||
filter.CreatorHistory = false
|
||||
if actor.IsAdmin() {
|
||||
creatorValue = strings.TrimSpace(creatorValue)
|
||||
if creatorValue == TaskCreatorHistoryValue {
|
||||
filter.CreatorHistory = true
|
||||
} else if creatorValue != "" {
|
||||
filter.CreatorUserID = creatorValue
|
||||
}
|
||||
}
|
||||
return ListTasksView(db, filter, requestedPage)
|
||||
}
|
||||
|
||||
// StatusLine 拼底部状态条,形如:
|
||||
//
|
||||
// 共 42 条 · 待分配 3 · 待领取 5 · 已领取 2 · 成功 30 · 需人工 1 · 失败 1 · 已取消 0
|
||||
@@ -317,6 +377,34 @@ func DeleteTasks(db *sql.DB, taskIDs []string) (int64, error) {
|
||||
return repository.DeleteTasks(db, dedupe(taskIDs))
|
||||
}
|
||||
|
||||
// DeleteTasksForUser 保证整批删除要么全部在当前范围内成功,要么全部回滚。
|
||||
func DeleteTasksForUser(db *sql.DB, actor *model.User, taskIDs []string) (int64, error) {
|
||||
visibleUserID, err := visibleClientUserID(actor)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
ids := dedupe(taskIDs)
|
||||
if len(ids) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("开始删除任务事务失败: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
n, err := repository.DeleteTasksInScope(tx, ids, visibleUserID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if n != int64(len(ids)) {
|
||||
return 0, ErrTaskNotVisible
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, fmt.Errorf("提交删除任务事务失败: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// ---------- 详情弹窗 ----------
|
||||
|
||||
// resultDataLimit 是弹窗里展开显示 result_data 的最大字符数。
|
||||
@@ -333,6 +421,7 @@ type TaskDetailView struct {
|
||||
ExecutionModeText string
|
||||
StatusText string
|
||||
ClientText string
|
||||
CreatorText string
|
||||
ClaimedAt string
|
||||
FinishedAt string
|
||||
|
||||
@@ -369,6 +458,10 @@ func GetTaskDetail(db *sql.DB, taskID string) (*TaskDetailView, error) {
|
||||
if err != nil || t == nil {
|
||||
return nil, err
|
||||
}
|
||||
creatorUsername, err := repository.GetTaskCreatorUsername(db, taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
v := &TaskDetailView{
|
||||
TaskID: t.TaskID,
|
||||
@@ -376,6 +469,7 @@ func GetTaskDetail(db *sql.DB, taskID string) (*TaskDetailView, error) {
|
||||
ExecutionModeText: taskExecutionModeText(t.TaskType, t.ExecutionMode),
|
||||
StatusText: taskStatusText(t.Status),
|
||||
ClientText: clientText(t.AssignedClient),
|
||||
CreatorText: taskCreatorText(creatorUsername),
|
||||
ClaimedAt: formatLocalTime(t.ClaimedAt),
|
||||
FinishedAt: formatLocalTime(t.FinishedAt),
|
||||
PddGoodsURL: t.PddGoodsURL,
|
||||
@@ -401,6 +495,19 @@ func GetTaskDetail(db *sql.DB, taskID string) (*TaskDetailView, error) {
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// GetTaskDetailForUser 对采购员隐藏其他人和历史任务,统一表现为不存在。
|
||||
func GetTaskDetailForUser(db *sql.DB, actor *model.User, taskID string) (*TaskDetailView, error) {
|
||||
visibleUserID, err := visibleClientUserID(actor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
visible, err := repository.TaskVisibleToUser(db, taskID, visibleUserID)
|
||||
if err != nil || !visible {
|
||||
return nil, err
|
||||
}
|
||||
return GetTaskDetail(db, taskID)
|
||||
}
|
||||
|
||||
// purchaseResult 只声明详情页需要展示的字段,避免把 Client 的完整结果结构
|
||||
// 复制到 Admin。其余原始字段仍保存在 result_data 并可在详情底部展开查看。
|
||||
type purchaseResult struct {
|
||||
|
||||
+117
-17
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -39,20 +40,21 @@ func TestListTasksView_统一每页20条并收敛越界页(t *testing.T) {
|
||||
// 跟 client_test.go 里的 insertTask 不一样:那个专为"领取"场景写死了固定值,
|
||||
// 这里需要覆盖类型、状态、options 等更多维度。
|
||||
type testTaskParams struct {
|
||||
taskID string
|
||||
taskType model.TaskType
|
||||
status model.TaskStatus
|
||||
assignedClient string
|
||||
orderNo string
|
||||
pddGoodsID string
|
||||
pddOptions string
|
||||
quantity int
|
||||
maxPriceCent int64
|
||||
errorCode string
|
||||
errorMessage string
|
||||
resultData string
|
||||
claimedAt string
|
||||
finishedAt string
|
||||
taskID string
|
||||
taskType model.TaskType
|
||||
status model.TaskStatus
|
||||
assignedClient string
|
||||
orderNo string
|
||||
pddGoodsID string
|
||||
pddOptions string
|
||||
quantity int
|
||||
maxPriceCent int64
|
||||
errorCode string
|
||||
errorMessage string
|
||||
resultData string
|
||||
claimedAt string
|
||||
finishedAt string
|
||||
createdByUserID string
|
||||
}
|
||||
|
||||
func insertTestTask(t *testing.T, db *sql.DB, p testTaskParams) {
|
||||
@@ -100,20 +102,27 @@ func insertTestTask(t *testing.T, db *sql.DB, p testTaskParams) {
|
||||
INSERT INTO tasks (task_id, task_type, status, assigned_client, claimed_at,
|
||||
order_no, pdd_goods_url, pdd_goods_id, pdd_options,
|
||||
quantity, max_price_cent,
|
||||
error_code, error_message, result_data, finished_at,
|
||||
error_code, error_message, result_data, finished_at, created_by_user_id,
|
||||
created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'https://mobile.yangkeduo.com/goods.html?goods_id=1', ?, ?,
|
||||
?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
p.taskID, string(p.taskType), string(p.status), assigned, claimedAt,
|
||||
orderNo, pddGoodsID, pddOptions,
|
||||
quantity, maxPrice,
|
||||
errCode, errMsg, resultData, finishedAt,
|
||||
errCode, errMsg, resultData, finishedAt, nullableString(p.createdByUserID),
|
||||
now, now)
|
||||
if err != nil {
|
||||
t.Fatalf("插入测试任务 %s 失败: %v", p.taskID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func nullableString(value string) any {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// ── 列表:同时显示采集和采购 ──────────────────────────
|
||||
|
||||
func TestListTasksView_同时显示采集和采购(t *testing.T) {
|
||||
@@ -515,6 +524,97 @@ func TestDeleteTasks_批量删除(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskScope_采购员只能查看自己的任务且不能伪造筛选(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
admin, buyerA, buyerB := insertTaskUsers(t, db)
|
||||
insertTestTask(t, db, testTaskParams{taskID: "OWN-A", taskType: model.TaskCollect, status: model.TaskPending, createdByUserID: buyerA.UserID})
|
||||
insertTestTask(t, db, testTaskParams{taskID: "OWN-B", taskType: model.TaskCollect, status: model.TaskSucceeded, createdByUserID: buyerB.UserID})
|
||||
insertTestTask(t, db, testTaskParams{taskID: "HISTORY", taskType: model.TaskCollect, status: model.TaskFailed})
|
||||
|
||||
buyerResult, err := ListTasksViewForUser(db, buyerA, repository.TaskFilter{}, TaskCreatorHistoryValue, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if buyerResult.Total != 1 || len(buyerResult.Rows) != 1 || buyerResult.Rows[0].TaskID != "OWN-A" {
|
||||
t.Fatalf("采购员范围不正确: %+v", buyerResult.Rows)
|
||||
}
|
||||
if buyerResult.Counts[model.TaskSucceeded] != 0 || buyerResult.Counts[model.TaskFailed] != 0 {
|
||||
t.Fatalf("采购员统计泄露了其他任务: %+v", buyerResult.Counts)
|
||||
}
|
||||
|
||||
history, err := ListTasksViewForUser(db, admin, repository.TaskFilter{}, TaskCreatorHistoryValue, 1)
|
||||
if err != nil || history.Total != 1 || history.Rows[0].TaskID != "HISTORY" {
|
||||
t.Fatalf("管理员历史任务筛选不正确: err=%v rows=%+v", err, history.Rows)
|
||||
}
|
||||
byBuyer, err := ListTasksViewForUser(db, admin, repository.TaskFilter{}, buyerB.UserID, 1)
|
||||
if err != nil || byBuyer.Total != 1 || byBuyer.Rows[0].TaskID != "OWN-B" {
|
||||
t.Fatalf("管理员创建人筛选不正确: err=%v rows=%+v", err, byBuyer.Rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskScope_详情越权返回不存在且批量删除完整回滚(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
admin, buyerA, buyerB := insertTaskUsers(t, db)
|
||||
insertTestTask(t, db, testTaskParams{taskID: "OWN-A", taskType: model.TaskCollect, status: model.TaskPending, createdByUserID: buyerA.UserID})
|
||||
insertTestTask(t, db, testTaskParams{taskID: "OWN-B", taskType: model.TaskCollect, status: model.TaskPending, createdByUserID: buyerB.UserID})
|
||||
|
||||
detail, err := GetTaskDetailForUser(db, buyerA, "OWN-B")
|
||||
if err != nil || detail != nil {
|
||||
t.Fatalf("越权详情应表现为不存在: detail=%+v err=%v", detail, err)
|
||||
}
|
||||
if _, err := DeleteTasksForUser(db, buyerA, []string{"OWN-A", "OWN-B"}); !errors.Is(err, ErrTaskNotVisible) {
|
||||
t.Fatalf("混合越权删除应整批拒绝: %v", err)
|
||||
}
|
||||
for _, id := range []string{"OWN-A", "OWN-B"} {
|
||||
visible, err := repository.TaskVisibleToUser(db, id, "")
|
||||
if err != nil || !visible {
|
||||
t.Fatalf("回滚后任务 %s 应仍存在: visible=%t err=%v", id, visible, err)
|
||||
}
|
||||
}
|
||||
if n, err := DeleteTasksForUser(db, buyerA, []string{"OWN-A"}); err != nil || n != 1 {
|
||||
t.Fatalf("采购员删除本人任务失败: n=%d err=%v", n, err)
|
||||
}
|
||||
if n, err := DeleteTasksForUser(db, admin, []string{"OWN-B"}); err != nil || n != 1 {
|
||||
t.Fatalf("管理员删除任意任务失败: n=%d err=%v", n, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskCreatorOptions_包含禁用账号和历史任务(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
admin, _, buyerB := insertTaskUsers(t, db)
|
||||
if _, err := db.Exec(`UPDATE users SET status='disabled' WHERE user_id=?`, buyerB.UserID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
options, err := TaskCreatorOptions(db, admin)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
texts := map[string]string{}
|
||||
for _, option := range options {
|
||||
texts[option.Value] = option.Text
|
||||
}
|
||||
if texts[TaskCreatorHistoryValue] != "历史任务" || texts[buyerB.UserID] != buyerB.Username+"(已禁用)" {
|
||||
t.Fatalf("创建人选项不完整: %+v", texts)
|
||||
}
|
||||
}
|
||||
|
||||
func insertTaskUsers(t *testing.T, db *sql.DB) (*model.User, *model.User, *model.User) {
|
||||
t.Helper()
|
||||
now := model.NowISO()
|
||||
users := []*model.User{
|
||||
{UserID: "task-admin", Username: "task-admin", PasswordHash: "hash", Role: model.RoleAdmin, Status: model.UserActive},
|
||||
{UserID: "task-buyer-a", Username: "task-buyer-a", PasswordHash: "hash", Role: model.RolePurchaser, Status: model.UserActive},
|
||||
{UserID: "task-buyer-b", Username: "task-buyer-b", PasswordHash: "hash", Role: model.RolePurchaser, Status: model.UserActive},
|
||||
}
|
||||
for _, user := range users {
|
||||
user.PasswordChangedAt, user.CreatedAt, user.UpdatedAt = now, now, now
|
||||
if err := repository.CreateUser(db, *user); err != nil {
|
||||
t.Fatalf("插入任务测试用户失败: %v", err)
|
||||
}
|
||||
}
|
||||
return users[0], users[1], users[2]
|
||||
}
|
||||
|
||||
// ── 目标列拼接的边界情况 ──────────────────────────────
|
||||
|
||||
func TestSpecText_空或无法解析时返回占位符(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user