fix:优化匹配规则
This commit is contained in:
31
apps/backend/src/models/notification-rule.model.ts
Normal file
31
apps/backend/src/models/notification-rule.model.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import mongoose, { type InferSchemaType } from 'mongoose';
|
||||
|
||||
const notificationRuleSchema = new mongoose.Schema(
|
||||
{
|
||||
name: { type: String, required: true },
|
||||
enabled: { type: Boolean, default: true },
|
||||
priority: { type: Number, default: 50 },
|
||||
|
||||
// Match scope
|
||||
packageName: { type: String }, // optional: limit to a package/channel
|
||||
keywords: { type: [String], default: [] }, // any keyword match in title/body
|
||||
pattern: { type: String }, // optional regex string
|
||||
|
||||
// Action
|
||||
action: { type: String, enum: ['record', 'ignore'], default: 'record' },
|
||||
category: { type: String }, // override category when action is record
|
||||
type: { type: String, enum: ['income', 'expense'] }, // override type
|
||||
amountPattern: { type: String }, // custom regex for extracting amount
|
||||
|
||||
// Telemetry
|
||||
matchCount: { type: Number, default: 0 },
|
||||
lastMatchedAt: { type: Date }
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
export type NotificationRuleDocument = InferSchemaType<typeof notificationRuleSchema>;
|
||||
|
||||
export const NotificationRuleModel =
|
||||
mongoose.models.NotificationRule ?? mongoose.model('NotificationRule', notificationRuleSchema);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { createRule, deleteRule, listRules, updateRule } from './notification-rules.service.js';
|
||||
|
||||
const ruleSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
enabled: z.boolean().optional(),
|
||||
priority: z.number().int().min(0).max(100).optional(),
|
||||
packageName: z.string().optional(),
|
||||
keywords: z.array(z.string()).optional(),
|
||||
pattern: z.string().optional(),
|
||||
action: z.enum(['record', 'ignore']).optional(),
|
||||
category: z.string().optional(),
|
||||
type: z.enum(['income', 'expense']).optional(),
|
||||
amountPattern: z.string().optional()
|
||||
});
|
||||
|
||||
export async function listRulesHandler(_req: Request, res: Response) {
|
||||
const data = await listRules();
|
||||
return res.json({ data });
|
||||
}
|
||||
|
||||
export async function createRuleHandler(req: Request, res: Response) {
|
||||
const payload = ruleSchema.parse(req.body);
|
||||
const data = await createRule(payload as any);
|
||||
return res.status(201).json({ data });
|
||||
}
|
||||
|
||||
export async function updateRuleHandler(req: Request, res: Response) {
|
||||
const payload = ruleSchema.partial().parse(req.body);
|
||||
const data = await updateRule(req.params.id, payload as any);
|
||||
if (!data) return res.status(404).json({ message: 'Rule not found' });
|
||||
return res.json({ data });
|
||||
}
|
||||
|
||||
export async function deleteRuleHandler(req: Request, res: Response) {
|
||||
const ok = await deleteRule(req.params.id);
|
||||
if (!ok) return res.status(404).json({ message: 'Rule not found' });
|
||||
return res.status(204).send();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Router } from 'express';
|
||||
import {
|
||||
listRulesHandler,
|
||||
createRuleHandler,
|
||||
updateRuleHandler,
|
||||
deleteRuleHandler
|
||||
} from './notification-rules.controller.js';
|
||||
import { validateRequest } from '../../middlewares/validate-request.js';
|
||||
import { z } from 'zod';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', listRulesHandler);
|
||||
router.post('/', createRuleHandler);
|
||||
router.patch(
|
||||
'/:id',
|
||||
validateRequest({ params: z.object({ id: z.string().min(1) }) }),
|
||||
updateRuleHandler
|
||||
);
|
||||
router.delete(
|
||||
'/:id',
|
||||
validateRequest({ params: z.object({ id: z.string().min(1) }) }),
|
||||
deleteRuleHandler
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import mongoose from 'mongoose';
|
||||
import {
|
||||
NotificationRuleModel,
|
||||
type NotificationRuleDocument
|
||||
} from '../../models/notification-rule.model.js';
|
||||
|
||||
export interface NotificationRuleDto {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
priority: number;
|
||||
packageName?: string;
|
||||
keywords?: string[];
|
||||
pattern?: string;
|
||||
action: 'record' | 'ignore';
|
||||
category?: string;
|
||||
type?: 'income' | 'expense';
|
||||
amountPattern?: string;
|
||||
matchCount: number;
|
||||
lastMatchedAt?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
const toDto = (doc: NotificationRuleDocument & { _id: mongoose.Types.ObjectId }): NotificationRuleDto => ({
|
||||
id: doc._id.toString(),
|
||||
name: doc.name,
|
||||
enabled: doc.enabled ?? true,
|
||||
priority: doc.priority ?? 50,
|
||||
packageName: doc.packageName ?? undefined,
|
||||
keywords: doc.keywords ?? [],
|
||||
pattern: doc.pattern ?? undefined,
|
||||
action: (doc.action as 'record' | 'ignore') ?? 'record',
|
||||
category: doc.category ?? undefined,
|
||||
type: doc.type as any,
|
||||
amountPattern: doc.amountPattern ?? undefined,
|
||||
matchCount: doc.matchCount ?? 0,
|
||||
lastMatchedAt: doc.lastMatchedAt ? new Date(doc.lastMatchedAt).toISOString() : undefined,
|
||||
createdAt: (doc as any).createdAt ? new Date((doc as any).createdAt).toISOString() : undefined,
|
||||
updatedAt: (doc as any).updatedAt ? new Date((doc as any).updatedAt).toISOString() : undefined
|
||||
});
|
||||
|
||||
export async function listRules(): Promise<NotificationRuleDto[]> {
|
||||
const docs = await NotificationRuleModel.find().sort({ priority: -1, createdAt: 1 }).lean().exec();
|
||||
return docs.map((d) => toDto(d as NotificationRuleDocument & { _id: mongoose.Types.ObjectId }));
|
||||
}
|
||||
|
||||
export async function createRule(payload: Omit<NotificationRuleDto, 'id' | 'matchCount' | 'lastMatchedAt' | 'createdAt' | 'updatedAt'>): Promise<NotificationRuleDto> {
|
||||
const created = await NotificationRuleModel.create({
|
||||
name: payload.name,
|
||||
enabled: payload.enabled ?? true,
|
||||
priority: payload.priority ?? 50,
|
||||
packageName: payload.packageName ?? undefined,
|
||||
keywords: payload.keywords ?? [],
|
||||
pattern: payload.pattern ?? undefined,
|
||||
action: payload.action ?? 'record',
|
||||
category: payload.category ?? undefined,
|
||||
type: payload.type ?? undefined,
|
||||
amountPattern: payload.amountPattern ?? undefined
|
||||
});
|
||||
const doc = await NotificationRuleModel.findById(created._id).lean().exec();
|
||||
return toDto(doc as NotificationRuleDocument & { _id: mongoose.Types.ObjectId });
|
||||
}
|
||||
|
||||
export async function updateRule(
|
||||
id: string,
|
||||
payload: Partial<Omit<NotificationRuleDto, 'id' | 'matchCount' | 'lastMatchedAt' | 'createdAt' | 'updatedAt'>>
|
||||
): Promise<NotificationRuleDto | null> {
|
||||
const updated = await NotificationRuleModel.findByIdAndUpdate(
|
||||
id,
|
||||
{
|
||||
$set: {
|
||||
...payload
|
||||
}
|
||||
},
|
||||
{ new: true }
|
||||
)
|
||||
.lean<NotificationRuleDocument & { _id: mongoose.Types.ObjectId }>()
|
||||
.exec();
|
||||
return updated ? toDto(updated) : null;
|
||||
}
|
||||
|
||||
export async function deleteRule(id: string): Promise<boolean> {
|
||||
const res = await NotificationRuleModel.findByIdAndDelete(id).exec();
|
||||
return res !== null;
|
||||
}
|
||||
|
||||
export interface RuleMatchResult {
|
||||
ignore: boolean;
|
||||
overrides: {
|
||||
category?: string;
|
||||
type?: 'income' | 'expense';
|
||||
amountPattern?: string;
|
||||
};
|
||||
matchedRule?: NotificationRuleDto;
|
||||
}
|
||||
|
||||
function safeRegex(pattern?: string): RegExp | null {
|
||||
if (!pattern) return null;
|
||||
try {
|
||||
return new RegExp(pattern, 'i');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyNotificationRules(input: {
|
||||
packageName: string;
|
||||
title: string;
|
||||
body: string;
|
||||
}): Promise<RuleMatchResult> {
|
||||
const text = `${input.title ?? ''} ${input.body ?? ''}`;
|
||||
const rules = await NotificationRuleModel.find({ enabled: true }).sort({ priority: -1, createdAt: 1 }).lean().exec();
|
||||
|
||||
for (const r of rules as Array<NotificationRuleDocument & { _id: mongoose.Types.ObjectId }>) {
|
||||
if (r.packageName && r.packageName !== input.packageName) continue;
|
||||
|
||||
let matched = false;
|
||||
if (r.keywords && r.keywords.length > 0) {
|
||||
matched = r.keywords.some((kw) => kw && (text.includes(kw) || input.title?.includes(kw)));
|
||||
}
|
||||
if (!matched && r.pattern) {
|
||||
const regex = safeRegex(r.pattern);
|
||||
if (regex && regex.test(text)) matched = true;
|
||||
}
|
||||
|
||||
if (matched) {
|
||||
// update telemetry
|
||||
void NotificationRuleModel.updateOne(
|
||||
{ _id: r._id },
|
||||
{ $inc: { matchCount: 1 }, $set: { lastMatchedAt: new Date() } }
|
||||
).exec();
|
||||
|
||||
return {
|
||||
ignore: r.action === 'ignore',
|
||||
overrides: {
|
||||
category: r.category ?? undefined,
|
||||
type: (r.type as any) ?? undefined,
|
||||
amountPattern: r.amountPattern ?? undefined
|
||||
},
|
||||
matchedRule: toDto(r)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { ignore: false, overrides: {} };
|
||||
}
|
||||
|
||||
@@ -95,6 +95,10 @@ export const createTransactionFromNotificationHandler = async (req: Request, res
|
||||
receivedAt: new Date(receivedAt)
|
||||
});
|
||||
|
||||
if (!transaction) {
|
||||
return res.status(204).send();
|
||||
}
|
||||
|
||||
return res.status(202).json({
|
||||
message: 'Notification ingested and awaiting confirmation',
|
||||
data: transaction
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getChannelByPackage } from '../notifications/notification-channels.serv
|
||||
import { getDefaultUserId } from '../../services/user-context.js';
|
||||
import type { CreateTransactionInput, UpdateTransactionInput } from './transactions.schema.js';
|
||||
import { parseNotificationPayload } from './notification.parser.js';
|
||||
import { applyNotificationRules } from '../notifications/notification-rules.service.js';
|
||||
|
||||
export interface TransactionDto {
|
||||
id: string;
|
||||
@@ -156,19 +157,35 @@ interface NotificationPayload {
|
||||
receivedAt: Date;
|
||||
}
|
||||
|
||||
export async function ingestNotification(payload: NotificationPayload): Promise<TransactionDto> {
|
||||
export async function ingestNotification(payload: NotificationPayload): Promise<TransactionDto | null> {
|
||||
const now = payload.receivedAt;
|
||||
const channel = await getChannelByPackage(payload.packageName);
|
||||
if (!channel || !channel.enabled) {
|
||||
throw Object.assign(new Error('Notification channel disabled'), { statusCode: 403 });
|
||||
}
|
||||
|
||||
// Apply DB-based rules first (blacklist/overrides)
|
||||
const ruleResult = await applyNotificationRules({
|
||||
packageName: payload.packageName,
|
||||
title: payload.title ?? '',
|
||||
body: payload.body ?? ''
|
||||
});
|
||||
|
||||
if (ruleResult.ignore) {
|
||||
return null; // blacklisted by rule
|
||||
}
|
||||
|
||||
const template =
|
||||
channel.templates?.find((tpl: { keywords?: string[] }) =>
|
||||
tpl.keywords?.some((keyword: string) => keyword && (payload.title?.includes(keyword) || payload.body?.includes(keyword)))
|
||||
) ?? null;
|
||||
|
||||
const parsed = parseNotificationPayload(payload.title ?? '', payload.body ?? '', template);
|
||||
const mergedTemplate = {
|
||||
...(template ?? {}),
|
||||
...(ruleResult.overrides ?? {})
|
||||
} as Parameters<typeof parseNotificationPayload>[2];
|
||||
|
||||
const parsed = parseNotificationPayload(payload.title ?? '', payload.body ?? '', mergedTemplate);
|
||||
|
||||
const hashSource = `${payload.packageName}|${payload.title}|${payload.body}|${now.getTime()}`;
|
||||
const notificationHash = createHash('sha256').update(hashSource).digest('hex');
|
||||
|
||||
@@ -4,6 +4,7 @@ import transactionsRouter from '../modules/transactions/transactions.router.js';
|
||||
import analysisRouter from '../modules/analysis/analysis.router.js';
|
||||
import budgetsRouter from '../modules/budgets/budgets.router.js';
|
||||
import notificationsRouter from '../modules/notifications/notifications.router.js';
|
||||
import notificationRulesRouter from '../modules/notifications/notification-rules.router.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -16,5 +17,6 @@ router.use('/transactions', transactionsRouter);
|
||||
router.use('/analysis', analysisRouter);
|
||||
router.use('/budgets', budgetsRouter);
|
||||
router.use('/notifications', notificationsRouter);
|
||||
router.use('/notification-rules', notificationRulesRouter);
|
||||
|
||||
export { router };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { computed, onMounted, onBeforeUnmount } from 'vue';
|
||||
import OverviewCard from '../../../components/cards/OverviewCard.vue';
|
||||
import QuickActionButton from '../../../components/actions/QuickActionButton.vue';
|
||||
import TransactionItem from '../../../components/transactions/TransactionItem.vue';
|
||||
@@ -8,6 +8,8 @@ import { useAuthStore } from '../../../stores/auth';
|
||||
import { useTransactionsQuery } from '../../../composables/useTransactions';
|
||||
import { useBudgetsQuery } from '../../../composables/useBudgets';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { App, type AppState } from '@capacitor/app';
|
||||
import type { PluginListenerHandle } from '@capacitor/core';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const router = useRouter();
|
||||
@@ -67,6 +69,25 @@ const budgetAlerts = computed(() =>
|
||||
(budget) => budget.amount > 0 && (budget.usage / budget.amount >= budget.threshold || budget.usage >= budget.amount)
|
||||
)
|
||||
);
|
||||
|
||||
let appStateListener: PluginListenerHandle | null = null;
|
||||
|
||||
onMounted(async () => {
|
||||
// Ensure we fetch latest on mount/foreground
|
||||
await Promise.all([transactionsQuery.refetch(), budgetsQuery.refetch()]);
|
||||
appStateListener = await App.addListener('appStateChange', async ({ isActive }: AppState) => {
|
||||
if (isActive) {
|
||||
await Promise.all([transactionsQuery.refetch(), budgetsQuery.refetch()]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (appStateListener) {
|
||||
void appStateListener.remove();
|
||||
appStateListener = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -136,7 +157,7 @@ const budgetAlerts = computed(() =>
|
||||
<h2 class="text-xl font-semibold text-gray-900">近期交易</h2>
|
||||
<RouterLink to="/transactions" class="text-sm text-indigo-500">查看全部</RouterLink>
|
||||
</div>
|
||||
<div class="space-y-4 mt-4 pb-10">
|
||||
<div class="space-y-6 mt-4 pb-10">
|
||||
<div v-if="isInitialLoading" class="text-sm text-gray-400 text-center py-10">
|
||||
数据加载中...
|
||||
</div>
|
||||
|
||||
@@ -101,6 +101,9 @@ const submit = async () => {
|
||||
};
|
||||
|
||||
const removeTransaction = async (id: string) => {
|
||||
// 防误触:增加确认
|
||||
const ok = window.confirm('确认删除该交易吗?');
|
||||
if (!ok) return;
|
||||
try {
|
||||
await deleteTransaction.mutateAsync(id);
|
||||
showFeedback('交易已删除');
|
||||
@@ -178,24 +181,26 @@ const refreshTransactions = async () => {
|
||||
暂无交易记录,点击「新增」开始记账。
|
||||
</div>
|
||||
<div v-else>
|
||||
<div
|
||||
v-for="transaction in filteredTransactions"
|
||||
:key="transaction.id"
|
||||
class="relative group"
|
||||
>
|
||||
<RouterLink
|
||||
class="block"
|
||||
:to="{ name: 'transaction-detail', params: { id: transaction.id } }"
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-for="transaction in filteredTransactions"
|
||||
:key="transaction.id"
|
||||
class="relative group"
|
||||
>
|
||||
<TransactionItem :transaction="transaction" />
|
||||
</RouterLink>
|
||||
<button
|
||||
class="absolute top-4 right-4 opacity-0 group-hover:opacity-100 transition-opacity text-gray-400 hover:text-red-500 z-10"
|
||||
@click.stop="removeTransaction(transaction.id)"
|
||||
aria-label="删除"
|
||||
>
|
||||
<LucideIcon name="trash-2" :size="18" />
|
||||
</button>
|
||||
<RouterLink
|
||||
class="block"
|
||||
:to="{ name: 'transaction-detail', params: { id: transaction.id } }"
|
||||
>
|
||||
<TransactionItem :transaction="transaction" />
|
||||
</RouterLink>
|
||||
<button
|
||||
class="absolute top-4 right-4 opacity-0 group-hover:opacity-100 transition-opacity text-gray-400 hover:text-red-500 z-10"
|
||||
@click.stop="removeTransaction(transaction.id)"
|
||||
aria-label="删除"
|
||||
>
|
||||
<LucideIcon name="trash-2" :size="18" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user