Files
cmautobuy/admin/service/profile.go
T
chengmaandClaude Opus 5 5a5c1f1f68 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>
2026-08-06 17:31:38 +08:00

114 lines
3.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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),
}
}