feat: M0-M2 骨架落地(服务端/前端/宿主/数据库/积分引擎)

- server: Go 单二进制骨架,embed 内嵌前端,配置/迁移/HTTP 路由
- store: system.db + event.db 两套迁移,业务表 + 种子数据
- domain: 积分引擎(并列三口径/计分通道/流水/排名/破纪录)+ 68 个测试
- web: Vite + Vue 三入口骨架 + shared 层(类型/API/WebSocket 客户端)
- console: Electron 宿主骨架(选屏/防熄屏/进程守护/托盘)
This commit is contained in:
AskaEth committed 2026-09-09 18:36:40 +08:00
1 parent a61d793d79
commit 7d5d94f815
76 files changed
+10131

No files matched your search

+5
View File
@@ -29,6 +29,11 @@ logs/
# 前端 # 前端
web/node_modules/ web/node_modules/
web/dist/ web/dist/
console/node_modules/
# 前端构建产物(构建时生成,随 Go embed 内嵌)
server/internal/webui/assets/*
!server/internal/webui/assets/.gitkeep
# 编辑器 / 系统 # 编辑器 / 系统
.vscode/* .vscode/*
+10
View File
@@ -0,0 +1,10 @@
# 服务端启动配置
server:
host: 0.0.0.0
port: 8080
# 运行时数据目录(数据库 / 备份 / 上传资源)
dataDir: data
log:
level: info # debug | info | warn | error
+126
View File
@@ -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=<id>&instance=<host>-<id>`,页序按 `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 安装。
+32
View File
@@ -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
}
}
+95
View File
@@ -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 }
+276
View File
@@ -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 }
+299
View File
@@ -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()
})
}
+79
View File
@@ -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 }
+264
View File
@@ -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 }
+16
View File
@@ -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 }
+62
View File
@@ -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"
}
}
}
+130
View File
@@ -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()
+103
View File
@@ -0,0 +1,103 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'none'"
/>
<title>Scoreboard Console · 启动管理</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<header class="topbar">
<div class="brand">
<span class="brand-mark"></span>
<div>
<h1>Scoreboard Console</h1>
<p class="brand-sub">赛事积分榜 · 桌面宿主</p>
</div>
</div>
<div class="topbar-right">
<span id="app-version" class="muted"></span>
</div>
</header>
<main>
<section class="card">
<div class="card-head">
<h2>服务端状态</h2>
<div class="actions">
<button id="btn-start" class="btn primary" type="button">启动服务</button>
<button id="btn-stop" class="btn" type="button">停止服务</button>
<button id="btn-restart" class="btn" type="button">重启服务</button>
</div>
</div>
<div class="status-row">
<span id="state-dot" class="dot"></span>
<strong id="state-label">已停止</strong>
<span id="state-detail" class="muted"></span>
</div>
<dl class="kv">
<div>
<dt>服务地址</dt>
<dd id="k-url">—</dd>
</div>
<div>
<dt>进程 PID</dt>
<dd id="k-pid">—</dd>
</div>
<div>
<dt>自动重启</dt>
<dd id="k-restarts">—</dd>
</div>
<div>
<dt>防熄屏</dt>
<dd id="k-power">—</dd>
</div>
<div class="wide">
<dt>服务端程序</dt>
<dd id="k-exe" class="mono">—</dd>
</div>
<div class="wide">
<dt>配置文件</dt>
<dd id="k-config" class="mono">—</dd>
</div>
</dl>
<p id="config-warning" class="warning" hidden></p>
</section>
<section class="card">
<div class="card-head">
<h2>大屏窗口</h2>
<div class="actions">
<button id="btn-relayout" class="btn" type="button">重新排布</button>
<button id="btn-open-admin" class="btn" type="button">打开服务端管理面板</button>
</div>
</div>
<p class="hint">
单屏时窗口化、按 F11 切换全屏;多屏时除主屏外每块屏一个 kiosk 窗口,各锁一页。
</p>
<ul id="display-list" class="display-list">
<li class="muted">尚未打开大屏窗口</li>
</ul>
</section>
<section class="card log-card">
<div class="card-head">
<h2>运行日志</h2>
<div class="actions">
<label class="check"><input id="autoscroll" type="checkbox" checked /> 自动滚动</label>
<button id="btn-clear-log" class="btn" type="button">清空</button>
</div>
</div>
<pre id="log" class="log"></pre>
</section>
</main>
<script src="app.js"></script>
</body>
</html>
+23
View File
@@ -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)
})
+270
View File
@@ -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;
}
+20
View File
@@ -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
)
+54
View File
@@ -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=
+89
View File
@@ -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
}
}
+5
View File
@@ -0,0 +1,5 @@
// Package domain 承载积分、排名、纪录等纯业务逻辑。
//
// 本包不 import 任何 IO 包(数据库、HTTP、WebSocket),
// 输入输出都是普通结构体,以便完整单测,也方便将来替换存储层。
package domain
+16
View File
@@ -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: 未知的流水增量操作")
)
+200
View File
@@ -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}
}
+256
View File
@@ -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
}
+229
View File
@@ -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)
}
}
+457
View File
@@ -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)
}
}
+136
View File
@@ -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
}
+195
View File
@@ -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)
}
})
}
+536
View File
@@ -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
}
+601
View File
@@ -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})
})
}
+279
View File
@@ -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
}
+247
View File
@@ -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)
}
}
+513
View File
@@ -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
}
+168
View File
@@ -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)
}
}
}
+107
View File
@@ -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),
)
})
}
+105
View File
@@ -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()
}
+49
View File
@@ -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 }
+232
View File
@@ -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
}
+17
View File
@@ -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")
}
+74
View File
@@ -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)
}
}
+14
View File
@@ -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
@@ -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'))
);
@@ -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);
@@ -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)
);
@@ -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);
@@ -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);
+33
View File
@@ -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);
@@ -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);
@@ -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);
@@ -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);
@@ -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);
+45
View File
@@ -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');
+6
View File
@@ -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'))
);
+16
View File
@@ -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);
@@ -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);
@@ -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);
@@ -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');
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>管理面板</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/admin/main.ts"></script>
</body>
</html>
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>积分大屏</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/display/main.ts"></script>
</body>
</html>
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>赛事积分榜系统</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/index/main.ts"></script>
</body>
</html>
+23
View File
@@ -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"
}
}
+1084
View File
File diff suppressed because it is too large. Load diff
+65
View File
@@ -0,0 +1,65 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
const health = ref<string>('检测中…')
onMounted(async () => {
try {
const res = await fetch('/api/health')
const body = (await res.json()) as { ok: boolean; version: string }
health.value = body.ok ? `服务正常 · ${body.version}` : '服务异常'
} catch {
health.value = '无法连接服务端'
}
})
</script>
<template>
<main class="panel">
<header>
<h1>管理面板</h1>
<span class="status">{{ health }}</span>
</header>
<section class="card">
<h2>骨架已就绪</h2>
<p>MIUIx 风格与业务页面将在 M4 实现。</p>
</section>
</main>
</template>
<style scoped>
.panel {
padding: 32px;
display: flex;
flex-direction: column;
gap: 24px;
}
header {
display: flex;
align-items: baseline;
gap: 20px;
}
h1 {
font-size: 24px;
}
.status {
font-size: 13px;
color: var(--text-dim);
}
.card {
background: var(--panel);
border: 2px solid var(--panel-border);
border-radius: 16px;
padding: 24px 28px;
}
h2 {
font-size: 18px;
margin-bottom: 8px;
color: var(--cyan);
}
p {
font-size: 14px;
color: var(--text-dim);
}
</style>
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import App from './App.vue'
import '@/shared/styles/base.css'
createApp(App).mount('#app')
+180
View File
@@ -0,0 +1,180 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
// 占位数据:M1/M3 接入 /api 后替换为实时数据
const teams = ref([
{ rank: 1, name: '第一分公司', points: 186 },
{ rank: 2, name: '机关本部', points: 172 },
{ rank: 3, name: '第二分公司', points: 158 },
{ rank: 4, name: '第三分公司', points: 141 },
{ rank: 5, name: '第四分公司', points: 127 },
{ rank: 6, name: '第五分公司', points: 103 },
{ rank: 7, name: '第六分公司', points: 95 },
])
const scale = ref(1)
const stage = ref<HTMLElement | null>(null)
function fit() {
const s = Math.min(window.innerWidth / 1920, window.innerHeight / 1080)
scale.value = s
if (stage.value) {
stage.value.style.left = `${(window.innerWidth - 1920 * s) / 2}px`
stage.value.style.top = `${(window.innerHeight - 1080 * s) / 2}px`
}
}
onMounted(() => {
fit()
window.addEventListener('resize', fit)
})
onUnmounted(() => window.removeEventListener('resize', fit))
</script>
<template>
<div ref="stage" class="stage" :style="{ transform: `scale(${scale})` }">
<header class="head">
<div class="logo">会标</div>
<h1>赛事积分榜</h1>
<div class="spacer" />
</header>
<section class="board">
<h2>总积分榜</h2>
<table>
<thead>
<tr>
<th class="rk">名次</th>
<th>单位</th>
<th class="pt">总分</th>
</tr>
</thead>
<tbody>
<tr v-for="t in teams" :key="t.name">
<td class="rk">{{ t.rank }}</td>
<td class="nm">{{ t.name }}</td>
<td class="pt">{{ t.points }}</td>
</tr>
</tbody>
</table>
</section>
<footer class="ticker">底栏信息条(轮播占位)</footer>
</div>
</template>
<style scoped>
.stage {
position: absolute;
top: 0;
left: 0;
width: 1920px;
height: 1080px;
transform-origin: top left;
padding: 40px 48px;
display: flex;
flex-direction: column;
gap: 24px;
background:
radial-gradient(ellipse at 20% 0%, rgba(79, 216, 255, 0.12), transparent 55%),
radial-gradient(ellipse at 85% 100%, rgba(255, 215, 94, 0.08), transparent 50%), var(--bg);
cursor: none;
}
.head {
display: flex;
align-items: center;
gap: 28px;
}
.logo {
width: 96px;
height: 96px;
border-radius: 50%;
border: 3px solid var(--gold);
display: grid;
place-items: center;
font-size: 22px;
color: var(--text-dim);
}
h1 {
font-size: 64px;
letter-spacing: 8px;
background: linear-gradient(180deg, #fff 30%, var(--gold) 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.spacer {
flex: 1;
}
.board {
flex: 1;
min-height: 0;
background: var(--panel);
border: 2px solid var(--panel-border);
border-radius: 16px;
padding: 20px 28px 24px;
display: flex;
flex-direction: column;
}
h2 {
font-size: 34px;
letter-spacing: 4px;
color: var(--cyan);
padding-bottom: 10px;
border-bottom: 2px solid rgba(79, 216, 255, 0.35);
margin-bottom: 8px;
}
table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
th,
td {
font-size: 30px;
padding: 12px 8px;
text-align: center;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
th {
font-size: 20px;
color: var(--text-dim);
font-weight: 400;
letter-spacing: 2px;
}
td.rk {
width: 140px;
font-weight: 800;
font-size: 34px;
}
tbody tr:nth-child(1) td.rk {
color: var(--gold);
}
tbody tr:nth-child(2) td.rk {
color: #d8e2ee;
}
tbody tr:nth-child(3) td.rk {
color: #e0995e;
}
td.nm {
text-align: left;
padding-left: 32px;
}
td.pt {
width: 220px;
color: var(--gold);
font-weight: 800;
font-size: 36px;
}
.ticker {
height: 88px;
border-radius: 16px;
background: var(--panel);
border: 2px solid var(--panel-border);
display: grid;
place-items: center;
font-size: 26px;
color: var(--text-dim);
letter-spacing: 2px;
}
</style>
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import App from './App.vue'
import '@/shared/styles/base.css'
createApp(App).mount('#app')
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>
export default component
}
+69
View File
@@ -0,0 +1,69 @@
<script setup lang="ts">
const entries = [
{ path: '/display', title: '实时积分大屏', desc: '只读、无人值守;成绩发布后 1 秒内更新' },
{ path: '/admin', title: '管理面板', desc: '录入、发布成绩,控制大屏' },
]
</script>
<template>
<main class="nav">
<h1>赛事积分榜系统</h1>
<div class="cards">
<a v-for="e in entries" :key="e.path" class="card" :href="e.path">
<span class="title">{{ e.title }}</span>
<span class="desc">{{ e.desc }}</span>
<code>{{ e.path }}</code>
</a>
</div>
</main>
</template>
<style scoped>
.nav {
min-height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 40px;
padding: 48px;
}
h1 {
font-size: 32px;
letter-spacing: 2px;
}
.cards {
display: flex;
gap: 24px;
flex-wrap: wrap;
justify-content: center;
}
.card {
width: 320px;
padding: 28px 32px;
border-radius: 16px;
background: var(--panel);
border: 2px solid var(--panel-border);
display: flex;
flex-direction: column;
gap: 10px;
transition: border-color 0.2s ease;
}
.card:hover {
border-color: var(--cyan);
}
.title {
font-size: 22px;
font-weight: 700;
color: var(--gold);
}
.desc {
font-size: 14px;
color: var(--text-dim);
line-height: 1.6;
}
code {
font-size: 13px;
color: var(--cyan);
}
</style>
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import App from './App.vue'
import '@/shared/styles/base.css'
createApp(App).mount('#app')
+579
View File
@@ -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<string, QueryValue | QueryValue[]>
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<T, Input> {
list(): Promise<T[]>
get(id: number): Promise<T>
create(input: Input): Promise<T>
update(id: number, input: Partial<Input>): Promise<T>
remove(id: number): Promise<void>
}
export interface AuthApi {
login(username: string, password: string): Promise<CurrentUser>
logout(): Promise<void>
me(): Promise<CurrentUser>
}
export interface SettingsApi {
get(): Promise<EventSettings>
update(patch: Partial<EventSettings>): Promise<EventSettings>
}
export interface EventsApi extends CrudApi<Event, EventInput> {
entries(eventId: number): Promise<EntryWithResult[]>
categories(eventId: number): Promise<EventCategory[]>
saveCategories(eventId: number, categories: EventCategoryInput[]): Promise<EventCategory[]>
channels(eventId: number): Promise<ScoreChannel[]>
saveChannels(eventId: number, channels: ScoreChannelInput[]): Promise<ScoreChannel[]>
}
export interface EntriesApi {
/** 报名表按文件导入,后端解析 xlsx。 */
importFile(file: File): Promise<EntryImportResult>
}
export interface ResultsApi {
list(query: ResultQuery): Promise<Page<Result>>
saveDraft(payload: SaveDraftPayload): Promise<Result[]>
publish(payload: PublishPayload): Promise<Result[]>
unpublish(payload: PublishPayload): Promise<Result[]>
void(id: number, payload: VoidResultPayload): Promise<Result>
}
export interface StandingsApi {
teams(): Promise<StandingRow[]>
athletes(): Promise<AthleteStanding[]>
breakdown(teamId: number): Promise<TeamBreakdown>
}
export interface AdjustmentsApi {
list(query?: PageQuery & { teamId?: number }): Promise<Page<PointAdjustment>>
create(input: AdjustmentInput): Promise<PointAdjustment>
void(id: number, reason: string): Promise<PointAdjustment>
}
export interface RecordsApi {
list(query?: { eventId?: number }): Promise<RecordEntry[]>
createBreak(input: RecordBreakInput): Promise<RecordBreak>
approveBreak(id: number): Promise<RecordBreak>
}
export interface DisplayApi {
command(command: DisplayCommand): Promise<void>
}
export interface ExportQuery {
eventId?: number
teamId?: number
}
export interface ExportApi {
/** 返回可直接放进 <a download> 的地址,省掉一次内存中的 Blob。 */
standingsUrl(query?: ExportQuery): string
breakdownUrl(query: ExportQuery): string
resultsUrl(query?: ExportQuery): string
standings(query?: ExportQuery): Promise<Blob>
breakdown(query: ExportQuery): Promise<Blob>
results(query?: ExportQuery): Promise<Blob>
save(blob: Blob, filename: string): void
}
export interface AuditApi {
list(query?: PageQuery & { entityType?: string; userId?: number }): Promise<Page<AuditLog>>
}
export interface RulesApi {
/** GET /api/rules/export,返回当前规则包。 */
get(): Promise<RulePack>
/** 先预览差异,再决定是否落地(02 第 7.3 节硬性要求)。 */
preview(pack: RulePack): Promise<RulePackImportPreview>
apply(pack: RulePack): Promise<RulePackImportResult>
}
export interface SystemApi {
backup(): Promise<BackupResult>
}
interface Http {
request<T>(path: string, options?: RequestOptions): Promise<T>
requestVoid(path: string, options?: RequestOptions): Promise<void>
requestJson(path: string, options?: RequestOptions): Promise<unknown>
url(path: string, query?: object): string
blob(path: string, options?: RequestOptions): Promise<Blob>
}
function createCrudApi<T, Input>(http: Http, base: string): CrudApi<T, Input> {
return {
list: () => http.request<T[]>(base),
get: (id) => http.request<T>(`${base}/${id}`),
create: (input) => http.request<T>(base, { method: 'POST', body: input }),
update: (id, input) => http.request<T>(`${base}/${id}`, { method: 'PUT', body: input }),
remove: (id) => http.requestVoid(`${base}/${id}`, { method: 'DELETE' }),
}
}
function createEventsApi(http: Http): EventsApi {
return {
...createCrudApi<Event, EventInput>(http, '/events'),
entries: (eventId) => http.request<EntryWithResult[]>(`/events/${eventId}/entries`),
categories: (eventId) => http.request<EventCategory[]>(`/events/${eventId}/categories`),
saveCategories: (eventId, categories) =>
http.request<EventCategory[]>(`/events/${eventId}/categories`, {
method: 'PUT',
body: { categories },
}),
channels: (eventId) => http.request<ScoreChannel[]>(`/events/${eventId}/channels`),
saveChannels: (eventId, channels) =>
http.request<ScoreChannel[]>(`/events/${eventId}/channels`, {
method: 'PUT',
body: { channels },
}),
}
}
function createResultsApi(http: Http): ResultsApi {
return {
list: (query) => http.request<Page<Result>>('/results', { query }),
saveDraft: (payload) => http.request<Result[]>('/results/draft', { method: 'PUT', body: payload }),
publish: (payload) => http.request<Result[]>('/results/publish', { method: 'POST', body: payload }),
unpublish: (payload) =>
http.request<Result[]>('/results/unpublish', { method: 'POST', body: payload }),
void: (id, payload) => http.request<Result>(`/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<RulePack>('/rules/export'),
preview: (pack) =>
http.request<RulePackImportPreview>('/rules/import', {
method: 'POST',
query: { preview: 1 },
body: pack,
}),
apply: (pack) =>
http.request<RulePackImportResult>('/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<Team, TeamInput>
readonly sports: CrudApi<Sport, SportInput>
readonly events: EventsApi
readonly scoringProfiles: CrudApi<ScoringProfile, ScoringProfileInput>
readonly athletes: CrudApi<Athlete, AthleteInput>
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<CurrentUser>('/auth/login', { method: 'POST', body: { username, password } }),
logout: () => http.requestVoid('/auth/logout', { method: 'POST' }),
me: () => http.request<CurrentUser>('/auth/me'),
}
this.settings = {
get: () => http.request<EventSettings>('/settings'),
update: (patch) => http.request<EventSettings>('/settings', { method: 'PUT', body: patch }),
}
this.teams = createCrudApi<Team, TeamInput>(http, '/teams')
this.sports = createCrudApi<Sport, SportInput>(http, '/sports')
this.events = createEventsApi(http)
this.scoringProfiles = createCrudApi<ScoringProfile, ScoringProfileInput>(http, '/scoring-profiles')
this.athletes = createCrudApi<Athlete, AthleteInput>(http, '/athletes')
this.entries = {
importFile: (file) => {
const form = new FormData()
form.append('file', file)
return http.request<EntryImportResult>('/entries/import', { method: 'POST', body: form })
},
}
this.results = createResultsApi(http)
this.standings = {
teams: () => http.request<StandingRow[]>('/standings'),
athletes: () => http.request<AthleteStanding[]>('/standings/athletes'),
breakdown: (teamId) => http.request<TeamBreakdown>(`/standings/teams/${teamId}/breakdown`),
}
this.adjustments = {
list: (query) => http.request<Page<PointAdjustment>>('/adjustments', { query }),
create: (input) => http.request<PointAdjustment>('/adjustments', { method: 'POST', body: input }),
void: (id, reason) =>
http.request<PointAdjustment>(`/adjustments/${id}/void`, {
method: 'POST',
body: { reason },
}),
}
this.records = {
list: (query) => http.request<RecordEntry[]>('/records', { query }),
createBreak: (input) => http.request<RecordBreak>('/record-breaks', { method: 'POST', body: input }),
approveBreak: (id) =>
http.request<RecordBreak>(`/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<Page<AuditLog>>('/audit-logs', { query }),
}
this.rules = createRulesApi(http)
this.system = {
backup: () => http.request<BackupResult>('/backup', { method: 'POST' }),
}
}
/** 健康检查不走 {ok,data} 包装,直接返回 `{ok,version}`(05 第 7 节)。 */
async health(): Promise<HealthInfo> {
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<T>(path: string, options: RequestOptions = {}): Promise<T> {
const body = await this.requestJson(path, options)
return this.unwrap<T>(body)
}
private async requestVoid(path: string, options: RequestOptions = {}): Promise<void> {
const body = await this.requestJson(path, options)
// 204 或空响应视为成功;有响应体时仍校验 {ok:true}。
if (body === null || body === undefined) return
this.unwrap<unknown>(body)
}
private async requestJson(path: string, options: RequestOptions = {}): Promise<unknown> {
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<Blob> {
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<Response> {
const method: HttpMethod = options.method ?? 'GET'
const payload = this.prepareBody(method, options.body)
const headers: Record<string, string> = { 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<T>(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<unknown> {
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)
}
+111
View File
@@ -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
}
+27
View File
@@ -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'
+16
View File
@@ -0,0 +1,16 @@
/**
* shared 层内部工具,不对外导出。
*
* HTTP / WebSocket 收到的一切都先当 unknown,再用这些收窄函数校验;
* 直接断言会把服务端字段改名这类问题推迟到运行期某个深处才炸。
*/
export function isRecord(value: unknown): value is Record<string, unknown> {
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
}
+371
View File
@@ -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)://<host>/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<ReconnectOptions>
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<typeof setTimeout> | null = null
private watchdogTimer: ReturnType<typeof setTimeout> | 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<unknown>) => {
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<K extends keyof ClientMessageData>(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))
}
+43
View File
@@ -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;
}
}
+691
View File
@@ -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<Team, 'id'>
export interface Athlete {
id: number
name: string
gender: Gender | null
teamId: number
employeeNo: string | null
active: boolean
}
export type AthleteInput = Omit<Athlete, 'id'>
export interface Sport {
id: number
code: string
name: string
shortName: string | null
defaultProfileId: number | null
sortOrder: number
active: boolean
}
export type SportInput = Omit<Sport, 'id'>
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<EventCategory, 'id' | 'eventId'>
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<ScoreChannel, 'id' | 'eventId'>
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<Event, 'id' | 'categories' | 'channels'>
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<ScoringProfile, 'id'>
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<K,V>,同时保留库里 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<T> = { ok: true; data: T } | { ok: false; error: ApiErrorBody }
export interface PageQuery {
page?: number
size?: number
}
export interface Page<T> {
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<string, never>
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<K extends ServerEvent> = { event: K; data: ServerMessageData[K] }
export type ClientMessageOf<K extends ClientEvent> = { event: K; data: ClientMessageData[K] }
export type ServerMessage = { [K in ServerEvent]: ServerMessageOf<K> }[ServerEvent]
export type ClientMessage = { [K in ClientEvent]: ClientMessageOf<K> }[ClientEvent]
+23
View File
@@ -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"]
}
+24
View File
@@ -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'),
},
},
},
})