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))
|
||||
}
|
||||
Reference in New Issue
Block a user