fix:优化匹配规则

This commit is contained in:
2025-11-13 13:50:20 +08:00
parent fd442c2f8b
commit 53f5cf479d
9 changed files with 317 additions and 21 deletions

View 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);

View File

@@ -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();
}

View File

@@ -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;

View File

@@ -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: {} };
}

View File

@@ -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

View File

@@ -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');

View File

@@ -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 };

View File

@@ -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>

View File

@@ -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>