一场机器人赛事的初赛结束后,计分员会希望立刻公布最终榜单,但裁判台上可能还有两条计时申诉正在处理中。此时页面显示的分数可以用于内部预览,却不应被误认为已经封存。上一节为成绩修订建立了追加式裁决事件和反向更正链,能解释每一次得分变化;本篇解决新的工程问题:在什么条件下允许把某个赛段从可修改状态切换为封存状态,并保证这次切换和最终榜单来自同一份数据。

单靠按钮前的一次申诉数量查询不够。检查完成之后、真正写入封存状态之前,其他窗口仍可能提交申诉或修正成绩。界面禁用按钮是交互提示,不是并发控制。需要由数据库事务包住状态读取、未结申诉判断、最终榜单快照和赛段状态变更,配合修订号比较,才有明确的写入边界。

赛事积分工作台的赛段封存与申诉拦截

封存是状态迁移,不是关掉一个按钮

沿用初赛甲组 preliminary-A。赛段表保留 stage_id、state、revision 和 sealed_at;申诉表按照赛段关联,申诉状态包括 PENDING、REVIEWING、RESOLVED、REJECTED;成绩表保留各队当前有效积分与修订号。封存表记录排名快照及对应赛段修订。这些都是业务数据结构,并不由ArkUI提供。

状态迁移只允许从 OPEN 进入 SEALED。当存在待审或复核中的申诉时,封存动作应返回业务拒绝;当客户端持有的修订号已过期时,返回冲突并重新读取;已经封存的赛段不能通过普通成绩编辑入口继续落账。之后确实需要变更时,应按照前文的裁决审计链另行定义授权操作,不能改写封存快照来覆盖历史。

先用纯 ArkTS 函数描述判断规则。它没有数据库副作用,因此可直接用于页面提示,也方便把异常组合整理成可读的测试输入。注意此函数只决定按钮呈现,不构成最终写入许可。

// model/StageSealPolicy.ets
export type StageState = 'OPEN' | 'SEALED';

export interface SealFacts {
  state: StageState;
  unresolvedAppeals: number;
  currentRevision: number;
  loadedRevision: number;
  hasSealRole: boolean;
}

export interface SealDecision {
  enabled: boolean;
  reason: string;
}

export function evaluateSeal(facts: SealFacts): SealDecision {
  if (!facts.hasSealRole) {
    return { enabled: false, reason: '当前账号不具备封榜权限' };
  }
  if (facts.state !== 'OPEN') {
    return { enabled: false, reason: '赛段已经封存' };
  }
  if (facts.unresolvedAppeals > 0) {
    return { enabled: false, reason: `还有${facts.unresolvedAppeals}条申诉待处理` };
  }
  if (facts.currentRevision !== facts.loadedRevision) {
    return { enabled: false, reason: '成绩修订已经变化,请刷新榜单' };
  }
  return { enabled: true, reason: '可以提交封榜申请' };
}

hasSealRole 应由当前账号的业务授权服务提供。权限提示本身可以在界面上呈现,但仓储写入路径必须再次校验调用方的授权上下文。特别是两个终端同时操作的情况,谁先取得有效修订和数据库写入机会,应由事务结果决定,而不是由按钮点击的时间决定。

将未结申诉和榜单快照放进同一事务

这里采用华为 ArkData 的关系型存储。RdbStore.createTransaction 与事务对象的 querySql、update、insert、commit、rollback 已列在华为开发者的事务接口资料中,使用时需检查工程选定 SDK 对应的 API 声明。封存动作使用立即事务模式来尽早获得写入机会,申诉创建、成绩更新也应走相同数据库并遵守赛段状态检查,否则任何单独写入路径都可能绕过封榜约束。

// service/StageSealRepository.ets
import { relationalStore } from '@kit.ArkData';

interface SnapshotRow {
  teamId: string;
  points: number;
  durationMs: number;
  rank: number;
}

export class StageSealRepository {
  constructor(private db: relationalStore.RdbStore) {}

