527 lines
16 KiB
Go
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": {},
|
|
}
|