DriftLedger/src/domain/ocrProcessor.ts
fengmengqi 7fa345b558 feat: 品牌重命名为浮记(DriftLedger),全面升级精度安全与架构
品牌与配置
- 重命名 Bean Mobile → 浮记(DriftLedger),slug 改为 drift-ledger
- 所有 Config Plugin 从 app.json 动态读取 appId,替换硬编码 com.beancount.mobile
- 预构建时自动从 constants.ts 同步支付包名列表到 Kotlin 端
- size-optimization 插件新增 NDK 版本强制统一(27.1.12297006)
- 添加 app icon(direction_a_feather.png)
Decimal 精度改造
- 全面消除 parseFloat 用于金额计算的场景,改用 string decimal 运算
- 新增 divideDecimals(除法)、toDateString(Date→YYYY-MM-DD)
- negateDecimal 零值特殊处理,避免产生 "-0"
- 年报/月报/预算/信用卡/汇率/图表统计等模块全部迁移
- 去重指纹用 parseDecimal 归一化金额格式
- 转账识别增加 normalizeAmount,支持不同精度格式配对(100 vs 100.00)
架构变更
- mobile.bean → main.bean:移除启动时合并迁移逻辑,直接读写 main.bean
- TransactionDraft.sourceEventId → sourceEventIds(数组,支持转账双方)
- 新增 buildPostings() 纯函数,从 transactionBuilder 提取复式分录构建逻辑
- 新增 csvUtils.ts(引号感知 CSV 行拆分),statements/adapters 共用
- 新增 accessibilityParser.ts:从 automationPipeline 提取微信/支付宝文本解析
- 新增 floatingBillManager.ts:悬浮窗草稿管理(LRU+超时)与排序数据构建
- 新增 txKeyStore.ts:交易级去重键持久化(原子写入+500条LRU)
- ledger.ts 新增 resolveIncludes 选项,支持 include 指令递归解析
- metadataStore CRUD 用 createCrudActions 工厂消除7组实体重复代码
安全与持久化
- PIN 哈希改用带盐 SHA-256,兼容旧版无 salt 数据迁移
- storePersistence 从 persistTo 回调改为 subscribe 自动触发
- 持久化增加深比较跳过无变化写入 + 滞后重试
- 新增 flushPersistence(),App 切后台时立即刷盘
- CrashReporter 新增文件持久化(CrashFs 抽象)
- settingsStore hydrate 期间跳过自动持久化(skipPersist 标志)
去重与管道
- batchDedup 改用日期索引 Map,候选查找从 O(n) 降至 O(1)
- dedupAgainstHistory 按日期分组,仅扫描时间窗口内候选
- OcrProcessor 从布尔标志改为 Promise 队列,不再丢弃并发请求
- 去重缓存改用 Map<hash,timestamp> LRU,阈值超20%时批量淘汰
- 悬浮球全局开关(floatingBallEnabled) + 原生 bridge API
其他改进
- recurring 计算修复月/年溢出(1月31日+1月→2月28日,闰年2月29日)
- 预算进度按 posting 分录金额求和(不再仅过滤负数金额)
- recurring.ts 用 Date.parse 本地时间替代 toISOString 的 UTC 偏移
- 信用卡账单日/还款日限制不超过28,防止月份溢出
- 类别模糊匹配增加最小长度2,避免单字符误匹配
- 还款检测排除 refund 方向,避免退款被误判为还款
- WebDAV 同步增加 HTTP 日期格式解析
- Git 合并策略改为以 remote 为基础追加 local 独有行
性能优化
- commonStyles 全局 useMemo 缓存
- Button/Card 的 pressIn/pressOut 改用 useCallback
- TransactionCard/AccountTreeNode 包裹 React.memo
- PostingEditor 用 refs + 函数式更新避免重建
- CreditCard 页预计算账户余额 Map,避免渲染循环重复遍历
- OCR 处理器单例复用,配置变更时才重建
- getLocalAuth/getAccessibilityBridge 结果缓存
测试
- 新增 decimal/constants/channelConfig/transactionBuilder 单测
- 修复金额格式断言(-24.5→-24.50),匹配字符串精度行为
- billPipeline 新增不同格式金额配对、指纹唯一性测试
2026-07-21 15:16:44 +08:00

