首次提交:姜十三论坛

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

14
.env.example Normal file
View File

@@ -0,0 +1,14 @@
# jiang13-bbs 环境变量示例
# 复制为 .env 并修改
# PostgreSQL 连接串
DB_DSN=postgres://postgres:postgres@localhost:5432/jiang13?sslmode=disable
# JWT 签名密钥(生产环境务必修改)
JWT_SECRET=your-jwt-secret-change-me
# 服务端口
HTTP_PORT=3001
# 开发模式
DEV_MODE=true

53
.gitignore vendored Normal file
View File

@@ -0,0 +1,53 @@
# ===== 依赖目录 =====
node_modules/
frontend/node_modules/
# ===== Next.js 构建产物 =====
.next/
out/
.vercel/
*.tsbuildinfo
next-debug.log*
# ===== Go 构建产物 =====
*.exe
*.exe~
*.dll
*.so
*.dylib
*.test
*.out
/backend/jiang13
/backend/jiang13.exe
# ===== 本地配置与运行时数据(含密钥,禁止提交) =====
.env
.env.local
.env.*.local
backend/app.ini
backend/data/
*.pem
*.key
# ===== 日志 =====
*.log
logs/
# ===== 测试与覆盖率 =====
coverage/
.nyc_output/
# ===== IDE / 编辑器 =====
.idea/
.vscode/
*.swp
*.swo
*~
# ===== 操作系统 =====
.DS_Store
Thumbs.db
desktop.ini
# ===== Trae 工具内部目录 =====
.trae/

38
LICENSE Normal file
View File

@@ -0,0 +1,38 @@
姜十三论坛 专有软件许可协议
Proprietary Software License Agreement
Copyright (c) 2026 姜十三. All Rights Reserved.
版权所有 (c) 2026 姜十三。保留所有权利。
本软件(包括但不限于源代码、目标代码、文档、图像及其他随附资料,
以下统称"本软件")由版权方(以下称"授权方")开发并所有。本软件
以源码可见的方式发布,但这并不构成任何形式的开源授权。
在未获得授权方事先书面许可之前,任何个人或组织不得:
1. 以任何目的(包括商业、内部生产、教学培训等)使用、运行或部署
本软件的全部或任何部分;
2. 复制、影印、转载、镜像、收录到任何数据库或以任何方式再现本软件;
3. 修改、改编、翻译、合并、反向工程、反编译、反汇编本软件,或基于
本软件创作衍生作品;
4. 出售、出租、出借、分销、再许可、托管(SaaS)或以其他方式向第三方
提供本软件或其任何副本;
5. 删除、遮盖或更改本软件中的版权声明、商标或其他权利声明。
仅可出于个人学习、评估是否购买授权的目的浏览本仓库中的源代码;该等
浏览不视为获得任何明示或默示的使用许可。
商业使用、内部部署或其他任何形式的使用授权,均须与授权方另行签订
书面许可协议。
第三方开源组件按其各自附带的许可证条款授权,不受本协议约束。
本软件按"现状"(AS IS)提供,不附带任何明示或默示的担保,包括但不限
于适销性、特定用途适用性或不侵权的担保。在任何情况下,授权方均不对因
使用或无法使用本软件而导致的任何直接、间接、附带、特殊或后果性损害
承担责任。
未经授权使用本软件的行为,均构成对授权方著作权及其他知识产权的侵犯,
授权方保留依法追究法律责任的权利。
授权方联系方式:aarbbs@88.com

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

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

View File

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

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

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

49
backend/go.mod Normal file
View File

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

121
backend/go.sum Normal file
View File

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

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

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

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

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

View File

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

View File

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

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

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

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

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

View File

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

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

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

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

@@ -0,0 +1,79 @@
package handler
import (
"encoding/xml"
"fmt"
"net/http"
"time"
"github.com/freefire/jiang13-bbs/model"
"github.com/gin-gonic/gin"
)
const siteBaseURL = "http://localhost:3000"
// RobotsTxt robots.txt
func (h *Handlers) RobotsTxt(c *gin.Context) {
content := fmt.Sprintf(`User-agent: *
Allow: /
Sitemap: %s/sitemap.xml
`, siteBaseURL)
c.String(http.StatusOK, content)
}
// SitemapXML 生成 sitemap
func (h *Handlers) SitemapXML(c *gin.Context) {
type urlEntry struct {
Loc string `xml:"loc"`
Lastmod string `xml:"lastmod"`
Changefreq string `xml:"changefreq"`
Priority string `xml:"priority"`
}
type urlset struct {
XMLName xml.Name `xml:"urlset"`
Xmlns string `xml:"xmlns,attr"`
URLs []urlEntry `xml:"url"`
}
now := time.Now().Format("2006-01-02")
entries := []urlEntry{
{Loc: siteBaseURL + "/", Lastmod: now, Changefreq: "daily", Priority: "1.0"},
}
// 板块页
var boards []model.Board
model.DB.Find(&boards)
for _, b := range boards {
entries = append(entries, urlEntry{
Loc: fmt.Sprintf("%s/board/%d", siteBaseURL, b.ID),
Lastmod: now,
Changefreq: "daily",
Priority: "0.8",
})
}
// 帖子详情页
var posts []model.Post
model.DB.Where("status = ?", model.ContentStatusPublished).
Order("created_at DESC").Limit(1000).Find(&posts)
for _, p := range posts {
entries = append(entries, urlEntry{
Loc: fmt.Sprintf("%s/post/%d", siteBaseURL, p.ID),
Lastmod: p.UpdatedAt.Format("2006-01-02"),
Changefreq: "weekly",
Priority: "0.6",
})
}
us := urlset{
Xmlns: "http://www.sitemaps.org/schemas/sitemap/0.9",
URLs: entries,
}
output, err := xml.MarshalIndent(us, "", " ")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Header("Content-Type", "application/xml")
c.String(http.StatusOK, xml.Header+string(output))
}

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

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

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

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

View File

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

View File

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

View File

