diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 88dcfc35..3653a42a 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -24,9 +24,6 @@ jobs: # Set up Docker Buildx - name: Set up Docker Buildx uses: docker/setup-buildx-action@v2 - with: - install: true - driver: docker # Build and push Docker image - name: Build and push diff --git a/src/main/generated/learningFlow/learningFlow_BE/domain/QEmailVerificationToken.java b/src/main/generated/learningFlow/learningFlow_BE/domain/QEmailVerificationToken.java index 39ab4679..a1dd4c84 100644 --- a/src/main/generated/learningFlow/learningFlow_BE/domain/QEmailVerificationToken.java +++ b/src/main/generated/learningFlow/learningFlow_BE/domain/QEmailVerificationToken.java @@ -7,6 +7,7 @@ import com.querydsl.core.types.PathMetadata; import javax.annotation.processing.Generated; import com.querydsl.core.types.Path; +import com.querydsl.core.types.dsl.PathInits; /** @@ -17,6 +18,8 @@ public class QEmailVerificationToken extends EntityPathBase updatedAt = _super.updatedAt; + public final QUser user; + public final BooleanPath verified = createBoolean("verified"); public QEmailVerificationToken(String variable) { - super(EmailVerificationToken.class, forVariable(variable)); + this(EmailVerificationToken.class, forVariable(variable), INITS); } public QEmailVerificationToken(Path path) { - super(path.getType(), path.getMetadata()); + this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); } public QEmailVerificationToken(PathMetadata metadata) { - super(EmailVerificationToken.class, metadata); + this(metadata, PathInits.getFor(metadata, INITS)); + } + + public QEmailVerificationToken(PathMetadata metadata, PathInits inits) { + this(EmailVerificationToken.class, metadata, inits); + } + + public QEmailVerificationToken(Class type, PathMetadata metadata, PathInits inits) { + super(type, metadata, inits); + this.user = inits.isInitialized("user") ? new QUser(forProperty("user")) : null; } } diff --git a/src/main/generated/learningFlow/learningFlow_BE/domain/QUser.java b/src/main/generated/learningFlow/learningFlow_BE/domain/QUser.java index a772f919..aa6ef6fb 100644 --- a/src/main/generated/learningFlow/learningFlow_BE/domain/QUser.java +++ b/src/main/generated/learningFlow/learningFlow_BE/domain/QUser.java @@ -22,6 +22,8 @@ public class QUser extends EntityPathBase { public final QBaseEntity _super = new QBaseEntity(this); + public final StringPath bannerImgUrl = createString("bannerImgUrl"); + public final ListPath> bookmarkedCollectionIds = this.>createList("bookmarkedCollectionIds", Long.class, NumberPath.class, PathInits.DIRECT2); //inherited diff --git a/src/main/generated/learningFlow/learningFlow_BE/domain/QUserEpisodeProgress.java b/src/main/generated/learningFlow/learningFlow_BE/domain/QUserEpisodeProgress.java index 6b9e370c..e7aba947 100644 --- a/src/main/generated/learningFlow/learningFlow_BE/domain/QUserEpisodeProgress.java +++ b/src/main/generated/learningFlow/learningFlow_BE/domain/QUserEpisodeProgress.java @@ -31,6 +31,8 @@ public class QUserEpisodeProgress extends EntityPathBase { public final NumberPath episodeNumber = createNumber("episodeNumber", Integer.class); + public final BooleanPath isComplete = createBoolean("isComplete"); + public final EnumPath resourceType = createEnum("resourceType", learningFlow.learningFlow_BE.domain.enums.ResourceType.class); public final NumberPath totalProgress = createNumber("totalProgress", Integer.class); diff --git a/src/main/java/learningFlow/learningFlow_BE/LearningFlowBeApplication.java b/src/main/java/learningFlow/learningFlow_BE/LearningFlowBeApplication.java index 3d9123a8..0bd7a76b 100644 --- a/src/main/java/learningFlow/learningFlow_BE/LearningFlowBeApplication.java +++ b/src/main/java/learningFlow/learningFlow_BE/LearningFlowBeApplication.java @@ -2,7 +2,6 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.context.annotation.Import; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; import org.springframework.scheduling.annotation.EnableScheduling; diff --git a/src/main/java/learningFlow/learningFlow_BE/apiPayload/code/status/ErrorStatus.java b/src/main/java/learningFlow/learningFlow_BE/apiPayload/code/status/ErrorStatus.java index 503babf1..7dd2dde5 100644 --- a/src/main/java/learningFlow/learningFlow_BE/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/learningFlow/learningFlow_BE/apiPayload/code/status/ErrorStatus.java @@ -21,9 +21,24 @@ public enum ErrorStatus implements BaseErrorCode { // 멤버 관려 에러 + EMAIL_ALREADY_EXISTS(HttpStatus.BAD_REQUEST,"EMAIL4001" ,"이미 동일한 이메일로 생성된 계정이 존재합니다."), + EMAIL_VERIFICATION_IN_PROGRESS(HttpStatus.BAD_REQUEST, "EMAIL4002", "이미 진행 중인 이메일 인증이 있습니다. 이메일을 확인해주세요."), + EMAIL_CHANGE_SAME_AS_CURRENT(HttpStatus.BAD_REQUEST, "EMAIL4004", "기존과 동일한 이메일로는 변경하실 수 없습니다."), + GOOGLE_USER_CANNOT_CHANGE_EMAIL(HttpStatus.BAD_REQUEST,"EMAIL4005","구글 로그인 유저는 이메일 변경을 하실 수 없습니다."), + EMAIL_CODE_INVALID(HttpStatus.BAD_REQUEST, "EMAIL4006", "유효하지 않은 이메일 인증 코드입니다."), + EMAIL_CODE_EXPIRED(HttpStatus.BAD_REQUEST,"EMAIL4007","만료된 이메일 인증 코드입니다. 이메일 인증을 다시 요청해주세요."), + USER_NOT_FOUND(HttpStatus.BAD_REQUEST, "USER4001", "사용자를 찾을 수 없습니다."), NICKNAME_NOT_EXIST(HttpStatus.BAD_REQUEST, "USER4002", "닉네임은 필수 입니다."), + // 비밀번호 관련 에러 추가 + INVALID_PASSWORD(HttpStatus.BAD_REQUEST, "PASSWORD4001", "유효하지 않은 비밀번호입니다."), + PASSWORD_CURRENT_MISMATCH(HttpStatus.BAD_REQUEST, "PASSWORD4002", "현재 비밀번호가 일치하지 않습니다."), + PASSWORD_SAME_AS_CURRENT(HttpStatus.BAD_REQUEST, "PASSWORD4003", "새 비밀번호는 현재 비밀번호와 달라야 합니다."), + PASSWORD_RESET_CODE_INVALID(HttpStatus.BAD_REQUEST, "PASSWORD4004", "유효하지 않은 비밀번호 재설정 코드입니다."), + PASSWORD_RESET_CODE_EXPIRED(HttpStatus.BAD_REQUEST, "PASSWORD4005", "만료된 비밀번호 재설정 코드입니다. 비밀번호 재설정을 다시 요청해주세요."), + + //Resources 관련 에어 RESOURCES_NOT_FOUND(HttpStatus.NOT_FOUND,"RESOURCE4001","강의 에피소드를 찾을 수 없습니다."), QUANTITY_IS_NULL(HttpStatus.BAD_REQUEST, "RESOURCE4002", "분량이 존재하지 않습니다"), @@ -41,10 +56,6 @@ public enum ErrorStatus implements BaseErrorCode { // 예시,,, ARTICLE_NOT_FOUND(HttpStatus.NOT_FOUND, "ARTICLE4001", "게시글이 없습니다."), - - EMAIL_ALREADY_EXISTS(HttpStatus.BAD_REQUEST,"EMAIL4001" ,"이미 동일한 이메일로 생성된 계정이 존재합니다."), - INVALID_PASSWORD(HttpStatus.BAD_REQUEST, "PASSWORD4001", "유효하지 않은 비밀번호입니다."), - //이미지 IMAGE_FORMAT_BADREQUEST(HttpStatus.BAD_REQUEST,"COMMON400","이미지 파일만 업로드할 수 있습니다."), IMAGE_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON5001", "이미지 업로드에 실패했습니다. 다시 시도해주세요."), diff --git a/src/main/java/learningFlow/learningFlow_BE/config/CorsConfig.java b/src/main/java/learningFlow/learningFlow_BE/config/CorsConfig.java index 8f1b0568..080ca919 100644 --- a/src/main/java/learningFlow/learningFlow_BE/config/CorsConfig.java +++ b/src/main/java/learningFlow/learningFlow_BE/config/CorsConfig.java @@ -1,3 +1,4 @@ +/* package learningFlow.learningFlow_BE.config; //Spring Security까지 CORS 적용 목적 @@ -7,6 +8,7 @@ import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import org.springframework.web.filter.CorsFilter; +import java.util.Arrays; import java.util.List; @Configuration @@ -16,12 +18,28 @@ public class CorsConfig { public CorsFilter corsFilter() { UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); CorsConfiguration config = new CorsConfiguration(); + + config.setAllowedOrigins(Arrays.asList( + "http://localhost:3000", + "http://localhost:8081", + "http://onboarding.p-e.kr:8080", + "http://54.180.118.227", + "https://accounts.google.com" + )); + + config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); + config.setAllowedHeaders(Arrays.asList("*")); config.setAllowCredentials(true); - config.setAllowedOrigins(List.of("http://localhost:3000", "http://localhost:8081")); // ✅ Swagger 포함 - config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); - config.setAllowedHeaders(List.of("*")); - config.setMaxAge(3600L); + config.setExposedHeaders(Arrays.asList( + "Authorization", + "Refresh-Token", + "Access-Control-Allow-Origin", + "Access-Control-Allow-Credentials" + )); + config.setMaxAge(86400L); + source.registerCorsConfiguration("/**", config); return new CorsFilter(source); } } +*/ diff --git a/src/main/java/learningFlow/learningFlow_BE/config/WebConfig.java b/src/main/java/learningFlow/learningFlow_BE/config/WebConfig.java index d94dacbe..b5040d3d 100644 --- a/src/main/java/learningFlow/learningFlow_BE/config/WebConfig.java +++ b/src/main/java/learningFlow/learningFlow_BE/config/WebConfig.java @@ -1,3 +1,4 @@ +/* package learningFlow.learningFlow_BE.config; import org.springframework.context.annotation.Configuration; @@ -10,11 +11,19 @@ public class WebConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") - .allowedOrigins("http://localhost:3000", "http://localhost:8080", "http://onboarding.p-e.kr:8080", "http://54.180.118.227") -// .allowedOrigins("http://localhost:3000") // 프론트엔드 주소 + .allowedOriginPatterns("*") // 개발 중일 때만 사용 + // 또는 특정 출처만 허용 + .allowedOrigins( + "http://localhost:3000", + "http://localhost:8081", + "http://onboarding.p-e.kr:8080", + "http://54.180.118.227", + "https://accounts.google.com" + ) .allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(86400L); } } +*/ diff --git a/src/main/java/learningFlow/learningFlow_BE/config/security/SecurityConfig.java b/src/main/java/learningFlow/learningFlow_BE/config/security/SecurityConfig.java index daff5cd2..1e23f7df 100644 --- a/src/main/java/learningFlow/learningFlow_BE/config/security/SecurityConfig.java +++ b/src/main/java/learningFlow/learningFlow_BE/config/security/SecurityConfig.java @@ -53,10 +53,16 @@ public SecurityFilterChain filterChain(HttpSecurity http, JwtAuthenticationFilte "/search/**", "/", "/collections/{collectionId:[\\d]+}", - "/image/upload" //이미지 업로드는 허용 + "/image/upload", //이미지 업로드는 허용 + "/favicon.ico", + "/register", + "/register/complete", + "/login", + "/login/google", + "/oauth2/**", + "/login/oauth2/**", + "/user/change-email" ).permitAll() - .requestMatchers( - "/register", "/register/complete", "/login", "/login/google", "/oauth2/**").permitAll() .requestMatchers("/admin/**").hasRole("ADMIN") .requestMatchers("/user/**", "/resources/**", "/collections/{collectionId:[\\d]+}/likes", "/logout/**").authenticated() .anyRequest().permitAll() @@ -72,7 +78,6 @@ public SecurityFilterChain filterChain(HttpSecurity http, JwtAuthenticationFilte .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) .exceptionHandling(exception -> exception.authenticationEntryPoint(authenticationEntryPoint)); - return http.build(); } @@ -89,10 +94,37 @@ public PasswordEncoder passwordEncoder() { @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); - configuration.setAllowedOrigins(List.of("http://localhost:3000")); // 프론트엔드 주소 + + configuration.setAllowedOrigins(Arrays.asList( + "http://localhost:3000", + "http://localhost:8081", + "http://onboarding.p-e.kr:8080", + "http://54.180.118.227", + "https://accounts.google.com" + )); + configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); - configuration.setAllowedHeaders(List.of("*")); + configuration.setAllowedHeaders(Arrays.asList( + "Authorization", + "Refresh-Token", + "Access-Control-Allow-Origin", + "Access-Control-Allow-Credentials", + "Content-Type", + "Accept", + "Origin", + "X-Requested-With" + )); configuration.setAllowCredentials(true); + configuration.setExposedHeaders(Arrays.asList( + "Authorization", + "Refresh-Token", + "Access-Control-Allow-Origin", + "Access-Control-Allow-Credentials", + "Content-Type", + "Accept", + "Origin", + "X-Requested-With" + )); configuration.setMaxAge(86400L); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); diff --git a/src/main/java/learningFlow/learningFlow_BE/converter/CollectionConverter.java b/src/main/java/learningFlow/learningFlow_BE/converter/CollectionConverter.java index 870f6f0b..35a5fea8 100644 --- a/src/main/java/learningFlow/learningFlow_BE/converter/CollectionConverter.java +++ b/src/main/java/learningFlow/learningFlow_BE/converter/CollectionConverter.java @@ -47,12 +47,12 @@ public static SearchRequestDTO.SearchConditionDTO toSearchConditionDTO( public static CollectionResponseDTO.SearchResultDTO toSearchResultDTO( List collections, - Long lastId, boolean hasNext, int totalPages, int currentPage, User currentUser, - Map learningInfoMap + Map learningInfoMap, + int totalCount ) { List list = collections.stream() .map(collection -> toCollectionPreviewDTO( @@ -63,10 +63,10 @@ public static CollectionResponseDTO.SearchResultDTO toSearchResultDTO( return CollectionResponseDTO.SearchResultDTO.builder() .searchResults(list) - .lastId(lastId) .hasNext(hasNext) .currentPage(currentPage) .totalPages(totalPages) + .totalCount(totalCount) .build(); } @@ -82,6 +82,7 @@ public static CollectionResponseDTO.CollectionPreviewDTO toCollectionPreviewDTO( return CollectionResponseDTO.CollectionPreviewDTO.builder() .collectionId(collection.getId()) + .imageUrl(collection.getCollectionImgUrl()) .interestField(collection.getInterestField()) .title(collection.getTitle()) .creator(collection.getCreator()) diff --git a/src/main/java/learningFlow/learningFlow_BE/converter/ResourceConverter.java b/src/main/java/learningFlow/learningFlow_BE/converter/ResourceConverter.java index d57c8197..49a0d5a3 100644 --- a/src/main/java/learningFlow/learningFlow_BE/converter/ResourceConverter.java +++ b/src/main/java/learningFlow/learningFlow_BE/converter/ResourceConverter.java @@ -2,12 +2,10 @@ import learningFlow.learningFlow_BE.domain.*; import learningFlow.learningFlow_BE.domain.Collection; -import learningFlow.learningFlow_BE.web.dto.collection.CollectionResponseDTO; import learningFlow.learningFlow_BE.web.dto.resource.ResourceRequestDTO; import learningFlow.learningFlow_BE.web.dto.resource.ResourceResponseDTO; import learningFlow.learningFlow_BE.domain.CollectionEpisode; import learningFlow.learningFlow_BE.domain.UserCollection; -import learningFlow.learningFlow_BE.web.dto.home.HomeResponseDTO; import java.util.*; public class ResourceConverter { @@ -23,10 +21,15 @@ public static ResourceResponseDTO.ResourceUrlDTO watchEpisode(Collection collect .urlTitle(resource.getTitle()) .progress(userProgress.getCurrentProgress()) .memoContents(memoContents) - .episodeInformationList(episodeInformationList(collection)) + .episodeInformationList(episodeInformationList(collection,userProgress)) .build(); } - public static ResourceResponseDTO.ResourceBlogUrlDTO watchBlogEpisode(Collection collection, UserEpisodeProgress userProgress, String pageResource, String resourceTitle, Optional memo){ + public static ResourceResponseDTO.ResourceBlogUrlDTO watchBlogEpisode( + Collection collection, + UserEpisodeProgress userProgress, + String pageResource, + String resourceTitle, + Optional memo){ String memoContents = "작성하신 글의 첫 줄은 노트의 제목이 됩니다, 최대 2,000자까지 입력하실 수 있어요"; if (memo.isPresent()) memoContents = memo.get().getContents(); @@ -38,17 +41,20 @@ public static ResourceResponseDTO.ResourceBlogUrlDTO watchBlogEpisode(Collection .urlTitle(resourceTitle) .progress(userProgress.getCurrentProgress()) .memoContents(memoContents) - .episodeInformationList(episodeInformationList(collection)) + .episodeInformationList(episodeInformationList(collection, userProgress)) .build(); } - public static List episodeInformationList(Collection collection) { + public static List episodeInformationList( + Collection collection, UserEpisodeProgress userEpisodeProgress + ) { List episodeInformationList = new ArrayList<>(); for (CollectionEpisode episode : collection.getEpisodes()) { episodeInformationList.add(new ResourceResponseDTO.episodeInformation( episode.getEpisodeNumber(), - episode.getResource().getTitle() + episode.getResource().getTitle(), + userEpisodeProgress.getIsComplete() )); } episodeInformationList.sort(Comparator.comparingInt(ResourceResponseDTO.episodeInformation::getEpisodeNumber)); @@ -62,6 +68,12 @@ public static ResourceResponseDTO.ProgressResponseDTO toSaveProgressResponse(Res .build(); } + public static ResourceResponseDTO.changeEpisodeIsCompleteDTO toChangeEpisodeIsCompleteDTO(Boolean isComplete){ + return ResourceResponseDTO.changeEpisodeIsCompleteDTO.builder() + .isComplete(isComplete) + .build(); + } + public static ResourceResponseDTO.SearchResultResourceDTO convertToResourceDTO(CollectionEpisode episode) { return ResourceResponseDTO.SearchResultResourceDTO.builder() .resourceId(episode.getId()) diff --git a/src/main/java/learningFlow/learningFlow_BE/converter/UserConverter.java b/src/main/java/learningFlow/learningFlow_BE/converter/UserConverter.java index 9af1797a..5420ff52 100644 --- a/src/main/java/learningFlow/learningFlow_BE/converter/UserConverter.java +++ b/src/main/java/learningFlow/learningFlow_BE/converter/UserConverter.java @@ -19,6 +19,7 @@ public static UserResponseDTO.UserLoginResponseDTO toUserLoginResponseDTO(User u .name(user.getName()) .role(user.getRole()) .socialType(user.getSocialType()) + .profileImgUrl(user.getProfileImgUrl()) .build(); } @@ -30,6 +31,7 @@ public static UserInfoDTO convertToUserInfoDTO(User user) { .interestFields(user.getInterestFields()) .preferType(user.getPreferType()) .profileImgUrl(user.getProfileImgUrl()) + .bannerImgUrl(user.getBannerImgUrl()) .build(); } @@ -53,6 +55,7 @@ private static UserResponseDTO.UserPreviewDTO convertToUserPreviewDTO(User user) .email(user.getEmail()) .job(user.getJob().getDescription()) .profileImgUrl(user.getProfileImgUrl()) + .bannerImgUrl(user.getBannerImgUrl()) .build(); } } diff --git a/src/main/java/learningFlow/learningFlow_BE/domain/EmailVerificationToken.java b/src/main/java/learningFlow/learningFlow_BE/domain/EmailVerificationToken.java index 3ec808fc..3f357989 100644 --- a/src/main/java/learningFlow/learningFlow_BE/domain/EmailVerificationToken.java +++ b/src/main/java/learningFlow/learningFlow_BE/domain/EmailVerificationToken.java @@ -1,9 +1,6 @@ package learningFlow.learningFlow_BE.domain; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Id; -import jakarta.persistence.Table; +import jakarta.persistence.*; import lombok.*; import java.time.LocalDateTime; @@ -26,6 +23,10 @@ public class EmailVerificationToken extends BaseEntity { @Column(name = "password", nullable = false) private String password; + @OneToOne(fetch = FetchType.LAZY) // optional = true 제거 + @JoinColumn(name = "user_id", nullable = true) + private User user; + @Column(name = "expiry_date", nullable = false) private LocalDateTime expiryDate; diff --git a/src/main/java/learningFlow/learningFlow_BE/domain/User.java b/src/main/java/learningFlow/learningFlow_BE/domain/User.java index 3949c594..1835c891 100644 --- a/src/main/java/learningFlow/learningFlow_BE/domain/User.java +++ b/src/main/java/learningFlow/learningFlow_BE/domain/User.java @@ -62,6 +62,9 @@ public class User extends BaseEntity { @Column(nullable = true) private String profileImgUrl; + + @Column(nullable = true) + private String bannerImgUrl; // @ManyToOne(fetch = FetchType.LAZY) // @JoinColumn(name = "image_id") // private Image image; @@ -121,6 +124,10 @@ public void changePassword(String newEncodedPassword) { this.pw = newEncodedPassword; } + public void changeEmail(String newEmail) { + this.email = newEmail; + } + public void updateName(String name) { this.name = name; } diff --git a/src/main/java/learningFlow/learningFlow_BE/domain/UserCollection.java b/src/main/java/learningFlow/learningFlow_BE/domain/UserCollection.java index d3208ce5..8fef140a 100644 --- a/src/main/java/learningFlow/learningFlow_BE/domain/UserCollection.java +++ b/src/main/java/learningFlow/learningFlow_BE/domain/UserCollection.java @@ -31,7 +31,7 @@ public class UserCollection extends BaseEntity{ private User user; @Column(name = "user_collection_status", nullable = false) - private Integer userCollectionStatus; + private Integer userCollectionStatus; // 가장 최신에 수강한 강의 저장 @Column(name = "completed_time", nullable = false) private LocalDate completedTime; diff --git a/src/main/java/learningFlow/learningFlow_BE/domain/UserEpisodeProgress.java b/src/main/java/learningFlow/learningFlow_BE/domain/UserEpisodeProgress.java index 9e33dbd6..0767b2be 100644 --- a/src/main/java/learningFlow/learningFlow_BE/domain/UserEpisodeProgress.java +++ b/src/main/java/learningFlow/learningFlow_BE/domain/UserEpisodeProgress.java @@ -24,9 +24,16 @@ public class UserEpisodeProgress extends BaseEntity { @Setter private Integer currentProgress; - @Column(nullable = false) private Integer totalProgress; + @Column(nullable = false) + private Boolean isComplete = false; + @Column(nullable = false) private ResourceType resourceType; + + public Boolean setIsComplete(Boolean isComplete){ + this.isComplete = isComplete; + return this.isComplete; + } } diff --git a/src/main/java/learningFlow/learningFlow_BE/repository/collection/CollectionRepositoryCustom.java b/src/main/java/learningFlow/learningFlow_BE/repository/collection/CollectionRepositoryCustom.java index 432c81a5..0ebb6103 100644 --- a/src/main/java/learningFlow/learningFlow_BE/repository/collection/CollectionRepositoryCustom.java +++ b/src/main/java/learningFlow/learningFlow_BE/repository/collection/CollectionRepositoryCustom.java @@ -9,9 +9,8 @@ import java.util.List; public interface CollectionRepositoryCustom { - List searchCollections(SearchRequestDTO.SearchConditionDTO condition, Long lastId, Pageable pageable); + List searchCollections(SearchRequestDTO.SearchConditionDTO condition, Pageable pageable); Integer getTotalCount(SearchRequestDTO.SearchConditionDTO condition); - List searchNextPage(SearchRequestDTO.SearchConditionDTO condition, Collection lastCollection, Pageable pageable); Integer getCountGreaterThanBookmark(Integer bookmarkCount, Long lastId, SearchRequestDTO.SearchConditionDTO condition); List findTopBookmarkedCollections(int limit); List findByInterestFieldAndPreferType(List interestFields, diff --git a/src/main/java/learningFlow/learningFlow_BE/repository/collection/CollectionRepositoryImpl.java b/src/main/java/learningFlow/learningFlow_BE/repository/collection/CollectionRepositoryImpl.java index fc9ab29a..8a3159e8 100644 --- a/src/main/java/learningFlow/learningFlow_BE/repository/collection/CollectionRepositoryImpl.java +++ b/src/main/java/learningFlow/learningFlow_BE/repository/collection/CollectionRepositoryImpl.java @@ -1,8 +1,8 @@ package learningFlow.learningFlow_BE.repository.collection; -import com.querydsl.core.types.ExpressionUtils; import com.querydsl.core.types.OrderSpecifier; import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.Expressions; import com.querydsl.jpa.impl.JPAQueryFactory; import learningFlow.learningFlow_BE.apiPayload.code.status.ErrorStatus; import learningFlow.learningFlow_BE.apiPayload.exception.handler.CollectionHandler; @@ -28,10 +28,13 @@ public class CollectionRepositoryImpl implements CollectionRepositoryCustom { private final QCollection collection = QCollection.collection; @Override - public List searchCollections(SearchRequestDTO.SearchConditionDTO condition, Long lastId, Pageable pageable) { + public List searchCollections(SearchRequestDTO.SearchConditionDTO condition, Pageable pageable) { + + int skip = pageable.getPageNumber() * pageable.getPageSize(); BooleanExpression searchConditions = createSearchConditions(condition); +/* if (lastId == 0L) { return jpaQueryFactory .select(episode.collection) @@ -53,6 +56,16 @@ public List searchCollections(SearchRequestDTO.SearchConditionDTO co } return searchNextPage(condition, lastCollection, pageable); +*/ + return jpaQueryFactory + .select(episode.collection) + .from(episode) + .where(searchConditions) + .groupBy(episode.collection.id) + .orderBy(createOrderSpecifier(condition.getSortType())) + .offset(skip) // ✅ added: 시작 위치 설정 + .limit(pageable.getPageSize()) + .fetch(); } private OrderSpecifier[] createOrderSpecifier(Integer sortType) { // 반환 타입을 배열로 변경 @@ -66,7 +79,7 @@ private OrderSpecifier[] createOrderSpecifier(Integer sortType) { // 반환 } private BooleanExpression createSearchConditions(SearchRequestDTO.SearchConditionDTO condition) { - return (BooleanExpression) ExpressionUtils.allOf( + return Expressions.allOf( createDynamicKeyword(condition.getKeyword()), createDynamicInterestFields(condition.getInterestFields()), createDynamicPreferMediaType(condition.getPreferMediaType()), @@ -86,21 +99,6 @@ public Integer getTotalCount(SearchRequestDTO.SearchConditionDTO condition) { return count != null ? count.intValue() : 0; } - @Override - public List searchNextPage(SearchRequestDTO.SearchConditionDTO condition, Collection lastCollection, Pageable pageable) { - BooleanExpression searchConditions = createSearchConditions(condition); - BooleanExpression cursorCondition = createCursorCondition(condition.getSortType(), lastCollection); - - return jpaQueryFactory - .select(episode.collection) - .from(episode) - .where(searchConditions, cursorCondition) - .groupBy(episode.collection.id) - .orderBy(createOrderSpecifier(condition.getSortType())) - .limit(pageable.getPageSize()) - .fetch(); - } - private BooleanExpression createCursorCondition(Integer sortType, Collection lastCollection) { if (sortType == null || sortType == 0) { return episode.collection.id.lt(lastCollection.getId()); diff --git a/src/main/java/learningFlow/learningFlow_BE/security/handler/OAuth2LoginSuccessHandler.java b/src/main/java/learningFlow/learningFlow_BE/security/handler/OAuth2LoginSuccessHandler.java index dc3c8ff6..ccca6b8e 100644 --- a/src/main/java/learningFlow/learningFlow_BE/security/handler/OAuth2LoginSuccessHandler.java +++ b/src/main/java/learningFlow/learningFlow_BE/security/handler/OAuth2LoginSuccessHandler.java @@ -10,6 +10,8 @@ import learningFlow.learningFlow_BE.web.dto.user.UserResponseDTO; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; @@ -26,6 +28,9 @@ public class OAuth2LoginSuccessHandler implements AuthenticationSuccessHandler { private final JwtTokenProvider jwtTokenProvider; + @Value("${app.frontend-url}") + private String frontendUrl; + @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { @@ -34,7 +39,7 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo // Principal 타입 확인, 첫 로그인인 경우 회원가입으로 이동 if (authentication.getPrincipal() instanceof OAuth2UserTemp oAuth2UserTemp) { String temporaryToken = jwtTokenProvider.createTemporaryToken(oAuth2UserTemp); - String redirectUrl = "/oauth2/additional-info?token=" + temporaryToken; + String redirectUrl = frontendUrl + "/oauth2/additional-info?oauth2RegistrationCode=" + temporaryToken; response.sendRedirect(redirectUrl); return; } @@ -48,16 +53,21 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo //Access Token 생성 String accessToken = jwtTokenProvider.createAccessToken(authentication); log.info("Access 토큰 발급 : {}", accessToken); - response.addHeader("Authorization", "Bearer " + accessToken); + response.setHeader("Authorization", "Bearer " + accessToken); String refreshToken = jwtTokenProvider.createRefreshToken(authentication); log.info("자동 로그인 활성화, Refresh Token 발급 : {}", refreshToken); - response.addHeader("Refresh-Token", refreshToken); + response.setHeader("Refresh-Token", refreshToken); // 헤더 설정 확인 로깅 log.info("Authorization Header: {}", response.getHeader("Authorization")); log.info("Refresh-Token Header: {}", response.getHeader("Refresh-Token")); + response.setHeader("Access-Control-Allow-Origin", request.getHeader("Origin")); + response.setHeader("Access-Control-Allow-Credentials", "true"); + response.setHeader("Access-Control-Expose-Headers", "Authorization, Refresh-Token"); + +/* UserResponseDTO.UserLoginResponseDTO loginResponse = toUserLoginResponseDTO(principalDetails.getUser()); @@ -65,5 +75,7 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo response.setCharacterEncoding("UTF-8"); String jsonResponse = new ObjectMapper().writeValueAsString(ApiResponse.onSuccess(loginResponse)); response.getWriter().write(jsonResponse); +*/ + response.setStatus(HttpStatus.OK.value()); } } diff --git a/src/main/java/learningFlow/learningFlow_BE/security/jwt/JwtAuthenticationFilter.java b/src/main/java/learningFlow/learningFlow_BE/security/jwt/JwtAuthenticationFilter.java index 3673a330..589868cd 100644 --- a/src/main/java/learningFlow/learningFlow_BE/security/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/learningFlow/learningFlow_BE/security/jwt/JwtAuthenticationFilter.java @@ -131,14 +131,14 @@ private boolean isPermitAllUrl(String requestURI) { requestURI.startsWith("/search") || requestURI.equals("/reset-password") || requestURI.matches("/collections/\\d+") || + requestURI.contains("/user/change-email") || requestURI.startsWith("/user/imgUpload"); // 이미지 업로드는 인증 없이 허용 } - @Override protected boolean shouldNotFilter(HttpServletRequest request) { String path = request.getRequestURI(); - boolean shouldSkip = path.equals("/image/upload"); + boolean shouldSkip = path.equals("/image/upload") || path.equals("/favicon.ico"); log.info("🛑 [JwtAuthenticationFilter] shouldNotFilter 실행: path={}, shouldSkip={}", path, shouldSkip); return shouldSkip; } diff --git a/src/main/java/learningFlow/learningFlow_BE/service/auth/common/UserVerificationEmailService.java b/src/main/java/learningFlow/learningFlow_BE/service/auth/common/UserVerificationEmailService.java index 05c4a4e2..9e0bb04f 100644 --- a/src/main/java/learningFlow/learningFlow_BE/service/auth/common/UserVerificationEmailService.java +++ b/src/main/java/learningFlow/learningFlow_BE/service/auth/common/UserVerificationEmailService.java @@ -16,7 +16,7 @@ public class UserVerificationEmailService { private final JavaMailSender emailSender; - @Value("${app.url}") + @Value("${app.frontend-url}") private String baseUrl; public void sendVerificationEmail(String email, String token) { @@ -73,7 +73,7 @@ public void sendVerificationEmail(String email, String token) { 버튼을 누르면 자동으로 인증 후 추가 정보 입력 페이지로 이동합니다.

