Skip to content

Commit 6f14800

Browse files
authored
Merge pull request #1381 from Moadong/feature/#1370-improve-promotion-MOA-777
[feature] 홍보 게시글 삭제 기능 및 소프트 딜리트 적용
2 parents 1f74f5d + 965b573 commit 6f14800

11 files changed

Lines changed: 522 additions & 13 deletions

File tree

backend/src/main/java/moadong/club/controller/PromotionArticleController.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import org.springframework.http.ResponseEntity;
1414
import org.springframework.security.access.prepost.PreAuthorize;
1515
import org.springframework.validation.annotation.Validated;
16+
import org.springframework.web.bind.annotation.DeleteMapping;
1617
import org.springframework.web.bind.annotation.GetMapping;
1718
import org.springframework.web.bind.annotation.PathVariable;
1819
import org.springframework.web.bind.annotation.PostMapping;
@@ -56,4 +57,13 @@ public ResponseEntity<?> updatePromotionArticle(
5657
promotionArticleService.updatePromotionArticle(articleId, request);
5758
return Response.ok("홍보 게시글이 수정되었습니다.");
5859
}
60+
61+
@DeleteMapping("/{articleId}")
62+
@Operation(summary = "홍보 게시글 삭제", description = "기존 홍보 게시글을 삭제합니다.")
63+
@PreAuthorize("hasRole('DEVELOPER')")
64+
@SecurityRequirement(name = "BearerAuth")
65+
public ResponseEntity<?> deletePromotionArticle(@PathVariable String articleId) {
66+
promotionArticleService.deletePromotionArticle(articleId);
67+
return Response.ok("홍보 게시글이 삭제되었습니다.", null);
68+
}
5969
}