@@ -0,0 +1,43 @@
package middleware
import (
"github.com/gin-gonic/gin"
)
// SecurityHeaders 安全响应头中间件
// 为所有响应添加安全头,降低 XSS、点击劫持、MIME 嗅探等风险
func SecurityHeaders() gin.HandlerFunc {
return func(c *gin.Context) {
// CSP:限制资源加载来源,降低 XSS 危害
c.Header("Content-Security-Policy",
"default-src 'self'; "+
"script-src 'self' 'unsafe-inline'; "+
"style-src 'self' 'unsafe-inline'; "+
"img-src 'self' data: https:; "+
"font-src 'self' data:; "+
"connect-src 'self'; "+
"frame-ancestors 'none'; "+
"base-uri 'self'; "+
"form-action 'self'")
// HSTS:强制 HTTPS(生产环境生效,dev 模式浏览器会忽略)
c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload")
// 点击劫持防护
c.Header("X-Frame-Options", "DENY")
// MIME 嗅探防护
c.Header("X-Content-Type-Options", "nosniff")
// 控制 Referer 信息泄露
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
// XSS 防护(旧浏览器兼容,现代浏览器靠 CSP)
c.Header("X-XSS-Protection", "1; mode=block")
// 禁止浏览器缓存敏感页面(可按需覆盖)
// c.Header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
c.Next()
}
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

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

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

View File

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

21
docker-compose.yml Normal file
View File

@@ -0,0 +1,21 @@
services:
postgres:
image: postgres:17-alpine
container_name: jiang13-postgres
restart: unless-stopped
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: jiang13
ports:
- "5432:5432"
volumes:
- jiang13-pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d jiang13"]
interval: 5s
timeout: 5s
retries: 5
volumes:
jiang13-pgdata:

View File

@@ -0,0 +1,83 @@
import Link from "next/link";
import { Metadata } from "next";
import { fetchPosts, fetchBoards } from "@/lib/api";
import Pagination from "@/components/Pagination";
interface PageProps {
params: Promise<{ id: string }>;
searchParams: Promise<{ page?: string }>;
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { id } = await params;
const { boards } = await fetchBoards();
const board = boards.find((b) => b.id === Number(id));
return {
title: board ? `${board.name} - 姜十三论坛` : "板块 - 姜十三论坛",
description: board?.description || "板块帖子列表",
};
}
export default async function BoardPage({ params, searchParams }: PageProps) {
const { id } = await params;
const { page: pageStr } = await searchParams;
const page = Math.max(1, parseInt(pageStr || "1", 10) || 1);
const [postsData, boardsData] = await Promise.all([
fetchPosts(page, 20, Number(id)),
fetchBoards(),
]);
const board = boardsData.boards.find((b) => b.id === Number(id));
return (
<div>
<nav className="text-sm mb-4" style={{ color: "var(--text-secondary)" }}>
<Link href="/" className="hover:underline">首页</Link>
<span className="mx-2">/</span>
<span>{board?.name || "板块"}</span>
</nav>
<h1 className="text-2xl font-bold mb-2">{board?.name}</h1>
<p className="mb-6" style={{ color: "var(--text-secondary)" }}>{board?.description}</p>
<div className="space-y-3">
{postsData.posts.map((post) => (
<Link
key={post.id}
href={`/post/${post.id}`}
className="block p-4 rounded-lg border hover:shadow-md transition-shadow"
style={{ borderColor: "var(--border)", background: "var(--bg)" }}
>
<div className="flex items-center gap-2 mb-1">
{post.pinned > 0 && (
<span className="text-xs px-2 py-0.5 rounded bg-amber-100 text-amber-700">置顶</span>
)}
{post.recommended && (
<span className="text-xs px-2 py-0.5 rounded bg-rose-100 text-rose-700">推荐</span>
)}
</div>
<h2 className="font-semibold mb-1 hover:underline">{post.title}</h2>
<div className="flex items-center gap-4 text-sm" style={{ color: "var(--text-secondary)" }}>
<Link href={`/u/${post.user.id}`} className="hover:underline" style={{ color: "var(--primary)" }}>
{post.user.nickname}
</Link>
<span>{new Date(post.created_at).toLocaleDateString("zh-CN")}</span>
<span>💬 {post.comment_count}</span>
<span>👁 {post.view_count}</span>
</div>
</Link>
))}
{postsData.posts.length === 0 && (
<p className="text-center py-12" style={{ color: "var(--text-secondary)" }}>
该板块暂无帖子
</p>
)}
</div>
<Pagination
total={postsData.total}
page={page}
size={20}
basePath={`/board/${id}`}
/>
</div>
);
}

View File

@@ -0,0 +1,122 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { apiCreatePost, apiMe, fetchBoards, type Board } from "@/lib/api";
export default function ComposePage() {
const router = useRouter();
const [boards, setBoards] = useState<Board[]>([]);
const [boardId, setBoardId] = useState<number>(0);
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [tags, setTags] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
// 通过 /api/me 检查登录态(HttpOnly cookie 自动携带)
apiMe()
.then((res) => {
if (!res.user) {
router.push("/login?redirect=/compose");
return;
}
fetchBoards().then((data) => {
setBoards(data.boards);
if (data.boards.length > 0) setBoardId(data.boards[0].id);
});
})
.catch(() => router.push("/login?redirect=/compose"));
}, [router]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError("");
try {
const res = await apiCreatePost({
board_id: boardId,
title,
content,
tags,
post_type: "normal",
});
if (res.post) {
router.push(`/post/${res.post.id}`);
} else {
setError(res.error || "发帖失败");
}
} finally {
setLoading(false);
}
};
return (
<div className="max-w-3xl mx-auto">
<h1 className="text-2xl font-bold mb-6">发布新帖</h1>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="p-3 rounded-lg bg-red-50 text-red-600 text-sm">{error}</div>
)}
<div>
<label className="block text-sm mb-1" style={{ color: "var(--text-secondary)" }}>板块</label>
<select
value={boardId}
onChange={(e) => setBoardId(Number(e.target.value))}
className="w-full px-3 py-2 rounded-lg border"
style={{ borderColor: "var(--border)", background: "var(--bg)", color: "var(--text)" }}
>
{boards.map((b) => (
<option key={b.id} value={b.id}>{b.name}</option>
))}
</select>
</div>
<div>
<label className="block text-sm mb-1" style={{ color: "var(--text-secondary)" }}>标题</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
maxLength={256}
placeholder="帖子标题"
className="w-full px-3 py-2 rounded-lg border"
style={{ borderColor: "var(--border)", background: "var(--bg)", color: "var(--text)" }}
/>
</div>
<div>
<label className="block text-sm mb-1" style={{ color: "var(--text-secondary)" }}>标签(逗号分隔,可选)</label>
<input
type="text"
value={tags}
onChange={(e) => setTags(e.target.value)}
placeholder="例如:技术,Go"
className="w-full px-3 py-2 rounded-lg border"
style={{ borderColor: "var(--border)", background: "var(--bg)", color: "var(--text)" }}
/>
</div>
<div>
<label className="block text-sm mb-1" style={{ color: "var(--text-secondary)" }}>内容(支持 Markdown)</label>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
required
rows={12}
placeholder="帖子内容,支持 Markdown 格式..."
className="w-full px-3 py-2 rounded-lg border resize-y font-mono"
style={{ borderColor: "var(--border)", background: "var(--bg)", color: "var(--text)" }}
/>
</div>
<button
type="submit"
disabled={loading || !title.trim() || !content.trim()}
className="px-6 py-2 rounded-lg text-white disabled:opacity-50"
style={{ background: "var(--primary)" }}
>
{loading ? "发布中..." : "发布帖子"}
</button>
</form>
</div>
);
}

40
frontend/app/globals.css Normal file
View File

@@ -0,0 +1,40 @@
@import "tailwindcss";
:root {
--bg: #ffffff;
--bg-secondary: #f8fafc;
--text: #1e293b;
--text-secondary: #64748b;
--border: #e2e8f0;
--primary: #3b82f6;
--primary-hover: #2563eb;
}
.dark {
--bg: #0f172a;
--bg-secondary: #1e293b;
--text: #e2e8f0;
--text-secondary: #94a3b8;
--border: #334155;
--primary: #60a5fa;
--primary-hover: #3b82f6;
}
body {
background-color: var(--bg);
color: var(--text);
}
/* Markdown 内容样式 */
.prose-content h1 { font-size: 1.875rem; font-weight: 700; margin: 1rem 0; }
.prose-content h2 { font-size: 1.5rem; font-weight: 700; margin: 1rem 0; }
.prose-content h3 { font-size: 1.25rem; font-weight: 600; margin: 0.75rem 0; }
.prose-content p { margin: 0.75rem 0; line-height: 1.7; }
.prose-content ul { list-style: disc; padding-left: 1.5rem; margin: 0.75rem 0; }
.prose-content ol { list-style: decimal; padding-left: 1.5rem; margin: 0.75rem 0; }
.prose-content code { background: var(--bg-secondary); padding: 0.125rem 0.375rem; border-radius: 0.25rem; font-size: 0.875rem; }
.prose-content pre { background: var(--bg-secondary); padding: 1rem; border-radius: 0.5rem; overflow-x: auto; margin: 1rem 0; }
.prose-content pre code { background: none; padding: 0; }
.prose-content blockquote { border-left: 4px solid var(--border); padding-left: 1rem; color: var(--text-secondary); margin: 1rem 0; }
.prose-content a { color: var(--primary); text-decoration: underline; }
.prose-content img { max-width: 100%; border-radius: 0.5rem; }

36
frontend/app/layout.tsx Normal file
View File

@@ -0,0 +1,36 @@
import type { Metadata } from "next";
import { cookies } from "next/headers";
import "./globals.css";
import Header from "@/components/Header";
import Footer from "@/components/Footer";
export const metadata: Metadata = {
title: "姜十三论坛",
description: "姜十三论坛 - 技术分享与讨论社区",
openGraph: {
title: "姜十三论坛",
description: "姜十三论坛 - 技术分享与讨论社区",
type: "website",
},
};
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const cookieStore = await cookies();
const theme = cookieStore.get("j13-theme")?.value || "light";
return (
<html lang="zh-CN" className={theme === "dark" ? "dark" : ""}>
<body className="min-h-screen flex flex-col">
<Header />
<main className="flex-1 max-w-5xl mx-auto w-full px-4 py-6">
{children}
</main>
<Footer />
</body>
</html>
);
}

View File

@@ -0,0 +1,77 @@
"use client";
import { useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { apiLogin } from "@/lib/api";
export default function LoginPage() {
const router = useRouter();
const searchParams = useSearchParams();
const redirect = searchParams.get("redirect") || "/";
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
try {
const res = await apiLogin(username, password);
// 登录成功后后端通过 Set-Cookie 写入 HttpOnly JWT,前端无需存储 token
if (res.user) {
router.push(redirect);
} else {
setError(res.error || "登录失败");
}
} finally {
setLoading(false);
}
};
return (
<div className="max-w-md mx-auto mt-12">
<h1 className="text-2xl font-bold mb-6 text-center">登录</h1>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="p-3 rounded-lg bg-red-50 text-red-600 text-sm">{error}</div>
)}
<div>
<label className="block text-sm mb-1" style={{ color: "var(--text-secondary)" }}>用户名</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
className="w-full px-3 py-2 rounded-lg border"
style={{ borderColor: "var(--border)", background: "var(--bg)", color: "var(--text)" }}
/>
</div>
<div>
<label className="block text-sm mb-1" style={{ color: "var(--text-secondary)" }}>密码</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full px-3 py-2 rounded-lg border"
style={{ borderColor: "var(--border)", background: "var(--bg)", color: "var(--text)" }}
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-2 rounded-lg text-white disabled:opacity-50"
style={{ background: "var(--primary)" }}
>
{loading ? "登录中..." : "登录"}
</button>
</form>
<p className="text-center mt-4 text-sm" style={{ color: "var(--text-secondary)" }}>
还没有账号?<Link href="/register" className="hover:underline" style={{ color: "var(--primary)" }}>立即注册</Link>
</p>
</div>
);
}

View File

