feat:新增ai分析,新增储值账户,优化通知规则

This commit is contained in:
2026-03-12 14:03:01 +08:00
parent 6a00875246
commit 6ca962a187
22 changed files with 1294 additions and 237 deletions

View File

@@ -1,24 +1,22 @@
package com.echo.app.notification
package com.echo.app.notification
import android.content.Context
import android.database.Cursor
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
import android.util.Log
import java.util.UUID
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
import kotlin.math.abs
// 原生 SQLite 访问封装:与 @capacitor-community/sqlite 共用同一个 echo_local 数据库
// Native SQLite access shared with @capacitor-community/sqlite.
object NotificationDb {
private const val TAG = "NotificationDb"
// 注意:前端通过 @capacitor-community/sqlite 使用的 DB 名为 "echo_local"
// 插件在 Android 上实际创建的文件名为 "<dbName>SQLite.db"
private const val DB_FILE_NAME = "echo_localSQLite.db"
private const val DB_VERSION = 1
// transactions 表结构需与 src/lib/sqlite.js 保持完全一致
private const val SQL_CREATE_TRANSACTIONS = """
CREATE TABLE IF NOT EXISTS transactions (
id TEXT PRIMARY KEY NOT NULL,
@@ -27,11 +25,24 @@ object NotificationDb {
category TEXT DEFAULT 'Uncategorized',
date TEXT NOT NULL,
note TEXT,
sync_status INTEGER DEFAULT 0
sync_status INTEGER DEFAULT 0,
entry_type TEXT DEFAULT 'expense',
fund_source_type TEXT DEFAULT 'cash',
fund_source_name TEXT,
fund_target_type TEXT DEFAULT 'merchant',
fund_target_name TEXT,
impact_expense INTEGER DEFAULT 1,
impact_income INTEGER DEFAULT 0,
ai_category TEXT,
ai_tags TEXT,
ai_confidence REAL,
ai_reason TEXT,
ai_status TEXT DEFAULT 'idle',
ai_model TEXT,
ai_normalized_merchant TEXT
);
"""
// notifications_raw 表:用于调试与规则命中记录
private const val SQL_CREATE_NOTIFICATIONS_RAW = """
CREATE TABLE IF NOT EXISTS notifications_raw (
id TEXT PRIMARY KEY NOT NULL,
@@ -47,18 +58,33 @@ object NotificationDb {
);
"""
// 简单 SQLiteOpenHelper主要用于定位与创建数据库文件
private val TRANSACTION_REQUIRED_COLUMNS = linkedMapOf(
"entry_type" to "TEXT DEFAULT 'expense'",
"fund_source_type" to "TEXT DEFAULT 'cash'",
"fund_source_name" to "TEXT",
"fund_target_type" to "TEXT DEFAULT 'merchant'",
"fund_target_name" to "TEXT",
"impact_expense" to "INTEGER DEFAULT 1",
"impact_income" to "INTEGER DEFAULT 0",
"ai_category" to "TEXT",
"ai_tags" to "TEXT",
"ai_confidence" to "REAL",
"ai_reason" to "TEXT",
"ai_status" to "TEXT DEFAULT 'idle'",
"ai_model" to "TEXT",
"ai_normalized_merchant" to "TEXT",
)
private class EchoDbHelper(context: Context) :
SQLiteOpenHelper(context, DB_FILE_NAME, null, DB_VERSION) {
override fun onCreate(db: SQLiteDatabase) {
// 正常情况下,数据表由前端首次启动时创建;此处作为兜底
db.execSQL(SQL_CREATE_TRANSACTIONS)
db.execSQL(SQL_CREATE_NOTIFICATIONS_RAW)
}
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
// 当前仅有 v1暂不实现升级逻辑未来如有版本变更再补充
// No-op for now.
}
}
@@ -69,22 +95,37 @@ object NotificationDb {
private var schemaInitialized = false
private fun getHelper(context: Context): EchoDbHelper {
// 使用 applicationContext 避免泄漏 Activity
val appContext = context.applicationContext
return helper ?: synchronized(this) {
helper ?: EchoDbHelper(appContext).also { helper = it }
}
}
private fun ensureTableColumns(db: SQLiteDatabase, tableName: String, requiredColumns: Map<String, String>) {
val existingColumns = mutableSetOf<String>()
val cursor: Cursor = db.rawQuery("PRAGMA table_info($tableName)", null)
cursor.use {
val nameIndex = it.getColumnIndex("name")
while (it.moveToNext()) {
existingColumns += it.getString(nameIndex)
}
}
requiredColumns.forEach { (columnName, definition) ->
if (!existingColumns.contains(columnName)) {
db.execSQL("ALTER TABLE $tableName ADD COLUMN $columnName $definition")
}
}
}
private fun ensureSchema(db: SQLiteDatabase) {
if (schemaInitialized) return
// 多线程下可能执行多次,但都是幂等的 IF NOT EXISTS性能成本极低
db.execSQL(SQL_CREATE_TRANSACTIONS)
db.execSQL(SQL_CREATE_NOTIFICATIONS_RAW)
ensureTableColumns(db, "transactions", TRANSACTION_REQUIRED_COLUMNS)
schemaInitialized = true
}
// 用于规则引擎输入的原始通知模型
data class RawNotification(
val sourceId: String,
val packageName: String,
@@ -94,30 +135,19 @@ object NotificationDb {
val postedAt: String
)
/**
* 处理单条通知:
* 1. 写入 notifications_raw初始 status = 'unmatched'
* 2. 调用 NotificationRuleEngine 做规则解析
* 3. 命中规则时:
* - 写入一条 transactions 记录
* - 更新 notifications_raw.status = 'matched',补充 rule_id、transaction_id
* 4. 解析异常时:
* - 更新 notifications_raw.status = 'error',写入 error_message
*/
fun processAndStoreNotification(context: Context, raw: RawNotification) {
lock.withLock {
val db = getHelper(context).writableDatabase
ensureSchema(db)
val rawId = UUID.randomUUID().toString()
var status = "unmatched"
var ruleId: String? = null
var transactionId: String? = null
var errorMessage: String? = null
val rawId = UUID.randomUUID().toString()
var status = "unmatched"
var ruleId: String? = null
var transactionId: String? = null
var errorMessage: String? = null
db.beginTransaction()
try {
// 写入 notifications_raw初始 unmatched
db.compileStatement(
"""
INSERT INTO notifications_raw (
@@ -127,16 +157,8 @@ object NotificationDb {
).apply {
bindString(1, rawId)
bindString(2, raw.sourceId)
if (raw.channel != null) {
bindString(3, raw.channel)
} else {
bindNull(3)
}
if (raw.title != null) {
bindString(4, raw.title)
} else {
bindNull(4)
}
if (raw.channel != null) bindString(3, raw.channel) else bindNull(3)
if (raw.title != null) bindString(4, raw.title) else bindNull(4)
bindString(5, raw.text)
bindString(6, raw.postedAt)
bindString(7, status)
@@ -144,34 +166,37 @@ object NotificationDb {
close()
}
// 调用规则引擎
val parsed = NotificationRuleEngine.parse(raw)
if (parsed != null) {
ruleId = parsed.ruleId
// 与 JS 侧逻辑保持一致:
// requiresConfirmation = !(autoCapture && amount!=0 && merchant!='Unknown')
val hasAmount = parsed.amount != 0.0
val merchantKnown =
parsed.merchant.isNotBlank() && parsed.merchant != "Unknown"
val merchantKnown = parsed.merchant.isNotBlank() && parsed.merchant != "Unknown"
val requiresConfirmation = !(parsed.autoCapture && hasAmount && merchantKnown)
if (!requiresConfirmation) {
// 统一按照设计文档:支出为负数,收入为正数
val signedAmount = if (parsed.isExpense) {
-kotlin.math.abs(parsed.amount)
} else {
kotlin.math.abs(parsed.amount)
}
val signedAmount = if (parsed.isExpense) -abs(parsed.amount) else abs(parsed.amount)
transactionId = UUID.randomUUID().toString()
status = "matched"
db.compileStatement(
"""
INSERT INTO transactions (
id, amount, merchant, category, date, note, sync_status
) VALUES (?, ?, ?, ?, ?, ?, ?)
id,
amount,
merchant,
category,
date,
note,
sync_status,
entry_type,
fund_source_type,
fund_source_name,
fund_target_type,
fund_target_name,
impact_expense,
impact_income
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""".trimIndent()
).apply {
bindString(1, transactionId)
@@ -179,23 +204,21 @@ object NotificationDb {
bindString(3, parsed.merchant)
bindString(4, parsed.category)
bindString(5, raw.postedAt)
if (parsed.note != null) {
bindString(6, parsed.note)
} else {
bindNull(6)
}
// sync_status: 0 = pending本地生成尚未同步
if (parsed.note != null) bindString(6, parsed.note) else bindNull(6)
bindLong(7, 0L)
bindString(8, parsed.entryType)
bindString(9, parsed.fundSourceType)
bindString(10, parsed.fundSourceName)
bindString(11, parsed.fundTargetType)
bindString(12, parsed.fundTargetName)
bindLong(13, if (parsed.impactExpense) 1L else 0L)
bindLong(14, if (parsed.impactIncome) 1L else 0L)
executeInsert()
close()
}
} else {
// 保持 status = 'unmatched',但记录命中的 rule_id方便前端调试视图展示“有推荐规则”
status = "unmatched"
}
}
// 根据结果更新 notifications_raw 状态
db.compileStatement(
"""
UPDATE notifications_raw
@@ -204,21 +227,9 @@ object NotificationDb {
""".trimIndent()
).apply {
bindString(1, status)
if (ruleId != null) {
bindString(2, ruleId)
} else {
bindNull(2)
}
if (transactionId != null) {
bindString(3, transactionId)
} else {
bindNull(3)
}
if (errorMessage != null) {
bindString(4, errorMessage)
} else {
bindNull(4)
}
if (ruleId != null) bindString(2, ruleId) else bindNull(2)
if (transactionId != null) bindString(3, transactionId) else bindNull(3)
if (errorMessage != null) bindString(4, errorMessage) else bindNull(4)
bindString(5, rawId)
executeUpdateDelete()
close()
@@ -226,8 +237,7 @@ object NotificationDb {
db.setTransactionSuccessful()
} catch (e: Exception) {
// 若解析阶段抛异常,尽量在同一事务中回写 error 状态
Log.e(TAG, "处理通知并写入 SQLite 失败", e)
Log.e(TAG, "processAndStoreNotification failed", e)
status = "error"
errorMessage = e.message ?: e.toString()
try {
@@ -239,13 +249,13 @@ object NotificationDb {
""".trimIndent()
).apply {
bindString(1, status)
bindString(2, errorMessage!!)
bindString(2, errorMessage)
bindString(3, rawId)
executeUpdateDelete()
close()
}
} catch (inner: Exception) {
Log.e(TAG, "更新 notifications_raw 错误状态失败", inner)
Log.e(TAG, "failed to update notifications_raw error state", inner)
}
} finally {
db.endTransaction()

View File

@@ -10,6 +10,15 @@ internal object NotificationRuleData {
keywords = listOf("支付", "扣款", "支出", "消费", "成功支付"),
requiredTextPatterns = emptyList(),
direction = NotificationRuleDirection.EXPENSE,
entryType = "expense",
fundSourceType = "cash",
fundSourceName = "支付宝余额",
fundSourceNameFromChannel = false,
fundTargetType = "merchant",
fundTargetName = null,
fundTargetNameFromChannel = false,
impactExpense = true,
impactIncome = false,
defaultCategory = "Food",
autoCapture = false,
merchantPattern = Regex("(?:向|收款方|商户)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)", setOf(RegexOption.IGNORE_CASE)),
@@ -22,6 +31,15 @@ internal object NotificationRuleData {
keywords = listOf("到账", "收入", "收款", "入账"),
requiredTextPatterns = emptyList(),
direction = NotificationRuleDirection.INCOME,
entryType = "income",
fundSourceType = "external",
fundSourceName = "外部转入",
fundSourceNameFromChannel = false,
fundTargetType = "cash",
fundTargetName = "支付宝余额",
fundTargetNameFromChannel = false,
impactExpense = false,
impactIncome = true,
defaultCategory = "Income",
autoCapture = true,
merchantPattern = Regex("(?:来自|收款方|付款方|来源)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)", setOf(RegexOption.IGNORE_CASE)),
@@ -34,6 +52,15 @@ internal object NotificationRuleData {
keywords = listOf("支付", "支出", "扣款", "消费成功"),
requiredTextPatterns = emptyList(),
direction = NotificationRuleDirection.EXPENSE,
entryType = "expense",
fundSourceType = "cash",
fundSourceName = "微信余额",
fundSourceNameFromChannel = false,
fundTargetType = "merchant",
fundTargetName = null,
fundTargetNameFromChannel = false,
impactExpense = true,
impactIncome = false,
defaultCategory = "Groceries",
autoCapture = false,
merchantPattern = Regex("(?:向|收款方|商户)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)"),
@@ -46,6 +73,15 @@ internal object NotificationRuleData {
keywords = listOf("收款", "到账", "入账", "转入"),
requiredTextPatterns = emptyList(),
direction = NotificationRuleDirection.INCOME,
entryType = "income",
fundSourceType = "external",
fundSourceName = "外部转入",
fundSourceNameFromChannel = false,
fundTargetType = "cash",
fundTargetName = "微信余额",
fundTargetNameFromChannel = false,
impactExpense = false,
impactIncome = true,
defaultCategory = "Income",
autoCapture = true,
merchantPattern = Regex("(?:来自|收款方|付款方|来源)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)"),
@@ -58,6 +94,15 @@ internal object NotificationRuleData {
keywords = listOf("工资", "薪资", "代发", "到账", "入账", "收入"),
requiredTextPatterns = emptyList(),
direction = NotificationRuleDirection.INCOME,
entryType = "income",
fundSourceType = "external",
fundSourceName = "外部转入",
fundSourceNameFromChannel = false,
fundTargetType = "bank",
fundTargetName = "银行卡",
fundTargetNameFromChannel = false,
impactExpense = false,
impactIncome = true,
defaultCategory = "Income",
autoCapture = true,
merchantPattern = Regex("(?:来自|付款方|来源)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)"),
@@ -70,11 +115,62 @@ internal object NotificationRuleData {
keywords = listOf("支出", "消费", "扣款", "刷卡消费", "银联消费"),
requiredTextPatterns = emptyList(),
direction = NotificationRuleDirection.EXPENSE,
entryType = "expense",
fundSourceType = "bank",
fundSourceName = "银行卡",
fundSourceNameFromChannel = false,
fundTargetType = "merchant",
fundTargetName = null,
fundTargetNameFromChannel = false,
impactExpense = true,
impactIncome = false,
defaultCategory = "Expense",
autoCapture = false,
merchantPattern = Regex("(?:商户|商家|消费商户)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)"),
defaultMerchant = null,
),
NotificationRuleDefinition(
id = "stored-value-topup",
label = "储值账户充值",
channels = listOf("杭州通互联互通卡", "杭州通", "北京一卡通", "上海公共交通卡", "深圳通", "苏州公交卡", "交通联合", "乘车码", "地铁", "公交", "Mi Pay", "Huawei Pay", "华为钱包", "小米钱包", "OPPO 钱包"),
keywords = listOf("充值", "充值成功", "充值到账", "钱包充值", "卡内充值"),
requiredTextPatterns = emptyList(),
direction = NotificationRuleDirection.EXPENSE,
entryType = "transfer",
fundSourceType = "cash",
fundSourceName = "现金账户",
fundSourceNameFromChannel = false,
fundTargetType = "stored_value",
fundTargetName = null,
fundTargetNameFromChannel = true,
impactExpense = false,
impactIncome = false,
defaultCategory = "Transfer",
autoCapture = true,
merchantPattern = null,
defaultMerchant = "储值账户充值",
),
NotificationRuleDefinition(
id = "stored-value-refund",
label = "储值账户退款",
channels = listOf("杭州通互联互通卡", "杭州通", "北京一卡通", "上海公共交通卡", "深圳通", "苏州公交卡", "交通联合", "乘车码", "地铁", "公交", "Mi Pay", "Huawei Pay", "华为钱包", "小米钱包", "OPPO 钱包"),
keywords = listOf("退款", "退资", "退卡", "余额退回", "退款成功"),
requiredTextPatterns = emptyList(),
direction = NotificationRuleDirection.INCOME,
entryType = "transfer",
fundSourceType = "stored_value",
fundSourceName = null,
fundSourceNameFromChannel = true,
fundTargetType = "cash",
fundTargetName = "现金账户",
fundTargetNameFromChannel = false,
impactExpense = false,
impactIncome = false,
defaultCategory = "Transfer",
autoCapture = true,
merchantPattern = null,
defaultMerchant = "储值账户退款",
),
NotificationRuleDefinition(
id = "transit-card-expense",
label = "公交出行",
@@ -82,6 +178,15 @@ internal object NotificationRuleData {
keywords = emptyList(),
requiredTextPatterns = listOf("扣费", "扣款", "支付", "乘车码", "车费", "票价", "本次乘车", "实付", "优惠后"),
direction = NotificationRuleDirection.EXPENSE,
entryType = "expense",
fundSourceType = "stored_value",
fundSourceName = null,
fundSourceNameFromChannel = true,
fundTargetType = "merchant",
fundTargetName = null,
fundTargetNameFromChannel = false,
impactExpense = true,
impactIncome = false,
defaultCategory = "Transport",
autoCapture = true,
merchantPattern = Regex("([\\u4e00-\\u9fa5]{2,}\\s*(?:-|->|→|至|到)\\s*[\\u4e00-\\u9fa5]{2,})"),

View File

@@ -14,6 +14,15 @@ internal data class NotificationRuleDefinition(
val keywords: List<String> = emptyList(),
val requiredTextPatterns: List<String> = emptyList(),
val direction: NotificationRuleDirection,
val entryType: String = "expense",
val fundSourceType: String = "cash",
val fundSourceName: String? = null,
val fundSourceNameFromChannel: Boolean = false,
val fundTargetType: String = "merchant",
val fundTargetName: String? = null,
val fundTargetNameFromChannel: Boolean = false,
val impactExpense: Boolean = true,
val impactIncome: Boolean = false,
val defaultCategory: String,
val autoCapture: Boolean,
val merchantPattern: Regex? = null,
@@ -30,6 +39,13 @@ object NotificationRuleEngine {
val category: String,
val amount: Double,
val isExpense: Boolean,
val entryType: String,
val fundSourceType: String,
val fundSourceName: String,
val fundTargetType: String,
val fundTargetName: String,
val impactExpense: Boolean,
val impactIncome: Boolean,
val note: String?,
val autoCapture: Boolean,
)
@@ -60,11 +76,24 @@ object NotificationRuleEngine {
category = rule.defaultCategory.ifBlank { "Uncategorized" },
amount = amount,
isExpense = rule.direction == NotificationRuleDirection.EXPENSE,
entryType = rule.entryType,
fundSourceType = rule.fundSourceType,
fundSourceName = resolveLedgerName(raw.channel, rule.fundSourceName, rule.fundSourceNameFromChannel),
fundTargetType = rule.fundTargetType,
fundTargetName = resolveLedgerName(raw.channel, rule.fundTargetName, rule.fundTargetNameFromChannel)
.ifBlank { if (rule.entryType == "expense") merchant else "" },
impactExpense = rule.impactExpense,
impactIncome = rule.impactIncome,
note = content.take(200),
autoCapture = rule.autoCapture,
)
}
private fun resolveLedgerName(channel: String?, configuredName: String?, fromChannel: Boolean): String {
if (fromChannel && !channel.isNullOrBlank()) return channel
return configuredName.orEmpty()
}
private fun matchRule(
raw: NotificationDb.RawNotification,
text: String,
@@ -80,17 +109,25 @@ object NotificationRuleEngine {
val keywordHit =
rule.keywords.isEmpty() || rule.keywords.any { text.contains(it) }
val requiredTextHit =
rule.requiredTextPatterns.isEmpty() || rule.requiredTextPatterns.any { text.contains(it) }
rule.requiredTextPatterns.isEmpty() ||
rule.requiredTextPatterns.any { text.contains(it) } ||
hasTransitRouteAmountFallback(rule, text)
return channelHit && keywordHit && requiredTextHit
}
private fun hasTransitRouteAmountFallback(rule: NotificationRuleDefinition, text: String): Boolean {
if (rule.id != "transit-card-expense") return false
return Regex("""[\u4e00-\u9fa5]{2,}\s*(?:-|->|→|至|到)\s*[\u4e00-\u9fa5]{2,}[:]?\s*\d+(?:\.\d{1,2})?\s*(?:元|块|块钱)""")
.containsMatchIn(text)
}
private fun extractAmount(text: String): Double {
val normalized = text.replace(",", "").trim()
val patterns = listOf(
Regex("""(?:¥|¥|RMB|CNY|人民币)\s*(-?\d+(?:\.\d{1,2})?)""", RegexOption.IGNORE_CASE),
Regex("""(-?\d+(?:\.\d{1,2})?)\s*(?:元|块|块钱)"""),
Regex("""(?:金额|实付|扣费|扣款|消费|支出|收入|收款|到账|入账|转入|转出|支付|票价|车费|本次乘车)\D{0,8}?(-?\d+(?:\.\d{1,2})?)"""),
Regex("""(?:金额|实付|扣费|扣款|消费|支出|收入|收款|到账|入账|转入|转出|支付|票价|车费|本次乘车|充值)\D{0,8}?(-?\d+(?:\.\d{1,2})?)"""),
Regex("""(-?\d+(?:\.\d{1,2})?)\s*(?:元|块|块钱)?\s*(?:已支付|已收款|到账|入账)"""),
)