feat: 添加Telegram Bot通知功能
Some checks failed
Deploy to Production / Build and Test (push) Has been cancelled
Deploy to Production / Deploy to Server (push) Has been cancelled

 新功能:
- 添加Telegram Bot通知支持
- 账目记录自动推送到Telegram
- 支持多个Bot配置管理
- 支持群组和个人通知

📊 数据库:
- 新增telegram_notification_configs表
- 存储Bot配置和通知类型

🔧 后端API:
- GET /api/telegram/notifications - 获取所有配置
- POST /api/telegram/notifications - 创建配置
- PUT /api/telegram/notifications/:id - 更新配置
- DELETE /api/telegram/notifications/:id - 删除配置
- POST /api/telegram/test - 测试Bot配置

💬 通知功能:
- 自动发送账目记录通知
- 包含交易类型、金额、分类、账户等信息
- 支持格式化显示(类型图标、状态标识)
- 配置创建时自动测试有效性

📝 文档:
- 添加完整的使用说明文档
- API接口说明和示例
- 常见问题解答
This commit is contained in:
你的用户名
2025-11-04 23:15:19 +08:00
parent faafcf926a
commit a4e4168c00
13 changed files with 801 additions and 0 deletions

View File

@@ -5,6 +5,8 @@ import {
} from '~/utils/finance-repository';
import { useResponseError, useResponseSuccess } from '~/utils/response';
import { notifyTransactionWebhook } from '~/utils/telegram-webhook';
import { notifyTransaction } from '~/utils/telegram-bot';
import db from '~/utils/sqlite';
const DEFAULT_CURRENCY = 'CNY';
const ALLOWED_STATUSES: TransactionStatus[] = [
@@ -52,9 +54,48 @@ export default defineEventHandler(async (event) => {
approvedAt: body.approvedAt ?? undefined,
});
// 发送Webhook通知保留原有功能
notifyTransactionWebhook(transaction, { action: 'created' }).catch((error) =>
console.error('[finance][transactions.post] webhook notify failed', error),
);
// 发送Telegram通知新功能
try {
// 获取分类和账户名称
let categoryName: string | undefined;
let accountName: string | undefined;
if (transaction.categoryId) {
const category = db
.prepare<{ name: string }>('SELECT name FROM finance_categories WHERE id = ?')
.get(transaction.categoryId);
categoryName = category?.name;
}
if (transaction.accountId) {
const account = db
.prepare<{ name: string }>('SELECT name FROM finance_accounts WHERE id = ?')
.get(transaction.accountId);
accountName = account?.name;
}
await notifyTransaction(
{
id: transaction.id,
type: transaction.type,
amount: transaction.amount,
currency: transaction.currency,
categoryName,
accountName,
transactionDate: transaction.transactionDate,
description: transaction.description || undefined,
status: transaction.status,
},
'created',
);
} catch (error) {
console.error('[finance][transactions.post] telegram notify failed', error);
}
return useResponseSuccess(transaction);
});

View File

@@ -0,0 +1,27 @@
import db from '~/utils/sqlite';
import { useResponseSuccess } from '~/utils/response';
export default defineEventHandler(() => {
const configs = db
.prepare<{ id: number; name: string; bot_token: string; chat_id: string; notification_types: string; is_enabled: number; created_at: string; updated_at: string }>(
`
SELECT id, name, bot_token, chat_id, notification_types, is_enabled, created_at, updated_at
FROM telegram_notification_configs
ORDER BY created_at DESC
`,
)
.all();
const result = configs.map((row) => ({
id: row.id,
name: row.name,
botToken: row.bot_token,
chatId: row.chat_id,
notificationTypes: JSON.parse(row.notification_types) as string[],
isEnabled: row.is_enabled === 1,
createdAt: row.created_at,
updatedAt: row.updated_at,
}));
return useResponseSuccess(result);
});

View File

@@ -0,0 +1,55 @@
import { readBody } from 'h3';
import db from '~/utils/sqlite';
import { useResponseError, useResponseSuccess } from '~/utils/response';
import { testTelegramConfig } from '~/utils/telegram-bot';
export default defineEventHandler(async (event) => {
const body = await readBody(event);
if (!body?.name || !body?.botToken || !body?.chatId) {
return useResponseError('缺少必填字段', -1);
}
const notificationTypes = Array.isArray(body.notificationTypes)
? body.notificationTypes
: ['transaction'];
// 测试配置是否有效
const testResult = await testTelegramConfig(body.botToken, body.chatId);
if (!testResult.success) {
return useResponseError(
`Telegram配置测试失败: ${testResult.error}`,
-1,
);
}
const now = new Date().toISOString();
const result = db
.prepare<unknown, [string, string, string, string, number, string, string]>(
`
INSERT INTO telegram_notification_configs (name, bot_token, chat_id, notification_types, is_enabled, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`,
)
.run(
body.name,
body.botToken,
body.chatId,
JSON.stringify(notificationTypes),
body.isEnabled !== false ? 1 : 0,
now,
now,
);
return useResponseSuccess({
id: result.lastInsertRowid,
name: body.name,
botToken: body.botToken,
chatId: body.chatId,
notificationTypes,
isEnabled: body.isEnabled !== false,
createdAt: now,
updatedAt: now,
});
});

