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:
AskaEth committed 2026-09-09 21:41:08 +08:00
1 parent 4d56c73ae6
commit 2c9ff65fc4
42 files changed
+1271 -275

No files matched your search

+87
View File
@@ -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 }