feat: add SproutWorkCollect apps

This commit is contained in:
2026-03-13 17:14:37 +08:00
parent 189baa3d59
commit 46afd3149f
54 changed files with 28126 additions and 4 deletions

View File

@@ -0,0 +1,317 @@
package handler
import (
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/gin-gonic/gin"
"sproutworkcollect-backend/internal/model"
"sproutworkcollect-backend/internal/service"
)
const maxUploadBytes = 5000 << 20 // 5 000 MB
// AdminHandler handles admin-only API endpoints (protected by AdminAuth middleware).
type AdminHandler struct {
workSvc *service.WorkService
}
// NewAdminHandler wires up an AdminHandler.
func NewAdminHandler(workSvc *service.WorkService) *AdminHandler {
return &AdminHandler{workSvc: workSvc}
}
// GetWorks handles GET /api/admin/works
func (h *AdminHandler) GetWorks(c *gin.Context) {
works, err := h.workSvc.LoadAllWorks()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
return
}
responses := make([]*model.WorkResponse, len(works))
for i, w := range works {
responses[i] = h.workSvc.BuildResponse(w)
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": responses, "total": len(responses)})
}
// CreateWork handles POST /api/admin/works
func (h *AdminHandler) CreateWork(c *gin.Context) {
var data model.WorkConfig
if err := c.ShouldBindJSON(&data); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "请求数据格式错误"})
return
}
if data.WorkID == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "作品ID不能为空"})
return
}
ts := time.Now().Format("2006-01-02T15:04:05.000000")
data.UploadTime = ts
data.UpdateTime = ts
data.UpdateCount = 0
data.Downloads = 0
data.Views = 0
data.Likes = 0
data.Normalize()
if err := h.workSvc.CreateWork(&data); err != nil {
status := http.StatusInternalServerError
if strings.Contains(err.Error(), "已存在") {
status = http.StatusConflict
}
c.JSON(status, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "创建成功", "work_id": data.WorkID})
}
// UpdateWork handles PUT /api/admin/works/:work_id
func (h *AdminHandler) UpdateWork(c *gin.Context) {
workID := c.Param("work_id")
var data model.WorkConfig
if err := c.ShouldBindJSON(&data); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "请求数据格式错误"})
return
}
if err := h.workSvc.UpdateWork(workID, &data); err != nil {
status := http.StatusInternalServerError
if strings.Contains(err.Error(), "不存在") {
status = http.StatusNotFound
}
c.JSON(status, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "更新成功"})
}
// DeleteWork handles DELETE /api/admin/works/:work_id
func (h *AdminHandler) DeleteWork(c *gin.Context) {
if err := h.workSvc.DeleteWork(c.Param("work_id")); err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "删除成功"})
}
// UploadFile handles POST /api/admin/upload/:work_id/:file_type
// file_type: "image" | "video" | "platform"
// For "platform", the form field "platform" must specify the target platform name.
func (h *AdminHandler) UploadFile(c *gin.Context) {
workID := c.Param("work_id")
fileType := c.Param("file_type")
if !h.workSvc.WorkExists(workID) {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "作品不存在"})
return
}
fh, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "没有文件"})
return
}
if fh.Size > maxUploadBytes {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{
"success": false,
"message": fmt.Sprintf("文件太大,最大支持 %dMB当前 %dMB",
maxUploadBytes>>20, fh.Size>>20),
})
return
}
originalName := fh.Filename
safeName := service.SafeFilename(originalName)
ext := strings.ToLower(filepath.Ext(safeName))
allowed := map[string]bool{
".png": true, ".jpg": true, ".jpeg": true, ".gif": true,
".mp4": true, ".avi": true, ".mov": true,
".zip": true, ".rar": true, ".apk": true, ".exe": true, ".dmg": true,
}
if !allowed[ext] {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "不支持的文件格式"})
return
}
// Determine destination directory and resolve a unique filename.
// ModifyWork (called later) re-checks uniqueness under a write lock to avoid races.
work, err := h.workSvc.LoadWork(workID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "作品配置不存在"})
return
}
var saveDir string
var platform string
switch fileType {
case "image":
saveDir = filepath.Join(h.workSvc.WorksDir(), workID, "image")
case "video":
saveDir = filepath.Join(h.workSvc.WorksDir(), workID, "video")
case "platform":
platform = c.PostForm("platform")
if platform == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "平台参数缺失"})
return
}
saveDir = filepath.Join(h.workSvc.WorksDir(), workID, "platform", platform)
default:
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "不支持的文件类型"})
return
}
if err := os.MkdirAll(saveDir, 0755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "创建目录失败"})
return
}
// Pre-compute unique filename based on current state.
// The authoritative assignment happens inside ModifyWork below.
var previewName string
switch fileType {
case "image":
previewName = service.UniqueFilename(safeName, work.Screenshots)
case "video":
previewName = service.UniqueFilename(safeName, work.VideoFiles)
case "platform":
previewName = service.UniqueFilename(safeName, work.FileNames[platform])
}
destPath := filepath.Join(saveDir, previewName)
if err := c.SaveUploadedFile(fh, destPath); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": fmt.Sprintf("保存文件失败: %v", err),
})
return
}
// Atomically update the work config under a write lock.
var finalName string
modErr := h.workSvc.ModifyWork(workID, func(w *model.WorkConfig) {
// Re-derive the unique name inside the lock to handle concurrent uploads.
switch fileType {
case "image":
finalName = service.UniqueFilename(safeName, w.Screenshots)
case "video":
finalName = service.UniqueFilename(safeName, w.VideoFiles)
case "platform":
finalName = service.UniqueFilename(safeName, w.FileNames[platform])
}
// Rename the file on disk if the finalName differs from the pre-computed one.
if finalName != previewName {
newDest := filepath.Join(saveDir, finalName)
_ = os.Rename(destPath, newDest)
}
if w.OriginalNames == nil {
w.OriginalNames = make(map[string]string)
}
w.OriginalNames[finalName] = originalName
switch fileType {
case "image":
if !service.ContainsString(w.Screenshots, finalName) {
w.Screenshots = append(w.Screenshots, finalName)
}
if w.Cover == "" {
w.Cover = finalName
}
case "video":
if !service.ContainsString(w.VideoFiles, finalName) {
w.VideoFiles = append(w.VideoFiles, finalName)
}
case "platform":
if w.FileNames == nil {
w.FileNames = make(map[string][]string)
}
if !service.ContainsString(w.FileNames[platform], finalName) {
w.FileNames[platform] = append(w.FileNames[platform], finalName)
}
}
})
if modErr != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "更新配置失败"})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "上传成功",
"filename": finalName,
"file_size": fh.Size,
})
}
// DeleteFile handles DELETE /api/admin/delete-file/:work_id/:file_type/:filename
func (h *AdminHandler) DeleteFile(c *gin.Context) {
workID := c.Param("work_id")
fileType := c.Param("file_type")
filename := c.Param("filename")
if _, err := h.workSvc.LoadWork(workID); err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "作品不存在"})
return
}
platform := c.Query("platform")
var filePath string
switch fileType {
case "image":
filePath = filepath.Join(h.workSvc.WorksDir(), workID, "image", filename)
case "video":
filePath = filepath.Join(h.workSvc.WorksDir(), workID, "video", filename)
case "platform":
if platform == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "平台参数缺失"})
return
}
filePath = filepath.Join(h.workSvc.WorksDir(), workID, "platform", platform, filename)
default:
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "不支持的文件类型"})
return
}
_ = os.Remove(filePath)
modErr := h.workSvc.ModifyWork(workID, func(w *model.WorkConfig) {
if w.OriginalNames != nil {
delete(w.OriginalNames, filename)
}
switch fileType {
case "image":
w.Screenshots = service.RemoveString(w.Screenshots, filename)
if w.Cover == filename {
if len(w.Screenshots) > 0 {
w.Cover = w.Screenshots[0]
} else {
w.Cover = ""
}
}
case "video":
w.VideoFiles = service.RemoveString(w.VideoFiles, filename)
case "platform":
if w.FileNames != nil {
w.FileNames[platform] = service.RemoveString(w.FileNames[platform], filename)
}
}
})
if modErr != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "更新配置失败"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "删除成功"})
}

