diff --git a/src/services/notificationRuleService.js b/src/services/notificationRuleService.js index 436c2cf..2bece4e 100644 --- a/src/services/notificationRuleService.js +++ b/src/services/notificationRuleService.js @@ -1,42 +1,126 @@ const fallbackRules = [ + // 支付宝消费:日常吃饭、网购等 { id: 'alipay-expense', label: '支付宝消费', - channels: ['支付宝', 'Alipay'], - keywords: ['支付', '扣款', '支出'], + channels: ['支付宝', '支付', 'Alipay'], + keywords: ['支付', '扣款', '支出', '已消费', '成功支付'], direction: 'expense', defaultCategory: 'Food', autoCapture: false, - merchantPattern: /向\s?([\u4e00-\u9fa5A-Za-z0-9\s&]+)/i, + merchantPattern: /向\s*([\u4e00-\u9fa5A-Za-z0-9\s&]+)/i, }, + // 支付宝收款 / 到账 { id: 'alipay-income', - label: '支付宝收入', - channels: ['支付宝', 'Alipay'], - keywords: ['到账', '收入'], + label: '支付宝收款', + channels: ['支付宝', '支付', 'Alipay'], + keywords: ['到账', '收入', '收款', '入账'], direction: 'income', defaultCategory: 'Income', autoCapture: true, - merchantPattern: /来自\s?([\u4e00-\u9fa5A-Za-z0-9\s&]+)/i, + merchantPattern: /(?:来自|收款方)\s*([\u4e00-\u9fa5A-Za-z0-9\s&]+)/i, }, + // 微信消费 { id: 'wechat-expense', label: '微信消费', - channels: ['微信', 'WeChat'], - keywords: ['支付', '支出', '扣款'], + channels: ['微信', '微信支付', 'WeChat'], + keywords: ['支付', '支出', '扣款', '消费成功'], direction: 'expense', defaultCategory: 'Groceries', autoCapture: false, - merchantPattern: /:([\u4e00-\u9fa5A-Za-z0-9\s&]+)/, + merchantPattern: /向\s*([\u4e00-\u9fa5A-Za-z0-9\s&]+)/, }, + // 微信收款 / 转账入账 { - id: 'bank-income', - label: '工资到账', - channels: ['招商银行', '中国银行', '建设银行'], - keywords: ['工资', '薪资', '到账'], + 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, + // 公交卡类通知里商户往往不重要,给一个统一名称 + defaultMerchant: '公交/地铁出行', }, ] @@ -44,7 +128,7 @@ let cachedRules = [...fallbackRules] let remoteRuleFetcher = async () => [] /** - * 允许后续接入服务端规则同步,只需在应用启动时调用并注入实际 fetch 函数 + * 允许后续接入服务端规则同步,只需在应用启动时调用并注入实际的 fetch 函数 * @param {(currentRules: Array) => Promise} fetcher */ export const setRemoteRuleFetcher = (fetcher) => { @@ -76,10 +160,14 @@ export const loadNotificationRules = async () => { export const getCachedNotificationRules = () => cachedRules +// 从通知文本中提取交易金额,兼容「¥」「¥」「元」「人民币」等常见形式 const extractAmount = (text = '') => { - const match = text.match(/(?:¥|¥)\s?(-?\d+(?:\.\d{1,2})?)/) - if (match?.[1]) { - return Math.abs(parseFloat(match[1])) + 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 loose = text.match(/(-?\d+(?:\.\d{1,2})?)/) return loose?.[1] ? Math.abs(parseFloat(loose[1])) : 0 @@ -90,11 +178,11 @@ const extractMerchant = (text, rule) => { const m = text.match(rule.merchantPattern) if (m?.[1]) return m[1].trim() } - const generic = text.match(/向\s?([\u4e00-\u9fa5A-Za-z0-9\-\s&]+)/) + const generic = text.match(/向\s*([\u4e00-\u9fa5A-Za-z0-9\-\s&]+)/) if (generic?.[1]) return generic[1].trim() const parts = text.split(/:|:/) if (parts.length > 1) { - const candidate = parts[1].split(/[,。.\s]/)[0] + const candidate = parts[1].split(/[,,\s]/)[0] if (candidate) return candidate.trim() } return 'Unknown' @@ -120,9 +208,14 @@ export const transformNotificationToTransaction = (notification, options = {}) = const rule = ruleSet.find((item) => matchRule(notification, item)) const amountFromText = extractAmount(notification?.text) const direction = options.direction || rule?.direction || 'expense' - const normalizedAmount = direction === 'income' ? Math.abs(amountFromText) : -Math.abs(amountFromText) - const merchant = - options.merchant || extractMerchant(notification?.text || '', rule) || 'Unknown' + const normalizedAmount = + direction === 'income' ? Math.abs(amountFromText) : -Math.abs(amountFromText) + + let merchant = options.merchant || extractMerchant(notification?.text || '', rule) || 'Unknown' + if (merchant === 'Unknown' && rule?.defaultMerchant) { + merchant = rule.defaultMerchant + } + const date = options.date || notification?.createdAt || new Date().toISOString() const transaction = { @@ -141,3 +234,4 @@ export const transformNotificationToTransaction = (notification, options = {}) = return { transaction, rule, requiresConfirmation } } + diff --git a/src/services/notificationSourceService.js b/src/services/notificationSourceService.js index 9b2e4eb..bb5dd3f 100644 --- a/src/services/notificationSourceService.js +++ b/src/services/notificationSourceService.js @@ -13,7 +13,7 @@ const nativeBridgeReady = isNativeNotificationBridgeAvailable() /** * 本地模拟通知缓存: * - 浏览器环境:用于 Demo & Mock - * - 原生环境:作为 NotificationBridge 数据的补充/兜底 + * - 原生环境:作为 NotificationBridge 数据的补充兜底 */ let localNotificationQueue = [ { @@ -34,6 +34,18 @@ let localNotificationQueue = [ text: '招商银行:工资收入 ¥12500.00 已到账', createdAt: new Date(Date.now() - 1000 * 60 * 32).toISOString(), }, + { + id: uuidv4(), + channel: '北京一卡通', + text: '北京一卡通:乘车扣款 3.00 元,扣款金额 3.00 元', + createdAt: new Date(Date.now() - 1000 * 60 * 45).toISOString(), + }, + { + id: uuidv4(), + channel: '建设银行', + text: '建设银行信用卡:刷卡消费 人民币 86.20 元,消费商户 星巴克咖啡', + createdAt: new Date(Date.now() - 1000 * 60 * 60).toISOString(), + }, ] export const setRemoteNotificationFetcher = (fetcher) => { @@ -81,7 +93,7 @@ export const acknowledgeNotification = async (id) => { try { await remoteNotificationAcknowledger(id) } catch (error) { - console.warn('[notifications] 远程确认失败,将在下次同步重试', error) + console.warn('[notifications] 远程通知确认失败,将在下次同步重试', error) } } @@ -105,7 +117,7 @@ if (nativeBridgeReady) { try { await NotificationBridge.acknowledgeNotification({ id }) } catch (error) { - console.warn('[notifications] 通知确认同步失败', error) + console.warn('[notifications] 通知确认同步至原生失败', error) } }) }