- 이메일 인증하기 @@ -101,7 +101,89 @@ public void sendVerificationEmail(String email, String token) { } } - public void sendPasswordResetEmail(String email, String token) { + public void sendEmailResetEmail(String email, String token) { + try { + MimeMessage message = emailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8"); + + helper.setTo(email); + helper.setSubject("[OnBoarding] 이메일 인증"); + + String htmlContent = """ + + + + + + + + + + + + + + +
+
+
+ OnBoarding +
+ +
+

+ 이메일을 변경하기 위해 메일을 인증해주세요 +

+ +

+ 안녕하세요, OnBoarding입니다.
+ 이메일 변경을 완료하기 위해 메일을 인증해주세요.
+ 버튼을 누르면 자동으로 인증 후 이메일 변경이 완료됩니다. +

+ + + 이메일 인증하기 + + +

+ 이 메일은 24시간 동안 유효합니다.
+ 본인이 요청하지 않은 경우, 이 메일을 무시해주세요. +

+
+
+
+ + + """.formatted(baseUrl, token); + + helper.setText(htmlContent, true); + emailSender.send(message); + log.info("이메일 인증 메일 발송 완료: {}", email); + } catch (MessagingException e) { + log.error("이메일 발송 실패: {}", e.getMessage()); + throw new RuntimeException("이메일 발송에 실패했습니다."); + } + } + + public void sendPasswordResetEmail(String email, String passwordResetCode) { try { MimeMessage message = emailSender.createMimeMessage(); @@ -156,7 +238,7 @@ public void sendPasswordResetEmail(String email, String token) { 버튼을 누르면 자동으로 인증 후 비밀번호 재설정 페이지로 이동합니다.

-
이메일 인증하기 @@ -173,7 +255,7 @@ public void sendPasswordResetEmail(String email, String token) { - """.formatted(baseUrl, token); + """.formatted(baseUrl, passwordResetCode); helper.setText(htmlContent, true); emailSender.send(message); diff --git a/src/main/java/learningFlow/learningFlow_BE/service/auth/local/LocalUserAuthService.java b/src/main/java/learningFlow/learningFlow_BE/service/auth/local/LocalUserAuthService.java index 549af332..3b5948e8 100644 --- a/src/main/java/learningFlow/learningFlow_BE/service/auth/local/LocalUserAuthService.java +++ b/src/main/java/learningFlow/learningFlow_BE/service/auth/local/LocalUserAuthService.java @@ -60,14 +60,14 @@ public class LocalUserAuthService { public void initialRegister(UserRequestDTO.InitialRegisterDTO requestDTO) { // 이메일 중복 체크 if (userRepository.existsByEmail(requestDTO.getEmail())) { - throw new RuntimeException("이미 사용중인 이메일입니다."); + throw new GeneralException(ErrorStatus.EMAIL_ALREADY_EXISTS); } // 진행 중인 이메일 인증이 있는지 확인 if (emailVerificationTokenRepository.existsByEmailAndVerifiedFalse(requestDTO.getEmail())) { - throw new RuntimeException("이미 진행 중인 이메일 인증이 있습니다. 이메일을 확인해주세요."); + throw new GeneralException(ErrorStatus.EMAIL_VERIFICATION_IN_PROGRESS); } - //TODO: 현재는 진행중이던 이메일이면 500에러가 나는데 400에러가 나야함! + //TODO: 현재는 진행중이던 이메일이면 500에러가 나는데 400에러가 나야함! -> 확인 바랍니다! // 토큰 생성 String token = UUID.randomUUID().toString(); @@ -88,14 +88,14 @@ public void initialRegister(UserRequestDTO.InitialRegisterDTO requestDTO) { } @Transactional - public EmailVerificationToken validateRegistrationToken(String token) { + public EmailVerificationToken validateRegistrationToken(String emailVerificationCode) { // 토큰 유효성 검증 - EmailVerificationToken verificationToken = emailVerificationTokenRepository.findByTokenAndVerifiedFalse(token) - .orElseThrow(() -> new RuntimeException("유효하지 않은 토큰입니다.")); + EmailVerificationToken verificationToken = emailVerificationTokenRepository.findByTokenAndVerifiedFalse(emailVerificationCode) + .orElseThrow(() -> new GeneralException(ErrorStatus.EMAIL_CODE_INVALID)); if (verificationToken.isExpired()) { emailVerificationTokenRepository.delete(verificationToken); - throw new RuntimeException("만료된 토큰입니다. 회원가입을 다시 진행해주세요."); + throw new GeneralException(ErrorStatus.EMAIL_CODE_EXPIRED); } return verificationToken; @@ -103,13 +103,13 @@ public EmailVerificationToken validateRegistrationToken(String token) { @Transactional public UserResponseDTO.UserLoginResponseDTO completeRegister( - String token, + String emailVerificationCode, UserRequestDTO.CompleteRegisterDTO requestDTO, HttpServletResponse response ) { - String imageUrl = null; +// String profileImgUrl = null; //이메일 토큰 검증 - EmailVerificationToken verificationToken = validateRegistrationToken(token); + EmailVerificationToken verificationToken = validateRegistrationToken(emailVerificationCode); // 로그인 ID 생성 String loginuuid = UUID.randomUUID().toString().substring(0, 8); @@ -135,7 +135,7 @@ public UserResponseDTO.UserLoginResponseDTO completeRegister( .preferType(requestDTO.getPreferType()) .socialType(SocialType.LOCAL) .role(Role.USER) - .profileImgUrl(imageUrl) + .profileImgUrl(requestDTO.getImgProfileUrl()) .inactive(false) .build(); @@ -178,10 +178,6 @@ public UserResponseDTO.UserLoginResponseDTO completeRegister( // } // } - - - - public UserResponseDTO.UserLoginResponseDTO login(UserRequestDTO.UserLoginDTO request, HttpServletResponse response) { try { @@ -259,13 +255,13 @@ public String sendPasswordResetEmail(PrincipalDetails principalDetails) { } @Transactional - public PasswordResetToken validatePasswordResetToken(String token) { - PasswordResetToken resetToken = tokenRepository.findByToken(token) - .orElseThrow(() -> new RuntimeException("유효하지 않은 토큰입니다.")); + public PasswordResetToken validatePasswordResetToken(String passwordResetCode) { + PasswordResetToken resetToken = tokenRepository.findByToken(passwordResetCode) + .orElseThrow(() -> new GeneralException(ErrorStatus.PASSWORD_RESET_CODE_INVALID)); if (resetToken.isExpired()) { tokenRepository.delete(resetToken); - throw new RuntimeException("만료된 토큰입니다. 비밀번호 재설정을 다시 요청해주세요."); + throw new GeneralException(ErrorStatus.PASSWORD_RESET_CODE_EXPIRED); } return resetToken; @@ -273,19 +269,19 @@ public PasswordResetToken validatePasswordResetToken(String token) { @Transactional public String resetPassword( - String token, + String passwordResetCode, UserRequestDTO.ResetPasswordDTO request ) { - PasswordResetToken resetToken = validatePasswordResetToken(token); + PasswordResetToken resetToken = validatePasswordResetToken(passwordResetCode); User user = resetToken.getUser(); if (!passwordEncoder.matches(request.getCurrentPassword(), user.getPw())) { - throw new RuntimeException("현재 비밀번호가 일치하지 않습니다."); + throw new GeneralException(ErrorStatus.PASSWORD_CURRENT_MISMATCH); } if (passwordEncoder.matches(request.getNewPassword(), user.getPw())) { - throw new RuntimeException("새 비밀번호는 현재 비밀번호와 달라야 합니다."); + throw new GeneralException(ErrorStatus.PASSWORD_SAME_AS_CURRENT); } user.changePassword(passwordEncoder.encode(request.getNewPassword())); @@ -297,6 +293,68 @@ public String resetPassword( return "비밀번호 재설정이 완료되었습니다."; } + @Transactional + public String sendEmailResetEmail(String email, PrincipalDetails principalDetails) { + + //구글 로그인 유저인지 확인 + if (principalDetails.getUser().getSocialType().equals(SocialType.GOOGLE)) { + throw new GeneralException(ErrorStatus.GOOGLE_USER_CANNOT_CHANGE_EMAIL); + } + + // 이메일 중복 체크 + if (userRepository.existsByEmail(email)) { + throw new GeneralException(ErrorStatus.EMAIL_ALREADY_EXISTS); + } + + //현재 이메일과 동일한 이메일로는 변경 불가 + if (principalDetails.getUser().getEmail().equals(email)) { + throw new GeneralException(ErrorStatus.EMAIL_CHANGE_SAME_AS_CURRENT); + } + + // 진행 중인 이메일 인증이 있는지 확인 + if (emailVerificationTokenRepository.existsByEmailAndVerifiedFalse(email)) { + throw new GeneralException(ErrorStatus.EMAIL_VERIFICATION_IN_PROGRESS); + } + + // 토큰 생성 + String token = UUID.randomUUID().toString(); + + // 이메일 인증 토큰 저장 + EmailVerificationToken verificationToken = EmailVerificationToken.builder() + .token(token) + .email(email) + .password(passwordEncoder.encode(principalDetails.getPassword())) + .user(principalDetails.getUser()) + .expiryDate(LocalDateTime.now().plusHours(24)) + .verified(false) + .build(); + + emailVerificationTokenRepository.save(verificationToken); + + // 인증 이메일 발송 + userVerificationEmailService.sendEmailResetEmail(email, token); + + return "이메일 재설정을 위한 인증 링크를 담은 이메일이 성공적으로 발송되었습니다."; + } + + @Transactional + public String changeEmail( + EmailVerificationToken emailVerificationToken + ) { + User user = emailVerificationToken.getUser(); + if (user == null) { + throw new GeneralException(ErrorStatus.USER_NOT_FOUND); + } + + // 이메일 업데이트 + user.changeEmail(emailVerificationToken.getEmail()); + + // 토큰 삭제 + emailVerificationTokenRepository.delete(emailVerificationToken); + + return "이메일이 성공적으로 변경되었습니다."; + } + @Transactional public String logout(HttpServletRequest request, HttpServletResponse response) { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); diff --git a/src/main/java/learningFlow/learningFlow_BE/service/auth/oauth/OAuth2UserRegistrationService.java b/src/main/java/learningFlow/learningFlow_BE/service/auth/oauth/OAuth2UserRegistrationService.java index 6fb9a5b0..64e096b0 100644 --- a/src/main/java/learningFlow/learningFlow_BE/service/auth/oauth/OAuth2UserRegistrationService.java +++ b/src/main/java/learningFlow/learningFlow_BE/service/auth/oauth/OAuth2UserRegistrationService.java @@ -44,7 +44,7 @@ public Map getAdditionalInfoRequirements() { Map response = new HashMap<>(); response.put("message", "추가 정보 입력이 필요합니다"); response.put("requiredFields", Arrays.asList( - "job", "interestFields", "gender", "preferType" + "name", "job", "interestFields", "preferType" )); return response; @@ -64,7 +64,6 @@ public UserResponseDTO.UserLoginResponseDTO updateAdditionalInfo( Claims claims = jwtTokenProvider.getClaims(temporaryToken); String email = claims.getSubject(); - String name = claims.get("name", String.class); String providerId = claims.get("providerId", String.class); SocialType socialType = SocialType.valueOf(claims.get("socialType", String.class)); @@ -72,14 +71,14 @@ public UserResponseDTO.UserLoginResponseDTO updateAdditionalInfo( User newUser = User.builder() .loginId(socialType.name() + "_" + providerId) .email(email) - .name(name) + .name(additionalInfo.getName()) .providerId(providerId) .pw("OAUTH2_USER") .socialType(socialType) .job(additionalInfo.getJob()) .interestFields(additionalInfo.getInterestFields()) .preferType(additionalInfo.getPreferType()) - .profileImgUrl(imageUrl) + .profileImgUrl(additionalInfo.getImgProfileUrl()) .role(Role.USER) .inactive(false) .build(); diff --git a/src/main/java/learningFlow/learningFlow_BE/service/collection/CollectionService.java b/src/main/java/learningFlow/learningFlow_BE/service/collection/CollectionService.java index 8c0d0b0e..309d7fe0 100644 --- a/src/main/java/learningFlow/learningFlow_BE/service/collection/CollectionService.java +++ b/src/main/java/learningFlow/learningFlow_BE/service/collection/CollectionService.java @@ -68,22 +68,23 @@ private List getAllResources( .toList(); } - public CollectionResponseDTO.SearchResultDTO search(SearchRequestDTO.SearchConditionDTO condition, Long lastId, PrincipalDetails principalDetails) { + public CollectionResponseDTO.SearchResultDTO search(SearchRequestDTO.SearchConditionDTO condition, Integer page, PrincipalDetails principalDetails) { Authentication authentication = (principalDetails != null) ? SecurityContextHolder.getContext().getAuthentication() : null; - PageRequest pageRequest = PageRequest.of(0, PAGE_SIZE); - List collections = collectionRepository.searchCollections(condition, lastId, pageRequest); + PageRequest pageRequest = PageRequest.of(page - 1, PAGE_SIZE); + List collections = collectionRepository.searchCollections(condition, pageRequest); if (collections.isEmpty()) { - return CollectionConverter.toSearchResultDTO(collections, null, false, 0, 0, null, null); + return CollectionConverter.toSearchResultDTO(collections, false, 0, 0, null, null, 0); } Collection lastCollection = collections.getLast(); - boolean hasNext = hasNextPage(condition, lastCollection); Integer totalCount = collectionRepository.getTotalCount(condition); + int totalPages = (int) Math.ceil((double) totalCount / PAGE_SIZE); + boolean hasNext = page < totalPages; int currentPage = calculateCurrentPage(condition, lastCollection); @@ -102,12 +103,12 @@ public CollectionResponseDTO.SearchResultDTO search(SearchRequestDTO.SearchCondi return CollectionConverter.toSearchResultDTO( collections, - lastCollection.getId(), hasNext, totalPages, currentPage, currentUser, - learningInfoMap + learningInfoMap, + totalCount ); } @@ -290,8 +291,4 @@ private CollectionResponseDTO.CollectionPreviewDTO getRecentLearning(User user) private int calculateCurrentPage(SearchRequestDTO.SearchConditionDTO condition, Collection lastCollection) { return collectionRepository.getCountGreaterThanBookmark(lastCollection.getBookmarkCount(),lastCollection.getId(), condition) / PAGE_SIZE + 1; } - - private boolean hasNextPage(SearchRequestDTO.SearchConditionDTO condition, Collection lastCollection) { - return !collectionRepository.searchNextPage(condition, lastCollection, PageRequest.of(0, 1)).isEmpty(); - } } diff --git a/src/main/java/learningFlow/learningFlow_BE/service/embed/YoutubeUrlEmbedService.java b/src/main/java/learningFlow/learningFlow_BE/service/embed/YoutubeUrlEmbedService.java index 2b18e3a2..4de89a0e 100644 --- a/src/main/java/learningFlow/learningFlow_BE/service/embed/YoutubeUrlEmbedService.java +++ b/src/main/java/learningFlow/learningFlow_BE/service/embed/YoutubeUrlEmbedService.java @@ -41,27 +41,25 @@ public String EmbedUrl(String youtubeUrl){ String host = uri.getHost(); String query = uri.getQuery(); String path = uri.getPath(); + String adExistUrl = null; // 기본 형식: https://www.youtube.com/watch?v= if (host.contains("youtube.com") && query != null && query.contains("v=")){ String[] params = query.split("&"); for (String param : params) { if (param.startsWith("v=")){ String videoId = param.substring(2); - return "https://www.youtube.com/embed/" + videoId; + return "https:///www.youtube-nocookie.com/embed/" + videoId; } } } // 축약형: https://youtu.be/ if (host.contains("youtube.be") && path != null && path.length() > 1) { String videoId = path.substring(1); // 맨 앞 "/" 제거 - return "https://youtu.be/embed/" + videoId; + return "https:///www.youtube-nocookie.com/embed/" + videoId; } - throw new ResourceHandler(ErrorStatus.YOUTUBE_URI_SYNTAX_ERROR); } catch (URISyntaxException e) { throw new ResourceHandler(ErrorStatus.URI_SYNTAX_ERROR); } } - - } diff --git a/src/main/java/learningFlow/learningFlow_BE/service/resource/ResourceService.java b/src/main/java/learningFlow/learningFlow_BE/service/resource/ResourceService.java index 3934479c..5d6c0ceb 100644 --- a/src/main/java/learningFlow/learningFlow_BE/service/resource/ResourceService.java +++ b/src/main/java/learningFlow/learningFlow_BE/service/resource/ResourceService.java @@ -3,7 +3,6 @@ import learningFlow.learningFlow_BE.apiPayload.code.status.ErrorStatus; import learningFlow.learningFlow_BE.apiPayload.exception.handler.ResourceHandler; import learningFlow.learningFlow_BE.domain.*; -import learningFlow.learningFlow_BE.domain.enums.ResourceType; import learningFlow.learningFlow_BE.repository.*; import learningFlow.learningFlow_BE.web.dto.resource.ResourceRequestDTO; import lombok.RequiredArgsConstructor; @@ -38,7 +37,14 @@ public UserEpisodeProgress getUserEpisodeProgress(Long episodeId, String loginId Integer resourceQuantity = episode.getResource().getResourceQuantity(); if (resourceQuantity == null) throw new ResourceHandler(ErrorStatus.QUANTITY_IS_NULL); - UserEpisodeProgress userEpisodeProgress = new UserEpisodeProgress(userEpisodeProgressId, episode.getEpisodeNumber(), 0, episode.getResource().getResourceQuantity(), episode.getResource().getType()); + UserEpisodeProgress userEpisodeProgress = new UserEpisodeProgress( + userEpisodeProgressId, + episode.getEpisodeNumber(), + 0, + episode.getResource().getResourceQuantity(), + false, + episode.getResource().getType() + ); log.info("resourceType", episode.getResource().getType()); @@ -86,22 +92,27 @@ public void updateUserCollection(CollectionEpisode episode, String loginId) { userCollection = new UserCollection(); userCollection.setUserCollection(user, collection, episodeNumber); } - // 저장 - userCollectionRepository.save(userCollection); } @Transactional public void saveProgress(ResourceRequestDTO.ProgressRequestDTO request, String userId, Long episodeId) { - UserEpisodeProgressId progressId = new UserEpisodeProgressId(episodeId, userId); - UserEpisodeProgress progress = userEpisodeProgressRepository.findById(progressId) + UserEpisodeProgressId userEpisodeId = new UserEpisodeProgressId(episodeId, userId); + UserEpisodeProgress userEpisode = userEpisodeProgressRepository.findById(userEpisodeId) .orElseThrow(() -> new ResourceHandler(ErrorStatus.USER_PROGRESS_NOT_FOUND)); + // 만약 진도가 80이상인 경우 완료로 저장 + Integer requestProgress = request.getProgress(); + if (requestProgress >= 80) userEpisode.setIsComplete(true); + userEpisode.setCurrentProgress(requestProgress); + } - if (request.getResourceType() == ResourceType.VIDEO && request.getProgress() != null) { - progress.setCurrentProgress(request.getProgress()); - } else if (request.getResourceType() == ResourceType.TEXT && request.getProgress() != null) { - progress.setCurrentProgress(request.getProgress()); - } else { - throw new ResourceHandler(ErrorStatus._BAD_REQUEST); - } - userEpisodeProgressRepository.save(progress); + @Transactional + public Boolean changeEpisodeComplete(Long episodeId, String loginId){ + UserEpisodeProgressId userEpisodeId = new UserEpisodeProgressId(episodeId, loginId); + UserEpisodeProgress userEpisodeProgress = userEpisodeProgressRepository.findById(userEpisodeId) + .orElseThrow(() -> new ResourceHandler(ErrorStatus.USER_PROGRESS_NOT_FOUND)); + Boolean isComplete = userEpisodeProgress.getIsComplete(); + if (isComplete.equals(true)) isComplete = userEpisodeProgress.setIsComplete(false); + else isComplete = userEpisodeProgress.setIsComplete(true); + userEpisodeProgress.setCurrentProgress(0); + return isComplete; } } \ No newline at end of file diff --git a/src/main/java/learningFlow/learningFlow_BE/service/user/UserService.java b/src/main/java/learningFlow/learningFlow_BE/service/user/UserService.java index cc0e0ba7..b2fd187e 100644 --- a/src/main/java/learningFlow/learningFlow_BE/service/user/UserService.java +++ b/src/main/java/learningFlow/learningFlow_BE/service/user/UserService.java @@ -66,8 +66,12 @@ public UserInfoDTO updateUserInfo(String loginId, UpdateUserDTO updateUserDTO) { // } // 각 필드가 null이 아닌 경우에만 업데이트 - if(updateUserDTO.getImgUrl() != null){ - user.updateImage(updateUserDTO.getImgUrl()); + if(updateUserDTO.getImgProfileUrl() != null){ + user.updateImage(updateUserDTO.getImgProfileUrl()); + } + + if(updateUserDTO.getImgBannerUrl() != null){ + user.updateImage(updateUserDTO.getImgBannerUrl()); } if (updateUserDTO.getName() != null) { @@ -131,46 +135,52 @@ public BookmarkDTO.BookmarkResponseDTO toggleBookmark(String loginId, Long colle return new BookmarkDTO.BookmarkResponseDTO(!isCurrentlyBookmarked); } - public CollectionResponseDTO.SearchResultDTO getBookmarkedCollections(String loginId, Long lastId) { + public CollectionResponseDTO.SearchResultDTO getBookmarkedCollections(String loginId, Integer page) { User user = userRepository.findById(loginId) .orElseThrow(() -> new UserHandler(ErrorStatus.USER_NOT_FOUND)); - // 북마크된 컬렉션 ID 목록 가져오기 List bookmarkedIds = user.getBookmarkedCollectionIds(); - - if (bookmarkedIds.isEmpty()) { - return CollectionConverter.toSearchResultDTO(new ArrayList<>(), null, false, 0, 0, user, new HashMap<>()); + int totalCount = bookmarkedIds.size(); + + // 북마크가 없는 경우 + if (totalCount == 0) { + return CollectionConverter.toSearchResultDTO( + new ArrayList<>(), + false, + 0, + page, + user, + new HashMap<>(), + 0 + ); } - // lastId 이후의 컬렉션만 필터링 - List collections; - if (lastId == 0) { - collections = collectionRepository.findByIdIn( - bookmarkedIds.stream() - .limit(BOOKMARK_PAGE_SIZE) - .toList() - ); - } else { - int startIndex = bookmarkedIds.indexOf(lastId) + 1; - if (startIndex == 0 || startIndex >= bookmarkedIds.size()) { - return CollectionConverter.toSearchResultDTO(new ArrayList<>(), null, false, 0, 0, user, new HashMap<>()); - } - collections = collectionRepository.findByIdIn( - bookmarkedIds.stream() - .skip(startIndex) - .limit(BOOKMARK_PAGE_SIZE) - .toList() - ); + int totalPages = (int) Math.ceil((double) totalCount / BOOKMARK_PAGE_SIZE); + + // 페이지가 범위를 벗어나면 마지막 페이지 데이터 반환 + if (page > totalPages) { + page = totalPages; } + int startIndex = (page - 1) * BOOKMARK_PAGE_SIZE; + int endIndex = Math.min(startIndex + BOOKMARK_PAGE_SIZE, totalCount); + + List pageBookmarkIds = bookmarkedIds.subList(startIndex, endIndex); + List collections = collectionRepository.findByIdIn(pageBookmarkIds); + if (collections.isEmpty()) { - return CollectionConverter.toSearchResultDTO(collections, null, false, 0, 0, user, new HashMap<>()); + return CollectionConverter.toSearchResultDTO( + collections, + false, + totalPages, + page, + user, + new HashMap<>(), + totalCount + ); } - Long lastCollectionId = collections.getLast().getId(); - boolean hasNext = (bookmarkedIds.indexOf(lastCollectionId) + 1) < bookmarkedIds.size(); - int totalPages = (int) Math.ceil((double) bookmarkedIds.size() / BOOKMARK_PAGE_SIZE); - int currentPage = (lastId == 0) ? 1 : (bookmarkedIds.indexOf(lastId) / BOOKMARK_PAGE_SIZE) + 2; + boolean hasNext = page < totalPages; Map learningInfoMap = collections.stream() .collect(Collectors.toMap( @@ -180,12 +190,12 @@ public CollectionResponseDTO.SearchResultDTO getBookmarkedCollections(String log return CollectionConverter.toSearchResultDTO( collections, - lastCollectionId, hasNext, totalPages, - currentPage, + page, user, - learningInfoMap + learningInfoMap, + totalCount ); } diff --git a/src/main/java/learningFlow/learningFlow_BE/web/controller/ImageController.java b/src/main/java/learningFlow/learningFlow_BE/web/controller/ImageController.java index 987460bf..8a805680 100644 --- a/src/main/java/learningFlow/learningFlow_BE/web/controller/ImageController.java +++ b/src/main/java/learningFlow/learningFlow_BE/web/controller/ImageController.java @@ -34,12 +34,9 @@ public class ImageController { [파일 요구사항] - 형식: JPG, JPEG, PNG - 최대 크기: 5MB - - 최소 해상도: 100x100 - - 최대 해상도: 2000x2000 [주의사항] - 반환된 URL은 회원가입/정보수정 API 호출 시 필요 - - 미사용 이미지는 24시간 후 자동 삭제 """) @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse( diff --git a/src/main/java/learningFlow/learningFlow_BE/web/controller/LoginController.java b/src/main/java/learningFlow/learningFlow_BE/web/controller/LoginController.java index 4452531a..f0f3d2ae 100644 --- a/src/main/java/learningFlow/learningFlow_BE/web/controller/LoginController.java +++ b/src/main/java/learningFlow/learningFlow_BE/web/controller/LoginController.java @@ -20,7 +20,10 @@ import learningFlow.learningFlow_BE.web.dto.user.UserResponseDTO; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @@ -68,9 +71,9 @@ public ApiResponse register( 3. 추가 정보 입력 페이지로 이동 """) public ApiResponse goCompleteRegister( - @RequestParam String token + @RequestParam String emailVerificationCode ) { - localUserAuthService.validateRegistrationToken(token); + localUserAuthService.validateRegistrationToken(emailVerificationCode); return ApiResponse.onSuccess("토큰이 유효. 추가 정보를 입력해주세요."); } @@ -86,11 +89,11 @@ public ApiResponse goCompleteRegister( - 프로필 이미지 URL (이미지 업로드 API로 받은 URL) """) public ApiResponse completeRegister( - @RequestParam String token, + @RequestParam String emailVerificationCode, @Valid @RequestBody UserRequestDTO.CompleteRegisterDTO request, // ✅ JSON 데이터 - application/json HttpServletResponse response ) { - return ApiResponse.onSuccess(localUserAuthService.completeRegister(token, request, response)); + return ApiResponse.onSuccess(localUserAuthService.completeRegister(emailVerificationCode, request, response)); } @PostMapping("/login") @@ -150,23 +153,24 @@ public ApiResponse getAdditionalInfoPage() { OAuth2 회원가입의 추가 정보를 입력받습니다. [필수 입력] - - 직업 - - 관심분야 (다중선택) - - 선호 미디어 타입 + - 이름: 실명 또는 닉네임 + - 직업: STUDENT, ADULT, EMPLOYEE, JOB_SEEKER, OTHER + - 관심분야: 다중선택 (APP_DEVELOPMENT, WEB_DEVELOPMENT, PROGRAMMING_LANGUAGE, DEEP_LEARNING, STATISTICS, DATA_ANALYSIS, UI_UX, PLANNING, BUSINESS_PRODUCTIVITY, FOREIGN_LANGUAGE, CAREER) + - 선호 미디어: VIDEO, TEXT [선택 입력] - 프로필 이미지 URL [주의사항] - - 이메일/이름은 구글 계정 정보 사용 + - 이메일은 구글 계정 정보 사용 - 이미지 미입력시 기본 이미지 사용 """) public ApiResponse updateAdditionalInfo( - @RequestParam String token, + @RequestParam String oauth2RegistrationCode, @RequestBody @Valid UserRequestDTO.AdditionalInfoDTO request, // ✅ JSON 데이터 - application/json HttpServletResponse response) { log.info("put info"); - return ApiResponse.onSuccess(OAuth2UserRegistrationService.updateAdditionalInfo(token, request, response)); + return ApiResponse.onSuccess(OAuth2UserRegistrationService.updateAdditionalInfo(oauth2RegistrationCode, request, response)); } //TODO: 해당 DTO에 안 맞으면 500에러 나는데, 400에러이고 왜 회원가입 안되는 건지 구체적인 에러 작성 필요. @@ -184,57 +188,6 @@ public ApiResponse logout(HttpServletRequest request, HttpServletRespons return ApiResponse.onSuccess(localUserAuthService.logout(request, response)); } - @PostMapping("/send/change-password") - @Operation(summary = "비밀번호 재설정 요청 API", description = """ - 비밀번호 변경을 위한 인증 메일을 발송합니다. - - [처리 과정] - 1. 로그인 사용자 확인 - 2. 인증 토큰 생성 (30분 유효) - 3. 이메일 발송 - """) - public ApiResponse sendPasswordResetEmail( - @AuthenticationPrincipal PrincipalDetails principalDetails - ) { - return ApiResponse.onSuccess(localUserAuthService.sendPasswordResetEmail(principalDetails)); - } - - @GetMapping("/change-password") - @Operation(summary = "비밀번호 재설정 요청 API", description = """ - 비밀번호 변경 링크의 토큰을 검증합니다. - - [검증 항목] - - 토큰 유효성 - - 만료 여부 (30분) - - 사용자 매칭 - """) - public ApiResponse goChangePassword( - @RequestParam String token - ) { - localUserAuthService.validatePasswordResetToken(token); - return ApiResponse.onSuccess("토큰이 유효합니다. 새로운 비밀번호를 입력해주세요."); - } - - @PostMapping("/change-password") - @Operation(summary = "비밀번호 재설정 API", description = """ - 새 비밀번호로 변경합니다. - - [비밀번호 요구사항] - - 8-16자 - - 영문 대/소문자 각 1개 이상 - - 숫자 1개 이상 - - 특수문자 1개 이상 (@$!%*?&) - - [보안 처리] - - 이전 비밀번호 재사용 불가 - - 변경 시 모든 기기 로그아웃 - """) - public ApiResponse changePassword( - @RequestParam String token, - @Valid @RequestBody UserRequestDTO.ResetPasswordDTO request - ) { - return ApiResponse.onSuccess(localUserAuthService.resetPassword(token, request)); - } /* //일단은 사용X diff --git a/src/main/java/learningFlow/learningFlow_BE/web/controller/ResourceRestController.java b/src/main/java/learningFlow/learningFlow_BE/web/controller/ResourceRestController.java index 16b02cb6..85bdfcb0 100644 --- a/src/main/java/learningFlow/learningFlow_BE/web/controller/ResourceRestController.java +++ b/src/main/java/learningFlow/learningFlow_BE/web/controller/ResourceRestController.java @@ -35,7 +35,7 @@ @RestController @RequiredArgsConstructor @Validated -@RequestMapping("/resources") +@RequestMapping("/resources/{episodeId}") @Slf4j @Tag(name = "Resource", description = "Collection 내에 특정 resource 관련해서 기능하는 API") public class ResourceRestController { @@ -45,7 +45,7 @@ public class ResourceRestController { private final YoutubeUrlEmbedService youtubeUrlEmbedService; private final LambdaService lambdaService; - @GetMapping("/{episodeId}/youtube") + @GetMapping("/youtube") @Operation(summary = "강의 시청, 강좌로 이동 API", description = """ 영상 리소스 조회 및 시청 처리 API입니다. @@ -76,11 +76,12 @@ public ApiResponse watchEpisode( Collection collection = resourceService.getCollection(episodeId); Optional memo = resourceService.getMemoContents(episodeId); Resource resource = youtubeUrlEmbedService.getResource(episodeId); - - return ApiResponse.onSuccess(ResourceConverter.watchEpisode(collection, userEpisodeProgress, resource, memo)); + ResourceResponseDTO.ResourceUrlDTO response = + ResourceConverter.watchEpisode(collection, userEpisodeProgress, resource, memo); + return ApiResponse.onSuccess(response); } - @GetMapping("/{episodeId}/blog") + @GetMapping("/blog") @Operation(summary = "블로그 글 조회 API", description = """ 텍스트(블로그) 리소스 조회 API입니다. @@ -115,11 +116,11 @@ public ApiResponse watchBlogEpisode( Optional memo = resourceService.getMemoContents(episodeId); String resourceTitle = resourceService.getResource(episodeId).getTitle(); String blogSourceUrl = "/resources/" + episodeId + "/blog/content"; - return ApiResponse.onSuccess(ResourceConverter.watchBlogEpisode(collection, userEpisodeProgress, blogSourceUrl, resourceTitle, memo)); + ResourceResponseDTO.ResourceBlogUrlDTO response = ResourceConverter.watchBlogEpisode(collection, userEpisodeProgress, blogSourceUrl, resourceTitle, memo); + return ApiResponse.onSuccess(response); } - // Gzip으로 HTML을 반환하는 API - @GetMapping("{episodeId}/blog/content") + @GetMapping("/blog/content") @Operation(summary = "blog HTML 반환 API", description = """ 블로그 글의 HTML 컨텐츠를 반환하는 API입니다. /resources/{episodeId}/blog 호출 이후 사용됩니다. @@ -129,7 +130,7 @@ public ApiResponse watchBlogEpisode( - HTML 컨텐츠 가공 및 반환 [응답 형식] - - Gzip 압축된 HTML 문자열 + - S3 객체 URL 반환 [파라미터] - width: 컨텐츠 영역 너비 (기본값: 982) @@ -139,20 +140,6 @@ public ApiResponse getBlogEpisodeContent( @PathVariable("episodeId") Long episodeId, @RequestParam(defaultValue = "982") int width, @RequestParam(defaultValue = "552") int height) { - /* CompletableFuture blogSource = blogEmbedService.getBlogSource(episodeId); - - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_OCTET_STREAM); // 바이너리 파일 반환 - headers.add(HttpHeaders.CONTENT_ENCODING, "gzip"); // 올바르게 설정 - - try { - byte[] blogContent = blogSource.get(); // 예외 처리 추가 - headers.setContentLength(blogContent.length); - return new ResponseEntity<>(blogContent, headers, HttpStatus.OK); - } catch (InterruptedException | ExecutionException e) { - log.error("블로그 데이터를 가져오는 중 오류 발생: {}", e.getMessage(), e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new byte[0]); // 빈 응답 반환 - }*/ Resource resource = resourceService.getResource(episodeId); if (resource.getClientUrl() != null) { return ApiResponse.onSuccess(resource.getClientUrl()); @@ -160,13 +147,13 @@ public ApiResponse getBlogEpisodeContent( return ApiResponse.onSuccess(lambdaService.invokeLambda(resource.getUrl(), width, height, resource)); } - @PostMapping("/{episodeId}/save-progress") + @PostMapping("/save-progress") @Operation(summary = "강의 진도 저장 API", description = """ 리소스 학습 진도를 저장하는 API입니다. [입력 정보] - resourceType: 리소스 유형 (VIDEO/TEXT) - - progress: + - progress: * VIDEO: 재생 시간(초) * TEXT: 스크롤 위치(px) @@ -185,11 +172,34 @@ public ApiResponse saveProgress( @Valid @RequestBody ResourceRequestDTO.ProgressRequestDTO request) { String loginId = principalDetails.getUser().getLoginId(); resourceService.saveProgress(request, loginId, episodeId); - - return ApiResponse.onSuccess(ResourceConverter.toSaveProgressResponse(request)); + ResourceResponseDTO.ProgressResponseDTO response = ResourceConverter.toSaveProgressResponse(request); + return ApiResponse.onSuccess(response); + } + @PostMapping("/update-complete") + @Operation(summary = "에피소드 수강 상태 변환", description = """ + episode 수강 완료일 경우 수강 초기화, 수강 완료 상태가 아닐 경우 수강 완료로 표기하는 API 입니다. + + [처리 내용] + - 에피소드 수강 상태 확인 후 변경 + + [응답 정보] + - 바뀐 수강 상태 + """) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "RESOURCE4001", description = "강의 에피소드를 찾을 수 없습니다."), + }) + public ApiResponse updateEpisodeStatus( + @AuthenticationPrincipal PrincipalDetails principalDetails, + @Valid @PathVariable("episodeId") Long episodeId){ + String loginId = principalDetails.getUser().getLoginId(); + log.info("로그인 상태 확인 {}", loginId); + Boolean isComplete = resourceService.changeEpisodeComplete(episodeId, loginId); + ResourceResponseDTO.changeEpisodeIsCompleteDTO response = ResourceConverter.toChangeEpisodeIsCompleteDTO(isComplete); + return ApiResponse.onSuccess(response); } - @PostMapping("/{episodeId}/memo") + @PostMapping("/memo") @Operation(summary = "강의 메모 생성 API", description = """ 리소스에 대한 메모를 작성하는 API입니다. @@ -211,6 +221,7 @@ public ApiResponse createMemo( String loginId = principalDetails.getUser().getLoginId(); log.info("로그인 상태 확인 {}", loginId); memoCommandService.saveMemo(loginId, episodeId, request); - return ApiResponse.onSuccess(MemoConverter.createMemo(request)); // 성공 시 200 OK 반환 + MemoResponseDTO.MemoInfoDTO response = MemoConverter.createMemo(request); + return ApiResponse.onSuccess(response); // 성공 시 200 OK 반환 } } \ No newline at end of file diff --git a/src/main/java/learningFlow/learningFlow_BE/web/controller/SearchRestController.java b/src/main/java/learningFlow/learningFlow_BE/web/controller/SearchRestController.java index 70960880..f85247bc 100644 --- a/src/main/java/learningFlow/learningFlow_BE/web/controller/SearchRestController.java +++ b/src/main/java/learningFlow/learningFlow_BE/web/controller/SearchRestController.java @@ -71,9 +71,7 @@ public class SearchRestController { - 1: 좋아요순 [페이지네이션] - - 무한 스크롤 방식 - - lastId: 마지막으로 조회된 컬렉션 ID - - 첫 페이지는 lastId=0 + - 커서기반 페이징 - 8개씩 조회 [응답 정보] @@ -101,7 +99,7 @@ public class SearchRestController { @Parameter(name = "difficulties", description = "난이도 목록 (1:입문, 2:초급, 3:중급, 4:실무)", example = "[1, 2]"), @Parameter(name = "amounts", description = "학습량 (SHORT, MEDIUM, LONG)", example = "[\"SHORT\", \"MEDIUM\"]"), @Parameter(name = "sortType", description = "정렬 기준 (0:최신순, 1:좋아요순)", example = "0"), - @Parameter(name = "lastId", description = "마지막 컬렉션 ID (첫 페이지: 0)", example = "0") + @Parameter(name = "page", description = "페이지 번호 (1부터 시작)") }) public ApiResponse searchEpisodes( @RequestParam(required = false) String keyword, @@ -110,13 +108,13 @@ public ApiResponse searchEpisodes( @RequestParam(required = false) List difficulties, @RequestParam(required = false) List amounts, @RequestParam(required = false, defaultValue = "0") Integer sortType, - @RequestParam(required = false, defaultValue = "0") Long lastId, + @RequestParam(required = false, defaultValue = "1") Integer page, @AuthenticationPrincipal PrincipalDetails principalDetails ) { return ApiResponse.onSuccess( collectionService.search( CollectionConverter.toSearchConditionDTO(keyword, interestFields, preferMediaType, difficulties, amounts, sortType), - lastId, principalDetails) + page, principalDetails) ); } } \ No newline at end of file diff --git a/src/main/java/learningFlow/learningFlow_BE/web/controller/UserRestController.java b/src/main/java/learningFlow/learningFlow_BE/web/controller/UserRestController.java index 8121bc1e..65af3e89 100644 --- a/src/main/java/learningFlow/learningFlow_BE/web/controller/UserRestController.java +++ b/src/main/java/learningFlow/learningFlow_BE/web/controller/UserRestController.java @@ -9,8 +9,10 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import learningFlow.learningFlow_BE.apiPayload.ApiResponse; +import learningFlow.learningFlow_BE.domain.EmailVerificationToken; import learningFlow.learningFlow_BE.s3.AmazonS3Manager; import learningFlow.learningFlow_BE.security.auth.PrincipalDetails; +import learningFlow.learningFlow_BE.service.auth.local.LocalUserAuthService; import learningFlow.learningFlow_BE.service.user.UserService; import learningFlow.learningFlow_BE.web.dto.collection.CollectionResponseDTO; import learningFlow.learningFlow_BE.web.dto.user.UserRequestDTO; @@ -34,6 +36,7 @@ public class UserRestController { private final UserService userService; + private final LocalUserAuthService localUserAuthService; private final AmazonS3Manager amazonS3Manager; // @PostMapping(value = "/imgUpload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @@ -73,7 +76,11 @@ public class UserRestController { 4. 프로필 이미지 URL (선택) - 이미지 업로드 API를 통해 받은 URL 사용 - - 미입력시 기존 이미지 유지 + - 미입력시 기존(기본) 이미지 유지 + + 5. 배너 이미지 URL (선택) + - 이미지 업로드 API를 통해 받은 URL 사용 + - 미입력시 기존(기본) 이미지 유지 [응답 정보] 수정된 사용자 정보 반환: @@ -96,41 +103,7 @@ public ApiResponse updateUserInfo( } @GetMapping - @Operation(summary = "사용자 정보 조회 API", description = """ - 로그인한 사용자의 상세 프로필 정보를 조회합니다. - - [조회 정보] - 1. 기본 정보 - - 이름 - - 이메일 - - 직업 - * STUDENT: 대학생(휴학생) - * ADULT: 성인 - * EMPLOYEE: 직장인 - * JOB_SEEKER: 이직/취업 준비생 - * OTHER: 기타 - - 2. 관심분야 목록 - - APP_DEVELOPMENT: 앱개발 - - WEB_DEVELOPMENT: 웹개발 - - PROGRAMMING_LANGUAGE: 컴퓨터언어 - - DEEP_LEARNING: 딥러닝 - - STATISTICS: 통계 - - DATA_ANALYSIS: 데이터분석 - - UI_UX: UX/UI - - PLANNING: 기획 - - BUSINESS_PRODUCTIVITY: 업무생산성 - - FOREIGN_LANGUAGE: 외국어 - - CAREER: 취업 - - 3. 학습 설정 - - 선호 미디어 타입 (VIDEO/TEXT) - - 프로필 이미지 URL - - [접근 권한] - - 로그인한 사용자만 조회 가능 - - 본인 정보만 조회 가능 - """) + @Operation(summary = "사용자 조회 API", description = "사용자를 찾기 위한 API 입니다.") @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "조회 성공"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "USER4001", description = "사용자를 찾을 수 없습니다."), @@ -202,9 +175,7 @@ public ApiResponse getMyPage( - 본인의 좋아요 상태 [페이지네이션] - - 무한 스크롤 방식 - - lastId: 마지막으로 조회된 컬렉션 ID - - 첫 페이지는 lastId=0 + - 커서 기반 페이징 - 한 페이지당 8개 조회 - 좋아요 시간 기준 내림차순 정렬 """) @@ -213,14 +184,99 @@ public ApiResponse getMyPage( @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH4001", description = "로그인이 필요한 서비스입니다.") }) @Parameters({ - @Parameter(name = "lastId", description = "마지막으로 조회된 컬렉션 ID (첫 페이지: 0)", example = "0") + @Parameter(name = "page", description = "페이지 번호 (1부터 시작)") }) public ApiResponse getBookmarkedCollections( - @RequestParam(required = false, defaultValue = "0") Long lastId, + @RequestParam(required = false, defaultValue = "1") Integer page, @AuthenticationPrincipal PrincipalDetails principalDetails ) { return ApiResponse.onSuccess( - userService.getBookmarkedCollections(principalDetails.getUser().getLoginId(), lastId) + userService.getBookmarkedCollections(principalDetails.getUser().getLoginId(), page) ); } + + @PostMapping("/send/change-password") + @Operation(summary = "비밀번호 재설정 요청 API", description = """ + 비밀번호 변경을 위한 인증 메일을 발송합니다. + + [처리 과정] + 1. 로그인 사용자 확인 + 2. 인증 코드 생성 (30분 유효) + 3. 이메일 발송 + """) + public ApiResponse sendPasswordResetEmail( + @AuthenticationPrincipal PrincipalDetails principalDetails + ) { + return ApiResponse.onSuccess(localUserAuthService.sendPasswordResetEmail(principalDetails)); + } + + @GetMapping("/change-password") + @Operation(summary = "비밀번호 재설정 링크 클릭 후 이동하는 페이지에서 쿼리로 전달된 코드 유효성 검증하는 API", description = """ + 비밀번호 변경 링크의 코드을 검증합니다. + + [검증 항목] + - 코드 유효성 + - 만료 여부 (30분) + - 사용자 매칭 + """) + public ApiResponse goChangePassword( + @RequestParam String passwordResetCode + ) { + localUserAuthService.validatePasswordResetToken(passwordResetCode); + return ApiResponse.onSuccess("코드가 유효합니다. 새로운 비밀번호를 입력해주세요."); + } + + @PostMapping("/change-password") + @Operation(summary = "비밀번호 재설정 API", description = """ + 새 비밀번호로 변경합니다. + + [비밀번호 요구사항] + - 8-16자 + - 영문 대/소문자 각 1개 이상 + - 숫자 1개 이상 + - 특수문자 1개 이상 (@$!%*?&) + + [보안 처리] + - 이전 비밀번호 재사용 불가 + - 변경 시 모든 기기 로그아웃 + """) + public ApiResponse changePassword( + @RequestParam String passwordResetCode, + @Valid @RequestBody UserRequestDTO.ResetPasswordDTO request + ) { + return ApiResponse.onSuccess(localUserAuthService.resetPassword(passwordResetCode, request)); + } + + @PostMapping("/send/change-email") + @Operation(summary = "이메일 재설정 요청 API", description = """ + 이메일 변경을 위한 인증 메일을 발송합니다. + + [처리 과정] + 1. 로그인 사용자 확인 + 2. 인증 코드 생성 (30분 유효) + 3. 이메일 발송 + """) + public ApiResponse sendEmailResetEmail( + @RequestBody UserRequestDTO.ResetEmailDTO request, + @AuthenticationPrincipal PrincipalDetails principalDetails + ) { + return ApiResponse.onSuccess(localUserAuthService.sendEmailResetEmail(request.getEmail(), principalDetails)); + } + + @GetMapping("/change-email") + @Operation(summary = "이메일 재설정 링크 클릭 후 이동하는 페이지에서 쿼리로 전달된 코드 유효성 검증하고 이메일 재설정이 완료되는 API", description = """ + 이메일 변경 링크의 코드를 검증합니다. + 그 후 코드의 유효성이 검증되면 자동으로 이메일 재설정이 완료됩니다. + + [검증 항목] + - 코드 유효성 + - 만료 여부 (30분) + - 사용자 매칭 + """) + public ApiResponse goChangeEmail( + @RequestParam String emailResetCode + ) { + EmailVerificationToken verificationToken = localUserAuthService.validateRegistrationToken(emailResetCode); + return ApiResponse.onSuccess(localUserAuthService.changeEmail(verificationToken)); + } } \ No newline at end of file diff --git a/src/main/java/learningFlow/learningFlow_BE/web/dto/collection/CollectionResponseDTO.java b/src/main/java/learningFlow/learningFlow_BE/web/dto/collection/CollectionResponseDTO.java index af0a7342..b29e3a53 100644 --- a/src/main/java/learningFlow/learningFlow_BE/web/dto/collection/CollectionResponseDTO.java +++ b/src/main/java/learningFlow/learningFlow_BE/web/dto/collection/CollectionResponseDTO.java @@ -1,5 +1,8 @@ package learningFlow.learningFlow_BE.web.dto.collection; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer; import learningFlow.learningFlow_BE.domain.enums.InterestField; import learningFlow.learningFlow_BE.web.dto.resource.ResourceResponseDTO; import lombok.AllArgsConstructor; @@ -18,10 +21,10 @@ public class CollectionResponseDTO { @AllArgsConstructor public static class SearchResultDTO { List searchResults; - Long lastId; // 마지막 컬렉션의 ID Boolean hasNext; // 다음 페이지 존재 여부 Integer currentPage; //현재 페이지 Integer totalPages; //전체 페이지 수 + int totalCount; } @Getter @@ -30,6 +33,7 @@ public static class SearchResultDTO { @AllArgsConstructor public static class CollectionPreviewDTO { Long collectionId; + String imageUrl; InterestField interestField; String title; String creator; @@ -45,7 +49,10 @@ public static class CollectionPreviewDTO { Integer progressRatePercentage; String progressRatio; String learningStatus; + + @JsonSerialize(using = LocalDateSerializer.class) LocalDate startDate; + @JsonSerialize(using = LocalDateSerializer.class) LocalDate completedDate; } diff --git a/src/main/java/learningFlow/learningFlow_BE/web/dto/resource/ResourceRequestDTO.java b/src/main/java/learningFlow/learningFlow_BE/web/dto/resource/ResourceRequestDTO.java index f3ceb781..aeacf3ff 100644 --- a/src/main/java/learningFlow/learningFlow_BE/web/dto/resource/ResourceRequestDTO.java +++ b/src/main/java/learningFlow/learningFlow_BE/web/dto/resource/ResourceRequestDTO.java @@ -10,6 +10,7 @@ public class ResourceRequestDTO { public static class ProgressRequestDTO { @NotNull private ResourceType resourceType; // 강의 타입 (VIDEO or TEXT) + @NotNull private Integer progress; // 유튜브 강의 && 블로그 픽셀 } } diff --git a/src/main/java/learningFlow/learningFlow_BE/web/dto/resource/ResourceResponseDTO.java b/src/main/java/learningFlow/learningFlow_BE/web/dto/resource/ResourceResponseDTO.java index 59d34d3f..ca5824ea 100644 --- a/src/main/java/learningFlow/learningFlow_BE/web/dto/resource/ResourceResponseDTO.java +++ b/src/main/java/learningFlow/learningFlow_BE/web/dto/resource/ResourceResponseDTO.java @@ -51,6 +51,7 @@ public static class ResourceBlogUrlDTO { public static class episodeInformation { Integer episodeNumber; String urlTitle; + Boolean isCompleted; } @Getter @@ -89,4 +90,12 @@ public static class RecentlyWatchedEpisodeDTO { Integer currentProgress; Integer totalProgress; } + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class changeEpisodeIsCompleteDTO { + Boolean isComplete; + } } diff --git a/src/main/java/learningFlow/learningFlow_BE/web/dto/user/UserRequestDTO.java b/src/main/java/learningFlow/learningFlow_BE/web/dto/user/UserRequestDTO.java index 8586980b..faec21a9 100644 --- a/src/main/java/learningFlow/learningFlow_BE/web/dto/user/UserRequestDTO.java +++ b/src/main/java/learningFlow/learningFlow_BE/web/dto/user/UserRequestDTO.java @@ -43,7 +43,20 @@ public static class CompleteRegisterDTO { MediaType preferType; @NotEmpty - String imgUrl; + String imgProfileUrl; + + // ✅ 기본 생성자에서 기본값 설정 + public CompleteRegisterDTO() { + this.imgProfileUrl = "https://learningflow.s3.ap-northeast-2.amazonaws.com/%EC%98%A8%EB%B3%B4%EB%94%A9+%ED%8E%98%EC%9D%B4%EC%A7%80%EC%9A%A9.svg"; + } + + // ✅ @JsonSetter 사용 (JSON에서 필드가 누락된 경우 기본값 설정) + @JsonSetter + public void setImgProfileUrl(String imgProfileUrl) { + this.imgProfileUrl = (imgProfileUrl == null || imgProfileUrl.isEmpty()) + ? "https://learningflow.s3.ap-northeast-2.amazonaws.com/%EC%98%A8%EB%B3%B4%EB%94%A9+%ED%8E%98%EC%9D%B4%EC%A7%80%EC%9A%A9.svg" + : imgProfileUrl; + } } @Getter @@ -59,6 +72,10 @@ public static class UserLoginDTO { @Getter public static class AdditionalInfoDTO { + + @NotBlank(message = "이름은 필수 입력값입니다") + String name; + @NotNull(message = "직업은 필수 선택값입니다") Job job; @@ -68,20 +85,20 @@ public static class AdditionalInfoDTO { @NotNull(message = "선호하는 미디어 타입은 필수 선택값입니다") MediaType preferType; - private String imgUrl; + private String imgProfileUrl; // ✅ 기본 생성자에서 기본값 설정 public AdditionalInfoDTO() { - this.imgUrl = "https://learningflow.s3.ap-northeast-2.amazonaws.com/%EC%98%A8%EB%B3%B4%EB%94%A9+%ED%8E%98%EC%9D%B4%EC%A7%80%EC%9A%A9.svg"; + this.imgProfileUrl = "https://learningflow.s3.ap-northeast-2.amazonaws.com/%EC%98%A8%EB%B3%B4%EB%94%A9+%ED%8E%98%EC%9D%B4%EC%A7%80%EC%9A%A9.svg"; } // ✅ @JsonSetter 사용 (JSON에서 필드가 누락된 경우 기본값 설정) @JsonSetter - public void setImgUrl(String imgUrl) { - this.imgUrl = (imgUrl == null || imgUrl.isEmpty()) + public void setImgProfileUrlUrl(String imgProfileUrl) { + this.imgProfileUrl = (imgProfileUrl == null || imgProfileUrl.isEmpty()) ? "https://learningflow.s3.ap-northeast-2.amazonaws.com/%EC%98%A8%EB%B3%B4%EB%94%A9+%ED%8E%98%EC%9D%B4%EC%A7%80%EC%9A%A9.svg" - : imgUrl; + : imgProfileUrl; } } @@ -92,6 +109,13 @@ public static class FindPasswordDTO { String email; } + @Getter + public static class ResetEmailDTO { + @Email(message = "올바른 이메일 형식이어야 합니다") + @NotBlank(message = "이메일은 필수 입력값입니다") + String email; + } + @Getter public static class ResetPasswordDTO { @NotBlank(message = "현재 비밀번호는 필수 입력값입니다") @@ -108,7 +132,8 @@ public static class UpdateUserDTO { String name; Job job; List interestFields; - private String imgUrl; + String imgProfileUrl; + String imgBannerUrl; } } diff --git a/src/main/java/learningFlow/learningFlow_BE/web/dto/user/UserResponseDTO.java b/src/main/java/learningFlow/learningFlow_BE/web/dto/user/UserResponseDTO.java index d69fc220..e478b9e1 100644 --- a/src/main/java/learningFlow/learningFlow_BE/web/dto/user/UserResponseDTO.java +++ b/src/main/java/learningFlow/learningFlow_BE/web/dto/user/UserResponseDTO.java @@ -23,9 +23,9 @@ public static class UserInfoDTO { String email; Job job; List interestFields; - LocalDate birthDay; MediaType preferType; String profileImgUrl; + String bannerImgUrl; /** * 어떤 필드들을 사용자 정보 조회 시에 보여줘야 할지 아직 안정해서 비워두었습니다. */ @@ -42,6 +42,7 @@ public static class UserLoginResponseDTO { String name; Role role; SocialType socialType; + String profileImgUrl; } @Getter @@ -63,5 +64,6 @@ public static class UserPreviewDTO { String email; String job; String profileImgUrl; + String bannerImgUrl; } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f74a6ea7..39cf2cd8 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -56,8 +56,7 @@ custom: refresh-token-validity-in-seconds: 604800 # Refresh Token 1주일 app: - url: http://localhost:3000 #http://54.180.118.227:8080 - + frontend-url: http://localhost:3000 #http://54.180.118.227:8080 server: port: 8080