feat: [sms] SMS 발송 테스트 메뉴 추가

- SmsController (WEB): 카카오톡 패턴 동일한 HX-Redirect 처리
- BarobillSmsController (API): 발송, 발신번호 조회/확인, 전송상태 조회
- SMS 발송 테스트 블레이드 뷰: 발신번호 목록, 바이트 카운터, 발송 결과 표시
- web.php: barobill/sms/send 라우트 추가
- api.php: barobill/sms API 라우트 4개 추가
This commit is contained in:
김보곤
2026-02-26 10:29:44 +09:00
parent 25a7a87712
commit 401ac649ae
5 changed files with 483 additions and 0 deletions

View File

@@ -0,0 +1,137 @@
<?php
namespace App\Http\Controllers\Api\Admin\Barobill;
use App\Http\Controllers\Controller;
use App\Models\Barobill\BarobillMember;
use App\Services\Barobill\BarobillService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class BarobillSmsController extends Controller
{
public function __construct(
protected BarobillService $barobillService
) {}
/**
* 회원사 조회 및 서버 모드 전환 헬퍼
*/
private function resolveMember(): BarobillMember|JsonResponse
{
$tenantId = session('selected_tenant_id', 1);
$member = BarobillMember::where('tenant_id', $tenantId)->first();
if (! $member) {
return response()->json([
'success' => false,
'message' => '바로빌 회원사가 등록되어 있지 않습니다.',
], 404);
}
$this->barobillService->setServerMode($member->server_mode ?? 'test');
return $member;
}
/**
* SMS 발송
*/
public function sendSms(Request $request): JsonResponse
{
$validated = $request->validate([
'from_number' => 'required|string',
'to_name' => 'required|string',
'to_number' => 'required|string',
'contents' => 'required|string',
'send_dt' => 'nullable|string',
'ref_key' => 'nullable|string',
]);
$member = $this->resolveMember();
if ($member instanceof JsonResponse) {
return $member;
}
$result = $this->barobillService->sendSMSMessage(
corpNum: $member->biz_no,
senderId: $member->barobill_id,
fromNumber: $validated['from_number'],
toName: $validated['to_name'],
toNumber: $validated['to_number'],
contents: $validated['contents'],
sendDT: $validated['send_dt'] ?? '',
refKey: $validated['ref_key'] ?? ''
);
if (! $result['success']) {
return response()->json($result, 422);
}
return response()->json($result);
}
/**
* 등록된 발신번호 목록 조회
*/
public function getFromNumbers(): JsonResponse
{
$member = $this->resolveMember();
if ($member instanceof JsonResponse) {
return $member;
}
$result = $this->barobillService->getSMSFromNumbers($member->biz_no);
if (! $result['success']) {
return response()->json($result, 422);
}
return response()->json($result);
}
/**
* 발신번호 등록 여부 확인
*/
public function checkFromNumber(Request $request): JsonResponse
{
$validated = $request->validate([
'from_number' => 'required|string',
]);
$member = $this->resolveMember();
if ($member instanceof JsonResponse) {
return $member;
}
$result = $this->barobillService->checkSMSFromNumber(
$member->biz_no,
$validated['from_number']
);
if (! $result['success']) {
return response()->json($result, 422);
}
return response()->json($result);
}
/**
* SMS 전송 상태 조회
*/
public function getSendState(string $sendKey): JsonResponse
{
$member = $this->resolveMember();
if ($member instanceof JsonResponse) {
return $member;
}
$result = $this->barobillService->getSMSSendState($member->biz_no, $sendKey);
if (! $result['success']) {
return response()->json($result, 422);
}
return response()->json($result);
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Http\Controllers\Barobill;
use App\Http\Controllers\Controller;
use App\Models\Barobill\BarobillMember;
use App\Models\Tenants\Tenant;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\View\View;
class SmsController extends Controller
{
/**
* SMS 발송 테스트
*/
public function send(Request $request): View|Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('barobill.sms.send'));
}
$tenantId = session('selected_tenant_id', 1);
$currentTenant = Tenant::find($tenantId);
$barobillMember = BarobillMember::where('tenant_id', $tenantId)->first();
return view('barobill.sms.send.index', compact('currentTenant', 'barobillMember'));
}
}

