feat:添加登录功能
This commit is contained in:
@@ -4,8 +4,8 @@ import { logger } from './logger.js';
|
||||
|
||||
export async function connectToDatabase(): Promise<void> {
|
||||
if (!env.MONGODB_URI) {
|
||||
logger.warn('Skipping MongoDB connection: MONGODB_URI is not set.');
|
||||
return;
|
||||
logger.error('Database connection failed: MONGODB_URI is not set.');
|
||||
throw new Error('MONGODB_URI must be provided to connect to MongoDB.');
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
42
apps/backend/src/middlewares/authenticate.ts
Normal file
42
apps/backend/src/middlewares/authenticate.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import mongoose from 'mongoose';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { env } from '../config/env.js';
|
||||
import { UserModel } from '../models/user.model.js';
|
||||
|
||||
export async function authenticate(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ message: 'Authentication required' });
|
||||
}
|
||||
|
||||
const token = authHeader.slice('Bearer '.length).trim();
|
||||
if (!token) {
|
||||
return res.status(401).json({ message: 'Authentication required' });
|
||||
}
|
||||
|
||||
const payload = jwt.verify(token, env.JWT_SECRET) as { sub: string; email?: string };
|
||||
const userId = payload.sub;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ message: 'Invalid access token' });
|
||||
}
|
||||
|
||||
const user = await UserModel.findById(userId)
|
||||
.select('_id email')
|
||||
.lean<{ _id: mongoose.Types.ObjectId; email: string }>()
|
||||
.exec();
|
||||
if (!user) {
|
||||
return res.status(401).json({ message: 'User not found' });
|
||||
}
|
||||
|
||||
req.user = {
|
||||
id: user._id.toString(),
|
||||
email: user.email
|
||||
};
|
||||
|
||||
return next();
|
||||
} catch (error) {
|
||||
return res.status(401).json({ message: 'Invalid or expired token' });
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ const budgetSchema = new mongoose.Schema(
|
||||
period: { type: String, enum: ['monthly', 'weekly'], default: 'monthly' },
|
||||
threshold: { type: Number, min: 0, max: 1, default: 0.8 },
|
||||
usage: { type: Number, min: 0, default: 0 },
|
||||
userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: false }
|
||||
userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true }
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
@@ -12,7 +12,7 @@ const transactionSchema = new mongoose.Schema(
|
||||
occurredAt: { type: Date, required: true },
|
||||
notes: { type: String },
|
||||
metadata: { type: mongoose.Schema.Types.Mixed },
|
||||
userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: false }
|
||||
userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true }
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
@@ -2,12 +2,18 @@ import type { Request, Response } from 'express';
|
||||
import { buildSpendingInsights, estimateCalories } from './analysis.service.js';
|
||||
|
||||
export const getSpendingInsights = async (req: Request, res: Response) => {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ message: 'Authentication required' });
|
||||
}
|
||||
const range = (req.query.range as '30d' | '90d') ?? '30d';
|
||||
const data = await buildSpendingInsights(range);
|
||||
const data = await buildSpendingInsights(range, req.user.id);
|
||||
return res.json(data);
|
||||
};
|
||||
|
||||
export const estimateCaloriesHandler = (req: Request, res: Response) => {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ message: 'Authentication required' });
|
||||
}
|
||||
const { query } = req.body as { query: string };
|
||||
const data = estimateCalories(query);
|
||||
return res.json(data);
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { Router } from 'express';
|
||||
import { authenticate } from '../../middlewares/authenticate.js';
|
||||
import { validateRequest } from '../../middlewares/validate-request.js';
|
||||
import { analysisQuerySchema, calorieRequestSchema } from './analysis.schema.js';
|
||||
import { getSpendingInsights, estimateCaloriesHandler } from './analysis.controller.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(authenticate);
|
||||
|
||||
router.get('/habits', validateRequest({ query: analysisQuerySchema }), getSpendingInsights);
|
||||
router.post('/calories', validateRequest({ body: calorieRequestSchema }), estimateCaloriesHandler);
|
||||
|
||||
|
||||
@@ -50,8 +50,11 @@ const buildRecommendations = (topCategories: CategoryInsight[], totalExpense: nu
|
||||
});
|
||||
};
|
||||
|
||||
export async function buildSpendingInsights(range: '30d' | '90d' = '30d'): Promise<SpendingInsightResult> {
|
||||
const transactions = await listTransactions();
|
||||
export async function buildSpendingInsights(
|
||||
range: '30d' | '90d' = '30d',
|
||||
userId: string
|
||||
): Promise<SpendingInsightResult> {
|
||||
const transactions = await listTransactions(userId);
|
||||
const now = Date.now();
|
||||
const windowDays = range === '30d' ? 30 : 90;
|
||||
const windowStart = now - windowDays * DAY_IN_MS;
|
||||
|
||||
@@ -3,7 +3,6 @@ import jwt from 'jsonwebtoken';
|
||||
import mongoose from 'mongoose';
|
||||
import { env } from '../../config/env.js';
|
||||
import { UserModel, type UserDocument } from '../../models/user.model.js';
|
||||
import { createId } from '../../utils/id.js';
|
||||
import { logger } from '../../config/logger.js';
|
||||
|
||||
const ACCESS_TOKEN_TTL = '15m';
|
||||
@@ -26,81 +25,38 @@ interface AuthTokens {
|
||||
refreshExpiresIn: number;
|
||||
}
|
||||
|
||||
const memoryUsers = new Map<string, AuthUser>();
|
||||
const refreshTokenStore = new Map<string, { userId: string; expiresAt: Date }>();
|
||||
|
||||
const isDatabaseReady = () => mongoose.connection.readyState === 1;
|
||||
type UserDocumentWithId = UserDocument & { _id: mongoose.Types.ObjectId };
|
||||
|
||||
const toAuthUser = (user: (UserDocument & { _id?: mongoose.Types.ObjectId }) | AuthUser): AuthUser => {
|
||||
if ('passwordHash' in user && 'id' in user) {
|
||||
return user as AuthUser;
|
||||
}
|
||||
const toAuthUser = (doc: UserDocumentWithId): AuthUser => ({
|
||||
id: doc._id.toString(),
|
||||
email: doc.email,
|
||||
displayName: doc.displayName,
|
||||
passwordHash: doc.passwordHash,
|
||||
preferredCurrency: doc.preferredCurrency,
|
||||
notificationPermissionGranted: doc.notificationPermissionGranted ?? false,
|
||||
avatarUrl: doc.avatarUrl ?? undefined
|
||||
});
|
||||
|
||||
const doc = user as UserDocument & { _id: mongoose.Types.ObjectId };
|
||||
return {
|
||||
id: doc._id.toString(),
|
||||
email: doc.email,
|
||||
displayName: doc.displayName,
|
||||
passwordHash: doc.passwordHash,
|
||||
preferredCurrency: doc.preferredCurrency,
|
||||
notificationPermissionGranted: doc.notificationPermissionGranted ?? false,
|
||||
avatarUrl: doc.avatarUrl ?? undefined
|
||||
};
|
||||
};
|
||||
const profileFromUser = (user: AuthUser) => ({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
displayName: user.displayName,
|
||||
avatarUrl: user.avatarUrl,
|
||||
preferredCurrency: user.preferredCurrency,
|
||||
notificationPermissionGranted: user.notificationPermissionGranted
|
||||
});
|
||||
|
||||
const seedUsers = async () => {
|
||||
if (memoryUsers.size > 0) return;
|
||||
const passwordHash = await bcrypt.hash('Password123!', 10);
|
||||
const user: AuthUser = {
|
||||
id: createId(),
|
||||
email: 'user@example.com',
|
||||
displayName: '示例用户',
|
||||
passwordHash,
|
||||
preferredCurrency: 'CNY',
|
||||
notificationPermissionGranted: true
|
||||
};
|
||||
async function getUserByEmail(email: string): Promise<AuthUser | null> {
|
||||
const doc = await UserModel.findOne({ email }).lean<UserDocumentWithId>().exec();
|
||||
return doc ? toAuthUser(doc) : null;
|
||||
}
|
||||
|
||||
memoryUsers.set(user.email, user);
|
||||
};
|
||||
|
||||
void seedUsers();
|
||||
|
||||
const getUserByEmail = async (email: string): Promise<AuthUser | null> => {
|
||||
if (isDatabaseReady()) {
|
||||
const doc = await UserModel.findOne({ email }).lean<UserDocument & { _id: mongoose.Types.ObjectId }>();
|
||||
return doc ? toAuthUser(doc) : null;
|
||||
}
|
||||
|
||||
return memoryUsers.get(email) ?? null;
|
||||
};
|
||||
|
||||
const saveUser = async (user: AuthUser): Promise<AuthUser> => {
|
||||
if (isDatabaseReady()) {
|
||||
const created = await UserModel.create({
|
||||
email: user.email,
|
||||
passwordHash: user.passwordHash,
|
||||
displayName: user.displayName,
|
||||
avatarUrl: user.avatarUrl,
|
||||
preferredCurrency: user.preferredCurrency,
|
||||
notificationPermissionGranted: user.notificationPermissionGranted
|
||||
});
|
||||
return toAuthUser(created);
|
||||
}
|
||||
|
||||
memoryUsers.set(user.email, user);
|
||||
return user;
|
||||
};
|
||||
|
||||
const updateUserPassword = async (email: string, passwordHash: string) => {
|
||||
if (isDatabaseReady()) {
|
||||
await UserModel.updateOne({ email }, { passwordHash });
|
||||
return;
|
||||
}
|
||||
const existing = memoryUsers.get(email);
|
||||
if (existing) {
|
||||
memoryUsers.set(email, { ...existing, passwordHash });
|
||||
}
|
||||
};
|
||||
async function getUserById(id: string): Promise<AuthUser | null> {
|
||||
const doc = await UserModel.findById(id).lean<UserDocumentWithId>().exec();
|
||||
return doc ? toAuthUser(doc) : null;
|
||||
}
|
||||
|
||||
const signTokens = (user: AuthUser): AuthTokens => {
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
@@ -134,15 +90,6 @@ const signTokens = (user: AuthUser): AuthTokens => {
|
||||
};
|
||||
};
|
||||
|
||||
const profileFromUser = (user: AuthUser) => ({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
displayName: user.displayName,
|
||||
avatarUrl: user.avatarUrl,
|
||||
preferredCurrency: user.preferredCurrency,
|
||||
notificationPermissionGranted: user.notificationPermissionGranted
|
||||
});
|
||||
|
||||
export async function registerUser(input: {
|
||||
email: string;
|
||||
password: string;
|
||||
@@ -155,18 +102,21 @@ export async function registerUser(input: {
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(input.password, 10);
|
||||
const user: AuthUser = {
|
||||
id: createId(),
|
||||
const created = await UserModel.create({
|
||||
email: input.email,
|
||||
passwordHash,
|
||||
displayName: input.displayName,
|
||||
preferredCurrency: input.preferredCurrency,
|
||||
notificationPermissionGranted: false,
|
||||
passwordHash
|
||||
};
|
||||
notificationPermissionGranted: false
|
||||
});
|
||||
const createdDoc = await UserModel.findById(created._id).lean<UserDocumentWithId>().exec();
|
||||
if (!createdDoc) {
|
||||
throw new Error('Failed to load created user');
|
||||
}
|
||||
|
||||
const saved = await saveUser(user);
|
||||
const tokens = signTokens(saved);
|
||||
return { user: profileFromUser(saved), tokens };
|
||||
const user = toAuthUser(createdDoc);
|
||||
const tokens = signTokens(user);
|
||||
return { user: profileFromUser(user), tokens };
|
||||
}
|
||||
|
||||
export async function authenticateUser(input: { email: string; password: string }) {
|
||||
@@ -194,15 +144,11 @@ export async function refreshSession(refreshToken: string) {
|
||||
throw new Error('Refresh token revoked');
|
||||
}
|
||||
|
||||
const userDoc = isDatabaseReady()
|
||||
? await UserModel.findById(payload.sub).lean<UserDocument & { _id: mongoose.Types.ObjectId }>()
|
||||
: [...memoryUsers.values()].find((user) => user.id === payload.sub) ?? null;
|
||||
|
||||
if (!userDoc) {
|
||||
const user = await getUserById(payload.sub);
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
const user = toAuthUser(userDoc as AuthUser);
|
||||
refreshTokenStore.delete(refreshToken);
|
||||
const tokens = signTokens(user);
|
||||
return { user: profileFromUser(user), tokens };
|
||||
@@ -224,6 +170,6 @@ export async function requestPasswordReset(email: string) {
|
||||
|
||||
const tempPassword = Math.random().toString(36).slice(-10);
|
||||
const passwordHash = await bcrypt.hash(tempPassword, 10);
|
||||
await updateUserPassword(email, passwordHash);
|
||||
await UserModel.updateOne({ email }, { passwordHash }).exec();
|
||||
logger.info({ email }, 'Password reset token generated (simulated)');
|
||||
}
|
||||
|
||||
@@ -2,20 +2,29 @@ import type { Request, Response } from 'express';
|
||||
import { listBudgets, createBudget, updateBudget, deleteBudget } from './budgets.service.js';
|
||||
import type { CreateBudgetInput, UpdateBudgetInput } from './budgets.schema.js';
|
||||
|
||||
export const listBudgetsHandler = async (_req: Request, res: Response) => {
|
||||
const data = await listBudgets();
|
||||
export const listBudgetsHandler = async (req: Request, res: Response) => {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ message: 'Authentication required' });
|
||||
}
|
||||
const data = await listBudgets(req.user.id);
|
||||
return res.json({ data });
|
||||
};
|
||||
|
||||
export const createBudgetHandler = async (req: Request, res: Response) => {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ message: 'Authentication required' });
|
||||
}
|
||||
const payload = req.body as CreateBudgetInput;
|
||||
const budget = await createBudget(payload);
|
||||
const budget = await createBudget(payload, req.user.id);
|
||||
return res.status(201).json({ data: budget });
|
||||
};
|
||||
|
||||
export const updateBudgetHandler = async (req: Request, res: Response) => {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ message: 'Authentication required' });
|
||||
}
|
||||
const payload = req.body as UpdateBudgetInput;
|
||||
const budget = await updateBudget(req.params.id, payload);
|
||||
const budget = await updateBudget(req.params.id, payload, req.user.id);
|
||||
if (!budget) {
|
||||
return res.status(404).json({ message: 'Budget not found' });
|
||||
}
|
||||
@@ -23,7 +32,10 @@ export const updateBudgetHandler = async (req: Request, res: Response) => {
|
||||
};
|
||||
|
||||
export const deleteBudgetHandler = async (req: Request, res: Response) => {
|
||||
const removed = await deleteBudget(req.params.id);
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ message: 'Authentication required' });
|
||||
}
|
||||
const removed = await deleteBudget(req.params.id, req.user.id);
|
||||
if (!removed) {
|
||||
return res.status(404).json({ message: 'Budget not found' });
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { authenticate } from '../../middlewares/authenticate.js';
|
||||
import { validateRequest } from '../../middlewares/validate-request.js';
|
||||
import { createBudgetSchema, updateBudgetSchema } from './budgets.schema.js';
|
||||
import { listBudgetsHandler, createBudgetHandler, updateBudgetHandler, deleteBudgetHandler } from './budgets.controller.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(authenticate);
|
||||
|
||||
router.get('/', listBudgetsHandler);
|
||||
router.post('/', validateRequest({ body: createBudgetSchema }), createBudgetHandler);
|
||||
router.patch('/:id', validateRequest({ params: z.object({ id: z.string().min(1) }), body: updateBudgetSchema }), updateBudgetHandler);
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { BudgetModel, type BudgetDocument } from '../../models/budget.model.js';
|
||||
import { createId } from '../../utils/id.js';
|
||||
import { getDefaultUserId } from '../../services/user-context.js';
|
||||
import type { CreateBudgetInput, UpdateBudgetInput } from './budgets.schema.js';
|
||||
|
||||
export interface BudgetDto {
|
||||
@@ -17,17 +15,11 @@ export interface BudgetDto {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface MemoryBudget extends BudgetDto {}
|
||||
|
||||
const memoryBudgets: MemoryBudget[] = [];
|
||||
|
||||
const isDatabaseReady = () => mongoose.connection.readyState === 1;
|
||||
|
||||
const toDto = (doc: BudgetDocument & { _id?: mongoose.Types.ObjectId } & { id?: string }): BudgetDto => {
|
||||
const id = typeof doc.id === 'string' ? doc.id : doc._id ? doc._id.toString() : createId();
|
||||
type BudgetDocumentWithId = BudgetDocument & { _id: mongoose.Types.ObjectId };
|
||||
|
||||
const toDto = (doc: BudgetDocumentWithId): BudgetDto => {
|
||||
return {
|
||||
id,
|
||||
id: doc._id.toString(),
|
||||
category: doc.category,
|
||||
amount: doc.amount,
|
||||
currency: doc.currency ?? 'CNY',
|
||||
@@ -40,120 +32,43 @@ const toDto = (doc: BudgetDocument & { _id?: mongoose.Types.ObjectId } & { id?:
|
||||
};
|
||||
};
|
||||
|
||||
const seedBudgets = () => {
|
||||
if (memoryBudgets.length > 0) return;
|
||||
const now = new Date().toISOString();
|
||||
memoryBudgets.push(
|
||||
{
|
||||
id: createId(),
|
||||
category: '餐饮',
|
||||
amount: 2000,
|
||||
currency: 'CNY',
|
||||
period: 'monthly',
|
||||
threshold: 0.8,
|
||||
usage: 1250,
|
||||
userId: 'demo-user',
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
category: '交通',
|
||||
amount: 600,
|
||||
currency: 'CNY',
|
||||
period: 'monthly',
|
||||
threshold: 0.75,
|
||||
usage: 320,
|
||||
userId: 'demo-user',
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
seedBudgets();
|
||||
|
||||
export async function listBudgets(): Promise<BudgetDto[]> {
|
||||
if (isDatabaseReady()) {
|
||||
const userId = await getDefaultUserId();
|
||||
const docs = await BudgetModel.find({ userId }).lean().exec();
|
||||
return (docs as Array<BudgetDocument & { _id: mongoose.Types.ObjectId }>).map(toDto);
|
||||
}
|
||||
|
||||
return [...memoryBudgets].sort((a, b) => a.category.localeCompare(b.category));
|
||||
export async function listBudgets(userId: string): Promise<BudgetDto[]> {
|
||||
const docs = await BudgetModel.find({ userId }).sort({ createdAt: 1 }).lean<BudgetDocumentWithId[]>().exec();
|
||||
return docs.map(toDto);
|
||||
}
|
||||
|
||||
export async function createBudget(payload: CreateBudgetInput): Promise<BudgetDto> {
|
||||
export async function createBudget(payload: CreateBudgetInput, userId: string): Promise<BudgetDto> {
|
||||
const defaults = { currency: payload.currency ?? 'CNY', usage: payload.usage ?? 0 };
|
||||
const { userId: _ignoredUserId, ...rest } = payload;
|
||||
|
||||
if (isDatabaseReady()) {
|
||||
const userId = payload.userId ?? (await getDefaultUserId());
|
||||
const created = await BudgetModel.create({ ...payload, ...defaults, userId });
|
||||
return toDto(created);
|
||||
const created = await BudgetModel.create({
|
||||
...rest,
|
||||
...defaults,
|
||||
userId
|
||||
});
|
||||
const createdDoc = await BudgetModel.findById(created._id).lean<BudgetDocumentWithId>().exec();
|
||||
if (!createdDoc) {
|
||||
throw new Error('Failed to load created budget');
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const budget: MemoryBudget = {
|
||||
id: createId(),
|
||||
category: payload.category,
|
||||
amount: payload.amount,
|
||||
currency: defaults.currency,
|
||||
period: payload.period,
|
||||
threshold: payload.threshold,
|
||||
usage: defaults.usage ?? 0,
|
||||
userId: payload.userId,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
};
|
||||
|
||||
memoryBudgets.push(budget);
|
||||
return budget;
|
||||
return toDto(createdDoc);
|
||||
}
|
||||
|
||||
export async function updateBudget(id: string, payload: UpdateBudgetInput): Promise<BudgetDto | null> {
|
||||
if (isDatabaseReady()) {
|
||||
const userId = await getDefaultUserId();
|
||||
const updated = await BudgetModel.findOneAndUpdate(
|
||||
{ _id: id, userId },
|
||||
{ ...payload },
|
||||
{ new: true }
|
||||
);
|
||||
return updated ? toDto(updated) : null;
|
||||
}
|
||||
|
||||
const index = memoryBudgets.findIndex((budget) => budget.id === id);
|
||||
if (index === -1) {
|
||||
export async function updateBudget(id: string, payload: UpdateBudgetInput, userId: string): Promise<BudgetDto | null> {
|
||||
const updated = await BudgetModel.findOneAndUpdate(
|
||||
{ _id: id, userId },
|
||||
{ ...payload },
|
||||
{ new: true }
|
||||
)
|
||||
.lean<BudgetDocumentWithId>()
|
||||
.exec();
|
||||
if (!updated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const existing = memoryBudgets[index];
|
||||
const updated: MemoryBudget = {
|
||||
...existing,
|
||||
...payload,
|
||||
amount: payload.amount ?? existing.amount,
|
||||
usage: payload.usage ?? existing.usage,
|
||||
threshold: payload.threshold ?? existing.threshold,
|
||||
period: payload.period ?? existing.period,
|
||||
currency: payload.currency ?? existing.currency,
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
memoryBudgets[index] = updated;
|
||||
return updated;
|
||||
return toDto(updated);
|
||||
}
|
||||
|
||||
export async function deleteBudget(id: string): Promise<boolean> {
|
||||
if (isDatabaseReady()) {
|
||||
const userId = await getDefaultUserId();
|
||||
const result = await BudgetModel.findOneAndDelete({ _id: id, userId });
|
||||
return result !== null;
|
||||
}
|
||||
|
||||
const index = memoryBudgets.findIndex((budget) => budget.id === id);
|
||||
if (index === -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
memoryBudgets.splice(index, 1);
|
||||
return true;
|
||||
export async function deleteBudget(id: string, userId: string): Promise<boolean> {
|
||||
const result = await BudgetModel.findOneAndDelete({ _id: id, userId }).exec();
|
||||
return result !== null;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { env } from '../../config/env.js';
|
||||
import { TransactionModel } from '../../models/transaction.model.js';
|
||||
import { getDefaultUserId } from '../../services/user-context.js';
|
||||
|
||||
const maskSecret = (secret?: string | null) => {
|
||||
if (!secret) return null;
|
||||
@@ -10,8 +9,10 @@ const maskSecret = (secret?: string | null) => {
|
||||
};
|
||||
|
||||
export const getNotificationStatus = async (req: Request, res: Response) => {
|
||||
const userId = await getDefaultUserId();
|
||||
const filter = { userId, source: 'notification' as const };
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ message: 'Authentication required' });
|
||||
}
|
||||
const filter = { userId: req.user.id, source: 'notification' as const };
|
||||
const count = await TransactionModel.countDocuments(filter);
|
||||
const latest = (await TransactionModel.findOne(filter).sort({ createdAt: -1 }).lean()) as
|
||||
| { createdAt?: Date }
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { Router } from 'express';
|
||||
import { authenticate } from '../../middlewares/authenticate.js';
|
||||
import { getNotificationStatus } from './notifications.controller.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(authenticate);
|
||||
|
||||
router.get('/status', getNotificationStatus);
|
||||
|
||||
export default router;
|
||||
|
||||
71
apps/backend/src/modules/transactions/notification.parser.ts
Normal file
71
apps/backend/src/modules/transactions/notification.parser.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { classifyByText, extractAmountFromText } from '../../services/classification.service.js';
|
||||
import type { TransactionDto } from './transactions.service.js';
|
||||
|
||||
export interface ParsedNotification {
|
||||
amount?: number;
|
||||
type?: 'income' | 'expense';
|
||||
category?: string;
|
||||
notes?: string;
|
||||
ruleId?: string;
|
||||
confidence?: number;
|
||||
}
|
||||
|
||||
export function parseNotificationPayload(title: string, body: string): ParsedNotification {
|
||||
const combined = `${title}|${body}`;
|
||||
|
||||
const parsed: ParsedNotification = {
|
||||
notes: body
|
||||
};
|
||||
|
||||
const classification = classifyByText(title, body);
|
||||
if (classification.category) {
|
||||
parsed.category = classification.category;
|
||||
}
|
||||
if (classification.type) {
|
||||
parsed.type = classification.type;
|
||||
}
|
||||
parsed.ruleId = classification.ruleId;
|
||||
parsed.confidence = classification.confidence;
|
||||
|
||||
const amount = extractAmountFromText(title, body);
|
||||
if (amount !== undefined) {
|
||||
parsed.amount = amount;
|
||||
}
|
||||
|
||||
if (!parsed.type) {
|
||||
if (combined.includes('到账') || combined.includes('收入') || combined.includes('入账')) {
|
||||
parsed.type = 'income';
|
||||
} else if (combined.includes('付款') || combined.includes('消费') || combined.includes('支出') || combined.includes('已扣款')) {
|
||||
parsed.type = 'expense';
|
||||
}
|
||||
}
|
||||
|
||||
if (!parsed.category && parsed.type === 'income') {
|
||||
parsed.category = '收入';
|
||||
} else if (!parsed.category && parsed.type === 'expense') {
|
||||
parsed.category = '其他支出';
|
||||
}
|
||||
|
||||
if (!parsed.type) {
|
||||
parsed.type = parsed.category === '收入' ? 'income' : 'expense';
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function mergeParsedNotification(transaction: TransactionDto, parsed: ParsedNotification): Partial<TransactionDto> {
|
||||
const updates: Partial<TransactionDto> = {};
|
||||
if (parsed.amount !== undefined) {
|
||||
updates.amount = parsed.amount;
|
||||
}
|
||||
if (parsed.category) {
|
||||
updates.category = parsed.category;
|
||||
}
|
||||
if (parsed.type) {
|
||||
updates.type = parsed.type;
|
||||
}
|
||||
if (parsed.notes) {
|
||||
updates.notes = parsed.notes;
|
||||
}
|
||||
return updates;
|
||||
}
|
||||
@@ -12,19 +12,28 @@ import {
|
||||
} from './transactions.service.js';
|
||||
import type { CreateTransactionInput, UpdateTransactionInput } from './transactions.schema.js';
|
||||
|
||||
export const listTransactionsHandler = async (_req: Request, res: Response) => {
|
||||
const data = await listTransactions();
|
||||
export const listTransactionsHandler = async (req: Request, res: Response) => {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ message: 'Authentication required' });
|
||||
}
|
||||
const data = await listTransactions(req.user.id);
|
||||
return res.json({ data });
|
||||
};
|
||||
|
||||
export const createTransactionHandler = async (req: Request, res: Response) => {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ message: 'Authentication required' });
|
||||
}
|
||||
const payload = req.body as CreateTransactionInput;
|
||||
const transaction = await createTransaction(payload);
|
||||
const transaction = await createTransaction(payload, req.user.id);
|
||||
return res.status(201).json({ data: transaction });
|
||||
};
|
||||
|
||||
export const getTransactionHandler = async (req: Request, res: Response) => {
|
||||
const transaction = await getTransactionById(req.params.id);
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ message: 'Authentication required' });
|
||||
}
|
||||
const transaction = await getTransactionById(req.params.id, req.user.id);
|
||||
if (!transaction) {
|
||||
return res.status(404).json({ message: 'Transaction not found' });
|
||||
}
|
||||
@@ -32,8 +41,11 @@ export const getTransactionHandler = async (req: Request, res: Response) => {
|
||||
};
|
||||
|
||||
export const updateTransactionHandler = async (req: Request, res: Response) => {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ message: 'Authentication required' });
|
||||
}
|
||||
const payload = req.body as UpdateTransactionInput;
|
||||
const transaction = await updateTransaction(req.params.id, payload);
|
||||
const transaction = await updateTransaction(req.params.id, payload, req.user.id);
|
||||
if (!transaction) {
|
||||
return res.status(404).json({ message: 'Transaction not found' });
|
||||
}
|
||||
@@ -41,7 +53,10 @@ export const updateTransactionHandler = async (req: Request, res: Response) => {
|
||||
};
|
||||
|
||||
export const deleteTransactionHandler = async (req: Request, res: Response) => {
|
||||
const removed = await deleteTransaction(req.params.id);
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ message: 'Authentication required' });
|
||||
}
|
||||
const removed = await deleteTransaction(req.params.id, req.user.id);
|
||||
if (!removed) {
|
||||
return res.status(404).json({ message: 'Transaction not found' });
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Router } from 'express';
|
||||
import { authenticate } from '../../middlewares/authenticate.js';
|
||||
import { validateRequest } from '../../middlewares/validate-request.js';
|
||||
import {
|
||||
createTransactionSchema,
|
||||
@@ -17,11 +18,14 @@ import {
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.post('/notification', validateRequest({ body: notificationSchema }), createTransactionFromNotificationHandler);
|
||||
|
||||
router.use(authenticate);
|
||||
|
||||
router.get('/', listTransactionsHandler);
|
||||
router.post('/', validateRequest({ body: createTransactionSchema }), createTransactionHandler);
|
||||
router.get('/:id', validateRequest({ params: transactionIdSchema }), getTransactionHandler);
|
||||
router.patch('/:id', validateRequest({ params: transactionIdSchema, body: updateTransactionSchema }), updateTransactionHandler);
|
||||
router.delete('/:id', validateRequest({ params: transactionIdSchema }), deleteTransactionHandler);
|
||||
router.post('/notification', validateRequest({ body: notificationSchema }), createTransactionFromNotificationHandler);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { TransactionModel, type TransactionDocument } from '../../models/transaction.model.js';
|
||||
import { createId } from '../../utils/id.js';
|
||||
import { logger } from '../../config/logger.js';
|
||||
import { classifyByText } from '../../services/classification.service.js';
|
||||
import { getDefaultUserId } from '../../services/user-context.js';
|
||||
import type { CreateTransactionInput, UpdateTransactionInput } from './transactions.schema.js';
|
||||
import { parseNotificationPayload } from './notification.parser.js';
|
||||
|
||||
export interface TransactionDto {
|
||||
id: string;
|
||||
@@ -22,17 +22,11 @@ export interface TransactionDto {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface MemoryTransaction extends TransactionDto {}
|
||||
|
||||
const memoryTransactions: MemoryTransaction[] = [];
|
||||
|
||||
const isDatabaseReady = () => mongoose.connection.readyState === 1;
|
||||
|
||||
const toDto = (doc: TransactionDocument & { _id?: mongoose.Types.ObjectId } & { id?: string }): TransactionDto => {
|
||||
const rawId = typeof doc.id === 'string' ? doc.id : doc._id ? doc._id.toString() : createId();
|
||||
type TransactionDocumentWithId = TransactionDocument & { _id: mongoose.Types.ObjectId };
|
||||
|
||||
const toDto = (doc: TransactionDocumentWithId): TransactionDto => {
|
||||
return {
|
||||
id: rawId,
|
||||
id: doc._id.toString(),
|
||||
title: doc.title,
|
||||
amount: doc.amount,
|
||||
currency: doc.currency ?? 'CNY',
|
||||
@@ -49,171 +43,95 @@ const toDto = (doc: TransactionDocument & { _id?: mongoose.Types.ObjectId } & {
|
||||
};
|
||||
};
|
||||
|
||||
const seedMemoryTransactions = () => {
|
||||
if (memoryTransactions.length > 0) return;
|
||||
const now = new Date();
|
||||
memoryTransactions.push(
|
||||
{
|
||||
id: createId(),
|
||||
title: '星巴克咖啡',
|
||||
amount: 32.5,
|
||||
currency: 'CNY',
|
||||
category: '报销',
|
||||
type: 'expense',
|
||||
source: 'notification',
|
||||
status: 'confirmed',
|
||||
occurredAt: now.toISOString(),
|
||||
notes: '餐饮 | 早餐',
|
||||
metadata: { packageName: 'com.eg.android.AlipayGphone' },
|
||||
userId: 'demo-user',
|
||||
createdAt: now.toISOString(),
|
||||
updatedAt: now.toISOString()
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
title: '地铁充值',
|
||||
amount: 100,
|
||||
currency: 'CNY',
|
||||
category: '交通',
|
||||
type: 'expense',
|
||||
source: 'manual',
|
||||
status: 'confirmed',
|
||||
occurredAt: new Date(now.getTime() - 86_400_000).toISOString(),
|
||||
userId: 'demo-user',
|
||||
createdAt: now.toISOString(),
|
||||
updatedAt: now.toISOString()
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
title: '午餐报销',
|
||||
amount: 58,
|
||||
currency: 'CNY',
|
||||
category: '餐饮',
|
||||
type: 'income',
|
||||
source: 'ocr',
|
||||
status: 'pending',
|
||||
occurredAt: new Date(now.getTime() - 172_800_000).toISOString(),
|
||||
notes: '审批中',
|
||||
userId: 'demo-user',
|
||||
createdAt: now.toISOString(),
|
||||
updatedAt: now.toISOString()
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
seedMemoryTransactions();
|
||||
|
||||
export async function listTransactions(): Promise<TransactionDto[]> {
|
||||
if (isDatabaseReady()) {
|
||||
const userId = await getDefaultUserId();
|
||||
const docs = await TransactionModel.find({ userId })
|
||||
.sort({ occurredAt: -1 })
|
||||
.lean()
|
||||
.exec();
|
||||
return (docs as Array<TransactionDocument & { _id: mongoose.Types.ObjectId }>).map(toDto);
|
||||
}
|
||||
return memoryTransactions.slice().sort((a, b) => (a.occurredAt < b.occurredAt ? 1 : -1));
|
||||
export async function listTransactions(userId: string): Promise<TransactionDto[]> {
|
||||
const docs = await TransactionModel.find({ userId })
|
||||
.sort({ occurredAt: -1 })
|
||||
.lean<TransactionDocumentWithId[]>()
|
||||
.exec();
|
||||
return docs.map(toDto);
|
||||
}
|
||||
|
||||
export async function getTransactionById(id: string): Promise<TransactionDto | null> {
|
||||
if (isDatabaseReady()) {
|
||||
const userId = await getDefaultUserId();
|
||||
const doc = await TransactionModel.findOne({ _id: id, userId }).lean().exec();
|
||||
return doc ? toDto(doc as TransactionDocument & { _id: mongoose.Types.ObjectId }) : null;
|
||||
}
|
||||
return memoryTransactions.find((txn) => txn.id === id) ?? null;
|
||||
export async function getTransactionById(id: string, userId: string): Promise<TransactionDto | null> {
|
||||
const doc = await TransactionModel.findOne({ _id: id, userId }).lean<TransactionDocumentWithId>().exec();
|
||||
return doc ? toDto(doc) : null;
|
||||
}
|
||||
|
||||
export async function createTransaction(payload: CreateTransactionInput): Promise<TransactionDto> {
|
||||
export async function createTransaction(payload: CreateTransactionInput, userId?: string): Promise<TransactionDto> {
|
||||
const normalizedAmount = Math.abs(payload.amount);
|
||||
const occuredDate = payload.occurredAt instanceof Date ? payload.occurredAt : new Date(payload.occurredAt);
|
||||
const needsAutoCategory =
|
||||
!payload.category || ['未分类', '待分类', '待分類', '类别待定', '待确认'].includes(payload.category);
|
||||
const classification = needsAutoCategory ? classifyByText(payload.title, payload.notes) : null;
|
||||
|
||||
if (isDatabaseReady()) {
|
||||
const userId = payload.userId ?? (await getDefaultUserId());
|
||||
const created = await TransactionModel.create({
|
||||
...payload,
|
||||
source: payload.source ?? 'manual',
|
||||
currency: payload.currency ?? 'CNY',
|
||||
amount: normalizedAmount,
|
||||
occurredAt: occuredDate,
|
||||
userId
|
||||
});
|
||||
return toDto(created);
|
||||
}
|
||||
const resolvedCategory = classification?.category ?? payload.category ?? (classification?.type === 'income' ? '收入' : '其他支出');
|
||||
const resolvedType = classification?.type ?? payload.type;
|
||||
const metadata =
|
||||
classification && classification.ruleId
|
||||
? {
|
||||
...payload.metadata,
|
||||
classification: {
|
||||
ruleId: classification.ruleId,
|
||||
confidence: classification.confidence
|
||||
}
|
||||
}
|
||||
: payload.metadata;
|
||||
|
||||
const now = new Date();
|
||||
const transaction: MemoryTransaction = {
|
||||
id: createId(),
|
||||
title: payload.title,
|
||||
amount: normalizedAmount,
|
||||
currency: payload.currency ?? 'CNY',
|
||||
category: payload.category,
|
||||
type: payload.type,
|
||||
const resolvedUserId = userId ?? payload.userId ?? (await getDefaultUserId());
|
||||
const { userId: _ignoredUserId, ...restPayload } = payload;
|
||||
const created = await TransactionModel.create({
|
||||
...restPayload,
|
||||
category: resolvedCategory,
|
||||
type: resolvedType,
|
||||
source: payload.source ?? 'manual',
|
||||
status: payload.status,
|
||||
occurredAt: occuredDate.toISOString(),
|
||||
notes: payload.notes,
|
||||
metadata: payload.metadata,
|
||||
userId: payload.userId,
|
||||
createdAt: now.toISOString(),
|
||||
updatedAt: now.toISOString()
|
||||
};
|
||||
|
||||
memoryTransactions.unshift(transaction);
|
||||
logger.debug({ id: transaction.id }, 'Transaction created (memory)');
|
||||
|
||||
return transaction;
|
||||
currency: payload.currency ?? 'CNY',
|
||||
amount: normalizedAmount,
|
||||
occurredAt: occuredDate,
|
||||
metadata,
|
||||
userId: resolvedUserId
|
||||
});
|
||||
const createdDoc = await TransactionModel.findById(created._id).lean<TransactionDocumentWithId>().exec();
|
||||
if (!createdDoc) {
|
||||
throw new Error('Failed to load created transaction');
|
||||
}
|
||||
return toDto(createdDoc);
|
||||
}
|
||||
|
||||
export async function updateTransaction(id: string, payload: UpdateTransactionInput): Promise<TransactionDto | null> {
|
||||
if (isDatabaseReady()) {
|
||||
const userId = await getDefaultUserId();
|
||||
const updated = await TransactionModel.findOneAndUpdate(
|
||||
{ _id: id, userId },
|
||||
{
|
||||
...payload,
|
||||
currency: payload.currency ?? undefined,
|
||||
source: payload.source ?? undefined,
|
||||
amount: payload.amount !== undefined ? Math.abs(payload.amount) : undefined,
|
||||
occurredAt: payload.occurredAt ? new Date(payload.occurredAt) : undefined
|
||||
},
|
||||
{ new: true }
|
||||
);
|
||||
return updated ? toDto(updated) : null;
|
||||
}
|
||||
export async function updateTransaction(
|
||||
id: string,
|
||||
payload: UpdateTransactionInput,
|
||||
userId: string
|
||||
): Promise<TransactionDto | null> {
|
||||
const { userId: _ignoredUserId, ...restPayload } = payload;
|
||||
|
||||
const index = memoryTransactions.findIndex((txn) => txn.id === id);
|
||||
if (index === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const existing = memoryTransactions[index];
|
||||
const updated: MemoryTransaction = {
|
||||
...existing,
|
||||
...payload,
|
||||
amount: payload.amount !== undefined ? Math.abs(payload.amount) : existing.amount,
|
||||
occurredAt: payload.occurredAt ? new Date(payload.occurredAt).toISOString() : existing.occurredAt,
|
||||
updatedAt: new Date().toISOString()
|
||||
const updatePayload: Partial<TransactionDocument> = {
|
||||
...restPayload,
|
||||
currency: restPayload.currency ?? undefined,
|
||||
source: restPayload.source ?? undefined
|
||||
};
|
||||
|
||||
memoryTransactions[index] = updated;
|
||||
return updated;
|
||||
if (restPayload.amount !== undefined) {
|
||||
updatePayload.amount = Math.abs(restPayload.amount);
|
||||
}
|
||||
|
||||
if (restPayload.occurredAt) {
|
||||
updatePayload.occurredAt =
|
||||
restPayload.occurredAt instanceof Date ? restPayload.occurredAt : new Date(restPayload.occurredAt);
|
||||
}
|
||||
|
||||
const updated = await TransactionModel.findOneAndUpdate(
|
||||
{ _id: id, userId },
|
||||
updatePayload,
|
||||
{ new: true }
|
||||
)
|
||||
.lean<TransactionDocumentWithId>()
|
||||
.exec();
|
||||
|
||||
return updated ? toDto(updated) : null;
|
||||
}
|
||||
|
||||
export async function deleteTransaction(id: string): Promise<boolean> {
|
||||
if (isDatabaseReady()) {
|
||||
const userId = await getDefaultUserId();
|
||||
const result = await TransactionModel.findOneAndDelete({ _id: id, userId });
|
||||
return result !== null;
|
||||
}
|
||||
|
||||
const index = memoryTransactions.findIndex((txn) => txn.id === id);
|
||||
if (index === -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
memoryTransactions.splice(index, 1);
|
||||
return true;
|
||||
export async function deleteTransaction(id: string, userId: string): Promise<boolean> {
|
||||
const result = await TransactionModel.findOneAndDelete({ _id: id, userId }).exec();
|
||||
return result !== null;
|
||||
}
|
||||
|
||||
interface NotificationPayload {
|
||||
@@ -225,16 +143,25 @@ interface NotificationPayload {
|
||||
|
||||
export async function ingestNotification(payload: NotificationPayload): Promise<TransactionDto> {
|
||||
const now = payload.receivedAt;
|
||||
return createTransaction({
|
||||
const parsed = parseNotificationPayload(payload.title ?? '', payload.body ?? '');
|
||||
|
||||
const transactionPayload: CreateTransactionInput = {
|
||||
title: payload.title,
|
||||
amount: 0,
|
||||
amount: parsed.amount ?? 0,
|
||||
currency: 'CNY',
|
||||
category: '待分类',
|
||||
type: 'expense',
|
||||
category: parsed.category ?? '待分类',
|
||||
type: parsed.type ?? 'expense',
|
||||
source: 'notification',
|
||||
status: 'pending',
|
||||
occurredAt: now,
|
||||
notes: payload.body,
|
||||
metadata: { packageName: payload.packageName }
|
||||
});
|
||||
notes: parsed.notes ?? payload.body,
|
||||
metadata: {
|
||||
packageName: payload.packageName,
|
||||
rawBody: payload.body,
|
||||
parser: parsed
|
||||
}
|
||||
};
|
||||
|
||||
const userId = await getDefaultUserId();
|
||||
return createTransaction(transactionPayload, userId);
|
||||
}
|
||||
|
||||
94
apps/backend/src/services/classification.service.ts
Normal file
94
apps/backend/src/services/classification.service.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
interface ClassificationRule {
|
||||
id: string;
|
||||
pattern: RegExp;
|
||||
category: string;
|
||||
type: 'income' | 'expense';
|
||||
priority: number;
|
||||
fallbackCategory?: string;
|
||||
}
|
||||
|
||||
export interface ClassificationResult {
|
||||
category?: string;
|
||||
type?: 'income' | 'expense';
|
||||
ruleId?: string;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
const CURRENCY_SYMBOLS = ['¥', '¥', '元', '块', '人民币', 'rmb', 'cny', 'usd', '$'];
|
||||
|
||||
const classificationRules: ClassificationRule[] = [
|
||||
{ id: 'income-salary', pattern: /(工资|薪资|发放|到账|转入|收入|结算)/i, category: '收入', type: 'income', priority: 95 },
|
||||
{ id: 'income-reimbursement', pattern: /(报销|退税|补贴|退款成功)/i, category: '报销', type: 'income', priority: 92 },
|
||||
{ id: 'food-coffee', pattern: /(星巴克|瑞幸|咖啡|coffee|餐饮|午餐|早餐|美团|饿了么|必胜客|肯德基|kfc|麦当劳)/i, category: '餐饮', type: 'expense', priority: 90 },
|
||||
{ id: 'transport', pattern: /(地铁|公交|交通|打车|滴滴|高德|出行|出租|单车|骑行|12306|铁路|航班|航空)/i, category: '交通', type: 'expense', priority: 88 },
|
||||
{ id: 'shopping', pattern: /(淘宝|天猫|京东|拼多多|超市|购物|商城|消费|唯品会|苏宁|抖音商城)/i, category: '购物', type: 'expense', priority: 86 },
|
||||
{ id: 'housing-bills', pattern: /(房租|租金|水费|电费|燃气|煤气|物业|宽带|话费|缴费|电信|联通|移动|固话)/i, category: '生活缴费', type: 'expense', priority: 84 },
|
||||
{ id: 'transfer-out', pattern: /(转出|转账|扣款|支付成功|扣费|已支付)/i, category: '转账', type: 'expense', priority: 82, fallbackCategory: '其他支出' },
|
||||
{ id: 'health', pattern: /(医院|医疗|挂号|药店|体检|医保|诊所|药房)/i, category: '医疗', type: 'expense', priority: 80 },
|
||||
{ id: 'education', pattern: /(学费|培训|教育|课程|考试|教材|网课)/i, category: '教育', type: 'expense', priority: 78 },
|
||||
{ id: 'entertainment', pattern: /(会员|视频|音乐|游戏|腾讯视频|爱奇艺|优酷|哔哩哔哩|bilibili|网易云|spotify|netflix|影院|电影)/i, category: '娱乐', type: 'expense', priority: 76 },
|
||||
{ id: 'transfer-in', pattern: /(退款|退回|退货|返还|返现)/i, category: '退款', type: 'income', priority: 74, fallbackCategory: '收入' },
|
||||
{ id: 'investment', pattern: /(基金|股票|理财|收益|红利|分红|申购|赎回)/i, category: '理财', type: 'expense', priority: 72 },
|
||||
{ id: 'charity', pattern: /(捐赠|公益|慈善)/i, category: '公益', type: 'expense', priority: 70 }
|
||||
];
|
||||
|
||||
const sortedRules = [...classificationRules].sort((a, b) => b.priority - a.priority);
|
||||
|
||||
const normalizeText = (title: string, body?: string) => {
|
||||
const merged = `${title ?? ''} ${body ?? ''}`;
|
||||
return merged.replace(/\s+/g, ' ').trim();
|
||||
};
|
||||
|
||||
export function classifyByText(title: string, body?: string): ClassificationResult {
|
||||
const normalized = normalizeText(title, body);
|
||||
if (!normalized) {
|
||||
return { confidence: 0.1 };
|
||||
}
|
||||
|
||||
for (const rule of sortedRules) {
|
||||
if (rule.pattern.test(normalized)) {
|
||||
return {
|
||||
category: rule.category,
|
||||
type: rule.type,
|
||||
ruleId: rule.id,
|
||||
confidence: rule.priority / 100
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (/收(款|到)|到账/.test(normalized)) {
|
||||
return { category: '收入', type: 'income', confidence: 0.6, ruleId: 'fallback-income' };
|
||||
}
|
||||
|
||||
if (/付款|支出|扣款|消费/.test(normalized)) {
|
||||
return { category: '其他支出', type: 'expense', confidence: 0.5, ruleId: 'fallback-expense' };
|
||||
}
|
||||
|
||||
return { confidence: 0.1 };
|
||||
}
|
||||
|
||||
export function extractAmountFromText(title: string, body?: string): number | undefined {
|
||||
const normalized = normalizeText(title, body).toLowerCase();
|
||||
const sanitized = normalized.replace(/[,,。]/g, ' ');
|
||||
|
||||
const amountPatterns = [
|
||||
/(?:¥|¥|\$|人民币|rmb|cny)\s*([-+]?\d+(?:\.\d{1,2})?)/i,
|
||||
/([-+]?\d+(?:\.\d{1,2})?)\s*(?:元|块|圆)/i,
|
||||
/金额[::]?\s*([-+]?\d+(?:\.\d{1,2})?)/i,
|
||||
/([-+]?\d+(?:\.\d{1,2})?)(?:\s*(?:元|块|圆))?/i
|
||||
];
|
||||
|
||||
for (const pattern of amountPatterns) {
|
||||
const match = sanitized.match(pattern);
|
||||
if (match) {
|
||||
const raw = match[1];
|
||||
const cleaned = CURRENCY_SYMBOLS.reduce((acc, symbol) => acc.replace(symbol, ''), raw);
|
||||
const parsed = Number.parseFloat(cleaned);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
return Math.abs(parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
12
apps/backend/src/types/express.d.ts
vendored
Normal file
12
apps/backend/src/types/express.d.ts
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
user?: {
|
||||
id: string;
|
||||
email: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
Reference in New Issue
Block a user