From a9f22b9b0e4e4e2caf28d5255a6c7ec21652caf0 Mon Sep 17 00:00:00 2001 From: chengma Date: Mon, 10 Aug 2026 23:10:28 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8C=89=E5=88=9B=E5=BB=BA=E4=BA=BA?= =?UTF-8?q?=E9=9A=94=E7=A6=BB=E9=87=87=E9=9B=86=E9=87=87=E8=B4=AD=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=20(#127)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/handler/web/others.go | 48 +++++-- admin/handler/web/pagination_test.go | 7 +- admin/handler/web/shopee.go | 2 +- admin/main_test.go | 20 +++ admin/model/model.go | 1 + admin/repository/mysql_db.go | 70 ++++++++- admin/repository/mysql_db_integration_test.go | 54 +++++++ admin/repository/task.go | 115 ++++++++++++--- admin/repository/user.go | 18 +++ admin/service/pdd.go | 18 +-- admin/service/pdd_page_test.go | 4 + admin/service/purchase_workflow.go | 5 +- admin/service/purchase_workflow_test.go | 7 +- admin/service/service.go | 14 ++ admin/service/shopee_pdd.go | 28 ++++ admin/service/shopee_pdd_test.go | 15 ++ admin/service/task.go | 109 +++++++++++++- admin/service/task_test.go | 134 +++++++++++++++--- admin/templates/task/detail_modal.html | 1 + admin/templates/task/list.html | 15 +- docs/admin/01-requirements.md | 13 +- docs/admin/03-data-model.md | 14 ++ docs/admin/05-ui-specification.md | 11 +- docs/admin/06-quality-security.md | 6 + 24 files changed, 655 insertions(+), 74 deletions(-) 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}} +
+ {{if .ShowCreator}}{{end}} {{/* search-narrow 把搜索框收到 30%,让「搜索」按钮紧挨着它。 用类名而不是改 .toolbar input[type="text"]——那条被五个页面共用。 */}} @@ -36,6 +46,7 @@ {{/* 把当前筛选一起带过去,删除完还停在原来的筛选上 */}} + {{if .ShowCreator}}{{end}} @@ -57,6 +68,7 @@ 目标 状态 客户端 + {{if .ShowCreator}}创建人{{end}} 更新时间 @@ -74,12 +86,13 @@ {{.Target}} {{.StatusText}} {{.ClientText}} + {{if $.ShowCreator}}{{.CreatorText}}{{end}} {{.UpdatedAt}} {{else}} {{/* 空状态要分情况:从没建过任务 和 筛选没结果,下一步动作完全不同 */}} - + {{if .IsFiltered}} 当前筛选条件下没有任务。
换个类型、状态或关键词再试。查看全部 diff --git a/docs/admin/01-requirements.md b/docs/admin/01-requirements.md index 41e08e9..72db67b 100644 --- a/docs/admin/01-requirements.md +++ b/docs/admin/01-requirements.md @@ -261,12 +261,17 @@ PDD 商品之所以单独一个模块,是因为它在数据上就是**独立 **顶部工具条:** ```text -[类型▾ 全部] [状态▾ 全部] 关键词[____________] [搜索] [删除] +[类型▾ 全部] [状态▾ 全部] [创建人▾ 全部] 关键词[____________] [搜索] [删除] ``` - **类型**:全部 / 采集 / 采购。 - **状态**:全部 + 7 个状态,见 §6.2。 - **关键词**:同时匹配任务编号、订单号、PDD 商品 ID。 +- **创建人**:仅管理员显示,可选全部账号(包含已禁用账号)或“历史任务”。 + +`[必须]` 采购员登录后只可查看自己创建的任务;URL 参数不能扩大范围。管理员查看 +全部任务,`created_by_user_id` 为空的存量记录显示为“历史任务”。列表、统计、分页、 +详情和删除必须使用同一权限范围,采购员直接访问他人详情统一返回 404。 `[必须]` 筛选比搜索更常用:这个页面最常被问的问题是"有没有卡住的任务", 不是"订单 SO-001 怎么样了",所以类型和状态筛选要放在最前面。 @@ -285,6 +290,7 @@ PDD 商品之所以单独一个模块,是因为它在数据上就是**独立 | **目标** | 采集:`PDD `(能 join 到未删除的商品标题时追加显示);采购:` · <颜色/尺码> · <数量>件 · ≤<价格上限>` | | 状态 | 中文,7 个取值见 §6.2 | | 客户端 | `assigned_client`;**无主任务显示 `—`**(#17 之后采集任务默认无主,这一列会大量为空) | +| 创建人 | 仅管理员列表显示;存量 NULL 显示“历史任务” | | 更新时间 | 本地时区 | `[必须]` 目标列所需字段全在 `tasks` 表上(`pdd_goods_id` / `order_no` / @@ -302,7 +308,7 @@ PDD 商品之所以单独一个模块,是因为它在数据上就是**独立 `[必须]` **只读**。改派 / 重试 / 取消是后续工单的范围,本页不提供入口。 -弹窗显示:类型、状态、分配客户端、领取时间、完成时间;执行参数 +弹窗显示:类型、状态、创建人、分配客户端、领取时间、完成时间;执行参数 (PDD 链接,采购任务额外显示目标规格、数量、价格上限);错误信息 (错误码、错误说明,没有错误时不显示这一段)。 @@ -317,6 +323,9 @@ Admin 本地时区,付款状态只是 Client 核单上报时的快照,Admin `[建议]` `result_data`(Client 提交的完整结果)默认折叠,提供展开查看; 过长时截断显示。 +`[必须]` 批量删除是全有或全无:提交中只要混入不存在或不属于当前采购员的任务, +整批拒绝且一条也不删除。 + **底部状态条:** ```text diff --git a/docs/admin/03-data-model.md b/docs/admin/03-data-model.md index 93255ce..882311a 100644 --- a/docs/admin/03-data-model.md +++ b/docs/admin/03-data-model.md @@ -138,6 +138,10 @@ MySQL 迁移 v5(工单 #98)为 `tasks` 追加 `execution_mode`、`live_confi 和 `live_confirmed_at`,并增加模式/审计 CHECK 与领取索引。旧任务通过数据库默认值 统一成为 `dry_run`;每条 DDL 可重放,列、约束和索引全部通过自检后才记录 v5。 +MySQL 迁移 v6(工单 #127)为 `tasks` 追加可空的 `created_by_user_id` 和创建人列表 +索引。存量记录不回填,NULL 明确表示“历史任务”;新建采集和采购任务必须写当前 +网页登录用户。迁移与自检可重放,历史 SQLite migrations 保持冻结。 + ## 3. 蝦皮数据 蝦皮报表**一个文件里混了两层数据**,所以拆成两张表。 @@ -693,6 +697,16 @@ CREATE INDEX idx_tasks_order ON tasks(order_no); 缺少创建审计信息的真实任务;`idx_tasks_claim_mode` 支持按客户端能力领取。字段名保留 历史命名以兼容已发布的 v5 migration。 +生产 MySQL v6 再追加任务创建人字段: + +| 字段 | 含义 | +|---|---| +| `created_by_user_id` | 创建任务的 Admin 用户 ID;存量 NULL 表示“历史任务” | + +`idx_tasks_creator_list(created_by_user_id, updated_at DESC, task_id DESC)` 支持采购员 +按本人范围分页,以及管理员按创建人筛选。用户只禁用不删除,因此无需把显示名称冗余 +写入任务;`fk_tasks_created_by` 指向 `users(user_id)`,页面通过 `users` 关联显示用户名。 + `[必须]` 两条硬约束,来自 [Client 契约](../client/04-admin-api-contract.md) §4: 1. **`pdd_goods_url` 不能为空**,否则 Client 无法执行(它那边是 `NOT NULL`)。 diff --git a/docs/admin/05-ui-specification.md b/docs/admin/05-ui-specification.md index 23dcd97..13d4a4b 100644 --- a/docs/admin/05-ui-specification.md +++ b/docs/admin/05-ui-specification.md @@ -604,12 +604,14 @@ ID 和在线状态;所有可见 Client 都可选择且只有一个时自动选 ### 7.1 工具条 ```text -[类型▾ 全部] [状态▾ 全部] 关键词[___] [搜索] [删除] +[类型▾ 全部] [状态▾ 全部] [创建人▾ 全部] 关键词[___] [搜索] [删除] ``` - **类型**:全部 / 采集 / 采购。 - **状态**:全部 + 7 个状态,见 [01 需求](01-requirements.md) §6.2。 - **关键词**:同时匹配任务编号、订单号、PDD 商品 ID。 +- **创建人**:仅管理员显示;包含全部账号、已禁用账号和“历史任务”。采购员不显示 + 此控件,服务端固定只查询本人创建的任务。 `[必须]` 搜索框宽度见 [§3.1](#31-搜索框宽度)。 placeholder 写「任务编号 / 订单号 / 商品 ID」,**不要写全「PDD 商品 ID」**—— @@ -617,7 +619,7 @@ placeholder 写「任务编号 / 订单号 / 商品 ID」,**不要写全「PDD ### 7.2 表格列 -☐ / 任务编号 / 类型 / **执行模式** / **目标** / 状态 / 客户端 / 更新时间 +☐ / 任务编号 / 类型 / **执行模式** / **目标** / 状态 / 客户端 / 创建人(管理员) / 更新时间 ```text ☐ │ 任务编号 │ 类型 │ 执行模式 │ 目标 │ 状态 │ 客户端 │ 更新时间 @@ -632,6 +634,7 @@ placeholder 写「任务编号 / 订单号 / 商品 ID」,**不要写全「PDD - `[必须]` **客户端列**:无主任务显示 `—`,不是空白或 ``——#17 之后 采集任务默认无主,这一列会大量为空。 - `[必须]` 类型和状态都是文字,不能只靠颜色区分。 +- `[必须]` 管理员显示创建人列,NULL 显示“历史任务”;采购员省略该列。 ### 7.3 详情弹窗(双击行打开) @@ -647,6 +650,7 @@ placeholder 写「任务编号 / 订单号 / 商品 ID」,**不要写全「PDD │ 执行模式 采集 │ │ 状态 已领取 │ │ 分配客户端 办公室-01 │ +│ 创建人 buyer-a │ │ 领取时间 2026-08-07 15:20:31 │ │ 完成时间 — │ ├──────────────────────────────────────────────┤ @@ -685,6 +689,9 @@ placeholder 写「任务编号 / 订单号 / 商品 ID」,**不要写全「PDD `[必须]` 批量删除要二次确认,写明"将删除 N 条,不可恢复"——`tasks` 表 没有软删除列,删了就是真删了。 +`[必须]` 删除权限在服务端判断。采购员混选本人和他人任务、或提交不存在编号时, +整批拒绝并保持所有任务不变;详情越权与任务不存在都显示 404。 + ### 7.5 底部状态条 ```text diff --git a/docs/admin/06-quality-security.md b/docs/admin/06-quality-security.md index 93e74dc..f50d47c 100644 --- a/docs/admin/06-quality-security.md +++ b/docs/admin/06-quality-security.md @@ -37,6 +37,8 @@ - 最后管理员保护:不能禁用最后一个有效管理员。 - 客户端归属:一人多客户端、一台客户端唯一当前负责人、转交/解绑历史完整; - 归属权限:采购员只看到自己的客户端,不能绑定、解绑或删除;禁用采购员不能成为新目标。 +- 任务创建人隔离:采购员列表/统计/分页只包含本人任务,伪造创建人参数不能扩大范围; +- 任务越权:他人详情表现为 404,混合本人/他人/不存在编号的批量删除完整回滚。 `[必须]` 导入相关的测试用 `admin/testdata/` 下的**小样本**(几十行), 不要读完整报表。 @@ -77,6 +79,8 @@ - Client API 不返回登录页或 302 重定向。 - 管理员客户端页显示全量和负责人;采购员客户端页只读且只显示自己的客户端; - 绑定/转交弹窗有明确标签和焦点,解绑有二次确认,操作结果可被辅助技术读到。 +- 管理员采集采购页可按正常账号、禁用账号和历史任务筛选并显示创建人;采购员不显示 + 创建人筛选和列表列,详情仍显示创建人。 ### 2.4 契约测试 @@ -124,6 +128,8 @@ - `[必须]` 管理员自助修改密码必须验证当前密码,目标账号从当前 Session 取得;成功后撤销该账号全部 Session 并重新登录。 - `[必须]` `/setup`、`/login`、`/logout`、`/account/change-password` 和用户管理写操作都保留 CSRF 防护。 - `[必须]` 客户端绑定、转交、解绑和删除只能由管理员执行,并保留 CSRF 防护。 +- `[必须]` 任务列表、统计、分页、详情和删除按同一创建人范围授权;采购员提交 URL 或 + 表单中的其他创建人/任务编号不能扩大范围,混合越权批量删除必须全部回滚。 - `[必须]` Web 登录中间件只保护 HTML 路由,不得覆盖 `/api/v1/client/*`。 - `[建议]` 对连续登录失败做简单限速;错误提示不区分用户名不存在和密码错误。