HarmonyOS赛事积分连载08:封榜快照只读发布与修订核对【共创稿事节】
赛事的初赛甲组完成封榜之后,计分台进入另一种工作状态。工作人员仍要向参赛队伍展示排名、查阅积分和解释同分名次,但这些查询不应重新计算正在变化的工作表,更不能绕过封榜状态发起普通成绩更新。上一篇把未结申诉检查、修订号与最终快照绑定到封榜事务;这一篇处理后续的只读展示与交付前核对,让页面明确区分“仍在处理的实时榜单”和“已经封存的最终榜单”。

为什么封榜后还要核对版本
假设 preliminary-A 赛段以修订号18完成封榜,快照保存了青峰队92分、远航队88分、知行队88分以及星辰队81分。页面如果只根据赛段的 SEALED 状态打开实时成绩表,后续的管理操作可能已经使实时表产生新修订;读者看到的分数就不再等同于封榜时公布的内容。封榜展示必须读取快照本身,而不是用当前成绩重新拼装历史。
快照元信息至少包含赛段ID、封榜版本、封存时间、快照ID和只读状态;明细保存队伍ID、队伍名称、积分、耗时、名次以及明细顺序。队伍名称可能在档案中修改,封存明细仍应保留封榜时对外展示的名称。页面打开前还要核对明细是否与同一个快照ID绑定、行数是否与元信息相符、名次序列是否符合该赛段的并列排名规则。
工程延续前七篇的赛事积分应用,不新增另一套计分算法:model/PublishedBoard.ets 保存只读结构,service/SealedBoardReader.ets 读取同一事务快照,service/BoardVerifier.ets 实施纯数据核对,pages/PublishedBoardPage.ets 展示结果。原本的成绩更正、申诉裁决和封榜状态机继续留在各自业务模块,不让公开读取路径触发写操作。
用稳定的快照数据结构表达事实
排名规则在系列第一篇已明确:积分降序,积分相同按耗时升序展示;同分共享名次,下一名按实际队伍数跳号。因此样本的显示顺序为92、88、88、81,名次依次为1、2、2、4。计算名次应使用该封存赛段保存的规则版本,不以用户当前选择的其他组别规则重新解释历史。明细中的展示顺序只是阅读顺序,并不代替业务身份。
// model/PublishedBoard.ets
export interface PublishedRow {
teamId: string;
teamName: string;
points: number;
durationSec: number;
rank: number;
displayOrder: number;
}
export interface PublishedBoard {
snapshotId: string;
stageId: string;
stageRevision: number;
ruleVersion: string;
sealedAt: string;
declaredRows: number;
rows: PublishedRow[];
}
export interface BoardCheck {
ok: boolean;
reasons: string[];
}
export function checkPublishedBoard(board: PublishedBoard): BoardCheck {
const reasons: string[] = [];
if (!board.snapshotId || board.stageRevision < 0 || !board.ruleVersion) {
reasons.push('快照标识或修订信息缺失');
}
if (board.rows.length !== board.declaredRows) reasons.push('明细数量与快照记录不一致');
const seen = new Set<string>();
let lastPoints = Number.POSITIVE_INFINITY;
let currentRank = 0;
board.rows.forEach((row: PublishedRow, index: number) => {
if (!row.teamId || seen.has(row.teamId)) reasons.push('队伍身份缺失或重复');
seen.add(row.teamId);
if (!Number.isFinite(row.points) || row.points > lastPoints) reasons.push('积分顺序异常');
currentRank = index === 0 || row.points !== lastPoints ? index + 1 : currentRank;
if (row.rank !== currentRank || row.displayOrder !== index + 1) {
reasons.push('名次或展示序号不符合封存规则');
}
lastPoints = row.points;
});
return { ok: reasons.length === 0, reasons };
}
上面的检查并非数据库约束的替代品。数据库建表时仍应给快照ID、赛段版本及队伍身份建立相应唯一约束,避免重复明细进入封存表。这里的纯函数用于在读取完成后发现展示层可感知的异常。如果同分队伍耗时次序也需要核验,可以继续比较同分段内的 durationSec 与稳定队伍ID;一旦发现不匹配,应该暂停该快照的公开展示并安排数据核对,而不是自行改变已封存的明细。
在一次数据库读取范围内取出快照与明细
华为 ArkData 的 RdbStore.createTransaction 可以创建事务对象,事务对象提供 querySql、commit 和 rollback。在同一读取范围内先取得快照元数据,再按同一 snapshot_id 读取明细,能够减少两次独立请求之间元数据与行集合不一致的机会。下面的仓储只读方法以数据库已存在 stage_snapshot 与 stage_snapshot_row 两张表为前提,字段由应用业务建表脚本定义,不是系统自动生成的表名。
// service/SealedBoardReader.ets
import { relationalStore } from '@kit.ArkData';
import { PublishedBoard, PublishedRow } from '../model/PublishedBoard';
export class SealedBoardReader {
constructor(private store: relationalStore.RdbStore) {}
async load(stageId: string): Promise<PublishedBoard | null> {
const tx = await this.store.createTransaction();
let finished = false;
try {
const meta = await tx.querySql(
'SELECT snapshot_id, revision, rule_version, sealed_at, row_count FROM stage_snapshot WHERE stage_id=? AND state=?',
[stageId, 'SEALED']);
let snapshotId = ''; let revision = 0; let ruleVersion = '';
let sealedAt = ''; let declaredRows = 0;
try {
if (!meta.goToFirstRow()) { await tx.commit(); finished = true; return null; }
snapshotId = meta.getString(meta.getColumnIndex('snapshot_id'));
revision = Number(meta.getLong(meta.getColumnIndex('revision')));
ruleVersion = meta.getString(meta.getColumnIndex('rule_version'));
sealedAt = meta.getString(meta.getColumnIndex('sealed_at'));
declaredRows = Number(meta.getLong(meta.getColumnIndex('row_count')));
} finally { meta.close(); }
const result = await tx.querySql(
'SELECT team_id, team_name, points, duration_sec, rank, display_order FROM stage_snapshot_row WHERE snapshot_id=? ORDER BY display_order',
[snapshotId]);
const rows: PublishedRow[] = [];
try {
while (result.goToNextRow()) {
rows.push({
teamId: result.getString(result.getColumnIndex('team_id')),
teamName: result.getString(result.getColumnIndex('team_name')),
points: Number(result.getLong(result.getColumnIndex('points'))),
durationSec: Number(result.getLong(result.getColumnIndex('duration_sec'))),
rank: Number(result.getLong(result.getColumnIndex('rank'))),
displayOrder: Number(result.getLong(result.getColumnIndex('display_order')))
});
}
} finally { result.close(); }
await tx.commit(); finished = true;
return { snapshotId, stageId, stageRevision: revision,
ruleVersion, sealedAt, declaredRows, rows };
} finally { if (!finished) await tx.rollback(); }
}
}
查询条件使用参数绑定而不是字符串拼接。即使赛段ID来源于页面传参,也要避免把未经处理的内容放进SQL语句。结果集必须在读取后关闭,失败时释放事务;事务返回的空结果与读取失败需要在页面上分成不同状态。正式接入时,封榜写入路径应将元数据和明细放在同一事务提交,确保读者不会看到只有元信息、没有榜单行的半成品。
只读界面与工作榜单必须分离
封存页面需要清晰显示“已封榜”、封存时间、规则版本及修订号,避免用户把历史快照当作正在滚动更新的积分。切换到不同赛段时,只接受最新请求返回的结果;离开页面时清理接收标记,防止旧赛段覆盖当前标题。封存榜单禁止出现普通的分数输入框、删除按钮和直接更正入口。裁决记录可以提供独立只读跳转,但是否允许后续授权更正,应由原有审计业务来决定。
// pages/PublishedBoardPage.ets
import { PublishedBoard, PublishedRow, checkPublishedBoard } from '../model/PublishedBoard';
@Entry
@Component
struct PublishedBoardPage {
@State board: PublishedBoard | null = null;
@State message: string = '正在读取封存快照';
private requestEpoch: number = 0;
aboutToDisappear(): void { this.requestEpoch += 1; }
show(board: PublishedBoard, epoch: number): void {
if (epoch !== this.requestEpoch) return;
const checked = checkPublishedBoard(board);
if (!checked.ok) {
this.board = null;
this.message = checked.reasons.join(';');
return;
}
this.board = board;
this.message = '';
}
build() {
Column({ space: 12 }) {
Text('赛段封榜公示').fontSize(26).fontWeight(FontWeight.Bold)
if (this.board !== null) {
Text(`封存修订:${this.board.stageRevision} · 规则:${this.board.ruleVersion}`)
List() {
ForEach(this.board.rows, (row: PublishedRow) => {
ListItem() {
Row() {
Text(`${row.rank}`).width(56)
Text(row.teamName).layoutWeight(1)
Text(`${row.points}分`)
}.padding(12)
}
}, (row: PublishedRow) => row.teamId)
}
} else {
Text(this.message).fontColor(Color.Gray)
}
}.padding(16).width('100%').height('100%')
}
}
此处的 show 由异步仓储读取完成后调用,调用端必须先取得当前请求世代;更换赛段就增加世代并重发请求。仅依赖页面 aboutToDisappear 还不足以覆盖同一页面内的赛段切换,所以标签选择、筛选改变也要令旧世代失效。榜单原有“同分并列、按耗时展示”的算法在封存时已完成,公示界面只消费快照保存的 rank,不在每次构建UI时重新排序。

