diff --git a/admin/handler/web/others.go b/admin/handler/web/others.go
index c366bd9..0e64e26 100644
--- a/admin/handler/web/others.go
+++ b/admin/handler/web/others.go
@@ -573,7 +573,7 @@ func (h *Handler) SybAssociatePdd(c *gin.Context) {
// SybCollectPdd 为货运单当前关联的 PDD 商品创建采集任务。
func (h *Handler) SybCollectPdd(c *gin.Context) {
- result, err := service.CreateSybPddCollectTask(h.db, c.PostForm("syb_id"))
+ result, err := service.CreateSybPddCollectTaskForUser(h.db, currentUser(c), c.PostForm("syb_id"))
if err != nil {
h.sybRedirect(c, "采集任务未创建:"+err.Error())
return
@@ -689,18 +689,25 @@ func (h *Handler) SybDelete(c *gin.Context) {
// TaskList 渲染「采集采购」列表页。
func (h *Handler) TaskList(c *gin.Context) {
+ actor := currentUser(c)
+ creatorFilter := c.Query("creator")
filter := repository.TaskFilter{
Type: service.ParseTaskType(c.Query("type")),
Status: service.ParseTaskStatus(c.Query("status")),
Keyword: c.Query("q"),
}
- result, err := service.ListTasksView(h.db, filter, service.ParsePage(c.Query("page")))
+ result, err := service.ListTasksViewForUser(h.db, actor, filter, creatorFilter, service.ParsePage(c.Query("page")))
if err != nil {
fail(c, http.StatusInternalServerError,
"读取任务列表失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。")
return
}
+ creatorOptions, err := service.TaskCreatorOptions(h.db, actor)
+ if err != nil {
+ fail(c, http.StatusInternalServerError, "读取任务创建人筛选失败,请刷新页面重试。")
+ return
+ }
// 底部状态条平时显示统计,刚做完删除操作时先显示操作结果,
// 跳转带过来的 msg 参数,见 h.taskRedirect。
@@ -719,17 +726,23 @@ func (h *Handler) TaskList(c *gin.Context) {
if strings.TrimSpace(filter.Keyword) != "" {
values.Set("q", filter.Keyword)
}
+ if actor.IsAdmin() && creatorFilter != "" {
+ values.Set("creator", creatorFilter)
+ }
c.HTML(http.StatusOK, "task/list", page(c, "tasks", "采集采购", gin.H{
- "Rows": result.Rows,
- "Keyword": filter.Keyword,
- "Status": statusLine,
- "TypeFilter": string(filter.Type),
- "StatusFilter": string(filter.Status),
- "TypeOptions": service.TaskTypeOptions(),
- "StatusOptions": service.TaskStatusOptions(),
- "IsFiltered": result.IsFiltered,
- "CurrentPage": result.Page,
- "Pagination": service.NewPaginationView(result.Page, result.TotalPages, values.Encode()),
+ "Rows": result.Rows,
+ "Keyword": filter.Keyword,
+ "Status": statusLine,
+ "TypeFilter": string(filter.Type),
+ "StatusFilter": string(filter.Status),
+ "TypeOptions": service.TaskTypeOptions(),
+ "StatusOptions": service.TaskStatusOptions(),
+ "ShowCreator": actor.IsAdmin(),
+ "CreatorFilter": creatorFilter,
+ "CreatorOptions": creatorOptions,
+ "IsFiltered": result.IsFiltered,
+ "CurrentPage": result.Page,
+ "Pagination": service.NewPaginationView(result.Page, result.TotalPages, values.Encode()),
}))
}
@@ -745,7 +758,7 @@ func (h *Handler) TaskDetail(c *gin.Context) {
return
}
- detail, err := service.GetTaskDetail(h.db, taskID)
+ detail, err := service.GetTaskDetailForUser(h.db, currentUser(c), taskID)
if err != nil {
fail(c, http.StatusInternalServerError, "读取任务详情失败,数据没有被改动。")
return
@@ -769,8 +782,12 @@ func (h *Handler) TaskDelete(c *gin.Context) {
return
}
- n, err := service.DeleteTasks(h.db, ids)
+ n, err := service.DeleteTasksForUser(h.db, currentUser(c), ids)
if err != nil {
+ if errors.Is(err, service.ErrTaskNotVisible) {
+ fail(c, http.StatusBadRequest, "删除失败:所选任务不存在或不在当前账号可见范围。没有删除任何记录。")
+ return
+ }
fail(c, http.StatusInternalServerError,
"删除失败:"+err.Error()+"。没有删除任何记录。")
return
@@ -795,6 +812,9 @@ func (h *Handler) taskRedirect(c *gin.Context, msg string) {
if p := c.PostForm("page"); p != "" {
params.Set("page", p)
}
+ if creator := c.PostForm("creator"); creator != "" && currentUser(c).IsAdmin() {
+ params.Set("creator", creator)
+ }
if msg != "" {
params.Set("msg", msg)
}
diff --git a/admin/handler/web/pagination_test.go b/admin/handler/web/pagination_test.go
index 1ec146d..ff27679 100644
--- a/admin/handler/web/pagination_test.go
+++ b/admin/handler/web/pagination_test.go
@@ -8,6 +8,8 @@ import (
"testing"
"github.com/gin-gonic/gin"
+
+ "cmautobuy/admin/model"
)
func listPostContext(t *testing.T, path string, values url.Values) (*gin.Context, *httptest.ResponseRecorder) {
@@ -67,11 +69,12 @@ func Test列表写操作跳转保留筛选和页码(t *testing.T) {
})
t.Run("任务", func(t *testing.T) {
context, recorder := listPostContext(t, "/tasks/delete", url.Values{
- "q": {"TASK"}, "type": {"collect"}, "status": {"pending"}, "page": {"2"},
+ "q": {"TASK"}, "type": {"collect"}, "status": {"pending"}, "creator": {"buyer-a"}, "page": {"2"},
})
+ context.Set(currentUserKey, &model.User{Role: model.RoleAdmin})
(&Handler{}).taskRedirect(context, "完成")
assertRedirectQuery(t, recorder, "/tasks", map[string]string{
- "q": "TASK", "type": "collect", "status": "pending", "page": "2", "msg": "完成",
+ "q": "TASK", "type": "collect", "status": "pending", "creator": "buyer-a", "page": "2", "msg": "完成",
})
})
t.Run("客户端", func(t *testing.T) {
diff --git a/admin/handler/web/shopee.go b/admin/handler/web/shopee.go
index 07d66e7..5d64c6a 100644
--- a/admin/handler/web/shopee.go
+++ b/admin/handler/web/shopee.go
@@ -242,7 +242,7 @@ func (h *Handler) ShopeeDelete(c *gin.Context) {
//
// `[不做]` 本工单(#38)明确不实现这个接口,保持 501。
func (h *Handler) ShopeeCollect(c *gin.Context) {
- result, err := service.CreateShopeePddCollectTask(h.db, c.PostForm("shopee_goods_id"))
+ result, err := service.CreateShopeePddCollectTaskForUser(h.db, currentUser(c), c.PostForm("shopee_goods_id"))
if err != nil {
h.shopeeRedirect(c, "采集任务未创建:"+err.Error())
return
diff --git a/admin/main_test.go b/admin/main_test.go
index b16e5d8..c20e48a 100644
--- a/admin/main_test.go
+++ b/admin/main_test.go
@@ -100,6 +100,26 @@ func TestTaskDetail_采购核单结果使用独立只读分组(t *testing.T) {
}
}
+func TestTaskPage_创建人筛选列表列和详情字段齐全(t *testing.T) {
+ files := map[string][]string{
+ "templates/task/list.html": {
+ `{{if .ShowCreator}}`, `name="creator"`, `{{.CreatorText}}`, `>筛选`,
+ },
+ "templates/task/detail_modal.html": {`
创建人{{.CreatorText}}`},
+ }
+ for path, wants := range files {
+ content, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, want := range wants {
+ if !strings.Contains(string(content), want) {
+ t.Errorf("%s 缺少任务创建人界面约束 %q", path, want)
+ }
+ }
+ }
+}
+
func TestTemplatesAndRoutesCanBeBuiltWithoutDatabaseConnection(t *testing.T) {
if _, err := newRouter(nil); err != nil {
t.Fatalf("模板或路由组装失败: %v", err)
diff --git a/admin/model/model.go b/admin/model/model.go
index 543bb3e..a9ed665 100644
--- a/admin/model/model.go
+++ b/admin/model/model.go
@@ -356,6 +356,7 @@ type Task struct {
LiveConfirmedBy string
LiveConfirmedAt string
+ CreatedByUserID string
CreatedAt string
UpdatedAt string
diff --git a/admin/repository/mysql_db.go b/admin/repository/mysql_db.go
index e38dbcc..3619652 100644
--- a/admin/repository/mysql_db.go
+++ b/admin/repository/mysql_db.go
@@ -19,7 +19,7 @@ import (
"cmautobuy/admin/spec"
)
-const mysqlSchemaVersion = 5
+const mysqlSchemaVersion = 6
// OpenMySQL 打开生产 MySQL 8 数据库。错误信息绝不包含完整 DSN 或密码。
func OpenMySQL(cfg config.DatabaseConfig) (*sql.DB, error) {
@@ -483,10 +483,55 @@ func MigrateMySQL(db *sql.DB) error {
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)
}
+ current = 5
+ }
+ if current < 6 {
+ if err := migrateMySQLV6(db); err != nil {
+ return fmt.Errorf("执行 MySQL schema v6 失败: %w", err)
+ }
+ if err := checkMySQLV6Shape(db); err != nil {
+ return fmt.Errorf("MySQL schema v6 自检失败,未记录版本: %w", err)
+ }
+ if _, err := db.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`, 6, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
+ return fmt.Errorf("记录 MySQL schema v6 失败: %w", err)
+ }
}
return CheckMySQLSchema(db)
}
+// migrateMySQLV6 给任务补充创建人。存量任务保持 NULL,明确标记为历史任务;
+// 新代码创建任务时写入当前网页登录用户。DDL 逐项检查,支持中断后重放。
+func migrateMySQLV6(db *sql.DB) error {
+ exists, err := mysqlColumnExists(db, "tasks", "created_by_user_id")
+ if err != nil {
+ return err
+ }
+ if !exists {
+ if _, err := db.Exec(`ALTER TABLE tasks ADD COLUMN created_by_user_id VARCHAR(191) COLLATE utf8mb4_bin NULL AFTER live_confirmed_at`); err != nil {
+ return fmt.Errorf("增加 tasks.created_by_user_id 失败: %w", err)
+ }
+ }
+ constraintExists, err := mysqlConstraintExists(db, "tasks", "fk_tasks_created_by")
+ if err != nil {
+ return err
+ }
+ if !constraintExists {
+ if _, err := db.Exec(`ALTER TABLE tasks ADD CONSTRAINT fk_tasks_created_by FOREIGN KEY (created_by_user_id) REFERENCES users(user_id)`); err != nil {
+ return fmt.Errorf("增加任务创建人外键失败: %w", err)
+ }
+ }
+ indexExists, err := mysqlIndexExists(db, "tasks", "idx_tasks_creator_list")
+ if err != nil {
+ return err
+ }
+ if !indexExists {
+ if _, err := db.Exec(`ALTER TABLE tasks ADD INDEX idx_tasks_creator_list (created_by_user_id, updated_at DESC, task_id DESC)`); err != nil {
+ return fmt.Errorf("增加任务创建人列表索引失败: %w", err)
+ }
+ }
+ return nil
+}
+
// migrateMySQLV5 给任务增加不可变执行模式和真实下单创建审计。
// 每条 DDL 都先检查存在性,MySQL 在任意一步隐式提交后都可以安全重放。
func migrateMySQLV5(db *sql.DB) error {
@@ -808,7 +853,28 @@ func CheckMySQLSchema(db *sql.DB) error {
if err := checkMySQLV4Shape(db); err != nil {
return err
}
- return checkMySQLV5Shape(db)
+ if err := checkMySQLV5Shape(db); err != nil {
+ return err
+ }
+ return checkMySQLV6Shape(db)
+}
+
+func checkMySQLV6Shape(db *sql.DB) error {
+ if err := checkMySQLVarcharColumn(db, "tasks", "created_by_user_id", 191, true, "utf8mb4_bin", ""); err != nil {
+ return err
+ }
+ if err := checkMySQLNullDefault(db, "tasks", "created_by_user_id"); err != nil {
+ return err
+ }
+ var referencedTable, referencedColumn string
+ if err := db.QueryRow(`SELECT referenced_table_name, referenced_column_name FROM information_schema.key_column_usage WHERE constraint_schema=DATABASE() AND table_name='tasks' AND constraint_name='fk_tasks_created_by' AND column_name='created_by_user_id'`).Scan(&referencedTable, &referencedColumn); err != nil || referencedTable != "users" || referencedColumn != "user_id" {
+ return fmt.Errorf("任务创建人外键不正确")
+ }
+ 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_creator_list'`).Scan(&cols); err != nil || cols != "created_by_user_id:A,updated_at:D,task_id:D" {
+ return fmt.Errorf("任务创建人列表索引不正确")
+ }
+ return nil
}
func checkMySQLV5Shape(db *sql.DB) error {
diff --git a/admin/repository/mysql_db_integration_test.go b/admin/repository/mysql_db_integration_test.go
index 7bce38b..149a212 100644
--- a/admin/repository/mysql_db_integration_test.go
+++ b/admin/repository/mysql_db_integration_test.go
@@ -286,6 +286,60 @@ func TestMySQLMigrate_V5形状错误不记版本(t *testing.T) {
}
}
+func TestMySQLMigrate_V5升级V6且断点重跑(t *testing.T) {
+ db := openMySQLMigrationTestDB(t)
+ defer db.Close()
+ cleanMySQLTestSchema(t, db)
+ defer cleanMySQLTestSchema(t, db)
+ prepareMySQLV4(t, db)
+ if err := migrateMySQLV5(db); err != nil {
+ t.Fatal(err)
+ }
+ mustExec(t, db, `INSERT INTO schema_migrations(version,applied_at) VALUES (5,'2026-08-10T00:00:00Z')`)
+ 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('HISTORY','collect','pending','https://example.invalid',?,?)`, now, now)
+
+ // 模拟 DDL 已提交但版本号尚未写入,再启动必须能够收敛。
+ if err := migrateMySQLV6(db); err != nil {
+ t.Fatal(err)
+ }
+ if err := MigrateMySQL(db); err != nil {
+ t.Fatal(err)
+ }
+ if err := MigrateMySQL(db); err != nil {
+ t.Fatalf("v6 重跑失败: %v", err)
+ }
+ var versions int
+ if err := db.QueryRow(`SELECT COUNT(*) FROM schema_migrations WHERE version=6`).Scan(&versions); err != nil || versions != 1 {
+ t.Fatalf("v6=%d err=%v", versions, err)
+ }
+ var creator sql.NullString
+ if err := db.QueryRow(`SELECT created_by_user_id FROM tasks WHERE task_id='HISTORY'`).Scan(&creator); err != nil || creator.Valid {
+ t.Fatalf("存量任务应保持历史任务 NULL: creator=%+v err=%v", creator, err)
+ }
+}
+
+func TestMySQLMigrate_V6形状错误不记版本(t *testing.T) {
+ db := openMySQLMigrationTestDB(t)
+ defer db.Close()
+ cleanMySQLTestSchema(t, db)
+ defer cleanMySQLTestSchema(t, db)
+ prepareMySQLV4(t, db)
+ if err := migrateMySQLV5(db); err != nil {
+ t.Fatal(err)
+ }
+ mustExec(t, db, `INSERT INTO schema_migrations(version,applied_at) VALUES (5,'2026-08-10T00:00:00Z')`)
+ mustExec(t, db, `ALTER TABLE tasks ADD COLUMN created_by_user_id VARCHAR(32) NULL`)
+ if err := MigrateMySQL(db); err == nil {
+ t.Fatal("错误 created_by_user_id 形状必须阻止 v6")
+ }
+ var count int
+ db.QueryRow(`SELECT COUNT(*) FROM schema_migrations WHERE version=6`).Scan(&count)
+ if count != 0 {
+ t.Fatal("v6 自检失败不得记录版本")
+ }
+}
+
func openMySQLMigrationTestDB(t *testing.T) *sql.DB {
t.Helper()
if os.Getenv("CMAUTOBUY_MYSQL_TEST") != "1" {
diff --git a/admin/repository/task.go b/admin/repository/task.go
index d984ba9..c828720 100644
--- a/admin/repository/task.go
+++ b/admin/repository/task.go
@@ -113,7 +113,7 @@ func GetTask(db *sql.DB, taskID string) (*model.Task, error) {
var t model.Task
var assigned, claimedAt, sybID, orderNo, goodsID, skuID sql.NullString
var pddGoodsID, pddOptions, resultData, errCode, errMsg, finishedAt sql.NullString
- var liveConfirmedBy, liveConfirmedAt sql.NullString
+ var liveConfirmedBy, liveConfirmedAt, createdByUserID sql.NullString
var quantity, maxPrice sql.NullInt64
err := db.QueryRow(`
@@ -123,7 +123,7 @@ func GetTask(db *sql.DB, taskID string) (*model.Task, error) {
pdd_goods_url, pdd_goods_id, pdd_options,
quantity, max_price_cent,
result_data, error_code, error_message, finished_at,
- live_confirmed_by, live_confirmed_at,
+ live_confirmed_by, live_confirmed_at, created_by_user_id,
created_at, updated_at
FROM tasks WHERE task_id = ?`, taskID).Scan(
&t.TaskID, &t.TaskType, &t.Status, &t.ExecutionMode, &t.Version, &t.Priority,
@@ -132,7 +132,7 @@ func GetTask(db *sql.DB, taskID string) (*model.Task, error) {
&t.PddGoodsURL, &pddGoodsID, &pddOptions,
&quantity, &maxPrice,
&resultData, &errCode, &errMsg, &finishedAt,
- &liveConfirmedBy, &liveConfirmedAt,
+ &liveConfirmedBy, &liveConfirmedAt, &createdByUserID,
&t.CreatedAt, &t.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil
@@ -157,9 +157,37 @@ func GetTask(db *sql.DB, taskID string) (*model.Task, error) {
t.FinishedAt = finishedAt.String
t.LiveConfirmedBy = liveConfirmedBy.String
t.LiveConfirmedAt = liveConfirmedAt.String
+ t.CreatedByUserID = createdByUserID.String
return &t, nil
}
+// TaskVisibleToUser 判断任务是否在采购员创建人范围内。visibleUserID 为空表示管理员。
+func TaskVisibleToUser(q Execer, taskID, visibleUserID string) (bool, error) {
+ query := `SELECT COUNT(*) FROM tasks WHERE task_id = ?`
+ args := []any{taskID}
+ if visibleUserID != "" {
+ query += ` AND created_by_user_id = ?`
+ args = append(args, visibleUserID)
+ }
+ var count int
+ if err := q.QueryRow(query, args...).Scan(&count); err != nil {
+ return false, fmt.Errorf("检查任务可见范围失败: %w", err)
+ }
+ return count == 1, nil
+}
+
+// GetTaskCreatorUsername 返回任务创建人的用户名;历史任务或账号不存在时为空。
+func GetTaskCreatorUsername(q Execer, taskID string) (string, error) {
+ var username sql.NullString
+ if err := q.QueryRow(`SELECT u.username FROM tasks t LEFT JOIN users u ON u.user_id=t.created_by_user_id WHERE t.task_id=?`, taskID).Scan(&username); err != nil {
+ if err == sql.ErrNoRows {
+ return "", nil
+ }
+ return "", fmt.Errorf("读取任务创建人失败: %w", err)
+ }
+ return username.String, nil
+}
+
// RecordClaim 记一笔"某客户端领过某任务"。
//
// 同一台客户端重复领同一个任务时只更新时间,不报错。
@@ -278,16 +306,23 @@ type TaskFilter struct {
Type model.TaskType
Status model.TaskStatus
Keyword string // 同时匹配任务编号、订单号、PDD 商品 ID
+ // VisibleUserID 非空时强制只返回该创建人的任务,供采购员权限隔离。
+ VisibleUserID string
+ // CreatorUserID / CreatorHistory 只供管理员的创建人筛选使用。
+ CreatorUserID string
+ CreatorHistory bool
}
// TaskListRow 是列表一行要用到的原始字段,还没翻成界面文字——
// 那是 service 层的事(尤其是「目标」列的拼接,见 #19)。
type TaskListRow struct {
- TaskID string
- TaskType model.TaskType
- Status model.TaskStatus
- ExecutionMode model.TaskExecutionMode
- AssignedClient string // 空表示无主任务
+ TaskID string
+ TaskType model.TaskType
+ Status model.TaskStatus
+ ExecutionMode model.TaskExecutionMode
+ AssignedClient string // 空表示无主任务
+ CreatedByUserID string
+ CreatedByUsername string
OrderNo string
PddGoodsID string
@@ -324,6 +359,15 @@ func taskFilterClause(filter TaskFilter) (string, []any) {
"(t.task_id LIKE ? ESCAPE '!' OR t.order_no LIKE ? ESCAPE '!' OR t.pdd_goods_id LIKE ? ESCAPE '!')")
args = append(args, pattern, pattern, pattern)
}
+ if filter.VisibleUserID != "" {
+ clauses = append(clauses, "t.created_by_user_id = ?")
+ args = append(args, filter.VisibleUserID)
+ } else if filter.CreatorHistory {
+ clauses = append(clauses, "t.created_by_user_id IS NULL")
+ } else if filter.CreatorUserID != "" {
+ clauses = append(clauses, "t.created_by_user_id = ?")
+ args = append(args, filter.CreatorUserID)
+ }
if len(clauses) == 0 {
return "", args
@@ -343,10 +387,11 @@ func ListTasks(q Execer, filter TaskFilter, limit, offset int) ([]TaskListRow, e
sqlText := `
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.updated_at, p.title
+ t.updated_at, p.title, t.created_by_user_id, u.username
FROM tasks t
LEFT JOIN pdd_products p
- ON p.goods_id = t.pdd_goods_id AND p.deleted_at IS NULL` +
+ ON p.goods_id = t.pdd_goods_id AND p.deleted_at IS NULL
+ LEFT JOIN users u ON u.user_id = t.created_by_user_id` +
where + ` ORDER BY t.updated_at DESC, t.task_id DESC LIMIT ? OFFSET ?`
args = append(args, limit, offset)
@@ -359,13 +404,13 @@ func ListTasks(q Execer, filter TaskFilter, limit, offset int) ([]TaskListRow, e
list := make([]TaskListRow, 0, 16)
for rows.Next() {
var r TaskListRow
- var assigned, orderNo, pddGoodsID, pddOptions, title sql.NullString
+ var assigned, orderNo, pddGoodsID, pddOptions, title, creatorID, creatorName sql.NullString
var quantity, maxPrice sql.NullInt64
if err := rows.Scan(
&r.TaskID, &r.TaskType, &r.Status, &r.ExecutionMode, &assigned,
&orderNo, &pddGoodsID, &pddOptions, &quantity, &maxPrice,
- &r.UpdatedAt, &title,
+ &r.UpdatedAt, &title, &creatorID, &creatorName,
); err != nil {
return nil, fmt.Errorf("读取任务列表失败: %w", err)
}
@@ -377,6 +422,8 @@ func ListTasks(q Execer, filter TaskFilter, limit, offset int) ([]TaskListRow, e
r.Quantity = int(quantity.Int64)
r.MaxPriceCent = maxPrice.Int64
r.PddTitle = title.String
+ r.CreatedByUserID = creatorID.String
+ r.CreatedByUsername = creatorName.String
list = append(list, r)
}
return list, rows.Err()
@@ -431,16 +478,44 @@ func DeleteTasks(q Execer, taskIDs []string) (int64, error) {
return res.RowsAffected()
}
+// DeleteTasksInScope 原子删除指定权限范围内的任务。只要任一编号不存在或不在
+// 当前采购员范围内,受影响行数就不足,调用方必须回滚整个事务。
+func DeleteTasksInScope(q Execer, taskIDs []string, visibleUserID string) (int64, error) {
+ if len(taskIDs) == 0 {
+ return 0, nil
+ }
+ placeholders := strings.TrimSuffix(strings.Repeat("?,", len(taskIDs)), ",")
+ args := make([]any, 0, len(taskIDs)+1)
+ for _, id := range taskIDs {
+ args = append(args, id)
+ }
+ where := `task_id IN (` + placeholders + `)`
+ if visibleUserID != "" {
+ where += ` AND created_by_user_id = ?`
+ args = append(args, visibleUserID)
+ }
+ res, err := q.Exec(`DELETE FROM tasks WHERE `+where, args...)
+ if err != nil {
+ return 0, fmt.Errorf("批量删除任务失败: %w", err)
+ }
+ return res.RowsAffected()
+}
+
// InsertCollectTask 建一条不指定客户端的采集任务。
// 保留这个入口给蝦皮、顺运宝等现有流程使用,避免它们被 PDD 页的新选项影响。
func InsertCollectTask(q Execer, taskID, goodsID, goodsURL string) error {
- return InsertCollectTaskForClient(q, taskID, goodsID, goodsURL, "")
+ return InsertCollectTaskForClientAndUser(q, taskID, goodsID, goodsURL, "", "")
}
// InsertCollectTaskForClient 建一条采集任务。
// assignedClient 为空时任务无主待领;有值时只等待指定客户端领取。
// goodsURL 必填 —— Client 契约里 pdd_goods_url 是 NOT NULL。
func InsertCollectTaskForClient(q Execer, taskID, goodsID, goodsURL, assignedClient string) error {
+ return InsertCollectTaskForClientAndUser(q, taskID, goodsID, goodsURL, assignedClient, "")
+}
+
+// InsertCollectTaskForClientAndUser 建一条带创建人审计的采集任务。
+func InsertCollectTaskForClientAndUser(q Execer, taskID, goodsID, goodsURL, assignedClient, createdByUserID string) error {
if goodsID == "" || goodsURL == "" {
return fmt.Errorf("采集任务的商品 ID 和链接都不能为空")
}
@@ -452,11 +527,12 @@ func InsertCollectTaskForClient(q Execer, taskID, goodsID, goodsURL, assignedCli
assigned = sql.NullString{String: assignedClient, Valid: true}
}
now := model.NowISO()
+ creator := sql.NullString{String: strings.TrimSpace(createdByUserID), Valid: strings.TrimSpace(createdByUserID) != ""}
_, err := q.Exec(`
INSERT INTO tasks (task_id, task_type, status, assigned_client,
- pdd_goods_url, pdd_goods_id, created_at, updated_at)
- VALUES (?, 'collect', ?, ?, ?, ?, ?, ?)`,
- taskID, status, assigned, goodsURL, goodsID, now, now)
+ pdd_goods_url, pdd_goods_id, created_by_user_id, created_at, updated_at)
+ VALUES (?, 'collect', ?, ?, ?, ?, ?, ?, ?)`,
+ taskID, status, assigned, goodsURL, goodsID, creator, now, now)
if err != nil {
return fmt.Errorf("创建商品 %s 的采集任务失败: %w", goodsID, err)
}
@@ -505,12 +581,13 @@ func InsertPurchaseTask(q Execer, task model.Task) error {
(task_id, task_type, status, execution_mode, assigned_client,
syb_id, order_no, goods_id, shopee_sku_id,
pdd_goods_url, pdd_goods_id, pdd_options,
- quantity, max_price_cent, live_confirmed_by, live_confirmed_at,
+ quantity, max_price_cent, live_confirmed_by, live_confirmed_at, created_by_user_id,
created_at, updated_at)
- VALUES (?, 'purchase', 'assigned', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ VALUES (?, 'purchase', 'assigned', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
task.TaskID, task.ExecutionMode, task.AssignedClient, task.SybID, task.OrderNo,
task.GoodsID, task.ShopeeSKUID, task.PddGoodsURL, task.PddGoodsID,
- task.PddOptions, task.Quantity, task.MaxPriceCent, liveConfirmedBy, liveConfirmedAt, now, now)
+ task.PddOptions, task.Quantity, task.MaxPriceCent, liveConfirmedBy, liveConfirmedAt,
+ sql.NullString{String: task.CreatedByUserID, Valid: strings.TrimSpace(task.CreatedByUserID) != ""}, now, now)
if err != nil {
return fmt.Errorf("创建顺运宝明细 %s 的采购任务失败: %w", task.SybID, err)
}
diff --git a/admin/repository/user.go b/admin/repository/user.go
index 444e429..3207d6c 100644
--- a/admin/repository/user.go
+++ b/admin/repository/user.go
@@ -232,6 +232,24 @@ func ListUsers(q Execer, keyword string, status model.UserStatus, limit, offset
return users, total, nil
}
+// ListTaskCreatorUsers 返回管理员任务筛选所需的全部账号,包含已禁用采购员。
+func ListTaskCreatorUsers(q Execer) ([]model.User, error) {
+ rows, err := q.Query(`SELECT user_id, username, role, status FROM users ORDER BY username ASC`)
+ if err != nil {
+ return nil, fmt.Errorf("查询任务创建人选项失败: %w", err)
+ }
+ defer rows.Close()
+ var users []model.User
+ for rows.Next() {
+ var user model.User
+ if err := rows.Scan(&user.UserID, &user.Username, &user.Role, &user.Status); err != nil {
+ return nil, fmt.Errorf("读取任务创建人选项失败: %w", err)
+ }
+ users = append(users, user)
+ }
+ return users, rows.Err()
+}
+
// CreateUser 创建一个已经完成密码哈希的账号。用户名由数据库 NOCASE 唯一约束
// 做最终并发保护。
func CreateUser(q Execer, user model.User) error {
diff --git a/admin/service/pdd.go b/admin/service/pdd.go
index da4f960..38152a9 100644
--- a/admin/service/pdd.go
+++ b/admin/service/pdd.go
@@ -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++
diff --git a/admin/service/pdd_page_test.go b/admin/service/pdd_page_test.go
index fc97a72..2ac5134 100644
--- a/admin/service/pdd_page_test.go
+++ b/admin/service/pdd_page_test.go
@@ -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)
diff --git a/admin/service/purchase_workflow.go b/admin/service/purchase_workflow.go
index 0447b73..b81610d 100644
--- a/admin/service/purchase_workflow.go
+++ b/admin/service/purchase_workflow.go
@@ -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,
diff --git a/admin/service/purchase_workflow_test.go b/admin/service/purchase_workflow_test.go
index 82dbc84..916a991 100644
--- a/admin/service/purchase_workflow_test.go
+++ b/admin/service/purchase_workflow_test.go
@@ -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) {
diff --git a/admin/service/service.go b/admin/service/service.go
index a470c0e..76fc0e7 100644
--- a/admin/service/service.go
+++ b/admin/service/service.go
@@ -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 {
diff --git a/admin/service/shopee_pdd.go b/admin/service/shopee_pdd.go
index 3a46718..b33a8eb 100644
--- a/admin/service/shopee_pdd.go
+++ b/admin/service/shopee_pdd.go
@@ -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)
+}
diff --git a/admin/service/shopee_pdd_test.go b/admin/service/shopee_pdd_test.go
index 8f5834a..28990bf 100644
--- a/admin/service/shopee_pdd_test.go
+++ b/admin/service/shopee_pdd_test.go
@@ -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)
+ }
+}
diff --git a/admin/service/task.go b/admin/service/task.go
index 3f97bbb..79a2623 100644
--- a/admin/service/task.go
+++ b/admin/service/task.go
@@ -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 {
diff --git a/admin/service/task_test.go b/admin/service/task_test.go
index a16e2f1..20db2b4 100644
--- a/admin/service/task_test.go
+++ b/admin/service/task_test.go
@@ -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) {
diff --git a/admin/templates/task/detail_modal.html b/admin/templates/task/detail_modal.html
index 06196bd..6f22630 100644
--- a/admin/templates/task/detail_modal.html
+++ b/admin/templates/task/detail_modal.html
@@ -17,6 +17,7 @@
执行模式{{.ExecutionModeText}}
状态{{.StatusText}}
分配客户端{{.ClientText}}
+ 创建人{{.CreatorText}}
领取时间{{.ClaimedAt}}
完成时间{{.FinishedAt}}
diff --git a/admin/templates/task/list.html b/admin/templates/task/list.html
index 96c1a61..6006793 100644
--- a/admin/templates/task/list.html
+++ b/admin/templates/task/list.html
@@ -18,11 +18,21 @@
{{end}}
+ {{if .ShowCreator}}
+
+
+ {{end}}
+