View File

@@ -0,0 +1,72 @@
package handler
import (
"net/http"
"os"
"path/filepath"
"github.com/gin-gonic/gin"
"sproutworkcollect-backend/internal/service"
)
// MediaHandler serves static media files (images, videos, and downloadable assets).
type MediaHandler struct {
workSvc *service.WorkService
rateLimiter *service.RateLimiter
}
// NewMediaHandler wires up a MediaHandler with its dependencies.
func NewMediaHandler(workSvc *service.WorkService, rateLimiter *service.RateLimiter) *MediaHandler {
return &MediaHandler{workSvc: workSvc, rateLimiter: rateLimiter}
}
// ServeImage handles GET /api/image/:work_id/:filename
func (h *MediaHandler) ServeImage(c *gin.Context) {
imgPath := filepath.Join(
h.workSvc.WorksDir(),
c.Param("work_id"),
"image",
c.Param("filename"),
)
if _, err := os.Stat(imgPath); os.IsNotExist(err) {
c.JSON(http.StatusNotFound, gin.H{"error": "图片不存在"})
return
}
c.File(imgPath)
}
// ServeVideo handles GET /api/video/:work_id/:filename
func (h *MediaHandler) ServeVideo(c *gin.Context) {
videoPath := filepath.Join(
h.workSvc.WorksDir(),
c.Param("work_id"),
"video",
c.Param("filename"),
)
if _, err := os.Stat(videoPath); os.IsNotExist(err) {
c.JSON(http.StatusNotFound, gin.H{"error": "视频不存在"})
return
}
c.File(videoPath)
}
// DownloadFile handles GET /api/download/:work_id/:platform/:filename
func (h *MediaHandler) DownloadFile(c *gin.Context) {
workID := c.Param("work_id")
platform := c.Param("platform")
filename := c.Param("filename")
filePath := filepath.Join(h.workSvc.WorksDir(), workID, "platform", platform, filename)
if _, err := os.Stat(filePath); os.IsNotExist(err) {
c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在"})
return
}
fp := service.Fingerprint(c.ClientIP(), c.GetHeader("User-Agent"))
if h.rateLimiter.CanPerform(fp, "download", workID) {
_ = h.workSvc.UpdateStats(workID, "download")
}
c.FileAttachment(filePath, filename)
}

