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:
@@ -9,6 +9,7 @@ package service
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"cmautobuy/admin/model"
|
||||
@@ -113,16 +114,31 @@ func CreatePurchaseTasks(db *sql.DB, sybIDs []string, clientID string) (created
|
||||
|
||||
// ---------- 客户端 ----------
|
||||
|
||||
// RegisterClient 在客户端领取任务时登记或更新它。
|
||||
// RegisterClient 登记或更新一台客户端。
|
||||
//
|
||||
// **没有单独的注册接口,也没有心跳**——注册就在 claim 里做,
|
||||
// 理由见 docs/admin/04-client-api.md §3。
|
||||
// explicit 的含义见 repository.UpsertClient 的说明:
|
||||
// true = 设置页点保存(会更新名称),false = claim 顺带(不更新名称)。
|
||||
func RegisterClient(db repository.Execer, c model.Client, explicit bool) error {
|
||||
return repository.UpsertClient(db, c, explicit)
|
||||
}
|
||||
|
||||
// RegisterClientProfile 处理设置页发起的**显式登记**。
|
||||
//
|
||||
// 关于 name:**只在第一次注册时写入,之后不再更新**。
|
||||
// 这样操作员在界面上改成好记的名字后,客户端每次 claim
|
||||
// 都不会把它覆盖回去。客户端没上报 name 时用 clientID 当显示名。
|
||||
func RegisterClient(db *sql.DB, c model.Client) error {
|
||||
return repository.UpsertClient(db, c)
|
||||
// `[必须]` 本函数**不读取、不领取、不修改任何任务**,也不返回任务。
|
||||
// 这正是它存在的理由:设置页点"保存"不该顺带把一个任务领走——
|
||||
// 领走了 Admin 就把任务标成 claimed 了,而保存动作没有义务
|
||||
// 去可靠保存那个任务,任务就丢了。
|
||||
func RegisterClientProfile(db *sql.DB, clientID string, p ClientProfileRequest) (string, error) {
|
||||
if clientID == "" {
|
||||
return "", fmt.Errorf("client_id 不能为空")
|
||||
}
|
||||
if err := p.Validate(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := RegisterClient(db, p.ToClient(clientID), true); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return model.NowISO(), nil
|
||||
}
|
||||
|
||||
// TouchClient 刷新 last_seen_at。
|
||||
|
||||
Reference in New Issue
Block a user