From ba39376a0ed85798bdeb0df3062d0de1de4120ce Mon Sep 17 00:00:00 2001 From: freefire Date: Wed, 9 Sep 2026 02:41:53 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20Go=20=E7=AB=AF=E6=94=B6=E7=BC=A9?= =?UTF-8?q?=EF=BC=8C=E7=A7=BB=E9=99=A4=20SPA=20=E6=9C=8D=E5=8A=A1=E4=B8=8E?= =?UTF-8?q?=20embed=5Fstatic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除 embed_static(内嵌 SPA 静态资源)与 handler/ssr_home.go、ssr_icons.go - router 移除前端页面服务,NoRoute 统一返回 404 JSON - seo.go 仅保留 robots.txt / sitemap.xml / favicon.ico - 数据聚合逻辑迁移到 handler/home_boot.go(/api/home-boot) - 启动日志改为提示前端由 Node 渲染服务器提供 --- cmd/jiang13/program.go | 6 +- embed_static/embed.go | 118 ---- embed_static/spa_meta.go | 202 ------- handler/seo.go | 443 +-------------- handler/ssr_home.go | 1100 -------------------------------------- handler/ssr_icons.go | 361 ------------- router/router.go | 75 +-- 7 files changed, 19 insertions(+), 2286 deletions(-) delete mode 100644 embed_static/embed.go delete mode 100644 embed_static/spa_meta.go delete mode 100644 handler/ssr_home.go delete mode 100644 handler/ssr_icons.go diff --git a/cmd/jiang13/program.go b/cmd/jiang13/program.go index ee72e51..cb50bf5 100644 --- a/cmd/jiang13/program.go +++ b/cmd/jiang13/program.go @@ -98,7 +98,11 @@ func (p *program) setup() error { } log.Printf("姜十三论坛已启动: http://localhost%s", addr) - log.Printf("后台管理地址: http://localhost%s/admin/dashboard", addr) + if cfg.DevMode { + log.Printf("前端开发服务器: http://localhost:5173 (后台管理: http://localhost:5173/admin/dashboard)") + } else { + log.Printf("前端由 Node 渲染服务器提供(默认 http://localhost:3000),本服务仅提供 API") + } log.Printf("工作目录: %s", cfg.WorkPath) log.Printf("配置文件: %s", cfg.ConfigFile) log.Printf("数据目录: %s", cfg.DataDir) diff --git a/embed_static/embed.go b/embed_static/embed.go deleted file mode 100644 index 85d50ae..0000000 --- a/embed_static/embed.go +++ /dev/null @@ -1,118 +0,0 @@ -package embed_static - -import ( - "embed" - "io/fs" - "net/http" - "regexp" - "strings" - - "github.com/gin-gonic/gin" -) - -//go:embed static/* -var staticFS embed.FS - -var ( - spaTitleRe = regexp.MustCompile(`(?s).*?`) - spaBrandTitleFn func() string - spaBrandJSONFn func() []byte // 站点品牌 JSON,注入 window.__J13_BRANDING__ - spaBrandFaviconFn func() string // 站点 Favicon URL,注入 -) - -// SetSPADocumentTitle 注册站点标题提供者,ServeSPA 会注入到入口 HTML,避免刷新闪烁 -func SetSPADocumentTitle(fn func() string) { - spaBrandTitleFn = fn -} - -// SetSPABrandingJSON 注册品牌 JSON 提供者(须为合法 JSON 对象),供前端首屏同步读入 -func SetSPABrandingJSON(fn func() []byte) { - spaBrandJSONFn = fn -} - -// SetSPAFaviconURL 注册 Favicon URL 提供者,注入到入口 HTML 的 -func SetSPAFaviconURL(fn func() string) { - spaBrandFaviconFn = fn -} - -// SetupEmbed 配置内嵌资源:React SPA 静态资源 -func SetupEmbed(r *gin.Engine) error { - if sub, err := fs.Sub(staticFS, "static/spa/assets"); err == nil { - fileServer := http.StripPrefix("/assets", http.FileServer(http.FS(sub))) - r.GET("/assets/*filepath", func(c *gin.Context) { - // 仅 200 写 immutable,避免中间层把旧 chunk 的 404 长期缓存 - w := &cacheOnOKWriter{ResponseWriter: c.Writer, cacheControl: "public, max-age=31536000, immutable"} - c.Writer = w - fileServer.ServeHTTP(c.Writer, c.Request) - }) - } - - if sub, err := fs.Sub(staticFS, "static/spa/stickers"); err == nil { - fileServer := http.StripPrefix("/stickers", http.FileServer(http.FS(sub))) - r.GET("/stickers/*filepath", func(c *gin.Context) { - // stickers 不含哈希指纹,设置适中的缓存 - c.Header("Cache-Control", "public, max-age=86400") - fileServer.ServeHTTP(c.Writer, c.Request) - }) - } - return nil -} - -// cacheOnOKWriter 仅在最终状态码为 200 时写入长期 Cache-Control -type cacheOnOKWriter struct { - gin.ResponseWriter - cacheControl string - wroteHeader bool -} - -func (w *cacheOnOKWriter) WriteHeader(code int) { - if !w.wroteHeader { - w.wroteHeader = true - if code == http.StatusOK { - w.Header().Set("Cache-Control", w.cacheControl) - } - } - w.ResponseWriter.WriteHeader(code) -} - -func (w *cacheOnOKWriter) Write(b []byte) (int, error) { - if !w.wroteHeader { - w.WriteHeader(http.StatusOK) - } - return w.ResponseWriter.Write(b) -} - -// ServeSPA 返回 React SPA 入口(仅注入站点默认标题) -func ServeSPA(c *gin.Context) { - ServeSPAWithMeta(c, nil) -} - -// ServeSPANoIndex 返回带 noindex 的 SPA(登录/后台等私密页) -func ServeSPANoIndex(c *gin.Context) { - title := "" - if spaBrandTitleFn != nil { - title = strings.TrimSpace(spaBrandTitleFn()) - } - ServeSPAWithMeta(c, &SPAPageMeta{ - Title: title, - Robots: "noindex,nofollow", - }) -} - -// IsSPARoute 判断是否应由 SPA 处理 -func IsSPARoute(path string) bool { - if path == "/health" || path == "/robots.txt" || path == "/sitemap.xml" { - return false - } - if strings.HasPrefix(path, "/api") || - strings.HasPrefix(path, "/admin") || - strings.HasPrefix(path, "/uploads") || - strings.HasPrefix(path, "/media") || - strings.HasPrefix(path, "/assets") || - strings.HasPrefix(path, "/stickers") || - strings.HasPrefix(path, "/oauth") || - strings.HasPrefix(path, "/.well-known") { - return false - } - return true -} diff --git a/embed_static/spa_meta.go b/embed_static/spa_meta.go deleted file mode 100644 index a843107..0000000 --- a/embed_static/spa_meta.go +++ /dev/null @@ -1,202 +0,0 @@ -package embed_static - -import ( - "bytes" - "encoding/json" - "html" - "net/http" - "regexp" - "strings" - - "github.com/gin-gonic/gin" -) - -var spaRootEmptyRe = regexp.MustCompile(`(?s)
\s*
`) - -// SPAPageMeta 注入到 SPA 入口 HTML 的 SEO / 社交预览元数据。 -// 默认不写 #root;首页/板块文档 SSR 可填 RootHTML / BootJSON。 -type SPAPageMeta struct { - Title string // 完整 - Description string - Keywords string // meta keywords - Canonical string - OGType string // 默认 website - OGImage string - SiteName string // og:site_name - Locale string // og:locale,默认 zh_CN - Robots string // 如 noindex,nofollow - JSONLD string // 已序列化的 JSON-LD 对象(不含 script 标签) - Status int // HTTP 状态码,0 视为 200 - RootHTML string // 可选:写入 #root 的首屏 HTML(首页文档 SSR) - BootJSON []byte // 可选:window.__J13_HOME_BOOT__ 合法 JSON -} - -// ServeSPAWithMeta 返回带页面级 meta / JSON-LD 的干净 SPA 入口 -func ServeSPAWithMeta(c *gin.Context, meta *SPAPageMeta) { - status := http.StatusOK - if meta != nil && meta.Status != 0 { - status = meta.Status - } - data, err := staticFS.ReadFile("static/spa/index.html") - if err != nil { - c.String(http.StatusNotFound, "前端未构建,请运行: cd frontend && npm run build") - return - } - data = applySPAPageMeta(data, meta) - // 入口 HTML 禁止缓存,否则发版后仍引用旧 chunk 哈希(部分反代对 no-cache 仍会存) - c.Header("Cache-Control", "no-cache, no-store, must-revalidate") - c.Header("Pragma", "no-cache") - c.Data(status, "text/html; charset=utf-8", data) -} - -func applySPAPageMeta(data []byte, meta *SPAPageMeta) []byte { - if meta == nil { - meta = &SPAPageMeta{} - } - - title := strings.TrimSpace(meta.Title) - if title == "" && spaBrandTitleFn != nil { - title = strings.TrimSpace(spaBrandTitleFn()) - } - if title != "" { - escaped := html.EscapeString(title) - data = spaTitleRe.ReplaceAll(data, []byte("<title>"+escaped+"")) - } - - // —— 静态 SEO HTML(meta / OG / JSON-LD / favicon),紧跟 ,不经 JS —— - var seo strings.Builder - writeMeta(&seo, "description", meta.Description) - writeMeta(&seo, "keywords", meta.Keywords) - if canonical := strings.TrimSpace(meta.Canonical); canonical != "" { - seo.WriteString(``) - } - if favicon := spaFaviconHref(); favicon != "" { - seo.WriteString(``) - seo.WriteString(``) - } - robots := strings.TrimSpace(meta.Robots) - if robots != "" { - writeMeta(&seo, "robots", robots) - } - - ogType := strings.TrimSpace(meta.OGType) - if ogType == "" { - ogType = "website" - } - locale := strings.TrimSpace(meta.Locale) - if locale == "" { - locale = "zh_CN" - } - writeProp(&seo, "og:type", ogType) - writeProp(&seo, "og:site_name", meta.SiteName) - writeProp(&seo, "og:locale", locale) - writeProp(&seo, "og:title", firstNonEmpty(meta.Title, title)) - writeProp(&seo, "og:description", meta.Description) - writeProp(&seo, "og:url", meta.Canonical) - writeProp(&seo, "og:image", meta.OGImage) - writeMetaName(&seo, "twitter:card", twitterCard(meta.OGImage)) - writeMetaName(&seo, "twitter:title", firstNonEmpty(meta.Title, title)) - writeMetaName(&seo, "twitter:description", meta.Description) - writeMetaName(&seo, "twitter:image", meta.OGImage) - - if jsonld := strings.TrimSpace(meta.JSONLD); jsonld != "" { - // 常规 HTML 节点;仅转义 < 防止提前闭合,不是用 JS 写入 - seo.WriteString(``) - } - - if seo.Len() > 0 { - data = bytes.Replace(data, []byte(""), []byte("\n"+seo.String()), 1) - } - - // —— 可执行 boot 脚本仍放在 前 —— - var boot strings.Builder - if s := spaBrandingBootScript(); s != "" { - boot.WriteString(s) - } - if s := spaHomeBootScript(meta.BootJSON); s != "" { - boot.WriteString(s) - } - if boot.Len() > 0 { - data = bytes.Replace(data, []byte(""), []byte(boot.String()+""), 1) - } - - if root := strings.TrimSpace(meta.RootHTML); root != "" { - data = injectSPARootHTML(data, root) - } - - return data -} - -// spaHomeBootScript 生成 window.__J13_HOME_BOOT__=...; 内联脚本(前端灌缓存,非 SEO) -func spaHomeBootScript(raw []byte) string { - raw = bytes.TrimSpace(raw) - if len(raw) == 0 || !json.Valid(raw) { - return "" - } - safe := bytes.ReplaceAll(raw, []byte("<"), []byte(`\u003c`)) - return "" -} - -// injectSPARootHTML 将首屏 HTML 写入 #root(允许空白) -func injectSPARootHTML(data []byte, rootHTML string) []byte { - return spaRootEmptyRe.ReplaceAll(data, []byte(`
`+rootHTML+`
`)) -} - -// spaBrandingBootScript 生成 window.__J13_BRANDING__=...; 内联脚本 -func spaBrandingBootScript() string { - if spaBrandJSONFn == nil { - return "" - } - raw := bytes.TrimSpace(spaBrandJSONFn()) - if len(raw) == 0 || !json.Valid(raw) { - return "" - } - safe := bytes.ReplaceAll(raw, []byte("<"), []byte(`\u003c`)) - return "" -} - -func writeMeta(b *strings.Builder, name, content string) { - content = strings.TrimSpace(content) - if content == "" { - return - } - b.WriteString(``) -} - -func writeMetaName(b *strings.Builder, name, content string) { - writeMeta(b, name, content) -} - -func writeProp(b *strings.Builder, prop, content string) { - content = strings.TrimSpace(content) - if content == "" { - return - } - b.WriteString(``) -} - -func twitterCard(ogImage string) string { - if strings.TrimSpace(ogImage) != "" { - return "summary_large_image" - } - return "summary" -} - -func firstNonEmpty(vals ...string) string { - for _, v := range vals { - if s := strings.TrimSpace(v); s != "" { - return s - } - } - return "" -} - -// spaFaviconHref 当前站点配置的 Favicon(相对或绝对 URL) -func spaFaviconHref() string { - if spaBrandFaviconFn == nil { - return "" - } - return strings.TrimSpace(spaBrandFaviconFn()) -} diff --git a/handler/seo.go b/handler/seo.go index 5f587a4..797ce01 100644 --- a/handler/seo.go +++ b/handler/seo.go @@ -1,28 +1,15 @@ package handler import ( - "encoding/json" "net/http" - "regexp" - "strconv" "strings" "time" - "git.iioio.com/freefire/jiang13-forum/embed_static" - "git.iioio.com/freefire/jiang13-forum/model" "git.iioio.com/freefire/jiang13-forum/service" "github.com/gin-gonic/gin" ) -var ( - seoPostEditRe = regexp.MustCompile(`^/post/(\d+)/edit/?$`) -) - -const ( - seoDescMax = 160 - seoPrerenderMax = 4000 - seoSitemapLimit = 5000 -) +const seoSitemapLimit = 5000 // RobotsTxt 搜索引擎抓取规则 func (h *Handlers) RobotsTxt(c *gin.Context) { @@ -157,342 +144,6 @@ func (h *Handlers) SitemapXML(c *gin.Context) { c.Data(http.StatusOK, "application/xml; charset=utf-8", []byte(b.String())) } -// ServePublicSPA 公开页入口:人机统一响应,不再按 User-Agent 分叉。 -// - 首页 / 板块:完整首屏 SSR(serveFeedDocument) -// - 其余公开页:干净 SPA + meta -// - 伪静态:按后台配置的后缀做规范 URL,非规范路径 301 -func (h *Handlers) ServePublicSPA(c *gin.Context) { - path := c.Request.URL.Path - - brand := h.Settings.SiteBranding() - base := h.publicBaseURL(c) - siteName := strings.TrimSpace(brand.Name) - if siteName == "" { - siteName = "姜十三论坛" - } - defaultImage := service.AbsoluteURL(base, brand.DefaultShareImage()) - siteKeywords := brand.MetaKeywords() - permalink := h.Settings.Permalink() - - // 旧版 /?board=id → 规范板块路径 - if path == "/" || path == "" { - if boardID, err := strconv.ParseUint(c.Query("board"), 10, 64); err == nil && boardID > 0 { - target := service.QueryBoardHome(uint(boardID), permalink) - if q := c.Request.URL.RawQuery; q != "" { - // 保留 sort/keyword 等 query,去掉 board - vals := c.Request.URL.Query() - vals.Del("board") - if rest := vals.Encode(); rest != "" { - target += "?" + rest - } - } - c.Redirect(http.StatusMovedPermanently, target) - return - } - } - - // 板块首页(含可选伪静态后缀) - if bm := permalink.MatchBoardPath(path); bm.OK { - if bm.NeedsCanonicalRedirect(path) { - c.Redirect(http.StatusMovedPermanently, bm.Canonical+preserveQueryExceptBoard(c)) - return - } - board, err := h.Board.GetByID(bm.ID) - if err != nil { - h.serveNotFound(c, base, siteName, siteKeywords, path) - return - } - desc := strings.TrimSpace(board.Description) - if desc == "" { - desc = brand.MetaDescription() - } - meta := attachSiteSEO(&embed_static.SPAPageMeta{ - Title: pageTitle(board.Name, siteName), - Description: service.TruncateRunes(desc, seoDescMax), - Keywords: service.JoinSEOKeywords(board.Name, siteKeywords), - Canonical: service.AbsoluteURL(base, bm.Canonical), - OGType: "website", - OGImage: defaultImage, - }, siteName, siteKeywords) - h.serveFeedDocument(c, meta, board.ID) - return - } - - // 帖子详情(含可选伪静态后缀) - if pm := permalink.MatchPostPath(path); pm.OK { - if pm.NeedsCanonicalRedirect(path) { - c.Redirect(http.StatusMovedPermanently, pm.Canonical) - return - } - post, err := h.Post.FindByID(pm.ID) - if err != nil || !service.CanViewPost(post, h.currentUserID(c), h.isAdmin(c)) { - h.serveNotFound(c, base, siteName, siteKeywords, path) - return - } - postKeywords := service.JoinSEOKeywords(post.Board.Name, siteKeywords) - embed_static.ServeSPAWithMeta(c, attachSiteSEO(h.postPageMeta(base, siteName, defaultImage, post), siteName, postKeywords)) - return - } - - // 用户主页 - if um := permalink.MatchUserPath(path); um.OK { - if um.NeedsCanonicalRedirect(path) { - c.Redirect(http.StatusMovedPermanently, um.Canonical) - return - } - user, err := h.User.GetByID(um.ID) - if err != nil || user.Banned { - h.serveNotFound(c, base, siteName, siteKeywords, path) - return - } - embed_static.ServeSPAWithMeta(c, attachSiteSEO(h.userPageMeta(base, siteName, defaultImage, user), siteName, siteKeywords)) - return - } - - // 自定义单页 - if pg := permalink.MatchPagePath(path); pg.OK { - if strings.TrimSuffix(path, "/") != strings.TrimSuffix(pg.Canonical, "/") { - c.Redirect(http.StatusMovedPermanently, pg.Canonical) - return - } - page, err := h.SitePage.GetBySlug(pg.Slug, h.isAdmin(c)) - if err != nil { - h.serveNotFound(c, base, siteName, siteKeywords, path) - return - } - desc := service.ExcerptFromHTML(page.Content, seoDescMax) - meta := attachSiteSEO(&embed_static.SPAPageMeta{ - Title: pageTitle(page.Title, siteName), - Description: desc, - Keywords: service.JoinSEOKeywords(page.Title, siteKeywords), - Canonical: service.AbsoluteURL(base, pg.Canonical), - OGType: "article", - OGImage: defaultImage, - }, siteName, siteKeywords) - embed_static.ServeSPAWithMeta(c, meta) - return - } - - // 未知路径 → 404 - if !isKnownPublicPath(path) { - h.serveNotFound(c, base, siteName, siteKeywords, path) - return - } - - // 其余已知路由:SPA + head meta;首页/Feed 返回完整首屏 HTML - meta := h.buildSPAPageMeta(c, path, brand, base, siteName, defaultImage) - if path == "/" || path == "" { - h.serveFeedDocument(c, meta, 0) - return - } - embed_static.ServeSPAWithMeta(c, meta) -} - -func (h *Handlers) serveNotFound(c *gin.Context, base, siteName, keywords, path string) { - embed_static.ServeSPAWithMeta(c, notFoundPageMeta(base, siteName, keywords, path)) -} - -func notFoundPageMeta(base, siteName, keywords, path string) *embed_static.SPAPageMeta { - return attachSiteSEO(&embed_static.SPAPageMeta{ - Title: pageTitle("页面不存在", siteName), - Description: "您访问的页面不存在或已删除", - Canonical: service.AbsoluteURL(base, path), - OGType: "website", - Robots: "noindex,follow", - Status: http.StatusNotFound, - }, siteName, keywords) -} - -// attachSiteSEO 填充站点级 keywords / og:site_name / og:locale -func attachSiteSEO(meta *embed_static.SPAPageMeta, siteName, keywords string) *embed_static.SPAPageMeta { - if meta == nil { - return nil - } - meta.SiteName = strings.TrimSpace(siteName) - if strings.TrimSpace(meta.Keywords) == "" { - meta.Keywords = strings.TrimSpace(keywords) - } - meta.Locale = "zh_CN" - return meta -} - -func isKnownPublicPath(path string) bool { - switch path { - case "/", "/login", "/register", "/compose", "/profile", "/favorites", "/projects", "/links", "/boards": - return true - } - if seoPostEditRe.MatchString(path) { - return true - } - permalink := service.PermalinkConfig{} - if permalink.MatchBoardPath(path).OK { - return true - } - if permalink.MatchPagePath(path).OK { - return true - } - return false -} - -func (h *Handlers) buildSPAPageMeta(c *gin.Context, path string, brand service.SiteBranding, base, siteName, defaultImage string) *embed_static.SPAPageMeta { - siteTitle := brand.DocumentTitle() - homeDesc := service.TruncateRunes(brand.MetaDescription(), seoDescMax) - siteKeywords := brand.MetaKeywords() - meta := attachSiteSEO(&embed_static.SPAPageMeta{ - Title: siteTitle, - Description: homeDesc, - Keywords: siteKeywords, - Canonical: service.AbsoluteURL(base, pathWithQuery(c)), - OGType: "website", - OGImage: defaultImage, - }, siteName, siteKeywords) - - if isNoIndexPath(path) { - meta.Robots = "noindex,nofollow" - meta.Title = pageTitle(pathLabel(path), siteName) - return meta - } - - if path == "/" || path == "" { - boardID, _ := strconv.ParseUint(c.Query("board"), 10, 64) - if boardID > 0 { - if board, err := h.Board.GetByID(uint(boardID)); err == nil { - desc := strings.TrimSpace(board.Description) - if desc == "" { - desc = brand.MetaDescription() - } - meta.Title = pageTitle(board.Name, siteName) - meta.Description = service.TruncateRunes(desc, seoDescMax) - meta.Canonical = service.AbsoluteURL(base, service.QueryBoardHome(board.ID, h.Settings.Permalink())) - meta.Keywords = service.JoinSEOKeywords(board.Name, siteKeywords) - return meta - } - // 无效板块 id:仍显示首页,但可标记 noindex - meta.Robots = "noindex,follow" - return meta - } - meta.JSONLD = mustJSON(map[string]any{ - "@context": "https://schema.org", - "@type": "WebSite", - "name": siteName, - "description": meta.Description, - "url": service.AbsoluteURL(base, "/"), - }) - } - - if path == "/projects" { - meta.Title = pageTitle("项目", siteName) - meta.Description = service.TruncateRunes(siteName+" 的公开项目列表", seoDescMax) - meta.Keywords = service.JoinSEOKeywords("项目", siteKeywords) - } - - if path == "/links" { - meta.Title = pageTitle("友情链接", siteName) - meta.Description = service.TruncateRunes(siteName+" 的友情链接与申请入口", seoDescMax) - meta.Keywords = service.JoinSEOKeywords("友情链接", siteKeywords) - } - - return meta -} - -func (h *Handlers) postPageMeta(base, siteName, defaultImage string, post *model.Post) *embed_static.SPAPageMeta { - permalink := h.Settings.Permalink() - content := service.RedactGatedPostHTML(post.Content) - plain := post.ContentPlain - if plain == "" { - plain = service.StripHTMLForSearch(content) - } - desc := service.TruncateRunes(plain, seoDescMax) - author := service.DisplayName(&post.User) - canonical := service.AbsoluteURL(base, permalink.PostPath(post.ID)) - ogImage := service.AbsoluteURL(base, service.FirstImageURL(content)) - if ogImage == "" { - ogImage = service.AbsoluteURL(base, post.User.Avatar) - } - if ogImage == "" { - ogImage = defaultImage - } - - jsonld := map[string]any{ - "@context": "https://schema.org", - "@type": "DiscussionForumPosting", - "headline": post.Title, - "description": desc, - "datePublished": post.CreatedAt.UTC().Format(time.RFC3339), - "dateModified": post.UpdatedAt.UTC().Format(time.RFC3339), - "url": canonical, - "mainEntityOfPage": canonical, - "author": map[string]any{ - "@type": "Person", - "name": author, - "url": service.AbsoluteURL(base, permalink.UserPath(post.UserID)), - }, - "interactionStatistic": map[string]any{ - "@type": "InteractionCounter", - "interactionType": "https://schema.org/ViewAction", - "userInteractionCount": post.ViewCount, - }, - } - if post.Board.Name != "" { - jsonld["articleSection"] = post.Board.Name - } - if ogImage != "" { - jsonld["image"] = []string{ogImage} - } - body := service.TruncateRunes(plain, seoPrerenderMax) - if body != "" { - jsonld["articleBody"] = body - } - - return &embed_static.SPAPageMeta{ - Title: pageTitle(post.Title, siteName), - Description: desc, - Canonical: canonical, - OGType: "article", - OGImage: ogImage, - JSONLD: mustJSON(jsonld), - } -} - -func (h *Handlers) userPageMeta(base, siteName, defaultImage string, user *model.User) *embed_static.SPAPageMeta { - permalink := h.Settings.Permalink() - name := service.DisplayName(user) - desc := strings.TrimSpace(user.Signature) - if desc == "" { - desc = name + " 的主页" - } - desc = service.TruncateRunes(desc, seoDescMax) - canonical := service.AbsoluteURL(base, permalink.UserPath(user.ID)) - ogImage := service.AbsoluteURL(base, user.Avatar) - if ogImage == "" { - ogImage = defaultImage - } - - jsonld := map[string]any{ - "@context": "https://schema.org", - "@type": "ProfilePage", - "url": canonical, - "mainEntity": map[string]any{ - "@type": "Person", - "name": name, - "description": desc, - "url": canonical, - }, - } - if ogImage != "" { - jsonld["mainEntity"].(map[string]any)["image"] = ogImage - } - - return &embed_static.SPAPageMeta{ - Title: pageTitle(name+" 的主页", siteName), - Description: desc, - Canonical: canonical, - OGType: "profile", - OGImage: ogImage, - JSONLD: mustJSON(jsonld), - } -} - func (h *Handlers) publicBaseURL(c *gin.Context) string { return h.Settings.SitePublicBaseURL(requestOrigin(c)) } @@ -516,98 +167,6 @@ func requestOrigin(c *gin.Context) string { return proto + "://" + host } -func pathWithQuery(c *gin.Context) string { - path := c.Request.URL.Path - if path == "" { - path = "/" - } - permalink := service.PermalinkConfig{} // 由调用方在 buildSPAPageMeta 中单独处理 board - if q := c.Request.URL.RawQuery; q != "" { - if path == "/" { - board := c.Query("board") - if board != "" { - _ = permalink - return service.LegacyQueryBoardHome(uint(parseUintOrZero(board))) - } - return "/" - } - return path + "?" + q - } - return path -} - -func preserveQueryExceptBoard(c *gin.Context) string { - vals := c.Request.URL.Query() - vals.Del("board") - if rest := vals.Encode(); rest != "" { - return "?" + rest - } - return "" -} - -func parseUintOrZero(s string) uint64 { - n, _ := strconv.ParseUint(s, 10, 64) - return n -} - -func isNoIndexPath(path string) bool { - switch { - case path == "/login", path == "/register", path == "/compose", - path == "/profile", path == "/favorites": - return true - case strings.HasPrefix(path, "/admin"): - return true - case strings.HasSuffix(path, "/edit"): - return true - default: - return false - } -} - -func pathLabel(path string) string { - switch { - case path == "/login": - return "登录" - case path == "/register": - return "注册" - case path == "/compose": - return "发帖" - case path == "/profile": - return "个人中心" - case path == "/favorites": - return "我的收藏" - case strings.HasSuffix(path, "/edit"): - return "编辑帖子" - case strings.HasPrefix(path, "/admin"): - return "管理后台" - default: - return "" - } -} - -func pageTitle(page, siteName string) string { - page = strings.TrimSpace(page) - siteName = strings.TrimSpace(siteName) - switch { - case page == "" && siteName == "": - return "姜十三论坛" - case page == "": - return siteName - case siteName == "": - return page - default: - return page + " - " + siteName - } -} - -func mustJSON(v any) string { - b, err := json.Marshal(v) - if err != nil { - return "" - } - return string(b) -} - func xmlEscape(s string) string { r := strings.NewReplacer( `&`, "&", diff --git a/handler/ssr_home.go b/handler/ssr_home.go deleted file mode 100644 index db1c157..0000000 --- a/handler/ssr_home.go +++ /dev/null @@ -1,1100 +0,0 @@ -package handler - -import ( - "encoding/json" - "fmt" - "html" - "net/url" - "regexp" - "strconv" - "strings" - "sync" - "time" - "unicode/utf8" - - "git.iioio.com/freefire/jiang13-forum/embed_static" - "git.iioio.com/freefire/jiang13-forum/model" - "git.iioio.com/freefire/jiang13-forum/service" - "github.com/gin-gonic/gin" -) - -var firstImgSrcRe = regexp.MustCompile(`(?i)]+src=["']([^"']+)["']`) - -// homeBootPayload 与前端 __J13_HOME_BOOT__ 对齐,供冷启动灌缓存 -type homeBootPayload struct { - BoardID uint `json:"board_id"` - Sort string `json:"sort"` - Keyword string `json:"keyword"` - Tag string `json:"tag"` - Author string `json:"author"` - TitleOnly bool `json:"title_only"` - Posts []service.PostListItem `json:"posts"` - PostTotal int64 `json:"post_total"` - Page int `json:"page"` - Boards []service.BoardWithStats `json:"boards"` - Stats homeBootStats `json:"stats"` - RecentComments []service.RecentCommentItem `json:"recent_comments"` - RecentUsers []service.RecentUserItem `json:"recent_users"` - Tags []service.TagCount `json:"tags"` - // omitempty:侧栏关闭或未拉取时不写字段,避免 SPA 把 [] 当成有效会话快照 - Showcase []service.CommunityShowcaseItem `json:"showcase,omitempty"` - Pages []service.SitePageSummary `json:"pages"` - Limits service.ForumLimitsPublic `json:"limits"` - Branding service.SiteBranding `json:"branding"` - User *model.UserSelf `json:"user"` - UnreadMessages int `json:"unread_messages"` - CheckIn *service.CheckInStatus `json:"check_in"` -} - -type homeBootStats struct { - Users int64 `json:"users"` - Posts int64 `json:"posts"` - Boards int64 `json:"boards"` - Comments int64 `json:"comments"` -} - -type homeSSRData struct { - boot homeBootPayload - meta homeSSRMeta -} - -type homeSSRMeta struct { - boardName string - permalink service.PermalinkConfig - listStyle string - defSort string -} - -// serveFeedDocument 首页/板块:文档 SSR + boot;失败则回退空 root SPA -func (h *Handlers) serveFeedDocument(c *gin.Context, meta *embed_static.SPAPageMeta, boardID uint) { - data, err := h.gatherHomeSSR(c, boardID) - if err != nil { - embed_static.ServeSPAWithMeta(c, meta) - return - } - bootJSON, err := json.Marshal(data.boot) - if err != nil { - embed_static.ServeSPAWithMeta(c, meta) - return - } - meta.RootHTML = renderHomeSSRHTML(data) - meta.BootJSON = bootJSON - embed_static.ServeSPAWithMeta(c, meta) -} - -func (h *Handlers) gatherHomeSSR(c *gin.Context, boardID uint) (*homeSSRData, error) { - limits := h.Settings.PublicLimits() - brand := h.Settings.SiteBranding() - brand.SiteURL = h.publicBaseURL(c) - permalink := h.Settings.Permalink() - defSort := h.Settings.DefaultFeedSort() - - sort := strings.TrimSpace(c.Query("sort")) - if sort == "" { - sort = defSort - } - keyword := strings.TrimSpace(c.Query("keyword")) - tag := strings.TrimSpace(c.Query("tag")) - author := strings.TrimSpace(c.Query("author")) - titleOnly := c.Query("title_only") == "1" || strings.EqualFold(c.Query("title_only"), "true") - if tag != "" { - keyword = "" - author = "" - titleOnly = false - } - - pageSize := h.Settings.PageSizeDefault() - listStyle := limits.FeedListStyle - if listStyle == "" { - listStyle = "title" - } - - out := &homeSSRData{ - boot: homeBootPayload{ - BoardID: boardID, - Sort: sort, - Keyword: keyword, - Tag: tag, - Author: author, - TitleOnly: titleOnly, - Page: 1, - Limits: limits, - Branding: brand, - }, - meta: homeSSRMeta{ - permalink: permalink, - listStyle: listStyle, - defSort: defSort, - }, - } - - var ( - postsErr, boardsErr error - wg sync.WaitGroup - ) - - wg.Add(1) - go func() { - defer wg.Done() - q := service.PostListQuery{ - BoardID: boardID, - Page: 1, - Size: pageSize, - Sort: sort, - Keyword: keyword, - Tag: tag, - Author: author, - TitleOnly: titleOnly, - ViewerID: h.currentUserID(c), - ViewerIsAdmin: h.isAdmin(c), - } - items, total, err := h.Post.ListItems(q) - if err != nil { - postsErr = err - return - } - if items == nil { - items = []service.PostListItem{} - } - if h.Badge != nil { - users := make([]*model.User, 0, len(items)) - for i := range items { - if items[i].User.ID > 0 { - users = append(users, &items[i].User) - } - } - h.Badge.AttachBadgeSummaries(users, 2) - } - out.boot.Posts = items - out.boot.PostTotal = total - }() - - wg.Add(1) - go func() { - defer wg.Done() - boards, err := h.Board.ListWithStats() - if err != nil { - boardsErr = err - return - } - if boards == nil { - boards = []service.BoardWithStats{} - } - out.boot.Boards = boards - for _, board := range boards { - if board.ID == boardID { - out.meta.boardName = board.Name - break - } - } - }() - - wg.Add(1) - go func() { - defer wg.Done() - var users, posts, boardsN, comments int64 - model.DB.Model(&model.User{}).Count(&users) - model.DB.Model(&model.Post{}).Where("status = ?", model.ContentStatusPublished).Count(&posts) - model.DB.Model(&model.Board{}).Count(&boardsN) - model.DB.Model(&model.Comment{}).Where("status = ?", model.ContentStatusPublished).Count(&comments) - out.boot.Stats = homeBootStats{Users: users, Posts: posts, Boards: boardsN, Comments: comments} - }() - - wg.Add(1) - go func() { - defer wg.Done() - pages, err := h.SitePage.ListPublished() - if err != nil || pages == nil { - pages = []service.SitePageSummary{} - } - out.boot.Pages = pages - }() - - widgetEnabled := map[string]bool{} - for _, w := range limits.AsideWidgets { - widgetEnabled[w.ID] = w.Enabled - } - - if widgetEnabled[service.AsideWidgetRecentComments] { - wg.Add(1) - go func() { - defer wg.Done() - items, err := h.Comment.ListRecentPublic(8) - if err != nil || items == nil { - items = []service.RecentCommentItem{} - } - out.boot.RecentComments = items - }() - } else { - out.boot.RecentComments = []service.RecentCommentItem{} - } - - if widgetEnabled[service.AsideWidgetRecentUsers] { - wg.Add(1) - go func() { - defer wg.Done() - items, err := h.User.ListRecentRegistered(8) - if err != nil || items == nil { - items = []service.RecentUserItem{} - } - out.boot.RecentUsers = items - }() - } else { - out.boot.RecentUsers = []service.RecentUserItem{} - } - - if widgetEnabled[service.AsideWidgetTagCloud] { - wg.Add(1) - go func() { - defer wg.Done() - tags, err := h.Post.PopularTags(40) - if err != nil || tags == nil { - tags = []service.TagCount{} - } - out.boot.Tags = tags - }() - } else { - out.boot.Tags = []service.TagCount{} - } - - if widgetEnabled[service.AsideWidgetShowcase] && h.Community != nil { - wg.Add(1) - go func() { - defer wg.Done() - items, err := h.Community.ListShowcase(c.Request.Host) - if err != nil { - // 失败不写 showcase,让前端自行请求,避免假空粘死 - return - } - if items == nil { - items = []service.CommunityShowcaseItem{} - } - out.boot.Showcase = items - }() - } - - uid := h.currentUserID(c) - if uid > 0 { - wg.Add(1) - go func() { - defer wg.Done() - user, err := h.User.GetByID(uid) - if err != nil || user == nil { - return - } - view := user.ToSelf() - if h.Badge != nil { - _ = h.Badge.EvaluateAuto(user.ID) - if badges, bErr := h.Badge.ListUserBadges(user.ID); bErr == nil { - view.Badges = service.BadgeViews(badges, 0) - } - } - out.boot.User = &view - }() - if h.Message != nil { - wg.Add(1) - go func() { - defer wg.Done() - total, _, _, err := h.Message.UnreadCounts(uid) - if err == nil { - out.boot.UnreadMessages = int(total) - } - }() - } - if h.Points != nil { - wg.Add(1) - go func() { - defer wg.Done() - st, err := h.Points.GetCheckInStatus(uid) - if err == nil { - out.boot.CheckIn = &st - } - }() - } - } - - wg.Wait() - if postsErr != nil { - return nil, postsErr - } - if boardsErr != nil { - return nil, boardsErr - } - return out, nil -} - -func renderHomeSSRHTML(data *homeSSRData) string { - b := &strings.Builder{} - b.Grow(64 * 1024) - boot := data.boot - meta := data.meta - brandName := strings.TrimSpace(boot.Branding.Name) - if brandName == "" { - brandName = "姜十三论坛" - } - logoMark := strings.TrimSpace(boot.Branding.LogoMark) - if logoMark == "" { - if r, _ := utf8.DecodeRuneInString(brandName); r != utf8.RuneError { - logoMark = string(r) - } else { - logoMark = "姜" - } - } - loggedIn := boot.User != nil && boot.User.ID > 0 - - b.WriteString(`
`) - - b.WriteString(`
`) - // 手机端汉堡:桌面 CSS 隐藏;hydrate 后由 React 接管点击 - b.WriteString(``) - b.WriteString(``) - if logo := strings.TrimSpace(boot.Branding.Logo); logo != "" { - b.WriteString(``) - } else { - b.WriteString(`` + html.EscapeString(logoMark) + ``) - } - b.WriteString(`` + html.EscapeString(brandName) + ``) - - b.WriteString(``) - - b.WriteString(`
`) - composeHref := "/login?from=%2Fcompose" - if loggedIn { - composeHref = "/compose" - } - b.WriteString(``) - b.WriteString(ssrIconPlus()) - b.WriteString(`发帖`) - - b.WriteString(`
`) - // 主题:双图标 + CSS,配合 documentElement.dark;手机顶栏 CSS 隐藏 - b.WriteString(``) - - if loggedIn { - u := boot.User - msgTitle := "站内消息" - if boot.UnreadMessages > 0 { - msgTitle = strconv.Itoa(boot.UnreadMessages) + " 条未读消息" - } - b.WriteString(``) - b.WriteString(ssrIconMail()) - if boot.UnreadMessages > 0 { - badge := strconv.Itoa(boot.UnreadMessages) - if boot.UnreadMessages > 99 { - badge = "99+" - } - b.WriteString(`` + badge + ``) - } - b.WriteString(``) - nick := strings.TrimSpace(u.Nickname) - if nick == "" { - nick = u.Username - } - initial := "?" - if r, _ := utf8.DecodeRuneInString(nick); r != utf8.RuneError { - initial = string(r) - } - b.WriteString(``) - if u.Avatar != "" { - b.WriteString(``) - } else { - b.WriteString(`` + html.EscapeString(initial) + ``) - } - b.WriteString(``) - } else { - b.WriteString(``) - } - b.WriteString(`
`) - - b.WriteString(`
`) - writeSSRSidebar(b, boot, meta) - b.WriteString(`
`) - writeSSRFeed(b, boot, meta) - b.WriteString(`
`) - writeSSRAside(b, boot) - b.WriteString(`
`) - writeSSRFooter(b, boot, meta) - b.WriteString(`
`) - return b.String() -} - -func ssrFeedURL(boardID uint, sort, defSort string, pl service.PermalinkConfig, extra url.Values) string { - var path string - if boardID > 0 { - path = pl.BoardPath(boardID) - } else { - path = "/" - } - q := url.Values{} - for k, vs := range extra { - for _, v := range vs { - if strings.TrimSpace(v) != "" { - q.Add(k, v) - } - } - } - if sort != "" && sort != defSort { - q.Set("sort", sort) - } - enc := q.Encode() - if enc == "" { - return path - } - return path + "?" + enc -} - -func writeSSRSidebar(b *strings.Builder, boot homeBootPayload, meta homeSSRMeta) { - loggedIn := boot.User != nil && boot.User.ID > 0 - isAdmin := loggedIn && boot.User.Role == model.RoleAdmin - - b.WriteString(``) -} - -func writeSSRFeed(b *strings.Builder, boot homeBootPayload, meta homeSSRMeta) { - b.WriteString(`
`) - b.WriteString(`
`) - - // 板块首页不输出 h1(与 React FeedHeader 一致);仅搜索/标签/作者保留标题 - if boot.Keyword != "" || boot.Tag != "" || boot.Author != "" { - title := "帖子列表" - switch { - case boot.Tag != "": - title = "#" + boot.Tag - case boot.Keyword != "": - title = "搜索:" + boot.Keyword - case boot.Author != "": - title = "作者:" + boot.Author - } - b.WriteString(`

