feat: 优化统一规则逻辑
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
package com.echo.app.notification
|
||||
|
||||
// Generated from src/config/notificationRules.js by scripts/sync-notification-rules.mjs.
|
||||
internal object NotificationRuleData {
|
||||
val fallbackRules: List<NotificationRuleDefinition> = listOf(
|
||||
NotificationRuleDefinition(
|
||||
id = "alipay-expense",
|
||||
label = "支付宝消费",
|
||||
channels = listOf("支付宝", "Alipay"),
|
||||
keywords = listOf("支付", "扣款", "支出", "消费", "成功支付"),
|
||||
requiredTextPatterns = emptyList(),
|
||||
direction = NotificationRuleDirection.EXPENSE,
|
||||
defaultCategory = "Food",
|
||||
autoCapture = false,
|
||||
merchantPattern = Regex("(?:向|收款方|商户)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)", setOf(RegexOption.IGNORE_CASE)),
|
||||
defaultMerchant = null,
|
||||
),
|
||||
NotificationRuleDefinition(
|
||||
id = "alipay-income",
|
||||
label = "支付宝收款",
|
||||
channels = listOf("支付宝", "Alipay"),
|
||||
keywords = listOf("到账", "收入", "收款", "入账"),
|
||||
requiredTextPatterns = emptyList(),
|
||||
direction = NotificationRuleDirection.INCOME,
|
||||
defaultCategory = "Income",
|
||||
autoCapture = true,
|
||||
merchantPattern = Regex("(?:来自|收款方|付款方|来源)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)", setOf(RegexOption.IGNORE_CASE)),
|
||||
defaultMerchant = null,
|
||||
),
|
||||
NotificationRuleDefinition(
|
||||
id = "wechat-expense",
|
||||
label = "微信消费",
|
||||
channels = listOf("微信支付", "微信", "WeChat"),
|
||||
keywords = listOf("支付", "支出", "扣款", "消费成功"),
|
||||
requiredTextPatterns = emptyList(),
|
||||
direction = NotificationRuleDirection.EXPENSE,
|
||||
defaultCategory = "Groceries",
|
||||
autoCapture = false,
|
||||
merchantPattern = Regex("(?:向|收款方|商户)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)"),
|
||||
defaultMerchant = null,
|
||||
),
|
||||
NotificationRuleDefinition(
|
||||
id = "wechat-income",
|
||||
label = "微信收款",
|
||||
channels = listOf("微信支付", "微信", "WeChat"),
|
||||
keywords = listOf("收款", "到账", "入账", "转入"),
|
||||
requiredTextPatterns = emptyList(),
|
||||
direction = NotificationRuleDirection.INCOME,
|
||||
defaultCategory = "Income",
|
||||
autoCapture = true,
|
||||
merchantPattern = Regex("(?:来自|收款方|付款方|来源)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)"),
|
||||
defaultMerchant = null,
|
||||
),
|
||||
NotificationRuleDefinition(
|
||||
id = "bank-income",
|
||||
label = "银行入账",
|
||||
channels = listOf("招商银行", "中国银行", "建设银行", "工商银行", "农业银行", "交通银行", "浦发银行", "兴业银行", "光大银行", "中信银行", "广发银行", "平安银行"),
|
||||
keywords = listOf("工资", "薪资", "代发", "到账", "入账", "收入"),
|
||||
requiredTextPatterns = emptyList(),
|
||||
direction = NotificationRuleDirection.INCOME,
|
||||
defaultCategory = "Income",
|
||||
autoCapture = true,
|
||||
merchantPattern = Regex("(?:来自|付款方|来源)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)"),
|
||||
defaultMerchant = null,
|
||||
),
|
||||
NotificationRuleDefinition(
|
||||
id = "bank-expense",
|
||||
label = "银行卡支出",
|
||||
channels = listOf("招商银行", "中国银行", "建设银行", "工商银行", "农业银行", "交通银行", "浦发银行", "兴业银行", "光大银行", "中信银行", "广发银行", "平安银行", "借记卡", "信用卡"),
|
||||
keywords = listOf("支出", "消费", "扣款", "刷卡消费", "银联消费"),
|
||||
requiredTextPatterns = emptyList(),
|
||||
direction = NotificationRuleDirection.EXPENSE,
|
||||
defaultCategory = "Expense",
|
||||
autoCapture = false,
|
||||
merchantPattern = Regex("(?:商户|商家|消费商户)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)"),
|
||||
defaultMerchant = null,
|
||||
),
|
||||
NotificationRuleDefinition(
|
||||
id = "transit-card-expense",
|
||||
label = "公交出行",
|
||||
channels = listOf("杭州通互联互通卡", "杭州通", "北京一卡通", "上海公共交通卡", "深圳通", "苏州公交卡", "交通联合", "乘车码", "地铁", "公交", "Mi Pay", "Huawei Pay", "华为钱包", "小米钱包", "OPPO 钱包"),
|
||||
keywords = emptyList(),
|
||||
requiredTextPatterns = listOf("扣费", "扣款", "支付", "乘车码", "车费", "票价", "本次乘车", "实付", "优惠后"),
|
||||
direction = NotificationRuleDirection.EXPENSE,
|
||||
defaultCategory = "Transport",
|
||||
autoCapture = true,
|
||||
merchantPattern = Regex("([\\u4e00-\\u9fa5]{2,}\\s*(?:-|->|→|至|到)\\s*[\\u4e00-\\u9fa5]{2,})"),
|
||||
defaultMerchant = "公交/地铁出行",
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -2,149 +2,28 @@ package com.echo.app.notification
|
||||
|
||||
import kotlin.math.abs
|
||||
|
||||
// 原生通知解析引擎:将 JS 侧的 fallbackRules & 提取逻辑完整搬到 Kotlin,
|
||||
// 在后台直接把通知转成交易草稿。
|
||||
internal enum class NotificationRuleDirection {
|
||||
INCOME,
|
||||
EXPENSE,
|
||||
}
|
||||
|
||||
internal data class NotificationRuleDefinition(
|
||||
val id: String,
|
||||
val label: String,
|
||||
val channels: List<String> = emptyList(),
|
||||
val keywords: List<String> = emptyList(),
|
||||
val requiredTextPatterns: List<String> = emptyList(),
|
||||
val direction: NotificationRuleDirection,
|
||||
val defaultCategory: String,
|
||||
val autoCapture: Boolean,
|
||||
val merchantPattern: Regex? = null,
|
||||
val defaultMerchant: String? = null,
|
||||
val enabled: Boolean = true,
|
||||
)
|
||||
|
||||
object NotificationRuleEngine {
|
||||
private val fallbackRules: List<NotificationRuleDefinition> = NotificationRuleData.fallbackRules
|
||||
|
||||
// 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,
|
||||
@@ -155,12 +34,6 @@ object NotificationRuleEngine {
|
||||
val autoCapture: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* 尝试从原始通知中解析出一条交易记录。
|
||||
*
|
||||
* 返回 null 表示未命中任何规则或无法解析金额,
|
||||
* 此时应在前端视图中展示为 unmatched,供用户手动处理。
|
||||
*/
|
||||
fun parse(raw: NotificationDb.RawNotification): ParsedTransaction? {
|
||||
val content = buildString {
|
||||
if (!raw.title.isNullOrBlank()) {
|
||||
@@ -181,72 +54,77 @@ object NotificationRuleEngine {
|
||||
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,
|
||||
category = rule.defaultCategory.ifBlank { "Uncategorized" },
|
||||
amount = amount,
|
||||
isExpense = isExpense,
|
||||
isExpense = rule.direction == NotificationRuleDirection.EXPENSE,
|
||||
note = content.take(200),
|
||||
autoCapture = rule.autoCapture,
|
||||
)
|
||||
}
|
||||
|
||||
// ===== 内部辅助函数:匹配规则 & 提取金额 / 商户 =====
|
||||
|
||||
private fun matchRule(raw: NotificationDb.RawNotification, text: String, rule: Rule): Boolean {
|
||||
private fun matchRule(
|
||||
raw: NotificationDb.RawNotification,
|
||||
text: String,
|
||||
rule: NotificationRuleDefinition
|
||||
): Boolean {
|
||||
if (!rule.enabled) return false
|
||||
|
||||
val channel = raw.channel ?: ""
|
||||
// 频道命中:优先用通知来源/标题匹配,必要时回退到正文匹配(兼容 adb shell / 部分厂商改名)
|
||||
val channelHit =
|
||||
rule.channels.isEmpty() || rule.channels.any { channel.contains(it) || text.contains(it) }
|
||||
val keywordHit = rule.keywords.isEmpty() || rule.keywords.any { text.contains(it) }
|
||||
return channelHit && keywordHit
|
||||
rule.channels.isEmpty() ||
|
||||
rule.channels.any { channel.contains(it) } ||
|
||||
(channel.isBlank() && rule.channels.any { text.contains(it) })
|
||||
val keywordHit =
|
||||
rule.keywords.isEmpty() || rule.keywords.any { text.contains(it) }
|
||||
val requiredTextHit =
|
||||
rule.requiredTextPatterns.isEmpty() || rule.requiredTextPatterns.any { text.contains(it) }
|
||||
|
||||
return channelHit && keywordHit && requiredTextHit
|
||||
}
|
||||
|
||||
// 从通知文本中提取交易金额,兼容「¥」「¥」「元」「人民币」等常见形式
|
||||
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 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+(?:\.\d{1,2})?)\s*(?:元|块|块钱)?\s*(?:已支付|已收款|到账|入账)"""),
|
||||
)
|
||||
|
||||
for (pattern in patterns) {
|
||||
val match = pattern.find(normalized)
|
||||
val value = match?.groups?.get(1)?.value ?: continue
|
||||
val parsed = abs(value.toDoubleOrNull() ?: 0.0)
|
||||
if (parsed > 0.0) {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
return 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)
|
||||
return Regex("""^[-\d.,]+\s*(?:元|块|块钱|¥|¥|人民币)?$""", RegexOption.IGNORE_CASE)
|
||||
.matches(value)
|
||||
}
|
||||
|
||||
private fun extractMerchant(text: String, rule: Rule): String {
|
||||
private fun extractMerchant(text: String, rule: NotificationRuleDefinition): 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()
|
||||
val match = regex.find(content)
|
||||
if (match != null && match.groupValues.size > 1) {
|
||||
val candidate = match.groupValues[1].trim()
|
||||
if (candidate.isNotEmpty()) return candidate
|
||||
}
|
||||
}
|
||||
|
||||
// 通用的「站点 A -> 站点 B」线路模式(公交/地铁通知)
|
||||
val routeRegex =
|
||||
Regex("""([\u4e00-\u9fa5]{2,}\s*(?:-|—|>|→|至|到|->)\s*[\u4e00-\u9fa5]{2,})""")
|
||||
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()
|
||||
@@ -258,18 +136,17 @@ object NotificationRuleEngine {
|
||||
)
|
||||
|
||||
for (pattern in genericPatterns) {
|
||||
val m = pattern.find(content)
|
||||
if (m != null && m.groupValues.size > 1) {
|
||||
val candidate = m.groupValues[1].trim()
|
||||
val match = pattern.find(content)
|
||||
if (match != null && match.groupValues.size > 1) {
|
||||
val candidate = match.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)
|
||||
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)) {
|
||||
|
||||
Reference in New Issue
Block a user