feat: add SproutWorkCollect apps
This commit is contained in:
317
SproutWorkCollect-Backend-Golang/internal/handler/admin.go
Normal file
317
SproutWorkCollect-Backend-Golang/internal/handler/admin.go
Normal 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": "删除成功"})
|
||||
}
|
||||
72
SproutWorkCollect-Backend-Golang/internal/handler/media.go
Normal file
72
SproutWorkCollect-Backend-Golang/internal/handler/media.go
Normal 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)
|
||||
}
|
||||
145
SproutWorkCollect-Backend-Golang/internal/handler/public.go
Normal file
145
SproutWorkCollect-Backend-Golang/internal/handler/public.go
Normal 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,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user