首次提交:姜十三论坛

后端 Go+Gin:认证/CSRF/限流、板块、帖子、评论、点赞、通知、用户资料、置顶推荐;前端 Next.js 16:发帖/编辑/删除、搜索、分页、点赞、通知中心、设置;基础设施 docker-compose 与配置模板;添加 .gitignore 与专有许可证(保留所有权利)
This commit is contained in:
2026-09-12 01:11:05 +08:00
commit 0c2b0d2d6a
62 changed files with 8289 additions and 0 deletions

16
backend/app.ini.example Normal file
View File

@@ -0,0 +1,16 @@
[server]
HTTP_PORT = 3001
[database]
; PostgreSQL 连接串(部署时请修改账号密码)
DSN = postgres://postgres:postgres@localhost:5432/jiang13?sslmode=disable
[security]
; 留空则自动生成并持久化到 data/.jwt_secret;生产环境务必显式指定强随机值
JWT_SECRET =
[paths]
DATA = data
[app]
DEV_MODE = true

View File

@@ -0,0 +1,32 @@
package main
import (
"fmt"
"log"
"github.com/freefire/jiang13-bbs/config"
"github.com/freefire/jiang13-bbs/model"
"github.com/freefire/jiang13-bbs/router"
)
func main() {
cfg, err := config.Parse()
if err != nil {
log.Fatalf("配置解析失败: %v", err)
}
if err := model.InitDB(cfg.DBDSN); err != nil {
log.Fatalf("数据库初始化失败: %v", err)
}
r, err := router.Setup(cfg)
if err != nil {
log.Fatalf("路由初始化失败: %v", err)
}
addr := fmt.Sprintf(":%d", cfg.Port)
log.Printf("[jiang13-bbs] 服务启动于 %s (dev=%v)", addr, cfg.DevMode)
if err := r.Run(addr); err != nil {
log.Fatalf("服务启动失败: %v", err)
}
}

112
backend/config/config.go Normal file
View File

@@ -0,0 +1,112 @@
package config
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"gopkg.in/ini.v1"
)
// Config 应用全局配置
type Config struct {
WorkPath string
Port int
DataDir string
JWTSecret string
DBDSN string
DevMode bool
}
// Parse 解析配置:环境变量 > app.ini > 默认值
func Parse() (*Config, error) {
workPath, err := os.Getwd()
if err != nil {
return nil, fmt.Errorf("获取工作目录失败: %w", err)
}
// 默认值
cfg := &Config{
WorkPath: workPath,
Port: 3001,
DataDir: filepath.Join(workPath, "data"),
DBDSN: "postgres://postgres:postgres@localhost:5432/jiang13?sslmode=disable",
DevMode: true,
}
// 尝试加载 app.ini
iniPath := filepath.Join(workPath, "app.ini")
if _, err := os.Stat(iniPath); err == nil {
f, err := ini.Load(iniPath)
if err != nil {
return nil, fmt.Errorf("加载 app.ini 失败: %w", err)
}
if s := f.Section("server"); s.HasKey("HTTP_PORT") {
if v, err := s.Key("HTTP_PORT").Int(); err == nil {
cfg.Port = v
}
}
if s := f.Section("database"); s.HasKey("DSN") {
cfg.DBDSN = s.Key("DSN").String()
}
if s := f.Section("security"); s.HasKey("JWT_SECRET") {
cfg.JWTSecret = s.Key("JWT_SECRET").String()
}
if s := f.Section("paths"); s.HasKey("DATA") {
dataRel := s.Key("DATA").String()
if !filepath.IsAbs(dataRel) {
cfg.DataDir = filepath.Join(workPath, dataRel)
} else {
cfg.DataDir = dataRel
}
}
if s := f.Section("app"); s.HasKey("DEV_MODE") {
cfg.DevMode = s.Key("DEV_MODE").MustBool(true)
}
}
// 环境变量覆盖
if v := os.Getenv("HTTP_PORT"); v != "" {
if p, err := strconv.Atoi(v); err == nil {
cfg.Port = p
}
}
if v := os.Getenv("DB_DSN"); v != "" {
cfg.DBDSN = v
}
if v := os.Getenv("JWT_SECRET"); v != "" {
cfg.JWTSecret = v
}
if v := os.Getenv("DEV_MODE"); v != "" {
cfg.DevMode = strings.EqualFold(v, "true") || v == "1"
}
// 确保数据目录存在
if err := os.MkdirAll(cfg.DataDir, 0755); err != nil {
return nil, fmt.Errorf("创建数据目录失败: %w", err)
}
// JWT 密钥:留空则自动生成并持久化
if strings.TrimSpace(cfg.JWTSecret) == "" {
secretFile := filepath.Join(cfg.DataDir, ".jwt_secret")
if data, err := os.ReadFile(secretFile); err == nil && len(data) > 0 {
cfg.JWTSecret = string(data)
} else {
cfg.JWTSecret = generateSecret(32)
_ = os.WriteFile(secretFile, []byte(cfg.JWTSecret), 0600)
}
}
return cfg, nil
}
func generateSecret(n int) string {
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
b := make([]byte, n)
for i := range b {
b[i] = chars[i%len(chars)]
}
return string(b)
}

49
backend/go.mod Normal file
View File

