- Changed API base URLs in app.js to point to the production server for deployment. - Updated the my page UI by refining labels and removing unnecessary text for a cleaner look. - Adjusted CSS styles to enhance layout and reduce whitespace, improving overall user experience. This update aims to ensure proper API connectivity and enhance the visual presentation of the user interface.
333 lines
9.6 KiB
Go
333 lines
9.6 KiB
Go
package handler
|
||
|
||
import (
|
||
"bytes"
|
||
"fmt"
|
||
"image/gif"
|
||
"image/jpeg"
|
||
"image/png"
|
||
"io"
|
||
"log"
|
||
"math/rand"
|
||
"net/http"
|
||
"os"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"gorm.io/gorm"
|
||
|
||
"soul-api/internal/config"
|
||
"soul-api/internal/database"
|
||
"soul-api/internal/model"
|
||
"soul-api/internal/oss"
|
||
)
|
||
|
||
const (
|
||
uploadDirContent = "uploads"
|
||
maxImageBytes = 5 * 1024 * 1024 // 5MB
|
||
maxVideoBytes = 100 * 1024 * 1024 // 100MB
|
||
defaultImageQuality = 85
|
||
)
|
||
|
||
// effectiveUploadDiskDir 与 router.Static("/uploads")、upload.go uploadPostSaveLocal 一致;
|
||
// 避免配置了 UPLOAD_DIR(绝对路径)时仍写入进程 cwd 下 ./uploads,导致 GET /uploads/… 读挂载目录 404。
|
||
func effectiveUploadDiskDir() string {
|
||
if cfg := config.Get(); cfg != nil {
|
||
if d := strings.TrimSpace(cfg.UploadDir); d != "" {
|
||
return d
|
||
}
|
||
}
|
||
return "./uploads"
|
||
}
|
||
|
||
var (
|
||
allowedImageTypes = map[string]bool{
|
||
"image/jpeg": true, "image/png": true, "image/gif": true, "image/webp": true,
|
||
}
|
||
allowedVideoTypes = map[string]bool{
|
||
"video/mp4": true, "video/quicktime": true, "video/webm": true, "video/x-msvideo": true,
|
||
}
|
||
)
|
||
|
||
// UploadImagePost POST /api/miniprogram/upload/image 小程序-图片上传(支持压缩)
|
||
// 优先 OSS;未配置或失败时自动落本地 uploads/。
|
||
// 表单:file(必填), folder(可选,默认 images), quality(可选 1-100,默认 85)
|
||
func UploadImagePost(c *gin.Context) {
|
||
file, err := c.FormFile("file")
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请选择要上传的图片"})
|
||
return
|
||
}
|
||
if file.Size > maxImageBytes {
|
||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "图片大小不能超过 5MB"})
|
||
return
|
||
}
|
||
ct := file.Header.Get("Content-Type")
|
||
if !allowedImageTypes[ct] && !strings.HasPrefix(ct, "image/") {
|
||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "仅支持 jpg/png/gif/webp 格式"})
|
||
return
|
||
}
|
||
quality := defaultImageQuality
|
||
if q := c.PostForm("quality"); q != "" {
|
||
if qn, e := strconv.Atoi(q); e == nil && qn >= 1 && qn <= 100 {
|
||
quality = qn
|
||
}
|
||
}
|
||
folder := c.PostForm("folder")
|
||
if folder == "" {
|
||
folder = "images"
|
||
}
|
||
ext := filepath.Ext(file.Filename)
|
||
if ext == "" {
|
||
ext = ".jpg"
|
||
}
|
||
name := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), randomStrContent(6), ext)
|
||
|
||
src, err := file.Open()
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "打开文件失败"})
|
||
return
|
||
}
|
||
defer src.Close()
|
||
data, err := io.ReadAll(src)
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "读取文件失败"})
|
||
return
|
||
}
|
||
|
||
// JPEG 压缩
|
||
var finalData []byte
|
||
finalCt := ct
|
||
if strings.Contains(ct, "jpeg") || strings.Contains(ct, "jpg") {
|
||
if img, err := jpeg.Decode(bytes.NewReader(data)); err == nil {
|
||
var buf bytes.Buffer
|
||
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: quality}); err == nil {
|
||
finalData = buf.Bytes()
|
||
}
|
||
}
|
||
} else if strings.Contains(ct, "png") {
|
||
if img, err := png.Decode(bytes.NewReader(data)); err == nil {
|
||
var buf bytes.Buffer
|
||
if err := png.Encode(&buf, img); err == nil {
|
||
finalData = buf.Bytes()
|
||
}
|
||
}
|
||
} else if strings.Contains(ct, "gif") {
|
||
if img, err := gif.Decode(bytes.NewReader(data)); err == nil {
|
||
var buf bytes.Buffer
|
||
if err := gif.Encode(&buf, img, nil); err == nil {
|
||
finalData = buf.Bytes()
|
||
}
|
||
}
|
||
}
|
||
if finalData == nil {
|
||
finalData = data
|
||
}
|
||
|
||
var ossURL string
|
||
var ossErr error
|
||
if oss.IsEnabled() {
|
||
ossURL, ossErr = ossUploadBytes(finalData, folder, name, finalCt)
|
||
} else {
|
||
ossErr = fmt.Errorf("OSS 未配置")
|
||
}
|
||
if ossErr == nil && ossURL != "" {
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": true,
|
||
"url": ossURL,
|
||
"fullUrl": ossURL,
|
||
"data": gin.H{
|
||
"url": ossURL, "fullUrl": ossURL, "fileName": name, "size": int64(len(finalData)), "type": ct, "quality": quality, "storage": "oss",
|
||
},
|
||
})
|
||
return
|
||
}
|
||
if ossErr != nil {
|
||
log.Printf("upload/image: OSS failed folder=%s name=%s (local fallback): %v", folder, name, ossErr)
|
||
} else {
|
||
log.Printf("upload/image: OSS empty url folder=%s name=%s (local fallback)", folder, name)
|
||
}
|
||
|
||
// 回退本地存储
|
||
dir := filepath.Join(effectiveUploadDiskDir(), folder)
|
||
_ = os.MkdirAll(dir, 0755)
|
||
dst := filepath.Join(dir, name)
|
||
if err := os.WriteFile(dst, finalData, 0644); err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "保存失败"})
|
||
return
|
||
}
|
||
url := "/" + filepath.ToSlash(filepath.Join(uploadDirContent, folder, name))
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": true, "url": url, "fullUrl": url,
|
||
"data": gin.H{"url": url, "fullUrl": url, "fileName": name, "size": int64(len(finalData)), "type": ct, "quality": quality, "storage": "local"},
|
||
})
|
||
}
|
||
|
||
// UploadVideoPost POST /api/miniprogram/upload/video 小程序-视频上传
|
||
// 优先 OSS;未配置或失败时自动落本地。
|
||
// 表单:file(必填), folder(可选,默认 videos)
|
||
func UploadVideoPost(c *gin.Context) {
|
||
file, err := c.FormFile("file")
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请选择要上传的视频"})
|
||
return
|
||
}
|
||
if file.Size > maxVideoBytes {
|
||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "视频大小不能超过 100MB"})
|
||
return
|
||
}
|
||
ct := file.Header.Get("Content-Type")
|
||
if !allowedVideoTypes[ct] && !strings.HasPrefix(ct, "video/") {
|
||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "仅支持 mp4/mov/avi 等视频格式"})
|
||
return
|
||
}
|
||
folder := c.PostForm("folder")
|
||
if folder == "" {
|
||
folder = "videos"
|
||
}
|
||
ext := filepath.Ext(file.Filename)
|
||
if ext == "" {
|
||
ext = ".mp4"
|
||
}
|
||
name := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), randomStrContent(8), ext)
|
||
|
||
var ossURL string
|
||
var ossErr error
|
||
|
||
if oss.IsEnabled() {
|
||
src, openErr := file.Open()
|
||
if openErr != nil {
|
||
ossErr = openErr
|
||
} else {
|
||
ossURL, ossErr = ossUploadFile(src, folder, name)
|
||
_ = src.Close()
|
||
}
|
||
} else {
|
||
ossErr = fmt.Errorf("OSS 未配置")
|
||
}
|
||
|
||
if ossErr == nil && ossURL != "" {
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": true,
|
||
"url": ossURL,
|
||
"fullUrl": ossURL,
|
||
"data": gin.H{
|
||
"url": ossURL, "fullUrl": ossURL, "fileName": name, "size": file.Size, "type": ct, "folder": folder, "storage": "oss",
|
||
},
|
||
})
|
||
return
|
||
}
|
||
|
||
if ossErr != nil {
|
||
log.Printf("upload/video: OSS skipped or failed (local fallback) folder=%s name=%s: %v", folder, name, ossErr)
|
||
}
|
||
|
||
// 回退本地存储
|
||
dir := filepath.Join(effectiveUploadDiskDir(), folder)
|
||
_ = os.MkdirAll(dir, 0755)
|
||
dst := filepath.Join(dir, name)
|
||
if err := c.SaveUploadedFile(file, dst); err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "保存失败"})
|
||
return
|
||
}
|
||
url := "/" + filepath.ToSlash(filepath.Join(uploadDirContent, folder, name))
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": true,
|
||
"url": url,
|
||
"fullUrl": url,
|
||
"data": gin.H{"url": url, "fullUrl": url, "fileName": name, "size": file.Size, "type": ct, "folder": folder, "storage": "local"},
|
||
})
|
||
}
|
||
|
||
// AdminContentUpload POST /api/admin/content/upload 管理端-内容上传(通过 API 写入内容管理,不直接操作数据库)
|
||
// 需 AdminAuth。Body: { "action": "import", "data": [ { "id","title","content","price","isFree","partId","partTitle","chapterId","chapterTitle" } ] }
|
||
func AdminContentUpload(c *gin.Context) {
|
||
var body struct {
|
||
Action string `json:"action"`
|
||
Data []importItem `json:"data"`
|
||
}
|
||
if err := c.ShouldBindJSON(&body); err != nil {
|
||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
|
||
return
|
||
}
|
||
if body.Action != "import" {
|
||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "action 须为 import"})
|
||
return
|
||
}
|
||
if len(body.Data) == 0 {
|
||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "data 不能为空"})
|
||
return
|
||
}
|
||
db := database.DB()
|
||
imported, failed := 0, 0
|
||
for _, item := range body.Data {
|
||
if item.ID == "" || item.Title == "" {
|
||
failed++
|
||
continue
|
||
}
|
||
price := 1.0
|
||
if item.Price != nil {
|
||
price = *item.Price
|
||
}
|
||
isFree := false
|
||
if item.IsFree != nil {
|
||
isFree = *item.IsFree
|
||
}
|
||
wordCount := len(item.Content)
|
||
status := "published"
|
||
editionStandard, editionPremium := true, false
|
||
ch := model.Chapter{
|
||
ID: item.ID,
|
||
PartID: strPtrContent(item.PartID, "part-1"),
|
||
PartTitle: strPtrContent(item.PartTitle, "未分类"),
|
||
ChapterID: strPtrContent(item.ChapterID, "chapter-1"),
|
||
ChapterTitle: strPtrContent(item.ChapterTitle, "未分类"),
|
||
SectionTitle: item.Title,
|
||
Content: item.Content,
|
||
WordCount: &wordCount,
|
||
IsFree: &isFree,
|
||
Price: &price,
|
||
Status: &status,
|
||
EditionStandard: &editionStandard,
|
||
EditionPremium: &editionPremium,
|
||
}
|
||
err := db.Where("id = ?", item.ID).First(&model.Chapter{}).Error
|
||
if err == gorm.ErrRecordNotFound {
|
||
err = db.Create(&ch).Error
|
||
} else if err == nil {
|
||
err = db.Model(&model.Chapter{}).Where("id = ?", item.ID).Updates(map[string]interface{}{
|
||
"section_title": ch.SectionTitle,
|
||
"content": ch.Content,
|
||
"word_count": ch.WordCount,
|
||
"is_free": ch.IsFree,
|
||
"price": ch.Price,
|
||
}).Error
|
||
}
|
||
if err != nil {
|
||
failed++
|
||
continue
|
||
}
|
||
imported++
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "导入完成", "imported": imported, "failed": failed})
|
||
}
|
||
|
||
func randomStrContent(n int) string {
|
||
const letters = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||
b := make([]byte, n)
|
||
for i := range b {
|
||
b[i] = letters[rand.Intn(len(letters))]
|
||
}
|
||
return string(b)
|
||
}
|
||
|
||
func strPtrContent(s *string, def string) string {
|
||
if s != nil && *s != "" {
|
||
return *s
|
||
}
|
||
return def
|
||
}
|