@@ -0,0 +1,131 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Heart, MessageCircle, CheckCheck } from "lucide-react";
import {
apiFetchNotifications,
apiMarkRead,
apiMarkAllRead,
type NotificationItem,
} from "@/lib/api";
export default function NotificationsPage() {
const router = useRouter();
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
const [loading, setLoading] = useState(true);
const load = async () => {
setLoading(true);
try {
const res = await apiFetchNotifications(1);
setNotifications(res.notifications || []);
} catch {
setNotifications([]);
}
setLoading(false);
};
useEffect(() => {
load();
}, []);
const handleClick = async (n: NotificationItem) => {
if (!n.is_read) {
apiMarkRead(n.id).catch(() => {});
setNotifications((list) =>
list.map((item) => (item.id === n.id ? { ...item, is_read: true } : item))
);
}
router.push(`/post/${n.post_id}`);
};
const handleMarkAll = async () => {
await apiMarkAllRead();
setNotifications((list) => list.map((item) => ({ ...item, is_read: true })));
};
const formatTime = (dateStr: string) => {
const diff = Date.now() - new Date(dateStr).getTime();
const min = Math.floor(diff / 60000);
if (min < 1) return "刚刚";
if (min < 60) return `${min} 分钟前`;
const hour = Math.floor(min / 60);
if (hour < 24) return `${hour} 小时前`;
return `${Math.floor(hour / 24)} 天前`;
};
const hasUnread = notifications.some((n) => !n.is_read);
return (
<div className="max-w-2xl mx-auto">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold" style={{ color: "var(--text)" }}>通知中心</h1>
{hasUnread && (
<button
onClick={handleMarkAll}
className="flex items-center gap-1 text-sm hover:opacity-80"
style={{ color: "var(--primary)" }}
>
<CheckCheck size={16} /> 全部标记已读
</button>
)}
</div>
{loading ? (
<p className="text-center py-12" style={{ color: "var(--text-secondary)" }}>加载中...</p>
) : notifications.length === 0 ? (
<p className="text-center py-12" style={{ color: "var(--text-secondary)" }}>暂无通知</p>
) : (
<div className="space-y-2">
{notifications.map((n) => (
<button
key={n.id}
onClick={() => handleClick(n)}
className="w-full text-left p-4 rounded-lg border hover:shadow-sm transition-shadow"
style={{
borderColor: "var(--border)",
background: n.is_read ? "var(--bg)" : "var(--bg-secondary)",
}}
>
<div className="flex items-start gap-3">
<span style={{ color: n.type === "like" ? "#ef4444" : "var(--primary)" }}>
{n.type === "like" ? <Heart size={18} /> : <MessageCircle size={18} />}
</span>
<div className="flex-1 min-w-0">
<p className="text-sm" style={{ color: "var(--text)" }}>
<Link
href={`/u/${n.actor.id}`}
onClick={(e) => e.stopPropagation()}
className="font-medium hover:underline"
>
{n.actor.nickname}
</Link>{" "}
<span style={{ color: "var(--text-secondary)" }}>
{n.type === "comment" ? "评论了你的帖子" : "点赞了你的帖子"}
</span>
</p>
<p className="text-sm mt-1 font-medium truncate" style={{ color: "var(--text)" }}>
《{n.post.title}》
</p>
{n.content && (
<p className="text-sm mt-1 line-clamp-2" style={{ color: "var(--text-secondary)" }}>
{n.content}
</p>
)}
<p className="text-xs mt-2" style={{ color: "var(--text-secondary)" }}>
{formatTime(n.created_at)}
</p>
</div>
{!n.is_read && (
<span className="w-2.5 h-2.5 rounded-full shrink-0 mt-1.5" style={{ background: "var(--primary)" }} />
)}
</div>
</button>
))}
</div>
)}
</div>
);
}

105
frontend/app/page.tsx Normal file
View File

@@ -0,0 +1,105 @@
import Link from "next/link";
import { Metadata } from "next";
import { fetchPosts, fetchBoards } from "@/lib/api";
import Pagination from "@/components/Pagination";
export const metadata: Metadata = {
title: "姜十三论坛 - 技术分享与讨论社区",
description: "姜十三论坛,分享技术、讨论问题、交流心得的社区",
openGraph: {
title: "姜十三论坛 - 技术分享与讨论社区",
description: "姜十三论坛,分享技术、讨论问题、交流心得的社区",
type: "website",
},
};
interface HomePageProps {
searchParams: Promise<{ q?: string; page?: string }>;
}
export default async function HomePage({ searchParams }: HomePageProps) {
const { q, page: pageStr } = await searchParams;
const keyword = q?.trim() || "";
const page = Math.max(1, parseInt(pageStr || "1", 10) || 1);
const [postsData, boardsData] = await Promise.all([
fetchPosts(page, 20, undefined, "latest", keyword),
fetchBoards(),
]);
return (
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
{/* 主内容:帖子列表 */}
<div className="md:col-span-3">
<h1 className="text-2xl font-bold mb-4">
{keyword ? `搜索结果:"${keyword}"` : "最新帖子"}
</h1>
<div className="space-y-3">
{postsData.posts.map((post) => (
<Link
key={post.id}
href={`/post/${post.id}`}
className="block p-4 rounded-lg border hover:shadow-md transition-shadow"
style={{ borderColor: "var(--border)", background: "var(--bg)" }}
>
<div className="flex items-center gap-2 mb-2">
<span
className="text-xs px-2 py-0.5 rounded"
style={{ background: "var(--bg-secondary)", color: "var(--primary)" }}
>
{post.board.name}
</span>
{post.pinned > 0 && (
<span className="text-xs px-2 py-0.5 rounded bg-amber-100 text-amber-700">置顶</span>
)}
{post.recommended && (
<span className="text-xs px-2 py-0.5 rounded bg-rose-100 text-rose-700">推荐</span>
)}
</div>
<h2 className="font-semibold mb-1 hover:underline" style={{ color: "var(--text)" }}>
{post.title}
</h2>
<div className="flex items-center gap-4 text-sm" style={{ color: "var(--text-secondary)" }}>
<Link href={`/u/${post.user.id}`} className="hover:underline" style={{ color: "var(--primary)" }}>
{post.user.nickname}
</Link>
<span>{new Date(post.created_at).toLocaleDateString("zh-CN")}</span>
<span>💬 {post.comment_count}</span>
<span>👁 {post.view_count}</span>
</div>
</Link>
))}
{postsData.posts.length === 0 && (
<p className="text-center py-12" style={{ color: "var(--text-secondary)" }}>
{keyword ? "没有找到相关帖子" : "暂无帖子,来发第一篇吧!"}
</p>
)}
</div>
<Pagination
total={postsData.total}
page={page}
size={20}
basePath="/"
query={keyword ? { q: keyword } : {}}
/>
</div>
{/* 侧边栏:板块列表 */}
<aside className="md:col-span-1">
<h2 className="text-lg font-bold mb-3">板块</h2>
<div className="space-y-2">
{boardsData.boards.map((board) => (
<Link
key={board.id}
href={`/board/${board.id}`}
className="block p-3 rounded-lg border hover:shadow-sm transition-shadow"
style={{ borderColor: "var(--border)", background: "var(--bg-secondary)" }}
>
<div className="font-medium" style={{ color: "var(--text)" }}>{board.name}</div>
<div className="text-xs mt-1" style={{ color: "var(--text-secondary)" }}>{board.description}</div>
</Link>
))}
</div>
</aside>
</div>
);
}

View File

@@ -0,0 +1,137 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { fetchPostDetail, apiMe, apiUpdatePost, type Post, type User } from "@/lib/api";
interface EditPageProps {
params: Promise<{ id: string }>;
}
export default function EditPostPage({ params }: EditPageProps) {
const router = useRouter();
const [post, setPost] = useState<Post | null>(null);
const [user, setUser] = useState<User | null>(null);
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [tags, setTags] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [checking, setChecking] = useState(true);
// params 是 Promise,需要解包
const [id, setId] = useState("");
useEffect(() => {
params.then((p) => setId(p.id));
}, [params]);
// 加载帖子 + 校验权限
useEffect(() => {
if (!id) return;
Promise.all([fetchPostDetail(id), apiMe()])
.then(([postData, meData]) => {
setPost(postData.post);
setTitle(postData.post.title);
setContent(postData.post.content);
setTags(postData.post.tags || "");
const u = meData.user;
setUser(u || null);
// 非作者且非管理员 → 跳转
if (!u || (u.id !== postData.post.user.id && u.role !== "admin")) {
router.push(`/post/${id}`);
}
})
.catch(() => router.push(`/post/${id}`))
.finally(() => setChecking(false));
}, [id, router]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!post) return;
setLoading(true);
setError("");
try {
const res = await apiUpdatePost(String(post.id), { title, content, tags });
if (res.post) {
router.push(`/post/${post.id}`);
} else {
setError(res.error || "更新失败");
}
} finally {
setLoading(false);
}
};
if (checking) {
return (
<div className="max-w-3xl mx-auto mt-12 text-center" style={{ color: "var(--text-secondary)" }}>
加载中...
</div>
);
}
if (!post) return null;
return (
<div className="max-w-3xl mx-auto">
<h1 className="text-2xl font-bold mb-6">编辑帖子</h1>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="p-3 rounded-lg bg-red-50 text-red-600 text-sm">{error}</div>
)}
<div>
<label className="block text-sm mb-1" style={{ color: "var(--text-secondary)" }}>标题</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
maxLength={256}
className="w-full px-3 py-2 rounded-lg border"
style={{ borderColor: "var(--border)", background: "var(--bg)", color: "var(--text)" }}
/>
</div>
<div>
<label className="block text-sm mb-1" style={{ color: "var(--text-secondary)" }}>标签(逗号分隔,可选)</label>
<input
type="text"
value={tags}
onChange={(e) => setTags(e.target.value)}
placeholder="例如:技术,Go"
className="w-full px-3 py-2 rounded-lg border"
style={{ borderColor: "var(--border)", background: "var(--bg)", color: "var(--text)" }}
/>
</div>
<div>
<label className="block text-sm mb-1" style={{ color: "var(--text-secondary)" }}>内容(支持 Markdown)</label>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
required
rows={12}
className="w-full px-3 py-2 rounded-lg border resize-y font-mono"
style={{ borderColor: "var(--border)", background: "var(--bg)", color: "var(--text)" }}
/>
</div>
<div className="flex gap-3">
<button
type="submit"
disabled={loading || !title.trim() || !content.trim()}
className="px-6 py-2 rounded-lg text-white disabled:opacity-50"
style={{ background: "var(--primary)" }}
>
{loading ? "保存中..." : "保存修改"}
</button>
<button
type="button"
onClick={() => router.push(`/post/${post.id}`)}
className="px-6 py-2 rounded-lg"
style={{ background: "var(--bg-secondary)", color: "var(--text)" }}
>
取消
</button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,98 @@
import { Metadata } from "next";
import Link from "next/link";
import ReactMarkdown from "react-markdown";
import { fetchPostDetail, fetchComments } from "@/lib/api";
import CommentSection from "@/components/CommentSection";
import PostActions from "@/components/PostActions";
import LikeButton from "@/components/LikeButton";
interface PageProps {
params: Promise<{ id: string }>;
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { id } = await params;
try {
const { post } = await fetchPostDetail(id);
return {
title: `${post.title} - 姜十三论坛`,
description: post.content.slice(0, 160),
openGraph: {
title: post.title,
description: post.content.slice(0, 160),
type: "article",
},
};
} catch {
return { title: "帖子不存在 - 姜十三论坛" };
}
}
export default async function PostDetailPage({ params }: PageProps) {
const { id } = await params;
const { post } = await fetchPostDetail(id);
const { comments } = await fetchComments(id);
const jsonLd = {
"@context": "https://schema.org",
"@type": "DiscussionForumPosting",
headline: post.title,
articleBody: post.content,
datePublished: post.created_at,
dateModified: post.updated_at,
author: {
"@type": "Person",
name: post.user.nickname,
},
commentCount: post.comment_count,
interactionStatistic: {
"@type": "InteractionCounter",
interactionType: { "@type": "ReadAction" },
userInteractionCount: post.view_count,
},
};
return (
<article className="max-w-3xl mx-auto">
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<nav className="text-sm mb-4" style={{ color: "var(--text-secondary)" }}>
<Link href="/" className="hover:underline">首页</Link>
<span className="mx-2">/</span>
<Link href={`/board/${post.board.id}`} className="hover:underline">{post.board.name}</Link>
</nav>
<div className="flex items-center gap-2 mb-3">
{post.pinned > 0 && (
<span className="text-xs px-2 py-0.5 rounded bg-amber-100 text-amber-700">置顶</span>
)}
{post.recommended && (
<span className="text-xs px-2 py-0.5 rounded bg-rose-100 text-rose-700">推荐</span>
)}
</div>
<h1 className="text-3xl font-bold mb-4">{post.title}</h1>
<div className="flex items-center justify-between gap-4 mb-6 pb-4 border-b" style={{ borderColor: "var(--border)" }}>
<div className="flex items-center gap-4" style={{ color: "var(--text-secondary)" }}>
<Link href={`/u/${post.user.id}`} className="hover:underline" style={{ color: "var(--primary)" }}>
{post.user.nickname}
</Link>
<span>{new Date(post.created_at).toLocaleString("zh-CN")}</span>
<span>👁 {post.view_count}</span>
<span>💬 {post.comment_count}</span>
<LikeButton postId={id} liked={post.liked} likeCount={post.like_count} />
</div>
<PostActions postId={id} authorId={post.user.id} pinned={post.pinned} recommended={post.recommended} />
</div>
<div className="prose-content mb-8" style={{ color: "var(--text)" }}>
<ReactMarkdown>{post.content}</ReactMarkdown>
</div>
<CommentSection postId={id} comments={comments} />
</article>
);
}