@@ -0,0 +1,49 @@
module github.com/freefire/jiang13-bbs
go 1.27.0
require (
github.com/gin-contrib/cors v1.7.2
github.com/gin-gonic/gin v1.10.0
github.com/golang-jwt/jwt/v5 v5.2.2
golang.org/x/crypto v0.43.0
gopkg.in/ini.v1 v1.67.0
gorm.io/driver/postgres v1.5.9
gorm.io/gorm v1.25.12
)
require (
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/pgx/v5 v5.5.5 // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/net v0.45.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/text v0.30.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

121
backend/go.sum Normal file
View File

@@ -0,0 +1,121 @@
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw=
github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.5.9 h1:DkegyItji119OlcaLjqN11kHoUgZ/j13E0jkJZgD6A8=
gorm.io/driver/postgres v1.5.9/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

213
backend/handler/auth.go Normal file
View File

@@ -0,0 +1,213 @@
package handler
import (
"net/http"
"time"
"github.com/freefire/jiang13-bbs/middleware"
"github.com/freefire/jiang13-bbs/service"
"github.com/gin-gonic/gin"
)
var (
accessCookieMaxAge = int(service.AccessTokenTTL.Seconds()) // 15 分钟
refreshCookieMaxAge = int(service.RefreshTokenTTL.Seconds()) // 7 天
)
// setAuthCookies 设置认证 cookie:
// - j13_token: access token(HttpOnly,15min)
// - j13_refresh: refresh token(HttpOnly,7天,仅 /api/auth 路径)
// - j13_csrf: CSRF token(可读,7天,与 refresh 同生命周期,确保 refresh 流程可用)
func setAuthCookies(c *gin.Context, accessToken, refreshToken string, secure bool) {
csrfToken := service.GenerateCSRFToken()
// Access token cookie
http.SetCookie(c.Writer, &http.Cookie{
Name: service.CookieName,
Value: accessToken,
Path: "/",
MaxAge: accessCookieMaxAge,
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteStrictMode,
})
// Refresh token cookie(仅 /api/auth 路径使用,缩小攻击面)
http.SetCookie(c.Writer, &http.Cookie{
Name: service.RefreshCookieName,
Value: refreshToken,
Path: "/api/auth",
MaxAge: refreshCookieMaxAge,
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteStrictMode,
})
// CSRF cookie(前端可读,放入 X-CSRF-Token header;与 refresh 同寿命以支持 refresh 流程)
http.SetCookie(c.Writer, &http.Cookie{
Name: service.CSRFCookieName,
Value: csrfToken,
Path: "/",
MaxAge: refreshCookieMaxAge,
HttpOnly: false,
Secure: secure,
SameSite: http.SameSiteStrictMode,
})
}
// clearAuthCookies 清除所有认证 cookie
func clearAuthCookies(c *gin.Context) {
cookies := []struct{ name, path string }{
{service.CookieName, "/"},
{service.RefreshCookieName, "/api/auth"},
{service.CSRFCookieName, "/"},
}
for _, ck := range cookies {
http.SetCookie(c.Writer, &http.Cookie{
Name: ck.name,
Value: "",
Path: ck.path,
MaxAge: -1,
HttpOnly: true,
Secure: false,
SameSite: http.SameSiteStrictMode,
Expires: time.Unix(0, 0),
})
}
}
// RegisterRequest 注册请求
type RegisterRequest struct {
Username string `json:"username" binding:"required,min=3,max=32"`
Email string `json:"email" binding:"omitempty,email"`
Password string `json:"password" binding:"required,min=6,max=64"`
}
// LoginRequest 登录请求
type LoginRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
// Register 用户注册
func (h *Handlers) Register(c *gin.Context) {
var req RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
user, err := h.Auth.Register(req.Username, req.Email, req.Password)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"id": user.ID,
"username": user.Username,
})
}
// Login 用户登录:access + refresh token 写入 HttpOnly cookie,CSRF 写入可读 cookie
func (h *Handlers) Login(c *gin.Context) {
var req LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
accessToken, refreshToken, user, err := h.Auth.Login(req.Username, req.Password)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
// dev 模式不设 Secure,生产环境需 HTTPS
setAuthCookies(c, accessToken, refreshToken, !h.Cfg.DevMode)
c.JSON(http.StatusOK, gin.H{
"user": gin.H{
"id": user.ID,
"username": user.Username,
"nickname": user.Nickname,
"avatar": user.Avatar,
"role": user.Role,
},
})
}
// Refresh 刷新 access token:验证 refresh token,轮转后签发新 access + refresh
func (h *Handlers) Refresh(c *gin.Context) {
refreshToken, err := c.Cookie(service.RefreshCookieName)
if err != nil || refreshToken == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
return
}
accessToken, newRefresh, user, err := h.Auth.RotateRefreshToken(refreshToken)
if err != nil {
clearAuthCookies(c)
c.JSON(http.StatusUnauthorized, gin.H{"error": "登录已过期,请重新登录"})
return
}
setAuthCookies(c, accessToken, newRefresh, !h.Cfg.DevMode)
c.JSON(http.StatusOK, gin.H{
"user": gin.H{
"id": user.ID,
"username": user.Username,
"nickname": user.Nickname,
"avatar": user.Avatar,
"role": user.Role,
},
})
}
// Logout 登出:撤销 refresh token,清除所有认证 cookie
func (h *Handlers) Logout(c *gin.Context) {
claims := middleware.CurrentUser(c)
if claims != nil {
// 撤销该用户所有 refresh token(防止 refresh token 被盗用)
h.Auth.RevokeAllUserRefreshTokens(claims.ID)
}
clearAuthCookies(c)
c.JSON(http.StatusOK, gin.H{"message": "已登出"})
}
// ChangePasswordRequest 修改密码请求
type ChangePasswordRequest struct {
OldPassword string `json:"old_password" binding:"required"`
NewPassword string `json:"new_password" binding:"required,min=6,max=64"`
}
// ChangePassword 修改密码:校验旧密码,更新新密码,清除所有登录态
func (h *Handlers) ChangePassword(c *gin.Context) {
claims := middleware.CurrentUser(c)
var req ChangePasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.Auth.ChangePassword(claims.ID, req.OldPassword, req.NewPassword); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 密码已改,token_version 已递增,旧 token 全部失效,清除 cookie 要求重新登录
clearAuthCookies(c)
c.JSON(http.StatusOK, gin.H{"message": "密码修改成功,请重新登录"})
}
// Me 获取当前用户信息
func (h *Handlers) Me(c *gin.Context) {
claims := middleware.CurrentUser(c)
if claims == nil {
c.JSON(http.StatusOK, gin.H{"user": nil})
return
}
user, err := h.Auth.GetUserByID(claims.ID)
if err != nil {
c.JSON(http.StatusOK, gin.H{"user": nil})
return
}
c.JSON(http.StatusOK, gin.H{
"user": gin.H{
"id": user.ID,
"username": user.Username,
"nickname": user.Nickname,
"avatar": user.Avatar,
"role": user.Role,
},
})
}

17
backend/handler/board.go Normal file
View File

@@ -0,0 +1,17 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
)
// Boards 获取板块列表
func (h *Handlers) Boards(c *gin.Context) {
boards, err := h.Board.List()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"boards": boards})
}

View File

@@ -0,0 +1,70 @@
package handler
import (
"net/http"
"strconv"
"github.com/freefire/jiang13-bbs/middleware"
"github.com/freefire/jiang13-bbs/model"
"github.com/gin-gonic/gin"
)
// PostComments 获取帖子评论
func (h *Handlers) PostComments(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"})
return
}
comments, err := h.Comment.ListByPost(uint(id))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"comments": comments})
}
// CreateCommentRequest 评论请求
type CreateCommentRequest struct {
Content string `json:"content" binding:"required,min=1"`
}
// CreateComment 创建评论
func (h *Handlers) CreateComment(c *gin.Context) {
claims := middleware.CurrentUser(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"})
return
}
var req CreateCommentRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
comment, err := h.Comment.Create(claims.ID, uint(id), req.Content)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 通知帖子作者(排除自己评论自己的帖子)
if authorID, e := h.Post.GetAuthorID(uint(id)); e == nil {
h.Notification.Create(authorID, claims.ID, model.NotificationTypeComment, uint(id), comment.ID, req.Content)
}
c.JSON(http.StatusOK, gin.H{"comment": comment})
}
// DeleteComment 删除评论
func (h *Handlers) DeleteComment(c *gin.Context) {
claims := middleware.CurrentUser(c)
cid, err := strconv.ParseUint(c.Param("cid"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的评论 ID"})
return
}
if err := h.Comment.Delete(uint(cid), claims.ID, claims.Role); err != nil {
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "已删除"})
}

View File

@@ -0,0 +1,17 @@
package handler
import (
"github.com/freefire/jiang13-bbs/config"
"github.com/freefire/jiang13-bbs/service"
)
// Handlers 聚合所有服务引用
type Handlers struct {
Cfg *config.Config
Auth *service.AuthService
Board *service.BoardService
Post *service.PostService
Comment *service.CommentService
Like *service.LikeService
Notification *service.NotificationService
}

17
backend/handler/health.go Normal file
View File

@@ -0,0 +1,17 @@
package handler
import (
"net/http"
"github.com/freefire/jiang13-bbs/model"
"github.com/gin-gonic/gin"
)
// Health 健康检查
func (h *Handlers) Health(c *gin.Context) {
if err := model.PingDB(); err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"status": "error", "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}

32
backend/handler/like.go Normal file
View File

@@ -0,0 +1,32 @@
package handler
import (
"net/http"
"strconv"
"github.com/freefire/jiang13-bbs/middleware"
"github.com/freefire/jiang13-bbs/model"
"github.com/gin-gonic/gin"
)
// ToggleLike 点赞/取消点赞
func (h *Handlers) ToggleLike(c *gin.Context) {
claims := middleware.CurrentUser(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"})
return
}
liked, count, err := h.Like.Toggle(uint(id), claims.ID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 仅点赞时通知帖子作者(取消点赞不通知)
if liked {
if authorID, e := h.Post.GetAuthorID(uint(id)); e == nil {
h.Notification.Create(authorID, claims.ID, model.NotificationTypeLike, uint(id), 0, "")
}
}
c.JSON(http.StatusOK, gin.H{"liked": liked, "like_count": count})
}

View File

@@ -0,0 +1,64 @@
package handler
import (
"net/http"
"strconv"
"github.com/freefire/jiang13-bbs/middleware"
"github.com/gin-gonic/gin"
)
// Notifications 获取当前用户的通知列表(分页)
func (h *Handlers) Notifications(c *gin.Context) {
claims := middleware.CurrentUser(c)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
list, total, err := h.Notification.List(claims.ID, page, size)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"notifications": list,
"total": total,
"page": page,
"size": size,
})
}
// UnreadCount 获取未读通知数
func (h *Handlers) UnreadCount(c *gin.Context) {
claims := middleware.CurrentUser(c)
count, err := h.Notification.UnreadCount(claims.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"count": count})
}
// MarkRead 标记单条通知为已读
func (h *Handlers) MarkRead(c *gin.Context) {
claims := middleware.CurrentUser(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的通知 ID"})
return
}
if err := h.Notification.MarkRead(uint(id), claims.ID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "已标记为已读"})
}
// MarkAllRead 标记所有通知为已读
func (h *Handlers) MarkAllRead(c *gin.Context) {
claims := middleware.CurrentUser(c)
if err := h.Notification.MarkAllRead(claims.ID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "已全部标记为已读"})
}

