chore: 去掉未上线前的旧兼容垫片,板块流收口到首页

鉴权只认 cookie、SEO 只走 Next、sort 与推荐对齐;删除 Bearer、Go sitemap、post_type 回填等冗余路径。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-16 05:04:47 +08:00
parent 0e61a14ea3
commit 833bcd33a1
33 changed files with 715 additions and 548 deletions

View File

@@ -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) {

View File

@@ -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),

View File

@@ -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,
})
}

View File

@@ -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 {

View File

@@ -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))
}

View File

@@ -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 {

View File

@@ -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")

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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:纯按发帖时间(新帖子)