Files
cmroubao/backend-api/internal/usecase/order_submission_service.go
T

527 lines
16 KiB
Go

package usecase
import (
"context"
"errors"
"math"
"regexp"
"strings"
"time"
"cmroubao/backend-api/internal/domain"
)
const (
orderSubmissionStartOperation = "START"
orderSubmissionReconcileOperation = "RECONCILE"
orderSubmissionManualReviewOperation = "MANUAL_REVIEW"
)
type StartOrderSubmissionCommand struct {
UserID string
DeviceID string
TaskID string
ExecutionID string
AuthorizationID string
ClaimGeneration int64
ClaimToken string
CommandSHA256 string
DryRunID string
DryRunEvidenceSHA256 string
ObservedTitle string
SelectedSKU string
Quantity int
UnitPriceCents int64
TotalPriceCents int64
IdempotencyKey string
}
type StartOrderSubmissionWrite struct {
StartOrderSubmissionCommand
SubmissionID string
ClaimTokenHash string
RequestSHA256 string
Now time.Time
Event domain.TaskEvent
}
type ReconcileOrderSubmissionCommand struct {
UserID string
DeviceID string
TaskID string
ExecutionID string
AuthorizationID string
ClaimGeneration int64
ClaimToken string
CommandSHA256 string
SubmissionID string
PlatformOrderNo string
PlatformOrderedAt string
PlatformOrderStatus string
ObservedTitle string
SelectedSKU string
Quantity int
TotalPriceCents int64
EvidenceAssetID string
EvidenceSHA256 string
IdempotencyKey string
}
type ReconcileOrderSubmissionWrite struct {
ReconcileOrderSubmissionCommand
ParsedPlatformOrderedAt time.Time
ClaimTokenHash string
RequestSHA256 string
Now time.Time
Event domain.TaskEvent
}
type ManualReviewOrderSubmissionCommand struct {
UserID string
DeviceID string
TaskID string
ExecutionID string
AuthorizationID string
ClaimGeneration int64
ClaimToken string
CommandSHA256 string
SubmissionID string
ReasonCode string
EvidenceAssetID string
EvidenceSHA256 string
IdempotencyKey string
}
type ManualReviewOrderSubmissionWrite struct {
ManualReviewOrderSubmissionCommand
ClaimTokenHash string
RequestSHA256 string
Now time.Time
Event domain.TaskEvent
}
type OrderSubmissionResult struct {
Submission domain.OrderSubmission
Replayed bool
}
type OrderSubmissionRepository interface {
StartOrderSubmission(
context.Context,
StartOrderSubmissionWrite,
) (domain.OrderSubmission, bool, error)
ReconcileOrderSubmission(
context.Context,
ReconcileOrderSubmissionWrite,
) (domain.OrderSubmission, bool, error)
ManualReviewOrderSubmission(
context.Context,
ManualReviewOrderSubmissionWrite,
) (domain.OrderSubmission, bool, error)
}
type OrderSubmissionService struct {
repository OrderSubmissionRepository
clock Clock
ids IDGenerator
}
func NewOrderSubmissionService(
repository OrderSubmissionRepository,
clock Clock,
ids IDGenerator,
) (*OrderSubmissionService, error) {
if repository == nil || clock == nil || ids == nil {
return nil, errors.New("order submission service dependencies are required")
}
return &OrderSubmissionService{
repository: repository,
clock: clock,
ids: ids,
}, nil
}
func (service *OrderSubmissionService) Start(
ctx context.Context,
command StartOrderSubmissionCommand,
) (OrderSubmissionResult, error) {
command = normalizeStartOrderSubmission(command)
fields := orderSubmissionIdentityFields(
command.UserID,
command.DeviceID,
command.TaskID,
command.ExecutionID,
command.AuthorizationID,
command.ClaimGeneration,
command.ClaimToken,
command.CommandSHA256,
command.IdempotencyKey,
)
if !isUUID(command.DryRunID) {
fields["dry_run_id"] = "must be a UUID"
}
if !sha256Pattern.MatchString(command.DryRunEvidenceSHA256) {
fields["dry_run_evidence_sha256"] = "must be lowercase SHA-256"
}
validateOrderSnapshot(
fields,
command.ObservedTitle,
command.SelectedSKU,
command.Quantity,
command.UnitPriceCents,
command.TotalPriceCents,
)
if command.Quantity > 0 &&
command.UnitPriceCents <= math.MaxInt64/int64(command.Quantity) &&
command.TotalPriceCents !=
command.UnitPriceCents*int64(command.Quantity) {
fields["total_price_cents"] = "must equal unit price times quantity"
}
if len(fields) > 0 {
return OrderSubmissionResult{}, invalidError(
"ORDER_SUBMISSION_START_INVALID",
"order submission start request is invalid",
fields,
)
}
requestHash, err := lifecycleRequestHash(command)
if err != nil {
return OrderSubmissionResult{}, internalLifecycleFailure(err)
}
submissionID, err := service.ids.NewID()
if err != nil {
return OrderSubmissionResult{}, internalLifecycleFailure(err)
}
eventID, err := service.ids.NewID()
if err != nil {
return OrderSubmissionResult{}, internalLifecycleFailure(err)
}
now := service.clock.Now().UTC()
userID, deviceID := command.UserID, command.DeviceID
submission, replayed, err := service.repository.StartOrderSubmission(
ctx,
StartOrderSubmissionWrite{
StartOrderSubmissionCommand: command,
SubmissionID: submissionID,
ClaimTokenHash: hashSecret(command.ClaimToken),
RequestSHA256: requestHash,
Now: now,
Event: domain.TaskEvent{
ID: eventID,
TaskID: command.TaskID,
ActorUserID: &userID,
ActorDeviceID: &deviceID,
Type: "ORDER_SUBMISSION_FENCED",
Message: "single order submission fenced",
OccurredAt: now,
},
},
)
if err != nil {
return OrderSubmissionResult{}, wrapLifecycleRepositoryError(err)
}
return OrderSubmissionResult{
Submission: submission,
Replayed: replayed,
}, nil
}
func (service *OrderSubmissionService) Reconcile(
ctx context.Context,
command ReconcileOrderSubmissionCommand,
) (OrderSubmissionResult, error) {
command = normalizeReconcileOrderSubmission(command)
fields := orderSubmissionIdentityFields(
command.UserID,
command.DeviceID,
command.TaskID,
command.ExecutionID,
command.AuthorizationID,
command.ClaimGeneration,
command.ClaimToken,
command.CommandSHA256,
command.IdempotencyKey,
)
if !isUUID(command.SubmissionID) {
fields["submission_id"] = "must be a UUID"
}
if !platformOrderNumberPattern.MatchString(command.PlatformOrderNo) {
fields["platform_order_no"] = "must be 8 to 40 digits"
}
orderedAt, err := time.Parse(time.RFC3339, command.PlatformOrderedAt)
if err != nil {
fields["platform_ordered_at"] = "must be RFC3339"
}
if command.PlatformOrderStatus != "PENDING_PAYMENT" {
fields["platform_order_status"] = "must be PENDING_PAYMENT"
}
validateReconciledOrderSnapshot(
fields,
command.ObservedTitle,
command.SelectedSKU,
command.Quantity,
command.TotalPriceCents,
)
if !isUUID(command.EvidenceAssetID) {
fields["evidence_asset_id"] = "must be a UUID"
}
if !sha256Pattern.MatchString(command.EvidenceSHA256) {
fields["evidence_sha256"] = "must be lowercase SHA-256"
}
if len(fields) > 0 {
return OrderSubmissionResult{}, invalidError(
"ORDER_SUBMISSION_RECONCILE_INVALID",
"order submission reconciliation request is invalid",
fields,
)
}
requestHash, err := lifecycleRequestHash(command)
if err != nil {
return OrderSubmissionResult{}, internalLifecycleFailure(err)
}
eventID, err := service.ids.NewID()
if err != nil {
return OrderSubmissionResult{}, internalLifecycleFailure(err)
}
now := service.clock.Now().UTC()
userID, deviceID := command.UserID, command.DeviceID
submission, replayed, err := service.repository.ReconcileOrderSubmission(
ctx,
ReconcileOrderSubmissionWrite{
ReconcileOrderSubmissionCommand: command,
ParsedPlatformOrderedAt: orderedAt.UTC(),
ClaimTokenHash: hashSecret(command.ClaimToken),
RequestSHA256: requestHash,
Now: now,
Event: domain.TaskEvent{
ID: eventID,
TaskID: command.TaskID,
ActorUserID: &userID,
ActorDeviceID: &deviceID,
Type: "ORDER_SUBMISSION_RECONCILED",
Message: "pending-payment order uniquely reconciled",
OccurredAt: now,
},
},
)
if err != nil {
return OrderSubmissionResult{}, wrapLifecycleRepositoryError(err)
}
return OrderSubmissionResult{
Submission: submission,
Replayed: replayed,
}, nil
}
func (service *OrderSubmissionService) ManualReview(
ctx context.Context,
command ManualReviewOrderSubmissionCommand,
) (OrderSubmissionResult, error) {
command = normalizeManualReviewOrderSubmission(command)
fields := orderSubmissionIdentityFields(
command.UserID,
command.DeviceID,
command.TaskID,
command.ExecutionID,
command.AuthorizationID,
command.ClaimGeneration,
command.ClaimToken,
command.CommandSHA256,
command.IdempotencyKey,
)
if !isUUID(command.SubmissionID) {
fields["submission_id"] = "must be a UUID"
}
if _, ok := orderSubmissionManualReasons[command.ReasonCode]; !ok {
fields["reason_code"] = "must be an allowed reason"
}
if (command.EvidenceAssetID == "") != (command.EvidenceSHA256 == "") {
fields["evidence"] = "asset id and SHA-256 must be provided together"
} else if command.EvidenceAssetID != "" {
if !isUUID(command.EvidenceAssetID) {
fields["evidence_asset_id"] = "must be a UUID"
}
if !sha256Pattern.MatchString(command.EvidenceSHA256) {
fields["evidence_sha256"] = "must be lowercase SHA-256"
}
}
if len(fields) > 0 {
return OrderSubmissionResult{}, invalidError(
"ORDER_SUBMISSION_MANUAL_REVIEW_INVALID",
"order submission manual-review request is invalid",
fields,
)
}
requestHash, err := lifecycleRequestHash(command)
if err != nil {
return OrderSubmissionResult{}, internalLifecycleFailure(err)
}
eventID, err := service.ids.NewID()
if err != nil {
return OrderSubmissionResult{}, internalLifecycleFailure(err)
}
now := service.clock.Now().UTC()
userID, deviceID := command.UserID, command.DeviceID
submission, replayed, err := service.repository.ManualReviewOrderSubmission(
ctx,
ManualReviewOrderSubmissionWrite{
ManualReviewOrderSubmissionCommand: command,
ClaimTokenHash: hashSecret(command.ClaimToken),
RequestSHA256: requestHash,
Now: now,
Event: domain.TaskEvent{
ID: eventID,
TaskID: command.TaskID,
ActorUserID: &userID,
ActorDeviceID: &deviceID,
Type: "ORDER_SUBMISSION_MANUAL_REVIEW",
Message: "order submission requires manual reconciliation",
OccurredAt: now,
},
},
)
if err != nil {
return OrderSubmissionResult{}, wrapLifecycleRepositoryError(err)
}
return OrderSubmissionResult{
Submission: submission,
Replayed: replayed,
}, nil
}
func orderSubmissionIdentityFields(
userID, deviceID, taskID, executionID, authorizationID string,
claimGeneration int64,
claimToken, commandSHA256, idempotencyKey string,
) map[string]string {
return dryRunIdentityFields(
userID,
deviceID,
taskID,
executionID,
authorizationID,
claimGeneration,
claimToken,
commandSHA256,
idempotencyKey,
)
}
func validateOrderSnapshot(
fields map[string]string,
title, sku string,
quantity int,
unitPriceCents, totalPriceCents int64,
) {
if title == "" || len([]byte(title)) > 1024 {
fields["observed_title"] = "must be 1 to 1024 UTF-8 bytes"
}
if sku == "" || len([]byte(sku)) > 512 {
fields["selected_sku"] = "must be 1 to 512 UTF-8 bytes"
}
if quantity < 1 || quantity > 99 {
fields["quantity"] = "must be 1 to 99"
}
if unitPriceCents < 1 {
fields["unit_price_cents"] = "must be positive"
}
if totalPriceCents < 1 {
fields["total_price_cents"] = "must be positive"
}
if quantity > 0 &&
unitPriceCents > math.MaxInt64/int64(quantity) {
fields["total_price_cents"] = "price multiplication overflows"
}
}
func validateReconciledOrderSnapshot(
fields map[string]string,
title, sku string,
quantity int,
totalPriceCents int64,
) {
if title == "" || len([]byte(title)) > 1024 {
fields["observed_title"] = "must be 1 to 1024 UTF-8 bytes"
}
if sku == "" || len([]byte(sku)) > 512 {
fields["selected_sku"] = "must be 1 to 512 UTF-8 bytes"
}
if quantity < 1 || quantity > 99 {
fields["quantity"] = "must be 1 to 99"
}
if totalPriceCents < 1 {
fields["total_price_cents"] = "must be positive"
}
}
func normalizeStartOrderSubmission(
command StartOrderSubmissionCommand,
) StartOrderSubmissionCommand {
command.UserID = strings.TrimSpace(command.UserID)
command.DeviceID = strings.TrimSpace(command.DeviceID)
command.TaskID = strings.TrimSpace(command.TaskID)
command.ExecutionID = strings.TrimSpace(command.ExecutionID)
command.AuthorizationID = strings.TrimSpace(command.AuthorizationID)
command.ClaimToken = strings.TrimSpace(command.ClaimToken)
command.CommandSHA256 = strings.TrimSpace(command.CommandSHA256)
command.DryRunID = strings.TrimSpace(command.DryRunID)
command.DryRunEvidenceSHA256 = strings.TrimSpace(
command.DryRunEvidenceSHA256,
)
command.ObservedTitle = strings.TrimSpace(command.ObservedTitle)
command.SelectedSKU = strings.TrimSpace(command.SelectedSKU)
command.IdempotencyKey = strings.TrimSpace(command.IdempotencyKey)
return command
}
func normalizeReconcileOrderSubmission(
command ReconcileOrderSubmissionCommand,
) ReconcileOrderSubmissionCommand {
command.UserID = strings.TrimSpace(command.UserID)
command.DeviceID = strings.TrimSpace(command.DeviceID)
command.TaskID = strings.TrimSpace(command.TaskID)
command.ExecutionID = strings.TrimSpace(command.ExecutionID)
command.AuthorizationID = strings.TrimSpace(command.AuthorizationID)
command.ClaimToken = strings.TrimSpace(command.ClaimToken)
command.CommandSHA256 = strings.TrimSpace(command.CommandSHA256)
command.SubmissionID = strings.TrimSpace(command.SubmissionID)
command.PlatformOrderNo = strings.TrimSpace(command.PlatformOrderNo)
command.PlatformOrderedAt = strings.TrimSpace(command.PlatformOrderedAt)
command.PlatformOrderStatus = strings.TrimSpace(command.PlatformOrderStatus)
command.ObservedTitle = strings.TrimSpace(command.ObservedTitle)
command.SelectedSKU = strings.TrimSpace(command.SelectedSKU)
command.EvidenceAssetID = strings.TrimSpace(command.EvidenceAssetID)
command.EvidenceSHA256 = strings.TrimSpace(command.EvidenceSHA256)
command.IdempotencyKey = strings.TrimSpace(command.IdempotencyKey)
return command
}
func normalizeManualReviewOrderSubmission(
command ManualReviewOrderSubmissionCommand,
) ManualReviewOrderSubmissionCommand {
command.UserID = strings.TrimSpace(command.UserID)
command.DeviceID = strings.TrimSpace(command.DeviceID)
command.TaskID = strings.TrimSpace(command.TaskID)
command.ExecutionID = strings.TrimSpace(command.ExecutionID)
command.AuthorizationID = strings.TrimSpace(command.AuthorizationID)
command.ClaimToken = strings.TrimSpace(command.ClaimToken)
command.CommandSHA256 = strings.TrimSpace(command.CommandSHA256)
command.SubmissionID = strings.TrimSpace(command.SubmissionID)
command.ReasonCode = strings.TrimSpace(command.ReasonCode)
command.EvidenceAssetID = strings.TrimSpace(command.EvidenceAssetID)
command.EvidenceSHA256 = strings.TrimSpace(command.EvidenceSHA256)
command.IdempotencyKey = strings.TrimSpace(command.IdempotencyKey)
return command
}
var platformOrderNumberPattern = regexp.MustCompile(`^[0-9]{8,40}$`)
var orderSubmissionManualReasons = map[string]struct{}{
"ORDER_NOT_FOUND": {},
"ORDER_AMBIGUOUS": {},
"ORDER_FIELDS_INCOMPLETE": {},
"ORDER_PAGE_UNKNOWN": {},
"RISK_OR_PAYMENT_BOUNDARY": {},
"EVIDENCE_UNAVAILABLE": {},
}