179
backend/handler/post.go Normal file
View File

@@ -0,0 +1,179 @@
package handler
import (
"net/http"
"strconv"
"github.com/freefire/jiang13-bbs/middleware"
"github.com/freefire/jiang13-bbs/service"
"github.com/gin-gonic/gin"
)
// Posts 获取帖子列表
func (h *Handlers) Posts(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
boardID, _ := strconv.ParseUint(c.Query("board_id"), 10, 64)
sort := c.DefaultQuery("sort", "latest")
keyword := c.Query("keyword")
items, total, err := h.Post.List(service.PostListQuery{
BoardID: uint(boardID),
Page: page,
Size: size,
Sort: sort,
Keyword: keyword,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// 填充点赞状态(仅登录用户)
if claims := middleware.CurrentUser(c); claims != nil {
ids := make([]uint, 0, len(items))
for _, p := range items {
ids = append(ids, p.ID)
}
likedMap := h.Like.BatchHasLiked(ids, claims.ID)
for i := range items {
items[i].Liked = likedMap[items[i].ID]
}
}
c.JSON(http.StatusOK, gin.H{
"posts": items,
"total": total,
"page": page,
"size": size,
})
}
// PostDetail 获取帖子详情
func (h *Handlers) PostDetail(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"})
return
}
post, err := h.Post.GetByID(uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"})
return
}
// 填充点赞状态(仅登录用户)
if claims := middleware.CurrentUser(c); claims != nil {
post.Liked = h.Like.HasLiked(post.ID, claims.ID)
}
c.JSON(http.StatusOK, gin.H{"post": post})
}
// CreatePostRequest 发帖请求
type CreatePostRequest struct {
BoardID uint `json:"board_id" binding:"required"`
Title string `json:"title" binding:"required,min=1,max=256"`
Content string `json:"content" binding:"required,min=1"`
Tags string `json:"tags"`
PostType string `json:"post_type"`
}
// CreatePost 创建帖子
func (h *Handlers) CreatePost(c *gin.Context) {
claims := middleware.CurrentUser(c)
var req CreatePostRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
postType := req.PostType
if postType == "" {
postType = "normal"
}
post, err := h.Post.Create(claims.ID, req.BoardID, req.Title, req.Content, req.Tags, postType)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"post": post})
}
// UpdatePostRequest 更新帖子请求
type UpdatePostRequest struct {
Title string `json:"title" binding:"omitempty,min=1,max=256"`
Content string `json:"content" binding:"omitempty,min=1"`
Tags string `json:"tags"`
}
// UpdatePost 编辑帖子
func (h *Handlers) UpdatePost(c *gin.Context) {
claims := middleware.CurrentUser(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"})
return
}
var req UpdatePostRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
post, err := h.Post.Update(uint(id), claims.ID, claims.Role, req.Title, req.Content, req.Tags)
if err != nil {
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"post": post})
}
// DeletePost 删除帖子
func (h *Handlers) DeletePost(c *gin.Context) {
claims := middleware.CurrentUser(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"})
return
}
if err := h.Post.Delete(uint(id), claims.ID, claims.Role); err != nil {
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "已删除"})
}
// TogglePin 切换帖子置顶(仅管理员)
func (h *Handlers) TogglePin(c *gin.Context) {
claims := middleware.CurrentUser(c)
if claims.Role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "仅管理员可操作"})
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"})
return
}
pinned, err := h.Post.TogglePin(uint(id))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"pinned": pinned})
}
// ToggleRecommend 切换帖子推荐(仅管理员)
func (h *Handlers) ToggleRecommend(c *gin.Context) {
claims := middleware.CurrentUser(c)
if claims.Role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "仅管理员可操作"})
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"})
return
}
recommended, err := h.Post.ToggleRecommend(uint(id))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"recommended": recommended})
}

79
backend/handler/seo.go Normal file
View File

@@ -0,0 +1,79 @@
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",
})
}
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))
}

96
backend/handler/user.go Normal file
View File

@@ -0,0 +1,96 @@
package handler
import (
"net/http"
"strconv"
"github.com/freefire/jiang13-bbs/middleware"
"github.com/gin-gonic/gin"
)
// UserProfile 获取用户资料:基本信息 + 统计 + 发帖列表(分页)
func (h *Handlers) UserProfile(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的用户 ID"})
return
}
user, err := h.Auth.GetUserByID(uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
posts, postsTotal, err := h.Post.ListByUser(user.ID, page, size)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// 填充点赞状态(仅登录用户)
if claims := middleware.CurrentUser(c); claims != nil {
ids := make([]uint, 0, len(posts))
for _, p := range posts {
ids = append(ids, p.ID)
}
likedMap := h.Like.BatchHasLiked(ids, claims.ID)
for i := range posts {
posts[i].Liked = likedMap[posts[i].ID]
}
}
postCount, _ := h.Post.CountByUser(user.ID)
commentCount, _ := h.Comment.CountByUser(user.ID)
c.JSON(http.StatusOK, gin.H{
"user": gin.H{
"id": user.ID,
"username": user.Username,
"nickname": user.Nickname,
"avatar": user.Avatar,
"role": user.Role,
"created_at": user.CreatedAt,
},
"stats": gin.H{
"post_count": postCount,
"comment_count": commentCount,
},
"posts": posts,
"posts_total": postsTotal,
"page": page,
"size": size,
})
}
// UserComments 获取用户发表的评论列表(分页,含帖子标题)
func (h *Handlers) UserComments(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的用户 ID"})
return
}
// 校验用户存在
if _, err := h.Auth.GetUserByID(uint(id)); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
comments, total, err := h.Comment.ListByUser(uint(id), page, size)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"comments": comments,
"total": total,
"page": page,
"size": size,
})
}

100
backend/middleware/auth.go Normal file
View File

@@ -0,0 +1,100 @@
package middleware
import (
"net/http"
"strings"
"github.com/freefire/jiang13-bbs/service"
"github.com/gin-gonic/gin"
)
// AuthMiddleware 认证中间件
type AuthMiddleware struct {
auth *service.AuthService
}
func NewAuthMiddleware(auth *service.AuthService) *AuthMiddleware {
return &AuthMiddleware{auth: auth}
}
// OptionalAuth 可选登录:解析 token,失败不阻断
func (m *AuthMiddleware) OptionalAuth() gin.HandlerFunc {
return func(c *gin.Context) {
user, ok := m.parseToken(c)
if ok {
c.Set("user", user)
}
c.Next()
}
}
// RequireAuth 必须登录
func (m *AuthMiddleware) RequireAuth() gin.HandlerFunc {
return func(c *gin.Context) {
user, ok := m.parseToken(c)
if !ok {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
return
}
if user.Banned {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "账号已被封禁"})
return
}
c.Set("user", user)
c.Next()
}
}
// RequireAdmin 必须管理员
func (m *AuthMiddleware) RequireAdmin() gin.HandlerFunc {
return func(c *gin.Context) {
user, ok := m.parseToken(c)
if !ok {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
return
}
if user.Role != service.RoleAdmin {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "需要管理员权限"})
return
}
c.Set("user", user)
c.Next()
}
}
// parseToken 解析并校验 token:
// 1. 优先从 HttpOnly cookie 读取,回退 Authorization header
// 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
}
}
claims, err := m.auth.ParseToken(tokenStr)
if err != nil {
return nil, false
}
// 实时校验:token_version 匹配 + 未封禁(防止旧 JWT 在封禁/改密码后仍有效)
if _, err := m.auth.ValidateClaims(claims); err != nil {
return nil, false
}
return claims, true
}
// CurrentUser 从 context 获取当前用户
func CurrentUser(c *gin.Context) *service.UserClaims {
if v, ok := c.Get("user"); ok {
if u, ok := v.(*service.UserClaims); ok {
return u
}
}
return nil
}

View File

