feat: 优化统一规则逻辑

This commit is contained in:
2026-03-12 10:04:29 +08:00
parent d0f68e07b7
commit bf15918136
17 changed files with 859 additions and 600 deletions

View File

@@ -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 = "公交/地铁出行",
)
)
}

View File

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