上一节把 CSV 行解析封装进本地 HAR 后,调用方拿到的是稳定的 CsvParserPort 和错误结果。接着要处理一个文件导入中很容易被漏掉的场景:一条账单的说明列被双引号包裹,说明本身又包含换行。此时把文本先按 \n 拆成数组再送给单行解析器,会把一个合法账单拆成两条记录,后面的行号、金额和错误定位也会一起错位。

本篇仍使用 LedgerDock 的四条费用明细。TX001 金额 12500 分、说明为物料;TX002 为 8600 分,其说明从“配件”换行到“需复核”;TX003 金额写成 12.5x,仍属格式错误;TX004 为 4300 分、说明为办公。含表头的文件共有六行物理文本,但只有四条业务记录。目标结果仍是读取四条、有效三条、错误一条、待入库三条,不能因为 TX002 占了两行,就把它当成两次费用。

跨行引号与增量扫描缓冲的整体封面

换行不是记录结束的唯一条件

CSV 里的逗号和换行只有在双引号外面才拥有分隔意义。例如 "配件\n需复核" 中间的换行属于同一个字段,而 TX002 末尾闭合引号后的换行才是真正的记录终止符。如果先使用 split(/\r?\n/),扫描器便没有机会判断当时是否位于引号内部。另一类错误发生在输入分块:一次读取恰好停在引号处,下一块的开头才到达第二个引号。前一块末尾的引号既可能意味着字段闭合,也可能与下一块组成转义双引号,不能立刻判定。

所以需要给原来的解析 HAR 新增一层“逻辑记录扫描器”,放在文件读取与旧的 CsvParserPort.parse 之间。它收取任意大小的文本块,维护当前字段、引号状态和物理行号,只在确认记录边界后输出字段数组。已有的金额分校验仍由业务映射层完成,HAR 不参与“差旅金额是不是有效整数”这类财务判断。这样升级的是输入边界,不会破坏上一节稳定下来的返回协议。

为了让处理规则可推导,我用四种状态描述字段:字段开始、未加引号、引号内、刚遇到可能闭合的引号。第四种状态很关键:如果紧接着还是引号,就把两个连续双引号当成一个字段内容字符;如果接着是逗号或记录终止,就允许字段结束;如果跟着普通字符,则记录格式问题,留给错误列表处理。

代码要解决的问题,是在不同输入块之间保留扫描状态。这里输出每条记录的字段和起止物理行,用于下一篇的字段级定位,扫描器本身不生成费用明细。

export interface CsvLogicalRow {
  fields: string[];
  firstLine: number;
  lastLine: number;
  issue: string;
}

enum CsvScanState { START, PLAIN, QUOTED, QUOTE_END }

export class CsvRecordScanner {
  private state: CsvScanState = CsvScanState.START;
  private fields: string[] = [];
  private cell: string = '';
  private issue: string = '';
  private physicalLine: number = 1;
  private recordStart: number = 1;
  private skipLF: boolean = false;
  private firstChar: boolean = true;

  push(chunk: string): CsvLogicalRow[] {
    const rows: CsvLogicalRow[] = [];
    for (let i = 0; i < chunk.length; i++) {
      let ch: string = chunk.charAt(i);
      if (this.firstChar) {
        this.firstChar = false;
        if (ch === '\uFEFF') { continue; }
      }
      if (this.skipLF) {
        this.skipLF = false;
        if (ch === '\n') { continue; }
      }
      if (ch === '\r') {
        this.skipLF = true;
        ch = '\n';
      }
      this.accept(ch, rows);
      if (ch === '\n') { this.physicalLine++; }
    }
    return rows;
  }