208 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* OCR 分层处理协调器plan.md「3.1 分层处理架构」+「3.9 JS 层分层处理」)。
*
* Layer 1: 规则匹配(快速,无成本)→ 直接出账单
* Layer 2: OCR + 正则解析(中等精度,无成本)→ 出账单
* Layer 3: AI Vision高精度有成本→ 可选,需用户启用
*
* 每层失败自动降级到下一层。OCR 引擎与 AI Provider 通过注入抽象。
*/
import { DEDUP_CACHE_MAX, IMAGE_HASH_TRUNCATE_LEN } from './constants';
import { matchOcrRule, parseOcrBill, checkIsDetailPage } from './ocr';
import { hash } from './ledger';
import type { ImportedEvent } from './types';
/** OCR 引擎抽象(生产用原生 PP-OCRv5 Module测试用 mock。 */
export interface OcrEngine {
/** 识别图片文本,返回纯文本。 */
recognizeText(imageBase64: string): Promise<string>;
}
/** AI Vision 提供者抽象Layer 3可选。 */
export interface AiVisionProvider {
/** 识别图片中的账单,返回账单事件或 null。 */
recognizeBill(imageBase64: string): Promise<ImportedEvent | null>;
}
/** 分层处理结果。 */
export interface OcrProcessResult {
/** 最终识别出的事件(可能为 null 表示三层都未识别)。 */
event: ImportedEvent | null;
/** 命中的层级。 */
layer: 'layer1-rule' | 'layer2-ocr' | 'layer3-ai' | 'none';
/** OCR 原始文本Layer 2 命中时有值)。 */
ocrText?: string;
/** 跳过原因(去重/免打扰等)。 */
skipped?: 'dedup' | 'busy' | 'landscape-dnd' | 'non-bill';
}
export interface OcrProcessorConfig {
/** 是否启用 Layer 3 AI默认 false需用户启用。 */
aiVisionEnabled: boolean;
/** 横屏免打扰模式。 */
landscapeDnd: boolean;
}
/** 队列中等待处理的请求。 */
interface QueuedRequest {
imageBase64: string;
packageName?: string;
isLandscape: boolean;
resolve: (result: OcrProcessResult) => void;
}
/**
* OCR 处理器:协调三层降级 + 去重守卫。
*
* 去重守卫参考 AutoAccounting 的 ocrDoing防止同一截图重复触发。
* 使用 Promise 队列替代布尔标志,避免快速连续截屏时丢弃合法请求。
*/
export class OcrProcessor {
private processedHashes = new Map<string, number>(); // hash -> timestamp for LRU
private processing = false;
private queue: QueuedRequest[] = [];
constructor(
private readonly ocrEngine: OcrEngine,
private readonly aiProvider?: AiVisionProvider,
private readonly config: OcrProcessorConfig = { aiVisionEnabled: false, landscapeDnd: false },
) {}
/**
* 处理一张截图/图片,返回账单事件。
* 串行化:同时只处理一张(队列机制)。
*/
process(
imageBase64: string,
packageName?: string,
isLandscape = false,
): Promise<OcrProcessResult> {
return new Promise(resolve => {
this.queue.push({ imageBase64, packageName, isLandscape, resolve });
this.processQueue();
});
}
private async processQueue(): Promise<void> {
if (this.processing || this.queue.length === 0) return;
this.processing = true;
const request = this.queue.shift()!;
try {
const result = await this.doProcess(request.imageBase64, request.packageName, request.isLandscape);
request.resolve(result);
} catch {
request.resolve({ event: null, layer: 'none', skipped: 'non-bill' });
} finally {
this.processing = false;
this.processQueue();
}
}
private async doProcess(
imageBase64: string,
packageName?: string,
isLandscape = false,
): Promise<OcrProcessResult> {
// 横屏免打扰
if (this.config.landscapeDnd && isLandscape) {
return { event: null, layer: 'none', skipped: 'landscape-dnd' };
}
// 去重守卫:同一图片不重复处理
const imageHash = hash(imageBase64.slice(0, IMAGE_HASH_TRUNCATE_LEN));
if (this.processedHashes.has(imageHash)) {
// LRU: 更新时间戳
this.processedHashes.set(imageHash, Date.now());
return { event: null, layer: 'none', skipped: 'dedup' };
}
try {
// Layer 2: OCR 识别Layer 1 基于文本,需先 OCR
const ocrText = await this.ocrEngine.recognizeText(imageBase64);
// OCR 成功后才标记已处理,避免异常时永久阻断重试
this.processedHashes.set(imageHash, Date.now());
// LRU 淘汰:超出阈值 20% 时批量清理最旧条目
if (this.processedHashes.size > DEDUP_CACHE_MAX * 1.2) {
const entries = [...this.processedHashes.entries()]
.sort((a, b) => a[1] - b[1]);
const toDelete = entries.slice(0, entries.length - DEDUP_CACHE_MAX);
for (const [key] of toDelete) {
this.processedHashes.delete(key);
}
}
if (!ocrText || !ocrText.trim()) {
// 无文本,尝试 Layer 3
return await this.tryLayer3(imageBase64, undefined);
}
// 针对账单详情页进行特殊路由:若判断是结构化详情页,直接绕过宽松的 Layer 1 规则匹配,
// 以避免"银行卡"等规则在详情页上误提取(例如误将"消费1次"提取为"-1元")。
const isDetailPage = checkIsDetailPage(ocrText);
if (!isDetailPage) {
// Layer 1: 规则匹配(基于 OCR 文本)
const ruleMatch = matchOcrRule(ocrText, packageName);
if (ruleMatch.matched && ruleMatch.event) {
return { event: ruleMatch.event, layer: 'layer1-rule', ocrText };
}
}
// Layer 2: 退化解析
const fallback = parseOcrBill(ocrText, packageName);
if (fallback) {
return { event: fallback, layer: 'layer2-ocr', ocrText };
}
// 非账单内容billGuard尝试 Layer 3
return await this.tryLayer3(imageBase64, ocrText);
} catch {
return { event: null, layer: 'none', skipped: 'non-bill' };
}
}
private async tryLayer3(imageBase64: string, ocrText?: string): Promise<OcrProcessResult> {
if (!this.config.aiVisionEnabled || !this.aiProvider) {
return { event: null, layer: 'none', ocrText, skipped: 'non-bill' };
}
try {
const event = await this.aiProvider.recognizeBill(imageBase64);
if (event) return { event, layer: 'layer3-ai', ocrText };
return { event: null, layer: 'none', ocrText, skipped: 'non-bill' };
} catch {
return { event: null, layer: 'none', ocrText, skipped: 'non-bill' };
}
}
/** 重置去重状态(切换页面/应用重启时)。 */
reset(): void {
this.processedHashes.clear();
this.processing = false;
this.queue = [];
}
}
/** 模拟 OCR 引擎(测试用,返回预设文本)。 */
export class MockOcrEngine implements OcrEngine {
constructor(private responses: Map<string, string> = new Map(), public defaultResponse = '') {}
async recognizeText(imageBase64: string): Promise<string> {
// 简单匹配:按图片 hash 前缀找预设
const h = hash(imageBase64.slice(0, 100));
return this.responses.get(h) ?? this.defaultResponse;
}
/** 测试辅助:注入预设响应。 */
setResponse(imageBase64: string, text: string): void {
this.responses.set(hash(imageBase64.slice(0, 100)), text);
}
}
/** 模拟 AI Vision Provider测试用。 */
export class MockAiVisionProvider implements AiVisionProvider {
constructor(private response: ImportedEvent | null = null) {}
async recognizeBill(): Promise<ImportedEvent | null> {
return this.response;
}
}