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
This commit is contained in:
184
backend/go-service/pkg/logs/logger.go
Normal file
184
backend/go-service/pkg/logs/logger.go
Normal file
@@ -0,0 +1,184 @@
|
||||
// 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:]
|
||||
}
|
||||
44
backend/go-service/pkg/logs/trace.go
Normal file
44
backend/go-service/pkg/logs/trace.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package logs
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const traceIDKey contextKey = "trace_id"
|
||||
|
||||
// GenerateTraceID 生成唯一的链路追踪 ID(UUID v4)
|
||||
func GenerateTraceID() string {
|
||||
return uuid.New().String()
|
||||
}
|
||||
|
||||
// WithTraceID 将 trace_id 注入 context
|
||||
func WithTraceID(ctx context.Context, traceID string) context.Context {
|
||||
return context.WithValue(ctx, traceIDKey, traceID)
|
||||
}
|
||||
|
||||
// GetTraceID 从 context 提取 trace_id,不存在则返回空字符串
|
||||
func GetTraceID(ctx context.Context) string {
|
||||
if ctx == nil {
|
||||
return ""
|
||||
}
|
||||
if traceID, ok := ctx.Value(traceIDKey).(string); ok {
|
||||
return traceID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// DetachContext 剥离父 ctx 的 Deadline/Cancel 但保留 trace_id
|
||||
// 用于启动后台 goroutine 时避免随 HTTP 请求结束被取消,同时保持日志链路追踪连续性
|
||||
// 典型使用:go func() { ... }(logs.DetachContext(ctx))
|
||||
// Task 16 P1-3:后台 goroutine trace_id 保留 新增
|
||||
func DetachContext(ctx context.Context) context.Context {
|
||||
bg := context.Background()
|
||||
if traceID := GetTraceID(ctx); traceID != "" {
|
||||
return WithTraceID(bg, traceID)
|
||||
}
|
||||
return bg
|
||||
}
|
||||
Reference in New Issue
Block a user