Skip to content

Commit b20e45c

Browse files
authored
Merge pull request #214 from festimap-org/develop
feat : 선택사항 변경 및 회원정보 csv 내보내기
2 parents 924da72 + 813611e commit b20e45c

File tree

8 files changed

+148
-5
lines changed

8 files changed

+148
-5
lines changed

src/main/java/com/halo/eventer/domain/stamp/controller/v2/StampUserAdminController.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
package com.halo.eventer.domain.stamp.controller.v2;
22

3+
import java.io.IOException;
4+
import java.nio.file.Path;
35
import jakarta.validation.Valid;
46
import jakarta.validation.constraints.Min;
57

8+
import org.springframework.core.io.FileSystemResource;
9+
import org.springframework.core.io.Resource;
610
import org.springframework.web.bind.annotation.*;
711

812
import com.halo.eventer.domain.stamp.dto.mission.request.MissionClearReqDto;
@@ -77,4 +81,11 @@ public StampUserUserIdResDto getStampUserUuid(
7781
@PathVariable @Min(1) long festivalId, @PathVariable @Min(1) long stampId, @PathVariable String uuid) {
7882
return stampUserAdminService.getStampUserId(festivalId, stampId, uuid);
7983
}
84+
85+
@GetMapping(value = "/export", produces = "text/csv; charset=UTF-8")
86+
public Resource exportStampUsers(@PathVariable @Min(1) long festivalId, @PathVariable @Min(1) long stampId)
87+
throws IOException {
88+
Path file = stampUserAdminService.exportStampUser(festivalId, stampId);
89+
return new FileSystemResource(file);
90+
}
8091
}

