首次提交:姜十三论坛
后端 Go+Gin:认证/CSRF/限流、板块、帖子、评论、点赞、通知、用户资料、置顶推荐;前端 Next.js 16:发帖/编辑/删除、搜索、分页、点赞、通知中心、设置;基础设施 docker-compose 与配置模板;添加 .gitignore 与专有许可证(保留所有权利)
This commit is contained in:
16
backend/app.ini.example
Normal file
16
backend/app.ini.example
Normal file
@@ -0,0 +1,16 @@
|
||||
[server]
|
||||
HTTP_PORT = 3001
|
||||
|
||||
[database]
|
||||
; PostgreSQL 连接串(部署时请修改账号密码)
|
||||
DSN = postgres://postgres:postgres@localhost:5432/jiang13?sslmode=disable
|
||||
|
||||
[security]
|
||||
; 留空则自动生成并持久化到 data/.jwt_secret;生产环境务必显式指定强随机值
|
||||
JWT_SECRET =
|
||||
|
||||
[paths]
|
||||
DATA = data
|
||||
|
||||
[app]
|
||||
DEV_MODE = true
|
||||
32
backend/cmd/jiang13/main.go
Normal file
32
backend/cmd/jiang13/main.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/config"
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"github.com/freefire/jiang13-bbs/router"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg, err := config.Parse()
|
||||
if err != nil {
|
||||
log.Fatalf("配置解析失败: %v", err)
|
||||
}
|
||||
|
||||
if err := model.InitDB(cfg.DBDSN); err != nil {
|
||||
log.Fatalf("数据库初始化失败: %v", err)
|
||||
}
|
||||
|
||||
r, err := router.Setup(cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("路由初始化失败: %v", err)
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf(":%d", cfg.Port)
|
||||
log.Printf("[jiang13-bbs] 服务启动于 %s (dev=%v)", addr, cfg.DevMode)
|
||||
if err := r.Run(addr); err != nil {
|
||||
log.Fatalf("服务启动失败: %v", err)
|
||||
}
|
||||
}
|
||||
112
backend/config/config.go
Normal file
112
backend/config/config.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/ini.v1"
|
||||
)
|
||||
|
||||
// Config 应用全局配置
|
||||
type Config struct {
|
||||
WorkPath string
|
||||
Port int
|
||||
DataDir string
|
||||
JWTSecret string
|
||||
DBDSN string
|
||||
DevMode bool
|
||||
}
|
||||
|
||||
// Parse 解析配置:环境变量 > app.ini > 默认值
|
||||
func Parse() (*Config, error) {
|
||||
workPath, err := os.Getwd()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取工作目录失败: %w", err)
|
||||
}
|
||||
|
||||
// 默认值
|
||||
cfg := &Config{
|
||||
WorkPath: workPath,
|
||||
Port: 3001,
|
||||
DataDir: filepath.Join(workPath, "data"),
|
||||
DBDSN: "postgres://postgres:postgres@localhost:5432/jiang13?sslmode=disable",
|
||||
DevMode: true,
|
||||
}
|
||||
|
||||
// 尝试加载 app.ini
|
||||
iniPath := filepath.Join(workPath, "app.ini")
|
||||
if _, err := os.Stat(iniPath); err == nil {
|
||||
f, err := ini.Load(iniPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("加载 app.ini 失败: %w", err)
|
||||
}
|
||||
if s := f.Section("server"); s.HasKey("HTTP_PORT") {
|
||||
if v, err := s.Key("HTTP_PORT").Int(); err == nil {
|
||||
cfg.Port = v
|
||||
}
|
||||
}
|
||||
if s := f.Section("database"); s.HasKey("DSN") {
|
||||
cfg.DBDSN = s.Key("DSN").String()
|
||||
}
|
||||
if s := f.Section("security"); s.HasKey("JWT_SECRET") {
|
||||
cfg.JWTSecret = s.Key("JWT_SECRET").String()
|
||||
}
|
||||
if s := f.Section("paths"); s.HasKey("DATA") {
|
||||
dataRel := s.Key("DATA").String()
|
||||
if !filepath.IsAbs(dataRel) {
|
||||
cfg.DataDir = filepath.Join(workPath, dataRel)
|
||||
} else {
|
||||
cfg.DataDir = dataRel
|
||||
}
|
||||
}
|
||||
if s := f.Section("app"); s.HasKey("DEV_MODE") {
|
||||
cfg.DevMode = s.Key("DEV_MODE").MustBool(true)
|
||||
}
|
||||
}
|
||||
|
||||
// 环境变量覆盖
|
||||
if v := os.Getenv("HTTP_PORT"); v != "" {
|
||||
if p, err := strconv.Atoi(v); err == nil {
|
||||
cfg.Port = p
|
||||
}
|
||||
}
|
||||
if v := os.Getenv("DB_DSN"); v != "" {
|
||||
cfg.DBDSN = v
|
||||
}
|
||||
if v := os.Getenv("JWT_SECRET"); v != "" {
|
||||
cfg.JWTSecret = v
|
||||
}
|
||||
if v := os.Getenv("DEV_MODE"); v != "" {
|
||||
cfg.DevMode = strings.EqualFold(v, "true") || v == "1"
|
||||
}
|
||||
|
||||
// 确保数据目录存在
|
||||
if err := os.MkdirAll(cfg.DataDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("创建数据目录失败: %w", err)
|
||||
}
|
||||
|
||||
// JWT 密钥:留空则自动生成并持久化
|
||||
if strings.TrimSpace(cfg.JWTSecret) == "" {
|
||||
secretFile := filepath.Join(cfg.DataDir, ".jwt_secret")
|
||||
if data, err := os.ReadFile(secretFile); err == nil && len(data) > 0 {
|
||||
cfg.JWTSecret = string(data)
|
||||
} else {
|
||||
cfg.JWTSecret = generateSecret(32)
|
||||
_ = os.WriteFile(secretFile, []byte(cfg.JWTSecret), 0600)
|
||||
}
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func generateSecret(n int) string {
|
||||
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
b := make([]byte, n)
|
||||
for i := range b {
|
||||
b[i] = chars[i%len(chars)]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
49
backend/go.mod
Normal file
49
backend/go.mod
Normal file
@@ -0,0 +1,49 @@
|
||||
module github.com/freefire/jiang13-bbs
|
||||
|
||||
go 1.27.0
|
||||
|
||||
require (
|
||||
github.com/gin-contrib/cors v1.7.2
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2
|
||||
golang.org/x/crypto v0.43.0
|
||||
gopkg.in/ini.v1 v1.67.0
|
||||
gorm.io/driver/postgres v1.5.9
|
||||
gorm.io/gorm v1.25.12
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||
github.com/jackc/pgx/v5 v5.5.5 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/net v0.45.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/sys v0.37.0 // indirect
|
||||
golang.org/x/text v0.30.0 // indirect
|
||||
google.golang.org/protobuf v1.34.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
121
backend/go.sum
Normal file
121
backend/go.sum
Normal file
@@ -0,0 +1,121 @@
|
||||
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw=
|
||||
github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
|
||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
|
||||
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
|
||||
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
||||
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
|
||||
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||
golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
|
||||
golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/postgres v1.5.9 h1:DkegyItji119OlcaLjqN11kHoUgZ/j13E0jkJZgD6A8=
|
||||
gorm.io/driver/postgres v1.5.9/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
|
||||
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
|
||||
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
213
backend/handler/auth.go
Normal file
213
backend/handler/auth.go
Normal file
@@ -0,0 +1,213 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/middleware"
|
||||
"github.com/freefire/jiang13-bbs/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var (
|
||||
accessCookieMaxAge = int(service.AccessTokenTTL.Seconds()) // 15 分钟
|
||||
refreshCookieMaxAge = int(service.RefreshTokenTTL.Seconds()) // 7 天
|
||||
)
|
||||
|
||||
// setAuthCookies 设置认证 cookie:
|
||||
// - j13_token: access token(HttpOnly,15min)
|
||||
// - j13_refresh: refresh token(HttpOnly,7天,仅 /api/auth 路径)
|
||||
// - j13_csrf: CSRF token(可读,7天,与 refresh 同生命周期,确保 refresh 流程可用)
|
||||
func setAuthCookies(c *gin.Context, accessToken, refreshToken string, secure bool) {
|
||||
csrfToken := service.GenerateCSRFToken()
|
||||
|
||||
// Access token cookie
|
||||
http.SetCookie(c.Writer, &http.Cookie{
|
||||
Name: service.CookieName,
|
||||
Value: accessToken,
|
||||
Path: "/",
|
||||
MaxAge: accessCookieMaxAge,
|
||||
HttpOnly: true,
|
||||
Secure: secure,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
// Refresh token cookie(仅 /api/auth 路径使用,缩小攻击面)
|
||||
http.SetCookie(c.Writer, &http.Cookie{
|
||||
Name: service.RefreshCookieName,
|
||||
Value: refreshToken,
|
||||
Path: "/api/auth",
|
||||
MaxAge: refreshCookieMaxAge,
|
||||
HttpOnly: true,
|
||||
Secure: secure,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
// CSRF cookie(前端可读,放入 X-CSRF-Token header;与 refresh 同寿命以支持 refresh 流程)
|
||||
http.SetCookie(c.Writer, &http.Cookie{
|
||||
Name: service.CSRFCookieName,
|
||||
Value: csrfToken,
|
||||
Path: "/",
|
||||
MaxAge: refreshCookieMaxAge,
|
||||
HttpOnly: false,
|
||||
Secure: secure,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
}
|
||||
|
||||
// clearAuthCookies 清除所有认证 cookie
|
||||
func clearAuthCookies(c *gin.Context) {
|
||||
cookies := []struct{ name, path string }{
|
||||
{service.CookieName, "/"},
|
||||
{service.RefreshCookieName, "/api/auth"},
|
||||
{service.CSRFCookieName, "/"},
|
||||
}
|
||||
for _, ck := range cookies {
|
||||
http.SetCookie(c.Writer, &http.Cookie{
|
||||
Name: ck.name,
|
||||
Value: "",
|
||||
Path: ck.path,
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: false,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
Expires: time.Unix(0, 0),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterRequest 注册请求
|
||||
type RegisterRequest struct {
|
||||
Username string `json:"username" binding:"required,min=3,max=32"`
|
||||
Email string `json:"email" binding:"omitempty,email"`
|
||||
Password string `json:"password" binding:"required,min=6,max=64"`
|
||||
}
|
||||
|
||||
// LoginRequest 登录请求
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
// Register 用户注册
|
||||
func (h *Handlers) Register(c *gin.Context) {
|
||||
var req RegisterRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
user, err := h.Auth.Register(req.Username, req.Email, req.Password)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": user.ID,
|
||||
"username": user.Username,
|
||||
})
|
||||
}
|
||||
|
||||
// Login 用户登录:access + refresh token 写入 HttpOnly cookie,CSRF 写入可读 cookie
|
||||
func (h *Handlers) Login(c *gin.Context) {
|
||||
var req LoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
accessToken, refreshToken, user, err := h.Auth.Login(req.Username, req.Password)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// dev 模式不设 Secure,生产环境需 HTTPS
|
||||
setAuthCookies(c, accessToken, refreshToken, !h.Cfg.DevMode)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"user": gin.H{
|
||||
"id": user.ID,
|
||||
"username": user.Username,
|
||||
"nickname": user.Nickname,
|
||||
"avatar": user.Avatar,
|
||||
"role": user.Role,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Refresh 刷新 access token:验证 refresh token,轮转后签发新 access + refresh
|
||||
func (h *Handlers) Refresh(c *gin.Context) {
|
||||
refreshToken, err := c.Cookie(service.RefreshCookieName)
|
||||
if err != nil || refreshToken == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
|
||||
return
|
||||
}
|
||||
accessToken, newRefresh, user, err := h.Auth.RotateRefreshToken(refreshToken)
|
||||
if err != nil {
|
||||
clearAuthCookies(c)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "登录已过期,请重新登录"})
|
||||
return
|
||||
}
|
||||
setAuthCookies(c, accessToken, newRefresh, !h.Cfg.DevMode)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"user": gin.H{
|
||||
"id": user.ID,
|
||||
"username": user.Username,
|
||||
"nickname": user.Nickname,
|
||||
"avatar": user.Avatar,
|
||||
"role": user.Role,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Logout 登出:撤销 refresh token,清除所有认证 cookie
|
||||
func (h *Handlers) Logout(c *gin.Context) {
|
||||
claims := middleware.CurrentUser(c)
|
||||
if claims != nil {
|
||||
// 撤销该用户所有 refresh token(防止 refresh token 被盗用)
|
||||
h.Auth.RevokeAllUserRefreshTokens(claims.ID)
|
||||
}
|
||||
clearAuthCookies(c)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "已登出"})
|
||||
}
|
||||
|
||||
// ChangePasswordRequest 修改密码请求
|
||||
type ChangePasswordRequest struct {
|
||||
OldPassword string `json:"old_password" binding:"required"`
|
||||
NewPassword string `json:"new_password" binding:"required,min=6,max=64"`
|
||||
}
|
||||
|
||||
// ChangePassword 修改密码:校验旧密码,更新新密码,清除所有登录态
|
||||
func (h *Handlers) ChangePassword(c *gin.Context) {
|
||||
claims := middleware.CurrentUser(c)
|
||||
var req ChangePasswordRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.Auth.ChangePassword(claims.ID, req.OldPassword, req.NewPassword); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// 密码已改,token_version 已递增,旧 token 全部失效,清除 cookie 要求重新登录
|
||||
clearAuthCookies(c)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "密码修改成功,请重新登录"})
|
||||
}
|
||||
|
||||
// Me 获取当前用户信息
|
||||
func (h *Handlers) Me(c *gin.Context) {
|
||||
claims := middleware.CurrentUser(c)
|
||||
if claims == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"user": nil})
|
||||
return
|
||||
}
|
||||
user, err := h.Auth.GetUserByID(claims.ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"user": nil})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"user": gin.H{
|
||||
"id": user.ID,
|
||||
"username": user.Username,
|
||||
"nickname": user.Nickname,
|
||||
"avatar": user.Avatar,
|
||||
"role": user.Role,
|
||||
},
|
||||
})
|
||||
}
|
||||
17
backend/handler/board.go
Normal file
17
backend/handler/board.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Boards 获取板块列表
|
||||
func (h *Handlers) Boards(c *gin.Context) {
|
||||
boards, err := h.Board.List()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"boards": boards})
|
||||
}
|
||||
70
backend/handler/comment.go
Normal file
70
backend/handler/comment.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/middleware"
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// PostComments 获取帖子评论
|
||||
func (h *Handlers) PostComments(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"})
|
||||
return
|
||||
}
|
||||
comments, err := h.Comment.ListByPost(uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"comments": comments})
|
||||
}
|
||||
|
||||
// CreateCommentRequest 评论请求
|
||||
type CreateCommentRequest struct {
|
||||
Content string `json:"content" binding:"required,min=1"`
|
||||
}
|
||||
|
||||
// CreateComment 创建评论
|
||||
func (h *Handlers) CreateComment(c *gin.Context) {
|
||||
claims := middleware.CurrentUser(c)
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"})
|
||||
return
|
||||
}
|
||||
var req CreateCommentRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
comment, err := h.Comment.Create(claims.ID, uint(id), req.Content)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// 通知帖子作者(排除自己评论自己的帖子)
|
||||
if authorID, e := h.Post.GetAuthorID(uint(id)); e == nil {
|
||||
h.Notification.Create(authorID, claims.ID, model.NotificationTypeComment, uint(id), comment.ID, req.Content)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"comment": comment})
|
||||
}
|
||||
|
||||
// DeleteComment 删除评论
|
||||
func (h *Handlers) DeleteComment(c *gin.Context) {
|
||||
claims := middleware.CurrentUser(c)
|
||||
cid, err := strconv.ParseUint(c.Param("cid"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的评论 ID"})
|
||||
return
|
||||
}
|
||||
if err := h.Comment.Delete(uint(cid), claims.ID, claims.Role); err != nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "已删除"})
|
||||
}
|
||||
17
backend/handler/handlers.go
Normal file
17
backend/handler/handlers.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/freefire/jiang13-bbs/config"
|
||||
"github.com/freefire/jiang13-bbs/service"
|
||||
)
|
||||
|
||||
// Handlers 聚合所有服务引用
|
||||
type Handlers struct {
|
||||
Cfg *config.Config
|
||||
Auth *service.AuthService
|
||||
Board *service.BoardService
|
||||
Post *service.PostService
|
||||
Comment *service.CommentService
|
||||
Like *service.LikeService
|
||||
Notification *service.NotificationService
|
||||
}
|
||||
17
backend/handler/health.go
Normal file
17
backend/handler/health.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Health 健康检查
|
||||
func (h *Handlers) Health(c *gin.Context) {
|
||||
if err := model.PingDB(); err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"status": "error", "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
32
backend/handler/like.go
Normal file
32
backend/handler/like.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/middleware"
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ToggleLike 点赞/取消点赞
|
||||
func (h *Handlers) ToggleLike(c *gin.Context) {
|
||||
claims := middleware.CurrentUser(c)
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"})
|
||||
return
|
||||
}
|
||||
liked, count, err := h.Like.Toggle(uint(id), claims.ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// 仅点赞时通知帖子作者(取消点赞不通知)
|
||||
if liked {
|
||||
if authorID, e := h.Post.GetAuthorID(uint(id)); e == nil {
|
||||
h.Notification.Create(authorID, claims.ID, model.NotificationTypeLike, uint(id), 0, "")
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"liked": liked, "like_count": count})
|
||||
}
|
||||
64
backend/handler/notification.go
Normal file
64
backend/handler/notification.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/middleware"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Notifications 获取当前用户的通知列表(分页)
|
||||
func (h *Handlers) Notifications(c *gin.Context) {
|
||||
claims := middleware.CurrentUser(c)
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
|
||||
|
||||
list, total, err := h.Notification.List(claims.ID, page, size)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"notifications": list,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"size": size,
|
||||
})
|
||||
}
|
||||
|
||||
// UnreadCount 获取未读通知数
|
||||
func (h *Handlers) UnreadCount(c *gin.Context) {
|
||||
claims := middleware.CurrentUser(c)
|
||||
count, err := h.Notification.UnreadCount(claims.ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"count": count})
|
||||
}
|
||||
|
||||
// MarkRead 标记单条通知为已读
|
||||
func (h *Handlers) MarkRead(c *gin.Context) {
|
||||
claims := middleware.CurrentUser(c)
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的通知 ID"})
|
||||
return
|
||||
}
|
||||
if err := h.Notification.MarkRead(uint(id), claims.ID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "已标记为已读"})
|
||||
}
|
||||
|
||||
// MarkAllRead 标记所有通知为已读
|
||||
func (h *Handlers) MarkAllRead(c *gin.Context) {
|
||||
claims := middleware.CurrentUser(c)
|
||||
if err := h.Notification.MarkAllRead(claims.ID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "已全部标记为已读"})
|
||||
}
|
||||
179
backend/handler/post.go
Normal file
179
backend/handler/post.go
Normal file
@@ -0,0 +1,179 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/middleware"
|
||||
"github.com/freefire/jiang13-bbs/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Posts 获取帖子列表
|
||||
func (h *Handlers) Posts(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
|
||||
boardID, _ := strconv.ParseUint(c.Query("board_id"), 10, 64)
|
||||
sort := c.DefaultQuery("sort", "latest")
|
||||
keyword := c.Query("keyword")
|
||||
|
||||
items, total, err := h.Post.List(service.PostListQuery{
|
||||
BoardID: uint(boardID),
|
||||
Page: page,
|
||||
Size: size,
|
||||
Sort: sort,
|
||||
Keyword: keyword,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// 填充点赞状态(仅登录用户)
|
||||
if claims := middleware.CurrentUser(c); claims != nil {
|
||||
ids := make([]uint, 0, len(items))
|
||||
for _, p := range items {
|
||||
ids = append(ids, p.ID)
|
||||
}
|
||||
likedMap := h.Like.BatchHasLiked(ids, claims.ID)
|
||||
for i := range items {
|
||||
items[i].Liked = likedMap[items[i].ID]
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"posts": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"size": size,
|
||||
})
|
||||
}
|
||||
|
||||
// PostDetail 获取帖子详情
|
||||
func (h *Handlers) PostDetail(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"})
|
||||
return
|
||||
}
|
||||
post, err := h.Post.GetByID(uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"})
|
||||
return
|
||||
}
|
||||
// 填充点赞状态(仅登录用户)
|
||||
if claims := middleware.CurrentUser(c); claims != nil {
|
||||
post.Liked = h.Like.HasLiked(post.ID, claims.ID)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"post": post})
|
||||
}
|
||||
|
||||
// CreatePostRequest 发帖请求
|
||||
type CreatePostRequest struct {
|
||||
BoardID uint `json:"board_id" binding:"required"`
|
||||
Title string `json:"title" binding:"required,min=1,max=256"`
|
||||
Content string `json:"content" binding:"required,min=1"`
|
||||
Tags string `json:"tags"`
|
||||
PostType string `json:"post_type"`
|
||||
}
|
||||
|
||||
// CreatePost 创建帖子
|
||||
func (h *Handlers) CreatePost(c *gin.Context) {
|
||||
claims := middleware.CurrentUser(c)
|
||||
var req CreatePostRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
postType := req.PostType
|
||||
if postType == "" {
|
||||
postType = "normal"
|
||||
}
|
||||
post, err := h.Post.Create(claims.ID, req.BoardID, req.Title, req.Content, req.Tags, postType)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"post": post})
|
||||
}
|
||||
|
||||
// UpdatePostRequest 更新帖子请求
|
||||
type UpdatePostRequest struct {
|
||||
Title string `json:"title" binding:"omitempty,min=1,max=256"`
|
||||
Content string `json:"content" binding:"omitempty,min=1"`
|
||||
Tags string `json:"tags"`
|
||||
}
|
||||
|
||||
// UpdatePost 编辑帖子
|
||||
func (h *Handlers) UpdatePost(c *gin.Context) {
|
||||
claims := middleware.CurrentUser(c)
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"})
|
||||
return
|
||||
}
|
||||
var req UpdatePostRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
post, err := h.Post.Update(uint(id), claims.ID, claims.Role, req.Title, req.Content, req.Tags)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"post": post})
|
||||
}
|
||||
|
||||
// DeletePost 删除帖子
|
||||
func (h *Handlers) DeletePost(c *gin.Context) {
|
||||
claims := middleware.CurrentUser(c)
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"})
|
||||
return
|
||||
}
|
||||
if err := h.Post.Delete(uint(id), claims.ID, claims.Role); err != nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "已删除"})
|
||||
}
|
||||
|
||||
// TogglePin 切换帖子置顶(仅管理员)
|
||||
func (h *Handlers) TogglePin(c *gin.Context) {
|
||||
claims := middleware.CurrentUser(c)
|
||||
if claims.Role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "仅管理员可操作"})
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"})
|
||||
return
|
||||
}
|
||||
pinned, err := h.Post.TogglePin(uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"pinned": pinned})
|
||||
}
|
||||
|
||||
// ToggleRecommend 切换帖子推荐(仅管理员)
|
||||
func (h *Handlers) ToggleRecommend(c *gin.Context) {
|
||||
claims := middleware.CurrentUser(c)
|
||||
if claims.Role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "仅管理员可操作"})
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"})
|
||||
return
|
||||
}
|
||||
recommended, err := h.Post.ToggleRecommend(uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"recommended": recommended})
|
||||
}
|
||||
79
backend/handler/seo.go
Normal file
79
backend/handler/seo.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const siteBaseURL = "http://localhost:3000"
|
||||
|
||||
// RobotsTxt robots.txt
|
||||
func (h *Handlers) RobotsTxt(c *gin.Context) {
|
||||
content := fmt.Sprintf(`User-agent: *
|
||||
Allow: /
|
||||
Sitemap: %s/sitemap.xml
|
||||
`, siteBaseURL)
|
||||
c.String(http.StatusOK, content)
|
||||
}
|
||||
|
||||
// SitemapXML 生成 sitemap
|
||||
func (h *Handlers) SitemapXML(c *gin.Context) {
|
||||
type urlEntry struct {
|
||||
Loc string `xml:"loc"`
|
||||
Lastmod string `xml:"lastmod"`
|
||||
Changefreq string `xml:"changefreq"`
|
||||
Priority string `xml:"priority"`
|
||||
}
|
||||
type urlset struct {
|
||||
XMLName xml.Name `xml:"urlset"`
|
||||
Xmlns string `xml:"xmlns,attr"`
|
||||
URLs []urlEntry `xml:"url"`
|
||||
}
|
||||
|
||||
now := time.Now().Format("2006-01-02")
|
||||
entries := []urlEntry{
|
||||
{Loc: siteBaseURL + "/", Lastmod: now, Changefreq: "daily", Priority: "1.0"},
|
||||
}
|
||||
|
||||
// 板块页
|
||||
var boards []model.Board
|
||||
model.DB.Find(&boards)
|
||||
for _, b := range boards {
|
||||
entries = append(entries, urlEntry{
|
||||
Loc: fmt.Sprintf("%s/board/%d", siteBaseURL, b.ID),
|
||||
Lastmod: now,
|
||||
Changefreq: "daily",
|
||||
Priority: "0.8",
|
||||
})
|
||||
}
|
||||
|
||||
// 帖子详情页
|
||||
var posts []model.Post
|
||||
model.DB.Where("status = ?", model.ContentStatusPublished).
|
||||
Order("created_at DESC").Limit(1000).Find(&posts)
|
||||
for _, p := range posts {
|
||||
entries = append(entries, urlEntry{
|
||||
Loc: fmt.Sprintf("%s/post/%d", siteBaseURL, p.ID),
|
||||
Lastmod: p.UpdatedAt.Format("2006-01-02"),
|
||||
Changefreq: "weekly",
|
||||
Priority: "0.6",
|
||||
})
|
||||
}
|
||||
|
||||
us := urlset{
|
||||
Xmlns: "http://www.sitemaps.org/schemas/sitemap/0.9",
|
||||
URLs: entries,
|
||||
}
|
||||
output, err := xml.MarshalIndent(us, "", " ")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Header("Content-Type", "application/xml")
|
||||
c.String(http.StatusOK, xml.Header+string(output))
|
||||
}
|
||||
96
backend/handler/user.go
Normal file
96
backend/handler/user.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/middleware"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// UserProfile 获取用户资料:基本信息 + 统计 + 发帖列表(分页)
|
||||
func (h *Handlers) UserProfile(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的用户 ID"})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.Auth.GetUserByID(uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
|
||||
|
||||
posts, postsTotal, err := h.Post.ListByUser(user.ID, page, size)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// 填充点赞状态(仅登录用户)
|
||||
if claims := middleware.CurrentUser(c); claims != nil {
|
||||
ids := make([]uint, 0, len(posts))
|
||||
for _, p := range posts {
|
||||
ids = append(ids, p.ID)
|
||||
}
|
||||
likedMap := h.Like.BatchHasLiked(ids, claims.ID)
|
||||
for i := range posts {
|
||||
posts[i].Liked = likedMap[posts[i].ID]
|
||||
}
|
||||
}
|
||||
|
||||
postCount, _ := h.Post.CountByUser(user.ID)
|
||||
commentCount, _ := h.Comment.CountByUser(user.ID)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"user": gin.H{
|
||||
"id": user.ID,
|
||||
"username": user.Username,
|
||||
"nickname": user.Nickname,
|
||||
"avatar": user.Avatar,
|
||||
"role": user.Role,
|
||||
"created_at": user.CreatedAt,
|
||||
},
|
||||
"stats": gin.H{
|
||||
"post_count": postCount,
|
||||
"comment_count": commentCount,
|
||||
},
|
||||
"posts": posts,
|
||||
"posts_total": postsTotal,
|
||||
"page": page,
|
||||
"size": size,
|
||||
})
|
||||
}
|
||||
|
||||
// UserComments 获取用户发表的评论列表(分页,含帖子标题)
|
||||
func (h *Handlers) UserComments(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的用户 ID"})
|
||||
return
|
||||
}
|
||||
// 校验用户存在
|
||||
if _, err := h.Auth.GetUserByID(uint(id)); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
|
||||
|
||||
comments, total, err := h.Comment.ListByUser(uint(id), page, size)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"comments": comments,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"size": size,
|
||||
})
|
||||
}
|
||||
100
backend/middleware/auth.go
Normal file
100
backend/middleware/auth.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// AuthMiddleware 认证中间件
|
||||
type AuthMiddleware struct {
|
||||
auth *service.AuthService
|
||||
}
|
||||
|
||||
func NewAuthMiddleware(auth *service.AuthService) *AuthMiddleware {
|
||||
return &AuthMiddleware{auth: auth}
|
||||
}
|
||||
|
||||
// OptionalAuth 可选登录:解析 token,失败不阻断
|
||||
func (m *AuthMiddleware) OptionalAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
user, ok := m.parseToken(c)
|
||||
if ok {
|
||||
c.Set("user", user)
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RequireAuth 必须登录
|
||||
func (m *AuthMiddleware) RequireAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
user, ok := m.parseToken(c)
|
||||
if !ok {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
|
||||
return
|
||||
}
|
||||
if user.Banned {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "账号已被封禁"})
|
||||
return
|
||||
}
|
||||
c.Set("user", user)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RequireAdmin 必须管理员
|
||||
func (m *AuthMiddleware) RequireAdmin() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
user, ok := m.parseToken(c)
|
||||
if !ok {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
|
||||
return
|
||||
}
|
||||
if user.Role != service.RoleAdmin {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "需要管理员权限"})
|
||||
return
|
||||
}
|
||||
c.Set("user", user)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// parseToken 解析并校验 token:
|
||||
// 1. 优先从 HttpOnly cookie 读取,回退 Authorization header
|
||||
// 2. 校验 JWT 签名和过期
|
||||
// 3. 查 DB 实时校验 token_version 和 banned 状态
|
||||
func (m *AuthMiddleware) parseToken(c *gin.Context) (*service.UserClaims, bool) {
|
||||
tokenStr, err := c.Cookie(service.CookieName)
|
||||
if err != nil || tokenStr == "" {
|
||||
auth := c.GetHeader("Authorization")
|
||||
if auth == "" {
|
||||
return nil, false
|
||||
}
|
||||
tokenStr = strings.TrimPrefix(auth, "Bearer ")
|
||||
if tokenStr == auth {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
claims, err := m.auth.ParseToken(tokenStr)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
// 实时校验:token_version 匹配 + 未封禁(防止旧 JWT 在封禁/改密码后仍有效)
|
||||
if _, err := m.auth.ValidateClaims(claims); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return claims, true
|
||||
}
|
||||
|
||||
// CurrentUser 从 context 获取当前用户
|
||||
func CurrentUser(c *gin.Context) *service.UserClaims {
|
||||
if v, ok := c.Get("user"); ok {
|
||||
if u, ok := v.(*service.UserClaims); ok {
|
||||
return u
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
34
backend/middleware/csrf.go
Normal file
34
backend/middleware/csrf.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// CSRFMiddleware CSRF 防护中间件
|
||||
// 对 POST/PUT/DELETE 等状态变更请求,校验 X-CSRF-Token header 与 cookie 中的 CSRF token 是否一致
|
||||
func CSRFMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 仅对状态变更方法校验
|
||||
if c.Request.Method == http.MethodGet || c.Request.Method == http.MethodHead || c.Request.Method == http.MethodOptions {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
cookieToken, err := c.Cookie(service.CSRFCookieName)
|
||||
if err != nil || cookieToken == "" {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "CSRF token 缺失"})
|
||||
return
|
||||
}
|
||||
|
||||
headerToken := c.GetHeader(service.CSRFHeaderName)
|
||||
if headerToken == "" || headerToken != cookieToken {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "CSRF 校验失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
20
backend/middleware/ratelimit.go
Normal file
20
backend/middleware/ratelimit.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// RateLimitMiddleware 速率限制中间件
|
||||
func RateLimitMiddleware(rl *service.RateLimiter, rateType string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
key := rateType + ":" + c.ClientIP()
|
||||
if !rl.Allow(key) {
|
||||
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "请求过于频繁,请稍后再试"})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
43
backend/middleware/security.go
Normal file
43
backend/middleware/security.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// SecurityHeaders 安全响应头中间件
|
||||
// 为所有响应添加安全头,降低 XSS、点击劫持、MIME 嗅探等风险
|
||||
func SecurityHeaders() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// CSP:限制资源加载来源,降低 XSS 危害
|
||||
c.Header("Content-Security-Policy",
|
||||
"default-src 'self'; "+
|
||||
"script-src 'self' 'unsafe-inline'; "+
|
||||
"style-src 'self' 'unsafe-inline'; "+
|
||||
"img-src 'self' data: https:; "+
|
||||
"font-src 'self' data:; "+
|
||||
"connect-src 'self'; "+
|
||||
"frame-ancestors 'none'; "+
|
||||
"base-uri 'self'; "+
|
||||
"form-action 'self'")
|
||||
|
||||
// HSTS:强制 HTTPS(生产环境生效,dev 模式浏览器会忽略)
|
||||
c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload")
|
||||
|
||||
// 点击劫持防护
|
||||
c.Header("X-Frame-Options", "DENY")
|
||||
|
||||
// MIME 嗅探防护
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
|
||||
// 控制 Referer 信息泄露
|
||||
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
|
||||
// XSS 防护(旧浏览器兼容,现代浏览器靠 CSP)
|
||||
c.Header("X-XSS-Protection", "1; mode=block")
|
||||
|
||||
// 禁止浏览器缓存敏感页面(可按需覆盖)
|
||||
// c.Header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
63
backend/model/db.go
Normal file
63
backend/model/db.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// DB 全局数据库实例
|
||||
var DB *gorm.DB
|
||||
|
||||
// InitDB 连接 PostgreSQL 并自动迁移
|
||||
func InitDB(dsn string) error {
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Warn),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("连接 PostgreSQL 失败: %w", err)
|
||||
}
|
||||
|
||||
if err := db.AutoMigrate(
|
||||
&User{}, &Board{}, &Post{}, &Comment{}, &RefreshToken{}, &Like{}, &Notification{},
|
||||
); err != nil {
|
||||
return fmt.Errorf("自动迁移失败: %w", err)
|
||||
}
|
||||
|
||||
DB = db
|
||||
seedDefaultBoards(db)
|
||||
log.Println("[model] PostgreSQL 数据库初始化完成")
|
||||
return nil
|
||||
}
|
||||
|
||||
// PingDB 检测数据库连接
|
||||
func PingDB() error {
|
||||
if DB == nil {
|
||||
return fmt.Errorf("数据库未初始化")
|
||||
}
|
||||
sqlDB, err := DB.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return sqlDB.Ping()
|
||||
}
|
||||
|
||||
// seedDefaultBoards 写入默认板块
|
||||
func seedDefaultBoards(db *gorm.DB) {
|
||||
defaults := []Board{
|
||||
{Name: "综合讨论", Description: "什么都可以聊", Icon: "message-circle", SortOrder: 1},
|
||||
{Name: "技术分享", Description: "分享技术心得与问题", Icon: "code", SortOrder: 2},
|
||||
{Name: "问答求助", Description: "提问与解答", Icon: "help-circle", SortOrder: 3},
|
||||
{Name: "闲聊灌水", Description: "轻松闲聊", Icon: "coffee", SortOrder: 4},
|
||||
}
|
||||
for _, b := range defaults {
|
||||
var count int64
|
||||
db.Model(&Board{}).Where("name = ?", b.Name).Count(&count)
|
||||
if count == 0 {
|
||||
_ = db.Create(&b).Error
|
||||
}
|
||||
}
|
||||
}
|
||||
136
backend/model/models.go
Normal file
136
backend/model/models.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Role 用户角色
|
||||
type Role string
|
||||
|
||||
const (
|
||||
RoleUser Role = "user"
|
||||
RoleAdmin Role = "admin"
|
||||
)
|
||||
|
||||
// 内容审核状态
|
||||
const (
|
||||
ContentStatusPending = "pending"
|
||||
ContentStatusPublished = "published"
|
||||
ContentStatusRejected = "rejected"
|
||||
)
|
||||
|
||||
// 帖子类型
|
||||
const (
|
||||
PostTypeNormal = "normal"
|
||||
PostTypeQuestion = "question"
|
||||
)
|
||||
|
||||
// User 用户表
|
||||
type User struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Username string `gorm:"uniqueIndex;size:128;not null" json:"username"`
|
||||
Email string `gorm:"index;size:128;default:''" json:"-"`
|
||||
Password string `gorm:"size:128;not null" json:"-"`
|
||||
Nickname string `gorm:"size:64" json:"nickname"`
|
||||
Avatar string `gorm:"size:512" json:"avatar"`
|
||||
Role Role `gorm:"size:16;default:user" json:"role"`
|
||||
Banned bool `gorm:"default:false" json:"banned"`
|
||||
TokenVersion int `gorm:"default:0" json:"-"` // token 版本号,改密码/封禁时递增使旧 JWT 失效
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
// RefreshToken 刷新令牌表(支持服务端撤销与轮转)
|
||||
type RefreshToken struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UserID uint `gorm:"index;not null" json:"user_id"`
|
||||
Token string `gorm:"uniqueIndex;size:128;not null" json:"-"`
|
||||
ExpiresAt time.Time `gorm:"index;not null" json:"expires_at"`
|
||||
Revoked bool `gorm:"default:false" json:"revoked"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Board 论坛板块
|
||||
type Board struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"size:64;not null" json:"name"`
|
||||
Description string `gorm:"size:512" json:"description"`
|
||||
Icon string `gorm:"size:64;default:''" json:"icon"`
|
||||
ColorIndex int `gorm:"default:-1" json:"color_index"`
|
||||
SortOrder int `gorm:"default:0" json:"sort_order"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
// Post 帖子
|
||||
type Post struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
BoardID uint `gorm:"index;not null" json:"board_id"`
|
||||
UserID uint `gorm:"index;not null" json:"user_id"`
|
||||
Title string `gorm:"size:256;not null" json:"title"`
|
||||
Content string `gorm:"type:text;not null" json:"content"`
|
||||
Tags string `gorm:"size:256" json:"tags"`
|
||||
PostType string `gorm:"size:16;default:normal;index" json:"post_type"`
|
||||
Pinned int `gorm:"default:0" json:"pinned"`
|
||||
Recommended bool `gorm:"default:false;index" json:"recommended"`
|
||||
Status string `gorm:"size:16;default:published;index" json:"status"`
|
||||
LikeCount int `gorm:"default:0" json:"like_count"`
|
||||
ViewCount int `gorm:"default:0" json:"view_count"`
|
||||
CommentCount int `gorm:"default:0" json:"comment_count"`
|
||||
Liked bool `gorm:"-" json:"liked"` // 当前用户是否已点赞(展示字段,不入库)
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
Board Board `gorm:"foreignKey:BoardID" json:"board,omitempty"`
|
||||
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
||||
}
|
||||
|
||||
// Comment 评论
|
||||
type Comment struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
PostID uint `gorm:"index;not null" json:"post_id"`
|
||||
UserID uint `gorm:"index;not null" json:"user_id"`
|
||||
Content string `gorm:"type:text;not null" json:"content"`
|
||||
Status string `gorm:"size:16;default:published;index" json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
||||
}
|
||||
|
||||
// Like 点赞记录(联合唯一索引防止重复点赞)
|
||||
type Like struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
PostID uint `gorm:"uniqueIndex:idx_post_user;not null" json:"post_id"`
|
||||
UserID uint `gorm:"uniqueIndex:idx_post_user;not null" json:"user_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// 通知类型
|
||||
const (
|
||||
NotificationTypeComment = "comment" // 评论了你的帖子
|
||||
NotificationTypeLike = "like" // 点赞了你的帖子
|
||||
)
|
||||
|
||||
// Notification 站内通知
|
||||
type Notification struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UserID uint `gorm:"index;not null" json:"user_id"` // 接收通知的用户
|
||||
ActorID uint `gorm:"not null" json:"actor_id"` // 触发通知的用户
|
||||
Type string `gorm:"size:16;not null;index" json:"type"` // comment | like
|
||||
PostID uint `gorm:"index;not null" json:"post_id"` // 关联帖子
|
||||
CommentID uint `gorm:"index" json:"comment_id"` // 关联评论(点赞时为 0)
|
||||
Content string `gorm:"size:256" json:"content"` // 内容预览
|
||||
IsRead bool `gorm:"default:false;index" json:"is_read"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
Actor User `gorm:"foreignKey:ActorID" json:"actor,omitempty"`
|
||||
Post Post `gorm:"foreignKey:PostID" json:"post,omitempty"`
|
||||
}
|
||||
114
backend/router/router.go
Normal file
114
backend/router/router.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/config"
|
||||
"github.com/freefire/jiang13-bbs/handler"
|
||||
"github.com/freefire/jiang13-bbs/middleware"
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"github.com/freefire/jiang13-bbs/service"
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Setup 初始化路由
|
||||
func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
if cfg.DevMode {
|
||||
gin.SetMode(gin.DebugMode)
|
||||
} else {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
r.Use(gin.Recovery())
|
||||
r.Use(gin.Logger())
|
||||
|
||||
// 全局安全响应头
|
||||
r.Use(middleware.SecurityHeaders())
|
||||
|
||||
// CORS
|
||||
r.Use(cors.New(cors.Config{
|
||||
AllowOrigins: []string{"http://localhost:3000", "http://127.0.0.1:3000"},
|
||||
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
AllowHeaders: []string{"Origin", "Content-Type", "Authorization", "X-CSRF-Token"},
|
||||
AllowCredentials: true,
|
||||
MaxAge: 12 * time.Hour,
|
||||
}))
|
||||
|
||||
// 服务
|
||||
authSvc := service.NewAuthService(model.DB, cfg.JWTSecret)
|
||||
boardSvc := service.NewBoardService(model.DB)
|
||||
postSvc := service.NewPostService(model.DB)
|
||||
commentSvc := service.NewCommentService(model.DB)
|
||||
likeSvc := service.NewLikeService(model.DB)
|
||||
notifSvc := service.NewNotificationService(model.DB)
|
||||
limiter := service.DefaultRateLimiter()
|
||||
|
||||
h := &handler.Handlers{
|
||||
Cfg: cfg,
|
||||
Auth: authSvc,
|
||||
Board: boardSvc,
|
||||
Post: postSvc,
|
||||
Comment: commentSvc,
|
||||
Like: likeSvc,
|
||||
Notification: notifSvc,
|
||||
}
|
||||
|
||||
authMW := middleware.NewAuthMiddleware(authSvc)
|
||||
|
||||
// 健康检查 & SEO
|
||||
r.GET("/health", h.Health)
|
||||
r.GET("/robots.txt", h.RobotsTxt)
|
||||
r.GET("/sitemap.xml", h.SitemapXML)
|
||||
|
||||
// 公开 API(可选登录)
|
||||
pubAPI := r.Group("/api", authMW.OptionalAuth())
|
||||
{
|
||||
pubAPI.GET("/me", h.Me)
|
||||
pubAPI.GET("/boards", h.Boards)
|
||||
pubAPI.GET("/posts", h.Posts)
|
||||
pubAPI.GET("/posts/:id", h.PostDetail)
|
||||
pubAPI.GET("/posts/:id/comments", h.PostComments)
|
||||
pubAPI.GET("/users/:id", h.UserProfile)
|
||||
pubAPI.GET("/users/:id/comments", h.UserComments)
|
||||
pubAPI.POST("/register", middleware.RateLimitMiddleware(limiter, service.RateRegister), h.Register)
|
||||
pubAPI.POST("/login", middleware.RateLimitMiddleware(limiter, service.RateLogin), h.Login)
|
||||
// refresh token 端点:access 过期后用 refresh 换新 token(需 CSRF 防护)
|
||||
pubAPI.POST("/auth/refresh", middleware.CSRFMiddleware(), h.Refresh)
|
||||
}
|
||||
|
||||
// 需登录 API(先鉴权,再 CSRF 防护)
|
||||
api := r.Group("/api", authMW.RequireAuth(), middleware.CSRFMiddleware())
|
||||
{
|
||||
api.POST("/logout", h.Logout)
|
||||
api.POST("/change-password", h.ChangePassword)
|
||||
api.POST("/posts", middleware.RateLimitMiddleware(limiter, service.RatePost), h.CreatePost)
|
||||
api.PUT("/posts/:id", h.UpdatePost)
|
||||
api.DELETE("/posts/:id", h.DeletePost)
|
||||
api.PUT("/posts/:id/pin", h.TogglePin)
|
||||
api.PUT("/posts/:id/recommend", h.ToggleRecommend)
|
||||
api.POST("/posts/:id/like", h.ToggleLike)
|
||||
api.POST("/posts/:id/comments", middleware.RateLimitMiddleware(limiter, service.RateComment), h.CreateComment)
|
||||
api.DELETE("/posts/:id/comments/:cid", h.DeleteComment)
|
||||
// 通知
|
||||
api.GET("/notifications", h.Notifications)
|
||||
api.GET("/notifications/unread-count", h.UnreadCount)
|
||||
api.PUT("/notifications/:id/read", h.MarkRead)
|
||||
api.PUT("/notifications/read-all", h.MarkAllRead)
|
||||
}
|
||||
|
||||
// 管理员 API(同样需要 CSRF 防护)
|
||||
adminAPI := r.Group("/api/admin", authMW.RequireAuth(), middleware.CSRFMiddleware(), authMW.RequireAdmin())
|
||||
{
|
||||
adminAPI.GET("/dashboard", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"message": "admin dashboard"})
|
||||
})
|
||||
}
|
||||
|
||||
r.NoRoute(func(c *gin.Context) {
|
||||
c.JSON(404, gin.H{"error": "not found"})
|
||||
})
|
||||
|
||||
return r, nil
|
||||
}
|
||||
284
backend/service/auth.go
Normal file
284
backend/service/auth.go
Normal file
@@ -0,0 +1,284 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
RoleUser = "user"
|
||||
RoleAdmin = "admin"
|
||||
|
||||
// CookieName JWT access token 存储的 HttpOnly cookie 名
|
||||
CookieName = "j13_token"
|
||||
// RefreshCookieName refresh token 存储的 HttpOnly cookie 名
|
||||
RefreshCookieName = "j13_refresh"
|
||||
// CSRFCookieName CSRF token cookie 名(非 HttpOnly,前端可读)
|
||||
CSRFCookieName = "j13_csrf"
|
||||
// CSRFHeaderName 前端传递 CSRF token 的 header 名
|
||||
CSRFHeaderName = "X-CSRF-Token"
|
||||
|
||||
// AccessTokenTTL access token 有效期(短期,降低被盗窗口)
|
||||
AccessTokenTTL = 15 * time.Minute
|
||||
// RefreshTokenTTL refresh token 有效期(长期)
|
||||
RefreshTokenTTL = 7 * 24 * time.Hour
|
||||
)
|
||||
|
||||
// UserClaims JWT 中携带的用户信息
|
||||
type UserClaims struct {
|
||||
ID uint `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
Banned bool `json:"banned"`
|
||||
TokenVersion int `json:"tv"` // token 版本号,用于服务端撤销
|
||||
}
|
||||
|
||||
// AuthService 认证服务
|
||||
type AuthService struct {
|
||||
db *gorm.DB
|
||||
jwtSecret []byte
|
||||
}
|
||||
|
||||
func NewAuthService(db *gorm.DB, jwtSecret string) *AuthService {
|
||||
return &AuthService{db: db, jwtSecret: []byte(jwtSecret)}
|
||||
}
|
||||
|
||||
// Register 用户注册
|
||||
func (s *AuthService) Register(username, email, password string) (*model.User, error) {
|
||||
// 检查用户名是否已存在
|
||||
var count int64
|
||||
s.db.Model(&model.User{}).Where("username = ?", username).Count(&count)
|
||||
if count > 0 {
|
||||
return nil, errors.New("用户名已被使用")
|
||||
}
|
||||
|
||||
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user := &model.User{
|
||||
Username: username,
|
||||
Email: email,
|
||||
Password: string(hashed),
|
||||
Nickname: username,
|
||||
Role: model.RoleUser,
|
||||
}
|
||||
if err := s.db.Create(user).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// Login 用户登录,返回 access token + refresh token + user
|
||||
func (s *AuthService) Login(username, password string) (string, string, *model.User, error) {
|
||||
var user model.User
|
||||
if err := s.db.Where("username = ?", username).First(&user).Error; err != nil {
|
||||
return "", "", nil, errors.New("用户名或密码错误")
|
||||
}
|
||||
if user.Banned {
|
||||
return "", "", nil, errors.New("账号已被封禁")
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil {
|
||||
return "", "", nil, errors.New("用户名或密码错误")
|
||||
}
|
||||
|
||||
accessToken, err := s.generateToken(&user)
|
||||
if err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
refreshToken, err := s.CreateRefreshToken(user.ID)
|
||||
if err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
return accessToken, refreshToken, &user, nil
|
||||
}
|
||||
|
||||
// generateToken 签发 access token(短期)
|
||||
func (s *AuthService) generateToken(user *model.User) (string, error) {
|
||||
claims := &tokenClaims{
|
||||
UserClaims: UserClaims{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Role: string(user.Role),
|
||||
Banned: user.Banned,
|
||||
TokenVersion: user.TokenVersion,
|
||||
},
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Subject: user.Username,
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(AccessTokenTTL)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString(s.jwtSecret)
|
||||
}
|
||||
|
||||
type tokenClaims struct {
|
||||
UserClaims
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// ParseToken 解析 JWT(仅校验签名和过期,不查 DB)
|
||||
func (s *AuthService) ParseToken(tokenStr string) (*UserClaims, error) {
|
||||
claims := &tokenClaims{}
|
||||
_, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) {
|
||||
return s.jwtSecret, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &claims.UserClaims, nil
|
||||
}
|
||||
|
||||
// ValidateClaims 校验 claims 是否仍然有效(查 DB:token_version 匹配且未封禁)
|
||||
// 用于中间件在每个请求上做实时权限校验
|
||||
func (s *AuthService) ValidateClaims(claims *UserClaims) (*model.User, error) {
|
||||
var user model.User
|
||||
if err := s.db.First(&user, claims.ID).Error; err != nil {
|
||||
return nil, errors.New("用户不存在")
|
||||
}
|
||||
// token 版本不匹配 → 已被撤销(改密码/封禁/管理员操作)
|
||||
if user.TokenVersion != claims.TokenVersion {
|
||||
return nil, errors.New("token 已失效")
|
||||
}
|
||||
// 实时校验封禁状态(不依赖 JWT 中的缓存值)
|
||||
if user.Banned {
|
||||
return nil, errors.New("账号已被封禁")
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// GetUserByID 根据 ID 获取用户
|
||||
func (s *AuthService) GetUserByID(id uint) (*model.User, error) {
|
||||
var user model.User
|
||||
if err := s.db.First(&user, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// GenerateCSRFToken 生成随机 CSRF token
|
||||
func GenerateCSRFToken() string {
|
||||
b := make([]byte, 32)
|
||||
_, _ = rand.Read(b)
|
||||
return base64.URLEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
// generateRandomToken 生成随机令牌字符串
|
||||
func generateRandomToken() string {
|
||||
b := make([]byte, 32)
|
||||
_, _ = rand.Read(b)
|
||||
return base64.URLEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
// CreateRefreshToken 创建并存储 refresh token
|
||||
func (s *AuthService) CreateRefreshToken(userID uint) (string, error) {
|
||||
token := generateRandomToken()
|
||||
rt := &model.RefreshToken{
|
||||
UserID: userID,
|
||||
Token: token,
|
||||
ExpiresAt: time.Now().Add(RefreshTokenTTL),
|
||||
}
|
||||
if err := s.db.Create(rt).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// ValidateRefreshToken 校验 refresh token 并返回所属用户
|
||||
func (s *AuthService) ValidateRefreshToken(token string) (*model.User, error) {
|
||||
var rt model.RefreshToken
|
||||
if err := s.db.Where("token = ?", token).First(&rt).Error; err != nil {
|
||||
return nil, errors.New("refresh token 无效")
|
||||
}
|
||||
if rt.Revoked {
|
||||
return nil, errors.New("refresh token 已撤销")
|
||||
}
|
||||
if time.Now().After(rt.ExpiresAt) {
|
||||
return nil, errors.New("refresh token 已过期")
|
||||
}
|
||||
var user model.User
|
||||
if err := s.db.First(&user, rt.UserID).Error; err != nil {
|
||||
return nil, errors.New("用户不存在")
|
||||
}
|
||||
if user.Banned {
|
||||
return nil, errors.New("账号已被封禁")
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// RotateRefreshToken 轮转 refresh token:撤销旧的,签发新的
|
||||
func (s *AuthService) RotateRefreshToken(oldToken string) (string, string, *model.User, error) {
|
||||
user, err := s.ValidateRefreshToken(oldToken)
|
||||
if err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
// 撤销旧 token
|
||||
s.db.Model(&model.RefreshToken{}).Where("token = ?", oldToken).Update("revoked", true)
|
||||
// 签发新 access + refresh
|
||||
accessToken, err := s.generateToken(user)
|
||||
if err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
newRefresh, err := s.CreateRefreshToken(user.ID)
|
||||
if err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
return accessToken, newRefresh, user, nil
|
||||
}
|
||||
|
||||
// RevokeRefreshToken 撤销单个 refresh token(登出时用)
|
||||
func (s *AuthService) RevokeRefreshToken(token string) {
|
||||
s.db.Model(&model.RefreshToken{}).Where("token = ?", token).Update("revoked", true)
|
||||
}
|
||||
|
||||
// RevokeAllUserRefreshTokens 撤销用户所有 refresh token(改密码/封禁时用)
|
||||
func (s *AuthService) RevokeAllUserRefreshTokens(userID uint) {
|
||||
s.db.Model(&model.RefreshToken{}).Where("user_id = ?", userID).Update("revoked", true)
|
||||
}
|
||||
|
||||
// IncrementTokenVersion 递增用户 token 版本,使所有已有 JWT 失效
|
||||
// 用于:改密码、封禁用户、管理员强制下线
|
||||
func (s *AuthService) IncrementTokenVersion(userID uint) error {
|
||||
result := s.db.Model(&model.User{}).Where("id = ?", userID).UpdateColumn("token_version", gorm.Expr("token_version + 1"))
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
s.RevokeAllUserRefreshTokens(userID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ChangePassword 修改密码:校验旧密码,更新新密码,递增 token_version 使旧 token 失效
|
||||
func (s *AuthService) ChangePassword(userID uint, oldPassword, newPassword string) error {
|
||||
var user model.User
|
||||
if err := s.db.First(&user, userID).Error; err != nil {
|
||||
return errors.New("用户不存在")
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(oldPassword)); err != nil {
|
||||
return errors.New("旧密码错误")
|
||||
}
|
||||
if len(newPassword) < 6 {
|
||||
return errors.New("新密码至少 6 位")
|
||||
}
|
||||
hashed, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result := s.db.Model(&model.User{}).Where("id = ?", userID).Update("password", string(hashed))
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return errors.New("密码更新失败")
|
||||
}
|
||||
// 递增 token_version,使所有旧 JWT 和 refresh token 失效
|
||||
return s.IncrementTokenVersion(userID)
|
||||
}
|
||||
31
backend/service/board.go
Normal file
31
backend/service/board.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// BoardService 板块服务
|
||||
type BoardService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewBoardService(db *gorm.DB) *BoardService {
|
||||
return &BoardService{db: db}
|
||||
}
|
||||
|
||||
// List 获取所有板块(按 sort_order 排序)
|
||||
func (s *BoardService) List() ([]model.Board, error) {
|
||||
var boards []model.Board
|
||||
err := s.db.Order("sort_order ASC, id ASC").Find(&boards).Error
|
||||
return boards, err
|
||||
}
|
||||
|
||||
// Get 根据 ID 获取板块
|
||||
func (s *BoardService) Get(id uint) (*model.Board, error) {
|
||||
var board model.Board
|
||||
if err := s.db.First(&board, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &board, nil
|
||||
}
|
||||
122
backend/service/comment.go
Normal file
122
backend/service/comment.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// CommentService 评论服务
|
||||
type CommentService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewCommentService(db *gorm.DB) *CommentService {
|
||||
return &CommentService{db: db}
|
||||
}
|
||||
|
||||
// ListByPost 获取帖子的评论列表
|
||||
func (s *CommentService) ListByPost(postID uint) ([]model.Comment, error) {
|
||||
var comments []model.Comment
|
||||
err := s.db.Where("post_id = ? AND status = ?", postID, model.ContentStatusPublished).
|
||||
Order("created_at ASC").
|
||||
Preload("User").
|
||||
Find(&comments).Error
|
||||
return comments, err
|
||||
}
|
||||
|
||||
// Create 创建评论
|
||||
func (s *CommentService) Create(userID, postID uint, content string) (*model.Comment, error) {
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" {
|
||||
return nil, errors.New("评论内容不能为空")
|
||||
}
|
||||
|
||||
// 检查帖子是否存在且未锁定评论
|
||||
var post model.Post
|
||||
if err := s.db.First(&post, postID).Error; err != nil {
|
||||
return nil, errors.New("帖子不存在")
|
||||
}
|
||||
|
||||
comment := &model.Comment{
|
||||
PostID: postID,
|
||||
UserID: userID,
|
||||
Content: content,
|
||||
Status: model.ContentStatusPublished,
|
||||
}
|
||||
if err := s.db.Create(comment).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 更新帖子评论数
|
||||
s.db.Model(&post).UpdateColumn("comment_count", gorm.Expr("comment_count + 1"))
|
||||
// 预加载用户
|
||||
s.db.Preload("User").First(comment, comment.ID)
|
||||
return comment, nil
|
||||
}
|
||||
|
||||
// UserCommentItem 用户评论列表项(含帖子标题便于跳转)
|
||||
type UserCommentItem struct {
|
||||
ID uint `json:"id"`
|
||||
PostID uint `json:"post_id"`
|
||||
PostTitle string `json:"post_title"`
|
||||
Content string `json:"content"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// ListByUser 获取指定用户发表的评论(含所属帖子标题)
|
||||
func (s *CommentService) ListByUser(userID uint, page, size int) ([]UserCommentItem, int64, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if size < 1 || size > 50 {
|
||||
size = 20
|
||||
}
|
||||
query := s.db.Table("comments").
|
||||
Select("comments.id, comments.post_id, posts.title AS post_title, comments.content, comments.created_at").
|
||||
Joins("JOIN posts ON posts.id = comments.post_id").
|
||||
Where("comments.user_id = ? AND comments.status = ? AND posts.deleted_at IS NULL", userID, model.ContentStatusPublished)
|
||||
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var items []UserCommentItem
|
||||
offset := (page - 1) * size
|
||||
if err := query.Order("comments.created_at DESC").Offset(offset).Limit(size).Scan(&items).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
// CountByUser 统计用户评论数(仅统计未删除帖子上的评论,与列表保持一致)
|
||||
func (s *CommentService) CountByUser(userID uint) (int64, error) {
|
||||
var total int64
|
||||
err := s.db.Table("comments").
|
||||
Joins("JOIN posts ON posts.id = comments.post_id").
|
||||
Where("comments.user_id = ? AND comments.status = ? AND posts.deleted_at IS NULL", userID, model.ContentStatusPublished).
|
||||
Count(&total).Error
|
||||
return total, err
|
||||
}
|
||||
|
||||
// Delete 删除评论(仅作者或管理员可操作)
|
||||
func (s *CommentService) Delete(commentID, userID uint, role string) error {
|
||||
var comment model.Comment
|
||||
if err := s.db.First(&comment, commentID).Error; err != nil {
|
||||
return errors.New("评论不存在")
|
||||
}
|
||||
// 权限校验:作者本人或管理员
|
||||
if comment.UserID != userID && role != RoleAdmin {
|
||||
return errors.New("无权限删除此评论")
|
||||
}
|
||||
// 软删除
|
||||
if err := s.db.Delete(&comment).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// 更新帖子评论数
|
||||
s.db.Model(&model.Post{}).Where("id = ?", comment.PostID).
|
||||
UpdateColumn("comment_count", gorm.Expr("GREATEST(comment_count - 1, 0)"))
|
||||
return nil
|
||||
}
|
||||
77
backend/service/like.go
Normal file
77
backend/service/like.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// LikeService 点赞服务
|
||||
type LikeService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewLikeService(db *gorm.DB) *LikeService {
|
||||
return &LikeService{db: db}
|
||||
}
|
||||
|
||||
// Toggle 切换点赞状态:已点赞则取消,未点赞则点赞
|
||||
// 返回 (liked, likeCount, error)
|
||||
func (s *LikeService) Toggle(postID, userID uint) (bool, int, error) {
|
||||
// 检查帖子是否存在
|
||||
var post model.Post
|
||||
if err := s.db.First(&post, postID).Error; err != nil {
|
||||
return false, 0, errors.New("帖子不存在")
|
||||
}
|
||||
|
||||
var like model.Like
|
||||
err := s.db.Where("post_id = ? AND user_id = ?", postID, userID).First(&like).Error
|
||||
|
||||
if err == nil {
|
||||
// 已点赞 → 取消点赞
|
||||
if err := s.db.Delete(&like).Error; err != nil {
|
||||
return false, post.LikeCount, err
|
||||
}
|
||||
newCount := post.LikeCount - 1
|
||||
if newCount < 0 {
|
||||
newCount = 0
|
||||
}
|
||||
s.db.Model(&post).UpdateColumn("like_count", newCount)
|
||||
return false, newCount, nil
|
||||
}
|
||||
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return false, post.LikeCount, err
|
||||
}
|
||||
|
||||
// 未点赞 → 点赞
|
||||
newLike := model.Like{PostID: postID, UserID: userID}
|
||||
if err := s.db.Create(&newLike).Error; err != nil {
|
||||
return false, post.LikeCount, err
|
||||
}
|
||||
newCount := post.LikeCount + 1
|
||||
s.db.Model(&post).UpdateColumn("like_count", newCount)
|
||||
return true, newCount, nil
|
||||
}
|
||||
|
||||
// HasLiked 判断用户是否已点赞某帖子
|
||||
func (s *LikeService) HasLiked(postID, userID uint) bool {
|
||||
var count int64
|
||||
s.db.Model(&model.Like{}).Where("post_id = ? AND user_id = ?", postID, userID).Count(&count)
|
||||
return count > 0
|
||||
}
|
||||
|
||||
// BatchHasLiked 批量查询用户对多个帖子的点赞状态,返回 postID -> bool
|
||||
func (s *LikeService) BatchHasLiked(postIDs []uint, userID uint) map[uint]bool {
|
||||
result := make(map[uint]bool)
|
||||
if len(postIDs) == 0 || userID == 0 {
|
||||
return result
|
||||
}
|
||||
var likes []model.Like
|
||||
s.db.Where("post_id IN ? AND user_id = ?", postIDs, userID).Find(&likes)
|
||||
for _, l := range likes {
|
||||
result[l.PostID] = true
|
||||
}
|
||||
return result
|
||||
}
|
||||
82
backend/service/notification.go
Normal file
82
backend/service/notification.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// NotificationService 通知服务
|
||||
type NotificationService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewNotificationService(db *gorm.DB) *NotificationService {
|
||||
return &NotificationService{db: db}
|
||||
}
|
||||
|
||||
// Create 创建通知(actorID == userID 时跳过,不通知自己)
|
||||
func (s *NotificationService) Create(userID, actorID uint, notifType string, postID, commentID uint, content string) {
|
||||
if userID == 0 || actorID == 0 || userID == actorID {
|
||||
return
|
||||
}
|
||||
// 截断内容预览
|
||||
if len(content) > 200 {
|
||||
content = content[:200]
|
||||
}
|
||||
n := &model.Notification{
|
||||
UserID: userID,
|
||||
ActorID: actorID,
|
||||
Type: notifType,
|
||||
PostID: postID,
|
||||
CommentID: commentID,
|
||||
Content: content,
|
||||
IsRead: false,
|
||||
}
|
||||
// 通知创建失败不影响主流程,忽略错误
|
||||
_ = s.db.Create(n).Error
|
||||
}
|
||||
|
||||
// List 获取用户通知列表(分页,按时间倒序)
|
||||
func (s *NotificationService) List(userID uint, page, size int) ([]model.Notification, int64, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if size < 1 || size > 50 {
|
||||
size = 20
|
||||
}
|
||||
query := s.db.Model(&model.Notification{}).Where("user_id = ?", userID)
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var list []model.Notification
|
||||
offset := (page - 1) * size
|
||||
if err := query.Order("created_at DESC").Offset(offset).Limit(size).
|
||||
Preload("Actor").Preload("Post").Find(&list).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return list, total, nil
|
||||
}
|
||||
|
||||
// UnreadCount 获取未读通知数
|
||||
func (s *NotificationService) UnreadCount(userID uint) (int64, error) {
|
||||
var total int64
|
||||
err := s.db.Model(&model.Notification{}).Where("user_id = ? AND is_read = ?", userID, false).Count(&total).Error
|
||||
return total, err
|
||||
}
|
||||
|
||||
// MarkRead 标记单条通知为已读(校验归属)
|
||||
func (s *NotificationService) MarkRead(id, userID uint) error {
|
||||
result := s.db.Model(&model.Notification{}).
|
||||
Where("id = ? AND user_id = ?", id, userID).
|
||||
Update("is_read", true)
|
||||
return result.Error
|
||||
}
|
||||
|
||||
// MarkAllRead 标记用户所有通知为已读
|
||||
func (s *NotificationService) MarkAllRead(userID uint) error {
|
||||
result := s.db.Model(&model.Notification{}).
|
||||
Where("user_id = ? AND is_read = ?", userID, false).
|
||||
Update("is_read", true)
|
||||
return result.Error
|
||||
}
|
||||
287
backend/service/post.go
Normal file
287
backend/service/post.go
Normal file
@@ -0,0 +1,287 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// PostService 帖子服务
|
||||
type PostService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewPostService(db *gorm.DB) *PostService {
|
||||
return &PostService{db: db}
|
||||
}
|
||||
|
||||
// PostListQuery 帖子列表查询参数
|
||||
type PostListQuery struct {
|
||||
BoardID uint
|
||||
Page int
|
||||
Size int
|
||||
Sort string // latest | hot
|
||||
Keyword string // 搜索关键词
|
||||
}
|
||||
|
||||
// PostListItem 帖子列表项(不含正文)
|
||||
type PostListItem struct {
|
||||
ID uint `json:"id"`
|
||||
BoardID uint `json:"board_id"`
|
||||
UserID uint `json:"user_id"`
|
||||
Title string `json:"title"`
|
||||
Tags string `json:"tags"`
|
||||
PostType string `json:"post_type"`
|
||||
Pinned int `json:"pinned"`
|
||||
Recommended bool `json:"recommended"`
|
||||
LikeCount int `json:"like_count"`
|
||||
ViewCount int `json:"view_count"`
|
||||
CommentCount int `json:"comment_count"`
|
||||
Liked bool `json:"liked"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Board model.Board `json:"board"`
|
||||
User model.User `json:"user"`
|
||||
}
|
||||
|
||||
// List 获取帖子列表
|
||||
func (s *PostService) List(q PostListQuery) ([]PostListItem, int64, error) {
|
||||
if q.Page < 1 {
|
||||
q.Page = 1
|
||||
}
|
||||
if q.Size < 1 || q.Size > 50 {
|
||||
q.Size = 20
|
||||
}
|
||||
|
||||
query := s.db.Model(&model.Post{}).Where("status = ?", model.ContentStatusPublished)
|
||||
if q.BoardID > 0 {
|
||||
query = query.Where("board_id = ?", q.BoardID)
|
||||
}
|
||||
if q.Keyword != "" {
|
||||
kw := "%" + q.Keyword + "%"
|
||||
query = query.Where("title ILIKE ? OR content ILIKE ? OR tags ILIKE ?", kw, kw, kw)
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
var posts []model.Post
|
||||
offset := (q.Page - 1) * q.Size
|
||||
order := "pinned DESC, created_at DESC"
|
||||
if q.Sort == "hot" {
|
||||
order = "pinned DESC, (like_count + comment_count * 2) DESC, created_at DESC"
|
||||
}
|
||||
if err := query.Order(order).Offset(offset).Limit(q.Size).
|
||||
Preload("Board").Preload("User").Find(&posts).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
items := make([]PostListItem, 0, len(posts))
|
||||
for _, p := range posts {
|
||||
items = append(items, PostListItem{
|
||||
ID: p.ID, BoardID: p.BoardID, UserID: p.UserID,
|
||||
Title: p.Title, Tags: p.Tags, PostType: p.PostType,
|
||||
Pinned: p.Pinned, Recommended: p.Recommended, LikeCount: p.LikeCount, ViewCount: p.ViewCount,
|
||||
CommentCount: p.CommentCount, CreatedAt: p.CreatedAt,
|
||||
Board: p.Board, User: p.User,
|
||||
})
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
// ListByUser 获取指定用户发布的帖子(公开已发布)
|
||||
func (s *PostService) ListByUser(userID uint, page, size int) ([]PostListItem, int64, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if size < 1 || size > 50 {
|
||||
size = 20
|
||||
}
|
||||
query := s.db.Model(&model.Post{}).Where("user_id = ? AND status = ?", userID, model.ContentStatusPublished)
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var posts []model.Post
|
||||
offset := (page - 1) * size
|
||||
if err := query.Order("pinned DESC, created_at DESC").Offset(offset).Limit(size).
|
||||
Preload("Board").Preload("User").Find(&posts).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
items := make([]PostListItem, 0, len(posts))
|
||||
for _, p := range posts {
|
||||
items = append(items, PostListItem{
|
||||
ID: p.ID, BoardID: p.BoardID, UserID: p.UserID,
|
||||
Title: p.Title, Tags: p.Tags, PostType: p.PostType,
|
||||
Pinned: p.Pinned, Recommended: p.Recommended, LikeCount: p.LikeCount, ViewCount: p.ViewCount,
|
||||
CommentCount: p.CommentCount, CreatedAt: p.CreatedAt,
|
||||
Board: p.Board, User: p.User,
|
||||
})
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
// GetAuthorID 获取帖子作者 ID(不增加浏览量)
|
||||
func (s *PostService) GetAuthorID(postID uint) (uint, error) {
|
||||
var post model.Post
|
||||
if err := s.db.Select("user_id").First(&post, postID).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return post.UserID, nil
|
||||
}
|
||||
|
||||
// CountByUser 统计用户发帖数
|
||||
func (s *PostService) CountByUser(userID uint) (int64, error) {
|
||||
var total int64
|
||||
err := s.db.Model(&model.Post{}).Where("user_id = ? AND status = ?", userID, model.ContentStatusPublished).Count(&total).Error
|
||||
return total, err
|
||||
}
|
||||
|
||||
// TogglePin 切换置顶状态(仅管理员可操作,由 handler 校验权限)
|
||||
func (s *PostService) TogglePin(id uint) (int, error) {
|
||||
var post model.Post
|
||||
if err := s.db.First(&post, id).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var newPinned int
|
||||
if post.Pinned > 0 {
|
||||
newPinned = 0
|
||||
} else {
|
||||
newPinned = 1
|
||||
}
|
||||
result := s.db.Model(&post).Update("pinned", newPinned)
|
||||
if result.Error != nil {
|
||||
return 0, result.Error
|
||||
}
|
||||
return newPinned, nil
|
||||
}
|
||||
|
||||
// ToggleRecommend 切换推荐状态(仅管理员可操作,由 handler 校验权限)
|
||||
func (s *PostService) ToggleRecommend(id uint) (bool, error) {
|
||||
var post model.Post
|
||||
if err := s.db.First(&post, id).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
newVal := !post.Recommended
|
||||
result := s.db.Model(&post).Update("recommended", newVal)
|
||||
if result.Error != nil {
|
||||
return false, result.Error
|
||||
}
|
||||
return newVal, nil
|
||||
}
|
||||
|
||||
// GetByID 获取帖子详情
|
||||
func (s *PostService) GetByID(id uint) (*model.Post, error) {
|
||||
var post model.Post
|
||||
if err := s.db.Preload("Board").Preload("User").First(&post, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 增加浏览量
|
||||
s.db.Model(&post).UpdateColumn("view_count", gorm.Expr("view_count + 1"))
|
||||
return &post, nil
|
||||
}
|
||||
|
||||
// Create 创建帖子
|
||||
func (s *PostService) Create(userID uint, boardID uint, title, content, tags, postType string) (*model.Post, error) {
|
||||
title = strings.TrimSpace(title)
|
||||
content = strings.TrimSpace(content)
|
||||
if title == "" {
|
||||
return nil, errors.New("标题不能为空")
|
||||
}
|
||||
if content == "" {
|
||||
return nil, errors.New("内容不能为空")
|
||||
}
|
||||
if boardID == 0 {
|
||||
return nil, errors.New("请选择板块")
|
||||
}
|
||||
|
||||
// 新用户 24h 冷静期校验
|
||||
if err := s.checkNewUserCooldown(userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
post := &model.Post{
|
||||
BoardID: boardID,
|
||||
UserID: userID,
|
||||
Title: title,
|
||||
Content: content,
|
||||
Tags: tags,
|
||||
PostType: postType,
|
||||
Status: model.ContentStatusPublished,
|
||||
}
|
||||
if err := s.db.Create(post).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 预加载关联
|
||||
s.db.Preload("Board").Preload("User").First(post, post.ID)
|
||||
return post, nil
|
||||
}
|
||||
|
||||
// checkNewUserCooldown 新用户发帖 24h 冷静期
|
||||
func (s *PostService) checkNewUserCooldown(userID uint) error {
|
||||
var user model.User
|
||||
if err := s.db.First(&user, userID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// 注册不足 24 小时的新用户不能发帖
|
||||
if time.Since(user.CreatedAt) < 24*time.Hour {
|
||||
return errors.New("新用户注册 24 小时后才能发帖")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update 更新帖子(仅作者或管理员可操作)
|
||||
func (s *PostService) Update(postID, userID uint, role string, title, content, tags string) (*model.Post, error) {
|
||||
var post model.Post
|
||||
if err := s.db.First(&post, postID).Error; err != nil {
|
||||
return nil, errors.New("帖子不存在")
|
||||
}
|
||||
// 权限校验:作者本人或管理员
|
||||
if post.UserID != userID && role != RoleAdmin {
|
||||
return nil, errors.New("无权限编辑此帖子")
|
||||
}
|
||||
|
||||
updates := map[string]interface{}{}
|
||||
if title != "" {
|
||||
t := strings.TrimSpace(title)
|
||||
if t == "" {
|
||||
return nil, errors.New("标题不能为空")
|
||||
}
|
||||
updates["title"] = t
|
||||
}
|
||||
if content != "" {
|
||||
c := strings.TrimSpace(content)
|
||||
if c == "" {
|
||||
return nil, errors.New("内容不能为空")
|
||||
}
|
||||
updates["content"] = c
|
||||
}
|
||||
updates["tags"] = tags
|
||||
|
||||
if err := s.db.Model(&post).Updates(updates).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.db.Preload("Board").Preload("User").First(&post, post.ID)
|
||||
return &post, nil
|
||||
}
|
||||
|
||||
// Delete 删除帖子(仅作者或管理员可操作)
|
||||
func (s *PostService) Delete(postID, userID uint, role string) error {
|
||||
var post model.Post
|
||||
if err := s.db.First(&post, postID).Error; err != nil {
|
||||
return errors.New("帖子不存在")
|
||||
}
|
||||
// 权限校验:作者本人或管理员
|
||||
if post.UserID != userID && role != RoleAdmin {
|
||||
return errors.New("无权限删除此帖子")
|
||||
}
|
||||
// 软删除(gorm DeletedAt)
|
||||
if err := s.db.Delete(&post).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
79
backend/service/ratelimit.go
Normal file
79
backend/service/ratelimit.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RateLimiter 内存速率限制器
|
||||
type RateLimiter struct {
|
||||
mu sync.Mutex
|
||||
records map[string][]time.Time
|
||||
limits map[string]int // key -> 每分钟最大请求数
|
||||
cooldown map[string]time.Duration
|
||||
}
|
||||
|
||||
func NewRateLimiter() *RateLimiter {
|
||||
return &RateLimiter{
|
||||
records: make(map[string][]time.Time),
|
||||
limits: make(map[string]int),
|
||||
cooldown: make(map[string]time.Duration),
|
||||
}
|
||||
}
|
||||
|
||||
// SetLimit 设置某类请求的每分钟限制
|
||||
func (r *RateLimiter) SetLimit(key string, perMinute int) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.limits[key] = perMinute
|
||||
}
|
||||
|
||||
// Allow 检查是否允许请求
|
||||
func (r *RateLimiter) Allow(key string) bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
limit, ok := r.limits[key]
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
windowStart := now.Add(-1 * time.Minute)
|
||||
|
||||
// 清理过期记录
|
||||
records := r.records[key]
|
||||
valid := records[:0]
|
||||
for _, t := range records {
|
||||
if t.After(windowStart) {
|
||||
valid = append(valid, t)
|
||||
}
|
||||
}
|
||||
|
||||
if len(valid) >= limit {
|
||||
r.records[key] = valid
|
||||
return false
|
||||
}
|
||||
|
||||
valid = append(valid, now)
|
||||
r.records[key] = valid
|
||||
return true
|
||||
}
|
||||
|
||||
// 速率限制类型常量
|
||||
const (
|
||||
RateLogin = "login"
|
||||
RateRegister = "register"
|
||||
RatePost = "post"
|
||||
RateComment = "comment"
|
||||
)
|
||||
|
||||
// DefaultRateLimiter 创建默认速率限制器
|
||||
func DefaultRateLimiter() *RateLimiter {
|
||||
rl := NewRateLimiter()
|
||||
rl.SetLimit(RateLogin, 20) // 登录 20/分钟
|
||||
rl.SetLimit(RateRegister, 10) // 注册 10/分钟
|
||||
rl.SetLimit(RatePost, 10) // 发帖 10/分钟
|
||||
rl.SetLimit(RateComment, 30) // 评论 30/分钟
|
||||
return rl
|
||||
}
|
||||
Reference in New Issue
Block a user