From 263637cfaf4298792ebaaa89e1a3acac02c10be3 Mon Sep 17 00:00:00 2001 From: sehi55 Date: Wed, 1 Jul 2026 17:08:46 +0900 Subject: [PATCH 01/13] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EA=B3=B5=EA=B0=9C=20=EC=9A=94=EC=B2=AD=20api=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../matching/controller/PhotoRequestController.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/manabom/src/main/java/mannabom_server/manabom/presentation/matching/controller/PhotoRequestController.java b/manabom/src/main/java/mannabom_server/manabom/presentation/matching/controller/PhotoRequestController.java index 07ecf1b..d8f52de 100644 --- a/manabom/src/main/java/mannabom_server/manabom/presentation/matching/controller/PhotoRequestController.java +++ b/manabom/src/main/java/mannabom_server/manabom/presentation/matching/controller/PhotoRequestController.java @@ -30,6 +30,11 @@ public ResponseEntity> getProfileStatus return ResponseEntity.ok(ApiResponse.success(new LoveViewPhotoStatusResponse(chatRoomId, status, systemMessage), "프로필 사진 요청 상태 조회에 성공했습니다.")); } + @PostMapping("/request/{chatRoomId}") + public ResponseEntity> createPhotoRequest(@PathVariable Long chatRoomId, @AuthenticationPrincipal Long userId){ + photoRequestService.createPhotoRequest(chatRoomId, userId); + return ResponseEntity.ok(ApiResponse.success(null, "프로필 사진 공개를 요청했습니다.")); + } @PatchMapping("/accept/{chatRoomId}") public ResponseEntity> acceptPhotoRequest(@PathVariable Long chatRoomId, @AuthenticationPrincipal Long userId){ @@ -39,7 +44,7 @@ public ResponseEntity> acceptPhotoRequest(@PathVariable Long c @PatchMapping("/reject/{chatRoomId}") public ResponseEntity> rejectPhotoRequest(@PathVariable Long chatRoomId, @AuthenticationPrincipal Long userId){ photoRequestService.rejectPhotoRequest(chatRoomId, userId); - return ResponseEntity.ok(ApiResponse.success(null, "프로필 사진 거절을 수락하였습니다.")); + return ResponseEntity.ok(ApiResponse.success(null, "프로필 사진 요청을 거절하였습니다.")); } From 5d432e2ebfd77641dbca3ef6788288d9187dbad3 Mon Sep 17 00:00:00 2001 From: sehi55 Date: Wed, 1 Jul 2026 17:09:17 +0900 Subject: [PATCH 02/13] =?UTF-8?q?fix:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EA=B3=B5=EA=B0=9C=20=EC=9A=94=EC=B2=AD=20=EC=8B=9C=20ready=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EA=B2=80=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/matching/service/PhotoRequestService.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/manabom/src/main/java/mannabom_server/manabom/application/matching/service/PhotoRequestService.java b/manabom/src/main/java/mannabom_server/manabom/application/matching/service/PhotoRequestService.java index 67b9618..1216ac7 100644 --- a/manabom/src/main/java/mannabom_server/manabom/application/matching/service/PhotoRequestService.java +++ b/manabom/src/main/java/mannabom_server/manabom/application/matching/service/PhotoRequestService.java @@ -75,6 +75,12 @@ public void createPhotoRequest(Long roomId, Long userId) { ChatRoom room = findRoomById(roomId); validateLoveViewChatRoom(room); validateActiveMember(roomId, userId); + + LoveViewPhotoStatus currentStatus = getPhotoRequestStatus(roomId, userId); + if (currentStatus != LoveViewPhotoStatus.READY) { + throw new IllegalStateException("아직 프로필 사진을 요청할 수 있는 상태가 아닙니다."); + } + LoveViewRecommendHistory loveView = room.getLoveView(); Long loveViewId = loveView.getId(); From b669f43dea950cfa1ee3e69e88b70b57cbf54605 Mon Sep 17 00:00:00 2001 From: sehi55 Date: Wed, 1 Jul 2026 17:09:59 +0900 Subject: [PATCH 03/13] =?UTF-8?q?fix:=20=EC=95=8C=EB=A6=BC=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=20=EA=B0=9C=EC=88=98=20=EC=A6=9D=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../db/migration/V18__extend_notification_type_check.sql | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 manabom/src/main/resources/db/migration/V18__extend_notification_type_check.sql diff --git a/manabom/src/main/resources/db/migration/V18__extend_notification_type_check.sql b/manabom/src/main/resources/db/migration/V18__extend_notification_type_check.sql new file mode 100644 index 0000000..0c6d977 --- /dev/null +++ b/manabom/src/main/resources/db/migration/V18__extend_notification_type_check.sql @@ -0,0 +1,9 @@ +-- Extend notification.type check constraint for newly added SseEventName values. +-- Current SseEventName ordinal range: 0..9 + +ALTER TABLE notification +DROP CONSTRAINT IF EXISTS notification_type_check; + +ALTER TABLE notification + ADD CONSTRAINT notification_type_check + CHECK (type >= 0 AND type <= 9); \ No newline at end of file From c5643a8bea0c4f3633241956fb7f615d81ff0e16 Mon Sep 17 00:00:00 2001 From: sehi55 Date: Wed, 1 Jul 2026 22:12:06 +0900 Subject: [PATCH 04/13] =?UTF-8?q?fix:=20db=20migration=20version=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ion_type_check.sql => V19__extend_notification_type_check.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename manabom/src/main/resources/db/migration/{V18__extend_notification_type_check.sql => V19__extend_notification_type_check.sql} (100%) diff --git a/manabom/src/main/resources/db/migration/V18__extend_notification_type_check.sql b/manabom/src/main/resources/db/migration/V19__extend_notification_type_check.sql similarity index 100% rename from manabom/src/main/resources/db/migration/V18__extend_notification_type_check.sql rename to manabom/src/main/resources/db/migration/V19__extend_notification_type_check.sql From a0033f5e3e08c6fb9e201c88a262cf2fc9ad4fb9 Mon Sep 17 00:00:00 2001 From: sehi55 Date: Thu, 2 Jul 2026 16:49:49 +0900 Subject: [PATCH 05/13] =?UTF-8?q?fix:=20=EC=95=8C=EB=A6=BC=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=EC=9D=84=20=EB=AC=B8=EC=9E=90=EC=97=B4=20enum?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=A0=80=EC=9E=A5=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../notification/entity/Notification.java | 3 ++ .../V19__change_notification_type_check.sql | 34 +++++++++++++++++++ .../V19__extend_notification_type_check.sql | 9 ----- 3 files changed, 37 insertions(+), 9 deletions(-) create mode 100644 manabom/src/main/resources/db/migration/V19__change_notification_type_check.sql delete mode 100644 manabom/src/main/resources/db/migration/V19__extend_notification_type_check.sql diff --git a/manabom/src/main/java/mannabom_server/manabom/domain/notification/entity/Notification.java b/manabom/src/main/java/mannabom_server/manabom/domain/notification/entity/Notification.java index 9a520fb..b9104bf 100644 --- a/manabom/src/main/java/mannabom_server/manabom/domain/notification/entity/Notification.java +++ b/manabom/src/main/java/mannabom_server/manabom/domain/notification/entity/Notification.java @@ -26,7 +26,10 @@ public class Notification { private String message; private boolean isRead; + @Enumerated(EnumType.STRING) + @Column(name = "type", length = 50) private SseEventName type; + @Column(columnDefinition = "TEXT") // 혹은 @Lob. 내용이 길어질 수 있으므로 TEXT 권장 private String data; diff --git a/manabom/src/main/resources/db/migration/V19__change_notification_type_check.sql b/manabom/src/main/resources/db/migration/V19__change_notification_type_check.sql new file mode 100644 index 0000000..f200713 --- /dev/null +++ b/manabom/src/main/resources/db/migration/V19__change_notification_type_check.sql @@ -0,0 +1,34 @@ +ALTER TABLE notification +DROP CONSTRAINT IF EXISTS notification_type_check; + +ALTER TABLE notification +ALTER COLUMN type TYPE VARCHAR(50) +USING CASE type + WHEN 0 THEN 'CONNECTED' + WHEN 1 THEN 'MATCHING_STATUS' + WHEN 2 THEN 'MATCH_FOUND' + WHEN 3 THEN 'MATCHING_FAILED' + WHEN 4 THEN 'DECISION_RESULT' + WHEN 5 THEN 'MATCHING_COMPLETED' + WHEN 6 THEN 'NEW_CHAT_MESSAGE' + WHEN 7 THEN 'PHOTO_REQUEST_RECEIVED' + WHEN 8 THEN 'PHOTO_REQUEST_ACCEPTED' + WHEN 9 THEN 'PHOTO_REQUEST_REJECTED' +END; + +ALTER TABLE notification + ADD CONSTRAINT notification_type_check + CHECK ( + type IN ( + 'CONNECTED', + 'MATCHING_STATUS', + 'MATCH_FOUND', + 'MATCHING_FAILED', + 'DECISION_RESULT', + 'MATCHING_COMPLETED', + 'NEW_CHAT_MESSAGE', + 'PHOTO_REQUEST_RECEIVED', + 'PHOTO_REQUEST_ACCEPTED', + 'PHOTO_REQUEST_REJECTED' + ) + ); \ No newline at end of file diff --git a/manabom/src/main/resources/db/migration/V19__extend_notification_type_check.sql b/manabom/src/main/resources/db/migration/V19__extend_notification_type_check.sql deleted file mode 100644 index 0c6d977..0000000 --- a/manabom/src/main/resources/db/migration/V19__extend_notification_type_check.sql +++ /dev/null @@ -1,9 +0,0 @@ --- Extend notification.type check constraint for newly added SseEventName values. --- Current SseEventName ordinal range: 0..9 - -ALTER TABLE notification -DROP CONSTRAINT IF EXISTS notification_type_check; - -ALTER TABLE notification - ADD CONSTRAINT notification_type_check - CHECK (type >= 0 AND type <= 9); \ No newline at end of file From 62092977abc279ba234a9fd997fbc0ace574f4fc Mon Sep 17 00:00:00 2001 From: juneon Date: Thu, 25 Jun 2026 19:18:30 +0900 Subject: [PATCH 06/13] =?UTF-8?q?[Feature]=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=20CS=20=EA=B8=B0=EA=B0=84=20=EC=A0=95=EC=A7=80=20=EB=B0=8F=20?= =?UTF-8?q?=ED=8C=85=20=EC=A7=80=EA=B8=89=20=EC=B2=98=EB=A6=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/request/AdminAdjustWalletRequest.java | 4 +- .../request/AdminUpdateUserStatusRequest.java | 4 ++ .../dto/response/AdminUserDetailResponse.java | 1 + .../response/AdminUserSummaryResponse.java | 18 ++++- .../admin/service/AdminUserService.java | 69 ++++++++++++++++--- .../admin/entity/UserAccountRestriction.java | 34 ++++++++- .../admin/UserAccountAccessGuardFilter.java | 3 +- .../V18__add_admin_report_cs_support.sql | 5 ++ 8 files changed, 120 insertions(+), 18 deletions(-) create mode 100644 manabom/src/main/resources/db/migration/V18__add_admin_report_cs_support.sql diff --git a/manabom/src/main/java/mannabom_server/manabom/application/admin/dto/request/AdminAdjustWalletRequest.java b/manabom/src/main/java/mannabom_server/manabom/application/admin/dto/request/AdminAdjustWalletRequest.java index bf3bb0b..882b6be 100644 --- a/manabom/src/main/java/mannabom_server/manabom/application/admin/dto/request/AdminAdjustWalletRequest.java +++ b/manabom/src/main/java/mannabom_server/manabom/application/admin/dto/request/AdminAdjustWalletRequest.java @@ -7,8 +7,8 @@ @Getter @NoArgsConstructor public class AdminAdjustWalletRequest { - private final Integer tingDelta = 0; - private final Integer eventTingDelta = 0; + private Integer tingDelta = 0; + private Integer eventTingDelta = 0; @NotBlank(message = "reason은 필수입니다.") private String reason; diff --git a/manabom/src/main/java/mannabom_server/manabom/application/admin/dto/request/AdminUpdateUserStatusRequest.java b/manabom/src/main/java/mannabom_server/manabom/application/admin/dto/request/AdminUpdateUserStatusRequest.java index f29a4b5..769234b 100644 --- a/manabom/src/main/java/mannabom_server/manabom/application/admin/dto/request/AdminUpdateUserStatusRequest.java +++ b/manabom/src/main/java/mannabom_server/manabom/application/admin/dto/request/AdminUpdateUserStatusRequest.java @@ -5,11 +5,15 @@ import lombok.NoArgsConstructor; import mannabom_server.manabom.domain.admin.enums.UserAccountStatus; +import java.time.LocalDateTime; + @Getter @NoArgsConstructor public class AdminUpdateUserStatusRequest { @NotNull(message = "status는 필수입니다.") private UserAccountStatus status; + private LocalDateTime suspendedUntil; + private String reason; } diff --git a/manabom/src/main/java/mannabom_server/manabom/application/admin/dto/response/AdminUserDetailResponse.java b/manabom/src/main/java/mannabom_server/manabom/application/admin/dto/response/AdminUserDetailResponse.java index 3b30851..3adc3d2 100644 --- a/manabom/src/main/java/mannabom_server/manabom/application/admin/dto/response/AdminUserDetailResponse.java +++ b/manabom/src/main/java/mannabom_server/manabom/application/admin/dto/response/AdminUserDetailResponse.java @@ -20,6 +20,7 @@ public class AdminUserDetailResponse { private final Boolean membership; private final UserAccountStatus accountStatus; private final String statusReason; + private final LocalDateTime statusSuspendedUntil; private final Instant createdAt; private final Profile profile; private final Wallet wallet; diff --git a/manabom/src/main/java/mannabom_server/manabom/application/admin/dto/response/AdminUserSummaryResponse.java b/manabom/src/main/java/mannabom_server/manabom/application/admin/dto/response/AdminUserSummaryResponse.java index 7b809a2..e149bc6 100644 --- a/manabom/src/main/java/mannabom_server/manabom/application/admin/dto/response/AdminUserSummaryResponse.java +++ b/manabom/src/main/java/mannabom_server/manabom/application/admin/dto/response/AdminUserSummaryResponse.java @@ -5,6 +5,7 @@ import mannabom_server.manabom.domain.user.enums.Gender; import java.time.Instant; +import java.time.LocalDateTime; @Getter public class AdminUserSummaryResponse { @@ -20,6 +21,7 @@ public class AdminUserSummaryResponse { private Boolean verified; private Boolean membership; private UserAccountStatus accountStatus; + private LocalDateTime statusSuspendedUntil; private Instant createdAt; public AdminUserSummaryResponse(Long userId, @@ -34,6 +36,7 @@ public AdminUserSummaryResponse(Long userId, Boolean verified, Boolean membership, UserAccountStatus accountStatus, + LocalDateTime statusSuspendedUntil, Instant createdAt) { this.userId = userId; this.profileId = profileId; @@ -46,7 +49,20 @@ public AdminUserSummaryResponse(Long userId, this.regionSigunguName = regionSigunguName; this.verified = verified; this.membership = membership; - this.accountStatus = accountStatus == null ? UserAccountStatus.ACTIVE : accountStatus; + this.accountStatus = effectiveStatus(accountStatus, statusSuspendedUntil); + this.statusSuspendedUntil = statusSuspendedUntil; this.createdAt = createdAt; } + + private UserAccountStatus effectiveStatus(UserAccountStatus status, LocalDateTime suspendedUntil) { + if (status == null) { + return UserAccountStatus.ACTIVE; + } + if (status == UserAccountStatus.SUSPENDED + && suspendedUntil != null + && !suspendedUntil.isAfter(LocalDateTime.now())) { + return UserAccountStatus.ACTIVE; + } + return status; + } } diff --git a/manabom/src/main/java/mannabom_server/manabom/application/admin/service/AdminUserService.java b/manabom/src/main/java/mannabom_server/manabom/application/admin/service/AdminUserService.java index 7c7331e..3719974 100644 --- a/manabom/src/main/java/mannabom_server/manabom/application/admin/service/AdminUserService.java +++ b/manabom/src/main/java/mannabom_server/manabom/application/admin/service/AdminUserService.java @@ -78,6 +78,7 @@ public AdminUserListResponse searchUsers(AdminPrincipal admin, else false end, restriction.status, + restriction.suspendedUntil, u.createdAt ) from User u @@ -122,6 +123,7 @@ public AdminUserDetailResponse getUser(AdminPrincipal admin, Long userId) { Profile profile = profileRepository.findByUser(user).orElse(null); TingWallet wallet = tingWalletRepository.findByUserId(userId).orElse(null); UserAccountRestriction restriction = userAccountRestrictionRepository.findById(userId).orElse(null); + LocalDateTime now = LocalDateTime.now(); return AdminUserDetailResponse.builder() .userId(user.getUserId()) @@ -129,8 +131,9 @@ public AdminUserDetailResponse getUser(AdminPrincipal admin, Long userId) { .userName(user.getUserName()) .verified(user.getIsVerified()) .membership(wallet != null && wallet.isMembershipActive(LocalDateTime.now())) - .accountStatus(restriction == null ? UserAccountStatus.ACTIVE : restriction.getStatus()) + .accountStatus(effectiveStatus(restriction, now)) .statusReason(restriction == null ? null : restriction.getReason()) + .statusSuspendedUntil(restriction == null ? null : restriction.getSuspendedUntil()) .createdAt(user.getCreatedAt()) .profile(toProfile(profile)) .wallet(toWallet(wallet)) @@ -152,12 +155,14 @@ public AdminUserDetailResponse updateUserStatus(AdminPrincipal admin, .status(UserAccountStatus.ACTIVE) .updatedByAdminId(admin.adminId()) .build()); - String before = restriction.getStatus().name(); - restriction.update(request.getStatus(), request.getReason(), admin.adminId()); + validateSuspension(request.getStatus(), request.getSuspendedUntil()); + String before = accountStatusLabel(restriction); + restriction.update(request.getStatus(), request.getReason(), request.getSuspendedUntil(), admin.adminId()); userAccountRestrictionRepository.save(restriction); + String after = accountStatusLabel(restriction); adminAuditService.log(admin.adminId(), AdminAuditActionType.USER_STATUS_UPDATE, - AdminAuditTargetType.USER, userId, before, request.getStatus().name(), request.getReason(), ipAddress); + AdminAuditTargetType.USER, userId, before, after, request.getReason(), ipAddress); return getUser(admin, userId); } @@ -194,17 +199,25 @@ or lower(p.nickName) like :likeKeyword) private String statusCondition(StatusFilter statusFilter) { return switch (statusFilter) { - case ACTIVE -> "(restriction is null or restriction.status = :status)"; - case SUSPENDED, WITHDRAWN -> "restriction.status = :status"; + case ACTIVE -> """ + (restriction is null + or restriction.status = :status + or (restriction.status = :suspendedStatus + and restriction.suspendedUntil is not null + and restriction.suspendedUntil <= current_timestamp)) + """; + case SUSPENDED -> """ + restriction.status = :status + and (restriction.suspendedUntil is null or restriction.suspendedUntil > current_timestamp) + """; + case WITHDRAWN -> "restriction.status = :status"; case ALL -> ""; }; } private void applySearchParams(TypedQuery query, String keyword, SearchField field, StatusFilter statusFilter) { if (!StringUtils.hasText(keyword)) { - if (statusFilter != StatusFilter.ALL) { - query.setParameter("status", UserAccountStatus.valueOf(statusFilter.name())); - } + applyStatusParams(query, statusFilter); return; } if (field == SearchField.ALL || field == SearchField.USER_ID || field == SearchField.PROFILE_ID) { @@ -213,8 +226,16 @@ private void applySearchParams(TypedQuery query, String keyword, SearchField if (field == SearchField.ALL || field == SearchField.KAKAO_ID || field == SearchField.NICKNAME || field == SearchField.USER_NAME) { query.setParameter("likeKeyword", "%" + keyword.toLowerCase() + "%"); } - if (statusFilter != StatusFilter.ALL) { - query.setParameter("status", UserAccountStatus.valueOf(statusFilter.name())); + applyStatusParams(query, statusFilter); + } + + private void applyStatusParams(TypedQuery query, StatusFilter statusFilter) { + if (statusFilter == StatusFilter.ALL) { + return; + } + query.setParameter("status", UserAccountStatus.valueOf(statusFilter.name())); + if (statusFilter == StatusFilter.ACTIVE) { + query.setParameter("suspendedStatus", UserAccountStatus.SUSPENDED); } } @@ -256,6 +277,32 @@ private List toPhotos(Profile profile) { .toList(); } + private UserAccountStatus effectiveStatus(UserAccountRestriction restriction, LocalDateTime now) { + if (restriction == null) { + return UserAccountStatus.ACTIVE; + } + return restriction.effectiveStatus(now); + } + + private void validateSuspension(UserAccountStatus status, LocalDateTime suspendedUntil) { + if (status != UserAccountStatus.SUSPENDED || suspendedUntil == null) { + return; + } + if (!suspendedUntil.isAfter(LocalDateTime.now())) { + throw new IllegalArgumentException("정지 만료 시각은 현재 시각 이후여야 합니다."); + } + } + + private String accountStatusLabel(UserAccountRestriction restriction) { + if (restriction == null) { + return UserAccountStatus.ACTIVE.name(); + } + if (restriction.getSuspendedUntil() == null) { + return restriction.getStatus().name(); + } + return restriction.getStatus().name() + " until " + restriction.getSuspendedUntil(); + } + private AdminUserDetailResponse.Photo toPhoto(ProfileImage image) { String key = fileStoragePort.extractKeyFromUrl(image.getUrl()); return AdminUserDetailResponse.Photo.builder() diff --git a/manabom/src/main/java/mannabom_server/manabom/domain/admin/entity/UserAccountRestriction.java b/manabom/src/main/java/mannabom_server/manabom/domain/admin/entity/UserAccountRestriction.java index 86e8a9a..080f8c9 100644 --- a/manabom/src/main/java/mannabom_server/manabom/domain/admin/entity/UserAccountRestriction.java +++ b/manabom/src/main/java/mannabom_server/manabom/domain/admin/entity/UserAccountRestriction.java @@ -8,6 +8,8 @@ import mannabom_server.manabom.domain.admin.enums.UserAccountStatus; import mannabom_server.manabom.domain.common.BaseTimeEntity; +import java.time.LocalDateTime; + @Entity @Table(name = "user_account_restrictions") @Getter @@ -25,20 +27,48 @@ public class UserAccountRestriction extends BaseTimeEntity { @Column(name = "reason", columnDefinition = "TEXT") private String reason; + @Column(name = "suspended_until") + private LocalDateTime suspendedUntil; + @Column(name = "updated_by_admin_id") private Long updatedByAdminId; @Builder - public UserAccountRestriction(Long userId, UserAccountStatus status, String reason, Long updatedByAdminId) { + public UserAccountRestriction(Long userId, + UserAccountStatus status, + String reason, + LocalDateTime suspendedUntil, + Long updatedByAdminId) { this.userId = userId; this.status = status; this.reason = reason; + this.suspendedUntil = status == UserAccountStatus.SUSPENDED ? suspendedUntil : null; this.updatedByAdminId = updatedByAdminId; } - public void update(UserAccountStatus status, String reason, Long adminId) { + public void update(UserAccountStatus status, String reason, LocalDateTime suspendedUntil, Long adminId) { this.status = status; this.reason = reason; + this.suspendedUntil = status == UserAccountStatus.SUSPENDED ? suspendedUntil : null; this.updatedByAdminId = adminId; } + + public boolean isAccessBlocked(LocalDateTime now) { + if (status == UserAccountStatus.WITHDRAWN) { + return true; + } + if (status != UserAccountStatus.SUSPENDED) { + return false; + } + return suspendedUntil == null || suspendedUntil.isAfter(now); + } + + public UserAccountStatus effectiveStatus(LocalDateTime now) { + if (status == UserAccountStatus.SUSPENDED + && suspendedUntil != null + && !suspendedUntil.isAfter(now)) { + return UserAccountStatus.ACTIVE; + } + return status; + } } diff --git a/manabom/src/main/java/mannabom_server/manabom/infrastructure/security/admin/UserAccountAccessGuardFilter.java b/manabom/src/main/java/mannabom_server/manabom/infrastructure/security/admin/UserAccountAccessGuardFilter.java index f15c570..cfe6e2c 100644 --- a/manabom/src/main/java/mannabom_server/manabom/infrastructure/security/admin/UserAccountAccessGuardFilter.java +++ b/manabom/src/main/java/mannabom_server/manabom/infrastructure/security/admin/UserAccountAccessGuardFilter.java @@ -6,7 +6,6 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; -import mannabom_server.manabom.domain.admin.enums.UserAccountStatus; import mannabom_server.manabom.domain.admin.repository.UserAccountRestrictionRepository; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; @@ -36,7 +35,7 @@ protected void doFilterInternal(HttpServletRequest request, Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); if (authentication != null && authentication.getPrincipal() instanceof Long userId) { boolean blocked = userAccountRestrictionRepository.findById(userId) - .map(restriction -> restriction.getStatus() != UserAccountStatus.ACTIVE) + .map(restriction -> restriction.isAccessBlocked(LocalDateTime.now())) .orElse(false); if (blocked) { response.setStatus(HttpServletResponse.SC_FORBIDDEN); diff --git a/manabom/src/main/resources/db/migration/V18__add_admin_report_cs_support.sql b/manabom/src/main/resources/db/migration/V18__add_admin_report_cs_support.sql new file mode 100644 index 0000000..411b100 --- /dev/null +++ b/manabom/src/main/resources/db/migration/V18__add_admin_report_cs_support.sql @@ -0,0 +1,5 @@ +ALTER TABLE user_account_restrictions + ADD COLUMN IF NOT EXISTS suspended_until TIMESTAMP; + +CREATE INDEX IF NOT EXISTS idx_user_account_restrictions_status_until + ON user_account_restrictions(status, suspended_until); From fb00612b4a6b99cb06dd9d7b7cffb8f434e8d8ee Mon Sep 17 00:00:00 2001 From: juneon Date: Thu, 25 Jun 2026 19:18:47 +0900 Subject: [PATCH 07/13] =?UTF-8?q?[Feature]=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=20=EC=8B=A0=EA=B3=A0=20=EC=A1=B0=ED=9A=8C=20=EB=B0=8F=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../request/AdminProcessReportRequest.java | 33 ++ .../response/AdminReportDetailResponse.java | 54 +++ .../dto/response/AdminReportListResponse.java | 16 + .../response/AdminReportSummaryResponse.java | 53 +++ .../admin/service/AdminReportService.java | 408 ++++++++++++++++++ .../admin/enums/AdminAuditActionType.java | 3 +- .../admin/enums/AdminAuditTargetType.java | 3 +- .../manabom/domain/report/entity/Report.java | 1 + .../report/repository/ReportRepository.java | 12 + .../controller/AdminReportController.java | 67 +++ 10 files changed, 648 insertions(+), 2 deletions(-) create mode 100644 manabom/src/main/java/mannabom_server/manabom/application/admin/dto/request/AdminProcessReportRequest.java create mode 100644 manabom/src/main/java/mannabom_server/manabom/application/admin/dto/response/AdminReportDetailResponse.java create mode 100644 manabom/src/main/java/mannabom_server/manabom/application/admin/dto/response/AdminReportListResponse.java create mode 100644 manabom/src/main/java/mannabom_server/manabom/application/admin/dto/response/AdminReportSummaryResponse.java create mode 100644 manabom/src/main/java/mannabom_server/manabom/application/admin/service/AdminReportService.java create mode 100644 manabom/src/main/java/mannabom_server/manabom/presentation/admin/controller/AdminReportController.java diff --git a/manabom/src/main/java/mannabom_server/manabom/application/admin/dto/request/AdminProcessReportRequest.java b/manabom/src/main/java/mannabom_server/manabom/application/admin/dto/request/AdminProcessReportRequest.java new file mode 100644 index 0000000..8ffa4c5 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/admin/dto/request/AdminProcessReportRequest.java @@ -0,0 +1,33 @@ +package mannabom_server.manabom.application.admin.dto.request; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; +import lombok.Getter; +import lombok.NoArgsConstructor; +import mannabom_server.manabom.domain.admin.enums.UserAccountStatus; +import mannabom_server.manabom.domain.report.entity.ReportStatus; + +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor +public class AdminProcessReportRequest { + @NotNull(message = "status는 필수입니다.") + private ReportStatus status; + + private String adminComment; + + private UserAccountStatus targetAccountStatus; + + private LocalDateTime targetSuspendedUntil; + + private String targetAccountReason; + + @PositiveOrZero(message = "tingGrant는 0 이상이어야 합니다.") + private Integer tingGrant = 0; + + @PositiveOrZero(message = "eventTingGrant는 0 이상이어야 합니다.") + private Integer eventTingGrant = 0; + + private String walletReason; +} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/admin/dto/response/AdminReportDetailResponse.java b/manabom/src/main/java/mannabom_server/manabom/application/admin/dto/response/AdminReportDetailResponse.java new file mode 100644 index 0000000..d35ba3c --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/admin/dto/response/AdminReportDetailResponse.java @@ -0,0 +1,54 @@ +package mannabom_server.manabom.application.admin.dto.response; + +import lombok.Builder; +import lombok.Getter; +import mannabom_server.manabom.domain.admin.enums.UserAccountStatus; +import mannabom_server.manabom.domain.report.entity.ReportReason; +import mannabom_server.manabom.domain.report.entity.ReportStatus; +import mannabom_server.manabom.domain.report.entity.ReportType; +import mannabom_server.manabom.domain.user.enums.Gender; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Map; + +@Getter +@Builder +public class AdminReportDetailResponse { + private final Long reportId; + private final ReportType type; + private final Long contextId; + private final ReportReason reason; + private final String additionalDetail; + private final ReportStatus status; + private final String adminComment; + private final Instant processedAt; + private final Instant createdAt; + private final UserSnapshot reporter; + private final UserSnapshot target; + private final Map referenceContext; + + @Getter + @Builder + public static class UserSnapshot { + private final Long userId; + private final Long profileId; + private final String kakaoId; + private final String userName; + private final String phoneNum; + private final String nickName; + private final Gender gender; + private final LocalDate birthDate; + private final String universityName; + private final String regionSidoName; + private final String regionSigunguName; + private final Boolean verified; + private final UserAccountStatus accountStatus; + private final String statusReason; + private final LocalDateTime statusSuspendedUntil; + private final Integer ting; + private final Integer eventTing; + private final LocalDateTime membershipActiveUntil; + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/admin/dto/response/AdminReportListResponse.java b/manabom/src/main/java/mannabom_server/manabom/application/admin/dto/response/AdminReportListResponse.java new file mode 100644 index 0000000..8dfde4d --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/admin/dto/response/AdminReportListResponse.java @@ -0,0 +1,16 @@ +package mannabom_server.manabom.application.admin.dto.response; + +import lombok.Builder; +import lombok.Getter; + +import java.util.List; + +@Getter +@Builder +public class AdminReportListResponse { + private final List reports; + private final long totalCount; + private final int totalPages; + private final int page; + private final int size; +} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/admin/dto/response/AdminReportSummaryResponse.java b/manabom/src/main/java/mannabom_server/manabom/application/admin/dto/response/AdminReportSummaryResponse.java new file mode 100644 index 0000000..d0a0b2f --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/admin/dto/response/AdminReportSummaryResponse.java @@ -0,0 +1,53 @@ +package mannabom_server.manabom.application.admin.dto.response; + +import lombok.Getter; +import mannabom_server.manabom.domain.report.entity.ReportReason; +import mannabom_server.manabom.domain.report.entity.ReportStatus; +import mannabom_server.manabom.domain.report.entity.ReportType; + +import java.time.Instant; + +@Getter +public class AdminReportSummaryResponse { + private final Long reportId; + private final ReportType type; + private final Long contextId; + private final Long reporterId; + private final String reporterName; + private final String reporterNickName; + private final Long targetId; + private final String targetName; + private final String targetNickName; + private final ReportReason reason; + private final ReportStatus status; + private final Instant createdAt; + private final Instant processedAt; + + public AdminReportSummaryResponse(Long reportId, + ReportType type, + Long contextId, + Long reporterId, + String reporterName, + String reporterNickName, + Long targetId, + String targetName, + String targetNickName, + ReportReason reason, + ReportStatus status, + Instant createdAt, + Instant processedAt) { + this.reportId = reportId; + this.type = type; + this.contextId = contextId; + this.reporterId = reporterId; + this.reporterName = reporterName; + this.reporterNickName = reporterNickName; + this.targetId = targetId; + this.targetName = targetName; + this.targetNickName = targetNickName; + this.reason = reason; + this.status = status; + this.createdAt = createdAt; + this.processedAt = processedAt; + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/admin/service/AdminReportService.java b/manabom/src/main/java/mannabom_server/manabom/application/admin/service/AdminReportService.java new file mode 100644 index 0000000..21d631c --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/admin/service/AdminReportService.java @@ -0,0 +1,408 @@ +package mannabom_server.manabom.application.admin.service; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.TypedQuery; +import lombok.RequiredArgsConstructor; +import mannabom_server.manabom.application.admin.dto.request.AdminProcessReportRequest; +import mannabom_server.manabom.application.admin.dto.response.AdminReportDetailResponse; +import mannabom_server.manabom.application.admin.dto.response.AdminReportListResponse; +import mannabom_server.manabom.application.admin.dto.response.AdminReportSummaryResponse; +import mannabom_server.manabom.domain.admin.entity.UserAccountRestriction; +import mannabom_server.manabom.domain.admin.enums.AdminAuditActionType; +import mannabom_server.manabom.domain.admin.enums.AdminAuditTargetType; +import mannabom_server.manabom.domain.admin.enums.AdminRole; +import mannabom_server.manabom.domain.admin.enums.UserAccountStatus; +import mannabom_server.manabom.domain.admin.repository.UserAccountRestrictionRepository; +import mannabom_server.manabom.domain.currency.entity.TingWallet; +import mannabom_server.manabom.domain.currency.repository.TingWalletRepository; +import mannabom_server.manabom.domain.report.entity.Report; +import mannabom_server.manabom.domain.report.entity.ReportStatus; +import mannabom_server.manabom.domain.report.entity.ReportType; +import mannabom_server.manabom.domain.report.repository.ReportRepository; +import mannabom_server.manabom.domain.user.entity.Profile; +import mannabom_server.manabom.domain.user.entity.User; +import mannabom_server.manabom.domain.user.repository.ProfileRepository; +import mannabom_server.manabom.domain.user.repository.UserRepository; +import mannabom_server.manabom.infrastructure.security.admin.AdminPrincipal; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class AdminReportService { + + private final EntityManager entityManager; + private final ReportRepository reportRepository; + private final UserRepository userRepository; + private final ProfileRepository profileRepository; + private final TingWalletRepository tingWalletRepository; + private final UserAccountRestrictionRepository userAccountRestrictionRepository; + private final AdminAuditService adminAuditService; + + @Transactional(readOnly = true) + public AdminReportListResponse searchReports(AdminPrincipal admin, + ReportStatus status, + ReportType type, + String keyword, + int page, + int size) { + requireReportReadable(admin); + int safePage = Math.max(page, 0); + int safeSize = Math.min(Math.max(size, 1), 100); + String where = buildWhere(status, type, keyword); + + String selectJpql = """ + select new mannabom_server.manabom.application.admin.dto.response.AdminReportSummaryResponse( + r.id, + r.type, + r.contextId, + reporter.userId, + reporter.userName, + reporterProfile.nickName, + target.userId, + target.userName, + targetProfile.nickName, + r.reason, + r.status, + r.createdAt, + r.processedAt + ) + from Report r + join r.reporter reporter + join r.target target + left join Profile reporterProfile on reporterProfile.user = reporter + left join Profile targetProfile on targetProfile.user = target + """ + where + " order by r.createdAt desc"; + + TypedQuery query = + entityManager.createQuery(selectJpql, AdminReportSummaryResponse.class); + applySearchParams(query, status, type, keyword); + List reports = query + .setFirstResult(safePage * safeSize) + .setMaxResults(safeSize) + .getResultList(); + + String countJpql = """ + select count(r) + from Report r + join r.reporter reporter + join r.target target + left join Profile reporterProfile on reporterProfile.user = reporter + left join Profile targetProfile on targetProfile.user = target + """ + where; + TypedQuery countQuery = entityManager.createQuery(countJpql, Long.class); + applySearchParams(countQuery, status, type, keyword); + long totalCount = countQuery.getSingleResult(); + + return AdminReportListResponse.builder() + .reports(reports) + .totalCount(totalCount) + .totalPages((int) Math.ceil((double) totalCount / safeSize)) + .page(safePage) + .size(safeSize) + .build(); + } + + @Transactional(readOnly = true) + public AdminReportDetailResponse getReport(AdminPrincipal admin, Long reportId) { + requireReportReadable(admin); + return toDetail(getReportWithUsers(reportId)); + } + + @Transactional + public AdminReportDetailResponse processReport(AdminPrincipal admin, + Long reportId, + AdminProcessReportRequest request, + String ipAddress) { + requireReportProcessable(admin); + Report report = getReportWithUsers(reportId); + String before = report.getStatus().name(); + + processTargetAccount(admin, report, request, ipAddress); + grantTargetTing(admin, report, request, ipAddress); + report.processReport(request.getAdminComment(), request.getStatus()); + + adminAuditService.log(admin.adminId(), AdminAuditActionType.REPORT_PROCESS, + AdminAuditTargetType.REPORT, report.getId(), before, report.getStatus().name(), + request.getAdminComment(), ipAddress); + return toDetail(report); + } + + private void processTargetAccount(AdminPrincipal admin, + Report report, + AdminProcessReportRequest request, + String ipAddress) { + UserAccountStatus targetStatus = request.getTargetAccountStatus(); + if (targetStatus == null) { + return; + } + validateSuspension(targetStatus, request.getTargetSuspendedUntil()); + Long targetUserId = report.getTarget().getUserId(); + userRepository.findById(targetUserId) + .orElseThrow(() -> new IllegalArgumentException("신고 대상 사용자를 찾을 수 없습니다.")); + UserAccountRestriction restriction = userAccountRestrictionRepository.findById(targetUserId) + .orElseGet(() -> UserAccountRestriction.builder() + .userId(targetUserId) + .status(UserAccountStatus.ACTIVE) + .updatedByAdminId(admin.adminId()) + .build()); + String before = accountStatusLabel(restriction); + restriction.update(targetStatus, request.getTargetAccountReason(), request.getTargetSuspendedUntil(), admin.adminId()); + userAccountRestrictionRepository.save(restriction); + adminAuditService.log(admin.adminId(), AdminAuditActionType.USER_STATUS_UPDATE, + AdminAuditTargetType.USER, targetUserId, before, accountStatusLabel(restriction), + request.getTargetAccountReason(), ipAddress); + } + + private void grantTargetTing(AdminPrincipal admin, + Report report, + AdminProcessReportRequest request, + String ipAddress) { + int tingGrant = request.getTingGrant() == null ? 0 : request.getTingGrant(); + int eventTingGrant = request.getEventTingGrant() == null ? 0 : request.getEventTingGrant(); + if (tingGrant == 0 && eventTingGrant == 0) { + return; + } + if (!StringUtils.hasText(request.getWalletReason())) { + throw new IllegalArgumentException("팅 지급 사유는 필수입니다."); + } + + Long targetUserId = report.getTarget().getUserId(); + TingWallet wallet = tingWalletRepository.findByUserIdForUpdate(targetUserId) + .orElseGet(() -> tingWalletRepository.save(new TingWallet(targetUserId))); + String before = walletLabel(wallet); + if (tingGrant > 0) { + wallet.addTing(tingGrant); + } + if (eventTingGrant > 0) { + wallet.addEventTing(eventTingGrant); + } + adminAuditService.log(admin.adminId(), AdminAuditActionType.WALLET_ADJUST, + AdminAuditTargetType.TING_WALLET, targetUserId, before, walletLabel(wallet), + request.getWalletReason(), ipAddress); + } + + private AdminReportDetailResponse toDetail(Report report) { + User reporter = report.getReporter(); + User target = report.getTarget(); + return AdminReportDetailResponse.builder() + .reportId(report.getId()) + .type(report.getType()) + .contextId(report.getContextId()) + .reason(report.getReason()) + .additionalDetail(report.getAdditionalDetail()) + .status(report.getStatus()) + .adminComment(report.getAdminComment()) + .processedAt(report.getProcessedAt()) + .createdAt(report.getCreatedAt()) + .reporter(userSnapshot(reporter)) + .target(userSnapshot(target)) + .referenceContext(referenceContext(report)) + .build(); + } + + private AdminReportDetailResponse.UserSnapshot userSnapshot(User user) { + Profile profile = profileRepository.findByUserWithRegionAndUniversity(user).orElse(null); + TingWallet wallet = tingWalletRepository.findByUserId(user.getUserId()).orElse(null); + UserAccountRestriction restriction = userAccountRestrictionRepository.findById(user.getUserId()).orElse(null); + LocalDateTime now = LocalDateTime.now(); + + return AdminReportDetailResponse.UserSnapshot.builder() + .userId(user.getUserId()) + .profileId(profile == null ? null : profile.getProfileId()) + .kakaoId(user.getKakaoId()) + .userName(user.getUserName()) + .phoneNum(user.getPhoneNum()) + .nickName(profile == null ? null : profile.getNickName()) + .gender(profile == null ? null : profile.getGender()) + .birthDate(profile == null ? null : profile.getBirthDate()) + .universityName(profile == null || profile.getUniversity() == null ? null : profile.getUniversity().getName()) + .regionSidoName(profile == null || profile.getRegion() == null ? null : profile.getRegion().getSidoName()) + .regionSigunguName(profile == null || profile.getRegion() == null ? null : profile.getRegion().getSigunguName()) + .verified(user.getIsVerified()) + .accountStatus(restriction == null ? UserAccountStatus.ACTIVE : restriction.effectiveStatus(now)) + .statusReason(restriction == null ? null : restriction.getReason()) + .statusSuspendedUntil(restriction == null ? null : restriction.getSuspendedUntil()) + .ting(wallet == null ? null : wallet.getTing()) + .eventTing(wallet == null ? null : wallet.getEventTing()) + .membershipActiveUntil(wallet == null ? null : wallet.getMembershipActiveUntil()) + .build(); + } + + private Map referenceContext(Report report) { + Map context = new LinkedHashMap<>(); + context.put("reportedContext", reportedContext(report)); + context.put("targetRecentReports", recentReportsByTarget(report.getTarget().getUserId(), report.getId())); + context.put("reporterRecentReports", recentReportsByReporter(report.getReporter().getUserId(), report.getId())); + context.put("paymentHistory", "결제 내역 테이블이 아직 없어 지갑 스냅샷과 관련 ID를 우선 제공합니다."); + return context; + } + + private Map reportedContext(Report report) { + Map context = new LinkedHashMap<>(); + context.put("type", report.getType()); + context.put("contextId", report.getContextId()); + if (report.getType() == ReportType.CHAT && report.getContextId() != null) { + context.put("chatMessages", chatMessages(report.getContextId())); + } + if (report.getType() == ReportType.PROFILE) { + context.put("profileOwnerUserId", report.getTarget().getUserId()); + context.put("profileOwnerNickName", userSnapshot(report.getTarget()).getNickName()); + } + return context; + } + + private List chatMessages(Long roomId) { + return entityManager.createQuery(""" + select new map( + message.id as messageId, + sender.userId as senderUserId, + profile.nickName as senderNickName, + message.type as type, + message.content as content, + message.createdAt as createdAt + ) + from ChatMessage message + join message.user sender + left join Profile profile on profile.user = sender + where message.room.id = :roomId + order by message.createdAt desc + """) + .setParameter("roomId", roomId) + .setMaxResults(50) + .getResultList(); + } + + private List recentReportsByTarget(Long targetUserId, Long exceptReportId) { + return entityManager.createQuery(""" + select new map( + r.id as reportId, + r.type as type, + r.reason as reason, + r.status as status, + reporter.userId as reporterUserId, + reporterProfile.nickName as reporterNickName, + r.createdAt as createdAt + ) + from Report r + join r.reporter reporter + left join Profile reporterProfile on reporterProfile.user = reporter + where r.target.userId = :targetUserId + and r.id <> :exceptReportId + order by r.createdAt desc + """) + .setParameter("targetUserId", targetUserId) + .setParameter("exceptReportId", exceptReportId) + .setMaxResults(10) + .getResultList(); + } + + private List recentReportsByReporter(Long reporterUserId, Long exceptReportId) { + return entityManager.createQuery(""" + select new map( + r.id as reportId, + r.type as type, + r.reason as reason, + r.status as status, + target.userId as targetUserId, + targetProfile.nickName as targetNickName, + r.createdAt as createdAt + ) + from Report r + join r.target target + left join Profile targetProfile on targetProfile.user = target + where r.reporter.userId = :reporterUserId + and r.id <> :exceptReportId + order by r.createdAt desc + """) + .setParameter("reporterUserId", reporterUserId) + .setParameter("exceptReportId", exceptReportId) + .setMaxResults(10) + .getResultList(); + } + + private Report getReportWithUsers(Long reportId) { + return reportRepository.findByIdWithUsers(reportId) + .orElseThrow(() -> new IllegalArgumentException("신고 내역을 찾을 수 없습니다.")); + } + + private String buildWhere(ReportStatus status, ReportType type, String keyword) { + List conditions = new ArrayList<>(); + if (status != null) { + conditions.add("r.status = :status"); + } + if (type != null) { + conditions.add("r.type = :type"); + } + if (StringUtils.hasText(keyword)) { + conditions.add(""" + (cast(r.id as string) = :keyword + or cast(reporter.userId as string) = :keyword + or cast(target.userId as string) = :keyword + or lower(reporter.userName) like :likeKeyword + or lower(target.userName) like :likeKeyword + or lower(reporterProfile.nickName) like :likeKeyword + or lower(targetProfile.nickName) like :likeKeyword + or lower(r.additionalDetail) like :likeKeyword) + """); + } + if (conditions.isEmpty()) { + return ""; + } + return " where " + String.join(" and ", conditions); + } + + private void applySearchParams(TypedQuery query, ReportStatus status, ReportType type, String keyword) { + if (status != null) { + query.setParameter("status", status); + } + if (type != null) { + query.setParameter("type", type); + } + if (StringUtils.hasText(keyword)) { + query.setParameter("keyword", keyword); + query.setParameter("likeKeyword", "%" + keyword.toLowerCase() + "%"); + } + } + + private void validateSuspension(UserAccountStatus status, LocalDateTime suspendedUntil) { + if (status != UserAccountStatus.SUSPENDED || suspendedUntil == null) { + return; + } + if (!suspendedUntil.isAfter(LocalDateTime.now())) { + throw new IllegalArgumentException("정지 만료 시각은 현재 시각 이후여야 합니다."); + } + } + + private String accountStatusLabel(UserAccountRestriction restriction) { + if (restriction.getSuspendedUntil() == null) { + return restriction.getStatus().name(); + } + return restriction.getStatus().name() + " until " + restriction.getSuspendedUntil(); + } + + private String walletLabel(TingWallet wallet) { + return "ting=" + wallet.getTing() + ", eventTing=" + wallet.getEventTing(); + } + + private void requireReportReadable(AdminPrincipal admin) { + if (admin.hasAnyRole(AdminRole.SUPER_ADMIN, AdminRole.OPERATOR, AdminRole.SUPPORT, AdminRole.MODERATOR)) { + return; + } + throw new IllegalStateException("신고 내역 조회 권한이 없습니다."); + } + + private void requireReportProcessable(AdminPrincipal admin) { + if (admin.hasAnyRole(AdminRole.SUPER_ADMIN, AdminRole.OPERATOR, AdminRole.SUPPORT, AdminRole.MODERATOR)) { + return; + } + throw new IllegalStateException("신고 처리 권한이 없습니다."); + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/domain/admin/enums/AdminAuditActionType.java b/manabom/src/main/java/mannabom_server/manabom/domain/admin/enums/AdminAuditActionType.java index ba56421..e3beb8e 100644 --- a/manabom/src/main/java/mannabom_server/manabom/domain/admin/enums/AdminAuditActionType.java +++ b/manabom/src/main/java/mannabom_server/manabom/domain/admin/enums/AdminAuditActionType.java @@ -12,5 +12,6 @@ public enum AdminAuditActionType { WALLET_ADJUST, MEMBERSHIP_ACTIVATE, POLICY_UPDATE, - PUSH_SEND + PUSH_SEND, + REPORT_PROCESS } diff --git a/manabom/src/main/java/mannabom_server/manabom/domain/admin/enums/AdminAuditTargetType.java b/manabom/src/main/java/mannabom_server/manabom/domain/admin/enums/AdminAuditTargetType.java index 22294a3..63e7f62 100644 --- a/manabom/src/main/java/mannabom_server/manabom/domain/admin/enums/AdminAuditTargetType.java +++ b/manabom/src/main/java/mannabom_server/manabom/domain/admin/enums/AdminAuditTargetType.java @@ -5,5 +5,6 @@ public enum AdminAuditTargetType { USER, TING_WALLET, POLICY, - PUSH + PUSH, + REPORT } diff --git a/manabom/src/main/java/mannabom_server/manabom/domain/report/entity/Report.java b/manabom/src/main/java/mannabom_server/manabom/domain/report/entity/Report.java index 96aa183..c0e58d2 100644 --- a/manabom/src/main/java/mannabom_server/manabom/domain/report/entity/Report.java +++ b/manabom/src/main/java/mannabom_server/manabom/domain/report/entity/Report.java @@ -12,6 +12,7 @@ @Entity @Table(name = "reports") @EntityListeners(AuditingEntityListener.class) +@Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @Builder @AllArgsConstructor diff --git a/manabom/src/main/java/mannabom_server/manabom/domain/report/repository/ReportRepository.java b/manabom/src/main/java/mannabom_server/manabom/domain/report/repository/ReportRepository.java index 2a12096..68d8c29 100644 --- a/manabom/src/main/java/mannabom_server/manabom/domain/report/repository/ReportRepository.java +++ b/manabom/src/main/java/mannabom_server/manabom/domain/report/repository/ReportRepository.java @@ -2,6 +2,18 @@ import mannabom_server.manabom.domain.report.entity.Report; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; public interface ReportRepository extends JpaRepository { + @Query(""" + select r + from Report r + join fetch r.reporter + join fetch r.target + where r.id = :reportId + """) + Optional findByIdWithUsers(@Param("reportId") Long reportId); } diff --git a/manabom/src/main/java/mannabom_server/manabom/presentation/admin/controller/AdminReportController.java b/manabom/src/main/java/mannabom_server/manabom/presentation/admin/controller/AdminReportController.java new file mode 100644 index 0000000..aa8dc0e --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/presentation/admin/controller/AdminReportController.java @@ -0,0 +1,67 @@ +package mannabom_server.manabom.presentation.admin.controller; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import mannabom_server.manabom.application.admin.dto.request.AdminProcessReportRequest; +import mannabom_server.manabom.application.admin.dto.response.AdminReportDetailResponse; +import mannabom_server.manabom.application.admin.dto.response.AdminReportListResponse; +import mannabom_server.manabom.application.admin.service.AdminReportService; +import mannabom_server.manabom.domain.report.entity.ReportStatus; +import mannabom_server.manabom.domain.report.entity.ReportType; +import mannabom_server.manabom.infrastructure.security.admin.AdminPrincipal; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +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; + +@RestController +@RequestMapping("/api/admin/reports") +@RequiredArgsConstructor +public class AdminReportController { + + private final AdminReportService adminReportService; + + @GetMapping + public ResponseEntity searchReports( + @AuthenticationPrincipal AdminPrincipal admin, + @RequestParam(required = false) ReportStatus status, + @RequestParam(required = false) ReportType type, + @RequestParam(required = false) String keyword, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + return ResponseEntity.ok(adminReportService.searchReports(admin, status, type, keyword, page, size)); + } + + @GetMapping("/{reportId}") + public ResponseEntity getReport( + @AuthenticationPrincipal AdminPrincipal admin, + @PathVariable Long reportId + ) { + return ResponseEntity.ok(adminReportService.getReport(admin, reportId)); + } + + @PatchMapping("/{reportId}") + public ResponseEntity processReport( + @AuthenticationPrincipal AdminPrincipal admin, + @PathVariable Long reportId, + @RequestBody @Valid AdminProcessReportRequest request, + HttpServletRequest httpRequest + ) { + return ResponseEntity.ok(adminReportService.processReport(admin, reportId, request, clientIp(httpRequest))); + } + + private String clientIp(HttpServletRequest request) { + String forwarded = request.getHeader("X-Forwarded-For"); + if (forwarded != null && !forwarded.isBlank()) { + return forwarded.split(",")[0].trim(); + } + return request.getRemoteAddr(); + } +} From 7177123c01c123be72c9229d1b9d38b552ed159e Mon Sep 17 00:00:00 2001 From: juneon Date: Thu, 25 Jun 2026 19:19:03 +0900 Subject: [PATCH 08/13] =?UTF-8?q?[Feature]=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=20=EC=8B=A0=EA=B3=A0=20CS=20=ED=99=94=EB=A9=B4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/resources/static/admin/app.js | 370 +++++++++++++++++- .../main/resources/static/admin/index.html | 60 +++ .../main/resources/static/admin/styles.css | 54 +++ 3 files changed, 479 insertions(+), 5 deletions(-) diff --git a/manabom/src/main/resources/static/admin/app.js b/manabom/src/main/resources/static/admin/app.js index a56787a..62e4555 100644 --- a/manabom/src/main/resources/static/admin/app.js +++ b/manabom/src/main/resources/static/admin/app.js @@ -9,6 +9,12 @@ const state = { keyword: "", searchType: "ALL", accountStatus: "ALL", + reportPage: 0, + reportSize: 20, + reportKeyword: "", + reportStatus: "", + reportType: "", + selectedReportId: null, auditPage: 0, auditSize: 30, isActivatingMembership: false @@ -39,9 +45,9 @@ const policyKeys = [ const roleOptions = [ { value: "SUPER_ADMIN", name: "총괄 관리자", description: "관리자 계정, 정책, 회원, 지갑, 문의 전체 관리" }, - { value: "OPERATOR", name: "운영 관리자", description: "회원 상태, 푸시, 문의 처리" }, - { value: "SUPPORT", name: "고객지원", description: "회원 조회와 문의 답변" }, - { value: "MODERATOR", name: "심사 관리자", description: "회원/프로필 검토 중심" }, + { value: "OPERATOR", name: "운영 관리자", description: "회원 상태, 푸시, 문의/신고 처리" }, + { value: "SUPPORT", name: "고객지원", description: "회원 조회와 문의/신고 답변" }, + { value: "MODERATOR", name: "심사 관리자", description: "회원/프로필/신고 검토 중심" }, { value: "FINANCE", name: "재무 관리자", description: "팅 지갑 조회와 조정" } ]; @@ -114,6 +120,30 @@ function bindEvents() { $("adminForm").addEventListener("submit", saveAdminAccount); $("newAdminButton").addEventListener("click", resetAdminForm); $("deleteAdminButton").addEventListener("click", deleteAdminAccount); + $("reportSearchButton").addEventListener("click", applyReportFilters); + $("reportKeyword").addEventListener("keydown", (event) => { + if (event.key === "Enter") { + applyReportFilters(); + } + }); + $("reportStatusFilter").addEventListener("change", applyReportFilters); + $("reportTypeFilter").addEventListener("change", applyReportFilters); + $("reportPageSize").addEventListener("change", () => { + state.reportSize = numberOrZero($("reportPageSize").value) || 20; + state.reportPage = 0; + loadReports(); + }); + $("clearReportFilterButton").addEventListener("click", clearReportFilters); + $("prevReportPageButton").addEventListener("click", () => { + if (state.reportPage > 0) { + state.reportPage -= 1; + loadReports(); + } + }); + $("nextReportPageButton").addEventListener("click", () => { + state.reportPage += 1; + loadReports(); + }); $("inquiryStatusFilter").addEventListener("change", loadInquiries); $("auditPageSize").addEventListener("change", () => { state.auditSize = numberOrZero($("auditPageSize").value) || 30; @@ -212,6 +242,7 @@ function switchView(viewId) { usersView: "회원 관리", policiesView: "운영 정책", pushView: "푸시 알림", + reportsView: "신고/CS 처리", inquiriesView: "문의", auditsView: "감사 로그", adminsView: "관리자 계정" @@ -223,6 +254,11 @@ function switchView(viewId) { function refreshCurrentView() { if (!$("auditsView").classList.contains("hidden")) { loadAudits(); + } else if (!$("reportsView").classList.contains("hidden")) { + loadReports(); + if (state.selectedReportId) { + loadReportDetail(state.selectedReportId); + } } else if (!$("inquiriesView").classList.contains("hidden")) { loadInquiries(); } else if (!$("adminsView").classList.contains("hidden")) { @@ -315,6 +351,7 @@ function renderUserDetail(user) { ${kv("멤버십 만료", formatDate(user.wallet?.membershipActiveUntil))} ${kvHtml("계정 상태", statusBadge(user.accountStatus))} ${kv("상태 사유", user.statusReason || "-")} + ${kv("정지 만료", formatDate(user.statusSuspendedUntil))}
@@ -363,6 +400,10 @@ function renderUserDetail(user) { + ${reasonControl("statusReason", "상태 변경 사유", ["운영 정책 위반", "신고 누적", "본인 요청", "기타"])}
@@ -377,6 +418,9 @@ function renderUserDetail(user) { `; $("statusSelect").value = user.accountStatus || "ACTIVE"; + $("suspendedUntilInput").value = toDateTimeLocalValue(user.statusSuspendedUntil); + $("statusSelect").addEventListener("change", syncSuspendedUntilInput); + syncSuspendedUntilInput(); $("statusSaveButton").addEventListener("click", saveUserStatus); $("walletSaveButton").addEventListener("click", saveWallet); $("membershipActivateButton").addEventListener("click", activateMembership); @@ -393,6 +437,7 @@ async function saveUserStatus() { method: "PATCH", body: { status: $("statusSelect").value, + suspendedUntil: $("statusSelect").value === "SUSPENDED" ? localDateTimeOrNull($("suspendedUntilInput").value) : null, reason: getReasonValue("statusReason") } }); @@ -630,6 +675,178 @@ async function saveAdminAccount(event) { } } +async function loadReports() { + const query = new URLSearchParams({ + page: String(state.reportPage), + size: String(state.reportSize) + }); + if (state.reportStatus) query.set("status", state.reportStatus); + if (state.reportType) query.set("type", state.reportType); + if (state.reportKeyword) query.set("keyword", state.reportKeyword); + const data = await request(`/api/admin/reports?${query.toString()}`); + renderReports(data); +} + +function applyReportFilters() { + state.reportKeyword = $("reportKeyword").value.trim(); + state.reportStatus = $("reportStatusFilter").value; + state.reportType = $("reportTypeFilter").value; + state.reportPage = 0; + loadReports(); +} + +function clearReportFilters() { + $("reportKeyword").value = ""; + $("reportStatusFilter").value = ""; + $("reportTypeFilter").value = ""; + $("reportPageSize").value = "20"; + state.reportKeyword = ""; + state.reportStatus = ""; + state.reportType = ""; + state.reportPage = 0; + state.reportSize = 20; + loadReports(); +} + +function renderReports(data) { + $("reportCount").textContent = `${data.totalCount.toLocaleString()}건`; + $("reportPageInfo").textContent = + `${data.page + 1} / ${Math.max(data.totalPages || 1, 1)} 페이지`; + $("prevReportPageButton").disabled = data.page <= 0; + $("nextReportPageButton").disabled = (data.page + 1) * data.size >= data.totalCount; + $("reportList").innerHTML = data.reports.map((report) => ` + + `).join("") || `
신고가 없습니다.
`; + document.querySelectorAll("[data-report-id]").forEach((button) => { + button.addEventListener("click", () => loadReportDetail(button.dataset.reportId)); + }); +} + +async function loadReportDetail(reportId) { + state.selectedReportId = reportId; + $("reportActionResult").textContent = ""; + $("reportActionResult").classList.remove("error-text"); + const report = await request(`/api/admin/reports/${reportId}`); + $("selectedReportLabel").textContent = `신고 ${report.reportId}`; + renderReportDetail(report); +} + +function renderReportDetail(report) { + $("reportDetail").className = "detail-body"; + $("reportDetail").innerHTML = ` +
+

신고 정보

+ ${kv("유형", reportTypeLabel(report.type))} + ${kv("사유", reportReasonLabel(report.reason))} + ${kvHtml("상태", statusBadge(report.status))} + ${kv("접수일", formatDate(report.createdAt))} + ${kv("처리일", formatDate(report.processedAt))} + ${kv("신고 상세", report.additionalDetail || "-")} + ${kv("관리자 메모", report.adminComment || "-")} +
+
+

신고자

+ ${userSnapshotHtml(report.reporter)} +
+
+

신고 대상

+ ${userSnapshotHtml(report.target)} +
+
+

참고 지표

+ ${referenceContextHtml(report.referenceContext || {})} +
+
+

신고 처리

+ + +
+ + +
+ +
+ + +
+ + +
+ `; + $("reportProcessStatus").value = report.status || "RECEIVED"; + $("reportTargetStatus").addEventListener("change", syncReportTargetSuspendedUntilInput); + syncReportTargetSuspendedUntilInput(); + $("processReportButton").addEventListener("click", () => processReport(report.reportId)); +} + +async function processReport(reportId) { + const tingGrant = numberOrZero($("reportTingGrant").value); + const eventTingGrant = numberOrZero($("reportEventTingGrant").value); + const targetStatus = $("reportTargetStatus").value || null; + try { + await request(`/api/admin/reports/${reportId}`, { + method: "PATCH", + body: { + status: $("reportProcessStatus").value, + adminComment: $("reportAdminComment").value.trim(), + targetAccountStatus: targetStatus, + targetSuspendedUntil: targetStatus === "SUSPENDED" + ? localDateTimeOrNull($("reportTargetSuspendedUntil").value) + : null, + targetAccountReason: $("reportTargetAccountReason").value.trim(), + tingGrant, + eventTingGrant, + walletReason: $("reportWalletReason").value.trim() + } + }); + await loadReports(); + await loadReportDetail(reportId); + $("reportActionResult").classList.remove("error-text"); + $("reportActionResult").textContent = "신고 처리가 저장되었습니다."; + } catch (error) { + $("reportActionResult").textContent = `신고 처리 실패: ${error.message}`; + $("reportActionResult").classList.add("error-text"); + } +} + async function loadInquiries() { const status = $("inquiryStatusFilter").value; const query = status ? `?status=${encodeURIComponent(status)}` : ""; @@ -759,6 +976,103 @@ function selectedRoles() { return [...document.querySelectorAll(".role-card.selected")].map((button) => button.dataset.role); } +function userSnapshotHtml(user) { + if (!user) return `
정보가 없습니다.
`; + return ` + ${kv("User ID", user.userId)} + ${kv("Profile ID", user.profileId || "-")} + ${kv("이름", user.userName || "-")} + ${kv("닉네임", user.nickName || "-")} + ${kv("카카오 ID", user.kakaoId || "-")} + ${kv("전화번호", user.phoneNum || "-")} + ${kv("성별", genderLabel(user.gender))} + ${kv("생년월일", user.birthDate || "-")} + ${kv("학교", user.universityName || "-")} + ${kv("지역", [user.regionSidoName, user.regionSigunguName].filter(Boolean).join(" ") || "-")} + ${kv("인증", user.verified ? "완료" : "미완료")} + ${kvHtml("계정 상태", statusBadge(user.accountStatus))} + ${kv("상태 사유", user.statusReason || "-")} + ${kv("정지 만료", formatDate(user.statusSuspendedUntil))} + ${kv("팅", user.ting ?? "-")} + ${kv("이벤트 팅", user.eventTing ?? "-")} + ${kv("멤버십 만료", formatDate(user.membershipActiveUntil))} + `; +} + +function referenceContextHtml(context) { + const reported = context.reportedContext || {}; + return ` +
+

신고된 대상

+ ${kv("유형", reportTypeLabel(reported.type))} + ${kv("Context ID", reported.contextId || "-")} + ${reported.profileOwnerUserId ? kv("프로필 소유자", userDisplay(reported.profileOwnerUserId, reported.profileOwnerNickName, "")) : ""} +
+ ${chatMessagesHtml(reported.chatMessages || [])} + ${recentReportsHtml("대상자 최근 신고", context.targetRecentReports || [], "target")} + ${recentReportsHtml("신고자 최근 접수 이력", context.reporterRecentReports || [], "reporter")} +
+

결제/재화 참고

+
${escapeHtml(context.paymentHistory || "-")}
+
+ `; +} + +function chatMessagesHtml(messages) { + if (!messages.length) { + return ` +
+

채팅 내역

+
표시할 채팅 메시지가 없습니다.
+
+ `; + } + return ` +
+

채팅 내역 최신 50개

+
+ ${messages.map((message) => ` +
+ ${escapeHtml(userDisplay(message.senderUserId, message.senderNickName, ""))} + ${escapeHtml(formatDate(message.createdAt))} · ${escapeHtml(message.type || "-")} + ${escapeHtml(message.content || "")} +
+ `).join("")} +
+
+ `; +} + +function recentReportsHtml(title, reports, mode) { + if (!reports.length) { + return ` +
+

${escapeHtml(title)}

+
최근 이력이 없습니다.
+
+ `; + } + return ` +
+

${escapeHtml(title)}

+
+ ${reports.map((report) => { + const userText = mode === "target" + ? `신고자 ${userDisplay(report.reporterUserId, report.reporterNickName, "")}` + : `대상 ${userDisplay(report.targetUserId, report.targetNickName, "")}`; + return ` +
+ #${escapeHtml(report.reportId)} ${escapeHtml(reportTypeLabel(report.type))} · ${escapeHtml(reportReasonLabel(report.reason))} + ${escapeHtml(userText)} · ${escapeHtml(formatDate(report.createdAt))} + ${escapeHtml(report.status || "-")} +
+ `; + }).join("")} +
+
+ `; +} + function flattenPolicy(policy) { return policyKeys.map((key) => ({ key, value: getNested(policy, key) ?? 0 })); } @@ -800,6 +1114,14 @@ function getReasonValue(id) { return selected; } +function syncSuspendedUntilInput() { + $("suspendedUntilLabel").classList.toggle("hidden", $("statusSelect").value !== "SUSPENDED"); +} + +function syncReportTargetSuspendedUntilInput() { + $("reportTargetSuspendedUntilLabel").classList.toggle("hidden", $("reportTargetStatus").value !== "SUSPENDED"); +} + function reasonInline(prefix, options) { return `
@@ -860,6 +1182,33 @@ function genderLabel(gender) { return labels[gender] || gender || "-"; } +function userDisplay(userId, nickName, userName) { + const name = nickName || userName || "-"; + return `${name}(userId ${userId || "-"})`; +} + +function reportTypeLabel(type) { + const labels = { + CHAT: "채팅", + PROFILE: "프로필" + }; + return labels[type] || type || "-"; +} + +function reportReasonLabel(reason) { + const labels = { + INAPPROPRIATE_PROFILE: "부적절한 프로필", + ABUSIVE_LANGUAGE: "욕설 및 비하 발언", + SPAM: "스팸/홍보", + GHOSTING: "잠수", + NO_SHOW: "노쇼", + VIOLENT_LANGUAGE: "폭언 및 위협", + UNCOOPERATIVE: "비협조적인 태도", + ETC: "기타" + }; + return labels[reason] || reason || "-"; +} + function adminActorLabel(log) { return `${log.adminName || "관리자"}(${log.adminLoginId || log.adminId || "-"})`; } @@ -877,7 +1226,8 @@ function auditActionLabel(actionType) { WALLET_ADJUST: "팅 지갑 조정", MEMBERSHIP_ACTIVATE: "멤버십 활성화", POLICY_UPDATE: "운영 정책 변경", - PUSH_SEND: "푸시 발송" + PUSH_SEND: "푸시 발송", + REPORT_PROCESS: "신고 처리" }; return labels[actionType] || actionType || "-"; } @@ -888,7 +1238,8 @@ function auditTargetLabel(targetType) { USER: "회원", TING_WALLET: "팅 지갑", POLICY: "운영 정책", - PUSH: "푸시" + PUSH: "푸시", + REPORT: "신고" }; return labels[targetType] || targetType || "-"; } @@ -910,6 +1261,15 @@ function numberOrNull(value) { return Number.isFinite(parsed) && value !== "" ? parsed : null; } +function localDateTimeOrNull(value) { + return value ? value : null; +} + +function toDateTimeLocalValue(value) { + if (!value) return ""; + return String(value).slice(0, 16); +} + function formatDate(value) { if (!value) return "-"; return new Date(value).toLocaleString("ko-KR"); diff --git a/manabom/src/main/resources/static/admin/index.html b/manabom/src/main/resources/static/admin/index.html index 314bea9..7c7b655 100644 --- a/manabom/src/main/resources/static/admin/index.html +++ b/manabom/src/main/resources/static/admin/index.html @@ -38,6 +38,7 @@

관리자 로그인

+ @@ -247,6 +248,65 @@

관리자 생성/수정

+ +