Files
AskaEth c97d3150c9 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 例。
2026-09-12 18:30:46 +08:00

143 lines
6.4 KiB
Go

package httpapi_test
import (
"encoding/json"
"net"
"net/http"
"strings"
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/config"
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi"
)
// GET /api/system/network 是给大屏控制页拼「平板能直接打开的链接」用的:
// recorder 起可读,guest 与未登录一律 403(与 /api/system/about 同档——机器级信息不给匿名访客)。
func TestSystemNetworkPermission(t *testing.T) {
env := newTestEnvWith(t, func(o *httpapi.Options) { o.Port = 18080 })
guest, _ := env.client()
if status, env2 := env.do(guest, http.MethodGet, "/api/system/network", nil, false); status != http.StatusForbidden ||
env2.Error == nil || env2.Error.Code != "FORBIDDEN" {
t.Fatalf("guest 读 network 应 403 FORBIDDEN,得到 %d %+v", status, env2.Error)
}
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
status, env2 := env.do(client, http.MethodGet, "/api/system/network", nil, false)
if status != http.StatusOK || !env2.OK {
t.Fatalf("recorder 读 network 应 200,得到 %d %+v", status, env2.Error)
}
}
// 响应形状是对前端契约:字段一个不多一个不少,port 必须是实际监听端口(测试注入 18080),
// 路径字段则要与服务端真的托管着的页面一致——前端是照它拼链接的,写错了就是死链。
func TestSystemNetworkShape(t *testing.T) {
env := newTestEnvWith(t, func(o *httpapi.Options) { o.Port = 18080 })
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
status, env2 := env.do(client, http.MethodGet, "/api/system/network", nil, false)
if status != http.StatusOK {
t.Fatalf("读 network 应 200,得到 %d", status)
}
var body map[string]any
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", "skipped"})
if got, ok := body["port"].(float64); !ok || int(got) != 18080 {
t.Fatalf("port 应回注入的实际监听端口 18080,得到 %#v", body["port"])
}
if body["displayPath"] != "/display" || body["adminPath"] != "/admin" {
t.Fatalf("路径字段不符: displayPath=%#v adminPath=%#v", body["displayPath"], body["adminPath"])
}
// 路径字段指向的页面必须真的存在:改了前端产物名就会在这里红灯,而不是等到现场打开是 404。
for _, path := range []string{body["displayPath"].(string), body["adminPath"].(string)} {
code, raw := env.doRaw(client, http.MethodGet, path, nil, "", false)
if code != http.StatusOK || !strings.Contains(string(raw), "<html") {
t.Fatalf("路径 %s 应返回页面,得到 %d %.80s", path, code, raw)
}
}
// addresses 必须是数组(没有地址时是 [] 而不是 null),
// 每条都带 ip/interface/family/confidence/scope 且是 IPv4。
raw, ok := body["addresses"].([]any)
if !ok {
t.Fatalf("addresses 应是数组,得到 %#v", body["addresses"])
}
for _, item := range raw {
entry, ok := item.(map[string]any)
if !ok {
t.Fatalf("地址项应是对象,得到 %#v", item)
}
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"])
}
// 回环 / 未指定 / 组播 / 广播是「拿得到、连不上」的地址,绝不能出现在给平板用的列表里。
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"])
}
}
}
// 调用方忘了注入 Port 时也要给出一个真端口(config 的默认端口),不能是 :0 —— 那会拼出
// 一条根本打不开的链接。默认值的唯一来源是 config 包,本用例直接对比它,防止两处漂移。
func TestSystemNetworkPortFallsBackToConfigDefault(t *testing.T) {
env := newTestEnv(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
_, env2 := env.do(client, http.MethodGet, "/api/system/network", nil, false)
var body map[string]any
if err := json.Unmarshal(env2.Data, &body); err != nil {
t.Fatalf("解析 network: %v (%s)", err, env2.Data)
}
want := config.Default().Server.Port
if got, ok := body["port"].(float64); !ok || int(got) != want {
t.Fatalf("未注入端口时应回 config 默认端口 %d,得到 %#v", want, body["port"])
}
}