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:
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
|
||||
}
|
||||
Reference in New Issue
Block a user