Files
cmroubao/backend-api/internal/usecase/device_order_command_service.go

337 lines
11 KiB
Go

package usecase
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"strings"
"time"
"cmroubao/backend-api/internal/domain"
)
const (
deviceOrderCommandSchemaVersion = 1
deviceOrderCommandType = "CREATE_PENDING_ORDER"
)
type PullDeviceOrderCommand struct {
UserID string
DeviceID string
TaskID string
ExecutionID string
ClaimGeneration int64
ClaimToken string
}
type PullDeviceOrderCommandWrite struct {
PullDeviceOrderCommand
ClaimTokenHash string
Now time.Time
DeliveredEvent domain.TaskEvent
}
type AcknowledgeDeviceOrderCommand struct {
UserID string
DeviceID string
TaskID string
ExecutionID string
AuthorizationID string
ClaimGeneration int64
ClaimToken string
CommandSHA256 string
IdempotencyKey string
}
type AcknowledgeDeviceOrderCommandWrite struct {
AcknowledgeDeviceOrderCommand
ClaimTokenHash string
RequestSHA256 string
Now time.Time
AcknowledgedEvent domain.TaskEvent
}
type AcknowledgeDeviceOrderCommandResult struct {
Authorization domain.OrderAuthorization
Replayed bool
}
type DeviceOrderCommandRepository interface {
PullDeviceOrderCommand(
context.Context,
PullDeviceOrderCommandWrite,
) (*domain.DeviceOrderCommand, error)
AcknowledgeDeviceOrderCommand(
context.Context,
AcknowledgeDeviceOrderCommandWrite,
) (domain.OrderAuthorization, bool, error)
}
type DeviceOrderCommandService struct {
repository DeviceOrderCommandRepository
clock Clock
ids IDGenerator
}
func NewDeviceOrderCommandService(
repository DeviceOrderCommandRepository,
clock Clock,
ids IDGenerator,
) (*DeviceOrderCommandService, error) {
if repository == nil || clock == nil || ids == nil {
return nil, errors.New("device order command service dependencies are required")
}
return &DeviceOrderCommandService{
repository: repository,
clock: clock,
ids: ids,
}, nil
}
func (service *DeviceOrderCommandService) Pull(
ctx context.Context,
command PullDeviceOrderCommand,
) (*domain.DeviceOrderCommand, error) {
command = normalizePullDeviceOrderCommand(command)
fields := lifecycleClaimFields(
command.UserID,
command.DeviceID,
command.TaskID,
command.ClaimGeneration,
command.ClaimToken,
)
if !isUUID(command.ExecutionID) {
fields["execution_id"] = "must be a UUID"
}
if len(fields) > 0 {
return nil, invalidError(
"ORDER_COMMAND_PULL_INVALID",
"order command pull request is invalid",
fields,
)
}
eventID, err := service.ids.NewID()
if err != nil {
return nil, internalLifecycleFailure(err)
}
now := service.clock.Now().UTC()
userID, deviceID := command.UserID, command.DeviceID
result, err := service.repository.PullDeviceOrderCommand(
ctx,
PullDeviceOrderCommandWrite{
PullDeviceOrderCommand: command,
ClaimTokenHash: hashSecret(command.ClaimToken),
Now: now,
DeliveredEvent: domain.TaskEvent{
ID: eventID,
TaskID: command.TaskID,
ActorUserID: &userID,
ActorDeviceID: &deviceID,
Type: "ORDER_AUTHORIZATION_DELIVERED",
Message: "order authorization delivered to device",
OccurredAt: now,
},
},
)
if err != nil {
return nil, wrapLifecycleRepositoryError(err)
}
return result, nil
}
func (service *DeviceOrderCommandService) Acknowledge(
ctx context.Context,
command AcknowledgeDeviceOrderCommand,
) (AcknowledgeDeviceOrderCommandResult, error) {
command = normalizeAcknowledgeDeviceOrderCommand(command)
fields := lifecycleClaimFields(
command.UserID,
command.DeviceID,
command.TaskID,
command.ClaimGeneration,
command.ClaimToken,
)
if !isUUID(command.ExecutionID) {
fields["execution_id"] = "must be a UUID"
}
if !isUUID(command.AuthorizationID) {
fields["command_id"] = "must be a UUID"
}
if !sha256Pattern.MatchString(command.CommandSHA256) {
fields["command_sha256"] = "must be lowercase SHA-256"
}
validateIdempotencyField(fields, command.IdempotencyKey)
if len(fields) > 0 {
return AcknowledgeDeviceOrderCommandResult{}, invalidError(
"ORDER_COMMAND_ACK_INVALID",
"order command acknowledgement is invalid",
fields,
)
}
requestSHA256, err := lifecycleRequestHash(struct {
UserID string `json:"user_id"`
DeviceID string `json:"device_id"`
TaskID string `json:"task_id"`
ExecutionID string `json:"execution_id"`
AuthorizationID string `json:"authorization_id"`
ClaimGeneration int64 `json:"claim_generation"`
ClaimTokenHash string `json:"claim_token_sha256"`
CommandSHA256 string `json:"command_sha256"`
}{
UserID: command.UserID,
DeviceID: command.DeviceID,
TaskID: command.TaskID,
ExecutionID: command.ExecutionID,
AuthorizationID: command.AuthorizationID,
ClaimGeneration: command.ClaimGeneration,
ClaimTokenHash: hashSecret(command.ClaimToken),
CommandSHA256: command.CommandSHA256,
})
if err != nil {
return AcknowledgeDeviceOrderCommandResult{},
internalLifecycleFailure(err)
}
eventID, err := service.ids.NewID()
if err != nil {
return AcknowledgeDeviceOrderCommandResult{},
internalLifecycleFailure(err)
}
now := service.clock.Now().UTC()
userID, deviceID := command.UserID, command.DeviceID
authorization, replayed, err :=
service.repository.AcknowledgeDeviceOrderCommand(
ctx,
AcknowledgeDeviceOrderCommandWrite{
AcknowledgeDeviceOrderCommand: command,
ClaimTokenHash: hashSecret(command.ClaimToken),
RequestSHA256: requestSHA256,
Now: now,
AcknowledgedEvent: domain.TaskEvent{
ID: eventID,
TaskID: command.TaskID,
ActorUserID: &userID,
ActorDeviceID: &deviceID,
Type: "ORDER_AUTHORIZATION_ACKNOWLEDGED",
Message: "order authorization persisted by device",
OccurredAt: now,
},
},
)
if err != nil {
return AcknowledgeDeviceOrderCommandResult{},
wrapLifecycleRepositoryError(err)
}
return AcknowledgeDeviceOrderCommandResult{
Authorization: authorization,
Replayed: replayed,
}, nil
}
func DeviceOrderCommandSHA256(
command domain.DeviceOrderCommand,
) (string, error) {
payload := struct {
ID string `json:"id"`
SchemaVersion int `json:"schema_version"`
Type string `json:"type"`
AuthorizationVersion int `json:"authorization_version"`
TaskID string `json:"task_id"`
ExecutionID string `json:"execution_id"`
TaskContentSHA256 string `json:"task_content_sha256"`
OriginalSKU string `json:"original_sku"`
Quantity int `json:"quantity"`
Candidate struct {
CandidateKey string `json:"candidate_key"`
ObservedOrdinal int `json:"observed_ordinal"`
Title string `json:"title"`
SKUText string `json:"sku_text"`
PriceText string `json:"price_text"`
CardSignature string `json:"card_signature"`
DetailSignature string `json:"detail_signature"`
DetailEvidenceSHA256 string `json:"detail_evidence_sha256"`
SpecificationEvidenceSHA256 string `json:"specification_evidence_sha256"`
} `json:"candidate"`
}{
ID: command.ID,
SchemaVersion: command.SchemaVersion,
Type: command.Type,
AuthorizationVersion: command.AuthorizationVersion,
TaskID: command.TaskID,
ExecutionID: command.ExecutionID,
TaskContentSHA256: command.TaskContentSHA256,
OriginalSKU: command.OriginalSKU,
Quantity: command.Quantity,
}
payload.Candidate.CandidateKey = command.CandidateKey
payload.Candidate.ObservedOrdinal = command.ObservedOrdinal
payload.Candidate.Title = command.CandidateTitle
payload.Candidate.SKUText = command.CandidateSKUText
payload.Candidate.PriceText = command.CandidatePriceText
payload.Candidate.CardSignature = command.CardSignature
payload.Candidate.DetailSignature = command.DetailSignature
payload.Candidate.DetailEvidenceSHA256 = command.DetailEvidenceSHA256
payload.Candidate.SpecificationEvidenceSHA256 =
command.SpecificationEvidenceSHA256
encoded, err := json.Marshal(payload)
if err != nil {
return "", err
}
sum := sha256.Sum256(encoded)
return hex.EncodeToString(sum[:]), nil
}
func NewDeviceOrderCommand(
authorization domain.OrderAuthorization,
observedOrdinal int,
candidateTitle string,
) domain.DeviceOrderCommand {
return domain.DeviceOrderCommand{
ID: authorization.ID,
SchemaVersion: deviceOrderCommandSchemaVersion,
Type: deviceOrderCommandType,
AuthorizationVersion: authorization.AuthorizationVersion,
TaskID: authorization.TaskID,
ExecutionID: authorization.ExecutionID,
TaskContentSHA256: authorization.TaskContentSHA256,
OriginalSKU: authorization.OriginalSKU,
Quantity: authorization.Quantity,
CandidateKey: authorization.CandidateKey,
ObservedOrdinal: observedOrdinal,
CandidateTitle: candidateTitle,
CandidateSKUText: authorization.CandidateSKUText,
CandidatePriceText: authorization.CandidatePriceText,
CardSignature: authorization.CardSignature,
DetailSignature: authorization.DetailSignature,
DetailEvidenceSHA256: authorization.DetailEvidenceSHA256,
SpecificationEvidenceSHA256: authorization.SpecificationEvidenceSHA256,
AuthorizationStatus: authorization.Status,
}
}
func normalizePullDeviceOrderCommand(
command PullDeviceOrderCommand,
) PullDeviceOrderCommand {
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.ClaimToken = strings.TrimSpace(command.ClaimToken)
return command
}
func normalizeAcknowledgeDeviceOrderCommand(
command AcknowledgeDeviceOrderCommand,
) AcknowledgeDeviceOrderCommand {
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.IdempotencyKey = strings.TrimSpace(command.IdempotencyKey)
return command
}