第四篇把一条有争议的成绩从待复核推进到已裁定,也保存了复核人、裁决意见和版本。这里容易产生一个看似很小、实际影响整个赛场公信力的问题:裁决已经完成,甲组榜单却还显示旧积分;用户切到乙组再返回,又看到另一套排名。原因通常不在排序算法,而在“裁决状态”“正式成绩”“展示榜单”分别更新,没有定义谁是权威数据、什么时候允许发布。

这一节继续使用大学机器人赛事积分台。甲组青峰队原成绩90分,复核后决定更正为88分。裁决记录里存有目标成绩与关联的原成绩修订号,正式成绩表仍保存裁决前的数据。我们的目标是把裁决的生效和成绩更正放在一个数据库事务中;事务成功后重新计算受影响组别的排名,给页面一个明确版本的榜单快照。不能因为页面收到了一次异步查询结果,就认定它一定比屏幕上的数据更新。

裁决落账与榜单快照一致性

裁决与发布是两种不同业务状态

复核人员按下“已裁定”,表示证据核对和意见填写完成;按下“发布成绩”,才表示该裁决应影响正式积分。把这两个动作混在一个状态字段里,会让用户难以分辨某一分数到底是临时意见还是正式成绩。这里让 appeal_ticket 保留 resolved 与 published 两种业务状态,并持有对应的正式成绩ID、目标积分、裁决时读到的成绩版本号。

数据层至少包含 score_record、appeal_ticket、score_event 三张表。成绩表记录 phase_id、group_id、team_id、points、duration_sec 和 revision;申诉表记录审查意见、目标积分和发布状态;事件表记录每次正式更正的前后值与关联裁决。前篇已经确定“同组同阶段独立排名”,因此榜单缓存键应包含阶段、组别、计分规则版本,而不能使用整个赛事ID作为一个公共缓存键。

为了防止两个工作人员同时发布同一裁决,发布命令会带上当时读取的 expectedScoreRevision 与 expectedAppealRevision。写库时让条件谓词校验这两个值,任一不符都将本次操作视为过期。用户看到的提示应该是“成绩已更新,请重新查看”,而不是把竞争错误当作网络故障无限自动重试。

export interface PublishDecision {
  ticketId: string
  scoreId: string
  phaseId: string
  groupId: string
  teamId: string
  newPoints: number
  expectedScoreRevision: number
  expectedAppealRevision: number
  ruleVersion: string
}

export interface PublishedChange {
  phaseId: string
  groupId: string
  scoreId: string
  newRevision: number
  ruleVersion: string
}

export interface RankedRow {
  teamId: string
  teamName: string
  points: number
  durationSec: number
  rank: number
}

这组结构刻意把团队展示名称放在排行榜结果中,而不是写入发布命令。发布动作只需要稳定的主键和数值字段。名称变化是另外一类资料维护,不应触发成绩重写。目标积分必须经过赛事规则校验,例如分值上下限、是否允许小数、当前赛段是否已经封榜;这类业务校验在事务开始前执行一次,事务内仍要检查关联记录的当前版本。

事务内完成成绩、申诉和事件三处更新

如果先修改正式成绩,再修改申诉状态,第二步失败就会留下“申诉尚未发布,但积分已改变”的矛盾记录。关系型数据库提供的事务接口可以把这几个写入作为同一提交单元。华为开发者的6.1.1(24)版ArkData接口差异资料列出了 createTransaction、commit、rollback,API26工程可按这一方式组织原子写入。使用 createTransaction() 获取事务对象后,成绩行执行带修订号条件的更新;只有更新数量为1,才继续将申诉状态调整为 published,并新增正式成绩事件。

import { relationalStore } from '@kit.ArkData'

