diff --git a/.gitignore b/.gitignore index ade697b..8afb1f6 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,8 @@ rules/* # 内部署名(构建期注入,绝不进仓库) deploy/signature.local.txt deploy/signature.build.txt +# 版本注入脚本(构建时由 deploy/prepare-extra.mjs 从仓库根 VERSION 生成) +deploy/version.gen.bat # 安装器自定义脚本:许可页署名 + 上次安装目录记忆(构建时生成) console/resources/installer-custom.nsh diff --git a/README.md b/README.md index 0af9375..c37cb36 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,8 @@ - **变更历史**:仿 Git 图谱,一眼看清成绩是怎么变成现在这样的 - **现场公开**:积分明细可下钻,争议时一键推送到大屏 - **全屏公告**:推送后先全屏显示(5–120 秒),随即常驻大屏底部公告栏,直到替换或清除 +- **分批发布**:一个项目分多场跑(如跑道 8 条、30 人分 4 场)时,**每跑完一批就能先发布一批**——空成绩只是提示、**不再挡发布**,未录的行自动跳过,补录后再发一次即自动重算;名次与积分先按已发布的那部分算,大屏立刻有内容 +- **实时赛况大屏页**:新增「实时赛况」页,显示当前进行中项目的实时排名(名次 / 运动员·队 / 单位 / 成绩);1 个项目单栏、2 个自动左右分栏;当前项目按「人工指定 → 排期命中(开始时间前后 30 分钟)→ 最近发布过成绩的项目」判定,**下一场开始前持续显示上一场**;可在「大屏控制 → 实时赛况」卡片配置 - **小项类型齐全**:个人 / 团体 / 对抗比赛 / **个人+团体**(同一个项目同时按个人与团体通道记分) - **多显示器**:支持多屏各锁一页(kiosk 全屏),也能在管理端为指定显示器单独开窗或收窗;「大屏控制」页的「在线大屏」列表实时显示每个在线实例(含局域网浏览器打开的 `/display`)当前在哪一页,可逐屏 / 逐设备单独切页 - **规则可配置**:积分规则以规则包(YAML)描述,可导入导出、跨赛事复用 diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..0b26a7a --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +dev_0.1.1 diff --git a/console/README.md b/console/README.md index 3c2257d..31c59ba 100644 --- a/console/README.md +++ b/console/README.md @@ -182,7 +182,7 @@ pnpm dist # electron-builder --win --x64 → ../dist/console/ pnpm pack # 只出目录,不生成安装包,便于现场调试 ``` -- 产物:NSIS 安装包 + zip(`Scoreboard Console-0.1.0-x64.*`)。 +- 产物:NSIS 安装包 + zip(`Scoreboard Console-0.1.1-x64.*`)。 - `extraFiles` 会把 `../dist/scoreboard.exe`、`../config.yaml`、`config.json`、`../rules` 拷到安装目录 —— 这四样里的后三样(`config.json`、`config.yaml`、`rules/`)在打包态只当**模板**:首次启动时复制到用户数据目录,之后 Console 与 `backup.bat` 都只用用户目录那份(见「配置与规则目录」一节)。`config.json` 缺失时 Console 会回落到内置默认值。 - 部署包根目录的说明书是可选的:`deploy/prepare-extra.mjs` 在打包前按一张清单暂存到 `console/extra/`(`extraFiles` 会把该目录内容平铺到安装目录根):`docs/使用手册.md` → `使用说明.txt`,`docs/报名名单导入规范.md` → `报名名单导入规范.txt`(各自优先取 `docs/` 下的同名 `.txt`)。缺哪个就在构建日志里注明并跳过。 - 图标在 `resources/icon.ico`(`package.json` 的 `win.icon` 指向它)。 diff --git a/console/package.json b/console/package.json index b251d14..17e22a8 100644 --- a/console/package.json +++ b/console/package.json @@ -1,7 +1,7 @@ { "name": "scoreboard-console", "productName": "Scoreboard Console", - "version": "0.1.0", + "version": "0.1.1", "private": true, "author": "AskaEth", "description": "赛事积分榜 · 桌面宿主:守护 Go 服务端并把大屏投到扩展屏", diff --git a/deploy/build.bat b/deploy/build.bat index 45cab03..74378c0 100644 --- a/deploy/build.bat +++ b/deploy/build.bat @@ -27,6 +27,11 @@ call pnpm build || exit /b 1 cd .. echo === [3/4] server build === +REM version: repo-root VERSION is the single source of truth. The Node script +REM writes deploy\version.gen.bat (pure ASCII, for cmd) and syncs the installer +REM version in console\package.json. Must run before the go build below. +node deploy\prepare-extra.mjs || exit /b 1 +call deploy\version.gen.bat || exit /b 1 cd server if not exist ..\dist mkdir ..\dist REM internal signature is injected from a machine-local file (gitignored, never in repo). @@ -36,6 +41,9 @@ REM deploy\signature.build.txt, and for /f reads that file directly (no re-encod node ..\deploy\prepare-signature.mjs || exit /b 1 set "INTERNAL_SIGNATURE=" if exist "..\deploy\signature.build.txt" for /f "usebackq delims= eol=" %%A in ("..\deploy\signature.build.txt") do if not defined INTERNAL_SIGNATURE set "INTERNAL_SIGNATURE=%%A" +REM compile-time version: -X main.version targets package main (no import path), +REM unlike the two vars below which are import-path-qualified. +set "VERSION_LDFLAG=-X 'main.version=%SCOREBOARD_VERSION%'" REM build time for the About page. Do NOT use %DATE%/%TIME%: a localized Windows REM prints non-ASCII weekday names, which cmd mis-parses (see the header note). set "BUILD_TIME=" @@ -43,14 +51,15 @@ for /f "usebackq delims=" %%T in (`powershell -NoProfile -Command "(Get-Date).To set "BUILD_TIME_LDFLAG=-X 'git.yeij.top/AskaEth/Scoreboard/server/internal/version.BuildTime=%BUILD_TIME%'" if defined INTERNAL_SIGNATURE ( REM single quotes keep spaces inside the value intact for go's ldflags parser - go build -ldflags "-X 'git.yeij.top/AskaEth/Scoreboard/server/internal/version.InternalSignature=%INTERNAL_SIGNATURE%' %BUILD_TIME_LDFLAG%" -o ..\dist\scoreboard.exe . || exit /b 1 + go build -ldflags "-X 'git.yeij.top/AskaEth/Scoreboard/server/internal/version.InternalSignature=%INTERNAL_SIGNATURE%' %VERSION_LDFLAG% %BUILD_TIME_LDFLAG%" -o ..\dist\scoreboard.exe . || exit /b 1 node ..\deploy\verify-signature.mjs || exit /b 1 echo internal signature injected ) else ( - go build -ldflags "%BUILD_TIME_LDFLAG%" -o ..\dist\scoreboard.exe . || exit /b 1 + go build -ldflags "%VERSION_LDFLAG% %BUILD_TIME_LDFLAG%" -o ..\dist\scoreboard.exe . || exit /b 1 echo no internal signature ) echo build time %BUILD_TIME% +echo version %SCOREBOARD_VERSION% cd .. echo === [4/4] console package (electron-builder) === @@ -64,7 +73,7 @@ if not exist "rules" ( echo [ERROR] rules dir missing - electron-builder extraFiles needs it exit /b 1 ) -node deploy\prepare-extra.mjs || exit /b 1 +REM prepare-extra.mjs already ran in step 3 (version, user docs, installer script). cd console REM console\pnpm-workspace.yaml uses allowBuilds so electron's install script is REM allowed to run; do NOT add --ignore-scripts here, it makes pnpm run fail its diff --git a/deploy/prepare-extra.mjs b/deploy/prepare-extra.mjs index 1739617..6e73043 100644 --- a/deploy/prepare-extra.mjs +++ b/deploy/prepare-extra.mjs @@ -1,7 +1,9 @@ -// 构建前准备两样东西: +// 构建前准备三样东西: // 1) 把 docs/ 下的使用说明与报名名单导入规范暂存到 console/extra/,供 electron-builder 的 extraFiles 平铺到部署包根目录; // 2) 生成安装器自定义脚本(console/resources/installer-custom.nsh):许可页顶部文案(署名来自构建机本地文件) -// + 记住上次安装目录(重装时沿用,而不是退回系统默认路径)。 +// + 记住上次安装目录(重装时沿用,而不是退回系统默认路径); +// 3) 读仓库根 VERSION 文件:生成 deploy/version.gen.bat(供 build.bat 注入 Go 二进制) +// 并同步 console/package.json 的 version(决定安装包/免安装包的文件名)。 // 用 Node 而不是批处理:中文文件名/中文内容在 .bat 里会被 cmd 解析错乱。 import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' @@ -104,3 +106,48 @@ writeFileSync(nshFile, nsh, 'utf8') // 改名前生成的旧脚本已经没人引用,构建机上顺手清掉,免得看起来还在用。 rmSync(legacyNshFile, { force: true }) console.log(` installer custom script ready (${existsSync(signatureFile) ? 'with' : 'without'} internal signature)`) + +// ---- 3. version ------------------------------------------------------------- +// 版本号只维护仓库根的 VERSION 文件(唯一真相):这里读出来分两路用—— +// deploy/version.gen.bat 给 build.bat,编译期用 -X main.version 写进二进制; +// console/package.json electron-builder 据此给安装包/免安装包命名。 +// 二进制里的版本在编译那一刻定死,运行期不再读任何文件。 +const versionFile = join(root, 'VERSION') +if (!existsSync(versionFile)) { + console.error(' [version] missing VERSION file at repo root') + process.exit(1) +} +const version = (readFileSync(versionFile, 'utf8') + .replace(/^\uFEFF/, '') + .split(/\r?\n/) + .find((line) => line.trim() !== '') ?? '').trim() +if (version === '') { + console.error(' [version] VERSION file is empty') + process.exit(1) +} + +// 必须纯 ASCII:cmd.exe 会错解 .bat 里的非 ASCII 文本(见本文件头部说明)。 +// 用 CRLF:与 .gitattributes 里 *.bat 的约定一致,cmd 在 Windows 上按 CRLF 读最稳。 +// 只 set 不加 setlocal:build.bat 用 call 调它,变量要留在调用方的环境里。 +const genBatFile = join(root, 'deploy', 'version.gen.bat') +const genBat = [ + '@echo off', + 'REM Generated by deploy/prepare-extra.mjs from the VERSION file - do not edit.', + `set "SCOREBOARD_VERSION=${version}"`, + '', +].join('\r\n') +writeFileSync(genBatFile, genBat, 'utf8') +console.log(` version ${version} -> deploy/version.gen.bat`) + +// console/package.json 的 version 是 semver,不带 dev_ 前缀(dev_ 只用于二进制版本号)。 +// 逐字替换而不是 JSON.parse/stringify:保住原有缩进与末尾换行,改动最小。 +const pkgVersion = version.replace(/^dev_/, '') +const pkgFile = join(root, 'console', 'package.json') +const pkgRaw = readFileSync(pkgFile, 'utf8') +const pkgNext = pkgRaw.replace(/^(\s*"version"\s*:\s*)"[^"]*"/m, `$1"${pkgVersion}"`) +if (pkgNext === pkgRaw) { + console.log(` console/package.json version is ${pkgVersion}`) +} else { + writeFileSync(pkgFile, pkgNext, 'utf8') + console.log(` console/package.json version -> ${pkgVersion}`) +} diff --git a/docs/上线检查清单.md b/docs/上线检查清单.md index 6362b52..492d8ff 100644 --- a/docs/上线检查清单.md +++ b/docs/上线检查清单.md @@ -6,11 +6,11 @@ | 文件 | 用途 | | --- | --- | -| `Scoreboard Console-0.1.0-x64.exe` | NSIS 安装包(推荐),双击按向导装 | -| `Scoreboard Console-0.1.0-x64.zip` | 免安装包,解压即用 | +| `Scoreboard Console-0.1.1-x64.exe` | NSIS 安装包(推荐),双击按向导装 | +| `Scoreboard Console-0.1.1-x64.zip` | 免安装包,解压即用 | | `scoreboard.exe` | 服务端本体(已内嵌前端页面) | -> 校验完整性:`certutil -hashfile "Scoreboard Console-0.1.0-x64.exe" SHA256` +> 校验完整性:`certutil -hashfile "Scoreboard Console-0.1.1-x64.exe" SHA256` ## 2. 目标机(服务端主机) @@ -53,6 +53,8 @@ - [ ] **显示器菜单可用**:在服务管理窗口里点「打开服务端管理面板」,进「大屏控制 → 显示器」,能看到每块屏;副屏点「创建全屏窗口」能重新开出来(单屏 / 主显示器只能窗口化,属正常) - [ ] **在线大屏与逐屏切页**:同页右侧「在线大屏」列表能看到本机每块屏的在线实例(局域网设备也已在这一列表里,标记为「局域网浏览器」),每行显示当前所在页;用某一行自己的下拉 +「切到此页」能把那一块屏 / 那台设备单独切走,其余屏不受影响(离线实例的控件为灰色,属正常) - [ ] **公告可用**:推送一条测试公告 → 大屏全屏显示 → 到时退回原页且**底栏公告栏仍常驻显示正文** → 点「清除公告」后底栏回到「暂无公告」 +- [ ] **实时赛况配置正确**:「大屏控制 → 实时赛况」卡片里模式与项目已按今天赛程选好(自动判定 / 人工指定),「当前生效」显示的就是此刻该显示的项目;大屏切到「实时赛况」页能看到该项目**已发布**成绩的实时排名(两个项目同时进行时应自动左右分栏) +- [ ] **分批发布演练**:选一个分多场跑的项目,先只填一批成绩发布 → 大屏立即出现这一批的排名 → 补录下一批再发布 → 排名自动重算(未录的行不会被当成 0 分,也不挡发布) ## 6. 数据与备份 diff --git a/docs/使用手册.md b/docs/使用手册.md index 3336e37..58e2c6f 100644 --- a/docs/使用手册.md +++ b/docs/使用手册.md @@ -88,7 +88,7 @@ - 距离 / 计数 / 得分类:按工具栏提示的单位填数字,如米 `12.34`、次 `12`、分 `96.5`; - 秒位 ≥ 60 仍然拒绝(`1:70` 不是 70 秒),但报错会写明原因(见 4.4)。 -**示例文本不是已填内容**:空格子里显示的 `例 1:06.33`(距离 `例 12.34`、计数 `例 12`、得分 `例 96.5`)是**更淡的斜体灰字**,只是告诉你大概怎么写,**不代表这一格已经有成绩**。灰字不会被提交;点发布时如果那一格还是灰字,就按「成绩为空」拦下。过去示例文本长得和真实输入一样,容易看成「我已经填了」。 +**示例文本不是已填内容**:空格子里显示的 `例 1:06.33`(距离 `例 12.34`、计数 `例 12`、得分 `例 96.5`)是**更淡的斜体灰字**,只是告诉你大概怎么写,**不代表这一格已经有成绩**。灰字不会被提交;点发布时如果那一格还是灰字,就按「还没录」自动跳过(本次不发布这一行),不会连累其他已填成绩的发布。过去示例文本长得和真实输入一样,容易看成「我已经填了」。 右侧「实时积分预览」显示:若现在发布,各单位积分的变化、最大分差、受影响单位数。 @@ -110,26 +110,40 @@ ### 4.4 防呆校验 -页面下方「防呆校验」会实时列出问题: +页面下方「防呆校验」会实时列出问题,分两档: -- **错误(阻止发布)**:成绩格有三种原因,文案都会**把那一格的实际内容原样打出来**,并对症给出改法,不会只说一句「缺少成绩」让你去改本来就对的格式: - - 空格子:`第 3 行 张三:成绩为空(当前「(空)」),请填写成绩`; +- **错误(阻止发布)**:只有**会让名次或积分算错**的才挡发布,文案都会**把那一格的实际内容原样打出来**并对症给改法: - 秒位 ≥ 60:`第 3 行 张三:秒位不能 ≥ 60(当前「1:70」),请写成 1:06 或 66 这类`; - 写法无法识别:`第 3 行 张三:成绩写法无法识别(当前「约1分06秒」),可用 66、66.33、1:06、1:06.33、1分06秒`——非时间单位会换成对应的数字提示(不会拿时间格式去教距离项目); - - 另外还有:手动名次项目缺少名次。 -- **提醒(不阻止发布)**:成绩 ≤ 0、名次重复(请确认是否并列)、成绩与赛会纪录差距过大。 + - 手动名次项目缺少名次。 +- **提醒(不阻止发布)**: + - **成绩还没录**(空格子):`第 3 行 张三:还没录成绩(当前「(空)」),本次发布不计入这一行`。这是中性状态,**不挡发布**——跑完一批就能先发一批,剩下没跑的补录后再发(见 4.6); + - 成绩 ≤ 0、名次重复(请确认是否并列)、成绩与赛会纪录差距过大。 -有错误时点「发布」会被拦下并提示第一处问题。 +有**错误**时点「发布」会被拦下并提示第一处问题;只有提醒(含还没录的行)时发布会照常进行,未录的行自动跳过。 ### 4.5 存草稿与发布 1. **存草稿**:保存当前组别的成绩,草稿**不会上屏**,也不计入积分榜;有未保存修改时页面会显示「有未保存修改」徽标。 - **未保存的成绩不会被静默丢掉**:有未保存修改时,点左侧菜单切到别的页面(以及浏览器前进 / 后退)会先弹出确认框,确认丢弃才离开;关闭或刷新浏览器时浏览器也会原生提示;切换项目本来就会先确认。选「取消」留在本页,改动都还在。 -2. **发布**:点击后弹出确认框,显示本次影响的单位数与最大分差;确认后成绩立即上屏并重算积分榜。 +2. **发布**:点击后弹出确认框,说明本次将发布多少人、还有多少人没成绩,以及影响的单位数与最大分差;确认后这批成绩立即上屏并重算积分榜。 - 发布前系统会自动把当前内容整组保存为草稿再发布,无需手动先存; - 示例数据模式下「发布」按钮不可用,避免把未落库的改动当成发布成功。 -### 4.6 撤销发布 +### 4.6 分批发布(跑一批发一批) + +跑道只有 8 条、一个项目可能要分好几场跑时,**不需要等所有人都跑完**:每跑完一批就发一批,没跑到的先空着。 + +1. 这一批跑完,把已出的成绩填上(其他还没跑的行保持空白即可); +2. 点「发布」(该组已有发布过的成绩、且还有没发的已填行时,按钮显示为「**发布本批**」); +3. 确认框会写明:`本组还有 N 人没有成绩,本次将发布已填的 M 人;其余保持未发布,补录后再发布即可。本次影响 … 个单位,最大分差变化 … 分。发布后立即上屏并重算积分榜。`——**看完再确认**,系统只发布已填的那 M 人,不会把没成绩的人当成 0 分发出去; +4. 确认后这批成绩立即上屏,**名次与积分先按已发布的这部分算**,大屏立刻有内容; +5. 下一批跑完,回到本页补录成绩,再点一次「发布本批」——系统会把两批放在一起重算名次与积分,先发的部分自动跟着更新; +6. 全部发完后按钮不再显示「本批」(并变为不可用),此时该组视为已全部发布,要改就得走「撤销发布 → 修改 → 重新发布」(见 4.7)。 + +> 只有「会被算错」的写法才会挡住发布(见 4.4);空格子只是「还没录」,属于提醒,不影响发布。审核页(第 5 节)也是同一套口径:只有写错或缺名次才挡「发布整组」,空成绩只是本次跳过。 + +### 4.7 撤销发布 已发布的组别会出现「撤销发布」按钮: @@ -148,10 +162,10 @@ 3. 用「整组对比 / 逐条对比」切换,查看发布后各单位积分的变化; 4. 「发布影响」列出受影响单位与分值变化; 5. 操作: - - **发布整组**:确认后整组发布并重算积分榜; + - **发布整组**:确认框会写明本组还有多少条没有成绩、本次将发布已填的多少条;确认后**只发布已填的那部分**并重算积分榜,没录的保持未发布,补录后再发一次即可(分批发布,见 4.6); - **退回**:当前版本服务端没有「退回」接口,此操作**仅在管理端本地标记**,不会改动成绩数据;刷新页面后标记可能消失。如需真正退回,请通知录入员到「成绩录入」修改后重新保存。 -> 草稿态永远不会上屏;发布以「小项 + 组别」为一组整体生效。 +> 草稿态永远不会上屏;发布以「小项 + 组别」为一组整体生效,但**没录成绩的条目会被跳过**——它既不挡发布,也不会被当成 0 分(防呆口径与录入页一致,见 4.4)。 --- @@ -240,7 +254,7 @@ | 区块 | 操作 | | --- | --- | -| 切页 | 选择「总积分榜 / 团体奖牌榜 / 个人积分奖牌榜 / 分项成绩 / 公告」,点「立即跳转」(**所有大屏一起切**);只想切某一块屏或某台设备,用右侧「在线大屏」卡片里那一行的下拉与「切到此页」 | +| 切页 | 选择「总积分榜 / 团体奖牌榜 / 个人积分奖牌榜 / 分项成绩 / 实时赛况 / 公告」,点「立即跳转」(**所有大屏一起切**);只想切某一块屏或某台设备,用右侧「在线大屏」卡片里那一行的下拉与「切到此页」 | | 锁定与刷新 | **锁定**:停止自动轮播并停在当前页;**解锁**:恢复轮播;**强制刷新**:让所有大屏重新拉取快照,用于排障 | | 公告 | 输入公告文字,设置「滚动时长」(5–120 秒,实际是全屏展示的秒数),点「推送公告」;点「清除公告」撤下当前公告 | @@ -248,6 +262,14 @@ **公告**:点「推送公告」后所有大屏先**全屏**展示这条公告,到时间后自动退回原来的页面(没锁定就继续轮播),公告正文留在底栏的「公告」槽位继续显示,直到被新公告替换或点「清除公告」。公告卡片顶部徽标显示「当前公告:有 / 无」,徽标下面那一行就是当前公告正文(没有公告时显示「暂无公告(大屏底栏显示占位)」)。公告会随赛事设置保存:新开的大屏窗口、断线重连之后,会先把当前公告再全屏播一遍(默认 15 秒)后留在底栏;点「清除公告」后底栏回到「暂无公告」占位。 +**实时赛况**:「实时赛况」卡片决定大屏「实时赛况」页显示哪几个项目(**最多 2 个**)。 + +- **自动判定 / 人工指定**:卡片右上角徽标显示当前模式。选「自动判定」时按三条依据依次判定——**人工指定 → 排期命中(项目开始时间前后 30 分钟内)→ 最近发布过成绩的项目**;选「人工指定」时只显示下面指定的项目,不做任何回退; +- **项目 1 / 项目 2** 两个下拉:从当前赛事的小项里选(选项里有「未指定」),已选的行可以「清除」;同一个项目不会重复占两个槽位; +- **当前生效**:显示此刻大屏实际会显示的项目与入选原因,格式如 `男子 100 米(排期命中)`;没有可显示的项目时显示「当前没有可显示的项目」。旁边的「刷新」只重新读取,不改配置; +- **保存**:改完模式或项目后点「保存」,提示「实时赛况设置已保存」后大屏按新配置显示;卡片底部注明「当前项目最多 2 个」; +- 该卡片的「保存」需要**管理员**身份(实时赛况属于赛事设置,只有管理员能改);示例数据模式下不能保存。 + **在线大屏**:右侧「在线大屏」卡片列出的是**真实**的在线实例,直接读服务端 `GET /api/display/instances`,打开页面时每 5 秒自动刷新一次(浏览器切到后台时暂停,切回来立刻补一次)。每个实例一行,显示实例 ID、在线 / 离线徽标、「局域网浏览器」或「屏 N」标记、当前所在页、来源地址与最后心跳;服务端判定某个实例落后时还会多一个「落后」徽标(大屏可能在卡或网络差)。**本机用 Console 打开的大屏窗口、以及局域网设备打开 `/display` 连上的浏览器,都会出现在这里**——局域网浏览器没有屏号,那一栏显示「局域网浏览器」。离线实例会在服务端保留一段时间,方便排查它是什么时候掉的。 **逐屏 / 逐设备切页**:在线大屏列表里每一行都在「当前页」旁带一个页面下拉和「切到此页」按钮,只把**这一块屏 / 这一台设备**切过去,其他屏不受影响;上方「切页」卡片里的「立即跳转」则是**所有大屏一起切**。离线实例的下拉与按钮是灰的,发不到它;点名的大屏一台都没收到指令时会明确提示「目标大屏已离线,请刷新在线列表」,不会谎报成功。列表读取失败时页面会提示「读取在线大屏失败」,并保留上一次的结果,不会把列表清空。 @@ -633,7 +655,7 @@ ### 17.1 页面 -大屏支持五个页面: +大屏支持六个页面: | 页面 | 内容 | | --- | --- | @@ -641,8 +663,17 @@ | 团体奖牌榜 | 名次、单位(带单位 logo)、金 / 银 / 铜、合计 | | 个人积分奖牌榜 | 名次、姓名(姓名前显示所属单位 logo)、单位、总分、奖牌 | | 分项成绩 | 最新已完成项目的成绩,单位列名字前显示单位 logo | +| 实时赛况 | 当前正在进行项目的实时排名(见下方说明) | | 公告 | 管理端推送的公告:推送后先全屏展示,之后常驻底栏的「公告」槽位 | +**实时赛况**:专门给「现在正在比的项目」用,每一行显示**名次、运动员 / 队、单位(带单位 logo)、成绩**,按名次升序。 + +- **显示哪个项目**由管理端判定(见第 9 节的「实时赛况」卡片):优先按人工指定的项目,其次按项目排期(开始时间前后 30 分钟内),都没有时显示**最近发布过成绩的项目**——所以**在下一场开始之前,这一页会一直保持显示上一场的成绩**; +- **1 个项目单栏铺开,2 个项目自动左右分栏**(最多 2 栏,对应「两个项目时间撞在一起」的现场); +- 只显示**已发布**的成绩:配合分批发布,发一批这一页就更新一批(见 4.6); +- 同一个小项分了多个组别时,栏里显示**最近发布的那一组**,标题旁用「另有 N 组」标注;该项目还没有已发布成绩时栏内显示「该项目暂无已发布的成绩」; +- 每栏最多列前 10 个名次;一个项目都没有时整页显示「暂无进行中的项目」(刚切到本页、结果还没回来时显示「正在获取当前项目…」)。 + > 单位 logo 取自「基础数据 → 参赛单位」里上传的图片(见 10.1),按行内文字大小显示,不会撑开行高;没传 logo 的单位不占位,图片临时加载不出来时保留占位、刷新后恢复,队名不会跳动。 > 大屏的标题字体与正文字体取自「系统设置 → 赛事信息 → 字体」(见 14.3):可选四款内置字体,也可以上传自己的字体;上传字体加载失败时自动回落到默认字体,画面不会出现方块或空白。 @@ -653,7 +684,7 @@ - Console 启动的多屏大屏窗口默认**每屏锁定一页**,不轮播; - 在管理端「大屏控制」点「解锁」后开始整页轮播,**每 20 秒切换一页**; -- 公告页是手动触发的页面,不参与自动轮播;个人积分榜可在「赛事信息」里关闭上屏; +- 公告页是手动触发的页面,不参与自动轮播;个人积分榜可在「赛事信息」里关闭上屏;「实时赛况」页参与轮播(见 17.1); - 底部滚条固定有「公告」槽位:管理端推送过公告就一直显示最近那条,没有公告时显示「暂无公告」(见第 9 节); - 点「锁定」可停在当前页。 diff --git a/docs/常见问题.md b/docs/常见问题.md index 6b4480f..1506002 100644 --- a/docs/常见问题.md +++ b/docs/常见问题.md @@ -37,7 +37,7 @@ 撤销发布 → 修改 → 重新发布。撤销会作废该组积分流水,大屏回退到发布前状态,过程可在「变更历史」查看。 **Q9:为什么「发布」是灰的?** -常见原因:未登录、服务端断开、示例数据模式,或存在阻止发布的校验错误(成绩为空、秒位 ≥ 60、成绩写法无法识别、手动名次项目缺名次)。成绩格的报错现在会把**那一格的实际内容**打出来并对症给改法,照着提示改即可(见 Q75、Q76)。 +常见原因:未登录、服务端断开、示例数据模式,或存在**会阻止发布**的校验错误(秒位 ≥ 60、成绩写法无法识别、手动名次项目缺名次)。**没录成绩不算错误、不挡发布**(见 Q77)。成绩格的报错会把**那一格的实际内容**打出来并对症给改法(见 Q75、Q76)。 **Q10:并列名次怎么处理?** 成绩相同(或手动名次相同)自动按并列处理,显示同一名次。默认口径是「顺延」:并列第 3 各得第 3 名的分,无第 4 名,下一名第 5 名;奖牌同样并列(并列第 1 各记 1 金、无银)。具体口径由规则包配置决定。 @@ -428,10 +428,10 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点 别和 Console 自己的 `logs\console.log`(在安装目录里)混淆:那个装的是 Console 与服务端的控制台输出;服务端日志文件在数据目录。 -**Q75:为什么我明明填了成绩,发布却说缺少成绩?** +**Q75:为什么我明明填了成绩,那一行还是被当成「还没录」?** 最可能是把格子里的**灰字示例文本**当成了已填内容。过去成绩格的示例是 `1:06.33`,没有样式区分,看起来就像已经填好;现在示例统一带「例」字(时间 `例 1:06.33`、距离 `例 12.34`、计数 `例 12`、得分 `例 96.5`,备注格是 `可留空`),并且是**更淡的斜体灰字**,和真实输入明显不一样。灰字不会被提交,那一格仍然是空的。 -新版报错也会自证:会写 `第 3 行 张三:成绩为空(当前「(空)」),请填写成绩`,把那一格的实际内容原样打出来。填了但写法不认的是另外两条:`秒位不能 ≥ 60(当前「1:70」)…` 或 `成绩写法无法识别(当前「约1分06秒」)…`,各自带内容与可用写法(见 Q76)。 +那空着会不会发不出去?**不会**。空格子在防呆校验里只是**提醒**,文案是 `第 3 行 张三:还没录成绩(当前「(空)」),本次发布不计入这一行`,**不挡发布**;点发布会只发已填的行,其余保持未发布,补录后再发一次即可(见 Q77)。真正挡发布的只有写错:`秒位不能 ≥ 60(当前「1:70」)…` 或 `成绩写法无法识别(当前「约1分06秒」)…`(见 Q76)。 **Q76:成绩能怎么填?** 按项目单位填(工具栏会显示当前单位:秒 / 米 / 次 / 分): @@ -442,3 +442,33 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点 - 成绩格已经加宽,四位数也能看全。 有未保存的成绩时,切到别的菜单页面会先弹确认框,不会静默丢弃。 + +**Q77:100 米要分好几场跑,必须等所有人跑完才能发布吗?** +不用。跑道只有 8 条、一个项目分好几场时,**每跑完一批就能先发一批**,没跑到的先空着: + +1. 这一批跑完,把已出的成绩填上,其他还没跑的行保持空白; +2. 点「发布」(该组已有发布过的成绩、还有没发的已填行时,按钮显示为「**发布本批**」); +3. 确认框会写明 `本组还有 N 人没有成绩,本次将发布已填的 M 人;其余保持未发布,补录后再发布即可`——确认后系统只发布已填的 M 人,**不会把没成绩的人当成 0 分发出去**; +4. 这批成绩立即上屏,名次与积分先按已发布的这部分算; +5. 下一批跑完补录后,再点一次「发布本批」,系统会把两批放在一起**自动重算**名次与积分。 + +防呆校验里的「还没录成绩」只是提醒,**不挡发布**;只有写错(秒位 ≥ 60、写法无法识别)或手动名次项目缺名次才挡(见 Q75、Q76)。审核页的「发布整组」也是同一套口径。 + +**Q78:为什么有的成绩还没录就发布成功了?会不会发漏?** +不会发漏,也不会算错:分批发布**只发布已经填了成绩的那部分**,没录的行本次**跳过**、保持未发布状态,确认框和发布后的提示都会写明「本次发布 M 人;其余 N 人保持未发布,补录后再次发布即可」。 + +要留意的是「跳过」不等于「算 0 分」——没成绩的人这一批根本不参与排名,不会占名次也不会拉低别人。等下一批补录后再发布一次,系统会把两批合起来重算;先前发布的那部分名次与积分会跟着一起更新。所以只要最后把所有人补录并发布完整,结果与一次性发布是一致的。 + +**Q79:大屏上的「实时赛况」显示的是哪个项目?** +按三条依据**依次**判定,先满足的优先: + +1. **人工指定**:管理端「大屏控制 → 实时赛况」卡片里指定的项目(最多 2 个); +2. **排期命中**:小项的排期时间落在当前时间**前后 30 分钟内**; +3. **最近发布过成绩的项目**:以上都没有时,显示最近一次发布过成绩的项目——所以**下一场开始之前,这一页会一直保持显示上一场**。 + +当前实际显示哪个项目、为什么选中,可以在卡片里的「当前生效」一行看到,格式如 `男子 100 米(排期命中)`;没有可显示的项目时显示「当前没有可显示的项目」。选「人工指定」模式时只认第 1 条,不做任何回退。该页只显示**已发布**的成绩,所以配合分批发布,发一批它就更新一批。 + +**Q80:两个项目同时进行,实时赛况页怎么显示?** +会**自动左右分栏**,每个项目一栏,最多 2 栏(一个项目时单栏铺开,不会分栏)。每栏显示该项目的名次、运动员 / 队、单位(带单位 logo)与成绩,按名次升序、最多列前 10 个名次;同一个小项分了多个组别时,栏里显示最近发布的那一组,标题旁用「另有 N 组」标注。 + +场地上两个项目时间撞在一起、自动判定没选中你想看的组合时,让管理员到「大屏控制 → 实时赛况」把模式切到「人工指定」,把这两个项目分别选进「项目 1」「项目 2」并保存即可(该设置只有管理员能保存)。 diff --git a/docs/部署指南.md b/docs/部署指南.md index abfe986..0bfb47e 100644 --- a/docs/部署指南.md +++ b/docs/部署指南.md @@ -32,11 +32,11 @@ deploy\build.bat | 产物 | 类型 | 说明 | | --- | --- | --- | | `dist\scoreboard.exe` | 服务端 | 前端已通过内嵌方式打进可执行文件,单独运行也能提供全部页面 | -| `dist\console\Scoreboard Console-0.1.0-x64.exe` | 安装包 | NSIS 安装程序,双击按向导安装 | -| `dist\console\Scoreboard Console-0.1.0-x64.zip` | 免安装包 | 解压即用 | +| `dist\console\Scoreboard Console-0.1.1-x64.exe` | 安装包 | NSIS 安装程序,双击按向导安装 | +| `dist\console\Scoreboard Console-0.1.1-x64.zip` | 免安装包 | 解压即用 | | `dist\console\win-unpacked\` | 目录 | 未压缩的运行目录,便于现场调试 | -> 文件名中的 `0.1.0` 来自 `console\package.json` 的版本号,会随版本升级变化;以 `dist\console\` 下的实际文件名为准。 +> 版本号来自仓库根的 `VERSION` 文件:构建时由 `deploy\build.bat` 注入服务端二进制(`-ldflags -X main.version`,运行期不再读任何文件),并同步到 `console\package.json` 决定安装包/免安装包的文件名。升级版本只改 `VERSION` 一处;以 `dist\console\` 下的实际文件名为准。 > > 目标机器**不需要** Go / Node / pnpm,也不联网;上述工具只用于构建机。 > @@ -48,7 +48,7 @@ deploy\build.bat ### 3.1 使用安装包 -1. 双击 `Scoreboard Console-0.1.0-x64.exe`。 +1. 双击 `Scoreboard Console-0.1.1-x64.exe`。 2. 按向导选择安装目录(可自定义)。 3. 安装完成后,桌面会出现名为**「赛事积分榜」**的快捷方式。 4. 双击「赛事积分榜」启动。 @@ -57,7 +57,7 @@ deploy\build.bat ### 3.2 使用免安装包 -1. 把 `Scoreboard Console-0.1.0-x64.zip` 解压到任意目录(建议使用纯英文路径)。 +1. 把 `Scoreboard Console-0.1.1-x64.zip` 解压到任意目录(建议使用纯英文路径)。 2. 双击目录里的 `Scoreboard_Console.exe` 启动。 ### 3.3 首次启动会自动完成 @@ -295,7 +295,7 @@ scoreboard.exe passwd -config "%LOCALAPPDATA%\ScoreboardConsole\config.yaml" -da | `server.healthyResetMs` | `60000` | 连续健康多久后清零崩溃计数 | | `display.selection` | `all-secondary` | 选屏策略,见第 7 节 | | `display.displays` | `[]` | 仅 `specified` 策略生效,填显示器 id 或 0 起序号 | -| `display.pages` | `["standings","medals","individual","results"]` | 多屏时按屏序轮转锁定的页面 | +| `display.pages` | `["standings","medals","individual","results"]` | 多屏时按屏序轮转锁定的页面;可加入 `live`(实时赛况)等页面值,见 7.4 | | `display.pageParam` | `page` | 页面 URL 参数名 | | `display.kiosk` | `true` | 多屏时是否 kiosk 全屏(单屏恒为窗口化) | | `display.ignoreMouse` | `true` | 多屏窗口是否忽略鼠标 | @@ -351,7 +351,7 @@ scoreboard.exe passwd -config "%LOCALAPPDATA%\ScoreboardConsole\config.yaml" -da - 除内置屏外的每块屏各开一个 kiosk 全屏窗口; - 每块屏**锁定一个页面**,按 `display.pages` 的顺序轮转分配; - 多屏 kiosk 不响应 F11,也不接收鼠标(`ignoreMouse`),也不显示任何调试标识; -- 可用的页面值:`standings`(总积分榜)、`medals`(团体奖牌榜)、`individual`(个人积分奖牌榜)、`results`(分项成绩)、`announcement`(公告);管理端切页用的是同一批页面的规范名 `team-medals` / `athlete-standings` / `event-results`,两种写法大屏都认。 +- 可用的页面值:`standings`(总积分榜)、`medals`(团体奖牌榜)、`individual`(个人积分奖牌榜)、`results`(分项成绩)、`live`(实时赛况)、`announcement`(公告);管理端切页用的是同一批页面的规范名 `team-medals` / `athlete-standings` / `event-results`,两种写法大屏都认。把 `live` 加进 `display.pages`,就能把某块屏固定成「实时赛况」(`live` 也认 `live-results` / `实时赛况` / `实时` / `赛况` 这些别名,与其它页一样)。 ### 7.5 在管理端单独控制某块屏 diff --git a/server/internal/dto/live.go b/server/internal/dto/live.go new file mode 100644 index 0000000..d8ad06c --- /dev/null +++ b/server/internal/dto/live.go @@ -0,0 +1,25 @@ +package dto + +// LiveEvents 是 GET /api/display/live 的线上结构:大屏「实时赛况」页的当前项目及其成绩。 +// +// 为什么单开一个接口而不是塞进 snapshot:当前项目的判定依赖「当前时间 + 排期 + 最近一次发布」, +// 是随时间变化的量;快照只在变更时推送,装不下一个会自己过期的结论。 +// 两个设置键(display.live_events / display.live_source)仍然随 snapshot.settings 下发, +// 大屏也可以只用 events / latestResults 自行推导。 +type LiveEvents struct { + // Source 是本次生效的判定方式:manual 或 auto(来自 display.live_source)。 + Source string `json:"source"` + // EventIDs 是判定出的当前项目 id,最多 MaxLiveEvents 个,按重要程度排序;空表示没有。 + EventIDs []int64 `json:"eventIds"` + // Events 与 EventIDs 一一对应,带上小项信息与已发布成绩(名次已由发布时回写)。 + Events []LiveEvent `json:"events"` +} + +// LiveEvent 是一个当前项目。 +type LiveEvent struct { + Event Event `json:"event"` + // Reason 说明它为什么被选中:manual(人工指定)/ scheduled(排期命中)/ recent(最近发布)。 + Reason string `json:"reason"` + // Groups 是该小项已发布成绩按「小项 + 组别」分好的组;还没发布过任何成绩时为空数组。 + Groups []EventResultGroup `json:"groups"` +} diff --git a/server/internal/dto/settings.go b/server/internal/dto/settings.go index 74cae93..b7ebc0d 100644 --- a/server/internal/dto/settings.go +++ b/server/internal/dto/settings.go @@ -42,6 +42,72 @@ type EventSettingsDisplay struct { // null 表示用内置默认字体,取值合法性由 httpapi 在写入时校验。 FontTitle *string `json:"fontTitle"` FontBody *string `json:"fontBody"` + // LiveEvents 是「实时赛况」页人工指定的当前项目 id,最多 MaxLiveEvents 个。 + // 库里以逗号分隔的 id 存 display.live_events;空数组表示没有人工指定。 + LiveEvents []int64 `json:"liveEvents"` + // LiveSource 决定「当前项目」怎么判定:manual 只认 LiveEvents;auto 走 + // 「人工指定 → 排期命中 → 最近发布过成绩的项目」的回退链。默认 auto。 + LiveSource string `json:"liveSource"` +} + +// 「大屏实时赛况」的两个设置键与取值约束。 +const ( + // SettingKeyLiveEvents / SettingKeyLiveSource 是 settings 表里的点号键。 + SettingKeyLiveEvents = "display.live_events" + SettingKeyLiveSource = "display.live_source" + // MaxLiveEvents 是人工指定「当前项目」的上限:现场同时进行的比赛不超过两场。 + MaxLiveEvents = 2 + // LiveSourceAuto 按排期与最近发布自动判定;LiveSourceManual 只认人工指定。 + LiveSourceAuto = "auto" + LiveSourceManual = "manual" +) + +// ParseLiveEventIDs 解析 display.live_events 的逗号分隔 id 列表。 +// +// 非法项(非数字 / 0 / 负数)直接丢弃:这个键可能被手工改过, +// 让大屏因为一个坏 id 整块不显示,比忽略它更糟。 +func ParseLiveEventIDs(raw string) []int64 { + out := make([]int64, 0, MaxLiveEvents) + seen := make(map[int64]bool) + for _, part := range strings.Split(raw, ",") { + id, err := strconv.ParseInt(strings.TrimSpace(part), 10, 64) + if err != nil || id <= 0 || seen[id] { + continue + } + seen[id] = true + out = append(out, id) + if len(out) == MaxLiveEvents { + break + } + } + return out +} + +// FormatLiveEventIDs 把 id 列表写成 display.live_events 的存储格式(逗号分隔,最多 MaxLiveEvents 个)。 +func FormatLiveEventIDs(ids []int64) string { + parts := make([]string, 0, len(ids)) + for _, id := range ids { + if id <= 0 { + continue + } + parts = append(parts, strconv.FormatInt(id, 10)) + if len(parts) == MaxLiveEvents { + break + } + } + return strings.Join(parts, ",") +} + +// NormalizeLiveSource 归一 live_source;无法识别时返回空串,由调用方决定报错还是落默认。 +func NormalizeLiveSource(raw string) string { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "", LiveSourceAuto: + return LiveSourceAuto + case LiveSourceManual: + return LiveSourceManual + default: + return "" + } } // EventSettingsStandings 是榜单口径配置。 @@ -112,6 +178,9 @@ func ParseEventSettings(values map[string]string) EventSettings { out.Display.FontBody = nilIfEmpty(values["display.font_body"]) // 公告由 POST /api/display/command 落库(recorder 也能推),空串表示无公告。 out.Display.Announce = nilIfEmpty(values["display.announce_text"]) + // 实时赛况的「当前项目」:人工指定 + 判定方式,读取侧(大屏快照与管理端)口径一致。 + out.Display.LiveEvents = ParseLiveEventIDs(values[SettingKeyLiveEvents]) + out.Display.LiveSource = NormalizeLiveSource(values[SettingKeyLiveSource]) out.Standings.IndividualToTeamWeight = atofDefault(values["standings.individual_to_team_weight"], 1) out.Standings.RankingMode = orDefault(values["standings.mode"], "points") diff --git a/server/internal/dto/settings_test.go b/server/internal/dto/settings_test.go index 7da0f08..ba1f098 100644 --- a/server/internal/dto/settings_test.go +++ b/server/internal/dto/settings_test.go @@ -2,6 +2,7 @@ package dto import ( "encoding/json" + "strconv" "testing" "git.yeij.top/AskaEth/Scoreboard/server/internal/domain" @@ -154,6 +155,97 @@ func TestParseEventSettingsFonts(t *testing.T) { } } +// 「实时赛况」的两个设置键:live_events 存逗号分隔 id(最多 2 个),live_source 只认 auto/manual。 +func TestParseLiveEventSettings(t *testing.T) { + got := ParseEventSettings(map[string]string{ + SettingKeyLiveEvents: "12, 15", + SettingKeyLiveSource: "MANUAL", + }) + if len(got.Display.LiveEvents) != 2 || got.Display.LiveEvents[0] != 12 || got.Display.LiveEvents[1] != 15 { + t.Fatalf("应解析出人工指定的两个 id,得到 %+v", got.Display.LiveEvents) + } + if got.Display.LiveSource != LiveSourceManual { + t.Fatalf("大小写应归一成 manual,得到 %q", got.Display.LiveSource) + } + + // 缺键 / 空值:空数组 + 默认 auto。 + empty := ParseEventSettings(map[string]string{}) + if empty.Display.LiveEvents == nil || len(empty.Display.LiveEvents) != 0 { + t.Fatalf("缺键应下发空数组而不是 null,得到 %+v", empty.Display.LiveEvents) + } + if empty.Display.LiveSource != LiveSourceAuto { + t.Fatalf("缺键应默认 auto,得到 %q", empty.Display.LiveSource) + } + raw, err := json.Marshal(empty) + if err != nil { + t.Fatalf("序列化: %v", err) + } + var display map[string]json.RawMessage + if err := json.Unmarshal(mustField(t, raw, "display"), &display); err != nil { + t.Fatalf("解析 display: %v", err) + } + if string(display["liveEvents"]) != "[]" || string(display["liveSource"]) != `"auto"` { + t.Fatalf("缺键时应下发 [] 与 auto: %s", raw) + } +} + +// 解析要能容忍手工改库留下的脏值:坏项丢弃、去重、超过 2 个截断。 +func TestParseLiveEventIDs(t *testing.T) { + cases := []struct { + raw string + want string + }{ + {"", ""}, + {"12", "12"}, + {"12,15", "12,15"}, + {" 3 , 4 , 5 ", "3,4"}, + {"7,7,8", "7,8"}, + {"abc", ""}, + {"0,-1,6", "6"}, + } + for _, tc := range cases { + got := ParseLiveEventIDs(tc.raw) + joined := "" + for i, id := range got { + if i > 0 { + joined += "," + } + joined += strconv.FormatInt(id, 10) + } + if joined != tc.want { + t.Fatalf("ParseLiveEventIDs(%q) = %q,期望 %q", tc.raw, joined, tc.want) + } + } +} + +func TestFormatLiveEventIDs(t *testing.T) { + if got := FormatLiveEventIDs([]int64{9, 11, 13}); got != "9,11" { + t.Fatalf("最多存 2 个,得到 %q", got) + } + if got := FormatLiveEventIDs(nil); got != "" { + t.Fatalf("空列表应存空串,得到 %q", got) + } + if got := FormatLiveEventIDs([]int64{0, -2, 5}); got != "5" { + t.Fatalf("非法 id 应丢弃,得到 %q", got) + } +} + +func TestNormalizeLiveSource(t *testing.T) { + cases := map[string]string{ + "": LiveSourceAuto, + "auto": LiveSourceAuto, + "AUTO": LiveSourceAuto, + "manual": LiveSourceManual, + " Manual": LiveSourceManual, + "x": "", + } + for raw, want := range cases { + if got := NormalizeLiveSource(raw); got != want { + t.Fatalf("NormalizeLiveSource(%q) = %q,期望 %q", raw, got, want) + } + } +} + // mustField 取出顶层字段的原始 JSON,供字段级断言使用。 func mustField(t *testing.T, raw []byte, key string) []byte { t.Helper() diff --git a/server/internal/httpapi/dto.go b/server/internal/httpapi/dto.go index d99c58e..d88d6fb 100644 --- a/server/internal/httpapi/dto.go +++ b/server/internal/httpapi/dto.go @@ -100,21 +100,23 @@ type profileDTO struct { } type entryDTO struct { - ID int64 `json:"id"` - EventID int64 `json:"eventId"` - CategoryID int64 `json:"categoryId"` - TeamID int64 `json:"teamId"` - AthleteID *int64 `json:"athleteId"` - MemberNames []string `json:"memberNames"` - Bib *string `json:"bib"` - Seed *string `json:"seed"` - Status string `json:"status"` - Remark *string `json:"remark"` - AthleteName *string `json:"athleteName,omitempty"` - TeamCode string `json:"teamCode,omitempty"` - TeamName string `json:"teamName,omitempty"` - ShortName *string `json:"shortName,omitempty"` - CategoryName string `json:"categoryName,omitempty"` + ID int64 `json:"id"` + EventID int64 `json:"eventId"` + CategoryID int64 `json:"categoryId"` + TeamID int64 `json:"teamId"` + AthleteID *int64 `json:"athleteId"` + MemberNames []string `json:"memberNames"` + Bib *string `json:"bib"` + Seed *string `json:"seed"` + // Heat 是录入 / 展示用的批次号,null 表示「未分批」;批次不参与算分。 + Heat *int64 `json:"heat"` + Status string `json:"status"` + Remark *string `json:"remark"` + AthleteName *string `json:"athleteName,omitempty"` + TeamCode string `json:"teamCode,omitempty"` + TeamName string `json:"teamName,omitempty"` + ShortName *string `json:"shortName,omitempty"` + CategoryName string `json:"categoryName,omitempty"` } type entryWithResultDTO struct { @@ -122,6 +124,13 @@ type entryWithResultDTO struct { Result *resultDTO `json:"result"` } +// entryHeatResultDTO 是批量设置批次的结果:本批条目当前统一的批次号(null = 未分批)。 +type entryHeatResultDTO struct { + Updated int `json:"updated"` + Heat *int64 `json:"heat"` + EntryIDs []int64 `json:"entryIds"` +} + type resultDTO struct { ID int64 `json:"id"` EventID int64 `json:"eventId"` diff --git a/server/internal/httpapi/entry_heat_scoring_test.go b/server/internal/httpapi/entry_heat_scoring_test.go new file mode 100644 index 0000000..9c2ea1e --- /dev/null +++ b/server/internal/httpapi/entry_heat_scoring_test.go @@ -0,0 +1,186 @@ +package httpapi_test + +import ( + "net/http" + "reflect" + "testing" +) + +// scoringSnapshot 是「发布完全部成绩」后的计分快照:名次 + 每条报名的积分 + 单位总分。 +// +// entries 的 id 由 seed / seedBatch 按固定顺序插入决定,两个库里完全一致, +// 所以可以直接按 entryID 比对两份快照。 +type scoringSnapshot struct { + ranks map[int64]int + entryPoints map[int64]float64 + teamPoints map[int64]float64 +} + +func entryRank(t *testing.T, env *testEnv, id int64) int { + t.Helper() + var rank int + if err := env.ev.QueryRow(`SELECT rank FROM results WHERE entry_id = ?`, id).Scan(&rank); err != nil { + t.Fatalf("读名次: %v", err) + } + return rank +} + +// publishEightWithHeats 造一个 8 人的组别,可选地先分好批次,再全量发布,返回计分快照。 +func publishEightWithHeats(t *testing.T, assignHeats func(env *testEnv, client *http.Client, entries []int64)) scoringSnapshot { + t.Helper() + + env := newTestEnv(t) + ids := env.seed(t) + entries := seedBatch(t, env, ids, 8) + env.createUser("recorder", "recorder-pass", "recorder") + client, _ := env.client() + env.login(client, "recorder", "recorder-pass") + + if assignHeats != nil { + assignHeats(env, client, entries) + } + + times := make([]float64, len(entries)) + for i := range times { + times[i] = 12.0 + float64(i)*0.1 + } + if status, e := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{ + "eventId": ids.event, + "categoryId": ids.category, + "results": batchDraft(entries, len(entries), 0, times), + }, true); status != http.StatusOK { + t.Fatalf("保存草稿应 200,得到 %d %+v", status, e.Error) + } + if status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{ + "eventId": ids.event, + "categoryId": ids.category, + }, true); status != http.StatusOK { + t.Fatalf("发布应 200,得到 %d %+v", status, e.Error) + } + + snap := scoringSnapshot{ + ranks: make(map[int64]int, len(entries)), + entryPoints: make(map[int64]float64), + teamPoints: make(map[int64]float64), + } + for _, id := range entries { + snap.ranks[id] = entryRank(t, env, id) + } + rows, err := env.ev.Query( + `SELECT entry_id, team_id, SUM(points) FROM score_ledger + WHERE event_id = ? AND voided = 0 AND source_type = 'result' + GROUP BY entry_id, team_id`, ids.event) + if err != nil { + t.Fatalf("读积分流水: %v", err) + } + defer func() { _ = rows.Close() }() + for rows.Next() { + var ( + entryID int64 + teamID int64 + points float64 + ) + if err := rows.Scan(&entryID, &teamID, &points); err != nil { + t.Fatalf("扫描积分流水: %v", err) + } + snap.entryPoints[entryID] = points + snap.teamPoints[teamID] += points + } + if err := rows.Err(); err != nil { + t.Fatalf("遍历积分流水: %v", err) + } + return snap +} + +// 批次只做录入 / 展示分组:同一个组别分两批之后,发布出来的名次与积分必须与「没分批」完全一致。 +// 这条断言是防止后人把批次当成分组参与算分的护栏。 +func TestEntryHeatDoesNotAffectScoring(t *testing.T) { + plain := publishEightWithHeats(t, nil) + + batched := publishEightWithHeats(t, func(env *testEnv, client *http.Client, entries []int64) { + status, e := env.do(client, http.MethodPut, "/api/entries/heat", map[string]any{ + "entryIds": entries[:4], + "heat": 1, + }, true) + if status != http.StatusOK { + t.Fatalf("给前 4 人设第 1 批应 200,得到 %d %+v", status, e.Error) + } + status, e = env.do(client, http.MethodPut, "/api/entries/heat", map[string]any{ + "entryIds": entries[4:], + "heat": 2, + }, true) + if status != http.StatusOK { + t.Fatalf("给后 4 人设第 2 批应 200,得到 %d %+v", status, e.Error) + } + // 确认批次真的落库、且确实分成了两批,否则这条用例会假通过。 + assertCount(t, env, `SELECT COUNT(DISTINCT heat) FROM entries WHERE heat IS NOT NULL`, 2) + }) + + if !reflect.DeepEqual(plain, batched) { + t.Fatalf("分了批次的计分快照与未分批不一致:\n未分批 %+v\n分批次 %+v", plain, batched) + } + + // 逐项显式断言,失败时给出可读的差异,而不是只有一个 DeepEqual。 + // seed / seedBatch 依次插入,前 8 条报名的 id 就是 1..8。 + for i, points := range []float64{10, 8, 6, 3, 2, 1} { + id := int64(i + 1) + if got := batched.ranks[id]; got != i+1 { + t.Fatalf("entry %d 名次 = %d,期望 %d", id, got, i+1) + } + if got := batched.entryPoints[id]; got != points { + t.Fatalf("entry %d 积分 = %v,期望 %v", id, got, points) + } + } + // 第 7、8 名没有积分;总分仍是 10+8+6+3+2+1。 + if _, ok := batched.entryPoints[7]; ok { + t.Fatalf("第 7 名不该有积分流水: %+v", batched.entryPoints) + } + var total float64 + for _, points := range batched.teamPoints { + total += points + } + if total != 30 { + t.Fatalf("单位总分合计 = %v,期望 30", total) + } + t.Logf("分两批发布后:名次 %v,每条报名积分 %v,单位总分 %+v", batched.ranks, batched.entryPoints, batched.teamPoints) +} + +// 批次号大小不影响名次:同一批次里的人名次照常由成绩决定,慢的那个仍排后面。 +func TestEntryHeatKeepsRanksByResult(t *testing.T) { + env := newTestEnv(t) + ids := env.seed(t) + entries := seedBatch(t, env, ids, 4) + env.createUser("recorder", "recorder-pass", "recorder") + client, _ := env.client() + env.login(client, "recorder", "recorder-pass") + + if status, e := env.do(client, http.MethodPut, "/api/entries/heat", map[string]any{ + "entryIds": []int64{entries[0], entries[1]}, + "heat": 7, + }, true); status != http.StatusOK { + t.Fatalf("设置批次应 200,得到 %d %+v", status, e.Error) + } + + // 第 1、2 人同属第 7 批,但成绩比第 3、4 人慢。 + times := []float64{13.0, 12.0, 11.0, 10.0} + if status, e := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{ + "eventId": ids.event, + "categoryId": ids.category, + "results": batchDraft(entries, 4, 0, times), + }, true); status != http.StatusOK { + t.Fatalf("保存草稿应 200,得到 %d %+v", status, e.Error) + } + if status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{ + "eventId": ids.event, + "categoryId": ids.category, + }, true); status != http.StatusOK { + t.Fatalf("发布应 200,得到 %d %+v", status, e.Error) + } + + // 名次只看成绩(用时越短越好):13.0 → 第 4、12.0 → 第 3、11.0 → 第 2、10.0 → 第 1。 + for i, want := range []int{4, 3, 2, 1} { + if got := entryRank(t, env, entries[i]); got != want { + t.Fatalf("entry %d 名次 = %d,期望 %d(批次号不参与名次)", entries[i], got, want) + } + } +} diff --git a/server/internal/httpapi/entry_heat_test.go b/server/internal/httpapi/entry_heat_test.go new file mode 100644 index 0000000..5170130 --- /dev/null +++ b/server/internal/httpapi/entry_heat_test.go @@ -0,0 +1,268 @@ +package httpapi_test + +import ( + "fmt" + "net/http" + "strings" + "testing" +) + +// importHeaderHeat 是带批次列的 12 列表头(与 ImportSection 模板一致)。 +const importHeaderHeat = "event_code,category,team_code,athlete_name,id_card,gender,age,bib,seed,heat,status,member_names" + +// 批量设置批次:成功写库、GET 下发、再传 null 清除;重复提交幂等。 +func TestSetEntriesHeatSetsAndClears(t *testing.T) { + env, ids, client := recorderWithSeed(t) + + status, env2 := env.do(client, http.MethodPut, "/api/entries/heat", map[string]any{ + "entryIds": []int64{ids.entryA, ids.entryB}, + "heat": 2, + }, true) + if status != http.StatusOK || !env2.OK { + t.Fatalf("设置批次应 200,得到 %d %+v", status, env2.Error) + } + result := decodeData[map[string]any](t, env2) + if result["updated"] != float64(2) || result["heat"] != float64(2) { + t.Fatalf("批量设置结果不对: %+v", result) + } + + var heat int + if err := env.ev.QueryRow(`SELECT heat FROM entries WHERE id = ?`, ids.entryA).Scan(&heat); err != nil { + t.Fatalf("读 entries.heat: %v", err) + } + if heat != 2 { + t.Fatalf("entryA 的批次应为 2,得到 %d", heat) + } + + // 下发路径也要带上 heat:录入页靠 GET /api/events/{id}/entries 拿批次。 + status, env2 = env.do(client, http.MethodGet, fmt.Sprintf("/api/events/%d/entries", ids.event), nil, false) + if status != http.StatusOK || !env2.OK { + t.Fatalf("读报名列表应 200,得到 %d %+v", status, env2.Error) + } + items := decodeData[[]map[string]any](t, env2) + if len(items) != 2 { + t.Fatalf("报名列表应有 2 条,得到 %d", len(items)) + } + for _, item := range items { + if item["heat"] != float64(2) { + t.Fatalf("报名列表应下发 heat=2,得到 %+v", item["heat"]) + } + } + + // 幂等:同一批再设一次批次号,结果一致。 + status, env2 = env.do(client, http.MethodPut, "/api/entries/heat", map[string]any{ + "entryIds": []int64{ids.entryA, ids.entryB}, + "heat": 2, + }, true) + if status != http.StatusOK || !env2.OK { + t.Fatalf("重复设置同一批次应 200,得到 %d %+v", status, env2.Error) + } + if again := decodeData[map[string]any](t, env2); again["updated"] != float64(2) { + t.Fatalf("重复设置应同样更新 2 条,得到 %+v", again) + } + + // 传 null 清除为「未分批」。 + status, env2 = env.do(client, http.MethodPut, "/api/entries/heat", map[string]any{ + "entryIds": []int64{ids.entryA, ids.entryB}, + "heat": nil, + }, true) + if status != http.StatusOK || !env2.OK { + t.Fatalf("清除批次应 200,得到 %d %+v", status, env2.Error) + } + if cleared := decodeData[map[string]any](t, env2); cleared["heat"] != nil { + t.Fatalf("清除后 heat 应为 null,得到 %+v", cleared["heat"]) + } + var nullHeat any + if err := env.ev.QueryRow(`SELECT heat FROM entries WHERE id = ?`, ids.entryA).Scan(&nullHeat); err != nil { + t.Fatalf("读 entries.heat: %v", err) + } + if nullHeat != nil { + t.Fatalf("清除后 entries.heat 应为 NULL,得到 %v", nullHeat) + } +} + +// 跨小项批量设置一定是选错行:必须拒绝,且一条都不能改。 +func TestSetEntriesHeatRejectsCrossEvent(t *testing.T) { + env, ids, client := recorderWithSeed(t) + + otherEvent := env.insert(t, + `INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status) + VALUES (?, 'M200', '200米跑', 'individual', 'auto', 'time', 'asc', 1, 1, 'ongoing')`, ids.sport) + otherCategory := env.insert(t, + `INSERT INTO event_categories (event_id, name, multiplier, sort_order) VALUES (?, '男子', 1, 0)`, otherEvent) + otherEntry := env.insert(t, + `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status) VALUES (?, ?, ?, ?, 'registered')`, + otherEvent, otherCategory, ids.teamB, ids.athB) + + status, env2 := env.do(client, http.MethodPut, "/api/entries/heat", map[string]any{ + "entryIds": []int64{ids.entryA, otherEntry}, + "heat": 1, + }, true) + if status != http.StatusBadRequest { + t.Fatalf("跨小项设置批次应 400,得到 %d %+v", status, env2.Error) + } + assertNoHeat(t, env, ids.entryA) + assertNoHeat(t, env, otherEntry) +} + +// entryIds 为空 / 全部非法、以及 heat 越界都必须被拒。 +func TestSetEntriesHeatValidatesInput(t *testing.T) { + env, ids, client := recorderWithSeed(t) + + cases := []struct { + name string + body map[string]any + }{ + {"entryIds 为空", map[string]any{"entryIds": []int64{}, "heat": 1}}, + {"entryIds 缺失", map[string]any{"heat": 1}}, + {"entryIds 全是非法 id", map[string]any{"entryIds": []int64{0, -3}, "heat": 1}}, + {"heat 为 0", map[string]any{"entryIds": []int64{ids.entryA}, "heat": 0}}, + {"heat 为负", map[string]any{"entryIds": []int64{ids.entryA}, "heat": -1}}, + {"heat 超上限", map[string]any{"entryIds": []int64{ids.entryA}, "heat": 100000}}, + {"报名项不存在", map[string]any{"entryIds": []int64{ids.entryA, 999999}, "heat": 1}}, + } + for _, tc := range cases { + status, env2 := env.do(client, http.MethodPut, "/api/entries/heat", tc.body, true) + if status != http.StatusBadRequest { + t.Errorf("%s 应 400,得到 %d %+v", tc.name, status, env2.Error) + continue + } + if env2.Error == nil || env2.Error.Code != "VALIDATION_FAILED" { + t.Errorf("%s 应返回 VALIDATION_FAILED,得到 %+v", tc.name, env2.Error) + } + } + assertNoHeat(t, env, ids.entryA) +} + +// 权限:guest 未登录 401;recorder 放行;批次写操作进审计。 +func TestSetEntriesHeatPermissionAndAudit(t *testing.T) { + env, ids, _ := recorderWithSeed(t) + + guest, _ := env.client() + status, env2 := env.do(guest, http.MethodPut, "/api/entries/heat", map[string]any{ + "entryIds": []int64{ids.entryA}, "heat": 1, + }, false) + if status != http.StatusUnauthorized || env2.Error == nil || env2.Error.Code != "AUTH_REQUIRED" { + t.Fatalf("guest 设置批次应 401 AUTH_REQUIRED,得到 %d %+v", status, env2.Error) + } + assertNoHeat(t, env, ids.entryA) + + recorder, _ := env.client() + env.login(recorder, "recorder", "recorder-pass") + status, env2 = env.do(recorder, http.MethodPut, "/api/entries/heat", map[string]any{ + "entryIds": []int64{ids.entryA}, "heat": 3, + }, true) + if status != http.StatusOK || !env2.OK { + t.Fatalf("recorder 设置批次应 200,得到 %d %+v", status, env2.Error) + } + + var ( + action, after string + ) + if err := env.ev.QueryRow( + `SELECT action, after_json FROM audit_logs WHERE action = 'entry.heat' ORDER BY id DESC LIMIT 1`). + Scan(&action, &after); err != nil { + t.Fatalf("查批次审计: %v", err) + } + if !strings.Contains(after, `"heat":3`) { + t.Fatalf("审计里应记下批次号 3,得到 %s", after) + } +} + +func assertNoHeat(t *testing.T, env *testEnv, entryID int64) { + t.Helper() + var heat any + if err := env.ev.QueryRow(`SELECT heat FROM entries WHERE id = ?`, entryID).Scan(&heat); err != nil { + t.Fatalf("读 entries.heat: %v", err) + } + if heat != nil { + t.Fatalf("entry %d 的批次应保持未分批,得到 %v", entryID, heat) + } +} + +// 导入带 heat 列:正常值入库、留空 = 未分批、非法值走行级拦截且其余行照常导入。 +func TestImportEntriesWithHeatColumn(t *testing.T) { + env, _, client := recorderWithSeed(t) + + body := importHeaderHeat + "\n" + + "M100,男子,A,张三,110101199001011234,男,,,,1,registered,\n" + + "M100,男子,A,李强,110101199001011235,男,,,,2,registered,\n" + + "M100,男子,B,王芳,110101199001011236,女,,,,3,registered,\n" + + "M100,男子,A,赵六,110101199001011237,男,,,,abc,registered,\n" + + "M100,男子,B,孙七,110101199001011238,男,,,,0,registered,\n" + status, env2 := env.postImport(t, client, "/api/entries/import", body, "") + if status != http.StatusOK || !env2.OK { + t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error) + } + result := decodeData[map[string]any](t, env2) + if result["created"] != float64(3) || result["skipped"] != float64(2) { + t.Fatalf("应新建 3 条、跳过 2 条,得到 %+v", result) + } + errs, _ := result["errors"].([]any) + if len(errs) != 2 { + t.Fatalf("应有 2 条行级错误,得到 %+v", errs) + } + for _, raw := range errs { + text, _ := raw.(string) + if !strings.Contains(text, "heat") { + t.Errorf("行级错误应说明 heat 非法,得到 %q", text) + } + } + + // 正常值入库;heat 非法的两行被拦,不影响同一份表里的其余行。 + for idCard, want := range map[string]int64{ + "110101199001011234": 1, + "110101199001011235": 2, + "110101199001011236": 3, + } { + var heat int64 + if err := env.ev.QueryRow( + `SELECT e.heat FROM entries e JOIN athletes a ON a.id = e.athlete_id WHERE a.id_card = ?`, idCard). + Scan(&heat); err != nil { + t.Fatalf("读 %s 的报名批次: %v", idCard, err) + } + if heat != want { + t.Errorf("%s 的批次应为 %d,得到 %d", idCard, want, heat) + } + } + // 非法值的两行没有落库。 + assertCount(t, env, `SELECT COUNT(*) FROM athletes WHERE id_card IN ('110101199001011237','110101199001011238')`, 0) + // 表头没有 heat 列时(老模板)照常导入,批次为未分批。 + status, env2 = env.postImport(t, client, "/api/entries/import", importHeader+ + "\nM100,男子,A,周八,110101199001011239,男,,,,registered,\n", "") + if status != http.StatusOK || !env2.OK { + t.Fatalf("无 heat 列的老模板应照常导入,得到 %d %+v", status, env2.Error) + } + var heat any + if err := env.ev.QueryRow( + `SELECT e.heat FROM entries e JOIN athletes a ON a.id = e.athlete_id WHERE a.id_card = '110101199001011239'`). + Scan(&heat); err != nil { + t.Fatalf("读老模板导入的报名批次: %v", err) + } + if heat != nil { + t.Fatalf("无 heat 列时应为未分批,得到 %v", heat) + } +} + +// 导入留空 = 未分批:显式带 heat 列但留空,写入 NULL。 +func TestImportEntriesHeatBlankMeansUnbatched(t *testing.T) { + env, _, client := recorderWithSeed(t) + + body := importHeaderHeat + "\nM100,男子,A,张三,110101199001011234,男,,,,,registered,\n" + status, env2 := env.postImport(t, client, "/api/entries/import", body, "") + if status != http.StatusOK || !env2.OK { + t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error) + } + if result := decodeData[map[string]any](t, env2); result["created"] != float64(1) { + t.Fatalf("应新建 1 条,得到 %+v", result) + } + var heat any + if err := env.ev.QueryRow( + `SELECT e.heat FROM entries e JOIN athletes a ON a.id = e.athlete_id WHERE a.id_card = '110101199001011234'`). + Scan(&heat); err != nil { + t.Fatalf("读报名批次: %v", err) + } + if heat != nil { + t.Fatalf("留空应为未分批(NULL),得到 %v", heat) + } +} diff --git a/server/internal/httpapi/handlers_config.go b/server/internal/httpapi/handlers_config.go index fa5e675..207dce6 100644 --- a/server/internal/httpapi/handlers_config.go +++ b/server/internal/httpapi/handlers_config.go @@ -690,6 +690,21 @@ func (s *server) resolveImportHandler(w http.ResponseWriter, r *http.Request) (a return result, nil } +// setEntriesHeatHandler 批量设置 / 清除一批报名项的批次(PUT /api/entries/heat)。 +func (s *server) setEntriesHeatHandler(w http.ResponseWriter, r *http.Request) (any, error) { + var in entryHeatPayload + if err := decodeJSON(r, &in); err != nil { + return nil, err + } + result, err := s.setEntriesHeat(r.Context(), in.EntryIDs, in.Heat) + if err != nil { + return nil, err + } + s.audit(r.Context(), "entry.heat", "entry", 0, nil, + map[string]any{"entryIds": result.EntryIDs, "heat": result.Heat, "updated": result.Updated}) + return result, nil +} + func readTable(ext string, file io.Reader) ([][]string, error) { switch strings.ToLower(ext) { case ".xlsx": @@ -828,6 +843,14 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio result.Errors = append(result.Errors, s.interceptRow(line, "%s", err.Error())) continue } + // heat 是可选列:留空 = 未分批;填了必须是正整数,否则整行拦截(其余行照常导入)。 + heat, ok := parseImportHeat(cell("heat")) + if !ok { + result.Skipped++ + result.Errors = append(result.Errors, s.interceptRow(line, + "heat 需要是 1-%d 的整数(留空表示未分批)", maxEntryHeat)) + continue + } eventID, err := s.lookupID(ctx, `SELECT id FROM events WHERE code = ?`, eventCode) if err != nil { @@ -931,6 +954,7 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio MemberNames: splitNames(cell("member_names")), Bib: nilIfEmpty(cell("bib")), Seed: nilIfEmpty(cell("seed")), + Heat: heat, Status: status, }) switch { @@ -958,6 +982,18 @@ func parseImportAge(raw string) (*int, bool) { return &n, true } +// parseImportHeat 解析可选的 heat 列:留空表示未分批(nil),非整数或超出 1-maxEntryHeat 视为非法。 +func parseImportHeat(raw string) (*int64, bool) { + if raw == "" { + return nil, true + } + n, err := strconv.ParseInt(raw, 10, 64) + if err != nil || n < 1 || n > maxEntryHeat { + return nil, false + } + return &n, true +} + // normalizeGender 只认 男/女,其它值(含空)一律忽略。 func normalizeGender(raw string) string { switch raw { diff --git a/server/internal/httpapi/live_events.go b/server/internal/httpapi/live_events.go new file mode 100644 index 0000000..b2dc4e1 --- /dev/null +++ b/server/internal/httpapi/live_events.go @@ -0,0 +1,380 @@ +package httpapi + +import ( + "context" + "database/sql" + "errors" + "net/http" + "sort" + "strings" + "time" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/dto" +) + +// 「实时赛况」大屏页的当前项目判定。 +// +// 现场节奏:一个项目跑完一批就发布一批,下一场开始前大屏要一直显示上一场。 +// 判定顺序(display.live_source=auto 时): +// 1. 人工指定了 display.live_events → 用人工指定的(最多 2 个); +// 2. 否则取 scheduled_at 落在「现在 ± liveEventWindow」内的小项,按离现在多近排序,最多 2 个; +// 3. 都没有 → 取最近一次发布过成绩的小项(用户要求的「下一场开始前持续显示上一场」)。 +// +// manual 时只认人工指定,不做任何回退。 +const ( + // liveEventWindow 是排期窗口的半径:项目开始时间前后各 30 分钟都算「正在进行」。 + // 取 30 分钟是因为一个田径小项(含多轮次)通常不会超过一小时; + // 想更精确可以后续做成设置项,这里先给一个现场够用的固定值。 + liveEventWindow = 30 * time.Minute + + liveReasonManual = "manual" + liveReasonScheduled = "scheduled" + liveReasonRecent = "recent" +) + +// liveScheduledCandidate 是排期命中的候选小项。 +type liveScheduledCandidate struct { + ID int64 + At time.Time + SortOrder int +} + +// liveSelection 是判定结果:id 列表与每个 id 的入选原因。 +type liveSelection struct { + Source string + IDs []int64 + Reasons map[int64]string +} + +// pickLiveEvents 是纯函数形式的回退链,方便单测覆盖各种组合。 +// scheduled 传入的候选必须已经按窗口筛过。 +func pickLiveEvents(source string, manual []int64, scheduled []liveScheduledCandidate, recent int64, now time.Time) liveSelection { + out := liveSelection{Source: source, IDs: make([]int64, 0, dto.MaxLiveEvents), Reasons: map[int64]string{}} + take := func(ids []int64, reason string) { + for _, id := range ids { + if id <= 0 || len(out.IDs) >= dto.MaxLiveEvents { + return + } + if _, ok := out.Reasons[id]; ok { + continue + } + out.IDs = append(out.IDs, id) + out.Reasons[id] = reason + } + } + + if source == dto.LiveSourceManual { + take(manual, liveReasonManual) + return out + } + if len(manual) > 0 { + take(manual, liveReasonManual) + return out + } + + if len(scheduled) > 0 { + sort.SliceStable(scheduled, func(i, j int) bool { + di := absDuration(scheduled[i].At.Sub(now)) + dj := absDuration(scheduled[j].At.Sub(now)) + if di != dj { + return di < dj + } + if scheduled[i].SortOrder != scheduled[j].SortOrder { + return scheduled[i].SortOrder < scheduled[j].SortOrder + } + return scheduled[i].ID < scheduled[j].ID + }) + ids := make([]int64, 0, len(scheduled)) + for _, c := range scheduled { + ids = append(ids, c.ID) + } + take(ids, liveReasonScheduled) + return out + } + + if recent > 0 { + take([]int64{recent}, liveReasonRecent) + } + return out +} + +func absDuration(d time.Duration) time.Duration { + if d < 0 { + return -d + } + return d +} + +// resolveLiveEvents 读取设置与赛事库,判定当前项目。 +func (s *server) resolveLiveEvents(ctx context.Context, now time.Time) (liveSelection, error) { + values, err := loadSettingsMap(ctx, s.ev) + if err != nil { + return liveSelection{}, err + } + source := dto.NormalizeLiveSource(values[dto.SettingKeyLiveSource]) + + manual, err := s.existingEventIDs(ctx, dto.ParseLiveEventIDs(values[dto.SettingKeyLiveEvents])) + if err != nil { + return liveSelection{}, err + } + // manual 模式不必查排期与最近发布,省掉两次查询。 + if source == dto.LiveSourceManual { + return pickLiveEvents(source, manual, nil, 0, now), nil + } + scheduled, err := s.scheduledLiveCandidates(ctx, now) + if err != nil { + return liveSelection{}, err + } + recent, err := s.mostRecentPublishedEvent(ctx) + if err != nil { + return liveSelection{}, err + } + return pickLiveEvents(source, manual, scheduled, recent, now), nil +} + +// existingEventIDs 丢掉已不存在的小项 id:人工指定的小项被删除后, +// 大屏不该因为一个残留 id 报错或整块空白。 +func (s *server) existingEventIDs(ctx context.Context, ids []int64) ([]int64, error) { + out := make([]int64, 0, len(ids)) + for _, id := range ids { + var one int + err := s.ev.QueryRowContext(ctx, `SELECT 1 FROM events WHERE id = ?`, id).Scan(&one) + switch { + case errors.Is(err, sql.ErrNoRows): + continue + case err != nil: + return nil, internal(err) + default: + out = append(out, id) + } + } + return out, nil +} + +// scheduledLiveCandidates 取 scheduled_at 落在当前窗口内的小项。 +func (s *server) scheduledLiveCandidates(ctx context.Context, now time.Time) ([]liveScheduledCandidate, error) { + rows, err := s.ev.QueryContext(ctx, + `SELECT id, scheduled_at, sort_order FROM events + WHERE scheduled_at IS NOT NULL AND TRIM(scheduled_at) <> ''`) + if err != nil { + return nil, internal(err) + } + defer func() { _ = rows.Close() }() + + out := make([]liveScheduledCandidate, 0) + for rows.Next() { + var ( + c liveScheduledCandidate + raw string + sortOrd int64 + ) + if err := rows.Scan(&c.ID, &raw, &sortOrd); err != nil { + return nil, internal(err) + } + at, ok := parseScheduledAt(raw) + if !ok || absDuration(at.Sub(now)) > liveEventWindow { + continue + } + c.At = at + c.SortOrder = int(sortOrd) + out = append(out, c) + } + return out, rows.Err() +} + +// mostRecentPublishedEvent 取最近一次发布过成绩的小项,取不到时返回 0(全新赛事)。 +func (s *server) mostRecentPublishedEvent(ctx context.Context) (int64, error) { + var id int64 + err := s.ev.QueryRowContext(ctx, ` + SELECT r.event_id + FROM results r + JOIN events ev ON ev.id = r.event_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 1`).Scan(&id) + if errors.Is(err, sql.ErrNoRows) { + return 0, nil + } + if err != nil { + return 0, internal(err) + } + return id, nil +} + +// parseScheduledAt 解析 events.scheduled_at。 +// +// 管理端存的是 datetime-local 转出的 RFC3339(UTC),但手工改库或早期数据可能是 +// 其它几种常见写法;认不出来的一律当成「没有排期」,不让一个大屏因为格式怪就整块不显示。 +func parseScheduledAt(raw string) (time.Time, bool) { + v := strings.TrimSpace(raw) + if v == "" { + return time.Time{}, false + } + layouts := []string{ + time.RFC3339Nano, + time.RFC3339, + "2006-01-02T15:04:05.999999999", + "2006-01-02T15:04:05", + "2006-01-02T15:04", + "2006-01-02 15:04:05", + "2006-01-02 15:04", + "2006-01-02", + } + for _, layout := range layouts { + if at, err := time.Parse(layout, v); err == nil { + return at, true + } + } + return time.Time{}, false +} + +// dedupeIDs 去重并丢掉非法 id(0 / 负数),保持原顺序。 +func dedupeIDs(ids []int64) []int64 { + out := make([]int64, 0, len(ids)) + seen := make(map[int64]bool, len(ids)) + for _, id := range ids { + if id <= 0 || seen[id] { + continue + } + seen[id] = true + out = append(out, id) + } + return out +} + +// ---------- 接口 ---------- + +// liveEventsHandler 是 GET /api/display/live:大屏「实时赛况」页拉当前项目与已发布成绩。 +// 同样对 guest 开放——局域网大屏不带会话,判定结论本身不含任何管理配置。 +func (s *server) liveEventsHandler(w http.ResponseWriter, r *http.Request) (any, error) { + ctx := r.Context() + sel, err := s.resolveLiveEvents(ctx, time.Now()) + if err != nil { + return nil, err + } + out := dto.LiveEvents{Source: sel.Source, EventIDs: sel.IDs, Events: make([]dto.LiveEvent, 0, len(sel.IDs))} + for _, id := range sel.IDs { + ev, err := loadEventQ(ctx, s.ev, id) + if err != nil { + return nil, err + } + groups, err := s.liveResultGroups(ctx, id) + if err != nil { + return nil, err + } + out.Events = append(out.Events, dto.LiveEvent{ + Event: dto.EventFromDomain(ev), + Reason: sel.Reasons[id], + Groups: groups, + }) + } + return out, nil +} + +// liveResultGroups 取某个小项已发布的成绩,按「小项 + 组别」聚合成组。 +// +// 与实时层 snapshot.latestResults 的口径一致(排除 withdrawn、组内按名次排序), +// 但范围固定在这一个小项、且不设条数上限——当前项目要显示完整名单,不能被 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, + 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 + FROM results r + JOIN entries e ON e.id = r.entry_id + 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.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 { + return nil, internal(err) + } + defer func() { _ = rows.Close() }() + + index := make(map[int64]int) + out := make([]dto.EventResultGroup, 0) + for rows.Next() { + var ( + eventCode, eventName string + categoryID int64 + categoryName string + publishedAt string + row dto.EventResultRow + rank sql.NullInt64 + value sql.NullFloat64 + athleteID sql.NullInt64 + athleteName string + shortName string + logoPath sql.NullString + memberNames sql.NullString + isTie int + ) + if err := rows.Scan(&eventCode, &eventName, &categoryID, &categoryName, &publishedAt, + &row.EntryID, &rank, &row.TeamID, &row.TeamCode, &row.TeamName, &shortName, &logoPath, + &athleteID, &athleteName, &memberNames, + &row.ResultText, &value, &row.Unit, &isTie, &row.Status); err != nil { + return nil, internal(err) + } + if rank.Valid { + v := int(rank.Int64) + row.Rank = &v + } + if value.Valid { + v := value.Float64 + row.ResultValue = &v + } + if athleteID.Valid && athleteID.Int64 != 0 { + v := athleteID.Int64 + row.AthleteID = &v + } + row.AthleteName = nilIfEmpty(athleteName) + row.ShortName = nilIfEmpty(shortName) + row.LogoPath = nilIfEmpty(logoPath.String) + row.MemberNames = parseNames(memberNames) + row.IsTie = isTie != 0 + + key := categoryID + i, ok := index[key] + if !ok { + i = len(out) + index[key] = i + out = append(out, dto.EventResultGroup{ + EventID: eventID, + EventCode: eventCode, + EventName: eventName, + CategoryID: categoryID, + CategoryName: categoryName, + PublishedAt: nilIfEmpty(publishedAt), + Rows: []dto.EventResultRow{}, + }) + } + out[i].Rows = append(out[i].Rows, row) + } + if err := rows.Err(); err != nil { + return nil, internal(err) + } + + // 行是按发布时间倒序取回的,组内要改回名次顺序;无名次的排最后。 + for i := range out { + sort.SliceStable(out[i].Rows, func(a, b int) bool { + ra, rb := out[i].Rows[a], out[i].Rows[b] + switch { + case ra.Rank == nil && rb.Rank == nil: + return ra.EntryID < rb.EntryID + case ra.Rank == nil: + return false + case rb.Rank == nil: + return true + default: + return *ra.Rank < *rb.Rank + } + }) + } + return out, nil +} diff --git a/server/internal/httpapi/live_events_internal_test.go b/server/internal/httpapi/live_events_internal_test.go new file mode 100644 index 0000000..9f6012d --- /dev/null +++ b/server/internal/httpapi/live_events_internal_test.go @@ -0,0 +1,105 @@ +package httpapi + +import ( + "testing" + "time" +) + +func liveCandidate(id int64, offset time.Duration, order int) liveScheduledCandidate { + return liveScheduledCandidate{ID: id, At: time.Date(2026, 9, 11, 10, 0, 0, 0, time.UTC).Add(offset), SortOrder: order} +} + +func liveNow() time.Time { + return time.Date(2026, 9, 11, 10, 0, 0, 0, time.UTC) +} + +// manual 只认人工指定:人工没指定就是空,绝不回退。 +func TestPickLiveEventsManualOnly(t *testing.T) { + now := liveNow() + sel := pickLiveEvents("manual", []int64{7, 9, 11}, []liveScheduledCandidate{liveCandidate(1, 0, 0)}, 3, now) + if sel.Source != "manual" { + t.Fatalf("source 应原样保留,得到 %q", sel.Source) + } + if len(sel.IDs) != 2 || sel.IDs[0] != 7 || sel.IDs[1] != 9 { + t.Fatalf("manual 应取人工指定且最多 2 个,得到 %+v", sel.IDs) + } + if sel.Reasons[7] != "manual" || sel.Reasons[9] != "manual" { + t.Fatalf("manual 模式的入选原因应为 manual,得到 %+v", sel.Reasons) + } + + empty := pickLiveEvents("manual", nil, nil, 3, now) + if len(empty.IDs) != 0 { + t.Fatalf("manual 未指定时不应回退,得到 %+v", empty.IDs) + } +} + +// auto:人工指定优先,其余候选取最近的,最多 2 个。 +func TestPickLiveEventsAutoChain(t *testing.T) { + now := liveNow() + + manualFirst := pickLiveEvents("auto", []int64{4}, []liveScheduledCandidate{liveCandidate(1, 0, 0)}, 9, now) + if len(manualFirst.IDs) != 1 || manualFirst.IDs[0] != 4 || manualFirst.Reasons[4] != "manual" { + t.Fatalf("auto 下人工指定应优先,得到 %+v / %+v", manualFirst.IDs, manualFirst.Reasons) + } + + // 三个排期候选:按离现在的远近排序,取最近的两个。 + scheduled := pickLiveEvents("auto", nil, []liveScheduledCandidate{ + liveCandidate(11, 20*time.Minute, 0), + liveCandidate(12, -5*time.Minute, 2), + liveCandidate(13, 3*time.Minute, 1), + }, 9, now) + if len(scheduled.IDs) != 2 || scheduled.IDs[0] != 13 || scheduled.IDs[1] != 12 { + t.Fatalf("排期应按离现在远近取前 2 个,得到 %+v", scheduled.IDs) + } + if scheduled.Reasons[13] != "scheduled" { + t.Fatalf("排期命中的原因应为 scheduled,得到 %+v", scheduled.Reasons) + } + + // 距离相同时按 sort_order / id 稳定排序。 + tie := pickLiveEvents("auto", nil, []liveScheduledCandidate{ + liveCandidate(21, 5*time.Minute, 1), + liveCandidate(22, -5*time.Minute, 0), + }, 0, now) + if len(tie.IDs) != 2 || tie.IDs[0] != 22 || tie.IDs[1] != 21 { + t.Fatalf("距离相同应按 sort_order 排序,得到 %+v", tie.IDs) + } + + recent := pickLiveEvents("auto", nil, nil, 9, now) + if len(recent.IDs) != 1 || recent.IDs[0] != 9 || recent.Reasons[9] != "recent" { + t.Fatalf("都不命中时应回退到最近发布过成绩的项目,得到 %+v / %+v", recent.IDs, recent.Reasons) + } + + none := pickLiveEvents("auto", nil, nil, 0, now) + if len(none.IDs) != 0 { + t.Fatalf("空赛事不应选出任何项目,得到 %+v", none.IDs) + } + if none.Source != "auto" { + t.Fatalf("source 应原样保留,得到 %q", none.Source) + } +} + +func TestParseScheduledAt(t *testing.T) { + cases := []struct { + raw string + ok bool + want int // 期望的 UTC 小时 + }{ + {"2026-09-11T10:30:00.000Z", true, 10}, + {"2026-09-11T10:30:00+08:00", true, 2}, + {"2026-09-11T10:30:00", true, 10}, + {"2026-09-11T10:30", true, 10}, + {"2026-09-11 10:30:00", true, 10}, + {"2026-09-11", true, 0}, + {"", false, 0}, + {"待定", false, 0}, + } + for _, tc := range cases { + got, ok := parseScheduledAt(tc.raw) + if ok != tc.ok { + t.Fatalf("parseScheduledAt(%q) ok=%v,期望 %v", tc.raw, ok, tc.ok) + } + if ok && got.UTC().Hour() != tc.want { + t.Fatalf("parseScheduledAt(%q) = %v,期望 UTC %d 点", tc.raw, got, tc.want) + } + } +} diff --git a/server/internal/httpapi/live_events_test.go b/server/internal/httpapi/live_events_test.go new file mode 100644 index 0000000..c606d23 --- /dev/null +++ b/server/internal/httpapi/live_events_test.go @@ -0,0 +1,205 @@ +package httpapi_test + +import ( + "net/http" + "strconv" + "testing" + "time" +) + +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"` +} + +type settingsBody struct { + Display struct { + LiveEvents []int64 `json:"liveEvents"` + LiveSource string `json:"liveSource"` + } `json:"display"` +} + +// 设置读写:display.live_events / display.live_source 要能存取,非法值必须当场拒绝。 +func TestSettingsLiveEventsRoundTrip(t *testing.T) { + env := newTestEnv(t) + ids := env.seed(t) + env.createUser("admin", "admin-pass", "admin") + client, _ := env.client() + env.login(client, "admin", "admin-pass") + + if status, e := env.do(client, http.MethodPut, "/api/settings", map[string]any{ + "display": map[string]any{"liveEvents": []int64{ids.event}, "liveSource": "manual"}, + }, true); status != http.StatusOK { + t.Fatalf("保存实时赛况设置应 200,得到 %d %+v", status, e.Error) + } + + status, e := env.do(client, http.MethodGet, "/api/settings", nil, false) + if status != http.StatusOK { + t.Fatalf("读取设置应 200,得到 %d %+v", status, e.Error) + } + got := decodeData[settingsBody](t, e) + if got.Display.LiveSource != "manual" || len(got.Display.LiveEvents) != 1 || got.Display.LiveEvents[0] != ids.event { + t.Fatalf("设置应原样读回,得到 %+v", got.Display) + } + + // 库里以逗号分隔的 id 文本存储,随赛事包一起走。 + var raw string + if err := env.ev.QueryRow(`SELECT value FROM settings WHERE key = 'display.live_events'`).Scan(&raw); err != nil { + t.Fatalf("读取存储值: %v", err) + } + if raw != strconv.FormatInt(ids.event, 10) { + t.Fatalf("display.live_events 应存逗号分隔 id,得到 %q", raw) + } + + // 清空人工指定:传空数组。 + if status, e := env.do(client, http.MethodPut, "/api/settings", map[string]any{ + "display": map[string]any{"liveEvents": []int64{}}, + }, true); status != http.StatusOK { + t.Fatalf("清空人工指定应 200,得到 %d %+v", status, e.Error) + } + _, e = env.do(client, http.MethodGet, "/api/settings", nil, false) + if cleared := decodeData[settingsBody](t, e); len(cleared.Display.LiveEvents) != 0 { + t.Fatalf("清空后应回空数组,得到 %+v", cleared.Display.LiveEvents) + } + + // 非法取值全部拒绝。 + for _, patch := range []map[string]any{ + {"display": map[string]any{"liveSource": "sometimes"}}, + {"display": map[string]any{"liveEvents": []int64{ids.event, ids.event + 1, ids.event + 2}}}, + {"display": map[string]any{"liveEvents": []int64{999999}}}, + } { + status, e := env.do(client, http.MethodPut, "/api/settings", patch, true) + if status != http.StatusBadRequest || e.Error == nil || e.Error.Code != "VALIDATION_FAILED" { + t.Fatalf("非法设置应 400 VALIDATION_FAILED,得到 %d %+v(%+v)", status, e.Error, patch) + } + } +} + +// 当前项目的判定:manual 认人工指定;auto 先排期命中;都不命中回退到最近发布过成绩的项目;空赛事不炸。 +func TestDisplayLiveEndpoint(t *testing.T) { + env := newTestEnv(t) + ids := env.seed(t) + env.createUser("recorder", "recorder-pass", "recorder") + client, _ := env.client() + env.login(client, "recorder", "recorder-pass") + env.createUser("admin", "admin-pass", "admin") + adminClient, _ := env.client() + env.login(adminClient, "admin", "admin-pass") + + // 第二、三个小项:只用来验证人工指定(最多 2 个)。 + other := env.insert(t, + `INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status) + VALUES (?, 'M200', '200米跑', 'individual', 'auto', 'time', 'asc', 1, 1, 'pending')`, ids.sport) + other2 := env.insert(t, + `INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status) + VALUES (?, 'M400', '400米跑', 'individual', 'auto', 'time', 'asc', 1, 1, 'pending')`, ids.sport) + + guest, _ := env.client() + live := func() liveEventsBody { + t.Helper() + status, e := env.do(guest, http.MethodGet, "/api/display/live", nil, false) + if status != http.StatusOK || !e.OK { + t.Fatalf("读取实时赛况应 200,得到 %d %+v", status, e.Error) + } + return decodeData[liveEventsBody](t, e) + } + setDisplay := func(patch map[string]any) { + t.Helper() + if status, e := env.do(adminClient, http.MethodPut, "/api/settings", + map[string]any{"display": patch}, true); status != http.StatusOK { + t.Fatalf("保存设置应 200,得到 %d %+v", status, e.Error) + } + } + + // 1) manual 只认人工指定(两个)。 + setDisplay(map[string]any{"liveSource": "manual", "liveEvents": []int64{other, other2}}) + got := live() + if got.Source != "manual" || len(got.EventIDs) != 2 || got.EventIDs[0] != other || got.EventIDs[1] != other2 { + t.Fatalf("manual 应只认人工指定的两个项目 %d/%d,得到 %+v", other, other2, got) + } + if len(got.Events) != 2 || got.Events[0].Reason != "manual" || got.Events[0].Event.ID != other { + t.Fatalf("manual 命中原因应为 manual,得到 %+v", got.Events) + } + if len(got.Events[0].Groups) != 0 { + t.Fatalf("还没发布成绩时应回空组,得到 %+v", got.Events[0].Groups) + } + + // 2) auto:排期落在当前窗口内的小项命中。 + scheduled := time.Now().UTC().Add(10 * time.Minute).Format(time.RFC3339) + env.exec(t, `UPDATE events SET scheduled_at = ? WHERE id = ?`, scheduled, ids.event) + setDisplay(map[string]any{"liveSource": "auto", "liveEvents": []int64{}}) + got = live() + if got.Source != "auto" || len(got.EventIDs) != 1 || got.EventIDs[0] != ids.event { + t.Fatalf("auto 应命中排期窗口内的小项 %d,得到 %+v", ids.event, got) + } + if got.Events[0].Reason != "scheduled" { + t.Fatalf("排期命中的原因应为 scheduled,得到 %+v", got.Events[0]) + } + + // 3) auto:排期都不命中时回退到最近发布过成绩的小项。 + env.exec(t, `UPDATE events SET scheduled_at = NULL WHERE id = ?`, ids.event) + if status, e := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{ + "eventId": ids.event, + "categoryId": ids.category, + "results": []map[string]any{ + {"entryId": ids.entryA, "resultValue": 11.5, "resultText": "11.50", "unit": "s", "status": "ok"}, + {"entryId": ids.entryB, "status": "ok"}, + }, + }, true); status != http.StatusOK { + t.Fatalf("保存草稿应 200,得到 %d %+v", status, e.Error) + } + if status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{ + "eventId": ids.event, + "categoryId": ids.category, + }, true); status != http.StatusOK { + t.Fatalf("发布应 200,得到 %d %+v", status, e.Error) + } + + got = live() + if len(got.EventIDs) != 1 || got.EventIDs[0] != ids.event { + t.Fatalf("无排期命中时应回退到最近发布的小项 %d,得到 %+v", ids.event, got) + } + if got.Events[0].Reason != "recent" { + t.Fatalf("回退的原因应为 recent,得到 %+v", got.Events[0]) + } + if len(got.Events[0].Groups) != 1 || len(got.Events[0].Groups[0].Rows) != 1 { + t.Fatalf("最近发布的小项应带上已发布成绩(1 条),得到 %+v", got.Events[0].Groups) + } + if rank := got.Events[0].Groups[0].Rows[0].Rank; rank == nil || *rank != 1 { + t.Fatalf("已发布成绩应带名次,得到 %+v", got.Events[0].Groups[0].Rows[0]) + } +} + +// 全新赛事(一个小项都没有)时不能报错。 +func TestDisplayLiveEmptyEvent(t *testing.T) { + env := newTestEnv(t) + client, _ := env.client() + status, e := env.do(client, http.MethodGet, "/api/display/live", nil, false) + if status != http.StatusOK || !e.OK { + t.Fatalf("空赛事读取实时赛况应 200,得到 %d %+v", status, e.Error) + } + got := decodeData[liveEventsBody](t, e) + if len(got.EventIDs) != 0 || len(got.Events) != 0 { + t.Fatalf("空赛事应回空列表,得到 %+v", got) + } + if got.Source != "auto" { + t.Fatalf("未配置时默认 auto,得到 %q", got.Source) + } +} diff --git a/server/internal/httpapi/partial_publish_test.go b/server/internal/httpapi/partial_publish_test.go new file mode 100644 index 0000000..b618a52 --- /dev/null +++ b/server/internal/httpapi/partial_publish_test.go @@ -0,0 +1,209 @@ +package httpapi_test + +import ( + "fmt" + "net/http" + "testing" +) + +// seedBatch 在 seed 的基础上补齐到 30 条报名项(第 1、2 条来自 seed), +// 供「现场分批比赛」场景使用:跑道只有 8 条,一次只跑完一批。 +func seedBatch(t *testing.T, env *testEnv, ids seedIDs, total int) []int64 { + t.Helper() + entries := []int64{ids.entryA, ids.entryB} + for i := len(entries) + 1; i <= total; i++ { + team := ids.teamA + if i%2 == 0 { + team = ids.teamB + } + athlete := env.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES (?, '男', ?, 1)`, + fmt.Sprintf("运动员%02d", i), team) + entries = append(entries, env.insert(t, + `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status) + VALUES (?, ?, ?, ?, 'registered')`, ids.event, ids.category, team, athlete)) + } + return entries +} + +// 批次草稿:前 filled 条填成绩,其余留空,全量发给服务端(与录入页 buildDraftPayload 一致)。 +func batchDraft(entries []int64, filled, from int, times []float64) []map[string]any { + rows := make([]map[string]any, 0, len(entries)-from) + for i := from; i < len(entries); i++ { + row := map[string]any{"entryId": entries[i], "status": "ok"} + if i < filled { + value := times[i] + row["resultValue"] = value + row["resultText"] = fmt.Sprintf("%.2f", value) + row["unit"] = "s" + } + rows = append(rows, row) + } + return rows +} + +func assertCount(t *testing.T, env *testEnv, query string, want int, args ...any) { + t.Helper() + var got int + if err := env.ev.QueryRow(query, args...).Scan(&got); err != nil { + t.Fatalf("统计失败: %v", err) + } + if got != want { + t.Fatalf("期望 %d,得到 %d(%s)", want, got, query) + } +} + +func rankOfEntry(t *testing.T, env *testEnv, entryID int64) int { + t.Helper() + var rank int + if err := env.ev.QueryRow(`SELECT rank FROM results WHERE entry_id = ?`, entryID).Scan(&rank); err != nil { + t.Fatalf("读取名次: %v", err) + } + return rank +} + +// 现场分批比赛:30 人报名,先跑完 8 人发布,再跑完 8 人发布。 +// 关键要求:没跑/没录的行不阻塞发布、不被发布;已发布的先算名次积分,补发后重算要纳入先前批次。 +func TestPartialPublishKeepsUnfinishedRowsDraft(t *testing.T) { + env := newTestEnv(t) + ids := env.seed(t) + entries := seedBatch(t, env, ids, 30) + env.createUser("recorder", "recorder-pass", "recorder") + client, _ := env.client() + env.login(client, "recorder", "recorder-pass") + + // 第一批:第 1–8 人跑完,成绩由慢到快;其余 22 人留空。 + first := make([]float64, 30) + for i := range first { + first[i] = 20.0 + float64(i)*0.1 + } + if status, e := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{ + "eventId": ids.event, + "categoryId": ids.category, + "results": batchDraft(entries, 8, 0, first), + }, true); status != http.StatusOK { + t.Fatalf("保存第一批草稿应 200,得到 %d %+v", status, e.Error) + } + if status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{ + "eventId": ids.event, + "categoryId": ids.category, + }, true); status != http.StatusOK { + t.Fatalf("缺 22 条成绩时发布应成功(不报错),得到 %d %+v", status, e.Error) + } + + // 只有 8 条被发布,其余 22 条仍是草稿。 + assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'published'`, 8, ids.event) + assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'draft'`, 22, ids.event) + // 未录行必须保持空值:发布不能给它们塞任何成绩。 + assertCount(t, env, + `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'draft' AND result_value IS NULL AND rank IS NULL`, + 22, ids.event) + for i := 0; i < 8; i++ { + if got := rankOfEntry(t, env, entries[i]); got != i+1 { + t.Fatalf("第一批第 %d 人应第 %d 名,得到 %d", i+1, i+1, got) + } + } + // 积分只按已发布的 8 人算:前六名 10+8+6+3+2+1=30 分。 + assertCount(t, env, + `SELECT CAST(COALESCE(SUM(points), 0) AS INTEGER) FROM score_ledger WHERE event_id = ? AND voided = 0 AND source_type = 'result'`, + 30, ids.event) + + // 第二批:第 9–16 人跑完且明显更快。已发布的 1–8 不能再存草稿,只补未发布的行。 + second := make([]float64, 30) + for i := range second { + second[i] = 10.0 + float64(i)*0.1 + } + if status, e := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{ + "eventId": ids.event, + "categoryId": ids.category, + "results": batchDraft(entries, 16, 8, second), + }, true); status != http.StatusOK { + t.Fatalf("补录第二批草稿应 200,得到 %d %+v", status, e.Error) + } + if status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{ + "eventId": ids.event, + "categoryId": ids.category, + }, true); status != http.StatusOK { + t.Fatalf("第二批发布应 200,得到 %d %+v", status, e.Error) + } + + assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'published'`, 16, ids.event) + assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'draft'`, 14, ids.event) + // 先前的 8 人必须被重算纳入:第二批更快,第一批整体退到第 9–16 名。 + if got := rankOfEntry(t, env, entries[0]); got != 9 { + t.Fatalf("补发后第一批第一名应退到第 9 名(证明重算纳入了先前批次),得到 %d", got) + } + if got := rankOfEntry(t, env, entries[8]); got != 1 { + t.Fatalf("第二批最快者应第 1 名,得到 %d", got) + } + if got := rankOfEntry(t, env, entries[15]); got != 8 { + t.Fatalf("第二批最慢者应第 8 名,得到 %d", got) + } + // 16 人里仍只有前六名得分:30 分;流水不能因为重算而翻倍。 + assertCount(t, env, + `SELECT CAST(COALESCE(SUM(points), 0) AS INTEGER) FROM score_ledger WHERE event_id = ? AND voided = 0 AND source_type = 'result'`, + 30, ids.event) + assertCount(t, env, `SELECT COUNT(*) FROM score_ledger WHERE event_id = ? AND voided = 0`, 6, ids.event) +} + +// 一条有效成绩 + 29 条空成绩:发布必须成功,且只有那一条上屏。 +// 这也是「服务端是否会因缺成绩报错」的回归断言。 +func TestPublishWithSingleFilledRow(t *testing.T) { + env := newTestEnv(t) + ids := env.seed(t) + entries := seedBatch(t, env, ids, 30) + env.createUser("recorder", "recorder-pass", "recorder") + client, _ := env.client() + env.login(client, "recorder", "recorder-pass") + + times := make([]float64, 30) + for i := range times { + times[i] = 12.0 + float64(i)*0.1 + } + if status, e := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{ + "eventId": ids.event, + "categoryId": ids.category, + "results": batchDraft(entries, 1, 0, times), + }, true); status != http.StatusOK { + t.Fatalf("保存草稿应 200,得到 %d %+v", status, e.Error) + } + status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{ + "eventId": ids.event, + "categoryId": ids.category, + }, true) + if status != http.StatusOK || !e.OK { + t.Fatalf("缺 29 条成绩不是错误,发布应 200,得到 %d %+v", status, e.Error) + } + assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'published'`, 1, ids.event) + assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'draft'`, 29, ids.event) + if got := rankOfEntry(t, env, entries[0]); got != 1 { + t.Fatalf("唯一有效成绩应第 1 名,得到 %d", got) + } +} + +// DNS / DQ 这类「没有成绩但确实是现场结果」的行必须能发布(不能被当成空行留在草稿)。 +func TestPublishKeepsNonOKStatusWithoutValue(t *testing.T) { + env := newTestEnv(t) + ids := env.seed(t) + env.createUser("recorder", "recorder-pass", "recorder") + client, _ := env.client() + env.login(client, "recorder", "recorder-pass") + + if status, e := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{ + "eventId": ids.event, + "categoryId": ids.category, + "results": []map[string]any{ + {"entryId": ids.entryA, "resultValue": 11.5, "resultText": "11.50", "unit": "s", "status": "ok"}, + {"entryId": ids.entryB, "status": "dns"}, + }, + }, true); status != http.StatusOK { + t.Fatalf("保存草稿应 200,得到 %d %+v", status, e.Error) + } + if status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{ + "eventId": ids.event, + "categoryId": ids.category, + }, true); status != http.StatusOK { + t.Fatalf("含 DNS 的组应能发布,得到 %d %+v", status, e.Error) + } + assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'published'`, 2, ids.event) + assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'draft'`, 0, ids.event) +} diff --git a/server/internal/httpapi/payload.go b/server/internal/httpapi/payload.go index 6270a37..970d786 100644 --- a/server/internal/httpapi/payload.go +++ b/server/internal/httpapi/payload.go @@ -38,6 +38,13 @@ type voidResultPayload struct { RequestID string `json:"requestId"` } +// entryHeatPayload 是批量设置批次的请求体:heat 为 null / 缺省表示清除批次(未分批)。 +type entryHeatPayload struct { + EntryIDs []int64 `json:"entryIds"` + Heat *int64 `json:"heat"` + RequestID string `json:"requestId"` +} + type adjustmentInput struct { TeamID int64 `json:"teamId"` Delta float64 `json:"delta"` @@ -98,6 +105,11 @@ type settingsDisplayPatch struct { // FontTitle / FontBody 是字体 id;null 表示不改动(清空要传空串)。 FontTitle *string `json:"fontTitle"` FontBody *string `json:"fontBody"` + // LiveEvents 是「实时赛况」人工指定的当前项目 id(最多 2 个)。 + // nil 表示不改动;传 [] 表示清空人工指定。 + LiveEvents *[]int64 `json:"liveEvents"` + // LiveSource 是当前项目的判定方式(manual / auto);nil 表示不改动。 + LiveSource *string `json:"liveSource"` } type settingsStandingsPatch struct { diff --git a/server/internal/httpapi/repo_events.go b/server/internal/httpapi/repo_events.go index 71e5401..98d5235 100644 --- a/server/internal/httpapi/repo_events.go +++ b/server/internal/httpapi/repo_events.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "errors" + "fmt" "strings" "git.yeij.top/AskaEth/Scoreboard/server/internal/domain" @@ -385,7 +386,7 @@ func (s *server) replaceChannels(ctx context.Context, eventID int64, in []channe // ---------- entries ---------- const entryColumns = `e.id, e.event_id, e.category_id, e.team_id, e.athlete_id, e.member_names, - e.bib, e.seed, e.status, e.remark, a.name, t.code, t.name, t.short_name, c.name` + e.bib, e.seed, e.heat, e.status, e.remark, a.name, t.code, t.name, t.short_name, c.name` func scanEntry(row interface{ Scan(...any) error }) (entryDTO, error) { var ( @@ -393,6 +394,7 @@ func scanEntry(row interface{ Scan(...any) error }) (entryDTO, error) { athleteID sql.NullInt64 memberNames sql.NullString bib, seed, rem sql.NullString + heat sql.NullInt64 athleteName sql.NullString teamCode sql.NullString teamName sql.NullString @@ -400,13 +402,14 @@ func scanEntry(row interface{ Scan(...any) error }) (entryDTO, error) { categoryName sql.NullString ) if err := row.Scan(&e.ID, &e.EventID, &e.CategoryID, &e.TeamID, &athleteID, &memberNames, - &bib, &seed, &e.Status, &rem, &athleteName, &teamCode, &teamName, &shortName, &categoryName); err != nil { + &bib, &seed, &heat, &e.Status, &rem, &athleteName, &teamCode, &teamName, &shortName, &categoryName); err != nil { return e, err } e.AthleteID = i64Ptr(athleteID) e.MemberNames = parseNames(memberNames) e.Bib = strPtr(bib) e.Seed = strPtr(seed) + e.Heat = i64Ptr(heat) e.Remark = strPtr(rem) e.AthleteName = strPtr(athleteName) e.TeamCode = teamCode.String @@ -483,10 +486,10 @@ func (s *server) createEntry(ctx context.Context, in entryDTO) (entryDTO, error) return entryDTO{}, badRequest("eventId、categoryId、teamId 必填", nil) } res, err := s.ev.ExecContext(ctx, - `INSERT INTO entries (event_id, category_id, team_id, athlete_id, member_names, bib, seed, status, remark) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + `INSERT INTO entries (event_id, category_id, team_id, athlete_id, member_names, bib, seed, heat, status, remark) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, in.EventID, in.CategoryID, in.TeamID, nullI64(in.AthleteID), marshalNames(in.MemberNames), - nullStr(in.Bib), nullStr(in.Seed), orDefault(in.Status, "registered"), nullStr(in.Remark)) + nullStr(in.Bib), nullStr(in.Seed), nullI64(in.Heat), orDefault(in.Status, "registered"), nullStr(in.Remark)) if isUniqueViolation(err) { return entryDTO{}, conflict("该报名项已存在") } @@ -506,9 +509,9 @@ func (s *server) createEntry(ctx context.Context, in entryDTO) (entryDTO, error) func (s *server) updateEntry(ctx context.Context, id int64, in entryDTO) (entryDTO, error) { res, err := s.ev.ExecContext(ctx, `UPDATE entries SET category_id = ?, team_id = ?, athlete_id = ?, member_names = ?, - bib = ?, seed = ?, status = ?, remark = ? WHERE id = ?`, + bib = ?, seed = ?, heat = ?, status = ?, remark = ? WHERE id = ?`, in.CategoryID, in.TeamID, nullI64(in.AthleteID), marshalNames(in.MemberNames), - nullStr(in.Bib), nullStr(in.Seed), orDefault(in.Status, "registered"), nullStr(in.Remark), id) + nullStr(in.Bib), nullStr(in.Seed), nullI64(in.Heat), orDefault(in.Status, "registered"), nullStr(in.Remark), id) if isUniqueViolation(err) { return entryDTO{}, conflict("该报名项已存在") } @@ -532,3 +535,75 @@ func (s *server) deleteEntry(ctx context.Context, id int64) error { } return requireChanges(res, "报名项不存在") } + +// maxEntryHeat 是批次号上限:批次只是现场录入的编排分组,四位以上没有实际意义; +// 设上限同时挡住误填的大数(例如把成绩错填进批次号)。 +const maxEntryHeat = 999 + +// setEntriesHeat 给一批报名项统一设置批次(heat 为 nil 表示清除批次 / 未分批)。 +// +// 只改 entries.heat,不碰任何成绩与积分:批次不参与算分,排名与积分仍按「小项 + 组别」算。 +// 幂等:重复提交同一个批次号结果一致。要求所有报名项属于同一个小项(跨小项批量设置是误操作)。 +func (s *server) setEntriesHeat(ctx context.Context, entryIDs []int64, heat *int64) (entryHeatResultDTO, error) { + ids := dedupeIDs(entryIDs) + if len(ids) == 0 { + return entryHeatResultDTO{}, badRequest("entryIds 不能为空", nil) + } + if heat != nil && (*heat < 1 || *heat > maxEntryHeat) { + return entryHeatResultDTO{}, badRequest(fmt.Sprintf("heat 需要是 1-%d 的整数,或传 null 清除批次", maxEntryHeat), nil) + } + + args := make([]any, 0, len(ids)) + for _, id := range ids { + args = append(args, id) + } + rows, err := s.ev.QueryContext(ctx, + `SELECT id, event_id FROM entries WHERE id IN (`+placeholders(len(ids))+`)`, args...) + if err != nil { + return entryHeatResultDTO{}, internal(err) + } + defer func() { _ = rows.Close() }() + + found := make(map[int64]bool, len(ids)) + events := make(map[int64]bool) + for rows.Next() { + var ( + entryID int64 + eventID int64 + ) + if err := rows.Scan(&entryID, &eventID); err != nil { + return entryHeatResultDTO{}, internal(err) + } + found[entryID] = true + events[eventID] = true + } + if err := rows.Err(); err != nil { + return entryHeatResultDTO{}, internal(err) + } + + missing := make([]int64, 0) + for _, id := range ids { + if !found[id] { + missing = append(missing, id) + } + } + if len(missing) > 0 { + return entryHeatResultDTO{}, badRequest("部分报名项不存在", missing) + } + // 批次挂在「小项 + 组别」之下,批量设置跨小项一定是选错了行。 + if len(events) > 1 { + return entryHeatResultDTO{}, badRequest("不能跨小项批量设置批次,请在同一小项内操作", nil) + } + + res, err := s.ev.ExecContext(ctx, + `UPDATE entries SET heat = ? WHERE id IN (`+placeholders(len(ids))+`)`, + append([]any{nullI64(heat)}, args...)...) + if err != nil { + return entryHeatResultDTO{}, internal(err) + } + updated, err := res.RowsAffected() + if err != nil { + return entryHeatResultDTO{}, internal(err) + } + return entryHeatResultDTO{Updated: int(updated), Heat: heat, EntryIDs: ids}, nil +} diff --git a/server/internal/httpapi/repo_import.go b/server/internal/httpapi/repo_import.go index 4cce0dc..e4f0dd4 100644 --- a/server/internal/httpapi/repo_import.go +++ b/server/internal/httpapi/repo_import.go @@ -287,10 +287,10 @@ func (s *server) upsertImportedEntry(ctx context.Context, in entryDTO) (bool, er switch { case errors.Is(err, sql.ErrNoRows): if _, err := s.ev.ExecContext(ctx, - `INSERT INTO entries (event_id, category_id, team_id, athlete_id, member_names, bib, seed, status) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + `INSERT INTO entries (event_id, category_id, team_id, athlete_id, member_names, bib, seed, heat, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, in.EventID, in.CategoryID, in.TeamID, nullI64(in.AthleteID), marshalNames(in.MemberNames), - nullStr(in.Bib), nullStr(in.Seed), orDefault(in.Status, "registered")); err != nil { + nullStr(in.Bib), nullStr(in.Seed), nullI64(in.Heat), orDefault(in.Status, "registered")); err != nil { return false, err } return true, nil @@ -298,8 +298,8 @@ func (s *server) upsertImportedEntry(ctx context.Context, in entryDTO) (bool, er return false, err default: if _, err := s.ev.ExecContext(ctx, - `UPDATE entries SET member_names = ?, bib = ?, seed = ?, status = ? WHERE id = ?`, - marshalNames(in.MemberNames), nullStr(in.Bib), nullStr(in.Seed), + `UPDATE entries SET member_names = ?, bib = ?, seed = ?, heat = ?, status = ? WHERE id = ?`, + marshalNames(in.MemberNames), nullStr(in.Bib), nullStr(in.Seed), nullI64(in.Heat), orDefault(in.Status, "registered"), existing); err != nil { return false, err } diff --git a/server/internal/httpapi/repo_misc.go b/server/internal/httpapi/repo_misc.go index 82071e6..7975820 100644 --- a/server/internal/httpapi/repo_misc.go +++ b/server/internal/httpapi/repo_misc.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "errors" + "fmt" "strconv" "strings" "time" @@ -147,6 +148,36 @@ func (s *server) updateSettings(ctx context.Context, patch settingsPatch) (dto.E return dto.EventSettings{}, err } } + // 实时赛况的当前项目:最多 2 个,且必须是真实存在的小项。 + // 存不存在的 id 只会让大屏整块空白,现场还得回管理端排查,不如写入时就拒绝。 + if p.LiveEvents != nil { + if len(*p.LiveEvents) > dto.MaxLiveEvents { + return dto.EventSettings{}, badRequest(fmt.Sprintf("display.liveEvents 最多 %d 个", dto.MaxLiveEvents), len(*p.LiveEvents)) + } + ids := dedupeIDs(*p.LiveEvents) + for _, id := range ids { + var exists int + err := tx.QueryRowContext(ctx, `SELECT 1 FROM events WHERE id = ?`, id).Scan(&exists) + if errors.Is(err, sql.ErrNoRows) { + return dto.EventSettings{}, badRequest("display.liveEvents 引用了不存在的小项", id) + } + if err != nil { + return dto.EventSettings{}, internal(err) + } + } + if err := set(dto.SettingKeyLiveEvents, dto.FormatLiveEventIDs(ids)); err != nil { + return dto.EventSettings{}, err + } + } + if p.LiveSource != nil { + source := dto.NormalizeLiveSource(*p.LiveSource) + if source == "" { + return dto.EventSettings{}, badRequest("display.liveSource 只能是 manual / auto", *p.LiveSource) + } + if err := set(dto.SettingKeyLiveSource, source); err != nil { + return dto.EventSettings{}, err + } + } } if p := patch.Standings; p != nil { if p.IndividualToTeamWeight != nil { diff --git a/server/internal/httpapi/scoring.go b/server/internal/httpapi/scoring.go index a6ddecf..64b7fff 100644 --- a/server/internal/httpapi/scoring.go +++ b/server/internal/httpapi/scoring.go @@ -435,6 +435,16 @@ func applyComputedRanks(ctx context.Context, tx *sql.Tx, ranked []domain.RankedR // ---------- 发布 / 撤销 / 作废 ---------- +// publishableResultCond 判断一条 results 行是否承载了「可以上屏的成绩」: +// - 有解析出来的成绩值(计时 / 距离 / 计数 / 评分项目); +// - 有手工名次(resultMode=manual 或 compare=none 的项目,名次本身就是成绩); +// - 状态不是 ok(DNS / DQ / DNF / 退赛 也是现场明确给出的结果,要能上屏)。 +// +// 空且状态为 ok 的行只是「还没跑 / 还没录」,发布时必须留在 draft: +// 分批比赛时若把没跑的人一起置为 published,大屏的最新成绩会出现空行, +// 而且编辑页会把整组当成「已发布」锁死,补录无门。 +const publishableResultCond = `(result_value IS NOT NULL OR rank IS NOT NULL OR status <> 'ok')` + func (s *server) publishResults(ctx context.Context, in publishPayload, userID int64) ([]resultDTO, error) { if in.EventID == 0 { return nil, badRequest("eventId 必填", nil) @@ -458,9 +468,12 @@ func (s *server) publishResults(ctx context.Context, in publishPayload, userID i return nil, err } + // 只发布「已有内容」的成绩行:现场分批比赛时(如 100 米预赛分 4 组、每组 8 条跑道), + // 只跑完一组就要先把这一组上屏,没跑/没录的行必须留在 draft,不能被一起发布。 + // 判据见 publishableResultCond。名次与积分由下方重算按已发布部分计算,后续再发一批会自动纳入。 setPublished := `UPDATE results SET state = 'published', verified_by = ?, verified_at = datetime('now'), updated_at = datetime('now') - WHERE event_id = ?` + WHERE event_id = ? AND ` + publishableResultCond args := []any{nullID(userID), in.EventID} if catID > 0 { setPublished += ` AND entry_id IN (SELECT id FROM entries WHERE category_id = ?)` diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index 5ed154f..e9d0b86 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -215,6 +215,8 @@ func (s *server) routes(mux *http.ServeMux) { mux.HandleFunc("POST /api/entries/import", s.write(auth.RoleRecorder, s.importEntriesHandler)) mux.HandleFunc("POST /api/entries/import/resolve", s.write(auth.RoleRecorder, s.resolveImportHandler)) + // 批次(heat)是录入 / 展示分组,不参与算分,所以没有独立的「发布」概念。 + mux.HandleFunc("PUT /api/entries/heat", s.write(auth.RoleRecorder, s.setEntriesHeatHandler)) // 成绩 mux.HandleFunc("GET /api/results", s.public(s.listResultsHandler)) @@ -239,6 +241,8 @@ 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)) + // 「实时赛况」页的当前项目与已发布成绩;大屏(guest)要能直接拉,故对外只读开放。 + mux.HandleFunc("GET /api/display/live", s.public(s.liveEventsHandler)) mux.HandleFunc("GET /api/audit-logs", s.read(auth.RoleAdmin, s.listAuditLogsHandler)) mux.HandleFunc("POST /api/backup", s.write(auth.RoleAdmin, s.backupHandler)) diff --git a/server/internal/httpapi/system_test.go b/server/internal/httpapi/system_test.go index f854c50..5b17a52 100644 --- a/server/internal/httpapi/system_test.go +++ b/server/internal/httpapi/system_test.go @@ -252,7 +252,7 @@ func TestSettingsAndStandingsJSONContract(t *testing.T) { assertKeys(t, "settings.event", asMap(t, settings["event"]), []string{"name", "subtitle", "host", "logoPath", "primaryColor"}) assertKeys(t, "settings.display", asMap(t, settings["display"]), - []string{"background", "logo", "title", "subtitle", "primaryColor", "rotateSeconds", "announce", "fontTitle", "fontBody"}) + []string{"background", "logo", "title", "subtitle", "primaryColor", "rotateSeconds", "announce", "fontTitle", "fontBody", "liveEvents", "liveSource"}) assertKeys(t, "settings.standings", asMap(t, settings["standings"]), []string{"individualToTeamWeight", "rankingMode", "showIndividualBoard", "showIndividualMedals"}) diff --git a/server/main.go b/server/main.go index 94017a8..188fe83 100644 --- a/server/main.go +++ b/server/main.go @@ -28,8 +28,12 @@ import ( "git.yeij.top/AskaEth/Scoreboard/server/internal/store" ) -// version 由构建时注入:-ldflags "-X main.version=..." -var version = "dev_0.1.0" +// version 是服务端版本号,供 /api/health 与管理端「关于」页使用。 +// 真实版本来自仓库根的 VERSION 文件,由 deploy/build.bat 在构建时用 +// -ldflags "-X main.version=..." 注入;下面这行只是没走构建脚本(例如 go run)时的兜底。 +// 兜底值故意不写具体版本号:写死一个数字迟早会和 VERSION 漂移,报出一个"看着像真版本"的假值; +// 这样直接跑源码时一眼就能看出「这不是构建产物」。 +var version = "dev" func main() { if len(os.Args) > 1 && os.Args[1] == "backup" { diff --git a/server/migrations/event/0019_entries_heat.sql b/server/migrations/event/0019_entries_heat.sql new file mode 100644 index 0000000..9152c47 --- /dev/null +++ b/server/migrations/event/0019_entries_heat.sql @@ -0,0 +1,3 @@ +-- 批次是「组别之下」的录入 / 展示分组,不参与算分(排名与积分仍按「小项 + 组别」算)。 +-- 历史报名没有批次,允许为空;空值(NULL)表示「未分批」。 +ALTER TABLE entries ADD COLUMN heat INTEGER; diff --git a/server/migrations/event/migration_test.go b/server/migrations/event/migration_test.go index ade91b7..87d661a 100644 --- a/server/migrations/event/migration_test.go +++ b/server/migrations/event/migration_test.go @@ -529,3 +529,90 @@ func TestMigration0018AddsDeletedAtForExistingTeams(t *testing.T) { t.Fatalf("清 deleted_at: %v", err) } } + +// 老库升级:0019 只给 entries 补一列 heat(可空),已有报名一行不少、批次一律为空(未分批)。 +// 批次只是录入 / 展示分组,迁移不能改动任何成绩或积分数据。 +func TestMigration0019AddsHeatForExistingEntries(t *testing.T) { + db := openEventDB(t) + applyBefore(t, db, "0019") + + stmts := []string{ + `INSERT INTO teams(code, name) VALUES ('T1', '第一分公司')`, + `INSERT INTO athletes(name, team_id) VALUES ('张三', 1)`, + `INSERT INTO sports(code, name) VALUES ('SP1', '田径')`, + `INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, status) + VALUES (1, 'E1', '100米跑', 'individual', 'auto', 'time', 'asc', 'published')`, + `INSERT INTO event_categories(event_id, name) VALUES (1, '男子')`, + `INSERT INTO entries(event_id, category_id, team_id, athlete_id) VALUES (1, 1, 1, 1)`, + `INSERT INTO results(event_id, entry_id, rank, result_text, result_value, state) + VALUES (1, 1, 1, '11.58', 11.58, 'published')`, + `INSERT INTO score_ledger(team_id, athlete_id, event_id, entry_id, result_id, source_type, source_id, points) + VALUES (1, 1, 1, 1, 1, 'result', 1, 10)`, + } + for _, s := range stmts { + if _, err := db.Exec(s); err != nil { + t.Fatalf("造数据 %q: %v", s, err) + } + } + + var probe sql.NullInt64 + if err := db.QueryRow(`SELECT heat FROM entries`).Scan(&probe); err == nil { + t.Fatal("升级前不该有 heat 列") + } + + // 走真实升级路径;ALTER TABLE ADD COLUMN 只登记一次,重放会报 duplicate column。 + for i := 1; i <= 2; i++ { + if err := store.MigrateEvent(db); err != nil { + t.Fatalf("第 %d 次迁移: %v", i, err) + } + } + if got, want := countRows(t, db, "schema_migrations"), migrationCount(t); got != want { + t.Errorf("迁移登记数 = %d,期望 %d", got, want) + } + for table, want := range map[string]int{ + "entries": 1, "results": 1, "score_ledger": 1, + } { + if got := countRows(t, db, table); got != want { + t.Errorf("%s 升级后行数 = %d,期望 %d", table, got, want) + } + } + + // 老报名一律「未分批」,成绩与积分原样保留。 + if err := db.QueryRow(`SELECT heat FROM entries WHERE id = 1`).Scan(&probe); err != nil { + t.Fatalf("读 entries.heat: %v", err) + } + if probe.Valid { + t.Errorf("老报名的 heat 应为空(未分批),得到 %d", probe.Int64) + } + var ( + rank int + state string + ) + if err := db.QueryRow(`SELECT rank, state FROM results WHERE entry_id = 1`).Scan(&rank, &state); err != nil { + t.Fatalf("读成绩: %v", err) + } + if rank != 1 || state != "published" { + t.Errorf("老成绩被改动: rank=%d state=%q", rank, state) + } + var points float64 + if err := db.QueryRow(`SELECT points FROM score_ledger WHERE entry_id = 1`).Scan(&points); err != nil { + t.Fatalf("读流水: %v", err) + } + if points != 10 { + t.Errorf("老积分被改动: %v", points) + } + + // 新列可写可清。 + if _, err := db.Exec(`UPDATE entries SET heat = 3 WHERE id = 1`); err != nil { + t.Fatalf("写 heat: %v", err) + } + if err := db.QueryRow(`SELECT heat FROM entries WHERE id = 1`).Scan(&probe); err != nil { + t.Fatalf("读 entries.heat: %v", err) + } + if !probe.Valid || probe.Int64 != 3 { + t.Errorf("写入的 heat 未生效: %+v", probe) + } + if _, err := db.Exec(`UPDATE entries SET heat = NULL WHERE id = 1`); err != nil { + t.Fatalf("清 heat: %v", err) + } +} diff --git a/web/src/admin/api/client.ts b/web/src/admin/api/client.ts index 6bac819..28ca61e 100644 --- a/web/src/admin/api/client.ts +++ b/web/src/admin/api/client.ts @@ -6,10 +6,13 @@ import type { AuditLog, CurrentUser, DisplayCommand, + EntryHeatPayload, + EntryHeatResult, EntryWithResult, Event, EventSettings, HealthInfo, + LiveEvents, Page, PageQuery, PointAdjustment, @@ -139,6 +142,13 @@ export const adminApi = { entries: (eventId: number): Promise => call(() => client.events.entries(eventId), () => mockDb.listEntries(eventId)), + /** + * 批量设置 / 清除批次(heat)。写操作不降级:示例数据模式下没有后端可落库, + * 页面会先看 data.isMock 再决定是否调用。 + */ + setEntriesHeat: (payload: EntryHeatPayload): Promise => + callWrite(() => client.entries.setHeat(payload)), + saveDraft: (payload: SaveDraftPayload): Promise => callWrite(() => client.results.saveDraft(payload)), @@ -198,6 +208,18 @@ export const adminApi = { () => [], ), + /** + * 「实时赛况」当前项目(服务端按设置与时序判定)。 + * + * 判定的依据是排期与最近一次发布,随赛事进程自己变化;示例数据里没有这套时序, + * 连不上时给空结论、由页面显示「当前没有可显示的项目」,比弹错更有用。 + */ + displayLive: (): Promise => + call( + () => client.display.live(), + () => ({ source: 'auto', eventIds: [], events: [] }), + ), + login: (username: string, password: string): Promise => callWrite(() => client.auth.login(username, password)), diff --git a/web/src/admin/api/mock.ts b/web/src/admin/api/mock.ts index e65bb84..7b8b92c 100644 --- a/web/src/admin/api/mock.ts +++ b/web/src/admin/api/mock.ts @@ -369,6 +369,7 @@ class MockDb { memberNames: seed.eventType === 'individual' ? null : [`${team.shortName ?? team.name}代表队`], bib: seed.eventType === 'individual' ? String(entryId % 1000) : null, seed: null, + heat: null, status: 'registered', remark: null, athleteName, diff --git a/web/src/admin/components/baseData/ImportSection.vue b/web/src/admin/components/baseData/ImportSection.vue index 35f4ea5..15fcfc6 100644 --- a/web/src/admin/components/baseData/ImportSection.vue +++ b/web/src/admin/components/baseData/ImportSection.vue @@ -20,11 +20,12 @@ import { const data = useBaseData() const toast = useToastStore() +// heat 列是批次:留空 = 未分批,也可以直接填正整数(如 1、2)。 const TEMPLATE_CSV = [ REQUIRED_COLUMNS.join(','), - 'M100,男子组,F1,张伟,110101199001011234,男,26,101,,registered,', - 'W100,女子组,F2,王芳,,女,,202,,registered,', - 'BB,混合组,F2,,,,,,种子1,registered,第一分公司代表队', + 'M100,男子组,F1,张伟,110101199001011234,男,26,101,,1,registered,', + 'W100,女子组,F2,王芳,,女,,202,,2,registered,', + 'BB,混合组,F2,,,,,,种子1,,registered,第一分公司代表队', ].join('\n') const MAX_SIZE = 16 * 1024 * 1024 @@ -189,8 +190,9 @@ function downloadTemplate(): void {
- 表头为:{{ REQUIRED_COLUMNS.join(' / ') }}(大小写不敏感)。id_card、gender、age 可缺省, + 表头为:{{ REQUIRED_COLUMNS.join(' / ') }}(大小写不敏感)。id_card、gender、age、heat 可缺省, 但有姓名的行必须填证件号或号码布之一,否则该行会报错并跳过。年龄可留空,填了必须是 0–120 的整数。 + heat 是批次号:可留空(表示未分批),填了必须是 1–999 的整数;批次只用于录入 / 展示分组,不影响名次与积分。 证件号与号码布都是人员唯一标识: 先按证件号匹配,未命中再按号码布匹配,仍未命中则按「姓名 + 单位」匹配库里证件号为空的人并回填。 diff --git a/web/src/admin/components/baseData/entrySelection.ts b/web/src/admin/components/baseData/entrySelection.ts index e155fae..7c9d9c3 100644 --- a/web/src/admin/components/baseData/entrySelection.ts +++ b/web/src/admin/components/baseData/entrySelection.ts @@ -13,7 +13,11 @@ import type { ImportConflictField, } from '@/shared' -/** 与服务端 handlers_config.go 的 importColumns 一致,同时也是 CSV 模板表头。 */ +/** + * 与服务端 handlers_config.go 的 importColumns 一致,同时也是 CSV 模板表头。 + * + * heat 是批次列:可选,留空表示「未分批」;批次只做录入 / 展示分组,不参与算分。 + */ export const REQUIRED_COLUMNS = [ 'event_code', 'category', @@ -24,12 +28,13 @@ export const REQUIRED_COLUMNS = [ 'age', 'bib', 'seed', + 'heat', 'status', 'member_names', ] as const /** 可选列:表头缺失不报错,但 athlete_name 非空的行必须带 id_card 或 bib 之一。 */ -export const OPTIONAL_COLUMNS = ['id_card', 'gender', 'age'] as const +export const OPTIONAL_COLUMNS = ['id_card', 'gender', 'age', 'heat'] as const /** 必须存在的表头;服务端缺任一列会直接拒绝整份文件。 */ const OPTIONAL_SET: readonly string[] = OPTIONAL_COLUMNS @@ -92,6 +97,8 @@ export function buildSelectionRows(input: SelectionInput): string[][] { '', athlete.bib ?? '', '', + // 批次留空 = 未分批:批次在录入页按现场编排(批量设置 / 一键均分)。 + '', status, '', ]) @@ -142,6 +149,8 @@ export function buildTeamSelectionRows(input: TeamSelectionInput): string[][] { '', '', '', + // 批次留空 = 未分批,与个人项目一致。 + '', status, team.memberNames.join('/'), ]) diff --git a/web/src/admin/layouts/AdminLayout.vue b/web/src/admin/layouts/AdminLayout.vue index 813a03f..3fbd996 100644 --- a/web/src/admin/layouts/AdminLayout.vue +++ b/web/src/admin/layouts/AdminLayout.vue @@ -118,6 +118,12 @@ onMounted(async () => {