599 lines
20 KiB
Go
599 lines
20 KiB
Go
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
|
||
}
|