diff --git a/android/app/src/main/java/com/echo/app/notification/NotificationDb.kt b/android/app/src/main/java/com/echo/app/notification/NotificationDb.kt index 4d11a2a..83333a9 100644 --- a/android/app/src/main/java/com/echo/app/notification/NotificationDb.kt +++ b/android/app/src/main/java/com/echo/app/notification/NotificationDb.kt @@ -1,4 +1,4 @@ -package com.echo.app.notification +package com.echo.app.notification import android.content.Context import android.database.Cursor @@ -118,6 +118,14 @@ object NotificationDb { } } + private fun hasRawNotification(db: SQLiteDatabase, sourceId: String): Boolean { + if (sourceId.isBlank()) return false + val cursor = db.rawQuery("SELECT 1 FROM notifications_raw WHERE source_id = ? LIMIT 1", arrayOf(sourceId)) + cursor.use { + return it.moveToFirst() + } + } + private fun ensureSchema(db: SQLiteDatabase) { if (schemaInitialized) return db.execSQL(SQL_CREATE_TRANSACTIONS) @@ -140,6 +148,10 @@ object NotificationDb { val db = getHelper(context).writableDatabase ensureSchema(db) + if (hasRawNotification(db, raw.sourceId)) { + return + } + val rawId = UUID.randomUUID().toString() var status = "unmatched" var ruleId: String? = null @@ -174,6 +186,8 @@ object NotificationDb { val merchantKnown = parsed.merchant.isNotBlank() && parsed.merchant != "Unknown" val requiresConfirmation = !(parsed.autoCapture && hasAmount && merchantKnown) + status = if (requiresConfirmation) "review" else "matched" + if (!requiresConfirmation) { val signedAmount = if (parsed.isExpense) -abs(parsed.amount) else abs(parsed.amount) transactionId = UUID.randomUUID().toString() diff --git a/android/app/src/main/java/com/echo/app/notification/NotificationRuleData.kt b/android/app/src/main/java/com/echo/app/notification/NotificationRuleData.kt index e1bead8..b47a230 100644 --- a/android/app/src/main/java/com/echo/app/notification/NotificationRuleData.kt +++ b/android/app/src/main/java/com/echo/app/notification/NotificationRuleData.kt @@ -126,7 +126,7 @@ internal object NotificationRuleData { impactIncome = false, defaultCategory = "Expense", autoCapture = false, - merchantPattern = Regex("(?:商户|商家|消费商户)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)"), + merchantPattern = Regex("(?:在【([^】]+)】发生|商户|商家|消费商户)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)?"), defaultMerchant = null, ), NotificationRuleDefinition( diff --git a/android/app/src/main/java/com/echo/app/notification/NotificationRuleEngine.kt b/android/app/src/main/java/com/echo/app/notification/NotificationRuleEngine.kt index 4c1e9da..df21ce6 100644 --- a/android/app/src/main/java/com/echo/app/notification/NotificationRuleEngine.kt +++ b/android/app/src/main/java/com/echo/app/notification/NotificationRuleEngine.kt @@ -1,4 +1,4 @@ -package com.echo.app.notification +package com.echo.app.notification import kotlin.math.abs @@ -156,7 +156,7 @@ object NotificationRuleEngine { rule.merchantPattern?.let { regex -> val match = regex.find(content) if (match != null && match.groupValues.size > 1) { - val candidate = match.groupValues[1].trim() + val candidate = match.groupValues.drop(1).firstOrNull { it.isNotBlank() }?.trim().orEmpty() if (candidate.isNotEmpty()) return candidate } } diff --git a/src/config/notificationRules.js b/src/config/notificationRules.js index 2eccdaa..b09cfe4 100644 --- a/src/config/notificationRules.js +++ b/src/config/notificationRules.js @@ -1,32 +1,32 @@ const transitStoredValueChannels = [ - '杭州通互联互通卡', - '杭州通', - '北京一卡通', - '上海公共交通卡', - '深圳通', - '苏州公交卡', - '交通联合', - '乘车码', - '地铁', - '公交', + '\u676d\u5dde\u901a\u4e92\u8054\u4e92\u901a\u5361', + '\u676d\u5dde\u901a', + '\u5317\u4eac\u4e00\u5361\u901a', + '\u4e0a\u6d77\u516c\u5171\u4ea4\u901a\u5361', + '\u6df1\u5733\u901a', + '\u82cf\u5dde\u516c\u4ea4\u5361', + '\u4ea4\u901a\u8054\u5408', + '\u4e58\u8f66\u7801', + '\u5730\u94c1', + '\u516c\u4ea4', 'Mi Pay', 'Huawei Pay', - '华为钱包', - '小米钱包', - 'OPPO 钱包', + '\u534e\u4e3a\u94b1\u5305', + '\u5c0f\u7c73\u94b1\u5305', + 'OPPO \u94b1\u5305', ] const notificationRules = [ { id: 'alipay-expense', - label: '支付宝消费', - channels: ['支付宝', 'Alipay'], - keywords: ['支付', '扣款', '支出', '消费', '成功支付'], + label: '\u652f\u4ed8\u5b9d\u6d88\u8d39', + channels: ['\u652f\u4ed8\u5b9d', 'Alipay'], + keywords: ['\u652f\u4ed8', '\u6263\u6b3e', '\u652f\u51fa', '\u6d88\u8d39', '\u6210\u529f\u652f\u4ed8'], requiredTextPatterns: [], direction: 'expense', entryType: 'expense', fundSourceType: 'cash', - fundSourceName: '支付宝余额', + fundSourceName: '\u652f\u4ed8\u5b9d\u4f59\u989d', fundTargetType: 'merchant', fundTargetName: '', impactExpense: true, @@ -34,43 +34,43 @@ const notificationRules = [ defaultCategory: 'Food', autoCapture: false, merchantPattern: { - pattern: '(?:向|收款方|商户)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)', + pattern: '(?:\u5411|\u6536\u6b3e\u65b9|\u5546\u6237)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)', flags: 'i', }, defaultMerchant: '', }, { id: 'alipay-income', - label: '支付宝收款', - channels: ['支付宝', 'Alipay'], - keywords: ['到账', '收入', '收款', '入账'], + label: '\u652f\u4ed8\u5b9d\u6536\u6b3e', + channels: ['\u652f\u4ed8\u5b9d', 'Alipay'], + keywords: ['\u5230\u8d26', '\u6536\u5165', '\u6536\u6b3e', '\u5165\u8d26'], requiredTextPatterns: [], direction: 'income', entryType: 'income', fundSourceType: 'external', - fundSourceName: '外部转入', + fundSourceName: '\u5916\u90e8\u8f6c\u5165', fundTargetType: 'cash', - fundTargetName: '支付宝余额', + fundTargetName: '\u652f\u4ed8\u5b9d\u4f59\u989d', impactExpense: false, impactIncome: true, defaultCategory: 'Income', autoCapture: true, merchantPattern: { - pattern: '(?:来自|收款方|付款方|来源)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)', + pattern: '(?:\u6765\u81ea|\u6536\u6b3e\u65b9|\u4ed8\u6b3e\u65b9|\u6765\u6e90)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)', flags: 'i', }, defaultMerchant: '', }, { id: 'wechat-expense', - label: '微信消费', - channels: ['微信支付', '微信', 'WeChat'], - keywords: ['支付', '支出', '扣款', '消费成功'], + label: '\u5fae\u4fe1\u6d88\u8d39', + channels: ['\u5fae\u4fe1\u652f\u4ed8', '\u5fae\u4fe1', 'WeChat'], + keywords: ['\u652f\u4ed8', '\u652f\u51fa', '\u6263\u6b3e', '\u6d88\u8d39\u6210\u529f'], requiredTextPatterns: [], direction: 'expense', entryType: 'expense', fundSourceType: 'cash', - fundSourceName: '微信余额', + fundSourceName: '\u5fae\u4fe1\u4f59\u989d', fundTargetType: 'merchant', fundTargetName: '', impactExpense: true, @@ -78,93 +78,93 @@ const notificationRules = [ defaultCategory: 'Groceries', autoCapture: false, merchantPattern: { - pattern: '(?:向|收款方|商户)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)', + pattern: '(?:\u5411|\u6536\u6b3e\u65b9|\u5546\u6237)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)', flags: '', }, defaultMerchant: '', }, { id: 'wechat-income', - label: '微信收款', - channels: ['微信支付', '微信', 'WeChat'], - keywords: ['收款', '到账', '入账', '转入'], + label: '\u5fae\u4fe1\u6536\u6b3e', + channels: ['\u5fae\u4fe1\u652f\u4ed8', '\u5fae\u4fe1', 'WeChat'], + keywords: ['\u6536\u6b3e', '\u5230\u8d26', '\u5165\u8d26', '\u8f6c\u5165'], requiredTextPatterns: [], direction: 'income', entryType: 'income', fundSourceType: 'external', - fundSourceName: '外部转入', + fundSourceName: '\u5916\u90e8\u8f6c\u5165', fundTargetType: 'cash', - fundTargetName: '微信余额', + fundTargetName: '\u5fae\u4fe1\u4f59\u989d', impactExpense: false, impactIncome: true, defaultCategory: 'Income', autoCapture: true, merchantPattern: { - pattern: '(?:来自|收款方|付款方|来源)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)', + pattern: '(?:\u6765\u81ea|\u6536\u6b3e\u65b9|\u4ed8\u6b3e\u65b9|\u6765\u6e90)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)', flags: '', }, defaultMerchant: '', }, { id: 'bank-income', - label: '银行入账', + label: '\u94f6\u884c\u5165\u8d26', channels: [ - '招商银行', - '中国银行', - '建设银行', - '工商银行', - '农业银行', - '交通银行', - '浦发银行', - '兴业银行', - '光大银行', - '中信银行', - '广发银行', - '平安银行', + '\u62db\u5546\u94f6\u884c', + '\u4e2d\u56fd\u94f6\u884c', + '\u5efa\u8bbe\u94f6\u884c', + '\u5de5\u5546\u94f6\u884c', + '\u519c\u4e1a\u94f6\u884c', + '\u4ea4\u901a\u94f6\u884c', + '\u6d66\u53d1\u94f6\u884c', + '\u5174\u4e1a\u94f6\u884c', + '\u5149\u5927\u94f6\u884c', + '\u4e2d\u4fe1\u94f6\u884c', + '\u5e7f\u53d1\u94f6\u884c', + '\u5e73\u5b89\u94f6\u884c', ], - keywords: ['工资', '薪资', '代发', '到账', '入账', '收入'], + keywords: ['\u5de5\u8d44', '\u85aa\u8d44', '\u4ee3\u53d1', '\u5230\u8d26', '\u5165\u8d26', '\u6536\u5165'], requiredTextPatterns: [], direction: 'income', entryType: 'income', fundSourceType: 'external', - fundSourceName: '外部转入', + fundSourceName: '\u5916\u90e8\u8f6c\u5165', fundTargetType: 'bank', - fundTargetName: '银行卡', + fundTargetName: '\u94f6\u884c\u5361', impactExpense: false, impactIncome: true, defaultCategory: 'Income', autoCapture: true, merchantPattern: { - pattern: '(?:来自|付款方|来源)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)', + pattern: '(?:\u6765\u81ea|\u4ed8\u6b3e\u65b9|\u6765\u6e90)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)', flags: '', }, defaultMerchant: '', }, { id: 'bank-expense', - label: '银行卡支出', + label: '\u94f6\u884c\u5361\u652f\u51fa', channels: [ - '招商银行', - '中国银行', - '建设银行', - '工商银行', - '农业银行', - '交通银行', - '浦发银行', - '兴业银行', - '光大银行', - '中信银行', - '广发银行', - '平安银行', - '借记卡', - '信用卡', + '\u62db\u5546\u94f6\u884c', + '\u4e2d\u56fd\u94f6\u884c', + '\u5efa\u8bbe\u94f6\u884c', + '\u5de5\u5546\u94f6\u884c', + '\u519c\u4e1a\u94f6\u884c', + '\u4ea4\u901a\u94f6\u884c', + '\u6d66\u53d1\u94f6\u884c', + '\u5174\u4e1a\u94f6\u884c', + '\u5149\u5927\u94f6\u884c', + '\u4e2d\u4fe1\u94f6\u884c', + '\u5e7f\u53d1\u94f6\u884c', + '\u5e73\u5b89\u94f6\u884c', + '\u501f\u8bb0\u5361', + '\u4fe1\u7528\u5361', ], - keywords: ['支出', '消费', '扣款', '刷卡消费', '银联消费'], + keywords: ['\u652f\u51fa', '\u6d88\u8d39', '\u6263\u6b3e', '\u5237\u5361\u6d88\u8d39', '\u94f6\u8054\u6d88\u8d39'], requiredTextPatterns: [], direction: 'expense', entryType: 'expense', fundSourceType: 'bank', - fundSourceName: '银行卡', + fundSourceName: '\u94f6\u884c\u5361', fundTargetType: 'merchant', fundTargetName: '', impactExpense: true, @@ -172,21 +172,22 @@ const notificationRules = [ defaultCategory: 'Expense', autoCapture: false, merchantPattern: { - pattern: '(?:商户|商家|消费商户)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)', + pattern: + '(?:\u5728【([^】]+)】\u53d1\u751f|\u5546\u6237|\u5546\u5bb6|\u6d88\u8d39\u5546\u6237)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)?', flags: '', }, defaultMerchant: '', }, { id: 'stored-value-topup', - label: '储值账户充值', + label: '\u50a8\u503c\u8d26\u6237\u5145\u503c', channels: transitStoredValueChannels, - keywords: ['充值', '充值成功', '充值到账', '钱包充值', '卡内充值'], + keywords: ['\u5145\u503c', '\u5145\u503c\u6210\u529f', '\u5145\u503c\u5230\u8d26', '\u94b1\u5305\u5145\u503c', '\u5361\u5185\u5145\u503c'], requiredTextPatterns: [], direction: 'expense', entryType: 'transfer', fundSourceType: 'cash', - fundSourceName: '现金账户', + fundSourceName: '\u73b0\u91d1\u8d26\u6237', fundTargetType: 'stored_value', fundTargetName: '', fundTargetNameFromChannel: true, @@ -195,13 +196,13 @@ const notificationRules = [ defaultCategory: 'Transfer', autoCapture: true, merchantPattern: null, - defaultMerchant: '储值账户充值', + defaultMerchant: '\u50a8\u503c\u8d26\u6237\u5145\u503c', }, { id: 'stored-value-refund', - label: '储值账户退款', + label: '\u50a8\u503c\u8d26\u6237\u9000\u6b3e', channels: transitStoredValueChannels, - keywords: ['退款', '退资', '退卡', '余额退回', '退款成功'], + keywords: ['\u9000\u6b3e', '\u9000\u8d44', '\u9000\u5361', '\u4f59\u989d\u9000\u56de', '\u9000\u6b3e\u6210\u529f'], requiredTextPatterns: [], direction: 'income', entryType: 'transfer', @@ -209,20 +210,20 @@ const notificationRules = [ fundSourceName: '', fundSourceNameFromChannel: true, fundTargetType: 'cash', - fundTargetName: '现金账户', + fundTargetName: '\u73b0\u91d1\u8d26\u6237', impactExpense: false, impactIncome: false, defaultCategory: 'Transfer', autoCapture: true, merchantPattern: null, - defaultMerchant: '储值账户退款', + defaultMerchant: '\u50a8\u503c\u8d26\u6237\u9000\u6b3e', }, { id: 'transit-card-expense', - label: '公交出行', + label: '\u516c\u4ea4\u51fa\u884c', channels: transitStoredValueChannels, keywords: [], - requiredTextPatterns: ['扣费', '扣款', '支付', '乘车码', '车费', '票价', '本次乘车', '实付', '优惠后'], + requiredTextPatterns: ['\u6263\u8d39', '\u6263\u6b3e', '\u652f\u4ed8', '\u4e58\u8f66\u7801', '\u8f66\u8d39', '\u7968\u4ef7', '\u672c\u6b21\u4e58\u8f66', '\u5b9e\u4ed8', '\u4f18\u60e0\u540e'], direction: 'expense', entryType: 'expense', fundSourceType: 'stored_value', @@ -238,7 +239,7 @@ const notificationRules = [ pattern: '([\\u4e00-\\u9fa5]{2,}\\s*(?:-|->|→|至|到)\\s*[\\u4e00-\\u9fa5]{2,})', flags: '', }, - defaultMerchant: '公交/地铁出行', + defaultMerchant: '\u516c\u4ea4/\u5730\u94c1\u51fa\u884c', }, ] diff --git a/src/services/notificationDebugService.js b/src/services/notificationDebugService.js index 25fdf9c..25d9e4f 100644 --- a/src/services/notificationDebugService.js +++ b/src/services/notificationDebugService.js @@ -22,6 +22,28 @@ const mapRow = (row) => ({ errorMessage: row.error_message || '', }) +const dedupeRecords = (rows = []) => { + const bucket = new Map() + + rows.forEach((row) => { + const item = mapRow(row) + const key = item.sourceId || item.id + if (!bucket.has(key)) { + bucket.set(key, item) + return + } + + const existing = bucket.get(key) + const existingScore = Number(Boolean(existing.transactionId)) + Number(existing.status === NOTIFICATION_DEBUG_STATUS.matched) + Number(Boolean(existing.ruleId)) + const nextScore = Number(Boolean(item.transactionId)) + Number(item.status === NOTIFICATION_DEBUG_STATUS.matched) + Number(Boolean(item.ruleId)) + if (nextScore > existingScore) { + bucket.set(key, item) + } + }) + + return Array.from(bucket.values()) +} + const getLatestBySourceId = async (sourceId) => { if (!sourceId) return null const db = await getDb() @@ -150,5 +172,5 @@ export const fetchNotificationDebugRecords = async (options = {}) => { params, ) - return (result?.values || []).map(mapRow) + return dedupeRecords(result?.values || []) } diff --git a/src/services/notificationRuleService.js b/src/services/notificationRuleService.js index 24bb935..547bbe8 100644 --- a/src/services/notificationRuleService.js +++ b/src/services/notificationRuleService.js @@ -1,4 +1,4 @@ -import { DEFAULT_LEDGER_BY_ENTRY_TYPE } from '../config/ledger.js' +import { DEFAULT_LEDGER_BY_ENTRY_TYPE } from '../config/ledger.js' import notificationRulesSource from '../config/notificationRules.js' const fallbackRules = notificationRulesSource.map((rule) => ({ @@ -115,7 +115,8 @@ const extractMerchant = (text, rule) => { if (rule?.merchantPattern instanceof RegExp) { const matched = content.match(rule.merchantPattern) - if (matched?.[1]) return matched[1].trim() + const candidate = matched?.slice(1).find((item) => item && String(item).trim()) + if (candidate) return candidate.trim() } const routeMatch = content.match(/([\u4e00-\u9fa5]{2,}\s*(?:-|->|→|至|到)\s*[\u4e00-\u9fa5]{2,})/)