Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
28254b6
feat: 페널티/경고 타입 분리 및 예외 추가
woneeeee Aug 24, 2026
0578400
feat: 페널티 부여 API - 다수 멤버 및 점수 지원
woneeeee Aug 24, 2026
0dfaba9
feat: 페널티 수정 API에 점수 수정 기능 추가
woneeeee Aug 24, 2026
7c4197c
feat: 어드민 멤버 페널티 상세 조회 API 추가
woneeeee Aug 24, 2026
ed02c86
fix: 페널티 삭제 시 score 기반 카운트 차감
woneeeee Aug 24, 2026
93902c4
feat: 패널티 규정 저장 API 추가
woneeeee Aug 24, 2026
20eed0d
feat: 어드민 멤버 목록에 페널티 수 및 최근 페널티 일시 추가
woneeeee Aug 24, 2026
a98bd6b
feat: 마이페이지 stats에 페널티 수 추가
woneeeee Aug 24, 2026
5b9a55e
feat: 마이페이지 페널티 목록 조회 API 추가
woneeeee Aug 24, 2026
6db74d1
feat: 마이페이지 패널티 규정 조회 API 추가
woneeeee Aug 24, 2026
de66d54
style: 한국어 표기 패널티→페널티 일괄 수정
woneeeee Aug 24, 2026
975be9a
test: 페널티 기능 추가에 따른 테스트 코드 컴파일 오류 수정
woneeeee Aug 24, 2026
e8dd5df
test: penaltyReader 스텁 누락
woneeeee Aug 24, 2026
1c440ab
test: 페널티 테스트 Fixture 추가
woneeeee Aug 24, 2026
ae62a8f
test: UpdatePenaltyUseCase 테스트 추가
woneeeee Aug 24, 2026
07cd52f
test: GetPenaltyQueryService 테스트 추가
woneeeee Aug 24, 2026
ca10ec7
test: SavePenaltyRuleUseCase 테스트 추가
woneeeee Aug 24, 2026
d5741a1
test: GetPenaltyRuleQueryService 테스트 추가
woneeeee Aug 24, 2026
ec0f03a
test: Penalty Entity 테스트 추가
woneeeee Aug 24, 2026
fc91a47
feat: 멤버 검색 QueryService 메서드 추가
woneeeee Aug 24, 2026
411fd63
feat: 멤버 검색 API 엔드포인트 추가 (GET /members/search)
woneeeee Aug 24, 2026
ced4eae
test: 멤버 검색 QueryService 및 Controller 테스트 추가
woneeeee Aug 24, 2026
3ecebba
feat: club 테이블 warning_enabled·penalty_rule 컬럼 마이그레이션 추가 및 nullable 명시
woneeeee Aug 25, 2026
b50a856
refactor: GetPenaltyRuleQueryService @Transactional(readOnly=true) 클래…
woneeeee Aug 25, 2026
8616053
fix: WARNING 활성화 여부 검증을 반복문 밖으로 이동
woneeeee Aug 25, 2026
31e83cb
fix: 페널티 사유 최대 50자 제한 추가
woneeeee Aug 25, 2026
667ae03
refactor: 멤버 검색 API 페이지네이션 제거
woneeeee Aug 25, 2026
fcc2f23
docs: QueryService @Transactional(readOnly=true) 클래스 레벨 컨벤션 rules에 추가
woneeeee Aug 25, 2026
14f0b9c
test: 멤버 검색 테스트에서 페이지네이션 제거
woneeeee Aug 25, 2026
d741941
revert: 마이페이지 stats 페널티 수 제거 (유저 PR로 분리)
woneeeee Aug 25, 2026
0d7bb6d
revert: UserMyPageMapper 페널티 매핑 제거 (유저 PR로 분리)
woneeeee Aug 25, 2026
a4068b6
revert: GetUserMyPageQueryService 페널티 조회 제거 (유저 PR로 분리)
woneeeee Aug 25, 2026
d4336b7
revert: ClubMemberMyPageController 페널티 엔드포인트 제거 (유저 PR로 분리)
woneeeee Aug 25, 2026
a6f911c
revert: UserResponseCode 페널티 응답 코드 제거 (유저 PR로 분리)
woneeeee Aug 25, 2026
09b9d5b
revert: ClubMemberMyPageControllerTest 제거 (유저 PR로 분리)
woneeeee Aug 25, 2026
6120d53
revert: UserMyPenaltyResponse 제거 (유저 PR로 분리)
woneeeee Aug 25, 2026
af0716b
revert: GetUserPenaltyQueryService 제거 (유저 PR로 분리)
woneeeee Aug 25, 2026
5473c04
fix: 페널티 사유 최대 글자 수 50자에서 20자로 수정
woneeeee Aug 25, 2026
976b590
chore: N+1 쿼리 개선 TODO 주석 추가
woneeeee Aug 25, 2026
8475eda
fix: V12 마이그레이션에 warning_count, score 컬럼 추가
woneeeee Aug 25, 2026
2505c42
fix: UpdatePenaltyRequest 사유 최대 글자 수 20자 제한 추가
woneeeee Aug 25, 2026
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
2 changes: 1 addition & 1 deletion .claude/rules/transaction-concurrency.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Use the `concurrency-safety` skill when changing transaction boundaries, lock be

