第五篇把赛事裁决的生效过程固定下来:正式成绩和申诉状态在同一次关系存储事务中写入,榜单按阶段、组别和规则版本重算。这个机制解决了“裁决已经发布而界面仍显示旧分”的一致性问题,随后还有更敏感的情况:组织者发现上一次裁决采用了错误的计时记录,需要把成绩改回去。若直接修改旧事件,或者把历史成绩行覆盖成原值,后续就无法解释某个队伍为什么曾经从90分变成88分、又恢复到90分。

我把这次修改定义为一条新的“反向更正”事件,而不是删除前一次裁决。榜单依然以最新有效的 score_record 为准,score_event 则保留不可随意改写的事件序列。以初赛甲组的青峰队为例:原成绩90分,某次裁决发布到88分,后续发现证据解释有误,经过独立授权的复核操作,把最新成绩更正回90分。用户可以追到每次变更的来源,页面也不用猜测哪张截图才是权威结果。

裁决事件审计与反向更正链路

先区分撤回页面操作和更改正式成绩

操作人员误点了筛选按钮,可以直接恢复页面状态;正式裁决已经发布,则涉及成绩、申诉和榜单,不能靠一个“撤销”按钮把数据库中的上一条记录删除。业务上应要求再次确认、填写更正理由、记录关联事件,并且仅允许拥有相应权限的复核角色发起。原裁决数据继续存在,反向事件中记录 sourceEventId,明确这次更正关联哪一条历史发布记录。

事件表可设计为 event_id、score_id、phase_id、group_id、previous_points、current_points、score_revision、source_event_id、reason、operator_id、created_at。对外展示的排序可以按单调递增的行序号降序,不能只依赖毫秒时间戳:两个写入同一毫秒时,时间并不能天然定义先后。数据表另外保持 revision 自增,事件一旦提交就只追加新的记录。历史事件用于解释过程,实时排名仍读取当前成绩表。

本篇不重新发明第一篇的排序规则,也不重复第三篇的普通成绩更正。这里的关键是“更正源头”和“当前版本”双重校验:请求必须指出要反向处理哪一条裁决发布事件,并且数据库中该成绩的最新修订号应与打开详情时看到的一致。若后来已经有其他合法更正,原来的反向命令就过时了,需要重新复核当前历史链,而不是机械地回写某个老分数。

// model/ScoreAudit.ets
export interface ScoreAuditEvent {
  eventId: string
  scoreId: string
  phaseId: string
  groupId: string
  previousPoints: number
  currentPoints: number
  scoreRevision: number
  sourceEventId: string
  reason: string
  createdAt: number
}

export interface ReverseDecisionRequest {
  eventId: string
  sourceEventId: string
  scoreId: string
  phaseId: string
  groupId: string
  currentPoints: number
  restorePoints: number
  expectedRevision: number
  operatorId: string
  reason: string
}

export function validateReverseRequest(req: ReverseDecisionRequest): void {
  if (req.sourceEventId.length === 0 || req.reason.trim().length < 8) {
    throw new Error('请填写关联事件和具体更正理由')
  }
  if (!Number.isFinite(req.restorePoints) || req.restorePoints < 0) {
    throw new Error('目标积分超出规则范围')
  }
  if (req.currentPoints === req.restorePoints) {
    throw new Error('更正前后积分相同')
  }
}

这里的基本检查不会替代赛事分值上限、封榜状态和人员权限判断。完整流程在服务入口还要结合所属赛段规则、用户权限及当前裁决状态;展示层不能自己决定“谁有资格反向更正”。eventId 应由业务服务生成并在事件表上建立唯一约束,重复提交同一个请求时返回明确的已处理结果或冲突状态,而不是再次提高修订号。

让历史检索始终限定阶段与组别

比赛现场经常需要查“某队最近十次改分”“甲组某个裁决何时生效”以及“复核后的比分为什么和上一张榜单不一样”。如果历史页只根据队伍名称查询,改名或跨组同名时就可能混淆记录。因此检索条件中保留稳定的成绩主键,并把阶段、组别作为必填范围。分页排序要稳定,推荐按数据库自增的事件序号,而不是 created_at 单字段排序。

