feat: add image optimization task model

This commit is contained in:
QiuSW
2026-07-09 00:47:35 +08:00
parent 7942add8b6
commit 3bd05b316e
16 changed files with 827 additions and 34 deletions
+38
View File
@@ -0,0 +1,38 @@
package models
type ImageTaskInput struct {
SourcePath string `json:"sourcePath"`
Operation string `json:"operation"`
}
type ImageTaskFilter struct {
Status string `json:"status"`
Operation string `json:"operation"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
type ImageTask struct {
ID int64 `json:"id"`
SourcePath string `json:"sourcePath"`
Status string `json:"status"`
Operation string `json:"operation"`
DurationMs int64 `json:"durationMs"`
ErrorMessage string `json:"errorMessage"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
type ImageTaskResult struct {
ID int64 `json:"id"`
TaskID int64 `json:"taskId"`
OutputPath string `json:"outputPath"`
BeforeSize int64 `json:"beforeSize"`
AfterSize int64 `json:"afterSize"`
CreatedAt string `json:"createdAt"`
}
type ImageTaskDetail struct {
Task ImageTask `json:"task"`
Result ImageTaskResult `json:"result"`
}
@@ -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)
}
}
+24
View File
@@ -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