侧边栏壁纸
博主头像
离开的兔子

行动起来,活在当下

  • 累计撰写 8 篇文章
  • 累计创建 1 个标签
  • 累计收到 0 条评论

目 录CONTENT

文章目录

小程序记账截图导入:从前端选型到后端 OCR 落地

Administrator
2026-06-25 / 0 评论 / 0 点赞 / 1 阅读 / 0 字

小程序记账截图导入:从前端选型到后端 OCR 落地

记账功能原本是「手动填表 → 提交一笔」。用户的真实诉求是:把微信/支付宝的账单截图丢进来,自动识别成账目。这篇记录从需求拆解到上线的完整决策链路,以及几个容易踩的实现细节。

一、动手前先定两个岔路口

截图导入看似一个功能,其实有两个决定改动范围的关键选型,必须先和需求方对齐,不能闷头写:

选型 1:OCR 走哪条路?

方案

说明

代价

后端 OCR 接口(最终选择)

前端上传截图到 /miniapp/ledger/import,后端识别+解析后返回账目列表

需要后端配合,但前端轻、可控、可换引擎

小程序 OCR 插件

用腾讯免费「OCR 文字识别」插件在端上识别

无需后端,但要在公众平台审核插件,精度/版式适配弱

纯前端正则

不识别图片,只解析已有文本

不适用截图场景

选后端的核心理由:识别引擎可替换(后面果然从微信 OCR 换成了百度 OCR),且前端不背插件审核包袱。

选型 2:识别完怎么入库?

选了「先确认再批量入库」而不是「直接填一笔」。原因很简单:OCR 一定会有误识别,让用户在一个可编辑列表里勾选/修改后再批量保存,能挡掉脏数据。这个决定反过来塑造了前端 UI(底部确认面板)和后端接口(/batch)。

经验:凡是「改动范围由它决定」的选型,先问清楚再写代码,比写完返工省得多。

二、前端:上传、可编辑列表、批量提交

新增两个 API:

// utils/api.js
ledgerImport: (filePath, opt) => http.uploadFile(Object.assign(
  { url: '/miniapp/ledger/import', filePath, name: 'file', showError: false }, opt)),
ledgerBatchAdd: (records, opt) => http.post('/miniapp/ledger/batch', { records }, opt),

多图顺序上传:用 reduce 串成 Promise 链

一次可选 9 张截图,但不想并发打爆后端 OCR,于是顺序上传并汇总结果:

paths.reduce((chain, p) => chain.then((acc) => api.ledgerImport(p)
  .then((res) => acc.concat(Array.isArray(res) ? res : (res && res.records) || []))
  .catch(() => acc)), Promise.resolve([]))
  .then((raw) => { /* 归一化 + 弹确认面板 */ });

单张失败 .catch(() => acc) 直接跳过,不影响其它图。

归一化容错:别信后端给的每个字段

OCR 解析结果字段可能缺、可能越界,前端统一兜底,避免脏数据进面板:

function normalizeImport(r) {
  const type = r.type === 'income' ? 'income' : 'expense';
  const cats = CATS[type];
  let category = r.category || '其他';
  if (cats.indexOf(category) < 0) category = '其他';      // 分类不在白名单 → 其他
  const amount = Math.abs(parseFloat(r.amount)) || 0;     // 一律取绝对值
  let recordDate = parseInt(r.recordDate, 10);
  if (!recordDate || String(recordDate).length !== 8) recordDate = todayInt(); // 日期非法 → 今天
  return { /* ... */ };
}

可编辑列表的 setData:用路径写法改单个字段

确认面板里每条都能改类型/分类/金额/备注/日期。改嵌套数组的某个字段,用路径式 setData,别整组替换:

impAmount(e) {
  const i = Number(e.currentTarget.dataset.i);
  this.setData({ ['importList[' + i + '].amount']: e.detail.value });
}

三、后端:识别只解析不入库,批量才写库

两个端点职责清晰:

  • POST /import:存临时文件 → OCR → 启发式解析 → 只返回列表,不写库。临时文件用后即删,不长期保留用户账单截图。

  • POST /batch:服务端再校验一遍(类型/金额>0/日期),过滤非法项后 insertBatch

MyBatis 批量插入用 foreach

<insert id="insertBatch">
  insert into mp_ledger (mp_user_id, type, category, amount, note, record_date, create_time, del_flag)
  values
  <foreach collection="list" item="item" separator=",">
    (#{item.mpUserId}, #{item.type}, #{item.category}, #{item.amount},
     #{item.note}, #{item.recordDate}, sysdate(), 0)
  foreach>
insert>

四、OCR 引擎:从微信换到百度,代码反而更短

最初用微信服务端 cv/ocr/comm(复用已有的 access_token)。但它要 multipart 上传图片,而项目里的 HttpUtils 只支持表单/JSON 字符串体,于是手写了一段 multipart——能用,但啰嗦。

后来改用百度通用文字识别 general_basic,发现它接收 base64 表单,直接复用现成的 HttpUtils.sendPost,multipart 那一大坨全删了:

String base64 = Base64.getEncoder().encodeToString(Files.readAllBytes(file.toPath()));
String param = "image=" + URLEncoder.encode(base64, "UTF-8");
String body = HttpUtils.sendPost(GENERAL_BASIC_URL + "?access_token=" + token, param,
        "application/x-www-form-urlencoded");

两家的 access_token 都做了内存缓存(百度有效期约 30 天,提前 1 天过期)。因为解析器吃的是 List 文字行,换引擎只动「取文字」那一层,解析逻辑零改动——这正是当初选「后端 OCR」留下的活口。

五、解析器:求「尽量命中」,不求精确

账单截图的 OCR 文字行无法保证逐笔对齐,解析器用启发式:

  • 带正负号/¥的金额行为锚点成一笔;

  • 备注取金额行内剩余文字,否则用上一条描述行;

  • 正负号 + 关键词(「退款/红包/已存入」)判断收入/支出;

  • 关键词映射分类(餐饮/购物/交通…),命中不了给「其他」;

  • 日期抓 yyyy-MM-ddM月d日,缺失回退当天。

为什么敢糙?因为前端有确认面板兜底。识别命中 80% + 人工修 20%,体验远好过追求 100% 精确却迟迟上不了线。

小结

  • 影响改动范围的选型,先对齐再写。

  • 容错尽量往前端放(归一化),后端再校验一道。

  • 抽象层选对(解析吃文字行),换第三方就只动一层。

  • 第三方接口优先挑「能复用现有工具」的提交格式——百度的 base64 比微信的 multipart 省一大段代码。

0

评论区