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

+6 -1
View File
@@ -237,7 +237,12 @@ const SETTINGS: EventSettings = {
primaryColor: '#3482FF',
rotateSeconds: 15,
},
standings: { individualToTeamWeight: 1, rankingMode: 'points', showIndividualBoard: true },
standings: {
individualToTeamWeight: 1,
rankingMode: 'points',
showIndividualBoard: true,
showIndividualMedals: true,
},
defaultProfileId: 1,
requireApproval: false,
}
@@ -184,6 +184,16 @@ async function save(): Promise<void> {
<span class="switch-text">{{ form.standings.showIndividualBoard ? '展示' : '隐藏' }}</span>
</span>
</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>
<p class="notice">
+13 -5
View File
@@ -7,6 +7,7 @@ import {
type AdjustmentCategory,
type PointAdjustment,
type RulePack,
type RuleSwitches,
} from '@/shared'
import MxIcon from '../components/MxIcon.vue'
import PageHeader from '../components/PageHeader.vue'
@@ -32,6 +33,7 @@ const size = 20
const loading = ref(false)
const error = ref<string | null>(null)
const rulePack = ref<RulePack | null>(null)
const ruleSwitches = ref<RuleSwitches | null>(null)
const ruleError = ref<string | null>(null)
const search = ref('')
const categoryFilter = ref<AdjustmentCategory | 'all'>('all')
@@ -57,12 +59,12 @@ const canWrite = computed(() => !auth.readOnly && data.canWrite)
/**
* 人工调整开关:true / false / null(未知)。
*
* 规则包只有 admin 能读(GET /api/rules/export 是 admin-only),录入员必然 403——
* 不能据此断言「已关闭」,那会把已启用的功能谎报成关闭。读不到时为 null,
* 界面如实显示「未知」,最终由服务端校验决定是否受理。
* 后端补一个 recorder 可读的只读开关端点后,把 loadRules 换成读该端点即可。
* 优先取 GET /api/rules/switches(recorder 可读);读不到时再退回完整规则包(admin)。
* 两者都没有则为 null,界面如实显示「未知」,最终由服务端校验决定是否受理。
*/
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])
@@ -122,6 +124,12 @@ async function loadRules(): Promise<void> {
rulePack.value = null
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> {
+13 -5
View File
@@ -8,6 +8,7 @@ import {
type RecordEntry,
type RecordLevel,
type RulePack,
type RuleSwitches,
} from '@/shared'
import MxIcon from '../components/MxIcon.vue'
import PageHeader from '../components/PageHeader.vue'
@@ -64,6 +65,7 @@ const suspected = ref<SuspectedBreak[]>([])
const suspectedLoading = ref(false)
const suspectedError = ref<string | null>(null)
const rulePack = ref<RulePack | null>(null)
const ruleSwitches = ref<RuleSwitches | null>(null)
const ruleError = ref<string | null>(null)
const rulesLoading = ref(false)
const search = ref('')
@@ -79,12 +81,12 @@ const canWrite = computed(() => !auth.readOnly && data.canWrite)
/**
* 破纪录加分开关:true / false / null(未知)。
*
* 规则包只有 admin 能读(GET /api/rules/export 是 admin-only),
* 录入员必然 403——不能据此断言「已关闭」,那会把已启用的功能谎报成关闭。
* 读不到时为 null,界面如实显示「未知」,最终由服务端校验决定是否加分。
* 后端补一个 recorder 可读的只读开关端点后,把 loadRules 换成读该端点即可。
* 优先取 GET /api/rules/switches(recorder 可读);读不到时再退回完整规则包(admin)。
* 两者都没有则为 null,界面如实显示「未知」,最终由服务端校验决定是否加分。
*/
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(() =>
rulePack.value === null ? '—' : rulePack.value.recordBonus.mode === 'sum' ? '多档累加' : '取最高档',
)
@@ -167,6 +169,12 @@ async function loadRules(): Promise<void> {
} finally {
rulesLoading.value = false
}
// 规则包只有 admin 能读;录入员改用只读开关端点,避免把已启用功能误报成关闭。
try {
ruleSwitches.value = await adminApi.client.rules.switches()
} catch {
ruleSwitches.value = null
}
}
async function loadCategoryLabels(eventIds: number[]): Promise<void> {
@@ -8,11 +8,8 @@ import PageFrame from '../PageFrame.vue'
const { athletes, settings } = useScoreboard()
const rows = computed(() => athletes.value)
// 奖牌列由独立开关控制;旧配置没有 showIndividualMedals 时回退到「个人榜上屏」开关,避免升级后突然多出一列。
const showMedals = computed(() => {
const standings = settings.value?.standings
return standings?.showIndividualMedals ?? standings?.showIndividualBoard ?? true
})
// 奖牌列由独立开关控制;缺字段时的回退已在 guards.parseSettings 里按「跟随个人榜上屏」处理。
const showMedals = computed(() => settings.value?.standings.showIndividualMedals ?? true)
const rowFont = computed(() => fitRowFont(rows.value.length, 560))
</script>
+13 -14
View File
@@ -296,14 +296,10 @@ export function parseRecordEntry(source: unknown): RecordEntry {
}
/**
* 大屏侧扩展的赛事配置。
*
* `standings.showIndividualMedals` 还没进入 shared 契约,但大屏需要它独立控制奖牌列;
* 声明成可选,旧配置(服务端只给 showIndividualBoard)仍可赋值,页面再回退。
* 大屏侧赛事配置。字段与 shared 契约一致;
* `showIndividualMedals` 缺失时由 parseSettings 回退到 `showIndividualBoard`(旧服务端只下发后者)。
*/
export type DisplaySettings = EventSettings & {
standings: EventSettings['standings'] & { showIndividualMedals?: boolean }
}
export type DisplaySettings = EventSettings
/**
* 嵌套路径取值。
@@ -330,6 +326,10 @@ function settingValue(
export function parseSettings(source: unknown): DisplaySettings {
const rec = record(source)
const showIndividualBoard = bool(
settingValue(rec, 'standings.showIndividualBoard', 'standings.show_individual_board'),
true,
)
return {
event: {
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'),
'points',
),
showIndividualBoard: bool(
settingValue(rec, 'standings.showIndividualBoard', 'standings.show_individual_board'),
true,
),
showIndividualMedals: boolOrUndefined(
settingValue(rec, 'standings.showIndividualMedals', 'standings.show_individual_medals'),
),
showIndividualBoard,
// 旧服务端只下发 showIndividualBoard,奖牌列跟随它,避免升级后突然多出一列。
showIndividualMedals:
boolOrUndefined(
settingValue(rec, 'standings.showIndividualMedals', 'standings.show_individual_medals'),
) ?? showIndividualBoard,
},
defaultProfileId: numOrNull(rec.defaultProfileId),
requireApproval: bool(rec.requireApproval),
+1
View File
@@ -271,6 +271,7 @@ export function createMockSnapshot(): SnapshotMessage {
individualToTeamWeight: 1,
rankingMode: 'points',
showIndividualBoard: true,
showIndividualMedals: true,
},
defaultProfileId: null,
requireApproval: false,
+4
View File
@@ -32,6 +32,7 @@ import type {
RulePack,
RulePackImportPreview,
RulePackImportResult,
RuleSwitches,
SaveDraftPayload,
ScoreChannel,
ScoreChannelInput,
@@ -194,6 +195,8 @@ export interface AuditApi {
export interface RulesApi {
/** GET /api/rules/export,返回当前规则包。 */
get(): Promise<RulePack>
/** GET /api/rules/switches,recorder 可读的布尔开关(不含规则内容)。 */
switches(): Promise<RuleSwitches>
/** 先预览差异,再决定是否落地(02 第 7.3 节硬性要求)。 */
preview(pack: RulePack): Promise<RulePackImportPreview>
apply(pack: RulePack): Promise<RulePackImportResult>
@@ -295,6 +298,7 @@ function createRulesApi(http: Http): RulesApi {
}
return pack
},
switches: () => http.request<RuleSwitches>('/rules/switches'),
preview: (pack) =>
http.request<RulePackImportPreview>('/rules/import', {
method: 'POST',
+5
View File
@@ -84,6 +84,11 @@ export function normalizeSettings(raw: unknown): EventSettings {
isFlat ? source['standings.show_individual_medals'] : undefined,
nestedStandings?.showIndividualBoard,
),
showIndividualMedals: firstBool(
true,
isFlat ? source['standings.show_individual_medals'] : undefined,
nestedStandings?.showIndividualMedals,
),
},
// 扁平 map 里只有模板编码(scoring.default_profile),解析不出数据库 id,只能置空。
defaultProfileId: isFlat ? null : firstNumberOrNull(source.defaultProfileId),
+15
View File
@@ -372,6 +372,8 @@ export interface EventSettings {
/** 排名模式;文档未枚举取值,用 string 保留后端扩展空间。 */
rankingMode: string
showIndividualBoard: boolean
/** 个人榜奖牌列开关;旧服务端不下发时按 true 处理。 */
showIndividualMedals: boolean
}
defaultProfileId: number | null
requireApproval: boolean
@@ -468,6 +470,19 @@ export interface RulePackManualAdjustment {
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,便于跨赛事复用。 */
export interface RuleChannel {
scope: ChannelScope