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)
|
||||
|
||||
Reference in New Issue
Block a user