feat(t218): reconcile single order submissions
This commit is contained in:
@@ -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(v11) error = %v", err)
|
||||
}
|
||||
if err := runner.Down(context.Background()); err != nil {
|
||||
t.Fatalf("Down(v10) error = %v", err)
|
||||
}
|
||||
@@ -420,9 +423,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-v10) error = %v", err)
|
||||
} else if applied != 8 {
|
||||
t.Fatalf("Up(v3-v10) applied = %d, want 8", applied)
|
||||
t.Fatalf("Up(v3-v11) error = %v", err)
|
||||
} else if applied != 9 {
|
||||
t.Fatalf("Up(v3-v11) applied = %d, want 9", applied)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,880 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"cmroubao/backend-api/internal/domain"
|
||||
"cmroubao/backend-api/internal/usecase"
|
||||
)
|
||||
|
||||
const orderReconciliationClockWindow = 5 * time.Minute
|
||||
|
||||
func (s *Store) StartOrderSubmission(
|
||||
ctx context.Context,
|
||||
write usecase.StartOrderSubmissionWrite,
|
||||
) (domain.OrderSubmission, bool, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return domain.OrderSubmission{}, false, repositoryFailure(err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
if result, found, err := replayOrderSubmissionRequest(
|
||||
ctx,
|
||||
tx,
|
||||
write.DeviceID,
|
||||
"START",
|
||||
write.IdempotencyKey,
|
||||
write.RequestSHA256,
|
||||
write.TaskID,
|
||||
); err != nil || found {
|
||||
return commitOrderSubmissionReplay(tx, result, found, err)
|
||||
}
|
||||
if err := validateDeviceOrderCommandClaim(
|
||||
ctx,
|
||||
tx,
|
||||
write.UserID,
|
||||
write.DeviceID,
|
||||
write.TaskID,
|
||||
write.ExecutionID,
|
||||
write.ClaimGeneration,
|
||||
write.ClaimTokenHash,
|
||||
write.Now,
|
||||
); err != nil {
|
||||
return domain.OrderSubmission{}, false, err
|
||||
}
|
||||
authorization, err := getOrderAuthorization(
|
||||
ctx,
|
||||
tx,
|
||||
write.AuthorizationID,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.OrderSubmission{}, false, err
|
||||
}
|
||||
if err := validateSubmissionAuthorization(
|
||||
authorization,
|
||||
write.UserID,
|
||||
write.DeviceID,
|
||||
write.TaskID,
|
||||
write.ExecutionID,
|
||||
write.ClaimGeneration,
|
||||
write.CommandSHA256,
|
||||
); err != nil {
|
||||
return domain.OrderSubmission{}, false, err
|
||||
}
|
||||
if authorization.Status != domain.OrderAuthorizationExecuting {
|
||||
return domain.OrderSubmission{}, false, usecase.ErrTaskStateConflict
|
||||
}
|
||||
dryRun, found, err := getOrderDryRunByAuthorization(
|
||||
ctx,
|
||||
tx,
|
||||
authorization.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.OrderSubmission{}, false, err
|
||||
}
|
||||
if !found ||
|
||||
dryRun.ID != write.DryRunID ||
|
||||
dryRun.Status != domain.OrderDryRunReady ||
|
||||
dryRun.CommandSHA256 != write.CommandSHA256 ||
|
||||
dryRun.EvidenceSHA256 == nil ||
|
||||
*dryRun.EvidenceSHA256 != write.DryRunEvidenceSHA256 ||
|
||||
dryRun.ObservedTitle == nil ||
|
||||
normalizeSubmissionText(*dryRun.ObservedTitle) !=
|
||||
normalizeSubmissionText(write.ObservedTitle) ||
|
||||
dryRun.SelectedSKU == nil ||
|
||||
strings.TrimSpace(*dryRun.SelectedSKU) != write.SelectedSKU ||
|
||||
dryRun.Quantity == nil ||
|
||||
*dryRun.Quantity != write.Quantity ||
|
||||
dryRun.UnitPriceCents == nil ||
|
||||
*dryRun.UnitPriceCents != write.UnitPriceCents ||
|
||||
dryRun.TotalPriceCents == nil ||
|
||||
*dryRun.TotalPriceCents != write.TotalPriceCents {
|
||||
return domain.OrderSubmission{}, false, usecase.ErrTaskStateConflict
|
||||
}
|
||||
if _, found, err := getOrderSubmissionByAuthorization(
|
||||
ctx,
|
||||
tx,
|
||||
authorization.ID,
|
||||
); err != nil {
|
||||
return domain.OrderSubmission{}, false, err
|
||||
} else if found {
|
||||
return domain.OrderSubmission{}, false, usecase.ErrTaskStateConflict
|
||||
}
|
||||
_, err = tx.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO order_submissions (
|
||||
id, authorization_id, dry_run_id, task_id, execution_id,
|
||||
command_sha256, dry_run_evidence_sha256, status,
|
||||
expected_title, expected_sku, expected_quantity,
|
||||
expected_unit_price_cents, expected_total_price_cents, fenced_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, 'FENCED', ?, ?, ?, ?, ?, ?)`,
|
||||
write.SubmissionID,
|
||||
authorization.ID,
|
||||
dryRun.ID,
|
||||
write.TaskID,
|
||||
write.ExecutionID,
|
||||
write.CommandSHA256,
|
||||
write.DryRunEvidenceSHA256,
|
||||
write.ObservedTitle,
|
||||
write.SelectedSKU,
|
||||
write.Quantity,
|
||||
write.UnitPriceCents,
|
||||
write.TotalPriceCents,
|
||||
formatTimestamp(write.Now),
|
||||
)
|
||||
if err != nil {
|
||||
return domain.OrderSubmission{}, false, repositoryFailure(err)
|
||||
}
|
||||
if err := insertTaskEvent(ctx, tx, write.Event); err != nil {
|
||||
return domain.OrderSubmission{}, false, err
|
||||
}
|
||||
if err := insertOrderSubmissionRequest(
|
||||
ctx,
|
||||
tx,
|
||||
write.DeviceID,
|
||||
"START",
|
||||
write.IdempotencyKey,
|
||||
write.RequestSHA256,
|
||||
write.TaskID,
|
||||
write.SubmissionID,
|
||||
write.Now,
|
||||
); err != nil {
|
||||
return domain.OrderSubmission{}, false, err
|
||||
}
|
||||
submission, err := getOrderSubmissionByID(
|
||||
ctx,
|
||||
tx,
|
||||
write.SubmissionID,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.OrderSubmission{}, false, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return domain.OrderSubmission{}, false, repositoryFailure(err)
|
||||
}
|
||||
return submission, false, nil
|
||||
}
|
||||
|
||||
func (s *Store) ReconcileOrderSubmission(
|
||||
ctx context.Context,
|
||||
write usecase.ReconcileOrderSubmissionWrite,
|
||||
) (domain.OrderSubmission, bool, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return domain.OrderSubmission{}, false, repositoryFailure(err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
if result, found, err := replayOrderSubmissionRequest(
|
||||
ctx,
|
||||
tx,
|
||||
write.DeviceID,
|
||||
"RECONCILE",
|
||||
write.IdempotencyKey,
|
||||
write.RequestSHA256,
|
||||
write.TaskID,
|
||||
); err != nil || found {
|
||||
return commitOrderSubmissionReplay(tx, result, found, err)
|
||||
}
|
||||
if err := validateFencedSubmissionOwner(
|
||||
ctx,
|
||||
tx,
|
||||
write.UserID,
|
||||
write.DeviceID,
|
||||
write.TaskID,
|
||||
write.ExecutionID,
|
||||
write.ClaimGeneration,
|
||||
write.ClaimTokenHash,
|
||||
); err != nil {
|
||||
return domain.OrderSubmission{}, false, err
|
||||
}
|
||||
authorization, err := getOrderAuthorization(
|
||||
ctx,
|
||||
tx,
|
||||
write.AuthorizationID,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.OrderSubmission{}, false, err
|
||||
}
|
||||
if err := validateSubmissionAuthorization(
|
||||
authorization,
|
||||
write.UserID,
|
||||
write.DeviceID,
|
||||
write.TaskID,
|
||||
write.ExecutionID,
|
||||
write.ClaimGeneration,
|
||||
write.CommandSHA256,
|
||||
); err != nil {
|
||||
return domain.OrderSubmission{}, false, err
|
||||
}
|
||||
if authorization.Status != domain.OrderAuthorizationExecuting {
|
||||
return domain.OrderSubmission{}, false, usecase.ErrTaskStateConflict
|
||||
}
|
||||
submission, err := getOrderSubmissionByID(
|
||||
ctx,
|
||||
tx,
|
||||
write.SubmissionID,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.OrderSubmission{}, false, err
|
||||
}
|
||||
if err := validateSubmissionRecord(
|
||||
submission,
|
||||
authorization.ID,
|
||||
write.TaskID,
|
||||
write.ExecutionID,
|
||||
write.CommandSHA256,
|
||||
); err != nil {
|
||||
return domain.OrderSubmission{}, false, err
|
||||
}
|
||||
if submission.Status != domain.OrderSubmissionFenced &&
|
||||
submission.Status != domain.OrderSubmissionManualReview {
|
||||
return domain.OrderSubmission{}, false, usecase.ErrTaskStateConflict
|
||||
}
|
||||
if normalizeSubmissionText(write.ObservedTitle) !=
|
||||
normalizeSubmissionText(submission.ExpectedTitle) ||
|
||||
write.SelectedSKU != submission.ExpectedSKU ||
|
||||
write.Quantity != submission.ExpectedQuantity ||
|
||||
write.TotalPriceCents != submission.ExpectedTotalPriceCents ||
|
||||
write.PlatformOrderStatus != "PENDING_PAYMENT" {
|
||||
return domain.OrderSubmission{}, false, usecase.ErrTaskStateConflict
|
||||
}
|
||||
if write.ParsedPlatformOrderedAt.Before(
|
||||
submission.FencedAt.Add(-orderReconciliationClockWindow),
|
||||
) || write.ParsedPlatformOrderedAt.After(
|
||||
write.Now.Add(orderReconciliationClockWindow),
|
||||
) {
|
||||
return domain.OrderSubmission{}, false, usecase.ErrTaskStateConflict
|
||||
}
|
||||
evidence, err := getExecutionEvidence(
|
||||
ctx,
|
||||
tx,
|
||||
write.EvidenceAssetID,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.OrderSubmission{}, false, err
|
||||
}
|
||||
if evidence.TaskID != write.TaskID ||
|
||||
evidence.ExecutionID != write.ExecutionID ||
|
||||
evidence.MediaType != "image/jpeg" ||
|
||||
evidence.SHA256 != write.EvidenceSHA256 {
|
||||
return domain.OrderSubmission{}, false, usecase.ErrTaskStateConflict
|
||||
}
|
||||
result, err := tx.ExecContext(
|
||||
ctx,
|
||||
`UPDATE order_submissions
|
||||
SET status = 'RECONCILED',
|
||||
platform_order_no = ?,
|
||||
platform_ordered_at = ?,
|
||||
platform_order_status = 'PENDING_PAYMENT',
|
||||
reconciliation_evidence_asset_id = ?,
|
||||
reconciliation_evidence_sha256 = ?,
|
||||
manual_reason_code = NULL,
|
||||
reconciled_at = ?,
|
||||
manual_review_at = NULL
|
||||
WHERE id = ? AND status IN ('FENCED', 'MANUAL_REVIEW')`,
|
||||
write.PlatformOrderNo,
|
||||
formatTimestamp(write.ParsedPlatformOrderedAt),
|
||||
write.EvidenceAssetID,
|
||||
write.EvidenceSHA256,
|
||||
formatTimestamp(write.Now),
|
||||
submission.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.OrderSubmission{}, false, repositoryFailure(err)
|
||||
}
|
||||
if affected, err := result.RowsAffected(); err != nil || affected != 1 {
|
||||
if err != nil {
|
||||
return domain.OrderSubmission{}, false, repositoryFailure(err)
|
||||
}
|
||||
return domain.OrderSubmission{}, false, usecase.ErrTaskStateConflict
|
||||
}
|
||||
result, err = tx.ExecContext(
|
||||
ctx,
|
||||
`UPDATE order_authorizations
|
||||
SET status = 'CONSUMED', consumed_at = ?
|
||||
WHERE id = ? AND status = 'EXECUTING'`,
|
||||
formatTimestamp(write.Now),
|
||||
authorization.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.OrderSubmission{}, false, repositoryFailure(err)
|
||||
}
|
||||
if affected, err := result.RowsAffected(); err != nil || affected != 1 {
|
||||
if err != nil {
|
||||
return domain.OrderSubmission{}, false, repositoryFailure(err)
|
||||
}
|
||||
return domain.OrderSubmission{}, false, usecase.ErrTaskStateConflict
|
||||
}
|
||||
if err := finishReconciledOrderExecution(
|
||||
ctx,
|
||||
tx,
|
||||
write,
|
||||
authorization,
|
||||
); err != nil {
|
||||
return domain.OrderSubmission{}, false, err
|
||||
}
|
||||
if err := insertTaskEvent(ctx, tx, write.Event); err != nil {
|
||||
return domain.OrderSubmission{}, false, err
|
||||
}
|
||||
if err := insertOrderSubmissionRequest(
|
||||
ctx,
|
||||
tx,
|
||||
write.DeviceID,
|
||||
"RECONCILE",
|
||||
write.IdempotencyKey,
|
||||
write.RequestSHA256,
|
||||
write.TaskID,
|
||||
submission.ID,
|
||||
write.Now,
|
||||
); err != nil {
|
||||
return domain.OrderSubmission{}, false, err
|
||||
}
|
||||
submission, err = getOrderSubmissionByID(ctx, tx, submission.ID)
|
||||
if err != nil {
|
||||
return domain.OrderSubmission{}, false, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return domain.OrderSubmission{}, false, repositoryFailure(err)
|
||||
}
|
||||
return submission, false, nil
|
||||
}
|
||||
|
||||
func (s *Store) ManualReviewOrderSubmission(
|
||||
ctx context.Context,
|
||||
write usecase.ManualReviewOrderSubmissionWrite,
|
||||
) (domain.OrderSubmission, bool, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return domain.OrderSubmission{}, false, repositoryFailure(err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
if result, found, err := replayOrderSubmissionRequest(
|
||||
ctx,
|
||||
tx,
|
||||
write.DeviceID,
|
||||
"MANUAL_REVIEW",
|
||||
write.IdempotencyKey,
|
||||
write.RequestSHA256,
|
||||
write.TaskID,
|
||||
); err != nil || found {
|
||||
return commitOrderSubmissionReplay(tx, result, found, err)
|
||||
}
|
||||
if err := validateFencedSubmissionOwner(
|
||||
ctx,
|
||||
tx,
|
||||
write.UserID,
|
||||
write.DeviceID,
|
||||
write.TaskID,
|
||||
write.ExecutionID,
|
||||
write.ClaimGeneration,
|
||||
write.ClaimTokenHash,
|
||||
); err != nil {
|
||||
return domain.OrderSubmission{}, false, err
|
||||
}
|
||||
authorization, err := getOrderAuthorization(
|
||||
ctx,
|
||||
tx,
|
||||
write.AuthorizationID,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.OrderSubmission{}, false, err
|
||||
}
|
||||
if err := validateSubmissionAuthorization(
|
||||
authorization,
|
||||
write.UserID,
|
||||
write.DeviceID,
|
||||
write.TaskID,
|
||||
write.ExecutionID,
|
||||
write.ClaimGeneration,
|
||||
write.CommandSHA256,
|
||||
); err != nil {
|
||||
return domain.OrderSubmission{}, false, err
|
||||
}
|
||||
if authorization.Status != domain.OrderAuthorizationExecuting {
|
||||
return domain.OrderSubmission{}, false, usecase.ErrTaskStateConflict
|
||||
}
|
||||
submission, err := getOrderSubmissionByID(
|
||||
ctx,
|
||||
tx,
|
||||
write.SubmissionID,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.OrderSubmission{}, false, err
|
||||
}
|
||||
if err := validateSubmissionRecord(
|
||||
submission,
|
||||
authorization.ID,
|
||||
write.TaskID,
|
||||
write.ExecutionID,
|
||||
write.CommandSHA256,
|
||||
); err != nil {
|
||||
return domain.OrderSubmission{}, false, err
|
||||
}
|
||||
if submission.Status != domain.OrderSubmissionFenced {
|
||||
return domain.OrderSubmission{}, false, usecase.ErrTaskStateConflict
|
||||
}
|
||||
var evidenceID, evidenceSHA any
|
||||
if write.EvidenceAssetID != "" {
|
||||
evidence, err := getExecutionEvidence(
|
||||
ctx,
|
||||
tx,
|
||||
write.EvidenceAssetID,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.OrderSubmission{}, false, err
|
||||
}
|
||||
if evidence.TaskID != write.TaskID ||
|
||||
evidence.ExecutionID != write.ExecutionID ||
|
||||
evidence.MediaType != "image/jpeg" ||
|
||||
evidence.SHA256 != write.EvidenceSHA256 {
|
||||
return domain.OrderSubmission{}, false, usecase.ErrTaskStateConflict
|
||||
}
|
||||
evidenceID = write.EvidenceAssetID
|
||||
evidenceSHA = write.EvidenceSHA256
|
||||
}
|
||||
result, err := tx.ExecContext(
|
||||
ctx,
|
||||
`UPDATE order_submissions
|
||||
SET status = 'MANUAL_REVIEW',
|
||||
reconciliation_evidence_asset_id = ?,
|
||||
reconciliation_evidence_sha256 = ?,
|
||||
manual_reason_code = ?,
|
||||
manual_review_at = ?
|
||||
WHERE id = ? AND status = 'FENCED'`,
|
||||
evidenceID,
|
||||
evidenceSHA,
|
||||
write.ReasonCode,
|
||||
formatTimestamp(write.Now),
|
||||
submission.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.OrderSubmission{}, false, repositoryFailure(err)
|
||||
}
|
||||
if affected, err := result.RowsAffected(); err != nil || affected != 1 {
|
||||
if err != nil {
|
||||
return domain.OrderSubmission{}, false, repositoryFailure(err)
|
||||
}
|
||||
return domain.OrderSubmission{}, false, usecase.ErrTaskStateConflict
|
||||
}
|
||||
if err := insertTaskEvent(ctx, tx, write.Event); err != nil {
|
||||
return domain.OrderSubmission{}, false, err
|
||||
}
|
||||
if err := insertOrderSubmissionRequest(
|
||||
ctx,
|
||||
tx,
|
||||
write.DeviceID,
|
||||
"MANUAL_REVIEW",
|
||||
write.IdempotencyKey,
|
||||
write.RequestSHA256,
|
||||
write.TaskID,
|
||||
submission.ID,
|
||||
write.Now,
|
||||
); err != nil {
|
||||
return domain.OrderSubmission{}, false, err
|
||||
}
|
||||
submission, err = getOrderSubmissionByID(ctx, tx, submission.ID)
|
||||
if err != nil {
|
||||
return domain.OrderSubmission{}, false, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return domain.OrderSubmission{}, false, repositoryFailure(err)
|
||||
}
|
||||
return submission, false, nil
|
||||
}
|
||||
|
||||
func validateSubmissionAuthorization(
|
||||
authorization domain.OrderAuthorization,
|
||||
userID, deviceID, taskID, executionID string,
|
||||
claimGeneration int64,
|
||||
commandSHA256 string,
|
||||
) error {
|
||||
if authorization.UserID != userID ||
|
||||
authorization.DeviceID != deviceID ||
|
||||
authorization.TaskID != taskID ||
|
||||
authorization.ExecutionID != executionID ||
|
||||
authorization.ClaimGeneration != claimGeneration ||
|
||||
authorization.CommandSHA256 == nil ||
|
||||
*authorization.CommandSHA256 != commandSHA256 {
|
||||
return usecase.ErrExecutionMismatch
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateFencedSubmissionOwner(
|
||||
ctx context.Context,
|
||||
tx *sql.Tx,
|
||||
userID, deviceID, taskID, executionID string,
|
||||
claimGeneration int64,
|
||||
claimTokenHash string,
|
||||
) error {
|
||||
task, err := getClaimProtectedTask(ctx, tx, taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateClaimOwner(
|
||||
task,
|
||||
userID,
|
||||
deviceID,
|
||||
claimGeneration,
|
||||
claimTokenHash,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if task.Status != domain.TaskStatusWaitingConfirmation ||
|
||||
task.CancelRequestedAt != nil {
|
||||
return usecase.ErrTaskStateConflict
|
||||
}
|
||||
execution, err := getExecutionByID(ctx, tx, executionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if execution.TaskID != taskID ||
|
||||
execution.UserID != userID ||
|
||||
execution.DeviceID != deviceID ||
|
||||
execution.ClaimGeneration != claimGeneration ||
|
||||
execution.FinishedAt != nil {
|
||||
return usecase.ErrExecutionMismatch
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSubmissionRecord(
|
||||
submission domain.OrderSubmission,
|
||||
authorizationID, taskID, executionID, commandSHA256 string,
|
||||
) error {
|
||||
if submission.AuthorizationID != authorizationID ||
|
||||
submission.TaskID != taskID ||
|
||||
submission.ExecutionID != executionID ||
|
||||
submission.CommandSHA256 != commandSHA256 {
|
||||
return usecase.ErrExecutionMismatch
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func finishReconciledOrderExecution(
|
||||
ctx context.Context,
|
||||
tx *sql.Tx,
|
||||
write usecase.ReconcileOrderSubmissionWrite,
|
||||
authorization domain.OrderAuthorization,
|
||||
) error {
|
||||
var executionMode string
|
||||
err := tx.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT COALESCE(
|
||||
(SELECT execution_mode
|
||||
FROM execution_candidate_batches
|
||||
WHERE execution_id = ? AND task_id = ?),
|
||||
(SELECT execution_mode
|
||||
FROM candidate_search_runs
|
||||
WHERE execution_id = ? AND task_id = ?)
|
||||
)`,
|
||||
write.ExecutionID,
|
||||
write.TaskID,
|
||||
write.ExecutionID,
|
||||
write.TaskID,
|
||||
).Scan(&executionMode)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return usecase.ErrRepositoryInvariant
|
||||
}
|
||||
if err != nil {
|
||||
return repositoryFailure(err)
|
||||
}
|
||||
evidenceIDs, err := json.Marshal([]string{write.EvidenceAssetID})
|
||||
if err != nil {
|
||||
return repositoryFailure(err)
|
||||
}
|
||||
expired := false
|
||||
var claimExpiresAt sql.NullString
|
||||
if err := tx.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT claim_expires_at FROM purchase_tasks WHERE id = ?`,
|
||||
write.TaskID,
|
||||
).Scan(&claimExpiresAt); err != nil {
|
||||
return repositoryFailure(err)
|
||||
}
|
||||
if !claimExpiresAt.Valid {
|
||||
expired = true
|
||||
} else {
|
||||
expiresAt, err := parseTimestamp(claimExpiresAt.String)
|
||||
if err != nil {
|
||||
return repositoryFailure(err)
|
||||
}
|
||||
expired = !expiresAt.After(write.Now)
|
||||
}
|
||||
_, err = tx.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO execution_outcomes (
|
||||
execution_id, task_id, result_type, execution_mode,
|
||||
task_content_sha256, outcome, operator_reason,
|
||||
selected_candidate_json, evidence_asset_ids_json,
|
||||
error_code, error_message, error_step, retryable,
|
||||
order_submitted, received_at,
|
||||
received_after_execution_expiry
|
||||
) VALUES (?, ?, 'COMPLETE', ?, ?, 'CANDIDATE_ACCEPTED',
|
||||
?, NULL, ?, NULL, NULL, NULL, NULL, 1, ?, ?)`,
|
||||
write.ExecutionID,
|
||||
write.TaskID,
|
||||
executionMode,
|
||||
authorization.TaskContentSHA256,
|
||||
"pending-payment order uniquely reconciled",
|
||||
string(evidenceIDs),
|
||||
formatTimestamp(write.Now),
|
||||
expired,
|
||||
)
|
||||
if err != nil {
|
||||
return repositoryFailure(err)
|
||||
}
|
||||
result, err := tx.ExecContext(
|
||||
ctx,
|
||||
`UPDATE task_executions
|
||||
SET current_step = 'ORDER_RECONCILED',
|
||||
last_heartbeat_at = ?,
|
||||
finished_at = ?
|
||||
WHERE id = ? AND finished_at IS NULL`,
|
||||
formatTimestamp(write.Now),
|
||||
formatTimestamp(write.Now),
|
||||
write.ExecutionID,
|
||||
)
|
||||
if err != nil {
|
||||
return repositoryFailure(err)
|
||||
}
|
||||
if affected, err := result.RowsAffected(); err != nil || affected != 1 {
|
||||
if err != nil {
|
||||
return repositoryFailure(err)
|
||||
}
|
||||
return usecase.ErrTaskStateConflict
|
||||
}
|
||||
result, err = tx.ExecContext(
|
||||
ctx,
|
||||
`UPDATE purchase_tasks
|
||||
SET status = 'SUCCEEDED',
|
||||
version = version + 1,
|
||||
claimed_by_user_id = NULL,
|
||||
claimed_by_device_id = NULL,
|
||||
claim_token_hash = NULL,
|
||||
claim_issued_at = NULL,
|
||||
claim_expires_at = NULL,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
AND status = 'WAITING_CONFIRMATION'
|
||||
AND claim_generation = ?`,
|
||||
formatTimestamp(write.Now),
|
||||
write.TaskID,
|
||||
write.ClaimGeneration,
|
||||
)
|
||||
if err != nil {
|
||||
return repositoryFailure(err)
|
||||
}
|
||||
if affected, err := result.RowsAffected(); err != nil || affected != 1 {
|
||||
if err != nil {
|
||||
return repositoryFailure(err)
|
||||
}
|
||||
return usecase.ErrTaskStateConflict
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getOrderSubmissionByAuthorization(
|
||||
ctx context.Context,
|
||||
queryer queryRower,
|
||||
authorizationID string,
|
||||
) (domain.OrderSubmission, bool, error) {
|
||||
submission, err := scanOrderSubmission(queryer.QueryRowContext(
|
||||
ctx,
|
||||
orderSubmissionSelect+` WHERE authorization_id = ?`,
|
||||
authorizationID,
|
||||
))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return domain.OrderSubmission{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return domain.OrderSubmission{}, false, repositoryFailure(err)
|
||||
}
|
||||
return submission, true, nil
|
||||
}
|
||||
|
||||
func getOrderSubmissionByID(
|
||||
ctx context.Context,
|
||||
queryer queryRower,
|
||||
submissionID string,
|
||||
) (domain.OrderSubmission, error) {
|
||||
submission, err := scanOrderSubmission(queryer.QueryRowContext(
|
||||
ctx,
|
||||
orderSubmissionSelect+` WHERE id = ?`,
|
||||
submissionID,
|
||||
))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return domain.OrderSubmission{}, usecase.ErrRepositoryNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return domain.OrderSubmission{}, repositoryFailure(err)
|
||||
}
|
||||
return submission, nil
|
||||
}
|
||||
|
||||
const orderSubmissionSelect = `SELECT
|
||||
id, authorization_id, dry_run_id, task_id, execution_id,
|
||||
command_sha256, dry_run_evidence_sha256, status,
|
||||
expected_title, expected_sku, expected_quantity,
|
||||
expected_unit_price_cents, expected_total_price_cents,
|
||||
platform_order_no, platform_ordered_at, platform_order_status,
|
||||
reconciliation_evidence_asset_id, reconciliation_evidence_sha256,
|
||||
manual_reason_code, fenced_at, reconciled_at, manual_review_at
|
||||
FROM order_submissions`
|
||||
|
||||
func scanOrderSubmission(
|
||||
scanner rowScanner,
|
||||
) (domain.OrderSubmission, error) {
|
||||
var submission domain.OrderSubmission
|
||||
var orderNo, orderedAt, orderStatus sql.NullString
|
||||
var evidenceID, evidenceSHA, manualReason sql.NullString
|
||||
var fencedAt string
|
||||
var reconciledAt, manualReviewAt sql.NullString
|
||||
err := scanner.Scan(
|
||||
&submission.ID,
|
||||
&submission.AuthorizationID,
|
||||
&submission.DryRunID,
|
||||
&submission.TaskID,
|
||||
&submission.ExecutionID,
|
||||
&submission.CommandSHA256,
|
||||
&submission.DryRunEvidenceSHA256,
|
||||
&submission.Status,
|
||||
&submission.ExpectedTitle,
|
||||
&submission.ExpectedSKU,
|
||||
&submission.ExpectedQuantity,
|
||||
&submission.ExpectedUnitPriceCents,
|
||||
&submission.ExpectedTotalPriceCents,
|
||||
&orderNo,
|
||||
&orderedAt,
|
||||
&orderStatus,
|
||||
&evidenceID,
|
||||
&evidenceSHA,
|
||||
&manualReason,
|
||||
&fencedAt,
|
||||
&reconciledAt,
|
||||
&manualReviewAt,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.OrderSubmission{}, err
|
||||
}
|
||||
submission.PlatformOrderNo = optionalSQLString(orderNo)
|
||||
submission.PlatformOrderStatus = optionalSQLString(orderStatus)
|
||||
submission.ReconciliationEvidenceAssetID = optionalSQLString(evidenceID)
|
||||
submission.ReconciliationEvidenceSHA256 = optionalSQLString(evidenceSHA)
|
||||
submission.ManualReasonCode = optionalSQLString(manualReason)
|
||||
submission.FencedAt, err = parseTimestamp(fencedAt)
|
||||
if err != nil {
|
||||
return domain.OrderSubmission{}, err
|
||||
}
|
||||
if orderedAt.Valid {
|
||||
parsed, parseErr := parseTimestamp(orderedAt.String)
|
||||
if parseErr != nil {
|
||||
return domain.OrderSubmission{}, parseErr
|
||||
}
|
||||
submission.PlatformOrderedAt = &parsed
|
||||
}
|
||||
if submission.ReconciledAt, err = parseNullableTimestamp(reconciledAt); err != nil {
|
||||
return domain.OrderSubmission{}, err
|
||||
}
|
||||
if submission.ManualReviewAt, err = parseNullableTimestamp(manualReviewAt); err != nil {
|
||||
return domain.OrderSubmission{}, err
|
||||
}
|
||||
return submission, nil
|
||||
}
|
||||
|
||||
func optionalSQLString(value sql.NullString) *string {
|
||||
if !value.Valid {
|
||||
return nil
|
||||
}
|
||||
return &value.String
|
||||
}
|
||||
|
||||
func replayOrderSubmissionRequest(
|
||||
ctx context.Context,
|
||||
tx *sql.Tx,
|
||||
deviceID, operation, idempotencyKey, requestSHA256, taskID string,
|
||||
) (domain.OrderSubmission, bool, error) {
|
||||
var knownHash, knownTask, submissionID string
|
||||
err := tx.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT request_sha256, task_id, submission_id
|
||||
FROM device_order_submission_requests
|
||||
WHERE device_id = ? AND operation = ? AND idempotency_key = ?`,
|
||||
deviceID,
|
||||
operation,
|
||||
idempotencyKey,
|
||||
).Scan(&knownHash, &knownTask, &submissionID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return domain.OrderSubmission{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return domain.OrderSubmission{}, false, repositoryFailure(err)
|
||||
}
|
||||
if knownHash != requestSHA256 || knownTask != taskID {
|
||||
return domain.OrderSubmission{}, false, usecase.ErrIdempotencyConflict
|
||||
}
|
||||
result, err := getOrderSubmissionByID(ctx, tx, submissionID)
|
||||
if err != nil {
|
||||
return domain.OrderSubmission{}, false, err
|
||||
}
|
||||
return result, true, nil
|
||||
}
|
||||
|
||||
func commitOrderSubmissionReplay(
|
||||
tx *sql.Tx,
|
||||
result domain.OrderSubmission,
|
||||
found bool,
|
||||
err error,
|
||||
) (domain.OrderSubmission, bool, error) {
|
||||
if err != nil || !found {
|
||||
return domain.OrderSubmission{}, false, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return domain.OrderSubmission{}, false, repositoryFailure(err)
|
||||
}
|
||||
return result, true, nil
|
||||
}
|
||||
|
||||
func insertOrderSubmissionRequest(
|
||||
ctx context.Context,
|
||||
tx *sql.Tx,
|
||||
deviceID, operation, idempotencyKey, requestSHA256, taskID,
|
||||
submissionID string,
|
||||
now time.Time,
|
||||
) error {
|
||||
_, err := tx.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO device_order_submission_requests (
|
||||
device_id, operation, idempotency_key, request_sha256,
|
||||
task_id, submission_id, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
deviceID,
|
||||
operation,
|
||||
idempotencyKey,
|
||||
requestSHA256,
|
||||
taskID,
|
||||
submissionID,
|
||||
formatTimestamp(now.UTC()),
|
||||
)
|
||||
if err != nil {
|
||||
return repositoryFailure(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeSubmissionText(value string) string {
|
||||
return strings.Map(func(character rune) rune {
|
||||
if unicode.IsSpace(character) || unicode.IsPunct(character) {
|
||||
return -1
|
||||
}
|
||||
return unicode.ToLower(character)
|
||||
}, value)
|
||||
}
|
||||
|
||||
var _ usecase.OrderSubmissionRepository = (*Store)(nil)
|
||||
Reference in New Issue
Block a user