fix: Mattermost 채널 표시명 조회를 팀 전체 페이지네이션으로 교체하고 404 매핑을 좁힘
- findChannelIdByDisplayName이 /users/me/teams/{id}/channels(토큰 사용자가 가입한
채널만) 대신 /teams/{id}/channels를 페이지네이션(page/per_page=200, 상한 100페이지)
하며 표시명을 찾도록 변경. 레거시 채널에 토큰 사용자가 가입하지 않은 경우 생기던
중복 채널 생성 위험을 제거한다(system-admin PAT 필요, README에 명시).
- 존재하지 않는 기관 id에 대한 404를 폭넓은 IllegalArgumentException 대신 전용
OrgNotFoundException으로 좁혀서 매핑.
- 검증 실패(@Valid) 응답을 원시 필드명이 담긴 JSON 대신 한글 라벨 메시지로 변환.
- SeedController 업로드 스트림 읽기 실패를 SeedParseException(400)으로 통일.
Co-Authored-By: Claude Opus 4.8 (1M context)
@2859f3f659367bfcac3843c62e7773f33db8a590
--- src/main/java/kr/itn/itnhub/config/GlobalExceptionHandler.java
+++ src/main/java/kr/itn/itnhub/config/GlobalExceptionHandler.java
... | ... | @@ -1,17 +1,26 @@ |
| 1 | 1 |
package kr.itn.itnhub.config; |
| 2 | 2 |
|
| 3 |
+import kr.itn.itnhub.org.OrgNotFoundException; |
|
| 3 | 4 |
import kr.itn.itnhub.seed.SeedParseException; |
| 4 | 5 |
import org.springframework.http.HttpStatus; |
| 5 | 6 |
import org.springframework.http.ResponseEntity; |
| 7 |
+import org.springframework.web.bind.MethodArgumentNotValidException; |
|
| 6 | 8 |
import org.springframework.web.bind.annotation.ExceptionHandler; |
| 7 | 9 |
import org.springframework.web.bind.annotation.RestControllerAdvice; |
| 10 |
+ |
|
| 11 |
+import java.util.Map; |
|
| 12 |
+import java.util.stream.Collectors; |
|
| 8 | 13 |
|
| 9 | 14 |
/** |
| 10 | 15 |
* 운영자의 흔한 실수를 서버 장애(500)가 아니라 4xx로 돌려준다. |
| 11 | 16 |
* |
| 12 |
- * <p>{@link IllegalArgumentException}은 {@code OrganizationService.updateContact}와
|
|
| 17 |
+ * <p>{@link OrgNotFoundException}은 {@code OrganizationService.updateContact}와
|
|
| 13 | 18 |
* {@code ChannelProvisionService.provision}이 존재하지 않는 기관 id에 대해 던진다 -
|
| 14 | 19 |
* URL에 오타가 있는 것뿐이므로 404가 맞다.</p> |
| 20 |
+ * |
|
| 21 |
+ * <p>{@link MethodArgumentNotValidException}은 {@code @Valid} 대상 요청 바디의 필드가
|
|
| 22 |
+ * 비어있거나 형식이 틀렸을 때 Spring이 던진다 - 원본 필드명을 그대로 보여주는 대신 |
|
| 23 |
+ * 화면에 쓰는 한글 라벨로 바꿔서 알려준다.</p> |
|
| 15 | 24 |
* |
| 16 | 25 |
* <p>{@link SeedParseException}은 비어 있거나, 형식이 틀렸거나, 시트가 잘못된 업로드에
|
| 17 | 26 |
* 대해 던진다 - 흔한 사용자 실수이므로 400과 함께 무엇이 잘못됐는지 알려줘야 한다.</p> |
... | ... | @@ -28,11 +37,28 @@ |
| 28 | 37 |
@RestControllerAdvice |
| 29 | 38 |
public class GlobalExceptionHandler {
|
| 30 | 39 |
|
| 31 |
- @ExceptionHandler(IllegalArgumentException.class) |
|
| 32 |
- public ResponseEntity<ApiError> handleNotFound(IllegalArgumentException e) {
|
|
| 40 |
+ /** 검증 오류 메시지에 쓸 필드명 → 한글 라벨. 목록에 없는 필드는 원본 이름 그대로 보여준다. */ |
|
| 41 |
+ private static final Map<String, String> FIELD_LABELS = Map.of( |
|
| 42 |
+ "deptName", "부서명", |
|
| 43 |
+ "managerName", "담당자명", |
|
| 44 |
+ "managerTitle", "직급/직함", |
|
| 45 |
+ "managerPhone", "연락처", |
|
| 46 |
+ "managerEmail", "이메일"); |
|
| 47 |
+ |
|
| 48 |
+ @ExceptionHandler(OrgNotFoundException.class) |
|
| 49 |
+ public ResponseEntity<ApiError> handleNotFound(OrgNotFoundException e) {
|
|
| 33 | 50 |
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ApiError(e.getMessage())); |
| 34 | 51 |
} |
| 35 | 52 |
|
| 53 |
+ @ExceptionHandler(MethodArgumentNotValidException.class) |
|
| 54 |
+ public ResponseEntity<ApiError> handleValidation(MethodArgumentNotValidException e) {
|
|
| 55 |
+ String fields = e.getBindingResult().getFieldErrors().stream() |
|
| 56 |
+ .map(fe -> FIELD_LABELS.getOrDefault(fe.getField(), fe.getField())) |
|
| 57 |
+ .distinct() |
|
| 58 |
+ .collect(Collectors.joining(", "));
|
|
| 59 |
+ return ResponseEntity.badRequest().body(new ApiError(fields + " 값을 확인해 주세요.")); |
|
| 60 |
+ } |
|
| 61 |
+ |
|
| 36 | 62 |
@ExceptionHandler(SeedParseException.class) |
| 37 | 63 |
public ResponseEntity<ApiError> handleSeedParse(SeedParseException e) {
|
| 38 | 64 |
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(new ApiError(e.getMessage())); |
--- src/main/java/kr/itn/itnhub/mattermost/MattermostRestClient.java
+++ src/main/java/kr/itn/itnhub/mattermost/MattermostRestClient.java
... | ... | @@ -54,24 +54,52 @@ |
| 54 | 54 |
} |
| 55 | 55 |
} |
| 56 | 56 |
|
| 57 |
+ /** 팀 채널 목록 조회 시 한 페이지에 요청하는 최대 개수. */ |
|
| 58 |
+ private static final int DISPLAY_NAME_PAGE_SIZE = 200; |
|
| 59 |
+ |
|
| 60 |
+ /** |
|
| 61 |
+ * 무한 루프 방지용 페이지 상한. VBA 원본의 안전장치를 그대로 옮긴 것으로, |
|
| 62 |
+ * 정상적인 팀이라면 도달할 일이 없다. |
|
| 63 |
+ */ |
|
| 64 |
+ private static final int DISPLAY_NAME_MAX_PAGES = 100; |
|
| 65 |
+ |
|
| 66 |
+ /** |
|
| 67 |
+ * 표시명으로 채널을 찾는다. {@code /users/me/teams/{teamId}/channels}는 토큰 사용자가
|
|
| 68 |
+ * "가입한" 채널만 돌려주므로 쓰지 않는다 - 이 조회는 레거시 채널(현재 내부명 규칙 이전에 |
|
| 69 |
+ * 만들어져 내부명으로는 못 찾는 채널)을 복구하기 위한 것인데, 토큰 사용자가 그 채널에 |
|
| 70 |
+ * 가입해 있지 않으면 조회가 누락되어 중복 채널이 생성된다. 대신 팀 전체 채널을 |
|
| 71 |
+ * {@code /teams/{teamId}/channels}로 페이지네이션하며 훑는다 - 이 엔드포인트는 비공개
|
|
| 72 |
+ * 채널까지 보려면 PAT가 system-admin 권한이어야 한다(README 참고). |
|
| 73 |
+ */ |
|
| 57 | 74 |
@Override |
| 58 | 75 |
public Optional<String> findChannelIdByDisplayName(String displayName) {
|
| 59 | 76 |
try {
|
| 60 |
- JsonNode channels = rest.get() |
|
| 61 |
- .uri(uri -> uri.path("/api/v4/users/me/teams/{teamId}/channels")
|
|
| 62 |
- .build(teamId)) |
|
| 63 |
- .retrieve() |
|
| 64 |
- .body(JsonNode.class); |
|
| 77 |
+ for (int page = 0; page < DISPLAY_NAME_MAX_PAGES; page++) {
|
|
| 78 |
+ int currentPage = page; |
|
| 79 |
+ JsonNode channels = rest.get() |
|
| 80 |
+ .uri(uri -> uri.path("/api/v4/teams/{teamId}/channels")
|
|
| 81 |
+ .queryParam("page", currentPage)
|
|
| 82 |
+ .queryParam("per_page", DISPLAY_NAME_PAGE_SIZE)
|
|
| 83 |
+ .build(teamId)) |
|
| 84 |
+ .retrieve() |
|
| 85 |
+ .body(JsonNode.class); |
|
| 65 | 86 |
|
| 66 |
- if (channels == null || !channels.isArray()) {
|
|
| 67 |
- return Optional.empty(); |
|
| 68 |
- } |
|
| 69 |
- for (JsonNode channel : channels) {
|
|
| 70 |
- if (displayName.equals(channel.path("display_name").asText())) {
|
|
| 71 |
- return idOf(channel); |
|
| 87 |
+ if (channels == null || !channels.isArray()) {
|
|
| 88 |
+ return Optional.empty(); |
|
| 89 |
+ } |
|
| 90 |
+ |
|
| 91 |
+ for (JsonNode channel : channels) {
|
|
| 92 |
+ if (displayName.equals(channel.path("display_name").asText())) {
|
|
| 93 |
+ return idOf(channel); |
|
| 94 |
+ } |
|
| 95 |
+ } |
|
| 96 |
+ |
|
| 97 |
+ if (channels.size() < DISPLAY_NAME_PAGE_SIZE) {
|
|
| 98 |
+ return Optional.empty(); |
|
| 72 | 99 |
} |
| 73 | 100 |
} |
| 74 |
- return Optional.empty(); |
|
| 101 |
+ throw new MattermostException( |
|
| 102 |
+ "채널 목록 조회 페이지 한도(" + DISPLAY_NAME_MAX_PAGES + ") 초과: " + displayName);
|
|
| 75 | 103 |
} catch (RestClientException e) {
|
| 76 | 104 |
throw new MattermostException("채널 목록 조회 실패", e);
|
| 77 | 105 |
} |
+++ src/main/java/kr/itn/itnhub/org/OrgNotFoundException.java
... | ... | @@ -0,0 +1,11 @@ |
| 1 | +package kr.itn.itnhub.org; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * 존재하지 않는 기관 id로 조회/수정/채널생성을 시도했을 때 던진다. | |
| 5 | + * URL 경로의 id가 단순히 틀린 것뿐이므로 {@link GlobalExceptionHandler}가 404로 변환한다. | |
| 6 | + */ | |
| 7 | +public class OrgNotFoundException extends RuntimeException { | |
| 8 | + public OrgNotFoundException(String message) { | |
| 9 | + super(message); | |
| 10 | + } | |
| 11 | +} |
--- src/main/java/kr/itn/itnhub/org/OrganizationService.java
+++ src/main/java/kr/itn/itnhub/org/OrganizationService.java
... | ... | @@ -22,7 +22,7 @@ |
| 22 | 22 |
public OrgResponse updateContact(Long id, ContactRequest request) {
|
| 23 | 23 |
Organization org = mapper.findById(id); |
| 24 | 24 |
if (org == null) {
|
| 25 |
- throw new IllegalArgumentException("기관을 찾을 수 없습니다: " + id);
|
|
| 25 |
+ throw new OrgNotFoundException("기관을 찾을 수 없습니다: " + id);
|
|
| 26 | 26 |
} |
| 27 | 27 |
org.setDeptName(request.deptName()); |
| 28 | 28 |
org.setManagerName(request.managerName()); |
--- src/main/java/kr/itn/itnhub/provision/ChannelProvisionService.java
+++ src/main/java/kr/itn/itnhub/provision/ChannelProvisionService.java
... | ... | @@ -2,6 +2,7 @@ |
| 2 | 2 |
|
| 3 | 3 |
import kr.itn.itnhub.config.MattermostProperties; |
| 4 | 4 |
import kr.itn.itnhub.mattermost.MattermostClient; |
| 5 |
+import kr.itn.itnhub.org.OrgNotFoundException; |
|
| 5 | 6 |
import kr.itn.itnhub.org.Organization; |
| 6 | 7 |
import kr.itn.itnhub.org.OrganizationMapper; |
| 7 | 8 |
import org.slf4j.Logger; |
... | ... | @@ -43,7 +44,7 @@ |
| 43 | 44 |
public ProvisionResult provision(Long orgId) {
|
| 44 | 45 |
Organization org = mapper.findById(orgId); |
| 45 | 46 |
if (org == null) {
|
| 46 |
- throw new IllegalArgumentException("기관을 찾을 수 없습니다: " + orgId);
|
|
| 47 |
+ throw new OrgNotFoundException("기관을 찾을 수 없습니다: " + orgId);
|
|
| 47 | 48 |
} |
| 48 | 49 |
|
| 49 | 50 |
if (!org.hasRequiredContact()) {
|
--- src/main/java/kr/itn/itnhub/seed/SeedController.java
+++ src/main/java/kr/itn/itnhub/seed/SeedController.java
... | ... | @@ -18,7 +18,7 @@ |
| 18 | 18 |
} |
| 19 | 19 |
|
| 20 | 20 |
@PostMapping("/api/seed")
|
| 21 |
- public SeedReport upload(@RequestParam("file") MultipartFile file) throws IOException {
|
|
| 21 |
+ public SeedReport upload(@RequestParam("file") MultipartFile file) {
|
|
| 22 | 22 |
if (file.isEmpty()) {
|
| 23 | 23 |
throw new SeedParseException("업로드된 파일이 비어 있습니다.");
|
| 24 | 24 |
} |
... | ... | @@ -29,8 +29,12 @@ |
| 29 | 29 |
if (filename == null || !hasAllowedExtension(filename)) {
|
| 30 | 30 |
throw new SeedParseException("엑셀 파일(.xlsx 또는 .xlsm)만 업로드할 수 있습니다.");
|
| 31 | 31 |
} |
| 32 |
+ // IOException을 그대로 던지면 바디 없는 500이 되어 다른 업로드 오류와 다르게 |
|
| 33 |
+ // 취급된다. 스트림 읽기 실패도 사용자가 이해할 수 있는 400으로 통일한다. |
|
| 32 | 34 |
try (InputStream in = file.getInputStream()) {
|
| 33 | 35 |
return seedService.seed(in); |
| 36 |
+ } catch (IOException e) {
|
|
| 37 |
+ throw new SeedParseException("엑셀 파일을 읽지 못했습니다.", e);
|
|
| 34 | 38 |
} |
| 35 | 39 |
} |
| 36 | 40 |
|
--- src/test/java/kr/itn/itnhub/mattermost/MattermostRestClientTest.java
+++ src/test/java/kr/itn/itnhub/mattermost/MattermostRestClientTest.java
... | ... | @@ -78,8 +78,10 @@ |
| 78 | 78 |
} |
| 79 | 79 |
|
| 80 | 80 |
@Test |
| 81 |
- void 표시명으로_내_채널목록에서_id를_찾는다() {
|
|
| 82 |
- server.stubFor(get(urlPathEqualTo("/api/v4/users/me/teams/team123/channels"))
|
|
| 81 |
+ void 표시명으로_팀전체_채널목록에서_id를_찾는다() {
|
|
| 82 |
+ server.stubFor(get(urlPathEqualTo("/api/v4/teams/team123/channels"))
|
|
| 83 |
+ .withQueryParam("page", equalTo("0"))
|
|
| 84 |
+ .withQueryParam("per_page", equalTo("200"))
|
|
| 83 | 85 |
.willReturn(okJson("""
|
| 84 | 86 |
[ |
| 85 | 87 |
{"id":"aaa","display_name":"001_국제방송교류재단 (문정원)"},
|
... | ... | @@ -89,8 +91,73 @@ |
| 89 | 91 |
|
| 90 | 92 |
assertThat(client.findChannelIdByDisplayName("001_국제방송교류재단 (법률검토)"))
|
| 91 | 93 |
.contains("bbb");
|
| 92 |
- assertThat(client.findChannelIdByDisplayName("002_없는기관 (문정원)"))
|
|
| 93 |
- .isEmpty(); |
|
| 94 |
+ } |
|
| 95 |
+ |
|
| 96 |
+ @Test |
|
| 97 |
+ void 표시명이_첫페이지에_없으면_빈값이다() {
|
|
| 98 |
+ server.stubFor(get(urlPathEqualTo("/api/v4/teams/team123/channels"))
|
|
| 99 |
+ .withQueryParam("page", equalTo("0"))
|
|
| 100 |
+ .withQueryParam("per_page", equalTo("200"))
|
|
| 101 |
+ .willReturn(okJson("""
|
|
| 102 |
+ [ |
|
| 103 |
+ {"id":"aaa","display_name":"001_국제방송교류재단 (문정원)"}
|
|
| 104 |
+ ] |
|
| 105 |
+ """))); |
|
| 106 |
+ |
|
| 107 |
+ assertThat(client.findChannelIdByDisplayName("002_없는기관 (문정원)")).isEmpty();
|
|
| 108 |
+ } |
|
| 109 |
+ |
|
| 110 |
+ @Test |
|
| 111 |
+ void 표시명_조회가_두번째_페이지에서_찾으면_id를_돌려준다() {
|
|
| 112 |
+ server.stubFor(get(urlPathEqualTo("/api/v4/teams/team123/channels"))
|
|
| 113 |
+ .withQueryParam("page", equalTo("0"))
|
|
| 114 |
+ .withQueryParam("per_page", equalTo("200"))
|
|
| 115 |
+ .willReturn(okJson(fullPageJson(200, "p0", null)))); |
|
| 116 |
+ |
|
| 117 |
+ server.stubFor(get(urlPathEqualTo("/api/v4/teams/team123/channels"))
|
|
| 118 |
+ .withQueryParam("page", equalTo("1"))
|
|
| 119 |
+ .withQueryParam("per_page", equalTo("200"))
|
|
| 120 |
+ .willReturn(okJson(fullPageJson(5, "p1", "004_찾는기관 (법률검토)")))); |
|
| 121 |
+ |
|
| 122 |
+ assertThat(client.findChannelIdByDisplayName("004_찾는기관 (법률검토)"))
|
|
| 123 |
+ .contains("p1-match");
|
|
| 124 |
+ } |
|
| 125 |
+ |
|
| 126 |
+ @Test |
|
| 127 |
+ void 표시명_조회가_두_페이지를_모두_소진하면_빈값이다() {
|
|
| 128 |
+ server.stubFor(get(urlPathEqualTo("/api/v4/teams/team123/channels"))
|
|
| 129 |
+ .withQueryParam("page", equalTo("0"))
|
|
| 130 |
+ .withQueryParam("per_page", equalTo("200"))
|
|
| 131 |
+ .willReturn(okJson(fullPageJson(200, "p0", null)))); |
|
| 132 |
+ |
|
| 133 |
+ server.stubFor(get(urlPathEqualTo("/api/v4/teams/team123/channels"))
|
|
| 134 |
+ .withQueryParam("page", equalTo("1"))
|
|
| 135 |
+ .withQueryParam("per_page", equalTo("200"))
|
|
| 136 |
+ .willReturn(okJson(fullPageJson(10, "p1", null)))); |
|
| 137 |
+ |
|
| 138 |
+ assertThat(client.findChannelIdByDisplayName("005_존재하지않는기관 (법률검토)")).isEmpty();
|
|
| 139 |
+ } |
|
| 140 |
+ |
|
| 141 |
+ /** 팀 채널 목록 한 페이지를 흉내낸 JSON을 만든다. matchDisplayName이 있으면 마지막에 추가한다. */ |
|
| 142 |
+ private static String fullPageJson(int fillerCount, String idPrefix, String matchDisplayName) {
|
|
| 143 |
+ StringBuilder sb = new StringBuilder("[");
|
|
| 144 |
+ for (int i = 0; i < fillerCount; i++) {
|
|
| 145 |
+ if (i > 0) {
|
|
| 146 |
+ sb.append(",");
|
|
| 147 |
+ } |
|
| 148 |
+ sb.append("{\"id\":\"").append(idPrefix).append("-filler").append(i)
|
|
| 149 |
+ .append("\",\"display_name\":\"filler-").append(idPrefix).append("-").append(i)
|
|
| 150 |
+ .append("\"}");
|
|
| 151 |
+ } |
|
| 152 |
+ if (matchDisplayName != null) {
|
|
| 153 |
+ if (fillerCount > 0) {
|
|
| 154 |
+ sb.append(",");
|
|
| 155 |
+ } |
|
| 156 |
+ sb.append("{\"id\":\"").append(idPrefix).append("-match\",\"display_name\":\"")
|
|
| 157 |
+ .append(matchDisplayName).append("\"}");
|
|
| 158 |
+ } |
|
| 159 |
+ sb.append("]");
|
|
| 160 |
+ return sb.toString(); |
|
| 94 | 161 |
} |
| 95 | 162 |
|
| 96 | 163 |
@Test |
--- src/test/java/kr/itn/itnhub/org/OrganizationControllerTest.java
+++ src/test/java/kr/itn/itnhub/org/OrganizationControllerTest.java
... | ... | @@ -123,6 +123,29 @@ |
| 123 | 123 |
.andExpect(status().isBadRequest()); |
| 124 | 124 |
} |
| 125 | 125 |
|
| 126 |
+ /** |
|
| 127 |
+ * Finding I6 회귀 테스트: 검증 실패 응답 본문이 필드명을 그대로 노출하는 원시 JSON |
|
| 128 |
+ * 블롭(예: {"deptName":"must not be blank"})이 아니라, 운영자가 읽을 수 있는
|
|
| 129 |
+ * 한글 라벨(부서명 등)이 담긴 {@link kr.itn.itnhub.config.ApiError} 메시지여야 한다.
|
|
| 130 |
+ */ |
|
| 131 |
+ @Test |
|
| 132 |
+ void 필수값이_비면_한글_필드명이_담긴_메시지를_돌려준다() throws Exception {
|
|
| 133 |
+ mvc.perform(put("/api/orgs/{id}/contact", orgId)
|
|
| 134 |
+ .with(csrf()) |
|
| 135 |
+ .contentType(MediaType.APPLICATION_JSON) |
|
| 136 |
+ .content("""
|
|
| 137 |
+ {
|
|
| 138 |
+ "deptName": "", |
|
| 139 |
+ "managerName": "", |
|
| 140 |
+ "managerPhone": "02-3475-5434", |
|
| 141 |
+ "managerEmail": "ming@arirang.com" |
|
| 142 |
+ } |
|
| 143 |
+ """)) |
|
| 144 |
+ .andExpect(status().isBadRequest()) |
|
| 145 |
+ .andExpect(jsonPath("$.message").value(containsString("부서명")))
|
|
| 146 |
+ .andExpect(jsonPath("$.message").value(containsString("담당자명")));
|
|
| 147 |
+ } |
|
| 148 |
+ |
|
| 126 | 149 |
@Test |
| 127 | 150 |
void 채널생성을_요청하면_결과를_돌려준다() throws Exception {
|
| 128 | 151 |
Organization ready = mapper.findById(orgId); |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?