feat(t215): add admin order authorization
This commit is contained in:
@@ -189,6 +189,14 @@ func buildRouter(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
authorizations, err := usecase.NewOrderAuthorizationService(
|
||||||
|
store,
|
||||||
|
clock,
|
||||||
|
ids,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
passwords, err := password.NewBcrypt(12)
|
passwords, err := password.NewBcrypt(12)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -214,7 +222,11 @@ func buildRouter(
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
webService, err := webui.NewUsecaseAdapter(tasks, assets)
|
webService, err := webui.NewUsecaseAdapter(
|
||||||
|
tasks,
|
||||||
|
assets,
|
||||||
|
authorizations,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -258,6 +270,7 @@ func buildRouter(
|
|||||||
Assets: assets,
|
Assets: assets,
|
||||||
Tasks: tasks,
|
Tasks: tasks,
|
||||||
Results: results,
|
Results: results,
|
||||||
|
Authorizations: authorizations,
|
||||||
},
|
},
|
||||||
webHandler,
|
webHandler,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ type ExecutionCandidateBatch struct {
|
|||||||
ProvenanceJSON *string
|
ProvenanceJSON *string
|
||||||
CandidatesJSON string
|
CandidatesJSON string
|
||||||
RecommendationJSON *string
|
RecommendationJSON *string
|
||||||
|
ReadyEvent *TaskEvent
|
||||||
ReceivedAt time.Time
|
ReceivedAt time.Time
|
||||||
ReceivedAfterExecutionExpiry bool
|
ReceivedAfterExecutionExpiry bool
|
||||||
}
|
}
|
||||||
@@ -253,6 +254,54 @@ type CandidateDecisionDataset struct {
|
|||||||
HumanReviews []CandidateHumanReview
|
HumanReviews []CandidateHumanReview
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OrderAuthorizationStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
OrderAuthorizationPendingDelivery OrderAuthorizationStatus = "PENDING_DELIVERY"
|
||||||
|
OrderAuthorizationDelivered OrderAuthorizationStatus = "DELIVERED"
|
||||||
|
OrderAuthorizationAcknowledged OrderAuthorizationStatus = "ACKNOWLEDGED"
|
||||||
|
OrderAuthorizationExecuting OrderAuthorizationStatus = "EXECUTING"
|
||||||
|
OrderAuthorizationConsumed OrderAuthorizationStatus = "CONSUMED"
|
||||||
|
OrderAuthorizationFailed OrderAuthorizationStatus = "FAILED"
|
||||||
|
OrderAuthorizationRevoked OrderAuthorizationStatus = "REVOKED"
|
||||||
|
OrderAuthorizationSuperseded OrderAuthorizationStatus = "SUPERSEDED"
|
||||||
|
)
|
||||||
|
|
||||||
|
type OrderAuthorization struct {
|
||||||
|
ID string
|
||||||
|
TaskID string
|
||||||
|
ExecutionID string
|
||||||
|
AuthorizationVersion int
|
||||||
|
CandidateKey string
|
||||||
|
TaskContentSHA256 string
|
||||||
|
TaskVersion int64
|
||||||
|
ReviewID string
|
||||||
|
ReviewVersion int
|
||||||
|
UserID string
|
||||||
|
DeviceID string
|
||||||
|
ClaimGeneration int64
|
||||||
|
OriginalSKU string
|
||||||
|
Quantity int
|
||||||
|
CandidateSKUText string
|
||||||
|
CandidatePriceText string
|
||||||
|
CardSignature string
|
||||||
|
DetailSignature string
|
||||||
|
DetailEvidenceSHA256 string
|
||||||
|
SpecificationEvidenceSHA256 string
|
||||||
|
Status OrderAuthorizationStatus
|
||||||
|
SupersedesAuthorizationID *string
|
||||||
|
CreatedByUserID string
|
||||||
|
CreatedAt time.Time
|
||||||
|
DeliveredAt *time.Time
|
||||||
|
AcknowledgedAt *time.Time
|
||||||
|
ExecutionStartedAt *time.Time
|
||||||
|
ConsumedAt *time.Time
|
||||||
|
FailedAt *time.Time
|
||||||
|
RevokedAt *time.Time
|
||||||
|
FailureCode *string
|
||||||
|
FailureMessage *string
|
||||||
|
}
|
||||||
|
|
||||||
type ExecutionReport struct {
|
type ExecutionReport struct {
|
||||||
Events []ExecutionEvent
|
Events []ExecutionEvent
|
||||||
EvidenceAssets []ExecutionEvidenceAsset
|
EvidenceAssets []ExecutionEvidenceAsset
|
||||||
@@ -267,6 +316,7 @@ type TaskDetail struct {
|
|||||||
Execution *TaskExecution
|
Execution *TaskExecution
|
||||||
Events []TaskEvent
|
Events []TaskEvent
|
||||||
Report *ExecutionReport
|
Report *ExecutionReport
|
||||||
|
OrderAuthorizations []OrderAuthorization
|
||||||
}
|
}
|
||||||
|
|
||||||
type TaskValidationError struct {
|
type TaskValidationError struct {
|
||||||
|
|||||||
@@ -34,8 +34,11 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
|
|||||||
|
|
||||||
if applied, err := runner.Up(ctx); err != nil {
|
if applied, err := runner.Up(ctx); err != nil {
|
||||||
t.Fatalf("initial Up() error = %v", err)
|
t.Fatalf("initial Up() error = %v", err)
|
||||||
} else if applied != 7 {
|
} else if applied != 8 {
|
||||||
t.Fatalf("initial Up() applied = %d, want 7", applied)
|
t.Fatalf("initial Up() applied = %d, want 8", applied)
|
||||||
|
}
|
||||||
|
if err := runner.Down(ctx); err != nil {
|
||||||
|
t.Fatalf("initial Down(v8) error = %v", err)
|
||||||
}
|
}
|
||||||
if err := runner.Down(ctx); err != nil {
|
if err := runner.Down(ctx); err != nil {
|
||||||
t.Fatalf("initial Down(v7) error = %v", err)
|
t.Fatalf("initial Down(v7) error = %v", err)
|
||||||
@@ -50,9 +53,14 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
|
|||||||
seedClaimsHistoricalFixture(t, db)
|
seedClaimsHistoricalFixture(t, db)
|
||||||
|
|
||||||
if applied, err := runner.Up(ctx); err != nil {
|
if applied, err := runner.Up(ctx); err != nil {
|
||||||
t.Fatalf("Up(v5-v7) over historical data error = %v", err)
|
t.Fatalf("Up(v5-v8) over historical data error = %v", err)
|
||||||
} else if applied != 3 {
|
} else if applied != 4 {
|
||||||
t.Fatalf("Up(v5-v7) applied = %d, want 3", applied)
|
t.Fatalf("Up(v5-v8) applied = %d, want 4", applied)
|
||||||
|
}
|
||||||
|
assertClaimsHistory(t, db, true)
|
||||||
|
|
||||||
|
if err := runner.Down(ctx); err != nil {
|
||||||
|
t.Fatalf("Down(v8) with compatible history error = %v", err)
|
||||||
}
|
}
|
||||||
assertClaimsHistory(t, db, true)
|
assertClaimsHistory(t, db, true)
|
||||||
|
|
||||||
@@ -77,9 +85,9 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
|
|||||||
assertClaimsHistory(t, db, false)
|
assertClaimsHistory(t, db, false)
|
||||||
|
|
||||||
if applied, err := runner.Up(ctx); err != nil {
|
if applied, err := runner.Up(ctx); err != nil {
|
||||||
t.Fatalf("final Up(v4-v7) error = %v", err)
|
t.Fatalf("final Up(v4-v8) error = %v", err)
|
||||||
} else if applied != 4 {
|
} else if applied != 5 {
|
||||||
t.Fatalf("final Up(v4-v7) applied = %d, want 4", applied)
|
t.Fatalf("final Up(v4-v8) applied = %d, want 5", applied)
|
||||||
}
|
}
|
||||||
assertClaimsHistory(t, db, true)
|
assertClaimsHistory(t, db, true)
|
||||||
}
|
}
|
||||||
@@ -315,6 +323,9 @@ func TestClaimsMigrationDownFailsClosedForNewAuditData(t *testing.T) {
|
|||||||
t.Fatalf("insert v4 audit event: %v", err)
|
t.Fatalf("insert v4 audit event: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := runner.Down(ctx); err != nil {
|
||||||
|
t.Fatalf("Down(v8) error = %v", err)
|
||||||
|
}
|
||||||
if err := runner.Down(ctx); err != nil {
|
if err := runner.Down(ctx); err != nil {
|
||||||
t.Fatalf("Down(v7) error = %v", err)
|
t.Fatalf("Down(v7) error = %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Up() error = %v", err)
|
t.Fatalf("Up() error = %v", err)
|
||||||
}
|
}
|
||||||
if applied != 7 {
|
if applied != 8 {
|
||||||
t.Fatalf("Up() applied = %d, want 7", applied)
|
t.Fatalf("Up() applied = %d, want 8", applied)
|
||||||
}
|
}
|
||||||
assertStatuses(t, runner, map[int64]bool{
|
assertStatuses(t, runner, map[int64]bool{
|
||||||
1: true,
|
1: true,
|
||||||
@@ -38,6 +38,7 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
|
|||||||
5: true,
|
5: true,
|
||||||
6: true,
|
6: true,
|
||||||
7: true,
|
7: true,
|
||||||
|
8: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
applied, err = runner.Up(context.Background())
|
applied, err = runner.Up(context.Background())
|
||||||
@@ -58,7 +59,8 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
|
|||||||
4: true,
|
4: true,
|
||||||
5: true,
|
5: true,
|
||||||
6: true,
|
6: true,
|
||||||
7: false,
|
7: true,
|
||||||
|
8: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
applied, err = runner.Up(context.Background())
|
applied, err = runner.Up(context.Background())
|
||||||
@@ -76,6 +78,7 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
|
|||||||
5: true,
|
5: true,
|
||||||
6: true,
|
6: true,
|
||||||
7: true,
|
7: true,
|
||||||
|
8: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -383,6 +383,9 @@ func TestAuthMigrationCanRollbackWithoutRebuildingPurchaseTasks(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("migration.New() error = %v", err)
|
t.Fatalf("migration.New() error = %v", err)
|
||||||
}
|
}
|
||||||
|
if err := runner.Down(context.Background()); err != nil {
|
||||||
|
t.Fatalf("Down(v8) error = %v", err)
|
||||||
|
}
|
||||||
if err := runner.Down(context.Background()); err != nil {
|
if err := runner.Down(context.Background()); err != nil {
|
||||||
t.Fatalf("Down(v7) error = %v", err)
|
t.Fatalf("Down(v7) error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -411,9 +414,9 @@ func TestAuthMigrationCanRollbackWithoutRebuildingPurchaseTasks(
|
|||||||
t.Fatal("purchase_tasks was lost during auth migration rollback")
|
t.Fatal("purchase_tasks was lost during auth migration rollback")
|
||||||
}
|
}
|
||||||
if applied, err := runner.Up(context.Background()); err != nil {
|
if applied, err := runner.Up(context.Background()); err != nil {
|
||||||
t.Fatalf("Up(v3-v7) error = %v", err)
|
t.Fatalf("Up(v3-v8) error = %v", err)
|
||||||
} else if applied != 5 {
|
} else if applied != 6 {
|
||||||
t.Fatalf("Up(v3-v7) applied = %d, want 5", applied)
|
t.Fatalf("Up(v3-v8) applied = %d, want 6", applied)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -192,6 +192,33 @@ func (s *Store) StoreExecutionCandidates(
|
|||||||
); err != nil {
|
); err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
if candidate.ReadyEvent != nil && !expired {
|
||||||
|
result, err := tx.ExecContext(
|
||||||
|
ctx,
|
||||||
|
`UPDATE purchase_tasks
|
||||||
|
SET status = 'WAITING_CONFIRMATION',
|
||||||
|
version = version + 1,
|
||||||
|
updated_at = ?
|
||||||
|
WHERE id = ? AND status = 'RUNNING'
|
||||||
|
AND version = ? AND cancel_requested_at IS NULL`,
|
||||||
|
formatTimestamp(write.Now),
|
||||||
|
write.TaskID,
|
||||||
|
task.Version,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return false, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
affected, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return false, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
if affected != 1 {
|
||||||
|
return false, usecase.ErrTaskStateConflict
|
||||||
|
}
|
||||||
|
if err := insertTaskEvent(ctx, tx, *candidate.ReadyEvent); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
if err := insertExecutionResultRequest(ctx, tx, write, nil); err != nil {
|
if err := insertExecutionResultRequest(ctx, tx, write, nil); err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,684 @@
|
|||||||
|
package sqlite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"cmroubao/backend-api/internal/domain"
|
||||||
|
"cmroubao/backend-api/internal/usecase"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Store) CreateOrderAuthorization(
|
||||||
|
ctx context.Context,
|
||||||
|
write usecase.CreateOrderAuthorizationWrite,
|
||||||
|
) (domain.OrderAuthorization, bool, error) {
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return domain.OrderAuthorization{}, false, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
existing, found, err := lookupOrderAuthorizationRequest(
|
||||||
|
ctx,
|
||||||
|
tx,
|
||||||
|
write.Command.ActorUserID,
|
||||||
|
write.Command.IdempotencyKey,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return domain.OrderAuthorization{}, false, err
|
||||||
|
}
|
||||||
|
if found {
|
||||||
|
if existing.RequestSHA256 != write.RequestSHA256 ||
|
||||||
|
existing.TaskID != write.Command.TaskID {
|
||||||
|
return domain.OrderAuthorization{}, false, usecase.ErrIdempotencyConflict
|
||||||
|
}
|
||||||
|
authorization, err := getOrderAuthorization(
|
||||||
|
ctx,
|
||||||
|
tx,
|
||||||
|
existing.AuthorizationID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return domain.OrderAuthorization{}, false, err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return domain.OrderAuthorization{}, false, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
return authorization, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
task, err := getTaskByID(
|
||||||
|
ctx,
|
||||||
|
tx,
|
||||||
|
localAdminSubject,
|
||||||
|
write.Command.TaskID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return domain.OrderAuthorization{}, false, err
|
||||||
|
}
|
||||||
|
if task.Status != domain.TaskStatusWaitingConfirmation ||
|
||||||
|
task.Version != write.Command.ExpectedTaskVersion ||
|
||||||
|
task.CancelRequestedAt != nil ||
|
||||||
|
task.ClaimedByUserID == nil ||
|
||||||
|
task.ClaimedByDeviceID == nil ||
|
||||||
|
task.ClaimExpiresAt == nil ||
|
||||||
|
!write.Now.Before(*task.ClaimExpiresAt) ||
|
||||||
|
usecase.TaskContentSHA256(task) != write.Command.TaskContentSHA256 {
|
||||||
|
return domain.OrderAuthorization{}, false, usecase.ErrTaskStateConflict
|
||||||
|
}
|
||||||
|
|
||||||
|
var executionUserID, executionDeviceID string
|
||||||
|
var executionClaimGeneration int64
|
||||||
|
var executionFinishedAt sql.NullString
|
||||||
|
err = tx.QueryRowContext(
|
||||||
|
ctx,
|
||||||
|
`SELECT user_id, device_id, claim_generation, finished_at
|
||||||
|
FROM task_executions
|
||||||
|
WHERE id = ? AND task_id = ?`,
|
||||||
|
write.Command.ExecutionID,
|
||||||
|
write.Command.TaskID,
|
||||||
|
).Scan(
|
||||||
|
&executionUserID,
|
||||||
|
&executionDeviceID,
|
||||||
|
&executionClaimGeneration,
|
||||||
|
&executionFinishedAt,
|
||||||
|
)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return domain.OrderAuthorization{}, false, usecase.ErrTaskStateConflict
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return domain.OrderAuthorization{}, false, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
if executionFinishedAt.Valid ||
|
||||||
|
executionUserID != *task.ClaimedByUserID ||
|
||||||
|
executionDeviceID != *task.ClaimedByDeviceID ||
|
||||||
|
executionClaimGeneration != task.ClaimGeneration {
|
||||||
|
return domain.OrderAuthorization{}, false, usecase.ErrTaskStateConflict
|
||||||
|
}
|
||||||
|
|
||||||
|
observations, err := orderAuthorizationObservations(
|
||||||
|
ctx,
|
||||||
|
tx,
|
||||||
|
write.Command.TaskID,
|
||||||
|
write.Command.ExecutionID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return domain.OrderAuthorization{}, false, err
|
||||||
|
}
|
||||||
|
if len(observations) == 0 ||
|
||||||
|
len(observations) != len(write.Command.Items) {
|
||||||
|
return domain.OrderAuthorization{}, false, usecase.ErrTaskStateConflict
|
||||||
|
}
|
||||||
|
byKey := make(map[string]orderAuthorizationObservation, len(observations))
|
||||||
|
for _, observation := range observations {
|
||||||
|
byKey[observation.CandidateKey] = observation
|
||||||
|
}
|
||||||
|
selected, found := byKey[write.Command.CandidateKey]
|
||||||
|
if !found {
|
||||||
|
return domain.OrderAuthorization{}, false, usecase.ErrTaskStateConflict
|
||||||
|
}
|
||||||
|
for _, item := range write.Command.Items {
|
||||||
|
if _, found := byKey[item.CandidateKey]; !found {
|
||||||
|
return domain.OrderAuthorization{}, false, usecase.ErrTaskStateConflict
|
||||||
|
}
|
||||||
|
for _, reason := range item.ReasonCodes {
|
||||||
|
if task.MaxBudgetCents == nil &&
|
||||||
|
(reason == "PRICE_ACCEPTABLE" || reason == "PRICE_TOO_HIGH") {
|
||||||
|
return domain.OrderAuthorization{}, false, usecase.ErrTaskStateConflict
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
activeID, activeStatus, err := activeOrderAuthorization(
|
||||||
|
ctx,
|
||||||
|
tx,
|
||||||
|
write.Command.ExecutionID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return domain.OrderAuthorization{}, false, err
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case activeID == nil && write.Command.SupersedesAuthorizationID != nil:
|
||||||
|
return domain.OrderAuthorization{}, false, usecase.ErrTaskStateConflict
|
||||||
|
case activeID != nil &&
|
||||||
|
(write.Command.SupersedesAuthorizationID == nil ||
|
||||||
|
*write.Command.SupersedesAuthorizationID != *activeID ||
|
||||||
|
activeStatus != domain.OrderAuthorizationPendingDelivery):
|
||||||
|
return domain.OrderAuthorization{}, false, usecase.ErrTaskStateConflict
|
||||||
|
}
|
||||||
|
if activeID != nil {
|
||||||
|
result, err := tx.ExecContext(
|
||||||
|
ctx,
|
||||||
|
`UPDATE order_authorizations
|
||||||
|
SET status = 'SUPERSEDED'
|
||||||
|
WHERE id = ? AND status = 'PENDING_DELIVERY'`,
|
||||||
|
*activeID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return domain.OrderAuthorization{}, false, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
affected, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return domain.OrderAuthorization{}, false, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
if affected != 1 {
|
||||||
|
return domain.OrderAuthorization{}, false, usecase.ErrTaskStateConflict
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
latestReviewID, latestReviewVersion, err := latestCandidateHumanReview(
|
||||||
|
ctx,
|
||||||
|
tx,
|
||||||
|
write.Command.ExecutionID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return domain.OrderAuthorization{}, false, err
|
||||||
|
}
|
||||||
|
review := domain.CandidateHumanReview{
|
||||||
|
ID: write.ReviewID,
|
||||||
|
TaskID: write.Command.TaskID,
|
||||||
|
ExecutionID: write.Command.ExecutionID,
|
||||||
|
TaskContentSHA256: write.Command.TaskContentSHA256,
|
||||||
|
Version: latestReviewVersion + 1,
|
||||||
|
ReasonSchemaVersion: write.Command.ReasonSchemaVersion,
|
||||||
|
Outcome: "CANDIDATE_ACCEPTED",
|
||||||
|
SelectedCandidateOrdinal: &selected.Ordinal,
|
||||||
|
PrimaryReasonCode: write.Command.PrimaryReasonCode,
|
||||||
|
Note: write.Command.Note,
|
||||||
|
SupersedesReviewID: latestReviewID,
|
||||||
|
ActorUserID: write.Command.ActorUserID,
|
||||||
|
CreatedAt: write.Now,
|
||||||
|
Items: make([]domain.CandidateHumanReviewItem, 0, len(write.Command.Items)),
|
||||||
|
}
|
||||||
|
for _, item := range write.Command.Items {
|
||||||
|
observation := byKey[item.CandidateKey]
|
||||||
|
review.Items = append(review.Items, domain.CandidateHumanReviewItem{
|
||||||
|
CandidateOrdinal: observation.Ordinal,
|
||||||
|
Label: item.Label,
|
||||||
|
PrimaryReasonCode: item.PrimaryReasonCode,
|
||||||
|
ReasonCodes: append([]string(nil), item.ReasonCodes...),
|
||||||
|
Note: item.Note,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := insertAdminCandidateReview(ctx, tx, review); err != nil {
|
||||||
|
return domain.OrderAuthorization{}, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
authorizationVersion, err := nextOrderAuthorizationVersion(
|
||||||
|
ctx,
|
||||||
|
tx,
|
||||||
|
write.Command.ExecutionID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return domain.OrderAuthorization{}, false, err
|
||||||
|
}
|
||||||
|
authorization := domain.OrderAuthorization{
|
||||||
|
ID: write.AuthorizationID,
|
||||||
|
TaskID: task.ID,
|
||||||
|
ExecutionID: write.Command.ExecutionID,
|
||||||
|
AuthorizationVersion: authorizationVersion,
|
||||||
|
CandidateKey: selected.CandidateKey,
|
||||||
|
TaskContentSHA256: write.Command.TaskContentSHA256,
|
||||||
|
TaskVersion: task.Version + 1,
|
||||||
|
ReviewID: review.ID,
|
||||||
|
ReviewVersion: review.Version,
|
||||||
|
UserID: executionUserID,
|
||||||
|
DeviceID: executionDeviceID,
|
||||||
|
ClaimGeneration: executionClaimGeneration,
|
||||||
|
OriginalSKU: task.SKU,
|
||||||
|
Quantity: task.Quantity,
|
||||||
|
CandidateSKUText: selected.SKUText,
|
||||||
|
CandidatePriceText: selected.PriceText,
|
||||||
|
CardSignature: selected.CardSignature,
|
||||||
|
DetailSignature: selected.DetailSignature,
|
||||||
|
DetailEvidenceSHA256: selected.DetailEvidenceSHA256,
|
||||||
|
SpecificationEvidenceSHA256: selected.SpecificationEvidenceSHA256,
|
||||||
|
Status: domain.OrderAuthorizationPendingDelivery,
|
||||||
|
SupersedesAuthorizationID: write.Command.SupersedesAuthorizationID,
|
||||||
|
CreatedByUserID: write.Command.ActorUserID,
|
||||||
|
CreatedAt: write.Now,
|
||||||
|
}
|
||||||
|
if err := insertOrderAuthorization(ctx, tx, authorization); err != nil {
|
||||||
|
return domain.OrderAuthorization{}, false, err
|
||||||
|
}
|
||||||
|
result, err := tx.ExecContext(
|
||||||
|
ctx,
|
||||||
|
`UPDATE purchase_tasks
|
||||||
|
SET version = version + 1, updated_at = ?
|
||||||
|
WHERE id = ? AND status = 'WAITING_CONFIRMATION'
|
||||||
|
AND version = ? AND cancel_requested_at IS NULL`,
|
||||||
|
formatTimestamp(write.Now),
|
||||||
|
task.ID,
|
||||||
|
task.Version,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return domain.OrderAuthorization{}, false, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
affected, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return domain.OrderAuthorization{}, false, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
if affected != 1 {
|
||||||
|
return domain.OrderAuthorization{}, false, usecase.ErrTaskStateConflict
|
||||||
|
}
|
||||||
|
if err := insertTaskEvent(ctx, tx, write.Event); err != nil {
|
||||||
|
return domain.OrderAuthorization{}, false, err
|
||||||
|
}
|
||||||
|
_, err = tx.ExecContext(
|
||||||
|
ctx,
|
||||||
|
`INSERT INTO admin_order_authorization_requests (
|
||||||
|
actor_user_id, idempotency_key, request_sha256,
|
||||||
|
task_id, authorization_id, created_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
write.Command.ActorUserID,
|
||||||
|
write.Command.IdempotencyKey,
|
||||||
|
write.RequestSHA256,
|
||||||
|
write.Command.TaskID,
|
||||||
|
authorization.ID,
|
||||||
|
formatTimestamp(write.Now),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return domain.OrderAuthorization{}, false, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return domain.OrderAuthorization{}, false, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
return authorization, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type orderAuthorizationRequestRecord struct {
|
||||||
|
RequestSHA256 string
|
||||||
|
TaskID string
|
||||||
|
AuthorizationID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func lookupOrderAuthorizationRequest(
|
||||||
|
ctx context.Context,
|
||||||
|
queryer queryRower,
|
||||||
|
actorUserID string,
|
||||||
|
idempotencyKey string,
|
||||||
|
) (orderAuthorizationRequestRecord, bool, error) {
|
||||||
|
var record orderAuthorizationRequestRecord
|
||||||
|
err := queryer.QueryRowContext(
|
||||||
|
ctx,
|
||||||
|
`SELECT request_sha256, task_id, authorization_id
|
||||||
|
FROM admin_order_authorization_requests
|
||||||
|
WHERE actor_user_id = ? AND idempotency_key = ?`,
|
||||||
|
actorUserID,
|
||||||
|
idempotencyKey,
|
||||||
|
).Scan(
|
||||||
|
&record.RequestSHA256,
|
||||||
|
&record.TaskID,
|
||||||
|
&record.AuthorizationID,
|
||||||
|
)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return orderAuthorizationRequestRecord{}, false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return orderAuthorizationRequestRecord{}, false, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
return record, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type orderAuthorizationObservation struct {
|
||||||
|
CandidateKey string
|
||||||
|
Ordinal int
|
||||||
|
SKUText string
|
||||||
|
PriceText string
|
||||||
|
CardSignature string
|
||||||
|
DetailSignature string
|
||||||
|
DetailEvidenceSHA256 string
|
||||||
|
SpecificationEvidenceSHA256 string
|
||||||
|
}
|
||||||
|
|
||||||
|
func orderAuthorizationObservations(
|
||||||
|
ctx context.Context,
|
||||||
|
queryer queryer,
|
||||||
|
taskID string,
|
||||||
|
executionID string,
|
||||||
|
) ([]orderAuthorizationObservation, error) {
|
||||||
|
rows, err := queryer.QueryContext(
|
||||||
|
ctx,
|
||||||
|
`SELECT identity.candidate_key, observation.ordinal,
|
||||||
|
observation.sku_text, observation.price_text,
|
||||||
|
identity.card_signature, identity.detail_signature,
|
||||||
|
identity.detail_evidence_sha256,
|
||||||
|
identity.specification_evidence_sha256
|
||||||
|
FROM candidate_observations AS observation
|
||||||
|
JOIN candidate_observation_identities AS identity
|
||||||
|
ON identity.execution_id = observation.execution_id
|
||||||
|
AND identity.candidate_ordinal = observation.ordinal
|
||||||
|
WHERE observation.task_id = ? AND observation.execution_id = ?
|
||||||
|
ORDER BY observation.ordinal ASC`,
|
||||||
|
taskID,
|
||||||
|
executionID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
result := make([]orderAuthorizationObservation, 0, 5)
|
||||||
|
for rows.Next() {
|
||||||
|
var observation orderAuthorizationObservation
|
||||||
|
if err := rows.Scan(
|
||||||
|
&observation.CandidateKey,
|
||||||
|
&observation.Ordinal,
|
||||||
|
&observation.SKUText,
|
||||||
|
&observation.PriceText,
|
||||||
|
&observation.CardSignature,
|
||||||
|
&observation.DetailSignature,
|
||||||
|
&observation.DetailEvidenceSHA256,
|
||||||
|
&observation.SpecificationEvidenceSHA256,
|
||||||
|
); err != nil {
|
||||||
|
return nil, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
result = append(result, observation)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func activeOrderAuthorization(
|
||||||
|
ctx context.Context,
|
||||||
|
queryer queryRower,
|
||||||
|
executionID string,
|
||||||
|
) (*string, domain.OrderAuthorizationStatus, error) {
|
||||||
|
var id string
|
||||||
|
var status domain.OrderAuthorizationStatus
|
||||||
|
err := queryer.QueryRowContext(
|
||||||
|
ctx,
|
||||||
|
`SELECT id, status
|
||||||
|
FROM order_authorizations
|
||||||
|
WHERE execution_id = ?
|
||||||
|
AND status IN (
|
||||||
|
'PENDING_DELIVERY',
|
||||||
|
'DELIVERED',
|
||||||
|
'ACKNOWLEDGED',
|
||||||
|
'EXECUTING'
|
||||||
|
)
|
||||||
|
LIMIT 1`,
|
||||||
|
executionID,
|
||||||
|
).Scan(&id, &status)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, "", nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", repositoryFailure(err)
|
||||||
|
}
|
||||||
|
return &id, status, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func nextOrderAuthorizationVersion(
|
||||||
|
ctx context.Context,
|
||||||
|
queryer queryRower,
|
||||||
|
executionID string,
|
||||||
|
) (int, error) {
|
||||||
|
var version int
|
||||||
|
err := queryer.QueryRowContext(
|
||||||
|
ctx,
|
||||||
|
`SELECT COALESCE(MAX(authorization_version), 0) + 1
|
||||||
|
FROM order_authorizations
|
||||||
|
WHERE execution_id = ?`,
|
||||||
|
executionID,
|
||||||
|
).Scan(&version)
|
||||||
|
if err != nil {
|
||||||
|
return 0, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
return version, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func insertAdminCandidateReview(
|
||||||
|
ctx context.Context,
|
||||||
|
tx *sql.Tx,
|
||||||
|
review domain.CandidateHumanReview,
|
||||||
|
) error {
|
||||||
|
_, err := tx.ExecContext(
|
||||||
|
ctx,
|
||||||
|
`INSERT INTO candidate_human_reviews (
|
||||||
|
id, execution_id, task_id, version, reason_schema_version, outcome,
|
||||||
|
selected_candidate_ordinal, primary_reason_code, note,
|
||||||
|
supersedes_review_id, actor_user_id, actor_device_id, created_at,
|
||||||
|
received_after_execution_expiry
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, 0)`,
|
||||||
|
review.ID,
|
||||||
|
review.ExecutionID,
|
||||||
|
review.TaskID,
|
||||||
|
review.Version,
|
||||||
|
review.ReasonSchemaVersion,
|
||||||
|
review.Outcome,
|
||||||
|
nullableInt(review.SelectedCandidateOrdinal),
|
||||||
|
review.PrimaryReasonCode,
|
||||||
|
review.Note,
|
||||||
|
nullableString(review.SupersedesReviewID),
|
||||||
|
review.ActorUserID,
|
||||||
|
formatTimestamp(review.CreatedAt),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return repositoryFailure(err)
|
||||||
|
}
|
||||||
|
for _, item := range review.Items {
|
||||||
|
_, err = tx.ExecContext(
|
||||||
|
ctx,
|
||||||
|
`INSERT INTO candidate_human_review_items (
|
||||||
|
review_id, candidate_ordinal, label, primary_reason_code, note
|
||||||
|
) VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
review.ID,
|
||||||
|
item.CandidateOrdinal,
|
||||||
|
item.Label,
|
||||||
|
item.PrimaryReasonCode,
|
||||||
|
item.Note,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return repositoryFailure(err)
|
||||||
|
}
|
||||||
|
for _, reason := range item.ReasonCodes {
|
||||||
|
_, err = tx.ExecContext(
|
||||||
|
ctx,
|
||||||
|
`INSERT INTO candidate_human_review_reasons (
|
||||||
|
review_id, candidate_ordinal, reason_code
|
||||||
|
) VALUES (?, ?, ?)`,
|
||||||
|
review.ID,
|
||||||
|
item.CandidateOrdinal,
|
||||||
|
reason,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return repositoryFailure(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func insertOrderAuthorization(
|
||||||
|
ctx context.Context,
|
||||||
|
tx *sql.Tx,
|
||||||
|
authorization domain.OrderAuthorization,
|
||||||
|
) error {
|
||||||
|
_, err := tx.ExecContext(
|
||||||
|
ctx,
|
||||||
|
`INSERT INTO order_authorizations (
|
||||||
|
id, task_id, execution_id, authorization_version, candidate_key,
|
||||||
|
task_content_sha256, task_version, review_id, review_version,
|
||||||
|
user_id, device_id, claim_generation, original_sku, quantity,
|
||||||
|
candidate_sku_text, candidate_price_text, card_signature,
|
||||||
|
detail_signature, detail_evidence_sha256,
|
||||||
|
specification_evidence_sha256, status,
|
||||||
|
supersedes_authorization_id, created_by_user_id, created_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
||||||
|
?, ?, ?, ?)`,
|
||||||
|
authorization.ID,
|
||||||
|
authorization.TaskID,
|
||||||
|
authorization.ExecutionID,
|
||||||
|
authorization.AuthorizationVersion,
|
||||||
|
authorization.CandidateKey,
|
||||||
|
authorization.TaskContentSHA256,
|
||||||
|
authorization.TaskVersion,
|
||||||
|
authorization.ReviewID,
|
||||||
|
authorization.ReviewVersion,
|
||||||
|
authorization.UserID,
|
||||||
|
authorization.DeviceID,
|
||||||
|
authorization.ClaimGeneration,
|
||||||
|
authorization.OriginalSKU,
|
||||||
|
authorization.Quantity,
|
||||||
|
authorization.CandidateSKUText,
|
||||||
|
authorization.CandidatePriceText,
|
||||||
|
authorization.CardSignature,
|
||||||
|
authorization.DetailSignature,
|
||||||
|
authorization.DetailEvidenceSHA256,
|
||||||
|
authorization.SpecificationEvidenceSHA256,
|
||||||
|
authorization.Status,
|
||||||
|
nullableString(authorization.SupersedesAuthorizationID),
|
||||||
|
authorization.CreatedByUserID,
|
||||||
|
formatTimestamp(authorization.CreatedAt),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return repositoryFailure(err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getOrderAuthorization(
|
||||||
|
ctx context.Context,
|
||||||
|
queryer queryRower,
|
||||||
|
authorizationID string,
|
||||||
|
) (domain.OrderAuthorization, error) {
|
||||||
|
authorization, err := scanOrderAuthorization(queryer.QueryRowContext(
|
||||||
|
ctx,
|
||||||
|
orderAuthorizationSelect+` WHERE id = ?`,
|
||||||
|
authorizationID,
|
||||||
|
))
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return domain.OrderAuthorization{}, usecase.ErrRepositoryNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return domain.OrderAuthorization{}, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
return authorization, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func listOrderAuthorizations(
|
||||||
|
ctx context.Context,
|
||||||
|
queryer queryer,
|
||||||
|
taskID string,
|
||||||
|
) ([]domain.OrderAuthorization, error) {
|
||||||
|
rows, err := queryer.QueryContext(
|
||||||
|
ctx,
|
||||||
|
orderAuthorizationSelect+
|
||||||
|
` WHERE task_id = ?
|
||||||
|
ORDER BY authorization_version ASC`,
|
||||||
|
taskID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
result := make([]domain.OrderAuthorization, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
authorization, err := scanOrderAuthorization(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
result = append(result, authorization)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, repositoryFailure(err)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanOrderAuthorization(
|
||||||
|
scanner rowScanner,
|
||||||
|
) (domain.OrderAuthorization, error) {
|
||||||
|
var authorization domain.OrderAuthorization
|
||||||
|
var supersedes, deliveredAt, acknowledgedAt sql.NullString
|
||||||
|
var executionStartedAt, consumedAt, failedAt, revokedAt sql.NullString
|
||||||
|
var failureCode, failureMessage sql.NullString
|
||||||
|
var createdAt string
|
||||||
|
err := scanner.Scan(
|
||||||
|
&authorization.ID,
|
||||||
|
&authorization.TaskID,
|
||||||
|
&authorization.ExecutionID,
|
||||||
|
&authorization.AuthorizationVersion,
|
||||||
|
&authorization.CandidateKey,
|
||||||
|
&authorization.TaskContentSHA256,
|
||||||
|
&authorization.TaskVersion,
|
||||||
|
&authorization.ReviewID,
|
||||||
|
&authorization.ReviewVersion,
|
||||||
|
&authorization.UserID,
|
||||||
|
&authorization.DeviceID,
|
||||||
|
&authorization.ClaimGeneration,
|
||||||
|
&authorization.OriginalSKU,
|
||||||
|
&authorization.Quantity,
|
||||||
|
&authorization.CandidateSKUText,
|
||||||
|
&authorization.CandidatePriceText,
|
||||||
|
&authorization.CardSignature,
|
||||||
|
&authorization.DetailSignature,
|
||||||
|
&authorization.DetailEvidenceSHA256,
|
||||||
|
&authorization.SpecificationEvidenceSHA256,
|
||||||
|
&authorization.Status,
|
||||||
|
&supersedes,
|
||||||
|
&authorization.CreatedByUserID,
|
||||||
|
&createdAt,
|
||||||
|
&deliveredAt,
|
||||||
|
&acknowledgedAt,
|
||||||
|
&executionStartedAt,
|
||||||
|
&consumedAt,
|
||||||
|
&failedAt,
|
||||||
|
&revokedAt,
|
||||||
|
&failureCode,
|
||||||
|
&failureMessage,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return domain.OrderAuthorization{}, err
|
||||||
|
}
|
||||||
|
if supersedes.Valid {
|
||||||
|
authorization.SupersedesAuthorizationID = &supersedes.String
|
||||||
|
}
|
||||||
|
authorization.CreatedAt, err = parseTimestamp(createdAt)
|
||||||
|
if err != nil {
|
||||||
|
return domain.OrderAuthorization{}, err
|
||||||
|
}
|
||||||
|
if authorization.DeliveredAt, err = parseNullableTimestamp(deliveredAt); err != nil {
|
||||||
|
return domain.OrderAuthorization{}, err
|
||||||
|
}
|
||||||
|
if authorization.AcknowledgedAt, err = parseNullableTimestamp(acknowledgedAt); err != nil {
|
||||||
|
return domain.OrderAuthorization{}, err
|
||||||
|
}
|
||||||
|
if authorization.ExecutionStartedAt, err = parseNullableTimestamp(executionStartedAt); err != nil {
|
||||||
|
return domain.OrderAuthorization{}, err
|
||||||
|
}
|
||||||
|
if authorization.ConsumedAt, err = parseNullableTimestamp(consumedAt); err != nil {
|
||||||
|
return domain.OrderAuthorization{}, err
|
||||||
|
}
|
||||||
|
if authorization.FailedAt, err = parseNullableTimestamp(failedAt); err != nil {
|
||||||
|
return domain.OrderAuthorization{}, err
|
||||||
|
}
|
||||||
|
if authorization.RevokedAt, err = parseNullableTimestamp(revokedAt); err != nil {
|
||||||
|
return domain.OrderAuthorization{}, err
|
||||||
|
}
|
||||||
|
if failureCode.Valid {
|
||||||
|
authorization.FailureCode = &failureCode.String
|
||||||
|
}
|
||||||
|
if failureMessage.Valid {
|
||||||
|
authorization.FailureMessage = &failureMessage.String
|
||||||
|
}
|
||||||
|
return authorization, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderAuthorizationSelect = `SELECT
|
||||||
|
id, task_id, execution_id, authorization_version, candidate_key,
|
||||||
|
task_content_sha256, task_version, review_id, review_version,
|
||||||
|
user_id, device_id, claim_generation, original_sku, quantity,
|
||||||
|
candidate_sku_text, candidate_price_text, card_signature,
|
||||||
|
detail_signature, detail_evidence_sha256,
|
||||||
|
specification_evidence_sha256, status,
|
||||||
|
supersedes_authorization_id, created_by_user_id, created_at,
|
||||||
|
delivered_at, acknowledged_at, execution_started_at, consumed_at,
|
||||||
|
failed_at, revoked_at, failure_code, failure_message
|
||||||
|
FROM order_authorizations`
|
||||||
|
|
||||||
|
const localAdminSubject = "local-admin"
|
||||||
|
|
||||||
|
var _ usecase.OrderAuthorizationRepository = (*Store)(nil)
|
||||||
@@ -320,12 +320,17 @@ func (s *Store) GetTaskDetail(
|
|||||||
return domain.TaskDetail{}, err
|
return domain.TaskDetail{}, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
orderAuthorizations, err := listOrderAuthorizations(ctx, tx, taskID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.TaskDetail{}, err
|
||||||
|
}
|
||||||
detail := domain.TaskDetail{
|
detail := domain.TaskDetail{
|
||||||
Task: task,
|
Task: task,
|
||||||
Asset: asset,
|
Asset: asset,
|
||||||
Execution: executionPointer,
|
Execution: executionPointer,
|
||||||
Events: events,
|
Events: events,
|
||||||
Report: report,
|
Report: report,
|
||||||
|
OrderAuthorizations: orderAuthorizations,
|
||||||
}
|
}
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
return domain.TaskDetail{}, repositoryFailure(err)
|
return domain.TaskDetail{}, repositoryFailure(err)
|
||||||
|
|||||||
@@ -27,10 +27,12 @@ type AdminServices struct {
|
|||||||
Assets *usecase.AssetService
|
Assets *usecase.AssetService
|
||||||
Tasks *usecase.TaskService
|
Tasks *usecase.TaskService
|
||||||
Results *usecase.ExecutionResultService
|
Results *usecase.ExecutionResultService
|
||||||
|
Authorizations *usecase.OrderAuthorizationService
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s AdminServices) validate() error {
|
func (s AdminServices) validate() error {
|
||||||
if s.Assets == nil || s.Tasks == nil || s.Results == nil {
|
if s.Assets == nil || s.Tasks == nil || s.Results == nil ||
|
||||||
|
s.Authorizations == nil {
|
||||||
return errors.New("admin services are required")
|
return errors.New("admin services are required")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -55,6 +57,10 @@ func registerAdminAPI(routes gin.IRoutes, services AdminServices) error {
|
|||||||
handler.evidenceContent,
|
handler.evidenceContent,
|
||||||
)
|
)
|
||||||
routes.POST("/api/v1/tasks/:id/cancel", handler.cancelTask)
|
routes.POST("/api/v1/tasks/:id/cancel", handler.cancelTask)
|
||||||
|
routes.POST(
|
||||||
|
"/api/v1/tasks/:id/order-authorizations",
|
||||||
|
handler.createOrderAuthorization,
|
||||||
|
)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,6 +377,9 @@ func (h *adminHandlers) taskDetail(ctx *gin.Context) {
|
|||||||
"claim": claim,
|
"claim": claim,
|
||||||
"execution": execution,
|
"execution": execution,
|
||||||
"execution_report": executionReport,
|
"execution_report": executionReport,
|
||||||
|
"order_authorizations": orderAuthorizationResponses(
|
||||||
|
detail.OrderAuthorizations,
|
||||||
|
),
|
||||||
"events": events,
|
"events": events,
|
||||||
"assets": []gin.H{
|
"assets": []gin.H{
|
||||||
assetResponse(detail.Asset),
|
assetResponse(detail.Asset),
|
||||||
@@ -378,6 +387,106 @@ func (h *adminHandlers) taskDetail(ctx *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *adminHandlers) createOrderAuthorization(ctx *gin.Context) {
|
||||||
|
var request struct {
|
||||||
|
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 []usecase.OrderAuthorizationItemInput `json:"items"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(ctx, &request); err != nil {
|
||||||
|
writePublicError(
|
||||||
|
ctx,
|
||||||
|
http.StatusBadRequest,
|
||||||
|
"INVALID_JSON",
|
||||||
|
"request body must be valid JSON",
|
||||||
|
false,
|
||||||
|
gin.H{},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := h.services.Authorizations.Create(
|
||||||
|
ctx.Request.Context(),
|
||||||
|
usecase.CreateOrderAuthorizationCommand{
|
||||||
|
ActorUserID: adminActorUserID(ctx),
|
||||||
|
TaskID: ctx.Param("id"),
|
||||||
|
IdempotencyKey: ctx.GetHeader("Idempotency-Key"),
|
||||||
|
ExecutionID: request.ExecutionID,
|
||||||
|
TaskContentSHA256: request.TaskContentSHA256,
|
||||||
|
ExpectedTaskVersion: request.ExpectedTaskVersion,
|
||||||
|
CandidateKey: request.CandidateKey,
|
||||||
|
ReasonSchemaVersion: request.ReasonSchemaVersion,
|
||||||
|
PrimaryReasonCode: request.PrimaryReasonCode,
|
||||||
|
Note: request.Note,
|
||||||
|
SupersedesAuthorizationID: request.SupersedesAuthorizationID,
|
||||||
|
Items: request.Items,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
writeUsecaseError(ctx, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Header("Cache-Control", "no-store")
|
||||||
|
ctx.JSON(http.StatusCreated, gin.H{
|
||||||
|
"authorization": orderAuthorizationResponse(result.Authorization),
|
||||||
|
"replayed": result.Replayed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func orderAuthorizationResponses(
|
||||||
|
authorizations []domain.OrderAuthorization,
|
||||||
|
) []gin.H {
|
||||||
|
result := make([]gin.H, 0, len(authorizations))
|
||||||
|
for _, authorization := range authorizations {
|
||||||
|
result = append(result, orderAuthorizationResponse(authorization))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func orderAuthorizationResponse(
|
||||||
|
authorization domain.OrderAuthorization,
|
||||||
|
) gin.H {
|
||||||
|
return gin.H{
|
||||||
|
"id": authorization.ID,
|
||||||
|
"task_id": authorization.TaskID,
|
||||||
|
"execution_id": authorization.ExecutionID,
|
||||||
|
"authorization_version": authorization.AuthorizationVersion,
|
||||||
|
"candidate_key": authorization.CandidateKey,
|
||||||
|
"task_content_sha256": authorization.TaskContentSHA256,
|
||||||
|
"task_version": authorization.TaskVersion,
|
||||||
|
"review_id": authorization.ReviewID,
|
||||||
|
"review_version": authorization.ReviewVersion,
|
||||||
|
"user_id": authorization.UserID,
|
||||||
|
"device_id": authorization.DeviceID,
|
||||||
|
"claim_generation": authorization.ClaimGeneration,
|
||||||
|
"original_sku": authorization.OriginalSKU,
|
||||||
|
"quantity": authorization.Quantity,
|
||||||
|
"candidate_sku_text": authorization.CandidateSKUText,
|
||||||
|
"candidate_price_text": authorization.CandidatePriceText,
|
||||||
|
"card_signature": authorization.CardSignature,
|
||||||
|
"detail_signature": authorization.DetailSignature,
|
||||||
|
"detail_evidence_sha256": authorization.DetailEvidenceSHA256,
|
||||||
|
"specification_evidence_sha256": authorization.SpecificationEvidenceSHA256,
|
||||||
|
"status": authorization.Status,
|
||||||
|
"supersedes_authorization_id": authorization.SupersedesAuthorizationID,
|
||||||
|
"created_by_user_id": authorization.CreatedByUserID,
|
||||||
|
"created_at": formatTime(authorization.CreatedAt),
|
||||||
|
"delivered_at": formatOptionalTime(authorization.DeliveredAt),
|
||||||
|
"acknowledged_at": formatOptionalTime(authorization.AcknowledgedAt),
|
||||||
|
"execution_started_at": formatOptionalTime(authorization.ExecutionStartedAt),
|
||||||
|
"consumed_at": formatOptionalTime(authorization.ConsumedAt),
|
||||||
|
"failed_at": formatOptionalTime(authorization.FailedAt),
|
||||||
|
"revoked_at": formatOptionalTime(authorization.RevokedAt),
|
||||||
|
"failure_code": authorization.FailureCode,
|
||||||
|
"failure_message": authorization.FailureMessage,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func executionReportResponse(report *domain.ExecutionReport) gin.H {
|
func executionReportResponse(report *domain.ExecutionReport) gin.H {
|
||||||
events := make([]gin.H, 0, len(report.Events))
|
events := make([]gin.H, 0, len(report.Events))
|
||||||
for _, event := range report.Events {
|
for _, event := range report.Events {
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package httpapi
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"image"
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
"image/jpeg"
|
"image/jpeg"
|
||||||
@@ -288,6 +290,131 @@ func TestAdminAPIAssetAndTaskLifecycle(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAdminOrderAuthorizationIsIdempotentAndRevisioned(t *testing.T) {
|
||||||
|
fixture := newAdminIntegrationFixture(t)
|
||||||
|
taskID, executionID, taskHash, firstKey, secondKey :=
|
||||||
|
seedAdminAuthorizationTask(t, fixture)
|
||||||
|
payload := fmt.Sprintf(
|
||||||
|
`{"execution_id":%q,"task_content_sha256":%q,"expected_task_version":2,"candidate_key":%q,"reason_schema_version":1,"primary_reason_code":"SELECTED_BEST_MATCH","note":"","supersedes_authorization_id":null,"items":[{"candidate_key":%q,"label":"ACCEPT","primary_reason_code":"SKU_MATCH","reason_codes":["SKU_MATCH"],"note":""},{"candidate_key":%q,"label":"REJECT","primary_reason_code":"NOT_BEST_MATCH","reason_codes":["NOT_BEST_MATCH"],"note":""}]}`,
|
||||||
|
executionID,
|
||||||
|
taskHash,
|
||||||
|
firstKey,
|
||||||
|
firstKey,
|
||||||
|
secondKey,
|
||||||
|
)
|
||||||
|
created := performAdminRequest(
|
||||||
|
t,
|
||||||
|
fixture.router,
|
||||||
|
http.MethodPost,
|
||||||
|
"/api/v1/tasks/"+taskID+"/order-authorizations",
|
||||||
|
"application/json",
|
||||||
|
strings.NewReader(payload),
|
||||||
|
"authorization-1",
|
||||||
|
)
|
||||||
|
if created.Code != http.StatusCreated {
|
||||||
|
t.Fatalf(
|
||||||
|
"authorization status/body = %d / %s",
|
||||||
|
created.Code,
|
||||||
|
created.Body.String(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
var createdBody struct {
|
||||||
|
Authorization struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Version int `json:"authorization_version"`
|
||||||
|
} `json:"authorization"`
|
||||||
|
Replayed bool `json:"replayed"`
|
||||||
|
}
|
||||||
|
decodeResponse(t, created, &createdBody)
|
||||||
|
if createdBody.Authorization.ID == "" ||
|
||||||
|
createdBody.Authorization.Status != "PENDING_DELIVERY" ||
|
||||||
|
createdBody.Authorization.Version != 1 ||
|
||||||
|
createdBody.Replayed {
|
||||||
|
t.Fatalf("authorization response = %+v", createdBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
replayed := performAdminRequest(
|
||||||
|
t,
|
||||||
|
fixture.router,
|
||||||
|
http.MethodPost,
|
||||||
|
"/api/v1/tasks/"+taskID+"/order-authorizations",
|
||||||
|
"application/json",
|
||||||
|
strings.NewReader(payload),
|
||||||
|
"authorization-1",
|
||||||
|
)
|
||||||
|
requireAdminStatus(t, replayed, http.StatusCreated)
|
||||||
|
if !strings.Contains(replayed.Body.String(), `"replayed":true`) ||
|
||||||
|
!strings.Contains(
|
||||||
|
replayed.Body.String(),
|
||||||
|
createdBody.Authorization.ID,
|
||||||
|
) {
|
||||||
|
t.Fatalf("authorization replay = %s", replayed.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
stale := performAdminRequest(
|
||||||
|
t,
|
||||||
|
fixture.router,
|
||||||
|
http.MethodPost,
|
||||||
|
"/api/v1/tasks/"+taskID+"/order-authorizations",
|
||||||
|
"application/json",
|
||||||
|
strings.NewReader(payload),
|
||||||
|
"authorization-stale",
|
||||||
|
)
|
||||||
|
requireAdminStatus(t, stale, http.StatusConflict)
|
||||||
|
|
||||||
|
revisedPayload := fmt.Sprintf(
|
||||||
|
`{"execution_id":%q,"task_content_sha256":%q,"expected_task_version":3,"candidate_key":%q,"reason_schema_version":1,"primary_reason_code":"SELECTED_BEST_MATCH","note":"","supersedes_authorization_id":%q,"items":[{"candidate_key":%q,"label":"REJECT","primary_reason_code":"NOT_BEST_MATCH","reason_codes":["NOT_BEST_MATCH"],"note":""},{"candidate_key":%q,"label":"ACCEPT","primary_reason_code":"IMAGE_MATCH","reason_codes":["IMAGE_MATCH"],"note":""}]}`,
|
||||||
|
executionID,
|
||||||
|
taskHash,
|
||||||
|
secondKey,
|
||||||
|
createdBody.Authorization.ID,
|
||||||
|
firstKey,
|
||||||
|
secondKey,
|
||||||
|
)
|
||||||
|
revised := performAdminRequest(
|
||||||
|
t,
|
||||||
|
fixture.router,
|
||||||
|
http.MethodPost,
|
||||||
|
"/api/v1/tasks/"+taskID+"/order-authorizations",
|
||||||
|
"application/json",
|
||||||
|
strings.NewReader(revisedPayload),
|
||||||
|
"authorization-2",
|
||||||
|
)
|
||||||
|
requireAdminStatus(t, revised, http.StatusCreated)
|
||||||
|
if !strings.Contains(revised.Body.String(), `"authorization_version":2`) ||
|
||||||
|
!strings.Contains(revised.Body.String(), `"candidate_key":"`+secondKey+`"`) {
|
||||||
|
t.Fatalf("revised authorization = %s", revised.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
detail := performAdminRequest(
|
||||||
|
t,
|
||||||
|
fixture.router,
|
||||||
|
http.MethodGet,
|
||||||
|
"/api/v1/tasks/"+taskID,
|
||||||
|
"",
|
||||||
|
nil,
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
requireAdminStatus(t, detail, http.StatusOK)
|
||||||
|
var detailBody map[string]any
|
||||||
|
decodeResponse(t, detail, &detailBody)
|
||||||
|
authorizations, _ := detailBody["order_authorizations"].([]any)
|
||||||
|
if detailBody["version"] != float64(4) || len(authorizations) != 2 ||
|
||||||
|
!strings.Contains(detail.Body.String(), `"status":"SUPERSEDED"`) ||
|
||||||
|
!strings.Contains(detail.Body.String(), `"review_version":2`) {
|
||||||
|
t.Fatalf("authorization detail = %#v", detailBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
runner, err := migration.New(fixture.db)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("migration.New() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := runner.Down(context.Background()); err == nil {
|
||||||
|
t.Fatal("order authorization migration down succeeded with retained data")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAdminRoutesRejectRequestsWithoutAdminSession(t *testing.T) {
|
func TestAdminRoutesRejectRequestsWithoutAdminSession(t *testing.T) {
|
||||||
router := newAdminIntegrationRouter(t)
|
router := newAdminIntegrationRouter(t)
|
||||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/tasks", nil)
|
request := httptest.NewRequest(http.MethodGet, "/api/v1/tasks", nil)
|
||||||
@@ -307,6 +434,201 @@ func TestAdminRoutesRejectRequestsWithoutAdminSession(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func seedAdminAuthorizationTask(
|
||||||
|
t *testing.T,
|
||||||
|
fixture *adminIntegrationFixture,
|
||||||
|
) (taskID string, executionID string, taskHash string, firstKey string, secondKey string) {
|
||||||
|
t.Helper()
|
||||||
|
imageBody, imageContentType := referenceUpload(t, "authorization-asset")
|
||||||
|
assetResponse := performAdminRequest(
|
||||||
|
t,
|
||||||
|
fixture.router,
|
||||||
|
http.MethodPost,
|
||||||
|
"/api/v1/assets",
|
||||||
|
imageContentType,
|
||||||
|
imageBody,
|
||||||
|
"authorization-asset",
|
||||||
|
)
|
||||||
|
requireAdminStatus(t, assetResponse, http.StatusCreated)
|
||||||
|
var asset struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
decodeResponse(t, assetResponse, &asset)
|
||||||
|
taskResponse := performAdminRequest(
|
||||||
|
t,
|
||||||
|
fixture.router,
|
||||||
|
http.MethodPost,
|
||||||
|
"/api/v1/tasks",
|
||||||
|
"application/json",
|
||||||
|
strings.NewReader(
|
||||||
|
`{"title":"后台授权测试商品","sku":"BLACK-L","description":"","image_asset_id":"`+
|
||||||
|
asset.ID+`","quantity":2,"max_budget":"100.00"}`,
|
||||||
|
),
|
||||||
|
"authorization-task",
|
||||||
|
)
|
||||||
|
requireAdminStatus(t, taskResponse, http.StatusCreated)
|
||||||
|
var task struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
decodeResponse(t, taskResponse, &task)
|
||||||
|
|
||||||
|
const (
|
||||||
|
buyerID = "00000000-0000-4000-8000-000000000901"
|
||||||
|
deviceID = "00000000-0000-4000-8000-000000000902"
|
||||||
|
)
|
||||||
|
executionID = "00000000-0000-4000-8000-000000000903"
|
||||||
|
now := time.Now().UTC()
|
||||||
|
nowText := now.Format(time.RFC3339Nano)
|
||||||
|
expiryText := now.Add(time.Hour).Format(time.RFC3339Nano)
|
||||||
|
if _, err := fixture.db.Exec(
|
||||||
|
`INSERT INTO users (
|
||||||
|
id, username, password_hash, role, is_active, created_at, updated_at
|
||||||
|
) VALUES (?, 'buyer-auth-test', 'test-only-hash', 'BUYER', 1, ?, ?)`,
|
||||||
|
buyerID,
|
||||||
|
nowText,
|
||||||
|
nowText,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("seed authorization buyer: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := fixture.db.Exec(
|
||||||
|
`INSERT INTO devices (
|
||||||
|
id, name, token_hash, bound_user_id, app_version,
|
||||||
|
android_version, pdd_version, last_seen_at, is_enabled,
|
||||||
|
created_at, updated_at, accessibility_enabled, pdd_installed,
|
||||||
|
readiness_reported_at
|
||||||
|
) VALUES (?, 'auth-device', ?, ?, 'test', '16', '8.17.0', ?, 1,
|
||||||
|
?, ?, 1, 1, ?)`,
|
||||||
|
deviceID,
|
||||||
|
strings.Repeat("9", 64),
|
||||||
|
buyerID,
|
||||||
|
nowText,
|
||||||
|
nowText,
|
||||||
|
nowText,
|
||||||
|
nowText,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("seed authorization device: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := fixture.db.Exec(
|
||||||
|
`UPDATE purchase_tasks SET
|
||||||
|
status = 'WAITING_CONFIRMATION', version = 2,
|
||||||
|
claimed_by_user_id = ?, claimed_by_device_id = ?,
|
||||||
|
claim_generation = 1, claim_token_hash = ?,
|
||||||
|
claim_issued_at = ?, claim_expires_at = ?, updated_at = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
buyerID,
|
||||||
|
deviceID,
|
||||||
|
strings.Repeat("8", 64),
|
||||||
|
nowText,
|
||||||
|
expiryText,
|
||||||
|
nowText,
|
||||||
|
task.ID,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("seed authorization task: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := fixture.db.Exec(
|
||||||
|
`INSERT INTO task_executions (
|
||||||
|
id, task_id, attempt_no, claim_generation, user_id, device_id,
|
||||||
|
current_step, last_heartbeat_at, order_submitted, started_at
|
||||||
|
) VALUES (?, ?, 1, 1, ?, ?, 'WAITING_ADMIN_CONFIRMATION', ?, 0, ?)`,
|
||||||
|
executionID,
|
||||||
|
task.ID,
|
||||||
|
buyerID,
|
||||||
|
deviceID,
|
||||||
|
nowText,
|
||||||
|
nowText,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("seed authorization execution: %v", err)
|
||||||
|
}
|
||||||
|
store, err := repository.New(fixture.db)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("repository.New() error = %v", err)
|
||||||
|
}
|
||||||
|
detail, err := store.GetTaskDetail(
|
||||||
|
context.Background(),
|
||||||
|
localAdminSubject,
|
||||||
|
task.ID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetTaskDetail() error = %v", err)
|
||||||
|
}
|
||||||
|
taskHash = usecase.TaskContentSHA256(detail.Task)
|
||||||
|
firstKey = strings.Repeat("a", 64)
|
||||||
|
secondKey = strings.Repeat("b", 64)
|
||||||
|
if _, err := fixture.db.Exec(
|
||||||
|
`INSERT INTO candidate_search_runs (
|
||||||
|
execution_id, task_id, task_content_sha256, execution_mode,
|
||||||
|
search_query, started_at, received_at, observation_count,
|
||||||
|
collection_complete, received_after_execution_expiry
|
||||||
|
) VALUES (?, ?, ?, 'MANUAL_FIRST', 'PDD_IMAGE_SEARCH', ?, ?, 2, 1, 0)`,
|
||||||
|
executionID,
|
||||||
|
task.ID,
|
||||||
|
taskHash,
|
||||||
|
nowText,
|
||||||
|
nowText,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("seed authorization search run: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := fixture.db.Exec(
|
||||||
|
`INSERT INTO candidate_observations (
|
||||||
|
execution_id, task_id, ordinal, title, sku_text, price_text,
|
||||||
|
product_url, image_url, evidence_asset_ids_json,
|
||||||
|
collection_status, observed_at
|
||||||
|
) VALUES
|
||||||
|
(?, ?, 1, '候选一', 'BLACK-L', '20.00', '', '', '[]', 'COMPLETE', ?),
|
||||||
|
(?, ?, 2, '候选二', 'BLACK-L', '22.00', '', '', '[]', 'COMPLETE', ?)`,
|
||||||
|
executionID,
|
||||||
|
task.ID,
|
||||||
|
nowText,
|
||||||
|
executionID,
|
||||||
|
task.ID,
|
||||||
|
nowText,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("seed authorization observations: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := fixture.db.Exec(
|
||||||
|
`INSERT INTO candidate_observation_identities (
|
||||||
|
candidate_key, execution_id, candidate_ordinal, card_signature,
|
||||||
|
detail_signature, detail_evidence_sha256,
|
||||||
|
specification_evidence_sha256, identity_version, created_at
|
||||||
|
) VALUES
|
||||||
|
(?, ?, 1, ?, ?, ?, ?, 1, ?),
|
||||||
|
(?, ?, 2, ?, ?, ?, ?, 1, ?)`,
|
||||||
|
firstKey,
|
||||||
|
executionID,
|
||||||
|
strings.Repeat("c", 64),
|
||||||
|
strings.Repeat("d", 64),
|
||||||
|
strings.Repeat("e", 64),
|
||||||
|
strings.Repeat("f", 64),
|
||||||
|
nowText,
|
||||||
|
secondKey,
|
||||||
|
executionID,
|
||||||
|
strings.Repeat("1", 64),
|
||||||
|
strings.Repeat("2", 64),
|
||||||
|
strings.Repeat("3", 64),
|
||||||
|
strings.Repeat("4", 64),
|
||||||
|
nowText,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("seed authorization identities: %v", err)
|
||||||
|
}
|
||||||
|
return task.ID, executionID, taskHash, firstKey, secondKey
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireAdminStatus(
|
||||||
|
t *testing.T,
|
||||||
|
response *httptest.ResponseRecorder,
|
||||||
|
want int,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
if response.Code != want {
|
||||||
|
t.Fatalf(
|
||||||
|
"status/body = %d / %s, want %d",
|
||||||
|
response.Code,
|
||||||
|
response.Body.String(),
|
||||||
|
want,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAdminAssetUploadRequiresIdempotencyKey(t *testing.T) {
|
func TestAdminAssetUploadRequiresIdempotencyKey(t *testing.T) {
|
||||||
router := newAdminIntegrationRouter(t)
|
router := newAdminIntegrationRouter(t)
|
||||||
imageBody, imageContentType := referenceUpload(t, "missing-key")
|
imageBody, imageContentType := referenceUpload(t, "missing-key")
|
||||||
@@ -337,6 +659,16 @@ type emptyAdminWeb struct{}
|
|||||||
func (emptyAdminWeb) RegisterProtected(gin.IRoutes) {}
|
func (emptyAdminWeb) RegisterProtected(gin.IRoutes) {}
|
||||||
|
|
||||||
func newAdminIntegrationRouter(t *testing.T) http.Handler {
|
func newAdminIntegrationRouter(t *testing.T) http.Handler {
|
||||||
|
t.Helper()
|
||||||
|
return newAdminIntegrationFixture(t).router
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminIntegrationFixture struct {
|
||||||
|
router http.Handler
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAdminIntegrationFixture(t *testing.T) *adminIntegrationFixture {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
db, err := database.Open(ctx, filepath.Join(t.TempDir(), "admin.db"))
|
db, err := database.Open(ctx, filepath.Join(t.TempDir(), "admin.db"))
|
||||||
@@ -385,8 +717,21 @@ func newAdminIntegrationRouter(t *testing.T) http.Handler {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("usecase.NewExecutionResultService() error = %v", err)
|
t.Fatalf("usecase.NewExecutionResultService() error = %v", err)
|
||||||
}
|
}
|
||||||
|
authorizations, err := usecase.NewOrderAuthorizationService(
|
||||||
|
repositories,
|
||||||
|
clock,
|
||||||
|
ids,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("usecase.NewOrderAuthorizationService() error = %v", err)
|
||||||
|
}
|
||||||
registrar, err := NewAdminRouteRegistrar(
|
registrar, err := NewAdminRouteRegistrar(
|
||||||
AdminServices{Assets: assets, Tasks: tasks, Results: results},
|
AdminServices{
|
||||||
|
Assets: assets,
|
||||||
|
Tasks: tasks,
|
||||||
|
Results: results,
|
||||||
|
Authorizations: authorizations,
|
||||||
|
},
|
||||||
emptyAdminWeb{},
|
emptyAdminWeb{},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -404,7 +749,7 @@ func newAdminIntegrationRouter(t *testing.T) http.Handler {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewRouter() error = %v", err)
|
t.Fatalf("NewRouter() error = %v", err)
|
||||||
}
|
}
|
||||||
return router
|
return &adminIntegrationFixture{router: router, db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
func referenceUpload(t *testing.T, key string) (io.Reader, string) {
|
func referenceUpload(t *testing.T, key string) (io.Reader, string) {
|
||||||
|
|||||||
@@ -629,6 +629,24 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
|
|||||||
if !strings.Contains(candidateReplay.Body.String(), `"replayed":true`) {
|
if !strings.Contains(candidateReplay.Body.String(), `"replayed":true`) {
|
||||||
t.Fatalf("candidate replay response = %s", candidateReplay.Body.String())
|
t.Fatalf("candidate replay response = %s", candidateReplay.Body.String())
|
||||||
}
|
}
|
||||||
|
detail, err = fixture.tasks.Get(context.Background(), "local-admin", taskID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get task after candidate upload: %v", err)
|
||||||
|
}
|
||||||
|
readyEvents := 0
|
||||||
|
for _, event := range detail.Events {
|
||||||
|
if event.Type == "CANDIDATES_READY" {
|
||||||
|
readyEvents++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if detail.Task.Status != domain.TaskStatusWaitingConfirmation ||
|
||||||
|
readyEvents != 1 {
|
||||||
|
t.Fatalf(
|
||||||
|
"candidate upload status/events = %s/%+v",
|
||||||
|
detail.Task.Status,
|
||||||
|
detail.Events,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
humanReviewPayload := fmt.Sprintf(
|
humanReviewPayload := fmt.Sprintf(
|
||||||
`{"execution_id":%q,"claim_generation":%d,"task_content_sha256":%q,"reason_schema_version":1,"outcome":"CANDIDATE_ACCEPTED","selected_candidate_ordinal":1,"primary_reason_code":"SELECTED_BEST_MATCH","note":"","items":[{"candidate_ordinal":1,"label":"ACCEPT","primary_reason_code":"SKU_MATCH","reason_codes":["SKU_MATCH"],"note":""}]}`,
|
`{"execution_id":%q,"claim_generation":%d,"task_content_sha256":%q,"reason_schema_version":1,"outcome":"CANDIDATE_ACCEPTED","selected_candidate_ordinal":1,"primary_reason_code":"SELECTED_BEST_MATCH","note":"","items":[{"candidate_ordinal":1,"label":"ACCEPT","primary_reason_code":"SKU_MATCH","reason_codes":["SKU_MATCH"],"note":""}]}`,
|
||||||
@@ -696,7 +714,7 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
|
|||||||
t.Fatalf("migration.New() after review error = %v", err)
|
t.Fatalf("migration.New() after review error = %v", err)
|
||||||
}
|
}
|
||||||
if err := runner.Down(context.Background()); err == nil {
|
if err := runner.Down(context.Background()); err == nil {
|
||||||
t.Fatal("candidate identity migration down succeeded with retained data")
|
t.Fatal("order workflow migration down succeeded with retained data")
|
||||||
}
|
}
|
||||||
|
|
||||||
completePayload := fmt.Sprintf(
|
completePayload := fmt.Sprintf(
|
||||||
|
|||||||
@@ -66,6 +66,11 @@ func (h *Handler) RegisterProtected(routes gin.IRoutes) {
|
|||||||
routes.POST("/tasks", SecurityHeaders(), h.CreateTask)
|
routes.POST("/tasks", SecurityHeaders(), h.CreateTask)
|
||||||
routes.GET("/tasks/:id", SecurityHeaders(), h.TaskDetail)
|
routes.GET("/tasks/:id", SecurityHeaders(), h.TaskDetail)
|
||||||
routes.POST("/tasks/:id/cancel", SecurityHeaders(), h.CancelTask)
|
routes.POST("/tasks/:id/cancel", SecurityHeaders(), h.CancelTask)
|
||||||
|
routes.POST(
|
||||||
|
"/tasks/:id/order-authorizations",
|
||||||
|
SecurityHeaders(),
|
||||||
|
h.AuthorizeOrder,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func SecurityHeaders() gin.HandlerFunc {
|
func SecurityHeaders() gin.HandlerFunc {
|
||||||
@@ -264,6 +269,11 @@ func (h *Handler) TaskDetail(ctx *gin.Context) {
|
|||||||
h.renderError(ctx, http.StatusInternalServerError, "页面暂时无法打开", "请稍后重试。")
|
h.renderError(ctx, http.StatusInternalServerError, "页面暂时无法打开", "请稍后重试。")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
authorizationKey, keyErr := newToken()
|
||||||
|
if keyErr != nil {
|
||||||
|
h.renderError(ctx, http.StatusInternalServerError, "页面暂时无法打开", "请稍后重试。")
|
||||||
|
return
|
||||||
|
}
|
||||||
page := taskDetailPage{
|
page := taskDetailPage{
|
||||||
Page: pageView{
|
Page: pageView{
|
||||||
Title: "任务详情",
|
Title: "任务详情",
|
||||||
@@ -273,11 +283,71 @@ func (h *Handler) TaskDetail(ctx *gin.Context) {
|
|||||||
Task: taskDetailViewFrom(task),
|
Task: taskDetailViewFrom(task),
|
||||||
CSRFToken: token,
|
CSRFToken: token,
|
||||||
CancelKey: cancelKey,
|
CancelKey: cancelKey,
|
||||||
|
AuthorizationKey: authorizationKey,
|
||||||
Notice: detailNotice(ctx.Query("notice")),
|
Notice: detailNotice(ctx.Query("notice")),
|
||||||
}
|
}
|
||||||
h.render(ctx, http.StatusOK, "task-detail", page)
|
h.render(ctx, http.StatusOK, "task-detail", page)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) AuthorizeOrder(ctx *gin.Context) {
|
||||||
|
if !validCSRF(ctx) {
|
||||||
|
h.renderError(
|
||||||
|
ctx,
|
||||||
|
http.StatusForbidden,
|
||||||
|
"请求已失效",
|
||||||
|
"请返回任务详情后重新操作。",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
taskID := strings.TrimSpace(ctx.Param("id"))
|
||||||
|
authorizationKey := strings.TrimSpace(
|
||||||
|
ctx.PostForm("authorization_key"),
|
||||||
|
)
|
||||||
|
expectedVersion, versionErr := strconv.ParseInt(
|
||||||
|
strings.TrimSpace(ctx.PostForm("expected_task_version")),
|
||||||
|
10,
|
||||||
|
64,
|
||||||
|
)
|
||||||
|
if !validToken(authorizationKey) || versionErr != nil ||
|
||||||
|
expectedVersion < 1 {
|
||||||
|
h.renderError(
|
||||||
|
ctx,
|
||||||
|
http.StatusForbidden,
|
||||||
|
"请求已失效",
|
||||||
|
"请返回任务详情后重新操作。",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, err := h.service.AuthorizeOrder(
|
||||||
|
ctx.Request.Context(),
|
||||||
|
AuthorizeOrderInput{
|
||||||
|
TaskID: taskID,
|
||||||
|
IdempotencyKey: authorizationKey,
|
||||||
|
ExpectedTaskVersion: expectedVersion,
|
||||||
|
CandidateKey: strings.TrimSpace(ctx.PostForm("candidate_key")),
|
||||||
|
SelectedReasonCode: strings.TrimSpace(ctx.PostForm("selected_reason_code")),
|
||||||
|
RejectedReasonCode: strings.TrimSpace(ctx.PostForm("rejected_reason_code")),
|
||||||
|
Note: strings.TrimSpace(ctx.PostForm("authorization_note")),
|
||||||
|
SupersedesAuthorizationID: strings.TrimSpace(ctx.PostForm("supersedes_authorization_id")),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrConflict) || errors.Is(err, ErrValidation) {
|
||||||
|
ctx.Redirect(
|
||||||
|
http.StatusSeeOther,
|
||||||
|
"/tasks/"+pathEscape(taskID)+"?notice=authorization-conflict",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.renderServiceError(ctx, err, "授权失败,请稍后重试。")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Redirect(
|
||||||
|
http.StatusSeeOther,
|
||||||
|
"/tasks/"+pathEscape(taskID)+"?notice=authorization-created",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) CancelTask(ctx *gin.Context) {
|
func (h *Handler) CancelTask(ctx *gin.Context) {
|
||||||
if !validCSRF(ctx) {
|
if !validCSRF(ctx) {
|
||||||
h.renderError(
|
h.renderError(
|
||||||
@@ -652,6 +722,10 @@ func detailNotice(value string) string {
|
|||||||
return "已请求设备安全停止;设备确认前任务仍保持当前执行状态。"
|
return "已请求设备安全停止;设备确认前任务仍保持当前执行状态。"
|
||||||
case "cancel-conflict":
|
case "cancel-conflict":
|
||||||
return "任务状态已变化,当前不能取消。"
|
return "任务状态已变化,当前不能取消。"
|
||||||
|
case "authorization-created":
|
||||||
|
return "候选已确认,待投递下单授权已创建。"
|
||||||
|
case "authorization-conflict":
|
||||||
|
return "候选或任务状态已变化,请检查最新证据后重新授权。"
|
||||||
default:
|
default:
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -749,6 +823,7 @@ type taskDetailView struct {
|
|||||||
Quantity int64
|
Quantity int64
|
||||||
MaxBudget string
|
MaxBudget string
|
||||||
Status string
|
Status string
|
||||||
|
Version int64
|
||||||
StatusLabel string
|
StatusLabel string
|
||||||
StatusClass string
|
StatusClass string
|
||||||
ReferenceAssetID string
|
ReferenceAssetID string
|
||||||
@@ -757,6 +832,10 @@ type taskDetailView struct {
|
|||||||
CanCancel bool
|
CanCancel bool
|
||||||
CancelRequiresAck bool
|
CancelRequiresAck bool
|
||||||
ExecutionReport *ExecutionReport
|
ExecutionReport *ExecutionReport
|
||||||
|
Candidates []AuthorizationCandidate
|
||||||
|
OrderAuthorizations []OrderAuthorization
|
||||||
|
CanAuthorizeOrder bool
|
||||||
|
ActiveAuthorizationID string
|
||||||
}
|
}
|
||||||
|
|
||||||
type taskDetailPage struct {
|
type taskDetailPage struct {
|
||||||
@@ -764,6 +843,7 @@ type taskDetailPage struct {
|
|||||||
Task taskDetailView
|
Task taskDetailView
|
||||||
CSRFToken string
|
CSRFToken string
|
||||||
CancelKey string
|
CancelKey string
|
||||||
|
AuthorizationKey string
|
||||||
Notice string
|
Notice string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -774,6 +854,17 @@ type errorPage struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func taskDetailViewFrom(task Task) taskDetailView {
|
func taskDetailViewFrom(task Task) taskDetailView {
|
||||||
|
activeAuthorizationID := ""
|
||||||
|
canAuthorizeOrder := task.Status == "WAITING_CONFIRMATION" &&
|
||||||
|
len(task.Candidates) > 0
|
||||||
|
for _, authorization := range task.OrderAuthorizations {
|
||||||
|
switch authorization.Status {
|
||||||
|
case "PENDING_DELIVERY":
|
||||||
|
activeAuthorizationID = authorization.ID
|
||||||
|
case "DELIVERED", "ACKNOWLEDGED", "EXECUTING":
|
||||||
|
canAuthorizeOrder = false
|
||||||
|
}
|
||||||
|
}
|
||||||
return taskDetailView{
|
return taskDetailView{
|
||||||
ID: task.ID,
|
ID: task.ID,
|
||||||
Title: task.Title,
|
Title: task.Title,
|
||||||
@@ -782,6 +873,7 @@ func taskDetailViewFrom(task Task) taskDetailView {
|
|||||||
Quantity: task.Quantity,
|
Quantity: task.Quantity,
|
||||||
MaxBudget: task.MaxBudget,
|
MaxBudget: task.MaxBudget,
|
||||||
Status: task.Status,
|
Status: task.Status,
|
||||||
|
Version: task.Version,
|
||||||
StatusLabel: statusLabel(task.Status),
|
StatusLabel: statusLabel(task.Status),
|
||||||
StatusClass: statusClass(task.Status),
|
StatusClass: statusClass(task.Status),
|
||||||
ReferenceAssetID: task.ReferenceAssetID,
|
ReferenceAssetID: task.ReferenceAssetID,
|
||||||
@@ -791,6 +883,10 @@ func taskDetailViewFrom(task Task) taskDetailView {
|
|||||||
CancelRequiresAck: task.Status == "RUNNING" ||
|
CancelRequiresAck: task.Status == "RUNNING" ||
|
||||||
task.Status == "WAITING_CONFIRMATION",
|
task.Status == "WAITING_CONFIRMATION",
|
||||||
ExecutionReport: task.ExecutionReport,
|
ExecutionReport: task.ExecutionReport,
|
||||||
|
Candidates: task.Candidates,
|
||||||
|
OrderAuthorizations: task.OrderAuthorizations,
|
||||||
|
CanAuthorizeOrder: canAuthorizeOrder,
|
||||||
|
ActiveAuthorizationID: activeAuthorizationID,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -509,6 +509,124 @@ func TestTaskDetailDoesNotLeakForbiddenResource(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTaskDetailAuthorizesOneCandidateWithCSRFAndPRG(t *testing.T) {
|
||||||
|
firstKey := strings.Repeat("a", 64)
|
||||||
|
secondKey := strings.Repeat("b", 64)
|
||||||
|
service := &fakeService{
|
||||||
|
getResult: Task{
|
||||||
|
ID: testTaskID,
|
||||||
|
Title: "候选确认任务",
|
||||||
|
SKU: "BLACK-L",
|
||||||
|
Quantity: 2,
|
||||||
|
Status: "WAITING_CONFIRMATION",
|
||||||
|
Version: 7,
|
||||||
|
ReferenceAssetID: "00000000-0000-4000-8000-000000000009",
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
UpdatedAt: time.Now().UTC(),
|
||||||
|
TaskContentSHA256: strings.Repeat("c", 64),
|
||||||
|
Candidates: []AuthorizationCandidate{
|
||||||
|
{
|
||||||
|
CandidateKey: firstKey,
|
||||||
|
Ordinal: 1,
|
||||||
|
Title: "候选一",
|
||||||
|
SKUText: "BLACK-L",
|
||||||
|
PriceText: "20.00",
|
||||||
|
EvidenceURLs: []string{"/api/v1/assets/evidence-1/content"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
CandidateKey: secondKey,
|
||||||
|
Ordinal: 2,
|
||||||
|
Title: "候选二",
|
||||||
|
SKUText: "BLACK-XL",
|
||||||
|
PriceText: "22.00",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
router := newTestRouter(t, service)
|
||||||
|
detail := performRequest(
|
||||||
|
t,
|
||||||
|
router,
|
||||||
|
http.MethodGet,
|
||||||
|
"/tasks/"+testTaskID,
|
||||||
|
nil,
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
if detail.Code != http.StatusOK {
|
||||||
|
t.Fatalf("detail status/body = %d/%s", detail.Code, detail.Body)
|
||||||
|
}
|
||||||
|
for _, expected := range []string{
|
||||||
|
"候选确认与下单授权",
|
||||||
|
"系统只创建待付款订单",
|
||||||
|
"候选一",
|
||||||
|
"BLACK-XL",
|
||||||
|
`name="candidate_key"`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(detail.Body.String(), expected) {
|
||||||
|
t.Fatalf("authorization detail missing %q", expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cookie := csrfCookie(t, detail)
|
||||||
|
authorizationKey := hiddenValue(
|
||||||
|
t,
|
||||||
|
detail.Body.String(),
|
||||||
|
"authorization_key",
|
||||||
|
)
|
||||||
|
form := url.Values{
|
||||||
|
"csrf_token": {cookie.Value},
|
||||||
|
"authorization_key": {authorizationKey},
|
||||||
|
"expected_task_version": {"7"},
|
||||||
|
"candidate_key": {firstKey},
|
||||||
|
"selected_reason_code": {"SKU_MATCH"},
|
||||||
|
"rejected_reason_code": {"NOT_BEST_MATCH"},
|
||||||
|
"authorization_note": {"已核对图片、规格与价格"},
|
||||||
|
"supersedes_authorization_id": {""},
|
||||||
|
}
|
||||||
|
request := httptest.NewRequest(
|
||||||
|
http.MethodPost,
|
||||||
|
"/tasks/"+testTaskID+"/order-authorizations",
|
||||||
|
strings.NewReader(form.Encode()),
|
||||||
|
)
|
||||||
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
request.AddCookie(cookie)
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(response, request)
|
||||||
|
|
||||||
|
if response.Code != http.StatusSeeOther ||
|
||||||
|
response.Header().Get("Location") !=
|
||||||
|
"/tasks/"+testTaskID+"?notice=authorization-created" {
|
||||||
|
t.Fatalf(
|
||||||
|
"status/location = %d/%q",
|
||||||
|
response.Code,
|
||||||
|
response.Header().Get("Location"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if service.authorizeInput.TaskID != testTaskID ||
|
||||||
|
service.authorizeInput.IdempotencyKey != authorizationKey ||
|
||||||
|
service.authorizeInput.ExpectedTaskVersion != 7 ||
|
||||||
|
service.authorizeInput.CandidateKey != firstKey ||
|
||||||
|
service.authorizeInput.SelectedReasonCode != "SKU_MATCH" ||
|
||||||
|
service.authorizeInput.RejectedReasonCode != "NOT_BEST_MATCH" {
|
||||||
|
t.Fatalf("authorize input = %+v", service.authorizeInput)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskDetailDisablesAuthorizationAfterDelivery(t *testing.T) {
|
||||||
|
view := taskDetailViewFrom(Task{
|
||||||
|
Status: "WAITING_CONFIRMATION",
|
||||||
|
Candidates: []AuthorizationCandidate{{
|
||||||
|
CandidateKey: strings.Repeat("a", 64),
|
||||||
|
}},
|
||||||
|
OrderAuthorizations: []OrderAuthorization{{
|
||||||
|
ID: "00000000-0000-4000-8000-000000000010",
|
||||||
|
Status: "DELIVERED",
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
if view.CanAuthorizeOrder {
|
||||||
|
t.Fatal("delivered authorization remains editable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestTaskDetailCancelModeFollowsLifecycleStatus(t *testing.T) {
|
func TestTaskDetailCancelModeFollowsLifecycleStatus(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
status string
|
status string
|
||||||
@@ -631,6 +749,9 @@ type fakeService struct {
|
|||||||
cancelResult Task
|
cancelResult Task
|
||||||
cancelErr error
|
cancelErr error
|
||||||
cancelInput CancelTaskInput
|
cancelInput CancelTaskInput
|
||||||
|
authorizeResult OrderAuthorization
|
||||||
|
authorizeErr error
|
||||||
|
authorizeInput AuthorizeOrderInput
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service *fakeService) ListTasks(
|
func (service *fakeService) ListTasks(
|
||||||
@@ -679,6 +800,14 @@ func (service *fakeService) CancelTask(
|
|||||||
return service.cancelResult, service.cancelErr
|
return service.cancelResult, service.cancelErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (service *fakeService) AuthorizeOrder(
|
||||||
|
_ context.Context,
|
||||||
|
input AuthorizeOrderInput,
|
||||||
|
) (OrderAuthorization, error) {
|
||||||
|
service.authorizeInput = input
|
||||||
|
return service.authorizeResult, service.authorizeErr
|
||||||
|
}
|
||||||
|
|
||||||
func newTestRouter(t *testing.T, service Service) http.Handler {
|
func newTestRouter(t *testing.T, service Service) http.Handler {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
|
|||||||
@@ -101,6 +101,157 @@ textarea {
|
|||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.authorization-section {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-heading-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-heading-row h2 {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.candidate-count {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authorization-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.candidate-fieldset {
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.candidate-fieldset legend {
|
||||||
|
margin-bottom: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authorization-candidates {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authorization-candidate {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--surface-soft);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authorization-candidate:has(input:checked) {
|
||||||
|
border-color: var(--brand);
|
||||||
|
box-shadow: 0 0 0 2px rgba(11, 107, 80, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.candidate-choice {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--brand-dark);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.candidate-choice input,
|
||||||
|
.authorization-confirm input {
|
||||||
|
width: 20px;
|
||||||
|
min-height: 20px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.candidate-key {
|
||||||
|
display: block;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.candidate-evidence {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.candidate-evidence img {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
|
object-fit: contain;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.authorization-reasons {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authorization-reasons label {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authorization-note {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authorization-confirm {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 12px;
|
||||||
|
border-left: 4px solid var(--warn);
|
||||||
|
background: var(--warn-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.authorization-form > .button {
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authorization-unavailable {
|
||||||
|
padding: 12px;
|
||||||
|
border-left: 4px solid var(--warn);
|
||||||
|
background: var(--warn-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.authorization-history {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
padding-left: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authorization-history li {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.authorization-history span,
|
||||||
|
.authorization-history time {
|
||||||
|
display: block;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
button,
|
button,
|
||||||
input,
|
input,
|
||||||
select {
|
select {
|
||||||
@@ -901,10 +1052,15 @@ tbody tr:last-child td {
|
|||||||
|
|
||||||
.upload-layout,
|
.upload-layout,
|
||||||
.detail-layout,
|
.detail-layout,
|
||||||
.requirement-layout {
|
.requirement-layout,
|
||||||
|
.authorization-reasons {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.authorization-note {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.image-preview {
|
.image-preview {
|
||||||
max-width: 320px;
|
max-width: 320px;
|
||||||
}
|
}
|
||||||
@@ -978,6 +1134,19 @@ tbody tr:last-child td {
|
|||||||
padding: 18px 14px;
|
padding: 18px 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.section-heading-row {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authorization-candidates {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authorization-form > .button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.form-actions,
|
.form-actions,
|
||||||
.dialog-actions {
|
.dialog-actions {
|
||||||
flex-direction: column-reverse;
|
flex-direction: column-reverse;
|
||||||
|
|||||||
@@ -73,6 +73,108 @@
|
|||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{if .Task.Candidates}}
|
||||||
|
<section class="content-section authorization-section" aria-labelledby="authorization-heading">
|
||||||
|
<div class="section-heading-row">
|
||||||
|
<div>
|
||||||
|
<h2 id="authorization-heading">候选确认与下单授权</h2>
|
||||||
|
<p class="section-note">选择只会授权设备创建一笔待付款订单,不授权付款。</p>
|
||||||
|
</div>
|
||||||
|
<span class="candidate-count">{{len .Task.Candidates}} 个候选</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .Task.CanAuthorizeOrder}}
|
||||||
|
<form class="authorization-form" method="post"
|
||||||
|
action="/tasks/{{pathPart .Task.ID}}/order-authorizations">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
|
<input type="hidden" name="authorization_key" value="{{.AuthorizationKey}}">
|
||||||
|
<input type="hidden" name="expected_task_version" value="{{.Task.Version}}">
|
||||||
|
<input type="hidden" name="supersedes_authorization_id"
|
||||||
|
value="{{.Task.ActiveAuthorizationID}}">
|
||||||
|
|
||||||
|
<fieldset class="candidate-fieldset">
|
||||||
|
<legend>选择要采购的商品</legend>
|
||||||
|
<div class="authorization-candidates">
|
||||||
|
{{range .Task.Candidates}}
|
||||||
|
<label class="authorization-candidate">
|
||||||
|
<span class="candidate-choice">
|
||||||
|
<input type="radio" name="candidate_key" value="{{.CandidateKey}}" required>
|
||||||
|
<span>候选 {{.Ordinal}}</span>
|
||||||
|
</span>
|
||||||
|
<strong>{{.Title}}</strong>
|
||||||
|
<span>规格:{{if .SKUText}}{{.SKUText}}{{else}}未读取{{end}}</span>
|
||||||
|
<span>组合价格:{{if .PriceText}}{{.PriceText}}{{else}}未读取{{end}}</span>
|
||||||
|
<code class="candidate-key">{{.CandidateKey}}</code>
|
||||||
|
{{if .EvidenceURLs}}
|
||||||
|
<span class="candidate-evidence">
|
||||||
|
{{range .EvidenceURLs}}<img src="{{.}}" alt="候选受控证据截图">{{end}}
|
||||||
|
</span>
|
||||||
|
{{end}}
|
||||||
|
</label>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="authorization-reasons">
|
||||||
|
<label>
|
||||||
|
<span>所选候选理由</span>
|
||||||
|
<select name="selected_reason_code" required>
|
||||||
|
<option value="">请选择</option>
|
||||||
|
<option value="SKU_MATCH">SKU 匹配</option>
|
||||||
|
<option value="IMAGE_MATCH">图片匹配</option>
|
||||||
|
{{if .Task.MaxBudget}}<option value="PRICE_ACCEPTABLE">价格可接受</option>{{end}}
|
||||||
|
<option value="EVIDENCE_SUFFICIENT">证据充分</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>其余候选拒绝理由</span>
|
||||||
|
<select name="rejected_reason_code" required>
|
||||||
|
<option value="">请选择</option>
|
||||||
|
<option value="NOT_BEST_MATCH">不是最佳匹配</option>
|
||||||
|
<option value="SKU_MISMATCH">SKU 不匹配</option>
|
||||||
|
<option value="IMAGE_MISMATCH">图片不匹配</option>
|
||||||
|
{{if .Task.MaxBudget}}<option value="PRICE_TOO_HIGH">价格过高</option>{{end}}
|
||||||
|
<option value="OUT_OF_STOCK">无库存</option>
|
||||||
|
<option value="EVIDENCE_INSUFFICIENT">证据不足</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="authorization-note">
|
||||||
|
<span>授权备注(可选)</span>
|
||||||
|
<textarea name="authorization_note" rows="3" maxlength="200"></textarea>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="authorization-confirm">
|
||||||
|
<input type="checkbox" required>
|
||||||
|
<span>我确认商品、SKU、数量和证据;系统只创建待付款订单,付款由人员在拼多多完成。</span>
|
||||||
|
</label>
|
||||||
|
<button class="button primary" type="submit">
|
||||||
|
{{if .Task.ActiveAuthorizationID}}改选并创建新授权{{else}}确认商品并授权下单{{end}}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{{else}}
|
||||||
|
<p class="authorization-unavailable" role="status">
|
||||||
|
当前候选不能创建或修改授权,请查看下方授权状态或刷新任务。
|
||||||
|
</p>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Task.OrderAuthorizations}}
|
||||||
|
<h3>授权历史</h3>
|
||||||
|
<ol class="authorization-history">
|
||||||
|
{{range .Task.OrderAuthorizations}}
|
||||||
|
<li>
|
||||||
|
<strong>版本 {{.Version}} · {{.Status}}</strong>
|
||||||
|
<span>候选 {{.CandidateKey}}</span>
|
||||||
|
<span>规格 {{if .CandidateSKUText}}{{.CandidateSKUText}}{{else}}未读取{{end}} ·
|
||||||
|
数量 {{.Quantity}} · 价格 {{if .CandidatePriceText}}{{.CandidatePriceText}}{{else}}未读取{{end}}</span>
|
||||||
|
<time datetime="{{machineTime .CreatedAt}}">{{displayTime .CreatedAt}}</time>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ol>
|
||||||
|
{{end}}
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{with .Task.ExecutionReport}}
|
{{with .Task.ExecutionReport}}
|
||||||
<section class="content-section execution-audit" aria-labelledby="execution-audit-heading">
|
<section class="content-section execution-audit" aria-labelledby="execution-audit-heading">
|
||||||
<h2 id="execution-audit-heading">执行审计</h2>
|
<h2 id="execution-audit-heading">执行审计</h2>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ type Service interface {
|
|||||||
UploadReference(context.Context, UploadReferenceInput) (UploadedAsset, error)
|
UploadReference(context.Context, UploadReferenceInput) (UploadedAsset, error)
|
||||||
CreateTask(context.Context, CreateTaskInput) (Task, error)
|
CreateTask(context.Context, CreateTaskInput) (Task, error)
|
||||||
CancelTask(context.Context, CancelTaskInput) (Task, error)
|
CancelTask(context.Context, CancelTaskInput) (Task, error)
|
||||||
|
AuthorizeOrder(context.Context, AuthorizeOrderInput) (OrderAuthorization, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type ListTasksInput struct {
|
type ListTasksInput struct {
|
||||||
@@ -55,10 +56,36 @@ type Task struct {
|
|||||||
Quantity int64
|
Quantity int64
|
||||||
MaxBudget string
|
MaxBudget string
|
||||||
Status string
|
Status string
|
||||||
|
Version int64
|
||||||
|
ExecutionID string
|
||||||
|
TaskContentSHA256 string
|
||||||
ReferenceAssetID string
|
ReferenceAssetID string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
ExecutionReport *ExecutionReport
|
ExecutionReport *ExecutionReport
|
||||||
|
Candidates []AuthorizationCandidate
|
||||||
|
OrderAuthorizations []OrderAuthorization
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthorizationCandidate struct {
|
||||||
|
CandidateKey string
|
||||||
|
Ordinal int
|
||||||
|
Title string
|
||||||
|
SKUText string
|
||||||
|
PriceText string
|
||||||
|
EvidenceURLs []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type OrderAuthorization struct {
|
||||||
|
ID string
|
||||||
|
Version int
|
||||||
|
CandidateKey string
|
||||||
|
CandidateSKUText string
|
||||||
|
CandidatePriceText string
|
||||||
|
Quantity int
|
||||||
|
Status string
|
||||||
|
SupersedesID string
|
||||||
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExecutionReport struct {
|
type ExecutionReport struct {
|
||||||
@@ -130,3 +157,14 @@ type CancelTaskInput struct {
|
|||||||
TaskID string
|
TaskID string
|
||||||
IdempotencyKey string
|
IdempotencyKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AuthorizeOrderInput struct {
|
||||||
|
TaskID string
|
||||||
|
IdempotencyKey string
|
||||||
|
ExpectedTaskVersion int64
|
||||||
|
CandidateKey string
|
||||||
|
SelectedReasonCode string
|
||||||
|
RejectedReasonCode string
|
||||||
|
Note string
|
||||||
|
SupersedesAuthorizationID string
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,18 +16,21 @@ const localAdminSubject = "local-admin"
|
|||||||
type UsecaseAdapter struct {
|
type UsecaseAdapter struct {
|
||||||
tasks *usecase.TaskService
|
tasks *usecase.TaskService
|
||||||
assets *usecase.AssetService
|
assets *usecase.AssetService
|
||||||
|
authorizations *usecase.OrderAuthorizationService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewUsecaseAdapter(
|
func NewUsecaseAdapter(
|
||||||
tasks *usecase.TaskService,
|
tasks *usecase.TaskService,
|
||||||
assets *usecase.AssetService,
|
assets *usecase.AssetService,
|
||||||
|
authorizations *usecase.OrderAuthorizationService,
|
||||||
) (*UsecaseAdapter, error) {
|
) (*UsecaseAdapter, error) {
|
||||||
if tasks == nil || assets == nil {
|
if tasks == nil || assets == nil || authorizations == nil {
|
||||||
return nil, errors.New("admin web use cases are required")
|
return nil, errors.New("admin web use cases are required")
|
||||||
}
|
}
|
||||||
return &UsecaseAdapter{
|
return &UsecaseAdapter{
|
||||||
tasks: tasks,
|
tasks: tasks,
|
||||||
assets: assets,
|
assets: assets,
|
||||||
|
authorizations: authorizations,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,6 +149,70 @@ func (adapter *UsecaseAdapter) CancelTask(
|
|||||||
return taskFromPurchase(task), nil
|
return taskFromPurchase(task), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (adapter *UsecaseAdapter) AuthorizeOrder(
|
||||||
|
ctx context.Context,
|
||||||
|
input AuthorizeOrderInput,
|
||||||
|
) (OrderAuthorization, error) {
|
||||||
|
detail, err := adapter.tasks.Get(ctx, localAdminSubject, input.TaskID)
|
||||||
|
if err != nil {
|
||||||
|
return OrderAuthorization{}, mapUsecaseError(err)
|
||||||
|
}
|
||||||
|
if detail.Task.Version != input.ExpectedTaskVersion ||
|
||||||
|
detail.Execution == nil ||
|
||||||
|
detail.Report == nil ||
|
||||||
|
detail.Report.DecisionDataset == nil {
|
||||||
|
return OrderAuthorization{}, ErrConflict
|
||||||
|
}
|
||||||
|
var supersedes *string
|
||||||
|
if input.SupersedesAuthorizationID != "" {
|
||||||
|
value := input.SupersedesAuthorizationID
|
||||||
|
supersedes = &value
|
||||||
|
}
|
||||||
|
items := make(
|
||||||
|
[]usecase.OrderAuthorizationItemInput,
|
||||||
|
0,
|
||||||
|
len(detail.Report.DecisionDataset.Observations),
|
||||||
|
)
|
||||||
|
for _, observation := range detail.Report.DecisionDataset.Observations {
|
||||||
|
if observation.Identity == nil {
|
||||||
|
return OrderAuthorization{}, ErrConflict
|
||||||
|
}
|
||||||
|
label := "REJECT"
|
||||||
|
reason := input.RejectedReasonCode
|
||||||
|
if observation.Identity.CandidateKey == input.CandidateKey {
|
||||||
|
label = "ACCEPT"
|
||||||
|
reason = input.SelectedReasonCode
|
||||||
|
}
|
||||||
|
items = append(items, usecase.OrderAuthorizationItemInput{
|
||||||
|
CandidateKey: observation.Identity.CandidateKey,
|
||||||
|
Label: label,
|
||||||
|
PrimaryReasonCode: reason,
|
||||||
|
ReasonCodes: []string{reason},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
result, err := adapter.authorizations.Create(
|
||||||
|
ctx,
|
||||||
|
usecase.CreateOrderAuthorizationCommand{
|
||||||
|
ActorUserID: actorUserID(ctx),
|
||||||
|
TaskID: input.TaskID,
|
||||||
|
IdempotencyKey: input.IdempotencyKey,
|
||||||
|
ExecutionID: detail.Execution.ID,
|
||||||
|
TaskContentSHA256: usecase.TaskContentSHA256(detail.Task),
|
||||||
|
ExpectedTaskVersion: input.ExpectedTaskVersion,
|
||||||
|
CandidateKey: input.CandidateKey,
|
||||||
|
ReasonSchemaVersion: 1,
|
||||||
|
PrimaryReasonCode: "SELECTED_BEST_MATCH",
|
||||||
|
Note: input.Note,
|
||||||
|
SupersedesAuthorizationID: supersedes,
|
||||||
|
Items: items,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return OrderAuthorization{}, mapUsecaseError(err)
|
||||||
|
}
|
||||||
|
return orderAuthorizationFrom(result.Authorization), nil
|
||||||
|
}
|
||||||
|
|
||||||
func taskSummaryFrom(task domain.PurchaseTask) TaskSummary {
|
func taskSummaryFrom(task domain.PurchaseTask) TaskSummary {
|
||||||
return TaskSummary{
|
return TaskSummary{
|
||||||
ID: task.ID,
|
ID: task.ID,
|
||||||
@@ -177,11 +244,64 @@ func taskFromPurchase(task domain.PurchaseTask) Task {
|
|||||||
|
|
||||||
func taskFromDetail(detail domain.TaskDetail) Task {
|
func taskFromDetail(detail domain.TaskDetail) Task {
|
||||||
task := taskFromPurchase(detail.Task)
|
task := taskFromPurchase(detail.Task)
|
||||||
|
task.Version = detail.Task.Version
|
||||||
task.ReferenceAssetID = detail.Asset.ID
|
task.ReferenceAssetID = detail.Asset.ID
|
||||||
task.ExecutionReport = executionReportFrom(detail.Report)
|
task.ExecutionReport = executionReportFrom(detail.Report)
|
||||||
|
task.TaskContentSHA256 = usecase.TaskContentSHA256(detail.Task)
|
||||||
|
if detail.Execution != nil {
|
||||||
|
task.ExecutionID = detail.Execution.ID
|
||||||
|
}
|
||||||
|
if detail.Report != nil && detail.Report.DecisionDataset != nil {
|
||||||
|
for _, observation := range detail.Report.DecisionDataset.Observations {
|
||||||
|
if observation.Identity == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
candidate := AuthorizationCandidate{
|
||||||
|
CandidateKey: observation.Identity.CandidateKey,
|
||||||
|
Ordinal: observation.Ordinal,
|
||||||
|
Title: observation.Title,
|
||||||
|
SKUText: observation.SKUText,
|
||||||
|
PriceText: observation.PriceText,
|
||||||
|
EvidenceURLs: make([]string, 0, len(observation.EvidenceAssetIDs)),
|
||||||
|
}
|
||||||
|
for _, evidenceID := range observation.EvidenceAssetIDs {
|
||||||
|
candidate.EvidenceURLs = append(
|
||||||
|
candidate.EvidenceURLs,
|
||||||
|
"/api/v1/tasks/"+detail.Task.ID+
|
||||||
|
"/evidence/"+evidenceID+"/content",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
task.Candidates = append(task.Candidates, candidate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, authorization := range detail.OrderAuthorizations {
|
||||||
|
task.OrderAuthorizations = append(
|
||||||
|
task.OrderAuthorizations,
|
||||||
|
orderAuthorizationFrom(authorization),
|
||||||
|
)
|
||||||
|
}
|
||||||
return task
|
return task
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func orderAuthorizationFrom(
|
||||||
|
authorization domain.OrderAuthorization,
|
||||||
|
) OrderAuthorization {
|
||||||
|
result := OrderAuthorization{
|
||||||
|
ID: authorization.ID,
|
||||||
|
Version: authorization.AuthorizationVersion,
|
||||||
|
CandidateKey: authorization.CandidateKey,
|
||||||
|
CandidateSKUText: authorization.CandidateSKUText,
|
||||||
|
CandidatePriceText: authorization.CandidatePriceText,
|
||||||
|
Quantity: authorization.Quantity,
|
||||||
|
Status: string(authorization.Status),
|
||||||
|
CreatedAt: authorization.CreatedAt,
|
||||||
|
}
|
||||||
|
if authorization.SupersedesAuthorizationID != nil {
|
||||||
|
result.SupersedesID = *authorization.SupersedesAuthorizationID
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
func executionReportFrom(report *domain.ExecutionReport) *ExecutionReport {
|
func executionReportFrom(report *domain.ExecutionReport) *ExecutionReport {
|
||||||
if report == nil {
|
if report == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -285,6 +285,24 @@ func (service *ExecutionResultService) StoreCandidates(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return false, internalExecutionResultFailure(err)
|
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(
|
replayed, err := service.repository.StoreExecutionCandidates(
|
||||||
ctx,
|
ctx,
|
||||||
service.write(identity, executionResultCandidatesOperation, requestHash, now),
|
service.write(identity, executionResultCandidatesOperation, requestHash, now),
|
||||||
@@ -297,6 +315,7 @@ func (service *ExecutionResultService) StoreCandidates(
|
|||||||
ProvenanceJSON: provenanceJSON,
|
ProvenanceJSON: provenanceJSON,
|
||||||
CandidatesJSON: candidatesJSON,
|
CandidatesJSON: candidatesJSON,
|
||||||
RecommendationJSON: recommendationJSON,
|
RecommendationJSON: recommendationJSON,
|
||||||
|
ReadyEvent: readyEvent,
|
||||||
ReceivedAt: now,
|
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"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
-- +goose Up
|
||||||
|
ALTER TABLE task_events RENAME TO task_events_v7;
|
||||||
|
|
||||||
|
CREATE TABLE task_events (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL
|
||||||
|
CHECK (length(id) = 36),
|
||||||
|
task_id TEXT NOT NULL
|
||||||
|
REFERENCES purchase_tasks(id) ON UPDATE RESTRICT ON DELETE CASCADE,
|
||||||
|
event_type TEXT NOT NULL
|
||||||
|
CHECK (
|
||||||
|
event_type IN (
|
||||||
|
'TASK_CREATED',
|
||||||
|
'TASK_CLAIMED',
|
||||||
|
'TASK_RECLAIMED',
|
||||||
|
'TASK_RELEASED',
|
||||||
|
'TASK_STARTED',
|
||||||
|
'TASK_CANCEL_REQUESTED',
|
||||||
|
'TASK_CANCELED',
|
||||||
|
'CANDIDATES_READY',
|
||||||
|
'ORDER_AUTHORIZATION_CREATED'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
occurred_at TEXT NOT NULL,
|
||||||
|
actor_user_id TEXT
|
||||||
|
REFERENCES users(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
|
||||||
|
actor_device_id TEXT
|
||||||
|
REFERENCES devices(id) ON UPDATE RESTRICT ON DELETE RESTRICT
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO task_events (
|
||||||
|
id, task_id, event_type, message, occurred_at,
|
||||||
|
actor_user_id, actor_device_id
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
id, task_id, event_type, message, occurred_at,
|
||||||
|
actor_user_id, actor_device_id
|
||||||
|
FROM task_events_v7;
|
||||||
|
|
||||||
|
DROP TABLE task_events_v7;
|
||||||
|
|
||||||
|
CREATE INDEX task_events_task_occurred_idx
|
||||||
|
ON task_events (task_id, occurred_at ASC, id ASC);
|
||||||
|
|
||||||
|
CREATE INDEX task_events_actor_user_idx
|
||||||
|
ON task_events (actor_user_id, occurred_at DESC, id DESC);
|
||||||
|
|
||||||
|
CREATE INDEX task_events_actor_device_idx
|
||||||
|
ON task_events (actor_device_id, occurred_at DESC, id DESC);
|
||||||
|
|
||||||
|
CREATE TABLE order_authorizations (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL
|
||||||
|
CHECK (length(id) = 36),
|
||||||
|
task_id TEXT NOT NULL
|
||||||
|
REFERENCES purchase_tasks(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
|
||||||
|
execution_id TEXT NOT NULL
|
||||||
|
REFERENCES task_executions(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
|
||||||
|
authorization_version INTEGER NOT NULL
|
||||||
|
CHECK (authorization_version > 0),
|
||||||
|
candidate_key TEXT NOT NULL
|
||||||
|
REFERENCES candidate_observation_identities(candidate_key)
|
||||||
|
ON UPDATE RESTRICT ON DELETE RESTRICT,
|
||||||
|
task_content_sha256 TEXT NOT NULL
|
||||||
|
CHECK (
|
||||||
|
length(task_content_sha256) = 64
|
||||||
|
AND task_content_sha256 NOT GLOB '*[^0-9a-f]*'
|
||||||
|
),
|
||||||
|
task_version INTEGER NOT NULL
|
||||||
|
CHECK (task_version > 0),
|
||||||
|
review_id TEXT NOT NULL
|
||||||
|
REFERENCES candidate_human_reviews(id)
|
||||||
|
ON UPDATE RESTRICT ON DELETE RESTRICT,
|
||||||
|
review_version INTEGER NOT NULL
|
||||||
|
CHECK (review_version > 0),
|
||||||
|
user_id TEXT NOT NULL
|
||||||
|
REFERENCES users(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
|
||||||
|
device_id TEXT NOT NULL
|
||||||
|
REFERENCES devices(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
|
||||||
|
claim_generation INTEGER NOT NULL
|
||||||
|
CHECK (claim_generation > 0),
|
||||||
|
original_sku TEXT NOT NULL
|
||||||
|
CHECK (
|
||||||
|
length(trim(original_sku)) > 0
|
||||||
|
AND length(CAST(original_sku AS BLOB)) <= 512
|
||||||
|
),
|
||||||
|
quantity INTEGER NOT NULL
|
||||||
|
CHECK (quantity > 0),
|
||||||
|
candidate_sku_text TEXT NOT NULL
|
||||||
|
CHECK (length(CAST(candidate_sku_text AS BLOB)) <= 512),
|
||||||
|
candidate_price_text TEXT NOT NULL
|
||||||
|
CHECK (length(CAST(candidate_price_text AS BLOB)) <= 64),
|
||||||
|
card_signature TEXT NOT NULL
|
||||||
|
CHECK (
|
||||||
|
length(card_signature) = 64
|
||||||
|
AND card_signature NOT GLOB '*[^0-9a-f]*'
|
||||||
|
),
|
||||||
|
detail_signature TEXT NOT NULL
|
||||||
|
CHECK (
|
||||||
|
length(detail_signature) = 64
|
||||||
|
AND detail_signature NOT GLOB '*[^0-9a-f]*'
|
||||||
|
),
|
||||||
|
detail_evidence_sha256 TEXT NOT NULL
|
||||||
|
CHECK (
|
||||||
|
length(detail_evidence_sha256) = 64
|
||||||
|
AND detail_evidence_sha256 NOT GLOB '*[^0-9a-f]*'
|
||||||
|
),
|
||||||
|
specification_evidence_sha256 TEXT NOT NULL
|
||||||
|
CHECK (
|
||||||
|
length(specification_evidence_sha256) = 64
|
||||||
|
AND specification_evidence_sha256 NOT GLOB '*[^0-9a-f]*'
|
||||||
|
),
|
||||||
|
status TEXT NOT NULL
|
||||||
|
CHECK (
|
||||||
|
status IN (
|
||||||
|
'PENDING_DELIVERY',
|
||||||
|
'DELIVERED',
|
||||||
|
'ACKNOWLEDGED',
|
||||||
|
'EXECUTING',
|
||||||
|
'CONSUMED',
|
||||||
|
'FAILED',
|
||||||
|
'REVOKED',
|
||||||
|
'SUPERSEDED'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
supersedes_authorization_id TEXT
|
||||||
|
REFERENCES order_authorizations(id)
|
||||||
|
ON UPDATE RESTRICT ON DELETE RESTRICT,
|
||||||
|
created_by_user_id TEXT NOT NULL
|
||||||
|
REFERENCES users(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
delivered_at TEXT,
|
||||||
|
acknowledged_at TEXT,
|
||||||
|
execution_started_at TEXT,
|
||||||
|
consumed_at TEXT,
|
||||||
|
failed_at TEXT,
|
||||||
|
revoked_at TEXT,
|
||||||
|
failure_code TEXT,
|
||||||
|
failure_message TEXT,
|
||||||
|
UNIQUE (execution_id, authorization_version)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX order_authorizations_active_execution_idx
|
||||||
|
ON order_authorizations (execution_id)
|
||||||
|
WHERE status IN (
|
||||||
|
'PENDING_DELIVERY',
|
||||||
|
'DELIVERED',
|
||||||
|
'ACKNOWLEDGED',
|
||||||
|
'EXECUTING'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX order_authorizations_task_created_idx
|
||||||
|
ON order_authorizations (task_id, created_at DESC, id DESC);
|
||||||
|
|
||||||
|
CREATE TABLE admin_order_authorization_requests (
|
||||||
|
actor_user_id TEXT NOT NULL
|
||||||
|
REFERENCES users(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
|
||||||
|
idempotency_key TEXT NOT NULL
|
||||||
|
CHECK (
|
||||||
|
length(trim(idempotency_key)) > 0
|
||||||
|
AND length(CAST(idempotency_key AS BLOB)) <= 128
|
||||||
|
),
|
||||||
|
request_sha256 TEXT NOT NULL
|
||||||
|
CHECK (
|
||||||
|
length(request_sha256) = 64
|
||||||
|
AND request_sha256 NOT GLOB '*[^0-9a-f]*'
|
||||||
|
),
|
||||||
|
task_id TEXT NOT NULL
|
||||||
|
REFERENCES purchase_tasks(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
|
||||||
|
authorization_id TEXT NOT NULL
|
||||||
|
REFERENCES order_authorizations(id)
|
||||||
|
ON UPDATE RESTRICT ON DELETE RESTRICT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (actor_user_id, idempotency_key)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
CREATE TEMP TABLE order_authorizations_v8_down_guard (
|
||||||
|
allowed INTEGER NOT NULL
|
||||||
|
CHECK (allowed = 1)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO order_authorizations_v8_down_guard (allowed)
|
||||||
|
SELECT CASE
|
||||||
|
WHEN EXISTS (SELECT 1 FROM order_authorizations)
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM task_events
|
||||||
|
WHERE event_type IN (
|
||||||
|
'CANDIDATES_READY',
|
||||||
|
'ORDER_AUTHORIZATION_CREATED'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
THEN 0
|
||||||
|
ELSE 1
|
||||||
|
END;
|
||||||
|
|
||||||
|
DROP TABLE order_authorizations_v8_down_guard;
|
||||||
|
DROP TABLE admin_order_authorization_requests;
|
||||||
|
DROP TABLE order_authorizations;
|
||||||
|
|
||||||
|
ALTER TABLE task_events RENAME TO task_events_v8;
|
||||||
|
|
||||||
|
CREATE TABLE task_events (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL
|
||||||
|
CHECK (length(id) = 36),
|
||||||
|
task_id TEXT NOT NULL
|
||||||
|
REFERENCES purchase_tasks(id) ON UPDATE RESTRICT ON DELETE CASCADE,
|
||||||
|
event_type TEXT NOT NULL
|
||||||
|
CHECK (
|
||||||
|
event_type IN (
|
||||||
|
'TASK_CREATED',
|
||||||
|
'TASK_CLAIMED',
|
||||||
|
'TASK_RECLAIMED',
|
||||||
|
'TASK_RELEASED',
|
||||||
|
'TASK_STARTED',
|
||||||
|
'TASK_CANCEL_REQUESTED',
|
||||||
|
'TASK_CANCELED'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
occurred_at TEXT NOT NULL,
|
||||||
|
actor_user_id TEXT
|
||||||
|
REFERENCES users(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
|
||||||
|
actor_device_id TEXT
|
||||||
|
REFERENCES devices(id) ON UPDATE RESTRICT ON DELETE RESTRICT
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO task_events (
|
||||||
|
id, task_id, event_type, message, occurred_at,
|
||||||
|
actor_user_id, actor_device_id
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
id, task_id, event_type, message, occurred_at,
|
||||||
|
actor_user_id, actor_device_id
|
||||||
|
FROM task_events_v8;
|
||||||
|
|
||||||
|
DROP TABLE task_events_v8;
|
||||||
|
|
||||||
|
CREATE INDEX task_events_task_occurred_idx
|
||||||
|
ON task_events (task_id, occurred_at ASC, id ASC);
|
||||||
|
|
||||||
|
CREATE INDEX task_events_actor_user_idx
|
||||||
|
ON task_events (actor_user_id, occurred_at DESC, id DESC);
|
||||||
|
|
||||||
|
CREATE INDEX task_events_actor_device_idx
|
||||||
|
ON task_events (actor_device_id, occurred_at DESC, id DESC);
|
||||||
@@ -57,8 +57,9 @@
|
|||||||
T-205 原子领取/租约状态机、T-206 Android 登录/有限离线、T-207 本地 VLM/候选/
|
T-205 原子领取/租约状态机、T-206 Android 登录/有限离线、T-207 本地 VLM/候选/
|
||||||
证据回传、T-211 参考图召回和 SKU 硬匹配、T-212 候选身份映射,以及 T-213 受控
|
证据回传、T-211 参考图召回和 SKU 硬匹配、T-212 候选身份映射,以及 T-213 受控
|
||||||
规格组合/价格核验均已完成。T-208 的原始候选观测、模型评估、确定性推荐、逐候选
|
规格组合/价格核验均已完成。T-208 的原始候选观测、模型评估、确定性推荐、逐候选
|
||||||
结构化人工理由和修订历史也已完成。T-214 商品持久身份和重新定位指纹也已完成;
|
结构化人工理由和修订历史也已完成。T-214 商品持久身份和重新定位指纹、T-215
|
||||||
当前正在实现 T-215 Admin 下单授权,不得直接把候选链接或列表 ordinal 当成授权。
|
Admin 候选确认与不可变待投递授权也已完成;下一步实现 T-216 设备命令投递与确认。
|
||||||
|
不得直接把候选链接或列表 ordinal 当成授权。
|
||||||
手机从管理后端领取任务并回传结果,VLM、拼多多自动化和人工确认在 App 本地完成。
|
手机从管理后端领取任务并回传结果,VLM、拼多多自动化和人工确认在 App 本地完成。
|
||||||
T-206 增加有限离线执行;T-207 已复用 Roubao 端上 OpenAI 兼容适配器并加密本地 Key。
|
T-206 增加有限离线执行;T-207 已复用 Roubao 端上 OpenAI 兼容适配器并加密本地 Key。
|
||||||
管理后端不保存/代理 VLM,后台任务不能覆盖手机 provider 配置。
|
管理后端不保存/代理 VLM,后台任务不能覆盖手机 provider 配置。
|
||||||
|
|||||||
+12
-8
@@ -5,7 +5,7 @@
|
|||||||
## 当前快照
|
## 当前快照
|
||||||
|
|
||||||
- 日期:2026-07-28
|
- 日期:2026-07-28
|
||||||
- 阶段:T-215 Admin 候选确认与一次性下单授权进行中
|
- 阶段:T-215 Admin 候选确认与一次性下单授权已完成,准备 T-216
|
||||||
- Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-207、T-209、
|
- Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-207、T-209、
|
||||||
T-208、T-210、T-211、T-212、T-213、T-214 均已纳入 Git 历史
|
T-208、T-210、T-211、T-212、T-213、T-214 均已纳入 Git 历史
|
||||||
- 生产代码:`android-buyer/` 已接入 Roubao Android 源码
|
- 生产代码:`android-buyer/` 已接入 Roubao Android 源码
|
||||||
@@ -17,10 +17,10 @@
|
|||||||
- 本机 Android 工具:JDK 17.0.13、Command-line Tools 22.0、SDK 34、
|
- 本机 Android 工具:JDK 17.0.13、Command-line Tools 22.0、SDK 34、
|
||||||
Build Tools 34.0.0、Platform Tools/ADB 37.0.0;用户级 SDK 环境变量已设置
|
Build Tools 34.0.0、Platform Tools/ADB 37.0.0;用户级 SDK 环境变量已设置
|
||||||
- Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建
|
- Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建
|
||||||
- 测试:T-214 Android 单元测试、Debug/Release 构建和根 `init.ps1` 通过;
|
- 测试:T-215 Android 单元测试、Debug/Release 构建和根 `init.ps1` 通过;
|
||||||
Debug APK `1.4.12 (17)` 已安装并启动于 PKG110
|
Debug APK `1.4.12 (17)` 已安装并启动于 PKG110
|
||||||
- 后端测试:T-214 运行 `go test ./...`、`go test -race ./...`、`go vet ./...`、
|
- 后端测试:T-215 运行 `go test ./...`、`go test -race ./...`、`go vet ./...`;
|
||||||
migration `up/down/up` 和带 identity 数据的降级保护均通过
|
v8 migration 往返、带授权数据的降级保护和 Admin 授权接口集成测试均通过
|
||||||
- 原型:4 个管理 Web 页面和 7 个 Android 页面均可离线独立打开;Playwright
|
- 原型:4 个管理 Web 页面和 7 个 Android 页面均可离线独立打开;Playwright
|
||||||
以 1440×900、390×844、360×800 验证 36 个页面/视口组合,无页面横向溢出、
|
以 1440×900、390×844、360×800 验证 36 个页面/视口组合,无页面横向溢出、
|
||||||
脚本错误或外部请求,Android 可见交互控件均不小于 44px
|
脚本错误或外部请求,Android 可见交互控件均不小于 44px
|
||||||
@@ -64,6 +64,10 @@
|
|||||||
已上传 asset 后,在 v7 表中生成 execution-scoped `candidate_key`。Admin API/Web
|
已上传 asset 后,在 v7 表中生成 execution-scoped `candidate_key`。Admin API/Web
|
||||||
展示 key、指纹、原始 observation、模型/人工结论和截图;缺少真实平台 URL 时保持
|
展示 key、指纹、原始 observation、模型/人工结论和截图;缺少真实平台 URL 时保持
|
||||||
空值,后端不请求第三方 URL。
|
空值,后端不请求第三方 URL。
|
||||||
|
- T-215 下单授权:非空候选回传原子进入 `WAITING_CONFIRMATION`;Admin 按
|
||||||
|
candidate key 选择并为全部候选填写结构化理由,服务端在同一事务追加人工 review、
|
||||||
|
不可变授权快照、事件和幂等记录。领取前可按版本改选,已投递后禁改;页面明确仅
|
||||||
|
授权创建设备端待付款订单,不授权付款。
|
||||||
- Workflow:纯 Kotlin runner 已支持步骤 timeout、最多 3 次 retry、安全阻塞、
|
- Workflow:纯 Kotlin runner 已支持步骤 timeout、最多 3 次 retry、安全阻塞、
|
||||||
用户停止和单 runner 并发拒绝;T-102 已接入搜索加有界候选采集五步
|
用户停止和单 runner 并发拒绝;T-102 已接入搜索加有界候选采集五步
|
||||||
- TaskSource:严格 CLI 已生成并验证真实私有 ProbeTask;默认 APK 不含私有 fixture
|
- TaskSource:严格 CLI 已生成并验证真实私有 ProbeTask;默认 APK 不含私有 fixture
|
||||||
@@ -92,7 +96,7 @@
|
|||||||
已用 CLI 真实导入并逐字段/图片哈希验证,生成物位于被忽略的 `.local/`
|
已用 CLI 真实导入并逐字段/图片哈希验证,生成物位于被忽略的 `.local/`
|
||||||
- 标准启动路径:`$env:RUN_START_COMMAND="1"; .\init.ps1`
|
- 标准启动路径:`$env:RUN_START_COMMAND="1"; .\init.ps1`
|
||||||
- 标准验证路径:`.\init.ps1`
|
- 标准验证路径:`.\init.ps1`
|
||||||
- 当前 blocker:T-214 无阻塞。真实 VLM 服务地址、模型、设备级测试凭证、成本上限
|
- 当前 blocker:T-215 无阻塞。真实 VLM 服务地址、模型、设备级测试凭证、成本上限
|
||||||
和数据留存尚未确认;当前只支持单 SKU/JPEG;候选探针截图要求 Android 11/API 30+
|
和数据留存尚未确认;当前只支持单 SKU/JPEG;候选探针截图要求 Android 11/API 30+
|
||||||
|
|
||||||
## 当前目录
|
## 当前目录
|
||||||
@@ -123,7 +127,7 @@
|
|||||||
| `docs/tasks/T-213.md` | DONE | 真机选择目标 SKU、读取组合价并安全返回 |
|
| `docs/tasks/T-213.md` | DONE | 真机选择目标 SKU、读取组合价并安全返回 |
|
||||||
| `docs/tasks/T-208.md` | DONE | 归一化候选决策数据并增加结构化人工 review |
|
| `docs/tasks/T-208.md` | DONE | 归一化候选决策数据并增加结构化人工 review |
|
||||||
| `docs/tasks/T-214.md` | DONE | 建立 execution-scoped candidate key 与设备采集指纹 |
|
| `docs/tasks/T-214.md` | DONE | 建立 execution-scoped candidate key 与设备采集指纹 |
|
||||||
| `docs/tasks/T-215.md` | DOING | Admin 按 candidate key 选择并创建不可变待投递授权 |
|
| `docs/tasks/T-215.md` | DONE | Admin 按 candidate key 选择并创建不可变待投递授权 |
|
||||||
| `docs/design/` | 已确认 | T-202 原型索引、4 个管理页和 7 个 Android 页面 |
|
| `docs/design/` | 已确认 | T-202 原型索引、4 个管理页和 7 个 Android 页面 |
|
||||||
| `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 |
|
| `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 |
|
||||||
| `android-buyer/` | 已有 | Roubao `main` 固定 commit 的 Android 基线 |
|
| `android-buyer/` | 已有 | Roubao `main` 固定 commit 的 Android 基线 |
|
||||||
@@ -134,8 +138,8 @@
|
|||||||
|
|
||||||
## 任务摘要
|
## 任务摘要
|
||||||
|
|
||||||
- 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-214。
|
- 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-215。
|
||||||
- 正在进行:T-215 Admin 候选确认与一次性下单授权。
|
- 正在进行:准备 T-216 设备命令投递与确认。
|
||||||
- 下一步:依次实现设备命令、订单 dry-run、单次提交对账和
|
- 下一步:依次实现设备命令、订单 dry-run、单次提交对账和
|
||||||
付款提醒。
|
付款提醒。
|
||||||
|
|
||||||
|
|||||||
+13
-8
@@ -4,7 +4,7 @@ title: Admin 候选确认与一次性下单授权
|
|||||||
phase: 2
|
phase: 2
|
||||||
deps:
|
deps:
|
||||||
- T-214
|
- T-214
|
||||||
status: DOING
|
status: DONE
|
||||||
created: 2026-07-28
|
created: 2026-07-28
|
||||||
context_ref: 56e7a2b
|
context_ref: 56e7a2b
|
||||||
work_branch: null
|
work_branch: null
|
||||||
@@ -124,13 +124,13 @@ REVOKED/SUPERSEDED`;T-215 只产生 `PENDING_DELIVERY` 和 `SUPERSEDED`。存
|
|||||||
|
|
||||||
## 验收要点
|
## 验收要点
|
||||||
|
|
||||||
- [ ] 非空候选批次原子进入 `WAITING_CONFIRMATION`,重复回传不重复增加事件/version。
|
- [x] 非空候选批次原子进入 `WAITING_CONFIRMATION`,重复回传不重复增加事件/version。
|
||||||
- [ ] Admin 请求只用 candidate key,完整覆盖候选且每项接受/拒绝都有合法理由。
|
- [x] Admin 请求只用 candidate key,完整覆盖候选且每项接受/拒绝都有合法理由。
|
||||||
- [ ] review、授权、旧授权 supersede、任务事件和幂等记录原子提交。
|
- [x] review、授权、旧授权 supersede、任务事件和幂等记录原子提交。
|
||||||
- [ ] 跨任务/execution、旧 hash/version、伪造 key、缺 identity 和并发双授权被拒绝。
|
- [x] 跨任务/execution、旧 hash/version、伪造 key、缺 identity 和并发双授权被拒绝。
|
||||||
- [ ] 授权数量/SKU/价格/指纹均由服务端快照,Admin 不能篡改。
|
- [x] 授权数量/SKU/价格/指纹均由服务端快照,Admin 不能篡改。
|
||||||
- [ ] Admin API/Web 可选候选、查看证据和理由,明确只创建待付款订单且不授权付款。
|
- [x] Admin API/Web 可选候选、查看证据和理由,明确只创建待付款订单且不授权付款。
|
||||||
- [ ] v8 migration、Go test/race/vet、根验证和管理页面桌面/移动视口通过。
|
- [x] v8 migration、Go test/race/vet、根验证和管理页面桌面/移动视口通过。
|
||||||
|
|
||||||
## 边界
|
## 边界
|
||||||
|
|
||||||
@@ -144,3 +144,8 @@ REVOKED/SUPERSEDED`;T-215 只产生 `PENDING_DELIVERY` 和 `SUPERSEDED`。存
|
|||||||
- 2026-07-28:T-214 实现提交 `56e7a2b` 后领取。决定复用
|
- 2026-07-28:T-214 实现提交 `56e7a2b` 后领取。决定复用
|
||||||
`WAITING_CONFIRMATION`,并把 Admin review 与一次性授权放在同一事务;设备投递和
|
`WAITING_CONFIRMATION`,并把 Admin review 与一次性授权放在同一事务;设备投递和
|
||||||
页面动作继续保持为后续独立权限层。
|
页面动作继续保持为后续独立权限层。
|
||||||
|
- 2026-07-28:完成 v8 migration、候选就绪原子状态迁移、Admin API/SSR 表单、
|
||||||
|
不可变授权快照、修订/幂等/并发保护和审计历史。`go test ./...`、
|
||||||
|
`go test -race ./...`、`go vet ./...` 与根 `init.ps1` 通过。
|
||||||
|
- 2026-07-28:真实 Gin handler、嵌入模板和 CSS 在 Playwright `1440x900`、
|
||||||
|
`390x844` 通过;均无横向溢出,主要控件高度至少 44px,控制台无错误。
|
||||||
|
|||||||
Reference in New Issue
Block a user