Skip to content

Commit bb2e1e7

Browse files
authored
Merge pull request #328 from CSID-DGU/develop
release: develop → main (버그 수정 및 안정성 개선 배치)
2 parents 0df7027 + 5aeee6e commit bb2e1e7

94 files changed

Lines changed: 4360 additions & 715 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ dependencies {
3737

3838
//spring security
3939
implementation 'org.springframework.boot:spring-boot-starter-security'
40+
testImplementation 'org.springframework.security:spring-security-test'
4041

4142
// Actuator
4243
implementation 'org.springframework.boot:spring-boot-starter-actuator'

lombok.config

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
config.stopBubbling = true
2+
lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package DGU_AI_LAB.admin_be.domain.alarm.controller;
2+
3+
import DGU_AI_LAB.admin_be.domain.alarm.dto.InfraSlackRequestDTO;
4+
import DGU_AI_LAB.admin_be.domain.alarm.service.InfraAlarmService;
5+
import DGU_AI_LAB.admin_be.global.common.SuccessResponse;
6+
import io.swagger.v3.oas.annotations.Operation;
7+
import io.swagger.v3.oas.annotations.responses.ApiResponse;
8+
import io.swagger.v3.oas.annotations.tags.Tag;
9+
import jakarta.validation.Valid;
10+
import lombok.RequiredArgsConstructor;
11+
import org.springframework.http.ResponseEntity;
12+
import org.springframework.web.bind.annotation.PostMapping;
13+
import org.springframework.web.bind.annotation.RequestBody;
14+
import org.springframework.web.bind.annotation.RequestMapping;
15+
import org.springframework.web.bind.annotation.RestController;
16+
17+
@Tag(name = "0. 내부 전용 API", description = "인프라·Config 서버 전용 내부 API (인증 불필요, 내부망 전용)")
18+
@RestController
19+
@RequiredArgsConstructor
20+
@RequestMapping("/api/internal")
21+
public class InfraSlackController {
22+
23+
private final InfraAlarmService infraAlarmService;
24+
25+
@Operation(
26+
summary = "인프라 Slack 알림 큐잉",
27+
description = "인프라 서버에서 호출. 지정한 Webhook URL로 메시지를 Redis 큐에 적재하며, " +
28+
"채널당 초당 1회 제한을 준수하여 순차 전송합니다. " +
29+
"이 엔드포인트는 인증이 필요 없으며 내부망에서만 접근 가능해야 합니다."
30+
)
31+
@ApiResponse(responseCode = "200", description = "큐 적재 성공")
32+
@ApiResponse(responseCode = "400", description = "webhookUrl 또는 message가 누락된 경우")
33+
@PostMapping("/slack/notify")
34+
public ResponseEntity<SuccessResponse<?>> notifySlack(
35+
@RequestBody @Valid InfraSlackRequestDTO dto
36+
) {
37+
infraAlarmService.enqueue(dto.webhookUrl(), dto.message());
38+
return SuccessResponse.ok(null);
39+
}
40+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package DGU_AI_LAB.admin_be.domain.alarm.dto;
2+
3+
import io.swagger.v3.oas.annotations.media.Schema;
4+
import jakarta.validation.constraints.NotBlank;
5+
import jakarta.validation.constraints.Pattern;
6+
7+
@Schema(description = "인프라 Slack 알림 요청 DTO")
8+
public record InfraSlackRequestDTO(
9+
10+
@NotBlank(message = "webhookUrl은 필수입니다.")
11+
@Pattern(
12+
regexp = "https://hooks\\.slack\\.com/services/[A-Za-z0-9]+/[A-Za-z0-9]+/[A-Za-z0-9]+",
13+
message = "webhookUrl은 Slack Webhook URL 형식이어야 합니다."
14+
)
15+
@Schema(description = "전송할 Slack Webhook URL", example = "https://hooks.slack.com/services/...")
16+
String webhookUrl,
17+
18+
@NotBlank(message = "message는 필수입니다.")
19+
@Schema(description = "전송할 메시지", example = "🚨 GPU 사용률 95% 초과")
20+
String message
21+
) {}

src/main/java/DGU_AI_LAB/admin_be/domain/alarm/service/AlarmService.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,8 @@ private void handleFallbackDirectSend(SlackMessageDto dto) {
190190
}
191191

192192
// Fallback이 작동했다는 건 시스템이 불안정하다는 뜻이므로 에러 로그 채널에 알립니다.
193-
if (!dto.getWebhookUrl().equals(errorLogWebhookUrl)) {
193+
String webhookUrl = dto.getWebhookUrl();
194+
if (webhookUrl == null || !webhookUrl.equals(errorLogWebhookUrl)) {
194195
slackApiService.sendWebhook(errorLogWebhookUrl, "⚠️ Redis 장애 발생 (Direct Send 작동됨)");
195196
}
196197

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package DGU_AI_LAB.admin_be.domain.alarm.service;
2+
3+
import DGU_AI_LAB.admin_be.domain.alarm.dto.SlackMessageDto;
4+
import lombok.RequiredArgsConstructor;
5+
import lombok.extern.slf4j.Slf4j;
6+
import org.springframework.data.redis.core.RedisTemplate;
7+
import org.springframework.stereotype.Service;
8+
9+
/**
10+
* 인프라 서버에서 호출하는 Slack 알림 큐잉 서비스.
11+
* 백엔드 알림 큐(slack:notification:queue)와 별도로 운영되어
12+
* 인프라 모니터링 알림과 백엔드 자동화 알림이 서로 지연을 주지 않는다.
13+
*/
14+
@Slf4j
15+
@Service
16+
@RequiredArgsConstructor
17+
public class InfraAlarmService {
18+
19+
public static final String INFRA_SLACK_QUEUE_KEY = "slack:infra:notification:queue";
20+
21+
private final RedisTemplate<String, Object> redisTemplate;
22+
private final SlackApiService slackApiService;
23+
24+
public void enqueue(String webhookUrl, String message) {
25+
SlackMessageDto dto = SlackMessageDto.builder()
26+
.type(SlackMessageDto.MessageType.WEBHOOK)
27+
.webhookUrl(webhookUrl)
28+
.message(message)
29+
.build();
30+
31+
try {
32+
redisTemplate.opsForList().rightPush(INFRA_SLACK_QUEUE_KEY, dto);
33+
log.debug("인프라 Slack 큐 적재 완료");
34+
} catch (Exception e) {
35+
log.error("Redis 장애 — 인프라 Slack 직접 전송 시도: {}", e.getMessage());
36+
try {
37+
slackApiService.sendWebhook(webhookUrl, message);
38+
log.info("인프라 Slack 직접 전송 성공 (fallback)");
39+
} catch (Exception ex) {
40+
log.error("인프라 Slack 직접 전송도 실패: {}", ex.getMessage());
41+
}
42+
}
43+
}
44+
}

src/main/java/DGU_AI_LAB/admin_be/domain/alarm/service/SlackApiService.java

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -170,10 +170,17 @@ private String openDMChannel(String userId, String token) {
170170

171171
try {
172172
ResponseEntity<Map> response = restTemplate.postForEntity(url, request, Map.class);
173-
if (Boolean.TRUE.equals(response.getBody().get("ok"))) {
174-
Map channel = (Map) response.getBody().get("channel");
175-
return (String) channel.get("id");
173+
Map<?, ?> body = response.getBody();
174+
if (body == null || !Boolean.TRUE.equals(body.get("ok"))) {
175+
log.warn("conversations.open API 응답 이상: ok=false 또는 body null");
176+
return null;
176177
}
178+
Map<?, ?> channel = (Map<?, ?>) body.get("channel");
179+
if (channel == null) {
180+
log.warn("conversations.open 응답에 channel 필드 없음");
181+
return null;
182+
}
183+
return (String) channel.get("id");
177184
} catch (Exception e) {
178185
log.error("DM 채널 오픈 API 오류", e);
179186
}

src/main/java/DGU_AI_LAB/admin_be/domain/containerImage/controller/docs/ContainerImageApi.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,11 @@
1515

1616
import java.util.List;
1717

18-
@Tag(name = "2. 이미지 관리", description = "컨테이너 이미지 조회 API")
18+
@Tag(name = "3. 관리자 시스템", description = "컨테이너 이미지, K8s Pod, 알림 템플릿 관리 API")
1919
public interface ContainerImageApi {
2020

21-
@Operation(summary = "이미지 생성", description = "새로운 컨테이너 이미지를 등록합니다. -> 관리자용으로 만들어졌으니, 수정 필요합니다. ")
22-
@ApiResponse(responseCode = "200", description = "이미지 생성 성공",
21+
@Operation(summary = "컨테이너 이미지 등록", description = "새로운 컨테이너 이미지를 등록합니다. (관리자 전용)")
22+
@ApiResponse(responseCode = "200", description = "등록 성공",
2323
content = @Content(schema = @Schema(implementation = ContainerImageResponseDTO.class)))
2424
ResponseEntity<ContainerImageResponseDTO> createImage(
2525
@RequestBody @Valid ContainerImageCreateRequest request

src/main/java/DGU_AI_LAB/admin_be/domain/dashboard/controller/DashboardController.java

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import DGU_AI_LAB.admin_be.domain.dashboard.controller.docs.DashBoardApi;
44
import DGU_AI_LAB.admin_be.domain.dashboard.service.DashboardService;
55
import DGU_AI_LAB.admin_be.domain.requests.dto.response.UserServerResponseDTO;
6-
import DGU_AI_LAB.admin_be.domain.requests.entity.Status;
6+
import DGU_AI_LAB.admin_be.domain.requests.entity.StatusFilter;
77
import DGU_AI_LAB.admin_be.global.auth.CustomUserDetails;
88
import DGU_AI_LAB.admin_be.global.common.SuccessResponse;
99
import lombok.RequiredArgsConstructor;
@@ -28,15 +28,15 @@ public class DashboardController implements DashBoardApi {
2828
/**
2929
* 사용자 신청 현황 서버 목록 조회 API
3030
*
31-
* @param principal 현재 로그인한 사용자의 인증 정보 (CustomUserDetails)
32-
* @param status 조회할 서버 요청의 상태 (필수 값: PENDING, FULFILLED, DENIED, ALL)
31+
* @param principal 현재 로그인한 사용자의 인증 정보 (CustomUserDetails)
32+
* @param statusFilter 조회할 서버 요청의 상태 필터 (PENDING, FULFILLED, DENIED, DELETED, ALL)
3333
*/
3434
@GetMapping("/me/servers")
3535
public ResponseEntity<SuccessResponse<?>> getUserServers(
3636
@AuthenticationPrincipal CustomUserDetails principal,
37-
@RequestParam(name = "status") Status status
37+
@RequestParam(name = "status") StatusFilter statusFilter
3838
) {
39-
List<UserServerResponseDTO> userServers = dashboardService.getUserServers(principal.getUserId(), status);
39+
List<UserServerResponseDTO> userServers = dashboardService.getUserServers(principal.getUserId(), statusFilter);
4040
return SuccessResponse.ok(userServers);
4141
}
4242

Lines changed: 9 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
package DGU_AI_LAB.admin_be.domain.dashboard.controller.docs;
22

33
import DGU_AI_LAB.admin_be.domain.requests.dto.response.UserServerResponseDTO;
4-
import DGU_AI_LAB.admin_be.domain.requests.entity.Status;
4+
import DGU_AI_LAB.admin_be.domain.requests.entity.StatusFilter;
55
import DGU_AI_LAB.admin_be.error.dto.ErrorResponse;
66
import DGU_AI_LAB.admin_be.global.auth.CustomUserDetails;
77
import DGU_AI_LAB.admin_be.global.common.SuccessResponse;
@@ -18,42 +18,20 @@
1818
import org.springframework.web.bind.annotation.RequestParam;
1919

2020

21-
@Tag(name = "2. 사용자 대시보드 API", description = "대시보드용 API")
21+
@Tag(name = "2. 사용자 대시보드", description = "사용자 신청 현황 대시보드 API")
2222
@RequestMapping("/api/dashboard")
2323
public interface DashBoardApi {
2424

25-
@Operation(
26-
summary = "사용자 서버 신청 목록 조회",
27-
description = "현재 로그인한 사용자의 서버 신청 목록을 상태별로 필터링하여 조회합니다.<br>" +
28-
"JWT를 통해 사용자 ID를 식별합니다.",
29-
responses = {
30-
@ApiResponse(
31-
responseCode = "200",
32-
description = "성공적으로 서버 신청 목록을 조회했습니다.",
33-
content = @Content(schema = @Schema(implementation = UserServerResponseDTO.class))
34-
),
35-
@ApiResponse(
36-
responseCode = "401",
37-
description = "인증되지 않은 사용자입니다.",
38-
content = @Content(schema = @Schema(implementation = ErrorResponse.class))
39-
),
40-
@ApiResponse(
41-
responseCode = "404",
42-
description = "사용자를 찾을 수 없거나 해당 사용자의 신청 내역이 없습니다. (USER_NOT_FOUND)",
43-
content = @Content(schema = @Schema(implementation = ErrorResponse.class))
44-
),
45-
@ApiResponse(
46-
responseCode = "500",
47-
description = "서버 내부 오류 발생.",
48-
content = @Content(schema = @Schema(implementation = ErrorResponse.class))
49-
)
50-
}
51-
)
25+
@Operation(summary = "내 서버 신청 목록 조회", description = "상태(status)로 필터링하여 로그인된 사용자의 서버 신청 목록을 조회합니다.")
26+
@ApiResponse(responseCode = "200", description = "성공",
27+
content = @Content(schema = @Schema(implementation = UserServerResponseDTO.class)))
28+
@ApiResponse(responseCode = "401", description = "인증되지 않은 사용자",
29+
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
5230
@GetMapping("/me/servers")
5331
ResponseEntity<SuccessResponse<?>> getUserServers(
5432
@Parameter(hidden = true)
5533
@AuthenticationPrincipal CustomUserDetails principal,
56-
@Parameter(description = "조회할 서버 신청 상태 (PENDING, FULFILLED, DENIED)", required = true, example = "FULFILLED")
57-
@RequestParam(name = "status") Status status
34+
@Parameter(description = "조회할 서버 신청 상태 필터 (PENDING, FULFILLED, DENIED, DELETED, ALL)", required = true, example = "FULFILLED")
35+
@RequestParam(name = "status") StatusFilter statusFilter
5836
);
5937
}

0 commit comments

Comments
 (0)