diff --git a/.github/workflows/ci-dev.yml b/.github/workflows/ci-dev.yml index fa209863..c35e9636 100644 --- a/.github/workflows/ci-dev.yml +++ b/.github/workflows/ci-dev.yml @@ -118,10 +118,16 @@ jobs: - name: Deploy with Docker Compose run: | - echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u ${{ secrets.DOCKER_USERNAME }} --password-stdin + export DOCKER_CONFIG=/tmp/.docker-ci + mkdir -p "$DOCKER_CONFIG" + DOCKER="docker --config $DOCKER_CONFIG" + AUTH_B64=$(printf '%s:%s' "${{ secrets.DOCKER_USERNAME }}" "${{ secrets.DOCKER_PASSWORD }}" | base64 | tr -d '\n') + cat > "$DOCKER_CONFIG/config.json" << EOF + {"auths":{"https://index.docker.io/v1/":{"auth":"$AUTH_B64"}}} + EOF - docker pull ${{ secrets.DOCKER_USERNAME }}/ono:${APP_TAG} - docker tag ${{ secrets.DOCKER_USERNAME }}/ono:${APP_TAG} ${{ secrets.DOCKER_USERNAME }}/ono:dev-latest + $DOCKER pull ${{ secrets.DOCKER_USERNAME }}/ono:${APP_TAG} + $DOCKER tag ${{ secrets.DOCKER_USERNAME }}/ono:${APP_TAG} ${{ secrets.DOCKER_USERNAME }}/ono:dev-latest docker-compose -f docker-compose.dev.yml --env-file .env.dev up -d mysql-dev redis-dev @@ -129,8 +135,8 @@ jobs: docker-compose -f docker-compose.dev.yml --env-file .env.dev rm -f app-dev || true docker-compose -f docker-compose.dev.yml --env-file .env.dev up -d app-dev - docker inspect ono-app-dev --format='Running Image: {{.Config.Image}}' - docker inspect ono-app-dev --format='Image ID: {{.Image}}' + $DOCKER inspect ono-app-dev --format='Running Image: {{.Config.Image}}' + $DOCKER inspect ono-app-dev --format='Image ID: {{.Image}}' echo "Waiting for development application to be healthy..." timeout=60 @@ -152,6 +158,8 @@ jobs: - name: Deploy Monitoring Stack run: | + docker-compose -f docker-compose.dev.yml --env-file .env.dev --profile monitoring pull \ + mysql-exporter-dev prometheus-dev grafana-dev docker-compose -f docker-compose.dev.yml --env-file .env.dev --profile monitoring stop prometheus-dev grafana-dev mysql-exporter-dev || true docker-compose -f docker-compose.dev.yml --env-file .env.dev --profile monitoring rm -f prometheus-dev grafana-dev mysql-exporter-dev || true docker-compose -f docker-compose.dev.yml --env-file .env.dev --profile monitoring up -d --force-recreate \ diff --git a/.github/workflows/ci-prod.yml b/.github/workflows/ci-prod.yml index b1dd8258..7314c9ee 100644 --- a/.github/workflows/ci-prod.yml +++ b/.github/workflows/ci-prod.yml @@ -116,10 +116,16 @@ jobs: - name: Blue-Green Deployment run: | - echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u ${{ secrets.DOCKER_USERNAME }} --password-stdin + export DOCKER_CONFIG=/tmp/.docker-ci + mkdir -p "$DOCKER_CONFIG" + DOCKER="docker --config $DOCKER_CONFIG" + AUTH_B64=$(printf '%s:%s' "${{ secrets.DOCKER_USERNAME }}" "${{ secrets.DOCKER_PASSWORD }}" | base64 | tr -d '\n') + cat > "$DOCKER_CONFIG/config.json" << EOF + {"auths":{"https://index.docker.io/v1/":{"auth":"$AUTH_B64"}}} + EOF - docker pull ${{ secrets.DOCKER_USERNAME }}/ono:${APP_TAG} - docker tag ${{ secrets.DOCKER_USERNAME }}/ono:${APP_TAG} ${{ secrets.DOCKER_USERNAME }}/ono:prod-latest + $DOCKER pull ${{ secrets.DOCKER_USERNAME }}/ono:${APP_TAG} + $DOCKER tag ${{ secrets.DOCKER_USERNAME }}/ono:${APP_TAG} ${{ secrets.DOCKER_USERNAME }}/ono:prod-latest docker-compose -f docker-compose.prod.yml --env-file .env.prod up -d mysql-prod redis-prod @@ -212,6 +218,8 @@ jobs: - name: Deploy Monitoring Stack run: | + docker-compose -f docker-compose.prod.yml --env-file .env.prod --profile monitoring pull \ + mysql-exporter-prod prometheus-prod grafana-prod docker-compose -f docker-compose.prod.yml --env-file .env.prod --profile monitoring stop prometheus-prod grafana-prod mysql-exporter-prod || true docker-compose -f docker-compose.prod.yml --env-file .env.prod --profile monitoring rm -f prometheus-prod grafana-prod mysql-exporter-prod || true docker-compose -f docker-compose.prod.yml --env-file .env.prod --profile monitoring up -d --force-recreate \ diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml index 873a42d1..444bfa12 100644 --- a/.github/workflows/deploy-dev.yml +++ b/.github/workflows/deploy-dev.yml @@ -62,10 +62,16 @@ jobs: - name: Deploy with Docker Compose run: | - echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u ${{ secrets.DOCKER_USERNAME }} --password-stdin + export DOCKER_CONFIG=/tmp/.docker-ci + mkdir -p "$DOCKER_CONFIG" + DOCKER="docker --config $DOCKER_CONFIG" + AUTH_B64=$(printf '%s:%s' "${{ secrets.DOCKER_USERNAME }}" "${{ secrets.DOCKER_PASSWORD }}" | base64 | tr -d '\n') + cat > "$DOCKER_CONFIG/config.json" << EOF + {"auths":{"https://index.docker.io/v1/":{"auth":"$AUTH_B64"}}} + EOF - docker pull ${{ secrets.DOCKER_USERNAME }}/ono:${APP_TAG} - docker tag ${{ secrets.DOCKER_USERNAME }}/ono:${APP_TAG} ${{ secrets.DOCKER_USERNAME }}/ono:dev-latest + $DOCKER pull ${{ secrets.DOCKER_USERNAME }}/ono:${APP_TAG} + $DOCKER tag ${{ secrets.DOCKER_USERNAME }}/ono:${APP_TAG} ${{ secrets.DOCKER_USERNAME }}/ono:dev-latest docker-compose -f docker-compose.dev.yml --env-file .env.dev up -d mysql-dev redis-dev @@ -73,8 +79,8 @@ jobs: docker-compose -f docker-compose.dev.yml --env-file .env.dev rm -f app-dev || true docker-compose -f docker-compose.dev.yml --env-file .env.dev up -d app-dev - docker inspect ono-app-dev --format='Running Image: {{.Config.Image}}' - docker inspect ono-app-dev --format='Image ID: {{.Image}}' + $DOCKER inspect ono-app-dev --format='Running Image: {{.Config.Image}}' + $DOCKER inspect ono-app-dev --format='Image ID: {{.Image}}' echo "Waiting for development application to be healthy..." timeout=60 diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml index 30818a2e..07641656 100644 --- a/.github/workflows/deploy-prod.yml +++ b/.github/workflows/deploy-prod.yml @@ -60,10 +60,16 @@ jobs: - name: Blue-Green Deployment run: | - echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u ${{ secrets.DOCKER_USERNAME }} --password-stdin - - docker pull ${{ secrets.DOCKER_USERNAME }}/ono:${APP_TAG} - docker tag ${{ secrets.DOCKER_USERNAME }}/ono:${APP_TAG} ${{ secrets.DOCKER_USERNAME }}/ono:prod-latest + export DOCKER_CONFIG=/tmp/.docker-ci + mkdir -p "$DOCKER_CONFIG" + DOCKER="docker --config $DOCKER_CONFIG" + AUTH_B64=$(printf '%s:%s' "${{ secrets.DOCKER_USERNAME }}" "${{ secrets.DOCKER_PASSWORD }}" | base64 | tr -d '\n') + cat > "$DOCKER_CONFIG/config.json" << EOF + {"auths":{"https://index.docker.io/v1/":{"auth":"$AUTH_B64"}}} + EOF + + $DOCKER pull ${{ secrets.DOCKER_USERNAME }}/ono:${APP_TAG} + $DOCKER tag ${{ secrets.DOCKER_USERNAME }}/ono:${APP_TAG} ${{ secrets.DOCKER_USERNAME }}/ono:prod-latest docker-compose -f docker-compose.prod.yml --env-file .env.prod up -d mysql-prod redis-prod diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index de01a881..e3f1c120 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -146,11 +146,14 @@ services: - monitoring mysql-exporter-dev: - image: prom/mysqld-exporter:latest + image: prom/mysqld-exporter:v0.15.1 container_name: ono-mysql-exporter-dev restart: unless-stopped + command: + - --mysqld.address=mysql-dev:3306 + - --mysqld.username=root environment: - DATA_SOURCE_NAME: root:${MYSQL_ROOT_PASSWORD}@(mysql-dev:3306)/ + MYSQLD_EXPORTER_PASSWORD: ${MYSQL_ROOT_PASSWORD} depends_on: mysql-dev: condition: service_healthy diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index c7d403a7..6df7a3b0 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -200,11 +200,14 @@ services: - monitoring mysql-exporter-prod: - image: prom/mysqld-exporter:latest + image: prom/mysqld-exporter:v0.15.1 container_name: ono-mysql-exporter-prod restart: unless-stopped + command: + - --mysqld.address=mysql-prod:3306 + - --mysqld.username=root environment: - DATA_SOURCE_NAME: root:${MYSQL_ROOT_PASSWORD}@(mysql-prod:3306)/ + MYSQLD_EXPORTER_PASSWORD: ${MYSQL_ROOT_PASSWORD} depends_on: mysql-prod: condition: service_healthy diff --git a/src/main/java/com/aisip/OnO/backend/auth/service/JwtTokenService.java b/src/main/java/com/aisip/OnO/backend/auth/service/JwtTokenService.java index f3f0ff3e..2a4b4698 100644 --- a/src/main/java/com/aisip/OnO/backend/auth/service/JwtTokenService.java +++ b/src/main/java/com/aisip/OnO/backend/auth/service/JwtTokenService.java @@ -33,11 +33,20 @@ public TokenResponseDto generateTokens(Long userId, Authority authority) { String accessToken = jwtTokenizer.createAccessToken(String.valueOf(userId), Map.of("authority", authority)); String refreshToken; - // DB에 기존 RefreshToken이 있으면 재사용 + // DB에 기존 RefreshToken이 있으면 검증 후 재사용, 만료/무효면 재발급 RefreshToken existingRefreshToken = refreshTokenRepository.findByUserId(userId).orElse(null); if (existingRefreshToken != null) { - refreshToken = existingRefreshToken.getRefreshToken(); - log.info("Reusing existing refresh token for userId: {}", userId); + String existingTokenValue = existingRefreshToken.getRefreshToken(); + try { + jwtTokenizer.validateRefreshToken(existingTokenValue); + refreshToken = existingTokenValue; + log.info("Reusing existing refresh token for userId: {}", userId); + } catch (ApplicationException e) { + refreshToken = jwtTokenizer.createRefreshToken(String.valueOf(userId), Map.of("authority", authority)); + refreshTokenRepository.deleteByUserId(userId); + refreshTokenRepository.save(RefreshToken.from(userId, authority, refreshToken)); + log.info("Existing refresh token was expired/invalid. Issued a new one for userId: {}", userId); + } } else { refreshToken = jwtTokenizer.createRefreshToken(String.valueOf(userId), Map.of("authority", authority)); RefreshToken refreshTokenEntity = RefreshToken.from(userId, authority, refreshToken); @@ -45,8 +54,8 @@ public TokenResponseDto generateTokens(Long userId, Authority authority) { log.info("Created new refresh token for userId: {}", userId); } - // Redis에 RefreshToken 캐싱 - long expiration = jwtTokenizer.getRefreshTokenExpirationSeconds(); + // Redis에 RefreshToken 캐싱 (토큰 실제 남은 만료시간 기준) + long expiration = jwtTokenizer.getRemainingRefreshExpirationTime(refreshToken); redisTokenService.saveRefreshToken(userId, refreshToken, expiration); log.info("userId: {} has : generate token with authority: {}", userId, authority); @@ -55,6 +64,7 @@ public TokenResponseDto generateTokens(Long userId, Authority authority) { /** * ✅ 리프레시 토큰을 이용한 액세스 토큰 갱신 + * - refresh token은 재사용하고 access token만 재발급 */ public TokenResponseDto refreshAccessToken(String refreshToken) { jwtTokenizer.validateRefreshToken(refreshToken); @@ -71,9 +81,10 @@ public TokenResponseDto refreshAccessToken(String refreshToken) { .orElseThrow(() -> new ApplicationException(AuthErrorCase.REFRESH_TOKEN_NOT_FOUND)); storedToken = refreshTokenEntity.getRefreshToken(); + jwtTokenizer.validateRefreshToken(storedToken); // DB에서 찾은 토큰을 Redis에 다시 캐싱 - long expiration = jwtTokenizer.getRefreshTokenExpirationSeconds(); + long expiration = jwtTokenizer.getRemainingRefreshExpirationTime(storedToken); redisTokenService.saveRefreshToken(userId, storedToken, expiration); log.info("RefreshToken cache miss - loaded from DB for userId: {}", userId); @@ -85,8 +96,14 @@ public TokenResponseDto refreshAccessToken(String refreshToken) { throw new ApplicationException(AuthErrorCase.REFRESH_TOKEN_NOT_EQUAL); } - log.info("userId: {} has : refresh access token", userId); - return generateTokens(userId, authority); + // 4. access token만 재발급 (refresh token은 만료 연장/교체하지 않음) + String newAccessToken = jwtTokenizer.createAccessToken(String.valueOf(userId), Map.of("authority", authority)); + + long expiration = jwtTokenizer.getRemainingRefreshExpirationTime(refreshToken); + redisTokenService.saveRefreshToken(userId, refreshToken, expiration); + + log.info("userId: {} has : refresh access token (refresh token reused)", userId); + return new TokenResponseDto(newAccessToken, refreshToken); } /** diff --git a/src/main/java/com/aisip/OnO/backend/auth/service/JwtTokenizer.java b/src/main/java/com/aisip/OnO/backend/auth/service/JwtTokenizer.java index 2e670415..f29b12d1 100644 --- a/src/main/java/com/aisip/OnO/backend/auth/service/JwtTokenizer.java +++ b/src/main/java/com/aisip/OnO/backend/auth/service/JwtTokenizer.java @@ -147,6 +147,16 @@ public long getRefreshTokenExpirationSeconds() { return refreshTokenExpiration / 1000; } + /** + * RefreshToken의 남은 만료 시간을 초 단위로 반환 + */ + public long getRemainingRefreshExpirationTime(String token) { + Claims claims = getClaimsFromRefreshToken(token); + Date expiration = claims.getExpiration(); + long now = System.currentTimeMillis(); + return Math.max(0, (expiration.getTime() - now) / 1000); + } + /** * AccessToken 만료 시간을 초 단위로 반환 */ diff --git a/src/main/java/com/aisip/OnO/backend/problem/controller/ProblemController.java b/src/main/java/com/aisip/OnO/backend/problem/controller/ProblemController.java index 80e6a354..78f87c62 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/controller/ProblemController.java +++ b/src/main/java/com/aisip/OnO/backend/problem/controller/ProblemController.java @@ -7,6 +7,7 @@ import com.aisip.OnO.backend.problem.dto.ProblemRegisterDto; import com.aisip.OnO.backend.problem.dto.ProblemRegisterV2Dto; import com.aisip.OnO.backend.problem.dto.ProblemResponseDto; +import com.aisip.OnO.backend.problem.dto.ProblemTagUpdateDto; import com.aisip.OnO.backend.problem.service.ProblemAnalysisService; import com.aisip.OnO.backend.problem.service.ProblemService; import lombok.RequiredArgsConstructor; @@ -70,6 +71,31 @@ public CommonResponse> getProblemsWithCur return CommonResponse.success(problemService.findProblemsByFolderWithCursor(folderId, cursor, size)); } + // ✅ V2 API: 커서 기반 태그의 문제 조회 (무한 스크롤) + @GetMapping("/tag/{tagId}/V2") + public CommonResponse> getProblemsWithCursorByTag( + @PathVariable("tagId") Long tagId, + @RequestParam(value = "cursor", required = false) Long cursor, + @RequestParam(value = "size", defaultValue = "20") int size) { + Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + log.info("userId: {} get problems for tagId: {} with cursor: {}, size: {}", userId, tagId, cursor, size); + + return CommonResponse.success(problemService.findProblemsByTagWithCursor(tagId, userId, cursor, size)); + } + + // ✅ V2 API: 제목(contains) 기반 문제 조회 (무한 스크롤) + @GetMapping("/title/V2") + public CommonResponse> getProblemsWithCursorByTitle( + @RequestParam("query") String query, + @RequestParam(value = "cursor", required = false) Long cursor, + @RequestParam(value = "size", defaultValue = "20") int size) { + Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + log.info("userId: {} search problems by title query: '{}' with cursor: {}, size: {}", + userId, query, cursor, size); + + return CommonResponse.success(problemService.findProblemsByTitleWithCursor(query, userId, cursor, size)); + } + // ✅ 사용자의 문제 개수 조회 @GetMapping("/problemCount") public CommonResponse getUserProblemCount() { @@ -157,6 +183,18 @@ public CommonResponse updateProblemPath(@RequestBody ProblemRegisterDto return CommonResponse.success("문제가 수정되었습니다."); } + // ✅ 문제 태그 추가/해제 + @PatchMapping("/{problemId}/tags") + public CommonResponse updateProblemTags( + @PathVariable("problemId") Long problemId, + @RequestBody ProblemTagUpdateDto problemTagUpdateDto + ) { + Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + problemService.updateProblemTags(problemId, userId, problemTagUpdateDto); + + return CommonResponse.success("문제 태그가 수정되었습니다."); + } + // ✅ 문제 삭제 @DeleteMapping("") public CommonResponse deleteProblems( @@ -184,4 +222,4 @@ public CommonResponse deleteProblemImageData(@RequestParam("imageUrl") S return CommonResponse.success("문제 이미지 데이터 삭제가 완료되었습니다."); } -} \ No newline at end of file +} diff --git a/src/main/java/com/aisip/OnO/backend/problem/dto/ProblemRegisterDto.java b/src/main/java/com/aisip/OnO/backend/problem/dto/ProblemRegisterDto.java index 54c647ba..70b87c5c 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/dto/ProblemRegisterDto.java +++ b/src/main/java/com/aisip/OnO/backend/problem/dto/ProblemRegisterDto.java @@ -12,6 +12,17 @@ public record ProblemRegisterDto ( Long folderId, - LocalDateTime solvedAt -) {} + LocalDateTime solvedAt, + List tagIds +) { + public ProblemRegisterDto( + Long problemId, + String memo, + String reference, + Long folderId, + LocalDateTime solvedAt + ) { + this(problemId, memo, reference, folderId, solvedAt, null); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/problem/dto/ProblemRegisterV2Dto.java b/src/main/java/com/aisip/OnO/backend/problem/dto/ProblemRegisterV2Dto.java index 2385e2c2..f2e19bcd 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/dto/ProblemRegisterV2Dto.java +++ b/src/main/java/com/aisip/OnO/backend/problem/dto/ProblemRegisterV2Dto.java @@ -10,7 +10,18 @@ public record ProblemRegisterV2Dto( Long folderId, LocalDateTime solvedAt, List problemImageUrls, - List answerImageUrls + List answerImageUrls, + List tagIds ) { + public ProblemRegisterV2Dto( + Long problemId, + String memo, + String reference, + Long folderId, + LocalDateTime solvedAt, + List problemImageUrls, + List answerImageUrls + ) { + this(problemId, memo, reference, folderId, solvedAt, problemImageUrls, answerImageUrls, null); + } } - diff --git a/src/main/java/com/aisip/OnO/backend/problem/dto/ProblemResponseDto.java b/src/main/java/com/aisip/OnO/backend/problem/dto/ProblemResponseDto.java index ffd1c8b3..2dd167c2 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/dto/ProblemResponseDto.java +++ b/src/main/java/com/aisip/OnO/backend/problem/dto/ProblemResponseDto.java @@ -1,6 +1,8 @@ package com.aisip.OnO.backend.problem.dto; import com.aisip.OnO.backend.problem.entity.Problem; +import com.aisip.OnO.backend.tag.dto.TagResponseDto; +import com.aisip.OnO.backend.tag.entity.ProblemTagMapping; import lombok.AccessLevel; import lombok.Builder; import org.jetbrains.annotations.NotNull; @@ -28,7 +30,11 @@ public record ProblemResponseDto ( List imageUrlList, - ProblemAnalysisResponseDto analysis + ProblemAnalysisResponseDto analysis, + + List tagIdList, + + List tags ) { public static ProblemResponseDto from(@NotNull Problem problem) { @@ -40,6 +46,20 @@ public static ProblemResponseDto from(@NotNull Problem problem) { .map(ProblemAnalysisResponseDto::from) .orElse(null); + List tagIds = Optional.ofNullable(problem.getProblemTagMappingList()) + .orElse(List.of()) + .stream() + .map(ProblemTagMapping::getTag) + .map(tag -> tag.getId()) + .toList(); + + List tags = Optional.ofNullable(problem.getProblemTagMappingList()) + .orElse(List.of()) + .stream() + .map(ProblemTagMapping::getTag) + .map(TagResponseDto::from) + .toList(); + return ProblemResponseDto.builder() .problemId(problem.getId()) .folderId(problem.getFolder().getId()) @@ -50,7 +70,8 @@ public static ProblemResponseDto from(@NotNull Problem problem) { .updatedAt(problem.getUpdatedAt()) .imageUrlList(problemImageDataList) .analysis(analysisDto) + .tagIdList(tagIds) + .tags(tags) .build(); } } - diff --git a/src/main/java/com/aisip/OnO/backend/problem/dto/ProblemTagUpdateDto.java b/src/main/java/com/aisip/OnO/backend/problem/dto/ProblemTagUpdateDto.java new file mode 100644 index 00000000..bed423e4 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/problem/dto/ProblemTagUpdateDto.java @@ -0,0 +1,9 @@ +package com.aisip.OnO.backend.problem.dto; + +import java.util.List; + +public record ProblemTagUpdateDto( + List addTagIds, + List removeTagIds +) { +} diff --git a/src/main/java/com/aisip/OnO/backend/problem/entity/Problem.java b/src/main/java/com/aisip/OnO/backend/problem/entity/Problem.java index 6afcbfdd..885e42f4 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/entity/Problem.java +++ b/src/main/java/com/aisip/OnO/backend/problem/entity/Problem.java @@ -4,6 +4,7 @@ import com.aisip.OnO.backend.folder.entity.Folder; import com.aisip.OnO.backend.practicenote.entity.ProblemPracticeNoteMapping; import com.aisip.OnO.backend.problem.dto.ProblemRegisterDto; +import com.aisip.OnO.backend.tag.entity.ProblemTagMapping; import jakarta.persistence.*; import lombok.*; import org.hibernate.annotations.SQLDelete; @@ -48,6 +49,9 @@ public class Problem extends BaseEntity { @OneToMany(mappedBy = "problem", cascade = CascadeType.ALL) private List problemPracticeNoteMappingList = new ArrayList<>(); + @OneToMany(mappedBy = "problem", cascade = CascadeType.ALL, orphanRemoval = true) + private List problemTagMappingList = new ArrayList<>(); + @OneToOne(mappedBy = "problem", cascade = CascadeType.ALL) private ProblemAnalysis problemAnalysis; @@ -60,6 +64,7 @@ public static Problem from(ProblemRegisterDto problemRegisterDto, Long userId) { .solvedAt(problemRegisterDto.solvedAt()) .problemImageDataList(new ArrayList<>()) .problemPracticeNoteMappingList(new ArrayList<>()) + .problemTagMappingList(new ArrayList<>()) .build(); } @@ -107,6 +112,14 @@ public void removePracticeMappingFromProblem(ProblemPracticeNoteMapping problemP problemPracticeNoteMappingList.remove(problemPracticeNoteMapping); } + public void addTagMappingToProblem(ProblemTagMapping problemTagMapping) { + problemTagMappingList.add(problemTagMapping); + } + + public void removeTagMappingFromProblem(ProblemTagMapping problemTagMapping) { + problemTagMappingList.remove(problemTagMapping); + } + public void updateProblemAnalysis(ProblemAnalysis analysis) { this.problemAnalysis = analysis; } diff --git a/src/main/java/com/aisip/OnO/backend/problem/repository/ProblemRepositoryCustom.java b/src/main/java/com/aisip/OnO/backend/problem/repository/ProblemRepositoryCustom.java index 6f5726dd..661b383f 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/repository/ProblemRepositoryCustom.java +++ b/src/main/java/com/aisip/OnO/backend/problem/repository/ProblemRepositoryCustom.java @@ -25,4 +25,24 @@ public interface ProblemRepositoryCustom { * @return 문제 리스트 (size+1개 조회하여 hasNext 판단) */ List findProblemsByFolderWithCursor(Long folderId, Long cursor, int size); + + /** + * 커서 기반 태그의 문제 조회 + * @param tagId 태그 ID + * @param userId 유저 ID + * @param cursor 마지막으로 조회한 문제 ID (null이면 처음부터) + * @param size 조회할 개수 + * @return 문제 리스트 (size+1개 조회하여 hasNext 판단) + */ + List findProblemsByTagWithCursor(Long tagId, Long userId, Long cursor, int size); + + /** + * 커서 기반 제목(contains) 문제 조회 + * @param titleQuery 제목 검색어 (contains) + * @param userId 유저 ID + * @param cursor 마지막으로 조회한 문제 ID (null이면 처음부터) + * @param size 조회할 개수 + * @return 문제 리스트 (size+1개 조회하여 hasNext 판단) + */ + List findProblemsByTitleWithCursor(String titleQuery, Long userId, Long cursor, int size); } diff --git a/src/main/java/com/aisip/OnO/backend/problem/repository/ProblemRepositoryImpl.java b/src/main/java/com/aisip/OnO/backend/problem/repository/ProblemRepositoryImpl.java index 112f957a..d814244a 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/repository/ProblemRepositoryImpl.java +++ b/src/main/java/com/aisip/OnO/backend/problem/repository/ProblemRepositoryImpl.java @@ -12,6 +12,7 @@ import static com.aisip.OnO.backend.practicenote.entity.QProblemPracticeNoteMapping.problemPracticeNoteMapping; import static com.aisip.OnO.backend.problem.entity.QProblem.problem; import static com.aisip.OnO.backend.problem.entity.QProblemImageData.problemImageData; +import static com.aisip.OnO.backend.tag.entity.QProblemTagMapping.problemTagMapping; public class ProblemRepositoryImpl implements ProblemRepositoryCustom { @@ -95,4 +96,49 @@ public List findProblemsByFolderWithCursor(Long folderId, Long cursor, .limit(size + 1) // hasNext 판단을 위해 +1개 조회 .fetch(); } + + @Override + public List findProblemsByTagWithCursor(Long tagId, Long userId, Long cursor, int size) { + var query = queryFactory + .selectDistinct(problem) + .from(problem) + .join(problemTagMapping).on(problemTagMapping.problem.id.eq(problem.id)) + .leftJoin(problem.folder).fetchJoin() + .leftJoin(problem.problemImageDataList, problemImageData).fetchJoin() + .where( + problemTagMapping.tag.id.eq(tagId), + problem.userId.eq(userId) + ); + + if (cursor != null) { + query.where(problem.id.gt(cursor)); + } + + return query + .orderBy(problem.id.asc()) + .limit(size + 1) + .fetch(); + } + + @Override + public List findProblemsByTitleWithCursor(String titleQuery, Long userId, Long cursor, int size) { + var query = queryFactory + .selectDistinct(problem) + .from(problem) + .leftJoin(problem.folder).fetchJoin() + .leftJoin(problem.problemImageDataList, problemImageData).fetchJoin() + .where( + problem.userId.eq(userId), + problem.reference.containsIgnoreCase(titleQuery) + ); + + if (cursor != null) { + query.where(problem.id.gt(cursor)); + } + + return query + .orderBy(problem.id.asc()) + .limit(size + 1) + .fetch(); + } } diff --git a/src/main/java/com/aisip/OnO/backend/problem/service/ProblemService.java b/src/main/java/com/aisip/OnO/backend/problem/service/ProblemService.java index 3ba9eff8..d7ed5ee9 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/service/ProblemService.java +++ b/src/main/java/com/aisip/OnO/backend/problem/service/ProblemService.java @@ -5,29 +5,40 @@ import com.aisip.OnO.backend.config.rabbitmq.producer.S3DeleteProducer; import com.aisip.OnO.backend.config.rabbitmq.producer.ProblemAnalysisProducer; import com.aisip.OnO.backend.mission.service.MissionLogService; +import com.aisip.OnO.backend.problem.entity.AnalysisStatus; import com.aisip.OnO.backend.problem.entity.ProblemImageType; import com.aisip.OnO.backend.util.fileupload.service.FileUploadService; import com.aisip.OnO.backend.problem.dto.ProblemImageDataRegisterDto; import com.aisip.OnO.backend.problem.dto.ProblemRegisterDto; import com.aisip.OnO.backend.problem.dto.ProblemRegisterV2Dto; +import com.aisip.OnO.backend.problem.dto.ProblemTagUpdateDto; import com.aisip.OnO.backend.folder.entity.Folder; import com.aisip.OnO.backend.problem.entity.ProblemImageData; import com.aisip.OnO.backend.folder.exception.FolderErrorCase; import com.aisip.OnO.backend.problem.exception.ProblemErrorCase; +import com.aisip.OnO.backend.problem.repository.ProblemAnalysisRepository; import com.aisip.OnO.backend.folder.repository.FolderRepository; import com.aisip.OnO.backend.problem.repository.ProblemImageDataRepository; import com.aisip.OnO.backend.problem.dto.ProblemResponseDto; import com.aisip.OnO.backend.problem.entity.Problem; import com.aisip.OnO.backend.problem.repository.ProblemRepository; import com.aisip.OnO.backend.practicenote.repository.PracticeNoteRepository; +import com.aisip.OnO.backend.tag.entity.ProblemTagMapping; +import com.aisip.OnO.backend.tag.entity.Tag; +import com.aisip.OnO.backend.tag.exception.TagErrorCase; +import com.aisip.OnO.backend.tag.repository.ProblemTagMappingRepository; +import com.aisip.OnO.backend.tag.repository.TagRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.ArrayList; import java.util.Collection; +import java.util.LinkedHashSet; import java.util.List; import java.util.Objects; +import java.util.Set; import java.util.stream.Collectors; import org.springframework.web.multipart.MultipartFile; @@ -38,6 +49,7 @@ public class ProblemService { private final ProblemRepository problemRepository; private final ProblemImageDataRepository problemImageDataRepository; + private final ProblemAnalysisRepository problemAnalysisRepository; private final FolderRepository folderRepository; @@ -52,6 +64,8 @@ public class ProblemService { private final S3DeleteProducer s3DeleteProducer; private final ProblemAnalysisProducer analysisProducer; + private final TagRepository tagRepository; + private final ProblemTagMappingRepository problemTagMappingRepository; @Transactional(readOnly = true) public ProblemResponseDto findProblem(Long problemId) { @@ -127,6 +141,7 @@ public Long registerProblem(ProblemRegisterDto problemRegisterDto, Long userId) Problem problem = Problem.from(problemRegisterDto, userId); problem.updateFolder(folder); problemRepository.save(problem); + syncProblemTags(problem, userId, problemRegisterDto.tagIds()); analysisService.createSkippedAnalysis(problem.getId()); missionLogService.registerProblemWriteMission(userId); @@ -150,12 +165,14 @@ public Long registerProblemV2(ProblemRegisterV2Dto problemRegisterV2Dto, Long us problemRegisterV2Dto.memo(), problemRegisterV2Dto.reference(), problemRegisterV2Dto.folderId(), - problemRegisterV2Dto.solvedAt() + problemRegisterV2Dto.solvedAt(), + problemRegisterV2Dto.tagIds() ); Problem problem = Problem.from(baseDto, userId); problem.updateFolder(folder); problemRepository.save(problem); + syncProblemTags(problem, userId, baseDto.tagIds()); if (problemRegisterV2Dto.problemImageUrls() != null) { problemRegisterV2Dto.problemImageUrls().stream() @@ -238,6 +255,14 @@ public void uploadProblemImages(Long problemId, Long userId, List */ @Transactional public void analysisProblem(Long problemId) { + // 이미 분석이 완료된 문제는 재요청하지 않음 + if (problemAnalysisRepository.findByProblemId(problemId) + .map(analysis -> analysis.getStatus() == AnalysisStatus.COMPLETED) + .orElse(false)) { + log.info("분석이 이미 완료된 문제이므로 분석을 진행하지 않습니다 - problemId: {}", problemId); + return; + } + // 1. 문제 이미지 개수 확인 long problemImageCount = problemImageDataRepository.findAllByProblemId(problemId) .stream() @@ -263,6 +288,7 @@ public void updateProblemInfo(ProblemRegisterDto problemRegisterDto, Long userId Problem problem = findProblemEntity(problemRegisterDto.problemId(), userId); problem.updateProblem(problemRegisterDto); + syncProblemTags(problem, userId, problemRegisterDto.tagIds()); log.info("userId: {} update problemId: {}", userId, problem.getId()); } @@ -281,6 +307,105 @@ public void updateProblemFolder(ProblemRegisterDto problemRegisterDto, Long user } } + @Transactional + public void updateProblemTags(Long problemId, Long userId, ProblemTagUpdateDto problemTagUpdateDto) { + Problem problem = findProblemEntity(problemId, userId); + + Set addTagIds = toDistinctIds(problemTagUpdateDto.addTagIds()); + Set removeTagIds = toDistinctIds(problemTagUpdateDto.removeTagIds()); + + List existingMappings = problemTagMappingRepository.findAllByProblemId(problemId); + Set existingTagIds = existingMappings.stream() + .map(mapping -> mapping.getTag().getId()) + .collect(Collectors.toSet()); + + List mappingsToDelete = existingMappings.stream() + .filter(mapping -> removeTagIds.contains(mapping.getTag().getId())) + .toList(); + if (!mappingsToDelete.isEmpty()) { + problemTagMappingRepository.deleteAll(mappingsToDelete); + } + + Set currentTagIds = new LinkedHashSet<>(existingTagIds); + currentTagIds.removeAll(mappingsToDelete.stream() + .map(mapping -> mapping.getTag().getId()) + .collect(Collectors.toSet())); + + Set candidateAddTagIds = new LinkedHashSet<>(addTagIds); + candidateAddTagIds.removeAll(currentTagIds); + + if (!candidateAddTagIds.isEmpty()) { + List tagsToAdd = tagRepository.findAllByIdInAndUserId(new ArrayList<>(candidateAddTagIds), userId); + if (tagsToAdd.size() != candidateAddTagIds.size()) { + throw new ApplicationException(TagErrorCase.TAG_NOT_FOUND); + } + + if (currentTagIds.size() + tagsToAdd.size() > 5) { + throw new ApplicationException(TagErrorCase.TAG_LIMIT_EXCEEDED); + } + + for (Tag tag : tagsToAdd) { + problemTagMappingRepository.save(ProblemTagMapping.from(problem, tag)); + } + } + + log.info("userId: {} updated tags for problemId: {}, addCount: {}, removeCount: {}", + userId, problemId, addTagIds.size(), removeTagIds.size()); + } + + private Set toDistinctIds(List ids) { + if (ids == null) { + return Set.of(); + } + return ids.stream() + .filter(Objects::nonNull) + .collect(Collectors.toCollection(LinkedHashSet::new)); + } + + private void syncProblemTags(Problem problem, Long userId, List requestedTagIds) { + // null이면 기존 태그 유지, 빈 배열이면 전체 해제 + if (requestedTagIds == null) { + return; + } + + Set targetTagIds = toDistinctIds(requestedTagIds); + if (targetTagIds.size() > 5) { + throw new ApplicationException(TagErrorCase.TAG_LIMIT_EXCEEDED); + } + + List existingMappings = problemTagMappingRepository.findAllByProblemId(problem.getId()); + Set existingTagIds = existingMappings.stream() + .map(mapping -> mapping.getTag().getId()) + .collect(Collectors.toSet()); + + if (!targetTagIds.isEmpty()) { + List targetTags = tagRepository.findAllByIdInAndUserId(new ArrayList<>(targetTagIds), userId); + if (targetTags.size() != targetTagIds.size()) { + throw new ApplicationException(TagErrorCase.TAG_NOT_FOUND); + } + + Set targetTagIdSet = targetTags.stream().map(Tag::getId).collect(Collectors.toSet()); + + List mappingsToDelete = existingMappings.stream() + .filter(mapping -> !targetTagIdSet.contains(mapping.getTag().getId())) + .toList(); + if (!mappingsToDelete.isEmpty()) { + problemTagMappingRepository.deleteAll(mappingsToDelete); + } + + for (Tag tag : targetTags) { + if (!existingTagIds.contains(tag.getId())) { + problemTagMappingRepository.save(ProblemTagMapping.from(problem, tag)); + } + } + } else { + // [] 전달 시 전체 해제 + if (!existingMappings.isEmpty()) { + problemTagMappingRepository.deleteAll(existingMappings); + } + } + } + /** * 문제 삭제 (비동기 S3 파일 삭제 적용) * - DB 삭제: 동기 (즉시 완료) @@ -291,19 +416,26 @@ public void updateProblemFolder(ProblemRegisterDto problemRegisterDto, Long user public void deleteProblem(Long problemId) { // 1. 이미지 데이터 조회 List imageDataList = problemImageDataRepository.findAllByProblemId(problemId); + // 1-1. 태그 매핑 조회 + List problemTagMappings = problemTagMappingRepository.findAllByProblemId(problemId); // 2. DB에서 이미지 메타데이터 삭제 (동기 - 빠름) problemImageDataRepository.deleteAll(imageDataList); - // 3. PracticeNote 매핑 삭제 (동기 - 데이터 정합성 보장) + // 3. 태그 매핑 삭제 (동기 - 데이터 정합성 보장) + if (!problemTagMappings.isEmpty()) { + problemTagMappingRepository.deleteAll(problemTagMappings); + } + + // 4. PracticeNote 매핑 삭제 (동기 - 데이터 정합성 보장) practiceNoteRepository.deleteProblemFromAllPractice(problemId); - // 4. 문제 삭제 (Soft Delete) + // 5. 문제 삭제 (Soft Delete) problemRepository.deleteById(problemId); log.info("problemId: {} DB 삭제 완료", problemId); - // 5. S3 파일 삭제는 비동기로 처리 (RabbitMQ Producer) + // 6. S3 파일 삭제는 비동기로 처리 (RabbitMQ Producer) imageDataList.forEach(imageData -> { try { s3DeleteProducer.sendDeleteMessage(imageData.getImageUrl(), problemId); @@ -374,4 +506,64 @@ public CursorPageResponse findProblemsByFolderWithCursor(Lon log.info("folderId: {} find problems with cursor: {}, size: {}, hasNext: {}", folderId, cursor, size, hasNext); return CursorPageResponse.of(dtoList, nextCursor, hasNext, size); } + + /** + * V2 API: 커서 기반 태그의 문제 조회 + * @param tagId 태그 ID + * @param userId 유저 ID + * @param cursor 마지막으로 조회한 문제 ID (null이면 처음부터) + * @param size 조회할 개수 + * @return 커서 기반 페이징 응답 + */ + @Transactional(readOnly = true) + public CursorPageResponse findProblemsByTagWithCursor(Long tagId, Long userId, Long cursor, int size) { + Tag tag = tagRepository.findById(tagId) + .orElseThrow(() -> new ApplicationException(TagErrorCase.TAG_NOT_FOUND)); + + if (!Objects.equals(tag.getUserId(), userId)) { + throw new ApplicationException(TagErrorCase.TAG_USER_UNMATCHED); + } + + List problems = problemRepository.findProblemsByTagWithCursor(tagId, userId, cursor, size); + + boolean hasNext = problems.size() > size; + List content = hasNext ? problems.subList(0, size) : problems; + Long nextCursor = hasNext ? content.get(content.size() - 1).getId() : null; + + List dtoList = content.stream() + .map(ProblemResponseDto::from) + .collect(Collectors.toList()); + + log.info("userId: {} find problems by tagId: {} with cursor: {}, size: {}, hasNext: {}", + userId, tagId, cursor, size, hasNext); + return CursorPageResponse.of(dtoList, nextCursor, hasNext, size); + } + + /** + * V2 API: 커서 기반 제목 contains 문제 조회 + * - 제목은 현재 Problem.reference 필드를 사용 + */ + @Transactional(readOnly = true) + public CursorPageResponse findProblemsByTitleWithCursor( + String titleQuery, Long userId, Long cursor, int size + ) { + String query = titleQuery == null ? "" : titleQuery.trim(); + if (query.isEmpty()) { + return CursorPageResponse.of(List.of(), null, false, size); + } + + List problems = problemRepository.findProblemsByTitleWithCursor(query, userId, cursor, size); + + boolean hasNext = problems.size() > size; + List content = hasNext ? problems.subList(0, size) : problems; + Long nextCursor = hasNext ? content.get(content.size() - 1).getId() : null; + + List dtoList = content.stream() + .map(ProblemResponseDto::from) + .collect(Collectors.toList()); + + log.info("userId: {} find problems by title query: '{}' with cursor: {}, size: {}, hasNext: {}", + userId, query, cursor, size, hasNext); + return CursorPageResponse.of(dtoList, nextCursor, hasNext, size); + } } diff --git a/src/main/java/com/aisip/OnO/backend/tag/controller/TagController.java b/src/main/java/com/aisip/OnO/backend/tag/controller/TagController.java new file mode 100644 index 00000000..9caeec64 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/tag/controller/TagController.java @@ -0,0 +1,65 @@ +package com.aisip.OnO.backend.tag.controller; + +import com.aisip.OnO.backend.common.response.CommonResponse; +import com.aisip.OnO.backend.tag.dto.TagCreateRequestDto; +import com.aisip.OnO.backend.tag.dto.TagDeleteRequestDto; +import com.aisip.OnO.backend.tag.dto.TagRecommendRequestDto; +import com.aisip.OnO.backend.tag.dto.TagResponseDto; +import com.aisip.OnO.backend.tag.service.TagService; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/tags") +public class TagController { + + private final TagService tagService; + + @PostMapping("") + public CommonResponse createTag( + @RequestBody TagCreateRequestDto tagCreateRequestDto + ) { + Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + + return CommonResponse.success(tagService.createTag(userId, tagCreateRequestDto)); + } + + @GetMapping("") + public CommonResponse> getUserTags() { + Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + + return CommonResponse.success(tagService.getUserTags(userId)); + } + + @DeleteMapping("/{tagId}") + public CommonResponse deleteTag(@PathVariable("tagId") Long tagId) { + Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + tagService.deleteTag(userId, tagId); + + return CommonResponse.success("태그가 삭제되었습니다."); + } + + @DeleteMapping("") + public CommonResponse deleteTags(@RequestBody TagDeleteRequestDto tagDeleteRequestDto) { + Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + tagService.deleteTags(userId, tagDeleteRequestDto); + + return CommonResponse.success("태그가 삭제되었습니다."); + } + + @PostMapping("/recommend") + public CommonResponse> recommendTags( + @RequestBody(required = false) TagRecommendRequestDto tagRecommendRequestDto + ) { + Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + TagRecommendRequestDto requestDto = tagRecommendRequestDto == null + ? new TagRecommendRequestDto(List.of()) + : tagRecommendRequestDto; + + return CommonResponse.success(tagService.recommendTags(userId, requestDto)); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/tag/dto/TagCreateRequestDto.java b/src/main/java/com/aisip/OnO/backend/tag/dto/TagCreateRequestDto.java new file mode 100644 index 00000000..8b354f08 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/tag/dto/TagCreateRequestDto.java @@ -0,0 +1,6 @@ +package com.aisip.OnO.backend.tag.dto; + +public record TagCreateRequestDto( + String name +) { +} diff --git a/src/main/java/com/aisip/OnO/backend/tag/dto/TagDeleteRequestDto.java b/src/main/java/com/aisip/OnO/backend/tag/dto/TagDeleteRequestDto.java new file mode 100644 index 00000000..caf359c6 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/tag/dto/TagDeleteRequestDto.java @@ -0,0 +1,8 @@ +package com.aisip.OnO.backend.tag.dto; + +import java.util.List; + +public record TagDeleteRequestDto( + List deleteTagIdList +) { +} diff --git a/src/main/java/com/aisip/OnO/backend/tag/dto/TagRecommendRequestDto.java b/src/main/java/com/aisip/OnO/backend/tag/dto/TagRecommendRequestDto.java new file mode 100644 index 00000000..3506c033 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/tag/dto/TagRecommendRequestDto.java @@ -0,0 +1,8 @@ +package com.aisip.OnO.backend.tag.dto; + +import java.util.List; + +public record TagRecommendRequestDto( + List imageUrls +) { +} diff --git a/src/main/java/com/aisip/OnO/backend/tag/dto/TagResponseDto.java b/src/main/java/com/aisip/OnO/backend/tag/dto/TagResponseDto.java new file mode 100644 index 00000000..7330d75f --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/tag/dto/TagResponseDto.java @@ -0,0 +1,12 @@ +package com.aisip.OnO.backend.tag.dto; + +import com.aisip.OnO.backend.tag.entity.Tag; + +public record TagResponseDto( + Long tagId, + String name +) { + public static TagResponseDto from(Tag tag) { + return new TagResponseDto(tag.getId(), tag.getName()); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/tag/entity/ProblemTagMapping.java b/src/main/java/com/aisip/OnO/backend/tag/entity/ProblemTagMapping.java new file mode 100644 index 00000000..7ff347e2 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/tag/entity/ProblemTagMapping.java @@ -0,0 +1,47 @@ +package com.aisip.OnO.backend.tag.entity; + +import com.aisip.OnO.backend.common.entity.BaseEntity; +import com.aisip.OnO.backend.problem.entity.Problem; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +@Entity +@Getter +@Builder(access = AccessLevel.PRIVATE) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLDelete(sql = "UPDATE problem_tag_mapping SET deleted_at = now() WHERE id = ?") +@SQLRestriction("deleted_at IS NULL") +@Table(name = "problem_tag_mapping", + uniqueConstraints = { + @UniqueConstraint(name = "uk_problem_tag_mapping_problem_tag", columnNames = {"problem_id", "tag_id"}) + }, + indexes = { + @Index(name = "idx_problem_tag_mapping_problem_id", columnList = "problem_id"), + @Index(name = "idx_problem_tag_mapping_tag_id", columnList = "tag_id") + }) +public class ProblemTagMapping extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "problem_id", nullable = false) + private Problem problem; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "tag_id", nullable = false) + private Tag tag; + + public static ProblemTagMapping from(Problem problem, Tag tag) { + ProblemTagMapping mapping = ProblemTagMapping.builder() + .problem(problem) + .tag(tag) + .build(); + problem.addTagMappingToProblem(mapping); + return mapping; + } +} diff --git a/src/main/java/com/aisip/OnO/backend/tag/entity/Tag.java b/src/main/java/com/aisip/OnO/backend/tag/entity/Tag.java new file mode 100644 index 00000000..0f16c5ad --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/tag/entity/Tag.java @@ -0,0 +1,41 @@ +package com.aisip.OnO.backend.tag.entity; + +import com.aisip.OnO.backend.common.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +@Entity +@Getter +@Builder(access = AccessLevel.PRIVATE) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLDelete(sql = "UPDATE tag SET deleted_at = now() WHERE id = ?") +@SQLRestriction("deleted_at IS NULL") +@Table(name = "tag", indexes = { + @Index(name = "idx_tag_user_id", columnList = "user_id"), + @Index(name = "idx_tag_user_normalized", columnList = "user_id, normalized_name", unique = true) +}) +public class Tag extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private Long userId; + + @Column(nullable = false, length = 30) + private String name; + + @Column(nullable = false, length = 30) + private String normalizedName; + + public static Tag from(Long userId, String name, String normalizedName) { + return Tag.builder() + .userId(userId) + .name(name) + .normalizedName(normalizedName) + .build(); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/tag/exception/TagErrorCase.java b/src/main/java/com/aisip/OnO/backend/tag/exception/TagErrorCase.java new file mode 100644 index 00000000..f7baf288 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/tag/exception/TagErrorCase.java @@ -0,0 +1,24 @@ +package com.aisip.OnO.backend.tag.exception; + +import com.aisip.OnO.backend.common.exception.ErrorCase; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum TagErrorCase implements ErrorCase { + + TAG_NAME_EMPTY(400, 9001, "태그명은 비어 있을 수 없습니다."), + + TAG_NAME_TOO_LONG(400, 9002, "태그명은 30자 이하여야 합니다."), + + TAG_NOT_FOUND(404, 9003, "태그를 찾을 수 없습니다."), + + TAG_USER_UNMATCHED(403, 9004, "태그를 소유한 유저가 아닙니다."), + + TAG_LIMIT_EXCEEDED(400, 9005, "문제당 태그는 최대 5개까지 가능합니다."); + + private final Integer httpStatusCode; + private final Integer errorCode; + private final String message; +} diff --git a/src/main/java/com/aisip/OnO/backend/tag/repository/ProblemTagMappingRepository.java b/src/main/java/com/aisip/OnO/backend/tag/repository/ProblemTagMappingRepository.java new file mode 100644 index 00000000..81c104b6 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/tag/repository/ProblemTagMappingRepository.java @@ -0,0 +1,19 @@ +package com.aisip.OnO.backend.tag.repository; + +import com.aisip.OnO.backend.tag.entity.ProblemTagMapping; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface ProblemTagMappingRepository extends JpaRepository { + + List findAllByProblemId(Long problemId); + + List findAllByTagId(Long tagId); + + List findAllByTagIdIn(List tagIds); + + List findAllByProblemIdAndTagIdIn(Long problemId, List tagIds); + + List findAllByTagUserIdOrderByCreatedAtDesc(Long userId); +} diff --git a/src/main/java/com/aisip/OnO/backend/tag/repository/TagRepository.java b/src/main/java/com/aisip/OnO/backend/tag/repository/TagRepository.java new file mode 100644 index 00000000..bdfb4576 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/tag/repository/TagRepository.java @@ -0,0 +1,16 @@ +package com.aisip.OnO.backend.tag.repository; + +import com.aisip.OnO.backend.tag.entity.Tag; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface TagRepository extends JpaRepository { + + Optional findByUserIdAndNormalizedName(Long userId, String normalizedName); + + List findAllByUserIdOrderByNameAsc(Long userId); + + List findAllByIdInAndUserId(List ids, Long userId); +} diff --git a/src/main/java/com/aisip/OnO/backend/tag/service/TagService.java b/src/main/java/com/aisip/OnO/backend/tag/service/TagService.java new file mode 100644 index 00000000..93297e8d --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/tag/service/TagService.java @@ -0,0 +1,142 @@ +package com.aisip.OnO.backend.tag.service; + +import com.aisip.OnO.backend.common.exception.ApplicationException; +import com.aisip.OnO.backend.tag.dto.TagCreateRequestDto; +import com.aisip.OnO.backend.tag.dto.TagDeleteRequestDto; +import com.aisip.OnO.backend.tag.dto.TagRecommendRequestDto; +import com.aisip.OnO.backend.tag.dto.TagResponseDto; +import com.aisip.OnO.backend.tag.entity.ProblemTagMapping; +import com.aisip.OnO.backend.tag.entity.Tag; +import com.aisip.OnO.backend.tag.exception.TagErrorCase; +import com.aisip.OnO.backend.tag.repository.ProblemTagMappingRepository; +import com.aisip.OnO.backend.tag.repository.TagRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional +public class TagService { + + private static final int MAX_TAG_NAME_LENGTH = 30; + private static final int RECOMMEND_TAG_LIMIT = 5; + + private final TagRepository tagRepository; + private final ProblemTagMappingRepository problemTagMappingRepository; + + public TagResponseDto createTag(Long userId, TagCreateRequestDto requestDto) { + String tagName = normalizeDisplayName(requestDto.name()); + String normalizedName = tagName.toLowerCase(Locale.ROOT); + + Tag tag = tagRepository.findByUserIdAndNormalizedName(userId, normalizedName) + .orElseGet(() -> tagRepository.save(Tag.from(userId, tagName, normalizedName))); + + log.info("userId: {} create tag: {}", userId, tag.getName()); + return TagResponseDto.from(tag); + } + + @Transactional(readOnly = true) + public List getUserTags(Long userId) { + return tagRepository.findAllByUserIdOrderByNameAsc(userId) + .stream() + .map(TagResponseDto::from) + .toList(); + } + + public void deleteTag(Long userId, Long tagId) { + deleteTags(userId, new TagDeleteRequestDto(List.of(tagId))); + } + + public void deleteTags(Long userId, TagDeleteRequestDto requestDto) { + Set tagIds = toDistinctIds(requestDto.deleteTagIdList()); + if (tagIds.isEmpty()) { + throw new ApplicationException(TagErrorCase.TAG_NOT_FOUND); + } + + List tags = tagRepository.findAllById(new ArrayList<>(tagIds)); + if (tags.size() != tagIds.size()) { + throw new ApplicationException(TagErrorCase.TAG_NOT_FOUND); + } + + boolean hasOtherUsersTag = tags.stream().anyMatch(tag -> !tag.getUserId().equals(userId)); + if (hasOtherUsersTag) { + throw new ApplicationException(TagErrorCase.TAG_USER_UNMATCHED); + } + + List mappings = problemTagMappingRepository.findAllByTagIdIn(new ArrayList<>(tagIds)); + if (!mappings.isEmpty()) { + problemTagMappingRepository.deleteAll(mappings); + } + + tagRepository.deleteAll(tags); + log.info("userId: {} deleted tags count: {}", userId, tagIds.size()); + } + + @Transactional(readOnly = true) + public List recommendTags(Long userId, TagRecommendRequestDto requestDto) { + List userTags = tagRepository.findAllByUserIdOrderByNameAsc(userId); + if (userTags.size() <= RECOMMEND_TAG_LIMIT) { + return userTags.stream().map(TagResponseDto::from).toList(); + } + + List recentMappings = problemTagMappingRepository.findAllByTagUserIdOrderByCreatedAtDesc(userId); + Map uniqueRecentTags = new LinkedHashMap<>(); + + for (ProblemTagMapping mapping : recentMappings) { + Tag tag = mapping.getTag(); + uniqueRecentTags.putIfAbsent(tag.getId(), TagResponseDto.from(tag)); + if (uniqueRecentTags.size() >= RECOMMEND_TAG_LIMIT) { + break; + } + } + + // 최근 사용 이력이 부족하면 남은 자리는 사용자 태그 목록으로 보완 + if (uniqueRecentTags.size() < RECOMMEND_TAG_LIMIT) { + for (Tag tag : userTags) { + uniqueRecentTags.putIfAbsent(tag.getId(), TagResponseDto.from(tag)); + if (uniqueRecentTags.size() >= RECOMMEND_TAG_LIMIT) { + break; + } + } + } + + return new ArrayList<>(uniqueRecentTags.values()); + } + + private Set toDistinctIds(List ids) { + if (ids == null) { + return Set.of(); + } + return ids.stream() + .filter(java.util.Objects::nonNull) + .collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new)); + } + + private String normalizeDisplayName(String rawTagName) { + String tagName = rawTagName == null ? "" : rawTagName.trim(); + if (tagName.startsWith("#")) { + tagName = tagName.substring(1).trim(); + } + + if (tagName.isBlank()) { + throw new ApplicationException(TagErrorCase.TAG_NAME_EMPTY); + } + + if (tagName.length() > MAX_TAG_NAME_LENGTH) { + throw new ApplicationException(TagErrorCase.TAG_NAME_TOO_LONG); + } + + return tagName; + } +}