Files
soul-yongping/soul-api/internal/handler/upload_content.go
卡若 991e17698c feat: 内容管理第5批优化 - Bug修复 + 分享功能 + 代付功能
1. Bug修复:
   - 修复Markdown星号/下划线在小程序端原样显示问题(markdownToHtml增加__和_支持,contentParser增加Markdown格式剥离)
   - 修复@提及无反应(MentionSuggestion使用ref保持persons最新值,解决闭包捕获空数组问题)
   - 修复#链接标签点击"未找到小程序配置"(增加appId直接跳转降级路径)

2. 分享功能优化:
   - "分享到朋友圈"改为"分享给好友"(open-type从shareTimeline改为share)
   - 90%收益提示移到分享按钮下方
   - 阅读20%后向上滑动弹出分享浮层提示(4秒自动消失)

3. 代付功能:
   - 后端:新增UserBalance/BalanceTransaction/GiftUnlock三个模型
   - 后端:新增8个余额相关API(查询/充值/充值确认/代付/领取/退款/交易记录/礼物信息)
   - 小程序:阅读页新增"代付分享"按钮,支持用余额为好友解锁章节
   - 分享链接携带gift参数,好友打开自动领取解锁

Made-with: Cursor
2026-03-15 09:20:27 +08:00

270 lines
8.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package handler
import (
"bytes"
"fmt"
"image/gif"
"image/jpeg"
"image/png"
"io"
"math/rand"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"soul-api/internal/database"
"soul-api/internal/model"
)
const (
uploadDirContent = "uploads"
maxImageBytes = 5 * 1024 * 1024 // 5MB
maxVideoBytes = 100 * 1024 * 1024 // 100MB
defaultImageQuality = 85
)
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/x-msvideo": true,
}
)
// UploadImagePost POST /api/miniprogram/upload/image 小程序-图片上传(支持压缩)
// 表单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"
}
dir := filepath.Join(uploadDirContent, folder)
_ = os.MkdirAll(dir, 0755)
ext := filepath.Ext(file.Filename)
if ext == "" {
ext = ".jpg"
}
name := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), randomStrContent(6), ext)
dst := filepath.Join(dir, name)
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支持质量压缩
if strings.Contains(ct, "jpeg") || strings.Contains(ct, "jpg") {
img, err := jpeg.Decode(bytes.NewReader(data))
if err == nil {
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: quality}); err == nil {
if err := os.WriteFile(dst, buf.Bytes(), 0644); err == nil {
url := "/" + filepath.ToSlash(filepath.Join(uploadDirContent, folder, name))
c.JSON(http.StatusOK, gin.H{
"success": true, "url": url,
"data": gin.H{"url": url, "fileName": name, "size": int64(buf.Len()), "type": ct, "quality": quality},
})
return
}
}
}
}
// PNG/GIF解码后原样保存
if strings.Contains(ct, "png") {
img, err := png.Decode(bytes.NewReader(data))
if err == nil {
var buf bytes.Buffer
if err := png.Encode(&buf, img); err == nil {
if err := os.WriteFile(dst, buf.Bytes(), 0644); err == nil {
url := "/" + filepath.ToSlash(filepath.Join(uploadDirContent, folder, name))
c.JSON(http.StatusOK, gin.H{"success": true, "url": url, "data": gin.H{"url": url, "fileName": name, "size": int64(buf.Len()), "type": ct}})
return
}
}
}
}
if strings.Contains(ct, "gif") {
img, err := gif.Decode(bytes.NewReader(data))
if err == nil {
var buf bytes.Buffer
if err := gif.Encode(&buf, img, nil); err == nil {
if err := os.WriteFile(dst, buf.Bytes(), 0644); err == nil {
url := "/" + filepath.ToSlash(filepath.Join(uploadDirContent, folder, name))
c.JSON(http.StatusOK, gin.H{"success": true, "url": url, "data": gin.H{"url": url, "fileName": name, "size": int64(buf.Len()), "type": ct}})
return
}
}
}
}
// 其他格式或解析失败时直接写入
if err := os.WriteFile(dst, data, 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, "data": gin.H{"url": url, "fileName": name, "size": int64(len(data)), "type": ct}})
}
// UploadVideoPost POST /api/miniprogram/upload/video 小程序-视频上传(指定目录)
// 表单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"
}
dir := filepath.Join(uploadDirContent, folder)
_ = os.MkdirAll(dir, 0755)
ext := filepath.Ext(file.Filename)
if ext == "" {
ext = ".mp4"
}
name := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), randomStrContent(8), ext)
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,
"data": gin.H{"url": url, "fileName": name, "size": file.Size, "type": ct, "folder": folder},
})
}
// 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
}