feat:分类改为数据库存储
This commit is contained in:
16
apps/backend/src/models/category.model.ts
Normal file
16
apps/backend/src/models/category.model.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import mongoose, { type InferSchemaType } from 'mongoose';
|
||||
|
||||
const categorySchema = new mongoose.Schema(
|
||||
{
|
||||
name: { type: String, required: true, unique: true },
|
||||
type: { type: String, enum: ['expense', 'income'], required: false },
|
||||
icon: { type: String },
|
||||
color: { type: String },
|
||||
enabled: { type: Boolean, default: true }
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
export type CategoryDocument = InferSchemaType<typeof categorySchema>;
|
||||
export const CategoryModel = mongoose.models.Category ?? mongoose.model('Category', categorySchema);
|
||||
|
||||
36
apps/backend/src/modules/categories/categories.controller.ts
Normal file
36
apps/backend/src/modules/categories/categories.controller.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { createCategory, deleteCategory, listCategories, updateCategory } from './categories.service.js';
|
||||
|
||||
const categorySchema = z.object({
|
||||
name: z.string().min(1),
|
||||
type: z.enum(['expense', 'income']).optional(),
|
||||
icon: z.string().optional(),
|
||||
color: z.string().optional(),
|
||||
enabled: z.boolean().optional()
|
||||
});
|
||||
|
||||
export async function listCategoriesHandler(_req: Request, res: Response) {
|
||||
const data = await listCategories();
|
||||
return res.json({ data });
|
||||
}
|
||||
|
||||
export async function createCategoryHandler(req: Request, res: Response) {
|
||||
const payload = categorySchema.parse(req.body);
|
||||
const data = await createCategory(payload as any);
|
||||
return res.status(201).json({ data });
|
||||
}
|
||||
|
||||
export async function updateCategoryHandler(req: Request, res: Response) {
|
||||
const payload = categorySchema.partial().parse(req.body);
|
||||
const data = await updateCategory(req.params.id, payload as any);
|
||||
if (!data) return res.status(404).json({ message: 'Category not found' });
|
||||
return res.json({ data });
|
||||
}
|
||||
|
||||
export async function deleteCategoryHandler(req: Request, res: Response) {
|
||||
const ok = await deleteCategory(req.params.id);
|
||||
if (!ok) return res.status(404).json({ message: 'Category not found' });
|
||||
return res.status(204).send();
|
||||
}
|
||||
|
||||
25
apps/backend/src/modules/categories/categories.router.ts
Normal file
25
apps/backend/src/modules/categories/categories.router.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Router } from 'express';
|
||||
import { authenticate } from '../../middlewares/authenticate.js';
|
||||
import { validateRequest } from '../../middlewares/validate-request.js';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
createCategoryHandler,
|
||||
deleteCategoryHandler,
|
||||
listCategoriesHandler,
|
||||
updateCategoryHandler
|
||||
} from './categories.controller.js';
|
||||
import { ensureDefaultCategories } from './categories.service.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(authenticate);
|
||||
|
||||
router.get('/', listCategoriesHandler);
|
||||
router.post('/', createCategoryHandler);
|
||||
router.patch('/:id', validateRequest({ params: z.object({ id: z.string().min(1) }) }), updateCategoryHandler);
|
||||
router.delete('/:id', validateRequest({ params: z.object({ id: z.string().min(1) }) }), deleteCategoryHandler);
|
||||
|
||||
void ensureDefaultCategories();
|
||||
|
||||
export default router;
|
||||
|
||||
85
apps/backend/src/modules/categories/categories.service.ts
Normal file
85
apps/backend/src/modules/categories/categories.service.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { CategoryModel, type CategoryDocument } from '../../models/category.model.js';
|
||||
|
||||
export interface CategoryDto {
|
||||
id: string;
|
||||
name: string;
|
||||
type?: 'expense' | 'income';
|
||||
icon?: string;
|
||||
color?: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
const toDto = (doc: CategoryDocument & { _id: mongoose.Types.ObjectId }): CategoryDto => ({
|
||||
id: doc._id.toString(),
|
||||
name: doc.name,
|
||||
type: (doc.type as any) ?? undefined,
|
||||
icon: doc.icon ?? undefined,
|
||||
color: doc.color ?? undefined,
|
||||
enabled: doc.enabled ?? true
|
||||
});
|
||||
|
||||
const DEFAULT_CATEGORIES: Array<Pick<CategoryDto, 'name' | 'type'>> = [
|
||||
{ name: '餐饮', type: 'expense' },
|
||||
{ name: '交通', type: 'expense' },
|
||||
{ name: '购物', type: 'expense' },
|
||||
{ name: '生活缴费', type: 'expense' },
|
||||
{ name: '医疗', type: 'expense' },
|
||||
{ name: '教育', type: 'expense' },
|
||||
{ name: '娱乐', type: 'expense' },
|
||||
{ name: '转账', type: 'expense' },
|
||||
{ name: '退款', type: 'income' },
|
||||
{ name: '理财', type: 'expense' },
|
||||
{ name: '公益', type: 'expense' },
|
||||
{ name: '收入', type: 'income' },
|
||||
{ name: '其他支出', type: 'expense' },
|
||||
{ name: '未分类', type: 'expense' }
|
||||
];
|
||||
|
||||
export async function ensureDefaultCategories(): Promise<void> {
|
||||
const count = await CategoryModel.estimatedDocumentCount().exec();
|
||||
if (count > 0) return;
|
||||
await CategoryModel.insertMany(
|
||||
DEFAULT_CATEGORIES.map((c) => ({ name: c.name, type: c.type, enabled: true })),
|
||||
{ ordered: false }
|
||||
);
|
||||
}
|
||||
|
||||
export async function listCategories(): Promise<CategoryDto[]> {
|
||||
const docs = await CategoryModel.find({ enabled: true }).sort({ name: 1 }).lean().exec();
|
||||
return docs.map((d) => toDto(d as CategoryDocument & { _id: mongoose.Types.ObjectId }));
|
||||
}
|
||||
|
||||
export async function createCategory(payload: Omit<CategoryDto, 'id' | 'enabled'> & { enabled?: boolean }): Promise<CategoryDto> {
|
||||
const created = await CategoryModel.create({
|
||||
name: payload.name,
|
||||
type: payload.type,
|
||||
icon: payload.icon,
|
||||
color: payload.color,
|
||||
enabled: payload.enabled ?? true
|
||||
});
|
||||
const doc = (await CategoryModel.findById(created._id).lean().exec()) as CategoryDocument & {
|
||||
_id: mongoose.Types.ObjectId;
|
||||
};
|
||||
return toDto(doc);
|
||||
}
|
||||
|
||||
export async function updateCategory(
|
||||
id: string,
|
||||
payload: Partial<Omit<CategoryDto, 'id'>>
|
||||
): Promise<CategoryDto | null> {
|
||||
const doc = await CategoryModel.findByIdAndUpdate(
|
||||
id,
|
||||
{ $set: payload },
|
||||
{ new: true }
|
||||
)
|
||||
.lean<CategoryDocument & { _id: mongoose.Types.ObjectId }>()
|
||||
.exec();
|
||||
return doc ? toDto(doc) : null;
|
||||
}
|
||||
|
||||
export async function deleteCategory(id: string): Promise<boolean> {
|
||||
const res = await CategoryModel.findByIdAndDelete(id).exec();
|
||||
return res !== null;
|
||||
}
|
||||
|
||||
@@ -119,11 +119,16 @@ export const aiClassifyTransactionHandler = async (req: Request, res: Response)
|
||||
if (!ai || (!ai.category && !ai.type && !ai.amount)) {
|
||||
return res.status(200).json({ data: txn, message: 'No AI classification available' });
|
||||
}
|
||||
const updated = await updateTransaction(id, {
|
||||
category: ai.category ?? txn.category,
|
||||
type: (ai.type as any) ?? txn.type,
|
||||
amount: ai.amount ?? txn.amount
|
||||
} as UpdateTransactionInput, req.user.id);
|
||||
const updated = await updateTransaction(
|
||||
id,
|
||||
{
|
||||
category: ai.category ?? txn.category,
|
||||
type: (ai.type as any) ?? txn.type,
|
||||
amount: ai.amount ?? txn.amount,
|
||||
metadata: { ...(txn.metadata as any), classification: { source: 'ai', confidence: ai.confidence } }
|
||||
} as UpdateTransactionInput,
|
||||
req.user.id
|
||||
);
|
||||
return res.json({ data: updated });
|
||||
};
|
||||
|
||||
|
||||
@@ -190,11 +190,13 @@ export async function ingestNotification(payload: NotificationPayload): Promise<
|
||||
const parsed = parseNotificationPayload(payload.title ?? '', payload.body ?? '', mergedTemplate);
|
||||
|
||||
// Optionally enhance with AI classification
|
||||
let aiUsed: { confidence?: number } | null = null;
|
||||
if (env.AI_ENABLE_CLASSIFICATION && !parsed.category) {
|
||||
const ai = await aiClassifyByText(payload.title ?? '', payload.body ?? '');
|
||||
if (ai?.category) parsed.category = ai.category;
|
||||
if (ai?.type) parsed.type = ai.type;
|
||||
if (ai?.amount && (!parsed.amount || parsed.amount === 0)) parsed.amount = ai.amount;
|
||||
if (ai) aiUsed = { confidence: ai.confidence };
|
||||
}
|
||||
|
||||
const hashSource = `${payload.packageName}|${payload.title}|${payload.body}|${now.getTime()}`;
|
||||
@@ -209,6 +211,18 @@ export async function ingestNotification(payload: NotificationPayload): Promise<
|
||||
return toDto(existing);
|
||||
}
|
||||
|
||||
const baseMetadata = {
|
||||
packageName: payload.packageName,
|
||||
rawBody: payload.body,
|
||||
parser: parsed,
|
||||
channelId: channel._id.toString(),
|
||||
notificationHash
|
||||
} as Record<string, unknown>;
|
||||
|
||||
if (aiUsed) {
|
||||
(baseMetadata as any).classification = { source: 'ai', confidence: aiUsed.confidence };
|
||||
}
|
||||
|
||||
const transactionPayload: CreateTransactionInput = {
|
||||
title: payload.title,
|
||||
amount: parsed.amount ?? 0,
|
||||
@@ -219,13 +233,7 @@ export async function ingestNotification(payload: NotificationPayload): Promise<
|
||||
status: 'pending',
|
||||
occurredAt: now,
|
||||
notes: parsed.notes ?? payload.body,
|
||||
metadata: {
|
||||
packageName: payload.packageName,
|
||||
rawBody: payload.body,
|
||||
parser: parsed,
|
||||
channelId: channel._id.toString(),
|
||||
notificationHash
|
||||
}
|
||||
metadata: baseMetadata
|
||||
};
|
||||
|
||||
const userId = await getDefaultUserId();
|
||||
|
||||
@@ -5,6 +5,7 @@ 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';
|
||||
import categoriesRouter from '../modules/categories/categories.router.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -18,5 +19,6 @@ router.use('/analysis', analysisRouter);
|
||||
router.use('/budgets', budgetsRouter);
|
||||
router.use('/notifications', notificationsRouter);
|
||||
router.use('/notification-rules', notificationRulesRouter);
|
||||
router.use('/categories', categoriesRouter);
|
||||
|
||||
export { router };
|
||||
|
||||
Reference in New Issue
Block a user