Compare commits

...

2 Commits

Author SHA1 Message Date
655e3f8879 chore: release v0.5.1 2026-03-13 10:37:36 +08:00
2b2d56520f fix:修复通知调试状态和重复命中问题 2026-03-13 10:35:00 +08:00
9 changed files with 131 additions and 93 deletions

View File

@@ -9,8 +9,8 @@ android {
applicationId "com.echo.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 500
versionName "0.5.0"
versionCode 501
versionName "0.5.1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {

View File

@@ -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()

View File

@@ -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(

View File

@@ -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
}
}

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "echo-app",
"version": "0.5.0",
"version": "0.5.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "echo-app",
"version": "0.5.0",
"version": "0.5.1",
"dependencies": {
"@capacitor-community/sqlite": "^5.7.2",
"@capacitor/android": "^5.7.8",

View File

@@ -1,7 +1,7 @@
{
"name": "echo-app",
"private": true,
"version": "0.5.0",
"version": "0.5.1",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",

View File

@@ -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',
},
]

View File

@@ -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 || [])
}

View File

@@ -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,})/)