View File

@@ -0,0 +1,19 @@
import db from '~/utils/sqlite';
import { useResponseError, useResponseSuccess } from '~/utils/response';
export default defineEventHandler((event) => {
const id = event.context.params?.id;
if (!id) {
return useResponseError('缺少ID参数', -1);
}
const result = db
.prepare('DELETE FROM telegram_notification_configs WHERE id = ?')
.run(id);
if (result.changes === 0) {
return useResponseError('配置不存在或删除失败', -1);
}
return useResponseSuccess({ id });
});

View File

@@ -0,0 +1,96 @@
import { readBody } from 'h3';
import db from '~/utils/sqlite';
import { useResponseError, useResponseSuccess } from '~/utils/response';
import { testTelegramConfig } from '~/utils/telegram-bot';
export default defineEventHandler(async (event) => {
const id = event.context.params?.id;
if (!id) {
return useResponseError('缺少ID参数', -1);
}
const body = await readBody(event);
// 如果更新了botToken或chatId需要测试配置
if (body.botToken || body.chatId) {
const existing = db
.prepare<{ bot_token: string; chat_id: string }>('SELECT bot_token, chat_id FROM telegram_notification_configs WHERE id = ?')
.get(id);
if (!existing) {
return useResponseError('配置不存在', -1);
}
const tokenToTest = body.botToken || existing.bot_token;
const chatIdToTest = body.chatId || existing.chat_id;
const testResult = await testTelegramConfig(tokenToTest, chatIdToTest);
if (!testResult.success) {
return useResponseError(
`Telegram配置测试失败: ${testResult.error}`,
-1,
);
}
}
const updates: string[] = [];
const values: (string | number)[] = [];
if (body.name !== undefined) {
updates.push('name = ?');
values.push(body.name);
}
if (body.botToken !== undefined) {
updates.push('bot_token = ?');
values.push(body.botToken);
}
if (body.chatId !== undefined) {
updates.push('chat_id = ?');
values.push(body.chatId);
}
if (body.notificationTypes !== undefined) {
updates.push('notification_types = ?');
values.push(JSON.stringify(body.notificationTypes));
}
if (body.isEnabled !== undefined) {
updates.push('is_enabled = ?');
values.push(body.isEnabled ? 1 : 0);
}
if (updates.length === 0) {
return useResponseError('没有可更新的字段', -1);
}
updates.push('updated_at = ?');
values.push(new Date().toISOString());
values.push(id);
db.prepare(`UPDATE telegram_notification_configs SET ${updates.join(', ')} WHERE id = ?`).run(
...values,
);
const updated = db
.prepare<{ id: number; name: string; bot_token: string; chat_id: string; notification_types: string; is_enabled: number; created_at: string; updated_at: string }>(
'SELECT * FROM telegram_notification_configs WHERE id = ?',
)
.get(id);
if (!updated) {
return useResponseError('更新失败', -1);
}
return useResponseSuccess({
id: updated.id,
name: updated.name,
botToken: updated.bot_token,
chatId: updated.chat_id,
notificationTypes: JSON.parse(updated.notification_types) as string[],
isEnabled: updated.is_enabled === 1,
createdAt: updated.created_at,
updatedAt: updated.updated_at,
});
});

View File

@@ -0,0 +1,19 @@
import { readBody } from 'h3';
import { useResponseError, useResponseSuccess } from '~/utils/response';
import { testTelegramConfig } from '~/utils/telegram-bot';
export default defineEventHandler(async (event) => {
const body = await readBody(event);
if (!body?.botToken || !body?.chatId) {
return useResponseError('缺少Bot Token或Chat ID', -1);
}
const result = await testTelegramConfig(body.botToken, body.chatId);
if (result.success) {
return useResponseSuccess({ message: '测试消息发送成功' });
} else {
return useResponseError(result.error || '测试失败', -1);
}
});

View File

@@ -157,4 +157,23 @@ database.exec(`
ON finance_media_messages (user_id);
`);
// Telegram通知配置表
database.exec(`
CREATE TABLE IF NOT EXISTS telegram_notification_configs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
bot_token TEXT NOT NULL,
chat_id TEXT NOT NULL,
notification_types TEXT NOT NULL,
is_enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`);
database.exec(`
CREATE INDEX IF NOT EXISTS idx_telegram_notification_configs_enabled
ON telegram_notification_configs (is_enabled);
`);
export default database;

View File

