Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
74aba15
[feat] #121 ChangeType enum 이름 변경
saokiritoni Sep 4, 2025
1aad5ff
[docs] #121 컨트롤러 주석 추가
saokiritoni Sep 4, 2025
5a37ec2
[feat] #121 사용 변경 신청 항목값 추가
saokiritoni Sep 4, 2025
90fe856
[feat] #121 사용 변경 신청 추가된 항목값에 대한 서비스 구현
saokiritoni Sep 4, 2025
e5c24fd
[feat] #121 관리자 사용 변경 신청 승인 서비스 구현
saokiritoni Sep 4, 2025
3c4f78d
[feat] #121 엔티티에 필요한 비즈니스 메서드 구현
saokiritoni Sep 4, 2025
8afd72b
[docs] #112 주석 추가
saokiritoni Sep 4, 2025
eb88373
[docs] #112 API 문서 작성 및 validation 추가
saokiritoni Sep 4, 2025
20c9846
[refactor] #112 관리자 사용 신청과 변경 컨트롤러 분리
saokiritoni Sep 4, 2025
b743cbb
[refactor] #112 deny없이 reject로 통일
saokiritoni Sep 4, 2025
63daf48
[refactor] #112 관리자 사용 신청과 변경 컨트롤러 분리
saokiritoni Sep 4, 2025
b90d53b
[docs] #112 문서 번호 수정
saokiritoni Sep 4, 2025
5f3434b
[docs] #112 짜잘한 멘트 수정
saokiritoni Sep 4, 2025
ecb2d08
[feat] #112 그룹 생성 API 연결
saokiritoni Sep 6, 2025
293ef98
[feat] #112 그룹 Id 할당 메서드 추가
saokiritoni Sep 6, 2025
3cbfaaf
[feat] #112 그룹 생성 컨트롤러, DTO 수정
saokiritoni Sep 6, 2025
67f9b3e
[feat] #112 WebClientConfig 네이밍 통일
saokiritoni Sep 6, 2025
c062df9
[feat] #112 그룹 관련 ErrorCode 추가
saokiritoni Sep 6, 2025
40e16d6
[fix] #112 내부 DTO 사용하도록 수정
saokiritoni Sep 8, 2025
2268fbd
[fix] #112 ErrorCode 추가
saokiritoni Sep 8, 2025
1a122d0
[fix] #112 ubuntuUsername 필수 필드 아니므로 annotation 제거 및 swagger 표시
saokiritoni Sep 8, 2025
4a33e7c
[refactor] #112 import문 최적화
saokiritoni Sep 8, 2025
a94a992
[docs] #112 swagger 문서 수정
saokiritoni Sep 8, 2025
31f04ff
Merge pull request #125 from CSID-DGU/feat/#112-ChangeRequestDetails
saokiritoni Sep 8, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import DGU_AI_LAB.admin_be.domain.dashboard.controller.docs.DashBoardApi;
import DGU_AI_LAB.admin_be.domain.dashboard.service.DashboardService;
import DGU_AI_LAB.admin_be.domain.requests.dto.response.ChangeRequestResponseDTO;
import DGU_AI_LAB.admin_be.domain.requests.dto.response.UserServerResponseDTO;
import DGU_AI_LAB.admin_be.domain.requests.entity.Status;
import DGU_AI_LAB.admin_be.global.auth.CustomUserDetails;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@
import DGU_AI_LAB.admin_be.domain.groups.dto.request.CreateGroupRequestDTO;
import DGU_AI_LAB.admin_be.domain.groups.dto.response.GroupResponseDTO;
import DGU_AI_LAB.admin_be.domain.groups.service.GroupService;
import DGU_AI_LAB.admin_be.global.auth.CustomUserDetails;
import DGU_AI_LAB.admin_be.global.common.SuccessResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;

