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

@@ -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
}