生成交付清单时保持可追溯性
业务上通常还需要交付一份可复制的文本清单,方便与赛段裁决记录对应。清单应明确标注赛段、快照ID、封存修订、规则版本、封存时间、队伍数量以及每条队伍分数。先核对快照,再构造文本;核对失败则停止导出。如果要进一步生成本地文件,交由独立的文件服务处理路径、安全保存和分享授权,不让页面代码直接写入公共目录。
// service/PublishedManifest.ets
import { PublishedBoard, checkPublishedBoard } from '../model/PublishedBoard';
export function buildPublishedManifest(board: PublishedBoard): string {
const check = checkPublishedBoard(board);
if (!check.ok) throw new Error(check.reasons.join(';'));
const lines: string[] = [
`赛段,${board.stageId}`,
`快照,${board.snapshotId}`,
`修订,${board.stageRevision}`,
`规则版本,${board.ruleVersion}`,
`封存时间,${board.sealedAt}`,
`队伍数量,${board.rows.length}`,
'名次,队伍,积分,耗时秒数'
];
board.rows.forEach((row) => {
lines.push(`${row.rank},${row.teamName.replace(/,/g, ',')},${row.points},${row.durationSec}`);
});
return lines.join('\n');
}
如果赛段包含大量队伍,清单生成要避免把整个数据库表一次性展开到界面组件;可以使用分批读取和分页展示,而封存规则与快照ID必须保持不变。还应检查快照明细中的字段范围,例如积分不得出现非有限值、队伍ID不得重复、缺少规则版本时暂停发布。另需对照审计事件确认封存时间早于后续授权调整,并保证后续追加事件不会悄悄重写历史快照。

