Files
dygou_go/internal/logger/logger.go
T
yml2213 99885c1ddd 优化抖动幅度和日志双时间显示
- jitterDuration 抖动从 base/3 降至 base/10,避免过大的随机延迟
- 日志新增斗鱼双时间显示:本地时间后追加 (D斗鱼时间),前端蓝色高亮
- 时间校准后自动同步斗鱼偏移量到日志模块
2026-06-27 16:39:23 +08:00

365 lines
9.6 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 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"
}
}