华为ArkData的关系型存储提供参数化的 querySql 和 ResultSet,可以读取这类事件列表。分页查询使用占位符传参,避免把用户输入直接拼成SQL。查询结果集必须关闭;筛选条件变化时,页面递增请求代数,旧页结果即便返回也不应该进入新筛选条件的列表。下方是一个局部查询服务,列名对应上面的事件表设计。

// service/AuditReader.ets
import { relationalStore } from '@kit.ArkData'
import { ScoreAuditEvent } from '../model/ScoreAudit'

export async function readScoreEvents(store: relationalStore.RdbStore,
  phaseId: string, groupId: string, scoreId: string,
  pageSize: number, offset: number): Promise<ScoreAuditEvent[]> {
  const limit = Math.min(Math.max(1, pageSize), 50)
  const start = Math.max(0, offset)
  const sql = `SELECT event_id, score_id, phase_id, group_id,
    previous_points, current_points, score_revision, source_event_id,
    reason, created_at FROM score_event
    WHERE phase_id = ? AND group_id = ? AND score_id = ?
    ORDER BY event_seq DESC LIMIT ? OFFSET ?`
  const rs = await store.querySql(sql, [phaseId, groupId, scoreId, limit, start])
  const rows: ScoreAuditEvent[] = []
  try {
    while (rs.goToNextRow()) {
      rows.push({
        eventId: rs.getString(rs.getColumnIndex('event_id')),
        scoreId: rs.getString(rs.getColumnIndex('score_id')),
        phaseId: rs.getString(rs.getColumnIndex('phase_id')),
        groupId: rs.getString(rs.getColumnIndex('group_id')),
        previousPoints: rs.getLong(rs.getColumnIndex('previous_points')),
        currentPoints: rs.getLong(rs.getColumnIndex('current_points')),
        scoreRevision: rs.getLong(rs.getColumnIndex('score_revision')),
        sourceEventId: rs.getString(rs.getColumnIndex('source_event_id')),
        reason: rs.getString(rs.getColumnIndex('reason')),
        createdAt: rs.getLong(rs.getColumnIndex('created_at'))
      })
    }
  } finally { rs.close() }
  return rows
}

积分在这个示例里使用整数。如果规则支持小数,应当明确采用统一的最小计分单位,例如将0.25分转换为整数25存储,避免业务比较直接使用浮点值。事件序号和修订号也不是同一个概念:事件序号代表整张事件表的稳定检索顺序,修订号只针对一条正式成绩的更新代次。一个组里有十个队伍,不应因为别人的事件进入表,就让当前队伍的预期修订号突然变化。

反向更正仍要走同一事务

反向更正至少涉及两个数据库动作:正式成绩更新为经批准的目标分数,追加一条引用旧裁决的新事件。如果这两个动作分开执行,任一步失败都会形成“成绩已经恢复但历史里找不到更正原因”的矛盾。沿用第五篇的 createTransaction,把修订号与当前分数一并放入条件谓词中。条件更新影响行数不是1时明确拒绝;没有通过验证时既不能插入事件,也不能修改榜单缓存。

// service/ReverseDecision.ets
import { relationalStore } from '@kit.ArkData'
import { ReverseDecisionRequest, validateReverseRequest } from '../model/ScoreAudit'

export async function reversePublishedScore(store: relationalStore.RdbStore,
  req: ReverseDecisionRequest): Promise<number> {
  validateReverseRequest(req)
  const tx = await store.createTransaction()
  try {
    const current = new relationalStore.RdbPredicates('score_record')
      .equalTo('score_id', req.scoreId)
      .equalTo('phase_id', req.phaseId)
      .equalTo('group_id', req.groupId)
      .equalTo('points', req.currentPoints)
      .equalTo('revision', req.expectedRevision)
    const newRevision = req.expectedRevision + 1
    const changed = await tx.update({ points: req.restorePoints,
      revision: newRevision }, current)
    if (changed !== 1) { throw new Error('成绩版本已变化,请刷新后复核') }
    await tx.insert('score_event', {
      event_id: req.eventId, score_id: req.scoreId,
      phase_id: req.phaseId, group_id: req.groupId,
      previous_points: req.currentPoints, current_points: req.restorePoints,
      score_revision: newRevision, source_event_id: req.sourceEventId,
      reason: req.reason, operator_id: req.operatorId, created_at: Date.now()
    })
    await tx.commit()
    return newRevision
  } catch (error) {
    await tx.rollback()
    throw error
  }
}