src/main/java/com/halo/eventer/domain/stamp/dto/stamp/request/StampTourLandingPageReqDto.java

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ public class StampTourLandingPageReqDto {
2323
@NotEmpty
2424
private String iconImgUrl;
2525

26-
@NotEmpty
2726
private String description;
2827

2928
@NotNull

src/main/java/com/halo/eventer/domain/stamp/dto/stamp/request/StampTourParticipateGuidePageReqDto.java

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,9 @@ public class StampTourParticipateGuidePageReqDto {
2020

2121
private String mediaUrl;
2222

23-
@NotBlank
2423
private String summary;
2524

26-
@NotBlank
2725
private String details;
2826

29-
@NotBlank
3027
private String additional;
3128
}

src/main/java/com/halo/eventer/domain/stamp/repository/StampUserRepository.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.halo.eventer.domain.stamp.repository;
22

33
import java.util.Optional;
4+
import java.util.stream.Stream;
45

56
import org.springframework.data.domain.Page;
67
import org.springframework.data.domain.Pageable;
@@ -17,6 +18,9 @@ public interface StampUserRepository extends JpaRepository<StampUser, Long> {
1718

1819
Optional<StampUser> findByStampIdAndPhoneAndName(Long stampId, String phone, String name);
1920

21+
@Query("select su from StampUser su where su.stamp.id = :stampId")
22+
Stream<StampUser> findByStampId(Long stampId);
23+
2024
@Query(
2125
"""
2226
select su

src/main/java/com/halo/eventer/domain/stamp/service/v2/StampUserAdminService.java

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,18 @@
11
package com.halo.eventer.domain.stamp.service.v2;
22

3+
import java.io.BufferedWriter;
4+
import java.io.IOException;
5+
import java.io.UncheckedIOException;
6+
import java.nio.charset.StandardCharsets;
7+
import java.nio.file.Files;
8+
import java.nio.file.Path;
9+
import java.nio.file.Paths;
10+
import java.nio.file.StandardOpenOption;
11+
import java.time.LocalDateTime;
12+
import java.time.format.DateTimeFormatter;
313
import java.util.Comparator;
414
import java.util.List;
15+
import java.util.stream.Stream;
516

617
import org.springframework.data.domain.Page;
718
import org.springframework.data.domain.PageRequest;
@@ -123,6 +134,59 @@ public StampUserUserIdResDto getStampUserId(long festivalId, long stampId, Strin
123134
return new StampUserUserIdResDto(stampUser.getId());
124135
}
125136

137+
@Transactional(readOnly = true)
138+
public Path exportStampUser(long festivalId, long stampId) throws IOException {
139+
ensureStamp(festivalId, stampId);
140+
final DateTimeFormatter TS_FMT = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss");
141+
final DateTimeFormatter CSV_TIME_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
142+
final String filename =
143+
"stamp_users_%d_%s.csv".formatted(stampId, LocalDateTime.now().format(TS_FMT));
144+
final Path filePath = Paths.get(filename);
145+
try (BufferedWriter writer = Files.newBufferedWriter(
146+
filePath,
147+
StandardCharsets.UTF_8,
148+
StandardOpenOption.CREATE,
149+
StandardOpenOption.TRUNCATE_EXISTING);
150+
Stream<StampUser> stream = stampUserRepository.findByStampId(stampId)) {
151+
writer.write("\uFEFF");
152+
writer.write("UUID,전화번호,이름,참여인원,완료여부,등록일시");
153+
writer.newLine();
154+
stream.forEach(s -> writeRow(writer, s, CSV_TIME_FMT));
155+
}
156+
157+
return filePath;
158+
}
159+
160+
private void writeRow(BufferedWriter w, StampUser s, DateTimeFormatter timeFmt) {
161+
try {
162+
final String uuid = nullSafe(s.getUuid());
163+
final String phone = escape(nullSafe(encryptService.decryptInfo(s.getPhone())));
164+
final String name = escape(nullSafe(encryptService.decryptInfo(s.getName())));
165+
final String count = String.valueOf(s.getParticipantCount());
166+
final String done = String.valueOf(s.isFinished());
167+
final String cAt =
168+
(s.getCreatedAt() == null) ? "" : s.getCreatedAt().format(timeFmt);
169+
w.write(String.join(",", uuid, phone, name, count, done, cAt));
170+
w.newLine();
171+
} catch (IOException e) {
172+
throw new UncheckedIOException(e);
173+
}
174+
}
175+
176+
private String escape(String value) {
177+
if (value == null || value.isEmpty()) return "";
178+
boolean needQuote = value.indexOf(',') >= 0
179+
|| value.indexOf('"') >= 0
180+
|| value.indexOf('\n') >= 0
181+
|| value.indexOf('\r') >= 0;
182+
String v = value.replace("\"", "\"\"");
183+
return needQuote ? "\"" + v + "\"" : v;
184+
}
185+
186+
private String nullSafe(String v) {
187+
return (v == null) ? "" : v;
188+
}
189+
126190
private void ensureStamp(long festivalId, long stampId) {
127191
Stamp stamp = loadStampOrThrow(stampId);
128192
stamp.ensureStampInFestival(festivalId);

src/main/java/com/halo/eventer/global/config/security/CorsConfig.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ public CorsConfigurationSource customCorsConfigurationSource() {
3535

3636
configuration.setAllowedOriginPatterns(ALLOWED_ORIGINS);
3737
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
38-
configuration.setAllowedHeaders(Arrays.asList("Content-Type", "Authorization", "X-Requested-With"));
38+
configuration.setAllowedHeaders(Arrays.asList("*"));
3939
configuration.setAllowCredentials(true);
4040

4141
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();

src/test/java/com/halo/eventer/domain/stamp/v2/api_docs/StampUserAdminDocs.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,22 @@ public static RestDocumentationResultHandler getUserIdByUuid() {
214214
.build()));
215215
}
216216

217+
public static RestDocumentationResultHandler exportCsv() {
218+
return document(
219+
"v2-stampuser-export",
220+
resource(builder()
221+
.tag(TAG)
222+
.summary("사용자 CSV 내보내기")
223+
.description("지정된 축제/스탬프투어의 사용자 목록을 CSV(UTF-8)로 다운로드합니다.")
224+
.pathParameters(
225+
parameterWithName("festivalId").description("축제 ID (>=1)"),
226+
parameterWithName("stampId").description("스탬프투어 ID (>=1)"))
227+
.requestHeaders(headerWithName("Authorization").description("JWT Access 토큰 (ADMIN)"))
228+
// CSV는 JSON 필드가 없으므로 responseFields 생략
229+
.responseHeaders(headerWithName("Content-Type").description("text/csv; charset=UTF-8"))
230+
.build()));
231+
}
232+
217233
// 공통 에러 문서
218234
public static RestDocumentationResultHandler error(String id) {
219235
return document(

src/test/java/com/halo/eventer/domain/stamp/v2/controller/StampUserAdminControllerTest.java

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
package com.halo.eventer.domain.stamp.v2.controller;
22

3+
import java.nio.charset.StandardCharsets;
4+
import java.nio.file.Files;
5+
import java.nio.file.Path;
36
import java.time.LocalDateTime;
47
import java.util.List;
58
import java.util.Map;
69

710
import org.junit.jupiter.api.Nested;
811
import org.junit.jupiter.api.Test;
12+
import org.junit.jupiter.api.io.TempDir;
913
import org.springframework.beans.factory.annotation.Autowired;
1014
import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs;
1115
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
@@ -342,4 +346,52 @@ class UUID로_userId_조회 {
342346
.andDo(StampUserAdminDocs.getUserIdByUuid());
343347
}
344348
}
349+
350+
@Nested
351+
class CSV_내보내기 {
352+
353+
@TempDir
354+
Path tempDir;
355+
356+
@Test
357+
@WithMockUser(roles = "ADMIN")
358+
void 성공() throws Exception {
359+
String csv = "번호,UUID,전화번호,이름,참여인원,완료여부,등록일시\n" + "1,uuid-1,010-1234-5678,홍길동,2,완료,2025-10-01 12:34:56\n";
360+
Path csvFile = tempDir.resolve("stamp_users.csv");
361+
Files.write(csvFile, csv.getBytes(StandardCharsets.UTF_8));
362+
363+
given(service.exportStampUser(축제_ID, 스탬프_ID)).willReturn(csvFile);
364+
365+
// when & then
366+
mockMvc.perform(get(
367+
"/api/v2/admin/festivals/{festivalId}/stamp-tours/{stampId}/users/export",
368+
축제_ID,
369+
스탬프_ID)
370+
.header(HttpHeaders.AUTHORIZATION, AUTH))
371+
.andExpect(status().isOk())
372+
.andExpect(content().contentType(MediaType.valueOf("text/csv; charset=UTF-8")))
373+
.andExpect(content().string(csv))
374+
.andDo(StampUserAdminDocs.exportCsv());
375+
}
376+
377+
@Test
378+
void 실패_권한없음() throws Exception {
379+
mockMvc.perform(get(
380+
"/api/v2/admin/festivals/{festivalId}/stamp-tours/{stampId}/users/export", 축제_ID, 스탬프_ID))
381+
.andExpect(status().isUnauthorized())
382+
.andDo(StampUserAdminDocs.error("v2-stampuser-export-unauthorized"));
383+
}
384+
385+
@Test
386+
@WithMockUser(roles = "ADMIN")
387+
void 실패_축제ID_검증() throws Exception {
388+
mockMvc.perform(get(
389+
"/api/v2/admin/festivals/{festivalId}/stamp-tours/{stampId}/users/export",
390+
잘못된_ID,
391+
스탬프_ID) // @Min(1) 위반
392+
.header(HttpHeaders.AUTHORIZATION, AUTH))
393+
.andExpect(status().isBadRequest())
394+
.andDo(StampUserAdminDocs.error("v2-stampuser-export-badrequest"));
395+
}
396+
}
345397
}

0 commit comments

Comments
 (0)