feat:新增离线存储
This commit is contained in:
27
apps/backend/src/models/notification-channel.model.ts
Normal file
27
apps/backend/src/models/notification-channel.model.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import mongoose, { type InferSchemaType } from 'mongoose';
|
||||
|
||||
const notificationTemplateSchema = new mongoose.Schema(
|
||||
{
|
||||
name: { type: String },
|
||||
keywords: { type: [String], default: [] },
|
||||
category: { type: String },
|
||||
type: { type: String, enum: ['income', 'expense'] },
|
||||
amountPattern: { type: String }
|
||||
},
|
||||
{ _id: false }
|
||||
);
|
||||
|
||||
const notificationChannelSchema = new mongoose.Schema(
|
||||
{
|
||||
packageName: { type: String, required: true, unique: true },
|
||||
displayName: { type: String, required: true },
|
||||
enabled: { type: Boolean, default: true },
|
||||
templates: { type: [notificationTemplateSchema], default: [] }
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
export type NotificationChannelDocument = InferSchemaType<typeof notificationChannelSchema>;
|
||||
|
||||
export const NotificationChannelModel =
|
||||
mongoose.models.NotificationChannel ?? mongoose.model('NotificationChannel', notificationChannelSchema);
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { listBudgets, createBudget, updateBudget, deleteBudget } from './budgets.service.js';
|
||||
import { listBudgets, createBudget, updateBudget, deleteBudget, syncBudgetUsage } from './budgets.service.js';
|
||||
import type { CreateBudgetInput, UpdateBudgetInput } from './budgets.schema.js';
|
||||
|
||||
export const listBudgetsHandler = async (req: Request, res: Response) => {
|
||||
@@ -16,6 +16,7 @@ export const createBudgetHandler = async (req: Request, res: Response) => {
|
||||
}
|
||||
const payload = req.body as CreateBudgetInput;
|
||||
const budget = await createBudget(payload, req.user.id);
|
||||
await syncBudgetUsage(req.user.id, [budget.category]);
|
||||
return res.status(201).json({ data: budget });
|
||||
};
|
||||
|
||||
@@ -28,6 +29,7 @@ export const updateBudgetHandler = async (req: Request, res: Response) => {
|
||||
if (!budget) {
|
||||
return res.status(404).json({ message: 'Budget not found' });
|
||||
}
|
||||
await syncBudgetUsage(req.user.id, [budget.category]);
|
||||
return res.json({ data: budget });
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { BudgetModel, type BudgetDocument } from '../../models/budget.model.js';
|
||||
import { TransactionModel } from '../../models/transaction.model.js';
|
||||
import type { CreateBudgetInput, UpdateBudgetInput } from './budgets.schema.js';
|
||||
|
||||
export interface BudgetDto {
|
||||
@@ -72,3 +73,57 @@ export async function deleteBudget(id: string, userId: string): Promise<boolean>
|
||||
const result = await BudgetModel.findOneAndDelete({ _id: id, userId }).exec();
|
||||
return result !== null;
|
||||
}
|
||||
|
||||
const getPeriodRange = (period: 'monthly' | 'weekly') => {
|
||||
const now = new Date();
|
||||
let start: Date;
|
||||
if (period === 'weekly') {
|
||||
const day = now.getDay();
|
||||
const diff = day === 0 ? 6 : day - 1; // Monday start
|
||||
start = new Date(now);
|
||||
start.setDate(now.getDate() - diff);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
} else {
|
||||
start = new Date(now.getFullYear(), now.getMonth(), 1, 0, 0, 0, 0);
|
||||
}
|
||||
const end = new Date(now);
|
||||
end.setHours(23, 59, 59, 999);
|
||||
return { start, end };
|
||||
};
|
||||
|
||||
async function calculateUsageForBudget(budget: BudgetDocumentWithId, userId: string): Promise<number> {
|
||||
const { start, end } = getPeriodRange(budget.period);
|
||||
const userObjectId = new mongoose.Types.ObjectId(userId);
|
||||
const [result] = await TransactionModel.aggregate<{ total: number }>([
|
||||
{
|
||||
$match: {
|
||||
userId: userObjectId,
|
||||
type: 'expense',
|
||||
status: { $ne: 'rejected' },
|
||||
category: budget.category,
|
||||
occurredAt: { $gte: start, $lte: end }
|
||||
}
|
||||
},
|
||||
{
|
||||
$group: { _id: null, total: { $sum: '$amount' } }
|
||||
}
|
||||
])
|
||||
.allowDiskUse(false)
|
||||
.exec();
|
||||
|
||||
return Number(result?.total ?? 0);
|
||||
}
|
||||
|
||||
export async function syncBudgetUsage(userId: string, categories?: string[]): Promise<void> {
|
||||
const filter: Record<string, unknown> = { userId };
|
||||
if (categories && categories.length > 0) {
|
||||
filter.category = { $in: categories };
|
||||
}
|
||||
const budgets = await BudgetModel.find(filter).lean<BudgetDocumentWithId[]>().exec();
|
||||
await Promise.all(
|
||||
budgets.map(async (budget) => {
|
||||
const usage = await calculateUsageForBudget(budget, userId);
|
||||
await BudgetModel.updateOne({ _id: budget._id }, { usage }).exec();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { NotificationChannelModel, type NotificationChannelDocument } from '../../models/notification-channel.model.js';
|
||||
import { env } from '../../config/env.js';
|
||||
|
||||
export interface NotificationChannelDto {
|
||||
id: string;
|
||||
packageName: string;
|
||||
displayName: string;
|
||||
enabled: boolean;
|
||||
templates: Array<{
|
||||
name?: string;
|
||||
keywords?: string[];
|
||||
category?: string;
|
||||
type?: 'income' | 'expense';
|
||||
amountPattern?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
const toDto = (doc: NotificationChannelDocument & { _id: mongoose.Types.ObjectId }): NotificationChannelDto => ({
|
||||
id: doc._id.toString(),
|
||||
packageName: doc.packageName,
|
||||
displayName: doc.displayName,
|
||||
enabled: doc.enabled,
|
||||
templates: doc.templates ?? []
|
||||
});
|
||||
|
||||
export async function ensureDefaultChannels(): Promise<void> {
|
||||
if ((await NotificationChannelModel.estimatedDocumentCount().exec()) > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const defaults = env.notificationPackageWhitelist.map((pkg) => ({
|
||||
packageName: pkg,
|
||||
displayName: pkg,
|
||||
enabled: true,
|
||||
templates: []
|
||||
}));
|
||||
|
||||
if (defaults.length > 0) {
|
||||
await NotificationChannelModel.insertMany(defaults, { ordered: false });
|
||||
}
|
||||
}
|
||||
|
||||
export async function listChannels(): Promise<NotificationChannelDto[]> {
|
||||
const docs = await NotificationChannelModel.find().sort({ displayName: 1 }).lean().exec();
|
||||
return docs.map((doc) => toDto(doc as NotificationChannelDocument & { _id: mongoose.Types.ObjectId }));
|
||||
}
|
||||
|
||||
export async function upsertChannel(input: {
|
||||
packageName: string;
|
||||
displayName: string;
|
||||
enabled?: boolean;
|
||||
templates?: NotificationChannelDto['templates'];
|
||||
}): Promise<NotificationChannelDto> {
|
||||
const updated = await NotificationChannelModel.findOneAndUpdate(
|
||||
{ packageName: input.packageName },
|
||||
{
|
||||
$set: {
|
||||
displayName: input.displayName,
|
||||
enabled: input.enabled ?? true,
|
||||
templates: input.templates ?? []
|
||||
}
|
||||
},
|
||||
{ new: true, upsert: true }
|
||||
)
|
||||
.lean<NotificationChannelDocument & { _id: mongoose.Types.ObjectId }>()
|
||||
.exec();
|
||||
|
||||
if (!updated) {
|
||||
throw new Error('Failed to upsert channel');
|
||||
}
|
||||
return toDto(updated);
|
||||
}
|
||||
|
||||
export async function updateChannel(
|
||||
id: string,
|
||||
payload: Partial<Omit<NotificationChannelDto, 'id'>>
|
||||
): Promise<NotificationChannelDto | null> {
|
||||
const updated = await NotificationChannelModel.findByIdAndUpdate(
|
||||
id,
|
||||
{ $set: payload },
|
||||
{ new: true }
|
||||
)
|
||||
.lean<NotificationChannelDocument & { _id: mongoose.Types.ObjectId }>()
|
||||
.exec();
|
||||
return updated ? toDto(updated) : null;
|
||||
}
|
||||
|
||||
export async function deleteChannel(id: string): Promise<boolean> {
|
||||
const result = await NotificationChannelModel.findByIdAndDelete(id).exec();
|
||||
return result !== null;
|
||||
}
|
||||
|
||||
export async function getChannelByPackage(
|
||||
packageName: string
|
||||
): Promise<(NotificationChannelDocument & { _id: mongoose.Types.ObjectId }) | null> {
|
||||
const doc = await NotificationChannelModel.findOne({ packageName }).lean().exec();
|
||||
if (!doc) return null;
|
||||
return doc as NotificationChannelDocument & { _id: mongoose.Types.ObjectId };
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { env } from '../../config/env.js';
|
||||
import { TransactionModel } from '../../models/transaction.model.js';
|
||||
import type { NotificationChannelDto } from './notification-channels.service.js';
|
||||
import {
|
||||
deleteChannel,
|
||||
listChannels,
|
||||
upsertChannel,
|
||||
updateChannel
|
||||
} from './notification-channels.service.js';
|
||||
|
||||
const maskSecret = (secret?: string | null) => {
|
||||
if (!secret) return null;
|
||||
@@ -19,12 +26,14 @@ export const getNotificationStatus = async (req: Request, res: Response) => {
|
||||
| null;
|
||||
const ingestEndpoint = `${req.protocol}://${req.get('host') ?? 'localhost'}/api/transactions/notification`;
|
||||
|
||||
const channels = await listChannels();
|
||||
const whitelist = channels.filter((channel) => channel.enabled).map((channel) => channel.packageName);
|
||||
const secret = env.NOTIFICATION_WEBHOOK_SECRET;
|
||||
const response = {
|
||||
secretConfigured: Boolean(secret),
|
||||
secretHint: maskSecret(secret),
|
||||
webhookSecret: env.isProduction ? null : secret ?? null,
|
||||
packageWhitelist: env.notificationPackageWhitelist,
|
||||
packageWhitelist: whitelist,
|
||||
ingestedCount: count,
|
||||
lastNotificationAt: latest?.createdAt ? new Date(latest.createdAt).toISOString() : null,
|
||||
ingestEndpoint
|
||||
@@ -32,3 +41,35 @@ export const getNotificationStatus = async (req: Request, res: Response) => {
|
||||
|
||||
return res.json(response);
|
||||
};
|
||||
|
||||
export const listChannelsHandler = async (_req: Request, res: Response) => {
|
||||
const data = await listChannels();
|
||||
return res.json({ data });
|
||||
};
|
||||
|
||||
export const upsertChannelHandler = async (req: Request, res: Response) => {
|
||||
const payload = req.body as {
|
||||
packageName: string;
|
||||
displayName: string;
|
||||
enabled?: boolean;
|
||||
templates?: NotificationChannelDto['templates'];
|
||||
};
|
||||
const channel = await upsertChannel(payload);
|
||||
return res.status(201).json({ data: channel });
|
||||
};
|
||||
|
||||
export const updateChannelHandler = async (req: Request, res: Response) => {
|
||||
const channel = await updateChannel(req.params.id, req.body);
|
||||
if (!channel) {
|
||||
return res.status(404).json({ message: 'Channel not found' });
|
||||
}
|
||||
return res.json({ data: channel });
|
||||
};
|
||||
|
||||
export const deleteChannelHandler = async (req: Request, res: Response) => {
|
||||
const removed = await deleteChannel(req.params.id);
|
||||
if (!removed) {
|
||||
return res.status(404).json({ message: 'Channel not found' });
|
||||
}
|
||||
return res.status(204).send();
|
||||
};
|
||||
|
||||
@@ -1,11 +1,66 @@
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { authenticate } from '../../middlewares/authenticate.js';
|
||||
import { getNotificationStatus } from './notifications.controller.js';
|
||||
import { getNotificationStatus, listChannelsHandler, upsertChannelHandler, updateChannelHandler, deleteChannelHandler } from './notifications.controller.js';
|
||||
import { validateRequest } from '../../middlewares/validate-request.js';
|
||||
import { ensureDefaultChannels } from './notification-channels.service.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(authenticate);
|
||||
|
||||
router.get('/status', getNotificationStatus);
|
||||
router.get('/channels', listChannelsHandler);
|
||||
router.post(
|
||||
'/channels',
|
||||
validateRequest({
|
||||
body: z.object({
|
||||
packageName: z.string().min(1),
|
||||
displayName: z.string().min(1),
|
||||
enabled: z.boolean().optional(),
|
||||
templates: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string().optional(),
|
||||
keywords: z.array(z.string()).optional(),
|
||||
category: z.string().optional(),
|
||||
type: z.enum(['income', 'expense']).optional(),
|
||||
amountPattern: z.string().optional()
|
||||
})
|
||||
)
|
||||
.optional()
|
||||
})
|
||||
}),
|
||||
upsertChannelHandler
|
||||
);
|
||||
router.patch(
|
||||
'/channels/:id',
|
||||
validateRequest({
|
||||
params: z.object({ id: z.string().min(1) }),
|
||||
body: z.object({
|
||||
displayName: z.string().min(1).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
templates: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string().optional(),
|
||||
keywords: z.array(z.string()).optional(),
|
||||
category: z.string().optional(),
|
||||
type: z.enum(['income', 'expense']).optional(),
|
||||
amountPattern: z.string().optional()
|
||||
})
|
||||
)
|
||||
.optional()
|
||||
})
|
||||
}),
|
||||
updateChannelHandler
|
||||
);
|
||||
router.delete(
|
||||
'/channels/:id',
|
||||
validateRequest({ params: z.object({ id: z.string().min(1) }) }),
|
||||
deleteChannelHandler
|
||||
);
|
||||
|
||||
void ensureDefaultChannels();
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -10,7 +10,19 @@ export interface ParsedNotification {
|
||||
confidence?: number;
|
||||
}
|
||||
|
||||
export function parseNotificationPayload(title: string, body: string): ParsedNotification {
|
||||
export interface ChannelTemplateOverrides {
|
||||
name?: string;
|
||||
keywords?: string[];
|
||||
category?: string;
|
||||
type?: 'income' | 'expense';
|
||||
amountPattern?: string;
|
||||
}
|
||||
|
||||
export function parseNotificationPayload(
|
||||
title: string,
|
||||
body: string,
|
||||
template?: ChannelTemplateOverrides | null
|
||||
): ParsedNotification {
|
||||
const combined = `${title}|${body}`;
|
||||
|
||||
const parsed: ParsedNotification = {
|
||||
@@ -27,11 +39,28 @@ export function parseNotificationPayload(title: string, body: string): ParsedNot
|
||||
parsed.ruleId = classification.ruleId;
|
||||
parsed.confidence = classification.confidence;
|
||||
|
||||
const amount = extractAmountFromText(title, body);
|
||||
let amount = extractAmountFromText(title, body);
|
||||
if (!amount && template?.amountPattern) {
|
||||
const customRegex = new RegExp(template.amountPattern);
|
||||
const match = body.match(customRegex);
|
||||
if (match?.[1]) {
|
||||
const candidate = Number.parseFloat(match[1]);
|
||||
if (!Number.isNaN(candidate)) {
|
||||
amount = Math.abs(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (amount !== undefined) {
|
||||
parsed.amount = amount;
|
||||
}
|
||||
|
||||
if (template?.category) {
|
||||
parsed.category = template.category;
|
||||
}
|
||||
if (template?.type) {
|
||||
parsed.type = template.type;
|
||||
}
|
||||
|
||||
if (!parsed.type) {
|
||||
if (combined.includes('到账') || combined.includes('收入') || combined.includes('入账')) {
|
||||
parsed.type = 'income';
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { TransactionModel, type TransactionDocument } from '../../models/transaction.model.js';
|
||||
import { classifyByText } from '../../services/classification.service.js';
|
||||
import { syncBudgetUsage } from '../budgets/budgets.service.js';
|
||||
import { getChannelByPackage } from '../notifications/notification-channels.service.js';
|
||||
import { getDefaultUserId } from '../../services/user-context.js';
|
||||
import type { CreateTransactionInput, UpdateTransactionInput } from './transactions.schema.js';
|
||||
import { parseNotificationPayload } from './notification.parser.js';
|
||||
@@ -93,6 +96,9 @@ export async function createTransaction(payload: CreateTransactionInput, userId?
|
||||
if (!createdDoc) {
|
||||
throw new Error('Failed to load created transaction');
|
||||
}
|
||||
if (createdDoc.type === 'expense' && createdDoc.category) {
|
||||
await syncBudgetUsage(resolvedUserId, [createdDoc.category]);
|
||||
}
|
||||
return toDto(createdDoc);
|
||||
}
|
||||
|
||||
@@ -126,11 +132,20 @@ export async function updateTransaction(
|
||||
.lean<TransactionDocumentWithId>()
|
||||
.exec();
|
||||
|
||||
if (updated && updated.type === 'expense' && updated.category) {
|
||||
await syncBudgetUsage(userId, [updated.category]);
|
||||
}
|
||||
|
||||
return updated ? toDto(updated) : null;
|
||||
}
|
||||
|
||||
export async function deleteTransaction(id: string, userId: string): Promise<boolean> {
|
||||
const result = await TransactionModel.findOneAndDelete({ _id: id, userId }).exec();
|
||||
const result = await TransactionModel.findOneAndDelete({ _id: id, userId })
|
||||
.lean<TransactionDocumentWithId>()
|
||||
.exec();
|
||||
if (result && result.type === 'expense' && result.category) {
|
||||
await syncBudgetUsage(userId, [result.category]);
|
||||
}
|
||||
return result !== null;
|
||||
}
|
||||
|
||||
@@ -143,7 +158,29 @@ interface NotificationPayload {
|
||||
|
||||
export async function ingestNotification(payload: NotificationPayload): Promise<TransactionDto> {
|
||||
const now = payload.receivedAt;
|
||||
const parsed = parseNotificationPayload(payload.title ?? '', payload.body ?? '');
|
||||
const channel = await getChannelByPackage(payload.packageName);
|
||||
if (!channel || !channel.enabled) {
|
||||
throw Object.assign(new Error('Notification channel disabled'), { statusCode: 403 });
|
||||
}
|
||||
|
||||
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 hashSource = `${payload.packageName}|${payload.title}|${payload.body}|${now.getTime()}`;
|
||||
const notificationHash = createHash('sha256').update(hashSource).digest('hex');
|
||||
|
||||
const existing = await TransactionModel.findOne({
|
||||
'metadata.notificationHash': notificationHash
|
||||
})
|
||||
.lean<TransactionDocumentWithId>()
|
||||
.exec();
|
||||
if (existing) {
|
||||
return toDto(existing);
|
||||
}
|
||||
|
||||
const transactionPayload: CreateTransactionInput = {
|
||||
title: payload.title,
|
||||
@@ -158,7 +195,9 @@ export async function ingestNotification(payload: NotificationPayload): Promise<
|
||||
metadata: {
|
||||
packageName: payload.packageName,
|
||||
rawBody: payload.body,
|
||||
parser: parsed
|
||||
parser: parsed,
|
||||
channelId: channel._id.toString(),
|
||||
notificationHash
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user