主要功能: - 实现完整的 RBAC 权限系统(账号、角色、权限的多对多关联) - 基于 owner_id + shop_id 的自动数据权限过滤 - 使用 PostgreSQL WITH RECURSIVE 查询下级账号 - Redis 缓存优化下级账号查询性能(30分钟过期) - 支持多租户数据隔离和层级权限管理 技术实现: - 新增 Account、Role、Permission 模型及关联关系表 - 实现 GORM Scopes 自动应用数据权限过滤 - 添加数据库迁移脚本(000002_rbac_data_permission、000003_add_owner_id_shop_id) - 完善错误码定义(1010-1027 为 RBAC 相关错误) - 重构 main.go 采用函数拆分提高可读性 测试覆盖: - 添加 Account、Role、Permission 的集成测试 - 添加数据权限过滤的单元测试和集成测试 - 添加下级账号查询和缓存的单元测试 - 添加 API 回归测试确保向后兼容 文档更新: - 更新 README.md 添加 RBAC 功能说明 - 更新 CLAUDE.md 添加技术栈和开发原则 - 添加 docs/004-rbac-data-permission/ 功能总结和使用指南 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
389 lines
9.3 KiB
Go
389 lines
9.3 KiB
Go
package logger
|
||
|
||
import (
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"go.uber.org/zap"
|
||
)
|
||
|
||
// TestLogRotation 测试日志轮转功能(T027)
|
||
func TestLogRotation(t *testing.T) {
|
||
// 创建临时目录
|
||
tempDir := t.TempDir()
|
||
|
||
appLogFile := filepath.Join(tempDir, "app-rotation.log")
|
||
|
||
// 初始化日志系统,设置较小的 MaxSize 以便测试
|
||
err := InitLoggers("info", false,
|
||
LogRotationConfig{
|
||
Filename: appLogFile,
|
||
MaxSize: 1, // 1MB,写入足够数据后会触发轮转
|
||
MaxBackups: 3,
|
||
MaxAge: 7,
|
||
Compress: false, // 不压缩以便检查
|
||
},
|
||
LogRotationConfig{
|
||
Filename: filepath.Join(tempDir, "access-rotation.log"),
|
||
MaxSize: 1,
|
||
MaxBackups: 3,
|
||
MaxAge: 7,
|
||
Compress: false,
|
||
},
|
||
)
|
||
if err != nil {
|
||
t.Fatalf("InitLoggers failed: %v", err)
|
||
}
|
||
|
||
logger := GetAppLogger()
|
||
|
||
// 写入大量日志数据以触发轮转(每条约100字节,写入15000条约1.5MB)
|
||
largeMessage := strings.Repeat("a", 100)
|
||
for i := 0; i < 15000; i++ {
|
||
logger.Info(largeMessage,
|
||
zap.Int("iteration", i),
|
||
zap.String("data", largeMessage),
|
||
)
|
||
}
|
||
|
||
// 刷新缓冲区
|
||
if err := Sync(); err != nil {
|
||
t.Fatalf("Sync failed: %v", err)
|
||
}
|
||
|
||
// 等待一小段时间确保文件写入完成
|
||
time.Sleep(100 * time.Millisecond)
|
||
|
||
// 验证主日志文件存在
|
||
if _, err := os.Stat(appLogFile); os.IsNotExist(err) {
|
||
t.Error("Main log file should exist")
|
||
}
|
||
|
||
// 检查是否有备份文件(轮转后的文件)
|
||
files, err := filepath.Glob(filepath.Join(tempDir, "app-rotation-*.log"))
|
||
if err != nil {
|
||
t.Fatalf("Failed to glob backup files: %v", err)
|
||
}
|
||
|
||
// 由于写入了超过1MB的数据,应该触发至少一次轮转
|
||
if len(files) == 0 {
|
||
// 可能系统写入速度或lumberjack行为导致未立即轮转,检查主文件大小
|
||
info, err := os.Stat(appLogFile)
|
||
if err != nil {
|
||
t.Fatalf("Failed to stat main log file: %v", err)
|
||
}
|
||
if info.Size() == 0 {
|
||
t.Error("Log file should have content")
|
||
}
|
||
// 不强制要求必须轮转,因为取决于具体实现
|
||
t.Logf("No rotation occurred, but main log file size: %d bytes", info.Size())
|
||
} else {
|
||
t.Logf("Found %d rotated backup file(s)", len(files))
|
||
}
|
||
}
|
||
|
||
// TestMaxBackups 测试最大备份数限制(T027)
|
||
func TestMaxBackups(t *testing.T) {
|
||
// 创建临时目录
|
||
tempDir := t.TempDir()
|
||
|
||
appLogFile := filepath.Join(tempDir, "app-backups.log")
|
||
|
||
// 初始化日志系统,设置 MaxBackups=2
|
||
err := InitLoggers("info", false,
|
||
LogRotationConfig{
|
||
Filename: appLogFile,
|
||
MaxSize: 1, // 1MB
|
||
MaxBackups: 2, // 最多保留2个备份
|
||
MaxAge: 7,
|
||
Compress: false,
|
||
},
|
||
LogRotationConfig{
|
||
Filename: filepath.Join(tempDir, "access-backups.log"),
|
||
MaxSize: 1,
|
||
MaxBackups: 2,
|
||
MaxAge: 7,
|
||
Compress: false,
|
||
},
|
||
)
|
||
if err != nil {
|
||
t.Fatalf("InitLoggers failed: %v", err)
|
||
}
|
||
|
||
logger := GetAppLogger()
|
||
|
||
// 写入足够的数据触发多次轮转(每次1.5MB,共4.5MB应该触发3次轮转)
|
||
largeMessage := strings.Repeat("b", 100)
|
||
for round := 0; round < 3; round++ {
|
||
for i := 0; i < 15000; i++ {
|
||
logger.Info(largeMessage,
|
||
zap.Int("round", round),
|
||
zap.Int("iteration", i),
|
||
)
|
||
}
|
||
_ = Sync()
|
||
time.Sleep(100 * time.Millisecond)
|
||
}
|
||
|
||
// 等待轮转完成
|
||
time.Sleep(200 * time.Millisecond)
|
||
|
||
// 检查备份文件数量
|
||
files, err := filepath.Glob(filepath.Join(tempDir, "app-backups-*.log"))
|
||
if err != nil {
|
||
t.Fatalf("Failed to glob backup files: %v", err)
|
||
}
|
||
|
||
// 由于 MaxBackups=2,即使触发了多次轮转,也只应保留最多2个备份文件
|
||
// (实际行为取决于 lumberjack 的实现细节,可能小于等于2)
|
||
if len(files) > 2 {
|
||
t.Errorf("Expected at most 2 backup files due to MaxBackups=2, got %d", len(files))
|
||
}
|
||
t.Logf("Found %d backup file(s) with MaxBackups=2", len(files))
|
||
}
|
||
|
||
// TestCompressionConfig 测试压缩配置(T027)
|
||
func TestCompressionConfig(t *testing.T) {
|
||
// 创建临时目录
|
||
tempDir := t.TempDir()
|
||
|
||
tests := []struct {
|
||
name string
|
||
compress bool
|
||
}{
|
||
{
|
||
name: "compression enabled",
|
||
compress: true,
|
||
},
|
||
{
|
||
name: "compression disabled",
|
||
compress: false,
|
||
},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
logFile := filepath.Join(tempDir, "app-"+tt.name+".log")
|
||
|
||
err := InitLoggers("info", false,
|
||
LogRotationConfig{
|
||
Filename: logFile,
|
||
MaxSize: 1,
|
||
MaxBackups: 3,
|
||
MaxAge: 7,
|
||
Compress: tt.compress,
|
||
},
|
||
LogRotationConfig{
|
||
Filename: filepath.Join(tempDir, "access-"+tt.name+".log"),
|
||
MaxSize: 1,
|
||
MaxBackups: 3,
|
||
MaxAge: 7,
|
||
Compress: tt.compress,
|
||
},
|
||
)
|
||
if err != nil {
|
||
t.Fatalf("InitLoggers failed: %v", err)
|
||
}
|
||
|
||
logger := GetAppLogger()
|
||
|
||
// 写入一些日志
|
||
for i := 0; i < 1000; i++ {
|
||
logger.Info("test compression",
|
||
zap.Int("id", i),
|
||
zap.String("data", strings.Repeat("c", 50)),
|
||
)
|
||
}
|
||
|
||
_ = Sync()
|
||
time.Sleep(100 * time.Millisecond)
|
||
|
||
// 验证日志文件存在
|
||
if _, err := os.Stat(logFile); os.IsNotExist(err) {
|
||
t.Error("Log file should exist")
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestMaxAge 测试日志文件保留时间(T027)
|
||
func TestMaxAge(t *testing.T) {
|
||
// 创建临时目录
|
||
tempDir := t.TempDir()
|
||
|
||
// 初始化日志系统,设置 MaxAge=1 天
|
||
err := InitLoggers("info", false,
|
||
LogRotationConfig{
|
||
Filename: filepath.Join(tempDir, "app-maxage.log"),
|
||
MaxSize: 10,
|
||
MaxBackups: 3,
|
||
MaxAge: 1, // 1天
|
||
Compress: false,
|
||
},
|
||
LogRotationConfig{
|
||
Filename: filepath.Join(tempDir, "access-maxage.log"),
|
||
MaxSize: 10,
|
||
MaxBackups: 3,
|
||
MaxAge: 1,
|
||
Compress: false,
|
||
},
|
||
)
|
||
if err != nil {
|
||
t.Fatalf("InitLoggers failed: %v", err)
|
||
}
|
||
|
||
logger := GetAppLogger()
|
||
|
||
// 写入日志
|
||
logger.Info("test max age", zap.String("config", "maxage=1"))
|
||
_ = Sync()
|
||
|
||
// 验证配置已应用(无法在单元测试中验证实际的清理行为,因为需要等待1天)
|
||
// 这里只验证初始化没有错误
|
||
if logger == nil {
|
||
t.Error("Logger should be initialized with MaxAge config")
|
||
}
|
||
}
|
||
|
||
// TestNewLumberjackLogger 测试 Lumberjack logger 创建(T027)
|
||
func TestNewLumberjackLogger(t *testing.T) {
|
||
// 创建临时目录
|
||
tempDir := t.TempDir()
|
||
|
||
tests := []struct {
|
||
name string
|
||
config LogRotationConfig
|
||
}{
|
||
{
|
||
name: "standard config",
|
||
config: LogRotationConfig{
|
||
Filename: filepath.Join(tempDir, "test1.log"),
|
||
MaxSize: 10,
|
||
MaxBackups: 3,
|
||
MaxAge: 7,
|
||
Compress: true,
|
||
},
|
||
},
|
||
{
|
||
name: "minimal config",
|
||
config: LogRotationConfig{
|
||
Filename: filepath.Join(tempDir, "test2.log"),
|
||
MaxSize: 1,
|
||
MaxBackups: 1,
|
||
MaxAge: 1,
|
||
Compress: false,
|
||
},
|
||
},
|
||
{
|
||
name: "large config",
|
||
config: LogRotationConfig{
|
||
Filename: filepath.Join(tempDir, "test3.log"),
|
||
MaxSize: 100,
|
||
MaxBackups: 10,
|
||
MaxAge: 30,
|
||
Compress: true,
|
||
},
|
||
},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
logger := newLumberjackLogger(tt.config)
|
||
if logger == nil {
|
||
t.Error("newLumberjackLogger should not return nil")
|
||
}
|
||
|
||
// 验证配置已正确设置
|
||
if logger.Filename != tt.config.Filename {
|
||
t.Errorf("Filename = %v, want %v", logger.Filename, tt.config.Filename)
|
||
}
|
||
if logger.MaxSize != tt.config.MaxSize {
|
||
t.Errorf("MaxSize = %v, want %v", logger.MaxSize, tt.config.MaxSize)
|
||
}
|
||
if logger.MaxBackups != tt.config.MaxBackups {
|
||
t.Errorf("MaxBackups = %v, want %v", logger.MaxBackups, tt.config.MaxBackups)
|
||
}
|
||
if logger.MaxAge != tt.config.MaxAge {
|
||
t.Errorf("MaxAge = %v, want %v", logger.MaxAge, tt.config.MaxAge)
|
||
}
|
||
if logger.Compress != tt.config.Compress {
|
||
t.Errorf("Compress = %v, want %v", logger.Compress, tt.config.Compress)
|
||
}
|
||
if !logger.LocalTime {
|
||
t.Error("LocalTime should be true")
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestConcurrentLogging 测试并发日志写入(T027)
|
||
func TestConcurrentLogging(t *testing.T) {
|
||
// 创建临时目录
|
||
tempDir := t.TempDir()
|
||
|
||
// 初始化日志系统
|
||
err := InitLoggers("info", false,
|
||
LogRotationConfig{
|
||
Filename: filepath.Join(tempDir, "app-concurrent.log"),
|
||
MaxSize: 10,
|
||
MaxBackups: 3,
|
||
MaxAge: 7,
|
||
Compress: false,
|
||
},
|
||
LogRotationConfig{
|
||
Filename: filepath.Join(tempDir, "access-concurrent.log"),
|
||
MaxSize: 10,
|
||
MaxBackups: 3,
|
||
MaxAge: 7,
|
||
Compress: false,
|
||
},
|
||
)
|
||
if err != nil {
|
||
t.Fatalf("InitLoggers failed: %v", err)
|
||
}
|
||
|
||
logger := GetAppLogger()
|
||
|
||
// 启动多个 goroutine 并发写入日志
|
||
done := make(chan bool)
|
||
goroutines := 10
|
||
messagesPerGoroutine := 100
|
||
|
||
for i := 0; i < goroutines; i++ {
|
||
go func(id int) {
|
||
for j := 0; j < messagesPerGoroutine; j++ {
|
||
logger.Info("concurrent log message",
|
||
zap.Int("goroutine", id),
|
||
zap.Int("message", j),
|
||
)
|
||
}
|
||
done <- true
|
||
}(i)
|
||
}
|
||
|
||
// 等待所有 goroutine 完成
|
||
for i := 0; i < goroutines; i++ {
|
||
<-done
|
||
}
|
||
|
||
// 刷新缓冲区
|
||
if err := Sync(); err != nil {
|
||
t.Fatalf("Sync failed: %v", err)
|
||
}
|
||
|
||
// 验证日志文件存在且有内容
|
||
logFile := filepath.Join(tempDir, "app-concurrent.log")
|
||
info, err := os.Stat(logFile)
|
||
if err != nil {
|
||
t.Fatalf("Failed to stat log file: %v", err)
|
||
}
|
||
if info.Size() == 0 {
|
||
t.Error("Log file should have content after concurrent writes")
|
||
}
|
||
|
||
t.Logf("Concurrent logging test completed, log file size: %d bytes", info.Size())
|
||
}
|