//! 网关配置(env 加载,凭证不进 git) //! //! 配置来源:环境变量 > 默认值(gateway.toml 文件加载为后续增强,当前未实现)。 //! u-relay token 与桌面端 df-tunnel 同源(env U_RELAY_TOKEN)。 use serde::Deserialize; /// 网关全局配置 #[derive(Debug, Clone, Deserialize)] pub struct GatewayConfig { /// u-relay WS 地址(生产 u-relay.1216.top,本地 http://127.0.0.1:9180) pub relay_url: String, /// relay 配对 token(env U_RELAY_TOKEN 同源) pub relay_token: String, /// channel 路由表:IM 侧默认走 default_channel;按前缀路由的多桌面端配置(预留项 ⑥) #[serde(default)] pub channels: Vec, /// 飞书应用凭证(未配置则该平台禁用) #[serde(default)] pub feishu: Option, /// TextDelta 节流窗口毫秒(默认 800,IM 频控友好) #[serde(default = "default_throttle_ms")] pub throttle_ms: u64, /// 用户白名单(空=放行所有,生产部署务必显式配置) /// /// 注:历史注释「空=拒绝所有」与 main.rs 实现(空=放行)矛盾,走查 2026-10-04 定为 /// 空=放行——否则首次启动零配置时飞书/Web 全部拒绝,连 banner 警告都没有,反而 /// 更难发现配置缺失。生产风险由 main.rs 启动 banner 常驻警告 + 白名单缺失提示兜底。 #[serde(default)] pub whitelist: Vec, } /// channel 路由项:IM 指令前缀 → 桌面端 channel #[derive(Debug, Clone, Deserialize)] pub struct ChannelRoute { /// 指令前缀(如 "/dev2 xxx" 路由到 chan-home);空串=默认 channel pub prefix: String, /// u-relay channel_id(桌面端 df-tunnel 配置的同一 channel) pub channel_id: String, } /// 飞书企业自建应用凭证 #[derive(Debug, Clone, Deserialize)] pub struct FeishuConfig { pub app_id: String, pub app_secret: String, /// 长连接模式事件订阅(免公网回调;当前恒 true,保留显式开关供回调模式扩展) #[allow(dead_code)] pub long_connection: bool, } fn default_throttle_ms() -> u64 { 800 } impl GatewayConfig { /// 从 env + 可选 toml 文件构建。缺失关键项(relay_url/token)返回 Err。 pub fn load() -> anyhow::Result { let relay_url = std::env::var("GATEWAY_RELAY_URL") .unwrap_or_else(|_| "ws://127.0.0.1:9180/ws/commander".to_string()); let relay_token = std::env::var("GATEWAY_RELAY_TOKEN") .or_else(|_| std::env::var("U_RELAY_TOKEN")) .map_err(|_| anyhow::anyhow!("缺少 GATEWAY_RELAY_TOKEN / U_RELAY_TOKEN 环境变量"))?; let feishu = match ( std::env::var("FEISHU_APP_ID"), std::env::var("FEISHU_APP_SECRET"), ) { (Ok(app_id), Ok(app_secret)) => Some(FeishuConfig { app_id, app_secret, long_connection: true, }), _ => None, }; let whitelist = std::env::var("GATEWAY_WHITELIST") .map(|v| v.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect()) .unwrap_or_default(); let default_channel = std::env::var("GATEWAY_DEFAULT_CHANNEL").unwrap_or_default(); let channels = if default_channel.is_empty() { Vec::new() } else { vec![ChannelRoute { prefix: String::new(), channel_id: default_channel }] }; Ok(Self { relay_url, relay_token, channels, feishu, throttle_ms: default_throttle_ms(), whitelist, }) } /// 查指令应路由到的 channel(前缀匹配,空前缀=兜底默认) pub fn route(&self, command_text: &str) -> Option<&str> { // 精确前缀优先,空前缀兜底 self.channels .iter() .find(|c| !c.prefix.is_empty() && command_text.starts_with(&c.prefix)) .map(|c| c.channel_id.as_str()) .or_else(|| self.channels.iter().find(|c| c.prefix.is_empty()).map(|c| c.channel_id.as_str())) } } #[cfg(test)] mod tests { use super::*; #[test] fn route_prefix_priority() { let cfg = GatewayConfig { relay_url: String::new(), relay_token: String::new(), channels: vec![ ChannelRoute { prefix: "/dev2".into(), channel_id: "chan-home".into() }, ChannelRoute { prefix: String::new(), channel_id: "chan-work".into() }, ], feishu: None, throttle_ms: 800, whitelist: vec![], }; assert_eq!(cfg.route("/dev2 修个 bug"), Some("chan-home")); assert_eq!(cfg.route("普通指令"), Some("chan-work")); assert_eq!(cfg.route("/dev2"), Some("chan-home")); } #[test] fn route_empty_when_no_channels() { let cfg = GatewayConfig { relay_url: String::new(), relay_token: String::new(), channels: vec![], feishu: None, throttle_ms: 800, whitelist: vec![], }; assert_eq!(cfg.route("任何"), None); } }