` + html.EscapeString(title) + `

`) - } - - showSort := boot.Keyword == "" && boot.Tag == "" && boot.Author == "" - if showSort { - tabs := boot.Limits.FeedSortTabs - if len(tabs) == 0 { - tabs = []service.FeedSortTab{ - {ID: service.FeedSortReply, Label: "最新", Enabled: true}, - {ID: service.FeedSortLatest, Label: "新帖子", Enabled: true}, - {ID: service.FeedSortHot, Label: "推荐帖", Enabled: true}, - } - } - b.WriteString(`
`) - for _, tab := range tabs { - if !tab.Enabled { - continue - } - active := "" - if tab.ID == boot.Sort { - active = " active" - } - href := ssrFeedURL(boot.BoardID, tab.ID, meta.defSort, meta.permalink, ssrFilterQuery(boot)) - b.WriteString(``) - switch tab.ID { - case service.FeedSortReply: - b.WriteString(ssrIconMessageCircle()) - case service.FeedSortHot: - b.WriteString(ssrIconBadgeCheck()) - default: - b.WriteString(ssrIconClock()) - } - b.WriteString(`` + html.EscapeString(tab.Label) + ``) - } - b.WriteString(`
`) - b.WriteString(`共 ` + strconv.FormatInt(boot.PostTotal, 10) + ` 条`) - b.WriteString(`
`) - } - - b.WriteString(`
`) - - b.WriteString(`
`) - if len(boot.Posts) == 0 { - b.WriteString(`
暂无帖子
`) - } else { - titleOnly := meta.listStyle == "title" - needExcerpt := meta.listStyle == "excerpt" || meta.listStyle == "thumbnail" - needThumb := meta.listStyle == "thumbnail" - for i := range boot.Posts { - writeSSRPostRow(b, &boot.Posts[i], boot.BoardID, meta.permalink, boot.Sort, titleOnly, needExcerpt, needThumb) - } - } - b.WriteString(`
`) - // 手机端流入页脚(桌面 CSS 隐藏);壳层贴底页脚见 writeSSRFooter - writeSSRFooter(b, boot, meta) - b.WriteString(`
`) -} - -func ssrFilterQuery(boot homeBootPayload) url.Values { - q := url.Values{} - if boot.Tag != "" { - q.Set("tag", boot.Tag) - return q - } - if boot.Keyword != "" { - q.Set("keyword", boot.Keyword) - if boot.TitleOnly { - q.Set("title_only", "1") - } - } - if boot.Author != "" { - q.Set("author", boot.Author) - } - return q -} - -func writeSSRPostRow( - b *strings.Builder, - post *service.PostListItem, - currentBoard uint, - pl service.PermalinkConfig, - sort string, - titleOnly, needExcerpt, needThumb bool, -) { - href := pl.PostPath(post.ID) - author := service.DisplayName(&post.User) - if author == "" { - author = "用户" - } - initial := "?" - if r, _ := utf8.DecodeRuneInString(author); r != utf8.RuneError { - initial = string(r) - } - - var excerpt, thumb string - if needExcerpt || needThumb { - plain := service.StripHTMLForSearch(service.RedactGatedPostHTML(post.Content)) - excerpt = service.TruncateRunes(plain, 120) - } - if needThumb { - thumb = firstImageSrc(post.Content) - } - - rowClass := "post-row post-row--v2" - if titleOnly { - rowClass += " post-row--title-only" - } - if thumb != "" { - rowClass += " post-row--has-thumb" - } - - b.WriteString(``) - if post.User.Avatar != "" { - b.WriteString(``) - } else { - b.WriteString(`` + html.EscapeString(initial) + ``) - } - - if thumb != "" { - b.WriteString(`
`) - } else { - b.WriteString(`
`) - } - - b.WriteString(`
`) - if post.Pinned > 0 { - b.WriteString(`全局置顶`) - } - if post.BoardPinned > 0 { - b.WriteString(`板块置顶`) - } - if post.Featured { - b.WriteString(`推荐`) - } - if post.Status == model.ContentStatusPending { - b.WriteString(`审核中`) - } - if post.Status == model.ContentStatusRejected { - b.WriteString(`未通过`) - } - b.WriteString(`` + html.EscapeString(post.Title) + ``) - hasTypeBadge := post.PostType == model.PostTypeQuestion || - post.PostType == model.PostTypePoll || - post.PostType == model.PostTypeLottery || - (post.PostType == model.PostTypeBounty && ((post.BountyStatus == model.BountyStatusOpen && post.BountyPoints > 0) || - post.BountyStatus == model.BountyStatusAwarded)) - if hasTypeBadge { - b.WriteString(``) - switch post.PostType { - case model.PostTypeQuestion: - if post.QuestionResolved { - b.WriteString(`已解决`) - } else { - b.WriteString(`未解决`) - } - case model.PostTypePoll: - b.WriteString(`投票`) - case model.PostTypeBounty: - if post.BountyStatus == model.BountyStatusOpen && post.BountyPoints > 0 { - b.WriteString(`悬赏 ` + strconv.Itoa(post.BountyPoints) + ``) - } else if post.BountyStatus == model.BountyStatusAwarded { - b.WriteString(`已采纳`) - } - case model.PostTypeLottery: - if post.LotteryStatus == model.PostLotteryStatusDrawn { - b.WriteString(`已开奖`) - } else { - b.WriteString(`抽奖`) - } - } - b.WriteString(``) - } - b.WriteString(`
`) - if excerpt != "" { - b.WriteString(`

