feat: add Finance MCP workflow
Some checks failed
Deploy Finance MCP Service / build-mcp (push) Successful in 5m21s
Deploy to Production / Build and Test (push) Successful in 10m12s
Deploy Finance MCP Service / deploy-mcp (push) Failing after 4s
Deploy to Production / Deploy to Server (push) Successful in 6m24s

This commit is contained in:
你的用户名
2025-11-08 19:39:10 +08:00
parent 8469cd8d83
commit 076b9fac5f
16 changed files with 2069 additions and 1268 deletions

View File

@@ -0,0 +1,394 @@
import { Buffer } from 'node:buffer';
interface FinanceEnvelope<T> {
code: number;
message?: string;
data: T;
}
interface BasicCredentials {
username: string;
password: string;
}
export interface FinanceClientConfig {
baseUrl: string;
apiKey?: string;
basicAuth?: BasicCredentials;
timeoutMs?: number;
}
export interface ListTransactionsParams {
type?: string;
statuses?: string[];
includeDeleted?: boolean;
}
export interface ListReimbursementsParams {
type?: string;
statuses?: string[];
includeDeleted?: boolean;
}
export interface ListMediaParams {
limit?: number;
fileTypes?: string[];
}
export interface ListExchangeRatesParams {
fromCurrency?: string;
toCurrency?: string;
date?: string;
}
export interface TelegramConfigPayload {
name?: string;
botToken?: string;
chatId?: string;
notificationTypes?: string[];
isEnabled?: boolean;
}
export class FinanceClient {
private readonly apiKey?: string;
private readonly baseUrl: string;
private readonly basicAuth?: BasicCredentials;
private readonly timeoutMs?: number;
constructor(config: FinanceClientConfig) {
if (!config?.baseUrl) {
throw new Error('FinanceClient requires a baseUrl');
}
this.baseUrl = config.baseUrl.replace(/\/$/, '');
this.apiKey = config.apiKey;
this.basicAuth = validateBasicAuth(config.basicAuth);
this.timeoutMs = config.timeoutMs;
}
createBudget(payload: unknown) {
return this.post('/api/finance/budgets', payload);
}
createCategory(payload: unknown) {
return this.post('/api/finance/categories', payload);
}
createReimbursement(payload: unknown) {
return this.post('/api/finance/reimbursements', payload);
}
createReimbursementMedia(payload: unknown) {
return this.post('/api/finance/media', payload);
}
createTelegramConfig(payload: TelegramConfigPayload) {
return this.post('/api/telegram/notifications', payload);
}
createTransaction(payload: unknown) {
return this.post('/api/finance/transactions', payload);
}
deleteBudget(id: number) {
return this.delete(`/api/finance/budgets/${id}`);
}
deleteCategory(id: number) {
return this.delete(`/api/finance/categories/${id}`);
}
deleteTelegramConfig(id: number) {
return this.delete(`/api/telegram/notifications/${id}`);
}
deleteTransaction(id: number) {
return this.delete(`/api/finance/transactions/${id}`);
}
downloadMedia(id: number) {
return this.download(`/api/finance/media/${id}/download`);
}
getMediaById(id: number) {
return this.get(`/api/finance/media/${id}`);
}
listAccounts(params: { currency?: string } = {}) {
return this.get('/api/finance/accounts', params);
}
listBudgets() {
return this.get('/api/finance/budgets');
}
listCategories(params: { type?: string } = {}) {
return this.get('/api/finance/categories', params);
}
listCurrencies() {
return this.get('/api/finance/currencies');
}
listExchangeRates(params: ListExchangeRatesParams = {}) {
const query: Record<string, string> = {};
if (params.fromCurrency) query.from = params.fromCurrency;
if (params.toCurrency) query.to = params.toCurrency;
if (params.date) query.date = params.date;
return this.get('/api/finance/exchange-rates', query);
}
listMedia(params: ListMediaParams = {}) {
const query: Record<string, number | string> = {};
if (typeof params.limit === 'number') query.limit = params.limit;
if (params.fileTypes?.length) query.types = params.fileTypes.join(',');
return this.get('/api/finance/media', query);
}
listReimbursements(params: ListReimbursementsParams = {}) {
const query: Record<string, boolean | string> = {};
if (params.type) query.type = params.type;
if (params.statuses?.length) query.statuses = params.statuses.join(',');
if (params.includeDeleted !== undefined)
query.includeDeleted = params.includeDeleted;
return this.get('/api/finance/reimbursements', query);
}
listTelegramConfigs() {
return this.get('/api/telegram/notifications');
}
listTransactions(params: ListTransactionsParams = {}) {
const query: Record<string, boolean | string> = {};
if (params.type) query.type = params.type;
if (params.statuses?.length) query.statuses = params.statuses.join(',');
if (params.includeDeleted !== undefined)
query.includeDeleted = params.includeDeleted;
return this.get('/api/finance/transactions', query);
}
testTelegramConfig(payload: { botToken: string; chatId: string }) {
return this.post('/api/telegram/test', payload);
}
updateBudget(id: number, payload: unknown) {
return this.put(`/api/finance/budgets/${id}`, payload);
}
updateCategory(id: number, payload: unknown) {
return this.put(`/api/finance/categories/${id}`, payload);
}
updateReimbursement(id: number, payload: unknown) {
return this.put(`/api/finance/reimbursements/${id}`, payload);
}
updateTelegramConfig(id: number, payload: TelegramConfigPayload) {
return this.put(`/api/telegram/notifications/${id}`, payload);
}
updateTransaction(id: number, payload: unknown) {
return this.put(`/api/finance/transactions/${id}`, payload);
}
private buildHeaders(json: boolean) {
const headers: Record<string, string> = { Accept: 'application/json' };
if (json) headers['Content-Type'] = 'application/json';
if (this.apiKey) headers.Authorization = `Bearer ${this.apiKey}`;
else if (this.basicAuth)
headers.Authorization = `Basic ${createBasicToken(this.basicAuth)}`;
return headers;
}
private createUrl(path: string) {
const normalized = path.startsWith('/') ? path : `/${path}`;
return new URL(normalized, this.baseUrl);
}
private async delete(path: string) {
return this.request('DELETE', path);
}
private async download(path: string) {
const url = this.createUrl(path);
const response = await this.performFetch(url, {
method: 'GET',
headers: this.buildHeaders(false),
});
if (!response.ok) {
const payload =
await this.safeParseEnvelope<FinanceEnvelope<unknown>>(response);
if (payload) {
throw new Error(payload.message || 'Failed to download media file');
}
throw new Error(
`Failed to download media file (HTTP ${response.status})`,
);
}
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
return {
fileName: this.extractFileName(
response.headers.get('content-disposition'),
),
mimeType:
response.headers.get('content-type') ?? 'application/octet-stream',
size: buffer.byteLength,
base64: buffer.toString('base64'),
};
}
private extractFileName(contentDisposition: null | string) {
if (!contentDisposition) return undefined;
const filenameStar = contentDisposition.match(/filename\*=([^;]+)/i);
if (filenameStar?.[1]) {
const value = filenameStar[1].replace(/^UTF-8''/, '');
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
const filename = contentDisposition.match(/filename="?([^";]+)"?/i);
return filename?.[1];
}
private async get(path: string, query?: Record<string, unknown>) {
return this.request('GET', path, { query });
}
private async parseEnvelope(response: Response, path: string) {
const payload =
await this.safeParseEnvelope<FinanceEnvelope<unknown>>(response);
if (!payload) {
const text = await response.text();
throw new Error(
`Unexpected response from ${path}: ${text || response.statusText}`,
);
}
if (!response.ok) {
throw new Error(
payload.message ||
`Finance API request failed (HTTP ${response.status})`,
);
}
return payload;
}
private async performFetch(url: URL, init: RequestInit) {
const controller = this.timeoutMs ? new AbortController() : undefined;
let timer: NodeJS.Timeout | undefined;
if (controller) {
init.signal = controller.signal;
timer = setTimeout(() => controller.abort(), this.timeoutMs);
}
try {
return await fetch(url, init);
} catch (error: unknown) {
if ((error as Error)?.name === 'AbortError') {
throw new Error(`Request to ${url.pathname} timed out`);
}
throw error;
} finally {
if (timer) clearTimeout(timer);
}
}
private async post(path: string, body?: unknown) {
return this.request('POST', path, { body });
}
private async put(path: string, body?: unknown) {
return this.request('PUT', path, { body });
}
private async request(
method: 'DELETE' | 'GET' | 'POST' | 'PUT',
path: string,
options: {
body?: unknown;
query?: Record<string, unknown>;
} = {},
) {
const url = this.createUrl(path);
if (options.query) {
for (const [key, value] of Object.entries(options.query)) {
if (value === undefined || value === null) continue;
if (Array.isArray(value)) {
if (value.length > 0) url.searchParams.set(key, value.join(','));
} else if (typeof value === 'boolean') {
url.searchParams.set(key, value ? 'true' : 'false');
} else {
url.searchParams.set(key, String(value));
}
}
}
const response = await this.performFetch(url, {
method,
headers: this.buildHeaders(method !== 'GET' && method !== 'DELETE'),
body: options.body ? JSON.stringify(options.body) : undefined,
});
const payload = await this.parseEnvelope(response, path);
if (payload.code !== 0) {
throw new Error(payload.message || 'Finance API returned an error');
}
return payload.data;
}
private async safeParseEnvelope<T>(response: Response) {
const contentType = response.headers.get('content-type') || '';
if (!contentType.includes('application/json')) return null;
try {
return (await response.clone().json()) as T;
} catch {
return null;
}
}
}
type BasicAuthLike = Partial<
BasicCredentials & {
login: string;
pass: string;
user: string;
}
>;
const validateBasicAuth = (
credentials?: BasicAuthLike | null,
): BasicCredentials | undefined => {
if (!credentials) return undefined;
const username =
credentials.username ?? credentials.user ?? credentials.login;
const password = credentials.password ?? credentials.pass;
if (!username && !password) return undefined;
if (!username || !password) {
throw new Error(
'FinanceClient basicAuth requires both username and password',
);
}
return { username: String(username), password: String(password) };
};
const createBasicToken = ({ username, password }: BasicCredentials) =>
Buffer.from(`${username}:${password}`, 'utf8').toString('base64');