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:
@@ -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.go(HTML 剥标签后与 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)
|
||||
|
||||
121
soul-api/internal/handler/chapter_preview.go
Normal file
121
soul-api/internal/handler/chapter_preview.go
Normal 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_ui(JSON 对象)与默认文案;占位符 {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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user