448 lines
14 KiB
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"]},
|
|
}
|
|
}
|