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

赛段封榜快照的只读发布流程

为什么封榜后还要核对版本

假设 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时重新排序。

DevEco Studio中的封存榜单只读页面

生成交付清单时保持可追溯性

业务上通常还需要交付一份可复制的文本清单,方便与赛段裁决记录对应。清单应明确标注赛段、快照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;对于读写交错,应确认公示读的是封存明细而非活动成绩表。客户端持有旧赛段请求时,新赛段的结果不能被覆盖;更换筛选条件也不应触发封存表的写入。封存后若新增授权更正,界面可以展示新旧版本关联,但原版快照的内容必须保持可访问,以便追踪哪个版本曾被公示。如此,赛事积分从录入到最终发布才形成完整的版本闭环。

相关开发文档

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