HarmonyOS ArkTS:跨行错误定位与扫描检查点回退(连载04)【共创稿事节】
上一节的CSV扫描器已经能够把跨行引号保留在同一条业务记录里,但财务同事真正需要看的不是“文件有一条坏记录”,而是错误落在第几行、哪一个字段、哪一列。TX003的金额写成12.5x时,如果只报“记录3格式错误”,用户还要自己回到原始CSV查找。更麻烦的是,TX002的说明字段从“配件”换到下一物理行的“需复核”,后面所有记录的物理行号都会比业务记录序号多一。
LedgerDock仍处理同一份费用导入.csv:表头在物理第1行,TX001在第2行;TX002跨第3—4行;TX003落在第5行,金额字段从第18列开始;TX004在第6行。四条业务明细中有效三条、错误一条,待入库三条。本篇要做的是让解析HAR报告精确位置,且输入块刚好截断在双引号、逗号或换行处时,可以安全等待后续数据,而不是先报错再把错误吞掉。

为每条逻辑记录建立提交检查点
不能把“收到一个输入块”和“完成一条记录”当成同一件事。一个文件可能每次只读到几百个字符,TXT格式的换行符也可能被拆成两块。扫描器应把当前尚未完结的字符保存在待处理缓冲区中,仅当扫描到引号外的记录分隔符,或者文件输入确认结束时,才把这段字符交给字段解析器。只有完整逻辑记录通过结构检查,解析位置才向前提交。这样不必把已经输出的TX001、TX002回滚,也不会因为TX003出错而覆盖三条正确记录。
这种机制可以理解为“检查点”:committedLine保存下一条记录预计开始的物理行号,pending保存检查点之后尚未确认的全部内容。若一块输入停在"配件中间,扫描器不会发出记录;下一块到来再从检查点重新检查引号结构。为了减少代码状态分支,这里对未提交的缓冲重新扫描,而不是在每个字符位置都更新多个可变游标。代价是超长单条记录会重复扫描,所以需要为单条记录设置明确的最大字符数并给出可恢复的错误。
第一段代码只负责查找一个完整逻辑记录的结束偏移,不解释金额,也不依赖特定文件读取接口。双引号包裹字段内的换行、连续两个引号以及CRLF记录分隔在这里先归一成“是否在引号外结束”的判断。最末尾只有半个CRLF时保持等待,避免下一块开头的LF被计算两次。
export function findRecordBoundary(data: string, finished: boolean): number {
let quoted = false;
for (let i = 0; i < data.length; i++) {
const c = data.charAt(i);
if (c === '"') {
if (quoted && i + 1 < data.length && data.charAt(i + 1) === '"') {
i++;
} else {
quoted = !quoted;
}
} else if (!quoted && c === '\r') {
if (i + 1 >= data.length && !finished) { return -1; }
return i + (data.charAt(i + 1) === '\n' ? 2 : 1);
} else if (!quoted && c === '\n') {
return i + 1;
}
}
return finished && data.length > 0 ? data.length : -1;
}
此处返回的是“记录结束后的偏移”,而不是刚找到的换行字符位置;调用方因此能够直接用substring(0,end)截取记录,再从substring(end)取得剩余内容。注意,若文件最终一个字段没有关闭双引号,函数在finished状态仍会交出末尾字符;字段解析器必须继续检查引号状态并把它标为结构错误,不能将未闭合的说明字段当成正常文本。这个分工刻意让记录边界判断保持简单,也避免错误数据被无声丢弃。
物理行号和字段列号由原文恢复
CSV结构解析完成后,金额校验是独立的一层。字段位置需要按原始记录扫描,而不是通过indexOf('12.5x')搜索。相同金额可能在备注里再次出现,字符串搜索会找到错误位置。字段扫描时记录每一个字段首字符的物理行号、列号;遇到双引号内换行时,行号增加且列号重置,遇到转义双引号时两个字符按原文位置推进。对于TX003,2026-10-03,12.5x,差旅,前面17列由流水号、逗号、日期和逗号组成,第三个字段的起点必然是第18列。
这里需要保留CSV格式规则:引号只能从字段起始位置开启,闭合之后只能紧跟逗号或行尾;引号外出现孤立双引号属于结构问题。实际定位函数不仅返回值,还返回字段在原文件中的来源位置。这样HAR对外暴露的错误仍是普通数据对象,UI能够生成“第5行第18列,金额分应为整数”的提示,而无需让页面去理解CSV词法细节。
下一段示例处理单条完整逻辑记录的位置。代码专注字段起点和转义引号,不在这里做金额数值转换;物理行使用从1开始的编号,列号同样从1开始,便于直接显示给使用者。
export interface CsvFieldAt {
value: string;
line: number;
column: number;
}
export function locateCsvFields(raw: string, firstLine: number): CsvFieldAt[] {
const result: CsvFieldAt[] = [];
let line = firstLine;
let column = 1;
let beginLine = firstLine;
let beginColumn = 1;
let content = '';
let quoted = false;
for (let i = 0; i < raw.length; i++) {
const c = raw.charAt(i);
if (c === '"') {
if (quoted && raw.charAt(i + 1) === '"') {
content += '"'; i++; column += 2; continue;
}
quoted = !quoted;
} else if (c === ',' && !quoted) {
result.push({ value: content, line: beginLine, column: beginColumn });
content = '';
beginLine = line;
beginColumn = column + 1;
} else if (c === '\n' || c === '\r') {
if (!quoted) { break; }
if (c === '\r' && raw.charAt(i + 1) === '\n') { i++; }
content += '\n';
line++;
column = 1;
continue;
} else {
content += c;
}
column++;
}
if (quoted) { throw new Error('说明字段引号未闭合'); }
result.push({ value: content, line: beginLine, column: beginColumn });
return result;
}
上面的函数只负责定位已形成的逻辑记录,生产实现还需要对不合法的引号位置给出诊断:比如普通字段中间出现一个双引号,或者闭合引号后仍跟着普通字符。可以在解析器已有的状态枚举中处理这一层,继续把issue放进返回对象。对于包含CRLF的文件,内部统一保存说明文本中的LF,显示时以换行呈现;但坐标信息仍按原始物理行计算,不应为了统一换行风格而改写出错行号。

