From a55d84e8ee7375fdbd1c558feb5afc2c3ae30fb3 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Sun, 15 Mar 2026 17:00:12 +0900 Subject: [PATCH 01/22] =?UTF-8?q?[fix]=20=EB=A6=AC=ED=94=84=EB=A0=88?= =?UTF-8?q?=EC=89=AC=20=ED=86=A0=ED=81=B0=20=EA=B0=B1=EC=8B=A0=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../OnO/backend/auth/service/JwtTokenService.java | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) 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..30da462a 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 @@ -55,6 +55,7 @@ public TokenResponseDto generateTokens(Long userId, Authority authority) { /** * ✅ 리프레시 토큰을 이용한 액세스 토큰 갱신 + * - refresh 호출 시마다 refresh token도 재발급하여 만료시간 연장 */ public TokenResponseDto refreshAccessToken(String refreshToken) { jwtTokenizer.validateRefreshToken(refreshToken); @@ -85,8 +86,18 @@ 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. refresh token rotation + String newAccessToken = jwtTokenizer.createAccessToken(String.valueOf(userId), Map.of("authority", authority)); + String newRefreshToken = jwtTokenizer.createRefreshToken(String.valueOf(userId), Map.of("authority", authority)); + + refreshTokenRepository.deleteByUserId(userId); + refreshTokenRepository.save(RefreshToken.from(userId, authority, newRefreshToken)); + + long expiration = jwtTokenizer.getRefreshTokenExpirationSeconds(); + redisTokenService.saveRefreshToken(userId, newRefreshToken, expiration); + + log.info("userId: {} has : refresh access token (refresh rotated)", userId); + return new TokenResponseDto(newAccessToken, newRefreshToken); } /** From cab1c585c129394001e0a8ac04c7ba03b6ec6dce Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Sun, 22 Mar 2026 13:43:45 +0900 Subject: [PATCH 02/22] =?UTF-8?q?[fix]=20=EC=98=A4=EB=8B=B5=EB=85=B8?= =?UTF-8?q?=ED=8A=B8=20=EC=88=98=EC=A0=95=20=EC=8B=9C,=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=EB=B6=84=EC=84=9D=EC=9D=84=20=EC=A7=84?= =?UTF-8?q?=ED=96=89=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../OnO/backend/problem/service/ProblemService.java | 11 +++++++++++ 1 file changed, 11 insertions(+) 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..87cd0870 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,6 +5,7 @@ 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; @@ -14,6 +15,7 @@ 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; @@ -38,6 +40,7 @@ public class ProblemService { private final ProblemRepository problemRepository; private final ProblemImageDataRepository problemImageDataRepository; + private final ProblemAnalysisRepository problemAnalysisRepository; private final FolderRepository folderRepository; @@ -238,6 +241,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() From c0a25d06266f14f5acbf66ce6dc9d7070bf96a40 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Sun, 22 Mar 2026 14:24:08 +0900 Subject: [PATCH 03/22] =?UTF-8?q?[feat]=20=ED=83=9C=EA=B7=B8=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20api=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../OnO/backend/problem/entity/Problem.java | 13 +++++ .../backend/tag/controller/TagController.java | 26 +++++++++ .../backend/tag/dto/TagCreateRequestDto.java | 6 +++ .../OnO/backend/tag/dto/TagResponseDto.java | 12 +++++ .../backend/tag/entity/ProblemTagMapping.java | 47 ++++++++++++++++ .../com/aisip/OnO/backend/tag/entity/Tag.java | 41 ++++++++++++++ .../backend/tag/exception/TagErrorCase.java | 18 +++++++ .../ProblemTagMappingRepository.java | 11 ++++ .../backend/tag/repository/TagRepository.java | 11 ++++ .../OnO/backend/tag/service/TagService.java | 53 +++++++++++++++++++ 10 files changed, 238 insertions(+) create mode 100644 src/main/java/com/aisip/OnO/backend/tag/controller/TagController.java create mode 100644 src/main/java/com/aisip/OnO/backend/tag/dto/TagCreateRequestDto.java create mode 100644 src/main/java/com/aisip/OnO/backend/tag/dto/TagResponseDto.java create mode 100644 src/main/java/com/aisip/OnO/backend/tag/entity/ProblemTagMapping.java create mode 100644 src/main/java/com/aisip/OnO/backend/tag/entity/Tag.java create mode 100644 src/main/java/com/aisip/OnO/backend/tag/exception/TagErrorCase.java create mode 100644 src/main/java/com/aisip/OnO/backend/tag/repository/ProblemTagMappingRepository.java create mode 100644 src/main/java/com/aisip/OnO/backend/tag/repository/TagRepository.java create mode 100644 src/main/java/com/aisip/OnO/backend/tag/service/TagService.java 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/tag/controller/TagController.java b/src/main/java/com/aisip/OnO/backend/tag/controller/TagController.java new file mode 100644 index 00000000..0fc12ffb --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/tag/controller/TagController.java @@ -0,0 +1,26 @@ +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.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.*; + +@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)); + } +} 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/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..4c0e14ee --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/tag/exception/TagErrorCase.java @@ -0,0 +1,18 @@ +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자 이하여야 합니다."); + + 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..d67704fc --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/tag/repository/ProblemTagMappingRepository.java @@ -0,0 +1,11 @@ +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); +} 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..331a18cd --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/tag/repository/TagRepository.java @@ -0,0 +1,11 @@ +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.Optional; + +public interface TagRepository extends JpaRepository { + + Optional findByUserIdAndNormalizedName(Long userId, String normalizedName); +} 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..064672d6 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/tag/service/TagService.java @@ -0,0 +1,53 @@ +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.TagResponseDto; +import com.aisip.OnO.backend.tag.entity.Tag; +import com.aisip.OnO.backend.tag.exception.TagErrorCase; +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.Locale; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional +public class TagService { + + private static final int MAX_TAG_NAME_LENGTH = 30; + + private final TagRepository tagRepository; + + 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); + } + + 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; + } +} From caac624ba41c6fe84643a18f7ae78522e9a75d3a Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Sun, 22 Mar 2026 14:25:48 +0900 Subject: [PATCH 04/22] =?UTF-8?q?[feat]=20=ED=83=9C=EA=B7=B8=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20api=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../aisip/OnO/backend/tag/controller/TagController.java | 9 +++++++++ .../aisip/OnO/backend/tag/repository/TagRepository.java | 3 +++ .../com/aisip/OnO/backend/tag/service/TagService.java | 9 +++++++++ 3 files changed, 21 insertions(+) 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 index 0fc12ffb..8bec96b5 100644 --- a/src/main/java/com/aisip/OnO/backend/tag/controller/TagController.java +++ b/src/main/java/com/aisip/OnO/backend/tag/controller/TagController.java @@ -8,6 +8,8 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; +import java.util.List; + @RestController @RequiredArgsConstructor @RequestMapping("/api/tags") @@ -23,4 +25,11 @@ public CommonResponse createTag( return CommonResponse.success(tagService.createTag(userId, tagCreateRequestDto)); } + + @GetMapping("") + public CommonResponse> getUserTags() { + Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + + return CommonResponse.success(tagService.getUserTags(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 index 331a18cd..d62a4855 100644 --- a/src/main/java/com/aisip/OnO/backend/tag/repository/TagRepository.java +++ b/src/main/java/com/aisip/OnO/backend/tag/repository/TagRepository.java @@ -3,9 +3,12 @@ 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); } 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 index 064672d6..3969eec2 100644 --- a/src/main/java/com/aisip/OnO/backend/tag/service/TagService.java +++ b/src/main/java/com/aisip/OnO/backend/tag/service/TagService.java @@ -11,6 +11,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.List; import java.util.Locale; @Slf4j @@ -34,6 +35,14 @@ public TagResponseDto createTag(Long userId, TagCreateRequestDto requestDto) { return TagResponseDto.from(tag); } + @Transactional(readOnly = true) + public List getUserTags(Long userId) { + return tagRepository.findAllByUserIdOrderByNameAsc(userId) + .stream() + .map(TagResponseDto::from) + .toList(); + } + private String normalizeDisplayName(String rawTagName) { String tagName = rawTagName == null ? "" : rawTagName.trim(); if (tagName.startsWith("#")) { From 4e39aaf2188006bb0b157f5024f48df8759df4dc Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Sun, 22 Mar 2026 14:27:42 +0900 Subject: [PATCH 05/22] =?UTF-8?q?[feat]=20=ED=83=9C=EA=B7=B8=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20api=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/tag/controller/TagController.java | 8 ++++++++ .../backend/tag/exception/TagErrorCase.java | 6 +++++- .../ProblemTagMappingRepository.java | 2 ++ .../OnO/backend/tag/service/TagService.java | 20 +++++++++++++++++++ 4 files changed, 35 insertions(+), 1 deletion(-) 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 index 8bec96b5..f5771709 100644 --- a/src/main/java/com/aisip/OnO/backend/tag/controller/TagController.java +++ b/src/main/java/com/aisip/OnO/backend/tag/controller/TagController.java @@ -32,4 +32,12 @@ public CommonResponse> getUserTags() { 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("태그가 삭제되었습니다."); + } } 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 index 4c0e14ee..5c324405 100644 --- a/src/main/java/com/aisip/OnO/backend/tag/exception/TagErrorCase.java +++ b/src/main/java/com/aisip/OnO/backend/tag/exception/TagErrorCase.java @@ -10,7 +10,11 @@ public enum TagErrorCase implements ErrorCase { TAG_NAME_EMPTY(400, 9001, "태그명은 비어 있을 수 없습니다."), - TAG_NAME_TOO_LONG(400, 9002, "태그명은 30자 이하여야 합니다."); + TAG_NAME_TOO_LONG(400, 9002, "태그명은 30자 이하여야 합니다."), + + TAG_NOT_FOUND(404, 9003, "태그를 찾을 수 없습니다."), + + TAG_USER_UNMATCHED(403, 9004, "태그를 소유한 유저가 아닙니다."); private final Integer httpStatusCode; private final Integer errorCode; 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 index d67704fc..2c0f2b15 100644 --- a/src/main/java/com/aisip/OnO/backend/tag/repository/ProblemTagMappingRepository.java +++ b/src/main/java/com/aisip/OnO/backend/tag/repository/ProblemTagMappingRepository.java @@ -8,4 +8,6 @@ public interface ProblemTagMappingRepository extends JpaRepository { List findAllByProblemId(Long problemId); + + List findAllByTagId(Long tagId); } 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 index 3969eec2..ac8654e0 100644 --- a/src/main/java/com/aisip/OnO/backend/tag/service/TagService.java +++ b/src/main/java/com/aisip/OnO/backend/tag/service/TagService.java @@ -3,8 +3,10 @@ import com.aisip.OnO.backend.common.exception.ApplicationException; import com.aisip.OnO.backend.tag.dto.TagCreateRequestDto; 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; @@ -23,6 +25,7 @@ public class TagService { private static final int MAX_TAG_NAME_LENGTH = 30; private final TagRepository tagRepository; + private final ProblemTagMappingRepository problemTagMappingRepository; public TagResponseDto createTag(Long userId, TagCreateRequestDto requestDto) { String tagName = normalizeDisplayName(requestDto.name()); @@ -43,6 +46,23 @@ public List getUserTags(Long userId) { .toList(); } + public void deleteTag(Long userId, Long tagId) { + Tag tag = tagRepository.findById(tagId) + .orElseThrow(() -> new ApplicationException(TagErrorCase.TAG_NOT_FOUND)); + + if (!tag.getUserId().equals(userId)) { + throw new ApplicationException(TagErrorCase.TAG_USER_UNMATCHED); + } + + List mappings = problemTagMappingRepository.findAllByTagId(tagId); + if (!mappings.isEmpty()) { + problemTagMappingRepository.deleteAll(mappings); + } + + tagRepository.delete(tag); + log.info("userId: {} deleted tagId: {}", userId, tagId); + } + private String normalizeDisplayName(String rawTagName) { String tagName = rawTagName == null ? "" : rawTagName.trim(); if (tagName.startsWith("#")) { From 29a6592f8b93936cbde4d56ba6fffa8f649c3a61 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Sun, 22 Mar 2026 14:32:29 +0900 Subject: [PATCH 06/22] =?UTF-8?q?[feat]=20=EC=98=A4=EB=8B=B5=EB=85=B8?= =?UTF-8?q?=ED=8A=B8=20=EB=93=B1=EB=A1=9D=20=EC=8B=9C,=20=ED=83=9C?= =?UTF-8?q?=EA=B7=B8=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=A0=20=EC=88=98=20?= =?UTF-8?q?=EC=9E=88=EB=8A=94=20api=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../problem/controller/ProblemController.java | 15 ++++- .../problem/dto/ProblemTagUpdateDto.java | 9 +++ .../problem/service/ProblemService.java | 66 +++++++++++++++++++ .../backend/tag/exception/TagErrorCase.java | 4 +- .../ProblemTagMappingRepository.java | 2 + .../backend/tag/repository/TagRepository.java | 2 + 6 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/aisip/OnO/backend/problem/dto/ProblemTagUpdateDto.java 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..10b0e47e 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; @@ -157,6 +158,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 +197,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/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/service/ProblemService.java b/src/main/java/com/aisip/OnO/backend/problem/service/ProblemService.java index 87cd0870..85499050 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 @@ -11,6 +11,7 @@ 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; @@ -22,14 +23,22 @@ 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; @@ -55,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) { @@ -292,6 +303,61 @@ 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)); + } + /** * 문제 삭제 (비동기 S3 파일 삭제 적용) * - DB 삭제: 동기 (즉시 완료) 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 index 5c324405..f7baf288 100644 --- a/src/main/java/com/aisip/OnO/backend/tag/exception/TagErrorCase.java +++ b/src/main/java/com/aisip/OnO/backend/tag/exception/TagErrorCase.java @@ -14,7 +14,9 @@ public enum TagErrorCase implements ErrorCase { TAG_NOT_FOUND(404, 9003, "태그를 찾을 수 없습니다."), - TAG_USER_UNMATCHED(403, 9004, "태그를 소유한 유저가 아닙니다."); + TAG_USER_UNMATCHED(403, 9004, "태그를 소유한 유저가 아닙니다."), + + TAG_LIMIT_EXCEEDED(400, 9005, "문제당 태그는 최대 5개까지 가능합니다."); private final Integer httpStatusCode; private final Integer errorCode; 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 index 2c0f2b15..2a8ac175 100644 --- a/src/main/java/com/aisip/OnO/backend/tag/repository/ProblemTagMappingRepository.java +++ b/src/main/java/com/aisip/OnO/backend/tag/repository/ProblemTagMappingRepository.java @@ -10,4 +10,6 @@ public interface ProblemTagMappingRepository extends JpaRepository findAllByProblemId(Long problemId); List findAllByTagId(Long tagId); + + List findAllByProblemIdAndTagIdIn(Long problemId, List tagIds); } 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 index d62a4855..bdfb4576 100644 --- a/src/main/java/com/aisip/OnO/backend/tag/repository/TagRepository.java +++ b/src/main/java/com/aisip/OnO/backend/tag/repository/TagRepository.java @@ -11,4 +11,6 @@ public interface TagRepository extends JpaRepository { Optional findByUserIdAndNormalizedName(Long userId, String normalizedName); List findAllByUserIdOrderByNameAsc(Long userId); + + List findAllByIdInAndUserId(List ids, Long userId); } From bbd315d302e64a8c845b234b85898fe6f8a5f05b Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Sun, 22 Mar 2026 14:50:23 +0900 Subject: [PATCH 07/22] =?UTF-8?q?[feat]=20=EC=98=A4=EB=8B=B5=EB=85=B8?= =?UTF-8?q?=ED=8A=B8=20=EC=9D=91=EB=8B=B5=EC=97=90=20=ED=83=9C=EA=B7=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../problem/dto/ProblemResponseDto.java | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) 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(); } } - From 42a0b9aacd4c5e8422c3309b95d8aa92399c5793 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Sun, 22 Mar 2026 14:52:41 +0900 Subject: [PATCH 08/22] =?UTF-8?q?[feat]=20=EC=98=A4=EB=8B=B5=EB=85=B8?= =?UTF-8?q?=ED=8A=B8=20=EB=93=B1=EB=A1=9D=20=EC=8B=9C=20=ED=83=9C=EA=B7=B8?= =?UTF-8?q?=EB=8F=84=20=ED=95=A8=EA=BB=98=20=EB=93=B1=EB=A1=9D=EB=90=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../problem/dto/ProblemRegisterDto.java | 15 +++++- .../problem/dto/ProblemRegisterV2Dto.java | 15 +++++- .../problem/service/ProblemService.java | 50 ++++++++++++++++++- 3 files changed, 75 insertions(+), 5 deletions(-) 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/service/ProblemService.java b/src/main/java/com/aisip/OnO/backend/problem/service/ProblemService.java index 85499050..91a1faf3 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 @@ -141,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); @@ -164,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() @@ -285,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()); } @@ -358,6 +362,50 @@ private Set toDistinctIds(List ids) { .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 삭제: 동기 (즉시 완료) From 7158f8718ae90affc6ab612dc7421bba26335e7d Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Sun, 22 Mar 2026 14:53:19 +0900 Subject: [PATCH 09/22] =?UTF-8?q?[feat]=20=EC=98=A4=EB=8B=B5=EB=85=B8?= =?UTF-8?q?=ED=8A=B8=20=EC=82=AD=EC=A0=9C=20=EC=8B=9C=20=ED=83=9C=EA=B7=B8?= =?UTF-8?q?=EB=8F=84=20=ED=95=A8=EA=BB=98=20=EC=82=AD=EC=A0=9C=EB=90=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../OnO/backend/problem/service/ProblemService.java | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) 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 91a1faf3..545ed5de 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 @@ -416,19 +416,26 @@ private void syncProblemTags(Problem problem, Long userId, List requestedT 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); From 1ac172fb348252726b3811fb7572ff7a94af95a8 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Sun, 22 Mar 2026 18:43:51 +0900 Subject: [PATCH 10/22] =?UTF-8?q?[fix]=20=ED=86=A0=ED=81=B0=20=EA=B0=B1?= =?UTF-8?q?=EC=8B=A0=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/auth/service/JwtTokenService.java | 38 +++++++++++-------- .../backend/auth/service/JwtTokenizer.java | 10 +++++ 2 files changed, 32 insertions(+), 16 deletions(-) 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 30da462a..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,7 +64,7 @@ public TokenResponseDto generateTokens(Long userId, Authority authority) { /** * ✅ 리프레시 토큰을 이용한 액세스 토큰 갱신 - * - refresh 호출 시마다 refresh token도 재발급하여 만료시간 연장 + * - refresh token은 재사용하고 access token만 재발급 */ public TokenResponseDto refreshAccessToken(String refreshToken) { jwtTokenizer.validateRefreshToken(refreshToken); @@ -72,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); @@ -86,18 +96,14 @@ public TokenResponseDto refreshAccessToken(String refreshToken) { throw new ApplicationException(AuthErrorCase.REFRESH_TOKEN_NOT_EQUAL); } - // 4. refresh token rotation + // 4. access token만 재발급 (refresh token은 만료 연장/교체하지 않음) String newAccessToken = jwtTokenizer.createAccessToken(String.valueOf(userId), Map.of("authority", authority)); - String newRefreshToken = jwtTokenizer.createRefreshToken(String.valueOf(userId), Map.of("authority", authority)); - - refreshTokenRepository.deleteByUserId(userId); - refreshTokenRepository.save(RefreshToken.from(userId, authority, newRefreshToken)); - long expiration = jwtTokenizer.getRefreshTokenExpirationSeconds(); - redisTokenService.saveRefreshToken(userId, newRefreshToken, expiration); + long expiration = jwtTokenizer.getRemainingRefreshExpirationTime(refreshToken); + redisTokenService.saveRefreshToken(userId, refreshToken, expiration); - log.info("userId: {} has : refresh access token (refresh rotated)", userId); - return new TokenResponseDto(newAccessToken, newRefreshToken); + 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 만료 시간을 초 단위로 반환 */ From 23221a9a62f0658bf0fd13866bb8a7616eee188c Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Sun, 22 Mar 2026 18:44:08 +0900 Subject: [PATCH 11/22] =?UTF-8?q?[fix]=20=ED=83=9C=EA=B7=B8=20=EC=97=AC?= =?UTF-8?q?=EB=9F=AC=20=EA=B0=9C=20=EC=82=AD=EC=A0=9C=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/tag/controller/TagController.java | 9 +++++ .../backend/tag/dto/TagDeleteRequestDto.java | 8 ++++ .../OnO/backend/tag/service/TagService.java | 37 ++++++++++++++++--- 3 files changed, 48 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/aisip/OnO/backend/tag/dto/TagDeleteRequestDto.java 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 index f5771709..4c04f42e 100644 --- a/src/main/java/com/aisip/OnO/backend/tag/controller/TagController.java +++ b/src/main/java/com/aisip/OnO/backend/tag/controller/TagController.java @@ -2,6 +2,7 @@ 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.TagResponseDto; import com.aisip.OnO.backend.tag.service.TagService; import lombok.RequiredArgsConstructor; @@ -40,4 +41,12 @@ public CommonResponse deleteTag(@PathVariable("tagId") Long 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("태그가 삭제되었습니다."); + } } 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/service/TagService.java b/src/main/java/com/aisip/OnO/backend/tag/service/TagService.java index ac8654e0..b3bb0453 100644 --- a/src/main/java/com/aisip/OnO/backend/tag/service/TagService.java +++ b/src/main/java/com/aisip/OnO/backend/tag/service/TagService.java @@ -2,6 +2,7 @@ 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.TagResponseDto; import com.aisip.OnO.backend.tag.entity.ProblemTagMapping; import com.aisip.OnO.backend.tag.entity.Tag; @@ -13,8 +14,11 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.ArrayList; +import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; +import java.util.Set; @Slf4j @Service @@ -47,20 +51,41 @@ public List getUserTags(Long userId) { } public void deleteTag(Long userId, Long tagId) { - Tag tag = tagRepository.findById(tagId) - .orElseThrow(() -> new ApplicationException(TagErrorCase.TAG_NOT_FOUND)); + 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); + } - if (!tag.getUserId().equals(userId)) { + boolean hasOtherUsersTag = tags.stream().anyMatch(tag -> !tag.getUserId().equals(userId)); + if (hasOtherUsersTag) { throw new ApplicationException(TagErrorCase.TAG_USER_UNMATCHED); } - List mappings = problemTagMappingRepository.findAllByTagId(tagId); + List mappings = problemTagMappingRepository.findAllByTagIdIn(new ArrayList<>(tagIds)); if (!mappings.isEmpty()) { problemTagMappingRepository.deleteAll(mappings); } - tagRepository.delete(tag); - log.info("userId: {} deleted tagId: {}", userId, tagId); + tagRepository.deleteAll(tags); + log.info("userId: {} deleted tags count: {}", userId, tagIds.size()); + } + + 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) { From 15bc886589567854062825b683549c37c2a09aea Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Sun, 22 Mar 2026 18:44:21 +0900 Subject: [PATCH 12/22] =?UTF-8?q?[fix]=20=ED=83=9C=EA=B7=B8=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=EC=98=A4=EB=8B=B5=EB=85=B8=ED=8A=B8=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20api=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../problem/controller/ProblemController.java | 12 +++++++ .../repository/ProblemRepositoryCustom.java | 10 ++++++ .../repository/ProblemRepositoryImpl.java | 24 ++++++++++++++ .../problem/service/ProblemService.java | 32 +++++++++++++++++++ .../ProblemTagMappingRepository.java | 2 ++ 5 files changed, 80 insertions(+) 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 10b0e47e..8941cb47 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 @@ -71,6 +71,18 @@ 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)); + } + // ✅ 사용자의 문제 개수 조회 @GetMapping("/problemCount") public CommonResponse getUserProblemCount() { 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..53740a31 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,14 @@ 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); } 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..433a0a78 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,27 @@ 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(); + } } 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 545ed5de..1d65aef4 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 @@ -506,4 +506,36 @@ 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); + } } 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 index 2a8ac175..b39ef25b 100644 --- a/src/main/java/com/aisip/OnO/backend/tag/repository/ProblemTagMappingRepository.java +++ b/src/main/java/com/aisip/OnO/backend/tag/repository/ProblemTagMappingRepository.java @@ -11,5 +11,7 @@ public interface ProblemTagMappingRepository extends JpaRepository findAllByTagId(Long tagId); + List findAllByTagIdIn(List tagIds); + List findAllByProblemIdAndTagIdIn(Long problemId, List tagIds); } From dc25c38ede4603f1811d9719206071bbb8d3543e Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Sun, 22 Mar 2026 18:47:36 +0900 Subject: [PATCH 13/22] =?UTF-8?q?[feat]=20=EA=B2=80=EC=83=89=20=ED=85=8D?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EA=B8=B0=EB=B0=98=20=EC=98=A4=EB=8B=B5?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=A1=B0=ED=9A=8C=20api=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../problem/controller/ProblemController.java | 13 +++++++++ .../repository/ProblemRepositoryCustom.java | 10 +++++++ .../repository/ProblemRepositoryImpl.java | 22 +++++++++++++++ .../problem/service/ProblemService.java | 28 +++++++++++++++++++ 4 files changed, 73 insertions(+) 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 8941cb47..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 @@ -83,6 +83,19 @@ public CommonResponse> getProblemsWithCur 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() { 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 53740a31..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 @@ -35,4 +35,14 @@ public interface ProblemRepositoryCustom { * @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 433a0a78..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 @@ -119,4 +119,26 @@ public List findProblemsByTagWithCursor(Long tagId, Long userId, Long c .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 1d65aef4..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 @@ -538,4 +538,32 @@ public CursorPageResponse findProblemsByTagWithCursor(Long t 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); + } } From 0b8736732db8584c9bdcd10455e81b55ab957ec7 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Mon, 23 Mar 2026 16:16:45 +0900 Subject: [PATCH 14/22] =?UTF-8?q?[feat]=20=EC=B5=9C=EA=B7=BC=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=ED=95=9C=20=ED=83=9C=EA=B7=B8=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20api=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/tag/controller/TagController.java | 13 +++++++ .../tag/dto/TagRecommendRequestDto.java | 8 +++++ .../ProblemTagMappingRepository.java | 2 ++ .../OnO/backend/tag/service/TagService.java | 35 +++++++++++++++++++ 4 files changed, 58 insertions(+) create mode 100644 src/main/java/com/aisip/OnO/backend/tag/dto/TagRecommendRequestDto.java 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 index 4c04f42e..9caeec64 100644 --- a/src/main/java/com/aisip/OnO/backend/tag/controller/TagController.java +++ b/src/main/java/com/aisip/OnO/backend/tag/controller/TagController.java @@ -3,6 +3,7 @@ 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; @@ -49,4 +50,16 @@ public CommonResponse deleteTags(@RequestBody TagDeleteRequestDto tagDel 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/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/repository/ProblemTagMappingRepository.java b/src/main/java/com/aisip/OnO/backend/tag/repository/ProblemTagMappingRepository.java index b39ef25b..81c104b6 100644 --- a/src/main/java/com/aisip/OnO/backend/tag/repository/ProblemTagMappingRepository.java +++ b/src/main/java/com/aisip/OnO/backend/tag/repository/ProblemTagMappingRepository.java @@ -14,4 +14,6 @@ public interface ProblemTagMappingRepository extends JpaRepository findAllByTagIdIn(List tagIds); List findAllByProblemIdAndTagIdIn(Long problemId, List tagIds); + + List findAllByTagUserIdOrderByCreatedAtDesc(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 index b3bb0453..93297e8d 100644 --- a/src/main/java/com/aisip/OnO/backend/tag/service/TagService.java +++ b/src/main/java/com/aisip/OnO/backend/tag/service/TagService.java @@ -3,6 +3,7 @@ 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; @@ -15,9 +16,11 @@ 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 @@ -27,6 +30,7 @@ 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; @@ -79,6 +83,37 @@ public void deleteTags(Long userId, TagDeleteRequestDto requestDto) { 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(); From 5e6aa732ed0512a9fa6e856b66c6d959fa155990 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Mon, 23 Mar 2026 17:12:55 +0900 Subject: [PATCH 15/22] =?UTF-8?q?[fix]=20CI/CD=20=ED=82=A4=EC=B2=B4?= =?UTF-8?q?=EC=9D=B8=20=EC=B6=A9=EB=8F=8C=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci-dev.yml | 3 +++ .github/workflows/ci-prod.yml | 3 +++ .github/workflows/deploy-dev.yml | 3 +++ .github/workflows/deploy-prod.yml | 3 +++ 4 files changed, 12 insertions(+) diff --git a/.github/workflows/ci-dev.yml b/.github/workflows/ci-dev.yml index fa209863..aebc8893 100644 --- a/.github/workflows/ci-dev.yml +++ b/.github/workflows/ci-dev.yml @@ -118,6 +118,9 @@ jobs: - name: Deploy with Docker Compose run: | + export DOCKER_CONFIG=/tmp/.docker-ci + mkdir -p "$DOCKER_CONFIG" + echo '{}' > "$DOCKER_CONFIG/config.json" echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u ${{ secrets.DOCKER_USERNAME }} --password-stdin docker pull ${{ secrets.DOCKER_USERNAME }}/ono:${APP_TAG} diff --git a/.github/workflows/ci-prod.yml b/.github/workflows/ci-prod.yml index b1dd8258..ff28ceff 100644 --- a/.github/workflows/ci-prod.yml +++ b/.github/workflows/ci-prod.yml @@ -116,6 +116,9 @@ jobs: - name: Blue-Green Deployment run: | + export DOCKER_CONFIG=/tmp/.docker-ci + mkdir -p "$DOCKER_CONFIG" + echo '{}' > "$DOCKER_CONFIG/config.json" echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u ${{ secrets.DOCKER_USERNAME }} --password-stdin docker pull ${{ secrets.DOCKER_USERNAME }}/ono:${APP_TAG} diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml index 873a42d1..5ebaf83a 100644 --- a/.github/workflows/deploy-dev.yml +++ b/.github/workflows/deploy-dev.yml @@ -62,6 +62,9 @@ jobs: - name: Deploy with Docker Compose run: | + export DOCKER_CONFIG=/tmp/.docker-ci + mkdir -p "$DOCKER_CONFIG" + echo '{}' > "$DOCKER_CONFIG/config.json" echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u ${{ secrets.DOCKER_USERNAME }} --password-stdin docker pull ${{ secrets.DOCKER_USERNAME }}/ono:${APP_TAG} diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml index 30818a2e..830072ca 100644 --- a/.github/workflows/deploy-prod.yml +++ b/.github/workflows/deploy-prod.yml @@ -60,6 +60,9 @@ jobs: - name: Blue-Green Deployment run: | + export DOCKER_CONFIG=/tmp/.docker-ci + mkdir -p "$DOCKER_CONFIG" + echo '{}' > "$DOCKER_CONFIG/config.json" echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u ${{ secrets.DOCKER_USERNAME }} --password-stdin docker pull ${{ secrets.DOCKER_USERNAME }}/ono:${APP_TAG} From 49f23a114417a334638dd17c3095b3c3a02d01e4 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Mon, 23 Mar 2026 17:20:03 +0900 Subject: [PATCH 16/22] =?UTF-8?q?[fix]=20CI/CD=20=ED=82=A4=EC=B2=B4?= =?UTF-8?q?=EC=9D=B8=20=EC=B6=A9=EB=8F=8C=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci-dev.yml | 11 ++++++----- .github/workflows/ci-prod.yml | 7 ++++--- .github/workflows/deploy-dev.yml | 11 ++++++----- .github/workflows/deploy-prod.yml | 7 ++++--- 4 files changed, 20 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci-dev.yml b/.github/workflows/ci-dev.yml index aebc8893..845d7b12 100644 --- a/.github/workflows/ci-dev.yml +++ b/.github/workflows/ci-dev.yml @@ -121,10 +121,11 @@ jobs: export DOCKER_CONFIG=/tmp/.docker-ci mkdir -p "$DOCKER_CONFIG" echo '{}' > "$DOCKER_CONFIG/config.json" - echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u ${{ secrets.DOCKER_USERNAME }} --password-stdin + DOCKER="docker --config $DOCKER_CONFIG" + 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: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 @@ -132,8 +133,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/ci-prod.yml b/.github/workflows/ci-prod.yml index ff28ceff..f8a94ab5 100644 --- a/.github/workflows/ci-prod.yml +++ b/.github/workflows/ci-prod.yml @@ -119,10 +119,11 @@ jobs: export DOCKER_CONFIG=/tmp/.docker-ci mkdir -p "$DOCKER_CONFIG" echo '{}' > "$DOCKER_CONFIG/config.json" - echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u ${{ secrets.DOCKER_USERNAME }} --password-stdin + DOCKER="docker --config $DOCKER_CONFIG" + 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 + $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/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml index 5ebaf83a..a96a8a68 100644 --- a/.github/workflows/deploy-dev.yml +++ b/.github/workflows/deploy-dev.yml @@ -65,10 +65,11 @@ jobs: export DOCKER_CONFIG=/tmp/.docker-ci mkdir -p "$DOCKER_CONFIG" echo '{}' > "$DOCKER_CONFIG/config.json" - echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u ${{ secrets.DOCKER_USERNAME }} --password-stdin + DOCKER="docker --config $DOCKER_CONFIG" + 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: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 @@ -76,8 +77,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 830072ca..01444dde 100644 --- a/.github/workflows/deploy-prod.yml +++ b/.github/workflows/deploy-prod.yml @@ -63,10 +63,11 @@ jobs: export DOCKER_CONFIG=/tmp/.docker-ci mkdir -p "$DOCKER_CONFIG" echo '{}' > "$DOCKER_CONFIG/config.json" - echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u ${{ secrets.DOCKER_USERNAME }} --password-stdin + DOCKER="docker --config $DOCKER_CONFIG" + 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 + $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 From 3ad81d89e0fdadc3a753d181f0c08ec7507e6f7b Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Mon, 23 Mar 2026 17:20:39 +0900 Subject: [PATCH 17/22] =?UTF-8?q?[fix]=20ci/cd=20=EB=8F=99=EC=9E=91=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci-dev.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-dev.yml b/.github/workflows/ci-dev.yml index 845d7b12..9c955760 100644 --- a/.github/workflows/ci-dev.yml +++ b/.github/workflows/ci-dev.yml @@ -2,7 +2,7 @@ name: CI Build & Deploy Dev on: push: - branches: ["develop"] + branches: ["fix/cicd"] workflow_dispatch: permissions: From f00262106ce3910b78fcbbc8a74f88394334a5d2 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Mon, 23 Mar 2026 17:26:39 +0900 Subject: [PATCH 18/22] =?UTF-8?q?[fix]=20=ED=82=A4=EC=B2=B4=EC=9D=B8=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EB=B0=A9=EC=8B=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci-dev.yml | 6 ++++-- .github/workflows/ci-prod.yml | 6 ++++-- .github/workflows/deploy-dev.yml | 6 ++++-- .github/workflows/deploy-prod.yml | 6 ++++-- 4 files changed, 16 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci-dev.yml b/.github/workflows/ci-dev.yml index 9c955760..16d27b9f 100644 --- a/.github/workflows/ci-dev.yml +++ b/.github/workflows/ci-dev.yml @@ -120,9 +120,11 @@ jobs: run: | export DOCKER_CONFIG=/tmp/.docker-ci mkdir -p "$DOCKER_CONFIG" - echo '{}' > "$DOCKER_CONFIG/config.json" DOCKER="docker --config $DOCKER_CONFIG" - echo "${{ secrets.DOCKER_PASSWORD }}" | $DOCKER login -u ${{ secrets.DOCKER_USERNAME }} --password-stdin + 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 diff --git a/.github/workflows/ci-prod.yml b/.github/workflows/ci-prod.yml index f8a94ab5..bf1f977a 100644 --- a/.github/workflows/ci-prod.yml +++ b/.github/workflows/ci-prod.yml @@ -118,9 +118,11 @@ jobs: run: | export DOCKER_CONFIG=/tmp/.docker-ci mkdir -p "$DOCKER_CONFIG" - echo '{}' > "$DOCKER_CONFIG/config.json" DOCKER="docker --config $DOCKER_CONFIG" - echo "${{ secrets.DOCKER_PASSWORD }}" | $DOCKER login -u ${{ secrets.DOCKER_USERNAME }} --password-stdin + 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 diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml index a96a8a68..444bfa12 100644 --- a/.github/workflows/deploy-dev.yml +++ b/.github/workflows/deploy-dev.yml @@ -64,9 +64,11 @@ jobs: run: | export DOCKER_CONFIG=/tmp/.docker-ci mkdir -p "$DOCKER_CONFIG" - echo '{}' > "$DOCKER_CONFIG/config.json" DOCKER="docker --config $DOCKER_CONFIG" - echo "${{ secrets.DOCKER_PASSWORD }}" | $DOCKER login -u ${{ secrets.DOCKER_USERNAME }} --password-stdin + 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 diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml index 01444dde..07641656 100644 --- a/.github/workflows/deploy-prod.yml +++ b/.github/workflows/deploy-prod.yml @@ -62,9 +62,11 @@ jobs: run: | export DOCKER_CONFIG=/tmp/.docker-ci mkdir -p "$DOCKER_CONFIG" - echo '{}' > "$DOCKER_CONFIG/config.json" DOCKER="docker --config $DOCKER_CONFIG" - echo "${{ secrets.DOCKER_PASSWORD }}" | $DOCKER login -u ${{ secrets.DOCKER_USERNAME }} --password-stdin + 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 From 8335c2cf47c32c2e3373861ebb593b3469475b28 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Mon, 23 Mar 2026 17:42:32 +0900 Subject: [PATCH 19/22] =?UTF-8?q?[fix]=20mysql-exporter=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.dev.yml | 2 +- docker-compose.prod.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index de01a881..260239cc 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -146,7 +146,7 @@ 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 environment: diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index c7d403a7..98998e65 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -200,7 +200,7 @@ 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 environment: From 14932b9faf80c6260462ff2ae39df13d42932d9a Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Mon, 23 Mar 2026 18:06:12 +0900 Subject: [PATCH 20/22] =?UTF-8?q?[fix]=20mysql-exporter=20=EB=B2=84?= =?UTF-8?q?=EC=A0=84=20=EC=B5=9C=EC=8B=A0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci-dev.yml | 2 ++ .github/workflows/ci-prod.yml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.github/workflows/ci-dev.yml b/.github/workflows/ci-dev.yml index 16d27b9f..68f41f9c 100644 --- a/.github/workflows/ci-dev.yml +++ b/.github/workflows/ci-dev.yml @@ -158,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 bf1f977a..7314c9ee 100644 --- a/.github/workflows/ci-prod.yml +++ b/.github/workflows/ci-prod.yml @@ -218,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 \ From e4e1d74f1be7356d2e8e2dba97a33daa5e044d40 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Mon, 23 Mar 2026 18:13:44 +0900 Subject: [PATCH 21/22] =?UTF-8?q?[fix]=20mysql-exporter=EB=A5=BC=20?= =?UTF-8?q?=EB=AA=85=EC=8B=9C=20=ED=94=8C=EB=9E=98=EA=B7=B8=20=EB=B0=A9?= =?UTF-8?q?=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.dev.yml | 5 ++++- docker-compose.prod.yml | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 260239cc..e3f1c120 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -149,8 +149,11 @@ services: 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 98998e65..6df7a3b0 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -203,8 +203,11 @@ services: 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 From 7e7d89a35cdc20d2918adc0f22ee86ecc89da6a3 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Mon, 23 Mar 2026 18:21:49 +0900 Subject: [PATCH 22/22] =?UTF-8?q?[test]=20ci/cd=20=EB=AC=B8=EC=A0=9C=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci-dev.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-dev.yml b/.github/workflows/ci-dev.yml index 68f41f9c..c35e9636 100644 --- a/.github/workflows/ci-dev.yml +++ b/.github/workflows/ci-dev.yml @@ -2,7 +2,7 @@ name: CI Build & Deploy Dev on: push: - branches: ["fix/cicd"] + branches: ["develop"] workflow_dispatch: permissions: