From 0c2b0d2d6a81237f52fcb3188644887c9785cd41 Mon Sep 17 00:00:00 2001 From: freefire Date: Sat, 12 Sep 2026 01:11:05 +0800 Subject: [PATCH] =?UTF-8?q?=E9=A6=96=E6=AC=A1=E6=8F=90=E4=BA=A4=EF=BC=9A?= =?UTF-8?q?=E5=A7=9C=E5=8D=81=E4=B8=89=E8=AE=BA=E5=9D=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端 Go+Gin:认证/CSRF/限流、板块、帖子、评论、点赞、通知、用户资料、置顶推荐;前端 Next.js 16:发帖/编辑/删除、搜索、分页、点赞、通知中心、设置;基础设施 docker-compose 与配置模板;添加 .gitignore 与专有许可证(保留所有权利) --- .env.example | 14 + .gitignore | 53 + LICENSE | 38 + backend/app.ini.example | 16 + backend/cmd/jiang13/main.go | 32 + backend/config/config.go | 112 + backend/go.mod | 49 + backend/go.sum | 121 + backend/handler/auth.go | 213 ++ backend/handler/board.go | 17 + backend/handler/comment.go | 70 + backend/handler/handlers.go | 17 + backend/handler/health.go | 17 + backend/handler/like.go | 32 + backend/handler/notification.go | 64 + backend/handler/post.go | 179 ++ backend/handler/seo.go | 79 + backend/handler/user.go | 96 + backend/middleware/auth.go | 100 + backend/middleware/csrf.go | 34 + backend/middleware/ratelimit.go | 20 + backend/middleware/security.go | 43 + backend/model/db.go | 63 + backend/model/models.go | 136 + backend/router/router.go | 114 + backend/service/auth.go | 284 ++ backend/service/board.go | 31 + backend/service/comment.go | 122 + backend/service/like.go | 77 + backend/service/notification.go | 82 + backend/service/post.go | 287 ++ backend/service/ratelimit.go | 79 + docker-compose.yml | 21 + frontend/app/board/[id]/page.tsx | 83 + frontend/app/compose/page.tsx | 122 + frontend/app/globals.css | 40 + frontend/app/layout.tsx | 36 + frontend/app/login/page.tsx | 77 + frontend/app/notifications/page.tsx | 131 + frontend/app/page.tsx | 105 + frontend/app/post/[id]/edit/page.tsx | 137 + frontend/app/post/[id]/page.tsx | 98 + frontend/app/register/page.tsx | 88 + frontend/app/robots.ts | 11 + frontend/app/settings/page.tsx | 139 + frontend/app/sitemap.ts | 35 + frontend/app/u/[id]/UserProfileClient.tsx | 148 + frontend/app/u/[id]/page.tsx | 93 + frontend/components/CommentSection.tsx | 121 + frontend/components/Footer.tsx | 7 + frontend/components/Header.tsx | 151 ++ frontend/components/LikeButton.tsx | 50 + frontend/components/NotificationBell.tsx | 197 ++ frontend/components/Pagination.tsx | 95 + frontend/components/PostActions.tsx | 117 + frontend/lib/api.ts | 395 +++ frontend/next-env.d.ts | 7 + frontend/next.config.ts | 20 + frontend/package-lock.json | 3000 +++++++++++++++++++++ frontend/package.json | 26 + frontend/postcss.config.mjs | 7 + frontend/tsconfig.json | 41 + 62 files changed, 8289 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 backend/app.ini.example create mode 100644 backend/cmd/jiang13/main.go create mode 100644 backend/config/config.go create mode 100644 backend/go.mod create mode 100644 backend/go.sum create mode 100644 backend/handler/auth.go create mode 100644 backend/handler/board.go create mode 100644 backend/handler/comment.go create mode 100644 backend/handler/handlers.go create mode 100644 backend/handler/health.go create mode 100644 backend/handler/like.go create mode 100644 backend/handler/notification.go create mode 100644 backend/handler/post.go create mode 100644 backend/handler/seo.go create mode 100644 backend/handler/user.go create mode 100644 backend/middleware/auth.go create mode 100644 backend/middleware/csrf.go create mode 100644 backend/middleware/ratelimit.go create mode 100644 backend/middleware/security.go create mode 100644 backend/model/db.go create mode 100644 backend/model/models.go create mode 100644 backend/router/router.go create mode 100644 backend/service/auth.go create mode 100644 backend/service/board.go create mode 100644 backend/service/comment.go create mode 100644 backend/service/like.go create mode 100644 backend/service/notification.go create mode 100644 backend/service/post.go create mode 100644 backend/service/ratelimit.go create mode 100644 docker-compose.yml create mode 100644 frontend/app/board/[id]/page.tsx create mode 100644 frontend/app/compose/page.tsx create mode 100644 frontend/app/globals.css create mode 100644 frontend/app/layout.tsx create mode 100644 frontend/app/login/page.tsx create mode 100644 frontend/app/notifications/page.tsx create mode 100644 frontend/app/page.tsx create mode 100644 frontend/app/post/[id]/edit/page.tsx create mode 100644 frontend/app/post/[id]/page.tsx create mode 100644 frontend/app/register/page.tsx create mode 100644 frontend/app/robots.ts create mode 100644 frontend/app/settings/page.tsx create mode 100644 frontend/app/sitemap.ts create mode 100644 frontend/app/u/[id]/UserProfileClient.tsx create mode 100644 frontend/app/u/[id]/page.tsx create mode 100644 frontend/components/CommentSection.tsx create mode 100644 frontend/components/Footer.tsx create mode 100644 frontend/components/Header.tsx create mode 100644 frontend/components/LikeButton.tsx create mode 100644 frontend/components/NotificationBell.tsx create mode 100644 frontend/components/Pagination.tsx create mode 100644 frontend/components/PostActions.tsx create mode 100644 frontend/lib/api.ts create mode 100644 frontend/next-env.d.ts create mode 100644 frontend/next.config.ts create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.mjs create mode 100644 frontend/tsconfig.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..bac536f --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# jiang13-bbs 环境变量示例 +# 复制为 .env 并修改 + +# PostgreSQL 连接串 +DB_DSN=postgres://postgres:postgres@localhost:5432/jiang13?sslmode=disable + +# JWT 签名密钥(生产环境务必修改) +JWT_SECRET=your-jwt-secret-change-me + +# 服务端口 +HTTP_PORT=3001 + +# 开发模式 +DEV_MODE=true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..571ac40 --- /dev/null +++ b/.gitignore @@ -0,0 +1,53 @@ +# ===== 依赖目录 ===== +node_modules/ +frontend/node_modules/ + +# ===== Next.js 构建产物 ===== +.next/ +out/ +.vercel/ +*.tsbuildinfo +next-debug.log* + +# ===== Go 构建产物 ===== +*.exe +*.exe~ +*.dll +*.so +*.dylib +*.test +*.out +/backend/jiang13 +/backend/jiang13.exe + +# ===== 本地配置与运行时数据(含密钥,禁止提交) ===== +.env +.env.local +.env.*.local +backend/app.ini +backend/data/ +*.pem +*.key + +# ===== 日志 ===== +*.log +logs/ + +# ===== 测试与覆盖率 ===== +coverage/ +.nyc_output/ + +# ===== IDE / 编辑器 ===== +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# ===== 操作系统 ===== +.DS_Store +Thumbs.db +desktop.ini + +# ===== Trae 工具内部目录 ===== +.trae/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fb5d833 --- /dev/null +++ b/LICENSE @@ -0,0 +1,38 @@ +姜十三论坛 专有软件许可协议 +Proprietary Software License Agreement + +Copyright (c) 2026 姜十三. All Rights Reserved. +版权所有 (c) 2026 姜十三。保留所有权利。 + +本软件(包括但不限于源代码、目标代码、文档、图像及其他随附资料, +以下统称"本软件")由版权方(以下称"授权方")开发并所有。本软件 +以源码可见的方式发布,但这并不构成任何形式的开源授权。 + +在未获得授权方事先书面许可之前,任何个人或组织不得: + +1. 以任何目的(包括商业、内部生产、教学培训等)使用、运行或部署 + 本软件的全部或任何部分; +2. 复制、影印、转载、镜像、收录到任何数据库或以任何方式再现本软件; +3. 修改、改编、翻译、合并、反向工程、反编译、反汇编本软件,或基于 + 本软件创作衍生作品; +4. 出售、出租、出借、分销、再许可、托管(SaaS)或以其他方式向第三方 + 提供本软件或其任何副本; +5. 删除、遮盖或更改本软件中的版权声明、商标或其他权利声明。 + +仅可出于个人学习、评估是否购买授权的目的浏览本仓库中的源代码;该等 +浏览不视为获得任何明示或默示的使用许可。 + +商业使用、内部部署或其他任何形式的使用授权,均须与授权方另行签订 +书面许可协议。 + +第三方开源组件按其各自附带的许可证条款授权,不受本协议约束。 + +本软件按"现状"(AS IS)提供,不附带任何明示或默示的担保,包括但不限 +于适销性、特定用途适用性或不侵权的担保。在任何情况下,授权方均不对因 +使用或无法使用本软件而导致的任何直接、间接、附带、特殊或后果性损害 +承担责任。 + +未经授权使用本软件的行为,均构成对授权方著作权及其他知识产权的侵犯, +授权方保留依法追究法律责任的权利。 + +授权方联系方式:aarbbs@88.com diff --git a/backend/app.ini.example b/backend/app.ini.example new file mode 100644 index 0000000..b3c32b6 --- /dev/null +++ b/backend/app.ini.example @@ -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 diff --git a/backend/cmd/jiang13/main.go b/backend/cmd/jiang13/main.go new file mode 100644 index 0000000..67c0b7b --- /dev/null +++ b/backend/cmd/jiang13/main.go @@ -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) + } +} diff --git a/backend/config/config.go b/backend/config/config.go new file mode 100644 index 0000000..1553587 --- /dev/null +++ b/backend/config/config.go @@ -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) +} diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..721924c --- /dev/null +++ b/backend/go.mod @@ -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 +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..1581348 --- /dev/null +++ b/backend/go.sum @@ -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= diff --git a/backend/handler/auth.go b/backend/handler/auth.go new file mode 100644 index 0000000..15ef5eb --- /dev/null +++ b/backend/handler/auth.go @@ -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, + }, + }) +} diff --git a/backend/handler/board.go b/backend/handler/board.go new file mode 100644 index 0000000..36801b6 --- /dev/null +++ b/backend/handler/board.go @@ -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}) +} diff --git a/backend/handler/comment.go b/backend/handler/comment.go new file mode 100644 index 0000000..939a0f1 --- /dev/null +++ b/backend/handler/comment.go @@ -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": "已删除"}) +} diff --git a/backend/handler/handlers.go b/backend/handler/handlers.go new file mode 100644 index 0000000..a97c632 --- /dev/null +++ b/backend/handler/handlers.go @@ -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 +} diff --git a/backend/handler/health.go b/backend/handler/health.go new file mode 100644 index 0000000..825bf20 --- /dev/null +++ b/backend/handler/health.go @@ -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"}) +} diff --git a/backend/handler/like.go b/backend/handler/like.go new file mode 100644 index 0000000..d81ca0d --- /dev/null +++ b/backend/handler/like.go @@ -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}) +} diff --git a/backend/handler/notification.go b/backend/handler/notification.go new file mode 100644 index 0000000..82fb432 --- /dev/null +++ b/backend/handler/notification.go @@ -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": "已全部标记为已读"}) +} diff --git a/backend/handler/post.go b/backend/handler/post.go new file mode 100644 index 0000000..1dec8a9 --- /dev/null +++ b/backend/handler/post.go @@ -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}) +} diff --git a/backend/handler/seo.go b/backend/handler/seo.go new file mode 100644 index 0000000..e0a2b69 --- /dev/null +++ b/backend/handler/seo.go @@ -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)) +} diff --git a/backend/handler/user.go b/backend/handler/user.go new file mode 100644 index 0000000..31e83c2 --- /dev/null +++ b/backend/handler/user.go @@ -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, + }) +} diff --git a/backend/middleware/auth.go b/backend/middleware/auth.go new file mode 100644 index 0000000..c5f0d43 --- /dev/null +++ b/backend/middleware/auth.go @@ -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 +} diff --git a/backend/middleware/csrf.go b/backend/middleware/csrf.go new file mode 100644 index 0000000..be52ef8 --- /dev/null +++ b/backend/middleware/csrf.go @@ -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() + } +} diff --git a/backend/middleware/ratelimit.go b/backend/middleware/ratelimit.go new file mode 100644 index 0000000..f183a59 --- /dev/null +++ b/backend/middleware/ratelimit.go @@ -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() + } +} diff --git a/backend/middleware/security.go b/backend/middleware/security.go new file mode 100644 index 0000000..600ee82 --- /dev/null +++ b/backend/middleware/security.go @@ -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() + } +} diff --git a/backend/model/db.go b/backend/model/db.go new file mode 100644 index 0000000..4cadbbf --- /dev/null +++ b/backend/model/db.go @@ -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 + } + } +} diff --git a/backend/model/models.go b/backend/model/models.go new file mode 100644 index 0000000..288d07f --- /dev/null +++ b/backend/model/models.go @@ -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"` +} diff --git a/backend/router/router.go b/backend/router/router.go new file mode 100644 index 0000000..afa8dc8 --- /dev/null +++ b/backend/router/router.go @@ -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 +} diff --git a/backend/service/auth.go b/backend/service/auth.go new file mode 100644 index 0000000..2963fae --- /dev/null +++ b/backend/service/auth.go @@ -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) +} diff --git a/backend/service/board.go b/backend/service/board.go new file mode 100644 index 0000000..4ce912a --- /dev/null +++ b/backend/service/board.go @@ -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 +} diff --git a/backend/service/comment.go b/backend/service/comment.go new file mode 100644 index 0000000..edf566a --- /dev/null +++ b/backend/service/comment.go @@ -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 +} diff --git a/backend/service/like.go b/backend/service/like.go new file mode 100644 index 0000000..c1b89b0 --- /dev/null +++ b/backend/service/like.go @@ -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 +} diff --git a/backend/service/notification.go b/backend/service/notification.go new file mode 100644 index 0000000..bd97858 --- /dev/null +++ b/backend/service/notification.go @@ -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 +} diff --git a/backend/service/post.go b/backend/service/post.go new file mode 100644 index 0000000..37c38cb --- /dev/null +++ b/backend/service/post.go @@ -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 +} diff --git a/backend/service/ratelimit.go b/backend/service/ratelimit.go new file mode 100644 index 0000000..1fa0ad4 --- /dev/null +++ b/backend/service/ratelimit.go @@ -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 +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1e652f6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,21 @@ +services: + postgres: + image: postgres:17-alpine + container_name: jiang13-postgres + restart: unless-stopped + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: jiang13 + ports: + - "5432:5432" + volumes: + - jiang13-pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d jiang13"] + interval: 5s + timeout: 5s + retries: 5 + +volumes: + jiang13-pgdata: diff --git a/frontend/app/board/[id]/page.tsx b/frontend/app/board/[id]/page.tsx new file mode 100644 index 0000000..e6504a4 --- /dev/null +++ b/frontend/app/board/[id]/page.tsx @@ -0,0 +1,83 @@ +import Link from "next/link"; +import { Metadata } from "next"; +import { fetchPosts, fetchBoards } from "@/lib/api"; +import Pagination from "@/components/Pagination"; + +interface PageProps { + params: Promise<{ id: string }>; + searchParams: Promise<{ page?: string }>; +} + +export async function generateMetadata({ params }: PageProps): Promise { + const { id } = await params; + const { boards } = await fetchBoards(); + const board = boards.find((b) => b.id === Number(id)); + return { + title: board ? `${board.name} - 姜十三论坛` : "板块 - 姜十三论坛", + description: board?.description || "板块帖子列表", + }; +} + +export default async function BoardPage({ params, searchParams }: PageProps) { + const { id } = await params; + const { page: pageStr } = await searchParams; + const page = Math.max(1, parseInt(pageStr || "1", 10) || 1); + const [postsData, boardsData] = await Promise.all([ + fetchPosts(page, 20, Number(id)), + fetchBoards(), + ]); + const board = boardsData.boards.find((b) => b.id === Number(id)); + + return ( +
+ + +