View File

@@ -0,0 +1,304 @@
@extends('layouts.app')
@section('title', 'SMS 발송 테스트')
@section('content')
<div class="flex flex-col h-full">
<!-- 페이지 헤더 -->
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-6 flex-shrink-0">
<div>
<div class="flex items-center gap-2 text-sm text-gray-500 mb-1">
<span>바로빌</span>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg>
<span class="text-gray-700">SMS 발송 테스트</span>
</div>
<h1 class="text-2xl font-bold text-gray-800">SMS 발송 테스트</h1>
<p class="text-sm text-gray-500 mt-1">바로빌 SMS 서비스 연동 테스트 (단문 16.5/)</p>
</div>
</div>
@if(!$barobillMember)
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-6 text-center">
<h3 class="text-lg font-semibold text-yellow-800 mb-1">바로빌 회원사 연동 필요</h3>
<p class="text-sm text-yellow-600">SMS 발송을 위해 먼저 바로빌 회원사를 등록해주세요.</p>
</div>
@else
<div class="space-y-6">
<!-- 발신번호 상태 카드 -->
<div class="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="font-semibold text-gray-800">등록된 발신번호</h3>
<button type="button" onclick="loadFromNumbers()" class="text-xs px-3 py-1 bg-gray-100 text-gray-600 rounded-lg hover:bg-gray-200 transition">
새로고침
</button>
</div>
<div id="from-numbers-area">
<div class="flex items-center gap-2 text-sm text-gray-500">
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg>
발신번호 목록 로딩 ...
</div>
</div>
</div>
<!-- SMS 발송 -->
<div class="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
<h3 class="font-semibold text-gray-800 mb-4">SMS 발송</h3>
<form id="sms-form" class="space-y-4">
<!-- 발신번호 -->
<div>
<label class="block text-sm font-medium text-gray-600 mb-1">발신번호</label>
<select name="from_number" id="sms-from-number" class="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-500">
<option value="">로딩 ...</option>
</select>
</div>
<!-- 수신자 -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-600 mb-1">수신자명</label>
<input type="text" name="to_name" class="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm" placeholder="홍길동">
</div>
<div>
<label class="block text-sm font-medium text-gray-600 mb-1">수신번호</label>
<input type="tel" name="to_number" class="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm" placeholder="01012345678">
</div>
</div>
<!-- 메시지 내용 -->
<div>
<div class="flex items-center justify-between mb-1">
<label class="block text-sm font-medium text-gray-600">메시지 내용</label>
<span id="byte-counter" class="text-xs text-gray-400">0 / 90 바이트</span>
</div>
<textarea name="contents" id="sms-contents" rows="4" class="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm" placeholder="SMS 메시지를 입력하세요 (90바이트 이내)" oninput="updateByteCounter()"></textarea>
<p id="lms-warning" class="text-xs text-orange-500 mt-1 hidden">90바이트 초과 LMS로 발송되며 요금이 다릅니다.</p>
</div>
<!-- 예약 발송 -->
<div>
<label class="block text-sm font-medium text-gray-600 mb-1">예약 발송</label>
<input type="datetime-local" name="send_dt" class="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm">
<p class="text-xs text-gray-500 mt-1">비워두면 즉시 발송됩니다.</p>
</div>
<div class="flex justify-end">
<button type="submit" id="sms-submit-btn" class="px-6 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition font-medium flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" /></svg>
SMS 발송
</button>
</div>
</form>
</div>
<!-- 발송 결과 -->
<div id="send-result-area" class="hidden">
<div class="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
<h3 class="font-semibold text-gray-800 mb-4">발송 결과</h3>
<div id="send-result-content"></div>
</div>
</div>
</div>
@endif
</div>
@endsection
@push('scripts')
<script>
let fromNumberList = [];
function getByteLength(str) {
let byteLen = 0;
for (let i = 0; i < str.length; i++) {
const charCode = str.charCodeAt(i);
byteLen += (charCode > 127 || charCode === 0xFFFD) ? 2 : 1;
}
return byteLen;
}
function updateByteCounter() {
const textarea = document.getElementById('sms-contents');
const counter = document.getElementById('byte-counter');
const warning = document.getElementById('lms-warning');
const bytes = getByteLength(textarea.value);
counter.textContent = bytes + ' / 90 바이트';
if (bytes > 90) {
counter.classList.remove('text-gray-400');
counter.classList.add('text-orange-500');
warning.classList.remove('hidden');
} else {
counter.classList.remove('text-orange-500');
counter.classList.add('text-gray-400');
warning.classList.add('hidden');
}
}
function loadFromNumbers() {
const area = document.getElementById('from-numbers-area');
const select = document.getElementById('sms-from-number');
area.innerHTML = '<div class="flex items-center gap-2 text-sm text-gray-500"><svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg> 발신번호 목록 로딩 중...</div>';
fetch('/api/admin/barobill/sms/from-numbers', {
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }
})
.then(r => r.json())
.then(data => {
if (!data.success) {
area.innerHTML = '<div class="text-sm text-red-500">발신번호 조회 실패: ' + (data.error || data.message || '알 수 없는 오류') + '</div>';
select.innerHTML = '<option value="">발신번호 조회 실패</option>';
return;
}
const raw = data.data;
let numbers = [];
if (Array.isArray(raw)) {
numbers = raw;
} else if (raw && raw.SMSFromNumber) {
numbers = Array.isArray(raw.SMSFromNumber) ? raw.SMSFromNumber : [raw.SMSFromNumber];
} else if (raw && typeof raw === 'string') {
numbers = [{ FromNumber: raw }];
}
fromNumberList = numbers;
if (numbers.length === 0) {
area.innerHTML = '<div class="text-sm text-gray-500">등록된 발신번호가 없습니다. 바로빌에서 발신번호를 먼저 등록해주세요.</div>';
select.innerHTML = '<option value="">등록된 발신번호 없음</option>';
return;
}
// 발신번호 목록 표시
let html = '<div class="flex flex-wrap gap-2">';
numbers.forEach(num => {
const number = num.FromNumber || num;
html += '<span class="inline-flex items-center gap-1 px-3 py-1 bg-blue-50 text-blue-700 rounded-full text-sm">';
html += '<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" /></svg>';
html += number;
html += '</span>';
});
html += '</div>';
area.innerHTML = html;
// 셀렉트 채우기
select.innerHTML = '<option value="">-- 발신번호 선택 --</option>';
numbers.forEach(num => {
const number = num.FromNumber || num;
const opt = document.createElement('option');
opt.value = number;
opt.textContent = number;
select.appendChild(opt);
});
})
.catch(err => {
area.innerHTML = '<div class="text-sm text-red-500">API 오류: ' + err.message + '</div>';
select.innerHTML = '<option value="">API 오류</option>';
});
}
function formatReserveDT(datetimeLocal) {
if (!datetimeLocal) return '';
return datetimeLocal.replace(/[-T:]/g, '').substring(0, 14);
}
function showSendResult(data) {
const area = document.getElementById('send-result-area');
const content = document.getElementById('send-result-content');
area.classList.remove('hidden');
if (data.success) {
const sendKey = data.data || '';
content.innerHTML = `
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
<div class="flex items-center gap-2 mb-2">
<svg class="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
<span class="font-semibold text-green-800">발송 성공</span>
</div>
<p class="text-sm text-green-700 mb-3">전송키: <code class="bg-green-100 px-2 py-0.5 rounded">${sendKey}</code></p>
<button type="button" onclick="checkSendState('${sendKey}')" class="text-xs px-3 py-1.5 bg-green-600 text-white rounded-lg hover:bg-green-700 transition">
전송 상태 조회
</button>
<div id="send-state-result" class="mt-3"></div>
</div>`;
} else {
content.innerHTML = `
<div class="bg-red-50 border border-red-200 rounded-lg p-4">
<div class="flex items-center gap-2 mb-2">
<svg class="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
<span class="font-semibold text-red-800">발송 실패</span>
</div>
<p class="text-sm text-red-700">${data.error || data.message || '알 수 없는 오류'}</p>
</div>`;
}
}
function checkSendState(sendKey) {
const stateArea = document.getElementById('send-state-result');
stateArea.innerHTML = '<div class="text-sm text-gray-500">상태 조회 중...</div>';
fetch('/api/admin/barobill/sms/send-state/' + encodeURIComponent(sendKey), {
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }
})
.then(r => r.json())
.then(data => {
if (data.success) {
const state = data.data;
let stateHtml = '<div class="bg-white border rounded-lg p-3 text-sm">';
stateHtml += '<p class="font-medium text-gray-700 mb-1">전송 상태 상세:</p>';
stateHtml += '<pre class="bg-gray-50 p-2 rounded text-xs overflow-auto">' + JSON.stringify(state, null, 2) + '</pre>';
stateHtml += '</div>';
stateArea.innerHTML = stateHtml;
} else {
stateArea.innerHTML = '<div class="text-sm text-red-500">상태 조회 실패: ' + (data.error || data.message) + '</div>';
}
})
.catch(err => {
stateArea.innerHTML = '<div class="text-sm text-red-500">API 오류: ' + err.message + '</div>';
});
}
// SMS 발송 폼 제출
document.getElementById('sms-form')?.addEventListener('submit', function(e) {
e.preventDefault();
const fd = new FormData(this);
const btn = document.getElementById('sms-submit-btn');
const body = {
from_number: fd.get('from_number'),
to_name: fd.get('to_name'),
to_number: fd.get('to_number'),
contents: fd.get('contents'),
send_dt: formatReserveDT(fd.get('send_dt')),
};
if (!body.from_number) { alert('발신번호를 선택해주세요.'); return; }
if (!body.to_name) { alert('수신자명을 입력해주세요.'); return; }
if (!body.to_number) { alert('수신번호를 입력해주세요.'); return; }
if (!body.contents) { alert('메시지 내용을 입력해주세요.'); return; }
btn.disabled = true;
btn.textContent = '발송 중...';
fetch('/api/admin/barobill/sms/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content
},
body: JSON.stringify(body),
})
.then(r => r.json())
.then(data => {
btn.disabled = false;
btn.innerHTML = '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" /></svg> SMS 발송';
showSendResult(data);
})
.catch(err => {
btn.disabled = false;
btn.innerHTML = '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" /></svg> SMS 발송';
showSendResult({ success: false, error: err.message });
});
});
document.addEventListener('DOMContentLoaded', loadFromNumbers);
</script>
@endpush