export async function publishDecision(
  store: relationalStore.RdbStore,
  request: PublishDecision
): Promise<PublishedChange> {
  const tx = await store.createTransaction()
  try {
    const scoreWhere = new relationalStore.RdbPredicates('score_record')
      .equalTo('score_id', request.scoreId)
      .equalTo('phase_id', request.phaseId)
      .equalTo('group_id', request.groupId)
      .equalTo('revision', request.expectedScoreRevision)
    const rows = await tx.query(scoreWhere, ['points'])
    let oldPoints: number
    try {
      if (!rows.goToFirstRow()) { throw new Error('成绩记录不存在') }
      oldPoints = rows.getDouble(0)
    } finally {
      rows.close()
    }
    const changed = await tx.update({
      points: request.newPoints,
      revision: request.expectedScoreRevision + 1
    }, scoreWhere)
    if (changed !== 1) {
      throw new Error('成绩版本已变化')
    }
    const ticketWhere = new relationalStore.RdbPredicates('appeal_ticket')
      .equalTo('ticket_id', request.ticketId)
      .equalTo('score_id', request.scoreId)
      .equalTo('state', 'resolved')
      .equalTo('target_points', request.newPoints)
      .equalTo('revision', request.expectedAppealRevision)
    const ticketChanged = await tx.update({
      state: 'published',
      revision: request.expectedAppealRevision + 1
    }, ticketWhere)
    if (ticketChanged !== 1) {
      throw new Error('裁决已被其他操作处理')
    }
    await tx.insert('score_event', {
      score_id: request.scoreId,
      ticket_id: request.ticketId,
      old_points: oldPoints,
      new_points: request.newPoints,
      revision: request.expectedScoreRevision + 1
    })
    await tx.commit()
    return {
      phaseId: request.phaseId,
      groupId: request.groupId,
      scoreId: request.scoreId,
      newRevision: request.expectedScoreRevision + 1,
      ruleVersion: request.ruleVersion
    }
  } catch (error) {
    await tx.rollback()
    throw error
  }
}

代码中的 oldPoints 直接在事务内从正式成绩表读取,而不是使用页面上可能已过期的标签。score_event 也需要约束关联申诉唯一性,阻止同一裁决写出两份生效事件。事务提交后,不要继续使用之前读取的榜单对象,否则它的积分和名次依然是旧值。事务回滚时,三个表共同恢复原状,页面保留可重载的申诉卡片,不应把“发布成功”的提示提前展示。

还有一类容易被忽略的竞争发生在组织权限上。裁决复核与成绩发布可能由不同职责的人员完成。服务层需要在执行业务命令前校验当前用户能否发布该赛段,并判断该赛段是否处于允许改分的窗口;界面隐藏发布按钮只是交互便利,不能代替写库入口的授权检查。分组切换不会改变某条裁决自身的阶段和组别,命令参数必须从裁决记录读取,不能直接取页面当前选中的页签。

同分排序不因更正而改变规则

第一篇已经约定同积分的队伍共享名次,展示顺序再由用时与队伍ID确定。第五篇必须复用这个计分定义,而不是为了更新后的成绩重写一套列表排序。否则青峰队从90分变成88分后,排序函数与名次函数使用不同规则,可能造成队伍顺序和名次不一致。把排序输入视为从数据库读到的一组不可变分数,输出新的榜单数组,避免直接修改正在被页面使用的旧列表。

export function rankGroup(scores: Array<{
  teamId: string; teamName: string; points: number; durationSec: number
}>): RankedRow[] {
  const ordered = scores.slice().sort((a, b) =>
    b.points - a.points ||
    a.durationSec - b.durationSec ||
    a.teamId.localeCompare(b.teamId))
  const result: RankedRow[] = []
  for (let i = 0; i < ordered.length; i++) {
    const row = ordered[i]
    const last = i > 0 ? ordered[i - 1] : undefined
    const rank = last !== undefined && last.points === row.points
      ? result[i - 1].rank : i + 1
    result.push({ ...row, rank })
  }
  return result
}

注意两个概念:rank 是规则决定的名次,i 是当前数组中的显示位置。两个88分的队伍即使用时不同,也可能都标记第2名;如果还有下一支队伍,它的名次应按竞争排名跳过对应位置。界面不能把 ForEach 的位置索引直接渲染成名次,否则列表在修订之后会出现错误编号。缓存对象中保存完整排名结果,并附带对应阶段、组别、规则版本和生成来源的成绩版本号。

开发环境中的裁决写回与排名刷新

解决异步刷新覆盖新榜单的问题

在页面上更常见的毛病是:发布后触发一次新的甲组查询,同时用户切换到乙组。旧甲组请求返回得更晚,页面便把甲组数组画进乙组页签。另一个场景是快速连续发布两次成绩,旧的榜单查询后返回,却覆盖较新的结果。问题的实质是异步结果缺少归属和顺序校验。

