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>
114 lines
3.8 KiB
Go
114 lines
3.8 KiB
Go
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),
|
||
}
|
||
}
|