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