Files
EchoChat/backend/go-service/pkg/logs/logger.go
bujinyuan ea2bf96c2f fix(meeting): Task 16 修复 code-reviewer 审计 P1 八项
按 docs/reviews/2026-04-23-phase2e-2-code-review.md 清单落地 P1 批次:

- P1-1 主持人转让原子性:MeetingParticipantDAO.TransferHost 内部事务
  合并 meeting_rooms.host_id 更新,service/lifecycle 移除冗余 UpdateHost
- P1-2 ListChatMessages / ListMyMeetings 回归 DAO:新增
  MeetingChatDAO.ListByRoomBefore 反向游标,service 移除 s.db 直查
- P1-3 ListMyMeetings N+1 放大优化:新增
  MeetingParticipantDAO.ListJoinedRoomsByUser JOIN + DISTINCT 单次 SQL
- P1-4 EndRoom 行锁 + 事务:s.db.Transaction 包裹
  SELECT FOR UPDATE → 快照 → MarkEnded → LeaveAllActive;DAO 补
  WithTx(*gorm.DB) 和 GetByIDForUpdate
- P1-5 后台 goroutine trace_id 保留:新增 logs.DetachContext(ctx);
  替换 meeting_service / meeting_signal_service 共 10 处 context.Background();
  meeting_ws_handler 每条 WS 消息分配独立 trace_id
- P1-6 _broadcastSelfState 重试:最多 2 次指数退避(700ms→2100ms),
  检测 localAudioEnabled/localVideoEnabled 已被后续动作覆盖时放弃旧 patch
- P1-7 HandleHostGraceExpired / HandleEmptyRoomExpired 处理锁:
  独立 host_grace_handling:<code> / empty_ttl_handling:<code> SETNX + 60s TTL,
  消除 Redis 自然过期 + 本地 timer 到点 + DEL 返回 0 的盲区
- P1-8 pushExistingRoomState 同步化:OnRoomJoin 返回前完成补推,
  消除与 REST member.joined 并行造成的前端状态闪烁

并附带修复 .gitignore 中 logs/ 规则误伤 pkg/logs/ 代码目录的历史遗留问题,
将 logger.go / trace.go 正式纳入版本控制。

验证:backend go build + go vet 通过;frontend npm run build:h5 通过。

Made-with: Cursor
2026-04-23 16:53:30 +08:00

