赛事积分榜的排序和分组规则稳定后,实际运行中最棘手的情况往往不是新成绩,而是已经公布的成绩需要更正。裁判可能发现一次计时录入错误,仲裁人员也可能调整违规扣分。同一队伍的成绩此时会同时出现在裁判端编辑页、场控大屏和组内排行榜。若所有页面都直接提交一份完整成绩对象,较早打开的编辑页就可能覆盖较新结果。解决办法不是强行让页面始终刷新,而是把每次修改变为携带期望版本的更正请求,让存储层明确拒绝过期写入。

成绩更正与修订竞争封面

成绩事实与展示排名应解耦

上一节已经把榜单上下文固定为“赛事阶段、组别、规则版本”。本节继续沿用这条边界,每条原始成绩增加一个单调递增的 revision。积分、完成时长和违规次数属于事实字段;计算得到的名次属于派生字段,不能与输入记录一起由编辑页面覆盖。更正一个队伍的积分后,应重算所属阶段和组别的排名快照,而不是逐个修改其他队伍存储记录中的名次。这一分离能够减少联动写入,也避免同分处理在不同界面出现两套算法。

一个更正请求需要明确四件事:目标成绩 ID、编辑者看到的修订号、目标数值,以及更正原因。期望版本并非客户端决定结果的授权,它只是用于判断编辑基础是否已经过时。举例说,青峰队的初赛甲组成绩从92分调整到90分,编辑页基于修订4提交;如果另一名裁判已经把记录更新到修订5,旧页面的请求必须进入冲突分支。此时把新值直接替换进去,会让仲裁过程失去可追溯性。

工程结构分成原始成绩模型、版本化写入服务、组内排名计算和更正页面,让每层都有清楚职责:

entry/src/main/ets/
├── model/ScoreRevision.ets
├── service/ScoreLedger.ets
├── service/ScoreRepository.ets
├── service/GroupRankingService.ets
└── pages/ScoreCorrectionPage.ets

要解决的问题: 把一次修改描述为业务事件,而不是对当前数组的任意赋值。

// model/ScoreRevision.ets
export interface ScoreRecord {
  id: string;
  phaseId: string;
  groupId: string;
  teamId: string;
  points: number;
  durationSec: number;
  revision: number;
}

export interface ScoreCorrection {
  recordId: string;
  expectedRevision: number;
  points: number;
  durationSec: number;
  editorId: string;
  reason: string;
}

export interface ScoreChangeEvent {
  recordId: string;
  beforePoints: number;
  afterPoints: number;
  beforeDuration: number;
  afterDuration: number;
  fromRevision: number;
  toRevision: number;
  editorId: string;
  reason: string;
}

export type CorrectionStatus = 'applied' | 'stale' | 'invalid' | 'missing';
export interface CorrectionResult {
  status: CorrectionStatus;
  latest: ScoreRecord | null;
}

模型里不传名次是刻意设计。不同赛段可能使用不同的规则版本,名次是相应快照的运算结果。把名次带进更正请求,就相当于允许任意页面替全组决定排序,之后很难说明为何同分队伍突然出现不同名次。编辑者 ID 与原因也不是装饰性字段,原因不能为空;需要保留原值与新值,供后续审阅和撤销补偿使用。页面中看到的 expectedRevision 应来自打开编辑页时读取的那条记录,而非点击保存时临时自增出来的数字。

用比较并交换阻止过期覆盖

应用内部先实现同步领域逻辑,能清楚表达所有分支:不存在的成绩返回 missing;分数超出范围、时长非法或原因空缺返回 invalid;版本不匹配返回 stale;只有校验通过且修订号相等,才更新值和生成修订事件。业务校验必须先于任何状态变更。更新与追加事件在这个纯内存版本中连续完成;换成多线程或多端服务后,应把相同约束迁移至事务内执行,不能把同步方法当作跨进程锁。

要解决的问题: 用明确的返回类型表达拒绝原因,并保证已有结果不会因过期请求改变。

// service/ScoreLedger.ets
import { ScoreRecord, ScoreCorrection, ScoreChangeEvent,
  CorrectionResult } from '../model/ScoreRevision';

export class ScoreLedger {
  private scores: Map<string, ScoreRecord> = new Map();
  private history: ScoreChangeEvent[] = [];

  constructor(seed: ScoreRecord[]) {
    seed.forEach((item: ScoreRecord) => this.scores.set(item.id, { ...item }));
  }