` + html.EscapeString(excerpt) + `

`) - } - - if thumb != "" { - b.WriteString(`
`) - } else { - b.WriteString(`
`) - } - b.WriteString(`
`) -} - -func writeSSRAside(b *strings.Builder, boot homeBootPayload) { - b.WriteString(``) -} - -func writeSSRCheckIn(b *strings.Builder, boot homeBootPayload) { - loggedIn := boot.User != nil && boot.User.ID > 0 - if !loggedIn { - b.WriteString(`
`) - b.WriteString(`
`) - b.WriteString(`
每日签到`) - b.WriteString(`登录后每日可得 5–15 积分
`) - b.WriteString(`` + ssrIconGift() + `登录签到`) - b.WriteString(`
`) - return - } - if boot.CheckIn == nil { - return - } - st := boot.CheckIn - checkedIn := st.CheckedIn - panelClass := "widget-checkin-panel" - if checkedIn { - panelClass += " widget-checkin-panel--done" - } - b.WriteString(`
`) - b.WriteString(`
`) - title := "每日签到" - if checkedIn { - title = "今日已签到" - } - b.WriteString(`` + title + ``) - var meta string - if checkedIn { - if st.Streak > 0 { - meta = fmt.Sprintf("连续 %d 天 · 今日已获得 %d 积分", st.Streak, st.TodayPoints) - } else { - meta = fmt.Sprintf("今日已获得 %d 积分", st.TodayPoints) - } - } else if st.Streak > 0 { - meta = fmt.Sprintf("连续 %d 天 · 今日可得 %d 积分", st.Streak, st.TodayPoints) - } else { - meta = fmt.Sprintf("今日签到可得 %d 积分", st.TodayPoints) - } - b.WriteString(`` + html.EscapeString(meta) + `
`) - if !checkedIn { - b.WriteString(``) - } - b.WriteString(`
`) - if !checkedIn { - b.WriteString(``) - } - b.WriteString(`
`) -} - -func writeSSRFriendLinks(b *strings.Builder, boot homeBootPayload) { - b.WriteString(``) -} - -func writeSSRTagCloud(b *strings.Builder, boot homeBootPayload) { - b.WriteString(`
`) - b.WriteString(ssrWidgetIconTags()) - b.WriteString(`标签云
`) - b.WriteString(`
`) - if len(boot.Tags) == 0 { - b.WriteString(`
暂无标签
`) - } else { - b.WriteString(`
`) - for _, t := range boot.Tags { - href := "/?tag=" + url.QueryEscape(t.Name) - b.WriteString(`` + html.EscapeString(t.Name) + ``) - } - b.WriteString(`
`) - } - b.WriteString(`
`) -} - -func writeSSRRecentComments(b *strings.Builder, boot homeBootPayload) { - b.WriteString(`
`) - b.WriteString(ssrWidgetIconMessageCircle()) - b.WriteString(`最新评论
`) - list := boot.RecentComments - if len(list) > 6 { - list = list[:6] - } - if len(list) == 0 { - b.WriteString(`
暂无评论
`) - } else { - pl := boot.Limits - permalink := service.PermalinkConfig{Enabled: pl.PermalinkEnabled, Ext: pl.PermalinkExt} - for _, item := range list { - href := permalink.PostPath(item.PostID) - if item.Floor > 0 { - href += "#floor-" + strconv.Itoa(item.Floor) - } - initial := "?" - if r, _ := utf8.DecodeRuneInString(item.Author); r != utf8.RuneError { - initial = string(r) - } - b.WriteString(``) - } - } - b.WriteString(`
`) -} - -func writeSSRRecentUsers(b *strings.Builder, boot homeBootPayload) { - b.WriteString(`
`) - b.WriteString(ssrWidgetIconUserPlus()) - b.WriteString(`最新注册
`) - b.WriteString(`
`) - if len(boot.RecentUsers) == 0 { - b.WriteString(`
暂无用户
`) - } else { - pl := service.PermalinkConfig{Enabled: boot.Limits.PermalinkEnabled, Ext: boot.Limits.PermalinkExt} - b.WriteString(`
`) - for _, u := range boot.RecentUsers { - initial := "?" - if r, _ := utf8.DecodeRuneInString(u.Nickname); r != utf8.RuneError { - initial = string(r) - } - b.WriteString(``) - b.WriteString(``) - if u.Avatar != "" { - b.WriteString(``) - } else { - b.WriteString(html.EscapeString(initial)) - } - b.WriteString(`` + html.EscapeString(u.Nickname) + ``) - } - b.WriteString(`
`) - } - b.WriteString(`
`) -} - -func writeSSRShowcase(b *strings.Builder, boot homeBootPayload) { - b.WriteString(`
`) - b.WriteString(`
`) - b.WriteString(ssrWidgetIconEarth()) - b.WriteString(`开源展柜`) - b.WriteString(`全部
`) - b.WriteString(`
`) - if len(boot.Showcase) == 0 { - b.WriteString(`
暂无展柜站点
`) - } else { - n := len(boot.Showcase) - if n > 6 { - n = 6 - } - for i := 0; i < n; i++ { - item := boot.Showcase[i] - b.WriteString(``) - b.WriteString(`` + html.EscapeString(item.SiteName) + ``) - if item.Version != "" { - b.WriteString(`` + html.EscapeString(item.Version) + ``) - } - b.WriteString(``) - } - } - b.WriteString(`
`) -} - -func writeSSRFooter(b *strings.Builder, boot homeBootPayload, meta homeSSRMeta) { - brandName := strings.TrimSpace(boot.Branding.Name) - if brandName == "" { - brandName = "姜十三论坛" - } - year := time.Now().Year() - b.WriteString(`
`) -} - -func boardThemeIndex(colorIndex int, id uint) int { - const n = 8 - if colorIndex >= 0 { - return colorIndex % n - } - return int(id % uint(n)) -} - -func firstImageSrc(htmlContent string) string { - m := firstImgSrcRe.FindStringSubmatch(htmlContent) - if len(m) < 2 { - return "" - } - src := strings.TrimSpace(m[1]) - if src == "" || strings.HasPrefix(src, "data:") { - return "" - } - return src -} - -func formatSSRTime(t time.Time) string { - if t.IsZero() { - return "" - } - return t.Local().Format("01-02 15:04") -} diff --git a/handler/ssr_icons.go b/handler/ssr_icons.go deleted file mode 100644 index 62ca7b6..0000000 --- a/handler/ssr_icons.go +++ /dev/null @@ -1,361 +0,0 @@ -package handler - -import ( - "strconv" - "strings" - "time" -) - -// 内联 SVG:path 对齐 lucide-react@1.18(由 scripts/extract-lucide-paths.mjs 抽取) - -func ssrSVG(size int, paths ...string) string { - return ssrSVGWithClass(size, "", paths...) -} - -func ssrSVGWithClass(size int, className string, paths ...string) string { - var b strings.Builder - b.WriteString(``) - return b.String() -} - -func ssrIconSearch() string { - return ssrSVGWithClass(16, "header-search-icon", - ``, - ``, - ) -} - -func ssrIconSliders() string { - return ssrSVG(15, - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ) -} - -func ssrIconPlus() string { - return ssrSVG(16, - ``, - ``, - ) -} - -func ssrIconMenu() string { - return ssrSVG(18, - ``, - ``, - ``, - ) -} - -func ssrIconMoon() string { - return ssrSVG(18, - ``, - ) -} - -func ssrIconSun() string { - return ssrSVG(18, - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ``, - ) -} - -func ssrIconMail() string { - return ssrSVG(18, - ``, - ``, - ) -} - -func ssrIconHome() string { - return ssrSVG(18, - ``, - ``, - ) -} - -func ssrIconStar() string { - return ssrSVG(18, - ``, - ) -} - -func ssrIconFolderGit() string { - return ssrSVG(18, - ``, - ``, - ``, - ``, - ) -} - -func ssrIconLink2() string { - return ssrSVG(18, - ``, - ``, - ``, - ) -} - -func ssrWidgetIconLink2() string { - return ssrSVGWithClass(15, "widget-card-icon widget-card-icon--links", - ``, - ``, - ``, - ) -} - -func ssrIconEarth() string { - return ssrSVG(18, - ``, - ``, - ``, - ``, - ) -} - -func ssrWidgetIconEarth() string { - return ssrSVGWithClass(15, "widget-card-icon widget-card-icon--showcase", - ``, - ``, - ``, - ``, - ) -} - -func ssrIconFileText() string { - return ssrSVG(18, - ``, - ``, - ``, - ``, - ``, - ) -} - -func ssrIconLayoutDashboard() string { - return ssrSVG(18, - ``, - ``, - ``, - ``, - ) -} - -func ssrIconMessageCircle() string { - return ssrSVG(16, - ``, - ) -} - -func ssrWidgetIconMessageCircle() string { - return ssrSVGWithClass(15, "widget-card-icon widget-card-icon--notice", - ``, - ) -} - -func ssrIconClock() string { - return ssrSVG(16, - ``, - ``, - ) -} - -func ssrIconBadgeCheck() string { - return ssrSVG(16, - ``, - ``, - ) -} - -func ssrIconTags() string { - return ssrSVG(16, - ``, - ``, - ``, - ) -} - -func ssrWidgetIconTags() string { - return ssrSVGWithClass(15, "widget-card-icon widget-card-icon--tags", - ``, - ``, - ``, - ) -} - -func ssrIconUserPlus() string { - return ssrSVG(16, - ``, - ``, - ``, - ``, - ) -} - -func ssrWidgetIconUserPlus() string { - return ssrSVGWithClass(15, "widget-card-icon widget-card-icon--users", - ``, - ``, - ``, - ``, - ) -} - -func ssrIconCalendarCheck() string { - return ssrSVG(18, - ``, - ``, - ``, - ``, - ``, - ) -} - -func ssrIconCheck() string { - return ssrSVG(18, - ``, - ) -} - -func ssrIconGift() string { - return ssrSVG(15, - ``, - ``, - ``, - ``, - ) -} - -func ssrIconPanelRight() string { - return ssrSVG(18, - ``, - ``, - ) -} - -// 板块图标 path(key 对齐 BOARD_ICON_OPTIONS / AllowedBoardIcons) -var ssrBoardIconInner = map[string]string{ - "code-2": ``, - "coffee": ``, - "help-circle": ``, - "message-square": ``, - "lightbulb": ``, - "book-open": ``, - "gamepad-2": ``, - "palette": ``, - "music": ``, - "camera": ``, - "heart": ``, - "zap": ``, - "globe": ``, - "users": ``, - "briefcase": ``, - "graduation-cap": ``, - "shopping-bag": ``, - "map-pin": ``, - "megaphone": ``, - "flame": ``, - "star": ``, - "folder": ``, - "wrench": ``, - "cpu": ``, -} - -// 与前端 DEFAULT_ICONS 顺序一致(按 themeIndex 回退) -var ssrBoardDefaultIcons = []string{ - "code-2", - "coffee", - "help-circle", - "message-square", - "lightbulb", - "book-open", - "gamepad-2", - "palette", -} - -// ssrBoardIconSVG 输出板块 Lucide 图标;class 打在 svg 上(与 React BoardIconDisplay 一致) -func ssrBoardIconSVG(icon string, themeIndex int, className string) string { - key := strings.TrimSpace(strings.ToLower(icon)) - inner, ok := ssrBoardIconInner[key] - if !ok || inner == "" { - if themeIndex < 0 { - themeIndex = 0 - } - key = ssrBoardDefaultIcons[themeIndex%len(ssrBoardDefaultIcons)] - inner = ssrBoardIconInner[key] - } - return ssrSVGWithClass(18, className, inner) -} - -// formatSSRRelativeTime 与前端 formatTime 同规则 -func formatSSRRelativeTime(t time.Time) string { - if t.IsZero() { - return "" - } - now := time.Now() - diffSec := now.Sub(t).Seconds() - if diffSec < 0 { - diffSec = 0 - } - if diffSec < 60 { - return "刚刚" - } - if diffSec < 3600 { - return strconv.Itoa(int(diffSec/60)) + "分钟前" - } - if diffSec < 86400 { - return strconv.Itoa(int(diffSec/3600)) + "小时前" - } - diffDay := int(diffSec / 86400) - if diffDay < 30 { - return strconv.Itoa(diffDay) + "天前" - } - if t.Year() == now.Year() { - return strconv.Itoa(int(t.Month())) + "月" + strconv.Itoa(t.Day()) + "日" - } - return strconv.Itoa(t.Year()) + "年" + strconv.Itoa(int(t.Month())) + "月" + strconv.Itoa(t.Day()) + "日" -} - -// formatSSRShortDateTime 与前端 formatShortDateTime 同规则(MM-DD HH:mm) -func formatSSRShortDateTime(t time.Time) string { - if t.IsZero() { - return "" - } - local := t.Local() - pad := func(n int) string { - if n < 10 { - return "0" + strconv.Itoa(n) - } - return strconv.Itoa(n) - } - return pad(int(local.Month())) + "-" + pad(local.Day()) + " " + pad(local.Hour()) + ":" + pad(local.Minute()) -} diff --git a/router/router.go b/router/router.go index 093c8fc..34a6ed3 100644 --- a/router/router.go +++ b/router/router.go @@ -1,14 +1,12 @@ package router import ( - "encoding/json" "fmt" "net/http" "os" "path/filepath" "git.iioio.com/freefire/jiang13-forum/config" - "git.iioio.com/freefire/jiang13-forum/embed_static" "git.iioio.com/freefire/jiang13-forum/handler" "git.iioio.com/freefire/jiang13-forum/middleware" "git.iioio.com/freefire/jiang13-forum/service" @@ -31,13 +29,9 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { monitorSvc.StartBackground() r.Use(middleware.AccessLogMiddleware(monitorSvc)) - // dev 模式:跳过内嵌静态资源,前端由 Vite 开发服务器(:5173)提供 - // 用户应访问 5173 端口,Vite 通过 proxy 将 /api 等请求转发到本服务(:3000) - if !cfg.DevMode { - if err := embed_static.SetupEmbed(r); err != nil { - return nil, err - } - } else { + // 前端由 Node 渲染服务器提供(对外 :3000),本服务仅提供 API; + // dev 模式前端由 Vite 开发服务器(:5173)提供,/api 经 Vite proxy 转发到本服务 + if cfg.DevMode { fmt.Fprintf(os.Stderr, "[dev] 后端仅提供 API,前端请访问 http://localhost:5173\n") } @@ -46,20 +40,6 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { if cfg.CommunityHub { fmt.Fprintf(os.Stderr, "[community] 社区枢纽已开启(运维配置),可接收自愿上报\n") } - // SPA 入口 HTML 注入标题与品牌 JSON,避免刷新时先闪默认文案 - embed_static.SetSPADocumentTitle(func() string { - return settingsSvc.SiteBranding().DocumentTitle() - }) - embed_static.SetSPABrandingJSON(func() []byte { - b, err := json.Marshal(settingsSvc.SiteBranding()) - if err != nil { - return nil - } - return b - }) - embed_static.SetSPAFaviconURL(func() string { - return settingsSvc.SiteBranding().Favicon - }) authSvc := service.NewAuthService(cfg.JWTSecret, filter, settingsSvc) userSvc := service.NewUserService(filter, settingsSvc) boardSvc := service.NewBoardService() @@ -138,6 +118,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { pubAPI.GET("/me", h.APIMe) pubAPI.GET("/boards", h.APIBoards) pubAPI.GET("/stats", h.APIStats) + // SSR 聚合数据(Vike +data 使用,Node 内部调用) + pubAPI.GET("/home-boot", h.APIHomeBoot) pubAPI.POST("/community/heartbeat", middleware.RateLimitMiddleware(limiter, "community_heartbeat"), h.APICommunityHeartbeat) pubAPI.GET("/community/showcase", h.APICommunityShowcase) pubAPI.POST("/monitor/pageview", middleware.RateLimitMiddleware(limiter, "monitor_pageview"), h.APIMonitorPageview) @@ -300,48 +282,17 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { adminAPI.GET("/backup/download/:name", h.APIAdminDownloadBackup) } - // 后台管理页面由 React SPA 渲染(JSON API 见上方 /api/admin) - // dev 模式下前端由 Vite 提供,后台页面路由不在此注册 - if !cfg.DevMode { - admin := r.Group("/admin") - { - admin.GET("/login", func(c *gin.Context) { - c.Redirect(http.StatusFound, "/login") - }) - - adminAuth := admin.Group("/", authMW.RequireAuth(), authMW.RequireAdmin()) - { - adminAuth.GET("/", func(c *gin.Context) { c.Redirect(http.StatusFound, "/admin/dashboard") }) - for _, page := range []string{ - "dashboard", "boards", "pages", "links", "community", "posts", - "comments", "reports", "users", "badges", "media", "monitor", "settings", - } { - adminAuth.GET("/"+page, embed_static.ServeSPANoIndex) - } - } - } - } - - // React SPA 入口 - // dev 模式:前端由 Vite(:5173) 提供,非 API 请求返回开发提示 - // 生产模式:注入 SEO meta / JSON-LD / 预渲染摘要 - if cfg.DevMode { - r.NoRoute(func(c *gin.Context) { + // 非 API 路径由 Node 渲染服务器(生产)或 Vite 开发服务器(dev)处理, + // 本服务不再服务前端页面,未知路径一律 404 JSON + r.NoRoute(func(c *gin.Context) { + if cfg.DevMode { c.JSON(http.StatusNotFound, gin.H{ "error": "dev 模式下前端由 Vite 提供,请访问 http://localhost:5173", }) - }) - } else { - // 文档请求也解析登录 cookie,首页 SSR 才能输出头像/签到/收藏等完整壳 - r.GET("/", authMW.OptionalAuth(), h.ServePublicSPA) - r.NoRoute(authMW.OptionalAuth(), func(c *gin.Context) { - if embed_static.IsSPARoute(c.Request.URL.Path) { - h.ServePublicSPA(c) - return - } - c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) - }) - } + return + } + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + }) return r, nil }