@@ -0,0 +1,34 @@
package middleware
import (
"net/http"
"github.com/freefire/jiang13-bbs/service"
"github.com/gin-gonic/gin"
)
// CSRFMiddleware CSRF 防护中间件
// 对 POST/PUT/DELETE 等状态变更请求,校验 X-CSRF-Token header 与 cookie 中的 CSRF token 是否一致
func CSRFMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// 仅对状态变更方法校验
if c.Request.Method == http.MethodGet || c.Request.Method == http.MethodHead || c.Request.Method == http.MethodOptions {
c.Next()
return
}
cookieToken, err := c.Cookie(service.CSRFCookieName)
if err != nil || cookieToken == "" {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "CSRF token 缺失"})
return
}
headerToken := c.GetHeader(service.CSRFHeaderName)
if headerToken == "" || headerToken != cookieToken {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "CSRF 校验失败"})
return
}
c.Next()
}
}

View File

@@ -0,0 +1,20 @@
package middleware
import (
"net/http"
"github.com/freefire/jiang13-bbs/service"
"github.com/gin-gonic/gin"
)
// RateLimitMiddleware 速率限制中间件
func RateLimitMiddleware(rl *service.RateLimiter, rateType string) gin.HandlerFunc {
return func(c *gin.Context) {
key := rateType + ":" + c.ClientIP()
if !rl.Allow(key) {
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "请求过于频繁,请稍后再试"})
return
}
c.Next()
}
}

View File

@@ -0,0 +1,43 @@
package middleware
import (
"github.com/gin-gonic/gin"
)
// SecurityHeaders 安全响应头中间件
// 为所有响应添加安全头,降低 XSS、点击劫持、MIME 嗅探等风险
func SecurityHeaders() gin.HandlerFunc {
return func(c *gin.Context) {
// CSP:限制资源加载来源,降低 XSS 危害
c.Header("Content-Security-Policy",
"default-src 'self'; "+
"script-src 'self' 'unsafe-inline'; "+
"style-src 'self' 'unsafe-inline'; "+
"img-src 'self' data: https:; "+
"font-src 'self' data:; "+
"connect-src 'self'; "+
"frame-ancestors 'none'; "+
"base-uri 'self'; "+
"form-action 'self'")
// HSTS:强制 HTTPS(生产环境生效,dev 模式浏览器会忽略)
c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload")
// 点击劫持防护
c.Header("X-Frame-Options", "DENY")
// MIME 嗅探防护
c.Header("X-Content-Type-Options", "nosniff")
// 控制 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")
c.Next()
}
}

63
backend/model/db.go Normal file
View File

@@ -0,0 +1,63 @@
package model
import (
"fmt"
"log"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// DB 全局数据库实例
var DB *gorm.DB
// InitDB 连接 PostgreSQL 并自动迁移
func InitDB(dsn string) error {
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Warn),
})
if err != nil {
return fmt.Errorf("连接 PostgreSQL 失败: %w", err)
}
if err := db.AutoMigrate(
&User{}, &Board{}, &Post{}, &Comment{}, &RefreshToken{}, &Like{}, &Notification{},
); err != nil {
return fmt.Errorf("自动迁移失败: %w", err)
}
DB = db
seedDefaultBoards(db)
log.Println("[model] PostgreSQL 数据库初始化完成")
return nil
}
// PingDB 检测数据库连接
func PingDB() error {
if DB == nil {
return fmt.Errorf("数据库未初始化")
}
sqlDB, err := DB.DB()
if err != nil {
return err
}
return sqlDB.Ping()
}
// seedDefaultBoards 写入默认板块
func seedDefaultBoards(db *gorm.DB) {
defaults := []Board{
{Name: "综合讨论", Description: "什么都可以聊", Icon: "message-circle", SortOrder: 1},
{Name: "技术分享", Description: "分享技术心得与问题", Icon: "code", SortOrder: 2},
{Name: "问答求助", Description: "提问与解答", Icon: "help-circle", SortOrder: 3},
{Name: "闲聊灌水", Description: "轻松闲聊", Icon: "coffee", SortOrder: 4},
}
for _, b := range defaults {
var count int64
db.Model(&Board{}).Where("name = ?", b.Name).Count(&count)
if count == 0 {
_ = db.Create(&b).Error
}
}
}

136
backend/model/models.go Normal file
View File

