feat(t215): add admin order authorization

This commit is contained in:
QiuSW
2026-07-28 12:50:42 +08:00
parent 16c2ea410a
commit 827afc7257
24 changed files with 2688 additions and 113 deletions
@@ -24,13 +24,15 @@ const (
)
type AdminServices struct {
Assets *usecase.AssetService
Tasks *usecase.TaskService
Results *usecase.ExecutionResultService
Assets *usecase.AssetService
Tasks *usecase.TaskService
Results *usecase.ExecutionResultService
Authorizations *usecase.OrderAuthorizationService
}
func (s AdminServices) validate() error {
if s.Assets == nil || s.Tasks == nil || s.Results == nil {
if s.Assets == nil || s.Tasks == nil || s.Results == nil ||
s.Authorizations == nil {
return errors.New("admin services are required")
}
return nil
@@ -55,6 +57,10 @@ func registerAdminAPI(routes gin.IRoutes, services AdminServices) error {
handler.evidenceContent,
)
routes.POST("/api/v1/tasks/:id/cancel", handler.cancelTask)
routes.POST(
"/api/v1/tasks/:id/order-authorizations",
handler.createOrderAuthorization,
)
return nil
}
@@ -371,13 +377,116 @@ func (h *adminHandlers) taskDetail(ctx *gin.Context) {
"claim": claim,
"execution": execution,
"execution_report": executionReport,
"events": events,
"order_authorizations": orderAuthorizationResponses(
detail.OrderAuthorizations,
),
"events": events,
"assets": []gin.H{
assetResponse(detail.Asset),
},
})
}
func (h *adminHandlers) createOrderAuthorization(ctx *gin.Context) {
var request struct {
ExecutionID string `json:"execution_id"`
TaskContentSHA256 string `json:"task_content_sha256"`
ExpectedTaskVersion int64 `json:"expected_task_version"`
CandidateKey string `json:"candidate_key"`
ReasonSchemaVersion int `json:"reason_schema_version"`
PrimaryReasonCode string `json:"primary_reason_code"`
Note string `json:"note"`
SupersedesAuthorizationID *string `json:"supersedes_authorization_id"`
Items []usecase.OrderAuthorizationItemInput `json:"items"`
}
if err := decodeJSON(ctx, &request); err != nil {
writePublicError(
ctx,
http.StatusBadRequest,
"INVALID_JSON",
"request body must be valid JSON",
false,
gin.H{},
)
return
}
result, err := h.services.Authorizations.Create(
ctx.Request.Context(),
usecase.CreateOrderAuthorizationCommand{
ActorUserID: adminActorUserID(ctx),
TaskID: ctx.Param("id"),
IdempotencyKey: ctx.GetHeader("Idempotency-Key"),
ExecutionID: request.ExecutionID,
TaskContentSHA256: request.TaskContentSHA256,
ExpectedTaskVersion: request.ExpectedTaskVersion,
CandidateKey: request.CandidateKey,
ReasonSchemaVersion: request.ReasonSchemaVersion,
PrimaryReasonCode: request.PrimaryReasonCode,
Note: request.Note,
SupersedesAuthorizationID: request.SupersedesAuthorizationID,
Items: request.Items,
},
)
if err != nil {
writeUsecaseError(ctx, err)
return
}
ctx.Header("Cache-Control", "no-store")
ctx.JSON(http.StatusCreated, gin.H{
"authorization": orderAuthorizationResponse(result.Authorization),
"replayed": result.Replayed,
})
}
func orderAuthorizationResponses(
authorizations []domain.OrderAuthorization,
) []gin.H {
result := make([]gin.H, 0, len(authorizations))
for _, authorization := range authorizations {
result = append(result, orderAuthorizationResponse(authorization))
}
return result
}
func orderAuthorizationResponse(
authorization domain.OrderAuthorization,
) gin.H {
return gin.H{
"id": authorization.ID,
"task_id": authorization.TaskID,
"execution_id": authorization.ExecutionID,
"authorization_version": authorization.AuthorizationVersion,
"candidate_key": authorization.CandidateKey,
"task_content_sha256": authorization.TaskContentSHA256,
"task_version": authorization.TaskVersion,
"review_id": authorization.ReviewID,
"review_version": authorization.ReviewVersion,
"user_id": authorization.UserID,
"device_id": authorization.DeviceID,
"claim_generation": authorization.ClaimGeneration,
"original_sku": authorization.OriginalSKU,
"quantity": authorization.Quantity,
"candidate_sku_text": authorization.CandidateSKUText,
"candidate_price_text": authorization.CandidatePriceText,
"card_signature": authorization.CardSignature,
"detail_signature": authorization.DetailSignature,
"detail_evidence_sha256": authorization.DetailEvidenceSHA256,
"specification_evidence_sha256": authorization.SpecificationEvidenceSHA256,
"status": authorization.Status,
"supersedes_authorization_id": authorization.SupersedesAuthorizationID,
"created_by_user_id": authorization.CreatedByUserID,
"created_at": formatTime(authorization.CreatedAt),
"delivered_at": formatOptionalTime(authorization.DeliveredAt),
"acknowledged_at": formatOptionalTime(authorization.AcknowledgedAt),
"execution_started_at": formatOptionalTime(authorization.ExecutionStartedAt),
"consumed_at": formatOptionalTime(authorization.ConsumedAt),
"failed_at": formatOptionalTime(authorization.FailedAt),
"revoked_at": formatOptionalTime(authorization.RevokedAt),
"failure_code": authorization.FailureCode,
"failure_message": authorization.FailureMessage,
}
}
func executionReportResponse(report *domain.ExecutionReport) gin.H {
events := make([]gin.H, 0, len(report.Events))
for _, event := range report.Events {
@@ -3,7 +3,9 @@ package httpapi
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"fmt"
"image"
"image/color"
"image/jpeg"
@@ -288,6 +290,131 @@ func TestAdminAPIAssetAndTaskLifecycle(t *testing.T) {
}
}
func TestAdminOrderAuthorizationIsIdempotentAndRevisioned(t *testing.T) {
fixture := newAdminIntegrationFixture(t)
taskID, executionID, taskHash, firstKey, secondKey :=
seedAdminAuthorizationTask(t, fixture)
payload := fmt.Sprintf(
`{"execution_id":%q,"task_content_sha256":%q,"expected_task_version":2,"candidate_key":%q,"reason_schema_version":1,"primary_reason_code":"SELECTED_BEST_MATCH","note":"","supersedes_authorization_id":null,"items":[{"candidate_key":%q,"label":"ACCEPT","primary_reason_code":"SKU_MATCH","reason_codes":["SKU_MATCH"],"note":""},{"candidate_key":%q,"label":"REJECT","primary_reason_code":"NOT_BEST_MATCH","reason_codes":["NOT_BEST_MATCH"],"note":""}]}`,
executionID,
taskHash,
firstKey,
firstKey,
secondKey,
)
created := performAdminRequest(
t,
fixture.router,
http.MethodPost,
"/api/v1/tasks/"+taskID+"/order-authorizations",
"application/json",
strings.NewReader(payload),
"authorization-1",
)
if created.Code != http.StatusCreated {
t.Fatalf(
"authorization status/body = %d / %s",
created.Code,
created.Body.String(),
)
}
var createdBody struct {
Authorization struct {
ID string `json:"id"`
Status string `json:"status"`
Version int `json:"authorization_version"`
} `json:"authorization"`
Replayed bool `json:"replayed"`
}
decodeResponse(t, created, &createdBody)
if createdBody.Authorization.ID == "" ||
createdBody.Authorization.Status != "PENDING_DELIVERY" ||
createdBody.Authorization.Version != 1 ||
createdBody.Replayed {
t.Fatalf("authorization response = %+v", createdBody)
}
replayed := performAdminRequest(
t,
fixture.router,
http.MethodPost,
"/api/v1/tasks/"+taskID+"/order-authorizations",
"application/json",
strings.NewReader(payload),
"authorization-1",
)
requireAdminStatus(t, replayed, http.StatusCreated)
if !strings.Contains(replayed.Body.String(), `"replayed":true`) ||
!strings.Contains(
replayed.Body.String(),
createdBody.Authorization.ID,
) {
t.Fatalf("authorization replay = %s", replayed.Body.String())
}
stale := performAdminRequest(
t,
fixture.router,
http.MethodPost,
"/api/v1/tasks/"+taskID+"/order-authorizations",
"application/json",
strings.NewReader(payload),
"authorization-stale",
)
requireAdminStatus(t, stale, http.StatusConflict)
revisedPayload := fmt.Sprintf(
`{"execution_id":%q,"task_content_sha256":%q,"expected_task_version":3,"candidate_key":%q,"reason_schema_version":1,"primary_reason_code":"SELECTED_BEST_MATCH","note":"","supersedes_authorization_id":%q,"items":[{"candidate_key":%q,"label":"REJECT","primary_reason_code":"NOT_BEST_MATCH","reason_codes":["NOT_BEST_MATCH"],"note":""},{"candidate_key":%q,"label":"ACCEPT","primary_reason_code":"IMAGE_MATCH","reason_codes":["IMAGE_MATCH"],"note":""}]}`,
executionID,
taskHash,
secondKey,
createdBody.Authorization.ID,
firstKey,
secondKey,
)
revised := performAdminRequest(
t,
fixture.router,
http.MethodPost,
"/api/v1/tasks/"+taskID+"/order-authorizations",
"application/json",
strings.NewReader(revisedPayload),
"authorization-2",
)
requireAdminStatus(t, revised, http.StatusCreated)
if !strings.Contains(revised.Body.String(), `"authorization_version":2`) ||
!strings.Contains(revised.Body.String(), `"candidate_key":"`+secondKey+`"`) {
t.Fatalf("revised authorization = %s", revised.Body.String())
}
detail := performAdminRequest(
t,
fixture.router,
http.MethodGet,
"/api/v1/tasks/"+taskID,
"",
nil,
"",
)
requireAdminStatus(t, detail, http.StatusOK)
var detailBody map[string]any
decodeResponse(t, detail, &detailBody)
authorizations, _ := detailBody["order_authorizations"].([]any)
if detailBody["version"] != float64(4) || len(authorizations) != 2 ||
!strings.Contains(detail.Body.String(), `"status":"SUPERSEDED"`) ||
!strings.Contains(detail.Body.String(), `"review_version":2`) {
t.Fatalf("authorization detail = %#v", detailBody)
}
runner, err := migration.New(fixture.db)
if err != nil {
t.Fatalf("migration.New() error = %v", err)
}
if err := runner.Down(context.Background()); err == nil {
t.Fatal("order authorization migration down succeeded with retained data")
}
}
func TestAdminRoutesRejectRequestsWithoutAdminSession(t *testing.T) {
router := newAdminIntegrationRouter(t)
request := httptest.NewRequest(http.MethodGet, "/api/v1/tasks", nil)
@@ -307,6 +434,201 @@ func TestAdminRoutesRejectRequestsWithoutAdminSession(t *testing.T) {
}
}
func seedAdminAuthorizationTask(
t *testing.T,
fixture *adminIntegrationFixture,
) (taskID string, executionID string, taskHash string, firstKey string, secondKey string) {
t.Helper()
imageBody, imageContentType := referenceUpload(t, "authorization-asset")
assetResponse := performAdminRequest(
t,
fixture.router,
http.MethodPost,
"/api/v1/assets",
imageContentType,
imageBody,
"authorization-asset",
)
requireAdminStatus(t, assetResponse, http.StatusCreated)
var asset struct {
ID string `json:"id"`
}
decodeResponse(t, assetResponse, &asset)
taskResponse := performAdminRequest(
t,
fixture.router,
http.MethodPost,
"/api/v1/tasks",
"application/json",
strings.NewReader(
`{"title":"后台授权测试商品","sku":"BLACK-L","description":"","image_asset_id":"`+
asset.ID+`","quantity":2,"max_budget":"100.00"}`,
),
"authorization-task",
)
requireAdminStatus(t, taskResponse, http.StatusCreated)
var task struct {
ID string `json:"id"`
}
decodeResponse(t, taskResponse, &task)
const (
buyerID = "00000000-0000-4000-8000-000000000901"
deviceID = "00000000-0000-4000-8000-000000000902"
)
executionID = "00000000-0000-4000-8000-000000000903"
now := time.Now().UTC()
nowText := now.Format(time.RFC3339Nano)
expiryText := now.Add(time.Hour).Format(time.RFC3339Nano)
if _, err := fixture.db.Exec(
`INSERT INTO users (
id, username, password_hash, role, is_active, created_at, updated_at
) VALUES (?, 'buyer-auth-test', 'test-only-hash', 'BUYER', 1, ?, ?)`,
buyerID,
nowText,
nowText,
); err != nil {
t.Fatalf("seed authorization buyer: %v", err)
}
if _, err := fixture.db.Exec(
`INSERT INTO devices (
id, name, token_hash, bound_user_id, app_version,
android_version, pdd_version, last_seen_at, is_enabled,
created_at, updated_at, accessibility_enabled, pdd_installed,
readiness_reported_at
) VALUES (?, 'auth-device', ?, ?, 'test', '16', '8.17.0', ?, 1,
?, ?, 1, 1, ?)`,
deviceID,
strings.Repeat("9", 64),
buyerID,
nowText,
nowText,
nowText,
nowText,
); err != nil {
t.Fatalf("seed authorization device: %v", err)
}
if _, err := fixture.db.Exec(
`UPDATE purchase_tasks SET
status = 'WAITING_CONFIRMATION', version = 2,
claimed_by_user_id = ?, claimed_by_device_id = ?,
claim_generation = 1, claim_token_hash = ?,
claim_issued_at = ?, claim_expires_at = ?, updated_at = ?
WHERE id = ?`,
buyerID,
deviceID,
strings.Repeat("8", 64),
nowText,
expiryText,
nowText,
task.ID,
); err != nil {
t.Fatalf("seed authorization task: %v", err)
}
if _, err := fixture.db.Exec(
`INSERT INTO task_executions (
id, task_id, attempt_no, claim_generation, user_id, device_id,
current_step, last_heartbeat_at, order_submitted, started_at
) VALUES (?, ?, 1, 1, ?, ?, 'WAITING_ADMIN_CONFIRMATION', ?, 0, ?)`,
executionID,
task.ID,
buyerID,
deviceID,
nowText,
nowText,
); err != nil {
t.Fatalf("seed authorization execution: %v", err)
}
store, err := repository.New(fixture.db)
if err != nil {
t.Fatalf("repository.New() error = %v", err)
}
detail, err := store.GetTaskDetail(
context.Background(),
localAdminSubject,
task.ID,
)
if err != nil {
t.Fatalf("GetTaskDetail() error = %v", err)
}
taskHash = usecase.TaskContentSHA256(detail.Task)
firstKey = strings.Repeat("a", 64)
secondKey = strings.Repeat("b", 64)
if _, err := fixture.db.Exec(
`INSERT INTO candidate_search_runs (
execution_id, task_id, task_content_sha256, execution_mode,
search_query, started_at, received_at, observation_count,
collection_complete, received_after_execution_expiry
) VALUES (?, ?, ?, 'MANUAL_FIRST', 'PDD_IMAGE_SEARCH', ?, ?, 2, 1, 0)`,
executionID,
task.ID,
taskHash,
nowText,
nowText,
); err != nil {
t.Fatalf("seed authorization search run: %v", err)
}
if _, err := fixture.db.Exec(
`INSERT INTO candidate_observations (
execution_id, task_id, ordinal, title, sku_text, price_text,
product_url, image_url, evidence_asset_ids_json,
collection_status, observed_at
) VALUES
(?, ?, 1, '候选一', 'BLACK-L', '20.00', '', '', '[]', 'COMPLETE', ?),
(?, ?, 2, '候选二', 'BLACK-L', '22.00', '', '', '[]', 'COMPLETE', ?)`,
executionID,
task.ID,
nowText,
executionID,
task.ID,
nowText,
); err != nil {
t.Fatalf("seed authorization observations: %v", err)
}
if _, err := fixture.db.Exec(
`INSERT INTO candidate_observation_identities (
candidate_key, execution_id, candidate_ordinal, card_signature,
detail_signature, detail_evidence_sha256,
specification_evidence_sha256, identity_version, created_at
) VALUES
(?, ?, 1, ?, ?, ?, ?, 1, ?),
(?, ?, 2, ?, ?, ?, ?, 1, ?)`,
firstKey,
executionID,
strings.Repeat("c", 64),
strings.Repeat("d", 64),
strings.Repeat("e", 64),
strings.Repeat("f", 64),
nowText,
secondKey,
executionID,
strings.Repeat("1", 64),
strings.Repeat("2", 64),
strings.Repeat("3", 64),
strings.Repeat("4", 64),
nowText,
); err != nil {
t.Fatalf("seed authorization identities: %v", err)
}
return task.ID, executionID, taskHash, firstKey, secondKey
}
func requireAdminStatus(
t *testing.T,
response *httptest.ResponseRecorder,
want int,
) {
t.Helper()
if response.Code != want {
t.Fatalf(
"status/body = %d / %s, want %d",
response.Code,
response.Body.String(),
want,
)
}
}
func TestAdminAssetUploadRequiresIdempotencyKey(t *testing.T) {
router := newAdminIntegrationRouter(t)
imageBody, imageContentType := referenceUpload(t, "missing-key")
@@ -337,6 +659,16 @@ type emptyAdminWeb struct{}
func (emptyAdminWeb) RegisterProtected(gin.IRoutes) {}
func newAdminIntegrationRouter(t *testing.T) http.Handler {
t.Helper()
return newAdminIntegrationFixture(t).router
}
type adminIntegrationFixture struct {
router http.Handler
db *sql.DB
}
func newAdminIntegrationFixture(t *testing.T) *adminIntegrationFixture {
t.Helper()
ctx := context.Background()
db, err := database.Open(ctx, filepath.Join(t.TempDir(), "admin.db"))
@@ -385,8 +717,21 @@ func newAdminIntegrationRouter(t *testing.T) http.Handler {
if err != nil {
t.Fatalf("usecase.NewExecutionResultService() error = %v", err)
}
authorizations, err := usecase.NewOrderAuthorizationService(
repositories,
clock,
ids,
)
if err != nil {
t.Fatalf("usecase.NewOrderAuthorizationService() error = %v", err)
}
registrar, err := NewAdminRouteRegistrar(
AdminServices{Assets: assets, Tasks: tasks, Results: results},
AdminServices{
Assets: assets,
Tasks: tasks,
Results: results,
Authorizations: authorizations,
},
emptyAdminWeb{},
)
if err != nil {
@@ -404,7 +749,7 @@ func newAdminIntegrationRouter(t *testing.T) http.Handler {
if err != nil {
t.Fatalf("NewRouter() error = %v", err)
}
return router
return &adminIntegrationFixture{router: router, db: db}
}
func referenceUpload(t *testing.T, key string) (io.Reader, string) {
@@ -629,6 +629,24 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
if !strings.Contains(candidateReplay.Body.String(), `"replayed":true`) {
t.Fatalf("candidate replay response = %s", candidateReplay.Body.String())
}
detail, err = fixture.tasks.Get(context.Background(), "local-admin", taskID)
if err != nil {
t.Fatalf("get task after candidate upload: %v", err)
}
readyEvents := 0
for _, event := range detail.Events {
if event.Type == "CANDIDATES_READY" {
readyEvents++
}
}
if detail.Task.Status != domain.TaskStatusWaitingConfirmation ||
readyEvents != 1 {
t.Fatalf(
"candidate upload status/events = %s/%+v",
detail.Task.Status,
detail.Events,
)
}
humanReviewPayload := fmt.Sprintf(
`{"execution_id":%q,"claim_generation":%d,"task_content_sha256":%q,"reason_schema_version":1,"outcome":"CANDIDATE_ACCEPTED","selected_candidate_ordinal":1,"primary_reason_code":"SELECTED_BEST_MATCH","note":"","items":[{"candidate_ordinal":1,"label":"ACCEPT","primary_reason_code":"SKU_MATCH","reason_codes":["SKU_MATCH"],"note":""}]}`,
@@ -696,7 +714,7 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
t.Fatalf("migration.New() after review error = %v", err)
}
if err := runner.Down(context.Background()); err == nil {
t.Fatal("candidate identity migration down succeeded with retained data")
t.Fatal("order workflow migration down succeeded with retained data")
}
completePayload := fmt.Sprintf(