diff --git a/.gitignore b/.gitignore index 17aa624..20e7026 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,11 @@ logs/ # 前端 web/node_modules/ web/dist/ +console/node_modules/ + +# 前端构建产物(构建时生成,随 Go embed 内嵌) +server/internal/webui/assets/* +!server/internal/webui/assets/.gitkeep # 编辑器 / 系统 .vscode/* diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..13cff93 --- /dev/null +++ b/config.yaml @@ -0,0 +1,10 @@ +# 服务端启动配置 +server: + host: 0.0.0.0 + port: 8080 + +# 运行时数据目录(数据库 / 备份 / 上传资源) +dataDir: data + +log: + level: info # debug | info | warn | error diff --git a/console/README.md b/console/README.md new file mode 100644 index 0000000..9095f72 --- /dev/null +++ b/console/README.md @@ -0,0 +1,126 @@ +# Scoreboard Console + +赛事积分榜系统的桌面宿主(Electron)。它只做四件事: + +1. 拉起并守护同目录(或配置指定)的 `scoreboard.exe`,把它的 stdout/stderr 接管到管理窗口与日志文件; +2. 用 `screen.getAllDisplays()` 枚举显示器,把大屏页面投到扩展屏; +3. `powerSaveBlocker.start('prevent-display-sleep')` 防熄屏; +4. 提供启动/停止/状态/日志的管理 GUI 和托盘菜单。 + +规范来源:`devdocs/06-大屏端设计.md` 第 6 节、`devdocs/10-部署与现场方案.md` 第 4 节。 + +## 目录结构 + +``` +console/ +├── package.json # main 指向 main/index.js;electron-builder 配置 +├── config.json # 运行配置(端口 / exe 路径 / 选屏策略) +├── main/ +│ ├── index.js # 应用入口:窗口 / 托盘 / IPC / 生命周期 +│ ├── config.js # 配置加载与合并、exe 路径解析 +│ ├── service.js # 服务端子进程守护:spawn / 日志转发 / 健康检查 / 自动重启 +│ ├── displays.js # 大屏窗口管理:选屏、kiosk、排布、24h 重载 +│ ├── log-bus.js # 日志环形缓冲 + 落文件 + 广播 +│ └── tray-icon.js # 托盘图标(base64 内嵌,无外部资源文件) +└── renderer/ + ├── index.html # 管理窗口 + ├── styles.css + ├── app.js + └── preload.js # contextBridge 白名单 +``` + +## 运行方式 + +```bash +cd console +npm install # 首次需要下载 Electron +npm start # 等价于 electron . +``` + +开发时把 `scoreboard.exe` 放在以下任一位置都能被找到(`config.json` 里的相对路径会按顺序探测): + +1. `console/` +2. 当前工作目录 +3. 仓库根目录 +4. `dist/` + +现场(部署包)形态: + +``` +scoreboard-windows-amd64/ +├── Scoreboard Console.exe +├── resources/app.asar # 内含 main/ renderer/ config.json +├── config.json # exe 旁边的可编辑配置 +├── scoreboard.exe +├── config.yaml +├── rules/ +├── data/ +└── logs/console.log # 运行时生成 +``` + +打包后 `config.json` 读取顺序:`SCOREBOARD_CONSOLE_CONFIG` 环境变量 → exe 同目录的 `config.json`。 + +## 行为说明 + +| 场景 | 行为 | +| --- | --- | +| 只有一块屏 | 窗口化(屏幕 80% 大小、居中),不做多屏编排,**不挂** `display-metrics-changed` 等监听(行为冻结);`F11` 手动切换全屏 | +| 多块屏 | 除主屏外每块屏一个 kiosk 窗口,按 `display.bounds`(DIP)定位,窗口 `setIgnoreMouseEvents(true)`,光标由页面 CSS 隐藏 | +| 选屏 | 运行时枚举,不写死坐标;`selection` 可选 `all-secondary`(默认)/ `all` / `specified` | +| 页面锁定 | 每块屏各锁一页,URL 形如 `http://127.0.0.1:8080/display?page=standings&lock=1&screen=&instance=-`,页序按 `display.pages` 轮转 | +| 防熄屏 | Console 启动即生效,退出时释放;前台后台都算 | +| 服务端就绪 | 首次 `/api/health` 通过后开大屏窗口;等待超过 `display.waitForServerMs` 也先开,避免现场只见桌面 | +| 服务端崩溃 | 按 `restartDelayMs` 递增退避自动重启(`maxRestarts=0` 为无限);恢复健康后自动重载大屏 | +| 关管理窗口 | 只隐藏到托盘,服务端继续运行;退出必须走托盘「退出」 | +| 日志 | 内存环形缓冲(`log.maxLines`)+ 文件 `logs/console.log`(超 `maxFileBytes` 轮转一份 `.1`) | + +## 配置项(`config.json`) + +| 键 | 默认值 | 说明 | +| --- | --- | --- | +| `server.exe` | `scoreboard.exe` | 相对路径按上文顺序探测,也可写绝对路径 | +| `server.args` | `["-config","config.yaml"]` | 传给服务端的参数 | +| `server.host` / `server.port` | `127.0.0.1` / `8080` | 健康检查与管理面板地址(`0.0.0.0` 会自动换成回环地址探测) | +| `server.autoStart` | `true` | Console 启动后是否立刻拉起服务端 | +| `server.healthIntervalMs` | `5000` | 健康检查间隔 | +| `server.restartDelayMs` | `3000` | 重启基础退避,每次翻倍,最多 5 倍 | +| `server.maxRestarts` | `0` | 0 = 无限重启 | +| `display.selection` | `all-secondary` | `all-secondary` / `all` / `specified` | +| `display.displays` | `[]` | `specified` 时生效,填 display id 或 0 起序号 | +| `display.pages` | `["standings","medals","individual","results","records"]` | 按屏序轮转锁定 | +| `display.pageParam` | `page` | 页面 URL 参数名 | +| `display.kiosk` | `true` | 多屏时是否 kiosk(单屏恒窗口化) | +| `display.ignoreMouse` | `true` | 多屏窗口是否忽略鼠标 | +| `display.reloadEveryHours` | `24` | 定时重载兜底,0 = 关闭 | +| `display.waitForServerMs` | `20000` | 服务端未就绪时开大屏的兜底等待 | +| `display.backgroundColor` | `#0b0f1a` | 窗口底色,减少加载白闪 | +| `admin.width` / `admin.height` | `1040` / `760` | 管理窗口初始尺寸 | +| `log.maxLines` / `log.file` / `log.maxFileBytes` | `2000` / `logs/console.log` / `4194304` | 日志缓冲与文件 | + +配置缺项自动用内置默认值,文件不存在或 JSON 语法错误时回落到全默认值,并在管理窗口顶部给出提示。 + +## 打包计划(electron-builder,尚未跑通) + +`package.json` 里已写好 `build` 段: + +```bash +cd console +npm install +npm run dist # electron-builder --win --x64 → ../dist/console/ +npm run pack # 只出目录,不生成安装包,便于现场调试 +``` + +- 产物:NSIS 安装包 + zip(`Scoreboard Console-0.1.0-x64.*`)。 +- `extraFiles` 会把 `../dist/scoreboard.exe` 与 `../config.yaml` 拷到安装目录,最终形成第 4 节里的部署包形态。 +- 图标资源目录 `build/` 尚未提供,暂用 Electron 默认图标;补上 `build/icon.ico` 后自动生效。 +- **未在 Windows 构建机上实际执行过 `npm run dist`**(本机没有安装 Electron/electron-builder,也按要求未执行 npm install)。 + +## 尚未实机验证 + +以下内容只做了静态语法检查,需要在 Windows + 真实多显示器环境跑一遍: + +- 多屏 kiosk 定位与 `display-metrics-changed` 重排(改分辨率 / 缩放 / 插拔 HDMI)。 +- `powerSaveBlocker` 在长时间后台下的实际效果。 +- 托盘图标在 Windows 任务栏的显示效果(16px 缩放)。 +- `scoreboard.exe` 真实拉起、日志转发、健康检查与崩溃重启的端到端链路。 +- electron-builder 打包与 NSIS 安装。 diff --git a/console/config.json b/console/config.json new file mode 100644 index 0000000..3c33b27 --- /dev/null +++ b/console/config.json @@ -0,0 +1,32 @@ +{ + "server": { + "exe": "scoreboard.exe", + "args": ["-config", "config.yaml"], + "host": "127.0.0.1", + "port": 8080, + "autoStart": true, + "healthIntervalMs": 5000, + "restartDelayMs": 3000, + "maxRestarts": 0 + }, + "display": { + "selection": "all-secondary", + "displays": [], + "pages": ["standings", "medals", "individual", "results", "records"], + "pageParam": "page", + "kiosk": true, + "ignoreMouse": true, + "reloadEveryHours": 24, + "waitForServerMs": 20000, + "backgroundColor": "#0b0f1a" + }, + "admin": { + "width": 1040, + "height": 760 + }, + "log": { + "maxLines": 2000, + "file": "logs/console.log", + "maxFileBytes": 4194304 + } +} diff --git a/console/main/config.js b/console/main/config.js new file mode 100644 index 0000000..4b5e886 --- /dev/null +++ b/console/main/config.js @@ -0,0 +1,95 @@ +'use strict' + +const fs = require('fs') +const path = require('path') + +// 默认值写死在代码里:config.json 只覆盖要改的项。 +// 这样升级新增字段时老配置依然能用,现场也不容易配出半成品。 +const DEFAULTS = { + server: { + exe: 'scoreboard.exe', + args: ['-config', 'config.yaml'], + host: '127.0.0.1', + port: 8080, + autoStart: true, + healthIntervalMs: 5000, + restartDelayMs: 3000, + // 0 表示无限重启;服务端是现场唯一进程,崩了必须拉起来 + maxRestarts: 0 + }, + display: { + // all-secondary | all | specified + selection: 'all-secondary', + displays: [], + pages: ['standings', 'medals', 'individual', 'results', 'records'], + pageParam: 'page', + kiosk: true, + ignoreMouse: true, + reloadEveryHours: 24, + // 服务端迟迟没就绪时的兜底:超时后也把大屏开出来,避免现场一片黑 + waitForServerMs: 20000, + backgroundColor: '#0b0f1a' + }, + admin: { + width: 1040, + height: 760 + }, + log: { + maxLines: 2000, + file: 'logs/console.log', + maxFileBytes: 4 * 1024 * 1024 + } +} + +function isPlainObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +function merge(base, override) { + const out = isPlainObject(base) ? { ...base } : {} + if (!isPlainObject(override)) return out + for (const [key, value] of Object.entries(override)) { + if (value === undefined) continue + out[key] = isPlainObject(value) && isPlainObject(base[key]) ? merge(base[key], value) : value + } + return out +} + +function readJson(file) { + try { + const raw = fs.readFileSync(file, 'utf8') + return { values: JSON.parse(raw), warning: null } + } catch (err) { + if (err.code === 'ENOENT') return { values: {}, warning: `配置文件不存在,使用内置默认值:${file}` } + return { values: {}, warning: `配置文件无法解析,使用内置默认值:${file}(${err.message})` } + } +} + +function loadConfig(configPath) { + const { values, warning } = readJson(configPath) + return { + path: configPath, + values: merge(DEFAULTS, values), + warning + } +} + +// 相对路径的 exe 需要按「部署包根目录」理解,而部署包根目录在不同形态下不同: +// 开发时是 console/ 的上级,打包后是 exe 所在目录。逐个候选位置探测,避免写死。 +function resolveExecutable(exe, configPath) { + if (!exe) return { path: null, candidates: [] } + if (path.isAbsolute(exe)) return { path: exe, candidates: [exe] } + + const configDir = path.dirname(configPath) + const candidates = [ + configDir, + process.cwd(), + path.resolve(configDir, '..'), + path.resolve(configDir, '..', 'dist') + ].map((dir) => path.resolve(dir, exe)) + + const found = candidates.find((candidate) => fs.existsSync(candidate)) + return { path: found || candidates[0], candidates: [...new Set(candidates)] } +} + +module.exports = { DEFAULTS, loadConfig, merge, resolveExecutable } diff --git a/console/main/displays.js b/console/main/displays.js new file mode 100644 index 0000000..0005ff6 --- /dev/null +++ b/console/main/displays.js @@ -0,0 +1,276 @@ +'use strict' + +const os = require('os') +const { BrowserWindow, screen } = require('electron') + +const SELECTION = { + ALL_SECONDARY: 'all-secondary', + ALL: 'all', + SPECIFIED: 'specified' +} + +// 大屏窗口管理:按显示器枚举结果开窗,绝不写死坐标。 +// 坐标一律用 DIP(display.bounds),页面内部再用 CSS 缩放适配,两边互不干扰。 +class DisplayManager { + constructor({ values, server, log }) { + this.display = values.display + this.server = server + this.log = log + + this.enabled = false + this.single = true + this.boundSingle = null + this.windows = new Map() // displayId -> { win, page, reloadTimer } + this.screenListeners = [] + this.layoutTimer = null + } + + enable() { + if (this.enabled) return + this.enabled = true + this.layout() + } + + pageFor(index) { + const pages = Array.isArray(this.display.pages) && this.display.pages.length ? this.display.pages : ['standings'] + // 单屏没有编排可言,固定锁第一页(默认总积分榜) + if (this.single) return pages[0] + return pages[index % pages.length] + } + + pickDisplays() { + const all = screen.getAllDisplays() + const primary = screen.getPrimaryDisplay() + + // 单屏机器上谈「扩展屏编排」没有意义,直接窗口化 + if (all.length <= 1) return { single: true, displays: all.slice(0, 1) } + + const selection = this.display.selection || SELECTION.ALL_SECONDARY + let picked + if (selection === SELECTION.ALL) { + picked = all.slice() + } else if (selection === SELECTION.SPECIFIED) { + picked = this.pickSpecified(all) + } else { + picked = all.filter((item) => item.id !== primary.id) + } + + // 只有整机就一块屏时才是「单屏测试模式」;笔记本 + 一块扩展屏属于多屏场景, + // 扩展屏要正常 kiosk,不能退化成窗口化 + if (!picked.length) return { single: true, displays: [primary] } + return { single: false, displays: picked } + } + + // displays 配置里可以写 display.id,也可以写 0 起的序号 + pickSpecified(all) { + const wanted = Array.isArray(this.display.displays) ? this.display.displays : [] + const picked = [] + for (const key of wanted) { + const match = typeof key === 'number' ? all.find((item) => item.id === key) || all[key] : all.find((item) => String(item.id) === String(key)) + if (match && !picked.includes(match)) picked.push(match) + } + return picked + } + + layout() { + if (!this.enabled) return + const { single, displays } = this.pickDisplays() + this.single = single + this.bindScreenListeners(single) + + const wanted = new Set(displays.map((item) => item.id)) + for (const [displayId, entry] of [...this.windows]) { + if (!wanted.has(displayId)) { + this.destroyEntry(displayId, entry) + } + } + + displays.forEach((display, index) => { + const page = this.pageFor(index) + const existing = this.windows.get(display.id) + if (existing) { + this.applyGeometry(existing.win, display, single) + if (existing.page !== page) { + existing.page = page + this.loadPage(existing, display, page) + } + return + } + this.windows.set(display.id, this.createWindow(display, index, single, page)) + }) + + this.log.push('console', `大屏排布完成:${single ? '单屏窗口化' : `多屏 kiosk × ${displays.length}`},显示器 ${displays.map((d) => d.id).join(', ')}`) + } + + createWindow(display, index, single, page) { + const bounds = display.bounds + const win = new BrowserWindow({ + ...this.geometryFor(display, single), + show: false, + frame: single, + kiosk: !single && this.display.kiosk !== false, + autoHideMenuBar: true, + backgroundColor: this.display.backgroundColor || '#000000', + title: `大屏 · ${page}`, + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + // 大屏常年在后台焦点之外,默认节流会冻结动画和定时器 + backgroundThrottling: false + } + }) + + // 大屏页零交互控件;窗口层再兜一道,防止现场误触鼠标 + if (!single && this.display.ignoreMouse !== false) { + win.setIgnoreMouseEvents(true) + } + + // F11 只在单屏模式下有意义(现场调试用),多屏 kiosk 不允许切走 + if (single) { + win.webContents.on('before-input-event', (event, input) => { + if (input.type === 'keyDown' && input.key === 'F11') { + event.preventDefault() + win.setFullScreen(!win.isFullScreen()) + } + }) + } + + win.once('ready-to-show', () => { + if (!win.isDestroyed()) win.show() + }) + + win.webContents.on('render-process-gone', (_event, details) => { + this.log.push('console', `大屏渲染进程异常(display=${display.id},${details.reason}),将重载`) + if (!win.isDestroyed()) win.webContents.reload() + }) + + const entry = { win, page, reloadTimer: null } + + // 无人值守跑 24h 以上,定时重载兜底内存与连接泄漏 + const hours = Number(this.display.reloadEveryHours) || 0 + if (hours > 0) { + entry.reloadTimer = setInterval(() => { + if (!win.isDestroyed()) win.webContents.reload() + }, hours * 3600 * 1000) + } + + this.applyGeometry(win, display, single) + this.loadPage(entry, display, page) + this.log.push('console', `打开大屏窗口 display=${display.id} bounds=${bounds.width}x${bounds.height}@${bounds.x},${bounds.y} page=${page}`) + return entry + } + + geometryFor(display, single) { + const bounds = display.bounds + if (!single) return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height } + // 单屏窗口化:不铺满,留出边距方便看管理窗口和调试 + const width = Math.round(bounds.width * 0.8) + const height = Math.round(bounds.height * 0.8) + return { + x: bounds.x + Math.round((bounds.width - width) / 2), + y: bounds.y + Math.round((bounds.height - height) / 2), + width, + height + } + } + + applyGeometry(win, display, single) { + if (!win || win.isDestroyed()) return + if (single) { + if (win.isKiosk()) win.setKiosk(false) + if (win.isFullScreen()) win.setFullScreen(false) + } else if (this.display.kiosk !== false) { + win.setKiosk(true) + } + win.setBounds(this.geometryFor(display, single)) + } + + loadPage(entry, display, page) { + if (!entry || !entry.win || entry.win.isDestroyed()) return + const url = this.urlFor(display, page) + this.log.push('console', `大屏加载 ${url}`) + entry.win.loadURL(url).catch((err) => { + // 服务端还没起来时加载失败是正常的,健康检查通过后会重排重载 + this.log.push('console', `大屏加载失败(display=${display.id}):${err.message}`) + }) + } + + urlFor(display, page) { + const params = new URLSearchParams() + params.set(this.display.pageParam || 'page', page) + // lock=1 告知页面锁死单页,不参与轮播 + params.set('lock', '1') + params.set('screen', String(display.id)) + params.set('instance', `${os.hostname()}-${display.id}`) + return `http://127.0.0.1:${this.server.port}/display?${params.toString()}` + } + + // 单屏模式要「行为冻结」:不跟随屏幕变化重排,所以连监听都不挂 + bindScreenListeners(single) { + if (this.screenListeners.length && this.boundSingle === single) return + this.unbindScreenListeners() + this.boundSingle = single + if (single) return + + const schedule = () => { + if (this.layoutTimer) clearTimeout(this.layoutTimer) + // 分辨率/缩放变化会连续触发多次,防抖后再重排 + this.layoutTimer = setTimeout(() => { + this.layoutTimer = null + this.layout() + }, 600) + } + + screen.on('display-metrics-changed', schedule) + screen.on('display-added', schedule) + screen.on('display-removed', schedule) + this.screenListeners = [ + ['display-metrics-changed', schedule], + ['display-added', schedule], + ['display-removed', schedule] + ] + } + + unbindScreenListeners() { + for (const [event, handler] of this.screenListeners) { + screen.removeListener(event, handler) + } + this.screenListeners = [] + } + + destroyEntry(displayId, entry) { + if (entry.reloadTimer) clearInterval(entry.reloadTimer) + if (entry.win && !entry.win.isDestroyed()) entry.win.destroy() + this.windows.delete(displayId) + this.log.push('console', `关闭大屏窗口 display=${displayId}`) + } + + relayout() { + if (!this.enabled) return + for (const [, entry] of this.windows) { + if (entry.win && !entry.win.isDestroyed()) entry.win.webContents.reload() + } + this.layout() + } + + getInfo() { + return [...this.windows.entries()].map(([displayId, entry]) => ({ + displayId, + page: entry.page, + url: entry.win && !entry.win.isDestroyed() ? entry.win.webContents.getURL() : '', + bounds: entry.win && !entry.win.isDestroyed() ? entry.win.getBounds() : null + })) + } + + closeAll() { + if (this.layoutTimer) clearTimeout(this.layoutTimer) + this.unbindScreenListeners() + for (const [displayId, entry] of [...this.windows]) { + this.destroyEntry(displayId, entry) + } + this.enabled = false + } +} + +module.exports = { DisplayManager, SELECTION } diff --git a/console/main/index.js b/console/main/index.js new file mode 100644 index 0000000..ec7eb22 --- /dev/null +++ b/console/main/index.js @@ -0,0 +1,299 @@ +'use strict' + +const path = require('path') +const { + app, + BrowserWindow, + Menu, + Tray, + ipcMain, + powerSaveBlocker, + shell +} = require('electron') + +const { loadConfig, resolveExecutable } = require('./config') +const { LogBus } = require('./log-bus') +const { ServiceSupervisor } = require('./service') +const { DisplayManager } = require('./displays') +const { createTrayIcon } = require('./tray-icon') + +// 现场只允许一个实例:两个 Console 会互相抢同一个服务端端口和同一块大屏 +if (!app.requestSingleInstanceLock()) { + app.quit() +} else { + main() +} + +function main() { + let cfg = null + let log = null + let service = null + let displays = null + let adminWindow = null + let tray = null + let powerBlockerId = null + let displayFallbackTimer = null + let displaysEnabled = false + let quitting = false + let shutdownDone = false + + function resolveConfigPath() { + if (process.env.SCOREBOARD_CONSOLE_CONFIG) { + return path.resolve(process.env.SCOREBOARD_CONSOLE_CONFIG) + } + // 打包后 config.json 放在 exe 旁边,方便现场直接改,不藏在 asar 里 + if (app.isPackaged) { + return path.join(path.dirname(app.getPath('exe')), 'config.json') + } + return path.join(__dirname, '..', 'config.json') + } + + function statusSnapshot() { + const status = service ? service.status : { state: 'stopped', stateLabel: '已停止' } + return { + ...status, + configPath: cfg ? cfg.path : null, + configWarning: cfg ? cfg.warning : null, + displays: displays ? displays.getInfo() : [], + powerSaveBlocked: powerBlockerId !== null ? powerSaveBlocker.isStarted(powerBlockerId) : false, + appVersion: app.getVersion() + } + } + + function sendToAdmin(channel, payload) { + if (adminWindow && !adminWindow.isDestroyed()) { + adminWindow.webContents.send(channel, payload) + } + } + + function broadcastStatus() { + sendToAdmin('service:status', statusSnapshot()) + } + + function showAdmin() { + if (!adminWindow || adminWindow.isDestroyed()) { + createAdminWindow() + return + } + if (adminWindow.isMinimized()) adminWindow.restore() + adminWindow.show() + adminWindow.focus() + } + + function createAdminWindow() { + const { width, height } = cfg.values.admin + adminWindow = new BrowserWindow({ + width, + height, + minWidth: 860, + minHeight: 600, + title: 'Scoreboard Console · 启动管理', + backgroundColor: '#0f1420', + show: false, + webPreferences: { + preload: path.join(__dirname, '..', 'renderer', 'preload.js'), + contextIsolation: true, + nodeIntegration: false, + sandbox: true + } + }) + + adminWindow.loadFile(path.join(__dirname, '..', 'renderer', 'index.html')) + adminWindow.once('ready-to-show', () => adminWindow.show()) + + // 关窗只隐藏,服务端和守护逻辑要继续在托盘里跑 + adminWindow.on('close', (event) => { + if (quitting) return + event.preventDefault() + adminWindow.hide() + log.push('console', '管理窗口已最小化到托盘(服务端继续运行)') + }) + + adminWindow.on('closed', () => { + adminWindow = null + }) + } + + function ensureDisplays() { + if (displaysEnabled || !displays) return + displaysEnabled = true + if (displayFallbackTimer) { + clearTimeout(displayFallbackTimer) + displayFallbackTimer = null + } + displays.enable() + } + + function buildTrayMenu() { + const status = statusSnapshot() + const busy = status.state === 'starting' || status.state === 'running' || status.state === 'stopping' + return Menu.buildFromTemplate([ + { + label: `服务端:${status.stateLabel}${status.healthy ? ' · 就绪' : ''}`, + enabled: false + }, + { type: 'separator' }, + { label: '启动服务', enabled: !busy, click: () => service.start() }, + { label: '停止服务', enabled: busy, click: () => service.stop() }, + { label: '重启服务', enabled: busy, click: () => service.restart() }, + { type: 'separator' }, + { label: '打开管理端', click: () => showAdmin() }, + { + label: '打开服务端管理面板', + click: () => shell.openExternal(status.adminUrl || `http://127.0.0.1:${cfg.values.server.port}/admin`) + }, + { type: 'separator' }, + { + label: '退出', + click: () => { + quitting = true + app.quit() + } + } + ]) + } + + function refreshTray() { + if (!tray || tray.isDestroyed()) return + const status = statusSnapshot() + tray.setToolTip(`Scoreboard Console · ${status.stateLabel}${status.healthy ? ' · 就绪' : ''}`) + tray.setContextMenu(buildTrayMenu()) + } + + function createTray() { + tray = new Tray(createTrayIcon()) + tray.setToolTip('Scoreboard Console') + tray.setContextMenu(buildTrayMenu()) + tray.on('double-click', () => showAdmin()) + } + + function registerIpc() { + ipcMain.handle('service:get-status', () => statusSnapshot()) + ipcMain.handle('service:start', () => { + service.start() + return statusSnapshot() + }) + ipcMain.handle('service:stop', async () => { + await service.stop() + return statusSnapshot() + }) + ipcMain.handle('service:restart', async () => { + await service.restart() + return statusSnapshot() + }) + ipcMain.handle('log:tail', () => (log ? log.tail() : [])) + ipcMain.handle('log:clear', () => { + if (log) log.clear() + return true + }) + ipcMain.handle('display:relayout', () => { + if (displays && displaysEnabled) displays.relayout() + return statusSnapshot() + }) + ipcMain.handle('app:open-external', (_event, url) => { + // 只放行 http/https,避免管理窗口被注入的链接拉去执行本地协议 + if (typeof url === 'string' && /^https?:\/\//i.test(url)) { + shell.openExternal(url) + return true + } + return false + }) + ipcMain.handle('app:open-admin-window', () => { + showAdmin() + return true + }) + } + + app.on('second-instance', () => showAdmin()) + + app.whenReady().then(() => { + cfg = loadConfig(resolveConfigPath()) + log = new LogBus({ + maxLines: cfg.values.log.maxLines, + file: path.resolve(path.dirname(cfg.path), cfg.values.log.file || 'logs/console.log'), + maxFileBytes: cfg.values.log.maxFileBytes + }) + + log.push('console', `Scoreboard Console v${app.getVersion()} 启动(配置:${cfg.path})`) + if (cfg.warning) log.push('console', cfg.warning) + + const resolved = resolveExecutable(cfg.values.server.exe, cfg.path) + log.push('console', `服务端程序:${resolved.path}`) + + service = new ServiceSupervisor({ + values: cfg.values, + exePath: resolved.path, + log + }) + + displays = new DisplayManager({ + values: cfg.values, + server: cfg.values.server, + log + }) + + log.onLine((line) => sendToAdmin('log:line', line)) + + service.on('status', (status) => { + broadcastStatus() + refreshTray() + if (!status.healthy) return + // 服务端恢复后必须让大屏重新加载,否则页面还停在断线兜底画面上 + if (displaysEnabled) displays.relayout() + else ensureDisplays() + }) + + createAdminWindow() + createTray() + registerIpc() + + // 防熄屏要在服务端起来之前就生效,否则开机后第一段时间屏幕仍会休眠 + powerBlockerId = powerSaveBlocker.start('prevent-display-sleep') + log.push('console', `防熄屏已启用(powerSaveBlocker=${powerBlockerId},生效=${powerSaveBlocker.isStarted(powerBlockerId)})`) + + if (cfg.values.server.autoStart) { + service.start() + } else { + log.push('console', '配置为不自动启动,等待手动启动服务端') + } + + // 服务端迟迟不健康(例如首次迁移很慢)也要把大屏开出来,别让现场只看到桌面 + const wait = Number(cfg.values.display.waitForServerMs) || 0 + if (wait > 0) { + displayFallbackTimer = setTimeout(() => { + displayFallbackTimer = null + if (!displaysEnabled) { + log.push('console', `等待服务端就绪超过 ${Math.round(wait / 1000)} 秒,先打开大屏窗口`) + ensureDisplays() + } + }, wait) + } + }) + + // 常驻托盘:窗口全关也不退出,服务端要继续守着 + app.on('window-all-closed', () => {}) + + app.on('before-quit', (event) => { + quitting = true + if (shutdownDone) return + event.preventDefault() + + const finish = () => { + if (displays) displays.closeAll() + if (powerBlockerId !== null) powerSaveBlocker.stop(powerBlockerId) + if (log) log.close() + shutdownDone = true + app.quit() + } + + if (service) { + service.stop().then(finish).catch(finish) + } else { + finish() + } + }) + + app.on('quit', () => { + if (service) service.dispose() + }) +} diff --git a/console/main/log-bus.js b/console/main/log-bus.js new file mode 100644 index 0000000..fd5a299 --- /dev/null +++ b/console/main/log-bus.js @@ -0,0 +1,79 @@ +'use strict' + +const fs = require('fs') +const path = require('path') + +// 日志总线:内存环形缓冲供管理窗口实时查看,同时落一份文件供现场排障。 +// 现场没有外网也没有终端,文件日志是事后定位问题的唯一线索。 +class LogBus { + constructor(options = {}) { + const { maxLines = 2000, file = null, maxFileBytes = 4 * 1024 * 1024 } = options + this.maxLines = maxLines + this.maxFileBytes = maxFileBytes + this.lines = [] + this.listeners = new Set() + // 子进程的输出是流式分片的,可能把一行拆到两次 data 事件里 + this.pending = new Map() + this.stream = null + if (file) this.openFile(file) + } + + openFile(file) { + try { + fs.mkdirSync(path.dirname(file), { recursive: true }) + if (fs.existsSync(file) && fs.statSync(file).size > this.maxFileBytes) { + fs.renameSync(file, `${file}.1`) + } + this.stream = fs.createWriteStream(file, { flags: 'a' }) + } catch (err) { + this.stream = null + this.push('console', `日志文件不可写,仅保留内存日志:${err.message}`) + } + } + + append(source, chunk) { + const text = `${this.pending.get(source) || ''}${String(chunk)}` + const parts = text.split(/\r?\n/) + // 最后一段是没有换行的残行,留到下一次拼接 + this.pending.set(source, parts.pop() || '') + for (const part of parts) this.push(source, part) + } + + push(source, message) { + const line = `[${new Date().toISOString()}] [${source}] ${message}` + this.lines.push(line) + if (this.lines.length > this.maxLines) { + this.lines.splice(0, this.lines.length - this.maxLines) + } + if (this.stream) this.stream.write(`${line}\n`) + for (const listener of this.listeners) { + try { + listener(line) + } catch { + // 单个订阅者出错不能拖垮日志链路 + } + } + } + + tail() { + return this.lines.slice() + } + + clear() { + this.lines = [] + } + + onLine(listener) { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + + close() { + if (this.stream) { + this.stream.end() + this.stream = null + } + } +} + +module.exports = { LogBus } diff --git a/console/main/service.js b/console/main/service.js new file mode 100644 index 0000000..3c7e584 --- /dev/null +++ b/console/main/service.js @@ -0,0 +1,264 @@ +'use strict' + +const fs = require('fs') +const http = require('http') +const path = require('path') +const { EventEmitter } = require('events') +const { spawn } = require('child_process') + +const STATE_LABEL = { + stopped: '已停止', + starting: '启动中', + running: '运行中', + stopping: '停止中', + crashed: '异常退出' +} + +// 服务端守护:拉起 scoreboard.exe、转发日志、健康探测、崩溃自动重启。 +// 现场无人值守,所以「崩了自动拉起」比「暴露复杂控制」更重要。 +class ServiceSupervisor extends EventEmitter { + constructor({ values, exePath, log }) { + super() + this.values = values + this.server = values.server + this.exePath = exePath + this.log = log + + this.child = null + this.state = 'stopped' + this.healthy = false + this.pid = null + this.restarts = 0 + this.startedAt = null + this.lastError = null + + this.stopRequested = false + this.healthTimer = null + this.restartTimer = null + this.forceKillTimer = null + } + + get status() { + const host = this.healthHost() + return { + state: this.state, + stateLabel: STATE_LABEL[this.state] || this.state, + healthy: this.healthy, + pid: this.pid, + restarts: this.restarts, + startedAt: this.startedAt, + lastError: this.lastError, + exePath: this.exePath, + url: `http://${host}:${this.server.port}`, + adminUrl: `http://${host}:${this.server.port}/admin` + } + } + + // 服务端可能监听 0.0.0.0,本机探测要走回环地址 + healthHost() { + const host = this.server.host || '127.0.0.1' + return host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host + } + + emitStatus() { + this.emit('status', this.status) + } + + setState(state, patch = {}) { + const changed = this.state !== state + this.state = state + Object.assign(this, patch) + if (changed) { + this.log.push('console', `服务状态:${STATE_LABEL[state] || state}${this.lastError ? `(${this.lastError})` : ''}`) + } + this.emitStatus() + } + + start() { + if (this.child || this.state === 'starting' || this.state === 'running') return this.status + this.stopRequested = false + + if (!this.exePath || !fs.existsSync(this.exePath)) { + this.setState('crashed', { + lastError: `未找到服务端程序:${this.exePath || this.server.exe}` + }) + return this.status + } + + const args = Array.isArray(this.server.args) ? this.server.args : [] + // 工作目录取 exe 所在目录:部署包里 config.yaml / data/ 都跟 exe 平级 + const cwd = path.dirname(this.exePath) + + this.lastError = null + this.startedAt = new Date().toISOString() + this.setState('starting') + this.log.push('console', `启动服务端:${this.exePath} ${args.join(' ')}(cwd=${cwd})`) + + let child + try { + child = spawn(this.exePath, args, { + cwd, + windowsHide: true, + stdio: ['ignore', 'pipe', 'pipe'] + }) + } catch (err) { + this.setState('crashed', { lastError: `启动失败:${err.message}` }) + this.scheduleRestart() + return this.status + } + + this.child = child + this.pid = child.pid + + if (child.stdout) { + child.stdout.setEncoding('utf8') + child.stdout.on('data', (chunk) => this.log.append('server', chunk)) + } + if (child.stderr) { + child.stderr.setEncoding('utf8') + child.stderr.on('data', (chunk) => this.log.append('server!', chunk)) + } + + child.on('error', (err) => { + this.log.push('console', `服务端进程错误:${err.message}`) + this.setState('crashed', { lastError: err.message }) + }) + + child.on('exit', (code, signal) => this.handleExit(code, signal)) + + this.setState('running') + this.startHealthCheck() + return this.status + } + + handleExit(code, signal) { + if (this.forceKillTimer) { + clearTimeout(this.forceKillTimer) + this.forceKillTimer = null + } + this.stopHealthCheck() + this.child = null + this.pid = null + this.healthy = false + + const reason = signal ? `信号 ${signal}` : `退出码 ${code}` + if (this.stopRequested) { + this.log.push('console', `服务端已停止(${reason})`) + this.setState('stopped', { lastError: null }) + return + } + + this.setState('crashed', { lastError: `服务端意外退出(${reason})` }) + this.scheduleRestart() + } + + scheduleRestart() { + const max = Number(this.server.maxRestarts) || 0 + if (max > 0 && this.restarts >= max) { + this.log.push('console', `已达到最大重启次数 ${max},不再自动拉起`) + return + } + const delay = (Number(this.server.restartDelayMs) || 3000) * Math.min(this.restarts + 1, 5) + this.restarts += 1 + this.log.push('console', `${Math.round(delay / 1000)} 秒后自动重启服务端(第 ${this.restarts} 次)`) + this.emitStatus() + if (this.restartTimer) clearTimeout(this.restartTimer) + this.restartTimer = setTimeout(() => { + this.restartTimer = null + if (!this.stopRequested) this.start() + }, delay) + } + + stop() { + return new Promise((resolve) => { + if (this.restartTimer) { + clearTimeout(this.restartTimer) + this.restartTimer = null + } + const child = this.child + if (!child) { + this.setState('stopped', { lastError: null }) + resolve() + return + } + + this.stopRequested = true + this.setState('stopping') + this.log.push('console', '正在停止服务端…') + + child.once('exit', () => resolve()) + try { + child.kill() + } catch (err) { + this.log.push('console', `发送停止信号失败:${err.message}`) + } + // Go 服务端有 10s 优雅关闭窗口,超过 5s 仍未退出就强杀,避免 Console 退不干净 + this.forceKillTimer = setTimeout(() => { + if (this.child && this.pid) { + this.log.push('console', '服务端未在 5 秒内退出,强制结束') + try { + this.child.kill('SIGKILL') + } catch { + // 进程可能已经没了 + } + } + }, 5000) + }) + } + + async restart() { + await this.stop() + this.restarts = 0 + return this.start() + } + + startHealthCheck() { + this.stopHealthCheck() + const interval = Number(this.server.healthIntervalMs) || 5000 + const tick = () => this.probe() + setTimeout(tick, 800) + this.healthTimer = setInterval(tick, interval) + } + + stopHealthCheck() { + if (this.healthTimer) { + clearInterval(this.healthTimer) + this.healthTimer = null + } + } + + probe() { + if (!this.child) return + const request = http.get( + { + host: this.healthHost(), + port: this.server.port, + path: '/api/health', + timeout: 2500 + }, + (response) => { + response.resume() + const ok = response.statusCode === 200 + this.setHealthy(ok) + } + ) + request.on('timeout', () => request.destroy()) + request.on('error', () => this.setHealthy(false)) + } + + setHealthy(ok) { + if (this.healthy === ok) return + this.healthy = ok + this.log.push('console', ok ? '服务端健康检查通过' : '服务端健康检查失败(等待重试)') + this.emitStatus() + } + + dispose() { + if (this.restartTimer) clearTimeout(this.restartTimer) + if (this.forceKillTimer) clearTimeout(this.forceKillTimer) + this.stopHealthCheck() + this.removeAllListeners() + } +} + +module.exports = { ServiceSupervisor, STATE_LABEL } diff --git a/console/main/tray-icon.js b/console/main/tray-icon.js new file mode 100644 index 0000000..15319e7 --- /dev/null +++ b/console/main/tray-icon.js @@ -0,0 +1,16 @@ +'use strict' + +const { nativeImage } = require('electron') + +// 托盘图标以 base64 内嵌:现场部署包只有 exe 和几个文件, +// 少一个需要跟着走的资源文件就少一处「文件丢了图标不显示」的故障点。 +const ICON_PNG_BASE64 = + 'iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAABUklEQVR42u2XwUdEURTG5z9JSholJY2SklJSf0+0iKFFtIhoES0iWkSLiBaRDKNSKU2GjCRGEkkSSZsvb3GfnHPnzTm382Y1P36b9x33fKv37stkmiiYLf1AovnimatvhPjvxdMXX7AwaPnU6ScsVS2fLH4gDUXLJwrvSNO6BcaP3pCmicvHDl/RCGsWGD14QSP0Lh/Zf4aVFN8MKzC89wQrKb4ZVmBotworKb4ZVmBw5xFWUnwzrEBu+wFWUnwzrMDA1j00UqSZkxXo36xAI0WaOVmBvo07aKRIMycr0LtehkaKNHOyAj1rt9BIkWZO79uwe/UGUinSLLLmt6Br5Rp/pVhkkYlfxOzyJZwUi6zufaBz6RxOikUmuhV1LJ4hkuKeh2aqe2F7/oQdFD1zarOgmzE9qG2hGKvJgv8L6EGt84VYTWZWoGXuOFaTNX9qk/gFLmHiXP3t+GEAAAAASUVORK5CYII=' + +function createTrayIcon() { + const image = nativeImage.createFromDataURL(`data:image/png;base64,${ICON_PNG_BASE64}`) + // Windows 上托盘按 16px 显示,缩放交给系统会更模糊,这里直接给 16px + return image.resize({ width: 16, height: 16 }) +} + +module.exports = { createTrayIcon } diff --git a/console/package.json b/console/package.json new file mode 100644 index 0000000..ed0407a --- /dev/null +++ b/console/package.json @@ -0,0 +1,62 @@ +{ + "name": "scoreboard-console", + "productName": "Scoreboard Console", + "version": "0.1.0", + "private": true, + "description": "赛事积分榜 · 桌面宿主:守护 Go 服务端并把大屏投到扩展屏", + "main": "main/index.js", + "scripts": { + "start": "electron .", + "check": "node -e \"const {execSync}=require('child_process');execSync('for f in $(find . -name \\\"*.js\\\" -not -path \\\"./node_modules/*\\\"); do node --check \\\"$f\\\"; done',{stdio:'inherit'})\"", + "dist": "electron-builder --win --x64", + "pack": "electron-builder --dir" + }, + "devDependencies": { + "electron": "^38.0.0", + "electron-builder": "^26.0.12" + }, + "build": { + "appId": "top.yeij.scoreboard.console", + "productName": "Scoreboard Console", + "directories": { + "output": "../dist/console", + "buildResources": "build" + }, + "files": [ + "main/**/*", + "renderer/**/*", + "config.json", + "package.json" + ], + "extraFiles": [ + { + "from": "../dist/scoreboard.exe", + "to": "scoreboard.exe", + "filter": ["**/*"] + }, + { + "from": "../config.yaml", + "to": "config.yaml" + } + ], + "win": { + "target": [ + { + "target": "nsis", + "arch": ["x64"] + }, + { + "target": "zip", + "arch": ["x64"] + } + ], + "artifactName": "${productName}-${version}-${arch}.${ext}" + }, + "nsis": { + "oneClick": false, + "allowToChangeInstallationDirectory": true, + "createDesktopShortcut": true, + "shortcutName": "Scoreboard Console" + } + } +} diff --git a/console/renderer/app.js b/console/renderer/app.js new file mode 100644 index 0000000..a83622b --- /dev/null +++ b/console/renderer/app.js @@ -0,0 +1,130 @@ +'use strict' + +const el = { + version: document.getElementById('app-version'), + stateDot: document.getElementById('state-dot'), + stateLabel: document.getElementById('state-label'), + stateDetail: document.getElementById('state-detail'), + url: document.getElementById('k-url'), + pid: document.getElementById('k-pid'), + restarts: document.getElementById('k-restarts'), + power: document.getElementById('k-power'), + exe: document.getElementById('k-exe'), + config: document.getElementById('k-config'), + configWarning: document.getElementById('config-warning'), + displayList: document.getElementById('display-list'), + log: document.getElementById('log'), + autoscroll: document.getElementById('autoscroll'), + btnStart: document.getElementById('btn-start'), + btnStop: document.getElementById('btn-stop'), + btnRestart: document.getElementById('btn-restart'), + btnRelayout: document.getElementById('btn-relayout'), + btnOpenAdmin: document.getElementById('btn-open-admin'), + btnClearLog: document.getElementById('btn-clear-log') +} + +const DOT_CLASS = { + running: 'ok', + starting: 'warn', + stopping: 'warn', + crashed: 'err', + stopped: '' +} + +let lastStatus = null + +function renderStatus(status) { + lastStatus = status + el.version.textContent = status.appVersion ? `v${status.appVersion}` : '' + + el.stateDot.className = `dot ${status.healthy ? 'ok' : DOT_CLASS[status.state] || ''}` + el.stateLabel.textContent = status.stateLabel || status.state + el.stateDetail.textContent = status.healthy + ? '健康检查通过' + : status.lastError || (status.state === 'stopped' ? '服务未运行' : '等待健康检查') + + el.url.textContent = status.url || '—' + el.pid.textContent = status.pid || '—' + el.restarts.textContent = status.restarts ? `${status.restarts} 次` : '0 次' + el.power.textContent = status.powerSaveBlocked ? '已启用(阻止显示器休眠)' : '未启用' + el.exe.textContent = status.exePath || '—' + el.config.textContent = status.configPath || '—' + + if (status.configWarning) { + el.configWarning.hidden = false + el.configWarning.textContent = status.configWarning + } else { + el.configWarning.hidden = true + } + + const busy = ['starting', 'running', 'stopping'].includes(status.state) + el.btnStart.disabled = busy + el.btnStop.disabled = !busy + el.btnRestart.disabled = !busy + + renderDisplays(status.displays || []) +} + +function renderDisplays(displays) { + el.displayList.textContent = '' + if (!displays.length) { + const li = document.createElement('li') + li.className = 'muted' + li.textContent = '尚未打开大屏窗口' + el.displayList.append(li) + return + } + for (const item of displays) { + const li = document.createElement('li') + const page = document.createElement('span') + page.className = 'page' + page.textContent = `屏 ${item.displayId} · ${item.page}` + const url = document.createElement('span') + url.className = 'url' + url.textContent = item.url || '(加载中)' + li.append(page, url) + el.displayList.append(li) + } +} + +function appendLog(line) { + const atBottom = el.log.scrollTop + el.log.clientHeight >= el.log.scrollHeight - 8 + el.log.textContent += `${line}\n` + if (el.autoscroll.checked && atBottom) { + el.log.scrollTop = el.log.scrollHeight + } +} + +function setLog(lines) { + el.log.textContent = lines.length ? `${lines.join('\n')}\n` : '' + el.log.scrollTop = el.log.scrollHeight +} + +async function refresh() { + renderStatus(await window.consoleApi.getStatus()) +} + +function bindActions() { + el.btnStart.addEventListener('click', async () => renderStatus(await window.consoleApi.startService())) + el.btnStop.addEventListener('click', async () => renderStatus(await window.consoleApi.stopService())) + el.btnRestart.addEventListener('click', async () => renderStatus(await window.consoleApi.restartService())) + el.btnRelayout.addEventListener('click', async () => renderStatus(await window.consoleApi.relayoutDisplays())) + el.btnClearLog.addEventListener('click', async () => { + await window.consoleApi.clearLog() + setLog([]) + }) + el.btnOpenAdmin.addEventListener('click', () => { + const url = (lastStatus && lastStatus.adminUrl) || 'http://127.0.0.1:8080/admin' + window.consoleApi.openExternal(url) + }) +} + +async function boot() { + bindActions() + window.consoleApi.onStatus(renderStatus) + window.consoleApi.onLog(appendLog) + setLog(await window.consoleApi.getLog()) + await refresh() +} + +boot() diff --git a/console/renderer/index.html b/console/renderer/index.html new file mode 100644 index 0000000..7ed94d4 --- /dev/null +++ b/console/renderer/index.html @@ -0,0 +1,103 @@ + + + + + + Scoreboard Console · 启动管理 + + + +
+
+ +
+