从第一篇的同分排名,到分组隔离、修订竞争、申诉复核、裁决落账、反向更正和赛段锁榜,这套工作流最后落在一份可检索的只读快照上。封榜不是删除编辑能力,而是为“哪一个版本曾经正式公布”建立持久身份。业务页面读取快照、校验版本与明细、拒绝过期请求,再交付含版本信息的文本清单,才不会让赛后的排名查询把历史与当前状态混在一起。
将发布异常与业务拒绝分开处理
公示界面读取时可以遇到三类结果:尚未封榜、已封榜但快照明细不完整、已封榜且快照数据可用。第一种应展示“赛段仍在计分”并引导回工作榜单;第二种应显示数据核对入口而不是推测缺失队伍的名次;只有第三种才提供复制清单或向外发布操作。数据库读取异常还需作为第四种状态提示“稍后重试”,不能把异常当成空快照。这样做能避免将业务进度、数据故障和访问失败混成一个灰色空页面。
在排查时,重点收集 stage_id、snapshot_id、revision 与 row_count 四组字段。对于并列排名,既要复核 88 分队伍的并列名次,也要复核下一队的名次是否跳过 3;对于读写交错,应确认公示读的是封存明细而非活动成绩表。客户端持有旧赛段请求时,新赛段的结果不能被覆盖;更换筛选条件也不应触发封存表的写入。封存后若新增授权更正,界面可以展示新旧版本关联,但原版快照的内容必须保持可访问,以便追踪哪个版本曾被公示。如此,赛事积分从录入到最终发布才形成完整的版本闭环。
相关开发文档
更多推荐


所有评论(0)