feat: complete remaining Go backend modules
This commit is contained in:
1 parent
cea2751dc5
commit
aef5a97165
145 files changed
+18376
-199
No files matched your search
@@ -0,0 +1,250 @@
|
||||
// Package templates owns user-scoped image-template validation and persistence.
|
||||
package templates
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidTemplate = errors.New("invalid image template")
|
||||
ErrNotFound = errors.New("image template not found")
|
||||
)
|
||||
|
||||
type Settings struct {
|
||||
Engine string `json:"engine,omitempty"`
|
||||
Width int `json:"width,omitempty"`
|
||||
Height int `json:"height,omitempty"`
|
||||
ForceSingle *bool `json:"forceSingle,omitempty"`
|
||||
Scale float64 `json:"scale,omitempty"`
|
||||
Quality string `json:"quality,omitempty"`
|
||||
}
|
||||
|
||||
type Template struct {
|
||||
ID string `json:"id"`
|
||||
OwnerID string `json:"ownerId"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Prompt string `json:"prompt"`
|
||||
PreviewImageURL string `json:"previewImageUrl,omitempty"`
|
||||
Settings Settings `json:"settings"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type CreateCommand struct {
|
||||
Name, Description, Prompt, PreviewImageURL string
|
||||
Settings Settings
|
||||
SortOrder int
|
||||
}
|
||||
|
||||
type UpdateCommand struct {
|
||||
Name, Description, Prompt, PreviewImageURL *string
|
||||
Settings *Settings
|
||||
SortOrder *int
|
||||
}
|
||||
|
||||
type Patch = UpdateCommand
|
||||
|
||||
type Catalog interface {
|
||||
ListTemplates(context.Context, string) ([]Template, error)
|
||||
CreateTemplate(context.Context, Template) (Template, error)
|
||||
UpdateTemplate(context.Context, string, string, Patch, time.Time) (Template, bool, error)
|
||||
DeleteTemplate(context.Context, string, string) (Template, bool, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
catalog Catalog
|
||||
now func() time.Time
|
||||
newID func() string
|
||||
}
|
||||
|
||||
func NewService(catalog Catalog, now func() time.Time, newID func() string) *Service {
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
if newID == nil {
|
||||
newID = templateID
|
||||
}
|
||||
return &Service{catalog: catalog, now: now, newID: newID}
|
||||
}
|
||||
|
||||
func (service *Service) List(ctx context.Context, owner string) ([]Template, error) {
|
||||
if strings.TrimSpace(owner) == "" {
|
||||
return nil, fmt.Errorf("%w: owner is required", ErrInvalidTemplate)
|
||||
}
|
||||
items, err := service.catalog.ListTemplates(ctx, owner)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list image templates: %w", err)
|
||||
}
|
||||
if items == nil {
|
||||
items = []Template{}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (service *Service) Create(ctx context.Context, owner string, command CreateCommand) (Template, error) {
|
||||
owner = strings.TrimSpace(owner)
|
||||
if owner == "" {
|
||||
return Template{}, fmt.Errorf("%w: owner is required", ErrInvalidTemplate)
|
||||
}
|
||||
name, err := required(command.Name, 80, "模板名称")
|
||||
if err != nil {
|
||||
return Template{}, err
|
||||
}
|
||||
prompt, err := required(command.Prompt, 4000, "预设提示词")
|
||||
if err != nil {
|
||||
return Template{}, err
|
||||
}
|
||||
preview, err := previewURL(command.PreviewImageURL)
|
||||
if err != nil {
|
||||
return Template{}, err
|
||||
}
|
||||
settings := normalizeSettings(command.Settings)
|
||||
now := service.now().UTC()
|
||||
item := Template{ID: service.newID(), OwnerID: owner, Name: name, Description: optional(command.Description, 240), Prompt: prompt, PreviewImageURL: preview, Settings: settings, SortOrder: command.SortOrder, CreatedAt: now, UpdatedAt: now}
|
||||
created, err := service.catalog.CreateTemplate(ctx, item)
|
||||
if err != nil {
|
||||
return Template{}, fmt.Errorf("create image template: %w", err)
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
|
||||
func (service *Service) Update(ctx context.Context, owner, id string, command UpdateCommand) (Template, error) {
|
||||
owner, id = strings.TrimSpace(owner), strings.TrimSpace(id)
|
||||
if owner == "" || id == "" {
|
||||
return Template{}, ErrNotFound
|
||||
}
|
||||
patch := Patch{}
|
||||
var err error
|
||||
if command.Name != nil {
|
||||
value, current := required(*command.Name, 80, "模板名称")
|
||||
err = current
|
||||
patch.Name = &value
|
||||
}
|
||||
if err != nil {
|
||||
return Template{}, err
|
||||
}
|
||||
if command.Prompt != nil {
|
||||
value, current := required(*command.Prompt, 4000, "预设提示词")
|
||||
err = current
|
||||
patch.Prompt = &value
|
||||
}
|
||||
if err != nil {
|
||||
return Template{}, err
|
||||
}
|
||||
if command.Description != nil {
|
||||
value := optional(*command.Description, 240)
|
||||
patch.Description = &value
|
||||
}
|
||||
if command.PreviewImageURL != nil {
|
||||
value, current := previewURL(*command.PreviewImageURL)
|
||||
if current != nil {
|
||||
return Template{}, current
|
||||
}
|
||||
patch.PreviewImageURL = &value
|
||||
}
|
||||
if command.Settings != nil {
|
||||
value := normalizeSettings(*command.Settings)
|
||||
patch.Settings = &value
|
||||
}
|
||||
patch.SortOrder = command.SortOrder
|
||||
item, found, err := service.catalog.UpdateTemplate(ctx, owner, id, patch, service.now().UTC())
|
||||
if err != nil {
|
||||
return Template{}, fmt.Errorf("update image template: %w", err)
|
||||
}
|
||||
if !found {
|
||||
return Template{}, ErrNotFound
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (service *Service) Delete(ctx context.Context, owner, id string) (Template, error) {
|
||||
item, found, err := service.catalog.DeleteTemplate(ctx, strings.TrimSpace(owner), strings.TrimSpace(id))
|
||||
if err != nil {
|
||||
return Template{}, fmt.Errorf("delete image template: %w", err)
|
||||
}
|
||||
if !found {
|
||||
return Template{}, ErrNotFound
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func applyPatch(item *Template, patch Patch) {
|
||||
if patch.Name != nil {
|
||||
item.Name = *patch.Name
|
||||
}
|
||||
if patch.Description != nil {
|
||||
item.Description = *patch.Description
|
||||
}
|
||||
if patch.Prompt != nil {
|
||||
item.Prompt = *patch.Prompt
|
||||
}
|
||||
if patch.PreviewImageURL != nil {
|
||||
item.PreviewImageURL = *patch.PreviewImageURL
|
||||
}
|
||||
if patch.Settings != nil {
|
||||
item.Settings = *patch.Settings
|
||||
}
|
||||
if patch.SortOrder != nil {
|
||||
item.SortOrder = *patch.SortOrder
|
||||
}
|
||||
}
|
||||
|
||||
func required(value string, limit int, label string) (string, error) {
|
||||
value = optional(value, limit)
|
||||
if value == "" {
|
||||
return "", fmt.Errorf("%w: %s不能为空", ErrInvalidTemplate, label)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
func optional(value string, limit int) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if len([]rune(value)) > limit {
|
||||
value = string([]rune(value)[:limit])
|
||||
}
|
||||
return value
|
||||
}
|
||||
func previewURL(value string) (string, error) {
|
||||
value = optional(value, 1000)
|
||||
if value == "" || strings.HasPrefix(value, "/") {
|
||||
return value, nil
|
||||
}
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
|
||||
return "", fmt.Errorf("%w: 效果预览图地址必须是 http(s) 或站内路径", ErrInvalidTemplate)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
func normalizeSettings(value Settings) Settings {
|
||||
if value.Engine != "jimeng" && value.Engine != "evolink" && value.Engine != "bailian" {
|
||||
value.Engine = ""
|
||||
}
|
||||
if value.Width < 1 || value.Height < 1 {
|
||||
value.Width, value.Height = 0, 0
|
||||
} else {
|
||||
value.Width, value.Height = min(value.Width, 8192), min(value.Height, 8192)
|
||||
}
|
||||
if math.IsNaN(value.Scale) || math.IsInf(value.Scale, 0) {
|
||||
value.Scale = 0
|
||||
} else if value.Scale != 0 {
|
||||
value.Scale = math.Max(1, math.Min(100, math.Trunc(value.Scale)))
|
||||
}
|
||||
if value.Quality != "low" && value.Quality != "medium" && value.Quality != "high" {
|
||||
value.Quality = ""
|
||||
}
|
||||
return value
|
||||
}
|
||||
func templateID() string {
|
||||
var raw [9]byte
|
||||
_, _ = rand.Read(raw[:])
|
||||
return "tmpl_" + hex.EncodeToString(raw[:])
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type memoryCatalog struct {
|
||||
items []Template
|
||||
err error
|
||||
}
|
||||
|
||||
func (catalog *memoryCatalog) ListTemplates(_ context.Context, owner string) ([]Template, error) {
|
||||
if catalog.err != nil {
|
||||
return nil, catalog.err
|
||||
}
|
||||
out := []Template{}
|
||||
for _, item := range catalog.items {
|
||||
if item.OwnerID == owner {
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (catalog *memoryCatalog) CreateTemplate(_ context.Context, item Template) (Template, error) {
|
||||
if catalog.err != nil {
|
||||
return Template{}, catalog.err
|
||||
}
|
||||
catalog.items = append(catalog.items, item)
|
||||
return item, nil
|
||||
}
|
||||
func (catalog *memoryCatalog) UpdateTemplate(_ context.Context, owner, id string, patch Patch, now time.Time) (Template, bool, error) {
|
||||
if catalog.err != nil {
|
||||
return Template{}, false, catalog.err
|
||||
}
|
||||
for index := range catalog.items {
|
||||
if catalog.items[index].OwnerID != owner || catalog.items[index].ID != id {
|
||||
continue
|
||||
}
|
||||
applyPatch(&catalog.items[index], patch)
|
||||
catalog.items[index].UpdatedAt = now
|
||||
return catalog.items[index], true, nil
|
||||
}
|
||||
return Template{}, false, nil
|
||||
}
|
||||
func (catalog *memoryCatalog) DeleteTemplate(_ context.Context, owner, id string) (Template, bool, error) {
|
||||
if catalog.err != nil {
|
||||
return Template{}, false, catalog.err
|
||||
}
|
||||
for index, item := range catalog.items {
|
||||
if item.OwnerID != owner || item.ID != id {
|
||||
continue
|
||||
}
|
||||
catalog.items = append(catalog.items[:index], catalog.items[index+1:]...)
|
||||
return item, true, nil
|
||||
}
|
||||
return Template{}, false, nil
|
||||
}
|
||||
|
||||
func TestServiceNormalizesAndScopesTemplates(t *testing.T) {
|
||||
now := time.Date(2026, 8, 13, 12, 0, 0, 0, time.UTC)
|
||||
catalog := &memoryCatalog{}
|
||||
service := NewService(catalog, func() time.Time { return now }, func() string { return "tmpl_fixed" })
|
||||
created, err := service.Create(context.Background(), "owner-a", CreateCommand{
|
||||
Name: " Campaign ", Description: " ", Prompt: " make it vivid ",
|
||||
PreviewImageURL: "/uploads/preview.png", SortOrder: 2,
|
||||
Settings: Settings{Engine: "jimeng", Width: 2048, Height: 2048, Scale: 50},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if created.ID != "tmpl_fixed" || created.OwnerID != "owner-a" || created.Name != "Campaign" || created.Prompt != "make it vivid" {
|
||||
t.Fatalf("unexpected template: %#v", created)
|
||||
}
|
||||
if created.Description != "" || created.CreatedAt != now || created.UpdatedAt != now {
|
||||
t.Fatalf("unexpected normalized values: %#v", created)
|
||||
}
|
||||
if _, err := service.Create(context.Background(), "owner-a", CreateCommand{Name: "x", Prompt: "p", PreviewImageURL: "ftp://bad"}); !errors.Is(err, ErrInvalidTemplate) {
|
||||
t.Fatalf("expected invalid preview URL, got %v", err)
|
||||
}
|
||||
items, err := service.List(context.Background(), "owner-b")
|
||||
if err != nil || len(items) != 0 {
|
||||
t.Fatalf("scope leak: %#v %v", items, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceUpdateDeleteAndNotFound(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
catalog := &memoryCatalog{items: []Template{{ID: "t1", OwnerID: "owner", Name: "old", Prompt: "p", Settings: Settings{}, CreatedAt: now, UpdatedAt: now}}}
|
||||
service := NewService(catalog, func() time.Time { return now.Add(time.Hour) }, nil)
|
||||
name := " new "
|
||||
updated, err := service.Update(context.Background(), "owner", "t1", UpdateCommand{Name: &name})
|
||||
if err != nil || updated.Name != "new" {
|
||||
t.Fatalf("update: %#v %v", updated, err)
|
||||
}
|
||||
if _, err := service.Update(context.Background(), "other", "t1", UpdateCommand{Name: &name}); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("expected scoped not found, got %v", err)
|
||||
}
|
||||
deleted, err := service.Delete(context.Background(), "owner", "t1")
|
||||
if err != nil || deleted.ID != "t1" {
|
||||
t.Fatalf("delete: %#v %v", deleted, err)
|
||||
}
|
||||
if _, err := service.Delete(context.Background(), "owner", "t1"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("expected not found, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServicePropagatesInfrastructureFailures(t *testing.T) {
|
||||
want := errors.New("database unavailable")
|
||||
service := NewService(&memoryCatalog{err: want}, nil, nil)
|
||||
if _, err := service.List(context.Background(), "owner"); !errors.Is(err, want) {
|
||||
t.Fatalf("expected wrapped cause, got %v", err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user