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:
@@ -52,7 +52,7 @@ func TestRegisterClient_新客户端被登记(t *testing.T) {
|
||||
err := RegisterClient(db, model.Client{
|
||||
ClientID: "client-001", Name: "办公室-01",
|
||||
DeviceAddress: "192.168.0.173:5555", Platform: "android",
|
||||
})
|
||||
}, true)
|
||||
if err != nil {
|
||||
t.Fatalf("注册失败: %v", err)
|
||||
}
|
||||
@@ -75,7 +75,7 @@ func TestRegisterClient_新客户端被登记(t *testing.T) {
|
||||
func TestRegisterClient_没上报名称时用编号兜底(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
|
||||
if err := RegisterClient(db, model.Client{ClientID: "client-002"}); err != nil {
|
||||
if err := RegisterClient(db, model.Client{ClientID: "client-002"}, true); err != nil {
|
||||
t.Fatalf("注册失败: %v", err)
|
||||
}
|
||||
|
||||
@@ -85,42 +85,80 @@ func TestRegisterClient_没上报名称时用编号兜底(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 这条是本工单的重点:操作员改过名字后,客户端再来注册不能覆盖它。
|
||||
func TestRegisterClient_人工改过的名称不被覆盖(t *testing.T) {
|
||||
// 名称规则分两种,下面两个测试各盯一种。规则见 repository.UpsertClient。
|
||||
|
||||
// 隐式登记(claim 顺带):**永远不更新名称**。
|
||||
// 否则操作员在 Admin 精心改的名字,会被客户端每次领取时反复冲掉。
|
||||
func TestRegisterClient_隐式登记不覆盖名称(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
|
||||
// 客户端第一次注册,上报名字是 "默认名"
|
||||
if err := RegisterClient(db, model.Client{
|
||||
ClientID: "client-003", Name: "默认名", Platform: "android",
|
||||
}); err != nil {
|
||||
}, true); err != nil {
|
||||
t.Fatalf("首次注册失败: %v", err)
|
||||
}
|
||||
|
||||
// 操作员在界面上改成好记的名字
|
||||
if _, err := db.Exec(
|
||||
`UPDATE clients SET name = ? WHERE client_id = ?`,
|
||||
// 操作员在 Admin 界面上改成好记的名字
|
||||
if _, err := db.Exec(`UPDATE clients SET name = ? WHERE client_id = ?`,
|
||||
"仓库那台", "client-003"); err != nil {
|
||||
t.Fatalf("人工改名失败: %v", err)
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 客户端再次 claim,又上报了 "默认名"
|
||||
// 客户端后台 claim,又上报了"默认名" —— explicit=false
|
||||
if err := RegisterClient(db, model.Client{
|
||||
ClientID: "client-003", Name: "默认名", Platform: "android",
|
||||
DeviceAddress: "10.0.0.9:5555",
|
||||
}); err != nil {
|
||||
t.Fatalf("再次注册失败: %v", err)
|
||||
}, false); err != nil {
|
||||
t.Fatalf("隐式登记失败: %v", err)
|
||||
}
|
||||
|
||||
views, _ := ListClientViews(db, "", time.Minute)
|
||||
if views[0].Name != "仓库那台" {
|
||||
t.Errorf("人工改的名字被覆盖了:期望 仓库那台,实际 %s", views[0].Name)
|
||||
t.Errorf("claim 不该覆盖名称:期望 仓库那台,实际 %s", views[0].Name)
|
||||
}
|
||||
// 但设备信息应该被更新
|
||||
// 但设备信息要更新
|
||||
if views[0].DeviceAddress != "10.0.0.9:5555" {
|
||||
t.Errorf("设备地址没更新:%s", views[0].DeviceAddress)
|
||||
}
|
||||
}
|
||||
|
||||
// 显式登记(设置页点保存):带非空名称时**更新**名称。
|
||||
// 这是用户明确的人为操作,应该能把新名字同步过去。
|
||||
func TestRegisterClient_显式登记更新名称(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
|
||||
RegisterClient(db, model.Client{ClientID: "client-003", Name: "旧名"}, true)
|
||||
if _, err := db.Exec(`UPDATE clients SET name='仓库那台' WHERE client_id='client-003'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := RegisterClient(db, model.Client{
|
||||
ClientID: "client-003", Name: "办公室-01",
|
||||
}, true); err != nil {
|
||||
t.Fatalf("显式登记失败: %v", err)
|
||||
}
|
||||
|
||||
views, _ := ListClientViews(db, "", time.Minute)
|
||||
if views[0].Name != "办公室-01" {
|
||||
t.Errorf("显式登记应更新名称:期望 办公室-01,实际 %s", views[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
// 显式登记但名称为空:保留原有名称,不要清空。
|
||||
func TestRegisterClient_显式登记空名称保留原名(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
|
||||
RegisterClient(db, model.Client{ClientID: "client-003", Name: "办公室-01"}, true)
|
||||
if err := RegisterClient(db, model.Client{ClientID: "client-003", Name: " "}, true); err != nil {
|
||||
t.Fatalf("登记失败: %v", err)
|
||||
}
|
||||
|
||||
views, _ := ListClientViews(db, "", time.Minute)
|
||||
if views[0].Name != "办公室-01" {
|
||||
t.Errorf("空名称不该清空原名:期望 办公室-01,实际 %q", views[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 在线状态 ───────────────────────────────────────────
|
||||
|
||||
func TestIsOnline_阈值边界(t *testing.T) {
|
||||
@@ -153,7 +191,7 @@ func TestTouchClient_刷新活动时间(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
|
||||
// 造一台很久没活动的客户端
|
||||
if err := RegisterClient(db, model.Client{ClientID: "client-004"}); err != nil {
|
||||
if err := RegisterClient(db, model.Client{ClientID: "client-004"}, true); err != nil {
|
||||
t.Fatalf("注册失败: %v", err)
|
||||
}
|
||||
old := "2020-01-01T00:00:00Z"
|
||||
@@ -182,8 +220,8 @@ func TestTouchClient_刷新活动时间(t *testing.T) {
|
||||
|
||||
func TestListClientViews_按名称搜索(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
RegisterClient(db, model.Client{ClientID: "c-1", Name: "办公室-01"})
|
||||
RegisterClient(db, model.Client{ClientID: "c-2", Name: "仓库-01"})
|
||||
RegisterClient(db, model.Client{ClientID: "c-1", Name: "办公室-01"}, true)
|
||||
RegisterClient(db, model.Client{ClientID: "c-2", Name: "仓库-01"}, true)
|
||||
|
||||
views, err := ListClientViews(db, "办公室", time.Minute)
|
||||
if err != nil {
|
||||
@@ -196,9 +234,9 @@ func TestListClientViews_按名称搜索(t *testing.T) {
|
||||
|
||||
func TestDeleteClients_批量删除(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
RegisterClient(db, model.Client{ClientID: "c-1"})
|
||||
RegisterClient(db, model.Client{ClientID: "c-2"})
|
||||
RegisterClient(db, model.Client{ClientID: "c-3"})
|
||||
RegisterClient(db, model.Client{ClientID: "c-1"}, true)
|
||||
RegisterClient(db, model.Client{ClientID: "c-2"}, true)
|
||||
RegisterClient(db, model.Client{ClientID: "c-3"}, true)
|
||||
|
||||
n, err := DeleteClients(db, []string{"c-1", "c-3"})
|
||||
if err != nil {
|
||||
@@ -218,7 +256,7 @@ func TestDeleteClients_批量删除(t *testing.T) {
|
||||
|
||||
func TestClaimNextTask_没有任务返回nil(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
RegisterClient(db, model.Client{ClientID: "client-001"})
|
||||
RegisterClient(db, model.Client{ClientID: "client-001"}, true)
|
||||
|
||||
task, err := ClaimNextTask(db, "client-001", []string{"collect", "purchase"})
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user