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: /:([\u4e00-\u9fa5A-Za-z0-9\s&]+)/, }, { id: 'bank-income', label: '工资到账', channels: ['招商银行', '中国银行', '建设银行'], keywords: ['工资', '薪资', '到账'], direction: 'income', defaultCategory: 'Income', autoCapture: true, }, ] let cachedRules = [...fallbackRules] let remoteRuleFetcher = async () => [] /** * 允许后续接入服务端规则同步,只需在应用启动时调用并注入实际 fetch 函数 * @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 match = text.match(/(?:¥|¥)\s?(-?\d+(?:\.\d{1,2})?)/) if (match?.[1]) { return Math.abs(parseFloat(match[1])) } const loose = text.match(/(-?\d+(?:\.\d{1,2})?)/) return loose?.[1] ? Math.abs(parseFloat(loose[1])) : 0 } const extractMerchant = (text, rule) => { if (rule?.merchantPattern instanceof RegExp) { const m = text.match(rule.merchantPattern) if (m?.[1]) return m[1].trim() } 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] if (candidate) return candidate.trim() } return 'Unknown' } const matchRule = (notification, rule) => { if (!rule) return false const channel = notification?.channel || '' const text = notification?.text || '' const channelHit = !rule.channels?.length || rule.channels.some((item) => channel.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) const merchant = options.merchant || extractMerchant(notification?.text || '', rule) || 'Unknown' 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 } }