refactor: 整合财务系统到主应用并重构后端架构
主要变更: - 将独立的 web-finance 应用整合到 web-antd 主应用中 - 重命名 backend-mock 为 backend,增强后端功能 - 新增财务模块 API 端点(账户、预算、类别、交易) - 增强财务仪表板和报表功能 - 添加 SQLite 数据存储支持和财务数据导入脚本 - 优化路由结构,删除冗余的 finance-system 模块 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
26
apps/backend/utils/cookie-utils.ts
Normal file
26
apps/backend/utils/cookie-utils.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { EventHandlerRequest, H3Event } from 'h3';
|
||||
|
||||
export function clearRefreshTokenCookie(event: H3Event<EventHandlerRequest>) {
|
||||
deleteCookie(event, 'jwt', {
|
||||
httpOnly: true,
|
||||
sameSite: 'none',
|
||||
secure: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function setRefreshTokenCookie(
|
||||
event: H3Event<EventHandlerRequest>,
|
||||
refreshToken: string,
|
||||
) {
|
||||
setCookie(event, 'jwt', refreshToken, {
|
||||
httpOnly: true,
|
||||
maxAge: 24 * 60 * 60, // unit: seconds
|
||||
sameSite: 'none',
|
||||
secure: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function getRefreshTokenFromCookie(event: H3Event<EventHandlerRequest>) {
|
||||
const refreshToken = getCookie(event, 'jwt');
|
||||
return refreshToken;
|
||||
}
|
||||
49
apps/backend/utils/finance-metadata.ts
Normal file
49
apps/backend/utils/finance-metadata.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { MOCK_ACCOUNTS, MOCK_BUDGETS, MOCK_CATEGORIES, MOCK_CURRENCIES, MOCK_EXCHANGE_RATES } from './mock-data';
|
||||
|
||||
export function listAccounts() {
|
||||
return MOCK_ACCOUNTS;
|
||||
}
|
||||
|
||||
export function listCategories() {
|
||||
return MOCK_CATEGORIES;
|
||||
}
|
||||
|
||||
export function listBudgets() {
|
||||
return MOCK_BUDGETS;
|
||||
}
|
||||
|
||||
export function listCurrencies() {
|
||||
return MOCK_CURRENCIES;
|
||||
}
|
||||
|
||||
export function listExchangeRates() {
|
||||
return MOCK_EXCHANGE_RATES;
|
||||
}
|
||||
|
||||
export function createCategoryRecord(category: any) {
|
||||
const newCategory = {
|
||||
...category,
|
||||
id: MOCK_CATEGORIES.length + 1,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
MOCK_CATEGORIES.push(newCategory);
|
||||
return newCategory;
|
||||
}
|
||||
|
||||
export function updateCategoryRecord(id: number, category: any) {
|
||||
const index = MOCK_CATEGORIES.findIndex(c => c.id === id);
|
||||
if (index !== -1) {
|
||||
MOCK_CATEGORIES[index] = { ...MOCK_CATEGORIES[index], ...category };
|
||||
return MOCK_CATEGORIES[index];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function deleteCategoryRecord(id: number) {
|
||||
const index = MOCK_CATEGORIES.findIndex(c => c.id === id);
|
||||
if (index !== -1) {
|
||||
MOCK_CATEGORIES.splice(index, 1);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
260
apps/backend/utils/finance-repository.ts
Normal file
260
apps/backend/utils/finance-repository.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
import db from './sqlite';
|
||||
|
||||
const BASE_CURRENCY = 'CNY';
|
||||
|
||||
interface TransactionRow {
|
||||
id: number;
|
||||
type: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
exchange_rate_to_base: number;
|
||||
amount_in_base: number;
|
||||
category_id: number | null;
|
||||
account_id: number | null;
|
||||
transaction_date: string;
|
||||
description: string | null;
|
||||
project: string | null;
|
||||
memo: string | null;
|
||||
created_at: string;
|
||||
is_deleted: number;
|
||||
deleted_at: string | null;
|
||||
}
|
||||
|
||||
interface TransactionPayload {
|
||||
type: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
categoryId?: number | null;
|
||||
accountId?: number | null;
|
||||
transactionDate: string;
|
||||
description?: string;
|
||||
project?: string | null;
|
||||
memo?: string | null;
|
||||
createdAt?: string;
|
||||
isDeleted?: boolean;
|
||||
}
|
||||
|
||||
function getExchangeRateToBase(currency: string) {
|
||||
if (currency === BASE_CURRENCY) {
|
||||
return 1;
|
||||
}
|
||||
const stmt = db.prepare(
|
||||
`SELECT rate FROM finance_exchange_rates WHERE from_currency = ? AND to_currency = ? ORDER BY date DESC LIMIT 1`,
|
||||
);
|
||||
const row = stmt.get(currency, BASE_CURRENCY) as { rate: number } | undefined;
|
||||
return row?.rate ?? 1;
|
||||
}
|
||||
|
||||
function mapTransaction(row: TransactionRow) {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: 1,
|
||||
type: row.type as 'income' | 'expense' | 'transfer',
|
||||
amount: row.amount,
|
||||
currency: row.currency,
|
||||
exchangeRateToBase: row.exchange_rate_to_base,
|
||||
amountInBase: row.amount_in_base,
|
||||
categoryId: row.category_id ?? undefined,
|
||||
accountId: row.account_id ?? undefined,
|
||||
transactionDate: row.transaction_date,
|
||||
description: row.description ?? '',
|
||||
project: row.project ?? undefined,
|
||||
memo: row.memo ?? undefined,
|
||||
createdAt: row.created_at,
|
||||
isDeleted: Boolean(row.is_deleted),
|
||||
deletedAt: row.deleted_at ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchTransactions(options: { type?: string; includeDeleted?: boolean } = {}) {
|
||||
const clauses: string[] = [];
|
||||
const params: Record<string, unknown> = {};
|
||||
|
||||
if (!options.includeDeleted) {
|
||||
clauses.push('is_deleted = 0');
|
||||
}
|
||||
if (options.type) {
|
||||
clauses.push('type = @type');
|
||||
params.type = options.type;
|
||||
}
|
||||
|
||||
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
|
||||
|
||||
const stmt = db.prepare<TransactionRow>(
|
||||
`SELECT id, type, amount, currency, exchange_rate_to_base, amount_in_base, category_id, account_id, transaction_date, description, project, memo, created_at, is_deleted, deleted_at FROM finance_transactions ${where} ORDER BY transaction_date DESC, id DESC`,
|
||||
);
|
||||
|
||||
return stmt.all(params).map(mapTransaction);
|
||||
}
|
||||
|
||||
export function getTransactionById(id: number) {
|
||||
const stmt = db.prepare<TransactionRow>(
|
||||
`SELECT id, type, amount, currency, exchange_rate_to_base, amount_in_base, category_id, account_id, transaction_date, description, project, memo, created_at, is_deleted, deleted_at FROM finance_transactions WHERE id = ?`,
|
||||
);
|
||||
const row = stmt.get(id);
|
||||
return row ? mapTransaction(row) : null;
|
||||
}
|
||||
|
||||
export function createTransaction(payload: TransactionPayload) {
|
||||
const exchangeRate = getExchangeRateToBase(payload.currency);
|
||||
const amountInBase = +(payload.amount * exchangeRate).toFixed(2);
|
||||
const createdAt = payload.createdAt && payload.createdAt.length ? payload.createdAt : new Date().toISOString();
|
||||
|
||||
const stmt = db.prepare(
|
||||
`INSERT INTO finance_transactions (type, amount, currency, exchange_rate_to_base, amount_in_base, category_id, account_id, transaction_date, description, project, memo, created_at, is_deleted) VALUES (@type, @amount, @currency, @exchangeRateToBase, @amountInBase, @categoryId, @accountId, @transactionDate, @description, @project, @memo, @createdAt, 0)`,
|
||||
);
|
||||
|
||||
const info = stmt.run({
|
||||
type: payload.type,
|
||||
amount: payload.amount,
|
||||
currency: payload.currency,
|
||||
exchangeRateToBase: exchangeRate,
|
||||
amountInBase,
|
||||
categoryId: payload.categoryId ?? null,
|
||||
accountId: payload.accountId ?? null,
|
||||
transactionDate: payload.transactionDate,
|
||||
description: payload.description ?? '',
|
||||
project: payload.project ?? null,
|
||||
memo: payload.memo ?? null,
|
||||
createdAt,
|
||||
});
|
||||
|
||||
return getTransactionById(Number(info.lastInsertRowid));
|
||||
}
|
||||
|
||||
export function updateTransaction(id: number, payload: TransactionPayload) {
|
||||
const current = getTransactionById(id);
|
||||
if (!current) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const next = {
|
||||
type: payload.type ?? current.type,
|
||||
amount: payload.amount ?? current.amount,
|
||||
currency: payload.currency ?? current.currency,
|
||||
categoryId: payload.categoryId ?? current.categoryId ?? null,
|
||||
accountId: payload.accountId ?? current.accountId ?? null,
|
||||
transactionDate: payload.transactionDate ?? current.transactionDate,
|
||||
description: payload.description ?? current.description ?? '',
|
||||
project: payload.project ?? current.project ?? null,
|
||||
memo: payload.memo ?? current.memo ?? null,
|
||||
isDeleted: payload.isDeleted ?? current.isDeleted,
|
||||
};
|
||||
|
||||
const exchangeRate = getExchangeRateToBase(next.currency);
|
||||
const amountInBase = +(next.amount * exchangeRate).toFixed(2);
|
||||
|
||||
const stmt = db.prepare(
|
||||
`UPDATE finance_transactions SET type = @type, amount = @amount, currency = @currency, exchange_rate_to_base = @exchangeRateToBase, amount_in_base = @amountInBase, category_id = @categoryId, account_id = @accountId, transaction_date = @transactionDate, description = @description, project = @project, memo = @memo, is_deleted = @isDeleted, deleted_at = @deletedAt WHERE id = @id`,
|
||||
);
|
||||
|
||||
const deletedAt = next.isDeleted ? new Date().toISOString() : null;
|
||||
|
||||
stmt.run({
|
||||
id,
|
||||
type: next.type,
|
||||
amount: next.amount,
|
||||
currency: next.currency,
|
||||
exchangeRateToBase: exchangeRate,
|
||||
amountInBase,
|
||||
categoryId: next.categoryId,
|
||||
accountId: next.accountId,
|
||||
transactionDate: next.transactionDate,
|
||||
description: next.description,
|
||||
project: next.project,
|
||||
memo: next.memo,
|
||||
isDeleted: next.isDeleted ? 1 : 0,
|
||||
deletedAt,
|
||||
});
|
||||
|
||||
return getTransactionById(id);
|
||||
}
|
||||
|
||||
export function softDeleteTransaction(id: number) {
|
||||
const stmt = db.prepare(`UPDATE finance_transactions SET is_deleted = 1, deleted_at = @deletedAt WHERE id = @id`);
|
||||
stmt.run({ id, deletedAt: new Date().toISOString() });
|
||||
return getTransactionById(id);
|
||||
}
|
||||
|
||||
export function restoreTransaction(id: number) {
|
||||
const stmt = db.prepare(`UPDATE finance_transactions SET is_deleted = 0, deleted_at = NULL WHERE id = @id`);
|
||||
stmt.run({ id });
|
||||
return getTransactionById(id);
|
||||
}
|
||||
|
||||
export function replaceAllTransactions(rows: Array<{
|
||||
type: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
categoryId: number | null;
|
||||
accountId: number | null;
|
||||
transactionDate: string;
|
||||
description: string;
|
||||
project?: string | null;
|
||||
memo?: string | null;
|
||||
createdAt?: string;
|
||||
}>) {
|
||||
db.prepare('DELETE FROM finance_transactions').run();
|
||||
|
||||
const insert = db.prepare(
|
||||
`INSERT INTO finance_transactions (type, amount, currency, exchange_rate_to_base, amount_in_base, category_id, account_id, transaction_date, description, project, memo, created_at, is_deleted) VALUES (@type, @amount, @currency, @exchangeRateToBase, @amountInBase, @categoryId, @accountId, @transactionDate, @description, @project, @memo, @createdAt, 0)`,
|
||||
);
|
||||
|
||||
const getRate = db.prepare(
|
||||
`SELECT rate FROM finance_exchange_rates WHERE from_currency = ? AND to_currency = 'CNY' ORDER BY date DESC LIMIT 1`,
|
||||
);
|
||||
|
||||
const insertMany = db.transaction((items: Array<any>) => {
|
||||
for (const item of items) {
|
||||
const row = getRate.get(item.currency) as { rate: number } | undefined;
|
||||
const rate = row?.rate ?? 1;
|
||||
const amountInBase = +(item.amount * rate).toFixed(2);
|
||||
insert.run({
|
||||
...item,
|
||||
exchangeRateToBase: rate,
|
||||
amountInBase,
|
||||
project: item.project ?? null,
|
||||
memo: item.memo ?? null,
|
||||
createdAt: item.createdAt ?? new Date(`${item.transactionDate}T00:00:00Z`).toISOString(),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
insertMany(rows);
|
||||
}
|
||||
|
||||
// 分类相关函数
|
||||
interface CategoryRow {
|
||||
id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
icon: string | null;
|
||||
color: string | null;
|
||||
user_id: number | null;
|
||||
is_active: number;
|
||||
}
|
||||
|
||||
function mapCategory(row: CategoryRow) {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id ?? null,
|
||||
name: row.name,
|
||||
type: row.type as 'income' | 'expense',
|
||||
icon: row.icon ?? '📝',
|
||||
color: row.color ?? '#dfe4ea',
|
||||
sortOrder: row.id,
|
||||
isSystem: row.user_id === null,
|
||||
isActive: Boolean(row.is_active),
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchCategories(options: { type?: 'income' | 'expense' } = {}) {
|
||||
const where = options.type ? `WHERE type = @type AND is_active = 1` : 'WHERE is_active = 1';
|
||||
const params = options.type ? { type: options.type } : {};
|
||||
|
||||
const stmt = db.prepare<CategoryRow>(
|
||||
`SELECT id, name, type, icon, color, user_id, is_active FROM finance_categories ${where} ORDER BY id ASC`,
|
||||
);
|
||||
|
||||
return stmt.all(params).map(mapCategory);
|
||||
}
|
||||
59
apps/backend/utils/jwt-utils.ts
Normal file
59
apps/backend/utils/jwt-utils.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { EventHandlerRequest, H3Event } from 'h3';
|
||||
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
import { UserInfo } from './mock-data';
|
||||
|
||||
// TODO: Replace with your own secret key
|
||||
const ACCESS_TOKEN_SECRET = 'access_token_secret';
|
||||
const REFRESH_TOKEN_SECRET = 'refresh_token_secret';
|
||||
|
||||
export interface UserPayload extends UserInfo {
|
||||
iat: number;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
export function generateAccessToken(user: UserInfo) {
|
||||
return jwt.sign(user, ACCESS_TOKEN_SECRET, { expiresIn: '7d' });
|
||||
}
|
||||
|
||||
export function generateRefreshToken(user: UserInfo) {
|
||||
return jwt.sign(user, REFRESH_TOKEN_SECRET, {
|
||||
expiresIn: '30d',
|
||||
});
|
||||
}
|
||||
|
||||
export function verifyAccessToken(
|
||||
event: H3Event<EventHandlerRequest>,
|
||||
): null | Omit<UserInfo, 'password'> {
|
||||
const authHeader = getHeader(event, 'Authorization');
|
||||
if (!authHeader?.startsWith('Bearer')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
try {
|
||||
const decoded = jwt.verify(token, ACCESS_TOKEN_SECRET) as UserPayload;
|
||||
|
||||
const username = decoded.username;
|
||||
const user = MOCK_USERS.find((item) => item.username === username);
|
||||
const { password: _pwd, ...userinfo } = user;
|
||||
return userinfo;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function verifyRefreshToken(
|
||||
token: string,
|
||||
): null | Omit<UserInfo, 'password'> {
|
||||
try {
|
||||
const decoded = jwt.verify(token, REFRESH_TOKEN_SECRET) as UserPayload;
|
||||
const username = decoded.username;
|
||||
const user = MOCK_USERS.find((item) => item.username === username);
|
||||
const { password: _pwd, ...userinfo } = user;
|
||||
return userinfo;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
1105
apps/backend/utils/mock-data.ts
Normal file
1105
apps/backend/utils/mock-data.ts
Normal file
File diff suppressed because it is too large
Load Diff
68
apps/backend/utils/response.ts
Normal file
68
apps/backend/utils/response.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { EventHandlerRequest, H3Event } from 'h3';
|
||||
|
||||
export function useResponseSuccess<T = any>(data: T) {
|
||||
return {
|
||||
code: 0,
|
||||
data,
|
||||
error: null,
|
||||
message: 'ok',
|
||||
};
|
||||
}
|
||||
|
||||
export function usePageResponseSuccess<T = any>(
|
||||
page: number | string,
|
||||
pageSize: number | string,
|
||||
list: T[],
|
||||
{ message = 'ok' } = {},
|
||||
) {
|
||||
const pageData = pagination(
|
||||
Number.parseInt(`${page}`),
|
||||
Number.parseInt(`${pageSize}`),
|
||||
list,
|
||||
);
|
||||
|
||||
return {
|
||||
...useResponseSuccess({
|
||||
items: pageData,
|
||||
total: list.length,
|
||||
}),
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
export function useResponseError(message: string, error: any = null) {
|
||||
return {
|
||||
code: -1,
|
||||
data: null,
|
||||
error,
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
export function forbiddenResponse(
|
||||
event: H3Event<EventHandlerRequest>,
|
||||
message = 'Forbidden Exception',
|
||||
) {
|
||||
setResponseStatus(event, 403);
|
||||
return useResponseError(message, message);
|
||||
}
|
||||
|
||||
export function unAuthorizedResponse(event: H3Event<EventHandlerRequest>) {
|
||||
setResponseStatus(event, 401);
|
||||
return useResponseError('Unauthorized Exception', 'Unauthorized Exception');
|
||||
}
|
||||
|
||||
export function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export function pagination<T = any>(
|
||||
pageNo: number,
|
||||
pageSize: number,
|
||||
array: T[],
|
||||
): T[] {
|
||||
const offset = (pageNo - 1) * Number(pageSize);
|
||||
return offset + Number(pageSize) >= array.length
|
||||
? array.slice(offset)
|
||||
: array.slice(offset, offset + Number(pageSize));
|
||||
}
|
||||
82
apps/backend/utils/sqlite.ts
Normal file
82
apps/backend/utils/sqlite.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { dirname, join } from 'pathe';
|
||||
|
||||
const dbFile = join(process.cwd(), 'storage', 'finance.db');
|
||||
|
||||
mkdirSync(dirname(dbFile), { recursive: true });
|
||||
|
||||
const database = new Database(dbFile);
|
||||
|
||||
database.pragma('journal_mode = WAL');
|
||||
|
||||
database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS finance_currencies (
|
||||
code TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
is_base INTEGER NOT NULL DEFAULT 0,
|
||||
is_active INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
`);
|
||||
|
||||
database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS finance_exchange_rates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
from_currency TEXT NOT NULL,
|
||||
to_currency TEXT NOT NULL,
|
||||
rate REAL NOT NULL,
|
||||
date TEXT NOT NULL,
|
||||
source TEXT DEFAULT 'manual'
|
||||
);
|
||||
`);
|
||||
|
||||
database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS finance_accounts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
currency TEXT NOT NULL,
|
||||
type TEXT DEFAULT 'cash',
|
||||
icon TEXT,
|
||||
color TEXT,
|
||||
user_id INTEGER DEFAULT 1,
|
||||
is_active INTEGER DEFAULT 1
|
||||
);
|
||||
`);
|
||||
|
||||
database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS finance_categories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
icon TEXT,
|
||||
color TEXT,
|
||||
user_id INTEGER DEFAULT 1,
|
||||
is_active INTEGER DEFAULT 1
|
||||
);
|
||||
`);
|
||||
|
||||
database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS finance_transactions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
type TEXT NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
currency TEXT NOT NULL,
|
||||
exchange_rate_to_base REAL NOT NULL,
|
||||
amount_in_base REAL NOT NULL,
|
||||
category_id INTEGER,
|
||||
account_id INTEGER,
|
||||
transaction_date TEXT NOT NULL,
|
||||
description TEXT,
|
||||
project TEXT,
|
||||
memo TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
is_deleted INTEGER NOT NULL DEFAULT 0,
|
||||
deleted_at TEXT,
|
||||
FOREIGN KEY (currency) REFERENCES finance_currencies(code),
|
||||
FOREIGN KEY (category_id) REFERENCES finance_categories(id),
|
||||
FOREIGN KEY (account_id) REFERENCES finance_accounts(id)
|
||||
);
|
||||
`);
|
||||
|
||||
export default database;
|
||||
Reference in New Issue
Block a user