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
This commit is contained in:
卡若
2026-03-15 09:20:27 +08:00
parent 8778a42429
commit 991e17698c
260 changed files with 26780 additions and 1026 deletions

View File

@@ -0,0 +1,352 @@
package handler
import (
"crypto/rand"
"encoding/hex"
"fmt"
"net/http"
"time"
"soul-api/internal/database"
"soul-api/internal/model"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// GET /api/miniprogram/balance 小程序-查询余额
func BalanceGet(c *gin.Context) {
userID := c.Query("userId")
if userID == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 userId"})
return
}
db := database.DB()
var bal model.UserBalance
if err := db.Where("user_id = ?", userID).First(&bal).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"userId": userID, "balance": 0, "totalRecharged": 0, "totalGifted": 0, "totalRefunded": 0}})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": bal})
}
// POST /api/miniprogram/balance/recharge 小程序-充值(创建充值订单)
func BalanceRecharge(c *gin.Context) {
var body struct {
UserID string `json:"userId" binding:"required"`
Amount float64 `json:"amount" binding:"required,gt=0"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数错误: " + err.Error()})
return
}
db := database.DB()
orderSN := fmt.Sprintf("BAL_%d", time.Now().UnixNano())
order := model.Order{
ID: orderSN,
OrderSN: orderSN,
UserID: body.UserID,
ProductType: "balance_recharge",
Amount: body.Amount,
}
desc := fmt.Sprintf("余额充值 ¥%.2f", body.Amount)
status := "pending"
order.Description = &desc
order.Status = &status
if err := db.Create(&order).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "创建充值订单失败"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"orderSn": orderSN, "amount": body.Amount}})
}
// POST /api/miniprogram/balance/recharge/confirm 充值完成回调(内部或手动确认)
func BalanceRechargeConfirm(c *gin.Context) {
var body struct {
OrderSN string `json:"orderSn" binding:"required"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数错误"})
return
}
db := database.DB()
var order model.Order
if err := db.Where("order_sn = ? AND product_type = ?", body.OrderSN, "balance_recharge").First(&order).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "订单不存在"})
return
}
if order.Status != nil && *order.Status == "paid" {
c.JSON(http.StatusOK, gin.H{"success": true, "message": "已确认"})
return
}
err := db.Transaction(func(tx *gorm.DB) error {
paid := "paid"
now := time.Now()
if err := tx.Model(&order).Updates(map[string]interface{}{"status": paid, "pay_time": now}).Error; err != nil {
return err
}
var bal model.UserBalance
if err := tx.Where("user_id = ?", order.UserID).First(&bal).Error; err != nil {
bal = model.UserBalance{UserID: order.UserID}
tx.Create(&bal)
}
if err := tx.Model(&bal).Updates(map[string]interface{}{
"balance": gorm.Expr("balance + ?", order.Amount),
"total_recharged": gorm.Expr("total_recharged + ?", order.Amount),
}).Error; err != nil {
return err
}
tx.Create(&model.BalanceTransaction{
UserID: order.UserID,
Type: "recharge",
Amount: order.Amount,
BalanceAfter: bal.Balance + order.Amount,
RelatedOrder: &order.OrderSN,
Description: fmt.Sprintf("充值 ¥%.2f", order.Amount),
})
return nil
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "确认失败"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "充值成功"})
}
// POST /api/miniprogram/balance/gift 小程序-代付解锁(用余额帮他人解锁章节)
func BalanceGift(c *gin.Context) {
var body struct {
GiverID string `json:"giverId" binding:"required"`
SectionID string `json:"sectionId" binding:"required"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数错误"})
return
}
db := database.DB()
var chapter model.Chapter
if err := db.Where("id = ?", body.SectionID).First(&chapter).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "章节不存在"})
return
}
price := float64(1)
if chapter.Price != nil {
price = *chapter.Price
}
if price <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "该章节免费,无需代付"})
return
}
var giftCode string
err := db.Transaction(func(tx *gorm.DB) error {
var bal model.UserBalance
if err := tx.Where("user_id = ?", body.GiverID).First(&bal).Error; err != nil || bal.Balance < price {
return fmt.Errorf("余额不足,当前 ¥%.2f,需要 ¥%.2f", bal.Balance, price)
}
if err := tx.Model(&bal).Updates(map[string]interface{}{
"balance": gorm.Expr("balance - ?", price),
"total_gifted": gorm.Expr("total_gifted + ?", price),
}).Error; err != nil {
return err
}
code := make([]byte, 16)
rand.Read(code)
giftCode = hex.EncodeToString(code)
tx.Create(&model.GiftUnlock{
GiftCode: giftCode,
GiverID: body.GiverID,
SectionID: body.SectionID,
Amount: price,
Status: "pending",
})
tx.Create(&model.BalanceTransaction{
UserID: body.GiverID,
Type: "gift",
Amount: -price,
BalanceAfter: bal.Balance - price,
SectionID: &body.SectionID,
Description: fmt.Sprintf("代付章节 %s (¥%.2f)", body.SectionID, price),
})
return nil
})
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{
"giftCode": giftCode,
"sectionId": body.SectionID,
"amount": price,
}})
}
// POST /api/miniprogram/balance/gift/redeem 领取代付礼物
func BalanceGiftRedeem(c *gin.Context) {
var body struct {
GiftCode string `json:"giftCode" binding:"required"`
ReceiverID string `json:"receiverId" binding:"required"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数错误"})
return
}
db := database.DB()
var gift model.GiftUnlock
if err := db.Where("gift_code = ?", body.GiftCode).First(&gift).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "礼物码无效"})
return
}
if gift.Status != "pending" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "礼物已被领取"})
return
}
err := db.Transaction(func(tx *gorm.DB) error {
now := time.Now()
tx.Model(&gift).Updates(map[string]interface{}{
"receiver_id": body.ReceiverID,
"status": "redeemed",
"redeemed_at": now,
})
orderSN := fmt.Sprintf("GIFT_%s", body.GiftCode[:8])
paid := "paid"
desc := fmt.Sprintf("来自好友的代付解锁")
tx.Create(&model.Order{
ID: orderSN,
OrderSN: orderSN,
UserID: body.ReceiverID,
ProductType: "section",
ProductID: &gift.SectionID,
Amount: 0,
Description: &desc,
Status: &paid,
PayTime: &now,
ReferrerID: &gift.GiverID,
})
return nil
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "领取失败"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{
"sectionId": gift.SectionID,
"message": "解锁成功!",
}})
}
// POST /api/miniprogram/balance/refund 申请余额退款9折
func BalanceRefund(c *gin.Context) {
var body struct {
UserID string `json:"userId" binding:"required"`
Amount float64 `json:"amount" binding:"required,gt=0"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数错误"})
return
}
db := database.DB()
refundAmount := body.Amount * 0.9
err := db.Transaction(func(tx *gorm.DB) error {
var bal model.UserBalance
if err := tx.Where("user_id = ?", body.UserID).First(&bal).Error; err != nil || bal.Balance < body.Amount {
return fmt.Errorf("余额不足")
}
if err := tx.Model(&bal).Updates(map[string]interface{}{
"balance": gorm.Expr("balance - ?", body.Amount),
"total_refunded": gorm.Expr("total_refunded + ?", body.Amount),
}).Error; err != nil {
return err
}
tx.Create(&model.BalanceTransaction{
UserID: body.UserID,
Type: "refund",
Amount: -body.Amount,
BalanceAfter: bal.Balance - body.Amount,
Description: fmt.Sprintf("退款 ¥%.2f(原额 ¥%.2f9折退回 ¥%.2f", body.Amount, body.Amount, refundAmount),
})
return nil
})
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{
"deducted": body.Amount,
"refundAmount": refundAmount,
"message": fmt.Sprintf("退款成功,实际退回 ¥%.2f", refundAmount),
}})
}
// GET /api/miniprogram/balance/transactions 交易记录
func BalanceTransactions(c *gin.Context) {
userID := c.Query("userId")
if userID == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 userId"})
return
}
db := database.DB()
var txns []model.BalanceTransaction
db.Where("user_id = ?", userID).Order("created_at DESC").Limit(50).Find(&txns)
c.JSON(http.StatusOK, gin.H{"success": true, "data": txns})
}
// GET /api/miniprogram/balance/gift/info 查询礼物码信息
func BalanceGiftInfo(c *gin.Context) {
code := c.Query("code")
if code == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 code"})
return
}
db := database.DB()
var gift model.GiftUnlock
if err := db.Where("gift_code = ?", code).First(&gift).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "礼物码无效"})
return
}
var chapter model.Chapter
db.Where("id = ?", gift.SectionID).First(&chapter)
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{
"giftCode": gift.GiftCode,
"sectionId": gift.SectionID,
"sectionTitle": chapter.SectionTitle,
"amount": gift.Amount,
"status": gift.Status,
"giverId": gift.GiverID,
}})
}

View File

@@ -24,7 +24,8 @@ var excludeParts = []string{"序言", "尾声", "附录"}
// 支持 excludeFixed=1排除序言、尾声、附录目录页固定模块不参与中间篇章
func BookAllChapters(c *gin.Context) {
db := database.DB()
q := db.Model(&model.Chapter{})
q := db.Model(&model.Chapter{}).
Select("mid, id, part_id, part_title, chapter_id, chapter_title, section_title, word_count, is_free, price, sort_order, status, is_new, edition_standard, edition_premium, hot_score, created_at, updated_at")
if c.Query("excludeFixed") == "1" {
for _, p := range excludeParts {
q = q.Where("part_title NOT LIKE ?", "%"+p+"%")
@@ -430,7 +431,8 @@ func escapeLikeBook(s string) string {
return s
}
// BookSearch GET /api/book/search?q= 章节搜索(与 /api/search 逻辑一致)
// BookSearch GET /api/book/search?q= 章节搜索
// 优化:先搜标题(快),再搜内容(慢),不加载完整 content
func BookSearch(c *gin.Context) {
q := strings.TrimSpace(c.Query("q"))
if q == "" {
@@ -438,26 +440,57 @@ func BookSearch(c *gin.Context) {
return
}
pattern := "%" + escapeLikeBook(q) + "%"
var list []model.Chapter
err := database.DB().Model(&model.Chapter{}).
Where("section_title LIKE ? OR content LIKE ?", pattern, pattern).
Order("sort_order ASC, id ASC").
Limit(20).
Find(&list).Error
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": true, "results": []interface{}{}, "total": 0, "keyword": q})
return
db := database.DB()
type row struct {
ID string `gorm:"column:id"`
MID uint `gorm:"column:mid"`
SectionTitle string `gorm:"column:section_title"`
PartTitle string `gorm:"column:part_title"`
ChapterTitle string `gorm:"column:chapter_title"`
IsFree *bool `gorm:"column:is_free"`
}
lowerQ := strings.ToLower(q)
results := make([]gin.H, 0, len(list))
for _, ch := range list {
matchType := "content"
if strings.Contains(strings.ToLower(ch.SectionTitle), lowerQ) {
matchType = "title"
var titleHits []row
db.Model(&model.Chapter{}).
Select("id, mid, section_title, part_title, chapter_title, is_free").
Where("section_title LIKE ?", pattern).
Order("sort_order ASC, id ASC").
Limit(15).
Find(&titleHits)
titleIDs := make(map[string]bool, len(titleHits))
for _, h := range titleHits {
titleIDs[h.ID] = true
}
remaining := 20 - len(titleHits)
var contentHits []row
if remaining > 0 {
cq := db.Model(&model.Chapter{}).
Select("id, mid, section_title, part_title, chapter_title, is_free").
Where("content LIKE ?", pattern)
if len(titleIDs) > 0 {
ids := make([]string, 0, len(titleIDs))
for id := range titleIDs {
ids = append(ids, id)
}
cq = cq.Where("id NOT IN ?", ids)
}
cq.Order("sort_order ASC, id ASC").Limit(remaining).Find(&contentHits)
}
results := make([]gin.H, 0, len(titleHits)+len(contentHits))
for _, ch := range titleHits {
results = append(results, gin.H{
"id": ch.ID, "mid": ch.MID, "title": ch.SectionTitle, "part": ch.PartTitle, "chapter": ch.ChapterTitle,
"isFree": ch.IsFree, "matchType": matchType,
"isFree": ch.IsFree, "matchType": "title",
})
}
for _, ch := range contentHits {
results = append(results, gin.H{
"id": ch.ID, "mid": ch.MID, "title": ch.SectionTitle, "part": ch.PartTitle, "chapter": ch.ChapterTitle,
"isFree": ch.IsFree, "matchType": "content",
})
}
c.JSON(http.StatusOK, gin.H{"success": true, "results": results, "total": len(results), "keyword": q})

View File

@@ -3,7 +3,6 @@ package handler
import (
"net/http"
"strings"
"unicode/utf8"
"soul-api/internal/database"
"soul-api/internal/model"
@@ -20,6 +19,7 @@ func escapeLike(s string) string {
}
// SearchGet GET /api/search?q= 从 chapters 表搜索GORM参数化
// 优化:先搜标题(快),再搜内容(慢),不加载完整 content 到内存
func SearchGet(c *gin.Context) {
q := strings.TrimSpace(c.Query("q"))
if q == "" {
@@ -27,51 +27,79 @@ func SearchGet(c *gin.Context) {
return
}
pattern := "%" + escapeLike(q) + "%"
var list []model.Chapter
err := database.DB().Model(&model.Chapter{}).
Where("section_title LIKE ? OR content LIKE ?", pattern, pattern).
Order("sort_order ASC, id ASC").
Limit(50).
Find(&list).Error
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"keyword": q, "total": 0, "results": []interface{}{}}})
return
db := database.DB()
// 第一步:标题匹配(快速,不加载 content
type searchRow struct {
ID string `gorm:"column:id"`
MID uint `gorm:"column:mid"`
SectionTitle string `gorm:"column:section_title"`
PartTitle string `gorm:"column:part_title"`
ChapterTitle string `gorm:"column:chapter_title"`
Price *float64 `gorm:"column:price"`
IsFree *bool `gorm:"column:is_free"`
Snippet string `gorm:"column:snippet"`
}
lowerQ := strings.ToLower(q)
results := make([]gin.H, 0, len(list))
for _, ch := range list {
matchType := "content"
score := 5
if strings.Contains(strings.ToLower(ch.SectionTitle), lowerQ) {
matchType = "title"
score = 10
}
snippet := ""
pos := strings.Index(strings.ToLower(ch.Content), lowerQ)
if pos >= 0 && len(ch.Content) > 0 {
start := pos - 50
if start < 0 {
start = 0
}
end := pos + utf8.RuneCountInString(q) + 50
if end > len(ch.Content) {
end = len(ch.Content)
}
snippet = ch.Content[start:end]
if start > 0 {
snippet = "..." + snippet
}
if end < len(ch.Content) {
snippet = snippet + "..."
}
var titleMatches []searchRow
db.Model(&model.Chapter{}).
Select("id, mid, section_title, part_title, chapter_title, price, is_free, '' as snippet").
Where("section_title LIKE ?", pattern).
Order("sort_order ASC, id ASC").
Limit(30).
Find(&titleMatches)
titleIDs := make(map[string]bool, len(titleMatches))
for _, m := range titleMatches {
titleIDs[m.ID] = true
}
// 第二步:内容匹配(排除已命中标题的,用 SQL 提取摘要避免加载完整 content
remaining := 50 - len(titleMatches)
var contentMatches []searchRow
if remaining > 0 {
contentQ := db.Model(&model.Chapter{}).
Select("id, mid, section_title, part_title, chapter_title, price, is_free, "+
"CONCAT(CASE WHEN LOCATE(?, content) > 60 THEN '...' ELSE '' END, "+
"SUBSTRING(content, GREATEST(1, LOCATE(?, content) - 50), 200), "+
"CASE WHEN LENGTH(content) > LOCATE(?, content) + 150 THEN '...' ELSE '' END) as snippet",
q, q, q).
Where("content LIKE ?", pattern)
if len(titleIDs) > 0 {
ids := make([]string, 0, len(titleIDs))
for id := range titleIDs {
ids = append(ids, id)
}
contentQ = contentQ.Where("id NOT IN ?", ids)
}
contentQ.Order("sort_order ASC, id ASC").
Limit(remaining).
Find(&contentMatches)
}
results := make([]gin.H, 0, len(titleMatches)+len(contentMatches))
for _, ch := range titleMatches {
price := 1.0
if ch.Price != nil {
price = *ch.Price
}
results = append(results, gin.H{
"id": ch.ID, "title": ch.SectionTitle, "partTitle": ch.PartTitle, "chapterTitle": ch.ChapterTitle,
"price": price, "isFree": ch.IsFree, "matchType": matchType, "score": score, "snippet": snippet,
"price": price, "isFree": ch.IsFree, "matchType": "title", "score": 10, "snippet": "",
})
}
for _, ch := range contentMatches {
price := 1.0
if ch.Price != nil {
price = *ch.Price
}
snippet := ch.Snippet
if len([]rune(snippet)) > 200 {
snippet = string([]rune(snippet)[:200]) + "..."
}
results = append(results, gin.H{
"id": ch.ID, "title": ch.SectionTitle, "partTitle": ch.PartTitle, "chapterTitle": ch.ChapterTitle,
"price": price, "isFree": ch.IsFree, "matchType": "content", "score": 5, "snippet": snippet,
})
}
c.JSON(http.StatusOK, gin.H{

View File

@@ -0,0 +1,269 @@
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
}