first commit
This commit is contained in:
13
apps/backend/.env.example
Normal file
13
apps/backend/.env.example
Normal file
@@ -0,0 +1,13 @@
|
||||
NODE_ENV=development
|
||||
PORT=4000
|
||||
HOST=0.0.0.0
|
||||
MONGODB_URI=mongodb://127.0.0.1:27017/ai-bill
|
||||
MONGODB_DB=ai-bill
|
||||
LOG_LEVEL=debug
|
||||
JWT_SECRET=dev-secret-change-me
|
||||
JWT_REFRESH_SECRET=dev-refresh-secret-change-me
|
||||
NOTIFICATION_WEBHOOK_SECRET=dev-webhook-secret
|
||||
NOTIFICATION_ALLOWED_PACKAGES=com.eg.android.AlipayGphone,com.tencent.mm
|
||||
GEMINI_API_KEY=
|
||||
OCR_PROJECT_ID=
|
||||
FILE_STORAGE_BUCKET=
|
||||
19
apps/backend/.eslintrc.cjs
Normal file
19
apps/backend/.eslintrc.cjs
Normal file
@@ -0,0 +1,19 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
node: true,
|
||||
es2022: true
|
||||
},
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
project: ['./tsconfig.json']
|
||||
},
|
||||
plugins: ['@typescript-eslint'],
|
||||
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'],
|
||||
rules: {
|
||||
'@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }],
|
||||
'@typescript-eslint/no-misused-promises': ['error', { checksVoidReturn: false }]
|
||||
}
|
||||
};
|
||||
44
apps/backend/package.json
Normal file
44
apps/backend/package.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch --env-file .env.local src/main.ts",
|
||||
"start": "node dist/main.js",
|
||||
"build": "tsc --project tsconfig.json",
|
||||
"lint": "eslint --ext .ts src",
|
||||
"seed": "tsx --env-file .env.local src/scripts/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.19.2",
|
||||
"express-async-errors": "^3.1.1",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"helmet": "^7.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mongoose": "^7.6.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"pino": "^9.5.0",
|
||||
"pino-http": "^11.0.0",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/express-rate-limit": "^6.0.1",
|
||||
"@types/jsonwebtoken": "^9.0.5",
|
||||
"@types/multer": "^1.4.11",
|
||||
"@types/node": "^20.12.7",
|
||||
"@typescript-eslint/eslint-plugin": "^7.3.1",
|
||||
"@typescript-eslint/parser": "^7.3.1",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"pino-pretty": "^11.2.2",
|
||||
"tsx": "^4.7.3",
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
}
|
||||
51
apps/backend/src/app.ts
Normal file
51
apps/backend/src/app.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import cors from 'cors';
|
||||
import express from 'express';
|
||||
import helmet from 'helmet';
|
||||
import pinoHttp from 'pino-http';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import { router } from './routes/index.js';
|
||||
import { notFoundHandler } from './middlewares/not-found.js';
|
||||
import { errorHandler } from './middlewares/error-handler.js';
|
||||
import { logger } from './config/logger.js';
|
||||
|
||||
export function createApp() {
|
||||
const app = express();
|
||||
|
||||
app.use(helmet());
|
||||
app.use(
|
||||
cors({
|
||||
origin: '*',
|
||||
credentials: true
|
||||
})
|
||||
);
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(
|
||||
rateLimit({
|
||||
windowMs: 60_000,
|
||||
limit: 100,
|
||||
legacyHeaders: false
|
||||
})
|
||||
);
|
||||
app.use(
|
||||
pinoHttp({
|
||||
logger,
|
||||
autoLogging: false
|
||||
})
|
||||
);
|
||||
|
||||
app.get('/', (_req, res) => {
|
||||
res.json({
|
||||
name: 'AI Bill API',
|
||||
version: '0.1.0',
|
||||
uptime: process.uptime()
|
||||
});
|
||||
});
|
||||
|
||||
app.use('/api', router);
|
||||
|
||||
app.use(notFoundHandler);
|
||||
app.use(errorHandler);
|
||||
|
||||
return app;
|
||||
}
|
||||
45
apps/backend/src/config/database.ts
Normal file
45
apps/backend/src/config/database.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { env } from './env.js';
|
||||
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;
|
||||
}
|
||||
|
||||
try {
|
||||
const connectionUri = env.MONGODB_URI;
|
||||
const options: mongoose.ConnectOptions = {};
|
||||
|
||||
if (env.MONGODB_DB) {
|
||||
options.dbName = env.MONGODB_DB;
|
||||
}
|
||||
|
||||
if (env.MONGODB_USER && env.MONGODB_PASS) {
|
||||
options.auth = {
|
||||
username: env.MONGODB_USER,
|
||||
password: env.MONGODB_PASS
|
||||
};
|
||||
options.authMechanism = 'SCRAM-SHA-256';
|
||||
}
|
||||
|
||||
if (env.MONGODB_AUTH_SOURCE) {
|
||||
options.authSource = env.MONGODB_AUTH_SOURCE;
|
||||
}
|
||||
|
||||
logger.info({ uri: connectionUri, db: options.dbName, authSource: options.authSource }, 'Attempting MongoDB connection');
|
||||
await mongoose.connect(connectionUri, options);
|
||||
logger.info('MongoDB connected');
|
||||
} catch (error) {
|
||||
logger.error({ error }, 'Failed to connect to MongoDB');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function disconnectFromDatabase(): Promise<void> {
|
||||
if (mongoose.connection.readyState !== 0) {
|
||||
await mongoose.disconnect();
|
||||
logger.info('MongoDB disconnected');
|
||||
}
|
||||
}
|
||||
45
apps/backend/src/config/env.ts
Normal file
45
apps/backend/src/config/env.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const DEFAULT_NOTIFICATION_PACKAGES = [
|
||||
'com.eg.android.AlipayGphone',
|
||||
'com.tencent.mm',
|
||||
'com.tencent.mobileqq',
|
||||
'com.google.android.gms',
|
||||
'com.android.mms'
|
||||
];
|
||||
|
||||
const envSchema = z
|
||||
.object({
|
||||
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
||||
PORT: z.coerce.number().default(3000),
|
||||
HOST: z.string().default('0.0.0.0'),
|
||||
MONGODB_URI: z.string().url().optional(),
|
||||
MONGODB_DB: z.string().optional(),
|
||||
MONGODB_USER: z.string().optional(),
|
||||
MONGODB_PASS: z.string().optional(),
|
||||
MONGODB_AUTH_SOURCE: z.string().optional(),
|
||||
JWT_SECRET: z.string().min(10).default('change-me-in-prod'),
|
||||
JWT_REFRESH_SECRET: z.string().min(10).optional(),
|
||||
NOTIFICATION_WEBHOOK_SECRET: z.string().min(10).optional(),
|
||||
NOTIFICATION_ALLOWED_PACKAGES: z.string().optional(),
|
||||
GEMINI_API_KEY: z.string().optional(),
|
||||
OCR_PROJECT_ID: z.string().optional(),
|
||||
FILE_STORAGE_BUCKET: z.string().optional()
|
||||
})
|
||||
.transform((values) => ({
|
||||
...values,
|
||||
isProduction: values.NODE_ENV === 'production',
|
||||
notificationPackageWhitelist: values.NOTIFICATION_ALLOWED_PACKAGES
|
||||
? values.NOTIFICATION_ALLOWED_PACKAGES.split(',').map((pkg) => pkg.trim()).filter(Boolean)
|
||||
: DEFAULT_NOTIFICATION_PACKAGES
|
||||
}));
|
||||
|
||||
const parsed = envSchema.safeParse(process.env);
|
||||
|
||||
if (!parsed.success) {
|
||||
console.error('❌ Invalid environment variables:', parsed.error.flatten().fieldErrors);
|
||||
throw new Error('Invalid environment configuration.');
|
||||
}
|
||||
|
||||
export const env = parsed.data;
|
||||
export type Env = typeof env;
|
||||
17
apps/backend/src/config/logger.ts
Normal file
17
apps/backend/src/config/logger.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import pino from 'pino';
|
||||
|
||||
const level = process.env.LOG_LEVEL ?? (process.env.NODE_ENV === 'production' ? 'info' : 'debug');
|
||||
|
||||
export const logger = pino({
|
||||
level,
|
||||
transport:
|
||||
process.env.NODE_ENV === 'production'
|
||||
? undefined
|
||||
: {
|
||||
target: 'pino-pretty',
|
||||
options: {
|
||||
translateTime: 'SYS:standard',
|
||||
ignore: 'pid,hostname'
|
||||
}
|
||||
}
|
||||
});
|
||||
37
apps/backend/src/main.ts
Normal file
37
apps/backend/src/main.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import 'dotenv/config';
|
||||
import 'express-async-errors';
|
||||
import http from 'node:http';
|
||||
import { createApp } from './app.js';
|
||||
import { connectToDatabase, disconnectFromDatabase } from './config/database.js';
|
||||
import { env } from './config/env.js';
|
||||
import { logger } from './config/logger.js';
|
||||
|
||||
async function bootstrap() {
|
||||
await connectToDatabase();
|
||||
|
||||
const app = createApp();
|
||||
const server = http.createServer(app);
|
||||
|
||||
server.listen(env.PORT, env.HOST, () => {
|
||||
logger.info(`API server listening on http://${env.HOST}:${env.PORT}`);
|
||||
});
|
||||
|
||||
const shutdown = async (signal: string) => {
|
||||
logger.info({ signal }, 'Shutting down gracefully');
|
||||
server.close(async (error) => {
|
||||
if (error) {
|
||||
logger.error({ error }, 'Error during HTTP shutdown');
|
||||
}
|
||||
await disconnectFromDatabase();
|
||||
process.exit(0);
|
||||
});
|
||||
};
|
||||
|
||||
process.on('SIGINT', () => void shutdown('SIGINT'));
|
||||
process.on('SIGTERM', () => void shutdown('SIGTERM'));
|
||||
}
|
||||
|
||||
bootstrap().catch((error) => {
|
||||
logger.error({ error }, 'Failed to bootstrap application');
|
||||
process.exit(1);
|
||||
});
|
||||
29
apps/backend/src/middlewares/error-handler.ts
Normal file
29
apps/backend/src/middlewares/error-handler.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { ErrorRequestHandler } from 'express';
|
||||
import { ZodError } from 'zod';
|
||||
import { logger } from '../config/logger.js';
|
||||
|
||||
type HttpError = Error & {
|
||||
statusCode?: number;
|
||||
details?: unknown;
|
||||
};
|
||||
|
||||
export const errorHandler: ErrorRequestHandler = (err: HttpError, _req, res, _next) => {
|
||||
void _next;
|
||||
if (err instanceof ZodError) {
|
||||
const formatted = err.flatten();
|
||||
return res.status(400).json({
|
||||
message: 'Invalid request payload',
|
||||
errors: formatted.fieldErrors
|
||||
});
|
||||
}
|
||||
|
||||
const status = err.statusCode ?? 500;
|
||||
if (status >= 500) {
|
||||
logger.error({ err }, 'Unhandled error');
|
||||
}
|
||||
|
||||
return res.status(status).json({
|
||||
message: err.message || 'Internal Server Error',
|
||||
details: err.details
|
||||
});
|
||||
};
|
||||
5
apps/backend/src/middlewares/not-found.ts
Normal file
5
apps/backend/src/middlewares/not-found.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { RequestHandler } from 'express';
|
||||
|
||||
export const notFoundHandler: RequestHandler = (_req, res) => {
|
||||
res.status(404).json({ message: 'Resource not found' });
|
||||
};
|
||||
30
apps/backend/src/middlewares/validate-request.ts
Normal file
30
apps/backend/src/middlewares/validate-request.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { RequestHandler } from 'express';
|
||||
import type { ZodTypeAny, ZodError } from 'zod';
|
||||
|
||||
interface ValidationSchema {
|
||||
body?: ZodTypeAny;
|
||||
query?: ZodTypeAny;
|
||||
params?: ZodTypeAny;
|
||||
}
|
||||
|
||||
export const validateRequest = (schema: ValidationSchema): RequestHandler => {
|
||||
return (req, _res, next) => {
|
||||
try {
|
||||
if (schema.body) {
|
||||
req.body = schema.body.parse(req.body);
|
||||
}
|
||||
if (schema.query) {
|
||||
req.query = schema.query.parse(req.query);
|
||||
}
|
||||
if (schema.params) {
|
||||
req.params = schema.params.parse(req.params);
|
||||
}
|
||||
return next();
|
||||
} catch (error) {
|
||||
if ((error as ZodError).name === 'ZodError') {
|
||||
return next(error);
|
||||
}
|
||||
return next(error);
|
||||
}
|
||||
};
|
||||
};
|
||||
18
apps/backend/src/models/budget.model.ts
Normal file
18
apps/backend/src/models/budget.model.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import mongoose, { type InferSchemaType } from 'mongoose';
|
||||
|
||||
const budgetSchema = new mongoose.Schema(
|
||||
{
|
||||
category: { type: String, required: true },
|
||||
amount: { type: Number, required: true, min: 0 },
|
||||
currency: { type: String, default: 'CNY' },
|
||||
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 }
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
export type BudgetDocument = InferSchemaType<typeof budgetSchema>;
|
||||
|
||||
export const BudgetModel = mongoose.models.Budget ?? mongoose.model('Budget', budgetSchema);
|
||||
22
apps/backend/src/models/transaction.model.ts
Normal file
22
apps/backend/src/models/transaction.model.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import mongoose, { type InferSchemaType } from 'mongoose';
|
||||
|
||||
const transactionSchema = new mongoose.Schema(
|
||||
{
|
||||
title: { type: String, required: true },
|
||||
amount: { type: Number, required: true, min: 0 },
|
||||
currency: { type: String, default: 'CNY' },
|
||||
category: { type: String, required: true },
|
||||
type: { type: String, enum: ['expense', 'income'], required: true },
|
||||
source: { type: String, enum: ['manual', 'notification', 'ocr', 'ai'], default: 'manual' },
|
||||
status: { type: String, enum: ['pending', 'confirmed', 'rejected'], default: 'confirmed' },
|
||||
occurredAt: { type: Date, required: true },
|
||||
notes: { type: String },
|
||||
metadata: { type: mongoose.Schema.Types.Mixed },
|
||||
userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: false }
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
export type TransactionDocument = InferSchemaType<typeof transactionSchema>;
|
||||
|
||||
export const TransactionModel = mongoose.models.Transaction ?? mongoose.model('Transaction', transactionSchema);
|
||||
17
apps/backend/src/models/user.model.ts
Normal file
17
apps/backend/src/models/user.model.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import mongoose, { type InferSchemaType } from 'mongoose';
|
||||
|
||||
const userSchema = new mongoose.Schema(
|
||||
{
|
||||
email: { type: String, required: true, unique: true, index: true },
|
||||
passwordHash: { type: String, required: true },
|
||||
displayName: { type: String, required: true },
|
||||
avatarUrl: { type: String },
|
||||
preferredCurrency: { type: String, default: 'CNY' },
|
||||
notificationPermissionGranted: { type: Boolean, default: false }
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
export type UserDocument = InferSchemaType<typeof userSchema>;
|
||||
|
||||
export const UserModel = mongoose.models.User ?? mongoose.model('User', userSchema);
|
||||
14
apps/backend/src/modules/analysis/analysis.controller.ts
Normal file
14
apps/backend/src/modules/analysis/analysis.controller.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { buildSpendingInsights, estimateCalories } from './analysis.service.js';
|
||||
|
||||
export const getSpendingInsights = async (req: Request, res: Response) => {
|
||||
const range = (req.query.range as '30d' | '90d') ?? '30d';
|
||||
const data = await buildSpendingInsights(range);
|
||||
return res.json(data);
|
||||
};
|
||||
|
||||
export const estimateCaloriesHandler = (req: Request, res: Response) => {
|
||||
const { query } = req.body as { query: string };
|
||||
const data = estimateCalories(query);
|
||||
return res.json(data);
|
||||
};
|
||||
11
apps/backend/src/modules/analysis/analysis.router.ts
Normal file
11
apps/backend/src/modules/analysis/analysis.router.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Router } from 'express';
|
||||
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.get('/habits', validateRequest({ query: analysisQuerySchema }), getSpendingInsights);
|
||||
router.post('/calories', validateRequest({ body: calorieRequestSchema }), estimateCaloriesHandler);
|
||||
|
||||
export default router;
|
||||
9
apps/backend/src/modules/analysis/analysis.schema.ts
Normal file
9
apps/backend/src/modules/analysis/analysis.schema.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const calorieRequestSchema = z.object({
|
||||
query: z.string().min(2)
|
||||
});
|
||||
|
||||
export const analysisQuerySchema = z.object({
|
||||
range: z.enum(['30d', '90d']).default('30d')
|
||||
});
|
||||
131
apps/backend/src/modules/analysis/analysis.service.ts
Normal file
131
apps/backend/src/modules/analysis/analysis.service.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import type { TransactionDto } from '../transactions/transactions.service.js';
|
||||
import { listTransactions } from '../transactions/transactions.service.js';
|
||||
|
||||
const DAY_IN_MS = 86_400_000;
|
||||
|
||||
interface CategoryInsight {
|
||||
name: string;
|
||||
amount: number;
|
||||
trend: 'up' | 'down' | 'flat';
|
||||
}
|
||||
|
||||
export interface SpendingInsightResult {
|
||||
range: '30d' | '90d';
|
||||
summary: string;
|
||||
recommendations: string[];
|
||||
categories: CategoryInsight[];
|
||||
}
|
||||
|
||||
const sumExpenses = (transactions: TransactionDto[]) =>
|
||||
transactions.filter((txn) => txn.type === 'expense').reduce((total, txn) => total + txn.amount, 0);
|
||||
|
||||
const groupByCategory = (transactions: TransactionDto[]) => {
|
||||
const map = new Map<string, number>();
|
||||
transactions
|
||||
.filter((txn) => txn.type === 'expense')
|
||||
.forEach((txn) => {
|
||||
map.set(txn.category, (map.get(txn.category) ?? 0) + txn.amount);
|
||||
});
|
||||
return map;
|
||||
};
|
||||
|
||||
const detectTrend = (current: number, previous: number): 'up' | 'down' | 'flat' => {
|
||||
if (previous === 0 && current === 0) return 'flat';
|
||||
if (previous === 0) return 'up';
|
||||
const ratio = current / previous;
|
||||
if (ratio > 1.1) return 'up';
|
||||
if (ratio < 0.9) return 'down';
|
||||
return 'flat';
|
||||
};
|
||||
|
||||
const buildRecommendations = (topCategories: CategoryInsight[], totalExpense: number) => {
|
||||
if (topCategories.length === 0 || totalExpense === 0) {
|
||||
return ['支出较低,保持良好的消费习惯。'];
|
||||
}
|
||||
|
||||
return topCategories.slice(0, 3).map((category) => {
|
||||
const ratio = (category.amount / totalExpense) * 100;
|
||||
const roundedRatio = Math.round(ratio);
|
||||
return `「${category.name}」支出占比约 ${roundedRatio}% 。试着为该分类设置日常预算或使用优惠券降低开销。`;
|
||||
});
|
||||
};
|
||||
|
||||
export async function buildSpendingInsights(range: '30d' | '90d' = '30d'): Promise<SpendingInsightResult> {
|
||||
const transactions = await listTransactions();
|
||||
const now = Date.now();
|
||||
const windowDays = range === '30d' ? 30 : 90;
|
||||
const windowStart = now - windowDays * DAY_IN_MS;
|
||||
const previousWindowStart = windowStart - windowDays * DAY_IN_MS;
|
||||
|
||||
const currentPeriod = transactions.filter((txn) => new Date(txn.occurredAt).getTime() >= windowStart);
|
||||
const previousPeriod = transactions.filter((txn) => {
|
||||
const time = new Date(txn.occurredAt).getTime();
|
||||
return time >= previousWindowStart && time < windowStart;
|
||||
});
|
||||
|
||||
const totalExpense = sumExpenses(currentPeriod);
|
||||
const previousExpense = sumExpenses(previousPeriod);
|
||||
|
||||
const currentByCategory = groupByCategory(currentPeriod);
|
||||
const previousByCategory = groupByCategory(previousPeriod);
|
||||
|
||||
const categories: CategoryInsight[] = [...currentByCategory.entries()]
|
||||
.sort(([, amountA], [, amountB]) => amountB - amountA)
|
||||
.slice(0, 5)
|
||||
.map(([name, amount]) => ({
|
||||
name,
|
||||
amount: Number(amount.toFixed(2)),
|
||||
trend: detectTrend(amount, previousByCategory.get(name) ?? 0)
|
||||
}));
|
||||
|
||||
const delta = totalExpense - previousExpense;
|
||||
const summary = previousExpense === 0
|
||||
? `本期总支出约 ¥${totalExpense.toFixed(2)}。`
|
||||
: delta >= 0
|
||||
? `本期总支出约 ¥${totalExpense.toFixed(2)},相比上一周期增加 ¥${Math.abs(delta).toFixed(2)}。`
|
||||
: `本期总支出约 ¥${totalExpense.toFixed(2)},相比上一周期减少 ¥${Math.abs(delta).toFixed(2)}。`;
|
||||
|
||||
const recommendations = buildRecommendations(categories, totalExpense);
|
||||
|
||||
return {
|
||||
range,
|
||||
summary,
|
||||
recommendations,
|
||||
categories
|
||||
};
|
||||
}
|
||||
|
||||
const kcalDictionary: Record<string, { calories: number; tips: string[] }> = {
|
||||
'麦辣鸡腿堡': {
|
||||
calories: 650,
|
||||
tips: ['搭配无糖饮品可减少额外糖分。', '可选择去皮鸡腿以减少脂肪摄入。']
|
||||
},
|
||||
'星冰乐': {
|
||||
calories: 420,
|
||||
tips: ['选择少糖或去奶油版本可以减少热量约 20%。']
|
||||
},
|
||||
'沙拉': {
|
||||
calories: 180,
|
||||
tips: ['补充蛋白质可增加饱腹感,例如鸡胸肉或豆腐。']
|
||||
}
|
||||
};
|
||||
|
||||
export function estimateCalories(query: string) {
|
||||
const normalized = query.trim();
|
||||
const match = Object.entries(kcalDictionary).find(([keyword]) => normalized.includes(keyword));
|
||||
|
||||
if (!match) {
|
||||
return {
|
||||
query,
|
||||
calories: 480,
|
||||
insights: ['未匹配到具体食物,以上为估算值。建议减少含糖饮料摄入,控制每日总热量。']
|
||||
};
|
||||
}
|
||||
|
||||
const [, info] = match;
|
||||
return {
|
||||
query,
|
||||
calories: info.calories,
|
||||
insights: info.tips
|
||||
};
|
||||
}
|
||||
62
apps/backend/src/modules/auth/auth.controller.ts
Normal file
62
apps/backend/src/modules/auth/auth.controller.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { logger } from '../../config/logger.js';
|
||||
import {
|
||||
registerUser,
|
||||
authenticateUser,
|
||||
refreshSession,
|
||||
requestPasswordReset,
|
||||
revokeRefreshToken
|
||||
} from './auth.service.js';
|
||||
import type { LoginInput, RegisterInput } from './auth.schema.js';
|
||||
|
||||
export const register = async (req: Request, res: Response) => {
|
||||
const payload = req.body as RegisterInput;
|
||||
const { email, password, displayName, preferredCurrency } = payload;
|
||||
|
||||
try {
|
||||
const result = await registerUser({
|
||||
email,
|
||||
password,
|
||||
displayName,
|
||||
preferredCurrency: preferredCurrency ?? 'CNY'
|
||||
});
|
||||
|
||||
return res.status(201).json(result);
|
||||
} catch (error) {
|
||||
const statusCode = (error as { statusCode?: number }).statusCode ?? 500;
|
||||
logger.warn({ error }, 'Registration failed');
|
||||
return res.status(statusCode).json({ message: (error as Error).message });
|
||||
}
|
||||
};
|
||||
|
||||
export const login = async (req: Request, res: Response) => {
|
||||
const payload = req.body as LoginInput;
|
||||
try {
|
||||
const result = await authenticateUser(payload);
|
||||
return res.json(result);
|
||||
} catch (error) {
|
||||
const statusCode = (error as { statusCode?: number }).statusCode ?? 500;
|
||||
logger.warn({ error }, 'Login failed');
|
||||
return res.status(statusCode).json({ message: (error as Error).message });
|
||||
}
|
||||
};
|
||||
|
||||
export const refresh = async (req: Request, res: Response) => {
|
||||
const { refreshToken } = req.body as { refreshToken: string };
|
||||
const result = await refreshSession(refreshToken);
|
||||
return res.json(result);
|
||||
};
|
||||
|
||||
export const forgotPassword = async (req: Request, res: Response) => {
|
||||
const { email } = req.body as { email: string };
|
||||
await requestPasswordReset(email);
|
||||
return res.json({ message: 'If the account exists, a reset link has been emailed.' });
|
||||
};
|
||||
|
||||
export const logout = async (req: Request, res: Response) => {
|
||||
const { refreshToken } = req.body as { refreshToken: string };
|
||||
if (refreshToken) {
|
||||
await revokeRefreshToken(refreshToken);
|
||||
}
|
||||
return res.status(204).send();
|
||||
};
|
||||
14
apps/backend/src/modules/auth/auth.router.ts
Normal file
14
apps/backend/src/modules/auth/auth.router.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Router } from 'express';
|
||||
import { validateRequest } from '../../middlewares/validate-request.js';
|
||||
import { registerSchema, loginSchema, refreshSchema, forgotPasswordSchema } from './auth.schema.js';
|
||||
import { register, login, refresh, forgotPassword, logout } from './auth.controller.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.post('/register', validateRequest({ body: registerSchema }), register);
|
||||
router.post('/login', validateRequest({ body: loginSchema }), login);
|
||||
router.post('/refresh', validateRequest({ body: refreshSchema }), refresh);
|
||||
router.post('/forgot-password', validateRequest({ body: forgotPasswordSchema }), forgotPassword);
|
||||
router.post('/logout', logout);
|
||||
|
||||
export default router;
|
||||
33
apps/backend/src/modules/auth/auth.schema.ts
Normal file
33
apps/backend/src/modules/auth/auth.schema.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const registerSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z
|
||||
.string()
|
||||
.min(8, 'Password must be at least 8 characters long')
|
||||
.regex(/[A-Z]/, 'Password must include an uppercase letter')
|
||||
.regex(/[0-9]/, 'Password must include a number')
|
||||
.regex(/[^A-Za-z0-9]/, 'Password must include a special character'),
|
||||
confirmPassword: z.string().min(8),
|
||||
displayName: z.string().min(1, 'Display name is required'),
|
||||
preferredCurrency: z.string().default('CNY')
|
||||
}).refine((data) => data.password === data.confirmPassword, {
|
||||
message: 'Passwords do not match',
|
||||
path: ['confirmPassword']
|
||||
});
|
||||
|
||||
export const loginSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(1)
|
||||
});
|
||||
|
||||
export const refreshSchema = z.object({
|
||||
refreshToken: z.string().min(10)
|
||||
});
|
||||
|
||||
export const forgotPasswordSchema = z.object({
|
||||
email: z.string().email()
|
||||
});
|
||||
|
||||
export type RegisterInput = z.infer<typeof registerSchema>;
|
||||
export type LoginInput = z.infer<typeof loginSchema>;
|
||||
229
apps/backend/src/modules/auth/auth.service.ts
Normal file
229
apps/backend/src/modules/auth/auth.service.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
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';
|
||||
const REFRESH_TOKEN_TTL = '7d';
|
||||
|
||||
interface AuthUser {
|
||||
id: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
passwordHash: string;
|
||||
preferredCurrency: string;
|
||||
notificationPermissionGranted: boolean;
|
||||
avatarUrl?: string;
|
||||
}
|
||||
|
||||
interface AuthTokens {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresIn: number;
|
||||
refreshExpiresIn: number;
|
||||
}
|
||||
|
||||
const memoryUsers = new Map<string, AuthUser>();
|
||||
const refreshTokenStore = new Map<string, { userId: string; expiresAt: Date }>();
|
||||
|
||||
const isDatabaseReady = () => mongoose.connection.readyState === 1;
|
||||
|
||||
const toAuthUser = (user: (UserDocument & { _id?: mongoose.Types.ObjectId }) | AuthUser): AuthUser => {
|
||||
if ('passwordHash' in user && 'id' in user) {
|
||||
return user as AuthUser;
|
||||
}
|
||||
|
||||
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 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
|
||||
};
|
||||
|
||||
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 });
|
||||
}
|
||||
};
|
||||
|
||||
const signTokens = (user: AuthUser): AuthTokens => {
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
const accessToken = jwt.sign(
|
||||
{
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
name: user.displayName
|
||||
},
|
||||
env.JWT_SECRET,
|
||||
{ expiresIn: ACCESS_TOKEN_TTL }
|
||||
);
|
||||
|
||||
const refreshTokenSecret = env.JWT_REFRESH_SECRET ?? env.JWT_SECRET;
|
||||
const refreshToken = jwt.sign(
|
||||
{
|
||||
sub: user.id
|
||||
},
|
||||
refreshTokenSecret,
|
||||
{ expiresIn: REFRESH_TOKEN_TTL }
|
||||
);
|
||||
|
||||
const refreshExpiresIn = jwt.decode(refreshToken, { json: true })?.exp ?? nowSeconds + 7 * 24 * 60 * 60;
|
||||
refreshTokenStore.set(refreshToken, { userId: user.id, expiresAt: new Date(refreshExpiresIn * 1000) });
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiresIn: nowSeconds + 15 * 60,
|
||||
refreshExpiresIn
|
||||
};
|
||||
};
|
||||
|
||||
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;
|
||||
displayName: string;
|
||||
preferredCurrency: string;
|
||||
}) {
|
||||
const existing = await getUserByEmail(input.email);
|
||||
if (existing) {
|
||||
throw Object.assign(new Error('Email already registered'), { statusCode: 409 });
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(input.password, 10);
|
||||
const user: AuthUser = {
|
||||
id: createId(),
|
||||
email: input.email,
|
||||
displayName: input.displayName,
|
||||
preferredCurrency: input.preferredCurrency,
|
||||
notificationPermissionGranted: false,
|
||||
passwordHash
|
||||
};
|
||||
|
||||
const saved = await saveUser(user);
|
||||
const tokens = signTokens(saved);
|
||||
return { user: profileFromUser(saved), tokens };
|
||||
}
|
||||
|
||||
export async function authenticateUser(input: { email: string; password: string }) {
|
||||
const user = await getUserByEmail(input.email);
|
||||
if (!user) {
|
||||
throw Object.assign(new Error('Invalid credentials'), { statusCode: 401 });
|
||||
}
|
||||
|
||||
const isValid = await bcrypt.compare(input.password, user.passwordHash);
|
||||
if (!isValid) {
|
||||
throw Object.assign(new Error('Invalid credentials'), { statusCode: 401 });
|
||||
}
|
||||
|
||||
const tokens = signTokens(user);
|
||||
return { user: profileFromUser(user), tokens };
|
||||
}
|
||||
|
||||
export async function refreshSession(refreshToken: string) {
|
||||
const refreshTokenSecret = env.JWT_REFRESH_SECRET ?? env.JWT_SECRET;
|
||||
try {
|
||||
const payload = jwt.verify(refreshToken, refreshTokenSecret) as { sub: string };
|
||||
const stored = refreshTokenStore.get(refreshToken);
|
||||
|
||||
if (!stored || stored.userId !== payload.sub) {
|
||||
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) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
const user = toAuthUser(userDoc as AuthUser);
|
||||
refreshTokenStore.delete(refreshToken);
|
||||
const tokens = signTokens(user);
|
||||
return { user: profileFromUser(user), tokens };
|
||||
} catch (error) {
|
||||
logger.warn({ error }, 'Failed to refresh session');
|
||||
throw Object.assign(new Error('Invalid refresh token'), { statusCode: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function revokeRefreshToken(refreshToken: string) {
|
||||
refreshTokenStore.delete(refreshToken);
|
||||
}
|
||||
|
||||
export async function requestPasswordReset(email: string) {
|
||||
const user = await getUserByEmail(email);
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tempPassword = Math.random().toString(36).slice(-10);
|
||||
const passwordHash = await bcrypt.hash(tempPassword, 10);
|
||||
await updateUserPassword(email, passwordHash);
|
||||
logger.info({ email }, 'Password reset token generated (simulated)');
|
||||
}
|
||||
31
apps/backend/src/modules/budgets/budgets.controller.ts
Normal file
31
apps/backend/src/modules/budgets/budgets.controller.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
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();
|
||||
return res.json({ data });
|
||||
};
|
||||
|
||||
export const createBudgetHandler = async (req: Request, res: Response) => {
|
||||
const payload = req.body as CreateBudgetInput;
|
||||
const budget = await createBudget(payload);
|
||||
return res.status(201).json({ data: budget });
|
||||
};
|
||||
|
||||
export const updateBudgetHandler = async (req: Request, res: Response) => {
|
||||
const payload = req.body as UpdateBudgetInput;
|
||||
const budget = await updateBudget(req.params.id, payload);
|
||||
if (!budget) {
|
||||
return res.status(404).json({ message: 'Budget not found' });
|
||||
}
|
||||
return res.json({ data: budget });
|
||||
};
|
||||
|
||||
export const deleteBudgetHandler = async (req: Request, res: Response) => {
|
||||
const removed = await deleteBudget(req.params.id);
|
||||
if (!removed) {
|
||||
return res.status(404).json({ message: 'Budget not found' });
|
||||
}
|
||||
return res.status(204).send();
|
||||
};
|
||||
14
apps/backend/src/modules/budgets/budgets.router.ts
Normal file
14
apps/backend/src/modules/budgets/budgets.router.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
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.get('/', listBudgetsHandler);
|
||||
router.post('/', validateRequest({ body: createBudgetSchema }), createBudgetHandler);
|
||||
router.patch('/:id', validateRequest({ params: z.object({ id: z.string().min(1) }), body: updateBudgetSchema }), updateBudgetHandler);
|
||||
router.delete('/:id', validateRequest({ params: z.object({ id: z.string().min(1) }) }), deleteBudgetHandler);
|
||||
|
||||
export default router;
|
||||
16
apps/backend/src/modules/budgets/budgets.schema.ts
Normal file
16
apps/backend/src/modules/budgets/budgets.schema.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const createBudgetSchema = z.object({
|
||||
category: z.string().min(1),
|
||||
amount: z.coerce.number().positive(),
|
||||
currency: z.string().length(3).default('CNY'),
|
||||
period: z.enum(['monthly', 'weekly']).default('monthly'),
|
||||
threshold: z.coerce.number().min(0).max(1).default(0.8),
|
||||
usage: z.coerce.number().min(0).optional(),
|
||||
userId: z.string().optional()
|
||||
});
|
||||
|
||||
export const updateBudgetSchema = createBudgetSchema.partial();
|
||||
|
||||
export type CreateBudgetInput = z.infer<typeof createBudgetSchema>;
|
||||
export type UpdateBudgetInput = z.infer<typeof updateBudgetSchema>;
|
||||
159
apps/backend/src/modules/budgets/budgets.service.ts
Normal file
159
apps/backend/src/modules/budgets/budgets.service.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
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 {
|
||||
id: string;
|
||||
category: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
period: 'monthly' | 'weekly';
|
||||
threshold: number;
|
||||
usage: number;
|
||||
userId?: string;
|
||||
createdAt: string;
|
||||
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();
|
||||
|
||||
return {
|
||||
id,
|
||||
category: doc.category,
|
||||
amount: doc.amount,
|
||||
currency: doc.currency ?? 'CNY',
|
||||
period: doc.period,
|
||||
threshold: doc.threshold,
|
||||
usage: doc.usage ?? 0,
|
||||
userId: doc.userId ? doc.userId.toString() : undefined,
|
||||
createdAt: new Date(doc.createdAt ?? Date.now()).toISOString(),
|
||||
updatedAt: new Date(doc.updatedAt ?? Date.now()).toISOString()
|
||||
};
|
||||
};
|
||||
|
||||
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 createBudget(payload: CreateBudgetInput): Promise<BudgetDto> {
|
||||
const defaults = { currency: payload.currency ?? 'CNY', usage: payload.usage ?? 0 };
|
||||
|
||||
if (isDatabaseReady()) {
|
||||
const userId = payload.userId ?? (await getDefaultUserId());
|
||||
const created = await BudgetModel.create({ ...payload, ...defaults, userId });
|
||||
return toDto(created);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
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;
|
||||
if (secret.length <= 6) return '*'.repeat(secret.length);
|
||||
return `${secret.slice(0, 3)}***${secret.slice(-3)}`;
|
||||
};
|
||||
|
||||
export const getNotificationStatus = async (req: Request, res: Response) => {
|
||||
const userId = await getDefaultUserId();
|
||||
const filter = { userId, source: 'notification' as const };
|
||||
const count = await TransactionModel.countDocuments(filter);
|
||||
const latest = (await TransactionModel.findOne(filter).sort({ createdAt: -1 }).lean()) as
|
||||
| { createdAt?: Date }
|
||||
| null;
|
||||
const ingestEndpoint = `${req.protocol}://${req.get('host') ?? 'localhost'}/api/transactions/notification`;
|
||||
|
||||
const secret = env.NOTIFICATION_WEBHOOK_SECRET;
|
||||
const response = {
|
||||
secretConfigured: Boolean(secret),
|
||||
secretHint: maskSecret(secret),
|
||||
webhookSecret: env.isProduction ? null : secret ?? null,
|
||||
packageWhitelist: env.notificationPackageWhitelist,
|
||||
ingestedCount: count,
|
||||
lastNotificationAt: latest?.createdAt ? new Date(latest.createdAt).toISOString() : null,
|
||||
ingestEndpoint
|
||||
};
|
||||
|
||||
return res.json(response);
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Router } from 'express';
|
||||
import { getNotificationStatus } from './notifications.controller.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/status', getNotificationStatus);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { createHmac } from 'node:crypto';
|
||||
import { env } from '../../config/env.js';
|
||||
import { logger } from '../../config/logger.js';
|
||||
import {
|
||||
listTransactions,
|
||||
createTransaction,
|
||||
getTransactionById,
|
||||
updateTransaction,
|
||||
deleteTransaction,
|
||||
ingestNotification
|
||||
} from './transactions.service.js';
|
||||
import type { CreateTransactionInput, UpdateTransactionInput } from './transactions.schema.js';
|
||||
|
||||
export const listTransactionsHandler = async (_req: Request, res: Response) => {
|
||||
const data = await listTransactions();
|
||||
return res.json({ data });
|
||||
};
|
||||
|
||||
export const createTransactionHandler = async (req: Request, res: Response) => {
|
||||
const payload = req.body as CreateTransactionInput;
|
||||
const transaction = await createTransaction(payload);
|
||||
return res.status(201).json({ data: transaction });
|
||||
};
|
||||
|
||||
export const getTransactionHandler = async (req: Request, res: Response) => {
|
||||
const transaction = await getTransactionById(req.params.id);
|
||||
if (!transaction) {
|
||||
return res.status(404).json({ message: 'Transaction not found' });
|
||||
}
|
||||
return res.json({ data: transaction });
|
||||
};
|
||||
|
||||
export const updateTransactionHandler = async (req: Request, res: Response) => {
|
||||
const payload = req.body as UpdateTransactionInput;
|
||||
const transaction = await updateTransaction(req.params.id, payload);
|
||||
if (!transaction) {
|
||||
return res.status(404).json({ message: 'Transaction not found' });
|
||||
}
|
||||
return res.json({ data: transaction });
|
||||
};
|
||||
|
||||
export const deleteTransactionHandler = async (req: Request, res: Response) => {
|
||||
const removed = await deleteTransaction(req.params.id);
|
||||
if (!removed) {
|
||||
return res.status(404).json({ message: 'Transaction not found' });
|
||||
}
|
||||
return res.status(204).send();
|
||||
};
|
||||
|
||||
const verifySignature = (signature: string, parts: string[]): boolean => {
|
||||
if (!env.NOTIFICATION_WEBHOOK_SECRET) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const payload = parts.join('|');
|
||||
const computed = createHmac('sha256', env.NOTIFICATION_WEBHOOK_SECRET).update(payload).digest('hex');
|
||||
return computed === signature;
|
||||
};
|
||||
|
||||
export const createTransactionFromNotificationHandler = async (req: Request, res: Response) => {
|
||||
const { packageName, title, body, receivedAt, signature } = req.body as {
|
||||
packageName: string;
|
||||
title: string;
|
||||
body: string;
|
||||
receivedAt: Date;
|
||||
signature: string;
|
||||
};
|
||||
|
||||
const payloadParts = [packageName, title, body, new Date(receivedAt).getTime().toString()];
|
||||
if (!verifySignature(signature, payloadParts)) {
|
||||
logger.warn({ packageName }, 'Notification signature mismatch');
|
||||
return res.status(401).json({ message: 'Invalid notification signature' });
|
||||
}
|
||||
|
||||
const transaction = await ingestNotification({
|
||||
packageName,
|
||||
title,
|
||||
body,
|
||||
receivedAt: new Date(receivedAt)
|
||||
});
|
||||
|
||||
return res.status(202).json({
|
||||
message: 'Notification ingested and awaiting confirmation',
|
||||
data: transaction
|
||||
});
|
||||
};
|
||||
27
apps/backend/src/modules/transactions/transactions.router.ts
Normal file
27
apps/backend/src/modules/transactions/transactions.router.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Router } from 'express';
|
||||
import { validateRequest } from '../../middlewares/validate-request.js';
|
||||
import {
|
||||
createTransactionSchema,
|
||||
updateTransactionSchema,
|
||||
transactionIdSchema,
|
||||
notificationSchema
|
||||
} from './transactions.schema.js';
|
||||
import {
|
||||
listTransactionsHandler,
|
||||
createTransactionHandler,
|
||||
getTransactionHandler,
|
||||
updateTransactionHandler,
|
||||
deleteTransactionHandler,
|
||||
createTransactionFromNotificationHandler
|
||||
} from './transactions.controller.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
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;
|
||||
35
apps/backend/src/modules/transactions/transactions.schema.ts
Normal file
35
apps/backend/src/modules/transactions/transactions.schema.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const baseTransactionSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
amount: z.coerce.number().min(0),
|
||||
currency: z.string().length(3).default('CNY'),
|
||||
category: z.string().min(1),
|
||||
type: z.enum(['expense', 'income']),
|
||||
source: z.enum(['manual', 'notification', 'ocr', 'ai']).default('manual'),
|
||||
status: z.enum(['pending', 'confirmed', 'rejected']).default('confirmed'),
|
||||
occurredAt: z.coerce.date(),
|
||||
notes: z.string().max(300).optional(),
|
||||
metadata: z.record(z.any()).optional(),
|
||||
userId: z.string().optional()
|
||||
});
|
||||
|
||||
export const createTransactionSchema = baseTransactionSchema;
|
||||
|
||||
export const updateTransactionSchema = baseTransactionSchema.partial();
|
||||
|
||||
export const transactionIdSchema = z.object({
|
||||
id: z.string().min(1)
|
||||
});
|
||||
|
||||
export const notificationSchema = z.object({
|
||||
packageName: z.string().min(1),
|
||||
title: z.string().min(1),
|
||||
body: z.string().min(1),
|
||||
receivedAt: z.coerce.date(),
|
||||
signature: z.string().min(32),
|
||||
extras: z.record(z.any()).optional()
|
||||
});
|
||||
|
||||
export type CreateTransactionInput = z.infer<typeof createTransactionSchema>;
|
||||
export type UpdateTransactionInput = z.infer<typeof updateTransactionSchema>;
|
||||
240
apps/backend/src/modules/transactions/transactions.service.ts
Normal file
240
apps/backend/src/modules/transactions/transactions.service.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
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 { getDefaultUserId } from '../../services/user-context.js';
|
||||
import type { CreateTransactionInput, UpdateTransactionInput } from './transactions.schema.js';
|
||||
|
||||
export interface TransactionDto {
|
||||
id: string;
|
||||
title: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
category: string;
|
||||
type: 'expense' | 'income';
|
||||
source: 'manual' | 'notification' | 'ocr' | 'ai';
|
||||
status: 'pending' | 'confirmed' | 'rejected';
|
||||
occurredAt: string;
|
||||
notes?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
userId?: string;
|
||||
createdAt: string;
|
||||
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();
|
||||
|
||||
return {
|
||||
id: rawId,
|
||||
title: doc.title,
|
||||
amount: doc.amount,
|
||||
currency: doc.currency ?? 'CNY',
|
||||
category: doc.category,
|
||||
type: doc.type,
|
||||
source: doc.source,
|
||||
status: doc.status,
|
||||
occurredAt: new Date(doc.occurredAt).toISOString(),
|
||||
notes: doc.notes,
|
||||
metadata: doc.metadata ?? undefined,
|
||||
userId: doc.userId ? doc.userId.toString() : undefined,
|
||||
createdAt: new Date(doc.createdAt ?? Date.now()).toISOString(),
|
||||
updatedAt: new Date(doc.updatedAt ?? Date.now()).toISOString()
|
||||
};
|
||||
};
|
||||
|
||||
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 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 createTransaction(payload: CreateTransactionInput): Promise<TransactionDto> {
|
||||
const normalizedAmount = Math.abs(payload.amount);
|
||||
const occuredDate = payload.occurredAt instanceof Date ? payload.occurredAt : new Date(payload.occurredAt);
|
||||
|
||||
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 now = new Date();
|
||||
const transaction: MemoryTransaction = {
|
||||
id: createId(),
|
||||
title: payload.title,
|
||||
amount: normalizedAmount,
|
||||
currency: payload.currency ?? 'CNY',
|
||||
category: payload.category,
|
||||
type: payload.type,
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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()
|
||||
};
|
||||
|
||||
memoryTransactions[index] = updated;
|
||||
return updated;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
interface NotificationPayload {
|
||||
packageName: string;
|
||||
title: string;
|
||||
body: string;
|
||||
receivedAt: Date;
|
||||
}
|
||||
|
||||
export async function ingestNotification(payload: NotificationPayload): Promise<TransactionDto> {
|
||||
const now = payload.receivedAt;
|
||||
return createTransaction({
|
||||
title: payload.title,
|
||||
amount: 0,
|
||||
currency: 'CNY',
|
||||
category: '待分类',
|
||||
type: 'expense',
|
||||
source: 'notification',
|
||||
status: 'pending',
|
||||
occurredAt: now,
|
||||
notes: payload.body,
|
||||
metadata: { packageName: payload.packageName }
|
||||
});
|
||||
}
|
||||
20
apps/backend/src/routes/index.ts
Normal file
20
apps/backend/src/routes/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Router } from 'express';
|
||||
import authRouter from '../modules/auth/auth.router.js';
|
||||
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';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/health', (_req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
router.use('/auth', authRouter);
|
||||
router.use('/transactions', transactionsRouter);
|
||||
router.use('/analysis', analysisRouter);
|
||||
router.use('/budgets', budgetsRouter);
|
||||
router.use('/notifications', notificationsRouter);
|
||||
|
||||
export { router };
|
||||
107
apps/backend/src/scripts/seed.ts
Normal file
107
apps/backend/src/scripts/seed.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import 'dotenv/config';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { connectToDatabase, disconnectFromDatabase } from '../config/database.js';
|
||||
import { logger } from '../config/logger.js';
|
||||
import { UserModel } from '../models/user.model.js';
|
||||
import { TransactionModel } from '../models/transaction.model.js';
|
||||
import { BudgetModel } from '../models/budget.model.js';
|
||||
|
||||
async function seed() {
|
||||
await connectToDatabase();
|
||||
|
||||
const [user] = await UserModel.find().limit(1);
|
||||
let targetUser = user;
|
||||
|
||||
if (!targetUser) {
|
||||
const passwordHash = await bcrypt.hash('Password123!', 10);
|
||||
targetUser = await UserModel.create({
|
||||
email: 'user@example.com',
|
||||
passwordHash,
|
||||
displayName: '示例用户',
|
||||
preferredCurrency: 'CNY',
|
||||
notificationPermissionGranted: true
|
||||
});
|
||||
logger.info({ email: targetUser.email }, 'Created default user');
|
||||
}
|
||||
|
||||
const userId = targetUser._id;
|
||||
|
||||
const existingBudgets = await BudgetModel.countDocuments({ userId });
|
||||
if (existingBudgets === 0) {
|
||||
await BudgetModel.create([
|
||||
{
|
||||
category: '餐饮',
|
||||
amount: 2000,
|
||||
currency: 'CNY',
|
||||
period: 'monthly',
|
||||
threshold: 0.8,
|
||||
usage: 1250,
|
||||
userId
|
||||
},
|
||||
{
|
||||
category: '交通',
|
||||
amount: 600,
|
||||
currency: 'CNY',
|
||||
period: 'monthly',
|
||||
threshold: 0.75,
|
||||
usage: 320,
|
||||
userId
|
||||
}
|
||||
]);
|
||||
logger.info('Seeded default budgets');
|
||||
}
|
||||
|
||||
const existingTransactions = await TransactionModel.countDocuments({ userId });
|
||||
if (existingTransactions === 0) {
|
||||
const now = new Date();
|
||||
await TransactionModel.create([
|
||||
{
|
||||
title: '星巴克咖啡',
|
||||
amount: 32.5,
|
||||
currency: 'CNY',
|
||||
category: '餐饮',
|
||||
type: 'expense',
|
||||
source: 'notification',
|
||||
status: 'confirmed',
|
||||
occurredAt: now,
|
||||
notes: '早餐咖啡',
|
||||
userId
|
||||
},
|
||||
{
|
||||
title: '地铁充值',
|
||||
amount: 100,
|
||||
currency: 'CNY',
|
||||
category: '交通',
|
||||
type: 'expense',
|
||||
source: 'manual',
|
||||
status: 'confirmed',
|
||||
occurredAt: new Date(now.getTime() - 86_400_000),
|
||||
userId
|
||||
},
|
||||
{
|
||||
title: '午餐报销',
|
||||
amount: 58,
|
||||
currency: 'CNY',
|
||||
category: '报销',
|
||||
type: 'income',
|
||||
source: 'ocr',
|
||||
status: 'pending',
|
||||
occurredAt: new Date(now.getTime() - 172_800_000),
|
||||
notes: '审批中',
|
||||
userId
|
||||
}
|
||||
]);
|
||||
logger.info('Seeded default transactions');
|
||||
}
|
||||
|
||||
logger.info('Database seed completed');
|
||||
}
|
||||
|
||||
seed()
|
||||
.catch((error) => {
|
||||
logger.error({ error }, 'Failed to seed database');
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(async () => {
|
||||
await disconnectFromDatabase();
|
||||
});
|
||||
33
apps/backend/src/services/user-context.ts
Normal file
33
apps/backend/src/services/user-context.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { UserModel } from '../models/user.model.js';
|
||||
import { logger } from '../config/logger.js';
|
||||
|
||||
let cachedUserId: string | null = null;
|
||||
|
||||
export async function getDefaultUserId(): Promise<string> {
|
||||
if (cachedUserId) {
|
||||
return cachedUserId;
|
||||
}
|
||||
|
||||
let user = await UserModel.findOne();
|
||||
|
||||
if (!user) {
|
||||
const passwordHash = await bcrypt.hash('Password123!', 10);
|
||||
user = await UserModel.create({
|
||||
email: 'user@example.com',
|
||||
passwordHash,
|
||||
displayName: '示例用户',
|
||||
preferredCurrency: 'CNY',
|
||||
notificationPermissionGranted: true
|
||||
});
|
||||
logger.info({ email: user.email }, 'Provisioned default user for context');
|
||||
}
|
||||
|
||||
const ensuredUser = user;
|
||||
if (!ensuredUser) {
|
||||
throw new Error('Unable to resolve default user');
|
||||
}
|
||||
|
||||
cachedUserId = ensuredUser._id.toString();
|
||||
return cachedUserId as string;
|
||||
}
|
||||
3
apps/backend/src/utils/id.ts
Normal file
3
apps/backend/src/utils/id.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
export const createId = () => randomUUID();
|
||||
17
apps/backend/tsconfig.json
Normal file
17
apps/backend/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"lib": ["ES2022"],
|
||||
"resolveJsonModule": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user