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

+62 -1
View File
@@ -1,6 +1,6 @@
import { ref } from 'vue'
import { ApiError, createApiClient } from '@/shared/api'
import type { AthletePurgeResult, DisplayInstance } from '@/shared/api'
import type { AthletePurgeResult, DisplayInstance, EntryBoard } from '@/shared/api'
import type {
AthleteStanding,
AuditLog,
@@ -25,6 +25,8 @@ import type {
StandingRow,
Team,
TeamBreakdown,
TeamRanksInput,
TeamRanksView,
} from '@/shared'
import { mockDb } from './mock'
@@ -43,6 +45,45 @@ const FALLBACK_CODES = ['NETWORK', 'TIMEOUT']
export const mockMode = ref(false)
export const mockReason = ref<string | null>(null)
/**
* 页面级降级标记也参与「示例数据」判定。
*
* 只看全局 mockMode 不够:服务端整体正常、只有某个页面的接口尚未实现时,
* bootstrap 会把 mockMode 复位成 false,那个页面却仍在用示例数据。
*/
const fallbackSources = new Set<() => boolean>()
export function registerFallbackSource(source: () => boolean): void {
fallbackSources.add(source)
}
/** 统一的「当前是否处于示例数据模式」判定;破坏性写操作一律先问它。 */
export function isSampleData(): boolean {
if (mockMode.value) return true
for (const source of fallbackSources) {
if (source()) return true
}
return false
}
/** 示例数据模式下写操作被拦下时的错误码。 */
export const SAMPLE_DATA_BLOCKED = 'SAMPLE_DATA_BLOCKED'
/**
* 示例数据模式下破坏性操作的统一拦截错误。
*
* 用它把「没有提交」与「真的失败了」分开:页面据此给出「未连接服务端」的说明,
* 而不是把内存里的改动当成落库成功。
*/
export function sampleDataBlockedError(action: string): ApiError {
return new ApiError(SAMPLE_DATA_BLOCKED, `当前是示例数据,未连接服务端,无法${action}`, 0)
}
/** 与上一条同源的提示文案(标题 / 说明),供页面 toast 直接使用。 */
export function sampleDataBlockedNotice(action: string): { title: string; detail: string } {
return { title: `示例数据模式不能${action}`, detail: '未连接服务端,操作不会提交' }
}
let unauthorizedHandler: (() => void) | null = null
export function setUnauthorizedHandler(handler: () => void): void {
@@ -142,6 +183,26 @@ export const adminApi = {
entries: (eventId: number): Promise<EntryWithResult[]> =>
call(() => client.events.entries(eventId), () => mockDb.listEntries(eventId)),
/**
* 录入页的完整读数:成绩行 + 随行的团体手工名次。
*
* 与 entries 是同一个接口,只是这里把 teamRanks 也留下;拿不到时页面再退到
* `GET .../team-ranks`,不会因为老服务端不多发这个字段就丢掉已有名次。
*/
entryBoard: (eventId: number): Promise<EntryBoard> =>
call(() => client.events.entryBoard(eventId), () => mockDb.entryBoard(eventId)),
/** 读某个组别的团体手工名次(设计 17 §七)。 */
teamRanks: (eventId: number, categoryId: number): Promise<TeamRanksView> =>
call(() => client.events.teamRanks(eventId, categoryId), () => mockDb.teamRanks(eventId, categoryId)),
/**
* 写团体手工名次:写操作不降级。示例数据模式下没有后端可落库,
* 页面会先看 data.isMock 再决定是否调用(与成绩草稿、发布同一口径)。
*/
saveTeamRanks: (eventId: number, categoryId: number, input: TeamRanksInput): Promise<TeamRanksView> =>
callWrite(() => client.events.saveTeamRanks(eventId, categoryId, input)),
/**
* 批量设置 / 清除批次(heat)。写操作不降级:示例数据模式下没有后端可落库,
* 页面会先看 data.isMock 再决定是否调用。
+83 -3
View File
@@ -26,6 +26,9 @@ import type {
Team,
TeamBreakdown,
BreakdownItem,
TeamRankEntry,
TeamRanksInput,
TeamRanksView,
} from '@/shared'
import { computeEventScore, type ScorableRow } from './scoring'
@@ -110,6 +113,8 @@ interface EventSeed {
resultType: ResultType
compare: Event['compare']
scored: boolean
/** 是否结算奖牌;缺省 true(与库里的 award_medals 默认值同口径)。 */
awardMedals?: boolean
categoryName: string
/** 「个人+团体」的团体成绩平均口径;缺省按 valid。 */
avgMode?: AvgMode
@@ -221,6 +226,7 @@ const EVENT_SEEDS: EventSeed[] = [
resultType: 'none',
compare: 'none',
scored: false,
awardMedals: false,
categoryName: '混合组',
},
{
@@ -295,6 +301,8 @@ class MockDb {
private readonly categories: EventCategory[]
private readonly channels: ScoreChannel[]
private readonly entries: Entry[] = []
/** 团体手工名次,键为 `eventId:categoryId`(与库表 (小项, 组别, scope, basis) 的定位口径一致)。 */
private readonly teamManualRanks = new Map<string, TeamRankEntry[]>()
private readonly results = new Map<number, Result>()
private readonly logs: AuditLog[] = []
private nextResultId = 9000
@@ -312,6 +320,7 @@ class MockDb {
compare: seed.compare,
recordEligible: seed.resultType === 'time' || seed.resultType === 'distance',
scored: seed.scored,
awardMedals: seed.awardMedals ?? true,
multiplier: 1,
status: seed.published === undefined ? 'pending' : 'published',
scheduledAt: null,
@@ -332,12 +341,16 @@ class MockDb {
sortOrder: 1,
}))
// 「个人+团体」按规则包默认模板配两条通道:个人通道只记个人分,团体通道取队内平均。
// 「个人+团体」按规则包默认模板配通道:个人通道只记个人分,团体通道取队内平均,
// 另加一条按单位手工录名次的团体通道(团体引体向上就是这么记分的),
// 录入页的「团体名次」区块在示例数据里也就能走通。
this.channels = EVENT_SEEDS.flatMap((seed): ScoreChannel[] => {
if (seed.eventType === 'individual_team') {
return [
{ id: seed.id * 10 + 1, eventId: seed.id, scope: 'athlete', basis: 'result', profileId: 1, sortOrder: 1, countToTeam: false },
{ id: seed.id * 10 + 2, eventId: seed.id, scope: 'team', basis: 'aggregate', profileId: 2, sortOrder: 2, countToTeam: true },
{ id: seed.id * 10 + 1, eventId: seed.id, scope: 'athlete', basis: 'result', profileId: 1, sortOrder: 1, countToTeam: false, countToMedals: true, countToTeamMedals: true },
// 队内平均通道只出积分、不发牌,正好演示「已关闭奖牌」的徽标。
{ id: seed.id * 10 + 2, eventId: seed.id, scope: 'team', basis: 'aggregate', profileId: 2, sortOrder: 2, countToTeam: true, countToMedals: false, countToTeamMedals: true },
{ id: seed.id * 10 + 3, eventId: seed.id, scope: 'team', basis: 'manual', profileId: 2, sortOrder: 3, countToTeam: true, countToMedals: true, countToTeamMedals: true },
]
}
return [{
@@ -348,9 +361,17 @@ class MockDb {
profileId: seed.eventType === 'individual' ? 1 : 2,
sortOrder: 1,
countToTeam: true,
countToMedals: true,
countToTeamMedals: true,
}]
})
// 团体手工名次:TPULL(108)预先填两个单位,用来演示「已有值回填」。
this.teamManualRanks.set(teamRanksKey(108, 108 * 10 + 1), [
{ teamId: this.teams[0]?.id ?? 1, rank: 1 },
{ teamId: this.teams[1]?.id ?? 2, rank: 2 },
])
for (const seed of EVENT_SEEDS) this.seedEntries(seed)
for (const seed of EVENT_SEEDS) this.seedResults(seed)
}
@@ -549,6 +570,60 @@ class MockDb {
})
}
/**
* 成绩录入接口的完整读数:成绩行 + 该小项的团体手工名次。
*
* 真服务端把 teamRanks 随行下发是为了省一轮请求;示例数据同样给,
* 录入页的「团体名次」区块在示例数据模式下才走得到与真服务端一致的分支。
*/
entryBoard(eventId: number): { entries: EntryWithResult[]; teamRanks: TeamRanksView[] } {
return { entries: this.listEntries(eventId), teamRanks: this.teamRanksViewsOf(eventId) }
}
/** 该小项所有配了团体手工通道的组别视图。 */
private teamRanksViewsOf(eventId: number): TeamRanksView[] {
return this.categories
.filter(
(category) =>
category.eventId === eventId &&
this.channels.some(
(channel) =>
channel.eventId === eventId && channel.scope === 'team' && channel.basis === 'manual',
),
)
.map((category) => this.teamRanksView(eventId, category.id))
}
teamRanks(eventId: number, categoryId: number): TeamRanksView {
return this.teamRanksView(eventId, categoryId)
}
private teamRanksView(eventId: number, categoryId: number): TeamRanksView {
return {
categoryId,
channels: this.channels
.filter((channel) => channel.eventId === eventId && channel.scope === 'team' && channel.basis === 'manual')
.map((channel) => ({
channelId: channel.id,
scope: channel.scope,
basis: channel.basis,
profileId: channel.profileId,
})),
teams: this.teams
.filter((team) => team.active)
.map((team) => ({ teamId: team.id, code: team.code, name: team.name, shortName: team.shortName })),
ranks: (this.teamManualRanks.get(teamRanksKey(eventId, categoryId)) ?? []).map((entry) => ({ ...entry })),
}
}
/** 整组替换该组别的团体手工名次;rank <= 0 表示清空(与真服务端一致)。 */
saveTeamRanks(eventId: number, categoryId: number, input: TeamRanksInput): TeamRanksView {
const ranks = input.ranks.filter((entry) => entry.rank > 0).map((entry) => ({ ...entry }))
this.teamManualRanks.set(teamRanksKey(eventId, categoryId), ranks)
this.log('channel.team_ranks', 'event', eventId, `保存团体名次 ${ranks.length} 条`)
return this.teamRanksView(eventId, categoryId)
}
saveDraft(payload: SaveDraftPayload): Result[] {
const saved = this.writeResults(payload.eventId, payload.categoryId, payload.results, 'draft')
this.log('result.draft', 'result', payload.eventId, `保存草稿 ${saved.length} 条`)
@@ -765,4 +840,9 @@ function round(value: number): number {
return Number(value.toFixed(2))
}
/** 团体手工名次的定位键:(小项, 组别) —— 与 channel_manual_ranks 的唯一键同口径。 */
function teamRanksKey(eventId: number, categoryId: number): string {
return `${eventId}:${categoryId}`
}
export const mockDb = new MockDb()