fix: P0 计分根因 + 奖牌结算/奖牌通道 + 身份口径改回证件号 + 19 条 P1(dev_0.1.8)

## P0:双通道项目重复发布跳分(45→85→收敛)
results.rank 一列同时充当「个人名次输出」「手工名次输入」「榜单数据源」,
团体手工通道把个人名次当团体名次又按团体表发一遍分。
- 新增 channel_ranks(每个计分通道各自持久化名次,发布时全量重建)
  与 channel_manual_ranks(按单位录入的团体手工名次,故意不挂通道外键,
  避免规则包重导时级联删掉录入数据)
- results.rank 语义收窄为「只表示个人名次」,只由 scope=athlete 通道写回;
  团体手工名次的来源改为 TeamRanks 优先、团体报名项兜底,
  个人报名项的名次绝不进团体通道
- writeLedgerTx 内重建 channel_ranks;buildRanked / export.loadRanked /
  realtime.loadRanked 三份同源 SQL 一起改读新表
- 回归:发布两次流水与名次逐字节一致(domain + httpapi 各一条,含变异验证)

## 奖牌结算开关与奖牌通道(P1(5),新功能)
- events.award_medals + 通道 count_to_medals / count_to_team_medals
  (迁移默认 1,升级不改变既有榜单)
- 单位榜入选 = 小项结算奖牌 && 通道发奖牌 && 前三名 &&(团体项目 || 个人奖牌并入单位)
- 个人榜只取个人通道;金牌榜与积分榜同源同数据、按通道过滤独立计算
- 前端:小项「该项启用奖牌结算」、通道「发奖牌/个人奖牌并入单位金牌榜」、
  录入页新增「团体名次」区块(含新接口 GET/PUT .../team-ranks)

## 身份口径改回证件号(P1(15),推翻第三十六轮)
- 匹配顺序恢复 证件号 → 号码布 → 姓名+单位;新增 athleteByIDCard
- 同表重复证件号 = 同一人报多个组别(notices 提示,不报错);
  只有「同证件号 + 同小项同组别」才是真重复(conflicts[].duplicate)
- 补齐后端缺失但前端/文档已在依赖的三处契约

## 其余 P1
- 安全:会话撤销断开 ws、实时层按角色裁剪、整库快照限流(含 hello 路径,
  被限流的请求挂起并由心跳兜底补发,不丢刷新)
- 错误分级:手填名次非正数 / gender / 组别年龄 / 导出不存在的小项与单位 /
  积分模板 awardRanks → 400 或 404,不再 500
- 算分一致性:破纪录加分看得失状态;批量退赛·恢复·移出、作废成绩、
  报名导入改状态一律无条件重算推榜(不受延迟结算约束,与实时模式逐字节等价)
- 配置改动触发重算:通道 / 小项参数 / 积分模板
- 升级自愈:channel_ranks 空而已有已发布成绩时,启动时一次性全量重算
  (服务对外监听之前完成,避免升级后奖牌榜全 0)
- 示例数据模式下破坏性操作被阻断,不再假成功
- Console:修复「只剩一块屏」后屏幕监听器被永久摘掉
- 大屏模板引擎:嵌套深度与体积安全阀,任何输入都不抛异常

## 文档
- docs/:使用手册(奖牌开关、奖牌通道、团体名次、枚举列留空口径)、
  常见问题(奖牌排查、升级后奖牌榜为 0、历史重放已知限制)、
  部署指南、上线检查清单
- 版本 dev_0.1.7 → dev_0.1.8(console/package.json 同步)

自检:服务端 13 包 ×2 全绿;前端 vue-tsc + 449 例 ×2 + pnpm build;
Console 35 例 + 语法检查。
This commit is contained in:
AskaEth committed 2026-09-11 21:47:07 +08:00
1 parent 53a0cb225c
commit 45edfbeb71
115 files changed
+10104 -755

No files matched your search

