From a109b5cc363a805e67d18f57491edea20f198a1c Mon Sep 17 00:00:00 2001 From: eedo_y Date: Tue, 21 Apr 2026 15:29:09 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20LogSanitizer=20=EC=A0=81=EC=9A=A9?= =?UTF-8?q?=20(#130)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../logging/SanitizedMessageConverter.java | 13 + .../src/main/resources/logback-spring.xml | 4 +- .../kr/pinhouse/common/util/LogSanitizer.java | 245 ++++++++++++++++++ .../home/application/service/HomeService.java | 20 +- .../application/service/ComplexService.java | 11 +- .../service/DistanceCacheService.java | 34 ++- .../CustomComplexDocumentRepositoryImpl.java | 5 +- .../service/ComplexFilterService.java | 8 +- .../application/service/NoticeService.java | 36 +-- .../application/service/ImageService.java | 11 +- .../service/SearchKeywordService.java | 6 +- .../user/application/service/UserService.java | 17 +- .../pinhouse/common/tracing/HttpLogUtil.java | 8 +- .../housing/complex/external/OdsayUtil.java | 4 +- .../image/external/GcsSignedUrlGenerator.java | 11 +- .../exception/GlobalExceptionHandler.java | 32 ++- .../co/kr/pinhouse/domain/auth/AuthApi.java | 4 +- .../co/kr/pinhouse/domain/user/UserApi.java | 4 +- .../auth/application/service/AuthService.java | 6 +- .../service/ExchangeCodeRateLimitService.java | 4 +- .../oauth2/handler/OAuth2SuccessHandler.java | 8 +- 21 files changed, 404 insertions(+), 87 deletions(-) create mode 100644 module-app/src/main/java/co/kr/pinhouse/app/logging/SanitizedMessageConverter.java create mode 100644 module-common/src/main/java/co/kr/pinhouse/common/util/LogSanitizer.java diff --git a/module-app/src/main/java/co/kr/pinhouse/app/logging/SanitizedMessageConverter.java b/module-app/src/main/java/co/kr/pinhouse/app/logging/SanitizedMessageConverter.java new file mode 100644 index 00000000..68f6931c --- /dev/null +++ b/module-app/src/main/java/co/kr/pinhouse/app/logging/SanitizedMessageConverter.java @@ -0,0 +1,13 @@ +package co.kr.pinhouse.app.logging; + +import ch.qos.logback.classic.pattern.MessageConverter; +import ch.qos.logback.classic.spi.ILoggingEvent; +import co.kr.pinhouse.common.util.LogSanitizer; + +public class SanitizedMessageConverter extends MessageConverter { + + @Override + public String convert(ILoggingEvent event) { + return LogSanitizer.sanitizeMessage(event.getFormattedMessage()); + } +} diff --git a/module-app/src/main/resources/logback-spring.xml b/module-app/src/main/resources/logback-spring.xml index 7e7e3f75..594979f8 100644 --- a/module-app/src/main/resources/logback-spring.xml +++ b/module-app/src/main/resources/logback-spring.xml @@ -1,13 +1,15 @@ + %d{yyyy-MM-dd HH:mm:ss.SSS} level=%-5level [%thread] [%X{traceId:-},%X{spanId:-}] %logger{36} - - %msg%n + %sanitizedMsg%n diff --git a/module-common/src/main/java/co/kr/pinhouse/common/util/LogSanitizer.java b/module-common/src/main/java/co/kr/pinhouse/common/util/LogSanitizer.java new file mode 100644 index 00000000..9e88fade --- /dev/null +++ b/module-common/src/main/java/co/kr/pinhouse/common/util/LogSanitizer.java @@ -0,0 +1,245 @@ +package co.kr.pinhouse.common.util; + +import java.lang.reflect.Array; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +public final class LogSanitizer { + + private static final Pattern URL_PATTERN = Pattern.compile("https?://\\S+"); + private static final Pattern NAME_FIELD_PATTERN = Pattern.compile( + "((?:(?:name|username|nickname|user_name)|이름|닉네임)\\s*[:=]\\s*)(\"[^\"]*\"|'[^']*'|[^,\\]\\)}]+)", + Pattern.CASE_INSENSITIVE); + private static final Pattern EMAIL_PATTERN = Pattern.compile( + "(?i)(? result.group(1) + sanitizeNamedFieldValue(result.group(2))); + return sanitized; + } + + public static Object sanitize(Object value) { + if (value == null) { + return null; + } + if (value instanceof UUID uuid) { + return uuid.toString(); + } + if (value instanceof CharSequence charSequence) { + return sanitizeMessage(charSequence.toString()); + } + if (value instanceof Optional optional) { + return optional.map(LogSanitizer::sanitize).orElse(null); + } + if (value instanceof Iterable iterable) { + return sanitizeIterable(iterable); + } + if (value instanceof Map map) { + return sanitizeMap(map); + } + if (value.getClass().isArray()) { + return sanitizeArray(value); + } + return value; + } + + public static String sanitizeName(String value) { + if (value == null) { + return null; + } + + String sanitized = sanitizePlainText(value).trim(); + if (sanitized.isBlank()) { + return sanitized; + } + return maskName(sanitized); + } + + private static Iterable sanitizeIterable(Iterable values) { + var sanitizedValues = new ArrayList<>(); + for (Object value : values) { + sanitizedValues.add(sanitize(value)); + } + return sanitizedValues; + } + + private static Map sanitizeMap(Map values) { + Map sanitizedValues = new LinkedHashMap<>(); + for (Map.Entry entry : values.entrySet()) { + sanitizedValues.put(sanitize(entry.getKey()), sanitize(entry.getValue())); + } + return sanitizedValues; + } + + private static Iterable sanitizeArray(Object array) { + int length = Array.getLength(array); + var sanitizedValues = new ArrayList<>(length); + for (int index = 0; index < length; index++) { + sanitizedValues.add(sanitize(Array.get(array, index))); + } + return sanitizedValues; + } + + private static String sanitizeUrl(String rawUrl) { + try { + URI uri = new URI(rawUrl); + StringBuilder builder = new StringBuilder(); + + if (uri.getScheme() != null) { + builder.append(uri.getScheme()).append("://"); + } + if (uri.getHost() != null) { + builder.append(uri.getHost()); + } else if (uri.getAuthority() != null) { + builder.append(uri.getAuthority()); + } + if (uri.getPort() != -1) { + builder.append(':').append(uri.getPort()); + } + if (uri.getRawPath() != null) { + builder.append(sanitizePath(uri.getRawPath())); + } + if (uri.getRawQuery() != null) { + builder.append('?').append(sanitizeQuery(uri.getRawQuery())); + } + if (uri.getRawFragment() != null) { + builder.append("#***"); + } + return builder.toString(); + } catch (URISyntaxException exception) { + return maskMiddle(normalizeWhitespace(rawUrl), 6, 4); + } + } + + private static String sanitizePlainText(String value) { + String sanitized = normalizeWhitespace(value); + sanitized = URL_PATTERN.matcher(sanitized).replaceAll(result -> sanitizeUrl(result.group())); + sanitized = EMAIL_PATTERN.matcher(sanitized).replaceAll(result -> maskEmail(result.group())); + sanitized = IPV4_PATTERN.matcher(sanitized).replaceAll(result -> maskIpv4(result.group())); + sanitized = JWT_PATTERN.matcher(sanitized).replaceAll(result -> maskMiddle(result.group(), 6, 4)); + sanitized = LONG_SECRET_PATTERN.matcher(sanitized).replaceAll(result -> maskMiddle(result.group(), 6, 4)); + return sanitized; + } + + private static String sanitizeNamedFieldValue(String value) { + String trimmedValue = value.trim(); + if (trimmedValue.length() >= 2 + && ((trimmedValue.startsWith("\"") && trimmedValue.endsWith("\"")) + || (trimmedValue.startsWith("'") && trimmedValue.endsWith("'")))) { + String quote = trimmedValue.substring(0, 1); + return quote + sanitizeName(trimmedValue.substring(1, trimmedValue.length() - 1)) + quote; + } + return sanitizeName(trimmedValue); + } + + private static String sanitizePath(String path) { + return Arrays.stream(path.split("/", -1)) + .map(LogSanitizer::sanitizePathSegment) + .collect(Collectors.joining("/")); + } + + private static String sanitizePathSegment(String segment) { + if (segment == null || segment.isBlank()) { + return segment; + } + + String sanitized = EMAIL_PATTERN.matcher(segment).replaceAll(result -> maskEmail(result.group())); + sanitized = JWT_PATTERN.matcher(sanitized).replaceAll(result -> maskMiddle(result.group(), 6, 4)); + sanitized = LONG_SECRET_PATTERN.matcher(sanitized).replaceAll(result -> maskMiddle(result.group(), 6, 4)); + return sanitized; + } + + private static String sanitizeQuery(String query) { + return Arrays.stream(query.split("&", -1)) + .map(LogSanitizer::sanitizeQueryParameter) + .collect(Collectors.joining("&")); + } + + private static String sanitizeQueryParameter(String parameter) { + if (parameter.isBlank()) { + return parameter; + } + + int separatorIndex = parameter.indexOf('='); + if (separatorIndex < 0) { + return parameter; + } + return parameter.substring(0, separatorIndex + 1) + "***"; + } + + private static String maskEmail(String email) { + int separatorIndex = email.indexOf('@'); + if (separatorIndex < 0) { + return email; + } + + String localPart = email.substring(0, separatorIndex); + String domainPart = email.substring(separatorIndex + 1); + if (localPart.length() <= 2) { + return localPart.charAt(0) + "***@" + domainPart; + } + return localPart.substring(0, 2) + "***@" + domainPart; + } + + private static String maskIpv4(String ipAddress) { + String[] parts = ipAddress.split("\\."); + if (parts.length != 4) { + return ipAddress; + } + return parts[0] + "." + parts[1] + "." + parts[2] + ".***"; + } + + private static String maskMiddle(String value, int prefixLength, int suffixLength) { + if (value == null || value.length() <= prefixLength + suffixLength) { + return "***"; + } + return value.substring(0, prefixLength) + "***" + value.substring(value.length() - suffixLength); + } + + private static String maskName(String value) { + return Arrays.stream(value.split("\\s+", -1)) + .map(LogSanitizer::maskNameToken) + .collect(Collectors.joining(" ")); + } + + private static String maskNameToken(String value) { + if (value == null || value.isBlank()) { + return value; + } + if (value.length() == 1) { + return "*"; + } + if (value.length() == 2) { + return value.charAt(0) + "*"; + } + return value.charAt(0) + "*" + value.charAt(value.length() - 1); + } + + private static String normalizeWhitespace(String value) { + return value + .replace('\r', ' ') + .replace('\n', ' ') + .replace('\t', ' '); + } +} diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/home/application/service/HomeService.java b/module-domain/src/main/java/co/kr/pinhouse/domain/home/application/service/HomeService.java index 1b9a3992..c7108dd3 100644 --- a/module-domain/src/main/java/co/kr/pinhouse/domain/home/application/service/HomeService.java +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/home/application/service/HomeService.java @@ -1,5 +1,7 @@ package co.kr.pinhouse.domain.home.application.service; +import static co.kr.pinhouse.common.util.LogSanitizer.sanitize; + import java.time.Instant; import java.time.ZoneId; import java.time.ZonedDateTime; @@ -82,7 +84,7 @@ public HomeNoticeListResponse getDeadlineApproachingNotices( // PinPoint 소유자 검증 boolean isOwner = pinPointService.checkPinPoint(pinpointId, userId); if (!isOwner) { - log.warn("PinPoint 소유자 불일치 - pinpointId={}, requestUserId={}", pinpointId, userId); + log.warn("PinPoint 소유자 불일치 - pinpointId={}, requestUserId={}", sanitize(pinpointId), sanitize(userId)); throw new CustomException(PinPointErrorCode.BAD_REQUEST_PINPOINT); } @@ -152,7 +154,7 @@ private String extractCountyFromAddress(String address) { } // 시/군/구를 찾지 못한 경우 null 반환 (광역시 등의 경우) - log.debug("주소에서 시/군/구를 추출할 수 없습니다. address={}", address); + log.debug("주소에서 시/군/구를 추출할 수 없습니다. address={}", sanitize(address)); return null; } @@ -227,7 +229,7 @@ private NoticeListRequest.Region extractRegionFromAddress(String address) { } // 매칭되는 Region을 찾지 못한 경우 - log.warn("주소에서 광역 단위를 추출할 수 없습니다. address={}", address); + log.warn("주소에서 광역 단위를 추출할 수 없습니다. address={}", sanitize(address)); throw new CustomException(PinPointErrorCode.BAD_REQUEST_PINPOINT); } @@ -347,7 +349,7 @@ public NoticeCountResponse getNoticeCountWithinTravelTime(String pinPointId, int // PinPoint 소유자 검증 boolean isOwner = pinPointService.checkPinPoint(pinPointId, userId); if (!isOwner) { - log.warn("PinPoint 소유자 불일치 - pinpointId={}, requestUserId={}", pinPointId, userId); + log.warn("PinPoint 소유자 불일치 - pinpointId={}, requestUserId={}", sanitize(pinPointId), sanitize(userId)); throw new CustomException(PinPointErrorCode.BAD_REQUEST_PINPOINT); } @@ -379,7 +381,7 @@ public NoticeCountResponse getNoticeCountWithinTravelTime(String pinPointId, int .count(); log.debug("핀포인트 {} 기준 {}분 내 공고 개수: {} (반경: {}km)", - pinPointId, maxTime, uniqueNoticeCount, distanceKm); + sanitize(pinPointId), sanitize(maxTime), sanitize(uniqueNoticeCount), sanitize(distanceKm)); return NoticeCountResponse.from(uniqueNoticeCount); } @@ -401,7 +403,7 @@ public HomeNoticeListResponse getRecommendedNoticesByDiagnosis( // 2. 진단 기록 없음 처리 if (diagnosis == null) { - log.warn("진단 기록이 없습니다 - userId={}", userId); + log.warn("진단 기록이 없습니다 - userId={}", sanitize(userId)); throw new CustomException(DiagnosisErrorCode.NOT_FOUND_DIAGNOSIS); } @@ -412,7 +414,7 @@ public HomeNoticeListResponse getRecommendedNoticesByDiagnosis( if (availableRentalTypes == null || availableRentalTypes.isEmpty() || availableRentalTypes.contains("해당 없음")) { - log.info("추천 가능한 임대주택이 없습니다 - userId={}", userId); + log.info("추천 가능한 임대주택이 없습니다 - userId={}", sanitize(userId)); return HomeNoticeListResponse.builder() .region(null) .title("진단 기반 추천") @@ -430,7 +432,7 @@ public HomeNoticeListResponse getRecommendedNoticesByDiagnosis( // 진단 결과에 매핑될 공고 유형이 없는 경우 빈 응답 반환 if (targetSupplyTypes.isEmpty()) { - log.info("진단 결과에 매핑 가능한 주택 유형이 없습니다 - userId={}", userId); + log.info("진단 결과에 매핑 가능한 주택 유형이 없습니다 - userId={}", sanitize(userId)); return HomeNoticeListResponse.builder() .region(null) .title("진단 기반 추천") @@ -441,7 +443,7 @@ public HomeNoticeListResponse getRecommendedNoticesByDiagnosis( } log.debug("진단 기반 필터링 - rentalTypes={}, supplyTypes={}", - availableRentalTypes, targetSupplyTypes); + sanitize(availableRentalTypes), sanitize(targetSupplyTypes)); // 6. 페이징 설정 (마감임박순) Sort sort = Sort.by(Sort.Order.asc("applyEnd"), Sort.Order.asc("noticeId")); diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/housing/complex/application/service/ComplexService.java b/module-domain/src/main/java/co/kr/pinhouse/domain/housing/complex/application/service/ComplexService.java index d4db0743..991f6d52 100644 --- a/module-domain/src/main/java/co/kr/pinhouse/domain/housing/complex/application/service/ComplexService.java +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/housing/complex/application/service/ComplexService.java @@ -1,5 +1,7 @@ package co.kr.pinhouse.domain.housing.complex.application.service; +import static co.kr.pinhouse.common.util.LogSanitizer.sanitize; + import java.io.UnsupportedEncodingException; import java.util.Comparator; import java.util.List; @@ -177,7 +179,7 @@ public List loadSortedComplexes( String noticeId, co.kr.pinhouse.domain.housing.notice.application.dto.UnitTypeSortType sortType ) { - log.debug("정렬된 단지 목록 조회 - noticeId: {}, sortType: {}", noticeId, sortType); + log.debug("정렬된 단지 목록 조회 - noticeId: {}, sortType: {}", sanitize(noticeId), sanitize(sortType)); return repository.findSortedComplexesWithUnitTypes(noticeId, sortType); } @@ -495,7 +497,8 @@ private T calculateTransitRoute( /// 계산 결과에 실제 경로 후보가 있는지 검증 private void validateTransitRoute(PathResult pathResult, String complexId, String pinPointId) { if (pathResult == null || pathResult.routes() == null || pathResult.routes().isEmpty()) { - log.warn("대중교통 경로를 찾지 못했습니다 - complexId={}, pinPointId={}", complexId, pinPointId); + log.warn("대중교통 경로를 찾지 못했습니다 - complexId={}, pinPointId={}", + sanitize(complexId), sanitize(pinPointId)); throw new CustomException(ComplexErrorCode.NOT_FOUND_TRANSIT_ROUTE); } } @@ -509,7 +512,7 @@ public TransitInfoResponse getTransitInfo(String id, String pinPointId) throws U TransitInfoResponse cachedTransitInfo = distanceCacheService.getTransitInfo(id, pinPointId); if (cachedTransitInfo != null) { - log.debug("Using cached TransitInfo for complexId={}, pinPointId={}", id, pinPointId); + log.debug("Using cached TransitInfo for complexId={}, pinPointId={}", sanitize(id), sanitize(pinPointId)); return cachedTransitInfo; } @@ -537,7 +540,7 @@ public DistanceResponse getEasyDistance(String id, String pinPointId) throws Uns distanceCacheService.getRootResult(id, pinPointId); if (cachedRootResult != null) { - log.debug("Using cached RootResult for complexId={}, pinPointId={}", id, pinPointId); + log.debug("Using cached RootResult for complexId={}, pinPointId={}", sanitize(id), sanitize(pinPointId)); List routes = mapper.from(cachedRootResult); return DistanceResponse.from(cachedRootResult, routes); } diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/housing/complex/application/service/DistanceCacheService.java b/module-domain/src/main/java/co/kr/pinhouse/domain/housing/complex/application/service/DistanceCacheService.java index f2b8065e..9a86ded5 100644 --- a/module-domain/src/main/java/co/kr/pinhouse/domain/housing/complex/application/service/DistanceCacheService.java +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/housing/complex/application/service/DistanceCacheService.java @@ -1,5 +1,7 @@ package co.kr.pinhouse.domain.housing.complex.application.service; +import static co.kr.pinhouse.common.util.LogSanitizer.sanitize; + import java.util.concurrent.TimeUnit; import org.springframework.data.redis.core.RedisTemplate; @@ -51,9 +53,10 @@ public void cacheRootResult(String complexId, String pinPointId, RootResult root try { String key = generateRootResultKey(complexId, pinPointId); redisTemplate.opsForValue().set(key, rootResult, CACHE_TTL_HOURS, TimeUnit.HOURS); - log.debug("Cached RootResult for complexId={}, pinPointId={}", complexId, pinPointId); + log.debug("Cached RootResult for complexId={}, pinPointId={}", sanitize(complexId), sanitize(pinPointId)); } catch (Exception e) { - log.error("Failed to cache RootResult: complexId={}, pinPointId={}", complexId, pinPointId, e); + log.error("Failed to cache RootResult: complexId={}, pinPointId={}", + sanitize(complexId), sanitize(pinPointId), e); } } @@ -69,14 +72,16 @@ public RootResult getRootResult(String complexId, String pinPointId) { Object cached = redisTemplate.opsForValue().get(key); if (cached instanceof RootResult) { - log.debug("RootResult cache hit for complexId={}, pinPointId={}", complexId, pinPointId); + log.debug("RootResult cache hit for complexId={}, pinPointId={}", + sanitize(complexId), sanitize(pinPointId)); return (RootResult)cached; } - log.debug("RootResult cache miss for complexId={}, pinPointId={}", complexId, pinPointId); + log.debug("RootResult cache miss for complexId={}, pinPointId={}", sanitize(complexId), sanitize(pinPointId)); return null; } catch (Exception e) { - log.error("Failed to get cached RootResult: complexId={}, pinPointId={}", complexId, pinPointId, e); + log.error("Failed to get cached RootResult: complexId={}, pinPointId={}", + sanitize(complexId), sanitize(pinPointId), e); return null; } } @@ -91,9 +96,10 @@ public void cacheTransitInfo(String complexId, String pinPointId, TransitInfoRes try { String key = generateTransitInfoKey(complexId, pinPointId); redisTemplate.opsForValue().set(key, transitInfo, CACHE_TTL_HOURS, TimeUnit.HOURS); - log.debug("Cached TransitInfo for complexId={}, pinPointId={}", complexId, pinPointId); + log.debug("Cached TransitInfo for complexId={}, pinPointId={}", sanitize(complexId), sanitize(pinPointId)); } catch (Exception e) { - log.error("Failed to cache TransitInfo: complexId={}, pinPointId={}", complexId, pinPointId, e); + log.error("Failed to cache TransitInfo: complexId={}, pinPointId={}", + sanitize(complexId), sanitize(pinPointId), e); } } @@ -109,14 +115,17 @@ public TransitInfoResponse getTransitInfo(String complexId, String pinPointId) { Object cached = redisTemplate.opsForValue().get(key); if (cached instanceof TransitInfoResponse) { - log.debug("TransitInfo cache hit for complexId={}, pinPointId={}", complexId, pinPointId); + log.debug("TransitInfo cache hit for complexId={}, pinPointId={}", + sanitize(complexId), sanitize(pinPointId)); return (TransitInfoResponse)cached; } - log.debug("TransitInfo cache miss for complexId={}, pinPointId={}", complexId, pinPointId); + log.debug("TransitInfo cache miss for complexId={}, pinPointId={}", + sanitize(complexId), sanitize(pinPointId)); return null; } catch (Exception e) { - log.error("Failed to get cached TransitInfo: complexId={}, pinPointId={}", complexId, pinPointId, e); + log.error("Failed to get cached TransitInfo: complexId={}, pinPointId={}", + sanitize(complexId), sanitize(pinPointId), e); return null; } } @@ -131,9 +140,10 @@ public void evictRootResult(String complexId, String pinPointId) { String key = generateRootResultKey(complexId, pinPointId); redisTemplate.delete(key); redisTemplate.delete(generateTransitInfoKey(complexId, pinPointId)); - log.debug("Evicted RootResult cache for complexId={}, pinPointId={}", complexId, pinPointId); + log.debug("Evicted RootResult cache for complexId={}, pinPointId={}", sanitize(complexId), sanitize(pinPointId)); } catch (Exception e) { - log.error("Failed to evict RootResult cache: complexId={}, pinPointId={}", complexId, pinPointId, e); + log.error("Failed to evict RootResult cache: complexId={}, pinPointId={}", + sanitize(complexId), sanitize(pinPointId), e); } } } diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/housing/complex/domain/repository/CustomComplexDocumentRepositoryImpl.java b/module-domain/src/main/java/co/kr/pinhouse/domain/housing/complex/domain/repository/CustomComplexDocumentRepositoryImpl.java index e631e748..40a99860 100644 --- a/module-domain/src/main/java/co/kr/pinhouse/domain/housing/complex/domain/repository/CustomComplexDocumentRepositoryImpl.java +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/housing/complex/domain/repository/CustomComplexDocumentRepositoryImpl.java @@ -1,5 +1,6 @@ package co.kr.pinhouse.domain.housing.complex.domain.repository; +import static co.kr.pinhouse.common.util.LogSanitizer.sanitize; import static org.springframework.data.mongodb.core.aggregation.Aggregation.group; import static org.springframework.data.mongodb.core.aggregation.Aggregation.match; import static org.springframework.data.mongodb.core.aggregation.Aggregation.newAggregation; @@ -56,7 +57,7 @@ public class CustomComplexDocumentRepositoryImpl implements CustomComplexDocumen @Override public List findSortedComplexesWithUnitTypes(String noticeId, UnitTypeSortType sortType) { - log.debug("MongoDB Aggregation 시작 - noticeId: {}, sortType: {}", noticeId, sortType); + log.debug("MongoDB Aggregation 시작 - noticeId: {}, sortType: {}", sanitize(noticeId), sanitize(sortType)); // 정렬 기준 설정 (tie-break 규칙 포함) Sort sort = buildSortCriteria(sortType); @@ -112,7 +113,7 @@ public List findSortedComplexesWithUnitTypes(String noticeId, U ); List complexes = results.getMappedResults(); - log.debug("MongoDB Aggregation 완료 - 조회된 단지 수: {}", complexes.size()); + log.debug("MongoDB Aggregation 완료 - 조회된 단지 수: {}", sanitize(complexes.size())); return complexes; } diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/housing/notice/application/service/ComplexFilterService.java b/module-domain/src/main/java/co/kr/pinhouse/domain/housing/notice/application/service/ComplexFilterService.java index 19f6cb72..473c324e 100644 --- a/module-domain/src/main/java/co/kr/pinhouse/domain/housing/notice/application/service/ComplexFilterService.java +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/housing/notice/application/service/ComplexFilterService.java @@ -1,5 +1,7 @@ package co.kr.pinhouse.domain.housing.notice.application.service; +import static co.kr.pinhouse.common.util.LogSanitizer.sanitize; + import java.util.ArrayList; import java.util.Comparator; import java.util.List; @@ -113,7 +115,7 @@ private boolean passesDistanceFilter( // 거리 정보가 없으면 필터링에서 제외 if (totalTime == null) { - log.warn("Distance info not found for complex {}", complex.getId()); + log.warn("Distance info not found for complex {}", sanitize(complex.getId())); return false; } @@ -134,7 +136,7 @@ private boolean passesComplexLevelFilters( String complexRegion = complex.getCounty() != null ? complex.getCounty() : ""; if (!request.region().contains(complexRegion)) { log.debug("Complex {} filtered out by region: {} not in {}", - complex.getId(), complexRegion, request.region()); + sanitize(complex.getId()), sanitize(complexRegion), sanitize(request.region())); return false; } } @@ -146,7 +148,7 @@ private boolean passesComplexLevelFilters( for (FacilityType requiredFacility : request.facilities()) { if (!availableFacilities.contains(requiredFacility)) { log.debug("Complex {} filtered out by facility: {} not available", - complex.getId(), requiredFacility); + sanitize(complex.getId()), sanitize(requiredFacility)); return false; } } diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/housing/notice/application/service/NoticeService.java b/module-domain/src/main/java/co/kr/pinhouse/domain/housing/notice/application/service/NoticeService.java index 1c207b24..cb958d30 100644 --- a/module-domain/src/main/java/co/kr/pinhouse/domain/housing/notice/application/service/NoticeService.java +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/housing/notice/application/service/NoticeService.java @@ -1,5 +1,7 @@ package co.kr.pinhouse.domain.housing.notice.application.service; +import static co.kr.pinhouse.common.util.LogSanitizer.sanitize; + import java.time.Instant; import java.time.ZoneId; import java.time.ZonedDateTime; @@ -130,7 +132,7 @@ public NoticeDetailFilteredResponse getNotice(String noticeId, NoticeDetailFilte Map totalTimeMap = new HashMap<>(); if (request.pinPointId() != null && !request.pinPointId().isBlank()) { log.info("공고 상세조회: 모든 단지에 대한 거리 계산 시작 - noticeId={}, pinPointId={}, 단지 개수={}", - noticeId, request.pinPointId(), complexes.size()); + sanitize(noticeId), sanitize(request.pinPointId()), sanitize(complexes.size())); int successCount = 0; int failCount = 0; @@ -142,16 +144,17 @@ public NoticeDetailFilteredResponse getNotice(String noticeId, NoticeDetailFilte totalTimeMap.put(complex.getId(), distance.totalTimeMinutes()); successCount++; log.debug("거리 계산 성공 및 Redis 캐싱 완료 - complexId={}, totalTime={}분", - complex.getId(), distance.totalTimeMinutes()); + sanitize(complex.getId()), sanitize(distance.totalTimeMinutes())); } catch (Exception e) { failCount++; totalTimeMap.put(complex.getId(), 0); log.error("거리 계산 실패 (0분으로 설정) - complexId={}, pinPointId={}, error={}", - complex.getId(), request.pinPointId(), e.getMessage(), e); + sanitize(complex.getId()), sanitize(request.pinPointId()), sanitize(e.getMessage()), e); } } - log.info("거리 계산 완료 - 성공: {}, 실패: {}, 총: {}", successCount, failCount, complexes.size()); + log.info("거리 계산 완료 - 성공: {}, 실패: {}, 총: {}", + sanitize(successCount), sanitize(failCount), sanitize(complexes.size())); } /// 서비스 레이어에서 필터링 수행 (totalTimeMap 전달) @@ -267,7 +270,7 @@ public int countFilteredComplexes(String noticeId, NoticeDetailFilterRequest req } catch (Exception e) { totalTimeMap.put(complex.getId(), 0); log.error("거리 계산 실패 (0분으로 설정) - complexId={}, pinPointId={}, error={}", - complex.getId(), request.pinPointId(), e.getMessage()); + sanitize(complex.getId()), sanitize(request.pinPointId()), sanitize(e.getMessage())); } } } @@ -302,7 +305,7 @@ public UnitTypeCompareResponse compareUnitTypes( /// FACILITY_MATCH 정렬은 nearbyFacilities 필수 if (finalSortType == UnitTypeSortType.FACILITY_MATCH && (nearbyFacilities == null || nearbyFacilities.isEmpty())) { - log.error("주변환경매칭순 정렬 요청이지만 nearbyFacilities가 없음 - noticeId={}", noticeId); + log.error("주변환경매칭순 정렬 요청이지만 nearbyFacilities가 없음 - noticeId={}", sanitize(noticeId)); throw new CustomException(NoticeErrorCode.MISSING_NEARBY_FACILITIES); } @@ -311,10 +314,10 @@ public UnitTypeCompareResponse compareUnitTypes( List complexes; if (finalSortType == UnitTypeSortType.FACILITY_MATCH || finalSortType == UnitTypeSortType.DISTANCE_ASC) { complexes = complexService.loadComplexes(noticeId); - log.debug("{} 정렬 - 정렬 없이 {} 개 단지 조회", finalSortType, complexes.size()); + log.debug("{} 정렬 - 정렬 없이 {} 개 단지 조회", sanitize(finalSortType), sanitize(complexes.size())); } else { complexes = complexService.loadSortedComplexes(noticeId, finalSortType); - log.debug("DB 정렬 완료 - 총 {} 개 단지 조회", complexes.size()); + log.debug("DB 정렬 완료 - 총 {} 개 단지 조회", sanitize(complexes.size())); } /// PinPoint 위치 조회 (optional) @@ -324,7 +327,7 @@ public UnitTypeCompareResponse compareUnitTypes( PinPoint pinPoint = pinPointService.loadPinPoint(pinPointId); userLocation = pinPoint.getLocation(); } catch (Exception e) { - log.warn("Failed to load PinPoint: {}", pinPointId, e); + log.warn("Failed to load PinPoint: {}", sanitize(pinPointId), e); } } @@ -347,7 +350,7 @@ public UnitTypeCompareResponse compareUnitTypes( Map totalTimeMap = new HashMap<>(); if (pinPointId != null && !pinPointId.isBlank()) { log.info("방 비교: 모든 단지에 대한 대중교통 소요 시간 계산 시작 - pinPointId={}, 단지 개수={}", - pinPointId, complexes.size()); + sanitize(pinPointId), sanitize(complexes.size())); int successCount = 0; int failCount = 0; @@ -360,16 +363,17 @@ public UnitTypeCompareResponse compareUnitTypes( totalTimeMap.put(complex.getId(), formattedTime); successCount++; log.debug("대중교통 시간 계산 성공 및 Redis 캐싱 완료 - complexId={}, totalTime={}", - complex.getId(), formattedTime); + sanitize(complex.getId()), sanitize(formattedTime)); } catch (Exception e) { failCount++; totalTimeMap.put(complex.getId(), null); log.error("대중교통 시간 계산 실패 (null로 설정) - complexId={}, pinPointId={}, error={}", - complex.getId(), pinPointId, e.getMessage(), e); + sanitize(complex.getId()), sanitize(pinPointId), sanitize(e.getMessage()), e); } } - log.info("대중교통 시간 계산 완료 - 성공: {}, 실패: {}, 총: {}", successCount, failCount, complexes.size()); + log.info("대중교통 시간 계산 완료 - 성공: {}, 실패: {}, 총: {}", + sanitize(successCount), sanitize(failCount), sanitize(complexes.size())); } /// 최종 시간 맵 (람다에서 사용하기 위해 effectively final) @@ -405,7 +409,7 @@ public UnitTypeCompareResponse compareUnitTypes( } case FACILITY_MATCH -> { sortByFacilityMatch(comparisonItems, nearbyFacilities); - log.debug("시설 매칭 정렬 완료 - 매칭 대상 시설: {}", nearbyFacilities); + log.debug("시설 매칭 정렬 완료 - 매칭 대상 시설: {}", sanitize(nearbyFacilities)); } case DISTANCE_ASC -> { sortByDistance(comparisonItems); @@ -571,7 +575,7 @@ private double parseDistanceToKm(String distanceStr) { } return Double.MAX_VALUE; } catch (NumberFormatException e) { - log.warn("거리 문자열 파싱 실패: {}", distanceStr); + log.warn("거리 문자열 파싱 실패: {}", sanitize(distanceStr)); return Double.MAX_VALUE; } } @@ -606,7 +610,7 @@ private int parseTimeToMinutes(String timeStr) { return totalMinutes; } catch (NumberFormatException e) { - log.warn("시간 문자열 파싱 실패: {}", timeStr); + log.warn("시간 문자열 파싱 실패: {}", sanitize(timeStr)); return Integer.MAX_VALUE; } } diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/image/application/service/ImageService.java b/module-domain/src/main/java/co/kr/pinhouse/domain/image/application/service/ImageService.java index 4596090a..d4ffb482 100644 --- a/module-domain/src/main/java/co/kr/pinhouse/domain/image/application/service/ImageService.java +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/image/application/service/ImageService.java @@ -1,5 +1,7 @@ package co.kr.pinhouse.domain.image.application.service; +import static co.kr.pinhouse.common.util.LogSanitizer.sanitize; + import java.util.List; import java.util.UUID; @@ -54,7 +56,8 @@ public PresignedUrlResponse generatePresignedUrl(PresignedUrlRequest request, UU // 6. Response 반환 int expiresInSeconds = expirationMinutes * 60; - log.info("Presigned URL 생성 완료: userId={}, fileName={}, objectKey={}", userId, request.fileName(), objectKey); + log.info("Presigned URL 생성 완료: userId={}, fileName={}, objectKey={}", + sanitize(userId), sanitize(request.fileName()), sanitize(objectKey)); return PresignedUrlResponse.of(presignedUrl, publicUrl, expiresInSeconds); } @@ -70,7 +73,7 @@ private void validateContentType(String contentType) { List allowedContentTypes = List.of("image/jpeg", "image/jpg", "image/png", "image/gif"); if (!allowedContentTypes.contains(contentType.toLowerCase())) { - log.warn("지원하지 않는 Content-Type: {}", contentType); + log.warn("지원하지 않는 Content-Type: {}", sanitize(contentType)); throw new CustomException(ImageErrorCode.INVALID_FILE_TYPE); } } @@ -85,7 +88,7 @@ private String extractExtension(String fileName) { int lastDotIndex = fileName.lastIndexOf('.'); if (lastDotIndex <= 0 || lastDotIndex == fileName.length() - 1) { - log.warn("파일 확장자 추출 실패: fileName={}", fileName); + log.warn("파일 확장자 추출 실패: fileName={}", sanitize(fileName)); throw new CustomException(ImageErrorCode.INVALID_FILE_EXTENSION); } @@ -99,7 +102,7 @@ private void validateExtension(String extension) { List allowedExtensions = List.of("jpg", "jpeg", "png", "gif"); if (!allowedExtensions.contains(extension)) { - log.warn("지원하지 않는 확장자: {}", extension); + log.warn("지원하지 않는 확장자: {}", sanitize(extension)); throw new CustomException(ImageErrorCode.INVALID_FILE_EXTENSION); } } diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/search/application/service/SearchKeywordService.java b/module-domain/src/main/java/co/kr/pinhouse/domain/search/application/service/SearchKeywordService.java index c4f19581..9c7ee2f5 100644 --- a/module-domain/src/main/java/co/kr/pinhouse/domain/search/application/service/SearchKeywordService.java +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/search/application/service/SearchKeywordService.java @@ -1,5 +1,7 @@ package co.kr.pinhouse.domain.search.application.service; +import static co.kr.pinhouse.common.util.LogSanitizer.sanitize; + import java.time.Instant; import java.util.List; @@ -67,9 +69,9 @@ public void recordSearch(String keyword, SearchKeywordScope scope) { mongoTemplate.upsert(query, update, SearchKeyword.class); - log.debug("Recorded search keyword: {}", normalizedKeyword); + log.debug("Recorded search keyword: {}", sanitize(normalizedKeyword)); } catch (Exception e) { - log.error("Failed to record search keyword: {}", normalizedKeyword, e); + log.error("Failed to record search keyword: {}", sanitize(normalizedKeyword), e); // 키워드 기록 실패는 검색 자체를 방해하지 않음 } } diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/user/application/service/UserService.java b/module-domain/src/main/java/co/kr/pinhouse/domain/user/application/service/UserService.java index 970c798c..563a38e1 100644 --- a/module-domain/src/main/java/co/kr/pinhouse/domain/user/application/service/UserService.java +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/user/application/service/UserService.java @@ -1,5 +1,8 @@ package co.kr.pinhouse.domain.user.application.service; +import static co.kr.pinhouse.common.util.LogSanitizer.sanitize; +import static co.kr.pinhouse.common.util.LogSanitizer.sanitizeName; + import java.util.ArrayList; import java.util.Map; import java.util.Optional; @@ -81,12 +84,12 @@ public User saveUser(String tempUserKey, UserRequest request) { .birthday((String)infoMap.get("birthday")) .build(); } else { - log.error("Redis raw object type is unexpected: {}", raw.getClass().getName()); + log.error("Redis raw object type is unexpected: {}", sanitize(raw.getClass().getName())); throw new CustomException(UserErrorCode.BAD_REQUEST_ONBOARDING); } log.info("Processing user signup - social: {}, email: {}, name: {}", - userInfo.getSocial(), userInfo.getEmail(), userInfo.getUsername()); + sanitize(userInfo.getSocial()), sanitize(userInfo.getEmail()), sanitizeName(userInfo.getUsername())); Provider provider = Provider.valueOf(userInfo.getSocial()); @@ -186,13 +189,13 @@ public void deleteUser(UUID userId, WithdrawRequest request) { /// 탈퇴 사유 로깅 (0개 이상 복수 선택 가능) if (request.reasons() != null && !request.reasons().isEmpty()) { log.info("회원 탈퇴 - userId={}, 탈퇴 사유 개수={}, 사유={}", - userId, - request.reasons().size(), - request.reasons().stream() + sanitize(userId), + sanitize(request.reasons().size()), + sanitize(request.reasons().stream() .map(reason -> reason.getValue()) - .toList()); + .toList())); } else { - log.info("회원 탈퇴 - userId={}, 탈퇴 사유 선택 안함", userId); + log.info("회원 탈퇴 - userId={}, 탈퇴 사유 선택 안함", sanitize(userId)); } /// 핀포인트 DB에서 삭제 diff --git a/module-infrastructure/src/main/java/co/kr/pinhouse/common/tracing/HttpLogUtil.java b/module-infrastructure/src/main/java/co/kr/pinhouse/common/tracing/HttpLogUtil.java index 3a3328d0..6d28f9f1 100644 --- a/module-infrastructure/src/main/java/co/kr/pinhouse/common/tracing/HttpLogUtil.java +++ b/module-infrastructure/src/main/java/co/kr/pinhouse/common/tracing/HttpLogUtil.java @@ -1,5 +1,8 @@ package co.kr.pinhouse.common.tracing; +import static co.kr.pinhouse.common.util.LogSanitizer.sanitize; +import static co.kr.pinhouse.common.util.LogSanitizer.sanitizeName; + import org.springframework.stereotype.Component; import co.kr.pinhouse.common.util.HttpUtil; @@ -24,8 +27,9 @@ public void logHttpRequest(HttpServletRequest httpServletRequest, String type) { var clientInfo = httpUtil.getClientInfo(httpServletRequest); /// 로그 - log.info("{} : {}, [{}], {}, {} ,{}", type, clientInfo.ip(), clientInfo.httpMethod(), clientInfo.uri(), - clientInfo.userName(), body); + log.info("{} : {}, [{}], {}, {} ,{}", sanitize(type), sanitize(clientInfo.ip()), + sanitize(clientInfo.httpMethod()), sanitize(clientInfo.uri()), sanitizeName(clientInfo.userName()), + sanitize(body)); } diff --git a/module-infrastructure/src/main/java/co/kr/pinhouse/infrastructure/housing/complex/external/OdsayUtil.java b/module-infrastructure/src/main/java/co/kr/pinhouse/infrastructure/housing/complex/external/OdsayUtil.java index 039cc75c..b0c79170 100644 --- a/module-infrastructure/src/main/java/co/kr/pinhouse/infrastructure/housing/complex/external/OdsayUtil.java +++ b/module-infrastructure/src/main/java/co/kr/pinhouse/infrastructure/housing/complex/external/OdsayUtil.java @@ -1,5 +1,7 @@ package co.kr.pinhouse.infrastructure.housing.complex.external; +import static co.kr.pinhouse.common.util.LogSanitizer.sanitize; + import java.net.URI; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; @@ -125,7 +127,7 @@ private void handleOdsayError(JsonNode root) { errorNode.toString() ); - log.error("ODsay 에러 응답 수신 - code={}, message={}", errorCode, errorMessage); + log.error("ODsay 에러 응답 수신 - code={}, message={}", sanitize(errorCode), sanitize(errorMessage)); if (isInvalidApiKey(errorCode, errorMessage)) { throw new CustomException(ComplexErrorCode.ODSAY_INVALID_API_KEY); diff --git a/module-infrastructure/src/main/java/co/kr/pinhouse/infrastructure/image/external/GcsSignedUrlGenerator.java b/module-infrastructure/src/main/java/co/kr/pinhouse/infrastructure/image/external/GcsSignedUrlGenerator.java index 510f7a63..f9680445 100644 --- a/module-infrastructure/src/main/java/co/kr/pinhouse/infrastructure/image/external/GcsSignedUrlGenerator.java +++ b/module-infrastructure/src/main/java/co/kr/pinhouse/infrastructure/image/external/GcsSignedUrlGenerator.java @@ -1,5 +1,7 @@ package co.kr.pinhouse.infrastructure.image.external; +import static co.kr.pinhouse.common.util.LogSanitizer.sanitize; + import java.net.URL; import java.util.HashMap; import java.util.Map; @@ -102,17 +104,20 @@ public String generatePutPresignedUrl(String objectKey, String contentType) { Storage.SignUrlOption.withV4Signature() ); - log.info("GCS Signed URL 생성 성공: objectKey={}, expiresIn={}분", objectKey, expirationMinutes); + log.info("GCS Signed URL 생성 성공: objectKey={}, expiresIn={}분", + sanitize(objectKey), sanitize(expirationMinutes)); return signedUrl.toString(); } catch (StorageException e) { // GCS API 호출 실패 (권한 문제, 네트워크 오류 등) - log.error("GCS Signed URL 생성 실패: objectKey={}, error={}", objectKey, e.getMessage()); + log.error("GCS Signed URL 생성 실패: objectKey={}, error={}", + sanitize(objectKey), sanitize(e.getMessage())); throw new CustomException(ImageErrorCode.GCS_PRESIGNED_URL_GENERATION_FAILED); } catch (Exception e) { // 기타 예외 (잘못된 파라미터, 내부 오류 등) - log.error("GCS Signed URL 생성 중 예외 발생: objectKey={}, error={}", objectKey, e.getMessage()); + log.error("GCS Signed URL 생성 중 예외 발생: objectKey={}, error={}", + sanitize(objectKey), sanitize(e.getMessage())); throw new CustomException(ImageErrorCode.GCS_CLIENT_ERROR); } } diff --git a/module-presentation/src/main/java/co/kr/pinhouse/common/exception/GlobalExceptionHandler.java b/module-presentation/src/main/java/co/kr/pinhouse/common/exception/GlobalExceptionHandler.java index 07e82781..145b2c1a 100644 --- a/module-presentation/src/main/java/co/kr/pinhouse/common/exception/GlobalExceptionHandler.java +++ b/module-presentation/src/main/java/co/kr/pinhouse/common/exception/GlobalExceptionHandler.java @@ -1,5 +1,7 @@ package co.kr.pinhouse.common.exception; +import static co.kr.pinhouse.common.util.LogSanitizer.sanitize; + import java.util.List; import java.util.NoSuchElementException; @@ -41,7 +43,7 @@ public ResponseEntity> handleCustomException(CustomException exce ErrorCode errorCode = exception.getErrorCode(); /// 로그찍기 - log.error(errorCode.getMessage()); + log.error(toLogMessage(errorCode.getMessage())); /// 응답 return ResponseEntity @@ -58,7 +60,7 @@ public ResponseEntity> handleCustomException(CustomException exce public ApiResponse handleValidationExceptions(MethodArgumentNotValidException exception) { /// 에러 이유 로그 찍기 - log.error(exception.getMessage()); + log.error(toLogMessage(exception.getMessage())); /// 파라미터용 예외 코드 ErrorCode errorCode = CommonErrorCode.BAD_PARAMETER; @@ -81,7 +83,7 @@ public ApiResponse handleValidationExceptions(MethodArgumentNot public ApiResponse handleRedisConnectionFailureException(RedisConnectionFailureException exception) { /// 에러 이유 로그 찍기 - log.error(exception.getMessage()); + log.error(toLogMessage(exception.getMessage())); /// 기본 에러 코드로 응답 생성 ErrorCode errorCode = CommonErrorCode.INTERNAL_REDIS_SERVER_ERROR; @@ -97,7 +99,7 @@ public ApiResponse handleRedisConnectionFailureException(RedisConnectionFailu public ApiResponse handleMongoException(UncategorizedMongoDbException exception) { /// 에러 이유 로그 찍기 - log.error(exception.getMessage()); + log.error(toLogMessage(exception.getMessage())); /// 기본 에러 코드로 응답 생성 ErrorCode errorCode = CommonErrorCode.INTERNAL_MONGO_SERVER_ERROR; @@ -113,7 +115,7 @@ public ApiResponse handleMongoException(UncategorizedMongoDbException excepti public ApiResponse handleNoSuchException(Exception exception) { /// 에러 이유 로그 찍기 - log.error(exception.getMessage()); + log.error(toLogMessage(exception.getMessage())); /// 기본 에러 코드로 응답 생성 ErrorCode errorCode = CommonErrorCode.NOT_FOUND; @@ -129,7 +131,7 @@ public ApiResponse handleNoSuchException(Exception exception) { public ApiResponse handleNullPointerException(NullPointerException exception) { /// 에러 이유 로그 찍기 - log.error(exception.getMessage()); + log.error(toLogMessage(exception.getMessage())); /// 기본 에러 코드로 응답 생성 ErrorCode errorCode = CommonErrorCode.NULL_VALUE; @@ -145,7 +147,7 @@ public ApiResponse handleNullPointerException(NullPointerException exception) public ApiResponse handleIllegalException(Exception exception) { /// 에러 이유 로그 찍기 - log.error(exception.getMessage(), exception); + log.error(toLogMessage(exception.getMessage()), exception); /// 기본 에러 코드로 응답 생성 ErrorCode errorCode = CommonErrorCode.BAD_REQUEST; @@ -161,7 +163,7 @@ public ApiResponse handleIllegalException(Exception exception) { public ApiResponse handleJsonException(HttpMessageNotReadableException exception) { /// 에러 이유 로그 찍기 - log.error(exception.getMessage()); + log.error(toLogMessage(exception.getMessage())); /// 기본 에러 코드로 응답 생성 ErrorCode errorCode = CommonErrorCode.BAD_REQUEST_JSON; @@ -177,7 +179,7 @@ public ApiResponse handleJsonException(HttpMessageNotReadableException except public ApiResponse handleJUnexpectedTypeException(Exception exception) { /// 에러 이유 로그 찍기 - log.error(exception.getMessage()); + log.error(toLogMessage(exception.getMessage())); /// 기본 에러 코드로 응답 생성 ErrorCode errorCode = CommonErrorCode.BAD_REQUEST_INVALID_INPUT; @@ -194,7 +196,7 @@ public ApiResponse handleInvalidDataAccessResourceUsageException( InvalidDataAccessResourceUsageException exception) { /// 에러 이유 로그 찍기 - log.error(exception.getMessage()); + log.error(toLogMessage(exception.getMessage())); /// 기본 에러 코드로 응답 생성 ErrorCode errorCode = CommonErrorCode.INTERNAL_DB_SERVER_ERROR; @@ -210,7 +212,7 @@ public ApiResponse handleInvalidDataAccessResourceUsageException( public ApiResponse handleDataIntegrityViolationException(Exception exception) { /// 에러 이유 로그 찍기 - log.error(exception.getMessage()); + log.error(toLogMessage(exception.getMessage())); /// 기본 에러 코드로 응답 생성 ErrorCode errorCode = CommonErrorCode.INTERNAL_DB_SCHEMA_SERVER_ERROR; @@ -226,7 +228,7 @@ public ApiResponse handleDataIntegrityViolationException(Exception exception) public ApiResponse handleTransientDataAccessException(TransientDataAccessException exception) { /// 에러 이유 로그 찍기 - log.error(exception.getMessage()); + log.error(toLogMessage(exception.getMessage())); /// 기본 에러 코드로 응답 생성 ErrorCode errorCode = CommonErrorCode.INTERNAL_DB_SERVER_ERROR; @@ -242,7 +244,7 @@ public ApiResponse handleTransientDataAccessException(TransientDataAccessExce public ApiResponse handleException(Exception exception) { /// 에러 이유 로그 찍기 - log.error(exception.getMessage(), exception); + log.error(toLogMessage(exception.getMessage()), exception); /// 기본 에러 코드로 응답 생성 ErrorCode errorCode = CommonErrorCode.INTERNAL_SERVER_ERROR; @@ -252,4 +254,8 @@ public ApiResponse handleException(Exception exception) { return ApiResponse.fail(customException); } + private String toLogMessage(String message) { + return String.valueOf(sanitize(message)); + } + } diff --git a/module-presentation/src/main/java/co/kr/pinhouse/domain/auth/AuthApi.java b/module-presentation/src/main/java/co/kr/pinhouse/domain/auth/AuthApi.java index be02301e..387063cc 100644 --- a/module-presentation/src/main/java/co/kr/pinhouse/domain/auth/AuthApi.java +++ b/module-presentation/src/main/java/co/kr/pinhouse/domain/auth/AuthApi.java @@ -1,5 +1,7 @@ package co.kr.pinhouse.domain.auth; +import static co.kr.pinhouse.common.util.LogSanitizer.sanitize; + import java.util.Optional; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -52,7 +54,7 @@ public ApiResponse exchangeCode( exchangeCodeRateLimitService.validateRequestAllowed(clientIp); log.info("공개 토큰 교환 요청 - clientIp: {}, code: {}...", - clientIp, request.code().substring(0, Math.min(8, request.code().length()))); + sanitize(clientIp), sanitize(request.code().substring(0, Math.min(8, request.code().length())))); AuthExchangeResponse response = service.exchangeCode(request.code()); return ApiResponse.ok(response); diff --git a/module-presentation/src/main/java/co/kr/pinhouse/domain/user/UserApi.java b/module-presentation/src/main/java/co/kr/pinhouse/domain/user/UserApi.java index c560576f..490b0163 100644 --- a/module-presentation/src/main/java/co/kr/pinhouse/domain/user/UserApi.java +++ b/module-presentation/src/main/java/co/kr/pinhouse/domain/user/UserApi.java @@ -1,5 +1,7 @@ package co.kr.pinhouse.domain.user; +import static co.kr.pinhouse.common.util.LogSanitizer.sanitize; + import java.util.UUID; import org.springframework.web.bind.annotation.DeleteMapping; @@ -63,7 +65,7 @@ public ApiResponse signUp( /// JWT 토큰 직접 발급 var tokenResponse = authUseCase.issueTokens(user); - log.info("회원가입 완료 - userId: {}, 토큰 발급 완료", user.getId()); + log.info("회원가입 완료 - userId: {}, 토큰 발급 완료", sanitize(user.getId())); /// 리턴 return ApiResponse.ok(SignupResponse.of( diff --git a/module-security/src/main/java/co/kr/pinhouse/security/auth/application/service/AuthService.java b/module-security/src/main/java/co/kr/pinhouse/security/auth/application/service/AuthService.java index 68b73050..53289073 100644 --- a/module-security/src/main/java/co/kr/pinhouse/security/auth/application/service/AuthService.java +++ b/module-security/src/main/java/co/kr/pinhouse/security/auth/application/service/AuthService.java @@ -1,5 +1,7 @@ package co.kr.pinhouse.security.auth.application.service; +import static co.kr.pinhouse.common.util.LogSanitizer.sanitize; + import java.time.Duration; import java.util.Optional; import java.util.UUID; @@ -165,7 +167,7 @@ public AuthExchangeResponse exchangeCode(String exchangeCode) { exchangeCodeRepository.delete(codeEntity); if (codeEntity.getExchangeCodeType() == JwtExchangeCodeType.SIGNUP_REQUIRED) { - log.info("Exchange code 사용 완료 - signup required, tempKey: {}", codeEntity.getTempUserKey()); + log.info("Exchange code 사용 완료 - signup required, tempKey: {}", sanitize(codeEntity.getTempUserKey())); return AuthExchangeResponse.signupRequired(codeEntity.getTempUserKey()); } @@ -177,7 +179,7 @@ public AuthExchangeResponse exchangeCode(String exchangeCode) { User user = repository.findById(codeEntity.getUserId()) .orElseThrow(() -> new CustomException(SecurityErrorCode.NOT_FOUND_ID)); - log.info("Exchange code 사용 완료 - userId: {}", user.getId()); + log.info("Exchange code 사용 완료 - userId: {}", sanitize(user.getId())); /// 토큰 발급 JwtTokenResponse tokenResponse = issueTokens(user); diff --git a/module-security/src/main/java/co/kr/pinhouse/security/auth/application/service/ExchangeCodeRateLimitService.java b/module-security/src/main/java/co/kr/pinhouse/security/auth/application/service/ExchangeCodeRateLimitService.java index b02ce087..4248b849 100644 --- a/module-security/src/main/java/co/kr/pinhouse/security/auth/application/service/ExchangeCodeRateLimitService.java +++ b/module-security/src/main/java/co/kr/pinhouse/security/auth/application/service/ExchangeCodeRateLimitService.java @@ -1,5 +1,7 @@ package co.kr.pinhouse.security.auth.application.service; +import static co.kr.pinhouse.common.util.LogSanitizer.sanitize; + import java.time.Duration; import org.springframework.beans.factory.annotation.Value; @@ -40,7 +42,7 @@ public void validateRequestAllowed(String clientIdentifier) { if (requestCount > maxAttempts) { log.warn("Exchange Code 교환 요청 제한 초과 - clientIdentifier: {}, count: {}", - normalizedClientIdentifier, requestCount); + sanitize(normalizedClientIdentifier), sanitize(requestCount)); throw new CustomException(SecurityErrorCode.EXCHANGE_CODE_RATE_LIMITED); } } diff --git a/module-security/src/main/java/co/kr/pinhouse/security/oauth2/handler/OAuth2SuccessHandler.java b/module-security/src/main/java/co/kr/pinhouse/security/oauth2/handler/OAuth2SuccessHandler.java index f8910506..7120feb0 100644 --- a/module-security/src/main/java/co/kr/pinhouse/security/oauth2/handler/OAuth2SuccessHandler.java +++ b/module-security/src/main/java/co/kr/pinhouse/security/oauth2/handler/OAuth2SuccessHandler.java @@ -1,5 +1,7 @@ package co.kr.pinhouse.security.oauth2.handler; +import static co.kr.pinhouse.common.util.LogSanitizer.sanitize; + import java.io.IOException; import org.springframework.security.core.Authentication; @@ -47,7 +49,8 @@ public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpS /// BFF 콜백 URL 생성 String redirectUrl = buildBffCallbackUrl(httpServletRequest, exchangeCode); - log.info("OAuth2 인증 성공 - userId: {}, BFF 리다이렉트: {}", user.getId(), redirectUrl); + log.info("OAuth2 인증 성공 - userId: {}, BFF 리다이렉트: {}", + sanitize(user.getId()), sanitize(redirectUrl)); /// BFF로 리다이렉트 (exchange code 포함) getRedirectStrategy().sendRedirect(httpServletRequest, httpServletResponse, redirectUrl); @@ -63,7 +66,6 @@ private String buildBffCallbackUrl(HttpServletRequest request, String exchangeCo } private String resolveBffCallbackUrl(HttpServletRequest request) { - String frontUrl = redirectUrlResolver.resolveRedirectUrl(request); - return frontUrl + "/api/auth/callback"; + return redirectUrlResolver.resolveRedirectUrl(request); } } From d3cb91f7273fddece63e4199a7de9ea4899a2576 Mon Sep 17 00:00:00 2001 From: eedo_y Date: Tue, 21 Apr 2026 15:33:19 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EB=82=B4=EC=9A=A9=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?(#129)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- module-app/src/main/resources/.env.example | 5 + module-app/src/main/resources/application.yml | 5 + module-domain/build.gradle | 1 + .../co/kr/pinhouse/domain/BaseTimeEntity.java | 3 +- .../response/AdminAdvertisementResponse.java | 51 ++++++ .../AdminAdvertisementSummaryResponse.java | 34 ++++ .../dto/response/AdminMeResponse.java | 27 +++ .../service/AdminSessionService.java | 51 ++++++ .../usecase/AdminSessionUseCase.java | 23 +++ .../dto/response/AdminAuditLogResponse.java | 41 +++++ .../service/AdminAuditLogService.java | 131 ++++++++++++++ .../usecase/AdminAuditLogUseCase.java | 43 +++++ .../domain/entity/AdminAuditActionType.java | 9 + .../audit/domain/entity/AdminAuditLog.java | 104 +++++++++++ .../domain/entity/AdminAuditTargetType.java | 7 + .../repository/AdminAuditLogRepository.java | 19 ++ .../dto/response/AdminNoticeResponse.java | 60 +++++++ .../response/AdminNoticeSummaryResponse.java | 39 +++++ .../service/AdminNoticeService.java | 126 +++++++++++++ .../usecase/AdminNoticeUseCase.java | 31 ++++ .../dto/response/AdminUserDetailResponse.java | 53 ++++++ .../response/AdminUserSummaryResponse.java | 38 ++++ .../application/service/AdminUserService.java | 117 +++++++++++++ .../application/usecase/AdminUserUseCase.java | 21 +++ .../common/util/RedirectUrlResolver.java | 165 +++++++++++++----- .../domain/admin/AdminSessionApi.java | 31 ++++ .../pinhouse/domain/admin/ad/AdminAdApi.java | 87 +++++++++ .../domain/admin/audit/AdminAuditLogApi.java | 30 ++++ .../domain/admin/cs/AdminCsInquiryApi.java | 93 ++++++++++ .../domain/admin/notice/AdminNoticeApi.java | 76 ++++++++ .../domain/admin/user/AdminUserApi.java | 45 +++++ .../pinhouse/security/config/CorsConfig.java | 18 ++ .../jwt/filter/RequestMatcherHolder.java | 14 ++ ...Auth2AuthorizationRequestOriginFilter.java | 4 +- .../oauth2/handler/OAuth2FailureHandler.java | 3 +- 35 files changed, 1561 insertions(+), 44 deletions(-) create mode 100644 module-domain/src/main/java/co/kr/pinhouse/domain/ad/application/dto/response/AdminAdvertisementResponse.java create mode 100644 module-domain/src/main/java/co/kr/pinhouse/domain/ad/application/dto/response/AdminAdvertisementSummaryResponse.java create mode 100644 module-domain/src/main/java/co/kr/pinhouse/domain/admin/application/dto/response/AdminMeResponse.java create mode 100644 module-domain/src/main/java/co/kr/pinhouse/domain/admin/application/service/AdminSessionService.java create mode 100644 module-domain/src/main/java/co/kr/pinhouse/domain/admin/application/usecase/AdminSessionUseCase.java create mode 100644 module-domain/src/main/java/co/kr/pinhouse/domain/admin/audit/application/dto/response/AdminAuditLogResponse.java create mode 100644 module-domain/src/main/java/co/kr/pinhouse/domain/admin/audit/application/service/AdminAuditLogService.java create mode 100644 module-domain/src/main/java/co/kr/pinhouse/domain/admin/audit/application/usecase/AdminAuditLogUseCase.java create mode 100644 module-domain/src/main/java/co/kr/pinhouse/domain/admin/audit/domain/entity/AdminAuditActionType.java create mode 100644 module-domain/src/main/java/co/kr/pinhouse/domain/admin/audit/domain/entity/AdminAuditLog.java create mode 100644 module-domain/src/main/java/co/kr/pinhouse/domain/admin/audit/domain/entity/AdminAuditTargetType.java create mode 100644 module-domain/src/main/java/co/kr/pinhouse/domain/admin/audit/domain/repository/AdminAuditLogRepository.java create mode 100644 module-domain/src/main/java/co/kr/pinhouse/domain/admin/notice/application/dto/response/AdminNoticeResponse.java create mode 100644 module-domain/src/main/java/co/kr/pinhouse/domain/admin/notice/application/dto/response/AdminNoticeSummaryResponse.java create mode 100644 module-domain/src/main/java/co/kr/pinhouse/domain/admin/notice/application/service/AdminNoticeService.java create mode 100644 module-domain/src/main/java/co/kr/pinhouse/domain/admin/notice/application/usecase/AdminNoticeUseCase.java create mode 100644 module-domain/src/main/java/co/kr/pinhouse/domain/admin/user/application/dto/response/AdminUserDetailResponse.java create mode 100644 module-domain/src/main/java/co/kr/pinhouse/domain/admin/user/application/dto/response/AdminUserSummaryResponse.java create mode 100644 module-domain/src/main/java/co/kr/pinhouse/domain/admin/user/application/service/AdminUserService.java create mode 100644 module-domain/src/main/java/co/kr/pinhouse/domain/admin/user/application/usecase/AdminUserUseCase.java create mode 100644 module-presentation/src/main/java/co/kr/pinhouse/domain/admin/AdminSessionApi.java create mode 100644 module-presentation/src/main/java/co/kr/pinhouse/domain/admin/ad/AdminAdApi.java create mode 100644 module-presentation/src/main/java/co/kr/pinhouse/domain/admin/audit/AdminAuditLogApi.java create mode 100644 module-presentation/src/main/java/co/kr/pinhouse/domain/admin/cs/AdminCsInquiryApi.java create mode 100644 module-presentation/src/main/java/co/kr/pinhouse/domain/admin/notice/AdminNoticeApi.java create mode 100644 module-presentation/src/main/java/co/kr/pinhouse/domain/admin/user/AdminUserApi.java diff --git a/module-app/src/main/resources/.env.example b/module-app/src/main/resources/.env.example index 00e4a237..3968f23d 100644 --- a/module-app/src/main/resources/.env.example +++ b/module-app/src/main/resources/.env.example @@ -43,7 +43,12 @@ JWT_SAME_SITE= CORS_FRONT_LOCAL= CORS_FRONT_DEV= CORS_FRONT_PROD= +CORS_ADMIN_FRONT_LOCAL= +CORS_ADMIN_FRONT_DEV= +CORS_ADMIN_FRONT_PROD= CORS_FRONT_REDIRECT= +CORS_FRONT_REDIRECT_PATH=/api/auth/callback +CORS_ADMIN_FRONT_REDIRECT_PATH=/admin/auth/callback CORS_BACK_DEV= # KAKAO API Keys diff --git a/module-app/src/main/resources/application.yml b/module-app/src/main/resources/application.yml index ef00f191..17b01e04 100644 --- a/module-app/src/main/resources/application.yml +++ b/module-app/src/main/resources/application.yml @@ -103,7 +103,12 @@ cors: local: ${CORS_FRONT_LOCAL} dev: ${CORS_FRONT_DEV} prod: ${CORS_FRONT_PROD} + admin-local: ${CORS_ADMIN_FRONT_LOCAL:} + admin-dev: ${CORS_ADMIN_FRONT_DEV:} + admin-prod: ${CORS_ADMIN_FRONT_PROD:} redirect: ${CORS_FRONT_REDIRECT} + redirect-path: ${CORS_FRONT_REDIRECT_PATH:/api/auth/callback} + admin-redirect-path: ${CORS_ADMIN_FRONT_REDIRECT_PATH:/admin/auth/callback} back: dev: ${CORS_BACK_DEV} diff --git a/module-domain/build.gradle b/module-domain/build.gradle index 4d8f1854..383a7e0e 100644 --- a/module-domain/build.gradle +++ b/module-domain/build.gradle @@ -16,6 +16,7 @@ dependencies { // Validation implementation 'org.springframework.boot:spring-boot-starter-validation' + compileOnly 'jakarta.servlet:jakarta.servlet-api' } bootJar { diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/BaseTimeEntity.java b/module-domain/src/main/java/co/kr/pinhouse/domain/BaseTimeEntity.java index 80080b22..1ce23a55 100644 --- a/module-domain/src/main/java/co/kr/pinhouse/domain/BaseTimeEntity.java +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/BaseTimeEntity.java @@ -4,6 +4,7 @@ import org.springframework.data.annotation.CreatedBy; import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedBy; import org.springframework.data.annotation.LastModifiedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; @@ -27,7 +28,7 @@ public abstract class BaseTimeEntity { @Column(updatable = false) private String createdBy; - @LastModifiedDate + @LastModifiedBy private String updatedBy; } diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/ad/application/dto/response/AdminAdvertisementResponse.java b/module-domain/src/main/java/co/kr/pinhouse/domain/ad/application/dto/response/AdminAdvertisementResponse.java new file mode 100644 index 00000000..fed3e1d3 --- /dev/null +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/ad/application/dto/response/AdminAdvertisementResponse.java @@ -0,0 +1,51 @@ +package co.kr.pinhouse.domain.ad.application.dto.response; + +import java.time.LocalDateTime; + +import co.kr.pinhouse.domain.ad.domain.entity.Advertisement; +import co.kr.pinhouse.domain.ad.domain.entity.AdvertisementLinkType; +import co.kr.pinhouse.domain.ad.domain.entity.AdvertisementPlacement; +import co.kr.pinhouse.domain.ad.domain.entity.AdvertisementStatus; +import lombok.Builder; + +@Builder +public record AdminAdvertisementResponse( + Long advertisementId, + String title, + AdvertisementStatus status, + AdvertisementPlacement placement, + String imageUrl, + AdvertisementLinkType linkType, + String linkValue, + LocalDateTime startAt, + LocalDateTime endAt, + int priority, + long impressionCount, + long clickCount, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { + + public static AdminAdvertisementResponse of( + Advertisement advertisement, + long impressionCount, + long clickCount + ) { + return AdminAdvertisementResponse.builder() + .advertisementId(advertisement.getId()) + .title(advertisement.getTitle()) + .status(advertisement.getStatus()) + .placement(advertisement.getPlacement()) + .imageUrl(advertisement.getImageUrl()) + .linkType(advertisement.getLinkType()) + .linkValue(advertisement.getLinkValue()) + .startAt(advertisement.getStartAt()) + .endAt(advertisement.getEndAt()) + .priority(advertisement.getPriority()) + .impressionCount(impressionCount) + .clickCount(clickCount) + .createdAt(advertisement.getCreatedAt()) + .updatedAt(advertisement.getUpdatedAt()) + .build(); + } +} diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/ad/application/dto/response/AdminAdvertisementSummaryResponse.java b/module-domain/src/main/java/co/kr/pinhouse/domain/ad/application/dto/response/AdminAdvertisementSummaryResponse.java new file mode 100644 index 00000000..5d0c8e9a --- /dev/null +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/ad/application/dto/response/AdminAdvertisementSummaryResponse.java @@ -0,0 +1,34 @@ +package co.kr.pinhouse.domain.ad.application.dto.response; + +import java.time.LocalDateTime; + +import co.kr.pinhouse.domain.ad.domain.entity.Advertisement; +import co.kr.pinhouse.domain.ad.domain.entity.AdvertisementPlacement; +import co.kr.pinhouse.domain.ad.domain.entity.AdvertisementStatus; +import lombok.Builder; + +@Builder +public record AdminAdvertisementSummaryResponse( + Long advertisementId, + String title, + AdvertisementStatus status, + AdvertisementPlacement placement, + LocalDateTime startAt, + LocalDateTime endAt, + int priority, + LocalDateTime createdAt +) { + + public static AdminAdvertisementSummaryResponse from(Advertisement advertisement) { + return AdminAdvertisementSummaryResponse.builder() + .advertisementId(advertisement.getId()) + .title(advertisement.getTitle()) + .status(advertisement.getStatus()) + .placement(advertisement.getPlacement()) + .startAt(advertisement.getStartAt()) + .endAt(advertisement.getEndAt()) + .priority(advertisement.getPriority()) + .createdAt(advertisement.getCreatedAt()) + .build(); + } +} diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/admin/application/dto/response/AdminMeResponse.java b/module-domain/src/main/java/co/kr/pinhouse/domain/admin/application/dto/response/AdminMeResponse.java new file mode 100644 index 00000000..ad8a4f17 --- /dev/null +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/admin/application/dto/response/AdminMeResponse.java @@ -0,0 +1,27 @@ +package co.kr.pinhouse.domain.admin.application.dto.response; + +import java.util.List; +import java.util.UUID; + +import co.kr.pinhouse.domain.user.domain.entity.User; +import lombok.Builder; + +@Builder +public record AdminMeResponse( + UUID adminId, + String name, + String email, + String role, + List permissions +) { + + public static AdminMeResponse from(User user) { + return AdminMeResponse.builder() + .adminId(user.getId()) + .name(user.getName()) + .email(user.getEmail()) + .role(user.getRole().name()) + .permissions(List.of("NOTICE_MANAGE", "USER_READ", "CS_HANDLE", "AD_MANAGE", "AUDIT_READ")) + .build(); + } +} diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/admin/application/service/AdminSessionService.java b/module-domain/src/main/java/co/kr/pinhouse/domain/admin/application/service/AdminSessionService.java new file mode 100644 index 00000000..cba3beb3 --- /dev/null +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/admin/application/service/AdminSessionService.java @@ -0,0 +1,51 @@ +package co.kr.pinhouse.domain.admin.application.service; + +import java.util.UUID; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import co.kr.pinhouse.common.exception.code.SecurityErrorCode; +import co.kr.pinhouse.common.response.CustomException; +import co.kr.pinhouse.domain.admin.application.dto.response.AdminMeResponse; +import co.kr.pinhouse.domain.admin.application.usecase.AdminSessionUseCase; +import co.kr.pinhouse.domain.user.domain.entity.Role; +import co.kr.pinhouse.domain.user.domain.entity.User; +import co.kr.pinhouse.domain.user.domain.repository.UserJpaRepository; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class AdminSessionService implements AdminSessionUseCase { + + private final UserJpaRepository userRepository; + + // ================= + // 퍼블릭 로직 + // ================= + + /// 현재 관리자 세션 정보 조회 + @Transactional(readOnly = true) + @Override + public AdminMeResponse getAdminMe(UUID userId) { + return AdminMeResponse.from(loadAdmin(userId)); + } + + // ================= + // 외부 로직 + // ================= + + /// 관리자 권한 사용자 조회 + @Transactional(readOnly = true) + @Override + public User loadAdmin(UUID userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(SecurityErrorCode.NOT_FOUND_ID)); + + if (user.getRole() != Role.ADMIN) { + throw new CustomException(SecurityErrorCode.FORBIDDEN); + } + + return user; + } +} diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/admin/application/usecase/AdminSessionUseCase.java b/module-domain/src/main/java/co/kr/pinhouse/domain/admin/application/usecase/AdminSessionUseCase.java new file mode 100644 index 00000000..1c5167be --- /dev/null +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/admin/application/usecase/AdminSessionUseCase.java @@ -0,0 +1,23 @@ +package co.kr.pinhouse.domain.admin.application.usecase; + +import java.util.UUID; + +import co.kr.pinhouse.domain.admin.application.dto.response.AdminMeResponse; +import co.kr.pinhouse.domain.user.domain.entity.User; + +public interface AdminSessionUseCase { + + // ================= + // 퍼블릭 로직 + // ================= + + /// 현재 관리자 세션 정보 조회 + AdminMeResponse getAdminMe(UUID userId); + + // ================= + // 외부 로직 + // ================= + + /// 관리자 권한 사용자 조회 + User loadAdmin(UUID userId); +} diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/admin/audit/application/dto/response/AdminAuditLogResponse.java b/module-domain/src/main/java/co/kr/pinhouse/domain/admin/audit/application/dto/response/AdminAuditLogResponse.java new file mode 100644 index 00000000..d5811ae1 --- /dev/null +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/admin/audit/application/dto/response/AdminAuditLogResponse.java @@ -0,0 +1,41 @@ +package co.kr.pinhouse.domain.admin.audit.application.dto.response; + +import java.time.LocalDateTime; +import java.util.UUID; + +import co.kr.pinhouse.domain.admin.audit.domain.entity.AdminAuditActionType; +import co.kr.pinhouse.domain.admin.audit.domain.entity.AdminAuditLog; +import co.kr.pinhouse.domain.admin.audit.domain.entity.AdminAuditTargetType; +import lombok.Builder; + +@Builder +public record AdminAuditLogResponse( + Long id, + UUID adminId, + AdminAuditActionType actionType, + AdminAuditTargetType targetType, + String targetId, + String summary, + String beforeJson, + String afterJson, + String ipAddress, + String userAgent, + LocalDateTime createdAt +) { + + public static AdminAuditLogResponse from(AdminAuditLog log) { + return AdminAuditLogResponse.builder() + .id(log.getId()) + .adminId(log.getAdminId()) + .actionType(log.getActionType()) + .targetType(log.getTargetType()) + .targetId(log.getTargetId()) + .summary(log.getSummary()) + .beforeJson(log.getBeforeJson()) + .afterJson(log.getAfterJson()) + .ipAddress(log.getIpAddress()) + .userAgent(log.getUserAgent()) + .createdAt(log.getCreatedAt()) + .build(); + } +} diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/admin/audit/application/service/AdminAuditLogService.java b/module-domain/src/main/java/co/kr/pinhouse/domain/admin/audit/application/service/AdminAuditLogService.java new file mode 100644 index 00000000..1de79fec --- /dev/null +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/admin/audit/application/service/AdminAuditLogService.java @@ -0,0 +1,131 @@ +package co.kr.pinhouse.domain.admin.audit.application.service; + +import static co.kr.pinhouse.common.util.LogSanitizer.sanitize; + +import java.util.UUID; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import co.kr.pinhouse.common.response.pageable.SliceRequest; +import co.kr.pinhouse.common.response.pageable.SliceResponse; +import co.kr.pinhouse.domain.admin.audit.application.dto.response.AdminAuditLogResponse; +import co.kr.pinhouse.domain.admin.audit.application.usecase.AdminAuditLogUseCase; +import co.kr.pinhouse.domain.admin.audit.domain.entity.AdminAuditActionType; +import co.kr.pinhouse.domain.admin.audit.domain.entity.AdminAuditLog; +import co.kr.pinhouse.domain.admin.audit.domain.entity.AdminAuditTargetType; +import co.kr.pinhouse.domain.admin.audit.domain.repository.AdminAuditLogRepository; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AdminAuditLogService implements AdminAuditLogUseCase { + + private final AdminAuditLogRepository repository; + private final ObjectMapper objectMapper; + + // ================= + // 외부 로직 + // ================= + + /// 관리자 감사로그 저장 + @Transactional + @Override + public void log( + UUID adminId, + AdminAuditActionType actionType, + AdminAuditTargetType targetType, + String targetId, + String summary, + Object beforeState, + Object afterState, + HttpServletRequest request + ) { + repository.save(AdminAuditLog.of( + adminId, + actionType, + targetType, + targetId, + summary, + toJson(beforeState), + toJson(afterState), + extractClientIp(request), + request != null ? request.getHeader("User-Agent") : null + )); + } + + // ================= + // 퍼블릭 로직 + // ================= + + /// 전체 감사로그 조회 + @Transactional(readOnly = true) + @Override + public SliceResponse getLogs(SliceRequest sliceRequest) { + var pageable = PageRequest.of(sliceRequest.page() - 1, sliceRequest.offSet(), + Sort.by(Sort.Order.desc("createdAt"), Sort.Order.desc("id"))); + var page = repository.findAllByOrderByCreatedAtDesc(pageable); + + return SliceResponse.from(page.map(AdminAuditLogResponse::from), page.getTotalElements()); + } + + /// 대상별 감사로그 조회 + @Transactional(readOnly = true) + @Override + public SliceResponse getLogsByTarget( + AdminAuditTargetType targetType, + String targetId, + SliceRequest sliceRequest + ) { + var pageable = PageRequest.of(sliceRequest.page() - 1, sliceRequest.offSet(), + Sort.by(Sort.Order.desc("createdAt"), Sort.Order.desc("id"))); + var page = repository.findByTargetTypeAndTargetIdOrderByCreatedAtDesc(targetType, targetId, pageable); + + return SliceResponse.from(page.map(AdminAuditLogResponse::from), page.getTotalElements()); + } + + // ================= + // 내부 로직 + // ================= + + /// 감사로그 상태 JSON 직렬화 + private String toJson(Object value) { + if (value == null) { + return null; + } + + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException e) { + log.warn("감사 로그 직렬화 실패 - type={}", sanitize(value.getClass().getName()), e); + return null; + } + } + + /// 요청 IP 추출 + private String extractClientIp(HttpServletRequest request) { + if (request == null) { + return null; + } + + String forwardedFor = request.getHeader("X-Forwarded-For"); + if (forwardedFor != null && !forwardedFor.isBlank() && !"unknown".equalsIgnoreCase(forwardedFor)) { + return forwardedFor.split(",")[0].trim(); + } + + String realIp = request.getHeader("X-Real-IP"); + if (realIp != null && !realIp.isBlank() && !"unknown".equalsIgnoreCase(realIp)) { + return realIp; + } + + return request.getRemoteAddr(); + } +} diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/admin/audit/application/usecase/AdminAuditLogUseCase.java b/module-domain/src/main/java/co/kr/pinhouse/domain/admin/audit/application/usecase/AdminAuditLogUseCase.java new file mode 100644 index 00000000..573d3238 --- /dev/null +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/admin/audit/application/usecase/AdminAuditLogUseCase.java @@ -0,0 +1,43 @@ +package co.kr.pinhouse.domain.admin.audit.application.usecase; + +import java.util.UUID; + +import co.kr.pinhouse.common.response.pageable.SliceRequest; +import co.kr.pinhouse.common.response.pageable.SliceResponse; +import co.kr.pinhouse.domain.admin.audit.application.dto.response.AdminAuditLogResponse; +import co.kr.pinhouse.domain.admin.audit.domain.entity.AdminAuditActionType; +import co.kr.pinhouse.domain.admin.audit.domain.entity.AdminAuditTargetType; +import jakarta.servlet.http.HttpServletRequest; + +public interface AdminAuditLogUseCase { + + // ================= + // 외부 로직 + // ================= + + /// 관리자 감사로그 저장 + void log( + UUID adminId, + AdminAuditActionType actionType, + AdminAuditTargetType targetType, + String targetId, + String summary, + Object beforeState, + Object afterState, + HttpServletRequest request + ); + + // ================= + // 퍼블릭 로직 + // ================= + + /// 전체 감사로그 조회 + SliceResponse getLogs(SliceRequest sliceRequest); + + /// 대상별 감사로그 조회 + SliceResponse getLogsByTarget( + AdminAuditTargetType targetType, + String targetId, + SliceRequest sliceRequest + ); +} diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/admin/audit/domain/entity/AdminAuditActionType.java b/module-domain/src/main/java/co/kr/pinhouse/domain/admin/audit/domain/entity/AdminAuditActionType.java new file mode 100644 index 00000000..34d2f547 --- /dev/null +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/admin/audit/domain/entity/AdminAuditActionType.java @@ -0,0 +1,9 @@ +package co.kr.pinhouse.domain.admin.audit.domain.entity; + +public enum AdminAuditActionType { + CREATE, + UPDATE, + STATUS_CHANGE, + REPLY, + ASSIGN +} diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/admin/audit/domain/entity/AdminAuditLog.java b/module-domain/src/main/java/co/kr/pinhouse/domain/admin/audit/domain/entity/AdminAuditLog.java new file mode 100644 index 00000000..ab13c45e --- /dev/null +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/admin/audit/domain/entity/AdminAuditLog.java @@ -0,0 +1,104 @@ +package co.kr.pinhouse.domain.admin.audit.domain.entity; + +import java.util.UUID; + +import co.kr.pinhouse.domain.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "admin_audit_logs") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class AdminAuditLog extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "admin_id", nullable = false, columnDefinition = "BINARY(16)") + private UUID adminId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 50) + private AdminAuditActionType actionType; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 50) + private AdminAuditTargetType targetType; + + @Column(nullable = false) + private String targetId; + + @Column(nullable = false) + private String summary; + + @Column(name = "before_json", columnDefinition = "TEXT") + private String beforeJson; + + @Column(name = "after_json", columnDefinition = "TEXT") + private String afterJson; + + @Column(name = "ip_address", length = 100) + private String ipAddress; + + @Column(name = "user_agent", columnDefinition = "TEXT") + private String userAgent; + + @Builder + protected AdminAuditLog( + UUID adminId, + AdminAuditActionType actionType, + AdminAuditTargetType targetType, + String targetId, + String summary, + String beforeJson, + String afterJson, + String ipAddress, + String userAgent + ) { + this.adminId = adminId; + this.actionType = actionType; + this.targetType = targetType; + this.targetId = targetId; + this.summary = summary; + this.beforeJson = beforeJson; + this.afterJson = afterJson; + this.ipAddress = ipAddress; + this.userAgent = userAgent; + } + + public static AdminAuditLog of( + UUID adminId, + AdminAuditActionType actionType, + AdminAuditTargetType targetType, + String targetId, + String summary, + String beforeJson, + String afterJson, + String ipAddress, + String userAgent + ) { + return AdminAuditLog.builder() + .adminId(adminId) + .actionType(actionType) + .targetType(targetType) + .targetId(targetId) + .summary(summary) + .beforeJson(beforeJson) + .afterJson(afterJson) + .ipAddress(ipAddress) + .userAgent(userAgent) + .build(); + } +} diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/admin/audit/domain/entity/AdminAuditTargetType.java b/module-domain/src/main/java/co/kr/pinhouse/domain/admin/audit/domain/entity/AdminAuditTargetType.java new file mode 100644 index 00000000..d15b3986 --- /dev/null +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/admin/audit/domain/entity/AdminAuditTargetType.java @@ -0,0 +1,7 @@ +package co.kr.pinhouse.domain.admin.audit.domain.entity; + +public enum AdminAuditTargetType { + NOTICE, + CS_INQUIRY, + ADVERTISEMENT +} diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/admin/audit/domain/repository/AdminAuditLogRepository.java b/module-domain/src/main/java/co/kr/pinhouse/domain/admin/audit/domain/repository/AdminAuditLogRepository.java new file mode 100644 index 00000000..fd1b1f80 --- /dev/null +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/admin/audit/domain/repository/AdminAuditLogRepository.java @@ -0,0 +1,19 @@ +package co.kr.pinhouse.domain.admin.audit.domain.repository; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import co.kr.pinhouse.domain.admin.audit.domain.entity.AdminAuditLog; +import co.kr.pinhouse.domain.admin.audit.domain.entity.AdminAuditTargetType; + +public interface AdminAuditLogRepository extends JpaRepository { + + Page findAllByOrderByCreatedAtDesc(Pageable pageable); + + Page findByTargetTypeAndTargetIdOrderByCreatedAtDesc( + AdminAuditTargetType targetType, + String targetId, + Pageable pageable + ); +} diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/admin/notice/application/dto/response/AdminNoticeResponse.java b/module-domain/src/main/java/co/kr/pinhouse/domain/admin/notice/application/dto/response/AdminNoticeResponse.java new file mode 100644 index 00000000..449d9246 --- /dev/null +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/admin/notice/application/dto/response/AdminNoticeResponse.java @@ -0,0 +1,60 @@ +package co.kr.pinhouse.domain.admin.notice.application.dto.response; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +import co.kr.pinhouse.domain.admin.notice.domain.entity.NoticeAdminOverride; +import co.kr.pinhouse.domain.housing.notice.domain.entity.NoticeDocument; +import lombok.Builder; + +@Builder +public record AdminNoticeResponse( + String noticeId, + String originalTitle, + String displayTitle, + String originalStatus, + String displayStatus, + String agency, + String originalThumbnail, + String displayThumbnail, + String originalContact, + String displayContact, + String city, + String county, + LocalDate announceDate, + LocalDate applyStart, + LocalDate applyEnd, + boolean hidden, + String adminMemo, + boolean hasOverride, + LocalDateTime overrideUpdatedAt +) { + + public static AdminNoticeResponse from(NoticeDocument notice, NoticeAdminOverride override) { + return AdminNoticeResponse.builder() + .noticeId(notice.getId()) + .originalTitle(notice.getTitle()) + .displayTitle(resolve(override != null ? override.getDisplayTitle() : null, notice.getTitle())) + .originalStatus(notice.getStatus()) + .displayStatus(resolve(override != null ? override.getDisplayStatus() : null, notice.getStatus())) + .agency(notice.getAgency()) + .originalThumbnail(notice.getThumbnail()) + .displayThumbnail(resolve(override != null ? override.getDisplayThumbnail() : null, notice.getThumbnail())) + .originalContact(notice.getContact()) + .displayContact(resolve(override != null ? override.getDisplayContact() : null, notice.getContact())) + .city(notice.getCity()) + .county(notice.getCounty()) + .announceDate(notice.getAnnounceDate()) + .applyStart(notice.getApplyStart()) + .applyEnd(notice.getApplyEnd()) + .hidden(override != null && override.isHidden()) + .adminMemo(override != null ? override.getAdminMemo() : null) + .hasOverride(override != null) + .overrideUpdatedAt(override != null ? override.getUpdatedAt() : null) + .build(); + } + + private static String resolve(String overrideValue, String originalValue) { + return overrideValue != null ? overrideValue : originalValue; + } +} diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/admin/notice/application/dto/response/AdminNoticeSummaryResponse.java b/module-domain/src/main/java/co/kr/pinhouse/domain/admin/notice/application/dto/response/AdminNoticeSummaryResponse.java new file mode 100644 index 00000000..1402ea5b --- /dev/null +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/admin/notice/application/dto/response/AdminNoticeSummaryResponse.java @@ -0,0 +1,39 @@ +package co.kr.pinhouse.domain.admin.notice.application.dto.response; + +import java.time.LocalDate; + +import co.kr.pinhouse.domain.admin.notice.domain.entity.NoticeAdminOverride; +import co.kr.pinhouse.domain.housing.notice.domain.entity.NoticeDocument; +import lombok.Builder; + +@Builder +public record AdminNoticeSummaryResponse( + String noticeId, + String title, + String status, + String agency, + LocalDate announceDate, + LocalDate applyStart, + LocalDate applyEnd, + boolean hidden, + boolean hasOverride +) { + + public static AdminNoticeSummaryResponse from(NoticeDocument notice, NoticeAdminOverride override) { + return AdminNoticeSummaryResponse.builder() + .noticeId(notice.getId()) + .title(resolve(override != null ? override.getDisplayTitle() : null, notice.getTitle())) + .status(resolve(override != null ? override.getDisplayStatus() : null, notice.getStatus())) + .agency(notice.getAgency()) + .announceDate(notice.getAnnounceDate()) + .applyStart(notice.getApplyStart()) + .applyEnd(notice.getApplyEnd()) + .hidden(override != null && override.isHidden()) + .hasOverride(override != null) + .build(); + } + + private static String resolve(String overrideValue, String originalValue) { + return overrideValue != null ? overrideValue : originalValue; + } +} diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/admin/notice/application/service/AdminNoticeService.java b/module-domain/src/main/java/co/kr/pinhouse/domain/admin/notice/application/service/AdminNoticeService.java new file mode 100644 index 00000000..2fc16425 --- /dev/null +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/admin/notice/application/service/AdminNoticeService.java @@ -0,0 +1,126 @@ +package co.kr.pinhouse.domain.admin.notice.application.service; + +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.Map; +import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import co.kr.pinhouse.common.exception.code.NoticeErrorCode; +import co.kr.pinhouse.common.response.CustomException; +import co.kr.pinhouse.common.response.pageable.SliceRequest; +import co.kr.pinhouse.common.response.pageable.SliceResponse; +import co.kr.pinhouse.domain.admin.audit.application.usecase.AdminAuditLogUseCase; +import co.kr.pinhouse.domain.admin.audit.domain.entity.AdminAuditActionType; +import co.kr.pinhouse.domain.admin.audit.domain.entity.AdminAuditTargetType; +import co.kr.pinhouse.domain.admin.notice.application.dto.request.UpdateAdminNoticeRequest; +import co.kr.pinhouse.domain.admin.notice.application.dto.response.AdminNoticeResponse; +import co.kr.pinhouse.domain.admin.notice.application.dto.response.AdminNoticeSummaryResponse; +import co.kr.pinhouse.domain.admin.notice.application.usecase.AdminNoticeUseCase; +import co.kr.pinhouse.domain.admin.notice.domain.entity.NoticeAdminOverride; +import co.kr.pinhouse.domain.admin.notice.domain.repository.NoticeAdminOverrideRepository; +import co.kr.pinhouse.domain.housing.notice.domain.entity.NoticeDocument; +import co.kr.pinhouse.domain.housing.notice.domain.repository.NoticeDocumentRepository; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class AdminNoticeService implements AdminNoticeUseCase { + + private final NoticeDocumentRepository noticeRepository; + private final NoticeAdminOverrideRepository overrideRepository; + private final AdminAuditLogUseCase adminAuditLogService; + + // ================= + // 퍼블릭 로직 + // ================= + + /// 관리자 공고 목록 조회 + @Transactional(readOnly = true) + @Override + public SliceResponse getNotices(String keyword, SliceRequest sliceRequest) { + var pageable = PageRequest.of(sliceRequest.page() - 1, sliceRequest.offSet(), + Sort.by(Sort.Order.desc("announceDate"), Sort.Order.desc("noticeId"))); + Page page = loadNoticePage(keyword, pageable); + + Map overrideMap = overrideRepository.findByNoticeIdIn( + page.getContent().stream().map(NoticeDocument::getId).toList()) + .stream() + .collect(Collectors.toMap(NoticeAdminOverride::getNoticeId, Function.identity())); + + return SliceResponse.from(page.map(notice -> + AdminNoticeSummaryResponse.from(notice, overrideMap.get(notice.getId()))), page.getTotalElements()); + } + + /// 관리자 공고 상세 조회 + @Transactional(readOnly = true) + @Override + public AdminNoticeResponse getNotice(String noticeId) { + NoticeDocument notice = loadNotice(noticeId); + NoticeAdminOverride override = overrideRepository.findByNoticeId(noticeId).orElse(null); + + return AdminNoticeResponse.from(notice, override); + } + + /// 관리자 공고 운영 정보 수정 + @Transactional + @Override + public AdminNoticeResponse updateNotice( + String noticeId, + UpdateAdminNoticeRequest request, + UUID adminId, + HttpServletRequest httpServletRequest + ) { + NoticeDocument notice = loadNotice(noticeId); + NoticeAdminOverride override = overrideRepository.findByNoticeId(noticeId) + .orElseGet(() -> NoticeAdminOverride.create(noticeId)); + AdminNoticeResponse before = AdminNoticeResponse.from(notice, override.getId() == null ? null : override); + + override.apply(request); + NoticeAdminOverride savedOverride = overrideRepository.save(override); + + AdminNoticeResponse after = AdminNoticeResponse.from(notice, savedOverride); + + adminAuditLogService.log( + adminId, + AdminAuditActionType.UPDATE, + AdminAuditTargetType.NOTICE, + noticeId, + "공고 운영 정보 수정", + before, + after, + httpServletRequest + ); + + return after; + } + + // ================= + // 내부 로직 + // ================= + + /// 검색 조건에 맞는 공고 페이지 조회 + private Page loadNoticePage(String keyword, PageRequest pageable) { + if (keyword == null || keyword.isBlank()) { + return noticeRepository.findAll(pageable); + } + + Instant now = ZonedDateTime.now(ZoneId.of("Asia/Seoul")).toInstant(); + return noticeRepository.searchByTitle(keyword, pageable, false, now); + } + + /// 공고 단건 조회 + private NoticeDocument loadNotice(String noticeId) { + return noticeRepository.findById(noticeId) + .orElseThrow(() -> new CustomException(NoticeErrorCode.NOT_FOUND_NOTICE)); + } +} diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/admin/notice/application/usecase/AdminNoticeUseCase.java b/module-domain/src/main/java/co/kr/pinhouse/domain/admin/notice/application/usecase/AdminNoticeUseCase.java new file mode 100644 index 00000000..324f3046 --- /dev/null +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/admin/notice/application/usecase/AdminNoticeUseCase.java @@ -0,0 +1,31 @@ +package co.kr.pinhouse.domain.admin.notice.application.usecase; + +import java.util.UUID; + +import co.kr.pinhouse.common.response.pageable.SliceRequest; +import co.kr.pinhouse.common.response.pageable.SliceResponse; +import co.kr.pinhouse.domain.admin.notice.application.dto.request.UpdateAdminNoticeRequest; +import co.kr.pinhouse.domain.admin.notice.application.dto.response.AdminNoticeResponse; +import co.kr.pinhouse.domain.admin.notice.application.dto.response.AdminNoticeSummaryResponse; +import jakarta.servlet.http.HttpServletRequest; + +public interface AdminNoticeUseCase { + + // ================= + // 퍼블릭 로직 + // ================= + + /// 관리자 공고 목록 조회 + SliceResponse getNotices(String keyword, SliceRequest sliceRequest); + + /// 관리자 공고 상세 조회 + AdminNoticeResponse getNotice(String noticeId); + + /// 관리자 공고 운영 정보 수정 + AdminNoticeResponse updateNotice( + String noticeId, + UpdateAdminNoticeRequest request, + UUID adminId, + HttpServletRequest httpServletRequest + ); +} diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/admin/user/application/dto/response/AdminUserDetailResponse.java b/module-domain/src/main/java/co/kr/pinhouse/domain/admin/user/application/dto/response/AdminUserDetailResponse.java new file mode 100644 index 00000000..5469d9a7 --- /dev/null +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/admin/user/application/dto/response/AdminUserDetailResponse.java @@ -0,0 +1,53 @@ +package co.kr.pinhouse.domain.admin.user.application.dto.response; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +import co.kr.pinhouse.domain.housing.facility.domain.entity.FacilityType; +import co.kr.pinhouse.domain.user.domain.entity.User; +import lombok.Builder; + +@Builder +public record AdminUserDetailResponse( + UUID userId, + String maskedName, + String nickname, + String maskedEmail, + String maskedPhoneNumber, + String provider, + String role, + String profileImage, + LocalDateTime createdAt, + List facilityTypes, + long likeCount, + long pinPointCount, + long diagnosisCount +) { + + public static AdminUserDetailResponse of( + User user, + String maskedName, + String maskedEmail, + String maskedPhoneNumber, + long likeCount, + long pinPointCount, + long diagnosisCount + ) { + return AdminUserDetailResponse.builder() + .userId(user.getId()) + .maskedName(maskedName) + .nickname(user.getNickname()) + .maskedEmail(maskedEmail) + .maskedPhoneNumber(maskedPhoneNumber) + .provider(user.getProvider().name()) + .role(user.getRole().name()) + .profileImage(user.getProfileImage()) + .createdAt(user.getCreatedAt()) + .facilityTypes(user.getFacilityTypes()) + .likeCount(likeCount) + .pinPointCount(pinPointCount) + .diagnosisCount(diagnosisCount) + .build(); + } +} diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/admin/user/application/dto/response/AdminUserSummaryResponse.java b/module-domain/src/main/java/co/kr/pinhouse/domain/admin/user/application/dto/response/AdminUserSummaryResponse.java new file mode 100644 index 00000000..270695c5 --- /dev/null +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/admin/user/application/dto/response/AdminUserSummaryResponse.java @@ -0,0 +1,38 @@ +package co.kr.pinhouse.domain.admin.user.application.dto.response; + +import java.time.LocalDateTime; +import java.util.UUID; + +import co.kr.pinhouse.domain.user.domain.entity.User; +import lombok.Builder; + +@Builder +public record AdminUserSummaryResponse( + UUID userId, + String maskedName, + String nickname, + String maskedEmail, + String maskedPhoneNumber, + String provider, + String role, + LocalDateTime createdAt +) { + + public static AdminUserSummaryResponse of( + User user, + String maskedName, + String maskedEmail, + String maskedPhoneNumber + ) { + return AdminUserSummaryResponse.builder() + .userId(user.getId()) + .maskedName(maskedName) + .nickname(user.getNickname()) + .maskedEmail(maskedEmail) + .maskedPhoneNumber(maskedPhoneNumber) + .provider(user.getProvider().name()) + .role(user.getRole().name()) + .createdAt(user.getCreatedAt()) + .build(); + } +} diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/admin/user/application/service/AdminUserService.java b/module-domain/src/main/java/co/kr/pinhouse/domain/admin/user/application/service/AdminUserService.java new file mode 100644 index 00000000..a0df83aa --- /dev/null +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/admin/user/application/service/AdminUserService.java @@ -0,0 +1,117 @@ +package co.kr.pinhouse.domain.admin.user.application.service; + +import java.util.UUID; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import co.kr.pinhouse.common.exception.code.UserErrorCode; +import co.kr.pinhouse.common.response.CustomException; +import co.kr.pinhouse.common.response.pageable.SliceRequest; +import co.kr.pinhouse.common.response.pageable.SliceResponse; +import co.kr.pinhouse.domain.admin.user.application.dto.response.AdminUserDetailResponse; +import co.kr.pinhouse.domain.admin.user.application.dto.response.AdminUserSummaryResponse; +import co.kr.pinhouse.domain.admin.user.application.usecase.AdminUserUseCase; +import co.kr.pinhouse.domain.diagnostic.diagnosis.domain.repository.DiagnosisJpaRepository; +import co.kr.pinhouse.domain.like.domain.LikeJpaRepository; +import co.kr.pinhouse.domain.pinpoint.domain.repository.PinPointMongoRepository; +import co.kr.pinhouse.domain.user.domain.entity.User; +import co.kr.pinhouse.domain.user.domain.repository.UserJpaRepository; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class AdminUserService implements AdminUserUseCase { + + private final UserJpaRepository userRepository; + private final LikeJpaRepository likeRepository; + private final PinPointMongoRepository pinPointRepository; + private final DiagnosisJpaRepository diagnosisRepository; + + // ================= + // 퍼블릭 로직 + // ================= + + /// 관리자 유저 목록 조회 + @Transactional(readOnly = true) + @Override + public SliceResponse getUsers(String keyword, SliceRequest sliceRequest) { + var pageable = PageRequest.of(sliceRequest.page() - 1, sliceRequest.offSet(), + Sort.by(Sort.Order.desc("createdAt"), Sort.Order.desc("id"))); + var page = (keyword == null || keyword.isBlank()) + ? userRepository.findAll(pageable) + : userRepository.findByNameContainingIgnoreCaseOrNicknameContainingIgnoreCaseOrEmailContainingIgnoreCase( + keyword, keyword, keyword, pageable); + + return SliceResponse.from(page.map(user -> AdminUserSummaryResponse.of( + user, + maskName(user.getName()), + maskEmail(user.getEmail()), + maskPhone(user.getPhoneNumber()) + )), page.getTotalElements()); + } + + /// 관리자 유저 상세 조회 + @Transactional(readOnly = true) + @Override + public AdminUserDetailResponse getUser(UUID userId) { + User user = userRepository.findWithFacilityTypesById(userId) + .orElseThrow(() -> new CustomException(UserErrorCode.NOT_FOUND_USER)); + + return AdminUserDetailResponse.of( + user, + maskName(user.getName()), + maskEmail(user.getEmail()), + maskPhone(user.getPhoneNumber()), + likeRepository.countByUser_Id(userId), + pinPointRepository.countByUserId(userId.toString()), + diagnosisRepository.countByUser_Id(userId) + ); + } + + // ================= + // 내부 로직 + // ================= + + /// 이름 마스킹 + private String maskName(String name) { + if (name == null || name.isBlank()) { + return null; + } + if (name.length() == 1) { + return "*"; + } + if (name.length() == 2) { + return name.charAt(0) + "*"; + } + return name.charAt(0) + "*" + name.charAt(name.length() - 1); + } + + /// 이메일 마스킹 + private String maskEmail(String email) { + if (email == null || email.isBlank() || !email.contains("@")) { + return null; + } + String[] parts = email.split("@", 2); + String localPart = parts[0]; + String domainPart = parts[1]; + + if (localPart.length() <= 2) { + return localPart.charAt(0) + "***@" + domainPart; + } + return localPart.substring(0, 2) + "***@" + domainPart; + } + + /// 전화번호 마스킹 + private String maskPhone(String phoneNumber) { + if (phoneNumber == null || phoneNumber.isBlank()) { + return null; + } + if (phoneNumber.length() < 8) { + return "****"; + } + return phoneNumber.substring(0, 3) + "-****-" + phoneNumber.substring(phoneNumber.length() - 4); + } +} diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/admin/user/application/usecase/AdminUserUseCase.java b/module-domain/src/main/java/co/kr/pinhouse/domain/admin/user/application/usecase/AdminUserUseCase.java new file mode 100644 index 00000000..c0a6a2f4 --- /dev/null +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/admin/user/application/usecase/AdminUserUseCase.java @@ -0,0 +1,21 @@ +package co.kr.pinhouse.domain.admin.user.application.usecase; + +import java.util.UUID; + +import co.kr.pinhouse.common.response.pageable.SliceRequest; +import co.kr.pinhouse.common.response.pageable.SliceResponse; +import co.kr.pinhouse.domain.admin.user.application.dto.response.AdminUserDetailResponse; +import co.kr.pinhouse.domain.admin.user.application.dto.response.AdminUserSummaryResponse; + +public interface AdminUserUseCase { + + // ================= + // 퍼블릭 로직 + // ================= + + /// 관리자 유저 목록 조회 + SliceResponse getUsers(String keyword, SliceRequest sliceRequest); + + /// 관리자 유저 상세 조회 + AdminUserDetailResponse getUser(UUID userId); +} diff --git a/module-infrastructure/src/main/java/co/kr/pinhouse/common/util/RedirectUrlResolver.java b/module-infrastructure/src/main/java/co/kr/pinhouse/common/util/RedirectUrlResolver.java index 94ac852c..d1ff4057 100644 --- a/module-infrastructure/src/main/java/co/kr/pinhouse/common/util/RedirectUrlResolver.java +++ b/module-infrastructure/src/main/java/co/kr/pinhouse/common/util/RedirectUrlResolver.java @@ -1,5 +1,7 @@ package co.kr.pinhouse.common.util; +import static co.kr.pinhouse.common.util.LogSanitizer.sanitize; + import java.net.URI; import java.net.URISyntaxException; import java.util.HashSet; @@ -18,6 +20,7 @@ public class RedirectUrlResolver { public static final String REDIRECT_ORIGIN_SESSION_ATTRIBUTE = "PINHOUSE_REDIRECT_ORIGIN"; + public static final String REDIRECT_PATH_SESSION_ATTRIBUTE = "PINHOUSE_REDIRECT_PATH"; @Value("${cors.front.local}") private String frontLocal; @@ -28,23 +31,43 @@ public class RedirectUrlResolver { @Value("${cors.front.prod}") private String frontProd; + @Value("${cors.front.admin-local:}") + private String adminFrontLocal; + + @Value("${cors.front.admin-dev:}") + private String adminFrontDev; + + @Value("${cors.front.admin-prod:}") + private String adminFrontProd; + @Value("${cors.front.redirect}") private String defaultRedirectUrl; + @Value("${cors.front.redirect-path:/api/auth/callback}") + private String defaultRedirectPath; + + @Value("${cors.front.admin-redirect-path:/admin/auth/callback}") + private String defaultAdminRedirectPath; + @Value("${spring.profiles.active:local}") private String activeProfile; private Set allowedOrigins; + private Set adminOrigins; @PostConstruct private void initAllowedOrigins() { allowedOrigins = new HashSet<>(); + adminOrigins = new HashSet<>(); addIfNotEmpty(allowedOrigins, frontLocal); addIfNotEmpty(allowedOrigins, frontDev); addIfNotEmpty(allowedOrigins, frontProd); + addAdminOriginIfNotEmpty(adminFrontLocal); + addAdminOriginIfNotEmpty(adminFrontDev); + addAdminOriginIfNotEmpty(adminFrontProd); log.info("RedirectUrlResolver 초기화 완료 - 활성 프로파일: {}, 허용된 Origin 개수: {}", - activeProfile, allowedOrigins.size()); + sanitize(activeProfile), sanitize(allowedOrigins.size())); } /** @@ -54,7 +77,7 @@ private void initAllowedOrigins() { * @return 결정된 리다이렉트 URL */ public String resolveRedirectUrl(HttpServletRequest request) { - return resolveRedirectUrlWithPath(request, ""); + return resolveRedirectUrlWithPath(request, null); } /** @@ -65,34 +88,28 @@ public String resolveRedirectUrl(HttpServletRequest request) { * @return 결정된 리다이렉트 URL + 경로 */ public String resolveRedirectUrlWithPath(HttpServletRequest request, String path) { - // prod 프로파일: 항상 고정 URL - if ("prod".equals(activeProfile)) { - String redirectUrl = frontProd + (path != null ? path : ""); - log.info("리다이렉트 URL 결정 (prod 고정) - URL: {}", redirectUrl); - return redirectUrl; - } + RedirectTarget target = consumeSavedRedirectTarget(request); + String origin = target.origin(); + String redirectPath = normalizeRedirectPath(path); - // OAuth2 인증 시작 시 저장해둔 프론트 Origin이 있으면 그것을 최우선으로 사용 - String origin = consumeSavedOrigin(request); - - if (isAllowedOrigin(origin)) { - String redirectUrl = origin + (path != null ? path : ""); - log.info("리다이렉트 URL 결정 (세션 저장 Origin) - Origin: {}, URL: {}", origin, redirectUrl); - return redirectUrl; + if (redirectPath == null) { + redirectPath = target.redirectPath(); + } + if (redirectPath == null) { + redirectPath = getDefaultRedirectPath(origin); } - - // dev/local 프로파일: 동적 결정 - origin = extractOriginFromRequest(request); if (isAllowedOrigin(origin)) { - String redirectUrl = origin + (path != null ? path : ""); - log.info("리다이렉트 URL 결정 (동적) - Origin: {}, URL: {}", origin, redirectUrl); + String redirectUrl = buildRedirectUrl(origin, redirectPath); + log.info("리다이렉트 URL 결정 - Origin: {}, path: {}, URL: {}", + sanitize(origin), sanitize(redirectPath), sanitize(redirectUrl)); return redirectUrl; } // 검증 실패 시 기본 URL 사용 - String redirectUrl = defaultRedirectUrl + (path != null ? path : ""); - log.warn("요청 Origin이 허용되지 않음: {}. 기본 URL 사용: {}", origin, redirectUrl); + String defaultPath = getDefaultRedirectPath(origin); + String redirectUrl = buildRedirectUrl(defaultRedirectUrl, redirectPath != null ? redirectPath : defaultPath); + log.warn("요청 Origin이 허용되지 않음: {}. 기본 URL 사용: {}", sanitize(origin), sanitize(redirectUrl)); return redirectUrl; } @@ -101,17 +118,20 @@ public String resolveRedirectUrlWithPath(HttpServletRequest request, String path * * @param request HTTP 요청 객체 */ - public void saveRedirectOrigin(HttpServletRequest request) { + public void saveRedirectContext(HttpServletRequest request) { String origin = extractOriginFromRequest(request); if (!isAllowedOrigin(origin)) { - log.debug("저장 가능한 OAuth2 Origin이 없습니다. Origin: {}", origin); + log.debug("저장 가능한 OAuth2 Origin이 없습니다. Origin: {}", sanitize(origin)); return; } + String redirectPath = extractRedirectPath(request, origin); + // OAuth 공급자 페이지를 거쳐 돌아와도 원래 프론트로 복귀할 수 있도록 세션에 보관 request.getSession(true).setAttribute(REDIRECT_ORIGIN_SESSION_ATTRIBUTE, origin); - log.info("OAuth2 리다이렉트 Origin 저장 - Origin: {}", origin); + request.getSession(true).setAttribute(REDIRECT_PATH_SESSION_ATTRIBUTE, redirectPath); + log.info("OAuth2 리다이렉트 정보 저장 - Origin: {}, path: {}", sanitize(origin), sanitize(redirectPath)); } /** @@ -143,21 +163,21 @@ private String extractOriginFromRequest(HttpServletRequest request) { * @param request HTTP 요청 객체 * @return 저장된 Origin (없으면 null) */ - private String consumeSavedOrigin(HttpServletRequest request) { + private RedirectTarget consumeSavedRedirectTarget(HttpServletRequest request) { HttpSession session = request.getSession(false); - if (session == null) { - return null; - } - - // 1회성 값으로 사용하고 바로 제거해 이전 로그인 시도의 값이 남지 않게 한다 - Object savedOrigin = session.getAttribute(REDIRECT_ORIGIN_SESSION_ATTRIBUTE); - session.removeAttribute(REDIRECT_ORIGIN_SESSION_ATTRIBUTE); - - if (!(savedOrigin instanceof String origin) || origin.isBlank()) { - return null; + if (session != null) { + Object savedOrigin = session.getAttribute(REDIRECT_ORIGIN_SESSION_ATTRIBUTE); + Object savedPath = session.getAttribute(REDIRECT_PATH_SESSION_ATTRIBUTE); + session.removeAttribute(REDIRECT_ORIGIN_SESSION_ATTRIBUTE); + session.removeAttribute(REDIRECT_PATH_SESSION_ATTRIBUTE); + + if (savedOrigin instanceof String origin && !origin.isBlank()) { + return new RedirectTarget(normalizeOrigin(origin), normalizeRedirectPath((String)savedPath)); + } } - return normalizeOrigin(origin); + String origin = extractOriginFromRequest(request); + return new RedirectTarget(origin, null); } /** @@ -179,7 +199,7 @@ private String extractOriginFromReferer(String referer) { // 프로토콜 검증 if (!"http".equals(scheme) && !"https".equals(scheme)) { - log.warn("허용되지 않은 프로토콜: {}", scheme); + log.warn("허용되지 않은 프로토콜: {}", sanitize(scheme)); return null; } @@ -190,7 +210,7 @@ private String extractOriginFromReferer(String referer) { return normalizeOrigin(origin); } catch (URISyntaxException e) { - log.warn("Referer 파싱 실패: {}", referer, e); + log.warn("Referer 파싱 실패: {}", sanitize(referer), e); return null; } } @@ -208,6 +228,13 @@ private boolean isAllowedOrigin(String origin) { return allowedOrigins.contains(origin); } + private boolean isAdminOrigin(String origin) { + if (origin == null || origin.isEmpty()) { + return false; + } + return adminOrigins.contains(origin); + } + /** * Origin을 정규화합니다 (trailing slash 제거, 소문자 변환). * @@ -218,6 +245,55 @@ private String normalizeOrigin(String origin) { return origin.replaceAll("/$", "").toLowerCase(); } + private String extractRedirectPath(HttpServletRequest request, String origin) { + String requestedPath = request.getParameter("redirectPath"); + String redirectPath = normalizeRedirectPath(requestedPath); + + if (redirectPath != null) { + return redirectPath; + } + + if (requestedPath != null && !requestedPath.isBlank()) { + log.warn("허용되지 않은 redirectPath: {}", sanitize(requestedPath)); + } + + return getDefaultRedirectPath(origin); + } + + private String normalizeRedirectPath(String path) { + if (path == null || path.isBlank()) { + return null; + } + + String normalizedPath = path.trim(); + if (!normalizedPath.startsWith("/") || normalizedPath.startsWith("//")) { + return null; + } + if (normalizedPath.indexOf('\r') >= 0 || normalizedPath.indexOf('\n') >= 0) { + return null; + } + + try { + URI uri = new URI(normalizedPath); + if (uri.isAbsolute() || uri.getHost() != null || uri.getAuthority() != null) { + return null; + } + return normalizedPath; + } catch (URISyntaxException exception) { + return null; + } + } + + private String getDefaultRedirectPath(String origin) { + String redirectPath = isAdminOrigin(origin) ? defaultAdminRedirectPath : defaultRedirectPath; + String normalizedPath = normalizeRedirectPath(redirectPath); + return normalizedPath != null ? normalizedPath : "/api/auth/callback"; + } + + private String buildRedirectUrl(String origin, String path) { + return normalizeOrigin(origin) + path; + } + /** * 값이 비어있지 않으면 Set에 추가합니다. * @@ -229,4 +305,15 @@ private void addIfNotEmpty(Set set, String value) { set.add(normalizeOrigin(value)); } } + + private void addAdminOriginIfNotEmpty(String origin) { + if (origin != null && !origin.isBlank()) { + String normalizedOrigin = normalizeOrigin(origin); + allowedOrigins.add(normalizedOrigin); + adminOrigins.add(normalizedOrigin); + } + } + + private record RedirectTarget(String origin, String redirectPath) { + } } diff --git a/module-presentation/src/main/java/co/kr/pinhouse/domain/admin/AdminSessionApi.java b/module-presentation/src/main/java/co/kr/pinhouse/domain/admin/AdminSessionApi.java new file mode 100644 index 00000000..fd79bcf0 --- /dev/null +++ b/module-presentation/src/main/java/co/kr/pinhouse/domain/admin/AdminSessionApi.java @@ -0,0 +1,31 @@ +package co.kr.pinhouse.domain.admin; + +import java.util.UUID; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import co.kr.pinhouse.common.auth.CurrentUserId; +import co.kr.pinhouse.common.response.ApiResponse; +import co.kr.pinhouse.domain.admin.application.dto.response.AdminMeResponse; +import co.kr.pinhouse.domain.admin.application.usecase.AdminSessionUseCase; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/v1/admin") +@RequiredArgsConstructor +@Tag(name = "관리자 세션 API", description = "관리자 세션 확인 API") +public class AdminSessionApi { + + private final AdminSessionUseCase adminSessionService; + + /// 현재 관리자 세션 조회 + @GetMapping("/me") + @Operation(summary = "관리자 세션 조회", description = "현재 로그인한 사용자의 관리자 정보를 조회합니다.") + public ApiResponse getAdminMe(@CurrentUserId(required = true) UUID userId) { + return ApiResponse.ok(adminSessionService.getAdminMe(userId)); + } +} diff --git a/module-presentation/src/main/java/co/kr/pinhouse/domain/admin/ad/AdminAdApi.java b/module-presentation/src/main/java/co/kr/pinhouse/domain/admin/ad/AdminAdApi.java new file mode 100644 index 00000000..ffa9e879 --- /dev/null +++ b/module-presentation/src/main/java/co/kr/pinhouse/domain/admin/ad/AdminAdApi.java @@ -0,0 +1,87 @@ +package co.kr.pinhouse.domain.admin.ad; + +import java.util.UUID; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import co.kr.pinhouse.common.auth.CurrentUserId; +import co.kr.pinhouse.common.response.ApiResponse; +import co.kr.pinhouse.common.response.pageable.SliceRequest; +import co.kr.pinhouse.common.response.pageable.SliceResponse; +import co.kr.pinhouse.domain.ad.application.dto.request.CreateAdvertisementRequest; +import co.kr.pinhouse.domain.ad.application.dto.request.UpdateAdvertisementRequest; +import co.kr.pinhouse.domain.ad.application.dto.request.UpdateAdvertisementStatusRequest; +import co.kr.pinhouse.domain.ad.application.dto.response.AdminAdvertisementResponse; +import co.kr.pinhouse.domain.ad.application.dto.response.AdminAdvertisementSummaryResponse; +import co.kr.pinhouse.domain.ad.application.usecase.AdvertisementUseCase; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/v1/admin/ads") +@RequiredArgsConstructor +@Tag(name = "관리자 광고 API", description = "관리자 광고 관리 API") +public class AdminAdApi { + + private final AdvertisementUseCase advertisementService; + + /// 관리자 광고 목록 조회 + @GetMapping + @Operation(summary = "광고 목록 조회", description = "관리자 광고 목록을 조회합니다.") + public ApiResponse> getAdvertisements(SliceRequest sliceRequest) { + return ApiResponse.ok(advertisementService.getAdminAdvertisements(sliceRequest)); + } + + /// 관리자 광고 생성 + @PostMapping + @Operation(summary = "광고 생성", description = "새 광고를 생성합니다.") + public ApiResponse createAdvertisement( + @RequestBody @Valid CreateAdvertisementRequest request, + @CurrentUserId(required = true) UUID userId, + HttpServletRequest httpServletRequest + ) { + return ApiResponse.ok(advertisementService.createAdvertisement(request, userId, httpServletRequest)); + } + + /// 관리자 광고 상세 조회 + @GetMapping("/{advertisementId}") + @Operation(summary = "광고 상세 조회", description = "광고 상세와 성과를 조회합니다.") + public ApiResponse getAdvertisement(@PathVariable Long advertisementId) { + return ApiResponse.ok(advertisementService.getAdminAdvertisement(advertisementId)); + } + + /// 관리자 광고 정보 수정 + @PatchMapping("/{advertisementId}") + @Operation(summary = "광고 수정", description = "광고 기본 정보를 수정합니다.") + public ApiResponse updateAdvertisement( + @PathVariable Long advertisementId, + @RequestBody UpdateAdvertisementRequest request, + @CurrentUserId(required = true) UUID userId, + HttpServletRequest httpServletRequest + ) { + return ApiResponse.ok( + advertisementService.updateAdvertisement(advertisementId, request, userId, httpServletRequest)); + } + + /// 관리자 광고 상태 변경 + @PatchMapping("/{advertisementId}/status") + @Operation(summary = "광고 상태 변경", description = "광고 상태를 변경합니다.") + public ApiResponse updateStatus( + @PathVariable Long advertisementId, + @RequestBody @Valid UpdateAdvertisementStatusRequest request, + @CurrentUserId(required = true) UUID userId, + HttpServletRequest httpServletRequest + ) { + return ApiResponse.ok( + advertisementService.updateStatus(advertisementId, request.status(), userId, httpServletRequest)); + } +} diff --git a/module-presentation/src/main/java/co/kr/pinhouse/domain/admin/audit/AdminAuditLogApi.java b/module-presentation/src/main/java/co/kr/pinhouse/domain/admin/audit/AdminAuditLogApi.java new file mode 100644 index 00000000..101bfd59 --- /dev/null +++ b/module-presentation/src/main/java/co/kr/pinhouse/domain/admin/audit/AdminAuditLogApi.java @@ -0,0 +1,30 @@ +package co.kr.pinhouse.domain.admin.audit; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import co.kr.pinhouse.common.response.ApiResponse; +import co.kr.pinhouse.common.response.pageable.SliceRequest; +import co.kr.pinhouse.common.response.pageable.SliceResponse; +import co.kr.pinhouse.domain.admin.audit.application.dto.response.AdminAuditLogResponse; +import co.kr.pinhouse.domain.admin.audit.application.usecase.AdminAuditLogUseCase; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/v1/admin/audit-logs") +@RequiredArgsConstructor +@Tag(name = "관리자 감사로그 API", description = "관리자 변경 이력 조회 API") +public class AdminAuditLogApi { + + private final AdminAuditLogUseCase adminAuditLogService; + + /// 전체 감사로그 조회 + @GetMapping + @Operation(summary = "감사로그 조회", description = "최신 관리자 감사로그를 페이지 단위로 조회합니다.") + public ApiResponse> getLogs(SliceRequest sliceRequest) { + return ApiResponse.ok(adminAuditLogService.getLogs(sliceRequest)); + } +} diff --git a/module-presentation/src/main/java/co/kr/pinhouse/domain/admin/cs/AdminCsInquiryApi.java b/module-presentation/src/main/java/co/kr/pinhouse/domain/admin/cs/AdminCsInquiryApi.java new file mode 100644 index 00000000..54679c4d --- /dev/null +++ b/module-presentation/src/main/java/co/kr/pinhouse/domain/admin/cs/AdminCsInquiryApi.java @@ -0,0 +1,93 @@ +package co.kr.pinhouse.domain.admin.cs; + +import java.util.UUID; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import co.kr.pinhouse.common.auth.CurrentUserId; +import co.kr.pinhouse.common.response.ApiResponse; +import co.kr.pinhouse.common.response.pageable.SliceRequest; +import co.kr.pinhouse.common.response.pageable.SliceResponse; +import co.kr.pinhouse.domain.cs.application.dto.request.AssignCsInquiryRequest; +import co.kr.pinhouse.domain.cs.application.dto.request.CreateCsInquiryMessageRequest; +import co.kr.pinhouse.domain.cs.application.dto.request.UpdateCsInquiryStatusRequest; +import co.kr.pinhouse.domain.cs.application.dto.response.CsInquiryDetailResponse; +import co.kr.pinhouse.domain.cs.application.dto.response.CsInquirySummaryResponse; +import co.kr.pinhouse.domain.cs.application.usecase.CsInquiryUseCase; +import co.kr.pinhouse.domain.cs.domain.entity.CsInquiryCategory; +import co.kr.pinhouse.domain.cs.domain.entity.CsInquiryStatus; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/v1/admin/cs/inquiries") +@RequiredArgsConstructor +@Tag(name = "관리자 CS API", description = "관리자 문의 처리 API") +public class AdminCsInquiryApi { + + private final CsInquiryUseCase csInquiryService; + + /// 관리자 문의 목록 조회 + @GetMapping + @Operation(summary = "관리자 문의 목록", description = "관리자 화면용 문의 목록을 조회합니다.") + public ApiResponse> getInquiries( + @RequestParam(required = false) CsInquiryStatus status, + @RequestParam(required = false) CsInquiryCategory category, + SliceRequest sliceRequest + ) { + return ApiResponse.ok(csInquiryService.getAdminInquiries(status, category, sliceRequest)); + } + + /// 관리자 문의 상세 조회 + @GetMapping("/{inquiryId}") + @Operation(summary = "관리자 문의 상세", description = "관리자 화면용 문의 상세를 조회합니다.") + public ApiResponse getInquiry(@PathVariable Long inquiryId) { + return ApiResponse.ok(csInquiryService.getAdminInquiry(inquiryId)); + } + + /// 문의 담당자 지정 + @PatchMapping("/{inquiryId}/assign") + @Operation(summary = "문의 담당자 지정", description = "문의 담당 관리자를 지정합니다.") + public ApiResponse assignInquiry( + @PathVariable Long inquiryId, + @RequestBody @Valid AssignCsInquiryRequest request, + @CurrentUserId(required = true) UUID userId, + HttpServletRequest httpServletRequest + ) { + return ApiResponse.ok(csInquiryService.assignInquiry(inquiryId, request.adminId(), userId, httpServletRequest)); + } + + /// 문의 상태 변경 + @PatchMapping("/{inquiryId}/status") + @Operation(summary = "문의 상태 변경", description = "문의 처리 상태를 변경합니다.") + public ApiResponse updateStatus( + @PathVariable Long inquiryId, + @RequestBody @Valid UpdateCsInquiryStatusRequest request, + @CurrentUserId(required = true) UUID userId, + HttpServletRequest httpServletRequest + ) { + return ApiResponse.ok(csInquiryService.updateStatus(inquiryId, request.status(), userId, httpServletRequest)); + } + + /// 관리자 답변 등록 + @PostMapping("/{inquiryId}/messages") + @Operation(summary = "관리자 답변 등록", description = "문의 스레드에 관리자 답변을 등록합니다.") + public ApiResponse addMessage( + @PathVariable Long inquiryId, + @RequestBody @Valid CreateCsInquiryMessageRequest request, + @CurrentUserId(required = true) UUID userId, + HttpServletRequest httpServletRequest + ) { + return ApiResponse.ok(csInquiryService.addAdminMessage(inquiryId, userId, request, httpServletRequest)); + } +} diff --git a/module-presentation/src/main/java/co/kr/pinhouse/domain/admin/notice/AdminNoticeApi.java b/module-presentation/src/main/java/co/kr/pinhouse/domain/admin/notice/AdminNoticeApi.java new file mode 100644 index 00000000..616faf52 --- /dev/null +++ b/module-presentation/src/main/java/co/kr/pinhouse/domain/admin/notice/AdminNoticeApi.java @@ -0,0 +1,76 @@ +package co.kr.pinhouse.domain.admin.notice; + +import java.util.UUID; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import co.kr.pinhouse.common.auth.CurrentUserId; +import co.kr.pinhouse.common.response.ApiResponse; +import co.kr.pinhouse.common.response.pageable.SliceRequest; +import co.kr.pinhouse.common.response.pageable.SliceResponse; +import co.kr.pinhouse.domain.admin.audit.application.dto.response.AdminAuditLogResponse; +import co.kr.pinhouse.domain.admin.audit.application.usecase.AdminAuditLogUseCase; +import co.kr.pinhouse.domain.admin.audit.domain.entity.AdminAuditTargetType; +import co.kr.pinhouse.domain.admin.notice.application.dto.request.UpdateAdminNoticeRequest; +import co.kr.pinhouse.domain.admin.notice.application.dto.response.AdminNoticeResponse; +import co.kr.pinhouse.domain.admin.notice.application.dto.response.AdminNoticeSummaryResponse; +import co.kr.pinhouse.domain.admin.notice.application.usecase.AdminNoticeUseCase; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/v1/admin/notices") +@RequiredArgsConstructor +@Tag(name = "관리자 공고 API", description = "관리자 공고 조회/수정 API") +public class AdminNoticeApi { + + private final AdminNoticeUseCase adminNoticeService; + private final AdminAuditLogUseCase adminAuditLogService; + + /// 관리자 공고 목록 조회 + @GetMapping + @Operation(summary = "관리자 공고 목록 조회", description = "운영 공고 목록을 조회합니다.") + public ApiResponse> getNotices( + @RequestParam(required = false) String keyword, + SliceRequest sliceRequest + ) { + return ApiResponse.ok(adminNoticeService.getNotices(keyword, sliceRequest)); + } + + /// 관리자 공고 상세 조회 + @GetMapping("/{noticeId}") + @Operation(summary = "관리자 공고 상세 조회", description = "운영 공고 상세와 override 정보를 조회합니다.") + public ApiResponse getNotice(@PathVariable String noticeId) { + return ApiResponse.ok(adminNoticeService.getNotice(noticeId)); + } + + /// 관리자 공고 운영 정보 수정 + @PatchMapping("/{noticeId}") + @Operation(summary = "관리자 공고 수정", description = "운영용 공고 override 정보를 수정합니다.") + public ApiResponse updateNotice( + @PathVariable String noticeId, + @RequestBody UpdateAdminNoticeRequest request, + @CurrentUserId(required = true) UUID userId, + HttpServletRequest httpServletRequest + ) { + return ApiResponse.ok(adminNoticeService.updateNotice(noticeId, request, userId, httpServletRequest)); + } + + /// 공고 수정 이력 조회 + @GetMapping("/{noticeId}/history") + @Operation(summary = "공고 수정 이력 조회", description = "특정 공고의 감사로그 이력을 조회합니다.") + public ApiResponse> getHistory( + @PathVariable String noticeId, + SliceRequest sliceRequest + ) { + return ApiResponse.ok(adminAuditLogService.getLogsByTarget(AdminAuditTargetType.NOTICE, noticeId, sliceRequest)); + } +} diff --git a/module-presentation/src/main/java/co/kr/pinhouse/domain/admin/user/AdminUserApi.java b/module-presentation/src/main/java/co/kr/pinhouse/domain/admin/user/AdminUserApi.java new file mode 100644 index 00000000..17d0be49 --- /dev/null +++ b/module-presentation/src/main/java/co/kr/pinhouse/domain/admin/user/AdminUserApi.java @@ -0,0 +1,45 @@ +package co.kr.pinhouse.domain.admin.user; + +import java.util.UUID; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import co.kr.pinhouse.common.response.ApiResponse; +import co.kr.pinhouse.common.response.pageable.SliceRequest; +import co.kr.pinhouse.common.response.pageable.SliceResponse; +import co.kr.pinhouse.domain.admin.user.application.dto.response.AdminUserDetailResponse; +import co.kr.pinhouse.domain.admin.user.application.dto.response.AdminUserSummaryResponse; +import co.kr.pinhouse.domain.admin.user.application.usecase.AdminUserUseCase; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/v1/admin/users") +@RequiredArgsConstructor +@Tag(name = "관리자 유저 API", description = "관리자 유저 조회 API") +public class AdminUserApi { + + private final AdminUserUseCase adminUserService; + + /// 관리자 유저 목록 조회 + @GetMapping + @Operation(summary = "관리자 유저 목록 조회", description = "마스킹된 유저 목록을 조회합니다.") + public ApiResponse> getUsers( + @RequestParam(required = false) String keyword, + SliceRequest sliceRequest + ) { + return ApiResponse.ok(adminUserService.getUsers(keyword, sliceRequest)); + } + + /// 관리자 유저 상세 조회 + @GetMapping("/{userId}") + @Operation(summary = "관리자 유저 상세 조회", description = "마스킹된 유저 상세와 활동 요약을 조회합니다.") + public ApiResponse getUser(@PathVariable UUID userId) { + return ApiResponse.ok(adminUserService.getUser(userId)); + } +} diff --git a/module-security/src/main/java/co/kr/pinhouse/security/config/CorsConfig.java b/module-security/src/main/java/co/kr/pinhouse/security/config/CorsConfig.java index bf376bdd..9fa2571c 100644 --- a/module-security/src/main/java/co/kr/pinhouse/security/config/CorsConfig.java +++ b/module-security/src/main/java/co/kr/pinhouse/security/config/CorsConfig.java @@ -19,6 +19,15 @@ public class CorsConfig { @Value("${cors.front.prod}") private String frontProd; + @Value("${cors.front.admin-local:}") + private String adminFrontLocal; + + @Value("${cors.front.admin-dev:}") + private String adminFrontDev; + + @Value("${cors.front.admin-prod:}") + private String adminFrontProd; + @Value("${cors.back.dev}") private String backDev; @@ -33,6 +42,9 @@ public CorsConfigurationSource corsConfigurationSource() { configuration.addAllowedOriginPattern(frontLocal); configuration.addAllowedOriginPattern(frontDev); configuration.addAllowedOriginPattern(frontProd); + addAllowedOriginIfPresent(configuration, adminFrontLocal); + addAllowedOriginIfPresent(configuration, adminFrontDev); + addAllowedOriginIfPresent(configuration, adminFrontProd); configuration.addAllowedOriginPattern(backDev); configuration.addAllowedHeader("*"); @@ -46,4 +58,10 @@ public CorsConfigurationSource corsConfigurationSource() { return source; } + private void addAllowedOriginIfPresent(CorsConfiguration configuration, String origin) { + if (origin != null && !origin.isBlank()) { + configuration.addAllowedOriginPattern(origin); + } + } + } diff --git a/module-security/src/main/java/co/kr/pinhouse/security/jwt/filter/RequestMatcherHolder.java b/module-security/src/main/java/co/kr/pinhouse/security/jwt/filter/RequestMatcherHolder.java index e69f76d2..bb95538c 100644 --- a/module-security/src/main/java/co/kr/pinhouse/security/jwt/filter/RequestMatcherHolder.java +++ b/module-security/src/main/java/co/kr/pinhouse/security/jwt/filter/RequestMatcherHolder.java @@ -55,6 +55,12 @@ public class RequestMatcherHolder { // oauth2 new RequestInfo(POST, "/api/v1/oauth2/**", null), + // admin + new RequestInfo(GET, "/v1/admin/**", Role.ADMIN), + new RequestInfo(POST, "/v1/admin/**", Role.ADMIN), + new RequestInfo(PATCH, "/v1/admin/**", Role.ADMIN), + new RequestInfo(DELETE, "/v1/admin/**", Role.ADMIN), + // notice new RequestInfo(GET, "/v1/notices/likes", Role.USER), new RequestInfo(GET, "/v1/notices/**", null), @@ -73,6 +79,14 @@ public class RequestMatcherHolder { // search new RequestInfo(GET, "/v1/search/fast", Role.USER), + // cs + new RequestInfo(GET, "/v1/cs/**", Role.USER), + new RequestInfo(POST, "/v1/cs/**", Role.USER), + + // ads + new RequestInfo(GET, "/v1/ads/**", null), + new RequestInfo(POST, "/v1/ads/events", null), + // batch new RequestInfo(POST, "/v1/facility/batch", null), diff --git a/module-security/src/main/java/co/kr/pinhouse/security/oauth2/filter/OAuth2AuthorizationRequestOriginFilter.java b/module-security/src/main/java/co/kr/pinhouse/security/oauth2/filter/OAuth2AuthorizationRequestOriginFilter.java index fdc89cee..085ccb01 100644 --- a/module-security/src/main/java/co/kr/pinhouse/security/oauth2/filter/OAuth2AuthorizationRequestOriginFilter.java +++ b/module-security/src/main/java/co/kr/pinhouse/security/oauth2/filter/OAuth2AuthorizationRequestOriginFilter.java @@ -32,8 +32,8 @@ protected boolean shouldNotFilter(HttpServletRequest request) { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - // 인증 공급자로 리다이렉트되기 전에 현재 프론트 Origin을 세션에 저장 - redirectUrlResolver.saveRedirectOrigin(request); + // 인증 공급자로 리다이렉트되기 전에 현재 프론트 Origin과 callback path를 세션에 저장 + redirectUrlResolver.saveRedirectContext(request); filterChain.doFilter(request, response); } } diff --git a/module-security/src/main/java/co/kr/pinhouse/security/oauth2/handler/OAuth2FailureHandler.java b/module-security/src/main/java/co/kr/pinhouse/security/oauth2/handler/OAuth2FailureHandler.java index f13356ff..995af996 100644 --- a/module-security/src/main/java/co/kr/pinhouse/security/oauth2/handler/OAuth2FailureHandler.java +++ b/module-security/src/main/java/co/kr/pinhouse/security/oauth2/handler/OAuth2FailureHandler.java @@ -55,7 +55,6 @@ private String buildCallbackUrl(HttpServletRequest request, String exchangeCode) } private String resolveCallbackUrl(HttpServletRequest request) { - String frontUrl = redirectUrlResolver.resolveRedirectUrl(request); - return frontUrl + "/api/auth/callback"; + return redirectUrlResolver.resolveRedirectUrl(request); } } From ac8b20fe7b18a5effeee6d396601cd65baad852d Mon Sep 17 00:00:00 2001 From: eedo_y Date: Tue, 21 Apr 2026 15:33:56 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20CS=20=EA=B4=80=EB=A0=A8=20=EB=82=B4?= =?UTF-8?q?=EC=9A=A9=20=EA=B5=AC=ED=98=84=20(#129)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/exception/code/CsErrorCode.java | 19 ++ .../CreateCsInquiryMessageRequest.java | 8 + .../dto/request/CreateCsInquiryRequest.java | 12 + .../request/UpdateCsInquiryStatusRequest.java | 9 + .../dto/response/CsInquiryDetailResponse.java | 44 +++ .../response/CsInquiryMessageResponse.java | 28 ++ .../response/CsInquiryRequesterResponse.java | 13 + .../response/CsInquirySummaryResponse.java | 33 ++ .../application/service/CsInquiryService.java | 305 ++++++++++++++++++ .../application/usecase/CsInquiryUseCase.java | 74 +++++ .../domain/cs/domain/entity/CsInquiry.java | 122 +++++++ .../cs/domain/entity/CsInquiryCategory.java | 10 + .../cs/domain/entity/CsInquiryMessage.java | 72 +++++ .../cs/domain/entity/CsInquiryStatus.java | 9 + .../cs/domain/entity/CsMessageSenderType.java | 6 + .../CsInquiryMessageRepository.java | 12 + .../repository/CsInquiryRepository.java | 17 + .../kr/pinhouse/domain/cs/CsInquiryApi.java | 79 +++++ 18 files changed, 872 insertions(+) create mode 100644 module-common/src/main/java/co/kr/pinhouse/common/exception/code/CsErrorCode.java create mode 100644 module-domain/src/main/java/co/kr/pinhouse/domain/cs/application/dto/request/CreateCsInquiryMessageRequest.java create mode 100644 module-domain/src/main/java/co/kr/pinhouse/domain/cs/application/dto/request/CreateCsInquiryRequest.java create mode 100644 module-domain/src/main/java/co/kr/pinhouse/domain/cs/application/dto/request/UpdateCsInquiryStatusRequest.java create mode 100644 module-domain/src/main/java/co/kr/pinhouse/domain/cs/application/dto/response/CsInquiryDetailResponse.java create mode 100644 module-domain/src/main/java/co/kr/pinhouse/domain/cs/application/dto/response/CsInquiryMessageResponse.java create mode 100644 module-domain/src/main/java/co/kr/pinhouse/domain/cs/application/dto/response/CsInquiryRequesterResponse.java create mode 100644 module-domain/src/main/java/co/kr/pinhouse/domain/cs/application/dto/response/CsInquirySummaryResponse.java create mode 100644 module-domain/src/main/java/co/kr/pinhouse/domain/cs/application/service/CsInquiryService.java create mode 100644 module-domain/src/main/java/co/kr/pinhouse/domain/cs/application/usecase/CsInquiryUseCase.java create mode 100644 module-domain/src/main/java/co/kr/pinhouse/domain/cs/domain/entity/CsInquiry.java create mode 100644 module-domain/src/main/java/co/kr/pinhouse/domain/cs/domain/entity/CsInquiryCategory.java create mode 100644 module-domain/src/main/java/co/kr/pinhouse/domain/cs/domain/entity/CsInquiryMessage.java create mode 100644 module-domain/src/main/java/co/kr/pinhouse/domain/cs/domain/entity/CsInquiryStatus.java create mode 100644 module-domain/src/main/java/co/kr/pinhouse/domain/cs/domain/entity/CsMessageSenderType.java create mode 100644 module-domain/src/main/java/co/kr/pinhouse/domain/cs/domain/repository/CsInquiryMessageRepository.java create mode 100644 module-domain/src/main/java/co/kr/pinhouse/domain/cs/domain/repository/CsInquiryRepository.java create mode 100644 module-presentation/src/main/java/co/kr/pinhouse/domain/cs/CsInquiryApi.java diff --git a/module-common/src/main/java/co/kr/pinhouse/common/exception/code/CsErrorCode.java b/module-common/src/main/java/co/kr/pinhouse/common/exception/code/CsErrorCode.java new file mode 100644 index 00000000..e9c7c971 --- /dev/null +++ b/module-common/src/main/java/co/kr/pinhouse/common/exception/code/CsErrorCode.java @@ -0,0 +1,19 @@ +package co.kr.pinhouse.common.exception.code; + +import org.springframework.http.HttpStatus; + +import co.kr.pinhouse.common.response.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum CsErrorCode implements ErrorCode { + + NOT_FOUND_INQUIRY(404_300, HttpStatus.NOT_FOUND, "해당 문의를 찾을 수 없습니다."), + FORBIDDEN_INQUIRY_ACCESS(403_300, HttpStatus.FORBIDDEN, "해당 문의에 접근할 권한이 없습니다."); + + private final Integer code; + private final HttpStatus httpStatus; + private final String message; +} diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/cs/application/dto/request/CreateCsInquiryMessageRequest.java b/module-domain/src/main/java/co/kr/pinhouse/domain/cs/application/dto/request/CreateCsInquiryMessageRequest.java new file mode 100644 index 00000000..1716fd85 --- /dev/null +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/cs/application/dto/request/CreateCsInquiryMessageRequest.java @@ -0,0 +1,8 @@ +package co.kr.pinhouse.domain.cs.application.dto.request; + +import jakarta.validation.constraints.NotBlank; + +public record CreateCsInquiryMessageRequest( + @NotBlank String content +) { +} diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/cs/application/dto/request/CreateCsInquiryRequest.java b/module-domain/src/main/java/co/kr/pinhouse/domain/cs/application/dto/request/CreateCsInquiryRequest.java new file mode 100644 index 00000000..d87f4663 --- /dev/null +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/cs/application/dto/request/CreateCsInquiryRequest.java @@ -0,0 +1,12 @@ +package co.kr.pinhouse.domain.cs.application.dto.request; + +import co.kr.pinhouse.domain.cs.domain.entity.CsInquiryCategory; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record CreateCsInquiryRequest( + @NotBlank String title, + @NotNull CsInquiryCategory category, + @NotBlank String content +) { +} diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/cs/application/dto/request/UpdateCsInquiryStatusRequest.java b/module-domain/src/main/java/co/kr/pinhouse/domain/cs/application/dto/request/UpdateCsInquiryStatusRequest.java new file mode 100644 index 00000000..5321e9c9 --- /dev/null +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/cs/application/dto/request/UpdateCsInquiryStatusRequest.java @@ -0,0 +1,9 @@ +package co.kr.pinhouse.domain.cs.application.dto.request; + +import co.kr.pinhouse.domain.cs.domain.entity.CsInquiryStatus; +import jakarta.validation.constraints.NotNull; + +public record UpdateCsInquiryStatusRequest( + @NotNull CsInquiryStatus status +) { +} diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/cs/application/dto/response/CsInquiryDetailResponse.java b/module-domain/src/main/java/co/kr/pinhouse/domain/cs/application/dto/response/CsInquiryDetailResponse.java new file mode 100644 index 00000000..b8db38bd --- /dev/null +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/cs/application/dto/response/CsInquiryDetailResponse.java @@ -0,0 +1,44 @@ +package co.kr.pinhouse.domain.cs.application.dto.response; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +import co.kr.pinhouse.domain.cs.domain.entity.CsInquiry; +import co.kr.pinhouse.domain.cs.domain.entity.CsInquiryCategory; +import co.kr.pinhouse.domain.cs.domain.entity.CsInquiryStatus; +import lombok.Builder; + +@Builder +public record CsInquiryDetailResponse( + Long inquiryId, + String title, + CsInquiryCategory category, + CsInquiryStatus status, + UUID assignedAdminId, + LocalDateTime createdAt, + LocalDateTime firstRespondedAt, + LocalDateTime resolvedAt, + CsInquiryRequesterResponse requester, + List messages +) { + + public static CsInquiryDetailResponse of( + CsInquiry inquiry, + CsInquiryRequesterResponse requester, + List messages + ) { + return CsInquiryDetailResponse.builder() + .inquiryId(inquiry.getId()) + .title(inquiry.getTitle()) + .category(inquiry.getCategory()) + .status(inquiry.getStatus()) + .assignedAdminId(inquiry.getAssignedAdminId()) + .createdAt(inquiry.getCreatedAt()) + .firstRespondedAt(inquiry.getFirstRespondedAt()) + .resolvedAt(inquiry.getResolvedAt()) + .requester(requester) + .messages(messages) + .build(); + } +} diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/cs/application/dto/response/CsInquiryMessageResponse.java b/module-domain/src/main/java/co/kr/pinhouse/domain/cs/application/dto/response/CsInquiryMessageResponse.java new file mode 100644 index 00000000..2eb6e418 --- /dev/null +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/cs/application/dto/response/CsInquiryMessageResponse.java @@ -0,0 +1,28 @@ +package co.kr.pinhouse.domain.cs.application.dto.response; + +import java.time.LocalDateTime; +import java.util.UUID; + +import co.kr.pinhouse.domain.cs.domain.entity.CsInquiryMessage; +import co.kr.pinhouse.domain.cs.domain.entity.CsMessageSenderType; +import lombok.Builder; + +@Builder +public record CsInquiryMessageResponse( + Long id, + CsMessageSenderType senderType, + UUID senderId, + String content, + LocalDateTime createdAt +) { + + public static CsInquiryMessageResponse from(CsInquiryMessage message) { + return CsInquiryMessageResponse.builder() + .id(message.getId()) + .senderType(message.getSenderType()) + .senderId(message.getSenderId()) + .content(message.getContent()) + .createdAt(message.getCreatedAt()) + .build(); + } +} diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/cs/application/dto/response/CsInquiryRequesterResponse.java b/module-domain/src/main/java/co/kr/pinhouse/domain/cs/application/dto/response/CsInquiryRequesterResponse.java new file mode 100644 index 00000000..b5a80953 --- /dev/null +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/cs/application/dto/response/CsInquiryRequesterResponse.java @@ -0,0 +1,13 @@ +package co.kr.pinhouse.domain.cs.application.dto.response; + +import java.util.UUID; + +import lombok.Builder; + +@Builder +public record CsInquiryRequesterResponse( + UUID userId, + String maskedName, + String maskedEmail +) { +} diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/cs/application/dto/response/CsInquirySummaryResponse.java b/module-domain/src/main/java/co/kr/pinhouse/domain/cs/application/dto/response/CsInquirySummaryResponse.java new file mode 100644 index 00000000..dea81375 --- /dev/null +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/cs/application/dto/response/CsInquirySummaryResponse.java @@ -0,0 +1,33 @@ +package co.kr.pinhouse.domain.cs.application.dto.response; + +import java.time.LocalDateTime; +import java.util.UUID; + +import co.kr.pinhouse.domain.cs.domain.entity.CsInquiry; +import co.kr.pinhouse.domain.cs.domain.entity.CsInquiryCategory; +import co.kr.pinhouse.domain.cs.domain.entity.CsInquiryStatus; +import lombok.Builder; + +@Builder +public record CsInquirySummaryResponse( + Long inquiryId, + String title, + CsInquiryCategory category, + CsInquiryStatus status, + UUID assignedAdminId, + LocalDateTime createdAt, + LocalDateTime lastMessageAt +) { + + public static CsInquirySummaryResponse from(CsInquiry inquiry) { + return CsInquirySummaryResponse.builder() + .inquiryId(inquiry.getId()) + .title(inquiry.getTitle()) + .category(inquiry.getCategory()) + .status(inquiry.getStatus()) + .assignedAdminId(inquiry.getAssignedAdminId()) + .createdAt(inquiry.getCreatedAt()) + .lastMessageAt(inquiry.getLastMessageAt()) + .build(); + } +} diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/cs/application/service/CsInquiryService.java b/module-domain/src/main/java/co/kr/pinhouse/domain/cs/application/service/CsInquiryService.java new file mode 100644 index 00000000..d7855bb5 --- /dev/null +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/cs/application/service/CsInquiryService.java @@ -0,0 +1,305 @@ +package co.kr.pinhouse.domain.cs.application.service; + +import java.util.List; +import java.util.UUID; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import co.kr.pinhouse.common.exception.code.CsErrorCode; +import co.kr.pinhouse.common.exception.code.UserErrorCode; +import co.kr.pinhouse.common.response.CustomException; +import co.kr.pinhouse.common.response.pageable.SliceRequest; +import co.kr.pinhouse.common.response.pageable.SliceResponse; +import co.kr.pinhouse.domain.admin.application.usecase.AdminSessionUseCase; +import co.kr.pinhouse.domain.admin.audit.application.usecase.AdminAuditLogUseCase; +import co.kr.pinhouse.domain.admin.audit.domain.entity.AdminAuditActionType; +import co.kr.pinhouse.domain.admin.audit.domain.entity.AdminAuditTargetType; +import co.kr.pinhouse.domain.cs.application.dto.request.CreateCsInquiryMessageRequest; +import co.kr.pinhouse.domain.cs.application.dto.request.CreateCsInquiryRequest; +import co.kr.pinhouse.domain.cs.application.dto.response.CsInquiryDetailResponse; +import co.kr.pinhouse.domain.cs.application.dto.response.CsInquiryMessageResponse; +import co.kr.pinhouse.domain.cs.application.dto.response.CsInquiryRequesterResponse; +import co.kr.pinhouse.domain.cs.application.dto.response.CsInquirySummaryResponse; +import co.kr.pinhouse.domain.cs.application.usecase.CsInquiryUseCase; +import co.kr.pinhouse.domain.cs.domain.entity.CsInquiry; +import co.kr.pinhouse.domain.cs.domain.entity.CsInquiryCategory; +import co.kr.pinhouse.domain.cs.domain.entity.CsInquiryMessage; +import co.kr.pinhouse.domain.cs.domain.entity.CsInquiryStatus; +import co.kr.pinhouse.domain.cs.domain.entity.CsMessageSenderType; +import co.kr.pinhouse.domain.cs.domain.repository.CsInquiryMessageRepository; +import co.kr.pinhouse.domain.cs.domain.repository.CsInquiryRepository; +import co.kr.pinhouse.domain.user.domain.entity.User; +import co.kr.pinhouse.domain.user.domain.repository.UserJpaRepository; +import jakarta.persistence.criteria.Predicate; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class CsInquiryService implements CsInquiryUseCase { + + private final UserJpaRepository userRepository; + private final CsInquiryRepository inquiryRepository; + private final CsInquiryMessageRepository messageRepository; + private final AdminSessionUseCase adminSessionService; + private final AdminAuditLogUseCase adminAuditLogService; + + // ================= + // 사용자 로직 + // ================= + + /// 사용자 문의 생성 + @Transactional + @Override + public CsInquiryDetailResponse createInquiry(UUID userId, CreateCsInquiryRequest request) { + User user = loadUser(userId); + CsInquiry inquiry = inquiryRepository.save(CsInquiry.create(user, request.category(), request.title())); + messageRepository.save(CsInquiryMessage.of(inquiry, CsMessageSenderType.USER, userId, request.content())); + + return toDetail(inquiry, true); + } + + /// 내 문의 목록 조회 + @Transactional(readOnly = true) + @Override + public SliceResponse getMyInquiries(UUID userId, SliceRequest sliceRequest) { + var pageable = PageRequest.of(sliceRequest.page() - 1, sliceRequest.offSet(), + Sort.by(Sort.Order.desc("createdAt"), Sort.Order.desc("id"))); + var page = inquiryRepository.findByUser_IdOrderByCreatedAtDesc(userId, pageable); + + return SliceResponse.from(page.map(CsInquirySummaryResponse::from), page.getTotalElements()); + } + + /// 내 문의 상세 조회 + @Transactional(readOnly = true) + @Override + public CsInquiryDetailResponse getMyInquiry(UUID userId, Long inquiryId) { + CsInquiry inquiry = loadInquiry(inquiryId); + validateOwner(inquiry, userId); + return toDetail(inquiry, false); + } + + /// 사용자 추가 메시지 등록 + @Transactional + @Override + public CsInquiryDetailResponse addUserMessage( + UUID userId, + Long inquiryId, + CreateCsInquiryMessageRequest request + ) { + CsInquiry inquiry = loadInquiry(inquiryId); + validateOwner(inquiry, userId); + + messageRepository.save(CsInquiryMessage.of(inquiry, CsMessageSenderType.USER, userId, request.content())); + inquiry.markUserMessage(); + + return toDetail(inquiry, false); + } + + // ================= + // 관리자 로직 + // ================= + + /// 관리자 문의 목록 조회 + @Transactional(readOnly = true) + @Override + public SliceResponse getAdminInquiries( + CsInquiryStatus status, + CsInquiryCategory category, + SliceRequest sliceRequest + ) { + var pageable = PageRequest.of(sliceRequest.page() - 1, sliceRequest.offSet(), + Sort.by(Sort.Order.desc("createdAt"), Sort.Order.desc("id"))); + var page = inquiryRepository.findAll(buildSpecification(status, category), pageable); + + return SliceResponse.from(page.map(CsInquirySummaryResponse::from), page.getTotalElements()); + } + + /// 관리자 문의 상세 조회 + @Transactional(readOnly = true) + @Override + public CsInquiryDetailResponse getAdminInquiry(Long inquiryId) { + return toDetail(loadInquiry(inquiryId), true); + } + + /// 문의 담당 관리자 지정 + @Transactional + @Override + public CsInquiryDetailResponse assignInquiry( + Long inquiryId, + UUID assigneeAdminId, + UUID actorAdminId, + HttpServletRequest httpServletRequest + ) { + adminSessionService.loadAdmin(actorAdminId); + adminSessionService.loadAdmin(assigneeAdminId); + + CsInquiry inquiry = loadInquiry(inquiryId); + CsInquirySummaryResponse before = CsInquirySummaryResponse.from(inquiry); + inquiry.assignAdmin(assigneeAdminId); + CsInquiryDetailResponse after = toDetail(inquiry, true); + + adminAuditLogService.log( + actorAdminId, + AdminAuditActionType.ASSIGN, + AdminAuditTargetType.CS_INQUIRY, + String.valueOf(inquiryId), + "CS 문의 담당자 지정", + before, + CsInquirySummaryResponse.from(inquiry), + httpServletRequest + ); + + return after; + } + + /// 문의 상태 변경 + @Transactional + @Override + public CsInquiryDetailResponse updateStatus( + Long inquiryId, + CsInquiryStatus status, + UUID adminId, + HttpServletRequest httpServletRequest + ) { + adminSessionService.loadAdmin(adminId); + + CsInquiry inquiry = loadInquiry(inquiryId); + CsInquirySummaryResponse before = CsInquirySummaryResponse.from(inquiry); + inquiry.changeStatus(status); + CsInquiryDetailResponse after = toDetail(inquiry, true); + + adminAuditLogService.log( + adminId, + AdminAuditActionType.STATUS_CHANGE, + AdminAuditTargetType.CS_INQUIRY, + String.valueOf(inquiryId), + "CS 문의 상태 변경", + before, + CsInquirySummaryResponse.from(inquiry), + httpServletRequest + ); + + return after; + } + + /// 관리자 답변 등록 + @Transactional + @Override + public CsInquiryDetailResponse addAdminMessage( + Long inquiryId, + UUID adminId, + CreateCsInquiryMessageRequest request, + HttpServletRequest httpServletRequest + ) { + adminSessionService.loadAdmin(adminId); + + CsInquiry inquiry = loadInquiry(inquiryId); + int beforeCount = messageRepository.findByInquiry_IdOrderByCreatedAtAsc(inquiryId).size(); + + messageRepository.save(CsInquiryMessage.of(inquiry, CsMessageSenderType.ADMIN, adminId, request.content())); + inquiry.markAdminResponse(); + CsInquiryDetailResponse after = toDetail(inquiry, true); + + adminAuditLogService.log( + adminId, + AdminAuditActionType.REPLY, + AdminAuditTargetType.CS_INQUIRY, + String.valueOf(inquiryId), + "CS 문의 답변 등록", + java.util.Map.of("messageCount", beforeCount), + java.util.Map.of("messageCount", beforeCount + 1), + httpServletRequest + ); + + return after; + } + + // ================= + // 내부 로직 + // ================= + + /// 문의 검색 조건 Specification 생성 + private Specification buildSpecification(CsInquiryStatus status, CsInquiryCategory category) { + return (root, query, criteriaBuilder) -> { + List predicates = new java.util.ArrayList<>(); + if (status != null) { + predicates.add(criteriaBuilder.equal(root.get("status"), status)); + } + if (category != null) { + predicates.add(criteriaBuilder.equal(root.get("category"), category)); + } + return criteriaBuilder.and(predicates.toArray(Predicate[]::new)); + }; + } + + /// 문의 단건 조회 + private CsInquiry loadInquiry(Long inquiryId) { + return inquiryRepository.findById(inquiryId) + .orElseThrow(() -> new CustomException(CsErrorCode.NOT_FOUND_INQUIRY)); + } + + /// 사용자 단건 조회 + private User loadUser(UUID userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new CustomException(UserErrorCode.NOT_FOUND_USER)); + } + + /// 문의 소유자 검증 + private void validateOwner(CsInquiry inquiry, UUID userId) { + if (!inquiry.getUser().getId().equals(userId)) { + throw new CustomException(CsErrorCode.FORBIDDEN_INQUIRY_ACCESS); + } + } + + /// 문의 상세 응답 DTO 변환 + private CsInquiryDetailResponse toDetail(CsInquiry inquiry, boolean includeRequester) { + List messages = messageRepository.findByInquiry_IdOrderByCreatedAtAsc(inquiry.getId()) + .stream() + .map(CsInquiryMessageResponse::from) + .toList(); + + CsInquiryRequesterResponse requester = includeRequester + ? CsInquiryRequesterResponse.builder() + .userId(inquiry.getUser().getId()) + .maskedName(maskName(inquiry.getUser().getName())) + .maskedEmail(maskEmail(inquiry.getUser().getEmail())) + .build() + : null; + + return CsInquiryDetailResponse.of(inquiry, requester, messages); + } + + /// 이름 마스킹 + private String maskName(String name) { + if (name == null || name.isBlank()) { + return null; + } + if (name.length() == 1) { + return "*"; + } + if (name.length() == 2) { + return name.charAt(0) + "*"; + } + return name.charAt(0) + "*" + name.charAt(name.length() - 1); + } + + /// 이메일 마스킹 + private String maskEmail(String email) { + if (email == null || email.isBlank() || !email.contains("@")) { + return null; + } + String[] parts = email.split("@", 2); + String local = parts[0]; + String domain = parts[1]; + + if (local.length() <= 2) { + return local.charAt(0) + "***@" + domain; + } + return local.substring(0, 2) + "***@" + domain; + } +} diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/cs/application/usecase/CsInquiryUseCase.java b/module-domain/src/main/java/co/kr/pinhouse/domain/cs/application/usecase/CsInquiryUseCase.java new file mode 100644 index 00000000..acac1896 --- /dev/null +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/cs/application/usecase/CsInquiryUseCase.java @@ -0,0 +1,74 @@ +package co.kr.pinhouse.domain.cs.application.usecase; + +import java.util.UUID; + +import co.kr.pinhouse.common.response.pageable.SliceRequest; +import co.kr.pinhouse.common.response.pageable.SliceResponse; +import co.kr.pinhouse.domain.cs.application.dto.request.CreateCsInquiryMessageRequest; +import co.kr.pinhouse.domain.cs.application.dto.request.CreateCsInquiryRequest; +import co.kr.pinhouse.domain.cs.application.dto.response.CsInquiryDetailResponse; +import co.kr.pinhouse.domain.cs.application.dto.response.CsInquirySummaryResponse; +import co.kr.pinhouse.domain.cs.domain.entity.CsInquiryCategory; +import co.kr.pinhouse.domain.cs.domain.entity.CsInquiryStatus; +import jakarta.servlet.http.HttpServletRequest; + +public interface CsInquiryUseCase { + + // ================= + // 사용자 로직 + // ================= + + /// 사용자 문의 생성 + CsInquiryDetailResponse createInquiry(UUID userId, CreateCsInquiryRequest request); + + /// 내 문의 목록 조회 + SliceResponse getMyInquiries(UUID userId, SliceRequest sliceRequest); + + /// 내 문의 상세 조회 + CsInquiryDetailResponse getMyInquiry(UUID userId, Long inquiryId); + + /// 사용자 추가 메시지 등록 + CsInquiryDetailResponse addUserMessage( + UUID userId, + Long inquiryId, + CreateCsInquiryMessageRequest request + ); + + // ================= + // 관리자 로직 + // ================= + + /// 관리자 문의 목록 조회 + SliceResponse getAdminInquiries( + CsInquiryStatus status, + CsInquiryCategory category, + SliceRequest sliceRequest + ); + + /// 관리자 문의 상세 조회 + CsInquiryDetailResponse getAdminInquiry(Long inquiryId); + + /// 문의 담당 관리자 지정 + CsInquiryDetailResponse assignInquiry( + Long inquiryId, + UUID assigneeAdminId, + UUID actorAdminId, + HttpServletRequest httpServletRequest + ); + + /// 문의 상태 변경 + CsInquiryDetailResponse updateStatus( + Long inquiryId, + CsInquiryStatus status, + UUID adminId, + HttpServletRequest httpServletRequest + ); + + /// 관리자 답변 등록 + CsInquiryDetailResponse addAdminMessage( + Long inquiryId, + UUID adminId, + CreateCsInquiryMessageRequest request, + HttpServletRequest httpServletRequest + ); +} diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/cs/domain/entity/CsInquiry.java b/module-domain/src/main/java/co/kr/pinhouse/domain/cs/domain/entity/CsInquiry.java new file mode 100644 index 00000000..23fefb94 --- /dev/null +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/cs/domain/entity/CsInquiry.java @@ -0,0 +1,122 @@ +package co.kr.pinhouse.domain.cs.domain.entity; + +import java.time.LocalDateTime; +import java.util.UUID; + +import co.kr.pinhouse.domain.BaseTimeEntity; +import co.kr.pinhouse.domain.user.domain.entity.User; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "cs_inquiries") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class CsInquiry extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 50) + private CsInquiryCategory category; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 50) + private CsInquiryStatus status; + + @Column(nullable = false) + private String title; + + @Column(name = "assigned_admin_id", columnDefinition = "BINARY(16)") + private UUID assignedAdminId; + + @Column(name = "first_responded_at") + private LocalDateTime firstRespondedAt; + + @Column(name = "resolved_at") + private LocalDateTime resolvedAt; + + @Column(name = "last_message_at", nullable = false) + private LocalDateTime lastMessageAt; + + @Builder + protected CsInquiry( + User user, + CsInquiryCategory category, + CsInquiryStatus status, + String title, + UUID assignedAdminId, + LocalDateTime firstRespondedAt, + LocalDateTime resolvedAt, + LocalDateTime lastMessageAt + ) { + this.user = user; + this.category = category; + this.status = status; + this.title = title; + this.assignedAdminId = assignedAdminId; + this.firstRespondedAt = firstRespondedAt; + this.resolvedAt = resolvedAt; + this.lastMessageAt = lastMessageAt; + } + + public static CsInquiry create(User user, CsInquiryCategory category, String title) { + return CsInquiry.builder() + .user(user) + .category(category) + .status(CsInquiryStatus.RECEIVED) + .title(title) + .lastMessageAt(LocalDateTime.now()) + .build(); + } + + public void assignAdmin(UUID adminId) { + this.assignedAdminId = adminId; + } + + public void changeStatus(CsInquiryStatus status) { + this.status = status; + if (status == CsInquiryStatus.RESOLVED || status == CsInquiryStatus.CLOSED) { + this.resolvedAt = LocalDateTime.now(); + } else { + this.resolvedAt = null; + } + } + + public void markUserMessage() { + this.lastMessageAt = LocalDateTime.now(); + if (this.status == CsInquiryStatus.WAITING_USER) { + this.status = CsInquiryStatus.IN_PROGRESS; + } + } + + public void markAdminResponse() { + LocalDateTime now = LocalDateTime.now(); + if (this.firstRespondedAt == null) { + this.firstRespondedAt = now; + } + this.lastMessageAt = now; + if (this.status == CsInquiryStatus.RECEIVED || this.status == CsInquiryStatus.WAITING_USER) { + this.status = CsInquiryStatus.IN_PROGRESS; + } + } +} diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/cs/domain/entity/CsInquiryCategory.java b/module-domain/src/main/java/co/kr/pinhouse/domain/cs/domain/entity/CsInquiryCategory.java new file mode 100644 index 00000000..fbc61879 --- /dev/null +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/cs/domain/entity/CsInquiryCategory.java @@ -0,0 +1,10 @@ +package co.kr.pinhouse.domain.cs.domain.entity; + +public enum CsInquiryCategory { + ACCOUNT, + NOTICE, + PAYMENT, + BUG, + ADVERTISEMENT, + OTHER +} diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/cs/domain/entity/CsInquiryMessage.java b/module-domain/src/main/java/co/kr/pinhouse/domain/cs/domain/entity/CsInquiryMessage.java new file mode 100644 index 00000000..325e8346 --- /dev/null +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/cs/domain/entity/CsInquiryMessage.java @@ -0,0 +1,72 @@ +package co.kr.pinhouse.domain.cs.domain.entity; + +import java.util.UUID; + +import co.kr.pinhouse.domain.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "cs_inquiry_messages") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class CsInquiryMessage extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "inquiry_id", nullable = false) + private CsInquiry inquiry; + + @Enumerated(EnumType.STRING) + @Column(name = "sender_type", nullable = false, length = 30) + private CsMessageSenderType senderType; + + @Column(name = "sender_id", nullable = false, columnDefinition = "BINARY(16)") + private UUID senderId; + + @Column(nullable = false, columnDefinition = "TEXT") + private String content; + + @Builder + protected CsInquiryMessage( + CsInquiry inquiry, + CsMessageSenderType senderType, + UUID senderId, + String content + ) { + this.inquiry = inquiry; + this.senderType = senderType; + this.senderId = senderId; + this.content = content; + } + + public static CsInquiryMessage of( + CsInquiry inquiry, + CsMessageSenderType senderType, + UUID senderId, + String content + ) { + return CsInquiryMessage.builder() + .inquiry(inquiry) + .senderType(senderType) + .senderId(senderId) + .content(content) + .build(); + } +} diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/cs/domain/entity/CsInquiryStatus.java b/module-domain/src/main/java/co/kr/pinhouse/domain/cs/domain/entity/CsInquiryStatus.java new file mode 100644 index 00000000..ed67e761 --- /dev/null +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/cs/domain/entity/CsInquiryStatus.java @@ -0,0 +1,9 @@ +package co.kr.pinhouse.domain.cs.domain.entity; + +public enum CsInquiryStatus { + RECEIVED, + IN_PROGRESS, + WAITING_USER, + RESOLVED, + CLOSED +} diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/cs/domain/entity/CsMessageSenderType.java b/module-domain/src/main/java/co/kr/pinhouse/domain/cs/domain/entity/CsMessageSenderType.java new file mode 100644 index 00000000..83ee45a4 --- /dev/null +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/cs/domain/entity/CsMessageSenderType.java @@ -0,0 +1,6 @@ +package co.kr.pinhouse.domain.cs.domain.entity; + +public enum CsMessageSenderType { + USER, + ADMIN +} diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/cs/domain/repository/CsInquiryMessageRepository.java b/module-domain/src/main/java/co/kr/pinhouse/domain/cs/domain/repository/CsInquiryMessageRepository.java new file mode 100644 index 00000000..0d67fb25 --- /dev/null +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/cs/domain/repository/CsInquiryMessageRepository.java @@ -0,0 +1,12 @@ +package co.kr.pinhouse.domain.cs.domain.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; + +import co.kr.pinhouse.domain.cs.domain.entity.CsInquiryMessage; + +public interface CsInquiryMessageRepository extends JpaRepository { + + List findByInquiry_IdOrderByCreatedAtAsc(Long inquiryId); +} diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/cs/domain/repository/CsInquiryRepository.java b/module-domain/src/main/java/co/kr/pinhouse/domain/cs/domain/repository/CsInquiryRepository.java new file mode 100644 index 00000000..81f40285 --- /dev/null +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/cs/domain/repository/CsInquiryRepository.java @@ -0,0 +1,17 @@ +package co.kr.pinhouse.domain.cs.domain.repository; + +import java.util.UUID; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; + +import co.kr.pinhouse.domain.cs.domain.entity.CsInquiry; + +public interface CsInquiryRepository extends JpaRepository, JpaSpecificationExecutor { + + Page findByUser_IdOrderByCreatedAtDesc(UUID userId, Pageable pageable); + + long countByUser_Id(UUID userId); +} diff --git a/module-presentation/src/main/java/co/kr/pinhouse/domain/cs/CsInquiryApi.java b/module-presentation/src/main/java/co/kr/pinhouse/domain/cs/CsInquiryApi.java new file mode 100644 index 00000000..7eeed33e --- /dev/null +++ b/module-presentation/src/main/java/co/kr/pinhouse/domain/cs/CsInquiryApi.java @@ -0,0 +1,79 @@ +package co.kr.pinhouse.domain.cs; + +import java.util.UUID; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import co.kr.pinhouse.common.auth.CurrentUserId; +import co.kr.pinhouse.common.response.ApiResponse; +import co.kr.pinhouse.common.response.pageable.SliceRequest; +import co.kr.pinhouse.common.response.pageable.SliceResponse; +import co.kr.pinhouse.domain.cs.application.dto.request.CreateCsInquiryMessageRequest; +import co.kr.pinhouse.domain.cs.application.dto.request.CreateCsInquiryRequest; +import co.kr.pinhouse.domain.cs.application.dto.response.CsInquiryDetailResponse; +import co.kr.pinhouse.domain.cs.application.dto.response.CsInquirySummaryResponse; +import co.kr.pinhouse.domain.cs.application.usecase.CsInquiryUseCase; +import co.kr.pinhouse.security.aop.CheckLogin; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/v1/cs/inquiries") +@RequiredArgsConstructor +@Tag(name = "CS 문의 API", description = "사용자 문의 생성/조회 API") +public class CsInquiryApi { + + private final CsInquiryUseCase csInquiryService; + + /// 문의 생성 + @PostMapping + @CheckLogin + @Operation(summary = "문의 생성", description = "사용자 문의와 첫 메시지를 생성합니다.") + public ApiResponse createInquiry( + @RequestBody @Valid CreateCsInquiryRequest request, + @CurrentUserId(required = true) UUID userId + ) { + return ApiResponse.ok(csInquiryService.createInquiry(userId, request)); + } + + /// 내 문의 목록 조회 + @GetMapping + @CheckLogin + @Operation(summary = "내 문의 목록", description = "로그인한 사용자의 문의 목록을 조회합니다.") + public ApiResponse> getMyInquiries( + SliceRequest sliceRequest, + @CurrentUserId(required = true) UUID userId + ) { + return ApiResponse.ok(csInquiryService.getMyInquiries(userId, sliceRequest)); + } + + /// 내 문의 상세 조회 + @GetMapping("/{inquiryId}") + @CheckLogin + @Operation(summary = "내 문의 상세", description = "로그인한 사용자의 문의 상세를 조회합니다.") + public ApiResponse getMyInquiry( + @PathVariable Long inquiryId, + @CurrentUserId(required = true) UUID userId + ) { + return ApiResponse.ok(csInquiryService.getMyInquiry(userId, inquiryId)); + } + + /// 문의 스레드에 사용자 메시지 추가 + @PostMapping("/{inquiryId}/messages") + @CheckLogin + @Operation(summary = "문의 추가 메시지", description = "문의 스레드에 사용자의 추가 메시지를 등록합니다.") + public ApiResponse addMessage( + @PathVariable Long inquiryId, + @RequestBody @Valid CreateCsInquiryMessageRequest request, + @CurrentUserId(required = true) UUID userId + ) { + return ApiResponse.ok(csInquiryService.addUserMessage(userId, inquiryId, request)); + } +} From f5d805b33b4fbca2495c0a0d6cde2c922ce595e5 Mon Sep 17 00:00:00 2001 From: eedo_y Date: Tue, 21 Apr 2026 15:34:21 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20=EA=B4=91=EA=B3=A0=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EB=82=B4=EC=9A=A9=20=EA=B5=AC=ED=98=84=20(#129)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/exception/code/AdErrorCode.java | 19 ++ .../request/CreateAdvertisementRequest.java | 20 ++ .../RecordAdvertisementEventRequest.java | 10 + .../request/UpdateAdvertisementRequest.java | 18 ++ .../UpdateAdvertisementStatusRequest.java | 9 + .../AdvertisementRuntimeResponse.java | 25 ++ .../service/AdvertisementService.java | 257 ++++++++++++++++++ .../usecase/AdvertisementUseCase.java | 66 +++++ .../ad/domain/entity/Advertisement.java | 154 +++++++++++ .../ad/domain/entity/AdvertisementEvent.java | 78 ++++++ .../domain/entity/AdvertisementEventType.java | 6 + .../domain/entity/AdvertisementLinkType.java | 7 + .../domain/entity/AdvertisementPlacement.java | 7 + .../ad/domain/entity/AdvertisementStatus.java | 8 + .../AdvertisementEventRepository.java | 11 + .../repository/AdvertisementRepository.java | 21 ++ .../dto/request/AssignCsInquiryRequest.java | 10 + .../java/co/kr/pinhouse/domain/ad/AdApi.java | 53 ++++ 18 files changed, 779 insertions(+) create mode 100644 module-common/src/main/java/co/kr/pinhouse/common/exception/code/AdErrorCode.java create mode 100644 module-domain/src/main/java/co/kr/pinhouse/domain/ad/application/dto/request/CreateAdvertisementRequest.java create mode 100644 module-domain/src/main/java/co/kr/pinhouse/domain/ad/application/dto/request/RecordAdvertisementEventRequest.java create mode 100644 module-domain/src/main/java/co/kr/pinhouse/domain/ad/application/dto/request/UpdateAdvertisementRequest.java create mode 100644 module-domain/src/main/java/co/kr/pinhouse/domain/ad/application/dto/request/UpdateAdvertisementStatusRequest.java create mode 100644 module-domain/src/main/java/co/kr/pinhouse/domain/ad/application/dto/response/AdvertisementRuntimeResponse.java create mode 100644 module-domain/src/main/java/co/kr/pinhouse/domain/ad/application/service/AdvertisementService.java create mode 100644 module-domain/src/main/java/co/kr/pinhouse/domain/ad/application/usecase/AdvertisementUseCase.java create mode 100644 module-domain/src/main/java/co/kr/pinhouse/domain/ad/domain/entity/Advertisement.java create mode 100644 module-domain/src/main/java/co/kr/pinhouse/domain/ad/domain/entity/AdvertisementEvent.java create mode 100644 module-domain/src/main/java/co/kr/pinhouse/domain/ad/domain/entity/AdvertisementEventType.java create mode 100644 module-domain/src/main/java/co/kr/pinhouse/domain/ad/domain/entity/AdvertisementLinkType.java create mode 100644 module-domain/src/main/java/co/kr/pinhouse/domain/ad/domain/entity/AdvertisementPlacement.java create mode 100644 module-domain/src/main/java/co/kr/pinhouse/domain/ad/domain/entity/AdvertisementStatus.java create mode 100644 module-domain/src/main/java/co/kr/pinhouse/domain/ad/domain/repository/AdvertisementEventRepository.java create mode 100644 module-domain/src/main/java/co/kr/pinhouse/domain/ad/domain/repository/AdvertisementRepository.java create mode 100644 module-domain/src/main/java/co/kr/pinhouse/domain/cs/application/dto/request/AssignCsInquiryRequest.java create mode 100644 module-presentation/src/main/java/co/kr/pinhouse/domain/ad/AdApi.java diff --git a/module-common/src/main/java/co/kr/pinhouse/common/exception/code/AdErrorCode.java b/module-common/src/main/java/co/kr/pinhouse/common/exception/code/AdErrorCode.java new file mode 100644 index 00000000..449e7485 --- /dev/null +++ b/module-common/src/main/java/co/kr/pinhouse/common/exception/code/AdErrorCode.java @@ -0,0 +1,19 @@ +package co.kr.pinhouse.common.exception.code; + +import org.springframework.http.HttpStatus; + +import co.kr.pinhouse.common.response.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum AdErrorCode implements ErrorCode { + + NOT_FOUND_ADVERTISEMENT(404_400, HttpStatus.NOT_FOUND, "해당 광고를 찾을 수 없습니다."), + BAD_REQUEST_AD_SCHEDULE(400_400, HttpStatus.BAD_REQUEST, "광고 노출 일정이 올바르지 않습니다."); + + private final Integer code; + private final HttpStatus httpStatus; + private final String message; +} diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/ad/application/dto/request/CreateAdvertisementRequest.java b/module-domain/src/main/java/co/kr/pinhouse/domain/ad/application/dto/request/CreateAdvertisementRequest.java new file mode 100644 index 00000000..aa75d4bc --- /dev/null +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/ad/application/dto/request/CreateAdvertisementRequest.java @@ -0,0 +1,20 @@ +package co.kr.pinhouse.domain.ad.application.dto.request; + +import java.time.LocalDateTime; + +import co.kr.pinhouse.domain.ad.domain.entity.AdvertisementLinkType; +import co.kr.pinhouse.domain.ad.domain.entity.AdvertisementPlacement; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record CreateAdvertisementRequest( + @NotBlank String title, + @NotNull AdvertisementPlacement placement, + @NotBlank String imageUrl, + @NotNull AdvertisementLinkType linkType, + String linkValue, + LocalDateTime startAt, + LocalDateTime endAt, + Integer priority +) { +} diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/ad/application/dto/request/RecordAdvertisementEventRequest.java b/module-domain/src/main/java/co/kr/pinhouse/domain/ad/application/dto/request/RecordAdvertisementEventRequest.java new file mode 100644 index 00000000..39109b8c --- /dev/null +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/ad/application/dto/request/RecordAdvertisementEventRequest.java @@ -0,0 +1,10 @@ +package co.kr.pinhouse.domain.ad.application.dto.request; + +import co.kr.pinhouse.domain.ad.domain.entity.AdvertisementEventType; +import jakarta.validation.constraints.NotNull; + +public record RecordAdvertisementEventRequest( + @NotNull Long advertisementId, + @NotNull AdvertisementEventType eventType +) { +} diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/ad/application/dto/request/UpdateAdvertisementRequest.java b/module-domain/src/main/java/co/kr/pinhouse/domain/ad/application/dto/request/UpdateAdvertisementRequest.java new file mode 100644 index 00000000..794e1357 --- /dev/null +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/ad/application/dto/request/UpdateAdvertisementRequest.java @@ -0,0 +1,18 @@ +package co.kr.pinhouse.domain.ad.application.dto.request; + +import java.time.LocalDateTime; + +import co.kr.pinhouse.domain.ad.domain.entity.AdvertisementLinkType; +import co.kr.pinhouse.domain.ad.domain.entity.AdvertisementPlacement; + +public record UpdateAdvertisementRequest( + String title, + AdvertisementPlacement placement, + String imageUrl, + AdvertisementLinkType linkType, + String linkValue, + LocalDateTime startAt, + LocalDateTime endAt, + Integer priority +) { +} diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/ad/application/dto/request/UpdateAdvertisementStatusRequest.java b/module-domain/src/main/java/co/kr/pinhouse/domain/ad/application/dto/request/UpdateAdvertisementStatusRequest.java new file mode 100644 index 00000000..1b440440 --- /dev/null +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/ad/application/dto/request/UpdateAdvertisementStatusRequest.java @@ -0,0 +1,9 @@ +package co.kr.pinhouse.domain.ad.application.dto.request; + +import co.kr.pinhouse.domain.ad.domain.entity.AdvertisementStatus; +import jakarta.validation.constraints.NotNull; + +public record UpdateAdvertisementStatusRequest( + @NotNull AdvertisementStatus status +) { +} diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/ad/application/dto/response/AdvertisementRuntimeResponse.java b/module-domain/src/main/java/co/kr/pinhouse/domain/ad/application/dto/response/AdvertisementRuntimeResponse.java new file mode 100644 index 00000000..12a4cdfd --- /dev/null +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/ad/application/dto/response/AdvertisementRuntimeResponse.java @@ -0,0 +1,25 @@ +package co.kr.pinhouse.domain.ad.application.dto.response; + +import co.kr.pinhouse.domain.ad.domain.entity.Advertisement; +import co.kr.pinhouse.domain.ad.domain.entity.AdvertisementLinkType; +import lombok.Builder; + +@Builder +public record AdvertisementRuntimeResponse( + Long advertisementId, + String title, + String imageUrl, + AdvertisementLinkType linkType, + String linkValue +) { + + public static AdvertisementRuntimeResponse from(Advertisement advertisement) { + return AdvertisementRuntimeResponse.builder() + .advertisementId(advertisement.getId()) + .title(advertisement.getTitle()) + .imageUrl(advertisement.getImageUrl()) + .linkType(advertisement.getLinkType()) + .linkValue(advertisement.getLinkValue()) + .build(); + } +} diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/ad/application/service/AdvertisementService.java b/module-domain/src/main/java/co/kr/pinhouse/domain/ad/application/service/AdvertisementService.java new file mode 100644 index 00000000..4b7cfb73 --- /dev/null +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/ad/application/service/AdvertisementService.java @@ -0,0 +1,257 @@ +package co.kr.pinhouse.domain.ad.application.service; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import co.kr.pinhouse.common.exception.code.AdErrorCode; +import co.kr.pinhouse.common.response.CustomException; +import co.kr.pinhouse.common.response.pageable.SliceRequest; +import co.kr.pinhouse.common.response.pageable.SliceResponse; +import co.kr.pinhouse.domain.ad.application.dto.request.CreateAdvertisementRequest; +import co.kr.pinhouse.domain.ad.application.dto.request.RecordAdvertisementEventRequest; +import co.kr.pinhouse.domain.ad.application.dto.request.UpdateAdvertisementRequest; +import co.kr.pinhouse.domain.ad.application.dto.response.AdminAdvertisementResponse; +import co.kr.pinhouse.domain.ad.application.dto.response.AdminAdvertisementSummaryResponse; +import co.kr.pinhouse.domain.ad.application.dto.response.AdvertisementRuntimeResponse; +import co.kr.pinhouse.domain.ad.application.usecase.AdvertisementUseCase; +import co.kr.pinhouse.domain.ad.domain.entity.Advertisement; +import co.kr.pinhouse.domain.ad.domain.entity.AdvertisementEvent; +import co.kr.pinhouse.domain.ad.domain.entity.AdvertisementEventType; +import co.kr.pinhouse.domain.ad.domain.entity.AdvertisementPlacement; +import co.kr.pinhouse.domain.ad.domain.entity.AdvertisementStatus; +import co.kr.pinhouse.domain.ad.domain.repository.AdvertisementEventRepository; +import co.kr.pinhouse.domain.ad.domain.repository.AdvertisementRepository; +import co.kr.pinhouse.domain.admin.application.usecase.AdminSessionUseCase; +import co.kr.pinhouse.domain.admin.audit.application.usecase.AdminAuditLogUseCase; +import co.kr.pinhouse.domain.admin.audit.domain.entity.AdminAuditActionType; +import co.kr.pinhouse.domain.admin.audit.domain.entity.AdminAuditTargetType; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class AdvertisementService implements AdvertisementUseCase { + + private final AdvertisementRepository advertisementRepository; + private final AdvertisementEventRepository advertisementEventRepository; + private final AdminSessionUseCase adminSessionService; + private final AdminAuditLogUseCase adminAuditLogService; + + // ================= + // 관리자 로직 + // ================= + + /// 관리자 광고 목록 조회 + @Transactional(readOnly = true) + @Override + public SliceResponse getAdminAdvertisements(SliceRequest sliceRequest) { + var pageable = PageRequest.of(sliceRequest.page() - 1, sliceRequest.offSet(), + Sort.by(Sort.Order.desc("createdAt"), Sort.Order.desc("id"))); + var page = advertisementRepository.findAllByOrderByCreatedAtDesc(pageable); + + return SliceResponse.from(page.map(AdminAdvertisementSummaryResponse::from), page.getTotalElements()); + } + + /// 관리자 광고 상세 조회 + @Transactional(readOnly = true) + @Override + public AdminAdvertisementResponse getAdminAdvertisement(Long advertisementId) { + Advertisement advertisement = loadAdvertisement(advertisementId); + return toAdminResponse(advertisement); + } + + /// 관리자 광고 생성 + @Transactional + @Override + public AdminAdvertisementResponse createAdvertisement( + CreateAdvertisementRequest request, + UUID adminId, + HttpServletRequest httpServletRequest + ) { + adminSessionService.loadAdmin(adminId); + validateSchedule(request.startAt(), request.endAt()); + + Advertisement advertisement = advertisementRepository.save(Advertisement.create( + request.title(), + request.placement(), + request.imageUrl(), + request.linkType(), + request.linkValue(), + request.startAt(), + request.endAt(), + request.priority() != null ? request.priority() : 0 + )); + + AdminAdvertisementResponse after = toAdminResponse(advertisement); + adminAuditLogService.log( + adminId, + AdminAuditActionType.CREATE, + AdminAuditTargetType.ADVERTISEMENT, + String.valueOf(advertisement.getId()), + "광고 생성", + null, + after, + httpServletRequest + ); + + return after; + } + + /// 관리자 광고 정보 수정 + @Transactional + @Override + public AdminAdvertisementResponse updateAdvertisement( + Long advertisementId, + UpdateAdvertisementRequest request, + UUID adminId, + HttpServletRequest httpServletRequest + ) { + adminSessionService.loadAdmin(adminId); + + Advertisement advertisement = loadAdvertisement(advertisementId); + AdminAdvertisementResponse before = toAdminResponse(advertisement); + LocalDateTime nextStartAt = request.startAt() != null ? request.startAt() : advertisement.getStartAt(); + LocalDateTime nextEndAt = request.endAt() != null ? request.endAt() : advertisement.getEndAt(); + validateSchedule(nextStartAt, nextEndAt); + + advertisement.update( + request.title(), + request.placement(), + request.imageUrl(), + request.linkType(), + request.linkValue(), + request.startAt(), + request.endAt(), + request.priority() + ); + + AdminAdvertisementResponse after = toAdminResponse(advertisement); + adminAuditLogService.log( + adminId, + AdminAuditActionType.UPDATE, + AdminAuditTargetType.ADVERTISEMENT, + String.valueOf(advertisementId), + "광고 수정", + before, + after, + httpServletRequest + ); + + return after; + } + + /// 관리자 광고 상태 변경 + @Transactional + @Override + public AdminAdvertisementResponse updateStatus( + Long advertisementId, + AdvertisementStatus status, + UUID adminId, + HttpServletRequest httpServletRequest + ) { + adminSessionService.loadAdmin(adminId); + + Advertisement advertisement = loadAdvertisement(advertisementId); + AdminAdvertisementResponse before = toAdminResponse(advertisement); + advertisement.changeStatus(status); + AdminAdvertisementResponse after = toAdminResponse(advertisement); + + adminAuditLogService.log( + adminId, + AdminAuditActionType.STATUS_CHANGE, + AdminAuditTargetType.ADVERTISEMENT, + String.valueOf(advertisementId), + "광고 상태 변경", + before, + after, + httpServletRequest + ); + + return after; + } + + // ================= + // 런타임 로직 + // ================= + + /// 노출 위치별 활성 광고 조회 + @Transactional(readOnly = true) + @Override + public List getPlacementAdvertisements(AdvertisementPlacement placement) { + LocalDateTime now = LocalDateTime.now(); + return advertisementRepository.findByPlacementAndStatusOrderByPriorityDescIdDesc(placement, AdvertisementStatus.ACTIVE) + .stream() + .filter(advertisement -> advertisement.isExposedAt(now)) + .map(AdvertisementRuntimeResponse::from) + .toList(); + } + + /// 광고 이벤트 저장 + @Transactional + @Override + public void recordEvent( + RecordAdvertisementEventRequest request, + UUID userId, + HttpServletRequest httpServletRequest + ) { + Advertisement advertisement = loadAdvertisement(request.advertisementId()); + advertisementEventRepository.save(AdvertisementEvent.of( + advertisement, + request.eventType(), + userId, + extractClientIp(httpServletRequest) + )); + } + + // ================= + // 내부 로직 + // ================= + + /// 광고 단건 조회 + private Advertisement loadAdvertisement(Long advertisementId) { + return advertisementRepository.findById(advertisementId) + .orElseThrow(() -> new CustomException(AdErrorCode.NOT_FOUND_ADVERTISEMENT)); + } + + /// 광고 노출 기간 검증 + private void validateSchedule(LocalDateTime startAt, LocalDateTime endAt) { + if (startAt != null && endAt != null && endAt.isBefore(startAt)) { + throw new CustomException(AdErrorCode.BAD_REQUEST_AD_SCHEDULE); + } + } + + /// 관리자 응답 DTO 변환 + private AdminAdvertisementResponse toAdminResponse(Advertisement advertisement) { + long impressionCount = advertisementEventRepository.countByAdvertisement_IdAndEventType( + advertisement.getId(), AdvertisementEventType.IMPRESSION); + long clickCount = advertisementEventRepository.countByAdvertisement_IdAndEventType( + advertisement.getId(), AdvertisementEventType.CLICK); + + return AdminAdvertisementResponse.of(advertisement, impressionCount, clickCount); + } + + /// 요청 IP 추출 + private String extractClientIp(HttpServletRequest request) { + if (request == null) { + return null; + } + + String forwardedFor = request.getHeader("X-Forwarded-For"); + if (forwardedFor != null && !forwardedFor.isBlank() && !"unknown".equalsIgnoreCase(forwardedFor)) { + return forwardedFor.split(",")[0].trim(); + } + + String realIp = request.getHeader("X-Real-IP"); + if (realIp != null && !realIp.isBlank() && !"unknown".equalsIgnoreCase(realIp)) { + return realIp; + } + + return request.getRemoteAddr(); + } +} diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/ad/application/usecase/AdvertisementUseCase.java b/module-domain/src/main/java/co/kr/pinhouse/domain/ad/application/usecase/AdvertisementUseCase.java new file mode 100644 index 00000000..1a58e658 --- /dev/null +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/ad/application/usecase/AdvertisementUseCase.java @@ -0,0 +1,66 @@ +package co.kr.pinhouse.domain.ad.application.usecase; + +import java.util.List; +import java.util.UUID; + +import co.kr.pinhouse.common.response.pageable.SliceRequest; +import co.kr.pinhouse.common.response.pageable.SliceResponse; +import co.kr.pinhouse.domain.ad.application.dto.request.CreateAdvertisementRequest; +import co.kr.pinhouse.domain.ad.application.dto.request.RecordAdvertisementEventRequest; +import co.kr.pinhouse.domain.ad.application.dto.request.UpdateAdvertisementRequest; +import co.kr.pinhouse.domain.ad.application.dto.response.AdminAdvertisementResponse; +import co.kr.pinhouse.domain.ad.application.dto.response.AdminAdvertisementSummaryResponse; +import co.kr.pinhouse.domain.ad.application.dto.response.AdvertisementRuntimeResponse; +import co.kr.pinhouse.domain.ad.domain.entity.AdvertisementPlacement; +import co.kr.pinhouse.domain.ad.domain.entity.AdvertisementStatus; +import jakarta.servlet.http.HttpServletRequest; + +public interface AdvertisementUseCase { + + // ================= + // 관리자 로직 + // ================= + + /// 관리자 광고 목록 조회 + SliceResponse getAdminAdvertisements(SliceRequest sliceRequest); + + /// 관리자 광고 상세 조회 + AdminAdvertisementResponse getAdminAdvertisement(Long advertisementId); + + /// 관리자 광고 생성 + AdminAdvertisementResponse createAdvertisement( + CreateAdvertisementRequest request, + UUID adminId, + HttpServletRequest httpServletRequest + ); + + /// 관리자 광고 정보 수정 + AdminAdvertisementResponse updateAdvertisement( + Long advertisementId, + UpdateAdvertisementRequest request, + UUID adminId, + HttpServletRequest httpServletRequest + ); + + /// 관리자 광고 상태 변경 + AdminAdvertisementResponse updateStatus( + Long advertisementId, + AdvertisementStatus status, + UUID adminId, + HttpServletRequest httpServletRequest + ); + + // ================= + // 런타임 로직 + // ================= + + /// 노출 위치별 활성 광고 조회 + List getPlacementAdvertisements(AdvertisementPlacement placement); + + /// 광고 이벤트 기록 + void recordEvent( + RecordAdvertisementEventRequest request, + UUID userId, + HttpServletRequest httpServletRequest + ); +} diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/ad/domain/entity/Advertisement.java b/module-domain/src/main/java/co/kr/pinhouse/domain/ad/domain/entity/Advertisement.java new file mode 100644 index 00000000..be2d34a0 --- /dev/null +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/ad/domain/entity/Advertisement.java @@ -0,0 +1,154 @@ +package co.kr.pinhouse.domain.ad.domain.entity; + +import java.time.LocalDateTime; + +import co.kr.pinhouse.domain.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "advertisements") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Advertisement extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String title; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 30) + private AdvertisementStatus status; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 50) + private AdvertisementPlacement placement; + + @Column(nullable = false) + private String imageUrl; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 30) + private AdvertisementLinkType linkType; + + private String linkValue; + + private LocalDateTime startAt; + + private LocalDateTime endAt; + + @Column(nullable = false) + private int priority; + + @Builder + protected Advertisement( + String title, + AdvertisementStatus status, + AdvertisementPlacement placement, + String imageUrl, + AdvertisementLinkType linkType, + String linkValue, + LocalDateTime startAt, + LocalDateTime endAt, + int priority + ) { + this.title = title; + this.status = status; + this.placement = placement; + this.imageUrl = imageUrl; + this.linkType = linkType; + this.linkValue = linkValue; + this.startAt = startAt; + this.endAt = endAt; + this.priority = priority; + } + + public static Advertisement create( + String title, + AdvertisementPlacement placement, + String imageUrl, + AdvertisementLinkType linkType, + String linkValue, + LocalDateTime startAt, + LocalDateTime endAt, + int priority + ) { + return Advertisement.builder() + .title(title) + .status(AdvertisementStatus.DRAFT) + .placement(placement) + .imageUrl(imageUrl) + .linkType(linkType) + .linkValue(linkValue) + .startAt(startAt) + .endAt(endAt) + .priority(priority) + .build(); + } + + public void update( + String title, + AdvertisementPlacement placement, + String imageUrl, + AdvertisementLinkType linkType, + String linkValue, + LocalDateTime startAt, + LocalDateTime endAt, + Integer priority + ) { + if (title != null) { + this.title = title; + } + if (placement != null) { + this.placement = placement; + } + if (imageUrl != null) { + this.imageUrl = imageUrl; + } + if (linkType != null) { + this.linkType = linkType; + } + if (linkValue != null) { + this.linkValue = linkValue; + } + if (startAt != null) { + this.startAt = startAt; + } + if (endAt != null) { + this.endAt = endAt; + } + if (priority != null) { + this.priority = priority; + } + } + + public void changeStatus(AdvertisementStatus status) { + this.status = status; + } + + public boolean isExposedAt(LocalDateTime now) { + if (status != AdvertisementStatus.ACTIVE) { + return false; + } + if (startAt != null && now.isBefore(startAt)) { + return false; + } + if (endAt != null && now.isAfter(endAt)) { + return false; + } + return true; + } +} diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/ad/domain/entity/AdvertisementEvent.java b/module-domain/src/main/java/co/kr/pinhouse/domain/ad/domain/entity/AdvertisementEvent.java new file mode 100644 index 00000000..4a2d0b07 --- /dev/null +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/ad/domain/entity/AdvertisementEvent.java @@ -0,0 +1,78 @@ +package co.kr.pinhouse.domain.ad.domain.entity; + +import java.time.LocalDateTime; +import java.util.UUID; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "advertisement_events") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class AdvertisementEvent { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "advertisement_id", nullable = false) + private Advertisement advertisement; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 30) + private AdvertisementEventType eventType; + + @Column(name = "user_id", columnDefinition = "BINARY(16)") + private UUID userId; + + @Column(name = "client_ip", length = 100) + private String clientIp; + + @Column(name = "occurred_at", nullable = false) + private LocalDateTime occurredAt; + + @Builder + protected AdvertisementEvent( + Advertisement advertisement, + AdvertisementEventType eventType, + UUID userId, + String clientIp, + LocalDateTime occurredAt + ) { + this.advertisement = advertisement; + this.eventType = eventType; + this.userId = userId; + this.clientIp = clientIp; + this.occurredAt = occurredAt; + } + + public static AdvertisementEvent of( + Advertisement advertisement, + AdvertisementEventType eventType, + UUID userId, + String clientIp + ) { + return AdvertisementEvent.builder() + .advertisement(advertisement) + .eventType(eventType) + .userId(userId) + .clientIp(clientIp) + .occurredAt(LocalDateTime.now()) + .build(); + } +} diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/ad/domain/entity/AdvertisementEventType.java b/module-domain/src/main/java/co/kr/pinhouse/domain/ad/domain/entity/AdvertisementEventType.java new file mode 100644 index 00000000..78577303 --- /dev/null +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/ad/domain/entity/AdvertisementEventType.java @@ -0,0 +1,6 @@ +package co.kr.pinhouse.domain.ad.domain.entity; + +public enum AdvertisementEventType { + IMPRESSION, + CLICK +} diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/ad/domain/entity/AdvertisementLinkType.java b/module-domain/src/main/java/co/kr/pinhouse/domain/ad/domain/entity/AdvertisementLinkType.java new file mode 100644 index 00000000..beb117fc --- /dev/null +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/ad/domain/entity/AdvertisementLinkType.java @@ -0,0 +1,7 @@ +package co.kr.pinhouse.domain.ad.domain.entity; + +public enum AdvertisementLinkType { + INTERNAL, + EXTERNAL, + NOTICE +} diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/ad/domain/entity/AdvertisementPlacement.java b/module-domain/src/main/java/co/kr/pinhouse/domain/ad/domain/entity/AdvertisementPlacement.java new file mode 100644 index 00000000..f1eac548 --- /dev/null +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/ad/domain/entity/AdvertisementPlacement.java @@ -0,0 +1,7 @@ +package co.kr.pinhouse.domain.ad.domain.entity; + +public enum AdvertisementPlacement { + HOME_BANNER, + HOME_POPUP, + NOTICE_DETAIL +} diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/ad/domain/entity/AdvertisementStatus.java b/module-domain/src/main/java/co/kr/pinhouse/domain/ad/domain/entity/AdvertisementStatus.java new file mode 100644 index 00000000..b850fd08 --- /dev/null +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/ad/domain/entity/AdvertisementStatus.java @@ -0,0 +1,8 @@ +package co.kr.pinhouse.domain.ad.domain.entity; + +public enum AdvertisementStatus { + DRAFT, + ACTIVE, + PAUSED, + ENDED +} diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/ad/domain/repository/AdvertisementEventRepository.java b/module-domain/src/main/java/co/kr/pinhouse/domain/ad/domain/repository/AdvertisementEventRepository.java new file mode 100644 index 00000000..ce0949f8 --- /dev/null +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/ad/domain/repository/AdvertisementEventRepository.java @@ -0,0 +1,11 @@ +package co.kr.pinhouse.domain.ad.domain.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import co.kr.pinhouse.domain.ad.domain.entity.AdvertisementEvent; +import co.kr.pinhouse.domain.ad.domain.entity.AdvertisementEventType; + +public interface AdvertisementEventRepository extends JpaRepository { + + long countByAdvertisement_IdAndEventType(Long advertisementId, AdvertisementEventType eventType); +} diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/ad/domain/repository/AdvertisementRepository.java b/module-domain/src/main/java/co/kr/pinhouse/domain/ad/domain/repository/AdvertisementRepository.java new file mode 100644 index 00000000..6ee11b93 --- /dev/null +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/ad/domain/repository/AdvertisementRepository.java @@ -0,0 +1,21 @@ +package co.kr.pinhouse.domain.ad.domain.repository; + +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import co.kr.pinhouse.domain.ad.domain.entity.Advertisement; +import co.kr.pinhouse.domain.ad.domain.entity.AdvertisementPlacement; +import co.kr.pinhouse.domain.ad.domain.entity.AdvertisementStatus; + +public interface AdvertisementRepository extends JpaRepository { + + Page findAllByOrderByCreatedAtDesc(Pageable pageable); + + List findByPlacementAndStatusOrderByPriorityDescIdDesc( + AdvertisementPlacement placement, + AdvertisementStatus status + ); +} diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/cs/application/dto/request/AssignCsInquiryRequest.java b/module-domain/src/main/java/co/kr/pinhouse/domain/cs/application/dto/request/AssignCsInquiryRequest.java new file mode 100644 index 00000000..3a557951 --- /dev/null +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/cs/application/dto/request/AssignCsInquiryRequest.java @@ -0,0 +1,10 @@ +package co.kr.pinhouse.domain.cs.application.dto.request; + +import java.util.UUID; + +import jakarta.validation.constraints.NotNull; + +public record AssignCsInquiryRequest( + @NotNull UUID adminId +) { +} diff --git a/module-presentation/src/main/java/co/kr/pinhouse/domain/ad/AdApi.java b/module-presentation/src/main/java/co/kr/pinhouse/domain/ad/AdApi.java new file mode 100644 index 00000000..8f4ea590 --- /dev/null +++ b/module-presentation/src/main/java/co/kr/pinhouse/domain/ad/AdApi.java @@ -0,0 +1,53 @@ +package co.kr.pinhouse.domain.ad; + +import java.util.List; +import java.util.UUID; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import co.kr.pinhouse.common.auth.CurrentUserId; +import co.kr.pinhouse.common.response.ApiResponse; +import co.kr.pinhouse.domain.ad.application.dto.request.RecordAdvertisementEventRequest; +import co.kr.pinhouse.domain.ad.application.dto.response.AdvertisementRuntimeResponse; +import co.kr.pinhouse.domain.ad.application.usecase.AdvertisementUseCase; +import co.kr.pinhouse.domain.ad.domain.entity.AdvertisementPlacement; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/v1/ads") +@RequiredArgsConstructor +@Tag(name = "광고 Runtime API", description = "광고 노출/이벤트 API") +public class AdApi { + + private final AdvertisementUseCase advertisementService; + + /// 노출 위치별 광고 목록 조회 + @GetMapping("/placements/{placement}") + @Operation(summary = "노출 광고 조회", description = "지정된 placement의 활성 광고를 조회합니다.") + public ApiResponse> getPlacementAdvertisements( + @PathVariable AdvertisementPlacement placement + ) { + return ApiResponse.ok(advertisementService.getPlacementAdvertisements(placement)); + } + + /// 광고 이벤트 기록 + @PostMapping("/events") + @Operation(summary = "광고 이벤트 기록", description = "광고 노출/클릭 이벤트를 기록합니다.") + public ApiResponse recordEvent( + @RequestBody @Valid RecordAdvertisementEventRequest request, + @CurrentUserId UUID userId, + HttpServletRequest httpServletRequest + ) { + advertisementService.recordEvent(request, userId, httpServletRequest); + return ApiResponse.created(); + } +} From ae8a0e29bf5b98d0d65af966cbcfd9504f0c19cf Mon Sep 17 00:00:00 2001 From: eedo_y Date: Tue, 21 Apr 2026 15:34:58 +0900 Subject: [PATCH 5/5] =?UTF-8?q?feat:=20=ED=86=B5=EA=B3=84=20=EB=B0=8F=20?= =?UTF-8?q?=EC=8B=9C=EC=9E=91=20=EC=96=B4=ED=94=8C=EB=A6=AC=EC=BC=80?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EA=B5=AC=EC=84=B1=20(#129)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kr/pinhouse/app/PinHouseApplication.java | 6 +- .../dto/request/UpdateAdminNoticeRequest.java | 11 +++ .../domain/entity/NoticeAdminOverride.java | 93 +++++++++++++++++++ .../NoticeAdminOverrideRepository.java | 16 ++++ .../repository/DiagnosisJpaRepository.java | 2 + .../domain/like/domain/LikeJpaRepository.java | 2 + .../repository/PinPointMongoRepository.java | 2 + .../domain/repository/UserJpaRepository.java | 9 ++ 8 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 module-domain/src/main/java/co/kr/pinhouse/domain/admin/notice/application/dto/request/UpdateAdminNoticeRequest.java create mode 100644 module-domain/src/main/java/co/kr/pinhouse/domain/admin/notice/domain/entity/NoticeAdminOverride.java create mode 100644 module-domain/src/main/java/co/kr/pinhouse/domain/admin/notice/domain/repository/NoticeAdminOverrideRepository.java diff --git a/module-app/src/main/java/co/kr/pinhouse/app/PinHouseApplication.java b/module-app/src/main/java/co/kr/pinhouse/app/PinHouseApplication.java index ac992575..c0aec489 100644 --- a/module-app/src/main/java/co/kr/pinhouse/app/PinHouseApplication.java +++ b/module-app/src/main/java/co/kr/pinhouse/app/PinHouseApplication.java @@ -14,7 +14,11 @@ "co.kr.pinhouse.domain.user.domain.repository", "co.kr.pinhouse.domain.diagnostic.diagnosis.domain.repository", "co.kr.pinhouse.domain.diagnostic.school.domain.repository", - "co.kr.pinhouse.domain.like.domain" + "co.kr.pinhouse.domain.like.domain", + "co.kr.pinhouse.domain.admin.audit.domain.repository", + "co.kr.pinhouse.domain.admin.notice.domain.repository", + "co.kr.pinhouse.domain.cs.domain.repository", + "co.kr.pinhouse.domain.ad.domain.repository" }) @EnableMongoRepositories(basePackages = { "co.kr.pinhouse.domain.housing.complex.domain.repository", diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/admin/notice/application/dto/request/UpdateAdminNoticeRequest.java b/module-domain/src/main/java/co/kr/pinhouse/domain/admin/notice/application/dto/request/UpdateAdminNoticeRequest.java new file mode 100644 index 00000000..5217bb8c --- /dev/null +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/admin/notice/application/dto/request/UpdateAdminNoticeRequest.java @@ -0,0 +1,11 @@ +package co.kr.pinhouse.domain.admin.notice.application.dto.request; + +public record UpdateAdminNoticeRequest( + String displayTitle, + String displayStatus, + String displayThumbnail, + String displayContact, + Boolean hidden, + String adminMemo +) { +} diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/admin/notice/domain/entity/NoticeAdminOverride.java b/module-domain/src/main/java/co/kr/pinhouse/domain/admin/notice/domain/entity/NoticeAdminOverride.java new file mode 100644 index 00000000..d84768f9 --- /dev/null +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/admin/notice/domain/entity/NoticeAdminOverride.java @@ -0,0 +1,93 @@ +package co.kr.pinhouse.domain.admin.notice.domain.entity; + +import co.kr.pinhouse.domain.BaseTimeEntity; +import co.kr.pinhouse.domain.admin.notice.application.dto.request.UpdateAdminNoticeRequest; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "notice_admin_overrides") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class NoticeAdminOverride extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "notice_id", nullable = false, unique = true) + private String noticeId; + + @Column(name = "display_title") + private String displayTitle; + + @Column(name = "display_status") + private String displayStatus; + + @Column(name = "display_thumbnail") + private String displayThumbnail; + + @Column(name = "display_contact") + private String displayContact; + + @Column(name = "is_hidden", nullable = false) + private boolean hidden; + + @Column(name = "admin_memo", columnDefinition = "TEXT") + private String adminMemo; + + @Builder + protected NoticeAdminOverride( + String noticeId, + String displayTitle, + String displayStatus, + String displayThumbnail, + String displayContact, + boolean hidden, + String adminMemo + ) { + this.noticeId = noticeId; + this.displayTitle = displayTitle; + this.displayStatus = displayStatus; + this.displayThumbnail = displayThumbnail; + this.displayContact = displayContact; + this.hidden = hidden; + this.adminMemo = adminMemo; + } + + public static NoticeAdminOverride create(String noticeId) { + return NoticeAdminOverride.builder() + .noticeId(noticeId) + .hidden(false) + .build(); + } + + public void apply(UpdateAdminNoticeRequest request) { + if (request.displayTitle() != null) { + this.displayTitle = request.displayTitle(); + } + if (request.displayStatus() != null) { + this.displayStatus = request.displayStatus(); + } + if (request.displayThumbnail() != null) { + this.displayThumbnail = request.displayThumbnail(); + } + if (request.displayContact() != null) { + this.displayContact = request.displayContact(); + } + if (request.hidden() != null) { + this.hidden = request.hidden(); + } + if (request.adminMemo() != null) { + this.adminMemo = request.adminMemo(); + } + } +} diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/admin/notice/domain/repository/NoticeAdminOverrideRepository.java b/module-domain/src/main/java/co/kr/pinhouse/domain/admin/notice/domain/repository/NoticeAdminOverrideRepository.java new file mode 100644 index 00000000..462916d8 --- /dev/null +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/admin/notice/domain/repository/NoticeAdminOverrideRepository.java @@ -0,0 +1,16 @@ +package co.kr.pinhouse.domain.admin.notice.domain.repository; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import co.kr.pinhouse.domain.admin.notice.domain.entity.NoticeAdminOverride; + +public interface NoticeAdminOverrideRepository extends JpaRepository { + + Optional findByNoticeId(String noticeId); + + List findByNoticeIdIn(Collection noticeIds); +} diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/diagnosis/domain/repository/DiagnosisJpaRepository.java b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/diagnosis/domain/repository/DiagnosisJpaRepository.java index fd009a02..ecd40cf6 100644 --- a/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/diagnosis/domain/repository/DiagnosisJpaRepository.java +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/diagnostic/diagnosis/domain/repository/DiagnosisJpaRepository.java @@ -31,6 +31,8 @@ public interface DiagnosisJpaRepository extends JpaRepository { */ List findAllByUserOrderByCreatedAtDesc(User user); + long countByUser_Id(UUID userId); + /** * 유저 ID 기반으로 진단 삭제 * @param userId 유저 ID diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/like/domain/LikeJpaRepository.java b/module-domain/src/main/java/co/kr/pinhouse/domain/like/domain/LikeJpaRepository.java index 3533fd94..d4c7a809 100644 --- a/module-domain/src/main/java/co/kr/pinhouse/domain/like/domain/LikeJpaRepository.java +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/like/domain/LikeJpaRepository.java @@ -22,5 +22,7 @@ public interface LikeJpaRepository extends JpaRepository { List findByUser_IdAndType(UUID userId, LikeType type); + long countByUser_Id(UUID userId); + void deleteByUser_Id(UUID userId); } diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/pinpoint/domain/repository/PinPointMongoRepository.java b/module-domain/src/main/java/co/kr/pinhouse/domain/pinpoint/domain/repository/PinPointMongoRepository.java index c56d0770..d863c1a4 100644 --- a/module-domain/src/main/java/co/kr/pinhouse/domain/pinpoint/domain/repository/PinPointMongoRepository.java +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/pinpoint/domain/repository/PinPointMongoRepository.java @@ -12,6 +12,8 @@ public interface PinPointMongoRepository extends MongoRepository findByUserId(String userId); + long countByUserId(String userId); + /// 아이디와 유저ID에 따른 존재 조회 Optional findByIdAndUserId(String id, String userId); diff --git a/module-domain/src/main/java/co/kr/pinhouse/domain/user/domain/repository/UserJpaRepository.java b/module-domain/src/main/java/co/kr/pinhouse/domain/user/domain/repository/UserJpaRepository.java index 6d56e458..5b9ea69e 100644 --- a/module-domain/src/main/java/co/kr/pinhouse/domain/user/domain/repository/UserJpaRepository.java +++ b/module-domain/src/main/java/co/kr/pinhouse/domain/user/domain/repository/UserJpaRepository.java @@ -3,6 +3,8 @@ import java.util.Optional; import java.util.UUID; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @@ -19,4 +21,11 @@ public interface UserJpaRepository extends JpaRepository { @EntityGraph(attributePaths = "facilityTypes") // LAZY 컬렉션을 같이 로딩 Optional findWithFacilityTypesById(UUID id); + + Page findByNameContainingIgnoreCaseOrNicknameContainingIgnoreCaseOrEmailContainingIgnoreCase( + String nameKeyword, + String nicknameKeyword, + String emailKeyword, + Pageable pageable + ); }