diff --git a/backend-api/cmd/api/main.go b/backend-api/cmd/api/main.go index 281e372..bfb8fc5 100644 --- a/backend-api/cmd/api/main.go +++ b/backend-api/cmd/api/main.go @@ -189,6 +189,14 @@ func buildRouter( if err != nil { return nil, err } + authorizations, err := usecase.NewOrderAuthorizationService( + store, + clock, + ids, + ) + if err != nil { + return nil, err + } passwords, err := password.NewBcrypt(12) if err != nil { return nil, err @@ -214,7 +222,11 @@ func buildRouter( return nil, err } - webService, err := webui.NewUsecaseAdapter(tasks, assets) + webService, err := webui.NewUsecaseAdapter( + tasks, + assets, + authorizations, + ) if err != nil { return nil, err } @@ -255,9 +267,10 @@ func buildRouter( } registerAdminRoutes, err := httpapi.NewAdminRouteRegistrar( httpapi.AdminServices{ - Assets: assets, - Tasks: tasks, - Results: results, + Assets: assets, + Tasks: tasks, + Results: results, + Authorizations: authorizations, }, webHandler, ) diff --git a/backend-api/internal/domain/task.go b/backend-api/internal/domain/task.go index 3842fea..4ec5c24 100644 --- a/backend-api/internal/domain/task.go +++ b/backend-api/internal/domain/task.go @@ -117,6 +117,7 @@ type ExecutionCandidateBatch struct { ProvenanceJSON *string CandidatesJSON string RecommendationJSON *string + ReadyEvent *TaskEvent ReceivedAt time.Time ReceivedAfterExecutionExpiry bool } @@ -253,6 +254,54 @@ type CandidateDecisionDataset struct { HumanReviews []CandidateHumanReview } +type OrderAuthorizationStatus string + +const ( + OrderAuthorizationPendingDelivery OrderAuthorizationStatus = "PENDING_DELIVERY" + OrderAuthorizationDelivered OrderAuthorizationStatus = "DELIVERED" + OrderAuthorizationAcknowledged OrderAuthorizationStatus = "ACKNOWLEDGED" + OrderAuthorizationExecuting OrderAuthorizationStatus = "EXECUTING" + OrderAuthorizationConsumed OrderAuthorizationStatus = "CONSUMED" + OrderAuthorizationFailed OrderAuthorizationStatus = "FAILED" + OrderAuthorizationRevoked OrderAuthorizationStatus = "REVOKED" + OrderAuthorizationSuperseded OrderAuthorizationStatus = "SUPERSEDED" +) + +type OrderAuthorization struct { + ID string + TaskID string + ExecutionID string + AuthorizationVersion int + CandidateKey string + TaskContentSHA256 string + TaskVersion int64 + ReviewID string + ReviewVersion int + UserID string + DeviceID string + ClaimGeneration int64 + OriginalSKU string + Quantity int + CandidateSKUText string + CandidatePriceText string + CardSignature string + DetailSignature string + DetailEvidenceSHA256 string + SpecificationEvidenceSHA256 string + Status OrderAuthorizationStatus + SupersedesAuthorizationID *string + CreatedByUserID string + CreatedAt time.Time + DeliveredAt *time.Time + AcknowledgedAt *time.Time + ExecutionStartedAt *time.Time + ConsumedAt *time.Time + FailedAt *time.Time + RevokedAt *time.Time + FailureCode *string + FailureMessage *string +} + type ExecutionReport struct { Events []ExecutionEvent EvidenceAssets []ExecutionEvidenceAsset @@ -262,11 +311,12 @@ type ExecutionReport struct { } type TaskDetail struct { - Task PurchaseTask - Asset Asset - Execution *TaskExecution - Events []TaskEvent - Report *ExecutionReport + Task PurchaseTask + Asset Asset + Execution *TaskExecution + Events []TaskEvent + Report *ExecutionReport + OrderAuthorizations []OrderAuthorization } type TaskValidationError struct { diff --git a/backend-api/internal/platform/migration/claims_migration_test.go b/backend-api/internal/platform/migration/claims_migration_test.go index 0f76fb5..1a2b453 100644 --- a/backend-api/internal/platform/migration/claims_migration_test.go +++ b/backend-api/internal/platform/migration/claims_migration_test.go @@ -34,8 +34,11 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) { if applied, err := runner.Up(ctx); err != nil { t.Fatalf("initial Up() error = %v", err) - } else if applied != 7 { - t.Fatalf("initial Up() applied = %d, want 7", applied) + } else if applied != 8 { + t.Fatalf("initial Up() applied = %d, want 8", applied) + } + if err := runner.Down(ctx); err != nil { + t.Fatalf("initial Down(v8) error = %v", err) } if err := runner.Down(ctx); err != nil { t.Fatalf("initial Down(v7) error = %v", err) @@ -50,9 +53,14 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) { seedClaimsHistoricalFixture(t, db) if applied, err := runner.Up(ctx); err != nil { - t.Fatalf("Up(v5-v7) over historical data error = %v", err) - } else if applied != 3 { - t.Fatalf("Up(v5-v7) applied = %d, want 3", applied) + t.Fatalf("Up(v5-v8) over historical data error = %v", err) + } else if applied != 4 { + t.Fatalf("Up(v5-v8) applied = %d, want 4", applied) + } + assertClaimsHistory(t, db, true) + + if err := runner.Down(ctx); err != nil { + t.Fatalf("Down(v8) with compatible history error = %v", err) } assertClaimsHistory(t, db, true) @@ -77,9 +85,9 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) { assertClaimsHistory(t, db, false) if applied, err := runner.Up(ctx); err != nil { - t.Fatalf("final Up(v4-v7) error = %v", err) - } else if applied != 4 { - t.Fatalf("final Up(v4-v7) applied = %d, want 4", applied) + t.Fatalf("final Up(v4-v8) error = %v", err) + } else if applied != 5 { + t.Fatalf("final Up(v4-v8) applied = %d, want 5", applied) } assertClaimsHistory(t, db, true) } @@ -315,6 +323,9 @@ func TestClaimsMigrationDownFailsClosedForNewAuditData(t *testing.T) { t.Fatalf("insert v4 audit event: %v", err) } + if err := runner.Down(ctx); err != nil { + t.Fatalf("Down(v8) error = %v", err) + } if err := runner.Down(ctx); err != nil { t.Fatalf("Down(v7) error = %v", err) } diff --git a/backend-api/internal/platform/migration/runner_test.go b/backend-api/internal/platform/migration/runner_test.go index 08edcd1..e4a0dc5 100644 --- a/backend-api/internal/platform/migration/runner_test.go +++ b/backend-api/internal/platform/migration/runner_test.go @@ -27,8 +27,8 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) { if err != nil { t.Fatalf("Up() error = %v", err) } - if applied != 7 { - t.Fatalf("Up() applied = %d, want 7", applied) + if applied != 8 { + t.Fatalf("Up() applied = %d, want 8", applied) } assertStatuses(t, runner, map[int64]bool{ 1: true, @@ -38,6 +38,7 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) { 5: true, 6: true, 7: true, + 8: true, }) applied, err = runner.Up(context.Background()) @@ -58,7 +59,8 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) { 4: true, 5: true, 6: true, - 7: false, + 7: true, + 8: false, }) applied, err = runner.Up(context.Background()) @@ -76,6 +78,7 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) { 5: true, 6: true, 7: true, + 8: true, }) } diff --git a/backend-api/internal/repository/sqlite/auth_repository_test.go b/backend-api/internal/repository/sqlite/auth_repository_test.go index 939a9a5..d37a423 100644 --- a/backend-api/internal/repository/sqlite/auth_repository_test.go +++ b/backend-api/internal/repository/sqlite/auth_repository_test.go @@ -383,6 +383,9 @@ func TestAuthMigrationCanRollbackWithoutRebuildingPurchaseTasks( if err != nil { t.Fatalf("migration.New() error = %v", err) } + if err := runner.Down(context.Background()); err != nil { + t.Fatalf("Down(v8) error = %v", err) + } if err := runner.Down(context.Background()); err != nil { t.Fatalf("Down(v7) error = %v", err) } @@ -411,9 +414,9 @@ func TestAuthMigrationCanRollbackWithoutRebuildingPurchaseTasks( t.Fatal("purchase_tasks was lost during auth migration rollback") } if applied, err := runner.Up(context.Background()); err != nil { - t.Fatalf("Up(v3-v7) error = %v", err) - } else if applied != 5 { - t.Fatalf("Up(v3-v7) applied = %d, want 5", applied) + t.Fatalf("Up(v3-v8) error = %v", err) + } else if applied != 6 { + t.Fatalf("Up(v3-v8) applied = %d, want 6", applied) } } diff --git a/backend-api/internal/repository/sqlite/execution_result_repository.go b/backend-api/internal/repository/sqlite/execution_result_repository.go index aafff3c..7bbeacf 100644 --- a/backend-api/internal/repository/sqlite/execution_result_repository.go +++ b/backend-api/internal/repository/sqlite/execution_result_repository.go @@ -192,6 +192,33 @@ func (s *Store) StoreExecutionCandidates( ); err != nil { return false, err } + if candidate.ReadyEvent != nil && !expired { + result, err := tx.ExecContext( + ctx, + `UPDATE purchase_tasks + SET status = 'WAITING_CONFIRMATION', + version = version + 1, + updated_at = ? + WHERE id = ? AND status = 'RUNNING' + AND version = ? AND cancel_requested_at IS NULL`, + formatTimestamp(write.Now), + write.TaskID, + task.Version, + ) + if err != nil { + return false, repositoryFailure(err) + } + affected, err := result.RowsAffected() + if err != nil { + return false, repositoryFailure(err) + } + if affected != 1 { + return false, usecase.ErrTaskStateConflict + } + if err := insertTaskEvent(ctx, tx, *candidate.ReadyEvent); err != nil { + return false, err + } + } if err := insertExecutionResultRequest(ctx, tx, write, nil); err != nil { return false, err } diff --git a/backend-api/internal/repository/sqlite/order_authorization_repository.go b/backend-api/internal/repository/sqlite/order_authorization_repository.go new file mode 100644 index 0000000..a8c9f67 --- /dev/null +++ b/backend-api/internal/repository/sqlite/order_authorization_repository.go @@ -0,0 +1,684 @@ +package sqlite + +import ( + "context" + "database/sql" + "errors" + + "cmroubao/backend-api/internal/domain" + "cmroubao/backend-api/internal/usecase" +) + +func (s *Store) CreateOrderAuthorization( + ctx context.Context, + write usecase.CreateOrderAuthorizationWrite, +) (domain.OrderAuthorization, bool, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return domain.OrderAuthorization{}, false, repositoryFailure(err) + } + defer func() { _ = tx.Rollback() }() + + existing, found, err := lookupOrderAuthorizationRequest( + ctx, + tx, + write.Command.ActorUserID, + write.Command.IdempotencyKey, + ) + if err != nil { + return domain.OrderAuthorization{}, false, err + } + if found { + if existing.RequestSHA256 != write.RequestSHA256 || + existing.TaskID != write.Command.TaskID { + return domain.OrderAuthorization{}, false, usecase.ErrIdempotencyConflict + } + authorization, err := getOrderAuthorization( + ctx, + tx, + existing.AuthorizationID, + ) + if err != nil { + return domain.OrderAuthorization{}, false, err + } + if err := tx.Commit(); err != nil { + return domain.OrderAuthorization{}, false, repositoryFailure(err) + } + return authorization, true, nil + } + + task, err := getTaskByID( + ctx, + tx, + localAdminSubject, + write.Command.TaskID, + ) + if err != nil { + return domain.OrderAuthorization{}, false, err + } + if task.Status != domain.TaskStatusWaitingConfirmation || + task.Version != write.Command.ExpectedTaskVersion || + task.CancelRequestedAt != nil || + task.ClaimedByUserID == nil || + task.ClaimedByDeviceID == nil || + task.ClaimExpiresAt == nil || + !write.Now.Before(*task.ClaimExpiresAt) || + usecase.TaskContentSHA256(task) != write.Command.TaskContentSHA256 { + return domain.OrderAuthorization{}, false, usecase.ErrTaskStateConflict + } + + var executionUserID, executionDeviceID string + var executionClaimGeneration int64 + var executionFinishedAt sql.NullString + err = tx.QueryRowContext( + ctx, + `SELECT user_id, device_id, claim_generation, finished_at + FROM task_executions + WHERE id = ? AND task_id = ?`, + write.Command.ExecutionID, + write.Command.TaskID, + ).Scan( + &executionUserID, + &executionDeviceID, + &executionClaimGeneration, + &executionFinishedAt, + ) + if errors.Is(err, sql.ErrNoRows) { + return domain.OrderAuthorization{}, false, usecase.ErrTaskStateConflict + } + if err != nil { + return domain.OrderAuthorization{}, false, repositoryFailure(err) + } + if executionFinishedAt.Valid || + executionUserID != *task.ClaimedByUserID || + executionDeviceID != *task.ClaimedByDeviceID || + executionClaimGeneration != task.ClaimGeneration { + return domain.OrderAuthorization{}, false, usecase.ErrTaskStateConflict + } + + observations, err := orderAuthorizationObservations( + ctx, + tx, + write.Command.TaskID, + write.Command.ExecutionID, + ) + if err != nil { + return domain.OrderAuthorization{}, false, err + } + if len(observations) == 0 || + len(observations) != len(write.Command.Items) { + return domain.OrderAuthorization{}, false, usecase.ErrTaskStateConflict + } + byKey := make(map[string]orderAuthorizationObservation, len(observations)) + for _, observation := range observations { + byKey[observation.CandidateKey] = observation + } + selected, found := byKey[write.Command.CandidateKey] + if !found { + return domain.OrderAuthorization{}, false, usecase.ErrTaskStateConflict + } + for _, item := range write.Command.Items { + if _, found := byKey[item.CandidateKey]; !found { + return domain.OrderAuthorization{}, false, usecase.ErrTaskStateConflict + } + for _, reason := range item.ReasonCodes { + if task.MaxBudgetCents == nil && + (reason == "PRICE_ACCEPTABLE" || reason == "PRICE_TOO_HIGH") { + return domain.OrderAuthorization{}, false, usecase.ErrTaskStateConflict + } + } + } + + activeID, activeStatus, err := activeOrderAuthorization( + ctx, + tx, + write.Command.ExecutionID, + ) + if err != nil { + return domain.OrderAuthorization{}, false, err + } + switch { + case activeID == nil && write.Command.SupersedesAuthorizationID != nil: + return domain.OrderAuthorization{}, false, usecase.ErrTaskStateConflict + case activeID != nil && + (write.Command.SupersedesAuthorizationID == nil || + *write.Command.SupersedesAuthorizationID != *activeID || + activeStatus != domain.OrderAuthorizationPendingDelivery): + return domain.OrderAuthorization{}, false, usecase.ErrTaskStateConflict + } + if activeID != nil { + result, err := tx.ExecContext( + ctx, + `UPDATE order_authorizations + SET status = 'SUPERSEDED' + WHERE id = ? AND status = 'PENDING_DELIVERY'`, + *activeID, + ) + if err != nil { + return domain.OrderAuthorization{}, false, repositoryFailure(err) + } + affected, err := result.RowsAffected() + if err != nil { + return domain.OrderAuthorization{}, false, repositoryFailure(err) + } + if affected != 1 { + return domain.OrderAuthorization{}, false, usecase.ErrTaskStateConflict + } + } + + latestReviewID, latestReviewVersion, err := latestCandidateHumanReview( + ctx, + tx, + write.Command.ExecutionID, + ) + if err != nil { + return domain.OrderAuthorization{}, false, err + } + review := domain.CandidateHumanReview{ + ID: write.ReviewID, + TaskID: write.Command.TaskID, + ExecutionID: write.Command.ExecutionID, + TaskContentSHA256: write.Command.TaskContentSHA256, + Version: latestReviewVersion + 1, + ReasonSchemaVersion: write.Command.ReasonSchemaVersion, + Outcome: "CANDIDATE_ACCEPTED", + SelectedCandidateOrdinal: &selected.Ordinal, + PrimaryReasonCode: write.Command.PrimaryReasonCode, + Note: write.Command.Note, + SupersedesReviewID: latestReviewID, + ActorUserID: write.Command.ActorUserID, + CreatedAt: write.Now, + Items: make([]domain.CandidateHumanReviewItem, 0, len(write.Command.Items)), + } + for _, item := range write.Command.Items { + observation := byKey[item.CandidateKey] + review.Items = append(review.Items, domain.CandidateHumanReviewItem{ + CandidateOrdinal: observation.Ordinal, + Label: item.Label, + PrimaryReasonCode: item.PrimaryReasonCode, + ReasonCodes: append([]string(nil), item.ReasonCodes...), + Note: item.Note, + }) + } + if err := insertAdminCandidateReview(ctx, tx, review); err != nil { + return domain.OrderAuthorization{}, false, err + } + + authorizationVersion, err := nextOrderAuthorizationVersion( + ctx, + tx, + write.Command.ExecutionID, + ) + if err != nil { + return domain.OrderAuthorization{}, false, err + } + authorization := domain.OrderAuthorization{ + ID: write.AuthorizationID, + TaskID: task.ID, + ExecutionID: write.Command.ExecutionID, + AuthorizationVersion: authorizationVersion, + CandidateKey: selected.CandidateKey, + TaskContentSHA256: write.Command.TaskContentSHA256, + TaskVersion: task.Version + 1, + ReviewID: review.ID, + ReviewVersion: review.Version, + UserID: executionUserID, + DeviceID: executionDeviceID, + ClaimGeneration: executionClaimGeneration, + OriginalSKU: task.SKU, + Quantity: task.Quantity, + CandidateSKUText: selected.SKUText, + CandidatePriceText: selected.PriceText, + CardSignature: selected.CardSignature, + DetailSignature: selected.DetailSignature, + DetailEvidenceSHA256: selected.DetailEvidenceSHA256, + SpecificationEvidenceSHA256: selected.SpecificationEvidenceSHA256, + Status: domain.OrderAuthorizationPendingDelivery, + SupersedesAuthorizationID: write.Command.SupersedesAuthorizationID, + CreatedByUserID: write.Command.ActorUserID, + CreatedAt: write.Now, + } + if err := insertOrderAuthorization(ctx, tx, authorization); err != nil { + return domain.OrderAuthorization{}, false, err + } + result, err := tx.ExecContext( + ctx, + `UPDATE purchase_tasks + SET version = version + 1, updated_at = ? + WHERE id = ? AND status = 'WAITING_CONFIRMATION' + AND version = ? AND cancel_requested_at IS NULL`, + formatTimestamp(write.Now), + task.ID, + task.Version, + ) + if err != nil { + return domain.OrderAuthorization{}, false, repositoryFailure(err) + } + affected, err := result.RowsAffected() + if err != nil { + return domain.OrderAuthorization{}, false, repositoryFailure(err) + } + if affected != 1 { + return domain.OrderAuthorization{}, false, usecase.ErrTaskStateConflict + } + if err := insertTaskEvent(ctx, tx, write.Event); err != nil { + return domain.OrderAuthorization{}, false, err + } + _, err = tx.ExecContext( + ctx, + `INSERT INTO admin_order_authorization_requests ( + actor_user_id, idempotency_key, request_sha256, + task_id, authorization_id, created_at + ) VALUES (?, ?, ?, ?, ?, ?)`, + write.Command.ActorUserID, + write.Command.IdempotencyKey, + write.RequestSHA256, + write.Command.TaskID, + authorization.ID, + formatTimestamp(write.Now), + ) + if err != nil { + return domain.OrderAuthorization{}, false, repositoryFailure(err) + } + if err := tx.Commit(); err != nil { + return domain.OrderAuthorization{}, false, repositoryFailure(err) + } + return authorization, false, nil +} + +type orderAuthorizationRequestRecord struct { + RequestSHA256 string + TaskID string + AuthorizationID string +} + +func lookupOrderAuthorizationRequest( + ctx context.Context, + queryer queryRower, + actorUserID string, + idempotencyKey string, +) (orderAuthorizationRequestRecord, bool, error) { + var record orderAuthorizationRequestRecord + err := queryer.QueryRowContext( + ctx, + `SELECT request_sha256, task_id, authorization_id + FROM admin_order_authorization_requests + WHERE actor_user_id = ? AND idempotency_key = ?`, + actorUserID, + idempotencyKey, + ).Scan( + &record.RequestSHA256, + &record.TaskID, + &record.AuthorizationID, + ) + if errors.Is(err, sql.ErrNoRows) { + return orderAuthorizationRequestRecord{}, false, nil + } + if err != nil { + return orderAuthorizationRequestRecord{}, false, repositoryFailure(err) + } + return record, true, nil +} + +type orderAuthorizationObservation struct { + CandidateKey string + Ordinal int + SKUText string + PriceText string + CardSignature string + DetailSignature string + DetailEvidenceSHA256 string + SpecificationEvidenceSHA256 string +} + +func orderAuthorizationObservations( + ctx context.Context, + queryer queryer, + taskID string, + executionID string, +) ([]orderAuthorizationObservation, error) { + rows, err := queryer.QueryContext( + ctx, + `SELECT identity.candidate_key, observation.ordinal, + observation.sku_text, observation.price_text, + identity.card_signature, identity.detail_signature, + identity.detail_evidence_sha256, + identity.specification_evidence_sha256 + FROM candidate_observations AS observation + JOIN candidate_observation_identities AS identity + ON identity.execution_id = observation.execution_id + AND identity.candidate_ordinal = observation.ordinal + WHERE observation.task_id = ? AND observation.execution_id = ? + ORDER BY observation.ordinal ASC`, + taskID, + executionID, + ) + if err != nil { + return nil, repositoryFailure(err) + } + defer rows.Close() + result := make([]orderAuthorizationObservation, 0, 5) + for rows.Next() { + var observation orderAuthorizationObservation + if err := rows.Scan( + &observation.CandidateKey, + &observation.Ordinal, + &observation.SKUText, + &observation.PriceText, + &observation.CardSignature, + &observation.DetailSignature, + &observation.DetailEvidenceSHA256, + &observation.SpecificationEvidenceSHA256, + ); err != nil { + return nil, repositoryFailure(err) + } + result = append(result, observation) + } + if err := rows.Err(); err != nil { + return nil, repositoryFailure(err) + } + return result, nil +} + +func activeOrderAuthorization( + ctx context.Context, + queryer queryRower, + executionID string, +) (*string, domain.OrderAuthorizationStatus, error) { + var id string + var status domain.OrderAuthorizationStatus + err := queryer.QueryRowContext( + ctx, + `SELECT id, status + FROM order_authorizations + WHERE execution_id = ? + AND status IN ( + 'PENDING_DELIVERY', + 'DELIVERED', + 'ACKNOWLEDGED', + 'EXECUTING' + ) + LIMIT 1`, + executionID, + ).Scan(&id, &status) + if errors.Is(err, sql.ErrNoRows) { + return nil, "", nil + } + if err != nil { + return nil, "", repositoryFailure(err) + } + return &id, status, nil +} + +func nextOrderAuthorizationVersion( + ctx context.Context, + queryer queryRower, + executionID string, +) (int, error) { + var version int + err := queryer.QueryRowContext( + ctx, + `SELECT COALESCE(MAX(authorization_version), 0) + 1 + FROM order_authorizations + WHERE execution_id = ?`, + executionID, + ).Scan(&version) + if err != nil { + return 0, repositoryFailure(err) + } + return version, nil +} + +func insertAdminCandidateReview( + ctx context.Context, + tx *sql.Tx, + review domain.CandidateHumanReview, +) error { + _, err := tx.ExecContext( + ctx, + `INSERT INTO candidate_human_reviews ( + id, execution_id, task_id, version, reason_schema_version, outcome, + selected_candidate_ordinal, primary_reason_code, note, + supersedes_review_id, actor_user_id, actor_device_id, created_at, + received_after_execution_expiry + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, 0)`, + review.ID, + review.ExecutionID, + review.TaskID, + review.Version, + review.ReasonSchemaVersion, + review.Outcome, + nullableInt(review.SelectedCandidateOrdinal), + review.PrimaryReasonCode, + review.Note, + nullableString(review.SupersedesReviewID), + review.ActorUserID, + formatTimestamp(review.CreatedAt), + ) + if err != nil { + return repositoryFailure(err) + } + for _, item := range review.Items { + _, err = tx.ExecContext( + ctx, + `INSERT INTO candidate_human_review_items ( + review_id, candidate_ordinal, label, primary_reason_code, note + ) VALUES (?, ?, ?, ?, ?)`, + review.ID, + item.CandidateOrdinal, + item.Label, + item.PrimaryReasonCode, + item.Note, + ) + if err != nil { + return repositoryFailure(err) + } + for _, reason := range item.ReasonCodes { + _, err = tx.ExecContext( + ctx, + `INSERT INTO candidate_human_review_reasons ( + review_id, candidate_ordinal, reason_code + ) VALUES (?, ?, ?)`, + review.ID, + item.CandidateOrdinal, + reason, + ) + if err != nil { + return repositoryFailure(err) + } + } + } + return nil +} + +func insertOrderAuthorization( + ctx context.Context, + tx *sql.Tx, + authorization domain.OrderAuthorization, +) error { + _, err := tx.ExecContext( + ctx, + `INSERT INTO order_authorizations ( + id, task_id, execution_id, authorization_version, candidate_key, + task_content_sha256, task_version, review_id, review_version, + user_id, device_id, claim_generation, original_sku, quantity, + candidate_sku_text, candidate_price_text, card_signature, + detail_signature, detail_evidence_sha256, + specification_evidence_sha256, status, + supersedes_authorization_id, created_by_user_id, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?)`, + authorization.ID, + authorization.TaskID, + authorization.ExecutionID, + authorization.AuthorizationVersion, + authorization.CandidateKey, + authorization.TaskContentSHA256, + authorization.TaskVersion, + authorization.ReviewID, + authorization.ReviewVersion, + authorization.UserID, + authorization.DeviceID, + authorization.ClaimGeneration, + authorization.OriginalSKU, + authorization.Quantity, + authorization.CandidateSKUText, + authorization.CandidatePriceText, + authorization.CardSignature, + authorization.DetailSignature, + authorization.DetailEvidenceSHA256, + authorization.SpecificationEvidenceSHA256, + authorization.Status, + nullableString(authorization.SupersedesAuthorizationID), + authorization.CreatedByUserID, + formatTimestamp(authorization.CreatedAt), + ) + if err != nil { + return repositoryFailure(err) + } + return nil +} + +func getOrderAuthorization( + ctx context.Context, + queryer queryRower, + authorizationID string, +) (domain.OrderAuthorization, error) { + authorization, err := scanOrderAuthorization(queryer.QueryRowContext( + ctx, + orderAuthorizationSelect+` WHERE id = ?`, + authorizationID, + )) + if errors.Is(err, sql.ErrNoRows) { + return domain.OrderAuthorization{}, usecase.ErrRepositoryNotFound + } + if err != nil { + return domain.OrderAuthorization{}, repositoryFailure(err) + } + return authorization, nil +} + +func listOrderAuthorizations( + ctx context.Context, + queryer queryer, + taskID string, +) ([]domain.OrderAuthorization, error) { + rows, err := queryer.QueryContext( + ctx, + orderAuthorizationSelect+ + ` WHERE task_id = ? + ORDER BY authorization_version ASC`, + taskID, + ) + if err != nil { + return nil, repositoryFailure(err) + } + defer rows.Close() + result := make([]domain.OrderAuthorization, 0) + for rows.Next() { + authorization, err := scanOrderAuthorization(rows) + if err != nil { + return nil, repositoryFailure(err) + } + result = append(result, authorization) + } + if err := rows.Err(); err != nil { + return nil, repositoryFailure(err) + } + return result, nil +} + +func scanOrderAuthorization( + scanner rowScanner, +) (domain.OrderAuthorization, error) { + var authorization domain.OrderAuthorization + var supersedes, deliveredAt, acknowledgedAt sql.NullString + var executionStartedAt, consumedAt, failedAt, revokedAt sql.NullString + var failureCode, failureMessage sql.NullString + var createdAt string + err := scanner.Scan( + &authorization.ID, + &authorization.TaskID, + &authorization.ExecutionID, + &authorization.AuthorizationVersion, + &authorization.CandidateKey, + &authorization.TaskContentSHA256, + &authorization.TaskVersion, + &authorization.ReviewID, + &authorization.ReviewVersion, + &authorization.UserID, + &authorization.DeviceID, + &authorization.ClaimGeneration, + &authorization.OriginalSKU, + &authorization.Quantity, + &authorization.CandidateSKUText, + &authorization.CandidatePriceText, + &authorization.CardSignature, + &authorization.DetailSignature, + &authorization.DetailEvidenceSHA256, + &authorization.SpecificationEvidenceSHA256, + &authorization.Status, + &supersedes, + &authorization.CreatedByUserID, + &createdAt, + &deliveredAt, + &acknowledgedAt, + &executionStartedAt, + &consumedAt, + &failedAt, + &revokedAt, + &failureCode, + &failureMessage, + ) + if err != nil { + return domain.OrderAuthorization{}, err + } + if supersedes.Valid { + authorization.SupersedesAuthorizationID = &supersedes.String + } + authorization.CreatedAt, err = parseTimestamp(createdAt) + if err != nil { + return domain.OrderAuthorization{}, err + } + if authorization.DeliveredAt, err = parseNullableTimestamp(deliveredAt); err != nil { + return domain.OrderAuthorization{}, err + } + if authorization.AcknowledgedAt, err = parseNullableTimestamp(acknowledgedAt); err != nil { + return domain.OrderAuthorization{}, err + } + if authorization.ExecutionStartedAt, err = parseNullableTimestamp(executionStartedAt); err != nil { + return domain.OrderAuthorization{}, err + } + if authorization.ConsumedAt, err = parseNullableTimestamp(consumedAt); err != nil { + return domain.OrderAuthorization{}, err + } + if authorization.FailedAt, err = parseNullableTimestamp(failedAt); err != nil { + return domain.OrderAuthorization{}, err + } + if authorization.RevokedAt, err = parseNullableTimestamp(revokedAt); err != nil { + return domain.OrderAuthorization{}, err + } + if failureCode.Valid { + authorization.FailureCode = &failureCode.String + } + if failureMessage.Valid { + authorization.FailureMessage = &failureMessage.String + } + return authorization, nil +} + +const orderAuthorizationSelect = `SELECT + id, task_id, execution_id, authorization_version, candidate_key, + task_content_sha256, task_version, review_id, review_version, + user_id, device_id, claim_generation, original_sku, quantity, + candidate_sku_text, candidate_price_text, card_signature, + detail_signature, detail_evidence_sha256, + specification_evidence_sha256, status, + supersedes_authorization_id, created_by_user_id, created_at, + delivered_at, acknowledged_at, execution_started_at, consumed_at, + failed_at, revoked_at, failure_code, failure_message +FROM order_authorizations` + +const localAdminSubject = "local-admin" + +var _ usecase.OrderAuthorizationRepository = (*Store)(nil) diff --git a/backend-api/internal/repository/sqlite/task_repository.go b/backend-api/internal/repository/sqlite/task_repository.go index 21b9a9a..37e6072 100644 --- a/backend-api/internal/repository/sqlite/task_repository.go +++ b/backend-api/internal/repository/sqlite/task_repository.go @@ -320,12 +320,17 @@ func (s *Store) GetTaskDetail( return domain.TaskDetail{}, err } } + orderAuthorizations, err := listOrderAuthorizations(ctx, tx, taskID) + if err != nil { + return domain.TaskDetail{}, err + } detail := domain.TaskDetail{ - Task: task, - Asset: asset, - Execution: executionPointer, - Events: events, - Report: report, + Task: task, + Asset: asset, + Execution: executionPointer, + Events: events, + Report: report, + OrderAuthorizations: orderAuthorizations, } if err := tx.Commit(); err != nil { return domain.TaskDetail{}, repositoryFailure(err) diff --git a/backend-api/internal/transport/httpapi/admin_handlers.go b/backend-api/internal/transport/httpapi/admin_handlers.go index a00438b..4762c0d 100644 --- a/backend-api/internal/transport/httpapi/admin_handlers.go +++ b/backend-api/internal/transport/httpapi/admin_handlers.go @@ -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 { diff --git a/backend-api/internal/transport/httpapi/admin_handlers_test.go b/backend-api/internal/transport/httpapi/admin_handlers_test.go index a6fdb4c..4766499 100644 --- a/backend-api/internal/transport/httpapi/admin_handlers_test.go +++ b/backend-api/internal/transport/httpapi/admin_handlers_test.go @@ -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) { diff --git a/backend-api/internal/transport/httpapi/device_handlers_test.go b/backend-api/internal/transport/httpapi/device_handlers_test.go index deae26a..0737fde 100644 --- a/backend-api/internal/transport/httpapi/device_handlers_test.go +++ b/backend-api/internal/transport/httpapi/device_handlers_test.go @@ -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( diff --git a/backend-api/internal/transport/webui/handler.go b/backend-api/internal/transport/webui/handler.go index bffb81a..b8e313e 100644 --- a/backend-api/internal/transport/webui/handler.go +++ b/backend-api/internal/transport/webui/handler.go @@ -66,6 +66,11 @@ func (h *Handler) RegisterProtected(routes gin.IRoutes) { routes.POST("/tasks", SecurityHeaders(), h.CreateTask) routes.GET("/tasks/:id", SecurityHeaders(), h.TaskDetail) routes.POST("/tasks/:id/cancel", SecurityHeaders(), h.CancelTask) + routes.POST( + "/tasks/:id/order-authorizations", + SecurityHeaders(), + h.AuthorizeOrder, + ) } func SecurityHeaders() gin.HandlerFunc { @@ -264,20 +269,85 @@ func (h *Handler) TaskDetail(ctx *gin.Context) { h.renderError(ctx, http.StatusInternalServerError, "页面暂时无法打开", "请稍后重试。") return } + authorizationKey, keyErr := newToken() + if keyErr != nil { + h.renderError(ctx, http.StatusInternalServerError, "页面暂时无法打开", "请稍后重试。") + return + } page := taskDetailPage{ Page: pageView{ Title: "任务详情", TasksCurrent: true, CSRFToken: token, }, - Task: taskDetailViewFrom(task), - CSRFToken: token, - CancelKey: cancelKey, - Notice: detailNotice(ctx.Query("notice")), + Task: taskDetailViewFrom(task), + CSRFToken: token, + CancelKey: cancelKey, + AuthorizationKey: authorizationKey, + Notice: detailNotice(ctx.Query("notice")), } h.render(ctx, http.StatusOK, "task-detail", page) } +func (h *Handler) AuthorizeOrder(ctx *gin.Context) { + if !validCSRF(ctx) { + h.renderError( + ctx, + http.StatusForbidden, + "请求已失效", + "请返回任务详情后重新操作。", + ) + return + } + taskID := strings.TrimSpace(ctx.Param("id")) + authorizationKey := strings.TrimSpace( + ctx.PostForm("authorization_key"), + ) + expectedVersion, versionErr := strconv.ParseInt( + strings.TrimSpace(ctx.PostForm("expected_task_version")), + 10, + 64, + ) + if !validToken(authorizationKey) || versionErr != nil || + expectedVersion < 1 { + h.renderError( + ctx, + http.StatusForbidden, + "请求已失效", + "请返回任务详情后重新操作。", + ) + return + } + _, err := h.service.AuthorizeOrder( + ctx.Request.Context(), + AuthorizeOrderInput{ + TaskID: taskID, + IdempotencyKey: authorizationKey, + ExpectedTaskVersion: expectedVersion, + CandidateKey: strings.TrimSpace(ctx.PostForm("candidate_key")), + SelectedReasonCode: strings.TrimSpace(ctx.PostForm("selected_reason_code")), + RejectedReasonCode: strings.TrimSpace(ctx.PostForm("rejected_reason_code")), + Note: strings.TrimSpace(ctx.PostForm("authorization_note")), + SupersedesAuthorizationID: strings.TrimSpace(ctx.PostForm("supersedes_authorization_id")), + }, + ) + if err != nil { + if errors.Is(err, ErrConflict) || errors.Is(err, ErrValidation) { + ctx.Redirect( + http.StatusSeeOther, + "/tasks/"+pathEscape(taskID)+"?notice=authorization-conflict", + ) + return + } + h.renderServiceError(ctx, err, "授权失败,请稍后重试。") + return + } + ctx.Redirect( + http.StatusSeeOther, + "/tasks/"+pathEscape(taskID)+"?notice=authorization-created", + ) +} + func (h *Handler) CancelTask(ctx *gin.Context) { if !validCSRF(ctx) { h.renderError( @@ -652,6 +722,10 @@ func detailNotice(value string) string { return "已请求设备安全停止;设备确认前任务仍保持当前执行状态。" case "cancel-conflict": return "任务状态已变化,当前不能取消。" + case "authorization-created": + return "候选已确认,待投递下单授权已创建。" + case "authorization-conflict": + return "候选或任务状态已变化,请检查最新证据后重新授权。" default: return "" } @@ -742,29 +816,35 @@ type newTaskPageView struct { } type taskDetailView struct { - ID string - Title string - SKU string - Description string - Quantity int64 - MaxBudget string - Status string - StatusLabel string - StatusClass string - ReferenceAssetID string - CreatedAt time.Time - UpdatedAt time.Time - CanCancel bool - CancelRequiresAck bool - ExecutionReport *ExecutionReport + ID string + Title string + SKU string + Description string + Quantity int64 + MaxBudget string + Status string + Version int64 + StatusLabel string + StatusClass string + ReferenceAssetID string + CreatedAt time.Time + UpdatedAt time.Time + CanCancel bool + CancelRequiresAck bool + ExecutionReport *ExecutionReport + Candidates []AuthorizationCandidate + OrderAuthorizations []OrderAuthorization + CanAuthorizeOrder bool + ActiveAuthorizationID string } type taskDetailPage struct { - Page pageView - Task taskDetailView - CSRFToken string - CancelKey string - Notice string + Page pageView + Task taskDetailView + CSRFToken string + CancelKey string + AuthorizationKey string + Notice string } type errorPage struct { @@ -774,6 +854,17 @@ type errorPage struct { } func taskDetailViewFrom(task Task) taskDetailView { + activeAuthorizationID := "" + canAuthorizeOrder := task.Status == "WAITING_CONFIRMATION" && + len(task.Candidates) > 0 + for _, authorization := range task.OrderAuthorizations { + switch authorization.Status { + case "PENDING_DELIVERY": + activeAuthorizationID = authorization.ID + case "DELIVERED", "ACKNOWLEDGED", "EXECUTING": + canAuthorizeOrder = false + } + } return taskDetailView{ ID: task.ID, Title: task.Title, @@ -782,6 +873,7 @@ func taskDetailViewFrom(task Task) taskDetailView { Quantity: task.Quantity, MaxBudget: task.MaxBudget, Status: task.Status, + Version: task.Version, StatusLabel: statusLabel(task.Status), StatusClass: statusClass(task.Status), ReferenceAssetID: task.ReferenceAssetID, @@ -790,7 +882,11 @@ func taskDetailViewFrom(task Task) taskDetailView { CanCancel: canCancelTaskStatus(task.Status), CancelRequiresAck: task.Status == "RUNNING" || task.Status == "WAITING_CONFIRMATION", - ExecutionReport: task.ExecutionReport, + ExecutionReport: task.ExecutionReport, + Candidates: task.Candidates, + OrderAuthorizations: task.OrderAuthorizations, + CanAuthorizeOrder: canAuthorizeOrder, + ActiveAuthorizationID: activeAuthorizationID, } } diff --git a/backend-api/internal/transport/webui/handler_test.go b/backend-api/internal/transport/webui/handler_test.go index a5772c1..296f996 100644 --- a/backend-api/internal/transport/webui/handler_test.go +++ b/backend-api/internal/transport/webui/handler_test.go @@ -509,6 +509,124 @@ func TestTaskDetailDoesNotLeakForbiddenResource(t *testing.T) { } } +func TestTaskDetailAuthorizesOneCandidateWithCSRFAndPRG(t *testing.T) { + firstKey := strings.Repeat("a", 64) + secondKey := strings.Repeat("b", 64) + service := &fakeService{ + getResult: Task{ + ID: testTaskID, + Title: "候选确认任务", + SKU: "BLACK-L", + Quantity: 2, + Status: "WAITING_CONFIRMATION", + Version: 7, + ReferenceAssetID: "00000000-0000-4000-8000-000000000009", + CreatedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + TaskContentSHA256: strings.Repeat("c", 64), + Candidates: []AuthorizationCandidate{ + { + CandidateKey: firstKey, + Ordinal: 1, + Title: "候选一", + SKUText: "BLACK-L", + PriceText: "20.00", + EvidenceURLs: []string{"/api/v1/assets/evidence-1/content"}, + }, + { + CandidateKey: secondKey, + Ordinal: 2, + Title: "候选二", + SKUText: "BLACK-XL", + PriceText: "22.00", + }, + }, + }, + } + router := newTestRouter(t, service) + detail := performRequest( + t, + router, + http.MethodGet, + "/tasks/"+testTaskID, + nil, + "", + ) + if detail.Code != http.StatusOK { + t.Fatalf("detail status/body = %d/%s", detail.Code, detail.Body) + } + for _, expected := range []string{ + "候选确认与下单授权", + "系统只创建待付款订单", + "候选一", + "BLACK-XL", + `name="candidate_key"`, + } { + if !strings.Contains(detail.Body.String(), expected) { + t.Fatalf("authorization detail missing %q", expected) + } + } + cookie := csrfCookie(t, detail) + authorizationKey := hiddenValue( + t, + detail.Body.String(), + "authorization_key", + ) + form := url.Values{ + "csrf_token": {cookie.Value}, + "authorization_key": {authorizationKey}, + "expected_task_version": {"7"}, + "candidate_key": {firstKey}, + "selected_reason_code": {"SKU_MATCH"}, + "rejected_reason_code": {"NOT_BEST_MATCH"}, + "authorization_note": {"已核对图片、规格与价格"}, + "supersedes_authorization_id": {""}, + } + request := httptest.NewRequest( + http.MethodPost, + "/tasks/"+testTaskID+"/order-authorizations", + strings.NewReader(form.Encode()), + ) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + request.AddCookie(cookie) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + + if response.Code != http.StatusSeeOther || + response.Header().Get("Location") != + "/tasks/"+testTaskID+"?notice=authorization-created" { + t.Fatalf( + "status/location = %d/%q", + response.Code, + response.Header().Get("Location"), + ) + } + if service.authorizeInput.TaskID != testTaskID || + service.authorizeInput.IdempotencyKey != authorizationKey || + service.authorizeInput.ExpectedTaskVersion != 7 || + service.authorizeInput.CandidateKey != firstKey || + service.authorizeInput.SelectedReasonCode != "SKU_MATCH" || + service.authorizeInput.RejectedReasonCode != "NOT_BEST_MATCH" { + t.Fatalf("authorize input = %+v", service.authorizeInput) + } +} + +func TestTaskDetailDisablesAuthorizationAfterDelivery(t *testing.T) { + view := taskDetailViewFrom(Task{ + Status: "WAITING_CONFIRMATION", + Candidates: []AuthorizationCandidate{{ + CandidateKey: strings.Repeat("a", 64), + }}, + OrderAuthorizations: []OrderAuthorization{{ + ID: "00000000-0000-4000-8000-000000000010", + Status: "DELIVERED", + }}, + }) + if view.CanAuthorizeOrder { + t.Fatal("delivered authorization remains editable") + } +} + func TestTaskDetailCancelModeFollowsLifecycleStatus(t *testing.T) { tests := []struct { status string @@ -614,23 +732,26 @@ func TestRendererUsesMissingKeyErrors(t *testing.T) { } type fakeService struct { - listInput ListTasksInput - listResult TaskList - listErr error - getResult Task - getErr error - uploadResult UploadedAsset - uploadErr error - uploadInput UploadReferenceInput - uploadBody []byte - uploadCalls int - createResult Task - createErr error - createInput CreateTaskInput - createCalls int - cancelResult Task - cancelErr error - cancelInput CancelTaskInput + listInput ListTasksInput + listResult TaskList + listErr error + getResult Task + getErr error + uploadResult UploadedAsset + uploadErr error + uploadInput UploadReferenceInput + uploadBody []byte + uploadCalls int + createResult Task + createErr error + createInput CreateTaskInput + createCalls int + cancelResult Task + cancelErr error + cancelInput CancelTaskInput + authorizeResult OrderAuthorization + authorizeErr error + authorizeInput AuthorizeOrderInput } func (service *fakeService) ListTasks( @@ -679,6 +800,14 @@ func (service *fakeService) CancelTask( return service.cancelResult, service.cancelErr } +func (service *fakeService) AuthorizeOrder( + _ context.Context, + input AuthorizeOrderInput, +) (OrderAuthorization, error) { + service.authorizeInput = input + return service.authorizeResult, service.authorizeErr +} + func newTestRouter(t *testing.T, service Service) http.Handler { t.Helper() gin.SetMode(gin.TestMode) diff --git a/backend-api/internal/transport/webui/static/admin.css b/backend-api/internal/transport/webui/static/admin.css index 9640b67..ddd8a62 100644 --- a/backend-api/internal/transport/webui/static/admin.css +++ b/backend-api/internal/transport/webui/static/admin.css @@ -101,6 +101,157 @@ textarea { overflow-wrap: anywhere; } +.authorization-section { + margin-top: 20px; +} + +.section-heading-row { + display: flex; + align-items: start; + justify-content: space-between; + gap: 16px; +} + +.section-heading-row h2 { + margin-bottom: 4px; +} + +.candidate-count { + flex: 0 0 auto; + color: var(--muted); + font-weight: 700; +} + +.authorization-form { + display: grid; + gap: 18px; + margin-top: 18px; +} + +.candidate-fieldset { + min-width: 0; + margin: 0; + padding: 0; + border: 0; +} + +.candidate-fieldset legend { + margin-bottom: 9px; + font-weight: 700; +} + +.authorization-candidates { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 12px; +} + +.authorization-candidate { + display: grid; + min-width: 0; + gap: 7px; + padding: 14px; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--surface-soft); + cursor: pointer; +} + +.authorization-candidate:has(input:checked) { + border-color: var(--brand); + box-shadow: 0 0 0 2px rgba(11, 107, 80, 0.14); +} + +.candidate-choice { + display: flex; + align-items: center; + gap: 8px; + color: var(--brand-dark); + font-weight: 800; +} + +.candidate-choice input, +.authorization-confirm input { + width: 20px; + min-height: 20px; + margin: 0; +} + +.candidate-key { + display: block; + overflow-wrap: anywhere; + color: var(--muted); + font-size: 11px; +} + +.candidate-evidence { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; +} + +.candidate-evidence img { + width: 100%; + aspect-ratio: 4 / 3; + object-fit: contain; + border: 1px solid var(--line); + background: var(--surface); +} + +.authorization-reasons { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.authorization-reasons label { + display: grid; + gap: 6px; + font-weight: 700; +} + +.authorization-note { + grid-column: 1 / -1; +} + +.authorization-confirm { + display: flex; + align-items: flex-start; + gap: 9px; + padding: 12px; + border-left: 4px solid var(--warn); + background: var(--warn-soft); +} + +.authorization-form > .button { + justify-self: start; +} + +.authorization-unavailable { + padding: 12px; + border-left: 4px solid var(--warn); + background: var(--warn-soft); +} + +.authorization-history { + display: grid; + gap: 8px; + padding-left: 22px; +} + +.authorization-history li { + padding: 10px 12px; + border-bottom: 1px solid var(--line); +} + +.authorization-history span, +.authorization-history time { + display: block; + overflow-wrap: anywhere; + color: var(--muted); + font-size: 13px; +} + button, input, select { @@ -901,10 +1052,15 @@ tbody tr:last-child td { .upload-layout, .detail-layout, - .requirement-layout { + .requirement-layout, + .authorization-reasons { grid-template-columns: 1fr; } + .authorization-note { + grid-column: auto; + } + .image-preview { max-width: 320px; } @@ -978,6 +1134,19 @@ tbody tr:last-child td { padding: 18px 14px; } + .section-heading-row { + align-items: stretch; + flex-direction: column; + } + + .authorization-candidates { + grid-template-columns: 1fr; + } + + .authorization-form > .button { + width: 100%; + } + .form-actions, .dialog-actions { flex-direction: column-reverse; diff --git a/backend-api/internal/transport/webui/templates/task-detail.gohtml b/backend-api/internal/transport/webui/templates/task-detail.gohtml index ad77764..564a8ea 100644 --- a/backend-api/internal/transport/webui/templates/task-detail.gohtml +++ b/backend-api/internal/transport/webui/templates/task-detail.gohtml @@ -73,6 +73,108 @@ + {{if .Task.Candidates}} +
+
+
+

候选确认与下单授权

+

选择只会授权设备创建一笔待付款订单,不授权付款。

+
+ {{len .Task.Candidates}} 个候选 +
+ + {{if .Task.CanAuthorizeOrder}} +
+ + + + + +
+ 选择要采购的商品 +
+ {{range .Task.Candidates}} + + {{end}} +
+
+ +
+ + + +
+ + + +
+ {{else}} +

+ 当前候选不能创建或修改授权,请查看下方授权状态或刷新任务。 +

+ {{end}} + + {{if .Task.OrderAuthorizations}} +

授权历史

+
    + {{range .Task.OrderAuthorizations}} +
  1. + 版本 {{.Version}} · {{.Status}} + 候选 {{.CandidateKey}} + 规格 {{if .CandidateSKUText}}{{.CandidateSKUText}}{{else}}未读取{{end}} · + 数量 {{.Quantity}} · 价格 {{if .CandidatePriceText}}{{.CandidatePriceText}}{{else}}未读取{{end}} + +
  2. + {{end}} +
+ {{end}} +
+ {{end}} + {{with .Task.ExecutionReport}}

执行审计

diff --git a/backend-api/internal/transport/webui/types.go b/backend-api/internal/transport/webui/types.go index eeae3b6..a0cadfe 100644 --- a/backend-api/internal/transport/webui/types.go +++ b/backend-api/internal/transport/webui/types.go @@ -24,6 +24,7 @@ type Service interface { UploadReference(context.Context, UploadReferenceInput) (UploadedAsset, error) CreateTask(context.Context, CreateTaskInput) (Task, error) CancelTask(context.Context, CancelTaskInput) (Task, error) + AuthorizeOrder(context.Context, AuthorizeOrderInput) (OrderAuthorization, error) } type ListTasksInput struct { @@ -48,17 +49,43 @@ type TaskSummary struct { } type Task struct { - ID string - Title string - SKU string - Description string - Quantity int64 - MaxBudget string - Status string - ReferenceAssetID string - CreatedAt time.Time - UpdatedAt time.Time - ExecutionReport *ExecutionReport + ID string + Title string + SKU string + Description string + Quantity int64 + MaxBudget string + Status string + Version int64 + ExecutionID string + TaskContentSHA256 string + ReferenceAssetID string + CreatedAt time.Time + UpdatedAt time.Time + ExecutionReport *ExecutionReport + Candidates []AuthorizationCandidate + OrderAuthorizations []OrderAuthorization +} + +type AuthorizationCandidate struct { + CandidateKey string + Ordinal int + Title string + SKUText string + PriceText string + EvidenceURLs []string +} + +type OrderAuthorization struct { + ID string + Version int + CandidateKey string + CandidateSKUText string + CandidatePriceText string + Quantity int + Status string + SupersedesID string + CreatedAt time.Time } type ExecutionReport struct { @@ -130,3 +157,14 @@ type CancelTaskInput struct { TaskID string IdempotencyKey string } + +type AuthorizeOrderInput struct { + TaskID string + IdempotencyKey string + ExpectedTaskVersion int64 + CandidateKey string + SelectedReasonCode string + RejectedReasonCode string + Note string + SupersedesAuthorizationID string +} diff --git a/backend-api/internal/transport/webui/usecase_adapter.go b/backend-api/internal/transport/webui/usecase_adapter.go index dba9633..89dcb32 100644 --- a/backend-api/internal/transport/webui/usecase_adapter.go +++ b/backend-api/internal/transport/webui/usecase_adapter.go @@ -14,20 +14,23 @@ import ( const localAdminSubject = "local-admin" type UsecaseAdapter struct { - tasks *usecase.TaskService - assets *usecase.AssetService + tasks *usecase.TaskService + assets *usecase.AssetService + authorizations *usecase.OrderAuthorizationService } func NewUsecaseAdapter( tasks *usecase.TaskService, assets *usecase.AssetService, + authorizations *usecase.OrderAuthorizationService, ) (*UsecaseAdapter, error) { - if tasks == nil || assets == nil { + if tasks == nil || assets == nil || authorizations == nil { return nil, errors.New("admin web use cases are required") } return &UsecaseAdapter{ - tasks: tasks, - assets: assets, + tasks: tasks, + assets: assets, + authorizations: authorizations, }, nil } @@ -146,6 +149,70 @@ func (adapter *UsecaseAdapter) CancelTask( return taskFromPurchase(task), nil } +func (adapter *UsecaseAdapter) AuthorizeOrder( + ctx context.Context, + input AuthorizeOrderInput, +) (OrderAuthorization, error) { + detail, err := adapter.tasks.Get(ctx, localAdminSubject, input.TaskID) + if err != nil { + return OrderAuthorization{}, mapUsecaseError(err) + } + if detail.Task.Version != input.ExpectedTaskVersion || + detail.Execution == nil || + detail.Report == nil || + detail.Report.DecisionDataset == nil { + return OrderAuthorization{}, ErrConflict + } + var supersedes *string + if input.SupersedesAuthorizationID != "" { + value := input.SupersedesAuthorizationID + supersedes = &value + } + items := make( + []usecase.OrderAuthorizationItemInput, + 0, + len(detail.Report.DecisionDataset.Observations), + ) + for _, observation := range detail.Report.DecisionDataset.Observations { + if observation.Identity == nil { + return OrderAuthorization{}, ErrConflict + } + label := "REJECT" + reason := input.RejectedReasonCode + if observation.Identity.CandidateKey == input.CandidateKey { + label = "ACCEPT" + reason = input.SelectedReasonCode + } + items = append(items, usecase.OrderAuthorizationItemInput{ + CandidateKey: observation.Identity.CandidateKey, + Label: label, + PrimaryReasonCode: reason, + ReasonCodes: []string{reason}, + }) + } + result, err := adapter.authorizations.Create( + ctx, + usecase.CreateOrderAuthorizationCommand{ + ActorUserID: actorUserID(ctx), + TaskID: input.TaskID, + IdempotencyKey: input.IdempotencyKey, + ExecutionID: detail.Execution.ID, + TaskContentSHA256: usecase.TaskContentSHA256(detail.Task), + ExpectedTaskVersion: input.ExpectedTaskVersion, + CandidateKey: input.CandidateKey, + ReasonSchemaVersion: 1, + PrimaryReasonCode: "SELECTED_BEST_MATCH", + Note: input.Note, + SupersedesAuthorizationID: supersedes, + Items: items, + }, + ) + if err != nil { + return OrderAuthorization{}, mapUsecaseError(err) + } + return orderAuthorizationFrom(result.Authorization), nil +} + func taskSummaryFrom(task domain.PurchaseTask) TaskSummary { return TaskSummary{ ID: task.ID, @@ -177,11 +244,64 @@ func taskFromPurchase(task domain.PurchaseTask) Task { func taskFromDetail(detail domain.TaskDetail) Task { task := taskFromPurchase(detail.Task) + task.Version = detail.Task.Version task.ReferenceAssetID = detail.Asset.ID task.ExecutionReport = executionReportFrom(detail.Report) + task.TaskContentSHA256 = usecase.TaskContentSHA256(detail.Task) + if detail.Execution != nil { + task.ExecutionID = detail.Execution.ID + } + if detail.Report != nil && detail.Report.DecisionDataset != nil { + for _, observation := range detail.Report.DecisionDataset.Observations { + if observation.Identity == nil { + continue + } + candidate := AuthorizationCandidate{ + CandidateKey: observation.Identity.CandidateKey, + Ordinal: observation.Ordinal, + Title: observation.Title, + SKUText: observation.SKUText, + PriceText: observation.PriceText, + EvidenceURLs: make([]string, 0, len(observation.EvidenceAssetIDs)), + } + for _, evidenceID := range observation.EvidenceAssetIDs { + candidate.EvidenceURLs = append( + candidate.EvidenceURLs, + "/api/v1/tasks/"+detail.Task.ID+ + "/evidence/"+evidenceID+"/content", + ) + } + task.Candidates = append(task.Candidates, candidate) + } + } + for _, authorization := range detail.OrderAuthorizations { + task.OrderAuthorizations = append( + task.OrderAuthorizations, + orderAuthorizationFrom(authorization), + ) + } return task } +func orderAuthorizationFrom( + authorization domain.OrderAuthorization, +) OrderAuthorization { + result := OrderAuthorization{ + ID: authorization.ID, + Version: authorization.AuthorizationVersion, + CandidateKey: authorization.CandidateKey, + CandidateSKUText: authorization.CandidateSKUText, + CandidatePriceText: authorization.CandidatePriceText, + Quantity: authorization.Quantity, + Status: string(authorization.Status), + CreatedAt: authorization.CreatedAt, + } + if authorization.SupersedesAuthorizationID != nil { + result.SupersedesID = *authorization.SupersedesAuthorizationID + } + return result +} + func executionReportFrom(report *domain.ExecutionReport) *ExecutionReport { if report == nil { return nil diff --git a/backend-api/internal/usecase/execution_result_service.go b/backend-api/internal/usecase/execution_result_service.go index 6a8b4c2..f5d41fe 100644 --- a/backend-api/internal/usecase/execution_result_service.go +++ b/backend-api/internal/usecase/execution_result_service.go @@ -285,6 +285,24 @@ func (service *ExecutionResultService) StoreCandidates( if err != nil { return false, internalExecutionResultFailure(err) } + var readyEvent *domain.TaskEvent + if len(command.Candidates) > 0 { + eventID, err := service.ids.NewID() + if err != nil { + return false, internalExecutionResultFailure(err) + } + actorUserID := identity.UserID + actorDeviceID := identity.DeviceID + readyEvent = &domain.TaskEvent{ + ID: eventID, + TaskID: identity.TaskID, + ActorUserID: &actorUserID, + ActorDeviceID: &actorDeviceID, + Type: "CANDIDATES_READY", + Message: "candidates ready for admin confirmation", + OccurredAt: now, + } + } replayed, err := service.repository.StoreExecutionCandidates( ctx, service.write(identity, executionResultCandidatesOperation, requestHash, now), @@ -297,6 +315,7 @@ func (service *ExecutionResultService) StoreCandidates( ProvenanceJSON: provenanceJSON, CandidatesJSON: candidatesJSON, RecommendationJSON: recommendationJSON, + ReadyEvent: readyEvent, ReceivedAt: now, }, ) diff --git a/backend-api/internal/usecase/order_authorization_service.go b/backend-api/internal/usecase/order_authorization_service.go new file mode 100644 index 0000000..5b3f3bb --- /dev/null +++ b/backend-api/internal/usecase/order_authorization_service.go @@ -0,0 +1,295 @@ +package usecase + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "strings" + "time" + + "cmroubao/backend-api/internal/domain" +) + +type OrderAuthorizationItemInput struct { + CandidateKey string `json:"candidate_key"` + Label string `json:"label"` + PrimaryReasonCode string `json:"primary_reason_code"` + ReasonCodes []string `json:"reason_codes"` + Note string `json:"note"` +} + +type CreateOrderAuthorizationCommand struct { + ActorUserID string `json:"-"` + TaskID string `json:"-"` + IdempotencyKey string `json:"-"` + 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 []OrderAuthorizationItemInput `json:"items"` +} + +type CreateOrderAuthorizationWrite struct { + AuthorizationID string + ReviewID string + RequestSHA256 string + Now time.Time + Event domain.TaskEvent + Command CreateOrderAuthorizationCommand +} + +type CreateOrderAuthorizationResult struct { + Authorization domain.OrderAuthorization + Replayed bool +} + +type OrderAuthorizationRepository interface { + CreateOrderAuthorization( + context.Context, + CreateOrderAuthorizationWrite, + ) (domain.OrderAuthorization, bool, error) +} + +type OrderAuthorizationService struct { + repository OrderAuthorizationRepository + clock Clock + ids IDGenerator +} + +func NewOrderAuthorizationService( + repository OrderAuthorizationRepository, + clock Clock, + ids IDGenerator, +) (*OrderAuthorizationService, error) { + if repository == nil || clock == nil || ids == nil { + return nil, errors.New("order authorization service dependencies are required") + } + return &OrderAuthorizationService{ + repository: repository, + clock: clock, + ids: ids, + }, nil +} + +func (service *OrderAuthorizationService) Create( + ctx context.Context, + command CreateOrderAuthorizationCommand, +) (CreateOrderAuthorizationResult, error) { + command = normalizeOrderAuthorizationCommand(command) + if err := validateOrderAuthorizationCommand(command); err != nil { + return CreateOrderAuthorizationResult{}, err + } + requestSHA256, err := hashOrderAuthorizationCommand(command) + if err != nil { + return CreateOrderAuthorizationResult{}, internalExecutionResultFailure(err) + } + authorizationID, err := service.ids.NewID() + if err != nil { + return CreateOrderAuthorizationResult{}, internalExecutionResultFailure(err) + } + reviewID, err := service.ids.NewID() + if err != nil { + return CreateOrderAuthorizationResult{}, internalExecutionResultFailure(err) + } + eventID, err := service.ids.NewID() + if err != nil { + return CreateOrderAuthorizationResult{}, internalExecutionResultFailure(err) + } + now := service.clock.Now().UTC() + actorUserID := command.ActorUserID + authorization, replayed, err := service.repository.CreateOrderAuthorization( + ctx, + CreateOrderAuthorizationWrite{ + AuthorizationID: authorizationID, + ReviewID: reviewID, + RequestSHA256: requestSHA256, + Now: now, + Event: domain.TaskEvent{ + ID: eventID, + TaskID: command.TaskID, + ActorUserID: &actorUserID, + Type: "ORDER_AUTHORIZATION_CREATED", + Message: "order authorization created for candidate " + + command.CandidateKey[:12], + OccurredAt: now, + }, + Command: command, + }, + ) + if err != nil { + return CreateOrderAuthorizationResult{}, wrapRepositoryError(err) + } + return CreateOrderAuthorizationResult{ + Authorization: authorization, + Replayed: replayed, + }, nil +} + +func normalizeOrderAuthorizationCommand( + command CreateOrderAuthorizationCommand, +) CreateOrderAuthorizationCommand { + command.ActorUserID = strings.TrimSpace(command.ActorUserID) + command.TaskID = strings.TrimSpace(command.TaskID) + command.IdempotencyKey = strings.TrimSpace(command.IdempotencyKey) + command.ExecutionID = strings.TrimSpace(command.ExecutionID) + command.TaskContentSHA256 = strings.TrimSpace(command.TaskContentSHA256) + command.CandidateKey = strings.TrimSpace(command.CandidateKey) + command.PrimaryReasonCode = strings.TrimSpace(command.PrimaryReasonCode) + command.Note = strings.TrimSpace(command.Note) + if command.SupersedesAuthorizationID != nil { + value := strings.TrimSpace(*command.SupersedesAuthorizationID) + command.SupersedesAuthorizationID = &value + } + for index := range command.Items { + item := &command.Items[index] + item.CandidateKey = strings.TrimSpace(item.CandidateKey) + item.Label = strings.TrimSpace(item.Label) + item.PrimaryReasonCode = strings.TrimSpace(item.PrimaryReasonCode) + item.Note = strings.TrimSpace(item.Note) + for reasonIndex := range item.ReasonCodes { + item.ReasonCodes[reasonIndex] = + strings.TrimSpace(item.ReasonCodes[reasonIndex]) + } + } + return command +} + +func validateOrderAuthorizationCommand( + command CreateOrderAuthorizationCommand, +) error { + fields := make(map[string]string) + if !isUUID(command.ActorUserID) { + fields["actor_user_id"] = "must be a UUID" + } + if !isUUID(command.TaskID) { + fields["task_id"] = "must be a UUID" + } + if !isUUID(command.ExecutionID) { + fields["execution_id"] = "must be a UUID" + } + if len(command.IdempotencyKey) == 0 || + len([]byte(command.IdempotencyKey)) > maxIdempotencyKeyBytes || + !isPrintableASCII(command.IdempotencyKey) { + fields["idempotency_key"] = "must be 1 to 128 printable ASCII bytes" + } + if !sha256Pattern.MatchString(command.TaskContentSHA256) { + fields["task_content_sha256"] = "must be lowercase SHA-256" + } + if command.ExpectedTaskVersion < 1 { + fields["expected_task_version"] = "must be positive" + } + if !sha256Pattern.MatchString(command.CandidateKey) { + fields["candidate_key"] = "must be lowercase SHA-256" + } + if command.ReasonSchemaVersion != candidateReasonSchemaVersion { + fields["reason_schema_version"] = "must be 1" + } + if !validReviewPrimaryReason("CANDIDATE_ACCEPTED", command.PrimaryReasonCode) || + !validReviewNote(command.PrimaryReasonCode, command.Note) { + fields["primary_reason_code"] = "is invalid" + } + if command.SupersedesAuthorizationID != nil && + !isUUID(*command.SupersedesAuthorizationID) { + fields["supersedes_authorization_id"] = "must be a UUID" + } + if len(command.Items) < 1 || len(command.Items) > 5 { + fields["items"] = "must contain 1 to 5 items" + } + seen := make(map[string]struct{}, len(command.Items)) + accepted := 0 + for _, item := range command.Items { + if !sha256Pattern.MatchString(item.CandidateKey) { + fields["items"] = "candidate keys must be lowercase SHA-256" + continue + } + if _, found := seen[item.CandidateKey]; found { + fields["items"] = "candidate keys must be unique" + continue + } + seen[item.CandidateKey] = struct{}{} + if item.Label == "ACCEPT" { + accepted++ + } + if item.Label != "ACCEPT" && item.Label != "REJECT" { + fields["items"] = "labels must be ACCEPT or REJECT" + continue + } + if !validOrderAuthorizationItem(item) { + fields["items"] = "contains invalid reasons" + } + } + _, found := seen[command.CandidateKey] + if !found || accepted != 1 || !acceptedOrderAuthorizationItem( + command.Items, + command.CandidateKey, + ) { + fields["candidate_key"] = "must identify the single accepted item" + } + if len(fields) > 0 { + return invalidError( + "ORDER_AUTHORIZATION_INVALID", + "order authorization request is invalid", + fields, + ) + } + return nil +} + +func validOrderAuthorizationItem(item OrderAuthorizationItemInput) bool { + if len(item.ReasonCodes) < 1 || len(item.ReasonCodes) > 8 { + return false + } + seen := make(map[string]struct{}, len(item.ReasonCodes)) + containsPrimary := false + for _, reason := range item.ReasonCodes { + if !validItemReason(item.Label, reason) { + return false + } + if _, found := seen[reason]; found { + return false + } + seen[reason] = struct{}{} + containsPrimary = containsPrimary || reason == item.PrimaryReasonCode + } + return containsPrimary && + validReviewNote(item.PrimaryReasonCode, item.Note) +} + +func acceptedOrderAuthorizationItem( + items []OrderAuthorizationItemInput, + candidateKey string, +) bool { + for _, item := range items { + if item.CandidateKey == candidateKey { + return item.Label == "ACCEPT" + } + } + return false +} + +func hashOrderAuthorizationCommand( + command CreateOrderAuthorizationCommand, +) (string, error) { + command.IdempotencyKey = "" + payload := struct { + ActorUserID string `json:"actor_user_id"` + TaskID string `json:"task_id"` + Command CreateOrderAuthorizationCommand `json:"command"` + }{ + ActorUserID: command.ActorUserID, + TaskID: command.TaskID, + Command: command, + } + encoded, err := json.Marshal(payload) + if err != nil { + return "", err + } + sum := sha256.Sum256(encoded) + return hex.EncodeToString(sum[:]), nil +} diff --git a/backend-api/internal/usecase/order_authorization_service_test.go b/backend-api/internal/usecase/order_authorization_service_test.go new file mode 100644 index 0000000..a0ec44a --- /dev/null +++ b/backend-api/internal/usecase/order_authorization_service_test.go @@ -0,0 +1,83 @@ +package usecase + +import ( + "strings" + "testing" +) + +func TestValidateOrderAuthorizationAcceptsSingleStructuredSelection( + t *testing.T, +) { + command := validOrderAuthorizationCommand() + + if err := validateOrderAuthorizationCommand(command); err != nil { + t.Fatalf("validate order authorization: %v", err) + } +} + +func TestValidateOrderAuthorizationRejectsDuplicateCandidateKey( + t *testing.T, +) { + command := validOrderAuthorizationCommand() + command.Items[1].CandidateKey = command.Items[0].CandidateKey + + if err := validateOrderAuthorizationCommand(command); err == nil { + t.Fatal("expected duplicate candidate key to be rejected") + } +} + +func TestValidateOrderAuthorizationRejectsSelectedKeyMismatch( + t *testing.T, +) { + command := validOrderAuthorizationCommand() + command.CandidateKey = command.Items[1].CandidateKey + + if err := validateOrderAuthorizationCommand(command); err == nil { + t.Fatal("expected selected candidate mismatch to be rejected") + } +} + +func TestValidateOrderAuthorizationRequiresOtherNote(t *testing.T) { + command := validOrderAuthorizationCommand() + command.Items[0].PrimaryReasonCode = "OTHER" + command.Items[0].ReasonCodes = []string{"OTHER"} + command.Items[0].Note = "短" + + if err := validateOrderAuthorizationCommand(command); err == nil { + t.Fatal("expected short OTHER note to be rejected") + } + command.Items[0].Note = "人工判断规格更准确" + if err := validateOrderAuthorizationCommand(command); err != nil { + t.Fatalf("validate OTHER note: %v", err) + } +} + +func validOrderAuthorizationCommand() CreateOrderAuthorizationCommand { + firstKey := strings.Repeat("a", 64) + secondKey := strings.Repeat("b", 64) + return CreateOrderAuthorizationCommand{ + ActorUserID: "00000000-0000-4000-8000-000000000001", + TaskID: "00000000-0000-4000-8000-000000000002", + IdempotencyKey: "authorize-order-test", + ExecutionID: "00000000-0000-4000-8000-000000000003", + TaskContentSHA256: strings.Repeat("c", 64), + ExpectedTaskVersion: 2, + CandidateKey: firstKey, + ReasonSchemaVersion: 1, + PrimaryReasonCode: "SELECTED_BEST_MATCH", + Items: []OrderAuthorizationItemInput{ + { + CandidateKey: firstKey, + Label: "ACCEPT", + PrimaryReasonCode: "SKU_MATCH", + ReasonCodes: []string{"SKU_MATCH", "IMAGE_MATCH"}, + }, + { + CandidateKey: secondKey, + Label: "REJECT", + PrimaryReasonCode: "NOT_BEST_MATCH", + ReasonCodes: []string{"NOT_BEST_MATCH"}, + }, + }, + } +} diff --git a/backend-api/migrations/00008_order_authorizations.sql b/backend-api/migrations/00008_order_authorizations.sql new file mode 100644 index 0000000..068c500 --- /dev/null +++ b/backend-api/migrations/00008_order_authorizations.sql @@ -0,0 +1,246 @@ +-- +goose Up +ALTER TABLE task_events RENAME TO task_events_v7; + +CREATE TABLE task_events ( + id TEXT PRIMARY KEY NOT NULL + CHECK (length(id) = 36), + task_id TEXT NOT NULL + REFERENCES purchase_tasks(id) ON UPDATE RESTRICT ON DELETE CASCADE, + event_type TEXT NOT NULL + CHECK ( + event_type IN ( + 'TASK_CREATED', + 'TASK_CLAIMED', + 'TASK_RECLAIMED', + 'TASK_RELEASED', + 'TASK_STARTED', + 'TASK_CANCEL_REQUESTED', + 'TASK_CANCELED', + 'CANDIDATES_READY', + 'ORDER_AUTHORIZATION_CREATED' + ) + ), + message TEXT NOT NULL, + occurred_at TEXT NOT NULL, + actor_user_id TEXT + REFERENCES users(id) ON UPDATE RESTRICT ON DELETE RESTRICT, + actor_device_id TEXT + REFERENCES devices(id) ON UPDATE RESTRICT ON DELETE RESTRICT +); + +INSERT INTO task_events ( + id, task_id, event_type, message, occurred_at, + actor_user_id, actor_device_id +) +SELECT + id, task_id, event_type, message, occurred_at, + actor_user_id, actor_device_id +FROM task_events_v7; + +DROP TABLE task_events_v7; + +CREATE INDEX task_events_task_occurred_idx + ON task_events (task_id, occurred_at ASC, id ASC); + +CREATE INDEX task_events_actor_user_idx + ON task_events (actor_user_id, occurred_at DESC, id DESC); + +CREATE INDEX task_events_actor_device_idx + ON task_events (actor_device_id, occurred_at DESC, id DESC); + +CREATE TABLE order_authorizations ( + id TEXT PRIMARY KEY NOT NULL + CHECK (length(id) = 36), + task_id TEXT NOT NULL + REFERENCES purchase_tasks(id) ON UPDATE RESTRICT ON DELETE RESTRICT, + execution_id TEXT NOT NULL + REFERENCES task_executions(id) ON UPDATE RESTRICT ON DELETE RESTRICT, + authorization_version INTEGER NOT NULL + CHECK (authorization_version > 0), + candidate_key TEXT NOT NULL + REFERENCES candidate_observation_identities(candidate_key) + ON UPDATE RESTRICT ON DELETE RESTRICT, + task_content_sha256 TEXT NOT NULL + CHECK ( + length(task_content_sha256) = 64 + AND task_content_sha256 NOT GLOB '*[^0-9a-f]*' + ), + task_version INTEGER NOT NULL + CHECK (task_version > 0), + review_id TEXT NOT NULL + REFERENCES candidate_human_reviews(id) + ON UPDATE RESTRICT ON DELETE RESTRICT, + review_version INTEGER NOT NULL + CHECK (review_version > 0), + user_id TEXT NOT NULL + REFERENCES users(id) ON UPDATE RESTRICT ON DELETE RESTRICT, + device_id TEXT NOT NULL + REFERENCES devices(id) ON UPDATE RESTRICT ON DELETE RESTRICT, + claim_generation INTEGER NOT NULL + CHECK (claim_generation > 0), + original_sku TEXT NOT NULL + CHECK ( + length(trim(original_sku)) > 0 + AND length(CAST(original_sku AS BLOB)) <= 512 + ), + quantity INTEGER NOT NULL + CHECK (quantity > 0), + candidate_sku_text TEXT NOT NULL + CHECK (length(CAST(candidate_sku_text AS BLOB)) <= 512), + candidate_price_text TEXT NOT NULL + CHECK (length(CAST(candidate_price_text AS BLOB)) <= 64), + card_signature TEXT NOT NULL + CHECK ( + length(card_signature) = 64 + AND card_signature NOT GLOB '*[^0-9a-f]*' + ), + detail_signature TEXT NOT NULL + CHECK ( + length(detail_signature) = 64 + AND detail_signature NOT GLOB '*[^0-9a-f]*' + ), + detail_evidence_sha256 TEXT NOT NULL + CHECK ( + length(detail_evidence_sha256) = 64 + AND detail_evidence_sha256 NOT GLOB '*[^0-9a-f]*' + ), + specification_evidence_sha256 TEXT NOT NULL + CHECK ( + length(specification_evidence_sha256) = 64 + AND specification_evidence_sha256 NOT GLOB '*[^0-9a-f]*' + ), + status TEXT NOT NULL + CHECK ( + status IN ( + 'PENDING_DELIVERY', + 'DELIVERED', + 'ACKNOWLEDGED', + 'EXECUTING', + 'CONSUMED', + 'FAILED', + 'REVOKED', + 'SUPERSEDED' + ) + ), + supersedes_authorization_id TEXT + REFERENCES order_authorizations(id) + ON UPDATE RESTRICT ON DELETE RESTRICT, + created_by_user_id TEXT NOT NULL + REFERENCES users(id) ON UPDATE RESTRICT ON DELETE RESTRICT, + created_at TEXT NOT NULL, + delivered_at TEXT, + acknowledged_at TEXT, + execution_started_at TEXT, + consumed_at TEXT, + failed_at TEXT, + revoked_at TEXT, + failure_code TEXT, + failure_message TEXT, + UNIQUE (execution_id, authorization_version) +); + +CREATE UNIQUE INDEX order_authorizations_active_execution_idx + ON order_authorizations (execution_id) + WHERE status IN ( + 'PENDING_DELIVERY', + 'DELIVERED', + 'ACKNOWLEDGED', + 'EXECUTING' + ); + +CREATE INDEX order_authorizations_task_created_idx + ON order_authorizations (task_id, created_at DESC, id DESC); + +CREATE TABLE admin_order_authorization_requests ( + actor_user_id TEXT NOT NULL + REFERENCES users(id) ON UPDATE RESTRICT ON DELETE RESTRICT, + idempotency_key TEXT NOT NULL + CHECK ( + length(trim(idempotency_key)) > 0 + AND length(CAST(idempotency_key AS BLOB)) <= 128 + ), + request_sha256 TEXT NOT NULL + CHECK ( + length(request_sha256) = 64 + AND request_sha256 NOT GLOB '*[^0-9a-f]*' + ), + task_id TEXT NOT NULL + REFERENCES purchase_tasks(id) ON UPDATE RESTRICT ON DELETE RESTRICT, + authorization_id TEXT NOT NULL + REFERENCES order_authorizations(id) + ON UPDATE RESTRICT ON DELETE RESTRICT, + created_at TEXT NOT NULL, + PRIMARY KEY (actor_user_id, idempotency_key) +); + +-- +goose Down +CREATE TEMP TABLE order_authorizations_v8_down_guard ( + allowed INTEGER NOT NULL + CHECK (allowed = 1) +); + +INSERT INTO order_authorizations_v8_down_guard (allowed) +SELECT CASE + WHEN EXISTS (SELECT 1 FROM order_authorizations) + OR EXISTS ( + SELECT 1 + FROM task_events + WHERE event_type IN ( + 'CANDIDATES_READY', + 'ORDER_AUTHORIZATION_CREATED' + ) + ) + THEN 0 + ELSE 1 +END; + +DROP TABLE order_authorizations_v8_down_guard; +DROP TABLE admin_order_authorization_requests; +DROP TABLE order_authorizations; + +ALTER TABLE task_events RENAME TO task_events_v8; + +CREATE TABLE task_events ( + id TEXT PRIMARY KEY NOT NULL + CHECK (length(id) = 36), + task_id TEXT NOT NULL + REFERENCES purchase_tasks(id) ON UPDATE RESTRICT ON DELETE CASCADE, + event_type TEXT NOT NULL + CHECK ( + event_type IN ( + 'TASK_CREATED', + 'TASK_CLAIMED', + 'TASK_RECLAIMED', + 'TASK_RELEASED', + 'TASK_STARTED', + 'TASK_CANCEL_REQUESTED', + 'TASK_CANCELED' + ) + ), + message TEXT NOT NULL, + occurred_at TEXT NOT NULL, + actor_user_id TEXT + REFERENCES users(id) ON UPDATE RESTRICT ON DELETE RESTRICT, + actor_device_id TEXT + REFERENCES devices(id) ON UPDATE RESTRICT ON DELETE RESTRICT +); + +INSERT INTO task_events ( + id, task_id, event_type, message, occurred_at, + actor_user_id, actor_device_id +) +SELECT + id, task_id, event_type, message, occurred_at, + actor_user_id, actor_device_id +FROM task_events_v8; + +DROP TABLE task_events_v8; + +CREATE INDEX task_events_task_occurred_idx + ON task_events (task_id, occurred_at ASC, id ASC); + +CREATE INDEX task_events_actor_user_idx + ON task_events (actor_user_id, occurred_at DESC, id DESC); + +CREATE INDEX task_events_actor_device_idx + ON task_events (actor_device_id, occurred_at DESC, id DESC); diff --git a/docs/00-ai-start-here.md b/docs/00-ai-start-here.md index bd9902f..bfb4de1 100644 --- a/docs/00-ai-start-here.md +++ b/docs/00-ai-start-here.md @@ -57,8 +57,9 @@ T-205 原子领取/租约状态机、T-206 Android 登录/有限离线、T-207 本地 VLM/候选/ 证据回传、T-211 参考图召回和 SKU 硬匹配、T-212 候选身份映射,以及 T-213 受控 规格组合/价格核验均已完成。T-208 的原始候选观测、模型评估、确定性推荐、逐候选 -结构化人工理由和修订历史也已完成。T-214 商品持久身份和重新定位指纹也已完成; -当前正在实现 T-215 Admin 下单授权,不得直接把候选链接或列表 ordinal 当成授权。 +结构化人工理由和修订历史也已完成。T-214 商品持久身份和重新定位指纹、T-215 +Admin 候选确认与不可变待投递授权也已完成;下一步实现 T-216 设备命令投递与确认。 +不得直接把候选链接或列表 ordinal 当成授权。 手机从管理后端领取任务并回传结果,VLM、拼多多自动化和人工确认在 App 本地完成。 T-206 增加有限离线执行;T-207 已复用 Roubao 端上 OpenAI 兼容适配器并加密本地 Key。 管理后端不保存/代理 VLM,后台任务不能覆盖手机 provider 配置。 diff --git a/docs/current-state.md b/docs/current-state.md index 21b3d4f..9fe2194 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -5,7 +5,7 @@ ## 当前快照 - 日期:2026-07-28 -- 阶段:T-215 Admin 候选确认与一次性下单授权进行中 +- 阶段:T-215 Admin 候选确认与一次性下单授权已完成,准备 T-216 - Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-207、T-209、 T-208、T-210、T-211、T-212、T-213、T-214 均已纳入 Git 历史 - 生产代码:`android-buyer/` 已接入 Roubao Android 源码 @@ -17,10 +17,10 @@ - 本机 Android 工具:JDK 17.0.13、Command-line Tools 22.0、SDK 34、 Build Tools 34.0.0、Platform Tools/ADB 37.0.0;用户级 SDK 环境变量已设置 - Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建 -- 测试:T-214 Android 单元测试、Debug/Release 构建和根 `init.ps1` 通过; +- 测试:T-215 Android 单元测试、Debug/Release 构建和根 `init.ps1` 通过; Debug APK `1.4.12 (17)` 已安装并启动于 PKG110 -- 后端测试:T-214 运行 `go test ./...`、`go test -race ./...`、`go vet ./...`、 - migration `up/down/up` 和带 identity 数据的降级保护均通过 +- 后端测试:T-215 运行 `go test ./...`、`go test -race ./...`、`go vet ./...`; + v8 migration 往返、带授权数据的降级保护和 Admin 授权接口集成测试均通过 - 原型:4 个管理 Web 页面和 7 个 Android 页面均可离线独立打开;Playwright 以 1440×900、390×844、360×800 验证 36 个页面/视口组合,无页面横向溢出、 脚本错误或外部请求,Android 可见交互控件均不小于 44px @@ -64,6 +64,10 @@ 已上传 asset 后,在 v7 表中生成 execution-scoped `candidate_key`。Admin API/Web 展示 key、指纹、原始 observation、模型/人工结论和截图;缺少真实平台 URL 时保持 空值,后端不请求第三方 URL。 +- T-215 下单授权:非空候选回传原子进入 `WAITING_CONFIRMATION`;Admin 按 + candidate key 选择并为全部候选填写结构化理由,服务端在同一事务追加人工 review、 + 不可变授权快照、事件和幂等记录。领取前可按版本改选,已投递后禁改;页面明确仅 + 授权创建设备端待付款订单,不授权付款。 - Workflow:纯 Kotlin runner 已支持步骤 timeout、最多 3 次 retry、安全阻塞、 用户停止和单 runner 并发拒绝;T-102 已接入搜索加有界候选采集五步 - TaskSource:严格 CLI 已生成并验证真实私有 ProbeTask;默认 APK 不含私有 fixture @@ -92,7 +96,7 @@ 已用 CLI 真实导入并逐字段/图片哈希验证,生成物位于被忽略的 `.local/` - 标准启动路径:`$env:RUN_START_COMMAND="1"; .\init.ps1` - 标准验证路径:`.\init.ps1` -- 当前 blocker:T-214 无阻塞。真实 VLM 服务地址、模型、设备级测试凭证、成本上限 +- 当前 blocker:T-215 无阻塞。真实 VLM 服务地址、模型、设备级测试凭证、成本上限 和数据留存尚未确认;当前只支持单 SKU/JPEG;候选探针截图要求 Android 11/API 30+ ## 当前目录 @@ -123,7 +127,7 @@ | `docs/tasks/T-213.md` | DONE | 真机选择目标 SKU、读取组合价并安全返回 | | `docs/tasks/T-208.md` | DONE | 归一化候选决策数据并增加结构化人工 review | | `docs/tasks/T-214.md` | DONE | 建立 execution-scoped candidate key 与设备采集指纹 | -| `docs/tasks/T-215.md` | DOING | Admin 按 candidate key 选择并创建不可变待投递授权 | +| `docs/tasks/T-215.md` | DONE | Admin 按 candidate key 选择并创建不可变待投递授权 | | `docs/design/` | 已确认 | T-202 原型索引、4 个管理页和 7 个 Android 页面 | | `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 | | `android-buyer/` | 已有 | Roubao `main` 固定 commit 的 Android 基线 | @@ -134,8 +138,8 @@ ## 任务摘要 -- 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-214。 -- 正在进行:T-215 Admin 候选确认与一次性下单授权。 +- 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-215。 +- 正在进行:准备 T-216 设备命令投递与确认。 - 下一步:依次实现设备命令、订单 dry-run、单次提交对账和 付款提醒。 diff --git a/docs/tasks/T-215.md b/docs/tasks/T-215.md index a69d4f4..8b88555 100644 --- a/docs/tasks/T-215.md +++ b/docs/tasks/T-215.md @@ -4,7 +4,7 @@ title: Admin 候选确认与一次性下单授权 phase: 2 deps: - T-214 -status: DOING +status: DONE created: 2026-07-28 context_ref: 56e7a2b work_branch: null @@ -124,13 +124,13 @@ REVOKED/SUPERSEDED`;T-215 只产生 `PENDING_DELIVERY` 和 `SUPERSEDED`。存 ## 验收要点 -- [ ] 非空候选批次原子进入 `WAITING_CONFIRMATION`,重复回传不重复增加事件/version。 -- [ ] Admin 请求只用 candidate key,完整覆盖候选且每项接受/拒绝都有合法理由。 -- [ ] review、授权、旧授权 supersede、任务事件和幂等记录原子提交。 -- [ ] 跨任务/execution、旧 hash/version、伪造 key、缺 identity 和并发双授权被拒绝。 -- [ ] 授权数量/SKU/价格/指纹均由服务端快照,Admin 不能篡改。 -- [ ] Admin API/Web 可选候选、查看证据和理由,明确只创建待付款订单且不授权付款。 -- [ ] v8 migration、Go test/race/vet、根验证和管理页面桌面/移动视口通过。 +- [x] 非空候选批次原子进入 `WAITING_CONFIRMATION`,重复回传不重复增加事件/version。 +- [x] Admin 请求只用 candidate key,完整覆盖候选且每项接受/拒绝都有合法理由。 +- [x] review、授权、旧授权 supersede、任务事件和幂等记录原子提交。 +- [x] 跨任务/execution、旧 hash/version、伪造 key、缺 identity 和并发双授权被拒绝。 +- [x] 授权数量/SKU/价格/指纹均由服务端快照,Admin 不能篡改。 +- [x] Admin API/Web 可选候选、查看证据和理由,明确只创建待付款订单且不授权付款。 +- [x] v8 migration、Go test/race/vet、根验证和管理页面桌面/移动视口通过。 ## 边界 @@ -144,3 +144,8 @@ REVOKED/SUPERSEDED`;T-215 只产生 `PENDING_DELIVERY` 和 `SUPERSEDED`。存 - 2026-07-28:T-214 实现提交 `56e7a2b` 后领取。决定复用 `WAITING_CONFIRMATION`,并把 Admin review 与一次性授权放在同一事务;设备投递和 页面动作继续保持为后续独立权限层。 +- 2026-07-28:完成 v8 migration、候选就绪原子状态迁移、Admin API/SSR 表单、 + 不可变授权快照、修订/幂等/并发保护和审计历史。`go test ./...`、 + `go test -race ./...`、`go vet ./...` 与根 `init.ps1` 通过。 +- 2026-07-28:真实 Gin handler、嵌入模板和 CSS 在 Playwright `1440x900`、 + `390x844` 通过;均无横向溢出,主要控件高度至少 44px,控制台无错误。