View File

@@ -0,0 +1,88 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { apiRegister } from "@/lib/api";
export default function RegisterPage() {
const router = useRouter();
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
try {
const res = await apiRegister(username, email, password);
if (res.id) {
router.push("/login");
} else {
setError(res.error || "注册失败");
}
} finally {
setLoading(false);
}
};
return (
<div className="max-w-md mx-auto mt-12">
<h1 className="text-2xl font-bold mb-6 text-center">注册</h1>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="p-3 rounded-lg bg-red-50 text-red-600 text-sm">{error}</div>
)}
<div>
<label className="block text-sm mb-1" style={{ color: "var(--text-secondary)" }}>用户名</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
minLength={3}
maxLength={32}
className="w-full px-3 py-2 rounded-lg border"
style={{ borderColor: "var(--border)", background: "var(--bg)", color: "var(--text)" }}
/>
</div>
<div>
<label className="block text-sm mb-1" style={{ color: "var(--text-secondary)" }}>邮箱(可选)</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-3 py-2 rounded-lg border"
style={{ borderColor: "var(--border)", background: "var(--bg)", color: "var(--text)" }}
/>
</div>
<div>
<label className="block text-sm mb-1" style={{ color: "var(--text-secondary)" }}>密码</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
className="w-full px-3 py-2 rounded-lg border"
style={{ borderColor: "var(--border)", background: "var(--bg)", color: "var(--text)" }}
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-2 rounded-lg text-white disabled:opacity-50"
style={{ background: "var(--primary)" }}
>
{loading ? "注册中..." : "注册"}
</button>
</form>
<p className="text-center mt-4 text-sm" style={{ color: "var(--text-secondary)" }}>
已有账号?<Link href="/login" className="hover:underline" style={{ color: "var(--primary)" }}>去登录</Link>
</p>
</div>
);
}

11
frontend/app/robots.ts Normal file
View File

@@ -0,0 +1,11 @@
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: "/",
},
sitemap: "http://localhost:3000/sitemap.xml",
};
}

View File

@@ -0,0 +1,139 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { apiChangePassword, apiMe, type User } from "@/lib/api";
export default function SettingsPage() {
const router = useRouter();
const [user, setUser] = useState<User | null>(null);
const [oldPassword, setOldPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState(false);
useEffect(() => {
apiMe()
.then((res) => {
if (!res.user) {
router.push("/login");
return;
}
setUser(res.user);
})
.catch(() => router.push("/login"));
}, [router]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
if (newPassword.length < 6) {
setError("新密码至少 6 位");
return;
}
if (newPassword !== confirmPassword) {
setError("两次输入的新密码不一致");
return;
}
setSubmitting(true);
try {
const res = await apiChangePassword(oldPassword, newPassword);
if (res.message) {
setSuccess(true);
setTimeout(() => router.push("/login"), 1500);
} else {
setError(res.error || "修改失败");
}
} catch (e) {
setError("网络错误,请重试");
} finally {
setSubmitting(false);
}
};
if (!user) {
return <p style={{ color: "var(--text-secondary)" }}>加载中...</p>;
}
return (
<div className="max-w-md mx-auto">
<nav className="text-sm mb-4" style={{ color: "var(--text-secondary)" }}>
<Link href="/" className="hover:underline">首页</Link>
<span className="mx-2">/</span>
<span>账号设置</span>
</nav>
<h1 className="text-2xl font-bold mb-6">账号设置</h1>
<div className="p-4 rounded-lg mb-6" style={{ background: "var(--bg-secondary)" }}>
<div className="text-sm" style={{ color: "var(--text-secondary)" }}>当前账号</div>
<div className="font-medium mt-1" style={{ color: "var(--text)" }}>{user.nickname}({user.username})</div>
</div>
<h2 className="text-lg font-semibold mb-4">修改密码</h2>
{success && (
<div className="p-3 rounded-lg mb-4 text-sm" style={{ background: "var(--success-bg, #dcfce7)", color: "#166534" }}>
密码修改成功,正在跳转登录页...
</div>
)}
{error && (
<div className="p-3 rounded-lg mb-4 text-sm" style={{ background: "var(--error-bg, #fee2e2)", color: "#991b1b" }}>
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text)" }}>当前密码</label>
<input
type="password"
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
required
className="w-full p-3 rounded-lg border"
style={{ borderColor: "var(--border)", background: "var(--bg)", color: "var(--text)" }}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text)" }}>新密码</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
minLength={6}
className="w-full p-3 rounded-lg border"
style={{ borderColor: "var(--border)", background: "var(--bg)", color: "var(--text)" }}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text)" }}>确认新密码</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
minLength={6}
className="w-full p-3 rounded-lg border"
style={{ borderColor: "var(--border)", background: "var(--bg)", color: "var(--text)" }}
/>
</div>
<button
type="submit"
disabled={submitting || success}
className="w-full py-3 rounded-lg text-white font-medium disabled:opacity-50"
style={{ background: "var(--primary)" }}
>
{submitting ? "提交中..." : "修改密码"}
</button>
</form>
</div>
);
}

35
frontend/app/sitemap.ts Normal file
View File

@@ -0,0 +1,35 @@
import type { MetadataRoute } from "next";
import { fetchPosts, fetchBoards } from "@/lib/api";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = "http://localhost:3000";
const entries: MetadataRoute.Sitemap = [
{ url: `${baseUrl}/`, lastModified: new Date(), changeFrequency: "daily", priority: 1 },
];
try {
const { boards } = await fetchBoards();
for (const board of boards) {
entries.push({
url: `${baseUrl}/board/${board.id}`,
lastModified: new Date(),
changeFrequency: "daily",
priority: 0.8,
});
}
const { posts } = await fetchPosts(1, 100);
for (const post of posts) {
entries.push({
url: `${baseUrl}/post/${post.id}`,
lastModified: new Date(post.created_at),
changeFrequency: "weekly",
priority: 0.6,
});
}
} catch {
/* 忽略 API 错误 */
}
return entries;
}

View File

