From 43d4bed7411b38d72a1a869bbaa70cc3e9797393 Mon Sep 17 00:00:00 2001 From: cowboysj Date: Sun, 13 Sep 2026 22:48:44 +0900 Subject: [PATCH 1/8] =?UTF-8?q?refactor=20:=20cohort/role=20system=20RBAC?= =?UTF-8?q?=20=EC=9E=AC=EC=A0=95=EB=B9=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - roles 5개 canonical(MASTER/CORE/ORGANIZER/DEEPER/GUEST) 고정 - member_roles.cohort_id 컬럼 도입 및 활성 기수 기반 판정 로직 - cohorts.is_active + PATCH /v1/cohorts/{id}/activate 신설 - member_authorities/authorities 구시스템 제거 - SQL 마이그레이션 스크립트 prod/pending/20260913_*.sql 추가 --- application/build.gradle.kts | 2 +- .../service/AfterPartyCommandService.kt | 20 +- .../application/service/RoleCommandService.kt | 24 +- .../application/service/RoleQueryService.kt | 34 +-- .../request/UpdateMemberRoleRequest.kt | 20 +- .../properties/CohortProperties.kt | 11 - .../service/CohortCommandService.kt | 6 + .../application/service/CohortQueryService.kt | 49 ++-- .../application/service/CohortRoleService.kt | 32 +-- .../presentation/controller/CohortAdminApi.kt | 19 ++ .../controller/CohortAdminController.kt | 37 +++ .../service/GatheringQueryService.kt | 5 +- .../member/GatheringMemberQueryService.kt | 20 +- .../service/MemberCommandService.kt | 23 +- .../service/access/MemberAccessService.kt | 64 +---- .../service/auth/EmailPasswordAuthService.kt | 32 +-- .../authority/MemberAuthorityService.kt | 39 --- .../service/role/CurrentCohortRoleResolver.kt | 113 +++++---- .../service/role/MemberRoleService.kt | 122 ++-------- .../presentation/controller/MemberApi.kt | 15 -- .../controller/MemberController.kt | 9 - .../ConvertDeeperToOrganizerRequest.kt | 15 -- .../redirect/OAuthCallbackRedirectService.kt | 29 ++- ...kieOAuth2AuthorizationRequestRepository.kt | 27 ++- .../security/oauth/token/JwtTokenInjector.kt | 11 +- .../src/main/resources/application-local.yml | 19 -- .../src/main/resources/application.yml | 4 +- .../role/CurrentCohortRoleResolverTest.kt | 73 ++++++ ...Auth2AuthorizationRequestRepositoryTest.kt | 10 + .../core/domain/authorization/vo/RoleType.kt | 55 ++--- .../core/domain/cohort/aggregate/Cohort.kt | 3 + .../cohort/port/inbound/CohortQueryUseCase.kt | 8 +- .../port/outbound/CohortPersistencePort.kt | 6 + .../domain/member/aggregate/MemberRole.kt | 2 + .../MemberAuthorityPersistencePort.kt | 32 --- .../outbound/MemberCohortPersistencePort.kt | 2 + .../outbound/MemberRolePersistencePort.kt | 8 +- .../domain/member/vo/MemberRoleAssignment.kt | 8 + .../kotlin/core/entity/cohort/CohortEntity.kt | 7 + .../entity/member/MemberPermissionEntity.kt | 5 + .../core/entity/member/MemberRoleEntity.kt | 4 + .../repository/RoleRepository.kt | 96 ++------ .../cohort/repository/CohortJpaRepository.kt | 2 + .../cohort/repository/CohortRepository.kt | 52 +++- .../member/repository/MemberRepository.kt | 17 +- .../authority/MemberAuthorityRepository.kt | 128 ---------- .../cohort/MemberCohortRepository.kt | 9 + .../repository/role/MemberRoleRepository.kt | 150 +++++------- prod/pending/20260913_backup_member_roles.sql | 47 ++++ prod/pending/20260913_role_system_seed.sql | 229 ++++++++++++++++++ prod/pending/20260913_schema_alignment.sql | 124 ++++++++++ 51 files changed, 962 insertions(+), 916 deletions(-) delete mode 100644 application/src/main/kotlin/core/application/cohort/application/properties/CohortProperties.kt create mode 100644 application/src/main/kotlin/core/application/cohort/presentation/controller/CohortAdminApi.kt create mode 100644 application/src/main/kotlin/core/application/cohort/presentation/controller/CohortAdminController.kt delete mode 100644 application/src/main/kotlin/core/application/member/application/service/authority/MemberAuthorityService.kt delete mode 100644 application/src/main/kotlin/core/application/member/presentation/request/ConvertDeeperToOrganizerRequest.kt create mode 100644 application/src/test/kotlin/core/application/member/application/service/role/CurrentCohortRoleResolverTest.kt delete mode 100644 domain/src/main/kotlin/core/domain/member/port/outbound/MemberAuthorityPersistencePort.kt create mode 100644 domain/src/main/kotlin/core/domain/member/vo/MemberRoleAssignment.kt delete mode 100644 persistence/src/main/kotlin/core/persistence/member/repository/authority/MemberAuthorityRepository.kt create mode 100644 prod/pending/20260913_backup_member_roles.sql create mode 100644 prod/pending/20260913_role_system_seed.sql create mode 100644 prod/pending/20260913_schema_alignment.sql diff --git a/application/build.gradle.kts b/application/build.gradle.kts index dddcc3aca..dd8d41c12 100644 --- a/application/build.gradle.kts +++ b/application/build.gradle.kts @@ -57,7 +57,7 @@ tasks.named("bootJar") { } springBoot { - mainClass.set("core.application.CoreApplication") + mainClass.set("core.application.CoreApplicationKt") } tasks.withType { diff --git a/application/src/main/kotlin/core/application/afterParty/application/service/AfterPartyCommandService.kt b/application/src/main/kotlin/core/application/afterParty/application/service/AfterPartyCommandService.kt index 41ad36c7c..708d10c15 100644 --- a/application/src/main/kotlin/core/application/afterParty/application/service/AfterPartyCommandService.kt +++ b/application/src/main/kotlin/core/application/afterParty/application/service/AfterPartyCommandService.kt @@ -129,11 +129,7 @@ class AfterPartyCommandService( cohortId: CohortId, ): Int { val now = Instant.now() - val memberRoleType = - memberAccessService.getRoleType( - memberId = memberId, - cohortValue = cohortQueryService.getCohort(cohortId).value, - ) + val memberRoleType = memberAccessService.getRoleType(memberId = memberId) val memberAuthorityId = legacyAuthorityIdForInviteTag(memberRoleType) ?: return 0 val afterParties: List = @@ -300,7 +296,7 @@ class AfterPartyCommandService( when (roleType) { RoleType.Deeper -> DEEPER_AUTHORITY_ID RoleType.Organizer -> ORGANIZER_AUTHORITY_ID - RoleType.Core, RoleType.Guest -> throw InviteTagNameNotFoundException(tagName) + RoleType.Master, RoleType.Core, RoleType.Guest -> throw InviteTagNameNotFoundException(tagName) }, ) @@ -332,13 +328,21 @@ class AfterPartyCommandService( roleType: RoleType, ): String { val latestCohort = cohortQueryService.getCohort(CohortId(cohortId)) - return "${latestCohort.value}기 ${roleType.aliases.first()}" + return "${latestCohort.value}기 ${roleLabel(roleType)}" + } + + private fun roleLabel(roleType: RoleType): String = when (roleType) { + RoleType.Organizer -> "운영진" + RoleType.Deeper -> "디퍼" + RoleType.Core -> "코어" + RoleType.Master -> "마스터" + RoleType.Guest -> "게스트" } private fun legacyAuthorityIdForInviteTag(roleType: RoleType): Long? = when (roleType) { RoleType.Deeper -> DEEPER_AUTHORITY_ID RoleType.Organizer -> ORGANIZER_AUTHORITY_ID - RoleType.Core, RoleType.Guest -> null + RoleType.Master, RoleType.Core, RoleType.Guest -> null } } diff --git a/application/src/main/kotlin/core/application/authorization/application/service/RoleCommandService.kt b/application/src/main/kotlin/core/application/authorization/application/service/RoleCommandService.kt index de5fc81ca..00480f372 100644 --- a/application/src/main/kotlin/core/application/authorization/application/service/RoleCommandService.kt +++ b/application/src/main/kotlin/core/application/authorization/application/service/RoleCommandService.kt @@ -4,6 +4,7 @@ import core.application.authorization.presentation.request.UpdateMemberRoleReque import core.application.member.application.service.MemberQueryService import core.application.member.application.service.role.MemberRoleService import core.domain.authorization.vo.RoleType +import core.domain.cohort.vo.CohortId import core.domain.member.vo.MemberId import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -19,25 +20,10 @@ class RoleCommandService( request: UpdateMemberRoleRequest, ) { memberQueryService.getMemberById(memberId) - - val cohortValue = normalizeCohortValue(request.cohort) - require(cohortValue.all(Char::isDigit)) { - "cohort must be numeric (e.g. 17 or 17기)" + val roleType = RoleType.fromCode(request.roleType) + require(roleType == RoleType.Organizer || roleType == RoleType.Deeper) { + "roleType must be ORGANIZER or DEEPER" } - val roleType = - if (request.isAdmin) { - RoleType.Organizer - } else { - RoleType.Deeper - } - val roleName = "${cohortValue}기 ${roleType.aliases.first()}" - - memberRoleService.replaceCohortRoleByName( - memberId = memberId, - roleName = roleName, - cohortRolePrefix = "${cohortValue}기 ", - ) + memberRoleService.replaceCohortRole(memberId, roleType, CohortId(request.cohortId)) } - - private fun normalizeCohortValue(cohort: String): String = cohort.trim().removeSuffix("기") } diff --git a/application/src/main/kotlin/core/application/authorization/application/service/RoleQueryService.kt b/application/src/main/kotlin/core/application/authorization/application/service/RoleQueryService.kt index 652843ec4..b47027db6 100644 --- a/application/src/main/kotlin/core/application/authorization/application/service/RoleQueryService.kt +++ b/application/src/main/kotlin/core/application/authorization/application/service/RoleQueryService.kt @@ -1,11 +1,10 @@ package core.application.authorization.application.service -import core.application.cohort.application.service.CohortQueryService +import core.application.member.application.service.role.CurrentCohortRoleResolver import core.domain.authorization.aggregate.Role import core.domain.authorization.port.inbound.RoleQueryUseCase import core.domain.authorization.port.outbound.RolePersistencePort import core.domain.authorization.vo.RoleType -import core.domain.member.port.inbound.MemberQueryUseCase import core.domain.member.port.outbound.MemberRolePersistencePort import core.domain.member.vo.MemberId import org.springframework.stereotype.Service @@ -14,48 +13,33 @@ import org.springframework.stereotype.Service class RoleQueryService( private val rolePersistencePort: RolePersistencePort, private val memberRolePersistencePort: MemberRolePersistencePort, - private val cohortQueryService: CohortQueryService, + private val currentCohortRoleResolver: CurrentCohortRoleResolver, ) : RoleQueryUseCase { - override fun getAllRoles(): List { - val latestCohortValue = cohortQueryService.getLatestCohortValue() - return getRolesByCohort(latestCohortValue) - } + override fun getAllRoles(): List = getRolesByCohort("") override fun getRolesByCohort(cohort: String): List { - val cohortValue = normalizeCohortValue(cohort) - return rolePersistencePort - .findAll() - .filter { role -> - val roleType = RoleType.from(role.name) - when (roleType) { - RoleType.Deeper, RoleType.Organizer -> role.name.startsWith("${cohortValue}기 ") - else -> true - } - } + val canonicalNames = setOf(RoleType.Core.code, RoleType.Organizer.code, RoleType.Deeper.code, RoleType.Guest.code) + return rolePersistencePort.findAll().filter { it.name in canonicalNames } } override fun getRoleNamesByMemberId(memberId: MemberId): List = memberRolePersistencePort.findRoleNamesByMemberId(memberId.value) override fun getRoleNamesByMemberIds(memberIds: List): Map> { - val roleNamesByMemberId = - memberRolePersistencePort.findRoleNamesByMemberIds( - memberIds.map { it.value }, - ) + val roleNamesByMemberId = memberRolePersistencePort.findRoleNamesByMemberIds(memberIds.map { it.value }) return memberIds.associateWith { memberId -> roleNamesByMemberId[memberId.value].orEmpty() } } override fun getRolesByExternalId(externalId: String): List = - rolePersistencePort.findAllByMemberExternalId(externalId).ifEmpty { listOf("GUEST") } + rolePersistencePort.findAllByMemberExternalId(externalId).ifEmpty { listOf(RoleType.Guest.code) } override fun getPermissionsByMemberId(memberId: MemberId): List { + val effectiveRoleNames = currentCohortRoleResolver.filterEffectiveRolesForMember(memberId) return rolePersistencePort.findAllPermissionsByMemberIdAndRoleNames( memberId = memberId, - roleNames = memberRolePersistencePort.findRoleNamesByMemberId(memberId.value), + roleNames = effectiveRoleNames, ) } override fun findIdByName(roleName: String): Long = rolePersistencePort.findIdByName(roleName) - - private fun normalizeCohortValue(cohort: String): String = cohort.trim().removeSuffix("기") } diff --git a/application/src/main/kotlin/core/application/authorization/presentation/request/UpdateMemberRoleRequest.kt b/application/src/main/kotlin/core/application/authorization/presentation/request/UpdateMemberRoleRequest.kt index cb612faec..32877f023 100644 --- a/application/src/main/kotlin/core/application/authorization/presentation/request/UpdateMemberRoleRequest.kt +++ b/application/src/main/kotlin/core/application/authorization/presentation/request/UpdateMemberRoleRequest.kt @@ -6,19 +6,11 @@ import jakarta.validation.constraints.NotNull import jakarta.validation.constraints.Pattern data class UpdateMemberRoleRequest( - @field:NotNull - @field:Schema( - description = "운영진 여부. true면 해당 기수 운영진, false면 해당 기수 디퍼로 변경합니다.", - example = "true", - requiredMode = Schema.RequiredMode.REQUIRED, - ) - val isAdmin: Boolean, @field:NotBlank - @field:Pattern(regexp = "\\d+기?", message = "cohort must be numeric (e.g. 17 or 17기)") - @field:Schema( - description = "역할을 변경할 기수 값. 17 또는 17기 형식을 허용합니다.", - example = "17", - requiredMode = Schema.RequiredMode.REQUIRED, - ) - val cohort: String, + @field:Pattern(regexp = "MASTER|CORE|ORGANIZER|DEEPER|GUEST", message = "roleType must be one of MASTER, CORE, ORGANIZER, DEEPER, GUEST") + @field:Schema(description = "역할 타입", example = "ORGANIZER", requiredMode = Schema.RequiredMode.REQUIRED) + val roleType: String, + @field:NotNull + @field:Schema(description = "역할이 적용될 기수 ID", example = "18", requiredMode = Schema.RequiredMode.REQUIRED) + val cohortId: Long, ) diff --git a/application/src/main/kotlin/core/application/cohort/application/properties/CohortProperties.kt b/application/src/main/kotlin/core/application/cohort/application/properties/CohortProperties.kt deleted file mode 100644 index b94d15dda..000000000 --- a/application/src/main/kotlin/core/application/cohort/application/properties/CohortProperties.kt +++ /dev/null @@ -1,11 +0,0 @@ -package core.application.cohort.application.properties - -import org.springframework.boot.context.properties.ConfigurationProperties -import org.springframework.context.annotation.Configuration - -@Configuration -@ConfigurationProperties(prefix = "cohort") -data class CohortProperties( - // TODO : 기수가 변경될 경우 수정 필요 - var value: String = "18", -) diff --git a/application/src/main/kotlin/core/application/cohort/application/service/CohortCommandService.kt b/application/src/main/kotlin/core/application/cohort/application/service/CohortCommandService.kt index b5c4b86e4..8d89bf44e 100644 --- a/application/src/main/kotlin/core/application/cohort/application/service/CohortCommandService.kt +++ b/application/src/main/kotlin/core/application/cohort/application/service/CohortCommandService.kt @@ -59,4 +59,10 @@ class CohortCommandService( } cohortPersistencePort.deleteById(cohortId) } + + fun activateCohort(cohortId: CohortId): Cohort { + cohortQueryService.getCohort(cohortId) + cohortPersistencePort.activate(cohortId) + return cohortQueryService.getCohort(cohortId) + } } diff --git a/application/src/main/kotlin/core/application/cohort/application/service/CohortQueryService.kt b/application/src/main/kotlin/core/application/cohort/application/service/CohortQueryService.kt index f02c0d710..fb8be51d5 100644 --- a/application/src/main/kotlin/core/application/cohort/application/service/CohortQueryService.kt +++ b/application/src/main/kotlin/core/application/cohort/application/service/CohortQueryService.kt @@ -13,48 +13,45 @@ import org.springframework.transaction.annotation.Transactional class CohortQueryService( private val cohortPersistencePort: CohortPersistencePort, ) : CohortQueryUseCase { - fun getLatestCohort(): Cohort = - cohortPersistencePort - .findAll() - .maxByOrNull { toNumericCohortValue(it.value) ?: Int.MIN_VALUE } - ?.takeIf { isNumericCohortValue(it.value) } - ?: throw CohortNotFoundException() + fun getActiveCohort(): Cohort = cohortPersistencePort.findActive() ?: getLatestCohortByMaxValue() - override fun getLatestCohortId(): CohortId = - getLatestCohort().id - ?: throw CohortNotFoundException() + fun getLatestCohort(): Cohort = getActiveCohort() + + override fun getActiveCohortId(): CohortId = getActiveCohort().id ?: throw CohortNotFoundException() + + override fun getActiveCohortValue(): String = getActiveCohort().value - override fun getLatestCohortValue(): String = getLatestCohort().value + override fun getLatestCohortId(): CohortId = getActiveCohortId() + + override fun getLatestCohortValue(): String = getActiveCohortValue() fun getAllCohorts(): List = - cohortPersistencePort - .findAll() - .sortedWith( - compareByDescending { toNumericCohortValue(it.value) ?: Int.MIN_VALUE } - .thenByDescending { it.createdAt ?: 0L }, - ) + cohortPersistencePort.findAll().sortedWith( + compareByDescending { toNumericCohortValue(it.value) ?: Int.MIN_VALUE } + .thenByDescending { it.createdAt ?: 0L }, + ) fun getCohort(cohortId: CohortId): Cohort = - cohortPersistencePort.findById(cohortId) - ?: throw CohortNotFoundException() + cohortPersistencePort.findById(cohortId) ?: throw CohortNotFoundException() fun getPreviousNumericCohortValue(targetValue: String): String? { val target = toNumericCohortValue(targetValue) ?: return null - return cohortPersistencePort - .findAll() + return cohortPersistencePort.findAll() .mapNotNull { cohort -> toNumericCohortValue(cohort.value)?.let { it to cohort.value } } .filter { (numericValue, _) -> numericValue < target } - .maxByOrNull { (numericValue, _) -> numericValue } - ?.second + .maxByOrNull { (numericValue, _) -> numericValue }?.second } fun isNumericCohortValue(value: String): Boolean = NUMERIC_COHORT_REGEX.matches(value.trim()) + private fun getLatestCohortByMaxValue(): Cohort = + cohortPersistencePort.findAll() + .maxByOrNull { toNumericCohortValue(it.value) ?: Int.MIN_VALUE } + ?.takeIf { isNumericCohortValue(it.value) } + ?: throw CohortNotFoundException() + private fun toNumericCohortValue(value: String): Int? = - value - .trim() - .takeIf { NUMERIC_COHORT_REGEX.matches(it) } - ?.toIntOrNull() + value.trim().takeIf { NUMERIC_COHORT_REGEX.matches(it) }?.toIntOrNull() companion object { private val NUMERIC_COHORT_REGEX = Regex("\\d+") diff --git a/application/src/main/kotlin/core/application/cohort/application/service/CohortRoleService.kt b/application/src/main/kotlin/core/application/cohort/application/service/CohortRoleService.kt index 2a5294f92..bc10039c3 100644 --- a/application/src/main/kotlin/core/application/cohort/application/service/CohortRoleService.kt +++ b/application/src/main/kotlin/core/application/cohort/application/service/CohortRoleService.kt @@ -1,40 +1,12 @@ package core.application.cohort.application.service -import core.domain.authorization.port.outbound.RolePersistencePort -import core.domain.authorization.vo.RoleType import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @Service @Transactional -class CohortRoleService( - private val rolePersistencePort: RolePersistencePort, - private val cohortQueryService: CohortQueryService, -) { +class CohortRoleService { fun createLatestCohortRoles(newCohortValue: String) { - val sourceCohortValue = cohortQueryService.getPreviousNumericCohortValue(newCohortValue) ?: return - - listOf(RoleType.Deeper, RoleType.Organizer).forEach { roleType -> - val sourceRoleName = buildCohortRoleName(sourceCohortValue, roleType) - if (!rolePersistencePort.existsByName(sourceRoleName)) { - return@forEach - } - - val targetRoleName = buildCohortRoleName(newCohortValue, roleType) - if (rolePersistencePort.existsByName(targetRoleName)) { - return@forEach - } - - val permissionIds = rolePersistencePort.findPermissionIdsByRoleName(sourceRoleName) - rolePersistencePort.createRoleWithPermissions( - roleName = targetRoleName, - permissionIds = permissionIds, - ) - } + // Phase 1: canonical roles(CORE/ORGANIZER/DEEPER/GUEST) are fixed. Per-cohort role rows are no longer created. } - - fun buildCohortRoleName( - cohortValue: String, - roleType: RoleType, - ): String = "${cohortValue}기 ${roleType.aliases.first()}" } diff --git a/application/src/main/kotlin/core/application/cohort/presentation/controller/CohortAdminApi.kt b/application/src/main/kotlin/core/application/cohort/presentation/controller/CohortAdminApi.kt new file mode 100644 index 000000000..a86225621 --- /dev/null +++ b/application/src/main/kotlin/core/application/cohort/presentation/controller/CohortAdminApi.kt @@ -0,0 +1,19 @@ +package core.application.cohort.presentation.controller + +import core.application.cohort.presentation.response.CohortNumberResponse +import core.application.common.exception.CustomResponse +import core.domain.cohort.vo.CohortId +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.tags.Tag + +@Tag(name = "Cohort Admin", description = "활성 기수 관리 API") +interface CohortAdminApi { + @ApiResponse(responseCode = "200", description = "활성 기수 조회 성공") + @Operation(summary = "활성 기수 조회", description = "현재 is_active=true인 기수를 조회합니다.") + fun getActiveCohort(): CustomResponse + + @ApiResponse(responseCode = "200", description = "활성 기수 전환 성공") + @Operation(summary = "활성 기수 전환", description = "지정 기수를 활성 기수로 전환합니다.") + fun activateCohort(cohortId: CohortId): CustomResponse +} diff --git a/application/src/main/kotlin/core/application/cohort/presentation/controller/CohortAdminController.kt b/application/src/main/kotlin/core/application/cohort/presentation/controller/CohortAdminController.kt new file mode 100644 index 000000000..921accf68 --- /dev/null +++ b/application/src/main/kotlin/core/application/cohort/presentation/controller/CohortAdminController.kt @@ -0,0 +1,37 @@ +package core.application.cohort.presentation.controller + +import core.application.cohort.application.service.CohortCommandService +import core.application.cohort.application.service.CohortQueryService +import core.application.cohort.presentation.response.CohortNumberResponse +import core.application.common.exception.CustomResponse +import core.domain.cohort.aggregate.Cohort +import core.domain.cohort.vo.CohortId +import org.springframework.security.access.prepost.PreAuthorize +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.RestController + +@RestController +class CohortAdminController( + private val cohortQueryService: CohortQueryService, + private val cohortCommandService: CohortCommandService, +) : CohortAdminApi { + @PreAuthorize("isAuthenticated()") + @GetMapping("/v1/cohorts/active") + override fun getActiveCohort(): CustomResponse = + CustomResponse.ok(cohortQueryService.getActiveCohort().toResponse()) + + @PreAuthorize("hasAuthority('update:cohort')") + @PatchMapping("/v1/cohorts/{cohortId}/activate") + override fun activateCohort( + @PathVariable cohortId: CohortId, + ): CustomResponse = + CustomResponse.ok(cohortCommandService.activateCohort(cohortId).toResponse()) + + private fun Cohort.toResponse(): CohortNumberResponse = + CohortNumberResponse( + cohortId = this.id?.value ?: error("Cohort id must not be null"), + cohortNumber = this.value, + ) +} diff --git a/application/src/main/kotlin/core/application/gathering/application/service/GatheringQueryService.kt b/application/src/main/kotlin/core/application/gathering/application/service/GatheringQueryService.kt index 2dae20a4f..126b0ec0f 100644 --- a/application/src/main/kotlin/core/application/gathering/application/service/GatheringQueryService.kt +++ b/application/src/main/kotlin/core/application/gathering/application/service/GatheringQueryService.kt @@ -91,12 +91,11 @@ class GatheringQueryService( }.groupBy({ it.first }, { it.second }) private fun getMemberNameRole(memberId: MemberId): Pair { - val latestCohortValue = memberQueryUseCase.getMemberById(memberId).latestCohortValue().orEmpty() val queryResults = memberQueryUseCase.getMemberNameRoleByMemberId(memberId) val representativeRole = - currentCohortRoleResolver.selectRepresentativeRole( + currentCohortRoleResolver.selectRepresentativeRoleForMember( + memberId = memberId, roleNames = queryResults.map { it.role }, - latestCohortValue = latestCohortValue, ) ?: queryResults.first().role val representativeQuery = diff --git a/application/src/main/kotlin/core/application/gathering/application/service/member/GatheringMemberQueryService.kt b/application/src/main/kotlin/core/application/gathering/application/service/member/GatheringMemberQueryService.kt index 2ae4911aa..a9e58defb 100644 --- a/application/src/main/kotlin/core/application/gathering/application/service/member/GatheringMemberQueryService.kt +++ b/application/src/main/kotlin/core/application/gathering/application/service/member/GatheringMemberQueryService.kt @@ -1,6 +1,5 @@ package core.application.gathering.application.service.member -import core.application.cohort.application.service.CohortQueryService import core.application.gathering.application.exception.member.GatheringMemberNotFoundException import core.application.gathering.application.validator.GatheringMemberValidator import core.application.member.application.service.role.CurrentCohortRoleResolver @@ -10,7 +9,6 @@ import core.domain.gathering.port.inbound.GatheringMemberQueryUseCase import core.domain.gathering.port.outbound.GatheringMemberPersistencePort import core.domain.gathering.port.outbound.query.GatheringMemberIsJoinQueryModel import core.domain.gathering.vo.GatheringId -import core.domain.member.port.inbound.MemberQueryUseCase import core.domain.member.vo.MemberId import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -20,7 +18,6 @@ import org.springframework.transaction.annotation.Transactional class GatheringMemberQueryService( private val gatheringMemberPersistencePort: GatheringMemberPersistencePort, private val gatheringMemberValidator: GatheringMemberValidator, - private val memberQueryUseCase: MemberQueryUseCase, private val currentCohortRoleResolver: CurrentCohortRoleResolver, ) : GatheringMemberQueryUseCase { override fun getGatheringMemberByGatheringId(gatheringId: GatheringId): List = @@ -46,11 +43,10 @@ class GatheringMemberQueryService( val queryResults = gatheringMemberPersistencePort .findGatheringMemberWithIsJoinByGatheringIdAndMemberId(gatheringId, memberId) - val latestCohortValue = memberQueryUseCase.getMemberById(memberId).latestCohortValue().orEmpty() val representativeAuthority = - currentCohortRoleResolver.selectRepresentativeRole( + currentCohortRoleResolver.selectRepresentativeRoleForMember( + memberId = memberId, roleNames = queryResults.map { it.authority }, - latestCohortValue = latestCohortValue, ) ?: queryResults.first().authority queryResults.first { it.authority == representativeAuthority } } @@ -64,11 +60,10 @@ class GatheringMemberQueryService( val queryResults = gatheringMemberPersistencePort .findGatheringMemberWithIsInvitationSubmittedByGatheringIdAndMemberId(gatheringId, memberId) - val latestCohortValue = memberQueryUseCase.getMemberById(memberId).latestCohortValue().orEmpty() val representativeAuthority = - currentCohortRoleResolver.selectRepresentativeRole( + currentCohortRoleResolver.selectRepresentativeRoleForMember( + memberId = memberId, roleNames = queryResults.map { it.authority }, - latestCohortValue = latestCohortValue, ) ?: queryResults.first().authority queryResults.firstOrNull { it.authority == representativeAuthority } ?: throw GatheringMemberNotFoundException() @@ -82,13 +77,6 @@ class GatheringMemberQueryService( gatheringMemberPersistencePort .findGatheringMembersByGatheringIdsAndMemberIds(gatheringIds, memberIds) - /** - * 각 회식 멤버가 회식 참여자에 속하는지 확인한 후, 참여자 수를 카운트합니다. - * - * @throws GatheringNotParticipantMemberException 회식 멤버가 해당 회식에 속하지 않는 경우 - * @author LeeHanEum - * @since 2025.09.13 - */ fun countGatheringParticipants( gatheringId: GatheringId, gatheringMembers: List, diff --git a/application/src/main/kotlin/core/application/member/application/service/MemberCommandService.kt b/application/src/main/kotlin/core/application/member/application/service/MemberCommandService.kt index 16816a48a..2ce43451f 100644 --- a/application/src/main/kotlin/core/application/member/application/service/MemberCommandService.kt +++ b/application/src/main/kotlin/core/application/member/application/service/MemberCommandService.kt @@ -9,7 +9,6 @@ import core.application.member.application.service.oauth.MemberOAuthService import core.application.member.application.service.role.MemberRoleService import core.application.member.application.service.team.MemberTeamService import core.application.member.presentation.request.AppleMemberProfileUpdateRequest -import core.application.member.presentation.request.ConvertDeeperToOrganizerRequest import core.application.member.presentation.request.InitMemberDataRequest import core.application.member.presentation.request.UpdateMemberStatusRequest import core.application.member.presentation.response.AppleMemberProfileUpdateResponse @@ -139,30 +138,12 @@ class MemberCommandService( fun activate(member: Member) { val memberId = requireNotNull(member.id) { "Activated member must have id" } - val latestCohortValue = cohortQueryUseCase.getLatestCohortValue() - val deeperRoleName = "${latestCohortValue}기 ${RoleType.Deeper.aliases.first()}" - - // Whitelist approval keeps exactly one active latest cohort DEEPER role. - memberRoleService.replaceWithSingleRoleByName(memberId, deeperRoleName) - + memberRoleService.replaceWithSingleRoleByType(memberId, RoleType.Deeper, cohortQueryUseCase.getActiveCohortId()) member.activate() val activatedMember = memberPersistencePort.save(member) initializeMemberDataForActiveMember(activatedMember) } - fun convertDeeperToOrganizer(request: ConvertDeeperToOrganizerRequest) { - val memberId = request.memberId - memberQueryService.getMemberById(memberId) - if (memberRoleService.resolvePrimaryRoleType(memberId) != RoleType.Deeper) { - return - } - - memberRoleService.replaceWithSingleRoleByName( - memberId = memberId, - roleName = "${cohortQueryUseCase.getLatestCohortValue()}기 ${RoleType.Organizer.aliases.first()}", - ) - } - /** * 멤버의 상태(status)를 변경함. * 개발 중 멤버 상태를 컨트롤하기 위해 사용합니다.(PENDING/ACTIVE) @@ -204,7 +185,7 @@ class MemberCommandService( val memberId = requireNotNull(member.id) { "Active member must have id" } memberTeamService.ensureMemberTeamInitialized(memberId) - val latestCohortId = cohortQueryUseCase.getLatestCohortId() + val latestCohortId = cohortQueryUseCase.getActiveCohortId() memberCohortService.addMemberToCohort(memberId, latestCohortId) publishMemberActivatedEvent(memberId, latestCohortId) } diff --git a/application/src/main/kotlin/core/application/member/application/service/access/MemberAccessService.kt b/application/src/main/kotlin/core/application/member/application/service/access/MemberAccessService.kt index f082ad036..d9c7c39bb 100644 --- a/application/src/main/kotlin/core/application/member/application/service/access/MemberAccessService.kt +++ b/application/src/main/kotlin/core/application/member/application/service/access/MemberAccessService.kt @@ -2,9 +2,6 @@ package core.application.member.application.service.access import core.application.member.application.service.role.CurrentCohortRoleResolver import core.domain.authorization.vo.RoleType -import core.domain.member.aggregate.Member -import core.domain.member.port.outbound.MemberPersistencePort -import core.domain.member.port.outbound.MemberRolePersistencePort import core.domain.member.vo.MemberId import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -12,66 +9,13 @@ import org.springframework.transaction.annotation.Transactional @Service @Transactional(readOnly = true) class MemberAccessService( - private val memberRolePersistencePort: MemberRolePersistencePort, - private val memberPersistencePort: MemberPersistencePort, private val currentCohortRoleResolver: CurrentCohortRoleResolver, ) { fun isAdmin(memberId: MemberId): Boolean = getRoleType(memberId) == RoleType.Organizer - fun isAdmin( - memberId: MemberId, - cohortValue: String, - ): Boolean = getRoleType(memberId, cohortValue) == RoleType.Organizer + fun getRoleType(memberId: MemberId): RoleType = + currentCohortRoleResolver.findPrimaryRoleTypeForMember(memberId) - fun getRoleType( - memberId: MemberId, - cohortValue: String? = null, - ): RoleType { - val resolvedCohortValue = normalizeCohortValue(cohortValue ?: latestCohortValue(memberId)) - val currentRoles = - currentCohortRoleResolver.filterCurrentRoles( - roleNames = memberRolePersistencePort.findRoleNamesByMemberId(memberId.value), - latestCohortValue = resolvedCohortValue, - ) - - return ROLE_PRIORITY.firstOrNull { roleType -> - currentRoles.any { roleName -> RoleType.from(roleName) == roleType } - } ?: RoleType.Guest - } - - fun getIsAdminByMemberIds( - memberIds: List, - ): Map { - if (memberIds.isEmpty()) return emptyMap() - - val membersById: Map = - memberPersistencePort.findAllByIds(memberIds).associateBy { requireNotNull(it.id) } - val roleNamesByMemberId: Map> = - memberRolePersistencePort.findRoleNamesByMemberIds(memberIds.map { it.value }) - - return memberIds.associateWith { memberId -> - val member = membersById[memberId] - val latestCohortValue = normalizeCohortValue(member?.latestCohortValue().orEmpty()) - val currentRoles = - currentCohortRoleResolver.filterCurrentRoles( - roleNames = roleNamesByMemberId[memberId.value] ?: emptyList(), - latestCohortValue = latestCohortValue, - ) - val roleType = - ROLE_PRIORITY.firstOrNull { roleType -> - currentRoles.any { roleName -> RoleType.from(roleName) == roleType } - } ?: RoleType.Guest - - roleType == RoleType.Organizer - } - } - - private fun latestCohortValue(memberId: MemberId): String = - memberPersistencePort.findById(memberId)?.latestCohortValue().orEmpty() - - private fun normalizeCohortValue(cohortValue: String?): String = cohortValue.orEmpty().trim().removeSuffix("기") - - companion object { - private val ROLE_PRIORITY = listOf(RoleType.Core, RoleType.Organizer, RoleType.Deeper, RoleType.Guest) - } + fun getIsAdminByMemberIds(memberIds: List): Map = + memberIds.associateWith { memberId -> getRoleType(memberId) == RoleType.Organizer } } diff --git a/application/src/main/kotlin/core/application/member/application/service/auth/EmailPasswordAuthService.kt b/application/src/main/kotlin/core/application/member/application/service/auth/EmailPasswordAuthService.kt index 5ba2f423f..8e2542310 100644 --- a/application/src/main/kotlin/core/application/member/application/service/auth/EmailPasswordAuthService.kt +++ b/application/src/main/kotlin/core/application/member/application/service/auth/EmailPasswordAuthService.kt @@ -1,6 +1,5 @@ package core.application.member.application.service.auth -import core.application.authorization.application.service.RoleQueryService import core.application.member.application.exception.InvalidEmailPasswordException import core.application.member.application.exception.MemberAllowedException import core.application.member.application.exception.MemberDeletedException @@ -32,7 +31,6 @@ import java.time.Instant class EmailPasswordAuthService( private val memberCredentialPersistencePort: MemberCredentialPersistencePort, private val memberPersistencePort: MemberPersistencePort, - private val roleQueryService: RoleQueryService, private val memberRoleService: MemberRoleService, private val memberTeamService: MemberTeamService, private val jwtTokenProvider: JwtTokenProvider, @@ -100,20 +98,7 @@ class EmailPasswordAuthService( memberRoleService.ensureGuestRoleAssigned(member.id!!) memberTeamService.ensureMemberTeamInitialized(member.id!!) - // Generate JWT tokens - val permissionStrings = roleQueryService.getPermissionsByMemberId(member.id!!) - val authorities = - permissionStrings.map { - org.springframework.security.core.authority.SimpleGrantedAuthority( - it, - ) - } - - val accessToken = - jwtTokenProvider.generateAccessTokenWithPermissions( - member.id!!.toString(), - authorities, - ) + val accessToken = jwtTokenProvider.generateAccessToken(member.id!!.toString()) val refreshToken = jwtTokenProvider.generateRefreshToken(member.id!!.toString()) @@ -156,20 +141,7 @@ class EmailPasswordAuthService( ) memberCredentialPersistencePort.save(newCredential) - // 4. Generate JWT tokens - val permissionStrings = roleQueryService.getPermissionsByMemberId(newMember.id!!) - val authorities = - permissionStrings.map { - org.springframework.security.core.authority.SimpleGrantedAuthority( - it, - ) - } - - val accessToken = - jwtTokenProvider.generateAccessTokenWithPermissions( - newMember.id!!.toString(), - authorities, - ) + val accessToken = jwtTokenProvider.generateAccessToken(newMember.id!!.toString()) val refreshToken = jwtTokenProvider.generateRefreshToken(newMember.id!!.toString()) diff --git a/application/src/main/kotlin/core/application/member/application/service/authority/MemberAuthorityService.kt b/application/src/main/kotlin/core/application/member/application/service/authority/MemberAuthorityService.kt deleted file mode 100644 index 60740b32d..000000000 --- a/application/src/main/kotlin/core/application/member/application/service/authority/MemberAuthorityService.kt +++ /dev/null @@ -1,39 +0,0 @@ -package core.application.member.application.service.authority - -import core.domain.cohort.vo.AuthorityId -import core.domain.member.port.outbound.MemberAuthorityPersistencePort -import core.domain.member.vo.MemberId -import org.springframework.stereotype.Service - -@Service -class MemberAuthorityService( - private val memberAuthorityPersistencePort: MemberAuthorityPersistencePort, -) { - fun getActiveAuthorityIdsByMemberId(memberId: MemberId): List = - memberAuthorityPersistencePort.findActiveAuthorityIdsByMemberId(memberId) - - fun getAuthorityNamesByMemberId(memberId: MemberId): List = - memberAuthorityPersistencePort.findAuthorityNamesByMemberId(memberId) - - fun ensureAuthorityAssigned( - memberId: MemberId, - authorityName: String, - ) = memberAuthorityPersistencePort.ensureAuthorityAssigned(memberId, authorityName) - - fun ensureAuthorityAssigned( - memberId: MemberId, - authorityId: AuthorityId, - ) = memberAuthorityPersistencePort.ensureAuthorityAssigned(memberId, authorityId) - - fun revokeAuthority( - memberId: MemberId, - authorityName: String, - ) = memberAuthorityPersistencePort.revokeAuthority(memberId, authorityName) - - fun revokeAuthority( - memberId: MemberId, - authorityId: AuthorityId, - ) = memberAuthorityPersistencePort.revokeAuthority(memberId, authorityId) - - fun revokeAllAuthorities(memberId: MemberId) = memberAuthorityPersistencePort.revokeAllByMemberId(memberId) -} diff --git a/application/src/main/kotlin/core/application/member/application/service/role/CurrentCohortRoleResolver.kt b/application/src/main/kotlin/core/application/member/application/service/role/CurrentCohortRoleResolver.kt index d6eeb1451..5f3dfd7cd 100644 --- a/application/src/main/kotlin/core/application/member/application/service/role/CurrentCohortRoleResolver.kt +++ b/application/src/main/kotlin/core/application/member/application/service/role/CurrentCohortRoleResolver.kt @@ -1,70 +1,99 @@ package core.application.member.application.service.role +import core.application.cohort.application.service.CohortQueryService import core.domain.authorization.vo.RoleType +import core.domain.member.port.outbound.MemberCohortPersistencePort +import core.domain.member.port.outbound.MemberRolePersistencePort +import core.domain.member.vo.MemberId +import core.domain.member.vo.MemberRoleAssignment import org.springframework.stereotype.Component @Component -class CurrentCohortRoleResolver { - fun filterCurrentRoles( +class CurrentCohortRoleResolver( + private val cohortQueryService: CohortQueryService, + private val memberCohortPersistencePort: MemberCohortPersistencePort, + private val memberRolePersistencePort: MemberRolePersistencePort, +) { + fun filterEffectiveRolesForMember(memberId: MemberId): List = + filterEffectiveRoles( + assignments = memberRolePersistencePort.findActiveRoleAssignmentsByMemberId(memberId.value), + context = buildContext(memberId), + ) + + fun findPrimaryRoleTypeForMember(memberId: MemberId): RoleType = + findPrimaryRoleType( + assignments = memberRolePersistencePort.findActiveRoleAssignmentsByMemberId(memberId.value), + context = buildContext(memberId), + ) + + fun selectRepresentativeRoleForMember( + memberId: MemberId, roleNames: List, - latestCohortValue: String, - ): List { - val latestCohortNumber = latestCohortValue.toIntOrNull() - return roleNames.filter { roleName -> - val cohortRole = parseCohortRole(roleName) - when { - cohortRole == null -> true - latestCohortNumber == null -> false - else -> cohortRole.cohortNumber == latestCohortNumber - } - } + ): String? { + val assignments = memberRolePersistencePort.findActiveRoleAssignmentsByMemberId(memberId.value) + .filter { it.roleName in roleNames } + return selectRepresentativeRole(assignments, buildContext(memberId)) } + fun filterEffectiveRoles( + assignments: List, + context: CohortRoleContext, + ): List = + assignments + .filter { isAssignmentEffective(it, context) } + .map { it.roleName } + .distinct() + fun findPrimaryRoleType( - roleNames: List, - latestCohortValue: String, + assignments: List, + context: CohortRoleContext, ): RoleType { - val currentRoles = filterCurrentRoles(roleNames, latestCohortValue) - if (currentRoles.isEmpty()) { - return RoleType.Guest - } - - val roleTypes = currentRoles.map { RoleType.from(it) } + val effectiveRoles = filterEffectiveRoles(assignments, context) + if (effectiveRoles.isEmpty()) return RoleType.Guest + val roleTypes = effectiveRoles.map { RoleType.from(it) } return ROLE_PRIORITY.firstOrNull { it in roleTypes } ?: RoleType.Guest } fun selectRepresentativeRole( - roleNames: List, - latestCohortValue: String, + assignments: List, + context: CohortRoleContext, ): String? = - filterCurrentRoles(roleNames, latestCohortValue) + filterEffectiveRoles(assignments, context) .sortedWith( - compareBy { roleName -> + compareBy { roleName -> ROLE_PRIORITY.indexOf(RoleType.from(roleName)).let { if (it == -1) Int.MAX_VALUE else it } - }.thenByDescending { parseCohortRole(it)?.cohortNumber ?: Int.MIN_VALUE }, + }, ).firstOrNull() - fun extractCurrentCohortValue( - cohortValues: List, - latestCohortValue: String, - ): String? = - cohortValues.firstOrNull { it == latestCohortValue } - ?: cohortValues.maxByOrNull { value -> - value.toIntOrNull() ?: Int.MIN_VALUE - } + private fun buildContext(memberId: MemberId): CohortRoleContext { + val activeCohort = runCatching { cohortQueryService.getActiveCohort() }.getOrNull() + return CohortRoleContext( + activeCohortId = activeCohort?.id?.value, + memberCohortIds = memberCohortPersistencePort.findCohortIdsByMemberId(memberId.value).toSet(), + ) + } - private fun parseCohortRole(roleName: String): ParsedCohortRole? { - val match = COHORT_ROLE_REGEX.matchEntire(roleName.trim()) ?: return null - val cohortNumber = match.groupValues[1].toIntOrNull() ?: return null - return ParsedCohortRole(cohortNumber = cohortNumber) + private fun isAssignmentEffective( + assignment: MemberRoleAssignment, + context: CohortRoleContext, + ): Boolean { + val isActiveMember = context.activeCohortId != null && context.activeCohortId in context.memberCohortIds + return when (assignment.roleName) { + RoleType.Master.code, RoleType.Guest.code -> true + RoleType.Core.code, RoleType.Organizer.code, RoleType.Deeper.code -> { + if (!isActiveMember) return false + assignment.cohortId?.value?.let { it in context.memberCohortIds } ?: isActiveMember + } + else -> false + } } - private data class ParsedCohortRole( - val cohortNumber: Int, + data class CohortRoleContext( + val activeCohortId: Long?, + val memberCohortIds: Set, ) companion object { - private val COHORT_ROLE_REGEX = Regex("^(\\d+)기\\s+.+$") - private val ROLE_PRIORITY = listOf(RoleType.Core, RoleType.Organizer, RoleType.Deeper, RoleType.Guest) + private val ROLE_PRIORITY = listOf(RoleType.Master, RoleType.Core, RoleType.Organizer, RoleType.Deeper, RoleType.Guest) } } diff --git a/application/src/main/kotlin/core/application/member/application/service/role/MemberRoleService.kt b/application/src/main/kotlin/core/application/member/application/service/role/MemberRoleService.kt index ee282fa42..6525bdd4c 100644 --- a/application/src/main/kotlin/core/application/member/application/service/role/MemberRoleService.kt +++ b/application/src/main/kotlin/core/application/member/application/service/role/MemberRoleService.kt @@ -4,8 +4,8 @@ import core.application.cohort.application.service.CohortQueryService import core.domain.authorization.port.inbound.RoleQueryUseCase import core.domain.authorization.vo.RoleId import core.domain.authorization.vo.RoleType +import core.domain.cohort.vo.CohortId import core.domain.member.aggregate.MemberRole -import core.domain.member.port.inbound.MemberQueryUseCase import core.domain.member.port.outbound.MemberRolePersistencePort import core.domain.member.vo.MemberId import org.springframework.stereotype.Service @@ -16,132 +16,60 @@ class MemberRoleService( private val memberRolePersistencePort: MemberRolePersistencePort, private val roleQueryUseCase: RoleQueryUseCase, private val cohortQueryService: CohortQueryService, - private val memberQueryUseCase: MemberQueryUseCase, private val currentCohortRoleResolver: CurrentCohortRoleResolver, ) { - /** - * 멤버 식별자로 해당 멤버가 소유한 권한 이름 목록을 조회함. - * - * @author LeeHanEum - * @since 2025.12.27 - */ fun getRoleNamesByMemberId(memberId: MemberId): List = - memberRolePersistencePort - .findRoleNamesByMemberId(memberId.value) + memberRolePersistencePort.findRoleNamesByMemberId(memberId.value) - /** - * 멤버 탈퇴 시에, 멤버가 소유한 모든 권한을 Soft Delete 처리하여, 권한을 회수함. - * - * - * @author LeeHanEum - * @since 2025.12.27 - */ fun revokeAllRoles(memberId: MemberId) = memberRolePersistencePort.softDeleteAllByMemberId(memberId.value) - /** - * 멤버 식별자로 해당 멤버의 최우선 권한 타입을 조회합니다. - * - * 권한 타입의 위계는 CORE > ORGANIZER > DEEPER > GUEST 순입니다. - * - * @author LeeHanEum - * @since 2025.12.27 - */ - fun resolvePrimaryRoleType(memberId: MemberId): RoleType { - val roles = - memberRolePersistencePort - .findRoleNamesByMemberId(memberId.value) - - val latestCohortValue = memberQueryUseCase.getMemberById(memberId).latestCohortValue().orEmpty() - return currentCohortRoleResolver.findPrimaryRoleType( - roleNames = roles, - latestCohortValue = latestCohortValue, - ) - } + fun resolvePrimaryRoleType(memberId: MemberId): RoleType = + currentCohortRoleResolver.findPrimaryRoleTypeForMember(memberId) fun assignGuestRole(memberId: MemberId) { val guestRoleId = roleQueryUseCase.findIdByName(RoleType.Guest.code) - val memberRole = - MemberRole( - memberId = memberId, - roleId = RoleId(guestRoleId), - grantedAt = Instant.now(), - ) - memberRolePersistencePort.save(memberRole) + memberRolePersistencePort.save( + MemberRole(memberId = memberId, roleId = RoleId(guestRoleId), grantedAt = Instant.now()), + ) } - fun assignRole( - memberId: MemberId, - roleType: RoleType, - ) { + fun assignRole(memberId: MemberId, roleType: RoleType, cohortId: CohortId? = null) { val roleId = roleQueryUseCase.findIdByName(roleType.code) - val memberRole = + memberRolePersistencePort.save( MemberRole( memberId = memberId, roleId = RoleId(roleId), + cohortId = cohortId, grantedAt = Instant.now(), - ) - memberRolePersistencePort.save(memberRole) + ), + ) } - fun ensureRoleAssigned( - memberId: MemberId, - roleType: RoleType, - ) { + fun ensureRoleAssigned(memberId: MemberId, roleType: RoleType) { val roles = memberRolePersistencePort.findRoleNamesByMemberId(memberId.value) - if (roles.none { it == roleType.code }) { - assignRole(memberId, roleType) - } + if (roles.none { it == roleType.code }) assignRole(memberId, roleType) } - fun revokeRole( - memberId: MemberId, - roleType: RoleType, - ) { + fun revokeRole(memberId: MemberId, roleType: RoleType) { val roleId = roleQueryUseCase.findIdByName(roleType.code) memberRolePersistencePort.softDeleteByMemberIdAndRoleId(memberId.value, roleId) } fun ensureGuestRoleAssigned(memberId: MemberId) { - val roles = memberRolePersistencePort.findRoleNamesByMemberId(memberId.value) - if (roles.isEmpty()) { - assignGuestRole(memberId) - } + if (memberRolePersistencePort.findRoleNamesByMemberId(memberId.value).isEmpty()) assignGuestRole(memberId) } - fun ensureRoleAssignedByName( - memberId: MemberId, - roleName: String, - ) { - val roles = memberRolePersistencePort.findRoleNamesByMemberId(memberId.value) - if (roles.none { it == roleName }) { - val roleId = roleQueryUseCase.findIdByName(roleName) - memberRolePersistencePort.save( - MemberRole.of( - memberId = memberId, - roleId = RoleId(roleId), - ), - ) - } - } - - fun replaceWithSingleRoleByName( - memberId: MemberId, - roleName: String, - ) { - val roleId = roleQueryUseCase.findIdByName(roleName) - memberRolePersistencePort.upsertSingleActiveRole(memberId.value, roleId) - } - - fun replaceCohortRoleByName( - memberId: MemberId, - roleName: String, - cohortRolePrefix: String, - ) { - val roleId = roleQueryUseCase.findIdByName(roleName) - memberRolePersistencePort.upsertCohortRole( + fun replaceWithSingleRoleByType(memberId: MemberId, roleType: RoleType, cohortId: CohortId? = null) { + val roleId = roleQueryUseCase.findIdByName(roleType.code) + memberRolePersistencePort.upsertSingleActiveRole( memberId = memberId.value, roleId = roleId, - cohortRolePrefix = cohortRolePrefix, + cohortId = cohortId?.value, ) } + + fun replaceCohortRole(memberId: MemberId, roleType: RoleType, cohortId: CohortId) { + val roleId = roleQueryUseCase.findIdByName(roleType.code) + memberRolePersistencePort.replaceCohortRole(memberId.value, roleId, cohortId.value) + } } diff --git a/application/src/main/kotlin/core/application/member/presentation/controller/MemberApi.kt b/application/src/main/kotlin/core/application/member/presentation/controller/MemberApi.kt index d38579019..1ef36457c 100644 --- a/application/src/main/kotlin/core/application/member/presentation/controller/MemberApi.kt +++ b/application/src/main/kotlin/core/application/member/presentation/controller/MemberApi.kt @@ -2,7 +2,6 @@ package core.application.member.presentation.controller import core.application.common.exception.CustomResponse import core.application.member.presentation.request.AppleMemberProfileUpdateRequest -import core.application.member.presentation.request.ConvertDeeperToOrganizerRequest import core.application.member.presentation.request.InitMemberDataRequest import core.application.member.presentation.request.MemberNameHashValidationRequest import core.application.member.presentation.request.UpdateMemberStatusRequest @@ -421,20 +420,6 @@ interface MemberApi { ) fun updateMemberStatus(request: UpdateMemberStatusRequest): CustomResponse - @Operation( - summary = "DEEPER를 ORGANIZER로 변환 API (dev)", - description = "지정한 멤버의 권한을 DEEPER에서 ORGANIZER로 변환합니다.", - ) - @ApiResponse(responseCode = "200", description = "권한 변환 성공") - fun convertDeeperToOrganizer(request: ConvertDeeperToOrganizerRequest): CustomResponse - - @Operation( - summary = "신규 기수 참여 회원 init API (dev)", - description = - "신규 기수 참여 회원에 대해 초기화 합니다.\n" + - "해당 기수 출석부, 공지/과제, 회식 참여 등에 해당 멤버를 추가합니다.", - ) - @ApiResponse(responseCode = "200", description = "신규 기수 참여 회원 init 성공") fun initMemberCohort( memberId: MemberId, cohortId: CohortId, diff --git a/application/src/main/kotlin/core/application/member/presentation/controller/MemberController.kt b/application/src/main/kotlin/core/application/member/presentation/controller/MemberController.kt index 316f3966b..038c20a11 100644 --- a/application/src/main/kotlin/core/application/member/presentation/controller/MemberController.kt +++ b/application/src/main/kotlin/core/application/member/presentation/controller/MemberController.kt @@ -10,7 +10,6 @@ import core.application.member.application.service.auth.AuthTokenResponse import core.application.member.application.service.auth.EmailPasswordAuthService import core.application.member.presentation.controller.MemberLoginController.AppleLoginRequest import core.application.member.presentation.request.AppleMemberProfileUpdateRequest -import core.application.member.presentation.request.ConvertDeeperToOrganizerRequest import core.application.member.presentation.request.InitMemberDataRequest import core.application.member.presentation.request.MemberNameHashValidationRequest import core.application.member.presentation.request.SetPasswordRequest @@ -166,14 +165,6 @@ class MemberController( return CustomResponse.ok() } - @PreAuthorize("hasAuthority('update:member')") - @PatchMapping("/authority/organizer") - override fun convertDeeperToOrganizer( - @Valid @RequestBody request: ConvertDeeperToOrganizerRequest, - ): CustomResponse { - memberCommandService.convertDeeperToOrganizer(request) - return CustomResponse.ok() - } @PostMapping("/authority/cohort/init/{cohortId}/{memberId}") override fun initMemberCohort( diff --git a/application/src/main/kotlin/core/application/member/presentation/request/ConvertDeeperToOrganizerRequest.kt b/application/src/main/kotlin/core/application/member/presentation/request/ConvertDeeperToOrganizerRequest.kt deleted file mode 100644 index d423435fa..000000000 --- a/application/src/main/kotlin/core/application/member/presentation/request/ConvertDeeperToOrganizerRequest.kt +++ /dev/null @@ -1,15 +0,0 @@ -package core.application.member.presentation.request - -import core.domain.member.vo.MemberId -import io.swagger.v3.oas.annotations.media.Schema -import jakarta.validation.constraints.NotNull - -data class ConvertDeeperToOrganizerRequest( - @field:NotNull - @field:Schema( - description = "변환 대상 멤버 식별자", - example = "1", - requiredMode = Schema.RequiredMode.REQUIRED, - ) - val memberId: MemberId, -) diff --git a/application/src/main/kotlin/core/application/security/oauth/redirect/OAuthCallbackRedirectService.kt b/application/src/main/kotlin/core/application/security/oauth/redirect/OAuthCallbackRedirectService.kt index d6664d7e9..8d2c78985 100644 --- a/application/src/main/kotlin/core/application/security/oauth/redirect/OAuthCallbackRedirectService.kt +++ b/application/src/main/kotlin/core/application/security/oauth/redirect/OAuthCallbackRedirectService.kt @@ -62,7 +62,7 @@ class OAuthCallbackRedirectService( private fun resolveClientRedirectUri(request: HttpServletRequest): String? = listOfNotNull(request.getHeader(REFERER), request.getHeader(ORIGIN)) - .firstNotNullOfOrNull(::validateClientRedirectUri) + .firstNotNullOfOrNull(::toAppOriginRedirect) private fun loadClientRedirectUri(request: HttpServletRequest): String? = request.cookies @@ -70,18 +70,23 @@ class OAuthCallbackRedirectService( ?.value ?.takeIf { it.isNotBlank() } ?.let(::decode) - ?.let(::validateClientRedirectUri) + ?.let(::toAppOriginRedirect) - private fun validateClientRedirectUri(candidate: String): String? = + /** + * Remember only the FE origin root ("https://host:port/"). + * Using the full Referer would bounce users back to /login after Kakao OAuth. + */ + private fun toAppOriginRedirect(candidate: String): String? = runCatching { val validatedUri = redirectUriValidator.validate(candidate) - val host = URI.create(validatedUri).host.lowercase() + val uri = URI.create(validatedUri) + val host = uri.host.lowercase() require(host !in DISALLOWED_REDIRECT_HOSTS && DISALLOWED_REDIRECT_PREFIXES.none(host::startsWith)) { "Disallowed OAuth browser redirect target: $host" } - validatedUri + URI(uri.scheme, uri.authority, "/", null, null).toString() }.getOrNull() private fun buildRedirectUri( @@ -111,11 +116,14 @@ class OAuthCallbackRedirectService( return when { requestHost == "localhost" || requestHost == "127.0.0.1" || requestHost == "::1" -> - "https://localhost:3000/" + // Prefer remembered FE origin cookie; this is only a last-resort fallback. + "https://local-admin.depromeet.shop:3020/" requestHost.startsWith("api.") -> "https://${requestHost.replaceFirst("api.", "core.")}/" requestHost.startsWith("local-api.") -> "https://${requestHost.replaceFirst("local-api.", "local-core.")}/" + requestHost.startsWith("dev-api.") -> + "https://${requestHost.replaceFirst("dev-api.", "dev-core.")}/" else -> { val portSuffix = when { @@ -134,13 +142,17 @@ class OAuthCallbackRedirectService( value: String, maxAgeSeconds: Long, ): String { + // SameSite=None requires Secure. On local HTTP (secure=false) use Lax so the + // browser actually stores OAUTH2_REDIRECT_URI and returns to the right FE. + val secure = securityProperties.cookie.secure + val sameSite = if (secure) SAME_SITE_NONE else SAME_SITE_LAX val builder = ResponseCookie .from(name, value) .path("/") .httpOnly(true) - .secure(securityProperties.cookie.secure) - .sameSite(SAME_SITE_NONE) + .secure(secure) + .sameSite(sameSite) .maxAge(Duration.ofSeconds(maxAgeSeconds)) resolveCookieDomain()?.let(builder::domain) @@ -172,6 +184,7 @@ class OAuthCallbackRedirectService( private const val REFERER = "Referer" private const val ORIGIN = "Origin" private const val SAME_SITE_NONE = "None" + private const val SAME_SITE_LAX = "Lax" private const val AUTHENTICATED_PARAM = "authenticated" private const val ERROR_CODE_PARAM = "errorCode" diff --git a/application/src/main/kotlin/core/application/security/oauth/repository/HttpCookieOAuth2AuthorizationRequestRepository.kt b/application/src/main/kotlin/core/application/security/oauth/repository/HttpCookieOAuth2AuthorizationRequestRepository.kt index 6842885c7..749b6a6bb 100644 --- a/application/src/main/kotlin/core/application/security/oauth/repository/HttpCookieOAuth2AuthorizationRequestRepository.kt +++ b/application/src/main/kotlin/core/application/security/oauth/repository/HttpCookieOAuth2AuthorizationRequestRepository.kt @@ -2,6 +2,7 @@ package core.application.security.oauth.repository import core.application.security.oauth.redirect.OAuthCallbackRedirectService import core.application.security.oauth.repository.mapper.AuthorizationRequestCookieValueMapper +import core.application.security.properties.SecurityProperties import jakarta.servlet.http.Cookie import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse @@ -16,6 +17,7 @@ private const val REQUEST_COOKIE_MAX_AGE = 180 class HttpCookieOAuth2AuthorizationRequestRepository( private val authorizationRequestCookieValueMapper: AuthorizationRequestCookieValueMapper, private val oAuthCallbackRedirectService: OAuthCallbackRedirectService, + private val securityProperties: SecurityProperties, ) : AuthorizationRequestRepository { override fun loadAuthorizationRequest(request: HttpServletRequest): OAuth2AuthorizationRequest? = getAuthorizationRequestCookie(request) @@ -52,22 +54,23 @@ class HttpCookieOAuth2AuthorizationRequestRepository( response: HttpServletResponse, value: String, ) { - val cookieValue = - "$REQUEST_COOKIE_NAME=$value; " + - "Path=/; " + - "HttpOnly; " + - "Secure; " + - "SameSite=None; " + - "Max-Age=$REQUEST_COOKIE_MAX_AGE" - - response.addHeader("Set-Cookie", cookieValue) + response.addHeader("Set-Cookie", buildCookieHeader(value = value, maxAge = REQUEST_COOKIE_MAX_AGE)) } private fun deleteCookie(response: HttpServletResponse) { - val cookieValue = - "$REQUEST_COOKIE_NAME=; Path=/; Max-Age=0; Secure; SameSite=None" + response.addHeader("Set-Cookie", buildCookieHeader(value = "", maxAge = 0)) + } + + private fun buildCookieHeader( + value: String, + maxAge: Int, + ): String { + val secure = securityProperties.cookie.secure + // SameSite=None requires Secure; use Lax on local HTTP so browsers keep the cookie. + val sameSite = if (secure) "None" else "Lax" + val secureAttr = if (secure) "; Secure" else "" - response.addHeader("Set-Cookie", cookieValue) + return "$REQUEST_COOKIE_NAME=$value; Path=/; HttpOnly$secureAttr; SameSite=$sameSite; Max-Age=$maxAge" } private fun getAuthorizationRequestCookie(request: HttpServletRequest): Cookie? = diff --git a/application/src/main/kotlin/core/application/security/oauth/token/JwtTokenInjector.kt b/application/src/main/kotlin/core/application/security/oauth/token/JwtTokenInjector.kt index bcbed9697..99dd40900 100644 --- a/application/src/main/kotlin/core/application/security/oauth/token/JwtTokenInjector.kt +++ b/application/src/main/kotlin/core/application/security/oauth/token/JwtTokenInjector.kt @@ -39,27 +39,30 @@ class JwtTokenInjector( refreshToken: String, response: HttpServletResponse, ) { + val secure = securityProperties.cookie.secure response.addHeader( SET_COOKIE_HEADER, buildCookie( name = REFRESH_TOKEN_CAMEL_CASE, value = refreshToken, maxAgeSeconds = tokenProperties.expirationTime.refreshToken, - secure = true, - sameSite = SAME_SITE_NONE, + // SameSite=None requires Secure. Local HTTP must use Lax so the browser stores it. + secure = secure, + sameSite = if (secure) SAME_SITE_NONE else SAME_SITE_LAX, ), ) } fun invalidateRefreshToken(response: HttpServletResponse) { + val secure = securityProperties.cookie.secure response.addHeader( SET_COOKIE_HEADER, buildCookie( name = REFRESH_TOKEN_CAMEL_CASE, value = "", maxAgeSeconds = 0, - secure = true, - sameSite = SAME_SITE_NONE, + secure = secure, + sameSite = if (secure) SAME_SITE_NONE else SAME_SITE_LAX, ), ) } diff --git a/application/src/main/resources/application-local.yml b/application/src/main/resources/application-local.yml index 164b4ef7b..490d08067 100644 --- a/application/src/main/resources/application-local.yml +++ b/application/src/main/resources/application-local.yml @@ -7,25 +7,6 @@ spring: username: ${DB_USERNAME} password: ${DB_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver - security: - oauth2: - client: - registration: - apple: - client-id: ${LOCAL_APPLE_CLIENT_ID} - client-secret: ${LOCAL_APPLE_CLIENT_SECRET} - redirect-uri: ${LOCAL_APPLE_REDIRECT_URI} - authorization-grant-type: authorization_code - client-authentication-method: client_secret_post - scope: - - name - - email - provider: - apple: - authorization-uri: https://appleid.apple.com/auth/authorize?response_mode=form_post - token-uri: https://appleid.apple.com/auth/token - jwk-set-uri: https://appleid.apple.com/auth/keys - user-name-attribute: sub jpa: hibernate: ddl-auto: update diff --git a/application/src/main/resources/application.yml b/application/src/main/resources/application.yml index 06fd4269d..cb64687a4 100644 --- a/application/src/main/resources/application.yml +++ b/application/src/main/resources/application.yml @@ -64,8 +64,6 @@ logging: org.jooq.tools.LoggerListener: DEBUG org.springframework.security: ${SECURITY_LOGGING_LEVEL:DEBUG} -cohort: - value: ${COHORT_VALUE} session: attendance: @@ -84,6 +82,8 @@ cors: - https://local.depromeet.shop - https://local-core.depromeet.shop:3010 - https://local-admin.depromeet.shop:3020 + - https://local-core.depromeet.com:3010 + - https://local-core.depromeet.com:3020 - https://core.depromeet.shop - https://admin.depromeet.shop - https://api.depromeet.shop diff --git a/application/src/test/kotlin/core/application/member/application/service/role/CurrentCohortRoleResolverTest.kt b/application/src/test/kotlin/core/application/member/application/service/role/CurrentCohortRoleResolverTest.kt new file mode 100644 index 000000000..af1c5adff --- /dev/null +++ b/application/src/test/kotlin/core/application/member/application/service/role/CurrentCohortRoleResolverTest.kt @@ -0,0 +1,73 @@ +package core.application.member.application.service.role + +import core.domain.authorization.vo.RoleType +import core.domain.cohort.vo.CohortId +import core.domain.member.vo.MemberRoleAssignment +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.mockito.Mockito.mock + +class CurrentCohortRoleResolverTest { + private val resolver = CurrentCohortRoleResolver( + cohortQueryService = mock(core.application.cohort.application.service.CohortQueryService::class.java), + memberCohortPersistencePort = mock(core.domain.member.port.outbound.MemberCohortPersistencePort::class.java), + memberRolePersistencePort = mock(core.domain.member.port.outbound.MemberRolePersistencePort::class.java), + ) + + @Test + fun guestIsAlwaysEffective() { + val context = context(18L, setOf(18L)) + assertEquals( + listOf("GUEST"), + resolver.filterEffectiveRoles(listOf(MemberRoleAssignment("GUEST", null)), context), + ) + } + + @Test + fun alumniOrganizerFilteredWhenNotInActiveCohort() { + val context = context(18L, setOf(17L)) + assertEquals( + emptyList(), + resolver.filterEffectiveRoles(listOf(MemberRoleAssignment("ORGANIZER", CohortId(17L))), context), + ) + } + + @Test + fun deeperInActiveCohortIsEffective() { + val context = context(18L, setOf(18L)) + assertEquals(listOf("DEEPER"), resolver.filterEffectiveRoles(listOf(MemberRoleAssignment("DEEPER", CohortId(18L))), context)) + } + + @Test + fun masterIsAlwaysEffective() { + val context = context(18L, emptySet()) + assertEquals(listOf("MASTER"), resolver.filterEffectiveRoles(listOf(MemberRoleAssignment("MASTER", null)), context)) + } + + @Test + fun coreIsFilteredWhenNotInActiveCohort() { + val context = context(18L, setOf(17L)) + assertEquals( + emptyList(), + resolver.filterEffectiveRoles(listOf(MemberRoleAssignment("CORE", CohortId(17L))), context), + ) + } + + @Test + fun coreInActiveCohortIsEffective() { + val context = context(18L, setOf(18L)) + assertEquals( + listOf("CORE"), + resolver.filterEffectiveRoles(listOf(MemberRoleAssignment("CORE", CohortId(18L))), context), + ) + } + + @Test + fun primaryRoleFallsBackToGuest() { + val context = context(18L, setOf(17L)) + assertEquals(RoleType.Guest, resolver.findPrimaryRoleType(listOf(MemberRoleAssignment("DEEPER", CohortId(17L))), context)) + } + + private fun context(activeCohortId: Long?, memberCohortIds: Set) = + CurrentCohortRoleResolver.CohortRoleContext(activeCohortId, memberCohortIds) +} diff --git a/application/src/test/kotlin/core/application/security/oauth/repository/HttpCookieOAuth2AuthorizationRequestRepositoryTest.kt b/application/src/test/kotlin/core/application/security/oauth/repository/HttpCookieOAuth2AuthorizationRequestRepositoryTest.kt index 40abd4771..6c9c1510c 100644 --- a/application/src/test/kotlin/core/application/security/oauth/repository/HttpCookieOAuth2AuthorizationRequestRepositoryTest.kt +++ b/application/src/test/kotlin/core/application/security/oauth/repository/HttpCookieOAuth2AuthorizationRequestRepositoryTest.kt @@ -57,6 +57,16 @@ class HttpCookieOAuth2AuthorizationRequestRepositoryTest { ), ), ), + securityProperties = + SecurityProperties( + logoutUrl = "/logout", + cookie = + SecurityProperties.Cookie( + domain = "depromeet.com", + httpOnly = true, + secure = true, + ), + ), ) private fun createAuthorizationRequest(): OAuth2AuthorizationRequest = diff --git a/domain/src/main/kotlin/core/domain/authorization/vo/RoleType.kt b/domain/src/main/kotlin/core/domain/authorization/vo/RoleType.kt index 2106723c2..6a5d84353 100644 --- a/domain/src/main/kotlin/core/domain/authorization/vo/RoleType.kt +++ b/domain/src/main/kotlin/core/domain/authorization/vo/RoleType.kt @@ -2,51 +2,32 @@ package core.domain.authorization.vo sealed class RoleType( val code: String, - val aliases: Set, ) { - data object Core : RoleType( - code = "CORE", - aliases = setOf("코어", "core"), - ) - - data object Organizer : RoleType( - code = "ORGANIZER", - aliases = setOf("운영진", "운영", "관리자"), - ) - - data object Deeper : RoleType( - code = "DEEPER", - aliases = setOf("디퍼", "deeper"), - ) - - data object Guest : RoleType( - code = "GUEST", - aliases = emptySet(), - ) + data object Master : RoleType("MASTER") + data object Core : RoleType("CORE") + data object Organizer : RoleType("ORGANIZER") + data object Deeper : RoleType("DEEPER") + data object Guest : RoleType("GUEST") companion object { private val values: List by lazy { - RoleType::class.sealedSubclasses - .mapNotNull { it.objectInstance } - .filter { it != Guest } + RoleType::class.sealedSubclasses.mapNotNull { it.objectInstance } } + fun fromCode(raw: String?): RoleType = + values.firstOrNull { it.code.equals(raw?.trim(), ignoreCase = true) } ?: Guest + fun from(raw: String?): RoleType { if (raw.isNullOrBlank()) return Guest - - val tokens = - raw - .lowercase() - .split(Regex("[^가-힣a-z]+")) - .filter { it.isNotBlank() } - - return values.firstOrNull { role -> - role.aliases.any { alias -> - tokens.any { token -> - token == alias.lowercase() - } - } - } ?: Guest + fromCode(raw).takeIf { it != Guest }?.let { return it } + val tokens = raw.lowercase().split(Regex("[^가-힣a-z]+")).filter { it.isNotBlank() } + val aliasMap = mapOf( + "master" to Master, "마스터" to Master, + "core" to Core, "코어" to Core, + "organizer" to Organizer, "운영진" to Organizer, "운영" to Organizer, "관리자" to Organizer, + "deeper" to Deeper, "디퍼" to Deeper, + ) + return tokens.firstNotNullOfOrNull { aliasMap[it] } ?: Guest } } } diff --git a/domain/src/main/kotlin/core/domain/cohort/aggregate/Cohort.kt b/domain/src/main/kotlin/core/domain/cohort/aggregate/Cohort.kt index cbbc7bf4f..e8f7e58bb 100644 --- a/domain/src/main/kotlin/core/domain/cohort/aggregate/Cohort.kt +++ b/domain/src/main/kotlin/core/domain/cohort/aggregate/Cohort.kt @@ -3,6 +3,7 @@ package core.domain.cohort.aggregate import core.domain.cohort.vo.CohortId import core.domain.member.vo.MemberCohortId import core.domain.team.vo.TeamId +import java.time.Instant /** * 기수(Cohort)를 표현하는 도메인 모델입니다. @@ -22,6 +23,8 @@ import core.domain.team.vo.TeamId class Cohort( val id: CohortId? = null, val value: String, + val isActive: Boolean = false, + val activatedAt: Instant? = null, val createdAt: Long? = null, val updatedAt: Long? = null, val teamIds: List = emptyList(), diff --git a/domain/src/main/kotlin/core/domain/cohort/port/inbound/CohortQueryUseCase.kt b/domain/src/main/kotlin/core/domain/cohort/port/inbound/CohortQueryUseCase.kt index 97c18db6e..5ab732587 100644 --- a/domain/src/main/kotlin/core/domain/cohort/port/inbound/CohortQueryUseCase.kt +++ b/domain/src/main/kotlin/core/domain/cohort/port/inbound/CohortQueryUseCase.kt @@ -3,7 +3,11 @@ package core.domain.cohort.port.inbound import core.domain.cohort.vo.CohortId interface CohortQueryUseCase { - fun getLatestCohortId(): CohortId + fun getActiveCohortId(): CohortId - fun getLatestCohortValue(): String + fun getActiveCohortValue(): String + + fun getLatestCohortId(): CohortId = getActiveCohortId() + + fun getLatestCohortValue(): String = getActiveCohortValue() } diff --git a/domain/src/main/kotlin/core/domain/cohort/port/outbound/CohortPersistencePort.kt b/domain/src/main/kotlin/core/domain/cohort/port/outbound/CohortPersistencePort.kt index 9f56ab062..e78f3236f 100644 --- a/domain/src/main/kotlin/core/domain/cohort/port/outbound/CohortPersistencePort.kt +++ b/domain/src/main/kotlin/core/domain/cohort/port/outbound/CohortPersistencePort.kt @@ -17,4 +17,10 @@ interface CohortPersistencePort { fun existsByValue(value: String): Boolean fun hasAnyReference(cohortId: CohortId): Boolean + + fun findActive(): Cohort? + + fun deactivateAll() + + fun activate(cohortId: CohortId) } diff --git a/domain/src/main/kotlin/core/domain/member/aggregate/MemberRole.kt b/domain/src/main/kotlin/core/domain/member/aggregate/MemberRole.kt index 1000a7221..32f39d0ef 100644 --- a/domain/src/main/kotlin/core/domain/member/aggregate/MemberRole.kt +++ b/domain/src/main/kotlin/core/domain/member/aggregate/MemberRole.kt @@ -1,6 +1,7 @@ package core.domain.member.aggregate import core.domain.authorization.vo.RoleId +import core.domain.cohort.vo.CohortId import core.domain.member.vo.MemberId import core.domain.member.vo.MemberRoleId import java.time.Instant @@ -9,6 +10,7 @@ class MemberRole( val id: MemberRoleId? = null, val memberId: MemberId, val roleId: RoleId, + val cohortId: CohortId? = null, grantedAt: Instant? = null, deletedAt: Instant? = null, ) { diff --git a/domain/src/main/kotlin/core/domain/member/port/outbound/MemberAuthorityPersistencePort.kt b/domain/src/main/kotlin/core/domain/member/port/outbound/MemberAuthorityPersistencePort.kt deleted file mode 100644 index 262960734..000000000 --- a/domain/src/main/kotlin/core/domain/member/port/outbound/MemberAuthorityPersistencePort.kt +++ /dev/null @@ -1,32 +0,0 @@ -package core.domain.member.port.outbound - -import core.domain.cohort.vo.AuthorityId -import core.domain.member.vo.MemberId - -interface MemberAuthorityPersistencePort { - fun findAuthorityNamesByMemberId(memberId: MemberId): List - - fun findActiveAuthorityIdsByMemberId(memberId: MemberId): List - - fun ensureAuthorityAssigned( - memberId: MemberId, - authorityName: String, - ) - - fun ensureAuthorityAssigned( - memberId: MemberId, - authorityId: AuthorityId, - ) - - fun revokeAuthority( - memberId: MemberId, - authorityName: String, - ) - - fun revokeAuthority( - memberId: MemberId, - authorityId: AuthorityId, - ) - - fun revokeAllByMemberId(memberId: MemberId) -} diff --git a/domain/src/main/kotlin/core/domain/member/port/outbound/MemberCohortPersistencePort.kt b/domain/src/main/kotlin/core/domain/member/port/outbound/MemberCohortPersistencePort.kt index 08a9ef95b..d7e8e2790 100644 --- a/domain/src/main/kotlin/core/domain/member/port/outbound/MemberCohortPersistencePort.kt +++ b/domain/src/main/kotlin/core/domain/member/port/outbound/MemberCohortPersistencePort.kt @@ -11,4 +11,6 @@ interface MemberCohortPersistencePort { ): Boolean fun deleteByMemberId(memberId: Long) + + fun findCohortIdsByMemberId(memberId: Long): List } diff --git a/domain/src/main/kotlin/core/domain/member/port/outbound/MemberRolePersistencePort.kt b/domain/src/main/kotlin/core/domain/member/port/outbound/MemberRolePersistencePort.kt index c1fe69278..a1adce455 100644 --- a/domain/src/main/kotlin/core/domain/member/port/outbound/MemberRolePersistencePort.kt +++ b/domain/src/main/kotlin/core/domain/member/port/outbound/MemberRolePersistencePort.kt @@ -1,6 +1,7 @@ package core.domain.member.port.outbound import core.domain.member.aggregate.MemberRole +import core.domain.member.vo.MemberRoleAssignment interface MemberRolePersistencePort { fun save(memberRole: MemberRole) @@ -8,16 +9,19 @@ interface MemberRolePersistencePort { fun upsertSingleActiveRole( memberId: Long, roleId: Long, + cohortId: Long? = null, ) - fun upsertCohortRole( + fun replaceCohortRole( memberId: Long, roleId: Long, - cohortRolePrefix: String, + cohortId: Long, ) fun findRoleNamesByMemberId(memberId: Long): List + fun findActiveRoleAssignmentsByMemberId(memberId: Long): List + fun findRoleNamesByMemberIds(memberIds: List): Map> fun softDeleteAllByMemberId(memberId: Long) diff --git a/domain/src/main/kotlin/core/domain/member/vo/MemberRoleAssignment.kt b/domain/src/main/kotlin/core/domain/member/vo/MemberRoleAssignment.kt new file mode 100644 index 000000000..a32ae60fb --- /dev/null +++ b/domain/src/main/kotlin/core/domain/member/vo/MemberRoleAssignment.kt @@ -0,0 +1,8 @@ +package core.domain.member.vo + +import core.domain.cohort.vo.CohortId + +data class MemberRoleAssignment( + val roleName: String, + val cohortId: CohortId?, +) diff --git a/entity/src/main/kotlin/core/entity/cohort/CohortEntity.kt b/entity/src/main/kotlin/core/entity/cohort/CohortEntity.kt index 58fdd23dc..9e45859b6 100644 --- a/entity/src/main/kotlin/core/entity/cohort/CohortEntity.kt +++ b/entity/src/main/kotlin/core/entity/cohort/CohortEntity.kt @@ -13,6 +13,7 @@ import jakarta.persistence.GenerationType import jakarta.persistence.Id import jakarta.persistence.OneToMany import jakarta.persistence.Table +import java.time.Instant @Entity @Table(name = "cohorts") @@ -23,6 +24,10 @@ class CohortEntity( val id: Long, @Column(name = "\"value\"", nullable = false, unique = true) val value: String, + @Column(name = "is_active", nullable = false) + val isActive: Boolean = false, + @Column(name = "activated_at") + val activatedAt: Instant? = null, @Column(name = "created_at", nullable = false, updatable = false) val createdAt: Long, @Column(name = "updated_at", nullable = false) @@ -36,6 +41,8 @@ class CohortEntity( Cohort( id = CohortId(id), value = value, + isActive = isActive, + activatedAt = activatedAt, createdAt = createdAt, updatedAt = updatedAt, ) diff --git a/entity/src/main/kotlin/core/entity/member/MemberPermissionEntity.kt b/entity/src/main/kotlin/core/entity/member/MemberPermissionEntity.kt index fd35120f1..46be7639d 100644 --- a/entity/src/main/kotlin/core/entity/member/MemberPermissionEntity.kt +++ b/entity/src/main/kotlin/core/entity/member/MemberPermissionEntity.kt @@ -18,6 +18,11 @@ import jakarta.persistence.ManyToOne import jakarta.persistence.Table import java.time.Instant +/** + * ⚠️ [2026-09] 리팩토링 시점 기준 이 테이블은 아무 곳에서도 READ 되지 않음. + * - 신규 코드에서 이 테이블 조회를 추가할 예정이라면 그 전에 반드시 이 주석 확인할 것. + * - 예외 권한 부여 기능이 실제 필요해질 때 조회 로직을 활성화하고 이 주석을 제거할 것. + */ @Entity @Table(name = "member_permissions") class MemberPermissionEntity( diff --git a/entity/src/main/kotlin/core/entity/member/MemberRoleEntity.kt b/entity/src/main/kotlin/core/entity/member/MemberRoleEntity.kt index 48b079dcc..1e41d2253 100644 --- a/entity/src/main/kotlin/core/entity/member/MemberRoleEntity.kt +++ b/entity/src/main/kotlin/core/entity/member/MemberRoleEntity.kt @@ -1,6 +1,7 @@ package core.entity.member import core.domain.authorization.vo.RoleId +import core.domain.cohort.vo.CohortId import core.domain.member.aggregate.MemberRole import core.domain.member.vo.MemberId import core.domain.member.vo.MemberRoleId @@ -31,6 +32,8 @@ class MemberRoleEntity( @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "role_id", nullable = false, foreignKey = ForeignKey(ConstraintMode.NO_CONSTRAINT)) val role: RoleEntity, + @Column(name = "cohort_id") + val cohortId: Long? = null, @Column(name = "granted_at", nullable = false, updatable = false) val grantedAt: Instant? = null, @Column(name = "deleted_at") @@ -41,6 +44,7 @@ class MemberRoleEntity( id = MemberRoleId(this.id), memberId = MemberId(this.member.id), roleId = RoleId(this.role.id), + cohortId = this.cohortId?.let { CohortId(it) }, grantedAt = this.grantedAt, deletedAt = this.deletedAt, ) diff --git a/persistence/src/main/kotlin/core/persistence/authorization/repository/RoleRepository.kt b/persistence/src/main/kotlin/core/persistence/authorization/repository/RoleRepository.kt index 9d83afe56..8298cc9e4 100644 --- a/persistence/src/main/kotlin/core/persistence/authorization/repository/RoleRepository.kt +++ b/persistence/src/main/kotlin/core/persistence/authorization/repository/RoleRepository.kt @@ -4,13 +4,7 @@ import core.domain.authorization.aggregate.Role import core.domain.authorization.port.outbound.RolePersistencePort import core.domain.member.vo.MemberId import org.jooq.DSLContext -import org.jooq.Table -import org.jooq.impl.DSL.inline -import org.jooq.impl.DSL.max -import org.jooq.impl.DSL.name -import org.jooq.dsl.tables.references.COHORTS import org.jooq.dsl.tables.references.MEMBERS -import org.jooq.dsl.tables.references.MEMBER_COHORTS import org.jooq.dsl.tables.references.MEMBER_OAUTH import org.jooq.dsl.tables.references.MEMBER_PERMISSIONS import org.jooq.dsl.tables.references.MEMBER_ROLES @@ -28,10 +22,7 @@ class RoleRepository( override fun findAll(): List = roleJpaRepository.findAll().mapNotNull { it.toDomain() } override fun findAllByMemberExternalId(externalId: String): List = - run { - val latestMemberCohorts = latestMemberCohorts() - - dsl + dsl .select(ROLES.NAME) .from(ROLES) .join(MEMBER_ROLES) @@ -40,19 +31,11 @@ class RoleRepository( .on(MEMBER_ROLES.MEMBER_ID.eq(MEMBERS.MEMBER_ID)) .join(MEMBER_OAUTH) .on(MEMBER_OAUTH.MEMBER_ID.eq(MEMBERS.MEMBER_ID)) - .join(latestMemberCohorts) - .on(latestMemberCohortMemberIdField(latestMemberCohorts).eq(MEMBERS.MEMBER_ID)) - .join(MEMBER_COHORTS) - .on(MEMBER_COHORTS.MEMBER_COHORT_ID.eq(latestMemberCohortIdField(latestMemberCohorts))) - .join(COHORTS) - .on(COHORTS.COHORT_ID.eq(MEMBER_COHORTS.COHORT_ID)) .where(MEMBER_OAUTH.EXTERNAL_ID.eq(externalId)) .and(MEMBER_ROLES.DELETED_AT.isNull) .and(MEMBERS.DELETED_AT.isNull) - .and(ROLES.NAME.like(COHORTS.VALUE.concat(inline("기 %")))) .fetch(ROLES.NAME) .filterNotNull() - } override fun findAllPermissionsByMemberId(memberId: MemberId): List = findAllPermissionsByMemberIdAndRoleNames( @@ -64,40 +47,26 @@ class RoleRepository( memberId: MemberId, roleNames: List, ): List { - val currentRoleNames = findCurrentRoleNamesByMemberId(memberId.value) - val filteredRoleNames = if (roleNames.isEmpty()) emptyList() else currentRoleNames.filter { it in roleNames } - - val directPermissionQuery = - dsl - .select(PERMISSIONS.RESOURCE, PERMISSIONS.ACTION) - .from(PERMISSIONS) - .join(MEMBER_PERMISSIONS) - .on(PERMISSIONS.PERMISSION_ID.eq(MEMBER_PERMISSIONS.PERMISSION_ID)) - .where(MEMBER_PERMISSIONS.MEMBER_ID.eq(memberId.value)) - .and(MEMBER_PERMISSIONS.DELETED_AT.isNull) - - if (filteredRoleNames.isEmpty()) { - return directPermissionQuery - .fetch() - .map { "${it.get(PERMISSIONS.ACTION)}:${it.get(PERMISSIONS.RESOURCE)}".lowercase() } + if (roleNames.isEmpty()) { + return emptyList() } - return directPermissionQuery - .union( - dsl.select(PERMISSIONS.RESOURCE, PERMISSIONS.ACTION) - .from(PERMISSIONS) - .join(ROLE_PERMISSIONS) - .on(PERMISSIONS.PERMISSION_ID.eq(ROLE_PERMISSIONS.PERMISSION_ID)) - .join(MEMBER_ROLES) - .on(ROLE_PERMISSIONS.ROLE_ID.eq(MEMBER_ROLES.ROLE_ID)) - .join(ROLES) - .on(MEMBER_ROLES.ROLE_ID.eq(ROLES.ROLE_ID)) - .where(MEMBER_ROLES.MEMBER_ID.eq(memberId.value)) - .and(MEMBER_ROLES.DELETED_AT.isNull) - .and(ROLE_PERMISSIONS.REVOKED_AT.isNull) - .and(ROLES.NAME.`in`(filteredRoleNames)), - ).fetch() + return dsl + .select(PERMISSIONS.RESOURCE, PERMISSIONS.ACTION) + .from(PERMISSIONS) + .join(ROLE_PERMISSIONS) + .on(PERMISSIONS.PERMISSION_ID.eq(ROLE_PERMISSIONS.PERMISSION_ID)) + .join(MEMBER_ROLES) + .on(ROLE_PERMISSIONS.ROLE_ID.eq(MEMBER_ROLES.ROLE_ID)) + .join(ROLES) + .on(MEMBER_ROLES.ROLE_ID.eq(ROLES.ROLE_ID)) + .where(MEMBER_ROLES.MEMBER_ID.eq(memberId.value)) + .and(MEMBER_ROLES.DELETED_AT.isNull) + .and(ROLE_PERMISSIONS.REVOKED_AT.isNull) + .and(ROLES.NAME.`in`(roleNames)) + .fetch() .map { "${it.get(PERMISSIONS.ACTION)}:${it.get(PERMISSIONS.RESOURCE)}".lowercase() } + .distinct() } override fun findIdByName(roleName: String): Long = @@ -154,42 +123,17 @@ class RoleRepository( .where(ROLES.NAME.eq(roleName)), ) - private fun findCurrentRoleNamesByMemberId(memberId: Long): List { - val latestMemberCohorts = latestMemberCohorts() - return dsl + private fun findCurrentRoleNamesByMemberId(memberId: Long): List = + dsl .select(ROLES.NAME) .from(ROLES) .join(MEMBER_ROLES) .on(MEMBER_ROLES.ROLE_ID.eq(ROLES.ROLE_ID)) .join(MEMBERS) .on(MEMBER_ROLES.MEMBER_ID.eq(MEMBERS.MEMBER_ID)) - .join(latestMemberCohorts) - .on(latestMemberCohortMemberIdField(latestMemberCohorts).eq(MEMBERS.MEMBER_ID)) - .join(MEMBER_COHORTS) - .on(MEMBER_COHORTS.MEMBER_COHORT_ID.eq(latestMemberCohortIdField(latestMemberCohorts))) - .join(COHORTS) - .on(COHORTS.COHORT_ID.eq(MEMBER_COHORTS.COHORT_ID)) .where(MEMBER_ROLES.MEMBER_ID.eq(memberId)) .and(MEMBER_ROLES.DELETED_AT.isNull) .and(MEMBERS.DELETED_AT.isNull) - .and(ROLES.NAME.like(COHORTS.VALUE.concat(inline("기 %")))) .fetch(ROLES.NAME) .filterNotNull() - } - - private fun latestMemberCohorts() = - dsl - .select( - MEMBER_COHORTS.MEMBER_ID, - max(MEMBER_COHORTS.MEMBER_COHORT_ID).`as`("latest_member_cohort_id"), - ) - .from(MEMBER_COHORTS) - .groupBy(MEMBER_COHORTS.MEMBER_ID) - .asTable("latest_member_cohorts") - - private fun latestMemberCohortIdField(table: Table<*>) = - table.field(name("latest_member_cohort_id"), Long::class.java)!! - - private fun latestMemberCohortMemberIdField(table: Table<*>) = - table.field(MEMBER_COHORTS.MEMBER_ID)!! } diff --git a/persistence/src/main/kotlin/core/persistence/cohort/repository/CohortJpaRepository.kt b/persistence/src/main/kotlin/core/persistence/cohort/repository/CohortJpaRepository.kt index 94919445c..1338ed155 100644 --- a/persistence/src/main/kotlin/core/persistence/cohort/repository/CohortJpaRepository.kt +++ b/persistence/src/main/kotlin/core/persistence/cohort/repository/CohortJpaRepository.kt @@ -7,4 +7,6 @@ interface CohortJpaRepository : JpaRepository { fun findByValue(value: String): CohortEntity? fun existsByValue(value: String): Boolean + + fun findByIsActiveTrue(): CohortEntity? } diff --git a/persistence/src/main/kotlin/core/persistence/cohort/repository/CohortRepository.kt b/persistence/src/main/kotlin/core/persistence/cohort/repository/CohortRepository.kt index cbb4f8b6d..a71f01253 100644 --- a/persistence/src/main/kotlin/core/persistence/cohort/repository/CohortRepository.kt +++ b/persistence/src/main/kotlin/core/persistence/cohort/repository/CohortRepository.kt @@ -7,6 +7,7 @@ import org.jooq.DSLContext import org.jooq.impl.DSL import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Repository +import java.time.Instant @Repository class CohortRepository( @@ -19,18 +20,57 @@ class CohortRepository( override fun findByValue(value: String): Cohort? = cohortJpaRepository.findByValue(value)?.toDomain() + override fun findActive(): Cohort? = cohortJpaRepository.findByIsActiveTrue()?.toDomain() + override fun save(cohort: Cohort): Cohort { val now = System.currentTimeMillis() + val existing = cohort.id?.let { cohortJpaRepository.findByIdOrNull(it.value) } val entity = core.entity.cohort.CohortEntity( id = cohort.id?.value ?: 0L, value = cohort.value, - createdAt = cohort.createdAt ?: now, + isActive = cohort.isActive, + activatedAt = cohort.activatedAt ?: existing?.activatedAt, + createdAt = cohort.createdAt ?: existing?.createdAt ?: now, updatedAt = now, ) return cohortJpaRepository.save(entity).toDomain() } + override fun deactivateAll() { + val now = System.currentTimeMillis() + cohortJpaRepository.findAll().forEach { entity -> + if (entity.isActive) { + cohortJpaRepository.save( + core.entity.cohort.CohortEntity( + id = entity.id, + value = entity.value, + isActive = false, + activatedAt = entity.activatedAt, + createdAt = entity.createdAt, + updatedAt = now, + ), + ) + } + } + } + + override fun activate(cohortId: CohortId) { + deactivateAll() + val entity = cohortJpaRepository.findByIdOrNull(cohortId.value) ?: return + val now = System.currentTimeMillis() + cohortJpaRepository.save( + core.entity.cohort.CohortEntity( + id = entity.id, + value = entity.value, + isActive = true, + activatedAt = Instant.now(), + createdAt = entity.createdAt, + updatedAt = now, + ), + ) + } + override fun deleteById(cohortId: CohortId) { cohortJpaRepository.deleteById(cohortId.value) } @@ -39,15 +79,9 @@ class CohortRepository( override fun hasAnyReference(cohortId: CohortId): Boolean { val cohortValue = cohortId.value - return listOf( - "member_cohorts", - "teams", - "sessions", - "after_party_invite_tags", - ).any { tableName -> + return listOf("member_cohorts", "teams", "sessions", "after_party_invite_tags").any { tableName -> dsl.fetchExists( - dsl - .selectOne() + dsl.selectOne() .from(DSL.table(DSL.name(tableName))) .where(DSL.field(DSL.name("cohort_id"), Long::class.java).eq(cohortValue)), ) diff --git a/persistence/src/main/kotlin/core/persistence/member/repository/MemberRepository.kt b/persistence/src/main/kotlin/core/persistence/member/repository/MemberRepository.kt index 82488e1d9..ee9063fb0 100644 --- a/persistence/src/main/kotlin/core/persistence/member/repository/MemberRepository.kt +++ b/persistence/src/main/kotlin/core/persistence/member/repository/MemberRepository.kt @@ -188,7 +188,7 @@ class MemberRepository( .where(COHORTS.COHORT_ID.eq(cohortId.value)) .fetchOne(COHORTS.VALUE) ?: return emptyList() - val roleName = "${cohortValue}기 ${roleTypeFromLegacyAuthorityId(authorityId).aliases.firstOrNull() ?: "__unknown__"}" + val roleName = roleTypeFromLegacyAuthorityId(authorityId).code val latestMemberCohorts = latestMemberCohorts() val latestMemberCohortMemberIdField = latestMemberCohortsFieldMemberId(latestMemberCohorts) val latestMemberCohortIdField = latestMemberCohortsFieldId(latestMemberCohorts) @@ -210,6 +210,7 @@ class MemberRepository( .and(MEMBERS.DELETED_AT.isNull) .and(MEMBER_ROLES.DELETED_AT.isNull) .and(ROLES.NAME.eq(roleName)) + .and(org.jooq.impl.DSL.field(org.jooq.impl.DSL.name("member_roles", "cohort_id"), Long::class.java).eq(cohortId.value)) .fetch(MEMBERS.MEMBER_ID) .filterNotNull() .map { MemberId(it) } @@ -267,14 +268,8 @@ class MemberRepository( .on(MEMBER_ROLES.ROLE_ID.eq(ROLES.ROLE_ID)) .where(MEMBER_ROLES.MEMBER_ID.eq(MEMBERS.MEMBER_ID)) .and(MEMBER_ROLES.DELETED_AT.isNull) - .and( - ROLES.NAME.eq( - roleNameForCohortValue( - latestCohortValueField, - RoleType.Organizer, - ), - ), - ), + .and(ROLES.NAME.eq(RoleType.Organizer.code)) + .and(org.jooq.impl.DSL.field(org.jooq.impl.DSL.name("member_roles", "cohort_id"), Long::class.java).eq(latestCohortIdField)), ).`as`("is_admin") val statusPriority = @@ -636,10 +631,6 @@ class MemberRepository( private fun latestMemberTeamsFieldMemberId(table: org.jooq.Table<*> = latestMemberTeams()) = table.field(MEMBER_TEAMS.MEMBER_ID)!! - private fun roleNameForCohortValue( - cohortValueField: org.jooq.Field, - roleType: RoleType, - ) = cohortValueField.concat(inline("기 ${roleType.aliases.firstOrNull() ?: "__unknown__"}")) companion object { private const val LEGACY_DEEPER_AUTHORITY_ID = 1L diff --git a/persistence/src/main/kotlin/core/persistence/member/repository/authority/MemberAuthorityRepository.kt b/persistence/src/main/kotlin/core/persistence/member/repository/authority/MemberAuthorityRepository.kt deleted file mode 100644 index 5e9833b1a..000000000 --- a/persistence/src/main/kotlin/core/persistence/member/repository/authority/MemberAuthorityRepository.kt +++ /dev/null @@ -1,128 +0,0 @@ -package core.persistence.member.repository.authority - -import core.domain.cohort.vo.AuthorityId -import core.domain.member.port.outbound.MemberAuthorityPersistencePort -import core.domain.member.vo.MemberId -import org.jooq.DSLContext -import org.jooq.impl.DSL.field -import org.jooq.impl.DSL.name -import org.jooq.impl.DSL.table -import org.springframework.stereotype.Repository -import java.time.LocalDateTime -import java.time.ZoneId - -@Repository -class MemberAuthorityRepository( - private val dsl: DSLContext, -) : MemberAuthorityPersistencePort { - override fun findAuthorityNamesByMemberId(memberId: MemberId): List { - val authorityNameField = field(name("a", "name"), String::class.java) - - return dsl - .select(authorityNameField) - .from(table(name("member_authorities")).`as`("ma")) - .join(table(name("authorities")).`as`("a")) - .on( - field( - name("ma", "authority_id"), - Long::class.java, - ).eq(field(name("a", "authority_id"), Long::class.java)), - ).where(field(name("ma", "member_id"), Long::class.java).eq(memberId.value)) - .and(field(name("ma", "deleted_at"), LocalDateTime::class.java).isNull) - .fetch(authorityNameField) - .filterNotNull() - } - - override fun findActiveAuthorityIdsByMemberId(memberId: MemberId): List { - val authorityIdField = field(name("authority_id"), Long::class.java) - - return dsl - .select(authorityIdField) - .from(table(name("member_authorities"))) - .where(field(name("member_id"), Long::class.java).eq(memberId.value)) - .and(field(name("deleted_at"), LocalDateTime::class.java).isNull) - .fetch(authorityIdField) - .filterNotNull() - } - - override fun ensureAuthorityAssigned( - memberId: MemberId, - authorityName: String, - ) { - val authorityId = AuthorityId(findAuthorityIdByName(authorityName)) - ensureAuthorityAssigned(memberId, authorityId) - } - - override fun ensureAuthorityAssigned( - memberId: MemberId, - authorityId: AuthorityId, - ) { - val memberIdField = field(name("member_id"), Long::class.java) - val authorityIdField = field(name("authority_id"), Long::class.java) - val deletedAtField = field(name("deleted_at"), LocalDateTime::class.java) - - val exists = - dsl.fetchExists( - dsl - .selectOne() - .from(table(name("member_authorities"))) - .where(memberIdField.eq(memberId.value)) - .and(authorityIdField.eq(authorityId.value)) - .and(deletedAtField.isNull), - ) - - if (!exists) { - dsl - .insertInto(table(name("member_authorities"))) - .set(memberIdField, memberId.value) - .set(authorityIdField, authorityId.value) - .set(field(name("granted_at"), LocalDateTime::class.java), LocalDateTime.now(ZoneId.of(TIME_ZONE))) - .execute() - } - } - - override fun revokeAuthority( - memberId: MemberId, - authorityName: String, - ) { - val authorityId: AuthorityId = AuthorityId(findAuthorityIdByName(authorityName)) - revokeAuthority(memberId, authorityId) - } - - override fun revokeAuthority( - memberId: MemberId, - authorityId: AuthorityId, - ) { - dsl - .update(table(name("member_authorities"))) - .set(field(name("deleted_at"), LocalDateTime::class.java), LocalDateTime.now(ZoneId.of(TIME_ZONE))) - .where(field(name("member_id"), Long::class.java).eq(memberId.value)) - .and(field(name("authority_id"), Long::class.java).eq(authorityId.value)) - .and(field(name("deleted_at"), LocalDateTime::class.java).isNull) - .execute() - } - - override fun revokeAllByMemberId(memberId: MemberId) { - dsl - .update(table(name("member_authorities"))) - .set(field(name("deleted_at"), LocalDateTime::class.java), LocalDateTime.now(ZoneId.of(TIME_ZONE))) - .where(field(name("member_id"), Long::class.java).eq(memberId.value)) - .and(field(name("deleted_at"), LocalDateTime::class.java).isNull) - .execute() - } - - private fun findAuthorityIdByName(authorityName: String): Long { - val authorityIdField = field(name("authority_id"), Long::class.java) - - return dsl - .select(authorityIdField) - .from(table(name("authorities"))) - .where(field(name("name"), String::class.java).eq(authorityName)) - .fetchOne(authorityIdField) - ?: throw IllegalArgumentException("Authority not found: $authorityName") - } - - companion object { - private const val TIME_ZONE = "Asia/Seoul" - } -} diff --git a/persistence/src/main/kotlin/core/persistence/member/repository/cohort/MemberCohortRepository.kt b/persistence/src/main/kotlin/core/persistence/member/repository/cohort/MemberCohortRepository.kt index e2dc69f3a..913537e1a 100644 --- a/persistence/src/main/kotlin/core/persistence/member/repository/cohort/MemberCohortRepository.kt +++ b/persistence/src/main/kotlin/core/persistence/member/repository/cohort/MemberCohortRepository.kt @@ -41,4 +41,13 @@ class MemberCohortRepository( ) override fun deleteByMemberId(memberId: Long) = memberCohortJpaRepository.deleteByMemberId(memberId) + + override fun findCohortIdsByMemberId(memberId: Long): List = + dsl + .select(MEMBER_COHORTS.COHORT_ID) + .from(MEMBER_COHORTS) + .where(MEMBER_COHORTS.MEMBER_ID.eq(memberId)) + .fetch(MEMBER_COHORTS.COHORT_ID) + .filterNotNull() } + diff --git a/persistence/src/main/kotlin/core/persistence/member/repository/role/MemberRoleRepository.kt b/persistence/src/main/kotlin/core/persistence/member/repository/role/MemberRoleRepository.kt index 1735d904a..abf35874b 100644 --- a/persistence/src/main/kotlin/core/persistence/member/repository/role/MemberRoleRepository.kt +++ b/persistence/src/main/kotlin/core/persistence/member/repository/role/MemberRoleRepository.kt @@ -1,8 +1,12 @@ package core.persistence.member.repository.role +import core.domain.cohort.vo.CohortId import core.domain.member.aggregate.MemberRole import core.domain.member.port.outbound.MemberRolePersistencePort +import core.domain.member.vo.MemberRoleAssignment import org.jooq.DSLContext +import org.jooq.impl.DSL +import org.jooq.impl.DSL.name import org.jooq.dsl.tables.references.MEMBER_ROLES import org.jooq.dsl.tables.references.ROLES import org.springframework.stereotype.Repository @@ -18,6 +22,7 @@ class MemberRoleRepository( .insertInto(MEMBER_ROLES) .set(MEMBER_ROLES.MEMBER_ID, memberRole.memberId.value) .set(MEMBER_ROLES.ROLE_ID, memberRole.roleId.value) + .set(COHORT_ID_FIELD, memberRole.cohortId?.value) .set( MEMBER_ROLES.GRANTED_AT, memberRole.grantedAt @@ -30,6 +35,7 @@ class MemberRoleRepository( override fun upsertSingleActiveRole( memberId: Long, roleId: Long, + cohortId: Long?, ) { val now = LocalDateTime.now(ZoneId.of(TIME_ZONE)) val activeRoles = @@ -46,172 +52,134 @@ class MemberRoleRepository( .insertInto(MEMBER_ROLES) .set(MEMBER_ROLES.MEMBER_ID, memberId) .set(MEMBER_ROLES.ROLE_ID, roleId) + .set(COHORT_ID_FIELD, cohortId) .set(MEMBER_ROLES.GRANTED_AT, now) .execute() return } - val keptRole = - activeRoles.firstOrNull { it[MEMBER_ROLES.ROLE_ID] == roleId } - ?: activeRoles.first() + val keptRole = activeRoles.firstOrNull { it[MEMBER_ROLES.ROLE_ID] == roleId } ?: activeRoles.first() val keptRoleId = keptRole[MEMBER_ROLES.MEMBER_ROLE_ID] ?: return dsl .update(MEMBER_ROLES) .set(MEMBER_ROLES.ROLE_ID, roleId) + .set(COHORT_ID_FIELD, cohortId) .set(MEMBER_ROLES.GRANTED_AT, now) .set(MEMBER_ROLES.DELETED_AT, null as LocalDateTime?) .where(MEMBER_ROLES.MEMBER_ROLE_ID.eq(keptRoleId)) .execute() - val duplicateRoleIds = - activeRoles - .mapNotNull { it[MEMBER_ROLES.MEMBER_ROLE_ID] } - .filter { it != keptRoleId } - - if (duplicateRoleIds.isNotEmpty()) { - dsl - .update(MEMBER_ROLES) - .set(MEMBER_ROLES.DELETED_AT, now) - .where(MEMBER_ROLES.MEMBER_ROLE_ID.`in`(duplicateRoleIds)) - .and(MEMBER_ROLES.DELETED_AT.isNull) - .execute() - } + softDeleteDuplicates(activeRoles.mapNotNull { it[MEMBER_ROLES.MEMBER_ROLE_ID] }.filter { it != keptRoleId }, now) } - override fun upsertCohortRole( + override fun replaceCohortRole( memberId: Long, roleId: Long, - cohortRolePrefix: String, + cohortId: Long, ) { val now = LocalDateTime.now(ZoneId.of(TIME_ZONE)) - val activeCohortRoles = + val cohortBoundRoleNames = listOf("ORGANIZER", "DEEPER") + val activeRoles = dsl - .select(MEMBER_ROLES.MEMBER_ROLE_ID, MEMBER_ROLES.ROLE_ID) + .select(MEMBER_ROLES.MEMBER_ROLE_ID, MEMBER_ROLES.ROLE_ID, COHORT_ID_FIELD) .from(MEMBER_ROLES) - .join(ROLES) - .on(MEMBER_ROLES.ROLE_ID.eq(ROLES.ROLE_ID)) + .join(ROLES).on(MEMBER_ROLES.ROLE_ID.eq(ROLES.ROLE_ID)) .where(MEMBER_ROLES.MEMBER_ID.eq(memberId)) .and(MEMBER_ROLES.DELETED_AT.isNull) - .and(ROLES.NAME.startsWith(cohortRolePrefix)) + .and(ROLES.NAME.`in`(cohortBoundRoleNames)) + .and(COHORT_ID_FIELD.eq(cohortId)) .orderBy(MEMBER_ROLES.MEMBER_ROLE_ID.asc()) .fetch() - if (activeCohortRoles.isEmpty()) { + if (activeRoles.isEmpty()) { dsl .insertInto(MEMBER_ROLES) .set(MEMBER_ROLES.MEMBER_ID, memberId) .set(MEMBER_ROLES.ROLE_ID, roleId) + .set(COHORT_ID_FIELD, cohortId) .set(MEMBER_ROLES.GRANTED_AT, now) .execute() return } - val keptRole = - activeCohortRoles.firstOrNull { it[MEMBER_ROLES.ROLE_ID] == roleId } - ?: activeCohortRoles.first() - val keptRoleId = keptRole[MEMBER_ROLES.MEMBER_ROLE_ID] ?: return - + val keptRoleId = activeRoles.first()[MEMBER_ROLES.MEMBER_ROLE_ID] ?: return dsl .update(MEMBER_ROLES) .set(MEMBER_ROLES.ROLE_ID, roleId) + .set(COHORT_ID_FIELD, cohortId) .set(MEMBER_ROLES.GRANTED_AT, now) .set(MEMBER_ROLES.DELETED_AT, null as LocalDateTime?) .where(MEMBER_ROLES.MEMBER_ROLE_ID.eq(keptRoleId)) .execute() - val duplicateRoleIds = - activeCohortRoles - .mapNotNull { it[MEMBER_ROLES.MEMBER_ROLE_ID] } - .filter { it != keptRoleId } - - if (duplicateRoleIds.isNotEmpty()) { - dsl - .update(MEMBER_ROLES) - .set(MEMBER_ROLES.DELETED_AT, now) - .where(MEMBER_ROLES.MEMBER_ROLE_ID.`in`(duplicateRoleIds)) - .and(MEMBER_ROLES.DELETED_AT.isNull) - .execute() - } + softDeleteDuplicates(activeRoles.mapNotNull { it[MEMBER_ROLES.MEMBER_ROLE_ID] }.filter { it != keptRoleId }, now) } - /** - * 단순 soft delete를 위해 jOOQ 사용. - * - * MemberAuthority의 연관관계가 복잡해 JPA로 save를 시도하면 불필요한 연관 엔티티 조회가 발생함. - * - * 이에 jOOQ로 직접 update 쿼리를 작성하여, 성능을 최적화하고 불필요한 JPA 연산을 최소화함. - * - * @author LeeHanEum - * @since 2025.09.02 - */ override fun softDeleteAllByMemberId(memberId: Long) { dsl .update(MEMBER_ROLES) - .set( - MEMBER_ROLES.DELETED_AT, - LocalDateTime.now(ZoneId.of(TIME_ZONE)), - ).where( - MEMBER_ROLES.MEMBER_ID - .eq(memberId) - .and(MEMBER_ROLES.DELETED_AT.isNull()), - ).execute() + .set(MEMBER_ROLES.DELETED_AT, LocalDateTime.now(ZoneId.of(TIME_ZONE))) + .where(MEMBER_ROLES.MEMBER_ID.eq(memberId).and(MEMBER_ROLES.DELETED_AT.isNull)) + .execute() } - override fun softDeleteByMemberIdAndRoleId( - memberId: Long, - roleId: Long, - ) { + override fun softDeleteByMemberIdAndRoleId(memberId: Long, roleId: Long) { dsl .update(MEMBER_ROLES) .set(MEMBER_ROLES.DELETED_AT, LocalDateTime.now(ZoneId.of(TIME_ZONE))) .where( - MEMBER_ROLES.MEMBER_ID - .eq(memberId) + MEMBER_ROLES.MEMBER_ID.eq(memberId) .and(MEMBER_ROLES.ROLE_ID.eq(roleId)) - .and(MEMBER_ROLES.DELETED_AT.isNull()), + .and(MEMBER_ROLES.DELETED_AT.isNull), ).execute() } override fun findRoleNamesByMemberId(memberId: Long): List = + findActiveRoleAssignmentsByMemberId(memberId).map { it.roleName } + + override fun findActiveRoleAssignmentsByMemberId(memberId: Long): List = dsl - .select(ROLES.NAME) + .select(ROLES.NAME, COHORT_ID_FIELD) .from(MEMBER_ROLES) - .join(ROLES) - .on(MEMBER_ROLES.ROLE_ID.eq(ROLES.ROLE_ID)) - .where( - MEMBER_ROLES.MEMBER_ID - .eq(memberId) - .and(MEMBER_ROLES.DELETED_AT.isNull()), - ).fetch(ROLES.NAME) - .filterNotNull() + .join(ROLES).on(MEMBER_ROLES.ROLE_ID.eq(ROLES.ROLE_ID)) + .where(MEMBER_ROLES.MEMBER_ID.eq(memberId).and(MEMBER_ROLES.DELETED_AT.isNull)) + .fetch() + .mapNotNull { record -> + val roleName = record.get(ROLES.NAME) ?: return@mapNotNull null + val cohortIdValue = record.get(COHORT_ID_FIELD) + MemberRoleAssignment( + roleName = roleName, + cohortId = cohortIdValue?.let { CohortId(it) }, + ) + } override fun findRoleNamesByMemberIds(memberIds: List): Map> { - if (memberIds.isEmpty()) { - return emptyMap() - } - + if (memberIds.isEmpty()) return emptyMap() return dsl .select(MEMBER_ROLES.MEMBER_ID, ROLES.NAME) .from(MEMBER_ROLES) - .join(ROLES) - .on(MEMBER_ROLES.ROLE_ID.eq(ROLES.ROLE_ID)) - .where( - MEMBER_ROLES.MEMBER_ID - .`in`(memberIds) - .and(MEMBER_ROLES.DELETED_AT.isNull()), - ).fetch() + .join(ROLES).on(MEMBER_ROLES.ROLE_ID.eq(ROLES.ROLE_ID)) + .where(MEMBER_ROLES.MEMBER_ID.`in`(memberIds).and(MEMBER_ROLES.DELETED_AT.isNull)) + .fetch() .mapNotNull { record -> val memberId = record[MEMBER_ROLES.MEMBER_ID] ?: return@mapNotNull null val roleName = record[ROLES.NAME] ?: return@mapNotNull null memberId to roleName - }.groupBy( - keySelector = { (memberId, _) -> memberId }, - valueTransform = { (_, roleName) -> roleName }, - ) + }.groupBy({ it.first }, { it.second }) + } + + private fun softDeleteDuplicates(roleIds: List, now: LocalDateTime) { + if (roleIds.isEmpty()) return + dsl + .update(MEMBER_ROLES) + .set(MEMBER_ROLES.DELETED_AT, now) + .where(MEMBER_ROLES.MEMBER_ROLE_ID.`in`(roleIds).and(MEMBER_ROLES.DELETED_AT.isNull)) + .execute() } companion object { private const val TIME_ZONE = "Asia/Seoul" + private val COHORT_ID_FIELD = DSL.field(name("member_roles", "cohort_id"), Long::class.java) } } diff --git a/prod/pending/20260913_backup_member_roles.sql b/prod/pending/20260913_backup_member_roles.sql new file mode 100644 index 000000000..495866439 --- /dev/null +++ b/prod/pending/20260913_backup_member_roles.sql @@ -0,0 +1,47 @@ +-- ============================================================================= +-- 2026-09-13 · Phase 1 안전장치: member_roles 레거시 백업 +-- ============================================================================= +-- 목적: Phase 1 재매핑(role_id 교체)/Phase 2 cohort_id 채우기 실행 전, 원본을 +-- 같은 테이블의 legacy_role_id / legacy_role_name 컬럼에 백업한다. +-- 선행: 없음 (Phase 1 최초 스텝) +-- 후행: 20260913_role_system_seed.sql + +START TRANSACTION; + +SET @has_legacy_id := (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='member_roles' AND column_name='legacy_role_id'); +SET @sql1 := IF(@has_legacy_id=0, + 'ALTER TABLE member_roles ADD COLUMN legacy_role_id BIGINT NULL COMMENT ''Phase1 실행 전 role_id 백업''', + 'SELECT 1'); +PREPARE s1 FROM @sql1; EXECUTE s1; DEALLOCATE PREPARE s1; + +SET @has_legacy_name := (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='member_roles' AND column_name='legacy_role_name'); +SET @sql2 := IF(@has_legacy_name=0, + 'ALTER TABLE member_roles ADD COLUMN legacy_role_name VARCHAR(255) NULL COMMENT ''Phase1 실행 전 roles.name 백업''', + 'SELECT 1'); +PREPARE s2 FROM @sql2; EXECUTE s2; DEALLOCATE PREPARE s2; + +UPDATE member_roles mr +INNER JOIN roles r ON mr.role_id = r.role_id +SET mr.legacy_role_id = mr.role_id, + mr.legacy_role_name = r.name +WHERE mr.legacy_role_id IS NULL; + +COMMIT; + +-- ============================================================================= +-- ROLLBACK +-- ============================================================================= +-- 이 스크립트는 컬럼 추가 + 값 복사만 수행. 롤백은 컬럼 DROP만으로 충분. +-- 단, 후속 스크립트(role_system_seed) 재매핑을 되돌리려면 legacy 값을 이용해 +-- 원본 role_id 로 복원하는 UPDATE 를 먼저 실행할 것. +-- +-- ROLLBACK: +-- START TRANSACTION; +-- -- (선택) 재매핑 되돌리기 — Phase 1 role_id 재매핑 실행 이후에만 +-- UPDATE member_roles SET role_id = legacy_role_id WHERE legacy_role_id IS NOT NULL; +-- ALTER TABLE member_roles DROP COLUMN legacy_role_id; +-- ALTER TABLE member_roles DROP COLUMN legacy_role_name; +-- COMMIT; +-- ============================================================================= diff --git a/prod/pending/20260913_role_system_seed.sql b/prod/pending/20260913_role_system_seed.sql new file mode 100644 index 000000000..ff958e766 --- /dev/null +++ b/prod/pending/20260913_role_system_seed.sql @@ -0,0 +1,229 @@ +-- ============================================================================= +-- 2026-09-13 · Phase 1 본작업: 역할·기수 시스템 시드 & 재매핑 +-- ============================================================================= +-- 목적: 문서 §2-2 / §7 반영. 아래 5개 원본 스크립트를 하나로 합침. +-- 1) cohorts.is_active/activated_at 컬럼 + 활성 기수 세팅 + cohort_id=0 특수 슬롯 +-- 2) roles 시드 (MASTER/CORE/ORGANIZER/DEEPER/GUEST) + role_permissions 매트릭스 +-- 3) member_roles.role_id 를 "N기 역할" → canonical role 로 재매핑 +-- 4) member_cohorts 중복 제거 + UNIQUE(member_id, cohort_id) +-- 5) 구시스템 member_authorities / authorities 아카이빙 후 DROP +-- 선행: 20260913_backup_member_roles.sql +-- 후행: 20260913_schema_alignment.sql +-- 검증: 파일 하단 VERIFY 섹션 (읽기 전용) + +-- ----------------------------------------------------------------------------- +-- [1] cohorts.is_active / activated_at + cohort_id=0 특수 슬롯 +-- ----------------------------------------------------------------------------- +SET @has_is_active := (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='cohorts' AND column_name='is_active'); +SET @sql1 := IF(@has_is_active=0, + 'ALTER TABLE cohorts ADD COLUMN is_active BOOLEAN NOT NULL DEFAULT FALSE', + 'SELECT 1'); +PREPARE s1 FROM @sql1; EXECUTE s1; DEALLOCATE PREPARE s1; + +SET @has_activated_at := (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='cohorts' AND column_name='activated_at'); +SET @sql2 := IF(@has_activated_at=0, + 'ALTER TABLE cohorts ADD COLUMN activated_at DATETIME NULL', + 'SELECT 1'); +PREPARE s2 FROM @sql2; EXECUTE s2; DEALLOCATE PREPARE s2; + +UPDATE cohorts SET is_active = TRUE, activated_at = NOW() +WHERE cohort_id = (SELECT cohort_id FROM (SELECT cohort_id FROM cohorts ORDER BY CAST(`value` AS UNSIGNED) DESC LIMIT 1) t); + +-- 특수 슬롯 (§2-2): MASTER / GUEST role 저장 위치. is_active 는 항상 FALSE. +SET @prev_sql_mode := @@SESSION.sql_mode; +SET SESSION sql_mode = CONCAT_WS(',', @prev_sql_mode, 'NO_AUTO_VALUE_ON_ZERO'); +INSERT INTO cohorts (cohort_id, `value`, is_active, activated_at, created_at, updated_at) +VALUES (0, '0', FALSE, NULL, UNIX_TIMESTAMP()*1000, UNIX_TIMESTAMP()*1000) +ON DUPLICATE KEY UPDATE + is_active = FALSE, + activated_at = NULL, + updated_at = UNIX_TIMESTAMP()*1000; +SET SESSION sql_mode = @prev_sql_mode; + +-- ----------------------------------------------------------------------------- +-- [2] roles 시드 + role_permissions 매트릭스 +-- ----------------------------------------------------------------------------- +START TRANSACTION; + +INSERT INTO roles (name) VALUES ('MASTER'), ('CORE'), ('ORGANIZER'), ('DEEPER'), ('GUEST') +ON DUPLICATE KEY UPDATE name = VALUES(name); + +DELETE rp FROM role_permissions rp +INNER JOIN roles r ON rp.role_id = r.role_id +WHERE r.name IN ('MASTER', 'CORE', 'ORGANIZER', 'DEEPER', 'GUEST') AND rp.revoked_at IS NULL; + +-- MASTER: 최상위 super-admin. 문서 §6-1 결정에 따라 CORE 와 동일 매트릭스로 시작. +INSERT INTO role_permissions (role_id, permission_id, granted_at) +SELECT (SELECT role_id FROM roles WHERE name = 'MASTER'), p.permission_id, NOW() +FROM permissions p WHERE (p.resource, p.action) IN ( + ('MEMBER','CREATE'),('MEMBER','READ'),('MEMBER','UPDATE'),('MEMBER','DELETE'), + ('SESSION','CREATE'),('SESSION','READ'),('SESSION','UPDATE'),('SESSION','DELETE'), + ('ATTENDANCE','CREATE'),('ATTENDANCE','READ'),('ATTENDANCE','UPDATE'), + ('GATHERING','READ'), + ('BILL','CREATE'),('BILL','READ'),('BILL','UPDATE'), + ('AFTER_PARTY','CREATE'),('AFTER_PARTY','READ'),('AFTER_PARTY','UPDATE'), + ('COHORT','CREATE'),('COHORT','UPDATE'),('COHORT','DELETE'), + ('AUTHORIZATION','READ'),('AUTHORIZATION','UPDATE') +); + +INSERT INTO role_permissions (role_id, permission_id, granted_at) +SELECT (SELECT role_id FROM roles WHERE name = 'CORE'), p.permission_id, NOW() +FROM permissions p WHERE (p.resource, p.action) IN ( + ('MEMBER','CREATE'),('MEMBER','READ'),('MEMBER','UPDATE'),('MEMBER','DELETE'), + ('SESSION','CREATE'),('SESSION','READ'),('SESSION','UPDATE'),('SESSION','DELETE'), + ('ATTENDANCE','CREATE'),('ATTENDANCE','READ'),('ATTENDANCE','UPDATE'), + ('GATHERING','READ'), + ('BILL','CREATE'),('BILL','READ'),('BILL','UPDATE'), + ('AFTER_PARTY','CREATE'),('AFTER_PARTY','READ'),('AFTER_PARTY','UPDATE'), + ('COHORT','CREATE'),('COHORT','UPDATE'),('COHORT','DELETE'), + ('AUTHORIZATION','READ'),('AUTHORIZATION','UPDATE') +); + +INSERT INTO role_permissions (role_id, permission_id, granted_at) +SELECT (SELECT role_id FROM roles WHERE name = 'ORGANIZER'), p.permission_id, NOW() +FROM permissions p WHERE (p.resource, p.action) IN ( + ('MEMBER','CREATE'),('MEMBER','READ'),('MEMBER','UPDATE'), + ('SESSION','CREATE'),('SESSION','READ'),('SESSION','UPDATE'),('SESSION','DELETE'), + ('ATTENDANCE','CREATE'),('ATTENDANCE','READ'),('ATTENDANCE','UPDATE'), + ('GATHERING','READ'), + ('BILL','CREATE'),('BILL','READ'),('BILL','UPDATE'), + ('AFTER_PARTY','CREATE'),('AFTER_PARTY','READ'),('AFTER_PARTY','UPDATE'), + ('AUTHORIZATION','READ') +); + +INSERT INTO role_permissions (role_id, permission_id, granted_at) +SELECT (SELECT role_id FROM roles WHERE name = 'DEEPER'), p.permission_id, NOW() +FROM permissions p WHERE (p.resource, p.action) IN ( + ('MEMBER','READ'),('SESSION','READ'), + ('ATTENDANCE','CREATE'),('ATTENDANCE','READ'), + ('GATHERING','READ'),('BILL','READ'),('AFTER_PARTY','READ') +); + +COMMIT; + +-- ----------------------------------------------------------------------------- +-- [3] member_roles 재매핑: "N기 운영진/디퍼/코어" → canonical role +-- ----------------------------------------------------------------------------- +START TRANSACTION; + +UPDATE member_roles mr INNER JOIN roles old_role ON mr.role_id = old_role.role_id +SET mr.role_id = (SELECT role_id FROM roles WHERE name = 'ORGANIZER') +WHERE old_role.name REGEXP '^[0-9]+기 운영진$'; + +UPDATE member_roles mr INNER JOIN roles old_role ON mr.role_id = old_role.role_id +SET mr.role_id = (SELECT role_id FROM roles WHERE name = 'DEEPER') +WHERE old_role.name REGEXP '^[0-9]+기 디퍼$'; + +UPDATE member_roles mr INNER JOIN roles old_role ON mr.role_id = old_role.role_id +SET mr.role_id = (SELECT role_id FROM roles WHERE name = 'CORE') +WHERE old_role.name LIKE '코어%'; + +UPDATE member_roles mr INNER JOIN roles old_role ON mr.role_id = old_role.role_id +SET mr.role_id = (SELECT role_id FROM roles WHERE name = 'GUEST') +WHERE old_role.name IN ('GUEST', '게스트') + AND mr.role_id <> (SELECT role_id FROM roles WHERE name = 'GUEST'); + +COMMIT; + +-- ----------------------------------------------------------------------------- +-- [4] member_cohorts 중복 정리 + UNIQUE(member_id, cohort_id) +-- ----------------------------------------------------------------------------- +START TRANSACTION; + +DELETE mc1 FROM member_cohorts mc1 +INNER JOIN member_cohorts mc2 + ON mc1.member_id = mc2.member_id + AND mc1.cohort_id = mc2.cohort_id + AND mc1.member_cohort_id > mc2.member_cohort_id; + +SET @has_uk := (SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema=DATABASE() AND table_name='member_cohorts' AND index_name='uk_member_cohort'); +SET @sql := IF(@has_uk=0, + 'ALTER TABLE member_cohorts ADD UNIQUE KEY uk_member_cohort (member_id, cohort_id)', + 'SELECT 1'); +PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s; + +COMMIT; + +-- ----------------------------------------------------------------------------- +-- [5] 구시스템 아카이빙 + DROP: member_authorities / authorities +-- ----------------------------------------------------------------------------- +START TRANSACTION; + +CREATE TABLE IF NOT EXISTS _archive_member_authorities_20260913 AS SELECT * FROM member_authorities; +CREATE TABLE IF NOT EXISTS _archive_authorities_20260913 AS SELECT * FROM authorities; + +DROP TABLE IF EXISTS member_authorities; +DROP TABLE IF EXISTS authorities; + +COMMIT; + +-- ============================================================================= +-- VERIFY (읽기 전용) — 문서 §7.5 +-- ============================================================================= +-- active cohort (기대: 정확히 1개, 최신 기수) +SELECT cohort_id, `value`, is_active, activated_at FROM cohorts WHERE is_active = TRUE; + +-- 특수 슬롯 (기대: cohort_id=0 존재, is_active=FALSE) +SELECT cohort_id, `value`, is_active FROM cohorts WHERE cohort_id = 0; + +-- role_permissions 개수 (기대: MASTER=23, CORE=23, ORGANIZER=17, DEEPER=7, GUEST=0) +SELECT r.name, COUNT(rp.permission_id) AS perm_count +FROM roles r +LEFT JOIN role_permissions rp ON r.role_id = rp.role_id AND rp.revoked_at IS NULL +WHERE r.name IN ('MASTER', 'CORE', 'ORGANIZER', 'DEEPER', 'GUEST') +GROUP BY r.name; + +-- member_roles (기대: canonical role 만 남음) +SELECT r.name, COUNT(*) AS member_count +FROM member_roles mr +JOIN roles r ON mr.role_id = r.role_id +WHERE mr.deleted_at IS NULL +GROUP BY r.name; + +-- 구시스템 제거 (기대: member_authorities/authorities 부재, _archive_* 존재) +SHOW TABLES LIKE 'member_authorities'; +SHOW TABLES LIKE 'authorities'; +SHOW TABLES LIKE '_archive_member_authorities_%'; + +-- member_cohorts 유니크 (기대: 중복 0건, uk_member_cohort 인덱스 존재) +SELECT member_id, cohort_id, COUNT(*) AS dup +FROM member_cohorts +GROUP BY member_id, cohort_id +HAVING dup > 1; +SHOW INDEX FROM member_cohorts WHERE Key_name = 'uk_member_cohort'; + +-- ============================================================================= +-- ROLLBACK (역순 실행) +-- ============================================================================= +-- START TRANSACTION; +-- +-- -- [5] 구시스템 복원 +-- CREATE TABLE IF NOT EXISTS authorities AS SELECT * FROM _archive_authorities_20260913; +-- CREATE TABLE IF NOT EXISTS member_authorities AS SELECT * FROM _archive_member_authorities_20260913; +-- -- 필요 시 원본 인덱스/PK/FK 재적용 (아카이브 테이블에는 제약이 포함되지 않음) +-- +-- -- [4] member_cohorts UNIQUE 해제 +-- ALTER TABLE member_cohorts DROP INDEX uk_member_cohort; +-- +-- -- [3] member_roles 재매핑 되돌리기 (백업 스크립트가 채워둔 legacy_role_id 사용) +-- UPDATE member_roles SET role_id = legacy_role_id WHERE legacy_role_id IS NOT NULL; +-- +-- -- [2] role_permissions / roles 시드 원복 +-- -- 주의: 사전 스냅샷이 없다면 이 단계로는 원본 매트릭스를 재구성할 수 없음. +-- -- 운영 환경에서는 [3] 원복 완료 후 실행할 것 (기수 문자열 role 참조 무결성 이유). +-- DELETE rp FROM role_permissions rp +-- INNER JOIN roles r ON rp.role_id = r.role_id +-- WHERE r.name IN ('MASTER','CORE','ORGANIZER','DEEPER','GUEST'); +-- DELETE FROM roles WHERE name = 'MASTER'; +-- +-- -- [1] cohorts 원복 +-- DELETE FROM cohorts WHERE cohort_id = 0; +-- UPDATE cohorts SET is_active = FALSE, activated_at = NULL WHERE is_active = TRUE; +-- ALTER TABLE cohorts DROP COLUMN activated_at; +-- ALTER TABLE cohorts DROP COLUMN is_active; +-- +-- COMMIT; +-- ============================================================================= diff --git a/prod/pending/20260913_schema_alignment.sql b/prod/pending/20260913_schema_alignment.sql new file mode 100644 index 000000000..791b06c33 --- /dev/null +++ b/prod/pending/20260913_schema_alignment.sql @@ -0,0 +1,124 @@ +-- ============================================================================= +-- 2026-09-13 · Phase 2 본작업: 스키마 정합화 +-- ============================================================================= +-- 목적: 문서 §Phase 2 반영. 아래 4개 원본 스크립트를 하나로 합침. +-- 1) member_roles.cohort_id 컬럼 + 인덱스 idx_member_role_cohort +-- 2) legacy_role_name 에서 cohort_id 채우기 (ORGANIZER / DEEPER 만) +-- 3) canonical role (MASTER/CORE/ORGANIZER/DEEPER/GUEST) 외 아카이빙 + 삭제 +-- 4) member_cohorts.cohort_value 컬럼 제거 +-- 선행: 20260913_role_system_seed.sql, 20260913_backup_member_roles.sql +-- 후행: 없음 (Phase 2 최종) +-- 검증: 파일 하단 VERIFY 섹션 (읽기 전용) +-- 주의: 스키마 삭제 포함 — 롤백 비용 큼. 실행 전 논리 백업 필수. + +-- ----------------------------------------------------------------------------- +-- [1] member_roles.cohort_id 컬럼 + 인덱스 +-- ----------------------------------------------------------------------------- +START TRANSACTION; + +SET @has_cohort_id := (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='member_roles' AND column_name='cohort_id'); +SET @sql1 := IF(@has_cohort_id=0, + 'ALTER TABLE member_roles ADD COLUMN cohort_id BIGINT NULL', + 'SELECT 1'); +PREPARE s1 FROM @sql1; EXECUTE s1; DEALLOCATE PREPARE s1; + +SET @has_idx := (SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema=DATABASE() AND table_name='member_roles' AND index_name='idx_member_role_cohort'); +SET @sql2 := IF(@has_idx=0, + 'ALTER TABLE member_roles ADD INDEX idx_member_role_cohort (member_id, cohort_id)', + 'SELECT 1'); +PREPARE s2 FROM @sql2; EXECUTE s2; DEALLOCATE PREPARE s2; + +COMMIT; + +-- ----------------------------------------------------------------------------- +-- [2] legacy_role_name 에서 cohort_id 채우기 +-- ORGANIZER / DEEPER 만 대상. MASTER / CORE / GUEST 는 NULL 유지 +-- (애플리케이션 레이어에서 cohort_id=0 특수 슬롯으로 보정) +-- ----------------------------------------------------------------------------- +START TRANSACTION; + +UPDATE member_roles mr +INNER JOIN cohorts c ON c.`value` = REGEXP_SUBSTR(mr.legacy_role_name, '^[0-9]+') +INNER JOIN roles r ON mr.role_id = r.role_id +SET mr.cohort_id = c.cohort_id +WHERE mr.legacy_role_name REGEXP '^[0-9]+기' + AND r.name IN ('ORGANIZER', 'DEEPER'); + +COMMIT; + +-- ----------------------------------------------------------------------------- +-- [3] 구 roles 아카이빙 + canonical 이외 삭제 +-- ----------------------------------------------------------------------------- +START TRANSACTION; + +CREATE TABLE IF NOT EXISTS _archive_roles_20260913 AS SELECT * FROM roles; + +DELETE rp FROM role_permissions rp +INNER JOIN roles r ON rp.role_id = r.role_id +WHERE r.name NOT IN ('MASTER', 'CORE', 'ORGANIZER', 'DEEPER', 'GUEST'); + +DELETE FROM roles WHERE name NOT IN ('MASTER', 'CORE', 'ORGANIZER', 'DEEPER', 'GUEST'); + +COMMIT; + +-- ----------------------------------------------------------------------------- +-- [4] member_cohorts.cohort_value 컬럼 제거 (레거시) +-- ----------------------------------------------------------------------------- +START TRANSACTION; + +SET @has_col := (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='member_cohorts' AND column_name='cohort_value'); +SET @sql := IF(@has_col>0, + 'ALTER TABLE member_cohorts DROP COLUMN cohort_value', + 'SELECT 1'); +PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s; + +COMMIT; + +-- ============================================================================= +-- VERIFY (읽기 전용) — 문서 §7.5 +-- ============================================================================= +-- cohort_id 채움 (기대: ORGANIZER/DEEPER 는 null_cohort=0, MASTER/CORE/GUEST 는 NULL 다수) +SELECT r.name, + COUNT(*) AS total, + COUNT(mr.cohort_id) AS with_cohort, + COUNT(*) - COUNT(mr.cohort_id) AS null_cohort +FROM member_roles mr +JOIN roles r ON mr.role_id = r.role_id +WHERE mr.deleted_at IS NULL +GROUP BY r.name; + +-- canonical roles 5개만 (기대: MASTER/CORE/ORGANIZER/DEEPER/GUEST) +SELECT COUNT(*) AS role_count, GROUP_CONCAT(name ORDER BY name) AS names FROM roles; + +-- cohort_value 제거 (기대: 빈 결과) +SHOW COLUMNS FROM member_cohorts LIKE 'cohort_value'; + +-- ============================================================================= +-- ROLLBACK (역순 실행) — 스키마 삭제 포함이라 아카이브 의존 +-- ============================================================================= +-- START TRANSACTION; +-- +-- -- [4] cohort_value 컬럼 재추가 (값 복원은 별도 백업이 있어야 가능) +-- ALTER TABLE member_cohorts ADD COLUMN cohort_value VARCHAR(255) NULL; +-- -- 필요 시: UPDATE member_cohorts mc JOIN cohorts c ON mc.cohort_id = c.cohort_id +-- -- SET mc.cohort_value = c.`value`; +-- +-- -- [3] 기수 문자열 role 복원 (아카이브에서 되돌리기) +-- INSERT INTO roles (role_id, name) +-- SELECT role_id, name FROM _archive_roles_20260913 +-- WHERE name NOT IN ('MASTER','CORE','ORGANIZER','DEEPER','GUEST') +-- ON DUPLICATE KEY UPDATE name = VALUES(name); +-- -- role_permissions 는 별도 스냅샷이 없으면 재구성 불가. +-- +-- -- [2] cohort_id 채우기 되돌리기 +-- UPDATE member_roles SET cohort_id = NULL; +-- +-- -- [1] 인덱스 + 컬럼 제거 +-- ALTER TABLE member_roles DROP INDEX idx_member_role_cohort; +-- ALTER TABLE member_roles DROP COLUMN cohort_id; +-- +-- COMMIT; +-- ============================================================================= From 0de1188bc2cc44df9e3df05d9f4c52a2db1350b8 Mon Sep 17 00:00:00 2001 From: cowboysj Date: Mon, 14 Sep 2026 20:44:55 +0900 Subject: [PATCH 2/8] =?UTF-8?q?chore=20:=20build.gradle=20mainClass=20?= =?UTF-8?q?=EC=9B=90=EB=B3=B5=20=EB=B0=8F=20=EB=A7=88=EC=9D=B4=EA=B7=B8?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=85=98=20=ED=8C=8C=EC=9D=BC=20=EB=82=A0?= =?UTF-8?q?=EC=A7=9C=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - application/build.gradle.kts: mainClass를 CoreApplicationKt에서 CoreApplication으로 원복 (develop 시점 값) - prod/pending/20260913_*.sql 3개를 20260914_*.sql로 리네임 및 파일 내부 날짜 참조 갱신 --- application/build.gradle.kts | 2 +- ...backup_member_roles.sql => 20260914_backup_member_roles.sql} | 0 ...60913_role_system_seed.sql => 20260914_role_system_seed.sql} | 0 ...60913_schema_alignment.sql => 20260914_schema_alignment.sql} | 0 4 files changed, 1 insertion(+), 1 deletion(-) rename prod/pending/{20260913_backup_member_roles.sql => 20260914_backup_member_roles.sql} (100%) rename prod/pending/{20260913_role_system_seed.sql => 20260914_role_system_seed.sql} (100%) rename prod/pending/{20260913_schema_alignment.sql => 20260914_schema_alignment.sql} (100%) diff --git a/application/build.gradle.kts b/application/build.gradle.kts index dd8d41c12..dddcc3aca 100644 --- a/application/build.gradle.kts +++ b/application/build.gradle.kts @@ -57,7 +57,7 @@ tasks.named("bootJar") { } springBoot { - mainClass.set("core.application.CoreApplicationKt") + mainClass.set("core.application.CoreApplication") } tasks.withType { diff --git a/prod/pending/20260913_backup_member_roles.sql b/prod/pending/20260914_backup_member_roles.sql similarity index 100% rename from prod/pending/20260913_backup_member_roles.sql rename to prod/pending/20260914_backup_member_roles.sql diff --git a/prod/pending/20260913_role_system_seed.sql b/prod/pending/20260914_role_system_seed.sql similarity index 100% rename from prod/pending/20260913_role_system_seed.sql rename to prod/pending/20260914_role_system_seed.sql diff --git a/prod/pending/20260913_schema_alignment.sql b/prod/pending/20260914_schema_alignment.sql similarity index 100% rename from prod/pending/20260913_schema_alignment.sql rename to prod/pending/20260914_schema_alignment.sql From 3480470d4f9ffcf625a53216e52c43ae556ce435 Mon Sep 17 00:00:00 2001 From: cowboysj Date: Mon, 14 Sep 2026 21:20:40 +0900 Subject: [PATCH 3/8] =?UTF-8?q?chore=20:=20.gitignore=EC=97=90=20.env.*,?= =?UTF-8?q?=20.DS=5FStore,=20IDE=20=EB=A1=9C=EC=BB=AC=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=20=ED=8C=A8=ED=84=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.gitignore b/.gitignore index 7fdd26648..6fde46a9f 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,16 @@ out/ # Secrets secrets/ +.env +.env.* +!.env.example + +# OS metadata +.DS_Store + +# IDE / agent 로컬 설정 +.claude/ +.cursor/ # Superpowers plans (로컬 작업 문서) docs/superpowers/ From 9a478ee1d725e77b08063035969e6ee7df14363b Mon Sep 17 00:00:00 2001 From: cowboysj Date: Mon, 14 Sep 2026 21:25:36 +0900 Subject: [PATCH 4/8] =?UTF-8?q?style=20:=20ktlint=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=ED=8F=AC=EB=A7=B7=20(max-line-length,=20argument-list-wrapping?= =?UTF-8?q?=20=EB=93=B1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/AfterPartyCommandService.kt | 15 ++-- .../service/AbsenceReasonQueryService.kt | 4 -- .../application/service/RoleQueryService.kt | 3 +- .../request/UpdateMemberRoleRequest.kt | 5 +- .../service/GatheringQueryService.kt | 1 - .../service/access/MemberAccessService.kt | 3 +- .../service/role/CurrentCohortRoleResolver.kt | 8 ++- .../service/role/MemberRoleService.kt | 28 ++++++-- .../controller/MemberController.kt | 1 - .../role/CurrentCohortRoleResolverTest.kt | 17 +++-- .../absencereason/aggregate/AbsenceReason.kt | 3 +- .../core/domain/authorization/vo/RoleType.kt | 17 +++-- .../core/domain/member/aggregate/Member.kt | 5 +- .../repository/RoleRepository.kt | 1 - .../member/repository/MemberRepository.kt | 70 +++++++++++-------- .../cohort/MemberCohortRepository.kt | 1 - .../repository/role/MemberRoleRepository.kt | 24 +++++-- 17 files changed, 124 insertions(+), 82 deletions(-) diff --git a/application/src/main/kotlin/core/application/afterParty/application/service/AfterPartyCommandService.kt b/application/src/main/kotlin/core/application/afterParty/application/service/AfterPartyCommandService.kt index 708d10c15..448217f8c 100644 --- a/application/src/main/kotlin/core/application/afterParty/application/service/AfterPartyCommandService.kt +++ b/application/src/main/kotlin/core/application/afterParty/application/service/AfterPartyCommandService.kt @@ -331,13 +331,14 @@ class AfterPartyCommandService( return "${latestCohort.value}기 ${roleLabel(roleType)}" } - private fun roleLabel(roleType: RoleType): String = when (roleType) { - RoleType.Organizer -> "운영진" - RoleType.Deeper -> "디퍼" - RoleType.Core -> "코어" - RoleType.Master -> "마스터" - RoleType.Guest -> "게스트" - } + private fun roleLabel(roleType: RoleType): String = + when (roleType) { + RoleType.Organizer -> "운영진" + RoleType.Deeper -> "디퍼" + RoleType.Core -> "코어" + RoleType.Master -> "마스터" + RoleType.Guest -> "게스트" + } private fun legacyAuthorityIdForInviteTag(roleType: RoleType): Long? = when (roleType) { diff --git a/application/src/main/kotlin/core/application/attendance/application/service/AbsenceReasonQueryService.kt b/application/src/main/kotlin/core/application/attendance/application/service/AbsenceReasonQueryService.kt index 7cbe6522a..f2a8b1704 100644 --- a/application/src/main/kotlin/core/application/attendance/application/service/AbsenceReasonQueryService.kt +++ b/application/src/main/kotlin/core/application/attendance/application/service/AbsenceReasonQueryService.kt @@ -4,7 +4,6 @@ import core.application.attendance.presentation.response.MyAbsenceReasonResponse import core.application.attendance.presentation.response.SessionAbsenceReasonItem import core.application.attendance.presentation.response.SessionAbsenceReasonsResponse import core.application.common.converter.TimeMapper.instantToLocalDateTime -import core.application.common.converter.TimeMapper.localDateTimeToInstant import core.domain.absencereason.aggregate.AbsenceReason import core.domain.absencereason.port.outbound.AbsenceReasonPersistencePort import core.domain.member.port.inbound.MemberQueryUseCase @@ -12,8 +11,6 @@ import core.domain.member.vo.MemberId import core.domain.session.vo.SessionId import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional -import java.time.Instant -import java.time.LocalDateTime import java.time.ZoneId @Service @@ -75,5 +72,4 @@ class AbsenceReasonQueryService( return SessionAbsenceReasonsResponse(items) } - } diff --git a/application/src/main/kotlin/core/application/authorization/application/service/RoleQueryService.kt b/application/src/main/kotlin/core/application/authorization/application/service/RoleQueryService.kt index b47027db6..f9271c64e 100644 --- a/application/src/main/kotlin/core/application/authorization/application/service/RoleQueryService.kt +++ b/application/src/main/kotlin/core/application/authorization/application/service/RoleQueryService.kt @@ -18,7 +18,8 @@ class RoleQueryService( override fun getAllRoles(): List = getRolesByCohort("") override fun getRolesByCohort(cohort: String): List { - val canonicalNames = setOf(RoleType.Core.code, RoleType.Organizer.code, RoleType.Deeper.code, RoleType.Guest.code) + val canonicalNames = + setOf(RoleType.Core.code, RoleType.Organizer.code, RoleType.Deeper.code, RoleType.Guest.code) return rolePersistencePort.findAll().filter { it.name in canonicalNames } } diff --git a/application/src/main/kotlin/core/application/authorization/presentation/request/UpdateMemberRoleRequest.kt b/application/src/main/kotlin/core/application/authorization/presentation/request/UpdateMemberRoleRequest.kt index 32877f023..8e2223523 100644 --- a/application/src/main/kotlin/core/application/authorization/presentation/request/UpdateMemberRoleRequest.kt +++ b/application/src/main/kotlin/core/application/authorization/presentation/request/UpdateMemberRoleRequest.kt @@ -7,7 +7,10 @@ import jakarta.validation.constraints.Pattern data class UpdateMemberRoleRequest( @field:NotBlank - @field:Pattern(regexp = "MASTER|CORE|ORGANIZER|DEEPER|GUEST", message = "roleType must be one of MASTER, CORE, ORGANIZER, DEEPER, GUEST") + @field:Pattern( + regexp = "MASTER|CORE|ORGANIZER|DEEPER|GUEST", + message = "roleType must be one of MASTER, CORE, ORGANIZER, DEEPER, GUEST", + ) @field:Schema(description = "역할 타입", example = "ORGANIZER", requiredMode = Schema.RequiredMode.REQUIRED) val roleType: String, @field:NotNull diff --git a/application/src/main/kotlin/core/application/gathering/application/service/GatheringQueryService.kt b/application/src/main/kotlin/core/application/gathering/application/service/GatheringQueryService.kt index 126b0ec0f..15d4936e2 100644 --- a/application/src/main/kotlin/core/application/gathering/application/service/GatheringQueryService.kt +++ b/application/src/main/kotlin/core/application/gathering/application/service/GatheringQueryService.kt @@ -19,7 +19,6 @@ import core.domain.gathering.port.outbound.query.GatheringMemberIsJoinQueryModel import core.domain.gathering.port.outbound.query.SubmittedParticipantGathering import core.domain.gathering.vo.GatheringId import core.domain.member.port.inbound.MemberQueryUseCase -import core.domain.member.aggregate.Member import core.domain.member.vo.MemberId import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional diff --git a/application/src/main/kotlin/core/application/member/application/service/access/MemberAccessService.kt b/application/src/main/kotlin/core/application/member/application/service/access/MemberAccessService.kt index d9c7c39bb..184044d98 100644 --- a/application/src/main/kotlin/core/application/member/application/service/access/MemberAccessService.kt +++ b/application/src/main/kotlin/core/application/member/application/service/access/MemberAccessService.kt @@ -13,8 +13,7 @@ class MemberAccessService( ) { fun isAdmin(memberId: MemberId): Boolean = getRoleType(memberId) == RoleType.Organizer - fun getRoleType(memberId: MemberId): RoleType = - currentCohortRoleResolver.findPrimaryRoleTypeForMember(memberId) + fun getRoleType(memberId: MemberId): RoleType = currentCohortRoleResolver.findPrimaryRoleTypeForMember(memberId) fun getIsAdminByMemberIds(memberIds: List): Map = memberIds.associateWith { memberId -> getRoleType(memberId) == RoleType.Organizer } diff --git a/application/src/main/kotlin/core/application/member/application/service/role/CurrentCohortRoleResolver.kt b/application/src/main/kotlin/core/application/member/application/service/role/CurrentCohortRoleResolver.kt index 5f3dfd7cd..62955bc06 100644 --- a/application/src/main/kotlin/core/application/member/application/service/role/CurrentCohortRoleResolver.kt +++ b/application/src/main/kotlin/core/application/member/application/service/role/CurrentCohortRoleResolver.kt @@ -30,8 +30,9 @@ class CurrentCohortRoleResolver( memberId: MemberId, roleNames: List, ): String? { - val assignments = memberRolePersistencePort.findActiveRoleAssignmentsByMemberId(memberId.value) - .filter { it.roleName in roleNames } + val assignments = + memberRolePersistencePort.findActiveRoleAssignmentsByMemberId(memberId.value) + .filter { it.roleName in roleNames } return selectRepresentativeRole(assignments, buildContext(memberId)) } @@ -94,6 +95,7 @@ class CurrentCohortRoleResolver( ) companion object { - private val ROLE_PRIORITY = listOf(RoleType.Master, RoleType.Core, RoleType.Organizer, RoleType.Deeper, RoleType.Guest) + private val ROLE_PRIORITY = + listOf(RoleType.Master, RoleType.Core, RoleType.Organizer, RoleType.Deeper, RoleType.Guest) } } diff --git a/application/src/main/kotlin/core/application/member/application/service/role/MemberRoleService.kt b/application/src/main/kotlin/core/application/member/application/service/role/MemberRoleService.kt index 6525bdd4c..6be916a2a 100644 --- a/application/src/main/kotlin/core/application/member/application/service/role/MemberRoleService.kt +++ b/application/src/main/kotlin/core/application/member/application/service/role/MemberRoleService.kt @@ -33,7 +33,11 @@ class MemberRoleService( ) } - fun assignRole(memberId: MemberId, roleType: RoleType, cohortId: CohortId? = null) { + fun assignRole( + memberId: MemberId, + roleType: RoleType, + cohortId: CohortId? = null, + ) { val roleId = roleQueryUseCase.findIdByName(roleType.code) memberRolePersistencePort.save( MemberRole( @@ -45,12 +49,18 @@ class MemberRoleService( ) } - fun ensureRoleAssigned(memberId: MemberId, roleType: RoleType) { + fun ensureRoleAssigned( + memberId: MemberId, + roleType: RoleType, + ) { val roles = memberRolePersistencePort.findRoleNamesByMemberId(memberId.value) if (roles.none { it == roleType.code }) assignRole(memberId, roleType) } - fun revokeRole(memberId: MemberId, roleType: RoleType) { + fun revokeRole( + memberId: MemberId, + roleType: RoleType, + ) { val roleId = roleQueryUseCase.findIdByName(roleType.code) memberRolePersistencePort.softDeleteByMemberIdAndRoleId(memberId.value, roleId) } @@ -59,7 +69,11 @@ class MemberRoleService( if (memberRolePersistencePort.findRoleNamesByMemberId(memberId.value).isEmpty()) assignGuestRole(memberId) } - fun replaceWithSingleRoleByType(memberId: MemberId, roleType: RoleType, cohortId: CohortId? = null) { + fun replaceWithSingleRoleByType( + memberId: MemberId, + roleType: RoleType, + cohortId: CohortId? = null, + ) { val roleId = roleQueryUseCase.findIdByName(roleType.code) memberRolePersistencePort.upsertSingleActiveRole( memberId = memberId.value, @@ -68,7 +82,11 @@ class MemberRoleService( ) } - fun replaceCohortRole(memberId: MemberId, roleType: RoleType, cohortId: CohortId) { + fun replaceCohortRole( + memberId: MemberId, + roleType: RoleType, + cohortId: CohortId, + ) { val roleId = roleQueryUseCase.findIdByName(roleType.code) memberRolePersistencePort.replaceCohortRole(memberId.value, roleId, cohortId.value) } diff --git a/application/src/main/kotlin/core/application/member/presentation/controller/MemberController.kt b/application/src/main/kotlin/core/application/member/presentation/controller/MemberController.kt index d42d7abd0..d33118155 100644 --- a/application/src/main/kotlin/core/application/member/presentation/controller/MemberController.kt +++ b/application/src/main/kotlin/core/application/member/presentation/controller/MemberController.kt @@ -168,7 +168,6 @@ class MemberController( return CustomResponse.ok() } - @PostMapping("/authority/cohort/init/{cohortId}/{memberId}") override fun initMemberCohort( @PathVariable memberId: MemberId, diff --git a/application/src/test/kotlin/core/application/member/application/service/role/CurrentCohortRoleResolverTest.kt b/application/src/test/kotlin/core/application/member/application/service/role/CurrentCohortRoleResolverTest.kt index af1c5adff..bd4fbbbac 100644 --- a/application/src/test/kotlin/core/application/member/application/service/role/CurrentCohortRoleResolverTest.kt +++ b/application/src/test/kotlin/core/application/member/application/service/role/CurrentCohortRoleResolverTest.kt @@ -8,11 +8,12 @@ import org.junit.jupiter.api.Test import org.mockito.Mockito.mock class CurrentCohortRoleResolverTest { - private val resolver = CurrentCohortRoleResolver( - cohortQueryService = mock(core.application.cohort.application.service.CohortQueryService::class.java), - memberCohortPersistencePort = mock(core.domain.member.port.outbound.MemberCohortPersistencePort::class.java), - memberRolePersistencePort = mock(core.domain.member.port.outbound.MemberRolePersistencePort::class.java), - ) + private val resolver = + CurrentCohortRoleResolver( + cohortQueryService = mock(core.application.cohort.application.service.CohortQueryService::class.java), + memberCohortPersistencePort = mock(core.domain.member.port.outbound.MemberCohortPersistencePort::class.java), + memberRolePersistencePort = mock(core.domain.member.port.outbound.MemberRolePersistencePort::class.java), + ) @Test fun guestIsAlwaysEffective() { @@ -68,6 +69,8 @@ class CurrentCohortRoleResolverTest { assertEquals(RoleType.Guest, resolver.findPrimaryRoleType(listOf(MemberRoleAssignment("DEEPER", CohortId(17L))), context)) } - private fun context(activeCohortId: Long?, memberCohortIds: Set) = - CurrentCohortRoleResolver.CohortRoleContext(activeCohortId, memberCohortIds) + private fun context( + activeCohortId: Long?, + memberCohortIds: Set, + ) = CurrentCohortRoleResolver.CohortRoleContext(activeCohortId, memberCohortIds) } diff --git a/domain/src/main/kotlin/core/domain/absencereason/aggregate/AbsenceReason.kt b/domain/src/main/kotlin/core/domain/absencereason/aggregate/AbsenceReason.kt index b5f3a705b..d8f667e8c 100644 --- a/domain/src/main/kotlin/core/domain/absencereason/aggregate/AbsenceReason.kt +++ b/domain/src/main/kotlin/core/domain/absencereason/aggregate/AbsenceReason.kt @@ -68,8 +68,7 @@ class AbsenceReason( return result } - override fun toString(): String = - "AbsenceReason(id=$id, sessionId=$sessionId, memberId=$memberId, status=$status)" + override fun toString(): String = "AbsenceReason(id=$id, sessionId=$sessionId, memberId=$memberId, status=$status)" companion object { fun create(command: AbsenceReportCreateCommand): AbsenceReason = diff --git a/domain/src/main/kotlin/core/domain/authorization/vo/RoleType.kt b/domain/src/main/kotlin/core/domain/authorization/vo/RoleType.kt index 6a5d84353..1b64920f7 100644 --- a/domain/src/main/kotlin/core/domain/authorization/vo/RoleType.kt +++ b/domain/src/main/kotlin/core/domain/authorization/vo/RoleType.kt @@ -4,9 +4,13 @@ sealed class RoleType( val code: String, ) { data object Master : RoleType("MASTER") + data object Core : RoleType("CORE") + data object Organizer : RoleType("ORGANIZER") + data object Deeper : RoleType("DEEPER") + data object Guest : RoleType("GUEST") companion object { @@ -21,12 +25,13 @@ sealed class RoleType( if (raw.isNullOrBlank()) return Guest fromCode(raw).takeIf { it != Guest }?.let { return it } val tokens = raw.lowercase().split(Regex("[^가-힣a-z]+")).filter { it.isNotBlank() } - val aliasMap = mapOf( - "master" to Master, "마스터" to Master, - "core" to Core, "코어" to Core, - "organizer" to Organizer, "운영진" to Organizer, "운영" to Organizer, "관리자" to Organizer, - "deeper" to Deeper, "디퍼" to Deeper, - ) + val aliasMap = + mapOf( + "master" to Master, "마스터" to Master, + "core" to Core, "코어" to Core, + "organizer" to Organizer, "운영진" to Organizer, "운영" to Organizer, "관리자" to Organizer, + "deeper" to Deeper, "디퍼" to Deeper, + ) return tokens.firstNotNullOfOrNull { aliasMap[it] } ?: Guest } } diff --git a/domain/src/main/kotlin/core/domain/member/aggregate/Member.kt b/domain/src/main/kotlin/core/domain/member/aggregate/Member.kt index 8d736f099..d84feb72c 100644 --- a/domain/src/main/kotlin/core/domain/member/aggregate/Member.kt +++ b/domain/src/main/kotlin/core/domain/member/aggregate/Member.kt @@ -1,8 +1,8 @@ package core.domain.member.aggregate +import core.domain.cohort.vo.CohortId import core.domain.member.enums.MemberPart import core.domain.member.enums.MemberStatus -import core.domain.cohort.vo.CohortId import core.domain.member.vo.MemberId import java.time.Instant @@ -85,8 +85,7 @@ class Member( updatedAt = Instant.now() } - fun latestMemberCohort(): MemberCohort? = - memberCohorts.maxByOrNull { it.id?.value ?: Long.MIN_VALUE } + fun latestMemberCohort(): MemberCohort? = memberCohorts.maxByOrNull { it.id?.value ?: Long.MIN_VALUE } fun latestCohortId(): CohortId? = latestMemberCohort()?.cohortId diff --git a/persistence/src/main/kotlin/core/persistence/authorization/repository/RoleRepository.kt b/persistence/src/main/kotlin/core/persistence/authorization/repository/RoleRepository.kt index 8298cc9e4..3470c56ca 100644 --- a/persistence/src/main/kotlin/core/persistence/authorization/repository/RoleRepository.kt +++ b/persistence/src/main/kotlin/core/persistence/authorization/repository/RoleRepository.kt @@ -6,7 +6,6 @@ import core.domain.member.vo.MemberId import org.jooq.DSLContext import org.jooq.dsl.tables.references.MEMBERS import org.jooq.dsl.tables.references.MEMBER_OAUTH -import org.jooq.dsl.tables.references.MEMBER_PERMISSIONS import org.jooq.dsl.tables.references.MEMBER_ROLES import org.jooq.dsl.tables.references.PERMISSIONS import org.jooq.dsl.tables.references.ROLES diff --git a/persistence/src/main/kotlin/core/persistence/member/repository/MemberRepository.kt b/persistence/src/main/kotlin/core/persistence/member/repository/MemberRepository.kt index 9468ec832..49973a343 100644 --- a/persistence/src/main/kotlin/core/persistence/member/repository/MemberRepository.kt +++ b/persistence/src/main/kotlin/core/persistence/member/repository/MemberRepository.kt @@ -44,10 +44,9 @@ import org.jooq.dsl.tables.references.TEAMS import org.jooq.impl.DSL import org.jooq.impl.DSL.exists import org.jooq.impl.DSL.field -import org.jooq.impl.DSL.inline -import org.jooq.impl.DSL.noCondition import org.jooq.impl.DSL.max import org.jooq.impl.DSL.name +import org.jooq.impl.DSL.noCondition import org.jooq.impl.DSL.selectOne import org.jooq.impl.DSL.`when` import org.springframework.stereotype.Repository @@ -137,21 +136,21 @@ class MemberRepository( val latestMemberCohortIdField = latestMemberCohortsFieldId(latestMemberCohorts) dsl - .select(MEMBERS.MEMBER_ID) - .from(MEMBERS) - .join(latestMemberCohorts) - .on(latestMemberCohortMemberIdField.eq(MEMBERS.MEMBER_ID)) - .join(MEMBER_COHORTS) - .on(MEMBER_COHORTS.MEMBER_COHORT_ID.eq(latestMemberCohortIdField)) - .join(COHORTS) - .on(MEMBER_COHORTS.COHORT_ID.eq(COHORTS.COHORT_ID)) - .where(COHORTS.VALUE.eq(value)) - .and(MEMBERS.DELETED_AT.isNull) - .fetch(MEMBERS.MEMBER_ID) - .filterNotNull() - .map { - MemberId(it) - } + .select(MEMBERS.MEMBER_ID) + .from(MEMBERS) + .join(latestMemberCohorts) + .on(latestMemberCohortMemberIdField.eq(MEMBERS.MEMBER_ID)) + .join(MEMBER_COHORTS) + .on(MEMBER_COHORTS.MEMBER_COHORT_ID.eq(latestMemberCohortIdField)) + .join(COHORTS) + .on(MEMBER_COHORTS.COHORT_ID.eq(COHORTS.COHORT_ID)) + .where(COHORTS.VALUE.eq(value)) + .and(MEMBERS.DELETED_AT.isNull) + .fetch(MEMBERS.MEMBER_ID) + .filterNotNull() + .map { + MemberId(it) + } } override fun findAllByCohortId(cohortId: CohortId): List = @@ -161,17 +160,17 @@ class MemberRepository( val latestMemberCohortIdField = latestMemberCohortsFieldId(latestMemberCohorts) dsl - .select(MEMBERS.MEMBER_ID) - .from(MEMBERS) - .join(latestMemberCohorts) - .on(latestMemberCohortMemberIdField.eq(MEMBERS.MEMBER_ID)) - .join(MEMBER_COHORTS) - .on(MEMBER_COHORTS.MEMBER_COHORT_ID.eq(latestMemberCohortIdField)) - .where(MEMBER_COHORTS.COHORT_ID.eq(cohortId.value)) - .and(MEMBERS.DELETED_AT.isNull) - .fetch(MEMBERS.MEMBER_ID) - .filterNotNull() - .map { MemberId(it) } + .select(MEMBERS.MEMBER_ID) + .from(MEMBERS) + .join(latestMemberCohorts) + .on(latestMemberCohortMemberIdField.eq(MEMBERS.MEMBER_ID)) + .join(MEMBER_COHORTS) + .on(MEMBER_COHORTS.MEMBER_COHORT_ID.eq(latestMemberCohortIdField)) + .where(MEMBER_COHORTS.COHORT_ID.eq(cohortId.value)) + .and(MEMBERS.DELETED_AT.isNull) + .fetch(MEMBERS.MEMBER_ID) + .filterNotNull() + .map { MemberId(it) } } override fun findAllMemberIdsByCohortIdAndAuthorityId( @@ -207,7 +206,12 @@ class MemberRepository( .and(MEMBERS.DELETED_AT.isNull) .and(MEMBER_ROLES.DELETED_AT.isNull) .and(ROLES.NAME.eq(roleName)) - .and(org.jooq.impl.DSL.field(org.jooq.impl.DSL.name("member_roles", "cohort_id"), Long::class.java).eq(cohortId.value)) + .and( + org.jooq.impl.DSL.field( + org.jooq.impl.DSL.name("member_roles", "cohort_id"), + Long::class.java, + ).eq(cohortId.value), + ) .fetch(MEMBERS.MEMBER_ID) .filterNotNull() .map { MemberId(it) } @@ -266,7 +270,12 @@ class MemberRepository( .where(MEMBER_ROLES.MEMBER_ID.eq(MEMBERS.MEMBER_ID)) .and(MEMBER_ROLES.DELETED_AT.isNull) .and(ROLES.NAME.eq(RoleType.Organizer.code)) - .and(org.jooq.impl.DSL.field(org.jooq.impl.DSL.name("member_roles", "cohort_id"), Long::class.java).eq(latestCohortIdField)), + .and( + org.jooq.impl.DSL.field( + org.jooq.impl.DSL.name("member_roles", "cohort_id"), + Long::class.java, + ).eq(latestCohortIdField), + ), ).`as`("is_admin") val statusPriority = @@ -629,7 +638,6 @@ class MemberRepository( private fun latestMemberTeamsFieldMemberId(table: org.jooq.Table<*> = latestMemberTeams()) = table.field(MEMBER_TEAMS.MEMBER_ID)!! - companion object { private const val LEGACY_DEEPER_AUTHORITY_ID = 1L private const val LEGACY_ORGANIZER_AUTHORITY_ID = 2L diff --git a/persistence/src/main/kotlin/core/persistence/member/repository/cohort/MemberCohortRepository.kt b/persistence/src/main/kotlin/core/persistence/member/repository/cohort/MemberCohortRepository.kt index 913537e1a..e18ae4120 100644 --- a/persistence/src/main/kotlin/core/persistence/member/repository/cohort/MemberCohortRepository.kt +++ b/persistence/src/main/kotlin/core/persistence/member/repository/cohort/MemberCohortRepository.kt @@ -50,4 +50,3 @@ class MemberCohortRepository( .fetch(MEMBER_COHORTS.COHORT_ID) .filterNotNull() } - diff --git a/persistence/src/main/kotlin/core/persistence/member/repository/role/MemberRoleRepository.kt b/persistence/src/main/kotlin/core/persistence/member/repository/role/MemberRoleRepository.kt index abf35874b..e591d66c0 100644 --- a/persistence/src/main/kotlin/core/persistence/member/repository/role/MemberRoleRepository.kt +++ b/persistence/src/main/kotlin/core/persistence/member/repository/role/MemberRoleRepository.kt @@ -5,10 +5,10 @@ import core.domain.member.aggregate.MemberRole import core.domain.member.port.outbound.MemberRolePersistencePort import core.domain.member.vo.MemberRoleAssignment import org.jooq.DSLContext -import org.jooq.impl.DSL -import org.jooq.impl.DSL.name import org.jooq.dsl.tables.references.MEMBER_ROLES import org.jooq.dsl.tables.references.ROLES +import org.jooq.impl.DSL +import org.jooq.impl.DSL.name import org.springframework.stereotype.Repository import java.time.LocalDateTime import java.time.ZoneId @@ -70,7 +70,10 @@ class MemberRoleRepository( .where(MEMBER_ROLES.MEMBER_ROLE_ID.eq(keptRoleId)) .execute() - softDeleteDuplicates(activeRoles.mapNotNull { it[MEMBER_ROLES.MEMBER_ROLE_ID] }.filter { it != keptRoleId }, now) + softDeleteDuplicates( + activeRoles.mapNotNull { it[MEMBER_ROLES.MEMBER_ROLE_ID] }.filter { it != keptRoleId }, + now, + ) } override fun replaceCohortRole( @@ -113,7 +116,10 @@ class MemberRoleRepository( .where(MEMBER_ROLES.MEMBER_ROLE_ID.eq(keptRoleId)) .execute() - softDeleteDuplicates(activeRoles.mapNotNull { it[MEMBER_ROLES.MEMBER_ROLE_ID] }.filter { it != keptRoleId }, now) + softDeleteDuplicates( + activeRoles.mapNotNull { it[MEMBER_ROLES.MEMBER_ROLE_ID] }.filter { it != keptRoleId }, + now, + ) } override fun softDeleteAllByMemberId(memberId: Long) { @@ -124,7 +130,10 @@ class MemberRoleRepository( .execute() } - override fun softDeleteByMemberIdAndRoleId(memberId: Long, roleId: Long) { + override fun softDeleteByMemberIdAndRoleId( + memberId: Long, + roleId: Long, + ) { dsl .update(MEMBER_ROLES) .set(MEMBER_ROLES.DELETED_AT, LocalDateTime.now(ZoneId.of(TIME_ZONE))) @@ -169,7 +178,10 @@ class MemberRoleRepository( }.groupBy({ it.first }, { it.second }) } - private fun softDeleteDuplicates(roleIds: List, now: LocalDateTime) { + private fun softDeleteDuplicates( + roleIds: List, + now: LocalDateTime, + ) { if (roleIds.isEmpty()) return dsl .update(MEMBER_ROLES) From c4af10c00a00d293c917cdfc555fa3d978c2a426 Mon Sep 17 00:00:00 2001 From: cowboysj Date: Mon, 14 Sep 2026 21:33:40 +0900 Subject: [PATCH 5/8] =?UTF-8?q?Revert=20"style=20:=20ktlint=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=ED=8F=AC=EB=A7=B7=20(max-line-length,=20argument-l?= =?UTF-8?q?ist-wrapping=20=EB=93=B1)"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 9a478ee1d725e77b08063035969e6ee7df14363b. --- .../service/AfterPartyCommandService.kt | 15 ++-- .../service/AbsenceReasonQueryService.kt | 4 ++ .../application/service/RoleQueryService.kt | 3 +- .../request/UpdateMemberRoleRequest.kt | 5 +- .../service/GatheringQueryService.kt | 1 + .../service/access/MemberAccessService.kt | 3 +- .../service/role/CurrentCohortRoleResolver.kt | 8 +-- .../service/role/MemberRoleService.kt | 28 ++------ .../controller/MemberController.kt | 1 + .../role/CurrentCohortRoleResolverTest.kt | 17 ++--- .../absencereason/aggregate/AbsenceReason.kt | 3 +- .../core/domain/authorization/vo/RoleType.kt | 17 ++--- .../core/domain/member/aggregate/Member.kt | 5 +- .../repository/RoleRepository.kt | 1 + .../member/repository/MemberRepository.kt | 70 ++++++++----------- .../cohort/MemberCohortRepository.kt | 1 + .../repository/role/MemberRoleRepository.kt | 24 ++----- 17 files changed, 82 insertions(+), 124 deletions(-) diff --git a/application/src/main/kotlin/core/application/afterParty/application/service/AfterPartyCommandService.kt b/application/src/main/kotlin/core/application/afterParty/application/service/AfterPartyCommandService.kt index 448217f8c..708d10c15 100644 --- a/application/src/main/kotlin/core/application/afterParty/application/service/AfterPartyCommandService.kt +++ b/application/src/main/kotlin/core/application/afterParty/application/service/AfterPartyCommandService.kt @@ -331,14 +331,13 @@ class AfterPartyCommandService( return "${latestCohort.value}기 ${roleLabel(roleType)}" } - private fun roleLabel(roleType: RoleType): String = - when (roleType) { - RoleType.Organizer -> "운영진" - RoleType.Deeper -> "디퍼" - RoleType.Core -> "코어" - RoleType.Master -> "마스터" - RoleType.Guest -> "게스트" - } + private fun roleLabel(roleType: RoleType): String = when (roleType) { + RoleType.Organizer -> "운영진" + RoleType.Deeper -> "디퍼" + RoleType.Core -> "코어" + RoleType.Master -> "마스터" + RoleType.Guest -> "게스트" + } private fun legacyAuthorityIdForInviteTag(roleType: RoleType): Long? = when (roleType) { diff --git a/application/src/main/kotlin/core/application/attendance/application/service/AbsenceReasonQueryService.kt b/application/src/main/kotlin/core/application/attendance/application/service/AbsenceReasonQueryService.kt index f2a8b1704..7cbe6522a 100644 --- a/application/src/main/kotlin/core/application/attendance/application/service/AbsenceReasonQueryService.kt +++ b/application/src/main/kotlin/core/application/attendance/application/service/AbsenceReasonQueryService.kt @@ -4,6 +4,7 @@ import core.application.attendance.presentation.response.MyAbsenceReasonResponse import core.application.attendance.presentation.response.SessionAbsenceReasonItem import core.application.attendance.presentation.response.SessionAbsenceReasonsResponse import core.application.common.converter.TimeMapper.instantToLocalDateTime +import core.application.common.converter.TimeMapper.localDateTimeToInstant import core.domain.absencereason.aggregate.AbsenceReason import core.domain.absencereason.port.outbound.AbsenceReasonPersistencePort import core.domain.member.port.inbound.MemberQueryUseCase @@ -11,6 +12,8 @@ import core.domain.member.vo.MemberId import core.domain.session.vo.SessionId import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import java.time.Instant +import java.time.LocalDateTime import java.time.ZoneId @Service @@ -72,4 +75,5 @@ class AbsenceReasonQueryService( return SessionAbsenceReasonsResponse(items) } + } diff --git a/application/src/main/kotlin/core/application/authorization/application/service/RoleQueryService.kt b/application/src/main/kotlin/core/application/authorization/application/service/RoleQueryService.kt index f9271c64e..b47027db6 100644 --- a/application/src/main/kotlin/core/application/authorization/application/service/RoleQueryService.kt +++ b/application/src/main/kotlin/core/application/authorization/application/service/RoleQueryService.kt @@ -18,8 +18,7 @@ class RoleQueryService( override fun getAllRoles(): List = getRolesByCohort("") override fun getRolesByCohort(cohort: String): List { - val canonicalNames = - setOf(RoleType.Core.code, RoleType.Organizer.code, RoleType.Deeper.code, RoleType.Guest.code) + val canonicalNames = setOf(RoleType.Core.code, RoleType.Organizer.code, RoleType.Deeper.code, RoleType.Guest.code) return rolePersistencePort.findAll().filter { it.name in canonicalNames } } diff --git a/application/src/main/kotlin/core/application/authorization/presentation/request/UpdateMemberRoleRequest.kt b/application/src/main/kotlin/core/application/authorization/presentation/request/UpdateMemberRoleRequest.kt index 8e2223523..32877f023 100644 --- a/application/src/main/kotlin/core/application/authorization/presentation/request/UpdateMemberRoleRequest.kt +++ b/application/src/main/kotlin/core/application/authorization/presentation/request/UpdateMemberRoleRequest.kt @@ -7,10 +7,7 @@ import jakarta.validation.constraints.Pattern data class UpdateMemberRoleRequest( @field:NotBlank - @field:Pattern( - regexp = "MASTER|CORE|ORGANIZER|DEEPER|GUEST", - message = "roleType must be one of MASTER, CORE, ORGANIZER, DEEPER, GUEST", - ) + @field:Pattern(regexp = "MASTER|CORE|ORGANIZER|DEEPER|GUEST", message = "roleType must be one of MASTER, CORE, ORGANIZER, DEEPER, GUEST") @field:Schema(description = "역할 타입", example = "ORGANIZER", requiredMode = Schema.RequiredMode.REQUIRED) val roleType: String, @field:NotNull diff --git a/application/src/main/kotlin/core/application/gathering/application/service/GatheringQueryService.kt b/application/src/main/kotlin/core/application/gathering/application/service/GatheringQueryService.kt index 15d4936e2..126b0ec0f 100644 --- a/application/src/main/kotlin/core/application/gathering/application/service/GatheringQueryService.kt +++ b/application/src/main/kotlin/core/application/gathering/application/service/GatheringQueryService.kt @@ -19,6 +19,7 @@ import core.domain.gathering.port.outbound.query.GatheringMemberIsJoinQueryModel import core.domain.gathering.port.outbound.query.SubmittedParticipantGathering import core.domain.gathering.vo.GatheringId import core.domain.member.port.inbound.MemberQueryUseCase +import core.domain.member.aggregate.Member import core.domain.member.vo.MemberId import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional diff --git a/application/src/main/kotlin/core/application/member/application/service/access/MemberAccessService.kt b/application/src/main/kotlin/core/application/member/application/service/access/MemberAccessService.kt index 184044d98..d9c7c39bb 100644 --- a/application/src/main/kotlin/core/application/member/application/service/access/MemberAccessService.kt +++ b/application/src/main/kotlin/core/application/member/application/service/access/MemberAccessService.kt @@ -13,7 +13,8 @@ class MemberAccessService( ) { fun isAdmin(memberId: MemberId): Boolean = getRoleType(memberId) == RoleType.Organizer - fun getRoleType(memberId: MemberId): RoleType = currentCohortRoleResolver.findPrimaryRoleTypeForMember(memberId) + fun getRoleType(memberId: MemberId): RoleType = + currentCohortRoleResolver.findPrimaryRoleTypeForMember(memberId) fun getIsAdminByMemberIds(memberIds: List): Map = memberIds.associateWith { memberId -> getRoleType(memberId) == RoleType.Organizer } diff --git a/application/src/main/kotlin/core/application/member/application/service/role/CurrentCohortRoleResolver.kt b/application/src/main/kotlin/core/application/member/application/service/role/CurrentCohortRoleResolver.kt index 62955bc06..5f3dfd7cd 100644 --- a/application/src/main/kotlin/core/application/member/application/service/role/CurrentCohortRoleResolver.kt +++ b/application/src/main/kotlin/core/application/member/application/service/role/CurrentCohortRoleResolver.kt @@ -30,9 +30,8 @@ class CurrentCohortRoleResolver( memberId: MemberId, roleNames: List, ): String? { - val assignments = - memberRolePersistencePort.findActiveRoleAssignmentsByMemberId(memberId.value) - .filter { it.roleName in roleNames } + val assignments = memberRolePersistencePort.findActiveRoleAssignmentsByMemberId(memberId.value) + .filter { it.roleName in roleNames } return selectRepresentativeRole(assignments, buildContext(memberId)) } @@ -95,7 +94,6 @@ class CurrentCohortRoleResolver( ) companion object { - private val ROLE_PRIORITY = - listOf(RoleType.Master, RoleType.Core, RoleType.Organizer, RoleType.Deeper, RoleType.Guest) + private val ROLE_PRIORITY = listOf(RoleType.Master, RoleType.Core, RoleType.Organizer, RoleType.Deeper, RoleType.Guest) } } diff --git a/application/src/main/kotlin/core/application/member/application/service/role/MemberRoleService.kt b/application/src/main/kotlin/core/application/member/application/service/role/MemberRoleService.kt index 6be916a2a..6525bdd4c 100644 --- a/application/src/main/kotlin/core/application/member/application/service/role/MemberRoleService.kt +++ b/application/src/main/kotlin/core/application/member/application/service/role/MemberRoleService.kt @@ -33,11 +33,7 @@ class MemberRoleService( ) } - fun assignRole( - memberId: MemberId, - roleType: RoleType, - cohortId: CohortId? = null, - ) { + fun assignRole(memberId: MemberId, roleType: RoleType, cohortId: CohortId? = null) { val roleId = roleQueryUseCase.findIdByName(roleType.code) memberRolePersistencePort.save( MemberRole( @@ -49,18 +45,12 @@ class MemberRoleService( ) } - fun ensureRoleAssigned( - memberId: MemberId, - roleType: RoleType, - ) { + fun ensureRoleAssigned(memberId: MemberId, roleType: RoleType) { val roles = memberRolePersistencePort.findRoleNamesByMemberId(memberId.value) if (roles.none { it == roleType.code }) assignRole(memberId, roleType) } - fun revokeRole( - memberId: MemberId, - roleType: RoleType, - ) { + fun revokeRole(memberId: MemberId, roleType: RoleType) { val roleId = roleQueryUseCase.findIdByName(roleType.code) memberRolePersistencePort.softDeleteByMemberIdAndRoleId(memberId.value, roleId) } @@ -69,11 +59,7 @@ class MemberRoleService( if (memberRolePersistencePort.findRoleNamesByMemberId(memberId.value).isEmpty()) assignGuestRole(memberId) } - fun replaceWithSingleRoleByType( - memberId: MemberId, - roleType: RoleType, - cohortId: CohortId? = null, - ) { + fun replaceWithSingleRoleByType(memberId: MemberId, roleType: RoleType, cohortId: CohortId? = null) { val roleId = roleQueryUseCase.findIdByName(roleType.code) memberRolePersistencePort.upsertSingleActiveRole( memberId = memberId.value, @@ -82,11 +68,7 @@ class MemberRoleService( ) } - fun replaceCohortRole( - memberId: MemberId, - roleType: RoleType, - cohortId: CohortId, - ) { + fun replaceCohortRole(memberId: MemberId, roleType: RoleType, cohortId: CohortId) { val roleId = roleQueryUseCase.findIdByName(roleType.code) memberRolePersistencePort.replaceCohortRole(memberId.value, roleId, cohortId.value) } diff --git a/application/src/main/kotlin/core/application/member/presentation/controller/MemberController.kt b/application/src/main/kotlin/core/application/member/presentation/controller/MemberController.kt index d33118155..d42d7abd0 100644 --- a/application/src/main/kotlin/core/application/member/presentation/controller/MemberController.kt +++ b/application/src/main/kotlin/core/application/member/presentation/controller/MemberController.kt @@ -168,6 +168,7 @@ class MemberController( return CustomResponse.ok() } + @PostMapping("/authority/cohort/init/{cohortId}/{memberId}") override fun initMemberCohort( @PathVariable memberId: MemberId, diff --git a/application/src/test/kotlin/core/application/member/application/service/role/CurrentCohortRoleResolverTest.kt b/application/src/test/kotlin/core/application/member/application/service/role/CurrentCohortRoleResolverTest.kt index bd4fbbbac..af1c5adff 100644 --- a/application/src/test/kotlin/core/application/member/application/service/role/CurrentCohortRoleResolverTest.kt +++ b/application/src/test/kotlin/core/application/member/application/service/role/CurrentCohortRoleResolverTest.kt @@ -8,12 +8,11 @@ import org.junit.jupiter.api.Test import org.mockito.Mockito.mock class CurrentCohortRoleResolverTest { - private val resolver = - CurrentCohortRoleResolver( - cohortQueryService = mock(core.application.cohort.application.service.CohortQueryService::class.java), - memberCohortPersistencePort = mock(core.domain.member.port.outbound.MemberCohortPersistencePort::class.java), - memberRolePersistencePort = mock(core.domain.member.port.outbound.MemberRolePersistencePort::class.java), - ) + private val resolver = CurrentCohortRoleResolver( + cohortQueryService = mock(core.application.cohort.application.service.CohortQueryService::class.java), + memberCohortPersistencePort = mock(core.domain.member.port.outbound.MemberCohortPersistencePort::class.java), + memberRolePersistencePort = mock(core.domain.member.port.outbound.MemberRolePersistencePort::class.java), + ) @Test fun guestIsAlwaysEffective() { @@ -69,8 +68,6 @@ class CurrentCohortRoleResolverTest { assertEquals(RoleType.Guest, resolver.findPrimaryRoleType(listOf(MemberRoleAssignment("DEEPER", CohortId(17L))), context)) } - private fun context( - activeCohortId: Long?, - memberCohortIds: Set, - ) = CurrentCohortRoleResolver.CohortRoleContext(activeCohortId, memberCohortIds) + private fun context(activeCohortId: Long?, memberCohortIds: Set) = + CurrentCohortRoleResolver.CohortRoleContext(activeCohortId, memberCohortIds) } diff --git a/domain/src/main/kotlin/core/domain/absencereason/aggregate/AbsenceReason.kt b/domain/src/main/kotlin/core/domain/absencereason/aggregate/AbsenceReason.kt index d8f667e8c..b5f3a705b 100644 --- a/domain/src/main/kotlin/core/domain/absencereason/aggregate/AbsenceReason.kt +++ b/domain/src/main/kotlin/core/domain/absencereason/aggregate/AbsenceReason.kt @@ -68,7 +68,8 @@ class AbsenceReason( return result } - override fun toString(): String = "AbsenceReason(id=$id, sessionId=$sessionId, memberId=$memberId, status=$status)" + override fun toString(): String = + "AbsenceReason(id=$id, sessionId=$sessionId, memberId=$memberId, status=$status)" companion object { fun create(command: AbsenceReportCreateCommand): AbsenceReason = diff --git a/domain/src/main/kotlin/core/domain/authorization/vo/RoleType.kt b/domain/src/main/kotlin/core/domain/authorization/vo/RoleType.kt index 1b64920f7..6a5d84353 100644 --- a/domain/src/main/kotlin/core/domain/authorization/vo/RoleType.kt +++ b/domain/src/main/kotlin/core/domain/authorization/vo/RoleType.kt @@ -4,13 +4,9 @@ sealed class RoleType( val code: String, ) { data object Master : RoleType("MASTER") - data object Core : RoleType("CORE") - data object Organizer : RoleType("ORGANIZER") - data object Deeper : RoleType("DEEPER") - data object Guest : RoleType("GUEST") companion object { @@ -25,13 +21,12 @@ sealed class RoleType( if (raw.isNullOrBlank()) return Guest fromCode(raw).takeIf { it != Guest }?.let { return it } val tokens = raw.lowercase().split(Regex("[^가-힣a-z]+")).filter { it.isNotBlank() } - val aliasMap = - mapOf( - "master" to Master, "마스터" to Master, - "core" to Core, "코어" to Core, - "organizer" to Organizer, "운영진" to Organizer, "운영" to Organizer, "관리자" to Organizer, - "deeper" to Deeper, "디퍼" to Deeper, - ) + val aliasMap = mapOf( + "master" to Master, "마스터" to Master, + "core" to Core, "코어" to Core, + "organizer" to Organizer, "운영진" to Organizer, "운영" to Organizer, "관리자" to Organizer, + "deeper" to Deeper, "디퍼" to Deeper, + ) return tokens.firstNotNullOfOrNull { aliasMap[it] } ?: Guest } } diff --git a/domain/src/main/kotlin/core/domain/member/aggregate/Member.kt b/domain/src/main/kotlin/core/domain/member/aggregate/Member.kt index d84feb72c..8d736f099 100644 --- a/domain/src/main/kotlin/core/domain/member/aggregate/Member.kt +++ b/domain/src/main/kotlin/core/domain/member/aggregate/Member.kt @@ -1,8 +1,8 @@ package core.domain.member.aggregate -import core.domain.cohort.vo.CohortId import core.domain.member.enums.MemberPart import core.domain.member.enums.MemberStatus +import core.domain.cohort.vo.CohortId import core.domain.member.vo.MemberId import java.time.Instant @@ -85,7 +85,8 @@ class Member( updatedAt = Instant.now() } - fun latestMemberCohort(): MemberCohort? = memberCohorts.maxByOrNull { it.id?.value ?: Long.MIN_VALUE } + fun latestMemberCohort(): MemberCohort? = + memberCohorts.maxByOrNull { it.id?.value ?: Long.MIN_VALUE } fun latestCohortId(): CohortId? = latestMemberCohort()?.cohortId diff --git a/persistence/src/main/kotlin/core/persistence/authorization/repository/RoleRepository.kt b/persistence/src/main/kotlin/core/persistence/authorization/repository/RoleRepository.kt index 3470c56ca..8298cc9e4 100644 --- a/persistence/src/main/kotlin/core/persistence/authorization/repository/RoleRepository.kt +++ b/persistence/src/main/kotlin/core/persistence/authorization/repository/RoleRepository.kt @@ -6,6 +6,7 @@ import core.domain.member.vo.MemberId import org.jooq.DSLContext import org.jooq.dsl.tables.references.MEMBERS import org.jooq.dsl.tables.references.MEMBER_OAUTH +import org.jooq.dsl.tables.references.MEMBER_PERMISSIONS import org.jooq.dsl.tables.references.MEMBER_ROLES import org.jooq.dsl.tables.references.PERMISSIONS import org.jooq.dsl.tables.references.ROLES diff --git a/persistence/src/main/kotlin/core/persistence/member/repository/MemberRepository.kt b/persistence/src/main/kotlin/core/persistence/member/repository/MemberRepository.kt index 49973a343..9468ec832 100644 --- a/persistence/src/main/kotlin/core/persistence/member/repository/MemberRepository.kt +++ b/persistence/src/main/kotlin/core/persistence/member/repository/MemberRepository.kt @@ -44,9 +44,10 @@ import org.jooq.dsl.tables.references.TEAMS import org.jooq.impl.DSL import org.jooq.impl.DSL.exists import org.jooq.impl.DSL.field +import org.jooq.impl.DSL.inline +import org.jooq.impl.DSL.noCondition import org.jooq.impl.DSL.max import org.jooq.impl.DSL.name -import org.jooq.impl.DSL.noCondition import org.jooq.impl.DSL.selectOne import org.jooq.impl.DSL.`when` import org.springframework.stereotype.Repository @@ -136,21 +137,21 @@ class MemberRepository( val latestMemberCohortIdField = latestMemberCohortsFieldId(latestMemberCohorts) dsl - .select(MEMBERS.MEMBER_ID) - .from(MEMBERS) - .join(latestMemberCohorts) - .on(latestMemberCohortMemberIdField.eq(MEMBERS.MEMBER_ID)) - .join(MEMBER_COHORTS) - .on(MEMBER_COHORTS.MEMBER_COHORT_ID.eq(latestMemberCohortIdField)) - .join(COHORTS) - .on(MEMBER_COHORTS.COHORT_ID.eq(COHORTS.COHORT_ID)) - .where(COHORTS.VALUE.eq(value)) - .and(MEMBERS.DELETED_AT.isNull) - .fetch(MEMBERS.MEMBER_ID) - .filterNotNull() - .map { - MemberId(it) - } + .select(MEMBERS.MEMBER_ID) + .from(MEMBERS) + .join(latestMemberCohorts) + .on(latestMemberCohortMemberIdField.eq(MEMBERS.MEMBER_ID)) + .join(MEMBER_COHORTS) + .on(MEMBER_COHORTS.MEMBER_COHORT_ID.eq(latestMemberCohortIdField)) + .join(COHORTS) + .on(MEMBER_COHORTS.COHORT_ID.eq(COHORTS.COHORT_ID)) + .where(COHORTS.VALUE.eq(value)) + .and(MEMBERS.DELETED_AT.isNull) + .fetch(MEMBERS.MEMBER_ID) + .filterNotNull() + .map { + MemberId(it) + } } override fun findAllByCohortId(cohortId: CohortId): List = @@ -160,17 +161,17 @@ class MemberRepository( val latestMemberCohortIdField = latestMemberCohortsFieldId(latestMemberCohorts) dsl - .select(MEMBERS.MEMBER_ID) - .from(MEMBERS) - .join(latestMemberCohorts) - .on(latestMemberCohortMemberIdField.eq(MEMBERS.MEMBER_ID)) - .join(MEMBER_COHORTS) - .on(MEMBER_COHORTS.MEMBER_COHORT_ID.eq(latestMemberCohortIdField)) - .where(MEMBER_COHORTS.COHORT_ID.eq(cohortId.value)) - .and(MEMBERS.DELETED_AT.isNull) - .fetch(MEMBERS.MEMBER_ID) - .filterNotNull() - .map { MemberId(it) } + .select(MEMBERS.MEMBER_ID) + .from(MEMBERS) + .join(latestMemberCohorts) + .on(latestMemberCohortMemberIdField.eq(MEMBERS.MEMBER_ID)) + .join(MEMBER_COHORTS) + .on(MEMBER_COHORTS.MEMBER_COHORT_ID.eq(latestMemberCohortIdField)) + .where(MEMBER_COHORTS.COHORT_ID.eq(cohortId.value)) + .and(MEMBERS.DELETED_AT.isNull) + .fetch(MEMBERS.MEMBER_ID) + .filterNotNull() + .map { MemberId(it) } } override fun findAllMemberIdsByCohortIdAndAuthorityId( @@ -206,12 +207,7 @@ class MemberRepository( .and(MEMBERS.DELETED_AT.isNull) .and(MEMBER_ROLES.DELETED_AT.isNull) .and(ROLES.NAME.eq(roleName)) - .and( - org.jooq.impl.DSL.field( - org.jooq.impl.DSL.name("member_roles", "cohort_id"), - Long::class.java, - ).eq(cohortId.value), - ) + .and(org.jooq.impl.DSL.field(org.jooq.impl.DSL.name("member_roles", "cohort_id"), Long::class.java).eq(cohortId.value)) .fetch(MEMBERS.MEMBER_ID) .filterNotNull() .map { MemberId(it) } @@ -270,12 +266,7 @@ class MemberRepository( .where(MEMBER_ROLES.MEMBER_ID.eq(MEMBERS.MEMBER_ID)) .and(MEMBER_ROLES.DELETED_AT.isNull) .and(ROLES.NAME.eq(RoleType.Organizer.code)) - .and( - org.jooq.impl.DSL.field( - org.jooq.impl.DSL.name("member_roles", "cohort_id"), - Long::class.java, - ).eq(latestCohortIdField), - ), + .and(org.jooq.impl.DSL.field(org.jooq.impl.DSL.name("member_roles", "cohort_id"), Long::class.java).eq(latestCohortIdField)), ).`as`("is_admin") val statusPriority = @@ -638,6 +629,7 @@ class MemberRepository( private fun latestMemberTeamsFieldMemberId(table: org.jooq.Table<*> = latestMemberTeams()) = table.field(MEMBER_TEAMS.MEMBER_ID)!! + companion object { private const val LEGACY_DEEPER_AUTHORITY_ID = 1L private const val LEGACY_ORGANIZER_AUTHORITY_ID = 2L diff --git a/persistence/src/main/kotlin/core/persistence/member/repository/cohort/MemberCohortRepository.kt b/persistence/src/main/kotlin/core/persistence/member/repository/cohort/MemberCohortRepository.kt index e18ae4120..913537e1a 100644 --- a/persistence/src/main/kotlin/core/persistence/member/repository/cohort/MemberCohortRepository.kt +++ b/persistence/src/main/kotlin/core/persistence/member/repository/cohort/MemberCohortRepository.kt @@ -50,3 +50,4 @@ class MemberCohortRepository( .fetch(MEMBER_COHORTS.COHORT_ID) .filterNotNull() } + diff --git a/persistence/src/main/kotlin/core/persistence/member/repository/role/MemberRoleRepository.kt b/persistence/src/main/kotlin/core/persistence/member/repository/role/MemberRoleRepository.kt index e591d66c0..abf35874b 100644 --- a/persistence/src/main/kotlin/core/persistence/member/repository/role/MemberRoleRepository.kt +++ b/persistence/src/main/kotlin/core/persistence/member/repository/role/MemberRoleRepository.kt @@ -5,10 +5,10 @@ import core.domain.member.aggregate.MemberRole import core.domain.member.port.outbound.MemberRolePersistencePort import core.domain.member.vo.MemberRoleAssignment import org.jooq.DSLContext -import org.jooq.dsl.tables.references.MEMBER_ROLES -import org.jooq.dsl.tables.references.ROLES import org.jooq.impl.DSL import org.jooq.impl.DSL.name +import org.jooq.dsl.tables.references.MEMBER_ROLES +import org.jooq.dsl.tables.references.ROLES import org.springframework.stereotype.Repository import java.time.LocalDateTime import java.time.ZoneId @@ -70,10 +70,7 @@ class MemberRoleRepository( .where(MEMBER_ROLES.MEMBER_ROLE_ID.eq(keptRoleId)) .execute() - softDeleteDuplicates( - activeRoles.mapNotNull { it[MEMBER_ROLES.MEMBER_ROLE_ID] }.filter { it != keptRoleId }, - now, - ) + softDeleteDuplicates(activeRoles.mapNotNull { it[MEMBER_ROLES.MEMBER_ROLE_ID] }.filter { it != keptRoleId }, now) } override fun replaceCohortRole( @@ -116,10 +113,7 @@ class MemberRoleRepository( .where(MEMBER_ROLES.MEMBER_ROLE_ID.eq(keptRoleId)) .execute() - softDeleteDuplicates( - activeRoles.mapNotNull { it[MEMBER_ROLES.MEMBER_ROLE_ID] }.filter { it != keptRoleId }, - now, - ) + softDeleteDuplicates(activeRoles.mapNotNull { it[MEMBER_ROLES.MEMBER_ROLE_ID] }.filter { it != keptRoleId }, now) } override fun softDeleteAllByMemberId(memberId: Long) { @@ -130,10 +124,7 @@ class MemberRoleRepository( .execute() } - override fun softDeleteByMemberIdAndRoleId( - memberId: Long, - roleId: Long, - ) { + override fun softDeleteByMemberIdAndRoleId(memberId: Long, roleId: Long) { dsl .update(MEMBER_ROLES) .set(MEMBER_ROLES.DELETED_AT, LocalDateTime.now(ZoneId.of(TIME_ZONE))) @@ -178,10 +169,7 @@ class MemberRoleRepository( }.groupBy({ it.first }, { it.second }) } - private fun softDeleteDuplicates( - roleIds: List, - now: LocalDateTime, - ) { + private fun softDeleteDuplicates(roleIds: List, now: LocalDateTime) { if (roleIds.isEmpty()) return dsl .update(MEMBER_ROLES) From ab962005ee1ce106d9ce8475f6198910a226f721 Mon Sep 17 00:00:00 2001 From: cowboysj Date: Wed, 16 Sep 2026 00:37:40 +0900 Subject: [PATCH 6/8] =?UTF-8?q?refactor=20:=20CORE=20=EB=B6=80=EC=97=AC=20?= =?UTF-8?q?=ED=97=88=EC=9A=A9,=20role=20=ED=91=9C=EC=8B=9C=20=EB=AC=B8?= =?UTF-8?q?=EC=9E=90=EC=97=B4,=20=EC=8A=B9=EC=9D=B8=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20append,=20SQL=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/service/RoleCommandService.kt | 4 +-- .../application/service/RoleQueryService.kt | 18 +++++++---- .../service/MemberCommandService.kt | 4 ++- .../service/role/MemberRoleService.kt | 14 +++++++++ .../2609142310_backup_member_roles.sql | 0 .../pending/2609142320_role_system_seed.sql | 0 .../pending/2609142330_schema_alignment.sql | 0 .../authorization/vo/RoleDisplayName.kt | 30 +++++++++++++++++++ 8 files changed, 62 insertions(+), 8 deletions(-) rename prod/pending/20260914_backup_member_roles.sql => db/pending/2609142310_backup_member_roles.sql (100%) rename prod/pending/20260914_role_system_seed.sql => db/pending/2609142320_role_system_seed.sql (100%) rename prod/pending/20260914_schema_alignment.sql => db/pending/2609142330_schema_alignment.sql (100%) create mode 100644 domain/src/main/kotlin/core/domain/authorization/vo/RoleDisplayName.kt diff --git a/application/src/main/kotlin/core/application/authorization/application/service/RoleCommandService.kt b/application/src/main/kotlin/core/application/authorization/application/service/RoleCommandService.kt index 00480f372..7e3c32651 100644 --- a/application/src/main/kotlin/core/application/authorization/application/service/RoleCommandService.kt +++ b/application/src/main/kotlin/core/application/authorization/application/service/RoleCommandService.kt @@ -21,8 +21,8 @@ class RoleCommandService( ) { memberQueryService.getMemberById(memberId) val roleType = RoleType.fromCode(request.roleType) - require(roleType == RoleType.Organizer || roleType == RoleType.Deeper) { - "roleType must be ORGANIZER or DEEPER" + require(roleType == RoleType.Core || roleType == RoleType.Organizer || roleType == RoleType.Deeper) { + "roleType must be CORE, ORGANIZER or DEEPER" } memberRoleService.replaceCohortRole(memberId, roleType, CohortId(request.cohortId)) } diff --git a/application/src/main/kotlin/core/application/authorization/application/service/RoleQueryService.kt b/application/src/main/kotlin/core/application/authorization/application/service/RoleQueryService.kt index b47027db6..f1ca33a5b 100644 --- a/application/src/main/kotlin/core/application/authorization/application/service/RoleQueryService.kt +++ b/application/src/main/kotlin/core/application/authorization/application/service/RoleQueryService.kt @@ -1,10 +1,13 @@ package core.application.authorization.application.service +import core.application.cohort.application.service.CohortQueryService import core.application.member.application.service.role.CurrentCohortRoleResolver import core.domain.authorization.aggregate.Role import core.domain.authorization.port.inbound.RoleQueryUseCase import core.domain.authorization.port.outbound.RolePersistencePort +import core.domain.authorization.vo.RoleDisplayName import core.domain.authorization.vo.RoleType +import core.domain.cohort.vo.CohortId import core.domain.member.port.outbound.MemberRolePersistencePort import core.domain.member.vo.MemberId import org.springframework.stereotype.Service @@ -14,6 +17,7 @@ class RoleQueryService( private val rolePersistencePort: RolePersistencePort, private val memberRolePersistencePort: MemberRolePersistencePort, private val currentCohortRoleResolver: CurrentCohortRoleResolver, + private val cohortQueryService: CohortQueryService, ) : RoleQueryUseCase { override fun getAllRoles(): List = getRolesByCohort("") @@ -23,12 +27,16 @@ class RoleQueryService( } override fun getRoleNamesByMemberId(memberId: MemberId): List = - memberRolePersistencePort.findRoleNamesByMemberId(memberId.value) + memberRolePersistencePort + .findActiveRoleAssignmentsByMemberId(memberId.value) + .map { assignment -> RoleDisplayName.of(assignment.roleName, resolveCohortValue(assignment.cohortId)) } + .distinct() - override fun getRoleNamesByMemberIds(memberIds: List): Map> { - val roleNamesByMemberId = memberRolePersistencePort.findRoleNamesByMemberIds(memberIds.map { it.value }) - return memberIds.associateWith { memberId -> roleNamesByMemberId[memberId.value].orEmpty() } - } + override fun getRoleNamesByMemberIds(memberIds: List): Map> = + memberIds.associateWith { memberId -> getRoleNamesByMemberId(memberId) } + + private fun resolveCohortValue(cohortId: CohortId?): Long? = + cohortId?.value?.let { runCatching { cohortQueryService.getCohort(cohortId).value.toLong() }.getOrNull() } override fun getRolesByExternalId(externalId: String): List = rolePersistencePort.findAllByMemberExternalId(externalId).ifEmpty { listOf(RoleType.Guest.code) } diff --git a/application/src/main/kotlin/core/application/member/application/service/MemberCommandService.kt b/application/src/main/kotlin/core/application/member/application/service/MemberCommandService.kt index 2ce43451f..b8823c98c 100644 --- a/application/src/main/kotlin/core/application/member/application/service/MemberCommandService.kt +++ b/application/src/main/kotlin/core/application/member/application/service/MemberCommandService.kt @@ -138,7 +138,9 @@ class MemberCommandService( fun activate(member: Member) { val memberId = requireNotNull(member.id) { "Activated member must have id" } - memberRoleService.replaceWithSingleRoleByType(memberId, RoleType.Deeper, cohortQueryUseCase.getActiveCohortId()) + // 승인 시 기존 이력을 유지하고 새 role 만 append (예: (DEEPER, 17) 유지 + (DEEPER, 18) 추가). + // 판정은 CurrentCohortRoleResolver 가 활성 기수 기준으로 필터링한다. + memberRoleService.ensureCohortRoleAssigned(memberId, RoleType.Deeper, cohortQueryUseCase.getActiveCohortId()) member.activate() val activatedMember = memberPersistencePort.save(member) initializeMemberDataForActiveMember(activatedMember) diff --git a/application/src/main/kotlin/core/application/member/application/service/role/MemberRoleService.kt b/application/src/main/kotlin/core/application/member/application/service/role/MemberRoleService.kt index 6525bdd4c..9d92d154e 100644 --- a/application/src/main/kotlin/core/application/member/application/service/role/MemberRoleService.kt +++ b/application/src/main/kotlin/core/application/member/application/service/role/MemberRoleService.kt @@ -50,6 +50,20 @@ class MemberRoleService( if (roles.none { it == roleType.code }) assignRole(memberId, roleType) } + /** + * (roleType, cohortId) 조합의 활성 role 이 없으면 새로 append 한다. + * 기존 다른 기수 role 은 soft delete 하지 않고 이력으로 유지한다. + * 예: 17기 디퍼로 활동 이력 있는 회원이 18기 승인 시 → (DEEPER, 17), (DEEPER, 18) 둘 다 존재. + * 판정은 CurrentCohortRoleResolver 가 활성 기수 기준으로 필터링한다. + */ + fun ensureCohortRoleAssigned(memberId: MemberId, roleType: RoleType, cohortId: CohortId) { + val alreadyAssigned = + memberRolePersistencePort + .findActiveRoleAssignmentsByMemberId(memberId.value) + .any { it.roleName == roleType.code && it.cohortId?.value == cohortId.value } + if (!alreadyAssigned) assignRole(memberId, roleType, cohortId) + } + fun revokeRole(memberId: MemberId, roleType: RoleType) { val roleId = roleQueryUseCase.findIdByName(roleType.code) memberRolePersistencePort.softDeleteByMemberIdAndRoleId(memberId.value, roleId) diff --git a/prod/pending/20260914_backup_member_roles.sql b/db/pending/2609142310_backup_member_roles.sql similarity index 100% rename from prod/pending/20260914_backup_member_roles.sql rename to db/pending/2609142310_backup_member_roles.sql diff --git a/prod/pending/20260914_role_system_seed.sql b/db/pending/2609142320_role_system_seed.sql similarity index 100% rename from prod/pending/20260914_role_system_seed.sql rename to db/pending/2609142320_role_system_seed.sql diff --git a/prod/pending/20260914_schema_alignment.sql b/db/pending/2609142330_schema_alignment.sql similarity index 100% rename from prod/pending/20260914_schema_alignment.sql rename to db/pending/2609142330_schema_alignment.sql diff --git a/domain/src/main/kotlin/core/domain/authorization/vo/RoleDisplayName.kt b/domain/src/main/kotlin/core/domain/authorization/vo/RoleDisplayName.kt new file mode 100644 index 000000000..a3eaa2715 --- /dev/null +++ b/domain/src/main/kotlin/core/domain/authorization/vo/RoleDisplayName.kt @@ -0,0 +1,30 @@ +package core.domain.authorization.vo + +/** + * canonical role name + cohort_id 조합을 사람이 읽기 좋은 표시 문자열로 변환한다. + * + * 규칙 (팀 컨벤션): + * - MASTER → "마스터" + * - CORE + cohort N → "${N-16} 코어" (예: cohort=17 → "1 코어", cohort=18 → "2 코어") + * - ORGANIZER + cohort N → "${N}기 운영진" + * - DEEPER + cohort N → "${N}기 디퍼" + * - GUEST → "게스트" + * - cohort 매칭 불가 → 원본 canonical name 그대로 (fallback) + */ +object RoleDisplayName { + private const val CORE_COHORT_OFFSET = 16L + + fun of( + roleName: String?, + cohortValue: Long?, + ): String { + val roleType = RoleType.from(roleName) + return when (roleType) { + RoleType.Master -> "마스터" + RoleType.Guest -> "게스트" + RoleType.Organizer -> cohortValue?.let { "${it}기 운영진" } ?: roleType.code + RoleType.Deeper -> cohortValue?.let { "${it}기 디퍼" } ?: roleType.code + RoleType.Core -> cohortValue?.let { "${it - CORE_COHORT_OFFSET} 코어" } ?: roleType.code + } + } +} From b5bb09f475c8c5beb4ac15fb115dd7a0a51aaaf7 Mon Sep 17 00:00:00 2001 From: cowboysj Date: Sat, 19 Sep 2026 10:59:31 +0900 Subject: [PATCH 7/8] =?UTF-8?q?fix=20:=20=EA=B2=B8=EC=A7=81=20=ED=9A=8C?= =?UTF-8?q?=EC=9B=90=20=ED=8C=90=EC=A0=95=20=EB=B2=84=EA=B7=B8=20=EB=B0=8F?= =?UTF-8?q?=20CORE=20=ED=91=9C=EC=8B=9C=20=ED=8F=AC=EB=A7=B7=20=ED=86=B5?= =?UTF-8?q?=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/role/CurrentCohortRoleResolver.kt | 4 ++- db/pending/2609142310_backup_member_roles.sql | 4 +-- db/pending/2609142320_role_system_seed.sql | 17 ++++++------ db/pending/2609142330_schema_alignment.sql | 26 ++++++++++++++----- .../authorization/vo/RoleDisplayName.kt | 6 ++--- 5 files changed, 36 insertions(+), 21 deletions(-) diff --git a/application/src/main/kotlin/core/application/member/application/service/role/CurrentCohortRoleResolver.kt b/application/src/main/kotlin/core/application/member/application/service/role/CurrentCohortRoleResolver.kt index 5f3dfd7cd..adff6a712 100644 --- a/application/src/main/kotlin/core/application/member/application/service/role/CurrentCohortRoleResolver.kt +++ b/application/src/main/kotlin/core/application/member/application/service/role/CurrentCohortRoleResolver.kt @@ -82,7 +82,9 @@ class CurrentCohortRoleResolver( RoleType.Master.code, RoleType.Guest.code -> true RoleType.Core.code, RoleType.Organizer.code, RoleType.Deeper.code -> { if (!isActiveMember) return false - assignment.cohortId?.value?.let { it in context.memberCohortIds } ?: isActiveMember + // role 의 cohortId 가 활성 기수와 정확히 일치하는 경우만 유효로 판정. + // (member_cohorts 에 다른 기수 참여 이력이 있어도 그 기수의 role 은 유효하지 않음) + assignment.cohortId?.value?.let { it == context.activeCohortId } ?: isActiveMember } else -> false } diff --git a/db/pending/2609142310_backup_member_roles.sql b/db/pending/2609142310_backup_member_roles.sql index 495866439..447f57fdb 100644 --- a/db/pending/2609142310_backup_member_roles.sql +++ b/db/pending/2609142310_backup_member_roles.sql @@ -1,10 +1,10 @@ -- ============================================================================= --- 2026-09-13 · Phase 1 안전장치: member_roles 레거시 백업 +-- 2026-09-14 · Phase 1 안전장치: member_roles 레거시 백업 -- ============================================================================= -- 목적: Phase 1 재매핑(role_id 교체)/Phase 2 cohort_id 채우기 실행 전, 원본을 -- 같은 테이블의 legacy_role_id / legacy_role_name 컬럼에 백업한다. -- 선행: 없음 (Phase 1 최초 스텝) --- 후행: 20260913_role_system_seed.sql +-- 후행: 2609142320_role_system_seed.sql START TRANSACTION; diff --git a/db/pending/2609142320_role_system_seed.sql b/db/pending/2609142320_role_system_seed.sql index ff958e766..fb33cba92 100644 --- a/db/pending/2609142320_role_system_seed.sql +++ b/db/pending/2609142320_role_system_seed.sql @@ -1,5 +1,5 @@ -- ============================================================================= --- 2026-09-13 · Phase 1 본작업: 역할·기수 시스템 시드 & 재매핑 +-- 2026-09-14 · Phase 1 본작업: 역할·기수 시스템 시드 & 재매핑 -- ============================================================================= -- 목적: 문서 §2-2 / §7 반영. 아래 5개 원본 스크립트를 하나로 합침. -- 1) cohorts.is_active/activated_at 컬럼 + 활성 기수 세팅 + cohort_id=0 특수 슬롯 @@ -7,8 +7,8 @@ -- 3) member_roles.role_id 를 "N기 역할" → canonical role 로 재매핑 -- 4) member_cohorts 중복 제거 + UNIQUE(member_id, cohort_id) -- 5) 구시스템 member_authorities / authorities 아카이빙 후 DROP --- 선행: 20260913_backup_member_roles.sql --- 후행: 20260913_schema_alignment.sql +-- 선행: 2609142310_backup_member_roles.sql +-- 후행: 2609142330_schema_alignment.sql -- 검증: 파일 하단 VERIFY 섹션 (읽기 전용) -- ----------------------------------------------------------------------------- @@ -152,8 +152,8 @@ COMMIT; -- ----------------------------------------------------------------------------- START TRANSACTION; -CREATE TABLE IF NOT EXISTS _archive_member_authorities_20260913 AS SELECT * FROM member_authorities; -CREATE TABLE IF NOT EXISTS _archive_authorities_20260913 AS SELECT * FROM authorities; +CREATE TABLE IF NOT EXISTS _archive_member_authorities AS SELECT * FROM member_authorities; +CREATE TABLE IF NOT EXISTS _archive_authorities AS SELECT * FROM authorities; DROP TABLE IF EXISTS member_authorities; DROP TABLE IF EXISTS authorities; @@ -186,7 +186,8 @@ GROUP BY r.name; -- 구시스템 제거 (기대: member_authorities/authorities 부재, _archive_* 존재) SHOW TABLES LIKE 'member_authorities'; SHOW TABLES LIKE 'authorities'; -SHOW TABLES LIKE '_archive_member_authorities_%'; +SHOW TABLES LIKE '_archive_member_authorities'; +SHOW TABLES LIKE '_archive_authorities'; -- member_cohorts 유니크 (기대: 중복 0건, uk_member_cohort 인덱스 존재) SELECT member_id, cohort_id, COUNT(*) AS dup @@ -201,8 +202,8 @@ SHOW INDEX FROM member_cohorts WHERE Key_name = 'uk_member_cohort'; -- START TRANSACTION; -- -- -- [5] 구시스템 복원 --- CREATE TABLE IF NOT EXISTS authorities AS SELECT * FROM _archive_authorities_20260913; --- CREATE TABLE IF NOT EXISTS member_authorities AS SELECT * FROM _archive_member_authorities_20260913; +-- CREATE TABLE IF NOT EXISTS authorities AS SELECT * FROM _archive_authorities; +-- CREATE TABLE IF NOT EXISTS member_authorities AS SELECT * FROM _archive_member_authorities; -- -- 필요 시 원본 인덱스/PK/FK 재적용 (아카이브 테이블에는 제약이 포함되지 않음) -- -- -- [4] member_cohorts UNIQUE 해제 diff --git a/db/pending/2609142330_schema_alignment.sql b/db/pending/2609142330_schema_alignment.sql index 791b06c33..8f42672ef 100644 --- a/db/pending/2609142330_schema_alignment.sql +++ b/db/pending/2609142330_schema_alignment.sql @@ -1,12 +1,12 @@ -- ============================================================================= --- 2026-09-13 · Phase 2 본작업: 스키마 정합화 +-- 2026-09-14 · Phase 2 본작업: 스키마 정합화 -- ============================================================================= -- 목적: 문서 §Phase 2 반영. 아래 4개 원본 스크립트를 하나로 합침. -- 1) member_roles.cohort_id 컬럼 + 인덱스 idx_member_role_cohort -- 2) legacy_role_name 에서 cohort_id 채우기 (ORGANIZER / DEEPER 만) -- 3) canonical role (MASTER/CORE/ORGANIZER/DEEPER/GUEST) 외 아카이빙 + 삭제 -- 4) member_cohorts.cohort_value 컬럼 제거 --- 선행: 20260913_role_system_seed.sql, 20260913_backup_member_roles.sql +-- 선행: 2609142320_role_system_seed.sql, 2609142310_backup_member_roles.sql -- 후행: 없음 (Phase 2 최종) -- 검증: 파일 하단 VERIFY 섹션 (읽기 전용) -- 주의: 스키마 삭제 포함 — 롤백 비용 큼. 실행 전 논리 백업 필수. @@ -34,11 +34,15 @@ COMMIT; -- ----------------------------------------------------------------------------- -- [2] legacy_role_name 에서 cohort_id 채우기 --- ORGANIZER / DEEPER 만 대상. MASTER / CORE / GUEST 는 NULL 유지 +-- ORGANIZER / DEEPER / CORE 대상. MASTER / GUEST 는 NULL 유지 -- (애플리케이션 레이어에서 cohort_id=0 특수 슬롯으로 보정) +-- legacy_role_name 예시: +-- "17기 운영진", "18기 디퍼" → 앞 숫자를 그대로 cohort_id 로 매핑 +-- "코어 1기", "코어 2기" → 팀 규칙에 따라 1→17, 2→18 매핑 (활동한 실제 기수) -- ----------------------------------------------------------------------------- START TRANSACTION; +-- ORGANIZER / DEEPER: "N기 ..." 형식에서 앞의 숫자를 그대로 매핑 UPDATE member_roles mr INNER JOIN cohorts c ON c.`value` = REGEXP_SUBSTR(mr.legacy_role_name, '^[0-9]+') INNER JOIN roles r ON mr.role_id = r.role_id @@ -46,6 +50,16 @@ SET mr.cohort_id = c.cohort_id WHERE mr.legacy_role_name REGEXP '^[0-9]+기' AND r.name IN ('ORGANIZER', 'DEEPER'); +-- CORE: "코어 N기" 를 활동 실기수로 매핑 (1→17, 2→18) +UPDATE member_roles mr +INNER JOIN roles r ON mr.role_id = r.role_id +SET mr.cohort_id = CASE mr.legacy_role_name + WHEN '코어 1기' THEN 17 + WHEN '코어 2기' THEN 18 +END +WHERE r.name = 'CORE' + AND mr.legacy_role_name IN ('코어 1기', '코어 2기'); + COMMIT; -- ----------------------------------------------------------------------------- @@ -53,7 +67,7 @@ COMMIT; -- ----------------------------------------------------------------------------- START TRANSACTION; -CREATE TABLE IF NOT EXISTS _archive_roles_20260913 AS SELECT * FROM roles; +CREATE TABLE IF NOT EXISTS _archive_roles AS SELECT * FROM roles; DELETE rp FROM role_permissions rp INNER JOIN roles r ON rp.role_id = r.role_id @@ -80,7 +94,7 @@ COMMIT; -- ============================================================================= -- VERIFY (읽기 전용) — 문서 §7.5 -- ============================================================================= --- cohort_id 채움 (기대: ORGANIZER/DEEPER 는 null_cohort=0, MASTER/CORE/GUEST 는 NULL 다수) +-- cohort_id 채움 (기대: ORGANIZER/DEEPER/CORE 는 null_cohort=0, MASTER/GUEST 는 NULL 다수) SELECT r.name, COUNT(*) AS total, COUNT(mr.cohort_id) AS with_cohort, @@ -108,7 +122,7 @@ SHOW COLUMNS FROM member_cohorts LIKE 'cohort_value'; -- -- -- [3] 기수 문자열 role 복원 (아카이브에서 되돌리기) -- INSERT INTO roles (role_id, name) --- SELECT role_id, name FROM _archive_roles_20260913 +-- SELECT role_id, name FROM _archive_roles -- WHERE name NOT IN ('MASTER','CORE','ORGANIZER','DEEPER','GUEST') -- ON DUPLICATE KEY UPDATE name = VALUES(name); -- -- role_permissions 는 별도 스냅샷이 없으면 재구성 불가. diff --git a/domain/src/main/kotlin/core/domain/authorization/vo/RoleDisplayName.kt b/domain/src/main/kotlin/core/domain/authorization/vo/RoleDisplayName.kt index a3eaa2715..bd9262e38 100644 --- a/domain/src/main/kotlin/core/domain/authorization/vo/RoleDisplayName.kt +++ b/domain/src/main/kotlin/core/domain/authorization/vo/RoleDisplayName.kt @@ -5,15 +5,13 @@ package core.domain.authorization.vo * * 규칙 (팀 컨벤션): * - MASTER → "마스터" - * - CORE + cohort N → "${N-16} 코어" (예: cohort=17 → "1 코어", cohort=18 → "2 코어") + * - CORE + cohort N → "${N}기 코어" * - ORGANIZER + cohort N → "${N}기 운영진" * - DEEPER + cohort N → "${N}기 디퍼" * - GUEST → "게스트" * - cohort 매칭 불가 → 원본 canonical name 그대로 (fallback) */ object RoleDisplayName { - private const val CORE_COHORT_OFFSET = 16L - fun of( roleName: String?, cohortValue: Long?, @@ -24,7 +22,7 @@ object RoleDisplayName { RoleType.Guest -> "게스트" RoleType.Organizer -> cohortValue?.let { "${it}기 운영진" } ?: roleType.code RoleType.Deeper -> cohortValue?.let { "${it}기 디퍼" } ?: roleType.code - RoleType.Core -> cohortValue?.let { "${it - CORE_COHORT_OFFSET} 코어" } ?: roleType.code + RoleType.Core -> cohortValue?.let { "${it}기 코어" } ?: roleType.code } } } From d4b73a1aa8a5580aaf86986d7d07780a3e42c8ad Mon Sep 17 00:00:00 2001 From: cowboysj Date: Sat, 19 Sep 2026 10:59:47 +0900 Subject: [PATCH 8/8] =?UTF-8?q?chore=20:=20db/release=20=EB=94=94=EB=A0=89?= =?UTF-8?q?=ED=84=B0=EB=A6=AC=20=EA=B5=AC=EC=A1=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- db/release/2609141200/.gitkeep | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 db/release/2609141200/.gitkeep diff --git a/db/release/2609141200/.gitkeep b/db/release/2609141200/.gitkeep new file mode 100644 index 000000000..e69de29bb