296 lines
9.2 KiB
Go
296 lines
9.2 KiB
Go
package usecase
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"strings"
|
|
"time"
|
|
|
|
"cmroubao/backend-api/internal/domain"
|
|
)
|
|
|
|
type OrderAuthorizationItemInput struct {
|
|
CandidateKey string `json:"candidate_key"`
|
|
Label string `json:"label"`
|
|
PrimaryReasonCode string `json:"primary_reason_code"`
|
|
ReasonCodes []string `json:"reason_codes"`
|
|
Note string `json:"note"`
|
|
}
|
|
|
|
type CreateOrderAuthorizationCommand struct {
|
|
ActorUserID string `json:"-"`
|
|
TaskID string `json:"-"`
|
|
IdempotencyKey string `json:"-"`
|
|
ExecutionID string `json:"execution_id"`
|
|
TaskContentSHA256 string `json:"task_content_sha256"`
|
|
ExpectedTaskVersion int64 `json:"expected_task_version"`
|
|
CandidateKey string `json:"candidate_key"`
|
|
ReasonSchemaVersion int `json:"reason_schema_version"`
|
|
PrimaryReasonCode string `json:"primary_reason_code"`
|
|
Note string `json:"note"`
|
|
SupersedesAuthorizationID *string `json:"supersedes_authorization_id"`
|
|
Items []OrderAuthorizationItemInput `json:"items"`
|
|
}
|
|
|
|
type CreateOrderAuthorizationWrite struct {
|
|
AuthorizationID string
|
|
ReviewID string
|
|
RequestSHA256 string
|
|
Now time.Time
|
|
Event domain.TaskEvent
|
|
Command CreateOrderAuthorizationCommand
|
|
}
|
|
|
|
type CreateOrderAuthorizationResult struct {
|
|
Authorization domain.OrderAuthorization
|
|
Replayed bool
|
|
}
|
|
|
|
type OrderAuthorizationRepository interface {
|
|
CreateOrderAuthorization(
|
|
context.Context,
|
|
CreateOrderAuthorizationWrite,
|
|
) (domain.OrderAuthorization, bool, error)
|
|
}
|
|
|
|
type OrderAuthorizationService struct {
|
|
repository OrderAuthorizationRepository
|
|
clock Clock
|
|
ids IDGenerator
|
|
}
|
|
|
|
func NewOrderAuthorizationService(
|
|
repository OrderAuthorizationRepository,
|
|
clock Clock,
|
|
ids IDGenerator,
|
|
) (*OrderAuthorizationService, error) {
|
|
if repository == nil || clock == nil || ids == nil {
|
|
return nil, errors.New("order authorization service dependencies are required")
|
|
}
|
|
return &OrderAuthorizationService{
|
|
repository: repository,
|
|
clock: clock,
|
|
ids: ids,
|
|
}, nil
|
|
}
|
|
|
|
func (service *OrderAuthorizationService) Create(
|
|
ctx context.Context,
|
|
command CreateOrderAuthorizationCommand,
|
|
) (CreateOrderAuthorizationResult, error) {
|
|
command = normalizeOrderAuthorizationCommand(command)
|
|
if err := validateOrderAuthorizationCommand(command); err != nil {
|
|
return CreateOrderAuthorizationResult{}, err
|
|
}
|
|
requestSHA256, err := hashOrderAuthorizationCommand(command)
|
|
if err != nil {
|
|
return CreateOrderAuthorizationResult{}, internalExecutionResultFailure(err)
|
|
}
|
|
authorizationID, err := service.ids.NewID()
|
|
if err != nil {
|
|
return CreateOrderAuthorizationResult{}, internalExecutionResultFailure(err)
|
|
}
|
|
reviewID, err := service.ids.NewID()
|
|
if err != nil {
|
|
return CreateOrderAuthorizationResult{}, internalExecutionResultFailure(err)
|
|
}
|
|
eventID, err := service.ids.NewID()
|
|
if err != nil {
|
|
return CreateOrderAuthorizationResult{}, internalExecutionResultFailure(err)
|
|
}
|
|
now := service.clock.Now().UTC()
|
|
actorUserID := command.ActorUserID
|
|
authorization, replayed, err := service.repository.CreateOrderAuthorization(
|
|
ctx,
|
|
CreateOrderAuthorizationWrite{
|
|
AuthorizationID: authorizationID,
|
|
ReviewID: reviewID,
|
|
RequestSHA256: requestSHA256,
|
|
Now: now,
|
|
Event: domain.TaskEvent{
|
|
ID: eventID,
|
|
TaskID: command.TaskID,
|
|
ActorUserID: &actorUserID,
|
|
Type: "ORDER_AUTHORIZATION_CREATED",
|
|
Message: "order authorization created for candidate " +
|
|
command.CandidateKey[:12],
|
|
OccurredAt: now,
|
|
},
|
|
Command: command,
|
|
},
|
|
)
|
|
if err != nil {
|
|
return CreateOrderAuthorizationResult{}, wrapRepositoryError(err)
|
|
}
|
|
return CreateOrderAuthorizationResult{
|
|
Authorization: authorization,
|
|
Replayed: replayed,
|
|
}, nil
|
|
}
|
|
|
|
func normalizeOrderAuthorizationCommand(
|
|
command CreateOrderAuthorizationCommand,
|
|
) CreateOrderAuthorizationCommand {
|
|
command.ActorUserID = strings.TrimSpace(command.ActorUserID)
|
|
command.TaskID = strings.TrimSpace(command.TaskID)
|
|
command.IdempotencyKey = strings.TrimSpace(command.IdempotencyKey)
|
|
command.ExecutionID = strings.TrimSpace(command.ExecutionID)
|
|
command.TaskContentSHA256 = strings.TrimSpace(command.TaskContentSHA256)
|
|
command.CandidateKey = strings.TrimSpace(command.CandidateKey)
|
|
command.PrimaryReasonCode = strings.TrimSpace(command.PrimaryReasonCode)
|
|
command.Note = strings.TrimSpace(command.Note)
|
|
if command.SupersedesAuthorizationID != nil {
|
|
value := strings.TrimSpace(*command.SupersedesAuthorizationID)
|
|
command.SupersedesAuthorizationID = &value
|
|
}
|
|
for index := range command.Items {
|
|
item := &command.Items[index]
|
|
item.CandidateKey = strings.TrimSpace(item.CandidateKey)
|
|
item.Label = strings.TrimSpace(item.Label)
|
|
item.PrimaryReasonCode = strings.TrimSpace(item.PrimaryReasonCode)
|
|
item.Note = strings.TrimSpace(item.Note)
|
|
for reasonIndex := range item.ReasonCodes {
|
|
item.ReasonCodes[reasonIndex] =
|
|
strings.TrimSpace(item.ReasonCodes[reasonIndex])
|
|
}
|
|
}
|
|
return command
|
|
}
|
|
|
|
func validateOrderAuthorizationCommand(
|
|
command CreateOrderAuthorizationCommand,
|
|
) error {
|
|
fields := make(map[string]string)
|
|
if !isUUID(command.ActorUserID) {
|
|
fields["actor_user_id"] = "must be a UUID"
|
|
}
|
|
if !isUUID(command.TaskID) {
|
|
fields["task_id"] = "must be a UUID"
|
|
}
|
|
if !isUUID(command.ExecutionID) {
|
|
fields["execution_id"] = "must be a UUID"
|
|
}
|
|
if len(command.IdempotencyKey) == 0 ||
|
|
len([]byte(command.IdempotencyKey)) > maxIdempotencyKeyBytes ||
|
|
!isPrintableASCII(command.IdempotencyKey) {
|
|
fields["idempotency_key"] = "must be 1 to 128 printable ASCII bytes"
|
|
}
|
|
if !sha256Pattern.MatchString(command.TaskContentSHA256) {
|
|
fields["task_content_sha256"] = "must be lowercase SHA-256"
|
|
}
|
|
if command.ExpectedTaskVersion < 1 {
|
|
fields["expected_task_version"] = "must be positive"
|
|
}
|
|
if !sha256Pattern.MatchString(command.CandidateKey) {
|
|
fields["candidate_key"] = "must be lowercase SHA-256"
|
|
}
|
|
if command.ReasonSchemaVersion != candidateReasonSchemaVersion {
|
|
fields["reason_schema_version"] = "must be 1"
|
|
}
|
|
if !validReviewPrimaryReason("CANDIDATE_ACCEPTED", command.PrimaryReasonCode) ||
|
|
!validReviewNote(command.PrimaryReasonCode, command.Note) {
|
|
fields["primary_reason_code"] = "is invalid"
|
|
}
|
|
if command.SupersedesAuthorizationID != nil &&
|
|
!isUUID(*command.SupersedesAuthorizationID) {
|
|
fields["supersedes_authorization_id"] = "must be a UUID"
|
|
}
|
|
if len(command.Items) < 1 || len(command.Items) > 5 {
|
|
fields["items"] = "must contain 1 to 5 items"
|
|
}
|
|
seen := make(map[string]struct{}, len(command.Items))
|
|
accepted := 0
|
|
for _, item := range command.Items {
|
|
if !sha256Pattern.MatchString(item.CandidateKey) {
|
|
fields["items"] = "candidate keys must be lowercase SHA-256"
|
|
continue
|
|
}
|
|
if _, found := seen[item.CandidateKey]; found {
|
|
fields["items"] = "candidate keys must be unique"
|
|
continue
|
|
}
|
|
seen[item.CandidateKey] = struct{}{}
|
|
if item.Label == "ACCEPT" {
|
|
accepted++
|
|
}
|
|
if item.Label != "ACCEPT" && item.Label != "REJECT" {
|
|
fields["items"] = "labels must be ACCEPT or REJECT"
|
|
continue
|
|
}
|
|
if !validOrderAuthorizationItem(item) {
|
|
fields["items"] = "contains invalid reasons"
|
|
}
|
|
}
|
|
_, found := seen[command.CandidateKey]
|
|
if !found || accepted != 1 || !acceptedOrderAuthorizationItem(
|
|
command.Items,
|
|
command.CandidateKey,
|
|
) {
|
|
fields["candidate_key"] = "must identify the single accepted item"
|
|
}
|
|
if len(fields) > 0 {
|
|
return invalidError(
|
|
"ORDER_AUTHORIZATION_INVALID",
|
|
"order authorization request is invalid",
|
|
fields,
|
|
)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validOrderAuthorizationItem(item OrderAuthorizationItemInput) bool {
|
|
if len(item.ReasonCodes) < 1 || len(item.ReasonCodes) > 8 {
|
|
return false
|
|
}
|
|
seen := make(map[string]struct{}, len(item.ReasonCodes))
|
|
containsPrimary := false
|
|
for _, reason := range item.ReasonCodes {
|
|
if !validItemReason(item.Label, reason) {
|
|
return false
|
|
}
|
|
if _, found := seen[reason]; found {
|
|
return false
|
|
}
|
|
seen[reason] = struct{}{}
|
|
containsPrimary = containsPrimary || reason == item.PrimaryReasonCode
|
|
}
|
|
return containsPrimary &&
|
|
validReviewNote(item.PrimaryReasonCode, item.Note)
|
|
}
|
|
|
|
func acceptedOrderAuthorizationItem(
|
|
items []OrderAuthorizationItemInput,
|
|
candidateKey string,
|
|
) bool {
|
|
for _, item := range items {
|
|
if item.CandidateKey == candidateKey {
|
|
return item.Label == "ACCEPT"
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func hashOrderAuthorizationCommand(
|
|
command CreateOrderAuthorizationCommand,
|
|
) (string, error) {
|
|
command.IdempotencyKey = ""
|
|
payload := struct {
|
|
ActorUserID string `json:"actor_user_id"`
|
|
TaskID string `json:"task_id"`
|
|
Command CreateOrderAuthorizationCommand `json:"command"`
|
|
}{
|
|
ActorUserID: command.ActorUserID,
|
|
TaskID: command.TaskID,
|
|
Command: command,
|
|
}
|
|
encoded, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
sum := sha256.Sum256(encoded)
|
|
return hex.EncodeToString(sum[:]), nil
|
|
}
|