2026-07-26 14:03:32 +08:00
|
|
|
package sqlite
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"database/sql"
|
2026-07-26 16:16:36 +08:00
|
|
|
"errors"
|
2026-07-26 14:03:32 +08:00
|
|
|
"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 (
|
2026-07-26 15:18:48 +08:00
|
|
|
id, creator_subject, created_by_user_id, source_ref, title, description, sku,
|
2026-07-26 14:03:32 +08:00
|
|
|
image_asset_id, quantity, max_budget_cents, currency, status,
|
|
|
|
|
version, cancel_reason, canceled_at, created_at, updated_at
|
2026-07-26 15:18:48 +08:00
|
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?)`,
|
2026-07-26 14:03:32 +08:00
|
|
|
candidate.ID,
|
|
|
|
|
candidate.CreatorSubject,
|
2026-07-26 15:18:48 +08:00
|
|
|
nullableString(candidate.CreatedByUserID),
|
2026-07-26 14:03:32 +08:00
|
|
|
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
|
2026-07-26 15:18:48 +08:00
|
|
|
id, creator_subject, created_by_user_id, source_ref, title, description, sku,
|
2026-07-26 14:03:32 +08:00
|
|
|
image_asset_id, quantity, max_budget_cents, currency, status,
|
2026-07-26 16:16:36 +08:00
|
|
|
version, claimed_by_user_id, claimed_by_device_id, claim_generation,
|
|
|
|
|
claim_token_hash, claim_issued_at, claim_expires_at, cancel_reason,
|
|
|
|
|
cancel_requested_at, cancel_requested_by_user_id, canceled_at,
|
|
|
|
|
created_at, updated_at
|
2026-07-26 14:03:32 +08:00
|
|
|
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,
|
2026-07-26 16:16:36 +08:00
|
|
|
`SELECT
|
|
|
|
|
id, task_id, actor_user_id, actor_device_id,
|
|
|
|
|
event_type, message, occurred_at
|
2026-07-26 14:03:32 +08:00
|
|
|
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
|
2026-07-26 15:18:48 +08:00
|
|
|
var actorUserID sql.NullString
|
2026-07-26 16:16:36 +08:00
|
|
|
var actorDeviceID sql.NullString
|
2026-07-26 14:03:32 +08:00
|
|
|
var occurredAt string
|
|
|
|
|
if err := rows.Scan(
|
|
|
|
|
&event.ID,
|
|
|
|
|
&event.TaskID,
|
2026-07-26 15:18:48 +08:00
|
|
|
&actorUserID,
|
2026-07-26 16:16:36 +08:00
|
|
|
&actorDeviceID,
|
2026-07-26 14:03:32 +08:00
|
|
|
&event.Type,
|
|
|
|
|
&event.Message,
|
|
|
|
|
&occurredAt,
|
|
|
|
|
); err != nil {
|
|
|
|
|
return domain.TaskDetail{}, repositoryFailure(err)
|
|
|
|
|
}
|
2026-07-26 15:18:48 +08:00
|
|
|
if actorUserID.Valid {
|
|
|
|
|
event.ActorUserID = &actorUserID.String
|
|
|
|
|
}
|
2026-07-26 16:16:36 +08:00
|
|
|
if actorDeviceID.Valid {
|
|
|
|
|
event.ActorDeviceID = &actorDeviceID.String
|
|
|
|
|
}
|
2026-07-26 14:03:32 +08:00
|
|
|
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)
|
|
|
|
|
}
|
2026-07-26 16:16:36 +08:00
|
|
|
execution, err := scanExecution(tx.QueryRowContext(
|
|
|
|
|
ctx,
|
|
|
|
|
`SELECT
|
|
|
|
|
id, task_id, attempt_no, claim_generation, user_id, device_id,
|
|
|
|
|
current_step, order_submitted, started_at, last_heartbeat_at,
|
|
|
|
|
finished_at
|
|
|
|
|
FROM task_executions
|
|
|
|
|
WHERE task_id = ?
|
|
|
|
|
ORDER BY attempt_no DESC
|
|
|
|
|
LIMIT 1`,
|
|
|
|
|
taskID,
|
|
|
|
|
))
|
|
|
|
|
var executionPointer *domain.TaskExecution
|
|
|
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
|
|
|
executionPointer = nil
|
|
|
|
|
} else if err != nil {
|
|
|
|
|
return domain.TaskDetail{}, repositoryFailure(err)
|
|
|
|
|
} else {
|
|
|
|
|
executionPointer = &execution
|
|
|
|
|
}
|
2026-07-27 12:23:05 +08:00
|
|
|
var report *domain.ExecutionReport
|
|
|
|
|
if executionPointer != nil {
|
|
|
|
|
report, err = getExecutionReport(ctx, tx, taskID, executionPointer.ID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return domain.TaskDetail{}, err
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-26 14:03:32 +08:00
|
|
|
detail := domain.TaskDetail{
|
2026-07-26 16:16:36 +08:00
|
|
|
Task: task,
|
|
|
|
|
Asset: asset,
|
|
|
|
|
Execution: executionPointer,
|
|
|
|
|
Events: events,
|
2026-07-27 12:23:05 +08:00
|
|
|
Report: report,
|
2026-07-26 14:03:32 +08:00
|
|
|
}
|
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
|
|
|
return domain.TaskDetail{}, repositoryFailure(err)
|
|
|
|
|
}
|
|
|
|
|
return detail, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-26 16:16:36 +08:00
|
|
|
func (s *Store) CancelTask(
|
2026-07-26 14:03:32 +08:00
|
|
|
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
|
|
|
|
|
}
|
2026-07-26 16:16:36 +08:00
|
|
|
if domain.CanRequestCancel(task.Status) &&
|
|
|
|
|
task.CancelRequestedAt != nil {
|
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
|
|
|
return domain.PurchaseTask{}, repositoryFailure(err)
|
|
|
|
|
}
|
|
|
|
|
return task, nil
|
|
|
|
|
}
|
|
|
|
|
var result sql.Result
|
|
|
|
|
if domain.CanAdminCancelImmediately(task.Status) {
|
|
|
|
|
result, err = tx.ExecContext(
|
|
|
|
|
ctx,
|
|
|
|
|
`UPDATE purchase_tasks
|
|
|
|
|
SET status = 'CANCELED',
|
|
|
|
|
version = version + 1,
|
|
|
|
|
claimed_by_user_id = NULL,
|
|
|
|
|
claimed_by_device_id = NULL,
|
|
|
|
|
claim_token_hash = NULL,
|
|
|
|
|
claim_issued_at = NULL,
|
|
|
|
|
claim_expires_at = NULL,
|
|
|
|
|
cancel_reason = NULLIF(?, ''),
|
|
|
|
|
cancel_requested_at = NULL,
|
|
|
|
|
cancel_requested_by_user_id = NULL,
|
|
|
|
|
canceled_at = ?,
|
|
|
|
|
updated_at = ?
|
|
|
|
|
WHERE id = ?
|
|
|
|
|
AND creator_subject = ?
|
|
|
|
|
AND status IN ('PENDING', 'CLAIMED')
|
|
|
|
|
AND version = ?`,
|
|
|
|
|
reason,
|
|
|
|
|
formatTimestamp(canceledAt),
|
|
|
|
|
formatTimestamp(canceledAt),
|
|
|
|
|
taskID,
|
|
|
|
|
creatorSubject,
|
|
|
|
|
task.Version,
|
|
|
|
|
)
|
|
|
|
|
} else {
|
|
|
|
|
event.Type = "TASK_CANCEL_REQUESTED"
|
|
|
|
|
event.Message = "task cancellation requested"
|
|
|
|
|
result, err = tx.ExecContext(
|
|
|
|
|
ctx,
|
|
|
|
|
`UPDATE purchase_tasks
|
|
|
|
|
SET version = version + 1,
|
|
|
|
|
cancel_reason = NULLIF(?, ''),
|
|
|
|
|
cancel_requested_at = ?,
|
|
|
|
|
cancel_requested_by_user_id = ?,
|
|
|
|
|
updated_at = ?
|
|
|
|
|
WHERE id = ?
|
|
|
|
|
AND creator_subject = ?
|
|
|
|
|
AND status IN ('RUNNING', 'WAITING_CONFIRMATION')
|
|
|
|
|
AND cancel_requested_at IS NULL
|
|
|
|
|
AND version = ?`,
|
|
|
|
|
reason,
|
|
|
|
|
formatTimestamp(canceledAt),
|
|
|
|
|
nullableString(event.ActorUserID),
|
|
|
|
|
formatTimestamp(canceledAt),
|
|
|
|
|
taskID,
|
|
|
|
|
creatorSubject,
|
|
|
|
|
task.Version,
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-07-26 14:03:32 +08:00
|
|
|
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 (
|
2026-07-26 16:16:36 +08:00
|
|
|
id, task_id, actor_user_id, actor_device_id,
|
|
|
|
|
event_type, message, occurred_at
|
|
|
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
2026-07-26 14:03:32 +08:00
|
|
|
event.ID,
|
|
|
|
|
event.TaskID,
|
2026-07-26 15:18:48 +08:00
|
|
|
nullableString(event.ActorUserID),
|
2026-07-26 16:16:36 +08:00
|
|
|
nullableString(event.ActorDeviceID),
|
2026-07-26 14:03:32 +08:00
|
|
|
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)
|