From 4a6c9d2afe9de25cee79da25f4813ace94bf7102 Mon Sep 17 00:00:00 2001 From: comicgum Date: Thu, 10 Sep 2026 07:03:11 +0900 Subject: [PATCH] =?UTF-8?q?UI=20=EC=88=98=EC=A0=95,=20SMS=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../smartservice/component/SMSSender.java | 197 ++++++++++++++++++ .../src/app/(admin)/layout.tsx | 33 +-- smartservice_frontend/src/app/globals.css | 11 + smartservice_frontend/src/app/layout.tsx | 17 +- .../src/components/AuthGate.tsx | 33 +++ .../src/components/CtLoadingScreen.tsx | 14 ++ .../components/ecommerce/MonthlyTarget.tsx | 8 +- .../src/components/header/UserDropdown.tsx | 4 +- smartservice_frontend/src/lib/_AG.ts | 101 +++++---- 9 files changed, 356 insertions(+), 62 deletions(-) create mode 100644 smartservice_backend/src/main/java/com/handong/smartservice/component/SMSSender.java create mode 100644 smartservice_frontend/src/components/AuthGate.tsx create mode 100644 smartservice_frontend/src/components/CtLoadingScreen.tsx diff --git a/smartservice_backend/src/main/java/com/handong/smartservice/component/SMSSender.java b/smartservice_backend/src/main/java/com/handong/smartservice/component/SMSSender.java new file mode 100644 index 0000000..c1bf466 --- /dev/null +++ b/smartservice_backend/src/main/java/com/handong/smartservice/component/SMSSender.java @@ -0,0 +1,197 @@ +package com.handong.smartservice.component; + +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.Charset; +import java.time.Duration; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Munja114 (www.munja114.co.kr) SMS/LMS URL integration. + * + * Posts an application/x-www-form-urlencoded body to the Munja114 remote endpoint. + * SMS or LMS is chosen automatically by the EUC-KR byte length of the message: + * up to 90 bytes is sent as SMS, longer text is sent as LMS (2000 bytes max). + * + * The response body is a pipe separated string: code|msg|nums|cols|etc1|etc2 + */ +public class SMSSender { + private static final Logger logger = LoggerFactory.getLogger(SMSSender.class); + + // Munja114 account, issued and authorized through the Munja114 customer center + private static final String REMOTE_ID = ""; + private static final String REMOTE_PASS = ""; + + private static final String URL_SMS = "https://www.munja114.co.kr/Remote/RemoteSms.html"; + private static final String URL_LMS = "https://www.munja114.co.kr/Remote/RemoteMms.html"; + + // Munja114 sends and expects EUC-KR encoded text + private static final Charset CHARSET = Charset.forName("EUC-KR"); + + private static final int SMS_MAX_BYTES = 90; + private static final int LMS_MAX_BYTES = 2000; + + // Subject applies to LMS only, 20 characters max, no special characters + private static final String LMS_SUBJECT = "알림"; + + private static final Duration TIMEOUT = Duration.ofSeconds(10); + + // Result codes returned by the gateway + public static final String CODE_SUCCESS = "0000"; + public static final String CODE_CONNECT_ERROR = "0001"; + public static final String CODE_AUTH_ERROR = "0002"; + public static final String CODE_NO_CALL = "0003"; + public static final String CODE_MSG_FORMAT_ERROR = "0004"; + public static final String CODE_CALLBACK_ERROR = "0005"; + public static final String CODE_PHONE_COUNT_ERROR = "0006"; + public static final String CODE_RESERVE_TIME_ERROR = "0007"; + public static final String CODE_NOT_ENOUGH_CALL = "0008"; + public static final String CODE_SEND_FAIL = "0009"; + public static final String CODE_MSG_TOO_LONG = "0012"; + public static final String CODE_CALLBACK_NOT_REGISTERED = "0030"; + public static final String CODE_CALLBACK_TYPE_FAIL = "0033"; + public static final String CODE_SEND_LIMITED = "0080"; + public static final String CODE_BLOCKED = "6666"; + public static final String CODE_UNPAID = "9999"; + + // Local codes, never returned by the gateway + public static final String CODE_INVALID_PARAMETER = "9001"; + public static final String CODE_EXCEPTION = "9002"; + + private static final HttpClient httpClient = HttpClient.newBuilder() + .connectTimeout(TIMEOUT) + .build(); + + + /** Send result parsed from the gateway response. */ + public static class Result { + public final String code; // result code, "0000" on success + public final String msg; // result message + public final int sentCount; // number of messages sent + public final int remainCount; // remaining call count of the account + public final String rawResponse; + + public Result(String code, String msg, int sentCount, int remainCount, String rawResponse) { + this.code = code; + this.msg = msg; + this.sentCount = sentCount; + this.remainCount = remainCount; + this.rawResponse = rawResponse; + } + + public boolean isSuccess() { + return CODE_SUCCESS.equals(code); + } + + @Override + public String toString() { + return "SMSSender.Result{code=" + code + ", msg=" + msg + ", sentCount=" + sentCount + ", remainCount=" + remainCount + "}"; + } + } + + /** + * Send a text message. + * + * @param callback sender number, must be pre-registered with the carrier (digits only, other characters are stripped) + * @param phone receiver number, comma separated for multiple receivers + * @param msg message body, sent as SMS when it fits in 90 EUC-KR bytes, otherwise as LMS + */ + public static Result send(String callback, String phone, String msg) { + if (!hasText(callback) || !hasText(phone) || !hasText(msg)) { + logger.warn("send: missing parameter, callback={}, phone={}", callback, phone); + return new Result(CODE_INVALID_PARAMETER, "invalid parameter", 0, 0, ""); + } + + String callbackNum = callback.replaceAll("[^0-9]", ""); + String phoneList = phone.replaceAll("[^0-9,]", ""); + int receiverCount = phoneList.split(",").length; + + byte[] msgBytes = msg.getBytes(CHARSET); + boolean isLms = msgBytes.length > SMS_MAX_BYTES; + + if (msgBytes.length > LMS_MAX_BYTES) { + logger.warn("send: message too long, bytes={}", msgBytes.length); + return new Result(CODE_MSG_TOO_LONG, "message too long", 0, 0, ""); + } + + StringBuilder body = new StringBuilder(); + appendParam(body, "remote_id", REMOTE_ID); + appendParam(body, "remote_pass", REMOTE_PASS); + appendParam(body, "remote_num", String.valueOf(receiverCount)); + appendParam(body, "remote_reserve", "0"); + appendParam(body, "remote_phone", phoneList); + appendParam(body, "remote_callback", callbackNum); + appendParam(body, "remote_msg", msg); + if (isLms) + appendParam(body, "remote_subject", LMS_SUBJECT); + + String url = isLms ? URL_LMS : URL_SMS; + + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(TIMEOUT) + .header("Content-Type", "application/x-www-form-urlencoded;charset=ko") + .POST(HttpRequest.BodyPublishers.ofString(body.toString(), CHARSET)) + .build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray()); + + if (response.statusCode() != 200) { + logger.warn("send: http status={}, url={}", response.statusCode(), url); + return new Result(CODE_CONNECT_ERROR, "http status " + response.statusCode(), 0, 0, ""); + } + + String raw = new String(response.body(), CHARSET).trim(); + Result result = parseResult(raw); + + if (result.isSuccess()) + logger.info("send: ok, phone={}, type={}, remain={}", phoneList, isLms ? "lms" : "sms", result.remainCount); + else + logger.warn("send: failed, phone={}, result={}", phoneList, result); + + return result; + } + catch (Exception e) { + logger.warn("send: exception, url={}, msg={}", url, e.getMessage()); + return new Result(CODE_EXCEPTION, e.getMessage(), 0, 0, ""); + } + } + + // Response format: code|msg|nums|cols|etc1|etc2 + private static Result parseResult(String raw) { + String[] arr = raw.split("\\|"); + + String code = arr.length > 0 ? arr[0].trim() : ""; + String msg = arr.length > 1 ? arr[1].trim() : ""; + int sentCount = arr.length > 2 ? toInt(arr[2]) : 0; + int remainCount = arr.length > 3 ? toInt(arr[3]) : 0; + + return new Result(code, msg, sentCount, remainCount, raw); + } + + private static void appendParam(StringBuilder body, String key, String value) { + if (body.length() > 0) + body.append('&'); + + body.append(key).append('=').append(URLEncoder.encode(value == null ? "" : value, CHARSET)); + } + + private static boolean hasText(String str) { + return str != null && !str.trim().isEmpty(); + } + + private static int toInt(String str) { + try { + return Integer.parseInt(str.trim()); + } + catch (NumberFormatException e) { + return 0; + } + } +} diff --git a/smartservice_frontend/src/app/(admin)/layout.tsx b/smartservice_frontend/src/app/(admin)/layout.tsx index 5218d18..92bdcc7 100644 --- a/smartservice_frontend/src/app/(admin)/layout.tsx +++ b/smartservice_frontend/src/app/(admin)/layout.tsx @@ -4,6 +4,7 @@ import { useSidebar } from "@/context/SidebarContext"; import AppHeader from "@/layout/AppHeader"; import AppSidebar from "@/layout/AppSidebar"; import Backdrop from "@/layout/Backdrop"; +import AuthGate from "@/components/AuthGate"; import React from "react"; export default function AdminLayout({ @@ -21,23 +22,25 @@ export default function AdminLayout({ : "lg:ml-[90px]"; return ( -
- {/* Sidebar and Backdrop */} - - - {/* Main Content Area */} -
- {/* Header */} - - {/* Page Content */} -
-
- {children} + +
+ {/* Sidebar and Backdrop */} + + + {/* Main Content Area */} +
+ {/* Header */} + + {/* Page Content */} +
+
+ {children} +
-
+ ); } diff --git a/smartservice_frontend/src/app/globals.css b/smartservice_frontend/src/app/globals.css index bba8b63..e98ec77 100644 --- a/smartservice_frontend/src/app/globals.css +++ b/smartservice_frontend/src/app/globals.css @@ -206,6 +206,17 @@ body { @apply relative font-normal font-outfit z-1 bg-gray-50 dark:bg-gray-900; } + /* + Tells the browser which palette to use for native widgets: scrollbars, + the date/time picker panel, select popups and number spinners. + Without this they keep rendering light while the page is in dark mode. + */ + html { + color-scheme: light; + } + html.dark { + color-scheme: dark; + } } @utility menu-item { diff --git a/smartservice_frontend/src/app/layout.tsx b/smartservice_frontend/src/app/layout.tsx index acbb288..31bc914 100644 --- a/smartservice_frontend/src/app/layout.tsx +++ b/smartservice_frontend/src/app/layout.tsx @@ -21,8 +21,23 @@ export default function RootLayout({ }: Readonly<{ children: React.ReactNode; }>) { + // Applies the saved theme before the first paint, so a reload in dark mode + // never flashes the light palette while React hydrates. + const themeScript = ` + try { + var t = localStorage.getItem("theme"); + if (t === "dark") { + document.documentElement.classList.add("dark"); + document.documentElement.dataset.theme = "dark"; + } + } catch (e) {} + `; + return ( - + + +