From 3e55b5d230ca10e7dad823f323be670f9b02b078 Mon Sep 17 00:00:00 2001 From: freefire Date: Thu, 24 Sep 2026 03:37:44 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=AE=A1=E7=90=86=E7=AB=AF=E6=9D=BF?= =?UTF-8?q?=E5=9D=97=E7=AE=A1=E7=90=86=E4=B8=8E=E6=97=A7=E7=89=88=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=AF=BC=E5=85=A5=EF=BC=8C=E8=A1=A5=E5=85=85=E9=83=A8?= =?UTF-8?q?=E7=BD=B2=E8=BF=90=E8=90=A5=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增管理端板块管理页面与后端接口(admin_board) - 新增旧版数据导入:legacyimport 服务、导入面板、importusers 命令行工具 - 聊天用户卡片、板块图标等 UI 组件与界面优化 - 补充 about/公告/1Panel 部署等文档 - gitignore 排除 dist/ 构建产物与 .agents/ 本地工具目录 --- .gitignore | 4 + backend/cmd/importusers/main.go | 223 ++++++ backend/go.mod | 10 + backend/go.sum | 84 ++ backend/handler/admin_board.go | 198 +++++ backend/handler/admin_legacy_import.go | 139 ++++ backend/handler/auth.go | 8 +- backend/handler/handlers.go | 6 +- backend/handler/operations.go | 13 +- backend/handler/setting.go | 10 +- backend/middleware/ratelimit.go | 14 + backend/model/db.go | 42 +- backend/model/models.go | 31 +- backend/router/router.go | 69 +- backend/service/actor.go | 3 + backend/service/auth.go | 10 +- backend/service/board.go | 300 +++++++ backend/service/chat.go | 11 +- backend/service/legacyimport.go | 738 +++++++++++++++++ backend/service/operations.go | 13 + backend/service/operations_filter.go | 8 + backend/service/operations_mail.go | 47 +- backend/service/post.go | 70 +- backend/service/post_interact.go | 6 +- backend/service/setting.go | 24 + backend/service/staff_exempt_test.go | 146 ++++ docs/about.md | 66 ++ docs/announcement.md | 37 + docs/deploy-1panel.md | 183 +++++ .../AnnouncementComposeClient.tsx | 154 ++-- frontend/app/admin/boards/BoardsAdmin.tsx | 739 ++++++++++++++++++ frontend/app/admin/boards/page.tsx | 33 + .../app/admin/pages/PageComposeClient.tsx | 112 ++- .../app/admin/settings/AccessSettings.tsx | 49 +- frontend/app/admin/settings/SettingsShell.tsx | 1 + .../app/admin/settings/import/ImportPanel.tsx | 415 ++++++++++ frontend/app/admin/settings/import/page.tsx | 10 + frontend/app/chat/[id]/ChatRoomClient.tsx | 452 ++++++++--- frontend/app/globals.css | 338 ++++++-- frontend/app/login/page.tsx | 27 +- .../app/post/[id]/edit/EditPostClient.tsx | 3 + frontend/app/post/[id]/edit/page.tsx | 1 + frontend/app/post/[id]/page.tsx | 7 +- frontend/components/BoardIcon.tsx | 119 +++ frontend/components/BoardPicker.tsx | 26 +- frontend/components/ChatUserCard.tsx | 218 ++++++ frontend/components/PostComposer.tsx | 3 + frontend/components/RoleAccountModal.tsx | 2 + frontend/components/admin/AdminShell.tsx | 15 +- frontend/components/home/WorkspaceNav.tsx | 8 +- frontend/lib/api.ts | 237 +++++- frontend/lib/realtime.ts | 1 + frontend/next-env.d.ts | 4 +- 53 files changed, 5078 insertions(+), 409 deletions(-) create mode 100644 backend/cmd/importusers/main.go create mode 100644 backend/handler/admin_board.go create mode 100644 backend/handler/admin_legacy_import.go create mode 100644 backend/service/legacyimport.go create mode 100644 backend/service/staff_exempt_test.go create mode 100644 docs/about.md create mode 100644 docs/announcement.md create mode 100644 docs/deploy-1panel.md create mode 100644 frontend/app/admin/boards/BoardsAdmin.tsx create mode 100644 frontend/app/admin/boards/page.tsx create mode 100644 frontend/app/admin/settings/import/ImportPanel.tsx create mode 100644 frontend/app/admin/settings/import/page.tsx create mode 100644 frontend/components/BoardIcon.tsx create mode 100644 frontend/components/ChatUserCard.tsx diff --git a/.gitignore b/.gitignore index ed873f7..cc11750 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,7 @@ desktop.ini # ===== Trae 工具内部目录 ===== .trae/ +.agents/ + +# ===== 构建产物输出 ===== +dist/ diff --git a/backend/cmd/importusers/main.go b/backend/cmd/importusers/main.go new file mode 100644 index 0000000..40e3cd6 --- /dev/null +++ b/backend/cmd/importusers/main.go @@ -0,0 +1,223 @@ +// 一次性迁移工具:把旧论坛(jiang13-forum,SQLite)的用户导入本站(PostgreSQL)。 +// +// 迁移范围:用户名 / bcrypt 密码哈希 / 昵称 / 签名 / 邮箱 / 头像(含文件复制)。 +// 旧站 role=admin 的账号不导入;积分、经验、徽章等一律不迁。 +// 两站密码同为 bcrypt,哈希原样复制,老用户用原密码即可登录。 +// +// 幂等:已存在(含软删)同名用户自动跳过,可安全重复运行。 +// +// 用法示例: +// +// go run ./cmd/importusers --dry-run # 只统计打印,不写库不复制文件 +// go run ./cmd/importusers # 实际导入 +package main + +import ( + "flag" + "fmt" + "io" + "log" + "os" + "path/filepath" + "time" + + "github.com/glebarez/sqlite" + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "github.com/freefire/jiang13-bbs/model" +) + +const signatureMaxRunes = 255 // 新站 Signature 列上限 + +// oldUser 只映射迁移所需列,显式指向旧库 users 表 +type oldUser struct { + ID uint `gorm:"primaryKey"` + Username string `gorm:"column:username"` + Email string `gorm:"column:email"` + Password string `gorm:"column:password"` + Nickname string `gorm:"column:nickname"` + Signature string `gorm:"column:signature"` + Avatar string `gorm:"column:avatar"` + Role string `gorm:"column:role"` + Banned bool `gorm:"column:banned"` + CreatedAt time.Time `gorm:"column:created_at"` + DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"` // 软删行由 GORM 自动过滤 +} + +func (oldUser) TableName() string { return "users" } + +type report struct { + avatarMissing []string + conflicts []string + failed []string +} + +func main() { + var ( + sqlitePath = flag.String("sqlite", `C:\Users\freefire\Documents\jiang13-forum\data\jiang13.db`, "旧站 SQLite 库路径") + avatarsSrc = flag.String("avatars-src", `C:\Users\freefire\Documents\jiang13-forum\data\uploads\avatars`, "旧站头像文件目录") + avatarsDst = flag.String("avatars-dst", `./data/uploads/avatars`, "新站头像文件目录({DataDir}/uploads/avatars)") + dsn = flag.String("dsn", "postgres://postgres:postgres@localhost:5432/jiang13?sslmode=disable", "新站 PostgreSQL DSN") + dryRun = flag.Bool("dry-run", false, "只统计打印,不写库不复制文件") + ) + flag.Parse() + + // ---- 打开旧库(只读) ---- + oldDB, err := gorm.Open(sqlite.Open("file:"+filepath.ToSlash(*sqlitePath)+"?mode=ro"), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + log.Fatalf("打开旧库失败 %s: %v", *sqlitePath, err) + } + + var users []oldUser + if err := oldDB.Where("role <> ?", "admin").Order("id ASC").Find(&users).Error; err != nil { + log.Fatalf("读取旧库用户失败: %v", err) + } + log.Printf("旧库可迁移用户(role != admin): %d 人", len(users)) + + // ---- 打开新库 ---- + newDB, err := gorm.Open(postgres.Open(*dsn), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + log.Fatalf("连接新库失败: %v", err) + } + + if !*dryRun { + if err := os.MkdirAll(*avatarsDst, 0o755); err != nil { + log.Fatalf("创建头像目标目录失败 %s: %v", *avatarsDst, err) + } + } + + rep := &report{} + var imported int + for _, u := range users { + // 用户名查重(含软删行) + var n int64 + if err := newDB.Unscoped().Model(&model.User{}).Where("username = ?", u.Username).Count(&n).Error; err != nil { + rep.failed = append(rep.failed, fmt.Sprintf("%s (查重失败: %v)", u.Username, err)) + continue + } + if n > 0 { + rep.conflicts = append(rep.conflicts, u.Username) + continue + } + + avatarURL, err := resolveAvatar(u.Avatar, *avatarsSrc, *avatarsDst, *dryRun) + if err != nil { + rep.failed = append(rep.failed, fmt.Sprintf("%s (头像处理失败: %v)", u.Username, err)) + continue + } + if u.Avatar != "" && avatarURL == "" { + rep.avatarMissing = append(rep.avatarMissing, fmt.Sprintf("%s: %s", u.Username, u.Avatar)) + } + + user := model.User{ + Username: u.Username, + Email: u.Email, + Password: u.Password, // bcrypt 哈希原样复制 + Nickname: u.Nickname, + Signature: truncateRunes(u.Signature, signatureMaxRunes), + Avatar: avatarURL, + Role: model.RoleUser, + Banned: u.Banned, + CreatedAt: u.CreatedAt, // 保留原注册时间 + } + + if *dryRun { + imported++ + continue + } + if err := newDB.Create(&user).Error; err != nil { + rep.failed = append(rep.failed, fmt.Sprintf("%s (写入失败: %v)", u.Username, err)) + continue + } + imported++ + } + + printSummary(len(users), imported, rep, *dryRun) +} + +// resolveAvatar 把旧头像相对路径落成新站文件 + URL。 +// 返回新 Avatar 字段值;空串表示无法迁移(源缺失或格式不支持)。 +func resolveAvatar(oldAvatar, srcDir, dstDir string, dryRun bool) (string, error) { + if oldAvatar == "" { + return "", nil + } + const prefix = "/uploads/avatars/" + if len(oldAvatar) <= len(prefix) || filepath.Base(oldAvatar) != oldAvatar[len(prefix):] { + // 不符合旧站 "/uploads/avatars/<文件名>" 约定(如外部 URL),无法本地迁移 + return "", nil + } + filename := filepath.Base(oldAvatar) + src := filepath.Join(srcDir, filename) + if _, err := os.Stat(src); err != nil { + return "", nil // 源文件缺失 + } + dst := filepath.Join(dstDir, filename) + if !dryRun { + if _, err := os.Stat(dst); err != nil { + if err := copyFile(src, dst); err != nil { + return "", err + } + } + // dst 已存在则跳过复制,URL 仍然有效 + } + return prefix + filename, nil +} + +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + out, err := os.Create(dst) + if err != nil { + return err + } + defer out.Close() + if _, err := io.Copy(out, in); err != nil { + return err + } + return out.Close() +} + +func truncateRunes(s string, max int) string { + r := []rune(s) + if len(r) <= max { + return s + } + return string(r[:max]) +} + +func printSummary(total, imported int, rep *report, dryRun bool) { + mode := "导入完成" + if dryRun { + mode = "dry-run 统计" + } + fmt.Println("==========") + fmt.Printf("[%s] 旧库用户 %d | 成功 %d | 用户名冲突跳过 %d | 头像缺失 %d | 失败 %d\n", + mode, total, imported, len(rep.conflicts), len(rep.avatarMissing), len(rep.failed)) + if len(rep.conflicts) > 0 { + fmt.Println("-- 用户名冲突(新库已存在,跳过):") + for _, c := range rep.conflicts { + fmt.Println(" ", c) + } + } + if len(rep.avatarMissing) > 0 { + fmt.Println("-- 头像源文件缺失(Avatar 置空):") + for _, c := range rep.avatarMissing { + fmt.Println(" ", c) + } + } + if len(rep.failed) > 0 { + fmt.Println("-- 失败明细:") + for _, c := range rep.failed { + fmt.Println(" ", c) + } + } +} diff --git a/backend/go.mod b/backend/go.mod index e0b4db8..3292433 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -18,6 +18,9 @@ require ( ) require ( + github.com/JohannesKaufmann/html-to-markdown v1.6.0 // indirect + github.com/PuerkitoBio/goquery v1.9.2 // indirect + github.com/andybalholm/cascadia v1.3.2 // indirect 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 @@ -25,6 +28,8 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gin-contrib/sse v0.1.0 // indirect + github.com/glebarez/go-sqlite v1.21.2 // indirect + github.com/glebarez/sqlite v1.11.0 // indirect github.com/go-ini/ini v1.67.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect @@ -47,6 +52,7 @@ require ( 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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rs/xid v1.6.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect @@ -56,4 +62,8 @@ require ( golang.org/x/sys v0.48.0 // indirect google.golang.org/protobuf v1.34.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/libc v1.22.5 // indirect + modernc.org/mathutil v1.5.0 // indirect + modernc.org/memory v1.5.0 // indirect + modernc.org/sqlite v1.23.1 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index 8db50c7..6e44f5e 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,3 +1,9 @@ +github.com/JohannesKaufmann/html-to-markdown v1.6.0 h1:04VXMiE50YYfCfLboJCLcgqF5x+rHJnb1ssNmqpLH/k= +github.com/JohannesKaufmann/html-to-markdown v1.6.0/go.mod h1:NUI78lGg/a7vpEJTz/0uOcYMaibytE4BUOQS8k78yPQ= +github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE= +github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk= +github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss= +github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU= 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= @@ -20,6 +26,10 @@ 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/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= +github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= +github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= +github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= @@ -62,8 +72,11 @@ github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa02 github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 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= @@ -81,17 +94,25 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G 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/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 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/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/sebdah/goldie/v2 v2.5.3/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI= +github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= +github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= 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.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 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= @@ -103,33 +124,88 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS 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= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= 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.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ= golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= 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.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +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.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 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-20190902080502-41f04d3bba15/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.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 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= @@ -137,5 +213,13 @@ 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= +modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= +modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= +modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= +modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= +modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= +modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/backend/handler/admin_board.go b/backend/handler/admin_board.go new file mode 100644 index 0000000..37d3cb2 --- /dev/null +++ b/backend/handler/admin_board.go @@ -0,0 +1,198 @@ +package handler + +import ( + "errors" + "net/http" + "strconv" + + "github.com/freefire/jiang13-bbs/service" + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// ===== 板块管理(仅站长;路由层 RequirePerm(PermBoards) 兜底) ===== + +// adminBoardBody 板块新建/更新请求体 +type adminBoardBody struct { + Name string `json:"name"` + Description string `json:"description"` + Icon string `json:"icon"` + ColorIndex *int `json:"color_index"` + SortOrder *int `json:"sort_order"` + PostPolicy string `json:"post_policy"` + Visible *bool `json:"visible"` +} + +// toBoardInput 把请求体转 service 入参 +func toBoardInput(b adminBoardBody) service.BoardInput { + return service.BoardInput{ + Name: b.Name, + Description: b.Description, + Icon: b.Icon, + ColorIndex: b.ColorIndex, + SortOrder: b.SortOrder, + PostPolicy: b.PostPolicy, + Visible: b.Visible, + } +} + +// AdminListBoards 板块列表(管理端:含隐藏板块;附帖子计数) +func (h *Handlers) AdminListBoards(c *gin.Context) { + boards, err := h.Board.List() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "获取板块列表失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"boards": nonNilSlice(boards)}) +} + +// AdminCreateBoard 创建板块 +func (h *Handlers) AdminCreateBoard(c *gin.Context) { + var body adminBoardBody + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"}) + return + } + board, err := h.Board.Create(toBoardInput(body)) + if err != nil { + respondBoardError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"board": board}) +} + +// AdminUpdateBoard 更新板块 +func (h *Handlers) AdminUpdateBoard(c *gin.Context) { + id, ok := parseBoardID(c) + if !ok { + return + } + var body adminBoardBody + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"}) + return + } + board, err := h.Board.Update(id, toBoardInput(body)) + if err != nil { + respondBoardError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"board": board}) +} + +// AdminDeleteBoard 软删板块(仍有帖子则拒绝) +func (h *Handlers) AdminDeleteBoard(c *gin.Context) { + id, ok := parseBoardID(c) + if !ok { + return + } + if err := h.Board.Delete(id); err != nil { + respondBoardError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +// adminReorderBody 排序请求体 +type adminReorderBody struct { + IDs []uint `json:"ids"` +} + +// AdminReorderBoards 批量设置 sort_order +func (h *Handlers) AdminReorderBoards(c *gin.Context) { + var body adminReorderBody + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"}) + return + } + if err := h.Board.Reorder(body.IDs); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "排序失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +// AdminListBoardModerators 板块版主列表 +func (h *Handlers) AdminListBoardModerators(c *gin.Context) { + id, ok := parseBoardID(c) + if !ok { + return + } + mods, err := h.Board.ListModerators(id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "获取版主失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"moderators": nonNilSlice(mods)}) +} + +// adminBoardModBody 添加版主请求体(按用户名定位) +type adminBoardModBody struct { + Username string `json:"username"` +} + +// AdminAddBoardModerator 按用户名添加板块版主 +func (h *Handlers) AdminAddBoardModerator(c *gin.Context) { + boardID, ok := parseBoardID(c) + if !ok { + return + } + var body adminBoardModBody + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"}) + return + } + userID := h.Auth.GetUserIDByUsername(body.Username) + if userID == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"}) + return + } + if err := h.Board.AddModerator(boardID, userID); err != nil { + respondBoardError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +// AdminRemoveBoardModerator 移除版主 +func (h *Handlers) AdminRemoveBoardModerator(c *gin.Context) { + boardID, ok := parseBoardID(c) + if !ok { + return + } + uid, err := strconv.ParseUint(c.Param("uid"), 10, 64) + if err != nil || uid == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的用户 ID"}) + return + } + if err := h.Board.RemoveModerator(boardID, uint(uid)); err != nil { + respondBoardError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +// parseBoardID 解析路径中的板块 ID +func parseBoardID(c *gin.Context) (uint, bool) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil || id == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的板块 ID"}) + return 0, false + } + return uint(id), true +} + +// respondBoardError 板块业务错误映射 +func respondBoardError(c *gin.Context, err error) { + switch { + case errors.Is(err, gorm.ErrRecordNotFound): + c.JSON(http.StatusNotFound, gin.H{"error": "板块不存在"}) + case errors.Is(err, service.ErrBoardNameRequired), + errors.Is(err, service.ErrProtectedOwner): + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + case errors.Is(err, service.ErrBoardInUse): + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "操作失败"}) + } +} diff --git a/backend/handler/admin_legacy_import.go b/backend/handler/admin_legacy_import.go new file mode 100644 index 0000000..4dc075b --- /dev/null +++ b/backend/handler/admin_legacy_import.go @@ -0,0 +1,139 @@ +package handler + +import ( + "errors" + "io" + "mime/multipart" + "net/http" + "os" + "strconv" + + "github.com/freefire/jiang13-bbs/middleware" + "github.com/freefire/jiang13-bbs/service" + "github.com/gin-gonic/gin" +) + +// ===== 数据导入(settingsAPI 组内,PermSettings 门控:仅超管/站长) ===== +// +// POST /api/admin/import/:source multipart: +// - db_file 旧站 SQLite 库(必填) +// - avatars_zip 头像压缩包(可选) +// - with_content "true"/"false",是否同时导入板块/帖子/评论(默认 true) +// - dry_run "true"/"false",预检模式不写库(默认 false) +// +// source 走白名单(当前仅 jiang13),为 WordPress / Typecho 等来源预留扩展。 + +// AdminImportLegacy 导入外部站点数据,返回分类统计报告。幂等可重复导入。 +func (h *Handlers) AdminImportLegacy(c *gin.Context) { + source := c.Param("source") + if !service.LegacyImportSource(source) { + c.JSON(http.StatusBadRequest, gin.H{"error": "暂不支持该数据源(当前支持:jiang13)"}) + return + } + + const overhead = 64 << 10 // multipart 边界与表单开销 + limit := int64(service.LegacyDBMaxBytes) + int64(service.LegacyZipMaxBytes) + overhead + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, limit) + + claims := middleware.CurrentUser(c) + if claims == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"}) + return + } + + dbFH, err := c.FormFile("db_file") + if err != nil { + respondLegacyUploadError(c, err, "请上传旧站数据库文件(jiang13.db)") + return + } + if dbFH.Size > service.LegacyDBMaxBytes { + c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "数据库文件不能超过 64MB"}) + return + } + zipPath := "" + if zipFH, ferr := c.FormFile("avatars_zip"); ferr == nil && zipFH != nil && zipFH.Size > 0 { + if zipFH.Size > service.LegacyZipMaxBytes { + c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "头像压缩包不能超过 128MB"}) + return + } + zipPath, err = saveMultipartToTemp(zipFH, "legacy-avatars-*.zip") + if err != nil { + respondLegacyUploadError(c, err, "读取头像压缩包失败") + return + } + defer os.Remove(zipPath) + } else if ferr != nil { + // 表单整体解析失败(超限等);「字段不存在」直接忽略 + var maxErr *http.MaxBytesError + if errors.As(ferr, &maxErr) { + c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "上传内容过大"}) + return + } + } + + dbPath, err := saveMultipartToTemp(dbFH, "legacy-*.db") + if err != nil { + respondLegacyUploadError(c, err, "读取数据库文件失败") + return + } + defer os.Remove(dbPath) + + rep, err := h.LegacyImport.ImportFromFiles(dbPath, zipPath, service.LegacyImportOptions{ + WithContent: formBool(c, "with_content", true), + DryRun: formBool(c, "dry_run", false), + }, claims.ID) + if err != nil { + if errors.Is(err, service.ErrLegacyInvalidSQLite) || errors.Is(err, service.ErrLegacyBadZip) { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "导入失败: " + err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"report": rep}) +} + +func formBool(c *gin.Context, key string, def bool) bool { + v := c.PostForm(key) + if v == "" { + return def + } + b, err := strconv.ParseBool(v) + if err != nil { + return def + } + return b +} + +func respondLegacyUploadError(c *gin.Context, err error, fallback string) { + var maxErr *http.MaxBytesError + if errors.As(err, &maxErr) { + c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "上传内容过大"}) + return + } + c.JSON(http.StatusBadRequest, gin.H{"error": fallback}) +} + +// saveMultipartToTemp 把上传文件落盘为临时文件(大文件不宜整读进内存,Docker 下 /tmp 可写) +func saveMultipartToTemp(fh *multipart.FileHeader, pattern string) (string, error) { + src, err := fh.Open() + if err != nil { + return "", err + } + defer src.Close() + tmp, err := os.CreateTemp("", pattern) + if err != nil { + return "", err + } + name := tmp.Name() + if _, err := io.Copy(tmp, src); err != nil { + tmp.Close() + os.Remove(name) + return "", err + } + if err := tmp.Close(); err != nil { + os.Remove(name) + return "", err + } + return name, nil +} diff --git a/backend/handler/auth.go b/backend/handler/auth.go index 426ac6d..082a41f 100644 --- a/backend/handler/auth.go +++ b/backend/handler/auth.go @@ -137,7 +137,13 @@ func (h *Handlers) Register(c *gin.Context) { } if security.VerifyEmail { if err = h.Ops.ConsumeCode(req.Email, "register", req.Code); err != nil { - c.JSON(400, gin.H{"error": "请先完成邮箱验证"}) + var te *service.CodeThrottleError + if errors.As(err, &te) { + c.Header("Retry-After", strconv.Itoa(te.Wait)) + c.JSON(429, gin.H{"error": te.Error(), "retry_after": te.Wait}) + } else { + c.JSON(400, gin.H{"error": "请先完成邮箱验证"}) + } return } } diff --git a/backend/handler/handlers.go b/backend/handler/handlers.go index 34ddd05..75c00b2 100644 --- a/backend/handler/handlers.go +++ b/backend/handler/handlers.go @@ -28,6 +28,7 @@ type Handlers struct { Points *service.PointsService Setting *service.SettingService AdminUser *service.AdminUserService + LegacyImport *service.LegacyImportService Moderation *service.ModerationService Chat *service.ChatService Telemetry *service.TelemetryService @@ -37,11 +38,14 @@ type Handlers struct { } // resolvePublishStatus 决定新帖/新评的初始状态: -// staff 直发;若开启「已过审免审」且用户已有 published 内容则直发;否则进 pending。 +// staff 直发;全站免审开启时所有人直发;否则若开启「已过审免审」且用户已有 published 内容则直发;否则进 pending。 func (h *Handlers) resolvePublishStatus(userID uint, role string) string { if model.IsStaff(model.Role(role)) { return model.ContentStatusPublished } + if withoutReview, err := h.Setting.PublishWithoutReview(); err == nil && withoutReview { + return model.ContentStatusPublished + } trust, err := h.Setting.TrustReviewedPublish() if err != nil || !trust { return model.ContentStatusPending diff --git a/backend/handler/operations.go b/backend/handler/operations.go index 374bfc2..5f5ba2a 100644 --- a/backend/handler/operations.go +++ b/backend/handler/operations.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "github.com/freefire/jiang13-bbs/middleware" + "github.com/freefire/jiang13-bbs/model" "github.com/freefire/jiang13-bbs/service" "github.com/gin-gonic/gin" "io" @@ -202,6 +203,10 @@ func (h *Handlers) BusinessQuota(c *gin.Context) { } p := c.FullPath() user := middleware.CurrentUser(c) + if user != nil && model.IsStaff(model.Role(user.Role)) { + c.Next() // 管理角色不受发帖/评论间隔与搜索频控限制 + return + } identity := "ip:" + c.ClientIP() if user != nil { identity = "user:" + strconv.Itoa(int(user.ID)) @@ -272,7 +277,13 @@ func (h *Handlers) ResetPassword(c *gin.Context) { return } if e := h.Ops.ResetPassword(req.Email, req.Code, req.Password); e != nil { - c.JSON(400, gin.H{"error": "验证码无效、已过期或密码格式不符合要求"}) + var te *service.CodeThrottleError + if errors.As(e, &te) { + c.Header("Retry-After", strconv.Itoa(te.Wait)) + c.JSON(429, gin.H{"error": te.Error(), "retry_after": te.Wait}) + } else { + c.JSON(400, gin.H{"error": "验证码无效、已过期或密码格式不符合要求"}) + } return } c.JSON(200, gin.H{"message": "密码已更新,请重新登录"}) diff --git a/backend/handler/setting.go b/backend/handler/setting.go index cac56de..3fe5098 100644 --- a/backend/handler/setting.go +++ b/backend/handler/setting.go @@ -23,6 +23,7 @@ func (h *Handlers) PublicSettings(c *gin.Context) { type updateSettingsRequest struct { Accent *string `json:"accent"` TrustReviewedPublish *bool `json:"trust_reviewed_publish"` + PublishWithoutReview *bool `json:"publish_without_review"` SiteName *string `json:"site_name"` SiteDescription *string `json:"site_description"` AllowRegister *bool `json:"allow_register"` @@ -62,6 +63,7 @@ func settingsPayload(saved service.PublicSiteSettings) gin.H { return gin.H{ "accent": saved.Accent, "trust_reviewed_publish": saved.TrustReviewedPublish, + "publish_without_review": saved.PublishWithoutReview, "site_name": saved.SiteName, "site_description": saved.SiteDescription, "allow_register": saved.AllowRegister, @@ -99,7 +101,7 @@ func settingsPayload(saved service.PublicSiteSettings) gin.H { } func (req *updateSettingsRequest) hasAny() bool { - return req.Accent != nil || req.TrustReviewedPublish != nil || req.SiteName != nil || + return req.Accent != nil || req.TrustReviewedPublish != nil || req.PublishWithoutReview != nil || req.SiteName != nil || req.SiteDescription != nil || req.AllowRegister != nil || req.AllowComments != nil || req.CommentsRequireLogin != nil || req.AllowMessages != nil || req.PostCooldownHours != nil || req.CodeBlockAutoFold != nil || req.CodeBlockFoldLines != nil || req.UIAnimations != nil || req.AnimCodeFold != nil || @@ -156,6 +158,12 @@ func (h *Handlers) UpdateSettings(c *gin.Context) { return } } + if req.PublishWithoutReview != nil { + if err := h.Setting.SetPublishWithoutReview(*req.PublishWithoutReview); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"}) + return + } + } if req.SiteName != nil { if err := h.Setting.SetSiteName(*req.SiteName); err != nil { if errors.Is(err, service.ErrInvalidSiteSetting) { diff --git a/backend/middleware/ratelimit.go b/backend/middleware/ratelimit.go index 31412d0..ee3dbdd 100644 --- a/backend/middleware/ratelimit.go +++ b/backend/middleware/ratelimit.go @@ -4,6 +4,7 @@ import ( "net/http" "strconv" + "github.com/freefire/jiang13-bbs/model" "github.com/freefire/jiang13-bbs/service" "github.com/gin-gonic/gin" ) @@ -38,3 +39,16 @@ func RateLimitUserMiddleware(rl *service.RateLimiter, rateType string) gin.Handl c.Next() } } + +// StaffExempt 内容类频控豁免:管理角色直接放行,其余走原限流。 +// 必须挂在 RequireAuth 之后(依赖 CurrentUser);角色变更会递增 +// token_version 强制重登,故 JWT 内 role 可视为实时。 +func StaffExempt(inner gin.HandlerFunc) gin.HandlerFunc { + return func(c *gin.Context) { + if claims := CurrentUser(c); claims != nil && model.IsStaff(model.Role(claims.Role)) { + c.Next() + return + } + inner(c) + } +} diff --git a/backend/model/db.go b/backend/model/db.go index f409255..bd929b7 100644 --- a/backend/model/db.go +++ b/backend/model/db.go @@ -62,6 +62,7 @@ func InitDB(dsn string) error { &ChatRoom{}, &ChatRoomMember{}, &ChatMessage{}, &PointLedger{}, &PostContentUnlock{}, &PostAttachment{}, &PostAttachmentUnlock{}, &PostPollVote{}, &PostLotteryEntry{}, &Ad{}, + &ImportRecord{}, ); err != nil { return fmt.Errorf("自动迁移失败: %w", err) } @@ -96,8 +97,20 @@ func InitDB(dsn string) error { `).Error; err != nil { return fmt.Errorf("私聊唯一索引创建失败: %w", err) } + + // 昵称全站唯一(忽略大小写,软删用户不占用),与用户名同级约束: + // 保证 @提及 昵称→用户名 映射无歧义。应用层另有友好错误提示(service/auth.go UpdateProfile)。 + // 存量重名会令本索引创建失败(开发期数据可重置)。 + if err := db.Exec(` + CREATE UNIQUE INDEX IF NOT EXISTS idx_users_nickname_lower + ON users (LOWER(nickname)) + WHERE deleted_at IS NULL + `).Error; err != nil { + return fmt.Errorf("昵称唯一索引创建失败(存量数据可能存在重名昵称,请清理或重置数据库): %w", err) + } + backfillBoardDefaults(db) seedDefaultBoards(db) - if err := seedDefaultChatRoom(db); err != nil { + if err := EnsureDefaultChatRoom(db); err != nil { return fmt.Errorf("默认群聊大厅初始化失败: %w", err) } if err := ensureDefaultChatMemberships(db); err != nil { @@ -321,13 +334,27 @@ func ensureOwnerRole(db *gorm.DB) error { return nil } +// backfillBoardDefaults 为升级前已存在的板块补齐新字段默认值。 +// 以 post_policy 为空作为「迁移前旧行」的判定标志:仅对这些行一次性回填 +// post_policy=all、visible=true;后续已被显式设置过的行(含站长隐藏的板块)不会被覆盖。 +func backfillBoardDefaults(db *gorm.DB) { + if err := db.Model(&Board{}). + Where("post_policy = '' OR post_policy IS NULL"). + Updates(map[string]interface{}{ + "post_policy": BoardPostPolicyAll, + "visible": true, + }).Error; err != nil { + log.Printf("[model] 板块默认值回填失败: %v", err) + } +} + // 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}, + {Name: "综合讨论", Description: "什么都可以聊", Icon: "message-circle", SortOrder: 1, PostPolicy: BoardPostPolicyAll, Visible: true}, + {Name: "技术分享", Description: "分享技术心得与问题", Icon: "code", SortOrder: 2, PostPolicy: BoardPostPolicyAll, Visible: true}, + {Name: "问答求助", Description: "提问与解答", Icon: "help-circle", SortOrder: 3, PostPolicy: BoardPostPolicyAll, Visible: true}, + {Name: "闲聊灌水", Description: "轻松闲聊", Icon: "coffee", SortOrder: 4, PostPolicy: BoardPostPolicyAll, Visible: true}, } for _, b := range defaults { var count int64 @@ -338,8 +365,9 @@ func seedDefaultBoards(db *gorm.DB) { } } -// seedDefaultChatRoom 确保存在唯一的全站大厅(公开默认群) -func seedDefaultChatRoom(db *gorm.DB) error { +// EnsureDefaultChatRoom 确保存在唯一的全站大厅(公开默认群)。 +// 导出供注册链路在启动播种时因空库跳过后就地补建(自愈,不依赖重启)。 +func EnsureDefaultChatRoom(db *gorm.DB) error { var n int64 if err := db.Model(&ChatRoom{}).Where("is_default = ?", true).Count(&n).Error; err != nil { return err diff --git a/backend/model/models.go b/backend/model/models.go index e1a060b..766489b 100644 --- a/backend/model/models.go +++ b/backend/model/models.go @@ -116,7 +116,7 @@ type User struct { 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"` + Nickname string `gorm:"size:64" json:"nickname"` // 全站唯一:LOWER 函数唯一索引在 db.go InitDB 中创建(应用层校验见 service/auth.go) Avatar string `gorm:"size:512" json:"avatar"` Signature string `gorm:"size:255;default:''" json:"signature"` // 个性签名 Role Role `gorm:"size:16;default:user" json:"role"` @@ -161,11 +161,40 @@ type Board struct { Icon string `gorm:"size:64;default:''" json:"icon"` ColorIndex int `gorm:"default:-1" json:"color_index"` SortOrder int `gorm:"default:0" json:"sort_order"` + PostPolicy string `gorm:"size:16;default:'all'" json:"post_policy"` // all=全员可发;staff=仅管理员及以上 + Visible bool `gorm:"default:true" json:"visible"` // false 时不在前台列表/导航展示(仅隐藏入口) CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` } +// 板块发帖策略常量 +const ( + BoardPostPolicyAll = "all" // 全员可发帖 + BoardPostPolicyStaff = "staff" // 仅管理员及以上(含管辖该板的版主)可发帖 +) + +// NormalizeBoardPostPolicy 归一化发帖策略,非法值回落 all +func NormalizeBoardPostPolicy(v string) string { + switch v { + case BoardPostPolicyStaff: + return BoardPostPolicyStaff + default: + return BoardPostPolicyAll + } +} + +// ImportRecord 外部数据导入去重记录(旧站/WordPress/Typecho 等来源): +// 同一来源同一源对象只导入一次,保证「预检→正式导入→重复导入」全程幂等。 +type ImportRecord struct { + ID uint `gorm:"primaryKey" json:"id"` + Source string `gorm:"size:32;not null;uniqueIndex:idx_import_dedupe,priority:1" json:"source"` // jiang13 / wordpress / typecho + Kind string `gorm:"size:16;not null;uniqueIndex:idx_import_dedupe,priority:2" json:"kind"` // board / post / comment + SourceID string `gorm:"size:64;not null;uniqueIndex:idx_import_dedupe,priority:3" json:"source_id"` + NewID uint `json:"new_id"` + CreatedAt time.Time `json:"created_at"` +} + // Post 帖子 type Post struct { ID uint `gorm:"primaryKey" json:"id"` diff --git a/backend/router/router.go b/backend/router/router.go index 5148ab4..f49d6ff 100644 --- a/backend/router/router.go +++ b/backend/router/router.go @@ -56,7 +56,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { settingSvc := service.NewSettingService(model.DB) ops := service.NewOperations(model.DB, cfg) go ops.Run(context.Background()) - postSvc := service.NewPostService(model.DB).WithSetting(settingSvc).WithDevMode(cfg.DevMode) + postSvc := service.NewPostService(model.DB).WithSetting(settingSvc).WithBoard(boardSvc).WithDevMode(cfg.DevMode) commentSvc := service.NewCommentService(model.DB) likeSvc := service.NewLikeService(model.DB) notifSvc := service.NewNotificationService(model.DB) @@ -70,6 +70,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { postFileSvc.WithOperations(ops) pointsSvc := service.NewPointsService(model.DB) adminUserSvc := service.NewAdminUserService(model.DB) + legacyImportSvc := service.NewLegacyImportService(model.DB, filepath.Join(cfg.DataDir, "uploads")) moderationSvc := service.NewModerationService(model.DB, notifSvc) chatSvc := service.NewChatService(model.DB, notifSvc) telemetrySvc := service.NewTelemetryService(model.DB) @@ -103,6 +104,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { Points: pointsSvc, Setting: settingSvc, AdminUser: adminUserSvc, + LegacyImport: legacyImportSvc, Moderation: moderationSvc, Chat: chatSvc, Telemetry: telemetrySvc, @@ -176,22 +178,22 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { api.GET("/me/login-devices", h.MyLoginDevices) api.DELETE("/me/login-devices/:id", h.RevokeLoginDevice) api.PUT("/profile", h.UpdateProfile) - api.POST("/posts", middleware.RateLimitMiddleware(limiter, service.RatePost), h.CreatePost) + api.POST("/posts", middleware.StaffExempt(middleware.RateLimitMiddleware(limiter, service.RatePost)), h.CreatePost) api.PUT("/posts/:id", h.UpdatePost) - api.POST("/posts/:id/unlock", middleware.RateLimitUserMiddleware(limiter, service.RateInteract), h.UnlockPostContent) - api.POST("/posts/:id/poll/vote", middleware.RateLimitUserMiddleware(limiter, service.RateInteract), h.VotePoll) + api.POST("/posts/:id/unlock", middleware.StaffExempt(middleware.RateLimitUserMiddleware(limiter, service.RateInteract)), h.UnlockPostContent) + api.POST("/posts/:id/poll/vote", middleware.StaffExempt(middleware.RateLimitUserMiddleware(limiter, service.RateInteract)), h.VotePoll) api.POST("/posts/:id/poll/close", h.ClosePoll) - api.POST("/posts/:id/question/accept", middleware.RateLimitUserMiddleware(limiter, service.RateInteract), h.AcceptQuestion) - api.POST("/posts/:id/question/solve", middleware.RateLimitUserMiddleware(limiter, service.RateInteract), h.SolveQuestion) - api.POST("/posts/:id/question/reopen", middleware.RateLimitUserMiddleware(limiter, service.RateInteract), h.ReopenQuestion) - api.POST("/posts/:id/bounty/accept", middleware.RateLimitUserMiddleware(limiter, service.RateInteract), h.AcceptBounty) + api.POST("/posts/:id/question/accept", middleware.StaffExempt(middleware.RateLimitUserMiddleware(limiter, service.RateInteract)), h.AcceptQuestion) + api.POST("/posts/:id/question/solve", middleware.StaffExempt(middleware.RateLimitUserMiddleware(limiter, service.RateInteract)), h.SolveQuestion) + api.POST("/posts/:id/question/reopen", middleware.StaffExempt(middleware.RateLimitUserMiddleware(limiter, service.RateInteract)), h.ReopenQuestion) + api.POST("/posts/:id/bounty/accept", middleware.StaffExempt(middleware.RateLimitUserMiddleware(limiter, service.RateInteract)), h.AcceptBounty) api.POST("/posts/:id/lottery/draw", h.DrawLottery) 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.PUT("/posts/:id/comments/:cid", middleware.RateLimitMiddleware(limiter, service.RateComment), h.UpdateComment) + api.POST("/posts/:id/comments", middleware.StaffExempt(middleware.RateLimitMiddleware(limiter, service.RateComment)), h.CreateComment) + api.PUT("/posts/:id/comments/:cid", middleware.StaffExempt(middleware.RateLimitMiddleware(limiter, service.RateComment)), h.UpdateComment) api.DELETE("/posts/:id/comments/:cid", h.DeleteComment) api.PUT("/posts/:id/comments/:cid/restore", h.RestoreComment) api.DELETE("/posts/:id/comments/:cid/purge", h.PurgeComment) @@ -202,13 +204,13 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { api.PUT("/notifications/read-all", h.MarkAllRead) // 每日签到 / 积分 api.GET("/checkin", h.GetCheckin) - api.POST("/checkin", middleware.RateLimitMiddleware(limiter, service.RateComment), h.DoCheckin) + api.POST("/checkin", middleware.StaffExempt(middleware.RateLimitMiddleware(limiter, service.RateComment)), h.DoCheckin) api.GET("/points", h.GetPointsBalance) api.GET("/points/ledger", h.GetPointsLedger) // 头像上传(裁剪后的 WebP)/ 帖子插图 / 媒体库 / 附件删除 api.POST("/upload/avatar", h.UploadAvatar) - api.POST("/upload/image", middleware.RateLimitMiddleware(limiter, service.RateUpload), h.UploadImage) - api.POST("/upload/file", middleware.RateLimitMiddleware(limiter, service.RateUpload), h.UploadPostFile) + api.POST("/upload/image", middleware.StaffExempt(middleware.RateLimitMiddleware(limiter, service.RateUpload)), h.UploadImage) + api.POST("/upload/file", middleware.StaffExempt(middleware.RateLimitMiddleware(limiter, service.RateUpload)), h.UploadPostFile) api.PUT("/upload/file/:id/price", h.UpdatePostFilePrice) api.DELETE("/upload/file/:id", h.DeletePostFile) api.PUT("/avatar/use", h.UseAvatar) @@ -216,7 +218,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { api.DELETE("/my/attachments/:id", h.DeleteAttachment) // 时间线:从 Git commits 页导入(登录用户;适配器由超管配置) - api.POST("/timeline/from-git", middleware.RateLimitUserMiddleware(limiter, service.RateTimelineGit), h.TimelineFromGit) + api.POST("/timeline/from-git", middleware.StaffExempt(middleware.RateLimitUserMiddleware(limiter, service.RateTimelineGit)), h.TimelineFromGit) // 群聊(二期):建群/成员/消息/未读;全站关闭消息时前台一律 403 chatAPI := api.Group("/chat", h.RequireMessagesOpen) @@ -237,7 +239,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { chatAPI.PUT("/rooms/:id/members/:uid/mute", h.SetChatMemberMute) chatAPI.PUT("/rooms/:id/members/:uid/role", h.SetChatMemberRole) chatAPI.GET("/rooms/:id/messages", h.ListChatMessages) - chatAPI.POST("/rooms/:id/messages", middleware.RateLimitMiddleware(limiter, service.RateChat), h.SendChatMessage) + chatAPI.POST("/rooms/:id/messages", middleware.StaffExempt(middleware.RateLimitMiddleware(limiter, service.RateChat)), h.SendChatMessage) chatAPI.DELETE("/rooms/:id/messages/:mid", h.RecallChatMessage) chatAPI.PUT("/rooms/:id/read", h.MarkChatRead) chatAPI.GET("/unread-summary", h.ChatUnreadSummary) @@ -312,6 +314,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { opsAPI.GET("/:module/records", h.ModuleRecords) staffAPI.GET("/diagnostics", authMW.RequirePerm(service.PermSettings), h.Diagnostics) staffAPI.POST("/maintenance/actions", authMW.RequirePerm(service.PermSettings), h.MaintenanceAction) + // 数据导入(旧站 / WordPress / Typecho 等,source 白名单见 service.LegacyImportSource) + staffAPI.POST("/import/:source", authMW.RequirePerm(service.PermSettings), h.AdminImportLegacy) staffAPI.PUT("/settings", authMW.RequirePerm(service.PermSettings), h.UpdateSettings) staffAPI.POST("/upload/background", authMW.RequirePerm(service.PermSettings), middleware.RateLimitMiddleware(limiter, service.RateUpload), h.UploadBackground) staffAPI.POST("/upload/background/from-media", authMW.RequirePerm(service.PermSettings), middleware.RateLimitMiddleware(limiter, service.RateUpload), h.UploadBackgroundFromMedia) @@ -320,16 +324,31 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { // 用户与权限管理(超级管理员/站长):列表、角色授权、封禁、登录历史 usersAPI := staffAPI.Group("", authMW.RequirePerm(service.PermUsers)) - usersAPI.GET("/users", h.AdminListUsers) - usersAPI.PUT("/users/:id/role", h.AdminUpdateUserRole) - usersAPI.PUT("/users/:id/messages", h.AdminSetUserMessages) - usersAPI.PUT("/users/:id/ban", h.AdminSetUserBan) - usersAPI.GET("/users/:id/login-logs", h.AdminUserLoginLogs) - // 站长内容档案(handler 内再校验 owner) - usersAPI.GET("/users/:id", h.AdminGetUserAudit) - usersAPI.GET("/users/:id/posts", h.AdminUserAuditPosts) - usersAPI.GET("/users/:id/comments", h.AdminUserAuditComments) - usersAPI.GET("/users/:id/messages", h.AdminUserAuditMessages) + { + usersAPI.GET("/users", h.AdminListUsers) + usersAPI.PUT("/users/:id/role", h.AdminUpdateUserRole) + usersAPI.PUT("/users/:id/messages", h.AdminSetUserMessages) + usersAPI.PUT("/users/:id/ban", h.AdminSetUserBan) + usersAPI.GET("/users/:id/login-logs", h.AdminUserLoginLogs) + // 站长内容档案(handler 内再校验 owner) + usersAPI.GET("/users/:id", h.AdminGetUserAudit) + usersAPI.GET("/users/:id/posts", h.AdminUserAuditPosts) + usersAPI.GET("/users/:id/comments", h.AdminUserAuditComments) + usersAPI.GET("/users/:id/messages", h.AdminUserAuditMessages) + } + + // 板块管理(仅站长):增删改、排序、版主授权 + boardsAPI := staffAPI.Group("", authMW.RequirePerm(service.PermBoards)) + { + boardsAPI.GET("/boards", h.AdminListBoards) + boardsAPI.POST("/boards", h.AdminCreateBoard) + boardsAPI.PUT("/boards/:id", h.AdminUpdateBoard) + boardsAPI.DELETE("/boards/:id", h.AdminDeleteBoard) + boardsAPI.PUT("/boards/reorder", h.AdminReorderBoards) + boardsAPI.GET("/boards/:id/moderators", h.AdminListBoardModerators) + boardsAPI.POST("/boards/:id/moderators", h.AdminAddBoardModerator) + boardsAPI.DELETE("/boards/:id/moderators/:uid", h.AdminRemoveBoardModerator) + } } // 消息管理后台:登录即可(handler 内校验站点消息权或群管理员) diff --git a/backend/service/actor.go b/backend/service/actor.go index ecd6ff6..452ce2b 100644 --- a/backend/service/actor.go +++ b/backend/service/actor.go @@ -14,6 +14,7 @@ const ( PermSettings = "settings" // 站点外观设置(超管/站长) PermModeration = "moderation" // 内容审核(任意管理角色,板块范围受限) PermMessages = "messages" // 后台消息管理(站长/超管/站点消息 flag;群管另有业务层放行) + PermBoards = "boards" // 板块管理(仅站长) ) // Actor 当前请求操作者的实时权限快照(每次后台请求从 DB 现取, @@ -37,6 +38,8 @@ func (a *Actor) HasPerm(p string) bool { return false } switch p { + case PermBoards: + return a.Role == model.RoleOwner case PermUsers, PermSettings: return a.Role == model.RoleSuperAdmin || a.Role == model.RoleOwner case PermAnnouncements: diff --git a/backend/service/auth.go b/backend/service/auth.go index ede7dd4..de090b5 100644 --- a/backend/service/auth.go +++ b/backend/service/auth.go @@ -576,7 +576,7 @@ func (s *AuthService) ChangePassword(userID uint, oldPassword, newPassword strin // UpdateProfile 更新昵称/邮箱/签名。头像只能通过上传接口(/api/upload/avatar) // 或历史头像选用接口(/api/avatar/use)修改,这里不接收头像字段,防止写入任意外链。 -// - nickname 非空且不超过 64 字符 +// - nickname 非空、不超过 64 字符,且全站唯一(忽略大小写,排除自身;注册时昵称=用户名,用户名本身唯一) // - email 可为空;非空时需符合邮箱格式且不与他人重复 // - signature 不超过 255 字符 func (s *AuthService) UpdateProfile(userID uint, nickname, email, signature string) (*model.User, error) { @@ -590,6 +590,14 @@ func (s *AuthService) UpdateProfile(userID uint, nickname, email, signature stri if len(nickname) > 64 { return nil, errors.New("昵称不能超过 64 个字符") } + // 昵称全站唯一(排除自身):与用户名同级,保证 @提及 昵称→用户名 映射无歧义 + var nickCount int64 + s.db.Model(&model.User{}). + Where("LOWER(nickname) = LOWER(?) AND id <> ?", nickname, userID). + Count(&nickCount) + if nickCount > 0 { + return nil, errors.New("该昵称已被使用") + } email = strings.TrimSpace(email) if email != "" { if !strings.Contains(email, "@") || len(email) > 128 { diff --git a/backend/service/board.go b/backend/service/board.go index 6bbf8c9..ef065e8 100644 --- a/backend/service/board.go +++ b/backend/service/board.go @@ -1,6 +1,8 @@ package service import ( + "errors" + "strings" "time" "github.com/freefire/jiang13-bbs/model" @@ -85,6 +87,304 @@ func (s *BoardService) Get(id uint) (*model.Board, error) { return &board, nil } +// ===== 板块管理(仅站长;handler 通过 PermBoards 兜底) ===== + +// BoardInput 板块新建/更新入参;service 层做归一与长度约束 +type BoardInput struct { + Name string `json:"name"` + Description string `json:"description"` + Icon string `json:"icon"` + ColorIndex *int `json:"color_index"` + SortOrder *int `json:"sort_order"` + PostPolicy string `json:"post_policy"` + Visible *bool `json:"visible"` +} + +// 板块错误(handler 映射为 4xx) +var ( + ErrBoardNameRequired = errors.New("板块名称不能为空") + ErrBoardInUse = errors.New("该板块下仍有帖子,无法删除") +) + +const ( + boardNameMaxRunes = 64 + boardDescMaxRunes = 512 + boardIconMaxRunes = 64 +) + +// normalizeBoardInput 归一化并校验入参;ColorIndex/SortOrder/Visible 为 nil 时保持原值 +func normalizeBoardInput(in *BoardInput) error { + in.Name = strings.TrimSpace(in.Name) + in.Description = strings.TrimSpace(in.Description) + in.Icon = strings.TrimSpace(in.Icon) + in.PostPolicy = model.NormalizeBoardPostPolicy(in.PostPolicy) + if in.Name == "" { + return ErrBoardNameRequired + } + if len([]rune(in.Name)) > boardNameMaxRunes { + in.Name = string([]rune(in.Name)[:boardNameMaxRunes]) + } + if len([]rune(in.Description)) > boardDescMaxRunes { + in.Description = string([]rune(in.Description)[:boardDescMaxRunes]) + } + if len([]rune(in.Icon)) > boardIconMaxRunes { + in.Icon = string([]rune(in.Icon)[:boardIconMaxRunes]) + } + return nil +} + +// applyInput 把入参写入 board(用于 Create 与 Update;nil 字段保留旧值) +func applyInput(b *model.Board, in *BoardInput) { + b.Name = in.Name + b.Description = in.Description + b.Icon = in.Icon + b.PostPolicy = in.PostPolicy + if in.ColorIndex != nil { + b.ColorIndex = *in.ColorIndex + } + if in.SortOrder != nil { + b.SortOrder = *in.SortOrder + } + if in.Visible != nil { + b.Visible = *in.Visible + } +} + +// Create 创建板块 +func (s *BoardService) Create(in BoardInput) (*model.Board, error) { + if err := normalizeBoardInput(&in); err != nil { + return nil, err + } + b := model.Board{ + Name: in.Name, + Description: in.Description, + Icon: in.Icon, + ColorIndex: -1, + SortOrder: 0, + PostPolicy: in.PostPolicy, + Visible: true, + } + applyInput(&b, &in) + if err := s.db.Create(&b).Error; err != nil { + return nil, err + } + return &b, nil +} + +// Update 更新板块;不存在返回 gorm.ErrRecordNotFound +func (s *BoardService) Update(id uint, in BoardInput) (*model.Board, error) { + if err := normalizeBoardInput(&in); err != nil { + return nil, err + } + var b model.Board + if err := s.db.First(&b, id).Error; err != nil { + return nil, err + } + applyInput(&b, &in) + if err := s.db.Save(&b).Error; err != nil { + return nil, err + } + return &b, nil +} + +// Delete 软删板块;若仍有未删除帖子则拒绝,避免内容悬空 +func (s *BoardService) Delete(id uint) error { + var cnt int64 + if err := s.db.Model(&model.Post{}). + Where("board_id = ? AND deleted_at IS NULL", id). + Count(&cnt).Error; err != nil { + return err + } + if cnt > 0 { + return ErrBoardInUse + } + return s.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Where("board_id = ?", id).Delete(&model.UserBoard{}).Error; err != nil { + return err + } + return tx.Delete(&model.Board{}, id).Error + }) +} + +// Reorder 批量设置 sort_order;ids 顺序即展示顺序 +func (s *BoardService) Reorder(ids []uint) error { + if len(ids) == 0 { + return nil + } + return s.db.Transaction(func(tx *gorm.DB) error { + for i, id := range ids { + if err := tx.Model(&model.Board{}).Where("id = ?", id). + Update("sort_order", i).Error; err != nil { + return err + } + } + return nil + }) +} + +// BoardModeratorMember 板块版主列表项(id/username/nickname/avatar/role 透出) +type BoardModeratorMember struct { + ID uint `json:"id"` + Username string `json:"username"` + Nickname string `json:"nickname"` + Avatar string `json:"avatar"` + Email string `json:"email"` + Banned bool `json:"banned"` +} + +// ListModerators 列出该板块的版主(role=board_admin 且授权本板) +func (s *BoardService) ListModerators(boardID uint) ([]BoardModeratorMember, error) { + var mods []model.User + if err := s.db.Table("users"). + Select("users.id, users.username, users.nickname, users.avatar, users.email, users.banned"). + Joins("JOIN user_boards ON user_boards.user_id = users.id AND user_boards.board_id = ?", boardID). + Where("users.role = ? AND users.deleted_at IS NULL", model.RoleBoardAdmin). + Order("users.id ASC"). + Find(&mods).Error; err != nil { + return nil, err + } + out := make([]BoardModeratorMember, 0, len(mods)) + for _, u := range mods { + out = append(out, BoardModeratorMember{ + ID: u.ID, Username: u.Username, Nickname: u.Nickname, + Avatar: u.Avatar, Email: u.Email, Banned: u.Banned, + }) + } + return out, nil +} + +// AddModerator 将用户设为该板块版主: +// - 目标用户必须存在且非站长;站长身份不可被授予 +// - 若其当前非 board_admin,则升级为 board_admin 并授权本板(强制下线) +// - 若已是 board_admin,仅追加本板授权(不动角色与登录态) +// 操作者权限由 handler 通过 PermBoards 兜底(仅站长可调用) +func (s *BoardService) AddModerator(boardID, userID uint) error { + var board model.Board + if err := s.db.First(&board, boardID).Error; err != nil { + return err + } + return s.db.Transaction(func(tx *gorm.DB) error { + var u model.User + if err := tx.First(&u, userID).Error; err != nil { + return err + } + if u.Role == model.RoleOwner { + return ErrProtectedOwner + } + // 已授权则幂等返回 + var cnt int64 + if err := tx.Model(&model.UserBoard{}). + Where("user_id = ? AND board_id = ?", userID, boardID).Count(&cnt).Error; err != nil { + return err + } + if cnt > 0 { + return nil + } + // 角色升级:非 board_admin 升为 board_admin 并强制下线 + roleChanged := u.Role != model.RoleBoardAdmin + if roleChanged { + if err := tx.Model(&u).Update("role", model.RoleBoardAdmin).Error; err != nil { + return err + } + if err := invalidateUserSessions(tx, userID); err != nil { + return err + } + } + return tx.Create(&model.UserBoard{UserID: userID, BoardID: boardID}).Error + }) +} + +// RemoveModerator 取消该用户在本板的版主授权;若无其他板块授权则降级为普通用户 +func (s *BoardService) RemoveModerator(boardID, userID uint) error { + return s.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Where("user_id = ? AND board_id = ?", userID, boardID). + Delete(&model.UserBoard{}).Error; err != nil { + return err + } + var rest int64 + if err := tx.Model(&model.UserBoard{}). + Where("user_id = ?", userID).Count(&rest).Error; err != nil { + return err + } + if rest == 0 { + var u model.User + if err := tx.Select("id, role").First(&u, userID).Error; err != nil { + return err + } + if u.Role == model.RoleBoardAdmin { + if err := tx.Model(&u).Update("role", model.RoleUser).Error; err != nil { + return err + } + if err := invalidateUserSessions(tx, userID); err != nil { + return err + } + } + } + return nil + }) +} + +// viewerPostInfo 发帖/可见性判定所需的访问者信息 +type viewerPostInfo struct { + Role model.Role + BoardIDs []uint +} + +// loadViewerPostInfo 读取用户角色与其被授权的板块(用于发帖策略与可见性判定) +func (s *BoardService) loadViewerPostInfo(userID uint) (*viewerPostInfo, error) { + var u model.User + if err := s.db.Select("id, role").First(&u, userID).Error; err != nil { + return nil, err + } + info := &viewerPostInfo{Role: u.Role, BoardIDs: []uint{}} + if u.Role == model.RoleBoardAdmin { + if err := s.db.Model(&model.UserBoard{}). + Where("user_id = ?", userID).Pluck("board_id", &info.BoardIDs).Error; err != nil { + return nil, err + } + } + return info, nil +} + +// CanPostToBoard 判定 userID 是否可在 board 发帖。 +// policy=staff 时:仅 admin 及以上,或管辖该板的 board_admin 允许;否则拒绝。 +func (s *BoardService) CanPostToBoard(userID uint, board *model.Board) (bool, error) { + if board == nil || board.PostPolicy != model.BoardPostPolicyStaff { + return true, nil + } + info, err := s.loadViewerPostInfo(userID) + if err != nil { + return false, err + } + if model.RoleLevel(info.Role) >= model.RoleLevel(model.RoleAdmin) { + return true, nil + } + if info.Role == model.RoleBoardAdmin { + for _, id := range info.BoardIDs { + if id == board.ID { + return true, nil + } + } + } + return false, nil +} + +// CanViewBoardEntry 判定访问者是否可见该板块入口(列表/导航)。 +// visible=false 的板块仅 staff 可见;普通用户不可见。 +func (s *BoardService) CanViewBoardEntry(userID uint, board *model.Board) (bool, error) { + if board == nil || board.Visible { + return true, nil + } + if userID == 0 { + return false, nil + } + info, err := s.loadViewerPostInfo(userID) + if err != nil { + return false, err + } + return model.IsStaff(info.Role), nil +} + // Sidebar 首页板块右栏:管理员 + 概况统计 + 本板热门/活跃 func (s *BoardService) Sidebar(boardID uint) (*BoardSidebarData, error) { board, err := s.Get(boardID) diff --git a/backend/service/chat.go b/backend/service/chat.go index d5961eb..0c46c64 100644 --- a/backend/service/chat.go +++ b/backend/service/chat.go @@ -587,7 +587,16 @@ func (s *ChatService) InviteMembers(operatorID, roomID uint, userIDs []uint, ove func (s *ChatService) EnsureDefaultMembership(userID uint) error { var room model.ChatRoom if err := s.db.Where("is_default = ?", true).First(&room).Error; err != nil { - return nil // 尚无大厅则跳过 + if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + // 空库首次部署时启动播种因无用户跳过,这里就地补建大厅(自愈,不依赖重启) + if err := model.EnsureDefaultChatRoom(s.db); err != nil { + return err + } + if err := s.db.Where("is_default = ?", true).First(&room).Error; err != nil { + return nil // 仍无大厅(尚无任何用户)则跳过 + } } if _, err := s.membership(s.db, room.ID, userID); err == nil { return nil diff --git a/backend/service/legacyimport.go b/backend/service/legacyimport.go new file mode 100644 index 0000000..4fb5a3d --- /dev/null +++ b/backend/service/legacyimport.go @@ -0,0 +1,738 @@ +package service + +import ( + "archive/zip" + "errors" + "fmt" + "io" + "os" + "path" + "path/filepath" + "strconv" + "strings" + "time" + + htmltomarkdown "github.com/JohannesKaufmann/html-to-markdown" + "github.com/glebarez/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "github.com/freefire/jiang13-bbs/model" +) + +// 旧站(jiang13-forum,SQLite)数据导入:管理后台上传旧库 + 可选头像包, +// 导入用户账号(用户名 / bcrypt 密码 / 昵称 / 签名 / 邮箱 / 头像)与板块、帖子、评论。 +// 两站密码同为 bcrypt,哈希原样复制,老用户用原密码即可登录。 +// 支持预检(dry-run,不写库);ImportRecord 去重保证幂等可重复导入。 +// 来源参数化(source),为 WordPress / Typecho 等外部数据源预留扩展位。 + +const ( + // LegacyDBMaxBytes 旧库上传上限(实测旧站整库约 2MB,留足余量) + LegacyDBMaxBytes = 64 << 20 + // LegacyZipMaxBytes 头像压缩包上传上限 + LegacyZipMaxBytes = 128 << 20 + // legacyAvatarFileMax 单个头像解压上限 + legacyAvatarFileMax = 8 << 20 + // legacySignatureMaxRunes 新站 Signature 列上限 + legacySignatureMaxRunes = 255 + // legacyEmailMaxRunes 新站 Email 列上限 + legacyEmailMaxRunes = 128 + // legacyAvatarPrefix 旧站头像 URL 约定前缀(与旧站静态服务规范一致) + legacyAvatarPrefix = "/uploads/avatars/" + // legacyDetailLimit 报告明细条数上限,防止超长响应 + legacyDetailLimit = 50 +) + +var ( + ErrLegacyInvalidSQLite = errors.New("文件不是有效的旧站 SQLite 数据库") + ErrLegacyBadZip = errors.New("头像压缩包无法读取") +) + +// LegacyImportSource 受支持的导入来源(handler 按此做路由白名单) +func LegacyImportSource(source string) bool { + return source == "jiang13" +} + +// LegacyImportOptions 导入选项 +type LegacyImportOptions struct { + WithContent bool // 同时导入板块 / 帖子 / 评论 + DryRun bool // 预检:只统计不写库 +} + +// LegacyImportReport 导入结果摘要(JSON 返回给前端) +type LegacyImportReport struct { + DryRun bool `json:"dry_run"` + Users LegacyUserReport `json:"users"` + Boards *LegacyBoardReport `json:"boards,omitempty"` + Posts *LegacyPostReport `json:"posts,omitempty"` + Comments *LegacyCommentReport `json:"comments,omitempty"` + Notes []string `json:"notes,omitempty"` +} + +// LegacyUserReport 用户导入明细 +type LegacyUserReport struct { + Total int `json:"total"` + Imported int `json:"imported"` + Skipped int `json:"skipped"` + AvatarWritten int `json:"avatar_written"` + Conflicts []string `json:"conflicts,omitempty"` // 用户名已存在 + AvatarMissing []string `json:"avatar_missing,omitempty"` // 旧头像字段有值但包内缺失 + Failed []string `json:"failed,omitempty"` +} + +// LegacyBoardReport 板块导入明细 +type LegacyBoardReport struct { + Created int `json:"created"` // 新建(预检时为「将新建」) + Reused int `json:"reused"` // 复用同名已有板块 + Names []string `json:"names,omitempty"` // 新建板块名 +} + +// LegacyPostReport 帖子导入明细 +type LegacyPostReport struct { + Total int `json:"total"` + Imported int `json:"imported"` + Skipped int `json:"skipped"` // 已导入过(去重记录) + PollsSkipped int `json:"polls_skipped"` + PollTitles []string `json:"poll_titles,omitempty"` + OwnerFallback []string `json:"owner_fallback,omitempty"` // 作者缺失归到站长 + Failed []string `json:"failed,omitempty"` +} + +// LegacyCommentReport 评论导入明细 +type LegacyCommentReport struct { + Total int `json:"total"` + Imported int `json:"imported"` + Skipped int `json:"skipped"` + SkippedDetail []string `json:"skipped_detail,omitempty"` // 非公开评论等 + OwnerFallback []string `json:"owner_fallback,omitempty"` + Failed []string `json:"failed,omitempty"` +} + +// ===== 旧库行结构(只映射所需列) ===== + +type legacyUserRow struct { + ID uint `gorm:"primaryKey"` + Username string `gorm:"column:username"` + Email string `gorm:"column:email"` + Password string `gorm:"column:password"` + Nickname string `gorm:"column:nickname"` + Signature string `gorm:"column:signature"` + Avatar string `gorm:"column:avatar"` + Role string `gorm:"column:role"` + Banned bool `gorm:"column:banned"` + CreatedAt time.Time `gorm:"column:created_at"` + DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"` // 软删行由 GORM 自动过滤 +} + +func (legacyUserRow) TableName() string { return "users" } + +type legacyBoardRow struct { + ID uint `gorm:"primaryKey"` + Name string `gorm:"column:name"` + Description string `gorm:"column:description"` + Icon string `gorm:"column:icon"` + ColorIndex int `gorm:"column:color_index"` + SortOrder int `gorm:"column:sort_order"` + DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"` +} + +func (legacyBoardRow) TableName() string { return "boards" } + +type legacyPostRow struct { + ID uint `gorm:"primaryKey"` + BoardID uint `gorm:"column:board_id"` + UserID uint `gorm:"column:user_id"` + Title string `gorm:"column:title"` + Content string `gorm:"column:content"` + Tags string `gorm:"column:tags"` + Pinned int `gorm:"column:pinned"` + Featured bool `gorm:"column:featured"` + Status string `gorm:"column:status"` + PostType string `gorm:"column:post_type"` + LikeCount int `gorm:"column:like_count"` + ViewCount int `gorm:"column:view_count"` + CreatedAt time.Time `gorm:"column:created_at"` + UpdatedAt time.Time `gorm:"column:updated_at"` + DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"` +} + +func (legacyPostRow) TableName() string { return "posts" } + +type legacyCommentRow struct { + ID uint `gorm:"primaryKey"` + PostID uint `gorm:"column:post_id"` + UserID uint `gorm:"column:user_id"` + ReplyTo *uint `gorm:"column:reply_to"` + Content string `gorm:"column:content"` + Status string `gorm:"column:status"` + LikeCount int `gorm:"column:like_count"` + CreatedAt time.Time `gorm:"column:created_at"` + UpdatedAt time.Time `gorm:"column:updated_at"` + DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"` +} + +func (legacyCommentRow) TableName() string { return "comments" } + +// LegacyImportService 管理后台「数据导入」 +type LegacyImportService struct { + db *gorm.DB + uploadsDir string // {DataDir}/uploads,头像写入 uploads/avatars + md *htmltomarkdown.Converter +} + +func NewLegacyImportService(db *gorm.DB, uploadsDir string) *LegacyImportService { + return &LegacyImportService{ + db: db, + uploadsDir: uploadsDir, + // 空域名:相对链接保持原样;CommonMark 风格输出 + md: htmltomarkdown.NewConverter("", true, nil), + } +} + +// ImportFromFiles dbPath 为旧站 SQLite 库路径(调用方先落临时文件),zipPath 为可选头像 zip(空串表示未上传)。 +// operatorID 为执行导入的账号(站长),作者缺失的帖子/评论归属到该账号。 +func (s *LegacyImportService) ImportFromFiles(dbPath, zipPath string, opts LegacyImportOptions, operatorID uint) (*LegacyImportReport, error) { + if err := validateSQLiteMagic(dbPath); err != nil { + return nil, err + } + oldDB, err := gorm.Open(sqlite.Open("file:"+filepath.ToSlash(dbPath)+"?mode=ro"), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrLegacyInvalidSQLite, err) + } + + rep := &LegacyImportReport{DryRun: opts.DryRun} + rep.Users = s.importUsers(oldDB, zipPath, opts) + if !opts.WithContent { + return rep, nil + } + boardMap, boardRep := s.importBoards(oldDB, opts) + rep.Boards = boardRep + rep.Posts = s.importPosts(oldDB, boardMap, operatorID, opts, rep) + rep.Comments = s.importComments(oldDB, operatorID, opts, rep) + return rep, nil +} + +// ===== 用户 ===== + +func (s *LegacyImportService) importUsers(oldDB *gorm.DB, zipPath string, opts LegacyImportOptions) LegacyUserReport { + var rows []legacyUserRow + if err := oldDB.Order("id ASC").Find(&rows).Error; err != nil { + return LegacyUserReport{Failed: []string{"读取 users 表失败: " + err.Error()}} + } + + // 头像包索引:文件名 → 条目。 + // 兼容两种打包方式:文件平铺在 zip 根目录,或统一放在单个顶层文件夹下 + // (Windows 右键压缩文件夹的产物)。zip-slip 防护:只取纯文件名作键, + // 更深层嵌套与 .. 一律忽略,落盘路径永远由键拼接。 + avatars := map[string]*zip.File{} + if zipPath != "" { + zr, err := zip.OpenReader(zipPath) + if err != nil { + return LegacyUserReport{Failed: []string{ErrLegacyBadZip.Error()}} + } + defer zr.Close() + for _, f := range zr.File { + if f.FileInfo().IsDir() { + continue + } + name := strings.ReplaceAll(f.Name, "\\", "/") + parts := strings.Split(name, "/") + var key string + switch { + case len(parts) == 1: + key = parts[0] + case len(parts) == 2 && parts[0] != "" && parts[0] != "." && parts[0] != "..": + key = parts[1] + default: + continue + } + if key == "" || key == "." || key == ".." { + continue + } + if _, exists := avatars[key]; !exists { + avatars[key] = f + } + } + } + + rep := LegacyUserReport{Total: len(rows)} + avatarDir := filepath.Join(s.uploadsDir, "avatars") + for _, u := range rows { + username := strings.TrimSpace(u.Username) + if username == "" || u.Password == "" { + rep.Failed = append(rep.Failed, fmt.Sprintf("#%d (用户名或密码为空)", u.ID)) + continue + } + // 用户名列是硬唯一索引,软删行仍占用 → Unscoped 查重,存在即跳过(幂等) + var n int64 + if err := s.db.Unscoped().Model(&model.User{}).Where("username = ?", username).Count(&n).Error; err != nil { + rep.Failed = append(rep.Failed, fmt.Sprintf("%s (查重失败: %v)", username, err)) + continue + } + if n > 0 { + rep.Skipped++ + rep.Conflicts = append(rep.Conflicts, username) + continue + } + + avatarURL, wrote, err := s.resolveAvatar(u.Avatar, avatars, avatarDir, opts.DryRun) + if err != nil { + rep.Failed = append(rep.Failed, fmt.Sprintf("%s (头像写入失败: %v)", username, err)) + continue + } + if wrote { + rep.AvatarWritten++ + } + if u.Avatar != "" && avatarURL == "" { + rep.AvatarMissing = append(rep.AvatarMissing, fmt.Sprintf("%s: %s", username, u.Avatar)) + } + + if opts.DryRun { + rep.Imported++ + continue + } + user := model.User{ + Username: username, + Email: truncateRunesN(u.Email, legacyEmailMaxRunes), + Password: u.Password, // bcrypt 哈希原样复制 + Nickname: s.uniqueNickname(u.Nickname, username, u.ID), + Signature: truncateRunesN(u.Signature, legacySignatureMaxRunes), + Avatar: avatarURL, + Role: model.RoleUser, // 导入者即新站站长,旧角色一律降为普通用户 + Banned: u.Banned, + CreatedAt: u.CreatedAt, // 保留原注册时间 + } + if err := s.db.Create(&user).Error; err != nil { + rep.Failed = append(rep.Failed, fmt.Sprintf("%s (写入失败: %v)", username, err)) + continue + } + rep.Imported++ + } + return rep +} + +// uniqueNickname 旧昵称与本站已有昵称(忽略大小写;软删不占用,与唯一索引语义一致)冲突时降级: +// 旧昵称 → 用户名 → 用户名-旧ID,保证满足 LOWER(nickname) 唯一索引且不为空。 +func (s *LegacyImportService) uniqueNickname(nickname, username string, oldID uint) string { + candidates := []string{strings.TrimSpace(nickname), username, fmt.Sprintf("%s-%d", username, oldID)} + last := candidates[len(candidates)-1] + for _, cand := range candidates { + var n int64 + if err := s.db.Model(&model.User{}).Where("LOWER(nickname) = LOWER(?)", cand).Count(&n).Error; err != nil || n == 0 { + return cand // 查重失败按可用处理,Create 时仍有唯一索引兜底 + } + } + return last +} + +// resolveAvatar 从头像包解出旧头像文件到新站 avatars 目录(文件名原样保留,URL 即旧值)。 +// 返回新 Avatar 字段值(空串=无法迁移)与是否实际写入了文件。dryRun 时只判断不解压。 +func (s *LegacyImportService) resolveAvatar(oldAvatar string, pack map[string]*zip.File, avatarDir string, dryRun bool) (string, bool, error) { + if oldAvatar == "" || len(oldAvatar) <= len(legacyAvatarPrefix) { + return "", false, nil + } + name := oldAvatar[len(legacyAvatarPrefix):] + if name != path.Base(name) || strings.ContainsRune(name, '\\') { + return "", false, nil // 不符合旧站 URL 约定(如外部链接),无法迁移 + } + f, ok := pack[name] + if !ok { + return "", false, nil // 包内缺失 + } + dst := filepath.Join(avatarDir, name) + if _, err := os.Stat(dst); err == nil { + return legacyAvatarPrefix + name, false, nil // 已存在,幂等跳过 + } + if dryRun { + return legacyAvatarPrefix + name, true, nil + } + src, err := f.Open() + if err != nil { + return "", false, err + } + defer src.Close() + // 先写临时文件再 rename,避免中断留下半截文件 + out, err := os.CreateTemp(avatarDir, ".legacy-*") + if err != nil { + return "", false, err + } + tmpName := out.Name() + defer os.Remove(tmpName) // rename 成功后此处 Remove 必失败,忽略 + written, err := io.Copy(out, io.LimitReader(src, legacyAvatarFileMax+1)) + if closeErr := out.Close(); err == nil { + err = closeErr + } + if err != nil { + return "", false, err + } + if written > legacyAvatarFileMax { + return "", false, fmt.Errorf("头像文件超过 %dMB 上限", legacyAvatarFileMax>>20) + } + if err := os.Rename(tmpName, dst); err != nil { + // Windows 上目标已存在时 Rename 失败;视为已存在(幂等) + if _, statErr := os.Stat(dst); statErr == nil { + return legacyAvatarPrefix + name, false, nil + } + return "", false, err + } + return legacyAvatarPrefix + name, true, nil +} + +// ===== 板块 ===== + +// importBoards 按旧板块名自动建新板块(同名忽略大小写复用),返回 旧板块ID → 新板块ID 映射。 +func (s *LegacyImportService) importBoards(oldDB *gorm.DB, opts LegacyImportOptions) (map[uint]uint, *LegacyBoardReport) { + rep := &LegacyBoardReport{} + m := map[uint]uint{} + var rows []legacyBoardRow + if err := oldDB.Order("sort_order ASC, id ASC").Find(&rows).Error; err != nil { + return m, rep // 板块读取失败:帖子逐条按 board_id 缺失进 failed + } + for _, b := range rows { + name := strings.TrimSpace(b.Name) + if name == "" { + continue + } + var existing model.Board + if err := s.db.Where("LOWER(name) = LOWER(?)", name).First(&existing).Error; err == nil { + m[b.ID] = existing.ID + rep.Reused++ + continue + } + rep.Created++ + rep.Names = append(rep.Names, name) + if opts.DryRun { + // 预检不写库;占位映射(0 不会用于真实写入)让帖子预检仍按「将创建」计数 + m[b.ID] = 0 + continue + } + board := model.Board{ + Name: name, + Description: b.Description, + Icon: b.Icon, + ColorIndex: b.ColorIndex, + SortOrder: b.SortOrder, + } + if err := s.db.Create(&board).Error; err != nil { + continue // 创建失败:对应帖子进 failed + } + s.db.Create(&model.ImportRecord{Source: "jiang13", Kind: "board", SourceID: strconv.FormatUint(uint64(b.ID), 10), NewID: board.ID}) + m[b.ID] = board.ID + } + return m, rep +} + +// ===== 帖子 / 评论 ===== + +// mapLegacyUsers 旧用户ID → 本站用户ID。先按用户名映射(含软删本站用户), +// 找不到回退到 operatorID(站长,由调用方处理并记录明细)。 +func (s *LegacyImportService) mapLegacyUsers(oldDB *gorm.DB) map[uint]uint { + type oldRef struct { + ID uint + Username string + } + var olds []oldRef + // 含软删旧用户:其内容仍需归属。 + // oldRef 是包内匿名结构体,必须显式指定表名,否则 GORM 会推断成 old_refs + if err := oldDB.Unscoped().Table("users").Select("id", "username").Find(&olds).Error; err != nil { + return map[uint]uint{} + } + var news []model.User + // Unscoped:软删本站账号也参与映射(内容归属不变) + if err := s.db.Unscoped().Select("id", "username").Find(&news).Error; err != nil { + return map[uint]uint{} + } + byName := make(map[string]uint, len(news)) + for _, u := range news { + byName[strings.ToLower(u.Username)] = u.ID + } + m := make(map[uint]uint, len(olds)) + for _, o := range olds { + name := strings.ToLower(strings.TrimSpace(o.Username)) + if name == "" { + continue + } + if id, ok := byName[name]; ok { + m[o.ID] = id + } + } + return m +} + +func (s *LegacyImportService) importPosts(oldDB *gorm.DB, boardMap map[uint]uint, operatorID uint, opts LegacyImportOptions, rep *LegacyImportReport) *LegacyPostReport { + out := &LegacyPostReport{} + if boardMap == nil || len(boardMap) == 0 { + out.Failed = append(out.Failed, "无可用板块映射,帖子未导入") + return out + } + var rows []legacyPostRow + if err := oldDB.Order("id ASC").Find(&rows).Error; err != nil { + out.Failed = append(out.Failed, "读取 posts 表失败: "+err.Error()) + return out + } + out.Total = len(rows) + + userMap := s.mapLegacyUsers(oldDB) + missingAuthors := map[uint]bool{} // 旧用户ID → 已记录过 fallback + convFailed := 0 + + for _, p := range rows { + title := strings.TrimSpace(p.Title) + if title == "" { + out.Failed = append(out.Failed, fmt.Sprintf("#%d (标题为空)", p.ID)) + continue + } + if p.PostType == "poll" { + out.PollsSkipped++ + out.PollTitles = append(out.PollTitles, title) + continue + } + if p.Status != "published" { + out.Skipped++ + out.Failed = append(out.Failed, fmt.Sprintf("%s (旧状态 %s,跳过)", title, p.Status)) + continue + } + boardID, ok := boardMap[p.BoardID] + if !ok { + out.Failed = append(out.Failed, fmt.Sprintf("%s (旧板块 #%d 无法映射)", title, p.BoardID)) + continue + } + // 幂等:去重记录已存在即跳过 + if !opts.DryRun { + var n int64 + s.db.Model(&model.ImportRecord{}).Where("source = ? AND kind = ? AND source_id = ?", "jiang13", "post", strconv.FormatUint(uint64(p.ID), 10)).Count(&n) + if n > 0 { + out.Skipped++ + continue + } + } + + // 作者映射,缺失归站长 + authorID := operatorID + if id, ok := userMap[p.UserID]; ok { + authorID = id + } else if !missingAuthors[p.UserID] { + missingAuthors[p.UserID] = true + out.OwnerFallback = append(out.OwnerFallback, fmt.Sprintf("#%d 的部分内容(作者未找到,归到站长)", p.UserID)) + } + + // HTML → Markdown;失败保留原文(正文不丢) + content := p.Content + if strings.Contains(strings.ToLower(content), "<") { + if md, err := s.md.ConvertString(content); err == nil && strings.TrimSpace(md) != "" { + content = md + } else { + convFailed++ + } + } + + if opts.DryRun { + out.Imported++ + continue + } + post := model.Post{ + BoardID: boardID, + UserID: authorID, + Title: truncateRunesN(title, 256), + Content: content, + Tags: p.Tags, + PostType: model.NormalizePostType(p.PostType), // question→question,normal→discussion + Pinned: p.Pinned, + Recommended: p.Featured, + Status: model.ContentStatusPublished, + LikeCount: p.LikeCount, + ViewCount: p.ViewCount, + CreatedAt: p.CreatedAt, + UpdatedAt: p.UpdatedAt, + } + if err := s.db.Create(&post).Error; err != nil { + out.Failed = append(out.Failed, fmt.Sprintf("%s (写入失败: %v)", title, err)) + continue + } + s.db.Create(&model.ImportRecord{Source: "jiang13", Kind: "post", SourceID: strconv.FormatUint(uint64(p.ID), 10), NewID: post.ID}) + out.Imported++ + } + if convFailed > 0 { + rep.Notes = append(rep.Notes, fmt.Sprintf("%d 条内容 HTML 转 Markdown 失败,已保留原始内容", convFailed)) + } + out.OwnerFallback = capList(out.OwnerFallback) + out.Failed = capList(out.Failed) + out.PollTitles = capList(out.PollTitles) + return out +} + +func (s *LegacyImportService) importComments(oldDB *gorm.DB, operatorID uint, opts LegacyImportOptions, rep *LegacyImportReport) *LegacyCommentReport { + out := &LegacyCommentReport{} + var rows []legacyCommentRow + if err := oldDB.Order("post_id ASC, id ASC").Find(&rows).Error; err != nil { + out.Failed = append(out.Failed, "读取 comments 表失败: "+err.Error()) + return out + } + out.Total = len(rows) + + var posts []legacyPostRow + if err := oldDB.Unscoped().Select("id", "title").Find(&posts).Error; err != nil { + posts = nil + } + postTitles := map[uint]string{} + for _, p := range posts { + postTitles[p.ID] = p.Title + } + + userMap := s.mapLegacyUsers(oldDB) + missingAuthors := map[uint]bool{} + convFailed := 0 + + for _, cm := range rows { + if cm.Status != "published" { + out.Skipped++ + out.SkippedDetail = append(out.SkippedDetail, fmt.Sprintf("《%s》#%d (旧状态 %s)", postTitles[cm.PostID], cm.ID, cm.Status)) + continue + } + // 幂等:去重记录已存在即跳过 + if !opts.DryRun { + var n int64 + s.db.Model(&model.ImportRecord{}).Where("source = ? AND kind = ? AND source_id = ?", "jiang13", "comment", strconv.FormatUint(uint64(cm.ID), 10)).Count(&n) + if n > 0 { + out.Skipped++ + continue + } + // 所属帖子必须已导入(否则评论无落点) + var pn int64 + s.db.Model(&model.ImportRecord{}).Where("source = ? AND kind = ? AND source_id = ?", "jiang13", "post", strconv.FormatUint(uint64(cm.PostID), 10)).Count(&pn) + if pn == 0 { + out.Failed = append(out.Failed, fmt.Sprintf("《%s》#%d (所属帖子未导入)", postTitles[cm.PostID], cm.ID)) + continue + } + } + + authorID := operatorID + if id, ok := userMap[cm.UserID]; ok { + authorID = id + } else if !missingAuthors[cm.UserID] { + missingAuthors[cm.UserID] = true + out.OwnerFallback = append(out.OwnerFallback, fmt.Sprintf("#%d 的部分评论(作者未找到,归到站长)", cm.UserID)) + } + + // HTML → Markdown;失败保留原文(内容不丢) + content := cm.Content + if strings.Contains(strings.ToLower(content), "<") { + if md, err := s.md.ConvertString(content); err == nil && strings.TrimSpace(md) != "" { + content = md + } else { + convFailed++ + } + } + + if opts.DryRun { + out.Imported++ + continue + } + if err := s.importCommentOne(cm, authorID, content); err != nil { + out.Failed = append(out.Failed, fmt.Sprintf("《%s》#%d (写入失败: %v)", postTitles[cm.PostID], cm.ID, err)) + continue + } + out.Imported++ + } + if convFailed > 0 { + rep.Notes = append(rep.Notes, fmt.Sprintf("%d 条评论 HTML 转 Markdown 失败,已保留原始内容", convFailed)) + } + out.SkippedDetail = capList(out.SkippedDetail) + out.OwnerFallback = capList(out.OwnerFallback) + out.Failed = capList(out.Failed) + return out +} + +// importCommentOne 写入单条评论并处理回复链挂接与去重记录。 +// 源 ID 顺序保证父评论先于子评论处理;reply_to 指向的父评论通过去重记录取回新 ID。 +func (s *LegacyImportService) importCommentOne(cm legacyCommentRow, authorID uint, content string) error { + // 源帖子 → 新帖子 ID + var postRec model.ImportRecord + if err := s.db.Where("source = ? AND kind = ? AND source_id = ?", "jiang13", "post", strconv.FormatUint(uint64(cm.PostID), 10)).First(&postRec).Error; err != nil { + return errors.New("找不到源帖子记录") + } + + comment := model.Comment{ + PostID: postRec.NewID, + UserID: authorID, + Content: content, + Status: model.ContentStatusPublished, + CreatedAt: cm.CreatedAt, + UpdatedAt: cm.UpdatedAt, + } + + // 回复挂接:reply_to 指向同帖已有评论;找不到父评论(被删/跨帖)→ 落为主评论 + if cm.ReplyTo != nil && *cm.ReplyTo > 0 { + var parent model.Comment + err := s.db.Where("comments.post_id = ?", postRec.NewID). + Joins("JOIN import_records ir ON ir.kind = 'comment' AND ir.new_id = comments.id"). + Where("ir.source = ? AND ir.source_id = ?", "jiang13", strconv.FormatUint(uint64(*cm.ReplyTo), 10)). + First(&parent).Error + if err == nil { + comment.ParentID = &parent.ID + comment.Depth = parent.Depth + 1 + if parent.RootID != nil { + root := *parent.RootID + comment.RootID = &root + } else { + comment.RootID = &parent.ID + } + } + } + + // 评论 / 计数 / 去重记录同一事务,避免重试导致重复评论 + return s.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Create(&comment).Error; err != nil { + return err + } + // 帖子评论计数 +1(UpdateColumn 不触碰 updated_at) + if err := tx.Model(&model.Post{}).Where("id = ?", postRec.NewID). + UpdateColumn("comment_count", gorm.Expr("comment_count + 1")).Error; err != nil { + return err + } + return tx.Create(&model.ImportRecord{ + Source: "jiang13", Kind: "comment", + SourceID: strconv.FormatUint(uint64(cm.ID), 10), NewID: comment.ID, + }).Error + }) +} + +// ===== 工具 ===== + +// capList 明细条数截断,超出部分以「…等 N 条」收尾 +func capList(list []string) []string { + if len(list) <= legacyDetailLimit { + return list + } + out := append([]string{}, list[:legacyDetailLimit]...) + out = append(out, fmt.Sprintf("…等共 %d 条", len(list))) + return out +} + +// validateSQLiteMagic 校验文件以 SQLite 3 魔数开头,避免把任意文件喂给解析器 +func validateSQLiteMagic(p string) error { + f, err := os.Open(p) + if err != nil { + return err + } + defer f.Close() + magic := make([]byte, 16) + if _, err := io.ReadFull(f, magic); err != nil { + return ErrLegacyInvalidSQLite + } + if string(magic) != "SQLite format 3\x00" { + return ErrLegacyInvalidSQLite + } + return nil +} + +// truncateRunesN 按 rune 数截断字符串到 max +func truncateRunesN(s string, max int) string { + r := []rune(s) + if len(r) <= max { + return s + } + return string(r[:max]) +} diff --git a/backend/service/operations.go b/backend/service/operations.go index a6a2dac..c044b94 100644 --- a/backend/service/operations.go +++ b/backend/service/operations.go @@ -577,3 +577,16 @@ func randomID() string { } return base64.RawURLEncoding.EncodeToString(b) } + +// randomCode 生成 6 位数字邮箱验证码,供用户阅读输入。 +func randomCode() string { + const n = 6 + b := make([]byte, n) + if _, e := io.ReadFull(rand.Reader, b); e != nil { + panic(e) + } + for i := range b { + b[i] = '0' + b[i]%10 + } + return string(b) +} diff --git a/backend/service/operations_filter.go b/backend/service/operations_filter.go index 6cd6672..c912b55 100644 --- a/backend/service/operations_filter.go +++ b/backend/service/operations_filter.go @@ -3,6 +3,7 @@ package service import ( "encoding/json" "errors" + "github.com/freefire/jiang13-bbs/model" "github.com/yuin/goldmark" "github.com/yuin/goldmark/ast" "github.com/yuin/goldmark/text" @@ -125,6 +126,13 @@ func MatchFilter(c FilterConfig, scope, input string) FilterResult { return result } func (o *Operations) Filter(scope, input string, actor uint) error { + if actor > 0 { + var role model.Role + if err := o.db.Model(&model.User{}).Where("id = ?", actor). + Select("role").Scan(&role).Error; err == nil && model.IsStaff(role) { + return nil // 管理角色不受内容过滤限制 + } + } v, _, e := o.read(o.db, "filter") if e != nil { return errors.New("内容过滤暂不可用,请稍后重试") diff --git a/backend/service/operations_mail.go b/backend/service/operations_mail.go index 6510315..eb60f31 100644 --- a/backend/service/operations_mail.go +++ b/backend/service/operations_mail.go @@ -392,7 +392,7 @@ func (o *Operations) SendCode(email, purpose, ip string) (int, error) { return 0, nil } } - code := randomID() + code := randomCode() hash := counterKey(email + ":" + purpose + ":" + code) e = o.db.Transaction(func(tx *gorm.DB) error { if e := tx.Create(&model.EmailChallenge{Hash: hash, Email: email, Purpose: purpose, ExpiresAt: time.Now().Add(15 * time.Minute)}).Error; e != nil { @@ -402,17 +402,60 @@ func (o *Operations) SendCode(email, purpose, ip string) (int, error) { }) return 0, e } +const ( + codeVerifyMaxAttempts = 5 + codeVerifyWindow = 15 * 60 // 秒,与验证码有效期一致 +) + +// CodeThrottleError 表示验证码校验失败次数超限,携带需等待的秒数。 +type CodeThrottleError struct{ Wait int } + +func (e *CodeThrottleError) Error() string { + return fmt.Sprintf("验证码错误次数过多,请 %d 秒后重试", e.Wait) +} + func (o *Operations) ConsumeCode(email, purpose, code string) error { - r := o.db.Model(&model.EmailChallenge{}).Where("hash = ? AND used = false AND expires_at > ?", counterKey(strings.ToLower(strings.TrimSpace(email))+":"+purpose+":"+code), time.Now()).Update("used", true) + email = strings.ToLower(strings.TrimSpace(email)) + failKey := "verifyfail:" + purpose + ":" + email + + // 失败次数限流:窗口内错误次数超限则拒绝,防止 6 位数字码被暴力枚举。 + if wait, e := o.verifyFailWait(failKey); e != nil { + return e + } else if wait > 0 { + return &CodeThrottleError{Wait: wait} + } + + r := o.db.Model(&model.EmailChallenge{}).Where("hash = ? AND used = false AND expires_at > ?", counterKey(email+":"+purpose+":"+code), time.Now()).Update("used", true) if r.Error != nil { return r.Error } if r.RowsAffected != 1 { + if _, e := o.Quota(failKey, codeVerifyMaxAttempts, codeVerifyWindow); e != nil { + return e + } return fmt.Errorf("验证码无效或已过期") } + // 校验成功,清除失败计数。 + o.db.Delete(&model.ActionCounter{}, "key = ?", counterKey(failKey)) return nil } +// verifyFailWait 读取当前失败计数,若已达上限返回需等待的秒数,不修改计数。 +func (o *Operations) verifyFailWait(key string) (int, error) { + var c model.ActionCounter + e := o.db.First(&c, "key = ?", counterKey(key)).Error + if e != nil { + if errors.Is(e, gorm.ErrRecordNotFound) { + return 0, nil + } + return 0, e + } + if time.Now().Before(c.ExpiresAt) && c.Count >= codeVerifyMaxAttempts { + return int(time.Until(c.ExpiresAt).Seconds()) + 1, nil + } + return 0, nil +} + // Probe merged drafts so partial API updates cannot bypass activation validation. func (o *Operations) ProbeBeforeSave(ctx context.Context, name string, raw json.RawMessage, clear []string) error { v, _, _, e := o.draft(o.db, name, raw, clear) diff --git a/backend/service/post.go b/backend/service/post.go index 7c0097d..179df91 100644 --- a/backend/service/post.go +++ b/backend/service/post.go @@ -23,6 +23,7 @@ var ( type PostService struct { db *gorm.DB setting *SettingService + board *BoardService devMode bool } @@ -36,6 +37,12 @@ func (s *PostService) WithSetting(setting *SettingService) *PostService { return s } +// WithBoard 注入板块服务(发帖策略校验用);可链式调用 +func (s *PostService) WithBoard(board *BoardService) *PostService { + s.board = board + return s +} + // WithDevMode 开发态:抽奖同 IP 去重对回环地址放宽 func (s *PostService) WithDevMode(dev bool) *PostService { s.devMode = dev @@ -744,35 +751,37 @@ func (s *PostService) Create(in CreatePostInput) (*PostDetail, error) { typeMeta, _ = encodeMeta(qm) } - // 悬赏:创建时托管积分 + 防刷(未结算上限 / 每日上限) + // 悬赏:创建时托管积分 + 防刷(未结算上限 / 每日上限);管理角色豁免防刷 var bountyPts int if postType == model.PostTypeBounty { bm, _ := parseBountyMeta(typeMeta) bountyPts = bm.Points - var bountyPosts []model.Post - s.db.Select("type_meta"). - Where("user_id = ? AND post_type = ? AND deleted_at IS NULL", in.UserID, model.PostTypeBounty). - Find(&bountyPosts) - open := 0 - for _, bp := range bountyPosts { - m, err := parseBountyMeta(bp.TypeMeta) - if err != nil { - continue + if !s.isStaffUser(in.UserID) { + var bountyPosts []model.Post + s.db.Select("type_meta"). + Where("user_id = ? AND post_type = ? AND deleted_at IS NULL", in.UserID, model.PostTypeBounty). + Find(&bountyPosts) + open := 0 + for _, bp := range bountyPosts { + m, err := parseBountyMeta(bp.TypeMeta) + if err != nil { + continue + } + if m.Escrowed && m.AcceptedCommentID == 0 && !m.Refunded && !m.Expired { + open++ + } } - if m.Escrowed && m.AcceptedCommentID == 0 && !m.Refunded && !m.Expired { - open++ + if open >= 3 { + return nil, errors.New("未结算悬赏最多同时 3 个,请先采纳或退回") + } + var todayN int64 + s.db.Model(&model.Post{}). + Where("user_id = ? AND post_type = ? AND created_at >= CURRENT_DATE AND deleted_at IS NULL", + in.UserID, model.PostTypeBounty). + Count(&todayN) + if todayN >= 5 { + return nil, errors.New("今日悬赏发帖已达上限(5)") } - } - if open >= 3 { - return nil, errors.New("未结算悬赏最多同时 3 个,请先采纳或退回") - } - var todayN int64 - s.db.Model(&model.Post{}). - Where("user_id = ? AND post_type = ? AND created_at >= CURRENT_DATE AND deleted_at IS NULL", - in.UserID, model.PostTypeBounty). - Count(&todayN) - if todayN >= 5 { - return nil, errors.New("今日悬赏发帖已达上限(5)") } bm.Escrowed = true bm.Refunded = false @@ -1051,7 +1060,7 @@ func (s *PostService) EnsurePostVisible(postID, viewerID uint, loadActor func() return nil } -// checkNewUserCooldown 新用户发帖冷静期(小时数取自站点设置,0=关闭) +// checkNewUserCooldown 新用户发帖冷静期(小时数取自站点设置,0=关闭);管理角色豁免 func (s *PostService) checkNewUserCooldown(userID uint) error { hours := DefaultCooldownHours if s.setting != nil { @@ -1066,12 +1075,25 @@ func (s *PostService) checkNewUserCooldown(userID uint) error { if err := s.db.First(&user, userID).Error; err != nil { return err } + if model.IsStaff(user.Role) { // 管理角色不受发帖冷静期限制 + return nil + } if time.Since(user.CreatedAt) < time.Duration(hours)*time.Hour { return fmt.Errorf("新用户注册 %d 小时后才能发帖", hours) } return nil } +// isStaffUser 作者是否管理角色(悬赏防刷等限制豁免用) +func (s *PostService) isStaffUser(userID uint) bool { + var role model.Role + if err := s.db.Model(&model.User{}).Where("id = ?", userID). + Select("role").Scan(&role).Error; err != nil { + return false + } + return model.IsStaff(role) +} + // DeletePostOpts 删帖选项:staff 软删必填类型与理由;作者自删传零值 type DeletePostOpts struct { DeleteType string diff --git a/backend/service/post_interact.go b/backend/service/post_interact.go index 70f3d82..aa9f191 100644 --- a/backend/service/post_interact.go +++ b/backend/service/post_interact.go @@ -828,7 +828,11 @@ func (s *PostService) lotteryEligibleIDs(postID, authorID uint) ([]uint, error) Group("c.user_id") if hours > 0 { cutoff := time.Now().UTC().Add(-time.Duration(hours) * time.Hour) - q = q.Where("u.created_at <= ?", cutoff) + // 管理角色不受新号冷静期限制 + q = q.Where("u.created_at <= ? OR u.role IN ?", cutoff, []string{ + string(model.RoleBoardAdmin), string(model.RoleAdmin), + string(model.RoleSuperAdmin), string(model.RoleOwner), + }) } var ids []uint if err := q.Pluck("c.user_id", &ids).Error; err != nil { diff --git a/backend/service/setting.go b/backend/service/setting.go index 53fa5c3..810ec11 100644 --- a/backend/service/setting.go +++ b/backend/service/setting.go @@ -17,6 +17,8 @@ const ( SettingKeyAccent = "theme_accent" // SettingKeyTrustReviewedPublish 已过审用户后续发帖/评论免审;缺行视为开启(默认开) SettingKeyTrustReviewedPublish = "trust_reviewed_publish" + // SettingKeyPublishWithoutReview 全站免审:所有人发帖/评论直接公开,无需过审记录;缺行视为关闭(默认关) + SettingKeyPublishWithoutReview = "publish_without_review" SettingKeySiteName = "site_name" SettingKeySiteDescription = "site_description" SettingKeyAllowRegister = "allow_register" @@ -99,6 +101,7 @@ var accentHexRe = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) type PublicSiteSettings struct { Accent string `json:"accent"` TrustReviewedPublish bool `json:"trust_reviewed_publish"` + PublishWithoutReview bool `json:"publish_without_review"` SiteName string `json:"site_name"` SiteDescription string `json:"site_description"` AllowRegister bool `json:"allow_register"` @@ -210,6 +213,7 @@ func (s *SettingService) Public() (PublicSiteSettings, error) { SiteName: DefaultSiteName, SiteDescription: DefaultSiteDescription, TrustReviewedPublish: true, + PublishWithoutReview: false, AllowRegister: true, AllowComments: true, CommentsRequireLogin: false, @@ -247,6 +251,12 @@ func (s *SettingService) Public() (PublicSiteSettings, error) { } out.TrustReviewedPublish = trust + withoutReview, err := s.PublishWithoutReview() + if err != nil { + return out, err + } + out.PublishWithoutReview = withoutReview + name, found, err := s.getValue(SettingKeySiteName) if err != nil { return out, err @@ -429,6 +439,20 @@ func (s *SettingService) SetTrustReviewedPublish(on bool) error { return s.setBoolDefaultTrue(SettingKeyTrustReviewedPublish, on) } +// PublishWithoutReview 全站免审:所有人发布内容直接公开(优先于过审免审)。缺行视为关闭。 +func (s *SettingService) PublishWithoutReview() (bool, error) { + v, found, err := s.getValue(SettingKeyPublishWithoutReview) + if err != nil { + return false, err + } + return parseBoolDefaultFalse(v, found), nil +} + +// SetPublishWithoutReview 持久化全站免审开关;关闭时删键保持「缺行=默认关」。 +func (s *SettingService) SetPublishWithoutReview(on bool) error { + return s.setBoolDefaultFalse(SettingKeyPublishWithoutReview, on) +} + func (s *SettingService) SetSiteName(name string) error { name = strings.TrimSpace(name) if name == "" || name == DefaultSiteName { diff --git a/backend/service/staff_exempt_test.go b/backend/service/staff_exempt_test.go new file mode 100644 index 0000000..6361819 --- /dev/null +++ b/backend/service/staff_exempt_test.go @@ -0,0 +1,146 @@ +package service + +import ( + "encoding/json" + "fmt" + "net/url" + "os" + "testing" + "time" + + "github.com/freefire/jiang13-bbs/config" + "github.com/freefire/jiang13-bbs/model" + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +// staffTestDB 独立 schema 隔离的测试库(同 testOps 模式;未配置 DSN 则跳过) +func staffTestDB(t *testing.T) *gorm.DB { + t.Helper() + dsn := os.Getenv("OPS_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("set OPS_TEST_DATABASE_URL to an isolated PostgreSQL database") + } + u, e := url.Parse(dsn) + if e != nil { + t.Fatal(e) + } + db, e := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + if e != nil { + t.Fatal(e) + } + schema := "staff_exempt_" + fmt.Sprint(time.Now().UnixNano()) + if e = db.Exec("CREATE SCHEMA " + schema).Error; e != nil { + t.Fatal(e) + } + q := u.Query() + q.Set("search_path", schema) + u.RawQuery = q.Encode() + scoped, e := gorm.Open(postgres.Open(u.String()), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + if e != nil { + t.Fatal(e) + } + t.Cleanup(func() { + sql, _ := scoped.DB() + _ = sql.Close() + db.Exec("DROP SCHEMA " + schema + " CASCADE") + sql, _ = db.DB() + _ = sql.Close() + }) + if e = scoped.AutoMigrate(&model.User{}, &model.Board{}, &model.Post{}, &model.Comment{}, &model.SiteSetting{}, + &model.PostLotteryEntry{}, &model.ModuleConfig{}, &model.SettingsAudit{}); e != nil { + t.Fatal(e) + } + return scoped +} + +func mustUser(t *testing.T, db *gorm.DB, username string, role model.Role, createdAt time.Time) model.User { + t.Helper() + u := model.User{Username: username, Password: "x", Role: role, CreatedAt: createdAt, UpdatedAt: createdAt} + if err := db.Create(&u).Error; err != nil { + t.Fatal(err) + } + return u +} + +func TestCheckNewUserCooldownStaffExempt(t *testing.T) { + db := staffTestDB(t) + now := time.Now().UTC() + plain := mustUser(t, db, "plain", model.RoleUser, now) + owner := mustUser(t, db, "owner", model.RoleOwner, now) + badmin := mustUser(t, db, "badmin", model.RoleBoardAdmin, now) + + ps := NewPostService(db).WithSetting(NewSettingService(db)) + if err := ps.checkNewUserCooldown(plain.ID); err == nil { + t.Fatal("new plain user should be blocked by cooldown") + } + for _, u := range []model.User{owner, badmin} { + if err := ps.checkNewUserCooldown(u.ID); err != nil { + t.Fatalf("staff role %s should bypass cooldown: %v", u.Role, err) + } + } +} + +func TestFilterStaffExempt(t *testing.T) { + db := staffTestDB(t) + b, _ := json.Marshal(FilterConfig{Enabled: true, Rules: []FilterRule{{ + ID: "r1", Word: "bad", Scopes: []string{"body"}, Action: "block", Enabled: true, + }}}) + if err := db.Create(&model.ModuleConfig{Name: "filter", Version: 1, Data: string(b)}).Error; err != nil { + t.Fatal(err) + } + plain := mustUser(t, db, "plain", model.RoleUser, time.Now().UTC()) + owner := mustUser(t, db, "owner", model.RoleOwner, time.Now().UTC()) + + o := NewOperations(db, &config.Config{DevMode: true}) + if err := o.Filter("body", "a bad word", plain.ID); err == nil { + t.Fatal("plain user should be blocked by filter") + } + if err := o.Filter("body", "a bad word", owner.ID); err != nil { + t.Fatalf("owner should bypass filter: %v", err) + } + if err := o.Filter("body", "a bad word", 0); err == nil { + t.Fatal("anonymous should be blocked by filter") + } +} + +func TestLotteryEligibleStaffBypassesCooldown(t *testing.T) { + db := staffTestDB(t) + now := time.Now().UTC() + author := mustUser(t, db, "author", model.RoleUser, now) + plain := mustUser(t, db, "plain", model.RoleUser, now) + owner := mustUser(t, db, "owner", model.RoleOwner, now) + + board := model.Board{Name: "test"} + if err := db.Create(&board).Error; err != nil { + t.Fatal(err) + } + post := model.Post{BoardID: board.ID, UserID: author.ID, Title: "lottery", Content: "draw", + PostType: model.PostTypeLottery, Status: model.ContentStatusPublished} + if err := db.Create(&post).Error; err != nil { + t.Fatal(err) + } + for _, uid := range []uint{plain.ID, owner.ID} { + c := model.Comment{PostID: post.ID, UserID: uid, Content: "join", Status: model.ContentStatusPublished} + if err := db.Create(&c).Error; err != nil { + t.Fatal(err) + } + } + + ps := NewPostService(db).WithSetting(NewSettingService(db)) + ids, err := ps.lotteryEligibleIDs(post.ID, author.ID) + if err != nil { + t.Fatal(err) + } + got := map[uint]bool{} + for _, id := range ids { + got[id] = true + } + if got[plain.ID] { + t.Fatal("new plain user should be excluded from lottery pool") + } + if !got[owner.ID] { + t.Fatal("staff new account should stay in lottery pool") + } +} diff --git a/docs/about.md b/docs/about.md new file mode 100644 index 0000000..753cc43 --- /dev/null +++ b/docs/about.md @@ -0,0 +1,66 @@ +# 姜十三论坛 介绍 + +## 这是什么 + +**姜十三论坛(jiang13-bbs)** 是一个现代化的社区论坛系统,致力于提供流畅、安全、高质量的在线讨论体验。它面向需要自建社区的个人与团队,开箱即用,同时具备完整的内容、互动、通知与管理能力。 + +## 核心特性 + +### 📝 内容创作与讨论 +- **发帖 / 编辑 / 删除**:支持普通帖与问答帖两种类型,作者与管理员均可管理。 +- **Markdown 渲染**:完整支持 Markdown 语法,包含代码高亮、引用、列表等,技术内容排版美观。 +- **楼层化评论**:主评论占楼层、子评论不占楼层,最多 10 层嵌套,讨论层次分明。 +- **点赞互动**:帖子点赞带防重复机制,让优质内容被看见。 +- **置顶与推荐**:管理员可对帖子进行置顶、推荐,引导社区焦点。 + +### 🔔 实时通知 +- 评论、回复、点赞等互动实时推送,未读红点随页面首屏直出。 +- 基于 WebSocket 的实时通道,回前台自动校准,消息不遗漏。 +- 支持单条 / 全部标记已读。 + +### 👤 会员体系 +- 注册即登录,免二次跳转。 +- 个人资料:昵称、头像、邮箱、个性签名自由编辑。 +- 每日签到获取积分,连续签到有加成。 +- 个人主页聚合你的发帖、评论与签到记录。 + +### 🛠️ 管理后台 +- 仪表盘总览社区数据。 +- 公告管理:支持草稿与发布,标签预设色,重要信息精准触达。 +- 用户、内容、设置等全方位管理能力。 + +### 🔍 SEO 友好 +- 全站 SSR / ISR,搜索引擎可见的关键内容都在初始 HTML 中。 +- 帖子详情输出结构化数据(JSON-LD),利于搜索收录与展示。 +- 自动生成 sitemap 与 robots。 + +### 🔒 安全可靠 +- HttpOnly Cookie + Refresh Token 一次性轮转认证,令牌不落地浏览器存储。 +- CSRF 双提交防护、速率限制(登录 / 注册 / 发帖冷静期)。 +- 完备的安全响应头(CSP / HSTS 等)。 +- 生产环境强制 HTTPS,使用 `__Host-` 前缀 Cookie。 + +## 技术架构 + +| 层级 | 技术选型 | +|------|----------| +| 后端 | Go 1.27 · Gin · GORM · PostgreSQL 17 | +| 前端 | Next.js 16 (App Router) · TypeScript · Tailwind CSS v4 | +| 认证 | JWT(HttpOnly Cookie)+ Refresh Token 轮转 + CSRF 双提交 + Token Version 撤销 | +| 实时 | WebSocket | +| 部署 | Docker(Next SSR + Go + Postgres,反代自备) | + +系统采用前后端分离架构,前端负责服务端渲染与交互,后端负责业务逻辑与数据,二者职责清晰、可独立伸缩。 + +## 适用场景 + +- 个人 / 团队自建社区、技术论坛 +- 产品用户交流与反馈社区 +- 兴趣小组、垂直领域讨论站 +- 需要高质量 SEO 与安全性的内容社区 + +## 许可与联系方式 + +本项目为**专有软件**,源码可见但不构成开源授权,未经书面许可不得使用、部署或分发。 + +联系方式:aarbbs@88.com diff --git a/docs/announcement.md b/docs/announcement.md new file mode 100644 index 0000000..78bf37f --- /dev/null +++ b/docs/announcement.md @@ -0,0 +1,37 @@ +# 姜十三论坛正式上线公告 + +各位社区成员: + +经过一段时间的开发与打磨,**姜十三论坛**今天正式与大家见面了!🎉 + +这里是一个全新的社区交流空间,我们希望它能成为一个内容优质、氛围友好、体验流畅的讨论家园。无论你是来分享技术、交流想法,还是结识同好,都欢迎在这里扎根。 + +## 本站亮点 + +- **极致的阅读体验**:全站服务端渲染(SSR),首屏秒开,关键内容对搜索引擎友好。 +- **Markdown 原生支持**:发帖、评论全面支持 Markdown,代码高亮、引用、列表一应俱全。 +- **楼层化评论**:主评论按楼层排列,子评论最多嵌套 10 层,讨论结构清晰不混乱。 +- **实时通知**:评论、回复、点赞第一时间通过站内通知提醒你,不错过任何互动。 +- **每日签到**:每天签到领积分,连续签到还有额外加成,记录你与社区的陪伴。 +- **个人主页**:你的发帖、评论、签到积分一目了然,打造属于你的社区名片。 +- **问答帖子**:遇到问题可以发问答帖,获得解答后标记「采纳」,让好答案沉淀下来。 + +## 给新成员的几句话 + +1. **第一个注册的账号将自动成为站长**,欢迎你接手管理、建设这个社区。 +2. 发帖前建议先浏览各板块,遵守社区氛围,友善讨论、理性表达。 +3. 遇到任何问题或有建议,欢迎在反馈板块留言,或直接联系管理员。 + +## 关于安全与隐私 + +- 全站采用 HttpOnly Cookie + Refresh Token 轮转认证,不把令牌存进浏览器本地存储,更安全。 +- 密码修改、账号异常等操作会及时让旧登录失效,保护你的账号安全。 +- 生产环境强制 HTTPS,登录凭据加密传输。 + +--- + +社区的好坏,取决于每一位成员。期待与你一起,把姜十三论坛建设成一个有价值、有温度的地方。 + +如有问题或建议,可通过邮件联系:aarbbs@88.com + +—— 姜十三论坛 团队 diff --git a/docs/deploy-1panel.md b/docs/deploy-1panel.md new file mode 100644 index 0000000..98307e3 --- /dev/null +++ b/docs/deploy-1panel.md @@ -0,0 +1,183 @@ +# 姜十三论坛(jiang13-bbs)Docker 部署教程 + +> 前置条件:应用镜像(`jiang13-api`、`jiang13-web`)已推送到你的私有镜像仓库。本教程只讲如何在服务器上用 1Panel 部署、反代与运维。 +> +> 项目架构:Next.js 前端(web)+ Go 后端(api)+ PostgreSQL 17,三者为独立容器。 + +--- + +## 一、镜像说明 + +| 服务 | 镜像 | 来源 | +|---|---|---| +| api | `<你的用户名>/jiang13-api` | 你的私有仓库 | +| web | `<你的用户名>/jiang13-web` | 你的私有仓库 | +| postgres | `postgres:17-alpine` | Docker Hub 官方公共镜像,直接拉取 | + +> 安全说明:api / web 镜像为多阶段构建产物,**最终镜像不含源码**,密钥运行时注入,不烤进镜像。部署前请确认这两个仓库在 Docker Hub 上已设为 **Private**。 + +--- + +## 二、1Panel 部署 + +### 1. 配置私有仓库认证(关键) +私有镜像必须先认证,否则拉取报 `denied`。 + +1Panel 左侧 **容器 → 「仓库」标签 → 创建**: +- 仓库地址:`docker.io` +- 用户名:你的 Docker Hub 用户名 +- 密码:Docker Hub 密码或 Access Token(推荐 Token) + +### 2. 用编排一键部署 +**容器 → 「编排」标签 → 创建编排**,名称 `jiang13`,粘贴以下内容: + +```yaml +name: jiang13 + +services: + postgres: + image: postgres:17-alpine + restart: unless-stopped + environment: + POSTGRES_USER: jiang13 + POSTGRES_PASSWORD: 改成你的强密码 + POSTGRES_DB: jiang13 + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U jiang13 -d jiang13"] + interval: 5s + timeout: 5s + retries: 10 + + api: + image: <你的用户名>/jiang13-api:0.1.0 + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + environment: + HTTP_PORT: "3001" + DEV_MODE: "false" + JIANG13_WORK_PATH: /var/lib/jiang13 + DATA_DIR: /var/lib/jiang13/data + DB_DSN: postgres://jiang13:改成上面同一个密码@postgres:5432/jiang13?sslmode=disable + JWT_SECRET: "" + SITE_URL: https://改成你的域名 + SETTINGS_MASTER_KEY: "" + volumes: + - appdata:/var/lib/jiang13/data + + web: + image: <你的用户名>/jiang13-web:0.1.0 + restart: unless-stopped + depends_on: + api: + condition: service_healthy + environment: + BACKEND_URL: http://api:3001 + SITE_URL: https://改成你的域名 + NODE_ENV: production + DEV_MODE: "false" + PORT: "3000" + HOSTNAME: 0.0.0.0 + ports: + - "13000:3000" + +volumes: + pgdata: + appdata: +``` + +**只需改 3 处**: +1. `POSTGRES_PASSWORD` 的值 +2. `DB_DSN` 里的密码(**必须与第 1 处完全一致**) +3. 两处 `SITE_URL` 的域名 + +保存后,在编排列表点 **▶ 启动**。会自动:拉镜像 → 起 postgres → 健康后起 api → 健康后起 web。 + +> 容器名固定为 `postgres` / `api`,因为服务间通过容器名互访(`@postgres:5432`、`http://api:3001`)。 + +### 3. 验证启动 +「容器」标签确认三个容器状态: +- postgres Up (healthy) +- api Up (healthy) ← 健康才说明连库成功 +- web Up + +接口检查: +```bash +curl http://127.0.0.1:13000/healthz # web,返回 ok +docker exec jiang13-api-1 wget -qO- http://127.0.0.1:3001/health # api +``` + +--- + +## 三、Nginx 反向代理 + HTTPS + +`DEV_MODE=false` 启用了 `__Host-` 前缀 cookie,**必须 HTTPS**。 + +1Panel **网站 → 创建网站 → 反向代理**: +- 目标地址:`http://127.0.0.1:13000` +- 申请并开启 SSL(1Panel 可一键 Let's Encrypt) +- 域名 DNS 解析到服务器 + +访问 `https://你的域名`,空库下**第一个注册的账号自动成为站长(管理员)**。 + +--- + +## 四、配置说明 + +### 环境变量 +| 变量 | 说明 | 建议 | +|---|---|---| +| `POSTGRES_PASSWORD` | 数据库密码 | 强密码,与 DB_DSN 一致 | +| `DB_DSN` | 后端连库串 | 密码须与上者一致 | +| `JWT_SECRET` | 登录 token 签名密钥 | **留空**,自动生成并持久化到 data/.jwt_secret;自填须 ≥32 字符强随机 | +| `SETTINGS_MASTER_KEY` | 加密邮件/S3 凭据(32字节Base64) | **留空**=禁用保存凭据功能;需要时用 `openssl rand -base64 32` 生成 | +| `SITE_URL` | 对外公开 origin | 前后端一致,与浏览器地址栏完全相同(含 https、无尾斜杠)。运行时读取,可后改重启生效 | + +> `JWT_SECRET` 与 `SETTINGS_MASTER_KEY` **不要填同一个值**(密钥复用是安全大忌,且两者格式/生命周期不同)。 + +### 端口被占用怎么办 +- 容器内端口(3000/3001)写死不能改;只改映射的**宿主机端口**(冒号左边),如 `"13000:3000"`。 +- 用 Nginx 反代时 **api 无需暴露端口**(web 通过容器名内部访问)。 +- 查占用:`ss -tlnp | grep -E ':3000|:3001'` + +### 数据卷 +- `pgdata`:数据库数据 +- `appdata`:上传文件 / `.jwt_secret` + +删除编排时**不要勾选删除数据卷**,否则数据丢失。若换过数据库密码,需先删除旧 `pgdata` 卷重建(Postgres 只在空数据目录初始化密码)。 + +--- + +## 五、更新与运维 + +### 更新版本 +修改编排中镜像的 tag(如 `:0.1.0` → `:0.2.0`),或使用 `:latest` 标签后点「重新拉取镜像」再重启容器: +```bash +docker compose pull +docker compose up -d +``` +> `:latest` 不会自动更新,需手动重新拉取才生效;建议同时保留版本号标签以便回滚。 + +### 常用命令 +```bash +docker compose ps # 查看状态 +docker compose logs -f # 看日志 +docker compose logs api --tail 50 +docker compose down # 停止(保留数据) +docker compose down -v # 停止并清空数据(慎用) +``` + +--- + +## 六、排障速查 + +| 现象 | 原因 | 解决 | +|---|---|---| +| api 反复 restart,日志 `password authentication failed` | 密码不一致 / pgdata 残留旧密码 | 统一两处密码;删 pgdata 卷重建 | +| api 日志 `JWT_SECRET 过弱` | 填了 change-me 或过短 | 留空自动生成,或换 ≥32 字符强随机值 | +| 拉私有镜像 `denied` | 未配置仓库认证 | 1Panel 仓库里添加 Docker Hub 凭证 | +| web 不启动 | 等不到 api healthy | 先把 api 弄健康(解决上面问题) | +| 能打开页面但登录异常 | HTTP 下 __Host- cookie 不生效 | 必须走 HTTPS 反代 | diff --git a/frontend/app/admin/announcements/AnnouncementComposeClient.tsx b/frontend/app/admin/announcements/AnnouncementComposeClient.tsx index c7fc6c8..62cbffa 100644 --- a/frontend/app/admin/announcements/AnnouncementComposeClient.tsx +++ b/frontend/app/admin/announcements/AnnouncementComposeClient.tsx @@ -3,7 +3,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; -import { ArrowLeft, Copy, ExternalLink, Loader2 } from "lucide-react"; +import { ArrowLeft, Copy, ExternalLink, Loader2, Megaphone } from "lucide-react"; import { apiAdminCreateAnnouncement, apiAdminUpdateAnnouncement, @@ -246,34 +246,68 @@ export default function AnnouncementComposeClient({ return (
-
-
- - 返回列表 - -
+
+
+
+ + + 返回列表 + + + +