@@ -0,0 +1,148 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { fetchUserComments, type UserProfile, type UserCommentsResponse } from "@/lib/api";
import Pagination from "@/components/Pagination";
interface UserProfileClientProps {
userId: string;
initialPosts: UserProfile;
initialPage: number;
}
type Tab = "posts" | "comments";
export default function UserProfileClient({ userId, initialPosts, initialPage }: UserProfileClientProps) {
const [tab, setTab] = useState<Tab>("posts");
const [commentData, setCommentData] = useState<UserCommentsResponse | null>(null);
const [commentLoading, setCommentLoading] = useState(false);
const [commentPage, setCommentPage] = useState(1);
const loadComments = async (page: number) => {
setCommentLoading(true);
try {
const data = await fetchUserComments(userId, page);
setCommentData({ ...data, comments: data.comments || [] });
setCommentPage(page);
} catch {
setCommentData({ comments: [], total: 0, page, size: 20 });
}
setCommentLoading(false);
};
const handleTabChange = (t: Tab) => {
setTab(t);
if (t === "comments" && commentData === null) {
loadComments(1);
}
};
const tabBtn = (t: Tab, label: string) => (
<button
onClick={() => handleTabChange(t)}
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors"
style={
tab === t
? { background: "var(--primary)", color: "white" }
: { background: "var(--bg-secondary)", color: "var(--text-secondary)" }
}
>
{label}
</button>
);
return (
<div>
<div className="flex gap-2 mb-5">
{tabBtn("posts", `发帖 (${initialPosts.stats.post_count})`)}
{tabBtn("comments", `评论 (${initialPosts.stats.comment_count})`)}
</div>
{tab === "posts" ? (
<div className="space-y-3">
{initialPosts.posts.map((post) => (
<Link
key={post.id}
href={`/post/${post.id}`}
className="block p-4 rounded-lg border hover:shadow-md transition-shadow"
style={{ borderColor: "var(--border)", background: "var(--bg)" }}
>
<div className="flex items-center gap-2 mb-2">
<span
className="text-xs px-2 py-0.5 rounded"
style={{ background: "var(--bg-secondary)", color: "var(--primary)" }}
>
{post.board.name}
</span>
{post.pinned > 0 && (
<span className="text-xs px-2 py-0.5 rounded bg-amber-100 text-amber-700">置顶</span>
)}
{post.recommended && (
<span className="text-xs px-2 py-0.5 rounded bg-rose-100 text-rose-700">推荐</span>
)}
</div>
<h2 className="font-semibold mb-1 hover:underline" style={{ color: "var(--text)" }}>
{post.title}
</h2>
<div className="flex items-center gap-4 text-sm" style={{ color: "var(--text-secondary)" }}>
<span>{new Date(post.created_at).toLocaleDateString("zh-CN")}</span>
<span>💬 {post.comment_count}</span>
<span>👁 {post.view_count}</span>
<span>❤️ {post.like_count}</span>
</div>
</Link>
))}
{initialPosts.posts.length === 0 && (
<p className="text-center py-12" style={{ color: "var(--text-secondary)" }}>
暂无发帖
</p>
)}
<Pagination
total={initialPosts.posts_total}
page={initialPage}
size={20}
basePath={`/u/${userId}`}
/>
</div>
) : (
<div className="space-y-3">
{commentLoading ? (
<p className="text-center py-12" style={{ color: "var(--text-secondary)" }}>加载中...</p>
) : commentData && commentData.comments.length > 0 ? (
<>
{commentData.comments.map((c) => (
<Link
key={c.id}
href={`/post/${c.post_id}`}
className="block p-4 rounded-lg border hover:shadow-md transition-shadow"
style={{ borderColor: "var(--border)", background: "var(--bg)" }}
>
<div className="text-sm mb-2" style={{ color: "var(--text-secondary)" }}>
评论于 <span className="hover:underline" style={{ color: "var(--primary)" }}>{c.post_title}</span>
</div>
<p className="line-clamp-2" style={{ color: "var(--text)" }}>{c.content}</p>
<div className="text-xs mt-2" style={{ color: "var(--text-secondary)" }}>
{new Date(c.created_at).toLocaleString("zh-CN")}
</div>
</Link>
))}
<Pagination
total={commentData.total}
page={commentPage}
size={20}
basePath={`/u/${userId}`}
query={{ tab: "comments" }}
onChange={loadComments}
/>
</>
) : (
<p className="text-center py-12" style={{ color: "var(--text-secondary)" }}>
暂无评论
</p>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,93 @@
import Link from "next/link";
import { Metadata } from "next";
import { fetchUserProfile, type UserProfile } from "@/lib/api";
import Pagination from "@/components/Pagination";
import UserProfileClient from "./UserProfileClient";
interface UserPageProps {
params: Promise<{ id: string }>;
searchParams: Promise<{ page?: string; tab?: string }>;
}
export async function generateMetadata({ params }: UserPageProps): Promise<Metadata> {
const { id } = await params;
return {
title: `用户资料 - 姜十三论坛`,
description: `查看用户 ${id} 的资料、发帖与评论`,
};
}
export default async function UserPage({ params, searchParams }: UserPageProps) {
const { id } = await params;
const { page: pageStr } = await searchParams;
const page = Math.max(1, parseInt(pageStr || "1", 10) || 1);
let data: UserProfile;
try {
data = await fetchUserProfile(id, page);
} catch {
return (
<div className="text-center py-16" style={{ color: "var(--text-secondary)" }}>
<p className="text-lg">用户不存在</p>
<Link href="/" className="mt-4 inline-block hover:underline" style={{ color: "var(--primary)" }}>
返回首页
</Link>
</div>
);
}
const u = data.user;
const roleLabel = u.role === "admin" ? "管理员" : "用户";
return (
<div className="max-w-4xl mx-auto">
{/* 用户信息卡片 */}
<div
className="rounded-xl border p-6 mb-6 flex items-start gap-5"
style={{ borderColor: "var(--border)", background: "var(--bg)" }}
>
<div
className="w-20 h-20 rounded-full flex items-center justify-center text-2xl font-bold shrink-0"
style={{ background: "var(--bg-secondary)", color: "var(--primary)" }}
>
{u.avatar ? (
<img src={u.avatar} alt={u.nickname} className="w-full h-full rounded-full object-cover" />
) : (
(u.nickname || u.username).charAt(0).toUpperCase()
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h1 className="text-2xl font-bold" style={{ color: "var(--text)" }}>
{u.nickname || u.username}
</h1>
{u.role === "admin" && (
<span
className="text-xs px-2 py-0.5 rounded font-medium"
style={{ background: "var(--primary)", color: "white" }}
>
{roleLabel}
</span>
)}
</div>
<p className="text-sm mt-1" style={{ color: "var(--text-secondary)" }}>
@{u.username} · 加入于 {new Date(u.created_at).toLocaleDateString("zh-CN")}
</p>
<div className="flex gap-6 mt-3 text-sm">
<span style={{ color: "var(--text)" }}>
<strong>{data.stats.post_count}</strong>{" "}
<span style={{ color: "var(--text-secondary)" }}>帖子</span>
</span>
<span style={{ color: "var(--text)" }}>
<strong>{data.stats.comment_count}</strong>{" "}
<span style={{ color: "var(--text-secondary)" }}>评论</span>
</span>
</div>
</div>
</div>
{/* 发帖 / 评论 切换 */}
<UserProfileClient userId={id} initialPosts={data} initialPage={page} />
</div>
);
}

View File

@@ -0,0 +1,121 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Trash2 } from "lucide-react";
import { apiCreateComment, apiDeleteComment, apiMe, type Comment, type User } from "@/lib/api";
interface CommentSectionProps {
postId: string;
comments: Comment[];
}
export default function CommentSection({ postId, comments }: CommentSectionProps) {
const router = useRouter();
const [content, setContent] = useState("");
const [submitting, setSubmitting] = useState(false);
const [user, setUser] = useState<User | null>(null);
const [deletingId, setDeletingId] = useState<number | null>(null);
useEffect(() => {
apiMe()
.then((res) => setUser(res.user || null))
.catch(() => setUser(null));
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!content.trim()) return;
setSubmitting(true);
try {
const res = await apiCreateComment(postId, content);
if (res.comment) {
setContent("");
router.refresh();
} else if (res.error === "未登录") {
router.push("/login");
} else {
alert(res.error || "评论失败");
}
} finally {
setSubmitting(false);
}
};
const handleDelete = async (commentId: number) => {
if (!confirm("确定要删除这条评论吗?")) return;
setDeletingId(commentId);
try {
const res = await apiDeleteComment(postId, commentId);
if (res.message) {
router.refresh();
} else {
alert(res.error || "删除失败");
}
} finally {
setDeletingId(null);
}
};
const canDelete = (comment: Comment) =>
user && (user.id === comment.user.id || user.role === "admin");
return (
<section className="mt-8 pt-6 border-t" style={{ borderColor: "var(--border)" }}>
<h2 className="text-xl font-bold mb-4">评论 ({comments.length})</h2>
<form onSubmit={handleSubmit} className="mb-6">
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="写下你的评论..."
className="w-full p-3 rounded-lg border resize-none h-24"
style={{ borderColor: "var(--border)", background: "var(--bg)", color: "var(--text)" }}
/>
<button
type="submit"
disabled={submitting || !content.trim()}
className="mt-2 px-4 py-2 rounded-lg text-white disabled:opacity-50"
style={{ background: "var(--primary)" }}
>
{submitting ? "发送中..." : "发表评论"}
</button>
</form>
<div className="space-y-4">
{comments.map((comment) => (
<div key={comment.id} className="p-4 rounded-lg" style={{ background: "var(--bg-secondary)" }}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Link href={`/u/${comment.user.id}`} className="font-medium hover:underline" style={{ color: "var(--primary)" }}>
{comment.user.nickname}
</Link>
<span className="text-sm" style={{ color: "var(--text-secondary)" }}>
{new Date(comment.created_at).toLocaleString("zh-CN")}
</span>
</div>
{canDelete(comment) && (
<button
onClick={() => handleDelete(comment.id)}
disabled={deletingId === comment.id}
className="flex items-center gap-1 text-sm text-red-600 hover:opacity-80 disabled:opacity-50"
>
<Trash2 size={14} />
{deletingId === comment.id ? "删除中" : "删除"}
</button>
)}
</div>
<p style={{ color: "var(--text)" }}>{comment.content}</p>
</div>
))}
{comments.length === 0 && (
<p className="text-center py-8" style={{ color: "var(--text-secondary)" }}>
暂无评论,来抢沙发吧!
</p>
)}
</div>
</section>
);
}

View File

@@ -0,0 +1,7 @@
export default function Footer() {
return (
<footer className="border-t py-6 text-center text-sm" style={{ borderColor: "var(--border)", color: "var(--text-secondary)" }}>
<p>姜十三论坛 · Powered by Go 1.27 + Next.js 16</p>
</footer>
);
}

View File

@@ -0,0 +1,151 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState, useEffect } from "react";
import { Menu, X, Sun, Moon, LogIn, UserPlus, LogOut, PenSquare, Search, Settings } from "lucide-react";
import { apiMe, apiLogout, type User } from "@/lib/api";
import NotificationBell from "./NotificationBell";
export default function Header() {
const router = useRouter();
const [menuOpen, setMenuOpen] = useState(false);
const [theme, setTheme] = useState<"light" | "dark">("light");
const [user, setUser] = useState<User | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
const q = searchQuery.trim();
if (q) {
router.push(`/?q=${encodeURIComponent(q)}`);
}
};
useEffect(() => {
// 读取主题
const savedTheme = document.documentElement.classList.contains("dark") ? "dark" : "light";
setTheme(savedTheme);
// 从后端获取登录态(HttpOnly cookie 自动携带)
apiMe()
.then((res) => setUser(res.user || null))
.catch(() => setUser(null));
}, []);
const toggleTheme = () => {
const newTheme = theme === "dark" ? "light" : "dark";
setTheme(newTheme);
document.documentElement.classList.toggle("dark", newTheme === "dark");
document.cookie = `j13-theme=${newTheme}; path=/; max-age=31536000`;
};
const handleLogout = async () => {
try {
await apiLogout();
} catch {
/* ignore */
}
setUser(null);
setMenuOpen(false);
router.refresh();
};
return (
<header className="sticky top-0 z-50 border-b" style={{ borderColor: "var(--border)", background: "var(--bg)" }}>
<div className="max-w-5xl mx-auto px-4 h-14 flex items-center justify-between">
<Link href="/" className="font-bold text-lg" style={{ color: "var(--primary)" }}>
姜十三论坛
</Link>
{/* 桌面端导航 */}
<nav className="hidden md:flex items-center gap-4">
<form onSubmit={handleSearch} className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2" style={{ color: "var(--text-secondary)" }} />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="搜索帖子..."
className="pl-9 pr-3 py-1.5 rounded-lg text-sm w-44 focus:outline-none focus:w-56 transition-all"
style={{ background: "var(--bg-secondary)", color: "var(--text)", border: "1px solid var(--border)" }}
/>
</form>
<Link href="/" className="hover:opacity-80" style={{ color: "var(--text)" }}>首页</Link>
{user && (
<Link href="/compose" className="flex items-center gap-1 hover:opacity-80" style={{ color: "var(--text)" }}>
<PenSquare size={16} /> 发帖
</Link>
)}
<button onClick={toggleTheme} className="p-2 rounded-lg hover:opacity-80" style={{ color: "var(--text)" }}>
{theme === "dark" ? <Sun size={18} /> : <Moon size={18} />}
</button>
{user && <NotificationBell />}
{user ? (
<div className="flex items-center gap-3">
<span className="text-sm" style={{ color: "var(--text-secondary)" }}>{user.nickname}</span>
<Link href="/settings" className="flex items-center gap-1 text-sm hover:opacity-80" style={{ color: "var(--text-secondary)" }}>
<Settings size={14} /> 设置
</Link>
<button onClick={handleLogout} className="flex items-center gap-1 text-sm hover:opacity-80" style={{ color: "var(--text-secondary)" }}>
<LogOut size={14} /> 退出
</button>
</div>
) : (
<div className="flex items-center gap-2">
<Link href="/login" className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-sm" style={{ background: "var(--bg-secondary)", color: "var(--text)" }}>
<LogIn size={14} /> 登录
</Link>
<Link href="/register" className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-sm text-white" style={{ background: "var(--primary)" }}>
<UserPlus size={14} /> 注册
</Link>
</div>
)}
</nav>
{/* 移动端菜单按钮 */}
<button onClick={() => setMenuOpen(!menuOpen)} className="md:hidden p-2" style={{ color: "var(--text)" }}>
{menuOpen ? <X size={20} /> : <Menu size={20} />}
</button>
</div>
{/* 移动端下拉菜单 */}
{menuOpen && (
<div className="md:hidden border-t px-4 py-3 space-y-2" style={{ borderColor: "var(--border)", background: "var(--bg)" }}>
<form onSubmit={(e) => { handleSearch(e); setMenuOpen(false); }} className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2" style={{ color: "var(--text-secondary)" }} />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="搜索帖子..."
className="w-full pl-9 pr-3 py-2 rounded-lg text-sm"
style={{ background: "var(--bg-secondary)", color: "var(--text)", border: "1px solid var(--border)" }}
/>
</form>
<Link href="/" onClick={() => setMenuOpen(false)} className="block py-2" style={{ color: "var(--text)" }}>首页</Link>
{user && (
<Link href="/compose" onClick={() => setMenuOpen(false)} className="block py-2" style={{ color: "var(--text)" }}>发帖</Link>
)}
<button onClick={toggleTheme} className="block py-2 w-full text-left" style={{ color: "var(--text)" }}>
{theme === "dark" ? "切换亮色主题" : "切换暗色主题"}
</button>
{user ? (
<Link href="/notifications" onClick={() => setMenuOpen(false)} className="block py-2" style={{ color: "var(--text)" }}>通知中心</Link>
) : null}
{user ? (
<>
<Link href="/settings" onClick={() => setMenuOpen(false)} className="block py-2" style={{ color: "var(--text)" }}>账号设置</Link>
<button onClick={handleLogout} className="block py-2 w-full text-left" style={{ color: "var(--text)" }}>退出登录</button>
</>
) : (
<>
<Link href="/login" onClick={() => setMenuOpen(false)} className="block py-2" style={{ color: "var(--text)" }}>登录</Link>
<Link href="/register" onClick={() => setMenuOpen(false)} className="block py-2" style={{ color: "var(--text)" }}>注册</Link>
</>
)}
</div>
)}
</header>
);
}

View File

@@ -0,0 +1,50 @@
"use client";
import { useState } from "react";
import { Heart } from "lucide-react";
import { apiToggleLike } from "@/lib/api";
interface LikeButtonProps {
postId: string;
liked: boolean;
likeCount: number;
}
export default function LikeButton({ postId, liked: initialLiked, likeCount: initialCount }: LikeButtonProps) {
const [liked, setLiked] = useState(initialLiked);
const [count, setCount] = useState(initialCount);
const [loading, setLoading] = useState(false);
const handleClick = async () => {
if (loading) return;
setLoading(true);
try {
const res = await apiToggleLike(postId);
if (res.error === "未登录") {
window.location.href = "/login";
return;
}
if (typeof res.liked === "boolean") {
setLiked(res.liked);
setCount(res.like_count ?? count);
}
} finally {
setLoading(false);
}
};
return (
<button
onClick={handleClick}
disabled={loading}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium transition-all disabled:opacity-50 ${
liked
? "text-red-500 bg-red-50 dark:bg-red-900/20"
: "text-gray-500 hover:text-red-500 hover:bg-gray-100 dark:hover:bg-gray-800"
}`}
>
<Heart size={16} fill={liked ? "currentColor" : "none"} />
<span>{count}</span>
</button>
);
}

View File

@@ -0,0 +1,197 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Bell, CheckCheck, Heart, MessageCircle } from "lucide-react";
import {
apiFetchNotifications,
apiUnreadCount,
apiMarkRead,
apiMarkAllRead,
type NotificationItem,
} from "@/lib/api";
export default function NotificationBell() {
const router = useRouter();
const [open, setOpen] = useState(false);
const [unread, setUnread] = useState(0);
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
const [loading, setLoading] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// 轮询未读数(30 秒)
useEffect(() => {
const fetchCount = () => {
apiUnreadCount()
.then((res) => setUnread(res.count || 0))
.catch(() => {});
};
fetchCount();
const timer = setInterval(fetchCount, 30000);
return () => clearInterval(timer);
}, []);
// 点击外部关闭下拉
useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
const loadNotifications = async () => {
setLoading(true);
try {
const res = await apiFetchNotifications(1);
setNotifications(res.notifications || []);
} catch {
setNotifications([]);
}
setLoading(false);
};
const handleToggle = () => {
const next = !open;
setOpen(next);
if (next) {
loadNotifications();
}
};
const handleClickNotification = async (n: NotificationItem) => {
if (!n.is_read) {
apiMarkRead(n.id).catch(() => {});
setUnread((c) => Math.max(0, c - 1));
setNotifications((list) =>
list.map((item) => (item.id === n.id ? { ...item, is_read: true } : item))
);
}
setOpen(false);
router.push(`/post/${n.post_id}`);
};
const handleMarkAllRead = async () => {
await apiMarkAllRead();
setUnread(0);
setNotifications((list) => list.map((item) => ({ ...item, is_read: true })));
};
const formatTime = (dateStr: string) => {
const diff = Date.now() - new Date(dateStr).getTime();
const min = Math.floor(diff / 60000);
if (min < 1) return "刚刚";
if (min < 60) return `${min} 分钟前`;
const hour = Math.floor(min / 60);
if (hour < 24) return `${hour} 小时前`;
const day = Math.floor(hour / 24);
return `${day} 天前`;
};
const actionText = (n: NotificationItem) => {
if (n.type === "comment") return "评论了你的帖子";
return "点赞了你的帖子";
};
return (
<div className="relative" ref={dropdownRef}>
<button
onClick={handleToggle}
className="relative p-2 rounded-lg hover:opacity-80"
style={{ color: "var(--text)" }}
aria-label="通知"
>
<Bell size={18} />
{unread > 0 && (
<span
className="absolute -top-0.5 -right-0.5 min-w-[16px] h-4 px-1 rounded-full text-xs text-white flex items-center justify-center font-bold"
style={{ background: "var(--primary)" }}
>
{unread > 99 ? "99+" : unread}
</span>
)}
</button>
{open && (
<div
className="absolute right-0 mt-2 w-80 rounded-xl border shadow-xl overflow-hidden z-50"
style={{ borderColor: "var(--border)", background: "var(--bg)" }}
>
<div
className="flex items-center justify-between px-4 py-3 border-b"
style={{ borderColor: "var(--border)" }}
>
<span className="font-semibold" style={{ color: "var(--text)" }}>通知</span>
{unread > 0 && (
<button
onClick={handleMarkAllRead}
className="flex items-center gap-1 text-xs hover:opacity-80"
style={{ color: "var(--primary)" }}
>
<CheckCheck size={14} /> 全部已读
</button>
)}
</div>
<div className="max-h-96 overflow-y-auto">
{loading ? (
<p className="text-center py-8 text-sm" style={{ color: "var(--text-secondary)" }}>加载中...</p>
) : notifications.length === 0 ? (
<p className="text-center py-8 text-sm" style={{ color: "var(--text-secondary)" }}>暂无通知</p>
) : (
notifications.map((n) => (
<button
key={n.id}
onClick={() => handleClickNotification(n)}
className="w-full text-left px-4 py-3 border-b hover:opacity-90 transition-opacity"
style={{
borderColor: "var(--border)",
background: n.is_read ? "transparent" : "var(--bg-secondary)",
}}
>
<div className="flex items-start gap-2">
<span style={{ color: n.type === "like" ? "#ef4444" : "var(--primary)" }}>
{n.type === "like" ? <Heart size={16} /> : <MessageCircle size={16} />}
</span>
<div className="flex-1 min-w-0">
<p className="text-sm" style={{ color: "var(--text)" }}>
<span className="font-medium">{n.actor.nickname}</span>{" "}
<span style={{ color: "var(--text-secondary)" }}>{actionText(n)}</span>
</p>
<p className="text-xs mt-0.5 truncate" style={{ color: "var(--text-secondary)" }}>
《{n.post.title}》
</p>
{n.content && (
<p className="text-xs mt-1 line-clamp-1" style={{ color: "var(--text-secondary)" }}>
{n.content}
</p>
)}
<p className="text-xs mt-1" style={{ color: "var(--text-secondary)" }}>
{formatTime(n.created_at)}
</p>
</div>
{!n.is_read && (
<span
className="w-2 h-2 rounded-full shrink-0 mt-1.5"
style={{ background: "var(--primary)" }}
/>
)}
</div>
</button>
))
)}
</div>
<div className="px-4 py-2 border-t text-center" style={{ borderColor: "var(--border)" }}>
<Link href="/notifications" className="text-sm hover:underline" style={{ color: "var(--primary)" }}>
查看全部通知
</Link>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,95 @@
"use client";
import Link from "next/link";
import { ChevronLeft, ChevronRight } from "lucide-react";
interface PaginationProps {
total: number;
page: number;
size: number;
basePath: string; // e.g. "/" or "/board/1"
query?: Record<string, string>; // additional query params (e.g. q for search)
onChange?: (page: number) => void; // 客户端分页回调;提供时不渲染 Link 而是按钮
}
export default function Pagination({ total, page, size, basePath, query = {}, onChange }: PaginationProps) {
const totalPages = Math.max(1, Math.ceil(total / size));
if (totalPages <= 1) return null;
const buildHref = (p: number) => {
const params = new URLSearchParams(query);
params.set("page", String(p));
return `${basePath}?${params.toString()}`;
};
const handleClick = (p: number, e: React.MouseEvent) => {
if (onChange) {
e.preventDefault();
onChange(p);
}
};
// 生成页码按钮(最多显示 7 个,带省略号)
const pages: (number | "...")[] = [];
const add = (p: number | "...") => pages.push(p);
if (totalPages <= 7) {
for (let i = 1; i <= totalPages; i++) add(i);
} else {
add(1);
if (page > 4) add("...");
const start = Math.max(2, page - 1);
const end = Math.min(totalPages - 1, page + 1);
for (let i = start; i <= end; i++) add(i);
if (page < totalPages - 3) add("...");
add(totalPages);
}
return (
<nav className="flex items-center justify-center gap-1 mt-8">
{page > 1 && (
<Link
href={buildHref(page - 1)}
onClick={(e) => handleClick(page - 1, e)}
className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-sm hover:opacity-80"
style={{ background: "var(--bg-secondary)", color: "var(--text)" }}
>
<ChevronLeft size={16} /> 上一页
</Link>
)}
{pages.map((p, i) =>
p === "..." ? (
<span key={`e${i}`} className="px-2" style={{ color: "var(--text-secondary)" }}>…</span>
) : (
<Link
key={p}
href={buildHref(p)}
onClick={(e) => handleClick(p, e)}
className={`min-w-[36px] h-9 flex items-center justify-center rounded-lg text-sm ${
p === page ? "text-white" : "hover:opacity-80"
}`}
style={
p === page
? { background: "var(--primary)" }
: { background: "var(--bg-secondary)", color: "var(--text)" }
}
>
{p}
</Link>
)
)}
{page < totalPages && (
<Link
href={buildHref(page + 1)}
onClick={(e) => handleClick(page + 1, e)}
className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-sm hover:opacity-80"
style={{ background: "var(--bg-secondary)", color: "var(--text)" }}
>
下一页 <ChevronRight size={16} />
</Link>
)}
</nav>
);
}

View File

@@ -0,0 +1,117 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Pencil, Trash2, Pin, Star } from "lucide-react";
import { apiMe, apiDeletePost, apiTogglePin, apiToggleRecommend, type User } from "@/lib/api";
interface PostActionsProps {
postId: string;
authorId: number;
pinned: number;
recommended: boolean;
}
export default function PostActions({ postId, authorId, pinned, recommended }: PostActionsProps) {
const router = useRouter();
const [user, setUser] = useState<User | null>(null);
const [deleting, setDeleting] = useState(false);
const [isPinned, setIsPinned] = useState(pinned > 0);
const [isRecommended, setIsRecommended] = useState(recommended);
const [acting, setActing] = useState("");
useEffect(() => {
apiMe()
.then((res) => setUser(res.user || null))
.catch(() => setUser(null));
}, []);
// 仅作者本人或管理员可编辑/删除;置顶/推荐仅管理员
const canManage = user && (user.id === authorId || user.role === "admin");
const isAdmin = user?.role === "admin";
if (!canManage) return null;
const handleDelete = async () => {
if (!confirm("确定要删除这篇帖子吗?此操作不可恢复。")) return;
setDeleting(true);
try {
const res = await apiDeletePost(postId);
if (res.message) {
router.push("/");
} else {
alert(res.error || "删除失败");
}
} finally {
setDeleting(false);
}
};
const handlePin = async () => {
setActing("pin");
try {
const res = await apiTogglePin(postId);
setIsPinned(res.pinned > 0);
router.refresh();
} finally {
setActing("");
}
};
const handleRecommend = async () => {
setActing("recommend");
try {
const res = await apiToggleRecommend(postId);
setIsRecommended(res.recommended);
router.refresh();
} finally {
setActing("");
}
};
return (
<div className="flex items-center gap-2 flex-wrap justify-end">
{isAdmin && (
<>
<button
onClick={handlePin}
disabled={acting !== ""}
className="flex items-center gap-1 text-sm px-3 py-1.5 rounded-lg hover:opacity-80 disabled:opacity-50"
style={{
background: isPinned ? "var(--primary)" : "var(--bg-secondary)",
color: isPinned ? "white" : "var(--text)",
}}
>
<Pin size={14} /> {isPinned ? "取消置顶" : "置顶"}
</button>
<button
onClick={handleRecommend}
disabled={acting !== ""}
className="flex items-center gap-1 text-sm px-3 py-1.5 rounded-lg hover:opacity-80 disabled:opacity-50"
style={{
background: isRecommended ? "#e11d48" : "var(--bg-secondary)",
color: isRecommended ? "white" : "var(--text)",
}}
>
<Star size={14} /> {isRecommended ? "取消推荐" : "推荐"}
</button>
</>
)}
<Link
href={`/post/${postId}/edit`}
className="flex items-center gap-1 text-sm px-3 py-1.5 rounded-lg hover:opacity-80"
style={{ background: "var(--bg-secondary)", color: "var(--text)" }}
>
<Pencil size={14} /> 编辑
</Link>
<button
onClick={handleDelete}
disabled={deleting}
className="flex items-center gap-1 text-sm px-3 py-1.5 rounded-lg text-red-600 hover:bg-red-50 disabled:opacity-50"
style={{ background: "var(--bg-secondary)" }}
>
<Trash2 size={14} /> {deleting ? "删除中..." : "删除"}
</button>
</div>
);
}

395
frontend/lib/api.ts Normal file
View File

@@ -0,0 +1,395 @@
// API 基础配置
// 注意:客户端请求使用相对路径 /api/*,走 Next.js rewrite 代理到后端,
// 这样浏览器视为同源,Cookie 自动携带,无需处理 CORS。
// SSR 端(fetchPosts 等公开接口)直接请求后端 API_BASE。
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001";
// 类型定义
export interface User {
id: number;
username: string;
nickname: string;
avatar: string;
role: string;
}
export interface Board {
id: number;
name: string;
description: string;
icon: string;
sort_order: number;
}
export interface PostListItem {
id: number;
board_id: number;
user_id: number;
title: string;
tags: string;
post_type: string;
pinned: number;
recommended: boolean;
like_count: number;
view_count: number;
comment_count: number;
liked: boolean;
created_at: string;
board: Board;
user: User;
}
export interface Post {
id: number;
board_id: number;
user_id: number;
title: string;
content: string;
tags: string;
post_type: string;
pinned: number;
recommended: boolean;
status: string;
like_count: number;
view_count: number;
comment_count: number;
liked: boolean;
created_at: string;
updated_at: string;
board: Board;
user: User;
}
export interface Comment {
id: number;
post_id: number;
user_id: number;
content: string;
status: string;
created_at: string;
user: User;
}
export interface PostsResponse {
posts: PostListItem[];
total: number;
page: number;
size: number;
}
export interface UserProfile {
user: {
id: number;
username: string;
nickname: string;
avatar: string;
role: string;
created_at: string;
};
stats: {
post_count: number;
comment_count: number;
};
posts: PostListItem[];
posts_total: number;
page: number;
size: number;
}
export interface UserCommentItem {
id: number;
post_id: number;
post_title: string;
content: string;
created_at: string;
}
export interface UserCommentsResponse {
comments: UserCommentItem[];
total: number;
page: number;
size: number;
}
export interface NotificationItem {
id: number;
user_id: number;
actor_id: number;
type: "comment" | "like";
post_id: number;
comment_id: number;
content: string;
is_read: boolean;
created_at: string;
actor: User;
post: { id: number; title: string };
}
export interface NotificationsResponse {
notifications: NotificationItem[];
total: number;
page: number;
size: number;
}
// 从 cookie 读取 CSRF token(j13_csrf 为非 HttpOnly,前端可读)
function getCSRFToken(): string {
if (typeof document === "undefined") return "";
const match = document.cookie.match(/(?:^|;\s*)j13_csrf=([^;]+)/);
return match ? decodeURIComponent(match[1]) : "";
}
// 构造客户端 fetch 的通用 headers(含 CSRF token)
function clientHeaders(extra: Record<string, string> = {}): HeadersInit {
const headers: Record<string, string> = { ...extra };
const csrf = getCSRFToken();
if (csrf) headers["X-CSRF-Token"] = csrf;
return headers;
}
// ===== Refresh token 自动续期 =====
let refreshPromise: Promise<boolean> | null = null;
// 调用 /api/auth/refresh 刷新 access token(refresh cookie 由浏览器自动携带)
async function apiRefresh(): Promise<boolean> {
try {
const res = await fetch("/api/auth/refresh", {
method: "POST",
credentials: "include",
headers: clientHeaders(),
});
return res.ok;
} catch {
return false;
}
}
// 带自动续期的 fetch:遇到 401 时尝试 refresh,成功后重试原请求
async function fetchWithRefresh(url: string, init: RequestInit): Promise<Response> {
let res = await fetch(url, { ...init, credentials: "include" });
if (res.status === 401) {
// 串行化 refresh:多个并发 401 只触发一次 refresh
if (!refreshPromise) {
refreshPromise = apiRefresh().finally(() => {
refreshPromise = null;
});
}
const refreshed = await refreshPromise;
if (refreshed) {
// refresh 成功,重试原请求(CSRF cookie 可能已更新)
const newHeaders = clientHeaders();
const mergedInit = { ...init, credentials: "include" as const, headers: newHeaders };
res = await fetch(url, mergedInit);
}
}
return res;
}
// ===== SSR 端 fetch(公开接口,直接请求后端) =====
export async function fetchPosts(
page = 1,
size = 20,
boardId?: number,
sort = "latest",
keyword = ""
): Promise<PostsResponse> {
const params = new URLSearchParams({ page: String(page), size: String(size), sort });
if (boardId) params.set("board_id", String(boardId));
if (keyword) params.set("keyword", keyword);
const res = await fetch(`${API_BASE}/api/posts?${params}`, { cache: "no-store" });
if (!res.ok) throw new Error("获取帖子失败");
return res.json();
}
export async function fetchBoards(): Promise<{ boards: Board[] }> {
const res = await fetch(`${API_BASE}/api/boards`, { cache: "no-store" });
if (!res.ok) throw new Error("获取板块失败");
return res.json();
}
export async function fetchPostDetail(id: string): Promise<{ post: Post }> {
const res = await fetch(`${API_BASE}/api/posts/${id}`, { cache: "no-store" });
if (!res.ok) throw new Error("获取帖子失败");
return res.json();
}
export async function fetchComments(postId: string): Promise<{ comments: Comment[] }> {
const res = await fetch(`${API_BASE}/api/posts/${postId}/comments`, { cache: "no-store" });
if (!res.ok) throw new Error("获取评论失败");
return res.json();
}
export async function fetchUserProfile(id: string, page = 1): Promise<UserProfile> {
const params = new URLSearchParams({ page: String(page), size: "20" });
const res = await fetch(`${API_BASE}/api/users/${id}?${params}`, { cache: "no-store" });
if (res.status === 404) throw new Error("用户不存在");
if (!res.ok) throw new Error("获取用户资料失败");
return res.json();
}
export async function fetchUserComments(id: string, page = 1): Promise<UserCommentsResponse> {
const params = new URLSearchParams({ page: String(page), size: "20" });
const res = await fetch(`${API_BASE}/api/users/${id}/comments?${params}`, { cache: "no-store" });
if (res.status === 404) throw new Error("用户不存在");
if (!res.ok) throw new Error("获取评论失败");
return res.json();
}
// ===== 客户端 API 调用(走 rewrite,携带 cookie + CSRF) =====
// 获取当前登录用户(依赖 OptionalAuth,未登录返回 { user: null })
export async function apiMe(): Promise<{ user: User | null }> {
const res = await fetch("/api/me", {
credentials: "include",
cache: "no-store",
});
return res.json();
}
export async function apiLogin(username: string, password: string) {
const res = await fetch("/api/login", {
method: "POST",
credentials: "include",
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ username, password }),
});
return res.json();
}
export async function apiRegister(username: string, email: string, password: string) {
const res = await fetch("/api/register", {
method: "POST",
credentials: "include",
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ username, email, password }),
});
return res.json();
}
export async function apiLogout() {
const res = await fetchWithRefresh("/api/logout", {
method: "POST",
headers: clientHeaders(),
});
return res.json();
}
export async function apiCreatePost(data: {
board_id: number;
title: string;
content: string;
tags?: string;
post_type?: string;
}) {
const res = await fetchWithRefresh("/api/posts", {
method: "POST",
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify(data),
});
return res.json();
}
export async function apiCreateComment(postId: string, content: string) {
const res = await fetchWithRefresh(`/api/posts/${postId}/comments`, {
method: "POST",
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ content }),
});
return res.json();
}
export async function apiUpdatePost(
postId: string,
data: { title?: string; content?: string; tags?: string }
) {
const res = await fetchWithRefresh(`/api/posts/${postId}`, {
method: "PUT",
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify(data),
});
return res.json();
}
export async function apiDeletePost(postId: string) {
const res = await fetchWithRefresh(`/api/posts/${postId}`, {
method: "DELETE",
headers: clientHeaders(),
});
return res.json();
}
export async function apiTogglePin(postId: string): Promise<{ pinned: number }> {
const res = await fetchWithRefresh(`/api/posts/${postId}/pin`, {
method: "PUT",
headers: clientHeaders(),
});
return res.json();
}
export async function apiToggleRecommend(postId: string): Promise<{ recommended: boolean }> {
const res = await fetchWithRefresh(`/api/posts/${postId}/recommend`, {
method: "PUT",
headers: clientHeaders(),
});
return res.json();
}
export async function apiChangePassword(oldPassword: string, newPassword: string) {
const res = await fetchWithRefresh("/api/change-password", {
method: "POST",
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ old_password: oldPassword, new_password: newPassword }),
});
return res.json();
}
export async function apiToggleLike(postId: string) {
const res = await fetchWithRefresh(`/api/posts/${postId}/like`, {
method: "POST",
headers: clientHeaders(),
});
return res.json();
}
export async function apiDeleteComment(postId: string, commentId: number) {
const res = await fetchWithRefresh(`/api/posts/${postId}/comments/${commentId}`, {
method: "DELETE",
headers: clientHeaders(),
});
return res.json();
}
// ===== 通知 =====
export async function apiFetchNotifications(page = 1): Promise<NotificationsResponse> {
const res = await fetchWithRefresh(`/api/notifications?page=${page}&size=20`, {
headers: clientHeaders(),
});
return res.json();
}
export async function apiUnreadCount(): Promise<{ count: number }> {
const res = await fetchWithRefresh("/api/notifications/unread-count", {
headers: clientHeaders(),
});
return res.json();
}
export async function apiMarkRead(id: number) {
const res = await fetchWithRefresh(`/api/notifications/${id}/read`, {
method: "PUT",
headers: clientHeaders(),
});
return res.json();
}
export async function apiMarkAllRead() {
const res = await fetchWithRefresh("/api/notifications/read-all", {
method: "PUT",
headers: clientHeaders(),
});
return res.json();
}

7
frontend/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/types/root-params.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

20
frontend/next.config.ts Normal file
View File

@@ -0,0 +1,20 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
images: {
minimumCacheTTL: 14400, // 4 小时
remotePatterns: [
{ protocol: "http", hostname: "localhost" },
],
},
async rewrites() {
return [
{
source: "/api/:path*",
destination: "http://localhost:3001/api/:path*",
},
];
},
};
export default nextConfig;

3000
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
frontend/package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "jiang13-bbs-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev -p 3000",
"build": "next build",
"start": "next start -p 3000",
"lint": "next lint"
},
"dependencies": {
"next": "^16.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"lucide-react": "^0.460.0",
"react-markdown": "^9.0.1"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"tailwindcss": "^4.0.0",
"@tailwindcss/postcss": "^4.0.0",
"typescript": "^5.7.0"
}
}

View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

41
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,41 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}