Merged from master

This commit is contained in:
Rinjae
2026-07-31 13:41:25 +09:00
16 changed files with 510 additions and 135 deletions
@@ -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
@@ -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
@@ -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 조회.
*
* <p>역할 기반 알림 대상 조회는
* {@code com.eactive.apim.portal.djb.swing.repository.SwingStaffRepository} 로 분리했다.</p>
*/
@EMSDataSource
public interface UserInfoRepository extends BaseRepository<UserInfo, String> {
/**
* TSEAIRM02.roleidnfiname 컬럼은 콤마로 구분된 복수 역할을 저장한다.
* (예: {@code "admin,portal-admin"}). Oracle native query로 콤마 토큰 매치.
*/
@Query(value = "SELECT * FROM TSEAIRM02 t"
+ " WHERE ',' || t.ROLEIDNFINAME || ',' LIKE '%,' || :role || ',%'",
nativeQuery = true)
List<UserInfo> findByRoleContaining(@Param("role") String role);
}
@@ -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<String, Object> params) {
try {
List<UserInfo> 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;
}
}
@@ -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<String, Writer> resolveWriters(List<InquiryComment> comments) {
@@ -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() {
}
}
@@ -0,0 +1,40 @@
package com.eactive.apim.portal.djb.swing;
/**
* Swing 메신저 수신자 ID 가 행번(직원번호)이 아닐 때의 처리 정책.
*
* <p>Swing 메신저는 {@code TSEAIRM02.USERID} 를 수신자 코드로 사용하며 운영 직원은 숫자 행번을 쓴다.
* portal-admin 역할이 부여된 개발용 계정(영문 ID 등)은 Swing 에 존재하지 않아 발송이 무의미하므로
* 이 정책으로 처리 방식을 선택한다. PTL_PROPERTY
* {@code Portal / djb.swing.notify.non-employee.policy} 로 지정한다.</p>
*/
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;
}
}
@@ -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 에 직접 적재한다.
*
* <p>TSEAIRM02(직원) → PTL_MESSAGE_TEMPLATE(본문) → PTL_MESSAGE_REQUEST(발송 요청) 순으로
* 처리하며, 실제 발송은 기존과 동일하게 eapim-admin 의 {@code UmsDispatchJob}(5초 주기)이
* {@code REQUEST_STATUS='PENDING'} 행을 수집해 수행한다.</p>
*/
@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<String, Object> params) {
String role = properties.getTargetRole();
List<UserInfo> 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<String, Object> 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<String, String> 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<String, String> toStringParams(Map<String, Object> params) {
Map<String, String> result = new HashMap<>();
if (params != null) {
for (Map.Entry<String, Object> 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<String, String> params) {
if (!StringUtils.hasText(contents)) {
return "";
}
String result = contents;
for (Map.Entry<String, String> entry : params.entrySet()) {
if (entry.getKey() == null || entry.getValue() == null) {
continue;
}
result = result.replace("%" + entry.getKey() + "%", entry.getValue());
}
return result;
}
}
@@ -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(사내 메신저) 전용 알림 발행자.
*
* <p>포털 사용자용 범용 발송 경로({@code MessageHandlerService} → {@code MessageSendService})를
* 타지 않고 TSEAIRM02 / PTL_MESSAGE_TEMPLATE / PTL_MESSAGE_REQUEST 를 직접 다룬다.
* 내부 직원은 수신자 ID 체계(행번)와 채널(메신저 단일)이 포털 사용자와 완전히 달라 분리했다.</p>
*
* <p>비동기 실행이므로 게시물 등록 트랜잭션과 분리된다. 발송 적재 실패가 등록을 되돌리지 않는 대신,
* 등록이 롤백돼도 알림은 남을 수 있다.</p>
*/
@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<String, Object> params) {
try {
swingMessageWriter.write(code, params);
} catch (Exception e) {
log.warn("Swing 알림 발행 실패 — code={}", code == null ? null : code.name(), e);
}
}
}
@@ -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 접근 래퍼.
*
* <p>그룹 {@code Portal}, 점 구분 소문자 키 관례를 따른다.
* {@link PortalPropertyService#getOrCreateProperty} 는 최초 접근 시 기본값으로 DB row 를
* 생성하므로 별도 초기 데이터 없이도 동작한다.</p>
*/
@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<String, String> 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;
}
}
}
@@ -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 알림 전용 비동기 실행기.
*
* <p>게시물 등록 트랜잭션이 메신저 알림 적재를 기다리지 않도록 분리한다.
* eapim-admin 의 {@code WebhookAsyncConfig} 와 동일한 구성.</p>
*/
@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;
}
}
@@ -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<UserInfo, String> {
/**
* TSEAIRM02.roleidnfiname 컬럼은 콤마로 구분된 복수 역할을 저장한다.
* (예: {@code "admin,portal-admin"}). Oracle native query로 콤마 토큰 매치.
*/
@Query(value = "SELECT * FROM TSEAIRM02 t"
+ " WHERE ',' || t.ROLEIDNFINAME || ',' LIKE '%,' || :role || ',%'",
nativeQuery = true)
List<UserInfo> findByRole(@Param("role") String role);
}
+58 -45
View File
@@ -1160,7 +1160,7 @@ hr {
transition: all 0.3s ease;
}
.mobile-drawer .drawer-welcome .btn-drawer-login:hover {
background: rgb(0%, 25.7647058824%, 63.5294117647%);
background: rgb(0, 65.7, 162);
}
.mobile-drawer .drawer-welcome.authenticated {
flex-direction: row;
@@ -1692,6 +1692,19 @@ hr {
}
}
.footer-link--external {
display: inline-flex;
align-items: center;
gap: 4px;
.footer-link-external-icon {
flex-shrink: 0;
width: 14px;
height: 14px;
color: currentColor;
}
}
.footer-separator {
color: #D1D5DB;
font-size: 16px;
@@ -2457,7 +2470,7 @@ hr {
color: #FFFFFF;
}
.btn-success:hover {
background: rgb(32.662665066%, 78.1608643457%, 41.762304922%);
background: rgb(83.2897959184, 199.3102040816, 106.493877551);
transform: translateY(-3px);
}
.btn-danger {
@@ -2465,7 +2478,7 @@ hr {
color: #FFFFFF;
}
.btn-danger:hover {
background: rgb(100%, 27.7647058824%, 27.7647058824%);
background: rgb(255, 70.8, 70.8);
transform: translateY(-3px);
}
.btn-ghost {
@@ -2731,7 +2744,7 @@ hr {
.action-btn-delete:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1);
background: rgb(100%, 34.862745098%, 34.862745098%);
background: rgb(255, 88.9, 88.9);
}
.action-btn-delete:active {
transform: translateY(0);
@@ -2849,7 +2862,7 @@ hr {
background: #a4d6ea;
}
.btn-input-action.btn-change:hover {
background: rgb(51.6323529412%, 78.2079831933%, 88.8382352941%);
background: rgb(131.6625, 199.4303571429, 226.5375);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1);
}
@@ -2924,7 +2937,7 @@ hr {
border: none;
}
.btn-action-primary:hover {
background: rgb(12.4992826399%, 36.3615494978%, 80.6771879484%);
background: rgb(31.8731707317, 92.7219512195, 205.7268292683);
transform: translateY(-2px);
color: #fff;
}
@@ -2974,7 +2987,7 @@ hr {
}
.status-badge.status-processing {
background: rgba(255, 217, 61, 0.1);
color: rgb(86.7450980392%, 69.7537901759%, 0%);
color: rgb(221.2, 177.8721649485, 0);
}
.status-badge.status-failed {
background: rgba(255, 107, 107, 0.1);
@@ -3014,7 +3027,7 @@ hr {
}
.status-badge-header.status-processing {
background: rgba(255, 217, 61, 0.1);
color: rgb(86.7450980392%, 69.7537901759%, 0%);
color: rgb(221.2, 177.8721649485, 0);
}
.badge-sm {
@@ -4202,7 +4215,7 @@ select.form-control {
.file-upload-wrapper .file-remove-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1);
background: rgb(100%, 34.862745098%, 34.862745098%);
background: rgb(255, 88.9, 88.9);
}
.file-upload-wrapper .file-remove-btn:active {
transform: translateY(0);
@@ -4523,7 +4536,7 @@ select.form-control {
transition: all 0.3s ease;
}
.form-actions--with-withdrawal .withdrawal-link:hover {
background: rgb(82.4349376114%, 91.2174688057%, 95.2709447415%);
background: rgb(210.2090909091, 232.6045454545, 242.9409090909);
}
.form-actions--with-withdrawal .withdrawal-link img {
width: 22px;
@@ -4678,7 +4691,7 @@ select.form-control {
text-decoration: underline;
}
.notice-content-box a:hover {
color: rgb(0%, 25.7647058824%, 63.5294117647%);
color: rgb(0, 65.7, 162);
}
.form-row--content .form-label-wrapper {
@@ -5612,7 +5625,7 @@ select.form-control {
font-size: 16px;
}
.drawer-logout-btn:hover {
background: rgb(100%, 27.7647058824%, 27.7647058824%);
background: rgb(255, 70.8, 70.8);
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(75, 155, 255, 0.15);
}
@@ -6325,7 +6338,7 @@ select.form-control {
color: #64748b;
}
.list-table-btn--default:hover {
background-color: rgb(91.512605042%, 91.512605042%, 90.6638655462%);
background-color: rgb(233.3571428571, 233.3571428571, 231.1928571429);
}
.list-table-btn--primary {
background-color: #ecf0fa;
@@ -6333,7 +6346,7 @@ select.form-control {
color: #2a69de;
}
.list-table-btn--primary:hover {
background-color: rgb(85.0049019608%, 88.1617647059%, 96.0539215686%);
background-color: rgb(216.7625, 224.8125, 244.9375);
}
.list-table-btn--secondary {
background-color: #f5f5f4;
@@ -6341,7 +6354,7 @@ select.form-control {
color: #64748b;
}
.list-table-btn--secondary:hover {
background-color: rgb(91.512605042%, 91.512605042%, 90.6638655462%);
background-color: rgb(233.3571428571, 233.3571428571, 231.1928571429);
}
.list-table-btn--danger {
background-color: #fbe7e9;
@@ -6349,7 +6362,7 @@ select.form-control {
color: #bb1026;
}
.list-table-btn--danger:hover {
background-color: rgb(97.081232493%, 82.487394958%, 83.9467787115%);
background-color: rgb(247.5571428571, 210.3428571429, 214.0642857143);
}
.table-pagination {
@@ -6999,7 +7012,7 @@ select.form-control {
.alert.alert-error {
background: rgba(255, 107, 107, 0.1);
border: 1px solid rgba(255, 107, 107, 0.3);
color: rgb(100%, 27.7647058824%, 27.7647058824%);
color: rgb(255, 70.8, 70.8);
align-items: center;
}
.alert.alert-error svg {
@@ -7013,7 +7026,7 @@ select.form-control {
.alert.alert-success {
background: rgba(107, 207, 127, 0.1);
border: 1px solid rgba(107, 207, 127, 0.3);
color: rgb(24.12484994%, 74.3849539816%, 34.1768707483%);
color: rgb(61.5183673469, 189.6816326531, 87.1510204082);
}
.alert.alert-info {
background: rgba(0, 73, 180, 0.1);
@@ -12022,10 +12035,10 @@ body.index-page-body {
line-height: 20px;
}
.login-button:hover {
background: rgb(10.0588235294%, 27.568627451%, 68.1764705882%);
background: rgb(25.65, 70.3, 173.85);
}
.login-button:active {
background: rgb(9.5294117647%, 26.1176470588%, 64.5882352941%);
background: rgb(24.3, 66.6, 164.7);
}
.login-button:disabled {
opacity: 0.6;
@@ -12068,10 +12081,10 @@ body.index-page-body {
border-bottom-right-radius: 8px;
}
.login-links-container .link-btn:hover {
background: rgb(86.5137254902%, 89.3529411765%, 96.4509803922%);
background: rgb(220.61, 227.85, 245.95);
}
.login-links-container .link-btn:active {
background: rgb(80.4784313725%, 84.5882352941%, 94.862745098%);
background: rgb(205.22, 215.7, 241.9);
}
.login-alert {
@@ -12580,12 +12593,12 @@ body.index-page-body {
}
.auth-request-button:hover,
.auth-verify-button:hover {
background: rgb(14.6320689023%, 60.3648891332%, 92.1600879604%);
background: rgb(37.3117757009, 153.9304672897, 235.0082242991);
transform: none !important;
}
.auth-request-button:active,
.auth-verify-button:active {
background: rgb(8.3967014843%, 57.3774601429%, 91.4307494961%);
background: rgb(21.411588785, 146.3125233645, 233.148411215);
}
.auth-request-button:disabled,
.auth-verify-button:disabled {
@@ -12634,10 +12647,10 @@ body.index-page-body {
background: #f0f2f5;
}
.account-recovery-card .form-actions .cancel-button:hover {
background: rgb(88.4117647059%, 89.9568627451%, 92.2745098039%);
background: rgb(225.45, 229.39, 235.3);
}
.account-recovery-card .form-actions .cancel-button:active {
background: rgb(82.7058823529%, 85.0117647059%, 88.4705882353%);
background: rgb(210.9, 216.78, 225.6);
}
.account-recovery-card .form-actions .submit-button {
color: #FFFFFF;
@@ -12647,7 +12660,7 @@ body.index-page-body {
background: rgb(6, 54, 125);
}
.account-recovery-card .form-actions .submit-button:active {
background: rgb(0%, 25.7647058824%, 63.5294117647%);
background: rgb(0, 65.7, 162);
}
.account-recovery-card .form-actions .submit-button:disabled {
opacity: 0.6;
@@ -12852,7 +12865,7 @@ body.index-page-body {
transition: color 0.3s ease;
}
.result-info-box .info-text .info-link:hover {
color: rgb(0%, 25.7647058824%, 63.5294117647%);
color: rgb(0, 65.7, 162);
}
@media (max-width: 576px) {
.result-info-box .info-text {
@@ -18035,7 +18048,7 @@ input[type=checkbox]:checked + .custom-checkbox {
transition: background 0.2s ease;
}
.btn-copy-action:hover {
background: rgb(51.6323529412%, 78.2079831933%, 88.8382352941%);
background: rgb(131.6625, 199.4303571429, 226.5375);
}
@media (max-width: 768px) {
.btn-copy-action {
@@ -18062,7 +18075,7 @@ input[type=checkbox]:checked + .custom-checkbox {
transition: background 0.2s ease;
}
.btn-view-secret:hover {
background: rgb(12.5057724024%, 59.377680044%, 91.9648158329%);
background: rgb(31.8897196262, 151.4130841121, 234.5102803738);
}
.btn-view-secret svg {
width: 20px;
@@ -18247,7 +18260,7 @@ input[type=checkbox]:checked + .custom-checkbox {
border-radius: 8px;
}
.btn-copy-action:hover {
background: rgb(51.6323529412%, 78.2079831933%, 88.8382352941%);
background: rgb(131.6625, 199.4303571429, 226.5375);
}
.btn-view-secret {
width: 100% !important;
@@ -18263,7 +18276,7 @@ input[type=checkbox]:checked + .custom-checkbox {
height: 16px;
}
.btn-view-secret:hover {
background: rgb(12.5057724024%, 59.377680044%, 91.9648158329%);
background: rgb(31.8897196262, 151.4130841121, 234.5102803738);
}
#revealedSecretBox {
width: 100%;
@@ -18536,7 +18549,7 @@ input[type=checkbox]:checked + .custom-checkbox {
flex-shrink: 0;
}
.detail-wrap .dt-btn-copy:hover {
background: rgb(74.3529411765%, 90.2296918768%, 100%);
background: rgb(189.6, 230.0857142857, 255);
}
.detail-wrap .dt-btn-copy svg {
color: #2a69de;
@@ -18713,7 +18726,7 @@ input[type=checkbox]:checked + .custom-checkbox {
transition: background 0.2s ease;
}
.detail-wrap .dt-btn-gray:hover {
background: rgb(66.9250773994%, 71.9364293086%, 75.9455108359%);
background: rgb(170.6589473684, 183.4378947368, 193.6610526316);
}
.detail-wrap .dt-btn-red {
width: 156px;
@@ -18731,7 +18744,7 @@ input[type=checkbox]:checked + .custom-checkbox {
transition: background 0.2s ease;
}
.detail-wrap .dt-btn-red:hover {
background: rgb(100%, 27.4868759774%, 25.1921568627%);
background: rgb(255, 70.0915337423, 64.24);
}
.detail-wrap .dt-btn-blue {
width: 156px;
@@ -20206,7 +20219,7 @@ input[type=checkbox]:checked + .custom-checkbox {
}
}
.btn-inquiry-list:hover {
background: rgb(84.6615515772%, 85.8414322251%, 88.2011935209%);
background: rgb(215.8869565217, 218.8956521739, 224.9130434783);
}
.btn-inquiry-list:active {
transform: scale(0.98);
@@ -20239,7 +20252,7 @@ input[type=checkbox]:checked + .custom-checkbox {
}
}
.btn-inquiry-edit:hover {
background: rgb(0%, 27.1960784314%, 67.0588235294%);
background: rgb(0, 69.35, 171);
}
.btn-inquiry-edit:active {
transform: scale(0.98);
@@ -20272,7 +20285,7 @@ input[type=checkbox]:checked + .custom-checkbox {
}
}
.btn-inquiry-delete:hover {
background: rgb(85.4839910648%, 16.2218912882%, 22.8577810871%);
background: rgb(217.9841772152, 41.3658227848, 58.2873417722);
}
.btn-inquiry-delete:active {
transform: scale(0.98);
@@ -20344,7 +20357,7 @@ input[type=checkbox]:checked + .custom-checkbox {
margin-left: 8px;
}
.file-upload-inline .btn-remove-file-inline:hover {
background: rgb(82.1236038719%, 14.2293373045%, 20.7341772152%);
background: rgb(209.4151898734, 36.2848101266, 52.8721518987);
}
.file-upload-inline .btn-remove-file-inline svg {
width: 12px;
@@ -20376,7 +20389,7 @@ input[type=checkbox]:checked + .custom-checkbox {
}
}
.file-upload-inline .btn-file-attach:hover {
background: rgb(14.6320689023%, 60.3648891332%, 92.1600879604%);
background: rgb(37.3117757009, 153.9304672897, 235.0082242991);
}
.file-upload-inline .btn-file-attach svg {
width: 22px;
@@ -20436,7 +20449,7 @@ input[type=checkbox]:checked + .custom-checkbox {
border: none;
}
.inquiry-form-container .form-actions .btn-secondary:hover {
background: rgb(84.6615515772%, 85.8414322251%, 88.2011935209%);
background: rgb(215.8869565217, 218.8956521739, 224.9130434783);
}
.inquiry-form-container .form-actions .btn-primary {
background: #0049b4;
@@ -20444,7 +20457,7 @@ input[type=checkbox]:checked + .custom-checkbox {
border: none;
}
.inquiry-form-container .form-actions .btn-primary:hover {
background: rgb(0%, 27.1960784314%, 67.0588235294%);
background: rgb(0, 69.35, 171);
}
.inquiry-form-container .file-upload-inline .file-input-display {
min-height: 50px;
@@ -21233,7 +21246,7 @@ input[type=checkbox]:checked + .custom-checkbox {
cursor: pointer;
}
.djb-board-write-container .form-actions .btn-submit:hover {
background-color: rgb(13.193687231%, 38.3816355811%, 85.1592539455%);
background-color: rgb(33.643902439, 97.8731707317, 217.156097561);
}
@media (max-width: 768px) {
.djb-board-write-container .form-actions .btn-submit {
@@ -21719,7 +21732,7 @@ input[type=checkbox]:checked + .custom-checkbox {
transition: all 0.3s ease;
}
.org-file-remove:hover {
background: rgb(100%, 27.7647058824%, 27.7647058824%);
background: rgb(255, 70.8, 70.8);
}
.org-file-notice {
@@ -22731,7 +22744,7 @@ input[type=checkbox]:checked + .custom-checkbox {
}
.status-indicator.status-active {
background-color: rgba(107, 207, 127, 0.1);
color: rgb(32.662665066%, 78.1608643457%, 41.762304922%);
color: rgb(83.2897959184, 199.3102040816, 106.493877551);
}
.status-indicator.status-active .status-dot {
background-color: #6BCF7F;
File diff suppressed because one or more lines are too long
@@ -65,6 +65,19 @@
}
}
.footer-link--external {
display: inline-flex;
align-items: center;
gap: 4px;
.footer-link-external-icon {
flex-shrink: 0;
width: 14px;
height: 14px;
color: currentColor;
}
}
.footer-separator {
color: #D1D5DB;
font-size: 16px;
@@ -11,7 +11,13 @@
<a th:href="@{/agreements/terms}" class="footer-link">이용약관</a>
<span class="footer-separator"></span>
<a href="https://www.jejubank.co.kr/hmpg/csct/secuCenr/ptctPlcy/procsPlcy/ctnt.do"
target="_blank" rel="noopener noreferrer" class="footer-link">개인정보처리방침</a>
target="_blank" rel="noopener noreferrer" class="footer-link footer-link--external">개인정보처리방침<svg class="footer-link-external-icon" width="14" height="14" viewBox="0 0 16 16" fill="none"
xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">
<path d="M6.5 2.5H3.2A1.2 1.2 0 0 0 2 3.7v9.1A1.2 1.2 0 0 0 3.2 14h9.1a1.2 1.2 0 0 0 1.2-1.2V9.5"
stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9.5 2h4.5v4.5M14 2 7.5 8.5" stroke="currentColor" stroke-width="1.3"
stroke-linecap="round" stroke-linejoin="round"/>
</svg><span class="sr-only">새 창으로 열림</span></a>
</div>
<p class="footer-copyright">Copyright &copy; 2026 JEJU Bank. All Rights Reserved.</p>
</div>