feat: support internal SMS API
This commit is contained in:
@@ -814,3 +814,18 @@
|
||||
- `npm.cmd run typecheck` 성공.
|
||||
- `npm.cmd test` 성공.
|
||||
- `npm.cmd run build` 성공.
|
||||
|
||||
- 2026-07-16 사내 SMS API 가이드 반영 및 일시 선택 UI 개선:
|
||||
- `C:\ai-bok\SMS API가이드.pdf`를 로컬에서만 확인했고, PDF 원문 및 민감 자료는 저장소/서버 배포물에 포함하지 않음.
|
||||
- 사내 메시지 발송은 네이버 API 직접 호출이 아니라 DMZ 보안 API 호출 방식임을 확인.
|
||||
- Node SMS 어댑터에 `ACS_SMS_PROVIDER=hanbank`/`bok` provider를 추가:
|
||||
- `POST /sens/sms`
|
||||
- 요청 필드: `receive_number`, `content`, `msg_type=LMS`, `reserve_time`
|
||||
- 응답 `statusCode=202` 또는 `statusName=success`를 성공으로 처리.
|
||||
- 기존 `ACS_SMS_PROVIDER=dev`는 로컬/서버 모의 발송 검증용으로 유지.
|
||||
- 출입신청 일시 선택 UI는 입력칸 옆 외부 `적용` 버튼을 제거하고, 캘린더 팝업 우측 상단 `닫기` 버튼으로 닫도록 변경.
|
||||
- 검증:
|
||||
- `npm.cmd run typecheck` 성공.
|
||||
- `npm.cmd run build:frontend` 성공.
|
||||
- `npm.cmd run build:server` 성공.
|
||||
- `npm.cmd test` 성공.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useRef } from 'react';
|
||||
import DatePicker, { registerLocale } from 'react-datepicker';
|
||||
import { ko } from 'date-fns/locale';
|
||||
import 'react-datepicker/dist/react-datepicker.css';
|
||||
@@ -23,23 +23,13 @@ function fromLocalString(value: string): Date | null {
|
||||
}
|
||||
|
||||
export const DateTimePicker: React.FC<Props> = ({ value, onChange, placeholder }) => {
|
||||
const [draft, setDraft] = useState<Date | null>(fromLocalString(value));
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(fromLocalString(value));
|
||||
}, [value]);
|
||||
|
||||
const apply = () => {
|
||||
if (draft) {
|
||||
onChange(toLocalString(draft));
|
||||
}
|
||||
};
|
||||
const pickerRef = useRef<DatePicker>(null);
|
||||
|
||||
return (
|
||||
<div className="dt-apply-row">
|
||||
<DatePicker
|
||||
selected={draft}
|
||||
onChange={(d: Date | null) => setDraft(d)}
|
||||
ref={pickerRef}
|
||||
selected={fromLocalString(value)}
|
||||
onChange={(d: Date | null) => d && onChange(toLocalString(d))}
|
||||
showTimeSelect
|
||||
timeIntervals={5}
|
||||
timeCaption="시간"
|
||||
@@ -51,10 +41,22 @@ export const DateTimePicker: React.FC<Props> = ({ value, onChange, placeholder }
|
||||
placeholderText={placeholder ?? '날짜와 시간을 선택하세요'}
|
||||
className="dt-input"
|
||||
popperClassName="acs-datepicker"
|
||||
/>
|
||||
<button type="button" className="btn-ghost dt-apply-button" onClick={apply} disabled={!draft}>
|
||||
적용
|
||||
renderCustomHeader={({ date, decreaseMonth, increaseMonth, prevMonthButtonDisabled, nextMonthButtonDisabled }) => (
|
||||
<div className="dt-picker-header">
|
||||
<button type="button" className="dt-picker-nav" onClick={decreaseMonth} disabled={prevMonthButtonDisabled} aria-label="이전 달">
|
||||
‹
|
||||
</button>
|
||||
<span className="dt-picker-title">{date.getFullYear()}.{pad(date.getMonth() + 1)}</span>
|
||||
<div className="dt-picker-header-actions">
|
||||
<button type="button" className="dt-picker-nav" onClick={increaseMonth} disabled={nextMonthButtonDisabled} aria-label="다음 달">
|
||||
›
|
||||
</button>
|
||||
<button type="button" className="dt-picker-close" onClick={() => pickerRef.current?.setOpen(false)} aria-label="닫기">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -128,11 +128,15 @@ a { color: inherit; text-decoration: none; }
|
||||
/* react-datepicker: make the input fill the field like the native ones */
|
||||
.field .react-datepicker-wrapper { width: 100%; }
|
||||
.field .react-datepicker__input-container input { width: 100%; box-sizing: border-box; }
|
||||
/* clearer popup border + shadow, and a stable field-side apply button */
|
||||
/* clearer popup border + shadow */
|
||||
.acs-datepicker .react-datepicker { border: 1px solid var(--border); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); }
|
||||
.dt-apply-row { display: flex; align-items: center; gap: 8px; }
|
||||
.dt-apply-row .react-datepicker-wrapper { flex: 1; min-width: 0; }
|
||||
.dt-apply-button { flex: 0 0 auto; min-height: 40px; padding-inline: 14px; }
|
||||
.dt-picker-header { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 8px 4px; }
|
||||
.dt-picker-title { font-weight: 700; color: var(--text); }
|
||||
.dt-picker-header-actions { display: flex; align-items: center; gap: 4px; }
|
||||
.dt-picker-nav,
|
||||
.dt-picker-close { width: 28px; height: 28px; border: 1px solid var(--border); border-radius: 6px; background: #fff; color: var(--text); font-size: 18px; line-height: 1; cursor: pointer; }
|
||||
.dt-picker-nav:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.dt-picker-close { color: #b42318; font-size: 20px; }
|
||||
|
||||
/* In-app modal dialog (replaces window.prompt/confirm) */
|
||||
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 1000; padding: 16px; }
|
||||
|
||||
37
server/services/sms.test.ts
Normal file
37
server/services/sms.test.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user