chore: migrate to KT financial system
This commit is contained in:
@@ -1,3 +1,3 @@
|
||||
PORT=5320
|
||||
PORT=5666
|
||||
ACCESS_TOKEN_SECRET=access_token_secret
|
||||
REFRESH_TOKEN_SECRET=refresh_token_secret
|
||||
|
||||
@@ -13,3 +13,19 @@ $ pnpm run start
|
||||
# production mode
|
||||
$ pnpm run build
|
||||
```
|
||||
|
||||
## Telegram Webhook 集成
|
||||
|
||||
财务系统新增交易后可自动通知本地的 Telegram 机器人,默认会将交易数据通过以下 Webhook 发送:
|
||||
|
||||
- `http://192.168.9.28:8889/webhook/transaction`
|
||||
- 认证密钥:`ktapp.cc`
|
||||
|
||||
如需自定义目标地址或密钥,可在运行前设置以下环境变量:
|
||||
|
||||
```bash
|
||||
export TELEGRAM_WEBHOOK_URL="http://<bot-host>:8889/webhook/transaction"
|
||||
export TELEGRAM_WEBHOOK_SECRET="自定义密钥"
|
||||
```
|
||||
|
||||
也可以使用旧变量 `FINANCE_BOT_WEBHOOK_URL`、`FINANCE_BOT_WEBHOOK_SECRET` 进行兼容配置。
|
||||
|
||||
29
apps/backend/api/finance/media.get.ts
Normal file
29
apps/backend/api/finance/media.get.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { getQuery } from 'h3';
|
||||
|
||||
import { fetchMediaMessages } from '~/utils/media-repository';
|
||||
import { useResponseSuccess } from '~/utils/response';
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const query = getQuery(event);
|
||||
const limit =
|
||||
typeof query.limit === 'string' && query.limit.length > 0
|
||||
? Number.parseInt(query.limit, 10)
|
||||
: undefined;
|
||||
const rawTypes = (query.types ?? query.type ?? query.fileType) as
|
||||
| string
|
||||
| undefined;
|
||||
const fileTypes = rawTypes
|
||||
? rawTypes
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0)
|
||||
: undefined;
|
||||
|
||||
const messages = fetchMediaMessages({
|
||||
limit,
|
||||
fileTypes,
|
||||
});
|
||||
|
||||
return useResponseSuccess(messages);
|
||||
});
|
||||
|
||||
22
apps/backend/api/finance/media/[id].get.ts
Normal file
22
apps/backend/api/finance/media/[id].get.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { getRouterParam } from 'h3';
|
||||
|
||||
import { getMediaMessageById } from '~/utils/media-repository';
|
||||
import { useResponseError, useResponseSuccess } from '~/utils/response';
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const idParam = getRouterParam(event, 'id');
|
||||
const id = idParam ? Number.parseInt(idParam, 10) : NaN;
|
||||
|
||||
if (!Number.isInteger(id)) {
|
||||
return useResponseError('媒体ID不合法', -1);
|
||||
}
|
||||
|
||||
const media = getMediaMessageById(id);
|
||||
|
||||
if (!media) {
|
||||
return useResponseError('未找到对应的媒体记录', -1);
|
||||
}
|
||||
|
||||
return useResponseSuccess(media);
|
||||
});
|
||||
|
||||
46
apps/backend/api/finance/media/[id]/download.get.ts
Normal file
46
apps/backend/api/finance/media/[id]/download.get.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { createReadStream, existsSync, statSync } from 'node:fs';
|
||||
import { basename } from 'pathe';
|
||||
|
||||
import {
|
||||
getRouterParam,
|
||||
sendStream,
|
||||
setResponseHeader,
|
||||
setResponseStatus,
|
||||
} from 'h3';
|
||||
|
||||
import { getMediaMessageById } from '~/utils/media-repository';
|
||||
import { useResponseError } from '~/utils/response';
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const idParam = getRouterParam(event, 'id');
|
||||
const id = idParam ? Number.parseInt(idParam, 10) : NaN;
|
||||
|
||||
if (!Number.isInteger(id)) {
|
||||
setResponseStatus(event, 400);
|
||||
return useResponseError('媒体ID不合法', -1);
|
||||
}
|
||||
|
||||
const media = getMediaMessageById(id);
|
||||
if (!media) {
|
||||
setResponseStatus(event, 404);
|
||||
return useResponseError('未找到对应的媒体记录', -1);
|
||||
}
|
||||
|
||||
if (!media.filePath || !existsSync(media.filePath)) {
|
||||
setResponseStatus(event, 404);
|
||||
return useResponseError('媒体文件不存在或已被移除', -1);
|
||||
}
|
||||
|
||||
const fileStats = statSync(media.filePath);
|
||||
|
||||
setResponseHeader(event, 'Content-Type', media.mimeType ?? 'application/octet-stream');
|
||||
setResponseHeader(
|
||||
event,
|
||||
'Content-Disposition',
|
||||
`attachment; filename="${encodeURIComponent(media.fileName ?? basename(media.filePath))}"`,
|
||||
);
|
||||
setResponseHeader(event, 'Content-Length', `${fileStats.size}`);
|
||||
|
||||
return sendStream(event, createReadStream(media.filePath));
|
||||
});
|
||||
|
||||
35
apps/backend/api/finance/reimbursements.get.ts
Normal file
35
apps/backend/api/finance/reimbursements.get.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { getQuery } from 'h3';
|
||||
import {
|
||||
fetchTransactions,
|
||||
type TransactionStatus,
|
||||
} from '~/utils/finance-repository';
|
||||
import { useResponseSuccess } from '~/utils/response';
|
||||
|
||||
const DEFAULT_STATUSES: TransactionStatus[] = [
|
||||
'draft',
|
||||
'pending',
|
||||
'approved',
|
||||
'rejected',
|
||||
'paid',
|
||||
];
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const query = getQuery(event);
|
||||
const includeDeleted = query.includeDeleted === 'true';
|
||||
const type = query.type as string | undefined;
|
||||
const rawStatuses = (query.statuses ?? query.status) as string | undefined;
|
||||
const statuses = rawStatuses
|
||||
? (rawStatuses
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0) as TransactionStatus[])
|
||||
: DEFAULT_STATUSES;
|
||||
|
||||
const reimbursements = fetchTransactions({
|
||||
includeDeleted,
|
||||
type,
|
||||
statuses,
|
||||
});
|
||||
|
||||
return useResponseSuccess(reimbursements);
|
||||
});
|
||||
73
apps/backend/api/finance/reimbursements.post.ts
Normal file
73
apps/backend/api/finance/reimbursements.post.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { readBody } from 'h3';
|
||||
import {
|
||||
createTransaction,
|
||||
type TransactionStatus,
|
||||
} from '~/utils/finance-repository';
|
||||
import { useResponseError, useResponseSuccess } from '~/utils/response';
|
||||
import { notifyTransactionWebhook } from '~/utils/telegram-webhook';
|
||||
|
||||
const DEFAULT_CURRENCY = 'CNY';
|
||||
const DEFAULT_STATUS: TransactionStatus = 'pending';
|
||||
const ALLOWED_STATUSES: TransactionStatus[] = [
|
||||
'draft',
|
||||
'pending',
|
||||
'approved',
|
||||
'rejected',
|
||||
'paid',
|
||||
];
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event);
|
||||
|
||||
if (!body?.amount || !body?.transactionDate) {
|
||||
return useResponseError('缺少必填字段', -1);
|
||||
}
|
||||
|
||||
const amount = Number(body.amount);
|
||||
if (Number.isNaN(amount)) {
|
||||
return useResponseError('金额格式不正确', -1);
|
||||
}
|
||||
|
||||
const type =
|
||||
(body.type as 'expense' | 'income' | 'transfer' | undefined) ?? 'expense';
|
||||
const status =
|
||||
(body.status as TransactionStatus | undefined) ?? DEFAULT_STATUS;
|
||||
|
||||
if (!ALLOWED_STATUSES.includes(status)) {
|
||||
return useResponseError('状态值不合法', -1);
|
||||
}
|
||||
|
||||
const reimbursement = createTransaction({
|
||||
type,
|
||||
amount,
|
||||
currency: body.currency ?? DEFAULT_CURRENCY,
|
||||
categoryId: body.categoryId ?? null,
|
||||
accountId: body.accountId ?? null,
|
||||
transactionDate: body.transactionDate,
|
||||
description:
|
||||
body.description ??
|
||||
body.item ??
|
||||
(body.notes ? `${body.notes}` : '') ??
|
||||
'',
|
||||
project: body.project ?? body.category ?? null,
|
||||
memo: body.memo ?? body.notes ?? null,
|
||||
status,
|
||||
reimbursementBatch: body.reimbursementBatch ?? null,
|
||||
reviewNotes: body.reviewNotes ?? null,
|
||||
submittedBy: body.submittedBy ?? body.requester ?? null,
|
||||
approvedBy: body.approvedBy ?? null,
|
||||
approvedAt: body.approvedAt ?? null,
|
||||
statusUpdatedAt: body.statusUpdatedAt ?? undefined,
|
||||
});
|
||||
|
||||
notifyTransactionWebhook(reimbursement, {
|
||||
action: 'reimbursement.created',
|
||||
}).catch((error) =>
|
||||
console.error(
|
||||
'[finance][reimbursements.post] webhook notify failed',
|
||||
error,
|
||||
),
|
||||
);
|
||||
|
||||
return useResponseSuccess(reimbursement);
|
||||
});
|
||||
85
apps/backend/api/finance/reimbursements/[id].put.ts
Normal file
85
apps/backend/api/finance/reimbursements/[id].put.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { getRouterParam, readBody } from 'h3';
|
||||
import {
|
||||
restoreTransaction,
|
||||
updateTransaction,
|
||||
type TransactionStatus,
|
||||
} from '~/utils/finance-repository';
|
||||
import { useResponseError, useResponseSuccess } from '~/utils/response';
|
||||
|
||||
const ALLOWED_STATUSES: TransactionStatus[] = [
|
||||
'draft',
|
||||
'pending',
|
||||
'approved',
|
||||
'rejected',
|
||||
'paid',
|
||||
];
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const id = Number(getRouterParam(event, 'id'));
|
||||
if (Number.isNaN(id)) {
|
||||
return useResponseError('参数错误', -1);
|
||||
}
|
||||
|
||||
const body = await readBody(event);
|
||||
|
||||
if (body?.isDeleted === false) {
|
||||
const restored = restoreTransaction(id);
|
||||
if (!restored) {
|
||||
return useResponseError('报销单不存在', -1);
|
||||
}
|
||||
return useResponseSuccess(restored);
|
||||
}
|
||||
|
||||
const payload: Record<string, unknown> = {};
|
||||
|
||||
if (body?.type) payload.type = body.type;
|
||||
if (body?.amount !== undefined) {
|
||||
const amount = Number(body.amount);
|
||||
if (Number.isNaN(amount)) {
|
||||
return useResponseError('金额格式不正确', -1);
|
||||
}
|
||||
payload.amount = amount;
|
||||
}
|
||||
if (body?.currency) payload.currency = body.currency;
|
||||
if (body?.categoryId !== undefined)
|
||||
payload.categoryId = body.categoryId ?? null;
|
||||
if (body?.accountId !== undefined) payload.accountId = body.accountId ?? null;
|
||||
if (body?.transactionDate) payload.transactionDate = body.transactionDate;
|
||||
if (body?.description !== undefined)
|
||||
payload.description = body.description ?? '';
|
||||
if (body?.project !== undefined) payload.project = body.project ?? null;
|
||||
if (body?.memo !== undefined) payload.memo = body.memo ?? null;
|
||||
if (body?.isDeleted !== undefined) payload.isDeleted = body.isDeleted;
|
||||
if (body?.status !== undefined) {
|
||||
const status = body.status as TransactionStatus;
|
||||
if (!ALLOWED_STATUSES.includes(status)) {
|
||||
return useResponseError('状态值不合法', -1);
|
||||
}
|
||||
payload.status = status;
|
||||
}
|
||||
if (body?.statusUpdatedAt !== undefined) {
|
||||
payload.statusUpdatedAt = body.statusUpdatedAt;
|
||||
}
|
||||
if (body?.reimbursementBatch !== undefined) {
|
||||
payload.reimbursementBatch = body.reimbursementBatch ?? null;
|
||||
}
|
||||
if (body?.reviewNotes !== undefined) {
|
||||
payload.reviewNotes = body.reviewNotes ?? null;
|
||||
}
|
||||
if (body?.submittedBy !== undefined) {
|
||||
payload.submittedBy = body.submittedBy ?? null;
|
||||
}
|
||||
if (body?.approvedBy !== undefined) {
|
||||
payload.approvedBy = body.approvedBy ?? null;
|
||||
}
|
||||
if (body?.approvedAt !== undefined) {
|
||||
payload.approvedAt = body.approvedAt ?? null;
|
||||
}
|
||||
|
||||
const updated = updateTransaction(id, payload);
|
||||
if (!updated) {
|
||||
return useResponseError('报销单不存在', -1);
|
||||
}
|
||||
|
||||
return useResponseSuccess(updated);
|
||||
});
|
||||
@@ -1,11 +1,28 @@
|
||||
import { getQuery } from 'h3';
|
||||
import { fetchTransactions } from '~/utils/finance-repository';
|
||||
import {
|
||||
fetchTransactions,
|
||||
type TransactionStatus,
|
||||
} from '~/utils/finance-repository';
|
||||
import { useResponseSuccess } from '~/utils/response';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const query = getQuery(event);
|
||||
const type = query.type as string | undefined;
|
||||
const transactions = fetchTransactions({ type });
|
||||
const includeDeleted = query.includeDeleted === 'true';
|
||||
const rawStatuses = (query.statuses ?? query.status) as
|
||||
| string
|
||||
| undefined;
|
||||
const statuses = rawStatuses
|
||||
? (rawStatuses
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0) as TransactionStatus[])
|
||||
: (['approved', 'paid'] satisfies TransactionStatus[]);
|
||||
const transactions = fetchTransactions({
|
||||
type,
|
||||
includeDeleted,
|
||||
statuses,
|
||||
});
|
||||
|
||||
return useResponseSuccess(transactions);
|
||||
});
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import { readBody } from 'h3';
|
||||
import { createTransaction } from '~/utils/finance-repository';
|
||||
import {
|
||||
createTransaction,
|
||||
type TransactionStatus,
|
||||
} from '~/utils/finance-repository';
|
||||
import { useResponseError, useResponseSuccess } from '~/utils/response';
|
||||
import { notifyTransactionWebhook } from '~/utils/telegram-webhook';
|
||||
|
||||
const DEFAULT_CURRENCY = 'CNY';
|
||||
const ALLOWED_STATUSES: TransactionStatus[] = [
|
||||
'draft',
|
||||
'pending',
|
||||
'approved',
|
||||
'rejected',
|
||||
'paid',
|
||||
];
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event);
|
||||
@@ -16,6 +27,12 @@ export default defineEventHandler(async (event) => {
|
||||
return useResponseError('金额格式不正确', -1);
|
||||
}
|
||||
|
||||
const status =
|
||||
(body.status as TransactionStatus | undefined) ?? 'approved';
|
||||
if (!ALLOWED_STATUSES.includes(status)) {
|
||||
return useResponseError('状态值不合法', -1);
|
||||
}
|
||||
|
||||
const transaction = createTransaction({
|
||||
type: body.type,
|
||||
amount,
|
||||
@@ -26,7 +43,18 @@ export default defineEventHandler(async (event) => {
|
||||
description: body.description ?? '',
|
||||
project: body.project ?? null,
|
||||
memo: body.memo ?? null,
|
||||
status,
|
||||
reimbursementBatch: body.reimbursementBatch ?? null,
|
||||
reviewNotes: body.reviewNotes ?? null,
|
||||
submittedBy: body.submittedBy ?? null,
|
||||
approvedBy: body.approvedBy ?? null,
|
||||
statusUpdatedAt: body.statusUpdatedAt ?? undefined,
|
||||
approvedAt: body.approvedAt ?? undefined,
|
||||
});
|
||||
|
||||
notifyTransactionWebhook(transaction, { action: 'created' }).catch((error) =>
|
||||
console.error('[finance][transactions.post] webhook notify failed', error),
|
||||
);
|
||||
|
||||
return useResponseSuccess(transaction);
|
||||
});
|
||||
|
||||
@@ -2,9 +2,18 @@ import { getRouterParam, readBody } from 'h3';
|
||||
import {
|
||||
restoreTransaction,
|
||||
updateTransaction,
|
||||
type TransactionStatus,
|
||||
} from '~/utils/finance-repository';
|
||||
import { useResponseError, useResponseSuccess } from '~/utils/response';
|
||||
|
||||
const ALLOWED_STATUSES: TransactionStatus[] = [
|
||||
'draft',
|
||||
'pending',
|
||||
'approved',
|
||||
'rejected',
|
||||
'paid',
|
||||
];
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const id = Number(getRouterParam(event, 'id'));
|
||||
if (Number.isNaN(id)) {
|
||||
@@ -41,6 +50,31 @@ export default defineEventHandler(async (event) => {
|
||||
if (body?.project !== undefined) payload.project = body.project ?? null;
|
||||
if (body?.memo !== undefined) payload.memo = body.memo ?? null;
|
||||
if (body?.isDeleted !== undefined) payload.isDeleted = body.isDeleted;
|
||||
if (body?.status !== undefined) {
|
||||
const status = body.status as TransactionStatus;
|
||||
if (!ALLOWED_STATUSES.includes(status)) {
|
||||
return useResponseError('状态值不合法', -1);
|
||||
}
|
||||
payload.status = status;
|
||||
}
|
||||
if (body?.statusUpdatedAt !== undefined) {
|
||||
payload.statusUpdatedAt = body.statusUpdatedAt;
|
||||
}
|
||||
if (body?.reimbursementBatch !== undefined) {
|
||||
payload.reimbursementBatch = body.reimbursementBatch ?? null;
|
||||
}
|
||||
if (body?.reviewNotes !== undefined) {
|
||||
payload.reviewNotes = body.reviewNotes ?? null;
|
||||
}
|
||||
if (body?.submittedBy !== undefined) {
|
||||
payload.submittedBy = body.submittedBy ?? null;
|
||||
}
|
||||
if (body?.approvedBy !== undefined) {
|
||||
payload.approvedBy = body.approvedBy ?? null;
|
||||
}
|
||||
if (body?.approvedAt !== undefined) {
|
||||
payload.approvedAt = body.approvedAt ?? null;
|
||||
}
|
||||
|
||||
const updated = updateTransaction(id, payload);
|
||||
if (!updated) {
|
||||
|
||||
BIN
apps/backend/backend.tar.gz
Normal file
BIN
apps/backend/backend.tar.gz
Normal file
Binary file not shown.
@@ -42,6 +42,24 @@ fs.mkdirSync(storeDir, { recursive: true });
|
||||
const dbFile = path.join(storeDir, 'finance.db');
|
||||
const db = new Database(dbFile);
|
||||
|
||||
function assertIdentifier(name) {
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
|
||||
throw new Error(`Invalid identifier: ${name}`);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
function ensureColumn(table, column, definition) {
|
||||
const safeTable = assertIdentifier(table);
|
||||
const safeColumn = assertIdentifier(column);
|
||||
const columns = db
|
||||
.prepare(`PRAGMA table_info(${safeTable})`)
|
||||
.all()
|
||||
.map((item) => item.name);
|
||||
if (!columns.includes(safeColumn)) {
|
||||
db.exec(`ALTER TABLE ${safeTable} ADD COLUMN ${definition}`);
|
||||
}
|
||||
}
|
||||
db.pragma('journal_mode = WAL');
|
||||
|
||||
db.exec(`
|
||||
@@ -106,11 +124,38 @@ db.exec(`
|
||||
project TEXT,
|
||||
memo TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'approved',
|
||||
status_updated_at TEXT,
|
||||
reimbursement_batch TEXT,
|
||||
review_notes TEXT,
|
||||
submitted_by TEXT,
|
||||
approved_by TEXT,
|
||||
approved_at TEXT,
|
||||
is_deleted INTEGER NOT NULL DEFAULT 0,
|
||||
deleted_at TEXT
|
||||
);
|
||||
`);
|
||||
|
||||
ensureColumn(
|
||||
'finance_transactions',
|
||||
'status',
|
||||
"status TEXT NOT NULL DEFAULT 'approved'",
|
||||
);
|
||||
ensureColumn(
|
||||
'finance_transactions',
|
||||
'status_updated_at',
|
||||
'status_updated_at TEXT',
|
||||
);
|
||||
ensureColumn(
|
||||
'finance_transactions',
|
||||
'reimbursement_batch',
|
||||
'reimbursement_batch TEXT',
|
||||
);
|
||||
ensureColumn('finance_transactions', 'review_notes', 'review_notes TEXT');
|
||||
ensureColumn('finance_transactions', 'submitted_by', 'submitted_by TEXT');
|
||||
ensureColumn('finance_transactions', 'approved_by', 'approved_by TEXT');
|
||||
ensureColumn('finance_transactions', 'approved_at', 'approved_at TEXT');
|
||||
|
||||
const RAW_TEXT = fs.readFileSync(inputPath, 'utf8').replace(/^\uFEFF/, '');
|
||||
const lines = RAW_TEXT.split(/\r?\n/).filter((line) => line.trim().length > 0);
|
||||
if (lines.length <= 1) {
|
||||
|
||||
@@ -5,13 +5,39 @@ import {
|
||||
MOCK_CURRENCIES,
|
||||
MOCK_EXCHANGE_RATES,
|
||||
} from './mock-data';
|
||||
import db from './sqlite';
|
||||
|
||||
export function listAccounts() {
|
||||
return MOCK_ACCOUNTS;
|
||||
}
|
||||
|
||||
export function listCategories() {
|
||||
return MOCK_CATEGORIES;
|
||||
// 从数据库读取分类
|
||||
try {
|
||||
const stmt = db.prepare(`
|
||||
SELECT id, name, type, icon, color, user_id as userId, is_active as isActive
|
||||
FROM finance_categories
|
||||
WHERE is_active = 1
|
||||
ORDER BY type, id
|
||||
`);
|
||||
const categories = stmt.all() as any[];
|
||||
|
||||
// 转换为前端需要的格式
|
||||
return categories.map(cat => ({
|
||||
id: cat.id,
|
||||
userId: cat.userId,
|
||||
name: cat.name,
|
||||
type: cat.type,
|
||||
icon: cat.icon,
|
||||
color: cat.color,
|
||||
sortOrder: cat.id,
|
||||
isSystem: true,
|
||||
isActive: Boolean(cat.isActive),
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('从数据库读取分类失败,使用MOCK数据:', error);
|
||||
return MOCK_CATEGORIES;
|
||||
}
|
||||
}
|
||||
|
||||
export function listBudgets() {
|
||||
@@ -27,29 +53,78 @@ export function listExchangeRates() {
|
||||
}
|
||||
|
||||
export function createCategoryRecord(category: any) {
|
||||
const newCategory = {
|
||||
...category,
|
||||
id: MOCK_CATEGORIES.length + 1,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
MOCK_CATEGORIES.push(newCategory);
|
||||
return newCategory;
|
||||
try {
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO finance_categories (name, type, icon, color, user_id, is_active)
|
||||
VALUES (?, ?, ?, ?, ?, 1)
|
||||
`);
|
||||
const result = stmt.run(
|
||||
category.name,
|
||||
category.type,
|
||||
category.icon || '📝',
|
||||
category.color || '#dfe4ea',
|
||||
category.userId || 1
|
||||
);
|
||||
return {
|
||||
id: result.lastInsertRowid,
|
||||
...category,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('创建分类失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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];
|
||||
try {
|
||||
const updates: string[] = [];
|
||||
const params: any[] = [];
|
||||
|
||||
if (category.name) {
|
||||
updates.push('name = ?');
|
||||
params.push(category.name);
|
||||
}
|
||||
if (category.icon) {
|
||||
updates.push('icon = ?');
|
||||
params.push(category.icon);
|
||||
}
|
||||
if (category.color) {
|
||||
updates.push('color = ?');
|
||||
params.push(category.color);
|
||||
}
|
||||
|
||||
if (updates.length === 0) return null;
|
||||
|
||||
params.push(id);
|
||||
const stmt = db.prepare(`
|
||||
UPDATE finance_categories
|
||||
SET ${updates.join(', ')}
|
||||
WHERE id = ?
|
||||
`);
|
||||
stmt.run(...params);
|
||||
|
||||
// 返回更新后的分类
|
||||
const selectStmt = db.prepare('SELECT * FROM finance_categories WHERE id = ?');
|
||||
return selectStmt.get(id);
|
||||
} catch (error) {
|
||||
console.error('更新分类失败:', error);
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function deleteCategoryRecord(id: number) {
|
||||
const index = MOCK_CATEGORIES.findIndex((c) => c.id === id);
|
||||
if (index !== -1) {
|
||||
MOCK_CATEGORIES.splice(index, 1);
|
||||
try {
|
||||
// 软删除
|
||||
const stmt = db.prepare(`
|
||||
UPDATE finance_categories
|
||||
SET is_active = 0
|
||||
WHERE id = ?
|
||||
`);
|
||||
stmt.run(id);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('删除分类失败:', error);
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,13 @@ interface TransactionRow {
|
||||
project: null | string;
|
||||
memo: null | string;
|
||||
created_at: string;
|
||||
status: string;
|
||||
status_updated_at: null | string;
|
||||
reimbursement_batch: null | string;
|
||||
review_notes: null | string;
|
||||
submitted_by: null | string;
|
||||
approved_by: null | string;
|
||||
approved_at: null | string;
|
||||
is_deleted: number;
|
||||
deleted_at: null | string;
|
||||
}
|
||||
@@ -32,8 +39,22 @@ interface TransactionPayload {
|
||||
memo?: null | string;
|
||||
createdAt?: string;
|
||||
isDeleted?: boolean;
|
||||
status?: TransactionStatus;
|
||||
statusUpdatedAt?: string;
|
||||
reimbursementBatch?: null | string;
|
||||
reviewNotes?: null | string;
|
||||
submittedBy?: null | string;
|
||||
approvedBy?: null | string;
|
||||
approvedAt?: null | string;
|
||||
}
|
||||
|
||||
export type TransactionStatus =
|
||||
| 'draft'
|
||||
| 'pending'
|
||||
| 'approved'
|
||||
| 'rejected'
|
||||
| 'paid';
|
||||
|
||||
function getExchangeRateToBase(currency: string) {
|
||||
if (currency === BASE_CURRENCY) {
|
||||
return 1;
|
||||
@@ -49,11 +70,11 @@ function mapTransaction(row: TransactionRow) {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: 1,
|
||||
type: row.type as 'expense' | 'income' | 'transfer',
|
||||
amount: row.amount,
|
||||
type: 'expense' as const,
|
||||
amount: Math.abs(row.amount),
|
||||
currency: row.currency,
|
||||
exchangeRateToBase: row.exchange_rate_to_base,
|
||||
amountInBase: row.amount_in_base,
|
||||
amountInBase: Math.abs(row.amount_in_base),
|
||||
categoryId: row.category_id ?? undefined,
|
||||
accountId: row.account_id ?? undefined,
|
||||
transactionDate: row.transaction_date,
|
||||
@@ -61,13 +82,24 @@ function mapTransaction(row: TransactionRow) {
|
||||
project: row.project ?? undefined,
|
||||
memo: row.memo ?? undefined,
|
||||
createdAt: row.created_at,
|
||||
status: row.status as TransactionStatus,
|
||||
statusUpdatedAt: row.status_updated_at ?? undefined,
|
||||
reimbursementBatch: row.reimbursement_batch ?? undefined,
|
||||
reviewNotes: row.review_notes ?? undefined,
|
||||
submittedBy: row.submitted_by ?? undefined,
|
||||
approvedBy: row.approved_by ?? undefined,
|
||||
approvedAt: row.approved_at ?? undefined,
|
||||
isDeleted: Boolean(row.is_deleted),
|
||||
deletedAt: row.deleted_at ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchTransactions(
|
||||
options: { includeDeleted?: boolean; type?: string } = {},
|
||||
options: {
|
||||
includeDeleted?: boolean;
|
||||
type?: string;
|
||||
statuses?: TransactionStatus[];
|
||||
} = {},
|
||||
) {
|
||||
const clauses: string[] = [];
|
||||
const params: Record<string, unknown> = {};
|
||||
@@ -79,11 +111,19 @@ export function fetchTransactions(
|
||||
clauses.push('type = @type');
|
||||
params.type = options.type;
|
||||
}
|
||||
if (options.statuses && options.statuses.length > 0) {
|
||||
clauses.push(
|
||||
`status IN (${options.statuses.map((_, index) => `@status${index}`).join(', ')})`,
|
||||
);
|
||||
options.statuses.forEach((status, index) => {
|
||||
params[`status${index}`] = status;
|
||||
});
|
||||
}
|
||||
|
||||
const where = clauses.length > 0 ? `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`,
|
||||
`SELECT id, type, amount, currency, exchange_rate_to_base, amount_in_base, category_id, account_id, transaction_date, description, project, memo, created_at, status, status_updated_at, reimbursement_batch, review_notes, submitted_by, approved_by, approved_at, is_deleted, deleted_at FROM finance_transactions ${where} ORDER BY transaction_date DESC, id DESC`,
|
||||
);
|
||||
|
||||
return stmt.all(params).map(mapTransaction);
|
||||
@@ -91,7 +131,7 @@ export function fetchTransactions(
|
||||
|
||||
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 = ?`,
|
||||
`SELECT id, type, amount, currency, exchange_rate_to_base, amount_in_base, category_id, account_id, transaction_date, description, project, memo, created_at, status, status_updated_at, reimbursement_batch, review_notes, submitted_by, approved_by, approved_at, is_deleted, deleted_at FROM finance_transactions WHERE id = ?`,
|
||||
);
|
||||
const row = stmt.get(id);
|
||||
return row ? mapTransaction(row) : null;
|
||||
@@ -104,9 +144,20 @@ export function createTransaction(payload: TransactionPayload) {
|
||||
payload.createdAt && payload.createdAt.length > 0
|
||||
? payload.createdAt
|
||||
: new Date().toISOString();
|
||||
const status: TransactionStatus = payload.status ?? 'approved';
|
||||
const statusUpdatedAt =
|
||||
payload.statusUpdatedAt && payload.statusUpdatedAt.length > 0
|
||||
? payload.statusUpdatedAt
|
||||
: createdAt;
|
||||
const approvedAt =
|
||||
payload.approvedAt && payload.approvedAt.length > 0
|
||||
? payload.approvedAt
|
||||
: status === 'approved' || status === 'paid'
|
||||
? statusUpdatedAt
|
||||
: null;
|
||||
|
||||
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)`,
|
||||
`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, status, status_updated_at, reimbursement_batch, review_notes, submitted_by, approved_by, approved_at, is_deleted) VALUES (@type, @amount, @currency, @exchangeRateToBase, @amountInBase, @categoryId, @accountId, @transactionDate, @description, @project, @memo, @createdAt, @status, @statusUpdatedAt, @reimbursementBatch, @reviewNotes, @submittedBy, @approvedBy, @approvedAt, 0)`,
|
||||
);
|
||||
|
||||
const info = stmt.run({
|
||||
@@ -122,6 +173,13 @@ export function createTransaction(payload: TransactionPayload) {
|
||||
project: payload.project ?? null,
|
||||
memo: payload.memo ?? null,
|
||||
createdAt,
|
||||
status,
|
||||
statusUpdatedAt,
|
||||
reimbursementBatch: payload.reimbursementBatch ?? null,
|
||||
reviewNotes: payload.reviewNotes ?? null,
|
||||
submittedBy: payload.submittedBy ?? null,
|
||||
approvedBy: payload.approvedBy ?? null,
|
||||
approvedAt,
|
||||
});
|
||||
|
||||
return getTransactionById(Number(info.lastInsertRowid));
|
||||
@@ -133,6 +191,25 @@ export function updateTransaction(id: number, payload: TransactionPayload) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextStatus = (payload.status ?? current.status ?? 'approved') as TransactionStatus;
|
||||
const statusChanged = nextStatus !== current.status;
|
||||
const statusUpdatedAt =
|
||||
payload.statusUpdatedAt && payload.statusUpdatedAt.length > 0
|
||||
? payload.statusUpdatedAt
|
||||
: statusChanged
|
||||
? new Date().toISOString()
|
||||
: current.statusUpdatedAt ?? current.createdAt;
|
||||
const approvedAt =
|
||||
payload.approvedAt && payload.approvedAt.length > 0
|
||||
? payload.approvedAt
|
||||
: nextStatus === 'approved' || nextStatus === 'paid'
|
||||
? current.approvedAt ?? (statusChanged ? statusUpdatedAt : null)
|
||||
: null;
|
||||
const approvedBy =
|
||||
nextStatus === 'approved' || nextStatus === 'paid'
|
||||
? payload.approvedBy ?? current.approvedBy ?? null
|
||||
: payload.approvedBy ?? null;
|
||||
|
||||
const next = {
|
||||
type: payload.type ?? current.type,
|
||||
amount: payload.amount ?? current.amount,
|
||||
@@ -144,13 +221,21 @@ export function updateTransaction(id: number, payload: TransactionPayload) {
|
||||
project: payload.project ?? current.project ?? null,
|
||||
memo: payload.memo ?? current.memo ?? null,
|
||||
isDeleted: payload.isDeleted ?? current.isDeleted,
|
||||
status: nextStatus,
|
||||
statusUpdatedAt,
|
||||
reimbursementBatch:
|
||||
payload.reimbursementBatch ?? current.reimbursementBatch ?? null,
|
||||
reviewNotes: payload.reviewNotes ?? current.reviewNotes ?? null,
|
||||
submittedBy: payload.submittedBy ?? current.submittedBy ?? null,
|
||||
approvedBy,
|
||||
approvedAt,
|
||||
};
|
||||
|
||||
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`,
|
||||
`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, status = @status, status_updated_at = @statusUpdatedAt, reimbursement_batch = @reimbursementBatch, review_notes = @reviewNotes, submitted_by = @submittedBy, approved_by = @approvedBy, approved_at = @approvedAt, is_deleted = @isDeleted, deleted_at = @deletedAt WHERE id = @id`,
|
||||
);
|
||||
|
||||
const deletedAt = next.isDeleted ? new Date().toISOString() : null;
|
||||
@@ -168,6 +253,13 @@ export function updateTransaction(id: number, payload: TransactionPayload) {
|
||||
description: next.description,
|
||||
project: next.project,
|
||||
memo: next.memo,
|
||||
status: next.status,
|
||||
statusUpdatedAt: next.statusUpdatedAt,
|
||||
reimbursementBatch: next.reimbursementBatch,
|
||||
reviewNotes: next.reviewNotes,
|
||||
submittedBy: next.submittedBy,
|
||||
approvedBy: next.approvedBy,
|
||||
approvedAt: next.approvedAt,
|
||||
isDeleted: next.isDeleted ? 1 : 0,
|
||||
deletedAt,
|
||||
});
|
||||
@@ -203,12 +295,20 @@ export function replaceAllTransactions(
|
||||
project?: null | string;
|
||||
transactionDate: string;
|
||||
type: string;
|
||||
status?: TransactionStatus;
|
||||
statusUpdatedAt?: string;
|
||||
reimbursementBatch?: null | string;
|
||||
reviewNotes?: null | string;
|
||||
submittedBy?: null | string;
|
||||
approvedBy?: null | string;
|
||||
approvedAt?: null | string;
|
||||
isDeleted?: boolean;
|
||||
}>,
|
||||
) {
|
||||
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)`,
|
||||
`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, status, status_updated_at, reimbursement_batch, review_notes, submitted_by, approved_by, approved_at, is_deleted) VALUES (@type, @amount, @currency, @exchangeRateToBase, @amountInBase, @categoryId, @accountId, @transactionDate, @description, @project, @memo, @createdAt, @status, @statusUpdatedAt, @reimbursementBatch, @reviewNotes, @submittedBy, @approvedBy, @approvedAt, @isDeleted)`,
|
||||
);
|
||||
|
||||
const getRate = db.prepare(
|
||||
@@ -220,15 +320,36 @@ export function replaceAllTransactions(
|
||||
const row = getRate.get(item.currency) as undefined | { rate: number };
|
||||
const rate = row?.rate ?? 1;
|
||||
const amountInBase = +(item.amount * rate).toFixed(2);
|
||||
const createdAt =
|
||||
item.createdAt ??
|
||||
new Date(`${item.transactionDate}T00:00:00Z`).toISOString();
|
||||
const status = item.status ?? 'approved';
|
||||
const statusUpdatedAt =
|
||||
item.statusUpdatedAt ??
|
||||
new Date(
|
||||
`${item.transactionDate}T00:00:00Z`,
|
||||
).toISOString();
|
||||
const approvedAt =
|
||||
item.approvedAt ??
|
||||
(status === 'approved' || status === 'paid' ? statusUpdatedAt : null);
|
||||
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(),
|
||||
createdAt,
|
||||
status,
|
||||
statusUpdatedAt,
|
||||
reimbursementBatch: item.reimbursementBatch ?? null,
|
||||
reviewNotes: item.reviewNotes ?? null,
|
||||
submittedBy: item.submittedBy ?? null,
|
||||
approvedBy:
|
||||
status === 'approved' || status === 'paid'
|
||||
? item.approvedBy ?? null
|
||||
: null,
|
||||
approvedAt,
|
||||
isDeleted: item.isDeleted ? 1 : 0,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
117
apps/backend/utils/media-repository.ts
Normal file
117
apps/backend/utils/media-repository.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
|
||||
import db from './sqlite';
|
||||
|
||||
interface MediaRow {
|
||||
id: number;
|
||||
chat_id: number;
|
||||
message_id: number;
|
||||
user_id: number;
|
||||
username: null | string;
|
||||
display_name: null | string;
|
||||
file_type: string;
|
||||
file_id: string;
|
||||
file_unique_id: null | string;
|
||||
caption: null | string;
|
||||
file_name: null | string;
|
||||
file_path: string;
|
||||
file_size: null | number;
|
||||
mime_type: null | string;
|
||||
duration: null | number;
|
||||
width: null | number;
|
||||
height: null | number;
|
||||
forwarded_to: null | number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface MediaMessage {
|
||||
id: number;
|
||||
chatId: number;
|
||||
messageId: number;
|
||||
userId: number;
|
||||
username?: string;
|
||||
displayName?: string;
|
||||
fileType: string;
|
||||
fileId: string;
|
||||
fileUniqueId?: string;
|
||||
caption?: string;
|
||||
fileName?: string;
|
||||
filePath: string;
|
||||
fileSize?: number;
|
||||
mimeType?: string;
|
||||
duration?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
forwardedTo?: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
available: boolean;
|
||||
downloadUrl: string | null;
|
||||
}
|
||||
|
||||
function mapMediaRow(row: MediaRow): MediaMessage {
|
||||
const fileExists = existsSync(row.file_path);
|
||||
return {
|
||||
id: row.id,
|
||||
chatId: row.chat_id,
|
||||
messageId: row.message_id,
|
||||
userId: row.user_id,
|
||||
username: row.username ?? undefined,
|
||||
displayName: row.display_name ?? undefined,
|
||||
fileType: row.file_type,
|
||||
fileId: row.file_id,
|
||||
fileUniqueId: row.file_unique_id ?? undefined,
|
||||
caption: row.caption ?? undefined,
|
||||
fileName: row.file_name ?? undefined,
|
||||
filePath: row.file_path,
|
||||
fileSize: row.file_size ?? undefined,
|
||||
mimeType: row.mime_type ?? undefined,
|
||||
duration: row.duration ?? undefined,
|
||||
width: row.width ?? undefined,
|
||||
height: row.height ?? undefined,
|
||||
forwardedTo: row.forwarded_to ?? undefined,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
available: fileExists,
|
||||
downloadUrl: fileExists ? `/finance/media/${row.id}/download` : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchMediaMessages(params: {
|
||||
limit?: number;
|
||||
fileTypes?: string[];
|
||||
} = {}) {
|
||||
const clauses: string[] = [];
|
||||
const bindParams: Record<string, unknown> = {};
|
||||
|
||||
if (params.fileTypes && params.fileTypes.length > 0) {
|
||||
clauses.push(
|
||||
`file_type IN (${params.fileTypes.map((_, index) => `@type${index}`).join(', ')})`,
|
||||
);
|
||||
params.fileTypes.forEach((type, index) => {
|
||||
bindParams[`type${index}`] = type;
|
||||
});
|
||||
}
|
||||
|
||||
const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '';
|
||||
const limitClause =
|
||||
params.limit && params.limit > 0 ? `LIMIT ${Number(params.limit)}` : '';
|
||||
|
||||
const stmt = db.prepare<MediaRow>(
|
||||
`SELECT id, chat_id, message_id, user_id, username, display_name, file_type, file_id, file_unique_id, caption, file_name, file_path, file_size, mime_type, duration, width, height, forwarded_to, created_at, updated_at FROM finance_media_messages ${where} ORDER BY datetime(created_at) DESC, id DESC ${limitClause}`,
|
||||
);
|
||||
|
||||
return stmt.all(bindParams).map(mapMediaRow);
|
||||
}
|
||||
|
||||
export function getMediaMessageById(id: number) {
|
||||
const stmt = db.prepare<MediaRow>(
|
||||
`SELECT id, chat_id, message_id, user_id, username, display_name, file_type, file_id, file_unique_id, caption, file_name, file_path, file_size, mime_type, duration, width, height, forwarded_to, created_at, updated_at FROM finance_media_messages WHERE id = ?`,
|
||||
);
|
||||
|
||||
const row = stmt.get(id);
|
||||
|
||||
return row ? mapMediaRow(row) : null;
|
||||
}
|
||||
|
||||
15
apps/backend/utils/media-storage.ts
Normal file
15
apps/backend/utils/media-storage.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { join } from 'pathe';
|
||||
|
||||
const MEDIA_ROOT = join(process.cwd(), 'storage', 'telegram-media');
|
||||
|
||||
mkdirSync(MEDIA_ROOT, { recursive: true });
|
||||
|
||||
export function getMediaRoot() {
|
||||
return MEDIA_ROOT;
|
||||
}
|
||||
|
||||
export function resolveMediaAbsolutePath(relativePath: string) {
|
||||
return join(MEDIA_ROOT, relativePath);
|
||||
}
|
||||
|
||||
@@ -693,11 +693,11 @@ export interface Category {
|
||||
}
|
||||
|
||||
export const MOCK_CATEGORIES: Category[] = [
|
||||
// 支出分类
|
||||
// 支出分类 (ID 1-17)
|
||||
{
|
||||
id: 1,
|
||||
userId: null,
|
||||
name: '餐饮',
|
||||
name: '餐饮美食',
|
||||
type: 'expense',
|
||||
icon: '🍜',
|
||||
color: '#ff6b6b',
|
||||
@@ -708,9 +708,9 @@ export const MOCK_CATEGORIES: Category[] = [
|
||||
{
|
||||
id: 2,
|
||||
userId: null,
|
||||
name: '交通',
|
||||
name: '佣金/返佣',
|
||||
type: 'expense',
|
||||
icon: '🚗',
|
||||
icon: '💸',
|
||||
color: '#4ecdc4',
|
||||
sortOrder: 2,
|
||||
isSystem: true,
|
||||
@@ -719,9 +719,9 @@ export const MOCK_CATEGORIES: Category[] = [
|
||||
{
|
||||
id: 3,
|
||||
userId: null,
|
||||
name: '购物',
|
||||
name: '分红',
|
||||
type: 'expense',
|
||||
icon: '🛍️',
|
||||
icon: '💰',
|
||||
color: '#95e1d3',
|
||||
sortOrder: 3,
|
||||
isSystem: true,
|
||||
@@ -730,9 +730,9 @@ export const MOCK_CATEGORIES: Category[] = [
|
||||
{
|
||||
id: 4,
|
||||
userId: null,
|
||||
name: '娱乐',
|
||||
name: '技术/软件',
|
||||
type: 'expense',
|
||||
icon: '🎮',
|
||||
icon: '💻',
|
||||
color: '#f38181',
|
||||
sortOrder: 4,
|
||||
isSystem: true,
|
||||
@@ -741,9 +741,9 @@ export const MOCK_CATEGORIES: Category[] = [
|
||||
{
|
||||
id: 5,
|
||||
userId: null,
|
||||
name: '软件订阅',
|
||||
name: '固定资产',
|
||||
type: 'expense',
|
||||
icon: '💻',
|
||||
icon: '🏠',
|
||||
color: '#aa96da',
|
||||
sortOrder: 5,
|
||||
isSystem: true,
|
||||
@@ -752,9 +752,9 @@ export const MOCK_CATEGORIES: Category[] = [
|
||||
{
|
||||
id: 6,
|
||||
userId: null,
|
||||
name: '投资支出',
|
||||
name: '退款',
|
||||
type: 'expense',
|
||||
icon: '📊',
|
||||
icon: '↩️',
|
||||
color: '#fcbad3',
|
||||
sortOrder: 6,
|
||||
isSystem: true,
|
||||
@@ -763,9 +763,9 @@ export const MOCK_CATEGORIES: Category[] = [
|
||||
{
|
||||
id: 7,
|
||||
userId: null,
|
||||
name: '医疗健康',
|
||||
name: '服务器/技术',
|
||||
type: 'expense',
|
||||
icon: '🏥',
|
||||
icon: '🖥️',
|
||||
color: '#a8d8ea',
|
||||
sortOrder: 7,
|
||||
isSystem: true,
|
||||
@@ -774,9 +774,9 @@ export const MOCK_CATEGORIES: Category[] = [
|
||||
{
|
||||
id: 8,
|
||||
userId: null,
|
||||
name: '房租房贷',
|
||||
name: '工资',
|
||||
type: 'expense',
|
||||
icon: '🏠',
|
||||
icon: '💼',
|
||||
color: '#ffcccc',
|
||||
sortOrder: 8,
|
||||
isSystem: true,
|
||||
@@ -785,9 +785,9 @@ export const MOCK_CATEGORIES: Category[] = [
|
||||
{
|
||||
id: 9,
|
||||
userId: null,
|
||||
name: '教育',
|
||||
name: '借款/转账',
|
||||
type: 'expense',
|
||||
icon: '📚',
|
||||
icon: '🔄',
|
||||
color: '#ffd3b6',
|
||||
sortOrder: 9,
|
||||
isSystem: true,
|
||||
@@ -796,29 +796,106 @@ export const MOCK_CATEGORIES: Category[] = [
|
||||
{
|
||||
id: 10,
|
||||
userId: null,
|
||||
name: '广告推广',
|
||||
type: 'expense',
|
||||
icon: '📢',
|
||||
color: '#dfe4ea',
|
||||
sortOrder: 10,
|
||||
isSystem: true,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
userId: null,
|
||||
name: '交通出行',
|
||||
type: 'expense',
|
||||
icon: '🚗',
|
||||
color: '#74b9ff',
|
||||
sortOrder: 11,
|
||||
isSystem: true,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
userId: null,
|
||||
name: '购物消费',
|
||||
type: 'expense',
|
||||
icon: '🛍️',
|
||||
color: '#fd79a8',
|
||||
sortOrder: 12,
|
||||
isSystem: true,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: 13,
|
||||
userId: null,
|
||||
name: '娱乐休闲',
|
||||
type: 'expense',
|
||||
icon: '🎮',
|
||||
color: '#fdcb6e',
|
||||
sortOrder: 13,
|
||||
isSystem: true,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: 14,
|
||||
userId: null,
|
||||
name: '医疗健康',
|
||||
type: 'expense',
|
||||
icon: '🏥',
|
||||
color: '#55efc4',
|
||||
sortOrder: 14,
|
||||
isSystem: true,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: 15,
|
||||
userId: null,
|
||||
name: '教育学习',
|
||||
type: 'expense',
|
||||
icon: '📚',
|
||||
color: '#a29bfe',
|
||||
sortOrder: 15,
|
||||
isSystem: true,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: 16,
|
||||
userId: null,
|
||||
name: '房租房贷',
|
||||
type: 'expense',
|
||||
icon: '🏘️',
|
||||
color: '#ff7675',
|
||||
sortOrder: 16,
|
||||
isSystem: true,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: 17,
|
||||
userId: null,
|
||||
name: '其他支出',
|
||||
type: 'expense',
|
||||
icon: '📝',
|
||||
color: '#dfe4ea',
|
||||
color: '#b2bec3',
|
||||
sortOrder: 99,
|
||||
isSystem: true,
|
||||
isActive: true,
|
||||
},
|
||||
|
||||
// 收入分类
|
||||
// 收入分类 (ID 18-23)
|
||||
{
|
||||
id: 11,
|
||||
id: 18,
|
||||
userId: null,
|
||||
name: '工资',
|
||||
name: '工资收入',
|
||||
type: 'income',
|
||||
icon: '💼',
|
||||
icon: '💵',
|
||||
color: '#38ada9',
|
||||
sortOrder: 1,
|
||||
isSystem: true,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
id: 19,
|
||||
userId: null,
|
||||
name: '奖金',
|
||||
type: 'income',
|
||||
@@ -829,7 +906,7 @@ export const MOCK_CATEGORIES: Category[] = [
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: 13,
|
||||
id: 20,
|
||||
userId: null,
|
||||
name: '投资收益',
|
||||
type: 'income',
|
||||
@@ -840,7 +917,7 @@ export const MOCK_CATEGORIES: Category[] = [
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: 14,
|
||||
id: 21,
|
||||
userId: null,
|
||||
name: '副业收入',
|
||||
type: 'income',
|
||||
@@ -851,12 +928,23 @@ export const MOCK_CATEGORIES: Category[] = [
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: 15,
|
||||
id: 22,
|
||||
userId: null,
|
||||
name: '退款收入',
|
||||
type: 'income',
|
||||
icon: '↩️',
|
||||
color: '#82ccdd',
|
||||
sortOrder: 5,
|
||||
isSystem: true,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: 23,
|
||||
userId: null,
|
||||
name: '其他收入',
|
||||
type: 'income',
|
||||
icon: '💰',
|
||||
color: '#82ccdd',
|
||||
color: '#10ac84',
|
||||
sortOrder: 99,
|
||||
isSystem: true,
|
||||
isActive: true,
|
||||
|
||||
@@ -9,6 +9,24 @@ mkdirSync(dirname(dbFile), { recursive: true });
|
||||
|
||||
const database = new Database(dbFile);
|
||||
|
||||
function assertIdentifier(name: string) {
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
|
||||
throw new Error(`Invalid identifier: ${name}`);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
function ensureColumn(table: string, column: string, definition: string) {
|
||||
const safeTable = assertIdentifier(table);
|
||||
const safeColumn = assertIdentifier(column);
|
||||
const columns = database
|
||||
.prepare<{ name: string }>(`PRAGMA table_info(${safeTable})`)
|
||||
.all();
|
||||
if (!columns.some((item) => item.name === safeColumn)) {
|
||||
database.exec(`ALTER TABLE ${safeTable} ADD COLUMN ${definition}`);
|
||||
}
|
||||
}
|
||||
|
||||
database.pragma('journal_mode = WAL');
|
||||
|
||||
database.exec(`
|
||||
@@ -72,6 +90,13 @@ database.exec(`
|
||||
project TEXT,
|
||||
memo TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'approved',
|
||||
status_updated_at TEXT,
|
||||
reimbursement_batch TEXT,
|
||||
review_notes TEXT,
|
||||
submitted_by TEXT,
|
||||
approved_by TEXT,
|
||||
approved_at TEXT,
|
||||
is_deleted INTEGER NOT NULL DEFAULT 0,
|
||||
deleted_at TEXT,
|
||||
FOREIGN KEY (currency) REFERENCES finance_currencies(code),
|
||||
@@ -80,4 +105,56 @@ database.exec(`
|
||||
);
|
||||
`);
|
||||
|
||||
ensureColumn(
|
||||
'finance_transactions',
|
||||
'status',
|
||||
"status TEXT NOT NULL DEFAULT 'approved'",
|
||||
);
|
||||
ensureColumn('finance_transactions', 'status_updated_at', 'status_updated_at TEXT');
|
||||
ensureColumn(
|
||||
'finance_transactions',
|
||||
'reimbursement_batch',
|
||||
'reimbursement_batch TEXT',
|
||||
);
|
||||
ensureColumn('finance_transactions', 'review_notes', 'review_notes TEXT');
|
||||
ensureColumn('finance_transactions', 'submitted_by', 'submitted_by TEXT');
|
||||
ensureColumn('finance_transactions', 'approved_by', 'approved_by TEXT');
|
||||
ensureColumn('finance_transactions', 'approved_at', 'approved_at TEXT');
|
||||
|
||||
database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS finance_media_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
chat_id INTEGER NOT NULL,
|
||||
message_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
username TEXT,
|
||||
display_name TEXT,
|
||||
file_type TEXT NOT NULL,
|
||||
file_id TEXT NOT NULL,
|
||||
file_unique_id TEXT,
|
||||
caption TEXT,
|
||||
file_name TEXT,
|
||||
file_path TEXT NOT NULL,
|
||||
file_size INTEGER,
|
||||
mime_type TEXT,
|
||||
duration INTEGER,
|
||||
width INTEGER,
|
||||
height INTEGER,
|
||||
forwarded_to INTEGER,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(chat_id, message_id)
|
||||
);
|
||||
`);
|
||||
|
||||
database.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_finance_media_messages_created_at
|
||||
ON finance_media_messages (created_at DESC);
|
||||
`);
|
||||
|
||||
database.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_finance_media_messages_user_id
|
||||
ON finance_media_messages (user_id);
|
||||
`);
|
||||
|
||||
export default database;
|
||||
|
||||
73
apps/backend/utils/telegram-webhook.ts
Normal file
73
apps/backend/utils/telegram-webhook.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
const DEFAULT_WEBHOOK_URL =
|
||||
process.env.TELEGRAM_WEBHOOK_URL ??
|
||||
process.env.FINANCE_BOT_WEBHOOK_URL ??
|
||||
'http://192.168.9.28:8889/webhook/transaction';
|
||||
const DEFAULT_WEBHOOK_SECRET =
|
||||
process.env.TELEGRAM_WEBHOOK_SECRET ??
|
||||
process.env.FINANCE_BOT_WEBHOOK_SECRET ??
|
||||
'ktapp.cc';
|
||||
|
||||
interface TransactionPayload {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
async function postToWebhook(
|
||||
payload: TransactionPayload,
|
||||
webhookURL: string,
|
||||
webhookSecret: string,
|
||||
) {
|
||||
try {
|
||||
const response = await fetch(webhookURL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${webhookSecret}`,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
console.error(
|
||||
'[telegram-webhook] Failed to notify webhook',
|
||||
response.status,
|
||||
text,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[telegram-webhook] Webhook request error', error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function notifyTransactionWebhook(
|
||||
transaction: TransactionPayload,
|
||||
options: {
|
||||
webhookURL?: string;
|
||||
webhookSecret?: string;
|
||||
action?: string;
|
||||
source?: string;
|
||||
} = {},
|
||||
) {
|
||||
const url = (options.webhookURL ?? DEFAULT_WEBHOOK_URL).trim();
|
||||
const secret = (options.webhookSecret ?? DEFAULT_WEBHOOK_SECRET).trim();
|
||||
|
||||
if (!url || !secret) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: TransactionPayload = {
|
||||
...transaction,
|
||||
};
|
||||
|
||||
if (options.action) {
|
||||
payload.action = options.action;
|
||||
}
|
||||
|
||||
if (options.source) {
|
||||
payload.source = options.source;
|
||||
} else {
|
||||
payload.source = payload.source ?? 'finwise-backend';
|
||||
}
|
||||
|
||||
await postToWebhook(payload, url, secret);
|
||||
}
|
||||
Reference in New Issue
Block a user