## Transaction Placement

- `@Transactional` goes on **UseCase** methods only — Command: `@Transactional`, Query: `@Transactional(readOnly = true)`
- `@Transactional` goes on **UseCase** only — Command UseCase: `@Transactional` on each method; Query Service: `@Transactional(readOnly = true)` at the **class level** (not method level)
- Domain Services must NOT have `@Transactional`; UseCase owns transaction boundaries
- Keep transactions short — no external I/O (S3, HTTP) inside transactions

Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ src/main/resources/*.p8
src/test/resources/*.env
.env.local
.env.*.local
.env

### Claude Code ###
# 로컬 평가 텔레메트리 — 분석은 로컬에서, 결론만 docs/plan에 반영
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,10 @@ data class ClubMemberResponse(
val absenceCount: Int,
@field:Schema(description = "출석률 (%)", example = "83")
val attendanceRate: Int,
@field:Schema(description = "패널티 횟수", example = "1")
@field:Schema(description = "페널티 횟수", example = "1")
val penaltyCount: Int,
@field:Schema(description = "최근 페널티 일시 (없으면 null)", nullable = true)
val lastPenaltyAt: LocalDateTime? = null,
@field:Schema(description = "프로필 이미지 URL", example = "https://cdn.weeth.com/profile/1.png", nullable = true)
val profileImageUrl: String? = null,
@field:Schema(description = "자기소개", example = "안녕하세요", nullable = true)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ class ClubMapper(
fun toMemberResponse(
member: ClubMember,
cardinals: List<ClubMemberCardinal>,
lastPenaltyAt: java.time.LocalDateTime? = null,
) = ClubMemberResponse(
userId = member.user.id,
clubMemberId = member.id,
Expand All @@ -82,6 +83,7 @@ class ClubMapper(
absenceCount = member.attendanceStats.absenceCount,
attendanceRate = member.attendanceStats.attendanceRate,
penaltyCount = member.penaltyCount,
lastPenaltyAt = lastPenaltyAt,
profileImageUrl = resolveMemberProfileImage(member),
bio = resolveMemberBio(member),
joinedAt = member.createdAt,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import com.weeth.domain.club.domain.repository.ClubMemberCardinalReader
import com.weeth.domain.club.domain.repository.ClubMemberReader
import com.weeth.domain.club.domain.service.ClubMemberPolicy
import com.weeth.domain.club.domain.service.ClubPermissionPolicy
import com.weeth.domain.penalty.domain.repository.PenaltyReader
import com.weeth.domain.user.domain.repository.UserReader
import com.weeth.global.common.response.PageResponse
import org.springframework.data.domain.PageRequest
Expand All @@ -27,6 +28,7 @@ class GetClubMemberQueryService(
private val clubPermissionPolicy: ClubPermissionPolicy,
private val clubMapper: ClubMapper,
private val userReader: UserReader,
private val penaltyReader: PenaltyReader,
) {
fun findClubMembersForAdmin(
clubId: Long,
Expand All @@ -49,17 +51,31 @@ class GetClubMemberQueryService(
pageable = pageable,
)

// 기수는 조회된 페이지의 멤버에 대해서만 일괄 조회해 N+1을 피한다.
// 기수와 최근 페널티는 조회된 페이지의 멤버에 대해서만 일괄 조회해 N+1을 피한다.
val clubMemberIds = members.content.map { it.id }
val cardinalsByMemberId =
if (members.isEmpty) {
emptyMap()
} else {
clubMemberCardinalReader.findAllByClubMembers(members.content).groupBy { it.clubMember.id }
}
val lastPenaltyAtByMemberId =
if (clubMemberIds.isEmpty()) {
emptyMap()
} else {
penaltyReader
.findByClubMemberIds(clubMemberIds)
.groupBy { it.clubMember.id }
.mapValues { (_, penalties) -> penalties.first().createdAt }
}

return PageResponse.from(
members.map { member ->
clubMapper.toMemberResponse(member, cardinalsByMemberId[member.id] ?: emptyList())
clubMapper.toMemberResponse(
member,
cardinalsByMemberId[member.id] ?: emptyList(),
lastPenaltyAtByMemberId[member.id],
)
},
)
}
Expand Down Expand Up @@ -113,7 +129,24 @@ class GetClubMemberQueryService(
return clubMapper.toMemberSummaryResponse(member, cardinals)
}

fun searchClubMembers(
clubId: Long,
userId: Long,
keyword: String,
cardinalNumber: Int?,
): List<ClubMemberResponse> =
findClubMembersForAdmin(
clubId = clubId,
userId = userId,
page = 0,
size = MAX_SEARCH_SIZE,
keyword = keyword,
cardinalNumber = cardinalNumber,
sort = ClubMemberSort.CARDINAL_DESC, // 검색은 기수 내림차순 고정
).content

companion object {
private const val MAX_PAGE_SIZE = 100
private const val MAX_SEARCH_SIZE = 50
}
}
24 changes: 24 additions & 0 deletions src/main/kotlin/com/weeth/domain/club/domain/entity/Club.kt
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,14 @@ class Club(
var backgroundImageStorageKey: String? = backgroundImageStorageKey
private set

@Column(nullable = false)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DB 변경시 resources/db/migration에 마이그레이션 쿼리가 필요합니당
Flyway에 대해서 한 번 Claude와 함께 이야기해보고, 이해한 후에 추가해주세용

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아 확인했습니당 V12__add_club_warning_and_penalty_rule.sql로 파일 추가했습니다!

var warningEnabled: Boolean = false
private set

@Column(length = 500, nullable = true)
var penaltyRule: String? = null
private set

// todo: 동아리 삭제 지원

fun update(
Expand Down Expand Up @@ -119,6 +127,21 @@ class Club(
}
}

fun updatePenaltyRule(rule: String?) {
rule?.let {
require(it.length <= MAX_PENALTY_RULE_LENGTH) { "패널티 규정은 ${MAX_PENALTY_RULE_LENGTH}자 이하여야 합니다." }
}
this.penaltyRule = rule?.takeIf { it.isNotBlank() }
}

fun enableWarning() {
warningEnabled = true
}

fun disableWarning() {
warningEnabled = false
}

fun regenerateCode(newCode: String) {
require(newCode.isNotBlank()) { "초대 코드는 비어 있을 수 없습니다." }
this.code = newCode
Expand All @@ -141,6 +164,7 @@ class Club(

companion object {
private const val MAX_DESCRIPTION_LENGTH = 30
private const val MAX_PENALTY_RULE_LENGTH = 500

fun create(
name: String,
Expand Down
30 changes: 27 additions & 3 deletions src/main/kotlin/com/weeth/domain/club/domain/entity/ClubMember.kt
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ class ClubMember(
var penaltyCount: Int = 0
private set

@Column(nullable = false)
var warningCount: Int = 0
private set

@Column(length = 500)
var profileImageStorageKey: String? = null
private set
Expand Down Expand Up @@ -158,16 +162,25 @@ class ClubMember(
attendanceStats.recalculate(attendCount, absentCount)
}

fun incrementPenaltyCount() {
penaltyCount++
fun incrementPenaltyCount(score: Int = 1) {
require(score > 0) { "페널티 점수는 1 이상이어야 합니다." }
penaltyCount += score
}

fun adjustPenaltyCount(delta: Int) {
penaltyCount = (penaltyCount + delta).coerceAtLeast(0)
}

fun adjustWarningCount(delta: Int) {
warningCount = (warningCount + delta).coerceAtLeast(0)
}

fun resetPenaltyCount() {
penaltyCount = 0
}

fun recalculatePenaltyCount(count: Int) {
require(count >= 0) { "패널티 수는 0 이상이어야 합니다." }
require(count >= 0) { "페널티 수는 0 이상이어야 합니다." }
penaltyCount = count
}

Expand Down Expand Up @@ -198,6 +211,17 @@ class ClubMember(
}
}

fun incrementWarningCount(score: Int = 1) {
require(score > 0) { "경고 점수는 1 이상이어야 합니다." }
warningCount += score
}

fun decrementWarningCount() {
if (warningCount > 0) {
warningCount--
}
}

companion object {
private const val RETENTION_DAYS = 30L

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,34 @@ class ClubAdminController(
return CommonResponse.success(ClubResponseCode.MEMBER_FIND_ALL_SUCCESS, members)
}

@GetMapping("/members/search")
@Operation(
summary = "동아리 멤버 이름 검색",
description = """
멤버 이름으로 검색합니다. 가입 대기·추방·탈퇴 멤버도 포함됩니다.

사용 예시:
- 전체 멤버 검색: GET /api/v4/admin/clubs/xxx/members/search?keyword=김
- 5기만 검색: GET /api/v4/admin/clubs/xxx/members/search?keyword=김&cardinalNumber=5
""",
)
fun searchClubMembers(
@Parameter(hidden = true) @CurrentUser userId: Long,
@TsidParam
@TsidPathVariable clubId: Long,
@RequestParam keyword: String,
@RequestParam(required = false) cardinalNumber: Int?,
): CommonResponse<List<ClubMemberResponse>> {
val members =
getClubMemberQueryService.searchClubMembers(
clubId = clubId,
userId = userId,
keyword = keyword,
cardinalNumber = cardinalNumber,
)
return CommonResponse.success(ClubResponseCode.MEMBER_FIND_ALL_SUCCESS, members)
}

@GetMapping("/members/{clubMemberId}")
@Operation(summary = "동아리 멤버 상세 조회", description = "가입 대기·추방·탈퇴 멤버도 조회할 수 있습니다.")
fun getClubMemberDetail(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,23 @@
package com.weeth.domain.penalty.application.dto.request

import com.weeth.domain.penalty.domain.enums.PenaltyType
import io.swagger.v3.oas.annotations.media.Schema
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.NotEmpty
import jakarta.validation.constraints.Positive
import jakarta.validation.constraints.Size

data class SavePenaltyRequest(
@field:Schema(description = "패널티 대상 사용자 ID", example = "1")
val userId: Long,
@field:Schema(description = "패널티 사유", example = "정기모임 무단 불참")
val penaltyDescription: String?,
@field:Schema(description = "페널티 대상 사용자 ID 목록", example = "[1, 2, 3]")
@field:NotEmpty
val userIds: List<Long>,
@field:Schema(description = "페널티 점수", example = "1")
@field:Positive
val score: Int = 1,
@field:Schema(description = "페널티 사유", example = "정기모임 무단 불참")
@field:NotBlank
@field:Size(max = 20)
val penaltyDescription: String,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

사유가 디자인 스펙에는 몇 자인지 안 나타나있는 것 같네용 한 번 확인해서 반영해주세요!

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

피그마 코멘트로 한 번 여쭤보긴 했는데 일단 임의로 50자로 제한해두었습니닷 추후 스펙이 확정되면 수정하겠습니다!

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

디자인 상으로는 20자가 적합할 것 같다고 하셔서 20자로 수정해두었습니다!

@field:Schema(description = "페널티 타입 (기본값: PENALTY)", example = "PENALTY", allowableValues = ["PENALTY", "WARNING"])
val penaltyType: PenaltyType = PenaltyType.PENALTY,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.weeth.domain.penalty.application.dto.request

import io.swagger.v3.oas.annotations.media.Schema
import jakarta.validation.constraints.Size

data class SavePenaltyRuleRequest(
@field:Schema(description = "패널티 규정 내용 (null 또는 빈 값이면 삭제)", example = "정기 모임에 출석을 하지 않았을때 (=결석)", nullable = true)
@field:Size(max = 500)
val content: String?,
)
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
package com.weeth.domain.penalty.application.dto.request

import io.swagger.v3.oas.annotations.media.Schema
import jakarta.validation.constraints.Positive
import jakarta.validation.constraints.Size

data class UpdatePenaltyRequest(
@field:Schema(description = "수정할 패널티 ID", example = "1")
@field:Schema(description = "수정할 페널티 ID", example = "1")
val penaltyId: Long,
@field:Schema(description = "수정할 패널티 사유", example = "정기모임 무단 불참 (수정)")
@field:Schema(description = "수정할 페널티 사유 (null=변경 안 함)", example = "정기모임 무단 불참 (수정)", nullable = true)
@field:Size(max = 20)
val penaltyDescription: String?,
Comment on lines +10 to 12

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@field:Size(max = 20) 수정할 때도 20 글자수 제한 들어가야할 것 가타요!

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

헙 그러네요... 꼼꼼한 리뷰 감사함니다,, 생각도 못하고 있었어요

@field:Schema(description = "수정할 페널티 점수 (null=변경 안 함)", example = "2", nullable = true)
@field:Positive
val score: Int?,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.weeth.domain.penalty.application.dto.response

import com.weeth.domain.club.domain.enums.MemberStatus
import io.swagger.v3.oas.annotations.media.Schema

data class MemberPenaltyDetailResponse(
@field:Schema(description = "프로필 이미지 URL", nullable = true)
val profileImageUrl: String?,
@field:Schema(description = "이름", example = "홍길동")
val name: String,
@field:Schema(description = "소속 기수 목록", example = "[6, 7]")
val cardinals: List<Int>,
@field:Schema(description = "멤버 상태", example = "ACTIVE")
val memberStatus: MemberStatus,
@field:Schema(description = "자기소개", nullable = true)
val bio: String?,
@field:Schema(description = "페널티 목록")
val penalties: List<PenaltyDetailResponse>,
)
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@ import io.swagger.v3.oas.annotations.media.Schema
data class PenaltyByCardinalResponse(
@field:Schema(description = "기수 번호", example = "4")
val cardinal: Int?,
@field:Schema(description = "해당 기수의 유저별 패널티 목록")
@field:Schema(description = "해당 기수의 유저별 페널티 목록")
val responses: List<PenaltyResponse>,
)
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ import io.swagger.v3.oas.annotations.media.Schema
import java.time.LocalDateTime

data class PenaltyDetailResponse(
@field:Schema(description = "패널티 ID", example = "1")
@field:Schema(description = "페널티 ID", example = "1")
val penaltyId: Long,
@field:Schema(description = "기수 번호", example = "4")
val cardinal: Int?,
@field:Schema(description = "패널티 사유", example = "정기모임 무단 불참")
@field:Schema(description = "페널티 사유", example = "정기모임 무단 불참")
val penaltyDescription: String,
@field:Schema(description = "최종 수정 시간", example = "2026-02-19T01:00:00")
@field:Schema(description = "페널티 점수", example = "1")
val score: Int,
@field:Schema(description = "페널티 부여 일시", example = "2026-02-19T01:00:00")
val time: LocalDateTime,
)
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ data class PenaltyResponse(
val name: String,
@field:Schema(description = "동아리 멤버 상태", example = "ACTIVE")
val memberStatus: MemberStatus,
@field:Schema(description = "패널티 횟수", example = "2")
@field:Schema(description = "페널티 횟수", example = "2")
val penaltyCount: Int,
@field:Schema(description = "소속 기수 목록", example = "[3, 4]")
val cardinals: List<Int>,
@field:Schema(description = "패널티 상세 목록")
@field:Schema(description = "페널티 상세 목록")
val penalties: List<PenaltyDetailResponse>,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.weeth.domain.penalty.application.dto.response

import io.swagger.v3.oas.annotations.media.Schema

data class PenaltyRuleResponse(
@field:Schema(description = "패널티 규정 내용 (미설정 시 null)", nullable = true)
val content: String?,
)
Loading