feat:新增通知调试台和导入历史页
This commit is contained in:
@@ -493,3 +493,96 @@ export const exportTransactionsAsJson = async () => {
|
||||
)
|
||||
return transactions.length
|
||||
}
|
||||
|
||||
const parseSummaryJson = (value) => {
|
||||
if (!value) return null
|
||||
try {
|
||||
return JSON.parse(value)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const fetchImportBatches = async (limit = 20) => {
|
||||
const db = await getDb()
|
||||
const safeLimit = Math.max(1, Math.min(Number(limit) || 20, 100))
|
||||
const result = await db.query(
|
||||
`
|
||||
SELECT
|
||||
id,
|
||||
source,
|
||||
file_name,
|
||||
imported_at,
|
||||
total_count,
|
||||
inserted_count,
|
||||
merged_count,
|
||||
skipped_count,
|
||||
status,
|
||||
summary_json
|
||||
FROM import_batches
|
||||
ORDER BY imported_at DESC
|
||||
LIMIT ?
|
||||
`,
|
||||
[safeLimit],
|
||||
)
|
||||
|
||||
return (result?.values || []).map((row) => ({
|
||||
id: row.id,
|
||||
source: row.source || '',
|
||||
fileName: row.file_name || '',
|
||||
importedAt: row.imported_at || '',
|
||||
totalCount: Number(row.total_count || 0),
|
||||
insertedCount: Number(row.inserted_count || 0),
|
||||
mergedCount: Number(row.merged_count || 0),
|
||||
skippedCount: Number(row.skipped_count || 0),
|
||||
status: row.status || 'completed',
|
||||
summary: parseSummaryJson(row.summary_json),
|
||||
}))
|
||||
}
|
||||
|
||||
export const fetchImportBatchLines = async (batchId, status = 'all') => {
|
||||
if (!batchId) return []
|
||||
|
||||
const db = await getDb()
|
||||
const params = [batchId]
|
||||
const statusClause = status !== 'all' ? 'AND status = ?' : ''
|
||||
if (status !== 'all') {
|
||||
params.push(status)
|
||||
}
|
||||
|
||||
const result = await db.query(
|
||||
`
|
||||
SELECT
|
||||
id,
|
||||
batch_id,
|
||||
source_order_id,
|
||||
merchant,
|
||||
amount,
|
||||
occurred_at,
|
||||
status,
|
||||
transaction_id,
|
||||
reason,
|
||||
raw_data,
|
||||
created_at
|
||||
FROM import_lines
|
||||
WHERE batch_id = ?
|
||||
${statusClause}
|
||||
ORDER BY occurred_at DESC, created_at DESC
|
||||
`,
|
||||
params,
|
||||
)
|
||||
|
||||
return (result?.values || []).map((row) => ({
|
||||
id: row.id,
|
||||
batchId: row.batch_id,
|
||||
sourceOrderId: row.source_order_id || '',
|
||||
merchant: row.merchant || '',
|
||||
amount: Number(row.amount || 0),
|
||||
occurredAt: row.occurred_at || '',
|
||||
status: row.status || 'skipped',
|
||||
transactionId: row.transaction_id || '',
|
||||
reason: row.reason || '',
|
||||
rawData: parseSummaryJson(row.raw_data) || {},
|
||||
createdAt: row.created_at || '',
|
||||
}))
|
||||
}
|
||||
|
||||
154
src/services/notificationDebugService.js
Normal file
154
src/services/notificationDebugService.js
Normal file
@@ -0,0 +1,154 @@
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { getDb, saveDbToStore } from '../lib/sqlite.js'
|
||||
|
||||
export const NOTIFICATION_DEBUG_STATUS = {
|
||||
review: 'review',
|
||||
unmatched: 'unmatched',
|
||||
matched: 'matched',
|
||||
ignored: 'ignored',
|
||||
error: 'error',
|
||||
}
|
||||
|
||||
const mapRow = (row) => ({
|
||||
id: row.id,
|
||||
sourceId: row.source_id || '',
|
||||
channel: row.channel || '',
|
||||
title: row.title || '',
|
||||
text: row.text || '',
|
||||
postedAt: row.posted_at || '',
|
||||
status: row.status || NOTIFICATION_DEBUG_STATUS.unmatched,
|
||||
ruleId: row.rule_id || '',
|
||||
transactionId: row.transaction_id || '',
|
||||
errorMessage: row.error_message || '',
|
||||
})
|
||||
|
||||
const getLatestBySourceId = async (sourceId) => {
|
||||
if (!sourceId) return null
|
||||
const db = await getDb()
|
||||
const result = await db.query(
|
||||
`
|
||||
SELECT *
|
||||
FROM notifications_raw
|
||||
WHERE source_id = ?
|
||||
ORDER BY posted_at DESC
|
||||
LIMIT 1
|
||||
`,
|
||||
[sourceId],
|
||||
)
|
||||
return result?.values?.[0] ? mapRow(result.values[0]) : null
|
||||
}
|
||||
|
||||
export const ensureNotificationRaw = async (notification, options = {}) => {
|
||||
const sourceId = notification?.id || notification?.sourceId || ''
|
||||
const existing = await getLatestBySourceId(sourceId)
|
||||
if (existing) return existing
|
||||
|
||||
const db = await getDb()
|
||||
const id = notification?.rawId || uuidv4()
|
||||
await db.run(
|
||||
`
|
||||
INSERT INTO notifications_raw (
|
||||
id,
|
||||
source_id,
|
||||
channel,
|
||||
title,
|
||||
text,
|
||||
posted_at,
|
||||
status,
|
||||
rule_id,
|
||||
transaction_id,
|
||||
error_message
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
[
|
||||
id,
|
||||
sourceId || null,
|
||||
notification?.channel || null,
|
||||
notification?.title || null,
|
||||
notification?.text || '',
|
||||
notification?.createdAt || notification?.postedAt || new Date().toISOString(),
|
||||
options.status || NOTIFICATION_DEBUG_STATUS.unmatched,
|
||||
options.ruleId || null,
|
||||
options.transactionId || null,
|
||||
options.errorMessage || null,
|
||||
],
|
||||
)
|
||||
await saveDbToStore()
|
||||
return {
|
||||
id,
|
||||
sourceId,
|
||||
channel: notification?.channel || '',
|
||||
title: notification?.title || '',
|
||||
text: notification?.text || '',
|
||||
postedAt: notification?.createdAt || notification?.postedAt || '',
|
||||
status: options.status || NOTIFICATION_DEBUG_STATUS.unmatched,
|
||||
ruleId: options.ruleId || '',
|
||||
transactionId: options.transactionId || '',
|
||||
errorMessage: options.errorMessage || '',
|
||||
}
|
||||
}
|
||||
|
||||
export const updateNotificationRawStatus = async (sourceId, next = {}) => {
|
||||
if (!sourceId) return null
|
||||
const existing = await getLatestBySourceId(sourceId)
|
||||
if (!existing) return null
|
||||
|
||||
const db = await getDb()
|
||||
await db.run(
|
||||
`
|
||||
UPDATE notifications_raw
|
||||
SET status = ?, rule_id = ?, transaction_id = ?, error_message = ?
|
||||
WHERE id = ?
|
||||
`,
|
||||
[
|
||||
next.status || existing.status,
|
||||
next.ruleId ?? existing.ruleId ?? null,
|
||||
next.transactionId ?? existing.transactionId ?? null,
|
||||
next.errorMessage ?? existing.errorMessage ?? null,
|
||||
existing.id,
|
||||
],
|
||||
)
|
||||
await saveDbToStore()
|
||||
return {
|
||||
...existing,
|
||||
status: next.status || existing.status,
|
||||
ruleId: next.ruleId ?? existing.ruleId,
|
||||
transactionId: next.transactionId ?? existing.transactionId,
|
||||
errorMessage: next.errorMessage ?? existing.errorMessage,
|
||||
}
|
||||
}
|
||||
|
||||
export const fetchNotificationDebugRecords = async (options = {}) => {
|
||||
const db = await getDb()
|
||||
const params = []
|
||||
const where = []
|
||||
|
||||
if (options.status && options.status !== 'all') {
|
||||
where.push('status = ?')
|
||||
params.push(options.status)
|
||||
}
|
||||
|
||||
if (options.keyword) {
|
||||
where.push('(channel LIKE ? OR title LIKE ? OR text LIKE ?)')
|
||||
const keyword = `%${String(options.keyword).trim()}%`
|
||||
params.push(keyword, keyword, keyword)
|
||||
}
|
||||
|
||||
const whereClause = where.length ? `WHERE ${where.join(' AND ')}` : ''
|
||||
const limit = Math.max(1, Math.min(Number(options.limit) || 80, 200))
|
||||
params.push(limit)
|
||||
|
||||
const result = await db.query(
|
||||
`
|
||||
SELECT *
|
||||
FROM notifications_raw
|
||||
${whereClause}
|
||||
ORDER BY posted_at DESC
|
||||
LIMIT ?
|
||||
`,
|
||||
params,
|
||||
)
|
||||
|
||||
return (result?.values || []).map(mapRow)
|
||||
}
|
||||
@@ -3,17 +3,17 @@ import NotificationBridge, {
|
||||
isNativeNotificationBridgeAvailable,
|
||||
} from '../lib/notificationBridge'
|
||||
|
||||
// 原生插件回调,可按需在应用启动时注入,例如用于上报到服务端
|
||||
// 原生插件回调,可按需在应用启动时注入,例如后续接服务端同步。
|
||||
let remoteNotificationFetcher = async () => []
|
||||
let remoteNotificationAcknowledger = async () => {}
|
||||
|
||||
// 判断当前是否为原生环境(Android 容器内)
|
||||
// 判断当前是否在原生容器内。
|
||||
const nativeBridgeReady = isNativeNotificationBridgeAvailable()
|
||||
|
||||
/**
|
||||
* 本地模拟通知缓存:
|
||||
* - 浏览器环境:用于 Demo & Mock,可通过 pushLocalNotification 主动注入
|
||||
* - 原生环境:一般保持为空,仅在必要时作为 NotificationBridge 数据的补充兜底
|
||||
* - 浏览器环境:用于规则调试和 Demo。
|
||||
* - 原生环境:通常保持为空,仅在必要时补充桥接层数据。
|
||||
*/
|
||||
let localNotificationQueue = []
|
||||
|
||||
@@ -29,9 +29,9 @@ const sortByCreatedAtDesc = (a, b) =>
|
||||
new Date(b.createdAt || b.id) - new Date(a.createdAt || a.id)
|
||||
|
||||
/**
|
||||
* 统一获取待处理通知队列:
|
||||
* - 原生环境:优先从 NotificationBridge 中拿未处理通知
|
||||
* - 浏览器:仅使用本地模拟通知
|
||||
* 统一读取待处理通知队列。
|
||||
* - 原生环境:优先读取 NotificationBridge 中的待处理通知。
|
||||
* - 浏览器环境:仅使用本地模拟通知。
|
||||
*/
|
||||
export const fetchNotificationQueue = async () => {
|
||||
const remote = await remoteNotificationFetcher()
|
||||
@@ -40,20 +40,21 @@ export const fetchNotificationQueue = async () => {
|
||||
}
|
||||
|
||||
/**
|
||||
* 浏览器环境下模拟「新通知」进入队列
|
||||
* 浏览器环境下主动注入一条模拟通知。
|
||||
*/
|
||||
export const pushLocalNotification = (payload) => {
|
||||
const entry = {
|
||||
id: payload?.id || uuidv4(),
|
||||
createdAt: payload?.createdAt || new Date().toISOString(),
|
||||
channel: payload?.channel || '模拟',
|
||||
channel: payload?.channel || '\u6a21\u62df\u901a\u77e5',
|
||||
title: payload?.title || '',
|
||||
text: payload?.text || '',
|
||||
}
|
||||
localNotificationQueue = [entry, ...localNotificationQueue]
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认/忽略某条通知后,从本地队列中移除,并尝试同步到远端
|
||||
* 确认或忽略通知后,从本地队列移除,并尝试同步给原生层。
|
||||
*/
|
||||
export const acknowledgeNotification = async (id) => {
|
||||
if (id) {
|
||||
@@ -62,32 +63,27 @@ export const acknowledgeNotification = async (id) => {
|
||||
try {
|
||||
await remoteNotificationAcknowledger(id)
|
||||
} catch (error) {
|
||||
console.warn('[notifications] 远程通知确认失败,将在下次同步重试', error)
|
||||
console.warn('[notifications] remote acknowledgement failed, will retry on next sync', error)
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 原生桥接(Android NotificationListenerService)集成 =====
|
||||
|
||||
if (nativeBridgeReady) {
|
||||
// 从原生层拉取待处理通知
|
||||
setRemoteNotificationFetcher(async () => {
|
||||
try {
|
||||
const { notifications = [] } = await NotificationBridge.getPendingNotifications()
|
||||
return notifications
|
||||
} catch (error) {
|
||||
console.warn('[notifications] 获取原生通知失败,退回本地模拟数据', error)
|
||||
console.warn('[notifications] failed to fetch native notifications, fallback to local queue', error)
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
// 通知已确认/忽略后,告知原生层清除对应记录
|
||||
setRemoteNotificationAcknowledger(async (id) => {
|
||||
if (!id) return
|
||||
try {
|
||||
await NotificationBridge.acknowledgeNotification({ id })
|
||||
} catch (error) {
|
||||
console.warn('[notifications] 通知确认同步至原生失败', error)
|
||||
console.warn('[notifications] failed to acknowledge native notification', error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user