feat(t215): add admin order authorization

This commit is contained in:
QiuSW
2026-07-28 12:50:42 +08:00
parent 16c2ea410a
commit 827afc7257
24 changed files with 2688 additions and 113 deletions
+17 -4
View File
@@ -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
} }
@@ -255,9 +267,10 @@ func buildRouter(
} }
registerAdminRoutes, err := httpapi.NewAdminRouteRegistrar( registerAdminRoutes, err := httpapi.NewAdminRouteRegistrar(
httpapi.AdminServices{ httpapi.AdminServices{
Assets: assets, Assets: assets,
Tasks: tasks, Tasks: tasks,
Results: results, Results: results,
Authorizations: authorizations,
}, },
webHandler, webHandler,
) )
+55 -5
View File
@@ -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
@@ -262,11 +311,12 @@ type ExecutionReport struct {
} }
type TaskDetail struct { type TaskDetail struct {
Task PurchaseTask Task PurchaseTask
Asset Asset Asset Asset
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)
@@ -24,13 +24,15 @@ const (
) )
type AdminServices struct { 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,13 +377,116 @@ func (h *adminHandlers) taskDetail(ctx *gin.Context) {
"claim": claim, "claim": claim,
"execution": execution, "execution": execution,
"execution_report": executionReport, "execution_report": executionReport,
"events": events, "order_authorizations": orderAuthorizationResponses(
detail.OrderAuthorizations,
),
"events": events,
"assets": []gin.H{ "assets": []gin.H{
assetResponse(detail.Asset), assetResponse(detail.Asset),
}, },
}) })
} }
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(
+121 -25
View File
@@ -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,20 +269,85 @@ 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: "任务详情",
TasksCurrent: true, TasksCurrent: true,
CSRFToken: token, CSRFToken: token,
}, },
Task: taskDetailViewFrom(task), Task: taskDetailViewFrom(task),
CSRFToken: token, CSRFToken: token,
CancelKey: cancelKey, CancelKey: cancelKey,
Notice: detailNotice(ctx.Query("notice")), AuthorizationKey: authorizationKey,
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 ""
} }
@@ -742,29 +816,35 @@ type newTaskPageView struct {
} }
type taskDetailView struct { type taskDetailView struct {
ID string ID string
Title string Title string
SKU string SKU string
Description string Description string
Quantity int64 Quantity int64
MaxBudget string MaxBudget string
Status string Status string
StatusLabel string Version int64
StatusClass string StatusLabel string
ReferenceAssetID string StatusClass string
CreatedAt time.Time ReferenceAssetID string
UpdatedAt time.Time CreatedAt time.Time
CanCancel bool UpdatedAt time.Time
CancelRequiresAck bool CanCancel bool
ExecutionReport *ExecutionReport CancelRequiresAck bool
ExecutionReport *ExecutionReport
Candidates []AuthorizationCandidate
OrderAuthorizations []OrderAuthorization
CanAuthorizeOrder bool
ActiveAuthorizationID string
} }
type taskDetailPage struct { type taskDetailPage struct {
Page pageView Page pageView
Task taskDetailView Task taskDetailView
CSRFToken string CSRFToken string
CancelKey string CancelKey string
Notice string AuthorizationKey string
Notice string
} }
type errorPage struct { type errorPage struct {
@@ -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,
@@ -790,7 +882,11 @@ func taskDetailViewFrom(task Task) taskDetailView {
CanCancel: canCancelTaskStatus(task.Status), CanCancel: canCancelTaskStatus(task.Status),
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
@@ -614,23 +732,26 @@ func TestRendererUsesMissingKeyErrors(t *testing.T) {
} }
type fakeService struct { type fakeService struct {
listInput ListTasksInput listInput ListTasksInput
listResult TaskList listResult TaskList
listErr error listErr error
getResult Task getResult Task
getErr error getErr error
uploadResult UploadedAsset uploadResult UploadedAsset
uploadErr error uploadErr error
uploadInput UploadReferenceInput uploadInput UploadReferenceInput
uploadBody []byte uploadBody []byte
uploadCalls int uploadCalls int
createResult Task createResult Task
createErr error createErr error
createInput CreateTaskInput createInput CreateTaskInput
createCalls int createCalls int
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>
+49 -11
View File
@@ -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 {
@@ -48,17 +49,43 @@ type TaskSummary struct {
} }
type Task struct { type Task struct {
ID string ID string
Title string Title string
SKU string SKU string
Description string Description string
Quantity int64 Quantity int64
MaxBudget string MaxBudget string
Status string Status string
ReferenceAssetID string Version int64
CreatedAt time.Time ExecutionID string
UpdatedAt time.Time TaskContentSHA256 string
ExecutionReport *ExecutionReport ReferenceAssetID string
CreatedAt time.Time
UpdatedAt time.Time
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
}
@@ -14,20 +14,23 @@ import (
const localAdminSubject = "local-admin" 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);
+3 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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,控制台无错误。