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:
1 parent
53a0cb225c
commit
45edfbeb71
115 files changed
+10104
-755
No files matched your search
@@ -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 再决定是否调用。
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user