feat: 实现原生通知持久化与解析,支持 SQLite 存储和规则引擎
This commit is contained in:
@@ -4,13 +4,14 @@ import android.app.Notification
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.service.notification.NotificationListenerService
|
import android.service.notification.NotificationListenerService
|
||||||
import android.service.notification.StatusBarNotification
|
import android.service.notification.StatusBarNotification
|
||||||
|
import android.util.Log
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import java.util.TimeZone
|
import java.util.TimeZone
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
|
||||||
// Android notification listener: persist notifications to local storage and notify app via broadcast
|
// Android notification listener: 在原生层持久化通知到 SQLite,并通过广播通知前端
|
||||||
class NotificationBridgeService : NotificationListenerService() {
|
class NotificationBridgeService : NotificationListenerService() {
|
||||||
override fun onNotificationPosted(sbn: StatusBarNotification) {
|
override fun onNotificationPosted(sbn: StatusBarNotification) {
|
||||||
val notification = sbn.notification ?: return
|
val notification = sbn.notification ?: return
|
||||||
@@ -21,6 +22,7 @@ class NotificationBridgeService : NotificationListenerService() {
|
|||||||
val bigText = extras?.getCharSequence(Notification.EXTRA_BIG_TEXT)?.toString()
|
val bigText = extras?.getCharSequence(Notification.EXTRA_BIG_TEXT)?.toString()
|
||||||
val lines = extras?.getCharSequenceArray(Notification.EXTRA_TEXT_LINES)
|
val lines = extras?.getCharSequenceArray(Notification.EXTRA_TEXT_LINES)
|
||||||
|
|
||||||
|
// 汇总通知正文,尽量覆盖 BigText / InboxStyle 等不同样式
|
||||||
val builder = StringBuilder()
|
val builder = StringBuilder()
|
||||||
fun appendValue(value: CharSequence?) {
|
fun appendValue(value: CharSequence?) {
|
||||||
val safe = value?.toString()?.takeIf { it.isNotBlank() }
|
val safe = value?.toString()?.takeIf { it.isNotBlank() }
|
||||||
@@ -36,14 +38,33 @@ class NotificationBridgeService : NotificationListenerService() {
|
|||||||
lines?.forEach { appendValue(it) }
|
lines?.forEach { appendValue(it) }
|
||||||
|
|
||||||
val id = sbn.key ?: sbn.id.toString()
|
val id = sbn.key ?: sbn.id.toString()
|
||||||
|
val createdAt = isoNow()
|
||||||
|
val combinedText = if (builder.isNotEmpty()) builder.toString() else text.orEmpty()
|
||||||
|
|
||||||
val payload = JSONObject()
|
val payload = JSONObject()
|
||||||
payload.put("id", id)
|
payload.put("id", id)
|
||||||
payload.put("channel", title ?: sbn.packageName)
|
payload.put("channel", title ?: sbn.packageName)
|
||||||
payload.put("text", if (builder.isNotEmpty()) builder.toString() else text.orEmpty())
|
payload.put("text", combinedText)
|
||||||
payload.put("createdAt", isoNow())
|
payload.put("createdAt", createdAt)
|
||||||
payload.put("packageName", sbn.packageName)
|
payload.put("packageName", sbn.packageName)
|
||||||
|
|
||||||
// Persist notification in local queue for JS side to fetch via NotificationBridge
|
// 1) 原生层:直接写入 SQLite(notifications_raw + transactions)
|
||||||
|
try {
|
||||||
|
val raw = NotificationDb.RawNotification(
|
||||||
|
sourceId = id,
|
||||||
|
packageName = sbn.packageName,
|
||||||
|
channel = title ?: sbn.packageName,
|
||||||
|
title = title,
|
||||||
|
text = combinedText,
|
||||||
|
postedAt = createdAt
|
||||||
|
)
|
||||||
|
NotificationDb.processAndStoreNotification(applicationContext, raw)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// 防御性处理:保持队列与 JS 通知流程不受影响
|
||||||
|
Log.e("NotificationBridgeService", "持久化通知到 SQLite 失败", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) 兼容现有 JS 流程:仍然保留 SharedPreferences 队列与广播事件
|
||||||
NotificationStorage.add(applicationContext, payload)
|
NotificationStorage.add(applicationContext, payload)
|
||||||
|
|
||||||
// Broadcast an in-app event so the Capacitor plugin can notify JS listeners
|
// Broadcast an in-app event so the Capacitor plugin can notify JS listeners
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
package com.echo.app.notification
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
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
|
||||||
|
|
||||||
|
// 原生 SQLite 访问封装:与 @capacitor-community/sqlite 共用同一个 echo_local 数据库
|
||||||
|
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,
|
||||||
|
amount REAL NOT NULL,
|
||||||
|
merchant TEXT NOT NULL,
|
||||||
|
category TEXT DEFAULT 'Uncategorized',
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
note TEXT,
|
||||||
|
sync_status INTEGER DEFAULT 0
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
|
// notifications_raw 表:用于调试与规则命中记录
|
||||||
|
private const val SQL_CREATE_NOTIFICATIONS_RAW = """
|
||||||
|
CREATE TABLE IF NOT EXISTS notifications_raw (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
source_id TEXT,
|
||||||
|
channel TEXT,
|
||||||
|
title TEXT,
|
||||||
|
text TEXT,
|
||||||
|
posted_at TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
rule_id TEXT,
|
||||||
|
transaction_id TEXT,
|
||||||
|
error_message TEXT
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
|
// 简单 SQLiteOpenHelper:主要用于定位与创建数据库文件
|
||||||
|
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,暂不实现升级逻辑;未来如有版本变更再补充
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var helper: EchoDbHelper? = null
|
||||||
|
private val lock = ReentrantLock()
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
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 ensureSchema(db: SQLiteDatabase) {
|
||||||
|
if (schemaInitialized) return
|
||||||
|
// 多线程下可能执行多次,但都是幂等的 IF NOT EXISTS,性能成本极低
|
||||||
|
db.execSQL(SQL_CREATE_TRANSACTIONS)
|
||||||
|
db.execSQL(SQL_CREATE_NOTIFICATIONS_RAW)
|
||||||
|
schemaInitialized = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用于规则引擎输入的原始通知模型
|
||||||
|
data class RawNotification(
|
||||||
|
val sourceId: String,
|
||||||
|
val packageName: String,
|
||||||
|
val channel: String?,
|
||||||
|
val title: String?,
|
||||||
|
val text: String,
|
||||||
|
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
|
||||||
|
|
||||||
|
db.beginTransaction()
|
||||||
|
try {
|
||||||
|
// 写入 notifications_raw(初始 unmatched)
|
||||||
|
db.compileStatement(
|
||||||
|
"""
|
||||||
|
INSERT INTO notifications_raw (
|
||||||
|
id, source_id, channel, title, text, posted_at, status
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""".trimIndent()
|
||||||
|
).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)
|
||||||
|
}
|
||||||
|
bindString(5, raw.text)
|
||||||
|
bindString(6, raw.postedAt)
|
||||||
|
bindString(7, status)
|
||||||
|
executeInsert()
|
||||||
|
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 requiresConfirmation = !(parsed.autoCapture && hasAmount && merchantKnown)
|
||||||
|
|
||||||
|
if (!requiresConfirmation) {
|
||||||
|
// 统一按照设计文档:支出为负数,收入为正数
|
||||||
|
val signedAmount = if (parsed.isExpense) {
|
||||||
|
-kotlin.math.abs(parsed.amount)
|
||||||
|
} else {
|
||||||
|
kotlin.math.abs(parsed.amount)
|
||||||
|
}
|
||||||
|
transactionId = UUID.randomUUID().toString()
|
||||||
|
status = "matched"
|
||||||
|
|
||||||
|
db.compileStatement(
|
||||||
|
"""
|
||||||
|
INSERT INTO transactions (
|
||||||
|
id, amount, merchant, category, date, note, sync_status
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""".trimIndent()
|
||||||
|
).apply {
|
||||||
|
bindString(1, transactionId)
|
||||||
|
bindDouble(2, signedAmount)
|
||||||
|
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(本地生成,尚未同步)
|
||||||
|
bindLong(7, 0L)
|
||||||
|
executeInsert()
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 保持 status = 'unmatched',但记录命中的 rule_id,方便前端调试视图展示“有推荐规则”
|
||||||
|
status = "unmatched"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据结果更新 notifications_raw 状态
|
||||||
|
db.compileStatement(
|
||||||
|
"""
|
||||||
|
UPDATE notifications_raw
|
||||||
|
SET status = ?, rule_id = ?, transaction_id = ?, error_message = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""".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)
|
||||||
|
}
|
||||||
|
bindString(5, rawId)
|
||||||
|
executeUpdateDelete()
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
|
||||||
|
db.setTransactionSuccessful()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// 若解析阶段抛异常,尽量在同一事务中回写 error 状态
|
||||||
|
Log.e(TAG, "处理通知并写入 SQLite 失败", e)
|
||||||
|
status = "error"
|
||||||
|
errorMessage = e.message ?: e.toString()
|
||||||
|
try {
|
||||||
|
db.compileStatement(
|
||||||
|
"""
|
||||||
|
UPDATE notifications_raw
|
||||||
|
SET status = ?, error_message = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""".trimIndent()
|
||||||
|
).apply {
|
||||||
|
bindString(1, status)
|
||||||
|
bindString(2, errorMessage!!)
|
||||||
|
bindString(3, rawId)
|
||||||
|
executeUpdateDelete()
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
} catch (inner: Exception) {
|
||||||
|
Log.e(TAG, "更新 notifications_raw 错误状态失败", inner)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
db.endTransaction()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
package com.echo.app.notification
|
||||||
|
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
|
// 原生通知解析引擎:将 JS 侧的 fallbackRules & 提取逻辑完整搬到 Kotlin,
|
||||||
|
// 在后台直接把通知转成交易草稿。
|
||||||
|
object NotificationRuleEngine {
|
||||||
|
|
||||||
|
// JS 侧规则模型的 Kotlin 版本
|
||||||
|
private data class Rule(
|
||||||
|
val id: String,
|
||||||
|
val label: String,
|
||||||
|
val channels: List<String> = emptyList(),
|
||||||
|
val keywords: List<String> = emptyList(),
|
||||||
|
val direction: Direction,
|
||||||
|
val defaultCategory: String,
|
||||||
|
val autoCapture: Boolean,
|
||||||
|
val merchantPattern: Regex? = null,
|
||||||
|
val defaultMerchant: String? = null,
|
||||||
|
val enabled: Boolean = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
private enum class Direction {
|
||||||
|
INCOME,
|
||||||
|
EXPENSE,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 与 src/services/notificationRuleService.js 中的 fallbackRules 一一对应
|
||||||
|
private val fallbackRules: List<Rule> = listOf(
|
||||||
|
Rule(
|
||||||
|
id = "alipay-expense",
|
||||||
|
label = "支付宝消费",
|
||||||
|
channels = listOf("支付宝", "Alipay"),
|
||||||
|
keywords = listOf("支付", "扣款", "支出", "消费", "成功支付"),
|
||||||
|
direction = Direction.EXPENSE,
|
||||||
|
defaultCategory = "Food",
|
||||||
|
autoCapture = false,
|
||||||
|
merchantPattern = Regex("""向\s*([\u4e00-\u9fa5A-Za-z0-9\s&]+)""", RegexOption.IGNORE_CASE),
|
||||||
|
),
|
||||||
|
Rule(
|
||||||
|
id = "alipay-income",
|
||||||
|
label = "支付宝收款",
|
||||||
|
channels = listOf("支付宝", "Alipay"),
|
||||||
|
keywords = listOf("到账", "收入", "收款", "入账"),
|
||||||
|
direction = Direction.INCOME,
|
||||||
|
defaultCategory = "Income",
|
||||||
|
autoCapture = true,
|
||||||
|
merchantPattern = Regex("""(?:来自|收款方)\s*([\u4e00-\u9fa5A-Za-z0-9\s&]+)""", RegexOption.IGNORE_CASE),
|
||||||
|
),
|
||||||
|
Rule(
|
||||||
|
id = "wechat-expense",
|
||||||
|
label = "微信消费",
|
||||||
|
channels = listOf("微信支付", "微信", "WeChat"),
|
||||||
|
keywords = listOf("支付", "支出", "扣款", "消费成功"),
|
||||||
|
direction = Direction.EXPENSE,
|
||||||
|
defaultCategory = "Groceries",
|
||||||
|
autoCapture = false,
|
||||||
|
merchantPattern = Regex("""向\s*([\u4e00-\u9fa5A-Za-z0-9\s&]+)"""),
|
||||||
|
),
|
||||||
|
Rule(
|
||||||
|
id = "wechat-income",
|
||||||
|
label = "微信收款",
|
||||||
|
channels = listOf("微信支付", "微信", "WeChat"),
|
||||||
|
keywords = listOf("收款", "到账", "入账", "转入"),
|
||||||
|
direction = Direction.INCOME,
|
||||||
|
defaultCategory = "Income",
|
||||||
|
autoCapture = true,
|
||||||
|
merchantPattern = Regex("""(?:来自|收款方)\s*([\u4e00-\u9fa5A-Za-z0-9\s&]+)"""),
|
||||||
|
),
|
||||||
|
Rule(
|
||||||
|
id = "bank-income",
|
||||||
|
label = "工资到账",
|
||||||
|
channels = listOf(
|
||||||
|
"招商银行",
|
||||||
|
"中国银行",
|
||||||
|
"建设银行",
|
||||||
|
"工商银行",
|
||||||
|
"农业银行",
|
||||||
|
"交通银行",
|
||||||
|
"浦发银行",
|
||||||
|
"兴业银行",
|
||||||
|
"光大银行",
|
||||||
|
"中信银行",
|
||||||
|
"广发银行",
|
||||||
|
"平安银行",
|
||||||
|
),
|
||||||
|
keywords = listOf("工资", "薪资", "代发", "到账", "入账", "收入"),
|
||||||
|
direction = Direction.INCOME,
|
||||||
|
defaultCategory = "Income",
|
||||||
|
autoCapture = true,
|
||||||
|
merchantPattern = Regex("""(?:来自|付款方|来源)\s*([\u4e00-\u9fa5A-Za-z0-9\s&]+)"""),
|
||||||
|
),
|
||||||
|
Rule(
|
||||||
|
id = "bank-expense",
|
||||||
|
label = "银行卡支出",
|
||||||
|
channels = listOf(
|
||||||
|
"招商银行",
|
||||||
|
"中国银行",
|
||||||
|
"建设银行",
|
||||||
|
"工商银行",
|
||||||
|
"农业银行",
|
||||||
|
"交通银行",
|
||||||
|
"浦发银行",
|
||||||
|
"兴业银行",
|
||||||
|
"光大银行",
|
||||||
|
"中信银行",
|
||||||
|
"广发银行",
|
||||||
|
"平安银行",
|
||||||
|
"借记卡",
|
||||||
|
"信用卡",
|
||||||
|
),
|
||||||
|
keywords = listOf("支出", "消费", "扣款", "刷卡消费", "银联消费"),
|
||||||
|
direction = Direction.EXPENSE,
|
||||||
|
defaultCategory = "Expense",
|
||||||
|
autoCapture = false,
|
||||||
|
merchantPattern = Regex("""(?:商户|商家|消费商户)\s*([\u4e00-\u9fa5A-Za-z0-9\s&]+)"""),
|
||||||
|
),
|
||||||
|
Rule(
|
||||||
|
id = "transit-card-expense",
|
||||||
|
label = "公交出行",
|
||||||
|
channels = listOf(
|
||||||
|
"杭州通互联互通卡",
|
||||||
|
"杭州通",
|
||||||
|
"北京一卡通",
|
||||||
|
"上海公共交通卡",
|
||||||
|
"深圳通",
|
||||||
|
"苏州公交卡",
|
||||||
|
"交通联合",
|
||||||
|
"乘车码",
|
||||||
|
"地铁",
|
||||||
|
"公交",
|
||||||
|
"Mi Pay",
|
||||||
|
"Huawei Pay",
|
||||||
|
"华为钱包",
|
||||||
|
"小米钱包",
|
||||||
|
"OPPO 钱包",
|
||||||
|
),
|
||||||
|
keywords = emptyList(), // 只看来源,不强制正文关键字
|
||||||
|
direction = Direction.EXPENSE,
|
||||||
|
defaultCategory = "Transport",
|
||||||
|
autoCapture = true,
|
||||||
|
merchantPattern = Regex("""([\u4e00-\u9fa5]{2,}\s*(?:-|—|>|→|至|到)\s*[\u4e00-\u9fa5]{2,})"""),
|
||||||
|
defaultMerchant = "公交/地铁出行",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// 解析后的交易结果,与 JS transformNotificationToTransaction 对齐
|
||||||
|
data class ParsedTransaction(
|
||||||
|
val ruleId: String,
|
||||||
|
val merchant: String,
|
||||||
|
val category: String,
|
||||||
|
val amount: Double,
|
||||||
|
val isExpense: Boolean,
|
||||||
|
val note: String?,
|
||||||
|
val autoCapture: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 尝试从原始通知中解析出一条交易记录。
|
||||||
|
*
|
||||||
|
* 返回 null 表示未命中任何规则或无法解析金额,
|
||||||
|
* 此时应在前端视图中展示为 unmatched,供用户手动处理。
|
||||||
|
*/
|
||||||
|
fun parse(raw: NotificationDb.RawNotification): ParsedTransaction? {
|
||||||
|
val content = buildString {
|
||||||
|
if (!raw.title.isNullOrBlank()) {
|
||||||
|
append(raw.title.trim())
|
||||||
|
append(' ')
|
||||||
|
}
|
||||||
|
append(raw.text.trim())
|
||||||
|
}.trim()
|
||||||
|
|
||||||
|
if (content.isEmpty()) return null
|
||||||
|
|
||||||
|
val rule = fallbackRules.firstOrNull { matchRule(raw, content, it) } ?: return null
|
||||||
|
val amount = extractAmount(content)
|
||||||
|
if (amount <= 0.0) return null
|
||||||
|
|
||||||
|
var merchant = extractMerchant(content, rule)
|
||||||
|
if ((merchant.isBlank() || merchant == "Unknown") && !rule.defaultMerchant.isNullOrBlank()) {
|
||||||
|
merchant = rule.defaultMerchant
|
||||||
|
}
|
||||||
|
|
||||||
|
val isExpense = rule.direction == Direction.EXPENSE
|
||||||
|
val category = rule.defaultCategory.ifBlank { "Uncategorized" }
|
||||||
|
|
||||||
|
return ParsedTransaction(
|
||||||
|
ruleId = rule.id,
|
||||||
|
merchant = merchant.ifBlank { "Unknown" },
|
||||||
|
category = category,
|
||||||
|
amount = amount,
|
||||||
|
isExpense = isExpense,
|
||||||
|
note = content.take(200),
|
||||||
|
autoCapture = rule.autoCapture,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 内部辅助函数:匹配规则 & 提取金额 / 商户 =====
|
||||||
|
|
||||||
|
private fun matchRule(raw: NotificationDb.RawNotification, text: String, rule: Rule): Boolean {
|
||||||
|
if (!rule.enabled) return false
|
||||||
|
val channel = raw.channel ?: ""
|
||||||
|
val channelHit = rule.channels.isEmpty() || rule.channels.any { channel.contains(it) }
|
||||||
|
val keywordHit = rule.keywords.isEmpty() || rule.keywords.any { text.contains(it) }
|
||||||
|
return channelHit && keywordHit
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从通知文本中提取交易金额,兼容「¥」「¥」「元」「人民币」等常见形式
|
||||||
|
private fun extractAmount(text: String): Double {
|
||||||
|
val primaryRegex =
|
||||||
|
Regex("""(?:[¥¥]|人民币)\s*(-?\d+(?:\.\d{1,2})?)|(-?\d+(?:\.\d{1,2})?)\s*元""")
|
||||||
|
val primary = primaryRegex.find(text)
|
||||||
|
val v1 = primary?.groups?.get(1)?.value
|
||||||
|
val v2 = primary?.groups?.get(2)?.value
|
||||||
|
val value = v1 ?: v2
|
||||||
|
if (!value.isNullOrBlank()) {
|
||||||
|
return abs(value.toDoubleOrNull() ?: 0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
val looseRegex = Regex("""(-?\d+(?:\.\d{1,2})?)""")
|
||||||
|
val loose = looseRegex.find(text)
|
||||||
|
val looseVal = loose?.groups?.get(1)?.value
|
||||||
|
return if (!looseVal.isNullOrBlank()) abs(looseVal.toDoubleOrNull() ?: 0.0) else 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判断字符串是否更像是「金额」而不是商户名
|
||||||
|
private fun looksLikeAmount(raw: String?): Boolean {
|
||||||
|
val value = raw?.trim().orEmpty()
|
||||||
|
if (value.isEmpty()) return false
|
||||||
|
val pattern = Regex("""^[-\d.,]+\s*(?:元|块钱|¥|¥|人民币)?$""")
|
||||||
|
return pattern.matches(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractMerchant(text: String, rule: Rule): String {
|
||||||
|
val content = text.ifBlank { "" }
|
||||||
|
|
||||||
|
rule.merchantPattern?.let { regex ->
|
||||||
|
val m = regex.find(content)
|
||||||
|
if (m != null && m.groupValues.size > 1) {
|
||||||
|
val candidate = m.groupValues[1].trim()
|
||||||
|
if (candidate.isNotEmpty()) return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通用的「站点 A -> 站点 B」线路模式(公交/地铁通知)
|
||||||
|
val routeRegex =
|
||||||
|
Regex("""([\u4e00-\u9fa5]{2,}\s*(?:-|—|>|→|至|到|->)\s*[\u4e00-\u9fa5]{2,})""")
|
||||||
|
val routeMatch = routeRegex.find(content)
|
||||||
|
if (routeMatch != null && routeMatch.groupValues.size > 1) {
|
||||||
|
return routeMatch.groupValues[1].trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
val genericPatterns = listOf(
|
||||||
|
Regex("""向\s*([\u4e00-\u9fa5A-Za-z0-9\-\s&]+)"""),
|
||||||
|
Regex("""商户[::]?\s*([\u4e00-\u9fa5A-Za-z0-9\-\s&]+)"""),
|
||||||
|
)
|
||||||
|
|
||||||
|
for (pattern in genericPatterns) {
|
||||||
|
val m = pattern.find(content)
|
||||||
|
if (m != null && m.groupValues.size > 1) {
|
||||||
|
val candidate = m.groupValues[1].trim()
|
||||||
|
if (candidate.isNotEmpty()) return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理「……支出(消费支付宝-上海拉扎斯信息科技有限公司)3.23元」这类银行通知
|
||||||
|
val parenMatch = Regex("""\(([^)]+)\)""").find(content)
|
||||||
|
if (parenMatch != null && parenMatch.groupValues.size > 1) {
|
||||||
|
val inner = parenMatch.groupValues[1]
|
||||||
|
val payMatch = Regex("""(?:支付宝|微信支付|微信)[-—\s]*([^\d元¥¥]+)$""").find(inner)
|
||||||
|
if (payMatch != null && payMatch.groupValues.size > 1) {
|
||||||
|
val candidate = payMatch.groupValues[1].trim()
|
||||||
|
if (candidate.isNotEmpty() && !looksLikeAmount(candidate)) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val parts = content.split(Regex(""":|:"""))
|
||||||
|
if (parts.size > 1) {
|
||||||
|
val candidate =
|
||||||
|
parts[1].split(Regex("""[,,\s]""")).firstOrNull()?.trim().orEmpty()
|
||||||
|
if (candidate.isNotEmpty() && !looksLikeAmount(candidate)) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rule.defaultMerchant ?: "Unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -111,6 +111,20 @@ export const useTransactionEntry = () => {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (nativeBridgeReady) {
|
||||||
|
// 原生环境:规则在 Kotlin 原生层已经执行并写入 SQLite,这里只负责「待确认」列表和状态同步
|
||||||
|
if (!requiresConfirmation) {
|
||||||
|
// 已由原生自动入账,只需从原生通知队列中移除该条
|
||||||
|
await acknowledgeNotification(item.id)
|
||||||
|
} else {
|
||||||
|
manualQueue.push({
|
||||||
|
...item,
|
||||||
|
suggestion: transaction,
|
||||||
|
ruleId: rule?.id || null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Web / 模拟环境:沿用原有逻辑,由 JS 侧写入 SQLite
|
||||||
if (!requiresConfirmation) {
|
if (!requiresConfirmation) {
|
||||||
await transactionStore.addTransaction(transaction)
|
await transactionStore.addTransaction(transaction)
|
||||||
await acknowledgeNotification(item.id)
|
await acknowledgeNotification(item.id)
|
||||||
@@ -122,6 +136,7 @@ export const useTransactionEntry = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
notifications.value = manualQueue
|
notifications.value = manualQueue
|
||||||
} finally {
|
} finally {
|
||||||
syncing.value = false
|
syncing.value = false
|
||||||
|
|||||||
Reference in New Issue
Block a user