Scoreboard Console

+

赛事积分榜 · 桌面宿主

+
+
+
+ +
+
+ +
+
+
+

服务端状态

+
+ + + +
+
+ +
+ + 已停止 + +
+ +
+
+
服务地址
+
—
+
+
+
进程 PID
+
—
+
+
+
自动重启
+
—
+
+
+
防熄屏
+
—
+
+
+
服务端程序
+
—
+
+
+
配置文件
+
—
+
+
+ + +
+ +
+
+

大屏窗口

+
+ + +
+
+

+ 单屏时窗口化、按 F11 切换全屏;多屏时除主屏外每块屏一个 kiosk 窗口,各锁一页。 +

+
    +
  • 尚未打开大屏窗口
  • +
+
+ +
+
+

运行日志

+
+ + +
+
+

+      
+
+ + + + diff --git a/console/renderer/preload.js b/console/renderer/preload.js new file mode 100644 index 0000000..c3653d6 --- /dev/null +++ b/console/renderer/preload.js @@ -0,0 +1,23 @@ +'use strict' + +const { contextBridge, ipcRenderer } = require('electron') + +// 渲染层是纯静态页面,能力全部经这里白名单暴露,不开 nodeIntegration +const subscribe = (channel, callback) => { + const handler = (_event, payload) => callback(payload) + ipcRenderer.on(channel, handler) + return () => ipcRenderer.removeListener(channel, handler) +} + +contextBridge.exposeInMainWorld('consoleApi', { + getStatus: () => ipcRenderer.invoke('service:get-status'), + startService: () => ipcRenderer.invoke('service:start'), + stopService: () => ipcRenderer.invoke('service:stop'), + restartService: () => ipcRenderer.invoke('service:restart'), + getLog: () => ipcRenderer.invoke('log:tail'), + clearLog: () => ipcRenderer.invoke('log:clear'), + relayoutDisplays: () => ipcRenderer.invoke('display:relayout'), + openExternal: (url) => ipcRenderer.invoke('app:open-external', url), + onLog: (callback) => subscribe('log:line', callback), + onStatus: (callback) => subscribe('service:status', callback) +}) diff --git a/console/renderer/styles.css b/console/renderer/styles.css new file mode 100644 index 0000000..d70fab4 --- /dev/null +++ b/console/renderer/styles.css @@ -0,0 +1,270 @@ +:root { + --bg: #0f1420; + --panel: #161d2d; + --panel-2: #1d2637; + --border: #27314a; + --text: #e6ecf7; + --muted: #8b98b4; + --accent: #4fd8ff; + --ok: #3ddc97; + --warn: #ffb454; + --err: #ff6b6b; +} + +* { + box-sizing: border-box; +} + +html, +body { + height: 100%; + margin: 0; +} + +body { + display: flex; + flex-direction: column; + background: var(--bg); + color: var(--text); + font: 14px/1.5 "Segoe UI", "Microsoft YaHei", system-ui, sans-serif; +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 20px; + border-bottom: 1px solid var(--border); + background: var(--panel); +} + +.brand { + display: flex; + align-items: center; + gap: 12px; +} + +.brand-mark { + width: 10px; + height: 34px; + border-radius: 5px; + background: linear-gradient(180deg, var(--accent), #1b6bb8); +} + +h1 { + margin: 0; + font-size: 16px; + font-weight: 600; + letter-spacing: 0.02em; +} + +.brand-sub { + margin: 0; + font-size: 12px; + color: var(--muted); +} + +main { + flex: 1; + min-height: 0; + padding: 16px 20px 20px; + display: grid; + gap: 14px; + grid-template-columns: 1fr 1fr; + grid-template-rows: auto auto minmax(160px, 1fr); + overflow: auto; +} + +.card { + grid-column: span 2; + padding: 14px 16px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--panel); +} + +.log-card { + display: flex; + flex-direction: column; + min-height: 0; +} + +.card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; +} + +h2 { + margin: 0; + font-size: 13px; + font-weight: 600; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.actions { + display: flex; + gap: 8px; + align-items: center; +} + +.btn { + padding: 6px 14px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--panel-2); + color: var(--text); + font-size: 13px; + cursor: pointer; +} + +.btn:hover:not(:disabled) { + border-color: var(--accent); +} + +.btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.btn.primary { + border-color: transparent; + background: var(--accent); + color: #06202b; + font-weight: 600; +} + +.status-row { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 12px; + font-size: 15px; +} + +.dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--muted); +} + +.dot.ok { + background: var(--ok); + box-shadow: 0 0 0 4px rgba(61, 220, 151, 0.15); +} + +.dot.warn { + background: var(--warn); + box-shadow: 0 0 0 4px rgba(255, 180, 84, 0.15); +} + +.dot.err { + background: var(--err); + box-shadow: 0 0 0 4px rgba(255, 107, 107, 0.15); +} + +.muted { + color: var(--muted); +} + +.kv { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px 16px; + margin: 0; +} + +.kv > div.wide { + grid-column: span 2; +} + +.kv dt { + margin: 0; + font-size: 12px; + color: var(--muted); +} + +.kv dd { + margin: 2px 0 0; + word-break: break-all; +} + +.mono { + font-family: Consolas, "Cascadia Mono", monospace; + font-size: 12px; +} + +.warning { + margin: 12px 0 0; + padding: 8px 10px; + border-radius: 6px; + background: rgba(255, 180, 84, 0.12); + color: var(--warn); + font-size: 12px; +} + +.hint { + margin: 0 0 10px; + font-size: 12px; + color: var(--muted); +} + +.display-list { + margin: 0; + padding: 0; + list-style: none; + display: grid; + gap: 6px; +} + +.display-list li { + display: flex; + gap: 10px; + padding: 8px 10px; + border-radius: 6px; + background: var(--panel-2); + font-size: 13px; +} + +.display-list .page { + min-width: 96px; + color: var(--accent); + font-weight: 600; +} + +.display-list .url { + color: var(--muted); + font-family: Consolas, "Cascadia Mono", monospace; + font-size: 12px; + word-break: break-all; +} + +.check { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--muted); + cursor: pointer; +} + +.log { + flex: 1; + min-height: 0; + margin: 0; + padding: 10px 12px; + overflow: auto; + border-radius: 8px; + background: #0a0e17; + color: #b9c6e0; + font-family: Consolas, "Cascadia Mono", monospace; + font-size: 12px; + line-height: 1.55; + white-space: pre-wrap; + word-break: break-all; +} diff --git a/server/go.mod b/server/go.mod new file mode 100644 index 0000000..49d9848 --- /dev/null +++ b/server/go.mod @@ -0,0 +1,20 @@ +module git.yeij.top/AskaEth/Scoreboard/server + +go 1.26 + +require ( + gopkg.in/yaml.v3 v3.0.1 + modernc.org/sqlite v1.58.0 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.47.0 // indirect + modernc.org/libc v1.75.6 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.12.1 // indirect +) diff --git a/server/go.sum b/server/go.sum new file mode 100644 index 0000000..ac255d3 --- /dev/null +++ b/server/go.sum @@ -0,0 +1,54 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8= +modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w= +modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc= +modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.75.6 h1:yKk8qo+Di4gkmvRboK8ocCqH22FiUCR6jRy2OwtCRus= +modernc.org/libc v1.75.6/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g= +modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.58.0 h1:38u40/bwkfM7f0Myhosl+SEMltSDxnGdQf8o6Kjmys0= +modernc.org/sqlite v1.58.0/go.mod h1:rsD2CckafgObKC4DhBlGBf+RiHxkc3hINGt1Xw32tVY= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/server/internal/config/config.go b/server/internal/config/config.go new file mode 100644 index 0000000..743b8c7 --- /dev/null +++ b/server/internal/config/config.go @@ -0,0 +1,89 @@ +// Package config 负责加载服务端启动配置。 +package config + +import ( + "fmt" + "log/slog" + "os" + "strings" + + "gopkg.in/yaml.v3" +) + +// Config 是 config.yaml 的映射。 +type Config struct { + Server Server `yaml:"server"` + DataDir string `yaml:"dataDir"` + Log Log `yaml:"log"` +} + +// Server 是 HTTP 监听配置。 +type Server struct { + Host string `yaml:"host"` + Port int `yaml:"port"` +} + +// Log 是日志配置。 +type Log struct { + Level string `yaml:"level"` +} + +// Default 返回一份可直接使用的默认配置。 +func Default() *Config { + return &Config{ + Server: Server{Host: "0.0.0.0", Port: 8080}, + DataDir: "data", + Log: Log{Level: "info"}, + } +} + +// Load 读取配置文件;文件不存在时返回默认配置。 +func Load(path string) (*Config, error) { + cfg := Default() + + raw, err := os.ReadFile(path) + switch { + case err == nil: + if err := yaml.Unmarshal(raw, cfg); err != nil { + return nil, fmt.Errorf("解析配置文件 %s: %w", path, err) + } + case os.IsNotExist(err): + // 没有配置文件时使用默认值 + default: + return nil, fmt.Errorf("读取配置文件 %s: %w", path, err) + } + + if err := cfg.validate(); err != nil { + return nil, err + } + return cfg, nil +} + +func (c *Config) validate() error { + if c.Server.Port <= 0 || c.Server.Port > 65535 { + return fmt.Errorf("server.port 非法: %d", c.Server.Port) + } + if strings.TrimSpace(c.DataDir) == "" { + return fmt.Errorf("dataDir 不能为空") + } + return nil +} + +// Addr 返回 http.Server 使用的监听地址。 +func (s Server) Addr() string { + return fmt.Sprintf("%s:%d", s.Host, s.Port) +} + +// SlogLevel 把配置里的日志级别映射为 slog 级别。 +func (l Log) SlogLevel() slog.Level { + switch strings.ToLower(strings.TrimSpace(l.Level)) { + case "debug": + return slog.LevelDebug + case "warn", "warning": + return slog.LevelWarn + case "error": + return slog.LevelError + default: + return slog.LevelInfo + } +} diff --git a/server/internal/domain/doc.go b/server/internal/domain/doc.go new file mode 100644 index 0000000..a9e621b --- /dev/null +++ b/server/internal/domain/doc.go @@ -0,0 +1,5 @@ +// Package domain 承载积分、排名、纪录等纯业务逻辑。 +// +// 本包不 import 任何 IO 包(数据库、HTTP、WebSocket), +// 输入输出都是普通结构体,以便完整单测,也方便将来替换存储层。 +package domain diff --git a/server/internal/domain/errors.go b/server/internal/domain/errors.go new file mode 100644 index 0000000..ae4013b --- /dev/null +++ b/server/internal/domain/errors.go @@ -0,0 +1,16 @@ +package domain + +import "errors" + +// 引擎的哨兵错误:调用方(store / httpapi)据此区分「配置错」与「数据错」。 +var ( + ErrProfileNotFound = errors.New("domain: 计分通道引用的积分模板不存在") + ErrUnknownBasis = errors.New("domain: 未知的计分通道 basis") + ErrCompareRequired = errors.New("domain: basis=result 的通道要求小项配置 compare=asc/desc") + ErrLedgerNotFound = errors.New("domain: 积分流水不存在") + ErrLedgerAlreadyVoided = errors.New("domain: 积分流水已作废,不能重复作废") + ErrDuplicateLedgerID = errors.New("domain: 积分流水 id 重复") + ErrManualAdjustmentDisabled = errors.New("domain: 人工调整未启用") + ErrAdjustmentReasonRequired = errors.New("domain: 人工调整必须填写理由") + ErrUnknownLedgerOp = errors.New("domain: 未知的流水增量操作") +) diff --git a/server/internal/domain/helpers_test.go b/server/internal/domain/helpers_test.go new file mode 100644 index 0000000..7dfec98 --- /dev/null +++ b/server/internal/domain/helpers_test.go @@ -0,0 +1,200 @@ +package domain + +import "testing" + +// ---------- 通用构造 ---------- + +func ptr(f float64) *float64 { return &f } + +// individualTop6 是规则包里的个人项目前六名:10/8/6/3/2/1。 +func individualTop6() ScoringProfile { + return ScoringProfile{ + ID: 1, Code: "individual_top6", Name: "个人项目前六名", + AwardRanks: 6, LastPlaceZero: false, TieMode: TieSkip, + Rules: []ScoringRule{{1, 10}, {2, 8}, {3, 6}, {4, 3}, {5, 2}, {6, 1}}, + } +} + +// teamTop6 是规则包里的团体项目前六名:15/10/8/4/2/1。 +func teamTop6() ScoringProfile { + return ScoringProfile{ + ID: 2, Code: "team_top6", Name: "团体项目前六名", + AwardRanks: 6, LastPlaceZero: false, TieMode: TieSkip, + Rules: []ScoringRule{{1, 15}, {2, 10}, {3, 8}, {4, 4}, {5, 2}, {6, 1}}, + } +} + +// nineProfile 是测试矩阵 case 1..6 使用的八名表:9/7/6/5/4/3/2/1。 +func nineProfile(tie TieMode) ScoringProfile { + return ScoringProfile{ + ID: 3, Code: "nine", Name: "八名表", + AwardRanks: 8, LastPlaceZero: false, TieMode: tie, + Rules: []ScoringRule{{1, 9}, {2, 7}, {3, 6}, {4, 5}, {5, 4}, {6, 3}, {7, 2}, {8, 1}}, + } +} + +func testCategory() EventCategory { + return EventCategory{ID: 1, EventID: 1, Name: "男子组", Multiplier: 1} +} + +func individualEvent(multiplier float64) Event { + return Event{ + ID: 1, Code: "E1", Name: "100米跑", EventType: EventTypeIndividual, + Compare: CompareAsc, Scored: true, Multiplier: multiplier, Status: EventStatusPublished, + } +} + +func teamEvent(multiplier float64) Event { + e := individualEvent(multiplier) + e.EventType = EventTypeTeam + e.Code = "E2" + return e +} + +func athleteResultChannel(profileID int64) ScoreChannel { + return ScoreChannel{ID: 1, EventID: 1, Scope: ScopeAthlete, Basis: BasisResult, ProfileID: profileID} +} + +func athleteManualChannel(profileID int64) ScoreChannel { + return ScoreChannel{ID: 1, EventID: 1, Scope: ScopeAthlete, Basis: BasisManual, ProfileID: profileID} +} + +func teamManualChannel(profileID int64) ScoreChannel { + return ScoreChannel{ID: 2, EventID: 1, Scope: ScopeTeam, Basis: BasisManual, ProfileID: profileID} +} + +func teamAggregateChannel(profileID int64, method AggregateMethod) ScoreChannel { + return ScoreChannel{ID: 3, EventID: 1, Scope: ScopeTeam, Basis: BasisAggregate, ProfileID: profileID, Aggregate: method} +} + +// participant 是构造「报名项 + 成绩」的最小描述。 +type participant struct { + entryID int64 + athleteID int64 + teamID int64 + name string + value float64 + noValue bool // true 表示这条成绩没有可比数值(如直接录名次的项目) + rank int + status ResultStatus + entryStatus EntryStatus + state ResultState + shares []TeamShare +} + +func fixture(ps []participant) ([]Entry, []Result) { + entries := make([]Entry, 0, len(ps)) + results := make([]Result, 0, len(ps)) + for _, p := range ps { + entries = append(entries, Entry{ + ID: p.entryID, EventID: 1, CategoryID: 1, TeamID: p.teamID, + AthleteID: p.athleteID, AthleteName: p.name, + Status: p.entryStatus, TeamShares: p.shares, + }) + state := p.state + if state == "" { + state = ResultStatePublished + } + var value *float64 + if !p.noValue { + value = ptr(p.value) + } + results = append(results, Result{ + ID: p.entryID * 100, EventID: 1, EntryID: p.entryID, + Rank: p.rank, ResultValue: value, Status: p.status, State: state, + }) + } + return entries, results +} + +// lineup 生成 n 个参赛者:成绩 1..n,entryID 1..n。 +func lineup(n int) []participant { + ps := make([]participant, 0, n) + for i := 1; i <= n; i++ { + ps = append(ps, participant{ + entryID: int64(i), athleteID: int64(100 + i), teamID: int64(1000 + i), + name: "选手" + string(rune('A'+i-1)), value: float64(i), + }) + } + return ps +} + +// lineupValues 生成 n 个参赛者并指定成绩值,用于构造并列。 +func lineupValues(values ...float64) []participant { + ps := make([]participant, 0, len(values)) + for i, v := range values { + ps = append(ps, participant{ + entryID: int64(i + 1), athleteID: int64(100 + i + 1), teamID: int64(1000 + i + 1), + name: "选手" + string(rune('A'+i)), value: v, + }) + } + return ps +} + +func mustScore(t *testing.T, in EventScoreInput) EventScore { + t.Helper() + es, err := ScoreEvent(in) + if err != nil { + t.Fatalf("ScoreEvent 返回错误: %v", err) + } + return es +} + +func pointsByEntry(ledger []LedgerEntry) map[int64]float64 { + out := make(map[int64]float64) + for _, e := range ledger { + if e.Voided { + continue + } + out[e.EntryID] += e.Points + } + return out +} + +func rankByEntry(ranked []RankedResult) map[int64]int { + out := make(map[int64]int, len(ranked)) + for _, r := range ranked { + out[r.EntryID] = r.Rank + } + return out +} + +func pointsByTeam(ledger []LedgerEntry) map[int64]float64 { + out := make(map[int64]float64) + for _, e := range ledger { + if e.Voided { + continue + } + out[e.TeamID] += e.Points + } + return out +} + +// assertEntryPoints 断言 entryID 1..len(want) 的积分依次等于 want;缺键按 0 处理。 +func assertEntryPoints(t *testing.T, got map[int64]float64, want []float64) { + t.Helper() + for i, w := range want { + id := int64(i + 1) + if !nearlyEqual(got[id], w) { + t.Errorf("entry %d 积分 = %v,期望 %v", id, got[id], w) + } + } +} + +func testTeams(ids ...int64) []Team { + out := make([]Team, 0, len(ids)) + for i, id := range ids { + out = append(out, Team{ + ID: id, Code: string(rune('A' + i)), Name: "单位" + string(rune('A'+i)), Active: true, + }) + } + return out +} + +func ledgerFor(teamID int64, points float64) LedgerEntry { + return LedgerEntry{TeamID: teamID, SourceType: SourceResult, SourceID: teamID, Points: points} +} + +func ledgerForAthlete(teamID, athleteID int64, points float64) LedgerEntry { + return LedgerEntry{TeamID: teamID, AthleteID: athleteID, SourceType: SourceResult, SourceID: athleteID, Points: points} +} diff --git a/server/internal/domain/ledger.go b/server/internal/domain/ledger.go new file mode 100644 index 0000000..903c130 --- /dev/null +++ b/server/internal/domain/ledger.go @@ -0,0 +1,256 @@ +package domain + +import ( + "fmt" + "strings" +) + +// LedgerOpKind 增量操作类型。 +type LedgerOpKind string + +const ( + LedgerOpAppend LedgerOpKind = "append" // 追加一批流水 + LedgerOpVoidSource LedgerOpKind = "void_source" // 按来源作废 +) + +// LedgerOp 是一条可乱序应用的积分变更,用于「增量同步」场景。 +type LedgerOp struct { + Kind LedgerOpKind + Entries []LedgerEntry + SourceType SourceType + SourceID int64 + By string + Reason string +} + +// TeamPoints 一个单位的分项汇总;系数只在汇总时折算,历史流水保持原始分。 +type TeamPoints struct { + Total float64 // 单位总分 + TeamOnly float64 // 仅记单位的流水 + Individual float64 // 记到个人的流水(原始分,未乘系数) +} + +// AppendLedger 追加流水。只追加、不修改已有行;id 为 0 时按当前最大 id 续号。 +func AppendLedger(existing []LedgerEntry, adds ...LedgerEntry) ([]LedgerEntry, error) { + out := make([]LedgerEntry, len(existing), len(existing)+len(adds)) + copy(out, existing) + + maxID := int64(0) + used := make(map[int64]bool, len(out)+len(adds)) + for _, e := range out { + if e.ID > maxID { + maxID = e.ID + } + used[e.ID] = true + } + for _, a := range adds { + if a.ID == 0 { + continue + } + if used[a.ID] { + return nil, fmt.Errorf("%w: %d", ErrDuplicateLedgerID, a.ID) + } + used[a.ID] = true + if a.ID > maxID { + maxID = a.ID + } + } + for _, a := range adds { + if a.ID == 0 { + maxID++ + a.ID = maxID + } + out = append(out, a) + } + return out, nil +} + +// VoidLedger 作废一条流水;重复作废视为错误,避免掩盖重复撤销。 +func VoidLedger(entries []LedgerEntry, id int64, by, reason string) ([]LedgerEntry, error) { + out := append([]LedgerEntry(nil), entries...) + for i := range out { + if out[i].ID != id { + continue + } + if out[i].Voided { + return nil, fmt.Errorf("%w: %d", ErrLedgerAlreadyVoided, id) + } + out[i].Voided = true + out[i].VoidedBy = by + out[i].VoidReason = reason + return out, nil + } + return nil, fmt.Errorf("%w: %d", ErrLedgerNotFound, id) +} + +// VoidBySource 作废某来源产生的全部未作废流水,返回新的流水集合与作废条数。 +// 找不到时是空操作:撤销发布重复执行应当幂等。 +func VoidBySource(entries []LedgerEntry, sourceType SourceType, sourceID int64, by, reason string) ([]LedgerEntry, int) { + out := append([]LedgerEntry(nil), entries...) + count := 0 + for i := range out { + if out[i].Voided || out[i].SourceType != sourceType || out[i].SourceID != sourceID { + continue + } + out[i].Voided = true + out[i].VoidedBy = by + out[i].VoidReason = reason + count++ + } + return out, count +} + +// VoidByEvent 作废某个小项的全部未作废流水(含成绩分、破纪录加分、人工调整)。 +func VoidByEvent(entries []LedgerEntry, eventID int64, by, reason string) ([]LedgerEntry, int) { + out := append([]LedgerEntry(nil), entries...) + count := 0 + for i := range out { + if out[i].Voided || out[i].EventID != eventID { + continue + } + out[i].Voided = true + out[i].VoidedBy = by + out[i].VoidReason = reason + count++ + } + return out, count +} + +// ActiveLedger 返回未作废的流水。 +func ActiveLedger(entries []LedgerEntry) []LedgerEntry { + out := make([]LedgerEntry, 0, len(entries)) + for _, e := range entries { + if !e.Voided { + out = append(out, e) + } + } + return out +} + +// SumByTeam 汇总各单位得分:总分 = 仅记单位的流水 + 系数 × 记到个人的流水。 +// weight <= 0 时按 1 处理,避免零值把个人分抹掉。 +func SumByTeam(entries []LedgerEntry, weight float64) map[int64]TeamPoints { + if weight <= 0 { + weight = 1 + } + out := make(map[int64]TeamPoints) + for _, e := range entries { + if e.Voided { + continue + } + p := out[e.TeamID] + if e.AthleteID == 0 { + p.TeamOnly += e.Points + } else { + p.Individual += e.Points + } + p.Total = p.TeamOnly + weight*p.Individual + out[e.TeamID] = p + } + return out +} + +// SumByAthlete 汇总每个运动员的个人总分(原始分,不折算系数)。 +func SumByAthlete(entries []LedgerEntry) map[int64]float64 { + out := make(map[int64]float64) + for _, e := range entries { + if e.Voided || e.AthleteID == 0 { + continue + } + out[e.AthleteID] += e.Points + } + return out +} + +// RecomputeEventLedger 重算某个小项的流水:旧流水整项作废,再追加新流水。 +// 新旧集合一致时原样返回——重复发布同一组必须幂等,不能凭空多出流水。 +func RecomputeEventLedger(existing []LedgerEntry, eventID int64, fresh []LedgerEntry, by, reason string) ([]LedgerEntry, error) { + active := make([]LedgerEntry, 0, len(existing)) + for _, e := range existing { + if e.EventID == eventID && !e.Voided { + active = append(active, e) + } + } + if sameLedgerSet(active, fresh) { + return append([]LedgerEntry(nil), existing...), nil + } + + out, _ := VoidByEvent(existing, eventID, by, reason) + return AppendLedger(out, fresh...) +} + +// ApplyLedgerOps 按给定顺序应用增量操作。 +// 各操作只按来源/事件定位流水,互不依赖顺序,因此乱序到达也能收敛到同一结果。 +func ApplyLedgerOps(existing []LedgerEntry, ops []LedgerOp) ([]LedgerEntry, error) { + out := append([]LedgerEntry(nil), existing...) + for _, op := range ops { + switch op.Kind { + case LedgerOpAppend: + next, err := AppendLedger(out, op.Entries...) + if err != nil { + return nil, err + } + out = next + case LedgerOpVoidSource: + next, _ := VoidBySource(out, op.SourceType, op.SourceID, op.By, op.Reason) + out = next + default: + return nil, fmt.Errorf("%w: %q", ErrUnknownLedgerOp, op.Kind) + } + } + return out, nil +} + +// AdjustmentsToLedger 把人工调整转成积分流水;本期默认关闭,启用后分值可正可负。 +func AdjustmentsToLedger(cfg ManualAdjustmentConfig, adjustments []PointAdjustment) ([]LedgerEntry, error) { + if !cfg.Enabled { + return nil, ErrManualAdjustmentDisabled + } + out := make([]LedgerEntry, 0, len(adjustments)) + for _, a := range adjustments { + if a.Voided { + continue + } + if cfg.ReasonRequired && strings.TrimSpace(a.Reason) == "" { + return nil, fmt.Errorf("%w: 调整 %d", ErrAdjustmentReasonRequired, a.ID) + } + out = append(out, LedgerEntry{ + TeamID: a.TeamID, + EventID: a.EventID, + SourceType: SourceAdjustment, + SourceID: a.ID, + Points: a.Delta, + CreatedBy: a.CreatedBy, + CreatedAt: a.CreatedAt, + }) + } + return out, nil +} + +// sameLedgerSet 比较两组流水是否等价:只比「来源 + 归属 + 分值」,不比 id 与创建信息。 +func sameLedgerSet(a, b []LedgerEntry) bool { + if len(a) != len(b) { + return false + } + am := ledgerSetMap(a) + bm := ledgerSetMap(b) + if len(am) != len(bm) { + return false + } + for k, v := range am { + w, ok := bm[k] + if !ok || !nearlyEqual(v, w) { + return false + } + } + return true +} + +func ledgerSetMap(entries []LedgerEntry) map[string]float64 { + out := make(map[string]float64, len(entries)) + for _, e := range entries { + key := fmt.Sprintf("%s|%d|%d|%d|%d", e.SourceType, e.SourceID, e.ChannelID, e.TeamID, e.AthleteID) + out[key] += e.Points + } + return out +} diff --git a/server/internal/domain/ledger_test.go b/server/internal/domain/ledger_test.go new file mode 100644 index 0000000..64cbccc --- /dev/null +++ b/server/internal/domain/ledger_test.go @@ -0,0 +1,229 @@ +package domain + +import ( + "errors" + "testing" +) + +func TestAppendLedger(t *testing.T) { + t.Run("自动续号", func(t *testing.T) { + out, err := AppendLedger([]LedgerEntry{{ID: 3, Points: 1}}, LedgerEntry{Points: 2}, LedgerEntry{Points: 3}) + if err != nil { + t.Fatalf("追加失败: %v", err) + } + if len(out) != 3 || out[1].ID != 4 || out[2].ID != 5 { + t.Fatalf("id 续号错误: %+v", out) + } + }) + t.Run("保留已有行", func(t *testing.T) { + base := []LedgerEntry{{ID: 1, Points: 5}} + out, _ := AppendLedger(base, LedgerEntry{Points: 2}) + if base[0].Points != 5 || len(out) != 2 { + t.Fatalf("原流水被改动: base=%+v out=%+v", base, out) + } + }) + t.Run("显式 id 冲突报错", func(t *testing.T) { + if _, err := AppendLedger([]LedgerEntry{{ID: 1}}, LedgerEntry{ID: 1}); !errors.Is(err, ErrDuplicateLedgerID) { + t.Fatalf("错误 = %v,期望 ErrDuplicateLedgerID", err) + } + }) +} + +func TestVoidLedger(t *testing.T) { + base := []LedgerEntry{{ID: 1, Points: 5}, {ID: 2, Points: 3}} + + t.Run("作废指定流水", func(t *testing.T) { + out, err := VoidLedger(base, 1, "admin", "录入错误") + if err != nil { + t.Fatalf("作废失败: %v", err) + } + if !out[0].Voided || out[0].VoidedBy != "admin" || out[0].VoidReason != "录入错误" { + t.Fatalf("作废信息不全: %+v", out[0]) + } + if out[1].Voided { + t.Fatalf("误作废了其他流水: %+v", out[1]) + } + if base[0].Voided { + t.Fatal("作废不应原地修改入参") + } + }) + t.Run("重复作废报错", func(t *testing.T) { + out, _ := VoidLedger(base, 1, "admin", "x") + if _, err := VoidLedger(out, 1, "admin", "y"); !errors.Is(err, ErrLedgerAlreadyVoided) { + t.Fatalf("错误 = %v,期望 ErrLedgerAlreadyVoided", err) + } + }) + t.Run("流水不存在报错", func(t *testing.T) { + if _, err := VoidLedger(base, 404, "admin", "x"); !errors.Is(err, ErrLedgerNotFound) { + t.Fatalf("错误 = %v,期望 ErrLedgerNotFound", err) + } + }) +} + +func TestVoidBySource(t *testing.T) { + base := []LedgerEntry{ + {ID: 1, SourceType: SourceResult, SourceID: 100, Points: 10}, + {ID: 2, SourceType: SourceResult, SourceID: 100, Points: 8}, + {ID: 3, SourceType: SourceResult, SourceID: 200, Points: 6}, + } + out, count := VoidBySource(base, SourceResult, 100, "admin", "撤销") + if count != 2 { + t.Fatalf("作废条数 = %d,期望 2", count) + } + if !out[0].Voided || !out[1].Voided || out[2].Voided { + t.Fatalf("作废范围错误: %+v", out) + } + + t.Run("重复作废是空操作", func(t *testing.T) { + again, count := VoidBySource(out, SourceResult, 100, "admin", "再撤销") + if count != 0 { + t.Fatalf("重复作废条数 = %d,期望 0", count) + } + if len(again) != len(out) { + t.Fatalf("流水条数变了: %d", len(again)) + } + }) +} + +func TestSumByTeam(t *testing.T) { + ledger := []LedgerEntry{ + ledgerFor(1, 10), + ledgerForAthlete(1, 101, 10), + ledgerFor(2, 6), + } + got := SumByTeam(ledger, 1) + if !nearlyEqual(got[1].Total, 20) || !nearlyEqual(got[1].TeamOnly, 10) || !nearlyEqual(got[1].Individual, 10) { + t.Fatalf("单位1汇总 = %+v,期望 总分20 / 单位10 / 个人10", got[1]) + } + if !nearlyEqual(got[2].Total, 6) { + t.Fatalf("单位2汇总 = %+v,期望 6", got[2]) + } + + half := SumByTeam(ledger, 0.5) + if !nearlyEqual(half[1].Total, 15) { + t.Fatalf("系数 0.5 时单位1总分 = %v,期望 15", half[1].Total) + } +} + +func TestSumByAthlete(t *testing.T) { + ledger := []LedgerEntry{ + ledgerForAthlete(1, 101, 10), + ledgerForAthlete(1, 101, 2), + ledgerFor(1, 100), // 仅记单位,不进个人榜 + } + got := SumByAthlete(ledger) + if len(got) != 1 || !nearlyEqual(got[101], 12) { + t.Fatalf("个人汇总 = %+v,期望只有 101:12", got) + } +} + +func TestRecomputeEventLedger(t *testing.T) { + old := []LedgerEntry{ + {ID: 1, EventID: 1, SourceType: SourceResult, SourceID: 100, TeamID: 1, Points: 10}, + {ID: 2, EventID: 2, SourceType: SourceResult, SourceID: 200, TeamID: 1, Points: 8}, + } + fresh := []LedgerEntry{{EventID: 1, SourceType: SourceResult, SourceID: 100, TeamID: 1, Points: 6}} + + t.Run("有变化时旧流水作废并追加新流水", func(t *testing.T) { + out, err := RecomputeEventLedger(old, 1, fresh, "judge", "更正") + if err != nil { + t.Fatalf("重算失败: %v", err) + } + if len(out) != 3 { + t.Fatalf("流水条数 = %d,期望 3(旧2 + 新1)", len(out)) + } + if !out[0].Voided || out[1].Voided { + t.Fatalf("作废范围错误: %+v", out) + } + if !nearlyEqual(SumByTeam(out, 1)[1].Total, 14) { + t.Fatalf("重算后总分 = %v,期望 6 + 8 = 14", SumByTeam(out, 1)[1].Total) + } + }) + + t.Run("无变化时原样返回", func(t *testing.T) { + same := []LedgerEntry{{EventID: 1, SourceType: SourceResult, SourceID: 100, TeamID: 1, Points: 10}} + out, err := RecomputeEventLedger(old, 1, same, "judge", "重复发布") + if err != nil { + t.Fatalf("重算失败: %v", err) + } + if len(out) != len(old) { + t.Fatalf("流水条数 = %d,期望 %d(幂等)", len(out), len(old)) + } + }) + + t.Run("只影响目标小项", func(t *testing.T) { + out, _ := RecomputeEventLedger(old, 1, fresh, "judge", "更正") + if out[1].Voided { + t.Fatalf("其他小项的流水被误作废: %+v", out[1]) + } + }) +} + +func TestApplyLedgerOps(t *testing.T) { + a := []LedgerEntry{{EventID: 1, SourceType: SourceResult, SourceID: 100, TeamID: 1, Points: 10}} + b := []LedgerEntry{{EventID: 2, SourceType: SourceResult, SourceID: 200, TeamID: 2, Points: 8}} + + t.Run("追加与作废可乱序收敛", func(t *testing.T) { + ops := []LedgerOp{ + {Kind: LedgerOpAppend, Entries: b}, + {Kind: LedgerOpAppend, Entries: a}, + {Kind: LedgerOpVoidSource, SourceType: SourceResult, SourceID: 100, By: "admin", Reason: "撤销"}, + } + out, err := ApplyLedgerOps(nil, ops) + if err != nil { + t.Fatalf("应用失败: %v", err) + } + got := SumByTeam(out, 1) + if !nearlyEqual(got[1].Total, 0) || !nearlyEqual(got[2].Total, 8) { + t.Fatalf("最终状态 = %+v,期望 1:0 / 2:8", got) + } + }) + + t.Run("未知操作报错", func(t *testing.T) { + if _, err := ApplyLedgerOps(nil, []LedgerOp{{Kind: "magic"}}); !errors.Is(err, ErrUnknownLedgerOp) { + t.Fatalf("错误 = %v,期望 ErrUnknownLedgerOp", err) + } + }) +} + +func TestAdjustmentsToLedger(t *testing.T) { + adj := PointAdjustment{ID: 1, TeamID: 5, Delta: -3, Category: AdjustmentPenalty, Reason: "犯规"} + + t.Run("未启用时报错", func(t *testing.T) { + if _, err := AdjustmentsToLedger(ManualAdjustmentConfig{}, []PointAdjustment{adj}); !errors.Is(err, ErrManualAdjustmentDisabled) { + t.Fatalf("错误 = %v,期望 ErrManualAdjustmentDisabled", err) + } + }) + t.Run("启用后转成流水", func(t *testing.T) { + out, err := AdjustmentsToLedger(ManualAdjustmentConfig{Enabled: true, ReasonRequired: true}, []PointAdjustment{adj}) + if err != nil { + t.Fatalf("转换失败: %v", err) + } + if len(out) != 1 || out[0].SourceType != SourceAdjustment || !nearlyEqual(out[0].Points, -3) { + t.Fatalf("流水 = %+v,期望一条 -3 的 adjustment", out) + } + }) + t.Run("理由必填", func(t *testing.T) { + bad := adj + bad.Reason = " " + if _, err := AdjustmentsToLedger(ManualAdjustmentConfig{Enabled: true, ReasonRequired: true}, []PointAdjustment{bad}); !errors.Is(err, ErrAdjustmentReasonRequired) { + t.Fatalf("错误 = %v,期望 ErrAdjustmentReasonRequired", err) + } + }) + t.Run("已作废的调整被跳过", func(t *testing.T) { + voided := adj + voided.Voided = true + out, err := AdjustmentsToLedger(ManualAdjustmentConfig{Enabled: true}, []PointAdjustment{voided}) + if err != nil || len(out) != 0 { + t.Fatalf("流水 = %+v err=%v,期望空", out, err) + } + }) +} + +func TestActiveLedger(t *testing.T) { + ledger := []LedgerEntry{{ID: 1, Points: 1}, {ID: 2, Points: 2, Voided: true}} + got := ActiveLedger(ledger) + if len(got) != 1 || got[0].ID != 1 { + t.Fatalf("未作废流水 = %+v,期望只有 id=1", got) + } +} diff --git a/server/internal/domain/matrix_test.go b/server/internal/domain/matrix_test.go new file mode 100644 index 0000000..686f39f --- /dev/null +++ b/server/internal/domain/matrix_test.go @@ -0,0 +1,457 @@ +package domain + +import "testing" + +// 本文件逐条实现 devdocs/09-测试策略.md 第 2 节的 18 条用例矩阵。 +// 用例名带编号,go test -v 一眼能看出哪条矩阵用例通过。 + +// case 1:8 人参赛正常排名 → 9/7/6/5/4/3/2/1。 +func TestMatrix01_NormalRanking8(t *testing.T) { + entries, results := fixture(lineup(8)) + es := mustScore(t, EventScoreInput{ + Event: individualEvent(1), Category: testCategory(), + Channels: []ScoreChannel{athleteResultChannel(3)}, + Profiles: map[int64]ScoringProfile{3: nineProfile(TieSkip)}, + Entries: entries, Results: results, + }) + assertEntryPoints(t, pointsByEntry(es.Ledger), []float64{9, 7, 6, 5, 4, 3, 2, 1}) +} + +// case 2:两人并列第 2(skip)→ 7/7/-/5/4/3/2/1。 +func TestMatrix02_TieAtSecondSkip(t *testing.T) { + entries, results := fixture(lineupValues(1, 2, 2, 4, 5, 6, 7, 8)) + es := mustScore(t, EventScoreInput{ + Event: individualEvent(1), Category: testCategory(), + Channels: []ScoreChannel{athleteResultChannel(3)}, + Profiles: map[int64]ScoringProfile{3: nineProfile(TieSkip)}, + Entries: entries, Results: results, + }) + assertEntryPoints(t, pointsByEntry(es.Ledger), []float64{9, 7, 7, 5, 4, 3, 2, 1}) + ranks := rankByEntry(es.Ranked) + if ranks[2] != 2 || ranks[3] != 2 || ranks[4] != 4 { + t.Fatalf("并列名次错误:%v", ranks) + } +} + +// case 3:两人并列第 2(average)→ 6.5/6.5/-/5/4/3/2/1。 +func TestMatrix03_TieAtSecondAverage(t *testing.T) { + entries, results := fixture(lineupValues(1, 2, 2, 4, 5, 6, 7, 8)) + es := mustScore(t, EventScoreInput{ + Event: individualEvent(1), Category: testCategory(), + Channels: []ScoreChannel{athleteResultChannel(3)}, + Profiles: map[int64]ScoringProfile{3: nineProfile(TieAverage)}, + Entries: entries, Results: results, + }) + assertEntryPoints(t, pointsByEntry(es.Ledger), []float64{9, 6.5, 6.5, 5, 4, 3, 2, 1}) +} + +// case 4:三人并列第 3(skip)→ 6/6/6/-/-/3/2/1。 +func TestMatrix04_ThreeWayTieAtThird(t *testing.T) { + entries, results := fixture(lineupValues(1, 2, 3, 3, 3, 6, 7, 8)) + es := mustScore(t, EventScoreInput{ + Event: individualEvent(1), Category: testCategory(), + Channels: []ScoreChannel{athleteResultChannel(3)}, + Profiles: map[int64]ScoringProfile{3: nineProfile(TieSkip)}, + Entries: entries, Results: results, + }) + assertEntryPoints(t, pointsByEntry(es.Ledger), []float64{9, 7, 6, 6, 6, 3, 2, 1}) +} + +// case 5:团体项目双倍 → 18/14/12/… +func TestMatrix05_TeamEventDoubled(t *testing.T) { + ps := lineup(8) + for i := range ps { + ps[i].rank = i + 1 + } + entries, results := fixture(ps) + es := mustScore(t, EventScoreInput{ + Event: teamEvent(2), Category: testCategory(), + Channels: []ScoreChannel{teamManualChannel(3)}, + Profiles: map[int64]ScoringProfile{3: nineProfile(TieSkip)}, + Entries: entries, Results: results, + }) + assertEntryPoints(t, pointsByEntry(es.Ledger), []float64{18, 14, 12, 10, 8, 6, 4, 2}) +} + +// case 6:破纪录 + 双倍 → 成绩分双倍,破纪录加分不双倍。 +func TestMatrix06_RecordBonusNotDoubled(t *testing.T) { + entries, results := fixture(lineup(3)) + es := mustScore(t, EventScoreInput{ + Event: individualEvent(2), Category: testCategory(), + Channels: []ScoreChannel{athleteResultChannel(3)}, + Profiles: map[int64]ScoringProfile{3: nineProfile(TieSkip)}, + Entries: entries, Results: results, + RecordBonus: &RecordBonusConfig{ + Enabled: true, Mode: RecordBonusMax, + Levels: []RecordLevel{{Code: "meet", Name: "赛会纪录", Points: 2}}, + }, + RecordBreaks: []RecordBreak{{ + ID: 1, EventID: 1, EntryID: 1, AthleteID: 101, TeamID: 1001, + Level: "meet", State: RecordBreakApproved, + }}, + }) + + var bonus *LedgerEntry + for i := range es.Ledger { + if es.Ledger[i].SourceType == SourceRecordBonus { + bonus = &es.Ledger[i] + } + } + if bonus == nil { + t.Fatal("未产生破纪录加分流水") + } + if !nearlyEqual(bonus.Points, 2) { + t.Errorf("破纪录加分 = %v,期望 2(不随项目权重翻倍)", bonus.Points) + } + if got := pointsByEntry(es.Ledger)[1]; !nearlyEqual(got, 20) { + t.Errorf("entry1 合计 = %v,期望 18(成绩分)+ 2(加分)= 20", got) + } +} + +// case 7:并列第一的奖牌计数 → 2 金 0 银 1 铜。 +func TestMatrix07_TiedFirstMedalCount(t *testing.T) { + entries, results := fixture(lineupValues(1, 1, 3)) + es := mustScore(t, EventScoreInput{ + Event: individualEvent(1), Category: testCategory(), + Channels: []ScoreChannel{athleteResultChannel(3)}, + Profiles: map[int64]ScoringProfile{3: nineProfile(TieSkip)}, + Entries: entries, Results: results, + }) + rows := BuildTeamStandings(StandingsInput{ + Teams: testTeams(1001, 1002, 1003), Ledger: es.Ledger, Ranked: es.Ranked, + }) + var gold, silver, bronze int + for _, r := range rows { + gold += r.Gold + silver += r.Silver + bronze += r.Bronze + } + if gold != 2 || silver != 0 || bronze != 1 { + t.Fatalf("奖牌统计 = %d金 %d银 %d铜,期望 2/0/1", gold, silver, bronze) + } +} + +// case 8:发布后撤销 → 流水作废,总分回退。 +func TestMatrix08_UnpublishRevertsTotal(t *testing.T) { + entries, results := fixture(lineup(3)) + es := mustScore(t, EventScoreInput{ + Event: individualEvent(1), Category: testCategory(), + Channels: []ScoreChannel{athleteResultChannel(1)}, + Profiles: map[int64]ScoringProfile{1: individualTop6()}, + Entries: entries, Results: results, + }) + + published := SumByTeam(es.Ledger, 1) + if !nearlyEqual(published[1001].Total, 10) { + t.Fatalf("发布后总分 = %v,期望 10", published[1001].Total) + } + + voided, count := VoidByEvent(es.Ledger, 1, "admin", "撤销发布") + if count != len(es.Ledger) { + t.Fatalf("作废条数 = %d,期望 %d", count, len(es.Ledger)) + } + after := SumByTeam(voided, 1) + for id, p := range after { + if p.Total != 0 { + t.Errorf("单位 %d 撤销后总分 = %v,期望 0", id, p.Total) + } + } +} + +// case 9:修改已发布成绩 → 旧流水作废 + 新流水,审计可查。 +func TestMatrix09_EditPublishedResult(t *testing.T) { + entries, results := fixture(lineup(3)) + in := EventScoreInput{ + Event: individualEvent(1), Category: testCategory(), + Channels: []ScoreChannel{athleteResultChannel(1)}, + Profiles: map[int64]ScoringProfile{1: individualTop6()}, + Entries: entries, Results: results, + } + first := mustScore(t, in) + + // 改判:entry1 变成最后一名,entry2、entry3 各前进一位。 + entries[0].ID = 1 + results[0].ResultValue = ptr(10) + results[1].ResultValue = ptr(1) + results[2].ResultValue = ptr(2) + fresh := mustScore(t, in) + + rebuilt, err := RecomputeEventLedger(first.Ledger, 1, fresh.Ledger, "judge", "成绩更正") + if err != nil { + t.Fatalf("重算流水失败: %v", err) + } + if len(rebuilt) <= len(first.Ledger) { + t.Fatalf("重算后流水条数 = %d,期望大于 %d(旧作废 + 新追加)", len(rebuilt), len(first.Ledger)) + } + for _, e := range rebuilt { + if e.Voided && (e.VoidedBy != "judge" || e.VoidReason != "成绩更正") { + t.Errorf("作废流水缺少审计信息: %+v", e) + } + } + assertEntryPoints(t, pointsByEntry(rebuilt), []float64{6, 10, 8}) +} + +// case 10:弃权者不占名次 → 名次顺延。 +func TestMatrix10_WithdrawnDoesNotOccupyRank(t *testing.T) { + ps := lineup(5) + ps[2].status = ResultStatusWithdrawn + entries, results := fixture(ps) + es := mustScore(t, EventScoreInput{ + Event: individualEvent(1), Category: testCategory(), + Channels: []ScoreChannel{athleteResultChannel(3)}, + Profiles: map[int64]ScoringProfile{3: nineProfile(TieSkip)}, + Entries: entries, Results: results, + }) + assertEntryPoints(t, pointsByEntry(es.Ledger), []float64{9, 7, 0, 6, 5}) + if _, ok := rankByEntry(es.Ranked)[3]; ok { + t.Fatalf("弃权者不应出现在名次明细中: %v", rankByEntry(es.Ranked)) + } +} + +// case 11:报名不足 8 人 → 按实际名次取分。 +func TestMatrix11_FewerThanAwardRanks(t *testing.T) { + entries, results := fixture(lineup(5)) + es := mustScore(t, EventScoreInput{ + Event: individualEvent(1), Category: testCategory(), + Channels: []ScoreChannel{athleteResultChannel(3)}, + Profiles: map[int64]ScoringProfile{3: nineProfile(TieSkip)}, + Entries: entries, Results: results, + }) + assertEntryPoints(t, pointsByEntry(es.Ledger), []float64{9, 7, 6, 5, 4}) +} + +// case 12:人工扣分后排名变化 → tie-break 正确重排。 +func TestMatrix12_ManualPenaltyReorders(t *testing.T) { + ledger := []LedgerEntry{ledgerFor(1, 10), ledgerFor(2, 10)} + in := StandingsInput{Teams: testTeams(1, 2), Ledger: ledger} + + before := BuildTeamStandings(in) + if before[0].TeamID != 1 { + t.Fatalf("扣分前榜首 = 单位 %d,期望 1(同分时编码小者在前)", before[0].TeamID) + } + + adjustments, err := AdjustmentsToLedger( + ManualAdjustmentConfig{Enabled: true, ReasonRequired: true}, + []PointAdjustment{{ID: 1, TeamID: 1, Delta: -3, Category: AdjustmentPenalty, Reason: "犯规扣分"}}, + ) + if err != nil { + t.Fatalf("人工调整转换失败: %v", err) + } + in.Ledger = append(in.Ledger, adjustments...) + + after := BuildTeamStandings(in) + if after[0].TeamID != 2 { + t.Fatalf("扣分后榜首 = 单位 %d,期望 2", after[0].TeamID) + } + if !nearlyEqual(after[0].Points, 10) || !nearlyEqual(after[1].Points, 7) { + t.Fatalf("扣分后总分 = %v/%v,期望 10/7", after[0].Points, after[1].Points) + } +} + +// case 13:同分 tie-break → 金牌 → 银牌 → 铜牌 → 编码。 +func TestMatrix13_TieBreakOrder(t *testing.T) { + cases := []struct { + name string + ranked []RankedResult + want int64 + }{ + { + name: "金牌优先", + ranked: []RankedResult{{TeamID: 2, Shares: []TeamShare{{TeamID: 2, Weight: 1}}, Rank: 1, Scope: ScopeAthlete}}, + want: 2, + }, + { + name: "银牌优先", + ranked: []RankedResult{ + {TeamID: 1, Shares: []TeamShare{{TeamID: 1, Weight: 1}}, Rank: 1, Scope: ScopeAthlete}, + {TeamID: 2, Shares: []TeamShare{{TeamID: 2, Weight: 1}}, Rank: 1, Scope: ScopeAthlete}, + {TeamID: 2, Shares: []TeamShare{{TeamID: 2, Weight: 1}}, Rank: 2, Scope: ScopeAthlete}, + }, + want: 2, + }, + { + name: "铜牌优先", + ranked: []RankedResult{ + {TeamID: 1, Shares: []TeamShare{{TeamID: 1, Weight: 1}}, Rank: 3, Scope: ScopeAthlete}, + {TeamID: 2, Shares: []TeamShare{{TeamID: 2, Weight: 1}}, Rank: 3, Scope: ScopeAthlete}, + {TeamID: 1, Shares: []TeamShare{{TeamID: 1, Weight: 1}}, Rank: 3, Scope: ScopeAthlete}, + }, + want: 1, + }, + { + name: "编码兜底", + ranked: []RankedResult{}, + want: 1, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rows := BuildTeamStandings(StandingsInput{ + Teams: testTeams(1, 2), + Ledger: []LedgerEntry{ledgerFor(1, 10), ledgerFor(2, 10)}, + Ranked: tc.ranked, + }) + if rows[0].TeamID != tc.want { + t.Fatalf("榜首 = 单位 %d,期望 %d(%+v)", rows[0].TeamID, tc.want, rows) + } + }) + } +} + +// case 14:破纪录多级别 → 取最高档(max 模式)。 +func TestMatrix14_RecordBonusMax(t *testing.T) { + cfg := RecordBonusConfig{ + Enabled: true, Mode: RecordBonusMax, + Levels: []RecordLevel{ + {Code: "meet", Points: 2}, + {Code: "city", Points: 5}, + {Code: "province", Points: 8}, + }, + } + breaks := []RecordBreak{ + {ID: 1, EntryID: 7, EventID: 1, Level: "meet", State: RecordBreakApproved}, + {ID: 2, EntryID: 7, EventID: 1, Level: "city", State: RecordBreakApproved}, + {ID: 3, EntryID: 7, EventID: 1, Level: "province", State: RecordBreakApproved}, + } + got := RecordBonusPoints(cfg, breaks) + if len(got) != 1 || !nearlyEqual(got[0].Points, 8) { + t.Fatalf("max 模式 = %+v,期望 8 分一条", got) + } + if len(got[0].Levels) != 3 { + t.Errorf("档位记录 = %v,期望记录全部 3 个档位", got[0].Levels) + } +} + +// case 15:破纪录多级别(sum 模式)→ 累加。 +func TestMatrix15_RecordBonusSum(t *testing.T) { + cfg := RecordBonusConfig{ + Enabled: true, Mode: RecordBonusSum, + Levels: []RecordLevel{ + {Code: "meet", Points: 2}, + {Code: "city", Points: 5}, + {Code: "province", Points: 8}, + }, + } + breaks := []RecordBreak{ + {ID: 1, EntryID: 7, EventID: 1, Level: "meet", State: RecordBreakApproved}, + {ID: 2, EntryID: 7, EventID: 1, Level: "city", State: RecordBreakApproved}, + {ID: 3, EntryID: 7, EventID: 1, Level: "province", State: RecordBreakApproved}, + } + got := RecordBonusPoints(cfg, breaks) + if len(got) != 1 || !nearlyEqual(got[0].Points, 15) { + t.Fatalf("sum 模式 = %+v,期望 15 分一条", got) + } +} + +// case 16:一条成绩归属两个单位(联队)→ 按配置拆分或整体归属。 +func TestMatrix16_UnionEntry(t *testing.T) { + ps := []participant{{ + entryID: 1, athleteID: 101, teamID: 1001, name: "联队选手", value: 1, + shares: []TeamShare{{TeamID: 1001, Weight: 3}, {TeamID: 1002, Weight: 2}}, + }} + entries, results := fixture(ps) + + split := mustScore(t, EventScoreInput{ + Event: individualEvent(1), Category: testCategory(), + Channels: []ScoreChannel{athleteResultChannel(1)}, + Profiles: map[int64]ScoringProfile{1: individualTop6()}, + Entries: entries, Results: results, + Options: ScoreOptions{UnionAttribution: UnionSplit}, + }) + byTeam := pointsByTeam(split.Ledger) + if !nearlyEqual(byTeam[1001], 6) || !nearlyEqual(byTeam[1002], 4) { + t.Fatalf("拆分归属 = %v,期望 1001:6 / 1002:4", byTeam) + } + + whole := mustScore(t, EventScoreInput{ + Event: individualEvent(1), Category: testCategory(), + Channels: []ScoreChannel{athleteResultChannel(1)}, + Profiles: map[int64]ScoringProfile{1: individualTop6()}, + Entries: entries, Results: results, + Options: ScoreOptions{UnionAttribution: UnionWhole}, + }) + byTeam = pointsByTeam(whole.Ledger) + if !nearlyEqual(byTeam[1001], 10) || byTeam[1002] != 0 { + t.Fatalf("整体归属 = %v,期望 1001:10 / 1002:0", byTeam) + } +} + +// case 17:重复发布同一组 → 幂等,不产生重复流水。 +func TestMatrix17_RepublishIdempotent(t *testing.T) { + entries, results := fixture(lineup(3)) + in := EventScoreInput{ + Event: individualEvent(1), Category: testCategory(), + Channels: []ScoreChannel{athleteResultChannel(1)}, + Profiles: map[int64]ScoringProfile{1: individualTop6()}, + Entries: entries, Results: results, + } + first := mustScore(t, in) + second := mustScore(t, in) + + rebuilt, err := RecomputeEventLedger(first.Ledger, 1, second.Ledger, "admin", "重复发布") + if err != nil { + t.Fatalf("重算流水失败: %v", err) + } + if len(rebuilt) != len(first.Ledger) { + t.Fatalf("重复发布后流水条数 = %d,期望 %d(幂等)", len(rebuilt), len(first.Ledger)) + } + before := SumByTeam(first.Ledger, 1) + after := SumByTeam(rebuilt, 1) + for id, p := range before { + if !nearlyEqual(after[id].Total, p.Total) { + t.Errorf("单位 %d 总分 = %v,期望 %v", id, after[id].Total, p.Total) + } + } +} + +// case 18:乱序应用增量 → 最终状态一致。 +func TestMatrix18_OutOfOrderIncrements(t *testing.T) { + makeScore := func(eventID int64) []LedgerEntry { + entries, results := fixture([]participant{{entryID: eventID, athleteID: eventID, teamID: eventID, value: 1}}) + entries[0].EventID = eventID + results[0].EventID = eventID + return mustScore(t, EventScoreInput{ + Event: Event{ID: eventID, Compare: CompareAsc, Scored: true, Multiplier: 1}, + Category: testCategory(), + Channels: []ScoreChannel{athleteResultChannel(1)}, + Profiles: map[int64]ScoringProfile{1: individualTop6()}, + Entries: entries, Results: results, + }).Ledger + } + a := makeScore(11) + b := makeScore(22) + + appendA := LedgerOp{Kind: LedgerOpAppend, Entries: a} + appendB := LedgerOp{Kind: LedgerOpAppend, Entries: b} + voidA := LedgerOp{Kind: LedgerOpVoidSource, SourceType: SourceResult, SourceID: a[0].SourceID, By: "admin", Reason: "撤销"} + + orders := map[string][]LedgerOp{ + "A→B→撤销A": {appendA, appendB, voidA}, + "B→A→撤销A": {appendB, appendA, voidA}, + "A→撤销A→B": {appendA, voidA, appendB}, + } + + var baseline map[int64]TeamPoints + for name, ops := range orders { + out, err := ApplyLedgerOps(nil, ops) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + got := SumByTeam(out, 1) + if baseline == nil { + baseline = got + continue + } + if len(got) != len(baseline) { + t.Fatalf("%s: 单位数 %d,期望 %d", name, len(got), len(baseline)) + } + for id, p := range baseline { + if !nearlyEqual(got[id].Total, p.Total) { + t.Errorf("%s: 单位 %d 总分 = %v,期望 %v", name, id, got[id].Total, p.Total) + } + } + } + if !nearlyEqual(baseline[11].Total, 0) || !nearlyEqual(baseline[22].Total, 10) { + t.Fatalf("最终状态 = %+v,期望 A 作废 0 分、B 保留 10 分", baseline) + } +} diff --git a/server/internal/domain/records.go b/server/internal/domain/records.go new file mode 100644 index 0000000..e536a39 --- /dev/null +++ b/server/internal/domain/records.go @@ -0,0 +1,136 @@ +package domain + +import "sort" + +// levelPoints 按档位编码取分值;分值全部来自规则包,代码不内置任何数字。 +func (c RecordBonusConfig) levelPoints(code string) (float64, bool) { + for _, l := range c.Levels { + if l.Code == code { + return l.Points, true + } + } + return 0, false +} + +// RecordBonusPoints 把「已审核通过」的破纪录登记按报名项聚合: +// 同一人同时破多级时,max 取最高档、sum 累加,口径由配置决定。 +// 未配置的档位视为无加分,避免脏 level 凭空加分。 +func RecordBonusPoints(cfg RecordBonusConfig, breaks []RecordBreak) []RecordBonus { + if !cfg.Enabled || len(breaks) == 0 { + return nil + } + + type group struct { + first RecordBreak + levels []string + points []float64 + } + order := make([]int64, 0, len(breaks)) + groups := make(map[int64]*group) + + for _, b := range breaks { + if b.State != RecordBreakApproved { + continue + } + pts, ok := cfg.levelPoints(b.Level) + if !ok { + continue + } + g, exists := groups[b.EntryID] + if !exists { + g = &group{first: b} + groups[b.EntryID] = g + order = append(order, b.EntryID) + } + seen := false + for _, code := range g.levels { + if code == b.Level { + seen = true + break + } + } + if !seen { + g.levels = append(g.levels, b.Level) + g.points = append(g.points, pts) + } + } + + out := make([]RecordBonus, 0, len(order)) + for _, entryID := range order { + g := groups[entryID] + bonus := RecordBonus{ + EntryID: entryID, + EventID: g.first.EventID, + AthleteID: g.first.AthleteID, + TeamID: g.first.TeamID, + Levels: g.levels, + } + if cfg.Mode == RecordBonusSum { + for _, p := range g.points { + bonus.Points += p + } + } else { + for _, p := range g.points { + if p > bonus.Points { + bonus.Points = p + } + } + } + out = append(out, bonus) + } + sort.SliceStable(out, func(i, j int) bool { return out[i].EntryID < out[j].EntryID }) + return out +} + +// recordBonusLedger 把破纪录加分写成流水。 +// 加分与成绩分相互独立:不乘项目权重、也不乘组别权重(case 6 的口径)。 +func recordBonusLedger(in EventScoreInput) ([]LedgerEntry, error) { + if in.RecordBonus == nil || !in.RecordBonus.Enabled || len(in.RecordBreaks) == 0 { + return nil, nil + } + + breaks := make([]RecordBreak, 0, len(in.RecordBreaks)) + for _, b := range in.RecordBreaks { + if in.Event.ID != 0 && b.EventID != 0 && b.EventID != in.Event.ID { + continue + } + breaks = append(breaks, b) + } + bonuses := RecordBonusPoints(*in.RecordBonus, breaks) + if len(bonuses) == 0 { + return nil, nil + } + + byID := make(map[int64]Entry, len(in.Entries)) + for _, e := range in.Entries { + byID[e.ID] = e + } + + var out []LedgerEntry + for _, b := range bonuses { + if b.Points == 0 { + continue + } + e, ok := byID[b.EntryID] + if !ok { + continue + } + athleteID := b.AthleteID + if athleteID == 0 { + athleteID = e.AthleteID + } + for _, s := range e.resolvedShares(in.Options.UnionAttribution) { + out = append(out, LedgerEntry{ + TeamID: s.TeamID, + AthleteID: athleteID, + EventID: in.Event.ID, + CategoryID: in.Category.ID, + EntryID: e.ID, + SourceType: SourceRecordBonus, + SourceID: e.ID, + Points: b.Points * s.Weight, + }) + } + } + return out, nil +} diff --git a/server/internal/domain/records_test.go b/server/internal/domain/records_test.go new file mode 100644 index 0000000..be11f13 --- /dev/null +++ b/server/internal/domain/records_test.go @@ -0,0 +1,195 @@ +package domain + +import "testing" + +func bonusConfig(mode RecordBonusMode) RecordBonusConfig { + return RecordBonusConfig{ + Enabled: true, Mode: mode, + Levels: []RecordLevel{ + {Code: "meet", Name: "赛会纪录", Points: 2}, + {Code: "city", Name: "市级纪录", Points: 5}, + {Code: "province", Name: "省级纪录", Points: 8}, + }, + } +} + +func TestRecordBonusDisabled(t *testing.T) { + cfg := bonusConfig(RecordBonusMax) + cfg.Enabled = false + got := RecordBonusPoints(cfg, []RecordBreak{{EntryID: 1, Level: "meet", State: RecordBreakApproved}}) + if got != nil { + t.Fatalf("未启用时 = %+v,期望 nil", got) + } +} + +func TestRecordBonusStateFilter(t *testing.T) { + cases := []struct { + name string + state RecordBreakState + want float64 + }{ + {"已通过计入", RecordBreakApproved, 2}, + {"待审核不计入", RecordBreakPending, 0}, + {"已驳回不计入", RecordBreakRejected, 0}, + {"状态空值不计入", "", 0}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := RecordBonusPoints(bonusConfig(RecordBonusMax), []RecordBreak{{EntryID: 1, Level: "meet", State: tc.state}}) + if tc.want == 0 { + if len(got) != 0 { + t.Fatalf("结果 = %+v,期望空", got) + } + return + } + if len(got) != 1 || !nearlyEqual(got[0].Points, tc.want) { + t.Fatalf("结果 = %+v,期望 %v 分", got, tc.want) + } + }) + } +} + +func TestRecordBonusUnknownLevel(t *testing.T) { + got := RecordBonusPoints(bonusConfig(RecordBonusMax), []RecordBreak{{EntryID: 1, Level: "galaxy", State: RecordBreakApproved}}) + if len(got) != 0 { + t.Fatalf("未配置档位 = %+v,期望空(不能凭空加分)", got) + } +} + +func TestRecordBonusModes(t *testing.T) { + breaks := []RecordBreak{ + {ID: 1, EntryID: 1, Level: "meet", State: RecordBreakApproved}, + {ID: 2, EntryID: 1, Level: "city", State: RecordBreakApproved}, + {ID: 3, EntryID: 1, Level: "province", State: RecordBreakApproved}, + } + cases := []struct { + name string + mode RecordBonusMode + want float64 + }{ + {"max 取最高档", RecordBonusMax, 8}, + {"sum 累加", RecordBonusSum, 15}, + {"模式空值按 max", "", 8}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := RecordBonusPoints(bonusConfig(tc.mode), breaks) + if len(got) != 1 || !nearlyEqual(got[0].Points, tc.want) { + t.Fatalf("结果 = %+v,期望 %v", got, tc.want) + } + }) + } +} + +func TestRecordBonusGroupsByEntry(t *testing.T) { + breaks := []RecordBreak{ + {ID: 1, EntryID: 2, Level: "meet", State: RecordBreakApproved}, + {ID: 2, EntryID: 1, Level: "city", State: RecordBreakApproved}, + {ID: 3, EntryID: 1, Level: "meet", State: RecordBreakApproved}, + } + got := RecordBonusPoints(bonusConfig(RecordBonusMax), breaks) + if len(got) != 2 { + t.Fatalf("条数 = %d,期望 2(按报名项分组)", len(got)) + } + if got[0].EntryID != 1 || got[1].EntryID != 2 { + t.Fatalf("顺序 = %+v,期望按 EntryID 升序", got) + } + if !nearlyEqual(got[0].Points, 5) || !nearlyEqual(got[1].Points, 2) { + t.Fatalf("分值 = %v/%v,期望 5/2", got[0].Points, got[1].Points) + } +} + +func TestRecordBonusDedupLevel(t *testing.T) { + breaks := []RecordBreak{ + {ID: 1, EntryID: 1, Level: "meet", State: RecordBreakApproved}, + {ID: 2, EntryID: 1, Level: "meet", State: RecordBreakApproved}, + } + got := RecordBonusPoints(bonusConfig(RecordBonusSum), breaks) + if len(got) != 1 || !nearlyEqual(got[0].Points, 2) { + t.Fatalf("重复档位 = %+v,期望只计一次 2 分", got) + } + if len(got[0].Levels) != 1 { + t.Fatalf("档位列表 = %v,期望去重", got[0].Levels) + } +} + +func TestRecordBonusNoBreaks(t *testing.T) { + if got := RecordBonusPoints(bonusConfig(RecordBonusMax), nil); got != nil { + t.Fatalf("无破纪录登记 = %+v,期望 nil", got) + } +} + +func TestRecordBonusLedger(t *testing.T) { + entries, results := fixture(lineup(2)) + base := EventScoreInput{ + Event: individualEvent(1), Category: testCategory(), + Channels: []ScoreChannel{athleteResultChannel(1)}, + Profiles: map[int64]ScoringProfile{1: individualTop6()}, + Entries: entries, Results: results, + RecordBreaks: []RecordBreak{{ + ID: 1, EventID: 1, EntryID: 1, AthleteID: 101, TeamID: 1001, + Level: "meet", State: RecordBreakApproved, + }}, + } + + t.Run("未启用不产生加分流水", func(t *testing.T) { + cfg := bonusConfig(RecordBonusMax) + cfg.Enabled = false + base.RecordBonus = &cfg + es := mustScore(t, base) + for _, e := range es.Ledger { + if e.SourceType == SourceRecordBonus { + t.Fatalf("不应有加分流水: %+v", e) + } + } + }) + + t.Run("启用后归属到人", func(t *testing.T) { + cfg := bonusConfig(RecordBonusMax) + base.RecordBonus = &cfg + es := mustScore(t, base) + var found bool + for _, e := range es.Ledger { + if e.SourceType != SourceRecordBonus { + continue + } + found = true + if e.TeamID != 1001 || e.AthleteID != 101 || !nearlyEqual(e.Points, 2) { + t.Fatalf("加分流水 = %+v,期望 1001/101 记 2 分", e) + } + } + if !found { + t.Fatal("未产生加分流水") + } + }) + + t.Run("联队按份额拆分加分", func(t *testing.T) { + ps := []participant{{ + entryID: 1, athleteID: 101, teamID: 1001, value: 1, + shares: []TeamShare{{TeamID: 1001, Weight: 3}, {TeamID: 1002, Weight: 2}}, + }} + e2, r2 := fixture(ps) + cfg := bonusConfig(RecordBonusMax) + es := mustScore(t, EventScoreInput{ + Event: individualEvent(1), Category: testCategory(), + Channels: []ScoreChannel{athleteResultChannel(1)}, + Profiles: map[int64]ScoringProfile{1: individualTop6()}, + Entries: e2, Results: r2, + RecordBonus: &cfg, + RecordBreaks: []RecordBreak{{ + ID: 1, EventID: 1, EntryID: 1, AthleteID: 101, TeamID: 1001, + Level: "meet", State: RecordBreakApproved, + }}, + Options: ScoreOptions{UnionAttribution: UnionSplit}, + }) + var bonus float64 + for _, e := range es.Ledger { + if e.SourceType == SourceRecordBonus && e.TeamID == 1002 { + bonus += e.Points + } + } + if !nearlyEqual(bonus, 0.8) { + t.Fatalf("单位1002 的加分 = %v,期望 2 × 0.4 = 0.8", bonus) + } + }) +} diff --git a/server/internal/domain/scoring.go b/server/internal/domain/scoring.go new file mode 100644 index 0000000..cae0fa6 --- /dev/null +++ b/server/internal/domain/scoring.go @@ -0,0 +1,536 @@ +package domain + +import ( + "fmt" + "math" + "sort" +) + +// rankEpsilon 用于判定「成绩相同」。计时/测距保留两位小数,1e-9 足够区分。 +const rankEpsilon = 1e-9 + +// ScoreOptions 控制引擎的可选行为;零值即本次运动会的口径。 +type ScoreOptions struct { + // UnionAttribution 决定联队成绩的归属;空值等同 UnionWhole(整体记给主单位)。 + UnionAttribution UnionAttribution + // IncludeDrafts 为 true 时草稿成绩也参与计分;默认只算已发布(草稿不上屏也不计分)。 + IncludeDrafts bool +} + +// EventScoreInput 是一次小项(某组别)计分的全部输入,引擎只读不改。 +type EventScoreInput struct { + Event Event + Category EventCategory + Channels []ScoreChannel + Profiles map[int64]ScoringProfile + Entries []Entry + Results []Result + RecordBonus *RecordBonusConfig + RecordBreaks []RecordBreak + Options ScoreOptions +} + +// EventScore 是一次小项计分的结果。 +type EventScore struct { + Ledger []LedgerEntry + Ranked []RankedResult +} + +// RankedResult 记录「某条报名项在某通道下拿到的名次与原始积分」。 +// Points 尚未按联队份额拆分、也未乘个人→单位系数,因此可直接用于奖牌判定与分项成绩表。 +type RankedResult struct { + EntryID int64 + ResultID int64 + EventID int64 + CategoryID int64 + ChannelID int64 + Scope Scope + Basis Basis + AthleteID int64 + AthleteName string + TeamID int64 + Shares []TeamShare + Rank int + Points float64 + ResultValue *float64 + Status ResultStatus +} + +// ScoreEvent 按项目的计分通道逐条计算名次与积分,输出流水与名次明细。 +// 项目 scored=false 或没有通道时不产生任何流水(只排名不计分)。 +func ScoreEvent(in EventScoreInput) (EventScore, error) { + var out EventScore + if !in.Event.Scored { + return out, nil + } + + for _, ch := range sortedChannels(in.Channels) { + profile, ok := in.Profiles[ch.ProfileID] + if !ok { + return EventScore{}, fmt.Errorf("%w: 通道 %d 引用模板 %d", ErrProfileNotFound, ch.ID, ch.ProfileID) + } + ranked, err := scoreChannel(in, ch, profile) + if err != nil { + return EventScore{}, err + } + out.Ranked = append(out.Ranked, ranked...) + out.Ledger = append(out.Ledger, ledgerFromRanked(in, ch, ranked)...) + } + + bonus, err := recordBonusLedger(in) + if err != nil { + return EventScore{}, err + } + out.Ledger = append(out.Ledger, bonus...) + return out, nil +} + +// sortedChannels 固定通道处理顺序,保证同一份输入每次产出同样的流水顺序。 +func sortedChannels(channels []ScoreChannel) []ScoreChannel { + out := append([]ScoreChannel(nil), channels...) + sort.SliceStable(out, func(i, j int) bool { + if out[i].SortOrder != out[j].SortOrder { + return out[i].SortOrder < out[j].SortOrder + } + return out[i].ID < out[j].ID + }) + return out +} + +func scoreChannel(in EventScoreInput, ch ScoreChannel, profile ScoringProfile) ([]RankedResult, error) { + switch ch.Basis { + case BasisResult: + return scoreByResult(in, ch, profile) + case BasisManual: + return scoreByManual(in, ch, profile) + case BasisAggregate: + return scoreByAggregate(in, ch, profile) + default: + return nil, fmt.Errorf("%w: %q", ErrUnknownBasis, ch.Basis) + } +} + +// candidate 是排名过程的中间态:一条可参与排名的报名项 + 它的比较值。 +type candidate struct { + entry Entry + result Result + subject int64 // 聚合通道的排名主体(单位或运动员) + value float64 // 排序用比较值 + rankIn int // manual 通道录入的名次 +} + +// collectCandidates 过滤出本通道真正参与排名的报名项: +// 只取已发布、状态正常、且带有本通道所需数据的成绩。 +func collectCandidates(in EventScoreInput, ch ScoreChannel) ([]candidate, error) { + if ch.Basis == BasisResult && in.Event.Compare == CompareNone { + return nil, ErrCompareRequired + } + if ch.Basis == BasisAggregate && in.Event.Compare == CompareNone { + return nil, ErrCompareRequired + } + + byEntry := make(map[int64]Result, len(in.Results)) + for _, r := range in.Results { + byEntry[r.EntryID] = r + } + + out := make([]candidate, 0, len(in.Entries)) + for _, e := range in.Entries { + if in.Event.ID != 0 && e.EventID != 0 && e.EventID != in.Event.ID { + continue + } + if in.Category.ID != 0 && e.CategoryID != in.Category.ID { + continue + } + if !e.Status.rankable() { + continue + } + r, ok := byEntry[e.ID] + if !ok { + continue + } + if r.State == ResultStateDraft && !in.Options.IncludeDrafts { + continue + } + if !r.Status.rankable() { + continue + } + + c := candidate{entry: e, result: r} + switch ch.Basis { + case BasisManual: + if r.Rank <= 0 { + continue + } + c.rankIn = r.Rank + default: // result / aggregate 都按成绩值排序 + if r.ResultValue == nil { + continue + } + c.value = *r.ResultValue + } + out = append(out, c) + } + return out, nil +} + +func scoreByResult(in EventScoreInput, ch ScoreChannel, profile ScoringProfile) ([]RankedResult, error) { + cands, err := collectCandidates(in, ch) + if err != nil { + return nil, err + } + sortCandidatesByValue(cands, in.Event.Compare) + return rankGroups(in, ch, profile, groupByValue(cands)), nil +} + +func scoreByManual(in EventScoreInput, ch ScoreChannel, profile ScoringProfile) ([]RankedResult, error) { + cands, err := collectCandidates(in, ch) + if err != nil { + return nil, err + } + sort.SliceStable(cands, func(i, j int) bool { + if cands[i].rankIn != cands[j].rankIn { + return cands[i].rankIn < cands[j].rankIn + } + return cands[i].entry.ID < cands[j].entry.ID + }) + return rankGroups(in, ch, profile, groupByManualRank(cands)), nil +} + +// tieGroup 是一组并列的参赛者;startRank 是它们共同展示的名次。 +type tieGroup struct { + startRank int + members []candidate +} + +// groupByValue 按成绩值分组:值相同即并列,名次按「占位顺推」计算(skip 口径)。 +func groupByValue(cands []candidate) []tieGroup { + var groups []tieGroup + pos := 1 + for i := 0; i < len(cands); { + j := i + 1 + for j < len(cands) && nearlyEqual(cands[j].value, cands[i].value) { + j++ + } + groups = append(groups, tieGroup{startRank: pos, members: cands[i:j]}) + pos += j - i + i = j + } + return groups +} + +// groupByManualRank 按录入名次分组:录入名次就是展示名次,不做顺推改写。 +func groupByManualRank(cands []candidate) []tieGroup { + var groups []tieGroup + for i := 0; i < len(cands); { + j := i + 1 + for j < len(cands) && cands[j].rankIn == cands[i].rankIn { + j++ + } + groups = append(groups, tieGroup{startRank: cands[i].rankIn, members: cands[i:j]}) + i = j + } + return groups +} + +// rankGroups 给每组分配积分并产出名次明细。 +func rankGroups(in EventScoreInput, ch ScoreChannel, profile ScoringProfile, groups []tieGroup) []RankedResult { + lastRank := 0 + for _, g := range groups { + if r := g.startRank + len(g.members) - 1; r > lastRank { + lastRank = r + } + } + + var out []RankedResult + for _, g := range groups { + pts := groupPoints(profile, g.startRank, len(g.members), lastRank, in.Event, in.Category) + for _, c := range g.members { + out = append(out, rankedResult(in, ch, c, g.startRank, pts)) + } + } + return out +} + +// groupPoints 计算一组并列者拿到的分。 +// 三种口径都先确定这组「占用了哪些名次」:startRank .. startRank+size-1。 +func groupPoints(profile ScoringProfile, startRank, size, lastRank int, ev Event, cat EventCategory) float64 { + if startRank <= 0 || size <= 0 { + return 0 + } + // lastPlaceZero 是对「最后一名」的覆盖;并列在末位的整组都按最后一名处理, + // 否则同组出现 0 与 1 两种分数,违背「并列同分」的语义。 + if profile.LastPlaceZero && startRank <= lastRank && lastRank <= startRank+size-1 { + return 0 + } + + switch profile.tieMode() { + case TieAverage: + var sum float64 + for r := startRank; r < startRank+size; r++ { + p, _ := basePoints(profile, r, ev, cat) + sum += p + } + return sum / float64(size) + case TieHigher: + best := 0.0 + for r := startRank; r < startRank+size; r++ { + if p, ok := basePoints(profile, r, ev, cat); ok && p > best { + best = p + } + } + return best + default: // TieSkip + p, _ := basePoints(profile, startRank, ev, cat) + return p + } +} + +// basePoints 查表得分并乘项目权重、组别权重;超出录取名次或表中无名次一律 0。 +func basePoints(profile ScoringProfile, rank int, ev Event, cat EventCategory) (float64, bool) { + if rank <= 0 { + return 0, false + } + if profile.AwardRanks > 0 && rank > profile.AwardRanks { + return 0, false + } + p, ok := profile.pointsAt(rank) + if !ok { + return 0, false + } + return p * ev.multiplier() * cat.multiplier(), true +} + +func rankedResult(in EventScoreInput, ch ScoreChannel, c candidate, rank int, points float64) RankedResult { + rr := RankedResult{ + EntryID: c.entry.ID, + ResultID: c.result.ID, + EventID: in.Event.ID, + CategoryID: in.Category.ID, + ChannelID: ch.ID, + Scope: ch.Scope, + Basis: ch.Basis, + AthleteID: c.entry.AthleteID, + AthleteName: c.entry.AthleteName, + TeamID: c.entry.primaryTeamID(), + Shares: c.entry.resolvedShares(in.Options.UnionAttribution), + Rank: rank, + Points: points, + Status: c.result.Status, + } + if c.result.ResultValue != nil { + v := *c.result.ResultValue + rr.ResultValue = &v + } + return rr +} + +// scoreByAggregate 先把同一主体的成绩汇总成一个值,再按值排名(预留能力)。 +func scoreByAggregate(in EventScoreInput, ch ScoreChannel, profile ScoringProfile) ([]RankedResult, error) { + cands, err := collectCandidates(in, ch) + if err != nil { + return nil, err + } + + method := ch.Aggregate + if method == "" { + method = AggregateSum + } + + type bucket struct { + subject int64 + entry Entry + values []float64 + } + order := make([]int64, 0) + buckets := make(map[int64]*bucket) + for _, c := range cands { + subject := c.entry.TeamID + if ch.Scope == ScopeAthlete { + subject = c.entry.AthleteID + } + if subject == 0 { + continue + } + b, ok := buckets[subject] + if !ok { + b = &bucket{subject: subject, entry: c.entry} + buckets[subject] = b + order = append(order, subject) + } + b.values = append(b.values, c.value) + } + + agg := make([]candidate, 0, len(order)) + for _, id := range order { + b := buckets[id] + c := candidate{entry: b.entry, subject: b.subject, value: aggregateValue(method, b.values)} + c.entry.ID = 0 // 聚合结果不对应单条报名项 + agg = append(agg, c) + } + sortCandidatesByValue(agg, in.Event.Compare) + + groups := groupByValue(agg) + lastRank := 0 + for _, g := range groups { + if r := g.startRank + len(g.members) - 1; r > lastRank { + lastRank = r + } + } + + var out []RankedResult + for _, g := range groups { + pts := groupPoints(profile, g.startRank, len(g.members), lastRank, in.Event, in.Category) + for _, c := range g.members { + rr := RankedResult{ + EventID: in.Event.ID, + CategoryID: in.Category.ID, + ChannelID: ch.ID, + Scope: ch.Scope, + Basis: ch.Basis, + TeamID: c.entry.primaryTeamID(), + Rank: g.startRank, + Points: pts, + } + if ch.Scope == ScopeTeam { + rr.TeamID = c.subject + rr.Shares = []TeamShare{{TeamID: c.subject, Weight: 1}} + } else { + rr.AthleteID = c.subject + rr.AthleteName = c.entry.AthleteName + rr.Shares = c.entry.resolvedShares(in.Options.UnionAttribution) + } + v := c.value + rr.ResultValue = &v + out = append(out, rr) + } + } + return out, nil +} + +func aggregateValue(method AggregateMethod, values []float64) float64 { + if len(values) == 0 { + return 0 + } + switch method { + case AggregateAvg: + var sum float64 + for _, v := range values { + sum += v + } + return sum / float64(len(values)) + case AggregateMax: + best := values[0] + for _, v := range values[1:] { + if v > best { + best = v + } + } + return best + case AggregateMin: + best := values[0] + for _, v := range values[1:] { + if v < best { + best = v + } + } + return best + case AggregateCount: + return float64(len(values)) + default: + var sum float64 + for _, v := range values { + sum += v + } + return sum + } +} + +func sortCandidatesByValue(cands []candidate, compare CompareMode) { + sort.SliceStable(cands, func(i, j int) bool { + if nearlyEqual(cands[i].value, cands[j].value) { + return cands[i].entry.ID < cands[j].entry.ID + } + if compare == CompareDesc { + return cands[i].value > cands[j].value + } + return cands[i].value < cands[j].value + }) +} + +func nearlyEqual(a, b float64) bool { + return math.Abs(a-b) <= rankEpsilon +} + +// ledgerFromRanked 把名次明细转成积分流水:按通道 scope 决定记给个人还是单位, +// 联队按份额拆分;0 分不产生流水,避免流水表被零分噪声撑大。 +func ledgerFromRanked(in EventScoreInput, ch ScoreChannel, ranked []RankedResult) []LedgerEntry { + var out []LedgerEntry + for _, rr := range ranked { + if rr.Points == 0 { + continue + } + athleteID := int64(0) + if rr.Scope == ScopeAthlete { + athleteID = rr.AthleteID + } + sourceID := rr.ResultID + if sourceID == 0 { + sourceID = rr.EntryID + } + if sourceID == 0 { + sourceID = ch.ID + } + for _, s := range rr.Shares { + pts := rr.Points * s.Weight + if pts == 0 { + continue + } + out = append(out, LedgerEntry{ + TeamID: s.TeamID, + AthleteID: athleteID, + EventID: in.Event.ID, + CategoryID: in.Category.ID, + EntryID: rr.EntryID, + ResultID: rr.ResultID, + ChannelID: ch.ID, + SourceType: SourceResult, + SourceID: sourceID, + Points: pts, + }) + } + } + return out +} + +// resolvedShares 解析报名项的归属:默认整体归主单位,联队模式才按份额拆分。 +func (e Entry) resolvedShares(mode UnionAttribution) []TeamShare { + shares := e.teamShares() + if len(shares) == 0 { + return nil + } + if mode != UnionSplit { + return []TeamShare{{TeamID: e.primaryTeamID(), Weight: 1}} + } + return normalizeShares(shares) +} + +// normalizeShares 把份额归一化到总和 1,避免配置里权重不标准导致总分被放大。 +func normalizeShares(shares []TeamShare) []TeamShare { + var sum float64 + for _, s := range shares { + sum += s.Weight + } + out := make([]TeamShare, len(shares)) + if sum <= 0 { + w := 1 / float64(len(shares)) + for i, s := range shares { + out[i] = TeamShare{TeamID: s.TeamID, Weight: w} + } + return out + } + for i, s := range shares { + out[i] = TeamShare{TeamID: s.TeamID, Weight: s.Weight / sum} + } + return out +} diff --git a/server/internal/domain/scoring_test.go b/server/internal/domain/scoring_test.go new file mode 100644 index 0000000..387200d --- /dev/null +++ b/server/internal/domain/scoring_test.go @@ -0,0 +1,601 @@ +package domain + +import ( + "errors" + "testing" +) + +// TestGroupPointsTieModes 直接验证「一组并列者拿多少分」的分支,覆盖三种口径与边界。 +func TestGroupPointsTieModes(t *testing.T) { + ev := individualEvent(1) + cat := testCategory() + + cases := []struct { + name string + profile ScoringProfile + start int + size int + last int + want float64 + }{ + {"skip 单人第1", nineProfile(TieSkip), 1, 1, 8, 9}, + {"skip 单人第2", nineProfile(TieSkip), 2, 1, 8, 7}, + {"skip 两人并列第2", nineProfile(TieSkip), 2, 2, 8, 7}, + {"average 两人并列第2", nineProfile(TieAverage), 2, 2, 8, 6.5}, + {"higher 两人并列第2", nineProfile(TieHigher), 2, 2, 8, 7}, + {"skip 三人并列第3", nineProfile(TieSkip), 3, 3, 8, 6}, + {"average 三人并列第3", nineProfile(TieAverage), 3, 3, 8, 5}, + {"higher 三人并列第3", nineProfile(TieHigher), 3, 3, 8, 6}, + {"average 两人并列第6", nineProfile(TieAverage), 6, 2, 8, 2.5}, + {"higher 两人并列第6", nineProfile(TieHigher), 6, 2, 8, 3}, + {"skip 超出录取名次", nineProfile(TieSkip), 9, 1, 9, 0}, + {"空模板口径按 skip", ScoringProfile{AwardRanks: 8, Rules: nineProfile(TieSkip).Rules}, 2, 1, 8, 7}, + {"lastPlaceZero 抹掉最后一名", ScoringProfile{AwardRanks: 8, LastPlaceZero: true, Rules: nineProfile(TieSkip).Rules}, 1, 1, 1, 0}, + {"lastPlaceZero 抹掉并列末位整组", ScoringProfile{AwardRanks: 8, LastPlaceZero: true, Rules: nineProfile(TieSkip).Rules}, 2, 2, 3, 0}, + {"权重相乘", nineProfile(TieSkip), 1, 1, 8, 9}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := groupPoints(tc.profile, tc.start, tc.size, tc.last, ev, cat) + if !nearlyEqual(got, tc.want) { + t.Fatalf("groupPoints = %v,期望 %v", got, tc.want) + } + }) + } + + t.Run("项目与组别权重相乘", func(t *testing.T) { + ev := individualEvent(2) + cat := EventCategory{ID: 1, Multiplier: 3} + got := groupPoints(nineProfile(TieSkip), 1, 1, 8, ev, cat) + if !nearlyEqual(got, 54) { + t.Fatalf("9 × 2 × 3 = %v,期望 54", got) + } + }) +} + +// TestAwardRanksAndLastPlaceZero 覆盖录取名次与「最后一名 0 分」的组合边界。 +func TestAwardRanksAndLastPlaceZero(t *testing.T) { + cases := []struct { + name string + profile ScoringProfile + n int + want []float64 + }{ + { + "录取6名,7人参赛,末位不强制0", + ScoringProfile{AwardRanks: 6, Rules: individualTop6().Rules}, + 7, []float64{10, 8, 6, 3, 2, 1, 0}, + }, + { + "录取6名,7人参赛,末位强制0", + ScoringProfile{AwardRanks: 6, LastPlaceZero: true, Rules: individualTop6().Rules}, + 7, []float64{10, 8, 6, 3, 2, 1, 0}, + }, + { + "录取6名,5人参赛,末位不强制0", + ScoringProfile{AwardRanks: 6, Rules: individualTop6().Rules}, + 5, []float64{10, 8, 6, 3, 2}, + }, + { + "录取6名,5人参赛,末位强制0", + ScoringProfile{AwardRanks: 6, LastPlaceZero: true, Rules: individualTop6().Rules}, + 5, []float64{10, 8, 6, 3, 0}, + }, + { + "只录取3名", + ScoringProfile{AwardRanks: 3, Rules: individualTop6().Rules}, + 5, []float64{10, 8, 6, 0, 0}, + }, + { + "不配录取名次时以表为准", + ScoringProfile{Rules: individualTop6().Rules}, + 8, []float64{10, 8, 6, 3, 2, 1, 0, 0}, + }, + { + "仅1人参赛且末位强制0", + ScoringProfile{AwardRanks: 6, LastPlaceZero: true, Rules: individualTop6().Rules}, + 1, []float64{0}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + entries, results := fixture(lineup(tc.n)) + es := mustScore(t, EventScoreInput{ + Event: individualEvent(1), Category: testCategory(), + Channels: []ScoreChannel{athleteResultChannel(9)}, + Profiles: map[int64]ScoringProfile{9: tc.profile}, + Entries: entries, Results: results, + }) + assertEntryPoints(t, pointsByEntry(es.Ledger), tc.want) + }) + } +} + +// TestMultiplierCombinations 验证「积分表 × 项目权重 × 组别权重」,权重缺失按 1。 +func TestMultiplierCombinations(t *testing.T) { + cases := []struct { + name string + eventMult float64 + categoryMult float64 + want float64 + }{ + {"默认 1×1", 1, 1, 10}, + {"项目权重 2", 2, 1, 20}, + {"组别权重 2", 1, 2, 20}, + {"双权重 2×3", 2, 3, 60}, + {"权重缺失按 1", 0, 0, 10}, + {"项目权重 0.5", 0.5, 1, 5}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + entries, results := fixture(lineup(1)) + es := mustScore(t, EventScoreInput{ + Event: individualEvent(tc.eventMult), + Category: EventCategory{ID: 1, Multiplier: tc.categoryMult}, + Channels: []ScoreChannel{athleteResultChannel(1)}, + Profiles: map[int64]ScoringProfile{1: individualTop6()}, + Entries: entries, Results: results, + }) + if got := pointsByEntry(es.Ledger)[1]; !nearlyEqual(got, tc.want) { + t.Fatalf("积分 = %v,期望 %v", got, tc.want) + } + }) + } +} + +// TestScopeAndLedgerAttribution 验证通道 scope 决定流水记给个人还是单位。 +func TestScopeAndLedgerAttribution(t *testing.T) { + t.Run("athlete 通道记到个人", func(t *testing.T) { + entries, results := fixture(lineup(1)) + es := mustScore(t, EventScoreInput{ + Event: individualEvent(1), Category: testCategory(), + Channels: []ScoreChannel{athleteResultChannel(1)}, + Profiles: map[int64]ScoringProfile{1: individualTop6()}, + Entries: entries, Results: results, + }) + e := es.Ledger[0] + if e.AthleteID != 101 || e.TeamID != 1001 { + t.Fatalf("流水归属 = 单位 %d / 个人 %d,期望 1001/101", e.TeamID, e.AthleteID) + } + }) + + t.Run("team 通道仅记单位", func(t *testing.T) { + ps := lineup(1) + ps[0].rank = 1 + entries, results := fixture(ps) + es := mustScore(t, EventScoreInput{ + Event: teamEvent(1), Category: testCategory(), + Channels: []ScoreChannel{teamManualChannel(2)}, + Profiles: map[int64]ScoringProfile{2: teamTop6()}, + Entries: entries, Results: results, + }) + if es.Ledger[0].AthleteID != 0 { + t.Fatalf("team 通道的 AthleteID = %d,期望 0", es.Ledger[0].AthleteID) + } + if !nearlyEqual(es.Ledger[0].Points, 15) { + t.Fatalf("团体第一名积分 = %v,期望 15", es.Ledger[0].Points) + } + }) + + t.Run("athlete 通道但报名项无运动员时退化为单位流水", func(t *testing.T) { + entries, results := fixture([]participant{{entryID: 1, teamID: 1001, value: 1}}) + es := mustScore(t, EventScoreInput{ + Event: individualEvent(1), Category: testCategory(), + Channels: []ScoreChannel{athleteResultChannel(1)}, + Profiles: map[int64]ScoringProfile{1: individualTop6()}, + Entries: entries, Results: results, + }) + if es.Ledger[0].AthleteID != 0 { + t.Fatalf("无运动员的报名项 AthleteID = %d,期望 0", es.Ledger[0].AthleteID) + } + }) +} + +// TestAggregateChannel 覆盖预留的 aggregate 通道:先汇总、再排名。 +func TestAggregateChannel(t *testing.T) { + build := func() ([]Entry, []Result) { + return fixture([]participant{ + {entryID: 1, athleteID: 1, teamID: 1001, value: 1}, + {entryID: 2, athleteID: 2, teamID: 1001, value: 2}, + {entryID: 3, athleteID: 3, teamID: 1002, value: 4}, + }) + } + cases := []struct { + name string + method AggregateMethod + compare CompareMode + wantA float64 + wantB float64 + }{ + {"sum 升序", AggregateSum, CompareAsc, 15, 10}, + {"avg 升序", AggregateAvg, CompareAsc, 15, 10}, + {"max 升序", AggregateMax, CompareAsc, 15, 10}, + {"min 升序", AggregateMin, CompareAsc, 15, 10}, + {"count 降序", AggregateCount, CompareDesc, 15, 10}, + {"count 升序", AggregateCount, CompareAsc, 10, 15}, + {"sum 降序", AggregateSum, CompareDesc, 10, 15}, + {"默认方法按 sum", "", CompareAsc, 15, 10}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + entries, results := build() + ev := teamEvent(1) + ev.Compare = tc.compare + es := mustScore(t, EventScoreInput{ + Event: ev, Category: testCategory(), + Channels: []ScoreChannel{teamAggregateChannel(2, tc.method)}, + Profiles: map[int64]ScoringProfile{2: teamTop6()}, + Entries: entries, Results: results, + }) + got := pointsByTeam(es.Ledger) + if !nearlyEqual(got[1001], tc.wantA) || !nearlyEqual(got[1002], tc.wantB) { + t.Fatalf("聚合排名 = %v,期望 1001:%v / 1002:%v", got, tc.wantA, tc.wantB) + } + }) + } + + t.Run("汇总后同分并列", func(t *testing.T) { + entries, results := fixture([]participant{ + {entryID: 1, athleteID: 1, teamID: 1001, value: 1}, + {entryID: 2, athleteID: 2, teamID: 1001, value: 3}, + {entryID: 3, athleteID: 3, teamID: 1002, value: 4}, + }) + es := mustScore(t, EventScoreInput{ + Event: teamEvent(1), Category: testCategory(), + Channels: []ScoreChannel{teamAggregateChannel(2, AggregateSum)}, + Profiles: map[int64]ScoringProfile{2: teamTop6()}, + Entries: entries, Results: results, + }) + got := pointsByTeam(es.Ledger) + if !nearlyEqual(got[1001], 15) || !nearlyEqual(got[1002], 15) { + t.Fatalf("同分并列 = %v,期望各 15", got) + } + }) +} + +// TestStatusExclusions 覆盖弃权 / DNS / DSQ 等「0 分且不占名次」的情形。 +func TestStatusExclusions(t *testing.T) { + cases := []struct { + name string + status ResultStatus + entryStatus EntryStatus + state ResultState + want []float64 + }{ + {"成绩 ok", ResultStatusOK, "", "", []float64{10, 8, 6}}, + {"成绩状态空值按 ok", "", "", "", []float64{10, 8, 6}}, + {"成绩 DSQ", ResultStatusDQ, "", "", []float64{10, 0, 8}}, + {"成绩 DNS", ResultStatusDNS, "", "", []float64{10, 0, 8}}, + {"成绩 DNF", ResultStatusDNF, "", "", []float64{10, 0, 8}}, + {"成绩 withdrawn", ResultStatusWithdrawn, "", "", []float64{10, 0, 8}}, + {"报名 scratched", "", EntryStatusScratched, "", []float64{10, 0, 8}}, + {"报名 dq", "", EntryStatusDQ, "", []float64{10, 0, 8}}, + {"报名 dns", "", EntryStatusDNS, "", []float64{10, 0, 8}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ps := lineup(3) + ps[1].status = tc.status + ps[1].entryStatus = tc.entryStatus + ps[1].state = tc.state + entries, results := fixture(ps) + es := mustScore(t, EventScoreInput{ + Event: individualEvent(1), Category: testCategory(), + Channels: []ScoreChannel{athleteResultChannel(1)}, + Profiles: map[int64]ScoringProfile{1: individualTop6()}, + Entries: entries, Results: results, + }) + assertEntryPoints(t, pointsByEntry(es.Ledger), tc.want) + }) + } +} + +// TestDraftState 验证草稿不参与计分,除非显式要求。 +func TestDraftState(t *testing.T) { + cases := []struct { + name string + state ResultState + includeDrafts bool + want []float64 + }{ + {"草稿默认不计分", ResultStateDraft, false, []float64{10, 0, 8}}, + {"草稿显式计入", ResultStateDraft, true, []float64{10, 8, 6}}, + {"已发布正常计分", ResultStatePublished, false, []float64{10, 8, 6}}, + {"状态空值按已发布", "", false, []float64{10, 8, 6}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ps := lineup(3) + ps[1].state = tc.state + entries, results := fixture(ps) + es := mustScore(t, EventScoreInput{ + Event: individualEvent(1), Category: testCategory(), + Channels: []ScoreChannel{athleteResultChannel(1)}, + Profiles: map[int64]ScoringProfile{1: individualTop6()}, + Entries: entries, Results: results, + Options: ScoreOptions{IncludeDrafts: tc.includeDrafts}, + }) + assertEntryPoints(t, pointsByEntry(es.Ledger), tc.want) + }) + } +} + +// TestScoredFlagAndNoChannels 验证「只排名不计分」与「没有通道」都不产生流水。 +func TestScoredFlagAndNoChannels(t *testing.T) { + cases := []struct { + name string + scored bool + channels []ScoreChannel + }{ + {"scored=false 配了通道也不计分", false, []ScoreChannel{athleteResultChannel(1)}}, + {"scored=true 但没有通道", true, nil}, + {"scored=false 且没有通道", false, nil}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + entries, results := fixture(lineup(3)) + ev := individualEvent(1) + ev.Scored = tc.scored + es := mustScore(t, EventScoreInput{ + Event: ev, Category: testCategory(), + Channels: tc.channels, + Profiles: map[int64]ScoringProfile{1: individualTop6()}, + Entries: entries, Results: results, + }) + if len(es.Ledger) != 0 || len(es.Ranked) != 0 { + t.Fatalf("流水/名次应为空,实际 %d/%d", len(es.Ledger), len(es.Ranked)) + } + }) + } +} + +// TestScoreEventErrors 覆盖配置类错误,避免错配置静默算错分。 +func TestScoreEventErrors(t *testing.T) { + entries, results := fixture(lineup(1)) + base := EventScoreInput{ + Event: individualEvent(1), Category: testCategory(), + Channels: []ScoreChannel{athleteResultChannel(404)}, + Profiles: map[int64]ScoringProfile{1: individualTop6()}, + Entries: entries, Results: results, + } + if _, err := ScoreEvent(base); !errors.Is(err, ErrProfileNotFound) { + t.Errorf("模板缺失错误 = %v,期望 ErrProfileNotFound", err) + } + + bad := base + bad.Channels = []ScoreChannel{{ID: 1, Scope: ScopeAthlete, Basis: "magic", ProfileID: 1}} + if _, err := ScoreEvent(bad); !errors.Is(err, ErrUnknownBasis) { + t.Errorf("未知 basis 错误 = %v,期望 ErrUnknownBasis", err) + } + + noCompare := base + noCompare.Channels = []ScoreChannel{athleteResultChannel(1)} + noCompare.Event.Compare = CompareNone + if _, err := ScoreEvent(noCompare); !errors.Is(err, ErrCompareRequired) { + t.Errorf("compare=none 错误 = %v,期望 ErrCompareRequired", err) + } + + noCompareAgg := noCompare + noCompareAgg.Channels = []ScoreChannel{teamAggregateChannel(1, AggregateSum)} + if _, err := ScoreEvent(noCompareAgg); !errors.Is(err, ErrCompareRequired) { + t.Errorf("聚合 compare=none 错误 = %v,期望 ErrCompareRequired", err) + } +} + +// TestCompareDirections 验证 asc / desc 两种成绩排序方向。 +func TestCompareDirections(t *testing.T) { + t.Run("asc 越小越好", func(t *testing.T) { + entries, results := fixture(lineupValues(12, 11, 13)) + es := mustScore(t, EventScoreInput{ + Event: individualEvent(1), Category: testCategory(), + Channels: []ScoreChannel{athleteResultChannel(1)}, + Profiles: map[int64]ScoringProfile{1: individualTop6()}, + Entries: entries, Results: results, + }) + assertEntryPoints(t, pointsByEntry(es.Ledger), []float64{8, 10, 6}) + }) + t.Run("desc 越大越好", func(t *testing.T) { + entries, results := fixture(lineupValues(12, 11, 13)) + ev := individualEvent(1) + ev.Compare = CompareDesc + es := mustScore(t, EventScoreInput{ + Event: ev, Category: testCategory(), + Channels: []ScoreChannel{athleteResultChannel(1)}, + Profiles: map[int64]ScoringProfile{1: individualTop6()}, + Entries: entries, Results: results, + }) + assertEntryPoints(t, pointsByEntry(es.Ledger), []float64{8, 6, 10}) + }) +} + +// TestMultipleChannelsDoubleCount 复现「团体引体向上」的双通道:个人通道 + 团体通道都计分。 +func TestMultipleChannelsDoubleCount(t *testing.T) { + entries, results := fixture([]participant{ + {entryID: 1, athleteID: 1, teamID: 1001, value: 1}, + {entryID: 2, athleteID: 2, teamID: 1001, value: 3}, + {entryID: 3, athleteID: 3, teamID: 1002, value: 2}, + {entryID: 4, athleteID: 4, teamID: 1002, value: 4}, + {entryID: 5, teamID: 1001, rank: 1, noValue: true}, + {entryID: 6, teamID: 1002, rank: 2, noValue: true}, + }) + es := mustScore(t, EventScoreInput{ + Event: individualEvent(1), Category: testCategory(), + Channels: []ScoreChannel{athleteResultChannel(1), teamManualChannel(2)}, + Profiles: map[int64]ScoringProfile{1: individualTop6(), 2: teamTop6()}, + Entries: entries, Results: results, + }) + + var personal, teamOnly int + for _, e := range es.Ledger { + if e.AthleteID != 0 { + personal++ + } else { + teamOnly++ + } + } + if personal != 4 || teamOnly != 2 { + t.Fatalf("流水构成 = %d 条个人 / %d 条单位,期望 4/2", personal, teamOnly) + } + + got := SumByTeam(es.Ledger, 1) + // 单位1001:团体通道15 + 个人10 + 6 = 31;单位1002:10 + 8 + 3 = 21。 + if !nearlyEqual(got[1001].Total, 31) || !nearlyEqual(got[1002].Total, 21) { + t.Fatalf("单位总分 = %v,期望 1001:31 / 1002:21", got) + } +} + +// TestSameUnitMultipleAthletesBothScore 复现文档示例 2:同单位两人都计分。 +func TestSameUnitMultipleAthletesBothScore(t *testing.T) { + entries, results := fixture([]participant{ + {entryID: 1, athleteID: 1, teamID: 1001, value: 1}, + {entryID: 2, athleteID: 2, teamID: 1002, value: 2}, + {entryID: 3, athleteID: 3, teamID: 1003, value: 3}, + {entryID: 4, athleteID: 4, teamID: 1004, value: 4}, + {entryID: 5, athleteID: 5, teamID: 1001, value: 5}, + }) + es := mustScore(t, EventScoreInput{ + Event: individualEvent(1), Category: testCategory(), + Channels: []ScoreChannel{athleteResultChannel(1)}, + Profiles: map[int64]ScoringProfile{1: individualTop6()}, + Entries: entries, Results: results, + }) + got := SumByTeam(es.Ledger, 1) + if !nearlyEqual(got[1001].Total, 12) { + t.Fatalf("单位总分 = %v,期望第1名10 + 第5名2 = 12", got[1001].Total) + } +} + +// TestOneAthleteMultipleEvents 验证一人多项独立计分、个人总分累加。 +func TestOneAthleteMultipleEvents(t *testing.T) { + entries1, results1 := fixture([]participant{{entryID: 1, athleteID: 1, teamID: 1001, value: 1}}) + entries2, results2 := fixture([]participant{{entryID: 2, athleteID: 1, teamID: 1001, value: 2}}) + entries2[0].EventID = 2 + results2[0].EventID = 2 + + ledger := mustScore(t, EventScoreInput{ + Event: individualEvent(1), Category: testCategory(), + Channels: []ScoreChannel{athleteResultChannel(1)}, + Profiles: map[int64]ScoringProfile{1: individualTop6()}, + Entries: entries1, Results: results1, + }).Ledger + ledger = append(ledger, mustScore(t, EventScoreInput{ + Event: Event{ID: 2, Compare: CompareAsc, Scored: true, Multiplier: 1}, + Category: testCategory(), + Channels: []ScoreChannel{athleteResultChannel(1)}, + Profiles: map[int64]ScoringProfile{1: individualTop6()}, + Entries: entries2, Results: results2, + }).Ledger...) + + if got := SumByAthlete(ledger)[1]; !nearlyEqual(got, 20) { + t.Fatalf("个人总分 = %v,期望 10 + 10 = 20", got) + } +} + +// TestUnionSharesNormalization 覆盖联队份额的归一化与退化情形。 +func TestUnionSharesNormalization(t *testing.T) { + score := func(shares []TeamShare, mode UnionAttribution) map[int64]float64 { + entries, results := fixture([]participant{{ + entryID: 1, athleteID: 1, teamID: 1001, value: 1, shares: shares, + }}) + return pointsByTeam(mustScore(t, EventScoreInput{ + Event: individualEvent(1), Category: testCategory(), + Channels: []ScoreChannel{athleteResultChannel(1)}, + Profiles: map[int64]ScoringProfile{1: individualTop6()}, + Entries: entries, Results: results, + Options: ScoreOptions{UnionAttribution: mode}, + }).Ledger) + } + + cases := []struct { + name string + shares []TeamShare + mode UnionAttribution + want map[int64]float64 + }{ + {"份额 3:2 拆分为 6:4", []TeamShare{{1001, 3}, {1002, 2}}, UnionSplit, map[int64]float64{1001: 6, 1002: 4}}, + {"份额 1:1 平分为 5:5", []TeamShare{{1001, 1}, {1002, 1}}, UnionSplit, map[int64]float64{1001: 5, 1002: 5}}, + {"权重为 0 的份额被丢弃", []TeamShare{{1001, 0}, {1002, 1}}, UnionSplit, map[int64]float64{1002: 10}}, + {"份额全非法时退回主单位", []TeamShare{{1001, 0}, {1002, 0}}, UnionSplit, map[int64]float64{1001: 10}}, + {"无份额时归主单位", nil, UnionSplit, map[int64]float64{1001: 10}}, + {"整体归属只记主单位", []TeamShare{{1001, 3}, {1002, 2}}, UnionWhole, map[int64]float64{1001: 10}}, + {"归属口径空值等同整体", []TeamShare{{1001, 3}, {1002, 2}}, "", map[int64]float64{1001: 10}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := score(tc.shares, tc.mode) + for id, want := range tc.want { + if !nearlyEqual(got[id], want) { + t.Fatalf("单位 %d 得分 = %v,期望 %v(全部 %v)", id, got[id], want, got) + } + } + for id, p := range got { + if p != 0 { + if _, ok := tc.want[id]; !ok { + t.Fatalf("单位 %d 不应得分,实际 %v", id, p) + } + } + } + }) + } +} + +// TestTieDetectionEpsilon 验证「成绩相同」的判定精度。 +func TestTieDetectionEpsilon(t *testing.T) { + t.Run("差值在容差内视为并列", func(t *testing.T) { + entries, results := fixture(lineupValues(12.01, 12.01+1e-10, 13)) + es := mustScore(t, EventScoreInput{ + Event: individualEvent(1), Category: testCategory(), + Channels: []ScoreChannel{athleteResultChannel(1)}, + Profiles: map[int64]ScoringProfile{1: individualTop6()}, + Entries: entries, Results: results, + }) + assertEntryPoints(t, pointsByEntry(es.Ledger), []float64{10, 10, 6}) + }) + t.Run("差值超过容差不算并列", func(t *testing.T) { + entries, results := fixture(lineupValues(12.01, 12.02, 13)) + es := mustScore(t, EventScoreInput{ + Event: individualEvent(1), Category: testCategory(), + Channels: []ScoreChannel{athleteResultChannel(1)}, + Profiles: map[int64]ScoringProfile{1: individualTop6()}, + Entries: entries, Results: results, + }) + assertEntryPoints(t, pointsByEntry(es.Ledger), []float64{10, 8, 6}) + }) +} + +// TestMissingResultOrEntry 验证缺成绩、成绩指向不存在的报名项都不会算出分。 +func TestMissingResultOrEntry(t *testing.T) { + t.Run("没有成绩的报名项不计分", func(t *testing.T) { + entries, results := fixture(lineup(3)) + results = results[:2] // entry3 还没有成绩 + es := mustScore(t, EventScoreInput{ + Event: individualEvent(1), Category: testCategory(), + Channels: []ScoreChannel{athleteResultChannel(1)}, + Profiles: map[int64]ScoringProfile{1: individualTop6()}, + Entries: entries, Results: results, + }) + assertEntryPoints(t, pointsByEntry(es.Ledger), []float64{10, 8, 0}) + }) + t.Run("孤儿成绩被忽略", func(t *testing.T) { + entries, results := fixture(lineup(2)) + results = append(results, Result{ID: 999, EventID: 1, EntryID: 404, ResultValue: ptr(0.5), State: ResultStatePublished}) + es := mustScore(t, EventScoreInput{ + Event: individualEvent(1), Category: testCategory(), + Channels: []ScoreChannel{athleteResultChannel(1)}, + Profiles: map[int64]ScoringProfile{1: individualTop6()}, + Entries: entries, Results: results, + }) + assertEntryPoints(t, pointsByEntry(es.Ledger), []float64{10, 8}) + }) + t.Run("别的组别的成绩不串场", func(t *testing.T) { + entries, results := fixture(lineup(3)) + entries[2].CategoryID = 2 + es := mustScore(t, EventScoreInput{ + Event: individualEvent(1), Category: testCategory(), + Channels: []ScoreChannel{athleteResultChannel(1)}, + Profiles: map[int64]ScoringProfile{1: individualTop6()}, + Entries: entries, Results: results, + }) + assertEntryPoints(t, pointsByEntry(es.Ledger), []float64{10, 8, 0}) + }) +} diff --git a/server/internal/domain/standings.go b/server/internal/domain/standings.go new file mode 100644 index 0000000..6cecba0 --- /dev/null +++ b/server/internal/domain/standings.go @@ -0,0 +1,279 @@ +package domain + +import "sort" + +// StandingsInput 是排名聚合的输入:流水 + 名次明细 + 单位/运动员基础信息。 +type StandingsInput struct { + Teams []Team + Athletes []Athlete + Ledger []LedgerEntry + Ranked []RankedResult + // IndividualToTeamWeight 个人积分折算进单位总分的系数;<=0 时按 1 处理。 + IndividualToTeamWeight float64 + // ExcludeTeamEventMedals 为 true 时团体奖牌榜不计团体项目奖牌;默认计入。 + ExcludeTeamEventMedals bool +} + +type medalTally struct { + gold, silver, bronze int +} + +// rankMedal 把名次映射成奖牌:1 金 / 2 银 / 3 铜,其余无名次。 +func rankMedal(rank int) int { + switch rank { + case 1: + return 1 + case 2: + return 2 + case 3: + return 3 + default: + return 0 + } +} + +// BuildTeamStandings 生成总积分榜。 +// 总分 = Σ(仅记单位的流水) + 系数 × Σ(记到个人的流水);排序:总分 → 金 → 银 → 铜 → 单位编码。 +func BuildTeamStandings(in StandingsInput) []StandingsRow { + weight := effectiveWeight(in.IndividualToTeamWeight) + sums := SumByTeam(in.Ledger, weight) + medals := teamMedals(in) + + rows := make([]StandingsRow, 0, len(in.Teams)) + seen := make(map[int64]bool, len(in.Teams)) + for _, t := range in.Teams { + if seen[t.ID] { + continue + } + seen[t.ID] = true + rows = append(rows, buildTeamRow(t, sums[t.ID], medals[t.ID])) + } + // 兜底:流水/奖牌里出现但单位表没提供的单位也要上榜,否则分数会被静默吞掉。 + for id := range sums { + if !seen[id] { + seen[id] = true + rows = append(rows, buildTeamRow(Team{ID: id}, sums[id], medals[id])) + } + } + for id, m := range medals { + if !seen[id] { + seen[id] = true + rows = append(rows, buildTeamRow(Team{ID: id}, sums[id], m)) + } + } + + sort.SliceStable(rows, func(i, j int) bool { return lessTeamRow(rows[i], rows[j]) }) + for i := range rows { + rows[i].Rank = i + 1 + } + return rows +} + +// BuildMedalStandings 生成团体奖牌榜。 +// 排序口径与总积分榜一致(总分优先),只是列不同,保证两块榜的单位顺序不会打架。 +func BuildMedalStandings(in StandingsInput) []MedalRow { + teamRows := BuildTeamStandings(in) + out := make([]MedalRow, len(teamRows)) + for i, r := range teamRows { + out[i] = MedalRow{ + Rank: r.Rank, + TeamID: r.TeamID, + Code: r.Code, + Name: r.Name, + ShortName: r.ShortName, + Points: r.Points, + Gold: r.Gold, + Silver: r.Silver, + Bronze: r.Bronze, + Total: r.Gold + r.Silver + r.Bronze, + } + } + return out +} + +// BuildAthleteStandings 生成个人积分奖牌榜。 +// 排序:总分 → 金 → 银 → 铜 → 姓名;个人榜的奖牌只统计个人项目(团体项目奖牌归团体榜)。 +func BuildAthleteStandings(in StandingsInput) []AthleteStandingsRow { + points := SumByAthlete(in.Ledger) + medals := athleteMedals(in) + + names := make(map[int64]string, len(in.Athletes)) + teams := make(map[int64]int64, len(in.Athletes)) + for _, a := range in.Athletes { + names[a.ID] = a.Name + teams[a.ID] = a.TeamID + } + for _, rr := range in.Ranked { + if rr.AthleteID == 0 { + continue + } + if names[rr.AthleteID] == "" { + names[rr.AthleteID] = rr.AthleteName + } + if _, ok := teams[rr.AthleteID]; !ok { + teams[rr.AthleteID] = rr.TeamID + } + } + + teamByID := make(map[int64]Team, len(in.Teams)) + for _, t := range in.Teams { + teamByID[t.ID] = t + } + + seen := make(map[int64]bool, len(points)+len(medals)) + ids := make([]int64, 0, len(points)+len(medals)) + add := func(id int64) { + if id == 0 || seen[id] { + return + } + seen[id] = true + ids = append(ids, id) + } + for id := range points { + add(id) + } + for id := range medals { + add(id) + } + + rows := make([]AthleteStandingsRow, 0, len(ids)) + for _, id := range ids { + t := teamByID[teams[id]] + m := medals[id] + rows = append(rows, AthleteStandingsRow{ + AthleteID: id, + Name: names[id], + TeamID: teams[id], + TeamCode: t.Code, + TeamName: t.Name, + Points: points[id], + Gold: m.gold, + Silver: m.silver, + Bronze: m.bronze, + Medals: m.gold + m.silver + m.bronze, + }) + } + + sort.SliceStable(rows, func(i, j int) bool { return lessAthleteRow(rows[i], rows[j]) }) + for i := range rows { + rows[i].Rank = i + 1 + } + return rows +} + +func effectiveWeight(weight float64) float64 { + if weight <= 0 { + return 1 + } + return weight +} + +func buildTeamRow(t Team, p TeamPoints, m medalTally) StandingsRow { + return StandingsRow{ + TeamID: t.ID, + Code: t.Code, + Name: t.Name, + ShortName: t.ShortName, + Points: p.Total, + TeamOnlyPoints: p.TeamOnly, + IndividualPoints: p.Individual, + Gold: m.gold, + Silver: m.silver, + Bronze: m.bronze, + Medals: m.gold + m.silver + m.bronze, + } +} + +// teamMedals 统计团体奖牌榜。联队拆分时两个单位各记一枚: +// 联合组队拿到的名次对每个成员单位都是「本单位取得的名次」。 +func teamMedals(in StandingsInput) map[int64]medalTally { + out := make(map[int64]medalTally) + for _, rr := range in.Ranked { + if rr.Scope == ScopeTeam && in.ExcludeTeamEventMedals { + continue + } + medal := rankMedal(rr.Rank) + if medal == 0 { + continue + } + for _, s := range rr.Shares { + t := out[s.TeamID] + switch medal { + case 1: + t.gold++ + case 2: + t.silver++ + case 3: + t.bronze++ + } + out[s.TeamID] = t + } + } + return out +} + +// athleteMedals 只统计个人项目(scope=athlete)的奖牌。 +func athleteMedals(in StandingsInput) map[int64]medalTally { + out := make(map[int64]medalTally) + for _, rr := range in.Ranked { + if rr.Scope != ScopeAthlete || rr.AthleteID == 0 { + continue + } + medal := rankMedal(rr.Rank) + if medal == 0 { + continue + } + t := out[rr.AthleteID] + switch medal { + case 1: + t.gold++ + case 2: + t.silver++ + case 3: + t.bronze++ + } + out[rr.AthleteID] = t + } + return out +} + +func lessTeamRow(a, b StandingsRow) bool { + if !nearlyEqual(a.Points, b.Points) { + return a.Points > b.Points + } + if a.Gold != b.Gold { + return a.Gold > b.Gold + } + if a.Silver != b.Silver { + return a.Silver > b.Silver + } + if a.Bronze != b.Bronze { + return a.Bronze > b.Bronze + } + if a.Code != b.Code { + return a.Code < b.Code + } + return a.TeamID < b.TeamID +} + +func lessAthleteRow(a, b AthleteStandingsRow) bool { + if !nearlyEqual(a.Points, b.Points) { + return a.Points > b.Points + } + if a.Gold != b.Gold { + return a.Gold > b.Gold + } + if a.Silver != b.Silver { + return a.Silver > b.Silver + } + if a.Bronze != b.Bronze { + return a.Bronze > b.Bronze + } + if a.Name != b.Name { + return a.Name < b.Name + } + if a.TeamCode != b.TeamCode { + return a.TeamCode < b.TeamCode + } + return a.AthleteID < b.AthleteID +} diff --git a/server/internal/domain/standings_test.go b/server/internal/domain/standings_test.go new file mode 100644 index 0000000..e11f564 --- /dev/null +++ b/server/internal/domain/standings_test.go @@ -0,0 +1,247 @@ +package domain + +import "testing" + +func mkRanked(teamID int64, scope Scope, rank int) RankedResult { + return RankedResult{ + TeamID: teamID, + Shares: []TeamShare{{TeamID: teamID, Weight: 1}}, + Scope: scope, + Rank: rank, + } +} + +func mkAthleteRanked(athleteID, teamID int64, rank int) RankedResult { + r := mkRanked(teamID, ScopeAthlete, rank) + r.AthleteID = athleteID + return r +} + +func TestTeamStandingsWeight(t *testing.T) { + ledger := []LedgerEntry{ + ledgerFor(1, 10), // 仅记单位 + ledgerForAthlete(1, 9, 10), // 记到个人 + } + cases := []struct { + name string + weight float64 + want float64 + }{ + {"系数 1", 1, 20}, + {"系数 0.5", 0.5, 15}, + {"系数 2", 2, 30}, + {"系数 0 按 1 处理", 0, 20}, + {"系数负数按 1 处理", -1, 20}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rows := BuildTeamStandings(StandingsInput{ + Teams: testTeams(1), Ledger: ledger, IndividualToTeamWeight: tc.weight, + }) + if !nearlyEqual(rows[0].Points, tc.want) { + t.Fatalf("总分 = %v,期望 %v", rows[0].Points, tc.want) + } + if !nearlyEqual(rows[0].TeamOnlyPoints, 10) || !nearlyEqual(rows[0].IndividualPoints, 10) { + t.Fatalf("分项 = %v/%v,期望 10/10", rows[0].TeamOnlyPoints, rows[0].IndividualPoints) + } + }) + } +} + +func TestTeamStandingsSortOrder(t *testing.T) { + cases := []struct { + name string + ranked []RankedResult + want int64 + }{ + {"总分优先", nil, 1}, + {"金牌优先", []RankedResult{mkRanked(2, ScopeAthlete, 1)}, 2}, + { + "银牌优先", + []RankedResult{ + mkRanked(1, ScopeAthlete, 1), + mkRanked(2, ScopeAthlete, 1), + mkRanked(2, ScopeAthlete, 2), + }, + 2, + }, + { + "铜牌优先", + []RankedResult{ + mkRanked(1, ScopeAthlete, 1), + mkRanked(2, ScopeAthlete, 1), + mkRanked(1, ScopeAthlete, 2), + mkRanked(2, ScopeAthlete, 2), + mkRanked(2, ScopeAthlete, 3), + }, + 2, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ledger := []LedgerEntry{ledgerFor(1, 10), ledgerFor(2, 10)} + if tc.name == "总分优先" { + ledger[0].Points = 20 + } + rows := BuildTeamStandings(StandingsInput{Teams: testTeams(1, 2), Ledger: ledger, Ranked: tc.ranked}) + if rows[0].TeamID != tc.want { + t.Fatalf("榜首 = 单位 %d,期望 %d", rows[0].TeamID, tc.want) + } + if rows[0].Rank != 1 || rows[1].Rank != 2 { + t.Fatalf("名次 = %d/%d,期望 1/2", rows[0].Rank, rows[1].Rank) + } + }) + } +} + +func TestTeamStandingsIncludesAllTeams(t *testing.T) { + t.Run("没有得分的单位也占一行", func(t *testing.T) { + rows := BuildTeamStandings(StandingsInput{Teams: testTeams(1, 2), Ledger: []LedgerEntry{ledgerFor(1, 10)}}) + if len(rows) != 2 { + t.Fatalf("行数 = %d,期望 2", len(rows)) + } + if rows[1].TeamID != 2 || rows[1].Points != 0 { + t.Fatalf("第二行 = %+v,期望单位2零分", rows[1]) + } + }) + t.Run("流水里出现的未知单位也上榜", func(t *testing.T) { + rows := BuildTeamStandings(StandingsInput{Teams: testTeams(1), Ledger: []LedgerEntry{ledgerFor(9, 5)}}) + if len(rows) != 2 || rows[0].TeamID != 9 { + t.Fatalf("行 = %+v,期望未知单位9以5分居首", rows) + } + }) +} + +func TestMedalStandings(t *testing.T) { + ranked := []RankedResult{ + mkRanked(1, ScopeTeam, 1), // 团体项目金牌 + mkRanked(2, ScopeAthlete, 1), // 个人项目金牌 + mkRanked(2, ScopeAthlete, 3), + } + in := StandingsInput{Teams: testTeams(1, 2), Ranked: ranked} + + rows := BuildMedalStandings(in) + if len(rows) != 2 { + t.Fatalf("行数 = %d,期望 2", len(rows)) + } + var gold, silver, bronze, total int + for _, r := range rows { + gold += r.Gold + silver += r.Silver + bronze += r.Bronze + total += r.Total + } + if gold != 2 || silver != 0 || bronze != 1 || total != 3 { + t.Fatalf("奖牌合计 = %d/%d/%d(共%d),期望 2/0/1(共3)", gold, silver, bronze, total) + } + + in.ExcludeTeamEventMedals = true + rows = BuildMedalStandings(in) + for _, r := range rows { + if r.TeamID == 1 && r.Gold != 0 { + t.Fatalf("排除团体奖牌后单位1金牌 = %d,期望 0", r.Gold) + } + } +} + +func TestAthleteStandings(t *testing.T) { + ledger := []LedgerEntry{ + ledgerForAthlete(1001, 1, 10), + ledgerForAthlete(1002, 2, 10), + } + athletes := []Athlete{ + {ID: 1, Name: "张三", TeamID: 1001}, + {ID: 2, Name: "李四", TeamID: 1002}, + } + teams := []Team{ + {ID: 1001, Code: "A", Name: "一单位"}, + {ID: 1002, Code: "B", Name: "二单位"}, + } + + t.Run("金牌优先", func(t *testing.T) { + rows := BuildAthleteStandings(StandingsInput{ + Teams: teams, Athletes: athletes, Ledger: ledger, + Ranked: []RankedResult{mkAthleteRanked(2, 1002, 1)}, + }) + if rows[0].AthleteID != 2 { + t.Fatalf("榜首 = %+v,期望运动员2(金牌优先)", rows) + } + }) + + t.Run("同分按姓名", func(t *testing.T) { + rows := BuildAthleteStandings(StandingsInput{Teams: teams, Athletes: athletes, Ledger: ledger}) + if rows[0].AthleteID != 1 { + t.Fatalf("榜首 = %+v,期望运动员1(张三 < 李四)", rows) + } + }) + + t.Run("姓名与单位信息齐全", func(t *testing.T) { + rows := BuildAthleteStandings(StandingsInput{Teams: teams, Athletes: athletes, Ledger: ledger}) + if rows[0].Name != "张三" || rows[0].TeamCode != "A" || rows[0].TeamName != "一单位" { + t.Fatalf("行 = %+v,期望姓名/单位信息齐全", rows[0]) + } + }) + + t.Run("团体项目奖牌不计入个人榜", func(t *testing.T) { + rows := BuildAthleteStandings(StandingsInput{ + Teams: teams, Athletes: athletes, Ledger: ledger, + Ranked: []RankedResult{{ + TeamID: 1001, AthleteID: 1, Scope: ScopeTeam, Rank: 1, + Shares: []TeamShare{{TeamID: 1001, Weight: 1}}, + }}, + }) + for _, r := range rows { + if r.AthleteID == 1 && r.Gold != 0 { + t.Fatalf("个人榜不应统计团体项目奖牌: %+v", r) + } + } + }) +} + +func TestUnionMedals(t *testing.T) { + rr := RankedResult{ + TeamID: 1, Rank: 1, Scope: ScopeAthlete, + Shares: []TeamShare{{TeamID: 1, Weight: 0.6}, {TeamID: 2, Weight: 0.4}}, + } + t.Run("拆分时两单位各记一金", func(t *testing.T) { + rows := BuildTeamStandings(StandingsInput{Teams: testTeams(1, 2), Ranked: []RankedResult{rr}}) + for _, r := range rows { + if r.Gold != 1 { + t.Fatalf("单位 %d 金牌 = %d,期望 1", r.TeamID, r.Gold) + } + } + }) + t.Run("整体归属只记主单位", func(t *testing.T) { + rr := rr + rr.Shares = []TeamShare{{TeamID: 1, Weight: 1}} + rows := BuildTeamStandings(StandingsInput{Teams: testTeams(1, 2), Ranked: []RankedResult{rr}}) + if rows[0].TeamID != 1 || rows[0].Gold != 1 { + t.Fatalf("整体归属榜首 = %+v,期望单位1记1金", rows[0]) + } + if rows[1].Gold != 0 { + t.Fatalf("单位2金牌 = %d,期望 0", rows[1].Gold) + } + }) +} + +func TestStandingsEmptyInput(t *testing.T) { + t.Run("无单位无流水", func(t *testing.T) { + if rows := BuildTeamStandings(StandingsInput{}); len(rows) != 0 { + t.Fatalf("行数 = %d,期望 0", len(rows)) + } + }) + t.Run("个人榜无数据", func(t *testing.T) { + if rows := BuildAthleteStandings(StandingsInput{}); len(rows) != 0 { + t.Fatalf("行数 = %d,期望 0", len(rows)) + } + }) +} + +func TestSumByTeamIgnoresVoided(t *testing.T) { + ledger := []LedgerEntry{ledgerFor(1, 10), ledgerFor(1, 5)} + ledger[1].Voided = true + got := SumByTeam(ledger, 1) + if !nearlyEqual(got[1].Total, 10) { + t.Fatalf("总分 = %v,期望只算未作废的 10", got[1].Total) + } +} diff --git a/server/internal/domain/types.go b/server/internal/domain/types.go new file mode 100644 index 0000000..dc6d522 --- /dev/null +++ b/server/internal/domain/types.go @@ -0,0 +1,513 @@ +package domain + +// 本文件只放类型与枚举:取值与 devdocs/04-数据模型.md 的字段约束一一对应。 +// 这里不写任何计算,避免「结构」与「规则」互相纠缠。 + +// ---------- 枚举 ---------- + +// EventType 小项类型,仅用于展示归类,不参与积分计算。 +type EventType string + +const ( + EventTypeIndividual EventType = "individual" + EventTypeTeam EventType = "team" + EventTypeMatch EventType = "match" +) + +// ResultMode 成绩录入方式:auto 录成绩后自动排名,manual 直接录名次。 +type ResultMode string + +const ( + ResultModeAuto ResultMode = "auto" + ResultModeManual ResultMode = "manual" +) + +// ResultType 成绩的物理量类型,决定比较与展示方式。 +type ResultType string + +const ( + ResultTypeTime ResultType = "time" + ResultTypeDistance ResultType = "distance" + ResultTypeCount ResultType = "count" + ResultTypeScore ResultType = "score" + ResultTypeNone ResultType = "none" +) + +// CompareMode 排序方向;none 表示无法自动排名,必须走 manual 通道。 +type CompareMode string + +const ( + CompareAsc CompareMode = "asc" + CompareDesc CompareMode = "desc" + CompareNone CompareMode = "none" +) + +// EventStatus 小项进度。 +type EventStatus string + +const ( + EventStatusPending EventStatus = "pending" + EventStatusOngoing EventStatus = "ongoing" + EventStatusFinished EventStatus = "finished" + EventStatusPublished EventStatus = "published" +) + +// EntryStatus 报名项状态;非 registered 视为弃权/失格,0 分且不占名次。 +type EntryStatus string + +const ( + EntryStatusRegistered EntryStatus = "registered" + EntryStatusScratched EntryStatus = "scratched" + EntryStatusDQ EntryStatus = "dq" + EntryStatusDNS EntryStatus = "dns" +) + +// ResultStatus 成绩状态;非 ok 视为弃权/失格,0 分且不占名次。 +type ResultStatus string + +const ( + ResultStatusOK ResultStatus = "ok" + ResultStatusDQ ResultStatus = "dq" + ResultStatusDNS ResultStatus = "dns" + ResultStatusDNF ResultStatus = "dnf" + ResultStatusWithdrawn ResultStatus = "withdrawn" +) + +// ResultState 成绩发布状态;草稿不参与计分。 +type ResultState string + +const ( + ResultStateDraft ResultState = "draft" + ResultStatePublished ResultState = "published" +) + +// Scope 计分通道把名次分记给谁。 +type Scope string + +const ( + ScopeAthlete Scope = "athlete" + ScopeTeam Scope = "team" +) + +// Basis 名次从哪里来。 +type Basis string + +const ( + BasisResult Basis = "result" // 按成绩自动排名 + BasisManual Basis = "manual" // 直接录名次 + BasisAggregate Basis = "aggregate" // 按团体汇总(预留) +) + +// AggregateMethod 是 aggregate 通道的汇总口径,默认 sum。 +type AggregateMethod string + +const ( + AggregateSum AggregateMethod = "sum" + AggregateAvg AggregateMethod = "avg" + AggregateMax AggregateMethod = "max" + AggregateMin AggregateMethod = "min" + AggregateCount AggregateMethod = "count" +) + +// TieMode 并列名次口径。 +type TieMode string + +const ( + TieSkip TieMode = "skip" // 顺延:并列者同分,后续名次跳过被占用的位置 + TieAverage TieMode = "average" // 平均:并列者平分所占位置的积分 + TieHigher TieMode = "higher" // 就高:并列者都拿所占位置中的最高分 +) + +// RecordBonusMode 同时破多级纪录时的口径。 +type RecordBonusMode string + +const ( + RecordBonusMax RecordBonusMode = "max" // 取最高档,不累加 + RecordBonusSum RecordBonusMode = "sum" // 累加 +) + +// SourceType 积分流水来源,用于溯源与按来源作废。 +type SourceType string + +const ( + SourceResult SourceType = "result" + SourceRecordBonus SourceType = "record_bonus" + SourceAdjustment SourceType = "adjustment" +) + +// UnionAttribution 联队(一条成绩归属多个单位)的归属口径。 +type UnionAttribution string + +const ( + UnionWhole UnionAttribution = "whole" // 整体记给主单位 + UnionSplit UnionAttribution = "split" // 按 TeamShares 的份额拆分 +) + +// AdjustmentCategory 人工调整类别。 +type AdjustmentCategory string + +const ( + AdjustmentPenalty AdjustmentCategory = "penalty" + AdjustmentBonus AdjustmentCategory = "bonus" + AdjustmentCorrection AdjustmentCategory = "correction" +) + +// RecordBreakState 破纪录登记的审核状态。 +type RecordBreakState string + +const ( + RecordBreakPending RecordBreakState = "pending" + RecordBreakApproved RecordBreakState = "approved" + RecordBreakRejected RecordBreakState = "rejected" +) + +// ---------- 实体 ---------- + +// Team 参赛单位,积分榜上的行。 +type Team struct { + ID int64 `json:"id"` + Code string `json:"code"` + Name string `json:"name"` + ShortName string `json:"shortName,omitempty"` + LogoPath string `json:"logoPath,omitempty"` + Color string `json:"color,omitempty"` + SortOrder int `json:"sortOrder,omitempty"` + Active bool `json:"active"` +} + +// Athlete 运动员。 +type Athlete struct { + ID int64 `json:"id"` + Name string `json:"name"` + Gender string `json:"gender,omitempty"` + TeamID int64 `json:"teamId"` + EmployeeNo string `json:"employeeNo,omitempty"` + Active bool `json:"active"` +} + +// Sport 大项。 +type Sport struct { + ID int64 `json:"id"` + Code string `json:"code"` + Name string `json:"name"` + ShortName string `json:"shortName,omitempty"` + DefaultProfileID int64 `json:"defaultProfileId,omitempty"` + SortOrder int `json:"sortOrder,omitempty"` + Active bool `json:"active"` +} + +// Event 小项,积分计算的最小单位。 +type Event struct { + ID int64 `json:"id"` + SportID int64 `json:"sportId"` + Code string `json:"code"` + Name string `json:"name"` + EventType EventType `json:"eventType"` + ResultMode ResultMode `json:"resultMode"` + ResultType ResultType `json:"resultType"` + Compare CompareMode `json:"compare"` + RecordEligible bool `json:"recordEligible"` + Scored bool `json:"scored"` + Multiplier float64 `json:"multiplier"` + Status EventStatus `json:"status"` + ScheduledAt string `json:"scheduledAt,omitempty"` + Venue string `json:"venue,omitempty"` + SortOrder int `json:"sortOrder,omitempty"` + Remark string `json:"remark,omitempty"` +} + +// EventCategory 组别;名次在组别内有效,组别权重乘在成绩分上。 +type EventCategory struct { + ID int64 `json:"id"` + EventID int64 `json:"eventId"` + Name string `json:"name"` + Multiplier float64 `json:"multiplier"` + Gender string `json:"gender,omitempty"` + AgeMin *int `json:"ageMin,omitempty"` + AgeMax *int `json:"ageMax,omitempty"` + SortOrder int `json:"sortOrder,omitempty"` +} + +// ScoreChannel 计分通道 = 「排谁的名次」+「名次从哪来」+「用哪张积分表」。 +// 一个项目可配 0..N 个通道;0 个表示只排名不计分。 +type ScoreChannel struct { + ID int64 `json:"id"` + EventID int64 `json:"eventId"` + Scope Scope `json:"scope"` + Basis Basis `json:"basis"` + ProfileID int64 `json:"profileId"` + Aggregate AggregateMethod `json:"aggregate,omitempty"` // 仅 basis=aggregate 时使用 + SortOrder int `json:"sortOrder,omitempty"` +} + +// ScoringRule 积分模板的一条明细:名次 → 分值。分值一律来自配置。 +type ScoringRule struct { + Rank int `json:"rank"` + Points float64 `json:"points"` +} + +// ScoringProfile 积分模板。 +type ScoringProfile struct { + ID int64 `json:"id"` + Code string `json:"code"` + Name string `json:"name"` + AwardRanks int `json:"awardRanks"` + LastPlaceZero bool `json:"lastPlaceZero"` + TieMode TieMode `json:"tieMode"` + RecordBonusMode RecordBonusMode `json:"recordBonusMode"` + Rules []ScoringRule `json:"rules"` +} + +// TeamShare 联队中某单位的份额;Weight 为 0..1 的相对份额。 +type TeamShare struct { + TeamID int64 `json:"teamId"` + Weight float64 `json:"weight"` +} + +// Entry 报名项:某单位派某人/某队参加某小项。 +type Entry struct { + ID int64 `json:"id"` + EventID int64 `json:"eventId"` + CategoryID int64 `json:"categoryId"` + TeamID int64 `json:"teamId"` + AthleteID int64 `json:"athleteId,omitempty"` + AthleteName string `json:"athleteName,omitempty"` // 冗余姓名,便于引擎直接产出个人榜 + MemberNames []string `json:"memberNames,omitempty"` + TeamShares []TeamShare `json:"teamShares,omitempty"` // 跨单位联队;空表示只属 TeamID + Bib string `json:"bib,omitempty"` + Seed string `json:"seed,omitempty"` + Status EntryStatus `json:"status"` + Remark string `json:"remark,omitempty"` +} + +// Result 成绩;一条报名项一条成绩。 +type Result struct { + ID int64 `json:"id"` + EventID int64 `json:"eventId"` + EntryID int64 `json:"entryId"` + Rank int `json:"rank,omitempty"` + ResultText string `json:"resultText,omitempty"` + ResultValue *float64 `json:"resultValue,omitempty"` + Unit string `json:"unit,omitempty"` + IsTie bool `json:"isTie,omitempty"` + PointsOverride *float64 `json:"pointsOverride,omitempty"` + Status ResultStatus `json:"status"` + State ResultState `json:"state"` + RecordedBy string `json:"recordedBy,omitempty"` + RecordedAt string `json:"recordedAt,omitempty"` + VerifiedBy string `json:"verifiedBy,omitempty"` + VerifiedAt string `json:"verifiedAt,omitempty"` + Note string `json:"note,omitempty"` +} + +// LedgerEntry 积分流水,总分的唯一真相来源;只追加,纠错靠作废 + 追加。 +type LedgerEntry struct { + ID int64 `json:"id"` + TeamID int64 `json:"teamId"` + AthleteID int64 `json:"athleteId,omitempty"` // 0 表示「仅记单位」 + EventID int64 `json:"eventId,omitempty"` + CategoryID int64 `json:"categoryId,omitempty"` + EntryID int64 `json:"entryId,omitempty"` + ResultID int64 `json:"resultId,omitempty"` + ChannelID int64 `json:"channelId,omitempty"` + SourceType SourceType `json:"sourceType"` + SourceID int64 `json:"sourceId"` + Points float64 `json:"points"` + Voided bool `json:"voided"` + VoidedBy string `json:"voidedBy,omitempty"` + VoidReason string `json:"voidReason,omitempty"` + CreatedBy string `json:"createdBy,omitempty"` + CreatedAt string `json:"createdAt,omitempty"` +} + +// StandingsRow 总积分榜的一行。 +type StandingsRow struct { + Rank int `json:"rank"` + TeamID int64 `json:"teamId"` + Code string `json:"code"` + Name string `json:"name"` + ShortName string `json:"shortName,omitempty"` + Points float64 `json:"points"` + TeamOnlyPoints float64 `json:"teamOnlyPoints"` + IndividualPoints float64 `json:"individualPoints"` + Gold int `json:"gold"` + Silver int `json:"silver"` + Bronze int `json:"bronze"` + Medals int `json:"medals"` +} + +// MedalRow 团体奖牌榜的一行。 +type MedalRow struct { + Rank int `json:"rank"` + TeamID int64 `json:"teamId"` + Code string `json:"code"` + Name string `json:"name"` + ShortName string `json:"shortName,omitempty"` + Points float64 `json:"points"` + Gold int `json:"gold"` + Silver int `json:"silver"` + Bronze int `json:"bronze"` + Total int `json:"total"` +} + +// AthleteStandingsRow 个人积分奖牌榜的一行。 +type AthleteStandingsRow struct { + Rank int `json:"rank"` + AthleteID int64 `json:"athleteId"` + Name string `json:"name"` + TeamID int64 `json:"teamId"` + TeamCode string `json:"teamCode,omitempty"` + TeamName string `json:"teamName,omitempty"` + Points float64 `json:"points"` + Gold int `json:"gold"` + Silver int `json:"silver"` + Bronze int `json:"bronze"` + Medals int `json:"medals"` +} + +// RecordLevel 破纪录的一个档位及其加分值(来自规则包配置)。 +type RecordLevel struct { + Code string `json:"code"` + Name string `json:"name"` + Points float64 `json:"points"` +} + +// RecordBonusConfig 破纪录加分配置。 +type RecordBonusConfig struct { + Enabled bool `json:"enabled"` + Mode RecordBonusMode `json:"mode"` + Levels []RecordLevel `json:"levels"` +} + +// RecordBreak 破纪录登记。 +type RecordBreak struct { + ID int64 `json:"id"` + EventID int64 `json:"eventId"` + EntryID int64 `json:"entryId"` + AthleteID int64 `json:"athleteId,omitempty"` + TeamID int64 `json:"teamId"` + Level string `json:"level"` + ResultValue float64 `json:"resultValue"` + ResultText string `json:"resultText,omitempty"` + BonusPoints float64 `json:"bonusPoints,omitempty"` // 登记时预填值;实际以配置档位为准 + State RecordBreakState `json:"state"` +} + +// RecordBonus 一名参赛者(按报名项聚合)的破纪录加分结果。 +type RecordBonus struct { + EntryID int64 `json:"entryId"` + EventID int64 `json:"eventId"` + AthleteID int64 `json:"athleteId,omitempty"` + TeamID int64 `json:"teamId"` + Levels []string `json:"levels"` + Points float64 `json:"points"` +} + +// PointAdjustment 人工调整;本期关闭,但保留数据结构供以后的赛事启用。 +type PointAdjustment struct { + ID int64 `json:"id"` + TeamID int64 `json:"teamId"` + EventID int64 `json:"eventId,omitempty"` + Delta float64 `json:"delta"` + Category AdjustmentCategory `json:"category"` + Reason string `json:"reason"` + Voided bool `json:"voided"` + CreatedBy string `json:"createdBy,omitempty"` + CreatedAt string `json:"createdAt,omitempty"` +} + +// ManualAdjustmentConfig 人工调整开关。 +type ManualAdjustmentConfig struct { + Enabled bool `json:"enabled"` + ReasonRequired bool `json:"reasonRequired"` +} + +// ---------- 取值辅助 ---------- + +// multiplier 权重缺省为 1:配置里不写权重时按 1 处理,避免零值把分数乘没。 +func (e Event) multiplier() float64 { + if e.Multiplier <= 0 { + return 1 + } + return e.Multiplier +} + +func (c EventCategory) multiplier() float64 { + if c.Multiplier <= 0 { + return 1 + } + return c.Multiplier +} + +// tieMode 未配置或配错时按 skip 处理(与文档默认一致)。 +func (p ScoringProfile) tieMode() TieMode { + switch p.TieMode { + case TieAverage, TieHigher: + return p.TieMode + default: + return TieSkip + } +} + +// bonusMode 未配置时按 max 处理(与文档默认一致)。 +func (p ScoringProfile) bonusMode() RecordBonusMode { + if p.RecordBonusMode == RecordBonusSum { + return RecordBonusSum + } + return RecordBonusMax +} + +// pointsAt 精确查名次分值;表中没有该名次就返回 0。 +func (p ScoringProfile) pointsAt(rank int) (float64, bool) { + for _, r := range p.Rules { + if r.Rank == rank { + return r.Points, true + } + } + return 0, false +} + +// rankable 判断报名项是否可参与排名;空值按 registered 处理,兼容未填状态的数据。 +func (s EntryStatus) rankable() bool { + return s == "" || s == EntryStatusRegistered +} + +// rankable 判断成绩是否可参与排名;空值按 ok 处理。 +func (s ResultStatus) rankable() bool { + return s == "" || s == ResultStatusOK +} + +// teamShares 返回报名项的归属份额;未配联队份额时就是「主单位独占 100%」。 +// 份额全部非法(权重 <= 0)时退回主单位,宁可按主单位记分也不要静默丢分。 +func (e Entry) teamShares() []TeamShare { + if len(e.TeamShares) == 0 { + if e.TeamID == 0 { + return nil + } + return []TeamShare{{TeamID: e.TeamID, Weight: 1}} + } + out := make([]TeamShare, 0, len(e.TeamShares)) + for _, s := range e.TeamShares { + if s.TeamID != 0 && s.Weight > 0 { + out = append(out, s) + } + } + if len(out) == 0 { + if id := e.primaryTeamID(); id != 0 { + return []TeamShare{{TeamID: id, Weight: 1}} + } + return nil + } + return out +} + +// primaryTeamID 是「整体归属」时接收全部分数的单位。 +func (e Entry) primaryTeamID() int64 { + if e.TeamID != 0 { + return e.TeamID + } + if len(e.TeamShares) > 0 { + return e.TeamShares[0].TeamID + } + return 0 +} diff --git a/server/internal/domain/types_test.go b/server/internal/domain/types_test.go new file mode 100644 index 0000000..40d383d --- /dev/null +++ b/server/internal/domain/types_test.go @@ -0,0 +1,168 @@ +package domain + +import "testing" + +func TestEventMultiplierDefault(t *testing.T) { + cases := []struct { + in float64 + want float64 + }{ + {0, 1}, + {-3, 1}, + {1, 1}, + {2.5, 2.5}, + } + for _, tc := range cases { + if got := (Event{Multiplier: tc.in}).multiplier(); got != tc.want { + t.Errorf("Event{Multiplier:%v}.multiplier() = %v,期望 %v", tc.in, got, tc.want) + } + } +} + +func TestCategoryMultiplierDefault(t *testing.T) { + cases := []struct { + in float64 + want float64 + }{ + {0, 1}, + {-1, 1}, + {3, 3}, + } + for _, tc := range cases { + if got := (EventCategory{Multiplier: tc.in}).multiplier(); got != tc.want { + t.Errorf("EventCategory{Multiplier:%v}.multiplier() = %v,期望 %v", tc.in, got, tc.want) + } + } +} + +func TestTieModeDefault(t *testing.T) { + cases := []struct { + in TieMode + want TieMode + }{ + {TieSkip, TieSkip}, + {TieAverage, TieAverage}, + {TieHigher, TieHigher}, + {"", TieSkip}, + {"magic", TieSkip}, + } + for _, tc := range cases { + if got := (ScoringProfile{TieMode: tc.in}).tieMode(); got != tc.want { + t.Errorf("tieMode(%q) = %q,期望 %q", tc.in, got, tc.want) + } + } +} + +func TestBonusModeDefault(t *testing.T) { + cases := []struct { + in RecordBonusMode + want RecordBonusMode + }{ + {RecordBonusMax, RecordBonusMax}, + {RecordBonusSum, RecordBonusSum}, + {"", RecordBonusMax}, + {"magic", RecordBonusMax}, + } + for _, tc := range cases { + if got := (ScoringProfile{RecordBonusMode: tc.in}).bonusMode(); got != tc.want { + t.Errorf("bonusMode(%q) = %q,期望 %q", tc.in, got, tc.want) + } + } +} + +func TestProfilePointsAt(t *testing.T) { + p := individualTop6() + if got, ok := p.pointsAt(1); !ok || got != 10 { + t.Fatalf("pointsAt(1) = %v/%v,期望 10/true", got, ok) + } + if got, ok := p.pointsAt(7); ok || got != 0 { + t.Fatalf("pointsAt(7) = %v/%v,期望 0/false", got, ok) + } +} + +func TestEntryTeamShares(t *testing.T) { + cases := []struct { + name string + entry Entry + want []TeamShare + }{ + {"无份额归主单位", Entry{TeamID: 1}, []TeamShare{{1, 1}}}, + {"无单位也无份额", Entry{}, nil}, + {"保留正权重份额", Entry{TeamID: 1, TeamShares: []TeamShare{{1, 3}, {2, 2}}}, []TeamShare{{1, 3}, {2, 2}}}, + {"过滤非法份额", Entry{TeamID: 1, TeamShares: []TeamShare{{1, 0}, {2, 1}}}, []TeamShare{{2, 1}}}, + {"份额全非法退回主单位", Entry{TeamID: 1, TeamShares: []TeamShare{{1, 0}, {2, -1}}}, []TeamShare{{1, 1}}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := tc.entry.teamShares() + if len(got) != len(tc.want) { + t.Fatalf("份额 = %+v,期望 %+v", got, tc.want) + } + for i := range got { + if got[i] != tc.want[i] { + t.Fatalf("份额 = %+v,期望 %+v", got, tc.want) + } + } + }) + } +} + +func TestPrimaryTeamID(t *testing.T) { + if got := (Entry{TeamID: 7}).primaryTeamID(); got != 7 { + t.Errorf("主单位 = %d,期望 7", got) + } + if got := (Entry{TeamShares: []TeamShare{{9, 1}}}).primaryTeamID(); got != 9 { + t.Errorf("无 TeamID 时取首个份额 = %d,期望 9", got) + } + if got := (Entry{}).primaryTeamID(); got != 0 { + t.Errorf("无归属时 = %d,期望 0", got) + } +} + +func TestRankableStatuses(t *testing.T) { + entryCases := []struct { + in EntryStatus + want bool + }{ + {EntryStatusRegistered, true}, + {"", true}, + {EntryStatusScratched, false}, + {EntryStatusDQ, false}, + {EntryStatusDNS, false}, + } + for _, tc := range entryCases { + if got := tc.in.rankable(); got != tc.want { + t.Errorf("EntryStatus(%q).rankable() = %v,期望 %v", tc.in, got, tc.want) + } + } + + resultCases := []struct { + in ResultStatus + want bool + }{ + {ResultStatusOK, true}, + {"", true}, + {ResultStatusDQ, false}, + {ResultStatusDNS, false}, + {ResultStatusDNF, false}, + {ResultStatusWithdrawn, false}, + } + for _, tc := range resultCases { + if got := tc.in.rankable(); got != tc.want { + t.Errorf("ResultStatus(%q).rankable() = %v,期望 %v", tc.in, got, tc.want) + } + } +} + +func TestNormalizeShares(t *testing.T) { + got := normalizeShares([]TeamShare{{1, 3}, {2, 2}}) + if !nearlyEqual(got[0].Weight, 0.6) || !nearlyEqual(got[1].Weight, 0.4) { + t.Fatalf("归一化 = %+v,期望 0.6/0.4", got) + } + got = normalizeShares([]TeamShare{{1, 1}, {2, 1}, {3, 1}}) + for _, s := range got { + if !nearlyEqual(s.Weight, 1.0/3) { + t.Fatalf("平均拆分 = %+v,期望各 1/3", got) + } + } +} diff --git a/server/internal/httpapi/router.go b/server/internal/httpapi/router.go new file mode 100644 index 0000000..edd9b34 --- /dev/null +++ b/server/internal/httpapi/router.go @@ -0,0 +1,107 @@ +// Package httpapi 提供 HTTP 接口与静态资源托管。 +package httpapi + +import ( + "encoding/json" + "io/fs" + "log/slog" + "net/http" + "strings" + "time" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/webui" +) + +// Options 是构建路由所需的依赖。 +type Options struct { + Logger *slog.Logger + Version string +} + +// New 组装并返回服务端的 HTTP 处理器。 +func New(opts Options) http.Handler { + logger := opts.Logger + if logger == nil { + logger = slog.Default() + } + + mux := http.NewServeMux() + + mux.HandleFunc("GET /api/health", func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "version": opts.Version, + }) + }) + + assets, err := webui.FS() + if err != nil { + logger.Error("前端资源不可用", "err", err) + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "前端资源不可用", http.StatusServiceUnavailable) + }) + } else { + mux.Handle("/", staticHandler(assets)) + } + + return withRequestLog(mux, logger) +} + +// staticHandler 把 URL 映射到多页面前端产物。 +// +// / → index.html +// /display → display.html +// /admin → admin.html +// /其他 → 按文件路径查找 +func staticHandler(assets fs.FS) http.Handler { + files := http.FileServerFS(assets) + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + p := strings.TrimPrefix(r.URL.Path, "/") + + switch p { + case "", "index.html": + http.ServeFileFS(w, r, assets, "index.html") + case "display", "display/": + http.ServeFileFS(w, r, assets, "display.html") + case "admin", "admin/": + http.ServeFileFS(w, r, assets, "admin.html") + default: + if _, err := fs.Stat(assets, p); err != nil { + http.NotFound(w, r) + return + } + files.ServeHTTP(w, r) + } + }) +} + +func writeJSON(w http.ResponseWriter, code int, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(v) +} + +type statusRecorder struct { + http.ResponseWriter + status int +} + +func (r *statusRecorder) WriteHeader(code int) { + r.status = code + r.ResponseWriter.WriteHeader(code) +} + +func withRequestLog(next http.Handler, logger *slog.Logger) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK} + next.ServeHTTP(rec, r) + logger.Debug("http", + "method", r.Method, + "path", r.URL.Path, + "status", rec.status, + "dur", time.Since(start), + ) + }) +} diff --git a/server/internal/store/migrate.go b/server/internal/store/migrate.go new file mode 100644 index 0000000..b1682f8 --- /dev/null +++ b/server/internal/store/migrate.go @@ -0,0 +1,105 @@ +package store + +import ( + "database/sql" + "fmt" + "io/fs" + "log/slog" + "sort" + + "git.yeij.top/AskaEth/Scoreboard/server/migrations" +) + +const createMigrationsTable = `CREATE TABLE IF NOT EXISTS schema_migrations ( + name TEXT PRIMARY KEY, + applied_at TEXT NOT NULL DEFAULT (datetime('now')) +)` + +// Migrate 按文件名顺序迁移系统库(data/system.db)。 +func (s *Store) Migrate() error { + fsys, err := fs.Sub(migrations.SystemFS, "system") + if err != nil { + return fmt.Errorf("定位系统库迁移目录: %w", err) + } + return migrate(s.db, fsys, "system") +} + +// MigrateEvent 迁移赛事库(event.db)。赛事库由调用方自行打开, +// 本函数只负责执行 event/ 下的迁移,不持有连接。 +func MigrateEvent(db *sql.DB) error { + fsys, err := fs.Sub(migrations.EventFS, "event") + if err != nil { + return fmt.Errorf("定位赛事库迁移目录: %w", err) + } + return migrate(db, fsys, "event") +} + +// migrate 执行 fsys 中尚未应用的迁移,每个迁移在独立事务中完成。 +func migrate(db *sql.DB, fsys fs.FS, label string) error { + if _, err := db.Exec(createMigrationsTable); err != nil { + return fmt.Errorf("创建 schema_migrations: %w", err) + } + + applied, err := appliedMigrations(db) + if err != nil { + return err + } + + names, err := fs.Glob(fsys, "*.sql") + if err != nil { + return err + } + sort.Strings(names) + + for _, name := range names { + if applied[name] { + continue + } + body, err := fs.ReadFile(fsys, name) + if err != nil { + return fmt.Errorf("读取迁移 %s: %w", name, err) + } + if err := applyMigration(db, name, string(body)); err != nil { + return err + } + slog.Info("已应用迁移", "db", label, "name", name) + } + return nil +} + +func appliedMigrations(db *sql.DB) (map[string]bool, error) { + rows, err := db.Query(`SELECT name FROM schema_migrations`) + if err != nil { + return nil, fmt.Errorf("查询迁移记录: %w", err) + } + defer func() { _ = rows.Close() }() + + applied := make(map[string]bool) + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, err + } + applied[name] = true + } + if err := rows.Err(); err != nil { + return nil, err + } + return applied, nil +} + +func applyMigration(db *sql.DB, name, body string) error { + tx, err := db.Begin() + if err != nil { + return fmt.Errorf("开启事务: %w", err) + } + defer func() { _ = tx.Rollback() }() + + if _, err := tx.Exec(body); err != nil { + return fmt.Errorf("执行迁移 %s: %w", name, err) + } + if _, err := tx.Exec(`INSERT INTO schema_migrations(name) VALUES (?)`, name); err != nil { + return fmt.Errorf("记录迁移 %s: %w", name, err) + } + return tx.Commit() +} diff --git a/server/internal/store/store.go b/server/internal/store/store.go new file mode 100644 index 0000000..422a517 --- /dev/null +++ b/server/internal/store/store.go @@ -0,0 +1,49 @@ +// Package store 负责数据库连接、迁移与仓储。 +package store + +import ( + "database/sql" + "fmt" + "os" + "path/filepath" + + _ "modernc.org/sqlite" +) + +// Store 持有系统库(data/system.db)的连接。 +type Store struct { + db *sql.DB +} + +// Open 打开(必要时创建)数据目录下的 system.db。 +func Open(dataDir string) (*Store, error) { + if err := os.MkdirAll(dataDir, 0o755); err != nil { + return nil, fmt.Errorf("创建数据目录 %s: %w", dataDir, err) + } + + path := filepath.Join(dataDir, "system.db") + dsn := "file:" + path + + "?_pragma=journal_mode(WAL)" + + "&_pragma=foreign_keys(1)" + + "&_pragma=busy_timeout(5000)" + + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, fmt.Errorf("打开数据库 %s: %w", path, err) + } + + // SQLite 只允许单个写者,连接数收紧到 1 最省心。 + db.SetMaxOpenConns(1) + + if err := db.Ping(); err != nil { + _ = db.Close() + return nil, fmt.Errorf("连接数据库 %s: %w", path, err) + } + return &Store{db: db}, nil +} + +// Close 关闭数据库连接。 +func (s *Store) Close() error { return s.db.Close() } + +// DB 暴露底层连接,供仓储层使用。 +func (s *Store) DB() *sql.DB { return s.db } diff --git a/server/internal/store/store_test.go b/server/internal/store/store_test.go new file mode 100644 index 0000000..5178ff7 --- /dev/null +++ b/server/internal/store/store_test.go @@ -0,0 +1,232 @@ +package store + +import ( + "database/sql" + "path/filepath" + "testing" +) + +// openEventDB 打开一个临时赛事库;DSN 与 store.Open 保持一致,便于验证外键约束。 +func openEventDB(t *testing.T) *sql.DB { + t.Helper() + + path := filepath.Join(t.TempDir(), "event.db") + dsn := "file:" + path + + "?_pragma=journal_mode(WAL)" + + "&_pragma=foreign_keys(1)" + + "&_pragma=busy_timeout(5000)" + + db, err := sql.Open("sqlite", dsn) + if err != nil { + t.Fatalf("打开赛事库: %v", err) + } + db.SetMaxOpenConns(1) + t.Cleanup(func() { _ = db.Close() }) + return db +} + +func TestMigrateSystem(t *testing.T) { + st, err := Open(t.TempDir()) + if err != nil { + t.Fatalf("Open: %v", err) + } + t.Cleanup(func() { _ = st.Close() }) + + if err := st.Migrate(); err != nil { + t.Fatalf("首次迁移: %v", err) + } + + for _, table := range []string{"schema_migrations", "settings", "users", "sessions", "recent_projects"} { + assertTable(t, st.DB(), table) + } + + if got := queryString(t, st.DB(), `SELECT value FROM settings WHERE key = 'app.name'`); got == "" { + t.Error("默认系统设置 app.name 缺失") + } + + before := countRows(t, st.DB(), "schema_migrations") + if err := st.Migrate(); err != nil { + t.Fatalf("重复迁移: %v", err) + } + if after := countRows(t, st.DB(), "schema_migrations"); after != before { + t.Errorf("重复迁移应幂等,迁移记录 %d → %d", before, after) + } +} + +func TestMigrateEvent(t *testing.T) { + db := openEventDB(t) + + if err := MigrateEvent(db); err != nil { + t.Fatalf("首次迁移: %v", err) + } + + tables := []string{ + "schema_migrations", "settings", + "teams", "athletes", "sports", "events", "event_categories", "event_score_channels", + "scoring_profiles", "scoring_profile_rules", + "entries", "results", "records", "record_breaks", + "score_ledger", "point_adjustments", "result_revisions", "change_log", "audit_logs", + } + for _, table := range tables { + assertTable(t, db, table) + } + + checkProfile(t, db, "individual_top6", "个人项目前六名", []float64{10, 8, 6, 3, 2, 1}) + checkProfile(t, db, "team_top6", "团体项目前六名", []float64{15, 10, 8, 4, 2, 1}) + + if got := queryString(t, db, `SELECT value FROM settings WHERE key = 'event.name'`); got != "未命名赛事" { + t.Errorf("默认赛事设置 event.name = %q,期望 %q", got, "未命名赛事") + } + + before := []int{ + countRows(t, db, "schema_migrations"), + countRows(t, db, "scoring_profiles"), + countRows(t, db, "scoring_profile_rules"), + countRows(t, db, "settings"), + } + if err := MigrateEvent(db); err != nil { + t.Fatalf("重复迁移: %v", err) + } + after := []int{ + countRows(t, db, "schema_migrations"), + countRows(t, db, "scoring_profiles"), + countRows(t, db, "scoring_profile_rules"), + countRows(t, db, "settings"), + } + for i, name := range []string{"schema_migrations", "scoring_profiles", "scoring_profile_rules", "settings"} { + if before[i] != after[i] { + t.Errorf("重复迁移应幂等,%s 行数 %d → %d", name, before[i], after[i]) + } + } +} + +func TestEventSchemaAcceptsCoreFlow(t *testing.T) { + db := openEventDB(t) + if err := MigrateEvent(db); err != nil { + t.Fatalf("迁移: %v", err) + } + + mustExec(t, db, `INSERT INTO teams(code, name) VALUES ('T1', '第一分公司')`) + mustExec(t, db, `INSERT INTO sports(code, name) VALUES ('SP1', '田径')`) + mustExec(t, db, `INSERT INTO events(sport_id, code, name) VALUES (1, 'E1', '100米跑')`) + mustExec(t, db, `INSERT INTO event_categories(event_id, name) VALUES (1, '男子')`) + mustExec(t, db, `INSERT INTO entries(event_id, category_id, team_id) VALUES (1, 1, 1)`) + mustExec(t, db, `INSERT INTO results(event_id, entry_id, rank, result_text, result_value, unit, state) + VALUES (1, 1, 1, '11.58', 11.58, 's', 'published')`) + mustExec(t, db, `INSERT INTO score_ledger(team_id, event_id, entry_id, result_id, source_type, source_id, points) + VALUES (1, 1, 1, 1, 'result', 1, 10)`) + + if _, err := db.Exec(`INSERT INTO results(event_id, entry_id) VALUES (1, 1)`); err == nil { + t.Error("results.entry_id 应 UNIQUE") + } + if _, err := db.Exec(`INSERT INTO events(sport_id, code, name) VALUES (999, 'E9', '幽灵项目')`); err == nil { + t.Error("外键未生效:不存在的 sport_id 应被拒绝") + } + if _, err := db.Exec(`INSERT INTO athletes(name, gender) VALUES ('张三', '未知')`); err == nil { + t.Error("CHECK 未生效:非法性别应被拒绝") + } +} + +func checkProfile(t *testing.T, db *sql.DB, code, name string, want []float64) { + t.Helper() + + var ( + gotName, tieMode, bonusMode string + awardRanks, lastPlaceZero int + ) + err := db.QueryRow( + `SELECT name, tie_mode, record_bonus_mode, award_ranks, last_place_zero + FROM scoring_profiles WHERE code = ?`, code, + ).Scan(&gotName, &tieMode, &bonusMode, &awardRanks, &lastPlaceZero) + if err != nil { + t.Fatalf("查询模板 %s: %v", code, err) + } + + if gotName != name { + t.Errorf("模板 %s 名称 = %q,期望 %q", code, gotName, name) + } + if tieMode != "skip" { + t.Errorf("模板 %s tie_mode = %q,期望 skip", code, tieMode) + } + if bonusMode != "max" { + t.Errorf("模板 %s record_bonus_mode = %q,期望 max", code, bonusMode) + } + if awardRanks != 6 { + t.Errorf("模板 %s award_ranks = %d,期望 6", code, awardRanks) + } + if lastPlaceZero != 0 { + t.Errorf("模板 %s last_place_zero = %d,期望 0", code, lastPlaceZero) + } + + rows, err := db.Query( + `SELECT points FROM scoring_profile_rules + WHERE profile_id = (SELECT id FROM scoring_profiles WHERE code = ?) + ORDER BY rank`, code, + ) + if err != nil { + t.Fatalf("查询模板 %s 明细: %v", code, err) + } + defer func() { _ = rows.Close() }() + + var got []float64 + for rows.Next() { + var points float64 + if err := rows.Scan(&points); err != nil { + t.Fatalf("扫描模板 %s 明细: %v", code, err) + } + got = append(got, points) + } + if err := rows.Err(); err != nil { + t.Fatalf("遍历模板 %s 明细: %v", code, err) + } + + if len(got) != len(want) { + t.Fatalf("模板 %s 名次条数 = %d,期望 %d(%v)", code, len(got), len(want), want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("模板 %s 第 %d 名积分 = %v,期望 %v", code, i+1, got[i], want[i]) + } + } +} + +func assertTable(t *testing.T, db *sql.DB, table string) { + t.Helper() + + var n int + if err := db.QueryRow( + `SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name = ?`, table, + ).Scan(&n); err != nil { + t.Fatalf("查询表 %s: %v", table, err) + } + if n != 1 { + t.Errorf("表 %s 不存在", table) + } +} + +func mustExec(t *testing.T, db *sql.DB, query string) { + t.Helper() + if _, err := db.Exec(query); err != nil { + t.Fatalf("执行 %q: %v", query, err) + } +} + +func countRows(t *testing.T, db *sql.DB, table string) int { + t.Helper() + + var n int + if err := db.QueryRow(`SELECT count(*) FROM ` + table).Scan(&n); err != nil { + t.Fatalf("统计 %s: %v", table, err) + } + return n +} + +func queryString(t *testing.T, db *sql.DB, query string, args ...any) string { + t.Helper() + + var v string + if err := db.QueryRow(query, args...).Scan(&v); err != nil { + t.Fatalf("查询 %q: %v", query, err) + } + return v +} diff --git a/server/internal/webui/embed.go b/server/internal/webui/embed.go new file mode 100644 index 0000000..3d31f18 --- /dev/null +++ b/server/internal/webui/embed.go @@ -0,0 +1,17 @@ +// Package webui 内嵌前端构建产物。 +// +// 前端由 web/ 构建后输出到 assets/ 目录,Go 二进制通过本包把静态资源一并打包。 +package webui + +import ( + "embed" + "io/fs" +) + +//go:embed all:assets +var assets embed.FS + +// FS 返回去掉 assets 前缀后的静态资源文件系统。 +func FS() (fs.FS, error) { + return fs.Sub(assets, "assets") +} diff --git a/server/main.go b/server/main.go new file mode 100644 index 0000000..a358389 --- /dev/null +++ b/server/main.go @@ -0,0 +1,74 @@ +// Command scoreboard 是赛事积分榜系统的服务端。 +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/config" + "git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi" + "git.yeij.top/AskaEth/Scoreboard/server/internal/store" +) + +// version 由构建时注入:-ldflags "-X main.version=..." +var version = "dev" + +func main() { + configPath := flag.String("config", "config.yaml", "配置文件路径") + flag.Parse() + + cfg, err := config.Load(*configPath) + if err != nil { + fmt.Fprintf(os.Stderr, "加载配置失败: %v\n", err) + os.Exit(1) + } + + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: cfg.Log.SlogLevel()})) + slog.SetDefault(logger) + + st, err := store.Open(cfg.DataDir) + if err != nil { + logger.Error("打开数据库失败", "err", err) + os.Exit(1) + } + defer func() { _ = st.Close() }() + + if err := st.Migrate(); err != nil { + logger.Error("数据库迁移失败", "err", err) + os.Exit(1) + } + logger.Info("数据库就绪", "dataDir", cfg.DataDir) + + srv := &http.Server{ + Addr: cfg.Server.Addr(), + Handler: httpapi.New(httpapi.Options{Logger: logger, Version: version}), + ReadHeaderTimeout: 10 * time.Second, + } + + go func() { + logger.Info("服务启动", "addr", srv.Addr, "version", version) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + logger.Error("服务异常退出", "err", err) + os.Exit(1) + } + }() + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + <-ctx.Done() + + logger.Info("正在关闭…") + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := srv.Shutdown(shutdownCtx); err != nil { + logger.Error("关闭超时", "err", err) + } +} diff --git a/server/migrations/embed.go b/server/migrations/embed.go new file mode 100644 index 0000000..7c05c7b --- /dev/null +++ b/server/migrations/embed.go @@ -0,0 +1,14 @@ +// Package migrations 内嵌 SQL 迁移脚本。 +package migrations + +import "embed" + +// SystemFS 内嵌系统库(data/system.db)的迁移:system/*.sql。 +// +//go:embed system/*.sql +var SystemFS embed.FS + +// EventFS 内嵌赛事库(data/event_projs/<项目名>/event.db)的迁移:event/*.sql。 +// +//go:embed event/*.sql +var EventFS embed.FS diff --git a/server/migrations/event/0001_settings.sql b/server/migrations/event/0001_settings.sql new file mode 100644 index 0000000..0afb6bc --- /dev/null +++ b/server/migrations/event/0001_settings.sql @@ -0,0 +1,6 @@ +-- 赛事级配置:键值对,与系统库同名表结构一致,但各存各的。 +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); diff --git a/server/migrations/event/0002_teams_athletes.sql b/server/migrations/event/0002_teams_athletes.sql new file mode 100644 index 0000000..fc19c50 --- /dev/null +++ b/server/migrations/event/0002_teams_athletes.sql @@ -0,0 +1,24 @@ +CREATE TABLE IF NOT EXISTS teams ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + short_name TEXT, + logo_path TEXT, + color TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)) +); + +CREATE INDEX IF NOT EXISTS idx_teams_sort_order ON teams(sort_order, code); + +CREATE TABLE IF NOT EXISTS athletes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + gender TEXT CHECK (gender IN ('男', '女')), + team_id INTEGER REFERENCES teams(id), + employee_no TEXT, + active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)) +); + +CREATE INDEX IF NOT EXISTS idx_athletes_team_id ON athletes(team_id); +CREATE INDEX IF NOT EXISTS idx_athletes_name ON athletes(name); diff --git a/server/migrations/event/0003_scoring_profiles.sql b/server/migrations/event/0003_scoring_profiles.sql new file mode 100644 index 0000000..023a44c --- /dev/null +++ b/server/migrations/event/0003_scoring_profiles.sql @@ -0,0 +1,19 @@ +CREATE TABLE IF NOT EXISTS scoring_profiles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + tie_mode TEXT NOT NULL DEFAULT 'skip' CHECK (tie_mode IN ('skip', 'average', 'higher')), + record_bonus_mode TEXT NOT NULL DEFAULT 'max' CHECK (record_bonus_mode IN ('max', 'sum')), + -- 文档只在规则包里写了 awardRanks / lastPlaceZero,落库是为了让积分引擎不必再读 YAML。 + award_ranks INTEGER NOT NULL DEFAULT 6 CHECK (award_ranks >= 0), + last_place_zero INTEGER NOT NULL DEFAULT 0 CHECK (last_place_zero IN (0, 1)), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS scoring_profile_rules ( + profile_id INTEGER NOT NULL REFERENCES scoring_profiles(id), + rank INTEGER NOT NULL CHECK (rank >= 1), + points REAL NOT NULL, + PRIMARY KEY (profile_id, rank) +); diff --git a/server/migrations/event/0004_sports_events.sql b/server/migrations/event/0004_sports_events.sql new file mode 100644 index 0000000..b0a375b --- /dev/null +++ b/server/migrations/event/0004_sports_events.sql @@ -0,0 +1,64 @@ +CREATE TABLE IF NOT EXISTS sports ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + short_name TEXT, + default_profile_id INTEGER REFERENCES scoring_profiles(id), + sort_order INTEGER NOT NULL DEFAULT 0, + active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)) +); + +CREATE INDEX IF NOT EXISTS idx_sports_sort_order ON sports(sort_order, code); + +CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sport_id INTEGER NOT NULL REFERENCES sports(id), + code TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + event_type TEXT NOT NULL DEFAULT 'individual' + CHECK (event_type IN ('individual', 'team', 'match')), + result_mode TEXT NOT NULL DEFAULT 'auto' + CHECK (result_mode IN ('auto', 'manual')), + result_type TEXT NOT NULL DEFAULT 'none' + CHECK (result_type IN ('time', 'distance', 'count', 'score', 'none')), + compare TEXT NOT NULL DEFAULT 'none' + CHECK (compare IN ('asc', 'desc', 'none')), + record_eligible INTEGER NOT NULL DEFAULT 1 CHECK (record_eligible IN (0, 1)), + scored INTEGER NOT NULL DEFAULT 1 CHECK (scored IN (0, 1)), + multiplier REAL NOT NULL DEFAULT 1 CHECK (multiplier >= 0), + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'ongoing', 'finished', 'published')), + scheduled_at TEXT, + venue TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + remark TEXT +); + +CREATE INDEX IF NOT EXISTS idx_events_sport_id ON events(sport_id); +CREATE INDEX IF NOT EXISTS idx_events_status ON events(status, sort_order); + +CREATE TABLE IF NOT EXISTS event_categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_id INTEGER NOT NULL REFERENCES events(id), + name TEXT NOT NULL, + multiplier REAL NOT NULL DEFAULT 1 CHECK (multiplier >= 0), + gender TEXT CHECK (gender IN ('男', '女')), + age_min INTEGER, + age_max INTEGER, + sort_order INTEGER NOT NULL DEFAULT 0, + UNIQUE (event_id, name) +); + +CREATE INDEX IF NOT EXISTS idx_event_categories_event_id ON event_categories(event_id); + +CREATE TABLE IF NOT EXISTS event_score_channels ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_id INTEGER NOT NULL REFERENCES events(id), + scope TEXT NOT NULL CHECK (scope IN ('athlete', 'team')), + basis TEXT NOT NULL CHECK (basis IN ('result', 'manual', 'aggregate')), + profile_id INTEGER NOT NULL REFERENCES scoring_profiles(id), + sort_order INTEGER NOT NULL DEFAULT 0, + UNIQUE (event_id, scope, basis) +); + +CREATE INDEX IF NOT EXISTS idx_event_score_channels_event_id ON event_score_channels(event_id); diff --git a/server/migrations/event/0005_entries_results.sql b/server/migrations/event/0005_entries_results.sql new file mode 100644 index 0000000..b5d8681 --- /dev/null +++ b/server/migrations/event/0005_entries_results.sql @@ -0,0 +1,49 @@ +CREATE TABLE IF NOT EXISTS entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_id INTEGER NOT NULL REFERENCES events(id), + category_id INTEGER NOT NULL REFERENCES event_categories(id), + team_id INTEGER NOT NULL REFERENCES teams(id), + athlete_id INTEGER REFERENCES athletes(id), + member_names TEXT, + bib TEXT, + seed TEXT, + status TEXT NOT NULL DEFAULT 'registered' + CHECK (status IN ('registered', 'scratched', 'dq', 'dns')), + remark TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (category_id, team_id, athlete_id) +); + +CREATE INDEX IF NOT EXISTS idx_entries_event_id ON entries(event_id); +CREATE INDEX IF NOT EXISTS idx_entries_team_id ON entries(team_id); +CREATE INDEX IF NOT EXISTS idx_entries_athlete_id ON entries(athlete_id); +CREATE INDEX IF NOT EXISTS idx_entries_category_id ON entries(category_id); +-- athlete_id 为 NULL 时 UNIQUE 视为互不相等,团体项目的重复报名靠这条部分索引兜住。 +CREATE UNIQUE INDEX IF NOT EXISTS idx_entries_team_unique + ON entries(category_id, team_id) WHERE athlete_id IS NULL; + +CREATE TABLE IF NOT EXISTS results ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_id INTEGER NOT NULL REFERENCES events(id), + entry_id INTEGER NOT NULL UNIQUE REFERENCES entries(id), + rank INTEGER CHECK (rank IS NULL OR rank >= 1), + result_text TEXT, + result_value REAL, + unit TEXT, + is_tie INTEGER NOT NULL DEFAULT 0 CHECK (is_tie IN (0, 1)), + points_override REAL, + status TEXT NOT NULL DEFAULT 'ok' + CHECK (status IN ('ok', 'dq', 'dns', 'dnf', 'withdrawn')), + state TEXT NOT NULL DEFAULT 'draft' CHECK (state IN ('draft', 'published')), + -- recorded_by / verified_by 指向系统库 users,跨库不能建外键。 + recorded_by INTEGER, + recorded_at TEXT, + verified_by INTEGER, + verified_at TEXT, + note TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_results_event_state ON results(event_id, state); +CREATE INDEX IF NOT EXISTS idx_results_event_rank ON results(event_id, rank); diff --git a/server/migrations/event/0006_records.sql b/server/migrations/event/0006_records.sql new file mode 100644 index 0000000..d2a38b8 --- /dev/null +++ b/server/migrations/event/0006_records.sql @@ -0,0 +1,33 @@ +CREATE TABLE IF NOT EXISTS records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_id INTEGER NOT NULL REFERENCES events(id), + level TEXT NOT NULL CHECK (level IN ('meet', 'city', 'province', 'national')), + result_value REAL, + result_text TEXT, + holder_name TEXT, + holder_team_id INTEGER REFERENCES teams(id), + achieved_at TEXT, + is_current INTEGER NOT NULL DEFAULT 1 CHECK (is_current IN (0, 1)), + source TEXT NOT NULL DEFAULT 'history' CHECK (source IN ('history', 'current')), + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_records_event_level ON records(event_id, level, is_current); + +CREATE TABLE IF NOT EXISTS record_breaks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_id INTEGER NOT NULL REFERENCES events(id), + entry_id INTEGER NOT NULL REFERENCES entries(id), + level TEXT NOT NULL CHECK (level IN ('meet', 'city', 'province', 'national')), + result_value REAL, + result_text TEXT, + bonus_points REAL NOT NULL DEFAULT 0, + state TEXT NOT NULL DEFAULT 'pending' + CHECK (state IN ('pending', 'approved', 'rejected')), + -- approved_by 指向系统库 users,跨库不能建外键。 + approved_by INTEGER, + approved_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_record_breaks_event_id ON record_breaks(event_id, state); diff --git a/server/migrations/event/0007_score_ledger.sql b/server/migrations/event/0007_score_ledger.sql new file mode 100644 index 0000000..30684dc --- /dev/null +++ b/server/migrations/event/0007_score_ledger.sql @@ -0,0 +1,38 @@ +-- created_by / voided_by 指向系统库 users,跨库不能建外键,靠服务层校验。 +CREATE TABLE IF NOT EXISTS score_ledger ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + team_id INTEGER NOT NULL REFERENCES teams(id), + athlete_id INTEGER REFERENCES athletes(id), + event_id INTEGER REFERENCES events(id), + entry_id INTEGER REFERENCES entries(id), + result_id INTEGER REFERENCES results(id), + source_type TEXT NOT NULL CHECK (source_type IN ('result', 'record_bonus', 'adjustment')), + source_id INTEGER NOT NULL, + points REAL NOT NULL, + voided INTEGER NOT NULL DEFAULT 0 CHECK (voided IN (0, 1)), + voided_by INTEGER, + voided_at TEXT, + void_reason TEXT, + created_by INTEGER, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_score_ledger_team_voided ON score_ledger(team_id, voided); +CREATE INDEX IF NOT EXISTS idx_score_ledger_source ON score_ledger(source_type, source_id); +CREATE INDEX IF NOT EXISTS idx_score_ledger_athlete ON score_ledger(athlete_id, voided); + +CREATE TABLE IF NOT EXISTS point_adjustments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + team_id INTEGER NOT NULL REFERENCES teams(id), + delta REAL NOT NULL, + category TEXT NOT NULL CHECK (category IN ('penalty', 'bonus', 'correction')), + reason TEXT NOT NULL, + event_id INTEGER REFERENCES events(id), + voided INTEGER NOT NULL DEFAULT 0 CHECK (voided IN (0, 1)), + voided_by INTEGER, + voided_at TEXT, + created_by INTEGER, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_point_adjustments_team_voided ON point_adjustments(team_id, voided); diff --git a/server/migrations/event/0008_result_revisions.sql b/server/migrations/event/0008_result_revisions.sql new file mode 100644 index 0000000..4a76796 --- /dev/null +++ b/server/migrations/event/0008_result_revisions.sql @@ -0,0 +1,15 @@ +-- 成绩版本图谱:parent_id 形成 DAG,撤销后从撤销点分支,仅用于可视化。 +CREATE TABLE IF NOT EXISTS result_revisions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + parent_id INTEGER REFERENCES result_revisions(id), + event_id INTEGER NOT NULL REFERENCES events(id), + action TEXT NOT NULL CHECK (action IN ('publish', 'unpublish', 'edit')), + snapshot_json TEXT NOT NULL, + ledger_ids TEXT NOT NULL DEFAULT '[]', + -- actor_id 指向系统库 users,跨库不能建外键。 + actor_id INTEGER, + message TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_result_revisions_event_id ON result_revisions(event_id, created_at); diff --git a/server/migrations/event/0009_change_log.sql b/server/migrations/event/0009_change_log.sql new file mode 100644 index 0000000..398c4c2 --- /dev/null +++ b/server/migrations/event/0009_change_log.sql @@ -0,0 +1,13 @@ +-- id 就是实时协议的 version,必须单调递增、不重用,所以用 AUTOINCREMENT。 +CREATE TABLE IF NOT EXISTS change_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + entity_type TEXT NOT NULL, + entity_id INTEGER, + action TEXT NOT NULL CHECK (action IN ('upsert', 'void', 'replace')), + payload_json TEXT NOT NULL DEFAULT '{}', + -- actor_id 指向系统库 users,跨库不能建外键。 + actor_id INTEGER, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_change_log_entity ON change_log(entity_type, entity_id); diff --git a/server/migrations/event/0010_audit_logs.sql b/server/migrations/event/0010_audit_logs.sql new file mode 100644 index 0000000..a6b42bc --- /dev/null +++ b/server/migrations/event/0010_audit_logs.sql @@ -0,0 +1,17 @@ +-- 审计日志只读不可删;user_id 指向系统库 users,跨库不能建外键。 +CREATE TABLE IF NOT EXISTS audit_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + action TEXT NOT NULL, + entity_type TEXT, + entity_id INTEGER, + before_json TEXT, + after_json TEXT, + ip TEXT, + request_id TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_audit_logs_user_id ON audit_logs(user_id); +CREATE INDEX IF NOT EXISTS idx_audit_logs_entity ON audit_logs(entity_type, entity_id); +CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at); diff --git a/server/migrations/event/0011_seed.sql b/server/migrations/event/0011_seed.sql new file mode 100644 index 0000000..73187a8 --- /dev/null +++ b/server/migrations/event/0011_seed.sql @@ -0,0 +1,45 @@ +-- 默认积分模板:个人 10/8/6/3/2/1,团体 15/10/8/4/2/1(见 02-积分与排名规则 第 2.1 节)。 +INSERT OR IGNORE INTO scoring_profiles (code, name, tie_mode, record_bonus_mode, award_ranks, last_place_zero) +VALUES ('individual_top6', '个人项目前六名', 'skip', 'max', 6, 0), + ('team_top6', '团体项目前六名', 'skip', 'max', 6, 0); + +INSERT OR IGNORE INTO scoring_profile_rules (profile_id, rank, points) +SELECT p.id, r.rank, r.points +FROM scoring_profiles p +JOIN (SELECT 1 AS rank, 10.0 AS points UNION ALL + SELECT 2, 8.0 UNION ALL + SELECT 3, 6.0 UNION ALL + SELECT 4, 3.0 UNION ALL + SELECT 5, 2.0 UNION ALL + SELECT 6, 1.0) r +WHERE p.code = 'individual_top6'; + +INSERT OR IGNORE INTO scoring_profile_rules (profile_id, rank, points) +SELECT p.id, r.rank, r.points +FROM scoring_profiles p +JOIN (SELECT 1 AS rank, 15.0 AS points UNION ALL + SELECT 2, 10.0 UNION ALL + SELECT 3, 8.0 UNION ALL + SELECT 4, 4.0 UNION ALL + SELECT 5, 2.0 UNION ALL + SELECT 6, 1.0) r +WHERE p.code = 'team_top6'; + +-- 赛事级默认配置;键名沿用 04-数据模型 的 event.name / display.rotate_seconds 风格。 +INSERT OR IGNORE INTO settings (key, value) VALUES + ('event.name', '未命名赛事'), + ('event.subtitle', ''), + ('event.organizer', ''), + ('event.logo_path', ''), + ('display.theme_color', '#1E88E5'), + ('display.rotate_seconds', '15'), + ('standings.mode', 'points'), + ('standings.individual_to_team_weight', '1'), + ('standings.show_individual_medals', 'true'), + ('standings.medal_counts_team_events', 'true'), + ('scoring.default_profile', 'individual_top6'), + ('results.review_required', 'false'), + ('record_bonus.enabled', 'false'), + ('record_bonus.mode', 'max'), + ('manual_adjustment.enabled', 'false'), + ('manual_adjustment.reason_required', 'true'); diff --git a/server/migrations/system/0001_init.sql b/server/migrations/system/0001_init.sql new file mode 100644 index 0000000..053bfb9 --- /dev/null +++ b/server/migrations/system/0001_init.sql @@ -0,0 +1,6 @@ +-- 系统级配置:键值对 +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); diff --git a/server/migrations/system/0002_users.sql b/server/migrations/system/0002_users.sql new file mode 100644 index 0000000..56e7cd8 --- /dev/null +++ b/server/migrations/system/0002_users.sql @@ -0,0 +1,16 @@ +-- 登录用户。 +-- role 不含 guest:guest 是「未登录」的默认身份,不落库(见 08-安全与可靠性 第 2 节)。 +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + display_name TEXT NOT NULL DEFAULT '', + role TEXT NOT NULL DEFAULT 'recorder' CHECK (role IN ('recorder', 'admin')), + active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)), + must_change_password INTEGER NOT NULL DEFAULT 1 CHECK (must_change_password IN (0, 1)), + last_login_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_users_active ON users(active); diff --git a/server/migrations/system/0003_sessions.sql b/server/migrations/system/0003_sessions.sql new file mode 100644 index 0000000..504026e --- /dev/null +++ b/server/migrations/system/0003_sessions.sql @@ -0,0 +1,14 @@ +-- 服务端会话;id 为随机字符串,token 只存哈希,便于强制下线。 +CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id), + token_hash TEXT NOT NULL UNIQUE, + expires_at TEXT NOT NULL, + ip TEXT, + user_agent TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_seen_at TEXT +); + +CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at); diff --git a/server/migrations/system/0004_recent_projects.sql b/server/migrations/system/0004_recent_projects.sql new file mode 100644 index 0000000..b89fbbd --- /dev/null +++ b/server/migrations/system/0004_recent_projects.sql @@ -0,0 +1,11 @@ +-- 最近打开的赛事项目;启动时取 last_opened_at 最新的一条自动打开。 +CREATE TABLE IF NOT EXISTS recent_projects ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project TEXT NOT NULL UNIQUE, -- data/event_projs/<项目名> + name TEXT NOT NULL DEFAULT '', -- 展示名,冗余自赛事库 settings + db_path TEXT NOT NULL, -- 相对数据根目录的 event.db 路径 + open_count INTEGER NOT NULL DEFAULT 1, + last_opened_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_recent_projects_last_opened ON recent_projects(last_opened_at DESC); diff --git a/server/migrations/system/0005_seed_settings.sql b/server/migrations/system/0005_seed_settings.sql new file mode 100644 index 0000000..c4ec019 --- /dev/null +++ b/server/migrations/system/0005_seed_settings.sql @@ -0,0 +1,8 @@ +-- 系统级默认配置;赛事级配置在 event.db 的 settings 里,两者不共用。 +INSERT OR IGNORE INTO settings (key, value) VALUES + ('app.name', '赛事积分榜系统'), + ('app.locale', 'zh-CN'), + ('security.session_hours', '12'), + ('security.login_rate_per_minute', '5'), + ('backup.interval_minutes', '5'), + ('backup.keep', '20'); diff --git a/web/admin.html b/web/admin.html new file mode 100644 index 0000000..d8c8118 --- /dev/null +++ b/web/admin.html @@ -0,0 +1,12 @@ + + + + + + 管理面板 + + +
+ + + diff --git a/web/display.html b/web/display.html new file mode 100644 index 0000000..0330350 --- /dev/null +++ b/web/display.html @@ -0,0 +1,12 @@ + + + + + + 积分大屏 + + +
+ + + diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..c21f3b0 --- /dev/null +++ b/web/index.html @@ -0,0 +1,12 @@ + + + + + + 赛事积分榜系统 + + +
+ + + diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..7371884 --- /dev/null +++ b/web/package.json @@ -0,0 +1,23 @@ +{ + "name": "scoreboard-web", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc --noEmit && vite build", + "preview": "vite preview" + }, + "dependencies": { + "pinia": "^4.0.3", + "vue": "^3.5.42", + "vue-router": "^5.3.1" + }, + "devDependencies": { + "@types/node": "^26.5.0", + "@vitejs/plugin-vue": "^6.0.8", + "typescript": "^5.9.3", + "vite": "^8.2.2", + "vue-tsc": "^3.3.11" + } +} diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml new file mode 100644 index 0000000..02923d8 --- /dev/null +++ b/web/pnpm-lock.yaml @@ -0,0 +1,1084 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + pinia: + specifier: ^4.0.3 + version: 4.0.3(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.42(typescript@5.9.3)) + vue: + specifier: ^3.5.42 + version: 3.5.42(typescript@5.9.3) + vue-router: + specifier: ^5.3.1 + version: 5.3.1(@vue/compiler-sfc@3.5.42)(pinia@4.0.3(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.42(typescript@5.9.3)))(rolldown@1.2.7)(vite@8.2.2(@types/node@26.5.0))(vue@3.5.42(typescript@5.9.3)) + devDependencies: + '@types/node': + specifier: ^26.5.0 + version: 26.5.0 + '@vitejs/plugin-vue': + specifier: ^6.0.8 + version: 6.0.8(vite@8.2.2(@types/node@26.5.0))(vue@3.5.42(typescript@5.9.3)) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vite: + specifier: ^8.2.2 + version: 8.2.2(@types/node@26.5.0) + vue-tsc: + specifier: ^3.3.11 + version: 3.3.11(typescript@5.9.3) + +packages: + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@jridgewell/gen-mapping@0.3.5': + resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==} + engines: {node: '>=6.0.0'} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/set-array@1.2.1': + resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@oxc-project/types@0.148.0': + resolution: {integrity: sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==} + + '@rolldown/binding-android-arm-eabi@1.2.7': + resolution: {integrity: sha512-EypzgnYCwyVY4NDHKzGmNJT5b+XaQEBniHxsMdeIQLB/tcCzZnhqrzHpZFbX9iaxx+5RiB8caATBtfvZP7zVxQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.7': + resolution: {integrity: sha512-l17HE9EweWaqJZhuUuNBN/FzM62xw+DECVnJyvMsxn8vJFAGLy5QfLDoYAcronkAN8VxKZHezDpulHDPx95vFw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.7': + resolution: {integrity: sha512-8ED8ELFvHXc6OCETIn4gXObPiaR6bckM/ipXtbzlPVDRMBfEGjCKgO90F9YtfdpDatVx/ZQw7aZ1vUMf/+T3Mw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.7': + resolution: {integrity: sha512-/WPripjtiAIZ2tWY7ddijORT0Ujg87wxWW/qcoFVCKAWVDPhtY0xr7Dj0M3GyNGz60jGwTElhro/mkF9dT7dDQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.7': + resolution: {integrity: sha512-14DI4NcqpvbICxSnGLx3PmtDaWqRP/KGSGb6C+JLLVPeZRl6dKdHba3pGsqT3vpdTqhEYIPG0MMQ8c0xYqoJxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.7': + resolution: {integrity: sha512-bxrWIRvHWQvbJwi+VIie/kDJmQxcNE6xxWwZdqF/ExVAigtHkv54WTLQPb+QsZdnFy18fg7JPfWGL0RH6vwIlQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.7': + resolution: {integrity: sha512-toOY2BChBZyuxU7OYX6Tn389di4IzAqPTycVcci0O7FSfBqzRB3RZn+K5Is6ANf4tmgRd/K1yZTsNTXbkXsnLg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.7': + resolution: {integrity: sha512-lAIXTH/aiLRLxsTgQvfhjo4K1ydWIp00+V0voOr9beb/9ZmkUFrSIb03dXNFRgMNvkE6oGsF10ioQ6UsI+vS5Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.7': + resolution: {integrity: sha512-kdnwS28Pkenp/mZMRwjXXXwxQ7pIsm+bF919LUK93BOyhcLsrVKdP2p9fxpiPNPAbNuch8ypQt0pm2P2LYCAGg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.7': + resolution: {integrity: sha512-516OdsyLdr5E65paF3yBF55t8mfm9+gmtCsK3xI7XKXIT7EfRlHhxL8K/NR6Hu8BWSgF5+1w74lTL0+nxcc8Qw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.7': + resolution: {integrity: sha512-r8/z8n7GFaYRln3xmP1Cxy0HH/HLM0uBUPkEuSVEfKGDA89M0FsZRZJRSwe/tJjRx+fpH/gjorfhB8tmEbSFLA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.7': + resolution: {integrity: sha512-pAsE8iiDxUg1xBqdhrTfg45AVDVpirjz00sblEYClGNNcMnDb+e8beQgqIAw6LvauX/APvgxUnwrgun/YYGBhw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.7': + resolution: {integrity: sha512-lTcIYmmnQQA8Or/2DatS6oSqcdLHvendjS+zLu+FwgToynWMRSmQdpM65fTANJgIS4mjbMOo5KT2lnT9SAb96w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.7': + resolution: {integrity: sha512-e3Gu3WxbNk/UqQhxqU7YIYO+9ZBvWNz3U+h/qRFosscMFzdRPbXYSaSWgSnklv2fz1TgzBTcti2z35c/7irsHw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.7': + resolution: {integrity: sha512-W/jg5qoRSqjsEv0+dZi4e687mcHqmVuU0P4fK6qS/xjetW2Gmc1W8j//z5nAeNcC8Ttm0hV46IjcYeuVwYhuiw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@types/node@26.5.0': + resolution: {integrity: sha512-dVSGpriSoCgz8WnDNTuSSuSv1PC/ALXihO4ulRZt7Md8k9mlbdin3lGOcDE8SnWOgf513ByWlXd7BK4azmyg/A==} + + '@vitejs/plugin-vue@6.0.8': + resolution: {integrity: sha512-0ZjgOg7oO6farnNGup7yvoM/YXZV84OZxHAwtflItNa/6zzQyVb5LNxyea3FEKEX2XlagIKzrlH7wwxkKgtiew==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + vue: ^3.2.25 + + '@volar/language-core@2.4.28': + resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==} + + '@volar/source-map@2.4.28': + resolution: {integrity: sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==} + + '@volar/typescript@2.4.28': + resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@vue-macros/common@3.1.4': + resolution: {integrity: sha512-/5Fv+6DgIcM9ajY05ZmKBv+LMX1M9A0X+IUwDRVdt67ciw8OV9bvG2r34p3RiEadlsQybjhKPRKNXDC8Bp23cw==} + engines: {node: '>=20.19.0'} + peerDependencies: + vue: ^2.7.0 || ^3.2.25 + peerDependenciesMeta: + vue: + optional: true + + '@vue/compiler-core@3.5.42': + resolution: {integrity: sha512-2Ye1ilMtKXxl8qZUrQ5j0CdgenFp/HFQmta6rfRyfEsTG69L6Wk+tWuNoHYHMx9E8tF2Slvdg1FuwDvAXdy1LQ==} + + '@vue/compiler-dom@3.5.42': + resolution: {integrity: sha512-qbhQZEFmycr+ni/qyuccS4sucNN7VAbDfbkvNxWOX2VfgFm90MNs3/UhRNKoPMEIVn0F8gdlYjLPvqxHwHeQOA==} + + '@vue/compiler-sfc@3.5.42': + resolution: {integrity: sha512-fkCAFB4okcAANGMThboWnScp/gzWjU0ZSkVnjTIiplmMDq2uq0tIB3j+xVu4rhv5rvOgBySCysudmbMd6xRRqw==} + + '@vue/compiler-ssr@3.5.42': + resolution: {integrity: sha512-xmLk3wLkbizPAiLyomjgFFosf2ys9b5Ghb+oh/k2tnvipNz8OFrQOiTcWCzyK7MpBp9KkyGtfvgfLUivbmuGYA==} + + '@vue/devtools-api@8.2.1': + resolution: {integrity: sha512-6u4vXBlIBAC1wMplIZgpyPn7uh/s4Bf6F5bMzvLv+EdJ0aHs/+4B7Ygv864EStQSjRbsRzTko/kUG1A1IejQ3A==} + + '@vue/devtools-kit@8.2.1': + resolution: {integrity: sha512-FIGIuq3AWReEpbAHY/cRGeHDfI0qOb8OCQ3YjbEAX04uaxIDbGc9rhkbVcG7rnfHPXE3RsU5KrWOu9V/okd8AQ==} + + '@vue/devtools-shared@8.2.1': + resolution: {integrity: sha512-Fkac7lUdGReh6pVOi3AYPRGe82LQqRmAfThW7RRligOAP0ZA/Z1z9XLHDM9dv34pV2HRc79DK8uKPeG2fLnA/g==} + + '@vue/language-core@3.3.11': + resolution: {integrity: sha512-QJmpliwAVpC/OxubIByPAhNzsQPRc8/gxlN2qnVzVfIMjMDz/9RnXRFoetjz5yEgXVXyp4LqhXq3V53PjmNzFw==} + + '@vue/reactivity@3.5.42': + resolution: {integrity: sha512-TzNNfKpb7hDxbQltwAut8VDQA5YP+BuRlxntHUuRjyKwlMvmAPbs3unhCvieijifY6vFfVBwsS7wG/C7uq+bEQ==} + + '@vue/runtime-core@3.5.42': + resolution: {integrity: sha512-9uACtuHs7vJGkm5Bp3xu4xRDLFTIYy5DgxpToVjqGIAhAEKwQfsaLvKINhM6nFVp6bZPRFGdDqd1g52MqKsotA==} + + '@vue/runtime-dom@3.5.42': + resolution: {integrity: sha512-rsCmhiWLaRxGltLwhlCWyYkFn7WAbKRh0q17eZ1A6Dq6eqc2ACQ61IIryxz0LrsvCzHSilLA9JHovVwM8CNE2g==} + + '@vue/server-renderer@3.5.42': + resolution: {integrity: sha512-2++5dUyYS4gvo7xQXSECUDhB7TS0aOl5SeVfC5qSq1Jgfhjvegw1zqhwTIR3imZ+QYPJQw9gfcFvXGAjGZ7ajQ==} + + '@vue/shared@3.5.42': + resolution: {integrity: sha512-2rPxex1jQf4jvl9MOHl6YaXCPcrNqz/FstMOEh3QWY+/OME9nQTvl9WYeCwhW7AFjaR0SnngZGlp/wkR6rkI6g==} + + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + alien-signals@3.2.1: + resolution: {integrity: sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==} + + ast-kit@2.1.2: + resolution: {integrity: sha512-cl76xfBQM6pztbrFWRnxbrDm9EOqDr1BF6+qQnnDZG2Co2LjyUktkN9GTJfBAfdae+DbT2nJf2nCGAdDDN7W2g==} + engines: {node: '>=20.18.0'} + + ast-kit@2.2.0: + resolution: {integrity: sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==} + engines: {node: '>=20.19.0'} + + ast-walker-scope@0.9.0: + resolution: {integrity: sha512-IJdzo2vLiElBxKzwS36VsCue/62d6IdWjnPB2v3nuPKeWGynp6FF/CYoLa5i/3jXH/z97ZDdsXz6abpgM6w07A==} + engines: {node: '>=20.19.0'} + + birpc@2.6.1: + resolution: {integrity: sha512-LPnFhlDpdSH6FJhJyn4M0kFO7vtQ5iPw24FnG0y21q09xC7e8+1LeR31S1MAIrDAHp4m7aas4bEkTDTvMAtebQ==} + + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + + confbox@0.3.1: + resolution: {integrity: sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + exsolve@1.1.1: + resolution: {integrity: sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + hookable@5.5.3: + resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + local-pkg@1.2.1: + resolution: {integrity: sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==} + engines: {node: '>=14'} + + magic-string-ast@1.0.2: + resolution: {integrity: sha512-8ngQgLhcT0t3YBdn9CGkZqCYlvwW9pm7aWJwd7AxseVWf1RU8ZHCQvG1mt3N5vvUme+pXTcHB8G/7fE666U8Vw==} + engines: {node: '>=20.18.0'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + + muggle-string@0.4.1: + resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + nostics@1.2.0: + resolution: {integrity: sha512-FGqEfhQjrvo1lL8KFifdTQiNwwQHJxC1jtYE1Rc54qF/jxONUNL+kC9gS1krX8Q65PgrQ5fCqH/I4NhWBvdSqg==} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + perfect-debounce@2.1.0: + resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + pinia@4.0.3: + resolution: {integrity: sha512-XMQqpvjgG7LMqVhhFUzKLT4KEbsYbfZZ0CZU9PgdFm1O2VKBmIkykL8+SfNhOaT7sR0wT6BEgKT0YNDYWgmVRA==} + peerDependencies: + '@vue/devtools-api': ^8.1.5 + typescript: '>=5.6.0' + vue: ^3.5.11 + peerDependenciesMeta: + typescript: + optional: true + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + pkg-types@2.3.3: + resolution: {integrity: sha512-j/lCFdcppV0JxWpCEITdbDltBxPP6cHT+yNJ6Go2OgoSA9518X847X9z0p6LtA4Nc16+eQzCZjRrWanTGvHJ5w==} + + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} + engines: {node: ^10 || ^12 || >=14} + + quansync@0.2.11: + resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} + + readdirp@5.1.1: + resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==} + engines: {node: '>= 20.19.0'} + + rolldown@1.2.7: + resolution: {integrity: sha512-g0EtLvBjTUB7jhyV0S/TCup3v/XSVl45vUIGbOGU4QPiyjTenCe4mKuFvW9fEgYmS2Fo42AUssRmNuMziXdrig==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + scule@1.3.0: + resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + + undici-types@8.9.0: + resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} + + unplugin-utils@0.3.2: + resolution: {integrity: sha512-xVToRh2CTmLk2HnEG7ac4rl1MJTT3RFkpS8B++/SnB0kXvuaavD+n3m/vrzyWQOdJNSZQACnbz01pnppbwV5BA==} + engines: {node: '>=20.19.0'} + + unplugin@3.3.0: + resolution: {integrity: sha512-qa66K+crbfyE6JK10GjvbJeRrOsuC/JpbnHctfyp/i4oBTxWOzJfRZyDiOk1PtErMFRu8JhsU/wPvOdBNWe5Rg==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@farmfe/core': '*' + '@rspack/core': '*' + bun-types-no-globals: '*' + esbuild: '*' + rolldown: '*' + rollup: '*' + unloader: '*' + vite: '*' + webpack: '*' + peerDependenciesMeta: + '@farmfe/core': + optional: true + '@rspack/core': + optional: true + bun-types-no-globals: + optional: true + esbuild: + optional: true + rolldown: + optional: true + rollup: + optional: true + unloader: + optional: true + vite: + optional: true + webpack: + optional: true + + vite@8.2.2: + resolution: {integrity: sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.4.0 || ^0.5.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vscode-uri@3.2.0: + resolution: {integrity: sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==} + + vue-router@5.3.1: + resolution: {integrity: sha512-GDBZzgmILxA/kFnkFbJjQZdZ2QQbngnIMMuoUcjhZIfH1RGMaPjPwX5ASnV38qamuA9uhO0RDjSBHTDNG2uXyQ==} + peerDependencies: + '@pinia/colada': '>=0.21.2' + '@vue/compiler-sfc': ^3.5.34 || ^4.0.0 + pinia: ^3.0.4 || ^4.0.2 + vite: ^7.3.0 || ^8.0.0 + vue: ^3.5.34 || ^4.0.0 + peerDependenciesMeta: + '@pinia/colada': + optional: true + '@vue/compiler-sfc': + optional: true + pinia: + optional: true + vite: + optional: true + + vue-tsc@3.3.11: + resolution: {integrity: sha512-gOb0B9rtU2+f1dszwPqSH5kAieIF9ReeLhD3kSRNHv5WZZUQz/JdVXW0RTdqhNTMlQkqKzrTTviqKr/4FYZraQ==} + hasBin: true + peerDependencies: + typescript: '>=5.0.0' + + vue@3.5.42: + resolution: {integrity: sha512-4RyHQTbQvOPs3MfvUO1Sg0YRrKNnA0mAVtvpd12Tg1fKDN7OHBUl1IqSn8zGJjK9nI3NkNp8cgTpVrSZC5TTcA==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + webpack-virtual-modules@0.6.2: + resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + +snapshots: + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@jridgewell/gen-mapping@0.3.5': + dependencies: + '@jridgewell/set-array': 1.2.1 + '@jridgewell/sourcemap-codec': 1.6.0 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/set-array@1.2.1': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@oxc-project/types@0.148.0': {} + + '@rolldown/binding-android-arm-eabi@1.2.7': + optional: true + + '@rolldown/binding-android-arm64@1.2.7': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.7': + optional: true + + '@rolldown/binding-darwin-x64@1.2.7': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.7': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.7': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.7': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.7': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.7': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.7': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.7': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.7': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.7': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.7': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.7': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@types/node@26.5.0': + dependencies: + undici-types: 8.9.0 + + '@vitejs/plugin-vue@6.0.8(vite@8.2.2(@types/node@26.5.0))(vue@3.5.42(typescript@5.9.3))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.2.2(@types/node@26.5.0) + vue: 3.5.42(typescript@5.9.3) + + '@volar/language-core@2.4.28': + dependencies: + '@volar/source-map': 2.4.28 + + '@volar/source-map@2.4.28': {} + + '@volar/typescript@2.4.28(typescript@5.9.3)': + dependencies: + '@volar/language-core': 2.4.28 + path-browserify: 1.0.1 + vscode-uri: 3.2.0 + optionalDependencies: + typescript: 5.9.3 + + '@vue-macros/common@3.1.4(vue@3.5.42(typescript@5.9.3))': + dependencies: + '@vue/compiler-sfc': 3.5.42 + ast-kit: 2.1.2 + local-pkg: 1.2.1 + magic-string-ast: 1.0.2 + unplugin-utils: 0.3.2 + optionalDependencies: + vue: 3.5.42(typescript@5.9.3) + + '@vue/compiler-core@3.5.42': + dependencies: + '@babel/parser': 7.29.8 + '@vue/shared': 3.5.42 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.42': + dependencies: + '@vue/compiler-core': 3.5.42 + '@vue/shared': 3.5.42 + + '@vue/compiler-sfc@3.5.42': + dependencies: + '@babel/parser': 7.29.8 + '@vue/compiler-core': 3.5.42 + '@vue/compiler-dom': 3.5.42 + '@vue/compiler-ssr': 3.5.42 + '@vue/shared': 3.5.42 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.28 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.42': + dependencies: + '@vue/compiler-dom': 3.5.42 + '@vue/shared': 3.5.42 + + '@vue/devtools-api@8.2.1': + dependencies: + '@vue/devtools-kit': 8.2.1 + + '@vue/devtools-kit@8.2.1': + dependencies: + '@vue/devtools-shared': 8.2.1 + birpc: 2.6.1 + hookable: 5.5.3 + perfect-debounce: 2.1.0 + + '@vue/devtools-shared@8.2.1': {} + + '@vue/language-core@3.3.11': + dependencies: + '@volar/language-core': 2.4.28 + '@vue/compiler-dom': 3.5.42 + '@vue/shared': 3.5.42 + alien-signals: 3.2.1 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + picomatch: 4.0.7 + + '@vue/reactivity@3.5.42': + dependencies: + '@vue/shared': 3.5.42 + + '@vue/runtime-core@3.5.42': + dependencies: + '@vue/reactivity': 3.5.42 + '@vue/shared': 3.5.42 + + '@vue/runtime-dom@3.5.42': + dependencies: + '@vue/reactivity': 3.5.42 + '@vue/runtime-core': 3.5.42 + '@vue/shared': 3.5.42 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.42': + dependencies: + '@vue/compiler-ssr': 3.5.42 + '@vue/runtime-dom': 3.5.42 + '@vue/shared': 3.5.42 + + '@vue/shared@3.5.42': {} + + acorn@8.18.0: {} + + alien-signals@3.2.1: {} + + ast-kit@2.1.2: + dependencies: + '@babel/parser': 7.29.8 + pathe: 2.0.3 + + ast-kit@2.2.0: + dependencies: + '@babel/parser': 7.29.8 + pathe: 2.0.3 + + ast-walker-scope@0.9.0: + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + ast-kit: 2.2.0 + + birpc@2.6.1: {} + + chokidar@5.0.0: + dependencies: + readdirp: 5.1.1 + + confbox@0.1.8: {} + + confbox@0.2.4: {} + + confbox@0.3.1: {} + + csstype@3.2.3: {} + + detect-libc@2.1.2: {} + + entities@7.0.1: {} + + estree-walker@2.0.2: {} + + exsolve@1.1.1: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + fsevents@2.3.3: + optional: true + + hookable@5.5.3: {} + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + local-pkg@1.2.1: + dependencies: + mlly: 1.8.2 + pkg-types: 2.3.3 + quansync: 0.2.11 + + magic-string-ast@1.0.2: + dependencies: + magic-string: 0.30.21 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + mlly@1.8.2: + dependencies: + acorn: 8.18.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.4 + + muggle-string@0.4.1: {} + + nanoid@3.3.18: {} + + nostics@1.2.0: {} + + path-browserify@1.0.1: {} + + pathe@2.0.3: {} + + perfect-debounce@2.1.0: {} + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + pinia@4.0.3(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.42(typescript@5.9.3)): + dependencies: + '@vue/devtools-api': 8.2.1 + nostics: 1.2.0 + vue: 3.5.42(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + + pkg-types@2.3.3: + dependencies: + confbox: 0.3.1 + exsolve: 1.1.1 + pathe: 2.0.3 + + postcss@8.5.28: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + quansync@0.2.11: {} + + readdirp@5.1.1: {} + + rolldown@1.2.7: + dependencies: + '@oxc-project/types': 0.148.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.7 + '@rolldown/binding-android-arm64': 1.2.7 + '@rolldown/binding-darwin-arm64': 1.2.7 + '@rolldown/binding-darwin-x64': 1.2.7 + '@rolldown/binding-freebsd-x64': 1.2.7 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.7 + '@rolldown/binding-linux-arm64-gnu': 1.2.7 + '@rolldown/binding-linux-arm64-musl': 1.2.7 + '@rolldown/binding-linux-ppc64-gnu': 1.2.7 + '@rolldown/binding-linux-s390x-gnu': 1.2.7 + '@rolldown/binding-linux-x64-gnu': 1.2.7 + '@rolldown/binding-linux-x64-musl': 1.2.7 + '@rolldown/binding-openharmony-arm64': 1.2.7 + '@rolldown/binding-win32-arm64-msvc': 1.2.7 + '@rolldown/binding-win32-x64-msvc': 1.2.7 + + scule@1.3.0: {} + + source-map-js@1.2.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + typescript@5.9.3: {} + + ufo@1.6.4: {} + + undici-types@8.9.0: {} + + unplugin-utils@0.3.2: + dependencies: + pathe: 2.0.3 + picomatch: 4.0.7 + + unplugin@3.3.0(rolldown@1.2.7)(vite@8.2.2(@types/node@26.5.0)): + dependencies: + '@jridgewell/remapping': 2.3.5 + picomatch: 4.0.7 + webpack-virtual-modules: 0.6.2 + optionalDependencies: + rolldown: 1.2.7 + vite: 8.2.2(@types/node@26.5.0) + + vite@8.2.2(@types/node@26.5.0): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.28 + rolldown: 1.2.7 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.5.0 + fsevents: 2.3.3 + + vscode-uri@3.2.0: {} + + vue-router@5.3.1(@vue/compiler-sfc@3.5.42)(pinia@4.0.3(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.42(typescript@5.9.3)))(rolldown@1.2.7)(vite@8.2.2(@types/node@26.5.0))(vue@3.5.42(typescript@5.9.3)): + dependencies: + '@vue-macros/common': 3.1.4(vue@3.5.42(typescript@5.9.3)) + '@vue/devtools-api': 8.2.1 + ast-walker-scope: 0.9.0 + chokidar: 5.0.0 + confbox: 0.2.4 + local-pkg: 1.2.1 + magic-string: 0.30.21 + mlly: 1.8.2 + muggle-string: 0.4.1 + nostics: 1.2.0 + pathe: 2.0.3 + picomatch: 4.0.7 + scule: 1.3.0 + tinyglobby: 0.2.17 + unplugin: 3.3.0(rolldown@1.2.7)(vite@8.2.2(@types/node@26.5.0)) + unplugin-utils: 0.3.2 + vue: 3.5.42(typescript@5.9.3) + optionalDependencies: + '@vue/compiler-sfc': 3.5.42 + pinia: 4.0.3(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.42(typescript@5.9.3)) + vite: 8.2.2(@types/node@26.5.0) + transitivePeerDependencies: + - '@farmfe/core' + - '@rspack/core' + - bun-types-no-globals + - esbuild + - rolldown + - rollup + - unloader + - webpack + + vue-tsc@3.3.11(typescript@5.9.3): + dependencies: + '@volar/typescript': 2.4.28(typescript@5.9.3) + '@vue/language-core': 3.3.11 + typescript: 5.9.3 + + vue@3.5.42(typescript@5.9.3): + dependencies: + '@vue/compiler-dom': 3.5.42 + '@vue/compiler-sfc': 3.5.42 + '@vue/runtime-dom': 3.5.42 + '@vue/server-renderer': 3.5.42 + '@vue/shared': 3.5.42 + optionalDependencies: + typescript: 5.9.3 + + webpack-virtual-modules@0.6.2: {} diff --git a/web/src/admin/App.vue b/web/src/admin/App.vue new file mode 100644 index 0000000..cb54af9 --- /dev/null +++ b/web/src/admin/App.vue @@ -0,0 +1,65 @@ + + + + + diff --git a/web/src/admin/main.ts b/web/src/admin/main.ts new file mode 100644 index 0000000..7dcacf9 --- /dev/null +++ b/web/src/admin/main.ts @@ -0,0 +1,5 @@ +import { createApp } from 'vue' +import App from './App.vue' +import '@/shared/styles/base.css' + +createApp(App).mount('#app') diff --git a/web/src/display/App.vue b/web/src/display/App.vue new file mode 100644 index 0000000..f21fca6 --- /dev/null +++ b/web/src/display/App.vue @@ -0,0 +1,180 @@ + + + + + diff --git a/web/src/display/main.ts b/web/src/display/main.ts new file mode 100644 index 0000000..7dcacf9 --- /dev/null +++ b/web/src/display/main.ts @@ -0,0 +1,5 @@ +import { createApp } from 'vue' +import App from './App.vue' +import '@/shared/styles/base.css' + +createApp(App).mount('#app') diff --git a/web/src/env.d.ts b/web/src/env.d.ts new file mode 100644 index 0000000..8b53f84 --- /dev/null +++ b/web/src/env.d.ts @@ -0,0 +1,7 @@ +/// + +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent, Record, unknown> + export default component +} diff --git a/web/src/index/App.vue b/web/src/index/App.vue new file mode 100644 index 0000000..cabd126 --- /dev/null +++ b/web/src/index/App.vue @@ -0,0 +1,69 @@ + + + + + diff --git a/web/src/index/main.ts b/web/src/index/main.ts new file mode 100644 index 0000000..7dcacf9 --- /dev/null +++ b/web/src/index/main.ts @@ -0,0 +1,5 @@ +import { createApp } from 'vue' +import App from './App.vue' +import '@/shared/styles/base.css' + +createApp(App).mount('#app') diff --git a/web/src/shared/api.ts b/web/src/shared/api.ts new file mode 100644 index 0000000..51d8c98 --- /dev/null +++ b/web/src/shared/api.ts @@ -0,0 +1,579 @@ +import { isRecord, readString } from './internal' +import type { + AdjustmentInput, + ApiErrorCode, + Athlete, + AthleteInput, + AthleteStanding, + AuditLog, + BackupResult, + CurrentUser, + DisplayCommand, + EntryImportResult, + EntryWithResult, + Event, + EventCategory, + EventCategoryInput, + EventInput, + EventSettings, + HealthInfo, + Page, + PageQuery, + PointAdjustment, + PublishPayload, + RecordBreak, + RecordBreakInput, + RecordEntry, + Result, + ResultQuery, + RulePack, + RulePackImportPreview, + RulePackImportResult, + SaveDraftPayload, + ScoreChannel, + ScoreChannelInput, + ScoringProfile, + ScoringProfileInput, + Sport, + SportInput, + StandingRow, + Team, + TeamBreakdown, + TeamInput, + VoidResultPayload, +} from './types' + +export type QueryValue = string | number | boolean | null | undefined +export type QueryParams = Record + +export interface ApiClientOptions { + /** 基础路径,默认 `/api`;Electron 宿主或反向代理下可传绝对地址。 */ + baseUrl?: string + /** 会话是 HttpOnly Cookie,默认 `include` 才能带上。 */ + credentials?: RequestCredentials + /** CSRF Token 来源;默认读非 HttpOnly 的 csrf_token Cookie(登录时下发)。 */ + csrfToken?: () => string | null + /** 单次请求超时;现场局域网也可能出现半开连接,必须能主动放弃。 */ + timeoutMs?: number + /** 幂等键生成器;重试写操作时复用同一个 requestId,服务端据此返回首次结果。 */ + requestId?: () => string + onUnauthorized?: (error: ApiError) => void + fetchImpl?: typeof fetch +} + +interface RequestOptions { + method?: HttpMethod + /** 传接口对象即可;接口没有索引签名,所以这里用 object 接收后运行时收窄。 */ + query?: object + body?: unknown + signal?: AbortSignal +} + +type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' + +/** 统一响应约定见 05-接口与实时协议 第 6 节。 */ +export class ApiError extends Error { + readonly code: ApiErrorCode + readonly status: number + readonly detail: unknown + + constructor(code: ApiErrorCode, message: string, status: number, detail?: unknown) { + super(message) + this.name = 'ApiError' + this.code = code + this.status = status + this.detail = detail + } +} + +const API_ERROR_CODES: readonly string[] = [ + 'AUTH_REQUIRED', + 'AUTH_FAILED', + 'FORBIDDEN', + 'VALIDATION_FAILED', + 'CONFLICT', + 'STATE_INVALID', + 'RATE_LIMITED', + 'INTERNAL', + 'NETWORK', + 'TIMEOUT', + 'PARSE_FAILED', +] + +function isApiErrorCode(value: unknown): value is ApiErrorCode { + return typeof value === 'string' && API_ERROR_CODES.includes(value) +} + +/** 组别 / 通道随小项整体保存,避免只写了一半的配置被当成有效配置。 */ +export interface CrudApi { + list(): Promise + get(id: number): Promise + create(input: Input): Promise + update(id: number, input: Partial): Promise + remove(id: number): Promise +} + +export interface AuthApi { + login(username: string, password: string): Promise + logout(): Promise + me(): Promise +} + +export interface SettingsApi { + get(): Promise + update(patch: Partial): Promise +} + +export interface EventsApi extends CrudApi { + entries(eventId: number): Promise + categories(eventId: number): Promise + saveCategories(eventId: number, categories: EventCategoryInput[]): Promise + channels(eventId: number): Promise + saveChannels(eventId: number, channels: ScoreChannelInput[]): Promise +} + +export interface EntriesApi { + /** 报名表按文件导入,后端解析 xlsx。 */ + importFile(file: File): Promise +} + +export interface ResultsApi { + list(query: ResultQuery): Promise> + saveDraft(payload: SaveDraftPayload): Promise + publish(payload: PublishPayload): Promise + unpublish(payload: PublishPayload): Promise + void(id: number, payload: VoidResultPayload): Promise +} + +export interface StandingsApi { + teams(): Promise + athletes(): Promise + breakdown(teamId: number): Promise +} + +export interface AdjustmentsApi { + list(query?: PageQuery & { teamId?: number }): Promise> + create(input: AdjustmentInput): Promise + void(id: number, reason: string): Promise +} + +export interface RecordsApi { + list(query?: { eventId?: number }): Promise + createBreak(input: RecordBreakInput): Promise + approveBreak(id: number): Promise +} + +export interface DisplayApi { + command(command: DisplayCommand): Promise +} + +export interface ExportQuery { + eventId?: number + teamId?: number +} + +export interface ExportApi { + /** 返回可直接放进 的地址,省掉一次内存中的 Blob。 */ + standingsUrl(query?: ExportQuery): string + breakdownUrl(query: ExportQuery): string + resultsUrl(query?: ExportQuery): string + standings(query?: ExportQuery): Promise + breakdown(query: ExportQuery): Promise + results(query?: ExportQuery): Promise + save(blob: Blob, filename: string): void +} + +export interface AuditApi { + list(query?: PageQuery & { entityType?: string; userId?: number }): Promise> +} + +export interface RulesApi { + /** GET /api/rules/export,返回当前规则包。 */ + get(): Promise + /** 先预览差异,再决定是否落地(02 第 7.3 节硬性要求)。 */ + preview(pack: RulePack): Promise + apply(pack: RulePack): Promise +} + +export interface SystemApi { + backup(): Promise +} + +interface Http { + request(path: string, options?: RequestOptions): Promise + requestVoid(path: string, options?: RequestOptions): Promise + requestJson(path: string, options?: RequestOptions): Promise + url(path: string, query?: object): string + blob(path: string, options?: RequestOptions): Promise +} + +function createCrudApi(http: Http, base: string): CrudApi { + return { + list: () => http.request(base), + get: (id) => http.request(`${base}/${id}`), + create: (input) => http.request(base, { method: 'POST', body: input }), + update: (id, input) => http.request(`${base}/${id}`, { method: 'PUT', body: input }), + remove: (id) => http.requestVoid(`${base}/${id}`, { method: 'DELETE' }), + } +} + +function createEventsApi(http: Http): EventsApi { + return { + ...createCrudApi(http, '/events'), + entries: (eventId) => http.request(`/events/${eventId}/entries`), + categories: (eventId) => http.request(`/events/${eventId}/categories`), + saveCategories: (eventId, categories) => + http.request(`/events/${eventId}/categories`, { + method: 'PUT', + body: { categories }, + }), + channels: (eventId) => http.request(`/events/${eventId}/channels`), + saveChannels: (eventId, channels) => + http.request(`/events/${eventId}/channels`, { + method: 'PUT', + body: { channels }, + }), + } +} + +function createResultsApi(http: Http): ResultsApi { + return { + list: (query) => http.request>('/results', { query }), + saveDraft: (payload) => http.request('/results/draft', { method: 'PUT', body: payload }), + publish: (payload) => http.request('/results/publish', { method: 'POST', body: payload }), + unpublish: (payload) => + http.request('/results/unpublish', { method: 'POST', body: payload }), + void: (id, payload) => http.request(`/results/${id}/void`, { method: 'POST', body: payload }), + } +} + +function createExportApi(http: Http): ExportApi { + const path = { + standings: '/export/standings.xlsx', + breakdown: '/export/breakdown.xlsx', + results: '/export/results.xlsx', + } + return { + standingsUrl: (query) => http.url(path.standings, query), + breakdownUrl: (query) => http.url(path.breakdown, query), + resultsUrl: (query) => http.url(path.results, query), + standings: (query) => http.blob(path.standings, { query }), + breakdown: (query) => http.blob(path.breakdown, { query }), + results: (query) => http.blob(path.results, { query }), + save: (blob, filename) => { + const url = URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = filename + anchor.rel = 'noopener' + anchor.click() + // 立即 revoke 会在部分浏览器里取消下载,等一帧再释放。 + setTimeout(() => URL.revokeObjectURL(url), 0) + }, + } +} + +function createRulesApi(http: Http): RulesApi { + return { + get: () => http.request('/rules/export'), + preview: (pack) => + http.request('/rules/import', { + method: 'POST', + query: { preview: 1 }, + body: pack, + }), + apply: (pack) => + http.request('/rules/import', { method: 'POST', body: pack }), + } +} + +function readCsrfCookie(): string | null { + if (typeof document === 'undefined') return null + const match = /(?:^|;\s*)csrf_token=([^;]+)/.exec(document.cookie) + const raw = match?.[1] + return raw === undefined ? null : decodeURIComponent(raw) +} + +function defaultRequestId(): string { + const cryptoApi: Crypto | undefined = globalThis.crypto + if (cryptoApi !== undefined && typeof cryptoApi.randomUUID === 'function') { + return cryptoApi.randomUUID() + } + return `req-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}` +} + +export class ApiClient { + readonly auth: AuthApi + readonly settings: SettingsApi + readonly teams: CrudApi + readonly sports: CrudApi + readonly events: EventsApi + readonly scoringProfiles: CrudApi + readonly athletes: CrudApi + readonly entries: EntriesApi + readonly results: ResultsApi + readonly standings: StandingsApi + readonly adjustments: AdjustmentsApi + readonly records: RecordsApi + readonly display: DisplayApi + readonly exports: ExportApi + readonly auditLogs: AuditApi + readonly rules: RulesApi + readonly system: SystemApi + + private readonly baseUrl: string + private readonly credentials: RequestCredentials + private readonly timeoutMs: number + private readonly csrfToken: () => string | null + private readonly newRequestId: () => string + private readonly onUnauthorized: ((error: ApiError) => void) | undefined + private readonly fetchImpl: typeof fetch + + constructor(options: ApiClientOptions = {}) { + this.baseUrl = (options.baseUrl ?? '/api').replace(/\/+$/, '') + this.credentials = options.credentials ?? 'include' + this.timeoutMs = options.timeoutMs ?? 15_000 + this.csrfToken = options.csrfToken ?? readCsrfCookie + this.newRequestId = options.requestId ?? defaultRequestId + this.onUnauthorized = options.onUnauthorized + this.fetchImpl = options.fetchImpl ?? globalThis.fetch.bind(globalThis) + + const http: Http = { + request: (path, requestOptions) => this.request(path, requestOptions), + requestVoid: (path, requestOptions) => this.requestVoid(path, requestOptions), + requestJson: (path, requestOptions) => this.requestJson(path, requestOptions), + url: (path, query) => this.buildUrl(path, query), + blob: (path, requestOptions) => this.fetchBlob(path, requestOptions), + } + + this.auth = { + login: (username, password) => + http.request('/auth/login', { method: 'POST', body: { username, password } }), + logout: () => http.requestVoid('/auth/logout', { method: 'POST' }), + me: () => http.request('/auth/me'), + } + + this.settings = { + get: () => http.request('/settings'), + update: (patch) => http.request('/settings', { method: 'PUT', body: patch }), + } + + this.teams = createCrudApi(http, '/teams') + this.sports = createCrudApi(http, '/sports') + this.events = createEventsApi(http) + this.scoringProfiles = createCrudApi(http, '/scoring-profiles') + this.athletes = createCrudApi(http, '/athletes') + + this.entries = { + importFile: (file) => { + const form = new FormData() + form.append('file', file) + return http.request('/entries/import', { method: 'POST', body: form }) + }, + } + + this.results = createResultsApi(http) + + this.standings = { + teams: () => http.request('/standings'), + athletes: () => http.request('/standings/athletes'), + breakdown: (teamId) => http.request(`/standings/teams/${teamId}/breakdown`), + } + + this.adjustments = { + list: (query) => http.request>('/adjustments', { query }), + create: (input) => http.request('/adjustments', { method: 'POST', body: input }), + void: (id, reason) => + http.request(`/adjustments/${id}/void`, { + method: 'POST', + body: { reason }, + }), + } + + this.records = { + list: (query) => http.request('/records', { query }), + createBreak: (input) => http.request('/record-breaks', { method: 'POST', body: input }), + approveBreak: (id) => + http.request(`/record-breaks/${id}/approve`, { method: 'POST' }), + } + + this.display = { + command: (command) => http.requestVoid('/display/command', { method: 'POST', body: command }), + } + + this.exports = createExportApi(http) + this.auditLogs = { + list: (query) => http.request>('/audit-logs', { query }), + } + this.rules = createRulesApi(http) + this.system = { + backup: () => http.request('/backup', { method: 'POST' }), + } + } + + /** 健康检查不走 {ok,data} 包装,直接返回 `{ok,version}`(05 第 7 节)。 */ + async health(): Promise { + const body = await this.requestJson('/health') + if (isRecord(body) && body.ok === false) throw this.toApiError(body, 0) + const version = readString(body, 'version') + if (version === null) { + throw new ApiError('PARSE_FAILED', '健康检查响应缺少 version 字段', 0, body) + } + return { version } + } + + private async request(path: string, options: RequestOptions = {}): Promise { + const body = await this.requestJson(path, options) + return this.unwrap(body) + } + + private async requestVoid(path: string, options: RequestOptions = {}): Promise { + const body = await this.requestJson(path, options) + // 204 或空响应视为成功;有响应体时仍校验 {ok:true}。 + if (body === null || body === undefined) return + this.unwrap(body) + } + + private async requestJson(path: string, options: RequestOptions = {}): Promise { + const response = await this.send(path, options) + const body = await readBody(response) + if (response.status === 401) { + const error = this.toApiError(body, response.status) + this.onUnauthorized?.(error) + throw error + } + if (!response.ok) throw this.toApiError(body, response.status) + return body + } + + private async fetchBlob(path: string, options: RequestOptions = {}): Promise { + const response = await this.send(path, options) + if (!response.ok) { + const body = await readBody(response) + if (response.status === 401) { + const error = this.toApiError(body, response.status) + this.onUnauthorized?.(error) + throw error + } + throw this.toApiError(body, response.status) + } + return response.blob() + } + + private async send(path: string, options: RequestOptions): Promise { + const method: HttpMethod = options.method ?? 'GET' + const payload = this.prepareBody(method, options.body) + const headers: Record = { Accept: 'application/json' } + + if (payload instanceof FormData) { + // 交给浏览器带 multipart boundary,手动设置 Content-Type 会丢 boundary。 + } else if (payload !== undefined) { + headers['Content-Type'] = 'application/json; charset=utf-8' + } + + if (method !== 'GET') { + const token = this.csrfToken() + if (token !== null && token !== '') headers['X-CSRF-Token'] = token + headers['X-Request-Id'] = this.newRequestId() + } + + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), this.timeoutMs) + const external = options.signal + if (external !== undefined) { + if (external.aborted) controller.abort() + else external.addEventListener('abort', () => controller.abort(), { once: true }) + } + + try { + return await this.fetchImpl(this.buildUrl(path, options.query), { + method, + headers, + credentials: this.credentials, + body: + payload === undefined + ? undefined + : payload instanceof FormData + ? payload + : JSON.stringify(payload), + signal: controller.signal, + }) + } catch (error) { + if (controller.signal.aborted) { + throw new ApiError('TIMEOUT', `请求超时(${this.timeoutMs}ms)`, 0, error) + } + throw new ApiError('NETWORK', '无法连接服务端', 0, error) + } finally { + clearTimeout(timer) + } + } + + /** 写操作注入 requestId:网络抖动重发时服务端能返回首次结果而不是重复执行。 */ + private prepareBody(method: HttpMethod, body: unknown): unknown { + if (method === 'GET' || body === undefined) return undefined + if (isRecord(body) && !('requestId' in body)) { + return { ...body, requestId: this.newRequestId() } + } + return body + } + + private unwrap(body: unknown): T { + if (!isRecord(body) || typeof body.ok !== 'boolean') { + throw new ApiError('PARSE_FAILED', '服务端响应不是约定的 {ok,data} 结构', 0, body) + } + if (body.ok) return body.data as T + throw this.toApiError(body, 0) + } + + private toApiError(body: unknown, status: number): ApiError { + if (isRecord(body) && isRecord(body.error)) { + const code = body.error.code + const message = readString(body.error, 'message') + return new ApiError( + isApiErrorCode(code) ? code : 'INTERNAL', + message ?? `请求失败(HTTP ${status})`, + status, + body.error.detail, + ) + } + return new ApiError('INTERNAL', `请求失败(HTTP ${status})`, status, body) + } + + private buildUrl(path: string, query?: object): string { + const base = path.startsWith('/') ? `${this.baseUrl}${path}` : `${this.baseUrl}/${path}` + if (query === undefined || query === null || !isRecord(query)) return base + + const params = new URLSearchParams() + for (const [key, value] of Object.entries(query)) { + if (Array.isArray(value)) { + for (const item of value) { + if (isScalar(item)) params.append(key, String(item)) + } + continue + } + if (isScalar(value)) params.append(key, String(value)) + } + const search = params.toString() + return search === '' ? base : `${base}?${search}` + } +} + +function isScalar(value: unknown): value is string | number | boolean { + return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' +} + +async function readBody(response: Response): Promise { + if (response.status === 204) return null + const text = await response.text() + if (text.trim() === '') return null + try { + return JSON.parse(text) + } catch { + // 非 JSON 也原样返回,报错里带上原文才定位得到问题。 + return text + } +} + +export function createApiClient(options: ApiClientOptions = {}): ApiClient { + return new ApiClient(options) +} diff --git a/web/src/shared/format.ts b/web/src/shared/format.ts new file mode 100644 index 0000000..24bc137 --- /dev/null +++ b/web/src/shared/format.ts @@ -0,0 +1,111 @@ +import type { ResultType } from './types' + +/** + * 成绩 / 时间的展示与录入互转。 + * + * 库里同时存 result_text(给人看)和 result_value(给排序用), + * 两者必须在同一处换算,否则录入页和导出会出现「看起来一样、排出来不一样」。 + */ + +const DATE_PAD = (value: number): string => String(value).padStart(2, '0') + +/** 秒 → `1:06.33`;不足一分钟用 `6.33`。 */ +export function formatSeconds(seconds: number, fractionDigits = 2): string { + if (!Number.isFinite(seconds)) return '—' + + const sign = seconds < 0 ? '-' : '' + const abs = Math.abs(seconds) + let minutes = Math.floor(abs / 60) + let rest = (abs - minutes * 60).toFixed(fractionDigits) + + // 59.999 会四舍五入成 "60.00",进位到分钟,避免出现 "1:60.00"。 + if (Number(rest) >= 60) { + minutes += 1 + rest = (0).toFixed(fractionDigits) + } + + const body = minutes > 0 ? `${minutes}:${rest.padStart(3 + fractionDigits, '0')}` : rest + return `${sign}${body}` +} + +/** `1:06.33` / `66.33` → 66.33;无法解析返回 null。 */ +export function parseSeconds(text: string): number | null { + const trimmed = text.trim() + if (trimmed === '') return null + + const parts = trimmed.split(':') + if (parts.length > 2) return null + + const numbers: number[] = [] + for (const part of parts) { + const value = Number(part) + if (!Number.isFinite(value)) return null + numbers.push(value) + } + + if (numbers.length === 1) return numbers[0] ?? null + + const minutes = numbers[0] ?? 0 + const seconds = numbers[1] ?? 0 + if (seconds < 0 || seconds >= 60) return null + return minutes * 60 + seconds +} + +/** 按单位把比较值渲染成展示文本,如 `66.33` + `s` → `1:06.33`。 */ +export function formatResultText( + value: number | null, + unit: string, + type: ResultType = 'none', +): string { + if (value === null || !Number.isFinite(value)) return '—' + if (type === 'time' || unit === 's') return formatSeconds(value) + if (unit === 'm') return `${value.toFixed(2)}米` + if (unit === 'pts') return `${formatPoints(value)}分` + if (unit === '次') return `${Math.round(value)}次` + return `${formatPoints(value)}${unit}` +} + +/** 展示文本 → 比较值,与 formatResultText 互逆。 */ +export function parseResultValue(text: string, unit: string): number | null { + const trimmed = text.trim() + if (trimmed === '') return null + if (unit === 's') return parseSeconds(trimmed) + + const numeric = Number(trimmed.replace(/[^\d.+-]/g, '')) + return Number.isFinite(numeric) ? numeric : null +} + +/** 积分去掉无意义的 `.0`,同时避免 `12.25` 被截成 `12.3`。 */ +export function formatPoints(points: number): string { + if (!Number.isFinite(points)) return '—' + return Number.isInteger(points) ? String(points) : String(Number(points.toFixed(2))) +} + +/** 名次升降:正数为上升。无变化返回空串,交给调用方决定是否占位。 */ +export function formatRankChange(delta: number | null): string { + if (delta === null || delta === 0) return '' + return delta > 0 ? `↑${delta}` : `↓${Math.abs(delta)}` +} + +/** ISO → `YYYY-MM-DD HH:mm`(本地时区)。 */ +export function formatDateTime(iso: string | null): string { + const date = toDate(iso) + if (date === null) return '—' + return ( + `${date.getFullYear()}-${DATE_PAD(date.getMonth() + 1)}-${DATE_PAD(date.getDate())}` + + ` ${DATE_PAD(date.getHours())}:${DATE_PAD(date.getMinutes())}` + ) +} + +/** ISO → `HH:mm`,大屏顶栏时钟用。 */ +export function formatClock(iso: string | null): string { + const date = toDate(iso) + if (date === null) return '--:--' + return `${DATE_PAD(date.getHours())}:${DATE_PAD(date.getMinutes())}` +} + +function toDate(iso: string | null): Date | null { + if (iso === null || iso === '') return null + const date = new Date(iso) + return Number.isNaN(date.getTime()) ? null : date +} diff --git a/web/src/shared/index.ts b/web/src/shared/index.ts new file mode 100644 index 0000000..ee16c2e --- /dev/null +++ b/web/src/shared/index.ts @@ -0,0 +1,27 @@ +/** + * shared 层统一出口。 + * + * 三个入口(index / display / admin)只从这里取公共能力, + * 避免各自 import 到内部文件后形成难以收敛的依赖网。 + */ + +export * from './types' +export { ApiClient, ApiError, createApiClient } from './api' +export type { ApiClientOptions, QueryParams, QueryValue } from './api' +export { RealtimeClient } from './realtime' +export type { + RealtimeInfo, + RealtimeOptions, + RealtimeStatus, + ReconnectOptions, +} from './realtime' +export { + formatClock, + formatDateTime, + formatPoints, + formatRankChange, + formatResultText, + formatSeconds, + parseResultValue, + parseSeconds, +} from './format' diff --git a/web/src/shared/internal.ts b/web/src/shared/internal.ts new file mode 100644 index 0000000..41805d4 --- /dev/null +++ b/web/src/shared/internal.ts @@ -0,0 +1,16 @@ +/** + * shared 层内部工具,不对外导出。 + * + * HTTP / WebSocket 收到的一切都先当 unknown,再用这些收窄函数校验; + * 直接断言会把服务端字段改名这类问题推迟到运行期某个深处才炸。 + */ + +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export function readString(source: unknown, key: string): string | null { + if (!isRecord(source)) return null + const value = source[key] + return typeof value === 'string' ? value : null +} diff --git a/web/src/shared/realtime.ts b/web/src/shared/realtime.ts new file mode 100644 index 0000000..d4456ed --- /dev/null +++ b/web/src/shared/realtime.ts @@ -0,0 +1,371 @@ +import { isRecord } from './internal' +import type { + CatchupMessage, + ChangeMessage, + ClientMessageData, + ClientRole, + DisplayCommand, + HeartbeatMessage, + HelloMessage, + ServerMessage, + SnapshotMessage, +} from './types' + +export type RealtimeStatus = 'idle' | 'connecting' | 'online' | 'offline' + +export interface RealtimeInfo { + /** 本地已应用到的版本;null 表示还没拿到快照。 */ + version: number | null + /** 连续重连次数,连上后归零。 */ + attempt: number + /** 服务端最近一次心跳时间,用于显示时钟偏差。 */ + serverTime: string | null +} + +export interface ReconnectOptions { + baseDelayMs?: number + maxDelayMs?: number + factor?: number + /** 抖动比例,避免多块大屏同时重连把服务端打满。 */ + jitterRatio?: number +} + +export interface RealtimeOptions { + /** 默认按当前页面推导 `ws(s):///ws`;Electron 或反代下可显式指定。 */ + url?: string + role: ClientRole + displayKey?: string + /** 大屏从 localStorage 恢复快照后传入,可让服务端只补增量。 */ + initialVersion?: number | null + reconnect?: ReconnectOptions + /** 超过该时间没有任何服务端消息即判定半开连接,主动断开重连。 */ + watchdogMs?: number + /** resync 最小间隔,避免版本持续落后时反复打断服务端。 */ + resyncCooldownMs?: number + onSnapshot?: (snapshot: SnapshotMessage) => void + onCatchup?: (message: CatchupMessage) => void + onChange?: (message: ChangeMessage) => void + onCommand?: (command: DisplayCommand) => void + onStatusChange?: (status: RealtimeStatus, info: RealtimeInfo) => void + onVersionChange?: (version: number) => void + onError?: (error: Error) => void +} + +const SERVER_EVENTS: readonly string[] = [ + 'snapshot', + 'catchup', + 'change', + 'heartbeat', + 'display:command', +] + +function isServerMessage(value: unknown): value is ServerMessage { + if (!isRecord(value)) return false + const event = value.event + return typeof event === 'string' && SERVER_EVENTS.includes(event) && isRecord(value.data) +} + +function defaultUrl(): string { + if (typeof location === 'undefined') return '/ws' + const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:' + return `${protocol}//${location.host}/ws` +} + +/** + * 版本号 + 快照/增量的自研实时客户端(05 第 1–5 节)。 + * + * 关键约束:变更必须按 version 顺序应用,缺口只靠 resync 补, + * 不允许把乱序的增量直接合并进本地状态。 + */ +export class RealtimeClient { + private readonly url: string + private readonly role: ClientRole + private readonly displayKey: string | undefined + private readonly reconnect: Required + private readonly watchdogMs: number + private readonly resyncCooldownMs: number + private readonly handlers: Pick< + RealtimeOptions, + | 'onSnapshot' + | 'onCatchup' + | 'onChange' + | 'onCommand' + | 'onStatusChange' + | 'onVersionChange' + | 'onError' + > + + private socket: WebSocket | null = null + private currentStatus: RealtimeStatus = 'idle' + private currentVersion: number | null + private attempt = 0 + private serverTime: string | null = null + private closedByUser = false + private reconnectTimer: ReturnType | null = null + private watchdogTimer: ReturnType | null = null + private lastResyncAt = 0 + + constructor(options: RealtimeOptions) { + this.url = options.url ?? defaultUrl() + this.role = options.role + this.displayKey = options.displayKey + this.currentVersion = options.initialVersion ?? null + this.watchdogMs = options.watchdogMs ?? 25_000 + this.resyncCooldownMs = options.resyncCooldownMs ?? 3_000 + this.reconnect = { + baseDelayMs: options.reconnect?.baseDelayMs ?? 1_000, + maxDelayMs: options.reconnect?.maxDelayMs ?? 30_000, + factor: options.reconnect?.factor ?? 2, + jitterRatio: options.reconnect?.jitterRatio ?? 0.2, + } + this.handlers = { + onSnapshot: options.onSnapshot, + onCatchup: options.onCatchup, + onChange: options.onChange, + onCommand: options.onCommand, + onStatusChange: options.onStatusChange, + onVersionChange: options.onVersionChange, + onError: options.onError, + } + } + + get status(): RealtimeStatus { + return this.currentStatus + } + + get version(): number | null { + return this.currentVersion + } + + get lastServerTime(): string | null { + return this.serverTime + } + + connect(): void { + if (typeof WebSocket === 'undefined') { + this.handlers.onError?.(new Error('当前环境不支持 WebSocket')) + return + } + const existing = this.socket + if (existing !== null && existing.readyState !== WebSocket.CLOSED) return + + this.closedByUser = false + this.clearReconnectTimer() + this.setStatus('connecting') + + let socket: WebSocket + try { + socket = new WebSocket(this.url) + } catch (error) { + this.handlers.onError?.(toError(error)) + this.scheduleReconnect() + return + } + + this.socket = socket + socket.onopen = () => { + this.attempt = 0 + this.setStatus('online') + this.sendHello() + this.armWatchdog() + } + socket.onmessage = (event: MessageEvent) => { + this.handleMessage(event.data) + } + socket.onerror = () => { + // 错误之后必然跟一次 close,重连交给 onclose 统一处理。 + this.handlers.onError?.(new Error('WebSocket 连接出错')) + } + socket.onclose = () => { + this.socket = null + this.clearWatchdog() + if (!this.closedByUser) this.scheduleReconnect() + } + } + + close(): void { + this.closedByUser = true + this.clearReconnectTimer() + this.clearWatchdog() + + const socket = this.socket + this.socket = null + if (socket !== null) { + // 先摘掉回调,否则 close() 会触发 onclose 又排一次重连。 + socket.onopen = null + socket.onmessage = null + socket.onerror = null + socket.onclose = null + socket.close() + } + this.setStatus('idle') + } + + /** 客户端发现本地版本落后时主动补数据(心跳里 version 更大、或收到无法应用的增量)。 */ + requestResync(): void { + const now = Date.now() + if (now - this.lastResyncAt < this.resyncCooldownMs) return + this.lastResyncAt = now + this.send('resync', {}) + } + + private handleMessage(raw: unknown): void { + if (typeof raw !== 'string') { + this.handlers.onError?.(new Error('收到非文本 WebSocket 消息')) + return + } + + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + this.handlers.onError?.(new Error('WebSocket 消息不是合法 JSON')) + return + } + + if (!isServerMessage(parsed)) { + this.handlers.onError?.(new Error('WebSocket 消息缺少 event/data 字段')) + return + } + + // 任何服务端消息都算一次心跳,watchdog 只关心「多久没消息」。 + this.armWatchdog() + + switch (parsed.event) { + case 'snapshot': + this.applySnapshot(parsed.data) + break + case 'catchup': + this.applyCatchup(parsed.data) + break + case 'change': + this.applyChange(parsed.data) + break + case 'heartbeat': + this.applyHeartbeat(parsed.data) + break + case 'display:command': + this.handlers.onCommand?.(parsed.data) + break + } + } + + private applySnapshot(data: SnapshotMessage): void { + if (this.currentVersion !== null && data.version < this.currentVersion) return + this.setVersion(data.version) + this.handlers.onSnapshot?.(data) + this.sendAck() + } + + private applyCatchup(data: CatchupMessage): void { + if (this.currentVersion !== null && data.version <= this.currentVersion) return + this.setVersion(data.version) + this.handlers.onCatchup?.(data) + this.sendAck() + } + + private applyChange(data: ChangeMessage): void { + // 重复或乱序的变更直接丢弃(05 第 5 节)。 + if (this.currentVersion !== null && data.version <= this.currentVersion) return + // 还没有快照就收到增量,说明错过了 hello 的响应,先要全量。 + if (this.currentVersion === null) { + this.requestResync() + return + } + this.setVersion(data.version) + this.handlers.onChange?.(data) + this.sendAck() + } + + private applyHeartbeat(data: HeartbeatMessage): void { + this.serverTime = data.serverTime + // 心跳 version 更大说明本地漏了变更(服务端每 10s 一次),补一发 resync。 + if (this.currentVersion === null || data.version > this.currentVersion) { + this.requestResync() + } + } + + private setVersion(version: number): void { + if (this.currentVersion === version) return + this.currentVersion = version + this.handlers.onVersionChange?.(version) + } + + private sendHello(): void { + const hello: HelloMessage = { role: this.role } + if (this.currentVersion !== null) hello.lastVersion = this.currentVersion + if (this.displayKey !== undefined) hello.displayKey = this.displayKey + this.send('hello', hello) + } + + private sendAck(): void { + if (this.currentVersion === null) return + this.send('ack', { version: this.currentVersion }) + } + + private send(event: K, data: ClientMessageData[K]): void { + const socket = this.socket + if (socket === null || socket.readyState !== WebSocket.OPEN) return + socket.send(JSON.stringify({ event, data })) + } + + private armWatchdog(): void { + this.clearWatchdog() + this.watchdogTimer = setTimeout(() => { + this.handlers.onError?.(new Error('心跳超时,判定连接已断开')) + // 主动关闭以触发 onclose 里的重连;半开连接不会自己走到这一步。 + this.socket?.close() + }, this.watchdogMs) + } + + private clearWatchdog(): void { + if (this.watchdogTimer !== null) { + clearTimeout(this.watchdogTimer) + this.watchdogTimer = null + } + } + + private scheduleReconnect(): void { + if (this.closedByUser || this.reconnectTimer !== null) return + this.setStatus('offline') + + const delay = this.nextDelay() + this.attempt += 1 + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null + this.connect() + }, delay) + } + + private nextDelay(): number { + const raw = this.reconnect.baseDelayMs * this.reconnect.factor ** this.attempt + const capped = Math.min(raw, this.reconnect.maxDelayMs) + const jitter = capped * this.reconnect.jitterRatio * (Math.random() * 2 - 1) + return Math.max(0, Math.round(capped + jitter)) + } + + private clearReconnectTimer(): void { + if (this.reconnectTimer !== null) { + clearTimeout(this.reconnectTimer) + this.reconnectTimer = null + } + } + + private setStatus(status: RealtimeStatus): void { + if (this.currentStatus === status) return + this.currentStatus = status + this.handlers.onStatusChange?.(status, this.info()) + } + + private info(): RealtimeInfo { + return { + version: this.currentVersion, + attempt: this.attempt, + serverTime: this.serverTime, + } + } +} + +function toError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)) +} diff --git a/web/src/shared/styles/base.css b/web/src/shared/styles/base.css new file mode 100644 index 0000000..05929b4 --- /dev/null +++ b/web/src/shared/styles/base.css @@ -0,0 +1,43 @@ +/* 全局基础样式。设计令牌待视觉定稿后替换,详见 devdocs/06-大屏端设计.md */ +:root { + --bg: #0b1e3a; + --panel: rgba(255, 255, 255, 0.05); + --panel-border: rgba(120, 180, 255, 0.25); + --gold: #ffd75e; + --cyan: #4fd8ff; + --text: #e8f1ff; + --text-dim: #9db4d4; + + --design-w: 1920px; + --design-h: 1080px; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, +body, +#app { + width: 100%; + height: 100%; +} + +body { + background: var(--bg); + color: var(--text); + font-family: + 'Noto Sans SC', 'Source Han Sans SC', 'Microsoft YaHei', 'PingFang SC', system-ui, sans-serif; + -webkit-font-smoothing: antialiased; +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + transition-duration: 0.01ms !important; + } +} diff --git a/web/src/shared/types.ts b/web/src/shared/types.ts new file mode 100644 index 0000000..fa2f442 --- /dev/null +++ b/web/src/shared/types.ts @@ -0,0 +1,691 @@ +/** + * 后端 API JSON 的领域类型。 + * + * 统一 camelCase:服务端负责把 SQLite 的 snake_case 列名映射成 camelCase, + * 前端不再维护第二套命名,避免两处各改一半。 + * 布尔字段对应库里的 INTEGER 0/1,服务端序列化为 JSON 布尔值。 + */ + +export type Gender = '男' | '女' + +export interface Team { + id: number + code: string + name: string + shortName: string | null + logoPath: string | null + color: string | null + sortOrder: number + active: boolean +} + +export type TeamInput = Omit + +export interface Athlete { + id: number + name: string + gender: Gender | null + teamId: number + employeeNo: string | null + active: boolean +} + +export type AthleteInput = Omit + +export interface Sport { + id: number + code: string + name: string + shortName: string | null + defaultProfileId: number | null + sortOrder: number + active: boolean +} + +export type SportInput = Omit + +export type EventType = 'individual' | 'team' | 'match' +export type ResultMode = 'auto' | 'manual' +export type ResultType = 'time' | 'distance' | 'count' | 'score' | 'none' +export type CompareMode = 'asc' | 'desc' | 'none' +export type EventStatus = 'pending' | 'ongoing' | 'finished' | 'published' + +export interface EventCategory { + id: number + eventId: number + name: string + multiplier: number + gender: Gender | null + ageMin: number | null + ageMax: number | null + sortOrder: number +} + +export type EventCategoryInput = Omit + +export type ChannelScope = 'athlete' | 'team' +export type ChannelBasis = 'result' | 'manual' | 'aggregate' + +export interface ScoreChannel { + id: number + eventId: number + scope: ChannelScope + basis: ChannelBasis + profileId: number + sortOrder: number +} + +export type ScoreChannelInput = Omit + +export interface Event { + id: number + sportId: number + code: string + name: string + eventType: EventType + resultMode: ResultMode + resultType: ResultType + compare: CompareMode + recordEligible: boolean + /** false 表示只排名不计分(events.scored = 0)。 */ + scored: boolean + multiplier: number + status: EventStatus + scheduledAt: string | null + venue: string | null + sortOrder: number + remark: string | null + /** 详情接口才带出,列表接口可能省略。 */ + categories?: EventCategory[] + channels?: ScoreChannel[] +} + +export type EventInput = Omit + +export type TieMode = 'skip' | 'average' | 'higher' +export type RecordBonusMode = 'max' | 'sum' + +export interface ScoringProfileRule { + rank: number + points: number +} + +export interface ScoringProfile { + id: number + code: string + name: string + tieMode: TieMode + recordBonusMode: RecordBonusMode + /** 录取名次;文档中由规则包定义,接口可能省略。 */ + awardRanks?: number + lastPlaceZero?: boolean + rules: ScoringProfileRule[] +} + +export type ScoringProfileInput = Omit + +export type EntryStatus = 'registered' | 'scratched' | 'dq' | 'dns' + +export interface Entry { + id: number + eventId: number + categoryId: number + teamId: number + athleteId: number | null + /** 团体/接力成员;库里是 JSON 数组字符串,服务端解析成数组。 */ + memberNames: string[] | null + bib: string | null + seed: string | null + status: EntryStatus + remark: string | null + /** 以下为联表展示字段,接口按需带出。 */ + athleteName?: string | null + teamCode?: string + teamName?: string + shortName?: string | null + categoryName?: string +} + +export interface EntryWithResult extends Entry { + result: Result | null +} + +export interface EntryImportResult { + created: number + updated: number + skipped: number + errors: string[] +} + +export type ResultStatus = 'ok' | 'dq' | 'dns' | 'dnf' | 'withdrawn' +export type ResultState = 'draft' | 'published' + +export interface Result { + id: number + eventId: number + entryId: number + rank: number | null + /** 展示用文本,如「1:06.33」「6.12米」。 */ + resultText: string + /** 比较用数值(秒/米/分),排序与破纪录判断以它为准。 */ + resultValue: number | null + unit: string + isTie: boolean + pointsOverride: number | null + status: ResultStatus + state: ResultState + recordedBy: number | null + recordedAt: string | null + verifiedBy: number | null + verifiedAt: string | null + note: string | null + /** 服务端按计分通道算出的得分,只读;录入页用它做实时预览。 */ + points?: number | null +} + +export interface DraftResultInput { + entryId: number + rank?: number | null + resultText?: string + resultValue?: number | null + unit?: string + isTie?: boolean + status?: ResultStatus + pointsOverride?: number | null + note?: string | null +} + +export interface SaveDraftPayload { + eventId: number + /** 缺省表示整个小项一次保存。 */ + categoryId?: number + results: DraftResultInput[] + requestId?: string +} + +export interface PublishPayload { + eventId: number + categoryId?: number + requestId?: string +} + +export interface VoidResultPayload { + reason: string + requestId?: string +} + +export interface ResultQuery extends PageQuery { + eventId?: number + state?: ResultState +} + +/** 总榜行的公共部分:单位身份信息。 */ +export interface TeamRef { + teamId: number + teamCode: string + teamName: string + shortName: string | null + color: string | null + logoPath: string | null +} + +export interface MedalRow extends TeamRef { + rank: number + gold: number + silver: number + bronze: number + medalTotal: number +} + +/** 总积分榜行;接口约定「含奖牌数」,所以直接扩展 MedalRow。 */ +export interface StandingRow extends MedalRow { + totalPoints: number + /** 相对上一次快照的名次变化:正数上升、负数下降,null 表示无对比基准。 */ + rankChange: number | null +} + +export interface AthleteStanding { + rank: number + athleteId: number + athleteName: string + gender: Gender | null + teamId: number + teamCode: string + teamName: string + shortName: string | null + totalPoints: number + gold: number + silver: number + bronze: number +} + +export type LedgerSourceType = 'result' | 'record_bonus' | 'adjustment' + +/** 单位积分明细的一行,用于「可解释每一分」。 */ +export interface BreakdownItem { + ledgerId: number + sourceType: LedgerSourceType + eventId: number | null + eventCode: string | null + eventName: string | null + categoryName: string | null + athleteId: number | null + athleteName: string | null + rank: number | null + resultText: string | null + points: number + createdAt: string +} + +export interface TeamBreakdown { + teamId: number + teamCode: string + teamName: string + totalPoints: number + items: BreakdownItem[] +} + +export type AdjustmentCategory = 'penalty' | 'bonus' | 'correction' + +export interface PointAdjustment { + id: number + teamId: number + delta: number + category: AdjustmentCategory + reason: string + eventId: number | null + voided: boolean + createdBy: number | null + createdAt: string +} + +export interface AdjustmentInput { + teamId: number + delta: number + category: AdjustmentCategory + reason: string + eventId?: number | null +} + +export type RecordLevel = 'meet' | 'city' | 'province' | 'national' +export type RecordSource = 'history' | 'current' + +/** 命名避开 TS 内置的 Record,同时保留库里 records 表的语义。 */ +export interface RecordEntry { + id: number + eventId: number + level: RecordLevel + resultValue: number + resultText: string + holderName: string | null + holderTeamId: number | null + achievedAt: string | null + isCurrent: boolean + source: RecordSource +} + +export type RecordBreakState = 'pending' | 'approved' | 'rejected' + +export interface RecordBreak { + id: number + eventId: number + entryId: number + level: RecordLevel + resultValue: number + resultText: string + bonusPoints: number + state: RecordBreakState + approvedBy: number | null + approvedAt: string | null +} + +export interface RecordBreakInput { + eventId: number + entryId: number + level: RecordLevel + resultValue: number + resultText: string +} + +/** + * 赛事配置。服务端以 `a.b` 形式存在 settings 表里(如 `display.rotate_seconds`), + * 接口按前缀聚合成嵌套对象,前端不必再解析点号键。 + */ +export interface EventSettings { + event: { + name: string + subtitle: string | null + host: string | null + logoPath: string | null + primaryColor: string | null + } + display: { + background: string | null + logo: string | null + title: string + subtitle: string | null + primaryColor: string | null + rotateSeconds: number + } + standings: { + individualToTeamWeight: number + /** 排名模式;文档未枚举取值,用 string 保留后端扩展空间。 */ + rankingMode: string + showIndividualBoard: boolean + } + defaultProfileId: number | null + requireApproval: boolean +} + +/** 角色取值文档未枚举,用 string 避免前后端枚举不同步时误判。 */ +export interface CurrentUser { + id: number + username: string + displayName: string + role: string + lastLoginAt: string | null +} + +export interface AuditLog { + id: number + userId: number | null + action: string + entityType: string + entityId: number | null + /** 库里是 JSON 文本,前端需要时自行解析。 */ + beforeJson: string | null + afterJson: string | null + ip: string | null + requestId: string | null + createdAt: string +} + +export interface BackupResult { + path: string + createdAt: string +} + +export interface HealthInfo { + version: string +} + +// --------------------------------------------------------------------------- +// 规则包(见 02-积分与排名规则 第 7 节) +// --------------------------------------------------------------------------- + +export type TieBreakKey = 'points' | 'gold' | 'silver' | 'bronze' | 'teamCode' + +export interface RulePackMeta { + code: string + name: string + version: string + updatedAt: string + author: string | null + note: string | null +} + +export interface RulePackStandings { + individualToTeamWeight: number + tieBreak: TieBreakKey[] + medalCountsTeamEvents: boolean + showIndividualBoard: boolean + showIndividualMedals: boolean +} + +export interface RuleProfile { + code: string + name: string + awardRanks: number + lastPlaceZero: boolean + tieMode: TieMode + rules: ScoringProfileRule[] +} + +export interface RecordBonusLevel { + code: string + name: string + points: number +} + +export interface RulePackRecordBonus { + enabled: boolean + mode: RecordBonusMode + levels: RecordBonusLevel[] +} + +export interface RulePackManualAdjustment { + enabled: boolean + reasonRequired: boolean +} + +/** 规则包里的通道用 profile code 引用模板,而不是数据库 id,便于跨赛事复用。 */ +export interface RuleChannel { + scope: ChannelScope + basis: ChannelBasis + profile: string +} + +export interface ChannelTemplateMatch { + eventType?: EventType + eventCode?: string + sportCode?: string +} + +export interface ChannelTemplate { + match: ChannelTemplateMatch + channels: RuleChannel[] +} + +export interface EventOverride { + eventCode: string + name?: string + scored?: boolean + channels?: RuleChannel[] +} + +export interface RulePack { + format: 'scoreboard-rules' + formatVersion: number + meta: RulePackMeta + standings: RulePackStandings + profiles: RuleProfile[] + recordBonus: RulePackRecordBonus + manualAdjustment: RulePackManualAdjustment + channelTemplates: ChannelTemplate[] + eventOverrides: EventOverride[] +} + +export type RulePackDiffKind = 'profile' | 'channelTemplate' | 'eventOverride' | 'standings' +export type RulePackDiffChange = 'added' | 'removed' | 'modified' | 'unchanged' + +export interface RulePackDiffEntry { + kind: RulePackDiffKind + target: string + change: RulePackDiffChange + detail: string +} + +/** 导入必须先预览再落地(02 第 7.3 节),所以预览结果是独立类型。 */ +export interface RulePackImportPreview { + formatVersion: number + /** false 表示 formatVersion 不兼容,此时不允许落地。 */ + compatible: boolean + diffs: RulePackDiffEntry[] + affectedEventCount: number +} + +export interface RulePackImportResult extends RulePackImportPreview { + applied: boolean + /** 导入后服务端全量重算的积分流水条数,用于确认重算确实发生。 */ + recalculatedLedgerRows: number +} + +// --------------------------------------------------------------------------- +// REST 统一响应 +// --------------------------------------------------------------------------- + +export type ApiErrorCode = + | 'AUTH_REQUIRED' + | 'AUTH_FAILED' + | 'FORBIDDEN' + | 'VALIDATION_FAILED' + | 'CONFLICT' + | 'STATE_INVALID' + | 'RATE_LIMITED' + | 'INTERNAL' + // 以下三个由客户端产生,服务端不会返回,但前端要能统一处理 + | 'NETWORK' + | 'TIMEOUT' + | 'PARSE_FAILED' + +export interface ApiErrorBody { + code: ApiErrorCode + message: string + detail?: unknown +} + +export type ApiResponse = { ok: true; data: T } | { ok: false; error: ApiErrorBody } + +export interface PageQuery { + page?: number + size?: number +} + +export interface Page { + items: T[] + total: number + page: number + size: number +} + +// --------------------------------------------------------------------------- +// 实时协议(见 05-接口与实时协议 第 1–5 节) +// --------------------------------------------------------------------------- + +export type ClientRole = 'display' | 'admin' + +export interface HelloMessage { + role: ClientRole + /** 上一次已应用的版本,服务端据此决定发增量还是整包快照。 */ + lastVersion?: number + displayKey?: string +} + +export interface AckMessage { + version: number +} + +/** resync 无载荷,用空对象保持与服务端事件格式一致。 */ +export type ResyncMessage = Record + +export type ChangeAction = 'upsert' | 'void' | 'replace' + +export interface Change { + entity: string + id?: number + action: ChangeAction + /** 载荷形状随 entity 变化,调用方按 entity 收窄后再使用。 */ + data: unknown +} + +export interface EventResultRow { + entryId: number + rank: number | null + teamId: number + teamCode: string + teamName: string + shortName: string | null + athleteId: number | null + athleteName: string | null + memberNames: string[] | null + resultText: string + resultValue: number | null + unit: string + isTie: boolean + status: ResultStatus +} + +export interface EventResultGroup { + eventId: number + eventCode: string + eventName: string + categoryId: number + categoryName: string + publishedAt: string | null + rows: EventResultRow[] +} + +export interface SnapshotMessage { + version: number + settings: EventSettings + teams: Team[] + events: Event[] + standings: StandingRow[] + records: RecordEntry[] + latestResults: EventResultGroup[] +} + +export interface CatchupMessage { + fromVersion: number + version: number + changes: Change[] +} + +export interface ChangeMessage { + version: number + changes: Change[] +} + +export interface HeartbeatMessage { + version: number + serverTime: string +} + +export type DisplayAction = + | 'goto' + | 'lock' + | 'unlock' + | 'reload' + | 'announce' + | 'show-detail' + | 'hide-detail' + +export interface DisplayCommandPayload { + page?: string + text?: string + /** 公告滚动时长。 */ + seconds?: number + autoCloseSeconds?: number + teamId?: number + title?: string + /** 明细由管理端算好后随命令下发,大屏只负责显示(所见即所得)。 */ + detail?: TeamBreakdown +} + +export interface DisplayCommand { + action: DisplayAction + payload: DisplayCommandPayload +} + +export interface ServerMessageData { + snapshot: SnapshotMessage + catchup: CatchupMessage + change: ChangeMessage + heartbeat: HeartbeatMessage + 'display:command': DisplayCommand +} + +export interface ClientMessageData { + hello: HelloMessage + ack: AckMessage + resync: ResyncMessage +} + +export type ServerEvent = keyof ServerMessageData +export type ClientEvent = keyof ClientMessageData + +export type ServerMessageOf = { event: K; data: ServerMessageData[K] } +export type ClientMessageOf = { event: K; data: ClientMessageData[K] } + +export type ServerMessage = { [K in ServerEvent]: ServerMessageOf }[ServerEvent] +export type ClientMessage = { [K in ClientEvent]: ClientMessageOf }[ClientEvent] diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..030b87c --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noEmit": true, + "isolatedModules": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "jsx": "preserve", + "types": ["vite/client", "node"], + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue", "vite.config.ts"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..898c7db --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,24 @@ +import { fileURLToPath, URL } from 'node:url' +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +const resolve = (p: string) => fileURLToPath(new URL(p, import.meta.url)) + +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { '@': resolve('./src') }, + }, + build: { + // 构建产物直接输出到 Go 的 embed 目录 + outDir: resolve('../server/internal/webui/assets'), + emptyOutDir: true, + rollupOptions: { + input: { + index: resolve('./index.html'), + display: resolve('./display.html'), + admin: resolve('./admin.html'), + }, + }, + }, +})