HarmonyOS赛事积分连载05:裁决落账与榜单快照一致性【共创稿事节】
第四篇把一条有争议的成绩从待复核推进到已裁定,也保存了复核人、裁决意见和版本。这里容易产生一个看似很小、实际影响整个赛场公信力的问题:裁决已经完成,甲组榜单却还显示旧积分;用户切到乙组再返回,又看到另一套排名。原因通常不在排序算法,而在“裁决状态”“正式成绩”“展示榜单”分别更新,没有定义谁是权威数据、什么时候允许发布。
这一节继续使用大学机器人赛事积分台。甲组青峰队原成绩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开发文档中心。
更多推荐


所有评论(0)