238 lines
6.3 KiB
TypeScript
238 lines
6.3 KiB
TypeScript
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;
|
|
}
|
|
|
|
export interface SmsDiagnosticsResult {
|
|
provider: string;
|
|
url: string;
|
|
ok: boolean;
|
|
reachable: boolean;
|
|
httpStatus: number | null;
|
|
elapsedMs: number;
|
|
checkedAt: string;
|
|
error: string | null;
|
|
}
|
|
|
|
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<SmsSendResult> {
|
|
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`;
|
|
}
|
|
|
|
export async function diagnoseSmsConnectivity(): Promise<SmsDiagnosticsResult> {
|
|
const provider = smsProvider();
|
|
const url = provider.toLowerCase() === 'hanbank' || provider.toLowerCase() === 'bok'
|
|
? resolveHanbankSmsUrl()
|
|
: smsApiUrl() ?? '';
|
|
const startedAt = Date.now();
|
|
|
|
if (!url) {
|
|
return {
|
|
provider,
|
|
url,
|
|
ok: false,
|
|
reachable: false,
|
|
httpStatus: null,
|
|
elapsedMs: 0,
|
|
checkedAt: new Date().toISOString(),
|
|
error: 'ACS_SMS_API_URL이 설정되지 않았습니다.',
|
|
};
|
|
}
|
|
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), smsTimeoutMs());
|
|
|
|
try {
|
|
const response = await fetch(url, {
|
|
method: 'GET',
|
|
headers: { accept: 'application/json' },
|
|
signal: controller.signal,
|
|
});
|
|
return {
|
|
provider,
|
|
url,
|
|
ok: true,
|
|
reachable: true,
|
|
httpStatus: response.status,
|
|
elapsedMs: Date.now() - startedAt,
|
|
checkedAt: new Date().toISOString(),
|
|
error: null,
|
|
};
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return {
|
|
provider,
|
|
url,
|
|
ok: false,
|
|
reachable: false,
|
|
httpStatus: null,
|
|
elapsedMs: Date.now() - startedAt,
|
|
checkedAt: new Date().toISOString(),
|
|
error: message.slice(0, 500),
|
|
};
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|
|
|
|
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<SmsSendResult> {
|
|
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);
|
|
}
|
|
}
|