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
@@ -383,6 +383,9 @@ func TestAuthMigrationCanRollbackWithoutRebuildingPurchaseTasks(
if err != nil {
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 {
t.Fatalf("Down(v7) error = %v", err)
}
@@ -411,9 +414,9 @@ func TestAuthMigrationCanRollbackWithoutRebuildingPurchaseTasks(
t.Fatal("purchase_tasks was lost during auth migration rollback")
}
if applied, err := runner.Up(context.Background()); err != nil {
t.Fatalf("Up(v3-v7) error = %v", err)
} else if applied != 5 {
t.Fatalf("Up(v3-v7) applied = %d, want 5", applied)
t.Fatalf("Up(v3-v8) error = %v", err)
} else if applied != 6 {
t.Fatalf("Up(v3-v8) applied = %d, want 6", applied)
}
}
@@ -192,6 +192,33 @@ func (s *Store) StoreExecutionCandidates(
); err != nil {
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 {
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
}
}
orderAuthorizations, err := listOrderAuthorizations(ctx, tx, taskID)
if err != nil {
return domain.TaskDetail{}, err
}
detail := domain.TaskDetail{
Task: task,
Asset: asset,
Execution: executionPointer,
Events: events,
Report: report,
Task: task,
Asset: asset,
Execution: executionPointer,
Events: events,
Report: report,
OrderAuthorizations: orderAuthorizations,
}
if err := tx.Commit(); err != nil {
return domain.TaskDetail{}, repositoryFailure(err)