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

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