@@ -0,0 +1,229 @@
import db from './sqlite';
interface TelegramNotificationConfig {
id: number;
name: string;
botToken: string;
chatId: string;
notificationTypes: string[];
isEnabled: boolean;
}
interface TransactionNotificationData {
id: number;
type: string;
amount: number;
currency: string;
categoryName?: string;
accountName?: string;
transactionDate: string;
description?: string;
status: string;
}
/**
* 获取所有启用的Telegram通知配置
*/
export function getEnabledNotificationConfigs(
notificationType: string = 'transaction',
): TelegramNotificationConfig[] {
const rows = db
.prepare<{ id: number; name: string; bot_token: string; chat_id: string; notification_types: string; is_enabled: number }>(
`
SELECT id, name, bot_token, chat_id, notification_types, is_enabled
FROM telegram_notification_configs
WHERE is_enabled = 1
`,
)
.all();
return rows
.map((row) => ({
id: row.id,
name: row.name,
botToken: row.bot_token,
chatId: row.chat_id,
notificationTypes: JSON.parse(row.notification_types) as string[],
isEnabled: row.is_enabled === 1,
}))
.filter((config) => config.notificationTypes.includes(notificationType));
}
/**
* 格式化交易金额
*/
function formatAmount(amount: number, currency: string): string {
const formatted = amount.toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
return `${currency} ${formatted}`;
}
/**
* 格式化交易类型
*/
function formatTransactionType(type: string): string {
const typeMap: Record<string, string> = {
income: '💰 收入',
expense: '💸 支出',
transfer: '🔄 转账',
};
return typeMap[type] || type;
}
/**
* 格式化交易状态
*/
function formatTransactionStatus(status: string): string {
const statusMap: Record<string, string> = {
draft: '📝 草稿',
pending: '⏳ 待审核',
approved: '✅ 已批准',
rejected: '❌ 已拒绝',
paid: '💵 已支付',
};
return statusMap[status] || status;
}
/**
* 构建交易通知消息
*/
function buildTransactionMessage(
transaction: TransactionNotificationData,
action: string = 'created',
): string {
const actionMap: Record<string, string> = {
created: '📋 新增账目记录',
updated: '✏️ 更新账目记录',
deleted: '🗑️ 删除账目记录',
};
const lines: string[] = [
`${actionMap[action] || '📋 账目记录'}`,
'',
`类型:${formatTransactionType(transaction.type)}`,
`金额:${formatAmount(transaction.amount, transaction.currency)}`,
`日期:${transaction.transactionDate}`,
];
if (transaction.categoryName) {
lines.push(`分类:${transaction.categoryName}`);
}
if (transaction.accountName) {
lines.push(`账户:${transaction.accountName}`);
}
lines.push(`状态:${formatTransactionStatus(transaction.status)}`);
if (transaction.description) {
lines.push(``, `备注:${transaction.description}`);
}
lines.push(
``,
`🕐 记录时间:${new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}`,
);
return lines.join('\n');
}
/**
* 发送Telegram消息
*/
async function sendTelegramMessage(
botToken: string,
chatId: string,
message: string,
): Promise<boolean> {
try {
const url = `https://api.telegram.org/bot${botToken}/sendMessage`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
chat_id: chatId,
text: message,
parse_mode: 'HTML',
}),
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
console.error(
'[telegram-bot] Failed to send message:',
response.status,
error,
);
return false;
}
return true;
} catch (error) {
console.error('[telegram-bot] Error sending message:', error);
return false;
}
}
/**
* 通知交易记录
*/
export async function notifyTransaction(
transaction: TransactionNotificationData,
action: string = 'created',
): Promise<void> {
const configs = getEnabledNotificationConfigs('transaction');
if (configs.length === 0) {
console.log('[telegram-bot] No enabled notification configs found');
return;
}
const message = buildTransactionMessage(transaction, action);
const results = await Promise.allSettled(
configs.map((config) =>
sendTelegramMessage(config.botToken, config.chatId, message),
),
);
results.forEach((result, index) => {
if (result.status === 'fulfilled' && result.value) {
console.log(
`[telegram-bot] Sent notification via config: ${configs[index].name}`,
);
} else {
console.error(
`[telegram-bot] Failed to send notification via config: ${configs[index].name}`,
);
}
});
}
/**
* 测试Telegram Bot配置
*/
export async function testTelegramConfig(
botToken: string,
chatId: string,
): Promise<{ success: boolean; error?: string }> {
try {
const testMessage = `🤖 KT财务系统\n\n✅ Telegram通知配置测试成功\n\n🕐 ${new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}`;
const success = await sendTelegramMessage(botToken, chatId, testMessage);
if (success) {
return { success: true };
} else {
return { success: false, error: '发送消息失败请检查Bot Token和Chat ID' };
}
} catch (error: unknown) {
return {
success: false,
error: error instanceof Error ? error.message : '未知错误',
};
}
}