{board?.name}

+

{board?.description}

+ +
+ {postsData.posts.map((post) => ( + +
+ {post.pinned > 0 && ( + 置顶 + )} + {post.recommended && ( + 推荐 + )} +
+

{post.title}

+
+ + {post.user.nickname} + + {new Date(post.created_at).toLocaleDateString("zh-CN")} + 💬 {post.comment_count} + 👁 {post.view_count} +
+ + ))} + {postsData.posts.length === 0 && ( +

+ 该板块暂无帖子 +

+ )} +
+ +
+ ); +} diff --git a/frontend/app/compose/page.tsx b/frontend/app/compose/page.tsx new file mode 100644 index 0000000..f160c2d --- /dev/null +++ b/frontend/app/compose/page.tsx @@ -0,0 +1,122 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { apiCreatePost, apiMe, fetchBoards, type Board } from "@/lib/api"; + +export default function ComposePage() { + const router = useRouter(); + const [boards, setBoards] = useState([]); + const [boardId, setBoardId] = useState(0); + const [title, setTitle] = useState(""); + const [content, setContent] = useState(""); + const [tags, setTags] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + // 通过 /api/me 检查登录态(HttpOnly cookie 自动携带) + apiMe() + .then((res) => { + if (!res.user) { + router.push("/login?redirect=/compose"); + return; + } + fetchBoards().then((data) => { + setBoards(data.boards); + if (data.boards.length > 0) setBoardId(data.boards[0].id); + }); + }) + .catch(() => router.push("/login?redirect=/compose")); + }, [router]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(""); + try { + const res = await apiCreatePost({ + board_id: boardId, + title, + content, + tags, + post_type: "normal", + }); + if (res.post) { + router.push(`/post/${res.post.id}`); + } else { + setError(res.error || "发帖失败"); + } + } finally { + setLoading(false); + } + }; + + return ( +
+

发布新帖

+
+ {error && ( +
{error}
+ )} +
+ + +
+
+ + setTitle(e.target.value)} + required + maxLength={256} + placeholder="帖子标题" + className="w-full px-3 py-2 rounded-lg border" + style={{ borderColor: "var(--border)", background: "var(--bg)", color: "var(--text)" }} + /> +
+
+ + setTags(e.target.value)} + placeholder="例如:技术,Go" + className="w-full px-3 py-2 rounded-lg border" + style={{ borderColor: "var(--border)", background: "var(--bg)", color: "var(--text)" }} + /> +
+
+ +