first commit

This commit is contained in:
2025-11-01 09:24:26 +08:00
commit 6516d8dc78
87 changed files with 7558 additions and 0 deletions

13
apps/backend/.env.example Normal file
View 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=

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

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

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

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

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

View File

@@ -0,0 +1,5 @@
import type { RequestHandler } from 'express';
export const notFoundHandler: RequestHandler = (_req, res) => {
res.status(404).json({ message: 'Resource not found' });
};

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

@@ -0,0 +1,8 @@
import { Router } from 'express';
import { getNotificationStatus } from './notifications.controller.js';
const router = Router();
router.get('/status', getNotificationStatus);
export default router;

View File

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

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

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

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

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

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

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

View File

@@ -0,0 +1,3 @@
import { randomUUID } from 'node:crypto';
export const createId = () => randomUUID();

View 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"]
}