first commit

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

View File

@@ -0,0 +1,87 @@
import type { Request, Response } from 'express';
import { createHmac } from 'node:crypto';
import { env } from '../../config/env.js';
import { logger } from '../../config/logger.js';
import {
listTransactions,
createTransaction,
getTransactionById,
updateTransaction,
deleteTransaction,
ingestNotification
} from './transactions.service.js';
import type { CreateTransactionInput, UpdateTransactionInput } from './transactions.schema.js';
export const listTransactionsHandler = async (_req: Request, res: Response) => {
const data = await listTransactions();
return res.json({ data });
};
export const createTransactionHandler = async (req: Request, res: Response) => {
const payload = req.body as CreateTransactionInput;
const transaction = await createTransaction(payload);
return res.status(201).json({ data: transaction });
};
export const getTransactionHandler = async (req: Request, res: Response) => {
const transaction = await getTransactionById(req.params.id);
if (!transaction) {
return res.status(404).json({ message: 'Transaction not found' });
}
return res.json({ data: transaction });
};
export const updateTransactionHandler = async (req: Request, res: Response) => {
const payload = req.body as UpdateTransactionInput;
const transaction = await updateTransaction(req.params.id, payload);
if (!transaction) {
return res.status(404).json({ message: 'Transaction not found' });
}
return res.json({ data: transaction });
};
export const deleteTransactionHandler = async (req: Request, res: Response) => {
const removed = await deleteTransaction(req.params.id);
if (!removed) {
return res.status(404).json({ message: 'Transaction not found' });
}
return res.status(204).send();
};
const verifySignature = (signature: string, parts: string[]): boolean => {
if (!env.NOTIFICATION_WEBHOOK_SECRET) {
return true;
}
const payload = parts.join('|');
const computed = createHmac('sha256', env.NOTIFICATION_WEBHOOK_SECRET).update(payload).digest('hex');
return computed === signature;
};
export const createTransactionFromNotificationHandler = async (req: Request, res: Response) => {
const { packageName, title, body, receivedAt, signature } = req.body as {
packageName: string;
title: string;
body: string;
receivedAt: Date;
signature: string;
};
const payloadParts = [packageName, title, body, new Date(receivedAt).getTime().toString()];
if (!verifySignature(signature, payloadParts)) {
logger.warn({ packageName }, 'Notification signature mismatch');
return res.status(401).json({ message: 'Invalid notification signature' });
}
const transaction = await ingestNotification({
packageName,
title,
body,
receivedAt: new Date(receivedAt)
});
return res.status(202).json({
message: 'Notification ingested and awaiting confirmation',
data: transaction
});
};

View File

@@ -0,0 +1,27 @@
import { Router } from 'express';
import { validateRequest } from '../../middlewares/validate-request.js';
import {
createTransactionSchema,
updateTransactionSchema,
transactionIdSchema,
notificationSchema
} from './transactions.schema.js';
import {
listTransactionsHandler,
createTransactionHandler,
getTransactionHandler,
updateTransactionHandler,
deleteTransactionHandler,
createTransactionFromNotificationHandler
} from './transactions.controller.js';
const router = Router();
router.get('/', listTransactionsHandler);
router.post('/', validateRequest({ body: createTransactionSchema }), createTransactionHandler);
router.get('/:id', validateRequest({ params: transactionIdSchema }), getTransactionHandler);
router.patch('/:id', validateRequest({ params: transactionIdSchema, body: updateTransactionSchema }), updateTransactionHandler);
router.delete('/:id', validateRequest({ params: transactionIdSchema }), deleteTransactionHandler);
router.post('/notification', validateRequest({ body: notificationSchema }), createTransactionFromNotificationHandler);
export default router;

View File

