Files
cmautobuy/admin/service/service.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

191 lines
7.1 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 放业务逻辑。
//
// 本包**不认识 *gin.Context**——这样才能不起服务器就写单元测试。
// handler 负责取参数,service 负责判断和编排,repository 负责读写数据库。
//
// 骨架阶段这里只有函数签名和 TODO,实现按工单逐个补。
package service
import (
"database/sql"
"errors"
"fmt"
"time"
"cmautobuy/admin/model"
"cmautobuy/admin/repository"
)
// ErrNotImplemented 表示该功能还没实现。
// 补完实现后要把对应的返回删掉,不要留着假装能用。
var ErrNotImplemented = errors.New("功能尚未实现")
// ---------- 蝦皮 Excel 导入 ----------
// ImportResult 是一次导入的统计结果,要显示给操作员看。
type ImportResult struct {
ProductCount int // 写入的商品数,样本应为 5195
SKUCount int // 写入的 SKU 数,样本应为 6092
FailedRows []int // 解析失败的行号,**不要静默跳过**
}
// ImportShopeeExcel 解析蝦皮报表并 upsert 进库。
//
// 实现要点见 docs/admin/03-data-model.md §3.3,五步缺一不可:
// 1. 分行:商品規格ID 为 "-" 的是商品汇总行,其余是 SKU 行;
// 2. 按列名找索引,不要写死列号(报表 40 列,蝦皮改格式列号就变);
// 3. 解析规格原文:先按第一个逗号切开,右边再提取建议。
// 实测两种格式各占 53.4% / 46.6%,只认【】会漏掉一半。
// **任何一步失败都不要猜**,parse_ok 置 0,颜色尺码留空,原文照存;
// 4. upsert,**绝不清空**。DO UPDATE SET 里不得出现
// pdd_goods_url / pdd_data / collect_status——报表里没这些列,
// 写进去会把人工填的洗成空;
// 5. 返回统计。
func ImportShopeeExcel(db *sql.DB, path string) (*ImportResult, error) {
// TODO(骨架): 用 github.com/xuri/excelize/v2 流式读行
return nil, ErrNotImplemented
}
// ParseSpec 把蝦皮的规格原文拆成颜色、尺码、建议。
//
// 输入样例(两种格式各占一半):
//
// "黑色,M【建議40-50公斤】" -> 黑色 / M / 40-50公斤 / true
// "卡其色拼黑色,L 建議50-57.5kg" -> 卡其色拼黑色 / L / 50-57.5kg / true
// "莫名其妙的格式" -> "" / "" / "" / false
//
// 最后一个返回值是 ok;false 时前三个必须为空,**不要猜**。
// 调用方要把 parse_ok 存进库,界面上把这些行标出来让人工补。
func ParseSpec(raw string) (color, size, advice string, ok bool) {
// TODO(骨架): 按第一个逗号切开;右边先找【建議...】,再找空格后的 建議...
return "", "", "", false
}
// ---------- 采集 ----------
// CreateCollectTasks 为若干蝦皮商品创建采集任务。
//
// 规则:
// - 按 goods_id **去重**(一个商品有多个 SKU 行,别建重复任务);
// - PDD 链接为空的跳过;
// - collect_status 已是 collecting 的跳过,并在结果里说明跳过了几个;
// - 建任务成功后把 collect_status 置为 collecting。
func CreateCollectTasks(db *sql.DB, goodsIDs []string, clientID string) (created, skipped int, err error) {
// TODO(骨架)
return 0, 0, ErrNotImplemented
}
// ---------- 规格匹配 ----------
// SaveMapping 保存「蝦皮规格 = PDD 规格」的对应关系。
//
// 存的是**可复用的映射**(sku_mappings 表),不是某一张订单的临时数据。
// 下次遇到同一个蝦皮 SKU 自动带出,操作员只需确认。
func SaveMapping(db *sql.DB, shopeeSKUID, goodsID, pddOptionsJSON, operator string) error {
// TODO(骨架): upsert sku_mappings
return ErrNotImplemented
}
// ---------- 采购任务 ----------
// TaskCreateError 说明某一条为什么建不了任务。
// **不要静默跳过**,要把这些列出来告诉操作员缺什么。
type TaskCreateError struct {
SybID string
Reason string // 例如「该商品未填写 PDD 链接」
}
// CreatePurchaseTasks 由货运单创建采购任务。
//
// 六条校验缺一不可(docs/admin/01-requirements.md §5):
// 1. 已填 PDD 链接
// 2. 已采集成功(pdd_data 非空)
// 3. 已有 SKU 映射
// 4. 数量 > 0
// 5. **价格上限已填且 > 0**——默认从 pdd_data 里该 SKU 的价格带出,
// 操作员可改但不允许为空。没有它 Client 会拒绝执行。
// 6. 已选择分配的客户端
//
// 另外 pdd_goods_url 必须写进任务,Client 那边是 NOT NULL。
func CreatePurchaseTasks(db *sql.DB, sybIDs []string, clientID string) (created int, failures []TaskCreateError, err error) {
// TODO(骨架)
return 0, nil, ErrNotImplemented
}
// ---------- 客户端 ----------
// RegisterClient 登记或更新一台客户端。
//
// explicit 的含义见 repository.UpsertClient 的说明:
// true = 设置页点保存(会更新名称),false = claim 顺带(不更新名称)。
func RegisterClient(db repository.Execer, c model.Client, explicit bool) error {
return repository.UpsertClient(db, c, explicit)
}
// RegisterClientProfile 处理设置页发起的**显式登记**。
//
// `[必须]` 本函数**不读取、不领取、不修改任何任务**,也不返回任务。
// 这正是它存在的理由:设置页点"保存"不该顺带把一个任务领走——
// 领走了 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。
//
// claim / result / failure **三个接口都要调**。
// 只在 claim 里调的话,客户端执行长任务期间不调 claim,
// 会被误判成离线。
func TouchClient(db *sql.DB, clientID string) error {
return repository.TouchClient(db, clientID)
}
// ClientView 是客户端列表页要显示的一行。
// Status 是**算出来的**,数据库里没有这个字段。
type ClientView struct {
model.Client
Status string
}
// ListClientViews 查客户端列表,并把在线状态算出来。
func ListClientViews(db *sql.DB, keyword string, threshold time.Duration) ([]ClientView, error) {
clients, err := repository.ListClients(db, keyword)
if err != nil {
return nil, err
}
now := time.Now().UTC()
views := make([]ClientView, 0, len(clients))
for _, c := range clients {
views = append(views, ClientView{
Client: c,
Status: c.StatusText(now, threshold),
})
}
return views, nil
}
// DeleteClients 批量删除,返回实际删除条数。
func DeleteClients(db *sql.DB, clientIDs []string) (int64, error) {
return repository.DeleteClients(db, clientIDs)
}
// ---------- 领取任务 ----------
// ClaimNextTask 为客户端领取一个任务,没有可领的返回 (nil, nil)。
//
// 调用方拿到 nil 要返回 204 No Content,**不是 200 加空对象**。
func ClaimNextTask(db *sql.DB, clientID string, supportedTypes []string) (*model.Task, error) {
return repository.ClaimNextTask(db, clientID, supportedTypes)
}