feat(*): 生图与视频工作台 - 新增独立生成流程并整合平台配置

This commit is contained in:
ddcat666 committed 2026-10-13 09:04:34 +08:00
1 parent 4c1d2b4012
commit 1f3f2c6020
69 files changed
+7108 -87

No files matched your search

+182
View File
@@ -0,0 +1,182 @@
package workbench
import (
"encoding/json"
"fmt"
"strings"
)
// InputContract describes the media and text inputs a workbench tool expects.
// It is stored inside capability JSON so the catalog and task admission share one contract.
type InputContract struct {
PromptRequired bool `json:"promptRequired"`
MinTextReferences int `json:"minTextReferences"`
Slots []ReferenceSlot `json:"slots"`
}
type ReferenceSlot struct {
Key string `json:"key"`
Name string `json:"name"`
MediaTypes []string `json:"mediaTypes"`
ResourceKind string `json:"resourceKind"`
MaxCount int `json:"maxCount"`
Required bool `json:"required"`
MinCount int `json:"minCount"`
CoverResourceID string `json:"coverResourceId,omitempty"`
SampleResourceID string `json:"sampleResourceId,omitempty"`
}
type ResultExample struct {
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
ImageResourceIDs []string `json:"imageResourceIds,omitempty"`
VideoResourceID string `json:"videoResourceId,omitempty"`
}
type catalogConfig struct {
InputContract InputContract `json:"inputContract"`
SearchKeywords []string `json:"searchKeywords,omitempty"`
Surfaces []string `json:"surfaces,omitempty"`
Recommended bool `json:"recommended"`
RecommendationOrder int `json:"recommendationOrder"`
InputHint string `json:"inputHint,omitempty"`
ResultExample ResultExample `json:"resultExample,omitempty"`
}
func parseCatalogConfig(capabilityJSON, guideJSON string) (catalogConfig, error) {
config := catalogConfig{InputContract: InputContract{PromptRequired: true, Slots: []ReferenceSlot{}}}
capability := map[string]json.RawMessage{}
if strings.TrimSpace(capabilityJSON) != "" && strings.TrimSpace(capabilityJSON) != "{}" {
if err := json.Unmarshal([]byte(capabilityJSON), &capability); err != nil {
return config, fmt.Errorf("capability: %w", err)
}
}
if raw, ok := capability["inputContract"]; ok {
if err := json.Unmarshal(raw, &config.InputContract); err != nil {
return config, fmt.Errorf("inputContract: %w", err)
}
}
if raw, ok := capability["searchKeywords"]; ok {
_ = json.Unmarshal(raw, &config.SearchKeywords)
}
if raw, ok := capability["surfaces"]; ok {
_ = json.Unmarshal(raw, &config.Surfaces)
}
if raw, ok := capability["recommended"]; ok {
_ = json.Unmarshal(raw, &config.Recommended)
}
if raw, ok := capability["recommendationOrder"]; ok {
_ = json.Unmarshal(raw, &config.RecommendationOrder)
}
guide := map[string]json.RawMessage{}
if strings.TrimSpace(guideJSON) != "" && strings.TrimSpace(guideJSON) != "{}" {
if err := json.Unmarshal([]byte(guideJSON), &guide); err != nil {
return config, fmt.Errorf("guide: %w", err)
}
}
if raw, ok := guide["inputHint"]; ok {
_ = json.Unmarshal(raw, &config.InputHint)
}
if raw, ok := guide["resultExample"]; ok {
if err := json.Unmarshal(raw, &config.ResultExample); err != nil {
return config, fmt.Errorf("resultExample: %w", err)
}
}
return config, nil
}
func normalizeCatalogConfig(config catalogConfig) (catalogConfig, error) {
if config.InputContract.MinTextReferences < 0 || config.InputContract.MinTextReferences > 20 {
return config, fmt.Errorf("文本参考数量无效")
}
if len(config.InputContract.Slots) > 20 {
return config, fmt.Errorf("输入素材槽位不能超过 20 个")
}
seen := map[string]bool{}
for index := range config.InputContract.Slots {
slot := &config.InputContract.Slots[index]
slot.Key = strings.TrimSpace(slot.Key)
slot.Name = strings.TrimSpace(slot.Name)
slot.ResourceKind = strings.TrimSpace(slot.ResourceKind)
if slot.ResourceKind == "" {
slot.ResourceKind = "asset"
}
if slot.Key == "" || slot.Name == "" || seen[slot.Key] {
return config, fmt.Errorf("输入素材槽位名称或标识无效")
}
seen[slot.Key] = true
if len(slot.MediaTypes) == 0 {
return config, fmt.Errorf("输入素材槽位必须指定媒体类型")
}
for mediaIndex, mediaType := range slot.MediaTypes {
mediaType = strings.TrimSpace(mediaType)
if mediaType != "image" && mediaType != "video" && mediaType != "audio" {
return config, fmt.Errorf("输入素材槽位媒体类型无效")
}
slot.MediaTypes[mediaIndex] = mediaType
}
if slot.MaxCount < 1 || slot.MaxCount > 20 || slot.MinCount < 0 || slot.MinCount > slot.MaxCount {
return config, fmt.Errorf("输入素材槽位数量范围无效")
}
if slot.Required && slot.MinCount < 1 {
slot.MinCount = 1
}
slot.CoverResourceID = strings.TrimSpace(slot.CoverResourceID)
slot.SampleResourceID = strings.TrimSpace(slot.SampleResourceID)
}
config.SearchKeywords = cleanStrings(config.SearchKeywords, 20)
config.Surfaces = cleanStrings(config.Surfaces, 8)
config.InputHint = strings.TrimSpace(config.InputHint)
config.ResultExample.Title = strings.TrimSpace(config.ResultExample.Title)
config.ResultExample.Description = strings.TrimSpace(config.ResultExample.Description)
config.ResultExample.ImageResourceIDs = cleanStrings(config.ResultExample.ImageResourceIDs, 8)
config.ResultExample.VideoResourceID = strings.TrimSpace(config.ResultExample.VideoResourceID)
if config.RecommendationOrder < 0 || config.RecommendationOrder > 1_000_000 {
return config, fmt.Errorf("推荐排序值无效")
}
return config, nil
}
func validCategoryID(categoryID string) bool {
switch categoryID {
case "recommended", "people", "ecommerce", "product", "drama-character", "drama-scene", "storyboard", "short-video", "scene", "enhancement":
return true
default:
return false
}
}
func cleanStrings(values []string, limit int) []string {
seen := map[string]bool{}
result := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" || seen[value] || len(result) >= limit {
continue
}
seen[value] = true
result = append(result, value)
}
return result
}
func catalogResourceIDs(config catalogConfig) []struct{ id, kind string } {
result := make([]struct{ id, kind string }, 0)
seen := map[string]bool{}
appendResource := func(id, kind string) {
id = strings.TrimSpace(id)
if id == "" || seen[id] {
return
}
seen[id] = true
result = append(result, struct{ id, kind string }{id: id, kind: kind})
}
for _, slot := range config.InputContract.Slots {
appendResource(slot.CoverResourceID, "image")
appendResource(slot.SampleResourceID, "image")
}
for _, id := range config.ResultExample.ImageResourceIDs {
appendResource(id, "image")
}
appendResource(config.ResultExample.VideoResourceID, "video")
return result
}
+603
View File
@@ -0,0 +1,603 @@
package workbench
import (
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"unicode/utf8"
"yingce/backend/internal/kernel"
"yingce/backend/internal/model"
"yingce/backend/internal/repository"
)
const (
KindImage = model.WorkbenchKindImage
KindVideo = model.WorkbenchKindVideo
)
type AdminGate interface {
RequireAdmin(*model.User) error
AppendAudit(*model.User, string, string, string, string, any) error
}
type ResourceAccessIssuer interface {
WorkbenchResourceAccess(resourceID string) (string, error)
}
type Service struct {
repo *repository.Repository
admin AdminGate
access ResourceAccessIssuer
}
func New(repo *repository.Repository, admin AdminGate, access ResourceAccessIssuer) *Service {
return &Service{repo: repo, admin: admin, access: access}
}
type ToolInput struct {
Kind string `json:"kind"`
CategoryID string `json:"categoryId"`
Name string `json:"name"`
Subtitle string `json:"subtitle"`
Description string `json:"description"`
IconResourceID string `json:"iconResourceId"`
CoverResourceID string `json:"coverResourceId"`
SortOrder int `json:"sortOrder"`
Capability json.RawMessage `json:"capability"`
Guide json.RawMessage `json:"guide"`
Enabled bool `json:"enabled"`
Presets *[]ToolPresetInput `json:"presets,omitempty"`
}
// ToolPresetInput allows the editor to save a tool and its presets atomically.
type ToolPresetInput struct {
ID string `json:"id,omitempty"`
PresetInput
}
type PresetInput struct {
ToolID string `json:"toolId"`
Name string `json:"name"`
Summary string `json:"summary"`
PromptTemplate string `json:"promptTemplate"`
DefaultParameters json.RawMessage `json:"defaultParameters"`
SortOrder int `json:"sortOrder"`
Enabled bool `json:"enabled"`
}
type ToolView struct {
ID string `json:"id"`
Kind string `json:"kind"`
CategoryID string `json:"categoryId"`
Name string `json:"name"`
Subtitle string `json:"subtitle"`
Description string `json:"description"`
IconResourceID string `json:"iconResourceId,omitempty"`
CoverResourceID string `json:"coverResourceId,omitempty"`
IconURL string `json:"iconUrl,omitempty"`
CoverURL string `json:"coverUrl,omitempty"`
SortOrder int `json:"sortOrder"`
Capability json.RawMessage `json:"capability,omitempty"`
Guide json.RawMessage `json:"guide,omitempty"`
InputHint string `json:"inputHint,omitempty"`
SearchKeywords []string `json:"searchKeywords,omitempty"`
Surfaces []string `json:"surfaces,omitempty"`
Recommended bool `json:"recommended"`
RecommendationOrder int `json:"recommendationOrder"`
InputContract InputContractView `json:"inputContract"`
ResultExample ResultExampleView `json:"resultExample"`
Enabled bool `json:"enabled"`
Version int `json:"version"`
Presets []PresetView `json:"presets"`
UpdatedAt time.Time `json:"updatedAt"`
}
type InputContractView struct {
PromptRequired bool `json:"promptRequired"`
MinTextReferences int `json:"minTextReferences"`
Slots []ReferenceSlotView `json:"slots"`
}
type ReferenceSlotView struct {
Key string `json:"key"`
Name string `json:"name"`
MediaTypes []string `json:"mediaTypes"`
ResourceKind string `json:"resourceKind"`
MaxCount int `json:"maxCount"`
Required bool `json:"required"`
MinCount int `json:"minCount"`
CoverResourceID string `json:"coverResourceId,omitempty"`
CoverURL string `json:"coverUrl,omitempty"`
SampleResourceID string `json:"sampleResourceId,omitempty"`
SampleURL string `json:"sampleUrl,omitempty"`
}
type ResultExampleView struct {
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
ImageResourceIDs []string `json:"imageResourceIds,omitempty"`
ImageURLs []string `json:"imageUrls,omitempty"`
VideoResourceID string `json:"videoResourceId,omitempty"`
VideoURL string `json:"videoUrl,omitempty"`
}
type PresetView struct {
ID string `json:"id"`
ToolID string `json:"toolId"`
Name string `json:"name"`
Summary string `json:"summary"`
PromptTemplate string `json:"promptTemplate"`
DefaultParameters json.RawMessage `json:"defaultParameters,omitempty"`
SortOrder int `json:"sortOrder"`
Enabled bool `json:"enabled"`
Version int `json:"version"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (s *Service) PublicTools(kind string) ([]ToolView, error) {
if !validKind(kind) {
return nil, kernel.BadAuthRequest("工作台类型无效")
}
tools, err := s.repo.WorkbenchTools(kind, true)
if err != nil {
return nil, err
}
presetsByTool, err := s.repo.WorkbenchEnabledPresetsForTools(toolIDs(tools))
if err != nil {
return nil, err
}
views := make([]ToolView, 0, len(tools))
for _, item := range tools {
view := toolView(item, presetsByTool[item.ID])
if s.access != nil {
if err := s.attachToolMedia(&view); err != nil {
return nil, err
}
}
views = append(views, view)
}
return views, nil
}
func (s *Service) AdminTools(actor *model.User, kind string) ([]ToolView, error) {
if err := s.requireAdmin(actor); err != nil {
return nil, err
}
if kind != "" && !validKind(kind) {
return nil, kernel.BadAuthRequest("工作台类型无效")
}
tools, err := s.repo.WorkbenchTools(kind, false)
if err != nil {
return nil, err
}
views := make([]ToolView, 0, len(tools))
for _, item := range tools {
presets, err := s.repo.WorkbenchPresets(item.ID, false)
if err != nil {
return nil, err
}
view := toolView(item, presets)
view.IconURL, view.CoverURL = "", ""
views = append(views, view)
}
return views, nil
}
func (s *Service) CreateTool(actor *model.User, req ToolInput) (*ToolView, error) {
if err := s.requireAdmin(actor); err != nil {
return nil, err
}
item, err := normalizeTool(req)
if err != nil {
return nil, err
}
if err := s.validateToolResources(actor.ID, item.IconResourceID, item.CoverResourceID, item.CapabilityJSON, item.GuideJSON); err != nil {
return nil, err
}
item.ID, item.CreatedBy, item.UpdatedBy = kernel.NewID(), actor.ID, actor.ID
item.Version, item.CreatedAt, item.UpdatedAt = 1, time.Now(), time.Now()
presets, err := s.normalizeToolPresets(actor.ID, item.ID, req.Presets)
if err != nil {
return nil, err
}
if err := s.repo.SaveWorkbenchToolWithPresets(item, presets, true); err != nil {
return nil, err
}
if err := s.audit(actor, "workbench_tool.create", item.ID, "创建工作台工具", map[string]any{"kind": item.Kind, "enabled": item.Enabled}); err != nil {
return nil, err
}
return s.adminToolView(item)
}
func (s *Service) UpdateTool(actor *model.User, id string, req ToolInput) (*ToolView, error) {
if err := s.requireAdmin(actor); err != nil {
return nil, err
}
before, err := s.repo.WorkbenchTool(id)
if err != nil {
return nil, err
}
item, err := normalizeTool(req)
if err != nil {
return nil, err
}
if item.Kind != before.Kind {
return nil, kernel.BadAuthRequest("工具类型创建后不可更改")
}
if err := s.validateToolResources(actor.ID, item.IconResourceID, item.CoverResourceID, item.CapabilityJSON, item.GuideJSON); err != nil {
return nil, err
}
item.ID, item.CreatedBy, item.CreatedAt = before.ID, before.CreatedBy, before.CreatedAt
item.UpdatedBy, item.UpdatedAt, item.Version = actor.ID, time.Now(), before.Version+1
presets, err := s.normalizeToolPresets(actor.ID, item.ID, req.Presets)
if err != nil {
return nil, err
}
if err := s.repo.SaveWorkbenchToolWithPresets(item, presets, false); err != nil {
return nil, err
}
if err := s.audit(actor, "workbench_tool.update", id, "更新工作台工具", map[string]any{"beforeVersion": before.Version, "version": item.Version, "enabled": item.Enabled}); err != nil {
return nil, err
}
return s.adminToolView(item)
}
func (s *Service) SetToolEnabled(actor *model.User, id string, enabled bool) error {
if err := s.requireAdmin(actor); err != nil {
return err
}
item, err := s.repo.WorkbenchTool(id)
if err != nil {
return err
}
if enabled {
if strings.TrimSpace(item.Name) == "" || !validKind(item.Kind) {
return kernel.BadAuthRequest("工具缺少有效名称或类型")
}
if err := s.validateToolResources(actor.ID, item.IconResourceID, item.CoverResourceID, item.CapabilityJSON, item.GuideJSON); err != nil {
return err
}
}
before := item.Enabled
item.Enabled, item.UpdatedBy, item.UpdatedAt, item.Version = enabled, actor.ID, time.Now(), item.Version+1
if err := s.repo.SaveWorkbenchTool(item); err != nil {
return err
}
return s.audit(actor, "workbench_tool.publish", id, map[bool]string{true: "发布工作台工具", false: "下线工作台工具"}[enabled], map[string]any{"before": before, "enabled": enabled, "version": item.Version})
}
func (s *Service) CreatePreset(actor *model.User, req PresetInput) (*PresetView, error) {
if err := s.requireAdmin(actor); err != nil {
return nil, err
}
tool, err := s.repo.WorkbenchTool(strings.TrimSpace(req.ToolID))
if err != nil {
return nil, err
}
item, err := normalizePreset(req)
if err != nil {
return nil, err
}
item.ID, item.CreatedBy, item.UpdatedBy = kernel.NewID(), actor.ID, actor.ID
item.Version, item.CreatedAt, item.UpdatedAt = 1, time.Now(), time.Now()
if err := s.repo.CreateWorkbenchPreset(item); err != nil {
return nil, err
}
if err := s.audit(actor, "workbench_preset.create", item.ID, "创建工作台提示词预设", map[string]any{"toolId": tool.ID, "enabled": item.Enabled}); err != nil {
return nil, err
}
view := presetView(*item)
return &view, nil
}
func (s *Service) UpdatePreset(actor *model.User, id string, req PresetInput) (*PresetView, error) {
if err := s.requireAdmin(actor); err != nil {
return nil, err
}
before, err := s.repo.WorkbenchPreset(id)
if err != nil {
return nil, err
}
item, err := normalizePreset(req)
if err != nil {
return nil, err
}
if item.ToolID != before.ToolID {
return nil, kernel.BadAuthRequest("预设所属工具不可更改")
}
item.ID, item.CreatedBy, item.CreatedAt = before.ID, before.CreatedBy, before.CreatedAt
item.Version, item.UpdatedBy, item.UpdatedAt = before.Version+1, actor.ID, time.Now()
if err := s.repo.SaveWorkbenchPreset(item); err != nil {
return nil, err
}
if err := s.audit(actor, "workbench_preset.update", id, "更新工作台提示词预设", map[string]any{"beforeVersion": before.Version, "version": item.Version, "enabled": item.Enabled}); err != nil {
return nil, err
}
view := presetView(*item)
return &view, nil
}
func (s *Service) SetPresetEnabled(actor *model.User, id string, enabled bool) error {
if err := s.requireAdmin(actor); err != nil {
return err
}
item, err := s.repo.WorkbenchPreset(id)
if err != nil {
return err
}
if _, err := s.repo.WorkbenchTool(item.ToolID); err != nil {
return err
}
before := item.Enabled
item.Enabled, item.Version, item.UpdatedBy, item.UpdatedAt = enabled, item.Version+1, actor.ID, time.Now()
if err := s.repo.SaveWorkbenchPreset(item); err != nil {
return err
}
return s.audit(actor, "workbench_preset.publish", id, map[bool]string{true: "启用工作台预设", false: "停用工作台预设"}[enabled], map[string]any{"before": before, "enabled": enabled, "version": item.Version})
}
func (s *Service) adminToolView(item *model.WorkbenchTool) (*ToolView, error) {
presets, err := s.repo.WorkbenchPresets(item.ID, false)
if err != nil {
return nil, err
}
view := toolView(*item, presets)
view.IconURL, view.CoverURL = "", ""
return &view, nil
}
func (s *Service) requireAdmin(actor *model.User) error {
if s == nil || s.admin == nil {
return errors.New("工作台管理权限未配置")
}
return s.admin.RequireAdmin(actor)
}
func (s *Service) audit(actor *model.User, action, id, summary string, data any) error {
if s == nil || s.admin == nil {
return errors.New("工作台审计未配置")
}
return s.admin.AppendAudit(actor, action, "workbench", id, summary, data)
}
func (s *Service) normalizeToolPresets(actorID, toolID string, inputs *[]ToolPresetInput) (*[]model.WorkbenchPreset, error) {
if inputs == nil {
return nil, nil
}
if len(*inputs) > 50 {
return nil, kernel.BadAuthRequest("每个工具最多配置 50 条预设")
}
items := make([]model.WorkbenchPreset, 0, len(*inputs))
seen := map[string]bool{}
for _, input := range *inputs {
input.ToolID = toolID
item, err := normalizePreset(input.PresetInput)
if err != nil {
return nil, err
}
item.ID = strings.TrimSpace(input.ID)
if item.ID == "" {
item.ID = kernel.NewID()
} else {
before, err := s.repo.WorkbenchPreset(item.ID)
if err != nil || before.ToolID != toolID {
return nil, kernel.BadAuthRequest("预设不属于当前工具")
}
item.CreatedBy, item.CreatedAt, item.Version = before.CreatedBy, before.CreatedAt, before.Version
}
if seen[item.ID] {
return nil, kernel.BadAuthRequest("预设重复")
}
seen[item.ID] = true
if item.CreatedBy == "" {
item.CreatedBy, item.CreatedAt = actorID, time.Now()
}
item.UpdatedBy, item.UpdatedAt, item.Version = actorID, time.Now(), item.Version+1
items = append(items, *item)
}
return &items, nil
}
func (s *Service) validateToolResources(adminID, iconID, coverID, capabilityJSON, guideJSON string) error {
seen := map[string]bool{}
for _, id := range []string{iconID, coverID} {
id = strings.TrimSpace(id)
if id == "" || seen[id] {
continue
}
seen[id] = true
resource, err := s.repo.ResourceForUser(adminID, id)
if err != nil || resource.Status != model.ResourceStatusReady || resource.Kind != "image" {
return kernel.BadAuthRequest("工具图标和封面必须引用当前管理员已就绪的图片资源")
}
}
config, err := parseCatalogConfig(capabilityJSON, guideJSON)
if err != nil {
return kernel.BadAuthRequest("工具输入配置无效")
}
for _, reference := range catalogResourceIDs(config) {
if seen[reference.id] {
continue
}
seen[reference.id] = true
resource, resourceErr := s.repo.ResourceForUser(adminID, reference.id)
if resourceErr != nil || resource.Status != model.ResourceStatusReady || resource.Kind != reference.kind {
return kernel.BadAuthRequest("工具输入槽位和效果示例必须引用当前管理员已就绪的对应媒体")
}
}
return nil
}
func (s *Service) attachToolMedia(view *ToolView) error {
if s.access == nil {
return nil
}
if view.IconResourceID != "" {
url, err := s.access.WorkbenchResourceAccess(view.IconResourceID)
if err != nil {
return err
}
view.IconURL = url
}
if view.CoverResourceID != "" {
url, err := s.access.WorkbenchResourceAccess(view.CoverResourceID)
if err != nil {
return err
}
view.CoverURL = url
}
for index := range view.InputContract.Slots {
slot := &view.InputContract.Slots[index]
if slot.CoverResourceID != "" {
if url, err := s.access.WorkbenchResourceAccess(slot.CoverResourceID); err == nil {
slot.CoverURL = url
}
}
if slot.SampleResourceID != "" {
if url, err := s.access.WorkbenchResourceAccess(slot.SampleResourceID); err == nil {
slot.SampleURL = url
}
}
}
for _, resourceID := range view.ResultExample.ImageResourceIDs {
if url, err := s.access.WorkbenchResourceAccess(resourceID); err == nil {
view.ResultExample.ImageURLs = append(view.ResultExample.ImageURLs, url)
}
}
if view.ResultExample.VideoResourceID != "" {
if url, err := s.access.WorkbenchResourceAccess(view.ResultExample.VideoResourceID); err == nil {
view.ResultExample.VideoURL = url
}
}
return nil
}
func normalizeTool(req ToolInput) (*model.WorkbenchTool, error) {
item := &model.WorkbenchTool{
Kind: strings.TrimSpace(req.Kind), CategoryID: strings.TrimSpace(req.CategoryID), Name: strings.TrimSpace(req.Name),
Subtitle: strings.TrimSpace(req.Subtitle), Description: strings.TrimSpace(req.Description), IconResourceID: strings.TrimSpace(req.IconResourceID),
CoverResourceID: strings.TrimSpace(req.CoverResourceID), SortOrder: req.SortOrder, Enabled: req.Enabled,
}
if !validKind(item.Kind) {
return nil, kernel.BadAuthRequest("工作台类型只能是 image 或 video")
}
if !validCategoryID(item.CategoryID) {
return nil, kernel.BadAuthRequest("工具分类无效")
}
if item.Name == "" || utf8.RuneCountInString(item.Name) > 120 || utf8.RuneCountInString(item.CategoryID) > 64 || utf8.RuneCountInString(item.Subtitle) > 240 || utf8.RuneCountInString(item.Description) > 12000 {
return nil, kernel.BadAuthRequest("工具名称、分类或说明长度无效")
}
if item.SortOrder < 0 || item.SortOrder > 1_000_000 {
return nil, kernel.BadAuthRequest("工具排序值无效")
}
var err error
item.CapabilityJSON, err = optionalJSONObject(req.Capability)
if err != nil {
return nil, fmt.Errorf("capability: %w", err)
}
item.GuideJSON, err = optionalJSONObject(req.Guide)
if err != nil {
return nil, fmt.Errorf("guide: %w", err)
}
config, err := parseCatalogConfig(item.CapabilityJSON, item.GuideJSON)
if err != nil {
return nil, kernel.BadAuthRequest("工具预设结构无效")
}
config, err = normalizeCatalogConfig(config)
if err != nil {
return nil, kernel.BadAuthRequest(err.Error())
}
capability := map[string]any{}
guide := map[string]any{}
_ = json.Unmarshal([]byte(item.CapabilityJSON), &capability)
_ = json.Unmarshal([]byte(item.GuideJSON), &guide)
capability["inputContract"] = config.InputContract
capability["searchKeywords"], capability["surfaces"] = config.SearchKeywords, config.Surfaces
capability["recommended"], capability["recommendationOrder"] = config.Recommended, config.RecommendationOrder
guide["inputHint"], guide["resultExample"] = config.InputHint, config.ResultExample
encodedCapability, err := json.Marshal(capability)
if err != nil {
return nil, err
}
encodedGuide, err := json.Marshal(guide)
if err != nil {
return nil, err
}
item.CapabilityJSON, item.GuideJSON = string(encodedCapability), string(encodedGuide)
return item, nil
}
func normalizePreset(req PresetInput) (*model.WorkbenchPreset, error) {
item := &model.WorkbenchPreset{
ToolID: strings.TrimSpace(req.ToolID), Name: strings.TrimSpace(req.Name), Summary: strings.TrimSpace(req.Summary),
PromptTemplate: strings.TrimSpace(req.PromptTemplate), SortOrder: req.SortOrder, Enabled: req.Enabled,
}
if item.ToolID == "" || item.Name == "" || item.PromptTemplate == "" || utf8.RuneCountInString(item.Name) > 120 || utf8.RuneCountInString(item.Summary) > 500 || utf8.RuneCountInString(item.PromptTemplate) > 12000 {
return nil, kernel.BadAuthRequest("预设工具、名称、提示词或文本长度无效")
}
if item.SortOrder < 0 || item.SortOrder > 1_000_000 {
return nil, kernel.BadAuthRequest("预设排序值无效")
}
var err error
item.DefaultParamsJSON, err = optionalJSONObject(req.DefaultParameters)
if err != nil {
return nil, fmt.Errorf("defaultParameters: %w", err)
}
return item, nil
}
func optionalJSONObject(raw json.RawMessage) (string, error) {
if len(raw) == 0 || string(raw) == "null" {
return "{}", nil
}
var value map[string]any
if err := json.Unmarshal(raw, &value); err != nil || value == nil {
return "", kernel.BadAuthRequest("字段必须是 JSON 对象")
}
return string(raw), nil
}
func toolIDs(items []model.WorkbenchTool) []string {
ids := make([]string, 0, len(items))
for _, item := range items {
ids = append(ids, item.ID)
}
return ids
}
func validKind(kind string) bool { return kind == KindImage || kind == KindVideo }
func toolView(item model.WorkbenchTool, presets []model.WorkbenchPreset) ToolView {
config, _ := parseCatalogConfig(item.CapabilityJSON, item.GuideJSON)
view := ToolView{
ID: item.ID, Kind: item.Kind, CategoryID: item.CategoryID, Name: item.Name, Subtitle: item.Subtitle, Description: item.Description,
IconResourceID: item.IconResourceID, CoverResourceID: item.CoverResourceID, SortOrder: item.SortOrder,
Capability: json.RawMessage(item.CapabilityJSON), Guide: json.RawMessage(item.GuideJSON), InputHint: config.InputHint,
SearchKeywords: config.SearchKeywords, Surfaces: config.Surfaces, Recommended: config.Recommended, RecommendationOrder: config.RecommendationOrder,
InputContract: inputContractView(config.InputContract), ResultExample: resultExampleView(config.ResultExample), Enabled: item.Enabled, Version: item.Version, UpdatedAt: item.UpdatedAt,
Presets: make([]PresetView, 0, len(presets)),
}
for _, preset := range presets {
view.Presets = append(view.Presets, presetView(preset))
}
return view
}
func inputContractView(contract InputContract) InputContractView {
view := InputContractView{PromptRequired: contract.PromptRequired, MinTextReferences: contract.MinTextReferences, Slots: make([]ReferenceSlotView, 0, len(contract.Slots))}
for _, slot := range contract.Slots {
view.Slots = append(view.Slots, ReferenceSlotView{Key: slot.Key, Name: slot.Name, MediaTypes: slot.MediaTypes, ResourceKind: slot.ResourceKind, MaxCount: slot.MaxCount, Required: slot.Required, MinCount: slot.MinCount, CoverResourceID: slot.CoverResourceID, SampleResourceID: slot.SampleResourceID})
}
return view
}
func resultExampleView(example ResultExample) ResultExampleView {
return ResultExampleView{Title: example.Title, Description: example.Description, ImageResourceIDs: example.ImageResourceIDs, VideoResourceID: example.VideoResourceID}
}
func presetView(item model.WorkbenchPreset) PresetView {
return PresetView{ID: item.ID, ToolID: item.ToolID, Name: item.Name, Summary: item.Summary, PromptTemplate: item.PromptTemplate,
DefaultParameters: json.RawMessage(item.DefaultParamsJSON), SortOrder: item.SortOrder, Enabled: item.Enabled, Version: item.Version, UpdatedAt: item.UpdatedAt}
}
+210
View File
@@ -0,0 +1,210 @@
package workbench
import (
"encoding/json"
"strings"
"testing"
"yingce/backend/internal/model"
"yingce/backend/internal/repository"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
type allowAdmin struct{ audited int }
func (a *allowAdmin) RequireAdmin(user *model.User) error {
if user == nil || user.Role != model.UserRoleAdmin {
return gorm.ErrInvalidData
}
return nil
}
func (a *allowAdmin) AppendAudit(_ *model.User, _, _, _, _ string, _ any) error {
a.audited++
return nil
}
type resourceAccessStub struct{}
func (resourceAccessStub) WorkbenchResourceAccess(id string) (string, error) {
return "/api/public/resources/" + id, nil
}
func newWorkbenchTest(t *testing.T) (*Service, *gorm.DB, *allowAdmin) {
t.Helper()
dsn := "file:workbench-tests-" + strings.ReplaceAll(t.Name(), "/", "_") + "?mode=memory&cache=shared"
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = sqlDB.Close() })
if err := db.AutoMigrate(&model.WorkbenchTool{}, &model.WorkbenchPreset{}, &model.Resource{}); err != nil {
t.Fatal(err)
}
admin := &allowAdmin{}
return New(repository.New(db), admin, resourceAccessStub{}), db, admin
}
func TestWorkbenchAdminCRUDAuditsAndPublishesSafeDirectory(t *testing.T) {
s, db, audit := newWorkbenchTest(t)
admin := &model.User{ID: "admin", Role: model.UserRoleAdmin}
resource := model.Resource{ID: "icon", UserID: "admin", Kind: "image", Status: model.ResourceStatusReady}
if err := db.Create(&resource).Error; err != nil {
t.Fatal(err)
}
tool, err := s.CreateTool(admin, ToolInput{Kind: KindImage, CategoryID: "recommended", Name: "Portrait", IconResourceID: "icon", Capability: json.RawMessage(`{"referenceKinds":["image"]}`), Enabled: true})
if err != nil {
t.Fatal(err)
}
if tool.IconResourceID != "icon" || tool.IconURL != "" {
t.Fatalf("admin projection mismatch: %#v", tool)
}
preset, err := s.CreatePreset(admin, PresetInput{ToolID: tool.ID, Name: "Studio portrait", PromptTemplate: "Portrait of {{subject}}", Enabled: true})
if err != nil {
t.Fatal(err)
}
public, err := s.PublicTools(KindImage)
if err != nil {
t.Fatal(err)
}
if len(public) != 1 || public[0].IconResourceID != "icon" || public[0].IconURL != "/api/public/resources/icon" || len(public[0].Presets) != 1 || public[0].Presets[0].ID != preset.ID {
t.Fatalf("public catalog mismatch: %#v", public)
}
if audit.audited != 2 {
t.Fatalf("audits=%d want 2", audit.audited)
}
}
func TestWorkbenchRejectsNonAdminResourcesAndInvalidPresetParent(t *testing.T) {
s, db, _ := newWorkbenchTest(t)
admin := &model.User{ID: "admin", Role: model.UserRoleAdmin}
resource := model.Resource{ID: "private-icon", UserID: "other", Kind: "image", Status: model.ResourceStatusReady}
if err := db.Create(&resource).Error; err != nil {
t.Fatal(err)
}
if _, err := s.CreateTool(admin, ToolInput{Kind: KindVideo, Name: "Video", IconResourceID: "private-icon"}); err == nil {
t.Fatal("expected foreign resource rejection")
}
if _, err := s.CreatePreset(admin, PresetInput{ToolID: "missing", Name: "Preset", PromptTemplate: "prompt"}); err == nil {
t.Fatal("expected missing tool rejection")
}
if _, err := s.CreateTool(&model.User{ID: "user", Role: model.UserRoleUser}, ToolInput{Kind: KindImage, Name: "No"}); err == nil {
t.Fatal("expected non-admin rejection")
}
}
func TestWorkbenchTaskRejectsForeignResourcesAndPreservesRoleAndPresetSnapshot(t *testing.T) {
s, db, _ := newWorkbenchTest(t)
tool := model.WorkbenchTool{ID: "tool", Kind: KindVideo, Name: "Image to video", Enabled: true, Version: 4, CapabilityJSON: `{"referenceKinds":["image"],"inputContract":{"slots":[{"key":"subject","name":"Subject","mediaTypes":["image"],"maxCount":1,"minCount":1,"required":true}]}}`}
preset := model.WorkbenchPreset{ID: "preset", ToolID: "tool", Name: "Motion", PromptTemplate: "move gently", Enabled: true, Version: 2}
image := model.Resource{ID: "image", UserID: "user", Kind: "image", Status: model.ResourceStatusReady}
foreign := model.Resource{ID: "foreign", UserID: "other", Kind: "image", Status: model.ResourceStatusReady}
for _, item := range []any{&tool, &preset, &image, &foreign} {
if err := db.Create(item).Error; err != nil {
t.Fatal(err)
}
}
request := TaskInput{Kind: KindVideo, ToolID: "tool", PresetID: "preset", Prompt: "A woman walks", Model: "video-model", LogicalModelID: "logical", Parameters: map[string]any{"seconds": 6, "ratio": "16:9", "generateAudio": true}, References: []TaskReference{{ResourceID: "image", Kind: "image", Role: "first_frame", SlotKey: "subject"}}}
resolved, err := s.ResolveTask("user", request)
if err != nil {
t.Fatal(err)
}
if resolved.TaskType != "canvas_video" {
t.Fatalf("task type = %q", resolved.TaskType)
}
metadata := resolved.Input["metadata"].(map[string]any)
if metadata["workbenchToolVersion"] != 4 || metadata["workbenchPresetVersion"] != 2 || metadata["workbenchPresetPrompt"] != "move gently" {
t.Fatalf("missing historical snapshot: %#v", metadata)
}
workbenchSnapshot := metadata["workbench"].(map[string]any)
parameters := workbenchSnapshot["parameters"].(map[string]any)
if parameters["seconds"] != 6 || parameters["ratio"] != "16:9" || parameters["generateAudio"] != true {
t.Fatalf("parameters not snapshotted: %#v", parameters)
}
refs := resolved.Input["referenceImages"].([]map[string]any)
if refs[0]["role"] != "first_frame" || refs[0]["resourceId"] != "image" || refs[0]["storageKey"] != "resource:image" || refs[0]["slotKey"] != "subject" {
t.Fatalf("authorized reference not preserved for provider input: %#v", refs)
}
snapshotRefs := workbenchSnapshot["references"].([]map[string]any)
if len(snapshotRefs) != 1 || snapshotRefs[0]["resourceId"] != "image" || snapshotRefs[0]["role"] != "first_frame" || snapshotRefs[0]["slotKey"] != "subject" {
t.Fatalf("workbench reference snapshot = %#v", snapshotRefs)
}
request.References[0].ResourceID = "foreign"
if _, err := s.ResolveTask("user", request); err == nil {
t.Fatal("expected foreign resource rejection")
}
request.References[0] = TaskReference{ResourceID: "image", Kind: "image", Role: "unexpected"}
if _, err := s.ResolveTask("user", request); err == nil {
t.Fatal("expected unsupported role rejection")
}
}
func TestWorkbenchTaskRejectsUnknownParametersAndClientConfig(t *testing.T) {
s, db, _ := newWorkbenchTest(t)
tool := model.WorkbenchTool{ID: "tool", Kind: KindImage, Name: "Image", Enabled: true, CapabilityJSON: "{}"}
if err := db.Create(&tool).Error; err != nil {
t.Fatal(err)
}
request := TaskInput{Kind: KindImage, ToolID: "tool", Prompt: "prompt", Model: "image-model", LogicalModelID: "logical", Parameters: map[string]any{"channelId": "attacker"}}
if _, err := s.ResolveTask("user", request); err == nil {
t.Fatal("expected arbitrary field rejection")
}
}
func TestWorkbenchTaskCanBeCreatedWithoutTool(t *testing.T) {
s, _, _ := newWorkbenchTest(t)
request := TaskInput{Kind: KindImage, Prompt: "A glass greenhouse at sunrise", Model: "image-model", LogicalModelID: "logical", Parameters: map[string]any{"size": "1024x1024", "count": 1}}
resolved, err := s.ResolveTask("user", request)
if err != nil {
t.Fatal(err)
}
if resolved.Tool != nil || resolved.Preset != nil {
t.Fatalf("tool-less task unexpectedly resolved a tool or preset: %#v", resolved)
}
metadata := resolved.Input["metadata"].(map[string]any)
if metadata["source"] != "workbench" || metadata["workbenchKind"] != KindImage {
t.Fatalf("missing workbench task identity: %#v", metadata)
}
if _, ok := metadata["workbenchToolId"]; ok {
t.Fatalf("tool-less task contains a tool id: %#v", metadata)
}
snapshot := metadata["workbench"].(map[string]any)
if snapshot["kind"] != KindImage || snapshot["parameters"].(map[string]any)["count"] != 1 {
t.Fatalf("workbench parameters not snapshotted: %#v", snapshot)
}
if resolved.TaskType != "canvas_image" || resolved.Operation != "image" {
t.Fatalf("unexpected task routing: %#v", resolved)
}
}
func TestWorkbenchTaskRejectsPresetWithoutTool(t *testing.T) {
s, _, _ := newWorkbenchTest(t)
request := TaskInput{Kind: KindVideo, PresetID: "preset", Prompt: "A quiet city street", Model: "video-model", LogicalModelID: "logical"}
if _, err := s.ResolveTask("user", request); err == nil {
t.Fatal("expected preset without tool rejection")
}
}
func TestWorkbenchTaskAcceptsSystemChannelWithoutLogicalModel(t *testing.T) {
s, _, _ := newWorkbenchTest(t)
request := TaskInput{Kind: KindImage, Prompt: "A glass greenhouse at sunrise", Model: "image-model", ChannelID: "channel-1", Parameters: map[string]any{"size": "1024x1024", "count": 1}}
resolved, err := s.ResolveTask("user", request)
if err != nil {
t.Fatal(err)
}
if resolved.LogicalModelID != "" || resolved.ChannelID != "channel-1" {
t.Fatalf("channel selection = logical %q channel %q", resolved.LogicalModelID, resolved.ChannelID)
}
config, _ := resolved.Input["config"].(map[string]any)
if config["channelId"] != "channel-1" || config["model"] != "image-model" {
t.Fatalf("provider config = %#v", config)
}
if _, err := s.ResolveTask("user", TaskInput{Kind: KindImage, Prompt: "missing model identity", Model: "image-model"}); err == nil {
t.Fatal("expected rejection when neither logical model nor channel is selected")
}
}
+393
View File
@@ -0,0 +1,393 @@
package workbench
import (
"encoding/json"
"errors"
"math"
"strconv"
"strings"
"yingce/backend/internal/kernel"
"yingce/backend/internal/model"
)
type TaskInput struct {
Kind string `json:"kind"`
ToolID string `json:"toolId"`
PresetID string `json:"presetId,omitempty"`
Workbench *TaskWorkbench `json:"workbench,omitempty"`
Prompt string `json:"prompt"`
Model string `json:"model,omitempty"`
LogicalModelID string `json:"logicalModelId,omitempty"`
ChannelID string `json:"channelId,omitempty"`
ModelChoice *ModelChoice `json:"modelChoice,omitempty"`
Parameters map[string]any `json:"parameters,omitempty"`
References []TaskReference `json:"references,omitempty"`
}
type TaskWorkbench struct {
Kind string `json:"kind"`
ToolID string `json:"toolId"`
PresetID string `json:"presetId,omitempty"`
}
type ModelChoice struct {
LogicalModelID string `json:"logicalModelId,omitempty"`
UserChannelID string `json:"userChannelId,omitempty"`
ModelName string `json:"modelName,omitempty"`
}
type TaskReference struct {
ResourceID string `json:"resourceId"`
Kind string `json:"kind"`
Role string `json:"role,omitempty"`
SlotKey string `json:"slotKey,omitempty"`
}
type TaskResolution struct {
TaskType string
Operation string
Input map[string]any
Parameters map[string]any
Prompt string
Model string
LogicalModelID string
ChannelID string
Tool *model.WorkbenchTool
Preset *model.WorkbenchPreset
}
func (s *Service) ResolveTask(userID string, req TaskInput) (*TaskResolution, error) {
toolID := strings.TrimSpace(req.ToolID)
presetID := strings.TrimSpace(req.PresetID)
if req.Workbench != nil && (req.Workbench.Kind != req.Kind || strings.TrimSpace(req.Workbench.ToolID) != toolID || (strings.TrimSpace(req.Workbench.PresetID) != "" && strings.TrimSpace(req.Workbench.PresetID) != presetID)) {
return nil, kernel.BadAuthRequest("工作台任务范围字段不一致")
}
if !validKind(req.Kind) {
return nil, kernel.BadAuthRequest("工作台类型无效")
}
if toolID == "" && presetID != "" {
return nil, kernel.BadAuthRequest("使用提示词预设前必须选择创作工具")
}
var tool *model.WorkbenchTool
var preset *model.WorkbenchPreset
if toolID != "" {
var err error
tool, preset, err = s.repo.WorkbenchToolAndPreset(toolID, presetID, true)
if err != nil {
return nil, kernel.BadAuthRequest("工具或预设未开放")
}
if tool.Kind != req.Kind {
return nil, kernel.BadAuthRequest("所选工具与工作台类型不匹配")
}
}
if strings.TrimSpace(req.Prompt) == "" && (tool == nil || toolRequiresPrompt(tool)) {
return nil, kernel.BadAuthRequest("当前工具需要填写提示词")
}
modelName, logicalModelID, channelID, providerConfig, err := resolveModelInput(req)
if err != nil {
return nil, err
}
if len(providerConfig) == 0 {
return nil, kernel.BadAuthRequest("必须提供可执行模型配置")
}
if containsSensitiveURL(providerConfig) {
return nil, kernel.BadAuthRequest("浏览器不能直接提交模型配置、URL 或凭据")
}
if len(req.References) > 20 {
return nil, kernel.BadAuthRequest("参考素材数量超出限制")
}
parameters, err := normalizeTaskParameters(req.Kind, req.Parameters)
if err != nil {
return nil, err
}
refs := make([]TaskReference, 0, len(req.References))
frameRoles := map[string]bool{}
for _, ref := range req.References {
if strings.TrimSpace(ref.ResourceID) == "" || !validReferenceKind(ref.Kind) {
return nil, kernel.BadAuthRequest("参考素材无效")
}
role := strings.TrimSpace(ref.Role)
if !validReferenceRole(req.Kind, ref.Kind, role) {
return nil, kernel.BadAuthRequest("参考素材角色无效")
}
if role == "first_frame" || role == "last_frame" {
if frameRoles[role] {
return nil, kernel.BadAuthRequest("首帧和尾帧各只能指定一个素材")
}
frameRoles[role] = true
}
resource, err := s.repo.ResourceForUser(userID, strings.TrimSpace(ref.ResourceID))
if err != nil || resource.Status != model.ResourceStatusReady || resource.Kind != ref.Kind {
return nil, kernel.BadAuthRequest("参考素材不存在、未就绪或类型不匹配")
}
refs = append(refs, TaskReference{ResourceID: resource.ID, Kind: resource.Kind, Role: role, SlotKey: strings.TrimSpace(ref.SlotKey)})
}
if tool != nil {
capability := map[string]any{}
if err := json.Unmarshal([]byte(tool.CapabilityJSON), &capability); err != nil {
return nil, errors.New("工具能力配置无效")
}
if err := validateTaskCapabilities(tool.Kind, capability, refs); err != nil {
return nil, err
}
}
metadata := map[string]any{"source": "workbench", "workbenchKind": req.Kind}
workbenchSnapshot := map[string]any{"kind": req.Kind, "parameters": parameters}
snapshotReferences := make([]map[string]any, 0, len(refs))
for _, ref := range refs {
reference := map[string]any{"resourceId": ref.ResourceID, "kind": ref.Kind}
if ref.Role != "" {
reference["role"] = ref.Role
}
if ref.SlotKey != "" {
reference["slotKey"] = ref.SlotKey
}
snapshotReferences = append(snapshotReferences, reference)
}
workbenchSnapshot["references"] = snapshotReferences
if tool != nil {
metadata["workbenchToolId"], metadata["workbenchToolName"], metadata["workbenchToolVersion"] = tool.ID, tool.Name, tool.Version
workbenchSnapshot["toolId"], workbenchSnapshot["toolName"], workbenchSnapshot["toolVersion"] = tool.ID, tool.Name, tool.Version
}
metadata["workbench"] = workbenchSnapshot
input := map[string]any{"mode": req.Kind, "prompt": strings.TrimSpace(req.Prompt), "config": providerConfig, "metadata": metadata}
if len(refs) > 0 {
imageRefs, videoRefs, audioRefs := []map[string]any{}, []map[string]any{}, []map[string]any{}
for _, ref := range refs {
value := map[string]any{"resourceId": ref.ResourceID, "storageKey": "resource:" + ref.ResourceID}
if ref.Role != "" {
value["role"] = ref.Role
}
if ref.SlotKey != "" {
value["slotKey"] = ref.SlotKey
}
switch ref.Kind {
case "image":
imageRefs = append(imageRefs, value)
case "video":
videoRefs = append(videoRefs, value)
case "audio":
audioRefs = append(audioRefs, value)
}
}
if len(imageRefs) > 0 {
input["referenceImages"] = imageRefs
}
if len(videoRefs) > 0 {
input["referenceVideos"] = videoRefs
}
if len(audioRefs) > 0 {
input["referenceAudios"] = audioRefs
}
}
if preset != nil {
meta := input["metadata"].(map[string]any)
meta["workbenchPresetId"], meta["workbenchPresetVersion"], meta["workbenchPresetName"] = preset.ID, preset.Version, preset.Name
meta["workbenchPresetPrompt"] = preset.PromptTemplate
workbenchSnapshot := meta["workbench"].(map[string]any)
workbenchSnapshot["presetId"], workbenchSnapshot["presetName"], workbenchSnapshot["presetVersion"] = preset.ID, preset.Name, preset.Version
workbenchSnapshot["presetPrompt"] = preset.PromptTemplate
}
return &TaskResolution{TaskType: map[string]string{KindImage: "canvas_image", KindVideo: "canvas_video"}[req.Kind], Operation: map[string]string{KindImage: "image", KindVideo: "video"}[req.Kind], Input: input, Parameters: parameters, Prompt: strings.TrimSpace(req.Prompt), Model: modelName, LogicalModelID: logicalModelID, ChannelID: channelID, Tool: tool, Preset: preset}, nil
}
func normalizeTaskParameters(kind string, params map[string]any) (map[string]any, error) {
allowed := map[string]map[string]bool{
KindImage: {"size": true, "quality": true, "count": true},
KindVideo: {"seconds": true, "ratio": true, "resolution": true, "generateAudio": true, "watermark": true},
}[kind]
if allowed == nil {
return nil, kernel.BadAuthRequest("工作台类型无效")
}
normalized := make(map[string]any, len(params))
for key, value := range params {
if !allowed[key] {
return nil, kernel.BadAuthRequest("生成参数包含不支持字段: " + key)
}
switch key {
case "size", "quality", "ratio", "resolution":
text, ok := value.(string)
text = strings.TrimSpace(text)
if !ok || len(text) > 64 || strings.Contains(text, "://") || strings.HasPrefix(strings.ToLower(text), "data:") {
return nil, kernel.BadAuthRequest("生成参数格式无效: " + key)
}
if text != "" {
normalized[key] = text
}
case "count", "seconds":
number, ok := taskParameterInteger(value)
max := 20
if key == "seconds" {
max = 3600
}
if !ok || number < 1 || number > max {
return nil, kernel.BadAuthRequest("生成参数数值无效: " + key)
}
normalized[key] = number
case "generateAudio", "watermark":
flag, ok := value.(bool)
if !ok {
return nil, kernel.BadAuthRequest("生成参数格式无效: " + key)
}
normalized[key] = flag
}
}
return normalized, nil
}
func taskParameterInteger(value any) (int, bool) {
switch number := value.(type) {
case int:
return number, true
case int64:
return int(number), int64(int(number)) == number
case float64:
if math.IsNaN(number) || math.IsInf(number, 0) || math.Trunc(number) != number {
return 0, false
}
return int(number), float64(int(number)) == number
case json.Number:
parsed, err := strconv.Atoi(number.String())
return parsed, err == nil
default:
return 0, false
}
}
func validReferenceRole(kind, referenceKind, role string) bool {
if role == "" || role == "reference" {
return kind == KindVideo || referenceKind == "image"
}
return kind == KindVideo && referenceKind == "image" && (role == "first_frame" || role == "last_frame")
}
func resolveModelInput(req TaskInput) (string, string, string, map[string]any, error) {
modelName := strings.TrimSpace(req.Model)
logicalModelID := strings.TrimSpace(req.LogicalModelID)
channelID := strings.TrimSpace(req.ChannelID)
if req.ModelChoice != nil {
choice := req.ModelChoice
if choice.UserChannelID != "" {
return "", "", "", nil, kernel.BadAuthRequest("工作台暂不接受浏览器自定义渠道选择")
}
if value := strings.TrimSpace(choice.LogicalModelID); value != "" {
if logicalModelID != "" && logicalModelID != value {
return "", "", "", nil, kernel.BadAuthRequest("模型选择字段不一致")
}
logicalModelID = value
}
if value := strings.TrimSpace(choice.ModelName); value != "" {
if modelName != "" && modelName != value {
return "", "", "", nil, kernel.BadAuthRequest("模型名称字段不一致")
}
modelName = value
}
}
if modelName == "" {
return "", "", "", nil, kernel.BadAuthRequest("必须选择一个模型")
}
// 创作页的系统目录模型多数只有渠道 ID + 模型名,没有 logicalModelId。
// 两者都空才拒绝;渠道是否为系统渠道由后续 admission 再校验。
if logicalModelID == "" && channelID == "" {
return "", "", "", nil, kernel.BadAuthRequest("工作台任务必须选择系统模型目录中的模型")
}
config := map[string]any{"mode": req.Kind, "model": modelName}
if channelID != "" {
config["channelId"] = channelID
}
return modelName, logicalModelID, channelID, config, nil
}
func validReferenceKind(kind string) bool {
return kind == "image" || kind == "video" || kind == "audio"
}
func containsSensitiveURL(value any) bool {
switch item := value.(type) {
case map[string]any:
for key, child := range item {
lower := strings.ToLower(key)
if lower == "api_key" || lower == "apikey" || lower == "authorization" || lower == "headers" || lower == "baseurl" || lower == "base_url" || lower == "url" {
return true
}
if containsSensitiveURL(child) {
return true
}
}
case []any:
for _, child := range item {
if containsSensitiveURL(child) {
return true
}
}
}
return false
}
func validateTaskCapabilities(kind string, capability map[string]any, refs []TaskReference) error {
allowedRefs, _ := capability["referenceKinds"].([]any)
allowedSet := map[string]bool{}
for _, value := range allowedRefs {
if text, ok := value.(string); ok {
allowedSet[text] = true
}
}
var contract InputContract
if raw, ok := capability["inputContract"]; ok {
encoded, _ := json.Marshal(raw)
_ = json.Unmarshal(encoded, &contract)
}
slots := map[string]ReferenceSlot{}
for _, slot := range contract.Slots {
slots[slot.Key] = slot
}
counts := map[string]int{}
for _, ref := range refs {
if len(allowedSet) > 0 && !allowedSet[ref.Kind] {
return kernel.BadAuthRequest("当前工具不支持该类型参考素材")
}
if kind == KindImage && ref.Kind != "image" {
return kernel.BadAuthRequest("生图任务只允许使用图片参考素材")
}
if kind == KindVideo && ref.Kind != "image" && ref.Kind != "video" && ref.Kind != "audio" {
return kernel.BadAuthRequest("视频任务参考素材类型无效")
}
if len(contract.Slots) == 0 {
continue
}
slotKey := strings.TrimSpace(ref.SlotKey)
slot, ok := slots[slotKey]
if !ok {
return kernel.BadAuthRequest("参考素材未指定有效的输入槽位")
}
mediaAllowed := false
for _, mediaType := range slot.MediaTypes {
if mediaType == ref.Kind {
mediaAllowed = true
break
}
}
if !mediaAllowed {
return kernel.BadAuthRequest("参考素材类型与输入槽位不匹配")
}
counts[slotKey]++
if counts[slotKey] > slot.MaxCount {
return kernel.BadAuthRequest("输入槽位的参考素材数量超出上限")
}
}
for _, slot := range contract.Slots {
if counts[slot.Key] < slot.MinCount || (slot.Required && counts[slot.Key] == 0) {
return kernel.BadAuthRequest("请补齐必需的输入素材:" + slot.Name)
}
}
return nil
}
func toolRequiresPrompt(tool *model.WorkbenchTool) bool {
if tool == nil {
return true
}
config, err := parseCatalogConfig(tool.CapabilityJSON, tool.GuideJSON)
return err != nil || config.InputContract.PromptRequired || config.InputContract.MinTextReferences > 0
}
+66
View File
@@ -0,0 +1,66 @@
package workbench
import (
"testing"
"yingce/backend/internal/model"
)
func TestValidateTaskCapabilitiesInputContract(t *testing.T) {
capability := map[string]any{
"referenceKinds": []any{"image", "video"},
"inputContract": map[string]any{
"promptRequired": false,
"slots": []any{
map[string]any{
"key": "subject",
"name": "Subject",
"mediaTypes": []any{"image"},
"minCount": float64(1),
"maxCount": float64(2),
"required": true,
},
},
},
}
tests := []struct {
name string
refs []TaskReference
want bool
}{
{name: "valid slot reference", refs: []TaskReference{{ResourceID: "image-1", Kind: "image", SlotKey: "subject"}}, want: true},
{name: "missing required slot", want: false},
{name: "unknown slot", refs: []TaskReference{{ResourceID: "image-1", Kind: "image", SlotKey: "unknown"}}, want: false},
{name: "media type mismatch", refs: []TaskReference{{ResourceID: "video-1", Kind: "video", SlotKey: "subject"}}, want: false},
{name: "slot count exceeded", refs: []TaskReference{{ResourceID: "image-1", Kind: "image", SlotKey: "subject"}, {ResourceID: "image-2", Kind: "image", SlotKey: "subject"}, {ResourceID: "image-3", Kind: "image", SlotKey: "subject"}}, want: false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
err := validateTaskCapabilities(KindVideo, capability, test.refs)
if (err == nil) != test.want {
t.Fatalf("validateTaskCapabilities() error = %v, want success = %t", err, test.want)
}
})
}
}
func TestValidateTaskCapabilitiesLegacyWithoutSlots(t *testing.T) {
capability := map[string]any{"referenceKinds": []any{"image"}}
refs := []TaskReference{{ResourceID: "image-1", Kind: "image"}}
if err := validateTaskCapabilities(KindImage, capability, refs); err != nil {
t.Fatalf("legacy reference without slot was rejected: %v", err)
}
}
func TestToolRequiresPromptDefaultsAndOverride(t *testing.T) {
legacy := &model.WorkbenchTool{CapabilityJSON: `{}`}
if !toolRequiresPrompt(legacy) {
t.Fatal("legacy tool must keep requiring a prompt")
}
optionalPrompt := &model.WorkbenchTool{CapabilityJSON: `{"inputContract":{"promptRequired":false}}`}
if toolRequiresPrompt(optionalPrompt) {
t.Fatal("tool configured for optional prompts must allow an empty prompt")
}
}