refactor(server): decouple services and improve architecture
- Introduce interfaces for all major services (JWT, PostAI, Comment, Message, Notification, QRCodeLogin, Upload, Vote, etc.) to support dependency inversion.
- Move query parameters from `internal/dto` to a new `internal/query` package to separate request payloads from data transfer objects.
- Refactor `internal/router` to use embedded `RouterDeps` for cleaner dependency management.
- Decouple handlers from repositories by injecting services instead of direct repository access, ensuring proper layering.
- Improve database initialization by moving it from `internal/model` to `internal/database`.
- Optimize message decryption by implementing a more efficient `BatchDecrypt` method in `MessageEncryptor` using a worker pool.
- Enhance error handling and security by implementing fail-fast checks for encryption key length during startup.
- Clean up unused code, including the `avatar` package and several unused DTOs.
2026-06-15 03:41:59 +08:00
|
|
|
|
package database
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"os"
|
|
|
|
|
|
"path/filepath"
|
|
|
|
|
|
"testing"
|
|
|
|
|
|
|
|
|
|
|
|
"with_you/internal/config"
|
|
|
|
|
|
"with_you/internal/model"
|
|
|
|
|
|
|
|
|
|
|
|
"gorm.io/driver/sqlite"
|
|
|
|
|
|
"gorm.io/gorm"
|
|
|
|
|
|
"gorm.io/gorm/logger"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// 注意:完整的 NewDB→autoMigrate 在 SQLite 下会因 model 包多个表共用
|
|
|
|
|
|
// 同名索引 idx_created(data_change_log / login_log / operation_log)而冲突。
|
|
|
|
|
|
// 这是 model 包的既有约束(PostgreSQL 经 CREATE INDEX IF NOT EXISTS 容忍),
|
|
|
|
|
|
// 与本次分层拆分无关。因此种子逻辑测试在隔离的 SQLite 库上验证:
|
|
|
|
|
|
// 仅迁移被种子函数读写的表,避开跨表索引冲突,专注验证种子正确性与幂等性。
|
|
|
|
|
|
|
|
|
|
|
|
// newSeedTestDB 创建一个仅迁移种子相关表的 SQLite 测试库。
|
|
|
|
|
|
func newSeedTestDB(t *testing.T) *gorm.DB {
|
|
|
|
|
|
t.Helper()
|
|
|
|
|
|
|
|
|
|
|
|
dbPath := filepath.Join(t.TempDir(), "seed_test.db")
|
|
|
|
|
|
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
|
|
|
|
|
|
Logger: logger.Default.LogMode(logger.Silent),
|
|
|
|
|
|
})
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("open sqlite failed: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 仅迁移种子函数涉及的表(无 idx_created 冲突)
|
|
|
|
|
|
if err := db.AutoMigrate(
|
|
|
|
|
|
&model.Role{},
|
|
|
|
|
|
&model.CasbinRule{},
|
|
|
|
|
|
&model.Channel{},
|
|
|
|
|
|
&model.MaterialSubject{},
|
|
|
|
|
|
&model.Post{}, // dropPostsHotScoreColumnIfExists 依赖 posts 表存在
|
|
|
|
|
|
); err != nil {
|
|
|
|
|
|
t.Fatalf("autoMigrate seed tables failed: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
t.Cleanup(func() {
|
|
|
|
|
|
if sqlDB, err := db.DB(); err == nil {
|
|
|
|
|
|
_ = sqlDB.Close()
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
return db
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestSeedRoles 验证角色种子写入 5 个预定义角色。
|
|
|
|
|
|
func TestSeedRoles(t *testing.T) {
|
|
|
|
|
|
db := newSeedTestDB(t)
|
|
|
|
|
|
|
|
|
|
|
|
if err := seedRoles(db); err != nil {
|
|
|
|
|
|
t.Fatalf("seedRoles failed: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var count int64
|
|
|
|
|
|
db.Model(&model.Role{}).Count(&count)
|
|
|
|
|
|
if count != 5 {
|
|
|
|
|
|
t.Errorf("expected 5 seeded roles, got %d", count)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
expected := []string{
|
|
|
|
|
|
model.RoleSuperAdmin, model.RoleAdmin, model.RoleModerator,
|
|
|
|
|
|
model.RoleUser, model.RoleBanned,
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, name := range expected {
|
|
|
|
|
|
var role model.Role
|
|
|
|
|
|
if err := db.Where("name = ?", name).First(&role).Error; err != nil {
|
|
|
|
|
|
t.Errorf("expected role %q: %v", name, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestSeedRoles_Idempotent 验证角色种子幂等:表非空时跳过。
|
|
|
|
|
|
func TestSeedRoles_Idempotent(t *testing.T) {
|
|
|
|
|
|
db := newSeedTestDB(t)
|
|
|
|
|
|
|
|
|
|
|
|
if err := seedRoles(db); err != nil {
|
|
|
|
|
|
t.Fatalf("first seedRoles: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
// 第二次调用应跳过(表已有数据)
|
|
|
|
|
|
if err := seedRoles(db); err != nil {
|
|
|
|
|
|
t.Fatalf("second seedRoles: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var count int64
|
|
|
|
|
|
db.Model(&model.Role{}).Count(&count)
|
|
|
|
|
|
if count != 5 {
|
|
|
|
|
|
t.Errorf("idempotent seed failed: expected 5 roles, got %d", count)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestSeedPermissions 验证权限策略种子写入,含超级管理员通配规则。
|
|
|
|
|
|
func TestSeedPermissions(t *testing.T) {
|
|
|
|
|
|
db := newSeedTestDB(t)
|
|
|
|
|
|
|
|
|
|
|
|
if err := seedPermissions(db); err != nil {
|
|
|
|
|
|
t.Fatalf("seedPermissions failed: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var count int64
|
|
|
|
|
|
db.Model(&model.CasbinRule{}).Where("ptype = ?", "p").Count(&count)
|
|
|
|
|
|
if count == 0 {
|
|
|
|
|
|
t.Fatal("expected seeded permissions, got 0")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(auth): implement session-based token management with revocation support
Add Session model, SessionService, and SessionRepository to track user login
sessions and enable token revocation on auth-critical events.
- Introduce explicit TokenType (access/refresh) in JWT claims to prevent
refresh token misuse via access token endpoints
- Add SessionID field to JWT claims, enabling stateless JWT validation
against revoked sessions
- Replace legacy Auth middleware with RequireAuth/OptionalAuth pipeline
that validates token type, account status, and session validity
- Implement session revocation on password change, reset, user ban/inactive,
and explicit logout
- Add Principal cache with active invalidation for banned/role-changed users
- Fix IDOR vulnerability: GetMessagesByCursor now validates currentUserID
is conversation participant via GetParticipantStrict
- Add group member visibility checks: announcements, group info, member list
now require group membership
- Simplify Casbin policy: remove g grouping, use r.sub == p.sub matcher
with globMatch; user_roles table is single source of truth for roles
- Add migration logic to clean legacy casbin g rules and migrate old p rules
from path-style to admin/<domain> resource naming
2026-07-05 18:28:08 +08:00
|
|
|
|
// 超级管理员 admin/** 通配(globMatch 中 ** 跨 '/')
|
refactor(server): decouple services and improve architecture
- Introduce interfaces for all major services (JWT, PostAI, Comment, Message, Notification, QRCodeLogin, Upload, Vote, etc.) to support dependency inversion.
- Move query parameters from `internal/dto` to a new `internal/query` package to separate request payloads from data transfer objects.
- Refactor `internal/router` to use embedded `RouterDeps` for cleaner dependency management.
- Decouple handlers from repositories by injecting services instead of direct repository access, ensuring proper layering.
- Improve database initialization by moving it from `internal/model` to `internal/database`.
- Optimize message decryption by implementing a more efficient `BatchDecrypt` method in `MessageEncryptor` using a worker pool.
- Enhance error handling and security by implementing fail-fast checks for encryption key length during startup.
- Clean up unused code, including the `avatar` package and several unused DTOs.
2026-06-15 03:41:59 +08:00
|
|
|
|
var rule model.CasbinRule
|
feat(auth): implement session-based token management with revocation support
Add Session model, SessionService, and SessionRepository to track user login
sessions and enable token revocation on auth-critical events.
- Introduce explicit TokenType (access/refresh) in JWT claims to prevent
refresh token misuse via access token endpoints
- Add SessionID field to JWT claims, enabling stateless JWT validation
against revoked sessions
- Replace legacy Auth middleware with RequireAuth/OptionalAuth pipeline
that validates token type, account status, and session validity
- Implement session revocation on password change, reset, user ban/inactive,
and explicit logout
- Add Principal cache with active invalidation for banned/role-changed users
- Fix IDOR vulnerability: GetMessagesByCursor now validates currentUserID
is conversation participant via GetParticipantStrict
- Add group member visibility checks: announcements, group info, member list
now require group membership
- Simplify Casbin policy: remove g grouping, use r.sub == p.sub matcher
with globMatch; user_roles table is single source of truth for roles
- Add migration logic to clean legacy casbin g rules and migrate old p rules
from path-style to admin/<domain> resource naming
2026-07-05 18:28:08 +08:00
|
|
|
|
if err := db.Where("ptype=? AND v0=? AND v1=?", "p", model.RoleSuperAdmin, "admin/**").First(&rule).Error; err != nil {
|
|
|
|
|
|
t.Errorf("expected superadmin admin/** wildcard permission: %v", err)
|
refactor(server): decouple services and improve architecture
- Introduce interfaces for all major services (JWT, PostAI, Comment, Message, Notification, QRCodeLogin, Upload, Vote, etc.) to support dependency inversion.
- Move query parameters from `internal/dto` to a new `internal/query` package to separate request payloads from data transfer objects.
- Refactor `internal/router` to use embedded `RouterDeps` for cleaner dependency management.
- Decouple handlers from repositories by injecting services instead of direct repository access, ensuring proper layering.
- Improve database initialization by moving it from `internal/model` to `internal/database`.
- Optimize message decryption by implementing a more efficient `BatchDecrypt` method in `MessageEncryptor` using a worker pool.
- Enhance error handling and security by implementing fail-fast checks for encryption key length during startup.
- Clean up unused code, including the `avatar` package and several unused DTOs.
2026-06-15 03:41:59 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestSeedPermissions_Idempotent 验证权限种子幂等。
|
|
|
|
|
|
func TestSeedPermissions_Idempotent(t *testing.T) {
|
|
|
|
|
|
db := newSeedTestDB(t)
|
|
|
|
|
|
|
|
|
|
|
|
if err := seedPermissions(db); err != nil {
|
|
|
|
|
|
t.Fatalf("first seedPermissions: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
var count1 int64
|
|
|
|
|
|
db.Model(&model.CasbinRule{}).Where("ptype = ?", "p").Count(&count1)
|
|
|
|
|
|
|
|
|
|
|
|
if err := seedPermissions(db); err != nil {
|
|
|
|
|
|
t.Fatalf("second seedPermissions: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
var count2 int64
|
|
|
|
|
|
db.Model(&model.CasbinRule{}).Where("ptype = ?", "p").Count(&count2)
|
|
|
|
|
|
|
|
|
|
|
|
if count2 != count1 {
|
|
|
|
|
|
t.Errorf("seed not idempotent: first=%d, second=%d", count1, count2)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestSeedChannels 验证频道种子写入 5 个默认频道。
|
|
|
|
|
|
func TestSeedChannels(t *testing.T) {
|
|
|
|
|
|
db := newSeedTestDB(t)
|
|
|
|
|
|
|
|
|
|
|
|
if err := seedChannels(db); err != nil {
|
|
|
|
|
|
t.Fatalf("seedChannels failed: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var count int64
|
|
|
|
|
|
db.Model(&model.Channel{}).Count(&count)
|
|
|
|
|
|
if count != 5 {
|
|
|
|
|
|
t.Errorf("expected 5 channels, got %d", count)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 抽查一个频道
|
|
|
|
|
|
var ch model.Channel
|
|
|
|
|
|
if err := db.Where("slug = ?", "market").First(&ch).Error; err != nil {
|
|
|
|
|
|
t.Errorf("expected market channel: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestSeedMaterialSubjects 验证学科种子写入 8 个默认学科。
|
|
|
|
|
|
func TestSeedMaterialSubjects(t *testing.T) {
|
|
|
|
|
|
db := newSeedTestDB(t)
|
|
|
|
|
|
|
|
|
|
|
|
if err := seedMaterialSubjects(db); err != nil {
|
|
|
|
|
|
t.Fatalf("seedMaterialSubjects failed: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var count int64
|
|
|
|
|
|
db.Model(&model.MaterialSubject{}).Count(&count)
|
|
|
|
|
|
if count != 8 {
|
|
|
|
|
|
t.Errorf("expected 8 material subjects, got %d", count)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestParseGormLogLevel 验证日志级别解析映射。
|
|
|
|
|
|
func TestParseGormLogLevel(t *testing.T) {
|
|
|
|
|
|
cases := []struct {
|
|
|
|
|
|
in string
|
|
|
|
|
|
want logger.LogLevel
|
|
|
|
|
|
}{
|
|
|
|
|
|
{"silent", logger.Silent},
|
|
|
|
|
|
{"error", logger.Error},
|
|
|
|
|
|
{"warn", logger.Warn},
|
|
|
|
|
|
{"info", logger.Info},
|
|
|
|
|
|
{"", logger.Warn}, // 默认
|
|
|
|
|
|
{"bogus", logger.Warn}, // 未知值降级为 warn
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, c := range cases {
|
|
|
|
|
|
if got := parseGormLogLevel(c.in); got != c.want {
|
|
|
|
|
|
t.Errorf("parseGormLogLevel(%q) = %v, want %v", c.in, got, c.want)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestDropPostsHotScoreColumnIfExists_NoTable 验证当 posts 表不存在时安全跳过。
|
|
|
|
|
|
func TestDropPostsHotScoreColumnIfExists_NoTable(t *testing.T) {
|
|
|
|
|
|
dbPath := filepath.Join(t.TempDir(), "empty.db")
|
|
|
|
|
|
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
|
|
|
|
|
|
Logger: logger.Default.LogMode(logger.Silent),
|
|
|
|
|
|
})
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("open sqlite: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
t.Cleanup(func() {
|
|
|
|
|
|
if sqlDB, err := db.DB(); err == nil {
|
|
|
|
|
|
_ = sqlDB.Close()
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 未迁移 posts 表 → 应安全返回 nil
|
|
|
|
|
|
if err := dropPostsHotScoreColumnIfExists(db); err != nil {
|
|
|
|
|
|
t.Errorf("expected nil when posts table absent, got: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 编译期断言:确保 NewDB 签名符合 (*config.DatabaseConfig) -> (*gorm.DB, error)
|
|
|
|
|
|
var _ = func() (*gorm.DB, error) {
|
|
|
|
|
|
return NewDB(&config.DatabaseConfig{})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var _ = os.RemoveAll // 保持 os 引用可用(供未来临时文件场景扩展)
|