Files
ttstd-infinite-canvas/backend/internal/workbench/service.go

604 lines
22 KiB
Go

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}
}