feat: add image optimization task model
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"cmbone/internal/models"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
imageTaskStatusSucceeded = "succeeded"
|
||||
imageTaskStatusCanceled = "canceled"
|
||||
|
||||
defaultImageOperation = "enhance"
|
||||
defaultTaskLimit = 50
|
||||
maxTaskLimit = 200
|
||||
)
|
||||
|
||||
var (
|
||||
errImageTaskSourcePathRequired = errors.New("source path is required")
|
||||
errImageTaskNotFound = errors.New("image task not found")
|
||||
errImageTaskCannotCancel = errors.New("image task cannot be canceled")
|
||||
)
|
||||
|
||||
type ImageOptimizationService struct {
|
||||
store *SuiStore
|
||||
}
|
||||
|
||||
func NewImageOptimizationService(store *SuiStore) *ImageOptimizationService {
|
||||
return &ImageOptimizationService{store: store}
|
||||
}
|
||||
|
||||
func (s *ImageOptimizationService) CreateTask(input models.ImageTaskInput) (models.ImageTaskDetail, error) {
|
||||
sourcePath := strings.TrimSpace(input.SourcePath)
|
||||
if sourcePath == "" {
|
||||
return models.ImageTaskDetail{}, errImageTaskSourcePathRequired
|
||||
}
|
||||
|
||||
operation := strings.TrimSpace(input.Operation)
|
||||
if operation == "" {
|
||||
operation = defaultImageOperation
|
||||
}
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
beforeSize := fileSizeOrZero(sourcePath)
|
||||
afterSize := simulatedAfterSize(beforeSize)
|
||||
|
||||
tx, err := s.store.DB.Begin()
|
||||
if err != nil {
|
||||
return models.ImageTaskDetail{}, err
|
||||
}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
result, err := tx.Exec(`
|
||||
INSERT INTO image_tasks (source_path, status, operation, duration_ms, error_message, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, '', ?, ?)
|
||||
`, sourcePath, imageTaskStatusSucceeded, operation, 1200, now, now)
|
||||
if err != nil {
|
||||
return models.ImageTaskDetail{}, err
|
||||
}
|
||||
|
||||
taskID, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return models.ImageTaskDetail{}, err
|
||||
}
|
||||
|
||||
outputPath := simulatedOutputPath(sourcePath, operation)
|
||||
result, err = tx.Exec(`
|
||||
INSERT INTO image_task_results (task_id, output_path, before_size, after_size, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`, taskID, outputPath, beforeSize, afterSize, now)
|
||||
if err != nil {
|
||||
return models.ImageTaskDetail{}, err
|
||||
}
|
||||
|
||||
resultID, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return models.ImageTaskDetail{}, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return models.ImageTaskDetail{}, err
|
||||
}
|
||||
|
||||
return models.ImageTaskDetail{
|
||||
Task: models.ImageTask{
|
||||
ID: taskID,
|
||||
SourcePath: sourcePath,
|
||||
Status: imageTaskStatusSucceeded,
|
||||
Operation: operation,
|
||||
DurationMs: 1200,
|
||||
ErrorMessage: "",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
Result: models.ImageTaskResult{
|
||||
ID: resultID,
|
||||
TaskID: taskID,
|
||||
OutputPath: outputPath,
|
||||
BeforeSize: beforeSize,
|
||||
AfterSize: afterSize,
|
||||
CreatedAt: now,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ImageOptimizationService) ListTasks(filter models.ImageTaskFilter) ([]models.ImageTask, error) {
|
||||
limit := filter.Limit
|
||||
if limit <= 0 {
|
||||
limit = defaultTaskLimit
|
||||
}
|
||||
if limit > maxTaskLimit {
|
||||
limit = maxTaskLimit
|
||||
}
|
||||
offset := filter.Offset
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
query := strings.Builder{}
|
||||
query.WriteString(`
|
||||
SELECT id, source_path, status, operation, duration_ms, error_message, created_at, updated_at
|
||||
FROM image_tasks
|
||||
WHERE 1 = 1
|
||||
`)
|
||||
|
||||
args := make([]any, 0, 4)
|
||||
if strings.TrimSpace(filter.Status) != "" {
|
||||
query.WriteString(" AND status = ?")
|
||||
args = append(args, strings.TrimSpace(filter.Status))
|
||||
}
|
||||
if strings.TrimSpace(filter.Operation) != "" {
|
||||
query.WriteString(" AND operation = ?")
|
||||
args = append(args, strings.TrimSpace(filter.Operation))
|
||||
}
|
||||
query.WriteString(" ORDER BY id DESC LIMIT ? OFFSET ?")
|
||||
args = append(args, limit, offset)
|
||||
|
||||
rows, err := s.store.DB.Query(query.String(), args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
tasks := make([]models.ImageTask, 0)
|
||||
for rows.Next() {
|
||||
task, err := scanImageTask(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tasks = append(tasks, task)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tasks, nil
|
||||
}
|
||||
|
||||
func (s *ImageOptimizationService) GetTask(id int64) (models.ImageTaskDetail, error) {
|
||||
row := s.store.DB.QueryRow(`
|
||||
SELECT
|
||||
t.id, t.source_path, t.status, t.operation, t.duration_ms, t.error_message, t.created_at, t.updated_at,
|
||||
r.id, r.task_id, r.output_path, r.before_size, r.after_size, r.created_at
|
||||
FROM image_tasks t
|
||||
LEFT JOIN image_task_results r ON r.task_id = t.id
|
||||
WHERE t.id = ?
|
||||
ORDER BY r.id DESC
|
||||
LIMIT 1
|
||||
`, id)
|
||||
|
||||
detail, err := scanImageTaskDetail(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return models.ImageTaskDetail{}, errImageTaskNotFound
|
||||
}
|
||||
return detail, err
|
||||
}
|
||||
|
||||
func (s *ImageOptimizationService) CancelTask(id int64) error {
|
||||
result, err := s.store.DB.Exec(`
|
||||
UPDATE image_tasks
|
||||
SET status = ?, updated_at = ?
|
||||
WHERE id = ? AND status IN ('pending', 'processing')
|
||||
`, imageTaskStatusCanceled, time.Now().UTC().Format(time.RFC3339), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected == 0 {
|
||||
return errImageTaskCannotCancel
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type rowScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanImageTask(scanner rowScanner) (models.ImageTask, error) {
|
||||
var task models.ImageTask
|
||||
err := scanner.Scan(
|
||||
&task.ID,
|
||||
&task.SourcePath,
|
||||
&task.Status,
|
||||
&task.Operation,
|
||||
&task.DurationMs,
|
||||
&task.ErrorMessage,
|
||||
&task.CreatedAt,
|
||||
&task.UpdatedAt,
|
||||
)
|
||||
return task, err
|
||||
}
|
||||
|
||||
func scanImageTaskDetail(scanner rowScanner) (models.ImageTaskDetail, error) {
|
||||
var detail models.ImageTaskDetail
|
||||
err := scanner.Scan(
|
||||
&detail.Task.ID,
|
||||
&detail.Task.SourcePath,
|
||||
&detail.Task.Status,
|
||||
&detail.Task.Operation,
|
||||
&detail.Task.DurationMs,
|
||||
&detail.Task.ErrorMessage,
|
||||
&detail.Task.CreatedAt,
|
||||
&detail.Task.UpdatedAt,
|
||||
&detail.Result.ID,
|
||||
&detail.Result.TaskID,
|
||||
&detail.Result.OutputPath,
|
||||
&detail.Result.BeforeSize,
|
||||
&detail.Result.AfterSize,
|
||||
&detail.Result.CreatedAt,
|
||||
)
|
||||
return detail, err
|
||||
}
|
||||
|
||||
func fileSizeOrZero(path string) int64 {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || info.IsDir() {
|
||||
return 0
|
||||
}
|
||||
return info.Size()
|
||||
}
|
||||
|
||||
func simulatedAfterSize(beforeSize int64) int64 {
|
||||
if beforeSize <= 0 {
|
||||
return 0
|
||||
}
|
||||
return beforeSize * 82 / 100
|
||||
}
|
||||
|
||||
func simulatedOutputPath(sourcePath string, operation string) string {
|
||||
dir := filepath.Dir(sourcePath)
|
||||
ext := filepath.Ext(sourcePath)
|
||||
name := strings.TrimSuffix(filepath.Base(sourcePath), ext)
|
||||
if name == "" || name == "." {
|
||||
name = "image"
|
||||
}
|
||||
if ext == "" {
|
||||
ext = ".jpg"
|
||||
}
|
||||
return filepath.Join(dir, fmt.Sprintf("%s_%s_optimized%s", name, operation, ext))
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cmbone/internal/models"
|
||||
)
|
||||
|
||||
func TestMigrateCreatesImageTaskTables(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
|
||||
for _, table := range []string{"image_tasks", "image_task_results"} {
|
||||
var name string
|
||||
if err := store.DB.QueryRow("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", table).Scan(&name); err != nil {
|
||||
t.Fatalf("query table %s: %v", table, err)
|
||||
}
|
||||
if name != table {
|
||||
t.Fatalf("table = %q, want %q", name, table)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageOptimizationServiceCreateTask(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
service := NewImageOptimizationService(store)
|
||||
sourcePath := filepath.Join(t.TempDir(), "sku-main.jpg")
|
||||
|
||||
detail, err := service.CreateTask(models.ImageTaskInput{
|
||||
SourcePath: sourcePath,
|
||||
Operation: "enhance",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create task: %v", err)
|
||||
}
|
||||
|
||||
if detail.Task.ID == 0 {
|
||||
t.Fatal("task id is empty")
|
||||
}
|
||||
if detail.Task.Status != imageTaskStatusSucceeded {
|
||||
t.Fatalf("status = %q, want %q", detail.Task.Status, imageTaskStatusSucceeded)
|
||||
}
|
||||
if detail.Task.Operation != "enhance" {
|
||||
t.Fatalf("operation = %q, want enhance", detail.Task.Operation)
|
||||
}
|
||||
if detail.Result.TaskID != detail.Task.ID {
|
||||
t.Fatalf("result task id = %d, want %d", detail.Result.TaskID, detail.Task.ID)
|
||||
}
|
||||
if !strings.Contains(detail.Result.OutputPath, "sku-main_enhance_optimized.jpg") {
|
||||
t.Fatalf("output path = %q, want simulated optimized path", detail.Result.OutputPath)
|
||||
}
|
||||
|
||||
got, err := service.GetTask(detail.Task.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get task: %v", err)
|
||||
}
|
||||
if got.Task.ID != detail.Task.ID {
|
||||
t.Fatalf("got task id = %d, want %d", got.Task.ID, detail.Task.ID)
|
||||
}
|
||||
|
||||
tasks, err := service.ListTasks(models.ImageTaskFilter{Status: imageTaskStatusSucceeded})
|
||||
if err != nil {
|
||||
t.Fatalf("list tasks: %v", err)
|
||||
}
|
||||
if len(tasks) != 1 {
|
||||
t.Fatalf("tasks len = %d, want 1", len(tasks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageOptimizationServiceCreateTaskRequiresSourcePath(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
service := NewImageOptimizationService(store)
|
||||
|
||||
_, err := service.CreateTask(models.ImageTaskInput{})
|
||||
if !errors.Is(err, errImageTaskSourcePathRequired) {
|
||||
t.Fatalf("create task error = %v, want source path required", err)
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,30 @@ func Migrate(db *sql.DB) error {
|
||||
detail TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS image_tasks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source_path TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
operation TEXT NOT NULL,
|
||||
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||||
error_message TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS image_task_results (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id INTEGER NOT NULL,
|
||||
output_path TEXT NOT NULL,
|
||||
before_size INTEGER NOT NULL DEFAULT 0,
|
||||
after_size INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY(task_id) REFERENCES image_tasks(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_image_tasks_status_created_at ON image_tasks(status, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_image_task_results_task_id ON image_task_results(task_id);
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
Reference in New Issue
Block a user