import { env } from '../config/env.js'; export interface SmsSendResult { channel: string; recipient: string; status: 'SENT' | 'FAILED'; lastError: string | null; } export interface SmsMessage { to: string; text: string; } type SmsApiResponse = { requestId?: string; requestTime?: string; statusCode?: string | number; statusName?: string; }; function normalizePhone(value: string | null | undefined): string { return String(value ?? '').replace(/\D/g, ''); } function fail(channel: string, recipient: string, error: unknown): SmsSendResult { const message = error instanceof Error ? error.message : String(error); return { channel, recipient, status: 'FAILED', lastError: message.slice(0, 500), }; } function smsProvider(): string { return process.env.ACS_SMS_PROVIDER ?? env.smsProvider; } function smsApiUrl(): string | undefined { return process.env.ACS_SMS_API_URL ?? env.smsApiUrl; } function smsApiKey(): string | undefined { return process.env.ACS_SMS_API_KEY ?? env.smsApiKey; } function smsSender(): string | undefined { return process.env.ACS_SMS_SENDER ?? env.smsSender; } function smsTimeoutMs(): number { return Number(process.env.ACS_SMS_TIMEOUT_MS ?? env.smsTimeoutMs); } export async function sendSms(message: SmsMessage): Promise { const configuredProvider = smsProvider(); const provider = configuredProvider.toLowerCase(); const recipient = normalizePhone(message.to); if (!recipient) { return fail(provider.toUpperCase(), recipient, '수신자 휴대폰 번호가 없습니다.'); } if (provider === 'dev') { console.log('[sms:dev]', { to: recipient, text: message.text }); return { channel: 'DEV', recipient, status: 'SENT', lastError: null }; } if (provider === 'hanbank' || provider === 'bok') { return sendHanbankSms(recipient, message.text); } if (provider !== 'http') { return fail(provider.toUpperCase(), recipient, `지원하지 않는 SMS provider입니다: ${configuredProvider}`); } const apiUrl = smsApiUrl(); if (!apiUrl) { return fail('SMS', recipient, 'ACS_SMS_API_URL이 설정되지 않았습니다.'); } const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), smsTimeoutMs()); try { const apiKey = smsApiKey(); const response = await fetch(apiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), }, body: JSON.stringify({ to: recipient, from: smsSender(), message: message.text, }), signal: controller.signal, }); if (!response.ok) { const text = await response.text().catch(() => ''); throw new Error(`SMS API HTTP ${response.status}${text ? `: ${text.slice(0, 300)}` : ''}`); } return { channel: 'SMS', recipient, status: 'SENT', lastError: null }; } catch (error) { return fail('SMS', recipient, error); } finally { clearTimeout(timeout); } } function resolveHanbankSmsUrl(): string { const baseUrl = smsApiUrl() ?? 'http://210.104.132.59:8000'; if (/\/sens\/sms\/?$/i.test(baseUrl)) { return baseUrl; } return `${baseUrl.replace(/\/+$/, '')}/sens/sms`; } function isSuccessResponse(response: SmsApiResponse): boolean { const statusCode = String(response.statusCode ?? '').trim(); const statusName = String(response.statusName ?? '').trim().toLowerCase(); return statusCode === '202' || statusName === 'success'; } async function sendHanbankSms(recipient: string, text: string): Promise { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), smsTimeoutMs()); try { const response = await fetch(resolveHanbankSmsUrl(), { method: 'POST', headers: { accept: 'application/json', 'Content-Type': 'application/json', }, body: JSON.stringify({ receive_number: recipient, content: text, msg_type: 'LMS', reserve_time: '', }), signal: controller.signal, }); const rawBody = await response.text().catch(() => ''); let body: SmsApiResponse = {}; if (rawBody) { try { body = JSON.parse(rawBody) as SmsApiResponse; } catch { body = {}; } } if (!response.ok || (body.statusCode !== undefined && !isSuccessResponse(body))) { const detail = rawBody ? `: ${rawBody.slice(0, 300)}` : ''; throw new Error(`사내 SMS API 발송 실패 HTTP ${response.status}${detail}`); } return { channel: 'HANBANK', recipient, status: 'SENT', lastError: null }; } catch (error) { return fail('HANBANK', recipient, error); } finally { clearTimeout(timeout); } }