diff --git a/build.gradle b/build.gradle index 69893cd..0bd25de 100644 --- a/build.gradle +++ b/build.gradle @@ -29,11 +29,20 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-validation' developmentOnly 'org.springframework.boot:spring-boot-devtools' + implementation 'org.springframework.boot:spring-boot-starter-aop' + + // spring +// implementation 'org.springframework.boot:spring-boot-starter-mail' + + // Jwt + implementation 'io.jsonwebtoken:jjwt-api:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' + + // Spring Security + implementation 'org.springframework.boot:spring-boot-starter-security' + testImplementation 'org.springframework.security:spring-security-test' - // jwt - implementation("io.jsonwebtoken:jjwt-api:0.11.2") - implementation("io.jsonwebtoken:jjwt-jackson:0.11.2") - runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.2") // lombok compileOnly 'org.projectlombok:lombok' @@ -49,6 +58,10 @@ dependencies { // redis implementation("org.springframework.boot:spring-boot-starter-data-redis:2.7.7") + //embedded-redis +// implementation group: 'it.ozimov', name: 'embedded-redis', version: '0.7.1' + implementation 'com.github.codemonstur:embedded-redis:1.4.3' + // s3 implementation("io.awspring.cloud:spring-cloud-aws-s3:3.0.2") implementation platform('software.amazon.awssdk:bom:2.21.0') @@ -71,6 +84,8 @@ dependencies { // test testImplementation 'org.springframework.boot:spring-boot-starter-test' + + } compileJava { diff --git a/dump.rdb b/dump.rdb new file mode 100644 index 0000000..3fb521d Binary files /dev/null and b/dump.rdb differ diff --git a/src/main/java/com/hanshin/supernova/SupernovaApplication.java b/src/main/java/com/hanshin/supernova/SupernovaApplication.java index 7d776c0..510387b 100644 --- a/src/main/java/com/hanshin/supernova/SupernovaApplication.java +++ b/src/main/java/com/hanshin/supernova/SupernovaApplication.java @@ -1,5 +1,8 @@ package com.hanshin.supernova; +import com.hanshin.supernova.util.TimeUtil; +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.scheduling.annotation.EnableScheduling; @@ -10,6 +13,21 @@ public class SupernovaApplication { public static void main(String[] args) { SpringApplication.run(SupernovaApplication.class, args); - } + // HikariCP 데이터소스 설정 + HikariConfig hikariConfig = new HikariConfig(); + hikariConfig.setJdbcUrl("jdbc:mysql://localhost:3306/supernova"); + hikariConfig.setUsername("root"); + hikariConfig.setPassword("1234"); + + HikariDataSource hikariDataSource = new HikariDataSource(hikariConfig); + + // TimeUtil 객체 생성 및 시간 출력 + TimeUtil timeUtil = new TimeUtil(hikariDataSource); + timeUtil.printTime(); + + // HikariCP 데이터소스 닫기 + hikariDataSource.close(); + + } } diff --git a/src/main/java/com/hanshin/supernova/ai_comment/application/AiAnswerService.java b/src/main/java/com/hanshin/supernova/ai_comment/application/AiAnswerService.java index 81243c1..d000382 100644 --- a/src/main/java/com/hanshin/supernova/ai_comment/application/AiAnswerService.java +++ b/src/main/java/com/hanshin/supernova/ai_comment/application/AiAnswerService.java @@ -7,6 +7,7 @@ import com.hanshin.supernova.exception.dto.ErrorType; import com.hanshin.supernova.exception.gpt.GptInvalidException; import com.hanshin.supernova.rate_limiter.annotation.RateLimit; +import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import org.json.simple.parser.ParseException; import org.springframework.stereotype.Service; @@ -20,6 +21,7 @@ public class AiAnswerService { @RateLimit(key = "'createAIAnswerTest2:user' + #user.id", limit = 5, period = 24 * 60 * 60) public AiAnswerResponse generateAiAnswer(AuthUser user, String title, String content) { + try { return chatGptAPIManager.generateAiAnswer(title, content); } catch (ParseException | JsonProcessingException e) { diff --git a/src/main/java/com/hanshin/supernova/answer/application/AnswerService.java b/src/main/java/com/hanshin/supernova/answer/application/AnswerService.java index a175ccd..4adb316 100644 --- a/src/main/java/com/hanshin/supernova/answer/application/AnswerService.java +++ b/src/main/java/com/hanshin/supernova/answer/application/AnswerService.java @@ -52,7 +52,6 @@ public AnswerResponse createAnswer(AuthUser user, Long qId, AnswerRequest reques return getAnswerResponse(savedAnswer, findUser); } - /** * 답변 조회 */ @@ -187,6 +186,7 @@ public AnswerResponse updateAnswerRecommendation(AuthUser user, Long aId) { return AnswerResponse.toResponse( findAnswer.getId(), findUser.getNickname(), + findUser.getProfileImageUrl(), findAnswer.getAnswer(), findAnswer.getCreatedAt(), findAnswer.getRecommendationCnt(), @@ -217,6 +217,7 @@ private static AnswerResponse getAnswerResponse(Answer answer, User user) { return AnswerResponse.toResponse( answer.getId(), user.getNickname(), + user.getProfileImageUrl(), answer.getAnswer(), answer.getCreatedAt(), answer.getRecommendationCnt(), @@ -260,4 +261,4 @@ private static void validateSameAnswerer(Answer findAnswer, Long userId) { throw new AuthInvalidException(ErrorType.NON_IDENTICAL_USER_ERROR); } } -} +} \ No newline at end of file diff --git a/src/main/java/com/hanshin/supernova/answer/dto/response/AnswerResponse.java b/src/main/java/com/hanshin/supernova/answer/dto/response/AnswerResponse.java index 9284e5b..1eba95b 100644 --- a/src/main/java/com/hanshin/supernova/answer/dto/response/AnswerResponse.java +++ b/src/main/java/com/hanshin/supernova/answer/dto/response/AnswerResponse.java @@ -1,7 +1,9 @@ package com.hanshin.supernova.answer.dto.response; import com.hanshin.supernova.answer.domain.Tag; + import java.time.LocalDateTime; + import lombok.AllArgsConstructor; import lombok.Data; @@ -11,6 +13,7 @@ public class AnswerResponse { private Long id; private String nickname; + private String profileImageUrl; private String answer; private LocalDateTime createdAt; private int recCnt; @@ -19,10 +22,9 @@ public class AnswerResponse { private boolean isAi; private boolean isAccepted; - public static AnswerResponse toResponse(Long id, String nickname, String answer, - LocalDateTime createdAt, int recCnt, - Tag tag, String source, boolean isAi, boolean isAccepted) { - return new AnswerResponse(id, nickname, answer, createdAt, recCnt, tag, source, isAi, - isAccepted); + public static AnswerResponse toResponse(Long id, String nickname, String profileImageUrl, String answer, + LocalDateTime createdAt, int recCnt, Tag tag, String source, + boolean isAi, boolean isAccepted) { + return new AnswerResponse(id, nickname, profileImageUrl, answer, createdAt, recCnt, tag, source, isAi, isAccepted); } } diff --git a/src/main/java/com/hanshin/supernova/answer/infrastructure/AnswerRepository.java b/src/main/java/com/hanshin/supernova/answer/infrastructure/AnswerRepository.java index 9ac2b5a..60c94b1 100644 --- a/src/main/java/com/hanshin/supernova/answer/infrastructure/AnswerRepository.java +++ b/src/main/java/com/hanshin/supernova/answer/infrastructure/AnswerRepository.java @@ -1,8 +1,12 @@ package com.hanshin.supernova.answer.infrastructure; import com.hanshin.supernova.answer.domain.Answer; + import java.util.List; + +import io.lettuce.core.dynamic.annotation.Param; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; @@ -11,4 +15,31 @@ public interface AnswerRepository extends JpaRepository { List findAllByQuestionId(Long qId); + + /** + * 특정 사용자가 작성한 답변 중 소스가 있고 채택된 답변을 가져오는 메서드 + * + * @param userId 답변 작성자 ID + * @return 소스가 있고 채택된 답변 목록 + */ + @Query("SELECT a FROM Answer a WHERE a.answererId = :userId AND a.isAccepted = true AND a.source IS NOT NULL") + List findAcceptedAnswersWithSourceByUserId(@Param("userId") Long userId); + + /** + * 사용자가 작성한 답변 중 소스를 제공하고 추천 수가 10회 이상인 답변 목록을 조회합니다. + * + * @param userId 사용자의 ID + * @param recommendationThreshold 추천 수 기준 + * @return 추천 수가 기준을 넘는 답변 목록 + */ + @Query("SELECT a FROM Answer a " + + "WHERE a.answererId = :userId AND a.source IS NOT NULL " + + "AND a.recommendationCnt >= :recommendationThreshold") + List findPopularAnswersWithSourceByUserId(@Param("userId") Long userId, + @Param("recommendationThreshold") int recommendationThreshold); + + + // 특정 사용자가 작성한 답변 목록을 최신순으로 조회 + @Query("SELECT a FROM Answer a WHERE a.answererId = :userId ORDER BY a.createdAt DESC") + List findAllByAnswererId(@Param("userId") Long userId); } diff --git a/src/main/java/com/hanshin/supernova/auth/AuthConstants.java b/src/main/java/com/hanshin/supernova/auth/AuthConstants.java new file mode 100644 index 0000000..6978e48 --- /dev/null +++ b/src/main/java/com/hanshin/supernova/auth/AuthConstants.java @@ -0,0 +1,6 @@ +package com.hanshin.supernova.auth; + +public class AuthConstants { + public static final String ACCESS_TOKEN_HEADER_KEY = "X-QQ-ACCESS-TOKEN"; // 기존 X-QQ-AUTH-TOKEN을 변경 // AccessToken 헤더 + public static final String REFRESH_TOKEN_HEADER_KEY = "X-QQ-REFRESH-TOKEN"; // RefreshToken 헤더 +} diff --git a/src/main/java/com/hanshin/supernova/auth/AuthCostants.java b/src/main/java/com/hanshin/supernova/auth/AuthCostants.java deleted file mode 100644 index fe3038b..0000000 --- a/src/main/java/com/hanshin/supernova/auth/AuthCostants.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.hanshin.supernova.auth; - -public class AuthCostants { - public static final String AUTH_TOKEN_HEADER_KEY = "X-QQ-AUTH-TOKEN"; -} diff --git a/src/main/java/com/hanshin/supernova/auth/application/AuthService.java b/src/main/java/com/hanshin/supernova/auth/application/AuthService.java deleted file mode 100644 index 8a77cf6..0000000 --- a/src/main/java/com/hanshin/supernova/auth/application/AuthService.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.hanshin.supernova.auth.application; - -import com.hanshin.supernova.auth.dto.request.AuthLoginRequest; -import com.hanshin.supernova.auth.dto.response.AuthLoginResponse; -import com.hanshin.supernova.exception.auth.AuthInvalidException; -import com.hanshin.supernova.exception.dto.ErrorType; -import com.hanshin.supernova.user.domain.User; -import com.hanshin.supernova.user.infrastructure.UserRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class AuthService { - - private final UserRepository userRepository; - private final TokenService tokenService; - - public AuthLoginResponse login(AuthLoginRequest request) { - - var user = getUserByEmail(request.getEmail()); - - verifyPassword(request.getPassword(), user.getPassword()); - - var token = getTokenByUser(user); - - return new AuthLoginResponse(user.getNickname(), token); - } - - private User getUserByEmail(String email) { - var user = userRepository.findByEmail(email) - .orElseThrow(() -> new AuthInvalidException(ErrorType.USER_NOT_FOUND_ERROR)); - return user; - } - - private void verifyPassword(String reqPassword, String userPassword) { - if (!reqPassword.equals(userPassword)) { - throw new AuthInvalidException(ErrorType.INVALID_PASSWORD); - } - } - - private String getTokenByUser(User user) { - return tokenService.jwtBuilder(user.getId(), user.getNickname()); - } - - public void logout(String token) { - tokenService.logout(token); - } -} \ No newline at end of file diff --git a/src/main/java/com/hanshin/supernova/auth/application/TokenService.java b/src/main/java/com/hanshin/supernova/auth/application/TokenService.java deleted file mode 100644 index 2192a20..0000000 --- a/src/main/java/com/hanshin/supernova/auth/application/TokenService.java +++ /dev/null @@ -1,113 +0,0 @@ -package com.hanshin.supernova.auth.application; - -import com.hanshin.supernova.auth.model.AuthToken; -import com.hanshin.supernova.auth.model.AuthUser; -import com.hanshin.supernova.auth.model.AuthUserImpl; -import com.hanshin.supernova.exception.auth.AuthorizationException; -import com.hanshin.supernova.exception.dto.ErrorType; -import com.hanshin.supernova.user.infrastructure.UserRepository; -import io.jsonwebtoken.Claims; -import io.jsonwebtoken.Jws; -import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.SignatureAlgorithm; -import jakarta.servlet.http.HttpServletRequest; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; - -import java.util.Date; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; - -import static com.hanshin.supernova.auth.AuthCostants.AUTH_TOKEN_HEADER_KEY; - -@Slf4j -@Component -@RequiredArgsConstructor - -public class TokenService { - // private final long accessTokenValidMillisecond = 1000L * 60 * 100000; // AccessToken 30초 토큰 - // 유효 - private final UserRepository userRepository; - private String key; - private final Set blacklistedTokens = ConcurrentHashMap.newKeySet(); - - @Value("${jwt.secret.key}") - public void getSecretKey(String secretKey) { - log.info("secret key {}", secretKey); - key = secretKey; - } - - public void verifyToken(String token) { - if (isTokenBlacklisted(token)) { - throw new AuthorizationException(ErrorType.TOKEN_BLACKLISTED); - } - try { - Jws claims = Jwts.parser().setSigningKey(key.getBytes()).parseClaimsJws(token); - } catch (Exception e) { - if (e.getMessage().contains("JWT expired")) { - throw new AuthorizationException(ErrorType.AUTHORIZATION_ERROR); - } - throw new AuthorizationException(ErrorType.AUTHORIZATION_ERROR); - } - Long uid = getUserIdFromToken(token); - if (!userRepository.existsById(uid)) { - throw new AuthorizationException(ErrorType.AUTHORIZATION_ERROR); - } - } - - public AuthUser getAuthUser(AuthToken token) { - verifyToken(token.getToken()); - var id = getUserIdFromToken(token.getToken()); - var user = - userRepository - .findById(id) - .orElseThrow( - () -> new AuthorizationException(ErrorType.AUTHORIZATION_ERROR)); - return new AuthUserImpl(id); - } - - public Long getUserIdFromToken(String token) { - return Long.valueOf( - (Integer) - Jwts.parser() - .setSigningKey(key.getBytes()) - .parseClaimsJws(token) - .getBody() - .get("uid")); - } - - public String jwtBuilder(Long id, String nickname) { - Claims claims = Jwts.claims(); - claims.put("nickname", nickname); - claims.put("uid", id); - Date now = new Date(); - return Jwts.builder() - .setClaims(claims) - // TODO : 유효기간 설정은 다음 MVP에서 진행한다. - // .setExpiration(new Date(now.getTime() + accessTokenValidMillisecond)) - .signWith(SignatureAlgorithm.HS256, key.getBytes()) - .compact(); - } - - public void logout(String token) { - if (token != null && token.startsWith("Bearer ")) { - token = token.substring(7); - } - - try { - verifyToken(token); - blacklistedTokens.add(token); - log.info("Token blacklisted: {}", token); - } catch (Exception e) { - log.error("Error during logout", e); - throw new AuthorizationException(ErrorType.AUTHORIZATION_ERROR); - } - } - - public boolean isTokenBlacklisted(String token) { - return blacklistedTokens.contains(token); - } - -} \ No newline at end of file diff --git a/src/main/java/com/hanshin/supernova/auth/dto/request/AuthLoginRequest.java b/src/main/java/com/hanshin/supernova/auth/dto/request/AuthLoginRequest.java index 1768c5b..527a6a0 100644 --- a/src/main/java/com/hanshin/supernova/auth/dto/request/AuthLoginRequest.java +++ b/src/main/java/com/hanshin/supernova/auth/dto/request/AuthLoginRequest.java @@ -1,10 +1,16 @@ package com.hanshin.supernova.auth.dto.request; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; import lombok.Data; @Data public class AuthLoginRequest { + @NotBlank + @Email private String email; + + @NotBlank private String password; } diff --git a/src/main/java/com/hanshin/supernova/auth/dto/response/AuthLoginResponse.java b/src/main/java/com/hanshin/supernova/auth/dto/response/AuthLoginResponse.java index 12eba7b..343976d 100644 --- a/src/main/java/com/hanshin/supernova/auth/dto/response/AuthLoginResponse.java +++ b/src/main/java/com/hanshin/supernova/auth/dto/response/AuthLoginResponse.java @@ -8,5 +8,6 @@ public class AuthLoginResponse { private String nickname; - private String token; + private String accessToken; + private String refreshToken; } diff --git a/src/main/java/com/hanshin/supernova/auth/model/AuthToken.java b/src/main/java/com/hanshin/supernova/auth/model/AuthToken.java index 1e13d76..e1478b9 100644 --- a/src/main/java/com/hanshin/supernova/auth/model/AuthToken.java +++ b/src/main/java/com/hanshin/supernova/auth/model/AuthToken.java @@ -2,7 +2,7 @@ import lombok.Data; -import static com.hanshin.supernova.auth.AuthCostants.AUTH_TOKEN_HEADER_KEY; +import static com.hanshin.supernova.auth.AuthConstants.ACCESS_TOKEN_HEADER_KEY; @Data public class AuthToken { @@ -10,7 +10,7 @@ public class AuthToken { private final String token; public AuthToken(String token) { - this.key = AUTH_TOKEN_HEADER_KEY; + this.key = ACCESS_TOKEN_HEADER_KEY; this.token = token; } } \ No newline at end of file diff --git a/src/main/java/com/hanshin/supernova/auth/model/AuthUserImpl.java b/src/main/java/com/hanshin/supernova/auth/model/AuthUserImpl.java index 767bc12..ff9e6ef 100644 --- a/src/main/java/com/hanshin/supernova/auth/model/AuthUserImpl.java +++ b/src/main/java/com/hanshin/supernova/auth/model/AuthUserImpl.java @@ -1,13 +1,70 @@ package com.hanshin.supernova.auth.model; +import com.hanshin.supernova.user.domain.Authority; +import jakarta.validation.constraints.NotNull; import lombok.Data; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.Collections; @Data -public class AuthUserImpl implements AuthUser { +@RequiredArgsConstructor +public class AuthUserImpl implements AuthUser, UserDetails { + + @NotNull private final Long id; + private final String email; + private final String password; + private final Authority authority; + // AuthUser 인터페이스 메서드 @Override public Long getId() { return this.id; } + + // UserDetails 인터페이스 메서드 구현 + @Override + public Collection getAuthorities() { + // 사용자의 권한을 설정, 여기서는 간단하게 하나의 권한을 반환하도록 함 + return Collections.singletonList(new SimpleGrantedAuthority("ROLE_" + authority.name())); + } + + @Override + public String getPassword() { + return this.password; + } + + @Override + public String getUsername() { + return this.email; + } + + @Override + public boolean isAccountNonExpired() { + // 계정 만료 여부 (true로 설정해 만료되지 않음) + return true; + } + + @Override + public boolean isAccountNonLocked() { + // 계정 잠금 여부 (true로 설정해 잠기지 않음) + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + // 자격 증명(비밀번호) 만료 여부 (true로 설정해 만료되지 않음) + return true; + } + + @Override + public boolean isEnabled() { + // 계정 활성화 여부 (true로 설정해 활성화 상태 유지) + return true; + } } \ No newline at end of file diff --git a/src/main/java/com/hanshin/supernova/auth/presentation/AuthController.java b/src/main/java/com/hanshin/supernova/auth/presentation/AuthController.java index 5d29736..30f5708 100644 --- a/src/main/java/com/hanshin/supernova/auth/presentation/AuthController.java +++ b/src/main/java/com/hanshin/supernova/auth/presentation/AuthController.java @@ -1,33 +1,192 @@ package com.hanshin.supernova.auth.presentation; -import com.hanshin.supernova.auth.application.AuthService; import com.hanshin.supernova.auth.dto.request.AuthLoginRequest; -import com.hanshin.supernova.common.model.ResponseDto; +import com.hanshin.supernova.auth.dto.response.AuthLoginResponse; import com.hanshin.supernova.exception.auth.AuthInvalidException; +import com.hanshin.supernova.exception.dto.ErrorType; +import com.hanshin.supernova.redis.service.RedisService; +import com.hanshin.supernova.security.service.JwtService; +import com.hanshin.supernova.user.application.UserService; +import com.hanshin.supernova.user.domain.User; +import io.jsonwebtoken.ExpiredJwtException; +import jakarta.annotation.PostConstruct; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import lombok.Getter; import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.web.bind.annotation.*; -import static com.hanshin.supernova.auth.AuthCostants.AUTH_TOKEN_HEADER_KEY; +import static com.hanshin.supernova.auth.AuthConstants.*; +@Slf4j @RestController @RequestMapping(path = "/api/auth", produces = MediaType.APPLICATION_JSON_VALUE) @RequiredArgsConstructor public class AuthController { - private final AuthService authService; + private final UserService userService; + private final PasswordEncoder passwordEncoder; + private final JwtService jwtService; + private final RedisService redisService; // Redis를 통한 RefreshToken 관리 + @Value("${spring.security.jwt.refresh.expiration}") + private int refreshTokenExpiration; + + + @Getter + private int refreshTokenExpirationMinutes; + + @PostConstruct + public void init() { + // 초를 분으로 변환 + this.refreshTokenExpirationMinutes = this.refreshTokenExpiration / 60; + } + + + // 로그인 엔드포인트 @PostMapping("/login") - public ResponseEntity login(@RequestBody AuthLoginRequest request) { - var response = authService.login(request); - return ResponseDto.ok(response); + public ResponseEntity login(@RequestBody AuthLoginRequest loginRequest) { + // 이메일로 사용자를 조회 + User user = userService.getByEmail(loginRequest.getEmail()); + + // 비밀번호가 일치하는지 검증 + if (!passwordEncoder.matches(loginRequest.getPassword(), user.getPassword())) { + throw new AuthInvalidException(ErrorType.WRONG_PASSWORD_ERROR); + } + + // 로그인 성공 시 AccessToken과 RefreshToken 발급 + String accessToken = jwtService.generateAccessToken(user.getId(), user.getEmail(), user.getAuthority().name()); + + // RefreshToken 생성 및 Redis에 저장 (7일간 유효) + String refreshToken = jwtService.generateRefreshToken(user.getId(), user.getEmail(), user.getAuthority().name()); + log.info("Redis에 저장할 RefreshToken: {}", refreshToken); + + // Redis에 RefreshToken 저장 및 로그 추가 + redisService.set(user.getEmail(), refreshToken, refreshTokenExpirationMinutes); // Redis에 RefreshToken 저장 + log.info("Redis에 RefreshToken 저장 완료: 키={}, 만료시간={}", user.getEmail(), refreshTokenExpirationMinutes); + + + // 헤더와 쿠키를 설정할 HttpHeaders 객체 생성 + HttpHeaders headers = new HttpHeaders(); + + // RefreshToken을 HttpOnly 쿠키에 추가 + ResponseCookie refreshTokenCookie = ResponseCookie.from(REFRESH_TOKEN_HEADER_KEY, refreshToken) + .httpOnly(true) // JavaScript 접근 불가 (HttpOnly 쿠키) + .secure(true) // HTTPS를 사용하는 경우 true로 설정 + .path("/") // 쿠키 경로 설정 + .maxAge(refreshTokenExpirationMinutes) // 쿠키 만료 시간 설정 + .build(); + headers.add(HttpHeaders.SET_COOKIE, refreshTokenCookie.toString()); + + // AccessToken과 RefreshToken을 클라이언트로 반환 + return ResponseEntity.ok() + .headers(headers) + .body(new AuthLoginResponse(user.getNickname(), accessToken, refreshToken)); + } + + + // RefreshToken을 사용해 AccessToken 재발급 + @PostMapping("/refresh") + public ResponseEntity refreshAccessToken(HttpServletRequest request) { + +// // 요청에서 RefreshToken 가져오기 +// String refreshToken = request.getHeader(REFRESH_TOKEN_HEADER_KEY); + + // 요청에서 쿠키로 RefreshToken 가져오기 + String refreshToken = null; + if (request.getCookies() != null) { + for (Cookie cookie : request.getCookies()) { + if (REFRESH_TOKEN_HEADER_KEY.equals(cookie.getName())) { + refreshToken = cookie.getValue(); + break; + } + } + } + + // RefreshToken이 없는 경우 + if (refreshToken == null || refreshToken.isBlank()) { + return ResponseEntity.status(400).body("Refresh token is missing"); + } + + // RefreshToken 유효성 검증 + if (!jwtService.validateToken(refreshToken)) { + return ResponseEntity.status(403).body("Invalid Refresh Token"); + } + + // RefreshToken에서 사용자 이메일 추출 + String email = jwtService.getClaimsFromToken(refreshToken).getSubject(); + Long userId = jwtService.getClaimsFromToken(refreshToken).get("userId", Long.class); + + // Redis에서 해당 이메일의 RefreshToken 조회 (유효성 재검증) + String storedRefreshToken = redisService.getValue(email); + if (!refreshToken.equals(storedRefreshToken)) { + return ResponseEntity.status(403).body("Refresh Token mismatch"); + } + + // 새로운 AccessToken 발급 + String newAccessToken = jwtService.generateAccessToken(userId, email, "USER"); // 권한은 예시로 ROLE_USER 사용 + + // 새 AccessToken 반환 + return ResponseEntity.ok(newAccessToken); } +// // 로그아웃 엔드포인트 (변경 없음) +// @PostMapping("/logout") +// public ResponseEntity logout(HttpServletRequest request) { +// String email = request.getHeader(USER_EMAIL_HEADER_KEY); +// redisService.delete(email); // 로그아웃 시 Redis에서 RefreshToken 삭제 +// return ResponseEntity.ok("Logged out successfully"); +// } + @PostMapping("/logout") - public ResponseEntity logout(@RequestHeader(AUTH_TOKEN_HEADER_KEY) String token) { - authService.logout(token); - return ResponseDto.ok("Successfully logged out"); + public ResponseEntity logout(HttpServletRequest request) { + + // 쿠키에서 RefreshToken 가져오기 + String refreshToken = null; + if (request.getCookies() != null) { + for (Cookie cookie : request.getCookies()) { + if (cookie.getName().equals(REFRESH_TOKEN_HEADER_KEY)) { + refreshToken = cookie.getValue(); + break; + } + } + } + + // RefreshToken이 없는 경우 오류 응답 반환 + if (refreshToken == null) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("Refresh token is missing"); + } + + HttpHeaders headers = new HttpHeaders(); + + try { + // RefreshToken에서 이메일 추출 + String email = jwtService.getClaimsFromToken(refreshToken).getSubject(); + + // Redis에서 RefreshToken 삭제 + redisService.delete(email); + + // RefreshToken 쿠키를 삭제하도록 만료된 쿠키 설정 + ResponseCookie expiredRefreshTokenCookie = ResponseCookie.from(REFRESH_TOKEN_HEADER_KEY, "") + .httpOnly(true) + .secure(true) // HTTPS를 사용하는 경우 true로 설정 + .path("/") // 쿠키 경로 설정 + .maxAge(0) // 쿠키를 즉시 만료 + .build(); + headers.add(HttpHeaders.SET_COOKIE, expiredRefreshTokenCookie.toString()); + + return ResponseEntity.ok() + .headers(headers) + .body("Logged out successfully"); + + } catch (ExpiredJwtException e) { + return ResponseEntity.status(ErrorType.EXPIRED_ACCESS_TOKEN.getStatus()).body("Access token is expired"); + } catch (Exception e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("An error occurred during logout"); + } } } \ No newline at end of file diff --git a/src/main/java/com/hanshin/supernova/auth/service/CustomUserDetailsService.java b/src/main/java/com/hanshin/supernova/auth/service/CustomUserDetailsService.java new file mode 100644 index 0000000..7abd4c3 --- /dev/null +++ b/src/main/java/com/hanshin/supernova/auth/service/CustomUserDetailsService.java @@ -0,0 +1,34 @@ +package com.hanshin.supernova.auth.service; + +import com.hanshin.supernova.auth.model.AuthUserImpl; +import com.hanshin.supernova.exception.dto.ErrorType; +import com.hanshin.supernova.exception.user.UserInvalidException; +import com.hanshin.supernova.user.domain.User; +import com.hanshin.supernova.user.infrastructure.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + + private final UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { + // userRepository를 사용해서 이메일로 사용자를 조회 + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new UserInvalidException(ErrorType.USER_NOT_FOUND_ERROR)); + + // 조회된 사용자를 AuthUserImpl로 변환해서 반환 + return new AuthUserImpl( + user.getId(), + user.getEmail(), + user.getPassword(), + user.getAuthority() + ); + } +} diff --git a/src/main/java/com/hanshin/supernova/badge/application/BadgeService.java b/src/main/java/com/hanshin/supernova/badge/application/BadgeService.java new file mode 100644 index 0000000..caa98e7 --- /dev/null +++ b/src/main/java/com/hanshin/supernova/badge/application/BadgeService.java @@ -0,0 +1,131 @@ +package com.hanshin.supernova.badge.application; + +import com.hanshin.supernova.answer.domain.Answer; +import com.hanshin.supernova.answer.infrastructure.AnswerRepository; +import com.hanshin.supernova.auth.model.AuthUser; +import com.hanshin.supernova.question.infrastructure.QuestionRepository; +import com.hanshin.supernova.user.domain.Activity; +import com.hanshin.supernova.user.domain.User; +import com.hanshin.supernova.user.infrastructure.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class BadgeService { + + private final QuestionRepository questionRepository; + private final UserRepository userRepository; + private final AnswerRepository answerRepository; + + /** + * 멋진 질문자 배지를 부여하는 메서드 + * @param user 인증된 사용자 정보 + */ + @Transactional + public void grantBookmarkedQuestionerBadge(AuthUser user) { + log.info("\n\n\n\ngrantBookmarkedQuestionerBadge"); + // 1. AuthUser의 userId로 User 엔티티 조회 + User foundUser = userRepository.findById(user.getId()) + .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다.")); + + // 2. 사용자가 작성한 질문 중 다른 사용자의 북마크에 10건 이상 추가된 질문 조회 + int bookmarkThreshold = 10; // 북마크 기준 수 + List bookmarkedQuestions = questionRepository.findBookmarkedQuestionsByUserId(foundUser.getId(), bookmarkThreshold); + log.info("bookmarked questions: {}", bookmarkedQuestions); + + // 3. 북마크 기준을 충족하는 질문이 있으면 멋진 질문자 배지를 부여 + if (!bookmarkedQuestions.isEmpty()) { + Activity activity = foundUser.getActivity(); + if (!activity.hasMarkedQuestionBadge()) { + activity.setMarkedQuestionBadge(true); + userRepository.save(foundUser); // 변경된 Activity 상태를 저장 + } + } + } + + /** + * 인기 질문자 배지를 부여하는 메서드 + * @param user 인증된 사용자 정보 + */ + @Transactional + public void grantPopularQuestionBadge(AuthUser user) { + log.info("\n\n\n\ngrantPopularQuestionBadge"); + // 1. AuthUser의 userId로 User 엔티티 조회 + User foundUser = userRepository.findById(user.getId()) + .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다.")); + + // 2. 추천 수가 10 이상인 질문을 조회 + int recommendationThreshold = 10; // 추천 수 기준 + int questionCountThreshold = 3; // 질문 개수 기준 + List popularQuestions = questionRepository.findPopularQuestionsByUserId(foundUser.getId(), recommendationThreshold); + log.info("popular questions: {}", popularQuestions); + + // 3. 조건을 충족하는 경우 인기 질문자 배지를 부여 + if (popularQuestions.size() >= questionCountThreshold) { + Activity activity = foundUser.getActivity(); + if (!activity.hasPopularQuestionBadge()) { + activity.setPopularQuestionBadge(true); + userRepository.save(foundUser); // 변경된 Activity 상태를 저장 + } + } + } + + /** + * 정확한 답변자 배지를 부여하는 메서드 + * @param user 인증된 사용자 정보 + */ + @Transactional + public void grantAcceptedAnswerBadge(AuthUser user) { + log.info("\n\n\n\ngrantAcceptedAnswerBadge"); + // 1. AuthUser의 userId로 User 엔티티 조회 + User foundUser = userRepository.findById(user.getId()) + .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다.")); + + // 2. 사용자가 작성한 답변 중 소스를 제공하고 채택된 답변을 조회 + int acceptedAnswerCountThreshold = 3; // 채택된 답변 수 기준 + List acceptedAnswersWithSource = answerRepository.findAcceptedAnswersWithSourceByUserId(foundUser.getId()); + log.info("acceptedAnswerCountThreshold: {}", acceptedAnswersWithSource); + + // 3. 조건을 충족하는 경우 정확한 답변자 배지를 부여 + if (acceptedAnswersWithSource.size() >= acceptedAnswerCountThreshold) { + Activity activity = foundUser.getActivity(); + if (!activity.hasAcceptedAnswerBadge()) { + activity.setAcceptedAnswerBadge(true); + userRepository.save(foundUser); // 변경된 Activity 상태를 저장 + } + } + } + + /** + * 인기 답변자 배지를 부여하는 메서드 + * @param user 인증된 사용자 정보 + */ + @Transactional + public void grantPopularAnswererBadge(AuthUser user) { + log.info("\n\n\n\ngrantPopularAnswererBadge"); + // 1. AuthUser의 userId로 User 엔티티 조회 + User foundUser = userRepository.findById(user.getId()) + .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다.")); + + // 2. 사용자가 작성한 답변 중 소스를 제공하고 추천 수가 10 이상인 답변을 조회 + int recommendationThreshold = 10; // 추천 수 기준 + int answerCountThreshold = 3; // 답변 개수 기준 + List popularAnswersWithSource = answerRepository.findPopularAnswersWithSourceByUserId(foundUser.getId(), recommendationThreshold); + log.info("popularAnswersWithSource: {}", popularAnswersWithSource); + + // 3. 조건을 충족하는 경우 인기 답변자 배지를 부여 + if (popularAnswersWithSource.size() >= answerCountThreshold) { + Activity activity = foundUser.getActivity(); + if (!activity.hasPopularAnswerBadge()) { + activity.setPopularAnswerBadge(true); + userRepository.save(foundUser); // 변경된 Activity 상태를 저장 + } + } + } +} diff --git a/src/main/java/com/hanshin/supernova/badge/presentation/BadgeController.java b/src/main/java/com/hanshin/supernova/badge/presentation/BadgeController.java new file mode 100644 index 0000000..fe5d505 --- /dev/null +++ b/src/main/java/com/hanshin/supernova/badge/presentation/BadgeController.java @@ -0,0 +1,51 @@ +package com.hanshin.supernova.badge.presentation; + +import com.hanshin.supernova.auth.model.AuthUser; +import com.hanshin.supernova.badge.application.BadgeService; +import com.hanshin.supernova.user.domain.Activity; +import com.hanshin.supernova.user.domain.User; +import com.hanshin.supernova.user.application.UserService; +import com.hanshin.supernova.user.infrastructure.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import jakarta.servlet.http.HttpServletRequest; + +@Slf4j +@RestController +@RequestMapping("/api/badges") +@RequiredArgsConstructor +public class BadgeController { + + private final BadgeService badgeService; + private final UserRepository userRepository; + + /** + * 사용자의 배지 상태를 반환하는 엔드포인트 + * @param authUser JwtFilter 및 UserArgumentResolver로 주입된 AuthUser + * @return 사용자의 배지 상태 + */ + @GetMapping + public ResponseEntity getUserBadges(AuthUser authUser) { + // 1. AuthUser의 userId로 User 엔티티 조회 + User user = userRepository.findById(authUser.getId()) + .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다.")); + + // 2. 배지 부여 로직 실행 + badgeService.grantBookmarkedQuestionerBadge(authUser); + badgeService.grantPopularQuestionBadge(authUser); + badgeService.grantAcceptedAnswerBadge(authUser); + badgeService.grantPopularAnswererBadge(authUser); + + // 3. User의 Activity(배지 상태) 반환 + Activity activity = user.getActivity(); + + log.info("BadgeController Activity: {}", activity); + + return ResponseEntity.ok(activity); + } +} \ No newline at end of file diff --git a/src/main/java/com/hanshin/supernova/bookmark/application/BookmarkService.java b/src/main/java/com/hanshin/supernova/bookmark/application/BookmarkService.java new file mode 100644 index 0000000..c3fa2e0 --- /dev/null +++ b/src/main/java/com/hanshin/supernova/bookmark/application/BookmarkService.java @@ -0,0 +1,109 @@ +package com.hanshin.supernova.bookmark.application; + +import com.hanshin.supernova.answer.infrastructure.AnswerRepository; +import com.hanshin.supernova.bookmark.domain.Bookmark; +import com.hanshin.supernova.bookmark.domain.BookmarkType; +import com.hanshin.supernova.bookmark.dto.request.BookmarkRequest; +import com.hanshin.supernova.bookmark.dto.response.BookmarkResponse; +import com.hanshin.supernova.bookmark.infrastructure.BookmarkRepository; +import com.hanshin.supernova.exception.bookmark.BookmarkNotFoundException; +import com.hanshin.supernova.exception.dto.ErrorType; +import com.hanshin.supernova.exception.user.UserInvalidException; +import com.hanshin.supernova.question.infrastructure.QuestionRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional +public class BookmarkService { + + private final BookmarkRepository bookmarkRepository; + private final QuestionRepository questionRepository; + private final AnswerRepository answerRepository; + + /** + * 특정 유저의 질문 북마크 리스트 조회 + */ + public List getBookmarkedQuestions(Long userId, Long commId) { + List bookmarks = bookmarkRepository.findByUserIdAndCommIdAndType(userId, commId, BookmarkType.QUESTION); + + if (bookmarks == null || bookmarks.isEmpty()) { + return Collections.emptyList(); // 빈 리스트 반환 + } + + // 람다식을 사용하여 추가 매개변수 전달 + return bookmarks.stream() + .map(bookmark -> BookmarkResponse.fromEntity(bookmark, questionRepository, answerRepository)) + .collect(Collectors.toList()); + } + + /** + * 특정 유저의 답변 북마크 리스트 조회 + */ + public List getBookmarkedAnswers(Long userId, Long commId) { + List bookmarks = bookmarkRepository.findByUserIdAndCommIdAndType(userId, commId, BookmarkType.ANSWER); + + if (bookmarks == null || bookmarks.isEmpty()) { + return Collections.emptyList(); // 빈 리스트 반환 + } + + // 람다식을 사용하여 추가 매개변수 전달 + return bookmarks.stream() + .map(bookmark -> BookmarkResponse.fromEntity(bookmark, questionRepository, answerRepository)) + .collect(Collectors.toList()); + } + + /** + * 북마크 추가 + */ + public void addBookmark(Long userId, BookmarkRequest request) { + Bookmark bookmark = Bookmark.builder() + .userId(userId) + .targetId(request.getTargetId()) + .type(request.getType()) + .commId(request.getCommId()) + .build(); + bookmarkRepository.save(bookmark); + } + + /** + * 북마크 삭제 + */ + public void removeBookmark(Long userId, BookmarkRequest request) { + BookmarkType bookmarkType = request.getType(); // 타입이 이미 BookmarkType으로 제공됨 + Bookmark bookmark = bookmarkRepository.findByUserIdAndCommIdAndTargetIdAndType( + userId, + request.getCommId(), + request.getTargetId(), + bookmarkType + ).orElseThrow(() -> new BookmarkNotFoundException(ErrorType.BOOKMARK_NOT_FOUND)); + + if (!bookmark.getUserId().equals(userId)) { + throw new UserInvalidException(ErrorType.USER_NOT_FOUND_ERROR); + } + + bookmarkRepository.delete(bookmark); + } + + + + /** + * 특정 질문 북마크 상태 확인 + */ + public boolean isQuestionBookmarked(Long userId, Long commId, Long questionId) { + return bookmarkRepository.existsByUserIdAndCommIdAndTargetIdAndType(userId, commId, questionId, BookmarkType.QUESTION); + } + + /** + * 특정 답변 북마크 상태 확인 + */ + public boolean isAnswerBookmarked(Long userId, Long commId, Long answerId) { + return bookmarkRepository.existsByUserIdAndCommIdAndTargetIdAndType(userId, commId, answerId, BookmarkType.ANSWER); + } +} diff --git a/src/main/java/com/hanshin/supernova/bookmark/application/BookmarkServiceImpl.java b/src/main/java/com/hanshin/supernova/bookmark/application/BookmarkServiceImpl.java new file mode 100644 index 0000000..4238b6a --- /dev/null +++ b/src/main/java/com/hanshin/supernova/bookmark/application/BookmarkServiceImpl.java @@ -0,0 +1,218 @@ +////package com.hanshin.supernova.bookmark.application; +// +////import com.hanshin.supernova.bookmark.domain.Bookmark; +////import com.hanshin.supernova.bookmark.infrastructure.BookmarkRepository; +////import com.hanshin.supernova.exception.auth.AuthInvalidException; +////import com.hanshin.supernova.exception.dto.ErrorType; +////import com.hanshin.supernova.exception.question.QuestionInvalidException; +////import com.hanshin.supernova.question.domain.Question; +////import com.hanshin.supernova.question.infrastructure.QuestionRepository; +////import com.hanshin.supernova.user.domain.User; +////import com.hanshin.supernova.user.infrastructure.UserRepository; +////import com.hanshin.supernova.answer.domain.Answer; +////import com.hanshin.supernova.answer.infrastructure.AnswerRepository; +////import jakarta.servlet.http.HttpServletRequest; +////import io.jsonwebtoken.Claims; +////import lombok.RequiredArgsConstructor; +////import org.springframework.stereotype.Service; +////import org.springframework.transaction.annotation.Transactional; +//// +////import java.util.ArrayList; +////import java.util.List; +//// +////@Service +////@RequiredArgsConstructor +////public class BookmarkServiceImpl implements BookmarkService { +//// +//// private final BookmarkRepository bookmarkRepository; +//// private final QuestionRepository questionRepository; +//// private final AnswerRepository answerRepository; +//// private final UserRepository userRepository; +//// +//// /** +//// * 질문에 대한 북마크 추가 +//// */ +//// @Override +//// @Transactional +//// public void addQuestionBookmark(HttpServletRequest request, Long questionId) { +//// User user = getUserFromClaims(request); +//// Question question = questionRepository.findById(questionId) +//// .orElseThrow(() -> new QuestionInvalidException(ErrorType.QUESTION_NOT_FOUND_ERROR)); +//// bookmarkRepository.findByUserAndQuestion(user, question) +//// .orElseGet(() -> bookmarkRepository.save(Bookmark.builder().user(user).question(question).build())); +//// } +//// +//// /** +//// * 질문에 대한 북마크 삭제 +//// */ +//// @Override +//// @Transactional +//// public void removeQuestionBookmark(HttpServletRequest request, Long questionId) { +//// User user = getUserFromClaims(request); +//// Question question = questionRepository.findById(questionId) +//// .orElseThrow(() -> new QuestionInvalidException(ErrorType.QUESTION_NOT_FOUND_ERROR)); +//// bookmarkRepository.findByUserAndQuestion(user, question) +//// .ifPresent(bookmarkRepository::delete); +//// } +//// +//// /** +//// * 답변에 대한 북마크 추가 +//// */ +//// @Override +//// @Transactional +//// public void addAnswerBookmark(HttpServletRequest request, Long answerId) { +//// User user = getUserFromClaims(request); +//// Answer answer = answerRepository.findById(answerId) +//// .orElseThrow(() -> new QuestionInvalidException(ErrorType.ANSWER_NOT_FOUND_ERROR)); +//// bookmarkRepository.findByUserAndAnswer(user, answer) +//// .orElseGet(() -> bookmarkRepository.save(Bookmark.builder().user(user).answer(answer).build())); +//// } +//// +//// /** +//// * 답변에 대한 북마크 삭제 +//// */ +//// @Override +//// @Transactional +//// public void removeAnswerBookmark(HttpServletRequest request, Long answerId) { +//// User user = getUserFromClaims(request); +//// Answer answer = answerRepository.findById(answerId) +//// .orElseThrow(() -> new QuestionInvalidException(ErrorType.ANSWER_NOT_FOUND_ERROR)); +//// bookmarkRepository.findByUserAndAnswer(user, answer) +//// .ifPresent(bookmarkRepository::delete); +//// } +//// +//// /** +//// * 북마크된 질문 목록 조회 +//// */ +//// @Override +//// @Transactional(readOnly = true) +//// public List getBookmarkedQuestions(HttpServletRequest request) { +//// User user = getUserFromClaims(request); +//// return bookmarkRepository.findAllByUserAndQuestionIsNotNull(user); +//// } +//// +//// /** +//// * 북마크된 답변 목록 조회 +//// */ +//// @Override +//// @Transactional(readOnly = true) +//// public List getBookmarkedAnswers(HttpServletRequest request) { +//// User user = getUserFromClaims(request); +//// return bookmarkRepository.findAllByUserAndAnswerIsNotNull(user); +//// } +//// +//// private User getUserFromClaims(HttpServletRequest request) { +//// // HttpServletRequest에서 Claims 객체를 가져와 User 정보를 반환 +//// Claims claims = (Claims) request.getAttribute("claims"); +//// if (claims == null) { +//// throw new AuthInvalidException(ErrorType.SYSTEM_USER_NOT_FOUND_ERROR); +//// } +//// +//// String email = claims.getSubject(); +//// if (email == null) { +//// throw new AuthInvalidException(ErrorType.SYSTEM_USER_NOT_FOUND_ERROR); +//// } +//// +//// return userRepository.findByEmail(email) +//// .orElseThrow(() -> new AuthInvalidException(ErrorType.SYSTEM_USER_NOT_FOUND_ERROR)); +//// } +//// +//// @Override +//// @Transactional(readOnly = true) +//// public List getCommunityBookmarks(Long communityId, HttpServletRequest httpRequest) { +//// User user = getUserFromClaims(httpRequest); +//// +//// // 북마크된 질문 및 답변을 각각 조회 +//// List questionBookmarks = bookmarkRepository.findByUserAndCommunityIdForQuestions(user, communityId); +//// List answerBookmarks = bookmarkRepository.findByUserAndCommunityIdForAnswers(user, communityId); +//// +//// // 질문과 답변 북마크를 하나의 리스트로 결합 +//// List allBookmarks = new ArrayList<>(); +//// allBookmarks.addAll(questionBookmarks); +//// allBookmarks.addAll(answerBookmarks); +//// +//// return allBookmarks; +//// } +//// +////} +//package com.hanshin.supernova.bookmark.application; +// +//import com.hanshin.supernova.answer.infrastructure.AnswerRepository; +//import com.hanshin.supernova.bookmark.domain.Bookmark; +//import com.hanshin.supernova.bookmark.infrastructure.BookmarkRepository; +//import com.hanshin.supernova.exception.answer.AnswerInvalidException; +//import com.hanshin.supernova.exception.dto.ErrorType; +//import com.hanshin.supernova.exception.question.QuestionInvalidException; +//import com.hanshin.supernova.question.domain.Question; +//import com.hanshin.supernova.answer.domain.Answer; +//import com.hanshin.supernova.question.infrastructure.QuestionRepository; +//import lombok.RequiredArgsConstructor; +//import lombok.extern.slf4j.Slf4j; +//import org.springframework.stereotype.Service; +//import org.springframework.transaction.annotation.Transactional; +// +//import java.time.LocalDateTime; +//import java.util.List; +// +//@Service +//public class BookmarkServiceImpl implements BookmarkService { +// +// private final BookmarkRepository bookmarkRepository; +// private final QuestionRepository questionRepository; +// private final AnswerRepository answerRepository; // 추가 +// +// public BookmarkServiceImpl(BookmarkRepository bookmarkRepository, +// QuestionRepository questionRepository, +// AnswerRepository answerRepository) { +// this.bookmarkRepository = bookmarkRepository; +// this.questionRepository = questionRepository; +// this.answerRepository = answerRepository; +// } +// +// @Override +// public Long addQuestionBookmark(Long questionId, Long userId) throws QuestionInvalidException { +// // QuestionRepository를 사용해 질문 조회 +// Question question = questionRepository.findById(questionId) +// .orElseThrow(() -> new QuestionInvalidException(ErrorType.QUESTION_NOT_FOUND_ERROR)); +// +// Bookmark bookmark = new Bookmark(); +// bookmark.setUserId(userId); +// bookmark.setQuestion(question); +// +// Bookmark savedBookmark = bookmarkRepository.save(bookmark); +// return savedBookmark.getId(); +// } +// +// @Override +// public Long addAnswerBookmark(Long answerId, Long userId) throws AnswerInvalidException { +// Answer answer = answerRepository.findById(answerId) +// .orElseThrow(() -> new AnswerInvalidException(ErrorType.ANSWER_NOT_FOUND_ERROR)); +// +// Bookmark bookmark = new Bookmark(); +// bookmark.setUserId(userId); +// bookmark.setAnswer(answer); +// +// Bookmark savedBookmark = bookmarkRepository.save(bookmark); +// return savedBookmark.getId(); +// } +// +// @Override +// public LocalDateTime getBookmarkCreatedTime(Long bookmarkId) { +// return bookmarkRepository.findById(bookmarkId).orElseThrow().getCreatedAt(); +// } +// +// @Override +// public List getBookmarkedQuestionsByCommunity(Long userId, Long communityId) { +// return bookmarkRepository.findBookmarkedQuestionsByCommunity(userId, communityId); +// } +// +// @Override +// public List getBookmarkedAnswersByCommunity(Long userId, Long communityId) { +// return bookmarkRepository.findBookmarkedAnswersByCommunity(userId, communityId); +// } +// +// @Override +// public void deleteQuestionBookmark(Long questionId, Long userId) { +// bookmarkRepository.deleteByUserIdAndQuestionId(userId, questionId); +// } +//} diff --git a/src/main/java/com/hanshin/supernova/bookmark/domain/Bookmark.java b/src/main/java/com/hanshin/supernova/bookmark/domain/Bookmark.java new file mode 100644 index 0000000..ece15b5 --- /dev/null +++ b/src/main/java/com/hanshin/supernova/bookmark/domain/Bookmark.java @@ -0,0 +1,33 @@ +package com.hanshin.supernova.bookmark.domain; + +import com.hanshin.supernova.common.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Bookmark extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long userId; // 로그인한 유저 ID + + @Column(nullable = false) + private Long targetId; // 질문 또는 답변의 ID + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private BookmarkType type; // 질문인지 답변인지 구분 + + @Column(nullable = false) + private Long commId; // 커뮤니티 ID +} diff --git a/src/main/java/com/hanshin/supernova/bookmark/domain/BookmarkType.java b/src/main/java/com/hanshin/supernova/bookmark/domain/BookmarkType.java new file mode 100644 index 0000000..58ecd95 --- /dev/null +++ b/src/main/java/com/hanshin/supernova/bookmark/domain/BookmarkType.java @@ -0,0 +1,5 @@ +package com.hanshin.supernova.bookmark.domain; + +public enum BookmarkType { + QUESTION, ANSWER +} diff --git a/src/main/java/com/hanshin/supernova/bookmark/dto/request/BookmarkRequest.java b/src/main/java/com/hanshin/supernova/bookmark/dto/request/BookmarkRequest.java new file mode 100644 index 0000000..c30e5d4 --- /dev/null +++ b/src/main/java/com/hanshin/supernova/bookmark/dto/request/BookmarkRequest.java @@ -0,0 +1,16 @@ +package com.hanshin.supernova.bookmark.dto.request; + +import com.hanshin.supernova.bookmark.domain.BookmarkType; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class BookmarkRequest { + private Long targetId; // 질문/답변 ID + private BookmarkType type; // 질문/답변 구분 + private Long commId; // 커뮤니티 ID +} + diff --git a/src/main/java/com/hanshin/supernova/bookmark/dto/response/BookmarkResponse.java b/src/main/java/com/hanshin/supernova/bookmark/dto/response/BookmarkResponse.java new file mode 100644 index 0000000..f038b04 --- /dev/null +++ b/src/main/java/com/hanshin/supernova/bookmark/dto/response/BookmarkResponse.java @@ -0,0 +1,64 @@ +package com.hanshin.supernova.bookmark.dto.response; + +import com.hanshin.supernova.answer.domain.Answer; +import com.hanshin.supernova.answer.infrastructure.AnswerRepository; +import com.hanshin.supernova.bookmark.domain.Bookmark; +import com.hanshin.supernova.bookmark.domain.BookmarkType; +import com.hanshin.supernova.exception.answer.AnswerInvalidException; +import com.hanshin.supernova.exception.dto.ErrorType; +import com.hanshin.supernova.exception.question.QuestionInvalidException; +import com.hanshin.supernova.question.domain.Question; +import com.hanshin.supernova.question.infrastructure.QuestionRepository; +import lombok.*; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class BookmarkResponse { + private Long id; + private String title; // 질문 제목 + private String content; // 답변 내용 + private Long questionId; // 답변이 속한 질문 ID + + // 데이터가 제대로 직렬화되는지 확인 + @Override + public String toString() { + return "BookmarkResponse{" + + "id=" + id + + ", title='" + title + '\'' + + ", content='" + content + '\'' + + ", questionId=" + questionId + + '}'; + } + + public static BookmarkResponse fromEntity(Bookmark bookmark, QuestionRepository questionRepository, AnswerRepository answerRepository) { + if (bookmark.getType() == BookmarkType.QUESTION) { + Question question = questionRepository.findById(bookmark.getTargetId()) + .orElseThrow(() -> new QuestionInvalidException(ErrorType.QUESTION_NOT_FOUND_ERROR)); + + return new BookmarkResponse( + bookmark.getId(), + question.getTitle(), // 실제 질문 제목 + question.getContent(), // 실제 질문 내용 + bookmark.getTargetId() // 북마크된 대상 ID + ); + } else if (bookmark.getType() == BookmarkType.ANSWER) { + Answer answer = answerRepository.findById(bookmark.getTargetId()) + .orElseThrow(() -> new AnswerInvalidException(ErrorType.ANSWER_NOT_FOUND_ERROR)); + + Question question = questionRepository.findById(answer.getQuestionId()) + .orElseThrow(() -> new QuestionInvalidException(ErrorType.QUESTION_NOT_FOUND_ERROR)); + + return new BookmarkResponse( + bookmark.getId(), + question.getTitle(), // 답변이 달린 질문의 제목 + answer.getAnswer(), // 실제 답변 내용 + bookmark.getTargetId() // 북마크된 대상 ID + ); + } + + throw new IllegalArgumentException("Unknown BookmarkType: " + bookmark.getType()); + } + +} diff --git a/src/main/java/com/hanshin/supernova/bookmark/infrastructure/BookmarkRepository.java b/src/main/java/com/hanshin/supernova/bookmark/infrastructure/BookmarkRepository.java new file mode 100644 index 0000000..a0c506b --- /dev/null +++ b/src/main/java/com/hanshin/supernova/bookmark/infrastructure/BookmarkRepository.java @@ -0,0 +1,21 @@ +package com.hanshin.supernova.bookmark.infrastructure; + +import com.hanshin.supernova.bookmark.domain.Bookmark; +import com.hanshin.supernova.bookmark.domain.BookmarkType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface BookmarkRepository extends JpaRepository { + // 특정 유저가 북마크한 질문 리스트 조회 + List findByUserIdAndCommIdAndType(Long userId, Long commId, BookmarkType type); + + boolean existsByUserIdAndCommIdAndTargetIdAndType(Long userId, Long commId, Long targetId, BookmarkType type); + + // 특정 유저의 특정 타겟 ID와 타입으로 북마크 검색 + Optional findByUserIdAndCommIdAndTargetIdAndType(Long userId, Long commId, Long targetId, BookmarkType type); + +} diff --git a/src/main/java/com/hanshin/supernova/bookmark/presentation/BookmarkController.java b/src/main/java/com/hanshin/supernova/bookmark/presentation/BookmarkController.java new file mode 100644 index 0000000..eb64330 --- /dev/null +++ b/src/main/java/com/hanshin/supernova/bookmark/presentation/BookmarkController.java @@ -0,0 +1,182 @@ +//package com.hanshin.supernova.bookmark.presentation; +// +//import com.hanshin.supernova.bookmark.application.BookmarkService; +//import com.hanshin.supernova.bookmark.domain.Bookmark; +//import io.jsonwebtoken.Claims; +//import jakarta.servlet.http.HttpServletRequest; +//import lombok.RequiredArgsConstructor; +//import org.springframework.http.ResponseEntity; +//import org.springframework.web.bind.annotation.*; +// +//import java.util.List; +// +//@RestController +//@RequiredArgsConstructor +//@RequestMapping("/api/bookmarks") +//public class BookmarkController { +// +// private final BookmarkService bookmarkService; +// +// /** +// * 질문 북마크 추가 +// */ +// @PostMapping("/questions/{questionId}") +// public ResponseEntity addQuestionBookmark( +// HttpServletRequest request, +// @PathVariable Long questionId) { +// bookmarkService.addQuestionBookmark(request, questionId); +// return ResponseEntity.ok().build(); +// } +// +// /** +// * 질문 북마크 삭제 +// */ +// @DeleteMapping("/questions/{questionId}") +// public ResponseEntity removeQuestionBookmark( +// HttpServletRequest request, +// @PathVariable Long questionId) { +// bookmarkService.removeQuestionBookmark(request, questionId); +// return ResponseEntity.ok().build(); +// } +// +// /** +// * 답변 북마크 추가 +// */ +// @PostMapping("/answers/{answerId}") +// public ResponseEntity addAnswerBookmark( +// HttpServletRequest request, +// @PathVariable Long answerId) { +// bookmarkService.addAnswerBookmark(request, answerId); +// return ResponseEntity.ok().build(); +// } +// +// /** +// * 답변 북마크 삭제 +// */ +// @DeleteMapping("/answers/{answerId}") +// public ResponseEntity removeAnswerBookmark( +// HttpServletRequest request, +// @PathVariable Long answerId) { +// bookmarkService.removeAnswerBookmark(request, answerId); +// return ResponseEntity.ok().build(); +// } +// +// /** +// * 북마크된 질문 목록 조회 +// */ +// @GetMapping("/questions") +// public ResponseEntity> getBookmarkedQuestions(HttpServletRequest request) { +// List bookmarks = bookmarkService.getBookmarkedQuestions(request); +// return ResponseEntity.ok(bookmarks); +// } +// +// /** +// * 북마크된 답변 목록 조회 +// */ +// @GetMapping("/answers") +// public ResponseEntity> getBookmarkedAnswers(HttpServletRequest request) { +// List bookmarks = bookmarkService.getBookmarkedAnswers(request); +// return ResponseEntity.ok(bookmarks); +// } +//} +package com.hanshin.supernova.bookmark.presentation; + +import com.hanshin.supernova.auth.model.AuthUser; +import com.hanshin.supernova.bookmark.application.BookmarkService; +import com.hanshin.supernova.bookmark.domain.Bookmark; +import com.hanshin.supernova.bookmark.dto.request.BookmarkRequest; +import com.hanshin.supernova.bookmark.dto.response.BookmarkResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.stream.Collectors; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/communities/{c_id}/bookmarks") +public class BookmarkController { + + private final BookmarkService bookmarkService; + + /** + * 로그인한 유저의 질문 북마크 리스트 조회 + */ + @GetMapping("/questions") + public List getBookmarkedQuestions( + AuthUser authUser, + @PathVariable(name = "c_id") Long commId) { + List responses = bookmarkService.getBookmarkedQuestions(authUser.getId(), commId); + + // 로그 추가 + log.info("User ID: {}, Community ID: {} - Retrieved Bookmarked Questions: {}", + authUser.getId(), commId, responses); + + return responses; + } + + /** + * 로그인한 유저의 답변 북마크 리스트 조회 + */ + @GetMapping("/answers") + public List getBookmarkedAnswers( + AuthUser authUser, + @PathVariable(name = "c_id") Long commId) { + List responses = bookmarkService.getBookmarkedAnswers(authUser.getId(), commId); + + // 로그 추가 + log.info("User ID: {}, Community ID: {} - Retrieved Bookmarked Answers: {}", + authUser.getId(), commId, responses); + + return responses; + } + + /** + * 북마크 추가 + */ + @PostMapping + public void addBookmark( + AuthUser authUser, + @PathVariable(name = "c_id") Long c_id, + @RequestBody BookmarkRequest request) { + request.setCommId(c_id); // 요청 바디에 커뮤니티 ID 설정 + bookmarkService.addBookmark(authUser.getId(), request); + } + + /** + * 북마크 삭제 + */ + @DeleteMapping + public void removeBookmark( + AuthUser authUser, + @PathVariable(name = "c_id") Long c_id, + @RequestBody BookmarkRequest request) { + request.setCommId(c_id); // 요청 바디에 커뮤니티 ID 설정 + bookmarkService.removeBookmark(authUser.getId(), request); + } + + + /** + * 특정 질문 북마크 상태 조회 + */ + @GetMapping("/questions/{questionId}") + public boolean isQuestionBookmarked( + AuthUser authUser, + @PathVariable(name = "c_id") Long commId, + @PathVariable Long questionId) { + return bookmarkService.isQuestionBookmarked(authUser.getId(), commId, questionId); + } + + /** + * 특정 답변 북마크 상태 조회 + */ + @GetMapping("/answers/{answerId}") + public boolean isAnswerBookmarked( + AuthUser authUser, + @PathVariable(name = "c_id") Long commId, + @PathVariable Long answerId) { + return bookmarkService.isAnswerBookmarked(authUser.getId(), commId, answerId); + } +} diff --git a/src/main/java/com/hanshin/supernova/community/application/CommunityService.java b/src/main/java/com/hanshin/supernova/community/application/CommunityService.java index f98e534..0246ba4 100644 --- a/src/main/java/com/hanshin/supernova/community/application/CommunityService.java +++ b/src/main/java/com/hanshin/supernova/community/application/CommunityService.java @@ -18,13 +18,16 @@ import com.hanshin.supernova.exception.dto.ErrorType; import java.util.ArrayList; import java.util.List; + import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.web.config.PageableHandlerMethodArgumentResolverCustomizer; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +@Slf4j @Service @RequiredArgsConstructor public class CommunityService extends AbstractValidateService { @@ -39,6 +42,8 @@ public class CommunityService extends AbstractValidateService { @Transactional public CommunityResponse createCommunity(AuthUser user, CommunityRequest request) { + log.info("createCommunity 호출됨: AuthUser = {}", user); + // 커뮤니티 이름 중복 체크 isCommunityNameDuplicated(request); diff --git a/src/main/java/com/hanshin/supernova/community/infrastructure/CommunityRepository.java b/src/main/java/com/hanshin/supernova/community/infrastructure/CommunityRepository.java index 4eeeb2c..6a6760f 100644 --- a/src/main/java/com/hanshin/supernova/community/infrastructure/CommunityRepository.java +++ b/src/main/java/com/hanshin/supernova/community/infrastructure/CommunityRepository.java @@ -1,6 +1,7 @@ package com.hanshin.supernova.community.infrastructure; import com.hanshin.supernova.community.domain.Community; +import io.lettuce.core.dynamic.annotation.Param; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; @@ -8,12 +9,18 @@ import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; +import java.util.Optional; + @Repository @Transactional(readOnly = true) public interface CommunityRepository extends JpaRepository { boolean existsByName(String name); + // 커뮤니티 ID로 이미지 URL 조회 + @Query("SELECT c.imgUrl FROM Community c WHERE c.id = :communityId") + Optional findImgUrlById(@Param("communityId") Long communityId); + @Query("SELECT c FROM Community c ORDER BY c.createdAt DESC") Page findAllOrderByCreatedAtDesc(Pageable pageable); diff --git a/src/main/java/com/hanshin/supernova/community/presentation/CommunityController.java b/src/main/java/com/hanshin/supernova/community/presentation/CommunityController.java index 0babada..d5712a7 100644 --- a/src/main/java/com/hanshin/supernova/community/presentation/CommunityController.java +++ b/src/main/java/com/hanshin/supernova/community/presentation/CommunityController.java @@ -8,6 +8,9 @@ import com.hanshin.supernova.community.application.CommunityService; import com.hanshin.supernova.community.dto.request.CommunityRequest; import com.hanshin.supernova.community.dto.response.CommunityInfoResponse; +import com.hanshin.supernova.my.dto.response.MyCommunityResponse; +import com.hanshin.supernova.exception.auth.AuthInvalidException; +import com.hanshin.supernova.exception.dto.ErrorType; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; diff --git a/src/main/java/com/hanshin/supernova/config/resolver/UserArgumentResolver.java b/src/main/java/com/hanshin/supernova/config/resolver/UserArgumentResolver.java index eb1bb17..39ee98e 100644 --- a/src/main/java/com/hanshin/supernova/config/resolver/UserArgumentResolver.java +++ b/src/main/java/com/hanshin/supernova/config/resolver/UserArgumentResolver.java @@ -1,8 +1,8 @@ package com.hanshin.supernova.config.resolver; -import com.hanshin.supernova.auth.application.TokenService; -import com.hanshin.supernova.auth.model.AuthToken; import com.hanshin.supernova.auth.model.AuthUser; +import com.hanshin.supernova.exception.auth.AuthInvalidException; +import com.hanshin.supernova.exception.dto.ErrorType; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -13,45 +13,32 @@ import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.method.support.ModelAndViewContainer; -import static com.hanshin.supernova.auth.AuthCostants.AUTH_TOKEN_HEADER_KEY; - -@Slf4j @Component @RequiredArgsConstructor public class UserArgumentResolver implements HandlerMethodArgumentResolver { - private final TokenService tokenService; - @Override - public boolean supportsParameter( - MethodParameter parameter) { - return parameter.getParameterType() - .equals(AuthUser.class); + public boolean supportsParameter(MethodParameter parameter) { + // AuthUser 타입의 매개변수를 지원하는지 확인 + boolean supports = parameter.getParameterType().equals(AuthUser.class); + return supports; } @Override - public Object resolveArgument( - MethodParameter parameter, ModelAndViewContainer mavContainer, - NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { - var httpServletRequest = (HttpServletRequest) webRequest.getNativeRequest(); - - var accessToken = httpServletRequest.getHeader( - AUTH_TOKEN_HEADER_KEY); + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { + HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest(); - log.info(httpServletRequest.getHeader(AUTH_TOKEN_HEADER_KEY)); + // JwtFilter에서 설정한 authUser 가져오기 + AuthUser authUser = (AuthUser) request.getAttribute("authUser"); - if (accessToken - == null) { + // authUser가 null일 경우 예외 발생 + if (authUser == null) { if (parameter.isOptional()) { return null; } - log.error("토큰 없음 2"); - accessToken = ""; + throw new AuthInvalidException(ErrorType.USER_NOT_FOUND_ERROR); } - - var token = new AuthToken(accessToken); - - return tokenService.getAuthUser( - token); + return authUser; // AuthUser 반환 } -} +} \ No newline at end of file diff --git a/src/main/java/com/hanshin/supernova/exception/auth/UserAuthManagementInvalidException.java b/src/main/java/com/hanshin/supernova/exception/auth/UserAuthManagementInvalidException.java new file mode 100644 index 0000000..651ea5d --- /dev/null +++ b/src/main/java/com/hanshin/supernova/exception/auth/UserAuthManagementInvalidException.java @@ -0,0 +1,11 @@ +package com.hanshin.supernova.exception.auth; + +import com.hanshin.supernova.exception.BusinessException; +import com.hanshin.supernova.exception.dto.ErrorType; + +public class UserAuthManagementInvalidException extends BusinessException { + + public UserAuthManagementInvalidException(ErrorType errorType) { + super(errorType); + } +} diff --git a/src/main/java/com/hanshin/supernova/exception/bookmark/BookmarkNotFoundException.java b/src/main/java/com/hanshin/supernova/exception/bookmark/BookmarkNotFoundException.java new file mode 100644 index 0000000..4092d59 --- /dev/null +++ b/src/main/java/com/hanshin/supernova/exception/bookmark/BookmarkNotFoundException.java @@ -0,0 +1,10 @@ +package com.hanshin.supernova.exception.bookmark; + +import com.hanshin.supernova.exception.BusinessException; +import com.hanshin.supernova.exception.dto.ErrorType; + +public class BookmarkNotFoundException extends BusinessException { + public BookmarkNotFoundException(ErrorType errorType) { + super(errorType); + } +} \ No newline at end of file diff --git a/src/main/java/com/hanshin/supernova/exception/dto/ErrorType.java b/src/main/java/com/hanshin/supernova/exception/dto/ErrorType.java index b9e4204..412717c 100644 --- a/src/main/java/com/hanshin/supernova/exception/dto/ErrorType.java +++ b/src/main/java/com/hanshin/supernova/exception/dto/ErrorType.java @@ -27,16 +27,24 @@ public enum ErrorType { // auth 예외 NON_IDENTICAL_USER_ERROR(HttpStatus.FORBIDDEN, "작성자와 접근자가 일치하지 않습니다."), WRITER_CANNOT_RECOMMEND_ERROR(HttpStatus.FORBIDDEN, "자신의 게시물은 추천할 수 없습니다."), + USED_ACCESS_TOKEN_ERROR(HttpStatus.FORBIDDEN, "이미 사용된 엑세스 토큰입니다"), + USED_REFRESH_TOKEN_ERROR(HttpStatus.FORBIDDEN, "이미 사용된 리프레시 토큰입니다"), + REFRESH_ACCESS_TOKEN_NOT_MATCH_ERROR(HttpStatus.FORBIDDEN, "엑세스 토큰이 일치하지 않습니다"), + INVALID_ACCESS_TOKEN_ERROR(HttpStatus.FORBIDDEN, "잘못된 액세스 토큰입니다"), + WRONG_PASSWORD_ERROR(HttpStatus.UNAUTHORIZED, "잘못된 비밀번호입니다"), + // notice 예외 NOTICE_NOT_FOUND_ERROR(HttpStatus.NOT_FOUND, "공지사항을 찾을 수 없습니다."), // user 예외 SYSTEM_USER_NOT_FOUND_ERROR(HttpStatus.NOT_FOUND, "시스템 유저를 찾을 수 없습니다."), + PASSWORD_NOT(HttpStatus.BAD_REQUEST, "패스워드가 확인 패스워드랑 일치하지 않습니다."), + // 토큰 오류 AUTHORIZATION_ERROR(HttpStatus.UNAUTHORIZED, "인증, 인가 오류"), - EXPIRED_TOKEN(HttpStatus.BAD_REQUEST, "해당 토큰은 만료된 토큰입니다."), + EXPIRED_ACCESS_TOKEN(HttpStatus.BAD_REQUEST, "해당 액세스 토큰은 만료된 토큰입니다."), NULL_TOKEN(HttpStatus.UNAUTHORIZED, "access token 이 존재하지 않습니다."), TOKEN_BLACKLISTED(HttpStatus.BAD_REQUEST, "해당 토큰은 이미 로그아웃 되었습니다."), @@ -75,7 +83,10 @@ public enum ErrorType { JSON_PARSE_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, "파싱 과정에서 문제가 생겼습니다."), GPT_UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "인가되지 않은 요청입니다."), GPT_RATE_LIMIT_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, "요청이 너무 많습니다."), - GPT_REQUEST_FAILED(HttpStatus.BAD_REQUEST, "요청이 실패하였습니다."); + GPT_REQUEST_FAILED(HttpStatus.BAD_REQUEST, "요청이 실패하였습니다."), + + // 북마크 예외 + BOOKMARK_NOT_FOUND(HttpStatus.NOT_FOUND, "북마크를 찾을 수 없습니다."); private final HttpStatus status; private final String message; diff --git a/src/main/java/com/hanshin/supernova/hashtag/application/HashtagService.java b/src/main/java/com/hanshin/supernova/hashtag/application/HashtagService.java index 7e5c9e7..02cd3b1 100644 --- a/src/main/java/com/hanshin/supernova/hashtag/application/HashtagService.java +++ b/src/main/java/com/hanshin/supernova/hashtag/application/HashtagService.java @@ -38,7 +38,7 @@ public class HashtagService { @Transactional public HashtagSaveResponse saveQuestionHashtag(Long qId, HashtagRequest request, - AuthUser authUser) { + AuthUser authUser) { List hashtagNames = request.getHashtagNames(); List savedHashtagNames = new ArrayList<>(); diff --git a/src/main/java/com/hanshin/supernova/hashtag/presentaion/HashtagController.java b/src/main/java/com/hanshin/supernova/hashtag/presentaion/HashtagController.java index e007a83..f4e53a4 100644 --- a/src/main/java/com/hanshin/supernova/hashtag/presentaion/HashtagController.java +++ b/src/main/java/com/hanshin/supernova/hashtag/presentaion/HashtagController.java @@ -6,6 +6,7 @@ import com.hanshin.supernova.common.model.ResponseDto; import com.hanshin.supernova.hashtag.application.HashtagService; import com.hanshin.supernova.hashtag.dto.request.HashtagRequest; +import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; diff --git a/src/main/java/com/hanshin/supernova/my/application/MypageService.java b/src/main/java/com/hanshin/supernova/my/application/MypageService.java new file mode 100644 index 0000000..41110cc --- /dev/null +++ b/src/main/java/com/hanshin/supernova/my/application/MypageService.java @@ -0,0 +1,188 @@ +package com.hanshin.supernova.my.application; + +import com.hanshin.supernova.answer.domain.Answer; +import com.hanshin.supernova.answer.dto.request.AnswerRequest; +import com.hanshin.supernova.answer.dto.response.AnswerResponse; +import com.hanshin.supernova.community.domain.Community; +import com.hanshin.supernova.community.domain.CommunityMember; +import com.hanshin.supernova.community.infrastructure.CommunityMemberRepository; +import com.hanshin.supernova.community.infrastructure.CommunityRepository; +import com.hanshin.supernova.exception.community.CommunityInvalidException; +import com.hanshin.supernova.my.dto.response.AnswerWithQuestionResponse; +import com.hanshin.supernova.answer.infrastructure.AnswerRepository; +import com.hanshin.supernova.auth.model.AuthUser; +import com.hanshin.supernova.community.application.CommunityService; +import com.hanshin.supernova.exception.dto.ErrorType; +import com.hanshin.supernova.exception.question.QuestionInvalidException; +import com.hanshin.supernova.my.dto.response.MyCommunityResponse; +import com.hanshin.supernova.my.dto.response.MyQuestionResponse; +import com.hanshin.supernova.question.domain.Question; +import com.hanshin.supernova.question.infrastructure.QuestionRepository; +import com.hanshin.supernova.user.domain.User; +import com.hanshin.supernova.user.infrastructure.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class MypageService { + + private final AnswerRepository answerRepository; + private final QuestionRepository questionRepository; + private final CommunityRepository communityRepository; + private final CommunityMemberRepository communityMemberRepository; + private final UserRepository userRepository; + + @Transactional + public AnswerWithQuestionResponse createAnswerWithQId(AuthUser user, Long qId, AnswerRequest request) { + // 질문 조회 + Question findQuestion = getQuestionById(qId); + + // 유저 조회 + User findUser = getUserOrThrowIfNotExist(user.getId()); + + // 답변 생성 및 저장 + Answer answer = buildAnswer(qId, request, findUser.getId()); + Answer savedAnswer = answerRepository.save(answer); + + // 질문 답변 카운트 증가 + findQuestion.increaseAnswerCnt(); + + // 커뮤니티 이미지 조회 + String communityImg = getCommunityImageById(findQuestion.getCommId()); + + // 기존 AnswerResponse 생성 + AnswerResponse answerResponse = AnswerResponse.toResponse( + savedAnswer.getId(), + findUser.getNickname(), + findUser.getProfileImageUrl(), + savedAnswer.getAnswer(), + savedAnswer.getCreatedAt(), + savedAnswer.getRecommendationCnt(), + savedAnswer.getTag(), + savedAnswer.getSource(), + savedAnswer.isAi(), + savedAnswer.isAccepted() + ); + + return new AnswerWithQuestionResponse( + answerResponse, + findQuestion.getTitle(), + communityImg, + findQuestion.getId(), + findQuestion.getCommId() + ); + } + + public List getAnswersByUserWithQuestionTitle(AuthUser authUser) { + List answers = answerRepository.findAllByAnswererId(authUser.getId()); + + return answers.stream() + .map(answer -> { + Question question = getQuestionById(answer.getQuestionId()); + String communityImg = getCommunityImageById(question.getCommId()); + AnswerResponse answerResponse = AnswerResponse.toResponse( + answer.getId(), + getUserOrThrowIfNotExist(authUser.getId()).getNickname(), + getUserOrThrowIfNotExist(authUser.getId()).getProfileImageUrl(), + answer.getAnswer(), + answer.getCreatedAt(), + answer.getRecommendationCnt(), + answer.getTag(), + answer.getSource(), + answer.isAi(), + answer.isAccepted() + ); + return new AnswerWithQuestionResponse( + answerResponse, + question.getTitle(), + communityImg, + question.getId(), + question.getCommId() + ); + }) + .collect(Collectors.toList()); + } + + private Question getQuestionById(Long qId) { + return questionRepository.findById(qId).orElseThrow( + () -> new QuestionInvalidException(ErrorType.QUESTION_NOT_FOUND_ERROR) + ); + } + + private User getUserOrThrowIfNotExist(Long userId) { + return userRepository.findById(userId).orElseThrow( + () -> new IllegalStateException("사용자를 찾을 수 없습니다.") + ); + } + + private static Answer buildAnswer(Long qId, AnswerRequest request, Long userId) { + return Answer.builder() + .answer(request.getAnswer()) + .tag(request.getTag()) + .source(request.getSource()) + .isAi(false) + .isAccepted(false) + .recommendationCnt(0) + .answererId(userId) + .questionId(qId) + .build(); + } + + /** + * 현재 로그인한 사용자의 질문 목록을 반환. + */ + @Transactional(readOnly = true) + public List getQuestionsByUser(AuthUser authUser) { + log.info("Received AuthUser: {}", authUser); + + // 사용자가 작성한 모든 질문 조회 + List questions = questionRepository.findAllByQuestionerId(authUser.getId()); + log.info("Found questions: {}", questions.size()); + + // 질문 목록을 QuestionResponse로 변환 + return questions.stream() + .map(question -> { + String communityImg = getCommunityImageById(question.getCommId()); + log.info("community img: {}", communityImg); + + // QuestionResponse 생성 및 반환 + MyQuestionResponse response = new MyQuestionResponse( + question.getId(), + question.getCommId(), + question.getTitle(), + communityImg + ); + + log.info("Response Object: {}", response); + return response; + + }) + .collect(Collectors.toList()); + } + + + public String getCommunityImageById(Long communityId) { + // 커뮤니티 이미지 URL 조회, 존재하지 않으면 예외 발생 + return communityRepository.findImgUrlById(communityId) + .orElseThrow(() -> new CommunityInvalidException(ErrorType.COMMUNITY_NOT_FOUND_ERROR)); + } + + public List getCommunitiesByUser(Long userId) { + List memberships = communityMemberRepository.findAllByUserId(userId); + + return memberships.stream() + .map(member -> { + Community community = communityRepository.findById(member.getCommunityId()) + .orElseThrow(() -> new CommunityInvalidException(ErrorType.COMMUNITY_NOT_FOUND_ERROR)); + return MyCommunityResponse.toResponse(community); + }) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/com/hanshin/supernova/my/dto/response/AnswerWithQuestionResponse.java b/src/main/java/com/hanshin/supernova/my/dto/response/AnswerWithQuestionResponse.java new file mode 100644 index 0000000..335775b --- /dev/null +++ b/src/main/java/com/hanshin/supernova/my/dto/response/AnswerWithQuestionResponse.java @@ -0,0 +1,15 @@ +package com.hanshin.supernova.my.dto.response; + +import com.hanshin.supernova.answer.dto.response.AnswerResponse; +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class AnswerWithQuestionResponse { + private AnswerResponse answerResponse; + private String questionTitle; // 질문 제목 + private String communityImg; // 커뮤니티 이미지 URL + private Long questionId; // 질문 ID + private Long communityId; // 커뮤니티 ID +} diff --git a/src/main/java/com/hanshin/supernova/my/dto/response/MyCommunityResponse.java b/src/main/java/com/hanshin/supernova/my/dto/response/MyCommunityResponse.java new file mode 100644 index 0000000..1ff7171 --- /dev/null +++ b/src/main/java/com/hanshin/supernova/my/dto/response/MyCommunityResponse.java @@ -0,0 +1,23 @@ +package com.hanshin.supernova.my.dto.response; + +import com.hanshin.supernova.community.domain.Community; +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class MyCommunityResponse { + private Long id; + private String name; + private String description; + private String imgUrl; + + public static MyCommunityResponse toResponse(Community community) { + return new MyCommunityResponse( + community.getId(), + community.getName(), + community.getDescription(), + community.getImgUrl() + ); + } +} diff --git a/src/main/java/com/hanshin/supernova/my/dto/response/MyQuestionResponse.java b/src/main/java/com/hanshin/supernova/my/dto/response/MyQuestionResponse.java new file mode 100644 index 0000000..855945b --- /dev/null +++ b/src/main/java/com/hanshin/supernova/my/dto/response/MyQuestionResponse.java @@ -0,0 +1,13 @@ +package com.hanshin.supernova.my.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class MyQuestionResponse { + private Long questionId; // 질문 ID + private Long communityId; // 커뮤니티 ID + private String title; // 질문 제목 + private String imgUrl; // 커뮤니티 이미지 URL +} diff --git a/src/main/java/com/hanshin/supernova/my/presentation/MypageController.java b/src/main/java/com/hanshin/supernova/my/presentation/MypageController.java new file mode 100644 index 0000000..6baeda1 --- /dev/null +++ b/src/main/java/com/hanshin/supernova/my/presentation/MypageController.java @@ -0,0 +1,75 @@ +package com.hanshin.supernova.my.presentation; + +import com.hanshin.supernova.auth.model.AuthUser; +import com.hanshin.supernova.exception.auth.AuthInvalidException; +import com.hanshin.supernova.exception.dto.ErrorType; +import com.hanshin.supernova.my.application.MypageService; +import com.hanshin.supernova.answer.dto.request.AnswerRequest; +import com.hanshin.supernova.my.dto.response.AnswerWithQuestionResponse; +import com.hanshin.supernova.my.dto.response.MyCommunityResponse; +import com.hanshin.supernova.my.dto.response.MyQuestionResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Slf4j +@RestController +@RequestMapping("/api/my") +@RequiredArgsConstructor +public class MypageController { + + private final MypageService mypageService; + + /** + * 질문 제목과 함께 답변 생성 + */ + @PostMapping("/questions/{q_id}/answers/with-question-title") + public AnswerWithQuestionResponse createAnswerWithQuestionTitle( + AuthUser user, + @PathVariable("q_id") Long qId, + @RequestBody @Valid AnswerRequest request + ) { + return mypageService.createAnswerWithQId(user, qId, request); + } + + /** + * 마이페이지 답변 조회 + */ + @GetMapping("/answers") + public List getMyAnswers(AuthUser authUser) { + if (authUser == null) { + throw new AuthInvalidException(ErrorType.USER_NOT_FOUND_ERROR); + } + return mypageService.getAnswersByUserWithQuestionTitle(authUser); + } + + /** + * 마이페이지 질문 조회 + */ + @GetMapping("/questions") + public List getMyQuestions(AuthUser authUser) { + log.info("getMyQuestions AuthUser : {}", authUser); + + if (authUser == null) { + throw new AuthInvalidException(ErrorType.USER_NOT_FOUND_ERROR); + } + List responses = mypageService.getQuestionsByUser(authUser); + log.info("Returning Responses: {}", responses); + + return responses; + } + + @GetMapping("/communities") + public ResponseEntity> getMyCommunities(AuthUser authUser) { + if (authUser == null) { + throw new AuthInvalidException(ErrorType.USER_NOT_FOUND_ERROR); + } + + List communities = mypageService.getCommunitiesByUser(authUser.getId()); + return ResponseEntity.ok(communities); + } +} diff --git a/src/main/java/com/hanshin/supernova/orchestration/application/QuestionOrchestrator.java b/src/main/java/com/hanshin/supernova/orchestration/application/QuestionOrchestrator.java index 6c53c3e..5eee937 100644 --- a/src/main/java/com/hanshin/supernova/orchestration/application/QuestionOrchestrator.java +++ b/src/main/java/com/hanshin/supernova/orchestration/application/QuestionOrchestrator.java @@ -21,6 +21,7 @@ import com.hanshin.supernova.question.dto.request.QuestionRequest; import com.hanshin.supernova.question.dto.response.QuestionSaveResponse; import com.hanshin.supernova.question.infrastructure.QuestionRepository; +import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; diff --git a/src/main/java/com/hanshin/supernova/question/application/QuestionService.java b/src/main/java/com/hanshin/supernova/question/application/QuestionService.java index fc8d6d9..c2cd4b1 100644 --- a/src/main/java/com/hanshin/supernova/question/application/QuestionService.java +++ b/src/main/java/com/hanshin/supernova/question/application/QuestionService.java @@ -215,6 +215,7 @@ private QuestionResponse getQuestionResponse(Question findQuestion) { findQuestion.getTitle(), findQuestion.getContent(), findQuestion.getImgUrl(), + findUser.getProfileImageUrl(), findQuestion.isResolved(), findQuestion.getCreatedAt(), findQuestion.getModifiedAt(), @@ -226,4 +227,8 @@ private QuestionResponse getQuestionResponse(Question findQuestion) { findUser.getNickname()); } + // questionId를 통해 communityId 조회 + public Long findCommunityIdByQuestionId(Long questionId) { + return questionRepository.findCommunityIdByQuestionId(questionId); + } } diff --git a/src/main/java/com/hanshin/supernova/question/dto/request/QuestionRequest.java b/src/main/java/com/hanshin/supernova/question/dto/request/QuestionRequest.java index 44fe4c4..b756c15 100644 --- a/src/main/java/com/hanshin/supernova/question/dto/request/QuestionRequest.java +++ b/src/main/java/com/hanshin/supernova/question/dto/request/QuestionRequest.java @@ -3,9 +3,7 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; -import java.util.LinkedHashSet; import java.util.List; -import java.util.Set; import lombok.Data; @Data diff --git a/src/main/java/com/hanshin/supernova/question/dto/response/QuestionResponse.java b/src/main/java/com/hanshin/supernova/question/dto/response/QuestionResponse.java index 6d8ad3f..02e5f45 100644 --- a/src/main/java/com/hanshin/supernova/question/dto/response/QuestionResponse.java +++ b/src/main/java/com/hanshin/supernova/question/dto/response/QuestionResponse.java @@ -12,6 +12,7 @@ public class QuestionResponse { private String title; private String content; private String imgUrl; + private String profileImageUrl; private boolean isResolved; private LocalDateTime createdAt; private LocalDateTime modifiedAt; @@ -26,6 +27,7 @@ public static QuestionResponse toResponse( String title, String content, String imgUrl, + String profileImageUrl, boolean isResolved, LocalDateTime createdAt, LocalDateTime modifiedAt, @@ -36,7 +38,7 @@ public static QuestionResponse toResponse( String commName, String questionerName ) { - return new QuestionResponse(title, content, imgUrl, isResolved, createdAt, modifiedAt, viewCnt, + return new QuestionResponse(title, content, imgUrl, profileImageUrl, isResolved, createdAt, modifiedAt, viewCnt, recCnt, commId, questionerId, commName, questionerName); } } diff --git a/src/main/java/com/hanshin/supernova/question/infrastructure/QuestionRepository.java b/src/main/java/com/hanshin/supernova/question/infrastructure/QuestionRepository.java index dd2d48b..592cb77 100644 --- a/src/main/java/com/hanshin/supernova/question/infrastructure/QuestionRepository.java +++ b/src/main/java/com/hanshin/supernova/question/infrastructure/QuestionRepository.java @@ -1,11 +1,15 @@ package com.hanshin.supernova.question.infrastructure; import com.hanshin.supernova.question.domain.Question; + import java.time.LocalDate; import java.util.Collection; + import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; + import java.util.List; + import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -70,4 +74,25 @@ List findAllPopularQuestionsByCommunityAndDate( @Param("startDate") LocalDate startDate, @Param("endDate") LocalDate endDate ); + + // 특정 사용자의 질문 중 추천 수가 기준 이상인 질문 목록을 반환 + @Query("SELECT q.id FROM Question q WHERE q.questionerId = :userId AND q.recommendationCnt >= :recommendationThreshold") + List findPopularQuestionsByUserId(@Param("userId") Long userId, @Param("recommendationThreshold") int recommendationThreshold); + + + // 특정 사용자의 질문 중 북마크가 10회 이상 된 질문 목록을 반환 + @Query("SELECT q.id FROM Question q JOIN Bookmark b ON q.id = b.targetId " + + "WHERE q.questionerId = :userId " + + "GROUP BY q.id HAVING COUNT(b.id) >= :bookmarkThreshold") + List findBookmarkedQuestionsByUserId(@Param("userId") Long userId, + @Param("bookmarkThreshold") int bookmarkThreshold); + + // 특정 사용자가 작성한 질문 목록을 최신순으로 조회 + @Query("SELECT q FROM Question q WHERE q.questionerId = :userId ORDER BY q.createdAt DESC") + List findAllByQuestionerId(@Param("userId") Long userId); + + + // questionId로 commId 조회 + @Query("SELECT q.commId FROM Question q WHERE q.id = :questionId") + Long findCommunityIdByQuestionId(Long questionId); } diff --git a/src/main/java/com/hanshin/supernova/question/presentation/QuestionController.java b/src/main/java/com/hanshin/supernova/question/presentation/QuestionController.java index 420aa94..30d0590 100644 --- a/src/main/java/com/hanshin/supernova/question/presentation/QuestionController.java +++ b/src/main/java/com/hanshin/supernova/question/presentation/QuestionController.java @@ -84,4 +84,11 @@ public ResponseEntity getMyCommunities( List responses = questionService.getMyCommunities(user); return ResponseDto.ok(responses); } + + // questionId로 communityId 조회 + @GetMapping("/{q_id}/c_id") + public ResponseEntity getCommunityIdByQuestionId(@PathVariable Long q_id) { + Long communityId = questionService.findCommunityIdByQuestionId(q_id); + return ResponseEntity.ok(communityId); + } } diff --git a/src/main/java/com/hanshin/supernova/redis/community_stat/interceptor/SingleVisitInterceptor.java b/src/main/java/com/hanshin/supernova/redis/community_stat/interceptor/SingleVisitInterceptor.java index c899dc0..a9d8f87 100644 --- a/src/main/java/com/hanshin/supernova/redis/community_stat/interceptor/SingleVisitInterceptor.java +++ b/src/main/java/com/hanshin/supernova/redis/community_stat/interceptor/SingleVisitInterceptor.java @@ -1,13 +1,12 @@ package com.hanshin.supernova.redis.community_stat.interceptor; -import static com.hanshin.supernova.auth.AuthCostants.AUTH_TOKEN_HEADER_KEY; +import static com.hanshin.supernova.auth.AuthConstants.ACCESS_TOKEN_HEADER_KEY; -import com.hanshin.supernova.auth.application.TokenService; -import com.hanshin.supernova.auth.model.AuthToken; import com.hanshin.supernova.auth.model.AuthUser; import com.hanshin.supernova.community.infrastructure.CommunityRepository; import com.hanshin.supernova.exception.community.CommunityInvalidException; import com.hanshin.supernova.exception.dto.ErrorType; +import com.hanshin.supernova.security.service.JwtService; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.time.LocalDate; @@ -34,7 +33,9 @@ public class SingleVisitInterceptor implements HandlerInterceptor { private final CommunityRepository communityRepository; // TODO 만약 예원이가 한 내용 병합될 경우, TokenService -> SecurityTokenService - private final TokenService tokenService; +// private final TokenService tokenService; +// private final AuthUserResolver authUserResolver; + private final JwtService jwtService; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, @@ -48,12 +49,13 @@ public boolean preHandle(HttpServletRequest request, HttpServletResponse respons } // 토큰에서 AuthUser 정보 추출 - String accessToken = request.getHeader(AUTH_TOKEN_HEADER_KEY); + String accessToken = request.getHeader(ACCESS_TOKEN_HEADER_KEY); AuthUser authUser = null; if (accessToken != null && !accessToken.isEmpty()) { - AuthToken token = new AuthToken(accessToken); +// AuthToken token = new AuthToken(accessToken); try { - authUser = tokenService.getAuthUser(token); +// authUser = tokenService.getAuthUser(token); + authUser = jwtService.getAuthUserFromToken(accessToken); } catch (Exception e) { log.warn("Failed to get AuthUser from token", e); } diff --git a/src/main/java/com/hanshin/supernova/redis/config/InMemoryRedisConfig.java b/src/main/java/com/hanshin/supernova/redis/config/InMemoryRedisConfig.java new file mode 100644 index 0000000..c2f6ce0 --- /dev/null +++ b/src/main/java/com/hanshin/supernova/redis/config/InMemoryRedisConfig.java @@ -0,0 +1,32 @@ +package com.hanshin.supernova.redis.config; + +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import redis.embedded.RedisServer; + + +import java.io.IOException; + +@Configuration +public class InMemoryRedisConfig { + + @Value("${spring.cache.port}") + private int port; + + private RedisServer redisServer; + + @PostConstruct + public void startRedis() throws IOException { + redisServer = new RedisServer(port); + redisServer.start(); + } + + @PreDestroy + public void stopRedis() throws IOException { + if (redisServer != null) { + redisServer.stop(); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/hanshin/supernova/redis/config/RedisConfig.java b/src/main/java/com/hanshin/supernova/redis/config/RedisConfig.java index 1147661..52b549a 100644 --- a/src/main/java/com/hanshin/supernova/redis/config/RedisConfig.java +++ b/src/main/java/com/hanshin/supernova/redis/config/RedisConfig.java @@ -8,6 +8,8 @@ import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; @Configuration @EnableRedisRepositories @@ -31,6 +33,15 @@ public RedisConnectionFactory redisConnectionFactory() { return redisTemplate; } + @Bean(name = "jwtRedisTemplate") + public RedisTemplate jwtRedisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); + return template; + } + @Bean public RedisClient redisClient() { return RedisClient.create("redis://" + redisHost + ":" + redisPort); diff --git a/src/main/java/com/hanshin/supernova/redis/service/RedisService.java b/src/main/java/com/hanshin/supernova/redis/service/RedisService.java new file mode 100644 index 0000000..e60f80b --- /dev/null +++ b/src/main/java/com/hanshin/supernova/redis/service/RedisService.java @@ -0,0 +1,50 @@ +package com.hanshin.supernova.redis.service; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.concurrent.TimeUnit; + +@Slf4j +@Service +public class RedisService { + + private final RedisTemplate jwtRedisTemplate; + + @Autowired + public RedisService(@Qualifier("jwtRedisTemplate")RedisTemplate jwtRedisTemplate) { + this.jwtRedisTemplate = jwtRedisTemplate; + } + + public void set(String key, V value, long timeoutSeconds) { + jwtRedisTemplate.opsForValue().set(key, value, timeoutSeconds, TimeUnit.SECONDS); + } + + public V getValue(String key) { + V value = jwtRedisTemplate.opsForValue().get(key); + log.info("Redis에서 데이터 조회. 키: {}, 값: {}", key, value); + return value; + } + + public Boolean contains(String key) { + return jwtRedisTemplate.hasKey(key); + } + + public void delete(String key) { + jwtRedisTemplate.delete(key); + } + + /** + * Redis에서 특정 키가 존재하는지 확인하는 메서드 + * + * @param key Redis 키 + * @return 키가 존재하면 true, 그렇지 않으면 false + */ + public boolean exists(String key) { + Boolean hasKey = jwtRedisTemplate.hasKey(key); + return hasKey != null && hasKey; // null 확인 후 Boolean 처리 + } +} \ No newline at end of file diff --git a/src/main/java/com/hanshin/supernova/security/RandomStringGenerator.java b/src/main/java/com/hanshin/supernova/security/RandomStringGenerator.java new file mode 100644 index 0000000..86dff17 --- /dev/null +++ b/src/main/java/com/hanshin/supernova/security/RandomStringGenerator.java @@ -0,0 +1,30 @@ +package com.hanshin.supernova.security; + +import java.security.SecureRandom; + +public class RandomStringGenerator { + + private static final String CHAR_LOWER = "abcdefghijklmnopqrstuvwxyz"; + private static final String CHAR_UPPER = CHAR_LOWER.toUpperCase(); + private static final String NUMBER = "0123456789"; + private static final String DATA_FOR_RANDOM_STRING = CHAR_LOWER + CHAR_UPPER + NUMBER; + private static final SecureRandom random = new SecureRandom(); + + public static String generateRandomString(int length) { + if (length < 1) throw new IllegalArgumentException(); + + StringBuilder sb = new StringBuilder(length); + for (int i = 0; i < length; i++) { + int rndCharAt = random.nextInt(DATA_FOR_RANDOM_STRING.length()); + char rndChar = DATA_FOR_RANDOM_STRING.charAt(rndCharAt); + + sb.append(rndChar); + } + + return sb.toString(); + } + + private RandomStringGenerator() { + } + +} diff --git a/src/main/java/com/hanshin/supernova/security/TokenConstants.java b/src/main/java/com/hanshin/supernova/security/TokenConstants.java new file mode 100644 index 0000000..a81810c --- /dev/null +++ b/src/main/java/com/hanshin/supernova/security/TokenConstants.java @@ -0,0 +1,12 @@ +//package com.hanshin.supernova.security; +// +//public class TokenConstants { +// +// public static final String USER_ID_CLAIM = "user_id"; +// public static final String USER_EMAIL_CLAIM = "email"; +// public static final String REFRESH_TOKEN_COOKIE_NAME = "refresh_token"; +// public static final String USER_AUTHORITY_CLAIM = "role"; +// +// private TokenConstants() { +// } +//} diff --git a/src/main/java/com/hanshin/supernova/security/argumentResolver/AuthUserResolver.java b/src/main/java/com/hanshin/supernova/security/argumentResolver/AuthUserResolver.java new file mode 100644 index 0000000..76e3c55 --- /dev/null +++ b/src/main/java/com/hanshin/supernova/security/argumentResolver/AuthUserResolver.java @@ -0,0 +1,58 @@ +//package com.hanshin.supernova.security.argumentResolver; +// +//import com.hanshin.supernova.auth.model.AuthUser; +//import com.hanshin.supernova.auth.model.AuthUserImpl; +//import com.hanshin.supernova.exception.auth.AuthorizationException; +//import com.hanshin.supernova.exception.dto.ErrorType; +//import com.hanshin.supernova.security.service.JwtService; +//import jakarta.servlet.http.HttpServletRequest; +//import org.springframework.beans.factory.annotation.Autowired; +//import org.springframework.core.MethodParameter; +//import org.springframework.stereotype.Component; +//import org.springframework.web.bind.support.WebDataBinderFactory; +//import org.springframework.web.context.request.NativeWebRequest; +//import org.springframework.web.method.support.HandlerMethodArgumentResolver; +//import org.springframework.web.method.support.ModelAndViewContainer; +// +//import static com.hanshin.supernova.auth.AuthConstants.ACCESS_TOKEN_HEADER_KEY; +// +//@Component +//public class AuthUserResolver implements HandlerMethodArgumentResolver { +// +// private final JwtService jwtService; +// +// @Autowired +// public AuthUserResolver(JwtService jwtService) { +// this.jwtService = jwtService; +// } +// +// @Override +// public boolean supportsParameter(MethodParameter parameter) { +// return parameter.getParameterType().equals(AuthUserImpl.class); +// } +// +// @Override +// public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { +// final String BEARER = "Bearer "; +// +// HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest(); +// String accessToken = request.getHeader(ACCESS_TOKEN_HEADER_KEY); +// if (accessToken == null || !accessToken.startsWith(BEARER)) { +// return null; +// } +// +// Long userId = jwtService.getUserId(accessToken.substring(BEARER.length())); +// return new AuthUserImpl(userId); +// } +// +// public AuthUser getAuthUser(String token) { +// final String BEARER = "Bearer "; +// +// if (token == null || !token.startsWith(BEARER)) { +// throw new AuthorizationException(ErrorType.AUTHORIZATION_ERROR); +// } +// Long userId = jwtService.getUserId(token.substring(BEARER.length())); +// return new AuthUserImpl(userId); +// } +// +//} \ No newline at end of file diff --git a/src/main/java/com/hanshin/supernova/security/config/SecurityConfig.java b/src/main/java/com/hanshin/supernova/security/config/SecurityConfig.java new file mode 100644 index 0000000..904d7a3 --- /dev/null +++ b/src/main/java/com/hanshin/supernova/security/config/SecurityConfig.java @@ -0,0 +1,114 @@ +package com.hanshin.supernova.security.config; + +import com.hanshin.supernova.auth.service.CustomUserDetailsService; +import com.hanshin.supernova.security.jwt.JwtFilter; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.access.hierarchicalroles.RoleHierarchy; +import org.springframework.security.access.hierarchicalroles.RoleHierarchyImpl; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.crypto.password.Pbkdf2PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.authentication.logout.LogoutHandler; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final CustomUserDetailsService userDetailsService; // CustomUserDetailsService 주입 + private final JwtFilter jwtFilter; // JWT 필터 주입 + private final LogoutHandler logoutHandler; // 로그아웃 핸들러 주입 + + @Value("${spring.security.jwt.secretKey}") + private String secretKey; + + // AuthenticationManager 빈 등록 + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception { + return authenticationConfiguration.getAuthenticationManager(); + } + + // PasswordEncoder 설정 + @Bean + public PasswordEncoder passwordEncoder() { + return new Pbkdf2PasswordEncoder( + secretKey, + 16, + 310000, + Pbkdf2PasswordEncoder.SecretKeyFactoryAlgorithm.PBKDF2WithHmacSHA256); + } + + // DaoAuthenticationProvider 빈 등록 (CustomUserDetailsService와 PasswordEncoder 설정) + @Bean + public DaoAuthenticationProvider authenticationProvider() { + DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(); + authProvider.setUserDetailsService(userDetailsService); // CustomUserDetailsService 사용 + authProvider.setPasswordEncoder(passwordEncoder()); // PasswordEncoder 사용 + return authProvider; + } + + // SecurityFilterChain 설정 + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http.csrf(cnf -> cnf.ignoringRequestMatchers("/api/**")); + + // 접근 허용 관련 설정 + http.authorizeHttpRequests(auth -> { + auth.requestMatchers("/", "/auth/login", "/register", "/users/**").permitAll(); // 홈, 로그인, 회원가입 페이지 접근 허용 + auth.requestMatchers("/api/**").permitAll(); // API 요청 전체 허용 (필요에 따라 수정 가능) + auth.anyRequest().permitAll(); + }); + +// http.authorizeHttpRequests(auth -> { +// auth.requestMatchers("/api/auth/login").permitAll(); +// auth.requestMatchers("/api/users/all").permitAll(); +// auth.requestMatchers("/api/bookmarks/**").permitAll(); +// auth.requestMatchers("/api/main").permitAll(); +// auth.requestMatchers("/api/notices").permitAll(); +// auth.requestMatchers("/api/news").permitAll(); +// auth.requestMatchers("/api/communities/**").permitAll(); +// auth.requestMatchers("/api/questions/**").permitAll(); +// auth.requestMatchers("/api/users/**").permitAll(); +// auth.requestMatchers("/api/auth/refresh").permitAll(); +// auth.requestMatchers("/api/auth/**").anonymous(); +// auth.requestMatchers("/api/**").permitAll(); +// }); + + // 폼 로그인 설정 + http.formLogin(cnf -> { + cnf.loginPage("/auth/login") // 로그인 페이지 경로 설정 + .loginProcessingUrl("/login") // 로그인 폼의 action 경로와 일치 + .defaultSuccessUrl("/") // 로그인 성공 시 리다이렉트할 페이지 + .permitAll(); // 로그인 관련 URL은 누구나 접근 가능 + }); + + http.logout(cnf -> { + cnf.logoutUrl("/api/auth/logout"); + cnf.permitAll(); + cnf.addLogoutHandler(logoutHandler); + cnf.logoutSuccessUrl("/"); + }); + + http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); // JWT 필터 추가 + + return http.build(); + } + + // RoleHierarchy 설정 (ROLE_ADMIN이 ROLE_USER보다 상위) + @Bean + public RoleHierarchy roleHierarchy() { + RoleHierarchyImpl roleHierarchy = new RoleHierarchyImpl(); + roleHierarchy.setHierarchy("ROLE_ADMIN > ROLE_USER"); + return roleHierarchy; + } +} diff --git a/src/main/java/com/hanshin/supernova/security/jwt/JwtFilter.java b/src/main/java/com/hanshin/supernova/security/jwt/JwtFilter.java new file mode 100644 index 0000000..03b047c --- /dev/null +++ b/src/main/java/com/hanshin/supernova/security/jwt/JwtFilter.java @@ -0,0 +1,158 @@ +package com.hanshin.supernova.security.jwt; + +import com.hanshin.supernova.auth.model.AuthUser; +import com.hanshin.supernova.auth.model.AuthUserImpl; +import com.hanshin.supernova.security.service.JwtService; +import com.hanshin.supernova.user.domain.Authority; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +import static com.hanshin.supernova.auth.AuthConstants.ACCESS_TOKEN_HEADER_KEY; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtFilter extends OncePerRequestFilter { + + private final JwtService jwtService; +// +// @Override +// protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException { +// +//// // 로그인 요청의 경우 필터를 거치지 않도록 처리 +//// String requestURI = request.getRequestURI(); +//// if (requestURI.equals("/api/auth/login") || requestURI.equals("/api/users/all")) { +//// filterChain.doFilter(request, response); +//// return; +//// } +// +// // AccessToken 헤더에서 가져오기 +// String accessToken = request.getHeader(ACCESS_TOKEN_HEADER_KEY); +// +// // 토큰이 유효할 경우에만 사용자 정보를 추출 +// Claims claims = (accessToken != null) ? jwtService.getClaimsFromToken(accessToken) : null; +// +// log.info("Claims : {}", claims); +// +// if (claims != null) { +// try{ +// Long userId = claims.get("userId", Long.class); +// String email = claims.getSubject(); +// String role = claims.get("role", String.class); +// +// AuthUser authUser = new AuthUserImpl(userId, email, null, Authority.valueOf(role)); +// request.setAttribute("authUser", authUser); // AuthUser 자동 주입을 위한 코드 +// request.setAttribute("userEmail", email); // 사용자 이메일을 위한 코드 +// request.setAttribute("userRole", role); // 사용자 역할을 위한 코드 +// +// // 로그 추가 +// log.debug("AuthUser set: {}", authUser); +// log.debug("userEmail set: {}", email); +// log.debug("userRole set: {}", role); +// +// } catch (ExpiredJwtException e) { +// // 만료된 토큰 예외 처리 +// log.warn("JwtFilter) AccessToken expired: {}", e.getMessage()); +// response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); +// response.getWriter().write("AccessToken is expired"); +// return; // 요청 필터링 중단 +// } catch (JwtException e) { +// // 유효하지 않은 토큰 예외 처리 +// log.warn("JwtFilter) Invalid AccessToken: {}", e.getMessage()); +// response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); +// response.getWriter().write("Invalid AccessToken"); +// return; // 요청 필터링 중단 +// } catch (Exception e) { +// // 기타 예외 처리 +// response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); +// response.getWriter().write("An unexpected error occurred while validating the token"); +// return; // 요청 필터링 중단 +// } +// } +// +// // 로그 추가: 토큰이 없을 때 +// if (claims == null) { +// log.debug("No AccessToken found in request"); +// } +// +// // 토큰이 없거나 예외가 발생하지 않은 경우 다음 필터로 진행 +// filterChain.doFilter(request, response); +// } +//} + + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException { + + log.info("JwtFilter 실행됨"); + + // 필터를 거치지 않도록 처리 + String requestURI = request.getRequestURI(); + + log.info("requestURI: {}", requestURI); + + if (requestURI.equals("/") || requestURI.equals("/api/users/all") ||requestURI.equals("/api/auth/login") || requestURI.equals("/api/main/**")) { + filterChain.doFilter(request, response); + return; + } + + // AccessToken 헤더에서 가져오기 + String accessToken = request.getHeader(ACCESS_TOKEN_HEADER_KEY); + + if (accessToken != null) { + try{ + // 토큰이 유효할 경우에만 사용자 정보를 추출 + Claims claims = jwtService.getClaimsFromToken(accessToken); + log.info("JWT Claims: {}", claims); + + Long userId = claims.get("userId", Long.class); + String email = claims.getSubject(); + String role = claims.get("role", String.class); + + AuthUser authUser = new AuthUserImpl(userId, email, null, Authority.valueOf(role)); + log.info("AuthUser 생성됨: {}", authUser); + + request.setAttribute("authUser", authUser); // AuthUser 자동 주입을 위한 코드 + request.setAttribute("userEmail", email); // 사용자 이메일을 위한 코드 + request.setAttribute("userRole", role); // 사용자 역할을 위한 코드 + + } catch (ExpiredJwtException e) { + // 만료된 토큰 예외 처리 + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.getWriter().write("AccessToken is expired"); + return; // 요청 필터링 중단 + } catch (JwtException e) { + // 유효하지 않은 토큰 예외 처리 + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.getWriter().write("Invalid AccessToken"); + return; // 요청 필터링 중단 + } catch (Exception e) { + // 기타 예외 처리 + response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + response.getWriter().write("An unexpected error occurred while validating the token"); + return; // 요청 필터링 중단 + } + } + + // 로그 추가: 토큰이 없을 때 +// if (accessToken == null) { +// log.info("AccessToken Request URI: {}", requestURI); +// log.info("No AccessToken found in request"); +//// response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); +// } + + // 토큰이 없거나 예외가 발생하지 않은 경우 다음 필터로 진행 + filterChain.doFilter(request, response); + } +} \ No newline at end of file diff --git a/src/main/java/com/hanshin/supernova/security/jwt/JwtFilterImpl.java b/src/main/java/com/hanshin/supernova/security/jwt/JwtFilterImpl.java new file mode 100644 index 0000000..5811828 --- /dev/null +++ b/src/main/java/com/hanshin/supernova/security/jwt/JwtFilterImpl.java @@ -0,0 +1,75 @@ +//package com.hanshin.supernova.security.jwt; +// +//import com.hanshin.supernova.security.service.JwtService; +//import io.jsonwebtoken.Claims; +//import jakarta.servlet.FilterChain; +//import jakarta.servlet.ServletException; +//import jakarta.servlet.http.HttpServletRequest; +//import jakarta.servlet.http.HttpServletResponse; +//import lombok.RequiredArgsConstructor; +//import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +//import org.springframework.security.core.Authentication; +//import org.springframework.security.core.GrantedAuthority; +//import org.springframework.security.core.authority.SimpleGrantedAuthority; +//import org.springframework.security.core.context.SecurityContextHolder; +//import org.springframework.stereotype.Component; +// +//import java.io.IOException; +//import java.util.ArrayList; +//import java.util.Enumeration; +//import java.util.List; +// +//import static com.hanshin.supernova.auth.AuthConstants.ACCESS_TOKEN_HEADER_KEY; +//import static com.hanshin.supernova.security.TokenConstants.USER_AUTHORITY_CLAIM; +//import static com.hanshin.supernova.security.TokenConstants.USER_EMAIL_CLAIM; +// +//@Component +//@RequiredArgsConstructor +//public class JwtFilterImpl extends JwtFilter { +// +// private final JwtService jwtService; +// +// @Override +// protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException { +// Enumeration authorizationHeaders = request.getHeaders(ACCESS_TOKEN_HEADER_KEY); +// Authentication authentication = null; +// boolean shortCircuit = false; +// +// while (authorizationHeaders.hasMoreElements()) { +// String header = authorizationHeaders.nextElement(); +// +// if (header == null || !header.startsWith("Bearer ")) { +// continue; +// } +// +// String accessToken = header.substring("Bearer ".length()); +// if (accessToken.isBlank()) { +// response.sendError(HttpServletResponse.SC_FORBIDDEN, "Access token required"); +// shortCircuit = true; +// break; +// } +// +// Claims claims = jwtService.validateAccessToken(accessToken); +// if (claims == null) { +// response.sendError(HttpServletResponse.SC_FORBIDDEN, "Invalid access token"); +// shortCircuit = true; +// } else { +// String email = claims.get(USER_EMAIL_CLAIM, String.class); +// String role = claims.get(USER_AUTHORITY_CLAIM, String.class); +// List authorities = new ArrayList<>(); +// authorities.add(new SimpleGrantedAuthority("ROLE_" + role)); +// +// authentication = UsernamePasswordAuthenticationToken.authenticated(email, null, authorities); +// } +// break; +// } +// +// if (authentication != null) { +// SecurityContextHolder.getContext().setAuthentication(authentication); +// } +// +// if (!shortCircuit) { +// filterChain.doFilter(request, response); +// } +// } +//} \ No newline at end of file diff --git a/src/main/java/com/hanshin/supernova/security/jwt/JwtLogoutHandler.java b/src/main/java/com/hanshin/supernova/security/jwt/JwtLogoutHandler.java new file mode 100644 index 0000000..e1f3a2d --- /dev/null +++ b/src/main/java/com/hanshin/supernova/security/jwt/JwtLogoutHandler.java @@ -0,0 +1,21 @@ +package com.hanshin.supernova.security.jwt; + +import com.hanshin.supernova.security.service.JwtService; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.logout.LogoutHandler; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class JwtLogoutHandler implements LogoutHandler { + + private final JwtService jwtService; + + @Override + public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) { + jwtService.remove(request, response); // 쿠키와 Redis에서 RefreshToken 삭제 + } +} \ No newline at end of file diff --git a/src/main/java/com/hanshin/supernova/security/model/AccessTokenWrapper.java b/src/main/java/com/hanshin/supernova/security/model/AccessTokenWrapper.java new file mode 100644 index 0000000..fc4f482 --- /dev/null +++ b/src/main/java/com/hanshin/supernova/security/model/AccessTokenWrapper.java @@ -0,0 +1,17 @@ +package com.hanshin.supernova.security.model; + +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class AccessTokenWrapper { + + @JsonProperty("access_token") + @NotBlank + private String accessToken; +} diff --git a/src/main/java/com/hanshin/supernova/security/model/AuthorizeToken.java b/src/main/java/com/hanshin/supernova/security/model/AuthorizeToken.java new file mode 100644 index 0000000..e71e0ba --- /dev/null +++ b/src/main/java/com/hanshin/supernova/security/model/AuthorizeToken.java @@ -0,0 +1,16 @@ +package com.hanshin.supernova.security.model; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; + +@Getter +public class AuthorizeToken extends AccessTokenWrapper { + + @JsonProperty("refresh_token") + private final String refreshToken; + + public AuthorizeToken(String accessToken, String refreshToken) { + super(accessToken); + this.refreshToken = refreshToken; + } +} \ No newline at end of file diff --git a/src/main/java/com/hanshin/supernova/security/service/JwtService.java b/src/main/java/com/hanshin/supernova/security/service/JwtService.java new file mode 100644 index 0000000..b12df01 --- /dev/null +++ b/src/main/java/com/hanshin/supernova/security/service/JwtService.java @@ -0,0 +1,212 @@ +package com.hanshin.supernova.security.service; + +import com.hanshin.supernova.auth.model.AuthUser; +import com.hanshin.supernova.auth.model.AuthUserImpl; +import com.hanshin.supernova.redis.service.RedisService; +import com.hanshin.supernova.user.domain.Authority; +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import jakarta.annotation.PostConstruct; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import javax.crypto.SecretKey; +import java.util.Date; + +import static com.hanshin.supernova.auth.AuthConstants.ACCESS_TOKEN_HEADER_KEY; +import static com.hanshin.supernova.auth.AuthConstants.REFRESH_TOKEN_HEADER_KEY; + +@Slf4j +@Service +@RequiredArgsConstructor +public class JwtService { + + @Value("${spring.security.jwt.secretKey}") + private String SECRET_KEY; + + @Value("${spring.security.jwt.access.expiration}") + private int accessTokenExpiration; + + @Value("${spring.security.jwt.refresh.expiration}") + private int refreshTokenExpiration; + + @Getter + private int accessTokenExpirationMinutes; + + @Getter + private int refreshTokenExpirationMinutes; + + + @PostConstruct + public void init() { + // 초를 분으로 변환 + this.accessTokenExpirationMinutes = this.accessTokenExpiration / 60; + this.refreshTokenExpirationMinutes = this.refreshTokenExpiration / 60; + } + + private SecretKey key; + private final RedisService redisService; // RedisService 주입 + + @Autowired + public JwtService(@Value("${spring.security.jwt.secretKey}") String secretKey, RedisService redisService) { + this.redisService = redisService; + this.SECRET_KEY = secretKey; + + // key 초기화 및 로그 확인 + if (SECRET_KEY != null) + key = Keys.hmacShaKeyFor(SECRET_KEY.getBytes()); + } + + // JWT 토큰 유효성 검증 + public boolean validateToken(String token) { + try { + Jwts.parser() + .verifyWith(key) + .clockSkewSeconds(360) // 1분의 시간 차이를 허용 + .build() + .parseSignedClaims(token); + return true; + } catch (JwtException e) { + return false; + } + } + + +// // JWT 토큰에서 Claims 추출 +// public Claims getClaimsFromToken(String token) { +// return Jwts.parser() +// .verifyWith(key) +// .clockSkewSeconds(360) // 1분의 시간 차이를 허용 +// .build() +// .parseSignedClaims(token) +// .getPayload(); +// } + + public Claims getClaimsFromToken(String token) { + try { + return Jwts.parser() + .verifyWith(key) + .clockSkewSeconds(60) // 1분의 시간 차이를 허용 + .build() + .parseSignedClaims(token) + .getPayload(); + } catch (ExpiredJwtException e) { + log.warn("Token has expired: {}", e.getMessage()); + throw e; // 예외를 던져 JwtFilter에서 처리하도록 함 + } catch (UnsupportedJwtException e) { + log.warn("Unsupported JWT token: {}", e.getMessage()); + throw e; + } catch (MalformedJwtException e) { + log.warn("Malformed JWT token: {}", e.getMessage()); + throw e; + } catch (SignatureException e) { + log.warn("Invalid signature for JWT token: {}", e.getMessage()); + throw e; + } catch (JwtException e) { + log.warn("General JWT processing error: {}", e.getMessage()); + throw e; + } + } + + // AccessToken 생성 + public String generateAccessToken(Long userId, String email, String role) { + return Jwts.builder() + .subject(email) + .claim("role", role) + .claim("userId", userId) + .issuedAt(new Date()) + .expiration(new Date(System.currentTimeMillis() + accessTokenExpirationMinutes * 1000L)) + .signWith(key) + .compact(); + } + + // RefreshToken 생성 + public String generateRefreshToken(Long userId, String email, String role) { + return Jwts.builder() + .subject(email) + .claim("role", role) + .claim("userId", userId) + .issuedAt(new Date()) + .expiration(new Date(System.currentTimeMillis() + refreshTokenExpirationMinutes * 1000L)) + .signWith(key) + .compact(); + } + + // AccessToken에서 사용자 정보를 추출하는 메서드 + public AuthUser getAuthUserFromToken(String accessToken) { + Claims claims = getClaimsFromToken(accessToken); + + Long userId = claims.get("userId", Long.class); + String email = claims.getSubject(); + String role = claims.get("role", String.class); + + return new AuthUserImpl(userId, email, "username_placeholder", Authority.valueOf(role)); + } + + // 로그아웃 시 AccessToken과 RefreshToken 제거 메서드 + public void remove(HttpServletRequest request, HttpServletResponse response) { + String refreshToken = extractTokenFromRequest(request); + + if (refreshToken != null) { + String email = getEmailFromToken(refreshToken); + + if (email != null && redisService.contains(email)) { + redisService.delete(email); // Redis에서 RefreshToken 삭제 + } + + // RefreshToken 쿠키에서 제거 + Cookie removeCookie = new Cookie(REFRESH_TOKEN_HEADER_KEY, null); + removeCookie.setMaxAge(0); + removeCookie.setHttpOnly(true); + removeCookie.setSecure(true); + response.addCookie(removeCookie); + } + + // AccessToken 헤더에서 제거 + response.setHeader(ACCESS_TOKEN_HEADER_KEY, ""); + } + + // HttpServletRequest에서 RefreshToken을 추출하는 유틸리티 메서드 + private String extractTokenFromRequest(HttpServletRequest request) { + // 헤더에서 RefreshToken 추출 시도 + String refreshToken = request.getHeader(REFRESH_TOKEN_HEADER_KEY); + + // 쿠키에서도 RefreshToken을 확인 + if (refreshToken == null || refreshToken.isBlank()) { + Cookie[] cookies = request.getCookies(); + if (cookies != null) { + for (Cookie cookie : cookies) { + if (REFRESH_TOKEN_HEADER_KEY.equals(cookie.getName())) { + refreshToken = cookie.getValue(); + break; + } + } + } + } + return refreshToken; + } + + // RefreshToken에서 이메일을 추출하는 메서드 + public String getEmailFromToken(String token) { + try { + Claims claims = getClaimsFromToken(token); + return claims.getSubject(); + } catch (JwtException e) { + log.warn("Failed to extract email from token", e); + return null; + } + } + + public boolean isRefreshTokenStored(String token) { + String key = "refresh_token:" + token; + return redisService.exists(key); // Redis에 토큰 키가 존재하는지 확인 + } + +} \ No newline at end of file diff --git a/src/main/java/com/hanshin/supernova/user/application/UserService.java b/src/main/java/com/hanshin/supernova/user/application/UserService.java index e1b96d2..b26ec3e 100644 --- a/src/main/java/com/hanshin/supernova/user/application/UserService.java +++ b/src/main/java/com/hanshin/supernova/user/application/UserService.java @@ -1,50 +1,47 @@ package com.hanshin.supernova.user.application; -import com.hanshin.supernova.exception.dto.ErrorType; -import com.hanshin.supernova.exception.user.UserRegisterInvalidException; -import com.hanshin.supernova.user.domain.User; +import com.hanshin.supernova.auth.model.AuthUser; import com.hanshin.supernova.user.dto.request.UserRegisterRequest; -import com.hanshin.supernova.user.dto.response.UserRegisterResponse; -import com.hanshin.supernova.user.infrastructure.UserRepository; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; +import com.hanshin.supernova.user.dto.response.*; +import com.hanshin.supernova.user.domain.User; +import jakarta.servlet.http.HttpServletRequest; + +import java.util.List; + +public interface UserService { + + UserRegisterResponse registerUser(UserRegisterRequest request); + + User getById(Long id); + + User getByEmail(String email); + + boolean checkNickname(String nickname); + + boolean existsByEmail(String email); + + boolean validatePassword(String password); + + ChangePasswordResponse changePassword(HttpServletRequest request, String currentPassword, String newPassword, String confirmNewPassword); -@Service -@RequiredArgsConstructor -public class UserService { - - private final UserRepository userRepository; + ResetPasswordResponse resetPassword(String email, String username, String newPassword, String confirmNewPassword); +// void deleteUser(Long userId, String password); - public UserRegisterResponse register(@Valid UserRegisterRequest request) { + void deleteUser(Long userId, String password); - // 이메일 중복 검증 - if(userRepository.existsByEmail(request.getEmail())) { - throw new UserRegisterInvalidException(ErrorType.EMAIL_DUPLICATE_ERROR); - } + public List getAllUsers(); - // 닉네임 중복 검증 - if(userRepository.existsByNickname(request.getNickname())) { - throw new UserRegisterInvalidException(ErrorType.NICKNAME_DUPLICATE_ERROR); - } + public User getUserFromClaims(HttpServletRequest request); + public String getNicknameById(Long userId); - // 비밀번호 유효성 검사 / 암호화 +// public boolean updateUserName(Long id, String newName); - // user 를 빌드해서 DB 에 저장 - User user = User.builder() - .email(request.getEmail()) - .password(request.getPassword()) - .username(request.getNickname()) - .nickname(request.getNickname()) - .authority(request.getAuthority()) - .build(); + ChangeNicknameResponse changeNickname(Long userId, String newNickname); - User savedUser = userRepository.save(user); + public UserProfileResponse getUserProfile(AuthUser authUser); + public void updateProfileImage(String imageUrl, AuthUser authuser); - // 컨트롤러에 반환 - return new UserRegisterResponse(savedUser.getId(), savedUser.getNickname()); - } -} +} \ No newline at end of file diff --git a/src/main/java/com/hanshin/supernova/user/application/UserServiceImpl.java b/src/main/java/com/hanshin/supernova/user/application/UserServiceImpl.java new file mode 100644 index 0000000..28c0251 --- /dev/null +++ b/src/main/java/com/hanshin/supernova/user/application/UserServiceImpl.java @@ -0,0 +1,267 @@ +package com.hanshin.supernova.user.application; + +import com.hanshin.supernova.auth.model.AuthUser; +import com.hanshin.supernova.exception.auth.AuthInvalidException; +import com.hanshin.supernova.exception.auth.UserAuthManagementInvalidException; +import com.hanshin.supernova.exception.dto.ErrorType; +import com.hanshin.supernova.exception.user.UserInvalidException; +import com.hanshin.supernova.exception.user.UserRegisterInvalidException; +import com.hanshin.supernova.security.service.JwtService; +import com.hanshin.supernova.user.domain.Activity; +import com.hanshin.supernova.user.domain.Authority; +import com.hanshin.supernova.user.domain.User; +import com.hanshin.supernova.user.dto.request.UserRegisterRequest; +import com.hanshin.supernova.user.dto.response.*; +import com.hanshin.supernova.user.infrastructure.UserRepository; +import io.jsonwebtoken.Claims; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +//import jakarta.persistence.EntityManager; +//import jakarta.persistence.PersistenceContext; + +import java.util.List; +import java.util.Optional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class UserServiceImpl implements UserService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final JwtService jwtService; + + + @Transactional + @Override + public UserRegisterResponse registerUser(UserRegisterRequest request) { + // 이메일 중복 검증 + if (userRepository.existsByEmail(request.getEmail())) { + throw new UserRegisterInvalidException(ErrorType.EMAIL_DUPLICATE_ERROR); + } + + // 닉네임 중복 검증 + if (userRepository.existsByNickname(request.getNickname())) { + throw new UserRegisterInvalidException(ErrorType.NICKNAME_DUPLICATE_ERROR); + } + + validatePassword(request.getPassword()); + + if (!request.getPassword().equals(request.getConfirmPassword())) { + throw new UserRegisterInvalidException(ErrorType.PASSWORD_NOT); + } + + User savedUser = buildAndSaveUser(request); + + return UserRegisterResponse.toResponse( + savedUser.getId(), + savedUser.getUsername(), + savedUser.getNickname(), + savedUser.getEmail(), + savedUser.getPassword(), + savedUser.getAuthority()); + } + + private User buildAndSaveUser(UserRegisterRequest request) { + Activity newActivity = new Activity(); + + // request.getAuthority()가 null인 경우 Authority.USER로 설정 + Authority authority = request.getAuthority() != null ? request.getAuthority() : Authority.USER; + + User user = User.builder() + .email(request.getEmail()) + .password(passwordEncoder.encode(request.getPassword())) + .username(request.getUsername()) + .nickname(request.getNickname()) + .authority(authority) + .activity(newActivity) + .build(); + return userRepository.save(user); + } + + @Transactional(readOnly = true) + @Override + public User getById(Long id) { + return userRepository + .findById(id) + .orElseThrow(() -> new UserInvalidException(ErrorType.USER_NOT_FOUND_ERROR)); + } + + @Transactional(readOnly = true) + @Override + public User getByEmail(String email) { + return userRepository + .findByEmail(email) + .orElseThrow(() -> new UserInvalidException(ErrorType.USER_NOT_FOUND_ERROR)); + } + + @Override + public boolean checkNickname(String nickname) { + return !userRepository.existsByNickname(nickname); + } + + @Transactional(readOnly = true) + @Override + public boolean existsByEmail(String email) { + return userRepository.existsByEmail(email); + } + + @Override + public boolean validatePassword(String password) { + return password.matches("^(?=.*[a-z])(?=.*\\d)[a-z\\d]{8,}$"); + } + + @Transactional + @Override + public ChangePasswordResponse changePassword(HttpServletRequest request, String currentPassword, String newPassword, String confirmNewPassword) { + // 토큰을 통해 이메일 추출 + String email = (String) request.getAttribute("userEmail"); + + if (email == null) { + throw new AuthInvalidException(ErrorType.SYSTEM_USER_NOT_FOUND_ERROR); + } + + User user = getByEmail(email); + if (user == null) { + throw new AuthInvalidException(ErrorType.SYSTEM_USER_NOT_FOUND_ERROR); + } + + if (!passwordEncoder.matches(currentPassword, user.getPassword())) { + throw new AuthInvalidException(ErrorType.WRONG_PASSWORD_ERROR); + } + + if (!newPassword.equals(confirmNewPassword)) { + throw new UserAuthManagementInvalidException(ErrorType.PASSWORD_NOT); + } + + user.updatePassword(passwordEncoder.encode(newPassword)); + userRepository.save(user); + + return new ChangePasswordResponse("Password changed successfully"); + } + + @Transactional + @Override + public ResetPasswordResponse resetPassword(String email, String username, String newPassword, String confirmNewPassword) { + User user = userRepository.findByEmailAndUsername(email, username) + .orElseThrow(() -> new UserInvalidException(ErrorType.USER_NOT_FOUND_ERROR)); + + if (!newPassword.equals(confirmNewPassword)) { + throw new UserAuthManagementInvalidException(ErrorType.PASSWORD_NOT); + } + + user.updatePassword(passwordEncoder.encode(newPassword)); + userRepository.save(user); + + return new ResetPasswordResponse("Password reset successfully"); + } + + @Transactional + @Override + public void deleteUser(Long userId, String password) { + User user = getById(userId); + if (!passwordEncoder.matches(password, user.getPassword())) { + throw new UserAuthManagementInvalidException(ErrorType.PASSWORD_NOT); + } + userRepository.delete(user); + } + +//// userId만으로 회원 삭제(외래키의 영향을 받는다면 검사를 비활성화하여 삭제되도록 함 +// @PersistenceContext +// private EntityManager entityManager; +// +// @Transactional +// @Override +// public void deleteUser(Long userId) { +// User user = getById(userId); +// // 외래 키 검사 비활성화 +// entityManager.createNativeQuery("SET FOREIGN_KEY_CHECKS = 0").executeUpdate(); +// +// // 유저 삭제 +// userRepository.deleteById(userId); +// +// // 외래 키 검사 활성화 +// entityManager.createNativeQuery("SET FOREIGN_KEY_CHECKS = 1").executeUpdate(); +// +// } + + public List getAllUsers() { + return userRepository.findAll(); + } + + public User getUserFromClaims(HttpServletRequest request) { + + // JwtFilter에서 저장한 Claims 객체를 HttpServletRequest에서 가져옴 + Claims claims = (Claims) request.getAttribute("claims"); + + if (claims == null) { + throw new AuthInvalidException(ErrorType.SYSTEM_USER_NOT_FOUND_ERROR); + } + // Claims에서 사용자 이메일 추출 + String email = claims.getSubject(); + if (email == null) { + throw new AuthInvalidException(ErrorType.SYSTEM_USER_NOT_FOUND_ERROR); + } + + // 이메일을 통해 User 엔티티 조회하여 반환 + return userRepository.findByEmail(email) + .orElseThrow(() -> new AuthInvalidException(ErrorType.SYSTEM_USER_NOT_FOUND_ERROR)); + } + + public String getUsernameById(Long userId) { + return userRepository.findById(userId) + .map(User::getUsername) + .orElseThrow(() -> new UserInvalidException(ErrorType.USER_NOT_FOUND_ERROR)); + } + + public String getNicknameById(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new UserInvalidException(ErrorType.USER_NOT_FOUND_ERROR)); + return user.getNickname(); + } + + @Override + public ChangeNicknameResponse changeNickname(Long userId, String newNickname) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new AuthInvalidException(ErrorType.USER_NOT_FOUND_ERROR)); + + log.info("newNickname:{}", newNickname); + // 닉네임 업데이트 + user.setNickname(newNickname); + userRepository.save(user); + + return new ChangeNicknameResponse("닉네임이 성공적으로 변경되었습니다.", newNickname); + + } + + public UserProfileResponse getUserProfile(AuthUser authUser){ + + User user = userRepository.findById(authUser.getId()) + .orElseThrow(() -> new UserInvalidException(ErrorType.USER_NOT_FOUND_ERROR)); + return new UserProfileResponse(user); + } + + public void updateProfileImage(String imageUrl, AuthUser authUser) { + User user = userRepository.findById(authUser.getId()) + .orElseThrow(() -> new UserInvalidException(ErrorType.USER_NOT_FOUND_ERROR)); + user.updateProfileImage(imageUrl); + userRepository.save(user); + } + +// public boolean updateUserName(Long id, String newName) { +// Optional userOptional = userRepository.findById(id); +// log.info("ServiceImpl newName: {}", newName); +// +// if (userOptional.isPresent()) { +// User user = userOptional.get(); +// user.setUsername(newName); // 이름 업데이트 +// userRepository.save(user); // 변경사항 저장 +// return true; +// } +// return false; // 회원을 찾지 못한 경우 +// } +} diff --git a/src/main/java/com/hanshin/supernova/user/domain/Activity.java b/src/main/java/com/hanshin/supernova/user/domain/Activity.java index bd342e2..a69fb2e 100644 --- a/src/main/java/com/hanshin/supernova/user/domain/Activity.java +++ b/src/main/java/com/hanshin/supernova/user/domain/Activity.java @@ -2,17 +2,96 @@ import jakarta.persistence.Column; import jakarta.persistence.Embeddable; +import lombok.Data; +import lombok.RequiredArgsConstructor; +@Embeddable +@Data +@RequiredArgsConstructor public class Activity { - @Column(name = "marked_q_cnt") - private int markedQuestionCnt; // 멋진 질문자 배지 계수 - @Column(name = "popular_q_cnt") - private int popularQuestionCnt; // 인기 질문자 배지 계수 + @Column(name = "marked_q_badge") + private boolean markedQuestionBadge; // 멋진 질문자 배지 상태 - @Column(name = "accepted_a_cnt") - private int acceptedAnswerCnt; // 정확한 답변자 배지 계수 + @Column(name = "popular_q_badge") + private boolean popularQuestionBadge; // 인기 질문자 배지 상태 - @Column(name = "popular_a_cnt") - private int popularAnswerCnt; // 인기 답변자 배지 계수 + @Column(name = "accepted_a_badge") + private boolean acceptedAnswerBadge; // 정확한 답변자 배지 상태 + + @Column(name = "popular_a_badge") + private boolean popularAnswerBadge; // 인기 답변자 배지 상태 + + + // buildAndSaveUser 메서드 실행 시 네 개의 배지 상태가 모두 false로 설정되어 저장됨 + public Activity(boolean markedQuestionBadge, boolean popularQuestionBadge, boolean acceptedAnswerBadge, boolean popularAnswerBadge) { + this.markedQuestionBadge = markedQuestionBadge; + this.popularQuestionBadge = popularQuestionBadge; + this.acceptedAnswerBadge = acceptedAnswerBadge; + this.popularAnswerBadge = popularAnswerBadge; + } + + /** + * 멋진 질문자 배지 여부를 반환 + * @return true if the badge is acquired, false otherwise + */ + public boolean hasMarkedQuestionBadge() { + return markedQuestionBadge; + } + + /** + * 멋진 질문자 배지를 설정 + * @param markedQuestionBadgeStatus true로 설정하면 배지를 획득한 것으로 간주 + */ + public void setMarkedQuestionBadge(boolean markedQuestionBadgeStatus) { + this.markedQuestionBadge = markedQuestionBadgeStatus; + } + + /** + * 인기 질문자 배지 여부를 반환 + * @return true if the badge is acquired, false otherwise + */ + public boolean hasPopularQuestionBadge() { + return popularQuestionBadge; + } + + /** + * 인기 질문자 배지를 설정 + * @param popularQuestionBadgeStatus true로 설정하면 배지를 획득한 것으로 간주 + */ + public void setPopularQuestionBadge(boolean popularQuestionBadgeStatus) { + this.popularQuestionBadge = popularQuestionBadgeStatus; + } + + /** + * 정확한 답변자 배지 여부를 반환 + * @return true if the badge is acquired, false otherwise + */ + public boolean hasAcceptedAnswerBadge() { + return acceptedAnswerBadge; + } + + /** + * 정확한 답변자 배지를 설정 + * @param acceptedAnswerBadgeStatus true로 설정하면 배지를 획득한 것으로 간주 + */ + public void setAcceptedAnswerBadge(boolean acceptedAnswerBadgeStatus) { + this.acceptedAnswerBadge = acceptedAnswerBadgeStatus; + } + + /** + * 인기 답변자 배지 여부를 반환 + * @return true if the badge is acquired, false otherwise + */ + public boolean hasPopularAnswerBadge() { + return popularAnswerBadge; + } + + /** + * 인기 답변자 배지를 설정 + * @param popularAnswerBadgeStatus true로 설정하면 배지를 획득한 것으로 간주 + */ + public void setPopularAnswerBadge(boolean popularAnswerBadgeStatus) { + this.popularAnswerBadge = popularAnswerBadgeStatus; + } } diff --git a/src/main/java/com/hanshin/supernova/user/domain/User.java b/src/main/java/com/hanshin/supernova/user/domain/User.java index b5e0797..fd38dbf 100644 --- a/src/main/java/com/hanshin/supernova/user/domain/User.java +++ b/src/main/java/com/hanshin/supernova/user/domain/User.java @@ -2,14 +2,11 @@ import com.hanshin.supernova.common.entity.BaseEntity; import jakarta.persistence.*; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; +import lombok.*; @Entity @Builder -@Getter +@Data @AllArgsConstructor @NoArgsConstructor public class User extends BaseEntity { @@ -34,4 +31,13 @@ public class User extends BaseEntity { @Embedded private Activity activity; + private String profileImageUrl; // 프로필 이미지 URL + + public void updatePassword(String newPassword) { + this.password = newPassword; + } + + public void updateProfileImage(String newImageUrl) { + this.profileImageUrl = newImageUrl; + } } diff --git a/src/main/java/com/hanshin/supernova/user/dto/request/ChangeNicknameRequest.java b/src/main/java/com/hanshin/supernova/user/dto/request/ChangeNicknameRequest.java new file mode 100644 index 0000000..a339ea9 --- /dev/null +++ b/src/main/java/com/hanshin/supernova/user/dto/request/ChangeNicknameRequest.java @@ -0,0 +1,10 @@ +package com.hanshin.supernova.user.dto.request; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class ChangeNicknameRequest { + @NotBlank(message = "새로운 닉네임을 입력하세요.") + private String newNickname; +} diff --git a/src/main/java/com/hanshin/supernova/user/dto/request/ChangePasswordRequest.java b/src/main/java/com/hanshin/supernova/user/dto/request/ChangePasswordRequest.java new file mode 100644 index 0000000..db593bd --- /dev/null +++ b/src/main/java/com/hanshin/supernova/user/dto/request/ChangePasswordRequest.java @@ -0,0 +1,17 @@ +package com.hanshin.supernova.user.dto.request; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class ChangePasswordRequest { + + @NotBlank(message = "현재 비밀번호는 필수입니다.") + private String currentPassword; + + @NotBlank(message = "새 비밀번호는 필수입니다.") + private String newPassword; + + @NotBlank(message = "비밀번호 확인은 필수입니다.") + private String confirmNewPassword; +} diff --git a/src/main/java/com/hanshin/supernova/user/dto/request/DeleteUserRequest.java b/src/main/java/com/hanshin/supernova/user/dto/request/DeleteUserRequest.java new file mode 100644 index 0000000..2dd73c0 --- /dev/null +++ b/src/main/java/com/hanshin/supernova/user/dto/request/DeleteUserRequest.java @@ -0,0 +1,14 @@ +package com.hanshin.supernova.user.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Data +public class DeleteUserRequest { + @NotNull(message = "User ID는 필수입니다.") + private Long userId; + + @NotBlank(message = "비밀번호는 필수입니다.") + private String password; +} diff --git a/src/main/java/com/hanshin/supernova/user/dto/request/ResetPasswordRequest.java b/src/main/java/com/hanshin/supernova/user/dto/request/ResetPasswordRequest.java new file mode 100644 index 0000000..39d90c3 --- /dev/null +++ b/src/main/java/com/hanshin/supernova/user/dto/request/ResetPasswordRequest.java @@ -0,0 +1,22 @@ +package com.hanshin.supernova.user.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class ResetPasswordRequest { + + @NotBlank(message = "이메일은 필수입니다.") + @Email(message = "유효한 이메일을 입력해주세요.") + private String email; + + @NotBlank(message = "사용자 이름은 필수입니다.") + private String username; + + @NotBlank(message = "새 비밀번호는 필수입니다.") + private String newPassword; + + @NotBlank(message = "비밀번호 확인은 필수입니다.") + private String confirmNewPassword; +} diff --git a/src/main/java/com/hanshin/supernova/user/dto/request/UpdateNameRequest.java b/src/main/java/com/hanshin/supernova/user/dto/request/UpdateNameRequest.java new file mode 100644 index 0000000..f2a0624 --- /dev/null +++ b/src/main/java/com/hanshin/supernova/user/dto/request/UpdateNameRequest.java @@ -0,0 +1,16 @@ +//package com.hanshin.supernova.user.dto.request; +// +//import lombok.Data; +// +//@Data +//public class UpdateNameRequest { +// private String newName; +// +// public String getNewName() { +// return newName; +// } +// +// public void setNewName(String newName) { +// this.newName = newName; +// } +//} diff --git a/src/main/java/com/hanshin/supernova/user/dto/request/UserRegisterRequest.java b/src/main/java/com/hanshin/supernova/user/dto/request/UserRegisterRequest.java index 7b6eedb..b8941cc 100644 --- a/src/main/java/com/hanshin/supernova/user/dto/request/UserRegisterRequest.java +++ b/src/main/java/com/hanshin/supernova/user/dto/request/UserRegisterRequest.java @@ -1,5 +1,6 @@ package com.hanshin.supernova.user.dto.request; +import com.fasterxml.jackson.annotation.JsonProperty; import com.hanshin.supernova.user.domain.Authority; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; @@ -14,6 +15,9 @@ public class UserRegisterRequest { @NotNull(message = "비밀번호는 필수입니다.") private String password; + @JsonProperty("confirmPassword") + private String confirmPassword; + @NotNull(message = "사용자 이름은 필수입니다.") @Size(max = 5, message = "사용자의 이름은 최대 5글자 입니다.") private String username; diff --git a/src/main/java/com/hanshin/supernova/user/dto/response/ChangeNicknameResponse.java b/src/main/java/com/hanshin/supernova/user/dto/response/ChangeNicknameResponse.java new file mode 100644 index 0000000..d37b3b2 --- /dev/null +++ b/src/main/java/com/hanshin/supernova/user/dto/response/ChangeNicknameResponse.java @@ -0,0 +1,11 @@ +package com.hanshin.supernova.user.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class ChangeNicknameResponse { + private String message; + private String newNickname; +} \ No newline at end of file diff --git a/src/main/java/com/hanshin/supernova/user/dto/response/ChangePasswordResponse.java b/src/main/java/com/hanshin/supernova/user/dto/response/ChangePasswordResponse.java new file mode 100644 index 0000000..b1e0960 --- /dev/null +++ b/src/main/java/com/hanshin/supernova/user/dto/response/ChangePasswordResponse.java @@ -0,0 +1,10 @@ +package com.hanshin.supernova.user.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class ChangePasswordResponse { + private String message; +} \ No newline at end of file diff --git a/src/main/java/com/hanshin/supernova/user/dto/response/ResetPasswordResponse.java b/src/main/java/com/hanshin/supernova/user/dto/response/ResetPasswordResponse.java new file mode 100644 index 0000000..21e722d --- /dev/null +++ b/src/main/java/com/hanshin/supernova/user/dto/response/ResetPasswordResponse.java @@ -0,0 +1,10 @@ +package com.hanshin.supernova.user.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class ResetPasswordResponse { + private String message; +} \ No newline at end of file diff --git a/src/main/java/com/hanshin/supernova/user/dto/response/UserProfileResponse.java b/src/main/java/com/hanshin/supernova/user/dto/response/UserProfileResponse.java new file mode 100644 index 0000000..5c90129 --- /dev/null +++ b/src/main/java/com/hanshin/supernova/user/dto/response/UserProfileResponse.java @@ -0,0 +1,13 @@ +package com.hanshin.supernova.user.dto.response; + +import com.hanshin.supernova.user.domain.User; +import lombok.Data; + +@Data +public class UserProfileResponse { + private final String profileImageUrl; + + public UserProfileResponse(User user) { + this.profileImageUrl = user.getProfileImageUrl(); + } +} diff --git a/src/main/java/com/hanshin/supernova/user/dto/response/UserRegisterResponse.java b/src/main/java/com/hanshin/supernova/user/dto/response/UserRegisterResponse.java index 96474ef..ad4845f 100644 --- a/src/main/java/com/hanshin/supernova/user/dto/response/UserRegisterResponse.java +++ b/src/main/java/com/hanshin/supernova/user/dto/response/UserRegisterResponse.java @@ -1,5 +1,7 @@ package com.hanshin.supernova.user.dto.response; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.hanshin.supernova.user.domain.Authority; import lombok.AllArgsConstructor; import lombok.Data; @@ -7,6 +9,16 @@ @AllArgsConstructor public class UserRegisterResponse { + @JsonProperty("userId") private Long id; + private String username; private String nickname; + private String email; + private String password; + private Authority authority; + + public static UserRegisterResponse toResponse(Long userId, String username, String nickname, String email, String password, + Authority authority) { + return new UserRegisterResponse(userId, username, nickname, email, password, authority); + } } diff --git a/src/main/java/com/hanshin/supernova/user/infrastructure/UserRepository.java b/src/main/java/com/hanshin/supernova/user/infrastructure/UserRepository.java index 2bde323..4bf8800 100644 --- a/src/main/java/com/hanshin/supernova/user/infrastructure/UserRepository.java +++ b/src/main/java/com/hanshin/supernova/user/infrastructure/UserRepository.java @@ -2,9 +2,10 @@ import com.hanshin.supernova.user.domain.Authority; import com.hanshin.supernova.user.domain.User; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.lang.NonNull; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; @@ -20,5 +21,14 @@ public interface UserRepository extends JpaRepository { Optional findByEmail(String email); + // 이메일과 username으로 사용자 찾기 (메서드 수정) + @Query("SELECT u FROM User u WHERE u.email = :email AND u.username = :username") + Optional findByEmailAndUsername(String email, String username); + + @Transactional + @Modifying + @Query("update User u set u.password = ?2 where u.email = ?1") + int updatePasswordByEmail(@NonNull String email, @NonNull String password); + Optional findByAuthority(Authority authority); } diff --git a/src/main/java/com/hanshin/supernova/user/presentation/HomeController.java b/src/main/java/com/hanshin/supernova/user/presentation/HomeController.java new file mode 100644 index 0000000..823cda1 --- /dev/null +++ b/src/main/java/com/hanshin/supernova/user/presentation/HomeController.java @@ -0,0 +1,14 @@ +//package com.hanshin.supernova.user.presentation; +// +//import org.springframework.http.ResponseEntity; +//import org.springframework.web.bind.annotation.GetMapping; +//import org.springframework.web.bind.annotation.RestController; +// +//@RestController +//public class HomeController { +// +// @GetMapping("/") +// public ResponseEntity home() { +// return ResponseEntity.ok("Welcome to the API!"); +// } +//} \ No newline at end of file diff --git a/src/main/java/com/hanshin/supernova/user/presentation/UserController.java b/src/main/java/com/hanshin/supernova/user/presentation/UserController.java index 31c1b4c..3051ab4 100644 --- a/src/main/java/com/hanshin/supernova/user/presentation/UserController.java +++ b/src/main/java/com/hanshin/supernova/user/presentation/UserController.java @@ -1,25 +1,132 @@ package com.hanshin.supernova.user.presentation; +import com.hanshin.supernova.auth.model.AuthUser; import com.hanshin.supernova.common.model.ResponseDto; +import com.hanshin.supernova.exception.auth.AuthInvalidException; +import com.hanshin.supernova.exception.dto.ErrorType; import com.hanshin.supernova.user.application.UserService; -import com.hanshin.supernova.user.dto.request.UserRegisterRequest; -import com.hanshin.supernova.user.dto.response.UserRegisterResponse; +import com.hanshin.supernova.user.domain.User; +import com.hanshin.supernova.user.dto.request.*; +import com.hanshin.supernova.user.dto.response.ChangeNicknameResponse; +import com.hanshin.supernova.user.dto.response.ChangePasswordResponse; +import com.hanshin.supernova.user.dto.response.ResetPasswordResponse; +import com.hanshin.supernova.user.dto.response.UserProfileResponse; +import com.hanshin.supernova.user.infrastructure.UserRepository; +import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Slf4j @RestController @RequestMapping(path = "/api/users", produces = MediaType.APPLICATION_JSON_VALUE) @RequiredArgsConstructor public class UserController { private final UserService userService; + private final UserRepository userRepository; - @PostMapping - public ResponseEntity createUser(@RequestBody @Valid UserRegisterRequest request){ - UserRegisterResponse response = userService.register(request); + @PostMapping("/register") + public ResponseEntity registerUser(@RequestBody UserRegisterRequest request) { + var response = userService.registerUser(request); return ResponseDto.created(response); } + + @PostMapping("/change-password") + public ResponseEntity changePassword(HttpServletRequest request, @RequestBody @Valid ChangePasswordRequest changePasswordRequest) { + ChangePasswordResponse response = userService.changePassword( + request, + changePasswordRequest.getCurrentPassword(), + changePasswordRequest.getNewPassword(), + changePasswordRequest.getConfirmNewPassword()); + return ResponseEntity.ok(response); + } + + @PostMapping("/reset-password") + public ResponseEntity resetPassword(@RequestBody @Valid ResetPasswordRequest request) { + ResetPasswordResponse response = userService.resetPassword( + request.getEmail(), + request.getUsername(), + request.getNewPassword(), + request.getConfirmNewPassword()); + return ResponseEntity.ok(response); + } + + @GetMapping("/all") + public ResponseEntity> getAllUsers() { + List users = userService.getAllUsers(); + return ResponseEntity.ok(users); + } + + // @DeleteMapping("/delete") +// public ResponseEntity deleteUser(@Validated @RequestBody DeleteUserRequest deleteUserRequest) { +// userService.deleteUser(deleteUserRequest.getUserId(), deleteUserRequest.getPassword()); +// return ResponseEntity.ok("유저가 성공적으로 삭제되었습니다."); +// } + @DeleteMapping("/delete") + public ResponseEntity deleteUser(@Validated @RequestBody DeleteUserRequest deleteUserRequest) { + userService.deleteUser(deleteUserRequest.getUserId(), deleteUserRequest.getPassword()); + return ResponseEntity.ok("유저가 성공적으로 삭제되었습니다."); + } + + @GetMapping("/nickname") + public ResponseEntity> getNickname(@RequestBody(required = false) Map request, AuthUser authUser) { + if (authUser == null) { + throw new AuthInvalidException(ErrorType.USER_NOT_FOUND_ERROR); + } + + // 닉네임 조회 + String nickname = userService.getNicknameById(authUser.getId()); + Map response = new HashMap<>(); + response.put("nickname", nickname); + + return ResponseEntity.ok(response); + } + + @PostMapping("/change-nickname") + public ResponseEntity getNickname(@RequestBody @Valid ChangeNicknameRequest request, AuthUser authUser) { + if (authUser == null) { + throw new AuthInvalidException(ErrorType.USER_NOT_FOUND_ERROR); + } + + log.info("controller new Nickname:{}",request.getNewNickname()); + ChangeNicknameResponse response = userService.changeNickname( + authUser.getId(), + request.getNewNickname()); + + return ResponseEntity.ok(response); + } + + @GetMapping("/profile") + public ResponseEntity getUserProfile(AuthUser authUser) { + UserProfileResponse profile = userService.getUserProfile(authUser); + return ResponseDto.ok(profile); + } + + @PutMapping("/profile-image") + public ResponseEntity updateProfileImage(@RequestBody Map payload, AuthUser authUser) { + String imageUrl = payload.get("imageUrl"); + userService.updateProfileImage(imageUrl, authUser); + return ResponseDto.ok("프로필 이미지가 성공적으로 업데이트되었습니다."); + } + +// // 특정 회원의 이름 업데이트 +// @PutMapping("/{id}/update-name") +// public String updateUserName(@PathVariable Long id, @RequestBody UpdateNameRequest request) { +// String newName = request.getNewName(); // JSON에서 파싱된 값 +// boolean isUpdated = userService.updateUserName(id, newName); +// if (isUpdated) { +// return "회원 이름이 성공적으로 업데이트되었습니다."; +// } else { +// return "업데이트 실패: 해당 회원을 찾을 수 없습니다."; +// } +// } } diff --git a/src/main/java/com/hanshin/supernova/util/TimeUtil.java b/src/main/java/com/hanshin/supernova/util/TimeUtil.java new file mode 100644 index 0000000..c44f55f --- /dev/null +++ b/src/main/java/com/hanshin/supernova/util/TimeUtil.java @@ -0,0 +1,27 @@ +package com.hanshin.supernova.util; + +import com.zaxxer.hikari.HikariDataSource; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +public class TimeUtil { + + private final HikariDataSource hikariDataSource; + + public TimeUtil(HikariDataSource hikariDataSource) { + this.hikariDataSource = hikariDataSource; + } + + public void printTime() { + // HikariCP 현재 시간 + LocalDateTime hikariTime = LocalDateTime.now(); // Hikari Time을 현재 시간으로 설정 + + // 포맷팅하여 출력 + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS"); + System.out.println("Hikari Time: " + hikariTime.format(formatter)); // 밀리초 포함 + + // 서버 시간 출력 + System.out.println("Server Time: " + LocalDateTime.now().format(formatter)); // 서버 시간도 밀리초 포함 + } +} diff --git a/src/main/java/com/hanshin/supernova/view_controller/BookmarkViewController.java b/src/main/java/com/hanshin/supernova/view_controller/BookmarkViewController.java new file mode 100644 index 0000000..c460821 --- /dev/null +++ b/src/main/java/com/hanshin/supernova/view_controller/BookmarkViewController.java @@ -0,0 +1,30 @@ +//package com.hanshin.supernova.bookmark.presentation; +// +//import com.hanshin.supernova.bookmark.application.BookmarkService; +//import com.hanshin.supernova.bookmark.domain.Bookmark; +//import io.jsonwebtoken.Claims; +//import jakarta.servlet.http.HttpServletRequest; +//import lombok.RequiredArgsConstructor; +//import org.springframework.http.ResponseEntity; +//import org.springframework.web.bind.annotation.*; +// +//import java.util.List; +// +//@RestController +//@RequiredArgsConstructor +//@RequestMapping("/communities/{communityId}/my-note/bookmarks") +//public class BookmarkViewController { +// +// private final BookmarkService bookmarkService; +// +// /** +// * 특정 커뮤니티에서 북마크된 질문과 답변 조회 +// */ +// @GetMapping +// public ResponseEntity> getCommunityBookmarks( +// @PathVariable Long communityId, +// @RequestAttribute("httpRequest") HttpServletRequest httpRequest) { +// List bookmarks = bookmarkService.getCommunityBookmarks(communityId, httpRequest); +// return ResponseEntity.ok(bookmarks); +// } +//} diff --git a/src/main/java/com/hanshin/supernova/view_controller/CommunityViewController.java b/src/main/java/com/hanshin/supernova/view_controller/CommunityViewController.java index 4155feb..1b7c076 100644 --- a/src/main/java/com/hanshin/supernova/view_controller/CommunityViewController.java +++ b/src/main/java/com/hanshin/supernova/view_controller/CommunityViewController.java @@ -66,4 +66,11 @@ public String allQuestions(@PathVariable(name = "communityId") Long communityId, model.addAttribute("communityId", communityId); return "community/all_question_list"; } + + @GetMapping("/my-note/{id}") + public String communityMyNote(@PathVariable("id") Long id, Model model) { + model.addAttribute("communityId", id); + return "my/community_my_note"; + } + } diff --git a/src/main/java/com/hanshin/supernova/view_controller/HomeController.java b/src/main/java/com/hanshin/supernova/view_controller/HomeController.java index a072da2..5339f9a 100644 --- a/src/main/java/com/hanshin/supernova/view_controller/HomeController.java +++ b/src/main/java/com/hanshin/supernova/view_controller/HomeController.java @@ -1,13 +1,39 @@ package com.hanshin.supernova.view_controller; +import jakarta.servlet.http.HttpServletRequest; import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +//@Controller +//public class HomeController { +// +// @GetMapping("/") +// public String home() { +// return "home"; +// } +//} @Controller public class HomeController { @GetMapping("/") - public String home() { - return "home"; + public String home(Model model, HttpServletRequest request) { + // 사용자가 로그인했는지 확인 + boolean isLoggedIn = request.getAttribute("userEmail") != null; // 예를 들어, userEmail 속성이 있다면 로그인 상태 + model.addAttribute("isLoggedIn", isLoggedIn); // 모델에 isLoggedIn 추가 + + return "home"; // home.html 템플릿으로 이동 + } + + @GetMapping("/auth/login") + public String showLoginPage() { + return "auth/signIn"; // signIn.html 템플릿을 반환 } + + @GetMapping("/register") + public String showRegisterPage() { + return "user/signUp"; // signIn.html 템플릿을 반환 + } + } diff --git a/src/main/java/com/hanshin/supernova/view_controller/MyPageViewController.java b/src/main/java/com/hanshin/supernova/view_controller/MyPageViewController.java new file mode 100644 index 0000000..36aec7d --- /dev/null +++ b/src/main/java/com/hanshin/supernova/view_controller/MyPageViewController.java @@ -0,0 +1,41 @@ +package com.hanshin.supernova.view_controller; + +import com.hanshin.supernova.answer.application.AnswerService; +import com.hanshin.supernova.answer.domain.Answer; +import com.hanshin.supernova.auth.model.AuthUser; +import com.hanshin.supernova.question.application.QuestionService; +import com.hanshin.supernova.question.domain.Question; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +import java.util.List; + +@Slf4j +@Controller +@RequestMapping("/my") +@RequiredArgsConstructor +public class MyPageViewController { + + @GetMapping + public String getMyPage() { + return "my/my"; // my.html을 렌더링 + } + + @GetMapping("/profile") + public String getMyProfile() {return "my/profile";} // profile.html을 렌더링 + + @GetMapping("/community") + public String manageMyCommunity() {return "my/community_management";} // community_management.html을 렌더링 + + @GetMapping("/change-password") + public String changePassword() {return "my/change_password";} // change_password.html을 렌더링 + + + @GetMapping("/change-nickname") + public String changeNickname() {return "my/change_nickname";} // change_password.html을 렌더링 +} diff --git a/src/main/java/com/hanshin/supernova/view_controller/UserInfoViewController.java b/src/main/java/com/hanshin/supernova/view_controller/UserInfoViewController.java new file mode 100644 index 0000000..54ac242 --- /dev/null +++ b/src/main/java/com/hanshin/supernova/view_controller/UserInfoViewController.java @@ -0,0 +1,16 @@ +package com.hanshin.supernova.view_controller; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +@Controller +@RequestMapping("/users") +public class UserInfoViewController { + + // 비밀번호 찾기 페이지로 이동 + @GetMapping("/reset-password") + public String resetPassword() { + return "auth/reset_password"; // reset_password.html로 매핑 + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..e5400d7 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,51 @@ +#spring: +# profiles: +# active: dev +# application: +# name: supernova +# servlet: +# multipart: +# enabled: true +# datasource: +# url: jdbc:mysql://localhost:3306/supernova +# username: root +# password: 1234 +# driver-class-name: com.mysql.cj.jdbc.Driver +# mvc: +# pathmatch: +# matching-strategy: ant_path_matcher +# +# +# jpa: +# open-in-view: true +# hibernate: +# ddl-auto: update +# format_sql: true +# show-sql: true +# properties: +# dialect: org.hibernate.dialect.MySQL8InnoDBDialect +# # database-platform: org.hibernate.dialect.MySQL8InnoDBDialect +# +# data: +# redis: +# repositories: +# enabled: false +# +# thymeleaf: +# prefix: classpath:/templates/ +# suffix: .html +# +#server: +# port: 8080 +# tomcat: +# connection-timeout: 30000 +# # max-threads: 100 +# # min-spare-threads: 50 +# # accept-count: 50 +# uri-encoding: UTF-8 +# forward-headers-strategy: framework +# +#logging: +# level: +# org.hibernate.SQL: debug +# org.hibernate.type: trace diff --git a/src/main/resources/config/application-dev.yml b/src/main/resources/config/application-dev.yml index 41fdeec..cf8ee4f 100644 --- a/src/main/resources/config/application-dev.yml +++ b/src/main/resources/config/application-dev.yml @@ -6,6 +6,19 @@ spring: host: localhost port: 6379 + cache: + type: redis + port: 6379 + + + security: + jwt: + secretKey: daccaf8d974dc147519ea15f6c3ae221b57a6ef8fa7508e7eca7e4acc863a6a2972ffd261e05dbe48479dd8e91186e5aaf720ffc223d29558848ada33ca48e07 + access: + expiration: 3600 # 액세스 토큰 만료 시간 (초 단위) + refresh: + expiration: 604800 # 리프레시 토큰 만료 시간 (초 단위, 7일) + # chat gpt key openai: api: diff --git a/src/main/resources/config/application.yml b/src/main/resources/config/application.yml index 3c90b8f..f11659d 100644 --- a/src/main/resources/config/application.yml +++ b/src/main/resources/config/application.yml @@ -1,6 +1,8 @@ spring: application: name: supernova + profiles: + active: dev servlet: multipart: enabled: true @@ -44,14 +46,14 @@ server: # accept-count: 50 uri-encoding: UTF-8 forward-headers-strategy: framework - -jwt: - secret: - key: daccaf8d974dc147519ea15f6c3ae221b57a6ef8fa7508e7eca7e4acc863a6a2972ffd261e05dbe48479dd8e91186e5aaf720ffc223d29558848ada33ca48e07 - access: - expiration: 3600 # 액세스 토큰 만료 시간 (초 단위) - refresh: - expiration: 604800 # 리프레시 토큰 만료 시간 (초 단위, 7일) +# +#jwt: +# secret: +# key: daccaf8d974dc147519ea15f6c3ae221b57a6ef8fa7508e7eca7e4acc863a6a2972ffd261e05dbe48479dd8e91186e5aaf720ffc223d29558848ada33ca48e07 +# access: +# expiration: 3600 # 액세스 토큰 만료 시간 (초 단위) +# refresh: +# expiration: 604800 # 리프레시 토큰 만료 시간 (초 단위, 7일) logging: level: diff --git a/src/main/resources/static/images/acceptedAnswerBadge.png b/src/main/resources/static/images/acceptedAnswerBadge.png new file mode 100644 index 0000000..2525bb3 Binary files /dev/null and b/src/main/resources/static/images/acceptedAnswerBadge.png differ diff --git a/src/main/resources/static/images/basic-profile.png b/src/main/resources/static/images/basic-profile.png new file mode 100644 index 0000000..140afcc Binary files /dev/null and b/src/main/resources/static/images/basic-profile.png differ diff --git a/src/main/resources/static/images/bookmark.png b/src/main/resources/static/images/bookmark.png new file mode 100644 index 0000000..562fdf6 Binary files /dev/null and b/src/main/resources/static/images/bookmark.png differ diff --git a/src/main/resources/static/images/hong.png b/src/main/resources/static/images/hong.png new file mode 100644 index 0000000..7ee2181 Binary files /dev/null and b/src/main/resources/static/images/hong.png differ diff --git a/src/main/resources/static/images/lock.png b/src/main/resources/static/images/lock.png new file mode 100644 index 0000000..d718ff9 Binary files /dev/null and b/src/main/resources/static/images/lock.png differ diff --git a/src/main/resources/static/images/markedQuestionBadge.png b/src/main/resources/static/images/markedQuestionBadge.png new file mode 100644 index 0000000..b3d08c8 Binary files /dev/null and b/src/main/resources/static/images/markedQuestionBadge.png differ diff --git a/src/main/resources/static/images/nickname.png b/src/main/resources/static/images/nickname.png new file mode 100644 index 0000000..59d4abd Binary files /dev/null and b/src/main/resources/static/images/nickname.png differ diff --git a/src/main/resources/static/images/no-bookmark.png b/src/main/resources/static/images/no-bookmark.png new file mode 100644 index 0000000..dafbf9c Binary files /dev/null and b/src/main/resources/static/images/no-bookmark.png differ diff --git a/src/main/resources/static/images/oh.png b/src/main/resources/static/images/oh.png new file mode 100644 index 0000000..454112d Binary files /dev/null and b/src/main/resources/static/images/oh.png differ diff --git a/src/main/resources/static/images/park.png b/src/main/resources/static/images/park.png new file mode 100644 index 0000000..d04ec2d Binary files /dev/null and b/src/main/resources/static/images/park.png differ diff --git a/src/main/resources/static/images/popularAnswerBadge.png b/src/main/resources/static/images/popularAnswerBadge.png new file mode 100644 index 0000000..7d586c2 Binary files /dev/null and b/src/main/resources/static/images/popularAnswerBadge.png differ diff --git a/src/main/resources/static/images/popularQuestionBadge.png b/src/main/resources/static/images/popularQuestionBadge.png new file mode 100644 index 0000000..a912e5c Binary files /dev/null and b/src/main/resources/static/images/popularQuestionBadge.png differ diff --git a/src/main/resources/static/images/profile.png b/src/main/resources/static/images/profile.png new file mode 100644 index 0000000..6fa7bef Binary files /dev/null and b/src/main/resources/static/images/profile.png differ diff --git a/src/main/resources/static/images/star.png b/src/main/resources/static/images/star.png new file mode 100644 index 0000000..bc1018a Binary files /dev/null and b/src/main/resources/static/images/star.png differ diff --git a/src/main/resources/static/js/community-navigation.js b/src/main/resources/static/js/community-navigation.js new file mode 100644 index 0000000..998a166 --- /dev/null +++ b/src/main/resources/static/js/community-navigation.js @@ -0,0 +1,23 @@ +console.log("community script loaded"); + +document.addEventListener('DOMContentLoaded', function () { + // URL에서 communityId 추출 + const pathSegments = window.location.pathname.split('/'); + const communityId = pathSegments[pathSegments.length - 1]; // URL의 마지막 부분을 ID로 간주 + + // "내 노트" 버튼 클릭 시 처리 + const myNoteButton = document.getElementById('my-note-button'); + if (myNoteButton) { + myNoteButton.addEventListener('click', function () { + window.location.href = `/communities/my-note/${communityId}`; + }); + } + + // "질의응답" 버튼 클릭 시 처리 + const qnaButton = document.getElementById('qna-button'); + if (qnaButton) { + qnaButton.addEventListener('click', function () { + window.location.href = `/communities/info/${communityId}`; + }); + } +}); diff --git a/src/main/resources/static/js/config.js b/src/main/resources/static/js/config.js index da3c7d6..94ad637 100644 --- a/src/main/resources/static/js/config.js +++ b/src/main/resources/static/js/config.js @@ -1,7 +1,7 @@ let CONFIG = { API: { - // BASE_URL: 'http://localhost:8080', - BASE_URL: 'http://13.124.80.116:8080', + BASE_URL: 'http://localhost:8080', + // BASE_URL: 'http://13.124.80.116:8080', ENDPOINTS: { COMMUNITIES: '/api/communities', QUESTIONS: '/api/questions', @@ -12,8 +12,8 @@ let CONFIG = { } }, AUTH: { - TOKEN_KEY: 'X-QQ-AUTH-TOKEN', - DEFAULT_TOKEN: 'eyJhbGciOiJIUzI1NiJ9.eyJuaWNrbmFtZSI6IuydtOyaqeyekEEiLCJ1aWQiOjJ9.oZzB9H5K81iaQ1qfeA95MfQLMGEpzqxKqWks21qcOR0' + TOKEN_KEY: 'X-QQ-ACCESS-TOKEN', + DEFAULT_TOKEN: localStorage.getItem('ACCESS_TOKEN_HEADER_KEY') }, PAGINATION: { DEFAULT_PAGE: 0, diff --git a/src/main/resources/static/js/header.js b/src/main/resources/static/js/header.js new file mode 100644 index 0000000..4fbc18b --- /dev/null +++ b/src/main/resources/static/js/header.js @@ -0,0 +1,154 @@ +console.log("header script loaded"); + +document.addEventListener("DOMContentLoaded", function() { + + + // // 토큰을 localStorage에서 삭제 + // localStorage.removeItem("ACCESS_TOKEN_HEADER_KEY"); + + // localStorage에서 accessToken 확인 + const accessToken = localStorage.getItem("ACCESS_TOKEN_HEADER_KEY"); + + // 로그인 상태에 따라 요소 표시 + if (accessToken) { + // accessToken이 존재하면 로그인 상태 요소 표시 + document.getElementById("loggedInElements").style.display = "flex"; + document.getElementById("loggedOutElements").style.display = "none"; + // 로그인 상태일 때만 fetchProfile 호출 + fetchProfile(); + } else { + // accessToken이 없으면 로그아웃 상태 요소 표시 + document.getElementById("loggedInElements").style.display = "none"; + document.getElementById("loggedOutElements").style.display = "flex"; + } + + + // 드롭다운과 로그아웃 관련 요소들 가져오기 + const settingBtn = document.getElementById("setting-btn"); + const dropdownMenu = document.getElementById("setting-dropdown-menu"); + const logoutBtn = document.getElementById("logout-btn"); + + // setting-btn 클릭 시 드롭다운 메뉴 토글 + settingBtn.addEventListener("click", function () { + dropdownMenu.style.display = dropdownMenu.style.display === "block" ? "none" : "block"; + }); + + // 로그아웃 버튼 클릭 시 로그아웃 처리 + logoutBtn.addEventListener("click", async function () { + try { + // 서버에 로그아웃 요청 + await fetch("/api/auth/logout", { + method: "POST", + headers: { + "X-QQ-ACCESS-TOKEN": localStorage.getItem("ACCESS_TOKEN_HEADER_KEY"), + "Content-Type": "application/json" + } + }); + + // 클라이언트 로컬스토리지에서 AccessToken 삭제 및 로그인 페이지로 리디렉션 + localStorage.removeItem("ACCESS_TOKEN_HEADER_KEY"); + window.location.href = "/"; + } catch (error) { + console.error("로그아웃 중 오류 발생:", error); + } + }); + + // 페이지 외부 클릭 시 드롭다운 닫기 + document.addEventListener("click", function (event) { + if (!settingBtn.contains(event.target) && !dropdownMenu.contains(event.target)) { + dropdownMenu.style.display = "none"; + } + }); +}); + +// 프로필 데이터 가져오기 및 표시 +async function fetchProfile() { + console.log("fetchProfile 실행됨"); + try { + const response = await fetchWithAuth("/api/users/profile", { + method: "GET", + headers: { "Content-Type": "application/json" }, + }); + + if (!response.ok) throw new Error("Failed to fetch profile data"); + + const data = await response.json(); + console.log("fetchProfile data:", data); + + displayProfile(data); // 프로필 데이터를 화면에 표시 + } catch (error) { + console.error("Error fetching profile:", error); + } +} + +// 프로필 데이터를 화면에 표시 +function displayProfile(data) { + console.log("displayProfile data:", data); + + const profile = document.getElementById("profile"); + const profilePicture = document.getElementById("profile-picture"); + const profileImageUrl = data.data?.profileImageUrl || "/images/basic-profile.png"; + + console.log("프로필 이미지 URL:", profileImageUrl); + + profile.src = profileImageUrl; // 프로필 이미지 업데이트 + profilePicture.src = profileImageUrl; // 프로필 이미지 업데이트 +} + +// AccessToken 자동 갱신을 위한 fetchWithAuth 함수 정의 +async function fetchWithAuth(url, options = {}) { + // 헤더에 AccessToken 추가 + let accessToken = localStorage.getItem("ACCESS_TOKEN_HEADER_KEY"); + console.log("fetchWithAuth AccessToken : {}",accessToken); + + if (accessToken) { + options.headers = { + ...options.headers, + "X-QQ-ACCESS-TOKEN": accessToken + }; + } + + let response = await fetch(url, options); + console.log("API 응답 상태 코드:", response.status); + + // AccessToken 만료로 인해 401 Unauthorized 반환 시 + if (response.status === 401) { + console.log("AccessToken이 만료됨. RefreshToken으로 새로고침 시도 중..."); + + // RefreshToken으로 AccessToken 재발급 요청 + const refreshResponse = await fetch("/api/auth/refresh", { + method: "POST", + headers: { + "Content-Type": "application/json" + } + }); + + if (refreshResponse.ok) { + // 새 AccessToken 저장 + const newAccessToken = await refreshResponse.text(); // 서버가 새 AccessToken만 반환한다고 가정 + localStorage.setItem("ACCESS_TOKEN_HEADER_KEY", newAccessToken); + console.log("AccessToken이 재발급되었습니다 : " , newAccessToken) + + // 기존 요청 재시도 + options.headers["X-QQ-ACCESS-TOKEN"] = newAccessToken; + response = await fetch(url, options); + } else { + // RefreshToken이 유효하지 않을 경우 로그아웃 처리 + console.log("RefreshToken이 만료되었습니다. 재로그인이 필요합니다."); + + // 서버에 로그아웃 요청 + await fetch("/api/auth/logout", { + method: "POST", + headers: { + "X-QQ-ACCESS-TOKEN": accessToken // 기존 만료된 AccessToken을 로그아웃 요청에 사용 + } + }); + + // 클라이언트 로컬스토리지에서 AccessToken 삭제 및 로그인 페이지로 이동 + localStorage.removeItem("ACCESS_TOKEN_HEADER_KEY"); + window.location.href = "/"; + } + } + + return response; +} \ No newline at end of file diff --git a/src/main/resources/static/js/news.js b/src/main/resources/static/js/news.js index 2ca87e1..17fa023 100644 --- a/src/main/resources/static/js/news.js +++ b/src/main/resources/static/js/news.js @@ -63,7 +63,7 @@ function fetchNews() { method: 'GET', headers: { 'Content-Type': 'application/json', - 'X-QQ-AUTH-TOKEN': newsToken + 'X-QQ-ACCESS-TOKEN': newsToken } }) .then(response => { @@ -101,7 +101,7 @@ function fetchNewsDetails(newsId) { method: 'GET', headers: { 'Content-Type': 'application/json', - 'X-QQ-AUTH-TOKEN': newsToken + 'X-QQ-ACCESS-TOKEN': newsToken } }) .then(response => { diff --git a/src/main/resources/templates/auth/reset_password.html b/src/main/resources/templates/auth/reset_password.html new file mode 100644 index 0000000..03d0551 --- /dev/null +++ b/src/main/resources/templates/auth/reset_password.html @@ -0,0 +1,172 @@ + + + + + + 비밀번호 재설정 + + + + +
+
+ +
+
+

비밀번호 재설정

+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+
+
+
+ + + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/auth/signIn.html b/src/main/resources/templates/auth/signIn.html new file mode 100644 index 0000000..f0afb8f --- /dev/null +++ b/src/main/resources/templates/auth/signIn.html @@ -0,0 +1,164 @@ + + + + + + + + + +
+
+
+
+

로그인

+
+
+ + +
+ +
+ + +
+ + +
+ +
+
+
+ + + diff --git a/src/main/resources/templates/community/all_question_list.html b/src/main/resources/templates/community/all_question_list.html index 3422657..36a7fd7 100644 --- a/src/main/resources/templates/community/all_question_list.html +++ b/src/main/resources/templates/community/all_question_list.html @@ -1,119 +1,142 @@ - +
- -
- - +
@@ -122,50 +145,49 @@

전체 질문

\ No newline at end of file diff --git a/src/main/resources/templates/community/community_create.html b/src/main/resources/templates/community/community_create.html index 830a7be..a9fd4dc 100644 --- a/src/main/resources/templates/community/community_create.html +++ b/src/main/resources/templates/community/community_create.html @@ -1,213 +1,230 @@ - - - 새 커뮤니티 생성 - - - -
+ + + 새 커뮤니티 생성 + + + +
+
+ +
+
+

새 커뮤니티 생성

+
+ + + + + + + +

커뮤니티 홈 화면/커뮤니티 가입 시 표시되는 설명입니다.

+ + +
커뮤니티 아이콘
+
+ + 커뮤니티를 대표하는 이미지를 등록해주세요.
+ 미등록 시, 기본이미지로 설정됩니다. +
+ + +
+

커뮤니티 설정*

+
+ +
+ 커뮤니티 공개 여부 +
+ + +
+
+
+ + +
+ 질문/답변 권한 +
+ + +
+
+
+
+ + +
+ + +
+
- -
+
@@ -217,100 +234,96 @@

커뮤니티 설정*

diff --git a/src/main/resources/templates/community/community_create_after.html b/src/main/resources/templates/community/community_create_after.html index 99cb8bd..1fd5546 100644 --- a/src/main/resources/templates/community/community_create_after.html +++ b/src/main/resources/templates/community/community_create_after.html @@ -1,112 +1,129 @@ - - - 커뮤니티 개설 완료 - + + + 커뮤니티 개설 완료 +
- -
- -

커뮤니티 생성이 완료되었습니다!

-

커뮤니티 이름:

- - - - -
- - -
-
- -
-
+
+ +
+
+ +

커뮤니티 생성이 완료되었습니다!

+

커뮤니티 이름:

+ + + + +
+ + +
+
+ +
+
+
diff --git a/src/main/resources/templates/community/community_info.html b/src/main/resources/templates/community/community_info.html index 7876758..4270817 100644 --- a/src/main/resources/templates/community/community_info.html +++ b/src/main/resources/templates/community/community_info.html @@ -1,279 +1,296 @@ - - - 커뮤니티 - + + + 커뮤니티 +
+
+ +
+
+ +
+ + +
-
- -
- - -
- -
- - -
- - -
-

답변을 기다리는 질문

- -
-
-
- - -
-
-

인기 Q&A

- -
-
-
- - -
-
- - -
- 커뮤니티 로고 + + +
+ + +
+

답변을 기다리는 질문

+ +
+
+
+ + +
+
+

인기 Q&A

+ +
+
+
+ + +
+
+ + +
+ 커뮤니티 로고 -

-

+

+

- - - -
-
- 회원수 - -
-
- 질문수 - + + +
+
+ 회원수 + +
+
+ 질문수 + +
+
+ 방문자 + +
+
+

+
-
- 방문자 - -
-
-

-
-
- +
- 무엇이든 물어보세요 이미지 -

무엇이든 물어보세요!

+ 무엇이든 물어보세요 이미지 +

무엇이든 물어보세요!

@@ -316,51 +333,60 @@

인기 Q&A

+ diff --git a/src/main/resources/templates/community/community_join.html b/src/main/resources/templates/community/community_join.html index f4b40c9..cb39bcc 100644 --- a/src/main/resources/templates/community/community_join.html +++ b/src/main/resources/templates/community/community_join.html @@ -5,14 +5,28 @@ 커뮤니티 가입 신청 +
+
-
+
@@ -174,29 +210,29 @@

인기 질문

- -
- -
+ 커뮤니티 만들기
-
-
-

공지사항

-

커뮤니티 생성 가이드 안내입니다...

-

다른 공지 내용을 적으세요.

-
-
-

공지사항

-

커뮤니티 생성 가이드 안내입니다...

-

다른 공지 내용을 적으세요.

-
-
-

공지사항

-

커뮤니티 생성 가이드 안내입니다...

-

다른 공지 내용을 적으세요.

-
+ +
+ +
+ 커뮤니티 만들기
+
+
+

공지사항

+

커뮤니티 생성 가이드 안내입니다...

+

다른 공지 내용을 적으세요.

+
+
+

공지사항

+

커뮤니티 생성 가이드 안내입니다...

+

다른 공지 내용을 적으세요.

+
+
+

공지사항

+

커뮤니티 생성 가이드 안내입니다...

+

다른 공지 내용을 적으세요.

+
+
-
-
+
@@ -206,21 +242,20 @@

인기 질문

diff --git a/src/main/resources/templates/community/community_update.html b/src/main/resources/templates/community/community_update.html index 5422c18..1fe62db 100644 --- a/src/main/resources/templates/community/community_update.html +++ b/src/main/resources/templates/community/community_update.html @@ -1,341 +1,354 @@ - - - 커뮤니티 수정 - - - -
+ + + 커뮤니티 수정 + + + +
+
+ +
+
+

커뮤니티 수정

+
+ + + + + + + +

커뮤니티 홈 화면/커뮤니티 가입 시 표시되는 설명입니다.

+ + +
커뮤니티 아이콘
+
+ +
+ 커뮤니티를 대표하는 이미지를 등록해주세요.
+ 미등록 시, 기존 이미지가 유지됩니다. +
+
+ + +
+

커뮤니티 설정*

+
+
+ 커뮤니티 공개 여부 +
+ + +
+
+
+
+ 질문/답변 권한 +
+ + +
+
+
+
+ + +
+ + +
+
- - +
diff --git a/src/main/resources/templates/community/unanswered_question_list.html b/src/main/resources/templates/community/unanswered_question_list.html index 05f1ebe..eaced3c 100644 --- a/src/main/resources/templates/community/unanswered_question_list.html +++ b/src/main/resources/templates/community/unanswered_question_list.html @@ -1,118 +1,142 @@ - +
- -
- +
@@ -122,49 +146,48 @@

답변을 기다리는 질문

\ No newline at end of file diff --git a/src/main/resources/templates/footer.html b/src/main/resources/templates/footer.html index 0d43fb3..a5d7a37 100644 --- a/src/main/resources/templates/footer.html +++ b/src/main/resources/templates/footer.html @@ -11,18 +11,18 @@ Home Community 일반게시판 - My Page + My Page About Us
- - - + + + Sci.Q + + + + +
-
+
+ + - -
- + + + \ No newline at end of file diff --git a/src/main/resources/templates/home.html b/src/main/resources/templates/home.html index bafe74f..9bd2d68 100644 --- a/src/main/resources/templates/home.html +++ b/src/main/resources/templates/home.html @@ -1,231 +1,248 @@ - - - + + 메인 페이지 +
+
-
-
-

인기 커뮤니티

-
- -
-
- -
-

인기 질문

-
- -
-
- -
-
-
-

공지사항

-
- -
-
- -
@@ -237,22 +254,23 @@

전 날 최다 추천 답변

diff --git a/src/main/resources/templates/my/change_nickname.html b/src/main/resources/templates/my/change_nickname.html new file mode 100644 index 0000000..8041175 --- /dev/null +++ b/src/main/resources/templates/my/change_nickname.html @@ -0,0 +1,156 @@ + + + + + + 비밀번호 변경 + + + + +
+
+ +
+
+

닉네임 변경

+
+
+ + +
+ +
+
+
+
+ + + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/my/change_password.html b/src/main/resources/templates/my/change_password.html new file mode 100644 index 0000000..fe29a6b --- /dev/null +++ b/src/main/resources/templates/my/change_password.html @@ -0,0 +1,166 @@ + + + + + + 비밀번호 변경 + + + + +
+
+ +
+
+

비밀번호 변경

+
+
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+
+
+
+ + + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/my/community_management.html b/src/main/resources/templates/my/community_management.html new file mode 100644 index 0000000..d4c3e23 --- /dev/null +++ b/src/main/resources/templates/my/community_management.html @@ -0,0 +1,282 @@ + + + + + + My Community Management + + + + +
+
+ +
+
+ +
+ Profile Picture +

+ +
+

배지 보유 현황

+
+ +
+
+ +
+ +
+ +
+
+
+
+ + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/my/community_my_note.html b/src/main/resources/templates/my/community_my_note.html new file mode 100644 index 0000000..5f61c93 --- /dev/null +++ b/src/main/resources/templates/my/community_my_note.html @@ -0,0 +1,519 @@ + + + + + + 커뮤니티 + + + +
+
+ +
+
+ +
+ + +
+ +
+ + +
+
+

즐겨찾기 한 질문

+ +
+
+
+ + +
+
+

즐겨찾기 한 답변

+ +
+
+
+ + +
+
+ + +
+ 커뮤니티 로고 +

+

+ + + + +
+
+ 회원수 + +
+
+ 질문수 + +
+
+ 방문자 + +
+
+

+
+
+
+
+ +
+ + + + + + + + + + + diff --git a/src/main/resources/templates/my/my.html b/src/main/resources/templates/my/my.html new file mode 100644 index 0000000..3e57c6b --- /dev/null +++ b/src/main/resources/templates/my/my.html @@ -0,0 +1,560 @@ + + + + + + My Page + + + + +
+
+ +
+
+ +
+

+ 내가 속한 커뮤니티 +

+
+ + +
+
+ + +
+
+

내 질문 조회

+
+ +
+
+
+

내 답변 조회

+
+ +
+
+
+ + +
+ Profile Picture +

+ +
+

배지 보유 현황

+
+ +
+
+ +
+
+
+
+ + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/my/profile.html b/src/main/resources/templates/my/profile.html new file mode 100644 index 0000000..7156d6c --- /dev/null +++ b/src/main/resources/templates/my/profile.html @@ -0,0 +1,441 @@ + + + + + + My profile + + + + +
+
+ +
+
+ +
+ Profile Picture +

+ +
+

배지 보유 현황

+
+ +
+
+ +
+ +
+
+ 닉네임 +

닉네임

+ + + +
+
+ 프로필 +

프로필 사진

+ + + +
+
+ 비밀번호 +

비밀번호

+ + + +
+ +
+
+
+
+ + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/news.html b/src/main/resources/templates/news.html index 3b18f5c..aaa6ef3 100644 --- a/src/main/resources/templates/news.html +++ b/src/main/resources/templates/news.html @@ -18,7 +18,7 @@ } .modal-content { - background-color: #34495e; + background-color: #007bff; margin: 15% auto; padding: 20px; border: 1px solid #888; @@ -61,7 +61,7 @@ .news-item.unviewed { font-weight: bold; - color: #1F364D; + color: #007bff; background-color: rgba(255, 255, 255, 0.9); } @@ -76,7 +76,7 @@ display: block; margin: 20px auto; padding: 10px 20px; - background-color: #34495e; + background-color: #007bff; color: white; border: 1px solid white; border-radius: 5px; @@ -89,7 +89,7 @@ display: block; margin: 20px auto 0; padding: 10px 20px; - background-color: #34495e; + background-color: #007bff; color: white; border: 1px solid white; border-radius: 5px; @@ -98,12 +98,12 @@ #news-info-back-btn:hover { background-color: white; - color: #34495e; + color: #007bff; } #news-info-related-btn:hover { background-color: white; - color: #34495e; + color: #007bff; } #title { @@ -206,15 +206,13 @@

// const url = `http://localhost:8080/api/news` const url = CONFIG.API.BASE_URL + CONFIG.API.ENDPOINTS.NEWS; - const token = CONFIG.AUTH.DEFAULT_TOKEN; function fetchNews() { fetch(url, { method: 'GET', headers: { - 'Content-Type': 'application/json', - 'X-QQ-AUTH-TOKEN': token + 'Content-Type': 'application/json' } }) .then(response => { @@ -250,8 +248,7 @@

fetch(url + `/${newsId}`, { method: 'GET', headers: { - 'Content-Type': 'application/json', - 'X-QQ-AUTH-TOKEN': token + 'Content-Type': 'application/json' } }) .then(response => { diff --git a/src/main/resources/templates/question/question_create.html b/src/main/resources/templates/question/question_create.html index 1c32635..a3c8bd3 100644 --- a/src/main/resources/templates/question/question_create.html +++ b/src/main/resources/templates/question/question_create.html @@ -1,325 +1,343 @@ - - - 질문 작성 - - - -
+ .submit-btn { + background-color: #007bff; + color: white; + } -
-

질문 작성

+ /* 커뮤니티 선택 스타일 추가 */ + .community-select { + width: 100%; + padding: 10px; + margin-bottom: 20px; + border: 1px solid #ccc; + border-radius: 5px; + background-color: #f4f9ff; + } - -

* 표시는 필수 입력 항목

+ .community-select:focus { + outline: none; + border-color: #007bff; + } - - - + /* 무엇이든 물어보세요 버튼 */ + .chat-widget { + position: fixed; + bottom: 20px; + right: 20px; + background-color: #fff; + border: 1px solid #ccc; + padding: 10px; + border-radius: 10px; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); + width: 150px; + text-align: center; + z-index: 1000; /* 화면에서 항상 보이게 z-index 설정 */ + } - - - + .chat-widget img { + width: 50px; + height: 50px; + margin-bottom: 10px; + } - -
- -
- + .chat-widget p { + font-size: 14px; + font-weight: bold; + color: #333; + margin: 0; + } + + + +
+
+ +
+
+

질문 작성

+ + +

* 표시는 필수 입력 항목

+ + + + + + + + + + +
+ +
+ +
+ + +

선택된 파일 없음

+
+ + + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
- - -

선택된 파일 없음

-
- - - - - - - -
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - -
-
+
- 무엇이든 물어보세요 이미지 -

무엇이든 물어보세요!

+ 무엇이든 물어보세요 이미지 +

무엇이든 물어보세요!

@@ -330,243 +348,238 @@

질문 작성

diff --git a/src/main/resources/templates/question/question_edit.html b/src/main/resources/templates/question/question_edit.html index 9156fcb..2146bbe 100644 --- a/src/main/resources/templates/question/question_edit.html +++ b/src/main/resources/templates/question/question_edit.html @@ -1,322 +1,339 @@ - - - 질문 작성 - + /* 무엇이든 물어보세요 버튼 */ + .chat-widget { + position: fixed; + bottom: 20px; + right: 20px; + background-color: #fff; + border: 1px solid #ccc; + padding: 10px; + border-radius: 10px; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); + width: 150px; + text-align: center; + z-index: 1000; /* 화면에서 항상 보이게 z-index 설정 */ + } + + .chat-widget img { + width: 50px; + height: 50px; + margin-bottom: 10px; + } + + .chat-widget p { + font-size: 14px; + font-weight: bold; + color: #333; + margin: 0; + } +
- -
-

질문 수정

- -

* 표시는 필수 입력 항목

- - - - - - - - -
- -
- -
-
- - - +
+ +
+
+

질문 수정

+ +

* 표시는 필수 입력 항목

+ + + + + + + + +
+ +
+ +
+
+ + + +
+

선택된 파일 없음

+
+ + + + + + +
+ +
+ +
+ + +
-

선택된 파일 없음

-
- - - - - - -
- -
- -
- - -
-
+
@@ -326,366 +343,359 @@

질문 수정

diff --git a/src/main/resources/templates/question/question_info.html b/src/main/resources/templates/question/question_info.html index adf2fc4..dce693b 100644 --- a/src/main/resources/templates/question/question_info.html +++ b/src/main/resources/templates/question/question_info.html @@ -1,689 +1,744 @@ - - - 질문 및 답변 - +
+
+ +
+
+ +
+ +
+
+ 프로필 이미지 +
+
+ +
+
+
+
-
- -
- -
-
-
- 퀴카가 되고싶은 강아지 - -
-
-
-
- - -
- - - - -
+ +
+ + + + +
- -
- - 커뮤니티 이름 -
-
- - -
- 제목 -
- - -
-

내용

-

해시태그 목록

-
- - -
- -
- - 댓글 아이콘 - 댓글 ? -
-
+ +
+ + 커뮤니티 이름 +
+
- -
-
- -
+ +
+ 제목 +
+ +
+

내용

+

해시태그 목록

+
+ +
+ + + + + + + +
- -
-
- 프로필 이미지 -
-
-
- -
- - + +
+
+
-
- 출처 - + + +
+ + +
+
+ + 프로필 이미지 + +
+
+
+ +
+
+
+ + +
+ +
+ 출처 + +
+
+ + +
+
+
-
-
-
- - -
-
+
- 무엇이든 물어보세요 이미지 - -

무엇이든 물어보세요!

+ 무엇이든 물어보세요 이미지 + +

무엇이든 물어보세요!

@@ -694,155 +749,159 @@ diff --git a/src/main/resources/templates/search/search.html b/src/main/resources/templates/search/search.html index ec9c31e..8727037 100644 --- a/src/main/resources/templates/search/search.html +++ b/src/main/resources/templates/search/search.html @@ -93,6 +93,7 @@
+
@@ -141,14 +142,12 @@

"" 검색 결과

// const token = 'eyJhbGciOiJIUzI1NiJ9.eyJuaWNrbmFtZSI6IuydtOyaqeyekEEiLCJ1aWQiOjJ9.oZzB9H5K81iaQ1qfeA95MfQLMGEpzqxKqWks21qcOR0'; const url = CONFIG.API.BASE_URL + CONFIG.API.ENDPOINTS.SEARCH; // const baseURL = CONFIG.API.BASE_URL; - const token = CONFIG.AUTH.DEFAULT_TOKEN; function fetchSearchResults(page) { fetch(url + `?search-keyword=${encodeURIComponent(searchKeyword)}&search-type=${searchType}&sort-type=${currentSort}&page=${page}`, { method: 'GET', headers: { - 'Content-Type': 'application/json', - 'X-QQ-AUTH-TOKEN': token + 'Content-Type': 'application/json' } }) .then(response => response.json()) diff --git a/src/main/resources/templates/user/signUp.html b/src/main/resources/templates/user/signUp.html new file mode 100644 index 0000000..5062232 --- /dev/null +++ b/src/main/resources/templates/user/signUp.html @@ -0,0 +1,171 @@ + + + + + + 회원가입 + + + + +
+
+ +
+
+

회원가입

+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+
+
+
+ + + + + \ No newline at end of file