{isEdit ? "编辑公告" : "新建公告"}

{savedPublished ? "已发布" : savedPublished === false ? "草稿" : "尚未保存"}
+
+ {canViewOnline ? ( + + 线上页面 + + ) : null} + + role="group" + ariaLabel="保存后状态" + size="sm" + value={form.published ? "published" : "draft"} + onChange={(key) => patchForm({ published: key === "published" })} + options={[ + { key: "draft", label: "草稿" }, + { key: "published", label: "发布" }, + ]} + /> + + +

+ {statusText} +

+
- {canViewOnline ? ( - - 查看线上页面 - - ) : null}
-

标签颜色

+

+ 标签颜色 + + {ANNOUNCEMENT_COLORS.find((c) => c.value === form.tag_color)?.label} + +

{ANNOUNCEMENT_COLORS.map((c) => (
-

只影响标签颜色,不改正文。

+ {/* 与前台同一套 j13-anno-tag 标记,所见即所得 */} +
+ + {form.tag.trim() || "公告"} + +
{errors.tag_color ? (

{errors.tag_color} @@ -366,65 +410,25 @@ export default function AnnouncementComposeClient({

- {isEdit && saved ? ( -
-

访问地址

-

- {publicPath} - {origin ? ` · ${origin.replace(/\/$/, "")}${publicPath}` : ""} -

-

地址按公告编号生成,不能改成自定义路径。

- -
- ) : ( -

保存后才会有公开地址 /announcement/编号。草稿没有公开入口。

- )} -
-

保存后状态

- - role="group" - ariaLabel="保存后状态" - size="sm" - value={form.published ? "published" : "draft"} - onChange={(key) => patchForm({ published: key === "published" })} - options={[ - { key: "draft", label: "草稿" }, - { key: "published", label: "已发布" }, - ]} - /> -

点选只改变这次要保存的状态,不会立刻发布或下线。

+

访问地址

+ {isEdit && saved ? ( + <> +

+ {publicPath} + {origin ? ` · ${origin.replace(/\/$/, "")}${publicPath}` : ""} +

+

地址按公告编号生成,不能改成自定义路径。

+ + + ) : ( +

保存后按公告编号生成 /announcement/编号;草稿没有公开入口。

+ )}
- -
-
- - -

- {statusText} -

-
-

一次保存会写入标题、正文和公告设置。Ctrl/⌘ + Enter 与这个按钮相同。

- {canViewOnline ? ( -

查看线上页面打开的是已发布内容,未保存的修改不会出现在那里。

- ) : ( -

草稿没有公开地址。要看正文,用编辑器里的预览。

- )} -
= { + [BOARD_POST_POLICY.ALL]: "全员可发", + [BOARD_POST_POLICY.STAFF]: "仅管理员", +}; + +const POST_POLICY_OPTIONS: { value: string; label: string; desc: string }[] = [ + { + value: BOARD_POST_POLICY.ALL, + label: "全员可发", + desc: "所有登录用户均可在此板块发帖", + }, + { + value: BOARD_POST_POLICY.STAFF, + label: "仅管理员及以上", + desc: "仅版主、超级管理员、站长可发帖", + }, +]; + +function emptyInput(): AdminBoardInput { + return { + name: "", + description: "", + icon: "message-circle", + color_index: -1, + sort_order: 0, + post_policy: BOARD_POST_POLICY.ALL, + visible: true, + }; +} + +function toInput(b: AdminBoard): AdminBoardInput { + return { + name: b.name, + description: b.description, + icon: b.icon, + color_index: b.color_index, + sort_order: b.sort_order, + post_policy: b.post_policy, + visible: b.visible, + }; +} + +function BoardEditModal({ + open, + initial, + title, + busy, + editTarget, + onClose, + onSubmit, +}: { + open: boolean; + initial: AdminBoardInput; + title: string; + busy: boolean; + /** 编辑中的板块(新建为 null);自动配色预览按其 ID 轮换 */ + editTarget: AdminBoard | null; + onClose: () => void; + onSubmit: (input: AdminBoardInput) => void; +}) { + const [form, setForm] = useState(initial); + + useEffect(() => { + if (open) setForm(initial); + }, [open, initial]); + + const update = (patch: Partial) => setForm((f) => ({ ...f, ...patch })); + + // 预览配色:固定(>0)按存值取模;自动(-1/0)按已有板块 ID 轮换,新板块用 accent 占位 + const previewColor = + (form.color_index ?? 0) > 0 + ? (form.color_index ?? 0) % BOARD_COLOR_COUNT + : editTarget + ? editTarget.id % BOARD_COLOR_COUNT + : -1; + + return ( + + + + + } + > +
+ + + {/* 外观:实时预览 + 图标 + 颜色,联动更新 */} +
+
+ = 0 ? `av-${previewColor}` : "" + }`} + style={ + previewColor < 0 + ? { background: "var(--accent-soft)", color: "var(--accent)" } + : undefined + } + aria-hidden + > + + +
+

+ {form.name.trim() || "板块名称预览"} +

+

+ {form.description?.trim() || "描述将显示在这里"} +

+
+ 前台预览 +
+ +
+

+ 图标 +

+
+ {BOARD_ICONS.map((o) => { + const active = form.icon === o.value; + return ( + + ); + })} +
+
+ +
+

+ 图标颜色 +

+
+ + {/* 存值 1..16 → 渲染 av-1..av-15、av-0;0 与 -1 均视为自动 */} + {Array.from({ length: BOARD_COLOR_COUNT }, (_, k) => k + 1).map((v) => { + const active = (form.color_index ?? 0) === v; + return ( + + ); + })} +
+

+ {(form.color_index ?? 0) <= 0 + ? "自动模式:前台按板块编号分配配色,各页面保持一致。" + : "指定固定配色,前台所有位置保持一致。"} +

+
+
+ +