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:
1 parent
a61d793d79
commit
7d5d94f815
76 files changed
+10131
No files matched your search
@@ -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
@@ -0,0 +1,10 @@
|
|||||||
|
# 服务端启动配置
|
||||||
|
server:
|
||||||
|
host: 0.0.0.0
|
||||||
|
port: 8080
|
||||||
|
|
||||||
|
# 运行时数据目录(数据库 / 备份 / 上传资源)
|
||||||
|
dataDir: data
|
||||||
|
|
||||||
|
log:
|
||||||
|
level: info # debug | info | warn | error
|
||||||
@@ -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 安装。
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -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>
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// Package domain 承载积分、排名、纪录等纯业务逻辑。
|
||||||
|
//
|
||||||
|
// 本包不 import 任何 IO 包(数据库、HTTP、WebSocket),
|
||||||
|
// 输入输出都是普通结构体,以便完整单测,也方便将来替换存储层。
|
||||||
|
package domain
|
||||||
@@ -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: 未知的流水增量操作")
|
||||||
|
)
|
||||||
@@ -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}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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');
|
||||||
@@ -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,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');
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+1084
File diff suppressed because it is too large.
Load diff
@@ -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>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
import '@/shared/styles/base.css'
|
||||||
|
|
||||||
|
createApp(App).mount('#app')
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
import '@/shared/styles/base.css'
|
||||||
|
|
||||||
|
createApp(App).mount('#app')
|
||||||
Vendored
+7
@@ -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
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
import '@/shared/styles/base.css'
|
||||||
|
|
||||||
|
createApp(App).mount('#app')
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in new issue
Block a user