Files
sub2api/backend/internal/service/image_task.go
T

211 lines
6.6 KiB
Go

package service
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/google/uuid"
)
const (
ImageTaskStatusProcessing = "processing"
ImageTaskStatusCompleted = "completed"
ImageTaskStatusFailed = "failed"
defaultImageTaskTTL = 24 * time.Hour
defaultImageTaskExecutionTimeout = 30 * time.Minute
)
var (
ErrImageTaskNotFound = infraerrors.New(http.StatusNotFound, "IMAGE_TASK_NOT_FOUND", "image task not found")
ErrImageTaskForbidden = infraerrors.New(http.StatusForbidden, "IMAGE_TASK_FORBIDDEN", "image task does not belong to this API key")
ErrImageTaskUnavailable = infraerrors.New(http.StatusServiceUnavailable, "IMAGE_TASK_UNAVAILABLE", "image task storage is unavailable")
)
// ImageTaskRecord is the private Redis representation of an asynchronous image
// request. Ownership fields are intentionally omitted from the public view.
type ImageTaskRecord struct {
ID string `json:"id"`
UserID int64 `json:"user_id"`
APIKeyID int64 `json:"api_key_id"`
Status string `json:"status"`
HTTPStatus int `json:"http_status,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
Error json.RawMessage `json:"error,omitempty"`
CreatedAt int64 `json:"created_at"`
CompletedAt *int64 `json:"completed_at,omitempty"`
ExpiresAt int64 `json:"expires_at"`
}
// ImageTask is the API-safe task representation returned to callers.
type ImageTask struct {
ID string `json:"id"`
TaskID string `json:"task_id"`
Object string `json:"object"`
Status string `json:"status"`
HTTPStatus int `json:"http_status,omitempty"`
ImageURL string `json:"image_url,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
Error json.RawMessage `json:"error,omitempty"`
CreatedAt int64 `json:"created_at"`
CompletedAt *int64 `json:"completed_at,omitempty"`
ExpiresAt int64 `json:"expires_at"`
}
type ImageTaskOwner struct {
UserID int64
APIKeyID int64
}
type ImageTaskStore interface {
Save(ctx context.Context, task *ImageTaskRecord, ttl time.Duration) error
Get(ctx context.Context, id string) (*ImageTaskRecord, error)
}
type ImageTaskService struct {
store ImageTaskStore
ttl time.Duration
executionTimeout time.Duration
}
func NewImageTaskService(store ImageTaskStore) *ImageTaskService {
return NewImageTaskServiceWithOptions(store, defaultImageTaskTTL, defaultImageTaskExecutionTimeout)
}
func NewImageTaskServiceWithOptions(store ImageTaskStore, ttl, executionTimeout time.Duration) *ImageTaskService {
if ttl <= 0 {
ttl = defaultImageTaskTTL
}
if executionTimeout <= 0 {
executionTimeout = defaultImageTaskExecutionTimeout
}
return &ImageTaskService{store: store, ttl: ttl, executionTimeout: executionTimeout}
}
func (s *ImageTaskService) ExecutionTimeout() time.Duration {
if s == nil || s.executionTimeout <= 0 {
return defaultImageTaskExecutionTimeout
}
return s.executionTimeout
}
func (s *ImageTaskService) Create(ctx context.Context, owner ImageTaskOwner) (*ImageTask, error) {
if s == nil || s.store == nil {
return nil, ErrImageTaskUnavailable
}
now := time.Now().UTC()
task := &ImageTaskRecord{
ID: "imgtask_" + strings.ReplaceAll(uuid.NewString(), "-", ""),
UserID: owner.UserID,
APIKeyID: owner.APIKeyID,
Status: ImageTaskStatusProcessing,
CreatedAt: now.Unix(),
ExpiresAt: now.Add(s.ttl).Unix(),
}
if err := s.store.Save(ctx, task, s.ttl); err != nil {
return nil, ErrImageTaskUnavailable.WithCause(err)
}
return imageTaskToPublic(task), nil
}
func (s *ImageTaskService) Get(ctx context.Context, owner ImageTaskOwner, id string) (*ImageTask, error) {
if s == nil || s.store == nil {
return nil, ErrImageTaskUnavailable
}
task, err := s.store.Get(ctx, strings.TrimSpace(id))
if err != nil {
if errors.Is(err, ErrImageTaskNotFound) {
return nil, ErrImageTaskNotFound
}
return nil, ErrImageTaskUnavailable.WithCause(err)
}
if task.UserID != owner.UserID || task.APIKeyID != owner.APIKeyID {
// Do not reveal whether a random task ID exists for another caller.
return nil, ErrImageTaskNotFound
}
return imageTaskToPublic(task), nil
}
func (s *ImageTaskService) Complete(ctx context.Context, id string, statusCode int, result json.RawMessage) error {
if !json.Valid(result) {
return s.Fail(ctx, id, http.StatusBadGateway, imageTaskErrorJSON("api_error", "upstream returned a non-JSON image response"))
}
return s.finish(ctx, id, ImageTaskStatusCompleted, statusCode, result, nil)
}
func (s *ImageTaskService) Fail(ctx context.Context, id string, statusCode int, taskErr json.RawMessage) error {
if !json.Valid(taskErr) {
taskErr = imageTaskErrorJSON("api_error", "image generation failed")
}
return s.finish(ctx, id, ImageTaskStatusFailed, statusCode, nil, taskErr)
}
func (s *ImageTaskService) finish(ctx context.Context, id, status string, statusCode int, result, taskErr json.RawMessage) error {
if s == nil || s.store == nil {
return ErrImageTaskUnavailable
}
task, err := s.store.Get(ctx, id)
if err != nil {
if errors.Is(err, ErrImageTaskNotFound) {
return ErrImageTaskNotFound
}
return ErrImageTaskUnavailable.WithCause(err)
}
now := time.Now().UTC()
completedAt := now.Unix()
task.Status = status
task.HTTPStatus = statusCode
task.Result = result
task.Error = taskErr
task.CompletedAt = &completedAt
task.ExpiresAt = now.Add(s.ttl).Unix()
if err := s.store.Save(ctx, task, s.ttl); err != nil {
return ErrImageTaskUnavailable.WithCause(err)
}
return nil
}
func imageTaskToPublic(task *ImageTaskRecord) *ImageTask {
if task == nil {
return nil
}
return &ImageTask{
ID: task.ID,
TaskID: task.ID,
Object: "image.generation.task",
Status: task.Status,
HTTPStatus: task.HTTPStatus,
ImageURL: firstImageTaskURL(task.Result),
Result: task.Result,
Error: task.Error,
CreatedAt: task.CreatedAt,
CompletedAt: task.CompletedAt,
ExpiresAt: task.ExpiresAt,
}
}
func firstImageTaskURL(result json.RawMessage) string {
if len(result) == 0 || !json.Valid(result) {
return ""
}
var response struct {
Data []struct {
URL string `json:"url"`
} `json:"data"`
}
if json.Unmarshal(result, &response) != nil || len(response.Data) == 0 {
return ""
}
return strings.TrimSpace(response.Data[0].URL)
}
func imageTaskErrorJSON(errorType, message string) json.RawMessage {
data, _ := json.Marshal(map[string]string{"type": errorType, "message": message})
return data
}