chore: 去掉未上线前的旧兼容垫片,板块流收口到首页
鉴权只认 cookie、SEO 只走 Next、sort 与推荐对齐;删除 Bearer、Go sitemap、post_type 回填等冗余路径。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -41,7 +41,7 @@ func (h *Handlers) AnnouncementDetail(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"announcement": a})
|
||||
}
|
||||
|
||||
// ===== 管理接口(RequireAdmin 兜底,前端不做权限判定) =====
|
||||
// ===== 管理接口(RequireStaff + PermAnnouncements,前端不做权限判定) =====
|
||||
|
||||
// AdminListAnnouncements 全部公告(含草稿)
|
||||
func (h *Handlers) AdminListAnnouncements(c *gin.Context) {
|
||||
|
||||
@@ -57,34 +57,23 @@ func setAuthCookies(c *gin.Context, accessToken, refreshToken string, secure boo
|
||||
})
|
||||
}
|
||||
|
||||
// clearAuthCookies 清除所有认证 cookie。
|
||||
// 除当前名称外,还需兼容清除:
|
||||
// - 启用 __Host- 前缀之前的旧无前缀名称(Path=/)
|
||||
// - 更早期 Path=/api/auth 的旧 refresh cookie
|
||||
// clearAuthCookies 清除当前契约下的三枚认证 cookie(名称随 ConfigureCookieNames,Path=/)。
|
||||
func clearAuthCookies(c *gin.Context, secure bool) {
|
||||
cookies := []struct{ name, path string }{
|
||||
{service.CookieName, "/"},
|
||||
{service.RefreshCookieName, "/"},
|
||||
{service.CSRFCookieName, "/"},
|
||||
cookies := []struct {
|
||||
name string
|
||||
httpOnly bool
|
||||
}{
|
||||
{service.CookieName, true},
|
||||
{service.RefreshCookieName, true},
|
||||
{service.CSRFCookieName, false},
|
||||
}
|
||||
// 当前生产名称带 __Host- 前缀时,旧无前缀 cookie 仍残留在浏览器中
|
||||
if service.CookieName != "j13_token" {
|
||||
cookies = append(cookies,
|
||||
struct{ name, path string }{"j13_token", "/"},
|
||||
struct{ name, path string }{"j13_refresh", "/"},
|
||||
struct{ name, path string }{"j13_csrf", "/"},
|
||||
)
|
||||
}
|
||||
// 更早期的 refresh cookie 只挂在 /api/auth 下
|
||||
cookies = append(cookies, struct{ name, path string }{"j13_refresh", "/api/auth"})
|
||||
|
||||
for _, ck := range cookies {
|
||||
http.SetCookie(c.Writer, &http.Cookie{
|
||||
Name: ck.name,
|
||||
Value: "",
|
||||
Path: ck.path,
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
HttpOnly: ck.httpOnly,
|
||||
Secure: secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Expires: time.Unix(0, 0),
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/middleware"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Boards 获取板块列表
|
||||
@@ -15,3 +19,43 @@ func (h *Handlers) Boards(c *gin.Context) {
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"boards": nonNilSlice(boards)})
|
||||
}
|
||||
|
||||
// BoardSidebar 首页选中板块后的右栏聚合数据
|
||||
func (h *Handlers) BoardSidebar(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的板块 ID"})
|
||||
return
|
||||
}
|
||||
data, err := h.Board.Sidebar(uint(id))
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "板块不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// 热门榜点赞态 + 登录用户签到
|
||||
if claims := middleware.CurrentUser(c); claims != nil {
|
||||
ids := make([]uint, 0, len(data.Hot))
|
||||
for _, p := range data.Hot {
|
||||
ids = append(ids, p.ID)
|
||||
}
|
||||
likedMap := h.Like.BatchHasLiked(ids, claims.ID)
|
||||
for i := range data.Hot {
|
||||
data.Hot[i].Liked = likedMap[data.Hot[i].ID]
|
||||
}
|
||||
if status, err := h.Checkin.Status(claims.ID); err == nil {
|
||||
data.Checkin = status
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"board": data.Board,
|
||||
"moderators": data.Moderators,
|
||||
"stats": data.Stats,
|
||||
"hot": data.Hot,
|
||||
"active_users": data.ActiveUsers,
|
||||
"checkin": data.Checkin,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -25,18 +25,13 @@ func parseWSChatRoom(room string) (uint, bool) {
|
||||
|
||||
// 实时总线 WebSocket 入口:GET /api/ws
|
||||
//
|
||||
// 鉴权:与 REST 一致,优先读 HttpOnly 的 j13_token cookie(浏览器握手自动携带),
|
||||
// 回退 Authorization 头;ParseToken + ValidateClaims 实时校验 token_version 与封禁。
|
||||
// 鉴权:与 REST 一致,读 HttpOnly 的 j13_token cookie(浏览器握手自动携带);
|
||||
// ParseToken + ValidateClaims 实时校验 token_version 与封禁。
|
||||
// WebSocket 握手是 GET 请求,天然不受 CSRF 约束,但必须严格校验 Origin,
|
||||
// 防止跨站页面在用户浏览器中发起握手。
|
||||
func (h *Handlers) RealtimeWS(c *gin.Context) {
|
||||
tokenStr, err := c.Cookie(service.CookieName)
|
||||
if err != nil || tokenStr == "" {
|
||||
if auth := c.GetHeader("Authorization"); strings.HasPrefix(auth, "Bearer ") {
|
||||
tokenStr = strings.TrimPrefix(auth, "Bearer ")
|
||||
}
|
||||
}
|
||||
if tokenStr == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
|
||||
return
|
||||
}
|
||||
@@ -105,17 +100,17 @@ func (h *Handlers) RealtimeWS(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// checkWSOrigin 校验握手 Origin:
|
||||
// - 无 Origin(非浏览器/native 客户端)放行
|
||||
// - dev:允许 localhost / 127.0.0.1 任意端口(前端 :3000 直连后端 :3001)
|
||||
// - 生产:Origin 主机必须与请求 Host 同源
|
||||
// checkWSOrigin 校验握手 Origin(仅服务浏览器前端):
|
||||
// - 必须带 Origin(浏览器 WS 握手总会带;拒绝空 Origin 的脚本/非浏览器探测)
|
||||
// - dev:允许 localhost / 127.0.0.1 任意端口(页面 :3000 直连后端 :3001,端口不同但 cookie 仍会带上)
|
||||
// - 生产:Origin 的 host 必须与请求 Host 完全一致(需反向代理把页面与 /api/ws 挂到同一 Host)
|
||||
func (h *Handlers) checkWSOrigin(r *http.Request) bool {
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin == "" {
|
||||
return true
|
||||
return false
|
||||
}
|
||||
u, err := url.Parse(origin)
|
||||
if err != nil || u.Scheme != "http" && u.Scheme != "https" {
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
|
||||
return false
|
||||
}
|
||||
if h.Cfg.DevMode {
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const siteBaseURL = "http://localhost:3000"
|
||||
|
||||
// RobotsTxt robots.txt
|
||||
func (h *Handlers) RobotsTxt(c *gin.Context) {
|
||||
content := fmt.Sprintf(`User-agent: *
|
||||
Allow: /
|
||||
Sitemap: %s/sitemap.xml
|
||||
`, siteBaseURL)
|
||||
c.String(http.StatusOK, content)
|
||||
}
|
||||
|
||||
// SitemapXML 生成 sitemap
|
||||
func (h *Handlers) SitemapXML(c *gin.Context) {
|
||||
type urlEntry struct {
|
||||
Loc string `xml:"loc"`
|
||||
Lastmod string `xml:"lastmod"`
|
||||
Changefreq string `xml:"changefreq"`
|
||||
Priority string `xml:"priority"`
|
||||
}
|
||||
type urlset struct {
|
||||
XMLName xml.Name `xml:"urlset"`
|
||||
Xmlns string `xml:"xmlns,attr"`
|
||||
URLs []urlEntry `xml:"url"`
|
||||
}
|
||||
|
||||
now := time.Now().Format("2006-01-02")
|
||||
entries := []urlEntry{
|
||||
{Loc: siteBaseURL + "/", Lastmod: now, Changefreq: "daily", Priority: "1.0"},
|
||||
}
|
||||
|
||||
// 板块页
|
||||
var boards []model.Board
|
||||
model.DB.Find(&boards)
|
||||
for _, b := range boards {
|
||||
entries = append(entries, urlEntry{
|
||||
Loc: fmt.Sprintf("%s/board/%d", siteBaseURL, b.ID),
|
||||
Lastmod: now,
|
||||
Changefreq: "daily",
|
||||
Priority: "0.8",
|
||||
})
|
||||
}
|
||||
|
||||
// 帖子详情页
|
||||
var posts []model.Post
|
||||
model.DB.Where("status = ?", model.ContentStatusPublished).
|
||||
Order("created_at DESC").Limit(1000).Find(&posts)
|
||||
for _, p := range posts {
|
||||
entries = append(entries, urlEntry{
|
||||
Loc: fmt.Sprintf("%s/post/%d", siteBaseURL, p.ID),
|
||||
Lastmod: p.UpdatedAt.Format("2006-01-02"),
|
||||
Changefreq: "weekly",
|
||||
Priority: "0.6",
|
||||
})
|
||||
}
|
||||
|
||||
// 站点公告
|
||||
var anns []model.Announcement
|
||||
model.DB.Where("published = ?", true).Order("created_at DESC").Limit(200).Find(&anns)
|
||||
for _, a := range anns {
|
||||
entries = append(entries, urlEntry{
|
||||
Loc: fmt.Sprintf("%s/announcement/%d", siteBaseURL, a.ID),
|
||||
Lastmod: a.UpdatedAt.Format("2006-01-02"),
|
||||
Changefreq: "weekly",
|
||||
Priority: "0.5",
|
||||
})
|
||||
}
|
||||
|
||||
us := urlset{
|
||||
Xmlns: "http://www.sitemaps.org/schemas/sitemap/0.9",
|
||||
URLs: entries,
|
||||
}
|
||||
output, err := xml.MarshalIndent(us, "", " ")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Header("Content-Type", "application/xml")
|
||||
c.String(http.StatusOK, xml.Header+string(output))
|
||||
}
|
||||
@@ -3,9 +3,7 @@ package middleware
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"github.com/freefire/jiang13-bbs/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -60,27 +58,6 @@ func (m *AuthMiddleware) RequireAuth() gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// RequireAdmin 必须管理员(保留兼容;等价于"管理员及以上",不含纯板块管理员)
|
||||
func (m *AuthMiddleware) RequireAdmin() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
user, ok := m.parseToken(c)
|
||||
if !ok {
|
||||
if c.GetBool(AccountBannedKey) {
|
||||
bannedJSON(c)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
|
||||
return
|
||||
}
|
||||
if model.RoleLevel(model.Role(user.Role)) < model.RoleLevel(model.RoleAdmin) {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "需要管理员权限"})
|
||||
return
|
||||
}
|
||||
c.Set("user", user)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// ActorKey 实时权限快照在 gin.Context 中的键
|
||||
const ActorKey = "actor"
|
||||
|
||||
@@ -121,20 +98,13 @@ func (m *AuthMiddleware) RequirePerm(perm string) gin.HandlerFunc {
|
||||
}
|
||||
|
||||
// parseToken 解析并校验 token:
|
||||
// 1. 优先从 HttpOnly cookie 读取,回退 Authorization header
|
||||
// 1. 从 HttpOnly cookie(j13_token / 生产 __Host-j13_token)读取
|
||||
// 2. 校验 JWT 签名和过期
|
||||
// 3. 查 DB 实时校验 token_version 和 banned 状态
|
||||
func (m *AuthMiddleware) parseToken(c *gin.Context) (*service.UserClaims, bool) {
|
||||
tokenStr, err := c.Cookie(service.CookieName)
|
||||
if err != nil || tokenStr == "" {
|
||||
auth := c.GetHeader("Authorization")
|
||||
if auth == "" {
|
||||
return nil, false
|
||||
}
|
||||
tokenStr = strings.TrimPrefix(auth, "Bearer ")
|
||||
if tokenStr == auth {
|
||||
return nil, false
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
claims, err := m.auth.ParseToken(tokenStr)
|
||||
if err != nil {
|
||||
|
||||
@@ -32,9 +32,6 @@ func SecurityHeaders() gin.HandlerFunc {
|
||||
// 控制 Referer 信息泄露
|
||||
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
|
||||
// XSS 防护(旧浏览器兼容,现代浏览器靠 CSP)
|
||||
c.Header("X-XSS-Protection", "1; mode=block")
|
||||
|
||||
// 禁止浏览器缓存敏感页面(可按需覆盖)
|
||||
// c.Header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
|
||||
|
||||
|
||||
@@ -45,12 +45,6 @@ func InitDB(dsn string) error {
|
||||
return fmt.Errorf("自动迁移失败: %w", err)
|
||||
}
|
||||
|
||||
// 旧 post_type=normal → discussion
|
||||
if err := db.Exec(`UPDATE posts SET post_type = ? WHERE post_type = ? OR post_type = '' OR post_type IS NULL`,
|
||||
PostTypeDiscussion, PostTypeNormal).Error; err != nil {
|
||||
return fmt.Errorf("帖子类型归一失败: %w", err)
|
||||
}
|
||||
|
||||
// 一次性:用签到累计回填 User.Points(仅余额仍为 0 且有签到积分的用户)
|
||||
if err := backfillPointsFromCheckin(db); err != nil {
|
||||
return fmt.Errorf("积分余额回填失败: %w", err)
|
||||
|
||||
@@ -54,14 +54,13 @@ const (
|
||||
ContentStatusRejected = "rejected"
|
||||
)
|
||||
|
||||
// 帖子类型(discussion 为默认;兼容旧值 normal → discussion)
|
||||
// 帖子类型(discussion 为默认)
|
||||
const (
|
||||
PostTypeDiscussion = "discussion" // 讨论(默认)
|
||||
PostTypeQuestion = "question" // 问答
|
||||
PostTypePoll = "poll" // 投票
|
||||
PostTypeBounty = "bounty" // 悬赏
|
||||
PostTypeLottery = "lottery" // 抽奖
|
||||
PostTypeNormal = "normal" // 旧值,读取时归一为 discussion
|
||||
)
|
||||
|
||||
// ValidPostType 发帖类型白名单
|
||||
@@ -73,11 +72,8 @@ func ValidPostType(t string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// NormalizePostType 归一化类型(空/旧 normal → discussion)
|
||||
// NormalizePostType 归一化类型(空或未知 → discussion)
|
||||
func NormalizePostType(t string) string {
|
||||
if t == "" || t == PostTypeNormal {
|
||||
return PostTypeDiscussion
|
||||
}
|
||||
if ValidPostType(t) {
|
||||
return t
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
r.Use(cors.New(cors.Config{
|
||||
AllowOrigins: []string{"http://localhost:3000", "http://127.0.0.1:3000"},
|
||||
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
AllowHeaders: []string{"Origin", "Content-Type", "Authorization", "X-CSRF-Token"},
|
||||
AllowHeaders: []string{"Origin", "Content-Type", "X-CSRF-Token"},
|
||||
AllowCredentials: true,
|
||||
MaxAge: 12 * time.Hour,
|
||||
}))
|
||||
@@ -98,10 +98,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
// 上传文件静态服务(data/uploads → /uploads)
|
||||
r.Static("/uploads", filepath.Join(cfg.DataDir, "uploads"))
|
||||
|
||||
// 健康检查 & SEO
|
||||
// 健康检查(sitemap / robots 由 Next.js Metadata Route 对外提供)
|
||||
r.GET("/health", h.Health)
|
||||
r.GET("/robots.txt", h.RobotsTxt)
|
||||
r.GET("/sitemap.xml", h.SitemapXML)
|
||||
|
||||
// 实时通信总线(WebSocket,cookie 鉴权 + Origin 校验,处理器内部完成鉴权升级)
|
||||
r.GET("/api/ws", h.RealtimeWS)
|
||||
@@ -111,6 +109,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
{
|
||||
pubAPI.GET("/me", h.Me)
|
||||
pubAPI.GET("/boards", h.Boards)
|
||||
pubAPI.GET("/boards/:id/sidebar", h.BoardSidebar)
|
||||
pubAPI.GET("/overview", h.Overview)
|
||||
pubAPI.GET("/posts", h.Posts)
|
||||
pubAPI.GET("/posts/:id", h.PostDetail)
|
||||
@@ -197,10 +196,6 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
// 各功能点再由 RequirePerm 按角色放行;板块范围在 service 层按 Actor.BoardIDs 隔离
|
||||
staffAPI := r.Group("/api/admin", authMW.RequireStaff(), middleware.CSRFMiddleware())
|
||||
{
|
||||
staffAPI.GET("/dashboard", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"message": "admin dashboard"})
|
||||
})
|
||||
|
||||
// 内容审核队列(任意管理角色;板块管理员只见授权板块)
|
||||
staffAPI.GET("/moderation/pending-posts", h.AdminPendingPosts)
|
||||
staffAPI.GET("/moderation/pending-comments", h.AdminPendingComments)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -20,6 +22,32 @@ type BoardWithCount struct {
|
||||
PostCount int64 `json:"post_count"`
|
||||
}
|
||||
|
||||
// BoardModerator 板块管理员公开资料
|
||||
type BoardModerator struct {
|
||||
ID uint `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Nickname string `json:"nickname"`
|
||||
Avatar string `json:"avatar"`
|
||||
}
|
||||
|
||||
// BoardSidebarStats 板级统计(不含在线)
|
||||
type BoardSidebarStats struct {
|
||||
PostCount int64 `json:"post_count"`
|
||||
TodayPosts int64 `json:"today_posts"`
|
||||
CommentCount int64 `json:"comment_count"`
|
||||
TodayComments int64 `json:"today_comments"`
|
||||
}
|
||||
|
||||
// BoardSidebarData 首页选中板块后的右栏聚合
|
||||
type BoardSidebarData struct {
|
||||
Board BoardWithCount `json:"board"`
|
||||
Moderators []BoardModerator `json:"moderators"`
|
||||
Stats BoardSidebarStats `json:"stats"`
|
||||
Hot []PostListItem `json:"hot"`
|
||||
ActiveUsers []ActiveUser `json:"active_users"`
|
||||
Checkin *CheckinStatus `json:"checkin,omitempty"`
|
||||
}
|
||||
|
||||
// List 获取所有板块(按 sort_order 排序),附带各板块已发布帖子数(板块目录页展示用)
|
||||
func (s *BoardService) List() ([]BoardWithCount, error) {
|
||||
var boards []model.Board
|
||||
@@ -56,3 +84,141 @@ func (s *BoardService) Get(id uint) (*model.Board, error) {
|
||||
}
|
||||
return &board, nil
|
||||
}
|
||||
|
||||
// Sidebar 首页板块右栏:管理员 + 概况统计 + 本板热门/活跃
|
||||
func (s *BoardService) Sidebar(boardID uint) (*BoardSidebarData, error) {
|
||||
board, err := s.Get(boardID)
|
||||
if err != nil {
|
||||
return nil, err // 含 gorm.ErrRecordNotFound
|
||||
}
|
||||
|
||||
data := &BoardSidebarData{
|
||||
Board: BoardWithCount{Board: *board},
|
||||
Moderators: []BoardModerator{},
|
||||
Hot: []PostListItem{},
|
||||
ActiveUsers: []ActiveUser{},
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
weekStart := startOfWeek(now)
|
||||
|
||||
// 帖子总数 / 今日新帖
|
||||
if err := s.db.Model(&model.Post{}).
|
||||
Where("board_id = ? AND status = ?", boardID, model.ContentStatusPublished).
|
||||
Count(&data.Stats.PostCount).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data.Board.PostCount = data.Stats.PostCount
|
||||
if err := s.db.Model(&model.Post{}).
|
||||
Where("board_id = ? AND status = ? AND created_at >= ?", boardID, model.ContentStatusPublished, dayStart).
|
||||
Count(&data.Stats.TodayPosts).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 评论总数 / 今日评论(经 posts.board_id)
|
||||
if err := s.db.Model(&model.Comment{}).
|
||||
Joins("JOIN posts ON posts.id = comments.post_id AND posts.deleted_at IS NULL").
|
||||
Where("posts.board_id = ? AND posts.status = ? AND comments.status = ?",
|
||||
boardID, model.ContentStatusPublished, model.ContentStatusPublished).
|
||||
Count(&data.Stats.CommentCount).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.db.Model(&model.Comment{}).
|
||||
Joins("JOIN posts ON posts.id = comments.post_id AND posts.deleted_at IS NULL").
|
||||
Where("posts.board_id = ? AND posts.status = ? AND comments.status = ? AND comments.created_at >= ?",
|
||||
boardID, model.ContentStatusPublished, model.ContentStatusPublished, dayStart).
|
||||
Count(&data.Stats.TodayComments).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 板块管理员:仅 board_admin 且授权本板、未封禁
|
||||
var mods []model.User
|
||||
if err := s.db.Table("users").
|
||||
Select("users.id, users.username, users.nickname, users.avatar").
|
||||
Joins("JOIN user_boards ON user_boards.user_id = users.id AND user_boards.board_id = ?", boardID).
|
||||
Where("users.role = ? AND users.banned = ? AND users.deleted_at IS NULL", model.RoleBoardAdmin, false).
|
||||
Order("users.id ASC").
|
||||
Find(&mods).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, u := range mods {
|
||||
data.Moderators = append(data.Moderators, BoardModerator{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
Nickname: u.Nickname,
|
||||
Avatar: u.Avatar,
|
||||
})
|
||||
}
|
||||
|
||||
// 本板热门
|
||||
var hotPosts []model.Post
|
||||
if err := s.db.Where("board_id = ? AND status = ? AND pinned = 0", boardID, model.ContentStatusPublished).
|
||||
Order("(like_count + comment_count * 2) DESC, created_at DESC").
|
||||
Limit(5).Preload("Board").Preload("User").Find(&hotPosts).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data.Hot = toPostListItems(hotPosts)
|
||||
|
||||
// 本板本周活跃
|
||||
type activityRow struct {
|
||||
UserID uint
|
||||
PostCount int64
|
||||
CommentCount int64
|
||||
}
|
||||
var rows []activityRow
|
||||
activitySQL := `
|
||||
SELECT a.user_id AS user_id,
|
||||
SUM(a.is_post) AS post_count,
|
||||
SUM(1 - a.is_post) AS comment_count
|
||||
FROM (
|
||||
SELECT user_id, 1 AS is_post FROM posts
|
||||
WHERE status = ? AND board_id = ? AND created_at >= ? AND deleted_at IS NULL
|
||||
UNION ALL
|
||||
SELECT c.user_id, 0 AS is_post FROM comments c
|
||||
JOIN posts p ON p.id = c.post_id AND p.deleted_at IS NULL
|
||||
WHERE c.status = ? AND c.created_at >= ? AND c.deleted_at IS NULL
|
||||
AND p.board_id = ? AND p.status = ?
|
||||
) AS a
|
||||
JOIN users AS u ON u.id = a.user_id
|
||||
WHERE u.banned = ? AND u.deleted_at IS NULL
|
||||
GROUP BY a.user_id
|
||||
ORDER BY COUNT(*) DESC, a.user_id ASC
|
||||
LIMIT 5`
|
||||
if err := s.db.Raw(activitySQL,
|
||||
model.ContentStatusPublished, boardID, weekStart,
|
||||
model.ContentStatusPublished, weekStart, boardID, model.ContentStatusPublished,
|
||||
false).Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(rows) > 0 {
|
||||
ids := make([]uint, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
ids = append(ids, r.UserID)
|
||||
}
|
||||
var users []model.User
|
||||
if err := s.db.Where("id IN ?", ids).Find(&users).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
userMap := make(map[uint]model.User, len(users))
|
||||
for _, u := range users {
|
||||
userMap[u.ID] = u
|
||||
}
|
||||
for _, r := range rows {
|
||||
u, ok := userMap[r.UserID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
data.ActiveUsers = append(data.ActiveUsers, ActiveUser{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
Nickname: u.Nickname,
|
||||
Avatar: u.Avatar,
|
||||
PostCount: r.PostCount,
|
||||
CommentCount: r.CommentCount,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ type PostListQuery struct {
|
||||
BoardID uint
|
||||
Page int
|
||||
Size int
|
||||
Sort string // latest | hot | new
|
||||
Sort string // latest | recommended | new
|
||||
Keyword string // 搜索关键词
|
||||
Recommended bool // 仅精华帖
|
||||
ViewerID uint // 当前查看者(0=游客)
|
||||
@@ -209,7 +209,7 @@ func (s *PostService) List(q PostListQuery) ([]PostListItem, int64, error) {
|
||||
SELECT MAX(c.created_at) FROM comments c
|
||||
WHERE c.post_id = posts.id AND c.status = 'published' AND c.deleted_at IS NULL
|
||||
), created_at)) DESC`
|
||||
if q.Sort == "hot" {
|
||||
if q.Sort == "recommended" {
|
||||
order = "pinned DESC, (like_count + comment_count * 2) DESC, created_at DESC"
|
||||
}
|
||||
// new:纯按发帖时间(新帖子)
|
||||
|
||||
Reference in New Issue
Block a user