feat(t217): verify authorized order dry runs

This commit is contained in:
QiuSW
2026-07-28 16:52:29 +08:00
parent 913107c28d
commit 1e87b6273a
45 changed files with 4358 additions and 128 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(v10) error = %v", err)
}
if err := runner.Down(context.Background()); err != nil {
t.Fatalf("Down(v9) error = %v", err)
}
@@ -417,9 +420,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-v9) error = %v", err)
} else if applied != 7 {
t.Fatalf("Up(v3-v9) applied = %d, want 7", applied)
t.Fatalf("Up(v3-v10) error = %v", err)
} else if applied != 8 {
t.Fatalf("Up(v3-v10) applied = %d, want 8", applied)
}
}
@@ -108,14 +108,15 @@ func (s *Store) PullDeviceOrderCommand(
}
command.AuthorizationStatus = domain.OrderAuthorizationDelivered
case domain.OrderAuthorizationDelivered,
domain.OrderAuthorizationAcknowledged:
domain.OrderAuthorizationAcknowledged,
domain.OrderAuthorizationExecuting:
result, err := tx.ExecContext(
ctx,
`UPDATE order_authorizations
SET last_delivered_at = ?,
delivery_attempt_count = delivery_attempt_count + 1
WHERE id = ?
AND status IN ('DELIVERED', 'ACKNOWLEDGED')
AND status IN ('DELIVERED', 'ACKNOWLEDGED', 'EXECUTING')
AND command_sha256 = ?`,
formatTimestamp(write.Now),
authorization.ID,
@@ -389,7 +390,8 @@ func findActiveDeviceOrderAuthorization(
AND oa.status IN (
'PENDING_DELIVERY',
'DELIVERED',
'ACKNOWLEDGED'
'ACKNOWLEDGED',
'EXECUTING'
)
ORDER BY oa.authorization_version DESC
LIMIT 1`,
@@ -0,0 +1,521 @@
package sqlite
import (
"context"
"database/sql"
"errors"
"math"
"regexp"
"strconv"
"strings"
"time"
"unicode"
"cmroubao/backend-api/internal/domain"
"cmroubao/backend-api/internal/usecase"
)
func (s *Store) StartOrderDryRun(
ctx context.Context,
write usecase.StartOrderDryRunWrite,
) (domain.OrderDryRun, bool, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return domain.OrderDryRun{}, false, repositoryFailure(err)
}
defer func() { _ = tx.Rollback() }()
if result, found, err := replayOrderDryRunRequest(
ctx, tx, write.DeviceID, "START", write.IdempotencyKey,
write.RequestSHA256, write.TaskID,
); err != nil || found {
return commitOrderDryRunReplay(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.OrderDryRun{}, false, err
}
authorization, err := getOrderAuthorization(
ctx, tx, write.AuthorizationID,
)
if err != nil {
return domain.OrderDryRun{}, false, err
}
if err := validateDryRunAuthorization(
authorization, write.UserID, write.DeviceID, write.TaskID,
write.ExecutionID, write.ClaimGeneration, write.CommandSHA256,
); err != nil {
return domain.OrderDryRun{}, false, err
}
dryRun, found, err := getOrderDryRunByAuthorization(
ctx, tx, authorization.ID,
)
if err != nil {
return domain.OrderDryRun{}, false, err
}
switch authorization.Status {
case domain.OrderAuthorizationAcknowledged:
if found {
return domain.OrderDryRun{}, false, usecase.ErrRepositoryInvariant
}
result, err := tx.ExecContext(
ctx,
`UPDATE order_authorizations
SET status = 'EXECUTING', execution_started_at = ?
WHERE id = ? AND status = 'ACKNOWLEDGED'
AND command_sha256 = ?`,
formatTimestamp(write.Now),
authorization.ID,
write.CommandSHA256,
)
if err != nil {
return domain.OrderDryRun{}, false, repositoryFailure(err)
}
if affected, err := result.RowsAffected(); err != nil || affected != 1 {
if err != nil {
return domain.OrderDryRun{}, false, repositoryFailure(err)
}
return domain.OrderDryRun{}, false, usecase.ErrTaskStateConflict
}
_, err = tx.ExecContext(
ctx,
`INSERT INTO order_dry_runs (
id, authorization_id, task_id, execution_id,
command_sha256, status, started_at
) VALUES (?, ?, ?, ?, ?, 'PREPARING', ?)`,
write.DryRunID,
authorization.ID,
write.TaskID,
write.ExecutionID,
write.CommandSHA256,
formatTimestamp(write.Now),
)
if err != nil {
return domain.OrderDryRun{}, false, repositoryFailure(err)
}
if err := insertTaskEvent(ctx, tx, write.Event); err != nil {
return domain.OrderDryRun{}, false, err
}
dryRun, found, err = getOrderDryRunByAuthorization(
ctx, tx, authorization.ID,
)
if err != nil || !found {
if err != nil {
return domain.OrderDryRun{}, false, err
}
return domain.OrderDryRun{}, false, usecase.ErrRepositoryInvariant
}
case domain.OrderAuthorizationExecuting:
if !found || dryRun.CommandSHA256 != write.CommandSHA256 {
return domain.OrderDryRun{}, false, usecase.ErrTaskStateConflict
}
default:
return domain.OrderDryRun{}, false, usecase.ErrTaskStateConflict
}
if err := insertOrderDryRunRequest(
ctx, tx, write.DeviceID, "START", write.IdempotencyKey,
write.RequestSHA256, write.TaskID, dryRun.ID, write.Now,
); err != nil {
return domain.OrderDryRun{}, false, err
}
if err := tx.Commit(); err != nil {
return domain.OrderDryRun{}, false, repositoryFailure(err)
}
return dryRun, false, nil
}
func (s *Store) ReadyOrderDryRun(
ctx context.Context,
write usecase.ReadyOrderDryRunWrite,
) (domain.OrderDryRun, bool, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return domain.OrderDryRun{}, false, repositoryFailure(err)
}
defer func() { _ = tx.Rollback() }()
if result, found, err := replayOrderDryRunRequest(
ctx, tx, write.DeviceID, "READY", write.IdempotencyKey,
write.RequestSHA256, write.TaskID,
); err != nil || found {
return commitOrderDryRunReplay(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.OrderDryRun{}, false, err
}
authorization, err := getOrderAuthorization(
ctx, tx, write.AuthorizationID,
)
if err != nil {
return domain.OrderDryRun{}, false, err
}
if err := validateDryRunAuthorization(
authorization, write.UserID, write.DeviceID, write.TaskID,
write.ExecutionID, write.ClaimGeneration, write.CommandSHA256,
); err != nil {
return domain.OrderDryRun{}, false, err
}
if authorization.Status != domain.OrderAuthorizationExecuting {
return domain.OrderDryRun{}, false, usecase.ErrTaskStateConflict
}
dryRun, found, err := getOrderDryRunByAuthorization(
ctx, tx, authorization.ID,
)
if err != nil {
return domain.OrderDryRun{}, false, err
}
if !found || dryRun.CommandSHA256 != write.CommandSHA256 {
return domain.OrderDryRun{}, false, usecase.ErrTaskStateConflict
}
if dryRun.Status == domain.OrderDryRunReady {
return domain.OrderDryRun{}, false, usecase.ErrTaskStateConflict
}
if write.Quantity != authorization.Quantity ||
write.SelectedSKU != authorization.OriginalSKU ||
(write.CardSignature != authorization.CardSignature &&
write.DetailSignature != authorization.DetailSignature) {
return domain.OrderDryRun{}, false, usecase.ErrTaskStateConflict
}
if write.UnitPriceCents > math.MaxInt64/int64(write.Quantity) ||
write.TotalPriceCents !=
write.UnitPriceCents*int64(write.Quantity) {
return domain.OrderDryRun{}, false, usecase.ErrTaskStateConflict
}
var observedTitle string
err = tx.QueryRowContext(
ctx,
`SELECT co.title
FROM candidate_observation_identities coi
JOIN candidate_observations co
ON co.execution_id = coi.execution_id
AND co.ordinal = coi.candidate_ordinal
WHERE coi.candidate_key = ? AND coi.execution_id = ?`,
authorization.CandidateKey,
authorization.ExecutionID,
).Scan(&observedTitle)
if errors.Is(err, sql.ErrNoRows) {
return domain.OrderDryRun{}, false, usecase.ErrRepositoryInvariant
}
if err != nil {
return domain.OrderDryRun{}, false, repositoryFailure(err)
}
if normalizeDryRunTitle(observedTitle) !=
normalizeDryRunTitle(write.ObservedTitle) {
return domain.OrderDryRun{}, false, usecase.ErrTaskStateConflict
}
task, err := getClaimProtectedTask(ctx, tx, write.TaskID)
if err != nil {
return domain.OrderDryRun{}, false, err
}
if task.MaxBudgetCents != nil &&
write.TotalPriceCents > *task.MaxBudgetCents {
return domain.OrderDryRun{}, false, usecase.ErrTaskStateConflict
}
if task.MaxBudgetCents == nil {
authorizedPrice, ok := parseDryRunPrice(
authorization.CandidatePriceText,
)
if !ok || write.UnitPriceCents != authorizedPrice {
return domain.OrderDryRun{}, false, usecase.ErrTaskStateConflict
}
}
evidence, err := getExecutionEvidence(
ctx, tx, write.EvidenceAssetID,
)
if err != nil {
return domain.OrderDryRun{}, false, err
}
if evidence.TaskID != write.TaskID ||
evidence.ExecutionID != write.ExecutionID ||
evidence.MediaType != "image/jpeg" ||
evidence.SHA256 != write.EvidenceSHA256 ||
evidence.ReceivedAfterExecutionExpiry {
return domain.OrderDryRun{}, false, usecase.ErrTaskStateConflict
}
result, err := tx.ExecContext(
ctx,
`UPDATE order_dry_runs
SET status = 'READY',
card_signature = ?,
detail_signature = ?,
observed_title = ?,
selected_sku = ?,
quantity = ?,
unit_price_cents = ?,
total_price_cents = ?,
evidence_asset_id = ?,
evidence_sha256 = ?,
ready_at = ?
WHERE id = ? AND status = 'PREPARING'`,
write.CardSignature,
write.DetailSignature,
write.ObservedTitle,
write.SelectedSKU,
write.Quantity,
write.UnitPriceCents,
write.TotalPriceCents,
write.EvidenceAssetID,
write.EvidenceSHA256,
formatTimestamp(write.Now),
dryRun.ID,
)
if err != nil {
return domain.OrderDryRun{}, false, repositoryFailure(err)
}
if affected, err := result.RowsAffected(); err != nil || affected != 1 {
if err != nil {
return domain.OrderDryRun{}, false, repositoryFailure(err)
}
return domain.OrderDryRun{}, false, usecase.ErrTaskStateConflict
}
if err := insertTaskEvent(ctx, tx, write.Event); err != nil {
return domain.OrderDryRun{}, false, err
}
if err := insertOrderDryRunRequest(
ctx, tx, write.DeviceID, "READY", write.IdempotencyKey,
write.RequestSHA256, write.TaskID, dryRun.ID, write.Now,
); err != nil {
return domain.OrderDryRun{}, false, err
}
dryRun, found, err = getOrderDryRunByAuthorization(
ctx, tx, authorization.ID,
)
if err != nil || !found {
if err != nil {
return domain.OrderDryRun{}, false, err
}
return domain.OrderDryRun{}, false, usecase.ErrRepositoryInvariant
}
if err := tx.Commit(); err != nil {
return domain.OrderDryRun{}, false, repositoryFailure(err)
}
return dryRun, false, nil
}
func validateDryRunAuthorization(
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 normalizeDryRunTitle(value string) string {
return strings.Map(func(character rune) rune {
if unicode.IsSpace(character) || unicode.IsPunct(character) {
return -1
}
return unicode.ToLower(character)
}, value)
}
func parseDryRunPrice(value string) (int64, bool) {
matches := dryRunPricePattern.FindStringSubmatch(strings.TrimSpace(value))
if len(matches) != 3 {
return 0, false
}
whole, err := strconv.ParseInt(matches[1], 10, 64)
if err != nil {
return 0, false
}
fractionText := matches[2]
if len(fractionText) == 1 {
fractionText += "0"
}
fraction := int64(0)
if fractionText != "" {
fraction, err = strconv.ParseInt(fractionText, 10, 64)
if err != nil {
return 0, false
}
}
cents := whole*100 + fraction
return cents, cents > 0 && cents <= 100_000_000
}
var dryRunPricePattern = regexp.MustCompile(
`^(?:首件)?[¥¥]?(0|[1-9][0-9]{0,6})(?:\.([0-9]{1,2}))?$`,
)
func getOrderDryRunByAuthorization(
ctx context.Context,
queryer queryRower,
authorizationID string,
) (domain.OrderDryRun, bool, error) {
var result domain.OrderDryRun
var card, detail, title, sku sql.NullString
var quantity sql.NullInt64
var unitPrice, totalPrice sql.NullInt64
var evidenceID, evidenceSHA, readyAt sql.NullString
var startedAt string
err := queryer.QueryRowContext(
ctx,
`SELECT id, authorization_id, task_id, execution_id,
command_sha256, status, card_signature, detail_signature,
observed_title, selected_sku, quantity, unit_price_cents,
total_price_cents, evidence_asset_id, evidence_sha256,
started_at, ready_at
FROM order_dry_runs WHERE authorization_id = ?`,
authorizationID,
).Scan(
&result.ID,
&result.AuthorizationID,
&result.TaskID,
&result.ExecutionID,
&result.CommandSHA256,
&result.Status,
&card,
&detail,
&title,
&sku,
&quantity,
&unitPrice,
&totalPrice,
&evidenceID,
&evidenceSHA,
&startedAt,
&readyAt,
)
if errors.Is(err, sql.ErrNoRows) {
return domain.OrderDryRun{}, false, nil
}
if err != nil {
return domain.OrderDryRun{}, false, repositoryFailure(err)
}
result.CardSignature = dryRunOptionalString(card)
result.DetailSignature = dryRunOptionalString(detail)
result.ObservedTitle = dryRunOptionalString(title)
result.SelectedSKU = dryRunOptionalString(sku)
if quantity.Valid {
value := int(quantity.Int64)
result.Quantity = &value
}
if unitPrice.Valid {
result.UnitPriceCents = &unitPrice.Int64
}
if totalPrice.Valid {
result.TotalPriceCents = &totalPrice.Int64
}
result.EvidenceAssetID = dryRunOptionalString(evidenceID)
result.EvidenceSHA256 = dryRunOptionalString(evidenceSHA)
parsed, err := parseTimestamp(startedAt)
if err != nil {
return domain.OrderDryRun{}, false, repositoryFailure(err)
}
result.StartedAt = parsed
if readyAt.Valid {
parsed, parseErr := parseTimestamp(readyAt.String)
if parseErr != nil {
return domain.OrderDryRun{}, false, repositoryFailure(parseErr)
}
result.ReadyAt = &parsed
}
return result, true, nil
}
func dryRunOptionalString(value sql.NullString) *string {
if !value.Valid {
return nil
}
return &value.String
}
func replayOrderDryRunRequest(
ctx context.Context,
tx *sql.Tx,
deviceID, operation, idempotencyKey, requestSHA256, taskID string,
) (domain.OrderDryRun, bool, error) {
var knownHash, knownTask, dryRunID string
err := tx.QueryRowContext(
ctx,
`SELECT request_sha256, task_id, dry_run_id
FROM device_order_dry_run_requests
WHERE device_id = ? AND operation = ? AND idempotency_key = ?`,
deviceID,
operation,
idempotencyKey,
).Scan(&knownHash, &knownTask, &dryRunID)
if errors.Is(err, sql.ErrNoRows) {
return domain.OrderDryRun{}, false, nil
}
if err != nil {
return domain.OrderDryRun{}, false, repositoryFailure(err)
}
if knownHash != requestSHA256 || knownTask != taskID {
return domain.OrderDryRun{}, false, usecase.ErrIdempotencyConflict
}
var authorizationID string
if err := tx.QueryRowContext(
ctx,
`SELECT authorization_id FROM order_dry_runs WHERE id = ?`,
dryRunID,
).Scan(&authorizationID); err != nil {
return domain.OrderDryRun{}, false, repositoryFailure(err)
}
result, found, err := getOrderDryRunByAuthorization(
ctx, tx, authorizationID,
)
if err != nil || !found {
if err != nil {
return domain.OrderDryRun{}, false, err
}
return domain.OrderDryRun{}, false, usecase.ErrRepositoryInvariant
}
return result, true, nil
}
func commitOrderDryRunReplay(
tx *sql.Tx,
result domain.OrderDryRun,
found bool,
err error,
) (domain.OrderDryRun, bool, error) {
if err != nil || !found {
return domain.OrderDryRun{}, false, err
}
if err := tx.Commit(); err != nil {
return domain.OrderDryRun{}, false, repositoryFailure(err)
}
return result, true, nil
}
func insertOrderDryRunRequest(
ctx context.Context,
tx *sql.Tx,
deviceID, operation, idempotencyKey, requestSHA256, taskID, dryRunID string,
now time.Time,
) error {
_, err := tx.ExecContext(
ctx,
`INSERT INTO device_order_dry_run_requests (
device_id, operation, idempotency_key, request_sha256,
task_id, dry_run_id, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
deviceID,
operation,
idempotencyKey,
requestSHA256,
taskID,
dryRunID,
formatTimestamp(now.UTC()),
)
if err != nil {
return repositoryFailure(err)
}
return nil
}