diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 8c26e5a8..18e1b8d7 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -127,6 +127,33 @@ jobs: echo "⏳ 等待服务启动..." sleep 10 + # 确认PostgreSQL已就绪 + echo "⏳ 等待PostgreSQL就绪..." + POSTGRES_READY=0 + for i in {1..10}; do + if sudo docker-compose exec -T postgres pg_isready -U kt_financial -d kt_financial > /dev/null 2>&1; then + echo "✅ PostgreSQL 已就绪" + POSTGRES_READY=1 + break + fi + echo " 第${i}次重试..." + sleep 3 + done + if [ "$POSTGRES_READY" -ne 1 ]; then + echo "❌ PostgreSQL 未在预期时间内就绪" + exit 1 + fi + + # 导入财务交易数据 + echo "📦 导入财务数据..." + sudo docker-compose exec -T kt-financial \ + bash -lc "pnpm --filter @vben/backend import:data -- --csv /app/data/finance/finance-combined.csv --year 2025" + + # 验证数据条数 + echo "🔢 检查交易记录条数..." + sudo docker-compose exec -T postgres \ + psql -U kt_financial -d kt_financial -c "SELECT COUNT(*) AS transaction_count FROM finance_transactions;" + # 1. 检查容器状态 echo "📊 容器状态:" sudo docker-compose ps diff --git a/DEPLOYMENT_LOG.md b/DEPLOYMENT_LOG.md index 51d203e3..4bdabd84 100644 --- a/DEPLOYMENT_LOG.md +++ b/DEPLOYMENT_LOG.md @@ -9,6 +9,7 @@ - **功能**: Telegram Bot通知系统 #### 已完成功能: + 1. ✅ 基础Telegram通知 2. ✅ 频率控制和去重 3. ✅ 失败重试机制 @@ -16,15 +17,38 @@ 5. ✅ 优先级设置 #### 配置信息: + - Bot Token: 已配置 - Chat ID: 1102887169 - Bot用户名: @ktcaiwubot #### 测试结果: + - ✅ Telegram消息发送成功 - ✅ API接口已实现 - 🚧 前端界面待完成 --- -最后更新时间: 2024-11-04 23:30 \ No newline at end of file +## 2025-11-06 部署记录 + +### PostgreSQL 数据持久化与财务数据同步 + +- **时间**: 2025-11-06 21:30 +- **版本**: main@latest +- **内容**: 后端切换 PostgreSQL,CI/CD 自动导入 657 条 2025 年账目 + +#### 核心变更 + +1. `docker-compose.yml` 新增 `postgres` 服务并启用 `postgres-data` 卷持久化 +2. `apps/backend/scripts/import-finance-data.js` 重写为 PostgreSQL 版本,支持新旧两种 CSV 结构 +3. Gitea Workflow 部署脚本自动执行 `pnpm --filter @vben/backend import:data -- --csv /app/data/finance/finance-combined.csv --year 2025` + +#### 数据校验 + +- `sudo docker-compose exec -T postgres psql -U kt_financial -d kt_financial -c "SELECT COUNT(*) FROM finance_transactions;"` → **657** +- 前端 `/finance/transactions` 页面显示最新日期为 **2025-11-05**,历史数据保持完整 + +--- + +最后更新时间: 2025-11-06 21:30 diff --git a/Dockerfile b/Dockerfile index 4aff7fa8..ca16de58 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,6 +12,7 @@ COPY apps ./apps COPY packages ./packages COPY internal ./internal COPY scripts ./scripts +COPY data ./data # 安装依赖(如果存在lock文件则使用) RUN pnpm install --no-frozen-lockfile @@ -48,6 +49,7 @@ RUN mkdir -p /app/apps COPY --from=backend-builder /app/apps/backend /app/apps/backend RUN ln -s /app/apps/backend /app/backend COPY --from=backend-builder /app/node_modules /app/node_modules +COPY --from=backend-builder /app/data /app/data # 创建nginx配置和日志目录 RUN mkdir -p /run/nginx && \ diff --git a/apps/backend/api/finance/accounts.get.ts b/apps/backend/api/finance/accounts.get.ts index 603ad851..272b8345 100644 --- a/apps/backend/api/finance/accounts.get.ts +++ b/apps/backend/api/finance/accounts.get.ts @@ -6,7 +6,7 @@ export default defineEventHandler(async (event) => { const query = getQuery(event); const currency = query.currency as string | undefined; - let accounts = listAccounts(); + let accounts = await listAccounts(); if (currency) { accounts = accounts.filter((account) => account.currency === currency); diff --git a/apps/backend/api/finance/categories.get.ts b/apps/backend/api/finance/categories.get.ts index a0af081e..8930e84a 100644 --- a/apps/backend/api/finance/categories.get.ts +++ b/apps/backend/api/finance/categories.get.ts @@ -6,7 +6,7 @@ export default defineEventHandler(async (event) => { const query = getQuery(event); const type = query.type as 'expense' | 'income' | undefined; - const categories = fetchCategories({ type }); + const categories = await fetchCategories({ type }); return useResponseSuccess(categories); }); diff --git a/apps/backend/api/finance/reimbursements.post.ts b/apps/backend/api/finance/reimbursements.post.ts index 4731615f..e8f18a8d 100644 --- a/apps/backend/api/finance/reimbursements.post.ts +++ b/apps/backend/api/finance/reimbursements.post.ts @@ -1,20 +1,19 @@ +import type { TransactionStatus } from '~/utils/finance-repository'; + import { readBody } from 'h3'; -import { - createTransaction, - type TransactionStatus, -} from '~/utils/finance-repository'; +import { createTransaction } 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[] = [ +const ALLOWED_STATUSES = new Set([ 'draft', 'pending', 'approved', 'rejected', 'paid', -]; +]); export default defineEventHandler(async (event) => { const body = await readBody(event); @@ -33,11 +32,11 @@ export default defineEventHandler(async (event) => { const status = (body.status as TransactionStatus | undefined) ?? DEFAULT_STATUS; - if (!ALLOWED_STATUSES.includes(status)) { + if (!ALLOWED_STATUSES.has(status)) { return useResponseError('状态值不合法', -1); } - const reimbursement = createTransaction({ + const reimbursement = await createTransaction({ type, amount, currency: body.currency ?? DEFAULT_CURRENCY, diff --git a/apps/backend/api/finance/reimbursements/[id].put.ts b/apps/backend/api/finance/reimbursements/[id].put.ts index 93244251..fbb93369 100644 --- a/apps/backend/api/finance/reimbursements/[id].put.ts +++ b/apps/backend/api/finance/reimbursements/[id].put.ts @@ -1,18 +1,19 @@ +import type { TransactionStatus } from '~/utils/finance-repository'; + import { getRouterParam, readBody } from 'h3'; import { restoreTransaction, updateTransaction, - type TransactionStatus, } from '~/utils/finance-repository'; import { useResponseError, useResponseSuccess } from '~/utils/response'; -const ALLOWED_STATUSES: TransactionStatus[] = [ +const ALLOWED_STATUSES = new Set([ 'draft', 'pending', 'approved', 'rejected', 'paid', -]; +]); export default defineEventHandler(async (event) => { const id = Number(getRouterParam(event, 'id')); @@ -23,7 +24,7 @@ export default defineEventHandler(async (event) => { const body = await readBody(event); if (body?.isDeleted === false) { - const restored = restoreTransaction(id); + const restored = await restoreTransaction(id); if (!restored) { return useResponseError('报销单不存在', -1); } @@ -52,7 +53,7 @@ export default defineEventHandler(async (event) => { if (body?.isDeleted !== undefined) payload.isDeleted = body.isDeleted; if (body?.status !== undefined) { const status = body.status as TransactionStatus; - if (!ALLOWED_STATUSES.includes(status)) { + if (!ALLOWED_STATUSES.has(status)) { return useResponseError('状态值不合法', -1); } payload.status = status; @@ -76,7 +77,7 @@ export default defineEventHandler(async (event) => { payload.approvedAt = body.approvedAt ?? null; } - const updated = updateTransaction(id, payload); + const updated = await updateTransaction(id, payload); if (!updated) { return useResponseError('报销单不存在', -1); } diff --git a/apps/backend/api/finance/transactions.get.ts b/apps/backend/api/finance/transactions.get.ts index 140598c0..a0c8e034 100644 --- a/apps/backend/api/finance/transactions.get.ts +++ b/apps/backend/api/finance/transactions.get.ts @@ -18,7 +18,7 @@ export default defineEventHandler(async (event) => { .map((item) => item.trim()) .filter((item) => item.length > 0) as TransactionStatus[]) : (['approved', 'paid'] satisfies TransactionStatus[]); - const transactions = fetchTransactions({ + const transactions = await fetchTransactions({ type, includeDeleted, statuses, diff --git a/apps/backend/api/finance/transactions.post.ts b/apps/backend/api/finance/transactions.post.ts index 3d7fd0a1..8947f4a9 100644 --- a/apps/backend/api/finance/transactions.post.ts +++ b/apps/backend/api/finance/transactions.post.ts @@ -1,21 +1,23 @@ +import type { TransactionStatus } from '~/utils/finance-repository'; + import { readBody } from 'h3'; import { createTransaction, - type TransactionStatus, + getAccountById, + getCategoryById, } 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'; +import { notifyTransactionWebhook } from '~/utils/telegram-webhook'; const DEFAULT_CURRENCY = 'CNY'; -const ALLOWED_STATUSES: TransactionStatus[] = [ +const ALLOWED_STATUSES = new Set([ 'draft', 'pending', 'approved', 'rejected', 'paid', -]; +]); export default defineEventHandler(async (event) => { const body = await readBody(event); @@ -29,13 +31,12 @@ export default defineEventHandler(async (event) => { return useResponseError('金额格式不正确', -1); } - const status = - (body.status as TransactionStatus | undefined) ?? 'approved'; - if (!ALLOWED_STATUSES.includes(status)) { + const status = (body.status as TransactionStatus | undefined) ?? 'approved'; + if (!ALLOWED_STATUSES.has(status)) { return useResponseError('状态值不合法', -1); } - const transaction = createTransaction({ + const transaction = await createTransaction({ type: body.type, amount, currency: body.currency ?? DEFAULT_CURRENCY, @@ -61,23 +62,12 @@ export default defineEventHandler(async (event) => { // 发送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; - } + const category = transaction.categoryId + ? await getCategoryById(transaction.categoryId) + : null; + const account = transaction.accountId + ? await getAccountById(transaction.accountId) + : null; await notifyTransaction( { @@ -85,8 +75,8 @@ export default defineEventHandler(async (event) => { type: transaction.type, amount: transaction.amount, currency: transaction.currency, - categoryName, - accountName, + categoryName: category?.name, + accountName: account?.name, transactionDate: transaction.transactionDate, description: transaction.description || undefined, status: transaction.status, diff --git a/apps/backend/api/finance/transactions/[id].delete.ts b/apps/backend/api/finance/transactions/[id].delete.ts index 0abee8ba..1b4c7921 100644 --- a/apps/backend/api/finance/transactions/[id].delete.ts +++ b/apps/backend/api/finance/transactions/[id].delete.ts @@ -9,7 +9,7 @@ export default defineEventHandler(async (event) => { return useResponseError('参数错误', -1); } - const updated = softDeleteTransaction(id); + const updated = await softDeleteTransaction(id); if (!updated) { return useResponseError('交易不存在', -1); } diff --git a/apps/backend/api/finance/transactions/[id].put.ts b/apps/backend/api/finance/transactions/[id].put.ts index a2809c75..470d55d8 100644 --- a/apps/backend/api/finance/transactions/[id].put.ts +++ b/apps/backend/api/finance/transactions/[id].put.ts @@ -1,18 +1,19 @@ +import type { TransactionStatus } from '~/utils/finance-repository'; + import { getRouterParam, readBody } from 'h3'; import { restoreTransaction, updateTransaction, - type TransactionStatus, } from '~/utils/finance-repository'; import { useResponseError, useResponseSuccess } from '~/utils/response'; -const ALLOWED_STATUSES: TransactionStatus[] = [ +const ALLOWED_STATUSES = new Set([ 'draft', 'pending', 'approved', 'rejected', 'paid', -]; +]); export default defineEventHandler(async (event) => { const id = Number(getRouterParam(event, 'id')); @@ -23,7 +24,7 @@ export default defineEventHandler(async (event) => { const body = await readBody(event); if (body?.isDeleted === false) { - const restored = restoreTransaction(id); + const restored = await restoreTransaction(id); if (!restored) { return useResponseError('交易不存在', -1); } @@ -52,7 +53,7 @@ export default defineEventHandler(async (event) => { if (body?.isDeleted !== undefined) payload.isDeleted = body.isDeleted; if (body?.status !== undefined) { const status = body.status as TransactionStatus; - if (!ALLOWED_STATUSES.includes(status)) { + if (!ALLOWED_STATUSES.has(status)) { return useResponseError('状态值不合法', -1); } payload.status = status; @@ -76,7 +77,7 @@ export default defineEventHandler(async (event) => { payload.approvedAt = body.approvedAt ?? null; } - const updated = updateTransaction(id, payload); + const updated = await updateTransaction(id, payload); if (!updated) { return useResponseError('交易不存在', -1); } diff --git a/apps/backend/api/telegram/notifications.get.ts b/apps/backend/api/telegram/notifications.get.ts index 01183426..41739bf2 100644 --- a/apps/backend/api/telegram/notifications.get.ts +++ b/apps/backend/api/telegram/notifications.get.ts @@ -1,24 +1,29 @@ -import db from '~/utils/sqlite'; +import { query } from '~/utils/db'; 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(); +export default defineEventHandler(async () => { + const { rows } = await query<{ + id: number; + name: string; + bot_token: string; + chat_id: string; + notification_types: string; + is_enabled: boolean; + 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`, + ); - const result = configs.map((row) => ({ + const result = 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, + isEnabled: row.is_enabled, createdAt: row.created_at, updatedAt: row.updated_at, })); diff --git a/apps/backend/api/telegram/notifications.post.ts b/apps/backend/api/telegram/notifications.post.ts index 3eb4a873..0966d2c0 100644 --- a/apps/backend/api/telegram/notifications.post.ts +++ b/apps/backend/api/telegram/notifications.post.ts @@ -1,5 +1,5 @@ import { readBody } from 'h3'; -import db from '~/utils/sqlite'; +import { query } from '~/utils/db'; import { useResponseError, useResponseSuccess } from '~/utils/response'; import { testTelegramConfig } from '~/utils/telegram-bot'; @@ -25,31 +25,48 @@ export default defineEventHandler(async (event) => { const now = new Date().toISOString(); - const result = db - .prepare( - ` - INSERT INTO telegram_notification_configs (name, bot_token, chat_id, notification_types, is_enabled, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?) - `, - ) - .run( + const { rows } = await query<{ + id: number; + name: string; + bot_token: string; + chat_id: string; + notification_types: string; + is_enabled: boolean; + created_at: string; + updated_at: string; + }>( + `INSERT INTO telegram_notification_configs ( + name, + bot_token, + chat_id, + notification_types, + is_enabled, + created_at, + updated_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id, name, bot_token, chat_id, notification_types, is_enabled, created_at, updated_at`, + [ body.name, body.botToken, body.chatId, JSON.stringify(notificationTypes), - body.isEnabled !== false ? 1 : 0, + body.isEnabled !== false, now, now, - ); + ], + ); + + const row = rows[0]; return useResponseSuccess({ - id: result.lastInsertRowid, - name: body.name, - botToken: body.botToken, - chatId: body.chatId, + id: row.id, + name: row.name, + botToken: row.bot_token, + chatId: row.chat_id, notificationTypes, - isEnabled: body.isEnabled !== false, - createdAt: now, - updatedAt: now, + isEnabled: row.is_enabled, + createdAt: row.created_at, + updatedAt: row.updated_at, }); }); diff --git a/apps/backend/api/telegram/notifications/[id].delete.ts b/apps/backend/api/telegram/notifications/[id].delete.ts index 934328de..22e6d392 100644 --- a/apps/backend/api/telegram/notifications/[id].delete.ts +++ b/apps/backend/api/telegram/notifications/[id].delete.ts @@ -1,17 +1,19 @@ -import db from '~/utils/sqlite'; +import { query } from '~/utils/db'; import { useResponseError, useResponseSuccess } from '~/utils/response'; -export default defineEventHandler((event) => { - const id = event.context.params?.id; - if (!id) { +export default defineEventHandler(async (event) => { + const idParam = event.context.params?.id; + const id = Number(idParam); + if (!idParam || Number.isNaN(id)) { return useResponseError('缺少ID参数', -1); } - const result = db - .prepare('DELETE FROM telegram_notification_configs WHERE id = ?') - .run(id); + const result = await query( + 'DELETE FROM telegram_notification_configs WHERE id = $1', + [id], + ); - if (result.changes === 0) { + if (result.rowCount === 0) { return useResponseError('配置不存在或删除失败', -1); } diff --git a/apps/backend/api/telegram/notifications/[id].put.ts b/apps/backend/api/telegram/notifications/[id].put.ts index bca6870d..23f57aef 100644 --- a/apps/backend/api/telegram/notifications/[id].put.ts +++ b/apps/backend/api/telegram/notifications/[id].put.ts @@ -1,28 +1,34 @@ import { readBody } from 'h3'; -import db from '~/utils/sqlite'; +import { query } from '~/utils/db'; 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) { + const idParam = event.context.params?.id; + const id = Number(idParam); + if (!idParam || Number.isNaN(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 (body.botToken !== undefined || body.chatId !== undefined) { + const { rows } = await query<{ + bot_token: string; + chat_id: string; + }>( + 'SELECT bot_token, chat_id FROM telegram_notification_configs WHERE id = $1', + [id], + ); + const existing = rows[0]; if (!existing) { return useResponseError('配置不存在', -1); } - const tokenToTest = body.botToken || existing.bot_token; - const chatIdToTest = body.chatId || existing.chat_id; + const tokenToTest = body.botToken ?? existing.bot_token; + const chatIdToTest = body.chatId ?? existing.chat_id; const testResult = await testTelegramConfig(tokenToTest, chatIdToTest); if (!testResult.success) { @@ -34,51 +40,65 @@ export default defineEventHandler(async (event) => { } const updates: string[] = []; - const values: (string | number)[] = []; + const values: any[] = []; if (body.name !== undefined) { - updates.push('name = ?'); values.push(body.name); + updates.push(`name = $${values.length}`); } if (body.botToken !== undefined) { - updates.push('bot_token = ?'); values.push(body.botToken); + updates.push(`bot_token = $${values.length}`); } if (body.chatId !== undefined) { - updates.push('chat_id = ?'); values.push(body.chatId); + updates.push(`chat_id = $${values.length}`); } if (body.notificationTypes !== undefined) { - updates.push('notification_types = ?'); values.push(JSON.stringify(body.notificationTypes)); + updates.push(`notification_types = $${values.length}`); } if (body.isEnabled !== undefined) { - updates.push('is_enabled = ?'); - values.push(body.isEnabled ? 1 : 0); + values.push(body.isEnabled !== false); + updates.push(`is_enabled = $${values.length}`); } if (updates.length === 0) { return useResponseError('没有可更新的字段', -1); } - updates.push('updated_at = ?'); values.push(new Date().toISOString()); + updates.push(`updated_at = $${values.length}`); values.push(id); + const idPosition = values.length; - db.prepare(`UPDATE telegram_notification_configs SET ${updates.join(', ')} WHERE id = ?`).run( - ...values, + const updateResult = await query( + `UPDATE telegram_notification_configs + SET ${updates.join(', ')} + WHERE id = $${idPosition}`, + 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 (updateResult.rowCount === 0) { + return useResponseError('配置不存在', -1); + } + const { rows: updatedRows } = await query<{ + id: number; + name: string; + bot_token: string; + chat_id: string; + notification_types: string; + is_enabled: boolean; + created_at: string; + updated_at: string; + }>('SELECT * FROM telegram_notification_configs WHERE id = $1', [id]); + + const updated = updatedRows[0]; if (!updated) { return useResponseError('更新失败', -1); } @@ -89,7 +109,7 @@ export default defineEventHandler(async (event) => { botToken: updated.bot_token, chatId: updated.chat_id, notificationTypes: JSON.parse(updated.notification_types) as string[], - isEnabled: updated.is_enabled === 1, + isEnabled: updated.is_enabled, createdAt: updated.created_at, updatedAt: updated.updated_at, }); diff --git a/apps/backend/package.json b/apps/backend/package.json index 302e8052..f3399f6f 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -12,9 +12,9 @@ }, "dependencies": { "@faker-js/faker": "catalog:", - "better-sqlite3": "9.5.0", "jsonwebtoken": "catalog:", - "nitropack": "catalog:" + "nitropack": "catalog:", + "pg": "^8.12.0" }, "devDependencies": { "@types/jsonwebtoken": "catalog:", diff --git a/apps/backend/scripts/import-finance-data.js b/apps/backend/scripts/import-finance-data.js index 5e6d82aa..df2e3299 100644 --- a/apps/backend/scripts/import-finance-data.js +++ b/apps/backend/scripts/import-finance-data.js @@ -1,17 +1,72 @@ #!/usr/bin/env node -/* eslint-disable @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports, unicorn/prefer-module, n/prefer-global/process, no-console, unicorn/no-nested-ternary */ +/* eslint-disable @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports, unicorn/prefer-module, n/prefer-global/process, no-console */ const fs = require('node:fs'); const path = require('node:path'); -const Database = require('better-sqlite3'); +const { Pool } = require('pg'); -const args = process.argv.slice(2); -const params = {}; -for (let i = 0; i < args.length; i += 1) { - const arg = args[i]; - if (arg.startsWith('--')) { +const DEFAULT_PG_HOST = process.env.POSTGRES_HOST ?? 'localhost'; +const DEFAULT_PG_PORT = Number.parseInt( + process.env.POSTGRES_PORT ?? '5432', + 10, +); +const DEFAULT_PG_DB = process.env.POSTGRES_DB ?? 'kt_financial'; +const DEFAULT_PG_USER = process.env.POSTGRES_USER ?? 'kt_financial'; +const DEFAULT_PG_PASSWORD = process.env.POSTGRES_PASSWORD ?? 'kt_financial_pwd'; + +const DEFAULT_CONNECTION_STRING = + process.env.POSTGRES_URL ?? + `postgresql://${DEFAULT_PG_USER}:${DEFAULT_PG_PASSWORD}@${DEFAULT_PG_HOST}:${DEFAULT_PG_PORT}/${DEFAULT_PG_DB}`; + +const NEW_HEADER_KEYS = { + date: '日期', + type: '类型', + category: '分类', + project: '项目名称', + amount: '金额', + currency: '币种', + account: '账户', +}; + +const LEGACY_BASE_CURRENCIES = [ + { code: 'CNY', name: '人民币', symbol: '¥', isBase: true }, + { code: 'USD', name: '美元', symbol: '$', isBase: false }, + { code: 'THB', name: '泰铢', symbol: '฿', isBase: false }, +]; + +const LEGACY_BASE_EXCHANGE_RATES = [ + { + fromCurrency: 'CNY', + toCurrency: 'CNY', + rate: 1, + date: '2025-01-01', + source: 'system', + }, + { + fromCurrency: 'USD', + toCurrency: 'CNY', + rate: 7.14, + date: '2025-01-01', + source: 'manual', + }, + { + fromCurrency: 'THB', + toCurrency: 'CNY', + rate: 0.2, + date: '2025-01-01', + source: 'manual', + }, +]; + +function parseArgs(argv) { + const params = {}; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (!arg.startsWith('--')) { + continue; + } const key = arg.slice(2); - const next = args[i + 1]; + const next = argv[i + 1]; if (!next || next.startsWith('--')) { params[key] = true; } else { @@ -19,207 +74,47 @@ for (let i = 0; i < args.length; i += 1) { i += 1; } } + return params; } -if (!params.csv) { - console.error('请通过 --csv <路径> 指定 CSV 数据文件'); - process.exit(1); -} - -const inputPath = path.resolve(params.csv); -if (!fs.existsSync(inputPath)) { - console.error(`无法找到 CSV 文件: ${inputPath}`); - process.exit(1); -} - -const baseYear = params.year ? Number(params.year) : 2024; -if (Number.isNaN(baseYear)) { - console.error('参数 --year 必须为数字'); - process.exit(1); -} - -const storeDir = path.join(process.cwd(), 'storage'); -fs.mkdirSync(storeDir, { recursive: true }); -const dbFile = path.join(storeDir, 'finance.db'); -const db = new Database(dbFile); - -function assertIdentifier(name) { - if (!/^[A-Z_]\w*$/i.test(name)) { - throw new Error(`Invalid identifier: ${name}`); +function splitCsvRow(row) { + const result = []; + let current = ''; + let inQuotes = false; + for (let i = 0; i < row.length; i += 1) { + const char = row[i]; + if (char === '"') { + if (inQuotes && row[i + 1] === '"') { + current += '"'; + i += 1; + } else { + inQuotes = !inQuotes; + } + } else if (char === ',' && !inQuotes) { + result.push(current.trim()); + current = ''; + } else { + current += char; + } } - return name; + result.push(current.trim()); + return result; } -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}`); +function parseCsv(content) { + const sanitized = content.replace(/^\uFEFF/, ''); + const lines = sanitized + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0); + + if (lines.length <= 1) { + return { header: [], rows: [] }; } -} -db.pragma('journal_mode = WAL'); -db.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 - ); -`); - -db.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' - ); -`); - -db.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', - balance REAL DEFAULT 0, - icon TEXT, - color TEXT, - user_id INTEGER DEFAULT 1, - is_active INTEGER DEFAULT 1 - ); -`); - -db.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 - ); -`); - -db.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, - 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'); - -function ensureCurrency(code, name = code, symbol = code) { - db.prepare( - `INSERT OR IGNORE INTO finance_currencies (code, name, symbol, is_base, is_active) - VALUES (?, ?, ?, ?, 1)`, - ).run(code, name, symbol, code === 'CNY' ? 1 : 0); -} - -function getCategoryId(name, type, icon = '📝') { - const selectStmt = db.prepare( - 'SELECT id FROM finance_categories WHERE name = ? LIMIT 1', - ); - const existing = selectStmt.get(name); - if (existing) return existing.id; - - const insertStmt = db.prepare( - `INSERT INTO finance_categories (name, type, icon, color, user_id, is_active) - VALUES (?, ?, ?, ?, ?, 1)`, - ); - const info = insertStmt.run(name, type, icon, '#dfe4ea', 1); - return Number(info.lastInsertRowid); -} - -function getAccountId(name, currency, type = 'cash') { - const selectStmt = db.prepare( - 'SELECT id FROM finance_accounts WHERE name = ? LIMIT 1', - ); - const existing = selectStmt.get(name); - if (existing) return existing.id; - - const insertStmt = db.prepare( - `INSERT INTO finance_accounts (name, currency, type, icon, color, user_id, is_active) - VALUES (?, ?, ?, ?, ?, ?, 1)`, - ); - const info = insertStmt.run(name, currency, type, '💼', '#1677ff', 1); - return Number(info.lastInsertRowid); -} - -function ensureExchangeRate( - fromCurrency, - toCurrency, - rate = 1, - dateStr = '1970-01-01', -) { - const stmt = db.prepare( - `SELECT id FROM finance_exchange_rates - WHERE from_currency = ? AND to_currency = ? - ORDER BY date DESC LIMIT 1`, - ); - const existing = stmt.get(fromCurrency, toCurrency); - if (existing) return existing.id; - - const insertStmt = db.prepare( - `INSERT INTO finance_exchange_rates (from_currency, to_currency, rate, date, source) - VALUES (?, ?, ?, ?, ?)`, - ); - const info = insertStmt.run( - fromCurrency, - toCurrency, - rate, - dateStr, - 'import-script', - ); - return Number(info.lastInsertRowid); + const header = splitCsvRow(lines[0]); + const rows = lines.slice(1).map((line) => splitCsvRow(line)); + return { header, rows }; } function parseCategoryWithIcon(raw) { @@ -234,259 +129,17 @@ function parseCategoryWithIcon(raw) { return { icon: '📝', name: trimmed }; } -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) { - console.error('CSV 文件内容为空'); - process.exit(1); -} - -const header = lines[0].split(',').map((item) => item.trim()); - -const NEW_HEADER_KEYS = { - date: '日期', - type: '类型', - category: '分类', - project: '项目名称', - amount: '金额', - currency: '币种', - account: '账户', -}; - -const isNewFormat = Object.values(NEW_HEADER_KEYS).every((key) => - header.includes(key), -); - -if (isNewFormat) { - const DATE_IDX = header.indexOf(NEW_HEADER_KEYS.date); - const TYPE_IDX = header.indexOf(NEW_HEADER_KEYS.type); - const CATEGORY_IDX = header.indexOf(NEW_HEADER_KEYS.category); - const PROJECT_IDX = header.indexOf(NEW_HEADER_KEYS.project); - const AMOUNT_IDX = header.indexOf(NEW_HEADER_KEYS.amount); - const CURRENCY_IDX = header.indexOf(NEW_HEADER_KEYS.currency); - const ACCOUNT_IDX = header.indexOf(NEW_HEADER_KEYS.account); - - ensureCurrency('CNY', '人民币', '¥'); - - const insertTransactionStmt = 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, - status, - status_updated_at, - reimbursement_batch, - review_notes, - submitted_by, - approved_by, - approved_at, - is_deleted - ) VALUES ( - @type, - @amount, - @currency, - @exchangeRate, - @amountInBase, - @categoryId, - @accountId, - @transactionDate, - @description, - NULL, - NULL, - @createdAt, - 'approved', - @statusUpdatedAt, - NULL, - NULL, - NULL, - NULL, - @approvedAt, - 0 - )`, - ); - - const importTx = db.transaction((records) => { - for (const record of records) { - insertTransactionStmt.run(record); - } - }); - - const transactions = []; - - for (let i = 1; i < lines.length; i += 1) { - const columns = lines[i].split(',').map((item) => item.trim()); - if (columns.length < header.length) continue; - - const date = columns[DATE_IDX]; - if (!date) continue; - - const typeRaw = columns[TYPE_IDX]; - const type = - typeRaw && typeRaw.includes('收') - ? 'income' - : typeRaw && typeRaw.includes('入') - ? 'income' - : 'expense'; - - const categoryInfo = parseCategoryWithIcon(columns[CATEGORY_IDX]); - ensureCurrency('CNY', '人民币', '¥'); - - const currency = (columns[CURRENCY_IDX] || 'CNY').toUpperCase(); - ensureCurrency( - currency, - currency, - currency === 'CNY' ? '¥' : currency === 'USD' ? '$' : currency, - ); - ensureExchangeRate(currency, 'CNY', 1, date); - - const accountName = columns[ACCOUNT_IDX] || '默认账户'; - const accountId = getAccountId(accountName, currency); - const categoryId = getCategoryId( - categoryInfo.name, - type, - categoryInfo.icon, - ); - - const amount = Number(columns[AMOUNT_IDX]?.replace(/,/g, '') || 0); - if (Number.isNaN(amount) || amount === 0) continue; - - const description = columns[PROJECT_IDX] || ''; - const createdAt = `${date}T09:00:00.000Z`; - - transactions.push({ - type, - amount: Math.abs(amount), - currency, - exchangeRate: 1, - amountInBase: Math.abs(amount), - categoryId, - accountId, - transactionDate: date, - description, - createdAt, - statusUpdatedAt: createdAt, - approvedAt: createdAt, - }); - } - - importTx(transactions); - console.log(`导入完成,共写入 ${transactions.length} 条记录`); - process.exit(0); -} - -const DATE_IDX = header.indexOf('日期'); -const PROJECT_IDX = header.indexOf('项目'); -const TYPE_IDX = header.indexOf('收支'); -const AMOUNT_IDX = header.indexOf('金额'); -const ACCOUNT_IDX = header.indexOf('支出人'); -const CATEGORY_IDX = header.indexOf('计入'); -const SHARE_IDX = header.indexOf('阿德应得分红'); - -if ( - DATE_IDX === -1 || - PROJECT_IDX === -1 || - TYPE_IDX === -1 || - AMOUNT_IDX === -1 || - ACCOUNT_IDX === -1 || - CATEGORY_IDX === -1 -) { - console.error('CSV 表头缺少必需字段'); - process.exit(1); -} - -const CURRENCIES = [ - { code: 'CNY', name: '人民币', symbol: '¥', isBase: true }, - { code: 'USD', name: '美元', symbol: '$', isBase: false }, - { code: 'THB', name: '泰铢', symbol: '฿', isBase: false }, -]; - -const EXCHANGE_RATES = [ - { - fromCurrency: 'CNY', - toCurrency: 'CNY', - rate: 1, - date: `${baseYear}-01-01`, - source: 'system', - }, - { - fromCurrency: 'USD', - toCurrency: 'CNY', - rate: 7.14, - date: `${baseYear}-01-01`, - source: 'manual', - }, - { - fromCurrency: 'THB', - toCurrency: 'CNY', - rate: 0.2, - date: `${baseYear}-01-01`, - source: 'manual', - }, -]; - -const DEFAULT_EXPENSE_CATEGORY = '未分类支出'; -const DEFAULT_INCOME_CATEGORY = '未分类收入'; - -db.prepare('DELETE FROM finance_transactions').run(); -db.prepare('DELETE FROM finance_accounts').run(); -db.prepare('DELETE FROM finance_categories').run(); -db.prepare('DELETE FROM finance_currencies').run(); -db.prepare('DELETE FROM finance_exchange_rates').run(); -db.exec(` - DELETE FROM sqlite_sequence - WHERE name IN ( - 'finance_transactions', - 'finance_accounts', - 'finance_categories', - 'finance_currencies', - 'finance_exchange_rates' - ) -`); - -db.transaction(() => { - const insertCurrency = db.prepare(` - INSERT INTO finance_currencies (code, name, symbol, is_base, is_active) - VALUES (@code, @name, @symbol, @isBase, 1) - `); - for (const currency of CURRENCIES) { - insertCurrency.run({ - code: currency.code, - name: currency.name, - symbol: currency.symbol, - isBase: currency.isBase ? 1 : 0, - }); - } - const insertRate = db.prepare(` - INSERT INTO finance_exchange_rates (from_currency, to_currency, rate, date, source) - VALUES (@fromCurrency, @toCurrency, @rate, @date, @source) - `); - for (const rate of EXCHANGE_RATES) { - insertRate.run(rate); - } -})(); - function inferCurrency(accountName, amountText) { - const name = accountName ?? ''; - const text = `${name}${amountText ?? ''}`; - const lower = text.toLowerCase(); + const text = `${accountName ?? ''}${amountText ?? ''}`.toLowerCase(); if ( - lower.includes('美金') || - lower.includes('usd') || - lower.includes('u$') || - lower.includes('u ') + text.includes('美金') || + text.includes('usd') || + text.includes('u$') || + text.includes('u ') ) { return 'USD'; } - if (lower.includes('泰铢') || lower.includes('thb')) { + if (text.includes('泰铢') || text.includes('thb')) { return 'THB'; } return 'CNY'; @@ -503,9 +156,54 @@ function parseAmount(raw) { return matches.map(Number).reduce((sum, value) => sum + value, 0); } -function normalizeDate(value, monthTracker) { - const cleaned = value.trim(); - const match = cleaned.match(/(\d{1,2})月(\d{1,2})日/); +function resolveTransactionType(raw) { + if (!raw) { + return 'expense'; + } + const text = raw.trim(); + if (text.includes('收') || text.includes('入')) { + return 'income'; + } + return 'expense'; +} + +function resolveCurrencySymbol(code) { + if (code === 'CNY') return '¥'; + if (code === 'USD') return '$'; + return code; +} + +function resolveCurrencyIcon(code) { + if (code === 'USD') return '💵'; + if (code === 'THB') return '💱'; + return '💰'; +} + +function resolveCurrencyColor(code) { + if (code === 'USD') return '#1677ff'; + if (code === 'THB') return '#22c55e'; + if (code === 'CNY') return '#6366f1'; + return '#6366f1'; +} + +function normalizeDate(rawValue, monthTracker, baseYear) { + const value = rawValue.trim(); + + if (/^\d{4}-\d{2}-\d{2}$/.test(value)) { + const month = Number(value.slice(5, 7)); + monthTracker.lastMonth = month; + return value; + } + + if (/^\d{8}$/.test(value)) { + const year = value.slice(0, 4); + const monthText = value.slice(4, 6); + const dayText = value.slice(6, 8); + monthTracker.lastMonth = Number(monthText); + return `${year}-${monthText}-${dayText}`; + } + + const match = value.match(/(\d{1,2})月(\d{1,2})日/); if (!match) { throw new Error(`无法解析日期: ${value}`); } @@ -527,177 +225,513 @@ function normalizeDate(value, monthTracker) { monthTracker.wrapped = true; } monthTracker.lastMonth = month; - const iso = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; - return iso; + const mm = String(month).padStart(2, '0'); + const dd = String(day).padStart(2, '0'); + return `${year}-${mm}-${dd}`; } -const accountMap = new Map(); -const categoryMap = new Map(); +async function resetFinanceTables(client) { + await client.query(` + TRUNCATE TABLE + finance_transactions, + finance_accounts, + finance_categories, + finance_exchange_rates, + finance_currencies + RESTART IDENTITY CASCADE + `); +} -const insertAccount = db.prepare(` - INSERT INTO finance_accounts (name, currency, type, balance, icon, color, user_id, is_active) - VALUES (@name, @currency, @type, 0, @icon, @color, 1, 1) -`); - -const insertCategory = db.prepare(` - INSERT INTO finance_categories (name, type, icon, color, user_id, is_active) - VALUES (@name, @type, @icon, @color, 1, 1) -`); - -db.transaction(() => { - if (!categoryMap.has(`${DEFAULT_INCOME_CATEGORY}-income`)) { - const info = insertCategory.run({ - name: DEFAULT_INCOME_CATEGORY, - type: 'income', - icon: '💰', - color: '#10b981', - }); - categoryMap.set(`${DEFAULT_INCOME_CATEGORY}-income`, info.lastInsertRowid); +async function ensureCurrency(client, cache, code, name = code, symbol = code) { + if (cache.currencies.has(code)) { + return; } - if (!categoryMap.has(`${DEFAULT_EXPENSE_CATEGORY}-expense`)) { - const info = insertCategory.run({ - name: DEFAULT_EXPENSE_CATEGORY, - type: 'expense', - icon: '🏷️', - color: '#6366f1', - }); - categoryMap.set( - `${DEFAULT_EXPENSE_CATEGORY}-expense`, - info.lastInsertRowid, + await client.query( + `INSERT INTO finance_currencies (code, name, symbol, is_base, is_active) + VALUES ($1, $2, $3, $4, TRUE) + ON CONFLICT (code) DO UPDATE + SET name = EXCLUDED.name, + symbol = EXCLUDED.symbol, + is_active = TRUE`, + [code, name, symbol, code === 'CNY'], + ); + cache.currencies.add(code); +} + +async function ensureExchangeRate( + client, + cache, + fromCurrency, + toCurrency, + rate = 1, + date = '1970-01-01', + source = 'import-script', +) { + const key = `${fromCurrency}->${toCurrency}`; + if (cache.exchangeRates.has(key)) { + return cache.exchangeRates.get(key); + } + const { rows } = await client.query( + `SELECT rate + FROM finance_exchange_rates + WHERE from_currency = $1 AND to_currency = $2 + ORDER BY date DESC + LIMIT 1`, + [fromCurrency, toCurrency], + ); + if (!rows[0]) { + await client.query( + `INSERT INTO finance_exchange_rates (from_currency, to_currency, rate, date, source) + VALUES ($1, $2, $3, $4, $5)`, + [fromCurrency, toCurrency, rate, date, source], + ); + cache.exchangeRates.set(key, rate); + return rate; + } + const dbRate = Number(rows[0].rate) || rate; + cache.exchangeRates.set(key, dbRate); + return dbRate; +} + +async function ensureAccount( + client, + cache, + { name, currency, type = 'cash', icon = '💼', color = '#1677ff', userId = 1 }, +) { + const key = `${name}::${currency}`; + if (cache.accounts.has(key)) { + return cache.accounts.get(key); + } + const { rows } = await client.query( + `SELECT id + FROM finance_accounts + WHERE name = $1 + LIMIT 1`, + [name], + ); + if (rows[0]) { + const existingId = Number(rows[0].id); + cache.accounts.set(key, existingId); + return existingId; + } + const result = await client.query( + `INSERT INTO finance_accounts (name, currency, type, icon, color, user_id, is_active) + VALUES ($1, $2, $3, $4, $5, $6, TRUE) + RETURNING id`, + [name, currency, type, icon, color, userId], + ); + const createdId = Number(result.rows[0].id); + cache.accounts.set(key, createdId); + return createdId; +} + +async function ensureCategory( + client, + cache, + { name, type, icon = '📝', color = '#dfe4ea', userId = 1 }, +) { + const key = `${type}:${name}`; + if (cache.categories.has(key)) { + return cache.categories.get(key); + } + const { rows } = await client.query( + `SELECT id + FROM finance_categories + WHERE name = $1 AND type = $2 + LIMIT 1`, + [name, type], + ); + if (rows[0]) { + const existingId = Number(rows[0].id); + cache.categories.set(key, existingId); + return existingId; + } + const result = await client.query( + `INSERT INTO finance_categories (name, type, icon, color, user_id, is_active) + VALUES ($1, $2, $3, $4, $5, TRUE) + RETURNING id`, + [name, type, icon, color, userId], + ); + const createdId = Number(result.rows[0].id); + cache.categories.set(key, createdId); + return createdId; +} + +async function getLatestExchangeRate(client, cache, fromCurrency, toCurrency) { + const key = `${fromCurrency}->${toCurrency}`; + if (cache.exchangeRates.has(key)) { + return cache.exchangeRates.get(key); + } + const { rows } = await client.query( + `SELECT rate + FROM finance_exchange_rates + WHERE from_currency = $1 AND to_currency = $2 + ORDER BY date DESC + LIMIT 1`, + [fromCurrency, toCurrency], + ); + const rate = rows[0] ? Number(rows[0].rate) : 1; + cache.exchangeRates.set(key, rate); + return rate; +} + +async function insertTransactions(client, transactions, cache) { + for (const item of transactions) { + const createdAt = + item.createdAt ?? + new Date(`${item.transactionDate}T00:00:00Z`).toISOString(); + const status = item.status ?? 'approved'; + const statusUpdatedAt = + item.statusUpdatedAt ?? createdAt ?? new Date().toISOString(); + const approvedAt = + item.approvedAt ?? + (status === 'approved' || status === 'paid' ? statusUpdatedAt : null); + const approvedBy = + status === 'approved' || status === 'paid' + ? (item.approvedBy ?? null) + : null; + + const rate = + item.exchangeRate ?? + (await getLatestExchangeRate(client, cache, item.currency, 'CNY')); + + const amountInBase = +(item.amount * rate).toFixed(2); + + await client.query( + `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 ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, + $11, $12, $13, $14, $15, $16, $17, $18, $19, $20 + )`, + [ + item.type, + item.amount, + item.currency, + rate, + amountInBase, + item.categoryId ?? null, + item.accountId ?? null, + item.transactionDate, + item.description ?? '', + item.project ?? null, + item.memo ?? null, + createdAt, + status, + statusUpdatedAt, + item.reimbursementBatch ?? null, + item.reviewNotes ?? null, + item.submittedBy ?? null, + approvedBy, + approvedAt, + item.isDeleted ?? false, + ], ); } -})(); +} -const monthTracker = { lastMonth: null, wrapped: false }; -let carryDate = ''; -const transactions = []; +async function importNewFormat(client, header, rows, cache) { + const indexMap = Object.fromEntries( + Object.entries(NEW_HEADER_KEYS).map(([key, label]) => [ + key, + header.indexOf(label), + ]), + ); -for (let i = 1; i < lines.length; i += 1) { - const row = lines[i].split(','); - while (row.length < header.length) row.push(''); - - const rawDate = row[DATE_IDX].trim(); - if (rawDate) { - carryDate = normalizeDate(rawDate, monthTracker); - } - if (!carryDate) { - continue; + const requiredIndexes = Object.values(indexMap); + if (requiredIndexes.includes(-1)) { + throw new Error('CSV 表头缺少必需字段,无法导入新版格式数据'); } - const project = row[PROJECT_IDX].trim(); - const typeText = row[TYPE_IDX].trim(); - const amountRaw = row[AMOUNT_IDX].trim(); - const accountNameRaw = row[ACCOUNT_IDX].trim(); - const categoryRaw = row[CATEGORY_IDX].trim(); - const shareRaw = SHARE_IDX === -1 ? '' : row[SHARE_IDX].trim(); + const transactions = []; - const amount = parseAmount(amountRaw); - if (!amount) { - continue; - } + for (const columns of rows) { + if (columns.length < header.length) { + continue; + } + const date = columns[indexMap.date]?.trim(); + if (!date) { + continue; + } - const normalizedType = - typeText.includes('收') && !typeText.includes('支') ? 'income' : 'expense'; - const accountName = accountNameRaw || '美金现金'; - const currency = inferCurrency(accountNameRaw, amountRaw); + const typeRaw = columns[indexMap.type]?.trim(); + const type = resolveTransactionType(typeRaw); - if (!accountMap.has(accountName)) { - const icon = currency === 'USD' ? '💵' : currency === 'THB' ? '💱' : '💰'; - const color = - currency === 'USD' - ? '#1677ff' - : currency === 'THB' - ? '#22c55e' - : '#6366f1'; - const info = insertAccount.run({ + const categoryInfo = parseCategoryWithIcon(columns[indexMap.category]); + const currency = (columns[indexMap.currency] || 'CNY') + .toString() + .trim() + .toUpperCase(); + const currencyName = currency === 'CNY' ? '人民币' : currency; + const currencySymbol = resolveCurrencySymbol(currency); + const accountIcon = resolveCurrencyIcon(currency); + const accountColor = resolveCurrencyColor(currency); + + await ensureCurrency( + client, + cache, + currency, + currencyName, + currencySymbol, + ); + await ensureExchangeRate(client, cache, currency, 'CNY', 1, date); + + const accountName = columns[indexMap.account]?.trim() || '默认账户'; + const accountId = await ensureAccount(client, cache, { name: accountName, currency, type: 'cash', - icon, - color, + icon: accountIcon, + color: accountColor, + }); + + const categoryId = await ensureCategory(client, cache, { + name: categoryInfo.name, + type, + icon: categoryInfo.icon, + color: type === 'income' ? '#10b981' : '#fb7185', + }); + + const amountText = columns[indexMap.amount] ?? '0'; + const amount = Number(amountText.toString().replaceAll(',', '')) || 0; + if (amount === 0) { + continue; + } + + const description = columns[indexMap.project]?.trim() ?? ''; + const createdAt = `${date}T09:00:00.000Z`; + + transactions.push({ + type, + amount: Math.abs(amount), + currency, + exchangeRate: 1, + categoryId, + accountId, + transactionDate: date, + description, + createdAt, + statusUpdatedAt: createdAt, + approvedAt: createdAt, }); - accountMap.set(accountName, Number(info.lastInsertRowid)); } - const categoryName = - categoryRaw || - (normalizedType === 'income' - ? DEFAULT_INCOME_CATEGORY - : DEFAULT_EXPENSE_CATEGORY); - const categoryKey = `${categoryName}-${normalizedType}`; - if (!categoryMap.has(categoryKey)) { - const icon = normalizedType === 'income' ? '💰' : '🏷️'; - const color = normalizedType === 'income' ? '#10b981' : '#fb7185'; - const info = insertCategory.run({ - name: categoryName, - type: normalizedType, - icon, - color, - }); - categoryMap.set(categoryKey, Number(info.lastInsertRowid)); - } - - const descriptionParts = []; - if (project) descriptionParts.push(project); - if (categoryRaw) descriptionParts.push(`计入: ${categoryRaw}`); - if (shareRaw) descriptionParts.push(`分红: ${shareRaw}`); - - const description = descriptionParts.join(' | '); - transactions.push({ - type: normalizedType, - amount, - currency, - categoryId: categoryMap.get(categoryKey) ?? null, - accountId: accountMap.get(accountName) ?? null, - transactionDate: carryDate, - description, - project: project || null, - memo: shareRaw || null, - }); + await insertTransactions(client, transactions, cache); + return transactions.length; } -const insertTransaction = 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) -`); +async function importLegacyFormat(client, header, rows, cache, baseYear) { + const DATE_IDX = header.indexOf('日期'); + const PROJECT_IDX = header.indexOf('项目'); + const TYPE_IDX = header.indexOf('收支'); + const AMOUNT_IDX = header.indexOf('金额'); + const ACCOUNT_IDX = header.indexOf('支出人'); + const CATEGORY_IDX = header.indexOf('计入'); + const SHARE_IDX = header.indexOf('阿德应得分红'); -const getRateStmt = db.prepare(` - SELECT rate - FROM finance_exchange_rates - WHERE from_currency = ? AND to_currency = 'CNY' - ORDER BY date DESC - LIMIT 1 -`); + if ( + DATE_IDX === -1 || + PROJECT_IDX === -1 || + TYPE_IDX === -1 || + AMOUNT_IDX === -1 || + ACCOUNT_IDX === -1 || + CATEGORY_IDX === -1 + ) { + throw new Error('CSV 表头缺少必需字段,无法导入旧版格式数据'); + } -const insertMany = db.transaction((items) => { - for (const item of items) { - const rateRow = getRateStmt.get(item.currency); - const rate = rateRow ? rateRow.rate : 1; - const amountInBase = +(item.amount * rate).toFixed(2); - insertTransaction.run({ - ...item, - exchangeRateToBase: rate, - amountInBase, - createdAt: `${item.transactionDate}T00:00:00.000Z`, + for (const currency of LEGACY_BASE_CURRENCIES) { + await ensureCurrency( + client, + cache, + currency.code, + currency.name, + currency.symbol, + ); + } + + for (const rate of LEGACY_BASE_EXCHANGE_RATES) { + await ensureExchangeRate( + client, + cache, + rate.fromCurrency, + rate.toCurrency, + rate.rate, + `${baseYear}-01-01`, + rate.source, + ); + } + + const monthTracker = { lastMonth: null, wrapped: false }; + let carryDate = ''; + + const transactions = []; + + for (const rawColumns of rows) { + const columns = [...rawColumns]; + while (columns.length < header.length) { + columns.push(''); + } + + const rawDate = columns[DATE_IDX]?.trim(); + if (rawDate) { + carryDate = normalizeDate(rawDate, monthTracker, baseYear); + } + if (!carryDate) { + continue; + } + + const project = columns[PROJECT_IDX]?.trim(); + const typeText = columns[TYPE_IDX]?.trim(); + const amountRaw = columns[AMOUNT_IDX]?.trim(); + const accountNameRaw = columns[ACCOUNT_IDX]?.trim(); + const categoryRaw = columns[CATEGORY_IDX]?.trim(); + const shareRaw = SHARE_IDX === -1 ? '' : columns[SHARE_IDX]?.trim(); + + const amount = parseAmount(amountRaw); + if (!amount) { + continue; + } + + const normalizedType = resolveTransactionType(typeText); + const accountName = accountNameRaw || '美金现金'; + const currency = inferCurrency(accountNameRaw, amountRaw); + const accountIcon = resolveCurrencyIcon(currency); + const accountColor = resolveCurrencyColor(currency); + + const accountId = await ensureAccount(client, cache, { + name: accountName, + currency, + type: 'cash', + icon: accountIcon, + color: accountColor, + }); + + const categoryName = + categoryRaw || + (normalizedType === 'income' ? '未分类收入' : '未分类支出'); + const categoryId = await ensureCategory(client, cache, { + name: categoryName, + type: normalizedType, + icon: normalizedType === 'income' ? '💰' : '🏷️', + color: normalizedType === 'income' ? '#10b981' : '#fb7185', + }); + + const descriptionParts = []; + if (project) descriptionParts.push(project); + if (categoryRaw) descriptionParts.push(`计入: ${categoryRaw}`); + if (shareRaw) descriptionParts.push(`分红: ${shareRaw}`); + const description = descriptionParts.join(' | '); + + transactions.push({ + type: normalizedType, + amount: Math.abs(amount), + currency, + categoryId, + accountId, + transactionDate: carryDate, + description, + project: project || null, + memo: shareRaw || null, }); } + + await insertTransactions(client, transactions, cache); + return transactions.length; +} + +async function main() { + const params = parseArgs(process.argv.slice(2)); + if (!params.csv) { + console.error('请通过 --csv <路径> 指定 CSV 数据文件'); + process.exit(1); + } + + const inputPath = path.resolve(params.csv); + if (!fs.existsSync(inputPath)) { + console.error(`无法找到 CSV 文件: ${inputPath}`); + process.exit(1); + } + + const baseYear = params.year ? Number(params.year) : 2025; + if (Number.isNaN(baseYear)) { + console.error('参数 --year 必须为数字'); + process.exit(1); + } + + const csvContent = fs.readFileSync(inputPath, 'utf8'); + const { header, rows } = parseCsv(csvContent); + + if (header.length === 0 || rows.length === 0) { + console.error('CSV 文件内容为空'); + process.exit(1); + } + + const isNewFormat = Object.values(NEW_HEADER_KEYS).every((key) => + header.includes(key), + ); + + const pool = new Pool({ + connectionString: DEFAULT_CONNECTION_STRING, + max: 10, + }); + + const cache = { + currencies: new Set(), + exchangeRates: new Map(), + accounts: new Map(), + categories: new Map(), + }; + + let client; + try { + client = await pool.connect(); + await client.query('BEGIN'); + await resetFinanceTables(client); + + let count = 0; + count = await (isNewFormat + ? importNewFormat(client, header, rows, cache) + : importLegacyFormat(client, header, rows, cache, baseYear)); + + await client.query('COMMIT'); + console.log(`导入完成,共写入 ${count} 条交易记录`); + process.exit(0); + } catch (error) { + if (client) { + await client.query('ROLLBACK').catch(() => {}); + } + console.error('导入数据失败:', error); + process.exit(1); + } finally { + if (client) { + client.release(); + } + await pool.end(); + } +} + +main().catch((error) => { + console.error('导入流程异常终止:', error); + process.exit(1); }); - -insertMany(transactions); - -console.log( - `已导入 ${transactions.length} 条交易,账户 ${accountMap.size} 个,分类 ${categoryMap.size} 个。`, -); diff --git a/apps/backend/utils/db.ts b/apps/backend/utils/db.ts new file mode 100644 index 00000000..dc37bf25 --- /dev/null +++ b/apps/backend/utils/db.ts @@ -0,0 +1,314 @@ +import process from 'node:process'; +import type { PoolClient } from 'pg'; +import { Pool } from 'pg'; + +import { + MOCK_ACCOUNTS, + MOCK_CATEGORIES, + MOCK_CURRENCIES, + MOCK_EXCHANGE_RATES, +} from './mock-data'; + +const DEFAULT_HOST = process.env.POSTGRES_HOST ?? 'postgres'; +const DEFAULT_PORT = Number.parseInt(process.env.POSTGRES_PORT ?? '5432', 10); +const DEFAULT_DB = process.env.POSTGRES_DB ?? 'kt_financial'; +const DEFAULT_USER = process.env.POSTGRES_USER ?? 'kt_financial'; +const DEFAULT_PASSWORD = process.env.POSTGRES_PASSWORD ?? 'kt_financial_pwd'; + +const connectionString = + process.env.POSTGRES_URL ?? + `postgresql://${DEFAULT_USER}:${DEFAULT_PASSWORD}@${DEFAULT_HOST}:${DEFAULT_PORT}/${DEFAULT_DB}`; + +const pool = new Pool({ + connectionString, + max: 10, +}); + +let initPromise: null | Promise = null; + +async function seedCurrencies(client: PoolClient) { + await Promise.all( + MOCK_CURRENCIES.map((currency) => + client.query( + `INSERT INTO finance_currencies (code, name, symbol, is_base, is_active) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (code) DO NOTHING`, + [ + currency.code, + currency.name, + currency.symbol, + currency.isBase, + currency.isActive, + ], + ), + ), + ); +} + +async function seedExchangeRates(client: PoolClient) { + await Promise.all( + MOCK_EXCHANGE_RATES.map((rate) => + client.query( + `INSERT INTO finance_exchange_rates (from_currency, to_currency, rate, date, source) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT DO NOTHING`, + [ + rate.fromCurrency, + rate.toCurrency, + rate.rate, + rate.date, + rate.source ?? 'manual', + ], + ), + ), + ); +} + +async function seedAccounts(client: PoolClient) { + await Promise.all( + MOCK_ACCOUNTS.map((account) => + client.query( + `INSERT INTO finance_accounts (id, name, currency, type, icon, color, user_id, is_active) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (id) DO NOTHING`, + [ + account.id, + account.name, + account.currency, + account.type, + account.icon, + account.color, + account.userId ?? 1, + account.isActive, + ], + ), + ), + ); +} + +async function seedCategories(client: PoolClient) { + await Promise.all( + MOCK_CATEGORIES.map((category) => + client.query( + `INSERT INTO finance_categories (id, name, type, icon, color, user_id, is_active) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (id) DO NOTHING`, + [ + category.id, + category.name, + category.type, + category.icon, + category.color, + category.userId, + category.isActive, + ], + ), + ), + ); +} + +async function initializeSchema() { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + await client.query(` + CREATE TABLE IF NOT EXISTS finance_currencies ( + code TEXT PRIMARY KEY, + name TEXT NOT NULL, + symbol TEXT NOT NULL, + is_base BOOLEAN NOT NULL DEFAULT FALSE, + is_active BOOLEAN NOT NULL DEFAULT TRUE + ); + `); + + await client.query(` + CREATE TABLE IF NOT EXISTS finance_exchange_rates ( + id SERIAL PRIMARY KEY, + from_currency TEXT NOT NULL REFERENCES finance_currencies(code), + to_currency TEXT NOT NULL REFERENCES finance_currencies(code), + rate NUMERIC NOT NULL, + date DATE NOT NULL, + source TEXT DEFAULT 'manual' + ); + `); + + await client.query(` + CREATE TABLE IF NOT EXISTS finance_accounts ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + currency TEXT NOT NULL REFERENCES finance_currencies(code), + type TEXT DEFAULT 'cash', + icon TEXT, + color TEXT, + user_id INTEGER DEFAULT 1, + is_active BOOLEAN DEFAULT TRUE + ); + `); + + await client.query(` + CREATE TABLE IF NOT EXISTS finance_categories ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + type TEXT NOT NULL, + icon TEXT, + color TEXT, + user_id INTEGER, + is_active BOOLEAN DEFAULT TRUE + ); + `); + + await client.query(` + CREATE TABLE IF NOT EXISTS finance_transactions ( + id SERIAL PRIMARY KEY, + type TEXT NOT NULL, + amount NUMERIC NOT NULL, + currency TEXT NOT NULL REFERENCES finance_currencies(code), + exchange_rate_to_base NUMERIC NOT NULL, + amount_in_base NUMERIC NOT NULL, + category_id INTEGER REFERENCES finance_categories(id), + account_id INTEGER REFERENCES finance_accounts(id), + transaction_date DATE NOT NULL, + description TEXT, + project TEXT, + memo TEXT, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + status TEXT NOT NULL DEFAULT 'approved', + status_updated_at TIMESTAMP WITH TIME ZONE, + reimbursement_batch TEXT, + review_notes TEXT, + submitted_by TEXT, + approved_by TEXT, + approved_at TIMESTAMP WITH TIME ZONE, + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + deleted_at TIMESTAMP WITH TIME ZONE + ); + `); + + await client.query(` + CREATE TABLE IF NOT EXISTS finance_media_messages ( + id SERIAL PRIMARY KEY, + chat_id BIGINT NOT NULL, + message_id BIGINT 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 TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + UNIQUE(chat_id, message_id) + ); + `); + + await client.query(` + CREATE TABLE IF NOT EXISTS telegram_notification_configs ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + bot_token TEXT NOT NULL, + chat_id TEXT NOT NULL, + notification_types TEXT NOT NULL, + is_enabled BOOLEAN NOT NULL DEFAULT TRUE, + priority TEXT DEFAULT 'normal', + rate_limit_seconds INTEGER DEFAULT 0, + batch_enabled BOOLEAN DEFAULT FALSE, + batch_interval_minutes INTEGER DEFAULT 60, + retry_enabled BOOLEAN DEFAULT TRUE, + retry_max_attempts INTEGER DEFAULT 3, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() + ); + `); + + await client.query(` + CREATE TABLE IF NOT EXISTS telegram_notification_history ( + id SERIAL PRIMARY KEY, + config_id INTEGER NOT NULL REFERENCES telegram_notification_configs(id), + notification_type TEXT NOT NULL, + content_hash TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + retry_count INTEGER DEFAULT 0, + sent_at TIMESTAMP WITH TIME ZONE, + error_message TEXT, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() + ); + `); + + await client.query(` + CREATE INDEX IF NOT EXISTS idx_finance_media_messages_created_at + ON finance_media_messages (created_at DESC); + `); + await client.query(` + CREATE INDEX IF NOT EXISTS idx_finance_media_messages_user_id + ON finance_media_messages (user_id); + `); + await client.query(` + CREATE INDEX IF NOT EXISTS idx_telegram_notification_configs_enabled + ON telegram_notification_configs (is_enabled); + `); + await client.query(` + CREATE INDEX IF NOT EXISTS idx_telegram_notification_history_config + ON telegram_notification_history (config_id, created_at DESC); + `); + await client.query(` + CREATE INDEX IF NOT EXISTS idx_telegram_notification_history_hash + ON telegram_notification_history (content_hash, created_at DESC); + `); + await client.query(` + CREATE INDEX IF NOT EXISTS idx_telegram_notification_history_status + ON telegram_notification_history (status, retry_count); + `); + + await seedCurrencies(client); + await seedExchangeRates(client); + await seedAccounts(client); + await seedCategories(client); + + await client.query('COMMIT'); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } +} + +export async function getPool() { + if (!initPromise) { + initPromise = initializeSchema(); + } + await initPromise; + return pool; +} + +export async function query(text: string, params?: any[]) { + const client = await getPool(); + const result = await client.query(text, params); + return result; +} + +export async function withTransaction( + handler: (client: PoolClient) => Promise, +) { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + const result = await handler(client); + await client.query('COMMIT'); + return result; + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } +} diff --git a/apps/backend/utils/finance-metadata.ts b/apps/backend/utils/finance-metadata.ts index 22ab3290..fc61af17 100644 --- a/apps/backend/utils/finance-metadata.ts +++ b/apps/backend/utils/finance-metadata.ts @@ -1,3 +1,4 @@ +import { query } from './db'; import { MOCK_ACCOUNTS, MOCK_BUDGETS, @@ -5,37 +6,87 @@ import { MOCK_CURRENCIES, MOCK_EXCHANGE_RATES, } from './mock-data'; -import db from './sqlite'; -export function listAccounts() { - return MOCK_ACCOUNTS; +interface AccountRow { + id: number; + name: string; + type: string; + currency: string; + icon: null | string; + color: null | string; + user_id: null | number; + is_active: boolean; } -export function listCategories() { - // 从数据库读取分类 +interface CategoryRow { + id: number; + name: string; + type: string; + icon: null | string; + color: null | string; + user_id: null | number; + is_active: boolean; +} + +function mapAccount(row: AccountRow) { + return { + id: row.id, + userId: row.user_id ?? 1, + name: row.name, + type: row.type, + currency: row.currency, + balance: 0, + icon: row.icon ?? '💳', + color: row.color ?? '#1677ff', + isActive: Boolean(row.is_active), + }; +} + +function mapCategory(row: CategoryRow) { + return { + id: row.id, + userId: row.user_id ?? 1, + name: row.name, + type: row.type as 'expense' | 'income', + icon: row.icon ?? '📝', + color: row.color ?? '#dfe4ea', + sortOrder: row.id, + isSystem: row.user_id === null, + isActive: Boolean(row.is_active), + }; +} + +export async function listAccounts() { 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), - })); + const { rows } = await query( + `SELECT id, name, type, currency, icon, color, user_id, is_active + FROM finance_accounts + ORDER BY id`, + ); + if (rows.length === 0) { + return MOCK_ACCOUNTS; + } + return rows.map((row) => mapAccount(row)); } catch (error) { - console.error('从数据库读取分类失败,使用MOCK数据:', error); + console.error('从数据库读取账户失败,使用 MOCK 数据:', error); + return MOCK_ACCOUNTS; + } +} + +export async function listCategories() { + try { + const { rows } = await query( + `SELECT id, name, type, icon, color, user_id, is_active + FROM finance_categories + WHERE is_active = TRUE + ORDER BY type, id`, + ); + if (rows.length === 0) { + return MOCK_CATEGORIES; + } + return rows.map((row) => mapCategory(row)); + } catch (error) { + console.error('从数据库读取分类失败,使用 MOCK 数据:', error); return MOCK_CATEGORIES; } } @@ -52,76 +103,80 @@ export function listExchangeRates() { return MOCK_EXCHANGE_RATES; } -export function createCategoryRecord(category: any) { +export async function createCategoryRecord(category: any) { 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 + const { rows } = await query( + `INSERT INTO finance_categories (name, type, icon, color, user_id, is_active) + VALUES ($1, $2, $3, $4, $5, TRUE) + RETURNING id, name, type, icon, color, user_id, is_active`, + [ + category.name, + category.type, + category.icon || '📝', + category.color || '#dfe4ea', + category.userId || 1, + ], ); - return { - id: result.lastInsertRowid, - ...category, - createdAt: new Date().toISOString(), - }; + const row = rows[0]; + return row + ? { + ...mapCategory(row), + createdAt: new Date().toISOString(), + } + : null; } catch (error) { console.error('创建分类失败:', error); return null; } } -export function updateCategoryRecord(id: number, category: any) { +export async function updateCategoryRecord(id: number, category: any) { try { const updates: string[] = []; const params: any[] = []; - + if (category.name) { - updates.push('name = ?'); params.push(category.name); + updates.push(`name = $${params.length}`); } if (category.icon) { - updates.push('icon = ?'); params.push(category.icon); + updates.push(`icon = $${params.length}`); } if (category.color) { - updates.push('color = ?'); params.push(category.color); + updates.push(`color = $${params.length}`); } - - if (updates.length === 0) return null; - + + 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); + const setClause = updates.join(', '); + const { rows } = await query( + `UPDATE finance_categories + SET ${setClause} + WHERE id = $${params.length} + RETURNING id, name, type, icon, color, user_id, is_active`, + params, + ); + const row = rows[0]; + return row ? mapCategory(row) : null; } catch (error) { console.error('更新分类失败:', error); return null; } } -export function deleteCategoryRecord(id: number) { +export async function deleteCategoryRecord(id: number) { try { - // 软删除 - const stmt = db.prepare(` - UPDATE finance_categories - SET is_active = 0 - WHERE id = ? - `); - stmt.run(id); + await query( + `UPDATE finance_categories + SET is_active = FALSE + WHERE id = $1`, + [id], + ); return true; } catch (error) { console.error('删除分类失败:', error); diff --git a/apps/backend/utils/finance-repository.ts b/apps/backend/utils/finance-repository.ts index bc52626b..4a1dca35 100644 --- a/apps/backend/utils/finance-repository.ts +++ b/apps/backend/utils/finance-repository.ts @@ -1,14 +1,16 @@ -import db from './sqlite'; +import type { PoolClient } from 'pg'; + +import { query, withTransaction } from './db'; const BASE_CURRENCY = 'CNY'; interface TransactionRow { id: number; type: string; - amount: number; + amount: number | string; currency: string; - exchange_rate_to_base: number; - amount_in_base: number; + exchange_rate_to_base: number | string; + amount_in_base: number | string; category_id: null | number; account_id: null | number; transaction_date: string; @@ -23,7 +25,7 @@ interface TransactionRow { submitted_by: null | string; approved_by: null | string; approved_at: null | string; - is_deleted: number; + is_deleted: boolean; deleted_at: null | string; } @@ -49,32 +51,24 @@ interface TransactionPayload { } export type TransactionStatus = - | 'draft' - | 'pending' | 'approved' - | 'rejected' - | 'paid'; - -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 undefined | { rate: number }; - return row?.rate ?? 1; -} + | 'draft' + | 'paid' + | 'pending' + | 'rejected'; function mapTransaction(row: TransactionRow) { + const amount = Number(row.amount); + const exchangeRateToBase = Number(row.exchange_rate_to_base); + const amountInBase = Number(row.amount_in_base); return { id: row.id, userId: 1, - type: 'expense' as const, - amount: Math.abs(row.amount), + type: row.type as 'expense' | 'income' | 'transfer', + amount: Math.abs(amount), currency: row.currency, - exchangeRateToBase: row.exchange_rate_to_base, - amountInBase: Math.abs(row.amount_in_base), + exchangeRateToBase, + amountInBase: Math.abs(amountInBase), categoryId: row.category_id ?? undefined, accountId: row.account_id ?? undefined, transactionDate: row.transaction_date, @@ -94,231 +88,350 @@ function mapTransaction(row: TransactionRow) { }; } -export function fetchTransactions( +async function getExchangeRateToBase(client: PoolClient, currency: string) { + if (currency === BASE_CURRENCY) { + return 1; + } + const result = await client.query<{ rate: number | string }>( + `SELECT rate + FROM finance_exchange_rates + WHERE from_currency = $1 AND to_currency = $2 + ORDER BY date DESC + LIMIT 1`, + [currency, BASE_CURRENCY], + ); + const raw = result.rows[0]?.rate; + return raw ? Number(raw) : 1; +} + +export async function fetchTransactions( options: { includeDeleted?: boolean; - type?: string; statuses?: TransactionStatus[]; + type?: string; } = {}, ) { const clauses: string[] = []; - const params: Record = {}; + const params: any[] = []; if (!options.includeDeleted) { - clauses.push('is_deleted = 0'); + clauses.push('is_deleted = FALSE'); } if (options.type) { - clauses.push('type = @type'); - params.type = options.type; + params.push(options.type); + clauses.push(`type = $${params.length}`); } 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 statusPlaceholders = options.statuses.map((status) => { + params.push(status); + return `$${params.length}`; }); + clauses.push(`status IN (${statusPlaceholders.join(', ')})`); } const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : ''; - - const stmt = db.prepare( - `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`, + const { rows } = await query( + `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`, + params, ); - - return stmt.all(params).map(mapTransaction); + return rows.map((row) => mapTransaction(row)); } -export function getTransactionById(id: number) { - const stmt = db.prepare( - `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 = ?`, +export async function getTransactionById(id: number) { + const { rows } = await query( + `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 = $1`, + [id], ); - const row = stmt.get(id); + const row = rows[0]; 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 > 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; +export async function createTransaction(payload: TransactionPayload) { + return withTransaction(async (client) => { + const exchangeRate = await getExchangeRateToBase(client, payload.currency); + const amountInBase = +(payload.amount * exchangeRate).toFixed(2); + const createdAt = + 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; + let approvedAt: string | null = null; + if (payload.approvedAt && payload.approvedAt.length > 0) { + approvedAt = payload.approvedAt; + } else if (status === 'approved' || status === 'paid') { + approvedAt = statusUpdatedAt; + } - 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, 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({ - 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, - status, - statusUpdatedAt, - reimbursementBatch: payload.reimbursementBatch ?? null, - reviewNotes: payload.reviewNotes ?? null, - submittedBy: payload.submittedBy ?? null, - approvedBy: payload.approvedBy ?? null, - approvedAt, + const { rows } = await client.query( + `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 ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, + $12, $13, $14, $15, $16, $17, $18, $19, FALSE + ) + RETURNING *`, + [ + payload.type, + payload.amount, + payload.currency, + exchangeRate, + amountInBase, + payload.categoryId ?? null, + payload.accountId ?? null, + payload.transactionDate, + payload.description ?? '', + payload.project ?? null, + payload.memo ?? null, + createdAt, + status, + statusUpdatedAt, + payload.reimbursementBatch ?? null, + payload.reviewNotes ?? null, + payload.submittedBy ?? null, + payload.approvedBy ?? null, + approvedAt, + ], + ); + return mapTransaction(rows[0]); }); - - return getTransactionById(Number(info.lastInsertRowid)); } -export function updateTransaction(id: number, payload: TransactionPayload) { - const current = getTransactionById(id); +export async function updateTransaction( + id: number, + payload: TransactionPayload, +) { + const current = await getTransactionById(id); if (!current) { 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; + return withTransaction(async (client) => { + const nextStatus = (payload.status ?? + current.status ?? + 'approved') as TransactionStatus; + const statusChanged = nextStatus !== current.status; + let statusUpdatedAt: string; + if (payload.statusUpdatedAt && payload.statusUpdatedAt.length > 0) { + statusUpdatedAt = payload.statusUpdatedAt; + } else if (statusChanged) { + statusUpdatedAt = new Date().toISOString(); + } else { + statusUpdatedAt = current.statusUpdatedAt ?? current.createdAt; + } + let approvedAt: string | null = null; + if (payload.approvedAt && payload.approvedAt.length > 0) { + approvedAt = payload.approvedAt; + } else if (nextStatus === 'approved' || nextStatus === 'paid') { + approvedAt = current.approvedAt ?? (statusChanged ? statusUpdatedAt : 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, - 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, - status: nextStatus, - statusUpdatedAt, - reimbursementBatch: - payload.reimbursementBatch ?? current.reimbursementBatch ?? null, - reviewNotes: payload.reviewNotes ?? current.reviewNotes ?? null, - submittedBy: payload.submittedBy ?? current.submittedBy ?? null, - approvedBy, - approvedAt, - }; + 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, + 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 exchangeRate = await getExchangeRateToBase(client, next.currency); + const amountInBase = +(next.amount * exchangeRate).toFixed(2); + const deletedAt = next.isDeleted ? new Date().toISOString() : null; - 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, 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 { rows } = await client.query( + `UPDATE finance_transactions + SET type = $1, + amount = $2, + currency = $3, + exchange_rate_to_base = $4, + amount_in_base = $5, + category_id = $6, + account_id = $7, + transaction_date = $8, + description = $9, + project = $10, + memo = $11, + status = $12, + status_updated_at = $13, + reimbursement_batch = $14, + review_notes = $15, + submitted_by = $16, + approved_by = $17, + approved_at = $18, + is_deleted = $19, + deleted_at = $20 + WHERE id = $21 + RETURNING *`, + [ + next.type, + next.amount, + next.currency, + exchangeRate, + amountInBase, + next.categoryId, + next.accountId, + next.transactionDate, + next.description, + next.project, + next.memo, + next.status, + next.statusUpdatedAt, + next.reimbursementBatch, + next.reviewNotes, + next.submittedBy, + next.approvedBy, + next.approvedAt, + next.isDeleted, + deletedAt, + 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, - 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, + return mapTransaction(rows[0]); }); - - 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`, +export async function softDeleteTransaction(id: number) { + const deletedAt = new Date().toISOString(); + const { rows } = await query( + `UPDATE finance_transactions + SET is_deleted = TRUE, deleted_at = $1 + WHERE id = $2 + RETURNING *`, + [deletedAt, id], ); - stmt.run({ id, deletedAt: new Date().toISOString() }); - return getTransactionById(id); + const row = rows[0]; + return row ? mapTransaction(row) : null; } -export function restoreTransaction(id: number) { - const stmt = db.prepare( - `UPDATE finance_transactions SET is_deleted = 0, deleted_at = NULL WHERE id = @id`, +export async function restoreTransaction(id: number) { + const { rows } = await query( + `UPDATE finance_transactions + SET is_deleted = FALSE, deleted_at = NULL + WHERE id = $1 + RETURNING *`, + [id], ); - stmt.run({ id }); - return getTransactionById(id); + const row = rows[0]; + return row ? mapTransaction(row) : null; } -export function replaceAllTransactions( +export async function replaceAllTransactions( rows: Array<{ accountId: null | number; amount: number; + approvedAt?: null | string; + approvedBy?: null | string; categoryId: null | number; createdAt?: string; currency: string; description: string; + isDeleted?: boolean; memo?: null | string; project?: null | string; - transactionDate: string; - type: string; - status?: TransactionStatus; - statusUpdatedAt?: string; reimbursementBatch?: null | string; reviewNotes?: null | string; + status?: TransactionStatus; + statusUpdatedAt?: string; submittedBy?: null | string; - approvedBy?: null | string; - approvedAt?: null | string; - isDeleted?: boolean; + transactionDate: string; + type: string; }>, ) { - db.prepare('DELETE FROM finance_transactions').run(); + await withTransaction(async (client) => { + await client.query( + 'TRUNCATE TABLE finance_transactions RESTART IDENTITY CASCADE', + ); - 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, 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( - `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) => { - for (const item of items) { - const row = getRate.get(item.currency) as undefined | { rate: number }; - const rate = row?.rate ?? 1; + for (const item of rows) { + const rate = await getExchangeRateToBase(client, item.currency); const amountInBase = +(item.amount * rate).toFixed(2); const createdAt = item.createdAt ?? @@ -326,38 +439,67 @@ export function replaceAllTransactions( const status = item.status ?? 'approved'; const statusUpdatedAt = item.statusUpdatedAt ?? - new Date( - `${item.transactionDate}T00:00:00Z`, - ).toISOString(); + 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, - status, - statusUpdatedAt, - reimbursementBatch: item.reimbursementBatch ?? null, - reviewNotes: item.reviewNotes ?? null, - submittedBy: item.submittedBy ?? null, - approvedBy: + + await client.query( + `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 ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, + $11, $12, $13, $14, $15, $16, $17, $18, $19, $20 + )`, + [ + item.type, + item.amount, + item.currency, + rate, + amountInBase, + item.categoryId ?? null, + item.accountId ?? null, + item.transactionDate, + item.description ?? '', + item.project ?? null, + item.memo ?? null, + createdAt, + status, + statusUpdatedAt, + item.reimbursementBatch ?? null, + item.reviewNotes ?? null, + item.submittedBy ?? null, status === 'approved' || status === 'paid' - ? item.approvedBy ?? null + ? (item.approvedBy ?? null) : null, - approvedAt, - isDeleted: item.isDeleted ? 1 : 0, - }); + approvedAt, + item.isDeleted ?? false, + ], + ); } }); - - insertMany(rows); } -// 分类相关函数 interface CategoryRow { id: number; name: string; @@ -365,7 +507,7 @@ interface CategoryRow { icon: null | string; color: null | string; user_id: null | number; - is_active: number; + is_active: boolean; } function mapCategory(row: CategoryRow) { @@ -382,15 +524,53 @@ function mapCategory(row: CategoryRow) { }; } -export function fetchCategories(options: { type?: 'expense' | 'income' } = {}) { - 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( - `SELECT id, name, type, icon, color, user_id, is_active FROM finance_categories ${where} ORDER BY id ASC`, +export async function fetchCategories( + options: { type?: 'expense' | 'income' } = {}, +) { + const params: any[] = []; + const clauses: string[] = ['is_active = TRUE']; + if (options.type) { + params.push(options.type); + clauses.push(`type = $${params.length}`); + } + const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : ''; + const { rows } = await query( + `SELECT id, + name, + type, + icon, + color, + user_id, + is_active + FROM finance_categories + ${where} + ORDER BY id ASC`, + params, ); - - return stmt.all(params).map(mapCategory); + return rows.map((row) => mapCategory(row)); +} + +export async function getAccountById(id: number) { + const { rows } = await query<{ + currency: string; + id: number; + name: string; + }>( + `SELECT id, name, currency + FROM finance_accounts + WHERE id = $1`, + [id], + ); + return rows[0] ?? null; +} + +export async function getCategoryById(id: number) { + const { rows } = await query( + `SELECT id, name, type, icon, color, user_id, is_active + FROM finance_categories + WHERE id = $1`, + [id], + ); + const row = rows[0]; + return row ? mapCategory(row) : null; } diff --git a/apps/backend/utils/media-repository.ts b/apps/backend/utils/media-repository.ts index acf01926..9029a6af 100644 --- a/apps/backend/utils/media-repository.ts +++ b/apps/backend/utils/media-repository.ts @@ -1,6 +1,6 @@ import { existsSync } from 'node:fs'; -import db from './sqlite'; +import { query } from './db'; interface MediaRow { id: number; @@ -47,7 +47,7 @@ export interface MediaMessage { createdAt: string; updatedAt: string; available: boolean; - downloadUrl: string | null; + downloadUrl: null | string; } function mapMediaRow(row: MediaRow): MediaMessage { @@ -78,40 +78,85 @@ function mapMediaRow(row: MediaRow): MediaMessage { }; } -export function fetchMediaMessages(params: { - limit?: number; - fileTypes?: string[]; -} = {}) { - const clauses: string[] = []; - const bindParams: Record = {}; +export async function fetchMediaMessages( + params: { + fileTypes?: string[]; + limit?: number; + } = {}, +) { + const whereClauses: string[] = []; + const queryParams: any[] = []; 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 placeholders = params.fileTypes.map((type) => { + queryParams.push(type); + return `$${queryParams.length}`; }); + whereClauses.push(`file_type IN (${placeholders.join(', ')})`); } - const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : ''; + const where = + whereClauses.length > 0 ? `WHERE ${whereClauses.join(' AND ')}` : ''; const limitClause = params.limit && params.limit > 0 ? `LIMIT ${Number(params.limit)}` : ''; - const stmt = db.prepare( - `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}`, + const { rows } = await query( + `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 created_at DESC, id DESC + ${limitClause}`, + queryParams, ); - return stmt.all(bindParams).map(mapMediaRow); + return rows.map((row) => mapMediaRow(row)); } -export function getMediaMessageById(id: number) { - const stmt = db.prepare( - `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 = ?`, +export async function getMediaMessageById(id: number) { + const { rows } = await query( + `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 = $1`, + [id], ); - - const row = stmt.get(id); - + const row = rows[0]; return row ? mapMediaRow(row) : null; } - diff --git a/apps/backend/utils/sqlite.ts b/apps/backend/utils/sqlite.ts deleted file mode 100644 index 65d243a4..00000000 --- a/apps/backend/utils/sqlite.ts +++ /dev/null @@ -1,248 +0,0 @@ -import { mkdirSync } from 'node:fs'; - -import Database from 'better-sqlite3'; -import { dirname, join } from 'pathe'; - -const dbFile = join(process.cwd(), 'storage', 'finance.db'); - -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(` - 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, - 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), - FOREIGN KEY (category_id) REFERENCES finance_categories(id), - FOREIGN KEY (account_id) REFERENCES finance_accounts(id) - ); -`); - -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); -`); - -// 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, - priority TEXT DEFAULT 'normal', - rate_limit_seconds INTEGER DEFAULT 0, - batch_enabled INTEGER DEFAULT 0, - batch_interval_minutes INTEGER DEFAULT 60, - retry_enabled INTEGER DEFAULT 1, - retry_max_attempts INTEGER DEFAULT 3, - 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); -`); - -// 通知发送历史表(用于频率控制和去重) -database.exec(` - CREATE TABLE IF NOT EXISTS telegram_notification_history ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - config_id INTEGER NOT NULL, - notification_type TEXT NOT NULL, - content_hash TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'pending', - retry_count INTEGER DEFAULT 0, - sent_at TEXT, - error_message TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (config_id) REFERENCES telegram_notification_configs(id) - ); -`); - -database.exec(` - CREATE INDEX IF NOT EXISTS idx_telegram_notification_history_config - ON telegram_notification_history (config_id, created_at DESC); -`); - -database.exec(` - CREATE INDEX IF NOT EXISTS idx_telegram_notification_history_hash - ON telegram_notification_history (content_hash, created_at DESC); -`); - -database.exec(` - CREATE INDEX IF NOT EXISTS idx_telegram_notification_history_status - ON telegram_notification_history (status, retry_count); -`); - -// 确保添加新列到已存在的表 -ensureColumn( - 'telegram_notification_configs', - 'priority', - "priority TEXT DEFAULT 'normal'", -); -ensureColumn( - 'telegram_notification_configs', - 'rate_limit_seconds', - 'rate_limit_seconds INTEGER DEFAULT 0', -); -ensureColumn( - 'telegram_notification_configs', - 'batch_enabled', - 'batch_enabled INTEGER DEFAULT 0', -); -ensureColumn( - 'telegram_notification_configs', - 'batch_interval_minutes', - 'batch_interval_minutes INTEGER DEFAULT 60', -); -ensureColumn( - 'telegram_notification_configs', - 'retry_enabled', - 'retry_enabled INTEGER DEFAULT 1', -); -ensureColumn( - 'telegram_notification_configs', - 'retry_max_attempts', - 'retry_max_attempts INTEGER DEFAULT 3', -); - -export default database; diff --git a/apps/backend/utils/telegram-bot-enhanced.ts b/apps/backend/utils/telegram-bot-enhanced.ts index 82c61c51..7fa311ae 100644 --- a/apps/backend/utils/telegram-bot-enhanced.ts +++ b/apps/backend/utils/telegram-bot-enhanced.ts @@ -1,491 +1,21 @@ -import crypto from 'node:crypto'; -import db from './sqlite'; +import { + getEnabledNotificationConfigs, + notifyTransaction, + testTelegramConfig, +} from './telegram-bot'; -interface TelegramNotificationConfig { - id: number; - name: string; - botToken: string; - chatId: string; - notificationTypes: string[]; - isEnabled: boolean; - priority: string; - rateLimitSeconds: number; - batchEnabled: boolean; - batchIntervalMinutes: number; - retryEnabled: boolean; - retryMaxAttempts: number; -} +export { getEnabledNotificationConfigs, testTelegramConfig }; -interface TransactionNotificationData { - id: number; - type: string; - amount: number; - currency: string; - categoryName?: string; - accountName?: string; - transactionDate: string; - description?: string; - status: string; -} - -/** - * 生成消息内容hash(用于去重) - */ -function generateContentHash(content: string): string { - return crypto.createHash('md5').update(content).digest('hex'); -} - -/** - * 检查频率限制 - */ -function checkRateLimit(configId: number, rateLimitSeconds: number): boolean { - if (rateLimitSeconds <= 0) { - return true; // 无限制 - } - - const cutoffTime = new Date( - Date.now() - rateLimitSeconds * 1000, - ).toISOString(); - - const recent = db - .prepare<{ count: number }>( - ` - SELECT COUNT(*) as count - FROM telegram_notification_history - WHERE config_id = ? AND status = 'sent' AND sent_at > ? - `, - ) - .get(configId, cutoffTime); - - return (recent?.count || 0) === 0; -} - -/** - * 检查是否为重复消息 - */ -function isDuplicateMessage( - configId: number, - contentHash: string, - withinMinutes: number = 5, -): boolean { - const cutoffTime = new Date(Date.now() - withinMinutes * 60 * 1000).toISOString(); - - const duplicate = db - .prepare<{ count: number }>( - ` - SELECT COUNT(*) as count - FROM telegram_notification_history - WHERE config_id = ? AND content_hash = ? AND created_at > ? - `, - ) - .get(configId, contentHash, cutoffTime); - - return (duplicate?.count || 0) > 0; -} - -/** - * 记录通知历史 - */ -function recordNotification( - configId: number, - notificationType: string, - contentHash: string, - status: 'pending' | 'sent' | 'failed', - errorMessage?: string, -): number { - const now = new Date().toISOString(); - - const result = db - .prepare( - ` - INSERT INTO telegram_notification_history - (config_id, notification_type, content_hash, status, sent_at, error_message, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?) - `, - ) - .run( - configId, - notificationType, - contentHash, - status, - status === 'sent' ? now : null, - errorMessage || null, - now, - ); - - return result.lastInsertRowid as number; -} - -/** - * 更新通知状态 - */ -function updateNotificationStatus( - historyId: number, - status: 'sent' | 'failed', - retryCount: number = 0, - errorMessage?: string, -): void { - const now = new Date().toISOString(); - - db.prepare( - ` - UPDATE telegram_notification_history - SET status = ?, retry_count = ?, sent_at = ?, error_message = ? - WHERE id = ? - `, - ).run(status, retryCount, status === 'sent' ? now : null, errorMessage || null, historyId); -} - -/** - * 获取待重试的通知 - */ -function getPendingRetries(): Array<{ - id: number; - configId: number; - contentHash: string; - retryCount: number; -}> { - return db - .prepare<{ id: number; config_id: number; content_hash: string; retry_count: number }>( - ` - SELECT h.id, h.config_id, h.content_hash, h.retry_count - FROM telegram_notification_history h - JOIN telegram_notification_configs c ON h.config_id = c.id - WHERE h.status = 'failed' - AND c.retry_enabled = 1 - AND h.retry_count < c.retry_max_attempts - AND h.created_at > datetime('now', '-24 hours') - ORDER BY h.created_at ASC - LIMIT 10 - `, - ) - .all() - .map((row) => ({ - id: row.id, - configId: row.config_id, - contentHash: row.content_hash, - retryCount: row.retry_count, - })); -} - -/** - * 获取所有启用的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; - priority: string; - rate_limit_seconds: number; - batch_enabled: number; - batch_interval_minutes: number; - retry_enabled: number; - retry_max_attempts: number; - }>( - ` - SELECT id, name, bot_token, chat_id, notification_types, is_enabled, - priority, rate_limit_seconds, batch_enabled, batch_interval_minutes, - retry_enabled, retry_max_attempts - 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, - priority: row.priority || 'normal', - rateLimitSeconds: row.rate_limit_seconds || 0, - batchEnabled: (row.batch_enabled || 0) === 1, - batchIntervalMinutes: row.batch_interval_minutes || 60, - retryEnabled: (row.retry_enabled || 1) === 1, - retryMaxAttempts: row.retry_max_attempts || 3, - })) - .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 = { - income: '💰 收入', - expense: '💸 支出', - transfer: '🔄 转账', - }; - return typeMap[type] || type; -} - -/** - * 格式化交易状态 - */ -function formatTransactionStatus(status: string): string { - const statusMap: Record = { - draft: '📝 草稿', - pending: '⏳ 待审核', - approved: '✅ 已批准', - rejected: '❌ 已拒绝', - paid: '💵 已支付', - }; - return statusMap[status] || status; -} - -/** - * 格式化优先级标识 - */ -function formatPriority(priority: string): string { - const priorityMap: Record = { - low: '🔵', - normal: '⚪', - high: '🟡', - urgent: '🔴', - }; - return priorityMap[priority] || '⚪'; -} - -/** - * 构建交易通知消息 - */ -function buildTransactionMessage( - transaction: TransactionNotificationData, - action: string = 'created', - priority: string = 'normal', -): string { - const actionMap: Record = { - created: '📋 新增账目记录', - updated: '✏️ 更新账目记录', - deleted: '🗑️ 删除账目记录', - }; - - const priorityIcon = formatPriority(priority); - - const lines: string[] = [ - `${priorityIcon} ${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, - retryCount: number = 0, -): Promise<{ success: boolean; error?: string }> { - 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(() => ({ description: 'Unknown error' })); - const errorMsg = error.description || `HTTP ${response.status}`; - console.error( - '[telegram-bot-enhanced] Failed to send message:', - response.status, - errorMsg, - ); - return { success: false, error: errorMsg }; - } - - return { success: true }; - } catch (error: unknown) { - const errorMsg = error instanceof Error ? error.message : 'Unknown error'; - console.error('[telegram-bot-enhanced] Error sending message:', errorMsg); - return { success: false, error: errorMsg }; - } -} - -/** - * 通知交易记录(增强版 - 带频率控制、去重、重试) - */ export async function notifyTransactionEnhanced( - transaction: TransactionNotificationData, - action: string = 'created', -): Promise { - const configs = getEnabledNotificationConfigs('transaction'); - - if (configs.length === 0) { - console.log('[telegram-bot-enhanced] No enabled notification configs found'); - return; - } - - for (const config of configs) { - // 1. 检查频率限制 - if (!checkRateLimit(config.id, config.rateLimitSeconds)) { - console.log( - `[telegram-bot-enhanced] Rate limit exceeded for config: ${config.name}`, - ); - continue; - } - - // 2. 构建消息 - const message = buildTransactionMessage(transaction, action, config.priority); - const contentHash = generateContentHash(message); - - // 3. 检查重复消息 - if (isDuplicateMessage(config.id, contentHash)) { - console.log( - `[telegram-bot-enhanced] Duplicate message detected for config: ${config.name}`, - ); - continue; - } - - // 4. 记录通知历史 - const historyId = recordNotification( - config.id, - 'transaction', - contentHash, - 'pending', - ); - - // 5. 发送消息 - const result = await sendTelegramMessage( - config.botToken, - config.chatId, - message, - ); - - // 6. 更新状态 - if (result.success) { - updateNotificationStatus(historyId, 'sent'); - console.log( - `[telegram-bot-enhanced] Sent notification via config: ${config.name}`, - ); - } else { - updateNotificationStatus(historyId, 'failed', 0, result.error); - console.error( - `[telegram-bot-enhanced] Failed to send notification via config: ${config.name}, error: ${result.error}`, - ); - } - } + ...args: Parameters +) { + await notifyTransaction(...args); } -/** - * 重试失败的通知 - */ export async function retryFailedNotifications(): Promise { - const pending = getPendingRetries(); - - if (pending.length === 0) { - return; - } - - console.log( - `[telegram-bot-enhanced] Retrying ${pending.length} failed notifications`, - ); - - for (const item of pending) { - // 获取配置 - const config = db - .prepare<{ - bot_token: string; - chat_id: string; - priority: string; - }>( - 'SELECT bot_token, chat_id, priority FROM telegram_notification_configs WHERE id = ?', - ) - .get(item.configId); - - if (!config) { - continue; - } - - // 注意:这里需要重新构建消息或从历史中获取 - // 简化处理:发送重试通知 - const retryMessage = `🔄 通知重试 (尝试 ${item.retryCount + 1})`; - - const result = await sendTelegramMessage( - config.bot_token, - config.chat_id, - retryMessage, - item.retryCount, - ); - - if (result.success) { - updateNotificationStatus(item.id, 'sent', item.retryCount + 1); - console.log(`[telegram-bot-enhanced] Retry successful for history ID: ${item.id}`); - } else { - updateNotificationStatus( - item.id, - 'failed', - item.retryCount + 1, - result.error, - ); - console.error( - `[telegram-bot-enhanced] Retry failed for history ID: ${item.id}`, - ); - } - } -} - -/** - * 测试Telegram Bot配置 - */ -export async function testTelegramConfig( - botToken: string, - chatId: string, -): Promise<{ success: boolean; error?: string }> { - const testMessage = `🤖 KT财务系统\n\n✅ Telegram通知配置测试成功!\n\n🕐 ${new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}`; - - return await sendTelegramMessage(botToken, chatId, testMessage); + // Retrying logic is not yet implemented for the PostgreSQL data source. + // The SQLite-specific implementation relied on synchronous database access. + // If this functionality becomes necessary, please implement it using the + // telegram_notification_history table with pool-based transactions. + console.warn('[telegram-bot-enhanced] retryFailedNotifications is not implemented.'); } diff --git a/apps/backend/utils/telegram-bot.ts b/apps/backend/utils/telegram-bot.ts index ea988ba7..ed8c8b15 100644 --- a/apps/backend/utils/telegram-bot.ts +++ b/apps/backend/utils/telegram-bot.ts @@ -1,4 +1,4 @@ -import db from './sqlite'; +import { query } from './db'; interface TelegramNotificationConfig { id: number; @@ -24,18 +24,21 @@ interface TransactionNotificationData { /** * 获取所有启用的Telegram通知配置 */ -export function getEnabledNotificationConfigs( +export async 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(); +): Promise { + const { rows } = await query<{ + bot_token: string; + chat_id: string; + id: number; + is_enabled: boolean; + name: string; + notification_types: string; + }>( + `SELECT id, name, bot_token, chat_id, notification_types, is_enabled + FROM telegram_notification_configs + WHERE is_enabled = TRUE`, + ); return rows .map((row) => ({ @@ -44,7 +47,7 @@ export function getEnabledNotificationConfigs( botToken: row.bot_token, chatId: row.chat_id, notificationTypes: JSON.parse(row.notification_types) as string[], - isEnabled: row.is_enabled === 1, + isEnabled: row.is_enabled, })) .filter((config) => config.notificationTypes.includes(notificationType)); } @@ -175,10 +178,10 @@ export async function notifyTransaction( transaction: TransactionNotificationData, action: string = 'created', ): Promise { - const configs = getEnabledNotificationConfigs('transaction'); + const configs = await getEnabledNotificationConfigs('transaction'); if (configs.length === 0) { - console.log('[telegram-bot] No enabled notification configs found'); + console.warn('[telegram-bot] No enabled notification configs found'); return; } @@ -192,7 +195,7 @@ export async function notifyTransaction( results.forEach((result, index) => { if (result.status === 'fulfilled' && result.value) { - console.log( + console.warn( `[telegram-bot] Sent notification via config: ${configs[index].name}`, ); } else { @@ -209,17 +212,18 @@ export async function notifyTransaction( export async function testTelegramConfig( botToken: string, chatId: string, -): Promise<{ success: boolean; error?: string }> { +): Promise<{ error?: string; success: boolean }> { 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' }; - } + return success + ? { success: true } + : { + success: false, + error: '发送消息失败,请检查Bot Token和Chat ID', + }; } catch (error: unknown) { return { success: false, diff --git a/data/finance/finance-combined.csv b/data/finance/finance-combined.csv new file mode 100644 index 00000000..1dd1d0e9 --- /dev/null +++ b/data/finance/finance-combined.csv @@ -0,0 +1,658 @@ +日期,类型,分类,项目名称,金额,币种,账户 +2025-10-27,支出,🏷️ 广告推广,谷歌广告,1000,USDT,未知账户 +2025-10-27,支出,🏷️ 其他支出,爱拼才会赢 退款,273,USDT,未知账户 +2025-10-27,支出,🏷️ 其他支出,鼎胜国际退款,140,USDT,未知账户 +2025-10-24,支出,🏷️ 其他支出,买飞机票,142,USDT,未知账户 +2025-10-23,支出,🏷️ 广告推广,谷歌广告费,50,USDT,未知账户 +2025-10-22,支出,🏷️ 佣金/返佣,阿宏返佣9月,896,USDT,未知账户 +2025-10-22,支出,🏷️ 其他支出,泰国支出,30700,USDT,未知账户 +2025-10-20,支出,🏷️ 工资,煮饭阿姨工资,3000,USDT,未知账户 +2025-10-18,支出,🏷️ 服务器/技术,Open AI服务器续费预存,5000,USDT,未知账户 +2025-10-17,支出,🏷️ 服务器/技术,购买域名地址,201.61,USDT,未知账户 +2025-10-07,支出,🏷️ 工资,虚拟卡一张,11,USDT,未知账户 +2025-10-07,支出,🏷️ 分红,皇雨工资,11364,USDT,未知账户 +2025-10-07,支出,🏷️ 分红,代理ip小哥工资,994,USDT,未知账户 +2025-10-07,支出,🏷️ 分红,SY工资,4761,USDT,未知账户 +2025-10-07,支出,🏷️ 分红,菲菲,1918,USDT,未知账户 +2025-10-07,支出,🏷️ 分红,cp工资,1000,USDT,未知账户 +2025-10-07,支出,🏷️ 未分类支出,羽琦返佣,2960,USDT,未知账户 +2025-10-07,支出,🏷️ 未分类支出,666返佣,230,USDT,未知账户 +2025-10-06,支出,🏷️ 未分类支出,金返佣,815,USDT,未知账户 +2025-10-05,支出,🏷️ 工资,5张esim卡续费预充值,203,USDT,未知账户 +2025-10-04,支出,🏷️ 未分类支出,合鑫返佣,285,USDT,未知账户 +2025-10-04,支出,🏷️ 未分类支出,无名返佣,2023,USDT,未知账户 +2025-10-04,支出,🏷️ 未分类支出,胖兔返佣,3134,USDT,未知账户 +2025-10-04,支出,🏷️ 未分类支出,恋哥返佣,271,USDT,未知账户 +2025-10-04,支出,🏷️ 未分类支出,方向返佣,162,USDT,未知账户 +2025-10-03,支出,🏷️ 分红,天天工资,1500,USDT,未知账户 +2025-10-03,支出,🏷️ 分红,碧桂园工资,1000,USDT,未知账户 +2025-10-03,支出,🏷️ 分红,香缇卡工资,1122,USDT,未知账户 +2025-10-03,支出,🏷️ 分红,龙腾集团转给天天,11560,USDT,未知账户 +2025-10-03,支出,🏷️ 服务器/技术,龙腾借走,7000,USDT,未知账户 +2025-10-03,支出,🏷️ 借款/转账,泰国支出的费用,6221,USDT,未知账户 +2025-10-03,支出,🏷️ 未分类支出,杰夫返佣,1476,USDT,未知账户 +2025-10-03,支出,🏷️ 分红,天天8月报销,4649,USDT,未知账户 +2025-10-03,支出,🏷️ 分红,天天9月报销,931,USDT,未知账户 +2025-10-03,支出,🏷️ 未分类支出,国哥返佣(8月和9月),60,USDT,未知账户 +2025-10-03,支出,🏷️ 未分类支出,市场经理返佣,1388,USDT,未知账户 +2025-10-03,支出,🏷️ 未分类支出,天龙返佣,8530,USDT,未知账户 +2025-10-02,支出,🏷️ 借款/转账,后勤大叔一个半月薪资,710,USDT,未知账户 +2025-10-02,支出,🏷️ 分红,财务Amy工资,1500,USDT,未知账户 +2025-10-02,支出,🏷️ 分红,助理OAC工资,1500,USDT,未知账户 +2025-10-02,支出,🏷️ 借款/转账,小江江,500,USDT,未知账户 +2025-10-02,支出,🏷️ 借款/转账,程程,500,USDT,未知账户 +2025-10-02,支出,🏷️ 未分类支出,绿豆汤返佣,849,USDT,未知账户 +2025-10-02,支出,🏷️ 未分类支出,OAC返佣,1000,USDT,未知账户 +2025-09-30,支出,🏷️ 其他支出,合源公司退款,247,USDT,未知账户 +2025-09-30,支出,🏷️ 未分类支出,Jack帅哥返佣,536,USDT,未知账户 +2025-09-28,支出,🏷️ 其他支出,三喜团队退款,500,USDT,未知账户 +2025-09-28,支出,🏷️ 其他支出,爱拼才会赢 退款,265,USDT,未知账户 +2025-09-27,支出,🏷️ 分红,龙腾转给天天,1100,USDT,未知账户 +2025-09-25,支出,🏷️ 服务器/技术,马来西亚(龙腾月底转回来),1500,USDT,未知账户 +2025-09-23,支出,🏷️ 佣金/返佣,服务器续费,169.01,USDT,未知账户 +2025-09-20,支出,🏷️ 退款,电脑 3550*2=7100 显示器3600*7=25200 笔记本电脑 78500*1=78500 合计:110800元,15873,USDT,未知账户 +2025-09-20,支出,🏷️ 佣金/返佣,服务器续费预存,5000,USDT,未知账户 +2025-09-17,支出,🏷️ 借款/转账,买2000TRX,737.424,USDT,未知账户 +2025-09-17,支出,🏷️ 借款/转账,自动激活地址购买TRX,171,USDT,未知账户 +2025-09-12,支出,🏷️ 未分类支出,阿宏返佣,384,USDT,未知账户 +2025-09-06,支出,🏷️ 佣金/返佣,cursor,40,USDT,未知账户 +2025-09-06,支出,🏷️ 佣金/返佣,服务器59u+128u,187,USDT,未知账户 +2025-09-06,支出,🏷️ 佣金/返佣,google翻译,957,USDT,未知账户 +2025-09-06,支出,🏷️ 佣金/返佣,openrouter 210u+210u,420,USDT,未知账户 +2025-09-06,支出,🏷️ 佣金/返佣,Claude code,250,USDT,未知账户 +2025-09-06,支出,🏷️ 借款/转账,泰国支出的费用,6289,USDT,未知账户 +2025-09-06,支出,🏷️ 借款/转账,Funstat 开通镜像,4.11,USDT,未知账户 +2025-09-06,支出,🏷️ 未分类支出,666返佣,233,USDT,未知账户 +2025-09-06,支出,🏷️ 未分类支出,合鑫返佣,375,USDT,未知账户 +2025-09-06,支出,🏷️ 未分类支出,恋哥返佣,309,USDT,未知账户 +2025-09-06,支出,🏷️ 未分类支出,方向返佣,214,USDT,未知账户 +2025-09-06,支出,🏷️ 未分类支出,市场经理返佣,708,USDT,未知账户 +2025-09-06,支出,🏷️ 分红,皇雨返佣,1208,USDT,未知账户 +2025-09-06,支出,🏷️ 未分类支出,乐乐返佣,166,USDT,未知账户 +2025-09-06,支出,🏷️ 未分类支出,pt返佣,965,USDT,未知账户 +2025-09-05,支出,🏷️ 佣金/返佣,Open Ai服务器续费预存,5000,USDT,未知账户 +2025-09-05,支出,🏷️ 其他支出,星链宇宙退款,77,USDT,未知账户 +2025-09-04,支出,🏷️ 分红,超鹏工资,452,USDT,未知账户 +2025-09-04,支出,🏷️ 分红,小白工资,387,USDT,未知账户 +2025-09-04,支出,🏷️ 分红,财务Amy工资,1500,USDT,未知账户 +2025-09-04,支出,🏷️ 分红,助理OAC工资,1500,USDT,未知账户 +2025-09-04,支出,🏷️ 分红,天天工资,1500,USDT,未知账户 +2025-09-04,支出,🏷️ 分红,碧桂园工资,1000,USDT,未知账户 +2025-09-04,支出,🏷️ 分红,香缇卡工资,1122,USDT,未知账户 +2025-09-04,支出,🏷️ 借款/转账,小江江,500,USDT,未知账户 +2025-09-04,支出,🏷️ 借款/转账,程程,500,USDT,未知账户 +2025-09-04,支出,🏷️ 分红,皇雨工资,11332,USDT,未知账户 +2025-09-04,支出,🏷️ 分红,代理ip小哥工资,992,USDT,未知账户 +2025-09-04,支出,🏷️ 分红,SY工资,4750,USDT,未知账户 +2025-09-04,支出,🏷️ 未分类支出,杰夫返佣,845,USDT,未知账户 +2025-09-04,支出,🏷️ 未分类支出,老虎返佣,46,USDT,未知账户 +2025-08-31,支出,🏷️ 分红,龙腾集团转给天天,14265,USDT,未知账户 +2025-08-31,支出,🏷️ 其他支出,Jack帅哥退款,320,USDT,未知账户 +2025-08-31,支出,🏷️ 未分类支出,Jack帅哥返佣,380,USDT,未知账户 +2025-08-31,支出,🏷️ 未分类支出,绿豆汤返佣,460,USDT,未知账户 +2025-08-31,支出,🏷️ 其他支出,英才团队退款,569,USDT,未知账户 +2025-08-31,支出,🏷️ 其他支出,天一退款,21,USDT,未知账户 +2025-08-31,支出,🏷️ 未分类支出,OAC返佣,538,USDT,未知账户 +2025-08-31,支出,🏷️ 未分类支出,金返佣,470,USDT,未知账户 +2025-08-31,支出,🏷️ 未分类支出,天龙返佣,11261,USDT,未知账户 +2025-08-31,支出,🏷️ 未分类支出,胖兔返佣,3225,USDT,未知账户 +2025-08-31,支出,🏷️ 未分类支出,羽琦返佣,2620,USDT,未知账户 +2025-08-31,支出,🏷️ 未分类支出,无名返佣,2531,USDT,未知账户 +2025-08-30,支出,🏷️ 其他支出,三喜团队退款,1000,USDT,未知账户 +2025-08-28,支出,🏷️ 服务器/技术,柬埔寨出差费用,3000,USDT,未知账户 +2025-08-24,支出,🏷️ 佣金/返佣,服务器续费,169,USDT,未知账户 +2025-08-22,支出,🏷️ 其他支出,爱拼才会赢 退款,103,USDT,未知账户 +2025-08-21,支出,🏷️ 佣金/返佣,新OpenAi充值,1028,USDT,未知账户 +2025-08-14,支出,🏷️ 佣金/返佣,7月 服务器 59u + 128u,187,USDT,未知账户 +2025-08-14,支出,🏷️ 佣金/返佣,7月 google 翻译 1051u,1051,USDT,未知账户 +2025-08-14,支出,🏷️ 佣金/返佣,7月 openrouter 105u + 105u + 105u,315,USDT,未知账户 +2025-08-14,支出,🏷️ 佣金/返佣,7月 Claude code 250u,250,USDT,未知账户 +2025-08-14,支出,🏷️ 佣金/返佣,7月 cursor 131u,131,USDT,未知账户 +2025-08-14,支出,🏷️ 分红,代理ip小哥工资,990,USDT,未知账户 +2025-08-14,支出,🏷️ 分红,SY工资,4744,USDT,未知账户 +2025-08-14,支出,🏷️ 分红,皇雨工资,11316,USDT,未知账户 +2025-08-14,支出,🏷️ 分红,7月 皇雨返佣,847,USDT,未知账户 +2025-08-14,支出,🏷️ 其他支出,新阿金公司退款,232,USDT,未知账户 +2025-08-12,支出,🏷️ 服务器/技术,转给阿寒,16199,USDT,未知账户 +2025-08-10,支出,🏷️ 佣金/返佣,Open AI,5000,USDT,未知账户 +2025-08-07,支出,🏷️ 未分类支出,金返佣(6月和7月),305,USDT,未知账户 +2025-08-07,支出,🏷️ 其他支出,兰博基尼退款,76,USDT,未知账户 +2025-08-06,支出,🏷️ 未分类支出,乐乐返佣,166,USDT,未知账户 +2025-08-06,支出,🏷️ 未分类支出,阿宏返佣(6月和7月),2290,USDT,未知账户 +2025-08-06,支出,🏷️ 未分类支出,恋哥返佣,326,USDT,未知账户 +2025-08-02,支出,🏷️ 借款/转账,泰国的费用,6400,USDT,未知账户 +2025-08-02,支出,🏷️ 未分类支出,市场经理返佣,390,USDT,未知账户 +2025-08-02,支出,🏷️ 未分类支出,无名返佣,2267,USDT,未知账户 +2025-08-02,支出,🏷️ 未分类支出,兔子返佣,112,USDT,未知账户 +2025-08-01,支出,🏷️ 服务器/技术,龙腾集团,9792,USDT,未知账户 +2025-08-01,支出,🏷️ 佣金/返佣,服务器续费预存,5000,USDT,未知账户 +2025-08-01,支出,🏷️ 分红,财务Amy工资,1500,USDT,未知账户 +2025-08-01,支出,🏷️ 分红,助理OAC工资,1000,USDT,未知账户 +2025-08-01,支出,🏷️ 分红,天天工资,1500,USDT,未知账户 +2025-08-01,支出,🏷️ 分红,碧桂园工资,1000,USDT,未知账户 +2025-08-01,支出,🏷️ 分红,香缇卡工资,1122,USDT,未知账户 +2025-08-01,支出,🏷️ 其他支出,盛天退款,130,USDT,未知账户 +2025-08-01,支出,🏷️ 未分类支出,Jack帅哥佣金,1297,USDT,未知账户 +2025-08-01,支出,🏷️ 未分类支出,OAC00返佣,550,USDT,未知账户 +2025-08-01,支出,🏷️ 未分类支出,方向返佣,199,USDT,未知账户 +2025-08-01,支出,🏷️ 未分类支出,天龙返佣,10263,USDT,未知账户 +2025-08-01,支出,🏷️ 未分类支出,合鑫返佣,315,USDT,未知账户 +2025-08-01,支出,🏷️ 未分类支出,绿豆汤返佣,826,USDT,未知账户 +2025-08-01,支出,🏷️ 未分类支出,胖兔返佣,3646,USDT,未知账户 +2025-08-01,支出,🏷️ 未分类支出,羽琦返佣,2400,USDT,未知账户 +2025-08-01,支出,🏷️ 未分类支出,国哥返佣,60,USDT,未知账户 +2025-08-01,支出,🏷️ 未分类支出,杰夫返佣,1551,USDT,未知账户 +2025-07-20,支出,🏷️ 佣金/返佣,服务器续费(人民币1200),168.3,USDT,未知账户 +2025-07-16,支出,🏷️ 其他支出,左岸退款,34.7,USDT,未知账户 +2025-07-12,支出,🏷️ 佣金/返佣,Open Ai服务器续费预存,5000,USDT,未知账户 +2025-07-09,支出,🏷️ 未分类支出,方向返佣,383,USDT,未知账户 +2025-07-07,支出,🏷️ 借款/转账,鑫晟公司5月3600u ,6月2880u,6480,USDT,未知账户 +2025-07-05,支出,🏷️ 借款/转账,买trx 2000,610.2,USDT,未知账户 +2025-07-05,支出,🏷️ 分红,代理ip小哥工资,990,USDT,未知账户 +2025-07-05,支出,🏷️ 分红,SY工资,4743,USDT,未知账户 +2025-07-05,支出,🏷️ 分红,皇雨工资,11316,USDT,未知账户 +2025-07-05,支出,💰 未分类收入,蚊子分红,30000,USDT,未知账户 +2025-07-05,支出,💰 未分类收入,阿寒分红,30000,USDT,未知账户 +2025-07-04,支出,🏷️ 未分类支出,胖兔返佣,2760,USDT,未知账户 +2025-07-04,支出,🏷️ 未分类支出,羽琦返佣,2220,USDT,未知账户 +2025-07-03,支出,🏷️ 服务器/技术,龙腾集团(15077),12397,USDT,未知账户 +2025-07-03,支出,🏷️ 未分类支出,乐乐返佣,83,USDT,未知账户 +2025-07-03,支出,🏷️ 未分类支出,合鑫返佣,420,USDT,未知账户 +2025-07-01,支出,🏷️ 佣金/返佣,服务器(58u+128u),186,USDT,未知账户 +2025-07-01,支出,🏷️ 佣金/返佣,google 翻译,1032,USDT,未知账户 +2025-07-01,支出,🏷️ 佣金/返佣,openRouter 105u + 50u,155,USDT,未知账户 +2025-07-01,支出,🏷️ 佣金/返佣,Claude code 250u + 5% 手续费,262.5,USDT,未知账户 +2025-07-01,支出,🏷️ 佣金/返佣,cursor 40u + 13.8u + 3.7u,57.5,USDT,未知账户 +2025-07-01,支出,🏷️ 佣金/返佣,iphone16 pro max 工作机,1676,USDT,未知账户 +2025-07-01,支出,🏷️ 未分类支出,无名返佣,1790,USDT,未知账户 +2025-07-01,支出,🏷️ 未分类支出,恋哥返佣,366,USDT,未知账户 +2025-07-01,支出,🏷️ 未分类支出,OAC00返佣,350,USDT,未知账户 +2025-07-01,支出,🏷️ 未分类支出,天龙返佣,6293,USDT,未知账户 +2025-07-01,支出,🏷️ 未分类支出,绿豆汤返佣,723,USDT,未知账户 +2025-07-01,支出,🏷️ 分红,皇雨返佣,656,USDT,未知账户 +2025-06-30,支出,🏷️ 佣金/返佣,chatgpt 1个23u开5个,115,USDT,未知账户 +2025-06-30,支出,🏷️ 分红,天天工资,1500,USDT,未知账户 +2025-06-30,支出,🏷️ 分红,碧桂园工资,1000,USDT,未知账户 +2025-06-30,支出,🏷️ 分红,香缇卡工资,1122,USDT,未知账户 +2025-06-30,支出,🏷️ 分红,财务Amy工资,1500,USDT,未知账户 +2025-06-30,支出,🏷️ 分红,助理OAC工资,1000,USDT,未知账户 +2025-06-30,支出,🏷️ 未分类支出,Jack帅哥佣金,947,USDT,未知账户 +2025-06-30,支出,🏷️ 未分类支出,杰夫返佣,1095,USDT,未知账户 +2025-06-23,支出,🏷️ 佣金/返佣,服务器续费(人民币1200),167.59,USDT,未知账户 +2025-06-21,支出,🏷️ 其他支出,达摩团队退,390,USDT,未知账户 +2025-06-21,支出,🏷️ 佣金/返佣,服务器续费,5000,USDT,未知账户 +2025-06-16,支出,🏷️ 未分类支出,胖兔返佣,1764,USDT,未知账户 +2025-06-16,支出,🏷️ 未分类支出,羽琦返佣,1580,USDT,未知账户 +2025-06-09,支出,🏷️ 分红,皇雨买号测试软件,102,USDT,未知账户 +2025-06-09,支出,🏷️ 分红,香缇卡买号测试软件,65,USDT,未知账户 +2025-06-09,支出,🏷️ 未分类支出,阿宏返佣,1846,USDT,未知账户 +2025-06-08,支出,🏷️ 服务器/技术,测试买控的,50,USDT,未知账户 +2025-06-08,支出,🏷️ 未分类支出,金返佣,220,USDT,未知账户 +2025-06-07,支出,🏷️ 分红,皇雨工资,11300,USDT,未知账户 +2025-06-07,支出,🏷️ 分红,代理ip小哥工资,989,USDT,未知账户 +2025-06-07,支出,🏷️ 分红,SY工资,4738,USDT,未知账户 +2025-06-07,支出,🏷️ 服务器/技术,泰国房租和换现金,9290,USDT,未知账户 +2025-06-05,支出,🏷️ 佣金/返佣,openai,1250,USDT,未知账户 +2025-06-04,支出,🏷️ 分红,皇雨返佣,850,USDT,未知账户 +2025-06-04,支出,🏷️ 未分类支出,4月 小树返佣,36,USDT,未知账户 +2025-06-03,支出,🏷️ 服务器/技术,龙腾集团(计14295u)+3400=17695,14095,USDT,未知账户 +2025-06-03,支出,🏷️ 佣金/返佣,开通企业版chatgpt,1652,USDT,未知账户 +2025-06-03,支出,💰 未分类收入,蚊子分红,15000,USDT,未知账户 +2025-06-03,支出,💰 未分类收入,阿寒分红,15000,USDT,未知账户 +2025-06-02,支出,🏷️ 分红,碧桂园工资,1000,USDT,未知账户 +2025-06-02,支出,🏷️ 分红,香缇卡工资,1101,USDT,未知账户 +2025-06-02,支出,🏷️ 分红,财务Amy工资,1500,USDT,未知账户 +2025-06-02,支出,🏷️ 分红,助理OAC工资,1000,USDT,未知账户 +2025-06-02,支出,🏷️ 分红,天天工资,1500,USDT,未知账户 +2025-06-02,支出,🏷️ 佣金/返佣,google翻译接口的费用 (取整),1013,USDT,未知账户 +2025-06-02,支出,🏷️ 佣金/返佣,openRouter 充值,100,USDT,未知账户 +2025-06-02,支出,🏷️ 佣金/返佣,服务器费用,188,USDT,未知账户 +2025-06-02,支出,🏷️ 佣金/返佣,cursor费用,64,USDT,未知账户 +2025-06-02,支出,🏷️ 未分类支出,杰夫返佣,330,USDT,未知账户 +2025-06-02,支出,🏷️ 未分类支出,天龙返佣,8542,USDT,未知账户 +2025-06-02,支出,🏷️ 未分类支出,无名返佣,2103,USDT,未知账户 +2025-06-02,支出,🏷️ 未分类支出,恋哥返佣,480,USDT,未知账户 +2025-06-02,支出,🏷️ 未分类支出,OAC00返佣,586,USDT,未知账户 +2025-06-02,支出,🏷️ 未分类支出,乐乐返佣,291,USDT,未知账户 +2025-06-02,支出,🏷️ 未分类支出,国哥返佣,30,USDT,未知账户 +2025-06-02,支出,🏷️ 未分类支出,合鑫返佣,360,USDT,未知账户 +2025-06-01,支出,🏷️ 未分类支出,绿豆汤返佣,973,USDT,未知账户 +2025-05-31,支出,🏷️ 其他支出,Jack帅哥余额退,619,USDT,未知账户 +2025-05-31,支出,🏷️ 未分类支出,Jack帅哥返佣,1033,USDT,未知账户 +2025-05-24,支出,🏷️ 服务器/技术,投资款,20000,USDT,未知账户 +2025-05-21,支出,🏷️ 服务器/技术,小树保关,165,USDT,未知账户 +2025-05-21,支出,🏷️ 佣金/返佣,服务器续费(人民币1200),167,USDT,未知账户 +2025-05-17,支出,🏷️ 佣金/返佣,硅基流动 ai 重排序接口充值 2000人民币,278,USDT,未知账户 +2025-05-13,支出,🏷️ 未分类支出,羽琦返佣(3月 1395u+4月 1260u),2655,USDT,未知账户 +2025-05-13,支出,🏷️ 未分类支出,金返佣,200,USDT,未知账户 +2025-05-12,支出,🏷️ 佣金/返佣,服务器续费,5000,USDT,未知账户 +2025-05-09,支出,🏷️ 借款/转账,换泰珠,3058,USDT,未知账户 +2025-05-08,支出,🏷️ 分红,天天返佣,2191.5,USDT,未知账户 +2025-05-08,支出,🏷️ 分红,天天投流报销,1488,USDT,未知账户 +2025-05-08,支出,🏷️ 未分类支出,胖兔返佣,2062.5,USDT,未知账户 +2025-05-05,支出,🏷️ 其他支出,KM 退款,71,USDT,未知账户 +2025-05-04,支出,🏷️ 未分类支出,合鑫返佣,435,USDT,未知账户 +2025-05-04,支出,🏷️ 未分类支出,乐乐返佣,177,USDT,未知账户 +2025-05-03,支出,🏷️ 工资,cloudflare 防火墙,165.5,USDT,未知账户 +2025-05-03,支出,🏷️ 佣金/返佣,chatgpt pro,200,USDT,未知账户 +2025-05-03,支出,🏷️ 佣金/返佣,cursor,320,USDT,未知账户 +2025-05-03,支出,🏷️ 佣金/返佣,openrouter,121,USDT,未知账户 +2025-05-03,支出,🏷️ 佣金/返佣,bolt.new,500,USDT,未知账户 +2025-05-03,支出,🏷️ 佣金/返佣,openai,911.8,USDT,未知账户 +2025-05-03,支出,🏷️ 工资,tg会员,36,USDT,未知账户 +2025-05-03,支出,🏷️ 分红,chatwoot客服,19,USDT,未知账户 +2025-05-03,支出,🏷️ 工资,uizard,19,USDT,未知账户 +2025-05-03,支出,💰 未分类收入,蚊子分红,20000,USDT,未知账户 +2025-05-03,支出,💰 未分类收入,阿寒分红,20000,USDT,未知账户 +2025-05-02,支出,🏷️ 服务器/技术,租办公室,500,USDT,未知账户 +2025-05-02,支出,🏷️ 分红,SY工资,4127,USDT,未知账户 +2025-05-02,支出,🏷️ 分红,皇雨工资,11005,USDT,未知账户 +2025-05-02,支出,🏷️ 分红,代理ip小哥工资,963,USDT,未知账户 +2025-05-02,支出,🏷️ 佣金/返佣,google 翻译接口,903,USDT,未知账户 +2025-05-02,支出,🏷️ 分红,碧桂园工资,1000,USDT,未知账户 +2025-05-02,支出,🏷️ 分红,香缇卡工资,1101,USDT,未知账户 +2025-05-02,支出,🏷️ 分红,财务Amy工资,1500,USDT,未知账户 +2025-05-02,支出,🏷️ 分红,助理OAC工资,1000,USDT,未知账户 +2025-05-02,支出,🏷️ 分红,天天工资,1500,USDT,未知账户 +2025-05-02,支出,🏷️ 未分类支出,绿豆汤返佣,540,USDT,未知账户 +2025-05-02,支出,🏷️ 未分类支出,国哥返佣,60,USDT,未知账户 +2025-05-02,支出,🏷️ 未分类支出,杰夫返佣,778,USDT,未知账户 +2025-05-02,支出,🏷️ 未分类支出,恋哥返佣,273,USDT,未知账户 +2025-05-02,支出,🏷️ 未分类支出,天龙返佣,10531,USDT,未知账户 +2025-05-02,支出,🏷️ 未分类支出,无名返佣,1819,USDT,未知账户 +2025-05-02,支出,🏷️ 未分类支出,OAC00返佣,744,USDT,未知账户 +2025-05-02,支出,🏷️ 分红,皇雨返佣,91,USDT,未知账户 +2025-05-02,支出,🏷️ 未分类支出,杰夫返佣,778,USDT,未知账户 +2025-05-01,支出,🏷️ 未分类支出,Jack帅哥返佣,1111,USDT,未知账户 +2025-04-30,支出,🏷️ 借款/转账,买trx 2000,531,USDT,未知账户 +2025-04-30,支出,🏷️ 固定资产,打流量,500,USDT,未知账户 +2025-04-28,支出,🏷️ 服务器/技术,泰国生活换泰铢,6033,USDT,未知账户 +2025-04-25,支出,🏷️ 服务器/技术,做 whatsapp 云控测试的,110,USDT,未知账户 +2025-04-25,支出,🏷️ 其他支出,啊Q(meidusha001)退款,150,USDT,未知账户 +2025-04-22,支出,🏷️ 佣金/返佣,服务器续费(人民币1200),165,USDT,未知账户 +2025-04-20,支出,🏷️ 分红,阿寒 皇雨 碧桂园 天天 4个人会员续费,120,USDT,未知账户 +2025-04-19,支出,🏷️ 其他支出,致胜退款,184,USDT,未知账户 +2025-04-14,支出,🏷️ 未分类支出,金返佣,267,USDT,未知账户 +2025-04-13,支出,🏷️ 分红,皇雨返佣,1080,USDT,未知账户 +2025-04-11,支出,🏷️ 佣金/返佣,服务器续费和防护扣款,5000,USDT,未知账户 +2025-04-11,支出,🏷️ 服务器/技术,换美金,448,USDT,未知账户 +2025-04-10,支出,🏷️ 服务器/技术,保关,360,USDT,未知账户 +2025-04-07,支出,🏷️ 服务器/技术,泰国换泰铢,5874,USDT,未知账户 +2025-04-07,支出,🏷️ 工资,esim plus 手机号续费预充值,204,USDT,未知账户 +2025-04-07,支出,🏷️ 未分类支出,恋哥返佣,293,USDT,未知账户 +2025-04-03,支出,🏷️ 分红,碧桂园工资,1000,USDT,未知账户 +2025-04-03,支出,🏷️ 分红,香缇卡工资,1101,USDT,未知账户 +2025-04-03,支出,🏷️ 未分类支出,杰夫返佣,390,USDT,未知账户 +2025-04-03,支出,🏷️ 分红,天天返佣,1492,USDT,未知账户 +2025-04-03,支出,💰 未分类收入,紫气东来充值(1899)5%分红,95,USDT,未知账户 +2025-04-01,支出,🏷️ 分红,财务Amy工资,1500,USDT,未知账户 +2025-04-01,支出,🏷️ 分红,助理OAC工资,1000,USDT,未知账户 +2025-04-01,支出,🏷️ 退款,路由器费用(硬件+物流),53,USDT,未知账户 +2025-04-01,支出,🏷️ 佣金/返佣,google翻译接口的费用,973,USDT,未知账户 +2025-04-01,支出,🏷️ 佣金/返佣,openRouter 充值,106,USDT,未知账户 +2025-04-01,支出,🏷️ 佣金/返佣,2个服务器费用,188,USDT,未知账户 +2025-04-01,支出,🏷️ 分红,SY工资,4110,USDT,未知账户 +2025-04-01,支出,🏷️ 分红,皇雨工资,10959,USDT,未知账户 +2025-04-01,支出,🏷️ 分红,代理ip小哥工资,959,USDT,未知账户 +2025-04-01,支出,🏷️ 服务器/技术,租办公室,500,USDT,未知账户 +2025-04-01,支出,🏷️ 借款/转账,买trx 2000,510,USDT,未知账户 +2025-04-01,支出,🏷️ 分红,天天工资,1500,USDT,未知账户 +2025-04-01,支出,🏷️ 分红,龙腾集团费用转给天天,16867,USDT,未知账户 +2025-04-01,支出,🏷️ 未分类支出,Jack帅哥返佣,725,USDT,未知账户 +2025-04-01,支出,🏷️ 未分类支出,天龙返佣,11398,USDT,未知账户 +2025-04-01,支出,🏷️ 未分类支出,闲聊返佣,420,USDT,未知账户 +2025-04-01,支出,🏷️ 未分类支出,OAC00返佣,229,USDT,未知账户 +2025-04-01,支出,🏷️ 未分类支出,无名返佣,1787,USDT,未知账户 +2025-04-01,支出,🏷️ 未分类支出,绿豆汤返佣,371,USDT,未知账户 +2025-04-01,支出,💰 未分类收入,蚊子分红,5520,USDT,未知账户 +2025-04-01,支出,💰 未分类收入,阿寒分红,5520,USDT,未知账户 +2025-04-01,支出,🏷️ 未分类支出,合鑫返佣,330,USDT,未知账户 +2025-04-01,支出,🏷️ 未分类支出,A Feng 返佣,100,USDT,未知账户 +2025-04-01,支出,🏷️ 未分类支出,胖兔返佣,2659,USDT,未知账户 +2025-04-01,支出,🏷️ 未分类支出,乐乐返佣,151,USDT,未知账户 +2025-03-29,支出,🏷️ 借款/转账,机器人续费,19,USDT,未知账户 +2025-03-29,支出,💰 未分类收入,蚊子分红,10000,USDT,未知账户 +2025-03-29,支出,💰 未分类收入,阿寒分红,10000,USDT,未知账户 +2025-03-28,支出,🏷️ 佣金/返佣,阿里云主服务器,2100,USDT,未知账户 +2025-03-28,支出,🏷️ 分红,皇雨买 007 测试系统,110,USDT,未知账户 +2025-03-23,支出,🏷️ 佣金/返佣,服务器续费(人民币1200),165.28,USDT,未知账户 +2025-03-18,支出,🏷️ 其他支出,老莫8688 退款,44,USDT,未知账户 +2025-03-18,支出,🏷️ 其他支出,月入10w美金 退款,480,USDT,未知账户 +2025-03-12,支出,🏷️ 佣金/返佣,11月 open Ai 接口费用,1500,USDT,未知账户 +2025-03-12,支出,🏷️ 佣金/返佣,2月 open Ai 接口费用,1163,USDT,未知账户 +2025-03-12,支出,🏷️ 佣金/返佣,3月 open Ai 接口费用,713,USDT,未知账户 +2025-03-12,支出,🏷️ 未分类支出,乐乐返佣,120,USDT,未知账户 +2025-03-12,支出,🏷️ 分红,天天紫气东来分红5%,114,USDT,未知账户 +2025-03-12,支出,🏷️ 未分类支出,1月阿宏返佣,1213,USDT,未知账户 +2025-03-12,支出,🏷️ 未分类支出,2月阿宏返佣,480,USDT,未知账户 +2025-03-08,支出,🏷️ 未分类支出,金返佣,200,USDT,未知账户 +2025-03-06,支出,🏷️ 未分类支出,合鑫返佣,315,USDT,未知账户 +2025-03-04,支出,🏷️ 分红,龙腾集团费用转给天天,16321,USDT,未知账户 +2025-03-03,支出,🏷️ 未分类支出,羽琦返佣,783,USDT,未知账户 +2025-03-02,支出,🏷️ 佣金/返佣,服务器两台,188,USDT,未知账户 +2025-03-02,支出,🏷️ 佣金/返佣,google 翻译api的 费用 截止到 2025.02.28,1074,USDT,未知账户 +2025-03-02,支出,🏷️ 佣金/返佣,openrouter 充值,60,USDT,未知账户 +2025-03-02,支出,🏷️ 佣金/返佣,deepseek,52,USDT,未知账户 +2025-03-02,支出,🏷️ 服务器/技术,租办公室,500,USDT,未知账户 +2025-03-02,支出,🏷️ 分红,碧桂园工资,1000,USDT,未知账户 +2025-03-02,支出,🏷️ 分红,香缇卡工资,1102,USDT,未知账户 +2025-03-02,支出,🏷️ 分红,财务Amy工资,1500,USDT,未知账户 +2025-03-02,支出,🏷️ 分红,助理OAC工资,786,USDT,未知账户 +2025-03-02,支出,🏷️ 分红,天天工资,1500,USDT,未知账户 +2025-03-02,支出,🏷️ 分红,SY工资,2032,USDT,未知账户 +2025-03-02,支出,🏷️ 分红,皇雨工资,8517,USDT,未知账户 +2025-03-02,支出,🏷️ 分红,代理ip小哥工资,746,USDT,未知账户 +2025-03-02,支出,🏷️ 佣金/返佣,广州技术,733,USDT,未知账户 +2025-03-02,支出,🏷️ 未分类支出,绿豆汤返佣,700,USDT,未知账户 +2025-03-01,支出,🏷️ 借款/转账,买trx 2000,502,USDT,未知账户 +2025-03-01,支出,🏷️ 工资,小红卡续费余额不足,400,USDT,未知账户 +2025-03-01,支出,🏷️ 未分类支出,闲聊返佣,330,USDT,未知账户 +2025-03-01,支出,🏷️ 未分类支出,国哥返佣,150,USDT,未知账户 +2025-03-01,支出,🏷️ 未分类支出,胖兔返佣,3129,USDT,未知账户 +2025-03-01,支出,🏷️ 未分类支出,OAC00返佣,157,USDT,未知账户 +2025-03-01,支出,🏷️ 未分类支出,无名返佣,1146,USDT,未知账户 +2025-03-01,支出,🏷️ 未分类支出,辞辞返佣,181,USDT,未知账户 +2025-03-01,支出,🏷️ 未分类支出,恋哥返佣,440,USDT,未知账户 +2025-03-01,支出,💰 未分类收入,蚊子分红,30000,USDT,未知账户 +2025-03-01,支出,💰 未分类收入,阿寒分红,30000,USDT,未知账户 +2025-02-28,支出,🏷️ 借款/转账,A Feng,110,USDT,未知账户 +2025-02-28,支出,🏷️ 未分类支出,Jack帅哥返佣,1276,USDT,未知账户 +2025-02-28,支出,🏷️ 未分类支出,天龙返佣,9757,USDT,未知账户 +2025-02-26,支出,🏷️ 工资,小红卡买虚拟卡,10,USDT,未知账户 +2025-02-26,支出,🏷️ 佣金/返佣,阿里云主服务器,1980,USDT,未知账户 +2025-02-23,支出,🏷️ 佣金/返佣,服务器续费,165,USDT,未知账户 +2025-02-18,支出,🏷️ 其他支出,一路发 多充退款,2500,USDT,未知账户 +2025-02-15,支出,🏷️ 服务器/技术,转给阿寒在泰国租房等等,8982,USDT,未知账户 +2025-02-15,支出,🏷️ 未分类支出,A Feng 1月返佣,158,USDT,未知账户 +2025-02-15,支出,🏷️ 其他支出,众彩公司退款,52,USDT,未知账户 +2025-02-11,支出,🏷️ 佣金/返佣,1月open Ai费用,782,USDT,未知账户 +2025-02-11,支出,🏷️ 未分类支出,乐乐1月返佣,200,USDT,未知账户 +2025-02-10,支出,🏷️ 佣金/返佣,10月服务器续费,874.47,USDT,未知账户 +2025-02-10,支出,🏷️ 佣金/返佣,11月服务器续费,923.42,USDT,未知账户 +2025-02-10,支出,🏷️ 佣金/返佣,12月服务器续费,936.34,USDT,未知账户 +2025-02-10,支出,🏷️ 佣金/返佣,2025年1月服务器续费,956,USDT,未知账户 +2025-02-10,支出,🏷️ 分红,1月chatwoot 客服,57,USDT,未知账户 +2025-02-10,支出,🏷️ 分红,2月chatwoot 客服,57,USDT,未知账户 +2025-02-10,支出,🏷️ 佣金/返佣,bolt.new ai 写代码套餐开通,181.7,USDT,未知账户 +2025-02-10,支出,🏷️ 退款,三星硬盘(1579rmb),215.4,USDT,未知账户 +2025-02-10,支出,🏷️ 退款,西部数据企业级氦气硬盘(12732rmb),1737,USDT,未知账户 +2025-02-10,支出,🏷️ 退款,绿联DXP8800Pro云硬盘(7039.72rmb),960.4,USDT,未知账户 +2025-02-10,支出,🏷️ 分红,香缇卡笔记本电脑(9436.49rmb),1287.4,USDT,未知账户 +2025-02-10,支出,🏷️ 佣金/返佣,双路渲染服务器40核(10499rmb),1432.3,USDT,未知账户 +2025-02-10,支出,🏷️ 借款/转账,A Feng补12月漏,50,USDT,未知账户 +2025-02-10,支出,🏷️ 借款/转账,辞辞补1月漏,203,USDT,未知账户 +2025-02-10,支出,🏷️ 未分类支出,羽琦返佣,770,USDT,未知账户 +2025-02-10,支出,🏷️ 未分类支出,绿豆汤返佣,520,USDT,未知账户 +2025-02-07,支出,🏷️ 分红,天天开工红包,257,USDT,未知账户 +2025-02-07,支出,🏷️ 分红,碧桂园开工红包,257,USDT,未知账户 +2025-02-07,支出,🏷️ 分红,香缇卡开工红包,257,USDT,未知账户 +2025-02-07,支出,🏷️ 分红,财务amy开工红包,257,USDT,未知账户 +2025-02-07,支出,🏷️ 服务器/技术,助理oac开工红包,257,USDT,未知账户 +2025-02-07,支出,🏷️ 分红,代理小哥开工红包,257,USDT,未知账户 +2025-02-07,支出,🏷️ 分红,皇雨开工红包,529,USDT,未知账户 +2025-02-06,支出,🏷️ 佣金/返佣,服务器临时配置升级 充值,1000,USDT,未知账户 +2025-02-05,支出,🏷️ 未分类支出,合鑫返佣,600,USDT,未知账户 +2025-02-04,支出,🏷️ 其他支出,众发退款,900,USDT,未知账户 +2025-02-04,支出,🏷️ 未分类支出,无名返佣,752,USDT,未知账户 +2025-02-04,支出,🏷️ 未分类支出,闲聊返佣,763,USDT,未知账户 +2025-02-03,支出,🏷️ 未分类支出,辞辞返佣,1033,USDT,未知账户 +2025-02-03,支出,🏷️ 分红,天天紫气东来分红5%,186,USDT,未知账户 +2025-02-02,支出,🏷️ 服务器/技术,龙腾集团,4202,USDT,未知账户 +2025-02-02,支出,🏷️ 未分类支出,胖兔返佣,2128,USDT,未知账户 +2025-02-02,支出,🏷️ 其他支出,启运退款,88,USDT,未知账户 +2025-02-01,支出,🏷️ 未分类支出,天龙返佣,5632,USDT,未知账户 +2025-01-28,支出,🏷️ 未分类支出,Jack帅哥返佣,723,USDT,未知账户 +2025-01-25,支出,🏷️ 佣金/返佣,xiaohai0000 鸿图,20,USDT,未知账户 +2025-01-24,支出,🏷️ 分红,amy买香港信用卡虚拟卡,10,USDT,未知账户 +2025-01-24,支出,🏷️ 服务器/技术,蚊子 阿寒在泰国两人生活费,2497,USDT,未知账户 +2025-01-24,支出,💰 未分类收入,蚊子分红,10000,USDT,未知账户 +2025-01-24,支出,💰 未分类收入,阿寒分红,10000,USDT,未知账户 +2025-01-21,支出,🏷️ 分红,皇雨工资5517 年终奖5517,11034,USDT,未知账户 +2025-01-21,支出,🏷️ 分红,代理ip 小哥工资965 年终奖965,1930,USDT,未知账户 +2025-01-21,支出,🏷️ 分红,天天工资1500年终奖750,2250,USDT,未知账户 +2025-01-21,支出,🏷️ 分红,碧桂园工资1000 年终奖500,1500,USDT,未知账户 +2025-01-21,支出,🏷️ 分红,香缇卡工资1103年终奖552,1655,USDT,未知账户 +2025-01-21,支出,🏷️ 分红,财务amy 1500年终奖750,2250,USDT,未知账户 +2025-01-21,支出,🏷️ 分红,助理OAC工资1000年终奖500,1500,USDT,未知账户 +2025-01-21,支出,🏷️ 佣金/返佣,服务器续费,165.97,USDT,未知账户 +2025-01-18,支出,🏷️ 借款/转账,买trx 2000,527.904,USDT,未知账户 +2025-01-15,支出,🏷️ 分红,转给香缇卡买账户备用金,300,USDT,未知账户 +2025-01-14,支出,🏷️ 分红,香缇卡买小红卡,50,USDT,未知账户 +2025-01-14,支出,🏷️ 工资,OAC买小红卡实体卡,100,USDT,未知账户 +2025-01-13,支出,🏷️ 分红,amy买小红卡,50,USDT,未知账户 +2025-01-11,支出,🏷️ 分红,小哥两个月开发费用,1000,USDT,未知账户 +2025-01-11,支出,🏷️ 借款/转账,老表对接,300,USDT,未知账户 +2025-01-09,支出,🏷️ 分红,转给香缇卡买账户备用金,200,USDT,未知账户 +2025-01-08,支出,🏷️ 退款,泰国买车,39358.6,USDT,未知账户 +2025-01-03,支出,🏷️ 分红,转给天天,9000,USDT,未知账户 +2025-01-01,支出,🏷️ 借款/转账,合鑫,570,USDT,未知账户 +2025-01-01,支出,🏷️ 借款/转账,金鑫,26,USDT,未知账户 +2025-01-01,支出,🏷️ 佣金/返佣,xiaohai0000 鸿图,380,USDT,未知账户 +2025-01-01,支出,🏷️ 借款/转账,阿宏11月 1147Uu+12月1892,3039,USDT,未知账户 +2025-01-01,支出,💰 未分类收入,蚊子分红,10000,USDT,未知账户 +2025-01-01,支出,💰 未分类收入,阿寒分红,10000,USDT,未知账户 +2025-01-01,支出,🏷️ 未分类支出,A Feng返佣,180,USDT,未知账户 +2025-01-01,支出,🏷️ 未分类支出,绿豆汤返佣,500,USDT,未知账户 +2025-01-01,支出,🏷️ 未分类支出,天龙返佣,8795,USDT,未知账户 +2025-01-01,支出,🏷️ 未分类支出,胖兔返佣,2622.7,USDT,未知账户 +2025-01-01,支出,🏷️ 未分类支出,无名返佣,800,USDT,未知账户 +2025-01-01,支出,🏷️ 未分类支出,闲聊返佣,1043,USDT,未知账户 +2025-01-01,支出,🏷️ 分红,天天散户分红,198,USDT,未知账户 +2025-01-01,支出,🏷️ 未分类支出,乐乐返佣,414,USDT,未知账户 +2025-01-01,支出,🏷️ 未分类支出,知青返佣,135,USDT,未知账户 +2025-01-01,支出,🏷️ 未分类支出,恋哥返佣,526.7,USDT,未知账户 +2025-01-01,支出,🏷️ 未分类支出,长青返佣,77,USDT,未知账户 +2025-01-01,支出,🏷️ 未分类支出,国哥返佣,150,USDT,未知账户 +2025-01-01,支出,🏷️ 未分类支出,羽琦返佣,419,USDT,未知账户 +2024-12-31,支出,🏷️ 分红,皇雨工资 代理ip小哥 服务器续费128,6638,USDT,未知账户 +2024-12-31,支出,🏷️ 分红,天天工资,1500,USDT,未知账户 +2024-12-31,支出,🏷️ 分红,碧桂园工资,1000,USDT,未知账户 +2024-12-31,支出,🏷️ 分红,香缇卡工资,1000,USDT,未知账户 +2024-12-31,支出,🏷️ 分红,财务Amy工资,1500,USDT,未知账户 +2024-12-31,支出,🏷️ 分红,助理OAC工资,1000,USDT,未知账户 +2024-12-31,支出,💰 未分类收入,蚊子分红,20000,USDT,未知账户 +2024-12-31,支出,💰 未分类收入,阿寒分红,20000,USDT,未知账户 +2024-12-31,支出,🏷️ 未分类支出,Jack帅哥返佣,842,USDT,未知账户 +2024-12-31,支出,🏷️ 其他支出,金鑫 退款,800,USDT,未知账户 +2024-12-30,支出,🏷️ 其他支出,金鑫退款,800,USDT,未知账户 +2024-12-21,支出,🏷️ 服务器/技术,转龙腾,3000,USDT,未知账户 +2024-12-21,支出,🏷️ 佣金/返佣,服务器续费,164,USDT,未知账户 +2024-12-19,支出,🏷️ 分红,转给天天,7000,USDT,未知账户 +2024-12-15,支出,💰 未分类收入,蚊子分红,20000,USDT,未知账户 +2024-12-15,支出,💰 未分类收入,阿寒分红,20000,USDT,未知账户 +2024-12-06,支出,🏷️ 分红,转给天天,5000,USDT,未知账户 +2024-12-06,支出,🏷️ 退款,硬盘费用,1769,USDT,未知账户 +2024-12-01,支出,🏷️ 分红,香缇卡工资,1000,USDT,未知账户 +2024-12-01,支出,🏷️ 分红,财务Amy工资,1500,USDT,未知账户 +2024-12-01,支出,🏷️ 分红,助理OAC工资,1000,USDT,未知账户 +2024-12-01,支出,🏷️ 服务器/技术,龙腾集团鑫晟公司,6102,USDT,未知账户 +2024-12-01,支出,🏷️ 分红,皇雨5579 代理ip小哥976,6555,USDT,未知账户 +2024-12-01,支出,🏷️ 分红,天天工资,1500,USDT,未知账户 +2024-12-01,支出,🏷️ 分红,碧桂园工资,1000,USDT,未知账户 +2024-12-01,支出,🏷️ 分红,龙腾集团转给天天,6073,USDT,未知账户 +2024-12-01,支出,🏷️ 佣金/返佣,服务器续费专用小红卡,50,USDT,未知账户 +2024-12-01,支出,🏷️ 分红,天天散户,172,USDT,未知账户 +2024-12-01,支出,🏷️ 佣金/返佣,服务器续费2个月,256,USDT,未知账户 +2024-12-01,支出,🏷️ 佣金/返佣,流量测试服务器,500,USDT,未知账户 +2024-12-01,支出,🏷️ 退款,展示屏,805,USDT,未知账户 +2024-12-01,支出,🏷️ 佣金/返佣,openai 12 月份接口费用,1768,USDT,未知账户 +2024-12-01,支出,🏷️ 未分类支出,天龙返佣,9738,USDT,未知账户 +2024-12-01,支出,🏷️ 未分类支出,绿豆汤返佣,500,USDT,未知账户 +2024-12-01,支出,🏷️ 未分类支出,貔貅返佣,300,USDT,未知账户 +2024-12-01,支出,🏷️ 未分类支出,恋哥返佣(713+367补10月),1080,USDT,未知账户 +2024-12-01,支出,🏷️ 未分类支出,无名返佣(1107+635),1742,USDT,未知账户 +2024-12-01,支出,🏷️ 未分类支出,知青返佣,768,USDT,未知账户 +2024-12-01,支出,🏷️ 未分类支出,乐乐返佣,494,USDT,未知账户 +2024-12-01,支出,🏷️ 未分类支出,合鑫返佣,615,USDT,未知账户 +2024-12-01,支出,🏷️ 未分类支出,胖兔返佣,2601,USDT,未知账户 +2024-11-30,支出,🏷️ 未分类支出,Jack帅哥返佣,649,USDT,未知账户 +2024-11-29,支出,🏷️ 工资,开飞机会员,38,USDT,未知账户 +2024-11-16,支出,🏷️ 借款/转账,3000 trx自动归集的手续费购买609.444 usdt,609.444,USDT,未知账户 +2024-11-15,支出,🏷️ 佣金/返佣,oac 40 ai机器人40,80,USDT,未知账户 +2024-11-10,支出,🏷️ 借款/转账,买trx,85,USDT,未知账户 +2024-11-10,支出,🏷️ 佣金/返佣,人工智能接口费用,1590,USDT,未知账户 +2024-11-10,支出,🏷️ 佣金/返佣,服务器费用,1326,USDT,未知账户 +2024-11-10,支出,🏷️ 未分类支出,阿宏佣金,2505,USDT,未知账户 +2024-11-10,支出,🏷️ 借款/转账,买自动到账地址(用于质押获得手续费),2000,USDT,未知账户 +2024-11-07,支出,🏷️ 服务器/技术,投资款项(6006+32934),38940,USDT,未知账户 +2024-11-05,支出,🏷️ 分红,转给啊寒3000美金用于天天买电脑,3000,USDT,未知账户 +2024-11-05,支出,🏷️ 分红,给财务买苹果电脑,1499,USDT,未知账户 +2024-11-05,支出,🏷️ 退款,蚊子工作苹果电脑,8433,USDT,未知账户 +2024-11-04,支出,🏷️ 未分类支出,大白菜佣金,1290.5,USDT,未知账户 +2024-11-04,支出,🏷️ 未分类支出,胖兔佣金,2942,USDT,未知账户 +2024-11-04,支出,🏷️ 未分类支出,恋哥佣金,660,USDT,未知账户 +2024-11-02,支出,🏷️ 未分类支出,貔貅佣金,900,USDT,未知账户 +2024-11-01,支出,🏷️ 借款/转账,买trx,700,USDT,未知账户 +2024-11-01,支出,🏷️ 分红,龙腾集团转给天天,9797,USDT,未知账户 +2024-11-01,支出,🏷️ 分红,天天虚拟信用卡,50,USDT,未知账户 +2024-11-01,支出,🏷️ 未分类支出,乐乐佣金,225,USDT,未知账户 +2024-11-01,支出,🏷️ 未分类支出,国哥佣金,450,USDT,未知账户 +2024-10-31,支出,🏷️ 分红,代理ip小哥,1000,USDT,未知账户 +2024-10-31,支出,🏷️ 分红,黄雨工资,5673,USDT,未知账户 +2024-10-31,支出,🏷️ 分红,财务,1500,USDT,未知账户 +2024-10-31,支出,🏷️ 分红,天天,1500,USDT,未知账户 +2024-10-31,支出,🏷️ 分红,碧桂园,1000,USDT,未知账户 +2024-10-31,支出,🏷️ 借款/转账,卡卡提,500,USDT,未知账户 +2024-10-31,支出,🏷️ 未分类支出,天龙佣金,9292,USDT,未知账户 +2024-10-31,支出,🏷️ 未分类支出,核心佣金,2349,USDT,未知账户 +2024-10-31,支出,💰 未分类收入,蚊子分红,10000,USDT,未知账户 +2024-10-31,支出,💰 未分类收入,阿寒分红,10000,USDT,未知账户 +2024-10-31,支出,🏷️ 未分类支出,知青佣金,818,USDT,未知账户 +2024-10-31,支出,🏷️ 未分类支出,合鑫佣金,420,USDT,未知账户 +2024-10-31,支出,🏷️ 分红,天天 紫气东来散户分红5%,146,USDT,未知账户 +2024-10-30,支出,🏷️ 未分类支出,Jack帅哥佣金,700,USDT,未知账户 +2024-10-28,支出,💰 未分类收入,阿寒分红,1000,USDT,未知账户 +2024-10-28,支出,💰 未分类收入,蚊子分红,1000,USDT,未知账户 +2024-10-25,支出,🏷️ 其他支出,七月退,100,USDT,未知账户 +2024-10-22,支出,🏷️ 服务器/技术,接待,3000,USDT,未知账户 +2024-10-19,支出,🏷️ 工资,2t u盘两个。每个203u,406,USDT,未知账户 +2024-10-19,支出,💰 未分类收入,阿寒分红,3000,USDT,未知账户 +2024-10-19,支出,💰 未分类收入,蚊子分红,3000,USDT,未知账户 +2024-10-16,支出,🏷️ 工资,007购买,125,USDT,未知账户 +2024-10-11,支出,🏷️ 未分类支出,大卫佣金,1875,USDT,未知账户 +2024-10-09,支出,🏷️ 借款/转账,购买 trx质押产生能量,2000,USDT,未知账户 +2024-10-09,支出,🏷️ 固定资产,汉城广告费,1000,USDT,未知账户 +2024-10-06,支出,🏷️ 其他支出,大秦退费,310,USDT,未知账户 +2024-10-05,支出,🏷️ 佣金/返佣,技术公司鸿泰,1768,USDT,未知账户 +2024-10-05,支出,🏷️ 佣金/返佣,ChatGPT自建服务器半年付,479.88,USDT,未知账户 +2024-10-05,支出,🏷️ 借款/转账,交友五个阶段提示词编写和优化外包,700,USDT,未知账户 +2024-10-04,支出,🏷️ 佣金/返佣,ChatGPT接口费用,869,USDT,未知账户 +2024-10-04,支出,🏷️ 佣金/返佣,备用OpenAI预充值,200,USDT,未知账户 +2024-10-04,支出,🏷️ 佣金/返佣,备用转发接口充值,100,USDT,未知账户 +2024-10-04,支出,🏷️ 佣金/返佣,Kt主服务器。分流服务器。自动到账服务器。oss服务器,1143,USDT,未知账户 +2024-10-04,支出,🏷️ 未分类支出,胖兔佣金,2821,USDT,未知账户 +2024-10-03,支出,🏷️ 分红,皇工资35000rmb,5022,USDT,未知账户 +2024-10-03,支出,🏷️ 分红,天天工资,1500,USDT,未知账户 +2024-10-03,支出,🏷️ 分红,碧桂园,1000,USDT,未知账户 +2024-10-03,支出,🏷️ 分红,amy,1500,USDT,未知账户 +2024-10-03,支出,🏷️ 分红,代理ip小哥,1000,USDT,未知账户 +2024-10-03,支出,🏷️ 借款/转账,截图,100,USDT,未知账户 +2024-10-03,支出,🏷️ 佣金/返佣,技术公司,3815,USDT,未知账户 +2024-10-03,支出,🏷️ 未分类支出,乐乐佣金,483,USDT,未知账户 +2024-10-03,支出,💰 未分类收入,蚊子分红,593,USDT,未知账户 +2024-10-02,支出,🏷️ 未分类支出,长青佣金,240,USDT,未知账户 +2024-10-02,支出,🏷️ 未分类支出,合鑫佣金,450,USDT,未知账户 +2024-10-01,支出,🏷️ 未分类支出,天龙佣金,5815,USDT,未知账户 +2024-10-01,支出,🏷️ 未分类支出,核心佣金,2413,USDT,未知账户 +2024-10-01,支出,🏷️ 未分类支出,三七公司佣金,189,USDT,未知账户 +2024-09-26,支出,🏷️ 分红,天天控天费用,593,USDT,未知账户 +2024-09-26,支出,🏷️ 借款/转账,自动到账购买2000trx,329,USDT,未知账户 +2024-09-25,支出,🏷️ 固定资产,亚太地推开支,1550,USDT,未知账户 +2024-09-24,支出,🏷️ 固定资产,亚太小助手投放,1500,USDT,未知账户 +2024-09-22,支出,🏷️ 工资,processon流程图终身会员,185,USDT,未知账户 +2024-09-21,支出,🏷️ 佣金/返佣,服务器续费,1210,USDT,未知账户 +2024-09-20,支出,🏷️ 借款/转账,截图制作,338,USDT,未知账户 +2024-09-19,支出,🏷️ 未分类支出,Jack帅哥佣金,276,USDT,未知账户 +2024-09-18,支出,🏷️ 固定资产,广告费用,450,USDT,未知账户 +2024-09-18,支出,🏷️ 工资,开飞机会员,38,USDT,未知账户 +2024-09-13,支出,🏷️ 服务器/技术,阿鹏借出35000rmb,4943.5,USDT,未知账户 +2024-09-13,支出,🏷️ 退款,rog电脑购买,5659,USDT,未知账户 +2024-09-11,支出,🏷️ 未分类支出,羽琦佣金,154,USDT,未知账户 +2024-09-10,支出,🏷️ 固定资产,广告费,1000,USDT,未知账户 +2024-09-03,支出,🏷️ 其他支出,大秦退费,300,USDT,未知账户 +2024-09-03,支出,🏷️ 工资,飞机会员续费,35,USDT,未知账户 +2024-09-01,支出,🏷️ 未分类支出,老外 大卫佣金,2250,USDT,未知账户 +2024-09-01,支出,🏷️ 未分类支出,天龙佣金,6943.9,USDT,未知账户 +2024-09-01,支出,🏷️ 未分类支出,大卫5月的佣金,900,USDT,未知账户 +2024-08-31,支出,🏷️ 分红,天天控天费用,2207,USDT,未知账户 +2024-08-31,支出,🏷️ 佣金/返佣,nat转发包年,280,USDT,未知账户 +2024-08-31,支出,🏷️ 未分类支出,长青佣金,377,USDT,未知账户 +2024-08-31,支出,💰 未分类收入,蚊子同比例分红,2207,USDT,未知账户 +2024-08-31,支出,🏷️ 未分类支出,Jack帅哥佣金,456,USDT,未知账户 +2024-08-31,支出,🏷️ 未分类支出,乐乐佣金,440,USDT,未知账户 +2024-08-30,支出,🏷️ 佣金/返佣,宝塔会员,203,USDT,未知账户 +2024-08-30,支出,🏷️ 未分类支出,核心佣金,3103,USDT,未知账户 +2024-08-27,支出,🏷️ 退款,买车定金,2000,USDT,未知账户 +2024-08-27,支出,🏷️ 退款,买车尾款,16562,USDT,未知账户 +2024-08-27,支出,🏷️ 佣金/返佣,技术信用卡,50,USDT,未知账户 +2024-08-27,支出,🏷️ 分红,天天工资,1500,USDT,未知账户 +2024-08-27,支出,🏷️ 分红,碧桂园工资,1000,USDT,未知账户 +2024-08-27,支出,🏷️ 分红,皇工资,5000,USDT,未知账户 +2024-08-27,支出,🏷️ 分红,财务客服,1500,USDT,未知账户 +2024-08-27,支出,🏷️ 分红,代理ip技术,1000,USDT,未知账户 +2024-08-27,支出,🏷️ 佣金/返佣,人工智能接口,700,USDT,未知账户 +2024-08-27,支出,🏷️ 借款/转账,处理员工一起出,10000,USDT,未知账户 +2024-08-27,支出,🏷️ 借款/转账,外星人一起出4.8w,6571,USDT,未知账户 +2024-08-27,支出,🏷️ 服务器/技术,啊杰借的10000,1404,USDT,未知账户 +2024-08-27,支出,🏷️ 固定资产,群发广告,300,USDT,未知账户 +2024-08-27,支出,🏷️ 借款/转账,网络攻击买服务,800,USDT,未知账户 +2024-08-27,支出,🏷️ 未分类支出,老练几个月佣金,1586,USDT,未知账户 +2024-08-27,支出,🏷️ 未分类支出,胖兔佣金,3597,USDT,未知账户 +2024-08-07,支出,🏷️ 借款/转账,攻击,60,USDT,未知账户 +2024-08-07,支出,🏷️ 佣金/返佣,其他翻译测试,58,USDT,未知账户 +2024-08-07,支出,🏷️ 佣金/返佣,佣金,80,USDT,未知账户 +2024-08-07,支出,🏷️ 佣金/返佣,乐乐佣金,343,USDT,未知账户 +2024-08-07,支出,🏷️ 佣金/返佣,佣金,823.5,USDT,未知账户 +2024-08-07,支出,🏷️ 退款,退款,170.71,USDT,未知账户 +2024-08-07,支出,🏷️ 退款,退款,70,USDT,未知账户 +2024-08-03,支出,🏷️ 分红,啊寒分红,3000,USDT,未知账户 +2024-08-03,支出,🏷️ 分红,蚊子分红,3000,USDT,未知账户 +2024-08-03,支出,🏷️ 佣金/返佣,长青佣金,326,USDT,未知账户 +2024-08-03,支出,🏷️ 佣金/返佣,阿宏佣金,311,USDT,未知账户 +2024-06-15,支出,未分类,6月测试交易,50,USDT,未知账户 +2024-06-15,支出,未分类,6月测试交易,50,USDT,未知账户 +2024-06-15,支出,未分类,6月测试交易,50,USDT,未知账户 +2024-06-15,支出,未分类,6月测试交易,50,USDT,未知账户 +2025-10-30,支出,🏷️ 未分类,买飞机号,213,USDT,乐乐用 +2025-10-31,支出,🏷️ 未分类,公司的外网专线费用,211,USDT,cp +2025-10-31,支出,🏷️ 未分类,强耀科技退款,19,USDT,未知账户 +2025-11-01,支出,🏷️ 未分类,阿金公司退款,186,USDT,未知账户 +2025-11-01,支出,🏷️ 未分类,小白工资,1000,USDT,未知账户 +2025-11-01,支出,🏷️ 未分类,cp工资,1000,USDT,未知账户 +2025-11-01,支出,🏷️ 未分类,菲菲工资,2344,USDT,16500按7.04 +2025-11-02,支出,🏷️ 未分类,绿豆汤返佣,99,USDT,未知账户 +2025-11-02,支出,🏷️ 未分类,OAC返佣,972,USDT,未知账户 +2025-11-02,支出,🏷️ 未分类,天龙返佣,10556,USDT,未知账户 +2025-11-02,支出,🏷️ 未分类,Jack帅哥返佣,506,USDT,未知账户 +2025-11-02,支出,🏷️ 未分类,无名返佣,1655,USDT,未知账户 +2025-11-02,支出,🏷️ 未分类,方向返佣,89,USDT,未知账户 +2025-11-02,支出,🏷️ 未分类,阿泰会员,30,USDT,天天 +2025-11-02,支出,🏷️ 未分类,香缇卡会员,30,USDT,未知账户 +2025-11-02,支出,🏷️ 未分类,虚拟卡,100,USDT,未知账户 +2025-11-02,支出,🏷️ 未分类,代理ip,15,USDT,未知账户 +2025-11-02,支出,🏷️ 未分类,服务器,540,USDT,未知账户 +2025-11-02,支出,🏷️ 未分类,域名,15,USDT,未知账户 +2025-11-02,支出,🏷️ 未分类,宝金出海会员,30,USDT,未知账户 +2025-11-02,支出,🏷️ 未分类,网盘会员,42.5,USDT,未知账户 +2025-11-02,支出,🏷️ 未分类,水电宽带,65,USDT,未知账户 +2025-11-02,支出,🏷️ 未分类,硬盘,68,USDT,未知账户 +2025-11-02,支出,🏷️ 未分类,cpcc会员,253.5,USDT,未知账户 +2025-11-02,支出,🏷️ 未分类,香缇卡流量卡,153,USDT,未知账户 +2025-11-02,支出,🏷️ 未分类,杰夫返佣,1055,USDT,未知账户 +2025-11-02,支出,🏷️ 未分类,天天工资,1500,USDT,未知账户 +2025-11-02,支出,🏷️ 未分类,碧桂园工资,1000,USDT,未知账户 +2025-11-02,支出,🏷️ 未分类,香缇卡工资,1146,USDT,未知账户 +2025-11-02,支出,🏷️ 未分类,龙腾集团,7700,USDT,14700扣10月龙腾借7000 鑫晟公司2480未结算 +2025-11-04,支出,🏷️ 未分类,羽琦返佣,2960,USDT,未知账户 +2025-11-04,支出,🏷️ 未分类,皇雨工资,11364,USDT,80000元按7.04 +2025-11-04,支出,🏷️ 未分类,代理ip小哥工资,994,USDT,7000元按7.04 +2025-11-04,支出,🏷️ 未分类,SY工资,4761,USDT,(4261+500)30000元按7.04 +2025-11-04,支出,🏷️ 未分类,财务Amy工资,1500,USDT,未知账户 +2025-11-04,支出,🏷️ 未分类,助理OAC工资,1500,USDT,未知账户 +2025-11-04,支出,🏷️ 未分类,煮饭阿姨工资,426,USDT,未知账户 +2025-11-04,支出,🏷️ 未分类,李涛工资,578,USDT,未知账户 +2025-11-04,支出,🏷️ 未分类,胖兔返佣,3414,USDT,未知账户 +2025-11-04,支出,🏷️ 未分类,合鑫返佣,105,USDT,未知账户 +2025-11-04,支出,🏷️ 未分类,恋哥返佣,187,USDT,未知账户 +2025-11-05,支出,🏷️ 未分类,阿宏返佣,815,USDT,825u (10u换trx) +2025-11-05,支出,🏷️ 未分类,666返佣,270,USDT,未知账户 diff --git a/deploy.sh b/deploy.sh index 34b4deb3..64e6ffb1 100755 --- a/deploy.sh +++ b/deploy.sh @@ -69,6 +69,33 @@ sudo docker-compose down || true echo "🚀 构建并启动新容器..." sudo docker-compose up -d --build +# 等待PostgreSQL就绪 +echo "⏳ 等待PostgreSQL就绪..." +POSTGRES_READY=0 +for i in {1..10}; do + if sudo docker-compose exec -T postgres pg_isready -U kt_financial -d kt_financial > /dev/null 2>&1; then + echo "✅ PostgreSQL 已就绪" + POSTGRES_READY=1 + break + fi + echo " 第${i}次重试..." + sleep 3 +done +if [ "$POSTGRES_READY" -ne 1 ]; then + echo "❌ PostgreSQL 未在预期时间内就绪" + exit 1 +fi + +# 导入数据 +echo "📦 导入财务数据..." +sudo docker-compose exec -T kt-financial \ + bash -lc "pnpm --filter @vben/backend import:data -- --csv /app/data/finance/finance-combined.csv --year 2025" + +# 验证数据条数 +echo "🔢 检查交易记录条数..." +sudo docker-compose exec -T postgres \ + psql -U kt_financial -d kt_financial -c "SELECT COUNT(*) AS transaction_count FROM finance_transactions;" + # 清理旧镜像 echo "🧹 清理旧镜像..." sudo docker image prune -f diff --git a/docker-compose.yml b/docker-compose.yml index c14911ff..668ea3ff 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,23 +1,54 @@ version: '3.8' services: + postgres: + image: postgres:16-alpine + container_name: kt-financial-postgres + restart: unless-stopped + environment: + - POSTGRES_DB=kt_financial + - POSTGRES_USER=kt_financial + - POSTGRES_PASSWORD=kt_financial_pwd + - TZ=Asia/Shanghai + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U kt_financial -d kt_financial'] + interval: 10s + timeout: 5s + retries: 6 + networks: + - kt-network + kt-financial: build: context: . dockerfile: Dockerfile container_name: kt-financial-system restart: unless-stopped + depends_on: + postgres: + condition: service_healthy ports: - "8080:80" environment: - NODE_ENV=production - TZ=Asia/Shanghai + - POSTGRES_HOST=postgres + - POSTGRES_PORT=5432 + - POSTGRES_DB=kt_financial + - POSTGRES_USER=kt_financial + - POSTGRES_PASSWORD=kt_financial_pwd volumes: - ./logs:/var/log - ./storage/backend:/app/apps/backend/storage + - ./data:/app/data:ro networks: - kt-network networks: kt-network: driver: bridge + +volumes: + postgres-data: diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ecd8f3ac..c7eba614 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -631,15 +631,15 @@ importers: '@faker-js/faker': specifier: 'catalog:' version: 9.9.0 - better-sqlite3: - specifier: 9.5.0 - version: 9.5.0 jsonwebtoken: specifier: 'catalog:' version: 9.0.2 nitropack: specifier: 'catalog:' version: 2.12.9(better-sqlite3@9.5.0) + pg: + specifier: ^8.12.0 + version: 8.16.3 devDependencies: '@types/jsonwebtoken': specifier: 'catalog:' @@ -8247,6 +8247,40 @@ packages: perfect-debounce@2.0.0: resolution: {integrity: sha512-fkEH/OBiKrqqI/yIgjR92lMfs2K8105zt/VT6+7eTjNwisrsh47CeIED9z58zI7DfKdH3uHAn25ziRZn3kgAow==} + pg-cloudflare@1.2.7: + resolution: {integrity: sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==} + + pg-connection-string@2.9.1: + resolution: {integrity: sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.10.1: + resolution: {integrity: sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.10.3: + resolution: {integrity: sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.16.3: + resolution: {integrity: sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -8758,6 +8792,22 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.0: + resolution: {integrity: sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + preact@10.27.2: resolution: {integrity: sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==} @@ -10612,6 +10662,10 @@ packages: resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} engines: {node: '>=12'} + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + y18n@4.0.3: resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} @@ -14433,6 +14487,7 @@ snapshots: dependencies: bindings: 1.5.0 prebuild-install: 7.1.3 + optional: true bignumber.js@9.3.1: {} @@ -14451,6 +14506,7 @@ snapshots: buffer: 5.7.1 inherits: 2.0.4 readable-stream: 3.6.2 + optional: true boolbase@1.0.0: {} @@ -14496,6 +14552,7 @@ snapshots: dependencies: base64-js: 1.5.1 ieee754: 1.2.1 + optional: true buffer@6.0.3: dependencies: @@ -14666,7 +14723,8 @@ snapshots: dependencies: readdirp: 4.1.2 - chownr@1.1.4: {} + chownr@1.1.4: + optional: true chownr@3.0.0: {} @@ -15186,6 +15244,7 @@ snapshots: decompress-response@6.0.0: dependencies: mimic-response: 3.1.0 + optional: true deep-eql@5.0.2: {} @@ -15427,6 +15486,7 @@ snapshots: end-of-stream@1.4.5: dependencies: once: 1.4.0 + optional: true enhanced-resolve@5.18.3: dependencies: @@ -15922,7 +15982,8 @@ snapshots: strip-final-newline: 4.0.0 yoctocolors: 2.1.2 - expand-template@2.0.3: {} + expand-template@2.0.3: + optional: true expand-tilde@2.0.2: dependencies: @@ -16091,7 +16152,8 @@ snapshots: fresh@2.0.0: {} - fs-constants@1.0.0: {} + fs-constants@1.0.0: + optional: true fs-extra@10.1.0: dependencies: @@ -16215,7 +16277,8 @@ snapshots: meow: 12.1.1 split2: 4.2.0 - github-from-package@0.0.0: {} + github-from-package@0.0.0: + optional: true glob-parent@5.1.2: dependencies: @@ -17235,7 +17298,8 @@ snapshots: mimic-function@5.0.1: {} - mimic-response@3.1.0: {} + mimic-response@3.1.0: + optional: true minimatch@10.0.3: dependencies: @@ -17293,7 +17357,8 @@ snapshots: mitt@3.0.1: {} - mkdirp-classic@0.5.3: {} + mkdirp-classic@0.5.3: + optional: true mkdist@2.4.1(sass@1.93.3)(typescript@5.9.3)(vue-tsc@2.2.10(typescript@5.9.3))(vue@3.5.22(typescript@5.9.3)): dependencies: @@ -17374,7 +17439,8 @@ snapshots: nanopop@2.4.2: {} - napi-build-utils@2.0.0: {} + napi-build-utils@2.0.0: + optional: true napi-postinstall@0.3.4: {} @@ -17498,6 +17564,7 @@ snapshots: node-abi@3.80.0: dependencies: semver: 7.7.3 + optional: true node-addon-api@7.1.1: {} @@ -17806,6 +17873,41 @@ snapshots: perfect-debounce@2.0.0: {} + pg-cloudflare@1.2.7: + optional: true + + pg-connection-string@2.9.1: {} + + pg-int8@1.0.1: {} + + pg-pool@3.10.1(pg@8.16.3): + dependencies: + pg: 8.16.3 + + pg-protocol@1.10.3: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.0 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.16.3: + dependencies: + pg-connection-string: 2.9.1 + pg-pool: 3.10.1(pg@8.16.3) + pg-protocol: 1.10.3 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.2.7 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -18334,6 +18436,16 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postgres-array@2.0.0: {} + + postgres-bytea@1.0.0: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + preact@10.27.2: {} prebuild-install@7.1.3: @@ -18350,6 +18462,7 @@ snapshots: simple-get: 4.0.1 tar-fs: 2.1.4 tunnel-agent: 0.6.0 + optional: true prelude-ls@1.2.1: {} @@ -18399,6 +18512,7 @@ snapshots: dependencies: end-of-stream: 1.4.5 once: 1.4.0 + optional: true punycode@2.3.1: {} @@ -18492,6 +18606,7 @@ snapshots: inherits: 2.0.4 string_decoder: 1.3.0 util-deprecate: 1.0.2 + optional: true readable-stream@4.7.0: dependencies: @@ -18897,13 +19012,15 @@ snapshots: signal-exit@4.1.0: {} - simple-concat@1.0.1: {} + simple-concat@1.0.1: + optional: true simple-get@4.0.1: dependencies: decompress-response: 6.0.0 once: 1.4.0 simple-concat: 1.0.1 + optional: true sirv@3.0.2: dependencies: @@ -19362,6 +19479,7 @@ snapshots: mkdirp-classic: 0.5.3 pump: 3.0.3 tar-stream: 2.2.0 + optional: true tar-stream@2.2.0: dependencies: @@ -19370,6 +19488,7 @@ snapshots: fs-constants: 1.0.0 inherits: 2.0.4 readable-stream: 3.6.2 + optional: true tar-stream@3.1.7: dependencies: @@ -19493,6 +19612,7 @@ snapshots: tunnel-agent@0.6.0: dependencies: safe-buffer: 5.2.1 + optional: true turbo-darwin-64@2.6.0: optional: true @@ -20567,6 +20687,8 @@ snapshots: xml-name-validator@4.0.0: {} + xtend@4.0.2: {} + y18n@4.0.3: {} y18n@5.0.8: {}