feat(*): 技能库与认证体验 - 重构共享技能库并优化登录页面与默认创作入口

This commit is contained in:
ddCat
2026-08-02 16:27:22 +08:00
parent 219809c320
commit adbbe96ce4
28 changed files with 2480 additions and 963 deletions

View File

@@ -1,14 +1,7 @@
package handler
import (
"context"
"errors"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"infinite-canvas/backend/internal/service"
@@ -16,28 +9,17 @@ import (
)
func RegisterSkillRoutes(r *gin.RouterGroup, svc *service.Service) {
r.GET("/skills/image", proxySkillImage)
r.GET("/skills/capabilities", func(c *gin.Context) {
if _, err := currentUser(c, svc); err != nil {
failService(c, err)
return
}
ok(c, gin.H{"capabilities": svc.SkillIntegrationCapabilities()})
})
r.GET("/skills/community", func(c *gin.Context) {
r.GET("/skills", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "12"))
result, err := svc.CommunitySkills(c.Request.Context(), user.ID, service.CommunitySkillsRequest{
Page: page,
PageSize: pageSize,
Sort: c.DefaultQuery("sort", "hot"),
Search: c.Query("search"),
Categories: c.QueryArray("categories"),
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
result, err := svc.Skills(user.ID, service.SkillListRequest{
Page: page, PageSize: pageSize, Scope: c.DefaultQuery("scope", "public"),
Search: c.Query("search"), Tag: c.Query("tag"), Sort: c.DefaultQuery("sort", "popular"),
})
if err != nil {
failService(c, err)
@@ -45,158 +27,120 @@ func RegisterSkillRoutes(r *gin.RouterGroup, svc *service.Service) {
}
ok(c, result)
})
r.GET("/skills/activated", func(c *gin.Context) {
r.GET("/skills/added", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
skills, err := svc.ActivatedSkills(c.Request.Context(), user.ID)
skills, err := svc.AddedSkills(user.ID)
if err != nil {
failService(c, err)
return
}
ok(c, gin.H{"skills": skills})
})
r.GET("/skills/favorites", func(c *gin.Context) {
r.GET("/skills/:id", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
skills, err := svc.FavoriteSkills(c.Request.Context(), user.ID)
if err != nil {
failService(c, err)
return
}
ok(c, gin.H{"skills": skills})
})
r.GET("/skills/community/:dir", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
skill, err := svc.CommunitySkillDetail(c.Request.Context(), user.ID, c.Param("dir"))
skill, err := svc.SkillDetail(user.ID, c.Param("id"))
if err != nil {
failService(c, err)
return
}
ok(c, gin.H{"skill": skill})
})
r.POST("/skills/:dir/activate", func(c *gin.Context) {
r.POST("/skills", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
skill, err := svc.SetSkillActivated(c.Request.Context(), user.ID, c.Param("dir"), true)
var req service.SkillMutationRequest
if err := c.ShouldBindJSON(&req); err != nil {
failService(c, service.BadAuthRequest("技能数据格式无效"))
return
}
skill, err := svc.CreateSkill(user.ID, req)
if err != nil {
failService(c, err)
return
}
ok(c, gin.H{"skill": skill})
})
r.DELETE("/skills/:dir/activate", func(c *gin.Context) {
r.PUT("/skills/:id", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
skill, err := svc.SetSkillActivated(c.Request.Context(), user.ID, c.Param("dir"), false)
var req service.SkillMutationRequest
if err := c.ShouldBindJSON(&req); err != nil {
failService(c, service.BadAuthRequest("技能数据格式无效"))
return
}
skill, err := svc.UpdateSkill(user.ID, c.Param("id"), req)
if err != nil {
failService(c, err)
return
}
ok(c, gin.H{"skill": skill})
})
r.POST("/skills/:dir/favorite", func(c *gin.Context) {
r.DELETE("/skills/:id", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
skill, err := svc.SetSkillLiked(c.Request.Context(), user.ID, c.Param("dir"), true)
if err != nil {
if err := svc.DeleteSkill(user.ID, c.Param("id")); err != nil {
failService(c, err)
return
}
ok(c, gin.H{"skill": skill})
})
r.DELETE("/skills/:dir/favorite", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
skill, err := svc.SetSkillLiked(c.Request.Context(), user.ID, c.Param("dir"), false)
if err != nil {
failService(c, err)
return
}
ok(c, gin.H{"skill": skill})
ok(c, gin.H{"deleted": true})
})
r.POST("/skills/:id/add", setSkillAdded(svc, true))
r.DELETE("/skills/:id/add", setSkillAdded(svc, false))
r.POST("/skills/:id/like", setSkillLiked(svc, true))
r.DELETE("/skills/:id/like", setSkillLiked(svc, false))
}
func proxySkillImage(c *gin.Context) {
rawURL := strings.TrimSpace(c.Query("url"))
target, err := url.Parse(rawURL)
if err != nil || !allowedSkillImageURL(target) {
fail(c, http.StatusBadRequest, errInvalidSkillImageURL())
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target.String(), nil)
if err != nil {
fail(c, http.StatusBadRequest, err)
return
}
req.Header.Set("Accept", "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8")
req.Header.Set("Referer", "https://www.updream.cn/")
req.Header.Set("User-Agent", "Mozilla/5.0 InfiniteCanvas/skills-image-proxy")
client := service.OutboundHTTPClient(15 * time.Second)
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 || !allowedSkillImageURL(req.URL) {
return errInvalidSkillImageURL()
func setSkillAdded(svc *service.Service, added bool) gin.HandlerFunc {
return func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
return nil
skill, err := svc.SetSkillAdded(user.ID, c.Param("id"), added)
if err != nil {
failService(c, err)
return
}
ok(c, gin.H{"skill": skill})
}
resp, err := client.Do(req)
if err != nil {
fail(c, http.StatusBadGateway, err)
return
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
fail(c, http.StatusBadGateway, errSkillImageUpstream(resp.StatusCode))
return
}
contentType := resp.Header.Get("Content-Type")
if contentType == "" {
contentType = "application/octet-stream"
}
c.Header("Cache-Control", "public, max-age=604800")
if etag := resp.Header.Get("ETag"); etag != "" {
c.Header("ETag", etag)
}
c.Status(http.StatusOK)
c.Header("Content-Type", contentType)
_, _ = io.Copy(c.Writer, resp.Body)
}
func allowedSkillImageURL(target *url.URL) bool {
if target == nil || (target.Scheme != "https" && target.Scheme != "http") {
return false
func setSkillLiked(svc *service.Service, liked bool) gin.HandlerFunc {
return func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
skill, err := svc.SetSkillLiked(user.ID, c.Param("id"), liked)
if err != nil {
failService(c, err)
return
}
ok(c, gin.H{"skill": skill})
}
host := strings.ToLower(target.Hostname())
return host == "updream.cn" || strings.HasSuffix(host, ".updream.cn") || host == "hdslb.com" || strings.HasSuffix(host, ".hdslb.com")
}
func errInvalidSkillImageURL() error {
return errors.New("不支持的技能图片地址")
}
func errSkillImageUpstream(status int) error {
return errors.New("技能图片读取失败: " + strconv.Itoa(status))
}