HarmonyOS赛事积分连载07:赛后锁榜与待裁申诉拦截【共创稿事节】
一场机器人赛事的初赛结束后,计分员会希望立刻公布最终榜单,但裁判台上可能还有两条计时申诉正在处理中。此时页面显示的分数可以用于内部预览,却不应被误认为已经封存。上一节为成绩修订建立了追加式裁决事件和反向更正链,能解释每一次得分变化;本篇解决新的工程问题:在什么条件下允许把某个赛段从可修改状态切换为封存状态,并保证这次切换和最终榜单来自同一份数据。
单靠按钮前的一次申诉数量查询不够。检查完成之后、真正写入封存状态之前,其他窗口仍可能提交申诉或修正成绩。界面禁用按钮是交互提示,不是并发控制。需要由数据库事务包住状态读取、未结申诉判断、最终榜单快照和赛段状态变更,配合修订号比较,才有明确的写入边界。

封存是状态迁移,不是关掉一个按钮
沿用初赛甲组 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;裁判个人身份与申诉附件内容不适合打印到通用日志。出现榜单和封存状态不一致时,应先查快照修订是否对应赛段修订,再查有没有某条业务写入路径绕开事务闸门。不要通过客户端刷新次数推断数据库一致性,也不要把一次查询返回顺序等同于封榜成功的先后顺序。
从工程设计上看,封榜是一道最终业务事务:把未结申诉、有效成绩、规则版本、最新修订和状态变更固化为可追溯的一次决定。下一篇将继续处理封存榜单的发布视图和异常恢复,让前面各篇累积的修订、裁决和审计记录能收敛到稳定的赛事结果。
相关文档
更多推荐



所有评论(0)