Files
SamWaf/common/tasklog/tasklog.go
T
2026-04-13 14:29:17 +08:00

490 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package tasklog
import (
"crypto/md5"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"time"
"go.uber.org/zap/zapcore"
"gopkg.in/natefinch/lumberjack.v2"
)
// ---- goroutine 上下文:goroutine ID -> task method ----
var goroutineTaskMap sync.Map // key: int64(goroutine ID), value: string(task method)
// goroutineID 解析当前 goroutine 的 ID(通过 runtime.Stack)
func goroutineID() int64 {
var buf [64]byte
n := runtime.Stack(buf[:], false)
s := strings.TrimPrefix(string(buf[:n]), "goroutine ")
idx := strings.IndexByte(s, ' ')
if idx < 0 {
return -1
}
id, _ := strconv.ParseInt(s[:idx], 10, 64)
return id
}
// SetCurrentTask 在当前 goroutine 上注册正在执行的任务方法名
func SetCurrentTask(taskMethod string) {
goroutineTaskMap.Store(goroutineID(), taskMethod)
}
// ClearCurrentTask 清除当前 goroutine 的任务上下文
func ClearCurrentTask() {
goroutineTaskMap.Delete(goroutineID())
}
// GetCurrentTask 获取当前 goroutine 关联的任务方法名(若无则返回 "", false)
func GetCurrentTask() (string, bool) {
v, ok := goroutineTaskMap.Load(goroutineID())
if !ok {
return "", false
}
return v.(string), true
}
// ---- 自定义 zap Core:将 zlog 输出路由到任务日志文件 ----
// TaskZapCore 是一个 zapcore.Core 实现
// 它在当前 goroutine 有任务上下文时,把日志同步写入对应的任务日志文件
type TaskZapCore struct {
minLevel zapcore.Level
}
// NewTaskZapCore 创建 TaskZapCore,minLevel 决定哪些级别的日志写入任务文件
func NewTaskZapCore(minLevel zapcore.Level) zapcore.Core {
return &TaskZapCore{minLevel: minLevel}
}
func (c *TaskZapCore) Enabled(level zapcore.Level) bool {
return level >= c.minLevel
}
func (c *TaskZapCore) With(_ []zapcore.Field) zapcore.Core {
return c
}
func (c *TaskZapCore) Check(entry zapcore.Entry, ce *zapcore.CheckedEntry) *zapcore.CheckedEntry {
if !c.Enabled(entry.Level) {
return ce
}
if GlobalTaskLogManager == nil {
return ce
}
if _, ok := GetCurrentTask(); !ok {
return ce
}
return ce.AddCore(entry, c)
}
func (c *TaskZapCore) Write(entry zapcore.Entry, fields []zapcore.Field) error {
if GlobalTaskLogManager == nil {
return nil
}
taskMethod, ok := GetCurrentTask()
if !ok {
return nil
}
level := strings.ToUpper(entry.Level.String())
msg := entry.Message
if len(fields) > 0 {
enc := zapcore.NewMapObjectEncoder()
for _, f := range fields {
f.AddTo(enc)
}
var extras []string
for k, v := range enc.Fields {
extras = append(extras, fmt.Sprintf("%s=%v", k, v))
}
if len(extras) > 0 {
msg = msg + " " + strings.Join(extras, " ")
}
}
GlobalTaskLogManager.Log(taskMethod, level, msg)
return nil
}
func (c *TaskZapCore) Sync() error {
return nil
}
// ---- TaskLogManager ----
// rateLimitState 记录某个任务的速率限制状态
type rateLimitState struct {
lastWriteTime time.Time
suppressedCount int64
}
// TaskLogManager 任务日志管理器,为每个任务方法维护独立的日志文件
type TaskLogManager struct {
logDir string
retainDays int // 全局默认保留天数
// writer 相关(受 mu 保护)
mu sync.RWMutex
writers map[string]*lumberjack.Logger
taskRetainDays map[string]int // md5(taskMethod) -> 覆盖保留天数(0 = 使用全局 retainDays)
// 速率限制相关(受各自 mutex 保护)
policyMu sync.RWMutex
policies map[string]time.Duration // taskMethod -> 最小写入间隔(0 = 不限制)
rateMu sync.Mutex
rateStates map[string]*rateLimitState // taskMethod -> 速率限制状态
}
// GlobalTaskLogManager 全局任务日志管理器单例
var GlobalTaskLogManager *TaskLogManager
// InitGlobalTaskLogManager 初始化全局任务日志管理器
func InitGlobalTaskLogManager(logDir string, retainDays int) {
if retainDays <= 0 {
retainDays = 30
}
GlobalTaskLogManager = &TaskLogManager{
logDir: logDir,
retainDays: retainDays,
writers: make(map[string]*lumberjack.Logger),
taskRetainDays: make(map[string]int),
policies: make(map[string]time.Duration),
rateStates: make(map[string]*rateLimitState),
}
_ = os.MkdirAll(logDir, 0755)
}
// methodMD5 计算任务方法名的 MD5,用作文件名
func methodMD5(taskMethod string) string {
h := md5.New()
_, _ = io.WriteString(h, taskMethod)
return fmt.Sprintf("%x", h.Sum(nil))
}
// GetLogFilePath 根据任务方法名返回其日志文件的完整路径
func (m *TaskLogManager) GetLogFilePath(taskMethod string) string {
return filepath.Join(m.logDir, methodMD5(taskMethod)+".log")
}
// getEffectiveRetainDays 获取任务实际使用的保留天数(优先使用任务级别覆盖值)
func (m *TaskLogManager) getEffectiveRetainDays(key string) int {
if days, ok := m.taskRetainDays[key]; ok && days > 0 {
return days
}
return m.retainDays
}
// getWriter 获取或创建对应任务方法的 lumberjack 写入器(内部加锁)
func (m *TaskLogManager) getWriter(taskMethod string) *lumberjack.Logger {
key := methodMD5(taskMethod)
m.mu.RLock()
w, ok := m.writers[key]
m.mu.RUnlock()
if ok {
return w
}
m.mu.Lock()
defer m.mu.Unlock()
if w, ok = m.writers[key]; ok {
return w
}
logFilePath := filepath.Join(m.logDir, key+".log")
w = &lumberjack.Logger{
Filename: logFilePath,
MaxSize: 50,
MaxBackups: 10,
MaxAge: m.getEffectiveRetainDays(key),
Compress: false,
}
m.writers[key] = w
return w
}
// SetTaskRetainDays 为指定任务设置独立的日志保留天数(覆盖全局配置)
// days <= 0 表示使用全局默认值
func (m *TaskLogManager) SetTaskRetainDays(taskMethod string, days int) {
if m == nil {
return
}
key := methodMD5(taskMethod)
m.mu.Lock()
defer m.mu.Unlock()
if days > 0 {
m.taskRetainDays[key] = days
} else {
delete(m.taskRetainDays, key)
}
// 如果 writer 已存在,同步更新其 MaxAge
if w, ok := m.writers[key]; ok {
w.MaxAge = m.getEffectiveRetainDays(key)
}
}
// SetTaskLogPolicy 为指定任务方法设置最小写入间隔(速率限制)
// minInterval == 0 表示不限制;> 0 时 INFO/DEBUG 按间隔压缩,ERROR/WARN 仍直写
func (m *TaskLogManager) SetTaskLogPolicy(taskMethod string, minInterval time.Duration) {
if m == nil {
return
}
m.policyMu.Lock()
m.policies[taskMethod] = minInterval
m.policyMu.Unlock()
}
// getPolicy 获取任务的最小写入间隔(0 = 不限)
func (m *TaskLogManager) getPolicy(taskMethod string) time.Duration {
m.policyMu.RLock()
d := m.policies[taskMethod]
m.policyMu.RUnlock()
return d
}
// Log 写入一条任务日志
// ERROR / WARN 始终直接写入;INFO / DEBUG 受速率限制策略约束
func (m *TaskLogManager) Log(taskMethod, level, message string) {
if m == nil {
return
}
now := time.Now()
w := m.getWriter(taskMethod)
isHighPriority := level == "ERROR" || level == "WARN"
minInterval := m.getPolicy(taskMethod)
if minInterval <= 0 || isHighPriority {
// 高优先级日志:先把积压的压缩摘要冲出来,再写本条
if isHighPriority {
m.flushSuppressed(taskMethod, w, now)
}
_, _ = w.Write([]byte(formatLogLine(now, level, taskMethod, message)))
return
}
// INFO / DEBUG 受速率限制
m.rateMu.Lock()
state, ok := m.rateStates[taskMethod]
if !ok {
state = &rateLimitState{}
m.rateStates[taskMethod] = state
}
if state.lastWriteTime.IsZero() || now.Sub(state.lastWriteTime) >= minInterval {
suppressed := state.suppressedCount
state.lastWriteTime = now
state.suppressedCount = 0
m.rateMu.Unlock()
if suppressed > 0 {
summary := formatLogLine(now, "INFO", taskMethod,
fmt.Sprintf("(已压缩 %d 条日志)", suppressed))
_, _ = w.Write([]byte(summary))
}
_, _ = w.Write([]byte(formatLogLine(now, level, taskMethod, message)))
} else {
state.suppressedCount++
m.rateMu.Unlock()
}
}
// flushSuppressed 在写高优先级日志前,把积压的压缩摘要冲出来
func (m *TaskLogManager) flushSuppressed(taskMethod string, w *lumberjack.Logger, now time.Time) {
m.rateMu.Lock()
state, ok := m.rateStates[taskMethod]
if !ok || state.suppressedCount == 0 {
m.rateMu.Unlock()
return
}
suppressed := state.suppressedCount
state.suppressedCount = 0
state.lastWriteTime = now
m.rateMu.Unlock()
_, _ = w.Write([]byte(formatLogLine(now, "INFO", taskMethod,
fmt.Sprintf("(已压缩 %d 条日志)", suppressed))))
}
// formatLogLine 格式化一行日志
func formatLogLine(t time.Time, level, taskMethod, message string) string {
return fmt.Sprintf("[%s] [%s] [%s] %s\n",
t.Format("2006-01-02 15:04:05"),
level,
taskMethod,
message,
)
}
// LogResult 是 ReadLog 的返回值
type LogResult struct {
Content string `json:"content"`
NewOffset int64 `json:"new_offset"`
LogFile string `json:"log_file"`
FileSize int64 `json:"file_size"`
}
// ReadLog 读取任务日志
// offset=0 从头读取;offset>0 增量读取新增内容
func (m *TaskLogManager) ReadLog(taskMethod string, lines int, offset int64) (*LogResult, error) {
if m == nil {
return &LogResult{}, nil
}
logFilePath := m.GetLogFilePath(taskMethod)
f, err := os.Open(logFilePath)
if err != nil {
if os.IsNotExist(err) {
return &LogResult{LogFile: logFilePath}, nil
}
return nil, fmt.Errorf("打开日志文件失败: %w", err)
}
defer f.Close()
fi, err := f.Stat()
if err != nil {
return nil, fmt.Errorf("获取日志文件信息失败: %w", err)
}
fileSize := fi.Size()
if offset > fileSize {
offset = 0
}
if offset > 0 {
if offset == fileSize {
return &LogResult{NewOffset: fileSize, LogFile: logFilePath, FileSize: fileSize}, nil
}
_, err = f.Seek(offset, io.SeekStart)
if err != nil {
return nil, fmt.Errorf("定位日志文件失败: %w", err)
}
buf := make([]byte, fileSize-offset)
n, err := f.Read(buf)
if err != nil && err != io.EOF {
return nil, fmt.Errorf("读取日志文件失败: %w", err)
}
return &LogResult{
Content: string(buf[:n]),
NewOffset: offset + int64(n),
LogFile: logFilePath,
FileSize: fileSize,
}, nil
}
data, err := io.ReadAll(f)
if err != nil {
return nil, fmt.Errorf("读取日志文件失败: %w", err)
}
content := string(data)
if lines > 0 {
content = tailLines(content, lines)
}
return &LogResult{
Content: content,
NewOffset: fileSize,
LogFile: logFilePath,
FileSize: fileSize,
}, nil
}
// tailLines 返回文本最后 n 行
func tailLines(text string, n int) string {
if n <= 0 {
return text
}
lines := strings.Split(text, "\n")
if len(lines) > 0 && lines[len(lines)-1] == "" {
lines = lines[:len(lines)-1]
}
if len(lines) <= n {
return strings.Join(lines, "\n") + "\n"
}
return strings.Join(lines[len(lines)-n:], "\n") + "\n"
}
// ClearLog 清空指定任务的日志文件
func (m *TaskLogManager) ClearLog(taskMethod string) error {
if m == nil {
return nil
}
logFilePath := m.GetLogFilePath(taskMethod)
key := methodMD5(taskMethod)
m.mu.Lock()
if w, ok := m.writers[key]; ok {
_ = w.Close()
delete(m.writers, key)
}
m.mu.Unlock()
f, err := os.OpenFile(logFilePath, os.O_WRONLY|os.O_TRUNC|os.O_CREATE, 0644)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("清空日志文件失败: %w", err)
}
return f.Close()
}
// CleanExpiredLogs 扫描日志目录,删除超过各任务保留策略的过期备份文件
func (m *TaskLogManager) CleanExpiredLogs() {
if m == nil {
return
}
// 使用全局 retainDays 作为兜底清理基准(单个任务的短保留由 lumberjack MaxAge 自行处理)
cutoff := time.Now().AddDate(0, 0, -m.retainDays)
entries, err := os.ReadDir(m.logDir)
if err != nil {
return
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if !strings.HasSuffix(name, ".log") &&
!strings.HasSuffix(name, ".gz") &&
!strings.Contains(name, ".log-") {
continue
}
info, err := entry.Info()
if err != nil {
continue
}
if info.ModTime().Before(cutoff) {
_ = os.Remove(filepath.Join(m.logDir, name))
}
}
}
// UpdateRetainDays 动态更新全局默认保留天数,并刷新未被任务级别覆盖的 writer
func (m *TaskLogManager) UpdateRetainDays(days int) {
if m == nil || days <= 0 {
return
}
m.mu.Lock()
defer m.mu.Unlock()
m.retainDays = days
for key, w := range m.writers {
// 只更新没有任务级别覆盖的 writer
if _, hasOverride := m.taskRetainDays[key]; !hasOverride {
w.MaxAge = days
}
}
}