  apply(patch: ScoreCorrection): CorrectionResult {
    const old = this.scores.get(patch.recordId);
    if (!old) { return { status: 'missing', latest: null }; }
    if (!Number.isFinite(patch.points) || !Number.isFinite(patch.durationSec) ||
        patch.points < 0 || patch.points > 100 ||
        patch.durationSec <= 0 || !patch.reason.trim() ||
        !patch.editorId.trim()) {
      return { status: 'invalid', latest: { ...old } };
    }
    if (old.revision !== patch.expectedRevision) {
      return { status: 'stale', latest: { ...old } };
    }
    const next: ScoreRecord = {
      ...old, points: patch.points,
      durationSec: patch.durationSec, revision: old.revision + 1
    };
    const event: ScoreChangeEvent = {
      recordId: old.id,
      beforePoints: old.points, afterPoints: next.points,
      beforeDuration: old.durationSec, afterDuration: next.durationSec,
      fromRevision: old.revision, toRevision: next.revision,
      editorId: patch.editorId, reason: patch.reason.trim()
    };
    this.scores.set(old.id, next);
    this.history.push(event);
    return { status: 'applied', latest: { ...next } };
  }

  listGroup(phaseId: string, groupId: string): ScoreRecord[] {
    return Array.from(this.scores.values())
      .filter((v: ScoreRecord) => v.phaseId === phaseId && v.groupId === groupId)
      .map((v: ScoreRecord) => ({ ...v }));
  }

  events(): ScoreChangeEvent[] { return this.history.slice(); }
}

这里的 Map 用记录 ID 作键,保证调用方不会因为数组位置变化误改另一条数据。listGroup 返回副本,也能避免外部代码绕过 apply 修改内部对象。历史数组采用追加方式,展示时可以按修订号升序查看;如果接入审核流程,还可以增加审核人和审批状态,但不应回写旧事件。注意无论采用何种界面,过期请求都不能“自动以最新版本重试”,因为用户提交的新值建立在旧数值与旧扣分规则之上,自动重试等于未经核对地重新裁决。

关系库中的事务提交必须是一个整体

内存中的版本比较便于描述规则,真正的持久化还要考虑两个写入者在不同进程的竞争。合理的关系库写法是在同一事务中完成比较版本的更新和更正事件的插入。数据库使用 WHERE id = ? AND revision = ? 条件,更新行数必须为1;如果是0,就表示记录不存在或版本已变化,回滚后重新读取当前成绩。事务提交成功之后,再通知所属分组的排行榜失效。更新记录与写入事件如果被拆成两个互不关联的操作,中途失败时就会出现“分数变了但没有历史”的缺口。

较新的 ArkData 接口提供 RdbStore.createTransaction、Transaction.update、Transaction.insert、commit 和 rollback。使用这一实现时,要在应用所选 SDK 的声明文件中核对接口版本;不同发行版本的关系库事务入口存在差异。下面将事务对象封装在仓储方法内部,页面拿不到它,也就不会把事务生命周期拖进视图层。

要解决的问题: 让成绩修订与事件留痕同时生效,并区分冲突和存储异常。

// service/ScoreRepository.ets 的核心提交方法
import { relationalStore } from '@kit.ArkData';
import { ScoreCorrection } from '../model/ScoreRevision';

export class ScoreRepository {
  constructor(private store: relationalStore.RdbStore) {}

  async correct(patch: ScoreCorrection): Promise<boolean> {
    if (!Number.isFinite(patch.points) || patch.points < 0 ||
        patch.points > 100 || patch.durationSec <= 0 ||
        !patch.reason.trim()) { throw new Error('更正内容无效'); }
    const tx = await this.store.createTransaction({
      transactionType: relationalStore.TransactionType.IMMEDIATE
    });
    let finished = false;
    try {
      const p = new relationalStore.RdbPredicates('score_record');
      p.equalTo('id', patch.recordId);
      p.equalTo('revision', patch.expectedRevision);
      const rows = await tx.query(p, ['points', 'duration_sec']);
      let beforePoints: number = 0;
      let beforeDuration: number = 0;
      try {
        if (!rows.goToFirstRow()) {
          await tx.rollback();
          finished = true;
          return false;
        }
        beforePoints = rows.getDouble(rows.getColumnIndex('points'));
        beforeDuration = rows.getDouble(rows.getColumnIndex('duration_sec'));
      } finally { rows.close(); }
      const affected = await tx.update({
        points: patch.points,
        duration_sec: patch.durationSec,
        revision: patch.expectedRevision + 1
      }, p);
      if (affected !== 1) {
        await tx.rollback();
        finished = true;
        return false;
      }
      await tx.insert('score_event', {
        record_id: patch.recordId,
        before_points: beforePoints, after_points: patch.points,
        before_duration: beforeDuration, after_duration: patch.durationSec,
        revision_from: patch.expectedRevision,
        revision_to: patch.expectedRevision + 1,
        editor_id: patch.editorId,
        reason: patch.reason.trim()
      });
      await tx.commit();
      finished = true;
      return true;
    } finally {
      if (!finished) { await tx.rollback(); }
    }
  }
}

