Files
soul-yongping/soul-api/internal/handler/book.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

510 lines
16 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 (
"encoding/json"
"net/http"
"strconv"
"strings"
"time"
"unicode/utf8"
"soul-api/internal/database"
"soul-api/internal/model"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// excludeParts 排除序言、尾声、附录(不参与精选推荐/热门排序)
var excludeParts = []string{"序言", "尾声", "附录"}
// BookAllChapters GET /api/book/all-chapters 返回所有章节(列表,来自 chapters 表)
// 排序须与管理端 PUT /api/db/book action=reorder 一致:按 sort_order 升序,同序按 id
// 免费判断system_config.free_chapters / chapter_config.freeChapters 优先于 chapters.is_free
// 支持 excludeFixed=1排除序言、尾声、附录目录页固定模块不参与中间篇章
func BookAllChapters(c *gin.Context) {
db := database.DB()
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+"%")
}
}
var list []model.Chapter
if err := q.Order("COALESCE(sort_order, 999999) ASC, id ASC").Find(&list).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}})
return
}
freeIDs := getFreeChapterIDs(db)
for i := range list {
if freeIDs[list[i].ID] {
t := true
z := float64(0)
list[i].IsFree = &t
list[i].Price = &z
}
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
}
// BookChapterByID GET /api/book/chapter/:id 按业务 id 查询(兼容旧链接)
func BookChapterByID(c *gin.Context) {
id := c.Param("id")
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 id"})
return
}
findChapterAndRespond(c, func(db *gorm.DB) *gorm.DB {
return db.Where("id = ?", id)
})
}
// BookChapterByMID GET /api/book/chapter/by-mid/:mid 按自增主键 mid 查询(新链接推荐)
func BookChapterByMID(c *gin.Context) {
midStr := c.Param("mid")
if midStr == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 mid"})
return
}
mid, err := strconv.Atoi(midStr)
if err != nil || mid < 1 {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "mid 必须为正整数"})
return
}
findChapterAndRespond(c, func(db *gorm.DB) *gorm.DB {
return db.Where("mid = ?", mid)
})
}
// getFreeChapterIDs 从 system_config 读取免费章节 ID 列表free_chapters 或 chapter_config.freeChapters
func getFreeChapterIDs(db *gorm.DB) map[string]bool {
ids := make(map[string]bool)
for _, key := range []string{"free_chapters", "chapter_config"} {
var row model.SystemConfig
if err := db.Where("config_key = ?", key).First(&row).Error; err != nil {
continue
}
var val interface{}
if err := json.Unmarshal(row.ConfigValue, &val); err != nil {
continue
}
if key == "free_chapters" {
if arr, ok := val.([]interface{}); ok {
for _, v := range arr {
if s, ok := v.(string); ok {
ids[s] = true
}
}
}
} else if key == "chapter_config" {
if m, ok := val.(map[string]interface{}); ok {
if arr, ok := m["freeChapters"].([]interface{}); ok {
for _, v := range arr {
if s, ok := v.(string); ok {
ids[s] = true
}
}
}
}
}
}
return ids
}
// checkUserChapterAccess 判断 userId 是否有权读取 chapterIDVIP / 全书购买 / 单章购买)
// isPremium=true 表示增值版fullbook 买断不含增值版
func checkUserChapterAccess(db *gorm.DB, userID, chapterID string, isPremium bool) bool {
if userID == "" {
return false
}
// VIPis_vip=1 且未过期
var u model.User
if err := db.Select("id", "is_vip", "vip_expire_date", "has_full_book").Where("id = ?", userID).First(&u).Error; err != nil {
return false
}
if u.IsVip != nil && *u.IsVip && u.VipExpireDate != nil && u.VipExpireDate.After(time.Now()) {
return true
}
// 全书买断(不含增值版)
if !isPremium && u.HasFullBook != nil && *u.HasFullBook {
return true
}
// 全书订单(兜底)
if !isPremium {
var cnt int64
db.Model(&model.Order{}).Where("user_id = ? AND product_type = 'fullbook' AND status = 'paid'", userID).Count(&cnt)
if cnt > 0 {
return true
}
}
// 单章购买
var cnt int64
db.Model(&model.Order{}).Where(
"user_id = ? AND product_type = 'section' AND product_id = ? AND status = 'paid'",
userID, chapterID,
).Count(&cnt)
return cnt > 0
}
// previewContent 取内容的前 50%(不少于 100 个字符),并追加省略提示
func previewContent(content string) string {
total := utf8.RuneCountInString(content)
if total == 0 {
return ""
}
// 截取前 50% 的内容,保证有足够的预览长度
limit := total / 2
if limit < 100 {
limit = 100
}
if limit > total {
limit = total
}
runes := []rune(content)
return string(runes[:limit]) + "\n\n……购买后阅读完整内容"
}
// findChapterAndRespond 按条件查章节并返回统一格式
// 免费判断优先级system_config.free_chapters / chapter_config.freeChapters > chapters.is_free/price
// 付费章节:若请求携带 userId 且有购买权限则返回完整 content否则返回 previewContent
func findChapterAndRespond(c *gin.Context, whereFn func(*gorm.DB) *gorm.DB) {
var ch model.Chapter
db := database.DB()
if err := whereFn(db).First(&ch).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "章节不存在"})
return
}
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
isFreeFromConfig := getFreeChapterIDs(db)[ch.ID]
isFree := isFreeFromConfig
if !isFree && ch.IsFree != nil && *ch.IsFree {
isFree = true
}
if !isFree && ch.Price != nil && *ch.Price == 0 {
isFree = true
}
// 确定返回的 content免费直接返回付费须校验购买权限
userID := c.Query("userId")
isPremium := ch.EditionPremium != nil && *ch.EditionPremium
var returnContent string
if isFree {
returnContent = ch.Content
} else if checkUserChapterAccess(db, userID, ch.ID, isPremium) {
returnContent = ch.Content
} else {
returnContent = previewContent(ch.Content)
}
// data 中的 content 必须与外层 content 一致,避免泄露完整内容给未授权用户
chForResponse := ch
chForResponse.Content = returnContent
out := gin.H{
"success": true,
"data": chForResponse,
"content": returnContent,
"chapterTitle": ch.ChapterTitle,
"partTitle": ch.PartTitle,
"id": ch.ID,
"mid": ch.MID,
"sectionTitle": ch.SectionTitle,
"isFree": isFree,
}
if isFreeFromConfig {
out["price"] = float64(0)
} else if ch.Price != nil {
out["price"] = *ch.Price
}
c.JSON(http.StatusOK, out)
}
// BookChapters GET/POST/PUT/DELETE /api/book/chapters与 app/api/book/chapters 一致,用 GORM
func BookChapters(c *gin.Context) {
db := database.DB()
switch c.Request.Method {
case http.MethodGet:
partId := c.Query("partId")
status := c.Query("status")
if status == "" {
status = "published"
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "100"))
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 500 {
pageSize = 100
}
q := db.Model(&model.Chapter{})
if partId != "" {
q = q.Where("part_id = ?", partId)
}
if status != "" && status != "all" {
q = q.Where("status = ?", status)
}
var total int64
q.Count(&total)
var list []model.Chapter
q.Order("sort_order ASC, id ASC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&list)
totalPages := int(total) / pageSize
if int(total)%pageSize > 0 {
totalPages++
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"list": list, "total": total, "page": page, "pageSize": pageSize, "totalPages": totalPages,
},
})
return
case http.MethodPost:
var body model.Chapter
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请求体无效"})
return
}
if body.ID == "" || body.PartID == "" || body.ChapterID == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少必要字段 id/partId/chapterId"})
return
}
if err := db.Create(&body).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": body})
return
case http.MethodPut:
var body model.Chapter
if err := c.ShouldBindJSON(&body); err != nil || body.ID == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 id"})
return
}
updates := map[string]interface{}{
"part_title": body.PartTitle, "chapter_title": body.ChapterTitle, "section_title": body.SectionTitle,
"content": body.Content, "word_count": body.WordCount, "is_free": body.IsFree, "price": body.Price,
"sort_order": body.SortOrder, "status": body.Status,
}
if body.EditionStandard != nil {
updates["edition_standard"] = body.EditionStandard
}
if body.EditionPremium != nil {
updates["edition_premium"] = body.EditionPremium
}
if err := db.Model(&model.Chapter{}).Where("id = ?", body.ID).Updates(updates).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
return
case http.MethodDelete:
id := c.Query("id")
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 id"})
return
}
if err := db.Where("id = ?", id).Delete(&model.Chapter{}).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
return
}
c.JSON(http.StatusOK, gin.H{"success": false, "error": "不支持的请求方法"})
}
// bookHotChaptersSorted 按精选推荐算法排序:阅读量优先,同量按更新时间;排除序言/尾声/附录
func bookHotChaptersSorted(db *gorm.DB, limit int) []model.Chapter {
q := db.Model(&model.Chapter{})
for _, p := range excludeParts {
q = q.Where("part_title NOT LIKE ?", "%"+p+"%")
}
var all []model.Chapter
if err := q.Order("sort_order ASC, id ASC").Find(&all).Error; err != nil || len(all) == 0 {
return nil
}
// 从 reading_progress 统计阅读量
ids := make([]string, 0, len(all))
for _, c := range all {
ids = append(ids, c.ID)
}
var counts []struct {
SectionID string `gorm:"column:section_id"`
Cnt int64 `gorm:"column:cnt"`
}
db.Table("reading_progress").Select("section_id, COUNT(*) as cnt").
Where("section_id IN ?", ids).Group("section_id").Scan(&counts)
countMap := make(map[string]int64)
for _, r := range counts {
countMap[r.SectionID] = r.Cnt
}
// 按阅读量降序、同量按 updated_at 降序
type withSort struct {
ch model.Chapter
cnt int64
}
withCnt := make([]withSort, 0, len(all))
for _, c := range all {
withCnt = append(withCnt, withSort{ch: c, cnt: countMap[c.ID]})
}
for i := 0; i < len(withCnt)-1; i++ {
for j := i + 1; j < len(withCnt); j++ {
if withCnt[j].cnt > withCnt[i].cnt ||
(withCnt[j].cnt == withCnt[i].cnt && withCnt[j].ch.UpdatedAt.After(withCnt[i].ch.UpdatedAt)) {
withCnt[i], withCnt[j] = withCnt[j], withCnt[i]
}
}
}
out := make([]model.Chapter, 0, limit)
for i := 0; i < limit && i < len(withCnt); i++ {
out = append(out, withCnt[i].ch)
}
return out
}
// BookHot GET /api/book/hot 热门章节(按阅读量排序,排除序言/尾声/附录)
func BookHot(c *gin.Context) {
list := bookHotChaptersSorted(database.DB(), 10)
if len(list) == 0 {
// 兜底:按 sort_order 取前 10同样排除序言/尾声/附录
q := database.DB().Model(&model.Chapter{})
for _, p := range excludeParts {
q = q.Where("part_title NOT LIKE ?", "%"+p+"%")
}
q.Order("sort_order ASC, id ASC").Limit(10).Find(&list)
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
}
// BookRecommended GET /api/book/recommended 精选推荐(首页「为你推荐」前 3 章)
// 与内容排行榜完全同源:使用 computeArticleRankingSections取前 3 条,保证顺序一致
func BookRecommended(c *gin.Context) {
sections, err := computeArticleRankingSections(database.DB())
if err != nil || len(sections) == 0 {
c.JSON(http.StatusOK, gin.H{"success": true, "data": []gin.H{}})
return
}
limit := 3
if len(sections) < limit {
limit = len(sections)
}
tags := []string{"热门", "推荐", "精选"}
out := make([]gin.H, 0, limit)
for i := 0; i < limit; i++ {
s := sections[i]
tag := "精选"
if i < len(tags) {
tag = tags[i]
}
out = append(out, gin.H{
"id": s.ID,
"mid": s.MID,
"sectionTitle": s.Title,
"partTitle": s.PartTitle,
"chapterTitle": s.ChapterTitle,
"tag": tag,
"isFree": s.IsFree,
"price": s.Price,
"isNew": s.IsNew,
})
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": out})
}
// BookLatestChapters GET /api/book/latest-chapters
func BookLatestChapters(c *gin.Context) {
var list []model.Chapter
database.DB().Order("updated_at DESC, id ASC").Limit(20).Find(&list)
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
}
func escapeLikeBook(s string) string {
s = strings.ReplaceAll(s, "\\", "\\\\")
s = strings.ReplaceAll(s, "%", "\\%")
s = strings.ReplaceAll(s, "_", "\\_")
return s
}
// BookSearch GET /api/book/search?q= 章节搜索
// 优化:先搜标题(快),再搜内容(慢),不加载完整 content
func BookSearch(c *gin.Context) {
q := strings.TrimSpace(c.Query("q"))
if q == "" {
c.JSON(http.StatusOK, gin.H{"success": true, "results": []interface{}{}, "total": 0, "keyword": ""})
return
}
pattern := "%" + escapeLikeBook(q) + "%"
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"`
}
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": "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})
}
// BookStats GET /api/book/stats
func BookStats(c *gin.Context) {
var total int64
database.DB().Model(&model.Chapter{}).Count(&total)
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"totalChapters": total}})
}
// BookSync GET/POST /api/book/sync
func BookSync(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "message": "同步由 DB 维护"})
}