feat: 按创建人隔离采集采购任务 (#127)

This commit is contained in:
chengma
2026-08-10 23:10:28 +08:00
parent 74f99f37d0
commit a9f22b9b0e
24 changed files with 655 additions and 74 deletions
+9 -9
View File
@@ -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++
+4
View File
@@ -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)
+3 -2
View File
@@ -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,
+5 -2
View File
@@ -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) {
+14
View File
@@ -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 {
+28
View File
@@ -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)
}
+15
View File
@@ -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
View File
@@ -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
View File
@@ -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) {