Files
EchoChat/backend/go-service/config/config.go
bujinyuan 3b83c79036 feat(phase2e-2): 落地会议生命周期状态机 + Router 幂等双层防御(Task 8)
核心交付:
- 新建 MeetingLifecycleService(6 钩子 + sync.Map 本地 timer + Redis key 双保险 + RescheduleFromRedis)
- 新建 MeetingCleanupTask(启动重建 timer + 每 N 秒扫 host_grace/empty_ttl 兜底 + 4h stale active 回收)
- MediaOrchestrator 新增 ResolveRouterID;HTTPMediaOrchestrator.CreateRouter 入口 sync.Map 幂等防御
- 业务层 JoinRoom 移除 CreateRouter 调用改走 CancelEmptyTTL + ResolveRouterID;LeaveRoom 空房分支改调 OnAllMembersLeft 不再立即销毁
- MeetingSignalService 新增 OnWSDisconnect 实现 ws.MeetingDisconnectHook;OnRoomJoin 追加 host 重连钩子
- ws.handler 定义 MeetingDisconnectHook 接口 + SetMeetingDisconnectHook,解耦 ws→meeting 反向依赖
- config 新增 MeetingConfig{HostGrace=120, EmptyRoomTTL=300, CleanupInterval=30, StaleRoomHours=4}

关键设计决策:
- Redis key TTL = 业务时长 + max(CleanupIntervalSeconds*2, 30s) buffer:避免本地 timer 与
  Redis 自动过期同步到期导致 DEL 返回 0 被误判为"已被其他路径处理"而跳过业务逻辑
- Router 幂等双层防御(决策 q2_router_dedup=a2_both):业务层不重复调 + HTTP 层 sync.Map 命中直接返回
- 普通成员 WS 断开仅清 media 资源不动 participant 表(决策 q1_nonhost_disconnect=a1_keep_current)

E2E 验证:docs/verify/meeting_t8_verify.mjs PASS=20 FAIL=0,覆盖 5 场景:
- S1 host 宽限期过期自动转让(meeting.host.changed + DB host_id 更新)
- S2 宽限期内重连保留身份
- S3 empty_ttl 期内新成员加入复活房间
- S4 empty_ttl 过期 → 房间 Ended + 新 join 被拒
- S5 CreateRoom +1 Router / JoinRoom 不再创建新 Router(通过 media-server /internal/info stats.routers 断言)

media-server:/internal/info 响应追加 stats.routers + routers[] 供 E2E 断言 Router 幂等

文档同步:
- docs/progress/CURRENT_STATUS.md 头部 + 新增 Task 8 交付条目
- docs/plans/2026-04-21-phase2e-2-implementation.plan.md Task 8 标记完成 + 实际产出/决策/验证
- docs/api/frontend/meeting.md 补充 host.changed.auto_reason / room.ended.reason=system_error / 空房 TTL 复活语义 + Task 8 验证记录
- docs/architecture/system-architecture.md meeting 模块职责补充"会议生命周期状态机"
- .cursor/rules/project-context.mdc 追加 Task 8 条目并更新 Phase 2e-2 进度(Task 0-8 )

Made-with: Cursor
2026-04-21 18:21:04 +08:00