import java.util.List;
Expand Down Expand Up @@ -37,9 +39,12 @@ public ResponseEntity<SuccessResponse<?>> getGroups() {
* POST /api/groups
*/
@PostMapping
public ResponseEntity<SuccessResponse<?>> createGroup(@RequestBody @Valid CreateGroupRequestDTO dto) {
log.info("[createGroup] 새로운 그룹 생성 요청 접수: {}", dto.groupName());
GroupResponseDTO response = groupService.createGroup(dto);
public ResponseEntity<SuccessResponse<?>> createGroup(
@RequestBody @Valid CreateGroupRequestDTO dto,
@AuthenticationPrincipal CustomUserDetails principal
) {
log.info("[createGroup] 새로운 그룹 생성 요청 접수: groupName={}, ubuntuUsername={}", dto.groupName(), dto.ubuntuUsername());
GroupResponseDTO response = groupService.createGroup(dto, principal.getUserId());
return SuccessResponse.created(response);
}
}
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
package DGU_AI_LAB.admin_be.domain.groups.controller.docs;

import DGU_AI_LAB.admin_be.domain.groups.dto.request.CreateGroupRequestDTO;
import DGU_AI_LAB.admin_be.global.auth.CustomUserDetails;
import DGU_AI_LAB.admin_be.global.common.SuccessResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;

@Tag(name = "2. 그룹 API", description = "사용자용 그룹 목록 조회, 그룹 생성 API")
@Tag(name = "2. 사용자 그룹 API", description = "사용자용 우분투 그룹 관련 API")
public interface GroupApi {

@Operation(summary = "모든 그룹 목록 조회", description = "시스템에 등록된 모든 그룹의 정보를 조회합니다. 사용 신청 단계에서 이 목록을 조회하고, 그룹을 선택할 수 있습니다.")
Expand All @@ -21,12 +26,18 @@ public interface GroupApi {
@GetMapping
ResponseEntity<SuccessResponse<?>> getGroups();

@Operation(summary = "새로운 그룹 생성", description = "새로운 그룹을 생성하고 DB에 저장합니다.")
@Operation(summary = "새로운 그룹 생성", description = "새로운 그룹을 생성하고 MySQL DB에 저장합니다. 이후 Config Server에 그룹 생성 API를 호출합니다. 그룹명과 사용자 이름은 필수값이지만, 사용자 이름은 생략 가능하며 이 경우 멤버 없는 그룹이 생성됩니다. " +
"사용 신청을 아직 생성하지 않았지만, 그룹을 먼저 생성해야 하는 경우를 고려했습니다.")
@ApiResponses(value = {
@ApiResponse(responseCode = "201", description = "생성 성공"),
@ApiResponse(responseCode = "400", description = "잘못된 요청 (필수 필드 누락 등)"),
@ApiResponse(responseCode = "409", description = "중복된 GID를 가진 그룹이 이미 존재함")
@ApiResponse(responseCode = "400", description = "잘못된 요청: 필수 필드 누락(groupName) 또는 잘못된 형식"),
@ApiResponse(responseCode = "403", description = "접근 금지: 요청한 ubuntuUsername이 로그인한 사용자와 일치하지 않음"),
@ApiResponse(responseCode = "409", description = "데이터 충돌: 동일한 그룹명이 이미 존재함"),
@ApiResponse(responseCode = "500", description = "서버 오류: GID 할당 실패 또는 외부 API 호출 실패")
})
@PostMapping
ResponseEntity<SuccessResponse<?>> createGroup(@RequestBody @Valid CreateGroupRequestDTO dto);
ResponseEntity<SuccessResponse<?>> createGroup(
@RequestBody @Valid CreateGroupRequestDTO dto,
@AuthenticationPrincipal @Parameter(hidden = true) CustomUserDetails principal
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,17 @@

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import lombok.Builder;

@Schema(description = "1. 그룹 생성 요청 DTO")
@Schema(description = "그룹 생성 요청 DTO")
@Builder
public record CreateGroupRequestDTO(
@Schema(description = "할당할 우분투 GID (Group ID)", example = "1001")
@NotNull @Positive
Long ubuntuGid,

@Schema(description = "생성할 그룹명", example = "developers")
@NotBlank
String groupName
@NotBlank(message = "그룹명은 필수입니다.")
String groupName,

@Schema(description = "그룹에 추가할 우분투 사용자 이름", example = "user1", required = false)
String ubuntuUsername
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@
import org.springframework.stereotype.Repository;

import java.util.List;
import java.util.Optional;
import java.util.Set;

@Repository
public interface GroupRepository extends JpaRepository<Group, Long> {
List<Group> findAllByUbuntuGidIn(Set<Long> ubuntuGids);
boolean existsByUbuntuGid(Long ubuntuGid);
Optional<Group> findByUbuntuGid(Long ubuntuGid);
boolean existsByGroupName(String groupName);
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,31 +4,44 @@
import DGU_AI_LAB.admin_be.domain.groups.dto.response.GroupResponseDTO;
import DGU_AI_LAB.admin_be.domain.groups.entity.Group;
import DGU_AI_LAB.admin_be.domain.groups.repository.GroupRepository;
import DGU_AI_LAB.admin_be.domain.requests.repository.RequestRepository;
import DGU_AI_LAB.admin_be.domain.usedIds.entity.UsedId;
import DGU_AI_LAB.admin_be.domain.usedIds.repository.UsedIdRepository;
import DGU_AI_LAB.admin_be.domain.usedIds.service.IdAllocationService;
import DGU_AI_LAB.admin_be.error.ErrorCode;
import DGU_AI_LAB.admin_be.error.exception.BusinessException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientResponseException;

import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;

@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Transactional
public class GroupService {

private final GroupRepository groupRepository;
private final UsedIdRepository usedIdRepository;
private final RequestRepository requestRepository;
private final IdAllocationService idAllocationService;
private final @Qualifier("configWebClient") WebClient groupCreationWebClient;

/**
* 모든 그룹 정보를 조회하는 API
* GET /api/groups
*/
@Transactional(readOnly = true)
public List<GroupResponseDTO> getAllGroups() {
log.info("[getAllGroups] 모든 그룹 정보 조회 시작");
var groups = groupRepository.findAll();
Expand All @@ -51,36 +64,101 @@ public List<GroupResponseDTO> getAllGroups() {
* POST /api/groups
*/
@Transactional
public GroupResponseDTO createGroup(CreateGroupRequestDTO dto) {
log.info("[createGroup] 그룹 생성 요청 시작: groupName={}, ubuntuGid={}", dto.groupName(), dto.ubuntuGid());

// 1. 요청받은 GID로 UsedId가 이미 존재하는지 확인하고, 없으면 생성
Optional<UsedId> existingUsedId = usedIdRepository.findById(dto.ubuntuGid());
UsedId usedId;
if (existingUsedId.isPresent()) {
log.warn("[createGroup] 중복된 GID가 UsedId에 이미 존재합니다: {}", dto.ubuntuGid());
usedId = existingUsedId.get();
} else {
usedId = usedIdRepository.saveAndFlush(UsedId.builder().idValue(dto.ubuntuGid()).build());
log.info("[createGroup] UsedId에 GID {} 할당 완료", dto.ubuntuGid());
public GroupResponseDTO createGroup(CreateGroupRequestDTO dto, Long userId) {

log.info("[createGroup] 그룹 생성 요청 시작: groupName={}, ubuntuUsername={}", dto.groupName(), dto.ubuntuUsername());

// 1. ubuntuUsername이 제공된 경우에만 유효성 검사 (필수 X)
if (StringUtils.hasText(dto.ubuntuUsername())) {
if (!requestRepository.existsByUbuntuUsernameAndUser_UserId(dto.ubuntuUsername(), userId)) {
throw new BusinessException(ErrorCode.FORBIDDEN_REQUEST);
}
}

// 2. 해당 GID의 그룹이 이미 존재하는지 확인
if (groupRepository.existsByUbuntuGid(dto.ubuntuGid())) {
log.warn("[createGroup] 중복된 GID를 가진 그룹이 이미 존재합니다: {}", dto.ubuntuGid());
throw new BusinessException(ErrorCode.DUPLICATE_GROUP_ID);
// 2. DB에서 그룹명 중복을 먼저 확인합니다.
if (groupRepository.existsByGroupName(dto.groupName())) {
log.warn("[createGroup] DB에 이미 존재하는 그룹명입니다: {}", dto.groupName());
throw new BusinessException(ErrorCode.DUPLICATE_GROUP_NAME);
}

// 3. 새로운 GID를 할당받습니다.
Long assignedGid = idAllocationService.allocateNewGid();
log.info("[createGroup] 새로운 GID 할당 완료: {}", assignedGid);

if (assignedGid > Integer.MAX_VALUE || assignedGid < Integer.MIN_VALUE) {
log.error("[createGroup] 할당된 GID가 int 범위를 벗어납니다: {}", assignedGid);
throw new BusinessException(ErrorCode.GID_ALLOCATION_FAILED);
}

// 3. 그룹 엔티티 생성 및 저장
// 4. 외부 API 호출을 위한 ubuntuUser 멤버 리스트를 구성합니다.
List<String> members = Optional.ofNullable(dto.ubuntuUsername())
.filter(StringUtils::hasText)
.map(List::of)
.orElse(Collections.emptyList());

ConfigServerGroupRequest apiDto = new ConfigServerGroupRequest(
dto.groupName(),
assignedGid.intValue(),
members
);

try {
log.info("[createGroup] 외부 그룹 생성 API 호출 시작: {}", apiDto);

groupCreationWebClient.post()
.uri("/accounts/addgroup")
.bodyValue(apiDto)
.retrieve()
.bodyToMono(Map.class)
.block();

log.info("[createGroup] 외부 API 호출 성공");

} catch (WebClientResponseException e) {
log.error("[createGroup] 외부 API 호출 실패: 상태 코드={}, 응답={}", e.getStatusCode(), e.getResponseBodyAsString(), e);
String responseBody = e.getResponseBodyAsString();

if (e.getStatusCode() == HttpStatus.BAD_REQUEST) {
if (responseBody.contains("invalid members")) {
throw new BusinessException(ErrorCode.INVALID_GROUP_MEMBER);
}
throw new BusinessException(ErrorCode.EXTERNAL_API_ERROR);
} else if (e.getStatusCode() == HttpStatus.CONFLICT) {
if (responseBody.contains("group already exists")) {
throw new BusinessException(ErrorCode.DUPLICATE_GROUP_NAME);
}
throw new BusinessException(ErrorCode.DUPLICATE_GROUP_ID);
} else if (e.getStatusCode() == HttpStatus.INTERNAL_SERVER_ERROR) {
throw new BusinessException(ErrorCode.EXTERNAL_API_ERROR);
}

throw new BusinessException(ErrorCode.GROUP_CREATION_FAILED);

} catch (Exception e) {
log.error("[createGroup] 외부 API 호출 중 예상치 못한 오류 발생", e);
throw new BusinessException(ErrorCode.GROUP_CREATION_FAILED);
}

// 5. API 호출이 성공한 후에만 로컬 DB에 그룹을 저장합니다.
UsedId usedId = usedIdRepository.findById(assignedGid)
.orElseGet(() -> usedIdRepository.saveAndFlush(UsedId.builder().idValue(assignedGid).build()));

Group group = Group.builder()
.groupName(dto.groupName())
.ubuntuGid(dto.ubuntuGid())
.ubuntuGid(assignedGid)
.usedId(usedId)
.build();

group = groupRepository.save(group);
log.info("[createGroup] 그룹 생성 완료: id={}, name={}", group.getGroupId(), group.getGroupName());
log.info("[createGroup] 그룹 생성 및 로컬 DB 저장 완료: id={}, name={}", group.getGroupId(), group.getGroupName());

return GroupResponseDTO.fromEntity(group);
}

// Group Service 내부적으로만 사용하는 DTO입니다.
private record ConfigServerGroupRequest(
String name,
int gid,
List<String> members
) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package DGU_AI_LAB.admin_be.domain.requests.controller;

import DGU_AI_LAB.admin_be.domain.requests.controller.docs.AdminRequestChangeApi;
import DGU_AI_LAB.admin_be.domain.requests.dto.request.ApproveModificationDTO;
import DGU_AI_LAB.admin_be.domain.requests.dto.request.RejectModificationDTO;
import DGU_AI_LAB.admin_be.domain.requests.dto.response.ChangeRequestResponseDTO;
import DGU_AI_LAB.admin_be.domain.requests.service.AdminRequestCommandService;
import DGU_AI_LAB.admin_be.domain.requests.service.AdminRequestQueryService;
import DGU_AI_LAB.admin_be.global.common.SuccessResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/admin/requests/change")
public class AdminRequestChangeController implements AdminRequestChangeApi {

private final AdminRequestCommandService adminRequestCommandService;
private final AdminRequestQueryService adminRequestQueryService;

/**
* 변경 요청 목록 조회 (관리자용)
* PENDING 상태의 ChangeRequest 목록을 반환합니다.
*/
@GetMapping("/change")
public ResponseEntity<SuccessResponse<?>> getChangeRequests() {
List<ChangeRequestResponseDTO> changeRequests = adminRequestQueryService.getChangeRequests();
return ResponseEntity.ok((SuccessResponse<?>) changeRequests);
}

@PatchMapping("/change/approve")
public ResponseEntity<SuccessResponse<?>> approveModification(
@AuthenticationPrincipal(expression = "userId") Long adminId,
@RequestBody @Valid ApproveModificationDTO dto
) {
adminRequestCommandService.approveModification(adminId, dto);
return ResponseEntity.ok().build();
}


@PatchMapping("/change/reject")
public ResponseEntity<SuccessResponse<?>> rejectModification(
@AuthenticationPrincipal(expression = "userId") Long adminId,
@RequestBody @Valid RejectModificationDTO dto
) {
adminRequestCommandService.rejectModification(adminId, dto);
return ResponseEntity.ok().build();
}

}
Loading