feat: 新增幂等 Client 登记接口 (#12)

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>
This commit is contained in:
chengma
2026-08-06 17:31:38 +08:00
co-authored by Claude Opus 5
parent 02073eab37
commit 5a5c1f1f68
9 changed files with 516 additions and 79 deletions
+26 -13
View File
@@ -10,35 +10,48 @@ import (
// UpsertClient 登记或更新一台客户端。
//
// 注意 name 的处理:**只在第一次注册时写入,之后不再更新**。
// 这样操作员在 Admin 界面上改成好记的名字后,客户端每次 claim
// 都不会把它覆盖回去。做法是 ON CONFLICT 的 DO UPDATE 里不含 name。
// # 名称的更新规则(两个接口不一样,这是有意的)
//
// name 为空时用 clientID 当显示名,保证列表里不出现空白行。
func UpsertClient(db *sql.DB, c model.Client) error {
// 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 := c.Name
if name == "" {
name = c.ClientID
}
now := model.NowISO()
_, err := db.Exec(`
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, name, c.DeviceAddress, c.Platform,
c.PddPackage, c.Capabilities, now, now, now)
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)
}