feat: 管理端板块管理与旧版数据导入,补充部署运营文档

- 新增管理端板块管理页面与后端接口(admin_board)
- 新增旧版数据导入:legacyimport 服务、导入面板、importusers 命令行工具
- 聊天用户卡片、板块图标等 UI 组件与界面优化
- 补充 about/公告/1Panel 部署等文档
- gitignore 排除 dist/ 构建产物与 .agents/ 本地工具目录
This commit is contained in:
2026-09-24 03:37:44 +08:00
parent c1f6a6636b
commit 3e55b5d230
53 changed files with 5078 additions and 409 deletions

View File

@@ -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)
}
}
}

View File

@@ -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
)

View File

@@ -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=

View File

@@ -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": "操作失败"})
}
}

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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": "密码已更新,请重新登录"})

View File

@@ -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) {

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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"`

View File

@@ -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 内校验站点消息权或群管理员)

View File

@@ -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:

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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

View File

@@ -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])
}

View File

@@ -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)
}

View File

@@ -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("内容过滤暂不可用,请稍后重试")

View File

@@ -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)

View File

@@ -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

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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")
}
}