  async seal(stageId: string, expectedRevision: number): Promise<boolean> {
    const tx = await this.db.createTransaction({
      transactionType: relationalStore.TransactionType.IMMEDIATE
    });
    let finished = false;
    try {
      const check = await tx.querySql(
        'SELECT COUNT(*) FROM appeal WHERE stage_id=? AND state IN (?, ?)',
        [stageId, 'PENDING', 'REVIEWING']
      );
      let pending = 0;
      try {
        if (check.goToFirstRow()) { pending = check.getLong(0); }
      } finally { check.close(); }
      if (pending !== 0) {
        await tx.rollback(); finished = true; return false;
      }

      const scores = await tx.querySql(
        'SELECT team_id, points, duration_ms FROM score ' +
        'WHERE stage_id=? ORDER BY points DESC, duration_ms ASC, team_id ASC',
        [stageId]
      );
      let payload: string = '';
      try {
        const rows: SnapshotRow[] = [];
        let previousPoints = -1;
        let previousRank = 0;
        while (scores.goToNextRow()) {
          const points = scores.getLong(1);
          const rank = points === previousPoints ? previousRank : rows.length + 1;
          rows.push({ teamId: scores.getString(0), points: points,
            durationMs: scores.getLong(2), rank: rank });
          previousPoints = points;
          previousRank = rank;
        }
        payload = JSON.stringify({ ruleVersion: '2026A', rows: rows });
      } finally { scores.close(); }

      const criteria = new relationalStore.RdbPredicates('stage_state')
        .equalTo('stage_id', stageId)
        .equalTo('state', 'OPEN')
        .equalTo('revision', expectedRevision);
      const now = Date.now();
      const changed = await tx.update({
        state: 'SEALED', revision: expectedRevision + 1, sealed_at: now
      }, criteria);
      if (changed !== 1) {
        await tx.rollback(); finished = true; return false;
      }
      await tx.insert('stage_snapshot', {
        stage_id: stageId,
        revision: expectedRevision + 1,
        content_json: payload,
        created_at: now
      });
      await tx.commit(); finished = true;
      return true;
    } catch (error) {
      if (!finished) { await tx.rollback(); }
      throw error;
    }
  }
}

快照应该明确区分“榜单展示值”与“计分原始字段”。用于界面的名次可能随同分策略、违例次数或赛制规则变化;封存数据至少要记录赛段、组别、队伍稳定 ID、积分、计时数据、规则版本和排名结果。如果只存了最终显示文本,以后就难以解释为什么同分队伍共享第二名。对于赛制中按完成时间排序、但同分共享名次的规则,排序索引与名次计算必须分别定义,不能把列表下标当成名次。

有两个细节需要特别交代。其一,stage_snapshot.stage_id 应有唯一约束,禁止为同一赛段重复创建正式封存记录;stage_state 的修订号与 state='OPEN' 一起组成更新谓词,可拒绝过期写入。其二,最终榜单不能用按钮点击时页面里保留的数组拼装,而应在事务中读取成绩。正式方案还应把队伍分组、同分名次、规则版本和裁决事件末端标识一并纳入快照结构,保证以后可以复现当时的排名依据,而不是依赖未来可能更改的计算规则。

白色主题开发环境中的封榜条件及事务入口

让其他写入路径共同遵守封榜闸门

封榜功能不是孤立的一条 SQL。假设新的申诉创建逻辑不检查赛段状态,那么管理员刚封存,另一个入口就可能插入一条待裁申诉。因此申诉写入与成绩更正应在自身事务内读取赛段状态,并在状态为 SEALED 时拒绝普通修改。采用同一存储的事务隔离,能让封存和修改在清晰的事务顺序中完成。

对成绩修订还要比较 score.revision,不能认为赛段未封存就等于修改一定有效。裁决反向更正、人工录入更正和申诉裁决都应把新的修订事件写入统一审计表;这使锁榜之前的修改路径可以收束,而不是出现多份彼此不一致的“最后成绩”。

