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