@@ -0,0 +1,35 @@
import { z } from 'zod';
const baseTransactionSchema = z.object({
title: z.string().min(1),
amount: z.coerce.number().min(0),
currency: z.string().length(3).default('CNY'),
category: z.string().min(1),
type: z.enum(['expense', 'income']),
source: z.enum(['manual', 'notification', 'ocr', 'ai']).default('manual'),
status: z.enum(['pending', 'confirmed', 'rejected']).default('confirmed'),
occurredAt: z.coerce.date(),
notes: z.string().max(300).optional(),
metadata: z.record(z.any()).optional(),
userId: z.string().optional()
});
export const createTransactionSchema = baseTransactionSchema;
export const updateTransactionSchema = baseTransactionSchema.partial();
export const transactionIdSchema = z.object({
id: z.string().min(1)
});
export const notificationSchema = z.object({
packageName: z.string().min(1),
title: z.string().min(1),
body: z.string().min(1),
receivedAt: z.coerce.date(),
signature: z.string().min(32),
extras: z.record(z.any()).optional()
});
export type CreateTransactionInput = z.infer<typeof createTransactionSchema>;
export type UpdateTransactionInput = z.infer<typeof updateTransactionSchema>;

View File

@@ -0,0 +1,240 @@
import mongoose from 'mongoose';
import { TransactionModel, type TransactionDocument } from '../../models/transaction.model.js';
import { createId } from '../../utils/id.js';
import { logger } from '../../config/logger.js';
import { getDefaultUserId } from '../../services/user-context.js';
import type { CreateTransactionInput, UpdateTransactionInput } from './transactions.schema.js';
export interface TransactionDto {
id: string;
title: string;
amount: number;
currency: string;
category: string;
type: 'expense' | 'income';
source: 'manual' | 'notification' | 'ocr' | 'ai';
status: 'pending' | 'confirmed' | 'rejected';
occurredAt: string;
notes?: string;
metadata?: Record<string, unknown>;
userId?: string;
createdAt: string;
updatedAt: string;
}
interface MemoryTransaction extends TransactionDto {}
const memoryTransactions: MemoryTransaction[] = [];
const isDatabaseReady = () => mongoose.connection.readyState === 1;
const toDto = (doc: TransactionDocument & { _id?: mongoose.Types.ObjectId } & { id?: string }): TransactionDto => {
const rawId = typeof doc.id === 'string' ? doc.id : doc._id ? doc._id.toString() : createId();
return {
id: rawId,
title: doc.title,
amount: doc.amount,
currency: doc.currency ?? 'CNY',
category: doc.category,
type: doc.type,
source: doc.source,
status: doc.status,
occurredAt: new Date(doc.occurredAt).toISOString(),
notes: doc.notes,
metadata: doc.metadata ?? undefined,
userId: doc.userId ? doc.userId.toString() : undefined,
createdAt: new Date(doc.createdAt ?? Date.now()).toISOString(),
updatedAt: new Date(doc.updatedAt ?? Date.now()).toISOString()
};
};
const seedMemoryTransactions = () => {
if (memoryTransactions.length > 0) return;
const now = new Date();
memoryTransactions.push(
{
id: createId(),
title: '星巴克咖啡',
amount: 32.5,
currency: 'CNY',
category: '报销',
type: 'expense',
source: 'notification',
status: 'confirmed',
occurredAt: now.toISOString(),
notes: '餐饮 | 早餐',
metadata: { packageName: 'com.eg.android.AlipayGphone' },
userId: 'demo-user',
createdAt: now.toISOString(),
updatedAt: now.toISOString()
},
{
id: createId(),
title: '地铁充值',
amount: 100,
currency: 'CNY',
category: '交通',
type: 'expense',
source: 'manual',
status: 'confirmed',
occurredAt: new Date(now.getTime() - 86_400_000).toISOString(),
userId: 'demo-user',
createdAt: now.toISOString(),
updatedAt: now.toISOString()
},
{
id: createId(),
title: '午餐报销',
amount: 58,
currency: 'CNY',
category: '餐饮',
type: 'income',
source: 'ocr',
status: 'pending',
occurredAt: new Date(now.getTime() - 172_800_000).toISOString(),
notes: '审批中',
userId: 'demo-user',
createdAt: now.toISOString(),
updatedAt: now.toISOString()
}
);
};
seedMemoryTransactions();
export async function listTransactions(): Promise<TransactionDto[]> {
if (isDatabaseReady()) {
const userId = await getDefaultUserId();
const docs = await TransactionModel.find({ userId })
.sort({ occurredAt: -1 })
.lean()
.exec();
return (docs as Array<TransactionDocument & { _id: mongoose.Types.ObjectId }>).map(toDto);
}
return memoryTransactions.slice().sort((a, b) => (a.occurredAt < b.occurredAt ? 1 : -1));
}
export async function getTransactionById(id: string): Promise<TransactionDto | null> {
if (isDatabaseReady()) {
const userId = await getDefaultUserId();
const doc = await TransactionModel.findOne({ _id: id, userId }).lean().exec();
return doc ? toDto(doc as TransactionDocument & { _id: mongoose.Types.ObjectId }) : null;
}
return memoryTransactions.find((txn) => txn.id === id) ?? null;
}
export async function createTransaction(payload: CreateTransactionInput): Promise<TransactionDto> {
const normalizedAmount = Math.abs(payload.amount);
const occuredDate = payload.occurredAt instanceof Date ? payload.occurredAt : new Date(payload.occurredAt);
if (isDatabaseReady()) {
const userId = payload.userId ?? (await getDefaultUserId());
const created = await TransactionModel.create({
...payload,
source: payload.source ?? 'manual',
currency: payload.currency ?? 'CNY',
amount: normalizedAmount,
occurredAt: occuredDate,
userId
});
return toDto(created);
}
const now = new Date();
const transaction: MemoryTransaction = {
id: createId(),
title: payload.title,
amount: normalizedAmount,
currency: payload.currency ?? 'CNY',
category: payload.category,
type: payload.type,
source: payload.source ?? 'manual',
status: payload.status,
occurredAt: occuredDate.toISOString(),
notes: payload.notes,
metadata: payload.metadata,
userId: payload.userId,
createdAt: now.toISOString(),
updatedAt: now.toISOString()
};
memoryTransactions.unshift(transaction);
logger.debug({ id: transaction.id }, 'Transaction created (memory)');
return transaction;
}
export async function updateTransaction(id: string, payload: UpdateTransactionInput): Promise<TransactionDto | null> {
if (isDatabaseReady()) {
const userId = await getDefaultUserId();
const updated = await TransactionModel.findOneAndUpdate(
{ _id: id, userId },
{
...payload,
currency: payload.currency ?? undefined,
source: payload.source ?? undefined,
amount: payload.amount !== undefined ? Math.abs(payload.amount) : undefined,
occurredAt: payload.occurredAt ? new Date(payload.occurredAt) : undefined
},
{ new: true }
);
return updated ? toDto(updated) : null;
}
const index = memoryTransactions.findIndex((txn) => txn.id === id);
if (index === -1) {
return null;
}
const existing = memoryTransactions[index];
const updated: MemoryTransaction = {
...existing,
...payload,
amount: payload.amount !== undefined ? Math.abs(payload.amount) : existing.amount,
occurredAt: payload.occurredAt ? new Date(payload.occurredAt).toISOString() : existing.occurredAt,
updatedAt: new Date().toISOString()
};
memoryTransactions[index] = updated;
return updated;
}
export async function deleteTransaction(id: string): Promise<boolean> {
if (isDatabaseReady()) {
const userId = await getDefaultUserId();
const result = await TransactionModel.findOneAndDelete({ _id: id, userId });
return result !== null;
}
const index = memoryTransactions.findIndex((txn) => txn.id === id);
if (index === -1) {
return false;
}
memoryTransactions.splice(index, 1);
return true;
}
interface NotificationPayload {
packageName: string;
title: string;
body: string;
receivedAt: Date;
}
export async function ingestNotification(payload: NotificationPayload): Promise<TransactionDto> {
const now = payload.receivedAt;
return createTransaction({
title: payload.title,
amount: 0,
currency: 'CNY',
category: '待分类',
type: 'expense',
source: 'notification',
status: 'pending',
occurredAt: now,
notes: payload.body,
metadata: { packageName: payload.packageName }
});
}