@@ -0,0 +1,136 @@
package model
import (
"time"
"gorm.io/gorm"
)
// Role 用户角色
type Role string
const (
RoleUser Role = "user"
RoleAdmin Role = "admin"
)
// 内容审核状态
const (
ContentStatusPending = "pending"
ContentStatusPublished = "published"
ContentStatusRejected = "rejected"
)
// 帖子类型
const (
PostTypeNormal = "normal"
PostTypeQuestion = "question"
)
// User 用户表
type User struct {
ID uint `gorm:"primaryKey" json:"id"`
Username string `gorm:"uniqueIndex;size:128;not null" json:"username"`
Email string `gorm:"index;size:128;default:''" json:"-"`
Password string `gorm:"size:128;not null" json:"-"`
Nickname string `gorm:"size:64" json:"nickname"`
Avatar string `gorm:"size:512" json:"avatar"`
Role Role `gorm:"size:16;default:user" json:"role"`
Banned bool `gorm:"default:false" json:"banned"`
TokenVersion int `gorm:"default:0" json:"-"` // token 版本号,改密码/封禁时递增使旧 JWT 失效
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
// RefreshToken 刷新令牌表(支持服务端撤销与轮转)
type RefreshToken struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"index;not null" json:"user_id"`
Token string `gorm:"uniqueIndex;size:128;not null" json:"-"`
ExpiresAt time.Time `gorm:"index;not null" json:"expires_at"`
Revoked bool `gorm:"default:false" json:"revoked"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Board 论坛板块
type Board struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:64;not null" json:"name"`
Description string `gorm:"size:512" json:"description"`
Icon string `gorm:"size:64;default:''" json:"icon"`
ColorIndex int `gorm:"default:-1" json:"color_index"`
SortOrder int `gorm:"default:0" json:"sort_order"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
// Post 帖子
type Post struct {
ID uint `gorm:"primaryKey" json:"id"`
BoardID uint `gorm:"index;not null" json:"board_id"`
UserID uint `gorm:"index;not null" json:"user_id"`
Title string `gorm:"size:256;not null" json:"title"`
Content string `gorm:"type:text;not null" json:"content"`
Tags string `gorm:"size:256" json:"tags"`
PostType string `gorm:"size:16;default:normal;index" json:"post_type"`
Pinned int `gorm:"default:0" json:"pinned"`
Recommended bool `gorm:"default:false;index" json:"recommended"`
Status string `gorm:"size:16;default:published;index" json:"status"`
LikeCount int `gorm:"default:0" json:"like_count"`
ViewCount int `gorm:"default:0" json:"view_count"`
CommentCount int `gorm:"default:0" json:"comment_count"`
Liked bool `gorm:"-" json:"liked"` // 当前用户是否已点赞(展示字段,不入库)
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
Board Board `gorm:"foreignKey:BoardID" json:"board,omitempty"`
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
}
// Comment 评论
type Comment struct {
ID uint `gorm:"primaryKey" json:"id"`
PostID uint `gorm:"index;not null" json:"post_id"`
UserID uint `gorm:"index;not null" json:"user_id"`
Content string `gorm:"type:text;not null" json:"content"`
Status string `gorm:"size:16;default:published;index" json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
}
// Like 点赞记录(联合唯一索引防止重复点赞)
type Like struct {
ID uint `gorm:"primaryKey" json:"id"`
PostID uint `gorm:"uniqueIndex:idx_post_user;not null" json:"post_id"`
UserID uint `gorm:"uniqueIndex:idx_post_user;not null" json:"user_id"`
CreatedAt time.Time `json:"created_at"`
}
// 通知类型
const (
NotificationTypeComment = "comment" // 评论了你的帖子
NotificationTypeLike = "like" // 点赞了你的帖子
)
// Notification 站内通知
type Notification struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"index;not null" json:"user_id"` // 接收通知的用户
ActorID uint `gorm:"not null" json:"actor_id"` // 触发通知的用户
Type string `gorm:"size:16;not null;index" json:"type"` // comment | like
PostID uint `gorm:"index;not null" json:"post_id"` // 关联帖子
CommentID uint `gorm:"index" json:"comment_id"` // 关联评论(点赞时为 0)
Content string `gorm:"size:256" json:"content"` // 内容预览
IsRead bool `gorm:"default:false;index" json:"is_read"`
CreatedAt time.Time `json:"created_at"`
Actor User `gorm:"foreignKey:ActorID" json:"actor,omitempty"`
Post Post `gorm:"foreignKey:PostID" json:"post,omitempty"`
}

114
backend/router/router.go Normal file
View File

@@ -0,0 +1,114 @@
package router
import (
"time"
"github.com/freefire/jiang13-bbs/config"
"github.com/freefire/jiang13-bbs/handler"
"github.com/freefire/jiang13-bbs/middleware"
"github.com/freefire/jiang13-bbs/model"
"github.com/freefire/jiang13-bbs/service"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
)
// Setup 初始化路由
func Setup(cfg *config.Config) (*gin.Engine, error) {
if cfg.DevMode {
gin.SetMode(gin.DebugMode)
} else {
gin.SetMode(gin.ReleaseMode)
}
r := gin.New()
r.Use(gin.Recovery())
r.Use(gin.Logger())
// 全局安全响应头
r.Use(middleware.SecurityHeaders())
// CORS
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"},
AllowCredentials: true,
MaxAge: 12 * time.Hour,
}))
// 服务
authSvc := service.NewAuthService(model.DB, cfg.JWTSecret)
boardSvc := service.NewBoardService(model.DB)
postSvc := service.NewPostService(model.DB)
commentSvc := service.NewCommentService(model.DB)
likeSvc := service.NewLikeService(model.DB)
notifSvc := service.NewNotificationService(model.DB)
limiter := service.DefaultRateLimiter()
h := &handler.Handlers{
Cfg: cfg,
Auth: authSvc,
Board: boardSvc,
Post: postSvc,
Comment: commentSvc,
Like: likeSvc,
Notification: notifSvc,
}
authMW := middleware.NewAuthMiddleware(authSvc)
// 健康检查 & SEO
r.GET("/health", h.Health)
r.GET("/robots.txt", h.RobotsTxt)
r.GET("/sitemap.xml", h.SitemapXML)
// 公开 API(可选登录)
pubAPI := r.Group("/api", authMW.OptionalAuth())
{
pubAPI.GET("/me", h.Me)
pubAPI.GET("/boards", h.Boards)
pubAPI.GET("/posts", h.Posts)
pubAPI.GET("/posts/:id", h.PostDetail)
pubAPI.GET("/posts/:id/comments", h.PostComments)
pubAPI.GET("/users/:id", h.UserProfile)
pubAPI.GET("/users/:id/comments", h.UserComments)
pubAPI.POST("/register", middleware.RateLimitMiddleware(limiter, service.RateRegister), h.Register)
pubAPI.POST("/login", middleware.RateLimitMiddleware(limiter, service.RateLogin), h.Login)
// refresh token 端点:access 过期后用 refresh 换新 token(需 CSRF 防护)
pubAPI.POST("/auth/refresh", middleware.CSRFMiddleware(), h.Refresh)
}
// 需登录 API(先鉴权,再 CSRF 防护)
api := r.Group("/api", authMW.RequireAuth(), middleware.CSRFMiddleware())
{
api.POST("/logout", h.Logout)
api.POST("/change-password", h.ChangePassword)
api.POST("/posts", middleware.RateLimitMiddleware(limiter, service.RatePost), h.CreatePost)
api.PUT("/posts/:id", h.UpdatePost)
api.DELETE("/posts/:id", h.DeletePost)
api.PUT("/posts/:id/pin", h.TogglePin)
api.PUT("/posts/:id/recommend", h.ToggleRecommend)
api.POST("/posts/:id/like", h.ToggleLike)
api.POST("/posts/:id/comments", middleware.RateLimitMiddleware(limiter, service.RateComment), h.CreateComment)
api.DELETE("/posts/:id/comments/:cid", h.DeleteComment)
// 通知
api.GET("/notifications", h.Notifications)
api.GET("/notifications/unread-count", h.UnreadCount)
api.PUT("/notifications/:id/read", h.MarkRead)
api.PUT("/notifications/read-all", h.MarkAllRead)
}
// 管理员 API(同样需要 CSRF 防护)
adminAPI := r.Group("/api/admin", authMW.RequireAuth(), middleware.CSRFMiddleware(), authMW.RequireAdmin())
{
adminAPI.GET("/dashboard", func(c *gin.Context) {
c.JSON(200, gin.H{"message": "admin dashboard"})
})
}
r.NoRoute(func(c *gin.Context) {
c.JSON(404, gin.H{"error": "not found"})
})
return r, nil
}

284
backend/service/auth.go Normal file
View File

@@ -0,0 +1,284 @@
package service
import (
"crypto/rand"
"encoding/base64"
"errors"
"time"
"github.com/freefire/jiang13-bbs/model"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
const (
RoleUser = "user"
RoleAdmin = "admin"
// CookieName JWT access token 存储的 HttpOnly cookie 名
CookieName = "j13_token"
// RefreshCookieName refresh token 存储的 HttpOnly cookie 名
RefreshCookieName = "j13_refresh"
// CSRFCookieName CSRF token cookie 名(非 HttpOnly,前端可读)
CSRFCookieName = "j13_csrf"
// CSRFHeaderName 前端传递 CSRF token 的 header 名
CSRFHeaderName = "X-CSRF-Token"
// AccessTokenTTL access token 有效期(短期,降低被盗窗口)
AccessTokenTTL = 15 * time.Minute
// RefreshTokenTTL refresh token 有效期(长期)
RefreshTokenTTL = 7 * 24 * time.Hour
)
// UserClaims JWT 中携带的用户信息
type UserClaims struct {
ID uint `json:"id"`
Username string `json:"username"`
Role string `json:"role"`
Banned bool `json:"banned"`
TokenVersion int `json:"tv"` // token 版本号,用于服务端撤销
}
// AuthService 认证服务
type AuthService struct {
db *gorm.DB
jwtSecret []byte
}
func NewAuthService(db *gorm.DB, jwtSecret string) *AuthService {
return &AuthService{db: db, jwtSecret: []byte(jwtSecret)}
}
// Register 用户注册
func (s *AuthService) Register(username, email, password string) (*model.User, error) {
// 检查用户名是否已存在
var count int64
s.db.Model(&model.User{}).Where("username = ?", username).Count(&count)
if count > 0 {
return nil, errors.New("用户名已被使用")
}
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
user := &model.User{
Username: username,
Email: email,
Password: string(hashed),
Nickname: username,
Role: model.RoleUser,
}
if err := s.db.Create(user).Error; err != nil {
return nil, err
}
return user, nil
}
// Login 用户登录,返回 access token + refresh token + user
func (s *AuthService) Login(username, password string) (string, string, *model.User, error) {
var user model.User
if err := s.db.Where("username = ?", username).First(&user).Error; err != nil {
return "", "", nil, errors.New("用户名或密码错误")
}
if user.Banned {
return "", "", nil, errors.New("账号已被封禁")
}
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil {
return "", "", nil, errors.New("用户名或密码错误")
}
accessToken, err := s.generateToken(&user)
if err != nil {
return "", "", nil, err
}
refreshToken, err := s.CreateRefreshToken(user.ID)
if err != nil {
return "", "", nil, err
}
return accessToken, refreshToken, &user, nil
}
// generateToken 签发 access token(短期)
func (s *AuthService) generateToken(user *model.User) (string, error) {
claims := &tokenClaims{
UserClaims: UserClaims{
ID: user.ID,
Username: user.Username,
Role: string(user.Role),
Banned: user.Banned,
TokenVersion: user.TokenVersion,
},
RegisteredClaims: jwt.RegisteredClaims{
Subject: user.Username,
ExpiresAt: jwt.NewNumericDate(time.Now().Add(AccessTokenTTL)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(s.jwtSecret)
}
type tokenClaims struct {
UserClaims
jwt.RegisteredClaims
}
// ParseToken 解析 JWT(仅校验签名和过期,不查 DB)
func (s *AuthService) ParseToken(tokenStr string) (*UserClaims, error) {
claims := &tokenClaims{}
_, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) {
return s.jwtSecret, nil
})
if err != nil {
return nil, err
}
return &claims.UserClaims, nil
}
// ValidateClaims 校验 claims 是否仍然有效(查 DB:token_version 匹配且未封禁)
// 用于中间件在每个请求上做实时权限校验
func (s *AuthService) ValidateClaims(claims *UserClaims) (*model.User, error) {
var user model.User
if err := s.db.First(&user, claims.ID).Error; err != nil {
return nil, errors.New("用户不存在")
}
// token 版本不匹配 → 已被撤销(改密码/封禁/管理员操作)
if user.TokenVersion != claims.TokenVersion {
return nil, errors.New("token 已失效")
}
// 实时校验封禁状态(不依赖 JWT 中的缓存值)
if user.Banned {
return nil, errors.New("账号已被封禁")
}
return &user, nil
}
// GetUserByID 根据 ID 获取用户
func (s *AuthService) GetUserByID(id uint) (*model.User, error) {
var user model.User
if err := s.db.First(&user, id).Error; err != nil {
return nil, err
}
return &user, nil
}
// GenerateCSRFToken 生成随机 CSRF token
func GenerateCSRFToken() string {
b := make([]byte, 32)
_, _ = rand.Read(b)
return base64.URLEncoding.EncodeToString(b)
}
// generateRandomToken 生成随机令牌字符串
func generateRandomToken() string {
b := make([]byte, 32)
_, _ = rand.Read(b)
return base64.URLEncoding.EncodeToString(b)
}
// CreateRefreshToken 创建并存储 refresh token
func (s *AuthService) CreateRefreshToken(userID uint) (string, error) {
token := generateRandomToken()
rt := &model.RefreshToken{
UserID: userID,
Token: token,
ExpiresAt: time.Now().Add(RefreshTokenTTL),
}
if err := s.db.Create(rt).Error; err != nil {
return "", err
}
return token, nil
}
// ValidateRefreshToken 校验 refresh token 并返回所属用户
func (s *AuthService) ValidateRefreshToken(token string) (*model.User, error) {
var rt model.RefreshToken
if err := s.db.Where("token = ?", token).First(&rt).Error; err != nil {
return nil, errors.New("refresh token 无效")
}
if rt.Revoked {
return nil, errors.New("refresh token 已撤销")
}
if time.Now().After(rt.ExpiresAt) {
return nil, errors.New("refresh token 已过期")
}
var user model.User
if err := s.db.First(&user, rt.UserID).Error; err != nil {
return nil, errors.New("用户不存在")
}
if user.Banned {
return nil, errors.New("账号已被封禁")
}
return &user, nil
}
// RotateRefreshToken 轮转 refresh token:撤销旧的,签发新的
func (s *AuthService) RotateRefreshToken(oldToken string) (string, string, *model.User, error) {
user, err := s.ValidateRefreshToken(oldToken)
if err != nil {
return "", "", nil, err
}
// 撤销旧 token
s.db.Model(&model.RefreshToken{}).Where("token = ?", oldToken).Update("revoked", true)
// 签发新 access + refresh
accessToken, err := s.generateToken(user)
if err != nil {
return "", "", nil, err
}
newRefresh, err := s.CreateRefreshToken(user.ID)
if err != nil {
return "", "", nil, err
}
return accessToken, newRefresh, user, nil
}
// RevokeRefreshToken 撤销单个 refresh token(登出时用)
func (s *AuthService) RevokeRefreshToken(token string) {
s.db.Model(&model.RefreshToken{}).Where("token = ?", token).Update("revoked", true)
}
// RevokeAllUserRefreshTokens 撤销用户所有 refresh token(改密码/封禁时用)
func (s *AuthService) RevokeAllUserRefreshTokens(userID uint) {
s.db.Model(&model.RefreshToken{}).Where("user_id = ?", userID).Update("revoked", true)
}
// IncrementTokenVersion 递增用户 token 版本,使所有已有 JWT 失效
// 用于:改密码、封禁用户、管理员强制下线
func (s *AuthService) IncrementTokenVersion(userID uint) error {
result := s.db.Model(&model.User{}).Where("id = ?", userID).UpdateColumn("token_version", gorm.Expr("token_version + 1"))
if result.Error != nil {
return result.Error
}
s.RevokeAllUserRefreshTokens(userID)
return nil
}
// ChangePassword 修改密码:校验旧密码,更新新密码,递增 token_version 使旧 token 失效
func (s *AuthService) ChangePassword(userID uint, oldPassword, newPassword string) error {
var user model.User
if err := s.db.First(&user, userID).Error; err != nil {
return errors.New("用户不存在")
}
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(oldPassword)); err != nil {
return errors.New("旧密码错误")
}
if len(newPassword) < 6 {
return errors.New("新密码至少 6 位")
}
hashed, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
return err
}
result := s.db.Model(&model.User{}).Where("id = ?", userID).Update("password", string(hashed))
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return errors.New("密码更新失败")
}
// 递增 token_version,使所有旧 JWT 和 refresh token 失效
return s.IncrementTokenVersion(userID)
}

31
backend/service/board.go Normal file
View File

@@ -0,0 +1,31 @@
package service
import (
"github.com/freefire/jiang13-bbs/model"
"gorm.io/gorm"
)
// BoardService 板块服务
type BoardService struct {
db *gorm.DB
}
func NewBoardService(db *gorm.DB) *BoardService {
return &BoardService{db: db}
}
// List 获取所有板块(按 sort_order 排序)
func (s *BoardService) List() ([]model.Board, error) {
var boards []model.Board
err := s.db.Order("sort_order ASC, id ASC").Find(&boards).Error
return boards, err
}
// Get 根据 ID 获取板块
func (s *BoardService) Get(id uint) (*model.Board, error) {
var board model.Board
if err := s.db.First(&board, id).Error; err != nil {
return nil, err
}
return &board, nil
}

122
backend/service/comment.go Normal file
View File

@@ -0,0 +1,122 @@
package service
import (
"errors"
"strings"
"time"
"github.com/freefire/jiang13-bbs/model"
"gorm.io/gorm"
)
// CommentService 评论服务
type CommentService struct {
db *gorm.DB
}
func NewCommentService(db *gorm.DB) *CommentService {
return &CommentService{db: db}
}
// ListByPost 获取帖子的评论列表
func (s *CommentService) ListByPost(postID uint) ([]model.Comment, error) {
var comments []model.Comment
err := s.db.Where("post_id = ? AND status = ?", postID, model.ContentStatusPublished).
Order("created_at ASC").
Preload("User").
Find(&comments).Error
return comments, err
}
// Create 创建评论
func (s *CommentService) Create(userID, postID uint, content string) (*model.Comment, error) {
content = strings.TrimSpace(content)
if content == "" {
return nil, errors.New("评论内容不能为空")
}
// 检查帖子是否存在且未锁定评论
var post model.Post
if err := s.db.First(&post, postID).Error; err != nil {
return nil, errors.New("帖子不存在")
}
comment := &model.Comment{
PostID: postID,
UserID: userID,
Content: content,
Status: model.ContentStatusPublished,
}
if err := s.db.Create(comment).Error; err != nil {
return nil, err
}
// 更新帖子评论数
s.db.Model(&post).UpdateColumn("comment_count", gorm.Expr("comment_count + 1"))
// 预加载用户
s.db.Preload("User").First(comment, comment.ID)
return comment, nil
}
// UserCommentItem 用户评论列表项(含帖子标题便于跳转)
type UserCommentItem struct {
ID uint `json:"id"`
PostID uint `json:"post_id"`
PostTitle string `json:"post_title"`
Content string `json:"content"`
CreatedAt time.Time `json:"created_at"`
}
// ListByUser 获取指定用户发表的评论(含所属帖子标题)
func (s *CommentService) ListByUser(userID uint, page, size int) ([]UserCommentItem, int64, error) {
if page < 1 {
page = 1
}
if size < 1 || size > 50 {
size = 20
}
query := s.db.Table("comments").
Select("comments.id, comments.post_id, posts.title AS post_title, comments.content, comments.created_at").
Joins("JOIN posts ON posts.id = comments.post_id").
Where("comments.user_id = ? AND comments.status = ? AND posts.deleted_at IS NULL", userID, model.ContentStatusPublished)
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
var items []UserCommentItem
offset := (page - 1) * size
if err := query.Order("comments.created_at DESC").Offset(offset).Limit(size).Scan(&items).Error; err != nil {
return nil, 0, err
}
return items, total, nil
}
// CountByUser 统计用户评论数(仅统计未删除帖子上的评论,与列表保持一致)
func (s *CommentService) CountByUser(userID uint) (int64, error) {
var total int64
err := s.db.Table("comments").
Joins("JOIN posts ON posts.id = comments.post_id").
Where("comments.user_id = ? AND comments.status = ? AND posts.deleted_at IS NULL", userID, model.ContentStatusPublished).
Count(&total).Error
return total, err
}
// Delete 删除评论(仅作者或管理员可操作)
func (s *CommentService) Delete(commentID, userID uint, role string) error {
var comment model.Comment
if err := s.db.First(&comment, commentID).Error; err != nil {
return errors.New("评论不存在")
}
// 权限校验:作者本人或管理员
if comment.UserID != userID && role != RoleAdmin {
return errors.New("无权限删除此评论")
}
// 软删除
if err := s.db.Delete(&comment).Error; err != nil {
return err
}
// 更新帖子评论数
s.db.Model(&model.Post{}).Where("id = ?", comment.PostID).
UpdateColumn("comment_count", gorm.Expr("GREATEST(comment_count - 1, 0)"))
return nil
}

77
backend/service/like.go Normal file
View File

@@ -0,0 +1,77 @@
package service
import (
"errors"
"github.com/freefire/jiang13-bbs/model"
"gorm.io/gorm"
)
// LikeService 点赞服务
type LikeService struct {
db *gorm.DB
}
func NewLikeService(db *gorm.DB) *LikeService {
return &LikeService{db: db}
}
// Toggle 切换点赞状态:已点赞则取消,未点赞则点赞
// 返回 (liked, likeCount, error)
func (s *LikeService) Toggle(postID, userID uint) (bool, int, error) {
// 检查帖子是否存在
var post model.Post
if err := s.db.First(&post, postID).Error; err != nil {
return false, 0, errors.New("帖子不存在")
}
var like model.Like
err := s.db.Where("post_id = ? AND user_id = ?", postID, userID).First(&like).Error
if err == nil {
// 已点赞 → 取消点赞
if err := s.db.Delete(&like).Error; err != nil {
return false, post.LikeCount, err
}
newCount := post.LikeCount - 1
if newCount < 0 {
newCount = 0
}
s.db.Model(&post).UpdateColumn("like_count", newCount)
return false, newCount, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return false, post.LikeCount, err
}
// 未点赞 → 点赞
newLike := model.Like{PostID: postID, UserID: userID}
if err := s.db.Create(&newLike).Error; err != nil {
return false, post.LikeCount, err
}
newCount := post.LikeCount + 1
s.db.Model(&post).UpdateColumn("like_count", newCount)
return true, newCount, nil
}
// HasLiked 判断用户是否已点赞某帖子
func (s *LikeService) HasLiked(postID, userID uint) bool {
var count int64
s.db.Model(&model.Like{}).Where("post_id = ? AND user_id = ?", postID, userID).Count(&count)
return count > 0
}
// BatchHasLiked 批量查询用户对多个帖子的点赞状态,返回 postID -> bool
func (s *LikeService) BatchHasLiked(postIDs []uint, userID uint) map[uint]bool {
result := make(map[uint]bool)
if len(postIDs) == 0 || userID == 0 {
return result
}
var likes []model.Like
s.db.Where("post_id IN ? AND user_id = ?", postIDs, userID).Find(&likes)
for _, l := range likes {
result[l.PostID] = true
}
return result
}

View File

@@ -0,0 +1,82 @@
package service
import (
"github.com/freefire/jiang13-bbs/model"
"gorm.io/gorm"
)
// NotificationService 通知服务
type NotificationService struct {
db *gorm.DB
}
func NewNotificationService(db *gorm.DB) *NotificationService {
return &NotificationService{db: db}
}
// Create 创建通知(actorID == userID 时跳过,不通知自己)
func (s *NotificationService) Create(userID, actorID uint, notifType string, postID, commentID uint, content string) {
if userID == 0 || actorID == 0 || userID == actorID {
return
}
// 截断内容预览
if len(content) > 200 {
content = content[:200]
}
n := &model.Notification{
UserID: userID,
ActorID: actorID,
Type: notifType,
PostID: postID,
CommentID: commentID,
Content: content,
IsRead: false,
}
// 通知创建失败不影响主流程,忽略错误
_ = s.db.Create(n).Error
}
// List 获取用户通知列表(分页,按时间倒序)
func (s *NotificationService) List(userID uint, page, size int) ([]model.Notification, int64, error) {
if page < 1 {
page = 1
}
if size < 1 || size > 50 {
size = 20
}
query := s.db.Model(&model.Notification{}).Where("user_id = ?", userID)
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
var list []model.Notification
offset := (page - 1) * size
if err := query.Order("created_at DESC").Offset(offset).Limit(size).
Preload("Actor").Preload("Post").Find(&list).Error; err != nil {
return nil, 0, err
}
return list, total, nil
}
// UnreadCount 获取未读通知数
func (s *NotificationService) UnreadCount(userID uint) (int64, error) {
var total int64
err := s.db.Model(&model.Notification{}).Where("user_id = ? AND is_read = ?", userID, false).Count(&total).Error
return total, err
}
// MarkRead 标记单条通知为已读(校验归属)
func (s *NotificationService) MarkRead(id, userID uint) error {
result := s.db.Model(&model.Notification{}).
Where("id = ? AND user_id = ?", id, userID).
Update("is_read", true)
return result.Error
}
// MarkAllRead 标记用户所有通知为已读
func (s *NotificationService) MarkAllRead(userID uint) error {
result := s.db.Model(&model.Notification{}).
Where("user_id = ? AND is_read = ?", userID, false).
Update("is_read", true)
return result.Error
}

287
backend/service/post.go Normal file
View File

@@ -0,0 +1,287 @@
package service
import (
"errors"
"strings"
"time"
"github.com/freefire/jiang13-bbs/model"
"gorm.io/gorm"
)
// PostService 帖子服务
type PostService struct {
db *gorm.DB
}
func NewPostService(db *gorm.DB) *PostService {
return &PostService{db: db}
}
// PostListQuery 帖子列表查询参数
type PostListQuery struct {
BoardID uint
Page int
Size int
Sort string // latest | hot
Keyword string // 搜索关键词
}
// PostListItem 帖子列表项(不含正文)
type PostListItem struct {
ID uint `json:"id"`
BoardID uint `json:"board_id"`
UserID uint `json:"user_id"`
Title string `json:"title"`
Tags string `json:"tags"`
PostType string `json:"post_type"`
Pinned int `json:"pinned"`
Recommended bool `json:"recommended"`
LikeCount int `json:"like_count"`
ViewCount int `json:"view_count"`
CommentCount int `json:"comment_count"`
Liked bool `json:"liked"`
CreatedAt time.Time `json:"created_at"`
Board model.Board `json:"board"`
User model.User `json:"user"`
}
// List 获取帖子列表
func (s *PostService) List(q PostListQuery) ([]PostListItem, int64, error) {
if q.Page < 1 {
q.Page = 1
}
if q.Size < 1 || q.Size > 50 {
q.Size = 20
}
query := s.db.Model(&model.Post{}).Where("status = ?", model.ContentStatusPublished)
if q.BoardID > 0 {
query = query.Where("board_id = ?", q.BoardID)
}
if q.Keyword != "" {
kw := "%" + q.Keyword + "%"
query = query.Where("title ILIKE ? OR content ILIKE ? OR tags ILIKE ?", kw, kw, kw)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
var posts []model.Post
offset := (q.Page - 1) * q.Size
order := "pinned DESC, created_at DESC"
if q.Sort == "hot" {
order = "pinned DESC, (like_count + comment_count * 2) DESC, created_at DESC"
}
if err := query.Order(order).Offset(offset).Limit(q.Size).
Preload("Board").Preload("User").Find(&posts).Error; err != nil {
return nil, 0, err
}
items := make([]PostListItem, 0, len(posts))
for _, p := range posts {
items = append(items, PostListItem{
ID: p.ID, BoardID: p.BoardID, UserID: p.UserID,
Title: p.Title, Tags: p.Tags, PostType: p.PostType,
Pinned: p.Pinned, Recommended: p.Recommended, LikeCount: p.LikeCount, ViewCount: p.ViewCount,
CommentCount: p.CommentCount, CreatedAt: p.CreatedAt,
Board: p.Board, User: p.User,
})
}
return items, total, nil
}
// ListByUser 获取指定用户发布的帖子(公开已发布)
func (s *PostService) ListByUser(userID uint, page, size int) ([]PostListItem, int64, error) {
if page < 1 {
page = 1
}
if size < 1 || size > 50 {
size = 20
}
query := s.db.Model(&model.Post{}).Where("user_id = ? AND status = ?", userID, model.ContentStatusPublished)
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
var posts []model.Post
offset := (page - 1) * size
if err := query.Order("pinned DESC, created_at DESC").Offset(offset).Limit(size).
Preload("Board").Preload("User").Find(&posts).Error; err != nil {
return nil, 0, err
}
items := make([]PostListItem, 0, len(posts))
for _, p := range posts {
items = append(items, PostListItem{
ID: p.ID, BoardID: p.BoardID, UserID: p.UserID,
Title: p.Title, Tags: p.Tags, PostType: p.PostType,
Pinned: p.Pinned, Recommended: p.Recommended, LikeCount: p.LikeCount, ViewCount: p.ViewCount,
CommentCount: p.CommentCount, CreatedAt: p.CreatedAt,
Board: p.Board, User: p.User,
})
}
return items, total, nil
}
// GetAuthorID 获取帖子作者 ID(不增加浏览量)
func (s *PostService) GetAuthorID(postID uint) (uint, error) {
var post model.Post
if err := s.db.Select("user_id").First(&post, postID).Error; err != nil {
return 0, err
}
return post.UserID, nil
}
// CountByUser 统计用户发帖数
func (s *PostService) CountByUser(userID uint) (int64, error) {
var total int64
err := s.db.Model(&model.Post{}).Where("user_id = ? AND status = ?", userID, model.ContentStatusPublished).Count(&total).Error
return total, err
}
// TogglePin 切换置顶状态(仅管理员可操作,由 handler 校验权限)
func (s *PostService) TogglePin(id uint) (int, error) {
var post model.Post
if err := s.db.First(&post, id).Error; err != nil {
return 0, err
}
var newPinned int
if post.Pinned > 0 {
newPinned = 0
} else {
newPinned = 1
}
result := s.db.Model(&post).Update("pinned", newPinned)
if result.Error != nil {
return 0, result.Error
}
return newPinned, nil
}
// ToggleRecommend 切换推荐状态(仅管理员可操作,由 handler 校验权限)
func (s *PostService) ToggleRecommend(id uint) (bool, error) {
var post model.Post
if err := s.db.First(&post, id).Error; err != nil {
return false, err
}
newVal := !post.Recommended
result := s.db.Model(&post).Update("recommended", newVal)
if result.Error != nil {
return false, result.Error
}
return newVal, nil
}
// GetByID 获取帖子详情
func (s *PostService) GetByID(id uint) (*model.Post, error) {
var post model.Post
if err := s.db.Preload("Board").Preload("User").First(&post, id).Error; err != nil {
return nil, err
}
// 增加浏览量
s.db.Model(&post).UpdateColumn("view_count", gorm.Expr("view_count + 1"))
return &post, nil
}
// Create 创建帖子
func (s *PostService) Create(userID uint, boardID uint, title, content, tags, postType string) (*model.Post, error) {
title = strings.TrimSpace(title)
content = strings.TrimSpace(content)
if title == "" {
return nil, errors.New("标题不能为空")
}
if content == "" {
return nil, errors.New("内容不能为空")
}
if boardID == 0 {
return nil, errors.New("请选择板块")
}
// 新用户 24h 冷静期校验
if err := s.checkNewUserCooldown(userID); err != nil {
return nil, err
}
post := &model.Post{
BoardID: boardID,
UserID: userID,
Title: title,
Content: content,
Tags: tags,
PostType: postType,
Status: model.ContentStatusPublished,
}
if err := s.db.Create(post).Error; err != nil {
return nil, err
}
// 预加载关联
s.db.Preload("Board").Preload("User").First(post, post.ID)
return post, nil
}
// checkNewUserCooldown 新用户发帖 24h 冷静期
func (s *PostService) checkNewUserCooldown(userID uint) error {
var user model.User
if err := s.db.First(&user, userID).Error; err != nil {
return err
}
// 注册不足 24 小时的新用户不能发帖
if time.Since(user.CreatedAt) < 24*time.Hour {
return errors.New("新用户注册 24 小时后才能发帖")
}
return nil
}
// Update 更新帖子(仅作者或管理员可操作)
func (s *PostService) Update(postID, userID uint, role string, title, content, tags string) (*model.Post, error) {
var post model.Post
if err := s.db.First(&post, postID).Error; err != nil {
return nil, errors.New("帖子不存在")
}
// 权限校验:作者本人或管理员
if post.UserID != userID && role != RoleAdmin {
return nil, errors.New("无权限编辑此帖子")
}
updates := map[string]interface{}{}
if title != "" {
t := strings.TrimSpace(title)
if t == "" {
return nil, errors.New("标题不能为空")
}
updates["title"] = t
}
if content != "" {
c := strings.TrimSpace(content)
if c == "" {
return nil, errors.New("内容不能为空")
}
updates["content"] = c
}
updates["tags"] = tags
if err := s.db.Model(&post).Updates(updates).Error; err != nil {
return nil, err
}
s.db.Preload("Board").Preload("User").First(&post, post.ID)
return &post, nil
}
// Delete 删除帖子(仅作者或管理员可操作)
func (s *PostService) Delete(postID, userID uint, role string) error {
var post model.Post
if err := s.db.First(&post, postID).Error; err != nil {
return errors.New("帖子不存在")
}
// 权限校验:作者本人或管理员
if post.UserID != userID && role != RoleAdmin {
return errors.New("无权限删除此帖子")
}
// 软删除(gorm DeletedAt)
if err := s.db.Delete(&post).Error; err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,79 @@
package service
import (
"sync"
"time"
)
// RateLimiter 内存速率限制器
type RateLimiter struct {
mu sync.Mutex
records map[string][]time.Time
limits map[string]int // key -> 每分钟最大请求数
cooldown map[string]time.Duration
}
func NewRateLimiter() *RateLimiter {
return &RateLimiter{
records: make(map[string][]time.Time),
limits: make(map[string]int),
cooldown: make(map[string]time.Duration),
}
}
// SetLimit 设置某类请求的每分钟限制
func (r *RateLimiter) SetLimit(key string, perMinute int) {
r.mu.Lock()
defer r.mu.Unlock()
r.limits[key] = perMinute
}
// Allow 检查是否允许请求
func (r *RateLimiter) Allow(key string) bool {
r.mu.Lock()
defer r.mu.Unlock()
limit, ok := r.limits[key]
if !ok {
return true
}
now := time.Now()
windowStart := now.Add(-1 * time.Minute)
// 清理过期记录
records := r.records[key]
valid := records[:0]
for _, t := range records {
if t.After(windowStart) {
valid = append(valid, t)
}
}
if len(valid) >= limit {
r.records[key] = valid
return false
}
valid = append(valid, now)
r.records[key] = valid
return true
}
// 速率限制类型常量
const (
RateLogin = "login"
RateRegister = "register"
RatePost = "post"
RateComment = "comment"
)
// DefaultRateLimiter 创建默认速率限制器
func DefaultRateLimiter() *RateLimiter {
rl := NewRateLimiter()
rl.SetLimit(RateLogin, 20) // 登录 20/分钟
rl.SetLimit(RateRegister, 10) // 注册 10/分钟
rl.SetLimit(RatePost, 10) // 发帖 10/分钟
rl.SetLimit(RateComment, 30) // 评论 30/分钟
return rl
}