From 6516d8dc786d4510024edda6fe7a04f9b18819d4 Mon Sep 17 00:00:00 2001 From: Jafeng <2998840497@qq.com> Date: Sat, 1 Nov 2025 09:24:26 +0800 Subject: [PATCH] first commit --- .gitignore | 6 + README.md | 75 + apps/backend/.env.example | 13 + apps/backend/.eslintrc.cjs | 19 + apps/backend/package.json | 44 + apps/backend/src/app.ts | 51 + apps/backend/src/config/database.ts | 45 + apps/backend/src/config/env.ts | 45 + apps/backend/src/config/logger.ts | 17 + apps/backend/src/main.ts | 37 + apps/backend/src/middlewares/error-handler.ts | 29 + apps/backend/src/middlewares/not-found.ts | 5 + .../src/middlewares/validate-request.ts | 30 + apps/backend/src/models/budget.model.ts | 18 + apps/backend/src/models/transaction.model.ts | 22 + apps/backend/src/models/user.model.ts | 17 + .../modules/analysis/analysis.controller.ts | 14 + .../src/modules/analysis/analysis.router.ts | 11 + .../src/modules/analysis/analysis.schema.ts | 9 + .../src/modules/analysis/analysis.service.ts | 131 + .../src/modules/auth/auth.controller.ts | 62 + apps/backend/src/modules/auth/auth.router.ts | 14 + apps/backend/src/modules/auth/auth.schema.ts | 33 + apps/backend/src/modules/auth/auth.service.ts | 229 + .../src/modules/budgets/budgets.controller.ts | 31 + .../src/modules/budgets/budgets.router.ts | 14 + .../src/modules/budgets/budgets.schema.ts | 16 + .../src/modules/budgets/budgets.service.ts | 159 + .../notifications/notifications.controller.ts | 33 + .../notifications/notifications.router.ts | 8 + .../transactions/transactions.controller.ts | 87 + .../transactions/transactions.router.ts | 27 + .../transactions/transactions.schema.ts | 35 + .../transactions/transactions.service.ts | 240 ++ apps/backend/src/routes/index.ts | 20 + apps/backend/src/scripts/seed.ts | 107 + apps/backend/src/services/user-context.ts | 33 + apps/backend/src/utils/id.ts | 3 + apps/backend/tsconfig.json | 17 + apps/frontend/.gitignore | 24 + apps/frontend/.vscode/extensions.json | 3 + apps/frontend/README.md | 5 + apps/frontend/index.html | 13 + apps/frontend/package.json | 30 + apps/frontend/postcss.config.js | 6 + apps/frontend/public/vite.svg | 1 + apps/frontend/src/App.vue | 19 + apps/frontend/src/assets/main.css | 19 + apps/frontend/src/assets/vue.svg | 1 + .../components/actions/QuickActionButton.vue | 18 + .../src/components/actions/QuickAddButton.vue | 38 + .../src/components/budgets/BudgetCard.vue | 51 + .../src/components/cards/OverviewCard.vue | 33 + .../src/components/common/LucideIcon.vue | 27 + .../components/navigation/BottomTabBar.vue | 30 + .../transactions/TransactionItem.vue | 75 + apps/frontend/src/composables/useAnalysis.ts | 32 + apps/frontend/src/composables/useBudgets.ts | 79 + .../src/composables/useNotifications.ts | 39 + .../src/composables/useTransactions.ts | 85 + .../features/analysis/pages/AnalysisPage.vue | 149 + .../src/features/auth/pages/AuthLayout.vue | 11 + .../auth/pages/ForgotPasswordPage.vue | 54 + .../src/features/auth/pages/LoginPage.vue | 81 + .../src/features/auth/pages/RegisterPage.vue | 102 + .../dashboard/pages/DashboardPage.vue | 115 + .../features/settings/pages/SettingsPage.vue | 316 ++ .../transactions/pages/TransactionsPage.vue | 181 + apps/frontend/src/lib/api/client.ts | 21 + apps/frontend/src/main.ts | 23 + apps/frontend/src/mocks/analysis.ts | 16 + apps/frontend/src/mocks/budgets.ts | 24 + apps/frontend/src/mocks/transactions.ts | 57 + apps/frontend/src/router/index.ts | 67 + apps/frontend/src/stores/auth.ts | 41 + apps/frontend/src/types/analysis.ts | 18 + apps/frontend/src/types/budget.ts | 22 + apps/frontend/src/types/transaction.ts | 32 + apps/frontend/tailwind.config.js | 24 + apps/frontend/tsconfig.app.json | 16 + apps/frontend/tsconfig.json | 7 + apps/frontend/tsconfig.node.json | 26 + apps/frontend/vite.config.ts | 7 + docker-compose.yml | 13 + package.json | 21 + pnpm-lock.yaml | 3678 +++++++++++++++++ pnpm-workspace.yaml | 2 + 87 files changed, 7558 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 apps/backend/.env.example create mode 100644 apps/backend/.eslintrc.cjs create mode 100644 apps/backend/package.json create mode 100644 apps/backend/src/app.ts create mode 100644 apps/backend/src/config/database.ts create mode 100644 apps/backend/src/config/env.ts create mode 100644 apps/backend/src/config/logger.ts create mode 100644 apps/backend/src/main.ts create mode 100644 apps/backend/src/middlewares/error-handler.ts create mode 100644 apps/backend/src/middlewares/not-found.ts create mode 100644 apps/backend/src/middlewares/validate-request.ts create mode 100644 apps/backend/src/models/budget.model.ts create mode 100644 apps/backend/src/models/transaction.model.ts create mode 100644 apps/backend/src/models/user.model.ts create mode 100644 apps/backend/src/modules/analysis/analysis.controller.ts create mode 100644 apps/backend/src/modules/analysis/analysis.router.ts create mode 100644 apps/backend/src/modules/analysis/analysis.schema.ts create mode 100644 apps/backend/src/modules/analysis/analysis.service.ts create mode 100644 apps/backend/src/modules/auth/auth.controller.ts create mode 100644 apps/backend/src/modules/auth/auth.router.ts create mode 100644 apps/backend/src/modules/auth/auth.schema.ts create mode 100644 apps/backend/src/modules/auth/auth.service.ts create mode 100644 apps/backend/src/modules/budgets/budgets.controller.ts create mode 100644 apps/backend/src/modules/budgets/budgets.router.ts create mode 100644 apps/backend/src/modules/budgets/budgets.schema.ts create mode 100644 apps/backend/src/modules/budgets/budgets.service.ts create mode 100644 apps/backend/src/modules/notifications/notifications.controller.ts create mode 100644 apps/backend/src/modules/notifications/notifications.router.ts create mode 100644 apps/backend/src/modules/transactions/transactions.controller.ts create mode 100644 apps/backend/src/modules/transactions/transactions.router.ts create mode 100644 apps/backend/src/modules/transactions/transactions.schema.ts create mode 100644 apps/backend/src/modules/transactions/transactions.service.ts create mode 100644 apps/backend/src/routes/index.ts create mode 100644 apps/backend/src/scripts/seed.ts create mode 100644 apps/backend/src/services/user-context.ts create mode 100644 apps/backend/src/utils/id.ts create mode 100644 apps/backend/tsconfig.json create mode 100644 apps/frontend/.gitignore create mode 100644 apps/frontend/.vscode/extensions.json create mode 100644 apps/frontend/README.md create mode 100644 apps/frontend/index.html create mode 100644 apps/frontend/package.json create mode 100644 apps/frontend/postcss.config.js create mode 100644 apps/frontend/public/vite.svg create mode 100644 apps/frontend/src/App.vue create mode 100644 apps/frontend/src/assets/main.css create mode 100644 apps/frontend/src/assets/vue.svg create mode 100644 apps/frontend/src/components/actions/QuickActionButton.vue create mode 100644 apps/frontend/src/components/actions/QuickAddButton.vue create mode 100644 apps/frontend/src/components/budgets/BudgetCard.vue create mode 100644 apps/frontend/src/components/cards/OverviewCard.vue create mode 100644 apps/frontend/src/components/common/LucideIcon.vue create mode 100644 apps/frontend/src/components/navigation/BottomTabBar.vue create mode 100644 apps/frontend/src/components/transactions/TransactionItem.vue create mode 100644 apps/frontend/src/composables/useAnalysis.ts create mode 100644 apps/frontend/src/composables/useBudgets.ts create mode 100644 apps/frontend/src/composables/useNotifications.ts create mode 100644 apps/frontend/src/composables/useTransactions.ts create mode 100644 apps/frontend/src/features/analysis/pages/AnalysisPage.vue create mode 100644 apps/frontend/src/features/auth/pages/AuthLayout.vue create mode 100644 apps/frontend/src/features/auth/pages/ForgotPasswordPage.vue create mode 100644 apps/frontend/src/features/auth/pages/LoginPage.vue create mode 100644 apps/frontend/src/features/auth/pages/RegisterPage.vue create mode 100644 apps/frontend/src/features/dashboard/pages/DashboardPage.vue create mode 100644 apps/frontend/src/features/settings/pages/SettingsPage.vue create mode 100644 apps/frontend/src/features/transactions/pages/TransactionsPage.vue create mode 100644 apps/frontend/src/lib/api/client.ts create mode 100644 apps/frontend/src/main.ts create mode 100644 apps/frontend/src/mocks/analysis.ts create mode 100644 apps/frontend/src/mocks/budgets.ts create mode 100644 apps/frontend/src/mocks/transactions.ts create mode 100644 apps/frontend/src/router/index.ts create mode 100644 apps/frontend/src/stores/auth.ts create mode 100644 apps/frontend/src/types/analysis.ts create mode 100644 apps/frontend/src/types/budget.ts create mode 100644 apps/frontend/src/types/transaction.ts create mode 100644 apps/frontend/tailwind.config.js create mode 100644 apps/frontend/tsconfig.app.json create mode 100644 apps/frontend/tsconfig.json create mode 100644 apps/frontend/tsconfig.node.json create mode 100644 apps/frontend/vite.config.ts create mode 100644 docker-compose.yml create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2917600 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules +apps/frontend/dist +apps/backend/dist +*.local +.env +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..c8d370e --- /dev/null +++ b/README.md @@ -0,0 +1,75 @@ +# AI 记账应用框架 + +基于《项目设计蓝图》与《UI 设计规范》搭建的前后端 Monorepo。当前版本提供可运行的基础框架、示例数据与 API 接口骨架,现已补齐账户认证、预算管理、交易通知入库等核心流程,便于继续迭代为完整的 AI 记账应用。 + +## 项目结构 + +``` +├── apps +│ ├── backend # Node.js + Express + TypeScript,提供 REST API 骨架 +│ └── frontend # Vue 3 + Vite + TailwindCSS,包含移动端风格 UI +├── pnpm-workspace.yaml +├── package.json +└── README.md +``` + +## 快速开始 + +1. 安装依赖(首次执行已完成可跳过): + ```bash + pnpm install + ``` +2. (可选)启动内置 MongoDB(需 Docker 环境): + ```bash + pnpm db:up + pnpm --filter backend seed # 首次运行:写入默认用户/预算/交易 + ``` + 结束后可通过 `pnpm db:down` 停止容器。 +3. 启动后端(默认端口 4000): + ```bash + pnpm dev:backend + ``` +4. 启动前端(默认端口 5173): + ```bash + pnpm dev:frontend + ``` +5. 或者并行启动: + ```bash + pnpm dev + ``` + +## 后端说明(apps/backend) +- 技术栈:Express、TypeScript、Zod、Mongoose、Pino。 +- `.env.example` 给出了基础环境变量;开发时可复制为 `.env.local`。 +- 支持 MongoDB 或内存双模式运行:当 `MONGODB_URI` 未设置时自动回退到示例数据,方便离线体验。若使用内置 Docker 服务,可配置 `MONGODB_URI=mongodb://root:example@127.0.0.1:27017/ai-bill?authSource=admin`。 +- 核心 API: + - `POST /api/auth/login|register|refresh|logout|forgot-password` + - `GET /api/transactions`、`POST /api/transactions`、`PATCH /api/transactions/:id`、`DELETE /api/transactions/:id` + - `POST /api/transactions/notification`(HMAC 签名校验的通知入库) + - `GET /api/analysis/habits`(基于实时交易聚合)与 `POST /api/analysis/calories` + - `GET /api/budgets`、`POST /api/budgets`、`PATCH /api/budgets/:id`、`DELETE /api/budgets/:id` + - `GET /api/notifications/status`(返回 webhook 地址、密钥提示、通知包名白名单及最近入库信息) +- `pnpm --filter backend build` 进行编译,`pnpm --filter backend lint` 执行 ESLint。 + +## 前端说明(apps/frontend) +- 技术栈:Vue 3、TypeScript、Pinia、Vue Router、@tanstack/vue-query、TailwindCSS。 +- UI 与交互设计遵循 `UI设计规范 design_system.md` 与 `ui_mockup.html`。 +- 关键页面与能力: + - 仪表盘:展示当月收入/支出统计、预算剩余、快捷操作。 + - 交易列表:支持按类型筛选、新增、备注、删除,区分数据来源(手动/通知/OCR/AI)。 + - AI 分析:实时消费洞察 + 卡路里估算对话体验。 + - 设置中心:账户信息、偏好开关、预算卡片管理(新增/删除、阈值滑杆)。 + - 认证流程:登录/注册/找回密码,提供「体验模式」快捷入口。 +- 默认通过 Axios 访问后端 API,无法连接时自动回退到本地示例数据。 +- 若需要跨端访问,可在 `apps/frontend/.env.local` 中调整 `VITE_API_BASE_URL`(默认指向 `http://localhost:4000/api`)。 +- 构建与预览: + ```bash + pnpm --filter frontend build + pnpm --filter frontend preview + ``` + +## 下一步建议 +- 接入真实登录态(JWT 校验、刷新、登出)、以及通知监听插件(Android `NotificationListenerService`)调用示例。 +- 引入 OCR / LLM 能力(Google Vision、Gemini 等)补齐票据识别与消费分析的真实数据链路。 +- 补充自动化测试:Vitest(前端组件/状态)、Jest + Supertest(后端 API)、Playwright(端到端流程)。 +- 优化移动端体验:离线缓存、渐进式加载、通知权限引导流程。 diff --git a/apps/backend/.env.example b/apps/backend/.env.example new file mode 100644 index 0000000..c5ad5ef --- /dev/null +++ b/apps/backend/.env.example @@ -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= diff --git a/apps/backend/.eslintrc.cjs b/apps/backend/.eslintrc.cjs new file mode 100644 index 0000000..3d43999 --- /dev/null +++ b/apps/backend/.eslintrc.cjs @@ -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 }] + } +}; diff --git a/apps/backend/package.json b/apps/backend/package.json new file mode 100644 index 0000000..e24bb30 --- /dev/null +++ b/apps/backend/package.json @@ -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" + } +} diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts new file mode 100644 index 0000000..76a43d1 --- /dev/null +++ b/apps/backend/src/app.ts @@ -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; +} diff --git a/apps/backend/src/config/database.ts b/apps/backend/src/config/database.ts new file mode 100644 index 0000000..11d1db8 --- /dev/null +++ b/apps/backend/src/config/database.ts @@ -0,0 +1,45 @@ +import mongoose from 'mongoose'; +import { env } from './env.js'; +import { logger } from './logger.js'; + +export async function connectToDatabase(): Promise { + 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 { + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + logger.info('MongoDB disconnected'); + } +} diff --git a/apps/backend/src/config/env.ts b/apps/backend/src/config/env.ts new file mode 100644 index 0000000..77dee2d --- /dev/null +++ b/apps/backend/src/config/env.ts @@ -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; diff --git a/apps/backend/src/config/logger.ts b/apps/backend/src/config/logger.ts new file mode 100644 index 0000000..c3b0765 --- /dev/null +++ b/apps/backend/src/config/logger.ts @@ -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' + } + } +}); diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts new file mode 100644 index 0000000..0a34e05 --- /dev/null +++ b/apps/backend/src/main.ts @@ -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); +}); diff --git a/apps/backend/src/middlewares/error-handler.ts b/apps/backend/src/middlewares/error-handler.ts new file mode 100644 index 0000000..715a73b --- /dev/null +++ b/apps/backend/src/middlewares/error-handler.ts @@ -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 + }); +}; diff --git a/apps/backend/src/middlewares/not-found.ts b/apps/backend/src/middlewares/not-found.ts new file mode 100644 index 0000000..b5fd9c7 --- /dev/null +++ b/apps/backend/src/middlewares/not-found.ts @@ -0,0 +1,5 @@ +import type { RequestHandler } from 'express'; + +export const notFoundHandler: RequestHandler = (_req, res) => { + res.status(404).json({ message: 'Resource not found' }); +}; diff --git a/apps/backend/src/middlewares/validate-request.ts b/apps/backend/src/middlewares/validate-request.ts new file mode 100644 index 0000000..1ae63cc --- /dev/null +++ b/apps/backend/src/middlewares/validate-request.ts @@ -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); + } + }; +}; diff --git a/apps/backend/src/models/budget.model.ts b/apps/backend/src/models/budget.model.ts new file mode 100644 index 0000000..4f9b950 --- /dev/null +++ b/apps/backend/src/models/budget.model.ts @@ -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; + +export const BudgetModel = mongoose.models.Budget ?? mongoose.model('Budget', budgetSchema); diff --git a/apps/backend/src/models/transaction.model.ts b/apps/backend/src/models/transaction.model.ts new file mode 100644 index 0000000..7a7c73b --- /dev/null +++ b/apps/backend/src/models/transaction.model.ts @@ -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; + +export const TransactionModel = mongoose.models.Transaction ?? mongoose.model('Transaction', transactionSchema); diff --git a/apps/backend/src/models/user.model.ts b/apps/backend/src/models/user.model.ts new file mode 100644 index 0000000..6c02479 --- /dev/null +++ b/apps/backend/src/models/user.model.ts @@ -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; + +export const UserModel = mongoose.models.User ?? mongoose.model('User', userSchema); diff --git a/apps/backend/src/modules/analysis/analysis.controller.ts b/apps/backend/src/modules/analysis/analysis.controller.ts new file mode 100644 index 0000000..a189495 --- /dev/null +++ b/apps/backend/src/modules/analysis/analysis.controller.ts @@ -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); +}; diff --git a/apps/backend/src/modules/analysis/analysis.router.ts b/apps/backend/src/modules/analysis/analysis.router.ts new file mode 100644 index 0000000..1b769a3 --- /dev/null +++ b/apps/backend/src/modules/analysis/analysis.router.ts @@ -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; diff --git a/apps/backend/src/modules/analysis/analysis.schema.ts b/apps/backend/src/modules/analysis/analysis.schema.ts new file mode 100644 index 0000000..52ff6c4 --- /dev/null +++ b/apps/backend/src/modules/analysis/analysis.schema.ts @@ -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') +}); diff --git a/apps/backend/src/modules/analysis/analysis.service.ts b/apps/backend/src/modules/analysis/analysis.service.ts new file mode 100644 index 0000000..7539863 --- /dev/null +++ b/apps/backend/src/modules/analysis/analysis.service.ts @@ -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(); + 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 { + 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 = { + '麦辣鸡腿堡': { + 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 + }; +} diff --git a/apps/backend/src/modules/auth/auth.controller.ts b/apps/backend/src/modules/auth/auth.controller.ts new file mode 100644 index 0000000..ed1218d --- /dev/null +++ b/apps/backend/src/modules/auth/auth.controller.ts @@ -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(); +}; diff --git a/apps/backend/src/modules/auth/auth.router.ts b/apps/backend/src/modules/auth/auth.router.ts new file mode 100644 index 0000000..b2c893e --- /dev/null +++ b/apps/backend/src/modules/auth/auth.router.ts @@ -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; diff --git a/apps/backend/src/modules/auth/auth.schema.ts b/apps/backend/src/modules/auth/auth.schema.ts new file mode 100644 index 0000000..ec2cab3 --- /dev/null +++ b/apps/backend/src/modules/auth/auth.schema.ts @@ -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; +export type LoginInput = z.infer; diff --git a/apps/backend/src/modules/auth/auth.service.ts b/apps/backend/src/modules/auth/auth.service.ts new file mode 100644 index 0000000..9e309be --- /dev/null +++ b/apps/backend/src/modules/auth/auth.service.ts @@ -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(); +const refreshTokenStore = new Map(); + +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 => { + if (isDatabaseReady()) { + const doc = await UserModel.findOne({ email }).lean(); + return doc ? toAuthUser(doc) : null; + } + + return memoryUsers.get(email) ?? null; +}; + +const saveUser = async (user: AuthUser): Promise => { + 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() + : [...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)'); +} diff --git a/apps/backend/src/modules/budgets/budgets.controller.ts b/apps/backend/src/modules/budgets/budgets.controller.ts new file mode 100644 index 0000000..0256917 --- /dev/null +++ b/apps/backend/src/modules/budgets/budgets.controller.ts @@ -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(); +}; diff --git a/apps/backend/src/modules/budgets/budgets.router.ts b/apps/backend/src/modules/budgets/budgets.router.ts new file mode 100644 index 0000000..27e1717 --- /dev/null +++ b/apps/backend/src/modules/budgets/budgets.router.ts @@ -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; diff --git a/apps/backend/src/modules/budgets/budgets.schema.ts b/apps/backend/src/modules/budgets/budgets.schema.ts new file mode 100644 index 0000000..37506d5 --- /dev/null +++ b/apps/backend/src/modules/budgets/budgets.schema.ts @@ -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; +export type UpdateBudgetInput = z.infer; diff --git a/apps/backend/src/modules/budgets/budgets.service.ts b/apps/backend/src/modules/budgets/budgets.service.ts new file mode 100644 index 0000000..bdac921 --- /dev/null +++ b/apps/backend/src/modules/budgets/budgets.service.ts @@ -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 { + if (isDatabaseReady()) { + const userId = await getDefaultUserId(); + const docs = await BudgetModel.find({ userId }).lean().exec(); + return (docs as Array).map(toDto); + } + + return [...memoryBudgets].sort((a, b) => a.category.localeCompare(b.category)); +} + +export async function createBudget(payload: CreateBudgetInput): Promise { + 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 { + 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 { + 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; +} diff --git a/apps/backend/src/modules/notifications/notifications.controller.ts b/apps/backend/src/modules/notifications/notifications.controller.ts new file mode 100644 index 0000000..392abcc --- /dev/null +++ b/apps/backend/src/modules/notifications/notifications.controller.ts @@ -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); +}; diff --git a/apps/backend/src/modules/notifications/notifications.router.ts b/apps/backend/src/modules/notifications/notifications.router.ts new file mode 100644 index 0000000..a58e648 --- /dev/null +++ b/apps/backend/src/modules/notifications/notifications.router.ts @@ -0,0 +1,8 @@ +import { Router } from 'express'; +import { getNotificationStatus } from './notifications.controller.js'; + +const router = Router(); + +router.get('/status', getNotificationStatus); + +export default router; diff --git a/apps/backend/src/modules/transactions/transactions.controller.ts b/apps/backend/src/modules/transactions/transactions.controller.ts new file mode 100644 index 0000000..56744bf --- /dev/null +++ b/apps/backend/src/modules/transactions/transactions.controller.ts @@ -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 + }); +}; diff --git a/apps/backend/src/modules/transactions/transactions.router.ts b/apps/backend/src/modules/transactions/transactions.router.ts new file mode 100644 index 0000000..a2b7fe3 --- /dev/null +++ b/apps/backend/src/modules/transactions/transactions.router.ts @@ -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; diff --git a/apps/backend/src/modules/transactions/transactions.schema.ts b/apps/backend/src/modules/transactions/transactions.schema.ts new file mode 100644 index 0000000..65961bb --- /dev/null +++ b/apps/backend/src/modules/transactions/transactions.schema.ts @@ -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; +export type UpdateTransactionInput = z.infer; diff --git a/apps/backend/src/modules/transactions/transactions.service.ts b/apps/backend/src/modules/transactions/transactions.service.ts new file mode 100644 index 0000000..9637a04 --- /dev/null +++ b/apps/backend/src/modules/transactions/transactions.service.ts @@ -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; + 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 { + if (isDatabaseReady()) { + const userId = await getDefaultUserId(); + const docs = await TransactionModel.find({ userId }) + .sort({ occurredAt: -1 }) + .lean() + .exec(); + return (docs as Array).map(toDto); + } + return memoryTransactions.slice().sort((a, b) => (a.occurredAt < b.occurredAt ? 1 : -1)); +} + +export async function getTransactionById(id: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 } + }); +} diff --git a/apps/backend/src/routes/index.ts b/apps/backend/src/routes/index.ts new file mode 100644 index 0000000..c971d8b --- /dev/null +++ b/apps/backend/src/routes/index.ts @@ -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 }; diff --git a/apps/backend/src/scripts/seed.ts b/apps/backend/src/scripts/seed.ts new file mode 100644 index 0000000..e1ae7d2 --- /dev/null +++ b/apps/backend/src/scripts/seed.ts @@ -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(); + }); diff --git a/apps/backend/src/services/user-context.ts b/apps/backend/src/services/user-context.ts new file mode 100644 index 0000000..179b562 --- /dev/null +++ b/apps/backend/src/services/user-context.ts @@ -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 { + 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; +} diff --git a/apps/backend/src/utils/id.ts b/apps/backend/src/utils/id.ts new file mode 100644 index 0000000..f4c9d88 --- /dev/null +++ b/apps/backend/src/utils/id.ts @@ -0,0 +1,3 @@ +import { randomUUID } from 'node:crypto'; + +export const createId = () => randomUUID(); diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json new file mode 100644 index 0000000..6b0085b --- /dev/null +++ b/apps/backend/tsconfig.json @@ -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"] +} diff --git a/apps/frontend/.gitignore b/apps/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/apps/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/apps/frontend/.vscode/extensions.json b/apps/frontend/.vscode/extensions.json new file mode 100644 index 0000000..a7cea0b --- /dev/null +++ b/apps/frontend/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["Vue.volar"] +} diff --git a/apps/frontend/README.md b/apps/frontend/README.md new file mode 100644 index 0000000..33895ab --- /dev/null +++ b/apps/frontend/README.md @@ -0,0 +1,5 @@ +# Vue 3 + TypeScript + Vite + +This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 ` + + diff --git a/apps/frontend/package.json b/apps/frontend/package.json new file mode 100644 index 0000000..67a55df --- /dev/null +++ b/apps/frontend/package.json @@ -0,0 +1,30 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/vue-query": "^5", + "axios": "^1.7", + "lucide-vue-next": "^0.463.0", + "pinia": "2", + "vue": "^3.5.22", + "vue-router": "4" + }, + "devDependencies": { + "@types/node": "^24.6.0", + "@vitejs/plugin-vue": "^6.0.1", + "@vue/tsconfig": "^0.8.1", + "autoprefixer": "^10.4.21", + "postcss": "^8.5.6", + "tailwindcss": "^3.4", + "typescript": "~5.9.3", + "vite": "^7.1.7", + "vue-tsc": "^3.1.0" + } +} diff --git a/apps/frontend/postcss.config.js b/apps/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/apps/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/apps/frontend/public/vite.svg b/apps/frontend/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/apps/frontend/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/frontend/src/App.vue b/apps/frontend/src/App.vue new file mode 100644 index 0000000..cf93509 --- /dev/null +++ b/apps/frontend/src/App.vue @@ -0,0 +1,19 @@ + + + diff --git a/apps/frontend/src/assets/main.css b/apps/frontend/src/assets/main.css new file mode 100644 index 0000000..e25e46c --- /dev/null +++ b/apps/frontend/src/assets/main.css @@ -0,0 +1,19 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + color-scheme: light; +} + +body { + @apply bg-gray-100 text-gray-900 font-sans antialiased; +} + +a { + @apply text-indigo-500 hover:text-indigo-600; +} + +#app { + @apply min-h-screen; +} diff --git a/apps/frontend/src/assets/vue.svg b/apps/frontend/src/assets/vue.svg new file mode 100644 index 0000000..770e9d3 --- /dev/null +++ b/apps/frontend/src/assets/vue.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/frontend/src/components/actions/QuickActionButton.vue b/apps/frontend/src/components/actions/QuickActionButton.vue new file mode 100644 index 0000000..cdc0221 --- /dev/null +++ b/apps/frontend/src/components/actions/QuickActionButton.vue @@ -0,0 +1,18 @@ + + + diff --git a/apps/frontend/src/components/actions/QuickAddButton.vue b/apps/frontend/src/components/actions/QuickAddButton.vue new file mode 100644 index 0000000..db766a4 --- /dev/null +++ b/apps/frontend/src/components/actions/QuickAddButton.vue @@ -0,0 +1,38 @@ + + + diff --git a/apps/frontend/src/components/budgets/BudgetCard.vue b/apps/frontend/src/components/budgets/BudgetCard.vue new file mode 100644 index 0000000..3ea3164 --- /dev/null +++ b/apps/frontend/src/components/budgets/BudgetCard.vue @@ -0,0 +1,51 @@ + + + diff --git a/apps/frontend/src/components/cards/OverviewCard.vue b/apps/frontend/src/components/cards/OverviewCard.vue new file mode 100644 index 0000000..fd9c123 --- /dev/null +++ b/apps/frontend/src/components/cards/OverviewCard.vue @@ -0,0 +1,33 @@ + + + diff --git a/apps/frontend/src/components/common/LucideIcon.vue b/apps/frontend/src/components/common/LucideIcon.vue new file mode 100644 index 0000000..7405887 --- /dev/null +++ b/apps/frontend/src/components/common/LucideIcon.vue @@ -0,0 +1,27 @@ + + + diff --git a/apps/frontend/src/components/navigation/BottomTabBar.vue b/apps/frontend/src/components/navigation/BottomTabBar.vue new file mode 100644 index 0000000..c459efd --- /dev/null +++ b/apps/frontend/src/components/navigation/BottomTabBar.vue @@ -0,0 +1,30 @@ + + + diff --git a/apps/frontend/src/components/transactions/TransactionItem.vue b/apps/frontend/src/components/transactions/TransactionItem.vue new file mode 100644 index 0000000..d3d8cd7 --- /dev/null +++ b/apps/frontend/src/components/transactions/TransactionItem.vue @@ -0,0 +1,75 @@ + + + diff --git a/apps/frontend/src/composables/useAnalysis.ts b/apps/frontend/src/composables/useAnalysis.ts new file mode 100644 index 0000000..f0c21bd --- /dev/null +++ b/apps/frontend/src/composables/useAnalysis.ts @@ -0,0 +1,32 @@ +import { useMutation, useQuery } from '@tanstack/vue-query'; +import { computed, isRef, ref, type Ref } from 'vue'; +import { apiClient } from '../lib/api/client'; +import { sampleSpendingInsight } from '../mocks/analysis'; +import type { CalorieResponse, SpendingInsight } from '../types/analysis'; + +export function useSpendingInsightQuery(input: Ref<'30d' | '90d'> | '30d' | '90d' = '30d') { + const range = isRef(input) ? input : ref(input); + + return useQuery({ + queryKey: computed(() => ['analysis', range.value]), + queryFn: async () => { + try { + const { data } = await apiClient.get('/analysis/habits', { params: { range: range.value } }); + return data as SpendingInsight; + } catch (error) { + console.warn('[analysis] fallback to sample data', error); + return sampleSpendingInsight; + } + }, + initialData: sampleSpendingInsight + }); +} + +export function useCalorieEstimationMutation() { + return useMutation({ + mutationFn: async (query: string) => { + const { data } = await apiClient.post('/analysis/calories', { query }); + return data as CalorieResponse; + } + }); +} diff --git a/apps/frontend/src/composables/useBudgets.ts b/apps/frontend/src/composables/useBudgets.ts new file mode 100644 index 0000000..3a536e6 --- /dev/null +++ b/apps/frontend/src/composables/useBudgets.ts @@ -0,0 +1,79 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'; +import { apiClient } from '../lib/api/client'; +import { sampleBudgets } from '../mocks/budgets'; +import type { Budget, BudgetPayload } from '../types/budget'; + +const queryKey = ['budgets']; + +const mapBudget = (raw: any): Budget => ({ + id: raw.id, + category: raw.category, + amount: Number(raw.amount ?? 0), + currency: raw.currency ?? 'CNY', + period: raw.period ?? 'monthly', + threshold: raw.threshold ?? 0.8, + usage: raw.usage ?? raw.used ?? 0, + userId: raw.userId ?? undefined, + createdAt: raw.createdAt, + updatedAt: raw.updatedAt +}); + +export function useBudgetsQuery() { + return useQuery({ + queryKey, + queryFn: async () => { + try { + const { data } = await apiClient.get('/budgets'); + return (data.data as Budget[]).map(mapBudget); + } catch (error) { + console.warn('[budgets] fallback to sample data', error); + return sampleBudgets; + } + }, + initialData: sampleBudgets + }); +} + +export function useCreateBudgetMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (payload: BudgetPayload) => { + const { data } = await apiClient.post('/budgets', payload); + return mapBudget(data.data); + }, + onSuccess: (budget) => { + queryClient.setQueryData(queryKey, (existing = []) => [...existing, budget]); + } + }); +} + +export function useUpdateBudgetMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ id, payload }: { id: string; payload: Partial }) => { + const { data } = await apiClient.patch(`/budgets/${id}`, payload); + return mapBudget(data.data); + }, + onSuccess: (budget) => { + queryClient.setQueryData(queryKey, (existing = []) => + existing.map((item) => (item.id === budget.id ? budget : item)) + ); + } + }); +} + +export function useDeleteBudgetMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (id: string) => { + await apiClient.delete(`/budgets/${id}`); + return id; + }, + onSuccess: (id) => { + queryClient.setQueryData(queryKey, (existing = []) => existing.filter((item) => item.id !== id)); + } + }); +} diff --git a/apps/frontend/src/composables/useNotifications.ts b/apps/frontend/src/composables/useNotifications.ts new file mode 100644 index 0000000..b858a87 --- /dev/null +++ b/apps/frontend/src/composables/useNotifications.ts @@ -0,0 +1,39 @@ +import { useQuery } from '@tanstack/vue-query'; +import { apiClient } from '../lib/api/client'; + +export interface NotificationStatus { + secretConfigured: boolean; + secretHint: string | null; + webhookSecret: string | null; + packageWhitelist: string[]; + ingestedCount: number; + lastNotificationAt: string | null; + ingestEndpoint: string; +} + +const initialStatus: NotificationStatus = { + secretConfigured: false, + secretHint: null, + webhookSecret: null, + packageWhitelist: [], + ingestedCount: 0, + lastNotificationAt: null, + ingestEndpoint: 'http://localhost:4000/api/transactions/notification' +}; + +export function useNotificationStatusQuery() { + return useQuery({ + queryKey: ['notification-status'], + queryFn: async () => { + try { + const { data } = await apiClient.get('/notifications/status'); + return data as NotificationStatus; + } catch (error) { + console.warn('[notifications] using fallback status', error); + return initialStatus; + } + }, + initialData: initialStatus, + staleTime: 30_000 + }); +} diff --git a/apps/frontend/src/composables/useTransactions.ts b/apps/frontend/src/composables/useTransactions.ts new file mode 100644 index 0000000..ca79cae --- /dev/null +++ b/apps/frontend/src/composables/useTransactions.ts @@ -0,0 +1,85 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'; +import { apiClient } from '../lib/api/client'; +import { sampleTransactions } from '../mocks/transactions'; +import type { Transaction, TransactionPayload } from '../types/transaction'; + +const queryKey = ['transactions']; + +const mapTransaction = (raw: any): Transaction => ({ + id: raw.id, + title: raw.title, + description: raw.description ?? raw.notes ?? undefined, + icon: raw.icon, + amount: Number(raw.amount ?? 0), + currency: raw.currency ?? 'CNY', + category: raw.category ?? '未分类', + type: raw.type ?? 'expense', + source: raw.source ?? 'manual', + occurredAt: raw.occurredAt, + status: raw.status ?? 'pending', + notes: raw.notes, + metadata: raw.metadata ?? undefined, + userId: raw.userId ?? undefined, + createdAt: raw.createdAt, + updatedAt: raw.updatedAt +}); + +export function useTransactionsQuery() { + return useQuery({ + queryKey, + queryFn: async () => { + try { + const { data } = await apiClient.get('/transactions'); + return (data.data as Transaction[]).map(mapTransaction); + } catch (error) { + console.warn('[transactions] fallback to sample data', error); + return sampleTransactions; + } + }, + initialData: sampleTransactions + }); +} + +export function useCreateTransactionMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (payload: TransactionPayload) => { + const { data } = await apiClient.post('/transactions', payload); + return mapTransaction(data.data); + }, + onSuccess: (transaction) => { + queryClient.setQueryData(queryKey, (existing = []) => [transaction, ...existing]); + } + }); +} + +export function useUpdateTransactionMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ id, payload }: { id: string; payload: Partial }) => { + const { data } = await apiClient.patch(`/transactions/${id}`, payload); + return mapTransaction(data.data); + }, + onSuccess: (transaction) => { + queryClient.setQueryData(queryKey, (existing = []) => + existing?.map((item) => (item.id === transaction.id ? transaction : item)) ?? [transaction] + ); + } + }); +} + +export function useDeleteTransactionMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (id: string) => { + await apiClient.delete(`/transactions/${id}`); + return id; + }, + onSuccess: (id) => { + queryClient.setQueryData(queryKey, (existing = []) => existing.filter((item) => item.id !== id)); + } + }); +} diff --git a/apps/frontend/src/features/analysis/pages/AnalysisPage.vue b/apps/frontend/src/features/analysis/pages/AnalysisPage.vue new file mode 100644 index 0000000..7e9e350 --- /dev/null +++ b/apps/frontend/src/features/analysis/pages/AnalysisPage.vue @@ -0,0 +1,149 @@ + + + diff --git a/apps/frontend/src/features/auth/pages/AuthLayout.vue b/apps/frontend/src/features/auth/pages/AuthLayout.vue new file mode 100644 index 0000000..ce966da --- /dev/null +++ b/apps/frontend/src/features/auth/pages/AuthLayout.vue @@ -0,0 +1,11 @@ + + + diff --git a/apps/frontend/src/features/auth/pages/ForgotPasswordPage.vue b/apps/frontend/src/features/auth/pages/ForgotPasswordPage.vue new file mode 100644 index 0000000..d242e65 --- /dev/null +++ b/apps/frontend/src/features/auth/pages/ForgotPasswordPage.vue @@ -0,0 +1,54 @@ + + + diff --git a/apps/frontend/src/features/auth/pages/LoginPage.vue b/apps/frontend/src/features/auth/pages/LoginPage.vue new file mode 100644 index 0000000..b50c9ba --- /dev/null +++ b/apps/frontend/src/features/auth/pages/LoginPage.vue @@ -0,0 +1,81 @@ + + + diff --git a/apps/frontend/src/features/auth/pages/RegisterPage.vue b/apps/frontend/src/features/auth/pages/RegisterPage.vue new file mode 100644 index 0000000..557b047 --- /dev/null +++ b/apps/frontend/src/features/auth/pages/RegisterPage.vue @@ -0,0 +1,102 @@ + + + diff --git a/apps/frontend/src/features/dashboard/pages/DashboardPage.vue b/apps/frontend/src/features/dashboard/pages/DashboardPage.vue new file mode 100644 index 0000000..3e2414e --- /dev/null +++ b/apps/frontend/src/features/dashboard/pages/DashboardPage.vue @@ -0,0 +1,115 @@ + + + diff --git a/apps/frontend/src/features/settings/pages/SettingsPage.vue b/apps/frontend/src/features/settings/pages/SettingsPage.vue new file mode 100644 index 0000000..5eb4d4f --- /dev/null +++ b/apps/frontend/src/features/settings/pages/SettingsPage.vue @@ -0,0 +1,316 @@ + + + diff --git a/apps/frontend/src/features/transactions/pages/TransactionsPage.vue b/apps/frontend/src/features/transactions/pages/TransactionsPage.vue new file mode 100644 index 0000000..1625710 --- /dev/null +++ b/apps/frontend/src/features/transactions/pages/TransactionsPage.vue @@ -0,0 +1,181 @@ + + +