package sqlite import ( "context" "database/sql" "errors" "fmt" "strings" "time" "cmroubao/backend-api/internal/domain" "cmroubao/backend-api/internal/usecase" sqlite3 "github.com/mattn/go-sqlite3" ) const timestampLayout = time.RFC3339Nano type queryRower interface { QueryRowContext(context.Context, string, ...any) *sql.Row } type rowScanner interface { Scan(...any) error } func scanAsset(scanner rowScanner) (domain.Asset, error) { var asset domain.Asset var createdAt string err := scanner.Scan( &asset.ID, &asset.CreatorSubject, &asset.Purpose, &asset.MediaType, &asset.SizeBytes, &asset.SHA256, &asset.StorageKey, &createdAt, ) if err != nil { return domain.Asset{}, err } asset.CreatedAt, err = parseTimestamp(createdAt) if err != nil { return domain.Asset{}, err } return asset, nil } func scanTask(scanner rowScanner) (domain.PurchaseTask, error) { var task domain.PurchaseTask var createdByUserID sql.NullString var sourceRef sql.NullString var maxBudget sql.NullInt64 var cancelReason sql.NullString var canceledAt sql.NullString var createdAt string var updatedAt string err := scanner.Scan( &task.ID, &task.CreatorSubject, &createdByUserID, &sourceRef, &task.Title, &task.Description, &task.SKU, &task.ImageAssetID, &task.Quantity, &maxBudget, &task.Currency, &task.Status, &task.Version, &cancelReason, &canceledAt, &createdAt, &updatedAt, ) if err != nil { return domain.PurchaseTask{}, err } if sourceRef.Valid { task.SourceRef = &sourceRef.String } if createdByUserID.Valid { task.CreatedByUserID = &createdByUserID.String } if maxBudget.Valid { task.MaxBudgetCents = &maxBudget.Int64 } if cancelReason.Valid { task.CancelReason = &cancelReason.String } if canceledAt.Valid { value, err := parseTimestamp(canceledAt.String) if err != nil { return domain.PurchaseTask{}, err } task.CanceledAt = &value } task.CreatedAt, err = parseTimestamp(createdAt) if err != nil { return domain.PurchaseTask{}, err } task.UpdatedAt, err = parseTimestamp(updatedAt) if err != nil { return domain.PurchaseTask{}, err } return task, nil } func getAssetByID( ctx context.Context, queryer queryRower, creatorSubject string, assetID string, ) (domain.Asset, error) { asset, err := scanAsset(queryer.QueryRowContext( ctx, `SELECT id, creator_subject, purpose, media_type, size_bytes, sha256, storage_key, created_at FROM assets WHERE creator_subject = ? AND id = ?`, creatorSubject, assetID, )) if errors.Is(err, sql.ErrNoRows) { return domain.Asset{}, usecase.ErrRepositoryNotFound } if err != nil { return domain.Asset{}, repositoryFailure(err) } return asset, nil } func getTaskByID( ctx context.Context, queryer queryRower, creatorSubject string, taskID string, ) (domain.PurchaseTask, error) { task, err := scanTask(queryer.QueryRowContext( ctx, `SELECT id, creator_subject, created_by_user_id, source_ref, title, description, sku, image_asset_id, quantity, max_budget_cents, currency, status, version, cancel_reason, canceled_at, created_at, updated_at FROM purchase_tasks WHERE creator_subject = ? AND id = ?`, creatorSubject, taskID, )) if errors.Is(err, sql.ErrNoRows) { return domain.PurchaseTask{}, usecase.ErrRepositoryNotFound } if err != nil { return domain.PurchaseTask{}, repositoryFailure(err) } return task, nil } func lookupIdempotency( ctx context.Context, tx *sql.Tx, creatorSubject string, operation string, idempotencyKey string, ) (requestHash string, resourceID string, found bool, err error) { err = tx.QueryRowContext( ctx, `SELECT request_sha256, resource_id FROM idempotency_records WHERE creator_subject = ? AND operation = ? AND idempotency_key = ?`, creatorSubject, operation, idempotencyKey, ).Scan(&requestHash, &resourceID) if errors.Is(err, sql.ErrNoRows) { return "", "", false, nil } if err != nil { return "", "", false, repositoryFailure(err) } return requestHash, resourceID, true, nil } func insertIdempotency( ctx context.Context, tx *sql.Tx, creatorSubject string, operation string, idempotencyKey string, requestHash string, resourceType string, resourceID string, createdAt time.Time, ) error { _, err := tx.ExecContext( ctx, `INSERT INTO idempotency_records ( creator_subject, operation, idempotency_key, request_sha256, resource_type, resource_id, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?)`, creatorSubject, operation, idempotencyKey, requestHash, resourceType, resourceID, formatTimestamp(createdAt), ) if err != nil { return repositoryFailure(err) } return nil } func formatTimestamp(value time.Time) string { return value.UTC().Format(timestampLayout) } func parseTimestamp(value string) (time.Time, error) { parsed, err := time.Parse(timestampLayout, value) if err != nil { return time.Time{}, fmt.Errorf( "%w: invalid stored timestamp", usecase.ErrRepositoryInvariant, ) } return parsed.UTC(), nil } func nullableString(value *string) any { if value == nil { return nil } return *value } func nullableInt64(value *int64) any { if value == nil { return nil } return *value } func repositoryFailure(err error) error { if err == nil { return nil } var sqliteError sqlite3.Error if errors.As(err, &sqliteError) { switch sqliteError.Code { case sqlite3.ErrBusy, sqlite3.ErrLocked, sqlite3.ErrIoErr, sqlite3.ErrCantOpen, sqlite3.ErrFull: return fmt.Errorf("%w", usecase.ErrRepositoryUnavailable) } } if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { return fmt.Errorf("%w", usecase.ErrRepositoryUnavailable) } return fmt.Errorf("%w", usecase.ErrRepositoryInvariant) } func isUniqueConstraint(err error, fragment string) bool { var sqliteError sqlite3.Error if !errors.As(err, &sqliteError) || sqliteError.ExtendedCode != sqlite3.ErrConstraintUnique { return false } return strings.Contains(err.Error(), fragment) }