feat: 优化统一规则逻辑
This commit is contained in:
@@ -1,150 +1,32 @@
|
||||
const fallbackRules = [
|
||||
// 支付宝消费:日常吃饭、网购等
|
||||
{
|
||||
id: 'alipay-expense',
|
||||
label: '支付宝消费',
|
||||
channels: ['支付宝', 'Alipay'],
|
||||
keywords: ['支付', '扣款', '支出', '消费', '成功支付'],
|
||||
direction: 'expense',
|
||||
defaultCategory: 'Food',
|
||||
autoCapture: false,
|
||||
merchantPattern: /向\s*([\u4e00-\u9fa5A-Za-z0-9\s&]+)/i,
|
||||
},
|
||||
// 支付宝收款 / 到账
|
||||
{
|
||||
id: 'alipay-income',
|
||||
label: '支付宝收款',
|
||||
channels: ['支付宝', 'Alipay'],
|
||||
keywords: ['到账', '收入', '收款', '入账'],
|
||||
direction: 'income',
|
||||
defaultCategory: 'Income',
|
||||
autoCapture: true,
|
||||
merchantPattern: /(?:来自|收款方)\s*([\u4e00-\u9fa5A-Za-z0-9\s&]+)/i,
|
||||
},
|
||||
// 微信消费
|
||||
{
|
||||
id: 'wechat-expense',
|
||||
label: '微信消费',
|
||||
channels: ['微信支付', '微信', 'WeChat'],
|
||||
keywords: ['支付', '支出', '扣款', '消费成功'],
|
||||
direction: 'expense',
|
||||
defaultCategory: 'Groceries',
|
||||
autoCapture: false,
|
||||
merchantPattern: /向\s*([\u4e00-\u9fa5A-Za-z0-9\s&]+)/,
|
||||
},
|
||||
// 微信收款 / 转账入账
|
||||
{
|
||||
id: 'wechat-income',
|
||||
label: '微信收款',
|
||||
channels: ['微信支付', '微信', 'WeChat'],
|
||||
keywords: ['收款', '到账', '入账', '转入'],
|
||||
direction: 'income',
|
||||
defaultCategory: 'Income',
|
||||
autoCapture: true,
|
||||
merchantPattern: /(?:来自|收款方)\s*([\u4e00-\u9fa5A-Za-z0-9\s&]+)/,
|
||||
},
|
||||
// 银行工资 / 常规入账
|
||||
{
|
||||
id: 'bank-income',
|
||||
label: '工资到账',
|
||||
channels: [
|
||||
'招商银行',
|
||||
'中国银行',
|
||||
'建设银行',
|
||||
'工商银行',
|
||||
'农业银行',
|
||||
'交通银行',
|
||||
'浦发银行',
|
||||
'兴业银行',
|
||||
'光大银行',
|
||||
'中信银行',
|
||||
'广发银行',
|
||||
'平安银行',
|
||||
],
|
||||
keywords: ['工资', '薪资', '代发', '到账', '入账', '收入'],
|
||||
direction: 'income',
|
||||
defaultCategory: 'Income',
|
||||
autoCapture: true,
|
||||
merchantPattern: /(?:来自|付款方|来源)\s*([\u4e00-\u9fa5A-Za-z0-9\s&]+)/,
|
||||
},
|
||||
// 银行消费 / 借记卡扣款
|
||||
{
|
||||
id: 'bank-expense',
|
||||
label: '银行卡支出',
|
||||
channels: [
|
||||
'招商银行',
|
||||
'中国银行',
|
||||
'建设银行',
|
||||
'工商银行',
|
||||
'农业银行',
|
||||
'交通银行',
|
||||
'浦发银行',
|
||||
'兴业银行',
|
||||
'光大银行',
|
||||
'中信银行',
|
||||
'广发银行',
|
||||
'平安银行',
|
||||
'借记卡',
|
||||
'信用卡',
|
||||
],
|
||||
keywords: ['支出', '消费', '扣款', '刷卡消费', '银联消费'],
|
||||
direction: 'expense',
|
||||
defaultCategory: 'Expense',
|
||||
autoCapture: false,
|
||||
merchantPattern: /(?:商户|商家|消费商户)\s*([\u4e00-\u9fa5A-Za-z0-9\s&]+)/,
|
||||
},
|
||||
// 手机公交卡 / 乘车码扣款(地铁、公交等),按交通分类
|
||||
{
|
||||
id: 'transit-card-expense',
|
||||
label: '公交出行',
|
||||
channels: [
|
||||
'杭州通互联互通卡',
|
||||
'杭州通',
|
||||
'北京一卡通',
|
||||
'上海公共交通卡',
|
||||
'深圳通',
|
||||
'苏州公交卡',
|
||||
'交通联合',
|
||||
'乘车码',
|
||||
'地铁',
|
||||
'公交',
|
||||
'Mi Pay',
|
||||
'Huawei Pay',
|
||||
'华为钱包',
|
||||
'小米钱包',
|
||||
'OPPO 钱包',
|
||||
],
|
||||
// 对公交卡类通知,只要来源匹配即可视为出行支出,不强制正文关键字
|
||||
keywords: [],
|
||||
direction: 'expense',
|
||||
defaultCategory: 'Transport',
|
||||
autoCapture: true,
|
||||
// 优先从正文中提取「文三路->祥园路」之类的起点-终点信息作为“商户”
|
||||
merchantPattern: /([\u4e00-\u9fa5]{2,}\s*(?:-|—|>|→|至|到)\s*[\u4e00-\u9fa5]{2,})/,
|
||||
// 若无法提取线路,则使用统一名称
|
||||
defaultMerchant: '公交/地铁出行',
|
||||
},
|
||||
import notificationRulesSource from '../config/notificationRules.js'
|
||||
|
||||
const fallbackRules = notificationRulesSource.map((rule) => ({
|
||||
...rule,
|
||||
merchantPattern: rule.merchantPattern?.pattern
|
||||
? new RegExp(rule.merchantPattern.pattern, rule.merchantPattern.flags || '')
|
||||
: null,
|
||||
}))
|
||||
|
||||
const amountPatterns = [
|
||||
/(?:¥|¥|RMB|CNY|人民币)\s*(-?\d+(?:\.\d{1,2})?)/i,
|
||||
/(-?\d+(?:\.\d{1,2})?)\s*(?:元|块|块钱)/,
|
||||
/(?:金额|实付|扣费|扣款|消费|支出|收入|收款|到账|入账|转入|转出|支付|票价|车费|本次乘车)\D{0,8}?(-?\d+(?:\.\d{1,2})?)/,
|
||||
/(-?\d+(?:\.\d{1,2})?)\s*(?:元|块|块钱)?\s*(?:已支付|已收款|到账|入账)/,
|
||||
]
|
||||
|
||||
let cachedRules = [...fallbackRules]
|
||||
let remoteRuleFetcher = async () => []
|
||||
|
||||
/**
|
||||
* 允许后续接入服务端规则同步,只需在应用启动时调用并注入实际的 fetch 函数。
|
||||
* 服务端可以返回一组规则对象,形如:
|
||||
* {
|
||||
* id: string;
|
||||
* label?: string;
|
||||
* enabled?: boolean; // false 时在本地完全忽略此规则
|
||||
* priority?: number; // 预留优先级字段(当前仅在服务端使用)
|
||||
* channels?: string[]; // 匹配通知来源(标题 / 包名)
|
||||
* keywords?: string[]; // 匹配通知正文的关键字
|
||||
* direction?: 'income' | 'expense';
|
||||
* defaultCategory?: string;
|
||||
* autoCapture?: boolean;
|
||||
* }
|
||||
* @param {(currentRules: Array) => Promise<Array>} fetcher
|
||||
*/
|
||||
const buildEmptyTransaction = (notification, options = {}) => ({
|
||||
id: options.id,
|
||||
merchant: options.merchant || 'Unknown',
|
||||
category: options.category || 'Uncategorized',
|
||||
amount: 0,
|
||||
date: new Date(options.date || notification?.createdAt || new Date().toISOString()).toISOString(),
|
||||
note: notification?.text || '',
|
||||
syncStatus: 'pending',
|
||||
})
|
||||
|
||||
export const setRemoteRuleFetcher = (fetcher) => {
|
||||
remoteRuleFetcher = typeof fetcher === 'function' ? fetcher : remoteRuleFetcher
|
||||
}
|
||||
@@ -167,47 +49,44 @@ export const loadNotificationRules = async () => {
|
||||
}
|
||||
} catch (err) {
|
||||
cachedRules = [...cachedRules]
|
||||
console.warn('[rules] 使用本地规则,远程规则获取失败:', err)
|
||||
console.warn('[rules] using cached fallback rules because remote sync failed:', err)
|
||||
}
|
||||
return cachedRules
|
||||
}
|
||||
|
||||
export const getCachedNotificationRules = () => cachedRules
|
||||
|
||||
// 从通知文本中提取交易金额,兼容「¥」「¥」「元」「人民币」等常见形式
|
||||
const extractAmount = (text = '') => {
|
||||
const primary = text.match(
|
||||
/(?:[¥¥]|人民币)\s*(-?\d+(?:\.\d{1,2})?)|(-?\d+(?:\.\d{1,2})?)\s*元/,
|
||||
)
|
||||
const value = primary?.[1] || primary?.[2]
|
||||
if (value) {
|
||||
return Math.abs(parseFloat(value))
|
||||
const normalized = String(text).replace(/,/g, '').trim()
|
||||
|
||||
for (const pattern of amountPatterns) {
|
||||
const match = normalized.match(pattern)
|
||||
const value = match?.[1]
|
||||
if (!value) continue
|
||||
const parsed = Math.abs(Number.parseFloat(value))
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
const loose = text.match(/(-?\d+(?:\.\d{1,2})?)/)
|
||||
return loose?.[1] ? Math.abs(parseFloat(loose[1])) : 0
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// 判断字符串是否更像是「金额」而不是商户名,用于避免把「1.00元」当成商户
|
||||
const looksLikeAmount = (raw = '') => {
|
||||
const value = String(raw).trim()
|
||||
if (!value) return false
|
||||
const amountPattern = /^[-\d.,]+\s*(?:元|块钱|¥|¥|人民币)?$/
|
||||
return amountPattern.test(value)
|
||||
return /^[-\d.,]+\s*(?:元|块|块钱|¥|¥|人民币)?$/i.test(value)
|
||||
}
|
||||
|
||||
const extractMerchant = (text, rule) => {
|
||||
const content = text || ''
|
||||
|
||||
if (rule?.merchantPattern instanceof RegExp) {
|
||||
const m = content.match(rule.merchantPattern)
|
||||
if (m?.[1]) return m[1].trim()
|
||||
const matched = content.match(rule.merchantPattern)
|
||||
if (matched?.[1]) return matched[1].trim()
|
||||
}
|
||||
|
||||
// 通用的「站点 A -> 站点 B」线路模式(公交/地铁通知),
|
||||
// 即使未命中专用规则也尝试提取,避免退化为金额或「Unknown」
|
||||
const routeMatch = content.match(
|
||||
/([\u4e00-\u9fa5]{2,}\s*(?:-|—|>|→|至|到|->)\s*[\u4e00-\u9fa5]{2,})/,
|
||||
)
|
||||
const routeMatch = content.match(/([\u4e00-\u9fa5]{2,}\s*(?:-|->|→|至|到)\s*[\u4e00-\u9fa5]{2,})/)
|
||||
if (routeMatch?.[1]) {
|
||||
return routeMatch[1].trim()
|
||||
}
|
||||
@@ -218,16 +97,14 @@ const extractMerchant = (text, rule) => {
|
||||
]
|
||||
|
||||
for (const pattern of genericPatterns) {
|
||||
const m = content.match(pattern)
|
||||
if (m?.[1]) return m[1].trim()
|
||||
const matched = content.match(pattern)
|
||||
if (matched?.[1]) return matched[1].trim()
|
||||
}
|
||||
|
||||
// 针对「……支出(消费支付宝-上海拉扎斯信息科技有限公司)3.23元」这类银行通知,
|
||||
// 优先尝试从括号中的「支付宝-商户名」结构中提取真正的商户名
|
||||
const parenMatch = content.match(/\(([^)]+)\)/)
|
||||
if (parenMatch?.[1]) {
|
||||
const inner = parenMatch[1]
|
||||
const payMatch = inner.match(/(?:支付宝|微信支付|微信)[-—\s]*([^\d元¥¥]+)$/)
|
||||
const payMatch = inner.match(/(?:支付宝|微信支付|微信)[-\s]*([^\d元¥¥]+)$/)
|
||||
if (payMatch?.[1]) {
|
||||
const candidate = payMatch[1].trim()
|
||||
if (candidate && !looksLikeAmount(candidate)) {
|
||||
@@ -247,32 +124,48 @@ const extractMerchant = (text, rule) => {
|
||||
|
||||
const matchRule = (notification, rule) => {
|
||||
if (!rule || rule.enabled === false) return false
|
||||
|
||||
const channel = notification?.channel || ''
|
||||
const text = notification?.text || ''
|
||||
// 频道命中:优先用通知标题/来源匹配,退而求其次用正文包含匹配(兼容 adb shell / 部分 ROM)
|
||||
const channelHit =
|
||||
!rule.channels?.length ||
|
||||
rule.channels.some((item) => channel.includes(item) || text.includes(item))
|
||||
rule.channels.some((item) => channel.includes(item)) ||
|
||||
(!channel && rule.channels.some((item) => text.includes(item)))
|
||||
const keywordHit = !rule.keywords?.length || rule.keywords.some((word) => text.includes(word))
|
||||
return channelHit && keywordHit
|
||||
const requiredTextHit =
|
||||
!rule.requiredTextPatterns?.length ||
|
||||
rule.requiredTextPatterns.some((word) => text.includes(word))
|
||||
|
||||
return channelHit && keywordHit && requiredTextHit
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据当前规则集生成交易草稿,并返回命中的规则信息
|
||||
* @param {{ id: string, channel: string, text: string, createdAt: string }} notification
|
||||
* @param {{ ruleSet?: Array, overrides?: object }} options
|
||||
* @returns {{ transaction: import('../types/transaction').Transaction, rule?: object, requiresConfirmation: boolean }}
|
||||
*/
|
||||
export const transformNotificationToTransaction = (notification, options = {}) => {
|
||||
const ruleSet = options.ruleSet?.length ? options.ruleSet : cachedRules
|
||||
const rule = ruleSet.find((item) => matchRule(notification, item))
|
||||
const amountFromText = extractAmount(notification?.text)
|
||||
const direction = options.direction || rule?.direction || 'expense'
|
||||
|
||||
if (!rule) {
|
||||
return {
|
||||
transaction: buildEmptyTransaction(notification, options),
|
||||
rule: undefined,
|
||||
requiresConfirmation: false,
|
||||
}
|
||||
}
|
||||
|
||||
const amountFromText = extractAmount(notification?.text || '')
|
||||
if (amountFromText <= 0) {
|
||||
return {
|
||||
transaction: buildEmptyTransaction(notification, options),
|
||||
rule: undefined,
|
||||
requiresConfirmation: false,
|
||||
}
|
||||
}
|
||||
|
||||
const direction = options.direction || rule.direction || 'expense'
|
||||
const normalizedAmount =
|
||||
direction === 'income' ? Math.abs(amountFromText) : -Math.abs(amountFromText)
|
||||
|
||||
let merchant = options.merchant || extractMerchant(notification?.text || '', rule) || 'Unknown'
|
||||
if (merchant === 'Unknown' && rule?.defaultMerchant) {
|
||||
if (merchant === 'Unknown' && rule.defaultMerchant) {
|
||||
merchant = rule.defaultMerchant
|
||||
}
|
||||
|
||||
@@ -281,8 +174,8 @@ export const transformNotificationToTransaction = (notification, options = {}) =
|
||||
const transaction = {
|
||||
id: options.id,
|
||||
merchant,
|
||||
category: options.category || rule?.defaultCategory || 'Uncategorized',
|
||||
amount: Number.isFinite(normalizedAmount) ? normalizedAmount : 0,
|
||||
category: options.category || rule.defaultCategory || 'Uncategorized',
|
||||
amount: normalizedAmount,
|
||||
date: new Date(date).toISOString(),
|
||||
note: notification?.text || '',
|
||||
syncStatus: 'pending',
|
||||
@@ -290,7 +183,7 @@ export const transformNotificationToTransaction = (notification, options = {}) =
|
||||
|
||||
const requiresConfirmation =
|
||||
options.requiresConfirmation ??
|
||||
!(rule?.autoCapture && transaction.amount !== 0 && merchant !== 'Unknown')
|
||||
!(rule.autoCapture && transaction.amount !== 0 && merchant !== 'Unknown')
|
||||
|
||||
return { transaction, rule, requiresConfirmation }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user