diff --git a/docs/ACS작업일지.md b/docs/ACS작업일지.md index 0306aff..f40b6f4 100644 --- a/docs/ACS작업일지.md +++ b/docs/ACS작업일지.md @@ -829,3 +829,10 @@ - `npm.cmd run build:frontend` 성공. - `npm.cmd run build:server` 성공. - `npm.cmd test` 성공. + +- 2026-07-16 시스템관리 SMS 연결 점검 기능 추가: + - Coolify 서버 화면에서 컨테이너 터미널 접근 메뉴가 보이지 않아, 운영자가 화면에서 직접 점검할 수 있도록 `시스템관리 > SMS 점검` 탭 추가. + - ADMIN 전용 `POST /api/admin/sms/diagnostics` 추가. + - 실제 문자 발송 없이 현재 SMS provider/API URL 기준으로 서버 컨테이너에서 `/sens/sms` 연결을 짧게 점검. + - 점검 결과로 provider, URL, 연결 성공/실패, HTTP 상태, 소요시간, 오류 메시지를 표시. + - timeout이면 방화벽/라우팅/허용 IP 문제, HTTP 응답이면 API 서버 도달 성공으로 판단 가능. diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 9380b7d..4837759 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -15,6 +15,7 @@ import { PassDelivery, PurposeCode, Role, + SmsDiagnosticsResult, StatsSummary, PublicPass, Team, @@ -185,6 +186,8 @@ export const updateAdminPurposeCode = (id: number, payload: Omit request('/settings/watcher1'); export const updateWatcher1Settings = (payload: Watcher1Settings) => request('/admin/settings/watcher1', jsonInit('PUT', payload)); +export const runSmsDiagnostics = () => + request('/admin/sms/diagnostics', { method: 'POST' }); export const listAdminUsers = () => request('/admin/users'); export const updateAdminUserRoles = (id: number, roles: Role[]) => request(`/admin/users/${id}/roles`, jsonInit('PUT', { roles })); diff --git a/frontend/src/pages/AdminManagementPage.tsx b/frontend/src/pages/AdminManagementPage.tsx index 2ad4a00..8c900d1 100644 --- a/frontend/src/pages/AdminManagementPage.tsx +++ b/frontend/src/pages/AdminManagementPage.tsx @@ -12,14 +12,15 @@ import { updateAdminPurposeCode, updateAdminUserTeam, updateAdminUserRoles, + runSmsDiagnostics, uploadAdminTeams, uploadAdminUserRoles, updateWatcher1Settings, getWatcher1Settings, } from '../api'; -import { AdminExcelImportResult, AdminUser, PurposeCode, Role, Team, Watcher1Settings } from '../types'; +import { AdminExcelImportResult, AdminUser, PurposeCode, Role, SmsDiagnosticsResult, Team, Watcher1Settings } from '../types'; -type Tab = 'watcher' | 'purpose' | 'teams' | 'roles'; +type Tab = 'watcher' | 'purpose' | 'teams' | 'roles' | 'sms'; const EMPTY_PURPOSE = { code: '', @@ -137,6 +138,7 @@ export const AdminManagementPage: React.FC = () => { const [teamUploadResult, setTeamUploadResult] = useState(null); const [userRoleUploadFile, setUserRoleUploadFile] = useState(null); const [userRoleUploadResult, setUserRoleUploadResult] = useState(null); + const [smsDiagnostics, setSmsDiagnostics] = useState(null); const [notice, setNotice] = useState(null); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); @@ -169,6 +171,21 @@ export const AdminManagementPage: React.FC = () => { } }; + const checkSms = async () => { + setBusy(true); + setError(null); + setNotice(null); + try { + const result = await runSmsDiagnostics(); + setSmsDiagnostics(result); + setNotice(result.reachable ? 'SMS API 연결 점검이 완료되었습니다.' : 'SMS API 연결 점검에 실패했습니다.'); + } catch (err) { + setError(err instanceof Error ? err.message : 'SMS API 연결 점검 실패'); + } finally { + setBusy(false); + } + }; + const editPurpose = (p: PurposeCode) => { setEditingPurposeId(p.id); setPurposeForm({ @@ -404,6 +421,7 @@ export const AdminManagementPage: React.FC = () => { + {tab === 'watcher' && ( @@ -579,6 +597,32 @@ export const AdminManagementPage: React.FC = () => { )} + + {tab === 'sms' && ( +
+
+

SMS API 연결 점검

+ +
+ {smsDiagnostics ? ( + + + + + + + + + + +
Provider{smsDiagnostics.provider}
URL{smsDiagnostics.url || '-'}
연결{smsDiagnostics.reachable ? '성공' : '실패'}
HTTP 상태{smsDiagnostics.httpStatus ?? '-'}
소요시간{smsDiagnostics.elapsedMs} ms
점검시각{new Date(smsDiagnostics.checkedAt).toLocaleString()}
오류{smsDiagnostics.error ?? '-'}
+ ) : ( +

점검 실행 버튼을 누르면 서버 컨테이너에서 현재 SMS API URL로 연결을 확인합니다.

+ )} +
+ )} ); }; diff --git a/frontend/src/styles/common.css b/frontend/src/styles/common.css index d83b179..dc96570 100644 --- a/frontend/src/styles/common.css +++ b/frontend/src/styles/common.css @@ -443,6 +443,12 @@ button:disabled { opacity: .55; cursor: not-allowed; } .admin-preview-table td { vertical-align: top; } +.diagnostic-table th { + width: 140px; +} +.diagnostic-table td { + word-break: break-word; +} .row-error { background: #fff7f7; } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 7d118c0..e41ef73 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -48,6 +48,17 @@ export interface Watcher1Settings { contact: string; } +export interface SmsDiagnosticsResult { + provider: string; + url: string; + ok: boolean; + reachable: boolean; + httpStatus: number | null; + elapsedMs: number; + checkedAt: string; + error: string | null; +} + export interface Team { id: number; code: string; diff --git a/server/routes/admin.ts b/server/routes/admin.ts index 68bcae4..c9e4d67 100644 --- a/server/routes/admin.ts +++ b/server/routes/admin.ts @@ -5,6 +5,7 @@ import ExcelJS from 'exceljs'; import { ok } from '../http/apiResponse.js'; import { ApiError } from '../http/errors.js'; import { query } from '../db/pool.js'; +import { diagnoseSmsConnectivity } from '../services/sms.js'; import { parseRole, parseRoles, requireAdmin, requireCurrentUser, type QueryFn, type UserRow } from './security.js'; type Row = Record; @@ -469,6 +470,11 @@ export function createAdminRouter(deps: AdminRouterDeps = { query }): Router { ok(res, await watcher1(dbQuery)); })); + router.post('/sms/diagnostics', asyncRoute(async (req, res) => { + await requireAdmin(dbQuery, req); + ok(res, await diagnoseSmsConnectivity()); + })); + router.get('/teams', asyncRoute(async (req, res) => { await requireAdmin(dbQuery, req); ok(res, await listTeams(dbQuery)); diff --git a/server/services/sms.ts b/server/services/sms.ts index bfe5960..9fc3206 100644 --- a/server/services/sms.ts +++ b/server/services/sms.ts @@ -12,6 +12,17 @@ export interface SmsMessage { 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; @@ -120,6 +131,62 @@ function resolveHanbankSmsUrl(): string { return `${baseUrl.replace(/\/+$/, '')}/sens/sms`; } +export async function diagnoseSmsConnectivity(): Promise { + 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();