首次提交:姜十三论坛
后端 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