View File

@@ -225,6 +225,14 @@
Route::delete('/send/{sendKey}/cancel', [\App\Http\Controllers\Api\Admin\Barobill\BarobillKakaotalkController::class, 'cancelReserved'])->name('send.cancel');
});
// 바로빌 SMS API
Route::prefix('barobill/sms')->name('barobill.sms.')->group(function () {
Route::post('/send', [\App\Http\Controllers\Api\Admin\Barobill\BarobillSmsController::class, 'sendSms'])->name('send');
Route::get('/from-numbers', [\App\Http\Controllers\Api\Admin\Barobill\BarobillSmsController::class, 'getFromNumbers'])->name('from-numbers');
Route::post('/check-from-number', [\App\Http\Controllers\Api\Admin\Barobill\BarobillSmsController::class, 'checkFromNumber'])->name('check-from-number');
Route::get('/send-state/{sendKey}', [\App\Http\Controllers\Api\Admin\Barobill\BarobillSmsController::class, 'getSendState'])->name('send-state');
});
// 테넌트 관리 API
Route::prefix('tenants')->name('tenants.')->group(function () {
// 고정 경로는 먼저 정의

View File

@@ -631,6 +631,11 @@
Route::get('/history', [\App\Http\Controllers\Barobill\KakaotalkController::class, 'history'])->name('history');
Route::get('/guide', [\App\Http\Controllers\Barobill\KakaotalkController::class, 'guide'])->name('guide');
});
// SMS 발송 테스트
Route::prefix('sms')->name('sms.')->group(function () {
Route::get('/send', [\App\Http\Controllers\Barobill\SmsController::class, 'send'])->name('send');
});
});
/*