+182
View File
@@ -0,0 +1,182 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import type { ScoreChannel, Team, TeamRanksView } from '@/shared'
import {
hasTeamManualChannel,
parseTeamRankInput,
teamRankFlaggedTeams,
teamRankRowsOf,
teamRankWarnings,
teamRanksDirty,
teamRanksPayload,
} from '../pages/teamRanks'
import { mockDb } from '../api/mock'
// 录入页「团体名次」区块(设计 17 §八第 3 条):纯逻辑在 teamRanks.ts,
// 界面部分与仓库既有做法一致——没有组件测试工具,读源码断言关键接线。
const ENTRY_SOURCE = readFileSync(new URL('../pages/EntryPage.vue', import.meta.url), 'utf8')
function makeChannel(overrides: Partial<ScoreChannel> = {}): ScoreChannel {
return { id: 1, eventId: 108, scope: 'team', basis: 'manual', profileId: 2, sortOrder: 1, countToTeam: true, ...overrides }
}
function makeTeam(id: number, overrides: Partial<Team> = {}): Team {
return {
id,
code: `T${id}`,
name: `单位${id}`,
shortName: `单位${id}`,
logoPath: null,
color: null,
sortOrder: id,
active: true,
...overrides,
}
}
function makeView(overrides: Partial<TeamRanksView> = {}): TeamRanksView {
return {
channels: [{ channelId: 3, scope: 'team', basis: 'manual', profileId: 2 }],
teams: [
{ teamId: 1, code: 'JG', name: '机关本部', shortName: '机关' },
{ teamId: 2, code: 'F1', name: '第一分公司', shortName: '一分公司' },
],
ranks: [{ teamId: 1, rank: 2 }],
...overrides,
}
}
test('只有 scope=team + basis=manual 的通道才需要「团体名次」区块', () => {
assert.equal(hasTeamManualChannel([makeChannel()]), true)
assert.equal(hasTeamManualChannel([makeChannel({ basis: 'aggregate' })]), false)
assert.equal(hasTeamManualChannel([makeChannel({ scope: 'athlete' })]), false)
assert.equal(
hasTeamManualChannel([makeChannel({ scope: 'athlete', basis: 'result' }), makeChannel()]),
true,
)
assert.equal(hasTeamManualChannel([]), false)
assert.equal(hasTeamManualChannel(null), false)
assert.equal(hasTeamManualChannel(undefined), false)
})
test('名次输入:空 / 0 / 负数 / 非整数一律按清空处理', () => {
assert.equal(parseTeamRankInput('3'), 3)
assert.equal(parseTeamRankInput(' 4 '), 4)
// 留空 = 清空该单位,服务端口径是 rank 0。
assert.equal(parseTeamRankInput(''), 0)
assert.equal(parseTeamRankInput(' '), 0)
assert.equal(parseTeamRankInput('0'), 0)
assert.equal(parseTeamRankInput('-2'), 0)
assert.equal(parseTeamRankInput('2.5'), 0)
assert.equal(parseTeamRankInput('abc'), 0)
})
test('表格行:单位来自接口,已有名次回填,未填留空', () => {
const rows = teamRankRowsOf(makeView(), [])
assert.deepEqual(
rows.map((row) => [row.teamId, row.code, row.input, row.original]),
[
[1, 'JG', '2', '2'],
[2, 'F1', '', ''],
],
)
assert.equal(teamRanksDirty(rows), false)
})
test('接口没带单位时退回全局单位表,且不列停用 / 假删除的单位', () => {
const rows = teamRankRowsOf(makeView({ teams: [], ranks: [] }), [
makeTeam(1),
makeTeam(2, { active: false }),
makeTeam(3, { deletedAt: '2026-01-01' }),
])
assert.deepEqual(rows.map((row) => row.teamId), [1])
})
test('rank=0(清空)在表格里显示为未填,而不是 0', () => {
const rows = teamRankRowsOf(makeView({ ranks: [{ teamId: 1, rank: 0 }] }), [])
assert.equal(rows[0]?.input, '')
assert.equal(rows[0]?.original, '')
})
test('改动检测与保存载荷:整组回传,清空发 0', () => {
const rows = teamRankRowsOf(makeView(), [])
rows[0]!.input = '1'
rows[1]!.input = ''
assert.equal(teamRanksDirty(rows), true)
assert.deepEqual(teamRanksPayload(rows), {
scope: 'team',
basis: 'manual',
ranks: [
{ teamId: 1, rank: 1 },
{ teamId: 2, rank: 0 },
],
})
})
test('名次重复只提示不拦截,超出单位数同样只是提醒', () => {
const rows = teamRankRowsOf(makeView(), [])
rows[0]!.input = '1'
rows[1]!.input = '1'
const warnings = teamRankWarnings(rows)
assert.equal(warnings.length, 1)
assert.match(warnings[0] ?? '', /名次 1 重复 2 次,允许并列/)
assert.deepEqual([...teamRankFlaggedTeams(rows)], [1, 2])
// 超出参赛单位数(两个单位却填了 5)同样不抛错,只多一条提示。
rows[1]!.input = '5'
assert.match(teamRankWarnings(rows).join(';'), /超出参赛单位数 2/)
assert.deepEqual([...teamRankFlaggedTeams(rows)], [2])
})
test('没问题的输入不产生提示', () => {
const rows = teamRankRowsOf(makeView(), [])
rows[0]!.input = '1'
rows[1]!.input = '2'
assert.deepEqual(teamRankWarnings(rows), [])
assert.equal(teamRankFlaggedTeams(rows).size, 0)
})
test('示例数据:录入接口随行下发团体名次,保存后能原样读回', () => {
const event = mockDb.eventDetail(108)
assert.equal(hasTeamManualChannel(event.channels), true)
const board = mockDb.entryBoard(108)
const view = board.teamRanks.find((item) => item.channels.length > 0)
assert.ok(view !== undefined, 'TPULL 应随行下发团体名次视图')
assert.equal(view.categoryId, 108 * 10 + 1)
const rows = teamRankRowsOf(view, [])
assert.equal(rows.length, 7, '启用单位全部列出')
// 示例数据里预填了前两个单位,用来演示「已有值回填」。
assert.deepEqual(rows.slice(0, 2).map((row) => row.input), ['1', '2'])
// 改一个、清一个,保存后读回的口径与提交的一致。
rows[0]!.input = '3'
rows[1]!.input = ''
const saved = mockDb.saveTeamRanks(108, view.categoryId ?? 0, teamRanksPayload(rows))
const after = teamRankRowsOf(saved, [])
assert.equal(after[0]?.input, '3')
assert.equal(after[1]?.input, '', '空值 = 清空该单位')
assert.equal(mockDb.teamRanks(108, view.categoryId ?? 0).ranks.length, 1)
})
test('录入页按契约接线:区块可见性、随行数据优先、保存与 guest 只读', () => {
// 区块出现条件 = 该组别有团体手工通道。
assert.match(ENTRY_SOURCE, /hasTeamManualChannel\(channels\.value\)/)
assert.match(ENTRY_SOURCE, /v-if="showTeamRanks"/)
// 数据来源:录入接口随行的 teamRanks 优先,拿不到才退到 GET team-ranks。
assert.match(ENTRY_SOURCE, /adminApi\.entryBoard\(/)
assert.match(ENTRY_SOURCE, /adminApi\.teamRanks\(/)
// 保存走 PUT,并且是整组替换的载荷。
assert.match(ENTRY_SOURCE, /adminApi\.saveTeamRanks\(eventId, categoryId, teamRanksPayload\(/)
// 保存后整页刷新(名次 / 积分 / 榜单都会变)。
assert.match(ENTRY_SOURCE, /await reloadAfterSettlementChange\(\)/)
// 说明文案必须点明「按单位录入」且不影响个人名次。
assert.match(ENTRY_SOURCE, /按<strong>单位<\/strong>录入的团体名次/)
assert.match(ENTRY_SOURCE, /不影响个人名次/)
// guest / 断线只读:整块置灰 + 提示条。
assert.match(ENTRY_SOURCE, /'is-readonly': !canWrite/)
assert.match(ENTRY_SOURCE, /当前为访客只读视图,团体名次只能查看,无法录入/)
})