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

448 lines
14 KiB
Go

package service
import (
"encoding/json"
"errors"
"net/url"
"strings"
"unicode/utf8"
"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:"showcase_uri"`
ShowcaseURL string `json:"showcase_url"`
}
type SkillEffectiveUser struct {
Name string `json:"name"`
AvatarURL string `json:"avatar_url"`
UID string `json:"uid"`
}
type SkillItem struct {
SkillID string `json:"skill_id"`
SkillName string `json:"skill_name"`
Description string `json:"description"`
Instruction string `json:"instruction,omitempty"`
Status int `json:"status"`
MarkdownURL string `json:"markdown_url"`
CreateTime int64 `json:"create_time"`
UpdateTime int64 `json:"update_time"`
Source int `json:"source"`
Tag string `json:"tag"`
SortWeight int `json:"sort_weight"`
IsPrivate bool `json:"is_private"`
LikeCount int64 `json:"like_count"`
IsLike bool `json:"is_like"`
OwnerUID string `json:"owner_uid"`
EffectiveUser SkillEffectiveUser `json:"effective_user"`
OriginalSkillID *string `json:"original_skill_id"`
ShowcaseMedia []SkillShowcaseMedia `json:"showcase_media"`
AddedCount int64 `json:"added_count"`
IsTest bool `json:"is_test"`
ExtraInfo string `json:"extra_info"`
IsAdded bool `json:"is_added"`
IsOwner bool `json:"is_owner"`
}
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:"total_count"`
HasMore bool `json:"has_more"`
NextOffset int `json:"next_offset"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Categories []SkillCategory `json:"categories"`
}
type SkillMutationRequest struct {
SkillName string `json:"skill_name"`
Description string `json:"description"`
Instruction string `json:"instruction"`
Tag string `json:"tag"`
IsPrivate bool `json:"is_private"`
MarkdownURL string `json:"markdown_url"`
ShowcaseMedia []SkillShowcaseMedia `json:"showcase_media"`
ExtraInfo string `json:"extra_info"`
}
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, true)
}
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)
if err != nil {
return nil, err
}
skill := &model.Skill{
ID: newID(),
OwnerID: userID,
Name: normalized.SkillName,
Description: normalized.Description,
Instruction: normalized.Instruction,
Status: skillStatusEnabled,
Source: skillSourceUser,
Tag: normalized.Tag,
IsPrivate: normalized.IsPrivate,
MarkdownURL: normalized.MarkdownURL,
ShowcaseMediaJSON: mediaJSON,
ExtraInfo: normalized.ExtraInfo,
}
state := &model.UserSkillState{ID: newID(), UserID: userID, SkillID: skill.ID, Added: true}
if err := s.repo.CreateSkill(skill, state); 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
}
normalized, mediaJSON, err := normalizeSkillMutationRequest(req)
if err != nil {
return nil, err
}
skill.Name = normalized.SkillName
skill.Description = normalized.Description
skill.Instruction = normalized.Instruction
skill.Tag = normalized.Tag
skill.IsPrivate = normalized.IsPrivate
skill.MarkdownURL = normalized.MarkdownURL
skill.ShowcaseMediaJSON = mediaJSON
skill.ExtraInfo = normalized.ExtraInfo
if err := s.repo.Save(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
}
return s.repo.DeleteSkill(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, BadAuthRequest("自己创建的技能始终保留在我的技能中")
}
return s.SkillDetail(userID, id)
}
state, err := s.skillState(userID, id)
if err != nil {
return nil, err
}
state.Added = added
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 {
var showcaseMedia []SkillShowcaseMedia
if err := json.Unmarshal([]byte(skill.ShowcaseMediaJSON), &showcaseMedia); err != nil {
return nil, errors.New("技能展示媒体数据格式错误")
}
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,
Status: skill.Status, MarkdownURL: skill.MarkdownURL, CreateTime: skill.CreatedAt.UnixMilli(), UpdateTime: skill.UpdatedAt.UnixMilli(),
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, BadAuthRequest("技能 ID 不能为空")
}
skill, err := s.repo.Skill(id)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, BadAuthRequest("技能不存在或已删除")
}
if err != nil {
return nil, err
}
if skill.IsPrivate && skill.OwnerID != userID {
return nil, 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, 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: 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) (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, "", BadAuthRequest("技能名称必须为 1-80 个字符")
}
if req.Description == "" || utf8.RuneCountInString(req.Description) > 500 {
return req, "", BadAuthRequest("技能简介必须为 1-500 个字符")
}
if req.Instruction == "" || utf8.RuneCountInString(req.Instruction) > 100000 {
return req, "", BadAuthRequest("技能指令必须为 1-100000 个字符")
}
if _, ok := skillCategoryLabels[req.Tag]; !ok {
return req, "", BadAuthRequest("请选择有效的技能分类")
}
if req.MarkdownURL != "" && !validSkillURL(req.MarkdownURL) {
return req, "", BadAuthRequest("Markdown 地址必须是有效的 HTTP(S) 链接")
}
if utf8.RuneCountInString(req.ExtraInfo) > 2000 {
return req, "", BadAuthRequest("补充信息不能超过 2000 个字符")
}
if len(req.ShowcaseMedia) > 8 {
return req, "", 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, "", BadAuthRequest("展示媒体类型仅支持图片或视频")
}
if !validSkillURL(media.ShowcaseURL) {
return req, "", BadAuthRequest("展示媒体必须填写有效的 HTTP(S) 链接")
}
if utf8.RuneCountInString(media.ShowcaseURI) > 500 || utf8.RuneCountInString(media.ShowcaseURL) > 2000 {
return req, "", 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"]},
}
}