Skip to content

Commit

Permalink
Backend Api Release (#528)
Browse files Browse the repository at this point in the history
* refactor: ApiFolderErrorCode 오타 수정 (#468)

* [REFACTOR] 픽 리팩토링, 검색 테스트 구현 (#474)

* refactor: FolderType label 수정

* refactor: 파라미터 idList -> pickIdList 명시적으로 변경

* refactor: folder 내부에 있는 pick_order 리스트에 pickId가 중복 생성되는 문제 해결

* refactor: root folder 검색 못하도록 예외 처리

* refactor: pick controller 메서드명 수정

* refactor: DB fleid 함수를 이용하여 정렬된 픽 리스트 조회할 수 있는 메서드 구현

* refactor: 픽 검색 시 폴더 리스트가 null인 경우 검증하지 않도록 변경, tag 검증 추가

* refactor: 테스트 코드 작성

* refactor: 테스트 코드 ParameterizedTest로 리팩토링 (#476)

* [FIX] 픽 수정 리팩토링 및 검증로직 개선 (#477)

* refactor: 불필요한 초기값 제거

* fix: 엔티티를 list로 조회시 존재하지 않는 엔티티를 조회하려고 하면 NOT_FOUND 예외 발생

* fix: pick update시 부모폴더 id도 변경 가능하도록 수정, 누락된 검증 로직 추가

* fix: 기능 변경에 의한 테스트 코드 수정

* fix: idList로 폴더 조회시 존재하지 않는 폴더id로 조회하면 예외 발생

* refactor: 파라미터명 명확하게 수정 idList -> folderIdList

* feat: 폴더 응답에 생성 수정 일자 추가 (#485)

* [FEAT] 에러 요청에 대한 로깅 구현 (#481)

* feat: error request logging 구현

* feat: 기존 예외처리 로직에 요청 로깅 추가

* chore: 주석 추가

* fix: 로깅 방식 변경 - application.log에 sql 로깅 제외

* fix: 누락된 로깅 로직 추가

* �[FEAT] 폴더 export 기능 구현 (#504)

* feat: 폴더 export 기능 구현

* feat: url 수정

* [FEAT] 픽 제목 최대 길이 검증 및 Validation 검증 리팩토링 (#499)

* refactor: 픽 제목 최대 길이 제약조건 추가

* feat: 픽 제목 최대 길이 제약조건 예외, 예외 코드 추가

* refactor: bean validation 예외 상세하게 반환하도록 수정

* refactor: tag 이동 시 리스트 길이보다 큰 인덱스로 이동 시 IndexOutOfBoundsException 발생하는 문제 해결

* fix: title이 null일 때 터지는 오류 해결

* fix: && 문법 오류 수정

* [BUG] 픽 태그 삭제 버그, 픽 수정 버그 수정 (#509)

* refactor: 픽에 사용되었던 유저 태그 삭제 시 500에러 해결

* refactor: pick unique index title 제거

* refactor: 픽을 휴지통으로 이동 시 폴더 내에 있는 픽 리스트 제거 및 가독성을 위해 로직 공통 처리

* refactor: 불필요한 메서드 제거 및 메서드명 변경

* refactor: 운영 환경에서 스웨거 접근 못하도록 하는 설정 추가

* refactor: 폴더 삭제 시 픽들이 휴지통으로 이동될 때 부모 폴더의 픽 리스트 수정이 필요하지 않음.

* refactor: 미분류, 휴지통 폴더에 폴더 생성하지 못하도록 변경, 에러 메세지 변경

* refactor: 메서드명 수정

* refactor: 픽 생성 시 타이틀이 없으면 터지는 예외 해결

* refactor: 데드락 문제 해결을 위해 트랜잭션 범위 최소화

* [BUG] 픽 태그 리스트 수정 시 데드락 문제 수정 (#514)

* refactor: 픽 생성 시 타이틀이 없으면 터지는 예외 해결

* refactor: 데드락 문제 해결을 위해 트랜잭션 범위 최소화

* hotfix: Pick 엔티티의 Link Lazy -> Eager

* refactor: PickTag에 낙관적 락 설정

* refactor: Spring retry 추가

* [REFACTOR] PickTag 테이블에 낙관적 락 설정 (#519)

* refactor: PickTag에 낙관적 락 설정

* refactor: Spring retry 추가

* refactor: 픽태그 삭제 부분 수정

* refactor: pick 수정하는 부분에 비관적 락 추가 (#521)

* [FEAT] 크롬 북마크 Import 기능 구현 (#522)

* fix: access token, JSESSIONID 로깅 안하도록 수정, multipart file 캐싱 안하도록 수정

* fix: 입력값이 공백 또는 null일 경우 update하지 않음, 기본값을 빈스트링("")으로 설정

* feat: ogTag 업데이트 구현 및 필요한 의존성 추가

* feat: chrome 북마크 import를 위한 메소드 추가

* feat: chrome 북마크 import export 구현

* refactor: 주석 수정

* refactor: swagger 설정 수정

* refactor: link의 title과 description 필드 text타입으로 수정

* chore: 주석 typo 수정

* refactor: typo 수정

* feat: og 태그 데이터 가져오는 api 구현 (#525)

* refactor: 운영 서버에서 스웨거 접근 못하도록 변경 (#527)

---------

Co-authored-by: Pak Su Hyung <[email protected]>
Co-authored-by: Sangwon Yang <[email protected]>
  • Loading branch information
3 people authored Nov 22, 2024
1 parent d53ce07 commit 28ed30b
Show file tree
Hide file tree
Showing 50 changed files with 1,030 additions and 148 deletions.
8 changes: 8 additions & 0 deletions backend/techpick-api/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ repositories {
dependencies {
implementation project(":techpick-core")

// Spring Retry 의존성 추가
implementation 'org.springframework.retry:spring-retry'

// spring security and oauth client
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
Expand All @@ -20,6 +23,11 @@ dependencies {
// jwt
implementation 'io.jsonwebtoken:jjwt:0.9.1'

// jsoup
// https://mvnrepository.com/artifact/org.jsoup/jsoup
implementation 'org.jsoup:jsoup:1.18.1'

implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
}

tasks.named('test') {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package techpick.api.application.chromebookmark.controller;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import techpick.api.domain.chromebookmark.dto.ChromeImportResult;
import techpick.api.domain.chromebookmark.service.ChromeBookmarkService;
import techpick.api.domain.folder.dto.FolderCommand;
import techpick.api.domain.link.service.LinkService;
import techpick.security.annotation.LoginUserId;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/chrome")
@Tag(name = "Chrome API", description = "Chrome Bookmark Import / Export API")
public class ChromeBookmarkController {
private final ChromeBookmarkService chromeBookmarkService;
private final LinkService linkService;

@GetMapping("/{folderId}/export")
@Operation(summary = "특정 폴더 다운로드", description = "사용자의 특정 폴더를 크롬 브라우저 북마크에 import 가능한 형태로 다운로드 받습니다.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "다운로드 성공"),
@ApiResponse(responseCode = "401", description = "본인 폴더만 다운로드할 수 있습니다.")
})
public ResponseEntity<ByteArrayResource> exportFolder(@LoginUserId Long userId, @PathVariable Long folderId) {
String exportResult = chromeBookmarkService.exportFolder(new FolderCommand.Export(userId, folderId));

ByteArrayResource resource = new ByteArrayResource(exportResult.getBytes());

HttpHeaders headers = new HttpHeaders();
headers.add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=exported-folder.html");
headers.add(HttpHeaders.CONTENT_TYPE, "text/html; charset=UTF-8");

return ResponseEntity
.status(HttpStatus.OK)
.headers(headers)
.body(resource);
}

@GetMapping("/export")
@Operation(summary = "전체 폴더 다운로드", description = "사용자의 특정 폴더를 크롬 브라우저 북마크에 import 가능한 형태로 다운로드 받습니다.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "다운로드 성공")
})
public ResponseEntity<ByteArrayResource> exportUserFolder(@LoginUserId Long userId) {
String exportResult = chromeBookmarkService.exportUserFolder(userId);

ByteArrayResource resource = new ByteArrayResource(exportResult.getBytes());

HttpHeaders headers = new HttpHeaders();
headers.add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=exported-folder.html");
headers.add(HttpHeaders.CONTENT_TYPE, "text/html; charset=UTF-8");

return ResponseEntity
.status(HttpStatus.OK)
.headers(headers)
.body(resource);
}

@PostMapping(value = "/import", consumes = MediaType.MULTIPART_FORM_DATA_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
@Operation(summary = "크롬 북마크 업로드", description = "내보내기한 크롬 북마크(.html)을 업로드 하여 일괄 추가합니다. 이미 등록된 url(중복 url)을 응답으로 보냅니다.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "다운로드 성공"),
@ApiResponse(responseCode = "500", description = "파일 형식 및 파싱 오류")
})
public ResponseEntity<List<String>> importBookmark(@LoginUserId Long userId, @RequestParam("file")
@Parameter(required = true) MultipartFile file) throws
InterruptedException, IOException {
String html = new String(file.getBytes(), StandardCharsets.UTF_8);
ChromeImportResult result = chromeBookmarkService.importChromeBookmarks(userId, html);

// og 태그의 경우 정적 크롤링이 필요해, 최초 등록시에는 og 태그를 모두 빈스트링("")으로 등록하고,
// 이후 비동기적으로 업데이트 진행
int maxThreadPoolSize = 5;
ExecutorService executor = Executors.newFixedThreadPool(maxThreadPoolSize);
for (String url : result.ogTagUpdateUrls()) {
CompletableFuture.runAsync(() -> linkService.updateOgTag(url), executor)
.orTimeout(60, TimeUnit.SECONDS);
}

return ResponseEntity.ok(result.alreadyExistBookmarks());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,27 +9,27 @@
public class FolderApiRequest {

public record Create(
@Schema(example = "backend") @NotBlank String name,
@Schema(example = "3") @NotNull Long parentFolderId
@Schema(example = "backend") @NotBlank(message = "{folder.name.notBlank}") String name,
@Schema(example = "3") @NotNull(message = "{parentFolderId.notNull}") Long parentFolderId
) {
}

public record Update(
@Schema(example = "3") @NotNull Long id,
@Schema(example = "SpringBoot") @NotBlank String name
@Schema(example = "3") @NotNull(message = "{id.notNull}") Long id,
@Schema(example = "SpringBoot") @NotBlank(message = "{folder.name.notBlank}") String name
) {
}

public record Move(
@Schema(example = "[12, 11, 4, 5, 1, 6]") @NotNull List<Long> idList,
@Schema(example = "7") @NotNull Long parentFolderId,
@Schema(example = "3") @NotNull Long destinationFolderId,
@Schema(example = "[12, 11, 4, 5, 1, 6]") @NotNull(message = "{idList.notNull}") List<Long> idList,
@Schema(example = "7") @NotNull(message = "{parentFolderId.notNull}") Long parentFolderId,
@Schema(example = "3") @NotNull(message = "{destinationFolderId.notNull}") Long destinationFolderId,
@Schema(example = "2") int orderIdx
) {
}

public record Delete(
@Schema(example = "[12, 11, 4, 5, 1, 6]") @NotNull List<Long> idList
@Schema(example = "[12, 11, 4, 5, 1, 6]") @NotNull(message = "{idList.notNull}") List<Long> idList
) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package techpick.api.application.link.controller;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import techpick.api.application.link.dto.LinkApiMapper;
import techpick.api.application.link.dto.LinkApiResponse;
import techpick.api.domain.link.dto.LinkResult;
import techpick.api.domain.link.service.LinkService;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/links")
@Tag(name = "Link API", description = "링크 API")
public class LinkApiController {

private final LinkService linkService;
private final LinkApiMapper linkApiMapper;

@GetMapping
@Operation(summary = "해당 링크 og 데이터 조회", description = "해당 링크의 og 태그 데이터를 스크래핑을 통해 가져옵니다.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "조회 성공")
})
public ResponseEntity<LinkApiResponse> getLinkData(
@Parameter(description = "og 태그 데이터 가져올 url") @RequestParam String url
) {
LinkResult linkResult = linkService.getUpdateOgTag(url);
return ResponseEntity.ok(linkApiMapper.toLinkResult(linkResult));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package techpick.api.application.link.dto;

import org.mapstruct.InjectionStrategy;
import org.mapstruct.Mapper;
import org.mapstruct.ReportingPolicy;

import techpick.api.domain.link.dto.LinkResult;

@Mapper(
componentModel = "spring",
injectionStrategy = InjectionStrategy.CONSTRUCTOR,
unmappedTargetPolicy = ReportingPolicy.ERROR
)
public interface LinkApiMapper {

LinkApiResponse toLinkResult(LinkResult linkResult);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package techpick.api.application.link.dto;

public record LinkApiResponse(
String title,
String description,
String imageUrl
) {
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package techpick.api.application.pick.controller;

import java.util.List;
import java.util.Objects;

import org.springframework.data.domain.Slice;
import org.springframework.http.ResponseEntity;
Expand All @@ -21,15 +22,18 @@
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import techpick.api.application.pick.dto.PickApiMapper;
import techpick.api.application.pick.dto.PickApiRequest;
import techpick.api.application.pick.dto.PickApiResponse;
import techpick.api.application.pick.dto.PickSliceResponse;
import techpick.api.domain.pick.dto.PickResult;
import techpick.api.domain.pick.exception.ApiPickException;
import techpick.api.domain.pick.service.PickSearchService;
import techpick.api.domain.pick.service.PickService;
import techpick.security.annotation.LoginUserId;

@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/picks")
Expand Down Expand Up @@ -108,6 +112,10 @@ public ResponseEntity<PickApiResponse.Pick> getPick(@LoginUserId Long userId,
})
public ResponseEntity<PickApiResponse.Pick> savePick(@LoginUserId Long userId,
@Valid @RequestBody PickApiRequest.Create request) {
if (!Objects.isNull(request.title()) && 200 < request.title().length()) {
throw ApiPickException.PICK_TITLE_TOO_LONG();
}

return ResponseEntity.ok(
pickApiMapper.toApiResponse(pickService.saveNewPick(pickApiMapper.toCreateCommand(userId, request))));
}
Expand All @@ -119,6 +127,10 @@ public ResponseEntity<PickApiResponse.Pick> savePick(@LoginUserId Long userId,
})
public ResponseEntity<PickApiResponse.Pick> updatePick(@LoginUserId Long userId,
@Valid @RequestBody PickApiRequest.Update request) {
if (!Objects.isNull(request.title()) && 200 < request.title().length()) {
throw ApiPickException.PICK_TITLE_TOO_LONG();
}

return ResponseEntity.ok(
pickApiMapper.toApiResponse(pickService.updatePick(pickApiMapper.toUpdateCommand(userId, request))));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,27 +17,27 @@ public record Create(
}

public record Read(
@Schema(example = "1") @NotNull Long id
@Schema(example = "1") @NotNull(message = "{id.notNull}") Long id
) {
}

public record Update(
@Schema(example = "1") @NotNull Long id,
@Schema(example = "1") @NotNull(message = "{id.notNull}") Long id,
@Schema(example = "Record란 뭘까?") String title,
@Schema(example = "3") Long parentFolderId,
@Schema(example = "[4, 5, 2, 1]") List<Long> tagIdOrderedList
) {
}

public record Move(
@Schema(example = "[1, 2]") @NotNull List<Long> idList,
@Schema(example = "3") @NotNull Long destinationFolderId,
@Schema(example = "[1, 2]") @NotNull(message = "{idList.notNull}") List<Long> idList,
@Schema(example = "3") @NotNull(message = "{destinationFolderId.notNull}") Long destinationFolderId,
@Schema(example = "0") int orderIdx
) {
}

public record Delete(
@Schema(example = "[1]") @NotNull List<Long> idList
@Schema(example = "[1]") @NotNull(message = "{idList.notNull}") List<Long> idList
) {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,28 +8,28 @@
public class TagApiRequest {

public record Create(
@Schema(example = "SpringBoot") @NotBlank String name,
@Schema(example = "12") @NotNull Integer colorNumber) {
@Schema(example = "SpringBoot") @NotBlank(message = "{tag.name.notBlank}") String name,
@Schema(example = "12") @NotNull(message = "{tag.colorNumber.notNull}") Integer colorNumber) {
}

public record Read(
@Schema(example = "2") @NotNull Long id) {
@Schema(example = "2") @NotNull(message = "{id.notNull}") Long id) {
}

public record Update(
@Schema(example = "2") @NotNull Long id,
@Schema(example = "new tag name") @NotEmpty String name,
@Schema(example = "7") @NotNull Integer colorNumber) {
@Schema(example = "2") @NotNull(message = "{id.notNull}") Long id,
@Schema(example = "new tag name") @NotEmpty(message = "{tag.name.notBlank}") String name,
@Schema(example = "7") @NotNull(message = "{tag.colorNumber.notNull}") Integer colorNumber) {
}

public record Move(
@Schema(example = "3") @NotNull Long id,
@Schema(example = "1") @NotNull int orderIdx
@Schema(example = "3") @NotNull(message = "{id.notNull}") Long id,
@Schema(example = "1") int orderIdx
) {
}

public record Delete(
@Schema(example = "4") @NotNull Long id
@Schema(example = "4") @NotNull(message = "{id.notNull}") Long id
) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package techpick.api.config;

import org.springframework.context.MessageSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.support.ReloadableResourceBundleMessageSource;
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;

@Configuration
public class ValidationConfig {

@Bean
public MessageSource messageSource() {
ReloadableResourceBundleMessageSource messageSource
= new ReloadableResourceBundleMessageSource();

messageSource.setBasename("classpath:messages");
messageSource.setDefaultEncoding("UTF-8");
return messageSource;
}

@Bean
public LocalValidatorFactoryBean getValidator() {
LocalValidatorFactoryBean bean = new LocalValidatorFactoryBean();
bean.setValidationMessageSource(messageSource());
return bean;
}
}
Loading

0 comments on commit 28ed30b

Please sign in to comment.