feat: 按创建人隔离采集采购任务 (#127)
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user