fix:修复通知调试状态和重复命中问题
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -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 || [])
|
||||
}
|
||||
|
||||
@@ -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,})/)
|
||||
|
||||
Reference in New Issue
Block a user