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

422 lines
12 KiB
Go

package service
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"infinite-canvas/backend/internal/model"
"golang.org/x/net/html"
)
const updreamSkillCommunityURL = "https://www.updream.cn/api/skills/community"
type CommunitySkillsRequest struct {
Page int
PageSize int
Sort string
Search string
Categories []string
}
type CommunitySkillList struct {
Skills []UpdreamSkill `json:"skills"`
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Categories []string `json:"categories"`
}
type SkillIntegrationCapabilities struct {
Provider string `json:"provider"`
PublicCommunity bool `json:"publicCommunity"`
CategoryFilter bool `json:"categoryFilter"`
PublicRankings bool `json:"publicRankings"`
PrivateAuthorization string `json:"privateAuthorization"`
Upload bool `json:"upload"`
Comments bool `json:"comments"`
}
func (s *Service) SkillIntegrationCapabilities() SkillIntegrationCapabilities {
return SkillIntegrationCapabilities{Provider: "updream", PublicCommunity: true, CategoryFilter: true, PublicRankings: true, PrivateAuthorization: "not_configured", Upload: false, Comments: false}
}
type UpdreamSkill struct {
Dir string `json:"dir"`
Name string `json:"name"`
Description string `json:"description"`
IconURL string `json:"icon_url"`
CoverURL string `json:"cover_url"`
DetailContent string `json:"detail_content"`
DetailText string `json:"detail_text"`
Categories []string `json:"categories"`
Version int `json:"version"`
UploaderID int64 `json:"uploader_id"`
UploaderName string `json:"uploader_name"`
UploaderAvatar string `json:"uploader_avatar"`
IsPrivate bool `json:"is_private"`
ReviewStatus string `json:"review_status"`
CTime string `json:"ctime"`
MTime string `json:"mtime"`
FeaturedLabel string `json:"featured_label"`
ActivationCount int `json:"activation_count"`
LikeCount int `json:"like_count"`
UsageCount int `json:"usage_count"`
CommentCount int `json:"comment_count"`
RatingCount int `json:"rating_count"`
AvgRating *float64 `json:"avg_rating"`
HotScore int `json:"hot_score"`
Liked bool `json:"liked"`
Activated bool `json:"activated"`
UserRating *float64 `json:"user_rating"`
ShareScope string `json:"share_scope"`
ShareTeamID any `json:"share_team_id"`
}
type updreamCommunityResponse struct {
Skills []UpdreamSkill `json:"skills"`
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
func (s *Service) CommunitySkills(ctx context.Context, userID string, req CommunitySkillsRequest) (*CommunitySkillList, error) {
req = normalizeCommunitySkillsRequest(req)
skillsURL, err := updreamCommunityListURL(req)
if err != nil {
return nil, err
}
var payload updreamCommunityResponse
if err := fetchUpdreamJSON(ctx, skillsURL, &payload); err != nil {
return nil, err
}
states, err := s.repo.UserSkillStatesByDirs(userID, skillDirs(payload.Skills))
if err != nil {
return nil, err
}
applyUserSkillStates(payload.Skills, states)
for index := range payload.Skills {
payload.Skills[index].DetailText = strings.TrimSpace(payload.Skills[index].Description)
payload.Skills[index].DetailContent = ""
}
categories := []string{}
for _, skill := range payload.Skills {
categories = append(categories, skill.Categories...)
}
return &CommunitySkillList{Skills: payload.Skills, Total: payload.Total, Page: payload.Page, PageSize: payload.PageSize, Categories: cleanStringList(categories)}, nil
}
func (s *Service) CommunitySkillDetail(ctx context.Context, userID string, dir string) (*UpdreamSkill, error) {
dir = strings.TrimSpace(dir)
if dir == "" {
return nil, BadAuthRequest("技能标识不能为空")
}
skill, err := fetchUpdreamSkillDetail(ctx, dir)
if err != nil {
return nil, err
}
states, err := s.repo.UserSkillStatesByDirs(userID, []string{dir})
if err != nil {
return nil, err
}
if len(states) > 0 {
skill.Activated = states[0].Activated
skill.Liked = states[0].Liked
} else {
skill.Activated = false
skill.Liked = false
}
skill.DetailText = detailTextFromHTML(skill.DetailContent, skill.Description)
skill.DetailContent = ""
return skill, nil
}
func (s *Service) ActivatedSkills(ctx context.Context, userID string) ([]UpdreamSkill, error) {
return s.userStateSkills(ctx, userID, func(state model.UserSkillState) bool { return state.Activated })
}
func (s *Service) FavoriteSkills(ctx context.Context, userID string) ([]UpdreamSkill, error) {
return s.userStateSkills(ctx, userID, func(state model.UserSkillState) bool { return state.Liked })
}
func (s *Service) userStateSkills(ctx context.Context, userID string, pick func(model.UserSkillState) bool) ([]UpdreamSkill, error) {
states, err := s.repo.UserSkillStates(userID)
if err != nil {
return nil, err
}
skills := make([]UpdreamSkill, 0, len(states))
var firstErr error
for _, state := range states {
if !pick(state) {
continue
}
skill, err := fetchUpdreamSkillDetail(ctx, state.SkillDir)
if err != nil {
if firstErr == nil {
firstErr = err
}
continue
}
skill.Activated = state.Activated
skill.Liked = state.Liked
skill.DetailText = detailTextFromHTML(skill.DetailContent, skill.Description)
skill.DetailContent = ""
skills = append(skills, *skill)
}
if len(skills) == 0 && firstErr != nil {
return nil, firstErr
}
return skills, nil
}
func (s *Service) SetSkillActivated(ctx context.Context, userID string, dir string, activated bool) (*UpdreamSkill, error) {
skill, err := fetchUpdreamSkillDetail(ctx, strings.TrimSpace(dir))
if err != nil {
return nil, err
}
state, err := s.ensureUserSkillState(userID, skill.Dir)
if err != nil {
return nil, err
}
state.Activated = activated
if err := s.repo.Save(state); err != nil {
return nil, err
}
skill.Activated = state.Activated
skill.Liked = state.Liked
skill.DetailText = detailTextFromHTML(skill.DetailContent, skill.Description)
skill.DetailContent = ""
return skill, nil
}
func (s *Service) SetSkillLiked(ctx context.Context, userID string, dir string, liked bool) (*UpdreamSkill, error) {
skill, err := fetchUpdreamSkillDetail(ctx, strings.TrimSpace(dir))
if err != nil {
return nil, err
}
state, err := s.ensureUserSkillState(userID, skill.Dir)
if err != nil {
return nil, err
}
state.Liked = liked
if err := s.repo.Save(state); err != nil {
return nil, err
}
skill.Activated = state.Activated
skill.Liked = state.Liked
skill.DetailText = detailTextFromHTML(skill.DetailContent, skill.Description)
skill.DetailContent = ""
return skill, nil
}
func (s *Service) ensureUserSkillState(userID string, dir string) (*model.UserSkillState, error) {
if strings.TrimSpace(userID) == "" {
return nil, Unauthorized("请先登录")
}
if strings.TrimSpace(dir) == "" {
return nil, BadAuthRequest("技能标识不能为空")
}
state, err := s.repo.UserSkillState(userID, dir)
if err != nil {
return nil, err
}
if state != nil {
return state, nil
}
return &model.UserSkillState{ID: newID(), UserID: userID, SkillDir: dir}, nil
}
func normalizeCommunitySkillsRequest(req CommunitySkillsRequest) CommunitySkillsRequest {
if req.Page <= 0 {
req.Page = 1
}
if req.PageSize <= 0 {
req.PageSize = 12
}
if req.PageSize > 48 {
req.PageSize = 48
}
switch req.Sort {
case "hot", "top_rated", "new":
default:
req.Sort = "hot"
}
req.Search = strings.TrimSpace(req.Search)
req.Categories = cleanStringList(req.Categories)
return req
}
func updreamCommunityListURL(req CommunitySkillsRequest) (string, error) {
target, err := url.Parse(updreamSkillCommunityURL)
if err != nil {
return "", err
}
values := target.Query()
values.Set("page", strconv.Itoa(req.Page))
values.Set("page_size", strconv.Itoa(req.PageSize))
values.Set("sort", req.Sort)
if req.Search != "" {
values.Set("search", req.Search)
}
for _, category := range req.Categories {
values.Add("categories", category)
}
target.RawQuery = values.Encode()
return target.String(), nil
}
func fetchUpdreamSkillDetail(ctx context.Context, dir string) (*UpdreamSkill, error) {
dir = strings.TrimSpace(dir)
if dir == "" {
return nil, BadAuthRequest("技能标识不能为空")
}
var skill UpdreamSkill
if err := fetchUpdreamJSON(ctx, updreamSkillCommunityURL+"/"+url.PathEscape(dir), &skill); err != nil {
return nil, err
}
if skill.Dir == "" {
skill.Dir = dir
}
return &skill, nil
}
func fetchUpdreamJSON(ctx context.Context, target string, value any) error {
ctx, cancel := context.WithTimeout(ctx, 12*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if err != nil {
return err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "InfiniteCanvas/skills-proxy")
resp, err := OutboundHTTPClient(12 * time.Second).Do(req)
if err != nil {
return fmt.Errorf("Updream 技能接口连接失败: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("Updream 技能接口响应读取失败: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("Updream 技能接口返回 %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
if err := json.Unmarshal(body, value); err != nil {
return fmt.Errorf("Updream 技能接口数据格式错误: %w", err)
}
return nil
}
func applyUserSkillStates(skills []UpdreamSkill, states []model.UserSkillState) {
byDir := make(map[string]model.UserSkillState, len(states))
for _, state := range states {
byDir[state.SkillDir] = state
}
for index := range skills {
state, ok := byDir[skills[index].Dir]
if !ok {
skills[index].Activated = false
skills[index].Liked = false
continue
}
skills[index].Activated = state.Activated
skills[index].Liked = state.Liked
}
}
func skillDirs(skills []UpdreamSkill) []string {
dirs := make([]string, 0, len(skills))
for _, skill := range skills {
if strings.TrimSpace(skill.Dir) != "" {
dirs = append(dirs, skill.Dir)
}
}
return dirs
}
func detailTextFromHTML(raw string, fallback string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return strings.TrimSpace(fallback)
}
doc, err := html.Parse(strings.NewReader(raw))
if err != nil {
return strings.Join(strings.Fields(stripHTMLTags(raw)), " ")
}
lines := make([]string, 0, 64)
var walk func(*html.Node, bool)
walk = func(node *html.Node, skip bool) {
if node == nil {
return
}
nextSkip := skip || isSkippedHTMLNode(node)
if node.Type == html.TextNode && !nextSkip {
text := strings.Join(strings.Fields(node.Data), " ")
if text != "" {
lines = append(lines, text)
}
}
for child := node.FirstChild; child != nil; child = child.NextSibling {
walk(child, nextSkip)
}
}
walk(doc, false)
text := strings.TrimSpace(strings.Join(lines, "\n"))
if text == "" {
text = strings.TrimSpace(fallback)
}
return text
}
func isSkippedHTMLNode(node *html.Node) bool {
if node.Type != html.ElementNode {
return false
}
switch strings.ToLower(node.Data) {
case "script", "style", "noscript", "svg", "canvas":
return true
default:
return false
}
}
func stripHTMLTags(value string) string {
var builder strings.Builder
inTag := false
for _, char := range value {
switch {
case char == '<':
inTag = true
case char == '>':
inTag = false
builder.WriteRune(' ')
case !inTag:
builder.WriteRune(char)
}
}
return builder.String()
}
func cleanStringList(values []string) []string {
result := make([]string, 0, len(values))
seen := map[string]bool{}
for _, value := range values {
for _, item := range strings.Split(value, ",") {
item = strings.TrimSpace(item)
if item == "" || seen[item] {
continue
}
seen[item] = true
result = append(result, item)
}
}
return result
}