feat: support internal SMS API

This commit is contained in:
unknown
2026-07-16 14:46:57 +09:00
parent bd3d933a40
commit ae1f30592d
5 changed files with 194 additions and 45 deletions

View File

@@ -0,0 +1,37 @@
import assert from 'node:assert/strict';
import test from 'node:test';
test('hanbank provider posts the internal SMS API payload shape', async () => {
process.env.ACS_SMS_PROVIDER = 'hanbank';
process.env.ACS_SMS_API_URL = 'http://sms.example.test:8000';
const calls: Array<{ input: string | URL | Request; init?: RequestInit }> = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input, init) => {
calls.push({ input, init });
return new Response(JSON.stringify({ statusCode: '202', statusName: 'success' }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}) as typeof fetch;
try {
const { sendSms } = await import('./sms.js');
const result = await sendSms({
to: '010-1234-5678',
text: '[ACS] QR 출입증 링크',
});
assert.equal(result.status, 'SENT');
assert.equal(result.channel, 'HANBANK');
assert.equal(String(calls[0]?.input), 'http://sms.example.test:8000/sens/sms');
assert.deepEqual(JSON.parse(String(calls[0]?.init?.body)), {
receive_number: '01012345678',
content: '[ACS] QR 출입증 링크',
msg_type: 'LMS',
reserve_time: '',
});
} finally {
globalThis.fetch = originalFetch;
}
});

View File

@@ -12,8 +12,15 @@ export interface SmsMessage {
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, '');
return String(value ?? '').replace(/\D/g, '');
}
function fail(channel: string, recipient: string, error: unknown): SmsSendResult {
@@ -26,8 +33,29 @@ function fail(channel: string, recipient: string, error: unknown): SmsSendResult
};
}
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 provider = env.smsProvider.toLowerCase();
const configuredProvider = smsProvider();
const provider = configuredProvider.toLowerCase();
const recipient = normalizePhone(message.to);
if (!recipient) {
@@ -39,27 +67,33 @@ export async function sendSms(message: SmsMessage): Promise<SmsSendResult> {
return { channel: 'DEV', recipient, status: 'SENT', lastError: null };
}
if (provider !== 'http') {
return fail(provider.toUpperCase(), recipient, `지원하지 않는 SMS provider입니다: ${env.smsProvider}`);
if (provider === 'hanbank' || provider === 'bok') {
return sendHanbankSms(recipient, message.text);
}
if (!env.smsApiUrl) {
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(), env.smsTimeoutMs);
const timeout = setTimeout(() => controller.abort(), smsTimeoutMs());
try {
const response = await fetch(env.smsApiUrl, {
const apiKey = smsApiKey();
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(env.smsApiKey ? { Authorization: `Bearer ${env.smsApiKey}` } : {}),
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
},
body: JSON.stringify({
to: recipient,
from: env.smsSender,
from: smsSender(),
message: message.text,
}),
signal: controller.signal,
@@ -77,3 +111,60 @@ export async function sendSms(message: SmsMessage): Promise<SmsSendResult> {
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<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);
}
}