feat(admin): add routed task evidence details

This commit is contained in:
QiuSW
2026-08-04 19:42:35 +08:00
parent 9a4d11f74b
commit 89648880bc
28 changed files with 2997 additions and 26 deletions
+60
View File
@@ -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
}
+204
View File
@@ -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 &copy
}
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')
}
+82
View File
@@ -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
}