fix(admin): 局域网大屏卡片按「地址类别」判定 + 三层兜底 + 手填入口(dev_0.5.0)
## 判断失误的修正(主人现场实测出来的) 主人现场是 **169.254 自动配置网段**(没有 DHCP,设备靠链路本地地址互访), 平板实测能访问 `http://169.254.79.190:19540/display`;而卡片一开始把链路本地地址 **排除**了,于是显示「未检测到局域网地址」。 根因:`usableLanIPv4` 排除了 `IsLinkLocalUnicast()`,理由写的是「169.254 说明没拿到 IP」 ——那是**对网络健不健康的猜测**,不是**地址能不能用的事实**。 现改为按**地址类别**判定(`ipv4Scope`,全部用标准库谓词,**代码里没有任何网段字面量**): 只排除「作为对端访问目标没有意义」的四类(回环 / 未指定 / 组播 / 广播), 链路本地与私网、全局一样进候选,只**排在最后**并带说明 「自动配置地址:本网段没有 DHCP,同网段设备可以互相访问」。 新增**反硬编码护栏用例**:函数体内不得出现 `x.x.x.x` 字面量,且必须用满 6 个谓词。 顺带修掉新用例抓出的真 bug:`ipv4Scope(nil)` 曾落进 default 被当成「可用的 global」, 而 IPv6 的 `To4()` 正是 nil → 「只有 IPv6 的网卡」会被误判成有可用地址。 ## 三层兜底(离线现场必须拿得到地址) 1. **严格轮**:接口 up 且非回环; 2. **宽松轮**:严格轮为空时不再要求 up 位(某些驱动/虚拟网卡读不到),命中标 `confidence: low`; 3. **默认路由探测**:两轮皆空时用 `net.Dial("udp4", …)` 问出本机源地址 (UDP connect 只查路由表、不握手不发包,离线安全;TCP 会等 SYN 超时,不能用),标 `confidence: route`。 接口同时回 `scope`(private/global/link-local)与 `skipped`(被跳过的网卡 + 原因, code 收敛为 no-address / only-ipv6 / not-up / loopback——link-local 不再是「被跳过」的理由)。 ## 手填入口(最后一道保险) 自动检测不到、或不是现场要用的网段时,操作员把 `ipconfig` 里的地址填进卡片, 立刻生成链接 + 二维码;排在第一条、带「手动」徽标、可删除、localStorage 持久化、 四条就地校验文案(空/含空格/不像 IP/重复),IPv6 也支持。 ## 自检工具 `debug/诊断/局域网地址自检.exe` + 说明:只读、不写文件、不需管理员, 打印系统给 Go 看的网卡列表(含类别)、默认路由探测结果与可用大屏地址。 (正是它把现场定性成 169.254 网段的。) ## 真浏览器验证(四场景,非源码断言) 链接本地地址出链接+二维码+说明句 / 空态列出被跳过网卡并折叠(5 条 + 等共 N 块)/ low 与 route 两种来源提示各占一行互不顶掉 / 手填地址排第一并刷新不丢 —— 全部通过,0 页面错误。 ## 文档 使用手册 §9、部署指南 7.5/7.9、上线检查清单(**防火墙放行入站 19540** 与 「另一台设备能打开局域网大屏」两条新增)、常见问题 Q101、升级说明 dev_0.5.0; devdocs 05/07/13 同步(含这次误判的来龙去脉与「Get-NetAdapter 显示 Up ≠ 有 IP」)。 **没有数据迁移**,旧赛事包照常导入。 自检:服务端 13 包 ×2;前端 vue-tsc + 680 例 ×2 + build;Console 20 文件 + 68 例。
This commit is contained in:
1 parent
9ef68c2589
commit
c97d3150c9
18 files changed
+2425
-79
No files matched your search
@@ -47,7 +47,7 @@ func TestSystemNetworkShape(t *testing.T) {
|
||||
if err := json.Unmarshal(env2.Data, &body); err != nil {
|
||||
t.Fatalf("解析 network: %v (%s)", err, env2.Data)
|
||||
}
|
||||
assertKeys(t, "network", body, []string{"port", "displayPath", "adminPath", "addresses"})
|
||||
assertKeys(t, "network", body, []string{"port", "displayPath", "adminPath", "addresses", "skipped"})
|
||||
|
||||
if got, ok := body["port"].(float64); !ok || int(got) != 18080 {
|
||||
t.Fatalf("port 应回注入的实际监听端口 18080,得到 %#v", body["port"])
|
||||
@@ -64,7 +64,8 @@ func TestSystemNetworkShape(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// addresses 必须是数组(没有地址时是 [] 而不是 null),每条都带 ip/interface/family 且是 IPv4。
|
||||
// addresses 必须是数组(没有地址时是 [] 而不是 null),
|
||||
// 每条都带 ip/interface/family/confidence/scope 且是 IPv4。
|
||||
raw, ok := body["addresses"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("addresses 应是数组,得到 %#v", body["addresses"])
|
||||
@@ -74,17 +75,49 @@ func TestSystemNetworkShape(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatalf("地址项应是对象,得到 %#v", item)
|
||||
}
|
||||
assertKeys(t, "addresses[]", entry, []string{"ip", "interface", "family"})
|
||||
assertKeys(t, "addresses[]", entry, []string{"ip", "interface", "family", "confidence", "scope"})
|
||||
if entry["family"] != "ipv4" {
|
||||
t.Fatalf("family 应为 ipv4,得到 %#v", entry["family"])
|
||||
}
|
||||
if c, _ := entry["confidence"].(string); c != "high" && c != "low" && c != "route" {
|
||||
t.Fatalf("confidence 应是 high / low / route,得到 %#v", entry["confidence"])
|
||||
}
|
||||
if s, _ := entry["scope"].(string); s != "private" && s != "global" && s != "link-local" {
|
||||
t.Fatalf("scope 应是 private / global / link-local,得到 %#v", entry["scope"])
|
||||
}
|
||||
ip := net.ParseIP(entry["ip"].(string))
|
||||
if ip == nil || ip.To4() == nil {
|
||||
t.Fatalf("ip 应是 IPv4 文本,得到 %#v", entry["ip"])
|
||||
}
|
||||
// 回环与 link-local 是「拿得到、连不上」的地址,绝不能出现在给平板用的列表里。
|
||||
if ip.IsLoopback() || ip.IsLinkLocalUnicast() {
|
||||
t.Fatalf("不该出现回环 / link-local 地址: %#v", entry)
|
||||
// 回环 / 未指定 / 组播 / 广播是「拿得到、连不上」的地址,绝不能出现在给平板用的列表里。
|
||||
if ip.IsLoopback() || ip.IsUnspecified() || ip.IsMulticast() || ip.Equal(net.IPv4bcast) {
|
||||
t.Fatalf("不该出现回环 / 未指定 / 组播 / 广播地址: %#v", entry)
|
||||
}
|
||||
// 链路本地**是**候选(无 DHCP 的独立内网里它是唯一能用的地址),但必须如实标 scope。
|
||||
if ip.IsLinkLocalUnicast() && entry["scope"] != "link-local" {
|
||||
t.Fatalf("链路本地地址的 scope 应为 link-local,得到 %#v", entry["scope"])
|
||||
}
|
||||
}
|
||||
|
||||
// skipped 也必须始终是数组(没有可解释的接口时是 [] 而不是 null),每条的 code 都在约定集合里。
|
||||
// 它只是给前端解释空态用的旁白,地址列表不受它影响。
|
||||
skipped, ok := body["skipped"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("skipped 应是数组,得到 %#v", body["skipped"])
|
||||
}
|
||||
// 集合里没有 link-local:链路本地现在是候选地址,不再是「被跳过」的理由。
|
||||
codes := map[string]bool{"no-address": true, "only-ipv6": true, "not-up": true, "loopback": true}
|
||||
for _, item := range skipped {
|
||||
entry, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("skipped 项应是对象,得到 %#v", item)
|
||||
}
|
||||
assertKeys(t, "skipped[]", entry, []string{"interface", "code", "addresses"})
|
||||
if code, _ := entry["code"].(string); !codes[code] {
|
||||
t.Fatalf("skipped 的 code 不在约定集合里: %#v", entry["code"])
|
||||
}
|
||||
if _, ok := entry["addresses"].([]any); !ok {
|
||||
t.Fatalf("skipped 的 addresses 应是数组,得到 %#v", entry["addresses"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user