feat: 阅读页与章节预览 API;管理端内容页;book/h5_read;脚本与文档

- miniprogram: read 页与 member-detail/my;SOP 文档
- soul-api: chapter_preview、book/h5_read 调整;VIP 订单回填 SQL
- soul-admin: ContentPage、dist
- scripts: pull_from_baota;content_upload、gitignore、对话规则

Made-with: Cursor
This commit is contained in:
卡若
2026-03-26 20:08:43 +08:00
parent d6c8aabbe8
commit 6aa0d27da1
19 changed files with 1825 additions and 130 deletions

View File

@@ -10,7 +10,6 @@ import (
"strings"
"sync"
"time"
"unicode/utf8"
"soul-api/internal/cache"
"soul-api/internal/database"
@@ -643,44 +642,6 @@ func getUnpaidPreviewPercent(db *gorm.DB) int {
return 20
}
// effectivePreviewPercent 章节 preview_percent 优先1~100否则用全局 unpaid_preview_percent
func effectivePreviewPercent(db *gorm.DB, ch *model.Chapter) int {
if ch != nil && ch.PreviewPercent != nil {
p := *ch.PreviewPercent
if p >= 1 && p <= 100 {
return p
}
}
return getUnpaidPreviewPercent(db)
}
// previewContent 取内容的前 percent%(不少于 100 字,上限 500 字),并追加省略提示
func previewContent(content string, percent int) string {
total := utf8.RuneCountInString(content)
if total == 0 {
return ""
}
if percent < 1 {
percent = 1
}
if percent > 100 {
percent = 100
}
limit := total * percent / 100
if limit < 100 {
limit = 100
}
const maxPreview = 500
if limit > maxPreview {
limit = maxPreview
}
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
@@ -723,13 +684,12 @@ func findChapterAndRespond(c *gin.Context, whereFn func(*gorm.DB) *gorm.DB) {
isPremium := ch.EditionPremium != nil && *ch.EditionPremium
hasFullAccess := isFree || checkUserChapterAccess(db, userID, ch.ID, isPremium)
var returnContent string
// 未解锁:正文截取用「章节覆盖 全局」;响应里顶层 previewPercent 仅表示全局默认data.previewPercent 表示章节私有model omitempty
var effectiveUnpaidPreviewPercent int
// 未解锁:正文截取见 chapter_preview.goHTML 剥标签后与 H5 一致
if hasFullAccess {
returnContent = ch.Content
} else {
effectiveUnpaidPreviewPercent = effectivePreviewPercent(db, &ch)
returnContent = previewContent(ch.Content, effectiveUnpaidPreviewPercent)
percent := chapterPreviewPercent(db, &ch)
returnContent = previewContent(ch.Content, percent)
}
// data 中的 content 必须与外层 content 一致,避免泄露完整内容给未授权用户
@@ -739,17 +699,20 @@ func findChapterAndRespond(c *gin.Context, whereFn func(*gorm.DB) *gorm.DB) {
chForResponse.ChapterTitle = sanitizeChapterTitleField(ch.ChapterTitle)
chForResponse.SectionTitle = sanitizeChapterTitleField(ch.SectionTitle)
effectivePct := chapterPreviewPercent(db, &ch)
out := gin.H{
"success": true,
"data": chForResponse,
"content": returnContent,
"chapterTitle": chForResponse.ChapterTitle,
"partTitle": chForResponse.PartTitle,
"id": ch.ID,
"mid": ch.MID,
"sectionTitle": chForResponse.SectionTitle,
"isFree": isFree,
"hasFullAccess": hasFullAccess,
"success": true,
"data": chForResponse,
"content": returnContent,
"chapterTitle": chForResponse.ChapterTitle,
"partTitle": chForResponse.PartTitle,
"id": ch.ID,
"mid": ch.MID,
"sectionTitle": chForResponse.SectionTitle,
"isFree": isFree,
"hasFullAccess": hasFullAccess,
"unpaidPreviewPercent": effectivePct,
"readPreviewUi": mergeReadPreviewUI(db),
}
if !hasFullAccess {
out["previewPercent"] = getUnpaidPreviewPercent(db)

View File

@@ -0,0 +1,121 @@
package handler
import (
"encoding/json"
"html"
"regexp"
"strings"
"unicode/utf8"
"soul-api/internal/model"
"gorm.io/gorm"
)
var (
reHTMLScript = regexp.MustCompile(`(?is)<script[^>]*>.*?</script>`)
reHTMLStyle = regexp.MustCompile(`(?is)<style[^>]*>.*?</style>`)
reHTMLBr = regexp.MustCompile(`(?i)<\s*br\s*/?>`)
reHTMLPClose = regexp.MustCompile(`(?i)</\s*p\s*>`)
reHTMLTags = regexp.MustCompile(`<[^>]+>`)
)
// stripHTMLToPlainPreview 将正文 HTML 转为纯文本再截取,避免预览截在 <img 中间导致用户看到半截标签
func stripHTMLToPlainPreview(s string) string {
s = reHTMLScript.ReplaceAllString(s, " ")
s = reHTMLStyle.ReplaceAllString(s, " ")
s = reHTMLBr.ReplaceAllString(s, "\n")
s = reHTMLPClose.ReplaceAllString(s, "\n")
s = reHTMLTags.ReplaceAllString(s, "")
s = html.UnescapeString(s)
s = strings.NewReplacer("\r\n", "\n", "\r", "\n").Replace(s)
return strings.TrimSpace(s)
}
// chapterPreviewPercent 章节单独 preview_percent 覆盖全局 unpaid_preview_percent
func chapterPreviewPercent(db *gorm.DB, ch *model.Chapter) int {
g := getUnpaidPreviewPercent(db)
if ch != nil && ch.PreviewPercent != nil {
p := *ch.PreviewPercent
if p >= 1 && p <= 100 {
return p
}
}
return g
}
// previewContent 取内容的前 percent%(按纯文本 rune 计;原 HTML 先剥标签),与 h5 落地页一致
func previewContent(content string, percent int) string {
work := content
if strings.Contains(content, "<") && strings.Contains(content, ">") {
work = stripHTMLToPlainPreview(content)
}
total := utf8.RuneCountInString(work)
if total == 0 {
return ""
}
if percent < 1 {
percent = 1
}
if percent > 100 {
percent = 100
}
limit := total * percent / 100
if limit < 100 {
limit = 100
}
const maxPreview = 500
if limit > maxPreview {
limit = maxPreview
}
if limit > total {
limit = total
}
runes := []rune(work)
return string(runes[:limit]) + "\n\n……"
}
func defaultReadPreviewUI() map[string]string {
return map[string]string{
"singlePageUnlockTitle": "解锁完整内容",
"singlePagePayButtonText": "支付 ¥{price} 解锁全文",
"singlePageExpandedHint": "预览页不能直接付款,务必先点底栏「前往小程序」。",
"payTapModalTitle": "解锁说明",
"payTapModalContent": "全文 ¥{price}。预览里无法完成支付:请先点屏幕底部「前往小程序」进入完整版,登录后再付款解锁。",
"fullUnlockTitle": "解锁完整内容",
"fullUnlockDesc": "可先上滑阅读预览;需要全文时,点下方「支付¥{price}」查看说明",
"fullLockedProgressText": "已阅读约 {percent}% ,购买后继续阅读",
"fullPaywallTip": "转发给需要的人,一起学习还能赚佣金",
"notLoginUnlockDesc": "已预览约 {percent}% 内容,登录并支付 ¥{price} 后阅读全文",
"notLoginPaywallTip": "分享给好友一起学习,还能赚取佣金",
"shareTipLine": "好友经你分享购买,你可获得约 90% 收益",
"momentsModalTitle": "分享到朋友圈",
"momentsModalContent": "已复制发圈文案(非分享给好友)。\n\n请点击右上角「···」→「分享到朋友圈」粘贴发布。",
"momentsClipboardFooter": "\n\n—— 以上为正文预览约 {percent}% ,搜「卡若创业派对」小程序阅读全文 ——",
"timelineTitleSuffix": "(预览{percent}%",
}
}
// mergeReadPreviewUI 合并 system_config.read_preview_uiJSON 对象)与默认文案;占位符 {percent}/{price} 由小程序替换
func mergeReadPreviewUI(db *gorm.DB) map[string]string {
out := defaultReadPreviewUI()
var row model.SystemConfig
if err := db.Where("config_key = ?", "read_preview_ui").First(&row).Error; err != nil || len(row.ConfigValue) == 0 {
return out
}
var raw map[string]interface{}
if err := json.Unmarshal(row.ConfigValue, &raw); err != nil {
return out
}
for k, v := range raw {
switch t := v.(type) {
case string:
if strings.TrimSpace(t) != "" {
out[k] = strings.TrimSpace(t)
}
default:
// 兼容数字等被误存:忽略非字符串
}
}
return out
}

View File

@@ -5,7 +5,6 @@ import (
"html"
"net/http"
"strings"
"unicode/utf8"
"soul-api/internal/config"
"soul-api/internal/database"
@@ -37,8 +36,8 @@ func H5ReadPage(c *gin.Context) {
return
}
percent := effectivePreviewPercent(db, &ch)
preview := h5PreviewContent(ch.Content, percent)
percent := chapterPreviewPercent(db, &ch)
preview := previewContent(ch.Content, percent)
title := ch.SectionTitle
if title == "" {
@@ -74,28 +73,6 @@ func H5ReadPage(c *gin.Context) {
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(pageHTML))
}
func h5PreviewContent(content string, percent int) string {
total := utf8.RuneCountInString(content)
if total == 0 {
return ""
}
if percent < 1 {
percent = 1
}
if percent > 100 {
percent = 100
}
limit := total * percent / 100
if limit < 100 {
limit = 100
}
if limit > total {
limit = total
}
runes := []rune(content)
return string(runes[:limit])
}
func h5Error(msg string) string {
return fmt.Sprintf(`<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>提示</title><style>body{font-family:-apple-system,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;background:#0f1923;color:#ccc;}</style>