Compare commits

...

2 Commits

3 changed files with 132 additions and 47 deletions

View File

@@ -1,6 +1,16 @@
package com.echo.app; package com.echo.app;
import android.os.Bundle;
import com.getcapacitor.BridgeActivity; import com.getcapacitor.BridgeActivity;
import com.echo.app.notification.NotificationBridgePlugin;
// 主 Activity这里显式注册自定义的 NotificationBridge 插件,打通原生通知监听到前端的桥接
public class MainActivity extends BridgeActivity { public class MainActivity extends BridgeActivity {
@Override
public void onCreate(Bundle savedInstanceState) {
// 注意:必须在 super.onCreate 之前向 bridgeBuilder 注册插件,
// 否则 Bridge 已经创建完成JS 侧会报 "plugin is not implemented on android"
registerPlugin(NotificationBridgePlugin.class);
super.onCreate(savedInstanceState);
}
} }

View File

@@ -1,42 +1,126 @@
const fallbackRules = [ const fallbackRules = [
// 支付宝消费:日常吃饭、网购等
{ {
id: 'alipay-expense', id: 'alipay-expense',
label: '支付宝消费', label: '支付宝消费',
channels: ['支付宝', 'Alipay'], channels: ['支付宝', '支付', 'Alipay'],
keywords: ['支付', '扣款', '支出'], keywords: ['支付', '扣款', '支出', '已消费', '成功支付'],
direction: 'expense', direction: 'expense',
defaultCategory: 'Food', defaultCategory: 'Food',
autoCapture: false, autoCapture: false,
merchantPattern: /向\s?([\u4e00-\u9fa5A-Za-z0-9\s&]+)/i, merchantPattern: /向\s*([\u4e00-\u9fa5A-Za-z0-9\s&]+)/i,
}, },
// 支付宝收款 / 到账
{ {
id: 'alipay-income', id: 'alipay-income',
label: '支付宝收', label: '支付宝收',
channels: ['支付宝', 'Alipay'], channels: ['支付宝', '支付', 'Alipay'],
keywords: ['到账', '收入'], keywords: ['到账', '收入', '收款', '入账'],
direction: 'income', direction: 'income',
defaultCategory: 'Income', defaultCategory: 'Income',
autoCapture: true, autoCapture: true,
merchantPattern: /来自\s?([\u4e00-\u9fa5A-Za-z0-9\s&]+)/i, merchantPattern: /(?:来自|收款方)\s*([\u4e00-\u9fa5A-Za-z0-9\s&]+)/i,
}, },
// 微信消费
{ {
id: 'wechat-expense', id: 'wechat-expense',
label: '微信消费', label: '微信消费',
channels: ['微信', 'WeChat'], channels: ['微信', '微信支付', 'WeChat'],
keywords: ['支付', '支出', '扣款'], keywords: ['支付', '支出', '扣款', '消费成功'],
direction: 'expense', direction: 'expense',
defaultCategory: 'Groceries', defaultCategory: 'Groceries',
autoCapture: false, autoCapture: false,
merchantPattern: /([\u4e00-\u9fa5A-Za-z0-9\s&]+)/, merchantPattern: /向\s*([\u4e00-\u9fa5A-Za-z0-9\s&]+)/,
}, },
// 微信收款 / 转账入账
{ {
id: 'bank-income', id: 'wechat-income',
label: '工资到账', label: '微信收款',
channels: ['招商银行', '中国银行', '建设银行'], channels: ['微信', '微信支付', 'WeChat'],
keywords: ['工资', '薪资', '到账'], keywords: ['收款', '到账', '入账', '转入'],
direction: 'income', direction: 'income',
defaultCategory: 'Income', defaultCategory: 'Income',
autoCapture: true, 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 () => [] let remoteRuleFetcher = async () => []
/** /**
* 允许后续接入服务端规则同步,只需在应用启动时调用并注入实际 fetch 函数 * 允许后续接入服务端规则同步,只需在应用启动时调用并注入实际 fetch 函数
* @param {(currentRules: Array) => Promise<Array>} fetcher * @param {(currentRules: Array) => Promise<Array>} fetcher
*/ */
export const setRemoteRuleFetcher = (fetcher) => { export const setRemoteRuleFetcher = (fetcher) => {
@@ -76,10 +160,14 @@ export const loadNotificationRules = async () => {
export const getCachedNotificationRules = () => cachedRules export const getCachedNotificationRules = () => cachedRules
// 从通知文本中提取交易金额,兼容「¥」「¥」「元」「人民币」等常见形式
const extractAmount = (text = '') => { const extractAmount = (text = '') => {
const match = text.match(/(?:¥|¥)\s?(-?\d+(?:\.\d{1,2})?)/) const primary = text.match(
if (match?.[1]) { /(?:[¥¥]|人民币)\s*(-?\d+(?:\.\d{1,2})?)|(-?\d+(?:\.\d{1,2})?)\s*元/,
return Math.abs(parseFloat(match[1])) )
const value = primary?.[1] || primary?.[2]
if (value) {
return Math.abs(parseFloat(value))
} }
const loose = text.match(/(-?\d+(?:\.\d{1,2})?)/) const loose = text.match(/(-?\d+(?:\.\d{1,2})?)/)
return loose?.[1] ? Math.abs(parseFloat(loose[1])) : 0 return loose?.[1] ? Math.abs(parseFloat(loose[1])) : 0
@@ -90,11 +178,11 @@ const extractMerchant = (text, rule) => {
const m = text.match(rule.merchantPattern) const m = text.match(rule.merchantPattern)
if (m?.[1]) return m[1].trim() 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() if (generic?.[1]) return generic[1].trim()
const parts = text.split(/|:/) const parts = text.split(/|:/)
if (parts.length > 1) { if (parts.length > 1) {
const candidate = parts[1].split(/[。.\s]/)[0] const candidate = parts[1].split(/[,\s]/)[0]
if (candidate) return candidate.trim() if (candidate) return candidate.trim()
} }
return 'Unknown' return 'Unknown'
@@ -120,9 +208,14 @@ export const transformNotificationToTransaction = (notification, options = {}) =
const rule = ruleSet.find((item) => matchRule(notification, item)) const rule = ruleSet.find((item) => matchRule(notification, item))
const amountFromText = extractAmount(notification?.text) const amountFromText = extractAmount(notification?.text)
const direction = options.direction || rule?.direction || 'expense' const direction = options.direction || rule?.direction || 'expense'
const normalizedAmount = direction === 'income' ? Math.abs(amountFromText) : -Math.abs(amountFromText) const normalizedAmount =
const merchant = direction === 'income' ? Math.abs(amountFromText) : -Math.abs(amountFromText)
options.merchant || extractMerchant(notification?.text || '', rule) || 'Unknown'
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 date = options.date || notification?.createdAt || new Date().toISOString()
const transaction = { const transaction = {
@@ -141,3 +234,4 @@ export const transformNotificationToTransaction = (notification, options = {}) =
return { transaction, rule, requiresConfirmation } return { transaction, rule, requiresConfirmation }
} }

View File

@@ -12,29 +12,10 @@ const nativeBridgeReady = isNativeNotificationBridgeAvailable()
/** /**
* 本地模拟通知缓存: * 本地模拟通知缓存:
* - 浏览器环境:用于 Demo & Mock * - 浏览器环境:用于 Demo & Mock,可通过 pushLocalNotification 主动注入
* - 原生环境:作为 NotificationBridge 数据的补充/兜底 * - 原生环境:一般保持为空,仅在必要时作为 NotificationBridge 数据的补充兜底
*/ */
let localNotificationQueue = [ let localNotificationQueue = []
{
id: uuidv4(),
channel: '支付宝',
text: '支付宝提醒:你向 瑞幸咖啡 支付 ¥18.00',
createdAt: new Date().toISOString(),
},
{
id: uuidv4(),
channel: '微信支付',
text: '微信支付:美团外卖 实付 ¥46.50 · 订单已送达',
createdAt: new Date(Date.now() - 1000 * 60 * 8).toISOString(),
},
{
id: uuidv4(),
channel: '招商银行',
text: '招商银行:工资收入 ¥12500.00 已到账',
createdAt: new Date(Date.now() - 1000 * 60 * 32).toISOString(),
},
]
export const setRemoteNotificationFetcher = (fetcher) => { export const setRemoteNotificationFetcher = (fetcher) => {
remoteNotificationFetcher = typeof fetcher === 'function' ? fetcher : remoteNotificationFetcher remoteNotificationFetcher = typeof fetcher === 'function' ? fetcher : remoteNotificationFetcher
@@ -81,7 +62,7 @@ export const acknowledgeNotification = async (id) => {
try { try {
await remoteNotificationAcknowledger(id) await remoteNotificationAcknowledger(id)
} catch (error) { } catch (error) {
console.warn('[notifications] 远程确认失败,将在下次同步重试', error) console.warn('[notifications] 远程通知确认失败,将在下次同步重试', error)
} }
} }
@@ -105,7 +86,7 @@ if (nativeBridgeReady) {
try { try {
await NotificationBridge.acknowledgeNotification({ id }) await NotificationBridge.acknowledgeNotification({ id })
} catch (error) { } catch (error) {
console.warn('[notifications] 通知确认同步失败', error) console.warn('[notifications] 通知确认同步至原生失败', error)
} }
}) })
} }