feat: 实现原生通知持久化与解析,支持 SQLite 存储和规则引擎
This commit is contained in:
@@ -4,13 +4,14 @@ import android.app.Notification
|
||||
import android.content.Intent
|
||||
import android.service.notification.NotificationListenerService
|
||||
import android.service.notification.StatusBarNotification
|
||||
import android.util.Log
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
import org.json.JSONObject
|
||||
|
||||
// Android notification listener: persist notifications to local storage and notify app via broadcast
|
||||
// Android notification listener: 在原生层持久化通知到 SQLite,并通过广播通知前端
|
||||
class NotificationBridgeService : NotificationListenerService() {
|
||||
override fun onNotificationPosted(sbn: StatusBarNotification) {
|
||||
val notification = sbn.notification ?: return
|
||||
@@ -21,6 +22,7 @@ class NotificationBridgeService : NotificationListenerService() {
|
||||
val bigText = extras?.getCharSequence(Notification.EXTRA_BIG_TEXT)?.toString()
|
||||
val lines = extras?.getCharSequenceArray(Notification.EXTRA_TEXT_LINES)
|
||||
|
||||
// 汇总通知正文,尽量覆盖 BigText / InboxStyle 等不同样式
|
||||
val builder = StringBuilder()
|
||||
fun appendValue(value: CharSequence?) {
|
||||
val safe = value?.toString()?.takeIf { it.isNotBlank() }
|
||||
@@ -36,14 +38,33 @@ class NotificationBridgeService : NotificationListenerService() {
|
||||
lines?.forEach { appendValue(it) }
|
||||
|
||||
val id = sbn.key ?: sbn.id.toString()
|
||||
val createdAt = isoNow()
|
||||
val combinedText = if (builder.isNotEmpty()) builder.toString() else text.orEmpty()
|
||||
|
||||
val payload = JSONObject()
|
||||
payload.put("id", id)
|
||||
payload.put("channel", title ?: sbn.packageName)
|
||||
payload.put("text", if (builder.isNotEmpty()) builder.toString() else text.orEmpty())
|
||||
payload.put("createdAt", isoNow())
|
||||
payload.put("text", combinedText)
|
||||
payload.put("createdAt", createdAt)
|
||||
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)
|
||||
|
||||
// Broadcast an in-app event so the Capacitor plugin can notify JS listeners
|
||||
@@ -62,4 +83,4 @@ class NotificationBridgeService : NotificationListenerService() {
|
||||
formatter.timeZone = TimeZone.getTimeZone("UTC")
|
||||
return formatter.format(Date())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,15 +111,30 @@ export const useTransactionEntry = () => {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!requiresConfirmation) {
|
||||
await transactionStore.addTransaction(transaction)
|
||||
await acknowledgeNotification(item.id)
|
||||
if (nativeBridgeReady) {
|
||||
// 原生环境:规则在 Kotlin 原生层已经执行并写入 SQLite,这里只负责「待确认」列表和状态同步
|
||||
if (!requiresConfirmation) {
|
||||
// 已由原生自动入账,只需从原生通知队列中移除该条
|
||||
await acknowledgeNotification(item.id)
|
||||
} else {
|
||||
manualQueue.push({
|
||||
...item,
|
||||
suggestion: transaction,
|
||||
ruleId: rule?.id || null,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
manualQueue.push({
|
||||
...item,
|
||||
suggestion: transaction,
|
||||
ruleId: rule?.id || null,
|
||||
})
|
||||
// Web / 模拟环境:沿用原有逻辑,由 JS 侧写入 SQLite
|
||||
if (!requiresConfirmation) {
|
||||
await transactionStore.addTransaction(transaction)
|
||||
await acknowledgeNotification(item.id)
|
||||
} else {
|
||||
manualQueue.push({
|
||||
...item,
|
||||
suggestion: transaction,
|
||||
ruleId: rule?.id || null,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
notifications.value = manualQueue
|
||||
|
||||
Reference in New Issue
Block a user