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/AdminProcessReportRequest.java b/manabom/src/main/java/mannabom_server/manabom/application/admin/dto/request/AdminProcessReportRequest.java new file mode 100644 index 0000000..0e3e3e7 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/admin/dto/request/AdminProcessReportRequest.java @@ -0,0 +1,37 @@ +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; + + public boolean isTargetSuspensionRequested() { + return targetAccountStatus == UserAccountStatus.SUSPENDED; + } +} 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/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/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..d5c7965 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 @@ -1,10 +1,12 @@ package mannabom_server.manabom.application.admin.dto.response; import lombok.Getter; +import mannabom_server.manabom.domain.admin.entity.UserAccountRestriction; import mannabom_server.manabom.domain.admin.enums.UserAccountStatus; import mannabom_server.manabom.domain.user.enums.Gender; import java.time.Instant; +import java.time.LocalDateTime; @Getter public class AdminUserSummaryResponse { @@ -20,6 +22,7 @@ public class AdminUserSummaryResponse { private Boolean verified; private Boolean membership; private UserAccountStatus accountStatus; + private LocalDateTime statusSuspendedUntil; private Instant createdAt; public AdminUserSummaryResponse(Long userId, @@ -34,7 +37,9 @@ public AdminUserSummaryResponse(Long userId, Boolean verified, Boolean membership, UserAccountStatus accountStatus, - Instant createdAt) { + LocalDateTime statusSuspendedUntil, + Instant createdAt, + LocalDateTime now) { this.userId = userId; this.profileId = profileId; this.kakaoId = kakaoId; @@ -46,7 +51,8 @@ public AdminUserSummaryResponse(Long userId, this.regionSigunguName = regionSigunguName; this.verified = verified; this.membership = membership; - this.accountStatus = accountStatus == null ? UserAccountStatus.ACTIVE : accountStatus; + this.accountStatus = UserAccountRestriction.effectiveStatus(accountStatus, statusSuspendedUntil, now); + this.statusSuspendedUntil = statusSuspendedUntil; this.createdAt = createdAt; } } diff --git a/manabom/src/main/java/mannabom_server/manabom/application/admin/service/AdminAuditService.java b/manabom/src/main/java/mannabom_server/manabom/application/admin/service/AdminAuditService.java index 2042812..3492541 100644 --- a/manabom/src/main/java/mannabom_server/manabom/application/admin/service/AdminAuditService.java +++ b/manabom/src/main/java/mannabom_server/manabom/application/admin/service/AdminAuditService.java @@ -14,7 +14,6 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import java.util.List; @@ -29,7 +28,7 @@ public class AdminAuditService { private final AdminAuditLogRepository adminAuditLogRepository; private final AdminAccountRepository adminAccountRepository; - @Transactional(propagation = Propagation.REQUIRES_NEW) + @Transactional public void log(Long adminId, AdminAuditActionType actionType, AdminAuditTargetType targetType, 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..e35a25b --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/admin/service/AdminReportService.java @@ -0,0 +1,409 @@ +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.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 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(request); + Long targetUserId = report.getTarget().getUserId(); + UserAccountRestriction restriction = userAccountRestrictionRepository.findById(targetUserId) + .orElseGet(() -> UserAccountRestriction.builder() + .userId(targetUserId) + .status(UserAccountStatus.ACTIVE) + .updatedByAdminId(admin.adminId()) + .build()); + LocalDateTime auditNow = LocalDateTime.now(); + String before = accountStatusLabel(restriction, auditNow); + 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, auditNow), + 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", profileRepository.findByUser(report.getTarget()) + .map(Profile::getNickName) + .orElse(null)); + } + 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(AdminProcessReportRequest request) { + if (!request.isTargetSuspensionRequested()) { + return; + } + LocalDateTime suspendedUntil = request.getTargetSuspendedUntil(); + if (suspendedUntil == null || !suspendedUntil.isAfter(LocalDateTime.now())) { + throw new IllegalArgumentException("정지 만료 시각은 현재 시각 이후여야 합니다."); + } + } + + private String accountStatusLabel(UserAccountRestriction restriction, LocalDateTime now) { + UserAccountStatus effectiveStatus = restriction.effectiveStatus(now); + if (effectiveStatus == UserAccountStatus.SUSPENDED && restriction.getSuspendedUntil() != null) { + return effectiveStatus.name() + " until " + restriction.getSuspendedUntil(); + } + return effectiveStatus.name(); + } + + 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)) { + return; + } + throw new IllegalStateException("신고 처리 권한이 없습니다."); + } +} 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..f1e7ad1 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,7 +78,9 @@ public AdminUserListResponse searchUsers(AdminPrincipal admin, else false end, restriction.status, - u.createdAt + restriction.suspendedUntil, + u.createdAt, + :now ) from User u left join Profile p on p.user = u @@ -89,6 +91,7 @@ public AdminUserListResponse searchUsers(AdminPrincipal admin, """ + where + " order by u.userId desc"; TypedQuery query = entityManager.createQuery(selectJpql, AdminUserSummaryResponse.class); + query.setParameter("now", LocalDateTime.now()); applySearchParams(query, keyword, field, statusFilter); List users = query .setFirstResult(safePage * safeSize) @@ -122,6 +125,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 +133,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 +157,15 @@ 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()); + LocalDateTime auditNow = LocalDateTime.now(); + String before = accountStatusLabel(restriction, auditNow); + restriction.update(request.getStatus(), request.getReason(), request.getSuspendedUntil(), admin.adminId()); userAccountRestrictionRepository.save(restriction); + String after = accountStatusLabel(restriction, auditNow); 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 +202,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 +229,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 +280,33 @@ 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) { + return; + } + if (suspendedUntil == null || !suspendedUntil.isAfter(LocalDateTime.now())) { + throw new IllegalArgumentException("정지 만료 시각은 현재 시각 이후여야 합니다."); + } + } + + private String accountStatusLabel(UserAccountRestriction restriction, LocalDateTime now) { + if (restriction == null) { + return UserAccountStatus.ACTIVE.name(); + } + UserAccountStatus effectiveStatus = restriction.effectiveStatus(now); + if (effectiveStatus == UserAccountStatus.SUSPENDED && restriction.getSuspendedUntil() != null) { + return effectiveStatus.name() + " until " + restriction.getSuspendedUntil(); + } + return effectiveStatus.name(); + } + 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/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(); diff --git a/manabom/src/main/java/mannabom_server/manabom/application/meeting/handler/MatchingEventListener.java b/manabom/src/main/java/mannabom_server/manabom/application/meeting/handler/MatchingEventListener.java index 0f574aa..37aef69 100644 --- a/manabom/src/main/java/mannabom_server/manabom/application/meeting/handler/MatchingEventListener.java +++ b/manabom/src/main/java/mannabom_server/manabom/application/meeting/handler/MatchingEventListener.java @@ -8,6 +8,8 @@ import org.springframework.context.event.EventListener; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; import java.util.List; @@ -19,7 +21,7 @@ public class MatchingEventListener { private final RedisMatchAtomicOps atomic; @Async - @EventListener + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void handleMatchRequest(MeetingMatchingEvent event){ Long myId = event.getMeetingId(); if(!atomic.tryLock(event.getMeetingId())) { diff --git a/manabom/src/main/java/mannabom_server/manabom/application/meeting/service/MeetingMatchingService.java b/manabom/src/main/java/mannabom_server/manabom/application/meeting/service/MeetingMatchingService.java index 1a46038..c85a0e8 100644 --- a/manabom/src/main/java/mannabom_server/manabom/application/meeting/service/MeetingMatchingService.java +++ b/manabom/src/main/java/mannabom_server/manabom/application/meeting/service/MeetingMatchingService.java @@ -145,13 +145,12 @@ private void accumulateMatchCandidate(Set redisCandidates, Long meetingI if(finalMatchCandidate.size()>= limit) return; if(redisCandidates==null || redisCandidates.isEmpty()) return; - List parsedList = redisCandidates.stream() + List parsedList = new ArrayList<>(redisCandidates.stream() .map(Long::parseLong) - .filter(id-> !id.equals(meetingId)) - .filter(id-> !myHistory.contains(id)) - .filter(id-> !finalMatchCandidate.contains(id)) - .toList(); - + .filter(id -> !id.equals(meetingId)) + .filter(id -> !myHistory.contains(id)) + .filter(id -> !finalMatchCandidate.contains(id)) + .toList()); Collections.shuffle(parsedList); diff --git a/manabom/src/main/java/mannabom_server/manabom/application/meeting/service/MeetingVerificationService.java b/manabom/src/main/java/mannabom_server/manabom/application/meeting/service/MeetingVerificationService.java index 26b7dfb..3321b81 100644 --- a/manabom/src/main/java/mannabom_server/manabom/application/meeting/service/MeetingVerificationService.java +++ b/manabom/src/main/java/mannabom_server/manabom/application/meeting/service/MeetingVerificationService.java @@ -7,15 +7,16 @@ import mannabom_server.manabom.domain.chat.enums.ChatMemberStatus; import mannabom_server.manabom.domain.chat.repository.ChatMemberRepository; import mannabom_server.manabom.domain.chat.repository.ChatRoomRepository; -import mannabom_server.manabom.domain.meeting.entity.Meeting; import mannabom_server.manabom.domain.meeting.entity.MeetingParticipant; import mannabom_server.manabom.domain.meeting.entity.MeetingVerification; import mannabom_server.manabom.domain.meeting.repository.MeetingParticipantRepository; import mannabom_server.manabom.domain.meeting.repository.MeetingVerificationRepository; +import mannabom_server.manabom.domain.user.entity.Profile; import mannabom_server.manabom.domain.user.enums.Gender; import mannabom_server.manabom.domain.user.repository.ProfileRepository; import mannabom_server.manabom.global.util.LocationUtils; -import org.springframework.data.redis.core.RedisTemplate; +import org.redisson.api.RedissonClient; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.stereotype.Service; @@ -23,6 +24,7 @@ import java.time.*; import java.util.ArrayList; import java.util.List; +import java.util.Optional; import java.util.Set; import org.springframework.transaction.annotation.Transactional; @@ -37,20 +39,28 @@ public class MeetingVerificationService { private final ProfileRepository profileRepository; private final StringRedisTemplate stringRedisTemplate; + private final RedissonClient redissonClient; private final SimpMessagingTemplate simpMessagingTemplate; private static final String POS_KEY = "meeting:pos:%d:%d"; private static final String GATHER_KEY = "meeting:gather:%d"; private static final String FINAL_LOC_KEY = "meeting:final:%d"; - private static final double LIMIT_DISTANCE = 200.0; - private static final int POS_TTL_MINS = 30; + private static final double LIMIT_DISTANCE = 100.0; + private static final Duration VERIFICATION_TTL = Duration.ofHours(9); @Transactional public String verifyMeeting(Long chatRoomId, Long userId, double latitude, double longitude){ ChatRoom room = chatRoomRepository.findById(chatRoomId) .orElseThrow(()-> new IllegalArgumentException("존재 하지 않는 채팅방 입니다.")); - MeetingVerification verification = meetingVerificationRepository.findByRoomId(chatRoomId) - .orElseGet(()-> meetingVerificationRepository.save(MeetingVerification.builder().room(room).build())); + getActiveChatMember(chatRoomId, userId); + + MeetingVerification verification = getOrCreateVerification(room); + Instant now = Instant.now(); + + verification.startIfNeeded(now, VERIFICATION_TTL); + if(verification.isExpired(now)){ + throw new IllegalStateException("만남인증 가능 시간이 지났습니다."); + } if(verification.isVerified()){ processLatecomer(chatRoomId, userId, latitude, longitude); return "합류 성공! 보상이 지급됩니다."; @@ -58,13 +68,116 @@ public String verifyMeeting(Long chatRoomId, Long userId, double latitude, doubl return processGeneral(chatRoomId, userId, latitude, longitude, verification); } + private Optional findActiveChatMember(Long chatRoomId, Long userId){ + return chatMemberRepository.findByRoomIdAndUser_UserIdAndStatus( + chatRoomId, + userId, + ChatMemberStatus.ACTIVATE + ); + } + + private Optional findGender(ChatMember member){ + return profileRepository.findByUser(member.getUser()) + .map(Profile::getGender); + } + + @Transactional(readOnly = true) + public MeetingVerificationStatusData getStatus(Long chatRoomId, Long userId){ + chatRoomRepository.findById(chatRoomId) + .orElseThrow(()-> new IllegalArgumentException("존재 하지 않는 채팅방 입니다.")); + getActiveChatMember(chatRoomId, userId); + + Optional verificationOpt = meetingVerificationRepository.findByRoomId(chatRoomId); + int totalMembers = chatMemberRepository.countChatMemberByRoomIdAndStatus(chatRoomId, ChatMemberStatus.ACTIVATE); + int requiredCount = requiredCount(totalMembers); + if(verificationOpt.isEmpty()){ + return MeetingVerificationStatusData.notStarted(totalMembers, requiredCount, BestCluster.empty(), false); + } + + MeetingVerification verification = verificationOpt.get(); + boolean mySubmitted = stringRedisTemplate.hasKey(String.format(POS_KEY, chatRoomId, userId)); + FinalLocation myLocation = getUserLocation(chatRoomId, userId).orElse(null); + BestCluster bestCluster = findBestCluster(chatRoomId); + + Instant now = Instant.now(); + long remainingSeconds = Math.max(0, verification.remainingTime(now).toSeconds()); + FinalLocation finalLocation = getFinalLocation(chatRoomId).orElse(null); + + if(verification.isVerified()){ + return MeetingVerificationStatusData.verified( + verification, + remainingSeconds, + totalMembers, + requiredCount, + bestCluster, + mySubmitted, + myLocation, + finalLocation + ); + } + + if(verification.isExpired(now)){ + return MeetingVerificationStatusData.expired( + verification, + totalMembers, + requiredCount, + bestCluster, + mySubmitted, + myLocation + ); + } + + return MeetingVerificationStatusData.inProgress( + verification, + remainingSeconds, + totalMembers, + requiredCount, + bestCluster, + mySubmitted, + myLocation + ); + } + + private MeetingVerification getOrCreateVerification(ChatRoom room){ + return meetingVerificationRepository.findByRoomId(room.getId()) + .orElseGet(() -> { + try { + return meetingVerificationRepository.saveAndFlush( + MeetingVerification.builder().room(room).build() + ); + } catch (DataIntegrityViolationException e) { + return meetingVerificationRepository.findByRoomId(room.getId()) + .orElseThrow(() -> e); + } + }); + } + + private ChatMember getActiveChatMember(Long chatRoomId, Long userId){ + return chatMemberRepository.findByRoomIdAndUser_UserIdAndStatus( + chatRoomId, + userId, + ChatMemberStatus.ACTIVATE + ).orElseThrow(() -> new IllegalArgumentException("현재 채팅방에 속해있는 멤버가 아닙니다.")); + } + + private void processLatecomer(Long chatRoomId, Long userId, double latitude, double longitude ){ + if(isAlreadyVerifiedParticipant(chatRoomId, userId)){ + throw new IllegalStateException("이미 만남인증에 참여 완료되었습니다."); + } + String finalPos = stringRedisTemplate.opsForValue().get(String.format(FINAL_LOC_KEY,chatRoomId)); - if(finalPos == null) throw new IllegalStateException("인증 유효 기간 (당일 자정)이 지났습니다."); + if(finalPos == null) throw new IllegalStateException("인증 유효 기간이 지났습니다."); String[] s = finalPos.split(","); if(LocationUtils.calculateDistance(latitude,longitude,Double.parseDouble(s[0]), Double.parseDouble(s[1]))> LIMIT_DISTANCE) - throw new IllegalStateException("인증 장소와 너무 멉니다!(200m 이내)"); + throw new IllegalStateException("인증 장소와 너무 멉니다!(100m 이내)"); + + Duration positionTtl = verificationRemainingTime(chatRoomId); + if(positionTtl.isZero() || positionTtl.isNegative()){ + throw new IllegalStateException("만남인증 가능 시간이 지났습니다."); + } + saveUserLocation(chatRoomId, userId, latitude, longitude, positionTtl); //보상 reward(chatRoomId, List.of(userId)); @@ -74,43 +187,34 @@ private void processLatecomer(Long chatRoomId, Long userId, double latitude, dou private String processGeneral(Long chatRoomId, Long userId, double latitude, double longitude, MeetingVerification verification ){ String posKey = String.format(POS_KEY, chatRoomId, userId); String gatherKey = String.format(GATHER_KEY, chatRoomId); + Duration positionTtl = verification.remainingTime(Instant.now()); + if(positionTtl.isZero() || positionTtl.isNegative()){ + throw new IllegalStateException("만남인증 가능 시간이 지났습니다."); + } - stringRedisTemplate.opsForValue().set(posKey, latitude+","+longitude, Duration.ofMinutes(POS_TTL_MINS)); + saveUserLocation(chatRoomId, userId, latitude, longitude, positionTtl); stringRedisTemplate.opsForSet().add(gatherKey,userId.toString()); - stringRedisTemplate.expire(gatherKey, Duration.ofMinutes(POS_TTL_MINS)); + redissonClient.getSet(gatherKey).expire(positionTtl); - Set userIds = stringRedisTemplate.opsForSet().members(gatherKey); - List nearbyIds = new ArrayList<>(); - - if(userIds!=null){ - for(String id: userIds ){ - String pos = stringRedisTemplate.opsForValue().get(String.format(POS_KEY, chatRoomId, Long.parseLong(id))); - if(pos!=null){ - String[] s = pos.split(","); - if(LocationUtils.calculateDistance(latitude, longitude, Double.parseDouble(s[0]), Double.parseDouble(s[1]))< LIMIT_DISTANCE) - nearbyIds.add(Long.parseLong(id)); - } - } - } int totalMembers = chatMemberRepository.countChatMemberByRoomIdAndStatus(chatRoomId, ChatMemberStatus.ACTIVATE); - int nearbyCount = nearbyIds.size(); - if(nearbyCount>=(totalMembers/2)){ - List members = chatMemberRepository.findAllById(nearbyIds); - boolean hasMale = members.stream().anyMatch( - m-> profileRepository.findByUser(m.getUser()).get().getGender()== Gender.MALE - ); - boolean hasFemale = members.stream().anyMatch( - m-> profileRepository.findByUser(m.getUser()).get().getGender()== Gender.FEMALE - ); + int requiredCount = requiredCount(totalMembers); + BestCluster bestCluster = findBestCluster(chatRoomId); + int nearbyCount = bestCluster.count(); - if(hasMale && hasFemale){ + if(nearbyCount >= requiredCount){ + if(bestCluster.hasMale() && bestCluster.hasFemale()){ verification.verify(); - reward(chatRoomId, nearbyIds); - - LocalDateTime now = LocalDateTime.now(); - Duration timeUntilMidnight = Duration.between(now, now.with(LocalTime.MAX)); + reward(chatRoomId, bestCluster.userIds()); + Duration finalLocationTtl = verification.remainingTime(Instant.now()); + if(finalLocationTtl.isZero() || finalLocationTtl.isNegative()){ + throw new IllegalStateException("만남인증 가능 시간이 지났습니다."); + } - stringRedisTemplate.opsForValue().set(String.format(FINAL_LOC_KEY, chatRoomId), latitude + "," + longitude); + stringRedisTemplate.opsForValue().set( + String.format(FINAL_LOC_KEY, chatRoomId), + bestCluster.latitude() + "," + bestCluster.longitude() + ); + redissonClient.getBucket(String.format(FINAL_LOC_KEY, chatRoomId)).expire(finalLocationTtl); stringRedisTemplate.delete(gatherKey); simpMessagingTemplate.convertAndSend("/topic/chat/" + chatRoomId, "VERIFIED_SUCCESS"); @@ -126,14 +230,277 @@ private String processGeneral(Long chatRoomId, Long userId, double latitude, dou private void reward(Long chatRoomId, List userIds){ for(Long userId : userIds){ - ChatMember m = chatMemberRepository.findByRoomIdAndUser_UserIdAndStatus(chatRoomId,userId, ChatMemberStatus.ACTIVATE) - .orElseThrow(()->new IllegalArgumentException("현재 채팅방에 속해있는 멤버가 아닙니다.")); + ChatMember m = getActiveChatMember(chatRoomId, userId); MeetingParticipant p = meetingParticipantRepository.findByChatMemberId(m.getId()) - .orElseThrow(()-> new IllegalStateException("인증 참여 정보가 생성되지 않았습니다.")); + .orElseGet(() -> meetingParticipantRepository.save( + MeetingParticipant.builder() + .chatMember(m) + .build() + )); if(!p.isVerified()){ p.verify(); } } } + + private boolean isAlreadyVerifiedParticipant(Long chatRoomId, Long userId){ + ChatMember chatMember = getActiveChatMember(chatRoomId, userId); + return meetingParticipantRepository.findByChatMemberId(chatMember.getId()) + .map(MeetingParticipant::isVerified) + .orElse(false); + } + + private int requiredCount(int totalMembers){ + return Math.max(2, (int) Math.ceil(totalMembers / 2.0)); + } + + private BestCluster findBestCluster(Long chatRoomId){ + List submissions = getLocationSubmissions(chatRoomId); + if(submissions.isEmpty()){ + return BestCluster.empty(); + } + + BestCluster bestCluster = BestCluster.empty(); + for(LocationSubmission center : submissions){ + List nearby = submissions.stream() + .filter(submission -> LocationUtils.calculateDistance( + center.latitude(), + center.longitude(), + submission.latitude(), + submission.longitude() + ) <= LIMIT_DISTANCE) + .toList(); + + if(nearby.size() > bestCluster.count()){ + bestCluster = toBestCluster(chatRoomId, nearby); + } + } + return bestCluster; + } + + private BestCluster toBestCluster(Long chatRoomId, List submissions){ + List userIds = submissions.stream() + .map(LocationSubmission::userId) + .toList(); + List members = userIds.stream() + .flatMap(id -> findActiveChatMember(chatRoomId, id).stream()) + .toList(); + + boolean hasMale = members.stream() + .flatMap(member -> findGender(member).stream()) + .anyMatch(gender -> gender == Gender.MALE); + + boolean hasFemale = members.stream() + .flatMap(member -> findGender(member).stream()) + .anyMatch(gender -> gender == Gender.FEMALE); + + double latitude = submissions.stream() + .mapToDouble(LocationSubmission::latitude) + .average() + .orElse(0.0); + double longitude = submissions.stream() + .mapToDouble(LocationSubmission::longitude) + .average() + .orElse(0.0); + + return new BestCluster(userIds, latitude, longitude, hasMale, hasFemale); + } + + private List getLocationSubmissions(Long chatRoomId){ + String gatherKey = String.format(GATHER_KEY, chatRoomId); + Set userIds = stringRedisTemplate.opsForSet().members(gatherKey); + List submissions = new ArrayList<>(); + + if(userIds == null){ + return submissions; + } + + for(String id: userIds){ + Long parsedUserId = Long.parseLong(id); + String pos = stringRedisTemplate.opsForValue().get(String.format(POS_KEY, chatRoomId, parsedUserId)); + if(pos != null){ + String[] s = pos.split(","); + submissions.add(new LocationSubmission( + parsedUserId, + Double.parseDouble(s[0]), + Double.parseDouble(s[1]) + )); + } + } + + return submissions; + } + + private Optional getFinalLocation(Long chatRoomId){ + String finalPos = stringRedisTemplate.opsForValue().get(String.format(FINAL_LOC_KEY,chatRoomId)); + if(finalPos == null){ + return Optional.empty(); + } + String[] s = finalPos.split(","); + return Optional.of(new FinalLocation(Double.parseDouble(s[0]), Double.parseDouble(s[1]))); + } + + private Optional getUserLocation(Long chatRoomId, Long userId){ + String pos = stringRedisTemplate.opsForValue().get(String.format(POS_KEY, chatRoomId, userId)); + if(pos == null){ + return Optional.empty(); + } + String[] s = pos.split(","); + return Optional.of(new FinalLocation(Double.parseDouble(s[0]), Double.parseDouble(s[1]))); + } + + private void saveUserLocation(Long chatRoomId, Long userId, double latitude, double longitude, Duration ttl){ + stringRedisTemplate.opsForValue().set( + String.format(POS_KEY, chatRoomId, userId), + latitude + "," + longitude + ); + redissonClient.getBucket(String.format(POS_KEY, chatRoomId, userId)).expire(ttl); + } + + private Duration verificationRemainingTime(Long chatRoomId){ + MeetingVerification verification = meetingVerificationRepository.findByRoomId(chatRoomId) + .orElseThrow(() -> new IllegalStateException("만남인증 정보가 존재하지 않습니다.")); + return verification.remainingTime(Instant.now()); + } + + public record MeetingVerificationStatusData( + String status, + Instant startedAt, + Instant expiresAt, + long remainingSeconds, + int totalMembers, + int requiredCount, + int bestClusterCount, + boolean hasMale, + boolean hasFemale, + boolean mySubmitted, + boolean verified, + FinalLocation myLocation, + FinalLocation finalLocation, + FinalLocation bestClusterLocation + ) { + static MeetingVerificationStatusData notStarted(int totalMembers, int requiredCount, BestCluster bestCluster, boolean mySubmitted){ + return new MeetingVerificationStatusData( + "NOT_STARTED", + null, + null, + 0, + totalMembers, + requiredCount, + bestCluster.count(), + bestCluster.hasMale(), + bestCluster.hasFemale(), + mySubmitted, + false, + null, + null, + bestCluster.locationOrNull() + ); + } + + static MeetingVerificationStatusData inProgress( + MeetingVerification verification, + long remainingSeconds, + int totalMembers, + int requiredCount, + BestCluster bestCluster, + boolean mySubmitted, + FinalLocation myLocation + ){ + return new MeetingVerificationStatusData( + "IN_PROGRESS", + verification.getStartedAt(), + verification.getExpiresAt(), + remainingSeconds, + totalMembers, + requiredCount, + bestCluster.count(), + bestCluster.hasMale(), + bestCluster.hasFemale(), + mySubmitted, + false, + myLocation, + null, + bestCluster.locationOrNull() + ); + } + + static MeetingVerificationStatusData verified( + MeetingVerification verification, + long remainingSeconds, + int totalMembers, + int requiredCount, + BestCluster bestCluster, + boolean mySubmitted, + FinalLocation myLocation, + FinalLocation finalLocation + ){ + return new MeetingVerificationStatusData( + "VERIFIED", + verification.getStartedAt(), + verification.getExpiresAt(), + remainingSeconds, + totalMembers, + requiredCount, + bestCluster.count(), + bestCluster.hasMale(), + bestCluster.hasFemale(), + mySubmitted, + true, + myLocation, + finalLocation, + bestCluster.locationOrNull() + ); + } + + static MeetingVerificationStatusData expired( + MeetingVerification verification, + int totalMembers, + int requiredCount, + BestCluster bestCluster, + boolean mySubmitted, + FinalLocation myLocation + ){ + return new MeetingVerificationStatusData( + "EXPIRED", + verification.getStartedAt(), + verification.getExpiresAt(), + 0, + totalMembers, + requiredCount, + bestCluster.count(), + bestCluster.hasMale(), + bestCluster.hasFemale(), + mySubmitted, + false, + myLocation, + null, + bestCluster.locationOrNull() + ); + } + } + + public record FinalLocation(double latitude, double longitude) { + } + + private record LocationSubmission(Long userId, double latitude, double longitude) { + } + + private record BestCluster(List userIds, double latitude, double longitude, boolean hasMale, boolean hasFemale) { + static BestCluster empty(){ + return new BestCluster(List.of(), 0.0, 0.0, false, false); + } + + int count(){ + return userIds.size(); + } + + FinalLocation locationOrNull(){ + if(userIds.isEmpty()){ + return null; + } + return new FinalLocation(latitude, longitude); + } + } } 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..c697a5e 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,55 @@ 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) { + return effectiveStatus(status, suspendedUntil, now); + } + + public static UserAccountStatus effectiveStatus(UserAccountStatus status, LocalDateTime suspendedUntil, LocalDateTime now) { + if (status == null) { + return UserAccountStatus.ACTIVE; + } + if (status == UserAccountStatus.SUSPENDED + && suspendedUntil != null + && !suspendedUntil.isAfter(now)) { + return UserAccountStatus.ACTIVE; + } + return status; + } } 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/meeting/entity/MeetingMatch.java b/manabom/src/main/java/mannabom_server/manabom/domain/meeting/entity/MeetingMatch.java index 526314f..e3b83c3 100644 --- a/manabom/src/main/java/mannabom_server/manabom/domain/meeting/entity/MeetingMatch.java +++ b/manabom/src/main/java/mannabom_server/manabom/domain/meeting/entity/MeetingMatch.java @@ -9,6 +9,7 @@ import mannabom_server.manabom.domain.meeting.enums.MatchingStatusConverter; import mannabom_server.manabom.domain.meeting.enums.MeetingDecision; import mannabom_server.manabom.domain.meeting.enums.MeetingDecisionConverter; +import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.NotFound; import org.hibernate.annotations.NotFoundAction; import org.springframework.data.annotation.CreatedDate; @@ -43,7 +44,7 @@ public class MeetingMatch { @Convert(converter = MatchingStatusConverter.class) private MatchingStatus matchingStatus; - @CreatedDate + @CreationTimestamp @Column(nullable = false) private Instant createdAt; diff --git a/manabom/src/main/java/mannabom_server/manabom/domain/meeting/entity/MeetingVerification.java b/manabom/src/main/java/mannabom_server/manabom/domain/meeting/entity/MeetingVerification.java index f8f1002..54ed186 100644 --- a/manabom/src/main/java/mannabom_server/manabom/domain/meeting/entity/MeetingVerification.java +++ b/manabom/src/main/java/mannabom_server/manabom/domain/meeting/entity/MeetingVerification.java @@ -6,8 +6,8 @@ import lombok.Getter; import lombok.NoArgsConstructor; import mannabom_server.manabom.domain.chat.entity.ChatRoom; -import mannabom_server.manabom.domain.user.entity.User; +import java.time.Duration; import java.time.Instant; @Entity @@ -25,6 +25,10 @@ public class MeetingVerification { private boolean isVerified = false; + private Instant startedAt; + + private Instant expiresAt; + private Instant verifiedAt; @Builder @@ -32,6 +36,24 @@ public MeetingVerification(ChatRoom room) { this.room = room; } + public void startIfNeeded(Instant now, Duration validDuration){ + if(this.startedAt == null){ + this.startedAt = now; + this.expiresAt = now.plus(validDuration); + } + } + + public boolean isExpired(Instant now){ + return this.expiresAt != null && !now.isBefore(this.expiresAt); + } + + public Duration remainingTime(Instant now){ + if(this.expiresAt == null){ + return Duration.ZERO; + } + Duration remaining = Duration.between(now, this.expiresAt); + return remaining.isNegative() ? Duration.ZERO : remaining; + } public void verify(){ this.isVerified = true; 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/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/infrastructure/redis/meetingmatching/RedisMatchAtomicOps.java b/manabom/src/main/java/mannabom_server/manabom/infrastructure/redis/meetingmatching/RedisMatchAtomicOps.java index 0ec5a80..6c54496 100644 --- a/manabom/src/main/java/mannabom_server/manabom/infrastructure/redis/meetingmatching/RedisMatchAtomicOps.java +++ b/manabom/src/main/java/mannabom_server/manabom/infrastructure/redis/meetingmatching/RedisMatchAtomicOps.java @@ -1,40 +1,54 @@ package mannabom_server.manabom.infrastructure.redis.meetingmatching; import lombok.RequiredArgsConstructor; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.script.DefaultRedisScript; import org.springframework.stereotype.Component; -import java.time.Duration; import java.util.List; +import java.util.concurrent.TimeUnit; @Component @RequiredArgsConstructor public class RedisMatchAtomicOps { private final StringRedisTemplate redis; - private static final Duration LOCK_TTL = Duration.ofSeconds(3); + private final RedissonClient redissonClient; - //동시성 문제 해결-> 여러 큐를 순회하면서 특정 유저 지우기 + + + // 동시성 문제 해결 -> 여러 큐를 순회하면서 특정 미팅팀 제거 private static final DefaultRedisScript REMOVE_SCRIPT = new DefaultRedisScript<>( - "local id = ARGV[1]; local rm =0; "+ - "for i=1,#KEYS do rm = rm + redis.call('ZREM', KEYS[i], id) end; "+ - "if rm >0 then return 1 else return 0 end", Long.class - ); - - public boolean tryLock(Long meetingId){ - String key = lockKey(meetingId); - return Boolean.TRUE.equals( - redis.opsForValue().setIfAbsent(key,"1",LOCK_TTL) - ); + "local id = ARGV[1]; local rm = 0; " + + "for i = 1, #KEYS do rm = rm + redis.call('ZREM', KEYS[i], id) end; " + + "if rm > 0 then return 1 else return 0 end", + Long.class + ); + + public boolean tryLock(Long meetingId) { + RLock lock = redissonClient.getLock(lockKey(meetingId)); + try { + return lock.tryLock(0, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } } - public void tryUnlock(Long meetingId){ - redis.delete(lockKey(meetingId)); + + public void tryUnlock(Long meetingId) { + RLock lock = redissonClient.getLock(lockKey(meetingId)); + if (lock.isHeldByCurrentThread()) { + lock.unlock(); + } } - public boolean atomicRemove(Long id, List keys){ + + public boolean atomicRemove(Long id, List keys) { Long result = redis.execute(REMOVE_SCRIPT, keys, String.valueOf(id)); return result == 1L; } - private String lockKey(Long meetingId){ - return String.format("lock:match:"+meetingId); + + private String lockKey(Long meetingId) { + return String.format("lock:match:%d", meetingId); } -} +} \ No newline at end of file diff --git a/manabom/src/main/java/mannabom_server/manabom/infrastructure/redis/meetingmatching/RedisMatchHistory.java b/manabom/src/main/java/mannabom_server/manabom/infrastructure/redis/meetingmatching/RedisMatchHistory.java index fa09528..a346989 100644 --- a/manabom/src/main/java/mannabom_server/manabom/infrastructure/redis/meetingmatching/RedisMatchHistory.java +++ b/manabom/src/main/java/mannabom_server/manabom/infrastructure/redis/meetingmatching/RedisMatchHistory.java @@ -1,6 +1,7 @@ package mannabom_server.manabom.infrastructure.redis.meetingmatching; import lombok.RequiredArgsConstructor; +import org.redisson.api.RedissonClient; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Component; @@ -13,32 +14,39 @@ @RequiredArgsConstructor public class RedisMatchHistory { private final StringRedisTemplate redis; + private final RedissonClient redissonClient; + private static final Duration HISTORY_TTL = Duration.ofDays(30); - public void addHistory(Long me, Long opponent){ + public void addHistory(Long me, Long opponent) { String myKey = historyKey(me); String oppKey = historyKey(opponent); - redis.opsForSet().add(myKey,String.valueOf(opponent)); - redis.opsForSet().add(oppKey,String.valueOf(me)); + redis.opsForSet().add(myKey, String.valueOf(opponent)); + redis.opsForSet().add(oppKey, String.valueOf(me)); - redis.expire(myKey,HISTORY_TTL); - redis.expire(oppKey,HISTORY_TTL); + redissonClient.getSet(myKey).expire(HISTORY_TTL); + redissonClient.getSet(oppKey).expire(HISTORY_TTL); } - public Set getHistory(Long me){ + + public Set getHistory(Long me) { Set members = redis.opsForSet().members(historyKey(me)); - if(members==null || members.isEmpty()) + if (members == null || members.isEmpty()) { return Collections.emptySet(); + } - return members.stream().map(Long::valueOf).collect(Collectors.toSet()); - } - public void removeHistory(Long meetingId, Long opponentId){ - redis.opsForSet().remove(historyKey(meetingId),String.valueOf(opponentId)); - redis.opsForSet().remove(historyKey(opponentId),String.valueOf(meetingId)); + return members.stream() + .map(Long::valueOf) + .collect(Collectors.toSet()); } - private String historyKey(Long meetingId){ - return "history:match:"+meetingId; + + public void removeHistory(Long meetingId, Long opponentId) { + redis.opsForSet().remove(historyKey(meetingId), String.valueOf(opponentId)); + redis.opsForSet().remove(historyKey(opponentId), String.valueOf(meetingId)); } -} + private String historyKey(Long meetingId) { + return "history:match:" + meetingId; + } +} \ No newline at end of file diff --git a/manabom/src/main/java/mannabom_server/manabom/infrastructure/redis/meetingmatching/RedisMatchQueue.java b/manabom/src/main/java/mannabom_server/manabom/infrastructure/redis/meetingmatching/RedisMatchQueue.java index c3f2348..79029a3 100644 --- a/manabom/src/main/java/mannabom_server/manabom/infrastructure/redis/meetingmatching/RedisMatchQueue.java +++ b/manabom/src/main/java/mannabom_server/manabom/infrastructure/redis/meetingmatching/RedisMatchQueue.java @@ -70,7 +70,7 @@ public List execute(@NonNull RedisOperations operations) { } public void removeActiveKey(String queueKey){ - redis.opsForZSet().remove(ACTIVE_KEY,queueKey); + redis.opsForSet().remove(ACTIVE_KEY,queueKey); } public String timeKey(String sidoCode, int memberCount, String gender){ @@ -146,7 +146,7 @@ public Set anyAgeInSidoCandidates(String sidoCode, int membercount, Stri return redis.opsForZSet().range( timeKey(sidoCode,membercount,gender), 0, - limit + limit -1 ); } 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/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(); + } +} 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, "프로필 사진 요청을 거절하였습니다.")); } diff --git a/manabom/src/main/java/mannabom_server/manabom/presentation/meeting/controller/MeetingVerificationController.java b/manabom/src/main/java/mannabom_server/manabom/presentation/meeting/controller/MeetingVerificationController.java new file mode 100644 index 0000000..6c18ab9 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/presentation/meeting/controller/MeetingVerificationController.java @@ -0,0 +1,49 @@ +package mannabom_server.manabom.presentation.meeting.controller; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import mannabom_server.manabom.application.common.dto.ApiResponse; +import mannabom_server.manabom.application.meeting.service.MeetingVerificationService; +import mannabom_server.manabom.application.meeting.service.MeetingVerificationService.MeetingVerificationStatusData; +import mannabom_server.manabom.presentation.meeting.dto.MeetingVerificationRequest; +import mannabom_server.manabom.presentation.meeting.dto.MeetingVerificationResponse; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/meeting/verification") +public class MeetingVerificationController { + + private final MeetingVerificationService meetingVerificationService; + + @PostMapping("/{chatRoomId}") + public ResponseEntity> verifyMeeting( + @PathVariable Long chatRoomId, + @AuthenticationPrincipal Long userId, + @Valid @RequestBody MeetingVerificationRequest request + ) { + String message = meetingVerificationService.verifyMeeting( + chatRoomId, + userId, + request.getLatitude(), + request.getLongitude() + ); + + return ResponseEntity.ok(ApiResponse.success( + new MeetingVerificationResponse(message), + "만남 인증 요청이 처리되었습니다." + )); + } + + @GetMapping("/{chatRoomId}/status") + public ResponseEntity> getStatus( + @PathVariable Long chatRoomId, + @AuthenticationPrincipal Long userId + ) { + MeetingVerificationStatusData data = meetingVerificationService.getStatus(chatRoomId, userId); + return ResponseEntity.ok(ApiResponse.success(data, "만남 인증 상태 조회가 완료되었습니다.")); + } + +} diff --git a/manabom/src/main/java/mannabom_server/manabom/presentation/meeting/dto/MeetingVerificationRequest.java b/manabom/src/main/java/mannabom_server/manabom/presentation/meeting/dto/MeetingVerificationRequest.java new file mode 100644 index 0000000..c0d5715 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/presentation/meeting/dto/MeetingVerificationRequest.java @@ -0,0 +1,19 @@ +package mannabom_server.manabom.presentation.meeting.dto; + +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; + +@Getter +public class MeetingVerificationRequest { + @NotNull + @DecimalMin("-90.0") + @DecimalMax("90.0") + private Double latitude; + + @NotNull + @DecimalMin("-180.0") + @DecimalMax("180.0") + private Double longitude; +} diff --git a/manabom/src/main/java/mannabom_server/manabom/presentation/meeting/dto/MeetingVerificationResponse.java b/manabom/src/main/java/mannabom_server/manabom/presentation/meeting/dto/MeetingVerificationResponse.java new file mode 100644 index 0000000..5106e65 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/presentation/meeting/dto/MeetingVerificationResponse.java @@ -0,0 +1,5 @@ +package mannabom_server.manabom.presentation.meeting.dto; + +public record MeetingVerificationResponse(String message) { +} + 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); 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/V20__add_meeting_verification_valid_window.sql b/manabom/src/main/resources/db/migration/V20__add_meeting_verification_valid_window.sql new file mode 100644 index 0000000..df0a8b4 --- /dev/null +++ b/manabom/src/main/resources/db/migration/V20__add_meeting_verification_valid_window.sql @@ -0,0 +1,3 @@ +ALTER TABLE meeting_verification + ADD COLUMN IF NOT EXISTS started_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ; diff --git a/manabom/src/main/resources/static/admin/app.js b/manabom/src/main/resources/static/admin/app.js index a56787a..c7d129e 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); @@ -388,11 +432,18 @@ function renderUserDetail(user) { async function saveUserStatus() { if (!state.selectedUserId) return; + const status = $("statusSelect").value; + const suspendedUntil = status === "SUSPENDED" ? localDateTimeOrNull($("suspendedUntilInput").value) : null; + if (status === "SUSPENDED" && !suspendedUntil) { + showUserActionResult("정지 만료 시각을 입력하세요.", true); + return; + } try { await request(`/api/admin/users/${state.selectedUserId}/status`, { method: "PATCH", body: { - status: $("statusSelect").value, + status, + suspendedUntil, reason: getReasonValue("statusReason") } }); @@ -630,6 +681,184 @@ 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; + const targetSuspendedUntil = targetStatus === "SUSPENDED" + ? localDateTimeOrNull($("reportTargetSuspendedUntil").value) + : null; + if (targetStatus === "SUSPENDED" && !targetSuspendedUntil) { + $("reportActionResult").textContent = "정지 만료 시각을 입력하세요."; + $("reportActionResult").classList.add("error-text"); + return; + } + try { + await request(`/api/admin/reports/${reportId}`, { + method: "PATCH", + body: { + status: $("reportProcessStatus").value, + adminComment: $("reportAdminComment").value.trim(), + targetAccountStatus: targetStatus, + targetSuspendedUntil, + 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 +988,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 +1126,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 +1194,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 +1238,8 @@ function auditActionLabel(actionType) { WALLET_ADJUST: "팅 지갑 조정", MEMBERSHIP_ACTIVATE: "멤버십 활성화", POLICY_UPDATE: "운영 정책 변경", - PUSH_SEND: "푸시 발송" + PUSH_SEND: "푸시 발송", + REPORT_PROCESS: "신고 처리" }; return labels[actionType] || actionType || "-"; } @@ -888,7 +1250,8 @@ function auditTargetLabel(targetType) { USER: "회원", TING_WALLET: "팅 지갑", POLICY: "운영 정책", - PUSH: "푸시" + PUSH: "푸시", + REPORT: "신고" }; return labels[targetType] || targetType || "-"; } @@ -910,6 +1273,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 @@

관리자 생성/수정

+ +