feat: 搭建 Admin 项目骨架(Go + Gin + html/template)

可运行的骨架:四个页面能打开、数据库自动建表、给 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>
This commit is contained in:
chengma
2026-08-06 16:33:45 +08:00
co-authored by Claude Opus 5
parent 0d38eedee1
commit 799ee33045
23 changed files with 1910 additions and 17 deletions
+166
View File
@@ -0,0 +1,166 @@
// 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{},
},
})
}