  private accept(ch: string, rows: CsvLogicalRow[]): void {
    if (this.state === CsvScanState.QUOTED) {
      if (ch === '"') { this.state = CsvScanState.QUOTE_END; }
      else { this.cell += ch; }
      return;
    }
    if (this.state === CsvScanState.QUOTE_END) {
      if (ch === '"') { this.cell += '"'; this.state = CsvScanState.QUOTED; return; }
      if (ch !== ',' && ch !== '\n') {
        this.issue = '引号闭合后存在多余字符';
        this.state = CsvScanState.PLAIN;
        this.cell += ch;
        return;
      }
    }
    if (ch === ',') {
      this.fields.push(this.cell);
      this.cell = '';
      this.state = CsvScanState.START;
    } else if (ch === '\n') {
      this.fields.push(this.cell);
      this.emit(rows);
    } else if (ch === '"' && this.state === CsvScanState.START) {
      this.state = CsvScanState.QUOTED;
    } else {
      if (ch === '"') { this.issue = '普通字段出现双引号'; }
      this.cell += ch;
      this.state = CsvScanState.PLAIN;
    }
  }

  private emit(rows: CsvLogicalRow[]): void {
    rows.push({ fields: this.fields, firstLine: this.recordStart,
      lastLine: this.physicalLine, issue: this.issue });
    this.fields = [];
    this.cell = '';
    this.issue = '';
    this.state = CsvScanState.START;
    this.recordStart = this.physicalLine + 1;
  }

  finish(): CsvLogicalRow[] {
    const rows: CsvLogicalRow[] = [];
    if (this.state === CsvScanState.QUOTED) { this.issue = '双引号没有闭合'; }
    if (this.fields.length > 0 || this.cell.length > 0 || this.issue.length > 0 ||
      this.state !== CsvScanState.START) {
      this.fields.push(this.cell);
      this.emit(rows);
    }
    return rows;
  }
}

这段实现把 Windows 风格的 \r\n 规整为一个 \n,也照顾 \r 和 \n 恰好被切进两个块的情况。skipLF 是跨块保留的,不能在每次 push 开始时清空。记录中的换行如果发生在引号内部,会作为说明正文加入 cell,同时推进物理行号;这样一条记录可以对应 firstLine=3、lastLine=4,而逻辑记录序号依然只有一个。

字段解析与领域校验继续隔离

单行解析器和增量扫描器应避免争夺同一层职责。旧 CsvParserPort.parse 继续供小文本调用;新的扫描器面向原始文本流,两者复用一致的 CsvLogicalRow 规则。页面拿到完成的逻辑记录后,再做表头和金额映射。若第一个完成的记录不是约定的四列“流水号、日期、金额分、说明”,直接给出表头错误,不必把后续数据猜成另一种表格。金额使用“分”为单位,要求整数字符串,不接受 12.5x 或浮点金额直接转入账本。

下面把记录映射写成独立函数。它不隐藏错误行,也不在扫描过程中写入数据库;这能防止文件中途被截断后留下一半已经提交的数据。

export interface ImportLine {
  id: string;
  date: string;
  cents: number;
  note: string;
}
export interface ImportIssue {
  line: number;
  field: string;
  reason: string;
}
export interface ImportPreview {
  accepted: ImportLine[];
  issues: ImportIssue[];
}

export function buildPreview(rows: CsvLogicalRow[]): ImportPreview {
  const accepted: ImportLine[] = [];
  const issues: ImportIssue[] = [];
  if (rows.length === 0 ||
      rows[0].fields.join(',') !== '流水号,日期,金额分,说明') {
    issues.push({ line: 1, field: '表头', reason: '字段名称或列数不匹配' });
    return { accepted: accepted, issues: issues };
  }
  for (let i = 1; i < rows.length; i++) {
    const row = rows[i];
    if (row.issue.length > 0 || row.fields.length !== 4) {
      issues.push({ line: row.firstLine, field: 'CSV', reason: row.issue || '列数错误' });
      continue;
    }
    const amount: string = row.fields[2];
    if (!/^(0|[1-9][0-9]*)$/.test(amount) || !Number.isSafeInteger(Number(amount))) {
      issues.push({ line: row.firstLine, field: '金额分', reason: '需要整数分' });
      continue;
    }
    accepted.push({ id: row.fields[0], date: row.fields[1],
      cents: Number(amount), note: row.fields[3] });
  }
  return { accepted: accepted, issues: issues };
}

对于 TX002,说明最终值会包含内部换行,但金额 8600 的校验并不受影响。对于 TX003,扫描器正常交付四个字段,映射器再明确指出金额列错误。如果在扫描阶段直接把 TX003 的 12.5x 当成流边界问题,用户看到的将是错误的提示层级,也会给将来换第三方 CSV 库带来额外耦合。

