package skills import ( "encoding/json" "errors" "net/url" "os" "path/filepath" "strings" "time" "unicode/utf8" "infinite-canvas/backend/internal/kernel" "infinite-canvas/backend/internal/model" "infinite-canvas/backend/internal/repository" "gorm.io/gorm" ) const ( skillStatusEnabled = 1 skillSourceUser = 1 ) 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"` } type SkillCategory struct { Value string `json:"value"` Label string `json:"label"` } type SkillListRequest struct { Page int PageSize int Scope string Search string Tag string 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) rows, total, err := s.repo.Skills(repository.SkillListFilter{ UserID: userID, Scope: req.Scope, Search: req.Search, Tag: req.Tag, 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) ([]SkillItem, error) { rows, _, err := s.repo.Skills(repository.SkillListFilter{UserID: userID, Scope: "mine", Sort: "updated", Limit: -1}) if err != nil { return nil, err } return s.skillItems(userID, rows, false) } 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) 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, }) } 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 }