feat:添加登录功能
This commit is contained in:
28
Dockerfile
Normal file
28
Dockerfile
Normal file
@@ -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"]
|
||||||
@@ -4,8 +4,8 @@ import { logger } from './logger.js';
|
|||||||
|
|
||||||
export async function connectToDatabase(): Promise<void> {
|
export async function connectToDatabase(): Promise<void> {
|
||||||
if (!env.MONGODB_URI) {
|
if (!env.MONGODB_URI) {
|
||||||
logger.warn('Skipping MongoDB connection: MONGODB_URI is not set.');
|
logger.error('Database connection failed: MONGODB_URI is not set.');
|
||||||
return;
|
throw new Error('MONGODB_URI must be provided to connect to MongoDB.');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
42
apps/backend/src/middlewares/authenticate.ts
Normal file
42
apps/backend/src/middlewares/authenticate.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import type { NextFunction, Request, Response } from 'express';
|
||||||
|
import mongoose from 'mongoose';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { env } from '../config/env.js';
|
||||||
|
import { UserModel } from '../models/user.model.js';
|
||||||
|
|
||||||
|
export async function authenticate(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
return res.status(401).json({ message: 'Authentication required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.slice('Bearer '.length).trim();
|
||||||
|
if (!token) {
|
||||||
|
return res.status(401).json({ message: 'Authentication required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = jwt.verify(token, env.JWT_SECRET) as { sub: string; email?: string };
|
||||||
|
const userId = payload.sub;
|
||||||
|
if (!userId) {
|
||||||
|
return res.status(401).json({ message: 'Invalid access token' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await UserModel.findById(userId)
|
||||||
|
.select('_id email')
|
||||||
|
.lean<{ _id: mongoose.Types.ObjectId; email: string }>()
|
||||||
|
.exec();
|
||||||
|
if (!user) {
|
||||||
|
return res.status(401).json({ message: 'User not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
req.user = {
|
||||||
|
id: user._id.toString(),
|
||||||
|
email: user.email
|
||||||
|
};
|
||||||
|
|
||||||
|
return next();
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(401).json({ message: 'Invalid or expired token' });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ const budgetSchema = new mongoose.Schema(
|
|||||||
period: { type: String, enum: ['monthly', 'weekly'], default: 'monthly' },
|
period: { type: String, enum: ['monthly', 'weekly'], default: 'monthly' },
|
||||||
threshold: { type: Number, min: 0, max: 1, default: 0.8 },
|
threshold: { type: Number, min: 0, max: 1, default: 0.8 },
|
||||||
usage: { type: Number, min: 0, default: 0 },
|
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 }
|
{ timestamps: true }
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const transactionSchema = new mongoose.Schema(
|
|||||||
occurredAt: { type: Date, required: true },
|
occurredAt: { type: Date, required: true },
|
||||||
notes: { type: String },
|
notes: { type: String },
|
||||||
metadata: { type: mongoose.Schema.Types.Mixed },
|
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 }
|
{ timestamps: true }
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,12 +2,18 @@ import type { Request, Response } from 'express';
|
|||||||
import { buildSpendingInsights, estimateCalories } from './analysis.service.js';
|
import { buildSpendingInsights, estimateCalories } from './analysis.service.js';
|
||||||
|
|
||||||
export const getSpendingInsights = async (req: Request, res: Response) => {
|
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 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);
|
return res.json(data);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const estimateCaloriesHandler = (req: Request, res: Response) => {
|
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 { query } = req.body as { query: string };
|
||||||
const data = estimateCalories(query);
|
const data = estimateCalories(query);
|
||||||
return res.json(data);
|
return res.json(data);
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
|
import { authenticate } from '../../middlewares/authenticate.js';
|
||||||
import { validateRequest } from '../../middlewares/validate-request.js';
|
import { validateRequest } from '../../middlewares/validate-request.js';
|
||||||
import { analysisQuerySchema, calorieRequestSchema } from './analysis.schema.js';
|
import { analysisQuerySchema, calorieRequestSchema } from './analysis.schema.js';
|
||||||
import { getSpendingInsights, estimateCaloriesHandler } from './analysis.controller.js';
|
import { getSpendingInsights, estimateCaloriesHandler } from './analysis.controller.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
router.use(authenticate);
|
||||||
|
|
||||||
router.get('/habits', validateRequest({ query: analysisQuerySchema }), getSpendingInsights);
|
router.get('/habits', validateRequest({ query: analysisQuerySchema }), getSpendingInsights);
|
||||||
router.post('/calories', validateRequest({ body: calorieRequestSchema }), estimateCaloriesHandler);
|
router.post('/calories', validateRequest({ body: calorieRequestSchema }), estimateCaloriesHandler);
|
||||||
|
|
||||||
|
|||||||
@@ -50,8 +50,11 @@ const buildRecommendations = (topCategories: CategoryInsight[], totalExpense: nu
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function buildSpendingInsights(range: '30d' | '90d' = '30d'): Promise<SpendingInsightResult> {
|
export async function buildSpendingInsights(
|
||||||
const transactions = await listTransactions();
|
range: '30d' | '90d' = '30d',
|
||||||
|
userId: string
|
||||||
|
): Promise<SpendingInsightResult> {
|
||||||
|
const transactions = await listTransactions(userId);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const windowDays = range === '30d' ? 30 : 90;
|
const windowDays = range === '30d' ? 30 : 90;
|
||||||
const windowStart = now - windowDays * DAY_IN_MS;
|
const windowStart = now - windowDays * DAY_IN_MS;
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import jwt from 'jsonwebtoken';
|
|||||||
import mongoose from 'mongoose';
|
import mongoose from 'mongoose';
|
||||||
import { env } from '../../config/env.js';
|
import { env } from '../../config/env.js';
|
||||||
import { UserModel, type UserDocument } from '../../models/user.model.js';
|
import { UserModel, type UserDocument } from '../../models/user.model.js';
|
||||||
import { createId } from '../../utils/id.js';
|
|
||||||
import { logger } from '../../config/logger.js';
|
import { logger } from '../../config/logger.js';
|
||||||
|
|
||||||
const ACCESS_TOKEN_TTL = '15m';
|
const ACCESS_TOKEN_TTL = '15m';
|
||||||
@@ -26,18 +25,11 @@ interface AuthTokens {
|
|||||||
refreshExpiresIn: number;
|
refreshExpiresIn: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const memoryUsers = new Map<string, AuthUser>();
|
|
||||||
const refreshTokenStore = new Map<string, { userId: string; expiresAt: Date }>();
|
const refreshTokenStore = new Map<string, { userId: string; expiresAt: Date }>();
|
||||||
|
|
||||||
const isDatabaseReady = () => mongoose.connection.readyState === 1;
|
type UserDocumentWithId = UserDocument & { _id: mongoose.Types.ObjectId };
|
||||||
|
|
||||||
const toAuthUser = (user: (UserDocument & { _id?: mongoose.Types.ObjectId }) | AuthUser): AuthUser => {
|
const toAuthUser = (doc: UserDocumentWithId): 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(),
|
id: doc._id.toString(),
|
||||||
email: doc.email,
|
email: doc.email,
|
||||||
displayName: doc.displayName,
|
displayName: doc.displayName,
|
||||||
@@ -45,62 +37,26 @@ const toAuthUser = (user: (UserDocument & { _id?: mongoose.Types.ObjectId }) | A
|
|||||||
preferredCurrency: doc.preferredCurrency,
|
preferredCurrency: doc.preferredCurrency,
|
||||||
notificationPermissionGranted: doc.notificationPermissionGranted ?? false,
|
notificationPermissionGranted: doc.notificationPermissionGranted ?? false,
|
||||||
avatarUrl: doc.avatarUrl ?? undefined
|
avatarUrl: doc.avatarUrl ?? undefined
|
||||||
};
|
});
|
||||||
};
|
|
||||||
|
|
||||||
const seedUsers = async () => {
|
const profileFromUser = (user: AuthUser) => ({
|
||||||
if (memoryUsers.size > 0) return;
|
id: user.id,
|
||||||
const passwordHash = await bcrypt.hash('Password123!', 10);
|
|
||||||
const user: AuthUser = {
|
|
||||||
id: createId(),
|
|
||||||
email: 'user@example.com',
|
|
||||||
displayName: '示例用户',
|
|
||||||
passwordHash,
|
|
||||||
preferredCurrency: 'CNY',
|
|
||||||
notificationPermissionGranted: true
|
|
||||||
};
|
|
||||||
|
|
||||||
memoryUsers.set(user.email, user);
|
|
||||||
};
|
|
||||||
|
|
||||||
void seedUsers();
|
|
||||||
|
|
||||||
const getUserByEmail = async (email: string): Promise<AuthUser | null> => {
|
|
||||||
if (isDatabaseReady()) {
|
|
||||||
const doc = await UserModel.findOne({ email }).lean<UserDocument & { _id: mongoose.Types.ObjectId }>();
|
|
||||||
return doc ? toAuthUser(doc) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return memoryUsers.get(email) ?? null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveUser = async (user: AuthUser): Promise<AuthUser> => {
|
|
||||||
if (isDatabaseReady()) {
|
|
||||||
const created = await UserModel.create({
|
|
||||||
email: user.email,
|
email: user.email,
|
||||||
passwordHash: user.passwordHash,
|
|
||||||
displayName: user.displayName,
|
displayName: user.displayName,
|
||||||
avatarUrl: user.avatarUrl,
|
avatarUrl: user.avatarUrl,
|
||||||
preferredCurrency: user.preferredCurrency,
|
preferredCurrency: user.preferredCurrency,
|
||||||
notificationPermissionGranted: user.notificationPermissionGranted
|
notificationPermissionGranted: user.notificationPermissionGranted
|
||||||
});
|
});
|
||||||
return toAuthUser(created);
|
|
||||||
|
async function getUserByEmail(email: string): Promise<AuthUser | null> {
|
||||||
|
const doc = await UserModel.findOne({ email }).lean<UserDocumentWithId>().exec();
|
||||||
|
return doc ? toAuthUser(doc) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
memoryUsers.set(user.email, user);
|
async function getUserById(id: string): Promise<AuthUser | null> {
|
||||||
return user;
|
const doc = await UserModel.findById(id).lean<UserDocumentWithId>().exec();
|
||||||
};
|
return doc ? toAuthUser(doc) : null;
|
||||||
|
|
||||||
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 signTokens = (user: AuthUser): AuthTokens => {
|
||||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
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: {
|
export async function registerUser(input: {
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
@@ -155,18 +102,21 @@ export async function registerUser(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const passwordHash = await bcrypt.hash(input.password, 10);
|
const passwordHash = await bcrypt.hash(input.password, 10);
|
||||||
const user: AuthUser = {
|
const created = await UserModel.create({
|
||||||
id: createId(),
|
|
||||||
email: input.email,
|
email: input.email,
|
||||||
|
passwordHash,
|
||||||
displayName: input.displayName,
|
displayName: input.displayName,
|
||||||
preferredCurrency: input.preferredCurrency,
|
preferredCurrency: input.preferredCurrency,
|
||||||
notificationPermissionGranted: false,
|
notificationPermissionGranted: false
|
||||||
passwordHash
|
});
|
||||||
};
|
const createdDoc = await UserModel.findById(created._id).lean<UserDocumentWithId>().exec();
|
||||||
|
if (!createdDoc) {
|
||||||
|
throw new Error('Failed to load created user');
|
||||||
|
}
|
||||||
|
|
||||||
const saved = await saveUser(user);
|
const user = toAuthUser(createdDoc);
|
||||||
const tokens = signTokens(saved);
|
const tokens = signTokens(user);
|
||||||
return { user: profileFromUser(saved), tokens };
|
return { user: profileFromUser(user), tokens };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function authenticateUser(input: { email: string; password: string }) {
|
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');
|
throw new Error('Refresh token revoked');
|
||||||
}
|
}
|
||||||
|
|
||||||
const userDoc = isDatabaseReady()
|
const user = await getUserById(payload.sub);
|
||||||
? await UserModel.findById(payload.sub).lean<UserDocument & { _id: mongoose.Types.ObjectId }>()
|
if (!user) {
|
||||||
: [...memoryUsers.values()].find((user) => user.id === payload.sub) ?? null;
|
|
||||||
|
|
||||||
if (!userDoc) {
|
|
||||||
throw new Error('User not found');
|
throw new Error('User not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = toAuthUser(userDoc as AuthUser);
|
|
||||||
refreshTokenStore.delete(refreshToken);
|
refreshTokenStore.delete(refreshToken);
|
||||||
const tokens = signTokens(user);
|
const tokens = signTokens(user);
|
||||||
return { user: profileFromUser(user), tokens };
|
return { user: profileFromUser(user), tokens };
|
||||||
@@ -224,6 +170,6 @@ export async function requestPasswordReset(email: string) {
|
|||||||
|
|
||||||
const tempPassword = Math.random().toString(36).slice(-10);
|
const tempPassword = Math.random().toString(36).slice(-10);
|
||||||
const passwordHash = await bcrypt.hash(tempPassword, 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)');
|
logger.info({ email }, 'Password reset token generated (simulated)');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,20 +2,29 @@ import type { Request, Response } from 'express';
|
|||||||
import { listBudgets, createBudget, updateBudget, deleteBudget } from './budgets.service.js';
|
import { listBudgets, createBudget, updateBudget, deleteBudget } from './budgets.service.js';
|
||||||
import type { CreateBudgetInput, UpdateBudgetInput } from './budgets.schema.js';
|
import type { CreateBudgetInput, UpdateBudgetInput } from './budgets.schema.js';
|
||||||
|
|
||||||
export const listBudgetsHandler = async (_req: Request, res: Response) => {
|
export const listBudgetsHandler = async (req: Request, res: Response) => {
|
||||||
const data = await listBudgets();
|
if (!req.user) {
|
||||||
|
return res.status(401).json({ message: 'Authentication required' });
|
||||||
|
}
|
||||||
|
const data = await listBudgets(req.user.id);
|
||||||
return res.json({ data });
|
return res.json({ data });
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createBudgetHandler = async (req: Request, res: Response) => {
|
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 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 });
|
return res.status(201).json({ data: budget });
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateBudgetHandler = async (req: Request, res: Response) => {
|
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 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) {
|
if (!budget) {
|
||||||
return res.status(404).json({ message: 'Budget not found' });
|
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) => {
|
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) {
|
if (!removed) {
|
||||||
return res.status(404).json({ message: 'Budget not found' });
|
return res.status(404).json({ message: 'Budget not found' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { authenticate } from '../../middlewares/authenticate.js';
|
||||||
import { validateRequest } from '../../middlewares/validate-request.js';
|
import { validateRequest } from '../../middlewares/validate-request.js';
|
||||||
import { createBudgetSchema, updateBudgetSchema } from './budgets.schema.js';
|
import { createBudgetSchema, updateBudgetSchema } from './budgets.schema.js';
|
||||||
import { listBudgetsHandler, createBudgetHandler, updateBudgetHandler, deleteBudgetHandler } from './budgets.controller.js';
|
import { listBudgetsHandler, createBudgetHandler, updateBudgetHandler, deleteBudgetHandler } from './budgets.controller.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
router.use(authenticate);
|
||||||
|
|
||||||
router.get('/', listBudgetsHandler);
|
router.get('/', listBudgetsHandler);
|
||||||
router.post('/', validateRequest({ body: createBudgetSchema }), createBudgetHandler);
|
router.post('/', validateRequest({ body: createBudgetSchema }), createBudgetHandler);
|
||||||
router.patch('/:id', validateRequest({ params: z.object({ id: z.string().min(1) }), body: updateBudgetSchema }), updateBudgetHandler);
|
router.patch('/:id', validateRequest({ params: z.object({ id: z.string().min(1) }), body: updateBudgetSchema }), updateBudgetHandler);
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import mongoose from 'mongoose';
|
import mongoose from 'mongoose';
|
||||||
import { BudgetModel, type BudgetDocument } from '../../models/budget.model.js';
|
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';
|
import type { CreateBudgetInput, UpdateBudgetInput } from './budgets.schema.js';
|
||||||
|
|
||||||
export interface BudgetDto {
|
export interface BudgetDto {
|
||||||
@@ -17,17 +15,11 @@ export interface BudgetDto {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MemoryBudget extends BudgetDto {}
|
type BudgetDocumentWithId = BudgetDocument & { _id: mongoose.Types.ObjectId };
|
||||||
|
|
||||||
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();
|
|
||||||
|
|
||||||
|
const toDto = (doc: BudgetDocumentWithId): BudgetDto => {
|
||||||
return {
|
return {
|
||||||
id,
|
id: doc._id.toString(),
|
||||||
category: doc.category,
|
category: doc.category,
|
||||||
amount: doc.amount,
|
amount: doc.amount,
|
||||||
currency: doc.currency ?? 'CNY',
|
currency: doc.currency ?? 'CNY',
|
||||||
@@ -40,120 +32,43 @@ const toDto = (doc: BudgetDocument & { _id?: mongoose.Types.ObjectId } & { id?:
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const seedBudgets = () => {
|
export async function listBudgets(userId: string): Promise<BudgetDto[]> {
|
||||||
if (memoryBudgets.length > 0) return;
|
const docs = await BudgetModel.find({ userId }).sort({ createdAt: 1 }).lean<BudgetDocumentWithId[]>().exec();
|
||||||
const now = new Date().toISOString();
|
return docs.map(toDto);
|
||||||
memoryBudgets.push(
|
|
||||||
{
|
|
||||||
id: createId(),
|
|
||||||
category: '餐饮',
|
|
||||||
amount: 2000,
|
|
||||||
currency: 'CNY',
|
|
||||||
period: 'monthly',
|
|
||||||
threshold: 0.8,
|
|
||||||
usage: 1250,
|
|
||||||
userId: 'demo-user',
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: createId(),
|
|
||||||
category: '交通',
|
|
||||||
amount: 600,
|
|
||||||
currency: 'CNY',
|
|
||||||
period: 'monthly',
|
|
||||||
threshold: 0.75,
|
|
||||||
usage: 320,
|
|
||||||
userId: 'demo-user',
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
seedBudgets();
|
|
||||||
|
|
||||||
export async function listBudgets(): Promise<BudgetDto[]> {
|
|
||||||
if (isDatabaseReady()) {
|
|
||||||
const userId = await getDefaultUserId();
|
|
||||||
const docs = await BudgetModel.find({ userId }).lean().exec();
|
|
||||||
return (docs as Array<BudgetDocument & { _id: mongoose.Types.ObjectId }>).map(toDto);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...memoryBudgets].sort((a, b) => a.category.localeCompare(b.category));
|
export async function createBudget(payload: CreateBudgetInput, userId: string): Promise<BudgetDto> {
|
||||||
}
|
|
||||||
|
|
||||||
export async function createBudget(payload: CreateBudgetInput): Promise<BudgetDto> {
|
|
||||||
const defaults = { currency: payload.currency ?? 'CNY', usage: payload.usage ?? 0 };
|
const defaults = { currency: payload.currency ?? 'CNY', usage: payload.usage ?? 0 };
|
||||||
|
const { userId: _ignoredUserId, ...rest } = payload;
|
||||||
|
|
||||||
if (isDatabaseReady()) {
|
const created = await BudgetModel.create({
|
||||||
const userId = payload.userId ?? (await getDefaultUserId());
|
...rest,
|
||||||
const created = await BudgetModel.create({ ...payload, ...defaults, userId });
|
...defaults,
|
||||||
return toDto(created);
|
userId
|
||||||
|
});
|
||||||
|
const createdDoc = await BudgetModel.findById(created._id).lean<BudgetDocumentWithId>().exec();
|
||||||
|
if (!createdDoc) {
|
||||||
|
throw new Error('Failed to load created budget');
|
||||||
|
}
|
||||||
|
return toDto(createdDoc);
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
export async function updateBudget(id: string, payload: UpdateBudgetInput, userId: string): Promise<BudgetDto | null> {
|
||||||
const budget: MemoryBudget = {
|
|
||||||
id: createId(),
|
|
||||||
category: payload.category,
|
|
||||||
amount: payload.amount,
|
|
||||||
currency: defaults.currency,
|
|
||||||
period: payload.period,
|
|
||||||
threshold: payload.threshold,
|
|
||||||
usage: defaults.usage ?? 0,
|
|
||||||
userId: payload.userId,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now
|
|
||||||
};
|
|
||||||
|
|
||||||
memoryBudgets.push(budget);
|
|
||||||
return budget;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateBudget(id: string, payload: UpdateBudgetInput): Promise<BudgetDto | null> {
|
|
||||||
if (isDatabaseReady()) {
|
|
||||||
const userId = await getDefaultUserId();
|
|
||||||
const updated = await BudgetModel.findOneAndUpdate(
|
const updated = await BudgetModel.findOneAndUpdate(
|
||||||
{ _id: id, userId },
|
{ _id: id, userId },
|
||||||
{ ...payload },
|
{ ...payload },
|
||||||
{ new: true }
|
{ new: true }
|
||||||
);
|
)
|
||||||
return updated ? toDto(updated) : null;
|
.lean<BudgetDocumentWithId>()
|
||||||
}
|
.exec();
|
||||||
|
if (!updated) {
|
||||||
const index = memoryBudgets.findIndex((budget) => budget.id === id);
|
|
||||||
if (index === -1) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = memoryBudgets[index];
|
return toDto(updated);
|
||||||
const updated: MemoryBudget = {
|
|
||||||
...existing,
|
|
||||||
...payload,
|
|
||||||
amount: payload.amount ?? existing.amount,
|
|
||||||
usage: payload.usage ?? existing.usage,
|
|
||||||
threshold: payload.threshold ?? existing.threshold,
|
|
||||||
period: payload.period ?? existing.period,
|
|
||||||
currency: payload.currency ?? existing.currency,
|
|
||||||
updatedAt: new Date().toISOString()
|
|
||||||
};
|
|
||||||
|
|
||||||
memoryBudgets[index] = updated;
|
|
||||||
return updated;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteBudget(id: string): Promise<boolean> {
|
export async function deleteBudget(id: string, userId: string): Promise<boolean> {
|
||||||
if (isDatabaseReady()) {
|
const result = await BudgetModel.findOneAndDelete({ _id: id, userId }).exec();
|
||||||
const userId = await getDefaultUserId();
|
|
||||||
const result = await BudgetModel.findOneAndDelete({ _id: id, userId });
|
|
||||||
return result !== null;
|
return result !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const index = memoryBudgets.findIndex((budget) => budget.id === id);
|
|
||||||
if (index === -1) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
memoryBudgets.splice(index, 1);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { env } from '../../config/env.js';
|
import { env } from '../../config/env.js';
|
||||||
import { TransactionModel } from '../../models/transaction.model.js';
|
import { TransactionModel } from '../../models/transaction.model.js';
|
||||||
import { getDefaultUserId } from '../../services/user-context.js';
|
|
||||||
|
|
||||||
const maskSecret = (secret?: string | null) => {
|
const maskSecret = (secret?: string | null) => {
|
||||||
if (!secret) return null;
|
if (!secret) return null;
|
||||||
@@ -10,8 +9,10 @@ const maskSecret = (secret?: string | null) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getNotificationStatus = async (req: Request, res: Response) => {
|
export const getNotificationStatus = async (req: Request, res: Response) => {
|
||||||
const userId = await getDefaultUserId();
|
if (!req.user) {
|
||||||
const filter = { userId, source: 'notification' as const };
|
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 count = await TransactionModel.countDocuments(filter);
|
||||||
const latest = (await TransactionModel.findOne(filter).sort({ createdAt: -1 }).lean()) as
|
const latest = (await TransactionModel.findOne(filter).sort({ createdAt: -1 }).lean()) as
|
||||||
| { createdAt?: Date }
|
| { createdAt?: Date }
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
|
import { authenticate } from '../../middlewares/authenticate.js';
|
||||||
import { getNotificationStatus } from './notifications.controller.js';
|
import { getNotificationStatus } from './notifications.controller.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
router.use(authenticate);
|
||||||
|
|
||||||
router.get('/status', getNotificationStatus);
|
router.get('/status', getNotificationStatus);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
71
apps/backend/src/modules/transactions/notification.parser.ts
Normal file
71
apps/backend/src/modules/transactions/notification.parser.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { classifyByText, extractAmountFromText } from '../../services/classification.service.js';
|
||||||
|
import type { TransactionDto } from './transactions.service.js';
|
||||||
|
|
||||||
|
export interface ParsedNotification {
|
||||||
|
amount?: number;
|
||||||
|
type?: 'income' | 'expense';
|
||||||
|
category?: string;
|
||||||
|
notes?: string;
|
||||||
|
ruleId?: string;
|
||||||
|
confidence?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseNotificationPayload(title: string, body: string): ParsedNotification {
|
||||||
|
const combined = `${title}|${body}`;
|
||||||
|
|
||||||
|
const parsed: ParsedNotification = {
|
||||||
|
notes: body
|
||||||
|
};
|
||||||
|
|
||||||
|
const classification = classifyByText(title, body);
|
||||||
|
if (classification.category) {
|
||||||
|
parsed.category = classification.category;
|
||||||
|
}
|
||||||
|
if (classification.type) {
|
||||||
|
parsed.type = classification.type;
|
||||||
|
}
|
||||||
|
parsed.ruleId = classification.ruleId;
|
||||||
|
parsed.confidence = classification.confidence;
|
||||||
|
|
||||||
|
const amount = extractAmountFromText(title, body);
|
||||||
|
if (amount !== undefined) {
|
||||||
|
parsed.amount = amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsed.type) {
|
||||||
|
if (combined.includes('到账') || combined.includes('收入') || combined.includes('入账')) {
|
||||||
|
parsed.type = 'income';
|
||||||
|
} else if (combined.includes('付款') || combined.includes('消费') || combined.includes('支出') || combined.includes('已扣款')) {
|
||||||
|
parsed.type = 'expense';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsed.category && parsed.type === 'income') {
|
||||||
|
parsed.category = '收入';
|
||||||
|
} else if (!parsed.category && parsed.type === 'expense') {
|
||||||
|
parsed.category = '其他支出';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsed.type) {
|
||||||
|
parsed.type = parsed.category === '收入' ? 'income' : 'expense';
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeParsedNotification(transaction: TransactionDto, parsed: ParsedNotification): Partial<TransactionDto> {
|
||||||
|
const updates: Partial<TransactionDto> = {};
|
||||||
|
if (parsed.amount !== undefined) {
|
||||||
|
updates.amount = parsed.amount;
|
||||||
|
}
|
||||||
|
if (parsed.category) {
|
||||||
|
updates.category = parsed.category;
|
||||||
|
}
|
||||||
|
if (parsed.type) {
|
||||||
|
updates.type = parsed.type;
|
||||||
|
}
|
||||||
|
if (parsed.notes) {
|
||||||
|
updates.notes = parsed.notes;
|
||||||
|
}
|
||||||
|
return updates;
|
||||||
|
}
|
||||||
@@ -12,19 +12,28 @@ import {
|
|||||||
} from './transactions.service.js';
|
} from './transactions.service.js';
|
||||||
import type { CreateTransactionInput, UpdateTransactionInput } from './transactions.schema.js';
|
import type { CreateTransactionInput, UpdateTransactionInput } from './transactions.schema.js';
|
||||||
|
|
||||||
export const listTransactionsHandler = async (_req: Request, res: Response) => {
|
export const listTransactionsHandler = async (req: Request, res: Response) => {
|
||||||
const data = await listTransactions();
|
if (!req.user) {
|
||||||
|
return res.status(401).json({ message: 'Authentication required' });
|
||||||
|
}
|
||||||
|
const data = await listTransactions(req.user.id);
|
||||||
return res.json({ data });
|
return res.json({ data });
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createTransactionHandler = async (req: Request, res: Response) => {
|
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 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 });
|
return res.status(201).json({ data: transaction });
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getTransactionHandler = async (req: Request, res: Response) => {
|
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) {
|
if (!transaction) {
|
||||||
return res.status(404).json({ message: 'Transaction not found' });
|
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) => {
|
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 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) {
|
if (!transaction) {
|
||||||
return res.status(404).json({ message: 'Transaction not found' });
|
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) => {
|
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) {
|
if (!removed) {
|
||||||
return res.status(404).json({ message: 'Transaction not found' });
|
return res.status(404).json({ message: 'Transaction not found' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
|
import { authenticate } from '../../middlewares/authenticate.js';
|
||||||
import { validateRequest } from '../../middlewares/validate-request.js';
|
import { validateRequest } from '../../middlewares/validate-request.js';
|
||||||
import {
|
import {
|
||||||
createTransactionSchema,
|
createTransactionSchema,
|
||||||
@@ -17,11 +18,14 @@ import {
|
|||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
router.post('/notification', validateRequest({ body: notificationSchema }), createTransactionFromNotificationHandler);
|
||||||
|
|
||||||
|
router.use(authenticate);
|
||||||
|
|
||||||
router.get('/', listTransactionsHandler);
|
router.get('/', listTransactionsHandler);
|
||||||
router.post('/', validateRequest({ body: createTransactionSchema }), createTransactionHandler);
|
router.post('/', validateRequest({ body: createTransactionSchema }), createTransactionHandler);
|
||||||
router.get('/:id', validateRequest({ params: transactionIdSchema }), getTransactionHandler);
|
router.get('/:id', validateRequest({ params: transactionIdSchema }), getTransactionHandler);
|
||||||
router.patch('/:id', validateRequest({ params: transactionIdSchema, body: updateTransactionSchema }), updateTransactionHandler);
|
router.patch('/:id', validateRequest({ params: transactionIdSchema, body: updateTransactionSchema }), updateTransactionHandler);
|
||||||
router.delete('/:id', validateRequest({ params: transactionIdSchema }), deleteTransactionHandler);
|
router.delete('/:id', validateRequest({ params: transactionIdSchema }), deleteTransactionHandler);
|
||||||
router.post('/notification', validateRequest({ body: notificationSchema }), createTransactionFromNotificationHandler);
|
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import mongoose from 'mongoose';
|
import mongoose from 'mongoose';
|
||||||
import { TransactionModel, type TransactionDocument } from '../../models/transaction.model.js';
|
import { TransactionModel, type TransactionDocument } from '../../models/transaction.model.js';
|
||||||
import { createId } from '../../utils/id.js';
|
import { classifyByText } from '../../services/classification.service.js';
|
||||||
import { logger } from '../../config/logger.js';
|
|
||||||
import { getDefaultUserId } from '../../services/user-context.js';
|
import { getDefaultUserId } from '../../services/user-context.js';
|
||||||
import type { CreateTransactionInput, UpdateTransactionInput } from './transactions.schema.js';
|
import type { CreateTransactionInput, UpdateTransactionInput } from './transactions.schema.js';
|
||||||
|
import { parseNotificationPayload } from './notification.parser.js';
|
||||||
|
|
||||||
export interface TransactionDto {
|
export interface TransactionDto {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -22,17 +22,11 @@ export interface TransactionDto {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MemoryTransaction extends TransactionDto {}
|
type TransactionDocumentWithId = TransactionDocument & { _id: mongoose.Types.ObjectId };
|
||||||
|
|
||||||
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();
|
|
||||||
|
|
||||||
|
const toDto = (doc: TransactionDocumentWithId): TransactionDto => {
|
||||||
return {
|
return {
|
||||||
id: rawId,
|
id: doc._id.toString(),
|
||||||
title: doc.title,
|
title: doc.title,
|
||||||
amount: doc.amount,
|
amount: doc.amount,
|
||||||
currency: doc.currency ?? 'CNY',
|
currency: doc.currency ?? 'CNY',
|
||||||
@@ -49,173 +43,97 @@ const toDto = (doc: TransactionDocument & { _id?: mongoose.Types.ObjectId } & {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const seedMemoryTransactions = () => {
|
export async function listTransactions(userId: string): Promise<TransactionDto[]> {
|
||||||
if (memoryTransactions.length > 0) return;
|
|
||||||
const now = new Date();
|
|
||||||
memoryTransactions.push(
|
|
||||||
{
|
|
||||||
id: createId(),
|
|
||||||
title: '星巴克咖啡',
|
|
||||||
amount: 32.5,
|
|
||||||
currency: 'CNY',
|
|
||||||
category: '报销',
|
|
||||||
type: 'expense',
|
|
||||||
source: 'notification',
|
|
||||||
status: 'confirmed',
|
|
||||||
occurredAt: now.toISOString(),
|
|
||||||
notes: '餐饮 | 早餐',
|
|
||||||
metadata: { packageName: 'com.eg.android.AlipayGphone' },
|
|
||||||
userId: 'demo-user',
|
|
||||||
createdAt: now.toISOString(),
|
|
||||||
updatedAt: now.toISOString()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: createId(),
|
|
||||||
title: '地铁充值',
|
|
||||||
amount: 100,
|
|
||||||
currency: 'CNY',
|
|
||||||
category: '交通',
|
|
||||||
type: 'expense',
|
|
||||||
source: 'manual',
|
|
||||||
status: 'confirmed',
|
|
||||||
occurredAt: new Date(now.getTime() - 86_400_000).toISOString(),
|
|
||||||
userId: 'demo-user',
|
|
||||||
createdAt: now.toISOString(),
|
|
||||||
updatedAt: now.toISOString()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: createId(),
|
|
||||||
title: '午餐报销',
|
|
||||||
amount: 58,
|
|
||||||
currency: 'CNY',
|
|
||||||
category: '餐饮',
|
|
||||||
type: 'income',
|
|
||||||
source: 'ocr',
|
|
||||||
status: 'pending',
|
|
||||||
occurredAt: new Date(now.getTime() - 172_800_000).toISOString(),
|
|
||||||
notes: '审批中',
|
|
||||||
userId: 'demo-user',
|
|
||||||
createdAt: now.toISOString(),
|
|
||||||
updatedAt: now.toISOString()
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
seedMemoryTransactions();
|
|
||||||
|
|
||||||
export async function listTransactions(): Promise<TransactionDto[]> {
|
|
||||||
if (isDatabaseReady()) {
|
|
||||||
const userId = await getDefaultUserId();
|
|
||||||
const docs = await TransactionModel.find({ userId })
|
const docs = await TransactionModel.find({ userId })
|
||||||
.sort({ occurredAt: -1 })
|
.sort({ occurredAt: -1 })
|
||||||
.lean()
|
.lean<TransactionDocumentWithId[]>()
|
||||||
.exec();
|
.exec();
|
||||||
return (docs as Array<TransactionDocument & { _id: mongoose.Types.ObjectId }>).map(toDto);
|
return docs.map(toDto);
|
||||||
}
|
|
||||||
return memoryTransactions.slice().sort((a, b) => (a.occurredAt < b.occurredAt ? 1 : -1));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getTransactionById(id: string): Promise<TransactionDto | null> {
|
export async function getTransactionById(id: string, userId: string): Promise<TransactionDto | null> {
|
||||||
if (isDatabaseReady()) {
|
const doc = await TransactionModel.findOne({ _id: id, userId }).lean<TransactionDocumentWithId>().exec();
|
||||||
const userId = await getDefaultUserId();
|
return doc ? toDto(doc) : null;
|
||||||
const doc = await TransactionModel.findOne({ _id: id, userId }).lean().exec();
|
|
||||||
return doc ? toDto(doc as TransactionDocument & { _id: mongoose.Types.ObjectId }) : null;
|
|
||||||
}
|
|
||||||
return memoryTransactions.find((txn) => txn.id === id) ?? null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createTransaction(payload: CreateTransactionInput): Promise<TransactionDto> {
|
export async function createTransaction(payload: CreateTransactionInput, userId?: string): Promise<TransactionDto> {
|
||||||
const normalizedAmount = Math.abs(payload.amount);
|
const normalizedAmount = Math.abs(payload.amount);
|
||||||
const occuredDate = payload.occurredAt instanceof Date ? payload.occurredAt : new Date(payload.occurredAt);
|
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 resolvedCategory = classification?.category ?? payload.category ?? (classification?.type === 'income' ? '收入' : '其他支出');
|
||||||
const userId = payload.userId ?? (await getDefaultUserId());
|
const resolvedType = classification?.type ?? payload.type;
|
||||||
|
const metadata =
|
||||||
|
classification && classification.ruleId
|
||||||
|
? {
|
||||||
|
...payload.metadata,
|
||||||
|
classification: {
|
||||||
|
ruleId: classification.ruleId,
|
||||||
|
confidence: classification.confidence
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: payload.metadata;
|
||||||
|
|
||||||
|
const resolvedUserId = userId ?? payload.userId ?? (await getDefaultUserId());
|
||||||
|
const { userId: _ignoredUserId, ...restPayload } = payload;
|
||||||
const created = await TransactionModel.create({
|
const created = await TransactionModel.create({
|
||||||
...payload,
|
...restPayload,
|
||||||
|
category: resolvedCategory,
|
||||||
|
type: resolvedType,
|
||||||
source: payload.source ?? 'manual',
|
source: payload.source ?? 'manual',
|
||||||
currency: payload.currency ?? 'CNY',
|
currency: payload.currency ?? 'CNY',
|
||||||
amount: normalizedAmount,
|
amount: normalizedAmount,
|
||||||
occurredAt: occuredDate,
|
occurredAt: occuredDate,
|
||||||
userId
|
metadata,
|
||||||
|
userId: resolvedUserId
|
||||||
});
|
});
|
||||||
return toDto(created);
|
const createdDoc = await TransactionModel.findById(created._id).lean<TransactionDocumentWithId>().exec();
|
||||||
|
if (!createdDoc) {
|
||||||
|
throw new Error('Failed to load created transaction');
|
||||||
|
}
|
||||||
|
return toDto(createdDoc);
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date();
|
export async function updateTransaction(
|
||||||
const transaction: MemoryTransaction = {
|
id: string,
|
||||||
id: createId(),
|
payload: UpdateTransactionInput,
|
||||||
title: payload.title,
|
userId: string
|
||||||
amount: normalizedAmount,
|
): Promise<TransactionDto | null> {
|
||||||
currency: payload.currency ?? 'CNY',
|
const { userId: _ignoredUserId, ...restPayload } = payload;
|
||||||
category: payload.category,
|
|
||||||
type: payload.type,
|
const updatePayload: Partial<TransactionDocument> = {
|
||||||
source: payload.source ?? 'manual',
|
...restPayload,
|
||||||
status: payload.status,
|
currency: restPayload.currency ?? undefined,
|
||||||
occurredAt: occuredDate.toISOString(),
|
source: restPayload.source ?? undefined
|
||||||
notes: payload.notes,
|
|
||||||
metadata: payload.metadata,
|
|
||||||
userId: payload.userId,
|
|
||||||
createdAt: now.toISOString(),
|
|
||||||
updatedAt: now.toISOString()
|
|
||||||
};
|
};
|
||||||
|
|
||||||
memoryTransactions.unshift(transaction);
|
if (restPayload.amount !== undefined) {
|
||||||
logger.debug({ id: transaction.id }, 'Transaction created (memory)');
|
updatePayload.amount = Math.abs(restPayload.amount);
|
||||||
|
}
|
||||||
return transaction;
|
|
||||||
|
if (restPayload.occurredAt) {
|
||||||
|
updatePayload.occurredAt =
|
||||||
|
restPayload.occurredAt instanceof Date ? restPayload.occurredAt : new Date(restPayload.occurredAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateTransaction(id: string, payload: UpdateTransactionInput): Promise<TransactionDto | null> {
|
|
||||||
if (isDatabaseReady()) {
|
|
||||||
const userId = await getDefaultUserId();
|
|
||||||
const updated = await TransactionModel.findOneAndUpdate(
|
const updated = await TransactionModel.findOneAndUpdate(
|
||||||
{ _id: id, userId },
|
{ _id: id, userId },
|
||||||
{
|
updatePayload,
|
||||||
...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 }
|
{ new: true }
|
||||||
);
|
)
|
||||||
|
.lean<TransactionDocumentWithId>()
|
||||||
|
.exec();
|
||||||
|
|
||||||
return updated ? toDto(updated) : null;
|
return updated ? toDto(updated) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const index = memoryTransactions.findIndex((txn) => txn.id === id);
|
export async function deleteTransaction(id: string, userId: string): Promise<boolean> {
|
||||||
if (index === -1) {
|
const result = await TransactionModel.findOneAndDelete({ _id: id, userId }).exec();
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = memoryTransactions[index];
|
|
||||||
const updated: MemoryTransaction = {
|
|
||||||
...existing,
|
|
||||||
...payload,
|
|
||||||
amount: payload.amount !== undefined ? Math.abs(payload.amount) : existing.amount,
|
|
||||||
occurredAt: payload.occurredAt ? new Date(payload.occurredAt).toISOString() : existing.occurredAt,
|
|
||||||
updatedAt: new Date().toISOString()
|
|
||||||
};
|
|
||||||
|
|
||||||
memoryTransactions[index] = updated;
|
|
||||||
return updated;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteTransaction(id: string): Promise<boolean> {
|
|
||||||
if (isDatabaseReady()) {
|
|
||||||
const userId = await getDefaultUserId();
|
|
||||||
const result = await TransactionModel.findOneAndDelete({ _id: id, userId });
|
|
||||||
return result !== null;
|
return result !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const index = memoryTransactions.findIndex((txn) => txn.id === id);
|
|
||||||
if (index === -1) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
memoryTransactions.splice(index, 1);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface NotificationPayload {
|
interface NotificationPayload {
|
||||||
packageName: string;
|
packageName: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -225,16 +143,25 @@ interface NotificationPayload {
|
|||||||
|
|
||||||
export async function ingestNotification(payload: NotificationPayload): Promise<TransactionDto> {
|
export async function ingestNotification(payload: NotificationPayload): Promise<TransactionDto> {
|
||||||
const now = payload.receivedAt;
|
const now = payload.receivedAt;
|
||||||
return createTransaction({
|
const parsed = parseNotificationPayload(payload.title ?? '', payload.body ?? '');
|
||||||
|
|
||||||
|
const transactionPayload: CreateTransactionInput = {
|
||||||
title: payload.title,
|
title: payload.title,
|
||||||
amount: 0,
|
amount: parsed.amount ?? 0,
|
||||||
currency: 'CNY',
|
currency: 'CNY',
|
||||||
category: '待分类',
|
category: parsed.category ?? '待分类',
|
||||||
type: 'expense',
|
type: parsed.type ?? 'expense',
|
||||||
source: 'notification',
|
source: 'notification',
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
occurredAt: now,
|
occurredAt: now,
|
||||||
notes: payload.body,
|
notes: parsed.notes ?? payload.body,
|
||||||
metadata: { packageName: payload.packageName }
|
metadata: {
|
||||||
});
|
packageName: payload.packageName,
|
||||||
|
rawBody: payload.body,
|
||||||
|
parser: parsed
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const userId = await getDefaultUserId();
|
||||||
|
return createTransaction(transactionPayload, userId);
|
||||||
}
|
}
|
||||||
|
|||||||
94
apps/backend/src/services/classification.service.ts
Normal file
94
apps/backend/src/services/classification.service.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
interface ClassificationRule {
|
||||||
|
id: string;
|
||||||
|
pattern: RegExp;
|
||||||
|
category: string;
|
||||||
|
type: 'income' | 'expense';
|
||||||
|
priority: number;
|
||||||
|
fallbackCategory?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClassificationResult {
|
||||||
|
category?: string;
|
||||||
|
type?: 'income' | 'expense';
|
||||||
|
ruleId?: string;
|
||||||
|
confidence: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CURRENCY_SYMBOLS = ['¥', '¥', '元', '块', '人民币', 'rmb', 'cny', 'usd', '$'];
|
||||||
|
|
||||||
|
const classificationRules: ClassificationRule[] = [
|
||||||
|
{ id: 'income-salary', pattern: /(工资|薪资|发放|到账|转入|收入|结算)/i, category: '收入', type: 'income', priority: 95 },
|
||||||
|
{ id: 'income-reimbursement', pattern: /(报销|退税|补贴|退款成功)/i, category: '报销', type: 'income', priority: 92 },
|
||||||
|
{ id: 'food-coffee', pattern: /(星巴克|瑞幸|咖啡|coffee|餐饮|午餐|早餐|美团|饿了么|必胜客|肯德基|kfc|麦当劳)/i, category: '餐饮', type: 'expense', priority: 90 },
|
||||||
|
{ id: 'transport', pattern: /(地铁|公交|交通|打车|滴滴|高德|出行|出租|单车|骑行|12306|铁路|航班|航空)/i, category: '交通', type: 'expense', priority: 88 },
|
||||||
|
{ id: 'shopping', pattern: /(淘宝|天猫|京东|拼多多|超市|购物|商城|消费|唯品会|苏宁|抖音商城)/i, category: '购物', type: 'expense', priority: 86 },
|
||||||
|
{ id: 'housing-bills', pattern: /(房租|租金|水费|电费|燃气|煤气|物业|宽带|话费|缴费|电信|联通|移动|固话)/i, category: '生活缴费', type: 'expense', priority: 84 },
|
||||||
|
{ id: 'transfer-out', pattern: /(转出|转账|扣款|支付成功|扣费|已支付)/i, category: '转账', type: 'expense', priority: 82, fallbackCategory: '其他支出' },
|
||||||
|
{ id: 'health', pattern: /(医院|医疗|挂号|药店|体检|医保|诊所|药房)/i, category: '医疗', type: 'expense', priority: 80 },
|
||||||
|
{ id: 'education', pattern: /(学费|培训|教育|课程|考试|教材|网课)/i, category: '教育', type: 'expense', priority: 78 },
|
||||||
|
{ id: 'entertainment', pattern: /(会员|视频|音乐|游戏|腾讯视频|爱奇艺|优酷|哔哩哔哩|bilibili|网易云|spotify|netflix|影院|电影)/i, category: '娱乐', type: 'expense', priority: 76 },
|
||||||
|
{ id: 'transfer-in', pattern: /(退款|退回|退货|返还|返现)/i, category: '退款', type: 'income', priority: 74, fallbackCategory: '收入' },
|
||||||
|
{ id: 'investment', pattern: /(基金|股票|理财|收益|红利|分红|申购|赎回)/i, category: '理财', type: 'expense', priority: 72 },
|
||||||
|
{ id: 'charity', pattern: /(捐赠|公益|慈善)/i, category: '公益', type: 'expense', priority: 70 }
|
||||||
|
];
|
||||||
|
|
||||||
|
const sortedRules = [...classificationRules].sort((a, b) => b.priority - a.priority);
|
||||||
|
|
||||||
|
const normalizeText = (title: string, body?: string) => {
|
||||||
|
const merged = `${title ?? ''} ${body ?? ''}`;
|
||||||
|
return merged.replace(/\s+/g, ' ').trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
export function classifyByText(title: string, body?: string): ClassificationResult {
|
||||||
|
const normalized = normalizeText(title, body);
|
||||||
|
if (!normalized) {
|
||||||
|
return { confidence: 0.1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const rule of sortedRules) {
|
||||||
|
if (rule.pattern.test(normalized)) {
|
||||||
|
return {
|
||||||
|
category: rule.category,
|
||||||
|
type: rule.type,
|
||||||
|
ruleId: rule.id,
|
||||||
|
confidence: rule.priority / 100
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/收(款|到)|到账/.test(normalized)) {
|
||||||
|
return { category: '收入', type: 'income', confidence: 0.6, ruleId: 'fallback-income' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/付款|支出|扣款|消费/.test(normalized)) {
|
||||||
|
return { category: '其他支出', type: 'expense', confidence: 0.5, ruleId: 'fallback-expense' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { confidence: 0.1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractAmountFromText(title: string, body?: string): number | undefined {
|
||||||
|
const normalized = normalizeText(title, body).toLowerCase();
|
||||||
|
const sanitized = normalized.replace(/[,,。]/g, ' ');
|
||||||
|
|
||||||
|
const amountPatterns = [
|
||||||
|
/(?:¥|¥|\$|人民币|rmb|cny)\s*([-+]?\d+(?:\.\d{1,2})?)/i,
|
||||||
|
/([-+]?\d+(?:\.\d{1,2})?)\s*(?:元|块|圆)/i,
|
||||||
|
/金额[::]?\s*([-+]?\d+(?:\.\d{1,2})?)/i,
|
||||||
|
/([-+]?\d+(?:\.\d{1,2})?)(?:\s*(?:元|块|圆))?/i
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of amountPatterns) {
|
||||||
|
const match = sanitized.match(pattern);
|
||||||
|
if (match) {
|
||||||
|
const raw = match[1];
|
||||||
|
const cleaned = CURRENCY_SYMBOLS.reduce((acc, symbol) => acc.replace(symbol, ''), raw);
|
||||||
|
const parsed = Number.parseFloat(cleaned);
|
||||||
|
if (!Number.isNaN(parsed)) {
|
||||||
|
return Math.abs(parsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
12
apps/backend/src/types/express.d.ts
vendored
Normal file
12
apps/backend/src/types/express.d.ts
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
declare global {
|
||||||
|
namespace Express {
|
||||||
|
interface Request {
|
||||||
|
user?: {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
@@ -1,5 +1,14 @@
|
|||||||
package com.bill.ai;
|
package com.bill.ai;
|
||||||
|
|
||||||
|
import android.os.Bundle;
|
||||||
|
|
||||||
|
import com.bill.ai.notification.NotificationPermissionPlugin;
|
||||||
import com.getcapacitor.BridgeActivity;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor/android": "^7.4.4",
|
"@capacitor/android": "^7.4.4",
|
||||||
|
"@capacitor/app": "^7.1.0",
|
||||||
"@capacitor/cli": "^7.4.4",
|
"@capacitor/cli": "^7.4.4",
|
||||||
"@capacitor/core": "^7.4.4",
|
"@capacitor/core": "^7.4.4",
|
||||||
"@tanstack/vue-query": "^5",
|
"@tanstack/vue-query": "^5",
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query';
|
||||||
import { apiClient } from '../lib/api/client';
|
import { apiClient } from '../lib/api/client';
|
||||||
import { sampleBudgets } from '../mocks/budgets';
|
|
||||||
import type { Budget, BudgetPayload } from '../types/budget';
|
import type { Budget, BudgetPayload } from '../types/budget';
|
||||||
|
|
||||||
const queryKey = ['budgets'];
|
const queryKey = ['budgets'];
|
||||||
@@ -22,15 +21,11 @@ export function useBudgetsQuery() {
|
|||||||
return useQuery<Budget[]>({
|
return useQuery<Budget[]>({
|
||||||
queryKey,
|
queryKey,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
try {
|
|
||||||
const { data } = await apiClient.get('/budgets');
|
const { data } = await apiClient.get('/budgets');
|
||||||
return (data.data as Budget[]).map(mapBudget);
|
return (data.data as Budget[]).map(mapBudget);
|
||||||
} catch (error) {
|
|
||||||
console.warn('[budgets] fallback to sample data', error);
|
|
||||||
return sampleBudgets;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
initialData: sampleBudgets
|
refetchOnWindowFocus: true,
|
||||||
|
staleTime: 60_000
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query';
|
||||||
import { apiClient } from '../lib/api/client';
|
import { apiClient } from '../lib/api/client';
|
||||||
import { sampleTransactions } from '../mocks/transactions';
|
|
||||||
import type { Transaction, TransactionPayload } from '../types/transaction';
|
import type { Transaction, TransactionPayload } from '../types/transaction';
|
||||||
|
|
||||||
const queryKey = ['transactions'];
|
const queryKey = ['transactions'];
|
||||||
@@ -28,15 +27,11 @@ export function useTransactionsQuery() {
|
|||||||
return useQuery<Transaction[]>({
|
return useQuery<Transaction[]>({
|
||||||
queryKey,
|
queryKey,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
try {
|
|
||||||
const { data } = await apiClient.get('/transactions');
|
const { data } = await apiClient.get('/transactions');
|
||||||
return (data.data as Transaction[]).map(mapTransaction);
|
return (data.data as Transaction[]).map(mapTransaction);
|
||||||
} catch (error) {
|
|
||||||
console.warn('[transactions] fallback to sample data', error);
|
|
||||||
return sampleTransactions;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
initialData: sampleTransactions
|
refetchOnWindowFocus: true,
|
||||||
|
staleTime: 60_000
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,6 +45,9 @@ export function useCreateTransactionMutation() {
|
|||||||
},
|
},
|
||||||
onSuccess: (transaction) => {
|
onSuccess: (transaction) => {
|
||||||
queryClient.setQueryData<Transaction[]>(queryKey, (existing = []) => [transaction, ...existing]);
|
queryClient.setQueryData<Transaction[]>(queryKey, (existing = []) => [transaction, ...existing]);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive, ref } from 'vue';
|
import { computed, reactive, ref } from 'vue';
|
||||||
import { useRouter, RouterLink } from 'vue-router';
|
import { useRouter, useRoute, RouterLink } from 'vue-router';
|
||||||
import { apiClient } from '../../../lib/api/client';
|
import { apiClient } from '../../../lib/api/client';
|
||||||
import { useAuthStore } from '../../../stores/auth';
|
import { useAuthStore } from '../../../stores/auth';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
@@ -13,24 +14,34 @@ const form = reactive({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const errorMessage = ref('');
|
const errorMessage = ref('');
|
||||||
|
const successMessage = ref('');
|
||||||
const isLoading = ref(false);
|
const isLoading = ref(false);
|
||||||
|
const redirectTarget = computed(() => {
|
||||||
|
const target = route.query.redirect;
|
||||||
|
return typeof target === 'string' && target.startsWith('/') ? target : '/';
|
||||||
|
});
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
errorMessage.value = '';
|
errorMessage.value = '';
|
||||||
|
successMessage.value = '';
|
||||||
try {
|
try {
|
||||||
const { data } = await apiClient.post('/auth/login', form);
|
const { data } = await apiClient.post('/auth/login', form);
|
||||||
authStore.setSession(data.tokens, data.user);
|
authStore.setSession(data.tokens, data.user);
|
||||||
router.push('/');
|
successMessage.value = '登录成功,正在跳转...';
|
||||||
} catch (error) {
|
await router.replace(redirectTarget.value);
|
||||||
|
} catch (error: any) {
|
||||||
console.warn('login failed', error);
|
console.warn('login failed', error);
|
||||||
errorMessage.value = '登录失败,请检查邮箱或密码。';
|
const message = error?.response?.data?.message ?? '登录失败,请检查邮箱或密码。';
|
||||||
|
errorMessage.value = message;
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const enterDemoMode = () => {
|
const enterDemoMode = () => {
|
||||||
|
errorMessage.value = '';
|
||||||
|
successMessage.value = '已进入体验模式';
|
||||||
authStore.setSession(
|
authStore.setSession(
|
||||||
{ accessToken: 'demo-access', refreshToken: 'demo-refresh' },
|
{ accessToken: 'demo-access', refreshToken: 'demo-refresh' },
|
||||||
{
|
{
|
||||||
@@ -40,7 +51,7 @@ const enterDemoMode = () => {
|
|||||||
preferredCurrency: 'CNY'
|
preferredCurrency: 'CNY'
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
router.push('/');
|
void router.replace(redirectTarget.value);
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -61,6 +72,7 @@ const enterDemoMode = () => {
|
|||||||
<input v-model="form.password" type="password" class="px-4 py-3 border border-gray-300 rounded-2xl focus:outline-none focus:ring-2 focus:ring-indigo-500" />
|
<input v-model="form.password" type="password" class="px-4 py-3 border border-gray-300 rounded-2xl focus:outline-none focus:ring-2 focus:ring-indigo-500" />
|
||||||
</label>
|
</label>
|
||||||
<p v-if="errorMessage" class="text-sm text-red-500">{{ errorMessage }}</p>
|
<p v-if="errorMessage" class="text-sm text-red-500">{{ errorMessage }}</p>
|
||||||
|
<p v-if="successMessage" class="text-sm text-emerald-500">{{ successMessage }}</p>
|
||||||
<button
|
<button
|
||||||
class="w-full bg-indigo-500 text-white py-3 rounded-2xl font-semibold hover:bg-indigo-600 disabled:opacity-60"
|
class="w-full bg-indigo-500 text-white py-3 rounded-2xl font-semibold hover:bg-indigo-600 disabled:opacity-60"
|
||||||
:disabled="isLoading"
|
:disabled="isLoading"
|
||||||
|
|||||||
@@ -11,15 +11,23 @@ import { useRouter } from 'vue-router';
|
|||||||
|
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { data: transactions } = useTransactionsQuery();
|
const transactionsQuery = useTransactionsQuery();
|
||||||
const { data: budgets } = useBudgetsQuery();
|
const budgetsQuery = useBudgetsQuery();
|
||||||
|
const transactions = computed(() => transactionsQuery.data.value ?? []);
|
||||||
|
const budgets = computed(() => budgetsQuery.data.value ?? []);
|
||||||
|
const isInitialLoading = computed(() => transactionsQuery.isLoading.value || budgetsQuery.isLoading.value);
|
||||||
|
const isRefreshing = computed(
|
||||||
|
() =>
|
||||||
|
(transactionsQuery.isFetching.value || budgetsQuery.isFetching.value) &&
|
||||||
|
!isInitialLoading.value
|
||||||
|
);
|
||||||
|
|
||||||
const greetingName = computed(() => authStore.profile?.displayName ?? '用户');
|
const greetingName = computed(() => authStore.profile?.displayName ?? '用户');
|
||||||
|
|
||||||
const monthlyStats = computed(() => {
|
const monthlyStats = computed(() => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
const monthTransactions = (transactions.value ?? []).filter(
|
const monthTransactions = transactions.value.filter(
|
||||||
(txn) => new Date(txn.occurredAt) >= startOfMonth
|
(txn) => new Date(txn.occurredAt) >= startOfMonth
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -38,7 +46,7 @@ const monthlyStats = computed(() => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const activeBudget = computed(() => budgets.value?.[0]);
|
const activeBudget = computed(() => budgets.value[0]);
|
||||||
|
|
||||||
const budgetUsagePercentage = computed(() => {
|
const budgetUsagePercentage = computed(() => {
|
||||||
if (!activeBudget.value || !activeBudget.value.amount) return 0;
|
if (!activeBudget.value || !activeBudget.value.amount) return 0;
|
||||||
@@ -70,6 +78,9 @@ const navigateToBudgets = () => router.push('/settings');
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="px-6 pb-24 space-y-8">
|
<main class="px-6 pb-24 space-y-8">
|
||||||
|
<p v-if="isInitialLoading" class="text-xs text-gray-400 text-right">数据加载中...</p>
|
||||||
|
<p v-else-if="isRefreshing" class="text-xs text-indigo-500 text-right">数据同步中...</p>
|
||||||
|
|
||||||
<OverviewCard
|
<OverviewCard
|
||||||
title="本月总支出"
|
title="本月总支出"
|
||||||
:amount="`¥ ${monthlyStats.expense.toFixed(2)}`"
|
:amount="`¥ ${monthlyStats.expense.toFixed(2)}`"
|
||||||
@@ -107,7 +118,15 @@ const navigateToBudgets = () => router.push('/settings');
|
|||||||
<RouterLink to="/transactions" class="text-sm text-indigo-500">查看全部</RouterLink>
|
<RouterLink to="/transactions" class="text-sm text-indigo-500">查看全部</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-4 mt-4 pb-10">
|
<div class="space-y-4 mt-4 pb-10">
|
||||||
|
<div v-if="isInitialLoading" class="text-sm text-gray-400 text-center py-10">
|
||||||
|
数据加载中...
|
||||||
|
</div>
|
||||||
|
<div v-else-if="transactions.length === 0" class="text-sm text-gray-400 text-center py-10">
|
||||||
|
暂无交易记录,前往交易页添加一笔吧。
|
||||||
|
</div>
|
||||||
|
<template v-else>
|
||||||
<TransactionItem v-for="transaction in transactions" :key="transaction.id" :transaction="transaction" />
|
<TransactionItem v-for="transaction in transactions" :key="transaction.id" :transaction="transaction" />
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, reactive, ref } from 'vue';
|
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||||||
|
import { Capacitor, type PluginListenerHandle } from '@capacitor/core';
|
||||||
|
import { App, type AppState } from '@capacitor/app';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
import BudgetCard from '../../../components/budgets/BudgetCard.vue';
|
import BudgetCard from '../../../components/budgets/BudgetCard.vue';
|
||||||
import LucideIcon from '../../../components/common/LucideIcon.vue';
|
import LucideIcon from '../../../components/common/LucideIcon.vue';
|
||||||
import { useCreateBudgetMutation, useDeleteBudgetMutation, useBudgetsQuery } from '../../../composables/useBudgets';
|
import { useCreateBudgetMutation, useDeleteBudgetMutation, useBudgetsQuery } from '../../../composables/useBudgets';
|
||||||
import { useNotificationStatusQuery } from '../../../composables/useNotifications';
|
import { useNotificationStatusQuery } from '../../../composables/useNotifications';
|
||||||
import { useAuthStore } from '../../../stores/auth';
|
import { useAuthStore } from '../../../stores/auth';
|
||||||
|
import { NotificationPermission } from '../../../lib/native/notification-permission';
|
||||||
|
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
|
const router = useRouter();
|
||||||
|
const PREFERENCES_STORAGE_KEY = 'ai-bill/preferences';
|
||||||
|
|
||||||
const preferences = reactive({
|
const preferences = reactive({
|
||||||
notifications: true,
|
notifications: true,
|
||||||
@@ -14,6 +20,25 @@ const preferences = reactive({
|
|||||||
experimentalLab: false
|
experimentalLab: false
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isAndroid = Capacitor.getPlatform() === 'android';
|
||||||
|
const permissionLoading = ref(false);
|
||||||
|
const notificationListenerGranted = ref(!isAndroid);
|
||||||
|
const postNotificationGranted = ref(!isAndroid);
|
||||||
|
let appStateListener: PluginListenerHandle | null = null;
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const stored = localStorage.getItem(PREFERENCES_STORAGE_KEY);
|
||||||
|
if (stored) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(stored) as Partial<typeof preferences>;
|
||||||
|
Object.assign(preferences, parsed);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to parse stored preferences', error);
|
||||||
|
localStorage.removeItem(PREFERENCES_STORAGE_KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const { data: budgets } = useBudgetsQuery();
|
const { data: budgets } = useBudgetsQuery();
|
||||||
const createBudget = useCreateBudgetMutation();
|
const createBudget = useCreateBudgetMutation();
|
||||||
const deleteBudget = useDeleteBudgetMutation();
|
const deleteBudget = useDeleteBudgetMutation();
|
||||||
@@ -45,6 +70,47 @@ const formattedLastNotification = computed(() => {
|
|||||||
|
|
||||||
const copyFeedback = ref<string | null>(null);
|
const copyFeedback = ref<string | null>(null);
|
||||||
let copyTimeout: ReturnType<typeof setTimeout> | null = null;
|
let copyTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
const preferencesFeedback = ref<string | null>(null);
|
||||||
|
let preferenceTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const permissionStatusText = computed(() => {
|
||||||
|
if (!isAndroid) {
|
||||||
|
return '原生通知权限已就绪';
|
||||||
|
}
|
||||||
|
if (!notificationListenerGranted.value) {
|
||||||
|
return '尚未授权读取通知,自动记账将受限';
|
||||||
|
}
|
||||||
|
if (!postNotificationGranted.value) {
|
||||||
|
return '系统通知权限关闭,提醒可能无法送达';
|
||||||
|
}
|
||||||
|
return '通知权限已开启';
|
||||||
|
});
|
||||||
|
|
||||||
|
const refreshPermissionStatus = async () => {
|
||||||
|
if (!isAndroid) return;
|
||||||
|
permissionLoading.value = true;
|
||||||
|
try {
|
||||||
|
const status = await NotificationPermission.checkStatus();
|
||||||
|
notificationListenerGranted.value = Boolean(status.granted);
|
||||||
|
postNotificationGranted.value = status.postNotificationsGranted !== false;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to check notification permission', error);
|
||||||
|
} finally {
|
||||||
|
permissionLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openNotificationSettings = async () => {
|
||||||
|
if (!isAndroid) return;
|
||||||
|
permissionLoading.value = true;
|
||||||
|
try {
|
||||||
|
await NotificationPermission.requestAccess();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to open notification settings', error);
|
||||||
|
} finally {
|
||||||
|
permissionLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const showCopyFeedback = (message: string) => {
|
const showCopyFeedback = (message: string) => {
|
||||||
copyFeedback.value = message;
|
copyFeedback.value = message;
|
||||||
@@ -56,6 +122,32 @@ const showCopyFeedback = (message: string) => {
|
|||||||
}, 2000);
|
}, 2000);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const showPreferencesFeedback = (message: string) => {
|
||||||
|
preferencesFeedback.value = message;
|
||||||
|
if (preferenceTimeout) {
|
||||||
|
clearTimeout(preferenceTimeout);
|
||||||
|
}
|
||||||
|
preferenceTimeout = setTimeout(() => {
|
||||||
|
preferencesFeedback.value = null;
|
||||||
|
}, 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestSystemNotificationPermission = async () => {
|
||||||
|
if (!isAndroid) return;
|
||||||
|
permissionLoading.value = true;
|
||||||
|
try {
|
||||||
|
const result = await NotificationPermission.requestPostNotifications();
|
||||||
|
if (result?.granted) {
|
||||||
|
showPreferencesFeedback('系统通知权限已开启');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to request system notification permission', error);
|
||||||
|
} finally {
|
||||||
|
permissionLoading.value = false;
|
||||||
|
await refreshPermissionStatus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const copyText = async (text: string, label: string) => {
|
const copyText = async (text: string, label: string) => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(text);
|
await navigator.clipboard.writeText(text);
|
||||||
@@ -66,10 +158,42 @@ const copyText = async (text: string, label: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
preferences,
|
||||||
|
(value) => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const snapshot = { ...value };
|
||||||
|
localStorage.setItem(PREFERENCES_STORAGE_KEY, JSON.stringify(snapshot));
|
||||||
|
}
|
||||||
|
showPreferencesFeedback('偏好设置已更新');
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!isAndroid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void refreshPermissionStatus();
|
||||||
|
App.addListener('appStateChange', ({ isActive }: AppState) => {
|
||||||
|
if (isActive) {
|
||||||
|
void refreshPermissionStatus();
|
||||||
|
}
|
||||||
|
}).then((handle: PluginListenerHandle) => {
|
||||||
|
appStateListener = handle;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
if (copyTimeout) {
|
if (copyTimeout) {
|
||||||
clearTimeout(copyTimeout);
|
clearTimeout(copyTimeout);
|
||||||
}
|
}
|
||||||
|
if (preferenceTimeout) {
|
||||||
|
clearTimeout(preferenceTimeout);
|
||||||
|
}
|
||||||
|
if (appStateListener) {
|
||||||
|
void appStateListener.remove();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const resetBudgetForm = () => {
|
const resetBudgetForm = () => {
|
||||||
@@ -98,6 +222,7 @@ const removeBudget = async (id: string) => {
|
|||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
authStore.clearSession();
|
authStore.clearSession();
|
||||||
|
router.replace({ name: 'login', query: { redirect: '/' } });
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -152,6 +277,50 @@ const handleLogout = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="isAndroid"
|
||||||
|
class="bg-indigo-50 border border-indigo-100 rounded-2xl p-4 space-y-3"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-semibold text-indigo-600">{{ permissionStatusText }}</p>
|
||||||
|
<p class="text-xs text-indigo-500">开启后可自动解析支付宝、微信等通知中的账单信息。</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="text-xs text-indigo-600 font-medium disabled:opacity-60"
|
||||||
|
:disabled="permissionLoading"
|
||||||
|
@click="refreshPermissionStatus"
|
||||||
|
>
|
||||||
|
{{ permissionLoading ? '检测中...' : '重新检测' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
v-if="!notificationListenerGranted"
|
||||||
|
class="px-3 py-2 text-xs rounded-xl bg-white text-indigo-600 border border-indigo-200 disabled:opacity-60"
|
||||||
|
:disabled="permissionLoading"
|
||||||
|
@click="openNotificationSettings"
|
||||||
|
>
|
||||||
|
打开系统设置
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="notificationListenerGranted && !postNotificationGranted"
|
||||||
|
class="px-3 py-2 text-xs rounded-xl bg-indigo-500 text-white disabled:opacity-60"
|
||||||
|
:disabled="permissionLoading"
|
||||||
|
@click="requestSystemNotificationPermission"
|
||||||
|
>
|
||||||
|
允许系统通知
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
v-if="notificationListenerGranted && postNotificationGranted"
|
||||||
|
class="text-xs text-indigo-500 flex items-center space-x-1"
|
||||||
|
>
|
||||||
|
<LucideIcon name="check" :size="14" />
|
||||||
|
<span>权限已齐备</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div class="bg-gray-50 rounded-2xl p-4 space-y-2">
|
<div class="bg-gray-50 rounded-2xl p-4 space-y-2">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
@@ -242,6 +411,7 @@ const handleLogout = () => {
|
|||||||
<input v-model="preferences.experimentalLab" type="checkbox" class="w-12 h-6 rounded-full appearance-none bg-gray-200 checked:bg-indigo-500 transition" />
|
<input v-model="preferences.experimentalLab" type="checkbox" class="w-12 h-6 rounded-full appearance-none bg-gray-200 checked:bg-indigo-500 transition" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<p v-if="preferencesFeedback" class="text-xs text-emerald-500">{{ preferencesFeedback }}</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="bg-white rounded-3xl p-6 space-y-4">
|
<section class="bg-white rounded-3xl p-6 space-y-4">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, reactive, ref } from 'vue';
|
import { computed, onBeforeUnmount, reactive, ref } from 'vue';
|
||||||
import LucideIcon from '../../../components/common/LucideIcon.vue';
|
import LucideIcon from '../../../components/common/LucideIcon.vue';
|
||||||
import TransactionItem from '../../../components/transactions/TransactionItem.vue';
|
import TransactionItem from '../../../components/transactions/TransactionItem.vue';
|
||||||
import {
|
import {
|
||||||
@@ -23,9 +23,10 @@ const form = reactive({
|
|||||||
source: 'manual'
|
source: 'manual'
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: transactions } = useTransactionsQuery();
|
const transactionsQuery = useTransactionsQuery();
|
||||||
const createTransaction = useCreateTransactionMutation();
|
const createTransaction = useCreateTransactionMutation();
|
||||||
const deleteTransaction = useDeleteTransactionMutation();
|
const deleteTransaction = useDeleteTransactionMutation();
|
||||||
|
const transactions = computed(() => transactionsQuery.data.value ?? []);
|
||||||
|
|
||||||
const filters: Array<{ label: string; value: FilterOption }> = [
|
const filters: Array<{ label: string; value: FilterOption }> = [
|
||||||
{ label: '全部', value: 'all' },
|
{ label: '全部', value: 'all' },
|
||||||
@@ -34,12 +35,33 @@ const filters: Array<{ label: string; value: FilterOption }> = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const filteredTransactions = computed(() => {
|
const filteredTransactions = computed(() => {
|
||||||
if (!transactions.value) return [];
|
if (!transactions.value.length) return [];
|
||||||
if (filter.value === 'all') return transactions.value;
|
if (filter.value === 'all') return transactions.value;
|
||||||
return transactions.value.filter((txn) => txn.type === filter.value);
|
return transactions.value.filter((txn) => txn.type === filter.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
const isSaving = computed(() => createTransaction.isPending.value);
|
const isSaving = computed(() => createTransaction.isPending.value);
|
||||||
|
const isInitialLoading = computed(() => transactionsQuery.isLoading.value);
|
||||||
|
const isRefreshing = computed(() => transactionsQuery.isFetching.value && !transactionsQuery.isLoading.value);
|
||||||
|
|
||||||
|
const feedbackMessage = ref('');
|
||||||
|
let feedbackTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const showFeedback = (message: string) => {
|
||||||
|
feedbackMessage.value = message;
|
||||||
|
if (feedbackTimeout) {
|
||||||
|
clearTimeout(feedbackTimeout);
|
||||||
|
}
|
||||||
|
feedbackTimeout = setTimeout(() => {
|
||||||
|
feedbackMessage.value = '';
|
||||||
|
}, 2400);
|
||||||
|
};
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (feedbackTimeout) {
|
||||||
|
clearTimeout(feedbackTimeout);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const setFilter = (value: FilterOption) => {
|
const setFilter = (value: FilterOption) => {
|
||||||
filter.value = value;
|
filter.value = value;
|
||||||
@@ -67,13 +89,30 @@ const submit = async () => {
|
|||||||
notes: form.notes || undefined,
|
notes: form.notes || undefined,
|
||||||
occurredAt: new Date().toISOString()
|
occurredAt: new Date().toISOString()
|
||||||
};
|
};
|
||||||
|
try {
|
||||||
await createTransaction.mutateAsync(payload);
|
await createTransaction.mutateAsync(payload);
|
||||||
|
showFeedback('新增交易已提交');
|
||||||
showSheet.value = false;
|
showSheet.value = false;
|
||||||
resetForm();
|
resetForm();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to create transaction', error);
|
||||||
|
showFeedback('保存失败,请稍后重试');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeTransaction = async (id: string) => {
|
const removeTransaction = async (id: string) => {
|
||||||
|
try {
|
||||||
await deleteTransaction.mutateAsync(id);
|
await deleteTransaction.mutateAsync(id);
|
||||||
|
showFeedback('交易已删除');
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to delete transaction', error);
|
||||||
|
showFeedback('删除失败,请稍后重试');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshTransactions = async () => {
|
||||||
|
await transactionsQuery.refetch();
|
||||||
|
showFeedback('列表已更新');
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -84,14 +123,35 @@ const removeTransaction = async (id: string) => {
|
|||||||
<p class="text-sm text-gray-500">轻松管理你的收支</p>
|
<p class="text-sm text-gray-500">轻松管理你的收支</p>
|
||||||
<h1 class="text-3xl font-bold text-gray-900">交易记录</h1>
|
<h1 class="text-3xl font-bold text-gray-900">交易记录</h1>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<button
|
||||||
|
class="px-4 py-2 border border-gray-200 text-sm rounded-xl text-gray-600 hover:bg-gray-50 disabled:opacity-60"
|
||||||
|
:disabled="isRefreshing"
|
||||||
|
@click="refreshTransactions"
|
||||||
|
>
|
||||||
|
<span v-if="isRefreshing" class="flex items-center space-x-2">
|
||||||
|
<svg class="w-4 h-4 animate-spin text-indigo-500" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
||||||
|
</svg>
|
||||||
|
<span>刷新中</span>
|
||||||
|
</span>
|
||||||
|
<span v-else class="flex items-center space-x-2">
|
||||||
|
<LucideIcon name="refresh-cw" :size="16" />
|
||||||
|
<span>刷新</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
class="px-4 py-2 bg-indigo-500 text-white rounded-xl font-medium hover:bg-indigo-600"
|
class="px-4 py-2 bg-indigo-500 text-white rounded-xl font-medium hover:bg-indigo-600"
|
||||||
@click="showSheet = true"
|
@click="showSheet = true"
|
||||||
>
|
>
|
||||||
新增
|
新增
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<p v-if="feedbackMessage" class="text-xs text-emerald-500 text-right">{{ feedbackMessage }}</p>
|
||||||
|
|
||||||
<div class="bg-white rounded-2xl p-2 flex space-x-2 border border-gray-100">
|
<div class="bg-white rounded-2xl p-2 flex space-x-2 border border-gray-100">
|
||||||
<button
|
<button
|
||||||
v-for="item in filters"
|
v-for="item in filters"
|
||||||
@@ -104,7 +164,20 @@ const removeTransaction = async (id: string) => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4 min-h-[140px]">
|
||||||
|
<div v-if="isInitialLoading" class="flex justify-center py-16">
|
||||||
|
<svg class="w-8 h-8 animate-spin text-indigo-500" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="filteredTransactions.length === 0"
|
||||||
|
class="bg-white border border-dashed border-gray-200 rounded-2xl py-12 text-center text-sm text-gray-400"
|
||||||
|
>
|
||||||
|
暂无交易记录,点击「新增」开始记账。
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
<div
|
<div
|
||||||
v-for="transaction in filteredTransactions"
|
v-for="transaction in filteredTransactions"
|
||||||
:key="transaction.id"
|
:key="transaction.id"
|
||||||
@@ -119,6 +192,7 @@ const removeTransaction = async (id: string) => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="showSheet"
|
v-if="showSheet"
|
||||||
|
|||||||
@@ -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';
|
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
|
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(
|
apiClient.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
(error) => {
|
(error) => {
|
||||||
if (error.response) {
|
if (error.response) {
|
||||||
console.error('API error', error.response.status, error.response.data);
|
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 {
|
} else {
|
||||||
console.error('Network error', error.message);
|
console.error('Network error', error.message);
|
||||||
}
|
}
|
||||||
|
|||||||
49
apps/frontend/src/lib/native/notification-permission.ts
Normal file
49
apps/frontend/src/lib/native/notification-permission.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { Capacitor } from '@capacitor/core';
|
||||||
|
import { registerPlugin } from '@capacitor/core';
|
||||||
|
|
||||||
|
interface NotificationPermissionStatus {
|
||||||
|
granted: boolean;
|
||||||
|
postNotificationsGranted?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NotificationPermissionPlugin {
|
||||||
|
checkStatus(): Promise<NotificationPermissionStatus>;
|
||||||
|
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<NotificationPermissionPlugin>('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();
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query';
|
|||||||
import App from './App.vue';
|
import App from './App.vue';
|
||||||
import router from './router';
|
import router from './router';
|
||||||
import './assets/main.css';
|
import './assets/main.css';
|
||||||
|
import { useAuthStore } from './stores/auth';
|
||||||
|
|
||||||
const app = createApp(App);
|
const app = createApp(App);
|
||||||
const pinia = createPinia();
|
const pinia = createPinia();
|
||||||
@@ -16,8 +17,13 @@ const queryClient = new QueryClient({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const authStore = useAuthStore(pinia);
|
||||||
|
authStore.initialize();
|
||||||
|
|
||||||
app.use(pinia);
|
app.use(pinia);
|
||||||
app.use(router);
|
app.use(router);
|
||||||
app.use(VueQueryPlugin, { queryClient });
|
app.use(VueQueryPlugin, { queryClient });
|
||||||
|
|
||||||
|
router.isReady().finally(() => {
|
||||||
app.mount('#app');
|
app.mount('#app');
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,29 +1,30 @@
|
|||||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router';
|
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router';
|
||||||
|
import { useAuthStore } from '../stores/auth';
|
||||||
|
|
||||||
const routes: RouteRecordRaw[] = [
|
const routes: RouteRecordRaw[] = [
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
name: 'dashboard',
|
name: 'dashboard',
|
||||||
component: () => import('../features/dashboard/pages/DashboardPage.vue'),
|
component: () => import('../features/dashboard/pages/DashboardPage.vue'),
|
||||||
meta: { title: '仪表盘' }
|
meta: { title: '仪表盘', requiresAuth: true }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/transactions',
|
path: '/transactions',
|
||||||
name: 'transactions',
|
name: 'transactions',
|
||||||
component: () => import('../features/transactions/pages/TransactionsPage.vue'),
|
component: () => import('../features/transactions/pages/TransactionsPage.vue'),
|
||||||
meta: { title: '交易记录' }
|
meta: { title: '交易记录', requiresAuth: true }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/analysis',
|
path: '/analysis',
|
||||||
name: 'analysis',
|
name: 'analysis',
|
||||||
component: () => import('../features/analysis/pages/AnalysisPage.vue'),
|
component: () => import('../features/analysis/pages/AnalysisPage.vue'),
|
||||||
meta: { title: 'AI 智能分析' }
|
meta: { title: 'AI 智能分析', requiresAuth: true }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/settings',
|
path: '/settings',
|
||||||
name: 'settings',
|
name: 'settings',
|
||||||
component: () => import('../features/settings/pages/SettingsPage.vue'),
|
component: () => import('../features/settings/pages/SettingsPage.vue'),
|
||||||
meta: { title: '设置' }
|
meta: { title: '设置', requiresAuth: true }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/auth',
|
path: '/auth',
|
||||||
@@ -56,6 +57,22 @@ const router = createRouter({
|
|||||||
routes
|
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) => {
|
router.afterEach((to) => {
|
||||||
if (to.meta.title) {
|
if (to.meta.title) {
|
||||||
document.title = `AI 记账 · ${to.meta.title}`;
|
document.title = `AI 记账 · ${to.meta.title}`;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'ai-bill/auth-session';
|
||||||
|
|
||||||
type AuthStatus = 'authenticated' | 'guest';
|
type AuthStatus = 'authenticated' | 'guest';
|
||||||
|
|
||||||
interface UserProfile {
|
interface UserProfile {
|
||||||
@@ -30,12 +32,47 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
this.accessToken = tokens.accessToken;
|
this.accessToken = tokens.accessToken;
|
||||||
this.refreshToken = tokens.refreshToken;
|
this.refreshToken = tokens.refreshToken;
|
||||||
this.profile = profile;
|
this.profile = profile;
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem(
|
||||||
|
STORAGE_KEY,
|
||||||
|
JSON.stringify({
|
||||||
|
status: 'authenticated',
|
||||||
|
accessToken: tokens.accessToken,
|
||||||
|
refreshToken: tokens.refreshToken,
|
||||||
|
profile
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
clearSession() {
|
clearSession() {
|
||||||
this.status = 'guest';
|
this.status = 'guest';
|
||||||
this.accessToken = undefined;
|
this.accessToken = undefined;
|
||||||
this.refreshToken = undefined;
|
this.refreshToken = undefined;
|
||||||
this.profile = 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,8 +6,35 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "27017:27017"
|
- "27017:27017"
|
||||||
|
environment:
|
||||||
|
MONGO_INITDB_DATABASE: ai-bill
|
||||||
volumes:
|
volumes:
|
||||||
- mongo-data:/data/db
|
- 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:
|
volumes:
|
||||||
mongo-data:
|
mongo-data:
|
||||||
driver: local
|
driver: local
|
||||||
|
|||||||
11
pnpm-lock.yaml
generated
11
pnpm-lock.yaml
generated
@@ -98,6 +98,9 @@ importers:
|
|||||||
'@capacitor/android':
|
'@capacitor/android':
|
||||||
specifier: ^7.4.4
|
specifier: ^7.4.4
|
||||||
version: 7.4.4(@capacitor/core@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':
|
'@capacitor/cli':
|
||||||
specifier: ^7.4.4
|
specifier: ^7.4.4
|
||||||
version: 7.4.4
|
version: 7.4.4
|
||||||
@@ -200,6 +203,14 @@ packages:
|
|||||||
'@capacitor/core': 7.4.4
|
'@capacitor/core': 7.4.4
|
||||||
dev: false
|
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):
|
/@capacitor/assets@3.0.5(@types/node@24.9.2)(typescript@5.9.3):
|
||||||
resolution: {integrity: sha512-ohz/OUq61Y1Fc6aVSt0uDrUdeOA7oTH4pkWDbv/8I3UrPjH7oPkzYhShuDRUjekNp9RBi198VSFdt0CetpEOzw==}
|
resolution: {integrity: sha512-ohz/OUq61Y1Fc6aVSt0uDrUdeOA7oTH4pkWDbv/8I3UrPjH7oPkzYhShuDRUjekNp9RBi198VSFdt0CetpEOzw==}
|
||||||
engines: {node: '>=10.3.0'}
|
engines: {node: '>=10.3.0'}
|
||||||
|
|||||||
Reference in New Issue
Block a user