Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ dependencies {
testImplementation("io.rest-assured:rest-assured:5.3.1")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testImplementation("org.mockito.kotlin:mockito-kotlin:5.1.0")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")

// jwt
Expand Down
73 changes: 64 additions & 9 deletions src/main/kotlin/codel/admin/presentation/AdminController.kt
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,9 @@ class AdminController(
"PENDING" to adminService.countMembersByStatus("PENDING"),
"DONE" to adminService.countMembersByStatus("DONE"),
"REJECT" to adminService.countMembersByStatus("REJECT"),
"PHONE_VERIFIED" to adminService.countMembersByStatus("PHONE_VERIFIED")
"PHONE_VERIFIED" to adminService.countMembersByStatus("PHONE_VERIFIED"),
"WITHDRAWN" to adminService.countMembersByStatus("WITHDRAWN"),
"PERSONALITY_COMPLETED" to adminService.countMembersByStatus("PERSONALITY_COMPLETED")
)

model.addAttribute("members", members)
Expand Down Expand Up @@ -365,11 +367,9 @@ class AdminController(
val questions = adminService.findQuestionsWithFilter(keyword, category, isActive, pageable)
model.addAttribute("questions", questions)
model.addAttribute("categories", QuestionCategory.values())
model.addAttribute("param", mapOf(
"keyword" to (keyword ?: ""),
"category" to (category ?: ""),
"isActive" to (isActive?.toString() ?: "")
))
model.addAttribute("selectedKeyword", keyword ?: "")
model.addAttribute("selectedCategory", category ?: "")
model.addAttribute("selectedIsActive", isActive?.toString() ?: "")
return "questionList"
}

Expand Down Expand Up @@ -400,11 +400,21 @@ class AdminController(
@GetMapping("/v1/admin/questions/{questionId}/edit")
fun editQuestionForm(
@PathVariable questionId: Long,
@RequestParam(required = false) keyword: String?,
@RequestParam(required = false) category: String?,
@RequestParam(required = false) isActive: String?,
@RequestParam(required = false, defaultValue = "0") page: Int,
@RequestParam(required = false, defaultValue = "20") size: Int,
model: Model
): String {
val question = adminService.findQuestionById(questionId)
model.addAttribute("question", question)
model.addAttribute("categories", QuestionCategory.values())
model.addAttribute("filterKeyword", keyword)
model.addAttribute("filterCategory", category)
model.addAttribute("filterIsActive", isActive)
model.addAttribute("filterPage", page)
model.addAttribute("filterSize", size)
return "questionEditForm"
}

Expand All @@ -415,6 +425,11 @@ class AdminController(
@RequestParam category: String,
@RequestParam(required = false) description: String?,
@RequestParam(defaultValue = "false") isActive: Boolean,
@RequestParam(required = false) keyword: String?,
@RequestParam(required = false) filterCategory: String?,
@RequestParam(required = false) filterIsActive: String?,
@RequestParam(required = false, defaultValue = "0") page: Int,
@RequestParam(required = false, defaultValue = "20") size: Int,
redirectAttributes: RedirectAttributes
): String {
try {
Expand All @@ -424,12 +439,27 @@ class AdminController(
} catch (e: Exception) {
redirectAttributes.addFlashAttribute("error", "질문 수정에 실패했습니다: ${e.message}")
}
return "redirect:/v1/admin/questions"

// 필터 조건 유지하여 리다이렉트
val params = mutableListOf<String>()
keyword?.let { if (it.isNotBlank()) params.add("keyword=$it") }
filterCategory?.let { if (it.isNotBlank()) params.add("category=$it") }
filterIsActive?.let { if (it.isNotBlank()) params.add("isActive=$it") }
params.add("page=$page")
params.add("size=$size")

val queryString = if (params.isNotEmpty()) "?${params.joinToString("&")}" else ""
return "redirect:/v1/admin/questions$queryString"
}

@PostMapping("/v1/admin/questions/{questionId}/delete")
fun deleteQuestion(
@PathVariable questionId: Long,
@RequestParam(required = false) keyword: String?,
@RequestParam(required = false) category: String?,
@RequestParam(required = false) isActive: String?,
@RequestParam(required = false, defaultValue = "0") page: Int,
@RequestParam(required = false, defaultValue = "20") size: Int,
redirectAttributes: RedirectAttributes
): String {
try {
Expand All @@ -438,12 +468,27 @@ class AdminController(
} catch (e: Exception) {
redirectAttributes.addFlashAttribute("error", "질문 삭제에 실패했습니다: ${e.message}")
}
return "redirect:/v1/admin/questions"

// 필터 조건 유지하여 리다이렉트
val params = mutableListOf<String>()
keyword?.let { if (it.isNotBlank()) params.add("keyword=$it") }
category?.let { if (it.isNotBlank()) params.add("category=$it") }
isActive?.let { if (it.isNotBlank()) params.add("isActive=$it") }
params.add("page=$page")
params.add("size=$size")

val queryString = if (params.isNotEmpty()) "?${params.joinToString("&")}" else ""
return "redirect:/v1/admin/questions$queryString"
}

@PostMapping("/v1/admin/questions/{questionId}/toggle")
fun toggleQuestionStatus(
@PathVariable questionId: Long,
@RequestParam(required = false) keyword: String?,
@RequestParam(required = false) category: String?,
@RequestParam(required = false) isActive: String?,
@RequestParam(required = false, defaultValue = "0") page: Int,
@RequestParam(required = false, defaultValue = "20") size: Int,
redirectAttributes: RedirectAttributes
): String {
try {
Expand All @@ -453,7 +498,17 @@ class AdminController(
} catch (e: Exception) {
redirectAttributes.addFlashAttribute("error", "질문 상태 변경에 실패했습니다: ${e.message}")
}
return "redirect:/v1/admin/questions"

// 필터 조건 유지하여 리다이렉트
val params = mutableListOf<String>()
keyword?.let { if (it.isNotBlank()) params.add("keyword=$it") }
category?.let { if (it.isNotBlank()) params.add("category=$it") }
isActive?.let { if (it.isNotBlank()) params.add("isActive=$it") }
params.add("page=$page")
params.add("size=$size")

val queryString = if (params.isNotEmpty()) "?${params.joinToString("&")}" else ""
return "redirect:/v1/admin/questions$queryString"
}

// ========== 신고 관리 ==========
Expand Down
25 changes: 23 additions & 2 deletions src/main/kotlin/codel/member/business/MemberService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -701,11 +701,32 @@ class MemberService(
null
}

// 날짜 파싱 - yyyy-MM-dd 형식의 문자열을 LocalDateTime으로 변환
val startDateTime = if (!startDate.isNullOrBlank()) {
try {
LocalDate.parse(startDate).atStartOfDay() // 00:00:00
} catch (e: Exception) {
null
}
} else {
null
}

val endDateTime = if (!endDate.isNullOrBlank()) {
try {
LocalDate.parse(endDate).plusDays(1).atStartOfDay() // 다음 날 00:00:00 (해당일 23:59:59까지 포함)
} catch (e: Exception) {
null
}
} else {
null
}

// 정렬 처리를 위한 새로운 Pageable 생성
val sortedPageable = createSortedPageable(pageable, sort, direction)

// 새로운 메서드 사용
return memberJpaRepository.findMembersWithFilterAdvanced(keyword, statusEnum, sortedPageable)
// 새로운 메서드 사용 (날짜 파라미터 추가)
return memberJpaRepository.findMembersWithFilterAdvanced(keyword, statusEnum, startDateTime, endDateTime, sortedPageable)
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,14 +85,17 @@ interface MemberJpaRepository : JpaRepository<Member, Long> {
WHERE (:status IS NULL OR m.memberStatus = :status)
AND (
:keyword IS NULL OR :keyword = ''
OR LOWER(m.email) LIKE LOWER(CONCAT('%', :keyword, '%'))
OR LOWER(p.codeName) LIKE LOWER(CONCAT('%', :keyword, '%'))
)
AND (:startDate IS NULL OR m.createdAt >= :startDate)
AND (:endDate IS NULL OR m.createdAt < :endDate)
"""
)
fun findMembersWithFilterAdvanced(
@Param("keyword") keyword: String?,
@Param("status") status: MemberStatus?,
@Param("startDate") startDate: LocalDateTime?,
@Param("endDate") endDate: LocalDateTime?,
pageable: Pageable
): Page<Member>

Expand Down
14 changes: 11 additions & 3 deletions src/main/kotlin/codel/recommendation/business/CodeTimeService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -144,9 +144,11 @@ class CodeTimeService(
* 제외 대상:
* - 차단한 사용자
* - 나를 차단한 사용자
* - 최근 시그널 보낸 사용자
* - WITHDRAWN 상태의 사용자 (회원 탈퇴)
*
* 주의: 시그널 관계는 실시간 필터링에서 제외하지 않음
* → 추천 세션 일관성 유지를 위해 새로운 추천 생성 시에만 제외
*
* @param user 기준 사용자
* @param memberIds 필터링할 사용자 ID 목록
* @return 필터링된 사용자 ID 목록
Expand All @@ -156,15 +158,21 @@ class CodeTimeService(
return emptyList()
}

// 실시간 제외 대상 조회 (차단만)
val excludeIds = mutableSetOf<Long>()

// 1. 차단 관계만 확인 (즉시 반영)
excludeIds.addAll(exclusionService.getBlockedMemberIds(user))
excludeIds.addAll(exclusionService.getRecentSignalMemberIds(user))

// WITHDRAWN 상태의 회원 필터링
// 2. 시그널 관계는 확인하지 않음 (추천 세션 일관성 유지)
// → 새로운 추천 생성 시에만 제외됨

// 3. WITHDRAWN 상태의 회원 필터링
// getMembersByIds를 통해 조회하면 자동으로 WITHDRAWN이 제외됨
val validMembers = bucketService.getMembersByIds(memberIds)
val validIds = validMembers.map { it.getIdOrThrow() }

// 4. 최종 필터링
val filteredIds = validIds.filter { it !in excludeIds }

log.debug {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,22 +112,22 @@ interface RecommendationHistoryJpaRepository : JpaRepository<RecommendationHisto
/**
* 시간 범위 내 특정 시간대 코드타임 추천 조회
* 날짜가 바뀌는 경우를 처리하기 위한 범위 조회
*
*
* @param user 사용자
* @param timeSlot 시간대 ("10:00" 또는 "22:00")
* @param startDateTime 시작 시간 (포함)
* @param endDateTime 종료 시간 (미포함)
* @return 해당 시간 범위 내 추천된 사용자 ID 목록
* @return 해당 시간 범위 내 추천된 사용자 ID 목록 (생성 순서대로)
*/
@Query("""
SELECT rh.recommendedUser.id
FROM RecommendationHistory rh
WHERE rh.user = :user
SELECT rh.recommendedUser.id
FROM RecommendationHistory rh
WHERE rh.user = :user
AND rh.recommendationType = 'CODE_TIME'
AND rh.recommendationTimeSlot = :timeSlot
AND rh.recommendedAt >= :startDateTime
AND rh.recommendedAt < :endDateTime
ORDER BY rh.createdAt DESC
ORDER BY rh.createdAt ASC
""")
fun findCodeTimeIdsByTimeRange(
@Param("user") user: Member,
Expand Down
28 changes: 26 additions & 2 deletions src/main/resources/templates/memberList.html
Original file line number Diff line number Diff line change
Expand Up @@ -186,8 +186,8 @@ <h4 th:text="${statusCounts.REJECT}">0</h4>
<small>거부됨</small>
</div>
<div class="col-md-2">
<h4 th:text="${statusCounts.PHONE_VERIFIED}">0</h4>
<small>핸드폰 인증 완료</small>
<h4 th:text="${statusCounts.WITHDRAWN}">0</h4>
<small>탈퇴</small>
</div>
<div class="col-md-2">
<h4 th:text="${#numbers.formatDecimal((statusCounts.DONE * 100.0) / (statusCounts.DONE + statusCounts.REJECT), 1, 1) + '%'}">0%</h4>
Expand All @@ -213,6 +213,8 @@ <h4 th:text="${#numbers.formatDecimal((statusCounts.DONE * 100.0) / (statusCount
<option th:selected="${param.status == 'DONE'}" value="DONE">승인 완료</option>
<option th:selected="${param.status == 'REJECT'}" value="REJECT">거부됨</option>
<option th:selected="${param.status == 'PHONE_VERIFIED'}" value="PHONE_VERIFIED">핸드폰 인증 완료</option>
<option th:selected="${param.status == 'PERSONALITY_COMPLETED'}" value="PERSONALITY_COMPLETED">오픈프로필 작성 완료</option>
<option th:selected="${param.status == 'WITHDRAWN'}" value="WITHDRAWN">탈퇴</option>
</select>
</div>
<div class="col-md-2">
Expand Down Expand Up @@ -295,6 +297,22 @@ <h4 th:text="${#numbers.formatDecimal((statusCounts.DONE * 100.0) / (statusCount
<span class="badge badge-signup ms-2" th:text="${statusCounts.PHONE_VERIFIED}">0</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link d-flex align-items-center"
th:classappend="${param.status == 'PERSONALITY_COMPLETED'} ? 'active'"
th:href="@{/v1/admin/members(status='PERSONALITY_COMPLETED')}">
<span>오픈프로필 작성 완료</span>
<span class="badge bg-info ms-2" th:text="${statusCounts.PERSONALITY_COMPLETED}">0</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link d-flex align-items-center"
th:classappend="${param.status == 'WITHDRAWN'} ? 'active'"
th:href="@{/v1/admin/members(status='WITHDRAWN')}">
<span>탈퇴</span>
<span class="badge bg-secondary ms-2" th:text="${statusCounts.WITHDRAWN}">0</span>
</a>
</li>
</ul>

<!-- 빠른 액션 바 -->
Expand Down Expand Up @@ -401,6 +419,12 @@ <h4 th:text="${#numbers.formatDecimal((statusCounts.DONE * 100.0) / (statusCount
<span class="badge badge-signup" th:case="'PHONE_VERIFIED'">
<i class="fa-solid fa-user-plus me-1"></i>핸드폰 인증 완료
</span>
<span class="badge bg-info text-white" th:case="'PERSONALITY_COMPLETED'">
<i class="fa-solid fa-user-edit me-1"></i>오픈프로필 작성 완료
</span>
<span class="badge bg-secondary" th:case="'WITHDRAWN'">
<i class="fa-solid fa-user-slash me-1"></i>탈퇴
</span>
<span class="badge bg-light text-dark" th:case="*" th:text="${member.memberStatus}"></span>
</span>
</td>
Expand Down
15 changes: 12 additions & 3 deletions src/main/resources/templates/questionEditForm.html
Original file line number Diff line number Diff line change
Expand Up @@ -41,17 +41,25 @@
<main class="col-md-10 ms-sm-auto px-md-5 py-5">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="fw-bold">질문 수정</h1>
<a href="/v1/admin/questions" class="btn btn-outline-secondary">
<a th:href="@{/v1/admin/questions(keyword=${filterKeyword}, category=${filterCategory}, isActive=${filterIsActive}, page=${filterPage}, size=${filterSize})}"
class="btn btn-outline-secondary">
<i class="fa-solid fa-arrow-left"></i> 목록으로
</a>
</div>

<div class="card form-card">
<div class="card-body">
<form th:action="@{/v1/admin/questions/{id}(id=${question.id})}" method="post">
<!-- 필터 파라미터 유지 -->
<input type="hidden" name="keyword" th:value="${filterKeyword}">
<input type="hidden" name="filterCategory" th:value="${filterCategory}">
<input type="hidden" name="filterIsActive" th:value="${filterIsActive}">
<input type="hidden" name="page" th:value="${filterPage}">
<input type="hidden" name="size" th:value="${filterSize}">

<div class="mb-4">
<label for="content" class="form-label fw-bold">질문 내용 <span class="text-danger">*</span></label>
<textarea class="form-control" id="content" name="content" rows="3"
<textarea class="form-control" id="content" name="content" rows="3"
placeholder="질문 내용을 입력하세요" required maxlength="500"
th:text="${question.content}"></textarea>
<div class="form-text">최대 500자까지 입력 가능합니다.</div>
Expand Down Expand Up @@ -91,7 +99,8 @@ <h1 class="fw-bold">질문 수정</h1>
<button type="submit" class="btn btn-primary">
<i class="fa-solid fa-save"></i> 수정
</button>
<a href="/v1/admin/questions" class="btn btn-outline-secondary">
<a th:href="@{/v1/admin/questions(keyword=${filterKeyword}, category=${filterCategory}, isActive=${filterIsActive}, page=${filterPage}, size=${filterSize})}"
class="btn btn-outline-secondary">
<i class="fa-solid fa-times"></i> 취소
</a>
</div>
Expand Down
Loading