回退是未提交记录的保留,不是删除有效数据
findRecordBoundary对每块已接收数据返回完整记录边界。队列收到一个分隔符后才截出记录,将它交给locateCsvFields以及已有的金额映射层。金额必须使用整数分,不能用浮点元替代。如果校验失败,只把该条转入错误结果,同时记录原始开始行与金额字段位置;不会清空前面已经通过校验的两条,也不会影响末尾TX004继续导入。
检查点推进时,物理行增量应按照CRLF、LF和CR的实际情况统计,同一个CRLF算一行。下面的队列不承担文件IO,只接收从读取层交给它的文本块。为了避免恶意文件无限撑大内存,保留的未结束记录超过64KiB时立即报错;处理这类错误时要终止本次导入并允许重新选择文件,不要绕过检查继续拼接。这一策略把大文件总长度与单条异常记录长度区分开来。
export interface LocatedRow {
raw: string;
firstLine: number;
fields: CsvFieldAt[];
}
export class CsvCheckpointQueue {
private pending: string = '';
private firstLine: number = 1;
readonly maxPending: number = 65536;
push(chunk: string, finished: boolean = false): LocatedRow[] {
this.pending += chunk;
const rows: LocatedRow[] = [];
while (true) {
const end = findRecordBoundary(this.pending, finished);
if (end < 0) { break; }
const raw = this.pending.substring(0, end);
const first = this.firstLine;
this.pending = this.pending.substring(end);
this.firstLine += (raw.match(/\r\n|\r|\n/g) ?? []).length;
if (raw.length === 0) { continue; }
const fields = locateCsvFields(raw, first);
rows.push({ raw: raw, firstLine: first, fields: fields });
}
if (this.pending.length > this.maxPending) {
throw new Error('单条记录长度超过允许值');
}
return rows;
}
}
export function locateInvalidAmount(row: LocatedRow): string {
const amount = row.fields[2];
if (!amount) { return '金额分字段缺失'; }
if (!/^(0|[1-9][0-9]*)$/.test(amount.value)) {
return `第${amount.line}行第${amount.column}列:金额分应为非负整数`;
}
return '';
}
对本例而言,六行原始文本产出表头加四条业务记录,TX003所在的金额字段错误定位为第5行第18列;TX002的跨行说明仍处于一条记录中,有效金额8600分不受影响。页面统计继续显示“物理行6、业务记录4、有效3、错误1、待入库3”。还有一个容易忽略的故障:读取中断后,如果保留了上一次导入的pending却直接载入另一份文件,两个文件会被拼接成错误记录。因此每次重新选择文件都应创建新的CsvCheckpointQueue,并在页面退出或用户取消时丢弃旧实例及待处理字符。

让错误能定位,也让界面保持顺畅
页面层不应该每读到一个字符就刷新错误列表。读取层可以按块交付文本,解析层批量输出刚完成的逻辑记录,再把新增的有效条数和错误条数合并到一次状态更新中。遇到一条异常长的引号字段时,上述重复扫描会增加CPU开销,可以通过加大块大小和记录长度上限控制成本;后续如升级为真正持续游标的扫描器,需要保留相同的字段定位语义与公开接口。不要为追求速度把原始定位信息全部丢掉,否则后续对账复核会重新付出成本。
HAR的入口与依赖管理继续使用前文的本地模块方式,具体配置规则可参阅华为开发者添加依赖项和oh-package.json5配置说明。扫描器属于普通ArkTS字符串处理逻辑,跨行、定位和缓冲策略由应用定义;不应把这些应用内部规则误写成平台自动提供的CSV能力。
这一步把“错误行”和“错误字段”打通了,下一节可以直接沿用整数金额分结果计算对账差额。数据对不上时,页面除了指出差额,还应能反向定位是哪一笔流水贡献了异常,而不用再次解析整个文件。
更多推荐

所有评论(0)