diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9b73032 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +# syntax=docker/dockerfile:1.7 + +FROM node:20-bullseye-slim AS base +ENV PNPM_HOME=/root/.local/share/pnpm +ENV PATH=$PNPM_HOME:$PATH +RUN corepack enable + +FROM base AS deps +WORKDIR /app +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY apps/backend/package.json ./apps/backend/ +RUN pnpm install --filter backend... --frozen-lockfile + +FROM deps AS build +COPY apps ./apps +RUN pnpm --filter backend build + +FROM base AS runtime +WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY apps/backend/package.json ./apps/backend/ +RUN pnpm install --prod --filter backend +COPY --from=build /app/apps/backend/dist ./apps/backend/dist +WORKDIR /app/apps/backend +EXPOSE 4000 +ENV NODE_ENV=production +CMD ["node", "dist/main.js"] diff --git a/apps/backend/src/config/database.ts b/apps/backend/src/config/database.ts index 11d1db8..01ea989 100644 --- a/apps/backend/src/config/database.ts +++ b/apps/backend/src/config/database.ts @@ -4,8 +4,8 @@ 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; + logger.error('Database connection failed: MONGODB_URI is not set.'); + throw new Error('MONGODB_URI must be provided to connect to MongoDB.'); } try { diff --git a/apps/backend/src/middlewares/authenticate.ts b/apps/backend/src/middlewares/authenticate.ts new file mode 100644 index 0000000..a0b6093 --- /dev/null +++ b/apps/backend/src/middlewares/authenticate.ts @@ -0,0 +1,42 @@ +import type { NextFunction, Request, Response } from 'express'; +import mongoose from 'mongoose'; +import jwt from 'jsonwebtoken'; +import { env } from '../config/env.js'; +import { UserModel } from '../models/user.model.js'; + +export async function authenticate(req: Request, res: Response, next: NextFunction) { + try { + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ message: 'Authentication required' }); + } + + const token = authHeader.slice('Bearer '.length).trim(); + if (!token) { + return res.status(401).json({ message: 'Authentication required' }); + } + + const payload = jwt.verify(token, env.JWT_SECRET) as { sub: string; email?: string }; + const userId = payload.sub; + if (!userId) { + return res.status(401).json({ message: 'Invalid access token' }); + } + + const user = await UserModel.findById(userId) + .select('_id email') + .lean<{ _id: mongoose.Types.ObjectId; email: string }>() + .exec(); + if (!user) { + return res.status(401).json({ message: 'User not found' }); + } + + req.user = { + id: user._id.toString(), + email: user.email + }; + + return next(); + } catch (error) { + return res.status(401).json({ message: 'Invalid or expired token' }); + } +} diff --git a/apps/backend/src/models/budget.model.ts b/apps/backend/src/models/budget.model.ts index 4f9b950..d76a33f 100644 --- a/apps/backend/src/models/budget.model.ts +++ b/apps/backend/src/models/budget.model.ts @@ -8,7 +8,7 @@ const budgetSchema = new mongoose.Schema( period: { type: String, enum: ['monthly', 'weekly'], default: 'monthly' }, threshold: { type: Number, min: 0, max: 1, default: 0.8 }, usage: { type: Number, min: 0, default: 0 }, - userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: false } + userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true } }, { timestamps: true } ); diff --git a/apps/backend/src/models/transaction.model.ts b/apps/backend/src/models/transaction.model.ts index 7a7c73b..b2ee12a 100644 --- a/apps/backend/src/models/transaction.model.ts +++ b/apps/backend/src/models/transaction.model.ts @@ -12,7 +12,7 @@ const transactionSchema = new mongoose.Schema( occurredAt: { type: Date, required: true }, notes: { type: String }, metadata: { type: mongoose.Schema.Types.Mixed }, - userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: false } + userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true } }, { timestamps: true } ); diff --git a/apps/backend/src/modules/analysis/analysis.controller.ts b/apps/backend/src/modules/analysis/analysis.controller.ts index a189495..e9db834 100644 --- a/apps/backend/src/modules/analysis/analysis.controller.ts +++ b/apps/backend/src/modules/analysis/analysis.controller.ts @@ -2,12 +2,18 @@ import type { Request, Response } from 'express'; import { buildSpendingInsights, estimateCalories } from './analysis.service.js'; export const getSpendingInsights = async (req: Request, res: Response) => { + if (!req.user) { + return res.status(401).json({ message: 'Authentication required' }); + } const range = (req.query.range as '30d' | '90d') ?? '30d'; - const data = await buildSpendingInsights(range); + const data = await buildSpendingInsights(range, req.user.id); return res.json(data); }; export const estimateCaloriesHandler = (req: Request, res: Response) => { + if (!req.user) { + return res.status(401).json({ message: 'Authentication required' }); + } const { query } = req.body as { query: string }; const data = estimateCalories(query); return res.json(data); diff --git a/apps/backend/src/modules/analysis/analysis.router.ts b/apps/backend/src/modules/analysis/analysis.router.ts index 1b769a3..0e0a852 100644 --- a/apps/backend/src/modules/analysis/analysis.router.ts +++ b/apps/backend/src/modules/analysis/analysis.router.ts @@ -1,10 +1,13 @@ import { Router } from 'express'; +import { authenticate } from '../../middlewares/authenticate.js'; import { validateRequest } from '../../middlewares/validate-request.js'; import { analysisQuerySchema, calorieRequestSchema } from './analysis.schema.js'; import { getSpendingInsights, estimateCaloriesHandler } from './analysis.controller.js'; const router = Router(); +router.use(authenticate); + router.get('/habits', validateRequest({ query: analysisQuerySchema }), getSpendingInsights); router.post('/calories', validateRequest({ body: calorieRequestSchema }), estimateCaloriesHandler); diff --git a/apps/backend/src/modules/analysis/analysis.service.ts b/apps/backend/src/modules/analysis/analysis.service.ts index 7539863..d23b217 100644 --- a/apps/backend/src/modules/analysis/analysis.service.ts +++ b/apps/backend/src/modules/analysis/analysis.service.ts @@ -50,8 +50,11 @@ const buildRecommendations = (topCategories: CategoryInsight[], totalExpense: nu }); }; -export async function buildSpendingInsights(range: '30d' | '90d' = '30d'): Promise { - const transactions = await listTransactions(); +export async function buildSpendingInsights( + range: '30d' | '90d' = '30d', + userId: string +): Promise { + const transactions = await listTransactions(userId); const now = Date.now(); const windowDays = range === '30d' ? 30 : 90; const windowStart = now - windowDays * DAY_IN_MS; diff --git a/apps/backend/src/modules/auth/auth.service.ts b/apps/backend/src/modules/auth/auth.service.ts index 9e309be..d2209ce 100644 --- a/apps/backend/src/modules/auth/auth.service.ts +++ b/apps/backend/src/modules/auth/auth.service.ts @@ -3,7 +3,6 @@ import jwt from 'jsonwebtoken'; import mongoose from 'mongoose'; import { env } from '../../config/env.js'; import { UserModel, type UserDocument } from '../../models/user.model.js'; -import { createId } from '../../utils/id.js'; import { logger } from '../../config/logger.js'; const ACCESS_TOKEN_TTL = '15m'; @@ -26,81 +25,38 @@ interface AuthTokens { refreshExpiresIn: number; } -const memoryUsers = new Map(); const refreshTokenStore = new Map(); -const isDatabaseReady = () => mongoose.connection.readyState === 1; +type UserDocumentWithId = UserDocument & { _id: mongoose.Types.ObjectId }; -const toAuthUser = (user: (UserDocument & { _id?: mongoose.Types.ObjectId }) | AuthUser): AuthUser => { - if ('passwordHash' in user && 'id' in user) { - return user as AuthUser; - } +const toAuthUser = (doc: UserDocumentWithId): AuthUser => ({ + id: doc._id.toString(), + email: doc.email, + displayName: doc.displayName, + passwordHash: doc.passwordHash, + preferredCurrency: doc.preferredCurrency, + notificationPermissionGranted: doc.notificationPermissionGranted ?? false, + avatarUrl: doc.avatarUrl ?? undefined +}); - const doc = user as UserDocument & { _id: mongoose.Types.ObjectId }; - return { - id: doc._id.toString(), - email: doc.email, - displayName: doc.displayName, - passwordHash: doc.passwordHash, - preferredCurrency: doc.preferredCurrency, - notificationPermissionGranted: doc.notificationPermissionGranted ?? false, - avatarUrl: doc.avatarUrl ?? undefined - }; -}; +const profileFromUser = (user: AuthUser) => ({ + id: user.id, + email: user.email, + displayName: user.displayName, + avatarUrl: user.avatarUrl, + preferredCurrency: user.preferredCurrency, + notificationPermissionGranted: user.notificationPermissionGranted +}); -const seedUsers = async () => { - if (memoryUsers.size > 0) return; - const passwordHash = await bcrypt.hash('Password123!', 10); - const user: AuthUser = { - id: createId(), - email: 'user@example.com', - displayName: '示例用户', - passwordHash, - preferredCurrency: 'CNY', - notificationPermissionGranted: true - }; +async function getUserByEmail(email: string): Promise { + const doc = await UserModel.findOne({ email }).lean().exec(); + return doc ? toAuthUser(doc) : null; +} - 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 }); - } -}; +async function getUserById(id: string): Promise { + const doc = await UserModel.findById(id).lean().exec(); + return doc ? toAuthUser(doc) : null; +} const signTokens = (user: AuthUser): AuthTokens => { const nowSeconds = Math.floor(Date.now() / 1000); @@ -134,15 +90,6 @@ const signTokens = (user: AuthUser): AuthTokens => { }; }; -const profileFromUser = (user: AuthUser) => ({ - id: user.id, - email: user.email, - displayName: user.displayName, - avatarUrl: user.avatarUrl, - preferredCurrency: user.preferredCurrency, - notificationPermissionGranted: user.notificationPermissionGranted -}); - export async function registerUser(input: { email: string; password: string; @@ -155,18 +102,21 @@ export async function registerUser(input: { } const passwordHash = await bcrypt.hash(input.password, 10); - const user: AuthUser = { - id: createId(), + const created = await UserModel.create({ email: input.email, + passwordHash, displayName: input.displayName, preferredCurrency: input.preferredCurrency, - notificationPermissionGranted: false, - passwordHash - }; + notificationPermissionGranted: false + }); + const createdDoc = await UserModel.findById(created._id).lean().exec(); + if (!createdDoc) { + throw new Error('Failed to load created user'); + } - const saved = await saveUser(user); - const tokens = signTokens(saved); - return { user: profileFromUser(saved), tokens }; + const user = toAuthUser(createdDoc); + const tokens = signTokens(user); + return { user: profileFromUser(user), tokens }; } export async function authenticateUser(input: { email: string; password: string }) { @@ -194,15 +144,11 @@ export async function refreshSession(refreshToken: string) { throw new Error('Refresh token revoked'); } - const userDoc = isDatabaseReady() - ? await UserModel.findById(payload.sub).lean() - : [...memoryUsers.values()].find((user) => user.id === payload.sub) ?? null; - - if (!userDoc) { + const user = await getUserById(payload.sub); + if (!user) { throw new Error('User not found'); } - const user = toAuthUser(userDoc as AuthUser); refreshTokenStore.delete(refreshToken); const tokens = signTokens(user); return { user: profileFromUser(user), tokens }; @@ -224,6 +170,6 @@ export async function requestPasswordReset(email: string) { const tempPassword = Math.random().toString(36).slice(-10); const passwordHash = await bcrypt.hash(tempPassword, 10); - await updateUserPassword(email, passwordHash); + await UserModel.updateOne({ email }, { passwordHash }).exec(); logger.info({ email }, 'Password reset token generated (simulated)'); } diff --git a/apps/backend/src/modules/budgets/budgets.controller.ts b/apps/backend/src/modules/budgets/budgets.controller.ts index 0256917..72c238a 100644 --- a/apps/backend/src/modules/budgets/budgets.controller.ts +++ b/apps/backend/src/modules/budgets/budgets.controller.ts @@ -2,20 +2,29 @@ import type { Request, Response } from 'express'; import { listBudgets, createBudget, updateBudget, deleteBudget } from './budgets.service.js'; import type { CreateBudgetInput, UpdateBudgetInput } from './budgets.schema.js'; -export const listBudgetsHandler = async (_req: Request, res: Response) => { - const data = await listBudgets(); +export const listBudgetsHandler = async (req: Request, res: Response) => { + if (!req.user) { + return res.status(401).json({ message: 'Authentication required' }); + } + const data = await listBudgets(req.user.id); return res.json({ data }); }; export const createBudgetHandler = async (req: Request, res: Response) => { + if (!req.user) { + return res.status(401).json({ message: 'Authentication required' }); + } const payload = req.body as CreateBudgetInput; - const budget = await createBudget(payload); + const budget = await createBudget(payload, req.user.id); return res.status(201).json({ data: budget }); }; export const updateBudgetHandler = async (req: Request, res: Response) => { + if (!req.user) { + return res.status(401).json({ message: 'Authentication required' }); + } const payload = req.body as UpdateBudgetInput; - const budget = await updateBudget(req.params.id, payload); + const budget = await updateBudget(req.params.id, payload, req.user.id); if (!budget) { return res.status(404).json({ message: 'Budget not found' }); } @@ -23,7 +32,10 @@ export const updateBudgetHandler = async (req: Request, res: Response) => { }; export const deleteBudgetHandler = async (req: Request, res: Response) => { - const removed = await deleteBudget(req.params.id); + if (!req.user) { + return res.status(401).json({ message: 'Authentication required' }); + } + const removed = await deleteBudget(req.params.id, req.user.id); if (!removed) { return res.status(404).json({ message: 'Budget not found' }); } diff --git a/apps/backend/src/modules/budgets/budgets.router.ts b/apps/backend/src/modules/budgets/budgets.router.ts index 27e1717..a058701 100644 --- a/apps/backend/src/modules/budgets/budgets.router.ts +++ b/apps/backend/src/modules/budgets/budgets.router.ts @@ -1,11 +1,14 @@ import { Router } from 'express'; import { z } from 'zod'; +import { authenticate } from '../../middlewares/authenticate.js'; import { validateRequest } from '../../middlewares/validate-request.js'; import { createBudgetSchema, updateBudgetSchema } from './budgets.schema.js'; import { listBudgetsHandler, createBudgetHandler, updateBudgetHandler, deleteBudgetHandler } from './budgets.controller.js'; const router = Router(); +router.use(authenticate); + router.get('/', listBudgetsHandler); router.post('/', validateRequest({ body: createBudgetSchema }), createBudgetHandler); router.patch('/:id', validateRequest({ params: z.object({ id: z.string().min(1) }), body: updateBudgetSchema }), updateBudgetHandler); diff --git a/apps/backend/src/modules/budgets/budgets.service.ts b/apps/backend/src/modules/budgets/budgets.service.ts index bdac921..9569b2f 100644 --- a/apps/backend/src/modules/budgets/budgets.service.ts +++ b/apps/backend/src/modules/budgets/budgets.service.ts @@ -1,7 +1,5 @@ import mongoose from 'mongoose'; import { BudgetModel, type BudgetDocument } from '../../models/budget.model.js'; -import { createId } from '../../utils/id.js'; -import { getDefaultUserId } from '../../services/user-context.js'; import type { CreateBudgetInput, UpdateBudgetInput } from './budgets.schema.js'; export interface BudgetDto { @@ -17,17 +15,11 @@ export interface BudgetDto { updatedAt: string; } -interface MemoryBudget extends BudgetDto {} - -const memoryBudgets: MemoryBudget[] = []; - -const isDatabaseReady = () => mongoose.connection.readyState === 1; - -const toDto = (doc: BudgetDocument & { _id?: mongoose.Types.ObjectId } & { id?: string }): BudgetDto => { - const id = typeof doc.id === 'string' ? doc.id : doc._id ? doc._id.toString() : createId(); +type BudgetDocumentWithId = BudgetDocument & { _id: mongoose.Types.ObjectId }; +const toDto = (doc: BudgetDocumentWithId): BudgetDto => { return { - id, + id: doc._id.toString(), category: doc.category, amount: doc.amount, currency: doc.currency ?? 'CNY', @@ -40,120 +32,43 @@ const toDto = (doc: BudgetDocument & { _id?: mongoose.Types.ObjectId } & { id?: }; }; -const seedBudgets = () => { - if (memoryBudgets.length > 0) return; - const now = new Date().toISOString(); - memoryBudgets.push( - { - id: createId(), - category: '餐饮', - amount: 2000, - currency: 'CNY', - period: 'monthly', - threshold: 0.8, - usage: 1250, - userId: 'demo-user', - createdAt: now, - updatedAt: now - }, - { - id: createId(), - category: '交通', - amount: 600, - currency: 'CNY', - period: 'monthly', - threshold: 0.75, - usage: 320, - userId: 'demo-user', - createdAt: now, - updatedAt: now - } - ); -}; - -seedBudgets(); - -export async function listBudgets(): Promise { - 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 listBudgets(userId: string): Promise { + const docs = await BudgetModel.find({ userId }).sort({ createdAt: 1 }).lean().exec(); + return docs.map(toDto); } -export async function createBudget(payload: CreateBudgetInput): Promise { +export async function createBudget(payload: CreateBudgetInput, userId: string): Promise { const defaults = { currency: payload.currency ?? 'CNY', usage: payload.usage ?? 0 }; + const { userId: _ignoredUserId, ...rest } = payload; - if (isDatabaseReady()) { - const userId = payload.userId ?? (await getDefaultUserId()); - const created = await BudgetModel.create({ ...payload, ...defaults, userId }); - return toDto(created); + const created = await BudgetModel.create({ + ...rest, + ...defaults, + userId + }); + const createdDoc = await BudgetModel.findById(created._id).lean().exec(); + if (!createdDoc) { + throw new Error('Failed to load created budget'); } - - const now = new Date().toISOString(); - const budget: MemoryBudget = { - id: createId(), - category: payload.category, - amount: payload.amount, - currency: defaults.currency, - period: payload.period, - threshold: payload.threshold, - usage: defaults.usage ?? 0, - userId: payload.userId, - createdAt: now, - updatedAt: now - }; - - memoryBudgets.push(budget); - return budget; + return toDto(createdDoc); } -export async function updateBudget(id: string, payload: UpdateBudgetInput): Promise { - if (isDatabaseReady()) { - const userId = await getDefaultUserId(); - const updated = await BudgetModel.findOneAndUpdate( - { _id: id, userId }, - { ...payload }, - { new: true } - ); - return updated ? toDto(updated) : null; - } - - const index = memoryBudgets.findIndex((budget) => budget.id === id); - if (index === -1) { +export async function updateBudget(id: string, payload: UpdateBudgetInput, userId: string): Promise { + const updated = await BudgetModel.findOneAndUpdate( + { _id: id, userId }, + { ...payload }, + { new: true } + ) + .lean() + .exec(); + if (!updated) { return null; } - const existing = memoryBudgets[index]; - const updated: MemoryBudget = { - ...existing, - ...payload, - amount: payload.amount ?? existing.amount, - usage: payload.usage ?? existing.usage, - threshold: payload.threshold ?? existing.threshold, - period: payload.period ?? existing.period, - currency: payload.currency ?? existing.currency, - updatedAt: new Date().toISOString() - }; - - memoryBudgets[index] = updated; - return updated; + return toDto(updated); } -export async function deleteBudget(id: string): Promise { - if (isDatabaseReady()) { - const userId = await getDefaultUserId(); - const result = await BudgetModel.findOneAndDelete({ _id: id, userId }); - return result !== null; - } - - const index = memoryBudgets.findIndex((budget) => budget.id === id); - if (index === -1) { - return false; - } - - memoryBudgets.splice(index, 1); - return true; +export async function deleteBudget(id: string, userId: string): Promise { + const result = await BudgetModel.findOneAndDelete({ _id: id, userId }).exec(); + return result !== null; } diff --git a/apps/backend/src/modules/notifications/notifications.controller.ts b/apps/backend/src/modules/notifications/notifications.controller.ts index 392abcc..0627e7f 100644 --- a/apps/backend/src/modules/notifications/notifications.controller.ts +++ b/apps/backend/src/modules/notifications/notifications.controller.ts @@ -1,7 +1,6 @@ import type { Request, Response } from 'express'; import { env } from '../../config/env.js'; import { TransactionModel } from '../../models/transaction.model.js'; -import { getDefaultUserId } from '../../services/user-context.js'; const maskSecret = (secret?: string | null) => { if (!secret) return null; @@ -10,8 +9,10 @@ const maskSecret = (secret?: string | null) => { }; export const getNotificationStatus = async (req: Request, res: Response) => { - const userId = await getDefaultUserId(); - const filter = { userId, source: 'notification' as const }; + if (!req.user) { + return res.status(401).json({ message: 'Authentication required' }); + } + const filter = { userId: req.user.id, source: 'notification' as const }; const count = await TransactionModel.countDocuments(filter); const latest = (await TransactionModel.findOne(filter).sort({ createdAt: -1 }).lean()) as | { createdAt?: Date } diff --git a/apps/backend/src/modules/notifications/notifications.router.ts b/apps/backend/src/modules/notifications/notifications.router.ts index a58e648..77363e9 100644 --- a/apps/backend/src/modules/notifications/notifications.router.ts +++ b/apps/backend/src/modules/notifications/notifications.router.ts @@ -1,8 +1,11 @@ import { Router } from 'express'; +import { authenticate } from '../../middlewares/authenticate.js'; import { getNotificationStatus } from './notifications.controller.js'; const router = Router(); +router.use(authenticate); + router.get('/status', getNotificationStatus); export default router; diff --git a/apps/backend/src/modules/transactions/notification.parser.ts b/apps/backend/src/modules/transactions/notification.parser.ts new file mode 100644 index 0000000..d44a5cb --- /dev/null +++ b/apps/backend/src/modules/transactions/notification.parser.ts @@ -0,0 +1,71 @@ +import { classifyByText, extractAmountFromText } from '../../services/classification.service.js'; +import type { TransactionDto } from './transactions.service.js'; + +export interface ParsedNotification { + amount?: number; + type?: 'income' | 'expense'; + category?: string; + notes?: string; + ruleId?: string; + confidence?: number; +} + +export function parseNotificationPayload(title: string, body: string): ParsedNotification { + const combined = `${title}|${body}`; + + const parsed: ParsedNotification = { + notes: body + }; + + const classification = classifyByText(title, body); + if (classification.category) { + parsed.category = classification.category; + } + if (classification.type) { + parsed.type = classification.type; + } + parsed.ruleId = classification.ruleId; + parsed.confidence = classification.confidence; + + const amount = extractAmountFromText(title, body); + if (amount !== undefined) { + parsed.amount = amount; + } + + if (!parsed.type) { + if (combined.includes('到账') || combined.includes('收入') || combined.includes('入账')) { + parsed.type = 'income'; + } else if (combined.includes('付款') || combined.includes('消费') || combined.includes('支出') || combined.includes('已扣款')) { + parsed.type = 'expense'; + } + } + + if (!parsed.category && parsed.type === 'income') { + parsed.category = '收入'; + } else if (!parsed.category && parsed.type === 'expense') { + parsed.category = '其他支出'; + } + + if (!parsed.type) { + parsed.type = parsed.category === '收入' ? 'income' : 'expense'; + } + + return parsed; +} + +export function mergeParsedNotification(transaction: TransactionDto, parsed: ParsedNotification): Partial { + const updates: Partial = {}; + if (parsed.amount !== undefined) { + updates.amount = parsed.amount; + } + if (parsed.category) { + updates.category = parsed.category; + } + if (parsed.type) { + updates.type = parsed.type; + } + if (parsed.notes) { + updates.notes = parsed.notes; + } + return updates; +} diff --git a/apps/backend/src/modules/transactions/transactions.controller.ts b/apps/backend/src/modules/transactions/transactions.controller.ts index 56744bf..63d6a16 100644 --- a/apps/backend/src/modules/transactions/transactions.controller.ts +++ b/apps/backend/src/modules/transactions/transactions.controller.ts @@ -12,19 +12,28 @@ import { } from './transactions.service.js'; import type { CreateTransactionInput, UpdateTransactionInput } from './transactions.schema.js'; -export const listTransactionsHandler = async (_req: Request, res: Response) => { - const data = await listTransactions(); +export const listTransactionsHandler = async (req: Request, res: Response) => { + if (!req.user) { + return res.status(401).json({ message: 'Authentication required' }); + } + const data = await listTransactions(req.user.id); return res.json({ data }); }; export const createTransactionHandler = async (req: Request, res: Response) => { + if (!req.user) { + return res.status(401).json({ message: 'Authentication required' }); + } const payload = req.body as CreateTransactionInput; - const transaction = await createTransaction(payload); + const transaction = await createTransaction(payload, req.user.id); return res.status(201).json({ data: transaction }); }; export const getTransactionHandler = async (req: Request, res: Response) => { - const transaction = await getTransactionById(req.params.id); + if (!req.user) { + return res.status(401).json({ message: 'Authentication required' }); + } + const transaction = await getTransactionById(req.params.id, req.user.id); if (!transaction) { return res.status(404).json({ message: 'Transaction not found' }); } @@ -32,8 +41,11 @@ export const getTransactionHandler = async (req: Request, res: Response) => { }; export const updateTransactionHandler = async (req: Request, res: Response) => { + if (!req.user) { + return res.status(401).json({ message: 'Authentication required' }); + } const payload = req.body as UpdateTransactionInput; - const transaction = await updateTransaction(req.params.id, payload); + const transaction = await updateTransaction(req.params.id, payload, req.user.id); if (!transaction) { return res.status(404).json({ message: 'Transaction not found' }); } @@ -41,7 +53,10 @@ export const updateTransactionHandler = async (req: Request, res: Response) => { }; export const deleteTransactionHandler = async (req: Request, res: Response) => { - const removed = await deleteTransaction(req.params.id); + if (!req.user) { + return res.status(401).json({ message: 'Authentication required' }); + } + const removed = await deleteTransaction(req.params.id, req.user.id); if (!removed) { return res.status(404).json({ message: 'Transaction not found' }); } diff --git a/apps/backend/src/modules/transactions/transactions.router.ts b/apps/backend/src/modules/transactions/transactions.router.ts index a2b7fe3..83289a7 100644 --- a/apps/backend/src/modules/transactions/transactions.router.ts +++ b/apps/backend/src/modules/transactions/transactions.router.ts @@ -1,4 +1,5 @@ import { Router } from 'express'; +import { authenticate } from '../../middlewares/authenticate.js'; import { validateRequest } from '../../middlewares/validate-request.js'; import { createTransactionSchema, @@ -17,11 +18,14 @@ import { const router = Router(); +router.post('/notification', validateRequest({ body: notificationSchema }), createTransactionFromNotificationHandler); + +router.use(authenticate); + router.get('/', listTransactionsHandler); router.post('/', validateRequest({ body: createTransactionSchema }), createTransactionHandler); router.get('/:id', validateRequest({ params: transactionIdSchema }), getTransactionHandler); router.patch('/:id', validateRequest({ params: transactionIdSchema, body: updateTransactionSchema }), updateTransactionHandler); router.delete('/:id', validateRequest({ params: transactionIdSchema }), deleteTransactionHandler); -router.post('/notification', validateRequest({ body: notificationSchema }), createTransactionFromNotificationHandler); export default router; diff --git a/apps/backend/src/modules/transactions/transactions.service.ts b/apps/backend/src/modules/transactions/transactions.service.ts index 9637a04..95eab5b 100644 --- a/apps/backend/src/modules/transactions/transactions.service.ts +++ b/apps/backend/src/modules/transactions/transactions.service.ts @@ -1,9 +1,9 @@ import mongoose from 'mongoose'; import { TransactionModel, type TransactionDocument } from '../../models/transaction.model.js'; -import { createId } from '../../utils/id.js'; -import { logger } from '../../config/logger.js'; +import { classifyByText } from '../../services/classification.service.js'; import { getDefaultUserId } from '../../services/user-context.js'; import type { CreateTransactionInput, UpdateTransactionInput } from './transactions.schema.js'; +import { parseNotificationPayload } from './notification.parser.js'; export interface TransactionDto { id: string; @@ -22,17 +22,11 @@ export interface TransactionDto { updatedAt: string; } -interface MemoryTransaction extends TransactionDto {} - -const memoryTransactions: MemoryTransaction[] = []; - -const isDatabaseReady = () => mongoose.connection.readyState === 1; - -const toDto = (doc: TransactionDocument & { _id?: mongoose.Types.ObjectId } & { id?: string }): TransactionDto => { - const rawId = typeof doc.id === 'string' ? doc.id : doc._id ? doc._id.toString() : createId(); +type TransactionDocumentWithId = TransactionDocument & { _id: mongoose.Types.ObjectId }; +const toDto = (doc: TransactionDocumentWithId): TransactionDto => { return { - id: rawId, + id: doc._id.toString(), title: doc.title, amount: doc.amount, currency: doc.currency ?? 'CNY', @@ -49,171 +43,95 @@ const toDto = (doc: TransactionDocument & { _id?: mongoose.Types.ObjectId } & { }; }; -const seedMemoryTransactions = () => { - if (memoryTransactions.length > 0) return; - const now = new Date(); - memoryTransactions.push( - { - id: createId(), - title: '星巴克咖啡', - amount: 32.5, - currency: 'CNY', - category: '报销', - type: 'expense', - source: 'notification', - status: 'confirmed', - occurredAt: now.toISOString(), - notes: '餐饮 | 早餐', - metadata: { packageName: 'com.eg.android.AlipayGphone' }, - userId: 'demo-user', - createdAt: now.toISOString(), - updatedAt: now.toISOString() - }, - { - id: createId(), - title: '地铁充值', - amount: 100, - currency: 'CNY', - category: '交通', - type: 'expense', - source: 'manual', - status: 'confirmed', - occurredAt: new Date(now.getTime() - 86_400_000).toISOString(), - userId: 'demo-user', - createdAt: now.toISOString(), - updatedAt: now.toISOString() - }, - { - id: createId(), - title: '午餐报销', - amount: 58, - currency: 'CNY', - category: '餐饮', - type: 'income', - source: 'ocr', - status: 'pending', - occurredAt: new Date(now.getTime() - 172_800_000).toISOString(), - notes: '审批中', - userId: 'demo-user', - createdAt: now.toISOString(), - updatedAt: now.toISOString() - } - ); -}; - -seedMemoryTransactions(); - -export async function listTransactions(): Promise { - 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 listTransactions(userId: string): Promise { + const docs = await TransactionModel.find({ userId }) + .sort({ occurredAt: -1 }) + .lean() + .exec(); + return docs.map(toDto); } -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 getTransactionById(id: string, userId: string): Promise { + const doc = await TransactionModel.findOne({ _id: id, userId }).lean().exec(); + return doc ? toDto(doc) : null; } -export async function createTransaction(payload: CreateTransactionInput): Promise { +export async function createTransaction(payload: CreateTransactionInput, userId?: string): Promise { const normalizedAmount = Math.abs(payload.amount); const occuredDate = payload.occurredAt instanceof Date ? payload.occurredAt : new Date(payload.occurredAt); + const needsAutoCategory = + !payload.category || ['未分类', '待分类', '待分類', '类别待定', '待确认'].includes(payload.category); + const classification = needsAutoCategory ? classifyByText(payload.title, payload.notes) : null; - if (isDatabaseReady()) { - const userId = payload.userId ?? (await getDefaultUserId()); - const created = await TransactionModel.create({ - ...payload, - source: payload.source ?? 'manual', - currency: payload.currency ?? 'CNY', - amount: normalizedAmount, - occurredAt: occuredDate, - userId - }); - return toDto(created); - } + const resolvedCategory = classification?.category ?? payload.category ?? (classification?.type === 'income' ? '收入' : '其他支出'); + const resolvedType = classification?.type ?? payload.type; + const metadata = + classification && classification.ruleId + ? { + ...payload.metadata, + classification: { + ruleId: classification.ruleId, + confidence: classification.confidence + } + } + : payload.metadata; - const now = new Date(); - const transaction: MemoryTransaction = { - id: createId(), - title: payload.title, - amount: normalizedAmount, - currency: payload.currency ?? 'CNY', - category: payload.category, - type: payload.type, + const resolvedUserId = userId ?? payload.userId ?? (await getDefaultUserId()); + const { userId: _ignoredUserId, ...restPayload } = payload; + const created = await TransactionModel.create({ + ...restPayload, + category: resolvedCategory, + type: resolvedType, source: payload.source ?? 'manual', - status: payload.status, - occurredAt: occuredDate.toISOString(), - notes: payload.notes, - metadata: payload.metadata, - userId: payload.userId, - createdAt: now.toISOString(), - updatedAt: now.toISOString() - }; - - memoryTransactions.unshift(transaction); - logger.debug({ id: transaction.id }, 'Transaction created (memory)'); - - return transaction; + currency: payload.currency ?? 'CNY', + amount: normalizedAmount, + occurredAt: occuredDate, + metadata, + userId: resolvedUserId + }); + const createdDoc = await TransactionModel.findById(created._id).lean().exec(); + if (!createdDoc) { + throw new Error('Failed to load created transaction'); + } + return toDto(createdDoc); } -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; - } +export async function updateTransaction( + id: string, + payload: UpdateTransactionInput, + userId: string +): Promise { + const { userId: _ignoredUserId, ...restPayload } = payload; - const index = memoryTransactions.findIndex((txn) => txn.id === id); - if (index === -1) { - return null; - } - - const existing = memoryTransactions[index]; - const updated: MemoryTransaction = { - ...existing, - ...payload, - amount: payload.amount !== undefined ? Math.abs(payload.amount) : existing.amount, - occurredAt: payload.occurredAt ? new Date(payload.occurredAt).toISOString() : existing.occurredAt, - updatedAt: new Date().toISOString() + const updatePayload: Partial = { + ...restPayload, + currency: restPayload.currency ?? undefined, + source: restPayload.source ?? undefined }; - memoryTransactions[index] = updated; - return updated; + if (restPayload.amount !== undefined) { + updatePayload.amount = Math.abs(restPayload.amount); + } + + if (restPayload.occurredAt) { + updatePayload.occurredAt = + restPayload.occurredAt instanceof Date ? restPayload.occurredAt : new Date(restPayload.occurredAt); + } + + const updated = await TransactionModel.findOneAndUpdate( + { _id: id, userId }, + updatePayload, + { new: true } + ) + .lean() + .exec(); + + return updated ? toDto(updated) : null; } -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; +export async function deleteTransaction(id: string, userId: string): Promise { + const result = await TransactionModel.findOneAndDelete({ _id: id, userId }).exec(); + return result !== null; } interface NotificationPayload { @@ -225,16 +143,25 @@ interface NotificationPayload { export async function ingestNotification(payload: NotificationPayload): Promise { const now = payload.receivedAt; - return createTransaction({ + const parsed = parseNotificationPayload(payload.title ?? '', payload.body ?? ''); + + const transactionPayload: CreateTransactionInput = { title: payload.title, - amount: 0, + amount: parsed.amount ?? 0, currency: 'CNY', - category: '待分类', - type: 'expense', + category: parsed.category ?? '待分类', + type: parsed.type ?? 'expense', source: 'notification', status: 'pending', occurredAt: now, - notes: payload.body, - metadata: { packageName: payload.packageName } - }); + notes: parsed.notes ?? payload.body, + metadata: { + packageName: payload.packageName, + rawBody: payload.body, + parser: parsed + } + }; + + const userId = await getDefaultUserId(); + return createTransaction(transactionPayload, userId); } diff --git a/apps/backend/src/services/classification.service.ts b/apps/backend/src/services/classification.service.ts new file mode 100644 index 0000000..1e339c8 --- /dev/null +++ b/apps/backend/src/services/classification.service.ts @@ -0,0 +1,94 @@ +interface ClassificationRule { + id: string; + pattern: RegExp; + category: string; + type: 'income' | 'expense'; + priority: number; + fallbackCategory?: string; +} + +export interface ClassificationResult { + category?: string; + type?: 'income' | 'expense'; + ruleId?: string; + confidence: number; +} + +const CURRENCY_SYMBOLS = ['¥', '¥', '元', '块', '人民币', 'rmb', 'cny', 'usd', '$']; + +const classificationRules: ClassificationRule[] = [ + { id: 'income-salary', pattern: /(工资|薪资|发放|到账|转入|收入|结算)/i, category: '收入', type: 'income', priority: 95 }, + { id: 'income-reimbursement', pattern: /(报销|退税|补贴|退款成功)/i, category: '报销', type: 'income', priority: 92 }, + { id: 'food-coffee', pattern: /(星巴克|瑞幸|咖啡|coffee|餐饮|午餐|早餐|美团|饿了么|必胜客|肯德基|kfc|麦当劳)/i, category: '餐饮', type: 'expense', priority: 90 }, + { id: 'transport', pattern: /(地铁|公交|交通|打车|滴滴|高德|出行|出租|单车|骑行|12306|铁路|航班|航空)/i, category: '交通', type: 'expense', priority: 88 }, + { id: 'shopping', pattern: /(淘宝|天猫|京东|拼多多|超市|购物|商城|消费|唯品会|苏宁|抖音商城)/i, category: '购物', type: 'expense', priority: 86 }, + { id: 'housing-bills', pattern: /(房租|租金|水费|电费|燃气|煤气|物业|宽带|话费|缴费|电信|联通|移动|固话)/i, category: '生活缴费', type: 'expense', priority: 84 }, + { id: 'transfer-out', pattern: /(转出|转账|扣款|支付成功|扣费|已支付)/i, category: '转账', type: 'expense', priority: 82, fallbackCategory: '其他支出' }, + { id: 'health', pattern: /(医院|医疗|挂号|药店|体检|医保|诊所|药房)/i, category: '医疗', type: 'expense', priority: 80 }, + { id: 'education', pattern: /(学费|培训|教育|课程|考试|教材|网课)/i, category: '教育', type: 'expense', priority: 78 }, + { id: 'entertainment', pattern: /(会员|视频|音乐|游戏|腾讯视频|爱奇艺|优酷|哔哩哔哩|bilibili|网易云|spotify|netflix|影院|电影)/i, category: '娱乐', type: 'expense', priority: 76 }, + { id: 'transfer-in', pattern: /(退款|退回|退货|返还|返现)/i, category: '退款', type: 'income', priority: 74, fallbackCategory: '收入' }, + { id: 'investment', pattern: /(基金|股票|理财|收益|红利|分红|申购|赎回)/i, category: '理财', type: 'expense', priority: 72 }, + { id: 'charity', pattern: /(捐赠|公益|慈善)/i, category: '公益', type: 'expense', priority: 70 } +]; + +const sortedRules = [...classificationRules].sort((a, b) => b.priority - a.priority); + +const normalizeText = (title: string, body?: string) => { + const merged = `${title ?? ''} ${body ?? ''}`; + return merged.replace(/\s+/g, ' ').trim(); +}; + +export function classifyByText(title: string, body?: string): ClassificationResult { + const normalized = normalizeText(title, body); + if (!normalized) { + return { confidence: 0.1 }; + } + + for (const rule of sortedRules) { + if (rule.pattern.test(normalized)) { + return { + category: rule.category, + type: rule.type, + ruleId: rule.id, + confidence: rule.priority / 100 + }; + } + } + + if (/收(款|到)|到账/.test(normalized)) { + return { category: '收入', type: 'income', confidence: 0.6, ruleId: 'fallback-income' }; + } + + if (/付款|支出|扣款|消费/.test(normalized)) { + return { category: '其他支出', type: 'expense', confidence: 0.5, ruleId: 'fallback-expense' }; + } + + return { confidence: 0.1 }; +} + +export function extractAmountFromText(title: string, body?: string): number | undefined { + const normalized = normalizeText(title, body).toLowerCase(); + const sanitized = normalized.replace(/[,,。]/g, ' '); + + const amountPatterns = [ + /(?:¥|¥|\$|人民币|rmb|cny)\s*([-+]?\d+(?:\.\d{1,2})?)/i, + /([-+]?\d+(?:\.\d{1,2})?)\s*(?:元|块|圆)/i, + /金额[::]?\s*([-+]?\d+(?:\.\d{1,2})?)/i, + /([-+]?\d+(?:\.\d{1,2})?)(?:\s*(?:元|块|圆))?/i + ]; + + for (const pattern of amountPatterns) { + const match = sanitized.match(pattern); + if (match) { + const raw = match[1]; + const cleaned = CURRENCY_SYMBOLS.reduce((acc, symbol) => acc.replace(symbol, ''), raw); + const parsed = Number.parseFloat(cleaned); + if (!Number.isNaN(parsed)) { + return Math.abs(parsed); + } + } + } + + return undefined; +} diff --git a/apps/backend/src/types/express.d.ts b/apps/backend/src/types/express.d.ts new file mode 100644 index 0000000..7292064 --- /dev/null +++ b/apps/backend/src/types/express.d.ts @@ -0,0 +1,12 @@ +declare global { + namespace Express { + interface Request { + user?: { + id: string; + email: string; + }; + } + } +} + +export {}; diff --git a/apps/frontend/android/app/src/main/java/com/bill/ai/MainActivity.java b/apps/frontend/android/app/src/main/java/com/bill/ai/MainActivity.java index f3f7f09..007cfde 100644 --- a/apps/frontend/android/app/src/main/java/com/bill/ai/MainActivity.java +++ b/apps/frontend/android/app/src/main/java/com/bill/ai/MainActivity.java @@ -1,5 +1,14 @@ package com.bill.ai; +import android.os.Bundle; + +import com.bill.ai.notification.NotificationPermissionPlugin; import com.getcapacitor.BridgeActivity; -public class MainActivity extends BridgeActivity {} +public class MainActivity extends BridgeActivity { + @Override + protected void onCreate(Bundle savedInstanceState) { + registerPlugin(NotificationPermissionPlugin.class); + super.onCreate(savedInstanceState); + } +} diff --git a/apps/frontend/android/app/src/main/java/com/bill/ai/notification/NotificationPermissionPlugin.java b/apps/frontend/android/app/src/main/java/com/bill/ai/notification/NotificationPermissionPlugin.java new file mode 100644 index 0000000..7032b6e --- /dev/null +++ b/apps/frontend/android/app/src/main/java/com/bill/ai/notification/NotificationPermissionPlugin.java @@ -0,0 +1,84 @@ +package com.bill.ai.notification; + +import android.content.Context; +import android.content.Intent; +import android.provider.Settings; + +import androidx.annotation.NonNull; +import androidx.core.app.NotificationManagerCompat; + +import com.getcapacitor.JSObject; +import com.getcapacitor.Plugin; +import com.getcapacitor.PluginCall; +import com.getcapacitor.annotation.CapacitorPlugin; +import com.getcapacitor.annotation.Permission; +import com.getcapacitor.annotation.PermissionCallback; +import com.getcapacitor.annotation.PluginMethod; + +@CapacitorPlugin(name = "NotificationPermissions", permissions = { + @Permission(alias = "notifications", strings = {"android.permission.POST_NOTIFICATIONS"}) +}) +public class NotificationPermissionPlugin extends Plugin { + + @PluginMethod + public void checkStatus(@NonNull PluginCall call) { + JSObject result = new JSObject(); + result.put("granted", isNotificationListenerEnabled(getContext())); + result.put("postNotificationsGranted", isPostNotificationsGranted()); + call.resolve(result); + } + + @PluginMethod + public void requestAccess(@NonNull PluginCall call) { + boolean granted = isNotificationListenerEnabled(getContext()); + if (!granted) { + openNotificationListenerSettings(); + } + JSObject result = new JSObject(); + result.put("opened", true); + call.resolve(result); + } + + @PluginMethod + public void openSettings(@NonNull PluginCall call) { + openNotificationListenerSettings(); + JSObject result = new JSObject(); + result.put("opened", true); + call.resolve(result); + } + + @PluginMethod + public void requestPostNotifications(@NonNull PluginCall call) { + if (isPostNotificationsGranted()) { + JSObject result = new JSObject(); + result.put("granted", true); + call.resolve(result); + return; + } + + requestPermissionForAlias("notifications", call, "handlePostNotificationPermission"); + } + + @PermissionCallback + private void handlePostNotificationPermission(PluginCall call) { + boolean granted = isPostNotificationsGranted(); + JSObject result = new JSObject(); + result.put("granted", granted); + call.resolve(result); + } + + private boolean isNotificationListenerEnabled(Context context) { + return NotificationManagerCompat.getEnabledListenerPackages(context).contains(context.getPackageName()); + } + + private boolean isPostNotificationsGranted() { + return NotificationManagerCompat.from(getContext()).areNotificationsEnabled(); + } + + private void openNotificationListenerSettings() { + Context context = getContext(); + Intent intent = new Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + context.startActivity(intent); + } +} diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 06bad4f..4c6b0a4 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@capacitor/android": "^7.4.4", + "@capacitor/app": "^7.1.0", "@capacitor/cli": "^7.4.4", "@capacitor/core": "^7.4.4", "@tanstack/vue-query": "^5", diff --git a/apps/frontend/src/composables/useBudgets.ts b/apps/frontend/src/composables/useBudgets.ts index 3a536e6..6ba7181 100644 --- a/apps/frontend/src/composables/useBudgets.ts +++ b/apps/frontend/src/composables/useBudgets.ts @@ -1,6 +1,5 @@ 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']; @@ -22,15 +21,11 @@ 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; - } + const { data } = await apiClient.get('/budgets'); + return (data.data as Budget[]).map(mapBudget); }, - initialData: sampleBudgets + refetchOnWindowFocus: true, + staleTime: 60_000 }); } diff --git a/apps/frontend/src/composables/useTransactions.ts b/apps/frontend/src/composables/useTransactions.ts index ca79cae..c9498ee 100644 --- a/apps/frontend/src/composables/useTransactions.ts +++ b/apps/frontend/src/composables/useTransactions.ts @@ -1,6 +1,5 @@ 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']; @@ -28,15 +27,11 @@ 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; - } + const { data } = await apiClient.get('/transactions'); + return (data.data as Transaction[]).map(mapTransaction); }, - initialData: sampleTransactions + refetchOnWindowFocus: true, + staleTime: 60_000 }); } @@ -50,6 +45,9 @@ export function useCreateTransactionMutation() { }, onSuccess: (transaction) => { queryClient.setQueryData(queryKey, (existing = []) => [transaction, ...existing]); + }, + onError: () => { + queryClient.invalidateQueries({ queryKey }); } }); } diff --git a/apps/frontend/src/features/auth/pages/LoginPage.vue b/apps/frontend/src/features/auth/pages/LoginPage.vue index b50c9ba..302a914 100644 --- a/apps/frontend/src/features/auth/pages/LoginPage.vue +++ b/apps/frontend/src/features/auth/pages/LoginPage.vue @@ -1,10 +1,11 @@ @@ -61,6 +72,7 @@ const enterDemoMode = () => {

