Files
Mycontent/soul-api/internal/handler/h5_super_article.go
2026-05-09 18:46:59 +08:00

319 lines
13 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 (
"context"
"encoding/json"
"fmt"
"html"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"time"
"unicode/utf8"
"soul-api/internal/config"
"soul-api/internal/database"
"soul-api/internal/model"
"soul-api/internal/wechat"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
var (
reSAHTMLMention = regexp.MustCompile(`(^|[\s])(@[\x{4e00}-\x{9fa5}A-Za-z0-9_]+)`)
reSAHTMLTag = regexp.MustCompile(`(^|[\s])(#[^\s#]+)`)
reSAHTMLLink = regexp.MustCompile(`(https?://[^\s&<]+)`)
)
// h5SuperArticleBodyHTML 与 miniprogram super-article-detail renderArticleHtml 行为一致
func h5SuperArticleBodyHTML(text string) string {
src := html.EscapeString(text)
src = reSAHTMLMention.ReplaceAllString(src, "$1<span style=\"color:#67e8f9;\">$2</span>")
src = reSAHTMLTag.ReplaceAllString(src, "$1<span style=\"color:#5eead4;\">$2</span>")
src = reSAHTMLLink.ReplaceAllString(src, "<a href=\"$1\" style=\"color:#22d3ee;text-decoration:underline;\" rel=\"noopener noreferrer\" target=\"_blank\">$1</a>")
return strings.ReplaceAll(src, "\n", "<br/>")
}
func h5SuperArticleGalleryHTML(urls []string) string {
if len(urls) == 0 {
return ""
}
var b strings.Builder
b.WriteString(`<div class="sa-gallery">`)
for _, u := range urls {
u = strings.TrimSpace(u)
if u == "" {
continue
}
esc := html.EscapeString(u)
b.WriteString(`<div class="sa-img-wrap"><img src="`)
b.WriteString(esc)
b.WriteString(`" alt="" loading="lazy"/></div>`)
}
b.WriteString(`</div>`)
return b.String()
}
type h5SuperArticleData struct {
Title, MetaLine, ContentHTML string
Canonical string
OgTagsHTML string
ShareLinkJSON, DisplayURL string
AppID, MpPath string
JSSDKJSON string
}
// H5SuperArticlePage GET /s/:id
// 超级个体文章 H5 全文阅读(无小程序 token与书籍章节 /read/:id 区分)
// 支持 ?ref= 透传至「打开小程序」path
func H5SuperArticlePage(c *gin.Context) {
idStr := strings.TrimSpace(c.Param("id"))
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil || id == 0 {
c.Data(http.StatusBadRequest, "text/html; charset=utf-8", []byte(h5Error("缺少或无效文章 ID")))
return
}
ref := strings.TrimSpace(c.Query("ref"))
db := database.DB()
var row model.SuperArticle
if err := db.First(&row, id).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
}
if _, ok := miniprogramSuperIndividualPerson(db, row.UserID); !ok {
c.Data(http.StatusNotFound, "text/html; charset=utf-8", []byte(h5Error("文章不可见")))
return
}
if !model.SuperArticlePublicApproved(row.AuditStatus) {
c.Data(http.StatusNotFound, "text/html; charset=utf-8", []byte(h5Error("文章不存在或未通过审核")))
return
}
authorMap := loadSuperArticleAuthorMap([]string{row.UserID})
author := authorMap[row.UserID]
nick := strings.TrimSpace(author.Nickname)
if nick == "" {
nick = "超级个体"
}
created := row.CreatedAt.In(time.Local).Format("01-02 15:04")
metaLine := nick + " · " + created
cfg := config.Get()
appID := cfg.WechatAppID
publicBase := strings.TrimSuffix(strings.TrimSpace(cfg.H5ReadPublicBaseURL), "/")
if publicBase == "" {
publicBase = strings.TrimSuffix(strings.TrimSpace(cfg.BaseURL), "/")
}
if publicBase == "" {
publicBase = "https://soulapi.quwanzhi.com"
}
qv := url.Values{}
qv.Set("superArticleId", idStr)
if ref != "" {
qv.Set("ref", ref)
}
mpPath := "pages/read/read?" + qv.Encode()
canonical := fmt.Sprintf("%s/s/%s", publicBase, url.PathEscape(idStr))
if ref != "" {
canonical += "?ref=" + url.QueryEscape(ref)
}
plainTitle := strings.TrimSpace(row.Title)
ogDesc := plainTitle
if p := strings.TrimSpace(row.Content); p != "" {
if n := utf8.RuneCountInString(p); n > 120 {
ogDesc = string([]rune(p)[:120]) + "…"
} else {
ogDesc = p
}
}
articleImages := parseSuperArticleImagesJSON(row.Images)
ogImage := ""
if len(articleImages) > 0 && strings.TrimSpace(articleImages[0]) != "" {
ogImage = h5AbsolutizeImage(strings.TrimSpace(articleImages[0]), cfg.BaseURL)
}
if ogImage == "" {
ogImage = strings.TrimSpace(author.Avatar)
if ogImage != "" {
ogImage = h5AbsolutizeImage(ogImage, cfg.BaseURL)
}
}
shareLinkJSON, _ := json.Marshal(canonical)
signURL := h5RequestSignURL(c)
jssdk := wechat.BuildJSSDKOpenLaunchPayload(context.Background(), signURL)
jssdkBytes, jerr := json.Marshal(jssdk)
if jerr != nil {
jssdkBytes = []byte(`{"ok":false}`)
}
ti := html.EscapeString(plainTitle)
if ti == "" {
ti = "文章"
}
page := h5BuildSuperArticlePage(h5SuperArticleData{
Title: ti,
MetaLine: html.EscapeString(metaLine),
ContentHTML: h5SuperArticleGalleryHTML(articleImages) + h5SuperArticleBodyHTML(row.Content),
Canonical: canonical,
OgTagsHTML: h5BuildOgTags(canonical, plainTitle, ogDesc, ogImage),
ShareLinkJSON: string(shareLinkJSON),
DisplayURL: canonical,
AppID: appID,
MpPath: mpPath,
JSSDKJSON: string(jssdkBytes),
})
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(page))
}
func h5BuildSuperArticlePage(d h5SuperArticleData) string {
escAppID := html.EscapeString(d.AppID)
escMp := html.EscapeString(d.MpPath)
escURL := html.EscapeString(d.DisplayURL)
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>
.sa-shell{width:100%%;box-sizing:border-box;font-family:-apple-system,BlinkMacSystemFont,"PingFang SC",sans-serif;}
.sa-cta-wrap{padding:12px 14px 16px;}
.sa-cta{
display:flex;width:100%%;align-items:center;justify-content:center;padding:14px 16px;border-radius:999px;
background:linear-gradient(135deg,#00CED1 0%%,#20B2AA 100%%);
border:none;box-shadow:0 4px 16px rgba(0,206,209,0.28);
font-size:15px;font-weight:600;color:#fff;
}
</style>
<div class="sa-shell"><div class="sa-cta-wrap"><div class="sa-cta">在小程序中打开</div></div></div>
</script>
</wx-open-launch-weapp>`,
escAppID, escMp)
var b strings.Builder
b.WriteString("<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n<meta charset=\"utf-8\">\n")
b.WriteString(`<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">` + "\n")
b.WriteString(d.OgTagsHTML)
b.WriteString("\n<title>")
b.WriteString(d.Title)
b.WriteString(" - Soul创业派对</title>\n<style>\n")
b.WriteString(`*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Helvetica Neue",sans-serif;background:#0b1220;color:rgba(255,255,255,0.9);line-height:1.75;-webkit-font-smoothing:antialiased;padding-bottom:calc(200px + env(safe-area-inset-bottom))}
.wrap{max-width:680px;margin:0 auto;padding:20px 16px 32px}
.badge-row{margin-bottom:12px}
.sa-badge{display:inline-block;padding:4px 10px;border-radius:8px;font-size:12px;font-weight:600;color:#7dd3fc;background:rgba(56,189,248,0.12);border:1px solid rgba(56,189,248,0.35)}
.hdr{padding:8px 0 20px;border-bottom:1px solid rgba(255,255,255,0.08);margin-bottom:20px}
.hdr h1{font-size:22px;color:#f8fafc;line-height:1.45;font-weight:700}
.hdr .meta{font-size:13px;color:rgba(148,163,184,0.95);margin-top:10px}
.sa-gallery{display:flex;flex-direction:column;gap:12px;margin-bottom:20px}
.sa-img-wrap{border-radius:12px;overflow:hidden;border:1px solid rgba(255,255,255,0.08)}
.sa-gallery img{display:block;width:100%;height:auto}
.content{font-size:16px;color:#e2e8f0;line-height:1.8;word-break:break-word}
.cta{position:fixed;bottom:0;left:0;right:0;background:linear-gradient(to top,rgba(11,18,32,0.98) 45%,transparent);padding:10px 16px calc(16px + env(safe-area-inset-bottom));z-index:100}
.cta-inner{max-width:680px;margin:0 auto}
.share-link-bar{margin-bottom:10px;padding:10px 12px;border-radius:10px;background:rgba(255,255,255,0.05);border:1px solid rgba(56,189,248,0.25)}
.share-link-label{display:block;font-size:11px;color:rgba(255,255,255,0.5);margin-bottom:6px;text-align:center}
.share-url-text{font-size:12px;color:#7dd3fc;word-break:break-all;line-height:1.45;text-align:left;margin-bottom:8px}
.btn-copy-url{display:block;width:100%;padding:9px 0;text-align:center;font-size:13px;font-weight:600;color:#7dd3fc;background:rgba(56,189,248,0.12);border:1px solid rgba(56,189,248,0.45);border-radius:10px;cursor:pointer}
.btn-copy-url:active{opacity:0.88}
.btn-fallback{display:block;width:100%;margin-top:10px;padding:12px 0;text-align:center;background:transparent;color:rgba(255,255,255,0.88);border-radius:12px;font-size:14px;font-weight:500;border:1px solid rgba(255,255,255,0.22);cursor:pointer}
.copy-toast{display:none;position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:rgba(0,0,0,0.85);color:#fff;padding:12px 24px;border-radius:8px;font-size:14px;z-index:999}
`)
b.WriteString("</style>\n</head>\n<body>\n<div class=\"wrap\">\n")
b.WriteString(`<div class="badge-row"><span class="sa-badge">超级个体 · 文章</span></div>` + "\n")
b.WriteString("<div class=\"hdr\">\n<h1>")
b.WriteString(d.Title)
b.WriteString(`</h1><p class="meta">` + d.MetaLine + `</p></div>` + "\n")
b.WriteString(`<div class="content" id="article-content">` + "\n")
b.WriteString(d.ContentHTML)
b.WriteString("\n</div></div>\n")
b.WriteString(`<div class="cta"><div class="cta-inner">` + "\n")
b.WriteString(`<div class="share-link-bar">` + "\n")
b.WriteString(`<span class="share-link-label">复制链接 · 微信外或朋友圈用浏览器打开可阅读全文</span>` + "\n")
b.WriteString(`<p class="share-url-text" id="page-url-text">` + escURL + `</p>` + "\n")
b.WriteString(`<button type="button" class="btn-copy-url" onclick="copyPageUrl()">复制本页链接</button>` + "\n")
b.WriteString(`</div>` + "\n")
b.WriteString(mpBtn)
b.WriteString("\n")
b.WriteString(`<button class="btn-fallback" id="fallback-btn" style="display:none" onclick="copyAndOpen()">复制链接</button>` + "\n")
b.WriteString(`</div></div>` + "\n")
b.WriteString(`<script type="application/json" id="wx-jssdk-data">`)
b.WriteString(d.JSSDKJSON)
b.WriteString(`</script>` + "\n")
b.WriteString(`<div class="copy-toast" id="toast">已复制</div>` + "\n")
b.WriteString(`<script src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"></script>` + "\n<script>\n")
b.WriteString(`document.addEventListener('WeixinOpenTagsError', function (e) { console.error('[WeixinOpenTagsError]', e && e.detail); });
(function(){
try {
var el = document.getElementById('wx-jssdk-data');
if (el && el.textContent && typeof wx !== 'undefined') {
var j = JSON.parse(el.textContent);
if (j && j.ok) {
wx.config({
debug: false,
appId: j.appId,
timestamp: j.timestamp,
nonceStr: j.nonceStr,
signature: j.signature,
jsApiList: [],
openTagList: ['wx-open-launch-weapp']
});
wx.ready(function () {});
wx.error(function (err) { console.warn('[wx.config]', err); });
}
}
} catch (e) { console.warn('[wx-jssdk]', e); }
})();
`)
b.WriteString("(function(){\n")
b.WriteString(" var ua = navigator.userAgent.toLowerCase();\n")
b.WriteString(" var isWx = ua.indexOf('micromessenger') !== -1;\n")
b.WriteString(" var launchBtn = document.getElementById('launch-btn');\n")
b.WriteString(" var fallbackBtn = document.getElementById('fallback-btn');\n")
b.WriteString(" if (!isWx) {\n")
b.WriteString(" if (launchBtn) launchBtn.style.display = 'none';\n")
b.WriteString(" if (fallbackBtn) fallbackBtn.style.display = 'block';\n")
b.WriteString(" } else if (launchBtn) {\n")
b.WriteString(" launchBtn.addEventListener('error', function(e) {\n")
b.WriteString(" launchBtn.style.display = 'none';\n")
b.WriteString(" if (fallbackBtn) fallbackBtn.style.display = 'block';\n")
b.WriteString(" });\n")
b.WriteString(" }\n")
b.WriteString("})();\n")
b.WriteString("function copyPageUrl() {\n var link = ")
b.WriteString(d.ShareLinkJSON)
b.WriteString(`;
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(link).then(function(){ 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 copyAndOpen() { copyPageUrl(); }
function showToast(msg) {
var t = document.getElementById('toast');
if (msg) t.textContent = msg;
t.style.display = 'block';
setTimeout(function(){ t.style.display = 'none'; t.textContent = '已复制'; }, 2000);
}
</script>
</body>
</html>`)
return b.String()
}