feat(admin): add routed task evidence details
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
// Package taskdetail provides a read-only audit projection for one task.
|
||||
package taskdetail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("task detail not found")
|
||||
|
||||
type Store interface {
|
||||
Get(context.Context, string) (Detail, error)
|
||||
}
|
||||
|
||||
type Detail struct {
|
||||
Task Task
|
||||
Authorizations []Authorization
|
||||
Attempts []Attempt
|
||||
Submissions []Submission
|
||||
Evidence []Evidence
|
||||
}
|
||||
|
||||
type Task struct {
|
||||
ID, Source, Title, GoodsID, SKUColor, SKUSize, MaxTotalPrice, Status string
|
||||
Quantity, Version int
|
||||
CreatedAt, UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type Authorization struct {
|
||||
ID, Status, CreatedBy, TotalPriceCap string
|
||||
TaskVersion int
|
||||
CreatedAt, ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type Attempt struct {
|
||||
ID, AuthorizationID, Status string
|
||||
ClaimGeneration int
|
||||
Gate1UnitPrice *string
|
||||
Gate2UnitPrice *string
|
||||
QuantityRead *int
|
||||
ConfirmAmount *string
|
||||
FailureCode *string
|
||||
StartedAt time.Time
|
||||
FinishedAt *time.Time
|
||||
}
|
||||
|
||||
type Submission struct {
|
||||
ID, AuthorizationID, AttemptID, Status string
|
||||
Gate1UnitPrice, Gate2UnitPrice, ConfirmAmount string
|
||||
QuantityRead int
|
||||
CreatedAt time.Time
|
||||
ResolvedAt *time.Time
|
||||
}
|
||||
|
||||
type Evidence struct {
|
||||
ID, AttemptID, Kind, PrivacyTier, SHA256, ContentType string
|
||||
ByteSize, Width, Height int64
|
||||
CapturedAt time.Time
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package taskdetail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SQLiteStore struct{ database *sql.DB }
|
||||
|
||||
func NewSQLiteStore(database *sql.DB) (*SQLiteStore, error) {
|
||||
if database == nil {
|
||||
return nil, errors.New("task detail database is required")
|
||||
}
|
||||
if _, err := database.Exec("SELECT storage_key FROM evidence_assets LIMIT 1"); err != nil {
|
||||
return nil, fmt.Errorf("task detail migration is not available: %w", err)
|
||||
}
|
||||
return &SQLiteStore{database: database}, nil
|
||||
}
|
||||
|
||||
func (store *SQLiteStore) Get(ctx context.Context, id string) (Detail, error) {
|
||||
if !validUUID(id) {
|
||||
return Detail{}, ErrNotFound
|
||||
}
|
||||
tx, err := store.database.BeginTx(ctx, &sql.TxOptions{ReadOnly: true})
|
||||
if err != nil {
|
||||
return Detail{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var detail Detail
|
||||
var created, updated string
|
||||
err = tx.QueryRowContext(ctx, `SELECT id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, version, created_at, updated_at FROM tasks WHERE id = ?`, id).Scan(
|
||||
&detail.Task.ID, &detail.Task.Source, &detail.Task.Title, &detail.Task.GoodsID, &detail.Task.SKUColor, &detail.Task.SKUSize,
|
||||
&detail.Task.Quantity, &detail.Task.MaxTotalPrice, &detail.Task.Status, &detail.Task.Version, &created, &updated,
|
||||
)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Detail{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Detail{}, err
|
||||
}
|
||||
if detail.Task.CreatedAt, err = parseTime(created); err != nil {
|
||||
return Detail{}, err
|
||||
}
|
||||
if detail.Task.UpdatedAt, err = parseTime(updated); err != nil {
|
||||
return Detail{}, err
|
||||
}
|
||||
if detail.Authorizations, err = readAuthorizations(ctx, tx, id); err != nil {
|
||||
return Detail{}, err
|
||||
}
|
||||
if detail.Attempts, err = readAttempts(ctx, tx, id); err != nil {
|
||||
return Detail{}, err
|
||||
}
|
||||
if detail.Submissions, err = readSubmissions(ctx, tx, id); err != nil {
|
||||
return Detail{}, err
|
||||
}
|
||||
if detail.Evidence, err = readEvidence(ctx, tx, id); err != nil {
|
||||
return Detail{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return Detail{}, err
|
||||
}
|
||||
return detail, nil
|
||||
}
|
||||
|
||||
func readAuthorizations(ctx context.Context, tx *sql.Tx, taskID string) ([]Authorization, error) {
|
||||
rows, err := tx.QueryContext(ctx, `SELECT id, task_version, total_price_cap, status, created_by, created_at, expires_at FROM order_authorizations WHERE task_id = ? ORDER BY created_at DESC, id DESC`, taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := []Authorization{}
|
||||
for rows.Next() {
|
||||
var item Authorization
|
||||
var created, expires string
|
||||
if err := rows.Scan(&item.ID, &item.TaskVersion, &item.TotalPriceCap, &item.Status, &item.CreatedBy, &created, &expires); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if item.CreatedAt, err = parseTime(created); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if item.ExpiresAt, err = parseTime(expires); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func readAttempts(ctx context.Context, tx *sql.Tx, taskID string) ([]Attempt, error) {
|
||||
rows, err := tx.QueryContext(ctx, `SELECT id, authorization_id, claim_generation, status, gate1_unit_price, gate2_unit_price, quantity_read, confirm_amount, failure_code, started_at, finished_at FROM purchase_attempts WHERE task_id = ? ORDER BY started_at DESC, id DESC`, taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := []Attempt{}
|
||||
for rows.Next() {
|
||||
var item Attempt
|
||||
var gate1, gate2, confirm, failure, finished sql.NullString
|
||||
var quantity sql.NullInt64
|
||||
var started string
|
||||
if err := rows.Scan(&item.ID, &item.AuthorizationID, &item.ClaimGeneration, &item.Status, &gate1, &gate2, &quantity, &confirm, &failure, &started, &finished); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.Gate1UnitPrice, item.Gate2UnitPrice, item.ConfirmAmount, item.FailureCode = stringPointer(gate1), stringPointer(gate2), stringPointer(confirm), stringPointer(failure)
|
||||
if quantity.Valid {
|
||||
value := int(quantity.Int64)
|
||||
item.QuantityRead = &value
|
||||
}
|
||||
if item.StartedAt, err = parseTime(started); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if finished.Valid {
|
||||
value, parseErr := parseTime(finished.String)
|
||||
if parseErr != nil {
|
||||
return nil, parseErr
|
||||
}
|
||||
item.FinishedAt = &value
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func readSubmissions(ctx context.Context, tx *sql.Tx, taskID string) ([]Submission, error) {
|
||||
rows, err := tx.QueryContext(ctx, `SELECT id, authorization_id, attempt_id, status, gate1_unit_price, gate2_unit_price, quantity_read, confirm_amount, created_at, resolved_at FROM order_submissions WHERE task_id = ? ORDER BY created_at DESC, id DESC`, taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := []Submission{}
|
||||
for rows.Next() {
|
||||
var item Submission
|
||||
var created string
|
||||
var resolved sql.NullString
|
||||
if err := rows.Scan(&item.ID, &item.AuthorizationID, &item.AttemptID, &item.Status, &item.Gate1UnitPrice, &item.Gate2UnitPrice, &item.QuantityRead, &item.ConfirmAmount, &created, &resolved); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if item.CreatedAt, err = parseTime(created); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resolved.Valid {
|
||||
value, parseErr := parseTime(resolved.String)
|
||||
if parseErr != nil {
|
||||
return nil, parseErr
|
||||
}
|
||||
item.ResolvedAt = &value
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func readEvidence(ctx context.Context, tx *sql.Tx, taskID string) ([]Evidence, error) {
|
||||
rows, err := tx.QueryContext(ctx, `SELECT id, attempt_id, kind, privacy_tier, sha256, byte_size, content_type, width_px, height_px, captured_at FROM evidence_assets WHERE task_id = ? ORDER BY captured_at, created_at, id`, taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := []Evidence{}
|
||||
for rows.Next() {
|
||||
var item Evidence
|
||||
var captured string
|
||||
if err := rows.Scan(&item.ID, &item.AttemptID, &item.Kind, &item.PrivacyTier, &item.SHA256, &item.ByteSize, &item.ContentType, &item.Width, &item.Height, &captured); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if item.CapturedAt, err = parseTime(captured); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func parseTime(value string) (time.Time, error) { return time.Parse(time.RFC3339Nano, value) }
|
||||
|
||||
func stringPointer(value sql.NullString) *string {
|
||||
if !value.Valid {
|
||||
return nil
|
||||
}
|
||||
copy := value.String
|
||||
return ©
|
||||
}
|
||||
|
||||
func validUUID(value string) bool {
|
||||
if len(value) != 36 {
|
||||
return false
|
||||
}
|
||||
for index, character := range value {
|
||||
if index == 8 || index == 13 || index == 18 || index == 23 {
|
||||
if character != '-' {
|
||||
return false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !(character >= '0' && character <= '9' || character >= 'a' && character <= 'f') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return value[14] == '4' && (value[19] == '8' || value[19] == '9' || value[19] == 'a' || value[19] == 'b')
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package taskdetail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cmbuyer/admin/internal/migrations"
|
||||
"cmbuyer/admin/internal/storage/sqlite"
|
||||
)
|
||||
|
||||
const (
|
||||
detailTask = "a3c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
detailAuth = "b3c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
detailTry = "c3c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
)
|
||||
|
||||
func TestSQLiteStoreReturnsOnlyPersistedAuditFacts(t *testing.T) {
|
||||
database := openDetailDatabase(t)
|
||||
timestamp := "2026-08-04T00:00:00Z"
|
||||
if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, version, created_at, updated_at) VALUES (?, 'MANUAL', 'shirt', '123', 'black', 'M', 2, '30.00', 'CLAIMED', 3, ?, ?)`, detailTask, timestamp, timestamp); err != nil {
|
||||
t.Fatalf("insert task: %v", err)
|
||||
}
|
||||
if _, err := database.Exec(`INSERT INTO order_authorizations (id, task_id, task_version, start_key, goods_id, sku_color, sku_size, quantity, total_price_cap, status, created_by, created_at, expires_at) VALUES (?, ?, 2, 'start', '123', 'black', 'M', 2, '30.00', 'CLAIMED', 'admin', ?, ?)`, detailAuth, detailTask, timestamp, timestamp); err != nil {
|
||||
t.Fatalf("insert authorization: %v", err)
|
||||
}
|
||||
if _, err := database.Exec(`INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, started_at) VALUES (?, ?, ?, 1, 'CLAIMED', ?)`, detailTry, detailTask, detailAuth, timestamp); err != nil {
|
||||
t.Fatalf("insert attempt: %v", err)
|
||||
}
|
||||
hash := strings.Repeat("a", 64)
|
||||
if _, err := database.Exec(`INSERT INTO evidence_assets (id, upload_key, task_id, attempt_id, kind, privacy_tier, sha256, byte_size, content_type, width_px, height_px, storage_key, uploaded_by_device_id, captured_at, created_at) VALUES ('d3c9f507-7473-4fa6-8d71-8786c34c6301', 'upload', ?, ?, 'SKU_PANEL_GATE_1', 'INTERNAL_RAW', ?, 100, 'image/png', 10, 20, ?, 'device', ?, ?)`, detailTask, detailTry, hash, "aa/"+hash+".png", timestamp, timestamp); err != nil {
|
||||
t.Fatalf("insert evidence: %v", err)
|
||||
}
|
||||
store, err := NewSQLiteStore(database)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSQLiteStore: %v", err)
|
||||
}
|
||||
detail, err := store.Get(context.Background(), detailTask)
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if detail.Task.ID != detailTask || detail.Task.Status != "CLAIMED" || len(detail.Authorizations) != 1 || len(detail.Attempts) != 1 || len(detail.Evidence) != 1 || len(detail.Submissions) != 0 {
|
||||
t.Fatalf("detail = %#v", detail)
|
||||
}
|
||||
if detail.Attempts[0].Gate1UnitPrice != nil || detail.Attempts[0].FailureCode != nil {
|
||||
t.Fatalf("missing attempt facts were fabricated: %#v", detail.Attempts[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteStoreFailsClosedForMalformedAndMissingIDs(t *testing.T) {
|
||||
database := openDetailDatabase(t)
|
||||
store, err := NewSQLiteStore(database)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSQLiteStore: %v", err)
|
||||
}
|
||||
for _, id := range []string{"../database", "not-a-uuid", "a3c9f507-7473-1fa6-8d71-8786c34c6301"} {
|
||||
if _, err := store.Get(context.Background(), id); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("Get(%q) error = %v", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func openDetailDatabase(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
database, err := sqlite.Open(filepath.Join(t.TempDir(), "details.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("locate migration directory")
|
||||
}
|
||||
if err := migrations.Up(context.Background(), database, filepath.Join(filepath.Dir(file), "..", "..", "migrations")); err != nil {
|
||||
t.Fatalf("migrate database: %v", err)
|
||||
}
|
||||
return database
|
||||
}
|
||||
Reference in New Issue
Block a user