185 lines
5.3 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 logs 提供基于 zap 的结构化日志封装
// 支持从 context 中提取 trace_id 自动附加到每条日志
// 开发环境输出彩色可读文本,生产环境输出 JSON 结构化格式
// 支持日志文件轮转(按大小切割、自动归档、过期清理)
package logs
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/echochat/backend/config"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"gopkg.in/natefinch/lumberjack.v2"
)
var globalLogger *zap.Logger
// Init 初始化全局日志实例
// 根据 LogConfig 配置决定输出目标:控制台、文件、或两者同时输出
func Init(cfg *config.LogConfig) error {
lvl := parseLevel(cfg.Level)
// 控制台 encoder始终保留控制台输出
consoleEncoder := buildEncoder(cfg.Format)
var cores []zapcore.Core
// 控制台输出(始终启用)
consoleSyncer := zapcore.AddSync(os.Stdout)
cores = append(cores, zapcore.NewCore(consoleEncoder, consoleSyncer, lvl))
// 文件输出(按配置启用)
if cfg.File.Enable && cfg.File.Dir != "" {
if err := os.MkdirAll(cfg.File.Dir, 0755); err != nil {
return fmt.Errorf("创建日志目录失败 [%s]: %w", cfg.File.Dir, err)
}
// 应用日志文件(全量日志)
allLogWriter := &lumberjack.Logger{
Filename: filepath.Join(cfg.File.Dir, "app.log"),
MaxSize: cfg.File.MaxSize,
MaxBackups: cfg.File.MaxBackups,
MaxAge: cfg.File.MaxAge,
Compress: cfg.File.Compress,
LocalTime: true,
}
// 错误日志文件(仅 WARN 及以上),便于快速定位问题
errorLogWriter := &lumberjack.Logger{
Filename: filepath.Join(cfg.File.Dir, "error.log"),
MaxSize: cfg.File.MaxSize,
MaxBackups: cfg.File.MaxBackups,
MaxAge: cfg.File.MaxAge,
Compress: cfg.File.Compress,
LocalTime: true,
}
// 文件始终用 JSON 格式,便于后期接入 ELK/Loki
fileEncoder := buildEncoder("json")
cores = append(cores,
zapcore.NewCore(fileEncoder, zapcore.AddSync(allLogWriter), lvl),
zapcore.NewCore(fileEncoder, zapcore.AddSync(errorLogWriter), zap.WarnLevel),
)
}
core := zapcore.NewTee(cores...)
globalLogger = zap.New(core, zap.AddCaller(), zap.AddCallerSkip(1))
return nil
}
func buildEncoder(format string) zapcore.Encoder {
if format == "json" {
encoderConfig := zap.NewProductionEncoderConfig()
encoderConfig.TimeKey = "ts"
encoderConfig.EncodeTime = func(t time.Time, enc zapcore.PrimitiveArrayEncoder) {
enc.AppendString(t.Format("2006-01-02 15:04:05"))
}
encoderConfig.EncodeLevel = zapcore.CapitalLevelEncoder
return zapcore.NewJSONEncoder(encoderConfig)
}
encoderConfig := zap.NewDevelopmentEncoderConfig()
encoderConfig.EncodeTime = func(t time.Time, enc zapcore.PrimitiveArrayEncoder) {
enc.AppendString(t.Format("2006-01-02 15:04:05"))
}
encoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder
return zapcore.NewConsoleEncoder(encoderConfig)
}
// Debug 输出 DEBUG 级别日志
func Debug(ctx context.Context, funcName, msg string, fields ...zap.Field) {
globalLogger.Debug(msg, withContext(ctx, funcName, fields)...)
}
// Info 输出 INFO 级别日志
func Info(ctx context.Context, funcName, msg string, fields ...zap.Field) {
globalLogger.Info(msg, withContext(ctx, funcName, fields)...)
}
// Warn 输出 WARN 级别日志
func Warn(ctx context.Context, funcName, msg string, fields ...zap.Field) {
globalLogger.Warn(msg, withContext(ctx, funcName, fields)...)
}
// Error 输出 ERROR 级别日志
func Error(ctx context.Context, funcName, msg string, fields ...zap.Field) {
globalLogger.Error(msg, withContext(ctx, funcName, fields)...)
}
// Fatal 输出 FATAL 级别日志并退出进程
func Fatal(ctx context.Context, funcName, msg string, fields ...zap.Field) {
globalLogger.Fatal(msg, withContext(ctx, funcName, fields)...)
}
// Sync 刷新日志缓冲区,应在程序退出时调用
func Sync() {
if globalLogger != nil {
_ = globalLogger.Sync()
}
}
// withContext 从 context 提取 trace_id 和函数名,合并到日志字段中
func withContext(ctx context.Context, funcName string, fields []zap.Field) []zap.Field {
traceID := GetTraceID(ctx)
result := make([]zap.Field, 0, len(fields)+2)
if traceID != "" {
result = append(result, zap.String("trace_id", traceID))
}
if funcName != "" {
result = append(result, zap.String("func", funcName))
}
result = append(result, fields...)
return result
}
func parseLevel(level string) zapcore.Level {
switch strings.ToLower(level) {
case "debug":
return zapcore.DebugLevel
case "info":
return zapcore.InfoLevel
case "warn":
return zapcore.WarnLevel
case "error":
return zapcore.ErrorLevel
default:
return zapcore.InfoLevel
}
}
// MaskEmail 邮箱脱敏zh***@example.com
func MaskEmail(email string) string {
at := strings.Index(email, "@")
if at <= 0 {
return "***"
}
prefix := email[:at]
if len(prefix) <= 2 {
return prefix[:1] + "***" + email[at:]
}
return prefix[:2] + "***" + email[at:]
}
// MaskPhone 手机号脱敏138****8000
func MaskPhone(phone string) string {
if len(phone) < 7 {
return "***"
}
return phone[:3] + "****" + phone[len(phone)-4:]
}
// MaskToken Token 脱敏:只显示前后各 4 位
func MaskToken(token string) string {
if len(token) <= 8 {
return "***"
}
return token[:4] + "..." + token[len(token)-4:]
}