开发环境中的增量扫描器、结果预览与定位信息

收口时才能判断最后一条是否完整

分块读取时,不存在“这一块读完就意味着一条记录结束”的规则。push 的返回值可以是零条记录,也可以是多条;只有遇到引号外的记录换行,才能确认本条边界。如果源文件末尾没有换行,必须在全部文本输入完成后调用 finish,把最后一条合法记录输出。反过来,如果输入结束时仍位于引号内部,就要返回“引号没有闭合”的格式问题,不能把残缺字段写进有效列表。

处理流入前还有两个上限需要明确。其一是单条逻辑记录的最大字符数:特别长的说明如果无限堆积在 cell 中,会抬高内存峰值,生产实现应按业务允许长度提前终止并给出长度错误。其二是每次推给页面的结果数量:大量文件不应为每条完成记录立即刷新一个组件,而是按固定批量或节流间隔更新摘要。扫描器保存的只是当前未完成记录,已完成的条目要尽快交给上层消费并从临时队列释放。

下面是一份小型输入检查,故意把带引号的 TX002 拆在不同块里。逻辑上拼接后仍是一份 CSV 文件;这比只拿完整字符串调用一次解析,更容易暴露跨块状态缺陷。

export function inspectIncrementalSample(): string[] {
  const fragments: string[] = [
    '流水号,日期,金额分,说明\r\nTX001,2026-10-01,12500,物料\r\n',
    'TX002,2026-10-02,8600,"配件\r',
    '\n需复核"\r\nTX003,2026-10-03,12.5x,差旅\r\n',
    'TX004,2026-10-04,4300,办公'
  ];
  const scanner = new CsvRecordScanner();
  const rows: CsvLogicalRow[] = [];
  for (const part of fragments) { rows.push(...scanner.push(part)); }
  rows.push(...scanner.finish());
  const result = buildPreview(rows);
  return [
    `物理行=6,业务记录=${rows.length - 1}`,
    `有效=${result.accepted.length},错误=${result.issues.length}`,
    `TX002说明=${result.accepted[1].note.replace('\n', '↵')}`
  ];
}

当全部内容按这组数据读取,业务记录应为四条,三条接受、一条待修;TX002 的说明显示为“配件↵需复核”。这几个输出属于可检查的预期值,目的是让每次调整缓冲策略后都能定位哪一块改变了记录边界。实际导入逻辑仍需要按文件编码、超长记录和用户取消导入三类条件补上错误出口,不能让扫描器在取消后继续积累字符串。

把解析状态留在 HAR 内,把提交决定留在页面

应用页面应该拿到四条逻辑记录、错误位置和安全的三条候选记录,而不直接读扫描器的 state、cell、skipLF 等内部变量。继续通过 Index.ets 暴露稳定的解析端口,新增扫描能力可以采用新的公开类型,不随意改变上篇 CsvLineResult 的含义。模块依赖的版本字段、入口文件及本地 HAR 关系可按华为HAR 静态共享包指导和oh-package.json5 配置说明核对。

页面确认导入前,还应让使用者能看到 TX003 的问题,而不是只给一个“有一条错误”的数字。TX002 的换行备注建议按一条卡片展示,折叠时保留主要说明与“需复核”提示,防止它在窄屏上被误解为两个账单。确认按钮必须以候选有效记录为输入;若原文件在预览后发生变动,应重新计算结果,不沿用旧的候选数组。输出界面还需允许清空当前解析会话,避免切换文件时残留上一份文件的半条记录。

六行物理文本收敛为四条业务记录的手机界面

跨行 CSV 的重点不是识别换行字符,而是识别换行发生时解析器所处的状态。把“读入块”“逻辑记录”“领域金额”分成三层后,既能解释六行文本为何只有四条账单,也能保持 TX003 的金额错误独立可见。下一篇再沿用扫描器保留下来的起止行号,处理错误字段位置、引号错配后的回退策略和用户重新选择文件时的状态重置。

Logo

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

更多推荐