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"` Showcase []service.CommunityShowcaseItem `json:"showcase"` 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 || items == nil { items = []service.CommunityShowcaseItem{} } out.boot.Showcase = items }() } else { out.boot.Showcase = []service.CommunityShowcaseItem{} } 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 { b.WriteString(`全局置顶`) } if post.BoardPinned { 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") }