package httpapi import ( "bytes" "log/slog" "net" "net/http" "sort" "strings" ) // 大屏控制页要把「当前设备的局域网地址」显示成可点链接 + 二维码:面板自己往往是从 // 127.0.0.1 打开的,浏览器里的 location.host 对平板毫无用处,所以地址只能由服务端枚举网卡得到。 // // 路径也随响应一起下发:/ → index.html、/display → display.html、/admin → admin.html 的映射 // 写在 server.go 的 staticHandler 里。前端各自硬编码一份,改前端产物名时就会有两处要跟着动。 const ( displayPagePath = "/display" adminPagePath = "/admin" ) // lanAddress 是一个「局域网里其他设备能连上这台机器」的候选地址。 // // family 目前恒为 ipv4:IPv6 要写成 http://[fe80::1%eth0]:port/display,方括号与 %zone // 粘到平板浏览器里出错率高,而现场本来就是 IPv4 内网。留着这个字段是为了以后真要补 IPv6 时 // 前端不必改结构(见 06/07 文档里的前端约定)。 type lanAddress struct { IP string `json:"ip"` Interface string `json:"interface"` Family string `json:"family"` // Confidence 说明这条地址是怎么来的、有多可信: // - high:严格轮从网卡枚举里挑出来的(接口 up 位读得到); // - low :宽松轮兜底捡回来的(网卡 up 位读不到,见 lanAddresses); // - route:网卡枚举一分钱都没给,靠系统默认路由推断出来的源地址(第三层来源)。 // 老版本前端会直接忽略这个字段,加上不会破坏契约。 Confidence string `json:"confidence"` // Scope 说明这条地址属于哪一类(private / global / link-local,见 ipv4Scope)。 // 与 Confidence 正交:scope 说的是「这是什么地址」,confidence 说的是「怎么找到的」。 // 前端据此给链路本地地址补一句说明,而不是自己去猜 IP 前缀。 Scope string `json:"scope"` } // 地址置信度取值。low 只在宽松轮出现,route 只在第三层(默认路由探测)出现。 const ( confidenceHigh = "high" confidenceLow = "low" confidenceRoute = "route" ) // 地址类别取值(lanAddress.Scope)。 const ( scopePrivate = "private" scopeGlobal = "global" scopeLinkLocal = "link-local" ) // routeInterfaceLabel 是路由探测那条地址的 Interface 取值。 // // 它不是任何一张网卡的名字:这条地址压根不是从网卡枚举来的,写一个假的网卡名会让排查的人 // 去系统里找一张不存在的网卡。写清来源,接口本身就能自解释(前端不展示这个字段)。 const routeInterfaceLabel = "默认路由" // skippedInterface 解释「一张网卡为什么没贡献地址」。 // // 它只用于把空态换成有信息量的话(前端按 code 映射中文),**不参与地址列表的生成**: // 淘汰谁、留下谁完全由 lanAddresses 的两轮筛选决定,这里是从同一份快照上得出的旁白。 // 两者若互相影响(例如「被这里标了 not-up 的地址就不列」),空态原因和地址列表就会打架。 type skippedInterface struct { Interface string `json:"interface"` Code string `json:"code"` Addresses []string `json:"addresses"` } // 跳过原因。取值固定,前端与启动日志各有一份中文映射。 // // 一张网卡可能同时命中多个原因(例如既没置 up、又只有 IPv6 地址),此时只给最能解释 // 问题的那一个。优先级见 skipCode:no-address > only-ipv6 > not-up > loopback。 // // 注意这里**没有** link-local:链路本地地址现在会被收下(见 ipv4Scope), // 它不再是「被跳过」的理由。一张 down 掉、只配了链路本地地址的网卡,真实原因是 not-up。 const ( skipNoAddress = "no-address" skipOnlyIPv6 = "only-ipv6" skipNotUp = "not-up" skipLoopback = "loopback" ) // networkInfo 是 GET /api/system/network 的响应体。 type networkInfo struct { Port int `json:"port"` DisplayPath string `json:"displayPath"` AdminPath string `json:"adminPath"` Addresses []lanAddress `json:"addresses"` Skipped []skippedInterface `json:"skipped"` } // ifaceSnapshot 是 net.Interface 里本接口用得上的那部分。 // // 单独抽出来是为了让筛选与排序成为纯函数:net.Interface 的 Addrs() 直接问操作系统, // 拿真网卡根本造不出「网线 + WiFi 双网卡」「两张网卡配同一个地址」这类现场, // 而这正是最需要钉住的行为。测试构造快照即可,真网卡一行都不用碰。 type ifaceSnapshot struct { Name string Flags net.Flags Addrs []net.Addr } // readInterfaces 读一次本机网卡。读不到(极罕见)时回错误,由调用方决定怎么记: // HTTP 路径要降级成空列表(前端显示空态),启动日志那边要换成一条 WARN。 func readInterfaces() ([]ifaceSnapshot, error) { list, err := net.Interfaces() if err != nil { return nil, err } out := make([]ifaceSnapshot, 0, len(list)) for _, iface := range list { addrs, err := iface.Addrs() if err != nil { // 单张网卡读不到地址不影响别的网卡:现场常见的是一张 down 掉的虚拟网卡报错。 continue } out = append(out, ifaceSnapshot{Name: iface.Name, Flags: iface.Flags, Addrs: addrs}) } return out, nil } // snapshotInterfaces 读一次本机网卡,失败时只记日志并回空切片: // 前端据此显示「未检测到局域网地址」,比报错更接近事实——服务本身是好的。 func (s *server) snapshotInterfaces() []ifaceSnapshot { list, err := readInterfaces() if err != nil { s.logger.Warn("枚举网卡失败,大屏控制页将显示「未检测到局域网地址」", "err", err) return nil } return list } // dialProbe 与 net.Dial 同形(返回值收窄到本文件真正用到的两个方法),测试注入假实现用。 type dialProbe func(network, address string) (localAddrConn, error) // localAddrConn 是探测只需要的那一点能力:问出本地地址,然后关掉。 type localAddrConn interface { LocalAddr() net.Addr Close() error } // routeProbe 是第三层来源的可注入形式:回一组「系统默认路由用的源地址」。 // nil 表示这一层不跑(启动日志那条路径就是这样:它跟服务启动在同一个 goroutine 上, // 不该为了记一行日志去碰网络栈)。 type routeProbe func() []net.IP // routeProbeTargets 是默认路由探测的目标,顺序即尝试顺序。 // // 内网网关放在前面:现场要的正是「去局域网走哪块网卡」。末尾两个公网地址是兜底—— // 机器没配默认网关(只有一条去公网的路由,或走的是手机热点 / 4G 网卡)时,问公网地址一样能 // 让操作系统给出源地址,而那台机器的局域网地址通常就在同一张网卡上。 // 端口随便写(9 / 53):UDP 下这两个数只用于构造目标地址,一个包都不会发出去。 var routeProbeTargets = []string{ "192.168.0.1:9", "192.168.1.1:9", "10.0.0.1:9", "172.16.0.1:9", "8.8.8.8:53", "223.5.5.5:53", } // defaultDialProbe 是生产环境的 dialProbe,就是 net.Dial 本身。 func defaultDialProbe(network, address string) (localAddrConn, error) { return net.Dial(network, address) } // probeDefaultRoute 是生产环境的 routeProbe:拿 UDP Dial 去问操作系统。 func probeDefaultRoute() []net.IP { return probeRouteSourceIPs(defaultDialProbe, routeProbeTargets) } // probeRouteSourceIPs 让操作系统自己回答「去这些目标会走哪块网卡」,取 Dial 的本地地址。 // // 为什么 UDP 的 Dial 在完全离线的现场也安全(这是这一层敢放在请求路径里的唯一理由): // UDP 是「无连接」的,connect() 只做两件本地事——查一次路由表、把本地地址绑好,**不握手、 // 不发任何数据包**,所以对端在不在、网通不通都不影响它立刻返回;失败也是本地立刻失败 // (没有去那个网段的路由时直接回 ENETUNREACH)。换成 TCP 就得等 SYN 超时,没网的现场会把 // 这次请求拖住好几秒。也正因为它不发包,端口号在这里只是占位,写什么都一样。 // // 单个目标失败就跳过:没有默认路由的机器会把 6 个目标各试一次,各自立刻失败,代价可忽略。 func probeRouteSourceIPs(dial dialProbe, targets []string) []net.IP { if dial == nil { return nil } out := make([]net.IP, 0, len(targets)) seen := make(map[string]struct{}, len(targets)) for _, target := range targets { conn, err := dial("udp4", target) if err != nil { continue } if addr := conn.LocalAddr(); addr != nil { if host, _, err := net.SplitHostPort(addr.String()); err == nil { // 多个目标通常落在同一张网卡上:按地址去重,前端别看到重复的一条。 if ip4 := net.ParseIP(host).To4(); ip4 != nil { if _, dup := seen[ip4.String()]; !dup { seen[ip4.String()] = struct{}{} out = append(out, ip4) } } } } _ = conn.Close() } return out } // routeAddresses 跑第三层:探测 + 过滤,产出可直接下发的地址。 // 过滤口径与前两轮完全一致(ipv4Scope):探测回回环 / 未指定 / 组播 / 广播照样丢掉 // ——「系统说会走这块网卡」不等于「这个地址别的设备连得上」,比如只有回环路由的开发机。 // 链路本地与私网一样照收(scope 如实标出来),主人现场正是靠链路本地地址互访的那种网络。 func routeAddresses(probe routeProbe) []lanAddress { // 一开始就分配好:探测没得用、或探测回空时也必须给非 nil 空切片, // 否则 JSON 会下发 null,前端分不清「没有地址」和「服务端没给这个字段」。 out := make([]lanAddress, 0, 2) if probe == nil { return out } seen := make(map[string]struct{}) for _, ip := range probe() { ip4 := ip.To4() if ip4 == nil { continue } scope, usable := ipv4Scope(ip4) if !usable { continue } key := ip4.String() if _, dup := seen[key]; dup { continue } seen[key] = struct{}{} out = append(out, lanAddress{ IP: key, Interface: routeInterfaceLabel, Family: "ipv4", Confidence: confidenceRoute, Scope: scope, }) } sortLanAddresses(out) return out } // lanAddresses 从网卡快照里挑出可用的局域网 IPv4 地址,按「现场最可能用的那个」排在最前; // 同时给出被淘汰网卡的原因(只用于解释空态,不参与挑地址)。 // // 三层来源,逐层降级,前一层有结果就绝不跑后一层: // 1. 严格轮:接口置了 up 位的网卡(正常机器都走这里); // 2. 宽松轮:不要求 up 位(Windows 上 up 位读不到时的兜底); // 3. 默认路由探测:网卡枚举一份地址都没给时,直接问操作系统「去局域网走哪块网卡」。 // // 为什么要有宽松轮: // 严格轮要求接口置了 up 位——正常情况下这是对的,网线没插的接口确实连不上。但 Windows 上 // 这个位映射自 IfOperStatusUp,部分驱动 / 虚拟网卡 / 特定连接方式下**读不到 up 位**(接口 // 实际是通的,标志位却是 0)。只要机器上有一张这样的网卡,整台机器就会被判成「一个可用地址 // 都没有」,卡片只给一句没有信息量的空话——而局域网明明是好的。 // 所以严格轮一无所获时再跑一轮不要求 up 位的宽松轮:只是把「可能能用」的地址捡回来并标成 // low 交给现场判断,比把所有地址都藏起来强得多(真连不上,现场换一条就是了)。 // 严格轮有结果就绝不跑宽松轮:宁可少给几条,也不要让一张下线的虚拟网卡混进列表里。 // // 为什么还要第三层(probe): // 现场出现过单块 Intel 网卡(Windows 里 Get-NetAdapter 明明白白是 Up)两轮都筛不出东西的机器 // ——接口枚举这条路在那台机器上就是不通。此时让操作系统自己回答「去局域网会走哪块网卡」是唯一 // 还能拿到地址的办法,代价只是几次本地的 UDP connect(不发包,见 probeRouteSourceIPs)。 // **只有前两轮都为空才会走到这里**:正常机器上连一次 Dial 都不会发生,这条路径也就不可能 // 拖慢正常的请求,更不会在启动时打网络栈(启动日志传的就是 nil)。 // // 结果永远非 nil:没有任何可用地址时返回空切片,前端才知道这是「没有」而不是「没拿到字段」。 func lanAddresses(ifaces []ifaceSnapshot, probe routeProbe) (addresses []lanAddress, skipped []skippedInterface) { strict := collectLanAddresses(ifaces, usableInterface, confidenceHigh) if len(strict) > 0 { return strict, skippedInterfaces(ifaces, strict) } lenient := collectLanAddresses(ifaces, fallbackInterface, confidenceLow) if len(lenient) > 0 { return lenient, skippedInterfaces(ifaces, lenient) } routed := routeAddresses(probe) return routed, skippedInterfaces(ifaces, routed) } // collectLanAddresses 跑一轮筛选:keep 决定哪些网卡参与本轮,confidence 打在挑出来的地址上。 func collectLanAddresses(ifaces []ifaceSnapshot, keep func(net.Flags) bool, confidence string) []lanAddress { // 候选先带上 4 字节形式:去重、排序用得到,且避免在比较器里反复做 To4。 type candidate struct { entry lanAddress ip4 net.IP } seen := make(map[string]struct{}) cands := make([]candidate, 0, len(ifaces)) for _, iface := range ifaces { if !keep(iface.Flags) { continue } for _, raw := range iface.Addrs { ip4 := addrIP(raw).To4() if ip4 == nil { continue } scope, usable := ipv4Scope(ip4) if !usable { continue } key := ip4.String() // 同一地址配在多个接口上(桥接、双网卡同网段)时只留先出现的一条: // 列表里两条一模一样的链接,现场会以为是两个不同的东西。 if _, dup := seen[key]; dup { continue } seen[key] = struct{}{} cands = append(cands, candidate{ entry: lanAddress{ IP: key, Interface: iface.Name, Family: "ipv4", Confidence: confidence, Scope: scope, }, ip4: ip4, }) } } sort.Slice(cands, func(i, j int) bool { return lanLess(cands[i].entry, cands[i].ip4, cands[j].entry, cands[j].ip4) }) out := make([]lanAddress, 0, len(cands)) for _, c := range cands { out = append(out, c.entry) } return out } // lanLess 是地址排序的比较器:档位(越像现场在用的内网段越靠前)→ 地址数值 → 接口名。 // 网卡枚举与路由探测两条来源共用它,两边的顺序才是一致的、可预期的。 // // 同档按地址数值排序:同一台机器每次刷新都要给出同样的顺序, // 否则列表第一个地址会跳来跳去,现场刚记住的链接下次就变了。 func lanLess(a lanAddress, aIP net.IP, b lanAddress, bIP net.IP) bool { if ra, rb := lanRank(aIP), lanRank(bIP); ra != rb { return ra < rb } if c := bytes.Compare(aIP, bIP); c != 0 { return c < 0 } return a.Interface < b.Interface } // sortLanAddresses 按同一套比较器就地排序。 // 这里重新解析一次 IP 文本:调用点(路由探测)手里只有字符串形式的地址, // 而它的 IP 都是 net.ParseIP + ipv4Scope 校验过的,To4 一定拿得到。 func sortLanAddresses(list []lanAddress) { sort.Slice(list, func(i, j int) bool { a, b := list[i], list[j] return lanLess(a, net.ParseIP(a.IP).To4(), b, net.ParseIP(b.IP).To4()) }) } // skippedInterfaces 逐张说明「它为什么没贡献地址」,供前端把空态换成有信息量的话。 // addresses 是本次真正下发的那一份(严格轮或宽松轮的结果),同一张网卡只出现一次。 // // 有一张地址被别的网卡抢先拿走(同一地址配在桥接 / 双网卡上,去重只留了先出现的那个) // 的网卡不算被跳过:这不是网卡的问题,报出来只会让现场去查一张没毛病的网卡。 // 反过来,接口没置 up 却配着可用地址的网卡**要**报出来(code = not-up)——这正是主人这台 // Windows 机器最可能的现场:地址是好的,只有 up 位读不到。 func skippedInterfaces(ifaces []ifaceSnapshot, addresses []lanAddress) []skippedInterface { contributed := make(map[string]struct{}, len(addresses)) for _, a := range addresses { contributed[a.Interface] = struct{}{} } out := make([]skippedInterface, 0, len(ifaces)) seen := make(map[string]struct{}, len(ifaces)) for _, iface := range ifaces { if _, dup := seen[iface.Name]; dup { continue } seen[iface.Name] = struct{}{} if _, ok := contributed[iface.Name]; ok { continue } if iface.Flags&net.FlagUp != 0 && hasEligibleIPv4(iface) { continue } out = append(out, skippedInterface{ Interface: iface.Name, Code: skipCode(iface), Addresses: addrStrings(iface.Addrs), }) } return out } // hasEligibleIPv4 判断一张网卡有没有「两轮里都可能被挑走」的地址(宽松轮的口径)。 func hasEligibleIPv4(iface ifaceSnapshot) bool { if iface.Flags&net.FlagLoopback != 0 { return false } for _, raw := range iface.Addrs { if ip4 := addrIP(raw).To4(); ip4 != nil { if _, usable := ipv4Scope(ip4); usable { return true } } } return false } // skipCode 给出「这张网卡为什么没贡献地址」,只取最能解释问题的那一个。 // // 优先级(高到低):no-address > only-ipv6 > not-up > loopback。 // 之所以把地址层面的原因排在接口 up 位之前:现场要看到的是「怎么才能弄好」—— // 「接口没有地址」「只有 IPv6 地址」是能直接动手改的(插网线 / 换个网段 / 看看 DHCP), // 而「接口未连接」在 Windows 上还可能是 up 位读不到(见 lanAddresses 的宽松轮)。 // // 这里不再有 link-local:链路本地地址会被收下(见 ipv4Scope),不再是跳过的理由; // 一张 down 掉、只配了链路本地地址的网卡,真实原因是 not-up。 func skipCode(iface ifaceSnapshot) string { var hasAddr, hasIPv4, hasIPv6, hasLoopbackAddr bool for _, raw := range iface.Addrs { hasAddr = true ip := addrIP(raw) if ip == nil { continue } if ip4 := ip.To4(); ip4 != nil { hasIPv4 = true hasLoopbackAddr = hasLoopbackAddr || ip4.IsLoopback() continue } hasIPv6 = hasIPv6 || ip.To16() != nil } switch { case !hasAddr: return skipNoAddress case !hasIPv4 && hasIPv6: return skipOnlyIPv6 case iface.Flags&net.FlagUp == 0: return skipNotUp case iface.Flags&net.FlagLoopback != 0, hasLoopbackAddr: return skipLoopback default: // 只剩未指定(0.0.0.0)或组播这类地址:网卡在,但一个能当链接用的地址都没有。 return skipNoAddress } } // addrStrings 把网卡地址原样写成 CIDR 文本(如 `10.0.0.10/24`),给 skipped 用。 // 返回非 nil 空切片,保证 JSON 里是 [] 而不是 null。 func addrStrings(addrs []net.Addr) []string { out := make([]string, 0, len(addrs)) for _, raw := range addrs { out = append(out, raw.String()) } return out } // usableInterface 判断一张网卡能不能贡献地址(严格轮): // - 必须 UP —— 网线没插、接口被 down 掉时,配置还在但包根本发不出去; // - 排除回环接口 —— lo 上的 127.x 对别的设备毫无意义(面板自己就是从 127.0.0.1 打开的, // 真把它显示出来会误导现场照抄一个只有本机能打开的地址)。 func usableInterface(flags net.Flags) bool { return flags&net.FlagUp != 0 && flags&net.FlagLoopback == 0 } // fallbackInterface 是严格轮一无所获时的兜底条件:只排除回环接口,不再要求 up 位。 // 放宽的只有「读不到 up 位」这一种情况,回环接口在宽松轮里同样没有意义。 func fallbackInterface(flags net.Flags) bool { return flags&net.FlagLoopback == 0 } // ipv4Scope 判断一个 IPv4 地址属于哪一类,第二返回值表示它能不能作为候选地址下发。 // // 只排除四类「作为对端访问目标没有意义」的地址: // - 回环(net.IP.IsLoopback):只有本机访问得到; // - 未指定(IsUnspecified):0.0.0.0 拼不成 URL; // - 组播(IsMulticast):组播地址不是单播目的地; // - 广播(等于 net.IPv4bcast):同理。 // // **一句话规则:除了这四类,本机持有的、可能被同网段设备访问的 IPv4 一律进候选。** // 判定全部走标准库谓词,不写网段字面量:网段划分本身是标准库的知识,抄一份到代码里, // 抄错或漏掉一段就会变成「明明有地址却说没有」——正是这次要修的那个毛病。 // // 这里**曾经**把链路本地地址(IsLinkLocalUnicast)也当成「不能用」排除掉,理由写的是 // 「DHCP 没拿到地址才自配的,说明网络不健康」。那是对网络健不健康的**猜测**,不是地址能不能用的 // **事实**:主人现场就是无 DHCP 的独立内网,设备之间靠链路本地地址互访,平板实测能打开 // http://<链路本地地址>:端口/display,网络与防火墙全通,而卡片却因为这条猜测说「未检测到 // 局域网地址」。判断错了,所以改成按类别判定:链路本地照收,只是排在最后(见 lanRank)。 func ipv4Scope(ip4 net.IP) (scope string, usable bool) { // 传进来的必须是 4 字节形式(各调用点都先做了 To4)。这里显式挡一次 nil: // 谓词对 nil 全返回 false,落到 default 分支就会被当成「可用的 global 地址」—— // IPv6 的 To4() 正好是 nil,那会让「只有 IPv6 的网卡」被误判成有候选地址。 if ip4.To4() == nil { return "", false } switch { case ip4.IsLoopback(), ip4.IsUnspecified(), ip4.IsMulticast(), ip4.Equal(net.IPv4bcast): return "", false case ip4.IsLinkLocalUnicast(): return scopeLinkLocal, true case ip4.IsPrivate(): return scopePrivate, true default: return scopeGlobal, true } } // lanRank 是排序档位(越小越靠前):现场「网线 + WiFi 双网卡」时,排第一的应该是他们真正在用的那个。 // // 分档依据是各私有段在实际现场的常见程度: // - 0:192.168.0.0/16 —— 家用与小型办公路由器的出厂段,现场路由器 / 无线热点几乎都在这里; // - 1:10.0.0.0/8 —— 稍大的内网、企业路由器与手机热点(部分机型)常用的段; // - 2:172.16.0.0/12 —— RFC1918 三段里最少见的,多是虚拟化 / 容器网桥(docker 默认就用它), // 真要拿它给平板用也得排在直连网段之后; // - 3:其余(公网地址、运营商专线地址等)—— 能用就留着,但不给第一顺位: // 现场多半是靠某个中间设备才通的,未必是平板能直连的那个; // - 4:链路本地 —— 永远排最后。 // // 为什么链路本地排最后:它只在「目标设备也用链路本地地址」时才连得上(无 DHCP 的独立内网 // 正是这种情况),适用范围天然比配过 DHCP / 静态地址的网段窄。而排序第一位会决定现场 // 「照抄哪一条」和二维码给谁,所以让适用面更广的私网 / 全局地址先来;真只有链路本地可用时, // 它照样是列表第一条(前面的档位都是空的)。 func lanRank(ip4 net.IP) int { switch { case ip4.IsLinkLocalUnicast(): return 4 case ip4[0] == 192 && ip4[1] == 168: return 0 case ip4[0] == 10: return 1 case ip4[0] == 172 && ip4[1] >= 16 && ip4[1] <= 31: return 2 default: return 3 } } // addrIP 从 net.Addr 里取出 IP。网卡地址实际都是 *net.IPNet,*net.IPAddr 只是兜底。 func addrIP(addr net.Addr) net.IP { switch a := addr.(type) { case *net.IPNet: return a.IP case *net.IPAddr: return a.IP } return nil } // systemNetwork 是 GET /api/system/network:大屏控制页用它拼出平板能直接打开的链接。 // 端口取服务端实际监听的那个(见 server.port),不在这里另写一个数字。 func (s *server) systemNetwork(w http.ResponseWriter, r *http.Request) (any, error) { // 第三层(默认路由探测)只在这个接口里开:它是按请求跑的,前两轮为空才会真的 Dial。 addresses, skipped := lanAddresses(s.snapshotInterfaces(), probeDefaultRoute) return networkInfo{ Port: s.port, DisplayPath: displayPagePath, AdminPath: adminPagePath, Addresses: addresses, Skipped: skipped, }, nil } // LogLanAddresses 启动时枚举一次网卡,把大屏控制页会用到的局域网地址写进日志。 // // 现场排障用:卡片显示空态时,现场第一件事就是看这行日志——服务端可能跑在另一台机器上, // 而日志里能直接看出「枚举到了什么、为什么一个都没用」,不必再让主人去装工具查网卡。 // 一个地址都没有时降级为 WARN,并带上 skipped 的原因摘要(中文,能直接看懂)。 func LogLanAddresses(logger *slog.Logger) { ifaces, err := readInterfaces() if err != nil { logger.Warn("枚举网卡失败,大屏控制页将显示「未检测到局域网地址」", "err", err) return } logLanAddresses(logger, ifaces) } // logLanAddresses 是 LogLanAddresses 的纯逻辑部分:快照从外面给,日志分支才好钉住。 // // probe 传 nil:启动路径**不做默认路由探测**。它跟服务启动在同一个 goroutine 上,为一行日志 // 去碰网络栈(哪怕 UDP 不阻塞)不值得;也免得日志里的地址和卡片上真正下发的来源不一致 // ——卡片是按请求探测的,那时网卡状态可能已经变了。日志只负责如实说「枚举到了什么」。 func logLanAddresses(logger *slog.Logger, ifaces []ifaceSnapshot) { addresses, skipped := lanAddresses(ifaces, nil) if len(addresses) == 0 { // 一个地址都没有:这就是现场来问「卡片为什么是空的」时要找的那条日志, // 所以原因摘要必须和 WARN 一起出现,不能只在别处留个 code。 summary := skipSummary(skipped) if summary == "" { summary = "没有枚举到任何网卡" } logger.Warn("未找到可用的局域网地址:大屏控制页只能显示空态", "已检查", summary) return } logger.Info("局域网地址:" + addressSummary(addresses)) } // addressSummary 拼「10.0.0.10(以太网)、192.168.0.100(ZeroTier)」这样一行日志消息。 func addressSummary(addresses []lanAddress) string { parts := make([]string, 0, len(addresses)) for _, a := range addresses { parts = append(parts, a.IP+"("+a.Interface+")") } return strings.Join(parts, "、") } // skipSummary 把 skipped 摊成「以太网(接口未连接)、WLAN(只有 IPv6 地址)」。 // 没有可报的网卡时返回空串,由调用方补一句「没有枚举到任何网卡」。 func skipSummary(skipped []skippedInterface) string { parts := make([]string, 0, len(skipped)) for _, s := range skipped { parts = append(parts, s.Interface+"("+skipReasonLabel(s.Code)+")") } return strings.Join(parts, "、") } // skipReasonLabel 把跳过原因翻成日志里能直接看懂的中文。 // 前端另有一份自己的映射(服务端只下发 code,卡片文案由前端定):日志面向运维、 // 卡片面向现场操作员,措辞不必一模一样,但两份都得覆盖全部 code。 func skipReasonLabel(code string) string { switch code { case skipNoAddress: return "接口没有地址" case skipOnlyIPv6: return "只有 IPv6 地址" case skipNotUp: return "接口未连接" case skipLoopback: return "回环接口" default: return code } }