feat: 实现采购运行时规格解析 (#255)
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user