Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions api/app-api/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ description = "학생 앱 — STUDENT, /v1/app/**"
dependencies {
implementation(project(":api:common-api"))
implementation(project(":core:common"))
implementation(project(":core:domain:welfare"))
implementation(project(":gateway:auth"))
implementation(project(":gateway:logging"))

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package kr.ac.kookmin.stream.welfare;

import kr.ac.kookmin.stream.ApiResponse;
import kr.ac.kookmin.stream.CursorCodec;
import kr.ac.kookmin.stream.CursorSliceResponse;
import kr.ac.kookmin.stream.common.CursorSliceResult;
import kr.ac.kookmin.stream.welfare.domain.notice.domain.Notice;
import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCategory;
import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCursor;
import kr.ac.kookmin.stream.welfare.domain.notice.service.NoticeService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/v1/app/notices")
@RequiredArgsConstructor
public class AppNoticeController {

private final NoticeService noticeService;

@GetMapping
public ApiResponse<CursorSliceResponse<NoticeListItemResponse>> getNotices(
@RequestParam(name = "category", required = false) String category,
@RequestParam(name = "cursor", required = false) String cursor,
@RequestParam(name = "size", defaultValue = "20") int size
) {
NoticeCursor noticeCursor = cursor == null ? null : NoticeCursor.from(CursorCodec.decode(cursor));
CursorSliceResult<Notice> result = noticeService.getNotices(NoticeCategory.from(category), noticeCursor, size);
CursorSliceResponse<NoticeListItemResponse> response = new CursorSliceResponse<>(
result.content().stream().map(NoticeListItemResponse::from).toList(),
result.hasNext(),
result.nextCursor() == null ? null : CursorCodec.encode(result.nextCursor())
);
return ApiResponse.success(response);
}

@GetMapping("/{noticeId}")
public ApiResponse<NoticeDetailResponse> getNotice(@PathVariable("noticeId") Long noticeId) {
Notice notice = noticeService.getNotice(noticeId);
return ApiResponse.success(NoticeDetailResponse.from(notice));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package kr.ac.kookmin.stream.welfare;

import java.time.LocalDateTime;
import java.util.List;
import kr.ac.kookmin.stream.welfare.domain.notice.domain.Notice;
import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCategory;

public record NoticeDetailResponse(
Long noticeId,
String title,
String content,
NoticeCategory category,
List<Image> images,
List<Attachment> attachments,
LocalDateTime createdAt
) {

public static NoticeDetailResponse from(Notice notice) {
List<Image> images = notice.getImageIds() == null
? List.of()
: notice.getImageIds().stream().map(Image::from).toList();
List<Attachment> attachments = notice.getAttachmentIds() == null
? List.of()
: notice.getAttachmentIds().stream().map(Attachment::from).toList();

return new NoticeDetailResponse(
notice.getId(),
notice.getTitle(),
notice.getContent(),
notice.getCategory(),
images,
attachments,
notice.getCreatedAt()
);
}

public record Image(Long fileId, String fileUrl) {

public static Image from(Long fileId) {
return new Image(fileId, null);
}
}

public record Attachment(Long fileId, String fileName, String fileUrl) {

public static Attachment from(Long fileId) {
return new Attachment(fileId, null, null);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package kr.ac.kookmin.stream.welfare;

import java.time.LocalDateTime;
import kr.ac.kookmin.stream.welfare.domain.notice.domain.Notice;
import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCategory;

public record NoticeListItemResponse(
Long noticeId,
String title,
NoticeCategory category,
LocalDateTime createdAt,
String thumbnailUrl,
boolean pinned
) {

public static NoticeListItemResponse from(Notice notice) {
return new NoticeListItemResponse(
notice.getId(),
notice.getTitle(),
notice.getCategory(),
notice.getCreatedAt(),
null,
notice.isPinned()
);
}
}
25 changes: 25 additions & 0 deletions api/common-api/src/main/java/kr/ac/kookmin/stream/CursorCodec.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package kr.ac.kookmin.stream;

import java.nio.charset.StandardCharsets;
import java.util.Base64;
import kr.ac.kookmin.stream.common.BusinessException;
import kr.ac.kookmin.stream.common.CommonErrorCode;

// 커서 문자열을 클라이언트에게 불투명한 토큰으로 감싼다. 실제 정렬 키 파싱은 각 도메인이 담당하고,
// 여기서는 웹(쿼리 파라미터)으로 오가는 형태(Base64 URL-safe)만 다룬다.
public final class CursorCodec {

private CursorCodec() {}

public static String encode(String raw) {
return Base64.getUrlEncoder().withoutPadding().encodeToString(raw.getBytes(StandardCharsets.UTF_8));
}

public static String decode(String cursor) {
try {
return new String(Base64.getUrlDecoder().decode(cursor), StandardCharsets.UTF_8);
} catch (IllegalArgumentException e) {
throw new BusinessException(CommonErrorCode.INVALID_INPUT);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import java.util.List;
import kr.ac.kookmin.stream.common.CursorSliceResult;

public record CursorSliceResponse<T>(List<T> content, boolean hasNext, Long nextCursor) {
public record CursorSliceResponse<T>(List<T> content, boolean hasNext, String nextCursor) {

public static <T> CursorSliceResponse<T> from(CursorSliceResult<T> result) {
return new CursorSliceResponse<>(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@

import java.util.List;

public record CursorSliceResult<T>(List<T> content, boolean hasNext, Long nextCursor) {}
public record CursorSliceResult<T>(List<T> content, boolean hasNext, String nextCursor) {}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package kr.ac.kookmin.stream.welfare.domain.notice.domain;

import java.time.LocalDateTime;
import java.util.List;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
Expand All @@ -19,6 +20,7 @@ public class Notice {
private Long createdBy;
private List<Long> attachmentIds;
private List<Long> imageIds;
private LocalDateTime createdAt;

public static Notice of(
Long id,
Expand All @@ -28,8 +30,9 @@ public static Notice of(
boolean pinned,
Long createdBy,
List<Long> attachmentIds,
List<Long> imageIds
List<Long> imageIds,
LocalDateTime createdAt
) {
return new Notice(id, title, content, category, pinned, createdBy, attachmentIds, imageIds);
return new Notice(id, title, content, category, pinned, createdBy, attachmentIds, imageIds, createdAt);
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,20 @@
package kr.ac.kookmin.stream.welfare.domain.notice.domain;

import kr.ac.kookmin.stream.common.BusinessException;
import kr.ac.kookmin.stream.common.CommonErrorCode;

public enum NoticeCategory {
GENERAL,
PARTNERSHIP
PARTNERSHIP;

public static NoticeCategory from(String value) {
if (value == null) {
return null;
}
try {
return NoticeCategory.valueOf(value);
} catch (IllegalArgumentException e) {
throw new BusinessException(CommonErrorCode.INVALID_INPUT);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package kr.ac.kookmin.stream.welfare.domain.notice.domain;

import java.time.LocalDateTime;
import kr.ac.kookmin.stream.common.BusinessException;

public record NoticeCursor(boolean pinned, LocalDateTime createdAt, Long id, NoticeCategory category) {

private static final String JOIN = "|";
private static final String SPLIT_REGEX = "\\|";
private static final String NO_CATEGORY = "-";

public static NoticeCursor of(Notice notice, NoticeCategory category) {
return new NoticeCursor(notice.isPinned(), notice.getCreatedAt(), notice.getId(), category);
}

// Base64 인코딩은 웹(Controller) 계층 책임이라 여기서는 순수 문자열 표현만 다룬다
public static NoticeCursor from(String raw) {
String[] parts = raw.split(SPLIT_REGEX, -1);
if (parts.length != 4) {
throw new BusinessException(NoticeErrorCode.NOTICE_INVALID_CURSOR);
}
try {
boolean pinned = Boolean.parseBoolean(parts[0]);
LocalDateTime createdAt = LocalDateTime.parse(parts[1]);
Long id = Long.valueOf(parts[2]);
NoticeCategory category = NO_CATEGORY.equals(parts[3]) ? null : NoticeCategory.valueOf(parts[3]);
return new NoticeCursor(pinned, createdAt, id, category);
} catch (RuntimeException e) {
throw new BusinessException(NoticeErrorCode.NOTICE_INVALID_CURSOR);
}
}

public String format() {
String categoryPart = category == null ? NO_CATEGORY : category.name();
return pinned + JOIN + createdAt + JOIN + id + JOIN + categoryPart;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package kr.ac.kookmin.stream.welfare.domain.notice.domain;

import kr.ac.kookmin.stream.common.ErrorCode;
import kr.ac.kookmin.stream.common.ErrorStatus;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.experimental.Accessors;

@Getter
@Accessors(fluent = true)
@AllArgsConstructor
public enum NoticeErrorCode implements ErrorCode {

NOTICE_NOT_FOUND(ErrorStatus.NOT_FOUND, "공지를 찾을 수 없습니다."),
NOTICE_INVALID_CURSOR(ErrorStatus.BAD_REQUEST, "유효하지 않은 커서입니다.");

private final int status;
private final String message;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package kr.ac.kookmin.stream.welfare.domain.notice.repository;

import java.util.Optional;
import kr.ac.kookmin.stream.common.CursorSliceResult;
import kr.ac.kookmin.stream.welfare.domain.notice.domain.Notice;
import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCategory;
import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCursor;

public interface NoticeRepository {
CursorSliceResult<Notice> findAll(NoticeCategory category, NoticeCursor cursor, int size);
Optional<Notice> findById(Long id);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package kr.ac.kookmin.stream.welfare.domain.notice.service;

import kr.ac.kookmin.stream.common.CursorSliceResult;
import kr.ac.kookmin.stream.welfare.domain.notice.domain.Notice;
import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCategory;
import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCursor;

public interface NoticeService {
CursorSliceResult<Notice> getNotices(NoticeCategory category, NoticeCursor cursor, int size);
Notice getNotice(Long id);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package kr.ac.kookmin.stream.welfare.domain.notice.service.impl;

import kr.ac.kookmin.stream.common.BusinessException;
import kr.ac.kookmin.stream.common.CursorSliceResult;
import kr.ac.kookmin.stream.welfare.domain.notice.domain.Notice;
import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCategory;
import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeCursor;
import kr.ac.kookmin.stream.welfare.domain.notice.domain.NoticeErrorCode;
import kr.ac.kookmin.stream.welfare.domain.notice.repository.NoticeRepository;
import kr.ac.kookmin.stream.welfare.domain.notice.service.NoticeService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
class NoticeServiceImpl implements NoticeService {

private final NoticeRepository noticeRepository;

@Override
@Transactional(readOnly = true)
public CursorSliceResult<Notice> getNotices(NoticeCategory category, NoticeCursor cursor, int size) {
// 커서가 다른 category 필터에서 발급됐다면 keyset 경계가 다른 정렬 결과를 가리키므로 거부한다
if (cursor != null && cursor.category() != category) {
throw new BusinessException(NoticeErrorCode.NOTICE_INVALID_CURSOR);
}
return noticeRepository.findAll(category, cursor, size);
}

@Override
@Transactional(readOnly = true)
public Notice getNotice(Long id) {
return noticeRepository.findById(id)
.orElseThrow(() -> new BusinessException(NoticeErrorCode.NOTICE_NOT_FOUND));
}
}
16 changes: 16 additions & 0 deletions docs/conventions/coding-style.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,22 @@ public record MemberResponse(
public record MemberRegisterCommand(String studentNo, String name) {}
```

**오직 하나의 Response에서만 쓰이는 하위 DTO**는 별도 파일로 빼지 않고 그 Response 안에 중첩 `record`로 선언한다. 다른 곳에서도 쓰이게 되면 그 시점에 최상위 파일로 승격한다.

```java
// api:app-api
public record NoticeDetailResponse(
Long noticeId,
List<Image> images
) {
public record Image(Long fileId, String fileUrl) {
public static Image from(Long fileId) {
return new Image(fileId, null);
}
}
}
```

**공통 응답 래퍼 (`ApiResponse`, `api:common-api`)**`private` 생성자 + 정적 팩토리.

```java
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,6 @@ public static NoticeJpaEntity from(Notice notice) {
}

public Notice toDomain() {
return Notice.of(id, title, content, category, pinned, createdBy, attachmentIds, imageIds);
return Notice.of(id, title, content, category, pinned, createdBy, attachmentIds, imageIds, getCreatedAt());
}
}
Loading