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

+15 -14
View File
@@ -40,7 +40,7 @@ function respondWith(response: () => Response): void {
reply = response
}
const { ApiError, adminApi } = await import('../api/client')
const { ApiError, adminApi, mockMode, SAMPLE_DATA_BLOCKED } = await import('../api/client')
const { baseDataApi, baseDataFallback } = await import('../components/baseData/baseDataApi')
test('彻底删除走 DELETE /api/athletes/{id}?purge=1', async () => {
@@ -164,22 +164,23 @@ test('remove 的 404 仍降级到示例数据(既有行为未受影响)', as
assert.equal(baseDataFallback.value, true)
})
// 后端整体连不上时仍降级到示例数据;示例数据也返回完整的统计结构,页面不会拿到 undefined。
// (示例数据里没有归属到人员的报名与成绩,所以各项是 0,用 id=1002 避开上一条已删掉的 1001。)
test('后端不可用降级示例数据时,统计结构依然完整', async () => {
// 后端整体连不上时不再降级示例数据:内存里「删掉」会让操作员以为人真被删了,
// 恢复连接后这些人又会全部回来。必须给出可识别的「未提交」结果(SAMPLE_DATA_BLOCKED)。
test('后端不可用时彻底删除被阻断:不降级示例数据、不返回假成功', async () => {
baseDataFallback.value = false
mockMode.value = false
respondWith(() => {
throw new TypeError('fetch failed')
})
const result = await baseDataApi.athletes.purge(1002)
assert.deepEqual(result, {
deletedEntries: 0,
deletedResults: 0,
deletedLedger: 0,
deletedBreaks: 0,
recomputedEvents: [],
})
assert.equal(baseDataFallback.value, true)
await assert.rejects(
() => baseDataApi.athletes.purge(1002),
(error: unknown) => {
assert.ok(error instanceof ApiError)
assert.equal(error.code, SAMPLE_DATA_BLOCKED)
assert.match(error.message, /示例数据/)
return true
},
)
assert.equal(baseDataFallback.value, true, '写请求撞上后端不可达后,页面应切到示例数据标记')
})
@@ -46,7 +46,7 @@ function requestBody(): Record<string, unknown> {
return parsed
}
const { adminApi } = await import('../api/client')
const { ApiError, SAMPLE_DATA_BLOCKED, adminApi } = await import('../api/client')
const input = {
name: '男子甲组',
@@ -95,7 +95,7 @@ test('服务端 409 的原文要能被页面拿到(提示里那句话是现场
)
})
test('服务端不可达时,组别就地更新与运动员批量都降级到示例数据而不是抛错', async () => {
test('服务端不可达时,组别就地更新仍降级到示例数据,运动员批量则被阻断', async () => {
const { baseDataApi, baseDataFallback } = await import('../components/baseData/baseDataApi')
const { mockBaseData } = await import('../components/baseData/mockBaseData')
// 断网:fetch 直接抛,客户端归成 NETWORK,baseDataApi 据此降级到示例数据。
@@ -114,9 +114,15 @@ test('服务端不可达时,组别就地更新与运动员批量都降级到
assert.equal(categories.find((item) => item.id === category.id)?.name, '降级改名')
assert.equal(baseDataFallback.value, true)
// 批量停用是破坏性操作:示例数据模式下必须拦下,不能假装停用成功(P1(17))。
const athlete = mockBaseData.listAthletes()[0]
assert.ok(athlete !== undefined)
const result = await baseDataApi.athletes.batch('disable', [athlete.id])
assert.equal(result.succeeded, 1)
assert.equal(result.failed, 0)
await assert.rejects(
() => baseDataApi.athletes.batch('disable', [athlete.id]),
(error: unknown) => {
assert.ok(error instanceof ApiError)
assert.equal(error.code, SAMPLE_DATA_BLOCKED)
return true
},
)
})
@@ -0,0 +1,52 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
// 计分通道页的两个奖牌开关(设计 17 §八第 2 条)与小项页的「该项启用奖牌结算」。
// 项目没有组件测试工具,按仓库既有做法(avg-mode.test.ts)读源码断言接线。
const CHANNELS_SOURCE = readFileSync(
new URL('../components/baseData/ChannelsSection.vue', import.meta.url),
'utf8',
)
const EVENTS_SOURCE = readFileSync(
new URL('../components/baseData/SportsEventsSection.vue', import.meta.url),
'utf8',
)
test('通道行带两个奖牌开关,缺字段按 true 归一', () => {
assert.match(CHANNELS_SOURCE, /countToMedals: normalizeCountToMedals\(channel\.countToMedals\)/)
assert.match(CHANNELS_SOURCE, /countToTeamMedals: normalizeCountToTeamMedals\(channel\.countToTeamMedals\)/)
assert.match(CHANNELS_SOURCE, /发奖牌/)
assert.match(CHANNELS_SOURCE, /个人奖牌并入单位金牌榜/)
})
test('两个开关一起序列化进 ScoreChannelInput,scope=team 的值不被清掉', () => {
// buildInputs 必须带上两个字段:PUT 是整体替换,漏掉等于把它们重置回默认(发牌)。
assert.match(CHANNELS_SOURCE, /countToMedals: row\.countToMedals/)
assert.match(CHANNELS_SOURCE, /countToTeamMedals: row\.countToTeamMedals/)
// 「个人奖牌并入单位金牌榜」只在 scope=athlete 的行显示;值本身原样回传。
assert.match(CHANNELS_SOURCE, /v-if="row\.scope === 'athlete'"/)
assert.match(CHANNELS_SOURCE, /切换 scope 时不会被清掉/)
})
test('新通道默认发奖牌并并入单位榜(与库表默认值一致)', () => {
assert.match(CHANNELS_SOURCE, /countToMedals: true,\s*\n\s*countToTeamMedals: true,/)
})
test('小项编辑有「该项启用奖牌结算」开关,默认开并说清后果', () => {
assert.match(EVENTS_SOURCE, /awardMedals: true,/)
assert.match(EVENTS_SOURCE, /该项启用奖牌结算/)
assert.match(EVENTS_SOURCE, /关闭后该小项只出积分不出奖牌|关闭「该项启用奖牌结算」后,该小项只出积分不出奖牌/)
// 编辑已有小项时按当前值回填,不能每次都变成开。
assert.match(EVENTS_SOURCE, /awardMedals: normalizeAwardMedals\(event\.awardMedals\)/)
// 保存时带上该字段(PUT 整体替换)。
assert.match(EVENTS_SOURCE, /awardMedals: eventForm\.value\.awardMedals/)
})
test('小项页的通道摘要给「已关闭奖牌」的通道加可见徽标', () => {
assert.match(EVENTS_SOURCE, /!channel\.countToMedals/)
assert.match(EVENTS_SOURCE, /不发奖牌/)
assert.match(EVENTS_SOURCE, /!channel\.countToTeamMedals/)
assert.match(EVENTS_SOURCE, /个人奖牌不并入单位榜/)
})
@@ -37,10 +37,13 @@ test('选中「个人+团体」时提示双通道口径', () => {
assert.match(SECTION_SOURCE, /计分通道/)
})
test('示例数据里的「个人+团体」小项配了个人与团体两条通道', () => {
test('示例数据里的「个人+团体」小项配了个人与团体通道(含按单位手工录名次)', () => {
const event = mockDb.eventDetail(108)
assert.equal(event.eventType, 'individual_team')
const scopes = (event.channels ?? []).map((channel) => channel.scope)
assert.deepEqual(scopes, ['athlete', 'team'])
// athlete=个人成绩、team+aggregate=队内平均、team+manual=按单位手工录名次
// (设计 17 的「团体名次」区块就靠最后这条通道才会出现在录入页)。
assert.deepEqual(scopes, ['athlete', 'team', 'team'])
assert.ok((event.channels ?? []).some((channel) => channel.basis === 'manual'))
})
+192 -54
View File
@@ -1,6 +1,6 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import type { AthleteInput, ImportConflict } from '@/shared'
import type { AthleteInput, ImportConflict, ImportConflictFields } from '@/shared'
import { createApiClient } from '@/shared'
import {
REQUIRED_COLUMNS,
@@ -15,13 +15,28 @@ import {
} from '../components/baseData/entrySelection'
import { MockBaseData } from '../components/baseData/mockBaseData'
/** 冲突两端可比的字段快照;证件号默认两侧相同(同一个人),号码布默认相同。 */
function fields(overrides: Partial<ImportConflictFields> = {}): ImportConflictFields {
return {
name: '张三',
gender: '男',
age: 26,
teamCode: 'F1',
employeeNo: '1001',
idCard: '110101199001010001',
bib: '101',
...overrides,
}
}
function conflict(overrides: Partial<ImportConflict> & Pick<ImportConflict, 'idCard'>): ImportConflict {
return {
row: 12,
bib: '',
athleteId: 42,
existing: { name: '张三', gender: '男', age: 26, teamCode: 'F1', employeeNo: '1001', bib: '101' },
incoming: { name: '张三', gender: '男', age: 27, teamCode: 'F2', employeeNo: null, bib: '102' },
duplicate: false,
existing: fields(),
incoming: fields({ age: 27, teamCode: 'F2', employeeNo: null, bib: '102' }),
changedFields: ['age', 'teamCode', 'bib'],
...overrides,
}
@@ -32,38 +47,71 @@ function csv(rows: string[][]): File {
}
/** event_code / category / team_code / athlete_name / id_card / gender / age / bib / seed / heat / status / member_names */
function row(name: string, idCard: string, teamCode = 'F1', gender = '', bib = '', age = ''): string[] {
return ['M100', '男子组', teamCode, name, idCard, gender, age, bib, '', '', 'registered', '']
function rowAt(
eventCode: string,
category: string,
name: string,
idCard: string,
teamCode = 'F1',
gender = '',
bib = '',
age = '',
): string[] {
return [eventCode, category, teamCode, name, idCard, gender, age, bib, '', '', 'registered', '']
}
test('conflictDiffs 展开两侧字段并标出差异字段(含年龄 / 号码布)', () => {
/** 默认小项与组别(M100 / 男子组)的一行。 */
function row(name: string, idCard: string, teamCode = 'F1', gender = '', bib = '', age = ''): string[] {
return rowAt('M100', '男子组', name, idCard, teamCode, gender, bib, age)
}
test('conflictDiffs 展开两侧字段并标出差异字段(含证件号 / 年龄 / 号码布)', () => {
const diffs = conflictDiffs(conflict({ idCard: '110101199001011234' }))
assert.deepEqual(
diffs.map((diff) => diff.field),
['name', 'gender', 'age', 'teamCode', 'employeeNo', 'bib'],
['name', 'gender', 'age', 'teamCode', 'employeeNo', 'idCard', 'bib'],
)
assert.deepEqual(
diffs.map((diff) => diff.label),
['姓名', '性别', '年龄', '单位', '工号 / 学号', '号码布'],
['姓名', '性别', '年龄', '单位', '工号 / 学号', '证件号', '号码布'],
)
assert.deepEqual(
diffs.map((diff) => diff.changed),
[false, false, true, true, false, true],
[false, false, true, true, false, false, true],
'只高亮 changedFields 里的字段,employeeNo 差异不参与冲突判定',
)
assert.equal(diffs[2]?.existing, '26', 'number 类型的年龄转成文本展示')
assert.equal(diffs[2]?.incoming, '27')
assert.equal(diffs[4]?.incoming, '—', 'null 展示为占位符')
assert.equal(diffs[5]?.existing, '101')
assert.equal(diffs[5]?.incoming, '102')
assert.equal(diffs[6]?.existing, '101')
assert.equal(diffs[6]?.incoming, '102')
})
// P1(16):服务端只差证件号时回的是 changedFields:["idCard"],前端必须有对应标签并高亮,
// 否则冲突卡片上一个差异都不亮,用户无从判断该保留哪一侧。
test('changedFields 含 idCard 时证件号一行有标签并高亮', () => {
const diffs = conflictDiffs(
conflict({
idCard: 'C2',
existing: fields({ idCard: 'C1' }),
incoming: fields({ idCard: 'C2' }),
changedFields: ['idCard'],
}),
)
const idCard = diffs.find((diff) => diff.field === 'idCard')
assert.deepEqual([idCard?.label, idCard?.existing, idCard?.incoming, idCard?.changed], ['证件号', 'C1', 'C2', true])
assert.deepEqual(
diffs.filter((diff) => diff.changed).map((diff) => diff.field),
['idCard'],
)
})
test('conflictDiffs 年龄为空的一侧显示占位符,不算差异', () => {
const diffs = conflictDiffs(
conflict({
idCard: '110101199001011234',
existing: { name: '张三', gender: '男', age: null, teamCode: 'F1', employeeNo: null, bib: '101' },
incoming: { name: '张三', gender: '男', age: null, teamCode: 'F1', employeeNo: null, bib: '101' },
existing: fields({ age: null }),
incoming: fields({ age: null }),
changedFields: [],
}),
)
@@ -99,7 +147,7 @@ test('pruneDecisions / decisionsPayload / summarizeDecisions 按冲突列表收
test('只有号码布的冲突按号码布分键,不再互相覆盖', () => {
const conflicts = [conflict({ row: 2, idCard: '', bib: '101' }), conflict({ row: 3, idCard: '', bib: '102' })]
// 只勾选第一条:另一条必须仍是未决策,回退成按证件号作键时这里会被 '' 覆盖成空集。
// 只勾选第一条:另一条必须仍是未决策,按证件号作键时两条都会被空串键带偏。
const first = recordDecisions({}, conflicts, ['101'], 'keep-existing')
assert.deepEqual(first, { 101: 'keep-existing' })
assert.deepEqual(summarizeDecisions(first, conflicts), { keepExisting: 1, keepNew: 0, undecided: 1 })
@@ -120,26 +168,33 @@ test('只有号码布的冲突按号码布分键,不再互相覆盖', () => {
})
})
test('决策键号码布优先:共用一个证件号的两个人不再共用一行决策', () => {
// 决策键的优先级:证件号 → 号码布 → 姓名 + 单位,与服务端 athleteImportRow.decisionKey() 逐字一致。
test('决策键证件号优先:同证件号的两行是同一个人,共用一条决策', () => {
const shared = '110101199001010001'
// 证件号不参与匹配后,同一份表里两个不同的人可以共用一个证件号;键回到号码布才不会串。
// 同一证件号 = 同一个人(多半是报了多个组别),号码布不同也只做一次决策。
const first = conflict({ row: 2, idCard: shared, bib: '101' })
const second = conflict({ row: 3, idCard: shared, bib: '102' })
assert.equal(decisionKeyOf(first), '101')
assert.equal(decisionKeyOf(second), '102')
assert.equal(decisionKeyOf(first), shared)
assert.equal(decisionKeyOf(second), shared)
// 勾其中一行时另一行必须还是未决策(按证件号作键时两条会互相覆盖)。
const decisions = recordDecisions({}, [first, second], [decisionKeyOf(first)], 'keep-new')
assert.deepEqual(decisions, { 101: 'keep-new' })
assert.deepEqual(summarizeDecisions(decisions, [first, second]), { keepExisting: 0, keepNew: 1, undecided: 1 })
assert.deepEqual(decisionsPayload(decisions, [first, second]), [
{ idCard: shared, bib: '101', action: 'keep-new' },
])
const decisions = recordDecisions({}, [first, second], [shared], 'keep-new')
assert.deepEqual(decisions, { [shared]: 'keep-new' })
assert.deepEqual(summarizeDecisions(decisions, [first, second]), { keepExisting: 0, keepNew: 2, undecided: 0 })
assert.deepEqual(
decisionsPayload(decisions, [first, second]),
[
{ idCard: shared, bib: '101', action: 'keep-new' },
{ idCard: shared, bib: '102', action: 'keep-new' },
],
'同一个人的两条都带上证件号,服务端按证件号重算键后一次决策覆盖两行',
)
})
test('决策键回落:没有号码布用证件号,两样都没有用姓名 + 单位', () => {
const idCard = '110101199001010002'
assert.equal(decisionKeyOf(conflict({ idCard, bib: '' })), idCard)
// 证件号优先于号码布:库里那条的号码布与表里一致、证件号不同时,键也必须取证件号。
test('决策键证件号优先于号码布', () => {
const idCard = '110101199001010003'
assert.equal(decisionKeyOf(conflict({ idCard, bib: '101' })), idCard)
assert.equal(decisionKeyOf(conflict({ idCard: '', bib: '101' })), '101', '没有证件号才退到号码布')
assert.equal(decisionKeyOf(conflict({ idCard: '', bib: '' })), '张三·F2', '两样都没有时仍要有一行唯一的键')
})
@@ -161,25 +216,62 @@ test('导入:新建人员带证件号,姓名 + 单位命中且证件号为
assert.equal(mock.listAthletes().find((athlete) => athlete.name === '刘洋')?.idCard, '110101199001010002')
})
test('导入:有姓名的行缺证件号 / 号码布都按行报错;重复证件号不再拦截', async () => {
test('导入:有姓名的行缺证件号 / 号码布按行报错', async () => {
const mock = new MockBaseData()
const missing = await mock.importEntries(csv([row('无证人员', '')]))
assert.equal(missing.skipped, 1)
assert.match(missing.errors[0] ?? '', /必须填证件号或号码布/)
// 同一份表里两行共用证件号不再报错:证件号只是可选档案信息,人员按号码布 / 姓名 + 单位认,
// 两行都落在同一个人身上(第一行按号码布命中并回填证件号,第二行按姓名 + 单位命中)。
const before = mock.listAthletes().length
const duplicated = await mock.importEntries(
csv([row('刘洋', '110101199001010003', 'F1', '男', '201'), row('刘洋', '110101199001010003', 'F1', '男')]),
)
assert.deepEqual(duplicated.errors, [], '重复证件号不再拦截')
assert.equal(duplicated.created, 2, '两行都落库')
assert.equal(mock.listAthletes().length, before, '两行都命中已有人员,不新增档案')
assert.equal(mock.listAthletes().find((athlete) => athlete.name === '刘洋')?.idCard, '110101199001010003')
})
test('导入:证件号撞别人按行报错,复用同一个人时不算差异', async () => {
test('导入:同一证件号报多个组别不报错,逐行落库并给出提示', async () => {
const mock = new MockBaseData()
const idCard = '110101199001010003'
const before = mock.listAthletes().length
// 同一个人报两个小项:号码布也可以照抄(号码布不再是唯一标识)。
const result = await mock.importEntries(
csv([
rowAt('M100', '男子组', '多组别甲', idCard, 'F1', '男', '9901'),
rowAt('M800', '男子组', '多组别甲', idCard, 'F1', '男', '9901'),
]),
)
assert.deepEqual(result.errors, [], '同一证件号重复出现不再拦截')
assert.equal(result.created, 2, '同一人的两条报名项都落库')
assert.deepEqual(result.notices, [`证件号 ${idCard} 在本表中出现 2 次(同一人报名多个组别)`])
assert.deepEqual(result.conflicts, [], '不同小项不是真重复')
assert.equal(mock.listAthletes().length, before + 1, '同一个人只建一份档案')
})
test('导入:同证件号 + 同一个小项同一个组别才是真重复,等用户决策', async () => {
const mock = new MockBaseData()
const idCard = '110101199001010004'
const body = [row('重复甲', idCard, 'F1', '男', '9401'), row('重复甲', idCard, 'F1', '男', '9402')]
const conflicted = await mock.importEntries(csv(body))
assert.equal(conflicted.created, 1, '首行落库')
assert.equal(conflicted.skipped, 0, '真重复行不进「跳过」,要等决策')
assert.equal(conflicted.conflicts.length, 1)
const [conflict] = conflicted.conflicts
assert.equal(conflict?.duplicate, true, '标记为真重复行,面板才能说明为什么没有差异高亮')
assert.equal(conflict?.row, 3)
assert.deepEqual(conflict?.changedFields, ['bib'])
assert.deepEqual(conflicted.notices, [`证件号 ${idCard} 在本表中出现 2 次(同一人报名多个组别)`])
assert.equal(mock.listAthletes().find((athlete) => athlete.idCard === idCard)?.bib, '9401', '未决策前保持首行的值')
// 保留已有:真重复行不落库,同一个人一个字段都不动。
const keptExisting = await mock.resolveImportConflicts(csv(body), [
{ idCard, bib: '9402', action: 'keep-existing' },
])
assert.equal(keptExisting.skipped, 1)
assert.deepEqual(keptExisting.conflicts, [])
assert.equal(mock.listAthletes().find((athlete) => athlete.idCard === idCard)?.bib, '9401')
// 使用新数据:用真重复行的字段覆盖同一个人。
const keptNew = await mock.resolveImportConflicts(csv(body), [{ idCard, bib: '9402', action: 'keep-new' }])
assert.deepEqual(keptNew.conflicts, [])
assert.equal(mock.listAthletes().find((athlete) => athlete.idCard === idCard)?.bib, '9402')
})
test('导入:证件号优先命中,复用同一个人时不算差异', async () => {
const idCard = '110101199001010004'
const mock = new MockBaseData()
// 库里没有「张三」:第一行按新人员建,证件号写进档案。
@@ -188,21 +280,17 @@ test('导入:证件号撞别人按行报错,复用同一个人时不算差
assert.deepEqual(first.conflicts, [])
assert.ok(mock.listAthletes().some((athlete) => athlete.idCard === idCard))
// 同名同单位再来一行:姓名 + 单位命中同一个人,证件号相同不是差异。
const again = await mock.importEntries(csv([row('张三', idCard, 'F1', '男')]))
// 同一证件号换一个小项再来一行:按证件号命中同一个人,不算冲突。
const again = await mock.importEntries(csv([rowAt('M800', '男子组', '张三', idCard, 'F1', '男')]))
assert.equal(again.created, 1)
assert.deepEqual(again.conflicts, [])
// 同名但单位不同:姓名 + 单位找不到人,只能新建,而证件号已被 F1 的张三占用 → 行级报错。
const taken = await mock.importEntries(csv([row('张三', idCard, 'F2', '男')]))
assert.equal(taken.created, 0)
assert.equal(taken.skipped, 1)
assert.deepEqual(taken.conflicts, [])
assert.equal(
taken.errors[0],
`第 2 行:证件号 ${idCard} 已被另一个人占用(姓名 张三,号码布 未填)`,
'与服务端 idCardTakenError 同一句中文文案',
)
// 别的人拿着这个证件号来:按证件号命中张三,姓名 / 单位对不上 → 冲突面板,不是行级报错。
const taken = await mock.importEntries(csv([row('李四', idCard, 'F2', '男')]))
assert.equal(taken.skipped, 0)
assert.deepEqual(taken.errors, [], '证件号命中那个人就不再报「已被占用」')
assert.equal(taken.conflicts.length, 1)
assert.deepEqual(taken.conflicts[0]?.changedFields, ['name', 'teamCode'])
assert.equal(mock.listAthletes().filter((athlete) => athlete.name === '张三').length, 1)
})
@@ -319,7 +407,14 @@ test('导入:号码布不同 → 按「同名的另一个人」新建并报告
assert.equal(result.created, 1, '行本身不拦截:新建了同名的另一个人')
assert.deepEqual(result.conflicts, [], '号码布不同不是冲突')
assert.deepEqual(result.sameNameNewPersons, [
{ name: '张伟', teamCode: 'JG', newBib: '999', existingBib: '101' },
{
name: '张伟',
teamCode: 'JG',
newIdCard: '110101199001010007',
existingIdCard: '',
newBib: '999',
existingBib: '101',
},
])
assert.equal(mock.listAthletes().length, before + 1)
assert.equal(mock.listAthletes().find((athlete) => athlete.bib === '999')?.idCard, '110101199001010007')
@@ -328,6 +423,49 @@ test('导入:号码布不同 → 按「同名的另一个人」新建并报告
assert.equal(mock.listAthletes().find((athlete) => athlete.bib === '101')?.teamId, 1)
})
// 证件号是唯一标识:号码布写错不影响认人,只把差异交给冲突决策。
test('导入:证件号优先命中,号码布对不上也只是冲突', async () => {
const mock = new MockBaseData()
// 种子数据里 JG 的「张伟」号码布为 101;先给他登记证件号。
const zhangWei = mock.listAthletes().find((athlete) => athlete.bib === '101')
assert.ok(zhangWei !== undefined)
const { id: athleteId, ...base } = zhangWei
mock.updateAthlete(athleteId, { ...base, idCard: 'C30003' })
const before = mock.listAthletes().length
const result = await mock.importEntries(csv([row('张伟', 'C30003', 'JG', '男', '7777')]))
assert.equal(result.created, 0, '冲突行不落库')
assert.equal(mock.listAthletes().length, before, '证件号命中那个人,不新建档案')
assert.equal(result.conflicts.length, 1)
assert.equal(result.conflicts[0]?.athleteId, athleteId)
assert.deepEqual(result.conflicts[0]?.changedFields, ['bib'])
})
// 姓名 + 单位相同、证件号不同 → 同名的另一个人:新建并报告(与号码布不同同一条路径)。
test('导入:姓名 + 单位相同但证件号不同 → 按同名的另一个人新建并报告', async () => {
const mock = new MockBaseData()
const first = await mock.importEntries(csv([rowAt('M100', '男子组', '同名甲', 'C10001', 'F1', '男')]))
assert.equal(first.created, 1)
assert.deepEqual(first.sameNameNewPersons, [])
const before = mock.listAthletes().length
const result = await mock.importEntries(csv([rowAt('M800', '男子组', '同名甲', 'C20002', 'F1', '男')]))
assert.equal(result.created, 1, '行本身不拦截:新建了同名的另一个人')
assert.deepEqual(result.conflicts, [], '证件号不同不是冲突')
assert.deepEqual(result.sameNameNewPersons, [
{
name: '同名甲',
teamCode: 'F1',
newIdCard: 'C20002',
existingIdCard: 'C10001',
newBib: '',
existingBib: '',
},
])
assert.equal(mock.listAthletes().length, before + 1)
assert.equal(mock.listAthletes().find((athlete) => athlete.idCard === 'C10001')?.idCard, 'C10001', '原档案证件号不动')
})
test('导入:同名同单位有多个人时一律进冲突面板,以第一条候选为代表', async () => {
const mock = new MockBaseData()
const firstId = mock.listAthletes().find((athlete) => athlete.bib === '101')?.id
@@ -50,6 +50,7 @@ test('示例数据的组别就地更新:重名与不存在给与服务端一
compare: seed.compare,
recordEligible: seed.recordEligible,
scored: seed.scored,
awardMedals: seed.awardMedals,
multiplier: seed.multiplier,
status: seed.status,
scheduledAt: null,
@@ -0,0 +1,148 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
/**
* 示例数据模式(后端不可达 / mock 生效)下破坏性写操作的统一拦截(P1(17))。
*
* 以前「批量停用 / 批量彻底删除」会照常走 mock 分支:内存里把人删掉、还提示
* 「已彻底删除 N 人」,操作员以为真删了。这里钉住三件事——
* 示例数据模式下不发写请求、返回可识别的「未提交」结果;连上服务端时行为不变;
* 拦截文案存在且页面入口会触发它。
*/
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
/** 记录每一次真实发出的请求;破坏性操作在示例数据模式下应当一条都不产生。 */
let calls: string[] = []
let reply: () => Response = () => jsonResponse({ ok: true, data: null })
// 必须在导入被测模块之前装好:ApiClient 构造时就把当时的 fetch 绑进实例。
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
calls.push(`${init?.method ?? 'GET'} ${String(input)}`)
return reply()
}) as typeof fetch
const {
ApiError,
SAMPLE_DATA_BLOCKED,
isSampleData,
mockMode,
mockReason,
sampleDataBlockedError,
sampleDataBlockedNotice,
} = await import('../api/client')
const { baseDataApi, baseDataFallback } = await import('../components/baseData/baseDataApi')
function blocked(error: unknown): boolean {
assert.ok(error instanceof ApiError)
assert.equal(error.code, SAMPLE_DATA_BLOCKED)
return true
}
/** 切到「后端不可达 / mock 生效」这一态:全局降级为真。 */
function enterSampleData(): void {
mockMode.value = true
mockReason.value = '测试:后端未就绪'
baseDataFallback.value = false
calls = []
}
/** 切回「连得上服务端」这一态。 */
function leaveSampleData(): void {
mockMode.value = false
mockReason.value = null
baseDataFallback.value = false
calls = []
}
test('示例数据模式下批量停用被阻断:不发写请求,返回可识别的「未提交」结果', async () => {
enterSampleData()
await assert.rejects(() => baseDataApi.athletes.batch('disable', [1, 2]), blocked)
assert.deepEqual(calls, [], '示例数据模式下不应发出任何写请求')
})
test('示例数据模式下批量彻底删除(预览与提交)都被阻断且不发请求', async () => {
enterSampleData()
// dryRun 预览与真删是同一流程的两步,示例数据里连预览都不该发。
await assert.rejects(() => baseDataApi.athletes.batch('purge', [1], true), blocked)
await assert.rejects(() => baseDataApi.athletes.batch('purge', [1]), blocked)
assert.deepEqual(calls, [])
})
test('示例数据模式下单条彻底删除(人员 / 单位)也被阻断', async () => {
enterSampleData()
await assert.rejects(() => baseDataApi.athletes.purge(1), blocked)
await assert.rejects(() => baseDataApi.teams.purge(1), blocked)
assert.deepEqual(calls, [])
})
test('基础数据接口降级(仅页面级)同样算示例数据模式', async () => {
// 服务端整体正常、只有基础数据接口未实现:mockMode 为 false,页面却仍在用示例数据。
mockMode.value = false
baseDataFallback.value = true
calls = []
assert.equal(isSampleData(), true, '统一判定要同时看全局与页面级降级')
await assert.rejects(() => baseDataApi.athletes.batch('disable', [1]), blocked)
assert.deepEqual(calls, [])
})
test('连上服务端时行为不变:真发请求并原样带回服务端结果', async () => {
leaveSampleData()
reply = () => jsonResponse({ ok: true, data: { action: 'purge', items: [{ id: 1, ok: true }] } })
const result = await baseDataApi.athletes.batch('purge', [1])
assert.equal(result.succeeded, 1)
assert.deepEqual(calls, ['POST /api/athletes/batch'])
assert.equal(mockMode.value, false)
})
test('拦截错误与提示文案可识别:说清是示例数据、未连接服务端', () => {
assert.equal(SAMPLE_DATA_BLOCKED, 'SAMPLE_DATA_BLOCKED')
const error = sampleDataBlockedError('批量彻底删除')
assert.equal(error.code, SAMPLE_DATA_BLOCKED)
assert.match(error.message, /示例数据/)
assert.match(error.message, /未连接服务端/)
assert.match(error.message, /批量彻底删除/)
const notice = sampleDataBlockedNotice('批量彻底删除')
assert.equal(notice.title, '示例数据模式不能批量彻底删除')
assert.match(notice.detail, /未连接服务端/)
})
// 项目没有组件测试工具(无 @vue/test-utils / jsdom),界面部分按仓库既有做法读源码断言。
const ATHLETES_SOURCE = readFileSync(
new URL('../components/baseData/AthletesSection.vue', import.meta.url),
'utf8',
)
const TEAMS_SOURCE = readFileSync(
new URL('../components/baseData/TeamsSection.vue', import.meta.url),
'utf8',
)
const SPORTS_EVENTS_SOURCE = readFileSync(
new URL('../components/baseData/SportsEventsSection.vue', import.meta.url),
'utf8',
)
test('运动员页的批量停用 / 批量彻底删除 / 单条彻底删除都先过统一拦截', () => {
assert.match(ATHLETES_SOURCE, /blockDestructiveWrite\('批量停用'\)/)
assert.match(ATHLETES_SOURCE, /blockDestructiveWrite\('批量彻底删除'\)/)
assert.match(ATHLETES_SOURCE, /blockDestructiveWrite\('彻底删除'\)/)
})
test('单位与大小项页的破坏性入口同样先过统一拦截', () => {
assert.match(TEAMS_SOURCE, /blockDestructiveWrite\('彻底删除'\)/)
assert.match(SPORTS_EVENTS_SOURCE, /blockDestructiveWrite\('删除大项'\)/)
assert.match(SPORTS_EVENTS_SOURCE, /blockDestructiveWrite\('删除小项'\)/)
})
+1
View File
@@ -18,6 +18,7 @@ function makeEvent(overrides: Partial<Event> = {}): Event {
compare: 'asc',
recordEligible: false,
scored: true,
awardMedals: true,
multiplier: 1,
status: 'pending',
scheduledAt: null,
+15 -8
View File
@@ -40,7 +40,7 @@ function respondWith(response: () => Response): void {
reply = response
}
const { ApiError, adminApi } = await import('../api/client')
const { ApiError, adminApi, mockMode, SAMPLE_DATA_BLOCKED } = await import('../api/client')
const { baseDataApi, baseDataFallback } = await import('../components/baseData/baseDataApi')
test('彻底删除走 DELETE /api/teams/{id}?purge=1', async () => {
@@ -117,16 +117,23 @@ test('teams 其它接口的 404 仍降级到示例数据(既有行为未受影
assert.equal(baseDataFallback.value, true)
})
// 后端整体连不上时仍降级到示例数据;假删除没有统计对象,示例数据也不能凭空造一个出来。
// 用 id=2 避开上一条已经删掉的 1(示例数据是模块级单例)。
test('后端不可用降级示例数据时,假删除照常生效且不返回统计', async () => {
// 后端整体连不上时不再降级示例数据:内存里「删掉」只是假象,后端恢复后单位又会回来,
// 操作员却以为已经彻底删了。必须给出可识别的「未提交」结果(SAMPLE_DATA_BLOCKED)。
test('后端不可用时彻底删除单位被阻断:不降级示例数据、不假装删掉了', async () => {
baseDataFallback.value = false
mockMode.value = false
respondWith(() => {
throw new TypeError('fetch failed')
})
const result = await baseDataApi.teams.purge(2)
assert.equal(result, undefined)
assert.equal(baseDataFallback.value, true)
await assert.rejects(
() => baseDataApi.teams.purge(2),
(error: unknown) => {
assert.ok(error instanceof ApiError)
assert.equal(error.code, SAMPLE_DATA_BLOCKED)
assert.match(error.message, /示例数据/)
return true
},
)
assert.equal(baseDataFallback.value, true, '写请求撞上后端不可达后,页面应切到示例数据标记')
})
+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, /当前为访客只读视图,团体名次只能查看,无法录入/)
})