From 56ed90ff8bf43734302d90f0545019d99aece4f0 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 17 Sep 2026 18:19:38 +0800 Subject: [PATCH] =?UTF-8?q?fix(display,entry):=20=E4=BF=AE=E5=A4=A7?= =?UTF-8?q?=E5=B1=8F=E5=9B=A2=E4=BD=93=E5=90=8D=E6=AC=A1=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E3=80=8C-=E3=80=8D=E4=B8=8E=E5=88=86=E9=A1=B9=E6=88=90?= =?UTF-8?q?=E7=BB=A9=E7=AC=AC=203=20=E6=A0=8F=E7=BC=BA=E7=AC=AC=201=20?= =?UTF-8?q?=E5=90=8D=EF=BC=9B=E5=8F=91=E5=B8=83=E6=94=B9=E5=BC=B9=E7=AA=97?= =?UTF-8?q?=E9=80=89=E5=8F=A3=E5=BE=84=EF=BC=9B=E4=BC=9A=E8=AF=9D=E8=AE=BE?= =?UTF-8?q?=E5=A4=87=E5=90=8D=E4=B8=8E=E9=87=8D=E5=91=BD=E5=90=8D=EF=BC=9B?= =?UTF-8?q?=E5=BD=95=E5=88=86=E6=90=9C=E7=B4=A2=E6=A1=86=EF=BC=9B=E7=89=88?= =?UTF-8?q?=E6=9C=AC=20dev=5F0.5.15?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本批七项(三处是现场报的 bug,四处是新功能): ① 大屏上「纯团体项目」(如团体 4x100 接力)的名次全是「-」 根因不在算分:纯团体名次只落在 channel_ranks,而 results.rank 被有意收窄成 「个人名次」、对团体主体一直是 NULL;大屏的两个读者只读 results.rank、没有兜底 (导出件与积分明细下钻早就有兜底,所以当时是「导出有、屏幕没有」)。 改成读侧回落:results.rank 优先、为空时用通道名次。算分与写回一行未动。 ⚠️ 优先级的理由:只更新名次的发布只更新 results.rank、不重建通道名次, 若通道名次优先,个人项目会在那种发布之后显示旧名次。 ⚠️ 已发布的数据不需要重发或重算,升级后大屏下一次刷新即显示。 ⚠️ 仍未覆盖:只在「团体名次」卡片里按单位录名次、成绩行上没填的那类(无成绩行可挂)。 ② 分项成绩页第 3 栏看不到第 1 名 根因:快照 latestResults 的窗口原来按「行」截断(LIMIT 20),而屏上是一组一栏, 切口只能落在最旧那一栏(通常是第 3 栏)内部,把该组建号最早的行挤掉 (手录项目从第 1 名往下录 → 第 1 名建号最早)。 改成「最近 8 组 × 每组 10 行」(先按每组最新发布时间取 8 组,再每组按名次取前 10 行); 组的 publishedAt 改为组内最新一条。底栏「最新完成项目」一起吃这个修复。 ⚠️ 取舍:单组超过 10 行的部分不再下发(前端每栏只画 6 行、实时赛况上限 10 行); 将来把每栏行数上限调到 10 以上时,必须同步调这里的 R。 ③ 录分列表「运动员 / 队」列可限宽 + 逐行展开收起 多人团体名单以前把整列撑宽。收起限宽 14em(省略号)、超过 24 个显示宽度 (中文算 2)出现「展开 / 收起」;个人项目不出现按钮;展开状态只在本机内存。 取舍:展开那一行会把列略微撑宽(成绩列右移约 80px),收起即恢复。 ④ 录入页「以决赛成绩发布」从常驻勾选框改成发布弹窗里二选一 默认「只更新名次(不计分、不发奖牌)」;每次打开都复位、不吃上一次; Alt+Enter 走同一个入口;取消 = 什么都不发生。 审核页保持原样的常驻勾选框(两个页面口径一样、形态不同)。 ⑤ 大屏控制页「在线大屏」列表:可读设备名 + 手动重命名 + 修重复条目 - 自动设备标签(系统 · 浏览器 · 分辨率 · 手机/平板/桌面),由大屏端在握手时上报, 服务端截断到 80/300 字符并清掉控制字符;老客户端不报时列表与升级前逐字一致。 ⚠️ 浏览器没有读主机名/设备名的 API,自动标签只是辅助区分。 - 手动重命名(录入员及以上,与切页同档):存在系统库 settings 的 display.instance_aliases(机器级、重启与切赛事都不丢),清除即回到自动标签; 显示优先级 alias → device → id(id 降级成小字)。上限 200 条,满了拒绝新增并提示, 不静默顶掉别的设备。设备离线过久条目被清理后别名仍留着。 - 修:以前给局域网设备切页后列表底部会多出一条该设备的离线条目 (翻页会补发握手,而浏览器没有稳定 id、服务端每次新开一条实例)。 现在浏览器本机记住实例 id(localStorage),服务端也复用同一条连接已有的 id。 ⑥ 局域网地址条目都能出二维码 以前只有第一条常驻出码;现在其余每条多一个「二维码」按钮:点一下临时显示, 再点一次 / Esc / 点空白处收起,同一时刻只留一个,且只为当前打开的那条懒生成 SVG (不在渲染时为每条地址都算一遍)。第一条的常驻码与「复制链接」未变。 ⑦ 录分列表加「搜姓名 / 号码布」搜索框(人多项目找人不费劲) 复用既有的「姓名 / 工号 / 号码布」匹配口径(未新增第三份),支持模糊、大小写不敏感、 全角空格、号码布数字口径(007 搜 7、7 搜 007);命中高亮;提示「找到 N 条 / 共 M 条」; × / Esc / 清除三种清空方式;搜不到给明确空状态。 ⚠️ 搜索只影响表格显示:发布范围、进度条(仍是该批次总人数)、勾选、清空范围、 预览算分一律不受影响,界面上也写明了这一点。 ⚠️ 搜索状态下粘贴跟随「屏幕上看到的顺序」(隐藏的行不会被写到)——避免第二条成绩 静默写到看不见的人身上。 测试:四套自检 ×2 全绿(finalcheck_0515*.log:前端 1096 例 / Console 96 例 / 服务端 14 条 ok, EXIT=0)。①②都做了红/绿对照(改回旧实现,新用例当场变红;②的失败信息即现场形状 「第 3 栏应从第 1 名开始,实际名次 [5 6 7 8]」)。⑥⑦用真浏览器(playwright + chromium) 实机跑过(24 项 / 29 项全过,含「进度条数字不变」「搜索框里按 Alt+Enter 什么也不发生」)。 无数据库迁移;已有成绩、名次、积分与榜单不变。 文档:《升级说明》新增 dev_0.5.15;《使用手册》发布/录入/大屏控制三块与批量订正过期说法; 《常见问题》新增 Q105–Q107;《部署指南》《上线检查清单》同步; devdocs/05(hello 新字段 + 别名接口)、13(第五十五轮)、16/06/07/18 与两处前端注释的旧窗口说法一并订正。 --- VERSION | 2 +- console/installer/art/1x/brand.bmp | Bin 316854 -> 316854 bytes console/installer/art/2x/brand.bmp | Bin 1267254 -> 1267254 bytes console/installer/art/version.txt | 2 +- console/package.json | 2 +- docs/上线检查清单.md | 7 +- docs/使用手册.md | 67 +- docs/升级说明.md | 122 ++- docs/常见问题.md | 40 + docs/部署指南.md | 6 +- server/internal/export/load.go | 5 + server/internal/httpapi/display_alias_test.go | 706 ++++++++++++++++++ server/internal/httpapi/live_events.go | 37 +- server/internal/httpapi/live_events_test.go | 199 ++++- server/internal/httpapi/realtime.go | 204 ++++- server/internal/httpapi/server.go | 4 + server/internal/realtime/hub.go | 7 +- server/internal/realtime/instances.go | 61 +- server/internal/realtime/instances_test.go | 173 +++++ server/internal/realtime/protocol.go | 60 ++ server/internal/realtime/snapshot.go | 133 +++- server/internal/realtime/snapshot_test.go | 373 +++++++++ web/src/admin/api/client.ts | 9 + web/src/admin/components/ConfirmDialog.vue | 6 + .../components/baseData/entrySelection.ts | 112 ++- web/src/admin/pages/DisplayPage.vue | 302 +++++++- web/src/admin/pages/EntryPage.vue | 439 +++++++++-- web/src/admin/pages/displayInstanceLabel.ts | 66 ++ web/src/admin/pages/entryPublish.ts | 113 ++- web/src/admin/pages/entryRowDisplay.ts | 79 ++ web/src/admin/pages/entrySearch.ts | 116 +++ web/src/admin/pages/lanDisplay.ts | 45 ++ web/src/admin/tests/display-alias-api.test.ts | 161 ++++ .../tests/display-instance-label.test.ts | 97 +++ web/src/admin/tests/entry-row-bib.test.ts | 141 +++- web/src/admin/tests/entry-search.test.ts | 353 +++++++++ web/src/admin/tests/lan-display-card.test.ts | 8 +- web/src/admin/tests/lan-qr-toggle.test.ts | 151 ++++ web/src/admin/tests/publish-final.test.ts | 149 +++- web/src/display/components/pages/LivePage.vue | 6 +- web/src/display/deviceLabel.ts | 383 ++++++++++ web/src/display/instanceId.ts | 136 ++++ web/src/display/live.ts | 3 +- web/src/display/results.ts | 5 +- web/src/display/tests/device-label.test.ts | 233 ++++++ web/src/display/tests/device-wiring.test.ts | 70 ++ web/src/display/tests/hello-identity.test.ts | 152 ++++ web/src/display/tests/instance-id.test.ts | 134 ++++ web/src/display/useScoreboard.ts | 31 +- web/src/shared/api.ts | 46 +- web/src/shared/realtime.ts | 38 + web/src/shared/tests/realtime.test.ts | 93 +++ web/src/shared/types.ts | 9 + 53 files changed, 5675 insertions(+), 221 deletions(-) create mode 100644 server/internal/httpapi/display_alias_test.go create mode 100644 server/internal/realtime/instances_test.go create mode 100644 web/src/admin/pages/displayInstanceLabel.ts create mode 100644 web/src/admin/pages/entrySearch.ts create mode 100644 web/src/admin/tests/display-alias-api.test.ts create mode 100644 web/src/admin/tests/display-instance-label.test.ts create mode 100644 web/src/admin/tests/entry-search.test.ts create mode 100644 web/src/admin/tests/lan-qr-toggle.test.ts create mode 100644 web/src/display/deviceLabel.ts create mode 100644 web/src/display/instanceId.ts create mode 100644 web/src/display/tests/device-label.test.ts create mode 100644 web/src/display/tests/device-wiring.test.ts create mode 100644 web/src/display/tests/hello-identity.test.ts create mode 100644 web/src/display/tests/instance-id.test.ts diff --git a/VERSION b/VERSION index 548b0df..30a3103 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -dev_0.5.14 +dev_0.5.15 diff --git a/console/installer/art/1x/brand.bmp b/console/installer/art/1x/brand.bmp index 48e0736340310dee48c64d3d07e0d83aac9ae499..8de82150ff126f1a7c4e3d77dd85253c9f3b1551 100644 GIT binary patch delta 428 zcmdnCT6o)P;SE|joc=lj0bsP*IOhlxKalIK!Q-jQ>7&6DpewLBj8TkfvP|JJ5fceN zZGKNRt{6+%98cX?OIa^1Pp1xXqb4feL`;01W|};G@o+WT(8ODLvXk#usc(U#mFNWYhXN z(m;oLsdHzzYR#)j>`3*TRS{bfXzH!O4f0Y9$V*I3^FfM$2&kw(ClF{^u)eUDI?#Il z03H6#J*_}Rms-GzfNn{$SDsoF4hj<;fgpV$xT390(m;EFevdShnp+(|xiAbE0v%~y zKzo2{fTl*~~ccK6Q delta 418 zcmdnCT6o)P;SE|jo4pt{m?j71E#>#q1|feSsm14~&9^y>QH*J_OyM#SV+n{9L^eoI zC`exjBKxLznN+fezpfxqDnM7zU75W;*0D0&+FgY`P*-4c45I+kZ&*d^CB1 zD%_OWn-g7YqwV1;gqbGyS1ytUN`xATP0A1H%L(kw4wzFFx3E4r*IVCPoolmC)mA16 zPc<%Y4IZFWeT<`*8h1ykS5tzEuNLp-&Q=+w$)@#lq&-wQeYE(34Mk^`M+1FP7wu3J zY3ruU3ULY0FHBAIL5c$Ogxpm)iu_GF)4gk>>=2IWX$2~})B;unbTJTlsd2R?yDw@; zEf2BqP~`xb)YH0^NgC)FhytLmLJdV1H>Q;YnR%*lLKH|cO)l+P$`4fGtH}!qE>L(z nG6E%>dROp(0!v2#9?J4e&7u?9MJF%|EFPUlo_kZtu-#7bPw*4*JgI%iO^96@S z2H4e7d$t5{YGgpLpDtp!*Iy_n6_0-_RiYaTfh}87l^f&AiMFal6$$~)7#PleK$)pD z$Z?>)FloA_{Q21J&6%!O)14~=HGP#Atyv)QzoJ!-G8WsAdy{okl^N((cn3lAIPEgL>hYiWipOKi!E0-B3cAP)wH zM%?;}Oo3A?3;O6>hEsn>6%{;}Hp_byFn_9U!gV%0kx90j@_&l!MXc%(_ zC+JHIckqK0LmQ|B$JQ*dLn9}zVIU*5bZxltoxk5>NUu~0NwG;fkKqP>QU+@ED~}c^ zNsQqnPjx7YtOC7N+fVLwma8nvDCoG!BML(KAIcP}NTLtbg`OZJh7!Yg>aBl2KhS1m z&y<4^#AO&3r4Xryypo}zInUKJ!VFDwX^75>~V!No(qRT>>ip(f~ zmu<`6Uz;C)EiIx_Da48aIEpU*(;Uj7A!~`|XIif#+jf%zTY=5Qh`HbSw29$*<7hL* zl0z(iqQQvvFaTVPhH*E5R>Nja+@DV-?K-d{EPvRo$F06P16Hi9Gy_Z5YDjN4v2_Ir z=;1`Ln|{(RY@pG-<&lpIgNOxN4SZM_{Iu$uYA!RgJZFxaL!_e-BpeK&xg-^q#%^61 zKgC#W$^Ay-ow7{qo&!S*4S+k=qK6d34@&?#IfH^{%?fSdDEU$YI@&@d;&!K24j_gw x$a4;Uk>QgvFpu)}GL$5>N`tKsgxA5muW?QdKnnbDwbk^K)iJ)MMeF%`6RZoLMgUm%kDZu}SaaBAr%N zhbW*vKABv&?h7!A)(V{%#r{o*60{aHn-$W5*=vvuG?ZmhiHH4OuOTOBRl#3N^LyWg zC?T7=qw3616LZ6D9&E%h;nH7kOtd+-R^uxpkpY*n&t(YP)tOm=y7hy&2#b9DCV4Dy z@zc{g8%r@HgNyk0!^-V93&R^rW4miJ>(f05$Il)Rkh))_k+8_Gv-bR}`$MlEL?=R3 zF|J}fSUKWz%yl(@OpUjVHnUN$1@T%O8vfn^iOYme_)Yn6qliSi(|sW)Xi<6ev{aTy zPa%!~l#~l|Z2yf=41RNrGBwt=^<)y(khK69T8C7n)=A9HA(ch60gI~ZQZb5JpX$L( zIg>ov#E6JEBMITxa>3ZJ1QAw;1ktjc^z>Yp%cIY^>9L+*1PtZW(0sLua%VAdeS%i? z^LQtU?{Suh6(Kt*)DgC?1ICC9I)c)!UC{$u9c}G*8Bav}+la}tLRHe5ijBP6eH9oQ z{_fG(;-J4dyLm@dohm)X2(V3!PP|O^W(OM+(qn{eJz}O9AttPpPHFXq}1JRHS68@J5:19540/display` 能出画面,或直接扫「大屏控制 → 局域网大屏」卡片第一个地址的**二维码**;打不开时先看卡片列出的地址与提示,再按常见问题「大屏控制页显示未检测到局域网地址怎么办?」逐条排查(若卡片地址是 `169.254.x.x`,说明本网段没有 DHCP,同网段的设备照样能连) - [ ] **公告可用**:推送一条测试公告 → 大屏全屏显示 → 到时退回原页且**底栏公告栏仍常驻显示正文** → 点「清除公告」后底栏回到「暂无公告」 - [ ] **实时赛况配置正确**:「大屏控制 → 实时赛况」卡片里模式与项目已按今天赛程选好(自动判定 / 人工指定),「当前生效」显示的就是此刻该显示的项目;大屏切到「实时赛况」页能看到该项目**已发布**成绩的实时排名(两个项目同时进行时应自动左右分栏) -- [ ] **分批发布演练**:选一个分多场跑的项目,先只填一批成绩发布 → 大屏立即出现这一批的排名 → 补录下一批再发布 → 排名自动重算(未录的行不会被当成 0 分,也不挡发布) +- [ ] **分批发布演练**:选一个分多场跑的项目,先只填一批成绩发布(发布窗口里按当天口径选「只更新名次」或「按决赛成绩发布」,选完才确认)→ 大屏立即出现这一批的排名 → 补录下一批再发布 → 排名自动重算(未录的行不会被当成 0 分,也不挡发布) +- [ ] **分项成绩页看得全**:切到大屏「分项成绩」页,**每一栏(含第 3 栏)都从第 1 名开始**;**团体项目的名次列不应是 `-`**(升级后不用重发、不用重算,刷新即可)。⚠️ 例外:只在「团体名次」卡片里按单位录了名次、成绩行上没填的项目仍无大屏名次(见常见问题 Q105 / Q106) - [ ] **报名项批量操作演练**:在「成绩录入」勾选几行试一次**批量退赛 → 批量恢复**(确认框会逐条列出被跳过的 dq / dns 行及原因);「批量移出报名」是**物理删除、不可恢复**,只演示确认框、不要在真实报名上执行(详见使用手册 4.8) - [ ] **报名名单导出演练**:「导出 → 结果导出 → 报名名单」按小项 / 组别 / 单位 / 批次筛一次并导出,核对 13 列与状态中文(正常参赛 / 已退赛 / 取消资格 / 未出场)与录入页一致 @@ -82,7 +83,7 @@ ## 7. 现场流程演练(建议至少一次) -- [ ] 录入员登录 → 录入一条成绩 → 存草稿 → 发布 → 大屏刷新 +- [ ] 录入员登录 → 录入一条成绩 → 存草稿 → 点「**发布**」(弹出的窗口里**默认是「只更新名次(不计分、不发奖牌)」**;今天这一项要计分发牌就选「**按决赛成绩发布**」再确认)→ 大屏刷新 - [ ] 撤销发布 → 修改 → 重新发布(历史记录里能看到链路) - [ ] 导出核对:「结果导出」里「全部」与「全部(单页)」都能正常下载(后者应是一个工作簿、只有一张表);「积分榜」页三个榜单的卡片头各有导出——单位总分榜「导出总明细」得到 `单位积分总明细.xlsx`、单位奖牌榜「导出奖牌明细」得到 `单位奖牌明细.xlsx`、个人积分榜「导出个人明细」得到 `个人积分总明细.xlsx`;展开某单位后还能点「导出该单位明细」得到 `单位积分明细.xlsx` - [ ] 断网 → 大屏仍显示缓存数据 → 恢复后自动补增量 diff --git a/docs/使用手册.md b/docs/使用手册.md index ba59935..d00fdda 100644 --- a/docs/使用手册.md +++ b/docs/使用手册.md @@ -112,6 +112,8 @@ 表格列依次为:勾选框、录入(状态徽标)、号码、批次、单位、运动员 / 队、成绩输入、名次、状态、积分(自动)、备注、操作。**「运动员 / 队」这一列在姓名后面直接跟着该人的号码布**(灰色小字,例如 `张伟 101`):号码列仍然保留,这一处只是省得在长表格里左右横跳找号。**纯团体报名这一格旁边还有一个「队员」小按钮**(点开登记 / 修改这支队的队员,见 4.13);普通报名没有这个按钮。 +**「运动员 / 队」这一列会限宽,长名单可以手动展开**:多人团体项目的队员名单(`张伟(A001)、李强(A002)…`)以前会把整列撑得很宽,现在**收起时限宽**(**14em ≈ 182px**,超出显示省略号);名单够长时(超过 **24 个「显示宽度」**——中文算 2、字母算 1)这一格右侧出现**逐行的「展开 / 收起」**小按钮,点开看全。**个人项目永远不会出现这个按钮**。展开状态**只在本机内存、按报名项记**,换项目 / 刷新即恢复收起;展开那一行会把列略微撑宽(成绩列右移约 **80px**),点回收起就恢复。 + **「单位」这一列兼作「成员单位与份额」的入口**:普通报名显示单位简称(或全称)——**这一格与以前长得一模一样**,只是鼠标移上去变成手型、旁边有一行悬停提示,**点一下**会打开「成员单位与份额」弹窗(什么单位参赛、各占多少,见 4.11)。一条报名挂了多个单位(**联队**,两个单位因人数不足合并参赛)时,这一格会多一个「联队」小标,并把成员单位与份额逐条列出来:`联队 第一分公司 50% 第二分公司 50%`;不是均分时还会带上权重,例如 `联队 第一分公司 67%(权重 2) 第二分公司 33%`。**报名的队名**(同一个单位派两个队时写的那个,见 4.12)直接跟在单位名后面显示,例如 `第一分公司二队`;**没填队名时这一格与以前逐字一样**。 | 列 | 操作 | @@ -142,6 +144,19 @@ **示例文本不是已填内容**:空格子里显示的 `例 1:06.33`(距离 `例 12.34`、计数 `例 12`、得分 `例 96.5`)是**更淡的斜体灰字**,只是告诉你大概怎么写,**不代表这一格已经有成绩**。灰字不会被提交;点发布时如果那一格还是灰字,就按「还没录」自动跳过(本次不发布这一行),不会连累其他已填成绩的发布。过去示例文本长得和真实输入一样,容易看成「我已经填了」。 +**「搜姓名 / 号码布」搜索框(人多项目找人不费劲)**:位置在工具栏 **「批次」下拉右边**。 + +- **即时模糊匹配**:大小写不敏感、首尾空格与全角空格都当空白;号码布按**数字口径**——`007` 搜 `7`、`7` 搜 `007` 都能找到; +- **命中高亮**(只加一层淡底色); +- 输入框旁边显示 `找到 N 条 / 共 M 条`(**M 是当前批次口径的总行数**,不是搜索结果数); +- 清除有三种:输入框右侧的 × 按钮、在输入框里按 `Esc`、「清除搜索」按钮;搜不到时给明确的空状态与提示,不会只剩一张空表; +- 搜索范围是**行的姓名 / 队员名单 / 队员(含号码布)/ 行号码布**,**不含单位名**; +- **换小项 / 换组别会清空搜索**。 + +⚠️ 界面上也写着这行小字:**「搜索只影响表格显示,不影响发布范围与进度」** —— 筛完再点「发布」,发的仍然是**该小项 + 该组别里所有已填的成绩**(不是只发筛出来的这些);进度条的「已录 x/y」也仍然是**该批次总人数**。 + +⚠️ **粘贴跟着屏幕走**:搜索状态下按 `Ctrl+V` 是**按屏幕顺序**往下填的(**被筛掉、看不见的行不会被写到**)——**这是有意的**:否则第二条成绩会静默写到看不见的人身上。要按原来的行序粘贴,请先清除搜索。详见 4.3。 + 右侧「实时积分预览」显示:若现在发布,各单位积分的变化、最大分差、受影响单位数。 ### 4.3 键盘流与批量录入 @@ -151,14 +166,15 @@ | `Tab` | 跳到下一个单元格(浏览器原生焦点顺序) | | `Enter` | 在成绩格之间依次移动 | | `Ctrl+Enter` | 存草稿 | -| `Alt+Enter` | 发布当前项目 | +| `Alt+Enter` | 打开**发布窗口**(与工具栏「发布」同一个窗口,口径在里面二选一,见 4.5) | | 「按成绩重排」 | 按成绩从小到大 / 从大到小重排表格行(名次仍以比较值为准) | | 「下一格」 | 按钮方式移动焦点 | **从 Excel 粘贴**:复制区域后,在成绩格按 `Ctrl+V`。 - 两列及以上:第一列按单位名称 / 简称匹配行,最后一列作为成绩; -- 单列:从当前行开始向下依次填充。 +- 单列:从当前行开始向下依次填充; +- ⚠️ **搜索状态下按屏幕顺序粘贴**:表格正在按搜索框筛选时,粘贴只落在**看得见的行**上(按屏幕顺序),被筛掉的行**不会被写到**——这是有意的:否则第二条成绩会静默写到看不见的人身上。要按原来的行序粘贴,先清除搜索(见 4.2)。 ### 4.4 防呆校验 @@ -180,10 +196,22 @@ 1. **存草稿**:保存当前组别的成绩,草稿**不会上屏**,也不计入积分榜;有未保存修改时页面会显示「有未保存修改」徽标。 - **未保存的成绩不会被静默丢掉**:有未保存修改时,点左侧菜单切到别的页面(以及浏览器前进 / 后退)会先弹出确认框,确认丢弃才离开;关闭或刷新浏览器时浏览器也会原生提示;切换项目本来就会先确认。选「取消」留在本页,改动都还在。 -2. **发布**:点击后弹出确认框,说明本次将发布多少人、还有多少人没成绩,以及影响的单位数与最大分差;确认后这批成绩立即上屏并重算积分榜(若管理员开了**延迟结算**,则只上屏成绩,名次与榜单等该组录完再更新,见 4.6、10.5)。 +2. **发布**:点击后弹出**发布窗口**(标题形如 `发布该组:请选择本次的发布口径`),先在**两种口径里二选一**,再点「确认发布」: + + | 选项 | 会发生什么 | + | --- | --- | + | **只更新名次(不计分、不发奖牌)** | **默认选中**:名次照常算出来、写进系统,但不产生积分、不发奖牌(分轮次比赛里给预赛 / 复赛用)。选项下面的说明就是 `本次只更新名次:不计分、不发奖牌,榜单分数不变。` | + | **按决赛成绩发布(重算名次并计分、发奖牌、榜单跟着更新)** | 与升级前的「勾上再发布」完全一样:算分、发牌、上榜 | + + - 窗口里同时写明本次将发布多少人、还有多少人没成绩;选「按决赛成绩发布」时才会报「影响 N 个单位、最大分差 X 分」(只更新名次时报这两个数是假话,所以不报); + - 确认后这批成绩**立即上屏**:选计分那一项时同时重算积分榜;**只更新名次**时名次照常更新、榜单分数不变(若管理员开了**延迟结算**,则只上屏成绩,名次与榜单等该组录完再更新,见 4.6、10.5); + - **取消 = 什么都不发生**;**每次打开都回到默认那一项**(**不吃上一次的选择**)——这条是故意的:避免「换个小项再点发布,把预赛当成决赛算分」这种最贵的错法; + - `Alt+Enter`(见 4.3)走的是**同一个窗口**; - 发布前系统会自动把当前内容整组保存为草稿再发布,无需手动先存; - 示例数据模式下「发布」按钮不可用,避免把未落库的改动当成发布成功。 + > ⚠️ **成绩审核页(第 5 节)的入口形状不一样**:那里**仍然是发布按钮旁边的常驻勾选框**(勾上才计分发牌)。两页的口径与算法完全一致,只是录入页把口径挪进了弹窗——别以为审核页也改成弹窗了。 + ### 4.6 分批发布(跑一批发一批) 跑道只有 8 条、一个项目可能要分好几场跑时,**不需要等所有人都跑完**:每跑完一批就发一批,没跑到的先空着。 @@ -444,6 +472,8 @@ - **发布整组**:确认框会写明本组还有多少条没有成绩、本次将发布已填的多少条;确认后**只发布已填的那部分**并重算积分榜(延迟结算下同样等该组录完才更新名次与榜单),没录的保持未发布,补录后再发一次即可(分批发布,见 4.6); - **退回**:当前版本服务端没有「退回」接口,此操作**仅在管理端本地标记**,不会改动成绩数据;刷新页面后标记可能消失。如需真正退回,请通知录入员到「成绩录入」修改后重新保存。 +> ⚠️ **本页的发布口径入口与录入页不同(两页别混)**:**本页保持原来的常驻勾选框** —— 发布按钮旁边的 **「以决赛成绩发布(勾选才计分发牌)」**,**默认不勾**,勾上才计分、发牌;旁边徽标随时显示当前是哪种口径(**只更新名次(不计分)** / **计分发布(算分发牌)**)。**录入页(4.5)则是点「发布」后在弹窗里二选一**(默认「只更新名次」)。两页发出去的请求口径完全一样,只是入口形状不同。 + > 草稿态永远不会上屏;发布以「小项 + 组别」为一组整体生效,但**没录成绩的条目会被跳过**——它既不挡发布,也不会被当成 0 分(防呆口径与录入页一致,见 4.4)。 --- @@ -598,14 +628,24 @@ - **保存**:改完模式或项目后点「保存」,提示「实时赛况设置已保存」后大屏按新配置显示;卡片底部注明「当前项目最多 2 个」; - 该卡片的「保存」需要**管理员**身份(见本节开头的权限说明);示例数据模式下不能保存。 -**在线大屏**:右侧「在线大屏」卡片列出的是**真实**的在线实例,直接读服务端 `GET /api/display/instances`,打开页面时每 5 秒自动刷新一次(浏览器切到后台时暂停,切回来立刻补一次)。每个实例一行,显示实例 ID、在线 / 离线徽标、「局域网浏览器」或「屏 N」标记、当前所在页、来源地址与最后心跳;服务端判定某个实例落后时还会多一个「落后」徽标(大屏可能在卡或网络差)。**本机用 Console 打开的大屏窗口、以及局域网设备打开 `/display` 连上的浏览器,都会出现在这里**——局域网浏览器没有屏号,那一栏显示「局域网浏览器」。离线实例会在服务端保留一段时间,方便排查它是什么时候掉的。 +**在线大屏**:右侧「在线大屏」卡片列出的是**真实**的在线实例,直接读服务端 `GET /api/display/instances`,打开页面时每 5 秒自动刷新一次(浏览器切到后台时暂停,切回来立刻补一次)。每个实例一行,显示**设备名**、在线 / 离线徽标、「局域网浏览器」或「屏 N」标记、当前所在页、来源地址与最后心跳;服务端判定某个实例落后时还会多一个「落后」徽标(大屏可能在卡或网络差)。**本机用 Console 打开的大屏窗口、以及局域网设备打开 `/display` 连上的浏览器,都会出现在这里**——局域网浏览器没有屏号,那一栏显示「局域网浏览器」。离线实例会在服务端保留一段时间,方便排查它是什么时候掉的。 + +**设备名怎么来的(自动标签 + 手动改名)**:局域网浏览器连上来时会报一个**自动设备标签**,形如 `安卓 13 · Chrome 120 · 1080×2400 @3x · 手机`,写法是 **系统 + 版本 · 浏览器 · 分辨率 · 形态(手机 / 平板 / 桌面)**,某个段取不到就跳过(例:`iPadOS 17 · Safari 17 · 2048×2732 @2x · 平板`、`Windows 10 · Edge 120 · 1920×1080 · 桌面`、`安卓 13 · 微信 8 · 1080×2340 @2.75x · 手机`)。 + +- ⚠️ **自动标签认不出「哪台机器」**:浏览器**没有**读主机名 / 设备名的 API(这是安全边界),所以**拿不到 `XIAOMI-PAD-05` 那种真机名**;`model` 字段只在**部分安卓浏览器**上可能有值。两台同型号设备靠自动标签是分不出来的; +- **手动改名**:每条有「改名」入口(**录入员及以上**可用,与切页同档)——点它就地出现一个输入框(示例 `二号楼平板`),填好按「保存」(`Enter` 也行);旁边的**「清除」**回到自动标签、**「取消」**什么都不改。名字**存在服务端**,**重启服务端与切换赛事项目都不丢**(输入框下方就写着这句),最长 **40 个字符**; +- 显示优先级是 **手动名 → 自动标签 → 那个随机实例 id**(id 降级成小字,排障时还在);读不出设备名时(老服务端 / 浏览器不报)列表退回显示 id,不会空着。**鼠标停在设备名上**还能看到排障要的那几行:设备标签、**原始 UA**、实例 id、来源 IP、屏号(取不到的整行不出现); +- **上限 200 条**:满了会**拒绝新增并提示先清理**(不会悄悄顶掉别的设备); +- 别名跟着**设备**走:设备太久没连、条目被服务端清理之后,**别名仍然留着**,同一台设备再连上来还是那个名字。 + +> **以前给局域网设备切页后列表会多出一条离线条目**(根因是翻页会补发一次握手,而浏览器没有稳定 id、服务端每次都新开一条实例):现在已经修好——**浏览器本机记住自己的实例 id**,服务端也做了兜底(同一条连接重复握手复用原实例),**不会再出现这种重复条目**。 **逐屏 / 逐设备切页**:在线大屏列表里每一行都在「当前页」旁带一个页面下拉和「切到此页」按钮,只把**这一块屏 / 这一台设备**切过去,其他屏不受影响;上方「切页」卡片里的「立即跳转」则是**所有大屏一起切**。离线实例的下拉与按钮是灰的,发不到它;点名的大屏一台都没收到指令时会明确提示「目标大屏已离线,请刷新在线列表」,不会谎报成功。列表读取失败时页面会提示「读取在线大屏失败」,并保留上一次的结果,不会把列表清空。 **局域网大屏**:这张卡片是给「用别的设备当大屏」准备的——把主机在局域网里可用的地址拼成完整链接,手机 / 平板 / 电视浏览器打开就是一块只读大屏(卡片上就写着「在别的设备浏览器打开下面的地址,就是一块大屏。」)。 - 卡片右上角徽标显示「N 个可用地址」;每个地址一行,**点链接**直接在新标签页打开,点旁边的**复制**按钮把完整地址(形如 `http://192.168.1.5:19540/display`)复制到剪贴板,按钮会短暂变成「已复制」; -- **第一个地址带一个二维码**(下方注明「手机扫码直接打开」),手机相机一扫即可打开,省去手敲长地址; +- **第一个地址带一个常驻二维码**(下方注明「手机扫码直接打开」),手机相机一扫即可打开,省去手敲长地址;**其余每条地址右侧多一个「二维码」按钮**:**点一下临时显示**那条地址的码,**再点一次 / 按 Esc / 点空白处**都能收起,**同一时刻只留一个**(屏幕小的时候不必为别的网段留一块地方); - **地址分三类**,卡片会照实把可用地址都列出来(排除的只有回环、未指定、组播、广播这四类对本机之外没有意义的地址): - **内网地址**(`192.168.x.x`、`10.x.x.x`、`172.16–31.x.x` 这类):路由器 / 交换机分配或手工设好的地址,也是最常见的现场用法,**排在最前**; - **全局地址**:不在上面这些网段里的地址(运营商专线等),能用就照常列出来,只是**排在私网地址之后**; @@ -1202,7 +1242,7 @@ > 大屏的标题字体与正文字体取自「系统设置 → 赛事信息 → 字体」(见 14.3):可选四款内置字体,也可以上传自己的字体;上传字体加载失败时自动回落到默认字体,画面不会出现方块或空白。 -> 除了 Console 开出来的本机大屏窗口,**局域网设备(另一台电脑、电视浏览器)打开 `http://<主机IP>:19540/display` 也能当大屏用**。这个地址不用手敲:管理端「大屏控制 → 局域网大屏」卡片里有**可复制的完整链接与二维码**(见第 9 节)。这类设备会出现在管理端「大屏控制 → 在线大屏」列表里(标记为「局域网浏览器」),也能被单独切页,用法见第 9 节;它们不受 Console 的 kiosk、防熄屏控制(另见常见问题 Q29)。 +> 除了 Console 开出来的本机大屏窗口,**局域网设备(另一台电脑、电视浏览器)打开 `http://<主机IP>:19540/display` 也能当大屏用**。这个地址不用手敲:管理端「大屏控制 → 局域网大屏」卡片里有**可复制的完整链接与二维码**(**第一条是常驻二维码,其余每条都能点「二维码」按钮临时出码**,见第 9 节)。这类设备会出现在管理端「大屏控制 → 在线大屏」列表里(带一个自动设备标签,可手动改名),也能被单独切页,用法见第 9 节;它们不受 Console 的 kiosk、防熄屏控制(另见常见问题 Q29)。 ### 17.2 轮播与锁定 @@ -1277,3 +1317,18 @@ **Q:为什么有些页面 / 按钮看不到或不可用?** 权限按角色控制:访客只能只读;变更历史、日志、系统设置、备份、用户管理、规则包与赛事项目包导入需要相应角色。请用对应账号登录。 + +**Q:为什么点「发布」还得多选一次?** +因为**这一次发布的「口径」要现选**(录入页原来的常驻勾选框已经取消)。窗口里二选一,**默认是「只更新名次(不计分、不发奖牌)」**,「按决赛成绩发布」才计分发牌、榜单跟着更新。 + +- **每次打开都回到默认那一项**,**不吃上一次的选择**——这是故意的:避免「换个小项再点发布,把预赛当成决赛算分」这种最贵的错法; +- **取消 = 什么都不发生**;`Alt+Enter` 走同一个窗口; +- ⚠️ **成绩审核页不一样**:那里仍然是发布按钮旁边的**常驻勾选框**(勾上才计分发牌)。两页口径一样,只是入口形状不同(见 4.5、第 5 节)。 + +**Q:人多的项目(3 公里、5 公里)里怎么快速找到某个人?** +在「成绩录入」页工具栏 **「批次」下拉右边**的搜索框里输入**姓名或号码布**:边打边筛(大小写不敏感、首尾与全角空格都当空白),号码布按**数字口径**比——`007` 搜 `7`、`7` 搜 `007` 都能找到;命中的字会**高亮**,旁边显示 `找到 N 条 / 共 M 条`(M 是当前批次口径的总行数)。清除有三种:输入框的 × 按钮、在输入框里按 `Esc`、「清除搜索」;**换小项 / 换组别会自动清空搜索**。搜索范围是姓名 / 队员名单 / 队员(含号码布)/ 行号码布,**不含单位名**。 + +⚠️ 两条要记住: + +1. **搜索只影响表格显示,不影响发布范围与进度**(界面上也写着这行小字):筛完再点「发布」,发的仍是这个小项 + 这个组别里**所有已填的成绩**;进度条的「已录 x/y」也仍是**该批次总人数**; +2. **粘贴跟着屏幕走**:搜索状态下按 `Ctrl+V` 只往**看得见的行**填(被筛掉的行不会被写到,免得第二条成绩静默写到看不见的人身上);要按原来的行序粘贴,请先清除搜索。 diff --git a/docs/升级说明.md b/docs/升级说明.md index 24affbb..26ecf32 100644 --- a/docs/升级说明.md +++ b/docs/升级说明.md @@ -6,6 +6,123 @@ --- +## dev_0.5.15 + +### 一句话 + +**没有任何数据迁移:不改一行已有数据、不用重配、不用重新导入规则包**,已经录过、发过的成绩与积分**不用重发、不用重算**——新版起来之后,**大屏下一次刷新就显示对**。这一版**修两处现场看得见的错**(大屏上纯团体项目的名次全是 `-`,见 ①;分项成绩页第 3 栏看不到第 1 名,见 ②),另有五处**新增 / 改口径**(运动员列可限宽、发布改成弹窗里二选一、大屏设备名可读且能改名、局域网地址条目都能出二维码、录入页多了搜索框,见 ③–⑦)。 + +### ① 修:大屏上「纯团体项目」的名次全是 `-` + +**先说清楚它不是什么**:**不是算分错、也不是团体名次没算出来**——名次早就在库里,**导出件与积分明细下钻一直是对的**,录入页也一直看得见。**只是大屏那两个读者没读到它。** + +**为什么只有大屏看不到**:纯团体项目的名次(例如团体 4×100 接力的单位名次)**只落在「通道名次」上**(发布时算出来的那份名次);而成绩行上的「个人名次」这一列**是有意收窄的**——它只记个人名次,**对团体主体一直是空的**。大屏的两个读者(快照的「最新成绩 / 分项成绩 / 底栏」与「实时赛况」)原来**只读「个人名次」、没有兜底**,于是团体行画出来就是一个 `-`;而**导出件与积分明细下钻早就有兜底**——所以当时是「导出件有名次、屏幕上没有」。 + +**现在会怎样**:这两个读者都改成 **「个人名次」优先、为空时回落到「通道名次」**。**算分与写回一行没动**(名次怎么算、流水怎么写、榜单怎么更新全都没变)。 + +> ⚠️ **为什么是「个人名次优先」而不是「通道名次优先」**:**「只更新名次」的发布只写「个人名次」、不重建通道名次**(见 `dev_0.5.8` 那一节)。要是让通道名次优先,个人项目在那种发布之后会**显示上一次的旧名次**。反过来「个人名次为空才回落」正好补上团体主体——团体名次本来就不写「个人名次」。 + +**现场要强调的**:**已经发布过的数据不用重发、不用重算**,新版起来大屏下一次刷新就显示出来;名次在录入页本来就看得见,这一版只是把同一份名次也画到大屏上。 + +⚠️ **仍未覆盖的一种情形(如实写)**:**只在「团体名次」卡片里按单位录了名次、成绩行上没填**的那类项目(例如拔河只用卡片录),名次只存在「按单位录入的团体名次」里、**没有对应的成绩行**,大屏上**仍然显示 `-`**。这一版只修「名次落在通道名次上」的那一类。 + +### ② 修:分项成绩页「第 3 栏看不到第 1 名」 + +**症状的形状很特别**:同样几个项目,**放到前两栏就没事,落到第 3 栏就看不到第 1 名**——这个毛病**跟着栏位走、不跟着项目走**。 + +**真正的原因**:服务端快照里「最新成绩」的窗口原来是**按行取 20 行**,而大屏上是「一组一栏」——窗口的切口**只能落在最旧的那一栏(通常就是第 3 栏)内部**,把那一组里**建号最早的行**(手录项目通常就是第 1 名)挤了出去。 + +**现在会怎样**:窗口改成 **「最近 8 组 × 每组 10 行」**——先按每组最近一次发布时间取**最近 8 组**,再在每组里**按名次保底取前 10 行**。名次的切口不再落在某一栏内部,**第 3 栏从第 1 名开始画**。**底栏那条「最新完成项目」也一起吃这个修复。** + +**取舍(要写清)**:单组**超过 10 行**的部分**不再下发**。前端每栏只画 6 行、「实时赛况」上限也是 10 行,所以三个页面都看不出来;⚠️ **将来若把每栏行数上限调到 10 以上,必须同步调整这个「每组 10 行」**(这两处是一对)。 + +### ③ 新:录分列表「运动员 / 队」这一列可限宽 + 手动展开 + +多人团体项目的名单(`张伟(A001)、李强(A002)…`)以前会把整列撑得很宽。现在: + +- **收起时限宽**(**14em ≈ 182px**,超出显示省略号); +- 文本够长时(超过 **24 个「显示宽度」**——中文算 2、字母算 1),这一格右侧出现**逐行的「展开 / 收起」**小按钮; +- **个人项目永远不会出现这个按钮**; +- 展开状态**只在本机内存**、**按报名项记**,换项目 / 刷新即恢复收起。 + +**取舍**:展开那一行会把列略微撑宽(成绩列右移约 **80px**),点回收起就恢复。 + +### ④ 改:录入页「以决赛成绩发布」从勾选框改成发布弹窗里选 + +**成绩录入页**:工具栏上那个常驻勾选框(与旁边的口径徽标)**已移除**。现在**点「发布」弹出一个窗口**(标题形如 `发布该组:请选择本次的发布口径`),里面**二选一**: + +| 选项 | 会发生什么 | +| --- | --- | +| **只更新名次(不计分、不发奖牌)** | **默认选中**——名次照常算出来、写进系统,但不产生积分、不发奖牌 | +| **按决赛成绩发布(重算名次并计分、发奖牌、榜单跟着更新)** | 与以前「勾上再发布」完全一样:算分、发牌、上榜 | + +- 同一句口径说明也写在选项下面(`本次只更新名次:不计分、不发奖牌,榜单分数不变。` / `本次按决赛成绩发布:算分、发奖牌、榜单跟着更新。`); +- **取消 = 什么都不发生**; +- **每次打开都回到默认**(**不吃上一次的选择**)——这条是故意的:避免「换个小项再点发布,把预赛当成决赛算分」这种最贵的错法; +- `Alt+Enter` 快捷键走**同一个窗口**; +- 窗口里同时写明本次将发布多少人、还有多少人没成绩;选「按决赛成绩发布」时才会报「影响 N 个单位、最大分差 X 分」(只更新名次时报这两个数是假话)。 + +⚠️ **成绩审核页保持原来的常驻勾选框不变**(两个页面口径不同,别以为审核页也变了)——见《使用手册》4.5 与第 5 节。 + +### ⑤ 新:大屏控制页的「在线大屏」列表 —— 可读设备名 + 手动重命名 + 修一个重复条目 + +**自动设备标签**(局域网浏览器一连上来就带着): + +| 设备 | 标签 | +| --- | --- | +| 安卓手机 | `安卓 13 · Chrome 120 · 1080×2400 @3x · 手机` | +| iPad | `iPadOS 17 · Safari 17 · 2048×2732 @2x · 平板` | +| Windows 电脑 | `Windows 10 · Edge 120 · 1920×1080 · 桌面` | +| 微信内置浏览器 | `安卓 13 · 微信 8 · 1080×2340 @2.75x · 手机` | + +写法是 **系统 + 版本 · 浏览器 · 分辨率 · 形态(手机 / 平板 / 桌面)**,缺的段自动跳过。 + +⚠️ **做不到什么,必须如实说清**:浏览器**没有**读主机名 / 设备名的 API(这是安全边界),所以**拿不到那种「XIAOMI-PAD-05」的真机名**;`model` 字段只在**部分安卓浏览器**上可能有值。**自动标签只是辅助区分**,两台同型号设备还得靠**手动重命名**或**来源 IP** 分辨。 + +**手动重命名**:列表每条有「改名」入口(**录入员及以上**可用,与切页同档): + +- 点它**就地出现一个输入框**(示例 `二号楼平板`),按「保存」或 `Enter` 提交;旁边的**「清除」**回到自动标签、**「取消」**什么都不改。名字**存在服务端**,**重启与切赛事都不丢**(**最长 40 个字符**); +- 显示优先级:**手动名 → 自动标签 → 那个随机 id**(id 降级成小字,排障时还在); +- 上限 **200 条**,**满了会拒绝新增并提示先清理**(不会悄悄顶掉别的设备); +- 别名跟着**设备**走:设备太久没连、条目被清理之后,**别名仍留着**,同一台设备再连上来还是那个名字。 + +**修:给某台设备切页之后,列表底部会多出一个该设备的离线条目**。根因是**翻页会补发一次握手**,而浏览器**没有稳定 id**、服务端每次都新开一条实例。现在**浏览器本机记住自己的实例 id**,服务端也做了兜底(同一条连接重复握手复用原实例)——**升级后不会再出现这种重复条目**。 + +### ⑥ 新:局域网地址条目都能出二维码了 + +以前**只有第一条地址**自动带二维码。现在**其余每条**多了一个「**二维码**」按钮:**点一下临时显示**那条地址的码,**再点一次 / 按 Esc / 点空白处**都能收起,**同一时刻只留一个**。**第一条那条常驻二维码与「复制链接」都没变。** + +### ⑦ 新:成绩录入页加了「搜姓名 / 号码布」搜索框 + +3 公里、5 公里那种人多的项目,找人不费劲了。位置在**工具栏「批次」下拉右边**: + +- **即时模糊匹配**:大小写不敏感、首尾空格与全角空格都当空白;号码布支持数字口径——**`007` 搜 `7`、`7` 搜 `007` 都能找到**; +- **命中高亮**(只加一层淡底色); +- 提示 `找到 N 条 / 共 M 条`(**M 是批次口径的总行数**); +- 清除有三种:× 按钮、输入框里按 Esc、「清除搜索」按钮;搜不到时给明确的空状态与提示; +- 搜索范围是**行的姓名 / 队员名单 / 队员(含号码布)/ 行号码布**,**不含单位名**; +- 换小项 / 换组别会**清空搜索**。 + +⚠️ **「搜索只影响表格显示,不影响发布范围与进度」**(界面上也有这行小字):筛完再点发布,发的仍是**该小项 + 该组别里所有已填的成绩**;进度条的「已录 x/y」也仍是**该批次总人数**。 + +⚠️ **粘贴跟着屏幕走**:搜索状态下按**屏幕顺序**粘贴(**隐藏的行不会被写到**)——**这是有意的**,否则第二条成绩会静默写到看不见的人身上。 + +### ⑧ 升级后 5 分钟核对 + +1. 打开一个**纯团体项目**(例如团体 4×100 接力)已发布成绩的大屏页面:**分项成绩、实时赛况、底栏「最新完成项目」三处都应当有名次**,不再是 `-`(**不要重发、不要重算**,刷新一下大屏即可); +2. 看**分项成绩页的第 3 栏**:**应当从第 1 名开始**(升级前第 1 名会被挤出这一栏); +3. 到「成绩录入」页选一个**多人团体项目**:**「运动员 / 队」这一列收起时是限宽的**,长名单右侧有「展开 / 收起」;再看一个**个人项目**,那里不应当有这个按钮; +4. 点一次「发布」:应弹出**二选一**的窗口,**默认是「只更新名次(不计分、不发奖牌)」**,按「取消」什么都不发生;想计分发牌就选第二项再确认。⚠️ 到**成绩审核页**看一眼:那里**还是原来的常驻勾选框**; +5. 到「大屏控制 → 在线大屏」:局域网设备的条目应当**写着 `安卓 13 · Chrome 120 · … · 手机` 这样的可读标签**(不是一串随机 id),点「改名」能起个自己的名字;再给某台设备切一次页,**列表底部不该多出离线条目**; +6. 到「大屏控制 → 局域网大屏」:**每条地址都有「二维码」按钮**,点开再点一次能收起(第一条的常驻二维码照旧); +7. 到「成绩录入」页找一个人多的项目,在**「批次」下拉右边**输入姓名或号码布(`007` 与 `7` 互通):应看到 `找到 N 条 / 共 M 条` 与高亮;**「发布」窗口里的人数与进度条数字不受搜索影响**。 + +### ⑨ 详细说明在哪 + +《使用手册》4.2(录入页搜索框、「运动员 / 队」列的收起 / 展开)、4.3(粘贴跟着屏幕走)、4.5(发布的弹窗二选一口径)、第 5 节(审核页仍是常驻勾选框)、第 9 节(大屏设备标签 / 改名 / 地址二维码按钮、重复条目已修);《常见问题》Q105–Q107;《部署指南》7.5(局域网地址与二维码)。 + +--- + ## dev_0.5.14 ### 一句话 @@ -213,7 +330,8 @@ ### ① 新增:「以决赛成绩发布」开关 -- **位置**:成绩录入页(以及**成绩审核页**)的「发布」按钮旁边,一个勾选框:**「以决赛成绩发布(勾选才计分发牌)」**,**默认不勾**。旁边有一个小徽标随时告诉你当前是哪种口径:**只更新名次(不计分)** / **计分发布(算分发牌)**。 +- **位置**:成绩**审核**页的「发布」按钮旁边,一个勾选框:**「以决赛成绩发布(勾选才计分发牌)」**,**默认不勾**。旁边有一个小徽标随时告诉你当前是哪种口径:**只更新名次(不计分)** / **计分发布(算分发牌)**。 + > ⚠️ **本条的位置后来变过一处,口径以 `dev_0.5.15` 那一节为准**:**成绩录入页**上这个常驻勾选框与徽标**已移除**,改成点「发布」时**弹窗二选一**(默认「只更新名次」);**成绩审核页保持原样的常驻勾选框不变**。算法、写库行为与在榜口径提示**一个字节都没变**,只换了入口形状(见上面 `## dev_0.5.15` 的 ④)。 - **不勾(默认)**:这次发布**只更新名次** —— 名次会照常算出来、写进系统(预赛也要排名次),但**不产生积分、不发奖牌**。 - **勾上**:与升级前**完全一样** —— 算分、发牌、上榜。 - **勾选是"这一次发布"的口径,不是页面设置**:发完自动复位(免得您换到别的小项时,把预赛当成决赛算分)。 @@ -232,7 +350,7 @@ ### ③ 两条使用上的提醒 -1. **两个发布入口都有这个开关**:成绩录入页与成绩审核页 —— 从哪一页发布口径都一样(否则从审核页发布预赛会被悄悄算成分)。 +1. **两个发布入口口径一致**:成绩录入页与成绩审核页 —— 从哪一页发布口径都一样(否则从审核页发布预赛会被悄悄算成分)。⚠️ `dev_0.5.15` 起录入页的开关改成**发布弹窗里的二选一**(审核页仍是常驻勾选框),见上面 `## dev_0.5.15` 的 ④。 2. **只在"分轮次"的项目上用**:一次就比完的项目照旧**勾上**发布(与以前一样),别漏勾 —— 漏勾的话名次会更新,但**不会计分**,榜上不会有这一项的分(界面上有徽标和在榜口径提示,发布前看一眼就不会错)。 ### ④ 升级后 5 分钟核对 diff --git a/docs/常见问题.md b/docs/常见问题.md index 9802e4e..8289c84 100644 --- a/docs/常见问题.md +++ b/docs/常见问题.md @@ -761,3 +761,43 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点 3. **「队员名单」只增不减**:导出时会把界面上后来补进这支队的队员一并补上,所以重导后这支队的队员**不会少**,但个别名单的**顺序 / 写法**可能与最初上传的那张表不完全一样。 **一句话**:给人看的用「导出报名名单」;**要拿回去重导,用「导出(汇总·导入格式)」或「导出(分小项·导入格式)」**,导出的文件**不要改表头、不要插删列**。详见[使用手册 §11.1](./使用手册.md)与[报名名单导入规范 · 第九节](./报名名单导入规范.md#九导出的名单怎么导回去)。 + +**Q105:大屏上某个团体项目的名次显示 `-`,是不是没算出来?** + +**先说结论:名次早就算出来了——导出件与积分明细里一直是对的,以前只是大屏那一处没读它。新版起来刷新一下大屏就有了,不用重发、不用重算。** + +以前会这样的原因:纯团体项目的名次(例如团体 4×100 接力的单位名次)只写在**发布时算出来的那份名次(通道名次)**上;而成绩行上的「个人名次」这一列是**有意收窄的**——它只记个人名次,**对团体主体一直是空的**。大屏原来只读「个人名次」、没有兜底,于是团体行画出来就是一个 `-`;导出件与积分明细下钻**早就带了兜底**,所以当时是「导出的文件有名次、屏幕上没有」。录入页也一直看得见名次。 + +现在大屏的两个读者(分项成绩 / 底栏「最新完成项目」,以及实时赛况)都改成 **「个人名次」优先、为空时回落到「通道名次」**,**算分与写回一行没动**。 + +- **已经发布过的数据不用重发、不用重算**:新版起来大屏下一次刷新就显示出来; +- **为什么不干脆让「通道名次」优先**:「**只更新名次**」的发布只写「个人名次」、**不重建通道名次**,通道名次一优先,个人项目在那种发布之后会显示**上一次的旧名次**(见使用手册 4.5 / 5 与《升级说明》`dev_0.5.15` 的 ④); +- ⚠️ **还有一类项目仍然显示 `-`(已知限制)**:**只在「团体名次」卡片里按单位录了名次、成绩行上一个数都没填**的项目(例如拔河只用卡片录)——那类名次只存在「按单位录入的团体名次」里、**没有对应的成绩行**,大屏上仍然没有名次可画(团体名次本身照常算分,见 Q95)。要上屏就得在成绩行上也有成绩。 + +详见《升级说明》`dev_0.5.15` 的 ①。 + +**Q106:分项成绩页第 3 栏看不到第 1 名、名次看着是跳的,是不是算错了?** + +**先说结论:没算错,是那一栏被「截断」过。这个毛病跟着栏位走、不跟着项目走——同一批项目放在前两栏就没事,落到第 3 栏就少人。** + +原因:服务端快照里「最新成绩」的窗口原来是**按行取 20 行**,而屏上是「一组一栏」——窗口的切口**只能落在最旧的那一栏(通常就是第 3 栏)内部**,把那一组里**建号最早的行**(手录项目通常就是第 1 名)挤了出去;剩下能画的行名次自然是跳的。 + +现在窗口改成 **「最近 8 组 × 每组 10 行」**:先按每组最近一次发布时间取**最近 8 组**,再在每组里**按名次保底取前 10 行**。名次的切口不再落在某一栏内部,**第 3 栏从第 1 名开始画**;**底栏那条「最新完成项目」也一起吃这个修复**。 + +- ⚠️ **取舍**:单组**超过 10 行**的部分**不再下发**。前端每栏只画 6 行、「实时赛况」上限也是 10 行,所以三个页面都看不出来;**将来若把每栏的行数上限调到 10 以上,必须同步调这个「每组 10 行」**; +- 想核对某一组的**完整**名次:看「导出 → 结果」里的分项成绩表,或切到大屏的「实时赛况」页(当前项目下发的是完整名单,本来就不受这个窗口限制)。 + +详见《升级说明》`dev_0.5.15` 的 ②。 + +**Q107:大屏控制页「在线大屏」里的设备名看不懂、两台一样的平板分不出来,能改名吗?** + +能。列表里每条都有「**改名**」入口(**录入员及以上**可用,与切页同一档)。 + +- 现在显示的是**自动设备标签**,形如 `安卓 13 · Chrome 120 · 1080×2400 @3x · 手机`——写法是 **系统 + 版本 · 浏览器 · 分辨率 · 形态(手机 / 平板 / 桌面)**,取不到的段会自动跳过(例如 `iPadOS 17 · Safari 17 · 2048×2732 @2x · 平板`、`Windows 10 · Edge 120 · 1920×1080 · 桌面`、`安卓 13 · 微信 8 · 1080×2340 @2.75x · 手机`); +- ⚠️ **自动标签认不出「哪台机器」**:浏览器**没有**读主机名 / 设备名的 API(这是安全边界),所以**拿不到 `XIAOMI-PAD-05` 那种真机名**;两台同型号设备只能靠**手动改名**或**来源 IP** 分辨; +- **怎么改**:点该行的「改名」→ 就地填一个现场看得懂的名字(例如 `二号楼平板`)→「保存」(`Enter` 也可);旁边的**「清除」**回到自动标签、**「取消」**什么都不改。名字**存在服务端**,**重启服务端与切换赛事项目都不丢**(最长 40 个字符)。显示优先级是 **手动名 → 自动标签 → 那个随机实例 id**(id 降级成小字,排障时还在);鼠标停在设备名上能看到设备标签、**原始 UA**、实例 id、来源 IP、屏号(取不到的整行不出现); +- **上限 200 条**:满了会**拒绝新增并提示先清理**,不会悄悄顶掉别的设备; +- 别名跟着**设备**走:设备太久没连、条目被服务端清理之后,**别名仍然留着**,同一台设备再连上来还是那个名字; +- **以前给某台设备切页后,列表底部会多出一个该设备的离线条目**(根因是翻页会补发一次握手,而浏览器没有稳定 id、服务端每次都新开一条实例),**现在已经修好**:浏览器本机记住自己的实例 id,服务端也做了兜底(同一条连接重复握手复用原实例),升级后不会再出现这种重复条目。 + +顺带一句:「局域网大屏」卡片里**每一条地址**都能出二维码了——第一条是常驻二维码,其余每条点「**二维码**」按钮临时出码(再点一次 / 按 Esc / 点空白处收起,同一时刻只留一个),不用为别的网段再手敲地址。详见《使用手册》第 9 节、《部署指南》7.5。 diff --git a/docs/部署指南.md b/docs/部署指南.md index adeb0e0..1ea805b 100644 --- a/docs/部署指南.md +++ b/docs/部署指南.md @@ -382,7 +382,7 @@ scoreboard.exe passwd -config "%LOCALAPPDATA%\ScoreboardConsole\config.yaml" -da 管理端「大屏控制」页有**显示器**卡片,可以给指定显示器单独开窗或收窗。这张卡片只在**从服务管理窗口点「打开服务端管理面板」打开的那个窗口**里可用;用浏览器直接打开管理面板时,卡片会提示此功能不可用。 -同一页右侧还有「**局域网大屏**」卡片:把本机可用的局域网地址拼成完整链接(可点开、可一键复制),第一个地址还带二维码,别的设备扫码即可当大屏用。卡片需**录入员及以上**登录才看得到,用法见《使用手册》第 9 节。 +同一页右侧还有「**局域网大屏**」卡片:把本机可用的局域网地址拼成完整链接(可点开、可一键复制),**第一个地址带一个常驻二维码,其余每条也能点「二维码」按钮临时出码**(点一下显示、再点一次 / 按 Esc / 点空白处收起,同一时刻只留一个),别的设备扫码即可当大屏用。卡片需**录入员及以上**登录才看得到,用法见《使用手册》第 9 节。 - 列出本机每块显示器(名称、分辨率、缩放、是否主显示器、当前有没有大屏窗口、是全屏还是窗口化); - 先在「窗口页面」里选页面,再对某块屏点「创建全屏窗口」或「创建窗口(窗口化)」;已经开着窗口的屏显示「聚焦窗口」与「关闭窗口」; @@ -393,7 +393,7 @@ scoreboard.exe passwd -config "%LOCALAPPDATA%\ScoreboardConsole\config.yaml" -da **「局域网大屏」卡片:怎么拿到地址(两条路)** -1. **卡片自动检测**:「大屏控制 → 局域网大屏」卡片会列出主机上可用的地址,直接点开或扫码;第一个地址(也就是二维码那一条)就是现场最可能要用的; +1. **卡片自动检测**:「大屏控制 → 局域网大屏」卡片会列出主机上可用的地址,直接点开或扫码;**第一个地址(常驻二维码那一条)就是现场最可能要用的**,其余地址要多一个「二维码」按钮、点一下才出码; 2. **`ipconfig` 手填**:自动检测不到、或者列出来的不是你那张网卡时,在管理员 cmd / PowerShell 里跑 `ipconfig`,把对应网卡的 **IPv4 地址**抄进卡片下方的「手动添加地址」再点「添加」——手填的排在第一条、带「手动」徽标,记在浏览器本地,刷新不丢(最多 10 个,可「删除」)。 **三类地址的含义**(卡片按类别判定,只排除回环 / 未指定 / 组播 / 广播这四类对本机之外没有意义的地址): @@ -456,7 +456,7 @@ netsh advfirewall firewall add rule name="Scoreboard 19540" dir=in action=allow http://<主机IP>:19540/display ``` -- 这个地址不用手敲:管理端「大屏控制 → 局域网大屏」卡片里有**可复制的完整链接**与**二维码**(第一个地址),别的设备扫码或在浏览器里粘贴即可打开(该卡片需录入员及以上登录才看得到); +- 这个地址不用手敲:管理端「大屏控制 → 局域网大屏」卡片里有**可复制的完整链接**与**二维码**(**第一条是常驻二维码,其余每条点「二维码」按钮也能临时出码**),别的设备扫码或在浏览器里粘贴即可打开(该卡片需录入员及以上登录才看得到); - 需要主机设置固定 IP,且设备与主机在同一局域网; - **主机防火墙要放行入站 19540**(默认端口):**「为所有用户安装」会自动放行**(那一档提权时装好规则),不用再管;**「仅为我安装」不加规则、必须手工放行一次**(命令与说明见 7.5)。装的时候选哪一档,取决于 3.1 里那几行 —— 新版安装器一启动就申请管理员,但只有「为所有用户」那一趟才顺手改防火墙; - 安卓电视一般常亮,无需额外防熄屏; diff --git a/server/internal/export/load.go b/server/internal/export/load.go index 018082b..17303c5 100644 --- a/server/internal/export/load.go +++ b/server/internal/export/load.go @@ -551,6 +551,11 @@ func loadEventRowsWithMembers(db *sql.DB, eventID, categoryID int64, includeDraf // channel_ranks 是发布 / 重算才重建的派生数据,还没有它时(老库刚升级、只排名不计分项目的合成通道 // 落不了库)回落到 results.rank,保持名次列不空白。 // + // ⚠️ 这段子查询与 realtime/snapshot.go 的 mainChannelRankJoin、httpapi/live_events.go 的同名常量 + // 同源(大屏两个读者),改一处要改三处。优先级**故意不同**:导出这里是 cr 优先(既有口径, + // 由 TestEventRowsUseMainChannelRank 钉住),大屏那边是 results.rank 优先——「只更新名次」的发布 + // 只写 results.rank、不重建 channel_ranks,大屏 cr 一优先就会显示上一次的旧名次。 + // // 关联用「cr.id = 该报名在该通道下的最小 id」而不是直接按 (通道, 报名, 组别) JOIN: // 团体报名挂上队员之后(devdocs/20),一条报名在同一通道下有 1 + N 行名次(报名项自己那份 // 加每个队员一份个人副本),直接 JOIN 会把这条报名在成绩表里印 N+1 遍。 diff --git a/server/internal/httpapi/display_alias_test.go b/server/internal/httpapi/display_alias_test.go new file mode 100644 index 0000000..25a1b05 --- /dev/null +++ b/server/internal/httpapi/display_alias_test.go @@ -0,0 +1,706 @@ +package httpapi_test + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/coder/websocket" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi" + "git.yeij.top/AskaEth/Scoreboard/server/internal/realtime" +) + +// ---------- 别名用例的实时层替身 ---------- +// +// 不用 review_fixes_test.go 里那个 fakeRealtime:这些用例要在两次请求**之间**改写实例列表 +// (模拟「那台设备被 TTL 清理掉了」),而它的 instances 字段没有并发保护。这里给列表加锁, +// 写入与读取之间才有确定的同步关系。 +type aliasFakeRealtime struct { + mu sync.Mutex + instances []realtime.Instance +} + +func (f *aliasFakeRealtime) Broadcast(changes []realtime.Change) {} + +func (f *aliasFakeRealtime) LoadChanges(ctx context.Context, sinceVersion int64, limit int) ([]realtime.Change, error) { + return nil, nil +} + +func (f *aliasFakeRealtime) Instances() []realtime.Instance { + f.mu.Lock() + defer f.mu.Unlock() + return append([]realtime.Instance(nil), f.instances...) +} + +// set 整批替换实例列表(用例在请求之间调用)。 +func (f *aliasFakeRealtime) set(list ...realtime.Instance) { + f.mu.Lock() + defer f.mu.Unlock() + f.instances = list +} + +// aliasRef 是别名用例专用的测试环境:recorder / admin 两个账号已建好。 +type aliasRef struct { + env *testEnv + fake *aliasFakeRealtime +} + +func newAliasEnv(t *testing.T) *aliasRef { + t.Helper() + fake := &aliasFakeRealtime{} + env := newTestEnvWith(t, func(o *httpapi.Options) { o.Realtime = fake }) + env.createUser("recorder", "recorder-pass", "recorder") + env.createUser("boss", "boss-pass", "admin") + return &aliasRef{env: env, fake: fake} +} + +func (r *aliasRef) login(t *testing.T, username, password string) *http.Client { + t.Helper() + client, _ := r.env.client() + r.env.login(client, username, password) + return client +} + +// putAlias 打一次改名接口。 +func (r *aliasRef) putAlias(t *testing.T, client *http.Client, id, alias string) (int, envelope) { + t.Helper() + return r.env.do(client, http.MethodPut, + "/api/display/instances/"+id+"/alias", map[string]any{"alias": alias}, true) +} + +// rawAliasSetting 直接读系统库里的原始设置值(绕开接口,验证「真的落盘了」)。 +func rawAliasSetting(t *testing.T, db *sql.DB) string { + t.Helper() + var raw string + err := db.QueryRow(`SELECT value FROM settings WHERE key = 'display.instance_aliases'`).Scan(&raw) + if err == sql.ErrNoRows { + return "" + } + if err != nil { + t.Fatalf("读取别名设置: %v", err) + } + return raw +} + +// ---------- 设置 / 覆盖 / 清除 ---------- + +func TestDisplayAliasSetReturnsInstance(t *testing.T) { + r := newAliasEnv(t) + r.fake.set(realtime.Instance{ + ID: "browser-1", Online: true, Page: "standings", + Device: "安卓 13 · Chrome 120 · 手机", DeviceRaw: "Mozilla/5.0 (Linux; Android 13)", + }) + client := r.login(t, "recorder", "recorder-pass") + + status, env := r.putAlias(t, client, "browser-1", "二号楼平板") + if status != http.StatusOK { + t.Fatalf("改名应 200,得到 %d %+v", status, env.Error) + } + got := decodeData[map[string]any](t, env) + if got["id"] != "browser-1" || got["alias"] != "二号楼平板" { + t.Fatalf("响应应是该实例的最新记录(含新别名): %+v", got) + } + // 设备字段与老字段一并返回:形状与列表元素一致。 + if got["device"] != "安卓 13 · Chrome 120 · 手机" || got["deviceRaw"] != "Mozilla/5.0 (Linux; Android 13)" { + t.Fatalf("响应应带上设备字段: %+v", got) + } + for _, key := range []string{"online", "lastHeartbeat", "connectedAt", "ackVersion", "slow", "screen"} { + if _, ok := got[key]; !ok { + t.Fatalf("响应缺少老字段 %s: %+v", key, got) + } + } + + // 列表里也看得到:别名是列表这一层与实例合并的。 + list := decodeData[[]map[string]any](t, mustDo(t, r.env, client, http.MethodGet, "/api/display/instances")) + if len(list) != 1 || list[0]["alias"] != "二号楼平板" { + t.Fatalf("列表应带别名: %+v", list) + } +} + +func TestDisplayAliasOverwriteAndTrim(t *testing.T) { + r := newAliasEnv(t) + r.fake.set(realtime.Instance{ID: "browser-1", Online: true}) + client := r.login(t, "recorder", "recorder-pass") + + // 粘贴来的名字几乎都带首尾空白,服务端统一 trim 后再存。 + if status, env := r.putAlias(t, client, "browser-1", " 二号楼平板 "); status != http.StatusOK { + t.Fatalf("首次改名应 200,得到 %d %+v", status, env.Error) + } + if status, env := r.putAlias(t, client, "browser-1", "三楼平板"); status != http.StatusOK { + t.Fatalf("覆盖改名应 200,得到 %d %+v", status, env.Error) + } + if got := decodeData[[]map[string]any](t, mustDo(t, r.env, client, http.MethodGet, "/api/display/instances")); len(got) != 1 || got[0]["alias"] != "三楼平板" { + t.Fatalf("列表应只剩最新别名: %+v", got) + } + + var stored map[string]string + if err := json.Unmarshal([]byte(rawAliasSetting(t, r.env.sys)), &stored); err != nil { + t.Fatalf("别名设置应是 JSON 映射: %v", err) + } + if len(stored) != 1 || stored["browser-1"] != "三楼平板" { + t.Fatalf("覆盖后映射里应只剩最新别名: %+v", stored) + } +} + +// 清除别名必须真把这条从映射里删掉(不是存空串),否则映射只会一路涨。 +func TestDisplayAliasClearRemovesEntry(t *testing.T) { + r := newAliasEnv(t) + r.fake.set(realtime.Instance{ID: "browser-1", Online: true}, realtime.Instance{ID: "browser-2", Online: true}) + client := r.login(t, "recorder", "recorder-pass") + + if status, _ := r.putAlias(t, client, "browser-1", "二号楼平板"); status != http.StatusOK { + t.Fatalf("首次改名应 200,得到 %d", status) + } + if status, _ := r.putAlias(t, client, "browser-2", "三楼平板"); status != http.StatusOK { + t.Fatalf("另一台改名应 200,得到 %d", status) + } + + // 只空白 = 清除(与前端同口径)。 + status, env := r.putAlias(t, client, "browser-1", " ") + if status != http.StatusOK { + t.Fatalf("清除别名应 200,得到 %d %+v", status, env.Error) + } + got := decodeData[map[string]any](t, env) + if _, ok := got["alias"]; ok { + t.Fatalf("清除后响应里不该再有 alias 键(omitempty): %+v", got) + } + + var stored map[string]string + if err := json.Unmarshal([]byte(rawAliasSetting(t, r.env.sys)), &stored); err != nil { + t.Fatalf("别名设置应是 JSON 映射: %v", err) + } + if _, ok := stored["browser-1"]; ok { + t.Fatalf("清除后映射里不该还留着这一条: %+v", stored) + } + if stored["browser-2"] != "三楼平板" { + t.Fatalf("别的设备的别名不该被连带清掉: %+v", stored) + } + + list := decodeData[[]map[string]any](t, mustDo(t, r.env, client, http.MethodGet, "/api/display/instances")) + for _, item := range list { + if item["id"] == "browser-1" { + if _, ok := item["alias"]; ok { + t.Fatalf("清除别名后列表应回到自动标签: %+v", item) + } + } + } +} + +// ---------- 落盘:换连接 + 换服务端都还在 ---------- + +func TestDisplayAliasSurvivesServerRestart(t *testing.T) { + r := newAliasEnv(t) + r.fake.set(realtime.Instance{ + ID: "browser-1", Online: true, Device: "安卓 13 · 手机", + }) + client := r.login(t, "recorder", "recorder-pass") + if status, env := r.putAlias(t, client, "browser-1", "二号楼平板"); status != http.StatusOK { + t.Fatalf("改名应 200,得到 %d %+v", status, env.Error) + } + + // 1) 换一条全新的系统库连接读(绕开任何进程内状态):别名在库里。 + path := sysDBPath(t, r.env.sys) + fresh, err := sql.Open("sqlite", "file:"+path+"?_pragma=busy_timeout(5000)") + if err != nil { + t.Fatalf("重新打开系统库: %v", err) + } + defer func() { _ = fresh.Close() }() + var stored map[string]string + if err := json.Unmarshal([]byte(rawAliasSetting(t, fresh)), &stored); err != nil { + t.Fatalf("重开系统库后别名设置应是 JSON 映射: %v", err) + } + if stored["browser-1"] != "二号楼平板" { + t.Fatalf("重开系统库后别名应还在: %+v", stored) + } + + // 2) 再起一个全新的服务端(新的 handler、新的实时层替身),同一份系统库 / 赛事库。 + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + restarted := &aliasFakeRealtime{} + restarted.set(realtime.Instance{ID: "browser-1", Online: true, Device: "安卓 13 · 手机"}) + srv := httptest.NewServer(httpapi.New(httpapi.Options{ + Logger: logger, + Version: "test", + System: r.env.sys, + Event: r.env.ev, + Auth: r.env.auth, + DataDir: t.TempDir(), + Realtime: restarted, + })) + defer srv.Close() + + restartEnv := &testEnv{t: t, server: srv, sys: r.env.sys, ev: r.env.ev, auth: r.env.auth} + restartClient, _ := restartEnv.client() + restartEnv.login(restartClient, "recorder", "recorder-pass") + list := decodeData[[]map[string]any](t, mustDo(t, restartEnv, restartClient, http.MethodGet, "/api/display/instances")) + if len(list) != 1 || list[0]["alias"] != "二号楼平板" { + t.Fatalf("重开服务端后列表里应仍有别名: %+v", list) + } +} + +// ---------- 实例过期后别名留着 ---------- + +func TestDisplayAliasKeptAfterInstancePruned(t *testing.T) { + r := newAliasEnv(t) + r.fake.set(realtime.Instance{ID: "browser-1", Online: true}) + client := r.login(t, "recorder", "recorder-pass") + if status, env := r.putAlias(t, client, "browser-1", "二号楼平板"); status != http.StatusOK { + t.Fatalf("改名应 200,得到 %d %+v", status, env.Error) + } + + // 实例被 TTL 清理(注册表里没有它了):列表里当然没有条目,别名也不能跟着被删。 + r.fake.set() + if list := decodeData[[]map[string]any](t, mustDo(t, r.env, client, http.MethodGet, "/api/display/instances")); len(list) != 0 { + t.Fatalf("实例被清理后列表应为空: %+v", list) + } + if status, env := r.putAlias(t, client, "browser-1", "x"); status != http.StatusNotFound { + t.Fatalf("给已不在注册表里的实例改名应 404,得到 %d %+v", status, env.Error) + } + var stored map[string]string + if err := json.Unmarshal([]byte(rawAliasSetting(t, r.env.sys)), &stored); err != nil { + t.Fatalf("别名设置应是 JSON 映射: %v", err) + } + if stored["browser-1"] != "二号楼平板" { + t.Fatalf("实例被清理不该牵连别名: %+v", stored) + } + + // 那台设备再连上来:同一个实例 id,别名自动认回来。 + r.fake.set(realtime.Instance{ID: "browser-1", Online: true, Device: "安卓 13 · 手机"}) + list := decodeData[[]map[string]any](t, mustDo(t, r.env, client, http.MethodGet, "/api/display/instances")) + if len(list) != 1 || list[0]["alias"] != "二号楼平板" { + t.Fatalf("设备重连后应自动认回别名: %+v", list) + } +} + +// ---------- 权限 ---------- + +// 权限与大屏切页同档(recorder):未登录与切页接口得到同一个状态码,录入员能改名、管理员同样能。 +func TestDisplayAliasPermissionMatchesDisplayCommand(t *testing.T) { + r := newAliasEnv(t) + r.fake.set(realtime.Instance{ID: "browser-1", Online: true}) + + anon, _ := r.env.client() + // 未登录请求不带 CSRF(中间件对 guest 请求直接放行,由 RequireWrite 判权限)。 + aliasStatus, aliasEnv := r.env.do(anon, http.MethodPut, + "/api/display/instances/browser-1/alias", map[string]any{"alias": "x"}, false) + cmdStatus, _ := r.env.do(anon, http.MethodPost, "/api/display/command", + map[string]any{"action": "goto", "payload": map[string]any{"page": "standings"}}, false) + if aliasStatus != cmdStatus { + t.Fatalf("改名与大屏切页应同档:匿名访客改名 %d,切页 %d", aliasStatus, cmdStatus) + } + if aliasStatus != http.StatusUnauthorized { + t.Fatalf("匿名访客应被挡下(写接口未登录一律 401),得到 %d %+v", aliasStatus, aliasEnv.Error) + } + + recorder := r.login(t, "recorder", "recorder-pass") + if status, env := r.putAlias(t, recorder, "browser-1", "二号楼平板"); status != http.StatusOK { + t.Fatalf("录入员改名应 200,得到 %d %+v", status, env.Error) + } + + boss := r.login(t, "boss", "boss-pass") + if status, env := r.putAlias(t, boss, "browser-1", "一号楼大屏"); status != http.StatusOK { + t.Fatalf("管理员改名应 200,得到 %d %+v", status, env.Error) + } +} + +// ---------- 404 / 超长 / 超量 ---------- + +// 实例 id 走路径,前端会先 encodeURIComponent:服务端必须按**解码后**的 id 去认实例。 +func TestDisplayAliasAcceptsURLEncodedID(t *testing.T) { + r := newAliasEnv(t) + r.fake.set(realtime.Instance{ID: "browser 1", Online: true}) + client := r.login(t, "recorder", "recorder-pass") + + status, env := r.env.do(client, http.MethodPut, + "/api/display/instances/browser%201/alias", map[string]any{"alias": "二号楼平板"}, true) + if status != http.StatusOK { + t.Fatalf("URL 编码的实例 id 应能认出来,得到 %d %+v", status, env.Error) + } + var stored map[string]string + if err := json.Unmarshal([]byte(rawAliasSetting(t, r.env.sys)), &stored); err != nil { + t.Fatalf("别名设置应是 JSON 映射: %v", err) + } + if stored["browser 1"] != "二号楼平板" { + t.Fatalf("别名应按解码后的 id 存: %+v", stored) + } +} + +func TestDisplayAliasUnknownInstanceNotFound(t *testing.T) { + r := newAliasEnv(t) + r.fake.set(realtime.Instance{ID: "browser-1", Online: true}) + client := r.login(t, "recorder", "recorder-pass") + + status, env := r.putAlias(t, client, "gone-1", "二号楼平板") + if status != http.StatusNotFound { + t.Fatalf("给不存在的实例改名应 404,得到 %d %+v", status, env.Error) + } + if env.Error == nil || !strings.Contains(env.Error.Message, "离线") { + t.Fatalf("404 提示里应说清「可能已经离线久了」: %+v", env.Error) + } + // 失败的请求不该留下任何东西。 + if got := rawAliasSetting(t, r.env.sys); strings.Contains(got, "gone-1") { + t.Fatalf("404 不该写库: %s", got) + } +} + +func TestDisplayAliasLengthLimit(t *testing.T) { + r := newAliasEnv(t) + r.fake.set(realtime.Instance{ID: "browser-1", Online: true}) + client := r.login(t, "recorder", "recorder-pass") + + // 上限按**字符**算:40 个汉字正好合法(按字节算早就超了)。 + if status, env := r.putAlias(t, client, "browser-1", strings.Repeat("平", 40)); status != http.StatusOK { + t.Fatalf("40 个字符的别名应 200,得到 %d %+v", status, env.Error) + } + status, env := r.putAlias(t, client, "browser-1", strings.Repeat("平", 41)) + if status != http.StatusBadRequest { + t.Fatalf("超长别名应 400,得到 %d %+v", status, env.Error) + } + if env.Error == nil || !strings.Contains(env.Error.Message, "40") { + t.Fatalf("超长提示应说清上限: %+v", env.Error) + } + // 被拒绝的请求不该改动已存的值。 + var stored map[string]string + if err := json.Unmarshal([]byte(rawAliasSetting(t, r.env.sys)), &stored); err != nil { + t.Fatalf("别名设置应是 JSON 映射: %v", err) + } + if stored["browser-1"] != strings.Repeat("平", 40) { + t.Fatalf("超长请求不该覆盖已存别名: %+v", stored) + } + + // 带控制字符的别名同样要清干净再存(否则列表排版会被搞乱)。 + if status, _ := r.putAlias(t, client, "browser-1", "二号楼\n平板\t"); status != http.StatusOK { + t.Fatalf("带控制字符的别名应 200,得到 %d", status) + } + if err := json.Unmarshal([]byte(rawAliasSetting(t, r.env.sys)), &stored); err != nil { + t.Fatalf("别名设置应是 JSON 映射: %v", err) + } + if stored["browser-1"] != "二号楼平板" { + t.Fatalf("别名里的控制字符应被清掉: %q", stored["browser-1"]) + } +} + +func TestDisplayAliasCapacityLimit(t *testing.T) { + r := newAliasEnv(t) + r.fake.set(realtime.Instance{ID: "new-dev", Online: true}, realtime.Instance{ID: "dev-0", Online: true}) + client := r.login(t, "recorder", "recorder-pass") + + // 直接种满 200 条(逐条打接口太慢,这里要验的是上限判定本身)。 + seed := make(map[string]string, 200) + for i := 0; i < 200; i++ { + seed[fmt.Sprintf("dev-%d", i)] = fmt.Sprintf("设备 %d", i) + } + raw, err := json.Marshal(seed) + if err != nil { + t.Fatalf("构造种入数据: %v", err) + } + r.env.execSys(t, `INSERT INTO settings (key, value, updated_at) VALUES ('display.instance_aliases', ?, datetime('now'))`, + string(raw)) + + status, env := r.putAlias(t, client, "new-dev", "二号楼平板") + if status != http.StatusConflict { + t.Fatalf("映射已满时新增应 409,得到 %d %+v", status, env.Error) + } + if env.Error == nil || !strings.Contains(env.Error.Message, "清除") { + t.Fatalf("拒绝提示应告诉操作员怎么办: %+v", env.Error) + } + + // 已存在的条目仍然可以改名(覆盖不涨条数),也可以清除(腾位置)。 + if status, env := r.putAlias(t, client, "dev-0", "换一个名字"); status != http.StatusOK { + t.Fatalf("已存在的条目改名应 200,得到 %d %+v", status, env.Error) + } + if status, env := r.putAlias(t, client, "dev-0", ""); status != http.StatusOK { + t.Fatalf("清除应 200,得到 %d %+v", status, env.Error) + } + if status, env := r.putAlias(t, client, "new-dev", "二号楼平板"); status != http.StatusOK { + t.Fatalf("腾出位置后新增应 200,得到 %d %+v", status, env.Error) + } +} + +// 两个人同时给不同设备改名:一个都不许被另一个覆盖掉(读—改—写必须在同一个事务里)。 +func TestDisplayAliasConcurrentRenamesKeepAll(t *testing.T) { + r := newAliasEnv(t) + const n = 8 + instances := make([]realtime.Instance, 0, n) + for i := 0; i < n; i++ { + instances = append(instances, realtime.Instance{ID: fmt.Sprintf("browser-%d", i), Online: true}) + } + r.fake.set(instances...) + + // 只登录一次:登录接口有按 IP 的频控,每个 goroutine 各登一次会被 429 挡下。 + // Cookie jar 自身并发安全,同一个 client 可以并发发起写请求。 + client := r.login(t, "recorder", "recorder-pass") + + var wg sync.WaitGroup + for i := 0; i < n; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + status, env := r.putAlias(t, client, fmt.Sprintf("browser-%d", i), fmt.Sprintf("设备 %d", i)) + if status != http.StatusOK { + t.Errorf("并发改名应 200,得到 %d %+v", status, env.Error) + } + }(i) + } + wg.Wait() + + var stored map[string]string + if err := json.Unmarshal([]byte(rawAliasSetting(t, r.env.sys)), &stored); err != nil { + t.Fatalf("别名设置应是 JSON 映射: %v", err) + } + if len(stored) != n { + t.Fatalf("并发改名后应保留全部 %d 条,得到 %d 条: %+v", n, len(stored), stored) + } + for i := 0; i < n; i++ { + if stored[fmt.Sprintf("browser-%d", i)] != fmt.Sprintf("设备 %d", i) { + t.Fatalf("browser-%d 的别名不对: %+v", i, stored) + } + } +} + +// ---------- 老字段一个不少 / 老客户端不受影响 ---------- + +// 三个新字段都是 omitempty:老客户端(不上报设备)的列表元素**形状与升级前逐字一致**, +// 管理端不会因为多出空串键而被判成回归;有值时才出现。 +func TestDisplayInstancesKeepsLegacyShape(t *testing.T) { + r := newAliasEnv(t) + r.fake.set( + realtime.Instance{ID: "old-1", Online: true, Page: "standings", Screen: "screen-a", RemoteAddr: "10.0.0.5:1234"}, + realtime.Instance{ID: "new-1", Online: true, Device: "安卓 13 · 手机", DeviceRaw: "Mozilla/5.0"}, + ) + client := r.login(t, "recorder", "recorder-pass") + + raw := mustDo(t, r.env, client, http.MethodGet, "/api/display/instances") + var list []map[string]any + if err := json.Unmarshal(raw.Data, &list); err != nil { + t.Fatalf("解析列表: %v", err) + } + if len(list) != 2 { + t.Fatalf("列表应有两项: %+v", list) + } + legacyKeys := []string{"id", "online", "lastHeartbeat", "connectedAt", "ackVersion", "slow", "screen"} + for _, item := range list { + for _, key := range legacyKeys { + if _, ok := item[key]; !ok { + t.Fatalf("老字段 %s 必须一个不少: %+v", key, item) + } + } + } + for _, item := range list { + if item["id"] != "old-1" { + continue + } + for _, key := range []string{"device", "deviceRaw", "alias"} { + if _, ok := item[key]; ok { + t.Fatalf("老客户端(没报设备)不该出现新键 %s: %+v", key, item) + } + } + } + for _, item := range list { + if item["id"] != "new-1" { + continue + } + if item["device"] != "安卓 13 · 手机" || item["deviceRaw"] != "Mozilla/5.0" { + t.Fatalf("报了设备字段的实例应带上它们: %+v", item) + } + if _, ok := item["alias"]; ok { + t.Fatalf("没设过别名时不该出现 alias 键: %+v", item) + } + } +} + +// 设置值被改坏时(手工改库 / 别的版本写的)列表照常可用,只是退回显示实例 id。 +func TestDisplayInstancesToleratesCorruptAliasSetting(t *testing.T) { + r := newAliasEnv(t) + r.fake.set(realtime.Instance{ID: "browser-1", Online: true}) + client := r.login(t, "recorder", "recorder-pass") + + r.env.execSys(t, `INSERT INTO settings (key, value, updated_at) VALUES ('display.instance_aliases', '不是 JSON', datetime('now'))`) + + status, env := r.env.do(client, http.MethodGet, "/api/display/instances", nil, false) + if status != http.StatusOK { + t.Fatalf("坏设置不该让列表打不开,得到 %d %+v", status, env.Error) + } + list := decodeData[[]map[string]any](t, env) + if len(list) != 1 || list[0]["id"] != "browser-1" { + t.Fatalf("列表应照常返回实例: %+v", list) + } + if _, ok := list[0]["alias"]; ok { + t.Fatalf("坏设置下不该有别名: %+v", list[0]) + } +} + +// ---------- 端到端:真 Hub + 真 /ws + 真 HTTP ---------- + +// 现场原场景:局域网浏览器(URL 没有 instance,hello 不带 instanceId)连上来, +// 翻页让前端补发第二条 hello;管理端看到的应当是**一条**带设备标签的实例,且能给它改名。 +// +// 这里不用替身:真的 realtime.Hub、真的 WebSocket、真的 HTTP 链路,只有浏览器那一端是 +// 测试里手写的帧。 +func TestDisplayAliasEndToEndWithRealHub(t *testing.T) { + var hub *realtime.Hub + env := newTestEnvWith(t, func(o *httpapi.Options) { + hub = realtime.NewHub(o.Event, o.Logger) + o.Realtime = hub + o.WS = hub.Handler() + }) + t.Cleanup(func() { _ = hub.Close() }) + env.createUser("recorder", "recorder-pass", "recorder") + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + wsURL := "ws" + strings.TrimPrefix(env.server.URL, "http") + "/ws" + conn, _, err := websocket.Dial(ctx, wsURL, nil) + if err != nil { + t.Fatalf("连接 /ws: %v", err) + } + defer func() { _ = conn.CloseNow() }() + + // hello 不带 instanceId / displayKey:局域网浏览器的真实形状。 + hello := func(page string) { + t.Helper() + msg, err := json.Marshal(map[string]any{ + "event": "hello", + "data": map[string]any{ + "role": "display", + "page": page, + "device": "安卓 13 · Chrome 120 · 1080×2400 @3x · 手机", + "deviceRaw": "Mozilla/5.0 (Linux; Android 13) Chrome/120.0.0.0 Mobile Safari/537.36", + }, + }) + if err != nil { + t.Fatalf("编码 hello: %v", err) + } + if err := conn.Write(ctx, websocket.MessageText, msg); err != nil { + t.Fatalf("发送 hello: %v", err) + } + } + + client := func() *http.Client { + t.Helper() + c, _ := env.client() + env.login(c, "recorder", "recorder-pass") + return c + } + admin := client() + + list := func() []map[string]any { + t.Helper() + return decodeData[[]map[string]any](t, mustDo(t, env, admin, http.MethodGet, "/api/display/instances")) + } + waitList := func(what string, cond func([]map[string]any) bool) []map[string]any { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + var last []map[string]any + for time.Now().Before(deadline) { + last = list() + if cond(last) { + return last + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("等待「%s」超时,最后的列表: %+v", what, last) + return nil + } + + hello("standings") + first := waitList("第一条 hello 被登记", func(l []map[string]any) bool { + return len(l) == 1 && l[0]["page"] == "standings" + }) + id, _ := first[0]["id"].(string) + if !strings.HasPrefix(id, "display-") { + t.Fatalf("不带 instanceId 时实例 id 应由服务端生成,得到 %q", id) + } + if first[0]["device"] != "安卓 13 · Chrome 120 · 1080×2400 @3x · 手机" { + t.Fatalf("列表应带上设备标签: %+v", first[0]) + } + + // 翻页:前端 setPage 会再发一条同样不带 instanceId 的 hello——这正是那个 bug 的引信。 + hello("medals") + second := waitList("翻页后的 hello 被登记", func(l []map[string]any) bool { + return len(l) == 1 && l[0]["page"] == "medals" + }) + if second[0]["id"] != id { + t.Fatalf("同一条连接重复 hello 不该换实例:首次 %v,翻页后 %v", id, second[0]["id"]) + } + + // 改名:返回该实例的最新记录,列表里也立刻带上别名。 + status, env2 := env.do(admin, http.MethodPut, + "/api/display/instances/"+id+"/alias", map[string]any{"alias": "二号楼平板"}, true) + if status != http.StatusOK { + t.Fatalf("改名应 200,得到 %d %+v", status, env2.Error) + } + if got := decodeData[map[string]any](t, env2); got["alias"] != "二号楼平板" || got["id"] != id { + t.Fatalf("改名应返回该实例的最新记录: %+v", got) + } + after := waitList("别名出现在列表里", func(l []map[string]any) bool { + return len(l) == 1 && l[0]["alias"] == "二号楼平板" + }) + if after[0]["device"] == "" { + t.Fatalf("别名不该顶掉设备标签(两者各有各的用处): %+v", after[0]) + } + + // 清除:回到自动设备标签,注册表里仍然只有这一条。 + status, env3 := env.do(admin, http.MethodPut, + "/api/display/instances/"+id+"/alias", map[string]any{"alias": " "}, true) + if status != http.StatusOK { + t.Fatalf("清除别名应 200,得到 %d %+v", status, env3.Error) + } + if _, ok := decodeData[map[string]any](t, env3)["alias"]; ok { + t.Fatalf("清除后响应不该再有 alias: %s", env3.Data) + } + if l := list(); len(l) != 1 || l[0]["device"] == "" { + t.Fatalf("清除后应回到自动设备标签且实例仍只有一条: %+v", l) + } +} + +// ---------- 小工具 ---------- + +// execSys 在系统库上直接执行 SQL:别名的设置在 system.db 的 settings 表里, +// 而 testEnv.exec 写的是赛事库(种数据 / 改坏设置这两个场景都要用系统库)。 +func (e *testEnv) execSys(t *testing.T, query string, args ...any) { + t.Helper() + if _, err := e.sys.Exec(query, args...); err != nil { + t.Fatalf("执行系统库 SQL 失败: %v\n%s", err, query) + } +} + +func mustDo(t *testing.T, env *testEnv, client *http.Client, method, path string) envelope { + t.Helper() + status, body := env.do(client, method, path, nil, false) + if status != http.StatusOK || !body.OK { + t.Fatalf("%s %s 应 200,得到 %d %+v", method, path, status, body.Error) + } + return body +} + +// sysDBPath 取系统库的文件路径:用它另开一条连接读,绕开进程内可能存在的任何缓存。 +func sysDBPath(t *testing.T, db *sql.DB) string { + t.Helper() + rows, err := db.Query(`PRAGMA database_list`) + if err != nil { + t.Fatalf("读取系统库路径: %v", err) + } + defer func() { _ = rows.Close() }() + for rows.Next() { + var seq int + var name, file string + if err := rows.Scan(&seq, &name, &file); err != nil { + t.Fatalf("扫描系统库路径: %v", err) + } + if name == "main" { + return file + } + } + t.Fatal("系统库路径为空") + return "" +} diff --git a/server/internal/httpapi/live_events.go b/server/internal/httpapi/live_events.go index b2dc4e1..9d6bd92 100644 --- a/server/internal/httpapi/live_events.go +++ b/server/internal/httpapi/live_events.go @@ -271,16 +271,45 @@ func (s *server) liveEventsHandler(w http.ResponseWriter, r *http.Request) (any, return out, nil } +// mainChannelRankJoin 是大屏成绩行上名次列的「主通道 + 通道名次」两段关联, +// 与 realtime 侧 snapshot.go 的同名常量逐字一致(那边是 WS 快照的最新成绩,这里是实时赛况), +// 两处必须一起改,否则分项成绩页与实时赛况的名次会打架。 +// +// 名次取「主通道」口径(devdocs 17 §11.3):主通道 = scope=athlete 优先,其次通道 sort_order 小者, +// 与 export/load.go 的分项成绩表同一条规则。优先级是 **results.rank 优先、channel_ranks 兜底** +// (见下面查询里的 COALESCE(r.rank, cr.rank)),不能照抄导出侧的 cr 优先:results.rank 是这条报名项 +// 自己的名次,而「只更新名次」的发布(rankOnly,scoring.go)只写 results.rank、不重建 channel_ranks +// ——cr 一优先,个人项目在这种发布之后会显示上一次的旧名次。r 为空时才回落到 cr, +// 正好补上团体主体名次:团体名次有意不写 results.rank(applyComputedRanks 只认个人主体), +// 不回落到 channel_ranks 的话,团体项目发布之后大屏上这一行就是一个「-」。 +// +// 关联用 cr.entry_id = e.id,覆盖 basis=result 的名次(含纯团体项目,subject_key='e:<报名项id>')。 +// 覆盖不到聚合 / 团体手工名次:那类 channel_ranks 行的 entry_id / result_id 都是 NULL, +// 主体是 t:<单位id> / a:<运动员id>,而这类主体在分项成绩表里本来就没有对应的一行 +// (导出侧同一段子查询、同一处口径,见 export/load.go 的 loadEventRowsWithMembers)。 +// 取 MIN(cr2.id) = 报名项自己那一份名次:团体报名挂上队员后同一通道下还有每个队员的个人副本, +// 直接按 entry_id JOIN 会把这一行印 N+1 遍(与导出侧同一处理)。 +const mainChannelRankJoin = ` + LEFT JOIN event_score_channels mc ON mc.id = ( + SELECT c2.id FROM event_score_channels c2 + WHERE c2.event_id = r.event_id + ORDER BY CASE WHEN c2.scope = 'athlete' THEN 0 ELSE 1 END, c2.sort_order, c2.id + LIMIT 1) + LEFT JOIN channel_ranks cr ON cr.id = ( + SELECT MIN(cr2.id) FROM channel_ranks cr2 + WHERE cr2.channel_id = mc.id AND cr2.entry_id = e.id AND cr2.category_id = e.category_id)` + // liveResultGroups 取某个小项已发布的成绩,按「小项 + 组别」聚合成组。 // -// 与实时层 snapshot.latestResults 的口径一致(排除 withdrawn、组内按名次排序), -// 但范围固定在这一个小项、且不设条数上限——当前项目要显示完整名单,不能被 20 条截断。 +// 与实时层 snapshot.latestResults 的口径一致(排除 withdrawn、组内按名次排序、名次同样是 +// results.rank 优先 + channel_ranks 兜底),但范围固定在这一个小项、且不设条数上限—— +// 当前项目要显示完整名单,不能被 20 条截断。 func (s *server) liveResultGroups(ctx context.Context, eventID int64) ([]dto.EventResultGroup, error) { rows, err := s.ev.QueryContext(ctx, ` SELECT COALESCE(ev.code, ''), COALESCE(ev.name, ''), e.category_id, COALESCE(cat.name, ''), COALESCE(r.verified_at, r.recorded_at, r.updated_at, r.created_at, ''), - r.entry_id, r.rank, e.team_id, + r.entry_id, COALESCE(r.rank, cr.rank), e.team_id, COALESCE(t.code, ''), COALESCE(t.name, ''), COALESCE(t.short_name, ''), t.logo_path, e.athlete_id, COALESCE(a.name, ''), COALESCE(e.member_names, ''), COALESCE(r.result_text, ''), r.result_value, COALESCE(r.unit, ''), r.is_tie, r.status @@ -289,7 +318,7 @@ func (s *server) liveResultGroups(ctx context.Context, eventID int64) ([]dto.Eve JOIN events ev ON ev.id = r.event_id LEFT JOIN event_categories cat ON cat.id = e.category_id LEFT JOIN teams t ON t.id = e.team_id - LEFT JOIN athletes a ON a.id = e.athlete_id + LEFT JOIN athletes a ON a.id = e.athlete_id`+mainChannelRankJoin+` WHERE r.event_id = ? AND r.state = 'published' AND r.status <> 'withdrawn' ORDER BY COALESCE(r.verified_at, r.recorded_at, r.updated_at, r.created_at) DESC, r.id DESC`, eventID) if err != nil { diff --git a/server/internal/httpapi/live_events_test.go b/server/internal/httpapi/live_events_test.go index c606d23..3ae21e4 100644 --- a/server/internal/httpapi/live_events_test.go +++ b/server/internal/httpapi/live_events_test.go @@ -1,32 +1,42 @@ package httpapi_test import ( + "context" + "database/sql" "net/http" "strconv" "testing" "time" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/realtime" ) type liveEventsBody struct { - Source string `json:"source"` - EventIDs []int64 `json:"eventIds"` - Events []struct { - Event struct { - ID int64 `json:"id"` - Name string `json:"name"` - } `json:"event"` - Reason string `json:"reason"` - Groups []struct { - EventID int64 `json:"eventId"` - CategoryID int64 `json:"categoryId"` - CategoryName string - Rows []struct { - EntryID int64 `json:"entryId"` - Rank *int `json:"rank"` - ResultText string `json:"resultText"` - } `json:"rows"` - } `json:"groups"` - } `json:"events"` + Source string `json:"source"` + EventIDs []int64 `json:"eventIds"` + Events []liveEventBody `json:"events"` +} + +type liveEventBody struct { + Event struct { + ID int64 `json:"id"` + Name string `json:"name"` + } `json:"event"` + Reason string `json:"reason"` + Groups []liveGroupBody `json:"groups"` +} + +type liveGroupBody struct { + EventID int64 `json:"eventId"` + CategoryID int64 `json:"categoryId"` + CategoryName string `json:"categoryName"` + Rows []liveRowBody `json:"rows"` +} + +type liveRowBody struct { + EntryID int64 `json:"entryId"` + Rank *int `json:"rank"` + ResultText string `json:"resultText"` } type settingsBody struct { @@ -203,3 +213,154 @@ func TestDisplayLiveEmptyEvent(t *testing.T) { t.Fatalf("未配置时默认 auto,得到 %q", got.Source) } } + +// ---------- 大屏名次:团体项目回落 channel_ranks / 个人项目仍以 results.rank 为准 ---------- + +// latestResultsRank 取 WS 快照 latestResults 里某条报名项的名次——大屏的分项成绩页与底栏读的就是它。 +func latestResultsRank(t *testing.T, env *testEnv, entryID int64) *int { + t.Helper() + hub := realtime.NewHub(env.ev, nil) + t.Cleanup(func() { _ = hub.Close() }) + snap, err := hub.Snapshot(context.Background()) + if err != nil { + t.Fatalf("构建快照: %v", err) + } + for _, group := range snap.LatestResults { + for _, row := range group.Rows { + if row.EntryID == entryID { + return row.Rank + } + } + } + t.Fatalf("快照最新成绩里没有报名项 %d 的行:%+v", entryID, snap.LatestResults) + return nil +} + +// liveRanksForEvent 取实时赛况(GET /api/display/live)里某小项各报名项的名次。 +// 该小项没被选中时直接失败——这类用例验证的正是它必须出现在大屏上。 +func liveRanksForEvent(t *testing.T, env *testEnv, eventID int64) map[int64]*int { + t.Helper() + guest, _ := env.client() + status, resp := env.do(guest, http.MethodGet, "/api/display/live", nil, false) + if status != http.StatusOK || !resp.OK { + t.Fatalf("读取实时赛况应 200,得到 %d %+v", status, resp.Error) + } + body := decodeData[liveEventsBody](t, resp) + for _, ev := range body.Events { + if ev.Event.ID != eventID { + continue + } + out := make(map[int64]*int) + for _, group := range ev.Groups { + for _, row := range group.Rows { + out[row.EntryID] = row.Rank + } + } + return out + } + t.Fatalf("实时赛况里没有小项 %d(已选中 %+v)", eventID, body.EventIDs) + return nil +} + +// fmtRank 把可空名次转成可读文本:为空就是现场那个「-」。 +func fmtRank(rank *int) string { + if rank == nil { + return "空(大屏上就是「-」)" + } + return strconv.Itoa(*rank) +} + +// 现场回归:纯团体小项(event_type=team、通道 scope=team + basis=result,即「按成绩自动排名」的 +// 团体 4x100 接力)发布之后,大屏上显示该项目名次的地方都必须给出真实名次,不能是「-」。 +// +// 团体名次只落 channel_ranks、results.rank 有意留空(applyComputedRanks 只认个人主体,工单 A-1/A-2), +// 所以大屏两个读者都得回落到 channel_ranks:WS 快照的 latestResults(分项成绩页 + 底栏) +// 与实时赛况的 liveResultGroups(/api/display/live)。 +func TestDisplayShowsTeamRankForTeamEvent(t *testing.T) { + env, ids, client := recorderWithSeed(t) + eventID, categoryID, firstEntry := seedTeamEvent(t, env, ids) + // seedTeamEvent 只建了一个单位的报名项,再补一个单位,名次才排得出来。 + secondEntry := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status) + VALUES (?, ?, ?, NULL, 'registered')`, eventID, categoryID, ids.teamB) + publishResults(t, env, client, eventID, "team-rank", + map[string]any{"entryId": firstEntry, "resultValue": 45.20, "resultText": "45.20", "unit": "s", "status": "ok"}, + map[string]any{"entryId": secondEntry, "resultValue": 46.10, "resultText": "46.10", "unit": "s", "status": "ok"}, + ) + + // 1) 写侧不变:团体名次仍然不写 results.rank。 + if n := countRows(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND rank IS NOT NULL`, eventID); n != 0 { + t.Fatalf("团体名次不该写回 results.rank,得到 %d 行", n) + } + + // 2) 名次落在 channel_ranks 上,且 entry_id / result_id 都有值(下钻与导出靠它关联)。 + var ( + rank int + resultID sql.NullInt64 + ) + if err := env.ev.QueryRow(`SELECT rank, result_id FROM channel_ranks + WHERE event_id = ? AND category_id = ? AND scope = 'team' AND basis = 'result' + AND entry_id = ?`, eventID, categoryID, firstEntry).Scan(&rank, &resultID); err != nil { + t.Fatalf("读团体名次: %v", err) + } + if rank != 1 || !resultID.Valid { + t.Fatalf("channel_ranks 应有一行 rank=1 且挂 result_id,得到 rank=%d resultID=%+v", rank, resultID) + } + + // 3) / 4) 大屏两个读者都带上名次。 + for entryID, want := range map[int64]int{firstEntry: 1, secondEntry: 2} { + if got := latestResultsRank(t, env, entryID); got == nil || *got != want { + t.Fatalf("快照(分项成绩页 + 底栏)里报名项 %d 的名次 = %s,期望 %d", entryID, fmtRank(got), want) + } + } + live := liveRanksForEvent(t, env, eventID) + for entryID, want := range map[int64]int{firstEntry: 1, secondEntry: 2} { + if got := live[entryID]; got == nil || *got != want { + t.Fatalf("实时赛况里报名项 %d 的名次 = %s,期望 %d(完整:%+v)", entryID, fmtRank(got), want, live) + } + } +} + +// 不回归:个人项目的名次以 results.rank 为准,channel_ranks 只是空值兜底。 +// +// 「只更新名次」的发布(rankOnly)只写 results.rank、不重建 channel_ranks:这里先把决赛计分发布, +// 再改成绩按「只更新名次」发布——results.rank 换成新名次,channel_ranks 停在决赛那一版。 +// 大屏若改成 channel_ranks 优先,这种发布之后就会显示上一次的旧名次。 +func TestDisplayPrefersResultRankAfterRankOnlyPublish(t *testing.T) { + env, ids, client := recorderWithSeed(t) + publishResults(t, env, client, ids.event, "rank-pref-final", + map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"}, + map[string]any{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"}, + ) + assertRanksBothReaders(t, env, ids.event, map[int64]int{ids.entryA: 1, ids.entryB: 2}) + + // 改成绩(现场是退回草稿改完再发布),按「只更新名次」发布:名次换位。 + env.exec(t, `UPDATE results SET result_value = 11.92, result_text = '11.92' WHERE entry_id = ?`, ids.entryA) + env.exec(t, `UPDATE results SET result_value = 11.58, result_text = '11.58' WHERE entry_id = ?`, ids.entryB) + publishWith(t, env, client, ids.event, "rank-pref-rankonly", rankOnlyTrue) + + // 前提:results.rank 是新名次,channel_ranks 还是旧名次(这种发布不重建它)。 + if got := resultRank(t, env, ids.entryA); got != 2 { + t.Fatalf("只更新名次的发布应把 A 写成第 2 名,得到 %d", got) + } + if n := countRows(t, env, `SELECT COUNT(*) FROM channel_ranks WHERE event_id = ? AND entry_id = ? AND rank = 1`, + ids.event, ids.entryA); n != 1 { + t.Fatalf("前提不成立:channel_ranks 应停在旧名次(A 第 1 名),得到 %d 行", n) + } + + // 大屏必须跟着 results.rank 走,不能退回 channel_ranks 的旧名次。 + assertRanksBothReaders(t, env, ids.event, map[int64]int{ids.entryA: 2, ids.entryB: 1}) +} + +// assertRanksBothReaders 断言一组报名项在大屏两个读者上的名次(快照 + 实时赛况)。 +func assertRanksBothReaders(t *testing.T, env *testEnv, eventID int64, want map[int64]int) { + t.Helper() + live := liveRanksForEvent(t, env, eventID) + for entryID, rank := range want { + if got := latestResultsRank(t, env, entryID); got == nil || *got != rank { + t.Fatalf("快照(分项成绩页 + 底栏)里报名项 %d 的名次 = %s,期望 %d", entryID, fmtRank(got), rank) + } + if got := live[entryID]; got == nil || *got != rank { + t.Fatalf("实时赛况里报名项 %d 的名次 = %s,期望 %d(完整:%+v)", entryID, fmtRank(got), rank, live) + } + } +} diff --git a/server/internal/httpapi/realtime.go b/server/internal/httpapi/realtime.go index 916414d..1c0a445 100644 --- a/server/internal/httpapi/realtime.go +++ b/server/internal/httpapi/realtime.go @@ -3,8 +3,13 @@ package httpapi import ( "context" "database/sql" + "encoding/json" "errors" + "fmt" + "log/slog" "net/http" + "strings" + "unicode/utf8" "git.yeij.top/AskaEth/Scoreboard/server/internal/realtime" ) @@ -16,6 +21,193 @@ type Realtime interface { Instances() []realtime.Instance } +// ---------- 大屏别名(机器级设置) ---------- +// +// 别名与实例注册表是两套生命周期: +// - 实例注册表在**内存**里,连接断开后只留 TTL(十分钟)供排查; +// - 别名是操作员给「这台设备」起的名字,必须活过重启、活过实例过期。 +// +// 所以别名存系统库 system.db 的 settings 表(与 PUT /api/system/session 的登录有效期同一套 +// 读写机制,见 repo_misc.go 的 writeSessionHours),不新建表、不写迁移。 +const ( + // instanceAliasKey 下存一个 JSON 映射:{"": "<别名>"}。 + instanceAliasKey = "display.instance_aliases" + // maxInstanceAliases 是映射条数上限:别名会在实例过期后继续留着(下次那台设备连上来 + // 还认得出),只增不减会一路涨下去,必须有封顶。 + maxInstanceAliases = 200 + // maxAliasChars 是单个别名的字符数上限(按字符不是字节,中文名才存得下)。 + maxAliasChars = 40 +) + +// displayAliasPatch 是 PUT /api/display/instances/{id}/alias 的请求体。 +type displayAliasPatch struct { + // Alias 为空串或只空白 = 清除别名、回到自动设备标签。 + Alias string `json:"alias"` + RequestID string `json:"requestId"` +} + +// displayInstancesHandler 返回在线大屏实例列表(在线看板)。 +func (s *server) displayInstancesHandler(w http.ResponseWriter, r *http.Request) (any, error) { + if s.realtime == nil { + return []realtime.Instance{}, nil + } + list := s.realtime.Instances() + if list == nil { + list = []realtime.Instance{} + } + // 别名在这里与实例合并:注册表里只剩在线的与刚离线的,而别名可能属于一台早就 + // 被清理掉的设备——那种别名留着不填(没有对应实例),等它连上来自然就填上了。 + aliases, err := s.loadInstanceAliases(r.Context()) + if err != nil { + // 读别名失败不该让「在线大屏」列表整个打不开:现场靠它切页、排查。 + // 降级成「没有别名」(列表退回显示实例 id)并留下警告,其余字段照常返回。 + s.logger.Warn("读取大屏别名失败,列表按无别名返回", "err", err) + return list, nil + } + for i := range list { + if alias := aliases[list[i].ID]; alias != "" { + list[i].Alias = alias + } + } + return list, nil +} + +// updateDisplayInstanceAliasHandler 处理 PUT /api/display/instances/{id}/alias: +// 给一台设备设 / 清别名,返回该实例的最新记录(形状与列表元素一致,即 Instance DTO)。 +// 权限与大屏切页同档(recorder 及以上),见 server.go 的路由注册。 +func (s *server) updateDisplayInstanceAliasHandler(w http.ResponseWriter, r *http.Request) (any, error) { + id := strings.TrimSpace(r.PathValue("id")) + var in displayAliasPatch + if err := decodeJSON(r, &in); err != nil { + return nil, err + } + alias := realtime.CleanUserText(in.Alias) + if utf8.RuneCountInString(alias) > maxAliasChars { + return nil, badRequest( + fmt.Sprintf("别名最长 %d 个字符(当前 %d 个)", maxAliasChars, utf8.RuneCountInString(alias)), + nil) + } + + inst, ok := s.findDisplayInstance(id) + if !ok { + return nil, notFound("这台大屏当前不在注册表里:它可能已经离线很久、超过了保留时间。请刷新在线列表后重试。") + } + + return nil, s.idempotent(w, r, in.RequestID, func() (any, error) { + if err := s.saveInstanceAlias(r.Context(), id, alias); err != nil { + return nil, err + } + s.audit(r.Context(), "display.instance_alias", "display", 0, nil, + map[string]any{"instanceId": id, "alias": alias}) + inst.Alias = alias + return inst, nil + }) +} + +// findDisplayInstance 在注册表快照里按 id 找实例;找不到说明它已被清理(或压根没连过)。 +func (s *server) findDisplayInstance(id string) (realtime.Instance, bool) { + if id == "" || s.realtime == nil { + return realtime.Instance{}, false + } + for _, inst := range s.realtime.Instances() { + if inst.ID == id { + return inst, true + } + } + return realtime.Instance{}, false +} + +// loadInstanceAliases 读「实例 id → 别名」映射。 +// +// 存量值可能被手工改过、或是别的版本写的,所以读出来还要过一遍清洗:空 id / 空别名等于 +// 「没设」,直接丢掉(避免死条目一直占着条数上限),别名本身按同一个口径去控制字符与截断。 +func (s *server) loadInstanceAliases(ctx context.Context) (map[string]string, error) { + return loadInstanceAliasesQ(ctx, s.sys, s.logger) +} + +// loadInstanceAliasesQ 与 loadInstanceAliases 相同,但可以在给定连接 / 事务上读: +// saveInstanceAlias 要在同一个事务里「先读后写」,否则两个人同时改不同设备时, +// 后写的那个会把前一个的改动覆盖掉(两边各自读到旧映射,谁也看不见谁)。 +func loadInstanceAliasesQ(ctx context.Context, q queryer, logger *slog.Logger) (map[string]string, error) { + var raw string + err := q.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = ?`, instanceAliasKey).Scan(&raw) + switch { + case errors.Is(err, sql.ErrNoRows): + return map[string]string{}, nil + case err != nil: + return nil, internal(err) + } + if strings.TrimSpace(raw) == "" { + return map[string]string{}, nil + } + var stored map[string]string + if err := json.Unmarshal([]byte(raw), &stored); err != nil { + // 值被改坏时按「没有别名」处理并留下警告:一条脏设置不该把「在线大屏」列表打不开。 + logger.Warn("大屏别名设置不是合法 JSON,按无别名处理", "key", instanceAliasKey, "err", err) + return map[string]string{}, nil + } + out := make(map[string]string, len(stored)) + for id, alias := range stored { + id = strings.TrimSpace(id) + alias = realtime.TruncateChars(realtime.CleanUserText(alias), maxAliasChars) + if id == "" || alias == "" { + continue + } + out[id] = alias + } + return out, nil +} + +// saveInstanceAlias 写入 / 清除一个实例的别名。 +// +// 清除是真的把那条**删掉**(不是存空串):实例过期后别名会一直留着,只增不减的映射会一路 +// 顶到 maxInstanceAliases,之后所有设备都改不了名。 +// +// 超过条数上限时**拒绝**而不是淘汰最旧的:别名是按 id 存的、没有时间戳,淘汰谁都是猜; +// 而悄悄弄丢一台设备的命名比让操作员先清几条更难查。拒绝会带中文提示(409)。 +func (s *server) saveInstanceAlias(ctx context.Context, id, alias string) error { + // 读—改—写必须在一个事务里(系统库只有一条连接,事务把并发改名串起来)。 + tx, err := s.sys.BeginTx(ctx, nil) + if err != nil { + return internal(err) + } + defer func() { _ = tx.Rollback() }() + + aliases, err := loadInstanceAliasesQ(ctx, tx, s.logger) + if err != nil { + return err + } + if alias == "" { + if _, ok := aliases[id]; !ok { + return nil // 本来就没有别名:不必写库 + } + delete(aliases, id) + } else { + if _, exists := aliases[id]; !exists && len(aliases) >= maxInstanceAliases { + s.logger.Warn("大屏别名数量已达上限,拒绝新增", + "max", maxInstanceAliases, "instanceId", id) + return conflict(fmt.Sprintf( + "大屏别名最多 %d 条,已经存满。请先给不再使用的设备清除别名后重试。", maxInstanceAliases)) + } + aliases[id] = alias + } + + raw, err := json.Marshal(aliases) + if err != nil { + return internal(err) + } + if _, err := tx.ExecContext(ctx, + `INSERT INTO settings (key, value, updated_at) VALUES (?, ?, datetime('now')) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')`, + instanceAliasKey, string(raw)); err != nil { + return internal(err) + } + if err := tx.Commit(); err != nil { + return internal(err) + } + return nil +} + // broadcastChanges 在写请求成功返回后,把本次请求写入 change_log 的变更推给实时层。 // 版本号取请求开始时的最大 id,因此只推送本次写入,不会重复补发历史变更。 func (s *server) broadcastChanges(next http.Handler) http.Handler { @@ -121,15 +313,3 @@ func (w *statusRecorder) Write(b []byte) (int, error) { } return w.ResponseWriter.Write(b) } - -// displayInstancesHandler 返回在线大屏实例列表(在线看板)。 -func (s *server) displayInstancesHandler(w http.ResponseWriter, r *http.Request) (any, error) { - if s.realtime == nil { - return []realtime.Instance{}, nil - } - list := s.realtime.Instances() - if list == nil { - list = []realtime.Instance{} - } - return list, nil -} diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index 2496526..7483016 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -324,6 +324,10 @@ func (s *server) routes(mux *http.ServeMux) { // 大屏控制与运维 mux.HandleFunc("POST /api/display/command", s.write(auth.RoleRecorder, s.displayCommandHandler)) mux.HandleFunc("GET /api/display/instances", s.read(auth.RoleRecorder, s.displayInstancesHandler)) + // 给某台大屏设 / 清手动别名。**与切页同档(recorder)**:它改的是这台设备的显示名, + // 与「谁能在控制页下发指令」是同一批人,不该更严。别名是机器级设置(系统库), + // 实例注册表只在内存里,两者的合并见 displayInstancesHandler。 + mux.HandleFunc("PUT /api/display/instances/{id}/alias", s.write(auth.RoleRecorder, s.updateDisplayInstanceAliasHandler)) // 「实时赛况」页的当前项目与已发布成绩;大屏(guest)要能直接拉,故对外只读开放。 mux.HandleFunc("GET /api/display/live", s.public(s.liveEventsHandler)) mux.HandleFunc("GET /api/audit-logs", s.read(auth.RoleAdmin, s.listAuditLogsHandler)) diff --git a/server/internal/realtime/hub.go b/server/internal/realtime/hub.go index da83f25..2977e91 100644 --- a/server/internal/realtime/hub.go +++ b/server/internal/realtime/hub.go @@ -511,7 +511,12 @@ func (h *Hub) handleHello(ctx context.Context, c *client, p helloPayload) { if id == "" { id = p.DisplayKey } - h.registerDisplay(c, id, p.Page, p.Screen) + h.registerDisplay(c, id, displayRegistration{ + page: p.Page, + screen: p.Screen, + device: p.Device, + deviceRaw: p.DeviceRaw, + }) h.join(c, RoomDisplay) } diff --git a/server/internal/realtime/instances.go b/server/internal/realtime/instances.go index e891ea6..862cd4f 100644 --- a/server/internal/realtime/instances.go +++ b/server/internal/realtime/instances.go @@ -30,6 +30,14 @@ type Instance struct { // 管理端靠它把「本机显示器」与「在线实例」对上;局域网浏览器直接开 /display 时为空串。 // 用字符串而不是数字:显示器的 id 由 Electron 给,不保证是小的正整数。 Screen string `json:"screen"` + // Device 是浏览器报上来的设备标签(`安卓 13 · Chrome 120 · 1080×2400 @3x · 手机`), + // DeviceRaw 是原始 UA。老客户端不报时**整个键不出现**(omitempty),管理端列表退回显示 id。 + Device string `json:"device,omitempty"` + DeviceRaw string `json:"deviceRaw,omitempty"` + // Alias 是操作员手动起的名字。它**不住在注册表里**:注册表是内存的、实例过期即清理, + // 而别名是机器级的、必须活过重启与实例过期,所以由 httpapi 从系统库设置里读出来, + // 在返回列表时按实例 id 填进来(见 httpapi 的 loadInstanceAliases)。 + Alias string `json:"alias,omitempty"` } // instanceState 是注册表的内部记录;同一 instanceId 的多条连接合并成一个实例。 @@ -37,6 +45,8 @@ type instanceState struct { id string page string screen string + device string + deviceRaw string connectedAt time.Time lastHeartbeat time.Time remoteAddr string @@ -45,24 +55,45 @@ type instanceState struct { conns int } -// registerDisplay 把连接挂到大屏实例上;未带 instanceId 时由服务端生成, -// 保证「刷新页面 = 同一实例」之外的连接也能被登记和查询。 -func (h *Hub) registerDisplay(c *client, id, page, screen string) string { +// displayRegistration 是登记一个实例要用的 hello 字段。 +// 单独一个结构体而不是直接传 helloPayload:注册表只关心「在哪一页、哪块屏、什么设备」, +// role / lastVersion 那些与它无关。 +type displayRegistration struct { + page string + screen string + device string + deviceRaw string +} + +// registerDisplay 把连接挂到大屏实例上。 +// +// id 为空(局域网浏览器直接开 /display,URL 上没有 instance / screen)时**优先复用这条连接 +// 已经在用的实例 id**,只有从没用过才现生成一个:客户端翻页(setPage)与补齐设备标签都会 +// 再发一次 hello,而每次 hello 都现生成 `display-<随机>` 的话,老实例会永远留在注册表里 +// 变成一条「离线条目」(TTL 十分钟才清理),管理端「在线大屏」底下就凭空多出一条同一台 +// 设备的离线记录。刷新页面等新连接仍然各自登记,不会因此查不到。 +func (h *Hub) registerDisplay(c *client, id string, reg displayRegistration) string { + c.mu.Lock() + if id == "" { + id = c.instanceID + } if id == "" { id = "display-" + randomID() } - now := time.Now() - - c.mu.Lock() prev := c.instanceID c.instanceID = id c.mu.Unlock() // 同一连接重复 hello 或换了 instanceId 时,先归还旧实例的引用,避免计数虚高。 + // 复用同一条 id 时 prev == id,引用计数不动——这正是「重复 hello 不许产生新实例」的一半。 if prev != "" && prev != id { h.releaseDisplayID(prev, c) } + device := sanitizeDeviceText(reg.device, maxDeviceChars) + deviceRaw := sanitizeDeviceText(reg.deviceRaw, maxDeviceRawChars) + + now := time.Now() h.mu.Lock() defer h.mu.Unlock() h.pruneInstancesLocked(now) @@ -74,11 +105,19 @@ func (h *Hub) registerDisplay(c *client, id, page, screen string) string { if prev != id { st.conns++ } - if page != "" { - st.page = page + if reg.page != "" { + st.page = reg.page } - if screen != "" { - st.screen = screen + if reg.screen != "" { + st.screen = reg.screen + } + // 设备标签只在这次报了非空值时覆盖:老客户端(以及 localStorage 不可用的客户端) + // 不带这两个字段,不该把上一次 hello 报上来的标签抹掉。 + if device != "" { + st.device = device + } + if deviceRaw != "" { + st.deviceRaw = deviceRaw } st.lastHeartbeat = now st.remoteAddr = c.remoteAddr @@ -234,6 +273,8 @@ func (h *Hub) Instances() []Instance { AckVersion: st.ackVersion, Slow: st.slow, Screen: st.screen, + Device: st.device, + DeviceRaw: st.deviceRaw, }) } sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) diff --git a/server/internal/realtime/instances_test.go b/server/internal/realtime/instances_test.go new file mode 100644 index 0000000..45d2baa --- /dev/null +++ b/server/internal/realtime/instances_test.go @@ -0,0 +1,173 @@ +package realtime + +import ( + "strings" + "testing" +) + +// ---------- 设备字段的清洗与截断 ---------- + +func TestSanitizeDeviceText(t *testing.T) { + longUA := strings.Repeat("汉", 400) + cases := []struct { + name string + in string + limit int + want string + }{ + {"原样保留可读标签", "安卓 13 · Chrome 120 · 1080×2400 @3x · 手机", maxDeviceChars, + "安卓 13 · Chrome 120 · 1080×2400 @3x · 手机"}, + // 控制字符是能把管理端列表排版搞乱的东西(换行 / 制表 / 退格 / DEL),一律清掉。 + {"换行与制表被清掉", " 安卓 13\n\tChrome 120 \r\n", maxDeviceChars, "安卓 13Chrome 120"}, + {"DEL 与 C0 控制字符被清掉", "安卓\x00\x1b[31m 13\x7f", maxDeviceChars, "安卓[31m 13"}, + {"首尾空白去掉", " 平板 ", maxDeviceChars, "平板"}, + {"只空白视作没报", " \t\n ", maxDeviceChars, ""}, + {"空串保持空串", "", maxDeviceChars, ""}, + // 长度按**字符**不是字节:按字节截会把中文腰斩成半个字。 + {"按字符截断(中文)", strings.Repeat("汉", 81), maxDeviceChars, strings.Repeat("汉", 80)}, + {"恰好等于上限不截断", strings.Repeat("x", maxDeviceChars), maxDeviceChars, strings.Repeat("x", maxDeviceChars)}, + {"超长 UA 截到 300 字符", longUA, maxDeviceRawChars, strings.Repeat("汉", maxDeviceRawChars)}, + {"limit <= 0 不截断", strings.Repeat("x", 10), 0, strings.Repeat("x", 10)}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := sanitizeDeviceText(tc.in, tc.limit) + if got != tc.want { + t.Fatalf("清洗结果不符:\n得到 %q\n期望 %q", got, tc.want) + } + }) + } +} + +// 截断结果必须是合法 UTF-8(半个汉字会显示成替换方块)。 +func TestSanitizeDeviceTextKeepsValidUTF8(t *testing.T) { + got := sanitizeDeviceText(strings.Repeat("平板", 100), 5) + if got != "平板平板平" { + t.Fatalf("按字符截断结果应为 %q,得到 %q", "平板平板平", got) + } + for _, r := range got { + if r == '\uFFFD' { + t.Fatalf("截断后出现无效 UTF-8 字节: %q", got) + } + } +} + +// ---------- 同一条连接重复 hello 不许产生新实例 ---------- + +// 局域网浏览器(hello 不带 instanceId)翻页后 setPage 会再补一条 hello, +// 第二次 hello 必须落在**同一个**实例上,否则注册表里会多出一条该设备的离线条目。 +func TestRegisterDisplayReusesInstanceForRepeatHelloWithoutID(t *testing.T) { + env := newTestEnv(t) + env.hub.SetSnapshotMinInterval(0) + + c := env.dial(t, &helloPayload{Role: RoleDisplay, Page: "standings"}) + c.recvEvent(EventSnapshot) + + first := env.hub.Instances() + if len(first) != 1 { + t.Fatalf("首次 hello 后应只有一个实例,得到 %d 个: %+v", len(first), first) + } + if !strings.HasPrefix(first[0].ID, "display-") { + t.Fatalf("不带 instanceId 时实例 id 应由服务端生成,得到 %s", first[0].ID) + } + + // 第二次仍不带 instanceId(真实场景:翻页补发的 hello,端上没有本机 id)。 + c.send(EventHello, helloPayload{Role: RoleDisplay, Page: "medals"}) + c.recvEvent(EventSnapshot) + + got := env.hub.Instances() + if len(got) != 1 { + t.Fatalf("重复 hello 后注册表里应仍只有一个实例(旧实现会多出一条离线条目),得到 %d 个: %+v", + len(got), got) + } + if got[0].ID != first[0].ID { + t.Fatalf("重复 hello 应复用同一个实例 id:首次 %s,第二次 %s", first[0].ID, got[0].ID) + } + if !got[0].Online { + t.Fatalf("重复 hello 后实例应仍在线: %+v", got[0]) + } + if got[0].Page != "medals" { + t.Fatalf("重复 hello 应刷新当前页为 medals,得到 %s", got[0].Page) + } + + // 引用计数不能虚高:连接断开后实例必须立刻变成离线(虚高会显示成「还挂着一条连接」)。 + c.close() + waitFor(t, "重复 hello 后实例仍能正确离线", func() bool { + insts := env.hub.Instances() + return len(insts) == 1 && !insts[0].Online + }) +} + +// 换了 instanceId 的连接仍然各自登记:复用只针对「这次没带 id」,不能把显式换 id 吃掉。 +func TestRegisterDisplaySwitchesInstanceWhenIDChanges(t *testing.T) { + env := newTestEnv(t) + env.hub.SetSnapshotMinInterval(0) + + c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "screen-1"}) + c.recvEvent(EventSnapshot) + c.send(EventHello, helloPayload{Role: RoleDisplay, InstanceID: "screen-2"}) + c.recvEvent(EventSnapshot) + + insts := env.hub.Instances() + if len(insts) != 2 { + t.Fatalf("换 id 后应有两个实例,得到 %d 个: %+v", len(insts), insts) + } + if !instanceOnline(insts, "screen-2") { + t.Fatalf("新 id 应在线: %+v", insts) + } + // 旧实例的连接引用已归还,只剩「离线」的排查记录。 + if instanceOnline(insts, "screen-1") { + t.Fatalf("旧 id 的连接引用应已归还,不该仍显示在线: %+v", insts) + } +} + +// ---------- 设备字段进注册表 ---------- + +func TestRegisterDisplayStoresSanitizedDevice(t *testing.T) { + env := newTestEnv(t) + env.hub.SetSnapshotMinInterval(0) + + c := env.dial(t, &helloPayload{ + Role: RoleDisplay, + InstanceID: "pc-1", + Device: "安卓 13\n · Chrome 120\t", + DeviceRaw: "Mozilla/5.0 (Linux; Android 13)" + strings.Repeat("x", 400), + }) + c.recvEvent(EventSnapshot) + + insts := env.hub.Instances() + if len(insts) != 1 { + t.Fatalf("实例数应为 1,得到 %d", len(insts)) + } + if insts[0].Device != "安卓 13 · Chrome 120" { + t.Fatalf("设备标签应清洗掉换行与制表,得到 %q", insts[0].Device) + } + if n := len([]rune(insts[0].DeviceRaw)); n != maxDeviceRawChars { + t.Fatalf("原始 UA 应按字符截到 %d,得到 %d 个字符", maxDeviceRawChars, n) + } + if strings.ContainsAny(insts[0].DeviceRaw, "\n\t") { + t.Fatalf("原始 UA 不该含控制字符: %q", insts[0].DeviceRaw) + } + + // 老客户端(不带这两个字段)重连:不该把已经报上来的标签抹掉。 + c.send(EventHello, helloPayload{Role: RoleDisplay, InstanceID: "pc-1"}) + c.recvEvent(EventSnapshot) + if got := env.hub.Instances()[0]; got.Device == "" || got.DeviceRaw == "" { + t.Fatalf("不带设备字段的重复 hello 不该清空已有标签: %+v", got) + } +} + +// 老客户端(不带 device / deviceRaw)行为与今天一致:字段留空,注册表照常登记。 +func TestRegisterDisplayWithoutDeviceFields(t *testing.T) { + env := newTestEnv(t) + c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "old-1"}) + c.recvEvent(EventSnapshot) + + insts := env.hub.Instances() + if len(insts) != 1 || insts[0].ID != "old-1" || !insts[0].Online { + t.Fatalf("老客户端应照常登记: %+v", insts) + } + if insts[0].Device != "" || insts[0].DeviceRaw != "" { + t.Fatalf("老客户端没报设备字段时应留空: %+v", insts[0]) + } +} diff --git a/server/internal/realtime/protocol.go b/server/internal/realtime/protocol.go index 2a4d2c5..077ca58 100644 --- a/server/internal/realtime/protocol.go +++ b/server/internal/realtime/protocol.go @@ -7,7 +7,9 @@ package realtime import ( "encoding/json" "fmt" + "strings" "time" + "unicode" ) // 事件名与协议第 3 节的表格一一对应。 @@ -107,6 +109,64 @@ type helloPayload struct { Page string `json:"page,omitempty"` // Screen 是 Console 从 URL 带下来的屏号(screen=),局域网浏览器留空。 Screen string `json:"screen,omitempty"` + // Device 是浏览器自己拼好的设备标签(`安卓 13 · Chrome 120 · 1080×2400 @3x · 手机`), + // DeviceRaw 是原始 UA(排障用)。两者都 omitempty:**老客户端不带这两个字段,服务端行为 + // 与今天逐字一致**(管理端列表退回显示实例 id)。 + // + // 服务端不解析 UA,但也不能全信这两个值:它们是客户端自报的自由文本,会直接进管理端列表。 + // 长度按**字符**截断、控制字符一律清掉,收口在 sanitizeDeviceText(registerDisplay 调用)。 + Device string `json:"device,omitempty"` + DeviceRaw string `json:"deviceRaw,omitempty"` +} + +// 客户端自报文本的长度上限,按**字符**(rune)不是字节算: +// `安卓 13 · Chrome 120` 这种标签按字节截会切出半个汉字,显示成乱码方框。 +const ( + maxDeviceChars = 80 + maxDeviceRawChars = 300 +) + +// CleanUserText 清洗客户端自报的自由文本:去掉全部控制字符(换行、制表、退格、DEL…)与首尾空白。 +// +// 这些值最终要显示在管理端「在线大屏」列表里,一个带换行的 device 能把整行排版顶歪; +// 服务端是唯一的收口处,不能指望每个客户端都干净。TrimSpace 之后为空即视作「没报」,返回空串。 +// +// 保留字符间的普通空格与各种可见符号:设备标签本来就长这样,不该被服务端改写。 +func CleanUserText(s string) string { + if s == "" { + return "" + } + var b strings.Builder + b.Grow(len(s)) + for _, r := range s { + if unicode.IsControl(r) { + continue + } + b.WriteRune(r) + } + return strings.TrimSpace(b.String()) +} + +// TruncateChars 按字符(rune)截断到 maxChars 个;maxChars <= 0 表示不截断。 +// 会切断代理对时以整字符为界(Go 的 range 只落在字符起始字节上),不会切出半个 UTF-8 序列。 +func TruncateChars(s string, maxChars int) string { + if maxChars <= 0 { + return s + } + count := 0 + for i := range s { + if count == maxChars { + return s[:i] + } + count++ + } + return s +} + +// sanitizeDeviceText 是 hello 里设备字段的收口:先清洗再截断。 +// 没有这两个字段的老客户端拿到的是空串,注册表按「没报」处理。 +func sanitizeDeviceText(s string, maxChars int) string { + return TruncateChars(CleanUserText(s), maxChars) } // ackPayload 是 C→S 的 ack 载荷;服务端据此识别慢客户端。 diff --git a/server/internal/realtime/snapshot.go b/server/internal/realtime/snapshot.go index ce6d333..247299f 100644 --- a/server/internal/realtime/snapshot.go +++ b/server/internal/realtime/snapshot.go @@ -14,9 +14,28 @@ import ( "git.yeij.top/AskaEth/Scoreboard/server/internal/dto" ) -// latestResultsLimit 控制快照里「最新成绩」的行数上限,避免大屏首包过大。 -// 行会按「小项 + 组别」聚合后再下发,因此组数只会比这个值更少。 -const latestResultsLimit = 20 +// 快照「最新成绩」的窗口口径:先取最近发布的 K 组,再每组保底取前 R 行。 +// +// 不能按「总行数」截断:行是按发布时间倒序取的,而组是按「首次出现」建号的, +// 于是组数组的顺序 = 发布时间倒序——**屏上最旧的那一栏站在窗口尾部**,行数截断的切口 +// 只会落进那一组里,把「建号最早的行」切掉。手录名次的项目通常从第 1 名往下录, +// 第 1 名的 results.id 最小 → 现场表现就是「最旧那一栏看不到第 1 名、从第 2 名开始」, +// 且症状跟着**栏位**走(同一批项目挪到前面几栏就正常)。 +// +// 最坏情况下快照下发 8 × 10 = 80 行(旧口径是全局 20 行),首包因此变大; +// 换来的是每一组都从第 1 名开始,不再有「最旧一栏少一行」。 +const ( + // latestResultsGroupLimit 保留的**组数**上限。一组 = 一个小项 + 一个组别,即大屏的一栏。 + // 前端单屏最多 4 栏(web/src/display/useScoreboard.ts 把 resultsCount 夹在 1..4), + // 底栏只需最新一组;留一倍余量给「刚发布、还没轮到显示」的组,取 8。 + latestResultsGroupLimit = 8 + + // latestResultsRowsPerGroup 每组保留的**行数**上限,按名次取前 R 行。 + // 前端每栏只画 6 行(web/src/display/results.ts 的 RESULT_ROWS_PER_COLUMN), + // 并列名次会让一栏超过 6 行;实时赛况页的同一批数据上限是 10 行 + // (web/src/display/live.ts 的 MAX_LIVE_ROWS),兜底取 10。 + latestResultsRowsPerGroup = 10 +) // Snapshot 是 snapshot 事件的 data,也是客户端冷启动的完整状态。 // 字段名与协议第 3 节一致;settings/standings/latestResults 的形状必须与 REST 同构, @@ -423,15 +442,82 @@ func loadRecords(ctx context.Context, tx *sql.Tx, snap *Snapshot) error { return rows.Err() } +// mainChannelRankJoin 是大屏成绩行上名次列的「主通道 + 通道名次」两段关联,供 loadLatestResults +// 与 httpapi 的 liveResultGroups 使用(两边必须逐字相同,否则分项成绩页与实时赛况的名次会打架)。 +// +// 名次取「主通道」口径(devdocs 17 §11.3):一行成绩只能出一个名次,主通道 = scope=athlete 优先, +// 其次通道 sort_order 小者,与 export/load.go 的分项成绩表同一条规则。优先级是 +// **results.rank 优先、channel_ranks 兜底**(见下面各查询里的 COALESCE(r.rank, cr.rank)), +// 不能照抄导出侧的 cr 优先:results.rank 是这条报名项自己的名次,而「只更新名次」的发布 +// (rankOnly,httpapi/scoring.go)只写 results.rank、不重建 channel_ranks——cr 一优先, +// 个人项目在这种发布之后会显示上一次的旧名次。反过来 r 为空时才回落到 cr, +// 正好补上团体主体名次:团体名次有意不写 results.rank(applyComputedRanks 只认个人主体)。 +// +// 关联用 cr.entry_id = e.id,覆盖 basis=result 的名次(含纯团体项目,subject_key='e:<报名项id>')。 +// 覆盖不到聚合 / 团体手工名次:那类 channel_ranks 行的 entry_id / result_id 都是 NULL, +// 主体是 t:<单位id> / a:<运动员id>,而这类主体在分项成绩表里本来就没有对应的一行 +// (导出侧同一段子查询、同一处口径,见 export/load.go 的 loadEventRowsWithMembers)。 +// 取 MIN(cr2.id) = 报名项自己那一份名次:团体报名挂上队员后同一通道下还有每个队员的个人副本, +// 直接按 entry_id JOIN 会把这一行印 N+1 遍(与导出侧同一处理)。 +// +// ⚠️ 与 export/load.go(loadEventRowsWithMembers 的 cr 子查询)、httpapi/live_events.go(liveResultGroups) +// 同源,改一处要改三处。 +const mainChannelRankJoin = ` + LEFT JOIN event_score_channels mc ON mc.id = ( + SELECT c2.id FROM event_score_channels c2 + WHERE c2.event_id = r.event_id + ORDER BY CASE WHEN c2.scope = 'athlete' THEN 0 ELSE 1 END, c2.sort_order, c2.id + LIMIT 1) + LEFT JOIN channel_ranks cr ON cr.id = ( + SELECT MIN(cr2.id) FROM channel_ranks cr2 + WHERE cr2.channel_id = mc.id AND cr2.entry_id = e.id AND cr2.category_id = e.category_id)` + +// latestResultsScopedSelect 是窗口函数的输入:已发布、未作废的成绩行,每行只带分组键、 +// 发布时间与名次。时间表达式与主查询逐字相同——这里少一个兜底列,组的发布时间就会漂到别的列上。 +// 名次口径也必须与主查询一致(results.rank 优先、channel_ranks 兜底), +// 否则「每组取前 R 行」和「组内展示顺序」会各按一套名次排。 +const latestResultsScopedSelect = ` + SELECT r.id AS rid, r.event_id AS event_id, e.category_id AS category_id, + COALESCE(r.verified_at, r.recorded_at, r.updated_at, r.created_at, '') AS ts, + COALESCE(r.rank, cr.rank) AS rank_value + FROM results r + JOIN entries e ON e.id = r.entry_id` + mainChannelRankJoin + ` + WHERE r.state = 'published' AND r.status <> 'withdrawn'` + // loadLatestResults 取最近发布的成绩,按「小项 + 组别」聚合成组后下发。 // 已作废(status=withdrawn)的成绩必须排除,否则大屏会继续展示被作废的行; // 前端按组渲染(TickerBar 取最新一组、EventResultsPage 取前 N 组),平铺行会让它拿到 undefined。 +// +// 窗口是「K 组 × 每组 R 行」而不是「总 N 行」,理由见上面那组常量的注释。三件事都在 SQL 里定死: +// +// 1. recent_groups:先按**组的发布时间**(= 组内最新一条的时间)倒序取前 K 组。并列时间按 +// 「该组最新那一行的 r.id」倒序决出——与旧实现「行按 ts DESC, r.id DESC 取回、组按首次出现建号」 +// 的顺序**逐位相同**,组数组的顺序不变(`Snapshot.LatestResults` 是 WS 契约)。 +// 2. bounded:每组按名次升序编号(无名次排最后,与下面 Go 侧那次 sort 同口径),只留前 R 行。 +// ⚠️ 必须是「先定组、再每组截断」;退化成「取 N 行再分组」就还是同一个 bug,只是阈值变大。 +// 3. 组的时间取 anchors 定的 group_ts(组内最新一条),不是「这一组碰巧先被取回的那一行」—— +// 组内按名次截断之后,第一行的发布时间不再等于组的最新发布时间。 func loadLatestResults(ctx context.Context, tx *sql.Tx, snap *Snapshot) error { rows, err := tx.QueryContext(ctx, ` + WITH scoped AS (`+latestResultsScopedSelect+`), + anchors AS ( + SELECT event_id, category_id, ts AS group_ts, rid, + ROW_NUMBER() OVER (PARTITION BY event_id, category_id ORDER BY ts DESC, rid DESC) AS anchor_no + FROM scoped), + recent_groups AS ( + SELECT event_id, category_id, group_ts + FROM anchors WHERE anchor_no = 1 + ORDER BY group_ts DESC, rid DESC + LIMIT ?), + bounded AS ( + SELECT rid, event_id, category_id, + ROW_NUMBER() OVER (PARTITION BY event_id, category_id + ORDER BY rank_value IS NULL, rank_value, rid) AS row_no + FROM scoped) SELECT r.event_id, COALESCE(ev.code, ''), COALESCE(ev.name, ''), e.category_id, COALESCE(cat.name, ''), - COALESCE(r.verified_at, r.recorded_at, r.updated_at, r.created_at, ''), - r.entry_id, r.rank, e.team_id, + rg.group_ts, + r.entry_id, COALESCE(r.rank, cr.rank), e.team_id, COALESCE(t.code, ''), COALESCE(t.name, ''), COALESCE(t.short_name, ''), t.logo_path, e.athlete_id, COALESCE(a.name, ''), COALESCE(e.member_names, ''), COALESCE(r.result_text, ''), r.result_value, COALESCE(r.unit, ''), r.is_tie, r.status @@ -440,10 +526,13 @@ func loadLatestResults(ctx context.Context, tx *sql.Tx, snap *Snapshot) error { JOIN events ev ON ev.id = r.event_id LEFT JOIN event_categories cat ON cat.id = e.category_id LEFT JOIN teams t ON t.id = e.team_id - LEFT JOIN athletes a ON a.id = e.athlete_id - WHERE r.state = 'published' AND r.status <> 'withdrawn' - ORDER BY COALESCE(r.verified_at, r.recorded_at, r.updated_at, r.created_at) DESC, r.id DESC - LIMIT ?`, latestResultsLimit) + LEFT JOIN athletes a ON a.id = e.athlete_id`+mainChannelRankJoin+` + JOIN bounded b ON b.rid = r.id + JOIN recent_groups rg ON rg.event_id = b.event_id AND rg.category_id = b.category_id + WHERE r.state = 'published' AND r.status <> 'withdrawn' AND b.row_no <= ? + ORDER BY rg.group_ts DESC, + COALESCE(r.verified_at, r.recorded_at, r.updated_at, r.created_at) DESC, r.id DESC`, + latestResultsGroupLimit, latestResultsRowsPerGroup) if err != nil { return fmt.Errorf("读取最新成绩: %w", err) } @@ -460,18 +549,20 @@ func loadLatestResults(ctx context.Context, tx *sql.Tx, snap *Snapshot) error { eventID, categoryID int64 eventCode, eventName string categoryName string - publishedAt string - row dto.EventResultRow - rank sql.NullInt64 - value sql.NullFloat64 - athleteID sql.NullInt64 - athleteName string - shortName string - logoPath sql.NullString - memberNames string - isTie int + // groupPublishedAt 是这一**组**的发布时间(组内最新一条),来自 SQL 的 group_ts, + // 不是这一行自己的时间——组内按名次截断后,第一行的时间不再等于组的最新时间。 + groupPublishedAt string + row dto.EventResultRow + rank sql.NullInt64 + value sql.NullFloat64 + athleteID sql.NullInt64 + athleteName string + shortName string + logoPath sql.NullString + memberNames string + isTie int ) - if err := rows.Scan(&eventID, &eventCode, &eventName, &categoryID, &categoryName, &publishedAt, + if err := rows.Scan(&eventID, &eventCode, &eventName, &categoryID, &categoryName, &groupPublishedAt, &row.EntryID, &rank, &row.TeamID, &row.TeamCode, &row.TeamName, &shortName, &logoPath, &athleteID, &athleteName, &memberNames, &row.ResultText, &value, &row.Unit, &isTie, &row.Status); err != nil { @@ -506,7 +597,7 @@ func loadLatestResults(ctx context.Context, tx *sql.Tx, snap *Snapshot) error { EventName: eventName, CategoryID: categoryID, CategoryName: categoryName, - PublishedAt: nilIfEmpty(publishedAt), + PublishedAt: nilIfEmpty(groupPublishedAt), Rows: []dto.EventResultRow{}, }) } diff --git a/server/internal/realtime/snapshot_test.go b/server/internal/realtime/snapshot_test.go index c1f92db..3956c96 100644 --- a/server/internal/realtime/snapshot_test.go +++ b/server/internal/realtime/snapshot_test.go @@ -7,6 +7,7 @@ import ( "fmt" "strings" "testing" + "time" "git.yeij.top/AskaEth/Scoreboard/server/internal/dto" ) @@ -722,3 +723,375 @@ func TestSnapshotTeamsEventsWireFieldsPresent(t *testing.T) { } } } + +// insertTeamEvent 建一个纯团体小项(event_type=team),与现场「团体 4x100 接力」同形。 +func insertTeamEvent(t *testing.T, db *sql.DB, code, name string) int64 { + t.Helper() + if _, err := db.Exec(`INSERT OR IGNORE INTO sports(code, name) VALUES ('S1', '田径')`); err != nil { + t.Fatalf("插入大项: %v", err) + } + res, err := db.Exec(`INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, status) + VALUES (1, ?, ?, 'team', 'auto', 'time', 'asc', 'published')`, code, name) + if err != nil { + t.Fatalf("插入团体小项 %s: %v", code, err) + } + return lastID(t, res) +} + +// insertTeamResult 写一条团体成绩:rank 列保持 NULL。 +// 团体名次有意不写 results.rank(applyComputedRanks 只认个人主体),发布后的真实形状就是这样。 +func insertTeamResult(t *testing.T, db *sql.DB, eventID, entryID int64, text string, value float64, verifiedAt string) int64 { + t.Helper() + res, err := db.Exec(`INSERT INTO results(event_id, entry_id, rank, result_text, result_value, unit, state, status, verified_at) + VALUES (?, ?, NULL, ?, ?, 's', 'published', 'ok', ?)`, eventID, entryID, text, value, verifiedAt) + if err != nil { + t.Fatalf("插入团体成绩: %v", err) + } + return lastID(t, res) +} + +// latestRowsByEntry 把快照「最新成绩」摊成 entryID -> 名次,便于逐条断言。 +func latestRowsByEntry(t *testing.T, snap *Snapshot) map[int64]*int { + t.Helper() + out := make(map[int64]*int) + for _, group := range snap.LatestResults { + for _, row := range group.Rows { + out[row.EntryID] = row.Rank + } + } + return out +} + +// 团体项目的名次只落 channel_ranks、results.rank 有意留空,快照的 latestResults 必须回落到 +// channel_ranks:不回落的现场表现就是「团体 4x100 接力」发布之后,大屏分项成绩页与底栏上的 +// 名次整列都是「-」。库形态与发布后的真实落库一致(发布链路端到端钉在 httpapi 侧: +// TestDisplayShowsTeamRankForTeamEvent)。 +func TestSnapshotLatestResultsFallsBackToChannelRanks(t *testing.T) { + env := newTestEnv(t) + db := env.db + + team1 := insertTeam(t, db, "T1", "第一单位", "一队") + team2 := insertTeam(t, db, "T2", "第二单位", "二队") + eventID := insertTeamEvent(t, db, "T4X100", "4x100 米接力") + catID := insertCategory(t, db, eventID, "男子") + entry1 := insertEntry(t, db, eventID, catID, team1, 0) + entry2 := insertEntry(t, db, eventID, catID, team2, 0) + channelID := insertChannel(t, db, eventID, "team") + // 故意先写名次靠后的那条,验证组内按名次重排。 + result2 := insertTeamResult(t, db, eventID, entry2, "46.10", 46.10, "2026-09-09T10:00:02Z") + result1 := insertTeamResult(t, db, eventID, entry1, "45.20", 45.20, "2026-09-09T10:00:01Z") + insertChannelRank(t, db, eventID, catID, channelID, entry1, result1, team1, 0, "team", "result", 1, 45.20) + insertChannelRank(t, db, eventID, catID, channelID, entry2, result2, team2, 0, "team", "result", 2, 46.10) + + var ranked int + if err := db.QueryRow(`SELECT COUNT(*) FROM results WHERE event_id = ? AND rank IS NOT NULL`, eventID).Scan(&ranked); err != nil { + t.Fatalf("统计团体名次写回: %v", err) + } + if ranked != 0 { + t.Fatalf("前提不成立:团体名次不该写回 results.rank,得到 %d 行", ranked) + } + + snap, err := env.hub.Snapshot(context.Background()) + if err != nil { + t.Fatalf("构建快照: %v", err) + } + if len(snap.LatestResults) != 1 || len(snap.LatestResults[0].Rows) != 2 { + t.Fatalf("应有 1 组 2 行,得到 %+v", snap.LatestResults) + } + ranks := latestRowsByEntry(t, snap) + for entryID, want := range map[int64]int{entry1: 1, entry2: 2} { + got := ranks[entryID] + if got == nil { + t.Fatalf("报名项 %d 的名次为空(大屏上就是「-」,得到 %+v)", entryID, snap.LatestResults) + } + if *got != want { + t.Fatalf("报名项 %d 的名次 = %d,期望 %d(来自 channel_ranks)", entryID, *got, want) + } + } + if snap.LatestResults[0].Rows[0].EntryID != entry1 { + t.Fatalf("组内应按名次排序(第一条应是第 1 名的报名项 %d),得到 %+v", + entry1, snap.LatestResults[0].Rows) + } +} + +// 名次优先级必须是 results.rank 优先、channel_ranks 兜底: +// 「只更新名次」的发布(rankOnly)只写 results.rank、不重建 channel_ranks, +// 若 cr 优先,个人项目在这种发布之后会退回上一次的旧名次(从「正常」变成「显示旧名次」)。 +func TestSnapshotLatestResultsPrefersResultRank(t *testing.T) { + env := newTestEnv(t) + db := env.db + + team1 := insertTeam(t, db, "T1", "第一单位", "") + eventID := insertEvent(t, db, "E1", "100米") + catID := insertCategory(t, db, eventID, "男子") + athlete1 := insertAthlete(t, db, "张三", team1) + entry1 := insertEntry(t, db, eventID, catID, team1, athlete1) + channelID := insertChannel(t, db, eventID, "athlete") + // results.rank = 2 是「只更新名次」那次发布写下的新名次;channel_ranks 还是上一次发布的 1。 + result1 := insertResult(t, db, eventID, entry1, 2, "11.72", 11.72, "2026-09-09T10:00:01Z") + insertChannelRank(t, db, eventID, catID, channelID, entry1, result1, team1, athlete1, "athlete", "result", 1, 11.72) + + snap, err := env.hub.Snapshot(context.Background()) + if err != nil { + t.Fatalf("构建快照: %v", err) + } + got := latestRowsByEntry(t, snap)[entry1] + if got == nil { + t.Fatalf("名次为空(大屏上就是「-」),期望 results.rank 的 2") + } + if *got != 2 { + t.Fatalf("名次应是 results.rank 的 2(新名次),得到 %d", *got) + } +} + +// shiftTS 把 ISO 时间往后推 n 秒,造数里用来让「名次越靠后、发布时间越晚」(= 从第 1 名往下发)。 +func shiftTS(t *testing.T, base string, seconds int) string { + t.Helper() + ts, err := time.Parse(time.RFC3339, base) + if err != nil { + t.Fatalf("解析时间 %s: %v", base, err) + } + return ts.Add(time.Duration(seconds) * time.Second).Format(time.RFC3339) +} + +// insertRankedGroup 造一组「小项 + 组别」:rows 条已发布成绩,名次 1..rows **按顺序录入** +// (results.id 随名次递增,第 1 名建号最早),返回小项 id。 +// tsOf 给出每一行的发布时间:现场「从第 1 名往下录/往下发」时它随名次变晚, +// 而一次发布刷同一时间戳时它是常量(并列靠 r.id 决先后)。 +func insertRankedGroup(t *testing.T, db *sql.DB, code, name string, teamID int64, rows int, tsOf func(rank int) string) int64 { + t.Helper() + eventID := insertEvent(t, db, code, name) + catID := insertCategory(t, db, eventID, "男子") + for rank := 1; rank <= rows; rank++ { + athlete := insertAthlete(t, db, fmt.Sprintf("%s-第%d名", code, rank), teamID) + entry := insertEntry(t, db, eventID, catID, teamID, athlete) + insertResult(t, db, eventID, entry, rank, + fmt.Sprintf("11.%02d", rank), 11+float64(rank)/100, tsOf(rank)) + } + return eventID +} + +// ranksOf 把一组的名次摊平成列表,只用于失败信息:直接 `%+v` 一整组行会打出一串指针地址。 +func ranksOf(group dto.EventResultGroup) []string { + out := make([]string, 0, len(group.Rows)) + for _, row := range group.Rows { + if row.Rank == nil { + out = append(out, "—") + continue + } + out = append(out, fmt.Sprint(*row.Rank)) + } + return out +} + +// 快照「最新成绩」的窗口是「最近 K 组 × 每组前 R 行」,不是「总 N 行」。 +// +// 这条用例钉的是现场那个 bug:行按发布时间倒序取、组按「首次出现」建号 → **屏上最旧的那一栏 +// 站在窗口尾部**,按总行数截断时切口只会落进那一组里,把「建号最早、也就是第 1 名」的行切掉。 +// 现场表现就是「第 3 栏看不到第 1 名、从第 2 名开始」,而且症状跟着**栏位**走、不跟着项目走。 +// +// 旧实现(全局 20 行窗口)在这里必然失败:24 行里最旧的 4 行(第 3 栏的第 1–4 名)被切掉, +// 第 3 栏第一行的名次是 5 而不是 1。既有用例拦不住它——那些用例每组最多 2 行、总量远小于 20。 +func TestSnapshotLatestResultsKeepsRankOneInEveryGroup(t *testing.T) { + env := newTestEnv(t) + db := env.db + + team1 := insertTeam(t, db, "T1", "第一单位", "") + const rowsPerGroup = 8 + // 三栏的发布时间:第 3 栏(下标 2)最早发布、建号也最早,旧实现下正是它站在窗口尾部。 + publishedBases := [3]string{"2026-09-09T10:00:00Z", "2026-09-09T11:00:00Z", "2026-09-09T09:00:00Z"} + + events := make([]int64, len(publishedBases)) + // 造数顺序刻意与栏位顺序相反:先发布「最终落在第 3 栏」的那一组,再发另外两组。 + for _, col := range []int{2, 0, 1} { + base := publishedBases[col] + events[col] = insertRankedGroup(t, db, fmt.Sprintf("E%d", col+1), + fmt.Sprintf("大屏第 %d 栏的小项", col+1), team1, rowsPerGroup, + func(rank int) string { return shiftTS(t, base, rank) }) + } + + snap, err := env.hub.Snapshot(context.Background()) + if err != nil { + t.Fatalf("构建快照: %v", err) + } + + byEvent := make(map[int64]dto.EventResultGroup, len(snap.LatestResults)) + totalRows := 0 + for _, group := range snap.LatestResults { + byEvent[group.EventID] = group + totalRows += len(group.Rows) + } + if len(snap.LatestResults) < 3 { + t.Fatalf("三栏都应下发,得到 %d 组: %+v", len(snap.LatestResults), snap.LatestResults) + } + // 组数组的顺序不变(WS 契约):发布时间倒序,最旧的那一组在最尾部 = 屏上最后一栏。 + if snap.LatestResults[len(snap.LatestResults)-1].EventID != events[2] { + t.Fatalf("最旧发布的组应排在组数组尾部(第 3 栏),得到 %+v", + []int64{snap.LatestResults[0].EventID, snap.LatestResults[1].EventID, snap.LatestResults[2].EventID}) + } + + wantRows := min(rowsPerGroup, latestResultsRowsPerGroup) + for col, eventID := range events { + group, ok := byEvent[eventID] + if !ok { + t.Fatalf("第 %d 栏(小项 %d)整组都没下发: %+v", col+1, eventID, snap.LatestResults) + } + // 断言顺序有讲究:先看第 1 名在不在,失败信息才是这个 bug 的原始形状。 + if group.Rows[0].Rank == nil || *group.Rows[0].Rank != 1 { + t.Fatalf("第 %d 栏应从第 1 名开始,实际名次 %v(整组行数 %d)", col+1, ranksOf(group), len(group.Rows)) + } + for i, row := range group.Rows { + if row.Rank == nil || *row.Rank != i+1 { + t.Fatalf("第 %d 栏的名次应连续升序,实际 %v", col+1, ranksOf(group)) + } + } + if len(group.Rows) != wantRows { + t.Fatalf("第 %d 栏应有 %d 行(每组上限 R=%d),实际名次 %v", + col+1, wantRows, latestResultsRowsPerGroup, ranksOf(group)) + } + } + // 总行数 > 20 是用例的前提(旧口径正是从这里翻车),放最后断言: + // 失败信息才优先说清「是哪一栏少了第 1 名」。 + if totalRows <= 20 { + t.Fatalf("用例前提不成立:总行数必须 > 20(旧实现翻车的条件),得到 %d", totalRows) + } +} + +// 每组只保留**名次最靠前**的 R 行,且组的发布时间取组内最新一条(哪怕那一行被 R 截掉)。 +// +// 这一条同时挡住两种退化:①「先按总行数截断再分组」(会留下 12 行、超过 R); +// ②「组内取发布时间最新的 R 行」(会少掉第 1 名)。旧实现在这里也是红的(12 行一条不落地留下)。 +func TestSnapshotLatestResultsCapsRowsPerGroupByRank(t *testing.T) { + env := newTestEnv(t) + db := env.db + + team1 := insertTeam(t, db, "T1", "第一单位", "") + base := "2026-09-09T10:00:00Z" + // 比 R 多 2 行:名次最靠后的两行必须被丢掉,第 1 名必须在。 + rows := latestResultsRowsPerGroup + 2 + eventID := insertRankedGroup(t, db, "E1", "男子 100 米", team1, rows, + func(rank int) string { return shiftTS(t, base, rank) }) + + snap, err := env.hub.Snapshot(context.Background()) + if err != nil { + t.Fatalf("构建快照: %v", err) + } + if len(snap.LatestResults) != 1 { + t.Fatalf("应只有 1 组,得到 %d: %+v", len(snap.LatestResults), snap.LatestResults) + } + group := snap.LatestResults[0] + if group.EventID != eventID { + t.Fatalf("组的小项应是 %d,得到 %d", eventID, group.EventID) + } + if len(group.Rows) != latestResultsRowsPerGroup { + t.Fatalf("一组最多 %d 行,实际名次 %v", latestResultsRowsPerGroup, ranksOf(group)) + } + for i, row := range group.Rows { + if row.Rank == nil || *row.Rank != i+1 { + t.Fatalf("留下的必须是名次最靠前的 %d 行,实际名次 %v", latestResultsRowsPerGroup, ranksOf(group)) + } + } + // 组的发布时间 = 组内最新一条(第 12 名那行的 base+12s),不是「留下的第一行」的 base+1s。 + wantPublishedAt := shiftTS(t, base, rows) + if group.PublishedAt == nil || *group.PublishedAt != wantPublishedAt { + t.Fatalf("组的发布时间应取组内最新一条 %s,得到 %v", wantPublishedAt, group.PublishedAt) + } +} + +// 组数超过 K 时只保留**最近发布**的 K 组:组数组尾部(屏上最后一栏)先被丢掉。 +func TestSnapshotLatestResultsCapsGroupCount(t *testing.T) { + env := newTestEnv(t) + db := env.db + + team1 := insertTeam(t, db, "T1", "第一单位", "") + base := "2026-09-09T10:00:00Z" + const groupCount = latestResultsGroupLimit + 1 + + events := make([]int64, groupCount) + for i := 0; i < groupCount; i++ { + // 每组 1 行,发布时间依次变新:下标越大越新。 + ts := shiftTS(t, base, i*60) + events[i] = insertRankedGroup(t, db, fmt.Sprintf("E%02d", i+1), + fmt.Sprintf("第 %d 个小项", i+1), team1, 1, + func(int) string { return ts }) + } + + snap, err := env.hub.Snapshot(context.Background()) + if err != nil { + t.Fatalf("构建快照: %v", err) + } + if len(snap.LatestResults) != latestResultsGroupLimit { + t.Fatalf("组数应封顶在 K=%d,得到 %d", latestResultsGroupLimit, len(snap.LatestResults)) + } + if snap.LatestResults[0].EventID != events[groupCount-1] { + t.Fatalf("最新发布的组应排在最前,得到小项 %d,期望 %d", snap.LatestResults[0].EventID, events[groupCount-1]) + } + kept := make(map[int64]bool, len(snap.LatestResults)) + for _, group := range snap.LatestResults { + kept[group.EventID] = true + } + if kept[events[0]] { + t.Fatalf("最旧的那一组应被丢掉: %+v", snap.LatestResults) + } + for i := 1; i < groupCount; i++ { + if !kept[events[i]] { + t.Fatalf("最近发布的 %d 组都应保留,缺少小项 %d: %+v", latestResultsGroupLimit, events[i], snap.LatestResults) + } + } +} + +// 一次发布会把范围内的行刷成**同一个** verified_at(httpapi/scoring.go 的 setPublished 用 +// datetime('now')),所以现场最常见的形态是「组内行时间全并列、跨组也可能并列」。 +// 并列时按 r.id 决先后,组的顺序必须与旧实现逐位相同(行按 ts DESC, r.id DESC 取回、 +// 组按首次出现建号 → 时间并列时等于「组内最大 r.id」倒序),否则大屏的栏位会换位置。 +func TestSnapshotLatestResultsTiedPublishTimestamps(t *testing.T) { + env := newTestEnv(t) + db := env.db + + team1 := insertTeam(t, db, "T1", "第一单位", "") + // 三组同一次发布:9:30:00 那一秒。 + const sameTS = "2026-09-09T09:30:00Z" + const rowsPerGroup = 8 + + events := make([]int64, 3) + // 造数顺序 = 发布顺序:先发第 3 栏那一组(建号最早),再发第 1、2 栏。 + insertOrder := []int{2, 0, 1} + for _, col := range insertOrder { + events[col] = insertRankedGroup(t, db, fmt.Sprintf("E%d", col+1), + fmt.Sprintf("大屏第 %d 栏的小项", col+1), team1, rowsPerGroup, + func(int) string { return sameTS }) + } + + snap, err := env.hub.Snapshot(context.Background()) + if err != nil { + t.Fatalf("构建快照: %v", err) + } + if len(snap.LatestResults) != 3 { + t.Fatalf("三组都应下发,得到 %d 组", len(snap.LatestResults)) + } + // 时间并列 → 靠 r.id 决先后:最后发布的那一组(第 2 栏)在最前,最先发布的(第 3 栏)在最后。 + wantOrder := []int{1, 0, 2} + for i, col := range wantOrder { + if snap.LatestResults[i].EventID != events[col] { + t.Fatalf("并列时间的组顺序应与发布顺序相反,第 %d 位应是第 %d 栏的小项 %d,得到 %d", + i+1, col+1, events[col], snap.LatestResults[i].EventID) + } + } + byEvent := make(map[int64]dto.EventResultGroup, len(snap.LatestResults)) + for _, group := range snap.LatestResults { + byEvent[group.EventID] = group + } + for col, eventID := range events { + group := byEvent[eventID] + if group.Rows[0].Rank == nil || *group.Rows[0].Rank != 1 { + t.Fatalf("第 %d 栏应从第 1 名开始,实际名次 %v", col+1, ranksOf(group)) + } + if len(group.Rows) != rowsPerGroup { + t.Fatalf("第 %d 栏应有 %d 行,实际名次 %v", col+1, rowsPerGroup, ranksOf(group)) + } + // 并列时间下组的发布时间也是同一条,不能变成「某一行的时间」以外的值。 + if group.PublishedAt == nil || *group.PublishedAt != sameTS { + t.Fatalf("第 %d 栏的发布时间应是 %s,得到 %v", col+1, sameTS, group.PublishedAt) + } + } +} diff --git a/web/src/admin/api/client.ts b/web/src/admin/api/client.ts index f31ecf1..ca27e9b 100644 --- a/web/src/admin/api/client.ts +++ b/web/src/admin/api/client.ts @@ -328,6 +328,15 @@ export const adminApi = { () => [], ), + /** + * 给某台大屏设 / 清别名(`PUT /api/display/instances/{id}/alias`)。 + * + * 权限与大屏切页同档(录入员及以上),所以用 callWrite:写操作一律不降级——示例数据模式下 + * 返回「改好了」会让操作员以为名字存住了,而它只活在内存里。空串 = 清除别名、回到自动标签。 + */ + displayAlias: (id: string, alias: string): Promise => + callWrite(() => client.display.setAlias(id, alias)), + /** * 「实时赛况」当前项目(服务端按设置与时序判定)。 * diff --git a/web/src/admin/components/ConfirmDialog.vue b/web/src/admin/components/ConfirmDialog.vue index 8a33708..8304964 100644 --- a/web/src/admin/components/ConfirmDialog.vue +++ b/web/src/admin/components/ConfirmDialog.vue @@ -119,6 +119,12 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
  • {{ line }}
+ + +
+
+
+
手机扫码直接打开(再点一次「二维码」或点空白处收起)
+
+
- {{ instance.id }} + + {{ instanceDisplayName(instance) }} + + + {{ instanceIdNote(instance) }} + {{ instance.online ? '在线' : '离线' }} @@ -1088,6 +1293,50 @@ async function send(
最后心跳:{{ formatDateTime(instance.lastHeartbeat) }}
已离线:指令发不到它,等大屏重新连上再切页。
+ + +
+
+ +
+ + + +
+

+ 清除后回到浏览器报上来的设备标签;名字跟着这台设备,刷新与重连都不会丢。 +