Files
Scoreboard/deploy/prepare-extra.mjs
T
AskaEth 877ff15823 feat(0.5.1): 业务逻辑审查 9 条 P0 全修 + 自绘安装器 + 现场反馈四项
## 算分正确性(6 份只读审查 → 收敛成 5 个病根,均先复现失败再修)
- 「人工名次」与「自动名次回写」互斥:原来同一份成绩**发布两次分数不同**
  (团体报名项 dual 通道 10/8/6 → 25/18/14),现在连发多次逐字节一致
- 删小项时在同一事务作废旧流水并推榜:原来分数**永久残留**在总榜、任何重算都打不到
- 规则包导入整体事务化:原来中途失败会清空名次表且补不回(奖牌全丢);
  另加守卫「compare=none 的小项不得配 basis=result/aggregate」
- 末位归零只抹真正落在末位的那一档:原来跨末位的并列组会被整组抹 0
  (7 人三人并列第 5 时第 5、6 名的分也一起没了)
- 模板「分值表档位超出录取名次」现在拒绝保存:原来静默截断成 0 分

## 现场反馈(主人实测报的)
- 已发布成绩可改:行内「修改本条」+ 成绩格/状态格可点(退回草稿→改→重新发布本组),
  与「作废」语义分开;取消路径不产生任何副作用
- 单位积分明细补「运动员 / 来源」列,并显示「已修订 ×N(时间·原因)」:
  管理端、大屏覆盖层、xlsx 备注列三处同源
- 单位奖牌榜改为按奖牌独立排名(金→银→铜→单位编码),与积分榜解耦
- 历史孤儿积分流水清理(迁移 0022,只动 result/record_bonus,人工调整分不受影响)

## 新功能
- 团体项目聚合口径二选一:平均(默认)/ 总和(迁移 0023,通道编辑器可切)

## 安装器
- 自绘五页安装界面:无边框、自绘标题栏、DPI 自适应(4K@200% 实测)、
  安装/卸载逻辑复用 electron-builder 宏不变;默认「为所有用户安装」并自动放行防火墙 19540
- 构建期校验:安装器素材里的版本号必须与 VERSION 一致,否则构建失败

自检:服务端 14 包 ×2 / 前端 vue-tsc + build + 742 例 ×2 / Console 20 文件 + 68 例 ×2,全绿。
端到端对拍:修前 vs 修后,纯团体 / 双计 / 普通个人三形态**逐字节一致**,仅出 bug 的双通道形态按要求改变。
2026-09-13 15:20:18 +08:00

