feat(console,admin): kiosk 不再点击穿透 + 压住任务栏;大屏控制页新增「局域网大屏」(链接/复制/二维码)dev_0.4.0
## ① 现场问题:kiosk 窗口点击穿透(原设计方向反了) `displays.js` 原来对多屏 kiosk 调 `setIgnoreMouseEvents(true)`,注释写的是 「防止现场误触」——但该 API 的语义是「本窗口忽略鼠标」,点击会**落到它后面的 窗口或桌面**,现场想防的误触正好变成误点别的东西;被忽略鼠标的窗口也点不活。 现在窗口**默认吞掉鼠标、不穿透**。 配置项**改名**为 `display.allowClickThrough`(缺省 false;显式 true 才穿透)。 **旧键 `display.ignoreMouse` 完全不读**——老机器的配置文件里还写着旧版模板留下的 `ignoreMouse: true`,而配置策略是「已存在不覆盖」,只改默认值等于老机器升级后 照样穿透;改名才能让默认值真正生效。启动时若发现旧键会记一条中文提示(只记一次), 并在合并后的配置里把它剥掉,防止将来有人再读到那个陈年 true。 (不是自动改写用户配置文件:无法区分「操作员刻意写的 true」与「旧模板留下的 true」。) ## ② 现场问题:任务栏盖住 kiosk 窗口、被盖住后抢不回前台 原顺序是 `setKiosk(true)` 之后又 `setBounds(...)`,在 Windows 上会把窗口从真正的 全屏态拽回「恰好铺满该屏的无边框窗口」→ 不是 topmost,任务栏与拖过去的窗口都盖得住。 现在固定为 **先 `setBounds` 定位 → 再 `setKiosk(true)` → 最后 `setAlwaysOnTop(true, 'screen-saver')`**(默认层级 `floating` 在 Windows 上仍画在任务栏下面),并加了 「已达标就不重复进出 kiosk」的守卫避免重排白闪;非 kiosk 分支会摘掉置顶。 Win 真机行为需现场复核,代码/桩级已验(含顺序与层级的断言)。 ## ③ 新功能:大屏控制页「局域网大屏」卡片 - 服务端新增 `GET /api/system/network`(recorder 及以上):回本机可用局域网 IPv4 (排除回环/link-local/IPv6,去重,按 192.168/10/172.16-31 优先排序)+ 真实监听端口 + 路径;无地址回 `[]` 不报错。 - 卡片:每个地址一条可点击链接 + 复制按钮,第一个地址配二维码(148px)。 - **二维码是离线自实现的纯 TS 编码器**(版本 1–40 自动、ECC L/M/Q/H、8 种掩码择优), 正确性用 **Python OpenCV 真解码**验收:29/29 条解出原文(含中文与边界), SVG 渲染后再解码 4/4,并把验证过的矩阵冻结成仓库参照用例。 - 复制按**非安全上下文**处理:面板常从 `http://192.168.x.x` 打开,那里 `navigator.clipboard` 是 undefined,自动回退 `execCommand('copy')`。 ## ④ 真机联调抓出并修掉一个时序缺陷(真浏览器 + 真服务端) `DisplayPage` 的 `refreshLan()` 在挂载时跑,而 `auth` 是异步加载的——那一刻 `auth.readOnly` 必然为 true,卡片把「当前为只读访客」写进一个**普通 ref** 就返回了, 身份落定后没有任何东西再触发它:**管理员登录后卡片永远停在访客态, `/api/system/network` 一次都没被请求**。 改为「gate 由 computed 直接渲染 + watch gate 变 null 时重跑加载」,并补回归钉子。 (同时修正了一条把该错误结构当成正确行为的源码断言用例。) 端到端证据:真服务 + 局域网 IP(真非安全上下文)→ 卡片列出两个地址、点击复制变 「已复制」(走 execCommand 回退)→ 浏览器渲染的二维码截图经 cv2 解出 `http://10.50.101.85:19861/display`。 ## 文档 部署指南配置表与多屏行为、使用手册「大屏控制」与局域网设备段、devdocs 05/07/10/13、 升级说明新增 dev_0.4.0(含旧键废弃与启动提示)。**无数据迁移**,旧赛事包照常导入。 自检:服务端 13 包 ×2;前端 vue-tsc + 645 例 ×2 + build;Console 20 文件 + 68 例。
This commit is contained in:
1 parent
ed2c0b3f3c
commit
9ef68c2589
33 files changed
+2923
-95
No files matched your search
@@ -0,0 +1,187 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net"
|
||||
"net/http"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// 大屏控制页要把「当前设备的局域网地址」显示成可点链接 + 二维码:面板自己往往是从
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// networkInfo 是 GET /api/system/network 的响应体。
|
||||
type networkInfo struct {
|
||||
Port int `json:"port"`
|
||||
DisplayPath string `json:"displayPath"`
|
||||
AdminPath string `json:"adminPath"`
|
||||
Addresses []lanAddress `json:"addresses"`
|
||||
}
|
||||
|
||||
// ifaceSnapshot 是 net.Interface 里本接口用得上的那部分。
|
||||
//
|
||||
// 单独抽出来是为了让筛选与排序成为纯函数:net.Interface 的 Addrs() 直接问操作系统,
|
||||
// 拿真网卡根本造不出「网线 + WiFi 双网卡」「两张网卡配同一个地址」这类现场,
|
||||
// 而这正是最需要钉住的行为。测试构造快照即可,真网卡一行都不用碰。
|
||||
type ifaceSnapshot struct {
|
||||
Name string
|
||||
Flags net.Flags
|
||||
Addrs []net.Addr
|
||||
}
|
||||
|
||||
// snapshotInterfaces 读一次本机网卡。读不到(极罕见)时返回空切片:
|
||||
// 前端据此显示「未检测到局域网地址」,比报错更接近事实——服务本身是好的。
|
||||
func (s *server) snapshotInterfaces() []ifaceSnapshot {
|
||||
list, err := net.Interfaces()
|
||||
if err != nil {
|
||||
s.logger.Warn("枚举网卡失败,大屏控制页将显示「未检测到局域网地址」", "err", err)
|
||||
return nil
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// lanAddresses 从网卡快照里挑出可用的局域网 IPv4 地址,按「现场最可能用的那个」排在最前。
|
||||
// 结果永远非 nil:没有任何可用地址时返回空切片,前端才知道这是「没有」而不是「没拿到字段」。
|
||||
func lanAddresses(ifaces []ifaceSnapshot) []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 !usableInterface(iface.Flags) {
|
||||
continue
|
||||
}
|
||||
for _, raw := range iface.Addrs {
|
||||
ip4 := addrIP(raw).To4()
|
||||
if ip4 == nil || !usableLanIPv4(ip4) {
|
||||
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"},
|
||||
ip4: ip4,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(cands, func(i, j int) bool {
|
||||
a, b := cands[i], cands[j]
|
||||
if ra, rb := lanRank(a.ip4), lanRank(b.ip4); ra != rb {
|
||||
return ra < rb
|
||||
}
|
||||
// 同档按地址数值排序:同一台机器每次刷新都要给出同样的顺序,
|
||||
// 否则列表第一个地址会跳来跳去,现场刚记住的链接下次就变了。
|
||||
if c := bytes.Compare(a.ip4, b.ip4); c != 0 {
|
||||
return c < 0
|
||||
}
|
||||
return a.entry.Interface < b.entry.Interface
|
||||
})
|
||||
|
||||
out := make([]lanAddress, 0, len(cands))
|
||||
for _, c := range cands {
|
||||
out = append(out, c.entry)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// usableLanIPv4 判断一个 IPv4 地址能不能给局域网其他设备用。
|
||||
// 逐条排除的都是「拿得到、连不上」的地址:
|
||||
// - 127.0.0.0/8 回环:只有本机能访问;
|
||||
// - 169.254.0.0/16 link-local:DHCP 没拿到地址时系统自配的,同网段设备通常也到不了;
|
||||
// - 0.0.0.0:未指定地址,拼不成 URL;
|
||||
// - 224.0.0.0/4 组播:不是单播目的地址。
|
||||
func usableLanIPv4(ip4 net.IP) bool {
|
||||
switch {
|
||||
case ip4.IsLoopback(), ip4.IsUnspecified(), ip4.IsLinkLocalUnicast(), ip4.IsMulticast():
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// lanRank 是排序档位(越小越靠前):现场「网线 + WiFi 双网卡」时,排第一的应该是他们真正在用的那个。
|
||||
//
|
||||
// 分档依据是各私有段在实际现场的常见程度:
|
||||
// - 0:192.168.0.0/16 —— 家用与小型办公路由器的出厂段,现场路由器 / 无线热点几乎都在这里;
|
||||
// - 1:10.0.0.0/8 —— 稍大的内网、企业路由器与手机热点(部分机型)常用的段;
|
||||
// - 2:172.16.0.0/12 —— RFC1918 三段里最少见的,多是虚拟化 / 容器网桥(docker 默认就用它),
|
||||
// 真要拿它给平板用也得排在直连网段之后;
|
||||
// - 3:其余(公网地址、运营商专线地址等)—— 能用就留着,但不给第一顺位:
|
||||
// 现场多半是靠某个中间设备才通的,未必是平板能直连的那个。
|
||||
func lanRank(ip4 net.IP) int {
|
||||
switch {
|
||||
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) {
|
||||
return networkInfo{
|
||||
Port: s.port,
|
||||
DisplayPath: displayPagePath,
|
||||
AdminPath: adminPagePath,
|
||||
Addresses: lanAddresses(s.snapshotInterfaces()),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// lanIf 造一张网卡快照:测试里要的是「现场双网卡」这类布局,不能去动真网卡。
|
||||
func lanIf(name string, flags net.Flags, cidrs ...string) ifaceSnapshot {
|
||||
addrs := make([]net.Addr, 0, len(cidrs))
|
||||
for _, cidr := range cidrs {
|
||||
ip, ipnet, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
panic("测试地址非法: " + cidr)
|
||||
}
|
||||
addrs = append(addrs, &net.IPNet{IP: ip, Mask: ipnet.Mask})
|
||||
}
|
||||
return ifaceSnapshot{Name: name, Flags: flags, Addrs: addrs}
|
||||
}
|
||||
|
||||
func lanIPs(got []lanAddress) []string {
|
||||
out := make([]string, 0, len(got))
|
||||
for _, a := range got {
|
||||
out = append(out, a.IP)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 只回「平板真的连得上」的 IPv4:回环、link-local、未指定、组播、IPv6、down 掉的接口全要滤掉。
|
||||
// 这条用例钉住的是现场最容易踩的坑——面板从 127.0.0.1 打开,顺手把回环地址显示出去,
|
||||
// 现场照着抄到平板上就是打不开。
|
||||
func TestLanAddressesFiltersUnusable(t *testing.T) {
|
||||
ifaces := []ifaceSnapshot{
|
||||
lanIf("lo", net.FlagUp|net.FlagLoopback, "127.0.0.1/8", "::1/128"),
|
||||
// 网线插着但 DHCP 没拿到地址:系统自配 169.254.x,同网段设备通常也到不了。
|
||||
lanIf("eth0", net.FlagUp, "169.254.10.5/16", "fe80::a00:27ff:fe4e:66a1/64"),
|
||||
// 接口 down 掉:配置还在,包发不出去。
|
||||
lanIf("eth1", 0, "192.168.5.5/24"),
|
||||
// 非回环接口上被误配了回环地址(别名地址写错段):按地址本身也得滤掉,
|
||||
// 光看接口标志漏得掉这一条。
|
||||
lanIf("eth2", net.FlagUp, "127.0.0.5/8"),
|
||||
lanIf("docker0", net.FlagUp, "0.0.0.0/32", "224.0.0.1/4", "172.20.0.1/16"),
|
||||
// 双栈内网里网卡还会带全局 IPv6:拼 URL 要写方括号且常带 %zone,平板粘过去容易出错,
|
||||
// 所以一律不往下发(family 字段留着,将来真要用才补)。
|
||||
lanIf("wlan0", net.FlagUp, "2400:3200::1/64", "10.1.2.3/8"),
|
||||
}
|
||||
|
||||
want := []string{"10.1.2.3", "172.20.0.1"}
|
||||
got := lanAddresses(ifaces)
|
||||
if !reflect.DeepEqual(lanIPs(got), want) {
|
||||
t.Fatalf("可用地址应为 %v,得到 %v", want, lanIPs(got))
|
||||
}
|
||||
// family 是给前端拼 URL 用的:目前只回 IPv4,字段也必须存在。
|
||||
for _, a := range got {
|
||||
if a.Family != "ipv4" {
|
||||
t.Fatalf("family 应为 ipv4,得到 %q(%s)", a.Family, a.IP)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 一个地址都挑不出来时必须回空切片而不是 nil:JSON 里要是 null,前端分不清
|
||||
// 「没有局域网地址」和「服务端没给这个字段」。
|
||||
func TestLanAddressesEmptyResultIsNonNil(t *testing.T) {
|
||||
cases := map[string][]ifaceSnapshot{
|
||||
"没有网卡": nil,
|
||||
"只有回环": {lanIf("lo", net.FlagUp|net.FlagLoopback, "127.0.0.1/8")},
|
||||
"只有 IPv6": {lanIf("eth0", net.FlagUp, "fe80::1/64")},
|
||||
"接口全 down": {lanIf("eth0", 0, "192.168.1.23/24")},
|
||||
}
|
||||
for name, ifaces := range cases {
|
||||
got := lanAddresses(ifaces)
|
||||
if got == nil {
|
||||
t.Fatalf("%s:应回空切片而不是 nil", name)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("%s:不该有地址,得到 %v", name, lanIPs(got))
|
||||
}
|
||||
raw, err := json.Marshal(networkInfo{Port: 1, Addresses: got})
|
||||
if err != nil {
|
||||
t.Fatalf("%s:序列化失败 %v", name, err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"addresses":[]`) {
|
||||
t.Fatalf("%s:addresses 应序列化为 [],得到 %s", name, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 同一地址配在多张网卡上(桥接、双网卡同网段)时只留一条,且保留先出现的那张网卡名;
|
||||
// 顺序按「像不像常见内网段」分档,同档再按地址数值升序——否则列表每次刷新都在跳,
|
||||
// 现场刚记住的链接下次就不是第一条了。
|
||||
func TestLanAddressesDedupAndOrder(t *testing.T) {
|
||||
ifaces := []ifaceSnapshot{
|
||||
// 常见的「网线 eth0 + WiFi wlan0」现场:WiFi 落在路由器出厂段 192.168.x。
|
||||
lanIf("eth0", net.FlagUp, "172.20.1.5/16", "192.168.1.23/24", "8.8.8.8/32"),
|
||||
lanIf("wlan0", net.FlagUp, "192.168.1.9/24", "10.0.0.4/8", "192.168.1.100/24"),
|
||||
lanIf("br0", net.FlagUp, "192.168.1.23/24"),
|
||||
}
|
||||
|
||||
want := []string{"192.168.1.9", "192.168.1.23", "192.168.1.100", "10.0.0.4", "172.20.1.5", "8.8.8.8"}
|
||||
got := lanAddresses(ifaces)
|
||||
if !reflect.DeepEqual(lanIPs(got), want) {
|
||||
t.Fatalf("地址顺序应为 %v,得到 %v", want, lanIPs(got))
|
||||
}
|
||||
// 192.168.1.23 先出现在 eth0 上,br0 上的同一地址要被丢掉、也不能改写 interface。
|
||||
if got[1].Interface != "eth0" {
|
||||
t.Fatalf("去重后应保留先出现的接口名 eth0,得到 %q", got[1].Interface)
|
||||
}
|
||||
}
|
||||
|
||||
// 排序档位本身:192.168/16 → 10/8 → 172.16-31/12 → 其余,越靠前越是现场真在用的那条。
|
||||
func TestLanRankPrefersCommonLanSegments(t *testing.T) {
|
||||
order := []string{
|
||||
"192.168.0.1", "192.168.255.254", // 路由器出厂段
|
||||
"10.0.0.1", "10.255.255.254", // 企业内网 / 热点
|
||||
"172.16.0.1", "172.31.255.254", // 最少见,多是虚拟网桥
|
||||
"172.15.0.1", "172.32.0.1", "203.0.113.9", // 172.16/12 之外
|
||||
}
|
||||
for i := 1; i < len(order); i++ {
|
||||
prev, curr := net.ParseIP(order[i-1]).To4(), net.ParseIP(order[i]).To4()
|
||||
if lanRank(prev) > lanRank(curr) {
|
||||
t.Fatalf("%s 不该排在 %s 之后(%d > %d)", order[i-1], order[i], lanRank(prev), lanRank(curr))
|
||||
}
|
||||
}
|
||||
if lanRank(net.ParseIP("172.15.0.1").To4()) != 3 || lanRank(net.ParseIP("172.32.0.1").To4()) != 3 {
|
||||
t.Fatal("172.16/12 的两侧边界不该被当成常见内网段")
|
||||
}
|
||||
}
|
||||
|
||||
// 网卡地址实际都是 *net.IPNet,但兜底分支也要能取到 IP,否则换平台(如某些 BSD/虚拟网卡)
|
||||
// 会静默把地址全滤掉,现场表现为「明明有网却显示未检测到」。
|
||||
func TestAddrIPHandlesBothAddrTypes(t *testing.T) {
|
||||
if got := addrIP(&net.IPAddr{IP: net.ParseIP("192.168.1.23")}); got.String() != "192.168.1.23" {
|
||||
t.Fatalf("*net.IPAddr 应取到 IP,得到 %v", got)
|
||||
}
|
||||
if got := addrIP(&net.IPNet{IP: net.ParseIP("10.0.0.4"), Mask: net.CIDRMask(8, 32)}); got.String() != "10.0.0.4" {
|
||||
t.Fatalf("*net.IPNet 应取到 IP,得到 %v", got)
|
||||
}
|
||||
// 认不出的地址类型不能让整条链路 panic。
|
||||
if got := addrIP(nil); got != nil {
|
||||
t.Fatalf("未知地址类型应返回 nil,得到 %v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
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"})
|
||||
|
||||
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 且是 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"})
|
||||
if entry["family"] != "ipv4" {
|
||||
t.Fatalf("family 应为 ipv4,得到 %#v", entry["family"])
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 调用方忘了注入 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"])
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/config"
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/export"
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/webui"
|
||||
@@ -51,6 +52,10 @@ type Options struct {
|
||||
WS http.Handler
|
||||
// ProjectDir 可选,当前赛事项目目录(含 uploads/),用于赛事包导出。
|
||||
ProjectDir string
|
||||
// Port 是服务端**实际监听**的端口,由 main 从 config 传入;GET /api/system/network
|
||||
// 用它拼出「平板能直接打开的链接」。<=0(调用方没传)时按 config 的默认端口兜底,
|
||||
// 不让响应里出现 :0 —— 详见 New。
|
||||
Port int
|
||||
// WebFS 可选,默认使用内嵌前端资源。
|
||||
WebFS fs.FS
|
||||
}
|
||||
@@ -63,11 +68,13 @@ type server struct {
|
||||
auth *auth.Service
|
||||
dataDir string
|
||||
projectDir string
|
||||
backups *export.Service
|
||||
shutdown func()
|
||||
display DisplaySender
|
||||
realtime Realtime
|
||||
idem *idempotencyStore
|
||||
// port 是实际监听端口,只读地传给 GET /api/system/network(见 Options.Port)。
|
||||
port int
|
||||
backups *export.Service
|
||||
shutdown func()
|
||||
display DisplaySender
|
||||
realtime Realtime
|
||||
idem *idempotencyStore
|
||||
}
|
||||
|
||||
// New 组装并返回服务端的 HTTP 处理器。
|
||||
@@ -88,6 +95,12 @@ func New(opts Options) http.Handler {
|
||||
}
|
||||
backups = export.NewService(opts.System, opts.Event, dir, 0, logger)
|
||||
}
|
||||
// 端口取实际监听值。调用方漏传时用 config 的默认端口兜底,而不是在这里另抄一个数字:
|
||||
// 响应里出现 19540 至少是个真端口,出现 :0 会让前端拼出一条根本打不开的链接。
|
||||
port := opts.Port
|
||||
if port <= 0 {
|
||||
port = config.Default().Server.Port
|
||||
}
|
||||
|
||||
s := &server{
|
||||
logger: logger,
|
||||
@@ -97,6 +110,7 @@ func New(opts Options) http.Handler {
|
||||
auth: svc,
|
||||
dataDir: opts.DataDir,
|
||||
projectDir: opts.ProjectDir,
|
||||
port: port,
|
||||
backups: backups,
|
||||
shutdown: opts.Shutdown,
|
||||
display: opts.Display,
|
||||
@@ -202,6 +216,11 @@ func (s *server) routes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /api/system/session", s.read(auth.RoleAdmin, s.getSessionSettingsHandler))
|
||||
mux.HandleFunc("PUT /api/system/session", s.write(auth.RoleAdmin, s.updateSessionSettingsHandler))
|
||||
|
||||
// 大屏控制页要用「当前设备的局域网地址」拼出平板能直接打开的链接(可点 + 复制 + 二维码)。
|
||||
// 面板自己常常是从 127.0.0.1 打开的,浏览器端点算不出本机局域网 IP,只能由服务端枚举网卡。
|
||||
// 与 /api/system/about 同档:都是机器级信息,guest 一律 403。
|
||||
mux.HandleFunc("GET /api/system/network", s.read(auth.RoleRecorder, s.systemNetwork))
|
||||
|
||||
// 基础配置
|
||||
mux.HandleFunc("GET /api/teams", s.public(s.listTeamsHandler))
|
||||
mux.HandleFunc("POST /api/teams", s.write(auth.RoleRecorder, s.createTeamHandler))
|
||||
|
||||
Reference in new issue
Block a user