数据库写入边界还要处理两个相互竞争的操作。假设封榜申请与新申诉创建几乎同时到达,理想的规则不是预测哪个请求先按下按钮,而是规定二者各自在获取数据库写入机会后读取当前赛段状态。申诉入口先完成并提交,封榜就会发现未结数量增加;封榜先提交,申诉入口就必须因赛段已封存而拒绝普通创建。任何脱离这个约束的后台批量导入、重试队列或维护脚本,都会重新打开不一致窗口,因此封存后的写权限要在所有仓储入口统一收口。

页面只呈现事实和下一步操作

界面上可把锁榜流程分成三块:顶部显示赛段 OPEN/SEALED 与最新修订,中间列出尚未结案的申诉数量,底部显示快照准备状态与操作按钮。每次返回这个页面时重新读取事实;点击封存后先锁住本页重复提交,再根据仓储返回值更新展示。仓储拒绝时必须重新查询,而不能把本地状态直接改成已封存。

// pages/StageSealPage.ets
import { SealFacts, SealDecision, evaluateSeal } from '../model/StageSealPolicy';

@Entry
@Component
struct StageSealPage {
  @State facts: SealFacts = {
    state: 'OPEN', unresolvedAppeals: 2,
    currentRevision: 18, loadedRevision: 18,
    hasSealRole: true
  };
  @State busy: boolean = false;
  @State notice: string = '';

  private getDecision(): SealDecision {
    return evaluateSeal(this.facts);
  }

  build() {
    Column({ space: 18 }) {
      Text('赛事积分台 · 赛段封榜').fontSize(24).fontWeight(FontWeight.Bold)
      Text(`赛段状态:${this.facts.state}`)
      Text(`最新成绩修订:${this.facts.currentRevision}`)
      Text(`待裁申诉:${this.facts.unresolvedAppeals} 条`)
        .fontColor(this.facts.unresolvedAppeals > 0 ? '#C64326' : '#16875C')
      Text(this.getDecision().reason).fontSize(14)
      Button(this.busy ? '正在处理' : '确认封榜')
        .enabled(this.getDecision().enabled && !this.busy)
        .onClick(() => {
          this.notice = '进入封榜事务,等待仓储返回后刷新赛段状态';
          this.busy = true;
          // 此处连接 StageSealRepository.seal 与重新读取逻辑
        })
      Text(this.notice).fontColor('#6B7280')
    }
    .padding(20)
    .width('100%')
  }
}

这段页面着重呈现状态绑定;实际接入时由页面服务取得 SealFacts,发起事务后在 finally 恢复 busy,并以新的数据库结果刷新 facts。不要把页面上的“满足条件”误当成具有写入权限的证明。对于权限发生变化、网络连接断开或进程离开等情况,数据库事务结果是唯一可靠的状态来源。

赛段待裁申诉尚未清零时的封榜界面

调试时优先检查并发时序

可以组织四组用例来定位问题:申诉数量为二时提交封榜,应停在未结申诉判断;所有申诉结案后提交,两名操作员使用同一个预期修订号,应该最多有一次更新成功;封榜事务读取快照期间尝试修改成绩,应由共同的事务规则决定先后;已经封存后再次提交,应返回状态冲突而不是覆盖旧快照。

记录建议保留赛段标识、前后修订号、申诉数量、事务结果和快照 ID;裁判个人身份与申诉附件内容不适合打印到通用日志。出现榜单和封存状态不一致时,应先查快照修订是否对应赛段修订,再查有没有某条业务写入路径绕开事务闸门。不要通过客户端刷新次数推断数据库一致性,也不要把一次查询返回顺序等同于封榜成功的先后顺序。

从工程设计上看,封榜是一道最终业务事务:把未结申诉、有效成绩、规则版本、最新修订和状态变更固化为可追溯的一次决定。下一篇将继续处理封存榜单的发布视图和异常恢复,让前面各篇累积的修订、裁决和审计记录能收敛到稳定的赛事结果。

相关文档

Logo

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

更多推荐