feat: 补齐第一批已知缺口(关闭握手 / 定时备份 / Origin 白名单 / DTO 收敛)
- httpapi:新增 POST /api/shutdown(admin 会话或 X-Shutdown-Token 常量时间比对) 与 GET /api/rules/switches(recorder 可读,只回布尔开关);settings/standings 的 重复 DTO 收敛到 internal/dto;standings.showIndividualMedals 贯通到设置读写与前端 - export:抽出 export.Service,新增自动备份定时任务(backup.intervalMinutes/keep), 自动、手动、发布前三类备份共用同一把互斥锁与滚动清理 - realtime:/ws 去掉 InsecureSkipVerify,改用 OriginPatterns(同源与无 Origin 放行, 跨站需命中 server.allowedOrigins) - console:停止服务改走 POST /api/shutdown 优雅关闭(5 秒宽限后回退强杀), 服务管理器新增「已连接的大屏实例」卡片 - deploy:build.bat 改用 for /f 读内部署名——set /p 在 chcp 65001 下返回空, 导致最近这次构建的 exe 里根本没注入署名 - docs:备份策略、关闭超时、Origin 配置的说明与实现对齐
This commit is contained in:
1 parent
4d56c73ae6
commit
2c9ff65fc4
42 files changed
+1271
-275
No files matched your search
@@ -2,9 +2,16 @@
|
|||||||
server:
|
server:
|
||||||
host: 0.0.0.0
|
host: 0.0.0.0
|
||||||
port: 19540
|
port: 19540
|
||||||
|
# WebSocket 跨站 Origin 白名单(可选,path.Match 模式);同源与不带 Origin 的客户端始终放行
|
||||||
|
allowedOrigins: []
|
||||||
|
|
||||||
# 运行时数据目录(数据库 / 备份 / 上传资源)
|
# 运行时数据目录(数据库 / 备份 / 上传资源)
|
||||||
dataDir: data
|
dataDir: data
|
||||||
|
|
||||||
|
# 自动备份:按间隔把 system.db 与赛事库快照到 dataDir/backups
|
||||||
|
backup:
|
||||||
|
intervalMinutes: 5 # 备份间隔(分钟);<=0 关闭自动备份
|
||||||
|
keep: 20 # 每个库滚动保留的份数,超出的旧备份自动删除
|
||||||
|
|
||||||
log:
|
log:
|
||||||
level: info # debug | info | warn | error
|
level: info # debug | info | warn | error
|
||||||
+8
-5
@@ -1,12 +1,13 @@
|
|||||||
# Scoreboard Console
|
# Scoreboard Console
|
||||||
|
|
||||||
赛事积分榜系统的桌面宿主(Electron)。它只做五件事:
|
赛事积分榜系统的桌面宿主(Electron)。它只做六件事:
|
||||||
|
|
||||||
1. 拉起并守护同目录(或配置指定)的 `scoreboard.exe`,把它的 stdout/stderr 接管到管理窗口与日志文件;
|
1. 拉起并守护同目录(或配置指定)的 `scoreboard.exe`,把它的 stdout/stderr 接管到管理窗口与日志文件;
|
||||||
2. 用 `screen.getAllDisplays()` 枚举显示器,把大屏页面投到扩展屏;
|
2. 用 `screen.getAllDisplays()` 枚举显示器,把大屏页面投到扩展屏;
|
||||||
3. `powerSaveBlocker.start('prevent-display-sleep')` 防熄屏;
|
3. `powerSaveBlocker.start('prevent-display-sleep')` 防熄屏;
|
||||||
4. 提供启动/停止/状态/日志的管理 GUI 和托盘菜单;
|
4. 提供启动/停止/状态/日志的管理 GUI 和托盘菜单;
|
||||||
5. 打包后随系统启动,现场断电重启后无需人工干预。
|
5. 打包后随系统启动,现场断电重启后无需人工干预;
|
||||||
|
6. 在服务管理窗口展示服务端在线的大屏实例(`GET /api/display/instances`)。
|
||||||
|
|
||||||
规范来源:`devdocs/06-大屏端设计.md` 第 6 节、`devdocs/10-部署与现场方案.md` 第 4 节。
|
规范来源:`devdocs/06-大屏端设计.md` 第 6 节、`devdocs/10-部署与现场方案.md` 第 4 节。
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ console/
|
|||||||
│ ├── index.js # 应用入口:窗口 / 托盘 / IPC / 生命周期
|
│ ├── index.js # 应用入口:窗口 / 托盘 / IPC / 生命周期
|
||||||
│ ├── config.js # 配置加载与合并、exe 路径解析
|
│ ├── config.js # 配置加载与合并、exe 路径解析
|
||||||
│ ├── service.js # 服务端子进程守护:spawn / 日志转发 / 健康检查 / 自动重启
|
│ ├── service.js # 服务端子进程守护:spawn / 日志转发 / 健康检查 / 自动重启
|
||||||
|
│ ├── instances.js # 已连接大屏实例看板:复用管理面板登录态读 /api/display/instances
|
||||||
│ ├── account.js # 管理员账号重置:参数校验 + 调 scoreboard.exe passwd
|
│ ├── account.js # 管理员账号重置:参数校验 + 调 scoreboard.exe passwd
|
||||||
│ ├── displays.js # 大屏窗口管理:选屏、kiosk、排布、24h 重载
|
│ ├── displays.js # 大屏窗口管理:选屏、kiosk、排布、24h 重载
|
||||||
│ ├── log-bus.js # 日志环形缓冲 + 落文件 + 广播
|
│ ├── log-bus.js # 日志环形缓冲 + 落文件 + 广播
|
||||||
@@ -77,7 +79,8 @@ scoreboard-windows-amd64/
|
|||||||
| 防熄屏 | Console 启动即生效,退出时释放;前台后台都算 |
|
| 防熄屏 | Console 启动即生效,退出时释放;前台后台都算 |
|
||||||
| 服务端就绪 | 首次 `/api/health` 通过后开大屏窗口;等待超过 `display.waitForServerMs` 也先开,避免现场只见桌面 |
|
| 服务端就绪 | 首次 `/api/health` 通过后开大屏窗口;等待超过 `display.waitForServerMs` 也先开,避免现场只见桌面 |
|
||||||
| 服务端崩溃 | 按 `restartDelayMs` 递增退避自动重启(`maxRestarts=0` 为无限);持续健康 `healthyResetMs` 后崩溃计数清零;恢复健康后自动重载大屏 |
|
| 服务端崩溃 | 按 `restartDelayMs` 递增退避自动重启(`maxRestarts=0` 为无限);持续健康 `healthyResetMs` 后崩溃计数清零;恢复健康后自动重载大屏 |
|
||||||
| 停止服务 | 先 `POST server.shutdownPath` 请求服务端优雅关闭,`shutdownTimeoutMs` 内未退出再强杀;接口不可用时回退进程信号 |
|
| 停止服务 | 先 `POST server.shutdownPath` 并带 `X-Shutdown-Token`(启动时用 `crypto.randomBytes(32)` 生成,经环境变量 `SCOREBOARD_SHUTDOWN_TOKEN` 只交给该子进程)请求服务端优雅关闭;受理后最多等 `shutdownTimeoutMs`,超时或接口不可用(404/连接失败)再强杀 |
|
||||||
|
| 已连接大屏实例 | 管理窗口打开时每 5 秒读一次服务端 `GET /api/display/instances`(复用管理面板的 `scoreboard_session` Cookie,需记录员及以上),显示实例数、页面名与在线状态;窗口隐藏即停表,未登录/服务未启动时显示占位文案 |
|
||||||
| 关管理窗口 | 只隐藏到托盘,服务端继续运行;退出必须走托盘「退出」 |
|
| 关管理窗口 | 只隐藏到托盘,服务端继续运行;退出必须走托盘「退出」 |
|
||||||
| 开机自启 | 打包后在 Windows/macOS 上注册(`startup.openAtLogin`,默认开),开发态不注册 |
|
| 开机自启 | 打包后在 Windows/macOS 上注册(`startup.openAtLogin`,默认开),开发态不注册 |
|
||||||
| 窗口外观 | 管理窗口与服务端管理面板窗口都是 `frame: false` 无边框,标题栏与最小化/最大化/关闭三键由页面自绘(拖拽区 `-webkit-app-region: drag`,按钮 `no-drag`,双击标题栏切最大化,关闭键 hover 变红) |
|
| 窗口外观 | 管理窗口与服务端管理面板窗口都是 `frame: false` 无边框,标题栏与最小化/最大化/关闭三键由页面自绘(拖拽区 `-webkit-app-region: drag`,按钮 `no-drag`,双击标题栏切最大化,关闭键 hover 变红) |
|
||||||
@@ -96,8 +99,8 @@ scoreboard-windows-amd64/
|
|||||||
| `server.healthIntervalMs` | `5000` | 健康检查间隔 |
|
| `server.healthIntervalMs` | `5000` | 健康检查间隔 |
|
||||||
| `server.restartDelayMs` | `3000` | 重启基础退避,每次翻倍,最多 5 倍 |
|
| `server.restartDelayMs` | `3000` | 重启基础退避,每次翻倍,最多 5 倍 |
|
||||||
| `server.maxRestarts` | `0` | 0 = 无限重启 |
|
| `server.maxRestarts` | `0` | 0 = 无限重启 |
|
||||||
| `server.shutdownPath` | `/api/shutdown` | 优雅关闭握手接口(需服务端实现),不可用时回退进程信号 |
|
| `server.shutdownPath` | `/api/shutdown` | 优雅关闭握手接口(需服务端实现并校验 `X-Shutdown-Token`),不可用时回退强杀 |
|
||||||
| `server.shutdownTimeoutMs` | `12000` | 等待优雅关闭的上限,超过则强杀 |
|
| `server.shutdownTimeoutMs` | `5000` | 服务端受理关闭请求后等待其自行退出的上限,超过则强杀 |
|
||||||
| `server.healthyResetMs` | `60000` | 连续健康多久后清零崩溃计数 |
|
| `server.healthyResetMs` | `60000` | 连续健康多久后清零崩溃计数 |
|
||||||
| `display.selection` | `all-secondary` | `all-secondary` / `all` / `specified` |
|
| `display.selection` | `all-secondary` | `all-secondary` / `all` / `specified` |
|
||||||
| `display.displays` | `[]` | `specified` 时生效,填 display id 或 0 起序号 |
|
| `display.displays` | `[]` | `specified` 时生效,填 display id 或 0 起序号 |
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@
|
|||||||
"restartDelayMs": 3000,
|
"restartDelayMs": 3000,
|
||||||
"maxRestarts": 0,
|
"maxRestarts": 0,
|
||||||
"shutdownPath": "/api/shutdown",
|
"shutdownPath": "/api/shutdown",
|
||||||
"shutdownTimeoutMs": 12000,
|
"shutdownTimeoutMs": 5000,
|
||||||
"healthyResetMs": 60000
|
"healthyResetMs": 60000
|
||||||
},
|
},
|
||||||
"display": {
|
"display": {
|
||||||
|
|||||||
@@ -16,9 +16,11 @@ const DEFAULTS = {
|
|||||||
restartDelayMs: 3000,
|
restartDelayMs: 3000,
|
||||||
// 0 表示无限重启;服务端是现场唯一进程,崩了必须拉起来
|
// 0 表示无限重启;服务端是现场唯一进程,崩了必须拉起来
|
||||||
maxRestarts: 0,
|
maxRestarts: 0,
|
||||||
// 优雅关闭握手:服务端需实现该回环接口,未实现时回退到进程信号
|
// 优雅关闭握手:服务端需实现该回环接口并校验 X-Shutdown-Token,
|
||||||
|
// 未实现(404/连接失败)时回退到进程信号
|
||||||
shutdownPath: '/api/shutdown',
|
shutdownPath: '/api/shutdown',
|
||||||
shutdownTimeoutMs: 12000,
|
// 服务端受理关闭请求后等待其自行退出的上限,超过则强杀
|
||||||
|
shutdownTimeoutMs: 5000,
|
||||||
// 连续健康这么久后清零崩溃计数,避免历史崩溃耗尽 maxRestarts
|
// 连续健康这么久后清零崩溃计数,避免历史崩溃耗尽 maxRestarts
|
||||||
healthyResetMs: 60000
|
healthyResetMs: 60000
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const { loadConfig, resolveExecutable } = require('./config')
|
|||||||
const { LogBus } = require('./log-bus')
|
const { LogBus } = require('./log-bus')
|
||||||
const { ServiceSupervisor } = require('./service')
|
const { ServiceSupervisor } = require('./service')
|
||||||
const { DisplayManager } = require('./displays')
|
const { DisplayManager } = require('./displays')
|
||||||
|
const { fetchDisplayInstances } = require('./instances')
|
||||||
const { createTrayIcon } = require('./tray-icon')
|
const { createTrayIcon } = require('./tray-icon')
|
||||||
const { validateAccountField, buildPasswdArgs, runPasswd } = require('./account')
|
const { validateAccountField, buildPasswdArgs, runPasswd } = require('./account')
|
||||||
|
|
||||||
@@ -39,6 +40,9 @@ function main() {
|
|||||||
let quitting = false
|
let quitting = false
|
||||||
let shutdownDone = false
|
let shutdownDone = false
|
||||||
let exePath = null
|
let exePath = null
|
||||||
|
// 大屏实例看板只在管理窗口可见时轮询,窗口隐藏即停表
|
||||||
|
let instancesTimer = null
|
||||||
|
let instancesLastReason = null
|
||||||
// passwd 子进程同一时刻只允许一个:并发写同一个 sqlite 库没有意义,也会让输出互相串
|
// passwd 子进程同一时刻只允许一个:并发写同一个 sqlite 库没有意义,也会让输出互相串
|
||||||
let accountBusy = false
|
let accountBusy = false
|
||||||
|
|
||||||
@@ -118,7 +122,12 @@ function main() {
|
|||||||
log.push('console', '管理窗口已最小化到托盘(服务端继续运行)')
|
log.push('console', '管理窗口已最小化到托盘(服务端继续运行)')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 实例看板只在窗口可见时轮询,隐藏/关闭即停表
|
||||||
|
adminWindow.on('show', startInstancesPolling)
|
||||||
|
adminWindow.on('hide', stopInstancesPolling)
|
||||||
|
|
||||||
adminWindow.on('closed', () => {
|
adminWindow.on('closed', () => {
|
||||||
|
stopInstancesPolling()
|
||||||
adminWindow = null
|
adminWindow = null
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -130,6 +139,48 @@ function main() {
|
|||||||
return `http://${host || '127.0.0.1'}:${cfg.values.server.port}/admin`
|
return `http://${host || '127.0.0.1'}:${cfg.values.server.port}/admin`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function serverHost() {
|
||||||
|
if (service) return service.healthHost()
|
||||||
|
const host = cfg.values.server.host || '127.0.0.1'
|
||||||
|
return host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host
|
||||||
|
}
|
||||||
|
|
||||||
|
// 大屏实例看板:读服务端 GET /api/display/instances 并推给管理窗口。
|
||||||
|
// 失败只按状态变化记一次日志,避免未登录/服务未启动时刷屏。
|
||||||
|
async function pollDisplayInstances() {
|
||||||
|
let result
|
||||||
|
try {
|
||||||
|
result = await fetchDisplayInstances({ host: serverHost(), port: cfg.values.server.port })
|
||||||
|
} catch (err) {
|
||||||
|
result = { ok: false, reason: 'error', message: `读取大屏实例失败:${err.message}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.ok) {
|
||||||
|
if (instancesLastReason) {
|
||||||
|
instancesLastReason = null
|
||||||
|
log.push('console', `大屏实例列表恢复读取(${result.instances.length} 个实例)`)
|
||||||
|
}
|
||||||
|
} else if (result.reason !== instancesLastReason) {
|
||||||
|
instancesLastReason = result.reason
|
||||||
|
log.push('console', `读取大屏实例失败:${result.message}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
sendToAdmin('display:instances', result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function startInstancesPolling() {
|
||||||
|
if (instancesTimer) return
|
||||||
|
pollDisplayInstances()
|
||||||
|
instancesTimer = setInterval(pollDisplayInstances, 5000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopInstancesPolling() {
|
||||||
|
if (!instancesTimer) return
|
||||||
|
clearInterval(instancesTimer)
|
||||||
|
instancesTimer = null
|
||||||
|
}
|
||||||
|
|
||||||
function showServerAdmin() {
|
function showServerAdmin() {
|
||||||
if (!serverAdminWindow || serverAdminWindow.isDestroyed()) {
|
if (!serverAdminWindow || serverAdminWindow.isDestroyed()) {
|
||||||
createServerAdminWindow()
|
createServerAdminWindow()
|
||||||
@@ -352,6 +403,7 @@ function main() {
|
|||||||
showServerAdmin()
|
showServerAdmin()
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
ipcMain.handle('display:instances-get', () => pollDisplayInstances())
|
||||||
|
|
||||||
// 窗口控制统一从 event.sender 反查窗口,多个窗口共用同一套通道
|
// 窗口控制统一从 event.sender 反查窗口,多个窗口共用同一套通道
|
||||||
ipcMain.handle('window:minimize', (event) => {
|
ipcMain.handle('window:minimize', (event) => {
|
||||||
@@ -476,6 +528,7 @@ function main() {
|
|||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
||||||
const finish = () => {
|
const finish = () => {
|
||||||
|
stopInstancesPolling()
|
||||||
if (displays) displays.closeAll()
|
if (displays) displays.closeAll()
|
||||||
if (powerBlockerId !== null) powerSaveBlocker.stop(powerBlockerId)
|
if (powerBlockerId !== null) powerSaveBlocker.stop(powerBlockerId)
|
||||||
if (log) log.close()
|
if (log) log.close()
|
||||||
@@ -491,6 +544,7 @@ function main() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
app.on('quit', () => {
|
app.on('quit', () => {
|
||||||
|
stopInstancesPolling()
|
||||||
if (service) service.dispose()
|
if (service) service.dispose()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
'use strict'
|
||||||
|
|
||||||
|
const http = require('http')
|
||||||
|
const { session } = require('electron')
|
||||||
|
|
||||||
|
// 大屏实例看板:复用服务端管理面板的登录态,不另造一套登录逻辑。
|
||||||
|
// 管理面板窗口与 Console 同属默认 session,登录后 scoreboard_session 就存在这里,
|
||||||
|
// 主进程取同一张 Cookie 访问 GET /api/display/instances(记录员及以上可读)。
|
||||||
|
// Cookie 名与服务端 server/internal/auth/auth.go 的 CookieSession 一致。
|
||||||
|
const SESSION_COOKIE = 'scoreboard_session'
|
||||||
|
const REQUEST_TIMEOUT_MS = 3000
|
||||||
|
|
||||||
|
function requestJSON({ host, port, path, cookie }) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const request = http.get(
|
||||||
|
{
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
path,
|
||||||
|
timeout: REQUEST_TIMEOUT_MS,
|
||||||
|
headers: {
|
||||||
|
Cookie: cookie,
|
||||||
|
Accept: 'application/json',
|
||||||
|
Connection: 'close'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(response) => {
|
||||||
|
const chunks = []
|
||||||
|
response.on('data', (chunk) => chunks.push(chunk))
|
||||||
|
response.on('end', () => {
|
||||||
|
resolve({ status: response.statusCode, body: Buffer.concat(chunks).toString('utf8') })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
request.on('timeout', () => request.destroy(new Error('请求超时')))
|
||||||
|
request.on('error', (err) => resolve({ error: err }))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readSessionCookie(url) {
|
||||||
|
try {
|
||||||
|
const cookies = await session.defaultSession.cookies.get({ url })
|
||||||
|
const hit = cookies.find((item) => item.name === SESSION_COOKIE)
|
||||||
|
return hit ? `${hit.name}=${hit.value}` : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 成功返回 { ok: true, instances };失败返回 { ok: false, reason, message },
|
||||||
|
// message 直接作为界面占位文案,reason 供调用方决定要不要记日志(避免失败时刷屏)。
|
||||||
|
async function fetchDisplayInstances({ host, port }) {
|
||||||
|
const cookie = await readSessionCookie(`http://${host}:${port}/`)
|
||||||
|
if (!cookie) {
|
||||||
|
return { ok: false, reason: 'unauthenticated', message: '未登录,请先在服务端管理面板登录(记录员及以上)' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await requestJSON({ host, port, path: '/api/display/instances', cookie })
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
if (result.error.code === 'ECONNREFUSED' || result.error.code === 'ECONNRESET') {
|
||||||
|
return { ok: false, reason: 'offline', message: '服务端未运行,暂时无法读取大屏实例' }
|
||||||
|
}
|
||||||
|
return { ok: false, reason: 'error', message: `读取大屏实例失败:${result.error.message}` }
|
||||||
|
}
|
||||||
|
if (result.status === 401) {
|
||||||
|
return { ok: false, reason: 'unauthenticated', message: '登录已失效,请重新登录服务端管理面板' }
|
||||||
|
}
|
||||||
|
if (result.status === 403) {
|
||||||
|
return { ok: false, reason: 'forbidden', message: '当前账号权限不足(需要记录员及以上)' }
|
||||||
|
}
|
||||||
|
if (result.status === 404) {
|
||||||
|
return { ok: false, reason: 'unsupported', message: '服务端版本较旧,未提供大屏实例接口' }
|
||||||
|
}
|
||||||
|
if (result.status !== 200) {
|
||||||
|
return { ok: false, reason: 'error', message: `读取大屏实例失败:HTTP ${result.status}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(result.body)
|
||||||
|
return { ok: true, instances: Array.isArray(payload.data) ? payload.data : [] }
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, reason: 'error', message: `大屏实例响应无法解析:${err.message}` }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { fetchDisplayInstances }
|
||||||
+39
-19
@@ -1,5 +1,6 @@
|
|||||||
'use strict'
|
'use strict'
|
||||||
|
|
||||||
|
const crypto = require('crypto')
|
||||||
const fs = require('fs')
|
const fs = require('fs')
|
||||||
const http = require('http')
|
const http = require('http')
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
@@ -14,12 +15,14 @@ const STATE_LABEL = {
|
|||||||
crashed: '异常退出'
|
crashed: '异常退出'
|
||||||
}
|
}
|
||||||
|
|
||||||
// 优雅关闭握手:POST server.shutdownPath,让服务端自己走 signal.NotifyContext
|
// 优雅关闭握手:POST server.shutdownPath 并带上启动时下发的 token,
|
||||||
// 的关闭流程;请求失败或接口未实现时回退到进程信号。
|
// 让服务端自己走 signal.NotifyContext 的关闭流程;请求失败或接口未实现时回退到强杀。
|
||||||
const DEFAULT_SHUTDOWN_PATH = '/api/shutdown'
|
const DEFAULT_SHUTDOWN_PATH = '/api/shutdown'
|
||||||
const SHUTDOWN_REQUEST_TIMEOUT_MS = 2000
|
const SHUTDOWN_TOKEN_ENV = 'SCOREBOARD_SHUTDOWN_TOKEN'
|
||||||
// 服务端 srv.Shutdown 的超时是 10s,客户端必须留足余量再强杀
|
const SHUTDOWN_TOKEN_HEADER = 'X-Shutdown-Token'
|
||||||
const DEFAULT_SHUTDOWN_TIMEOUT_MS = 12000
|
const SHUTDOWN_REQUEST_TIMEOUT_MS = 3000
|
||||||
|
// 服务端收到关闭请求后承诺 3 秒内退出,客户端留 5 秒余量再强杀
|
||||||
|
const DEFAULT_SHUTDOWN_TIMEOUT_MS = 5000
|
||||||
// 握手不可用时信号已能结束进程,不必再等满超时
|
// 握手不可用时信号已能结束进程,不必再等满超时
|
||||||
const FALLBACK_FORCE_KILL_MS = 1500
|
const FALLBACK_FORCE_KILL_MS = 1500
|
||||||
// 连续健康这么久才认为崩溃计数可以清零
|
// 连续健康这么久才认为崩溃计数可以清零
|
||||||
@@ -48,6 +51,10 @@ class ServiceSupervisor extends EventEmitter {
|
|||||||
this.healthTimer = null
|
this.healthTimer = null
|
||||||
this.restartTimer = null
|
this.restartTimer = null
|
||||||
this.forceKillTimer = null
|
this.forceKillTimer = null
|
||||||
|
// 本次进程的关闭握手 token 与握手结果,仅用于日志区分「优雅退出 / 被强杀」
|
||||||
|
this.shutdownToken = null
|
||||||
|
this.gracefulAccepted = false
|
||||||
|
this.forcedKill = false
|
||||||
}
|
}
|
||||||
|
|
||||||
get status() {
|
get status() {
|
||||||
@@ -103,16 +110,23 @@ class ServiceSupervisor extends EventEmitter {
|
|||||||
|
|
||||||
this.lastError = null
|
this.lastError = null
|
||||||
this.healthySince = null
|
this.healthySince = null
|
||||||
|
this.gracefulAccepted = false
|
||||||
|
this.forcedKill = false
|
||||||
this.startedAt = new Date().toISOString()
|
this.startedAt = new Date().toISOString()
|
||||||
this.setState('starting')
|
this.setState('starting')
|
||||||
this.log.push('console', `启动服务端:${this.exePath} ${args.join(' ')}(cwd=${cwd})`)
|
this.log.push('console', `启动服务端:${this.exePath} ${args.join(' ')}(cwd=${cwd})`)
|
||||||
|
|
||||||
|
// 每次拉起都换一把新 token,只通过环境变量交给这一个子进程,
|
||||||
|
// 停止时凭它调用服务端的 /api/shutdown
|
||||||
|
this.shutdownToken = crypto.randomBytes(32).toString('hex')
|
||||||
|
|
||||||
let child
|
let child
|
||||||
try {
|
try {
|
||||||
child = spawn(this.exePath, args, {
|
child = spawn(this.exePath, args, {
|
||||||
cwd,
|
cwd,
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
stdio: ['ignore', 'pipe', 'pipe']
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
env: { ...process.env, [SHUTDOWN_TOKEN_ENV]: this.shutdownToken }
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.setState('crashed', { lastError: `启动失败:${err.message}` })
|
this.setState('crashed', { lastError: `启动失败:${err.message}` })
|
||||||
@@ -157,7 +171,10 @@ class ServiceSupervisor extends EventEmitter {
|
|||||||
|
|
||||||
const reason = signal ? `信号 ${signal}` : `退出码 ${code}`
|
const reason = signal ? `信号 ${signal}` : `退出码 ${code}`
|
||||||
if (this.stopRequested) {
|
if (this.stopRequested) {
|
||||||
this.log.push('console', `服务端已停止(${reason})`)
|
const how = this.forcedKill ? '已被强制结束' : this.gracefulAccepted ? '已优雅退出' : '已停止'
|
||||||
|
this.log.push('console', `服务端${how}(${reason})`)
|
||||||
|
this.gracefulAccepted = false
|
||||||
|
this.forcedKill = false
|
||||||
this.setState('stopped', { lastError: null })
|
this.setState('stopped', { lastError: null })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -198,24 +215,25 @@ class ServiceSupervisor extends EventEmitter {
|
|||||||
|
|
||||||
this.stopRequested = true
|
this.stopRequested = true
|
||||||
this.setState('stopping')
|
this.setState('stopping')
|
||||||
this.log.push('console', '正在停止服务端…')
|
this.log.push('console', '正在优雅关闭服务端…')
|
||||||
|
|
||||||
child.once('exit', () => resolve())
|
child.once('exit', () => resolve())
|
||||||
|
|
||||||
const timeoutMs = Number(this.server.shutdownTimeoutMs) || DEFAULT_SHUTDOWN_TIMEOUT_MS
|
const graceMs = Number(this.server.shutdownTimeoutMs) || DEFAULT_SHUTDOWN_TIMEOUT_MS
|
||||||
// 先按最坏情况挂强杀兜底,握手成功后也不撤:服务端可能卡在关闭流程里
|
|
||||||
this.armForceKill(timeoutMs)
|
|
||||||
|
|
||||||
this.requestShutdown().then((accepted) => {
|
this.requestShutdown().then((accepted) => {
|
||||||
|
// 请求期间进程可能已经退了,handleExit 会清掉兜底定时器
|
||||||
if (this.child !== child) return
|
if (this.child !== child) return
|
||||||
if (accepted) {
|
if (accepted) {
|
||||||
this.log.push('console', `服务端已接受关闭请求,最多等待 ${Math.round(timeoutMs / 1000)} 秒`)
|
this.gracefulAccepted = true
|
||||||
|
this.log.push('console', `服务端已接受优雅关闭请求,最多等待 ${Math.round(graceMs / 1000)} 秒自行退出`)
|
||||||
|
this.armForceKill(graceMs)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 握手不可用:服务端还没实现该接口,或进程已无响应。
|
// 握手不可用:服务端还是老版本没有该接口,或进程已无响应。
|
||||||
// 退回进程信号;Windows 上 Node 的 kill() 等同强杀,所以这条路只是兜底,
|
// 退回进程信号;Windows 上 Node 的 kill() 等同强杀,所以这条路只是兜底,
|
||||||
// 真正的优雅关闭依赖服务端补上 shutdownPath 接口。
|
// 真正的优雅关闭依赖服务端实现 shutdownPath 接口。
|
||||||
this.log.push('console', '关闭接口不可用,退回进程信号')
|
this.log.push('console', '优雅关闭接口不可用,退回强制结束')
|
||||||
this.armForceKill(FALLBACK_FORCE_KILL_MS)
|
this.armForceKill(FALLBACK_FORCE_KILL_MS)
|
||||||
try {
|
try {
|
||||||
child.kill()
|
child.kill()
|
||||||
@@ -226,9 +244,9 @@ class ServiceSupervisor extends EventEmitter {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 优雅关闭握手:让服务端自己走 signal.NotifyContext → srv.Shutdown 的流程,
|
// 优雅关闭握手:带启动时下发的 X-Shutdown-Token 请求服务端退出,
|
||||||
// 10s 内落盘退出。接口按回环地址访问、不鉴权,只在本机有意义。
|
// 2xx 表示已受理。老版本服务端没有该接口(404)或连不上时返回 false,
|
||||||
// 依赖:服务端实现 POST <server.shutdownPath>(2xx 表示已受理)。
|
// 由调用方回退到强杀。
|
||||||
requestShutdown() {
|
requestShutdown() {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const body = Buffer.from('{}')
|
const body = Buffer.from('{}')
|
||||||
@@ -242,13 +260,14 @@ class ServiceSupervisor extends EventEmitter {
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Content-Length': body.length,
|
'Content-Length': body.length,
|
||||||
|
[SHUTDOWN_TOKEN_HEADER]: this.shutdownToken || '',
|
||||||
Connection: 'close'
|
Connection: 'close'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
(response) => {
|
(response) => {
|
||||||
response.resume()
|
response.resume()
|
||||||
const accepted = response.statusCode >= 200 && response.statusCode < 300
|
const accepted = response.statusCode >= 200 && response.statusCode < 300
|
||||||
if (!accepted) this.log.push('console', `关闭接口返回 HTTP ${response.statusCode}`)
|
if (!accepted) this.log.push('console', `关闭接口返回 HTTP ${response.statusCode}(按老版本处理)`)
|
||||||
resolve(accepted)
|
resolve(accepted)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -266,6 +285,7 @@ class ServiceSupervisor extends EventEmitter {
|
|||||||
this.forceKillTimer = setTimeout(() => {
|
this.forceKillTimer = setTimeout(() => {
|
||||||
this.forceKillTimer = null
|
this.forceKillTimer = null
|
||||||
if (!this.child || !this.pid) return
|
if (!this.child || !this.pid) return
|
||||||
|
this.forcedKill = true
|
||||||
this.log.push('console', `服务端未在 ${Math.round(ms / 1000)} 秒内退出,强制结束`)
|
this.log.push('console', `服务端未在 ${Math.round(ms / 1000)} 秒内退出,强制结束`)
|
||||||
try {
|
try {
|
||||||
this.child.kill('SIGKILL')
|
this.child.kill('SIGKILL')
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ const el = {
|
|||||||
config: document.getElementById('k-config'),
|
config: document.getElementById('k-config'),
|
||||||
configWarning: document.getElementById('config-warning'),
|
configWarning: document.getElementById('config-warning'),
|
||||||
displayList: document.getElementById('display-list'),
|
displayList: document.getElementById('display-list'),
|
||||||
|
instanceList: document.getElementById('instance-list'),
|
||||||
|
instancesSummary: document.getElementById('instances-summary'),
|
||||||
log: document.getElementById('log'),
|
log: document.getElementById('log'),
|
||||||
autoscroll: document.getElementById('autoscroll'),
|
autoscroll: document.getElementById('autoscroll'),
|
||||||
btnStart: document.getElementById('btn-start'),
|
btnStart: document.getElementById('btn-start'),
|
||||||
@@ -94,6 +96,45 @@ function renderDisplays(displays) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderInstances(payload) {
|
||||||
|
el.instanceList.textContent = ''
|
||||||
|
|
||||||
|
if (!payload || !payload.ok) {
|
||||||
|
el.instancesSummary.textContent = ''
|
||||||
|
const li = document.createElement('li')
|
||||||
|
li.className = 'muted'
|
||||||
|
li.textContent = (payload && payload.message) || '暂时无法读取大屏实例'
|
||||||
|
el.instanceList.append(li)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = payload.instances || []
|
||||||
|
el.instancesSummary.textContent = `共 ${list.length} 个 · ${list.filter((item) => item.online).length} 在线`
|
||||||
|
|
||||||
|
if (!list.length) {
|
||||||
|
const li = document.createElement('li')
|
||||||
|
li.className = 'muted'
|
||||||
|
li.textContent = '当前没有大屏实例连接'
|
||||||
|
el.instanceList.append(li)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of list) {
|
||||||
|
const li = document.createElement('li')
|
||||||
|
const page = document.createElement('span')
|
||||||
|
page.className = 'page'
|
||||||
|
page.textContent = item.page || '未知页面'
|
||||||
|
const id = document.createElement('span')
|
||||||
|
id.className = 'url'
|
||||||
|
id.textContent = item.id || '—'
|
||||||
|
const state = document.createElement('span')
|
||||||
|
state.className = `state ${item.online ? 'online' : 'offline'}`
|
||||||
|
state.textContent = item.online ? '在线' : '离线'
|
||||||
|
li.append(page, id, state)
|
||||||
|
el.instanceList.append(li)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function appendLog(line) {
|
function appendLog(line) {
|
||||||
const atBottom = el.log.scrollTop + el.log.clientHeight >= el.log.scrollHeight - 8
|
const atBottom = el.log.scrollTop + el.log.clientHeight >= el.log.scrollHeight - 8
|
||||||
el.log.textContent += `${line}\n`
|
el.log.textContent += `${line}\n`
|
||||||
@@ -233,8 +274,11 @@ async function boot() {
|
|||||||
window.consoleApi.onStatus(renderStatus)
|
window.consoleApi.onStatus(renderStatus)
|
||||||
window.consoleApi.onLog(appendLog)
|
window.consoleApi.onLog(appendLog)
|
||||||
window.consoleApi.onAccountOutput(handleAccountOutput)
|
window.consoleApi.onAccountOutput(handleAccountOutput)
|
||||||
|
window.consoleApi.onDisplayInstances(renderInstances)
|
||||||
setLog(await window.consoleApi.getLog())
|
setLog(await window.consoleApi.getLog())
|
||||||
await refresh()
|
await refresh()
|
||||||
|
// 主进程只在窗口可见时轮询;这里补一次,保证窗口刚打开就有数据
|
||||||
|
renderInstances(await window.consoleApi.getDisplayInstances())
|
||||||
}
|
}
|
||||||
|
|
||||||
boot()
|
boot()
|
||||||
@@ -108,6 +108,20 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>已连接的大屏实例</h2>
|
||||||
|
<span id="instances-summary" class="muted"></span>
|
||||||
|
</div>
|
||||||
|
<p class="hint">
|
||||||
|
来自服务端 <span class="mono">GET /api/display/instances</span>,每 5 秒刷新一次;
|
||||||
|
沿用服务端管理面板的登录态,需要记录员及以上权限。
|
||||||
|
</p>
|
||||||
|
<ul id="instance-list" class="display-list">
|
||||||
|
<li class="muted">正在读取…</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<h2>管理员账号</h2>
|
<h2>管理员账号</h2>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ contextBridge.exposeInMainWorld('consoleApi', {
|
|||||||
getLog: () => ipcRenderer.invoke('log:tail'),
|
getLog: () => ipcRenderer.invoke('log:tail'),
|
||||||
clearLog: () => ipcRenderer.invoke('log:clear'),
|
clearLog: () => ipcRenderer.invoke('log:clear'),
|
||||||
relayoutDisplays: () => ipcRenderer.invoke('display:relayout'),
|
relayoutDisplays: () => ipcRenderer.invoke('display:relayout'),
|
||||||
|
getDisplayInstances: () => ipcRenderer.invoke('display:instances-get'),
|
||||||
openServerAdmin: () => ipcRenderer.invoke('app:open-server-admin'),
|
openServerAdmin: () => ipcRenderer.invoke('app:open-server-admin'),
|
||||||
resetAdminAccount: (payload) => ipcRenderer.invoke('account:reset', payload),
|
resetAdminAccount: (payload) => ipcRenderer.invoke('account:reset', payload),
|
||||||
minimize: () => ipcRenderer.invoke('window:minimize'),
|
minimize: () => ipcRenderer.invoke('window:minimize'),
|
||||||
@@ -25,6 +26,7 @@ contextBridge.exposeInMainWorld('consoleApi', {
|
|||||||
isMaximized: () => ipcRenderer.invoke('window:is-maximized'),
|
isMaximized: () => ipcRenderer.invoke('window:is-maximized'),
|
||||||
onMaximizeChange: (callback) => subscribe('window:maximize-changed', callback),
|
onMaximizeChange: (callback) => subscribe('window:maximize-changed', callback),
|
||||||
onAccountOutput: (callback) => subscribe('account:output', callback),
|
onAccountOutput: (callback) => subscribe('account:output', callback),
|
||||||
|
onDisplayInstances: (callback) => subscribe('display:instances', callback),
|
||||||
onLog: (callback) => subscribe('log:line', callback),
|
onLog: (callback) => subscribe('log:line', callback),
|
||||||
onStatus: (callback) => subscribe('service:status', callback)
|
onStatus: (callback) => subscribe('service:status', callback)
|
||||||
})
|
})
|
||||||
@@ -152,8 +152,8 @@ main {
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
/* 四张卡片各占一整行;最后一行(运行日志)吃掉剩余高度 */
|
/* 五张卡片各占一整行;最后一行(运行日志)吃掉剩余高度 */
|
||||||
grid-template-rows: auto auto auto minmax(160px, 1fr);
|
grid-template-rows: auto auto auto auto minmax(160px, 1fr);
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,6 +326,20 @@ h2 {
|
|||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.display-list .state {
|
||||||
|
margin-left: auto;
|
||||||
|
flex: none;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.display-list .state.online {
|
||||||
|
color: var(--ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
.display-list .state.offline {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
.check {
|
.check {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
+3
-2
@@ -29,9 +29,10 @@ cd ..
|
|||||||
echo === [3/4] server build ===
|
echo === [3/4] server build ===
|
||||||
cd server
|
cd server
|
||||||
if not exist ..\dist mkdir ..\dist
|
if not exist ..\dist mkdir ..\dist
|
||||||
REM internal signature is injected from a machine-local file (gitignored, never in repo)
|
REM internal signature is injected from a machine-local file (gitignored, never in repo).
|
||||||
|
REM set /p returns empty under chcp 65001, so read the first line with for /f instead.
|
||||||
set "INTERNAL_SIGNATURE="
|
set "INTERNAL_SIGNATURE="
|
||||||
if exist "..\deploy\signature.local.txt" set /p INTERNAL_SIGNATURE=<"..\deploy\signature.local.txt"
|
if exist "..\deploy\signature.local.txt" for /f "usebackq delims= eol=" %%A in ("..\deploy\signature.local.txt") do if not defined INTERNAL_SIGNATURE set "INTERNAL_SIGNATURE=%%A"
|
||||||
if defined INTERNAL_SIGNATURE (
|
if defined INTERNAL_SIGNATURE (
|
||||||
REM single quotes keep spaces inside the value intact for go's ldflags parser
|
REM single quotes keep spaces inside the value intact for go's ldflags parser
|
||||||
go build -ldflags "-X 'git.yeij.top/AskaEth/Scoreboard/server/internal/version.InternalSignature=%INTERNAL_SIGNATURE%'" -o ..\dist\scoreboard.exe . || exit /b 1
|
go build -ldflags "-X 'git.yeij.top/AskaEth/Scoreboard/server/internal/version.InternalSignature=%INTERNAL_SIGNATURE%'" -o ..\dist\scoreboard.exe . || exit /b 1
|
||||||
|
|||||||
@@ -351,6 +351,7 @@
|
|||||||
|
|
||||||
- 列表显示备份文件名、来源、大小、创建时间;
|
- 列表显示备份文件名、来源、大小、创建时间;
|
||||||
- 点「立即备份」马上生成一份;
|
- 点「立即备份」马上生成一份;
|
||||||
|
- 服务端默认每 5 分钟自动备份一次,自动、手动、发布前备份都按 `backup.keep`(默认 20 份)滚动保留;
|
||||||
- 右侧「恢复说明」列出停机恢复步骤(详见部署指南第 8 节)。
|
- 右侧「恢复说明」列出停机恢复步骤(详见部署指南第 8 节)。
|
||||||
|
|
||||||
### 14.3 赛事信息
|
### 14.3 赛事信息
|
||||||
|
|||||||
+3
-3
@@ -85,13 +85,13 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点
|
|||||||
## 数据与备份
|
## 数据与备份
|
||||||
|
|
||||||
**Q23:备份在哪里?**
|
**Q23:备份在哪里?**
|
||||||
`data\backups\`,文件名形如 `system-<时间戳>.db`、`<项目名>-<时间戳>.db`。
|
`data\backups\`,文件名形如 `system-<原因>-<时间戳>.db`、`<项目名>-<原因>-<时间戳>.db`(原因有 `auto` / `manual` / `publish`)。
|
||||||
|
|
||||||
**Q24:多久备份一次?**
|
**Q24:多久备份一次?**
|
||||||
当前版本需要手动触发:管理端「系统设置 → 备份 → 立即备份」,或执行 `scoreboard.exe backup -config config.yaml`。建议在开幕前、发布高峰前、每天结束时各备份一次。
|
服务端会自动备份,默认每 5 分钟一次(`config.yaml` 的 `backup.intervalMinutes`,设成 `0` 可关闭)。也可以手动触发:管理端「系统设置 → 备份 → 立即备份」,或执行 `scoreboard.exe backup -config config.yaml`。另外每次发布成绩前会自动备一份。
|
||||||
|
|
||||||
**Q25:备份会自动删除旧的么?**
|
**Q25:备份会自动删除旧的么?**
|
||||||
命令行备份会按前缀滚动保留最近 20 份,超出自动清理;管理端「立即备份」当前不自动清理,请定期检查目录占用。
|
会。自动、手动、命令行三种方式都按 `backup.keep`(默认 20 份)滚动保留,`system` 库与赛事库各自计算。
|
||||||
|
|
||||||
**Q26:怎么恢复备份?**
|
**Q26:怎么恢复备份?**
|
||||||
没有一键恢复接口,需停机手工替换数据库文件(复制备份为 `event.db`,删除 `event.db-wal` / `event.db-shm`,再启动),详见 [部署指南 · 备份与恢复](./部署指南.md#8-备份与恢复)。
|
没有一键恢复接口,需停机手工替换数据库文件(复制备份为 `event.db`,删除 `event.db-wal` / `event.db-shm`,再启动),详见 [部署指南 · 备份与恢复](./部署指南.md#8-备份与恢复)。
|
||||||
|
|||||||
+12
-7
@@ -197,6 +197,9 @@ scoreboard.exe passwd -config config.yaml
|
|||||||
| `server.port` | `19540` | HTTP 端口 |
|
| `server.port` | `19540` | HTTP 端口 |
|
||||||
| `dataDir` | `data` | 数据目录,相对服务端工作目录解析 |
|
| `dataDir` | `data` | 数据目录,相对服务端工作目录解析 |
|
||||||
| `log.level` | `info` | 日志级别:`debug` / `info` / `warn` / `error` |
|
| `log.level` | `info` | 日志级别:`debug` / `info` / `warn` / `error` |
|
||||||
|
| `backup.intervalMinutes` | `5` | 自动备份间隔(分钟),`<=0` 关闭自动备份 |
|
||||||
|
| `backup.keep` | `20` | 每个库滚动保留的备份份数 |
|
||||||
|
| `server.allowedOrigins` | `[]` | 额外放行的 WebSocket Origin 白名单;默认只放行同源与不带 Origin 的客户端 |
|
||||||
|
|
||||||
### 6.2 Console `console\config.json`
|
### 6.2 Console `console\config.json`
|
||||||
|
|
||||||
@@ -209,7 +212,7 @@ scoreboard.exe passwd -config config.yaml
|
|||||||
| `server.healthIntervalMs` | `5000` | 健康检查间隔(毫秒) |
|
| `server.healthIntervalMs` | `5000` | 健康检查间隔(毫秒) |
|
||||||
| `server.restartDelayMs` | `3000` | 崩溃重启的初始退避,之后逐次翻倍 |
|
| `server.restartDelayMs` | `3000` | 崩溃重启的初始退避,之后逐次翻倍 |
|
||||||
| `server.maxRestarts` | `0` | `0` = 无限重启 |
|
| `server.maxRestarts` | `0` | `0` = 无限重启 |
|
||||||
| `server.shutdownTimeoutMs` | `12000` | 等待服务端优雅退出的上限 |
|
| `server.shutdownTimeoutMs` | `5000` | 等待服务端优雅退出的上限 |
|
||||||
| `server.healthyResetMs` | `60000` | 连续健康多久后清零崩溃计数 |
|
| `server.healthyResetMs` | `60000` | 连续健康多久后清零崩溃计数 |
|
||||||
| `display.selection` | `all-secondary` | 选屏策略,见第 7 节 |
|
| `display.selection` | `all-secondary` | 选屏策略,见第 7 节 |
|
||||||
| `display.displays` | `[]` | 仅 `specified` 策略生效,填显示器 id 或 0 起序号 |
|
| `display.displays` | `[]` | 仅 `specified` 策略生效,填显示器 id 或 0 起序号 |
|
||||||
@@ -305,14 +308,16 @@ http://<主机IP>:19540/display
|
|||||||
|
|
||||||
| 方式 | 操作 | 产物 |
|
| 方式 | 操作 | 产物 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 管理端 | 「系统设置 → 备份 → 立即备份」 | `system-<时间戳>.db` 与 `event-<时间戳>.db` |
|
| 自动 | 服务端启动后按 `backup.intervalMinutes`(默认 5 分钟) | `system-auto-<时间戳>.db` 与 `event-auto-<时间戳>.db` |
|
||||||
| 命令行 | 在部署目录执行 `scoreboard.exe backup -config config.yaml` | `system-<原因>-<时间戳>.db` 与 `<项目名>-<原因>-<时间戳>.db` |
|
| 管理端 | 「系统设置 → 备份 → 立即备份」 | `system-manual-<时间戳>.db` 与 `event-manual-<时间戳>.db` |
|
||||||
| 快捷脚本 | `deploy\backup.bat`(原因记 `manual`)或 `deploy\backup.bat publish`(原因记 `publish`) | 同上 |
|
| 命令行 | 在部署目录执行 `scoreboard.exe backup -config config.yaml` | `system-manual-<时间戳>.db` 与 `event-manual-<时间戳>.db` |
|
||||||
|
| 快捷脚本 | `deploy\backup.bat`(原因记 `manual`)或 `deploy\backup.bat publish`(原因记 `publish`) | 同上(`publish` 时原因为 `publish`) |
|
||||||
|
| 发布前 | 每次发布成绩时自动 | `event-publish-<时间戳>.db` |
|
||||||
|
|
||||||
- 备份使用 SQLite 的 `VACUUM INTO`,服务运行中也能安全执行,不需要停服务,也不依赖外部 `sqlite3.exe`;
|
- 备份使用 SQLite 的 `VACUUM INTO`,服务运行中也能安全执行,不需要停服务,也不依赖外部 `sqlite3.exe`;
|
||||||
- 命令行方式会按前缀**滚动保留最近 20 份**(`system` 与赛事库各自计算),超出的旧备份自动删除;
|
- 自动、手动、命令行三种方式都按 `backup.keep`(默认 20 份)滚动保留,`system` 库与赛事库各自计算,超出的旧备份自动删除;
|
||||||
- 管理端「立即备份」按钮生成快照,但当前版本不会自动清理旧文件,请定期检查 `data\backups\` 占用;
|
- 同一时刻只允许一个备份在进行,自动备份与手动、发布前备份不会互相踩;
|
||||||
- 现场建议:开幕前、每次发布高峰前、每天结束时各备份一次。
|
- 现场建议:开幕前、每次发布高峰前、每天结束时各手动备份一次(自动备份只是兜底)。
|
||||||
|
|
||||||
### 8.3 如何恢复
|
### 8.3 如何恢复
|
||||||
|
|
||||||
|
|||||||
@@ -14,13 +14,24 @@ import (
|
|||||||
type Config struct {
|
type Config struct {
|
||||||
Server Server `yaml:"server"`
|
Server Server `yaml:"server"`
|
||||||
DataDir string `yaml:"dataDir"`
|
DataDir string `yaml:"dataDir"`
|
||||||
|
Backup Backup `yaml:"backup"`
|
||||||
Log Log `yaml:"log"`
|
Log Log `yaml:"log"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Backup 是自动备份配置。
|
||||||
|
type Backup struct {
|
||||||
|
// IntervalMinutes 是自动备份间隔(分钟);<=0 表示关闭自动备份。
|
||||||
|
IntervalMinutes int `yaml:"intervalMinutes"`
|
||||||
|
// Keep 是每个库滚动保留的份数;<=0 时用 export.DefaultBackupKeep。
|
||||||
|
Keep int `yaml:"keep"`
|
||||||
|
}
|
||||||
|
|
||||||
// Server 是 HTTP 监听配置。
|
// Server 是 HTTP 监听配置。
|
||||||
type Server struct {
|
type Server struct {
|
||||||
Host string `yaml:"host"`
|
Host string `yaml:"host"`
|
||||||
Port int `yaml:"port"`
|
Port int `yaml:"port"`
|
||||||
|
// AllowedOrigins 是 WebSocket 跨站 Origin 白名单;为空时只放行同源与不带 Origin 的客户端。
|
||||||
|
AllowedOrigins []string `yaml:"allowedOrigins"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log 是日志配置。
|
// Log 是日志配置。
|
||||||
@@ -33,6 +44,7 @@ func Default() *Config {
|
|||||||
return &Config{
|
return &Config{
|
||||||
Server: Server{Host: "0.0.0.0", Port: 19540},
|
Server: Server{Host: "0.0.0.0", Port: 19540},
|
||||||
DataDir: "data",
|
DataDir: "data",
|
||||||
|
Backup: Backup{IntervalMinutes: 5, Keep: 20},
|
||||||
Log: Log{Level: "info"},
|
Log: Log{Level: "info"},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDefaultAllowedOrigins(t *testing.T) {
|
||||||
|
if got := Default().Server.AllowedOrigins; len(got) != 0 {
|
||||||
|
t.Fatalf("默认白名单应为空: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadAllowedOrigins(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||||
|
raw := "server:\n allowedOrigins:\n - http://a.example\n - https://b.example\n"
|
||||||
|
if err := os.WriteFile(path, []byte(raw), 0o644); err != nil {
|
||||||
|
t.Fatalf("写入配置: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("加载配置: %v", err)
|
||||||
|
}
|
||||||
|
want := []string{"http://a.example", "https://b.example"}
|
||||||
|
if !reflect.DeepEqual(cfg.Server.AllowedOrigins, want) {
|
||||||
|
t.Fatalf("白名单解析错误: got %v want %v", cfg.Server.AllowedOrigins, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,6 +43,18 @@ type EventSettingsStandings struct {
|
|||||||
IndividualToTeamWeight float64 `json:"individualToTeamWeight"`
|
IndividualToTeamWeight float64 `json:"individualToTeamWeight"`
|
||||||
RankingMode string `json:"rankingMode"`
|
RankingMode string `json:"rankingMode"`
|
||||||
ShowIndividualBoard bool `json:"showIndividualBoard"`
|
ShowIndividualBoard bool `json:"showIndividualBoard"`
|
||||||
|
ShowIndividualMedals bool `json:"showIndividualMedals"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RuleSwitches 是 GET /api/rules/switches 的线上结构:只暴露规则包里的布尔开关,
|
||||||
|
// 不包含任何规则内容、模板明细或文件路径,供录入员判断相关功能是否启用。
|
||||||
|
type RuleSwitches struct {
|
||||||
|
RecordBonusEnabled bool `json:"recordBonusEnabled"`
|
||||||
|
AdjustmentsEnabled bool `json:"adjustmentsEnabled"`
|
||||||
|
AdjustmentReasonRequired bool `json:"adjustmentReasonRequired"`
|
||||||
|
MedalCountsTeamEvents bool `json:"medalCountsTeamEvents"`
|
||||||
|
ShowIndividualBoard bool `json:"showIndividualBoard"`
|
||||||
|
ShowIndividualMedals bool `json:"showIndividualMedals"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseEventSettings 把 settings 表的点号键聚合成嵌套对象。
|
// ParseEventSettings 把 settings 表的点号键聚合成嵌套对象。
|
||||||
@@ -74,8 +86,10 @@ func ParseEventSettings(values map[string]string) EventSettings {
|
|||||||
|
|
||||||
out.Standings.IndividualToTeamWeight = atofDefault(values["standings.individual_to_team_weight"], 1)
|
out.Standings.IndividualToTeamWeight = atofDefault(values["standings.individual_to_team_weight"], 1)
|
||||||
out.Standings.RankingMode = orDefault(values["standings.mode"], "points")
|
out.Standings.RankingMode = orDefault(values["standings.mode"], "points")
|
||||||
|
// showIndividualBoard 是历史别名:未单独配置时沿用旧的 show_individual_medals 取值。
|
||||||
out.Standings.ShowIndividualBoard = boolDefault(
|
out.Standings.ShowIndividualBoard = boolDefault(
|
||||||
firstNonEmpty(values["standings.show_individual_board"], values["standings.show_individual_medals"]), true)
|
firstNonEmpty(values["standings.show_individual_board"], values["standings.show_individual_medals"]), true)
|
||||||
|
out.Standings.ShowIndividualMedals = boolDefault(values["standings.show_individual_medals"], true)
|
||||||
|
|
||||||
out.RequireApproval = boolDefault(values["results.review_required"], false)
|
out.RequireApproval = boolDefault(values["results.review_required"], false)
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
package export
|
package export
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -48,7 +51,7 @@ func Backup(db *sql.DB, opts BackupOptions) (BackupInfo, error) {
|
|||||||
}
|
}
|
||||||
dir := strings.TrimSpace(opts.Dir)
|
dir := strings.TrimSpace(opts.Dir)
|
||||||
if dir == "" {
|
if dir == "" {
|
||||||
return BackupInfo{}, errors.New("export: 备份目录为空")
|
return BackupInfo{}, ErrNoDir
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
return BackupInfo{}, fmt.Errorf("export: 创建备份目录 %s: %w", dir, err)
|
return BackupInfo{}, fmt.Errorf("export: 创建备份目录 %s: %w", dir, err)
|
||||||
@@ -100,6 +103,113 @@ func BackupBeforePublish(db *sql.DB, opts BackupOptions) (BackupInfo, error) {
|
|||||||
return Backup(db, opts)
|
return Backup(db, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Service 收拢「系统库 + 赛事库」的备份,并串行化手动、发布前、定时三类触发:
|
||||||
|
// 同一时刻只允许一个 VACUUM INTO 在跑,避免并发快照互相拖慢或写入同一文件。
|
||||||
|
type Service struct {
|
||||||
|
system *sql.DB
|
||||||
|
event *sql.DB
|
||||||
|
dir string
|
||||||
|
keep int
|
||||||
|
logger *slog.Logger
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewService 构造备份服务;keep<=0 时按 DefaultBackupKeep 滚动保留。
|
||||||
|
func NewService(system, event *sql.DB, dir string, keep int, logger *slog.Logger) *Service {
|
||||||
|
if logger == nil {
|
||||||
|
logger = slog.Default()
|
||||||
|
}
|
||||||
|
return &Service{system: system, event: event, dir: dir, keep: keep, logger: logger}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run 备份系统库与赛事库各一份(手动备份、定时备份用)。
|
||||||
|
func (s *Service) Run(reason string) ([]BackupInfo, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.snapshotAll(reason, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunEvent 只备份赛事库(发布前备份用);与 Run 共用同一把锁。
|
||||||
|
func (s *Service) RunEvent(reason string) ([]BackupInfo, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.snapshotAll(reason, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartAuto 跟随 ctx 定时备份:ctx 取消即停止,间隔 <=0 表示关闭。
|
||||||
|
//
|
||||||
|
// 定时备份不写审计:一天下来会产生几百条 system.backup,把审计表刷满;
|
||||||
|
// 手动备份与发布前备份照旧写审计。
|
||||||
|
func (s *Service) StartAuto(ctx context.Context, interval time.Duration) {
|
||||||
|
if interval <= 0 {
|
||||||
|
s.logger.Info("自动备份已关闭", "interval", interval.String())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(s.dir) == "" {
|
||||||
|
s.logger.Warn("未配置数据目录,跳过自动备份")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
infos, err := s.Run("auto")
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("自动备份失败", "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, info := range infos {
|
||||||
|
s.logger.Info("自动备份完成", "path", info.Path, "size", info.Size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) snapshotAll(reason string, withSystem bool) ([]BackupInfo, error) {
|
||||||
|
if strings.TrimSpace(s.dir) == "" {
|
||||||
|
return nil, ErrNoDir
|
||||||
|
}
|
||||||
|
out := make([]BackupInfo, 0, 2)
|
||||||
|
if withSystem && s.system != nil {
|
||||||
|
info, err := s.snapshot(s.system, "system", reason)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
out = append(out, info)
|
||||||
|
}
|
||||||
|
if s.event != nil {
|
||||||
|
info, err := s.snapshot(s.event, "event", reason)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
out = append(out, info)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) snapshot(db *sql.DB, prefix, reason string) (BackupInfo, error) {
|
||||||
|
info, err := Backup(db, BackupOptions{Dir: s.dir, Prefix: prefix, Reason: reason, Keep: s.keep})
|
||||||
|
if err == nil {
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
// 快照已生成、只是滚动清理旧备份失败时 Backup 仍返回有效 info:
|
||||||
|
// 删不掉旧文件不算本次备份失败,记日志即可(下次备份会再试)。
|
||||||
|
if info.Path != "" {
|
||||||
|
s.logger.Warn("清理旧备份失败", "prefix", prefix, "err", err)
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
return BackupInfo{}, err
|
||||||
|
}
|
||||||
|
|
||||||
// ListBackups 按时间倒序列出备份;prefix 为空时列出全部 .db 备份。
|
// ListBackups 按时间倒序列出备份;prefix 为空时列出全部 .db 备份。
|
||||||
// 不计算校验和(避免每次列表都全量读盘),需要校验时用 Backup 的返回值。
|
// 不计算校验和(避免每次列表都全量读盘),需要校验时用 Backup 的返回值。
|
||||||
func ListBackups(dir, prefix string) ([]BackupInfo, error) {
|
func ListBackups(dir, prefix string) ([]BackupInfo, error) {
|
||||||
@@ -150,7 +260,8 @@ func LatestBackup(dir, prefix string) (BackupInfo, bool, error) {
|
|||||||
return list[0], true, nil
|
return list[0], true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// PruneBackups 只保留最近 keep 份,返回被删除的文件路径。
|
// PruneBackups 只保留最近 keep 份,返回被删除的文件路径;
|
||||||
|
// 个别文件删不掉只汇总到返回的错误里,不影响继续清理其余旧备份。
|
||||||
func PruneBackups(dir, prefix string, keep int) ([]string, error) {
|
func PruneBackups(dir, prefix string, keep int) ([]string, error) {
|
||||||
if keep <= 0 {
|
if keep <= 0 {
|
||||||
keep = DefaultBackupKeep
|
keep = DefaultBackupKeep
|
||||||
@@ -164,13 +275,15 @@ func PruneBackups(dir, prefix string, keep int) ([]string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
removed := make([]string, 0, len(list)-keep)
|
removed := make([]string, 0, len(list)-keep)
|
||||||
|
var errs []error
|
||||||
for _, b := range list[keep:] {
|
for _, b := range list[keep:] {
|
||||||
if err := os.Remove(b.Path); err != nil && !os.IsNotExist(err) {
|
if err := os.Remove(b.Path); err != nil && !os.IsNotExist(err) {
|
||||||
return removed, fmt.Errorf("export: 删除旧备份 %s: %w", b.Path, err)
|
errs = append(errs, fmt.Errorf("export: 删除旧备份 %s: %w", b.Path, err))
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
removed = append(removed, b.Path)
|
removed = append(removed, b.Path)
|
||||||
}
|
}
|
||||||
return removed, nil
|
return removed, errors.Join(errs...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func uniqueBackupPath(dir, prefix, reason string, now time.Time) string {
|
func uniqueBackupPath(dir, prefix, reason string, now time.Time) string {
|
||||||
@@ -184,15 +297,27 @@ func uniqueBackupPath(dir, prefix, reason string, now time.Time) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// reasonFromName 从 <prefix>-<reason>-<yyyyMMdd>-<HHmmss>.db 里取回原因。
|
// reasonFromName 从 <prefix>-<reason>-<yyyyMMdd>-<HHmmss>[-<序号>].db 里取回原因;
|
||||||
|
// 同一秒重复备份会多一段序号,所以从右侧找「8 位日期 + 6 位时间」再截断。
|
||||||
func reasonFromName(name, prefix string) string {
|
func reasonFromName(name, prefix string) string {
|
||||||
base := strings.TrimSuffix(name, filepath.Ext(name))
|
base := strings.TrimSuffix(name, filepath.Ext(name))
|
||||||
if prefix != "" {
|
if prefix != "" {
|
||||||
base = strings.TrimPrefix(base, prefix+"-")
|
base = strings.TrimPrefix(base, prefix+"-")
|
||||||
}
|
}
|
||||||
parts := strings.Split(base, "-")
|
parts := strings.Split(base, "-")
|
||||||
if len(parts) >= 3 {
|
for i := len(parts) - 2; i >= 0; i-- {
|
||||||
return strings.Join(parts[:len(parts)-2], "-")
|
if len(parts[i]) == 8 && len(parts[i+1]) == 6 && allDigits(parts[i]) && allDigits(parts[i+1]) {
|
||||||
|
return strings.Join(parts[:i], "-")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func allDigits(s string) bool {
|
||||||
|
for _, r := range s {
|
||||||
|
if r < '0' || r > '9' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s != ""
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
package export
|
package export
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -121,3 +124,133 @@ func TestListBackupsMissingDir(t *testing.T) {
|
|||||||
t.Errorf("空目录的最近备份: ok=%v err=%v", ok, err)
|
t.Errorf("空目录的最近备份: ok=%v err=%v", ok, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 间隔 <=0 关闭任务;未配置备份目录时安全跳过(不 panic、不生成文件)。
|
||||||
|
func TestStartAutoDisabled(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
svc := NewService(nil, nil, dir, 20, testLogger())
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
svc.StartAuto(ctx, 0)
|
||||||
|
svc.StartAuto(ctx, -time.Minute)
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("读取备份目录: %v", err)
|
||||||
|
}
|
||||||
|
if len(entries) != 0 {
|
||||||
|
t.Fatalf("间隔 <=0 不应生成备份,实际 %d 个文件", len(entries))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未配置备份目录:只记日志并跳过,不能 panic。
|
||||||
|
NewService(nil, nil, "", 20, testLogger()).StartAuto(ctx, time.Minute)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 定时任务按间隔生成 auto 备份。
|
||||||
|
func TestStartAutoBacksUp(t *testing.T) {
|
||||||
|
db := openEventDB(t)
|
||||||
|
seedEvent(t, db)
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
NewService(nil, db, dir, 20, testLogger()).StartAuto(ctx, 10*time.Millisecond)
|
||||||
|
|
||||||
|
deadline := time.Now().Add(10 * time.Second)
|
||||||
|
for {
|
||||||
|
list, err := ListBackups(dir, "event")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("列出备份: %v", err)
|
||||||
|
}
|
||||||
|
if len(list) > 0 {
|
||||||
|
if list[0].Reason != "auto" {
|
||||||
|
t.Fatalf("定时备份原因 = %q,期望 auto", list[0].Reason)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatal("10 秒内未生成定时备份")
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 超过保留份数时删除最旧的备份。
|
||||||
|
func TestServiceRunPrunesOldBackups(t *testing.T) {
|
||||||
|
db := openEventDB(t)
|
||||||
|
seedEvent(t, db)
|
||||||
|
dir := t.TempDir()
|
||||||
|
base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.Local)
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if _, err := Backup(db, BackupOptions{
|
||||||
|
Dir: dir, Prefix: "event", Reason: "auto", SkipPrune: true,
|
||||||
|
Now: base.Add(time.Duration(i) * time.Minute),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("预置第 %d 份备份: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := NewService(nil, db, dir, 2, testLogger())
|
||||||
|
if _, err := svc.RunEvent("auto"); err != nil {
|
||||||
|
t.Fatalf("RunEvent: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
list, err := ListBackups(dir, "event")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("列出备份: %v", err)
|
||||||
|
}
|
||||||
|
if len(list) != 2 {
|
||||||
|
t.Fatalf("保留份数 = %d,期望 2", len(list))
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "event-auto-20260101-000000.db")); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("最旧的备份应被删除,err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 并发触发时必须等待同一把锁,保证同一时刻只有一个 VACUUM INTO 在跑。
|
||||||
|
func TestServiceRunMutualExclusion(t *testing.T) {
|
||||||
|
db := openEventDB(t)
|
||||||
|
seedEvent(t, db)
|
||||||
|
svc := NewService(nil, db, t.TempDir(), 20, testLogger())
|
||||||
|
|
||||||
|
svc.mu.Lock()
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
_, err := svc.RunEvent("auto")
|
||||||
|
done <- err
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
t.Fatalf("RunEvent 未等待互斥锁就返回: %v", err)
|
||||||
|
case <-time.After(100 * time.Millisecond):
|
||||||
|
}
|
||||||
|
svc.mu.Unlock()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RunEvent: %v", err)
|
||||||
|
}
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Fatal("RunEvent 超时未完成")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同一秒重复备份会带 -2 之类的序号,原因解析不能把它当成时间的一部分。
|
||||||
|
func TestReasonFromNameWithSequence(t *testing.T) {
|
||||||
|
cases := []struct{ name, prefix, want string }{
|
||||||
|
{"event-auto-20260909-213614.db", "event", "auto"},
|
||||||
|
{"event-auto-20260909-213614-2.db", "event", "auto"},
|
||||||
|
{"event-publish-20260909-213614-10.db", "event", "publish"},
|
||||||
|
{"system-manual-20260909-213614.db", "system", "manual"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := reasonFromName(c.name, c.prefix); got != c.want {
|
||||||
|
t.Errorf("reasonFromName(%q) = %q,期望 %q", c.name, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLogger() *slog.Logger {
|
||||||
|
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@ import "errors"
|
|||||||
var (
|
var (
|
||||||
// ErrNoDatabase 表示调用方传入了空的数据库连接。
|
// ErrNoDatabase 表示调用方传入了空的数据库连接。
|
||||||
ErrNoDatabase = errors.New("export: 数据库连接为空")
|
ErrNoDatabase = errors.New("export: 数据库连接为空")
|
||||||
|
// ErrNoDir 表示未配置备份目录。
|
||||||
|
ErrNoDir = errors.New("export: 备份目录为空")
|
||||||
// ErrInvalidPackage 表示赛事项目包结构非法(缺 meta.yaml、路径穿越、校验信息缺失等)。
|
// ErrInvalidPackage 表示赛事项目包结构非法(缺 meta.yaml、路径穿越、校验信息缺失等)。
|
||||||
ErrInvalidPackage = errors.New("export: 赛事项目包非法")
|
ErrInvalidPackage = errors.New("export: 赛事项目包非法")
|
||||||
// ErrUnsupportedVersion 表示包内 meta.yaml 的格式版本高于当前程序能读的版本。
|
// ErrUnsupportedVersion 表示包内 meta.yaml 的格式版本高于当前程序能读的版本。
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
package httpapi
|
package httpapi
|
||||||
|
|
||||||
import "git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
|
|
||||||
|
|
||||||
// 本文件是 REST 的 JSON 契约(camelCase,与 web/src/shared/types.ts 一一对应)。
|
// 本文件是 REST 的 JSON 契约(camelCase,与 web/src/shared/types.ts 一一对应)。
|
||||||
// 数据库列是 snake_case,转换集中放在 repo.go 的扫描函数里。
|
// 数据库列是 snake_case,转换集中放在 repo.go 的扫描函数里。
|
||||||
|
|
||||||
@@ -135,22 +133,6 @@ type resultDTO struct {
|
|||||||
Points *float64 `json:"points,omitempty"`
|
Points *float64 `json:"points,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type standingRowDTO struct {
|
|
||||||
TeamID int64 `json:"teamId"`
|
|
||||||
TeamCode string `json:"teamCode"`
|
|
||||||
TeamName string `json:"teamName"`
|
|
||||||
ShortName *string `json:"shortName"`
|
|
||||||
Color *string `json:"color"`
|
|
||||||
LogoPath *string `json:"logoPath"`
|
|
||||||
Rank int `json:"rank"`
|
|
||||||
Gold int `json:"gold"`
|
|
||||||
Silver int `json:"silver"`
|
|
||||||
Bronze int `json:"bronze"`
|
|
||||||
MedalTotal int `json:"medalTotal"`
|
|
||||||
TotalPoints float64 `json:"totalPoints"`
|
|
||||||
RankChange *int `json:"rankChange"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type athleteStandingDTO struct {
|
type athleteStandingDTO struct {
|
||||||
Rank int `json:"rank"`
|
Rank int `json:"rank"`
|
||||||
AthleteID int64 `json:"athleteId"`
|
AthleteID int64 `json:"athleteId"`
|
||||||
@@ -254,37 +236,7 @@ type backupResultDTO struct {
|
|||||||
CreatedAt string `json:"createdAt"`
|
CreatedAt string `json:"createdAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// eventSettingsDTO 是 GET/PUT /api/settings 的响应;服务端把 settings 表的点号键聚合成嵌套对象。
|
// GET/PUT /api/settings 的线上结构见 internal/dto.EventSettings(与 snapshot.settings 共用)。
|
||||||
type eventSettingsDTO struct {
|
|
||||||
Event eventSettingsEvent `json:"event"`
|
|
||||||
Display eventSettingsDisplay `json:"display"`
|
|
||||||
Standings eventSettingsStandings `json:"standings"`
|
|
||||||
DefaultProfileID *int64 `json:"defaultProfileId"`
|
|
||||||
RequireApproval bool `json:"requireApproval"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type eventSettingsEvent struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Subtitle *string `json:"subtitle"`
|
|
||||||
Host *string `json:"host"`
|
|
||||||
LogoPath *string `json:"logoPath"`
|
|
||||||
PrimaryColor *string `json:"primaryColor"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type eventSettingsDisplay struct {
|
|
||||||
Background *string `json:"background"`
|
|
||||||
Logo *string `json:"logo"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Subtitle *string `json:"subtitle"`
|
|
||||||
PrimaryColor *string `json:"primaryColor"`
|
|
||||||
RotateSecond int `json:"rotateSeconds"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type eventSettingsStandings struct {
|
|
||||||
IndividualToTeamWeight float64 `json:"individualToTeamWeight"`
|
|
||||||
RankingMode string `json:"rankingMode"`
|
|
||||||
ShowIndividualBoard bool `json:"showIndividualBoard"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- 用户管理 ----------
|
// ---------- 用户管理 ----------
|
||||||
|
|
||||||
@@ -362,41 +314,6 @@ type revisionTeamDeltaDTO struct {
|
|||||||
|
|
||||||
// ---------- domain → DTO ----------
|
// ---------- domain → DTO ----------
|
||||||
|
|
||||||
func standingRowsDTO(rows []domain.StandingsRow, teams map[int64]teamDTO) []standingRowDTO {
|
|
||||||
out := make([]standingRowDTO, 0, len(rows))
|
|
||||||
for _, r := range rows {
|
|
||||||
t := teams[r.TeamID]
|
|
||||||
dto := standingRowDTO{
|
|
||||||
TeamID: r.TeamID,
|
|
||||||
TeamCode: r.Code,
|
|
||||||
TeamName: r.Name,
|
|
||||||
ShortName: nilIfEmpty(r.ShortName),
|
|
||||||
Rank: r.Rank,
|
|
||||||
Gold: r.Gold,
|
|
||||||
Silver: r.Silver,
|
|
||||||
Bronze: r.Bronze,
|
|
||||||
MedalTotal: r.Medals,
|
|
||||||
TotalPoints: r.Points,
|
|
||||||
RankChange: nil,
|
|
||||||
}
|
|
||||||
if t.ID != 0 {
|
|
||||||
dto.Color = t.Color
|
|
||||||
dto.LogoPath = t.LogoPath
|
|
||||||
if dto.TeamCode == "" {
|
|
||||||
dto.TeamCode = t.Code
|
|
||||||
}
|
|
||||||
if dto.TeamName == "" {
|
|
||||||
dto.TeamName = t.Name
|
|
||||||
}
|
|
||||||
if dto.ShortName == nil {
|
|
||||||
dto.ShortName = t.ShortName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out = append(out, dto)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func nilIfEmpty(s string) *string {
|
func nilIfEmpty(s string) *string {
|
||||||
if s == "" {
|
if s == "" {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -1,16 +1,25 @@
|
|||||||
package httpapi
|
package httpapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
|
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
|
||||||
|
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
|
||||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/version"
|
"git.yeij.top/AskaEth/Scoreboard/server/internal/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
// systemRoutes 挂载系统级只读信息。
|
// shutdownTokenHeader 是 Console 优雅关闭握手用的请求头,与环境变量比对。
|
||||||
|
const shutdownTokenHeader = "X-Shutdown-Token"
|
||||||
|
|
||||||
|
// systemRoutes 挂载系统级只读信息与优雅关闭握手。
|
||||||
func (s *server) systemRoutes(mux *http.ServeMux) {
|
func (s *server) systemRoutes(mux *http.ServeMux) {
|
||||||
// 内部署名是编译期注入的,不暴露给匿名访客:至少要登录(recorder 及以上)才能读到
|
// 内部署名是编译期注入的,不暴露给匿名访客:至少要登录(recorder 及以上)才能读到
|
||||||
mux.HandleFunc("GET /api/system/about", s.read(auth.RoleRecorder, s.systemAbout))
|
mux.HandleFunc("GET /api/system/about", s.read(auth.RoleRecorder, s.systemAbout))
|
||||||
|
// 关闭接口自带鉴权(admin 会话或 X-Shutdown-Token),不能走 s.write 的角色中间件。
|
||||||
|
mux.HandleFunc("POST /api/shutdown", s.handle(s.shutdownHandler))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *server) systemAbout(w http.ResponseWriter, r *http.Request) (any, error) {
|
func (s *server) systemAbout(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||||
@@ -18,3 +27,41 @@ func (s *server) systemAbout(w http.ResponseWriter, r *http.Request) (any, error
|
|||||||
"internalSignature": version.InternalSignature,
|
"internalSignature": version.InternalSignature,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// shutdownHandler 处理 POST /api/shutdown:返回 202 后触发 main 注入的优雅关闭回调。
|
||||||
|
func (s *server) shutdownHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||||
|
method, ok := s.shutdownAuth(r)
|
||||||
|
if !ok {
|
||||||
|
// 没带任何凭据的访客按「需登录」处理,其余一律 403,避免探测。
|
||||||
|
if middleware.User(r.Context()).Role == auth.RoleGuest && r.Header.Get(shutdownTokenHeader) == "" {
|
||||||
|
return nil, &apiError{status: http.StatusUnauthorized, code: "AUTH_REQUIRED", message: "请先登录"}
|
||||||
|
}
|
||||||
|
return nil, &apiError{status: http.StatusForbidden, code: "FORBIDDEN", message: "无权关闭服务"}
|
||||||
|
}
|
||||||
|
if s.shutdown == nil {
|
||||||
|
return nil, &apiError{status: http.StatusServiceUnavailable, code: "INTERNAL", message: "服务端未接入优雅关闭"}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.audit(r.Context(), "system.shutdown", "system", 0, nil, map[string]any{"auth": method})
|
||||||
|
writeJSON(w, http.StatusAccepted, map[string]any{"ok": true})
|
||||||
|
// 关闭放到响应写出之后:srv.Shutdown 会等本次请求(含审计落库)结束,不会截断 202。
|
||||||
|
go s.shutdown()
|
||||||
|
return nil, errHandled
|
||||||
|
}
|
||||||
|
|
||||||
|
// shutdownAuth 校验关闭凭据:admin 会话,或与环境变量 SCOREBOARD_SHUTDOWN_TOKEN 常量时间相等的请求头。
|
||||||
|
// 环境变量为空时只认 admin 会话。
|
||||||
|
func (s *server) shutdownAuth(r *http.Request) (string, bool) {
|
||||||
|
if middleware.User(r.Context()).Role.Allows(auth.RoleAdmin) {
|
||||||
|
return "session", true
|
||||||
|
}
|
||||||
|
expected := strings.TrimSpace(os.Getenv("SCOREBOARD_SHUTDOWN_TOKEN"))
|
||||||
|
if expected == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
got := r.Header.Get(shutdownTokenHeader)
|
||||||
|
if got == "" || subtle.ConstantTimeCompare([]byte(got), []byte(expected)) != 1 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return "token", true
|
||||||
|
}
|
||||||
@@ -97,6 +97,7 @@ type settingsStandingsPatch struct {
|
|||||||
IndividualToTeamWeight *float64 `json:"individualToTeamWeight"`
|
IndividualToTeamWeight *float64 `json:"individualToTeamWeight"`
|
||||||
RankingMode *string `json:"rankingMode"`
|
RankingMode *string `json:"rankingMode"`
|
||||||
ShowIndividualBoard *bool `json:"showIndividualBoard"`
|
ShowIndividualBoard *bool `json:"showIndividualBoard"`
|
||||||
|
ShowIndividualMedals *bool `json:"showIndividualMedals"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type categoriesPayload struct {
|
type categoriesPayload struct {
|
||||||
|
|||||||
@@ -4,14 +4,13 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
|
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
|
||||||
|
"git.yeij.top/AskaEth/Scoreboard/server/internal/dto"
|
||||||
|
"git.yeij.top/AskaEth/Scoreboard/server/internal/export"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ---------- settings ----------
|
// ---------- settings ----------
|
||||||
@@ -34,38 +33,13 @@ func loadSettingsMap(ctx context.Context, q queryer) (map[string]string, error)
|
|||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *server) getSettings(ctx context.Context) (eventSettingsDTO, error) {
|
func (s *server) getSettings(ctx context.Context) (dto.EventSettings, error) {
|
||||||
values, err := loadSettingsMap(ctx, s.ev)
|
values, err := loadSettingsMap(ctx, s.ev)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return eventSettingsDTO{}, err
|
return dto.EventSettings{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var out eventSettingsDTO
|
out := dto.ParseEventSettings(values)
|
||||||
out.Event.Name = orDefault(values["event.name"], "未命名赛事")
|
|
||||||
out.Event.Subtitle = nilIfEmpty(values["event.subtitle"])
|
|
||||||
out.Event.Host = nilIfEmpty(values["event.organizer"])
|
|
||||||
out.Event.LogoPath = nilIfEmpty(values["event.logo_path"])
|
|
||||||
out.Event.PrimaryColor = nilIfEmpty(values["display.theme_color"])
|
|
||||||
|
|
||||||
out.Display.Background = nilIfEmpty(values["display.background"])
|
|
||||||
out.Display.Logo = nilIfEmpty(values["display.logo"])
|
|
||||||
if out.Display.Logo == nil {
|
|
||||||
out.Display.Logo = out.Event.LogoPath
|
|
||||||
}
|
|
||||||
out.Display.Title = orDefault(values["display.title"], out.Event.Name)
|
|
||||||
out.Display.Subtitle = nilIfEmpty(values["display.subtitle"])
|
|
||||||
if out.Display.Subtitle == nil {
|
|
||||||
out.Display.Subtitle = out.Event.Subtitle
|
|
||||||
}
|
|
||||||
out.Display.PrimaryColor = out.Event.PrimaryColor
|
|
||||||
out.Display.RotateSecond = atoiDefault(values["display.rotate_seconds"], 15)
|
|
||||||
|
|
||||||
out.Standings.IndividualToTeamWeight = atofDefault(values["standings.individual_to_team_weight"], 1)
|
|
||||||
out.Standings.RankingMode = orDefault(values["standings.mode"], "points")
|
|
||||||
out.Standings.ShowIndividualBoard = boolDefault(
|
|
||||||
firstNonEmpty(values["standings.show_individual_board"], values["standings.show_individual_medals"]), true)
|
|
||||||
|
|
||||||
out.RequireApproval = boolDefault(values["results.review_required"], false)
|
|
||||||
|
|
||||||
if code := values["scoring.default_profile"]; code != "" {
|
if code := values["scoring.default_profile"]; code != "" {
|
||||||
var id int64
|
var id int64
|
||||||
@@ -74,7 +48,7 @@ func (s *server) getSettings(ctx context.Context) (eventSettingsDTO, error) {
|
|||||||
case errors.Is(err, sql.ErrNoRows):
|
case errors.Is(err, sql.ErrNoRows):
|
||||||
// 配置引用的模板已被删除:返回 null,由管理端提示重新选择。
|
// 配置引用的模板已被删除:返回 null,由管理端提示重新选择。
|
||||||
case err != nil:
|
case err != nil:
|
||||||
return eventSettingsDTO{}, internal(err)
|
return dto.EventSettings{}, internal(err)
|
||||||
default:
|
default:
|
||||||
out.DefaultProfileID = &id
|
out.DefaultProfileID = &id
|
||||||
}
|
}
|
||||||
@@ -82,10 +56,10 @@ func (s *server) getSettings(ctx context.Context) (eventSettingsDTO, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *server) updateSettings(ctx context.Context, patch settingsPatch) (eventSettingsDTO, error) {
|
func (s *server) updateSettings(ctx context.Context, patch settingsPatch) (dto.EventSettings, error) {
|
||||||
tx, err := s.ev.BeginTx(ctx, nil)
|
tx, err := s.ev.BeginTx(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return eventSettingsDTO{}, internal(err)
|
return dto.EventSettings{}, internal(err)
|
||||||
}
|
}
|
||||||
defer func() { _ = tx.Rollback() }()
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
@@ -108,40 +82,40 @@ func (s *server) updateSettings(ctx context.Context, patch settingsPatch) (event
|
|||||||
|
|
||||||
if p := patch.Event; p != nil {
|
if p := patch.Event; p != nil {
|
||||||
if err := setPtr("event.name", p.Name); err != nil {
|
if err := setPtr("event.name", p.Name); err != nil {
|
||||||
return eventSettingsDTO{}, err
|
return dto.EventSettings{}, err
|
||||||
}
|
}
|
||||||
if err := setPtr("event.subtitle", p.Subtitle); err != nil {
|
if err := setPtr("event.subtitle", p.Subtitle); err != nil {
|
||||||
return eventSettingsDTO{}, err
|
return dto.EventSettings{}, err
|
||||||
}
|
}
|
||||||
if err := setPtr("event.organizer", p.Host); err != nil {
|
if err := setPtr("event.organizer", p.Host); err != nil {
|
||||||
return eventSettingsDTO{}, err
|
return dto.EventSettings{}, err
|
||||||
}
|
}
|
||||||
if err := setPtr("event.logo_path", p.LogoPath); err != nil {
|
if err := setPtr("event.logo_path", p.LogoPath); err != nil {
|
||||||
return eventSettingsDTO{}, err
|
return dto.EventSettings{}, err
|
||||||
}
|
}
|
||||||
if err := setPtr("display.theme_color", p.PrimaryColor); err != nil {
|
if err := setPtr("display.theme_color", p.PrimaryColor); err != nil {
|
||||||
return eventSettingsDTO{}, err
|
return dto.EventSettings{}, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if p := patch.Display; p != nil {
|
if p := patch.Display; p != nil {
|
||||||
if err := setPtr("display.background", p.Background); err != nil {
|
if err := setPtr("display.background", p.Background); err != nil {
|
||||||
return eventSettingsDTO{}, err
|
return dto.EventSettings{}, err
|
||||||
}
|
}
|
||||||
if err := setPtr("display.logo", p.Logo); err != nil {
|
if err := setPtr("display.logo", p.Logo); err != nil {
|
||||||
return eventSettingsDTO{}, err
|
return dto.EventSettings{}, err
|
||||||
}
|
}
|
||||||
if err := setPtr("display.title", p.Title); err != nil {
|
if err := setPtr("display.title", p.Title); err != nil {
|
||||||
return eventSettingsDTO{}, err
|
return dto.EventSettings{}, err
|
||||||
}
|
}
|
||||||
if err := setPtr("display.subtitle", p.Subtitle); err != nil {
|
if err := setPtr("display.subtitle", p.Subtitle); err != nil {
|
||||||
return eventSettingsDTO{}, err
|
return dto.EventSettings{}, err
|
||||||
}
|
}
|
||||||
if err := setPtr("display.theme_color", p.PrimaryColor); err != nil {
|
if err := setPtr("display.theme_color", p.PrimaryColor); err != nil {
|
||||||
return eventSettingsDTO{}, err
|
return dto.EventSettings{}, err
|
||||||
}
|
}
|
||||||
if p.RotateSeconds != nil {
|
if p.RotateSeconds != nil {
|
||||||
if err := set("display.rotate_seconds", strconv.FormatFloat(*p.RotateSeconds, 'f', -1, 64)); err != nil {
|
if err := set("display.rotate_seconds", strconv.FormatFloat(*p.RotateSeconds, 'f', -1, 64)); err != nil {
|
||||||
return eventSettingsDTO{}, err
|
return dto.EventSettings{}, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -149,15 +123,20 @@ func (s *server) updateSettings(ctx context.Context, patch settingsPatch) (event
|
|||||||
if p.IndividualToTeamWeight != nil {
|
if p.IndividualToTeamWeight != nil {
|
||||||
if err := set("standings.individual_to_team_weight",
|
if err := set("standings.individual_to_team_weight",
|
||||||
strconv.FormatFloat(*p.IndividualToTeamWeight, 'f', -1, 64)); err != nil {
|
strconv.FormatFloat(*p.IndividualToTeamWeight, 'f', -1, 64)); err != nil {
|
||||||
return eventSettingsDTO{}, err
|
return dto.EventSettings{}, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := setPtr("standings.mode", p.RankingMode); err != nil {
|
if err := setPtr("standings.mode", p.RankingMode); err != nil {
|
||||||
return eventSettingsDTO{}, err
|
return dto.EventSettings{}, err
|
||||||
}
|
}
|
||||||
if p.ShowIndividualBoard != nil {
|
if p.ShowIndividualBoard != nil {
|
||||||
if err := set("standings.show_individual_board", strconv.FormatBool(*p.ShowIndividualBoard)); err != nil {
|
if err := set("standings.show_individual_board", strconv.FormatBool(*p.ShowIndividualBoard)); err != nil {
|
||||||
return eventSettingsDTO{}, err
|
return dto.EventSettings{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p.ShowIndividualMedals != nil {
|
||||||
|
if err := set("standings.show_individual_medals", strconv.FormatBool(*p.ShowIndividualMedals)); err != nil {
|
||||||
|
return dto.EventSettings{}, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -166,44 +145,27 @@ func (s *server) updateSettings(ctx context.Context, patch settingsPatch) (event
|
|||||||
if *patch.DefaultProfileID != 0 {
|
if *patch.DefaultProfileID != 0 {
|
||||||
if err := tx.QueryRowContext(ctx, `SELECT code FROM scoring_profiles WHERE id = ?`, *patch.DefaultProfileID).Scan(&code); err != nil {
|
if err := tx.QueryRowContext(ctx, `SELECT code FROM scoring_profiles WHERE id = ?`, *patch.DefaultProfileID).Scan(&code); err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return eventSettingsDTO{}, badRequest("defaultProfileId 不存在", nil)
|
return dto.EventSettings{}, badRequest("defaultProfileId 不存在", nil)
|
||||||
}
|
}
|
||||||
return eventSettingsDTO{}, internal(err)
|
return dto.EventSettings{}, internal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := set("scoring.default_profile", code); err != nil {
|
if err := set("scoring.default_profile", code); err != nil {
|
||||||
return eventSettingsDTO{}, err
|
return dto.EventSettings{}, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if patch.RequireApproval != nil {
|
if patch.RequireApproval != nil {
|
||||||
if err := set("results.review_required", strconv.FormatBool(*patch.RequireApproval)); err != nil {
|
if err := set("results.review_required", strconv.FormatBool(*patch.RequireApproval)); err != nil {
|
||||||
return eventSettingsDTO{}, err
|
return dto.EventSettings{}, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
return eventSettingsDTO{}, internal(err)
|
return dto.EventSettings{}, internal(err)
|
||||||
}
|
}
|
||||||
return s.getSettings(ctx)
|
return s.getSettings(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
func firstNonEmpty(values ...string) string {
|
|
||||||
for _, v := range values {
|
|
||||||
if strings.TrimSpace(v) != "" {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func atoiDefault(v string, def int) int {
|
|
||||||
n, err := strconv.Atoi(strings.TrimSpace(v))
|
|
||||||
if err != nil {
|
|
||||||
return def
|
|
||||||
}
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
|
|
||||||
func atofDefault(v string, def float64) float64 {
|
func atofDefault(v string, def float64) float64 {
|
||||||
n, err := strconv.ParseFloat(strings.TrimSpace(v), 64)
|
n, err := strconv.ParseFloat(strings.TrimSpace(v), 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -261,7 +223,7 @@ func (s *server) loadStandingsInput(ctx context.Context, q queryer) (domain.Stan
|
|||||||
|
|
||||||
// standingsSnapshot 构造与 GET /api/standings 完全同构的整张榜单,
|
// standingsSnapshot 构造与 GET /api/standings 完全同构的整张榜单,
|
||||||
// 供 change_log 的 standings replace 载荷使用(契约要求 data 是榜单数组)。
|
// 供 change_log 的 standings replace 载荷使用(契约要求 data 是榜单数组)。
|
||||||
func (s *server) standingsSnapshot(ctx context.Context, q queryer) ([]standingRowDTO, error) {
|
func (s *server) standingsSnapshot(ctx context.Context, q queryer) ([]dto.StandingRow, error) {
|
||||||
in, err := s.loadStandingsInput(ctx, q)
|
in, err := s.loadStandingsInput(ctx, q)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -270,7 +232,31 @@ func (s *server) standingsSnapshot(ctx context.Context, q queryer) ([]standingRo
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return standingRowsDTO(domain.BuildTeamStandings(in), teams), nil
|
return dto.StandingRows(domain.BuildTeamStandings(in), domainTeamsFromMap(teams)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// domainTeamsFromMap 把 teams 表的 DTO 还原成 domain.Team,供 dto.StandingRows 补齐展示字段。
|
||||||
|
// 这里用全量单位表(含停用单位):榜单兜底会带上流水里的停用单位,否则编码/名称会为空。
|
||||||
|
func domainTeamsFromMap(teams map[int64]teamDTO) []domain.Team {
|
||||||
|
out := make([]domain.Team, 0, len(teams))
|
||||||
|
for _, t := range teams {
|
||||||
|
out = append(out, domain.Team{
|
||||||
|
ID: t.ID,
|
||||||
|
Code: t.Code,
|
||||||
|
Name: t.Name,
|
||||||
|
ShortName: strValue(t.ShortName),
|
||||||
|
LogoPath: strValue(t.LogoPath),
|
||||||
|
Color: strValue(t.Color),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func strValue(s *string) string {
|
||||||
|
if s == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *s
|
||||||
}
|
}
|
||||||
|
|
||||||
// bumpStandings 在写事务内把整张榜单写入 change_log;榜单变化后必须调用,否则大屏不会刷新。
|
// bumpStandings 在写事务内把整张榜单写入 change_log;榜单变化后必须调用,否则大屏不会刷新。
|
||||||
@@ -823,25 +809,19 @@ func (s *server) listAuditLogs(ctx context.Context, page, size int, entityType s
|
|||||||
|
|
||||||
// ---------- backup ----------
|
// ---------- backup ----------
|
||||||
|
|
||||||
// backup 用 VACUUM INTO 生成一致性快照(见 devdocs/08 第 6 节)。
|
// backup 生成系统库与赛事库的一致性快照(见 devdocs/08 第 6 节)。
|
||||||
|
// 手动备份写审计;定时备份不写(见 export.Service.StartAuto 的说明)。
|
||||||
func (s *server) backup(ctx context.Context) (backupResultDTO, error) {
|
func (s *server) backup(ctx context.Context) (backupResultDTO, error) {
|
||||||
if strings.TrimSpace(s.dataDir) == "" {
|
infos, err := s.backups.Run("manual")
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, export.ErrNoDir) {
|
||||||
|
return backupResultDTO{}, internal(errors.New("未配置数据目录"))
|
||||||
|
}
|
||||||
|
return backupResultDTO{}, internal(err)
|
||||||
|
}
|
||||||
|
if len(infos) == 0 {
|
||||||
return backupResultDTO{}, internal(errors.New("未配置数据目录"))
|
return backupResultDTO{}, internal(errors.New("未配置数据目录"))
|
||||||
}
|
}
|
||||||
dir := filepath.Join(s.dataDir, "backups")
|
s.audit(ctx, "system.backup", "backup", 0, nil, map[string]any{"path": infos[0].Path})
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
return backupResultDTO{Path: infos[0].Path, CreatedAt: infos[0].CreatedAt.UTC().Format(time.RFC3339)}, nil
|
||||||
return backupResultDTO{}, internal(err)
|
|
||||||
}
|
|
||||||
now := time.Now()
|
|
||||||
stamp := now.Format("20060102-150405")
|
|
||||||
path := filepath.Join(dir, fmt.Sprintf("system-%s.db", stamp))
|
|
||||||
if _, err := s.sys.ExecContext(ctx, `VACUUM INTO ?`, path); err != nil {
|
|
||||||
return backupResultDTO{}, internal(err)
|
|
||||||
}
|
|
||||||
eventPath := filepath.Join(dir, fmt.Sprintf("event-%s.db", stamp))
|
|
||||||
if _, err := s.ev.ExecContext(ctx, `VACUUM INTO ?`, eventPath); err != nil {
|
|
||||||
return backupResultDTO{}, internal(err)
|
|
||||||
}
|
|
||||||
s.audit(ctx, "system.backup", "backup", 0, nil, map[string]any{"path": path})
|
|
||||||
return backupResultDTO{Path: path, CreatedAt: now.UTC().Format(time.RFC3339)}, nil
|
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
|
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
|
||||||
|
"git.yeij.top/AskaEth/Scoreboard/server/internal/dto"
|
||||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/rules"
|
"git.yeij.top/AskaEth/Scoreboard/server/internal/rules"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -13,6 +14,25 @@ import (
|
|||||||
func (s *server) rulesRoutes(mux *http.ServeMux) {
|
func (s *server) rulesRoutes(mux *http.ServeMux) {
|
||||||
mux.HandleFunc("GET /api/rules/export", s.read(auth.RoleAdmin, s.exportRules))
|
mux.HandleFunc("GET /api/rules/export", s.read(auth.RoleAdmin, s.exportRules))
|
||||||
mux.HandleFunc("POST /api/rules/import", s.write(auth.RoleAdmin, s.importRules))
|
mux.HandleFunc("POST /api/rules/import", s.write(auth.RoleAdmin, s.importRules))
|
||||||
|
// 录入员要判断破纪录加分/人工调整是否启用,但无权读 /api/settings,故单开只读开关。
|
||||||
|
mux.HandleFunc("GET /api/rules/switches", s.read(auth.RoleRecorder, s.ruleSwitches))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ruleSwitches 只返回布尔开关,不下发规则内容、模板明细或文件路径。
|
||||||
|
func (s *server) ruleSwitches(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||||
|
snap, err := rules.Load(s.ev)
|
||||||
|
if err != nil {
|
||||||
|
return nil, internal(err)
|
||||||
|
}
|
||||||
|
pack := snap.Pack
|
||||||
|
return dto.RuleSwitches{
|
||||||
|
RecordBonusEnabled: pack.RecordBonus.Enabled,
|
||||||
|
AdjustmentsEnabled: pack.ManualAdjustment.Enabled,
|
||||||
|
AdjustmentReasonRequired: pack.ManualAdjustment.ReasonRequired,
|
||||||
|
MedalCountsTeamEvents: pack.Standings.MedalCountsTeamEventsEnabled(),
|
||||||
|
ShowIndividualBoard: pack.Standings.ShowIndividualBoardEnabled(),
|
||||||
|
ShowIndividualMedals: pack.Standings.ShowIndividualMedalsEnabled(),
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *server) exportRules(w http.ResponseWriter, r *http.Request) (any, error) {
|
func (s *server) exportRules(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
|
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
|
||||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/export"
|
"git.yeij.top/AskaEth/Scoreboard/server/internal/export"
|
||||||
@@ -454,20 +452,19 @@ func (s *server) publishResults(ctx context.Context, in publishPayload, userID i
|
|||||||
|
|
||||||
// backupBeforePublish 在发布前留一份快照;失败只记日志与审计,不阻塞发布。
|
// backupBeforePublish 在发布前留一份快照;失败只记日志与审计,不阻塞发布。
|
||||||
func (s *server) backupBeforePublish(ctx context.Context) {
|
func (s *server) backupBeforePublish(ctx context.Context) {
|
||||||
if strings.TrimSpace(s.dataDir) == "" {
|
infos, err := s.backups.RunEvent("publish")
|
||||||
return
|
|
||||||
}
|
|
||||||
info, err := export.BackupBeforePublish(s.ev, export.BackupOptions{
|
|
||||||
Dir: filepath.Join(s.dataDir, "backups"),
|
|
||||||
Prefix: "event",
|
|
||||||
Reason: "publish",
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, export.ErrNoDir) {
|
||||||
|
return // 未配置数据目录:静默跳过,不写失败审计
|
||||||
|
}
|
||||||
s.logger.Error("发布前备份失败", "err", err)
|
s.logger.Error("发布前备份失败", "err", err)
|
||||||
s.audit(ctx, "system.backup_failed", "backup", 0, nil, map[string]any{"reason": "publish", "error": err.Error()})
|
s.audit(ctx, "system.backup_failed", "backup", 0, nil, map[string]any{"reason": "publish", "error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.audit(ctx, "system.backup", "backup", 0, nil, map[string]any{"reason": "publish", "path": info.Path})
|
if len(infos) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.audit(ctx, "system.backup", "backup", 0, nil, map[string]any{"reason": "publish", "path": infos[0].Path})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *server) unpublishResults(ctx context.Context, in publishPayload, userID int64) ([]resultDTO, error) {
|
func (s *server) unpublishResults(ctx context.Context, in publishPayload, userID int64) ([]resultDTO, error) {
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ import (
|
|||||||
"io/fs"
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
|
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
|
||||||
|
"git.yeij.top/AskaEth/Scoreboard/server/internal/export"
|
||||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
|
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
|
||||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/webui"
|
"git.yeij.top/AskaEth/Scoreboard/server/internal/webui"
|
||||||
)
|
)
|
||||||
@@ -31,6 +33,11 @@ type Options struct {
|
|||||||
Auth *auth.Service
|
Auth *auth.Service
|
||||||
// DataDir 用于备份目录 data/backups。
|
// DataDir 用于备份目录 data/backups。
|
||||||
DataDir string
|
DataDir string
|
||||||
|
// Backup 可选,手动/发布前备份共用的服务;为空时按 System/Event/DataDir 自动构建。
|
||||||
|
Backup *export.Service
|
||||||
|
// Shutdown 可选,由 main 注入:触发与 SIGINT/SIGTERM 相同的优雅关闭流程。
|
||||||
|
// 为空时 POST /api/shutdown 返回 503,避免把「没接关闭」误报成已受理。
|
||||||
|
Shutdown func()
|
||||||
// Display 可选,实时层接入后传入。
|
// Display 可选,实时层接入后传入。
|
||||||
Display DisplaySender
|
Display DisplaySender
|
||||||
// Realtime 可选,实时层接入后传入:写事务提交后推送变更,并提供在线大屏实例。
|
// Realtime 可选,实时层接入后传入:写事务提交后推送变更,并提供在线大屏实例。
|
||||||
@@ -51,6 +58,8 @@ type server struct {
|
|||||||
auth *auth.Service
|
auth *auth.Service
|
||||||
dataDir string
|
dataDir string
|
||||||
projectDir string
|
projectDir string
|
||||||
|
backups *export.Service
|
||||||
|
shutdown func()
|
||||||
display DisplaySender
|
display DisplaySender
|
||||||
realtime Realtime
|
realtime Realtime
|
||||||
idem *idempotencyStore
|
idem *idempotencyStore
|
||||||
@@ -66,6 +75,14 @@ func New(opts Options) http.Handler {
|
|||||||
if svc == nil {
|
if svc == nil {
|
||||||
svc = auth.New(opts.System, auth.Options{Logger: logger})
|
svc = auth.New(opts.System, auth.Options{Logger: logger})
|
||||||
}
|
}
|
||||||
|
backups := opts.Backup
|
||||||
|
if backups == nil {
|
||||||
|
dir := ""
|
||||||
|
if strings.TrimSpace(opts.DataDir) != "" {
|
||||||
|
dir = filepath.Join(opts.DataDir, "backups")
|
||||||
|
}
|
||||||
|
backups = export.NewService(opts.System, opts.Event, dir, 0, logger)
|
||||||
|
}
|
||||||
|
|
||||||
s := &server{
|
s := &server{
|
||||||
logger: logger,
|
logger: logger,
|
||||||
@@ -75,6 +92,8 @@ func New(opts Options) http.Handler {
|
|||||||
auth: svc,
|
auth: svc,
|
||||||
dataDir: opts.DataDir,
|
dataDir: opts.DataDir,
|
||||||
projectDir: opts.ProjectDir,
|
projectDir: opts.ProjectDir,
|
||||||
|
backups: backups,
|
||||||
|
shutdown: opts.Shutdown,
|
||||||
display: opts.Display,
|
display: opts.Display,
|
||||||
realtime: opts.Realtime,
|
realtime: opts.Realtime,
|
||||||
idem: newIdempotencyStore(10 * time.Minute),
|
idem: newIdempotencyStore(10 * time.Minute),
|
||||||
|
|||||||
@@ -0,0 +1,301 @@
|
|||||||
|
package httpapi_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi"
|
||||||
|
)
|
||||||
|
|
||||||
|
// waitClosed 等待关闭回调触发;没触发就报错。
|
||||||
|
func waitClosed(t *testing.T, done <-chan struct{}) {
|
||||||
|
t.Helper()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("等待关闭回调超时")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertNotClosed(t *testing.T, done <-chan struct{}) {
|
||||||
|
t.Helper()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
t.Fatal("鉴权失败时不应触发关闭")
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitAuditAction 等待审计落库:Auditor 在 handler 返回后才写,存在极短竞态。
|
||||||
|
func waitAuditAction(t *testing.T, env *testEnv, action string) int {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for {
|
||||||
|
var count int
|
||||||
|
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM audit_logs WHERE action = ?`, action).Scan(&count); err != nil {
|
||||||
|
t.Fatalf("查询审计: %v", err)
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatalf("等待审计 %s 超时", action)
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newShutdownEnv(t *testing.T) (*testEnv, chan struct{}) {
|
||||||
|
t.Helper()
|
||||||
|
done := make(chan struct{})
|
||||||
|
var once sync.Once
|
||||||
|
return newTestEnvWith(t, func(o *httpapi.Options) {
|
||||||
|
o.Shutdown = func() { once.Do(func() { close(done) }) }
|
||||||
|
}), done
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/shutdown:admin 会话可关闭;guest 401、recorder 403;成功只回 {"ok":true} 且写审计。
|
||||||
|
func TestShutdownByAdminSession(t *testing.T) {
|
||||||
|
t.Setenv("SCOREBOARD_SHUTDOWN_TOKEN", "")
|
||||||
|
env, done := newShutdownEnv(t)
|
||||||
|
env.createUser("admin", "admin-pass", "admin")
|
||||||
|
client, _ := env.client()
|
||||||
|
|
||||||
|
// 未登录:401,不触发关闭。
|
||||||
|
if status, env2 := env.do(client, http.MethodPost, "/api/shutdown", nil, false); status != http.StatusUnauthorized ||
|
||||||
|
env2.Error == nil || env2.Error.Code != "AUTH_REQUIRED" {
|
||||||
|
t.Fatalf("guest 关闭应 401 AUTH_REQUIRED,得到 %d %+v", status, env2.Error)
|
||||||
|
}
|
||||||
|
assertNotClosed(t, done)
|
||||||
|
|
||||||
|
// recorder:403,不触发关闭。
|
||||||
|
env.createUser("recorder", "recorder-pass", "recorder")
|
||||||
|
rec, _ := env.client()
|
||||||
|
env.login(rec, "recorder", "recorder-pass")
|
||||||
|
if status, env2 := env.do(rec, http.MethodPost, "/api/shutdown", nil, true); status != http.StatusForbidden ||
|
||||||
|
env2.Error == nil || env2.Error.Code != "FORBIDDEN" {
|
||||||
|
t.Fatalf("recorder 关闭应 403 FORBIDDEN,得到 %d %+v", status, env2.Error)
|
||||||
|
}
|
||||||
|
assertNotClosed(t, done)
|
||||||
|
|
||||||
|
// admin:202 + 裸 {"ok":true}(不套 data 包装),并触发关闭回调。
|
||||||
|
env.login(client, "admin", "admin-pass")
|
||||||
|
status, raw := env.doRaw(client, http.MethodPost, "/api/shutdown", []byte("{}"), "application/json", true)
|
||||||
|
if status != http.StatusAccepted {
|
||||||
|
t.Fatalf("admin 关闭应 202,得到 %d %s", status, raw)
|
||||||
|
}
|
||||||
|
if got := strings.TrimSpace(string(raw)); got != `{"ok":true}` {
|
||||||
|
t.Fatalf("关闭响应应为 {\"ok\":true},得到 %s", got)
|
||||||
|
}
|
||||||
|
waitClosed(t, done)
|
||||||
|
|
||||||
|
if count := waitAuditAction(t, env, "system.shutdown"); count != 1 {
|
||||||
|
t.Fatalf("关闭应写 1 条 system.shutdown 审计,得到 %d", count)
|
||||||
|
}
|
||||||
|
var after string
|
||||||
|
if err := env.ev.QueryRow(`SELECT after_json FROM audit_logs WHERE action = 'system.shutdown' ORDER BY id DESC LIMIT 1`).Scan(&after); err != nil {
|
||||||
|
t.Fatalf("读取关闭审计: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(after, `"session"`) {
|
||||||
|
t.Fatalf("关闭审计应标明凭据来源,得到 %s", after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 环境变量配置后:X-Shutdown-Token 正确即可关闭(无需会话),错误则 403。
|
||||||
|
func TestShutdownByToken(t *testing.T) {
|
||||||
|
t.Setenv("SCOREBOARD_SHUTDOWN_TOKEN", "s3cret-token")
|
||||||
|
env, done := newShutdownEnv(t)
|
||||||
|
client, _ := env.client()
|
||||||
|
|
||||||
|
if status, _ := env.doH(client, http.MethodPost, "/api/shutdown", nil, false,
|
||||||
|
map[string]string{"X-Shutdown-Token": "wrong"}); status != http.StatusForbidden {
|
||||||
|
t.Fatalf("错误 token 应 403,得到 %d", status)
|
||||||
|
}
|
||||||
|
assertNotClosed(t, done)
|
||||||
|
|
||||||
|
status, env2 := env.doH(client, http.MethodPost, "/api/shutdown", nil, false,
|
||||||
|
map[string]string{"X-Shutdown-Token": "s3cret-token"})
|
||||||
|
if status != http.StatusAccepted {
|
||||||
|
t.Fatalf("正确 token 应 202,得到 %d %+v", status, env2.Error)
|
||||||
|
}
|
||||||
|
waitClosed(t, done)
|
||||||
|
|
||||||
|
// 没注入关闭回调时应拒绝受理,避免谎报成功。
|
||||||
|
bare := newTestEnv(t)
|
||||||
|
bareClient, _ := bare.client()
|
||||||
|
if status, _ := bare.doH(bareClient, http.MethodPost, "/api/shutdown", nil, false,
|
||||||
|
map[string]string{"X-Shutdown-Token": "s3cret-token"}); status != http.StatusServiceUnavailable {
|
||||||
|
t.Fatalf("未接入关闭回调应 503,得到 %d", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/rules/switches:recorder 可读、guest 403,只返回布尔开关。
|
||||||
|
func TestRuleSwitchesReadableByRecorder(t *testing.T) {
|
||||||
|
env := newTestEnv(t)
|
||||||
|
env.createUser("recorder", "recorder-pass", "recorder")
|
||||||
|
|
||||||
|
guest, _ := env.client()
|
||||||
|
if status, _ := env.do(guest, http.MethodGet, "/api/rules/switches", nil, false); status != http.StatusForbidden {
|
||||||
|
t.Fatalf("guest 读规则开关应 403,得到 %d", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
client, _ := env.client()
|
||||||
|
env.login(client, "recorder", "recorder-pass")
|
||||||
|
status, env2 := env.do(client, http.MethodGet, "/api/rules/switches", nil, false)
|
||||||
|
if status != http.StatusOK || !env2.OK {
|
||||||
|
t.Fatalf("recorder 读规则开关应 200,得到 %d %+v", status, env2.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
var switches map[string]any
|
||||||
|
if err := json.Unmarshal(env2.Data, &switches); err != nil {
|
||||||
|
t.Fatalf("解析规则开关: %v (%s)", err, env2.Data)
|
||||||
|
}
|
||||||
|
want := []string{
|
||||||
|
"recordBonusEnabled", "adjustmentsEnabled", "adjustmentReasonRequired",
|
||||||
|
"medalCountsTeamEvents", "showIndividualBoard", "showIndividualMedals",
|
||||||
|
}
|
||||||
|
if len(switches) != len(want) {
|
||||||
|
t.Fatalf("只应返回布尔开关,得到 %v", switches)
|
||||||
|
}
|
||||||
|
for _, key := range want {
|
||||||
|
v, ok := switches[key]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("缺少开关 %s: %v", key, switches)
|
||||||
|
}
|
||||||
|
if _, isBool := v.(bool); !isBool {
|
||||||
|
t.Fatalf("开关 %s 应为布尔值,得到 %T", key, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 默认:破纪录加分与人工调整关闭,理由必填、奖牌统计与两块个人榜开关开启。
|
||||||
|
if switches["recordBonusEnabled"] != false || switches["adjustmentsEnabled"] != false ||
|
||||||
|
switches["adjustmentReasonRequired"] != true || switches["medalCountsTeamEvents"] != true ||
|
||||||
|
switches["showIndividualBoard"] != true || switches["showIndividualMedals"] != true {
|
||||||
|
t.Fatalf("默认开关取值不符: %v", switches)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 规则导入/配置改动后开关随之变化。
|
||||||
|
env.exec(t, `UPDATE settings SET value = 'true' WHERE key = 'record_bonus.enabled'`)
|
||||||
|
env.exec(t, `UPDATE settings SET value = 'true' WHERE key = 'manual_adjustment.enabled'`)
|
||||||
|
_, env2 = env.do(client, http.MethodGet, "/api/rules/switches", nil, false)
|
||||||
|
if err := json.Unmarshal(env2.Data, &switches); err != nil {
|
||||||
|
t.Fatalf("解析规则开关: %v", err)
|
||||||
|
}
|
||||||
|
if switches["recordBonusEnabled"] != true || switches["adjustmentsEnabled"] != true {
|
||||||
|
t.Fatalf("开关应随 settings 变化: %v", switches)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/settings 与 /api/standings 的 JSON 字段名是对外契约,收敛 DTO 时不能变。
|
||||||
|
func TestSettingsAndStandingsJSONContract(t *testing.T) {
|
||||||
|
env := newTestEnv(t)
|
||||||
|
env.seed(t)
|
||||||
|
env.createUser("admin", "admin-pass", "admin")
|
||||||
|
client, _ := env.client()
|
||||||
|
env.login(client, "admin", "admin-pass")
|
||||||
|
|
||||||
|
_, env2 := env.do(client, http.MethodGet, "/api/settings", nil, false)
|
||||||
|
var settings map[string]any
|
||||||
|
if err := json.Unmarshal(env2.Data, &settings); err != nil {
|
||||||
|
t.Fatalf("解析 settings: %v", err)
|
||||||
|
}
|
||||||
|
assertKeys(t, "settings", settings, []string{"event", "display", "standings", "defaultProfileId", "requireApproval"})
|
||||||
|
assertKeys(t, "settings.event", asMap(t, settings["event"]),
|
||||||
|
[]string{"name", "subtitle", "host", "logoPath", "primaryColor"})
|
||||||
|
assertKeys(t, "settings.display", asMap(t, settings["display"]),
|
||||||
|
[]string{"background", "logo", "title", "subtitle", "primaryColor", "rotateSeconds"})
|
||||||
|
assertKeys(t, "settings.standings", asMap(t, settings["standings"]),
|
||||||
|
[]string{"individualToTeamWeight", "rankingMode", "showIndividualBoard", "showIndividualMedals"})
|
||||||
|
|
||||||
|
_, env2 = env.do(client, http.MethodGet, "/api/standings", nil, false)
|
||||||
|
var rows []map[string]any
|
||||||
|
if err := json.Unmarshal(env2.Data, &rows); err != nil {
|
||||||
|
t.Fatalf("解析 standings: %v", err)
|
||||||
|
}
|
||||||
|
if len(rows) == 0 {
|
||||||
|
t.Fatal("榜单不应为空")
|
||||||
|
}
|
||||||
|
assertKeys(t, "standings[0]", rows[0], []string{
|
||||||
|
"teamId", "teamCode", "teamName", "shortName", "color", "logoPath",
|
||||||
|
"rank", "gold", "silver", "bronze", "medalTotal", "totalPoints", "rankChange",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func asMap(t *testing.T, v any) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
m, ok := v.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("期望对象,得到 %T", v)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertKeys(t *testing.T, name string, got map[string]any, want []string) {
|
||||||
|
t.Helper()
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("%s 字段集不符:want=%v got=%v", name, want, got)
|
||||||
|
}
|
||||||
|
for _, key := range want {
|
||||||
|
if _, ok := got[key]; !ok {
|
||||||
|
t.Fatalf("%s 缺少字段 %s:%v", name, key, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type settingsStandings struct {
|
||||||
|
Standings struct {
|
||||||
|
ShowIndividualBoard bool `json:"showIndividualBoard"`
|
||||||
|
ShowIndividualMedals bool `json:"showIndividualMedals"`
|
||||||
|
} `json:"standings"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// standings.showIndividualMedals 贯通 /api/settings 读写,且不破坏 showIndividualBoard 的别名语义。
|
||||||
|
func TestSettingsShowIndividualMedals(t *testing.T) {
|
||||||
|
env := newTestEnv(t)
|
||||||
|
env.createUser("admin", "admin-pass", "admin")
|
||||||
|
client, _ := env.client()
|
||||||
|
env.login(client, "admin", "admin-pass")
|
||||||
|
|
||||||
|
status, env2 := env.do(client, http.MethodGet, "/api/settings", nil, false)
|
||||||
|
if status != http.StatusOK {
|
||||||
|
t.Fatalf("读配置应 200,得到 %d", status)
|
||||||
|
}
|
||||||
|
settings := decodeData[settingsStandings](t, env2)
|
||||||
|
if !settings.Standings.ShowIndividualBoard || !settings.Standings.ShowIndividualMedals {
|
||||||
|
t.Fatalf("默认两个开关都应为 true: %+v", settings.Standings)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 旧客户端只传 showIndividualBoard:不得影响新的 showIndividualMedals。
|
||||||
|
status, env2 = env.do(client, http.MethodPut, "/api/settings", map[string]any{
|
||||||
|
"standings": map[string]any{"showIndividualBoard": false},
|
||||||
|
}, true)
|
||||||
|
if status != http.StatusOK {
|
||||||
|
t.Fatalf("保存配置应 200,得到 %d %+v", status, env2.Error)
|
||||||
|
}
|
||||||
|
settings = decodeData[settingsStandings](t, env2)
|
||||||
|
if settings.Standings.ShowIndividualBoard || !settings.Standings.ShowIndividualMedals {
|
||||||
|
t.Fatalf("只改 board 不应影响 medals: %+v", settings.Standings)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 两个开关可以独立配置:board=true + medals=false。
|
||||||
|
status, env2 = env.do(client, http.MethodPut, "/api/settings", map[string]any{
|
||||||
|
"standings": map[string]any{"showIndividualBoard": true, "showIndividualMedals": false},
|
||||||
|
}, true)
|
||||||
|
if status != http.StatusOK {
|
||||||
|
t.Fatalf("保存配置应 200,得到 %d %+v", status, env2.Error)
|
||||||
|
}
|
||||||
|
settings = decodeData[settingsStandings](t, env2)
|
||||||
|
if !settings.Standings.ShowIndividualBoard || settings.Standings.ShowIndividualMedals {
|
||||||
|
t.Fatalf("两个开关应可独立配置: %+v", settings.Standings)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 落库并复读。
|
||||||
|
_, env2 = env.do(client, http.MethodGet, "/api/settings", nil, false)
|
||||||
|
settings = decodeData[settingsStandings](t, env2)
|
||||||
|
if !settings.Standings.ShowIndividualBoard || settings.Standings.ShowIndividualMedals {
|
||||||
|
t.Fatalf("复读配置不符: %+v", settings.Standings)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,6 +54,7 @@ type Hub struct {
|
|||||||
slowLag int64
|
slowLag int64
|
||||||
helloTO time.Duration
|
helloTO time.Duration
|
||||||
writeTO time.Duration
|
writeTO time.Duration
|
||||||
|
allowedOrigins []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHub 创建实时中心并启动心跳循环;db 是赛事库(change_log 所在库)。
|
// NewHub 创建实时中心并启动心跳循环;db 是赛事库(change_log 所在库)。
|
||||||
@@ -160,6 +161,14 @@ func (h *Hub) SetSlowLag(n int64) {
|
|||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetAllowedOrigins 设置 WebSocket 跨站 Origin 白名单(path.Match 模式);
|
||||||
|
// 同源与不带 Origin 的握手始终放行,不受此设置影响。
|
||||||
|
func (h *Hub) SetAllowedOrigins(origins []string) {
|
||||||
|
h.mu.Lock()
|
||||||
|
h.allowedOrigins = append([]string(nil), origins...)
|
||||||
|
h.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Hub) heartbeatIntervalValue() time.Duration {
|
func (h *Hub) heartbeatIntervalValue() time.Duration {
|
||||||
h.mu.RLock()
|
h.mu.RLock()
|
||||||
defer h.mu.RUnlock()
|
defer h.mu.RUnlock()
|
||||||
@@ -190,16 +199,22 @@ func (h *Hub) writeTimeout() time.Duration {
|
|||||||
return h.writeTO
|
return h.writeTO
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Hub) allowedOriginsValue() []string {
|
||||||
|
h.mu.RLock()
|
||||||
|
defer h.mu.RUnlock()
|
||||||
|
return h.allowedOrigins
|
||||||
|
}
|
||||||
|
|
||||||
// Handler 返回 WebSocket 处理器,由调用方挂到 /ws 上。
|
// Handler 返回 WebSocket 处理器,由调用方挂到 /ws 上。
|
||||||
func (h *Hub) Handler() http.Handler {
|
func (h *Hub) Handler() http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
|
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
|
||||||
// 大屏常以 IP/别名访问且与管理端同端口,Origin 校验交给反向代理与鉴权层。
|
// 同源与不带 Origin 的握手由库放行;跨站需命中 server.allowedOrigins 白名单。
|
||||||
InsecureSkipVerify: true,
|
OriginPatterns: h.allowedOriginsValue(),
|
||||||
CompressionMode: websocket.CompressionDisabled,
|
CompressionMode: websocket.CompressionDisabled,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.log.Debug("WebSocket 握手失败", "remote", r.RemoteAddr, "err", err)
|
h.log.Debug("WebSocket 握手失败", "remote", r.RemoteAddr, "origin", r.Header.Get("Origin"), "err", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package realtime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/coder/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
// dialOrigin 以指定 Origin 发起握手;origin 为空表示不带 Origin 头(非浏览器客户端)。
|
||||||
|
func dialOrigin(t *testing.T, srv *httptest.Server, origin string) error {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
opts := &websocket.DialOptions{}
|
||||||
|
if origin != "" {
|
||||||
|
opts.HTTPHeader = http.Header{"Origin": []string{origin}}
|
||||||
|
}
|
||||||
|
conn, _, err := websocket.Dial(ctx, "ws"+strings.TrimPrefix(srv.URL, "http")+"/ws", opts)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_ = conn.CloseNow()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlerOrigin(t *testing.T) {
|
||||||
|
t.Run("同源放行", func(t *testing.T) {
|
||||||
|
e := newTestEnv(t)
|
||||||
|
// httptest.Server 的 URL 即页面 Origin:scheme + host 与请求 Host 同源。
|
||||||
|
if err := dialOrigin(t, e.srv, e.srv.URL); err != nil {
|
||||||
|
t.Fatalf("同源 Origin 应放行: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("空 Origin 放行", func(t *testing.T) {
|
||||||
|
e := newTestEnv(t)
|
||||||
|
if err := dialOrigin(t, e.srv, ""); err != nil {
|
||||||
|
t.Fatalf("不带 Origin 的客户端应放行: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("跨站拒绝", func(t *testing.T) {
|
||||||
|
e := newTestEnv(t)
|
||||||
|
if err := dialOrigin(t, e.srv, "http://evil.example"); err == nil {
|
||||||
|
t.Fatal("跨站 Origin 应拒绝")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("白名单放行", func(t *testing.T) {
|
||||||
|
e := newTestEnv(t)
|
||||||
|
e.hub.SetAllowedOrigins([]string{"http://evil.example"})
|
||||||
|
if err := dialOrigin(t, e.srv, "http://evil.example"); err != nil {
|
||||||
|
t.Fatalf("白名单内 Origin 应放行: %v", err)
|
||||||
|
}
|
||||||
|
if err := dialOrigin(t, e.srv, "http://other.example"); err == nil {
|
||||||
|
t.Fatal("白名单外的跨站 Origin 应拒绝")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
+17
-7
@@ -85,6 +85,7 @@ func main() {
|
|||||||
|
|
||||||
authSvc := auth.New(st.DB(), auth.Options{Logger: logger})
|
authSvc := auth.New(st.DB(), auth.Options{Logger: logger})
|
||||||
hub := realtime.NewHub(eventDB, logger)
|
hub := realtime.NewHub(eventDB, logger)
|
||||||
|
hub.SetAllowedOrigins(cfg.Server.AllowedOrigins)
|
||||||
defer func() { _ = hub.Close() }()
|
defer func() { _ = hub.Close() }()
|
||||||
|
|
||||||
// 可信代理:默认空,表示不采信任何代理头(见 devdocs/08 第 1 节)。
|
// 可信代理:默认空,表示不采信任何代理头(见 devdocs/08 第 1 节)。
|
||||||
@@ -110,15 +111,20 @@ func main() {
|
|||||||
logger.Warn("已创建默认管理员", "username", username, "password", password)
|
logger.Warn("已创建默认管理员", "username", username, "password", password)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
backupSvc := export.NewService(st.DB(), eventDB, filepath.Join(cfg.DataDir, "backups"), cfg.Backup.Keep, logger)
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: cfg.Server.Addr(),
|
Addr: cfg.Server.Addr(),
|
||||||
Handler: httpapi.New(httpapi.Options{
|
Handler: httpapi.New(httpapi.Options{
|
||||||
Logger: logger,
|
Logger: logger,
|
||||||
Version: version,
|
Version: version,
|
||||||
System: st.DB(),
|
System: st.DB(),
|
||||||
Event: eventDB,
|
Event: eventDB,
|
||||||
Auth: authSvc,
|
Auth: authSvc,
|
||||||
DataDir: cfg.DataDir,
|
DataDir: cfg.DataDir,
|
||||||
|
Backup: backupSvc,
|
||||||
|
// POST /api/shutdown 复用同一个关闭信号:取消 ctx 后走下面的 Shutdown + 关库。
|
||||||
|
Shutdown: stop,
|
||||||
ProjectDir: filepath.Join(cfg.DataDir, "event_projs", *project),
|
ProjectDir: filepath.Join(cfg.DataDir, "event_projs", *project),
|
||||||
Display: displaySender{hub: hub},
|
Display: displaySender{hub: hub},
|
||||||
Realtime: hub,
|
Realtime: hub,
|
||||||
@@ -127,6 +133,9 @@ func main() {
|
|||||||
ReadHeaderTimeout: 10 * time.Second,
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 定时备份跟随服务端生命周期:收到退出信号(ctx 取消)即停止。
|
||||||
|
backupSvc.StartAuto(ctx, time.Duration(cfg.Backup.IntervalMinutes)*time.Minute)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
logger.Info("服务启动", "addr", srv.Addr, "version", version)
|
logger.Info("服务启动", "addr", srv.Addr, "version", version)
|
||||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
@@ -138,7 +147,8 @@ func main() {
|
|||||||
<-ctx.Done()
|
<-ctx.Done()
|
||||||
|
|
||||||
logger.Info("正在关闭…")
|
logger.Info("正在关闭…")
|
||||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
// Console 的关闭握手要求 3 秒内完成,超时后仍会关库并退出(defer 顺序保证)。
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||||
logger.Error("关闭超时", "err", err)
|
logger.Error("关闭超时", "err", err)
|
||||||
|
|||||||
@@ -237,7 +237,12 @@ const SETTINGS: EventSettings = {
|
|||||||
primaryColor: '#3482FF',
|
primaryColor: '#3482FF',
|
||||||
rotateSeconds: 15,
|
rotateSeconds: 15,
|
||||||
},
|
},
|
||||||
standings: { individualToTeamWeight: 1, rankingMode: 'points', showIndividualBoard: true },
|
standings: {
|
||||||
|
individualToTeamWeight: 1,
|
||||||
|
rankingMode: 'points',
|
||||||
|
showIndividualBoard: true,
|
||||||
|
showIndividualMedals: true,
|
||||||
|
},
|
||||||
defaultProfileId: 1,
|
defaultProfileId: 1,
|
||||||
requireApproval: false,
|
requireApproval: false,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -184,6 +184,16 @@ async function save(): Promise<void> {
|
|||||||
<span class="switch-text">{{ form.standings.showIndividualBoard ? '展示' : '隐藏' }}</span>
|
<span class="switch-text">{{ form.standings.showIndividualBoard ? '展示' : '隐藏' }}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field switch-field">
|
||||||
|
<span class="mx-label">个人榜奖牌列</span>
|
||||||
|
<span class="switch-row">
|
||||||
|
<label class="mx-switch">
|
||||||
|
<input v-model="form.standings.showIndividualMedals" type="checkbox" />
|
||||||
|
<span class="mx-switch-track"><span class="mx-switch-thumb"></span></span>
|
||||||
|
</label>
|
||||||
|
<span class="switch-text">{{ form.standings.showIndividualMedals ? '展示' : '隐藏' }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="notice">
|
<p class="notice">
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
type AdjustmentCategory,
|
type AdjustmentCategory,
|
||||||
type PointAdjustment,
|
type PointAdjustment,
|
||||||
type RulePack,
|
type RulePack,
|
||||||
|
type RuleSwitches,
|
||||||
} from '@/shared'
|
} from '@/shared'
|
||||||
import MxIcon from '../components/MxIcon.vue'
|
import MxIcon from '../components/MxIcon.vue'
|
||||||
import PageHeader from '../components/PageHeader.vue'
|
import PageHeader from '../components/PageHeader.vue'
|
||||||
@@ -32,6 +33,7 @@ const size = 20
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
const rulePack = ref<RulePack | null>(null)
|
const rulePack = ref<RulePack | null>(null)
|
||||||
|
const ruleSwitches = ref<RuleSwitches | null>(null)
|
||||||
const ruleError = ref<string | null>(null)
|
const ruleError = ref<string | null>(null)
|
||||||
const search = ref('')
|
const search = ref('')
|
||||||
const categoryFilter = ref<AdjustmentCategory | 'all'>('all')
|
const categoryFilter = ref<AdjustmentCategory | 'all'>('all')
|
||||||
@@ -57,12 +59,12 @@ const canWrite = computed(() => !auth.readOnly && data.canWrite)
|
|||||||
/**
|
/**
|
||||||
* 人工调整开关:true / false / null(未知)。
|
* 人工调整开关:true / false / null(未知)。
|
||||||
*
|
*
|
||||||
* 规则包只有 admin 能读(GET /api/rules/export 是 admin-only),录入员必然 403——
|
* 优先取 GET /api/rules/switches(recorder 可读);读不到时再退回完整规则包(admin)。
|
||||||
* 不能据此断言「已关闭」,那会把已启用的功能谎报成关闭。读不到时为 null,
|
* 两者都没有则为 null,界面如实显示「未知」,最终由服务端校验决定是否受理。
|
||||||
* 界面如实显示「未知」,最终由服务端校验决定是否受理。
|
|
||||||
* 后端补一个 recorder 可读的只读开关端点后,把 loadRules 换成读该端点即可。
|
|
||||||
*/
|
*/
|
||||||
const adjustmentEnabled = computed<boolean | null>(() => rulePack.value?.manualAdjustment.enabled ?? null)
|
const adjustmentEnabled = computed<boolean | null>(
|
||||||
|
() => ruleSwitches.value?.adjustmentsEnabled ?? rulePack.value?.manualAdjustment.enabled ?? null,
|
||||||
|
)
|
||||||
|
|
||||||
const items = computed(() => [...localItems.value, ...serverItems.value])
|
const items = computed(() => [...localItems.value, ...serverItems.value])
|
||||||
|
|
||||||
@@ -122,6 +124,12 @@ async function loadRules(): Promise<void> {
|
|||||||
rulePack.value = null
|
rulePack.value = null
|
||||||
ruleError.value = caught instanceof Error ? caught.message : '无法读取规则包'
|
ruleError.value = caught instanceof Error ? caught.message : '无法读取规则包'
|
||||||
}
|
}
|
||||||
|
// 规则包只有 admin 能读;录入员改用只读开关端点,避免把已启用功能误报成关闭。
|
||||||
|
try {
|
||||||
|
ruleSwitches.value = await adminApi.client.rules.switches()
|
||||||
|
} catch {
|
||||||
|
ruleSwitches.value = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function load(pageNumber = page.value): Promise<void> {
|
async function load(pageNumber = page.value): Promise<void> {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
type RecordEntry,
|
type RecordEntry,
|
||||||
type RecordLevel,
|
type RecordLevel,
|
||||||
type RulePack,
|
type RulePack,
|
||||||
|
type RuleSwitches,
|
||||||
} from '@/shared'
|
} from '@/shared'
|
||||||
import MxIcon from '../components/MxIcon.vue'
|
import MxIcon from '../components/MxIcon.vue'
|
||||||
import PageHeader from '../components/PageHeader.vue'
|
import PageHeader from '../components/PageHeader.vue'
|
||||||
@@ -64,6 +65,7 @@ const suspected = ref<SuspectedBreak[]>([])
|
|||||||
const suspectedLoading = ref(false)
|
const suspectedLoading = ref(false)
|
||||||
const suspectedError = ref<string | null>(null)
|
const suspectedError = ref<string | null>(null)
|
||||||
const rulePack = ref<RulePack | null>(null)
|
const rulePack = ref<RulePack | null>(null)
|
||||||
|
const ruleSwitches = ref<RuleSwitches | null>(null)
|
||||||
const ruleError = ref<string | null>(null)
|
const ruleError = ref<string | null>(null)
|
||||||
const rulesLoading = ref(false)
|
const rulesLoading = ref(false)
|
||||||
const search = ref('')
|
const search = ref('')
|
||||||
@@ -79,12 +81,12 @@ const canWrite = computed(() => !auth.readOnly && data.canWrite)
|
|||||||
/**
|
/**
|
||||||
* 破纪录加分开关:true / false / null(未知)。
|
* 破纪录加分开关:true / false / null(未知)。
|
||||||
*
|
*
|
||||||
* 规则包只有 admin 能读(GET /api/rules/export 是 admin-only),
|
* 优先取 GET /api/rules/switches(recorder 可读);读不到时再退回完整规则包(admin)。
|
||||||
* 录入员必然 403——不能据此断言「已关闭」,那会把已启用的功能谎报成关闭。
|
* 两者都没有则为 null,界面如实显示「未知」,最终由服务端校验决定是否加分。
|
||||||
* 读不到时为 null,界面如实显示「未知」,最终由服务端校验决定是否加分。
|
|
||||||
* 后端补一个 recorder 可读的只读开关端点后,把 loadRules 换成读该端点即可。
|
|
||||||
*/
|
*/
|
||||||
const recordBonusEnabled = computed<boolean | null>(() => rulePack.value?.recordBonus.enabled ?? null)
|
const recordBonusEnabled = computed<boolean | null>(
|
||||||
|
() => ruleSwitches.value?.recordBonusEnabled ?? rulePack.value?.recordBonus.enabled ?? null,
|
||||||
|
)
|
||||||
const bonusMode = computed(() =>
|
const bonusMode = computed(() =>
|
||||||
rulePack.value === null ? '—' : rulePack.value.recordBonus.mode === 'sum' ? '多档累加' : '取最高档',
|
rulePack.value === null ? '—' : rulePack.value.recordBonus.mode === 'sum' ? '多档累加' : '取最高档',
|
||||||
)
|
)
|
||||||
@@ -167,6 +169,12 @@ async function loadRules(): Promise<void> {
|
|||||||
} finally {
|
} finally {
|
||||||
rulesLoading.value = false
|
rulesLoading.value = false
|
||||||
}
|
}
|
||||||
|
// 规则包只有 admin 能读;录入员改用只读开关端点,避免把已启用功能误报成关闭。
|
||||||
|
try {
|
||||||
|
ruleSwitches.value = await adminApi.client.rules.switches()
|
||||||
|
} catch {
|
||||||
|
ruleSwitches.value = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadCategoryLabels(eventIds: number[]): Promise<void> {
|
async function loadCategoryLabels(eventIds: number[]): Promise<void> {
|
||||||
|
|||||||
@@ -8,11 +8,8 @@ import PageFrame from '../PageFrame.vue'
|
|||||||
const { athletes, settings } = useScoreboard()
|
const { athletes, settings } = useScoreboard()
|
||||||
|
|
||||||
const rows = computed(() => athletes.value)
|
const rows = computed(() => athletes.value)
|
||||||
// 奖牌列由独立开关控制;旧配置没有 showIndividualMedals 时回退到「个人榜上屏」开关,避免升级后突然多出一列。
|
// 奖牌列由独立开关控制;缺字段时的回退已在 guards.parseSettings 里按「跟随个人榜上屏」处理。
|
||||||
const showMedals = computed(() => {
|
const showMedals = computed(() => settings.value?.standings.showIndividualMedals ?? true)
|
||||||
const standings = settings.value?.standings
|
|
||||||
return standings?.showIndividualMedals ?? standings?.showIndividualBoard ?? true
|
|
||||||
})
|
|
||||||
|
|
||||||
const rowFont = computed(() => fitRowFont(rows.value.length, 560))
|
const rowFont = computed(() => fitRowFont(rows.value.length, 560))
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
+13
-14
@@ -296,14 +296,10 @@ export function parseRecordEntry(source: unknown): RecordEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 大屏侧扩展的赛事配置。
|
* 大屏侧赛事配置。字段与 shared 契约一致;
|
||||||
*
|
* `showIndividualMedals` 缺失时由 parseSettings 回退到 `showIndividualBoard`(旧服务端只下发后者)。
|
||||||
* `standings.showIndividualMedals` 还没进入 shared 契约,但大屏需要它独立控制奖牌列;
|
|
||||||
* 声明成可选,旧配置(服务端只给 showIndividualBoard)仍可赋值,页面再回退。
|
|
||||||
*/
|
*/
|
||||||
export type DisplaySettings = EventSettings & {
|
export type DisplaySettings = EventSettings
|
||||||
standings: EventSettings['standings'] & { showIndividualMedals?: boolean }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 嵌套路径取值。
|
* 嵌套路径取值。
|
||||||
@@ -330,6 +326,10 @@ function settingValue(
|
|||||||
|
|
||||||
export function parseSettings(source: unknown): DisplaySettings {
|
export function parseSettings(source: unknown): DisplaySettings {
|
||||||
const rec = record(source)
|
const rec = record(source)
|
||||||
|
const showIndividualBoard = bool(
|
||||||
|
settingValue(rec, 'standings.showIndividualBoard', 'standings.show_individual_board'),
|
||||||
|
true,
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
event: {
|
event: {
|
||||||
name: str(settingValue(rec, 'event.name', 'event.name')),
|
name: str(settingValue(rec, 'event.name', 'event.name')),
|
||||||
@@ -355,13 +355,12 @@ export function parseSettings(source: unknown): DisplaySettings {
|
|||||||
settingValue(rec, 'standings.rankingMode', 'standings.ranking_mode'),
|
settingValue(rec, 'standings.rankingMode', 'standings.ranking_mode'),
|
||||||
'points',
|
'points',
|
||||||
),
|
),
|
||||||
showIndividualBoard: bool(
|
showIndividualBoard,
|
||||||
settingValue(rec, 'standings.showIndividualBoard', 'standings.show_individual_board'),
|
// 旧服务端只下发 showIndividualBoard,奖牌列跟随它,避免升级后突然多出一列。
|
||||||
true,
|
showIndividualMedals:
|
||||||
),
|
boolOrUndefined(
|
||||||
showIndividualMedals: boolOrUndefined(
|
settingValue(rec, 'standings.showIndividualMedals', 'standings.show_individual_medals'),
|
||||||
settingValue(rec, 'standings.showIndividualMedals', 'standings.show_individual_medals'),
|
) ?? showIndividualBoard,
|
||||||
),
|
|
||||||
},
|
},
|
||||||
defaultProfileId: numOrNull(rec.defaultProfileId),
|
defaultProfileId: numOrNull(rec.defaultProfileId),
|
||||||
requireApproval: bool(rec.requireApproval),
|
requireApproval: bool(rec.requireApproval),
|
||||||
|
|||||||
@@ -271,6 +271,7 @@ export function createMockSnapshot(): SnapshotMessage {
|
|||||||
individualToTeamWeight: 1,
|
individualToTeamWeight: 1,
|
||||||
rankingMode: 'points',
|
rankingMode: 'points',
|
||||||
showIndividualBoard: true,
|
showIndividualBoard: true,
|
||||||
|
showIndividualMedals: true,
|
||||||
},
|
},
|
||||||
defaultProfileId: null,
|
defaultProfileId: null,
|
||||||
requireApproval: false,
|
requireApproval: false,
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import type {
|
|||||||
RulePack,
|
RulePack,
|
||||||
RulePackImportPreview,
|
RulePackImportPreview,
|
||||||
RulePackImportResult,
|
RulePackImportResult,
|
||||||
|
RuleSwitches,
|
||||||
SaveDraftPayload,
|
SaveDraftPayload,
|
||||||
ScoreChannel,
|
ScoreChannel,
|
||||||
ScoreChannelInput,
|
ScoreChannelInput,
|
||||||
@@ -194,6 +195,8 @@ export interface AuditApi {
|
|||||||
export interface RulesApi {
|
export interface RulesApi {
|
||||||
/** GET /api/rules/export,返回当前规则包。 */
|
/** GET /api/rules/export,返回当前规则包。 */
|
||||||
get(): Promise<RulePack>
|
get(): Promise<RulePack>
|
||||||
|
/** GET /api/rules/switches,recorder 可读的布尔开关(不含规则内容)。 */
|
||||||
|
switches(): Promise<RuleSwitches>
|
||||||
/** 先预览差异,再决定是否落地(02 第 7.3 节硬性要求)。 */
|
/** 先预览差异,再决定是否落地(02 第 7.3 节硬性要求)。 */
|
||||||
preview(pack: RulePack): Promise<RulePackImportPreview>
|
preview(pack: RulePack): Promise<RulePackImportPreview>
|
||||||
apply(pack: RulePack): Promise<RulePackImportResult>
|
apply(pack: RulePack): Promise<RulePackImportResult>
|
||||||
@@ -295,6 +298,7 @@ function createRulesApi(http: Http): RulesApi {
|
|||||||
}
|
}
|
||||||
return pack
|
return pack
|
||||||
},
|
},
|
||||||
|
switches: () => http.request<RuleSwitches>('/rules/switches'),
|
||||||
preview: (pack) =>
|
preview: (pack) =>
|
||||||
http.request<RulePackImportPreview>('/rules/import', {
|
http.request<RulePackImportPreview>('/rules/import', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -84,6 +84,11 @@ export function normalizeSettings(raw: unknown): EventSettings {
|
|||||||
isFlat ? source['standings.show_individual_medals'] : undefined,
|
isFlat ? source['standings.show_individual_medals'] : undefined,
|
||||||
nestedStandings?.showIndividualBoard,
|
nestedStandings?.showIndividualBoard,
|
||||||
),
|
),
|
||||||
|
showIndividualMedals: firstBool(
|
||||||
|
true,
|
||||||
|
isFlat ? source['standings.show_individual_medals'] : undefined,
|
||||||
|
nestedStandings?.showIndividualMedals,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
// 扁平 map 里只有模板编码(scoring.default_profile),解析不出数据库 id,只能置空。
|
// 扁平 map 里只有模板编码(scoring.default_profile),解析不出数据库 id,只能置空。
|
||||||
defaultProfileId: isFlat ? null : firstNumberOrNull(source.defaultProfileId),
|
defaultProfileId: isFlat ? null : firstNumberOrNull(source.defaultProfileId),
|
||||||
|
|||||||
@@ -372,6 +372,8 @@ export interface EventSettings {
|
|||||||
/** 排名模式;文档未枚举取值,用 string 保留后端扩展空间。 */
|
/** 排名模式;文档未枚举取值,用 string 保留后端扩展空间。 */
|
||||||
rankingMode: string
|
rankingMode: string
|
||||||
showIndividualBoard: boolean
|
showIndividualBoard: boolean
|
||||||
|
/** 个人榜奖牌列开关;旧服务端不下发时按 true 处理。 */
|
||||||
|
showIndividualMedals: boolean
|
||||||
}
|
}
|
||||||
defaultProfileId: number | null
|
defaultProfileId: number | null
|
||||||
requireApproval: boolean
|
requireApproval: boolean
|
||||||
@@ -468,6 +470,19 @@ export interface RulePackManualAdjustment {
|
|||||||
reasonRequired: boolean
|
reasonRequired: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/rules/switches:规则包里布尔开关的只读视图。
|
||||||
|
* 录入员读不到完整规则包(export 是 admin-only),页面用这些布尔值判断功能是否启用。
|
||||||
|
*/
|
||||||
|
export interface RuleSwitches {
|
||||||
|
recordBonusEnabled: boolean
|
||||||
|
adjustmentsEnabled: boolean
|
||||||
|
adjustmentReasonRequired: boolean
|
||||||
|
medalCountsTeamEvents: boolean
|
||||||
|
showIndividualBoard: boolean
|
||||||
|
showIndividualMedals: boolean
|
||||||
|
}
|
||||||
|
|
||||||
/** 规则包里的通道用 profile code 引用模板,而不是数据库 id,便于跨赛事复用。 */
|
/** 规则包里的通道用 profile code 引用模板,而不是数据库 id,便于跨赛事复用。 */
|
||||||
export interface RuleChannel {
|
export interface RuleChannel {
|
||||||
scope: ChannelScope
|
scope: ChannelScope
|
||||||
|
|||||||
Reference in new issue
Block a user