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
+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 {