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