- 超级个体:去掉首位特例;列表仅展示有头像且非微信默认昵称(vip.go) - 个人资料:居中头像、低调联系方式、点头像优先走存客宝 lead(ckbLeadToken) - 阅读页分享朋友圈复制与 toast 去重 - soul-api: miniprogram users 带 ckbLeadToken;其它 handler 与路由调整 - 脚本:content_upload、miniprogram 上传辅助等 Made-with: Cursor
288 lines
9.1 KiB
Go
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()
|
|
}
|