SmsLib.php 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461
  1. <?php
  2. namespace App\Models;
  3. use Exception;
  4. class SmsLib
  5. {
  6. public string $token;
  7. public string $host;
  8. public string $port;
  9. public string $id;
  10. public string $pw;
  11. public string $callNum;
  12. public array $data;
  13. public array $error;
  14. public array $history; // 문자 전송 내역 정보
  15. public array $result; // 문자 전송 결과 정보
  16. public int $total; // 전송 횟수
  17. public int $success; // 성공 횟수
  18. public int $failed; // 실패 횟수
  19. public array $message; // 처리 결과
  20. public function __construct()
  21. {
  22. $configModel = new Config();
  23. $this->token = $configModel->item('sms_icode_token', env('SMS_TOKEN'));
  24. $this->host = $configModel->item('sms_icode_host', env('SMS_IP'));
  25. $this->port = $configModel->item('sms_icode_port', env('SMS_PORT'));
  26. $this->id = $configModel->item('sms_icode_id', env('SMS_ID'));
  27. $this->pw = $configModel->item('sms_icode_pw', env('SMS_PW'));
  28. $this->callNum = $configModel->item('sms_icode_call_num', env('SMS_NUMBER'));
  29. $this->_init();
  30. }
  31. /**
  32. * 초기화
  33. */
  34. private function _init(): void
  35. {
  36. $this->data = [];
  37. $this->error = [];
  38. $this->history = [];
  39. $this->result = [];
  40. $this->message = [];
  41. $this->total = 0;
  42. $this->success = 0;
  43. $this->failed = 0;
  44. }
  45. /**
  46. * 아이코드 계정 정보 조회
  47. */
  48. public function info(): array
  49. {
  50. $userInfo = explode(';', getSock("http://www.icodekorea.com/res/userinfo.php?userid={$this->id}&userpw={$this->pw}"));
  51. return [
  52. 'code' => $userInfo[0], // 결과코드
  53. 'coin' => $userInfo[1], // 고객 잔액 (충전제만 해당)
  54. 'gpay' => $userInfo[2], // 고객의 건수 별 차감액 표시 (충전제만 해당)
  55. 'payment' => $userInfo[3] // 요금제 표시, A:충전제, C:정액제
  56. ];
  57. }
  58. /**
  59. * 전송 패킷 생성
  60. * Add(수신번호목록(배열), 발신번호, 전송내용(2000자이내), 제목(옵션, 30자이내), 예약일자(옵션, 12자리)
  61. */
  62. private function _add($sendNumber = [], $sendData = []): bool
  63. {
  64. $this->_init();
  65. $userID = ($sendData['userID'] ?? null);
  66. $subject = ($sendData['subject'] ?? null);
  67. $content = ($sendData['content'] ?? null);
  68. $isReserve = ($sendData['isReserve'] ?? 0);
  69. $reserveAt = ($sendData['reserveAt'] ?? null);
  70. $callBack = $this->callNum;
  71. if(!$userID) {
  72. $this->error[] = '발신 회원번호가 존재하지 않습니다.';
  73. return false;
  74. }
  75. // 내용 개행치환
  76. $content = preg_replace("/\r\n/", "\n", $content);
  77. $content = preg_replace("/\r/", "\n", $content);
  78. // 문자 타입별 Port 설정.
  79. $sendType = (strlen($content) > 90 ? 1 : 2); // 0: SMS / 1: LMS
  80. if($sendType == 1) { // LMS일 경우 제목이 있고 SMS면 제목을 앞에 붙여준다.
  81. $subject = "";
  82. }else{
  83. $content = ($subject . ' ' . $content);
  84. }
  85. // 예약날짜 형식 변환
  86. if($isReserve) {
  87. $reserveAt = date("YmdHi", strtotime($reserveAt));
  88. }
  89. $callBack = getTelNumber($callBack, 0);
  90. $callBack = $this->_cutChar($callBack, 12); // 회신번호
  91. /** LMS 제목 **/
  92. /*
  93. 제목필드의 값이 없을 경우 단말기 기종및 설정에 따라 표기 방법이 다름
  94. 1. 설정에서 제목필드보기 설정 Disable -> 제목필드값을 넣어도 미표기
  95. 2. 설정에서 제목필드보기 설정 Enable -> 제목을 넣지 않을 경우 제목없음으로 자동표시
  96. 제목의 첫글자에 "<",">", 개행문자가 있을경우 단말기종류 및 통신사에 따라 메세지 전송실패 -> 글자를 체크하거나 취환처리요망
  97. $strSubject = str_replace("<br/>", " ", $strSubject);
  98. $strSubject = str_replace("<", "[", $strSubject);
  99. $strSubject = str_replace(">", "]", $strSubject);
  100. */
  101. $subject = $this->_cutCharUtf8($subject, 30);
  102. $content = $this->_cutCharUtf8($content, 2000);
  103. /* 필수 항목에 대해 정상적인 코드인지 검사 과정. (개발 방식에 따라 활용) */
  104. $this->error[] = $this->_checkCommonTypeDest($sendNumber); // 번호 검사
  105. $this->error[] = $this->_isVaildCallback($callBack);
  106. $this->error[] = $this->_checkCommonTypeDate($reserveAt);
  107. if(count($this->errors()) > 0) {
  108. return false;
  109. }
  110. $today = now();
  111. $alreadyTels = []; // 중복 번호 관리
  112. foreach ($sendNumber as $uid => $tel) {
  113. if (empty($tel)) {
  114. continue;
  115. }
  116. $tel = getTelNumber($tel, 1);
  117. if(in_array($tel, $alreadyTels)) {
  118. continue;
  119. }else{
  120. $alreadyTels[] = $tel;
  121. }
  122. $list = [
  123. "key" => $this->token,
  124. "tel" => $tel,
  125. "cb" => $callBack,
  126. "msg" => $content
  127. ];
  128. if (!empty($subject)) {
  129. $list['title'] = $subject;
  130. }
  131. if (!empty($reserveAt)) {
  132. $list['date'] = $reserveAt;
  133. }
  134. $packet = json_encode($list);
  135. $this->data[$uid] = '06' . str_pad(strlen($packet), 4, "0", STR_PAD_LEFT) . $packet;
  136. $this->history[$uid] = [
  137. 'user_id' => ($uid ?: null),
  138. 'type' => $sendType,
  139. 'subject' => $subject,
  140. 'content' => $content,
  141. 'tel' => $tel,
  142. 'is_reserve' => $isReserve,
  143. 'reserve_at' => $reserveAt,
  144. 'code' => null,
  145. 'message' => null,
  146. 'created_at' => $today,
  147. ];
  148. }
  149. // 전송 결과 저장
  150. $this->result['user_id'] = $userID;
  151. $this->result['subject'] = $subject;
  152. $this->result['content'] = $content;
  153. $this->result['reserve_at'] = $reserveAt;
  154. return true;
  155. }
  156. /**
  157. * 문자 실제 전송 처리
  158. */
  159. private function _push(): bool
  160. {
  161. $fSocket = fsockopen($this->host, $this->port, $errorNo, $errorStr, 2);
  162. if (!$fSocket) {
  163. return false;
  164. }
  165. set_time_limit(300);
  166. $gets = "";
  167. foreach ($this->data as $i => $puts) {
  168. fputs($fSocket, $puts, strlen($puts));
  169. while (!$gets) {
  170. $gets = fgets($fSocket, 32);
  171. }
  172. preg_match("/\"cb\":\"([0-9]*)\"/", substr($puts, 6), $matches);
  173. $tel = $matches[1];
  174. $resultCode = substr($gets, 6, 2);
  175. if ($resultCode == '00' || $resultCode == '17') { // 17은 접수(전송)대기.
  176. $this->message[$i] = ($resultCode . ":" . substr($gets, 8, 12) . ":" . substr($gets, 20, 11));
  177. $this->success++;
  178. } else {
  179. $this->message[$i] = ($resultCode . ":" . $tel . ":Error(" . $resultCode . ")");
  180. $this->failed++;
  181. if ($resultCode >= "80") {
  182. break;
  183. }
  184. }
  185. $gets = "";
  186. }
  187. fclose($fSocket);
  188. return true;
  189. }
  190. /**
  191. * 전송 결과 생성
  192. * $this->message 에 메시지 전송 데이터가 `:` 콜론 형태로 저장되어있음
  193. */
  194. public function report(): string
  195. {
  196. $resultMessage = "";
  197. if ($this->message) {
  198. $resultMessage .= "서버에 접속했습니다.<br/><br/>";
  199. foreach ($this->message as $i => $result) {
  200. list($code, $phone, $seq) = explode(":", $result);
  201. if (substr($seq, 0, 5) == "Error") {
  202. $resultCode = substr($code, 6, 2);
  203. echo $phone . ' 전송오류 (' . $resultCode . '): ';
  204. switch (substr($code, 6, 2)) {
  205. case '23': // "23:데이터오류, 전송날짜오류, 발신번호미등록"
  206. $resultMessage .= "데이터를 다시 확인해 주시기바랍니다.<br/>";
  207. break;
  208. // 아래의 사유들은 전송진행이 중단됨.
  209. case '85': // "85:전송번호 미등록"
  210. $resultMessage .= "등록되지 않는 전송번호 입니다.<br/>";
  211. break;
  212. case '87': // "87:인증실패"
  213. $resultMessage .= "(정액제-계약확인)인증 받지 못하였습니다.<br/>";
  214. break;
  215. case '88': // "88:연동모듈 전송불가"
  216. $resultMessage .= "연동모듈 사용이 불가능합니다. 아이코드로 문의하세요.<br/>";
  217. break;
  218. case '96': // "96:토큰 검사 실패"
  219. $resultMessage .= "사용할 수 없는 토큰키입니다.<br/>";
  220. break;
  221. case '97': // "97:잔여코인부족"
  222. $resultMessage .= "잔여코인이 부족합니다.<br/>";
  223. break;
  224. case '98': // "98:사용기간만료"
  225. $resultMessage .= "사용기간이 만료되었습니다.<br/>";
  226. break;
  227. case '99': // "99:인증실패"
  228. $resultMessage .= "서비스 사용이 불가능합니다. 아이코드로 문의하세요.<br/>";
  229. break;
  230. default: // "미 확인 오류"
  231. $resultMessage .= "알 수 없는 오류로 전송이 실패하었습니다.<br/>";
  232. break;
  233. }
  234. $this->error[] = $resultMessage;
  235. } else {
  236. $resultCode = substr($code, 0, 2);
  237. switch ($resultCode) {
  238. case '17': // "17: 접수(전송)대기 처리. 지연해소시 전송됨."
  239. $resultMessage .= "접수(전송)대기처리 되었습니다.";
  240. break;
  241. default: // "00: 전송완료."
  242. $resultMessage .= "전송되었습니다.<br/>";
  243. break;
  244. }
  245. $resultMessage .= $phone . "로 (msg seq : ' . $seq . ')<br/>";
  246. }
  247. $this->total++;
  248. $this->history[$i]['code'] = $resultCode;
  249. $this->history[$i]['message'] = addslashes($resultMessage);
  250. }
  251. $resultMessage .= "<br/> 전체(" . $this->total . "건) / 전송완료(" . $this->success . "건) / 전송실패(" . $this->failed . "건)<br/>";
  252. // 전송 결과 저장
  253. $this->result['call_num'] = $this->callNum;
  254. $this->result['total_count'] = $this->total;
  255. $this->result['success_count'] = $this->success;
  256. $this->result['failed_count'] = $this->failed;
  257. $this->result['message'] = '관리자 직접 전송 ' . $resultMessage;
  258. $this->result['created_at'] = now();
  259. }
  260. return $resultMessage;
  261. }
  262. /**
  263. * 전송하기
  264. */
  265. public function send($sendNumber = [], $sendData = []): bool
  266. {
  267. try {
  268. // 문자 전송 목록에 추가
  269. if(!$this->_add($sendNumber, $sendData)) {
  270. throw new Exception();
  271. }
  272. // 문자 전송
  273. $this->_push();
  274. // 전송 결과 생성
  275. $this->report();
  276. // DB 저장
  277. $this->_save();
  278. return true;
  279. }catch(Exception $e) {
  280. $this->error[] = $e->getMessage();
  281. return false;
  282. }
  283. }
  284. /**
  285. * 전송 자료 저장
  286. */
  287. private function _save(): void
  288. {
  289. // 문자 전송 내역 저장
  290. $keys = (new SmsHistory)->setHistory($this->history);
  291. // 문자 전송 결과 저장
  292. $this->result['start_id'] = min($keys);
  293. $this->result['end_id'] = max($keys);
  294. (new SmsResult)->insert($this->result);
  295. }
  296. /**
  297. * 오류 조회
  298. */
  299. public function errors(): array
  300. {
  301. return array_filter($this->error);
  302. }
  303. /**
  304. * 원하는 문자열의 길이를 원하는 길이만큼 공백을 넣어 맞추도록 합니다.
  305. */
  306. private function _fillSpace(string $text, int $size): string
  307. {
  308. for ($i = 0; $i < $size; $i++) {
  309. $text .= " ";
  310. }
  311. return substr($text, 0, $size);
  312. }
  313. /**
  314. * 원하는 문자열을 원하는 길에 맞는지 확인해서 조정하는 기능을 합니다.
  315. */
  316. private function _cutChar(string $word, int $cut): string
  317. {
  318. $word = substr($word, 0, $cut); // 필요한 길이만큼 취함.
  319. for ($k = $cut - 1; $k > 1; $k--) {
  320. if (ord(substr($word, $k, 1)) < 128) { // 한글값은 160 이상.
  321. break;
  322. }
  323. }
  324. return substr($word, 0, $cut - ($cut - $k + 1) % 2);;
  325. }
  326. private function _cutCharUtf8(string $word, int $cut): string
  327. {
  328. preg_match_all('/[\xE0-\xFF][\x80-\xFF]{2}|./', $word, $match); // target for BMP
  329. $m = $match[0];
  330. $slen = strlen($word); // length of source string
  331. if ($slen <= $cut) {
  332. return $word;
  333. }
  334. $ret = [];
  335. $count = 0;
  336. for ($i = 0; $i < $cut; $i++) {
  337. $count += (strlen($m[$i]) > 1) ? 2 : 1;
  338. if ($count > $cut) {
  339. break;
  340. }
  341. $ret[] = $m[$i];
  342. }
  343. return join('', $ret);
  344. }
  345. /**
  346. * 잘못된 수신번호 목록을 리턴합니다.
  347. */
  348. private function _checkCommonTypeDest(array $strTelList): string
  349. {
  350. $result = '';
  351. foreach ($strTelList as $tel) {
  352. $tel = preg_replace("/[^0-9]/", "", $tel);
  353. if (!preg_match("/^(0[173][0136789])([0-9]{3,4})([0-9]{4})$/", $tel)) {
  354. $result .= $tel . ',';
  355. }
  356. }
  357. return $result;
  358. }
  359. /**
  360. * 회신번호 유효성 여부조회
  361. * 한국인터넷진흥원 권고사항
  362. */
  363. private function _isVaildCallback(string $callback): string
  364. {
  365. $_callback = preg_replace('/[^0-9]/', '', $callback);
  366. if (!preg_match("/^(02|0[3-6]\d|01(0|1|3|5|6|7|8|9)|070|080|007)\-?\d{3,4}\-?\d{4,5}$/", $_callback) &&
  367. !preg_match("/^(15|16|18)\d{2}\-?\d{4,5}$/", $_callback)) {
  368. return "회신번호오류";
  369. }
  370. if (preg_match("/^(02|0[3-6]\d|01(0|1|3|5|6|7|8|9)|070|080)\-?0{3,4}\-?\d{4}$/", $_callback)) {
  371. return "회신번호오류";
  372. }
  373. return '';
  374. }
  375. /**
  376. * 문자열을 JSON 사용가능 타입으로 변환한다.
  377. */
  378. private function _escapeJsonString(string $value): string
  379. {
  380. $escapers = ['\\', '"'];
  381. $replacements = ['\\\\', '\"'];
  382. return str_replace($escapers, $replacements, $value);
  383. }
  384. /**
  385. * 예약날짜의 값이 정확한 값인지 확인합니다.
  386. */
  387. private function _checkCommonTypeDate(?string $strDate): string
  388. {
  389. $strDate = preg_replace("/[^0-9]/", "", $strDate);
  390. if ($strDate) {
  391. if (strlen($strDate) != 12) {
  392. return '예약날짜오류';
  393. }
  394. if (!checkdate(substr($strDate, 4, 2), substr($strDate, 6, 2), substr($strDate, 0, 4))) {
  395. return "예약날짜오류";
  396. }
  397. if (substr($strDate, 8, 2) > 23 || substr($strDate, 10, 2) > 59) {
  398. return "예약시간오류";
  399. }
  400. }
  401. return '';
  402. }
  403. }