feat: 实现采购运行时规格解析 (#255)

This commit is contained in:
chengma
2026-08-17 17:20:07 +08:00
parent a699ec8132
commit de36cfd0b5
10 changed files with 1347 additions and 42 deletions
+64 -5
View File
@@ -3,7 +3,7 @@
// **权威契约在 Client 那边**:docs/client/04-admin-api-contract.md。
// 本包只是实现,两边说法不一致时以 Client 契约为准,要改先走工单。
//
// 一共只有三个接口,**不得新增"让 Client 查询状态"类接口**,
// 接口都是一次性命令,**不得新增"让 Client 查询状态"类接口**,
// 也不得加回租约和心跳,理由见 docs/admin/04-client-api.md §1。
package api
@@ -23,12 +23,14 @@ import (
// Handler 持有接口共用的依赖。
type Handler struct {
db *sql.DB
db *sql.DB
aiSecrets service.AISecretStore
aiPolicy service.AIEndpointPolicy
}
// Register 挂上三个接口。
func Register(r *gin.Engine, db *sql.DB) {
h := &Handler{db: db}
// Register 挂上 Client 命令接口。
func Register(r *gin.Engine, db *sql.DB, aiSecrets service.AISecretStore, aiPolicy service.AIEndpointPolicy) {
h := &Handler{db: db, aiSecrets: aiSecrets, aiPolicy: aiPolicy}
g := r.Group("/api/v1/client")
{
@@ -38,6 +40,7 @@ func Register(r *gin.Engine, db *sql.DB) {
g.POST("/tasks/claim", h.Claim)
g.POST("/tasks/:task_id/result", h.SubmitResult)
g.POST("/tasks/:task_id/failure", h.SubmitFailure)
g.POST("/tasks/:task_id/spec-resolution", h.ResolvePurchaseSpec)
}
}
@@ -203,6 +206,62 @@ func (h *Handler) SubmitFailure(c *gin.Context) {
h.handleSubmit(c, service.SubmitFailure, "task_failure_received")
}
// ResolvePurchaseSpec 接收 Client 当前 PDD 页面上的可购买尺码快照。
// 这是一次性决策命令,不查询或修改任务状态,也不会绕过 Client 的下单复核门禁。
func (h *Handler) ResolvePurchaseSpec(c *gin.Context) {
taskID := c.Param("task_id")
clientID := c.GetHeader("X-Client-Id")
if clientID == "" {
apiError(c, http.StatusBadRequest, "MISSING_CLIENT_ID", "缺少 X-Client-Id 请求头", false)
return
}
idempotencyKey := c.GetHeader("Idempotency-Key")
if idempotencyKey == "" {
apiError(c, http.StatusBadRequest, "MISSING_IDEMPOTENCY_KEY", "缺少 Idempotency-Key 请求头", false)
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, service.MaxPurchaseSpecResolutionBodyBytes)
rawBody, err := io.ReadAll(c.Request.Body)
if err != nil {
apiError(c, http.StatusBadRequest, "INVALID_BODY", "请求体不是合法 JSON 或超过 64 KiB", false)
return
}
responseJSON, err := service.ResolvePurchaseSpec(c.Request.Context(), h.db, h.aiSecrets, h.aiPolicy,
taskID, clientID, idempotencyKey, rawBody)
switch {
case err == nil:
log.Printf("purchase_spec_resolved client_id=%s task_id=%s", clientID, taskID)
c.Data(http.StatusOK, "application/json; charset=utf-8", []byte(responseJSON))
case errors.Is(err, service.ErrInvalidSpecResolutionBody):
apiError(c, http.StatusBadRequest, "INVALID_BODY", "请求体不是合法 JSON 或超过 64 KiB", false)
case errors.Is(err, service.ErrInvalidSpecResolutionSchema):
apiError(c, http.StatusBadRequest, "INVALID_SPEC_RESOLUTION_SCHEMA", "不支持的规格解析 schema_version", false)
case errors.Is(err, service.ErrInvalidSpecResolutionRequest):
apiError(c, http.StatusUnprocessableEntity, "INVALID_SPEC_RESOLUTION_REQUEST", "规格解析字段或任务目标规格无效", false)
case errors.Is(err, service.ErrSpecResolutionHashMismatch):
apiError(c, http.StatusUnprocessableEntity, "SPEC_RESOLUTION_HASH_MISMATCH", "候选快照哈希或幂等键不一致", false)
case errors.Is(err, service.ErrTaskNotFound):
apiError(c, http.StatusNotFound, "TASK_NOT_FOUND", "任务不存在", false)
case errors.Is(err, service.ErrTaskNotPurchase):
apiError(c, http.StatusUnprocessableEntity, "TASK_NOT_PURCHASE", "任务不是采购任务", false)
case errors.Is(err, service.ErrTaskVersionConflict):
apiError(c, http.StatusConflict, "TASK_VERSION_CONFLICT", "任务版本已变化", false)
case errors.Is(err, service.ErrPDDGoodsMismatch):
apiError(c, http.StatusUnprocessableEntity, "PDD_GOODS_MISMATCH", "PDD 商品与任务不一致", false)
case errors.Is(err, service.ErrNeverClaimed):
apiError(c, http.StatusForbidden, "TASK_NOT_CLAIMED_BY_CLIENT", "该 Client 从未领取过此任务", false)
case errors.Is(err, service.ErrIdempotencyConflict):
apiError(c, http.StatusConflict, "IDEMPOTENCY_CONFLICT", "相同幂等身份提交了不同内容", false)
case errors.Is(err, service.ErrSpecResolutionUnavailable):
log.Printf("purchase_spec_unavailable client_id=%s task_id=%s err=%v", clientID, taskID, err)
apiError(c, http.StatusServiceUnavailable, "SPEC_RESOLUTION_UNAVAILABLE", "规格解析服务暂不可用,请有限重试原请求", true)
default:
log.Printf("purchase_spec_failed client_id=%s task_id=%s err=%v", clientID, taskID, err)
apiError(c, http.StatusInternalServerError, "SPEC_RESOLUTION_FAILED", "规格解析失败,请稍后重试", true)
}
}
// submitFunc 是两个提交接口共用的处理函数形状。
type submitFunc func(db *sql.DB, taskID, clientID, idemKey string, rawBody []byte) (string, error)
+24
View File
@@ -1,9 +1,16 @@
package api
import (
"bytes"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"cmautobuy/admin/model"
"cmautobuy/admin/service"
)
func TestTaskPayload_包含不可变执行模式(t *testing.T) {
@@ -12,3 +19,20 @@ func TestTaskPayload_包含不可变执行模式(t *testing.T) {
t.Fatalf("execution_mode=%v", payload["execution_mode"])
}
}
func TestResolvePurchaseSpec_请求体上限在进入数据库前拒绝(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
Register(router, nil, nil, service.AIEndpointPolicy{})
body := bytes.Repeat([]byte("x"), service.MaxPurchaseSpecResolutionBodyBytes+1)
request := httptest.NewRequest(http.MethodPost,
"/api/v1/client/tasks/cg255/spec-resolution", bytes.NewReader(body))
request.Header.Set("X-Client-Id", "CLIENT-255")
request.Header.Set("Idempotency-Key", "spec-resolution-v1:"+strings.Repeat("0", 64))
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest || !strings.Contains(recorder.Body.String(), `"code":"INVALID_BODY"`) {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
}