package repository import ( "database/sql" "encoding/json" "errors" "fmt" "strings" "cmautobuy/admin/model" ) // ClientPurchaseMode 返回客户端最后一次登记时声明的采购能力。 // 旧数据、空值或无法解析的 JSON 一律按 dry_run 处理,这是安全默认值。 func ClientPurchaseMode(q Execer, clientID string) (string, error) { var raw sql.NullString if err := q.QueryRow(`SELECT capabilities FROM clients WHERE client_id = ?`, clientID).Scan(&raw); errors.Is(err, sql.ErrNoRows) { return "", ErrClientNotFound } else if err != nil { return "", fmt.Errorf("读取客户端 %s 的采购能力失败: %w", clientID, err) } return ParseClientPurchaseMode(raw.String), nil } // ParseClientPurchaseMode 把登记能力转成安全的固定值,供列表和创建校验共用。 func ParseClientPurchaseMode(raw string) string { var capabilities struct { PurchaseMode string `json:"purchase_mode"` } if json.Unmarshal([]byte(raw), &capabilities) == nil && capabilities.PurchaseMode == string(model.TaskExecutionLive) { return capabilities.PurchaseMode } return string(model.TaskExecutionDryRun) } var ( ErrClientNotFound = errors.New("客户端不存在") ErrPurchaserNotActive = errors.New("采购员不存在或已禁用") ErrClientNotAssigned = errors.New("客户端当前未绑定采购员") ) // ClientWithAssignee 是客户端列表查询结果。归属为空表示尚未绑定。 type ClientWithAssignee struct { model.Client AssignedUserID string AssignedUsername string } // UpsertClient 登记或更新一台客户端。 // // # 名称的更新规则(两个接口不一样,这是有意的) // // explicit=true 用户在设置页点了"保存",是**明确的人为操作**。 // 带了非空名称就更新;名称为空则保留原有名称。 // explicit=false claim 顺带做的隐式登记,是**后台自动调用**。 // 永远不更新名称。 // // 为什么区分:后台每次领取任务都上报一次名称,如果照单全收, // 操作员在 Admin 界面精心改的名字会被客户端的默认值反复冲掉。 // 但用户明确点保存时,又应该能把新名字同步过去。 // // 新建时名称为空则用 clientID 兜底,保证列表里不出现空白行。 func UpsertClient(q Execer, c model.Client, explicit bool) error { if c.ClientID == "" { return fmt.Errorf("client_id 不能为空") } name := strings.TrimSpace(c.Name) insertName := name if insertName == "" { insertName = c.ClientID // 新建时的兜底 } // 只有"显式登记 + 名称非空"才允许覆盖已有名称 updateName := explicit && name != "" now := model.NowISO() _, err := q.Exec(` INSERT INTO clients (client_id, name, device_address, platform, pdd_package, capabilities, last_seen_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE name = CASE WHEN ? THEN ? ELSE clients.name END, device_address = VALUES(device_address), platform = VALUES(platform), pdd_package = VALUES(pdd_package), capabilities = VALUES(capabilities), last_seen_at = VALUES(last_seen_at), updated_at = VALUES(updated_at)`, c.ClientID, insertName, c.DeviceAddress, c.Platform, c.PddPackage, c.Capabilities, now, now, now, updateName, name) if err != nil { return fmt.Errorf("登记客户端 %s 失败: %w", c.ClientID, err) } return nil } // TouchClient 只刷新 last_seen_at。 // // claim / result / failure 三个接口都要调。只在 claim 里调的话, // 客户端执行长任务期间不调 claim,会被误判成离线。 func TouchClient(q Execer, clientID string) error { now := model.NowISO() _, err := q.Exec( `UPDATE clients SET last_seen_at = ?, updated_at = ? WHERE client_id = ?`, now, now, clientID) if err != nil { return fmt.Errorf("刷新客户端 %s 活动时间失败: %w", clientID, err) } return nil } // ListClients 按名称模糊查询客户端,keyword 为空时返回全部。 func ListClients(db *sql.DB, keyword string) ([]model.Client, error) { query := `SELECT client_id, name, device_address, platform, pdd_package, capabilities, last_seen_at, created_at, updated_at FROM clients` args := []any{} if kw := strings.TrimSpace(keyword); kw != "" { // 参数化查询,通配符拼在值里而不是 SQL 里 query += ` WHERE name LIKE ? OR client_id LIKE ?` like := "%" + kw + "%" args = append(args, like, like) } query += ` ORDER BY last_seen_at DESC, client_id` rows, err := db.Query(query, args...) if err != nil { return nil, fmt.Errorf("查询客户端列表失败: %w", err) } defer rows.Close() var out []model.Client for rows.Next() { var c model.Client var name, addr, platform, pkg, caps sql.NullString if err := rows.Scan(&c.ClientID, &name, &addr, &platform, &pkg, &caps, &c.LastSeenAt, &c.CreatedAt, &c.UpdatedAt); err != nil { return nil, fmt.Errorf("读取客户端行失败: %w", err) } c.Name = name.String c.DeviceAddress = addr.String c.Platform = platform.String c.PddPackage = pkg.String c.Capabilities = caps.String out = append(out, c) } return out, rows.Err() } // clientListFilter 统一生成客户端分页、总数和在线统计的可见范围。 func clientListFilter(keyword, visibleUserID string) (string, []any) { where := make([]string, 0, 2) args := make([]any, 0, 3) if visibleUserID != "" { where = append(where, `a.user_id = ?`) args = append(args, visibleUserID) } if kw := strings.TrimSpace(keyword); kw != "" { where = append(where, `(c.name LIKE ? OR c.client_id LIKE ?)`) like := "%" + kw + "%" args = append(args, like, like) } if len(where) == 0 { return "", args } return ` WHERE ` + strings.Join(where, ` AND `), args } const clientListFrom = ` FROM clients c LEFT JOIN client_user_assignments a ON a.client_id = c.client_id AND a.ended_at IS NULL LEFT JOIN users u ON u.user_id = a.user_id` func listClientsForUserPage(db *sql.DB, keyword, visibleUserID string, limit, offset int) ([]ClientWithAssignee, error) { where, args := clientListFilter(keyword, visibleUserID) query := `SELECT c.client_id, c.name, c.device_address, c.platform, c.pdd_package, c.capabilities, c.last_seen_at, c.created_at, c.updated_at, a.user_id, u.username` + clientListFrom + where + ` ORDER BY c.last_seen_at DESC, c.client_id` if limit >= 0 { query += ` LIMIT ? OFFSET ?` args = append(args, limit, offset) } rows, err := db.Query(query, args...) if err != nil { return nil, fmt.Errorf("查询客户端归属列表失败: %w", err) } defer rows.Close() out := make([]ClientWithAssignee, 0) for rows.Next() { var row ClientWithAssignee var name, addr, platform, pkg, caps, userID, username sql.NullString if err := rows.Scan(&row.ClientID, &name, &addr, &platform, &pkg, &caps, &row.LastSeenAt, &row.CreatedAt, &row.UpdatedAt, &userID, &username); err != nil { return nil, fmt.Errorf("读取客户端归属行失败: %w", err) } row.Name, row.DeviceAddress, row.Platform = name.String, addr.String, platform.String row.PddPackage, row.Capabilities = pkg.String, caps.String row.AssignedUserID, row.AssignedUsername = userID.String, username.String out = append(out, row) } return out, rows.Err() } // ListClientsForUser 返回带当前负责人的全部客户端,供需要完整候选集的业务使用。 func ListClientsForUser(db *sql.DB, keyword, visibleUserID string) ([]ClientWithAssignee, error) { return listClientsForUserPage(db, keyword, visibleUserID, -1, 0) } // ClientVisibleToUser 校验客户端是否存在且位于当前账号可见范围。 // visibleUserID 为空表示管理员,可选择任意已登记客户端。 func ClientVisibleToUser(q Execer, clientID, visibleUserID string) (bool, error) { query := `SELECT COUNT(*) FROM clients c` args := []any{} if visibleUserID != "" { query += ` JOIN client_user_assignments a ON a.client_id = c.client_id AND a.ended_at IS NULL AND a.user_id = ?` args = append(args, visibleUserID) } query += ` WHERE c.client_id = ?` args = append(args, clientID) var count int if err := q.QueryRow(query, args...).Scan(&count); err != nil { return false, fmt.Errorf("校验客户端可见范围失败: %w", err) } return count == 1, nil } // ListClientsForUserPage 返回一页客户端和同一可见范围内的总数。 func ListClientsForUserPage(db *sql.DB, keyword, visibleUserID string, limit, offset int) ([]ClientWithAssignee, int, error) { where, args := clientListFilter(keyword, visibleUserID) var total int if err := db.QueryRow(`SELECT COUNT(*)`+clientListFrom+where, args...).Scan(&total); err != nil { return nil, 0, fmt.Errorf("统计客户端列表失败: %w", err) } rows, err := listClientsForUserPage(db, keyword, visibleUserID, limit, offset) return rows, total, err } // CountOnlineClientsForUser 统计同一筛选和权限范围内的在线客户端。 // last_seen_at 统一以 UTC RFC3339 保存,固定宽度字符串的字典序就是时间顺序。 func CountOnlineClientsForUser(db *sql.DB, keyword, visibleUserID, cutoffISO string) (int, error) { where, args := clientListFilter(keyword, visibleUserID) args = append(args, cutoffISO) var online int query := `SELECT COUNT(*)` + clientListFrom + where if where == "" { query += ` WHERE c.last_seen_at > ?` } else { query += ` AND c.last_seen_at > ?` } if err := db.QueryRow(query, args...).Scan(&online); err != nil { return 0, fmt.Errorf("统计在线客户端失败: %w", err) } return online, nil } // ListActivePurchasers 返回可成为新负责人的启用采购员。 func ListActivePurchasers(db *sql.DB) ([]model.User, error) { rows, err := db.Query(` SELECT user_id, username, role, status, password_changed_at, created_at, updated_at FROM users WHERE role = ? AND status = ? ORDER BY username`, model.RolePurchaser, model.UserActive) if err != nil { return nil, fmt.Errorf("查询可绑定采购员失败: %w", err) } defer rows.Close() users := make([]model.User, 0) for rows.Next() { var user model.User if err := rows.Scan(&user.UserID, &user.Username, &user.Role, &user.Status, &user.PasswordChangedAt, &user.CreatedAt, &user.UpdatedAt); err != nil { return nil, fmt.Errorf("读取可绑定采购员失败: %w", err) } users = append(users, user) } return users, rows.Err() } // AssignClient 原子完成首次绑定或转交。返回 changed、transferred。 func AssignClient(db *sql.DB, assignment model.ClientUserAssignment) (bool, bool, error) { tx, err := db.Begin() if err != nil { return false, false, fmt.Errorf("开始绑定客户端事务失败: %w", err) } defer tx.Rollback() var lockedClientID string if err := tx.QueryRow(`SELECT client_id FROM clients WHERE client_id = ? FOR UPDATE`, assignment.ClientID).Scan(&lockedClientID); errors.Is(err, sql.ErrNoRows) { return false, false, ErrClientNotFound } else if err != nil { return false, false, fmt.Errorf("检查客户端失败: %w", err) } var exists int if err := tx.QueryRow(`SELECT COUNT(*) FROM users WHERE user_id = ? AND role = ? AND status = ?`, assignment.UserID, model.RolePurchaser, model.UserActive).Scan(&exists); err != nil { return false, false, fmt.Errorf("检查采购员失败: %w", err) } if exists == 0 { return false, false, ErrPurchaserNotActive } var currentID, currentUserID string err = tx.QueryRow(`SELECT assignment_id, user_id FROM client_user_assignments WHERE client_id = ? AND ended_at IS NULL`, assignment.ClientID).Scan(¤tID, ¤tUserID) if err != nil && !errors.Is(err, sql.ErrNoRows) { return false, false, fmt.Errorf("读取当前客户端归属失败: %w", err) } if err == nil && currentUserID == assignment.UserID { return false, false, nil } transferred := err == nil if transferred { if _, err := tx.Exec(`UPDATE client_user_assignments SET ended_at = ?, ended_by_user_id = ?, end_reason = 'transfer' WHERE assignment_id = ? AND ended_at IS NULL`, assignment.StartedAt, assignment.AssignedByUserID, currentID); err != nil { return false, false, fmt.Errorf("结束原客户端归属失败: %w", err) } } if _, err := tx.Exec(`INSERT INTO client_user_assignments (assignment_id, client_id, user_id, started_at, assigned_by_user_id) VALUES (?, ?, ?, ?, ?)`, assignment.AssignmentID, assignment.ClientID, assignment.UserID, assignment.StartedAt, assignment.AssignedByUserID); err != nil { return false, false, fmt.Errorf("保存客户端归属失败: %w", err) } if err := tx.Commit(); err != nil { return false, false, fmt.Errorf("提交客户端绑定事务失败: %w", err) } return true, transferred, nil } // UnassignClient 原子结束当前归属,历史记录保留。 func UnassignClient(db *sql.DB, clientID, actorUserID, endedAt string) error { tx, err := db.Begin() if err != nil { return fmt.Errorf("开始解绑客户端事务失败: %w", err) } defer tx.Rollback() res, err := tx.Exec(`UPDATE client_user_assignments SET ended_at = ?, ended_by_user_id = ?, end_reason = 'unbind' WHERE client_id = ? AND ended_at IS NULL`, endedAt, actorUserID, clientID) if err != nil { return fmt.Errorf("结束客户端归属失败: %w", err) } n, err := res.RowsAffected() if err != nil { return fmt.Errorf("读取解绑结果失败: %w", err) } if n == 0 { return ErrClientNotAssigned } if err := tx.Commit(); err != nil { return fmt.Errorf("提交客户端解绑事务失败: %w", err) } return nil } // DeleteClients 批量删除客户端。返回实际删除的条数。 func DeleteClients(db *sql.DB, clientIDs []string) (int64, error) { if len(clientIDs) == 0 { return 0, nil } // 占位符按数量生成,值仍然是参数化传入,不存在注入 placeholders := strings.TrimSuffix(strings.Repeat("?,", len(clientIDs)), ",") args := make([]any, len(clientIDs)) for i, id := range clientIDs { args[i] = id } res, err := db.Exec( `DELETE FROM clients WHERE client_id IN (`+placeholders+`)`, args...) if err != nil { return 0, fmt.Errorf("删除客户端失败: %w", err) } return res.RowsAffected() }