diff --git a/apps/api/src/integrations/tahesab/connections.service.ts b/apps/api/src/integrations/tahesab/connections.service.ts index 40a1ca5..ed02bd2 100644 --- a/apps/api/src/integrations/tahesab/connections.service.ts +++ b/apps/api/src/integrations/tahesab/connections.service.ts @@ -252,20 +252,51 @@ export class TahesabConnectionsService { const { row, api } = await this.getApi(organizationId, id); try { const result = await api.checkHealth(); + const patch: { + lastHealthAt: Date; + lastHealthOk: boolean; + lastError: string | null; + baseUrl?: string; + allowInsecureTls?: boolean; + } = { + lastHealthAt: new Date(), + lastHealthOk: true, + lastError: null, + }; + + // اگر health فقط با آدرس/پروتکل جایگزین جواب داد، همان را ذخیره کن + if ( + result.usedBaseUrl && + result.usedBaseUrl.replace(/\/+$/, '') !== row.baseUrl.replace(/\/+$/, '') + ) { + patch.baseUrl = result.usedBaseUrl.replace(/\/+$/, ''); + } + // اگر فقط با TLS بدون verify جواب داد، فلگ را روشن کن تا بقیهٔ متدها هم کار کنند + if (!row.allowInsecureTls && result.usedInsecureTls) { + patch.allowInsecureTls = true; + } + const updated = await this.prisma.accountingConnection.update({ where: { id: row.id }, - data: { - lastHealthAt: new Date(), - lastHealthOk: true, - lastError: null, - }, + data: patch, }); - return { ok: true, connection: this.toDto(updated), health: result }; + return { + ok: true, + connection: this.toDto(updated), + health: result, + adjusted: + Boolean(patch.baseUrl) || Boolean(patch.allowInsecureTls) + ? { + baseUrl: patch.baseUrl, + allowInsecureTls: patch.allowInsecureTls, + } + : undefined, + }; } catch (err) { const message = (err as Error).message ?? 'خطای ناشناخته'; const hint = - /مهلت اتصال|timeout/i.test(message) && !row.allowInsecureTls && /^https:/i.test(row.baseUrl) - ? ' اگر گواهی ته‌حساب خودامضاست، «پذیرش گواهی SSL خودامضا» را فعال کنید.' + /مهلت اتصال|timeout/i.test(message) + ? ' از سرور CRM به IP/پورت ته‌حساب دسترسی شبکه لازم است (فایروال). برای https خودامضا گزینه SSL را فعال کنید. پورت پیش‌فرض: https 8081 یا http 9550.' : ''; const full = `${message}${hint}`.slice(0, 500); const updated = await this.prisma.accountingConnection.update({ diff --git a/apps/api/src/integrations/tahesab/tahesab-http.client.ts b/apps/api/src/integrations/tahesab/tahesab-http.client.ts index 01d11d9..72bcfb8 100644 --- a/apps/api/src/integrations/tahesab/tahesab-http.client.ts +++ b/apps/api/src/integrations/tahesab/tahesab-http.client.ts @@ -12,7 +12,7 @@ export type TahesabCallOptions = { baseUrl: string; token: string; dbName: string; - /** مسیر OpenAPI مثل DoNewMoshtari یا CheckHealth-SSL */ + /** مسیر OpenAPI مثل DoNewMoshtari یا CheckHealth-SSL؛ خالی = ریشه */ path: string; /** کلید body؛ اگر خالی باشد فقط GET بدون body */ bodyKey?: string; @@ -38,6 +38,32 @@ function extractTahesabError(json: unknown, text: string): string { return trimmed ? trimmed.slice(0, 300) : ''; } +/** نامزدهای آدرس برای تست سلامت (پورت/پروتکل پیش‌فرض ته‌حساب) */ +export function tahesabHealthBaseCandidates(baseUrl: string): string[] { + let u: URL; + try { + u = new URL(baseUrl.trim().replace(/\/+$/, '')); + } catch { + return [baseUrl.trim().replace(/\/+$/, '')]; + } + const host = u.hostname; + const out: string[] = [`${u.protocol}//${u.host}`]; + if (u.protocol === 'https:') { + out.push(`http://${host}:9550`); + out.push(`http://${host}:8081`); + if (u.port && u.port !== '9550' && u.port !== '8081') { + out.push(`http://${host}:${u.port}`); + } + } else { + out.push(`https://${host}:8081`); + out.push(`http://${host}:9550`); + if (u.port && u.port !== '8081' && u.port !== '9550') { + out.push(`https://${host}:${u.port}`); + } + } + return [...new Set(out.filter(Boolean))]; +} + @Injectable() export class TahesabHttpClient { private readonly logger = new Logger(TahesabHttpClient.name); @@ -45,17 +71,18 @@ export class TahesabHttpClient { async call(opts: TahesabCallOptions): Promise { const base = opts.baseUrl.replace(/\/+$/, ''); const path = (opts.path ?? '').replace(/^\/+/, ''); - // Postman ته‌حساب برای health به ریشه می‌زند (بدون path) const url = path ? new URL(`${base}/${path}`) : new URL(base); const method = opts.method ?? (opts.bodyKey ? 'POST' : 'GET'); const timeoutMs = opts.timeoutMs ?? 30_000; - const label = path || '(root)'; + const label = `${method} ${path || '(root)'} @ ${url.host}`; const headers: Record = { Authorization: `Bearer ${opts.token}`, DBname: opts.dbName, DBName: opts.dbName, Accept: 'application/json', + // بعضی نسخه‌های وب‌سرویس ویندوزی keep-alive را نمی‌بندند و Node تا timeout می‌ماند + Connection: 'close', }; let body: string | undefined; @@ -69,8 +96,10 @@ export class TahesabHttpClient { const lib = isHttps ? https : http; const agent = isHttps && opts.allowInsecureTls - ? new https.Agent({ rejectUnauthorized: false }) - : undefined; + ? new https.Agent({ rejectUnauthorized: false, keepAlive: false }) + : isHttps + ? new https.Agent({ keepAlive: false }) + : new http.Agent({ keepAlive: false }); return new Promise((resolve, reject) => { let settled = false; @@ -94,8 +123,20 @@ export class TahesabHttpClient { }, (res) => { const chunks: Buffer[] = []; - res.on('data', (c) => chunks.push(c)); - res.on('end', () => { + let received = 0; + const expectedLen = Number(res.headers['content-length']); + let bodyDone = false; + + const completeBody = () => { + if (bodyDone || settled) return; + bodyDone = true; + // بعد از خواندن کامل، سوکت را قطع کن تا hang روی keep-alive نماند + try { + res.destroy(); + } catch { + /* ignore */ + } + const text = Buffer.concat(chunks).toString('utf8'); let json: unknown = null; if (text) { @@ -121,7 +162,7 @@ export class TahesabHttpClient { return; } this.logger.warn( - `Tahesab ${method} ${label} → ${status}: ${text.slice(0, 400)}`, + `Tahesab ${label} → ${status}: ${text.slice(0, 400)}`, ); finish(() => reject( @@ -134,7 +175,6 @@ export class TahesabHttpClient { ); return; } - // بعضی متدها با ۲۰۰ و فیلد ERROR برمی‌گردند if (json && typeof json === 'object' && 'ERROR' in (json as object)) { if (isEmptyOk) { this.logger.debug( @@ -153,11 +193,27 @@ export class TahesabHttpClient { return; } finish(() => resolve(json as T)); + }; + + res.on('data', (c: Buffer) => { + chunks.push(c); + received += c.length; + if ( + Number.isFinite(expectedLen) && + expectedLen > 0 && + received >= expectedLen + ) { + completeBody(); + } + }); + res.on('end', () => completeBody()); + res.on('close', () => { + // اگر بادی آمده ولی end نیامده (سرور عجیب)، با دادهٔ موجود تمام کن + if (!settled && chunks.length) completeBody(); }); }, ); - // مهلت از لحظهٔ شروع (شامل اتصال TCP/TLS) — timeout سوکت Node فقط بعد از connect است const hardTimer = setTimeout(() => { req.destroy(); finish(() => diff --git a/apps/api/src/integrations/tahesab/tahesab-methods.ts b/apps/api/src/integrations/tahesab/tahesab-methods.ts index 04087b2..103ddcc 100644 --- a/apps/api/src/integrations/tahesab/tahesab-methods.ts +++ b/apps/api/src/integrations/tahesab/tahesab-methods.ts @@ -1,5 +1,9 @@ import { randomBytes } from 'crypto'; -import type { TahesabHttpClient, TahesabCallOptions } from './tahesab-http.client'; +import { + tahesabHealthBaseCandidates, + type TahesabHttpClient, + type TahesabCallOptions, +} from './tahesab-http.client'; export type ConnCreds = { baseUrl: string; @@ -178,46 +182,67 @@ export class TahesabApi { } /** - * تست سلامت مطابق Postman ته‌حساب: - * ابتدا ریشهٔ baseUrl، بعد /CheckHealth، در نهایت مسیر SSL یا No-SSL بر اساس پروتکل. + * تست سلامت مطابق Postman ته‌حساب (ریشه + CheckHealth). + * روی تایم‌اوت، پورت/پروتکل جایگزین (8081/9550) و در صورت نیاز TLS بدون verify را هم امتحان می‌کند. */ - async checkHealth() { - const isHttps = /^https:/i.test(this.creds.baseUrl.trim()); - const timeoutMs = 12_000; + async checkHealth(): Promise<{ + Status?: string; + Api_Status?: string; + usedBaseUrl?: string; + usedPath?: string; + usedInsecureTls?: boolean; + }> { + const timeoutMs = 8_000; type Health = { Status?: string; Api_Status?: string }; - const attempts: Array<{ - path: string; - bodyKey?: string; - method: 'GET' | 'POST'; - }> = [ - { path: '', method: 'GET' }, - { path: '', bodyKey: 'CheckHealth', method: 'POST' }, - { path: 'CheckHealth', method: 'GET' }, - { - path: isHttps ? 'CheckHealth-SSL' : 'CheckHealth-No-SSL', - method: 'GET', - }, - ]; + const bases = tahesabHealthBaseCandidates(this.creds.baseUrl); + const insecureFlags = this.creds.allowInsecureTls + ? [true] + : [false, true]; let lastErr: unknown; - for (const attempt of attempts) { - try { - return await this.call( - attempt.path, - attempt.bodyKey, - attempt.bodyKey ? [] : undefined, - attempt.method, - timeoutMs, - ); - } catch (err) { - lastErr = err; - const msg = err instanceof Error ? err.message : String(err); - // خطای گواهی با تلاش‌های بعدی روی همان https حل نمی‌شود - if (/certificate|UNABLE_TO_VERIFY|self-signed|CERT_/i.test(msg)) { - throw err; + for (const baseUrl of bases) { + const isHttps = /^https:/i.test(baseUrl); + const pathAttempts: Array<{ + path: string; + bodyKey?: string; + method: 'GET' | 'POST'; + }> = [ + { path: '', method: 'GET' }, + { path: '', bodyKey: 'CheckHealth', method: 'POST' }, + { path: 'CheckHealth', method: 'GET' }, + { + path: isHttps ? 'CheckHealth-SSL' : 'CheckHealth-No-SSL', + method: 'GET', + }, + ]; + + for (const allowInsecureTls of insecureFlags) { + if (!isHttps && allowInsecureTls) continue; + for (const attempt of pathAttempts) { + try { + const health = await this.http.call({ + ...this.creds, + baseUrl, + allowInsecureTls, + path: attempt.path, + bodyKey: attempt.bodyKey, + args: attempt.bodyKey ? [] : undefined, + method: attempt.method, + timeoutMs, + }); + return { + ...health, + usedBaseUrl: baseUrl, + usedPath: attempt.path || '(root)', + usedInsecureTls: allowInsecureTls, + }; + } catch (err) { + lastErr = err; + } } } } + throw lastErr instanceof Error ? lastErr : new Error('تست اتصال ته‌حساب ناموفق بود.');