backend/src/main/java/moadong/club/entity/PromotionArticle.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ public class PromotionArticle {
4040
@Builder.Default
4141
private Instant createdAt = Instant.now();
4242

43+
@Builder.Default
44+
private boolean deleted = false;
45+
46+
private Instant deletedAt;
47+
4348
public void update(PromotionArticleUpdateRequest request, String clubName) {
4449
this.clubId = request.clubId();
4550
this.clubName = clubName;
@@ -50,4 +55,9 @@ public void update(PromotionArticleUpdateRequest request, String clubName) {
5055
this.description = request.description();
5156
this.images = request.images();
5257
}
58+
59+
public void softDelete() {
60+
this.deleted = true;
61+
this.deletedAt = Instant.now();
62+
}
5363
}

backend/src/main/java/moadong/club/repository/PromotionArticleRepository.java

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,24 @@
22

33
import moadong.club.entity.PromotionArticle;
44
import org.springframework.data.mongodb.repository.MongoRepository;
5+
import org.springframework.data.mongodb.repository.Query;
56
import org.springframework.stereotype.Repository;
67

78
import java.util.List;
9+
import java.util.Optional;
810

911
@Repository
1012
public interface PromotionArticleRepository extends MongoRepository<PromotionArticle, String> {
1113

12-
List<PromotionArticle> findAllByOrderByCreatedAtDesc();
14+
@Query(value = "{ 'deleted': { $ne: true } }", sort = "{ 'createdAt': -1 }")
15+
List<PromotionArticle> findAllActiveOrderByCreatedAtDesc();
1316

14-
List<PromotionArticle> findByClubIdOrderByCreatedAtDesc(String clubId);
17+
@Query(value = "{ 'clubId': ?0, 'deleted': { $ne: true } }", sort = "{ 'createdAt': -1 }")
18+
List<PromotionArticle> findActiveByClubIdOrderByCreatedAtDesc(String clubId);
19+
20+
@Query("{ '_id': ?0, 'deleted': { $ne: true } }")
21+
Optional<PromotionArticle> findActiveById(String id);
22+
23+
@Query(value = "{ '_id': ?0, 'deleted': { $ne: true } }", exists = true)
24+
boolean existsActiveById(String id);
1525
}

backend/src/main/java/moadong/club/service/PromotionArticleService.java

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ public class PromotionArticleService {
2727
private final ClubRepository clubRepository;
2828

2929
public PromotionArticleResponse getPromotionArticles() {
30-
List<PromotionArticleDto> articles = promotionArticleRepository.findAllByOrderByCreatedAtDesc()
30+
List<PromotionArticleDto> articles = promotionArticleRepository.findAllActiveOrderByCreatedAtDesc()
3131
.stream()
3232
.map(PromotionArticleDto::from)
3333
.toList();
@@ -55,14 +55,23 @@ public PromotionArticleCreateResultDto createPromotionArticle(PromotionArticleCr
5555

5656
@Transactional
5757
public void updatePromotionArticle(String articleId, PromotionArticleUpdateRequest request) {
58-
PromotionArticle article = promotionArticleRepository.findById(articleId)
58+
PromotionArticle article = promotionArticleRepository.findActiveById(articleId)
5959
.orElseThrow(() -> new RestApiException(ErrorCode.PROMOTION_ARTICLE_NOT_FOUND));
6060
Club club = getClub(request.clubId());
6161

6262
article.update(request, club.getName());
6363
promotionArticleRepository.save(article);
6464
}
6565

66+
@Transactional
67+
public void deletePromotionArticle(String articleId) {
68+
PromotionArticle article = promotionArticleRepository.findActiveById(articleId)
69+
.orElseThrow(() -> new RestApiException(ErrorCode.PROMOTION_ARTICLE_NOT_FOUND));
70+
71+
article.softDelete();
72+
promotionArticleRepository.save(article);
73+
}
74+
6675
private Club getClub(String clubId) {
6776
ObjectId clubObjectId = ObjectIdConverter.convertString(clubId);
6877
return clubRepository.findClubById(clubObjectId)

backend/src/main/java/moadong/media/service/PromotionImageUploadService.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ public PromotionImageUploadResponse upload(String articleId, MultipartFile file)
3535
}
3636

3737
private void ensurePromotionArticleExists(String articleId) {
38-
if (!promotionArticleRepository.existsById(articleId)) {
38+
if (!promotionArticleRepository.existsActiveById(articleId)) {
3939
throw new RestApiException(ErrorCode.PROMOTION_ARTICLE_NOT_FOUND);
4040
}
4141
}

backend/src/main/resources/static/dev/index.html

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,7 @@ <h3>선택된 게시글 편집</h3>
345345
<div class="promotion-actions">
346346
<button type="button" id="btnSavePromotion">저장</button>
347347
<button type="button" id="btnResetPromotion">원본으로 되돌리기</button>
348+
<button type="button" id="btnDeletePromotion">삭제</button>
348349
<button type="button" id="btnClearPromotionSelection">선택 해제</button>
349350
</div>
350351
<p id="promotionHelpText" class="promotion-help">기존 게시글의 `clubId`는 읽기 전용입니다. 저장 후 목록을 다시 조회해 서버 기준 최신값으로 동기화합니다.</p>
@@ -517,6 +518,7 @@ <h2>이미지 변환 배치 (전체 동아리)</h2>
517518
let promotionIsLoading = false;
518519
let promotionIsSaving = false;
519520
let promotionIsUploading = false;
521+
let promotionIsDeleting = false;
520522

521523
function getToken() { return sessionStorage.getItem('devPortalToken') || ''; }
522524
function setToken(t) { sessionStorage.setItem('devPortalToken', t); }
@@ -1084,7 +1086,7 @@ <h2>이미지 변환 배치 (전체 동아리)</h2>
10841086
const hasSelection = !!promotionSelectedArticleId;
10851087
const isCreateMode = isPromotionCreateMode();
10861088
const hasEditorTarget = hasSelection || isCreateMode;
1087-
const busy = promotionIsLoading || promotionIsSaving || promotionIsUploading;
1089+
const busy = promotionIsLoading || promotionIsSaving || promotionIsUploading || promotionIsDeleting;
10881090
const dirty = isPromotionDirty();
10891091
const badge = document.getElementById('promotionEditingBadge');
10901092
const summary = document.getElementById('promotionSelectionSummary');
@@ -1097,6 +1099,7 @@ <h2>이미지 변환 배치 (전체 동아리)</h2>
10971099
const btnCreate = document.getElementById('btnCreatePromotion');
10981100
const btnSave = document.getElementById('btnSavePromotion');
10991101
const btnReset = document.getElementById('btnResetPromotion');
1102+
const btnDelete = document.getElementById('btnDeletePromotion');
11001103
const btnClear = document.getElementById('btnClearPromotionSelection');
11011104

11021105
PROMOTION_FORM_IDS.forEach((id) => {
@@ -1111,10 +1114,12 @@ <h2>이미지 변환 배치 (전체 동아리)</h2>
11111114
btnCreate.disabled = busy;
11121115
btnSave.disabled = busy || !hasEditorTarget;
11131116
btnReset.disabled = busy || !hasEditorTarget || !dirty;
1117+
btnDelete.disabled = busy || !hasSelection || isCreateMode;
11141118
btnClear.disabled = busy || !hasEditorTarget;
11151119
btnSave.textContent = promotionIsSaving
11161120
? (isCreateMode ? '생성 중...' : '저장 중...')
11171121
: (isCreateMode ? '생성' : '저장');
1122+
btnDelete.textContent = promotionIsDeleting ? '삭제 중...' : '삭제';
11181123

11191124
if (isCreateMode) {
11201125
badge.textContent = dirty ? '새 게시글 작성 중 · 저장되지 않은 변경 있음' : '새 게시글 작성 중';
@@ -1326,7 +1331,7 @@ <h2>이미지 변환 배치 (전체 동아리)</h2>
13261331
const removeBtn = document.createElement('button');
13271332
removeBtn.type = 'button';
13281333
removeBtn.textContent = '제거';
1329-
removeBtn.disabled = promotionIsLoading || promotionIsSaving || promotionIsUploading || (!promotionSelectedArticleId && !isPromotionCreateMode());
1334+
removeBtn.disabled = promotionIsLoading || promotionIsSaving || promotionIsUploading || promotionIsDeleting || (!promotionSelectedArticleId && !isPromotionCreateMode());
13301335
removeBtn.onclick = () => {
13311336
removePromotionImageAt(index);
13321337
};
@@ -1463,6 +1468,15 @@ <h2>이미지 변환 배치 (전체 동아리)</h2>
14631468
return { res, data };
14641469
}
14651470

1471+
async function deletePromotionArticleRequest(articleId) {
1472+
const res = await fetch(API_BASE + '/api/promotion/' + encodeURIComponent(articleId), {
1473+
method: 'DELETE',
1474+
headers: headers()
1475+
});
1476+
const data = await readJsonOrEmpty(res);
1477+
return { res, data };
1478+
}
1479+
14661480
function clearPromotionState() {
14671481
promotionArticles = [];
14681482
promotionEditorMode = 'edit';
@@ -1472,6 +1486,7 @@ <h2>이미지 변환 배치 (전체 동아리)</h2>
14721486
promotionIsLoading = false;
14731487
promotionIsSaving = false;
14741488
promotionIsUploading = false;
1489+
promotionIsDeleting = false;
14751490
document.getElementById('promotionListLoading').classList.add('hidden');
14761491
document.getElementById('promotionImageUploadFile').value = '';
14771492
clearPromotionBanner();
@@ -1537,6 +1552,67 @@ <h2>이미지 변환 배치 (전체 동아리)</h2>
15371552
clearPromotionSelection();
15381553
};
15391554

1555+
document.getElementById('btnDeletePromotion').onclick = async () => {
1556+
const selectedId = promotionSelectedArticleId;
1557+
const selectedArticle = promotionArticles.find((article) => article.id === selectedId);
1558+
if (!selectedId || !selectedArticle) {
1559+
showPromotionSaveResult(false, '삭제할 홍보 게시글을 먼저 선택하세요.');
1560+
return;
1561+
}
1562+
1563+
const articleLabel = selectedArticle.title ? '"' + selectedArticle.title + '"' : '선택한 홍보 게시글';
1564+
const confirmMessage = isPromotionDirty()
1565+
? '저장되지 않은 변경사항이 있습니다. ' + articleLabel + '을(를) 정말 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.'
1566+
: articleLabel + '을(를) 정말 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.';
1567+
if (!confirm(confirmMessage)) return;
1568+
1569+
promotionIsDeleting = true;
1570+
updatePromotionEditorState();
1571+
clearPromotionBanner();
1572+
hidePromotionSaveResult();
1573+
1574+
try {
1575+
const { res, data } = await deletePromotionArticleRequest(selectedId);
1576+
if (res.status === 403) {
1577+
showPromotionSaveResult(false, '개발자 계정으로 로그인하세요.');
1578+
return;
1579+
}
1580+
if (res.status === 404) {
1581+
if (data.statuscode === '902-1') {
1582+
promotionArticles = promotionArticles.filter((article) => article.id !== selectedId);
1583+
clearPromotionSelection({ keepMessage: true });
1584+
showPromotionSaveResult(false, data.message || '선택한 홍보 게시글을 찾을 수 없습니다. 목록을 새로고침하세요.');
1585+
} else {
1586+
showPromotionSaveResult(false, data.message || '홍보 게시글 삭제 실패 (HTTP ' + res.status + ')');
1587+
}
1588+
return;
1589+
}
1590+
if (!res.ok) {
1591+
showPromotionSaveResult(false, data.message || '홍보 게시글 삭제 실패 (HTTP ' + res.status + ')');
1592+
return;
1593+
}
1594+
1595+
const deleteSuccessMessage = getApiSuccessMessage(data, '홍보 게시글이 삭제되었습니다.');
1596+
const syncResult = await reloadPromotionList({ keepMessage: true });
1597+
if (!syncResult.ok) {
1598+
clearPromotionSelection({ keepMessage: true });
1599+
showPromotionSaveResult(true, deleteSuccessMessage);
1600+
setPromotionBanner('삭제는 완료되었지만 목록 동기화에 실패했습니다. 목록을 새로고침해 확인하세요.', 'warn');
1601+
return;
1602+
}
1603+
1604+
clearPromotionSelection({ keepMessage: true });
1605+
clearPromotionBanner();
1606+
showPromotionSaveResult(true, deleteSuccessMessage);
1607+
showToast('홍보 게시글 삭제 완료', 'success');
1608+
} catch (e) {
1609+
showPromotionSaveResult(false, e.message || '요청 실패');
1610+
} finally {
1611+
promotionIsDeleting = false;
1612+
updatePromotionEditorState();
1613+
}
1614+
};
1615+
15401616
function extractCreatedPromotionId(data) {
15411617
return data?.data?.articleId || data?.data?.id || data?.articleId || data?.id || '';
15421618
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package moadong.club.controller;
2+
3+
import moadong.club.service.PromotionArticleService;
4+
import moadong.global.payload.Response;
5+
import org.junit.jupiter.api.Test;
6+
import org.junit.jupiter.api.extension.ExtendWith;
7+
import org.mockito.InjectMocks;
8+
import org.mockito.Mock;
9+
import org.mockito.junit.jupiter.MockitoExtension;
10+
import org.springframework.http.ResponseEntity;
11+
12+
import static org.junit.jupiter.api.Assertions.assertEquals;
13+
import static org.mockito.Mockito.verify;
14+
15+
@ExtendWith(MockitoExtension.class)
16+
class PromotionArticleControllerTest {
17+
18+
@Mock
19+
private PromotionArticleService promotionArticleService;
20+
21+
@InjectMocks
22+
private PromotionArticleController promotionArticleController;
23+
24+
@Test
25+
void 홍보게시글을_삭제하면_성공응답을_반환한다() {
26+
ResponseEntity<?> response = promotionArticleController.deletePromotionArticle("article-1");
27+
28+
assertEquals(200, response.getStatusCode().value());
29+
@SuppressWarnings("unchecked")
30+
Response<Object> body = (Response<Object>) response.getBody();
31+
assertEquals("홍보 게시글이 삭제되었습니다.", body.message());
32+
verify(promotionArticleService).deletePromotionArticle("article-1");
33+
}
34+
}

backend/src/test/java/moadong/club/service/PromotionArticleServiceTest.java

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import static org.junit.jupiter.api.Assertions.assertEquals;
2525
import static org.junit.jupiter.api.Assertions.assertNotNull;
2626
import static org.junit.jupiter.api.Assertions.assertThrows;
27+
import static org.junit.jupiter.api.Assertions.assertTrue;
2728
import static org.mockito.ArgumentMatchers.any;
2829
import static org.mockito.Mockito.never;
2930
import static org.mockito.Mockito.verify;
@@ -54,7 +55,7 @@ class PromotionArticleServiceTest {
5455
.description("설명")
5556
.images(List.of("image-1"))
5657
.build();
57-
when(promotionArticleRepository.findAllByOrderByCreatedAtDesc()).thenReturn(List.of(article));
58+
when(promotionArticleRepository.findAllActiveOrderByCreatedAtDesc()).thenReturn(List.of(article));
5859

5960
PromotionArticleResponse response = promotionArticleService.getPromotionArticles();
6061

@@ -63,6 +64,15 @@ class PromotionArticleServiceTest {
6364
assertEquals("모아동", response.articles().get(0).clubName());
6465
}
6566

67+
@Test
68+
void 홍보게시글_목록조회시_활성게시글_기준_조회한다() {
69+
when(promotionArticleRepository.findAllActiveOrderByCreatedAtDesc()).thenReturn(List.of());
70+
71+
promotionArticleService.getPromotionArticles();
72+
73+
verify(promotionArticleRepository).findAllActiveOrderByCreatedAtDesc();
74+
}
75+
6676
@Test
6777
void 홍보게시글을_수정한다() {
6878
String clubId = new ObjectId().toHexString();
@@ -86,7 +96,7 @@ class PromotionArticleServiceTest {
8696
"수정 설명",
8797
List.of("new-image-1", "new-image-2")
8898
);
89-
when(promotionArticleRepository.findById("article-1")).thenReturn(Optional.of(article));
99+
when(promotionArticleRepository.findActiveById("article-1")).thenReturn(Optional.of(article));
90100
when(clubRepository.findClubById(new ObjectId(clubId))).thenReturn(Optional.of(Club.builder()
91101
.name("수정 동아리")
92102
.category("category")
@@ -157,12 +167,58 @@ class PromotionArticleServiceTest {
157167
"수정 설명",
158168
List.of("new-image")
159169
);
160-
when(promotionArticleRepository.findById("missing-article")).thenReturn(Optional.empty());
170+
when(promotionArticleRepository.findActiveById("missing-article")).thenReturn(Optional.empty());
161171

162172
RestApiException exception = assertThrows(RestApiException.class,
163173
() -> promotionArticleService.updatePromotionArticle("missing-article", request));
164174

165175
assertEquals(ErrorCode.PROMOTION_ARTICLE_NOT_FOUND, exception.getErrorCode());
166176
verify(clubRepository, never()).findClubById(org.mockito.ArgumentMatchers.any());
167177
}
178+
179+
@Test
180+
void 삭제된_홍보게시글은_수정할_수_없다() {
181+
PromotionArticleUpdateRequest request = new PromotionArticleUpdateRequest(
182+
new ObjectId().toHexString(),
183+
"수정 제목",
184+
"수정 장소",
185+
Instant.parse("2026-04-01T00:00:00Z"),
186+
Instant.parse("2026-04-10T00:00:00Z"),
187+
"수정 설명",
188+
List.of("new-image")
189+
);
190+
when(promotionArticleRepository.findActiveById("deleted-article")).thenReturn(Optional.empty());
191+
192+
RestApiException exception = assertThrows(RestApiException.class,
193+
() -> promotionArticleService.updatePromotionArticle("deleted-article", request));
194+
195+
assertEquals(ErrorCode.PROMOTION_ARTICLE_NOT_FOUND, exception.getErrorCode());
196+
verify(clubRepository, never()).findClubById(org.mockito.ArgumentMatchers.any());
197+
}
198+
199+
@Test
200+
void 홍보게시글을_삭제한다() {
201+
PromotionArticle article = PromotionArticle.builder()
202+
.id("article-1")
203+
.build();
204+
when(promotionArticleRepository.findActiveById("article-1")).thenReturn(Optional.of(article));
205+
206+
promotionArticleService.deletePromotionArticle("article-1");
207+
208+
assertTrue(article.isDeleted());
209+
assertNotNull(article.getDeletedAt());
210+
verify(promotionArticleRepository).save(article);
211+
verify(promotionArticleRepository, never()).deleteById("article-1");
212+
}
213+
214+
@Test
215+
void 삭제대상_홍보게시글이_없으면_예외를_던진다() {
216+
when(promotionArticleRepository.findActiveById("missing-article")).thenReturn(Optional.empty());
217+
218+
RestApiException exception = assertThrows(RestApiException.class,
219+
() -> promotionArticleService.deletePromotionArticle("missing-article"));
220+
221+
assertEquals(ErrorCode.PROMOTION_ARTICLE_NOT_FOUND, exception.getErrorCode());
222+
verify(promotionArticleRepository, never()).deleteById("missing-article");
223+
}
168224
}

0 commit comments

Comments
 (0)