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: '公交/地铁出行', }, ] 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} fetcher */ export const setRemoteRuleFetcher = (fetcher) => { remoteRuleFetcher = typeof fetcher === 'function' ? fetcher : remoteRuleFetcher } const dedupeRules = (rules) => { const bucket = new Map() fallbackRules.forEach((rule) => bucket.set(rule.id, { ...rule })) rules.forEach((rule) => { const prev = bucket.get(rule.id) || {} bucket.set(rule.id, { ...prev, ...rule }) }) return Array.from(bucket.values()) } export const loadNotificationRules = async () => { try { const remote = await remoteRuleFetcher(cachedRules) if (Array.isArray(remote) && remote.length > 0) { cachedRules = dedupeRules(remote) } } catch (err) { cachedRules = [...cachedRules] console.warn('[rules] 使用本地规则,远程规则获取失败:', 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 loose = text.match(/(-?\d+(?:\.\d{1,2})?)/) return loose?.[1] ? Math.abs(parseFloat(loose[1])) : 0 } // 判断字符串是否更像是「金额」而不是商户名,用于避免把「1.00元」当成商户 const looksLikeAmount = (raw = '') => { const value = String(raw).trim() if (!value) return false const amountPattern = /^[-\d.,]+\s*(?:元|块钱|¥|¥|人民币)?$/ return amountPattern.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() } // 通用的「站点 A -> 站点 B」线路模式(公交/地铁通知), // 即使未命中专用规则也尝试提取,避免退化为金额或「Unknown」 const routeMatch = content.match( /([\u4e00-\u9fa5]{2,}\s*(?:-|—|>|→|至|到|->)\s*[\u4e00-\u9fa5]{2,})/, ) if (routeMatch?.[1]) { return routeMatch[1].trim() } const genericPatterns = [ /向\s*([\u4e00-\u9fa5A-Za-z0-9\-\s&]+)/, /商户[::]?\s*([\u4e00-\u9fa5A-Za-z0-9\-\s&]+)/, ] for (const pattern of genericPatterns) { const m = content.match(pattern) if (m?.[1]) return m[1].trim() } // 针对「……支出(消费支付宝-上海拉扎斯信息科技有限公司)3.23元」这类银行通知, // 优先尝试从括号中的「支付宝-商户名」结构中提取真正的商户名 const parenMatch = content.match(/\(([^)]+)\)/) if (parenMatch?.[1]) { const inner = parenMatch[1] const payMatch = inner.match(/(?:支付宝|微信支付|微信)[-—\s]*([^\d元¥¥]+)$/) if (payMatch?.[1]) { const candidate = payMatch[1].trim() if (candidate && !looksLikeAmount(candidate)) { return candidate } } } const parts = content.split(/:|:/) if (parts.length > 1) { const candidate = parts[1].split(/[,,\s]/)[0] if (candidate && !looksLikeAmount(candidate)) return candidate.trim() } return 'Unknown' } 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)) const keywordHit = !rule.keywords?.length || rule.keywords.some((word) => text.includes(word)) return channelHit && keywordHit } /** * 根据当前规则集生成交易草稿,并返回命中的规则信息 * @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' 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 = { id: options.id, merchant, category: options.category || rule?.defaultCategory || 'Uncategorized', amount: Number.isFinite(normalizedAmount) ? normalizedAmount : 0, date: new Date(date).toISOString(), note: notification?.text || '', syncStatus: 'pending', } const requiresConfirmation = options.requiresConfirmation ?? !(rule?.autoCapture && transaction.amount !== 0 && merchant !== 'Unknown') return { transaction, rule, requiresConfirmation } }