diff --git a/src/main/java/com/eactive/apim/portal/apps/community/partnership/service/PartnershipApplicationFacadeImpl.java b/src/main/java/com/eactive/apim/portal/apps/community/partnership/service/PartnershipApplicationFacadeImpl.java
index 22bbcbd..157b345 100644
--- a/src/main/java/com/eactive/apim/portal/apps/community/partnership/service/PartnershipApplicationFacadeImpl.java
+++ b/src/main/java/com/eactive/apim/portal/apps/community/partnership/service/PartnershipApplicationFacadeImpl.java
@@ -6,7 +6,7 @@ import com.eactive.apim.portal.apps.community.partnership.mapper.PartnershipAppl
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.common.util.UserTypeUtil;
-import com.eactive.apim.portal.djb.community.qna.comment.service.CommunityAdminNotifier;
+import com.eactive.apim.portal.djb.swing.SwingNotifier;
import com.eactive.apim.portal.file.entity.FileInfo;
import com.eactive.apim.portal.file.service.FileService;
import com.eactive.apim.portal.file.service.FileTypeContext;
@@ -29,7 +29,7 @@ public class PartnershipApplicationFacadeImpl implements PartnershipApplicationF
private final PartnershipApplicationMapper partnershipApplicationMapper;
private final FileService fileService;
// portal-admin 알림 발행기(범용). Q&A 등록 알림과 동일 컴포넌트를 재사용한다.
- private final CommunityAdminNotifier portalAdminNotifier;
+ private final SwingNotifier swingNotifier;
@Override
@@ -61,7 +61,7 @@ public class PartnershipApplicationFacadeImpl implements PartnershipApplicationF
if (writer != null) {
params.put("writerName", writer.getUserName());
}
- portalAdminNotifier.notifyPortalAdmins(MessageCode.PARTNERSHIP_CREATED, params);
+ swingNotifier.notifyPortalAdmins(MessageCode.PARTNERSHIP_CREATED, params);
}
@Override
diff --git a/src/main/java/com/eactive/apim/portal/apps/community/qna/service/InquiryFacadeImpl.java b/src/main/java/com/eactive/apim/portal/apps/community/qna/service/InquiryFacadeImpl.java
index 9b2d755..e675f1e 100644
--- a/src/main/java/com/eactive/apim/portal/apps/community/qna/service/InquiryFacadeImpl.java
+++ b/src/main/java/com/eactive/apim/portal/apps/community/qna/service/InquiryFacadeImpl.java
@@ -6,7 +6,7 @@ import com.eactive.apim.portal.apps.community.qna.mapper.InquiryMapper;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.common.util.StringMaskingUtil;
-import com.eactive.apim.portal.djb.community.qna.comment.service.CommunityAdminNotifier;
+import com.eactive.apim.portal.djb.swing.SwingNotifier;
import com.eactive.apim.portal.file.entity.FileInfo;
import com.eactive.apim.portal.file.exception.InvalidFileException;
import com.eactive.apim.portal.file.service.FileService;
@@ -40,7 +40,7 @@ public class InquiryFacadeImpl implements InquiryFacade {
private final InquiryService inquiryService;
private final InquiryMapper inquiryMapper;
- private final CommunityAdminNotifier inquiryAdminNotifier;
+ private final SwingNotifier swingNotifier;
private final FileService fileService;
@Override
@@ -142,7 +142,7 @@ public class InquiryFacadeImpl implements InquiryFacade {
params.put("inquiryId", inquiry.getId());
params.put("inquirySubject", inquiry.getInquirySubject());
params.put("writerName", current.getUserName());
- inquiryAdminNotifier.notifyPortalAdmins(MessageCode.INQUIRY_CREATED, params);
+ swingNotifier.notifyPortalAdmins(MessageCode.INQUIRY_CREATED, params);
}
@Override
diff --git a/src/main/java/com/eactive/apim/portal/djb/community/qna/comment/repository/UserInfoRepository.java b/src/main/java/com/eactive/apim/portal/djb/community/qna/comment/repository/UserInfoRepository.java
index e1796e1..58525ab 100644
--- a/src/main/java/com/eactive/apim/portal/djb/community/qna/comment/repository/UserInfoRepository.java
+++ b/src/main/java/com/eactive/apim/portal/djb/community/qna/comment/repository/UserInfoRepository.java
@@ -3,20 +3,13 @@ package com.eactive.apim.portal.djb.community.qna.comment.repository;
import com.eactive.apim.portal.user.entity.UserInfo;
import com.eactive.eai.data.jpa.BaseRepository;
import com.eactive.eai.rms.data.EMSDataSource;
-import org.springframework.data.jpa.repository.Query;
-import org.springframework.data.repository.query.Param;
-
-import java.util.List;
+/**
+ * 댓글 작성자(내부 직원) 표시용 TSEAIRM02 조회.
+ *
+ *
역할 기반 알림 대상 조회는
+ * {@code com.eactive.apim.portal.djb.swing.repository.SwingStaffRepository} 로 분리했다.
+ */
@EMSDataSource
public interface UserInfoRepository extends BaseRepository {
-
- /**
- * TSEAIRM02.roleidnfiname 컬럼은 콤마로 구분된 복수 역할을 저장한다.
- * (예: {@code "admin,portal-admin"}). Oracle native query로 콤마 토큰 매치.
- */
- @Query(value = "SELECT * FROM TSEAIRM02 t"
- + " WHERE ',' || t.ROLEIDNFINAME || ',' LIKE '%,' || :role || ',%'",
- nativeQuery = true)
- List findByRoleContaining(@Param("role") String role);
}
diff --git a/src/main/java/com/eactive/apim/portal/djb/community/qna/comment/service/CommunityAdminNotifier.java b/src/main/java/com/eactive/apim/portal/djb/community/qna/comment/service/CommunityAdminNotifier.java
deleted file mode 100644
index d09e88f..0000000
--- a/src/main/java/com/eactive/apim/portal/djb/community/qna/comment/service/CommunityAdminNotifier.java
+++ /dev/null
@@ -1,57 +0,0 @@
-package com.eactive.apim.portal.djb.community.qna.comment.service;
-
-import com.eactive.apim.portal.djb.community.qna.comment.repository.UserInfoRepository;
-import com.eactive.apim.portal.djb.community.qna.constant.DjbAdminRole;
-import com.eactive.apim.portal.template.entity.MessageCode;
-import com.eactive.apim.portal.template.service.MessageHandlerService;
-import com.eactive.apim.portal.template.service.MessageRecipient;
-import com.eactive.apim.portal.user.entity.UserInfo;
-import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.stereotype.Service;
-
-import java.util.List;
-import java.util.Map;
-
-/**
- * Q&A 등록/댓글 등록 시 portal-admin 역할(TSEAIRM02)을 가진 관리자 전원에게
- * 알림 메시지를 발행한다. 발송 실패는 트랜잭션 롤백을 유발하지 않는다.
- */
-@Slf4j
-@Service
-@RequiredArgsConstructor
-public class CommunityAdminNotifier {
-
- private final UserInfoRepository userInfoRepository;
- private final MessageHandlerService messageHandlerService;
-
- public void notifyPortalAdmins(MessageCode code, Map params) {
- try {
- List admins = userInfoRepository.findByRoleContaining(DjbAdminRole.PORTAL_ADMIN);
- if (admins == null || admins.isEmpty()) {
- log.warn("portal-admin 역할 관리자가 없습니다 — 알림 미발송 code={}", code.name());
- return;
- }
- for (UserInfo admin : admins) {
- try {
- MessageRecipient recipient = toRecipient(admin);
- messageHandlerService.publishEvent(code, recipient, params);
- } catch (Exception e) {
- log.warn("portal-admin 개별 알림 발행 실패 — userid={}, code={}",
- admin.getUserid(), code.name(), e);
- }
- }
- } catch (Exception e) {
- log.warn("portal-admin 알림 발행 실패 — code={}", code.name(), e);
- }
- }
-
- private MessageRecipient toRecipient(UserInfo admin) {
- MessageRecipient r = new MessageRecipient();
- r.setUsername(admin.getUsername());
- r.setUserId(admin.getEmad());
- r.setPhone(admin.getCphnno());
- r.setMessengerId(admin.getUserid());
- return r;
- }
-}
diff --git a/src/main/java/com/eactive/apim/portal/djb/community/qna/comment/service/InquiryCommentFacadeImpl.java b/src/main/java/com/eactive/apim/portal/djb/community/qna/comment/service/InquiryCommentFacadeImpl.java
index d646946..b5ea437 100644
--- a/src/main/java/com/eactive/apim/portal/djb/community/qna/comment/service/InquiryCommentFacadeImpl.java
+++ b/src/main/java/com/eactive/apim/portal/djb/community/qna/comment/service/InquiryCommentFacadeImpl.java
@@ -8,6 +8,7 @@ import com.eactive.apim.portal.djb.community.qna.comment.dto.InquiryCommentDTO;
import com.eactive.apim.portal.djb.community.qna.comment.repository.InquiryCommentRepository;
import com.eactive.apim.portal.djb.community.qna.comment.repository.UserInfoRepository;
import com.eactive.apim.portal.djb.community.qna.constant.DjbInquiryStatus;
+import com.eactive.apim.portal.djb.swing.SwingNotifier;
import com.eactive.apim.portal.djb.community.qna.support.InquiryCommentPermissionChecker;
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
@@ -47,7 +48,7 @@ public class InquiryCommentFacadeImpl implements InquiryCommentFacade {
private final InquiryCommentService commentService;
private final InquiryCommentRepository commentRepository;
private final InquiryCommentPermissionChecker permissionChecker;
- private final CommunityAdminNotifier adminNotifier;
+ private final SwingNotifier swingNotifier;
private final PortalUserRepository portalUserRepository;
private final UserInfoRepository userInfoRepository;
@@ -107,7 +108,7 @@ public class InquiryCommentFacadeImpl implements InquiryCommentFacade {
params.put("inquirySubject", inquiry.getInquirySubject());
params.put("commentContent", comment.getCommentDetail());
params.put("writerName", current.getUserName());
- adminNotifier.notifyPortalAdmins(MessageCode.INQUIRY_COMMENT_CREATED, params);
+ swingNotifier.notifyPortalAdmins(MessageCode.INQUIRY_COMMENT_CREATED, params);
}
private Map resolveWriters(List comments) {
diff --git a/src/main/java/com/eactive/apim/portal/djb/community/qna/constant/DjbAdminRole.java b/src/main/java/com/eactive/apim/portal/djb/community/qna/constant/DjbAdminRole.java
deleted file mode 100644
index da10892..0000000
--- a/src/main/java/com/eactive/apim/portal/djb/community/qna/constant/DjbAdminRole.java
+++ /dev/null
@@ -1,10 +0,0 @@
-package com.eactive.apim.portal.djb.community.qna.constant;
-
-public final class DjbAdminRole {
-
- /** TSEAIRM02.roleidnfiname 컬럼에서 포털 관리자를 식별하는 값. */
- public static final String PORTAL_ADMIN = "portal-admin";
-
- private DjbAdminRole() {
- }
-}
diff --git a/src/main/java/com/eactive/apim/portal/djb/swing/EmployeeIdPolicy.java b/src/main/java/com/eactive/apim/portal/djb/swing/EmployeeIdPolicy.java
new file mode 100644
index 0000000..2f82978
--- /dev/null
+++ b/src/main/java/com/eactive/apim/portal/djb/swing/EmployeeIdPolicy.java
@@ -0,0 +1,40 @@
+package com.eactive.apim.portal.djb.swing;
+
+/**
+ * Swing 메신저 수신자 ID 가 행번(직원번호)이 아닐 때의 처리 정책.
+ *
+ * Swing 메신저는 {@code TSEAIRM02.USERID} 를 수신자 코드로 사용하며 운영 직원은 숫자 행번을 쓴다.
+ * portal-admin 역할이 부여된 개발용 계정(영문 ID 등)은 Swing 에 존재하지 않아 발송이 무의미하므로
+ * 이 정책으로 처리 방식을 선택한다. PTL_PROPERTY
+ * {@code Portal / djb.swing.notify.non-employee.policy} 로 지정한다.
+ */
+public enum EmployeeIdPolicy {
+
+ /** 정상 발송 (PTL_MESSAGE_REQUEST 에 PENDING 적재) */
+ SEND,
+
+ /** 발송 + WARN 로그 */
+ SEND_WARN,
+
+ /** 무시 처리 — REQUEST_STATUS='SKIPPED' 로 적재해 이력만 남기고 배치는 수집하지 않음 */
+ SKIP,
+
+ /** 거부 — PTL_MESSAGE_REQUEST 에 적재하지 않음 + INFO 로그 */
+ REJECT_LOG,
+
+ /** 완전 거부 — 적재하지 않고 로그도 남기지 않음 */
+ REJECT;
+
+ /** 프로퍼티 문자열 → enum. 값이 없거나 알 수 없으면 기본값 {@link #SKIP}. */
+ public static EmployeeIdPolicy from(String value) {
+ if (value != null) {
+ String trimmed = value.trim();
+ for (EmployeeIdPolicy policy : values()) {
+ if (policy.name().equalsIgnoreCase(trimmed)) {
+ return policy;
+ }
+ }
+ }
+ return SKIP;
+ }
+}
diff --git a/src/main/java/com/eactive/apim/portal/djb/swing/SwingMessageWriter.java b/src/main/java/com/eactive/apim/portal/djb/swing/SwingMessageWriter.java
new file mode 100644
index 0000000..b80bc11
--- /dev/null
+++ b/src/main/java/com/eactive/apim/portal/djb/swing/SwingMessageWriter.java
@@ -0,0 +1,160 @@
+package com.eactive.apim.portal.djb.swing;
+
+import com.eactive.apim.portal.djb.swing.repository.SwingStaffRepository;
+import com.eactive.apim.portal.template.entity.MessageCode;
+import com.eactive.apim.portal.template.entity.MessageRequest;
+import com.eactive.apim.portal.template.entity.MessageTemplate;
+import com.eactive.apim.portal.template.repository.MessageRequestRepository;
+import com.eactive.apim.portal.template.repository.MessageTemplateRepository;
+import com.eactive.apim.portal.user.entity.UserInfo;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+
+import java.time.LocalDateTime;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Swing 메신저 발송 요청을 PTL_MESSAGE_REQUEST 에 직접 적재한다.
+ *
+ * TSEAIRM02(직원) → PTL_MESSAGE_TEMPLATE(본문) → PTL_MESSAGE_REQUEST(발송 요청) 순으로
+ * 처리하며, 실제 발송은 기존과 동일하게 eapim-admin 의 {@code UmsDispatchJob}(5초 주기)이
+ * {@code REQUEST_STATUS='PENDING'} 행을 수집해 수행한다.
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class SwingMessageWriter {
+
+ private static final String MESSAGE_TYPE_MESSENGER = "MESSENGER";
+ private static final String STATUS_PENDING = "PENDING";
+ /** 행번 아닌 ID 를 무시 처리할 때 쓰는 신설 상태값. 배치(PENDING 수집)가 건드리지 않는다. */
+ private static final String STATUS_SKIPPED = "SKIPPED";
+ private static final String ENABLED = "Y";
+
+ private final SwingStaffRepository swingStaffRepository;
+ private final MessageTemplateRepository messageTemplateRepository;
+ private final MessageRequestRepository messageRequestRepository;
+ private final SwingNotifyProperties properties;
+
+ @Transactional
+ public void write(MessageCode code, Map params) {
+ String role = properties.getTargetRole();
+ List staffs = swingStaffRepository.findByRole(role);
+ if (staffs == null || staffs.isEmpty()) {
+ log.warn("Swing 알림 대상 직원이 없습니다 — role={}, code={}", role, code.name());
+ return;
+ }
+
+ MessageTemplate template = messageTemplateRepository.findById(code.name()).orElse(null);
+ if (template == null) {
+ log.warn("메세지 템플릿이 존재하지 않습니다 — code={}", code.name());
+ return;
+ }
+ if (!ENABLED.equalsIgnoreCase(template.getEnableMessenger())) {
+ log.warn("메신저 발송이 비활성화된 템플릿입니다 — code={}, enableMessenger={}",
+ code.name(), template.getEnableMessenger());
+ return;
+ }
+
+ SwingNotifyProperties.UmsMessengerIds umsIds = properties.getUmsMessengerIds();
+ EmployeeIdPolicy policy = properties.getNonEmployeePolicy();
+
+ for (UserInfo staff : staffs) {
+ try {
+ writeOne(code, template, params, staff, umsIds, policy);
+ } catch (Exception e) {
+ log.warn("Swing 알림 적재 실패 — userid={}, code={}", staff.getUserid(), code.name(), e);
+ }
+ }
+ }
+
+ private void writeOne(MessageCode code, MessageTemplate template, Map params,
+ UserInfo staff, SwingNotifyProperties.UmsMessengerIds umsIds,
+ EmployeeIdPolicy policy) {
+
+ String messengerId = staff.getUserid();
+ String requestStatus = STATUS_PENDING;
+
+ if (!properties.isEmployeeId(messengerId)) {
+ switch (policy) {
+ case REJECT:
+ return;
+ case REJECT_LOG:
+ log.info("행번이 아닌 ID — 발송 거부(미적재). userid={}, code={}", messengerId, code.name());
+ return;
+ case SKIP:
+ log.info("행번이 아닌 ID — 무시 처리({}). userid={}, code={}",
+ STATUS_SKIPPED, messengerId, code.name());
+ requestStatus = STATUS_SKIPPED;
+ break;
+ case SEND_WARN:
+ log.warn("행번이 아닌 ID — 발송 진행. Swing 미등록 계정이면 발송 실패한다. userid={}, code={}",
+ messengerId, code.name());
+ break;
+ case SEND:
+ default:
+ break;
+ }
+ }
+
+ Map messageParams = toStringParams(params);
+ messageParams.put("userId", messengerId);
+ messageParams.putIfAbsent("USER_ID", messengerId);
+ if (StringUtils.hasText(staff.getUsername())) {
+ messageParams.put("userName", staff.getUsername());
+ messageParams.putIfAbsent("USER_NAME", staff.getUsername());
+ }
+
+ MessageRequest request = new MessageRequest();
+ request.setMessageCode(code);
+ request.setMessageType(MESSAGE_TYPE_MESSENGER);
+ request.setSubject(buildMessage(template.getSubjectTemplate(), messageParams));
+ request.setMessage(buildMessage(template.getMessengerTemplate(), messageParams));
+ request.setUsername(staff.getUsername());
+ request.setUserId(messengerId);
+ request.setMessengerId(messengerId);
+ request.setEaiInterfaceId(umsIds.getInterfaceId());
+ request.setServiceId(umsIds.getServiceId());
+ request.setRequestDate(LocalDateTime.now());
+ request.setRequestStatus(requestStatus);
+ // email/phone 은 설정하지 않는다 — 메신저 전용 경로라 개인정보를 적재할 이유가 없다.
+
+ messageRequestRepository.save(request);
+ log.debug("Swing 알림 적재 — code={}, userid={}, status={}", code.name(), messengerId, requestStatus);
+ }
+
+ /** 수신자별로 파라미터를 복사한다(공유 맵을 오염시키지 않기 위함). */
+ private static Map toStringParams(Map params) {
+ Map result = new HashMap<>();
+ if (params != null) {
+ for (Map.Entry entry : params.entrySet()) {
+ if (entry.getKey() == null || entry.getValue() == null) {
+ continue;
+ }
+ result.put(entry.getKey(), String.valueOf(entry.getValue()));
+ }
+ }
+ return result;
+ }
+
+ /** 템플릿 본문의 {@code %KEY%} 플레이스홀더를 치환한다(정규식 아닌 리터럴 치환). */
+ private static String buildMessage(String contents, Map params) {
+ if (!StringUtils.hasText(contents)) {
+ return "";
+ }
+
+ String result = contents;
+ for (Map.Entry entry : params.entrySet()) {
+ if (entry.getKey() == null || entry.getValue() == null) {
+ continue;
+ }
+ result = result.replace("%" + entry.getKey() + "%", entry.getValue());
+ }
+ return result;
+ }
+}
diff --git a/src/main/java/com/eactive/apim/portal/djb/swing/SwingNotifier.java b/src/main/java/com/eactive/apim/portal/djb/swing/SwingNotifier.java
new file mode 100644
index 0000000..b86923d
--- /dev/null
+++ b/src/main/java/com/eactive/apim/portal/djb/swing/SwingNotifier.java
@@ -0,0 +1,43 @@
+package com.eactive.apim.portal.djb.swing;
+
+import com.eactive.apim.portal.djb.swing.config.SwingAsyncConfig;
+import com.eactive.apim.portal.template.entity.MessageCode;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Service;
+
+import java.util.Map;
+
+/**
+ * DjBankSwing(사내 메신저) 전용 알림 발행자.
+ *
+ * 포털 사용자용 범용 발송 경로({@code MessageHandlerService} → {@code MessageSendService})를
+ * 타지 않고 TSEAIRM02 / PTL_MESSAGE_TEMPLATE / PTL_MESSAGE_REQUEST 를 직접 다룬다.
+ * 내부 직원은 수신자 ID 체계(행번)와 채널(메신저 단일)이 포털 사용자와 완전히 달라 분리했다.
+ *
+ * 비동기 실행이므로 게시물 등록 트랜잭션과 분리된다. 발송 적재 실패가 등록을 되돌리지 않는 대신,
+ * 등록이 롤백돼도 알림은 남을 수 있다.
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class SwingNotifier {
+
+ private final SwingMessageWriter swingMessageWriter;
+
+ /**
+ * 대상 역할(PTL_PROPERTY {@code djb.swing.notify.role}) 직원 전원에게 메신저 알림을 적재한다.
+ *
+ * @param code 메세지 코드 (PTL_MESSAGE_TEMPLATE.MESSAGE_CODE 와 동일)
+ * @param params 템플릿 {@code %KEY%} 치환 파라미터
+ */
+ @Async(SwingAsyncConfig.EXECUTOR)
+ public void notifyPortalAdmins(MessageCode code, Map params) {
+ try {
+ swingMessageWriter.write(code, params);
+ } catch (Exception e) {
+ log.warn("Swing 알림 발행 실패 — code={}", code == null ? null : code.name(), e);
+ }
+ }
+}
diff --git a/src/main/java/com/eactive/apim/portal/djb/swing/SwingNotifyProperties.java b/src/main/java/com/eactive/apim/portal/djb/swing/SwingNotifyProperties.java
new file mode 100644
index 0000000..2319eca
--- /dev/null
+++ b/src/main/java/com/eactive/apim/portal/djb/swing/SwingNotifyProperties.java
@@ -0,0 +1,112 @@
+package com.eactive.apim.portal.djb.swing;
+
+import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.util.Map;
+import java.util.regex.Pattern;
+
+/**
+ * Swing 알림 관련 PTL_PROPERTY 접근 래퍼.
+ *
+ * 그룹 {@code Portal}, 점 구분 소문자 키 관례를 따른다.
+ * {@link PortalPropertyService#getOrCreateProperty} 는 최초 접근 시 기본값으로 DB row 를
+ * 생성하므로 별도 초기 데이터 없이도 동작한다.
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class SwingNotifyProperties {
+
+ public static final String GROUP = "Portal";
+
+ public static final String KEY_TARGET_ROLE = "djb.swing.notify.role";
+ public static final String KEY_EMPLOYEE_ID_PATTERN = "djb.swing.notify.employee-id.pattern";
+ public static final String KEY_NON_EMPLOYEE_POLICY = "djb.swing.notify.non-employee.policy";
+
+ /** UMS 메신저 연계 식별자. 기존 발송 경로(MessageSendService)와 같은 키를 그대로 읽는다. */
+ public static final String KEY_MESSENGER_IF_ID = "djb.ums.messenger.if_id";
+ public static final String KEY_MESSENGER_TX_ID = "djb.ums.messenger.tx_id";
+
+ private static final String DEFAULT_TARGET_ROLE = "portal-admin";
+ private static final String DEFAULT_EMPLOYEE_ID_PATTERN = "^[0-9]+$";
+
+ private final PortalPropertyService portalPropertyService;
+
+ /** 컴파일된 행번 정규식 캐시. 프로퍼티 값이 바뀌면 다시 컴파일한다. */
+ private volatile String cachedPatternValue;
+ private volatile Pattern cachedPattern;
+
+ /** 알림 대상 직원을 식별하는 TSEAIRM02.ROLEIDNFINAME 역할명 */
+ public String getTargetRole() {
+ return resolve(KEY_TARGET_ROLE, DEFAULT_TARGET_ROLE,
+ "Swing 알림 대상 직원 역할명 (TSEAIRM02.ROLEIDNFINAME 의 콤마 구분 토큰)");
+ }
+
+ /** 행번 아닌 ID 처리 정책 */
+ public EmployeeIdPolicy getNonEmployeePolicy() {
+ return EmployeeIdPolicy.from(resolve(KEY_NON_EMPLOYEE_POLICY, EmployeeIdPolicy.SKIP.name(),
+ "행번 아닌 ID 처리 정책 (SEND/SEND_WARN/SKIP/REJECT_LOG/REJECT)"));
+ }
+
+ /**
+ * 행번(직원번호) 형식인지 여부. 정규식이 잘못 지정된 경우 기본 정규식으로 폴백한다.
+ *
+ * @param userId TSEAIRM02.USERID
+ */
+ public boolean isEmployeeId(String userId) {
+ if (userId == null || userId.trim().isEmpty()) {
+ return false;
+ }
+ return employeeIdPattern().matcher(userId.trim()).matches();
+ }
+
+ /** UMS 메신저 연계 식별자(EAI 인터페이스 ID / 서비스 ID) */
+ public UmsMessengerIds getUmsMessengerIds() {
+ // 환경별 실제 연계값이라 임의 기본값을 만들면 위험 — getOrCreateProperty 대신 조회만 한다.
+ Map portalProperties = portalPropertyService.getPortalPropertiesAsMap(GROUP);
+ return new UmsMessengerIds(
+ portalProperties.get(KEY_MESSENGER_IF_ID),
+ portalProperties.get(KEY_MESSENGER_TX_ID));
+ }
+
+ private Pattern employeeIdPattern() {
+ String value = resolve(KEY_EMPLOYEE_ID_PATTERN, DEFAULT_EMPLOYEE_ID_PATTERN,
+ "행번(직원번호) 판별 정규식. 미매치 시 개발용 ID 로 간주");
+
+ Pattern cached = this.cachedPattern;
+ if (cached != null && value != null && value.equals(this.cachedPatternValue)) {
+ return cached;
+ }
+
+ Pattern compiled;
+ try {
+ compiled = Pattern.compile(value);
+ } catch (Exception e) {
+ log.warn("행번 판별 정규식이 잘못되어 기본값으로 대체합니다 — pattern={}", value, e);
+ compiled = Pattern.compile(DEFAULT_EMPLOYEE_ID_PATTERN);
+ }
+ this.cachedPatternValue = value;
+ this.cachedPattern = compiled;
+ return compiled;
+ }
+
+ private String resolve(String key, String defaultValue, String description) {
+ return portalPropertyService.getOrCreateProperty(GROUP, key, defaultValue, description);
+ }
+
+ /** UMS 메신저 연계 식별자 묶음 */
+ @Getter
+ public static class UmsMessengerIds {
+ private final String interfaceId;
+ private final String serviceId;
+
+ public UmsMessengerIds(String interfaceId, String serviceId) {
+ this.interfaceId = interfaceId;
+ this.serviceId = serviceId;
+ }
+ }
+}
diff --git a/src/main/java/com/eactive/apim/portal/djb/swing/config/SwingAsyncConfig.java b/src/main/java/com/eactive/apim/portal/djb/swing/config/SwingAsyncConfig.java
new file mode 100644
index 0000000..207c469
--- /dev/null
+++ b/src/main/java/com/eactive/apim/portal/djb/swing/config/SwingAsyncConfig.java
@@ -0,0 +1,34 @@
+package com.eactive.apim.portal.djb.swing.config;
+
+import java.util.concurrent.Executor;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.annotation.EnableAsync;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
+
+/**
+ * Swing 알림 전용 비동기 실행기.
+ *
+ * 게시물 등록 트랜잭션이 메신저 알림 적재를 기다리지 않도록 분리한다.
+ * eapim-admin 의 {@code WebhookAsyncConfig} 와 동일한 구성.
+ */
+@Configuration
+@EnableAsync
+public class SwingAsyncConfig {
+
+ public static final String EXECUTOR = "swingNotifyExecutor";
+
+ @Bean(name = EXECUTOR)
+ public Executor swingNotifyExecutor() {
+ ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
+ executor.setCorePoolSize(2);
+ executor.setMaxPoolSize(10);
+ executor.setQueueCapacity(100);
+ executor.setThreadNamePrefix("swing-notify-");
+ executor.setWaitForTasksToCompleteOnShutdown(true);
+ executor.setAwaitTerminationSeconds(30);
+ executor.initialize();
+ return executor;
+ }
+}
diff --git a/src/main/java/com/eactive/apim/portal/djb/swing/repository/SwingStaffRepository.java b/src/main/java/com/eactive/apim/portal/djb/swing/repository/SwingStaffRepository.java
new file mode 100644
index 0000000..e3c8030
--- /dev/null
+++ b/src/main/java/com/eactive/apim/portal/djb/swing/repository/SwingStaffRepository.java
@@ -0,0 +1,27 @@
+package com.eactive.apim.portal.djb.swing.repository;
+
+import com.eactive.apim.portal.user.entity.UserInfo;
+import com.eactive.eai.rms.data.EMSDataSource;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+
+/**
+ * Swing 알림 대상 직원(TSEAIRM02) 조회 전용 리포지토리.
+ */
+@Repository
+@EMSDataSource
+public interface SwingStaffRepository extends JpaRepository {
+
+ /**
+ * TSEAIRM02.roleidnfiname 컬럼은 콤마로 구분된 복수 역할을 저장한다.
+ * (예: {@code "admin,portal-admin"}). Oracle native query로 콤마 토큰 매치.
+ */
+ @Query(value = "SELECT * FROM TSEAIRM02 t"
+ + " WHERE ',' || t.ROLEIDNFINAME || ',' LIKE '%,' || :role || ',%'",
+ nativeQuery = true)
+ List findByRole(@Param("role") String role);
+}