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
270 lines
8.3 KiB
Go
270 lines
8.3 KiB
Go
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
|
||
}
|