319 lines
13 KiB
Go
319 lines
13 KiB
Go
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()
|
||
}
|