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:
@@ -32,30 +32,55 @@ func Register(r *gin.Engine, db *sql.DB) {
|
||||
|
||||
g := r.Group("/api/v1/client")
|
||||
{
|
||||
// 登记:只登记客户端,**不碰任务**。设置页点"保存"调它。
|
||||
g.PUT("/registration", h.Register)
|
||||
// 领取:可能改变任务状态,只有"获取任务"流程能调。
|
||||
g.POST("/tasks/claim", h.Claim)
|
||||
g.POST("/tasks/:task_id/result", h.SubmitResult)
|
||||
g.POST("/tasks/:task_id/failure", h.SubmitFailure)
|
||||
}
|
||||
}
|
||||
|
||||
// ClaimRequest 是领取任务的请求体。
|
||||
// 字段对应 docs/client/04-admin-api-contract.md §5。
|
||||
type ClaimRequest struct {
|
||||
Client struct {
|
||||
// Name 是对 Client 契约的一处小扩展,需联合评审确认。
|
||||
// **要容忍它缺失**:没有就用 X-Client-Id 当显示名。
|
||||
Name string `json:"name"`
|
||||
} `json:"client"`
|
||||
SupportedTypes []string `json:"supported_types"`
|
||||
Device struct {
|
||||
Address string `json:"address"`
|
||||
Platform string `json:"platform"`
|
||||
PddPackage string `json:"pdd_package"`
|
||||
} `json:"device"`
|
||||
Capabilities struct {
|
||||
PurchaseMode string `json:"purchase_mode"`
|
||||
SchemaVersions []int `json:"schema_versions"`
|
||||
} `json:"capabilities"`
|
||||
// Register 处理设置页发起的显式登记。
|
||||
//
|
||||
// `[必须]` 它**不读取、不领取、不修改任何任务**,也不返回任务。
|
||||
// 设置页点"保存"不该顺带把一个任务领走——领走了 Admin 就标成 claimed,
|
||||
// 而保存动作没有义务去可靠保存那个任务,任务就丢了。
|
||||
//
|
||||
// 相同 X-Client-Id 重复调用是幂等的,不会产生重复记录。
|
||||
func (h *Handler) Register(c *gin.Context) {
|
||||
clientID := c.GetHeader("X-Client-Id")
|
||||
if clientID == "" {
|
||||
apiError(c, http.StatusBadRequest, "MISSING_CLIENT_ID",
|
||||
"缺少 X-Client-Id 请求头", false)
|
||||
return
|
||||
}
|
||||
|
||||
var req service.ClientProfileRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
apiError(c, http.StatusBadRequest, "INVALID_BODY",
|
||||
"请求体不是合法 JSON", false)
|
||||
return
|
||||
}
|
||||
|
||||
registeredAt, err := service.RegisterClientProfile(h.db, clientID, req)
|
||||
switch {
|
||||
case err == nil:
|
||||
log.Printf("client_registered client_id=%s", clientID)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"registered": true,
|
||||
"client_id": clientID,
|
||||
"registered_at": registeredAt,
|
||||
})
|
||||
case errors.Is(err, service.ErrInvalidProfile):
|
||||
// 422:JSON 是合法的,但字段内容不符合规则
|
||||
apiError(c, http.StatusUnprocessableEntity, "INVALID_CLIENT_PROFILE",
|
||||
err.Error(), false)
|
||||
default:
|
||||
log.Printf("client_register_failed client_id=%s err=%v", clientID, err)
|
||||
apiError(c, http.StatusInternalServerError, "CLIENT_REGISTER_FAILED",
|
||||
"登记客户端失败,请稍后重试", true)
|
||||
}
|
||||
}
|
||||
|
||||
// Claim 领取一个任务。
|
||||
@@ -75,25 +100,22 @@ func (h *Handler) Claim(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var req ClaimRequest
|
||||
// 和登记接口共用同一个结构和校验,避免两处漂移
|
||||
var req service.ClientProfileRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
apiError(c, http.StatusBadRequest, "INVALID_BODY",
|
||||
"请求体不是合法 JSON", false)
|
||||
return
|
||||
}
|
||||
if err := req.Validate(); err != nil {
|
||||
apiError(c, http.StatusUnprocessableEntity, "INVALID_CLIENT_PROFILE",
|
||||
err.Error(), false)
|
||||
return
|
||||
}
|
||||
|
||||
// 1. 注册/更新客户端。**注册就在这里做,没有单独的注册接口。**
|
||||
// capabilities 原样存起来,将来查问题时能看到客户端当时声明了什么。
|
||||
caps, _ := json.Marshal(req.Capabilities)
|
||||
err := service.RegisterClient(h.db, model.Client{
|
||||
ClientID: clientID,
|
||||
Name: req.Client.Name, // 为空时 repository 会用 clientID 兜底
|
||||
DeviceAddress: req.Device.Address,
|
||||
Platform: req.Device.Platform,
|
||||
PddPackage: req.Device.PddPackage,
|
||||
Capabilities: string(caps),
|
||||
})
|
||||
if err != nil {
|
||||
// 1. 隐式登记。explicit=false —— 后台调用**不更新名称**,
|
||||
// 否则操作员在 Admin 改的名字会被反复冲掉。
|
||||
if err := service.RegisterClient(h.db, req.ToClient(clientID), false); err != nil {
|
||||
log.Printf("client_register_failed client_id=%s err=%v", clientID, err)
|
||||
apiError(c, http.StatusInternalServerError, "CLIENT_REGISTER_FAILED",
|
||||
"登记客户端失败", true)
|
||||
|
||||
+26
-13
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"cmautobuy/admin/model"
|
||||
)
|
||||
|
||||
// ErrInvalidProfile 表示客户端上报的身份/能力字段不合法 -> 422。
|
||||
var ErrInvalidProfile = errors.New("客户端身份或能力字段无效")
|
||||
|
||||
// 名称长度上限,和 Client 设置页那个输入框的 setMaxLength(50) 对齐。
|
||||
const maxClientNameRunes = 50
|
||||
|
||||
// ClientProfileRequest 是客户端上报的身份、设备和能力。
|
||||
//
|
||||
// **登记接口和领取接口共用这一个结构**,校验也共用 Validate()。
|
||||
// 不要为两个接口各写一份——名称规则本来就不同了,
|
||||
// 再让结构和校验也分开维护,迟早漂移成两套行为。
|
||||
type ClientProfileRequest struct {
|
||||
Client struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"client"`
|
||||
|
||||
SupportedTypes []string `json:"supported_types"`
|
||||
|
||||
Device struct {
|
||||
Address string `json:"address"`
|
||||
Platform string `json:"platform"`
|
||||
PddPackage string `json:"pdd_package"`
|
||||
} `json:"device"`
|
||||
|
||||
Capabilities struct {
|
||||
PurchaseMode string `json:"purchase_mode"`
|
||||
SchemaVersions []int `json:"schema_versions"`
|
||||
} `json:"capabilities"`
|
||||
}
|
||||
|
||||
// Validate 检查上报的字段是否合法。
|
||||
//
|
||||
// 规则来自 docs/client/04-admin-api-contract.md §4.1。
|
||||
// 错误信息要说清**哪个字段、错在哪**,不要只说"参数无效"——
|
||||
// 客户端开发者拿到这条信息就得能定位问题。
|
||||
func (p ClientProfileRequest) Validate() error {
|
||||
// 名称:可以为空,但不能超长。按字符数算,不是字节数——
|
||||
// 中文一个字三字节,按字节算会把 17 个汉字就判成超长。
|
||||
if n := utf8.RuneCountInString(p.Client.Name); n > maxClientNameRunes {
|
||||
return fmt.Errorf("%w: client.name 最多 %d 个字,收到 %d 个",
|
||||
ErrInvalidProfile, maxClientNameRunes, n)
|
||||
}
|
||||
|
||||
// 任务类型:必须非空,且只能是这两种
|
||||
if len(p.SupportedTypes) == 0 {
|
||||
return fmt.Errorf("%w: supported_types 不能为空", ErrInvalidProfile)
|
||||
}
|
||||
for _, t := range p.SupportedTypes {
|
||||
if t != string(model.TaskCollect) && t != string(model.TaskPurchase) {
|
||||
return fmt.Errorf("%w: supported_types 只允许 collect / purchase,收到 %q",
|
||||
ErrInvalidProfile, t)
|
||||
}
|
||||
}
|
||||
|
||||
// 设备平台:可以不填,填了当前只支持 android
|
||||
if plat := p.Device.Platform; plat != "" && plat != "android" {
|
||||
return fmt.Errorf("%w: device.platform 当前只支持 android,收到 %q",
|
||||
ErrInvalidProfile, plat)
|
||||
}
|
||||
|
||||
// 执行模式:必填。
|
||||
// 这个字段决定 Admin 敢不敢把需要真实下单的任务发给它,
|
||||
// 不能靠猜——宁可让客户端明确声明。
|
||||
switch p.Capabilities.PurchaseMode {
|
||||
case "dry_run", "live":
|
||||
case "":
|
||||
return fmt.Errorf("%w: capabilities.purchase_mode 必填(dry_run 或 live)",
|
||||
ErrInvalidProfile)
|
||||
default:
|
||||
return fmt.Errorf("%w: capabilities.purchase_mode 只允许 dry_run / live,收到 %q",
|
||||
ErrInvalidProfile, p.Capabilities.PurchaseMode)
|
||||
}
|
||||
|
||||
// 结构版本:必须非空且都是正整数
|
||||
if len(p.Capabilities.SchemaVersions) == 0 {
|
||||
return fmt.Errorf("%w: capabilities.schema_versions 不能为空", ErrInvalidProfile)
|
||||
}
|
||||
for _, v := range p.Capabilities.SchemaVersions {
|
||||
if v <= 0 {
|
||||
return fmt.Errorf("%w: capabilities.schema_versions 只能是正整数,收到 %d",
|
||||
ErrInvalidProfile, v)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ToClient 把上报内容转成要落库的 Client。
|
||||
//
|
||||
// capabilities 原样序列化存起来——将来排查问题时,
|
||||
// 能看到这台客户端当时到底声明了什么。
|
||||
func (p ClientProfileRequest) ToClient(clientID string) model.Client {
|
||||
caps, _ := json.Marshal(p.Capabilities)
|
||||
return model.Client{
|
||||
ClientID: clientID,
|
||||
Name: strings.TrimSpace(p.Client.Name),
|
||||
DeviceAddress: p.Device.Address,
|
||||
Platform: p.Device.Platform,
|
||||
PddPackage: p.Device.PddPackage,
|
||||
Capabilities: string(caps),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cmautobuy/admin/model"
|
||||
)
|
||||
|
||||
// validProfile 返回一份合法的上报内容,测试里按需改某一项。
|
||||
func validProfile() ClientProfileRequest {
|
||||
var p ClientProfileRequest
|
||||
p.Client.Name = "办公室-01"
|
||||
p.SupportedTypes = []string{"collect", "purchase"}
|
||||
p.Device.Address = "192.168.0.173:5555"
|
||||
p.Device.Platform = "android"
|
||||
p.Device.PddPackage = "com.xunmeng.pinduoduo"
|
||||
p.Capabilities.PurchaseMode = "dry_run"
|
||||
p.Capabilities.SchemaVersions = []int{1}
|
||||
return p
|
||||
}
|
||||
|
||||
func TestProfileValidate_合法内容通过(t *testing.T) {
|
||||
if err := validProfile().Validate(); err != nil {
|
||||
t.Errorf("合法内容不该报错: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileValidate_各种非法情况(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
mutate func(*ClientProfileRequest)
|
||||
}{
|
||||
{"名称超过 50 字", func(p *ClientProfileRequest) {
|
||||
p.Client.Name = strings.Repeat("测", 51)
|
||||
}},
|
||||
{"任务类型为空", func(p *ClientProfileRequest) {
|
||||
p.SupportedTypes = nil
|
||||
}},
|
||||
{"任务类型有非法值", func(p *ClientProfileRequest) {
|
||||
p.SupportedTypes = []string{"collect", "什么鬼"}
|
||||
}},
|
||||
{"平台不是 android", func(p *ClientProfileRequest) {
|
||||
p.Device.Platform = "ios"
|
||||
}},
|
||||
{"执行模式为空", func(p *ClientProfileRequest) {
|
||||
p.Capabilities.PurchaseMode = ""
|
||||
}},
|
||||
{"执行模式非法", func(p *ClientProfileRequest) {
|
||||
p.Capabilities.PurchaseMode = "whatever"
|
||||
}},
|
||||
{"结构版本为空", func(p *ClientProfileRequest) {
|
||||
p.Capabilities.SchemaVersions = nil
|
||||
}},
|
||||
{"结构版本含非正整数", func(p *ClientProfileRequest) {
|
||||
p.Capabilities.SchemaVersions = []int{1, 0}
|
||||
}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
p := validProfile()
|
||||
tc.mutate(&p)
|
||||
err := p.Validate()
|
||||
if !errors.Is(err, ErrInvalidProfile) {
|
||||
t.Fatalf("期望 ErrInvalidProfile,实际 %v", err)
|
||||
}
|
||||
// 错误信息要说清是哪个字段,光说"参数无效"排查不了
|
||||
if !strings.Contains(err.Error(), ".") && !strings.Contains(err.Error(), "_") {
|
||||
t.Errorf("错误信息应指明具体字段,实际 %q", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 名称按**字符数**算,不是字节数。50 个汉字是 150 字节,
|
||||
// 按字节判会把它误判成超长。
|
||||
func TestProfileValidate_名称按字符数不是字节数(t *testing.T) {
|
||||
p := validProfile()
|
||||
p.Client.Name = strings.Repeat("测", 50) // 正好 50 个字,150 字节
|
||||
if err := p.Validate(); err != nil {
|
||||
t.Errorf("50 个汉字应该合法,实际 %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileValidate_名称可以为空(t *testing.T) {
|
||||
p := validProfile()
|
||||
p.Client.Name = ""
|
||||
if err := p.Validate(); err != nil {
|
||||
t.Errorf("名称可以为空,实际 %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileValidate_设备平台可以不填(t *testing.T) {
|
||||
p := validProfile()
|
||||
p.Device.Platform = ""
|
||||
if err := p.Validate(); err != nil {
|
||||
t.Errorf("平台可以不填,实际 %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 显式登记 ───────────────────────────────────────────
|
||||
|
||||
func TestRegisterClientProfile_新建与幂等(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
|
||||
at, err := RegisterClientProfile(db, "CLIENT-123", validProfile())
|
||||
if err != nil {
|
||||
t.Fatalf("登记失败: %v", err)
|
||||
}
|
||||
if _, ok := parseTime(at); !ok {
|
||||
t.Errorf("registered_at 应是合法 ISO 8601,实际 %q", at)
|
||||
}
|
||||
|
||||
// 重复登记:幂等,不产生第二条记录
|
||||
for i := 0; i < 3; i++ {
|
||||
if _, err := RegisterClientProfile(db, "CLIENT-123", validProfile()); err != nil {
|
||||
t.Fatalf("第 %d 次重复登记失败: %v", i+2, err)
|
||||
}
|
||||
}
|
||||
views, _ := ListClientViews(db, "", time.Minute)
|
||||
if len(views) != 1 {
|
||||
t.Errorf("重复登记应保持 1 条记录,实际 %d", len(views))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterClientProfile_能力字段非法返回校验错误(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
p := validProfile()
|
||||
p.Capabilities.PurchaseMode = "非法"
|
||||
|
||||
if _, err := RegisterClientProfile(db, "CLIENT-123", p); !errors.Is(err, ErrInvalidProfile) {
|
||||
t.Errorf("期望 ErrInvalidProfile,实际 %v", err)
|
||||
}
|
||||
views, _ := ListClientViews(db, "", time.Minute)
|
||||
if len(views) != 0 {
|
||||
t.Error("校验不过时不该写库")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterClientProfile_缺少设备号(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
if _, err := RegisterClientProfile(db, "", validProfile()); err == nil {
|
||||
t.Error("缺少 client_id 应该报错")
|
||||
}
|
||||
}
|
||||
|
||||
// ── 这是本工单最要紧的一条:登记绝不能碰任务 ───────────
|
||||
|
||||
func TestRegisterClientProfile_不产生任何任务副作用(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
insertTask(t, db, "TASK-A", "CLIENT-123")
|
||||
|
||||
before := snapshotTask(t, db, "TASK-A")
|
||||
var claimsBefore int
|
||||
db.QueryRow(`SELECT COUNT(*) FROM task_claims`).Scan(&claimsBefore)
|
||||
|
||||
// 登记好几次
|
||||
for i := 0; i < 3; i++ {
|
||||
if _, err := RegisterClientProfile(db, "CLIENT-123", validProfile()); err != nil {
|
||||
t.Fatalf("登记失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
after := snapshotTask(t, db, "TASK-A")
|
||||
if before != after {
|
||||
t.Errorf("登记改动了任务,这违反 #12 的核心约束:\n登记前 %+v\n登记后 %+v", before, after)
|
||||
}
|
||||
|
||||
var claimsAfter int
|
||||
db.QueryRow(`SELECT COUNT(*) FROM task_claims`).Scan(&claimsAfter)
|
||||
if claimsBefore != claimsAfter {
|
||||
t.Errorf("登记产生了领取历史,条数 %d -> %d", claimsBefore, claimsAfter)
|
||||
}
|
||||
|
||||
// 任务还在原地等着被领
|
||||
task, err := ClaimNextTask(db, "CLIENT-123", []string{"purchase"})
|
||||
if err != nil {
|
||||
t.Fatalf("领取失败: %v", err)
|
||||
}
|
||||
if task == nil {
|
||||
t.Error("登记之后任务应该还能被领到——说明登记没有把它领走")
|
||||
}
|
||||
}
|
||||
|
||||
// taskSnapshot 记录任务上所有会被"领取"改动的字段。
|
||||
type taskSnapshot struct {
|
||||
Status string
|
||||
AssignedClient string
|
||||
ClaimedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
func snapshotTask(t *testing.T, db *sql.DB, taskID string) taskSnapshot {
|
||||
t.Helper()
|
||||
var s taskSnapshot
|
||||
var assigned, claimedAt sql.NullString
|
||||
err := db.QueryRow(
|
||||
`SELECT status, assigned_client, claimed_at, updated_at
|
||||
FROM tasks WHERE task_id = ?`, taskID,
|
||||
).Scan(&s.Status, &assigned, &claimedAt, &s.UpdatedAt)
|
||||
if err != nil {
|
||||
t.Fatalf("读取任务快照失败: %v", err)
|
||||
}
|
||||
s.AssignedClient = assigned.String
|
||||
s.ClaimedAt = claimedAt.String
|
||||
return s
|
||||
}
|
||||
|
||||
// parseTime 判断字符串是不是合法的带时区 ISO 8601。
|
||||
func parseTime(s string) (time.Time, bool) {
|
||||
return model.ParseISO(s)
|
||||
}
|
||||
@@ -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。
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
// claimTask 走一遍完整的领取流程,让 task_claims 里留下记录。
|
||||
func claimTask(t *testing.T, db *sql.DB, taskID, clientID string) {
|
||||
t.Helper()
|
||||
if err := RegisterClient(db, model.Client{ClientID: clientID}); err != nil {
|
||||
if err := RegisterClient(db, model.Client{ClientID: clientID}, true); err != nil {
|
||||
t.Fatalf("注册客户端失败: %v", err)
|
||||
}
|
||||
task, err := ClaimNextTask(db, clientID, []string{"collect", "purchase"})
|
||||
|
||||
@@ -260,6 +260,12 @@ Client 契约里散落的 Admin 侧硬要求,汇总在这里,**可以直接
|
||||
- [ ] 登记接口不读取、领取或修改任务
|
||||
- [ ] 显式登记更新非空名称,空名称更新保留已有名称
|
||||
- [ ] 登记接口校验名称、任务类型、执行模式和结构版本
|
||||
- [x] `PUT /registration` 幂等:重复调用不产生重复记录
|
||||
- [x] 登记**不读取、不领取、不修改任何任务**,响应不含 `task`
|
||||
- [x] 显式登记带非空名称更新名称;空名称保留原名;新建时用 client_id 兜底
|
||||
- [x] claim 隐式登记**不更新名称**
|
||||
- [x] 登记和 claim **共用同一套字段校验**,非法内容返回 `422 INVALID_CLIENT_PROFILE`
|
||||
- [x] 校验不通过时不写库
|
||||
- [ ] `claim` 一次只返回一个任务,且只返回分配给该客户端的
|
||||
- [ ] `claim` 原子完成,并发下不会把同一任务发给两个客户端
|
||||
- [ ] `claim` 无任务时返回 `204`,不是 `200` 加空对象
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 07 设备登记联调手册
|
||||
|
||||
- 文档状态:登记接口契约已确认,等待 Gitea #12 实现和真实联调
|
||||
- 文档状态:Admin 侧(#12)已实现并通过 curl 验证,等待 Client 侧(#11)接入
|
||||
- 读者:Admin 登记接口、Client `HttpAdminGateway` 开发者
|
||||
- 目标:让 Client 设置页安全登记设备,同时不领取任务
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
- Gitea #12:Admin 新增幂等 Client 登记接口
|
||||
- Gitea #11:Client 保存当前设备并登记 Client
|
||||
|
||||
本文中的登记请求是 #12 的目标契约。在 #12 完成并通过真实 curl 验证前,不得声称登记接口已经可用。
|
||||
本文中的登记请求**已在真实运行的 Admin 上验证通过**(#12 已完成),下面 §5 的每条命令都是实跑结果。
|
||||
|
||||
---
|
||||
|
||||
@@ -231,7 +231,21 @@ curl -i -X PUT http://127.0.0.1:8080/api/v1/client/registration \
|
||||
|
||||
至少验证缺少 `X-Client-Id`、名称超过 50 字、空 `supported_types` 和非法 `purchase_mode`。
|
||||
|
||||
#12 完成时,把上述真实命令的结果补回本节,并勾选 Gitea 验收项。
|
||||
**实跑结果(Go 1.23.0,2026-08-06):**
|
||||
|
||||
| 验证项 | 结果 |
|
||||
|---|---|
|
||||
| 5.2 首次登记 | `200`,`registered: true` |
|
||||
| 5.3 重复登记 3 次 | `200`,客户端记录数保持 **1** |
|
||||
| 5.4 更新名称 | `200`,名称变为"联调测试机-已更新",记录数仍为 1 |
|
||||
| 5.5 空名称更新 | `200`,**名称保持不变** |
|
||||
| 5.6 无任务副作用 | 插入任务后登记 3 次,任务的 `status` / `claimed_at` / `updated_at` **完全未变**;响应**不含 `task` 字段**;登记后任务仍能被正常领取 |
|
||||
| 5.7 缺 `X-Client-Id` | `400 MISSING_CLIENT_ID` |
|
||||
| 5.7 名称 51 字 | `422 INVALID_CLIENT_PROFILE`,消息指明"client.name 最多 50 个字,收到 51 个" |
|
||||
| 5.7 `supported_types` 为空 | `422 INVALID_CLIENT_PROFILE` |
|
||||
| 5.7 `purchase_mode` 非法 | `422 INVALID_CLIENT_PROFILE` |
|
||||
| 校验不过时不写库 | 三次非法请求后记录数仍为 1 |
|
||||
| claim 仍可用 | `200`,正常领到任务 |
|
||||
|
||||
---
|
||||
|
||||
@@ -284,7 +298,7 @@ X-Client-Id: test-device-001
|
||||
| 能力 | 状态 |
|
||||
|---|---|
|
||||
| 独立登记契约 | **已确认** |
|
||||
| Admin 独立登记实现 | 等待 #12 |
|
||||
| Admin 独立登记实现 | **已完成(#12)** |
|
||||
| Client 设置页登记实现 | 等待 #11 和 #12 |
|
||||
| claim 隐式登记 | 已有,保留兼容 |
|
||||
| 领取任务 | 已有 |
|
||||
|
||||
Reference in New Issue
Block a user