232 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 构建前准备三样东西:
// 1) 把 docs/ 下的使用说明与报名名单导入规范暂存到 console/extra/,供 electron-builder 的 extraFiles 平铺到部署包根目录;
// 2) 生成安装器自定义脚本(console/resources/installer-custom.nsh):许可页顶部文案(署名来自构建机本地文件)
// + 记住上次安装目录(重装时沿用,而不是退回系统默认路径)
// + 按安装模式放行/回收防火墙入站 19540(「为所有用户安装」那一趟本来就提了权,顺手做掉,
// 省得现场再拿管理员 cmd 手工敲 netsh);
// 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'
const root = dirname(import.meta.dirname)
// ---- 1. usage docs ----------------------------------------------------------
// 都暂存成 .txt:部署包根目录里给现场双击打开,.md 在记事本里全是符号。
// 源文件优先取 docs/ 下同名 .txt(便于临时替换),否则用仓库里的 .md。
const extraDir = join(root, 'console', 'extra')
mkdirSync(extraDir, { recursive: true })
const docsToStage = [
{ target: '使用说明.txt', sources: ['使用说明.txt', '使用手册.md'], label: 'usage doc' },
{ target: '报名名单导入规范.txt', sources: ['报名名单导入规范.md'], label: 'entry import spec' },
// 升级须知也要随包走:现场覆盖安装前能在安装目录里先看到「这一版改了什么、要补什么」。
{ target: '升级说明.txt', sources: ['升级说明.md'], label: 'upgrade notes' },
]
for (const doc of docsToStage) {
const source = doc.sources.map((name) => join(root, 'docs', name)).find((file) => existsSync(file))
if (!source) {
console.log(` [docs] no ${doc.label}, skip`)
continue
}
copyFileSync(source, join(extraDir, doc.target))
console.log(` ${doc.label} staged from ${source}`)
}
// ---- 1b. 自绘安装器脚本的编码检查 -------------------------------------------
// 本机手工跑 makensis 时必须带 UTF-8 BOM,否则脚本里的中文直接被判成 Bad text encoding;
// electron-builder 那边传了 -INPUTCHARSET UTF8,构建机上没 BOM 也能过 —— 两边口径不一致,
// 出问题时只会表现为「本地编不过、构建机编得过」这种最难查的差异,所以统一要求带 BOM。
const installerScript = join(root, 'console', 'installer', 'installer.nsi')
if (existsSync(installerScript)) {
const head = readFileSync(installerScript).subarray(0, 3)
if (!(head[0] === 0xef && head[1] === 0xbb && head[2] === 0xbf)) {
console.error(` [installer] ${installerScript} 缺少 UTF-8 BOM:本机 makensis 会报 Bad text encoding`)
console.error(' [installer] 修复:用编辑器另存为「UTF-8 with BOM」,或跑 python3 -c "…" 补 BOM')
process.exit(1)
}
console.log(' installer script has UTF-8 BOM')
}
// ---- 2. installer custom script ---------------------------------------------
// electron-builder 的 nsis.include 只收一个文件,所以许可页文案、安装目录记忆与防火墙规则写在同一个 .nsh 里。
const signatureFile = join(root, 'deploy', 'signature.local.txt')
const nshDir = join(root, 'console', 'resources')
const nshFile = join(nshDir, 'installer-custom.nsh')
const legacyNshFile = join(nshDir, 'installer-signature.nsh')
mkdirSync(nshDir, { recursive: true })
let topText = '请阅读并接受许可协议'
if (existsSync(signatureFile)) {
const signature = readFileSync(signatureFile, 'utf8').trim()
if (signature !== '') topText = `内部署名:${signature}`
}
// 换行用 CRLF:.gitattributes 里 *.nsh 就是 CRLF,NSIS 在 Windows 上按CRLF 读最稳。
const nsh = [
'!include "LogicLib.nsh"',
'',
'; 安装器自定义脚本:由 deploy/prepare-extra.mjs 生成,不要手改。',
'; 内容:许可页文案、上次安装目录记忆、防火墙入站规则(装时放行 / 卸时回收)、卸载钩子。',
'',
'; 自己记的安装目录(per-user 落 HKCU,per-all-users 落 HKLM)。',
'; electron-builder 自带的 ${INSTALL_REGISTRY_KEY} 在卸载时会被整键删掉,',
'; 「先卸载再装新版」或换过安装模式就会退回系统默认路径,所以另存一份不会被卸载器清理的。',
'!define SCOREBOARD_INSTALL_KEY "Software\\Scoreboard Console"',
'',
'; 防火墙入站规则的规则名:安装与卸载共用这一个字面量,改它两处一起改(名字对不上就删不掉)。',
'; 端口写死安装包的默认端口:现场改过 config.yaml 里的 server.port 时,这条规则对不上实际端口,',
'; 得按实际端口现场再放行一次(见 docs/部署指南.md 7.5)。',
'!define SCOREBOARD_FIREWALL_RULE "Scoreboard Console (TCP 19540)"',
'!define SCOREBOARD_FIREWALL_PORT "19540"',
'',
'!macro customHeader',
` !define MUI_LICENSEPAGE_TEXT_TOP "${topText}"`,
'!macroend',
'',
'!macro customInit',
' ; 在 .onInit 里、initMultiUser 之后执行:此时改 $INSTDIR 还来得及影响后面的目录页。',
' ReadRegStr $R9 HKCU "${SCOREBOARD_INSTALL_KEY}" "InstallLocation"',
' ${If} $R9 != ""',
' StrCpy $INSTDIR $R9',
' ; 安装模式页(per-user / per-all-users)离开时,electron-builder 会用自己那个键',
' ; 重新算一遍 $INSTDIR;卸载后它已经被删了,把记忆值补写回同一个 hive 里。',
' ; 不跨 hive 写:否则会把 per-machine 装的机器悄悄带成默认 per-user 模式(反之亦然)。',
' WriteRegStr HKCU "${INSTALL_REGISTRY_KEY}" "InstallLocation" "$R9"',
' ${Else}',
' ReadRegStr $R9 HKLM "${SCOREBOARD_INSTALL_KEY}" "InstallLocation"',
' ${If} $R9 != ""',
' StrCpy $INSTDIR $R9',
' WriteRegStr HKLM "${INSTALL_REGISTRY_KEY}" "InstallLocation" "$R9"',
' ${Else}',
' ; 没有自己的记忆时,退回 electron-builder 的键(老版本装过、没卸载过的情况)',
' ReadRegStr $R9 HKCU "${INSTALL_REGISTRY_KEY}" "InstallLocation"',
' ${If} $R9 == ""',
' ReadRegStr $R9 HKLM "${INSTALL_REGISTRY_KEY}" "InstallLocation"',
' ${EndIf}',
' ; 只认非空值;旧目录已经被删掉也照样用(这就是「记住我的路径」的语义)。',
' ${If} $R9 != ""',
' StrCpy $INSTDIR $R9',
' ${EndIf}',
' ${EndIf}',
' ${EndIf}',
' ClearErrors',
'!macroend',
'',
'!macro customInstall',
' ; SHELL_CONTEXT 跟随安装模式:per-user → HKCU,per-all-users → HKLM。',
' WriteRegStr SHELL_CONTEXT "${SCOREBOARD_INSTALL_KEY}" "InstallLocation" "$INSTDIR"',
'',
' ; 放行防火墙入站端口,只在「为所有用户安装」这一路做。依据:',
' ; $installMode 由 electron-builder 的安装模式页写定(multiUser.nsh 里就是 "all" / "CurrentUser");',
' ; 选「为所有用户安装」时安装器先弹 UAC 提权、提权成功的那个进程才继续跑安装段',
' ; (multiUserUi.nsh 里对「all users」单选钮的处理:UAC_RunElevated,外层进程直接 Quit),',
' ; 所以走到这里时进程已经是管理员,netsh 才有权限改防火墙。',
' ; 选「仅为我安装」全程不提权(installer.nsi 里 RequestExecutionLevel user),netsh 必然失败,',
' ; 所以这里连试都不试:省一次注定失败的调用,也不往安装日志里塞看不懂的报错。',
' ; UAC_IsAdmin 再确认一次当前确实是提权进程(宏来自 UAC.nsh,multiUser.nsh 已经 include 过),',
' ; 两个条件都成立才动手,任何情况下都不会拿普通用户权限去调 netsh。',
' ${If} $installMode == "all"',
' ${AndIf} ${UAC_IsAdmin}',
' ; 先删后加:覆盖安装/重装时把上一条同名规则清掉,免得同名规则越积越多。',
' ; ExecToLog = 隐藏执行、不弹黑窗,输出只进安装日志(装完展开「显示细节」才看得到)。',
' nsExec::ExecToLog \'netsh advfirewall firewall delete rule name="${SCOREBOARD_FIREWALL_RULE}"\'',
' Pop $0',
' ; 失败一律忽略:netsh 返回非 0(被组策略禁用、杀软拦截、防火墙服务被拆……)就只留一行日志,',
' ; 绝不能因为防火墙这一步让安装失败——规则没加上,现场还能按 docs/部署指南.md 7.5 手工补。',
' nsExec::ExecToLog \'netsh advfirewall firewall add rule name="${SCOREBOARD_FIREWALL_RULE}" dir=in action=allow protocol=TCP localport=${SCOREBOARD_FIREWALL_PORT}\'',
' Pop $0',
' ClearErrors',
' ${EndIf}',
'!macroend',
'',
'!macro customUnInstall',
' ; 安装目录记忆故意不删:需求就是「卸载后重装仍沿用上次的目录」。',
' ; 若改成卸载即遗忘,在这里补两行(卸载时 SHELL_CONTEXT 不一定已设定,要写死根键):',
' ; DeleteRegValue HKCU "${SCOREBOARD_INSTALL_KEY}" "InstallLocation"',
' ; DeleteRegValue HKLM "${SCOREBOARD_INSTALL_KEY}" "InstallLocation"',
'',
' ; 回收安装时加的那条入站规则。卸载走哪条路要看当初是怎么装的:',
' ; 按「为所有用户安装」装的:卸载器进卸载段之前会先 UAC 提权',
' ; (multiUserUi.nsh 里发现只有 per-machine 安装、且当前不是管理员时 UAC_RunElevated),',
' ; 当前进程是管理员,netsh 能删掉;',
' ; 按「仅为我安装」装的:全程不提权,netsh 必然失败——需求是「失败就算了」。',
' ; 所以这里只判断「是不是管理员」,不判断安装模式:非管理员时连试都不试,不报错、不留提示,',
' ; 免得卸载日志里多出一行用户看不懂的 netsh 报错。',
' ${If} ${UAC_IsAdmin}',
' nsExec::ExecToLog \'netsh advfirewall firewall delete rule name="${SCOREBOARD_FIREWALL_RULE}"\'',
' Pop $0',
' ClearErrors',
' ${EndIf}',
'!macroend',
'',
].join('\r\n')
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)
}
// ---- 3b. 安装器素材的版本号核对 ---------------------------------------------
// 安装界面的版本号是「烘焙进位图」的(console/installer/art/*/brand.bmp 的品牌栏),
// 位图里读不出文本,所以 gen_art.py 另外写了一份 art/version.txt 作旁证。
// 不核对的话会出现「安装界面写着 v0.5.1、装的其实是 0.5.2」——这种错只在装完之后
// 被人一眼看到,而且很容易随包发出去;宁可构建直接失败,也不要发错版本号。
const artDir = join(root, 'console', 'installer', 'art')
if (existsSync(artDir)) {
const artVersionFile = join(artDir, 'version.txt')
const artVersion = existsSync(artVersionFile) ? readFileSync(artVersionFile, 'utf8').trim() : ''
const wantVersion = version.replace(/^dev_/, '')
if (artVersion !== wantVersion) {
console.error(` [art] 安装器素材里的版本号与 VERSION 对不上:素材=${artVersion || '(缺失)'},VERSION=${wantVersion}`)
console.error(' [art] 修复:跑 python3 console/installer/art-gen/gen_art.py 重新生成素材,然后提交')
process.exit(1)
}
console.log(` installer art version matches (${wantVersion})`)
}
// 必须纯 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}`)
}