这段仓储方法默认输入已通过领域校验,score_record 与 score_event 的表结构需分别包含被引用的列,且后者还应增加原始分值、原始耗时等审计字段。这里把前后值放在同一个事务中读取并写入,避免使用过期页面快照构造历史。事务回滚自身也可能报错,外层调用方需要记录完整异常上下文并重新读取状态,不可向用户直接报告“已更正”。成功提交后,还应依据 phaseId 与 groupId 精确失效排名快照,而不是刷新其他阶段的全部列表。

成绩修订的开发环境与组内榜单预览

更正页面只管理草稿和冲突提示

用户打开编辑页时,应先看到当前有效分数、修订号和上次更正原因。输入新分值后,界面保留草稿;点击提交才调用更正服务。收到 stale 时,展示“当前版本已变化”和最新分值,让编辑者选择放弃本次改动或基于最新记录重新填写。错误提示不能把原始输入抹掉,否则工作人员就无法对照自己的修改意图。提交按钮也要防止连续点击,异步提交过程中禁止第二次请求。

要解决的问题: 用页面状态呈现冲突,而不在控件事件里私自修改分数事实。

// pages/ScoreCorrectionPage.ets 的页面核心
import { ScoreLedger } from '../service/ScoreLedger';

@Entry
@Component
struct ScoreCorrectionPage {
  @State valueText: string = '90';
  @State reason: string = '计时复核后调整积分';
  @State message: string = '待提交';
  @State sending: boolean = false;
  private ledger: ScoreLedger = new ScoreLedger([{
    id: 'score-a1', phaseId: 'qualifier', groupId: 'A',
    teamId: '青峰队', points: 92, durationSec: 120, revision: 4
  }]);
  private expectedRevision: number = 4;

  private submit(): void {
    if (this.sending) { return; }
    if (!this.valueText.trim()) { this.message = '请填写积分'; return; }
    this.sending = true;
    const result = this.ledger.apply({
      recordId: 'score-a1', expectedRevision: this.expectedRevision,
      points: Number(this.valueText), durationSec: 120,
      editorId: 'judge-a', reason: this.reason
    });
    if (result.status === 'applied' && result.latest) {
      this.expectedRevision = result.latest.revision;
      this.message = '更正已记录,当前修订 ' + this.expectedRevision;
    } else if (result.status === 'stale') {
      this.message = '成绩已更新,请核对最新结果后再填写';
    } else {
      this.message = '请检查记录和更正内容';
    }
    this.sending = false;
  }

  build() {
    Column({ space: 12 }) {
      Text('青峰队成绩更正').fontSize(23).fontWeight(FontWeight.Bold)
      Text('基于修订 ' + this.expectedRevision)
      TextInput({ text: this.valueText, placeholder: '更正后积分' })
        .type(InputType.Number)
        .onChange((value: string) => { this.valueText = value; })
      TextInput({ text: this.reason, placeholder: '更正原因' })
        .onChange((value: string) => { this.reason = value; })
      Text(this.message)
      Button('提交更正').enabled(!this.sending)
        .onClick(() => { this.submit(); })
    }.padding(20).width('100%')
  }
}

在真实数据源接入后,submit 应改为异步调用 ScoreRepository.correct,并在事务返回成功后重新载入新记录;现在用领域服务呈现的是更正分支的交互骨架。对于输入值,还应明确排除非数字与小数,并依赛事规则决定积分上限。为了让用户知道影响范围,更正完成后的提示可附带“本次涉及甲组初赛榜单”,但不要由页面自行推断其他队伍名次。排名仍由前两节的组内排序服务统一计算,确保总榜、分组榜和历史页使用同一个规则版本。

赛事积分更正冲突与历史记录手机界面

把排错重点放到版本与事件上

一个可复核的检查路径是先固定同一阶段、同一组别的输入数据,让两个编辑页都读取修订4。第一份请求把青峰队92分调整为90分并提交,第二份仍拿修订4试图改为89分。预期结果是第一份生成修订5和一条更正事件,第二份收到版本冲突,不改变修订5的值;在第二份重新读取后,才允许基于修订5发起新的更正。接下来核对甲组榜单是否重算、乙组是否保持原规则版本,以及更正历史是否连续展示从4到5的变化。

如果日志只有“保存失败”四个字,难以判断是版本冲突、权限不足还是存储写入异常。建议记录成绩 ID、期望修订号、最新修订号、事务返回状态和受影响的分组键;操作者身份按需要使用内部权限系统的安全标识,避免日志直接暴露个人敏感信息。性能观察要把“单条写入耗时”和“分组重新排序耗时”分开,这能帮助判断瓶颈位于存储事务还是排名计算。对外显示的榜单应以成功提交后的快照为依据,不能让客户端草稿进入正式排行榜。

更正流程的价值不在于多了一个编辑按钮,而在于比赛结果可以被解释:每一次分值变化都有明确的基线、原因和下一修订,过期请求不会悄悄覆盖别人的裁决。至此,积分台已经具备确定排序、分组隔离和版本化更正三条基础约束,后续才适合继续处理历史回放与多端视图的一致性。

参考资料

Logo

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

更多推荐