From 2dff3b8af0b7142448ac1034edfdeb8d373ecf57 Mon Sep 17 00:00:00 2001 From: LeeJeongHeon02 Date: Tue, 21 Jul 2026 13:57:36 +0900 Subject: [PATCH 1/4] =?UTF-8?q?fix:=20=ED=8C=80=EB=AA=85=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EC=8B=9C=20=EC=95=9E=EB=92=A4=20=EA=B3=B5=EB=B0=B1?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0=20=EB=88=84=EB=9D=BD=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 로컬 서버를 띄워 PATCH /api/v1/teams/{teamId}를 실제로 호출하며 QA하다가 발견 - " 이름 " 처럼 앞뒤에 공백이 있는 teamName을 그대로 저장하고 있었다. 팀 생성 API(TeamCreateCommand)는 이미 Texts.trim()으로 teamName을 정리하는데 이번에 추가한 renameTeam()만 빠뜨렸다. 같은 유틸을 적용해 동작을 맞췄다. Co-Authored-By: Claude Sonnet 5 --- .../team/service/TeamMemberManagementService.java | 3 ++- .../service/TeamMemberManagementServiceTest.java | 12 ++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/shinhan/klljs/domain/team/service/TeamMemberManagementService.java b/src/main/java/com/shinhan/klljs/domain/team/service/TeamMemberManagementService.java index 25b8b4e..bc793ca 100644 --- a/src/main/java/com/shinhan/klljs/domain/team/service/TeamMemberManagementService.java +++ b/src/main/java/com/shinhan/klljs/domain/team/service/TeamMemberManagementService.java @@ -15,6 +15,7 @@ import com.shinhan.klljs.domain.team.repository.TeamRepository; import com.shinhan.klljs.global.apiPayload.code.GeneralErrorCode; import com.shinhan.klljs.global.apiPayload.exception.GeneralException; +import com.shinhan.klljs.global.util.Texts; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -72,7 +73,7 @@ public TeamRenameResponse renameTeam(Long requesterId, Long teamId, String teamN throw new GeneralException(TeamErrorCode.TEAM_SETTINGS_FORBIDDEN); } - team.rename(teamName); + team.rename(Texts.trim(teamName)); return TeamRenameResponse.of(team); } diff --git a/src/test/java/com/shinhan/klljs/domain/team/service/TeamMemberManagementServiceTest.java b/src/test/java/com/shinhan/klljs/domain/team/service/TeamMemberManagementServiceTest.java index 5273072..1b6d1c7 100644 --- a/src/test/java/com/shinhan/klljs/domain/team/service/TeamMemberManagementServiceTest.java +++ b/src/test/java/com/shinhan/klljs/domain/team/service/TeamMemberManagementServiceTest.java @@ -122,6 +122,18 @@ void renameTeam_allowsOwnerAndAdminButRejectsMember() { assertThat(team.getTeamName()).isEqualTo("관리자가 바꾼 팀명"); } + @Test + void renameTeam_trimsEdgeWhitespace() { + Team team = persistTeam(TeamStatus.ACTIVE); + TeamMember owner = persistMember(team, "오너", "owner@example.com", TeamMemberRole.OWNER, TeamMemberStatus.ACTIVE); + entityManager.flush(); + + TeamRenameResponse response = service.renameTeam(owner.getUser().getId(), team.getId(), " 공백 팀명 "); + + assertThat(response.teamName()).isEqualTo("공백 팀명"); + assertThat(team.getTeamName()).isEqualTo("공백 팀명"); + } + @Test void removeAndLeave_changeMembershipStatuses() { Team team = persistTeam(TeamStatus.ACTIVE); From 0e67e1427db53f9a96f3031e6edfe60bb8db69b2 Mon Sep 17 00:00:00 2001 From: LeeJeongHeon02 Date: Tue, 21 Jul 2026 14:47:10 +0900 Subject: [PATCH 2/4] =?UTF-8?q?test:=20=ED=8C=80=EB=AA=85=20trim=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=EA=B0=80=20=EC=8B=A4=EC=A0=9C=20DB?= =?UTF-8?q?=20=EB=B0=98=EC=98=81=EC=9D=84=20=EA=B2=80=EC=A6=9D=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EB=B3=B4=EA=B0=95=20(=EC=BD=94=EB=93=9C?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit 지적 - 기존 테스트는 flush 없이 영속성 컨텍스트에 남아있는 같은 Team 인스턴스를 검사해서, 실제로 DB에 반영됐는지가 아니라 메모리 상태만 확인하고 있었다. flush+clear 후 다시 조회하도록 고쳤다 - 같은 파일의 leaveTeam_allowsSoleOwnerToLeaveWithoutTransfer 테스트와 동일한 패턴이다. Co-Authored-By: Claude Sonnet 5 --- .../domain/team/service/TeamMemberManagementServiceTest.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/test/java/com/shinhan/klljs/domain/team/service/TeamMemberManagementServiceTest.java b/src/test/java/com/shinhan/klljs/domain/team/service/TeamMemberManagementServiceTest.java index 1b6d1c7..0a696da 100644 --- a/src/test/java/com/shinhan/klljs/domain/team/service/TeamMemberManagementServiceTest.java +++ b/src/test/java/com/shinhan/klljs/domain/team/service/TeamMemberManagementServiceTest.java @@ -129,9 +129,12 @@ void renameTeam_trimsEdgeWhitespace() { entityManager.flush(); TeamRenameResponse response = service.renameTeam(owner.getUser().getId(), team.getId(), " 공백 팀명 "); + entityManager.flush(); + entityManager.clear(); assertThat(response.teamName()).isEqualTo("공백 팀명"); - assertThat(team.getTeamName()).isEqualTo("공백 팀명"); + // 영속성 컨텍스트를 비우고 다시 조회해서, 메모리상 엔티티 상태가 아니라 실제 DB 반영 여부를 확인한다. + assertThat(entityManager.find(Team.class, team.getId()).getTeamName()).isEqualTo("공백 팀명"); } @Test From a960c3bbb2963536b55e5121aea1d5fef1ce3611 Mon Sep 17 00:00:00 2001 From: LeeJeongHeon02 Date: Tue, 21 Jul 2026 14:29:18 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20=EC=BA=A0=ED=8E=98=EC=9D=B8?= =?UTF-8?q?=EB=AA=85=20=EC=88=98=EC=A0=95=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 문의로 들어온 요청 — 캠페인명(campaignName)만 바꾸는 PATCH /api/v1/teams/{teamId}/campaigns/{campaignId}를 추가한다. 브랜드명·집행기간·목표 송출 횟수·소재 등 나머지 필드 수정과 재등록은 여전히 범위 밖이다(campaign-page-api-spec.md 12절). - 권한/락 모두 기존 deleteCampaign과 동일한 패턴: 팀 조회 → ACTIVE 멤버 확인 → OWNER/ADMIN만 허용(MEMBER는 TEAM_403_003) → campaignRepository.findByIdForUpdate로 잠금 → 이 팀 소유가 아니면 404. 상태(집행 전/중/완료)와 무관하게 항상 허용한다 (삭제와 동일 원칙). - campaignName 검증은 등록 API(CampaignRegistrationService)와 동일한 방식(수동 검증, trim 후 빈 값/30자 초과 시 CAMPAIGN_400_001)으로 맞췄다 - DB 컬럼은 200자까지 허용하지만 등록 시 30자로 더 엄격히 막고 있어 수정에서 다른 상한을 쓰면 "등록은 막혔는데 수정으로는 만들 수 있는" 모순이 생긴다. - TEAM_403_003(CAMPAIGN_MANAGEMENT_FORBIDDEN) 메시지를 "등록하거나 삭제"에서 "삭제하거나 수정"으로 정정 - 등록은 MEMBER도 가능해 이 코드가 실제로 막아온 적이 없었다. - Team.rename()과 같은 패턴으로 Campaign.rename() 엔티티 메서드를 추가했다. 로컬 서버를 띄워 실제 API를 호출하며 검증: 유효성 검증 전체, 권한 매트릭스 (OWNER/ADMIN 200, MEMBER 403), 다른 팀 소유 캠페인을 잘못된 teamId로 접근 시 404, 동시 수정 요청 2건이 락으로 직렬화되어 둘 다 성공하고 최종 상태가 깨지지 않는지 확인. Co-Authored-By: Claude Sonnet 5 --- docs/campaign-page-api-spec.md | 100 ++++++++++++++- .../controller/TeamCampaignController.java | 18 +++ .../TeamCampaignControllerDocs.java | 46 ++++++- .../campaign/dto/CampaignRenameRequest.java | 9 ++ .../campaign/dto/CampaignRenameResponse.java | 13 ++ .../domain/campaign/entity/Campaign.java | 4 + .../service/TeamCampaignCommandService.java | 35 ++++++ .../domain/team/exception/TeamErrorCode.java | 2 +- .../TeamCampaignCommandServiceTest.java | 114 ++++++++++++++++++ 9 files changed, 334 insertions(+), 7 deletions(-) create mode 100644 src/main/java/com/shinhan/klljs/domain/campaign/dto/CampaignRenameRequest.java create mode 100644 src/main/java/com/shinhan/klljs/domain/campaign/dto/CampaignRenameResponse.java diff --git a/docs/campaign-page-api-spec.md b/docs/campaign-page-api-spec.md index c1c8b72..9bcfadb 100644 --- a/docs/campaign-page-api-spec.md +++ b/docs/campaign-page-api-spec.md @@ -7,10 +7,12 @@ 1. 팀 캠페인 목록 조회 (이름/집행일 정렬, 상태 필터, 캠페인명 검색) 2. 캠페인 상세정보 보기 (팝업) 3. 캠페인 삭제 +4. 캠페인명 수정 -화면에 있는 "리포트 추출" 버튼은 MVP 범위가 아니다. 캠페인 수정·재등록 API도 이번 범위가 -아니다 — `campaign-registration-api-spec.md` 14절에서 이미 추후 범위로 남겨뒀고, 이번 문서는 -그중 "삭제"만 앞당겨 다룬다. +화면에 있는 "리포트 추출" 버튼은 MVP 범위가 아니다. 캠페인 전체 수정(브랜드명·집행기간·목표 +송출 횟수·소재 등)·재등록 API도 이번 범위가 아니다 — `campaign-registration-api-spec.md` +14절에서 이미 추후 범위로 남겨뒀고, 이번 문서는 그중 "삭제"와, 문의가 들어온 "캠페인명 +수정"만 앞당겨 다룬다. 이 문서는 `campaign-registration-api-spec.md`(등록)와 `docs/home-dashboard-api-spec.md`(홈 대시보드)에 이미 있는 `Campaign`/`MediaUnit` 엔티티와 상태 모델을 그대로 재사용한다 — 새 @@ -64,12 +66,18 @@ MVP 단순성을 우선해서 하드 삭제로 확정한다. `campaignRepository 코드에는 없음, 문서만 먼저 반영): ```java -CAMPAIGN_MANAGEMENT_FORBIDDEN(HttpStatus.FORBIDDEN, "TEAM_403_003", "캠페인을 등록하거나 삭제할 권한이 없습니다.") +CAMPAIGN_MANAGEMENT_FORBIDDEN(HttpStatus.FORBIDDEN, "TEAM_403_003", "캠페인을 삭제하거나 수정할 권한이 없습니다.") ``` `campaign-registration-api-spec.md`의 캠페인 등록 API도 같은 상황(`MEMBER`가 등록 시도)에 같은 코드를 써야 하므로 그 문서도 같이 고쳤다. +메시지는 원래 "등록하거나 삭제"였는데, 실제로 등록은 `MEMBER`도 할 수 있어(2-3절이 아니라 +`CampaignRegistrationTransactionService`의 역할 제한 없음 코멘트 참고) 이 코드가 지금까지 +실제로 막아온 건 삭제뿐이었다. 6-1절의 "캠페인명 수정"도 삭제와 같은 권한 기준(OWNER/ADMIN)이라 +이 코드를 그대로 재사용하면서, 메시지를 실제로 막는 두 가지 행동("삭제"·"수정")에 맞게 +고쳤다. + ### 2-3. 필터·정렬 매핑 | 화면 탭/옵션 | 내부 조건 | @@ -97,6 +105,7 @@ CAMPAIGN_MANAGEMENT_FORBIDDEN(HttpStatus.FORBIDDEN, "TEAM_403_003", "캠페인 |---|---|---|---| | 팀 캠페인 목록 조회 | GET | `/api/v1/teams/{teamId}/campaigns` | 팀 ACTIVE 멤버 | | 캠페인 상세정보 조회 | GET | `/api/v1/teams/{teamId}/campaigns/{campaignId}` | 팀 ACTIVE 멤버 | +| 캠페인명 수정 | PATCH | `/api/v1/teams/{teamId}/campaigns/{campaignId}` | OWNER/ADMIN | | 캠페인 삭제 | DELETE | `/api/v1/teams/{teamId}/campaigns/{campaignId}` | OWNER/ADMIN | `campaign-registration-api-spec.md`의 `POST /api/v1/teams/{teamId}/campaigns`와 같은 경로 @@ -299,6 +308,77 @@ Authorization: Bearer {accessToken} --- +## 6-1. 캠페인명 수정 API + +캠페인명(`campaignName`)만 바꾼다. 문의로 들어온 요청이라 뒤늦게 추가됐다 — 브랜드명·집행기간· +목표 송출 횟수·소재 등 나머지 필드 수정과 재등록은 여전히 이번 범위가 아니다(12절). + +### Request + +```http +PATCH /api/v1/teams/{teamId}/campaigns/{campaignId} +Authorization: Bearer {accessToken} +Content-Type: application/json + +{ + "campaignName": "나이키 썸머 프로모션 2026" +} +``` + +`campaignName` 검증 규칙은 등록 API(`campaign-registration-api-spec.md`)와 동일하게 맞췄다 — +앞뒤 공백을 제거하고, 빈 값이거나 30자를 넘으면 `400`(`CAMPAIGN_400_001`). `Campaign.campaignName` +컬럼 자체는 200자까지 허용하지만, 등록 시 이미 30자로 더 엄격하게 막고 있어 수정에서만 다른 +상한을 적용하면 "등록할 때는 막혔던 이름을 수정으로는 만들 수 있는" 모순이 생긴다 - 그래서 +같은 30자 기준을 그대로 따른다. + +### 접근 권한 + +- 팀 없음 `404`, 요청자 미소속 `403` +- 요청자가 `MEMBER`면 `403` (삭제와 동일한 권한 기준 — 등록은 `MEMBER`도 가능하지만 삭제·수정은 + OWNER/ADMIN만) + +### 처리 순서 + +1. 캠페인을 조회한다. 없거나 이 팀 소유가 아니면 `404`. +2. 삭제와 동일하게 `campaignRepository.findByIdForUpdate`로 잠근 뒤 `campaignName`을 갱신한다 — + 같은 캠페인에 대한 동시 수정/삭제 요청을 직렬화하기 위해서다. + +**상태(집행 전/중/완료)와 무관하게 항상 허용한다** — 삭제와 같은 원칙이다(2-2절). 이미 집행 +중이거나 끝난 캠페인이라고 이름을 못 바꿀 이유가 없다고 판단했다. + +### Response Fields + +| 필드 | 타입 | 설명 | +|---|---|---| +| `campaignId` | number | | +| `campaignName` | string | 정리(trim)된 뒤 저장된 값 | + +### Response Example + +```json +{ + "isSuccess": true, + "code": "COMMON_200_001", + "message": "성공적으로 요청을 처리했습니다.", + "result": { + "campaignId": 31, + "campaignName": "나이키 썸머 프로모션 2026" + } +} +``` + +### 에러 케이스 + +| 상황 | HTTP / 코드 | +|---|---| +| 팀이 없음 | `404 / TEAM_404_001` | +| 요청자가 이 팀 소속이 아님 | `403 / TEAM_403_001` | +| 요청자가 `MEMBER`임 | `403 / TEAM_403_003` | +| 캠페인이 없음(삭제됐거나 애초에 없음) 또는 이 팀 소유가 아님 | `404 / CAMPAIGN_404_001` | +| `campaignName`이 비어 있거나 30자를 초과 | `400 / CAMPAIGN_400_001` | + +--- + ## 7. 기존 기능에 미치는 영향 — 없음 하드 삭제라 `campaigns` row 자체가 사라지므로, 기존 코드 어디에도 변경이 필요 없다. @@ -364,6 +444,15 @@ Authorization: Bearer {accessToken} - 다른 팀 소유 캠페인 조회 시 404 - 팀 미소속 403 +### 캠페인명 수정 + +- OWNER/ADMIN 성공, MEMBER 403 +- 앞뒤 공백 제거 후 저장 +- 빈 값/30자 초과는 `400`, MEMBER의 403 응답에서 캠페인명이 실제로 안 바뀌었는지 확인 +- 팀 미소속 403, 팀 없음 404 +- 삭제됐거나 존재하지 않는 캠페인 수정 시 404, 다른 팀 소유 캠페인 수정 시 404 +- 집행 중/집행 완료 캠페인도 수정 가능(상태 무관) + ### 삭제 - OWNER/ADMIN 성공, MEMBER 403 @@ -378,7 +467,8 @@ Authorization: Bearer {accessToken} ## 12. 이번 범위에서 제외 -- **캠페인 수정** — `campaign-registration-api-spec.md`에서부터 이어지는 범위 밖 항목. +- **캠페인명 외 나머지 필드 수정** (브랜드명·집행기간·목표 송출 횟수·소재 등) — 6-1절은 + `campaignName`만 다룬다. `campaign-registration-api-spec.md`에서부터 이어지는 범위 밖 항목. - **캠페인 재등록** — 위와 동일. - **삭제 취소(복원)** — 하드 삭제를 선택했으므로 나중에 추가로 만들 수 있는 기능이 아니다. 삭제된 캠페인의 데이터는 영구적으로 복구할 수 없다. diff --git a/src/main/java/com/shinhan/klljs/domain/campaign/controller/TeamCampaignController.java b/src/main/java/com/shinhan/klljs/domain/campaign/controller/TeamCampaignController.java index 5fca9af..f50f45f 100644 --- a/src/main/java/com/shinhan/klljs/domain/campaign/controller/TeamCampaignController.java +++ b/src/main/java/com/shinhan/klljs/domain/campaign/controller/TeamCampaignController.java @@ -1,5 +1,7 @@ package com.shinhan.klljs.domain.campaign.controller; +import com.shinhan.klljs.domain.campaign.dto.CampaignRenameRequest; +import com.shinhan.klljs.domain.campaign.dto.CampaignRenameResponse; import com.shinhan.klljs.domain.campaign.dto.TeamCampaignDetailResponse; import com.shinhan.klljs.domain.campaign.dto.TeamCampaignListResponse; import com.shinhan.klljs.domain.campaign.dto.TeamCampaignSort; @@ -13,7 +15,9 @@ import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.web.bind.annotation.DeleteMapping; 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.RequestBody; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -56,6 +60,20 @@ public ApiResponse getCampaignDetail( return ApiResponse.onSuccess(GeneralSuccessCode.OK, response); } + @Override + @PatchMapping("/api/v1/teams/{teamId}/campaigns/{campaignId}") + public ApiResponse renameCampaign( + @AuthenticationPrincipal Jwt jwt, + @PathVariable Long teamId, + @PathVariable Long campaignId, + @RequestBody CampaignRenameRequest request + ) { + Long userId = Long.valueOf(jwt.getSubject()); + CampaignRenameResponse response = + teamCampaignCommandService.renameCampaign(userId, teamId, campaignId, request.campaignName()); + return ApiResponse.onSuccess(GeneralSuccessCode.OK, response); + } + @Override @DeleteMapping("/api/v1/teams/{teamId}/campaigns/{campaignId}") public ApiResponse deleteCampaign( diff --git a/src/main/java/com/shinhan/klljs/domain/campaign/controller/TeamCampaignControllerDocs.java b/src/main/java/com/shinhan/klljs/domain/campaign/controller/TeamCampaignControllerDocs.java index fc5d4b0..861519c 100644 --- a/src/main/java/com/shinhan/klljs/domain/campaign/controller/TeamCampaignControllerDocs.java +++ b/src/main/java/com/shinhan/klljs/domain/campaign/controller/TeamCampaignControllerDocs.java @@ -1,5 +1,7 @@ package com.shinhan.klljs.domain.campaign.controller; +import com.shinhan.klljs.domain.campaign.dto.CampaignRenameRequest; +import com.shinhan.klljs.domain.campaign.dto.CampaignRenameResponse; import com.shinhan.klljs.domain.campaign.dto.TeamCampaignDetailResponse; import com.shinhan.klljs.domain.campaign.dto.TeamCampaignListResponse; import com.shinhan.klljs.domain.campaign.dto.TeamCampaignSort; @@ -17,7 +19,7 @@ * {@link TeamCampaignController}의 Swagger(OpenAPI) 문서 전용 인터페이스. * DashboardCampaignControllerDocs와 같은 이유로 매핑 애노테이션과 문서화 애노테이션을 분리한다. */ -@Tag(name = "캠페인 페이지", description = "팀 캠페인 목록/상세/삭제 API") +@Tag(name = "캠페인 페이지", description = "팀 캠페인 목록/상세/수정/삭제 API") public interface TeamCampaignControllerDocs { @Operation( @@ -140,6 +142,48 @@ com.shinhan.klljs.global.apiPayload.ApiResponse getC Long campaignId ); + @Operation( + summary = "캠페인명 수정", + description = """ + 캠페인명만 바꾼다 (campaign-page-api-spec.md 6-1절). 브랜드명·집행기간·목표 + 송출 횟수·소재 등 나머지 필드는 이번 API 범위가 아니다 - 재등록·전체 수정 + 기능은 여전히 없다. 삭제와 마찬가지로 상태(집행 전/중/완료)와 무관하게 항상 + 허용한다. + + ### 접근 권한 + 팀의 `ACTIVE` `OWNER`/`ADMIN`만 가능하다 (캠페인 등록은 `MEMBER`도 가능하지만, + 수정은 삭제와 동일한 기준으로 `MEMBER`는 403). + """ + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "수정 성공", + content = @Content(mediaType = "application/json", examples = @ExampleObject(value = """ + { + "isSuccess": true, + "code": "COMMON_200_001", + "message": "성공적으로 요청을 처리했습니다.", + "result": { + "campaignId": 31, + "campaignName": "나이키 썸머 프로모션 2026" + } + } + """)) + ), + @ApiResponse(responseCode = "400", description = "`CAMPAIGN_400_001`: 캠페인명 누락 또는 30자 초과"), + @ApiResponse(responseCode = "403", description = "요청자가 이 팀 소속이 아님 (`TEAM_403_001`) 또는 `MEMBER`임 (`TEAM_403_003`)"), + @ApiResponse(responseCode = "404", description = "팀이 없음 (`TEAM_404_001`) 또는 캠페인이 없거나 이 팀 소유가 아님 (`CAMPAIGN_404_001`)") + }) + com.shinhan.klljs.global.apiPayload.ApiResponse renameCampaign( + @Parameter(hidden = true) Jwt jwt, + @Parameter(description = "팀 ID", example = "1") + Long teamId, + @Parameter(description = "캠페인 ID", example = "31") + Long campaignId, + CampaignRenameRequest request + ); + @Operation( summary = "캠페인 삭제", description = """ diff --git a/src/main/java/com/shinhan/klljs/domain/campaign/dto/CampaignRenameRequest.java b/src/main/java/com/shinhan/klljs/domain/campaign/dto/CampaignRenameRequest.java new file mode 100644 index 0000000..860e836 --- /dev/null +++ b/src/main/java/com/shinhan/klljs/domain/campaign/dto/CampaignRenameRequest.java @@ -0,0 +1,9 @@ +package com.shinhan.klljs.domain.campaign.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record CampaignRenameRequest( + @Schema(description = "캠페인명", example = "나이키 썸머 프로모션 2026") + String campaignName +) { +} diff --git a/src/main/java/com/shinhan/klljs/domain/campaign/dto/CampaignRenameResponse.java b/src/main/java/com/shinhan/klljs/domain/campaign/dto/CampaignRenameResponse.java new file mode 100644 index 0000000..aa7110e --- /dev/null +++ b/src/main/java/com/shinhan/klljs/domain/campaign/dto/CampaignRenameResponse.java @@ -0,0 +1,13 @@ +package com.shinhan.klljs.domain.campaign.dto; + +import com.shinhan.klljs.domain.campaign.entity.Campaign; + +public record CampaignRenameResponse( + Long campaignId, + String campaignName +) { + + public static CampaignRenameResponse of(Campaign campaign) { + return new CampaignRenameResponse(campaign.getId(), campaign.getCampaignName()); + } +} diff --git a/src/main/java/com/shinhan/klljs/domain/campaign/entity/Campaign.java b/src/main/java/com/shinhan/klljs/domain/campaign/entity/Campaign.java index 63dd10a..15d199e 100644 --- a/src/main/java/com/shinhan/klljs/domain/campaign/entity/Campaign.java +++ b/src/main/java/com/shinhan/klljs/domain/campaign/entity/Campaign.java @@ -125,4 +125,8 @@ public void markRegistrationFailed(String reason) { public void changeStatus(CampaignStatus status) { this.status = status; } + + public void rename(String campaignName) { + this.campaignName = campaignName; + } } diff --git a/src/main/java/com/shinhan/klljs/domain/campaign/service/TeamCampaignCommandService.java b/src/main/java/com/shinhan/klljs/domain/campaign/service/TeamCampaignCommandService.java index e7c5797..284ae9b 100644 --- a/src/main/java/com/shinhan/klljs/domain/campaign/service/TeamCampaignCommandService.java +++ b/src/main/java/com/shinhan/klljs/domain/campaign/service/TeamCampaignCommandService.java @@ -1,5 +1,6 @@ package com.shinhan.klljs.domain.campaign.service; +import com.shinhan.klljs.domain.campaign.dto.CampaignRenameResponse; import com.shinhan.klljs.domain.campaign.entity.Campaign; import com.shinhan.klljs.domain.campaign.exception.CampaignErrorCode; import com.shinhan.klljs.domain.campaign.repository.CampaignRepository; @@ -28,10 +29,44 @@ @RequiredArgsConstructor public class TeamCampaignCommandService { + private static final int MAX_CAMPAIGN_NAME_LENGTH = 30; + private final TeamRepository teamRepository; private final TeamMemberRepository teamMemberRepository; private final CampaignRepository campaignRepository; + /** OWNER/ADMIN만 캠페인명을 바꿀 수 있다 - 삭제와 동일한 권한 기준. */ + @Transactional + public CampaignRenameResponse renameCampaign(Long userId, Long teamId, Long campaignId, String campaignName) { + teamRepository.findById(teamId) + .orElseThrow(() -> new GeneralException(TeamErrorCode.TEAM_NOT_FOUND)); + + TeamMember requester = teamMemberRepository.findByUserIdAndTeamIdAndStatus( + userId, teamId, TeamMemberStatus.ACTIVE + ) + .orElseThrow(() -> new GeneralException(TeamErrorCode.TEAM_ACCESS_DENIED)); + if (requester.getRole() == TeamMemberRole.MEMBER) { + throw new GeneralException(TeamErrorCode.CAMPAIGN_MANAGEMENT_FORBIDDEN); + } + + if (campaignName == null || campaignName.isBlank()) { + throw new GeneralException(CampaignErrorCode.INVALID_CAMPAIGN_REQUEST); + } + String trimmedName = campaignName.trim(); + if (trimmedName.length() > MAX_CAMPAIGN_NAME_LENGTH) { + throw new GeneralException(CampaignErrorCode.INVALID_CAMPAIGN_REQUEST); + } + + Campaign campaign = campaignRepository.findByIdForUpdate(campaignId) + .orElseThrow(() -> new GeneralException(CampaignErrorCode.CAMPAIGN_NOT_FOUND)); + if (!campaign.getTeam().getId().equals(teamId)) { + throw new GeneralException(CampaignErrorCode.CAMPAIGN_NOT_FOUND); + } + + campaign.rename(trimmedName); + return CampaignRenameResponse.of(campaign); + } + @Transactional public void deleteCampaign(Long userId, Long teamId, Long campaignId) { teamRepository.findById(teamId) diff --git a/src/main/java/com/shinhan/klljs/domain/team/exception/TeamErrorCode.java b/src/main/java/com/shinhan/klljs/domain/team/exception/TeamErrorCode.java index 026e77c..636de17 100644 --- a/src/main/java/com/shinhan/klljs/domain/team/exception/TeamErrorCode.java +++ b/src/main/java/com/shinhan/klljs/domain/team/exception/TeamErrorCode.java @@ -16,7 +16,7 @@ public enum TeamErrorCode implements BaseErrorCode { CAMPAIGN_MANAGEMENT_FORBIDDEN( HttpStatus.FORBIDDEN, "TEAM_403_003", - "캠페인을 등록하거나 삭제할 권한이 없습니다." + "캠페인을 삭제하거나 수정할 권한이 없습니다." ), TEAM_SETTINGS_FORBIDDEN(HttpStatus.FORBIDDEN, "TEAM_403_004", "팀 설정을 변경할 권한이 없습니다."), diff --git a/src/test/java/com/shinhan/klljs/domain/campaign/service/TeamCampaignCommandServiceTest.java b/src/test/java/com/shinhan/klljs/domain/campaign/service/TeamCampaignCommandServiceTest.java index 4d94673..7711aaa 100644 --- a/src/test/java/com/shinhan/klljs/domain/campaign/service/TeamCampaignCommandServiceTest.java +++ b/src/test/java/com/shinhan/klljs/domain/campaign/service/TeamCampaignCommandServiceTest.java @@ -1,5 +1,6 @@ package com.shinhan.klljs.domain.campaign.service; +import com.shinhan.klljs.domain.campaign.dto.CampaignRenameResponse; import com.shinhan.klljs.domain.campaign.entity.Campaign; import com.shinhan.klljs.domain.campaign.entity.CampaignCreativeType; import com.shinhan.klljs.domain.campaign.entity.CampaignStatus; @@ -132,6 +133,119 @@ void deleteCampaign_leavesReferencingVisionSummaryRowWithCampaignIdSetToNull() { assertThat(reloaded.getCampaign()).isNull(); } + @Test + void renameCampaign_ownerCanRename() { + Fixture fixture = persistFixture(TeamMemberRole.OWNER); + + CampaignRenameResponse response = service.renameCampaign( + fixture.userId(), fixture.teamId(), fixture.campaignId(), "새 캠페인명"); + entityManager.flush(); + + assertThat(response.campaignId()).isEqualTo(fixture.campaignId()); + assertThat(response.campaignName()).isEqualTo("새 캠페인명"); + assertThat(entityManager.find(Campaign.class, fixture.campaignId()).getCampaignName()) + .isEqualTo("새 캠페인명"); + } + + @Test + void renameCampaign_adminCanRename() { + Fixture fixture = persistFixture(TeamMemberRole.ADMIN); + + service.renameCampaign(fixture.userId(), fixture.teamId(), fixture.campaignId(), "관리자가 바꾼 캠페인명"); + entityManager.flush(); + + assertThat(entityManager.find(Campaign.class, fixture.campaignId()).getCampaignName()) + .isEqualTo("관리자가 바꾼 캠페인명"); + } + + @Test + void renameCampaign_memberIsForbidden() { + Fixture fixture = persistFixture(TeamMemberRole.MEMBER); + String originalName = entityManager.find(Campaign.class, fixture.campaignId()).getCampaignName(); + + assertThatThrownBy(() -> + service.renameCampaign(fixture.userId(), fixture.teamId(), fixture.campaignId(), "멤버가 시도한 캠페인명")) + .isInstanceOfSatisfying(GeneralException.class, exception -> + assertThat(exception.getErrorCode()).isEqualTo(TeamErrorCode.CAMPAIGN_MANAGEMENT_FORBIDDEN)); + entityManager.flush(); + assertThat(entityManager.find(Campaign.class, fixture.campaignId()).getCampaignName()) + .isEqualTo(originalName); + } + + @Test + void renameCampaign_throwsAccessDeniedForNonMember() { + Fixture fixture = persistFixture(TeamMemberRole.OWNER); + User outsider = User.builder().displayName("외부인").status(UserStatus.ACTIVE).build(); + entityManager.persist(outsider); + entityManager.flush(); + + assertThatThrownBy(() -> + service.renameCampaign(outsider.getId(), fixture.teamId(), fixture.campaignId(), "새 캠페인명")) + .isInstanceOfSatisfying(GeneralException.class, exception -> + assertThat(exception.getErrorCode()).isEqualTo(TeamErrorCode.TEAM_ACCESS_DENIED)); + } + + @Test + void renameCampaign_throwsTeamNotFoundForNonExistentTeam() { + Fixture fixture = persistFixture(TeamMemberRole.OWNER); + + assertThatThrownBy(() -> + service.renameCampaign(fixture.userId(), 999_999L, fixture.campaignId(), "새 캠페인명")) + .isInstanceOfSatisfying(GeneralException.class, exception -> + assertThat(exception.getErrorCode()).isEqualTo(TeamErrorCode.TEAM_NOT_FOUND)); + } + + @Test + void renameCampaign_throwsCampaignNotFoundWhenCampaignDoesNotExist() { + Fixture fixture = persistFixture(TeamMemberRole.OWNER); + + assertThatThrownBy(() -> + service.renameCampaign(fixture.userId(), fixture.teamId(), 999_999L, "새 캠페인명")) + .isInstanceOfSatisfying(GeneralException.class, exception -> + assertThat(exception.getErrorCode()).isEqualTo(CampaignErrorCode.CAMPAIGN_NOT_FOUND)); + } + + @Test + void renameCampaign_throwsCampaignNotFoundWhenCampaignBelongsToDifferentTeam() { + Fixture fixture = persistFixture(TeamMemberRole.OWNER); + Fixture otherTeam = persistFixture(TeamMemberRole.OWNER); + + assertThatThrownBy(() -> + service.renameCampaign(fixture.userId(), fixture.teamId(), otherTeam.campaignId(), "새 캠페인명")) + .isInstanceOfSatisfying(GeneralException.class, exception -> + assertThat(exception.getErrorCode()).isEqualTo(CampaignErrorCode.CAMPAIGN_NOT_FOUND)); + } + + @Test + void renameCampaign_rejectsBlankName() { + Fixture fixture = persistFixture(TeamMemberRole.OWNER); + + assertThatThrownBy(() -> + service.renameCampaign(fixture.userId(), fixture.teamId(), fixture.campaignId(), " ")) + .isInstanceOfSatisfying(GeneralException.class, exception -> + assertThat(exception.getErrorCode()).isEqualTo(CampaignErrorCode.INVALID_CAMPAIGN_REQUEST)); + } + + @Test + void renameCampaign_rejectsNameOverMaxLength() { + Fixture fixture = persistFixture(TeamMemberRole.OWNER); + + assertThatThrownBy(() -> + service.renameCampaign(fixture.userId(), fixture.teamId(), fixture.campaignId(), "A".repeat(31))) + .isInstanceOfSatisfying(GeneralException.class, exception -> + assertThat(exception.getErrorCode()).isEqualTo(CampaignErrorCode.INVALID_CAMPAIGN_REQUEST)); + } + + @Test + void renameCampaign_trimsEdgeWhitespace() { + Fixture fixture = persistFixture(TeamMemberRole.OWNER); + + CampaignRenameResponse response = service.renameCampaign( + fixture.userId(), fixture.teamId(), fixture.campaignId(), " 공백 캠페인명 "); + + assertThat(response.campaignName()).isEqualTo("공백 캠페인명"); + } + private VisionSummary5s persistVisionSummary(Fixture fixture) { Campaign campaign = entityManager.getReference(Campaign.class, fixture.campaignId()); MediaUnit mediaUnit = entityManager.getReference(MediaUnit.class, fixture.mediaUnitId()); From 1fd808a4bb71c939d13e9c3874aa6b2978f72185 Mon Sep 17 00:00:00 2001 From: LeeJeongHeon02 Date: Tue, 21 Jul 2026 14:53:36 +0900 Subject: [PATCH 4/4] =?UTF-8?q?review:=20=EB=AC=B8=EC=84=9C=20=EC=A0=95?= =?UTF-8?q?=ED=95=A9=EC=84=B1=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=B3=B4=EA=B0=95=20(=EC=85=80=ED=94=84=20=EB=A6=AC=EB=B7=B0?= =?UTF-8?q?=20=EB=B0=98=EC=98=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - campaign-registration-api-spec.md의 CAMPAIGN_MANAGEMENT_FORBIDDEN 메시지 인용이 이전 값("등록하거나 삭제")으로 남아있어 campaign-page-api-spec.md와 모순되고 있었다 - 실제 코드값("삭제하거나 수정")에 맞게 정정. - TeamCampaignCommandService 클래스 Javadoc이 여전히 "삭제 API"만 언급하고 있어 캠페인명 수정 기능 추가를 반영해 갱신. - renameCampaign_trimsEdgeWhitespace가 응답 DTO만 확인하고 있던 걸, 팀명 trim 테스트와 동일하게 flush+clear 후 DB 재조회로 실제 반영 여부까지 확인하도록 보강. Co-Authored-By: Claude Sonnet 5 --- docs/campaign-registration-api-spec.md | 10 +++++++--- .../campaign/service/TeamCampaignCommandService.java | 6 ++++-- .../service/TeamCampaignCommandServiceTest.java | 4 ++++ 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/docs/campaign-registration-api-spec.md b/docs/campaign-registration-api-spec.md index c661746..0e63ace 100644 --- a/docs/campaign-registration-api-spec.md +++ b/docs/campaign-registration-api-spec.md @@ -82,11 +82,15 @@ SQS 메시지 한 건은 해당 이벤트 날짜에 캠페인이 존재하는 메시지가 상황과 안 맞는다. 캠페인 등록·삭제 권한에는 `TeamErrorCode`의 아래 전용 값을 사용한다: ```java -CAMPAIGN_MANAGEMENT_FORBIDDEN(HttpStatus.FORBIDDEN, "TEAM_403_003", "캠페인을 등록하거나 삭제할 권한이 없습니다.") +CAMPAIGN_MANAGEMENT_FORBIDDEN(HttpStatus.FORBIDDEN, "TEAM_403_003", "캠페인을 삭제하거나 수정할 권한이 없습니다.") ``` -`campaign-page-api-spec.md`의 캠페인 삭제 API도 같은 상황(`MEMBER`가 삭제 시도)에 같은 -코드를 쓴다 — 두 문서가 같은 에러 코드를 공유하므로 한쪽에서만 정의하면 된다. +`campaign-page-api-spec.md`의 캠페인 삭제·수정 API도 같은 상황(`MEMBER`가 삭제·수정 시도)에 +같은 코드를 쓴다 — 여러 문서가 같은 에러 코드를 공유하므로 한쪽에서만 정의하면 된다. + +(메시지는 이후 `campaign-page-api-spec.md` 2-2절에서 실제 등록 흐름은 `MEMBER`도 허용되어 +이 코드가 막아온 적이 없다는 게 확인되면서 "삭제하거나 수정"으로 정정됐다 — 최신 값은 항상 +`TeamErrorCode.java`를 기준으로 한다.) --- diff --git a/src/main/java/com/shinhan/klljs/domain/campaign/service/TeamCampaignCommandService.java b/src/main/java/com/shinhan/klljs/domain/campaign/service/TeamCampaignCommandService.java index 284ae9b..eebda43 100644 --- a/src/main/java/com/shinhan/klljs/domain/campaign/service/TeamCampaignCommandService.java +++ b/src/main/java/com/shinhan/klljs/domain/campaign/service/TeamCampaignCommandService.java @@ -16,13 +16,15 @@ import org.springframework.transaction.annotation.Transactional; /** - * 팀 "캠페인 페이지"의 삭제 API를 처리한다 (docs/campaign-page-api-spec.md 6절). + * 팀 "캠페인 페이지"의 캠페인명 수정·삭제 API를 처리한다 + * (docs/campaign-page-api-spec.md 6-1절, 6절). * - * 하드 삭제라 상태 전이는 없지만, 같은 캠페인에 대한 동시 삭제 요청은 직렬화해야 한다 - + * 삭제는 하드 삭제라 상태 전이는 없지만, 같은 캠페인에 대한 동시 삭제 요청은 직렬화해야 한다 - * 잠금 없이 findById로 읽으면 두 요청이 같은 행을 동시에 읽어 둘 다 삭제를 시도할 수 있고, * 나중에 커밋하는 쪽은 이미 지워진 행에 DELETE를 실행해 Hibernate가 stale-state 예외 * (0 rows affected)를 던진다 - findByIdForUpdate로 뒤 트랜잭션을 앞 트랜잭션의 커밋 * 이후로 미뤄서, 뒤 트랜잭션이 다시 조회했을 때 정상적으로 CAMPAIGN_NOT_FOUND(404)를 받게 한다. + * 수정도 같은 이유로 findByIdForUpdate를 재사용해 동시 수정/삭제 요청을 직렬화한다. * vision_summary_5s.campaign_id는 DB의 ON DELETE SET NULL이 알아서 처리한다. */ @Service diff --git a/src/test/java/com/shinhan/klljs/domain/campaign/service/TeamCampaignCommandServiceTest.java b/src/test/java/com/shinhan/klljs/domain/campaign/service/TeamCampaignCommandServiceTest.java index 7711aaa..547c9cf 100644 --- a/src/test/java/com/shinhan/klljs/domain/campaign/service/TeamCampaignCommandServiceTest.java +++ b/src/test/java/com/shinhan/klljs/domain/campaign/service/TeamCampaignCommandServiceTest.java @@ -242,8 +242,12 @@ void renameCampaign_trimsEdgeWhitespace() { CampaignRenameResponse response = service.renameCampaign( fixture.userId(), fixture.teamId(), fixture.campaignId(), " 공백 캠페인명 "); + entityManager.flush(); + entityManager.clear(); assertThat(response.campaignName()).isEqualTo("공백 캠페인명"); + assertThat(entityManager.find(Campaign.class, fixture.campaignId()).getCampaignName()) + .isEqualTo("공백 캠페인명"); } private VisionSummary5s persistVisionSummary(Fixture fixture) {