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
+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)
}