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

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
}