{{ errorMessage }}

+

{{ successMessage }}

+
+
+
+

{{ permissionStatusText }}

+

开启后可自动解析支付宝、微信等通知中的账单信息。

+
+ +
+
+ + + + + 权限已齐备 + +
+
+
@@ -242,6 +411,7 @@ const handleLogout = () => {
+

{{ preferencesFeedback }}

diff --git a/apps/frontend/src/features/transactions/pages/TransactionsPage.vue b/apps/frontend/src/features/transactions/pages/TransactionsPage.vue index c473788..d80f7a3 100644 --- a/apps/frontend/src/features/transactions/pages/TransactionsPage.vue +++ b/apps/frontend/src/features/transactions/pages/TransactionsPage.vue @@ -1,5 +1,5 @@ @@ -84,14 +123,35 @@ const removeTransaction = async (id: string) => {

轻松管理你的收支

交易记录

- +
+ + +
+

{{ feedbackMessage }}

+
-
+
+
+ + + + +
- - + + +
diff --git a/apps/frontend/src/lib/api/client.ts b/apps/frontend/src/lib/api/client.ts index 559bb08..112f237 100644 --- a/apps/frontend/src/lib/api/client.ts +++ b/apps/frontend/src/lib/api/client.ts @@ -1,4 +1,6 @@ -import axios from 'axios'; +import axios, { AxiosHeaders, type AxiosRequestHeaders } from 'axios'; +import router from '../../router'; +import { useAuthStore } from '../../stores/auth'; const baseURL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:4000/api'; @@ -7,11 +9,33 @@ export const apiClient = axios.create({ timeout: 10_000 }); +apiClient.interceptors.request.use((config) => { + const authStore = useAuthStore(); + if (authStore.isAuthenticated && authStore.accessToken) { + if (config.headers instanceof AxiosHeaders) { + config.headers.set('Authorization', `Bearer ${authStore.accessToken}`); + } else if (config.headers) { + (config.headers as AxiosRequestHeaders).Authorization = `Bearer ${authStore.accessToken}`; + } else { + config.headers = { Authorization: `Bearer ${authStore.accessToken}` } as AxiosRequestHeaders; + } + } + return config; +}); + apiClient.interceptors.response.use( (response) => response, (error) => { if (error.response) { console.error('API error', error.response.status, error.response.data); + if (error.response.status === 401) { + const authStore = useAuthStore(); + authStore.clearSession(); + const current = router.currentRoute.value; + if (!current.path.startsWith('/auth')) { + void router.replace({ name: 'login', query: { redirect: current.fullPath } }); + } + } } else { console.error('Network error', error.message); } diff --git a/apps/frontend/src/lib/native/notification-permission.ts b/apps/frontend/src/lib/native/notification-permission.ts new file mode 100644 index 0000000..28b2731 --- /dev/null +++ b/apps/frontend/src/lib/native/notification-permission.ts @@ -0,0 +1,49 @@ +import { Capacitor } from '@capacitor/core'; +import { registerPlugin } from '@capacitor/core'; + +interface NotificationPermissionStatus { + granted: boolean; + postNotificationsGranted?: boolean; +} + +interface NotificationPermissionPlugin { + checkStatus(): Promise; + requestAccess(): Promise<{ opened: boolean } | void>; + openSettings(): Promise<{ opened: boolean } | void>; + requestPostNotifications(): Promise<{ granted: boolean }>; +} + +const WebFallback: NotificationPermissionPlugin = { + async checkStatus() { + return { granted: true, postNotificationsGranted: true }; + }, + async requestAccess() { + return { opened: false }; + }, + async openSettings() { + return { opened: false }; + }, + async requestPostNotifications() { + return { granted: true }; + } +}; + +export const NotificationPermission = registerPlugin('NotificationPermissions', { + web: WebFallback +}); + +export async function ensureNotificationPermissions() { + if (!Capacitor.isNativePlatform()) { + return { granted: true, postNotificationsGranted: true } satisfies NotificationPermissionStatus; + } + + const status = await NotificationPermission.checkStatus(); + if (!status.postNotificationsGranted) { + try { + await NotificationPermission.requestPostNotifications(); + } catch (error) { + console.warn('requestPostNotifications failed', error); + } + } + return NotificationPermission.checkStatus(); +} diff --git a/apps/frontend/src/main.ts b/apps/frontend/src/main.ts index 37cff0b..a2ce960 100644 --- a/apps/frontend/src/main.ts +++ b/apps/frontend/src/main.ts @@ -4,6 +4,7 @@ import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query'; import App from './App.vue'; import router from './router'; import './assets/main.css'; +import { useAuthStore } from './stores/auth'; const app = createApp(App); const pinia = createPinia(); @@ -16,8 +17,13 @@ const queryClient = new QueryClient({ } }); +const authStore = useAuthStore(pinia); +authStore.initialize(); + app.use(pinia); app.use(router); app.use(VueQueryPlugin, { queryClient }); -app.mount('#app'); +router.isReady().finally(() => { + app.mount('#app'); +}); diff --git a/apps/frontend/src/router/index.ts b/apps/frontend/src/router/index.ts index 761b02d..96eebc3 100644 --- a/apps/frontend/src/router/index.ts +++ b/apps/frontend/src/router/index.ts @@ -1,29 +1,30 @@ import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'; +import { useAuthStore } from '../stores/auth'; const routes: RouteRecordRaw[] = [ { path: '/', name: 'dashboard', component: () => import('../features/dashboard/pages/DashboardPage.vue'), - meta: { title: '仪表盘' } + meta: { title: '仪表盘', requiresAuth: true } }, { path: '/transactions', name: 'transactions', component: () => import('../features/transactions/pages/TransactionsPage.vue'), - meta: { title: '交易记录' } + meta: { title: '交易记录', requiresAuth: true } }, { path: '/analysis', name: 'analysis', component: () => import('../features/analysis/pages/AnalysisPage.vue'), - meta: { title: 'AI 智能分析' } + meta: { title: 'AI 智能分析', requiresAuth: true } }, { path: '/settings', name: 'settings', component: () => import('../features/settings/pages/SettingsPage.vue'), - meta: { title: '设置' } + meta: { title: '设置', requiresAuth: true } }, { path: '/auth', @@ -56,6 +57,22 @@ const router = createRouter({ routes }); +router.beforeEach((to, _from, next) => { + const authStore = useAuthStore(); + if (to.meta.requiresAuth && !authStore.isAuthenticated) { + return next({ + name: 'login', + query: to.fullPath === '/' ? undefined : { redirect: to.fullPath } + }); + } + + if (authStore.isAuthenticated && to.name && ['login', 'register', 'forgot-password'].includes(to.name.toString())) { + return next({ name: 'dashboard' }); + } + + return next(); +}); + router.afterEach((to) => { if (to.meta.title) { document.title = `AI 记账 · ${to.meta.title}`; diff --git a/apps/frontend/src/stores/auth.ts b/apps/frontend/src/stores/auth.ts index c78bf4a..d0c51c3 100644 --- a/apps/frontend/src/stores/auth.ts +++ b/apps/frontend/src/stores/auth.ts @@ -1,5 +1,7 @@ import { defineStore } from 'pinia'; +const STORAGE_KEY = 'ai-bill/auth-session'; + type AuthStatus = 'authenticated' | 'guest'; interface UserProfile { @@ -30,12 +32,47 @@ export const useAuthStore = defineStore('auth', { this.accessToken = tokens.accessToken; this.refreshToken = tokens.refreshToken; this.profile = profile; + if (typeof window !== 'undefined') { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + status: 'authenticated', + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + profile + }) + ); + } }, clearSession() { this.status = 'guest'; this.accessToken = undefined; this.refreshToken = undefined; this.profile = undefined; + if (typeof window !== 'undefined') { + localStorage.removeItem(STORAGE_KEY); + } + }, + initialize() { + if (typeof window === 'undefined') { + return; + } + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) { + return; + } + try { + const stored = JSON.parse(raw) as { status: AuthStatus; accessToken: string; refreshToken: string; profile: UserProfile }; + if (stored.status === 'authenticated' && stored.accessToken && stored.refreshToken && stored.profile) { + this.status = stored.status; + this.accessToken = stored.accessToken; + this.refreshToken = stored.refreshToken; + this.profile = stored.profile; + } + } catch (error) { + console.warn('Failed to restore auth session', error); + localStorage.removeItem(STORAGE_KEY); + } } } }); diff --git a/docker-compose.yml b/docker-compose.yml index 3d32306..4082440 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,8 +6,35 @@ services: restart: unless-stopped ports: - "27017:27017" + environment: + MONGO_INITDB_DATABASE: ai-bill volumes: - mongo-data:/data/db + + backend: + build: + context: . + dockerfile: Dockerfile + container_name: ai-bill-backend + depends_on: + - mongo + environment: + NODE_ENV: production + HOST: 0.0.0.0 + PORT: 4000 + MONGODB_URI: mongodb://mongo:27017/ai-bill + MONGODB_DB: ai-bill + JWT_SECRET: change-me-in-prod + JWT_REFRESH_SECRET: change-me-in-prod + NOTIFICATION_WEBHOOK_SECRET: change-me-in-prod + ports: + - "4000:4000" + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:4000/api/health"] + interval: 30s + timeout: 5s + retries: 5 volumes: mongo-data: driver: local diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3761a0d..866bb88 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -98,6 +98,9 @@ importers: '@capacitor/android': specifier: ^7.4.4 version: 7.4.4(@capacitor/core@7.4.4) + '@capacitor/app': + specifier: ^7.1.0 + version: 7.1.0(@capacitor/core@7.4.4) '@capacitor/cli': specifier: ^7.4.4 version: 7.4.4 @@ -200,6 +203,14 @@ packages: '@capacitor/core': 7.4.4 dev: false + /@capacitor/app@7.1.0(@capacitor/core@7.4.4): + resolution: {integrity: sha512-W7m09IWrUjZbo7AKeq+rc/KyucxrJekTBg0l4QCm/yDtCejE3hebxp/W2esU26KKCzMc7H3ClkUw32E9lZkwRA==} + peerDependencies: + '@capacitor/core': '>=7.0.0' + dependencies: + '@capacitor/core': 7.4.4 + dev: false + /@capacitor/assets@3.0.5(@types/node@24.9.2)(typescript@5.9.3): resolution: {integrity: sha512-ohz/OUq61Y1Fc6aVSt0uDrUdeOA7oTH4pkWDbv/8I3UrPjH7oPkzYhShuDRUjekNp9RBi198VSFdt0CetpEOzw==} engines: {node: '>=10.3.0'}