feat(t215): add admin order authorization
This commit is contained in:
@@ -0,0 +1,295 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user