feat(t218): reconcile single order submissions

This commit is contained in:
QiuSW
2026-07-28 17:48:54 +08:00
parent 682cc7327c
commit a77d8be06d
32 changed files with 4306 additions and 79 deletions
@@ -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)