Files
ttstd-infinite-canvas/backend/internal/skills/skills.go

599 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package skills
import (
"encoding/json"
"errors"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"unicode/utf8"
"yingce/backend/internal/kernel"
"yingce/backend/internal/model"
"yingce/backend/internal/repository"
"gorm.io/gorm"
)
const (
skillStatusEnabled = 1
skillSourceUser = 1
)
var ErrNotBuiltinSkill = errors.New("target is not a builtin skill")
var skillCategoryLabels = map[string]string{
"drama": "短剧影视",
"ecommerce": "电商营销",
"creative": "创意设计",
"social": "社媒内容",
"others": "其他",
}
type SkillShowcaseMedia struct {
Type string `json:"type"`
ShowcaseURI string `json:"showcaseUri"`
ShowcaseURL string `json:"showcaseUrl"`
}
type SkillEffectiveUser struct {
Name string `json:"name"`
AvatarURL string `json:"avatarUrl"`
UID string `json:"uid"`
}
type SkillItem struct {
SkillID string `json:"skillId"`
SkillName string `json:"skillName"`
Description string `json:"description"`
Instruction string `json:"instruction,omitempty"`
VersionID string `json:"versionId"`
Version string `json:"version"`
ContentHash string `json:"contentHash"`
FileCount int `json:"fileCount"`
TotalBytes int64 `json:"totalBytes"`
SourceType string `json:"sourceType"`
SourceURL string `json:"sourceUrl"`
SourceRef string `json:"sourceRef"`
SourceSubdir string `json:"sourceSubdir"`
SourceCommit string `json:"sourceCommit"`
SyncStatus string `json:"syncStatus"`
SyncError string `json:"syncError,omitempty"`
AutoUpdate bool `json:"autoUpdate"`
LastCheckedAt *time.Time `json:"lastCheckedAt,omitempty"`
LastSyncedAt *time.Time `json:"lastSyncedAt,omitempty"`
Status int `json:"status"`
MarkdownURL string `json:"markdownUrl"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
Source int `json:"source"`
Tag string `json:"tag"`
SortWeight int `json:"sortWeight"`
IsPrivate bool `json:"isPrivate"`
LikeCount int64 `json:"likeCount"`
IsLike bool `json:"isLike"`
OwnerUID string `json:"ownerUid"`
EffectiveUser SkillEffectiveUser `json:"effectiveUser"`
OriginalSkillID *string `json:"originalSkillId"`
ShowcaseMedia []SkillShowcaseMedia `json:"showcaseMedia"`
AddedCount int64 `json:"addedCount"`
IsTest bool `json:"isTest"`
ExtraInfo string `json:"extraInfo"`
IsAdded bool `json:"isAdded"`
IsOwner bool `json:"isOwner"`
LibraryCategoryID string `json:"libraryCategoryId,omitempty"`
}
// AddedSkillReference is the small contract used by the runtime catalog.
// The full SkillItem contains editor, sync, author and showcase fields that
// are only needed by the skill detail/market pages, not when selecting an
// installed skill for a generation request.
type AddedSkillReference struct {
SkillID string `json:"skillId"`
SkillName string `json:"skillName"`
Description string `json:"description"`
VersionID string `json:"versionId"`
Version string `json:"version"`
Tag string `json:"tag"`
IsLike bool `json:"isLike"`
IsAdded bool `json:"isAdded"`
IsOwner bool `json:"isOwner"`
}
type SkillCategory struct {
Value string `json:"value"`
Label string `json:"label"`
}
type SkillListRequest struct {
PlatformRootID string
PlatformCategoryID string
PlatformUncategorized bool
Page int
PageSize int
Scope string
Search string
Tag string
LibraryCategoryID string
LibraryUncategorized bool
Sort string
}
type SkillList struct {
Skills []SkillItem `json:"skills"`
TotalCount int64 `json:"totalCount"`
HasMore bool `json:"hasMore"`
NextOffset int `json:"nextOffset"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
Categories []SkillCategory `json:"categories"`
}
type SkillMutationRequest struct {
SkillName string `json:"skillName"`
Description string `json:"description"`
Instruction string `json:"instruction"`
Tag string `json:"tag"`
IsPrivate bool `json:"isPrivate"`
MarkdownURL string `json:"markdownUrl"`
ShowcaseMedia []SkillShowcaseMedia `json:"showcaseMedia"`
ExtraInfo string `json:"extraInfo"`
}
func (s *Service) Skills(userID string, req SkillListRequest) (*SkillList, error) {
req = normalizeSkillListRequest(req)
categoryID, uncategorized, err := s.curationFilter(req)
if err != nil {
return nil, err
}
rootID, err := s.curationRootFilter(req.PlatformRootID)
if err != nil {
return nil, err
}
if req.LibraryCategoryID != "" && req.LibraryUncategorized {
return nil, kernel.BadAuthRequest("技能库分类与未分类筛选不能同时使用")
}
if req.LibraryCategoryID != "" || req.LibraryUncategorized {
if req.Scope != "mine" && req.Scope != "created" {
return nil, kernel.BadAuthRequest("用户技能分类仅支持筛选我的技能或我创建的技能")
}
}
rows, total, err := s.repo.Skills(repository.SkillListFilter{
PlatformRootID: rootID,
PlatformCategoryID: categoryID, PlatformUncategorized: uncategorized,
UserID: userID,
Scope: req.Scope,
Search: req.Search,
Tag: req.Tag,
LibraryCategoryID: req.LibraryCategoryID,
LibraryUncategorized: req.LibraryUncategorized,
Sort: req.Sort,
Limit: req.PageSize,
Offset: (req.Page - 1) * req.PageSize,
})
if err != nil {
return nil, err
}
items, err := s.skillItems(userID, rows, false)
if err != nil {
return nil, err
}
nextOffset := req.Page * req.PageSize
if int64(nextOffset) >= total {
nextOffset = 0
}
return &SkillList{
Skills: items,
TotalCount: total,
HasMore: int64(req.Page*req.PageSize) < total,
NextOffset: nextOffset,
Page: req.Page,
PageSize: req.PageSize,
Categories: skillCategories(),
}, nil
}
func (s *Service) AddedSkills(userID string) ([]AddedSkillReference, error) {
rows, _, err := s.repo.Skills(repository.SkillListFilter{UserID: userID, Scope: "mine", Sort: "updated", Limit: -1})
if err != nil {
return nil, err
}
items, err := s.skillItems(userID, rows, false)
if err != nil {
return nil, err
}
references := make([]AddedSkillReference, 0, len(items))
for _, item := range items {
references = append(references, AddedSkillReference{
SkillID: item.SkillID, SkillName: item.SkillName, Description: item.Description,
VersionID: item.VersionID, Version: item.Version, Tag: item.Tag,
IsLike: item.IsLike, IsAdded: item.IsAdded, IsOwner: item.IsOwner,
})
}
return references, nil
}
func (s *Service) SkillDetail(userID string, id string) (*SkillItem, error) {
skill, err := s.visibleSkill(userID, id)
if err != nil {
return nil, err
}
items, err := s.skillItems(userID, []model.Skill{*skill}, true)
if err != nil {
return nil, err
}
return &items[0], nil
}
func (s *Service) CreateSkill(userID string, req SkillMutationRequest) (*SkillItem, error) {
normalized, mediaJSON, err := normalizeSkillMutationRequest(req, true)
if err != nil {
return nil, err
}
created, err := s.createSingleMarkdownSkill(userID, normalized)
if err != nil {
return nil, err
}
skill, err := s.ownedSkill(userID, created.SkillID)
if err != nil {
return nil, err
}
skill.ShowcaseMediaJSON = mediaJSON
skill.ExtraInfo = normalized.ExtraInfo
if err := s.repo.SaveSkill(skill); err != nil {
return nil, err
}
return s.SkillDetail(userID, skill.ID)
}
func (s *Service) UpdateSkill(userID string, id string, req SkillMutationRequest) (*SkillItem, error) {
skill, err := s.ownedSkill(userID, id)
if err != nil {
return nil, err
}
requireInstruction := skill.SourceType == "markdown" || skill.SourceType == "builtin" || skill.SourceType == ""
normalized, mediaJSON, err := normalizeSkillMutationRequest(req, requireInstruction)
if err != nil {
return nil, err
}
skill.Name = normalized.SkillName
skill.Description = normalized.Description
skill.Tag = normalized.Tag
skill.IsPrivate = normalized.IsPrivate
skill.MarkdownURL = normalized.MarkdownURL
skill.ShowcaseMediaJSON = mediaJSON
skill.ExtraInfo = normalized.ExtraInfo
if skill.SourceType == "markdown" || skill.SourceType == "builtin" || skill.SourceType == "" {
if err := s.updateSingleMarkdownSkill(skill, normalized); err != nil {
return nil, err
}
} else if err := s.repo.SaveSkill(skill); err != nil {
return nil, err
}
return s.SkillDetail(userID, skill.ID)
}
func (s *Service) DeleteSkill(userID string, id string) error {
skill, err := s.ownedSkill(userID, id)
if err != nil {
return err
}
if err := s.repo.DeleteSkill(skill.ID); err != nil {
return err
}
return os.RemoveAll(filepath.Join(s.dataDir, "skill-packages", skill.ID))
}
func (s *Service) DeleteBuiltinSkill(actor *model.User, id string) error {
if actor == nil || actor.Role != model.UserRoleAdmin {
return kernel.Forbidden("只有管理员可以删除内置技能")
}
id = strings.TrimSpace(id)
if id == "" {
return kernel.BadAuthRequest("技能 ID 不能为空")
}
skill, err := s.repo.Skill(id)
if errors.Is(err, gorm.ErrRecordNotFound) {
deleted, tombstoneErr := s.repo.BuiltinSkillTombstoned(id)
if tombstoneErr != nil {
return tombstoneErr
}
if deleted {
return nil
}
return kernel.NotFound("技能不存在")
}
if err != nil {
return err
}
if skill.SourceType != "builtin" && skill.Source != 3 {
return ErrNotBuiltinSkill
}
if err := s.repo.DeleteBuiltinSkill(id, actor.ID); err != nil {
return err
}
return os.RemoveAll(filepath.Join(s.dataDir, "skill-packages", id))
}
func (s *Service) SetSkillAdded(userID string, id string, added bool) (*SkillItem, error) {
skill, err := s.visibleSkill(userID, id)
if err != nil {
return nil, err
}
if skill.OwnerID == userID {
if !added {
return nil, kernel.BadAuthRequest("自己创建的技能始终保留在我的技能中")
}
return s.SkillDetail(userID, id)
}
state, err := s.skillState(userID, id)
if err != nil {
return nil, err
}
state.Added = added
state.InstalledVersionID = skill.CurrentVersionID
state.AutoUpdate = skill.AutoUpdate
if err := s.repo.SetUserSkillAdded(state); err != nil {
return nil, err
}
return s.SkillDetail(userID, id)
}
func (s *Service) SetSkillLiked(userID string, id string, liked bool) (*SkillItem, error) {
if _, err := s.visibleSkill(userID, id); err != nil {
return nil, err
}
state, err := s.skillState(userID, id)
if err != nil {
return nil, err
}
state.Liked = liked
if err := s.repo.SetUserSkillLiked(state); err != nil {
return nil, err
}
return s.SkillDetail(userID, id)
}
func (s *Service) skillItems(userID string, skills []model.Skill, includeInstruction bool) ([]SkillItem, error) {
ids := make([]string, 0, len(skills))
ownerIDs := make([]string, 0, len(skills))
for _, skill := range skills {
ids = append(ids, skill.ID)
ownerIDs = append(ownerIDs, skill.OwnerID)
}
states, err := s.repo.UserSkillStatesBySkillIDs(userID, ids)
if err != nil {
return nil, err
}
metrics, err := s.repo.SkillMetrics(ids)
if err != nil {
return nil, err
}
owners, err := s.repo.SkillOwners(ownerIDs)
if err != nil {
return nil, err
}
ownerAvatars, err := s.repo.SkillOwnerAvatars(ownerIDs)
if err != nil {
return nil, err
}
stateBySkillID := make(map[string]model.UserSkillState, len(states))
for _, state := range states {
stateBySkillID[state.SkillID] = state
}
items := make([]SkillItem, 0, len(skills))
for _, skill := range skills {
showcaseMedia, err := parseShowcaseMedia(skill.ShowcaseMediaJSON)
if err != nil {
return nil, err
}
owner := owners[skill.OwnerID]
ownerName := strings.TrimSpace(skill.AuthorName)
ownerAvatarURL := strings.TrimSpace(skill.AuthorAvatarURL)
if strings.TrimSpace(owner.DisplayName) != "" {
ownerName = strings.TrimSpace(owner.DisplayName)
} else if strings.TrimSpace(owner.Username) != "" {
ownerName = strings.TrimSpace(owner.Username)
}
if ownerAvatars[skill.OwnerID] != "" {
ownerAvatarURL = ownerAvatars[skill.OwnerID]
}
state := stateBySkillID[skill.ID]
metric := metrics[skill.ID]
metric.LikeCount += skill.InitialLikeCount
metric.AddedCount += skill.InitialAddedCount
instruction := ""
if includeInstruction {
instruction = skill.Instruction
}
items = append(items, SkillItem{
SkillID: skill.ID, SkillName: skill.Name, Description: skill.Description, Instruction: instruction,
VersionID: skill.CurrentVersionID, Version: skill.VersionLabel, ContentHash: skill.ContentHash, FileCount: skill.FileCount, TotalBytes: skill.TotalBytes,
SourceType: skill.SourceType, SourceURL: skill.SourceURL, SourceRef: skill.SourceRef, SourceSubdir: skill.SourceSubdir, SourceCommit: skill.SourceCommit,
SyncStatus: skill.SyncStatus, SyncError: skill.SyncError, AutoUpdate: skill.AutoUpdate, LastCheckedAt: skill.LastCheckedAt, LastSyncedAt: skill.LastSyncedAt,
Status: skill.Status, MarkdownURL: skill.MarkdownURL, CreatedAt: skill.CreatedAt, UpdatedAt: skill.UpdatedAt,
Source: skill.Source, Tag: skill.Tag, SortWeight: skill.SortWeight, IsPrivate: skill.IsPrivate,
LikeCount: metric.LikeCount, IsLike: state.Liked, OwnerUID: skill.OwnerID,
EffectiveUser: SkillEffectiveUser{Name: ownerName, AvatarURL: ownerAvatarURL, UID: skill.OwnerID}, ShowcaseMedia: showcaseMedia,
AddedCount: metric.AddedCount, ExtraInfo: skill.ExtraInfo, IsAdded: state.Added || skill.OwnerID == userID, IsOwner: skill.OwnerID == userID,
LibraryCategoryID: state.LibraryCategoryID,
})
}
return items, nil
}
// 所有详情和关系写入都先经过同一可见性边界,避免私有技能通过加入、收藏或画布接口泄露正文。
func (s *Service) visibleSkill(userID string, id string) (*model.Skill, error) {
id = strings.TrimSpace(id)
if id == "" {
return nil, kernel.BadAuthRequest("技能 ID 不能为空")
}
skill, err := s.repo.Skill(id)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, kernel.BadAuthRequest("技能不存在或已删除")
}
if err != nil {
return nil, err
}
if skill.IsPrivate && skill.OwnerID != userID {
return nil, kernel.Forbidden("该技能未公开")
}
return skill, nil
}
func (s *Service) ownedSkill(userID string, id string) (*model.Skill, error) {
skill, err := s.visibleSkill(userID, id)
if err != nil {
return nil, err
}
if skill.OwnerID != userID {
return nil, kernel.Forbidden("只有作者可以修改或删除该技能")
}
return skill, nil
}
func (s *Service) skillState(userID string, skillID string) (*model.UserSkillState, error) {
state, err := s.repo.UserSkillState(userID, skillID)
if err != nil {
return nil, err
}
if state != nil {
return state, nil
}
return &model.UserSkillState{ID: kernel.NewID(), UserID: userID, SkillID: skillID}, nil
}
func normalizeSkillListRequest(req SkillListRequest) SkillListRequest {
if req.Page <= 0 {
req.Page = 1
}
if req.PageSize <= 0 {
req.PageSize = 20
}
if req.PageSize > 80 {
req.PageSize = 80
}
switch req.Scope {
case "public", "mine", "created", "favorites":
default:
req.Scope = "public"
}
switch req.Sort {
case "popular", "new", "updated":
default:
req.Sort = "popular"
}
req.Search = strings.TrimSpace(req.Search)
req.Tag = strings.TrimSpace(req.Tag)
if req.Tag != "" {
if _, ok := skillCategoryLabels[req.Tag]; !ok {
req.Tag = ""
}
}
return req
}
func normalizeSkillMutationRequest(req SkillMutationRequest, requireInstruction bool) (SkillMutationRequest, string, error) {
req.SkillName = strings.TrimSpace(req.SkillName)
req.Description = strings.TrimSpace(req.Description)
req.Instruction = strings.TrimSpace(req.Instruction)
req.Tag = strings.TrimSpace(req.Tag)
req.MarkdownURL = strings.TrimSpace(req.MarkdownURL)
req.ExtraInfo = strings.TrimSpace(req.ExtraInfo)
if req.SkillName == "" || utf8.RuneCountInString(req.SkillName) > 80 {
return req, "", kernel.BadAuthRequest("技能名称必须为 1-80 个字符")
}
if req.Description == "" || utf8.RuneCountInString(req.Description) > 500 {
return req, "", kernel.BadAuthRequest("技能简介必须为 1-500 个字符")
}
if (requireInstruction && req.Instruction == "") || utf8.RuneCountInString(req.Instruction) > 100000 {
return req, "", kernel.BadAuthRequest("技能指令必须为 1-100000 个字符")
}
if _, ok := skillCategoryLabels[req.Tag]; !ok {
return req, "", kernel.BadAuthRequest("请选择有效的技能分类")
}
if req.MarkdownURL != "" && !validSkillURL(req.MarkdownURL) {
return req, "", kernel.BadAuthRequest("Markdown 地址必须是有效的 HTTP(S) 链接")
}
if utf8.RuneCountInString(req.ExtraInfo) > 2000 {
return req, "", kernel.BadAuthRequest("补充信息不能超过 2000 个字符")
}
if len(req.ShowcaseMedia) > 8 {
return req, "", kernel.BadAuthRequest("展示媒体最多添加 8 个")
}
for index := range req.ShowcaseMedia {
media := &req.ShowcaseMedia[index]
media.Type = strings.TrimSpace(media.Type)
media.ShowcaseURI = strings.TrimSpace(media.ShowcaseURI)
media.ShowcaseURL = strings.TrimSpace(media.ShowcaseURL)
if media.Type != "image" && media.Type != "video" {
return req, "", kernel.BadAuthRequest("展示媒体类型仅支持图片或视频")
}
if !validSkillURL(media.ShowcaseURL) {
return req, "", kernel.BadAuthRequest("展示媒体必须填写有效的 HTTP(S) 链接")
}
if utf8.RuneCountInString(media.ShowcaseURI) > 500 || utf8.RuneCountInString(media.ShowcaseURL) > 2000 {
return req, "", kernel.BadAuthRequest("展示媒体地址过长")
}
}
mediaJSON, err := json.Marshal(req.ShowcaseMedia)
if err != nil {
return req, "", err
}
return req, string(mediaJSON), nil
}
func validSkillURL(value string) bool {
target, err := url.ParseRequestURI(value)
return err == nil && (target.Scheme == "http" || target.Scheme == "https") && target.Host != ""
}
func skillCategories() []SkillCategory {
return []SkillCategory{
{Value: "drama", Label: skillCategoryLabels["drama"]},
{Value: "ecommerce", Label: skillCategoryLabels["ecommerce"]},
{Value: "creative", Label: skillCategoryLabels["creative"]},
{Value: "social", Label: skillCategoryLabels["social"]},
{Value: "others", Label: skillCategoryLabels["others"]},
}
}
// parseShowcaseMedia 读取库内展示媒体 JSON。历史记录可能仍是 snake_case,只在持久化层转换;对外 API 只输出 camelCase。
func parseShowcaseMedia(raw string) ([]SkillShowcaseMedia, error) {
raw = strings.TrimSpace(raw)
if raw == "" || raw == "null" {
return []SkillShowcaseMedia{}, nil
}
var items []SkillShowcaseMedia
if err := json.Unmarshal([]byte(raw), &items); err != nil {
return nil, errors.New("技能展示媒体数据格式错误")
}
if showcaseMediaHasURL(items) {
return items, nil
}
var legacy []struct {
Type string `json:"type"`
ShowcaseURI string `json:"showcase_uri"`
ShowcaseURL string `json:"showcase_url"`
}
if err := json.Unmarshal([]byte(raw), &legacy); err != nil {
return nil, errors.New("技能展示媒体数据格式错误")
}
converted := make([]SkillShowcaseMedia, 0, len(legacy))
for _, item := range legacy {
converted = append(converted, SkillShowcaseMedia{Type: item.Type, ShowcaseURI: item.ShowcaseURI, ShowcaseURL: item.ShowcaseURL})
}
return converted, nil
}
func showcaseMediaHasURL(items []SkillShowcaseMedia) bool {
for _, item := range items {
if strings.TrimSpace(item.ShowcaseURI) != "" || strings.TrimSpace(item.ShowcaseURL) != "" {
return true
}
}
return len(items) == 0
}