View File

@@ -0,0 +1,145 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"sproutworkcollect-backend/internal/service"
)
// PublicHandler handles all publicly accessible API endpoints.
type PublicHandler struct {
workSvc *service.WorkService
settingsSvc *service.SettingsService
rateLimiter *service.RateLimiter
}
// NewPublicHandler wires up a PublicHandler with its dependencies.
func NewPublicHandler(
workSvc *service.WorkService,
settingsSvc *service.SettingsService,
rateLimiter *service.RateLimiter,
) *PublicHandler {
return &PublicHandler{
workSvc: workSvc,
settingsSvc: settingsSvc,
rateLimiter: rateLimiter,
}
}
// GetSettings handles GET /api/settings
func (h *PublicHandler) GetSettings(c *gin.Context) {
settings, err := h.settingsSvc.Load()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, settings)
}
// GetWorks handles GET /api/works
func (h *PublicHandler) GetWorks(c *gin.Context) {
works, err := h.workSvc.LoadAllWorks()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
return
}
responses := make([]any, len(works))
for i, w := range works {
responses[i] = h.workSvc.BuildResponse(w)
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": responses,
"total": len(responses),
})
}
// GetWorkDetail handles GET /api/works/:work_id
func (h *PublicHandler) GetWorkDetail(c *gin.Context) {
workID := c.Param("work_id")
work, err := h.workSvc.LoadWork(workID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "作品不存在"})
return
}
fp := service.Fingerprint(c.ClientIP(), c.GetHeader("User-Agent"))
if h.rateLimiter.CanPerform(fp, "view", workID) {
_ = h.workSvc.UpdateStats(workID, "view")
// Reload to return the updated view count.
if fresh, err2 := h.workSvc.LoadWork(workID); err2 == nil {
work = fresh
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": h.workSvc.BuildResponse(work),
})
}
// SearchWorks handles GET /api/search?q=...&category=...
func (h *PublicHandler) SearchWorks(c *gin.Context) {
works, err := h.workSvc.SearchWorks(c.Query("q"), c.Query("category"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
return
}
responses := make([]any, len(works))
for i, w := range works {
responses[i] = h.workSvc.BuildResponse(w)
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": responses,
"total": len(responses),
})
}
// GetCategories handles GET /api/categories
func (h *PublicHandler) GetCategories(c *gin.Context) {
cats, err := h.workSvc.AllCategories()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": cats})
}
// LikeWork handles POST /api/like/:work_id
func (h *PublicHandler) LikeWork(c *gin.Context) {
workID := c.Param("work_id")
if _, err := h.workSvc.LoadWork(workID); err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "作品不存在"})
return
}
fp := service.Fingerprint(c.ClientIP(), c.GetHeader("User-Agent"))
if !h.rateLimiter.CanPerform(fp, "like", workID) {
c.JSON(http.StatusTooManyRequests, gin.H{
"success": false,
"message": "操作太频繁,请稍后再试",
})
return
}
if err := h.workSvc.UpdateStats(workID, "like"); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "点赞失败"})
return
}
work, _ := h.workSvc.LoadWork(workID)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "点赞成功",
"likes": work.Likes,
})
}