PUT /api/v1/client/registration —— 设置页点"保存"时调用, 只登记客户端,不碰任务。 为什么需要它 原设计"注册就在 claim 里做"有个真问题:设置页保存被迫调 claim, 而 claim 可能真的领到一个任务——Admin 那边已把任务标成 claimed, Client 必须可靠落库否则任务就丢了。一个"保存设置"的动作 不该承担"领取任务并保证不丢"的责任。这违反了本项目自己的原则 (05 §1:界面上只有一个会产生外部后果的命令)。 实现 - ClientProfileRequest + Validate() 由**登记和领取共用**, 避免两个入口的结构和校验各写一份、迟早漂移 - 校验:名称 <=50 字(按字符不按字节,中文一个字三字节)、 supported_types 非空且只含 collect/purchase、platform 只支持 android、 purchase_mode 必填且只允许 dry_run/live、schema_versions 均为正整数 - 非法内容返回 422 INVALID_CLIENT_PROFILE,错误消息指明具体字段 - UpsertClient 加 explicit 参数区分名称规则: 显式登记(用户点保存)带非空名称时更新名称; 隐式登记(claim 顺带)永不更新,否则操作员改的名字会被反复冲掉 已验证(Go 1.23.0) - 单元测试 40 个全过,含"登记不产生任何任务副作用"的快照比对 - 端到端逐条走完手册 §5.2~5.7:重复登记记录数恒为 1; 更新/空名称行为正确;插入任务后登记 3 次任务字段完全未变且仍可领取; 四种非法输入均 422 且不写库;claim 不受影响 一处行为变更需注意 名称归属规则改了:原来是"Admin 操作员永远赢",现在是"最后一次 显式操作赢"——用户在 Client 点保存会覆盖 Admin 侧改的名字。 按 #12 文档实现,已拆成三个独立测试盯住三种情况。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
135 lines
4.4 KiB
Go
135 lines
4.4 KiB
Go
package repository
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"cmautobuy/admin/model"
|
|
)
|
|
|
|
// 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()
|
|
}
|
|
|
|
// 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()
|
|
}
|