可运行的骨架:四个页面能打开、数据库自动建表、给 Client 的三个接口
按契约响应。业务逻辑留 35 处 TODO(骨架),每处标明要点和文档章节。
结构(对应 docs/admin/02-architecture.md §2/§3)
- handler/web + handler/api 分开:页面要 CSRF、出错渲染错误页;
接口要认证、出错返回 JSON。混在一起迟早写错
- service 不认识 *gin.Context,方便不起服务器直接单测
- 只有 repository 能写 SQL
- 模板用 {{define "目录/名字"}},一次全部 ParseFS 进来不冲突
- 模板和静态资源用 //go:embed 打进二进制
已实测(Go 1.23.0,本机验证)
- go build / go vet / gofmt 全部通过
- 单文件产物 34MB,无需 cgo
- 四个页面 + 静态资源均 200,/ 正确 302 到 /shopee
- 建表 7 张 + 索引 10 个,user_version=1,二次启动不重复建表
- claim 带 X-Client-Id 返回 204(无任务可领),
缺 X-Client-Id 返回 400 + 契约格式的 JSON 错误体
依赖版本被 Go 1.23.0 卡死,已钉住并写进文档
- gin v1.11.0 (v1.12.0 起要求 Go >= 1.25.0)
- modernc.org/sqlite v1.38.0(v1.40.0 起要求 >= 1.24.0,v1.48.0 起 >= 1.25.0)
- excelize v2.9.1 (尚未入 go.mod,写导入功能时再加)
直接 go get 不带版本会拉到最新版并报 requires go >= 1.25.0,
所以文档里的命令一律带版本号。要升依赖就得先升 Go。
一处主动调整:迁移语句从「一个字符串装多条 SQL」改成 [][]string 逐条执行。
database/sql 的 Exec 对一次多条语句的支持因驱动而异,拆开最稳妥,
报错还能精确到第几条。
说明:Gitea 尚未配置,本次无对应工单号。
admin/testdata/ 只有 README,脱敏小样本需人工制作。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
167 lines
5.8 KiB
Go
167 lines
5.8 KiB
Go
// Package api 是给 Client 用的 JSON 接口。
|
||
//
|
||
// **权威契约在 Client 那边**:docs/client/04-admin-api-contract.md。
|
||
// 本包只是实现,两边说法不一致时以 Client 契约为准,要改先走工单。
|
||
//
|
||
// 一共只有三个接口,**不得新增"让 Client 查询状态"类接口**,
|
||
// 也不得加回租约和心跳,理由见 docs/admin/04-client-api.md §1。
|
||
package api
|
||
|
||
import (
|
||
"database/sql"
|
||
"net/http"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
)
|
||
|
||
// Handler 持有接口共用的依赖。
|
||
type Handler struct {
|
||
db *sql.DB
|
||
}
|
||
|
||
// Register 挂上三个接口。
|
||
func Register(r *gin.Engine, db *sql.DB) {
|
||
h := &Handler{db: db}
|
||
|
||
g := r.Group("/api/v1/client")
|
||
{
|
||
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"`
|
||
}
|
||
|
||
// Claim 领取一个任务。
|
||
//
|
||
// 处理顺序(见 docs/admin/04-client-api.md §2):
|
||
// 1. 先做客户端注册/更新——**注册就在这里做,没有单独的注册接口**;
|
||
// 2. 查 assigned_client = X-Client-Id 且 status = 'assigned' 的任务,取一条;
|
||
// 3. 没有返回 204 No Content(不是 200 加空对象);
|
||
// 4. 有就用条件更新把状态改成 claimed,检查影响行数防并发。
|
||
//
|
||
// 响应**不含租约**,也不含 Admin 侧状态。
|
||
func (h *Handler) Claim(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 ClaimRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
apiError(c, http.StatusBadRequest, "INVALID_BODY",
|
||
"请求体不是合法 JSON", false)
|
||
return
|
||
}
|
||
|
||
// TODO(骨架): 1. service.RegisterClient(h.db, clientID, req)
|
||
// 新 client_id 就新增,已有就更新 device/capabilities/last_seen_at。
|
||
// name 若已被人工改过,不要覆盖。
|
||
|
||
// TODO(骨架): 2. service.ClaimNextTask(h.db, clientID)
|
||
// 只返回分配给这个客户端的任务,一次一条。
|
||
// UPDATE ... WHERE task_id = ? AND status = 'assigned'
|
||
// 检查影响行数,为 0 说明被抢先了,取下一条。
|
||
|
||
// 新客户端第一次来必然拿不到任务(还没人给它分配),返回 204 是正常的。
|
||
c.Status(http.StatusNoContent)
|
||
}
|
||
|
||
// SubmitResult 接收成功结果。
|
||
//
|
||
// **最容易写错的一条**(docs/admin/04-client-api.md §4.1):
|
||
// - 不得因为任务已取消而拒绝;
|
||
// - 不得因为任务已重派给别的客户端而拒绝;
|
||
// - 必须能接受同一任务来自多个客户端的多份结果;
|
||
// - 只有**从未分配给该客户端**的任务才返回 403。
|
||
//
|
||
// 原因:Client 中途不查任务状态,所以它必然会提交一些
|
||
// "Admin 这边已经不要了"的结果。而它可能真的已经下单了,
|
||
// 这些数据必须能交上来留痕。
|
||
//
|
||
// accepted: true 的意思是"我收到并存下了",不代表任务还算数。
|
||
func (h *Handler) SubmitResult(c *gin.Context) {
|
||
taskID := c.Param("task_id")
|
||
idemKey := c.GetHeader("Idempotency-Key")
|
||
if idemKey == "" {
|
||
apiError(c, http.StatusBadRequest, "MISSING_IDEMPOTENCY_KEY",
|
||
"缺少 Idempotency-Key 请求头", false)
|
||
return
|
||
}
|
||
|
||
// TODO(骨架): 1. 查 idempotency_keys:
|
||
// 键同、内容哈希同 -> 直接返回上次的 response_body,不重复落库
|
||
// 键同、内容哈希不同 -> 409 IDEMPOTENCY_CONFLICT
|
||
// TODO(骨架): 2. 在**同一个事务**里:
|
||
// 写 tasks.result_data;
|
||
// 采集任务同时写 shopee_products.pdd_data 并置 collect_status = collected;
|
||
// tasks.status = 'succeeded';
|
||
// 写 idempotency_keys。
|
||
// TODO(骨架): 3. 刷新客户端 last_seen_at
|
||
// (长任务期间不调 claim,不刷新会被误判成离线)。
|
||
|
||
_ = taskID
|
||
apiError(c, http.StatusNotImplemented, "NOT_IMPLEMENTED",
|
||
"提交结果接口尚未实现", false)
|
||
}
|
||
|
||
// SubmitFailure 接收失败或需人工处理的结果。
|
||
//
|
||
// Client 报告的 status 只会是 retry_wait / manual_review / failed / cancelled,
|
||
// 按下表落地(docs/admin/04-client-api.md §5):
|
||
//
|
||
// retry_wait -> assigned(放回去,等它再来领)
|
||
// manual_review -> manual_review
|
||
// failed -> failed
|
||
// cancelled -> cancelled
|
||
//
|
||
// §4.1 的无条件接受规则同样适用于本接口。
|
||
func (h *Handler) SubmitFailure(c *gin.Context) {
|
||
taskID := c.Param("task_id")
|
||
|
||
// TODO(骨架): 同 SubmitResult 的幂等处理;
|
||
// 采集任务失败时同步把 shopee_products.collect_status 置 failed,
|
||
// 错误信息写 collect_error,界面上要看得见。
|
||
|
||
_ = taskID
|
||
apiError(c, http.StatusNotImplemented, "NOT_IMPLEMENTED",
|
||
"提交失败接口尚未实现", false)
|
||
}
|
||
|
||
// apiError 按契约格式返回错误。
|
||
//
|
||
// 接口出错返回 JSON,页面出错渲染错误页,两者不要混。
|
||
// 格式见 docs/client/04-admin-api-contract.md §3。
|
||
func apiError(c *gin.Context, status int, code, message string, retryable bool) {
|
||
c.JSON(status, gin.H{
|
||
"error": gin.H{
|
||
"code": code,
|
||
"message": message,
|
||
"retryable": retryable,
|
||
"request_id": c.GetHeader("X-Request-Id"),
|
||
"details": gin.H{},
|
||
},
|
||
})
|
||
}
|