356 lines
13 KiB
Go
356 lines
13 KiB
Go
package repository
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"cmautobuy/admin/model"
|
|
)
|
|
|
|
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 CONFLICT(client_id) DO UPDATE SET
|
|
name = CASE WHEN ? THEN ? ELSE clients.name END,
|
|
device_address = excluded.device_address,
|
|
platform = excluded.platform,
|
|
pdd_package = excluded.pdd_package,
|
|
capabilities = excluded.capabilities,
|
|
last_seen_at = excluded.last_seen_at,
|
|
updated_at = excluded.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 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 统计同一筛选和权限范围内的在线客户端。
|
|
// julianday 解析失败会得到 NULL,自然按离线处理,与 model.Client.IsOnline 一致。
|
|
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 julianday(c.last_seen_at) > julianday(?)`
|
|
} else {
|
|
query += ` AND julianday(c.last_seen_at) > julianday(?)`
|
|
}
|
|
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 exists int
|
|
if err := tx.QueryRow(`SELECT COUNT(*) FROM clients WHERE client_id = ?`, assignment.ClientID).Scan(&exists); err != nil {
|
|
return false, false, fmt.Errorf("检查客户端失败: %w", err)
|
|
}
|
|
if exists == 0 {
|
|
return false, false, ErrClientNotFound
|
|
}
|
|
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()
|
|
}
|