Files
Mycontent/soul-api/internal/handler/h5_read.go
2026-04-13 14:32:32 +08:00

296 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package handler
import (
"fmt"
"html"
"net/http"
"strings"
"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 := chapterPreviewPercent(db, &ch)
preview := previewContent(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 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}
.btn-unlock{display:block;width:100%%;padding:14px 0;text-align:center;background:linear-gradient(135deg,#f59e0b,#f97316);color:#fff;border-radius:8px;font-size:16px;font-weight:700;border:none;cursor:pointer;letter-spacing:1px;margin-bottom:10px;box-shadow:0 2px 12px rgba(245,158,11,.35)}
.btn-unlock:active{opacity:.85}
.guide-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.72);z-index:200;flex-direction:column;align-items:center;justify-content:flex-end;padding-bottom:80px}
.guide-overlay.visible{display:flex}
.guide-text{color:#fff;font-size:17px;font-weight:600;text-align:center;margin-bottom:16px;text-shadow:0 1px 4px rgba(0,0,0,.5)}
.guide-arrow-wrap{position:absolute;bottom:24px;right:36px;display:flex;flex-direction:column;align-items:center;gap:6px}
.guide-arrow-label{color:#fff;font-size:13px;white-space:nowrap;opacity:.9}
.guide-arrow{width:36px;height:36px;animation:bounceArrow 1s ease-in-out infinite}
@keyframes bounceArrow{0%%,100%%{transform:translateY(0)}50%%{transform:translateY(10px)}}
.guide-close{position:absolute;top:20px;right:20px;width:36px;height:36px;border-radius:50%%;background:rgba(255,255,255,.15);border:none;color:#fff;font-size:20px;cursor:pointer;display:flex;align-items:center;justify-content:center}
.guide-close:active{background:rgba(255,255,255,.3)}
</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>
<button class="btn-unlock" id="unlock-btn" onclick="showGuide()">¥1 解锁全文</button>
<div id="mp-btn-area">%s</div>
<button class="btn-fallback" id="fallback-btn" style="display:none" onclick="copyAndOpen()">
复制链接并打开微信
</button>
</div>
</div>
<div class="guide-overlay" id="guide-overlay" onclick="hideGuide()">
<button class="guide-close" onclick="hideGuide()">×</button>
<p class="guide-text">在小程序内完成购买,点击下方前往</p>
<div class="guide-arrow-wrap">
<span class="guide-arrow-label">点击右下角前往小程序</span>
<svg class="guide-arrow" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 4v12m0 0l-5-5m5 5l5-5" stroke="#fff" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</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 showGuide() {
document.getElementById('guide-overlay').classList.add('visible');
}
function hideGuide() {
document.getElementById('guide-overlay').classList.remove('visible');
}
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()
}