代码中的事务接口来自ArkData,而事件主键生成、权限核验、复核意见审批由业务层负责。应在调用该函数前确认 sourceEventId 属于同一 scoreId,并且确实对应能够反向处理的已发布裁决;这种关联检查同样要放到可靠的数据访问流程中。更严格的业务可以在事务内查询源事件,锁定其存在性和当前成绩间的关系,阻止使用另一个队伍的事件构造更正请求。

若写库成功、榜单刷新失败,需要保留成功的新修订号,同时提示重试读取榜单,而不是自动再次执行反向更正。反向更正不意味着恢复旧版本的整个快照,其他队伍后续增加的成绩都应该保留。重新排名时仍限定初赛甲组和当前规则版本;事件链提供的是解释能力,不是替代排名查询的另一个事实来源。

DevEco Studio中裁决事件查询和反向更正代码

页面按事件顺序呈现,不给历史加滤镜

事件页可以用“原始成绩→裁决发布→反向更正”的时间线展示,点开每个节点查看源事件、目标分数、操作原因和修订号。正常情况下不能把旧事件节点覆盖成最终状态,也不应把最新88分当作第一条原始录入。为了减少信息误读,页面明确区分“当时分数”和“当前有效分数”。两者在多次更正后可能不同,这是审计页面的重要价值。

// view/AuditTimeline.ets
import { ScoreAuditEvent } from '../model/ScoreAudit'

@Component
export struct AuditTimeline {
  @Prop events: ScoreAuditEvent[] = []
  @Prop currentPoints: number = 0

  build() {
    Column({ space: 12 }) {
      Text(`当前有效成绩 ${this.currentPoints}分`)
        .fontSize(20).fontWeight(FontWeight.Bold)
      List({ space: 8 }) {
        ForEach(this.events, (event: ScoreAuditEvent) => {
          ListItem() {
            Column({ space: 5 }) {
              Text(`修订 ${event.scoreRevision}`)
                .fontSize(15).fontColor('#2862D5')
              Text(`${event.previousPoints} → ${event.currentPoints} 分`)
                .fontSize(19).fontWeight(FontWeight.Medium)
              Text(event.reason).fontSize(13)
              Text(`关联事件 ${event.sourceEventId}`).fontSize(12)
            }.padding(12).width('100%')
             .backgroundColor('#F4F7FC').borderRadius(10)
          }
        }, (event: ScoreAuditEvent) => event.eventId)
      }.layoutWeight(1)
    }.height('100%').width('100%')
  }
}

实际页面的 events 由读取服务分页取得。筛选器切换时清空旧列表,使用请求序号只接收最后一次筛选的响应;页面离开时让未返回的查询结果失效。阅读事件详情不应该获取修改权限,编辑入口需要单独的业务核验。若某条历史事件的操作者已经离职,仍应保留可审计的操作者标识与当时角色信息,避免因为人员资料变更而改变旧事件含义。

赛事裁决事件时间线及当前积分界面

把容易混淆的三个失败点分开检查

调试时我会分别构造“源裁决不存在”“当前修订号过期”“事件插入被唯一约束拒绝”的请求。第一种应拒绝业务关联,第二种不能影响正式成绩,第三种应使事务整体回滚。如果两名人员同时尝试反向处理同一旧事件,至少一名的预期修订号会失效,需要重新加载详情。历史查询还要覆盖同名队伍、跨组同ID错误使用、分页边界和时间戳相同的事件,确认结果排序始终稳定。

另一个检查点是连续二次更正。假设90→88→90之后再次发现分数应为89,那么第三次操作需要新增90→89的事件,而不是把最早90→88的记录删除。当前状态取最后一次合法提交,历史页按事件序号展示每一步。涉及隐私的操作原因应保留必要的审批记录,但页面日志无需输出工作人员的个人联系方式,查询错误也不应包含全量成绩表内容。

到这一节,赛事成绩的变更既有了事务性结果,也有了能够解释结果的审计路径。下一步可以考虑榜单发布后的对外视图和不同角色能看到哪些裁决细节;无论展示终端如何变化,正式成绩、追加事件和版本条件更新仍应保持同一套数据规则。

相关资料:ArkData关系型存储事务接口更新、HarmonyOS开发文档中心、ArkData API接口差异参考。

Logo

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

更多推荐