可以给每次页面刷新分配递增序号,同时记住请求发起时的阶段和组别。结果返回后先检查它是否仍属于当前选择,序号是否还是最近一次请求。这个序号用于页面级别的过期响应丢弃,而不是数据库修订号;数据库修订号仍是判断正式数据新旧的权威依据。缓存失效同样应该限定到裁决所属的阶段、组别和规则版本,不能清空所有组的快照。

export interface ScoreSnapshot {
  phaseId: string
  groupId: string
  ruleVersion: string
  rows: RankedRow[]
}

@Entry
@Component
struct GroupScorePage {
  @State private rows: RankedRow[] = []
  @State private busy: boolean = false
  @State private errorText: string = ''
  private requestSerial: number = 0
  private selectedPhase: string = 'qualifier'
  private selectedGroup: string = 'A'

  async refresh(load: (phase: string, group: string) => Promise<ScoreSnapshot>): Promise<void> {
    const serial = ++this.requestSerial
    const phase = this.selectedPhase
    const group = this.selectedGroup
    this.busy = true
    try {
      const snapshot = await load(phase, group)
      if (serial !== this.requestSerial ||
          snapshot.phaseId !== this.selectedPhase ||
          snapshot.groupId !== this.selectedGroup) { return }
      this.rows = snapshot.rows
      this.errorText = ''
    } catch (error) {
      if (serial === this.requestSerial) {
        this.errorText = '榜单刷新失败,可再次加载'
      }
    } finally {
      if (serial === this.requestSerial) { this.busy = false }
    }
  }

  aboutToDisappear(): void { this.requestSerial++ }

  build() {
    Column() {
      Text('分组赛事榜单').fontSize(22).fontWeight(FontWeight.Bold)
      if (this.busy) { Text('正在刷新成绩') }
      if (this.errorText.length > 0) { Text(this.errorText).fontColor(Color.Red) }
      List() {
        ForEach(this.rows, (row: RankedRow) => {
          ListItem() {
            Row() {
              Text(`${row.rank}`).width(42)
              Text(row.teamName).layoutWeight(1)
              Text(`${row.points}分`)
            }.padding(12)
          }
        }, (row: RankedRow) => row.teamId)
      }.layoutWeight(1)
    }.width('100%').height('100%')
  }
}

实际页面还需要在切换阶段、组别时递增请求序号并重新刷新,不能只改变按钮选中样式。aboutToDisappear 使离开页面后的旧结果失效,但正式的数据查询或数据库事务仍由服务层决定是否取消;不能依靠页面消失来中断已经进入提交阶段的成绩发布。对于业务日志,记录 ticketId、scoreId、写入前后修订号、所属组别与发布动作阶段,就能检查一次裁决是否被重复生效。

裁决生效后的分组榜单界面

按边界场景检查业务是否连贯

排查时我会从“申诉已裁定、成绩未发布”开始,确认旧榜单仍是权威数据;发布成功后再检查申诉状态、成绩修订号、事件记录是否同步更新。随后制造一条过期修订号请求,确认数据库拒绝覆盖;在刷新期间迅速切换组别,确认旧查询结果不会写入新页签;再检查同分排名仍然保持第一篇定义的名次规则。

需要特别留意“更新成功但页面刷新失败”。这时数据库是正确的,页面应展示“已发布,榜单读取稍后重试”,而不是让用户误以为发布失败再次点击。发布命令返回成功时,可以记录提交后的成绩修订号;榜单快照加载成功后再展示相应的新名次。通过区分提交阶段与显示阶段,能避免用户把两个不同问题混在一起。

这篇的关键结论不是做了一个新的排行组件,而是把裁决生效的权威写入、排名算法和页面异步展示分成三个层次。正式成绩只在事务提交后改变,榜单始终使用同一套竞争排名规则,界面只接收归属正确且没有过期的结果。下一篇可以继续处理裁决事件的审计检索和撤销重发布,让赛事组织者追踪每一次成绩变化的来龙去脉。

相关资料:华为开发者文档 ArkData关系型存储事务接口说明、ArkData API更新、HarmonyOS开发文档中心。

Logo

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

更多推荐