feat: add SMS diagnostics admin check
This commit is contained in:
@@ -829,3 +829,10 @@
|
|||||||
- `npm.cmd run build:frontend` 성공.
|
- `npm.cmd run build:frontend` 성공.
|
||||||
- `npm.cmd run build:server` 성공.
|
- `npm.cmd run build:server` 성공.
|
||||||
- `npm.cmd test` 성공.
|
- `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 서버 도달 성공으로 판단 가능.
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
PassDelivery,
|
PassDelivery,
|
||||||
PurposeCode,
|
PurposeCode,
|
||||||
Role,
|
Role,
|
||||||
|
SmsDiagnosticsResult,
|
||||||
StatsSummary,
|
StatsSummary,
|
||||||
PublicPass,
|
PublicPass,
|
||||||
Team,
|
Team,
|
||||||
@@ -185,6 +186,8 @@ export const updateAdminPurposeCode = (id: number, payload: Omit<PurposeCode, 'i
|
|||||||
export const getWatcher1Settings = () => request<Watcher1Settings>('/settings/watcher1');
|
export const getWatcher1Settings = () => request<Watcher1Settings>('/settings/watcher1');
|
||||||
export const updateWatcher1Settings = (payload: Watcher1Settings) =>
|
export const updateWatcher1Settings = (payload: Watcher1Settings) =>
|
||||||
request<Watcher1Settings>('/admin/settings/watcher1', jsonInit('PUT', payload));
|
request<Watcher1Settings>('/admin/settings/watcher1', jsonInit('PUT', payload));
|
||||||
|
export const runSmsDiagnostics = () =>
|
||||||
|
request<SmsDiagnosticsResult>('/admin/sms/diagnostics', { method: 'POST' });
|
||||||
export const listAdminUsers = () => request<AdminUser[]>('/admin/users');
|
export const listAdminUsers = () => request<AdminUser[]>('/admin/users');
|
||||||
export const updateAdminUserRoles = (id: number, roles: Role[]) =>
|
export const updateAdminUserRoles = (id: number, roles: Role[]) =>
|
||||||
request<AdminUser>(`/admin/users/${id}/roles`, jsonInit('PUT', { roles }));
|
request<AdminUser>(`/admin/users/${id}/roles`, jsonInit('PUT', { roles }));
|
||||||
|
|||||||
@@ -12,14 +12,15 @@ import {
|
|||||||
updateAdminPurposeCode,
|
updateAdminPurposeCode,
|
||||||
updateAdminUserTeam,
|
updateAdminUserTeam,
|
||||||
updateAdminUserRoles,
|
updateAdminUserRoles,
|
||||||
|
runSmsDiagnostics,
|
||||||
uploadAdminTeams,
|
uploadAdminTeams,
|
||||||
uploadAdminUserRoles,
|
uploadAdminUserRoles,
|
||||||
updateWatcher1Settings,
|
updateWatcher1Settings,
|
||||||
getWatcher1Settings,
|
getWatcher1Settings,
|
||||||
} from '../api';
|
} 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 = {
|
const EMPTY_PURPOSE = {
|
||||||
code: '',
|
code: '',
|
||||||
@@ -137,6 +138,7 @@ export const AdminManagementPage: React.FC = () => {
|
|||||||
const [teamUploadResult, setTeamUploadResult] = useState<AdminExcelImportResult | null>(null);
|
const [teamUploadResult, setTeamUploadResult] = useState<AdminExcelImportResult | null>(null);
|
||||||
const [userRoleUploadFile, setUserRoleUploadFile] = useState<File | null>(null);
|
const [userRoleUploadFile, setUserRoleUploadFile] = useState<File | null>(null);
|
||||||
const [userRoleUploadResult, setUserRoleUploadResult] = useState<AdminExcelImportResult | null>(null);
|
const [userRoleUploadResult, setUserRoleUploadResult] = useState<AdminExcelImportResult | null>(null);
|
||||||
|
const [smsDiagnostics, setSmsDiagnostics] = useState<SmsDiagnosticsResult | null>(null);
|
||||||
const [notice, setNotice] = useState<string | null>(null);
|
const [notice, setNotice] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
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) => {
|
const editPurpose = (p: PurposeCode) => {
|
||||||
setEditingPurposeId(p.id);
|
setEditingPurposeId(p.id);
|
||||||
setPurposeForm({
|
setPurposeForm({
|
||||||
@@ -404,6 +421,7 @@ export const AdminManagementPage: React.FC = () => {
|
|||||||
<button className={tab === 'purpose' ? 'tab-active' : ''} onClick={() => setTab('purpose')}>출입목적 코드</button>
|
<button className={tab === 'purpose' ? 'tab-active' : ''} onClick={() => setTab('purpose')}>출입목적 코드</button>
|
||||||
<button className={tab === 'teams' ? 'tab-active' : ''} onClick={() => setTab('teams')}>팀 관리</button>
|
<button className={tab === 'teams' ? 'tab-active' : ''} onClick={() => setTab('teams')}>팀 관리</button>
|
||||||
<button className={tab === 'roles' ? 'tab-active' : ''} onClick={() => setTab('roles')}>권한관리</button>
|
<button className={tab === 'roles' ? 'tab-active' : ''} onClick={() => setTab('roles')}>권한관리</button>
|
||||||
|
<button className={tab === 'sms' ? 'tab-active' : ''} onClick={() => setTab('sms')}>SMS 점검</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tab === 'watcher' && (
|
{tab === 'watcher' && (
|
||||||
@@ -579,6 +597,32 @@ export const AdminManagementPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{tab === 'sms' && (
|
||||||
|
<div className="card">
|
||||||
|
<div className="admin-upload-head">
|
||||||
|
<h3>SMS API 연결 점검</h3>
|
||||||
|
<button className="btn-primary" type="button" disabled={busy} onClick={() => void checkSms()}>
|
||||||
|
점검 실행
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{smsDiagnostics ? (
|
||||||
|
<table className="table diagnostic-table">
|
||||||
|
<tbody>
|
||||||
|
<tr><th>Provider</th><td>{smsDiagnostics.provider}</td></tr>
|
||||||
|
<tr><th>URL</th><td>{smsDiagnostics.url || '-'}</td></tr>
|
||||||
|
<tr><th>연결</th><td className={smsDiagnostics.reachable ? '' : 'text-danger'}>{smsDiagnostics.reachable ? '성공' : '실패'}</td></tr>
|
||||||
|
<tr><th>HTTP 상태</th><td>{smsDiagnostics.httpStatus ?? '-'}</td></tr>
|
||||||
|
<tr><th>소요시간</th><td>{smsDiagnostics.elapsedMs} ms</td></tr>
|
||||||
|
<tr><th>점검시각</th><td>{new Date(smsDiagnostics.checkedAt).toLocaleString()}</td></tr>
|
||||||
|
<tr><th>오류</th><td className={smsDiagnostics.error ? 'text-danger' : ''}>{smsDiagnostics.error ?? '-'}</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
) : (
|
||||||
|
<p className="muted">점검 실행 버튼을 누르면 서버 컨테이너에서 현재 SMS API URL로 연결을 확인합니다.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -443,6 +443,12 @@ button:disabled { opacity: .55; cursor: not-allowed; }
|
|||||||
.admin-preview-table td {
|
.admin-preview-table td {
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
}
|
}
|
||||||
|
.diagnostic-table th {
|
||||||
|
width: 140px;
|
||||||
|
}
|
||||||
|
.diagnostic-table td {
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
.row-error {
|
.row-error {
|
||||||
background: #fff7f7;
|
background: #fff7f7;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,17 @@ export interface Watcher1Settings {
|
|||||||
contact: string;
|
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 {
|
export interface Team {
|
||||||
id: number;
|
id: number;
|
||||||
code: string;
|
code: string;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import ExcelJS from 'exceljs';
|
|||||||
import { ok } from '../http/apiResponse.js';
|
import { ok } from '../http/apiResponse.js';
|
||||||
import { ApiError } from '../http/errors.js';
|
import { ApiError } from '../http/errors.js';
|
||||||
import { query } from '../db/pool.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';
|
import { parseRole, parseRoles, requireAdmin, requireCurrentUser, type QueryFn, type UserRow } from './security.js';
|
||||||
|
|
||||||
type Row = Record<string, unknown>;
|
type Row = Record<string, unknown>;
|
||||||
@@ -469,6 +470,11 @@ export function createAdminRouter(deps: AdminRouterDeps = { query }): Router {
|
|||||||
ok(res, await watcher1(dbQuery));
|
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) => {
|
router.get('/teams', asyncRoute(async (req, res) => {
|
||||||
await requireAdmin(dbQuery, req);
|
await requireAdmin(dbQuery, req);
|
||||||
ok(res, await listTeams(dbQuery));
|
ok(res, await listTeams(dbQuery));
|
||||||
|
|||||||
@@ -12,6 +12,17 @@ export interface SmsMessage {
|
|||||||
text: 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 = {
|
type SmsApiResponse = {
|
||||||
requestId?: string;
|
requestId?: string;
|
||||||
requestTime?: string;
|
requestTime?: string;
|
||||||
@@ -120,6 +131,62 @@ function resolveHanbankSmsUrl(): string {
|
|||||||
return `${baseUrl.replace(/\/+$/, '')}/sens/sms`;
|
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 {
|
function isSuccessResponse(response: SmsApiResponse): boolean {
|
||||||
const statusCode = String(response.statusCode ?? '').trim();
|
const statusCode = String(response.statusCode ?? '').trim();
|
||||||
const statusName = String(response.statusName ?? '').trim().toLowerCase();
|
const statusName = String(response.statusName ?? '').trim().toLowerCase();
|
||||||
|
|||||||
Reference in New Issue
Block a user