142 lines
5.8 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 config 提供应用配置管理功能
// 使用 Viper 读取 YAML 配置文件,支持环境变量覆盖
package config
import (
"fmt"
"strings"
"github.com/spf13/viper"
)
// Config 应用全局配置结构体
type Config struct {
Server ServerConfig `mapstructure:"server"`
Database DatabaseConfig `mapstructure:"database"`
Redis RedisConfig `mapstructure:"redis"`
JWT JWTConfig `mapstructure:"jwt"`
Log LogConfig `mapstructure:"log"`
Minio MinioConfig `mapstructure:"minio"`
MediaServer MediaServerConfig `mapstructure:"media_server"`
Meeting MeetingConfig `mapstructure:"meeting"`
}
// MeetingConfig 会议模块生命周期参数Phase 2e-2 Task 8
// 所有字段均允许通过环境变量 ECHOCHAT_MEETING_* 覆盖,便于 E2E 测试以短时长加速场景触发
type MeetingConfig struct {
HostGraceSeconds int `mapstructure:"host_grace_seconds"` // host 掉线宽限期秒数,默认 120
EmptyRoomTTLSeconds int `mapstructure:"empty_room_ttl_seconds"` // 空房自动销毁 TTL 秒数,默认 300
CleanupIntervalSeconds int `mapstructure:"cleanup_interval_seconds"` // 兜底扫描周期秒数,默认 30
StaleRoomHours int `mapstructure:"stale_room_hours"` // 活跃超过此小时且无成员视为 stale默认 4
}
// MediaServerConfig Node media-server 接入配置Phase 2e-2 Task 7
// 与 media-server/.env 中的 MEDIA_INTERNAL_TOKEN / HTTP_PORT 成对使用
// BaseURL 需精确到协议与端口http://host:port不含末尾斜杠
type MediaServerConfig struct {
BaseURL string `mapstructure:"base_url"` // 如 http://localhost:3300
InternalToken string `mapstructure:"internal_token"` // 与 Node 共享密钥
TimeoutMS int `mapstructure:"timeout_ms"` // 创建类接口超时(毫秒),默认 5000
CloseTimeoutMS int `mapstructure:"close_timeout_ms"` // 关闭类接口超时(毫秒),默认 2000
CloseRetry int `mapstructure:"close_retry"` // 关闭类接口失败重试次数,默认 2
}
// MinioConfig MinIO 对象存储配置
type MinioConfig struct {
Endpoint string `mapstructure:"endpoint"` // MinIO 服务地址host:port
AccessKey string `mapstructure:"access_key"` // 访问密钥
SecretKey string `mapstructure:"secret_key"` // 密钥
Bucket string `mapstructure:"bucket"` // 存储桶名称
UseSSL bool `mapstructure:"use_ssl"` // 是否使用 HTTPS
}
// ServerConfig HTTP 服务配置
type ServerConfig struct {
Port int `mapstructure:"port"` // 监听端口
Mode string `mapstructure:"mode"` // 运行模式: debug/release
}
// DatabaseConfig PostgreSQL 数据库配置
type DatabaseConfig struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
User string `mapstructure:"user"`
Password string `mapstructure:"password"`
DBName string `mapstructure:"dbname"`
SSLMode string `mapstructure:"sslmode"`
MaxIdleConns int `mapstructure:"max_idle_conns"` // 最大空闲连接数
MaxOpenConns int `mapstructure:"max_open_conns"` // 最大打开连接数
}
// DSN 生成 PostgreSQL 连接字符串
func (d *DatabaseConfig) DSN() string {
return fmt.Sprintf(
"host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
d.Host, d.Port, d.User, d.Password, d.DBName, d.SSLMode,
)
}
// RedisConfig Redis 配置
type RedisConfig struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
Password string `mapstructure:"password"`
DB int `mapstructure:"db"` // 数据库编号
}
// Addr 生成 Redis 连接地址
func (r *RedisConfig) Addr() string {
return fmt.Sprintf("%s:%d", r.Host, r.Port)
}
// JWTConfig JWT 认证配置
type JWTConfig struct {
Secret string `mapstructure:"secret"` // 签名密钥
AccessExpireMin int `mapstructure:"access_expire_min"` // Access Token 有效期(分钟)
RefreshExpireDay int `mapstructure:"refresh_expire_day"` // Refresh Token 有效期(天)
Issuer string `mapstructure:"issuer"` // 签发者
}
// LogConfig 日志配置
type LogConfig struct {
Level string `mapstructure:"level"` // debug/info/warn/error
Format string `mapstructure:"format"` // text(开发)/json(生产)
OutputPath string `mapstructure:"output_path"` // stdout 或文件路径
File LogFileConfig `mapstructure:"file"` // 日志文件轮转配置
}
// LogFileConfig 日志文件轮转配置(基于 lumberjack
type LogFileConfig struct {
Enable bool `mapstructure:"enable"` // 是否启用文件日志
Dir string `mapstructure:"dir"` // 日志文件目录
MaxSize int `mapstructure:"max_size"` // 单个文件最大大小MB超过后自动切割
MaxBackups int `mapstructure:"max_backups"` // 保留的旧日志文件最大数量
MaxAge int `mapstructure:"max_age"` // 旧日志文件保留天数
Compress bool `mapstructure:"compress"` // 是否压缩归档的旧日志文件
}
// Load 加载配置文件并返回 Config 实例
// configPath 为配置文件所在目录configName 为文件名(不含扩展名)
func Load(configPath, configName string) (*Config, error) {
v := viper.New()
v.SetConfigName(configName)
v.SetConfigType("yaml")
v.AddConfigPath(configPath)
// 环境变量覆盖ECHOCHAT_SERVER_PORT → server.port
v.SetEnvPrefix("ECHOCHAT")
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
v.AutomaticEnv()
if err := v.ReadInConfig(); err != nil {
return nil, fmt.Errorf("读取配置文件失败: %w", err)
}
var cfg Config
if err := v.Unmarshal(&cfg); err != nil {
return nil, fmt.Errorf("解析配置文件失败: %w", err)
}
return &cfg, nil
}