Files
ttstd-infinite-canvas/backend/internal/tools/tools.go
T

408 lines
11 KiB
Go

package tools
import (
"errors"
"fmt"
"strings"
"time"
"yingce/backend/internal/kernel"
"yingce/backend/internal/model"
)
const (
ToolSourceBuiltin = "builtin"
ToolSourceUser = "user"
ToolVisibilityPublic = "public"
ToolVisibilityPrivate = "private"
// 查询范围
ToolScopePublic = "public"
ToolScopeFavorites = "favorites"
ToolScopeCustom = "custom"
toolMaxPageSize = 80
toolMaxPromptLength = 8000
)
var validToolTypes = map[string]struct{}{
ToolTypeStyle: {},
ToolTypeMotion: {},
ToolTypeNineGrid: {},
ToolTypeEffect: {},
}
// ToolListRequest 工具列表查询参数。
type ToolListRequest struct {
Page int `json:"page"`
PageSize int `json:"pageSize"`
Scope string `json:"scope"` // public | favorites | custom
Type string `json:"type"` // style | motion | nine_grid | effect
Tag string `json:"tag"`
Search string `json:"search"`
}
// ToolItem 工具列表项;ExtraInfo 为样本图路径数组。
type ToolItem struct {
ID int64 `json:"id"`
Type string `json:"type"`
LabelEn string `json:"labelEn"`
Label string `json:"label"`
Desc string `json:"desc"`
Tag string `json:"tag"`
Cover string `json:"cover"`
ExtraInfo []string `json:"extraInfo"`
Prompt string `json:"prompt"`
Ratio string `json:"ratio"`
MediaURL string `json:"mediaUrl"`
OwnerID string `json:"ownerId"`
Source string `json:"source"`
Enabled bool `json:"enabled"`
Visibility string `json:"visibility"`
SortWeight int `json:"sortWeight"`
Favorited bool `json:"favorited"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// ToolSummary 工具列表摘要项;不含 prompt、extraInfo 等大字段,详情/收藏结果才返回完整 ToolItem。
type ToolSummary struct {
ID int64 `json:"id"`
Type string `json:"type"`
LabelEn string `json:"labelEn"`
Label string `json:"label"`
Desc string `json:"desc"`
Tag string `json:"tag"`
Cover string `json:"cover"`
Ratio string `json:"ratio"`
MediaURL string `json:"mediaUrl"`
OwnerID string `json:"ownerId"`
Source string `json:"source"`
Enabled bool `json:"enabled"`
Visibility string `json:"visibility"`
SortWeight int `json:"sortWeight"`
Favorited bool `json:"favorited"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// ToolList 分页结果。
type ToolList struct {
Tools []ToolSummary `json:"tools"`
TotalCount int64 `json:"totalCount"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
HasMore bool `json:"hasMore"`
}
// ToolMutationRequest 新增/更新自定义工具的请求体。
type ToolMutationRequest struct {
Type string `json:"type"`
Label string `json:"label"`
Desc string `json:"desc"`
Tag string `json:"tag"`
Cover string `json:"cover"`
ExtraInfo []string `json:"extraInfo"`
Prompt string `json:"prompt"`
Ratio string `json:"ratio"`
MediaURL string `json:"mediaUrl"`
Visibility string `json:"visibility"`
}
// Service 工具域服务。
type Service struct {
repo Repository
}
func New(repo Repository) *Service {
return &Service{repo: repo}
}
// List 按范围分页查询工具列表。
func (s *Service) List(userID string, req ToolListRequest) (*ToolList, error) {
if strings.TrimSpace(userID) == "" {
return nil, kernel.Unauthorized("请先登录")
}
if err := normalizeToolListRequest(&req); err != nil {
return nil, err
}
items, total, err := s.repo.ListTools(userID, req)
if err != nil {
return nil, err
}
tools := make([]ToolSummary, 0, len(items))
for _, item := range items {
tools = append(tools, buildToolSummary(item.Tool, item.Favorited))
}
return &ToolList{
Tools: tools,
TotalCount: total,
Page: req.Page,
PageSize: req.PageSize,
HasMore: int64(req.Page*req.PageSize) < total,
}, nil
}
// Detail 返回单个工具详情(含提示词),供工具栏快捷调用。
func (s *Service) Detail(userID string, toolID int64) (*ToolItem, error) {
if userID == "" {
return nil, kernel.Unauthorized("请先登录")
}
if toolID <= 0 {
return nil, kernel.BadAuthRequest("工具 ID 无效")
}
tool, err := s.repo.ToolForUser(userID, toolID)
if err != nil {
return nil, err
}
return buildToolItemPtr(tool, false, nil), nil
}
// SetFavorite 添加/取消收藏;同一用户对同一工具仅一条记录。
func (s *Service) SetFavorite(userID string, toolID int64, favorite bool) (*ToolItem, error) {
if userID == "" {
return nil, kernel.Unauthorized("请先登录")
}
if toolID <= 0 {
return nil, kernel.BadAuthRequest("工具 ID 无效")
}
if _, err := s.repo.ToolForUser(userID, toolID); err != nil {
return nil, err
}
if favorite {
if err := s.repo.AddToolFavorite(userID, toolID); err != nil {
return nil, err
}
} else {
if err := s.repo.RemoveToolFavorite(userID, toolID); err != nil {
return nil, err
}
}
updated, favoritedAt, err := s.repo.ToolFavorited(userID, toolID)
if err != nil {
return nil, err
}
return buildToolItemPtr(updated, true, favoritedAt), nil
}
// Create 创建用户自定义工具。
func (s *Service) Create(userID string, req ToolMutationRequest) (*ToolItem, error) {
if userID == "" {
return nil, kernel.Unauthorized("请先登录")
}
normalized, err := normalizeToolMutationRequest(req)
if err != nil {
return nil, err
}
now := time.Now()
tool := model.Tool{
Type: normalized.Type,
LabelEn: normalized.LabelEn,
Label: normalized.Label,
Desc: normalized.Desc,
Tag: normalized.Tag,
Cover: normalized.Cover,
ExtraInfoJSON: normalized.ExtraInfoJSON,
Prompt: normalized.Prompt,
Ratio: normalized.Ratio,
MediaURL: normalized.MediaURL,
OwnerID: userID,
Source: ToolSourceUser,
Enabled: true,
Visibility: normalized.Visibility,
SortWeight: 0,
CreatedAt: now,
UpdatedAt: now,
}
created, err := s.repo.CreateTool(&tool)
if err != nil {
return nil, err
}
item := buildToolItem(*created, false, nil)
return &item, nil
}
// Delete 删除用户自定义工具;内置工具不可删除,同步清理收藏记录。
func (s *Service) Delete(userID string, toolID int64) error {
if userID == "" {
return kernel.Unauthorized("请先登录")
}
if toolID <= 0 {
return kernel.BadAuthRequest("工具 ID 无效")
}
return s.repo.DeleteUserTool(userID, toolID)
}
func normalizeToolListRequest(req *ToolListRequest) error {
if req.Scope == "" {
req.Scope = ToolScopePublic
}
if req.Page <= 0 {
req.Page = 1
}
if req.PageSize <= 0 {
req.PageSize = 20
}
if req.PageSize > toolMaxPageSize {
req.PageSize = toolMaxPageSize
}
switch req.Scope {
case ToolScopePublic, ToolScopeFavorites, ToolScopeCustom:
default:
return kernel.BadAuthRequest(fmt.Sprintf("不支持的范围: %s", req.Scope))
}
if req.Type != "" {
if _, ok := validToolTypes[req.Type]; !ok {
return kernel.BadAuthRequest(fmt.Sprintf("不支持的工具类型: %s", req.Type))
}
}
req.Search = strings.TrimSpace(req.Search)
req.Tag = strings.TrimSpace(req.Tag)
return nil
}
func normalizeToolMutationRequest(req ToolMutationRequest) (*model.Tool, error) {
toolType := strings.TrimSpace(req.Type)
if _, ok := validToolTypes[toolType]; !ok {
return nil, kernel.BadAuthRequest("工具类型无效,仅支持 style、motion、nine_grid、effect")
}
label := strings.TrimSpace(req.Label)
if label == "" {
return nil, kernel.BadAuthRequest("工具名称不能为空")
}
if len(label) > 120 {
return nil, kernel.BadAuthRequest("工具名称过长")
}
prompt := strings.TrimSpace(req.Prompt)
if prompt == "" {
return nil, kernel.BadAuthRequest("工具提示词不能为空")
}
if strings.Contains(prompt, "@[tool:") {
return nil, kernel.BadAuthRequest("不支持嵌套工具标签")
}
if len(prompt) > toolMaxPromptLength {
return nil, kernel.BadAuthRequest("工具提示词过长")
}
desc := strings.TrimSpace(req.Desc)
if len(desc) > 500 {
return nil, kernel.BadAuthRequest("工具描述过长")
}
tag := strings.TrimSpace(req.Tag)
if len(tag) > 64 {
return nil, kernel.BadAuthRequest("标签过长")
}
visibility := strings.TrimSpace(req.Visibility)
if visibility == "" {
visibility = ToolVisibilityPrivate
}
if visibility != ToolVisibilityPublic && visibility != ToolVisibilityPrivate {
return nil, kernel.BadAuthRequest("可见性仅支持 public 或 private")
}
cover := strings.TrimSpace(req.Cover)
if len(cover) > 500 {
return nil, kernel.BadAuthRequest("封面路径过长")
}
mediaURL := strings.TrimSpace(req.MediaURL)
if len(mediaURL) > 500 {
return nil, kernel.BadAuthRequest("媒体路径过长")
}
ratio := strings.TrimSpace(req.Ratio)
if len(ratio) > 32 {
return nil, kernel.BadAuthRequest("比例参数过长")
}
extraInfo := make([]string, 0, len(req.ExtraInfo))
for _, item := range req.ExtraInfo {
trimmed := strings.TrimSpace(item)
if trimmed == "" {
continue
}
if len(trimmed) > 500 {
return nil, kernel.BadAuthRequest("扩展信息路径过长")
}
extraInfo = append(extraInfo, trimmed)
}
extraInfoJSON := ""
if len(extraInfo) > 0 {
data, err := marshalExtraInfo(extraInfo)
if err != nil {
return nil, kernel.BadAuthRequest("扩展信息格式无效")
}
extraInfoJSON = string(data)
}
labelEn := sanitizeLabelEn(label)
return &model.Tool{
Type: toolType,
LabelEn: labelEn,
Label: label,
Desc: desc,
Tag: tag,
Cover: cover,
ExtraInfoJSON: extraInfoJSON,
Prompt: prompt,
Ratio: ratio,
MediaURL: mediaURL,
Visibility: visibility,
}, nil
}
func buildToolSummary(tool model.Tool, favorited bool) ToolSummary {
return ToolSummary{
ID: tool.ID,
Type: tool.Type,
LabelEn: tool.LabelEn,
Label: tool.Label,
Desc: tool.Desc,
Tag: tool.Tag,
Cover: tool.Cover,
Ratio: tool.Ratio,
MediaURL: tool.MediaURL,
OwnerID: tool.OwnerID,
Source: tool.Source,
Enabled: tool.Enabled,
Visibility: tool.Visibility,
SortWeight: tool.SortWeight,
Favorited: favorited,
CreatedAt: tool.CreatedAt,
UpdatedAt: tool.UpdatedAt,
}
}
func buildToolItem(tool model.Tool, favorited bool, favoritedAt *time.Time) ToolItem {
return ToolItem{
ID: tool.ID,
Type: tool.Type,
LabelEn: tool.LabelEn,
Label: tool.Label,
Desc: tool.Desc,
Tag: tool.Tag,
Cover: tool.Cover,
ExtraInfo: decodeExtraInfo(tool.ExtraInfoJSON),
Prompt: tool.Prompt,
Ratio: tool.Ratio,
MediaURL: tool.MediaURL,
OwnerID: tool.OwnerID,
Source: tool.Source,
Enabled: tool.Enabled,
Visibility: tool.Visibility,
SortWeight: tool.SortWeight,
Favorited: favorited,
CreatedAt: tool.CreatedAt,
UpdatedAt: tool.UpdatedAt,
}
}
func buildToolItemPtr(tool model.Tool, favorited bool, favoritedAt *time.Time) *ToolItem {
item := buildToolItem(tool, favorited, favoritedAt)
return &item
}
// IsToolNotFound 判断是否为工具不存在(含他人私有工具不可见)。
func IsToolNotFound(err error) bool {
var appErr *kernel.AppError
if errors.As(err, &appErr) {
return appErr.Status == 404
}
return false
}