Files
soul-yongping/soul-api/internal/handler/h5_read.go
卡若 5724fba877 feat: 小程序超级个体/个人资料/CKB获客;VIP列表展示过滤;管理端与API联调
- 超级个体:去掉首位特例;列表仅展示有头像且非微信默认昵称(vip.go)
- 个人资料:居中头像、低调联系方式、点头像优先走存客宝 lead(ckbLeadToken)
- 阅读页分享朋友圈复制与 toast 去重
- soul-api: miniprogram users 带 ckbLeadToken;其它 handler 与路由调整
- 脚本:content_upload、miniprogram 上传辅助等

Made-with: Cursor
2026-03-22 08:34:28 +08:00

288 lines
9.1 KiB
Go

package handler
import (
"fmt"
"html"
"net/http"
"strings"
"unicode/utf8"
"soul-api/internal/config"
"soul-api/internal/database"
"soul-api/internal/model"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// H5ReadPage GET /read/:id 朋友圈/外部链接落地页
// 渲染文章预览内容(按 unpaid_preview_percent 截取),底部显示「打开小程序继续阅读」按钮
// 支持 ?ref=xxx 分销参数透传
func H5ReadPage(c *gin.Context) {
sectionID := c.Param("id")
if sectionID == "" {
c.Data(http.StatusBadRequest, "text/html; charset=utf-8", []byte(h5Error("缺少文章 ID")))
return
}
ref := c.Query("ref")
db := database.DB()
var ch model.Chapter
if err := db.Where("id = ?", sectionID).First(&ch).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.Data(http.StatusNotFound, "text/html; charset=utf-8", []byte(h5Error("文章不存在")))
return
}
c.Data(http.StatusInternalServerError, "text/html; charset=utf-8", []byte(h5Error("加载失败")))
return
}
percent := getUnpaidPreviewPercent(db)
preview := h5PreviewContent(ch.Content, percent)
title := ch.SectionTitle
if title == "" {
title = ch.ChapterTitle
}
partTitle := ""
if ch.PartTitle != "" {
partTitle = ch.PartTitle
}
chapterTitle := ""
if ch.ChapterTitle != "" && ch.ChapterTitle != title {
chapterTitle = ch.ChapterTitle
}
cfg := config.Get()
appID := cfg.WechatAppID
mpPath := fmt.Sprintf("pages/read/read?id=%s&action=pay", sectionID)
if ref != "" {
mpPath += "&ref=" + ref
}
pageHTML := h5BuildPage(h5PageData{
Title: title,
PartTitle: partTitle,
ChapterTitle: chapterTitle,
Preview: preview,
Percent: percent,
SectionID: sectionID,
Ref: ref,
AppID: appID,
MpPath: mpPath,
})
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>
</head><body><p>%s</p></body></html>`, html.EscapeString(msg))
}
type h5PageData struct {
Title, PartTitle, ChapterTitle string
Preview string
Percent int
SectionID, Ref string
AppID, MpPath string
}
func h5BuildPage(d h5PageData) string {
escapedTitle := html.EscapeString(d.Title)
escapedPart := html.EscapeString(d.PartTitle)
escapedChapter := html.EscapeString(d.ChapterTitle)
contentHTML := h5ContentToHTML(d.Preview)
subtitle := ""
if escapedPart != "" || escapedChapter != "" {
parts := []string{}
if escapedPart != "" {
parts = append(parts, escapedPart)
}
if escapedChapter != "" {
parts = append(parts, escapedChapter)
}
subtitle = fmt.Sprintf(`<p class="sub">%s</p>`, strings.Join(parts, " · "))
}
mpBtn := fmt.Sprintf(
`<wx-open-launch-weapp id="launch-btn" appid="%s" path="%s" style="display:block;width:100%%">
<script type="text/wxtag-template">
<style>.btn{display:block;width:100%%;padding:14px 0;text-align:center;background:#07c160;color:#fff;border-radius:8px;font-size:16px;font-weight:600;border:none;letter-spacing:1px;}</style>
<button class="btn">打开小程序 购买全文</button>
</script>
</wx-open-launch-weapp>`,
html.EscapeString(d.AppID), html.EscapeString(d.MpPath))
return fmt.Sprintf(`<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<title>%s - 一场Soul的创业实验</title>
<meta name="description" content="%s">
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",sans-serif;background:#0b1220;color:#d1d5db;line-height:1.8;-webkit-font-smoothing:antialiased}
.wrap{max-width:680px;margin:0 auto;padding:20px 16px 120px}
.hdr{padding:16px 0;border-bottom:1px solid rgba(255,255,255,.08);margin-bottom:20px}
.hdr h1{font-size:22px;color:#fff;line-height:1.4;font-weight:700}
.hdr .sub{font-size:13px;color:#6b7280;margin-top:6px}
.content{font-size:15px;color:#c9d1d9;line-height:1.9;word-break:break-word}
.content p{margin-bottom:12px}
.content h1,.content h2,.content h3{color:#fff;margin:20px 0 10px;font-weight:600}
.content h1{font-size:20px}
.content h2{font-size:18px}
.content h3{font-size:16px}
.content strong{color:#fff}
.content blockquote{border-left:3px solid #38bdac;padding-left:12px;margin:12px 0;color:#9ca3af}
.content code{background:#1f2937;padding:2px 6px;border-radius:3px;font-size:13px;color:#38bdac}
.content img{max-width:100%%;border-radius:6px;margin:8px 0}
.fade{position:relative;overflow:hidden;max-height:none}
.fade::after{content:"";position:absolute;bottom:0;left:0;right:0;height:120px;background:linear-gradient(transparent,#0b1220);pointer-events:none}
.cta{position:fixed;bottom:0;left:0;right:0;background:linear-gradient(transparent,#0b1220 20%%);padding:16px 16px 24px;z-index:100}
.cta-inner{max-width:680px;margin:0 auto}
.cta-hint{text-align:center;font-size:12px;color:#6b7280;margin-bottom:8px}
.btn-fallback{display:block;width:100%%;padding:14px 0;text-align:center;background:#07c160;color:#fff;border-radius:8px;font-size:16px;font-weight:600;border:none;cursor:pointer;letter-spacing:1px;text-decoration:none}
.btn-fallback:active{opacity:.8}
.copy-toast{display:none;position:fixed;top:50%%;left:50%%;transform:translate(-50%%,-50%%);background:rgba(0,0,0,.8);color:#fff;padding:12px 24px;border-radius:8px;font-size:14px;z-index:999}
</style>
</head>
<body>
<div class="wrap">
<div class="hdr">
<h1>%s</h1>
%s
</div>
<div class="content fade" id="article-content">
%s
</div>
</div>
<div class="cta">
<div class="cta-inner">
<p class="cta-hint">已预览 %d%% · 打开小程序购买并阅读全文</p>
<div id="mp-btn-area">%s</div>
<button class="btn-fallback" id="fallback-btn" style="display:none" onclick="copyAndOpen()">
复制链接并打开微信
</button>
</div>
</div>
<div class="copy-toast" id="toast">已复制,请打开微信</div>
<script src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"></script>
<script>
(function(){
var ua = navigator.userAgent.toLowerCase();
var isWx = ua.indexOf('micromessenger') !== -1;
var launchBtn = document.getElementById('launch-btn');
var fallbackBtn = document.getElementById('fallback-btn');
if (!isWx) {
if (launchBtn) launchBtn.style.display = 'none';
fallbackBtn.style.display = 'block';
} else {
if (launchBtn) {
launchBtn.addEventListener('error', function(e) {
console.log('launch-btn error', e.detail);
launchBtn.style.display = 'none';
fallbackBtn.style.display = 'block';
});
}
}
})();
function copyAndOpen() {
var link = 'https://soul.quwanzhi.com/read/%s';
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(link).then(showToast);
} else {
var ta = document.createElement('textarea');
ta.value = link;
ta.style.position = 'fixed';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
showToast();
}
}
function showToast() {
var t = document.getElementById('toast');
t.style.display = 'block';
setTimeout(function(){ t.style.display = 'none'; }, 2000);
}
</script>
</body>
</html>`,
escapedTitle, escapedTitle,
escapedTitle, subtitle,
contentHTML,
d.Percent, mpBtn,
html.EscapeString(d.SectionID))
}
func h5ContentToHTML(content string) string {
if content == "" {
return ""
}
if strings.HasPrefix(content, "<") && strings.Contains(content, "</") {
return content
}
lines := strings.Split(content, "\n")
var sb strings.Builder
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
if strings.HasPrefix(trimmed, "### ") {
sb.WriteString("<h3>")
sb.WriteString(html.EscapeString(trimmed[4:]))
sb.WriteString("</h3>")
} else if strings.HasPrefix(trimmed, "## ") {
sb.WriteString("<h2>")
sb.WriteString(html.EscapeString(trimmed[3:]))
sb.WriteString("</h2>")
} else if strings.HasPrefix(trimmed, "# ") {
sb.WriteString("<h1>")
sb.WriteString(html.EscapeString(trimmed[2:]))
sb.WriteString("</h1>")
} else if strings.HasPrefix(trimmed, "> ") {
sb.WriteString("<blockquote>")
sb.WriteString(html.EscapeString(trimmed[2:]))
sb.WriteString("</blockquote>")
} else {
sb.WriteString("<p>")
sb.WriteString(html.EscapeString(trimmed))
sb.WriteString("</p>")
}
}
return sb.String()
}