- jitterDuration 抖动从 base/3 降至 base/10,避免过大的随机延迟 - 日志新增斗鱼双时间显示:本地时间后追加 (D斗鱼时间),前端蓝色高亮 - 时间校准后自动同步斗鱼偏移量到日志模块
365 lines
9.6 KiB
Go
365 lines
9.6 KiB
Go
package logger
|
||
|
||
import (
|
||
"fmt"
|
||
"io"
|
||
"log"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"sync"
|
||
"sync/atomic"
|
||
"time"
|
||
|
||
"douyu_go/internal/constants"
|
||
)
|
||
|
||
const (
|
||
asyncLogBufferSize = 8192
|
||
logTimeLayout = "2006/01/02 15:04:05.000"
|
||
logEntryTimeLayout = "2006-01-02 15:04:05.000"
|
||
)
|
||
|
||
// minLevel 日志最低等级,低于此等级的日志不输出。0=info, 1=warn, 2=error。
|
||
var minLevel atomic.Int32
|
||
|
||
var douyuOffset atomic.Int64
|
||
|
||
// SetMinLevel 设置日志最低显示等级。
|
||
func SetMinLevel(level string) {
|
||
minLevel.Store(int32(levelPriority(level)))
|
||
}
|
||
|
||
// SetDouyuOffset 设置斗鱼服务端时间与本地时间的偏移量,用于日志双时间显示。
|
||
func SetDouyuOffset(offset time.Duration) {
|
||
douyuOffset.Store(int64(offset))
|
||
}
|
||
|
||
func levelPriority(level string) int {
|
||
switch strings.ToLower(level) {
|
||
case "debug":
|
||
return -1
|
||
case "info":
|
||
return 0
|
||
case "warn":
|
||
return 1
|
||
case "error":
|
||
return 2
|
||
default:
|
||
return 0
|
||
}
|
||
}
|
||
|
||
// AsyncLogWriter 异步日志写入器,队列满时丢弃日志以保护主链路。
|
||
type AsyncLogWriter struct {
|
||
dst io.Writer
|
||
ch chan []byte
|
||
wg sync.WaitGroup
|
||
closed uint32
|
||
dropped uint64
|
||
}
|
||
|
||
// NewAsyncLogWriter 创建异步日志写入器。
|
||
func NewAsyncLogWriter(dst io.Writer, buffer int) *AsyncLogWriter {
|
||
if buffer <= 0 {
|
||
buffer = 1024
|
||
}
|
||
w := &AsyncLogWriter{
|
||
dst: dst,
|
||
ch: make(chan []byte, buffer),
|
||
}
|
||
w.wg.Add(1)
|
||
go w.run()
|
||
return w
|
||
}
|
||
|
||
// Write 异步写日志;队列满时丢弃,避免抢购请求被磁盘 I/O 阻塞。
|
||
func (w *AsyncLogWriter) Write(p []byte) (int, error) {
|
||
if w == nil {
|
||
return len(p), nil
|
||
}
|
||
if atomic.LoadUint32(&w.closed) == 1 {
|
||
return len(p), nil
|
||
}
|
||
buf := append([]byte(nil), p...)
|
||
select {
|
||
case w.ch <- buf:
|
||
default:
|
||
atomic.AddUint64(&w.dropped, 1)
|
||
}
|
||
return len(p), nil
|
||
}
|
||
|
||
func (w *AsyncLogWriter) run() {
|
||
defer w.wg.Done()
|
||
for p := range w.ch {
|
||
_, _ = w.dst.Write(p)
|
||
}
|
||
if dropped := atomic.LoadUint64(&w.dropped); dropped > 0 {
|
||
_, _ = fmt.Fprintln(w.dst, formatLogLine(time.Now(), "日志", "warn", "", fmt.Sprintf("日志队列已丢弃 %d 条,已优先保护抢购链路", dropped)))
|
||
}
|
||
}
|
||
|
||
// Close 关闭写入器,等待队列清空。
|
||
func (w *AsyncLogWriter) Close() error {
|
||
if w == nil {
|
||
return nil
|
||
}
|
||
if atomic.CompareAndSwapUint32(&w.closed, 0, 1) {
|
||
close(w.ch)
|
||
w.wg.Wait()
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// logFileWriter 同时写入文件、内存和终端。
|
||
// 终端写入失败时静默忽略(Windows GUI 应用无控制台)。
|
||
type logFileWriter struct {
|
||
file *os.File
|
||
store *MemoryLogStore
|
||
}
|
||
|
||
func (w *logFileWriter) Write(p []byte) (int, error) {
|
||
// 写入文件
|
||
if _, err := w.file.Write(p); err != nil {
|
||
return 0, err
|
||
}
|
||
// 写入内存
|
||
if _, err := w.store.Write(p); err != nil {
|
||
return 0, err
|
||
}
|
||
// 尝试写入终端(失败时忽略,Windows GUI 应用无控制台)
|
||
_, _ = os.Stdout.Write(p)
|
||
return len(p), nil
|
||
}
|
||
|
||
// InitLogging 初始化日志输出:日志文件和内存缓冲同时写入。
|
||
// 终端输出通过 logFileWriter 同步写入(Windows GUI 应用无控制台时自动跳过)。
|
||
func InitLogging(store *MemoryLogStore, logDir string) func() {
|
||
log.SetFlags(0)
|
||
log.SetPrefix("")
|
||
|
||
if logDir == "" {
|
||
logDir = constants.DefaultLogDir
|
||
}
|
||
|
||
// 解析为绝对路径,避免打包后工作目录不同导致日志写到意外位置
|
||
logDir = resolveAbsolutePath(logDir)
|
||
|
||
if err := os.MkdirAll(logDir, 0755); err != nil {
|
||
async := NewAsyncLogWriter(store, asyncLogBufferSize)
|
||
log.SetOutput(async)
|
||
LogEvent("日志", "warn", "日志目录创建失败,仅写入内存: %v", err)
|
||
return func() { _ = async.Close() }
|
||
}
|
||
|
||
filePath := filepath.Join(logDir, "douyu_"+time.Now().In(constants.CST).Format("20060102_150405")+".log")
|
||
file, err := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
||
if err != nil {
|
||
async := NewAsyncLogWriter(store, asyncLogBufferSize)
|
||
log.SetOutput(async)
|
||
LogEvent("日志", "warn", "日志文件创建失败,仅写入内存: %v", err)
|
||
return func() { _ = async.Close() }
|
||
}
|
||
|
||
store.SetFilePath(filePath)
|
||
// 使用 logFileWriter 同时写入文件、内存和终端(终端失败时静默忽略)
|
||
multi := &logFileWriter{file: file, store: store}
|
||
async := NewAsyncLogWriter(multi, asyncLogBufferSize)
|
||
log.SetOutput(async)
|
||
LogEvent("日志", "success", "日志文件已启用: %s", filePath)
|
||
return func() {
|
||
_ = async.Close()
|
||
_ = file.Close()
|
||
}
|
||
}
|
||
|
||
// LogEvent 输出带分类和级别的结构化文本日志。低于当前最低等级的日志会被丢弃。
|
||
func LogEvent(category, level, format string, args ...any) {
|
||
logStructuredEvent(category, level, "", format, args...)
|
||
}
|
||
|
||
// LogAccountEvent 输出带账号标签的结构化日志,便于前端按账号观察抢购链路。
|
||
func LogAccountEvent(category, level, account, format string, args ...any) {
|
||
logStructuredEvent(category, level, account, format, args...)
|
||
}
|
||
|
||
func logStructuredEvent(category, level, account, format string, args ...any) {
|
||
if category == "" {
|
||
category = "系统"
|
||
}
|
||
if level == "" {
|
||
level = "info"
|
||
}
|
||
if levelPriority(level) < int(minLevel.Load()) {
|
||
return
|
||
}
|
||
log.Print(formatLogLine(time.Now(), category, level, account, fmt.Sprintf(format, args...)))
|
||
}
|
||
|
||
func formatLogLine(now time.Time, category, level, account, message string) string {
|
||
ts := now.In(constants.CST).Format(logTimeLayout)
|
||
offset := time.Duration(douyuOffset.Load())
|
||
var prefix string
|
||
if offset != 0 {
|
||
dyTime := now.Add(offset)
|
||
prefix = fmt.Sprintf("%s(D%02d:%02d:%02d.%03d) ", ts,
|
||
dyTime.Hour(), dyTime.Minute(), dyTime.Second(), dyTime.Nanosecond()/1e6)
|
||
} else {
|
||
prefix = ts + " "
|
||
}
|
||
if account != "" {
|
||
return fmt.Sprintf("%s[%s][%s][账号:%s] %s", prefix, category, level, account, message)
|
||
}
|
||
return fmt.Sprintf("%s[%s][%s] %s", prefix, category, level, message)
|
||
}
|
||
|
||
// LogMeta 是从日志文本中解析出的结构化信息。
|
||
type LogMeta struct {
|
||
Time string
|
||
Category string
|
||
Level string
|
||
Account string
|
||
}
|
||
|
||
// ParseLogLine 从标准日志行中提取毫秒时间、分类、级别和账号。
|
||
func ParseLogLine(line string) LogMeta {
|
||
meta := LogMeta{
|
||
Time: parseLogTime(line),
|
||
}
|
||
|
||
idx := strings.Index(line, "[")
|
||
if idx < 0 {
|
||
meta.Account = parseAccountFromText(line)
|
||
return meta
|
||
}
|
||
category, rest, ok := cutBracket(line[idx:])
|
||
if !ok {
|
||
meta.Account = parseAccountFromText(line)
|
||
return meta
|
||
}
|
||
level, rest, ok := cutBracket(rest)
|
||
if !ok || !IsLogLevel(level) {
|
||
meta.Category = category
|
||
meta.Account = parseAccountFromText(line)
|
||
return meta
|
||
}
|
||
|
||
meta.Category = category
|
||
meta.Level = level
|
||
rest = strings.TrimLeft(rest, " \t")
|
||
tag, _, ok := cutBracket(rest)
|
||
if ok && strings.HasPrefix(tag, "账号:") {
|
||
meta.Account = strings.TrimSpace(strings.TrimPrefix(tag, "账号:"))
|
||
}
|
||
if meta.Account == "" {
|
||
meta.Account = parseAccountFromText(line)
|
||
}
|
||
return meta
|
||
}
|
||
|
||
func parseLogTime(line string) string {
|
||
if len(line) < len(logTimeLayout) {
|
||
return ""
|
||
}
|
||
raw := line[:len(logTimeLayout)]
|
||
t, err := time.ParseInLocation(logTimeLayout, raw, constants.CST)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
return t.Format(logEntryTimeLayout)
|
||
}
|
||
|
||
func parseAccountFromText(line string) string {
|
||
for _, marker := range []string{"账号=", "账号:"} {
|
||
idx := strings.Index(line, marker)
|
||
if idx < 0 {
|
||
continue
|
||
}
|
||
start := idx + len(marker)
|
||
rest := strings.TrimSpace(line[start:])
|
||
end := strings.IndexFunc(rest, func(r rune) bool {
|
||
return r == ' ' || r == '\t' || r == ']' || r == ',' || r == ',' || r == '|' || r == ';' || r == ';'
|
||
})
|
||
if end >= 0 {
|
||
rest = rest[:end]
|
||
}
|
||
return strings.TrimSpace(rest)
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// ParseLogMeta 从标准日志行中提取类似 [网络][warn] 的分类和级别。
|
||
func ParseLogMeta(line string) (string, string) {
|
||
meta := ParseLogLine(line)
|
||
return meta.Category, meta.Level
|
||
}
|
||
|
||
func cutBracket(s string) (string, string, bool) {
|
||
if !strings.HasPrefix(s, "[") {
|
||
return "", s, false
|
||
}
|
||
end := strings.Index(s, "]")
|
||
if end <= 1 {
|
||
return "", s, false
|
||
}
|
||
return s[1:end], s[end+1:], true
|
||
}
|
||
|
||
// IsLogLevel 判断是否为有效日志级别。
|
||
func IsLogLevel(level string) bool {
|
||
switch strings.ToLower(level) {
|
||
case "success", "error", "warn", "info", "debug":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
// LogDebug 输出 debug 级别日志,仅在日志等级设为 debug 时可见。
|
||
func LogDebug(category, format string, args ...any) {
|
||
LogEvent(category, "debug", format, args...)
|
||
}
|
||
|
||
// resolveAbsolutePath 将相对路径解析为绝对路径。
|
||
// 如果路径已经是绝对路径则直接返回;否则基于可执行文件所在目录解析。
|
||
func resolveAbsolutePath(path string) string {
|
||
if filepath.IsAbs(path) {
|
||
return path
|
||
}
|
||
// 先尝试基于当前工作目录解析
|
||
if abs, err := filepath.Abs(path); err == nil {
|
||
return abs
|
||
}
|
||
// 兜底:基于可执行文件所在目录
|
||
if exe, err := os.Executable(); err == nil {
|
||
return filepath.Join(filepath.Dir(exe), path)
|
||
}
|
||
return path
|
||
}
|
||
|
||
// DetectLogLevel 根据日志文本粗略分类,方便前端染色。
|
||
func DetectLogLevel(line string) string {
|
||
lower := strings.ToLower(line)
|
||
switch {
|
||
case strings.Contains(lower, "[debug]"):
|
||
return "debug"
|
||
case strings.Contains(lower, "[success]"):
|
||
return "success"
|
||
case strings.Contains(lower, "[error]"):
|
||
return "error"
|
||
case strings.Contains(lower, "[warn]"):
|
||
return "warn"
|
||
case strings.Contains(lower, "[info]"):
|
||
return "info"
|
||
case strings.Contains(line, "成功"):
|
||
return "success"
|
||
case strings.Contains(line, "失败") || strings.Contains(line, "错误") || strings.Contains(lower, "error") || strings.Contains(lower, "panic"):
|
||
return "error"
|
||
case strings.Contains(line, "降级") || strings.Contains(line, "跳过") || strings.Contains(line, "不存在") || strings.Contains(lower, "warn"):
|
||
return "warn"
|
||
default:
|
||
return "info"
|
||
}
|
||
}
|