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:
@@ -89,6 +89,15 @@ func Init(dsn string) error {
|
||||
if err := db.AutoMigrate(&model.LinkTag{}); err != nil {
|
||||
log.Printf("database: link_tags migrate warning: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.UserBalance{}); err != nil {
|
||||
log.Printf("database: user_balances migrate warning: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.BalanceTransaction{}); err != nil {
|
||||
log.Printf("database: balance_transactions migrate warning: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.GiftUnlock{}); err != nil {
|
||||
log.Printf("database: gift_unlocks migrate warning: %v", err)
|
||||
}
|
||||
// 以下表业务大量使用,必须参与 AutoMigrate,否则旧库缺字段会导致订单/用户/VIP 等接口报错
|
||||
if err := db.AutoMigrate(&model.User{}); err != nil {
|
||||
log.Printf("database: users migrate warning: %v", err)
|
||||
|
||||
352
soul-api/internal/handler/balance.go
Normal file
352
soul-api/internal/handler/balance.go
Normal 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(原额 ¥%.2f,9折退回 ¥%.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,
|
||||
}})
|
||||
}
|
||||
@@ -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})
|
||||
|
||||
@@ -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{
|
||||
|
||||
269
soul-api/internal/handler/upload_content.go
Normal file
269
soul-api/internal/handler/upload_content.go
Normal 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
|
||||
}
|
||||
44
soul-api/internal/model/balance.go
Normal file
44
soul-api/internal/model/balance.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type UserBalance struct {
|
||||
UserID string `gorm:"column:user_id;primaryKey;size:50" json:"userId"`
|
||||
Balance float64 `gorm:"column:balance;type:decimal(10,2);default:0" json:"balance"`
|
||||
TotalRecharged float64 `gorm:"column:total_recharged;type:decimal(10,2);default:0" json:"totalRecharged"`
|
||||
TotalGifted float64 `gorm:"column:total_gifted;type:decimal(10,2);default:0" json:"totalGifted"`
|
||||
TotalRefunded float64 `gorm:"column:total_refunded;type:decimal(10,2);default:0" json:"totalRefunded"`
|
||||
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (UserBalance) TableName() string { return "user_balances" }
|
||||
|
||||
type BalanceTransaction struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
UserID string `gorm:"column:user_id;size:50;index" json:"userId"`
|
||||
Type string `gorm:"column:type;size:20" json:"type"`
|
||||
Amount float64 `gorm:"column:amount;type:decimal(10,2)" json:"amount"`
|
||||
BalanceAfter float64 `gorm:"column:balance_after;type:decimal(10,2)" json:"balanceAfter"`
|
||||
RelatedOrder *string `gorm:"column:related_order;size:50" json:"relatedOrder,omitempty"`
|
||||
TargetUserID *string `gorm:"column:target_user_id;size:50" json:"targetUserId,omitempty"`
|
||||
SectionID *string `gorm:"column:section_id;size:50" json:"sectionId,omitempty"`
|
||||
Description string `gorm:"column:description;size:200" json:"description"`
|
||||
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
|
||||
}
|
||||
|
||||
func (BalanceTransaction) TableName() string { return "balance_transactions" }
|
||||
|
||||
type GiftUnlock struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
GiftCode string `gorm:"column:gift_code;uniqueIndex;size:32" json:"giftCode"`
|
||||
GiverID string `gorm:"column:giver_id;size:50;index" json:"giverId"`
|
||||
SectionID string `gorm:"column:section_id;size:50" json:"sectionId"`
|
||||
ReceiverID *string `gorm:"column:receiver_id;size:50" json:"receiverId,omitempty"`
|
||||
Amount float64 `gorm:"column:amount;type:decimal(10,2)" json:"amount"`
|
||||
Status string `gorm:"column:status;size:20;default:pending" json:"status"`
|
||||
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
|
||||
RedeemedAt *time.Time `gorm:"column:redeemed_at" json:"redeemedAt,omitempty"`
|
||||
}
|
||||
|
||||
func (GiftUnlock) TableName() string { return "gift_unlocks" }
|
||||
@@ -79,6 +79,7 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
admin.GET("/author-settings", handler.AdminAuthorSettingsGet)
|
||||
admin.POST("/author-settings", handler.AdminAuthorSettingsPost)
|
||||
admin.PUT("/orders/refund", handler.AdminOrderRefund)
|
||||
admin.POST("/content/upload", handler.AdminContentUpload)
|
||||
admin.GET("/users", handler.AdminUsersList)
|
||||
admin.POST("/users", handler.AdminUsersAction)
|
||||
admin.PUT("/users", handler.AdminUsersAction)
|
||||
@@ -278,6 +279,8 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
miniprogram.POST("/ckb/lead", handler.CKBLead)
|
||||
miniprogram.POST("/ckb/index-lead", handler.CKBIndexLead)
|
||||
miniprogram.POST("/upload", handler.UploadPost)
|
||||
miniprogram.POST("/upload/image", handler.UploadImagePost)
|
||||
miniprogram.POST("/upload/video", handler.UploadVideoPost)
|
||||
miniprogram.DELETE("/upload", handler.UploadDelete)
|
||||
miniprogram.GET("/user/addresses", handler.UserAddressesGet)
|
||||
miniprogram.POST("/user/addresses", handler.UserAddressesPost)
|
||||
@@ -310,6 +313,15 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
miniprogram.GET("/mentors/:id", handler.MiniprogramMentorsDetail)
|
||||
miniprogram.POST("/mentors/:id/book", handler.MiniprogramMentorsBook)
|
||||
miniprogram.GET("/about/author", handler.MiniprogramAboutAuthor)
|
||||
// 余额与代付
|
||||
miniprogram.GET("/balance", handler.BalanceGet)
|
||||
miniprogram.POST("/balance/recharge", handler.BalanceRecharge)
|
||||
miniprogram.POST("/balance/recharge/confirm", handler.BalanceRechargeConfirm)
|
||||
miniprogram.POST("/balance/gift", handler.BalanceGift)
|
||||
miniprogram.POST("/balance/gift/redeem", handler.BalanceGiftRedeem)
|
||||
miniprogram.GET("/balance/gift/info", handler.BalanceGiftInfo)
|
||||
miniprogram.POST("/balance/refund", handler.BalanceRefund)
|
||||
miniprogram.GET("/balance/transactions", handler.BalanceTransactions)
|
||||
}
|
||||
|
||||
// ----- 提现 -----
|
||||
|
||||
Reference in New Issue
Block a user