feat(t215): add admin order authorization

This commit is contained in:
QiuSW
2026-07-28 12:50:42 +08:00
parent 16c2ea410a
commit 827afc7257
24 changed files with 2688 additions and 113 deletions
@@ -285,6 +285,24 @@ func (service *ExecutionResultService) StoreCandidates(
if err != nil {
return false, internalExecutionResultFailure(err)
}
var readyEvent *domain.TaskEvent
if len(command.Candidates) > 0 {
eventID, err := service.ids.NewID()
if err != nil {
return false, internalExecutionResultFailure(err)
}
actorUserID := identity.UserID
actorDeviceID := identity.DeviceID
readyEvent = &domain.TaskEvent{
ID: eventID,
TaskID: identity.TaskID,
ActorUserID: &actorUserID,
ActorDeviceID: &actorDeviceID,
Type: "CANDIDATES_READY",
Message: "candidates ready for admin confirmation",
OccurredAt: now,
}
}
replayed, err := service.repository.StoreExecutionCandidates(
ctx,
service.write(identity, executionResultCandidatesOperation, requestHash, now),
@@ -297,6 +315,7 @@ func (service *ExecutionResultService) StoreCandidates(
ProvenanceJSON: provenanceJSON,
CandidatesJSON: candidatesJSON,
RecommendationJSON: recommendationJSON,
ReadyEvent: readyEvent,
ReceivedAt: now,
},
)
@@ -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
}
@@ -0,0 +1,83 @@
package usecase
import (
"strings"
"testing"
)
func TestValidateOrderAuthorizationAcceptsSingleStructuredSelection(
t *testing.T,
) {
command := validOrderAuthorizationCommand()
if err := validateOrderAuthorizationCommand(command); err != nil {
t.Fatalf("validate order authorization: %v", err)
}
}
func TestValidateOrderAuthorizationRejectsDuplicateCandidateKey(
t *testing.T,
) {
command := validOrderAuthorizationCommand()
command.Items[1].CandidateKey = command.Items[0].CandidateKey
if err := validateOrderAuthorizationCommand(command); err == nil {
t.Fatal("expected duplicate candidate key to be rejected")
}
}
func TestValidateOrderAuthorizationRejectsSelectedKeyMismatch(
t *testing.T,
) {
command := validOrderAuthorizationCommand()
command.CandidateKey = command.Items[1].CandidateKey
if err := validateOrderAuthorizationCommand(command); err == nil {
t.Fatal("expected selected candidate mismatch to be rejected")
}
}
func TestValidateOrderAuthorizationRequiresOtherNote(t *testing.T) {
command := validOrderAuthorizationCommand()
command.Items[0].PrimaryReasonCode = "OTHER"
command.Items[0].ReasonCodes = []string{"OTHER"}
command.Items[0].Note = "短"
if err := validateOrderAuthorizationCommand(command); err == nil {
t.Fatal("expected short OTHER note to be rejected")
}
command.Items[0].Note = "人工判断规格更准确"
if err := validateOrderAuthorizationCommand(command); err != nil {
t.Fatalf("validate OTHER note: %v", err)
}
}
func validOrderAuthorizationCommand() CreateOrderAuthorizationCommand {
firstKey := strings.Repeat("a", 64)
secondKey := strings.Repeat("b", 64)
return CreateOrderAuthorizationCommand{
ActorUserID: "00000000-0000-4000-8000-000000000001",
TaskID: "00000000-0000-4000-8000-000000000002",
IdempotencyKey: "authorize-order-test",
ExecutionID: "00000000-0000-4000-8000-000000000003",
TaskContentSHA256: strings.Repeat("c", 64),
ExpectedTaskVersion: 2,
CandidateKey: firstKey,
ReasonSchemaVersion: 1,
PrimaryReasonCode: "SELECTED_BEST_MATCH",
Items: []OrderAuthorizationItemInput{
{
CandidateKey: firstKey,
Label: "ACCEPT",
PrimaryReasonCode: "SKU_MATCH",
ReasonCodes: []string{"SKU_MATCH", "IMAGE_MATCH"},
},
{
CandidateKey: secondKey,
Label: "REJECT",
PrimaryReasonCode: "NOT_BEST_MATCH",
ReasonCodes: []string{"NOT_BEST_MATCH"},
},
},
}
}