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,
}})
}