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:
bujinyuan
2026-04-23 16:53:30 +08:00
parent cdaa39d686
commit ea2bf96c2f
12 changed files with 567 additions and 108 deletions

View 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:]
}

View File

@@ -0,0 +1,44 @@
package logs
import (
"context"
"github.com/google/uuid"
)
type contextKey string
const traceIDKey contextKey = "trace_id"
// GenerateTraceID 生成唯一的链路追踪 IDUUID 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
}