diff --git a/src/main/java/com/dku/council/debug/controller/TestController.java b/src/main/java/com/dku/council/debug/controller/TestController.java index 8d109fc5..3207f6ca 100644 --- a/src/main/java/com/dku/council/debug/controller/TestController.java +++ b/src/main/java/com/dku/council/debug/controller/TestController.java @@ -5,10 +5,12 @@ import com.dku.council.domain.user.exception.AlreadyPhoneException; import com.dku.council.domain.user.exception.AlreadyStudentIdException; import com.dku.council.domain.user.exception.MajorNotFoundException; +import com.dku.council.domain.user.model.UserStatus; import com.dku.council.domain.user.model.entity.Major; import com.dku.council.domain.user.model.entity.User; import com.dku.council.domain.user.repository.MajorRepository; import com.dku.council.domain.user.repository.UserRepository; +import com.dku.council.global.auth.role.UserRole; import com.dku.council.global.error.exception.UserNotFoundException; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; @@ -55,7 +57,9 @@ public void addUser(@RequestParam String studentId, @RequestParam String nickname, @RequestParam Long majorId, @RequestParam Integer yearOfAdmission, - @RequestParam String phone) { + @RequestParam String phone, + @RequestParam String age, + @RequestParam String gender) { if (userRepository.findByStudentId(studentId).isPresent()) { throw new AlreadyStudentIdException(); } @@ -78,10 +82,14 @@ public void addUser(@RequestParam String studentId, .password(encryptedPassword) .name(name) .nickname(nickname) + .age(age) + .gender(gender) .major(major) .yearOfAdmission(yearOfAdmission) .academicStatus("재학") .phone(phone) + .status(UserStatus.ACTIVE) + .role(UserRole.USER) .build(); userRepository.save(user); diff --git a/src/main/java/com/dku/council/domain/with_dankook/controller/TradeController.java b/src/main/java/com/dku/council/domain/with_dankook/controller/TradeController.java index 91e6e6da..45a8ec6c 100644 --- a/src/main/java/com/dku/council/domain/with_dankook/controller/TradeController.java +++ b/src/main/java/com/dku/council/domain/with_dankook/controller/TradeController.java @@ -1,5 +1,7 @@ package com.dku.council.domain.with_dankook.controller; +import com.dku.council.domain.post.model.dto.response.ResponsePage; +import com.dku.council.domain.with_dankook.model.dto.list.SummarizedTradeDto; import com.dku.council.domain.with_dankook.model.dto.request.RequestCreateTradeDto; import com.dku.council.domain.with_dankook.service.TradeService; import com.dku.council.global.auth.jwt.AppAuthentication; @@ -7,11 +9,11 @@ import com.dku.council.global.model.dto.ResponseIdDto; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; +import org.springdoc.api.annotations.ParameterObject; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.http.MediaType; -import org.springframework.web.bind.annotation.ModelAttribute; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; import javax.validation.Valid; @@ -23,14 +25,42 @@ public class TradeController { private final TradeService tradeService; -// /** -// * 단국 거래 게시글 작성 -// */ -// @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) -// @UserAuth -// public ResponseIdDto create(AppAuthentication auth, -// @Valid @ModelAttribute RequestCreateTradeDto dto) { -// Long id = tradeService.create(auth.getUserId(), dto); -// return new ResponseIdDto(id); -// } + /** + * 단국 거래 게시글 목록 조회 + * + * @param keyword 제목이나 내용에 포함된 검색어. 지정하지 않으면 모든 게시글 조회. + * @param bodySize 게시글 본문 길이. (글자 단위) 지정하지 않으면 50 글자. + * @param pageable 페이징 size, sort, page + * @return 페이징된 단국 거래 게시판 목록 + */ + @GetMapping + public ResponsePage list(@RequestParam(required = false) String keyword, + @RequestParam(defaultValue = "50") int bodySize, + @ParameterObject Pageable pageable) { + Page list = tradeService.list(keyword, pageable, bodySize); + return new ResponsePage<>(list); + } + + /** + * 단국 거래 게시글 등록 + */ + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @UserAuth + public ResponseIdDto create(AppAuthentication auth, + @Valid @ModelAttribute RequestCreateTradeDto dto) { + Long id = tradeService.create(auth.getUserId(), dto); + return new ResponseIdDto(id); + } + + /** + * 단국 거래 게시글 삭제 + * + * @param auth 사용자 인증정보 + * @param id 삭제할 게시글 id + */ + @DeleteMapping("/{id}") + @UserAuth + public void delete(AppAuthentication auth, @PathVariable Long id) { + tradeService.delete(id, auth.getUserId(), auth.isAdmin()); + } } diff --git a/src/main/java/com/dku/council/domain/with_dankook/model/dto/TradeImageDto.java b/src/main/java/com/dku/council/domain/with_dankook/model/dto/TradeImageDto.java new file mode 100644 index 00000000..f911e42a --- /dev/null +++ b/src/main/java/com/dku/council/domain/with_dankook/model/dto/TradeImageDto.java @@ -0,0 +1,52 @@ +package com.dku.council.domain.with_dankook.model.dto; + +import com.dku.council.domain.with_dankook.model.entity.TradeImage; +import com.dku.council.infra.nhn.s3.service.ObjectUploadContext; +import io.swagger.v3.oas.annotations.media.Schema; +import org.springframework.http.MediaType; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +public class TradeImageDto { + + @Schema(description = "이미지 아이디", example = "1") + private final Long id; + + @Schema(description = "이미지 url", example = "http://1.2.3.4/1ddee68d-6afb-48d0-9cb6-04a8d8fea4ae.png") + private final String url; + + @Schema(description = "썸네일 이미지 url (없으면 기본 이미지)", example = "http://1.2.3.4/thumb-1ddee68d-6afb-48d0-9cb6-04a8d8fea4ae.png") + private final String thumbnailUrl; + + @Schema(description = "원본이미지 파일 이름", example = "my_image.png") + private final String originalName; + + @Schema(description = "이미지 파일 타입", example = "image/jpeg") + private final String mimeType; + + public TradeImageDto(ObjectUploadContext context, TradeImage image) { + this.id = image.getId(); + this.url = context.getImageUrl(image.getFileId()); + this.thumbnailUrl = context.getThumbnailUrl(image.getThumbnailId()); + this.originalName = image.getFileName(); + + String fileMimeType = image.getMimeType(); + this.mimeType = Objects.requireNonNullElse(fileMimeType, MediaType.APPLICATION_OCTET_STREAM_VALUE); + } + + public static List listOf(ObjectUploadContext context, List entities) { + List result = new ArrayList<>(); + + for (TradeImage entity : entities) { + if (entity.getThumbnailId() != null) { + result.add(entity); + } + } + return result.stream() + .map(image -> new TradeImageDto(context, image)) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/com/dku/council/domain/with_dankook/model/dto/list/SummarizedTradeDto.java b/src/main/java/com/dku/council/domain/with_dankook/model/dto/list/SummarizedTradeDto.java new file mode 100644 index 00000000..cf09a345 --- /dev/null +++ b/src/main/java/com/dku/council/domain/with_dankook/model/dto/list/SummarizedTradeDto.java @@ -0,0 +1,37 @@ +package com.dku.council.domain.with_dankook.model.dto.list; + +import com.dku.council.domain.with_dankook.model.dto.TradeImageDto; +import com.dku.council.domain.with_dankook.model.entity.type.Trade; +import com.dku.council.infra.nhn.s3.service.ObjectUploadContext; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +import java.util.List; + +@Getter +public class SummarizedTradeDto extends SummarizedWithDankookDto{ + + @Schema(description = "제목", example = "게시글 제목") + private final String title; + + @Schema(description = "가격", example = "10000") + private final int price; + + @Schema(description = "내용", example = "게시글 본문") + private final String content; + + @Schema(description = "거래 장소", example = "단국대학교 정문") + private final String tradePlace; + + @Schema(description = "이미지 목록") + private final List images; + + public SummarizedTradeDto(SummarizedWithDankookDto dto, Trade trade, ObjectUploadContext context){ + super(dto); + this.title = trade.getTitle(); + this.price = trade.getPrice(); + this.content = trade.getContent(); + this.tradePlace = trade.getTradePlace(); + this.images = TradeImageDto.listOf(context, trade.getImages()); + } +} diff --git a/src/main/java/com/dku/council/domain/with_dankook/model/dto/list/SummarizedWithDankookDto.java b/src/main/java/com/dku/council/domain/with_dankook/model/dto/list/SummarizedWithDankookDto.java new file mode 100644 index 00000000..0cea4ab5 --- /dev/null +++ b/src/main/java/com/dku/council/domain/with_dankook/model/dto/list/SummarizedWithDankookDto.java @@ -0,0 +1,37 @@ +package com.dku.council.domain.with_dankook.model.dto.list; + +import com.dku.council.domain.with_dankook.model.entity.WithDankook; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +public class SummarizedWithDankookDto { + + @Schema(description = "With-Dankook 게시글 아이디", example = "1") + private final Long id; + + @Schema(description = "작성자", example = "익명") + private final String author; + + @Schema(description = "생성 날짜") + private final LocalDateTime createdAt; + + @Schema(description = "채팅 링크", example = "https://open.kakao.com/o/ghjgjgjg") + private final String chatLink; + + public SummarizedWithDankookDto(int bodySize, WithDankook withDankook) { + this.id = withDankook.getId(); + this.author = withDankook.getDisplayingUsername(); + this.createdAt = withDankook.getCreatedAt(); + this.chatLink = withDankook.getChatLink(); + } + + public SummarizedWithDankookDto(SummarizedWithDankookDto copy) { + this.id = copy.getId(); + this.author = copy.getAuthor(); + this.createdAt = copy.getCreatedAt(); + this.chatLink = copy.getChatLink(); + } +} diff --git a/src/main/java/com/dku/council/domain/with_dankook/model/dto/request/RequestCreateTradeDto.java b/src/main/java/com/dku/council/domain/with_dankook/model/dto/request/RequestCreateTradeDto.java index 6eeeb0f2..54429dd1 100644 --- a/src/main/java/com/dku/council/domain/with_dankook/model/dto/request/RequestCreateTradeDto.java +++ b/src/main/java/com/dku/council/domain/with_dankook/model/dto/request/RequestCreateTradeDto.java @@ -35,12 +35,17 @@ public class RequestCreateTradeDto extends RequestCreateWithDankookDto { @Schema(description = "이미지 파일 목록") private final List images; - public RequestCreateTradeDto(@NotBlank String title, @NotBlank int price, @NotBlank String content, @NotBlank String tradePlace, List images) { + @NotBlank + @Schema(description = "오픈채팅방 링크", example = "https://open.kakao.com/o/abc123") + private final String chatLink; + + public RequestCreateTradeDto(@NotBlank String title, @NotBlank int price, @NotBlank String content, @NotBlank String tradePlace, List images, @NotBlank String chatLink) { this.title = title; this.price = price; this.content = content; this.tradePlace = tradePlace; this.images = Objects.requireNonNullElseGet(images, ArrayList::new); + this.chatLink = chatLink; } public Trade toEntity(User user) { @@ -50,6 +55,7 @@ public Trade toEntity(User user) { .content(content) .tradePlace(tradePlace) .user(user) + .chatLink(chatLink) .build(); } } diff --git a/src/main/java/com/dku/council/domain/with_dankook/model/entity/WithDankook.java b/src/main/java/com/dku/council/domain/with_dankook/model/entity/WithDankook.java index a75d6a18..1daa930e 100644 --- a/src/main/java/com/dku/council/domain/with_dankook/model/entity/WithDankook.java +++ b/src/main/java/com/dku/council/domain/with_dankook/model/entity/WithDankook.java @@ -54,4 +54,8 @@ protected WithDankook(User user, String chatLink) { } public abstract String getDisplayingUsername(); + + public void markAsDeleted(boolean byAdmin) { + this.withDankookStatus = byAdmin ? WithDankookStatus.DELETED_BY_ADMIN : WithDankookStatus.DELETED; + } } diff --git a/src/main/java/com/dku/council/domain/with_dankook/repository/WithDankookRepository.java b/src/main/java/com/dku/council/domain/with_dankook/repository/WithDankookRepository.java index 54eaac1b..2e7dc7f2 100644 --- a/src/main/java/com/dku/council/domain/with_dankook/repository/WithDankookRepository.java +++ b/src/main/java/com/dku/council/domain/with_dankook/repository/WithDankookRepository.java @@ -3,6 +3,17 @@ import com.dku.council.domain.with_dankook.model.entity.WithDankook; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; -public interface WithDankookRepository extends JpaRepository, JpaSpecificationExecutor { +import java.util.Optional; + +public interface WithDankookRepository extends JpaRepository, JpaSpecificationExecutor { + + @Override + @Query("select w from WithDankook w " + + "join fetch w.masterUser u " + + "join fetch u.major " + + "where w.id=:id and w.withDankookStatus ='ACTIVE' ") + Optional findById(@Param("id") Long id); } diff --git a/src/main/java/com/dku/council/domain/with_dankook/repository/spec/WithDankookSpec.java b/src/main/java/com/dku/council/domain/with_dankook/repository/spec/WithDankookSpec.java new file mode 100644 index 00000000..077a651b --- /dev/null +++ b/src/main/java/com/dku/council/domain/with_dankook/repository/spec/WithDankookSpec.java @@ -0,0 +1,26 @@ +package com.dku.council.domain.with_dankook.repository.spec; + +import com.dku.council.domain.with_dankook.model.WithDankookStatus; +import com.dku.council.domain.with_dankook.model.entity.WithDankook; +import org.springframework.data.jpa.domain.Specification; + +public class WithDankookSpec { + + public static Specification withActive() { + return (root, query, builder) -> + builder.equal(root.get("withDankookStatus"), WithDankookStatus.ACTIVE); + } + + public static Specification withTitleOrBody(String keyword) { + if (keyword == null || keyword.equals("null")) { + return Specification.where(null); + } + + String pattern = "%" + keyword + "%"; + return (root, query, builder) -> + builder.or( + builder.like(root.get("title"), pattern), + builder.like(root.get("content"), pattern) + ); + } +} diff --git a/src/main/java/com/dku/council/domain/with_dankook/service/TradeService.java b/src/main/java/com/dku/council/domain/with_dankook/service/TradeService.java index dabfb3c9..f0eab39c 100644 --- a/src/main/java/com/dku/council/domain/with_dankook/service/TradeService.java +++ b/src/main/java/com/dku/council/domain/with_dankook/service/TradeService.java @@ -4,19 +4,26 @@ import com.dku.council.domain.user.model.entity.User; import com.dku.council.domain.user.repository.UserRepository; import com.dku.council.domain.with_dankook.exception.TradeCooltimeException; +import com.dku.council.domain.with_dankook.model.dto.list.SummarizedTradeDto; import com.dku.council.domain.with_dankook.model.dto.request.RequestCreateTradeDto; import com.dku.council.domain.with_dankook.model.entity.TradeImage; import com.dku.council.domain.with_dankook.model.entity.type.Trade; import com.dku.council.domain.with_dankook.repository.TradeRepository; import com.dku.council.domain.with_dankook.repository.WithDankookMemoryRepository; +import com.dku.council.domain.with_dankook.repository.spec.WithDankookSpec; import com.dku.council.global.error.exception.UserNotFoundException; import com.dku.council.infra.nhn.s3.model.ImageRequest; import com.dku.council.infra.nhn.s3.model.UploadedImage; import com.dku.council.infra.nhn.s3.service.ImageUploadService; +import com.dku.council.infra.nhn.s3.service.ObjectUploadContext; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; import java.time.Clock; @@ -28,6 +35,7 @@ @Service @RequiredArgsConstructor @Slf4j +@Transactional(readOnly = true) public class TradeService { public static final String TRADE_KEY = "trade"; @@ -36,14 +44,17 @@ public class TradeService { private final WithDankookMemoryRepository withDankookMemoryRepository; private final UserRepository userRepository; + private final WithDankookService withDankookService; private final ImageUploadService imageUploadService; private final ThumbnailService thumbnailService; + private final ObjectUploadContext objectUploadContext; private final Clock clock; @Value("${app.with-dankook.trade.write-cooltime}") private final Duration writeCooltime; + @Transactional public Long create(Long userId, RequestCreateTradeDto dto) { User user = userRepository.findById(userId).orElseThrow(UserNotFoundException::new); Instant now = Instant.now(clock); @@ -57,6 +68,7 @@ public Long create(Long userId, RequestCreateTradeDto dto) { .price(dto.getPrice()) .content(dto.getContent()) .tradePlace(dto.getTradePlace()) + .chatLink(dto.getChatLink()) .build(); attachImages(trade, dto.getImages()); @@ -92,4 +104,18 @@ private void attachImages(Trade trade, List dtoImages) { tradeImage.changeTrade(trade); } } + + @Transactional(readOnly = true) + public Page list(String keyword, Pageable pageable, int bodySize) { + Specification spec = WithDankookSpec.withTitleOrBody(keyword); + spec = spec.and(WithDankookSpec.withActive()); + Page result = tradeRepository.findAll(spec, pageable); + return result.map((trade) -> + new SummarizedTradeDto(withDankookService.makeListDto(bodySize, trade), trade, objectUploadContext)); + } + + @Transactional + public void delete(Long tradeId, Long userId, boolean isAdmin) { + withDankookService.delete(tradeRepository, tradeId, userId, isAdmin); + } } diff --git a/src/main/java/com/dku/council/domain/with_dankook/service/WithDankookService.java b/src/main/java/com/dku/council/domain/with_dankook/service/WithDankookService.java index 30cd21fd..b599eafd 100644 --- a/src/main/java/com/dku/council/domain/with_dankook/service/WithDankookService.java +++ b/src/main/java/com/dku/council/domain/with_dankook/service/WithDankookService.java @@ -1,9 +1,18 @@ package com.dku.council.domain.with_dankook.service; +import com.dku.council.domain.post.exception.PostNotFoundException; import com.dku.council.domain.user.repository.UserRepository; +import com.dku.council.domain.with_dankook.model.dto.list.SummarizedWithDankookDto; import com.dku.council.domain.with_dankook.model.entity.WithDankook; +import com.dku.council.domain.with_dankook.repository.WithDankookRepository; +import com.dku.council.domain.with_dankook.repository.spec.WithDankookSpec; +import com.dku.council.global.error.exception.NotGrantedException; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor @@ -11,5 +20,62 @@ public class WithDankookService { protected final UserRepository userRepository; - + /** + * With-Dankook 게시판 글 목록 조회 + */ + @Transactional(readOnly = true) + public Page list(WithDankookRepository repository, Specification spec, + Pageable pageable, int bodySize) { + Page result = list(repository, spec, pageable); + return result.map((withDankook) -> makeListDto(bodySize, withDankook)); + } + + @Transactional(readOnly = true) + public Page list(WithDankookRepository repository, Specification spec, Pageable pageable, int bodySize, + PostResultMapper mapper) { + Page result = list(repository, spec, pageable); + return result.map((withDankook) -> { + SummarizedWithDankookDto dto = makeListDto(bodySize, withDankook); + return mapper.map(dto, withDankook); + }); + } + + private Page list(WithDankookRepository repository, Specification spec, Pageable pageable) { + if (spec == null) { + spec = Specification.where(null); + } + + spec = spec.and(WithDankookSpec.withActive()); + + return repository.findAll(spec, pageable); + } + + public SummarizedWithDankookDto makeListDto(int bodySize, E withDankook) { + return new SummarizedWithDankookDto(bodySize, withDankook); + } + + /** + * With-Dankook 게시판 글 삭제. 실제 DB에서 삭제는 하지 않는다. + * + * @param repository 삭제할 게시글 repository + * @param withDankookId 삭제할 게시글 id + * @param userId 삭제 요청한 사용자 id + * @param isAdmin 삭제 요청한 사용자가 관리자인지 + */ + @Transactional + public void delete(WithDankookRepository repository, Long withDankookId, Long userId, boolean isAdmin) { + E withDankook = repository.findById(withDankookId).orElseThrow(PostNotFoundException::new); + if(isAdmin) { + withDankook.markAsDeleted(true); + } else if(withDankook.getMasterUser().getId().equals(userId)) { + withDankook.markAsDeleted(false); + } else { + throw new NotGrantedException(); + } + } + + @FunctionalInterface + public interface PostResultMapper { + T map(D dto, E withDankook); + } }