package sqlite import ( "context" "database/sql" "strings" "time" "cmroubao/backend-api/internal/domain" "cmroubao/backend-api/internal/usecase" ) const createTaskOperation = "CREATE_PURCHASE_TASK" func (s *Store) CreateTaskIdempotent( ctx context.Context, candidate domain.PurchaseTask, event domain.TaskEvent, idempotencyKey string, requestHash string, ) (domain.PurchaseTask, bool, error) { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return domain.PurchaseTask{}, false, repositoryFailure(err) } defer func() { _ = tx.Rollback() }() existingHash, resourceID, found, err := lookupIdempotency( ctx, tx, candidate.CreatorSubject, createTaskOperation, idempotencyKey, ) if err != nil { return domain.PurchaseTask{}, false, err } if found { if existingHash != requestHash { return domain.PurchaseTask{}, false, usecase.ErrIdempotencyConflict } existing, err := getTaskByID( ctx, tx, candidate.CreatorSubject, resourceID, ) if err != nil { return domain.PurchaseTask{}, false, err } if err := tx.Commit(); err != nil { return domain.PurchaseTask{}, false, repositoryFailure(err) } return existing, false, nil } var available int err = tx.QueryRowContext( ctx, `SELECT EXISTS ( SELECT 1 FROM assets AS asset WHERE asset.id = ? AND asset.creator_subject = ? AND asset.purpose = 'TASK_REFERENCE' AND NOT EXISTS ( SELECT 1 FROM purchase_tasks AS task WHERE task.image_asset_id = asset.id ) )`, candidate.ImageAssetID, candidate.CreatorSubject, ).Scan(&available) if err != nil { return domain.PurchaseTask{}, false, repositoryFailure(err) } if available != 1 { return domain.PurchaseTask{}, false, usecase.ErrAssetUnavailable } _, err = tx.ExecContext( ctx, `INSERT INTO purchase_tasks ( 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 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?)`, candidate.ID, candidate.CreatorSubject, nullableString(candidate.CreatedByUserID), nullableString(candidate.SourceRef), candidate.Title, candidate.Description, candidate.SKU, candidate.ImageAssetID, candidate.Quantity, nullableInt64(candidate.MaxBudgetCents), candidate.Currency, candidate.Status, candidate.Version, formatTimestamp(candidate.CreatedAt), formatTimestamp(candidate.UpdatedAt), ) if err != nil { switch { case isUniqueConstraint( err, "purchase_tasks.creator_subject, purchase_tasks.source_ref", ): return domain.PurchaseTask{}, false, usecase.ErrSourceReferenceConflict case isUniqueConstraint(err, "purchase_tasks.image_asset_id"): return domain.PurchaseTask{}, false, usecase.ErrAssetUnavailable default: return domain.PurchaseTask{}, false, repositoryFailure(err) } } if err := insertTaskEvent(ctx, tx, event); err != nil { return domain.PurchaseTask{}, false, err } if err := insertIdempotency( ctx, tx, candidate.CreatorSubject, createTaskOperation, idempotencyKey, requestHash, "PURCHASE_TASK", candidate.ID, candidate.CreatedAt, ); err != nil { return domain.PurchaseTask{}, false, err } if err := tx.Commit(); err != nil { return domain.PurchaseTask{}, false, repositoryFailure(err) } return candidate, true, nil } func (s *Store) ListTasks( ctx context.Context, filter usecase.TaskListFilter, ) ([]domain.PurchaseTask, error) { var query strings.Builder query.WriteString(`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 = ?`) arguments := []any{filter.CreatorSubject} if filter.Status != nil { query.WriteString(" AND status = ?") arguments = append(arguments, *filter.Status) } if filter.Query != "" { query.WriteString(` AND ( id LIKE ? ESCAPE '\' OR COALESCE(source_ref, '') LIKE ? ESCAPE '\' OR title LIKE ? ESCAPE '\' OR sku LIKE ? ESCAPE '\' )`) pattern := "%" + escapeLike(filter.Query) + "%" arguments = append( arguments, pattern, pattern, pattern, pattern, ) } if filter.CreatedFrom != nil { query.WriteString(" AND created_at >= ?") arguments = append( arguments, formatTimestamp(*filter.CreatedFrom), ) } if filter.CreatedTo != nil { query.WriteString(" AND created_at <= ?") arguments = append( arguments, formatTimestamp(*filter.CreatedTo), ) } if filter.After != nil { query.WriteString( " AND (created_at < ? OR (created_at = ? AND id < ?))", ) createdAt := formatTimestamp(filter.After.CreatedAt) arguments = append( arguments, createdAt, createdAt, filter.After.ID, ) } query.WriteString(" ORDER BY created_at DESC, id DESC LIMIT ?") arguments = append(arguments, filter.Limit) rows, err := s.db.QueryContext(ctx, query.String(), arguments...) if err != nil { return nil, repositoryFailure(err) } defer rows.Close() tasks := make([]domain.PurchaseTask, 0) for rows.Next() { task, err := scanTask(rows) if err != nil { return nil, repositoryFailure(err) } tasks = append(tasks, task) } if err := rows.Err(); err != nil { return nil, repositoryFailure(err) } return tasks, nil } func (s *Store) GetTaskDetail( ctx context.Context, creatorSubject string, taskID string, ) (domain.TaskDetail, error) { tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) if err != nil { return domain.TaskDetail{}, repositoryFailure(err) } defer func() { _ = tx.Rollback() }() task, err := getTaskByID(ctx, tx, creatorSubject, taskID) if err != nil { return domain.TaskDetail{}, err } asset, err := getAssetByID( ctx, tx, creatorSubject, task.ImageAssetID, ) if err != nil { return domain.TaskDetail{}, err } rows, err := tx.QueryContext( ctx, `SELECT id, task_id, actor_user_id, event_type, message, occurred_at FROM task_events WHERE task_id = ? ORDER BY occurred_at ASC, id ASC`, taskID, ) if err != nil { return domain.TaskDetail{}, repositoryFailure(err) } defer rows.Close() events := make([]domain.TaskEvent, 0) for rows.Next() { var event domain.TaskEvent var actorUserID sql.NullString var occurredAt string if err := rows.Scan( &event.ID, &event.TaskID, &actorUserID, &event.Type, &event.Message, &occurredAt, ); err != nil { return domain.TaskDetail{}, repositoryFailure(err) } if actorUserID.Valid { event.ActorUserID = &actorUserID.String } event.OccurredAt, err = parseTimestamp(occurredAt) if err != nil { return domain.TaskDetail{}, err } events = append(events, event) } if err := rows.Err(); err != nil { return domain.TaskDetail{}, repositoryFailure(err) } if err := rows.Close(); err != nil { return domain.TaskDetail{}, repositoryFailure(err) } detail := domain.TaskDetail{ Task: task, Asset: asset, Events: events, } if err := tx.Commit(); err != nil { return domain.TaskDetail{}, repositoryFailure(err) } return detail, nil } func (s *Store) CancelPendingTask( ctx context.Context, creatorSubject string, taskID string, reason string, canceledAt time.Time, event domain.TaskEvent, ) (domain.PurchaseTask, error) { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return domain.PurchaseTask{}, repositoryFailure(err) } defer func() { _ = tx.Rollback() }() task, err := getTaskByID(ctx, tx, creatorSubject, taskID) if err != nil { return domain.PurchaseTask{}, err } if !domain.CanCancel(task.Status) { return domain.PurchaseTask{}, usecase.ErrTaskStateConflict } result, err := tx.ExecContext( ctx, `UPDATE purchase_tasks SET status = 'CANCELED', version = version + 1, cancel_reason = NULLIF(?, ''), canceled_at = ?, updated_at = ? WHERE id = ? AND creator_subject = ? AND status = 'PENDING' AND version = ?`, reason, formatTimestamp(canceledAt), formatTimestamp(canceledAt), taskID, creatorSubject, task.Version, ) if err != nil { return domain.PurchaseTask{}, repositoryFailure(err) } affected, err := result.RowsAffected() if err != nil { return domain.PurchaseTask{}, repositoryFailure(err) } if affected != 1 { return domain.PurchaseTask{}, usecase.ErrTaskStateConflict } if err := insertTaskEvent(ctx, tx, event); err != nil { return domain.PurchaseTask{}, err } updated, err := getTaskByID(ctx, tx, creatorSubject, taskID) if err != nil { return domain.PurchaseTask{}, err } if err := tx.Commit(); err != nil { return domain.PurchaseTask{}, repositoryFailure(err) } return updated, nil } func insertTaskEvent( ctx context.Context, tx *sql.Tx, event domain.TaskEvent, ) error { _, err := tx.ExecContext( ctx, `INSERT INTO task_events ( id, task_id, actor_user_id, event_type, message, occurred_at ) VALUES (?, ?, ?, ?, ?, ?)`, event.ID, event.TaskID, nullableString(event.ActorUserID), event.Type, event.Message, formatTimestamp(event.OccurredAt), ) if err != nil { return repositoryFailure(err) } return nil } func escapeLike(value string) string { replacer := strings.NewReplacer( `\`, `\\`, `%`, `\%`, `_`, `\_`, ) return replacer.Replace(value) } var _ usecase.TaskRepository = (*Store)(nil)