diff --git a/build.gradle b/build.gradle index 491c004..10d68fc 100644 --- a/build.gradle +++ b/build.gradle @@ -24,16 +24,51 @@ repositories { } dependencies { + implementation 'org.springframework.boot:spring-boot-starter' + + // JPA implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - implementation 'org.springframework.boot:spring-boot-starter-jdbc' + + // Thymeleaf + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + + // Security + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-validation' + + // JWT + implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5' + runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5' + runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5' + + // Spring Web implementation 'org.springframework.boot:spring-boot-starter-web' + + // Lombok compileOnly 'org.projectlombok:lombok' - runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' + + // Database + implementation 'com.mysql:mysql-connector-j:8.2.0' // 보안 취약점 패치(>=8.2.0) 적용 + implementation 'org.springframework.boot:spring-boot-starter-jdbc' + runtimeOnly 'com.h2database:h2' + + // Test tool testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + testCompileOnly 'org.projectlombok:lombok' + testAnnotationProcessor 'org.projectlombok:lombok' + + // Querydsl + implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' + annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta" + annotationProcessor "jakarta.annotation:jakarta.annotation-api" + annotationProcessor "jakarta.persistence:jakarta.persistence-api" + + // Swagger + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2' } tasks.named('test') { useJUnitPlatform() -} +} \ No newline at end of file diff --git a/src/main/java/com/demo/DemoApplication.java b/src/main/java/com/demo/DemoApplication.java index c6f2a20..362a5ae 100644 --- a/src/main/java/com/demo/DemoApplication.java +++ b/src/main/java/com/demo/DemoApplication.java @@ -1,9 +1,14 @@ package com.demo; +import com.demo.config.properties.JWTProperties; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; @SpringBootApplication +@EnableConfigurationProperties( + JWTProperties.class +) public class DemoApplication { public static void main(String[] args) { diff --git a/src/main/java/com/demo/config/CorsConfig.java b/src/main/java/com/demo/config/CorsConfig.java new file mode 100644 index 0000000..aa9ecc7 --- /dev/null +++ b/src/main/java/com/demo/config/CorsConfig.java @@ -0,0 +1,27 @@ +package com.demo.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.filter.CorsFilter; + +/** + * 애플리케이션에서 CORS 설정을 하기 위한 Configuration + * + * @author duskafka + * */ +@Configuration +public class CorsConfig { + @Bean + public CorsFilter corsFilter() { + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + CorsConfiguration config = new CorsConfiguration(); + config.setAllowCredentials(true); + config.addAllowedOriginPattern("*"); + config.addAllowedHeader("*"); + config.addAllowedMethod("*"); + source.registerCorsConfiguration("/api/v1/**", config); + return new CorsFilter(source); + } +} \ No newline at end of file diff --git a/src/main/java/com/demo/config/JpaConfig.java b/src/main/java/com/demo/config/JpaConfig.java new file mode 100644 index 0000000..fe41588 --- /dev/null +++ b/src/main/java/com/demo/config/JpaConfig.java @@ -0,0 +1,19 @@ +package com.demo.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +/** + * JPA 설정을 설정하기 위한 Configuration + * + *
  • JPAQueryFactory: Querydsl을 사용하기 위해 JPAQueryFactory를 빈으로 등록
  • + * + * @author duskafka + * */ +@Slf4j +@EnableJpaAuditing +@Configuration +public class JpaConfig { + +} \ No newline at end of file diff --git a/src/main/java/com/demo/config/JwtSecurityConfig.java b/src/main/java/com/demo/config/JwtSecurityConfig.java new file mode 100644 index 0000000..5d767dd --- /dev/null +++ b/src/main/java/com/demo/config/JwtSecurityConfig.java @@ -0,0 +1,28 @@ +package com.demo.config; + +import com.demo.config.properties.JWTProperties; +import com.demo.jwt.JwtFilter; +import com.demo.jwt.TokenProvider; +import com.demo.jwt.TokenValidator; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.SecurityConfigurerAdapter; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.web.DefaultSecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@RequiredArgsConstructor +public class JwtSecurityConfig extends SecurityConfigurerAdapter { + private final TokenProvider tokenProvider; + private final TokenValidator tokenValidator; + private final JWTProperties jwtProperties; + + @Override + public void configure(HttpSecurity http) { + http.addFilterBefore( + new JwtFilter(tokenProvider, tokenValidator, jwtProperties), + UsernamePasswordAuthenticationFilter.class + ); + } +} diff --git a/src/main/java/com/demo/config/SecurityConfig.java b/src/main/java/com/demo/config/SecurityConfig.java new file mode 100644 index 0000000..e5bf511 --- /dev/null +++ b/src/main/java/com/demo/config/SecurityConfig.java @@ -0,0 +1,104 @@ +package com.demo.config; + +import com.demo.config.properties.JWTProperties; +import com.demo.jwt.JwtAccessDeniedHandler; +import com.demo.jwt.JwtAuthenticationEntryPoint; +import com.demo.jwt.TokenProvider; +import com.demo.jwt.TokenValidator; +import com.demo.repository.StudentRepository; +import com.demo.service.CustomLoginService; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.security.servlet.PathRequest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +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.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.filter.CorsFilter; +import org.springframework.web.cors.CorsUtils; + +@Slf4j +@EnableWebSecurity +@EnableMethodSecurity +@Configuration +@RequiredArgsConstructor(access = AccessLevel.PROTECTED) +public class SecurityConfig { + private final CorsFilter corsFilter; + private final TokenProvider tokenProvider; + private final TokenValidator tokenValidator; + private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; + private final JwtAccessDeniedHandler jwtAccessDeniedHandler; + private final JWTProperties jwtProperties; + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public CustomLoginService customLoginService(StudentRepository studentRepository) { + return new CustomLoginService(studentRepository); + } + + @Bean + public AuthenticationManager authenticationManager(HttpSecurity http, PasswordEncoder passwordEncoder, CustomLoginService customLoginService) throws Exception { + return http.getSharedObject(AuthenticationManagerBuilder.class) + .userDetailsService(customLoginService) + .passwordEncoder(passwordEncoder) + .and() + .build(); + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http.csrf(AbstractHttpConfigurer::disable) + .addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class) + .exceptionHandling(exception -> { + exception.accessDeniedHandler(jwtAccessDeniedHandler) + .authenticationEntryPoint(jwtAuthenticationEntryPoint); + }) + .authorizeHttpRequests(request -> { + request + // UI 라우트: 비로그인 허용 + .requestMatchers("/", "/sign-in", "/sign-up").permitAll() + .requestMatchers("/posts", "/posts/*", "/posts/edit/*").permitAll() + .requestMatchers("/js/**", "/css/**", "/images/**", "/favicon.ico", "/error").permitAll() + + // API 조회는 비로그인 허용 + .requestMatchers(HttpMethod.GET, "/api/v1/posts/**").permitAll() + + // API 쓰기 작업은 인증 필요 + .requestMatchers(HttpMethod.POST, "/api/v1/posts/**").authenticated() + .requestMatchers(HttpMethod.PUT, "/api/v1/posts/**").authenticated() + .requestMatchers(HttpMethod.DELETE, "/api/v1/posts/**").authenticated() + + // 나머지 기존 정책 + .requestMatchers("/api/v1/auth/login", "/api/v1/auth/register", "/api/v1/auth/check-id").permitAll() + .requestMatchers("/api/v1/admin/**").hasRole("ADMIN") + .requestMatchers("/api/v1/students/**").hasAnyRole("ADMIN", "USER") + .requestMatchers(org.springframework.web.cors.CorsUtils::isPreFlightRequest).permitAll() + .anyRequest().authenticated(); + }) + .sessionManagement(session -> { + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS); + }) + .headers(headers -> { + headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin); + }) + .with(new JwtSecurityConfig(tokenProvider, tokenValidator, jwtProperties), customizer -> { + }); + return http.build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/demo/config/properties/JWTProperties.java b/src/main/java/com/demo/config/properties/JWTProperties.java new file mode 100644 index 0000000..1646ef4 --- /dev/null +++ b/src/main/java/com/demo/config/properties/JWTProperties.java @@ -0,0 +1,28 @@ +package com.demo.config.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * JWT 토큰 프로퍼티를 가지는 클래스 + * + * @author duskafka + * */ +@ConfigurationProperties(prefix = "jwt") +public record JWTProperties( + //JWT 토큰이 HTTP 헤더에 담길 때 사용될 헤더 이름 (예: Authorization) + String accessTokenHeader, + + //JWT 서명 및 검증에 사용될 비밀 키 (Base64 인코딩된 문자열) + //HS512 알고리즘을 사용할 것이기 때문에 512bit, 즉 64byte 이상의 secret key를 사용해야 합니다. + String secret, + + //Access Token의 만료 기간 (초 단위) + long accessTokenValidityInHour, + + //인증용 키 + String authorityKey, + + //액세스 토큰 인증 헤더 + String bearerHeader +) { +} \ No newline at end of file diff --git a/src/main/java/com/demo/controller/ApiController.java b/src/main/java/com/demo/controller/ApiController.java deleted file mode 100644 index 238ac39..0000000 --- a/src/main/java/com/demo/controller/ApiController.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.demo.controller; - -import com.demo.service.StudentService; -import com.demo.dto.request.StudentCreateRequest; -import com.demo.dto.request.StudentUpdateRequest; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -@Slf4j -@RestController -@RequestMapping("/api/v1/students") -@RequiredArgsConstructor -public class ApiController { - private final StudentService studentService; - - @PostMapping - public ResponseEntity create(@RequestBody StudentCreateRequest request) { - var response = studentService.create(request); - return ResponseEntity.ok(response); - } - - @GetMapping("/{id}") - public ResponseEntity get(@PathVariable(name = "id") Long id) { - var response = studentService.findById(id); - return ResponseEntity.ok(response); - } - - @PostMapping("/{id}") - public ResponseEntity update( - @RequestBody StudentUpdateRequest request, - @PathVariable(name = "id") Long id - ) { - var response = studentService.update(id, request); - return ResponseEntity.ok(response); - } - - @DeleteMapping("/{id}") - public ResponseEntity delete(@PathVariable(name = "id") Long id) { - studentService.delete(id); - return ResponseEntity.noContent().build(); - } -} \ No newline at end of file diff --git a/src/main/java/com/demo/controller/api/AuthApiController.java b/src/main/java/com/demo/controller/api/AuthApiController.java new file mode 100644 index 0000000..f1efff2 --- /dev/null +++ b/src/main/java/com/demo/controller/api/AuthApiController.java @@ -0,0 +1,74 @@ +package com.demo.controller.api; + +import com.demo.config.properties.JWTProperties; +import com.demo.dto.request.LoginRequest; +import com.demo.dto.request.StudentCreateRequest; +import com.demo.jwt.TokenProvider; +//import com.demo.repository.StudentRepository; +import com.demo.service.StudentService; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.parameters.P; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/auth") +public class AuthApiController { + + private final TokenProvider tokenProvider; + private final JWTProperties jwtProperties; + private final StudentService studentService; + private final AuthenticationManager authenticationManager; + //private final StudentRepository studentRepository; + + @PostMapping("/login") + public ResponseEntity login( + @RequestBody LoginRequest loginRequest, + HttpServletResponse response + ) { + Authentication authentication = authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken(loginRequest.getLoginId(), loginRequest.getPassword()) + ); + + log.info(authentication.toString()); + + String accessToken = tokenProvider.createAccessToken(authentication); + response.addHeader(jwtProperties.accessTokenHeader(), jwtProperties.bearerHeader() + accessToken); + + return ResponseEntity.ok().build(); + } + + //@PostMapping("/register") + @PostMapping(value = "/register") + public ResponseEntity register(@RequestBody StudentCreateRequest studentCreateRequest) { + var response = studentService.create(studentCreateRequest); + return ResponseEntity.ok(response); + } + + /* 아이디 중복 확인 API 만들어봤는데 한번확인해주시면 감사하겠습니다. + @GetMapping("/check-id") + public ResponseEntity checkLoginId(@RequestParam String userId) { + boolean exists = studentRepository.existsByLoginId(userId); + + Map result = new HashMap<>(); + result.put("available", !exists); // true = 사용 가능 + + return ResponseEntity.ok(result); + }*/ + + @GetMapping("/check-id") + public ResponseEntity checkId(@RequestParam String loginId){ + if(studentService.ixExistLoginId(loginId)) { + return ResponseEntity.status(HttpStatus.CONFLICT).build(); + } + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/demo/controller/api/PostApiController.java b/src/main/java/com/demo/controller/api/PostApiController.java new file mode 100644 index 0000000..4554864 --- /dev/null +++ b/src/main/java/com/demo/controller/api/PostApiController.java @@ -0,0 +1,78 @@ +package com.demo.controller.api; + +import com.demo.dto.request.PostCreateRequest; +import com.demo.dto.request.PostUpdateRequest; +import com.demo.dto.response.PostResponse; +import com.demo.dto.response.PostWithUserNameResponse; +import com.demo.service.PostService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.bind.annotation.*; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/posts") +public class PostApiController { + + private final PostService postService; + + @PostMapping + @PreAuthorize("hasRole('USER')") + public PostResponse createPost(@RequestBody PostCreateRequest request, @AuthenticationPrincipal UserDetails userDetails) { + String studentId = userDetails.getUsername(); + log.info("Creating post: Student ID: {}, Post Title: {}", studentId, request.getTitle()); + PostResponse response = postService.createPost(request, studentId); + log.info("Post created successfully: Post ID: {}", response.getId()); + return response; + } + + // 상세 조회 반환 타입을 PostWithUserNameResponse로 변경 + @GetMapping("/{postId}") + public PostWithUserNameResponse getPost(@PathVariable Long postId) { + log.info("Fetching post with ID: {}", postId); + PostWithUserNameResponse response = postService.getPost(postId); + log.info("Post fetched successfully: Post ID: {}", postId); + return response; + } + + @PutMapping("/{postId}") + @PreAuthorize("hasAnyRole('USER', 'ADMIN')") + public PostResponse updatePost(@PathVariable Long postId, @RequestBody PostUpdateRequest request, @AuthenticationPrincipal UserDetails userDetails) { + String studentId = userDetails.getUsername(); + log.info("Updating post: Post ID: {}, Student ID: {}, New Title: {} ", postId, studentId, request.getTitle()); + PostResponse response = postService.updatePost(postId, request, studentId); + log.info("Post updated successfully: Post ID: {}", postId); + return response; + } + + @DeleteMapping("/{postId}") + @PreAuthorize("hasAnyRole('USER', 'ADMIN')") + public void deletePost(@PathVariable Long postId, @AuthenticationPrincipal UserDetails userDetails) { + String studentId = userDetails.getUsername(); + log.info("Deleting post: Post ID: {}, Student ID: {}", postId, studentId); + postService.deletePost(postId, studentId); + log.info("Post deleted successfully: Post ID: {}, ", postId); + } + + @GetMapping + public Page getPostList(@RequestParam(required = false) String titleSearch, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + Pageable pageable = PageRequest.of(page, size); + log.info("Fetching post list: Search Query: {}, Page: {}, Size: {}", titleSearch, page, size); + + Page postPage = (titleSearch == null) + ? postService.getAllPosts(pageable) + : postService.searchPosts(titleSearch, pageable); + + log.info("Fetched {} posts.", postPage.getTotalElements()); + return postPage; + } +} diff --git a/src/main/java/com/demo/controller/api/StudentApiController.java b/src/main/java/com/demo/controller/api/StudentApiController.java new file mode 100644 index 0000000..bc825af --- /dev/null +++ b/src/main/java/com/demo/controller/api/StudentApiController.java @@ -0,0 +1,43 @@ +package com.demo.controller.api; + +import com.demo.service.StudentService; +import com.demo.dto.request.StudentCreateRequest; +import com.demo.dto.request.StudentUpdateRequest; + +import jakarta.annotation.security.RolesAllowed; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RestController +@RequestMapping("/api/v1/students") +@RequiredArgsConstructor +public class StudentApiController { + private final StudentService studentService; + + @GetMapping + public ResponseEntity get(@AuthenticationPrincipal UserDetails authentication) { + var response = studentService.findById(authentication.getUsername()); + return ResponseEntity.ok(response); + } + + @PostMapping + public ResponseEntity update( + @RequestBody StudentUpdateRequest request, + @AuthenticationPrincipal UserDetails authentication + ) { + var response = studentService.update(authentication.getUsername(), request); + return ResponseEntity.ok(response); + } + + @DeleteMapping + public ResponseEntity delete(@AuthenticationPrincipal UserDetails authentication) { + studentService.delete(authentication.getUsername()); + return ResponseEntity.noContent().build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/demo/controller/ui/AuthUIController.java b/src/main/java/com/demo/controller/ui/AuthUIController.java new file mode 100644 index 0000000..1e83cc4 --- /dev/null +++ b/src/main/java/com/demo/controller/ui/AuthUIController.java @@ -0,0 +1,19 @@ +package com.demo.controller.ui; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.RequestMapping; + +@Controller +public class AuthUIController { + + @RequestMapping("/sign-in") + public String signIn() { + + return "/login/login-1.0"; + } + + @RequestMapping("/sign-up") + public String signUp() { + return "/signup/signup-1.0"; + } +} diff --git a/src/main/java/com/demo/controller/HomeController.java b/src/main/java/com/demo/controller/ui/HomeController.java similarity index 90% rename from src/main/java/com/demo/controller/HomeController.java rename to src/main/java/com/demo/controller/ui/HomeController.java index a0be3cb..44da75f 100644 --- a/src/main/java/com/demo/controller/HomeController.java +++ b/src/main/java/com/demo/controller/ui/HomeController.java @@ -1,4 +1,4 @@ -package com.demo.controller; +package com.demo.controller.ui; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Controller; diff --git a/src/main/java/com/demo/controller/ui/PostUIController.java b/src/main/java/com/demo/controller/ui/PostUIController.java new file mode 100644 index 0000000..8b20e92 --- /dev/null +++ b/src/main/java/com/demo/controller/ui/PostUIController.java @@ -0,0 +1,33 @@ +package com.demo.controller.ui; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.PathVariable; + +@Controller +public class PostUIController { + + // 게시글 목록 페이지 + + @RequestMapping("/posts") + public String postList() { + return "/post/post-list"; + } + + + @RequestMapping("/posts/new") + public String postCreate() { + return "/post/post-create"; + } + + @RequestMapping("/posts/edit/{id}") + public String postEdit(@PathVariable Long id) { + // id를 수정 페이지에 전달하고 싶으면 Model에 담아서 넘길 수도 있음 + return "/post/post-update"; + } + + @RequestMapping("/posts/{id}") + public String postDetails(@PathVariable Long id) { + return "/post/post-details"; + } +} diff --git a/src/main/java/com/demo/domain/Authority.java b/src/main/java/com/demo/domain/Authority.java new file mode 100644 index 0000000..489f153 --- /dev/null +++ b/src/main/java/com/demo/domain/Authority.java @@ -0,0 +1,50 @@ +package com.demo.domain; + +import jakarta.persistence.*; +import lombok.*; + +/** + * 사용자가 가지는 권한 엔티티 + * + *

    현재는 두 가지 권한만 존재한다.

    + *
  • ROLE_USER
  • + *
  • ROLE_ADMIN
  • + * + * @author duskafka + * */ +@Entity +@Table(name = "authority") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Authority { + + @Id + @Column(name = "authority_name", length = 50) + @Enumerated(EnumType.STRING) // Enum 값을 DB에 저장할 때 문자열로 저장하게 했습니다 + private Role role; + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Authority authority = (Authority) o; + return role == authority.role; + } + + public Authority(Role role) { + this.role = role; + } + + public static Authority createRole(Role role) { + return new Authority(role); + } + + public String getAuthorityName() { + return role.name(); // Role enum 값을 문자열로 반환 + } + + @Override + public int hashCode() { + return role.hashCode(); + } +} \ No newline at end of file diff --git a/src/main/java/com/demo/domain/Post.java b/src/main/java/com/demo/domain/Post.java new file mode 100644 index 0000000..8ad7a85 --- /dev/null +++ b/src/main/java/com/demo/domain/Post.java @@ -0,0 +1,63 @@ +package com.demo.domain; + +import com.demo.dto.request.PostUpdateRequest; +import com.demo.dto.response.PostResponse; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.AllArgsConstructor; + + +import java.time.ZoneOffset; +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class Post { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + private String title; + private String content; + private String description; + + @ManyToOne + @JoinColumn(name = "student_id") + private Student student; + + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + + @PrePersist + public void prePersist() { + this.createdAt = LocalDateTime.now(ZoneOffset.UTC); + this.updatedAt = LocalDateTime.now(ZoneOffset.UTC); + } + + // 게시글 수정 시 수정 시간 업데이트 + @PreUpdate + public void preUpdate() { + this.updatedAt = LocalDateTime.now(ZoneOffset.UTC); + } + + public void update(PostUpdateRequest request) { + this.title = request.getTitle(); + this.content = request.getContent(); + this.description = request.getDescription(); + } + + public Post(String title, String content, String description, Student student) { + this.title = title; + this.content = content; + this.description = description; + this.student = student; + } + + public PostResponse toResponse() { + return PostResponse.fromPost(this); + } +} \ No newline at end of file diff --git a/src/main/java/com/demo/domain/Role.java b/src/main/java/com/demo/domain/Role.java new file mode 100644 index 0000000..be7eb2f --- /dev/null +++ b/src/main/java/com/demo/domain/Role.java @@ -0,0 +1,25 @@ +package com.demo.domain; + +public enum Role { + ROLE_USER("ROLE_USER"), + ROLE_ADMIN("ROLE_ADMIN"); + + private final String roleName; + + Role(String roleName) { + this.roleName = roleName; + } + + public String getRoleName() { + return roleName; + } + + public static Role fromString(String roleName) { + for (Role role : Role.values()) { + if (role.getRoleName().equals(roleName)) { + return role; + } + } + throw new IllegalArgumentException("Unknown role: " + roleName); + } +} \ No newline at end of file diff --git a/src/main/java/com/demo/domain/Student.java b/src/main/java/com/demo/domain/Student.java index 15b48f0..5593f23 100644 --- a/src/main/java/com/demo/domain/Student.java +++ b/src/main/java/com/demo/domain/Student.java @@ -1,52 +1,104 @@ package com.demo.domain; +import com.demo.domain.converter.PasswordEncodeConverter; import com.demo.dto.request.StudentCreateRequest; import com.demo.dto.response.StudentResponse; import com.demo.dto.request.StudentUpdateRequest; import jakarta.persistence.*; -import lombok.AccessLevel; -import lombok.Builder; -import lombok.NoArgsConstructor; -import lombok.ToString; +import lombok.*; + +import java.util.HashSet; +import java.util.Set; @Entity @NoArgsConstructor(access = AccessLevel.PROTECTED) @ToString +@Getter public class Student { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "student_id") private Long id; - @Column(name = "student_name", nullable = true, length = 50) + @Column(name = "student_name", length = 50) private String name; - @Column(name = "student_age", nullable = true, length = 3) - private Long age; + @Column(name = "student_number", length = 10) + private Long studentNumber; + + @Column(name = "student_phone", length = 11) + private String phoneNumber; + + @Column(name = "user_id", length = 25) + private String loginId; + + @Column(name = "user_password", nullable = false) + @Convert(converter = PasswordEncodeConverter.class) + private String password; + + @Column(name = "user_email", length = 50) + private String email; + + @ManyToMany + @JoinTable( + name = "student_authority", + joinColumns = {@JoinColumn(name = "student_id", referencedColumnName = "student_id")}, + inverseJoinColumns = {@JoinColumn(name = "authority_name", referencedColumnName = "authority_name")} + ) + private Set authorities = new HashSet<>(); + + public void addAuthority(Authority authority) { + this.authorities.add(authority); + } + + /** + * 권한 보유 여부 반환 + * 기존 구현은 noneMatch를 사용해 "권한이 없을 때 true"가 되는 역동작이었음. + * → anyMatch로 수정하여 "권한이 있을 때 true"가 되도록 정상화. + */ + public boolean hasRole(Role role) { + return this.getAuthorities().stream() + .anyMatch(authority -> authority.getRole().equals(role)); // <-- 수정: noneMatch -> anyMatch + } @Builder(access = AccessLevel.PRIVATE) - public Student(String name, Long age) { + public Student(String name, Long studentNumber, String phoneNumber, String loginId, String password, String email) { this.name = name; - this.age = age; + this.studentNumber = studentNumber; + this.phoneNumber = phoneNumber; + this.loginId = loginId; + this.password = password; + this.email = email; } - public static Student toEntity(StudentCreateRequest request){ + public static Student toEntity(StudentCreateRequest request) { return Student.builder() .name(request.getName()) - .age(request.getAge()) + .studentNumber(request.getStudentNumber()) + .phoneNumber(request.getPhoneNumber()) + .loginId(request.getUserId()) + .password(request.getPassword()) + .email(request.getEmail()) .build(); } - public void update(StudentUpdateRequest request){ + public void update(StudentUpdateRequest request) { this.name = request.getName(); - this.age = request.getAge(); + this.studentNumber = request.getStudentNumber(); + this.phoneNumber = request.getPhoneNumber(); + this.loginId = request.getUserId(); + this.password = request.getPassword(); + this.email = request.getEmail(); } - public StudentResponse toResponse(){ + public StudentResponse toResponse() { return StudentResponse.builder() .id(this.id) .name(this.name) - .age(this.age) + .studentNumber(this.studentNumber) + .phoneNumber(this.phoneNumber) + .userId(this.loginId) + .password(this.password) .build(); } } \ No newline at end of file diff --git a/src/main/java/com/demo/domain/converter/PasswordEncodeConverter.java b/src/main/java/com/demo/domain/converter/PasswordEncodeConverter.java new file mode 100644 index 0000000..3e6573d --- /dev/null +++ b/src/main/java/com/demo/domain/converter/PasswordEncodeConverter.java @@ -0,0 +1,34 @@ +package com.demo.domain.converter; + +import com.demo.domain.vo.Password; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; + +/** + * Programmer 엔티티에서 password를 암호화하는 컨버터 + * + * @author duskafka + * */ +@Converter +@Component +@RequiredArgsConstructor(access = AccessLevel.PROTECTED) +public class PasswordEncodeConverter implements AttributeConverter { + private final PasswordEncoder passwordEncoder; + + @Override + public String convertToDatabaseColumn(String attribute) { + var password = new Password(attribute); + password.setEncodePassword(passwordEncoder.encode(attribute)); + + return password.getPassword(); + } + + @Override + public String convertToEntityAttribute(String dbData) { + return dbData; + } +} \ No newline at end of file diff --git a/src/main/java/com/demo/domain/vo/Password.java b/src/main/java/com/demo/domain/vo/Password.java new file mode 100644 index 0000000..64529b5 --- /dev/null +++ b/src/main/java/com/demo/domain/vo/Password.java @@ -0,0 +1,29 @@ +package com.demo.domain.vo; + +import lombok.Getter; + +import java.util.regex.Pattern; + +/** + * Programmer 엔티티에서 password가 유효한 형식인지 검사하고 저장하기 위한 VO + * + *
  • 최소 8글자, 글자 1개, 숫자 1개, 특수문자 1개
  • + * + * @author duskafka + * */ +@Getter +public class Password { + private String password; + + public Password(String password) { + validatePassword(password); + } + + private void validatePassword(String password) { + this.password = password; + } + + public void setEncodePassword(String encodePassword) { + this.password = encodePassword; + } +} \ No newline at end of file diff --git a/src/main/java/com/demo/dto/request/LoginRequest.java b/src/main/java/com/demo/dto/request/LoginRequest.java new file mode 100644 index 0000000..3e676b2 --- /dev/null +++ b/src/main/java/com/demo/dto/request/LoginRequest.java @@ -0,0 +1,9 @@ +package com.demo.dto.request; + +import lombok.Data; + +@Data +public class LoginRequest { + String loginId; + String password; +} \ No newline at end of file diff --git a/src/main/java/com/demo/dto/request/PostCreateRequest.java b/src/main/java/com/demo/dto/request/PostCreateRequest.java new file mode 100644 index 0000000..6adb563 --- /dev/null +++ b/src/main/java/com/demo/dto/request/PostCreateRequest.java @@ -0,0 +1,14 @@ +package com.demo.dto.request; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class PostCreateRequest { + + private String title; + private String content; + private String description; + private String studentId; +} \ No newline at end of file diff --git a/src/main/java/com/demo/dto/request/PostUpdateRequest.java b/src/main/java/com/demo/dto/request/PostUpdateRequest.java new file mode 100644 index 0000000..fdf4de8 --- /dev/null +++ b/src/main/java/com/demo/dto/request/PostUpdateRequest.java @@ -0,0 +1,13 @@ +package com.demo.dto.request; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class PostUpdateRequest { + + private String title; + private String content; + private String description; +} \ No newline at end of file diff --git a/src/main/java/com/demo/dto/request/StudentCreateRequest.java b/src/main/java/com/demo/dto/request/StudentCreateRequest.java index 33e9247..b58a6c1 100644 --- a/src/main/java/com/demo/dto/request/StudentCreateRequest.java +++ b/src/main/java/com/demo/dto/request/StudentCreateRequest.java @@ -5,5 +5,9 @@ @Getter public class StudentCreateRequest { private String name; - private Long age; + private Long studentNumber; + private String phoneNumber; + private String userId; + private String password; + private String email; } diff --git a/src/main/java/com/demo/dto/request/StudentUpdateRequest.java b/src/main/java/com/demo/dto/request/StudentUpdateRequest.java index 238332d..621fd09 100644 --- a/src/main/java/com/demo/dto/request/StudentUpdateRequest.java +++ b/src/main/java/com/demo/dto/request/StudentUpdateRequest.java @@ -5,5 +5,9 @@ @Getter public class StudentUpdateRequest { private String name; - private Long age; + private Long studentNumber; + private String phoneNumber; + private String userId; + private String password; + private String email; } diff --git a/src/main/java/com/demo/dto/response/PostResponse.java b/src/main/java/com/demo/dto/response/PostResponse.java new file mode 100644 index 0000000..b1c4b3c --- /dev/null +++ b/src/main/java/com/demo/dto/response/PostResponse.java @@ -0,0 +1,28 @@ +package com.demo.dto.response; + +import lombok.Getter; +import lombok.AllArgsConstructor; + +@Getter +@AllArgsConstructor +public class PostResponse { + + private Long id; // 게시글 ID + private String title; // 게시글 제목 + private String content; // 게시글 내용 + private String description; // 게시글 설명 + private String createdAt; // 게시글 작성 시간 + private String updatedAt; // 게시글 수정 시간 + + + public static PostResponse fromPost(com.demo.domain.Post post) { + return new PostResponse( + post.getId(), + post.getTitle(), + post.getContent(), + post.getDescription(), + post.getCreatedAt() != null ? post.getCreatedAt().toString() : null, + post.getUpdatedAt() != null ? post.getUpdatedAt().toString() : null + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/demo/dto/response/PostWithUserNameResponse.java b/src/main/java/com/demo/dto/response/PostWithUserNameResponse.java new file mode 100644 index 0000000..16ab365 --- /dev/null +++ b/src/main/java/com/demo/dto/response/PostWithUserNameResponse.java @@ -0,0 +1,29 @@ +package com.demo.dto.response; + +import com.demo.domain.Post; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class PostWithUserNameResponse { + private Long id; // 게시글 ID + private String title; // 게시글 제목 + private String content; // 게시글 내용 + private String description; // 게시글 설명 + private String createdAt; // 게시글 작성 시간 + private String updatedAt; // 게시글 수정 시간 + private String username; + + public static PostWithUserNameResponse toResponse(Post post) { + return PostWithUserNameResponse.builder() + .id(post.getId()) + .title(post.getTitle()) + .content(post.getContent()) + .description(post.getDescription()) + .createdAt(post.getCreatedAt().toString()) + .updatedAt(post.getUpdatedAt().toString()) + .username(post.getStudent().getName()) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/demo/dto/response/StudentResponse.java b/src/main/java/com/demo/dto/response/StudentResponse.java index 70d29b7..c0d6b0e 100644 --- a/src/main/java/com/demo/dto/response/StudentResponse.java +++ b/src/main/java/com/demo/dto/response/StudentResponse.java @@ -1,5 +1,6 @@ package com.demo.dto.response; +import com.demo.domain.Student; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -10,5 +11,19 @@ public class StudentResponse { private Long id; private String name; - private Long age; + private Long studentNumber; + private String phoneNumber; + private String userId; + private String password; + + public static StudentResponse fromStudent(Student student) { + return StudentResponse.builder() + .id(student.getId()) + .name(student.getName()) + .studentNumber(student.getStudentNumber()) + .phoneNumber(student.getPhoneNumber()) + .userId(student.getLoginId()) + .password(null) + .build(); + } } \ No newline at end of file diff --git a/src/main/java/com/demo/exception/BusinessException.java b/src/main/java/com/demo/exception/BusinessException.java new file mode 100644 index 0000000..bf02534 --- /dev/null +++ b/src/main/java/com/demo/exception/BusinessException.java @@ -0,0 +1,47 @@ +package com.demo.exception; + +import com.demo.exception.dto.ErrorMessage; +import lombok.Getter; + +/** + * 애플리케이션에서 발생하는 예외들을 래핑해서 {@code GlobalExceptionHandler}에서 차리하기 위한 예외. + * + *
      + *
    • 애플리케이션에서 발생할 수 있는 모든 예외는 이 클래스를 상속한다.
    • + *
    + * */ +@Getter +public class BusinessException extends RuntimeException { + private final ErrorMessage errorMessage; + + /** + * ErrorMessage를 사용하여 예외를 생성합니다. + * + * @param message 오류 메시지 + * */ + public BusinessException(ErrorMessage message) { + super(message.getMessage()); + this.errorMessage = message; + } + + /** + * ErrorMessage와 추가 이유를 사용하여 예외를 생성합니다. + * + * @param message 오휴 메시지 + * @param reason 추가 이유 + * */ + public BusinessException(ErrorMessage message, String reason) { + super(reason); + this.errorMessage = message; + } + + /** + * 이유만으로 예외를 생성한다. + * + * @param reason 예외 발생 이유 + * */ + public BusinessException(String reason) { + super(reason); + this.errorMessage = ErrorMessage.INTERNAL_SERVER_ERROR; + } +} \ No newline at end of file diff --git a/src/main/java/com/demo/exception/GlobalExceptionHandler.java b/src/main/java/com/demo/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..5b699f8 --- /dev/null +++ b/src/main/java/com/demo/exception/GlobalExceptionHandler.java @@ -0,0 +1,36 @@ +package com.demo.exception; + +import com.demo.exception.dto.ErrorMessage; +import com.demo.exception.dto.ErrorResponseDto; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.stream.Collectors; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(BusinessException.class) + protected ResponseEntity handleBusinessException(BusinessException e) { + var errorMessage = e.getErrorMessage(); + + log.error("[ERROR] BusinessException -> {}", errorMessage.getMessage()); + + return ErrorResponseDto.of(errorMessage); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + protected ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { + String detailedErrorMessage = e.getBindingResult().getFieldErrors().stream() + .map(error -> error.getField() + ": " + error.getDefaultMessage()) + .collect(Collectors.joining(" ")); + + log.error("[ERROR] MethodArgumentNotValidException -> {}", detailedErrorMessage); + + return ErrorResponseDto.of(ErrorMessage.INVALID_REQUEST_PARAMETER); + } +} \ No newline at end of file diff --git a/src/main/java/com/demo/exception/dto/ErrorDto.java b/src/main/java/com/demo/exception/dto/ErrorDto.java new file mode 100644 index 0000000..cb1db4b --- /dev/null +++ b/src/main/java/com/demo/exception/dto/ErrorDto.java @@ -0,0 +1,39 @@ +package com.demo.exception.dto; + +import org.springframework.validation.FieldError; + +import java.util.ArrayList; +import java.util.List; + +/** + * 에러가 발생했을 때 에러를 응답하기 위한 DTO + * + * @author duskafka + * */ +public class ErrorDto { + private final int status; + private final String message; + private List fieldErrors = new ArrayList<>(); + + public ErrorDto(int status, String message) { + this.status = status; + this.message = message; + } + + public int getStatus() { + return status; + } + + public String getMessage() { + return message; + } + + public void addFieldError(String objectName, String path, String message) { + FieldError error = new FieldError(objectName, path, message); + fieldErrors.add(error); + } + + public List getFieldErrors() { + return fieldErrors; + } +} \ No newline at end of file diff --git a/src/main/java/com/demo/exception/dto/ErrorMessage.java b/src/main/java/com/demo/exception/dto/ErrorMessage.java new file mode 100644 index 0000000..b6ec847 --- /dev/null +++ b/src/main/java/com/demo/exception/dto/ErrorMessage.java @@ -0,0 +1,76 @@ +package com.demo.exception.dto; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +/** + * 에러가 발생했을 때 HTTP 상태 코드와 메시지를 반환하는 열거형 + * + *
  • 열거형의 접미어로 Exception을 붙이지 않는다.
  • + *
  • 네이밍 일관성을 가지게 한다. NOT_FOUND_XXX, INVALID_XXX, FAILED_XXX, DUPLICATE_XXX 등으로 통일
  • + * + * @author duskafka + */ +@Getter +@RequiredArgsConstructor(access = AccessLevel.PROTECTED) +public enum ErrorMessage { + //Server + INVALID_REQUEST_PARAMETER(HttpStatus.BAD_REQUEST, "잘못된 요청 입니다."), + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "예기치 못한 에러가 발생했습니다."), + + //PROGRAMMER + DUPLICATE_PROGRAMMER(HttpStatus.CONFLICT, "중복된 사용자가 존재합니다."), + PROGRAMMER_NOT_CREATED(HttpStatus.BAD_REQUEST, "사용자 생성에 실패했습니다."), + INVALID_ID(HttpStatus.BAD_REQUEST, "존재하지 않는 ID입니다."), + NOT_FOUND_STUDENT(HttpStatus.NOT_FOUND, "요청한 사용자를 찾을 수 없습니다."), + INVALID_PASSWORD_REGEX(HttpStatus.CONFLICT, "유효한 비밀번호 형식이 아닙니다."), + INVALID_EMAIL_REGEX(HttpStatus.CONFLICT, "유효한 이메일 형식이 아닙니다."), + INVALID_NAME_REGEX(HttpStatus.CONFLICT, "유효한 이름 형식이 아닙니다."), + LOGIN_FAIL(HttpStatus.BAD_REQUEST, "ID/PW 가 일치하지 않습니다."), + WRONG_ID(HttpStatus.BAD_REQUEST, "ID 가 일치하지 않습니다."), + WRONG_PASSWORD(HttpStatus.BAD_REQUEST, "Password 가 일치하지 않습니다"), + UPDATE_FAILED(HttpStatus.NOT_FOUND, "업데이트에 실패했습니다."), + + + //ANSWER + NOT_FOUND_ANSWER(HttpStatus.NOT_FOUND, "풀이를 찾을 수 없습니다."), + ANSWER_AND_PROGRAMMER_DO_NOT_MATCH(HttpStatus.BAD_REQUEST, "해당 풀이는 사용자가 작성한 풀이가 아닙니다."), + + //JWT + INVALID_JWT(HttpStatus.UNAUTHORIZED, "유효하지 않은 JWT 토큰입니다."), + NO_TOKEN_IN_HEADER(HttpStatus.UNAUTHORIZED, "헤더에 토큰이 없습니다."), + EXPIRED_JWT(HttpStatus.UNAUTHORIZED, "토큰 유효기간이 만료되었습니다."), + SECURITY_TOKEN(HttpStatus.UNAUTHORIZED, "잘못된 JWT 서명입니다."), + REFRESH_TOKEN_IS_NULL(HttpStatus.BAD_REQUEST, "갱신할 리프레쉬 토큰이 없습니다."), + UN_MATCH_JTI(HttpStatus.BAD_REQUEST, "JTI가 일치하지 않습니다."), + NOT_FOUND_REFRESH_TOKEN(HttpStatus.BAD_REQUEST, "요청한 리프레시 토큰을 찾을 수 없습니다."), + REFRESH_TOKEN_OVER_MAX(HttpStatus.CONFLICT, "리프레시 토큰을 더이상 발급할 수 없습니다."), + REFRESH_TOKEN_REVOKED(HttpStatus.CONFLICT, "무효화된 리프레쉬 토큰입니다."), + + //RANKING + NO_RANKING(HttpStatus.NO_CONTENT, "금일 랭킹이 존재하지 않습니다."), + JOB_ALREADY_EXECUTION(HttpStatus.INTERNAL_SERVER_ERROR, "이미 스케줄러가 실행 중입니다."), + JOB_BUILDER_BUILD_INVALID_PARAMETERS(HttpStatus.INTERNAL_SERVER_ERROR, "잘못된 Job 파라미터가 생성되었습니다."), + RANKING_ILLEGAL_TYPE(HttpStatus.INTERNAL_SERVER_ERROR, "Rank 데이터 타입이 유효하지 않습니다."), + RANKING_COUNT(HttpStatus.INTERNAL_SERVER_ERROR, "랭킹 카운트 처리 중 예외가 발생했습니다."), + FAILED_SAVE_RANKING_IN_REDIS(HttpStatus.INTERNAL_SERVER_ERROR, "Redis에 저장하지 못했습니다."), + + //Redis + REDIS_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "Redis 서버 오류가 발생했습니다."), + FAILED_DELETE_REFRESH_TOKEN(HttpStatus.INTERNAL_SERVER_ERROR, "Redis에서 리프레시 토큰을 삭제하는 중 오류가 발생했습니다."), + FAILED_FIND_REFRESH_TOKEN(HttpStatus.BAD_REQUEST, "Redis에서 리프레시 토큰을 찾지 못했습니다."), + + //EMAIL + NOT_FOUND_EMAIL_VERIFICATION(HttpStatus.NOT_FOUND, "데이터베이스에서 이메일 인증 토큰을 조회하지 못했습니다"), + EMAIL_MESSAGING(HttpStatus.INTERNAL_SERVER_ERROR, "이메일 생성 중 오류가 발생했습니다."), + EMAIL_NOT_VERIFICATION(HttpStatus.BAD_REQUEST, "인증된 이메일이 아닙니다."), + DUPLICATE_EMAIL_VERIFICATION(HttpStatus.BAD_REQUEST, "이미 인증이 완료되었거나 인증이 요첟된 이메일입니디."), + ILLEGAL_EMAIL_REGEX(HttpStatus.BAD_REQUEST, "유효한 이메일 형식이 아니기에 이메일 인증 요청에 실패했습니다."), + ; + + + private final HttpStatus status; + private final String message; +} \ No newline at end of file diff --git a/src/main/java/com/demo/exception/dto/ErrorResponseDto.java b/src/main/java/com/demo/exception/dto/ErrorResponseDto.java new file mode 100644 index 0000000..4f10015 --- /dev/null +++ b/src/main/java/com/demo/exception/dto/ErrorResponseDto.java @@ -0,0 +1,31 @@ +package com.demo.exception.dto; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; + +import java.time.LocalDateTime; + +/** + * 에러가 발생했을 때 GlobalExceptionHandler에서 응답에 사용하는 DTO + * + * @author duskafka + * */ +@Getter +@RequiredArgsConstructor(access = AccessLevel.PROTECTED) +public class ErrorResponseDto { + private final String code; + private final String message; + private final LocalDateTime serverDateTime; + + public static ResponseEntity of(ErrorMessage message) { + return ResponseEntity + .status(message.getStatus()) + .body(new ErrorResponseDto( + message.getStatus().toString(), + message.getMessage(), + LocalDateTime.now()) + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/demo/exception/post/PostNotFoundException.java b/src/main/java/com/demo/exception/post/PostNotFoundException.java new file mode 100644 index 0000000..b94da2b --- /dev/null +++ b/src/main/java/com/demo/exception/post/PostNotFoundException.java @@ -0,0 +1,7 @@ +package com.demo.exception.post; + +public class PostNotFoundException extends RuntimeException { + public PostNotFoundException(String PostNotFound) { + super("게시글을 찾을 수 없습니다"); + } +} \ No newline at end of file diff --git a/src/main/java/com/demo/exception/post/UnauthorizedAccessException.java b/src/main/java/com/demo/exception/post/UnauthorizedAccessException.java new file mode 100644 index 0000000..878ec07 --- /dev/null +++ b/src/main/java/com/demo/exception/post/UnauthorizedAccessException.java @@ -0,0 +1,7 @@ +package com.demo.exception.post; + +public class UnauthorizedAccessException extends RuntimeException { + public UnauthorizedAccessException(String nonePermission) { + super("이 게시글에 대한 접근 권한이 없습니다."); + } +} \ No newline at end of file diff --git a/src/main/java/com/demo/exception/student/NotFoundStudentException.java b/src/main/java/com/demo/exception/student/NotFoundStudentException.java new file mode 100644 index 0000000..cff2055 --- /dev/null +++ b/src/main/java/com/demo/exception/student/NotFoundStudentException.java @@ -0,0 +1,18 @@ +package com.demo.exception.student; + +import com.demo.exception.BusinessException; +import com.demo.exception.dto.ErrorMessage; + +public class NotFoundStudentException extends BusinessException { + public NotFoundStudentException(ErrorMessage message) { + super(message); + } + + public NotFoundStudentException(ErrorMessage message, String reason) { + super(message, reason); + } + + public NotFoundStudentException(String reason) { + super(reason); + } +} \ No newline at end of file diff --git a/src/main/java/com/demo/jwt/JwtAccessDeniedHandler.java b/src/main/java/com/demo/jwt/JwtAccessDeniedHandler.java new file mode 100644 index 0000000..74617b7 --- /dev/null +++ b/src/main/java/com/demo/jwt/JwtAccessDeniedHandler.java @@ -0,0 +1,39 @@ +package com.demo.jwt; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import java.io.IOException; + +/** + * JWT 토큰이 없거나 권한이 없어서 요청이 거부된 경우 사용되는 핸들러 + * + * @author duskafka + * */ +@Slf4j +@Component +public class JwtAccessDeniedHandler implements AccessDeniedHandler { + /** + * 인증된 사용자가 필요한 권한 없이 보호된 리소스에 접근하려 할 때 호출됩니다. + * 이 메서드는 HTTP 응답을 '403 Forbidden' 상태로 설정하고, 접근 거부 발생을 로깅합니다. + * + * @param request 접근이 거부된 요청을 나타내는 {@link HttpServletRequest} 객체. + * @param response 클라이언트에게 응답을 보낼 {@link HttpServletResponse} 객체. + * @param accessDeniedException 접근 거부를 초래한 {@link AccessDeniedException} 객체. + * @throws IOException 응답을 보내는 도중 I/O 오류가 발생할 경우. + */ + @Override + public void handle( + HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException + ) throws IOException { + log.warn("[JwtAccessDeniedHandler] Access Denied for request URI: {}. Error message: {}", request.getRequestURI(), accessDeniedException.getMessage()); + response.sendError(HttpServletResponse.SC_FORBIDDEN, "Forbidden: You do not have sufficient permissions to access this resource."); + } +} \ No newline at end of file diff --git a/src/main/java/com/demo/jwt/JwtAuthenticationEntryPoint.java b/src/main/java/com/demo/jwt/JwtAuthenticationEntryPoint.java new file mode 100644 index 0000000..80a2731 --- /dev/null +++ b/src/main/java/com/demo/jwt/JwtAuthenticationEntryPoint.java @@ -0,0 +1,40 @@ +package com.demo.jwt; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import java.io.IOException; + +/** + * JWT 토큰을 가지고 요청을 했지만 유효한 권한 증명이 없어 요청이 거부되었을 때 호출되는 클래스 + * + * @author duskafka + * */ +@Slf4j +@Component +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + /** + * 클라이언트가 유효한 자격 증명 없이 보호된 리소스에 접근하려 할 때 호출됩니다. + * 이 메서드는 HTTP 응답을 '401 Unauthorized' 상태로 설정하고, 인증 실패를 로깅합니다. + * + * @param request 인증이 실패한 요청을 나타내는 {@link HttpServletRequest} 객체. + * @param response 클라이언트에게 응답을 보낼 {@link HttpServletResponse} 객체. + * @param authException 인증 실패를 초래한 {@link AuthenticationException} 객체. + * @throws IOException 응답을 보내는 도중 I/O 오류가 발생할 경우. + */ + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException + ) throws IOException { + // 인증 실패 발생 시, 요청 URI와 함께 경고 로그를 남깁니다. + log.warn("[JwtAuthenticationEntryPoint] Authentication failed for request URI: {}. Error message: {}", request.getRequestURI(), authException.getMessage()); + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized: Authentication required."); + } +} \ No newline at end of file diff --git a/src/main/java/com/demo/jwt/JwtFilter.java b/src/main/java/com/demo/jwt/JwtFilter.java new file mode 100644 index 0000000..d25a019 --- /dev/null +++ b/src/main/java/com/demo/jwt/JwtFilter.java @@ -0,0 +1,67 @@ +package com.demo.jwt; + +import com.demo.config.properties.JWTProperties; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +/** + * 이 애플리케이션에서 모든 요청에 대해서 기본적으로 수행될 JWT 필터 + *
  • 헤더에서 토큰을 가져와 유효한 자격을 가지고 있다면 SecurityContext에 저장한다
  • + *
  • 애플리케이션은 무상태로 구성되었기 때문에 이 저장 정보는 한 스레드에서만 가지고 있는다
  • + * + * @author duskafka + * */ +@Slf4j +public class JwtFilter extends OncePerRequestFilter { // 모든 요청에 대해 동일한 로직을 수행하는 일반 필터 + + private final TokenProvider tokenProvider; + private final TokenValidator tokenValidator; + private final String ACCESS_TOKEN_HEADER; + private final String BEARER_HEADER; + + public JwtFilter(TokenProvider tokenProvider, TokenValidator tokenValidator, JWTProperties jwtProperties) { + this.tokenProvider = tokenProvider; + this.tokenValidator = tokenValidator; + this.ACCESS_TOKEN_HEADER = jwtProperties.accessTokenHeader(); + this.BEARER_HEADER = jwtProperties.bearerHeader(); + } + + /** + * 요청이 들어오면 헤더의 토큰을 검사하고 검사 후 SecurityContextHolder에 정보를 저장해준다. + * + * @param request 토큰을 담고 있는 헤더를 가져오기 위해 사용 + * */ + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + + String token = resolveToken(request); + if (token != null && tokenValidator.validateToken(token)) { + Authentication authentication = tokenProvider.getAuthentication(token); + log.info(authentication.toString()); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + filterChain.doFilter(request, response); + } + + private String resolveToken(HttpServletRequest request) { + String bearer = request.getHeader(ACCESS_TOKEN_HEADER); + if (bearer != null && bearer.startsWith(BEARER_HEADER)) { + return bearer.substring(7); + } + return null; + } +} \ No newline at end of file diff --git a/src/main/java/com/demo/jwt/TokenProvider.java b/src/main/java/com/demo/jwt/TokenProvider.java new file mode 100644 index 0000000..364bdf7 --- /dev/null +++ b/src/main/java/com/demo/jwt/TokenProvider.java @@ -0,0 +1,114 @@ +package com.demo.jwt; + +import com.demo.config.properties.JWTProperties; +import io.jsonwebtoken.*; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import lombok.extern.slf4j.Slf4j; +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.userdetails.User; +import org.springframework.stereotype.Component; + +import java.security.Key; +import java.time.Duration; +import java.time.Instant; +import java.util.*; +import java.util.stream.Collectors; + +/** + * 토큰 발급을 위한 서비스 + * + * @author duskafka + * */ +@Slf4j +@Component +public class TokenProvider { + private final String AUTHORITIES_KEY; // JWT 클레임에서 권한 정보를 추출할 때 사용하는 키 + private final Key KEY; // JWT 서명에 사용되는 Secret Key + + private final long ACCESS_TOKEN_VALIDATE_HOUR; // 토큰의 유효 시간 (시간 단위) + + public TokenProvider( + JWTProperties jwtProperties + ) { + this.AUTHORITIES_KEY = jwtProperties.authorityKey(); + this.ACCESS_TOKEN_VALIDATE_HOUR = Duration.ofHours(jwtProperties.accessTokenValidityInHour()).toMillis(); + byte[] keyBytes = Decoders.BASE64.decode(jwtProperties.secret()); // Secret 값을 Base64 디코딩하여 HMAC SHA 키로 변환합니다. + this.KEY = Keys.hmacShaKeyFor(keyBytes); + } + + + public String createAccessToken(Authentication authentication) { + Claims claims = Jwts.claims().setSubject(authentication.getName()); + + List roles = authentication.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.toList()); + + claims.put(AUTHORITIES_KEY, roles); + + Date now = new Date(); + Date expiry = new Date(now.getTime() + ACCESS_TOKEN_VALIDATE_HOUR); + + return Jwts.builder() + .setClaims(claims) + .setIssuedAt(now) + .setExpiration(expiry) + .signWith(SignatureAlgorithm.HS256, KEY) + .compact(); + } + + public Authentication getAuthentication(String token) { + try { + Claims claims = Jwts + .parserBuilder() + .setSigningKey(KEY) + .build() + .parseClaimsJws(token) + .getBody(); + + // 클레임에서 권한 정보 추출 및 GrantedAuthority 객체로 변환 + Collection authorities = extractAuthorities(claims); + + User principal = new User(claims.getSubject(), "", authorities); + + return new UsernamePasswordAuthenticationToken(principal, token, authorities); + + } catch (JwtException | IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid JWT token", e); + } + } + + private Collection extractAuthorities(Claims claims) { + Object authoritiesObj = claims.get(AUTHORITIES_KEY); + + if (authoritiesObj == null) { + return Collections.emptyList(); + } + + if (authoritiesObj instanceof List) { + return ((List) authoritiesObj).stream() + .map(Object::toString) + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toList()); + } + + String authoritiesStr = authoritiesObj.toString(); + + // 대괄호 제거 (예: "[ROLE_USER]" -> "ROLE_USER") + authoritiesStr = authoritiesStr.replaceAll("^\\[|\\]$", ""); + + if (authoritiesStr.trim().isEmpty()) { + return Collections.emptyList(); + } + + return Arrays.stream(authoritiesStr.split(",")) + .map(String::trim) + .filter(role -> !role.isEmpty()) + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toList()); + } +} \ No newline at end of file diff --git a/src/main/java/com/demo/jwt/TokenValidator.java b/src/main/java/com/demo/jwt/TokenValidator.java new file mode 100644 index 0000000..92d4ae6 --- /dev/null +++ b/src/main/java/com/demo/jwt/TokenValidator.java @@ -0,0 +1,50 @@ +package com.demo.jwt; + +import com.demo.config.properties.JWTProperties; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.UnsupportedJwtException; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.security.Key; + +/** + * 토큰의 유효성을 검증하기 위한 클래스 + * + * @author duskafka + * */ +@Slf4j +@Service +public class TokenValidator { + private final Key KEY; + + public TokenValidator(JWTProperties jwtProperties) { + byte[] keyBytes = Decoders.BASE64.decode(jwtProperties.secret()); // Secret 값을 Base64 디코딩하여 HMAC SHA 키로 변환합니다. + this.KEY = Keys.hmacShaKeyFor(keyBytes); + } + + public boolean validateToken(String token) { + try { + log.debug("[TokenProvider] validateToken({})", token); + Jwts.parserBuilder().setSigningKey(KEY).build().parseClaimsJws(token); // 토큰 파싱 및 검증 + log.info("[TokenProvider] Token validation successful."); + return true; + } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) { + log.warn("[TokenProvider] Invalid JWT signature or malformed JWT token. Token: {}, Error: {}", token, e.getMessage()); + return false; + } catch (ExpiredJwtException e) { + log.warn("[TokenProvider] Expired JWT token. Token: {}, Error: {}", token, e.getMessage()); + return false; + } catch (UnsupportedJwtException e) { + log.warn("[TokenProvider] Unsupported JWT token. Token: {}, Error: {}", token, e.getMessage()); + return false; + } catch (IllegalArgumentException e) { + log.warn("[TokenProvider] JWT token is illegal or invalid argument. Token: {}, Error: {}", token, e.getMessage()); + return false; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/demo/repository/AuthorityRepository.java b/src/main/java/com/demo/repository/AuthorityRepository.java new file mode 100644 index 0000000..f4c09f3 --- /dev/null +++ b/src/main/java/com/demo/repository/AuthorityRepository.java @@ -0,0 +1,13 @@ +package com.demo.repository; + +import com.demo.domain.Authority; +import com.demo.domain.Role; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface AuthorityRepository extends JpaRepository { + Optional findByRole(Role role); +} \ No newline at end of file diff --git a/src/main/java/com/demo/repository/PostRepository.java b/src/main/java/com/demo/repository/PostRepository.java new file mode 100644 index 0000000..a4c3e3c --- /dev/null +++ b/src/main/java/com/demo/repository/PostRepository.java @@ -0,0 +1,11 @@ +package com.demo.repository; + +import com.demo.domain.Post; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PostRepository extends JpaRepository { + // 제목에 특정 단어가 포함된 게시글을 찾는 메소드입니다 + Page findByTitleContaining(String search, Pageable pageable); +} \ No newline at end of file diff --git a/src/main/java/com/demo/repository/StudentRepository.java b/src/main/java/com/demo/repository/StudentRepository.java index b78a078..507f045 100644 --- a/src/main/java/com/demo/repository/StudentRepository.java +++ b/src/main/java/com/demo/repository/StudentRepository.java @@ -4,6 +4,12 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.util.Optional; + @Repository public interface StudentRepository extends JpaRepository { + Optional findOneWithAuthoritiesByLoginId(String loginId); + Optional findByLoginId(String loginId); + void deleteByLoginId(String loginId); + boolean existsByLoginId(String loginId); } \ No newline at end of file diff --git a/src/main/java/com/demo/service/CustomLoginService.java b/src/main/java/com/demo/service/CustomLoginService.java new file mode 100644 index 0000000..6a9b36f --- /dev/null +++ b/src/main/java/com/demo/service/CustomLoginService.java @@ -0,0 +1,76 @@ + package com.demo.service; + + import com.demo.domain.Authority; + import com.demo.domain.Student; + import com.demo.repository.StudentRepository; + import lombok.RequiredArgsConstructor; + import lombok.extern.slf4j.Slf4j; + import org.springframework.security.core.GrantedAuthority; + import org.springframework.security.core.authority.SimpleGrantedAuthority; + import org.springframework.security.core.userdetails.User; + 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; + import org.springframework.transaction.annotation.Transactional; + + import java.util.List; + import java.util.stream.Collectors; + + /** + * 스프링 시큐리티에서 로그인을 처리하기 위한 클래스. + * + * @author duskafka + * */ + @Slf4j + @Service + @RequiredArgsConstructor + public class CustomLoginService implements UserDetailsService { + private final StudentRepository repo; + + /** + * 로그인 요청이 들어올 시 로그인을 요청을 처리해주는 메소드. + * + *
  • 로그인 요청 외에 리프레쉬 토큰을 재발급할 때 사용자 정보를 가져오는 용도로도 사용된다.
  • + * + * @param username 로그인을 요청한 사용자의 {@code username} + * @return 로그인을 성공하여 사용자 정보가 담긴 UerDetails + * @see UserDetails 내부에 권한과 사용자 이름({@code username})을 담아서 비즈니스 로직을 처리할 수 있게 해준다. + */ + @Override + @Transactional + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + log.info("[UserDetailLoginService] loadUserByUsername({})", username); + Student student = repo.findOneWithAuthoritiesByLoginId(username) + .orElseThrow(() -> new UsernameNotFoundException(username)); + log.info(student.toString()); + + return createUser(student); + } + + /** + * Programmer 엔티티를 User로 매핑해주는 메소드 + * + * @param student 사용자 정보를 가진 엔티티 + * @return UserDetails를 상속한 클래스로 인증 정보를 가진다. + */ + private User createUser(Student student) { + return new User( + student.getLoginId(), + student.getPassword(), + createAuthorities(student) + ); + } + + /** + * Programmer 엔티티 내부에 있는 authorities를 GrantedAuthority로 매핑해 List로 반환하는 메소드. + * + * @param student authorities를 가지고 있는 엔티티 + * @return 매핑하여 반환되는 {@code List} + */ + private List createAuthorities(Student student) { + return student.getAuthorities().stream() + .map(authority -> new SimpleGrantedAuthority(authority.getAuthorityName())) + .collect(Collectors.toList()); + } + } \ No newline at end of file diff --git a/src/main/java/com/demo/service/PostService.java b/src/main/java/com/demo/service/PostService.java new file mode 100644 index 0000000..257f36c --- /dev/null +++ b/src/main/java/com/demo/service/PostService.java @@ -0,0 +1,89 @@ +package com.demo.service; + +import com.demo.domain.Role; +import com.demo.domain.Student; +import com.demo.domain.Post; +import com.demo.dto.request.PostCreateRequest; +import com.demo.dto.request.PostUpdateRequest; +import com.demo.dto.response.PostResponse; +import com.demo.dto.response.PostWithUserNameResponse; +import com.demo.exception.student.NotFoundStudentException; +import com.demo.exception.post.PostNotFoundException; +import com.demo.exception.post.UnauthorizedAccessException; +import com.demo.repository.PostRepository; +import com.demo.repository.StudentRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class PostService { + + private final PostRepository postRepository; + private final StudentRepository studentRepository; + + // 상세 조회는 작성자 이름이 포함된 DTO로 반환 + @Transactional(readOnly = true) + public PostWithUserNameResponse getPost(Long postId) { + Post post = postRepository.findById(postId) + .orElseThrow(() -> new PostNotFoundException("게시글을 찾을 수 없습니다")); + + return PostWithUserNameResponse.toResponse(post); + } + + @Transactional(readOnly = true) + public Page getAllPosts(Pageable pageable) { + return postRepository.findAll(pageable).map(PostWithUserNameResponse::toResponse); + } + + @Transactional(readOnly = true) + public Page searchPosts(String search, Pageable pageable) { + return postRepository.findByTitleContaining(search, pageable) + .map(PostWithUserNameResponse::toResponse); + } + + @Transactional + public PostResponse createPost(PostCreateRequest request, String studentId) { + Student student = studentRepository.findByLoginId(studentId) + .orElseThrow(() -> new NotFoundStudentException("학생을 찾을 수 없습니다")); + + Post post = new Post(request.getTitle(), request.getContent(), request.getDescription(), student); + postRepository.save(post); + + return post.toResponse(); + } + + @Transactional + public PostResponse updatePost(Long postId, PostUpdateRequest request, String studentId) { + Post post = postRepository.findById(postId) + .orElseThrow(() -> new PostNotFoundException("게시글을 찾을 수 없습니다")); + + Student student = studentRepository.findByLoginId(studentId) + .orElseThrow(() -> new NotFoundStudentException("학생을 찾을 수 없습니다")); + + if (!post.getStudent().getLoginId().equals(studentId) && !student.hasRole(Role.ROLE_ADMIN)) { + throw new UnauthorizedAccessException("권한이 없습니다"); + } + + post.update(request); + return post.toResponse(); + } + + @Transactional + public void deletePost(Long postId, String studentId) { + Post post = postRepository.findById(postId) + .orElseThrow(() -> new PostNotFoundException("게시글을 찾을 수 없습니다")); + + Student student = studentRepository.findByLoginId(studentId) + .orElseThrow(() -> new NotFoundStudentException("학생을 찾을 수 없습니다")); + + if (!post.getStudent().getLoginId().equals(studentId) && !student.hasRole(Role.ROLE_ADMIN)) { + throw new UnauthorizedAccessException("권한이 없습니다"); + } + + postRepository.delete(post); + } +} diff --git a/src/main/java/com/demo/service/StudentService.java b/src/main/java/com/demo/service/StudentService.java index 3eea3c9..47a979f 100644 --- a/src/main/java/com/demo/service/StudentService.java +++ b/src/main/java/com/demo/service/StudentService.java @@ -1,44 +1,81 @@ package com.demo.service; +import com.demo.domain.Authority; +import com.demo.domain.Role; +import com.demo.exception.dto.ErrorMessage; +import com.demo.exception.student.NotFoundStudentException; +import com.demo.repository.AuthorityRepository; import com.demo.repository.StudentRepository; import com.demo.domain.Student; import com.demo.dto.request.StudentCreateRequest; import com.demo.dto.response.StudentResponse; import com.demo.dto.request.StudentUpdateRequest; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +@Slf4j @Service @RequiredArgsConstructor public class StudentService { private final StudentRepository studentRepository; + private final AuthorityRepository authorityRepository; @Transactional public StudentResponse create(StudentCreateRequest studentCreateRequest) { - Student entity = Student.toEntity(studentCreateRequest); //id 값이 없는 상태 + Student entity = Student.toEntity(studentCreateRequest); + log.info("Student information: {}", entity); - studentRepository.save(entity); //저장하면서 id값을 데이터베이스에서 생성해준다. + assignRole(entity, Role.ROLE_USER); + + studentRepository.save(entity); return entity.toResponse(); } + public void assignRole(Student entity, Role role) { + Authority authority = authorityRepository.findByRole(role) + .orElseThrow(() -> new RuntimeException(role.name() + " 권한이 존재하지 않습니다.")); + entity.addAuthority(authority); + } + @Transactional(readOnly = true) - public StudentResponse findById(Long id) { - return studentRepository.findById(id).orElse(null).toResponse(); + public StudentResponse findById(String id) { + return studentRepository.findByLoginId(id) + .orElseThrow(() -> new NotFoundStudentException( + ErrorMessage.NOT_FOUND_STUDENT, + "요청한 사용자를 찾을 수 없습니다.") + ) + .toResponse(); } @Transactional - public StudentResponse update(Long id, StudentUpdateRequest studentUpdateRequest) { - Student student = studentRepository.findById(id).get(); + public StudentResponse update(String id, StudentUpdateRequest studentUpdateRequest) { + Student student = studentRepository.findByLoginId(id) + .orElseThrow(() -> new NotFoundStudentException( + ErrorMessage.NOT_FOUND_STUDENT, + "요청한 사용자를 찾을 수 없습니다.") + ); + student.update(studentUpdateRequest); //객체가 수정될 것 return student.toResponse(); } @Transactional - public void delete(Long id) { - if(studentRepository.existsById(id)) { - studentRepository.deleteById(id); + public void delete(String id) { + if (studentRepository.existsByLoginId(id)) { + studentRepository.deleteByLoginId(id); + } else { + throw new NotFoundStudentException( + ErrorMessage.NOT_FOUND_STUDENT, + "요청한 사용자를 찾을 수 없습니다." + ); } } + + @Transactional(readOnly = true) + public boolean ixExistLoginId(String loginId){ + return studentRepository.existsByLoginId(loginId); + } } \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 3982117..845a328 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,26 +1,42 @@ spring: - - h2: - console: - enabled: true - + # 데이터베이스 연결 설정 datasource: - url: jdbc:mysql://127.0.0.1:3306/{사용할 데이터베이스} - driver-class-name: com.mysql.cj.jdbc.Driver - username: {접속자명} - password: {비밀번호} - #url: jdbc:h2:tcp://localhost/~/querydsl - #driver-class-name: org.h2.Driver - #username: sa - #password: - + driver-class-name: com.mysql.cj.jdbc.Driver # 사용하는 데이터베이스 드라이버 + url: jdbc:mysql://localhost:3306/SE_webserver_database # 데이터베이스 URL + username: root # 데이터베이스 유저 이름 + password: mmsdud00!! # 데이터베이스 유저의 비밀번호 + # 스프링 JPA 설정 jpa: - database-platform: org.hibernate.dialect.MySQLDialect - #database-platform: org.hibernate.dialect.H2Dialect hibernate: - ddl-auto: create-drop # 설정 변경 가능 + ddl-auto: create-drop # DB 테이블 생성 전략 설정 properties: hibernate: - format_sql: true - show_sql: true - defer-datasource-initialization: true \ No newline at end of file + dialect: org.hibernate.dialect.MySQL8Dialect # MySQL 8.x용 Dialect + format_sql: true # 쿼리 로그를 정렬해서 출력한다 + show_sql: true # 실행되는 SQL 쿼리를 콘솔에 출력한다. 프로덕션 환경에서는 false로 변경해야 함. + defer-datasource-initialization: true # SQL 스크립트 초기화를 JPA 앤티티 매니저 초기화 이후로 지연시킨다. 이는 data.sql이 있어서 필요하다. + sql: + init: + mode: always # SQL 초기화 스크립트를 언제 실행할지 결정한다. + + +springdoc: + swagger-ui: + path: /swagger-ui.html # 스웨거 접근 경로 + api-docs: + path: /api-docs # openAPI 접근 경로. default 값은 /v3/api-docs 이다. + show-actuator: true # Spring Actuator의 endpoint까지 보여줄 것인지? + default-consumes-media-type: application/json # request media type 의 기본 값 + default-produces-media-type: application/json # response media type 의 기본 값 + paths-to-match: # 해당 패턴에 매칭되는 controller만 swagger-ui에 노출한다. + - /api/v1/** + +# JWT 토큰 설정 +jwt: + #HS512 알고리즘을 사용할 것이기 때문에 512bit, 즉 64byte 이상의 secret key를 사용해야 한다. + #echo 'silvernine-tech-spring-boot-jwt-tutorial-secret-silvernine-tech-spring-boot-jwt-tutorial-secret'|base64 + secret: c2lsdmVybmluZS10ZWNoLXNwcmluZy1ib290LWp3dC10dXRvcmlhbC1zZWNyZXQtc2lsdmVybmluZS10ZWNoLXNwcmluZy1ib290LWp3dC10dXRvcmlhbC1zZWNyZXQK + access-token-header: Authorization # 액세스 토큰 헤더 + access-token-validity-in-hour: 3 # 액세스 토큰 유효기간 (시간) + authority-key: auth # 액세스 토큰에서 권한을 가져올 키 + bearer-header: "Bearer " \ No newline at end of file diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql new file mode 100644 index 0000000..e580547 --- /dev/null +++ b/src/main/resources/data.sql @@ -0,0 +1,2 @@ +insert into authority (authority_name) values ('ROLE_USER'); +insert into authority (authority_name) values ('ROLE_ADMIN'); \ No newline at end of file diff --git a/src/main/resources/static/css/common.css b/src/main/resources/static/css/common.css new file mode 100644 index 0000000..946e296 --- /dev/null +++ b/src/main/resources/static/css/common.css @@ -0,0 +1,10 @@ +body { + font-family: 'Arial', sans-serif; + background-color: #f0f2f5; + display: flex; + justify-content: center; + align-items: center; + height: 100vh; + margin: 0; + padding: 20px; +} \ No newline at end of file diff --git a/src/main/resources/static/css/login-1.0.css b/src/main/resources/static/css/login-1.0.css new file mode 100644 index 0000000..720cf93 --- /dev/null +++ b/src/main/resources/static/css/login-1.0.css @@ -0,0 +1,106 @@ +/* 로그인 페이지 디자인 (모바일 우선 + 데스크톱 대응) */ + +/* 공통 스타일 */ +body { + font-family: 'Arial', sans-serif; + background-color: #f0f2f5; + display: flex; + justify-content: center; + align-items: center; + height: 100vh; + margin: 0; + padding: 20px; +} + +.login-container { + background-color: white; + padding: 30px 20px; + border-radius: 10px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + width: 100%; + max-width: 300px; + box-sizing: border-box; +} + +.login-container h2 { + margin-bottom: 20px; + text-align: center; + color: #333; +} + +label { + display: block; + margin-top: 10px; + margin-bottom: 5px; + color: #555; +} + +input { + width: 100%; + padding: 8px 10px; + margin-bottom: 10px; + border: 1px solid #ccc; + border-radius: 5px; + box-sizing: border-box; +} + +/* input focus 스타일 */ +input:focus { + outline: none; + border-color: #007bff; + box-shadow: 0 0 4px rgba(0, 123, 255, 0.5); +} + +/* 공통 버튼 스타일 */ +button { + width: 100%; + border-radius: 5px; + cursor: pointer; + transition: background-color 0.2s; + font-size: 16px; +} + +/* 로그인 버튼 */ +#login-btn { + padding: 10px; + background-color: #007bff; + color: white; + border: none; +} + +#login-btn:hover { + background-color: #0056b3; +} + +/* 회원가입 버튼 스타일 */ +.signup-button { + margin-top: 10px; + padding: 8px; + background-color: transparent; + color: #333; + border: 1px solid #aaa; + font-size: 14px; +} + +.signup-button:hover { + background-color: #f1f1f1; +} + +/* 오류 메시지 */ +#message { + margin-top: 15px; + color: red; + text-align: center; +} + +/* 데스크톱 대응 */ +@media (min-width: 768px) { + .login-container { + max-width: 500px; + padding: 40px 30px; + } + + body { + font-size: 16px; + } +} \ No newline at end of file diff --git a/src/main/resources/static/css/login-purple-1.0.css b/src/main/resources/static/css/login-purple-1.0.css new file mode 100644 index 0000000..720cf93 --- /dev/null +++ b/src/main/resources/static/css/login-purple-1.0.css @@ -0,0 +1,106 @@ +/* 로그인 페이지 디자인 (모바일 우선 + 데스크톱 대응) */ + +/* 공통 스타일 */ +body { + font-family: 'Arial', sans-serif; + background-color: #f0f2f5; + display: flex; + justify-content: center; + align-items: center; + height: 100vh; + margin: 0; + padding: 20px; +} + +.login-container { + background-color: white; + padding: 30px 20px; + border-radius: 10px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + width: 100%; + max-width: 300px; + box-sizing: border-box; +} + +.login-container h2 { + margin-bottom: 20px; + text-align: center; + color: #333; +} + +label { + display: block; + margin-top: 10px; + margin-bottom: 5px; + color: #555; +} + +input { + width: 100%; + padding: 8px 10px; + margin-bottom: 10px; + border: 1px solid #ccc; + border-radius: 5px; + box-sizing: border-box; +} + +/* input focus 스타일 */ +input:focus { + outline: none; + border-color: #007bff; + box-shadow: 0 0 4px rgba(0, 123, 255, 0.5); +} + +/* 공통 버튼 스타일 */ +button { + width: 100%; + border-radius: 5px; + cursor: pointer; + transition: background-color 0.2s; + font-size: 16px; +} + +/* 로그인 버튼 */ +#login-btn { + padding: 10px; + background-color: #007bff; + color: white; + border: none; +} + +#login-btn:hover { + background-color: #0056b3; +} + +/* 회원가입 버튼 스타일 */ +.signup-button { + margin-top: 10px; + padding: 8px; + background-color: transparent; + color: #333; + border: 1px solid #aaa; + font-size: 14px; +} + +.signup-button:hover { + background-color: #f1f1f1; +} + +/* 오류 메시지 */ +#message { + margin-top: 15px; + color: red; + text-align: center; +} + +/* 데스크톱 대응 */ +@media (min-width: 768px) { + .login-container { + max-width: 500px; + padding: 40px 30px; + } + + body { + font-size: 16px; + } +} \ No newline at end of file diff --git a/src/main/resources/static/css/mainpage-1.0.css b/src/main/resources/static/css/mainpage-1.0.css new file mode 100644 index 0000000..3e87836 --- /dev/null +++ b/src/main/resources/static/css/mainpage-1.0.css @@ -0,0 +1,142 @@ +/* 공통 */ +html, body { + margin: 0; + padding: 0; + height: 100%; +} + +.page-wrapper { + display: flex; + flex-direction: column; + min-height: 100vh; + background-color: #f3e9ff; + color: #333; + font-family: Arial, sans-serif; +} + +/* 상단 헤더 */ +.top-section { + background-color: #6a0dad; + padding: 20px 10px; + text-align: center; + position: relative; +} + +.logo-center { + display: flex; + justify-content: center; + align-items: center; + gap: 10px; + margin-bottom: 10px; +} + +.logo-img { + height: 40px; + width: auto; +} + +.logo-text { + color: white; + font-size: 24px; + font-weight: bold; +} + +/* 네비게이션 메뉴 (데스크탑) */ +.main-nav { + display: flex; + justify-content: center; + gap: 20px; + flex-wrap: wrap; +} + +.main-nav a { + color: white; + text-decoration: none; + font-weight: 500; + font-size: 16px; + transition: opacity 0.2s ease; +} + +.main-nav a:hover { + opacity: 0.7; +} + +/* 햄버거 버튼 */ +.menu-toggle { + position: absolute; + top: 20px; + right: 20px; + font-size: 28px; + color: white; + cursor: pointer; + display: none; +} + +/* 메인 콘텐츠 */ +.main-content { + padding: 40px 20px; + flex-grow: 1; +} + +/* 푸터 */ +.main-footer { + background-color: #4b0082; + color: white; + text-align: center; + padding: 15px; + margin-top: auto; +} + +/* === 반응형: 모바일 햄버거 메뉴 === */ +@media (max-width: 768px) { + .menu-toggle { + display: block; + font-size: 28px; + color: white; + position: absolute; + right: 20px; + top: 26px; + cursor: pointer; + z-index: 1101; + } + + .main-nav { + position: fixed; + top: 0; + right: -100%; + width: 250px; + height: 100%; + background-color: #6a0dad; + flex-direction: column; + align-items: flex-start; + padding: 80px 20px; + transition: right 0.3s ease; + z-index: 1100; + } + + .main-nav a { + color: white; + margin-bottom: 20px; + font-size: 18px; + } + + .main-nav.active { + right: 0; + } + + .overlay { + position: fixed; + top: 0; left: 0; + width: 100%; height: 100%; + background-color: rgba(0, 0, 0, 0.4); + opacity: 0; + visibility: hidden; + transition: opacity 0.3s ease; + z-index: 1000; + } + + .overlay.active { + opacity: 1; + visibility: visible; + } +} \ No newline at end of file diff --git a/src/main/resources/static/css/post-create.css b/src/main/resources/static/css/post-create.css new file mode 100644 index 0000000..91b3684 --- /dev/null +++ b/src/main/resources/static/css/post-create.css @@ -0,0 +1,176 @@ +/* ========================================================================== + post-create.css — 글 작성 화면 전용 스타일 + 목표: 컴팩트하지만, 큰 화면(데스크탑/와이드)에서는 폼과 텍스트영역이 넉넉하게 커짐 + ========================================================================== */ + +:root { + /* 팔레트 */ + --brand: #6a0dad; + --brand-600: #5a0ab9; + --brand-100: rgba(106, 13, 173, 0.08); + --text: #222; + --muted: #6b6b6b; + --line: #e6e6eb; + --white: #fff; + + /* 모양/그림자 */ + --radius-lg: 14px; + --radius-md: 10px; + --shadow-sm: 0 1px 4px rgba(0, 0, 0, 0.06); + --shadow-md: 0 6px 24px rgba(62, 31, 117, 0.08); +} + +/* 페이지 레이아웃: 가운데 정렬 + 큰 화면에서도 여백 유지 */ +.main-content { + max-width: 1920px; /* ★ 변경: 상한 올려서 초와이드 지원 */ + margin: 28px auto 48px; + padding: 0 24px; /* 좌우 안전 여백 */ +} + +/* 타이틀 */ +.page-title { + margin: 4px 0 16px; + font-size: clamp(20px, 1.6vw, 26px); + font-weight: 700; + color: var(--brand); + letter-spacing: -0.2px; + position: relative; +} +.page-title::after { + content: ""; + display: block; + width: 48px; + height: 3px; + background: var(--brand); + opacity: .22; + border-radius: 999px; + margin-top: 8px; +} + +/* 카드형 폼 + - width: 100% 안에서, 뷰포트에 따라 유연하게 확장 + - clamp(min, preferred, max)로 넓게 성장 (기존 대비 2배 가까이 확대) +*/ +.post-form { + /* ★ 변경: 훨씬 넓게 — 최소 960px, 기본 92vw, 최대 1600px */ + width: min(100%, clamp(960px, 92vw, 1600px)); + background: var(--white); + border: 1px solid var(--line); + border-radius: var(--radius-lg); + padding: 20px 20px 16px; + margin: 0 auto; + box-shadow: var(--shadow-sm); + transition: box-shadow .2s ease, border-color .2s ease; +} +.post-form:focus-within { + border-color: var(--brand); + box-shadow: var(--shadow-md); +} + +/* 라벨 */ +.post-form label { + display: block; + margin: 10px 0 12px; + font-weight: 600; + color: var(--text); +} + +/* 입력/텍스트영역 + - 폰트와 패딩도 살짝 유동적으로(clamp) 성장 +*/ +.post-form input, +.post-form textarea { + width: 100%; + box-sizing: border-box; + margin-top: 6px; + padding: clamp(10px, 0.8vw, 14px) clamp(12px, 1vw, 16px); + border: 1px solid var(--line); + border-radius: var(--radius-md); + font-size: clamp(15px, 1vw + 12px, 18px); + color: var(--text); + background: #fafafa; + outline: none; + transition: border-color .15s ease, box-shadow .15s ease, background .15s ease; +} +.post-form input:focus, +.post-form textarea:focus { + border-color: var(--brand); + background: #fff; + box-shadow: 0 0 0 4px var(--brand-100); +} + +/* 텍스트영역 높이 + - 작은 화면: 200px + - 큰 화면: 화면 높이에 비례해 더 넓게 +*/ +.post-form textarea { + min-height: clamp(200px, 34vh, 520px); + resize: vertical; /* 세로만 조절 */ + line-height: 1.55; +} + +/* 액션 영역 */ +.actions { + margin-top: 16px; + padding-top: 12px; + display: flex; + gap: 10px; + justify-content: flex-end; + border-top: 1px dashed var(--line); +} + +/* 버튼 공통 */ +.btn { + appearance: none; + -webkit-appearance: none; + padding: 10px 16px; + border: 1px solid var(--line); + background: var(--white); + border-radius: 10px; + cursor: pointer; + text-decoration: none; + color: var(--text); + font-weight: 600; + line-height: 1; + transition: transform .06s ease, filter .12s ease, background .12s ease, border-color .12s ease; +} +.btn:hover { background: #f7f7fb; } +.btn:active { transform: translateY(1px); } + +.btn.primary { + background: var(--brand); + color: #fff; + border-color: var(--brand); +} +.btn.primary:hover { filter: brightness(1.06); } +.btn.primary:active { filter: brightness(0.98); } + +.btn:disabled, +.btn[disabled] { + opacity: .55; + cursor: not-allowed; +} + +/* 모바일 최적화 */ +@media (max-width: 520px) { + .main-content { margin-top: 20px; padding: 0 16px; } + .post-form { padding: 16px; width: 100%; } /* 모바일은 화면폭 꽉 채움 */ + .actions { + flex-direction: column-reverse; + align-items: stretch; + gap: 10px; + } + .actions .btn { width: 100%; } +} + +/* 초와이드 화면(예: 1600px 이상)에서 폼을 더 키우고 여백도 늘림 */ +@media (min-width: 1600px) { + .main-content { max-width: 2048px; } /* ★ 변경 */ + .post-form { width: min(100%, 1680px); } /* ★ 변경: 최대폭 더 확대 */ + .post-form textarea { min-height: clamp(260px, 40vh, 640px); } +} + +/* 사용자 환경설정: 모션 축소 */ +@media (prefers-reduced-motion: reduce) { + * { transition: none !important; } +} diff --git a/src/main/resources/static/css/post-details.css b/src/main/resources/static/css/post-details.css new file mode 100644 index 0000000..a644dba --- /dev/null +++ b/src/main/resources/static/css/post-details.css @@ -0,0 +1,55 @@ +.hidden { display: none !important; } +:root { + --purple: #6a0dad; + --purple-dark: #4b0082; + --line: #e5e5e5; + --text: #333; +} + +.detail-head { + display: flex; + gap: 8px; + align-items: center; + margin-bottom: 14px; +} +.detail-head .spacer { flex: 1; } + +.btn { + padding: 8px 12px; + border: 1px solid var(--line); + background: #fff; + border-radius: 8px; + cursor: pointer; + text-decoration: none; + color: var(--text); +} +.btn:hover { background: #f7f7f7; } +.btn.ghost { background: transparent; color: var(--purple); border-color: var(--purple); } +.btn.ghost:hover { background: #efe2ff; } +.btn.danger { background: #fff5f5; border-color: #ffcdcd; color: #a10000; } +.btn.danger:hover { background: #ffe9e9; } + +.post-card { + background: #fff; + border: 1px solid var(--line); + border-radius: 12px; + padding: 18px; + box-shadow: 0 2px 10px rgba(0,0,0,.04); +} +.post-title { + margin: 0 0 8px; + font-size: 22px; + color: var(--purple-dark); +} +.meta { + color: #666; + font-size: 13px; + margin-bottom: 16px; +} +.meta .dot { margin: 0 6px; } +.post-content { + white-space: pre-wrap; + line-height: 1.6; + font-size: 15px; + color: #222; +} diff --git a/src/main/resources/static/css/post-list.css b/src/main/resources/static/css/post-list.css new file mode 100644 index 0000000..47e8fc1 --- /dev/null +++ b/src/main/resources/static/css/post-list.css @@ -0,0 +1,128 @@ +.hidden { display:none !important; } +/* 메인 컬러 계열 맞춤 */ +:root { + --purple: #6a0dad; + --purple-dark: #4b0082; + --bg: #f3e9ff; + --line: #e5e5e5; + --text: #333; +} + +/* 메인 레이아웃(mainpage-1.0.css)과 조화되도록 최소 보강 */ +.page-title { + margin: 0 0 18px; + font-size: 22px; + color: var(--purple-dark); +} + +.post-toolbar { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; + margin-bottom: 10px; +} +.post-search { + display: flex; + gap: 8px; + align-items: center; +} +.post-toolbar .spacer { flex: 1; } + +.post-search input[type="text"], +.post-search input { + padding: 8px 10px; + min-width: 240px; + border: 1px solid var(--line); + border-radius: 6px; + outline: none; +} + +/* 버튼 스타일을 메인 톤에 맞춤 */ +.btn { + padding: 8px 12px; + border: 1px solid var(--line); + background: #fff; + color: var(--text); + border-radius: 8px; + cursor: pointer; +} +.btn:hover { background: #f7f7f7; } +.btn.primary { + background: var(--purple); + color: #fff; + border-color: var(--purple); +} +.btn.primary:hover { background: #7b16c6; } +.btn.ghost { + background: transparent; + border-color: var(--purple); + color: var(--purple); +} +.btn.ghost:hover { background: #efe2ff; } + +/* 테이블 */ +.table-wrap { width: 100%; overflow-x: auto; } +.post-table { + width: 100%; + border-collapse: collapse; + margin-top: 10px; + background: #fff; + border-radius: 12px; + overflow: hidden; + box-shadow: 0 2px 10px rgba(0,0,0,.04); +} +.post-table th, .post-table td { + border: 1px solid var(--line); + padding: 12px 10px; + font-size: 14px; + vertical-align: middle; +} +.post-table thead th { + background: #faf5ff; /* 보라톤 아주 옅게 */ + text-align: left; + color: var(--purple-dark); + font-weight: 700; +} +.post-table a.row-link { color: inherit; text-decoration: none; } +.post-table a.row-link:hover { text-decoration: underline; } + +.col-id { width: 80px; } +.col-writer { width: 160px; } +.col-date { width: 200px; } +.col-actions { width: 160px; } + +.action { + padding: 6px 10px; + margin-right: 6px; + border: 1px solid var(--line); + background: #fff; + border-radius: 8px; + cursor: pointer; +} +.action:hover { background: #f7f7f7; } + +.empty { + padding: 24px; + text-align: center; + color: #777; + border: 1px dashed var(--line); + border-radius: 8px; + margin-top: 14px; + background: #fff; +} + +/* 페이지네이션 */ +.paginator { + display: flex; + gap: 8px; + align-items: center; + justify-content: center; + margin: 16px 0; +} +.muted { color: #666; font-size: 12px; } + +/* 모바일: 작성자 컬럼 숨김 */ +@media (max-width: 768px) { + .col-writer { display: none; } +} diff --git a/src/main/resources/static/css/post-update.css b/src/main/resources/static/css/post-update.css new file mode 100644 index 0000000..91b3684 --- /dev/null +++ b/src/main/resources/static/css/post-update.css @@ -0,0 +1,176 @@ +/* ========================================================================== + post-create.css — 글 작성 화면 전용 스타일 + 목표: 컴팩트하지만, 큰 화면(데스크탑/와이드)에서는 폼과 텍스트영역이 넉넉하게 커짐 + ========================================================================== */ + +:root { + /* 팔레트 */ + --brand: #6a0dad; + --brand-600: #5a0ab9; + --brand-100: rgba(106, 13, 173, 0.08); + --text: #222; + --muted: #6b6b6b; + --line: #e6e6eb; + --white: #fff; + + /* 모양/그림자 */ + --radius-lg: 14px; + --radius-md: 10px; + --shadow-sm: 0 1px 4px rgba(0, 0, 0, 0.06); + --shadow-md: 0 6px 24px rgba(62, 31, 117, 0.08); +} + +/* 페이지 레이아웃: 가운데 정렬 + 큰 화면에서도 여백 유지 */ +.main-content { + max-width: 1920px; /* ★ 변경: 상한 올려서 초와이드 지원 */ + margin: 28px auto 48px; + padding: 0 24px; /* 좌우 안전 여백 */ +} + +/* 타이틀 */ +.page-title { + margin: 4px 0 16px; + font-size: clamp(20px, 1.6vw, 26px); + font-weight: 700; + color: var(--brand); + letter-spacing: -0.2px; + position: relative; +} +.page-title::after { + content: ""; + display: block; + width: 48px; + height: 3px; + background: var(--brand); + opacity: .22; + border-radius: 999px; + margin-top: 8px; +} + +/* 카드형 폼 + - width: 100% 안에서, 뷰포트에 따라 유연하게 확장 + - clamp(min, preferred, max)로 넓게 성장 (기존 대비 2배 가까이 확대) +*/ +.post-form { + /* ★ 변경: 훨씬 넓게 — 최소 960px, 기본 92vw, 최대 1600px */ + width: min(100%, clamp(960px, 92vw, 1600px)); + background: var(--white); + border: 1px solid var(--line); + border-radius: var(--radius-lg); + padding: 20px 20px 16px; + margin: 0 auto; + box-shadow: var(--shadow-sm); + transition: box-shadow .2s ease, border-color .2s ease; +} +.post-form:focus-within { + border-color: var(--brand); + box-shadow: var(--shadow-md); +} + +/* 라벨 */ +.post-form label { + display: block; + margin: 10px 0 12px; + font-weight: 600; + color: var(--text); +} + +/* 입력/텍스트영역 + - 폰트와 패딩도 살짝 유동적으로(clamp) 성장 +*/ +.post-form input, +.post-form textarea { + width: 100%; + box-sizing: border-box; + margin-top: 6px; + padding: clamp(10px, 0.8vw, 14px) clamp(12px, 1vw, 16px); + border: 1px solid var(--line); + border-radius: var(--radius-md); + font-size: clamp(15px, 1vw + 12px, 18px); + color: var(--text); + background: #fafafa; + outline: none; + transition: border-color .15s ease, box-shadow .15s ease, background .15s ease; +} +.post-form input:focus, +.post-form textarea:focus { + border-color: var(--brand); + background: #fff; + box-shadow: 0 0 0 4px var(--brand-100); +} + +/* 텍스트영역 높이 + - 작은 화면: 200px + - 큰 화면: 화면 높이에 비례해 더 넓게 +*/ +.post-form textarea { + min-height: clamp(200px, 34vh, 520px); + resize: vertical; /* 세로만 조절 */ + line-height: 1.55; +} + +/* 액션 영역 */ +.actions { + margin-top: 16px; + padding-top: 12px; + display: flex; + gap: 10px; + justify-content: flex-end; + border-top: 1px dashed var(--line); +} + +/* 버튼 공통 */ +.btn { + appearance: none; + -webkit-appearance: none; + padding: 10px 16px; + border: 1px solid var(--line); + background: var(--white); + border-radius: 10px; + cursor: pointer; + text-decoration: none; + color: var(--text); + font-weight: 600; + line-height: 1; + transition: transform .06s ease, filter .12s ease, background .12s ease, border-color .12s ease; +} +.btn:hover { background: #f7f7fb; } +.btn:active { transform: translateY(1px); } + +.btn.primary { + background: var(--brand); + color: #fff; + border-color: var(--brand); +} +.btn.primary:hover { filter: brightness(1.06); } +.btn.primary:active { filter: brightness(0.98); } + +.btn:disabled, +.btn[disabled] { + opacity: .55; + cursor: not-allowed; +} + +/* 모바일 최적화 */ +@media (max-width: 520px) { + .main-content { margin-top: 20px; padding: 0 16px; } + .post-form { padding: 16px; width: 100%; } /* 모바일은 화면폭 꽉 채움 */ + .actions { + flex-direction: column-reverse; + align-items: stretch; + gap: 10px; + } + .actions .btn { width: 100%; } +} + +/* 초와이드 화면(예: 1600px 이상)에서 폼을 더 키우고 여백도 늘림 */ +@media (min-width: 1600px) { + .main-content { max-width: 2048px; } /* ★ 변경 */ + .post-form { width: min(100%, 1680px); } /* ★ 변경: 최대폭 더 확대 */ + .post-form textarea { min-height: clamp(260px, 40vh, 640px); } +} + +/* 사용자 환경설정: 모션 축소 */ +@media (prefers-reduced-motion: reduce) { + * { transition: none !important; } +} diff --git a/src/main/resources/static/css/signup-1.0.css b/src/main/resources/static/css/signup-1.0.css new file mode 100644 index 0000000..a561088 --- /dev/null +++ b/src/main/resources/static/css/signup-1.0.css @@ -0,0 +1,55 @@ +/* 기본 레이아웃 */ +.login-container{ + background:#fff; padding:30px; border-radius:8px; + box-shadow:0 4px 8px rgba(0,0,0,.1); width:320px; +} +h2{ text-align:center; color:#333; margin-bottom:20px; } + +label{ display:block; margin-bottom:6px; color:#555; font-weight:bold; } + +input[type="text"],input[type="password"],input[type="email"],input[type="tel"]{ + width:100%; padding:10px; margin-bottom:15px; + border:1px solid #ccc; border-radius:4px; box-sizing:border-box; +} + +/* 제출 버튼 */ +button[type="submit"]{ + width:100%; padding:10px; background:#1034d3; color:#fff; + border:0; border-radius:4px; font-weight:bold; cursor:pointer; transition:.2s; +} +button[type="submit"]:hover{ background:#0d2ab0; } + +/* 결과/에러 문구 */ +.signup-msg{ margin-top:10px; color:red; text-align:center; font-size:.9em; } + +/* 한 줄 정렬 공통 */ +:root{ --control-h:40px; } + +.field-row{ + display:flex; align-items:stretch; gap:8px; margin-bottom:15px; +} +.field-row input{ + flex:1 1 auto; min-width:0; height:var(--control-h); +} + +/* 중복 확인 버튼 */ +.btn-idcheck{ + height:var(--control-h); padding:0 12px; white-space:nowrap; + border:1px solid #1034d3; background:#f5f8ff; color:#1034d3; + border-radius:4px; font-weight:600; cursor:pointer; +} +.btn-idcheck:hover,.btn-idcheck:focus{ background:#e9f0ff; outline:none; } + +/* 비밀번호 아이콘 버튼 */ +.icon-btn{ + width:var(--control-h); height:var(--control-h); + border:1px solid #ccc; background:#fff; border-radius:4px; + display:grid; place-items:center; cursor:pointer; +} +.icon-btn:hover,.icon-btn:focus{ border-color:#1034d3; outline:none; } + +/* 아이콘 크기 */ +.mi{ font-size:20px; line-height:1; } + +/* 중복 확인 결과 위치/색상 */ +.assistive-msg{ display:block; color:#0077cc; margin-top:-8px; margin-bottom:10px; } diff --git a/src/main/resources/static/css/write-page-1.0.css b/src/main/resources/static/css/write-page-1.0.css new file mode 100644 index 0000000..5566195 --- /dev/null +++ b/src/main/resources/static/css/write-page-1.0.css @@ -0,0 +1,197 @@ +/* === 기본 === */ +body, html { + margin: 0; + padding: 0; + height: 100%; +} + +.page-wrapper { + display: flex; + flex-direction: column; + min-height: 100vh; + background-color: #f3e9ff; + font-family: Arial, sans-serif; +} + +/* === 헤더 영역 === */ +.top-section { + background-color: #6a0dad; + padding: 20px 10px; + text-align: center; + position: relative; +} + +.logo-center { + display: flex; + justify-content: center; + align-items: center; + gap: 10px; +} + +.logo-img { + height: 40px; +} + +.logo-text { + color: white; + font-size: 24px; + font-weight: bold; +} + +.main-nav { + display: flex; + justify-content: center; + gap: 20px; +} + +.main-nav a { + color: white; + text-decoration: none; + font-weight: 500; + font-size: 16px; + transition: opacity 0.2s ease; +} + +.main-nav a:hover { + opacity: 0.7; +} + +/* === 햄버거 버튼 === */ +.menu-toggle { + display: none; +} + +/* === 본문 === */ +.main-content { + flex-grow: 1; + display: flex; + justify-content: center; + align-items: center; + padding: 40px 20px; +} + +.write-form-container { + background-color: white; + padding: 30px; + border-radius: 12px; + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1); + width: 100%; + max-width: 600px; +} + +#write-form { + display: flex; + flex-direction: column; + gap: 16px; +} + +#write-form input[type="text"] { + padding: 10px; + border: 1px solid #aaa; + border-radius: 6px; + font-size: 16px; +} + +.editor-toolbar { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.editor-toolbar button, +.editor-toolbar select, +.editor-toolbar input[type="color"] { + padding: 6px 8px; + border: 1px solid #ccc; + border-radius: 4px; + background: #fff; + cursor: pointer; + font-size: 14px; +} + +.editor-area { + min-height: 200px; + border: 1px solid #aaa; + border-radius: 6px; + padding: 10px; + font-size: 16px; + background-color: #fff; + overflow-y: auto; +} + +#write-form button[type="submit"] { + align-self: flex-end; + background-color: #6a0dad; + color: white; + border: none; + padding: 10px 16px; + border-radius: 6px; + font-size: 16px; + cursor: pointer; +} + +#write-form button[type="submit"]:hover { + background-color: #5a029d; +} + +/* === 푸터 === */ +.main-footer { + background-color: #4b0082; + color: white; + text-align: center; + padding: 15px; +} + +/* === 반응형: 모바일 햄버거 메뉴 === */ +@media (max-width: 768px) { + .menu-toggle { + display: block; + font-size: 28px; + color: white; + position: absolute; + right: 20px; + top: 26px; + cursor: pointer; + z-index: 1101; + } + + .main-nav { + position: fixed; + top: 0; + right: -100%; + width: 250px; + height: 100%; + background-color: #6a0dad; + flex-direction: column; + align-items: flex-start; + padding: 80px 20px; + transition: right 0.3s ease; + z-index: 1100; + } + + .main-nav a { + color: white; + margin-bottom: 20px; + font-size: 18px; + } + + .main-nav.active { + right: 0; + } + + .overlay { + position: fixed; + top: 0; left: 0; + width: 100%; height: 100%; + background-color: rgba(0, 0, 0, 0.4); + opacity: 0; + visibility: hidden; + transition: opacity 0.3s ease; + z-index: 1000; + } + + .overlay.active { + opacity: 1; + visibility: visible; + } +} diff --git a/src/main/resources/static/images/main-logo.svg b/src/main/resources/static/images/main-logo.svg new file mode 100644 index 0000000..73ccc95 --- /dev/null +++ b/src/main/resources/static/images/main-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/main/resources/static/images/main_logo.svg b/src/main/resources/static/images/main_logo.svg new file mode 100644 index 0000000..73ccc95 --- /dev/null +++ b/src/main/resources/static/images/main_logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/main/resources/static/js/apiClient.js b/src/main/resources/static/js/apiClient.js new file mode 100644 index 0000000..9911544 --- /dev/null +++ b/src/main/resources/static/js/apiClient.js @@ -0,0 +1,95 @@ +// === 공통 API 클라이언트 === +// - JWT 자동 첨부 +// - 401 응답 시 토큰 폐기 + 로그인으로 이동(redirect 유지) +// - 토큰 유효성(만료) 검사 유틸 제공 + +const API_BASE = '/api/v1'; +const TOKEN_HEADER_NAME = 'Authorization'; +const TOKEN_PREFIX = 'Bearer '; +const TOKEN_STORAGE_KEY = 'Authorization'; + +// ----- 과거 키 자동 마이그레이션(accessToken → Authorization) ----- +// 혹시모를 accessToken 남아있는거 방지 +(() => { + const old = localStorage.getItem('accessToken'); + const cur = localStorage.getItem(TOKEN_STORAGE_KEY); + if (old && !cur) { + const val = old.startsWith(TOKEN_PREFIX) ? old : (TOKEN_PREFIX + old); + localStorage.setItem(TOKEN_STORAGE_KEY, val); + localStorage.removeItem('accessToken'); + } +})(); + +// ----- 토큰 저장소 ----- +function getToken() { return localStorage.getItem(TOKEN_STORAGE_KEY); } +function setToken(t) { if (t) localStorage.setItem(TOKEN_STORAGE_KEY, t); } +function clearToken() { localStorage.removeItem(TOKEN_STORAGE_KEY); } + +// 내부 유틸: "Bearer " 제거 +function stripBearer(t) { + if (!t) return t; + return t.startsWith(TOKEN_PREFIX) ? t.slice(TOKEN_PREFIX.length) : t; +} + +// ----- JWT 유틸 ----- +function parseJwt(tokenLike) { + const token = stripBearer(tokenLike); + if (!token) return null; + const p = token.split('.'); + if (p.length < 2) return null; + try { + const b64 = p[1].replace(/-/g, '+').replace(/_/g, '/'); + const json = decodeURIComponent(escape(atob(b64))); + return JSON.parse(json); + } catch { + try { return JSON.parse(atob(p[1])); } catch { return null; } + } +} + +function isTokenValid(tokenLike) { + const payload = parseJwt(tokenLike); + if (!payload) return false; + if (typeof payload.exp !== 'number') return false; + return Date.now() < payload.exp * 1000; +} + +function isLoggedIn() { return isTokenValid(getToken()); } + +function getLoginIdFromToken(tokenLike) { + const p = parseJwt(tokenLike); + if (!p) return null; + return p.sub || p.loginId || p.userId || p.username || null; +} +function getCurrentLoginId() { return getLoginIdFromToken(getToken()); } + +// ----- fetch 래퍼 ----- +async function apiFetch(path, options = {}) { + const url = path.startsWith('http') ? path : `${API_BASE}${path}`; + const headers = new Headers(options.headers || {}); + if (!headers.has('Content-Type')) headers.set('Content-Type', 'application/json'); + + const token = getToken(); // "Bearer xxx" 또는 "xxx" + if (token) { + headers.set( + TOKEN_HEADER_NAME, + token.startsWith(TOKEN_PREFIX) ? token : (TOKEN_PREFIX + token) + ); + } + + const resp = await fetch(url, { ...options, headers }); + + if (resp.status === 401) { + clearToken(); + const back = encodeURIComponent(location.pathname + location.search); + location.href = `/sign-in?redirect=${back}`; + } + return resp; +} + +window.API = { + API_BASE, TOKEN_HEADER_NAME, TOKEN_PREFIX, + getToken, setToken, clearToken, + parseJwt, isTokenValid, isLoggedIn, + getLoginIdFromToken, getCurrentLoginId, + apiFetch +}; diff --git a/src/main/resources/static/js/login-1.0.js b/src/main/resources/static/js/login-1.0.js new file mode 100644 index 0000000..a8428cd --- /dev/null +++ b/src/main/resources/static/js/login-1.0.js @@ -0,0 +1,56 @@ +// login-1.0.js + +document.getElementById('login-form').addEventListener('submit', async (e) => { + e.preventDefault(); + + const id = document.getElementById('username').value.trim(); + const pw = document.getElementById('password').value.trim(); + const message = document.getElementById('message'); + const loginBtn = document.getElementById('login-btn'); + + message.textContent = ''; + message.style.color = 'red'; + + if (!id || !pw) { + message.textContent = '아이디와 비밀번호를 모두 입력해주세요.'; + return; + } + + loginBtn.disabled = true; + loginBtn.textContent = '로그인 중...'; + + try { + const res = await fetch('/api/v1/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ loginId: id, password: pw }) + }); + + if (res.ok) { + const headerToken = res.headers.get('Authorization'); + if (headerToken) { + if (window.API?.setToken) window.API.setToken(headerToken); + else localStorage.setItem('Authorization', headerToken); + + alert('로그인 성공!'); + window.location.replace('/posts'); + } else { + message.textContent = '로그인 실패: 토큰이 존재하지 않습니다.'; + } + } else { + const errorText = await res.text(); + message.textContent = errorText || '로그인 실패: 아이디 또는 비밀번호를 확인해주세요.'; + } + } catch (err) { + console.error('서버 연결 오류:', err); + message.textContent = '서버 오류가 발생했습니다. 나중에 다시 시도해주세요.'; + } finally { + loginBtn.disabled = false; + loginBtn.textContent = '로그인'; + } +}); + +// 회원가입 버튼 클릭 시 회원가입 페이지로 이동 +document.querySelector('.signup-button')?.addEventListener('click', () => { + window.location.href = '/sign-up'; +}); diff --git a/src/main/resources/static/js/mainpage-1.0.js b/src/main/resources/static/js/mainpage-1.0.js new file mode 100644 index 0000000..1995bbd --- /dev/null +++ b/src/main/resources/static/js/mainpage-1.0.js @@ -0,0 +1,77 @@ +// /js/mainpage-1.0.js +// - 네비 렌더링/토글 로직 동일 +// - 토큰은 Authorization 키만 사용 + +document.addEventListener('DOMContentLoaded', () => { + const toggleBtn = document.getElementById('menuToggle'); + const nav = document.getElementById('mainNav'); + const overlay = document.getElementById('overlay'); + + function getTokenSafe() { + try { return window.API?.getToken?.() || null; } + catch { return null; } + } + + function isLoggedIn() { + const token = getTokenSafe(); + return !!token; + } + + function buildRedirectParam() { + const redirect = location.pathname + location.search; + try { return encodeURIComponent(redirect); } + catch { return encodeURIComponent('/posts'); } + } + + function renderNav() { + if (!nav) return; + + const loggedIn = isLoggedIn(); + const redirect = buildRedirectParam(); + + const items = [{ href: '/posts', label: '목록' }]; + + if (!loggedIn) { + items.push( + { href: `/sign-in?redirect=${redirect}`, label: '로그인', id: 'nav-login' }, + { href: '/sign-up', label: '회원가입', id: 'nav-signup' }, + ); + } else { + items.push({ href: '#', label: '로그아웃', id: 'nav-logout' }); + } + + nav.innerHTML = items + .map(i => `${i.label}`) + .join(''); + + const $logout = document.getElementById('nav-logout'); + if ($logout) { + $logout.addEventListener('click', (e) => { + e.preventDefault(); + try { window.API?.clearToken?.(); } catch {} + localStorage.removeItem('Authorization'); // ← key 통일 + location.href = '/sign-in'; + }); + } + + document.querySelectorAll('.main-nav a').forEach(link => { + link.addEventListener('click', () => { + nav.classList.remove('active'); + overlay?.classList.remove('active'); + }); + }); + } + + renderNav(); + + if (toggleBtn && nav && overlay) { + toggleBtn.addEventListener('click', () => { + nav.classList.toggle('active'); + overlay.classList.toggle('active'); + }); + overlay.addEventListener('click', () => { + nav.classList.remove('active'); + overlay.classList.remove('active'); + }); + } +}); diff --git a/src/main/resources/static/js/post-create.js b/src/main/resources/static/js/post-create.js new file mode 100644 index 0000000..b84ca8e --- /dev/null +++ b/src/main/resources/static/js/post-create.js @@ -0,0 +1,73 @@ +// 글 작성 페이지 스크립트 +// - 진입 시 로그인/만료 검사 → 로그인으로 이동(redirect=/posts/new) +// - 제출 직전에도 토큰 재검사(세션 만료 대비) +// - 401/403/기타 에러 메시지 친화화 + +(() => { + const { apiFetch: callApi, isLoggedIn } = window.API || {}; + + // 진입 가드 + if (!isLoggedIn?.()) { + alert('로그인이 필요한 기능입니다.\n로그인 페이지로 이동합니다.'); + location.replace(`/sign-in?redirect=${encodeURIComponent('/posts/new')}`); + return; + } + + const form = document.getElementById('form'); + const btn = document.getElementById('btn-submit'); + + form.addEventListener('submit', async (e) => { + e.preventDefault(); + + // 제출 직전 재확인(세션 만료 대비) + if (!isLoggedIn?.()) { + alert('세션이 만료되었습니다.\n다시 로그인해 주세요.'); + location.replace(`/sign-in?redirect=${encodeURIComponent('/posts/new')}`); + return; + } + + const title = form.title.value.trim(); + const content = form.content.value.trim(); + const description = (form.description?.value ?? '').trim(); + + if (!title || !content) { + alert('제목과 내용을 모두 입력해주세요.'); + return; + } + + btn.disabled = true; + try { + const resp = await callApi(`/posts`, { + method: 'POST', + body: JSON.stringify({ + title, + content, + description: description || null + }) + }); + + if (resp.status === 401) { // apiClient가 이미 리다이렉트하지만, 이중 안전망 + alert('로그인이 필요한 기능입니다.'); + return; + } + if (resp.status === 403) { + alert('작성 권한이 없습니다.\n다른 계정으로 로그인해 주세요.'); + return; + } + if (!resp.ok) { + const t = await resp.text().catch(() => ''); + throw new Error(t || '등록 실패'); + } + + let id = null; + try { id = (await resp.json())?.id ?? null; } catch {} + + alert('등록되었습니다.'); + location.replace(id ? `/posts/${id}` : `/posts`); + } catch (err) { + alert(err.message || '등록 실패'); + } finally { + btn.disabled = false; + } + }); +})(); diff --git a/src/main/resources/static/js/post-details.js b/src/main/resources/static/js/post-details.js new file mode 100644 index 0000000..6651266 --- /dev/null +++ b/src/main/resources/static/js/post-details.js @@ -0,0 +1,182 @@ +// /js/post-details.js +// 상세 페이지 전용 스크립트 +// - 삭제 시 상태코드별 친화적 안내 +// - "내 글만" 수정/삭제 허용(가능하면 버튼 자체도 숨김) +// - 소유자를 확실히 알 수 없으면 UI는 보이되 서버 응답으로 차단 + +(() => { + const { apiFetch: callApi, getToken } = window.API || {}; + + // ===== 요소 ===== + const $title = document.getElementById('title'); + const $writer = document.getElementById('writer'); + const $createdAt = document.getElementById('createdAt'); + const $content = document.getElementById('content'); + const $btnEdit = document.getElementById('btn-edit'); + const $btnDel = document.getElementById('btn-del'); + + // ===== 유틸 ===== + function escapeHtml(v) { + return String(v ?? '').replace(/[&<>"']/g, c => ({ + '&':'&', '<':'<', '>':'>', '"':'"', "'":''' + })[c]); + } + function parseApiDate(v) { + if (v == null) return null; + if (typeof v === 'number') return new Date(v); + if (typeof v !== 'string') return null; + if (/[zZ]|[+\-]\d{2}:\d{2}$/.test(v)) return new Date(v); + if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(v)) return new Date(v + 'Z'); + return new Date(v); + } + function formatDateTime(v) { + const d = parseApiDate(v); + if (!d || Number.isNaN(d.getTime())) return v ?? ''; + return d.toLocaleString(); + } + function getId() { + const m = location.pathname.match(/\/posts\/(\d+)/); + return m ? m[1] : null; + } + + // JWT → 현재 사용자 loginId(= sub) + function decodeJwtPayload(token) { + if (!token) return null; + const parts = token.split('.'); + if (parts.length < 2) return null; + try { + const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/'); + const json = decodeURIComponent(escape(atob(b64))); + return JSON.parse(json); + } catch { + try { return JSON.parse(atob(parts[1])); } catch { return null; } + } + } + function getCurrentLoginId() { + const token = typeof getToken === 'function' ? getToken() : null; + const payload = decodeJwtPayload(token); + if (!payload) return null; + return payload.sub || payload.loginId || payload.userId || payload.username || null; + } + + // 상세 응답에서 글 소유자 loginId 후보 추출 (여러 스키마 호환) + function getOwnerLoginIdFromPost(data) { + if (data?.student?.loginId) return data.student.loginId; // 권장 + if (data?.student?.userId) return data.student.userId; // 대체 + return ( + data.ownerLoginId || + data.studentLoginId || + data.authorLoginId || + data.userLoginId || + data.userId || // 평면 키 + null + ); + } + + // 소유자 판별 결과 + let ownerLoginId = null; + let currentLoginId = null; + let canEditOrDelete = true; // 기본: 모르면 UI 보이고 서버가 막게 + + function applyOwnerGuard(postData) { + currentLoginId = getCurrentLoginId(); + ownerLoginId = getOwnerLoginIdFromPost(postData); + + // 두 값이 모두 있을 때만 엄격 비교해서 숨김 + if (currentLoginId && ownerLoginId) { + canEditOrDelete = (currentLoginId === ownerLoginId); + if (!canEditOrDelete) { + $btnEdit?.classList.add('hidden'); // CSS: .hidden { display:none !important; } + $btnDel?.classList.add('hidden'); + } + } else { + // 소유자 정보를 확정 못하면 UI는 보이게 두고 서버 응답으로 차단 + canEditOrDelete = true; + } + } + + // ===== 데이터 로드 & 렌더 ===== + async function load() { + const id = getId(); + if (!id) return; + + const resp = await callApi(`/posts/${encodeURIComponent(id)}`); + if (!resp.ok) { + const t = await resp.text().catch(()=> ''); + alert(t || '상세 조회 실패'); + return; + } + const data = await resp.json(); + + const title = data.title ?? '(제목)'; + const created = formatDateTime(data.createdAt ?? data.updatedAt ?? ''); + const body = (data.content ?? data.description ?? '').toString(); + + const writerRaw = data.authorName ?? data.username ?? data.studentName ?? data.student?.name ?? ''; + const writer = (writerRaw ?? '').toString().trim() || '-'; + + $title && ($title.textContent = title); + $writer && ($writer.textContent = writer); + $createdAt && ($createdAt.textContent = created || '-'); + $content && ($content.innerHTML = `
    ${escapeHtml(body)}
    `); + + applyOwnerGuard(data); + } + + // ===== 버튼 이벤트 ===== + if ($btnEdit) $btnEdit.addEventListener('click', (e) => { + const id = getId(); if (!id) return; + + // 1) 비로그인 → 로그인으로 (새 패턴으로 redirect 유지) + if (!getToken?.()) { + e.preventDefault(); + alert('로그인이 필요한 기능입니다.\n로그인 페이지로 이동합니다.'); + location.href = `/sign-in?redirect=${encodeURIComponent(`/posts/edit/${id}`)}`; + return; + } + + // 2) 소유자를 확실히 알 수 있고, 내가 아니면 이동 차단 + if (currentLoginId && ownerLoginId && currentLoginId !== ownerLoginId) { + e.preventDefault(); + alert('본인이 작성한 글만 수정할 수 있습니다.'); + return; + } + + // 3) 수정 페이지로 이동 (새 패턴) + location.href = `/posts/edit/${id}`; + }); + + if ($btnDel) $btnDel.addEventListener('click', async (e) => { + const id = getId(); if (!id) return; + + // 비로그인 → 상세로 돌아오도록 유지(기존 동작) + if (!getToken?.()) { + e.preventDefault(); + alert('로그인이 필요한 기능입니다.\n로그인 페이지로 이동합니다.'); + location.href = `/sign-in?redirect=${encodeURIComponent(`/posts/${id}`)}`; + return; + } + + // 소유자 확정 + 내가 아니면 즉시 차단 + if (currentLoginId && ownerLoginId && currentLoginId !== ownerLoginId) { + e.preventDefault(); + alert('본인이 작성한 글만 삭제할 수 있습니다.'); + return; + } + + if (!confirm('정말 삭제하시겠습니까?')) return; + + const resp = await callApi(`/posts/${encodeURIComponent(id)}`, { method: 'DELETE' }); + + // 상태코드별 친화적 메시지 + if (resp.status === 401) { alert('로그인이 필요합니다.'); location.href = '/sign-in'; return; } + if (resp.status === 403) { alert('본인이 작성한 글만 삭제할 수 있습니다.'); return; } + if (resp.status === 404) { alert('이미 삭제되었거나 존재하지 않는 게시글입니다.'); return; } + if (!resp.ok) { alert('삭제에 실패했습니다.'); return; } + + alert('삭제되었습니다.'); + location.href = '/posts'; + }); + + load(); +})(); diff --git a/src/main/resources/static/js/post-list.js b/src/main/resources/static/js/post-list.js new file mode 100644 index 0000000..ccc00a5 --- /dev/null +++ b/src/main/resources/static/js/post-list.js @@ -0,0 +1,178 @@ +// 게시글 목록 페이지 스크립트 (최신 글 페이지 자동 점프 + 로그인 가드 있는 '새 글 작성') +const { apiFetch: callApi } = window.API; + +const PAGE_SIZE = 20; +let state = { page: 0, q: '' }; + +// 최초 진입 시 최신 글(마지막 페이지)로 자동 이동 +let firstLoad = true; + +// ===== JWT 유틸 (새 글 작성 버튼 가드에만 사용) ===== +function getAccessToken() { + return window.API?.getToken?.() ?? null; // ← Authorization 키만 사용 +} +function decodeJwtPayload(tokenLike) { + const token = tokenLike?.startsWith('Bearer ') ? tokenLike.slice(7) : tokenLike; + if (!token) return null; + const parts = token.split('.'); + if (parts.length < 2) return null; + try { + const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/'); + const json = decodeURIComponent(escape(atob(b64))); + return JSON.parse(json); + } catch { + try { return JSON.parse(atob(parts[1])); } catch { return null; } + } +} + +// ===== DOM ===== +const tbody = document.querySelector('#list tbody'); +const emptyEl = document.getElementById('empty'); +const pageInfo = document.getElementById('page-info'); +const prevBtn = document.getElementById('prev'); +const nextBtn = document.getElementById('next'); +const searchForm= document.getElementById('search-form'); +const qInput = document.getElementById('q'); +const resetBtn = document.getElementById('btn-reset'); +const newBtn = document.getElementById('btn-new'); + +// 새 글 작성 (비로그인/만료 → 로그인으로 안내) +if (newBtn) { + newBtn.addEventListener('click', (e) => { + e.preventDefault(); + const token = getAccessToken(); + const payload = decodeJwtPayload(token); + const notLoggedIn = !payload || (typeof payload.exp === 'number' && Date.now() >= payload.exp * 1000); + + if (notLoggedIn) { + alert('로그인이 필요한 기능입니다.\n로그인 페이지로 이동합니다.'); + location.href = `/sign-in?redirect=${encodeURIComponent('/posts/new')}`; + return; + } + location.href = '/posts/new'; + }); +} + +// 검색 +searchForm.addEventListener('submit', (e) => { + e.preventDefault(); + state.q = qInput.value.trim(); + state.page = 0; + firstLoad = true; + load(); +}); + +// 초기화 +resetBtn.addEventListener('click', () => { + qInput.value = ''; + state.q = ''; + state.page = 0; + firstLoad = true; + load(); +}); + +// 페이징 +prevBtn.addEventListener('click', () => { + if (state.page > 0) { state.page--; firstLoad = false; load(); } +}); +nextBtn.addEventListener('click', () => { + state.page++; firstLoad = false; load(); +}); + +// 렌더 (관리 열/버튼 없음) +function renderRows(items) { + if (!items || items.length === 0) { + tbody.innerHTML = ''; + emptyEl.style.display = 'block'; + return; + } + emptyEl.style.display = 'none'; + + tbody.innerHTML = items.map(row => { + const id = row.id; + const title = row.title ?? '(제목 없음)'; + const writer = (row.authorName ?? row.username ?? row.studentName ?? '').toString().trim() || '-'; + const createdRaw = row.createdAt ?? row.updatedAt ?? row.createdDate ?? ''; + const createdAt = formatDateTime(createdRaw); + + return ` + + ${escapeHtml(id)} + + + ${escapeHtml(title)} + + + ${escapeHtml(writer)} + ${escapeHtml(createdAt)} + + `; + }).join(''); +} + +function renderPager(data) { + const cur = (data.number ?? state.page) + 1; + const total = data.totalPages ?? 1; + pageInfo.textContent = `페이지 ${cur} / ${total}`; + prevBtn.disabled = data.first === true || (data.number ?? 0) <= 0; + nextBtn.disabled = data.last === true || cur >= total; +} + +// 데이터 로드 +async function load() { + const params = new URLSearchParams({ page: state.page, size: PAGE_SIZE }); + params.append('sort', 'id,desc'); + if (state.q) params.append('titleSearch', state.q); + + const resp = await callApi(`/posts?${params.toString()}`); + if (!resp.ok) { + const t = await resp.text().catch(() => ''); + console.error('[POST LIST] API 실패', resp.status, t); + alert(t || '목록 조회 실패'); + return; + } + + const data = await resp.json(); + + if (firstLoad) { + firstLoad = false; + const last = Math.max(0, (data.totalPages ?? 1) - 1); + if (state.page !== last) { + state.page = last; + return load(); + } + } + + const rows = data.content || []; + + if (rows.length === 0 && (data.totalPages ?? 1) > 0 && state.page > 0) { + state.page = Math.min(state.page, (data.totalPages ?? 1) - 1); + return load(); + } + + renderRows(rows); + renderPager(data); +} + +// ===== 유틸 ===== +function escapeHtml(v) { + return String(v ?? '').replace(/[&<>"']/g, c => ({ + '&':'&', '<':'<', '>':'>', '"':'"', "'":''' + })[c]); +} +function parseApiDate(v) { + if (v == null) return null; + if (typeof v === 'number') return new Date(v); + if (typeof v !== 'string') return null; + if (/[zZ]|[+\-]\d{2}:\d{2}$/.test(v)) return new Date(v); + if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(v)) return new Date(v + 'Z'); + return new Date(v); +} +function formatDateTime(v) { + const d = parseApiDate(v); + if (!d || Number.isNaN(d.getTime())) return v ?? ''; + return d.toLocaleString(); +} + +// 시작 +load(); diff --git a/src/main/resources/static/js/post-update.js b/src/main/resources/static/js/post-update.js new file mode 100644 index 0000000..65fa6ea --- /dev/null +++ b/src/main/resources/static/js/post-update.js @@ -0,0 +1,142 @@ +// /js/post-update.js +// 수정 페이지 전용 스크립트 +// - 비로그인 접근 → 로그인으로 안내(redirect 유지) +// - 로드 시 소유자 확인 가능하면 내 글이 아닐 때 즉시 되돌림(상세로) +// - 저장(PUT) 시 상태코드별 친화적 안내 + +(() => { + const { apiFetch: callApi, getToken } = window.API || {}; + + // ===== JWT 유틸 ===== + function decodeJwtPayload(token) { + if (!token) return null; + const parts = token.split('.'); + if (parts.length < 2) return null; + try { + const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/'); + const json = decodeURIComponent(escape(atob(b64))); + return JSON.parse(json); + } catch { + try { return JSON.parse(atob(parts[1])); } catch { return null; } + } + } + function getCurrentLoginId() { + const token = getToken?.(); + const payload = decodeJwtPayload(token); + if (!payload) return null; + return payload.sub || payload.loginId || payload.userId || payload.username || null; + } + + // ✅ 새 패턴(/posts/edit/{id}) 인식 (필요시 옛 패턴도 함께 인식하려면 주석 해제) + function getId() { + // 새 패턴 + let m = location.pathname.match(/^\/posts\/edit\/(\d+)\/?$/); + if (m) return m[1]; + // // 옛 패턴(호환용) + // m = location.pathname.match(/^\/posts\/(\d+)\/edit\/?$/); + // if (m) return m[1]; + return null; + } + + function getOwnerLoginIdFromPost(data) { + if (data?.student?.loginId) return data.student.loginId; + if (data?.student?.userId) return data.student.userId; + return ( + data.ownerLoginId || + data.studentLoginId || + data.authorLoginId || + data.userLoginId || + data.userId || + null + ); + } + + // ===== 진입 가드: 비로그인 → 로그인으로 ===== + const id = getId(); + if (!getToken?.()) { + alert('로그인이 필요한 기능입니다.\n로그인 페이지로 이동합니다.'); + // ✅ 새 경로로 redirect 유지 + location.replace(`/sign-in?redirect=${encodeURIComponent(`/posts/edit/${id}`)}`); + return; + } + + const form = document.getElementById('form'); + const btn = document.getElementById('btn-submit'); + const cancel = document.getElementById('btn-cancel'); + + if (cancel) { + cancel.addEventListener('click', (e) => { + e.preventDefault(); + location.href = `/posts/${id}`; + }); + } + + // ===== 초기 데이터 로드(+ 소유자 체크) ===== + (async function load() { + const resp = await callApi(`/posts/${id}`); + if (!resp.ok) { + const t = await resp.text().catch(() => ''); + alert(t || '게시글 조회 실패'); + location.replace(`/posts/${id}`); + return; + } + const data = await resp.json(); + + // 소유자 판단 가능하면 내 글이 아니면 즉시 차단 + const current = getCurrentLoginId(); + const owner = getOwnerLoginIdFromPost(data); + if (current && owner && current !== owner) { + alert('본인이 작성한 글만 수정할 수 있습니다.'); + location.replace(`/posts/${id}`); + return; + } + + // 폼 채우기 + form.title.value = data.title ?? ''; + form.content.value = data.content ?? data.description ?? ''; + })(); + + // ===== 저장 ===== + form.addEventListener('submit', async (e) => { + e.preventDefault(); + + if (!getToken?.()) { + alert('세션이 만료되었습니다.\n다시 로그인해 주세요.'); + // ✅ 새 경로로 redirect 유지 + location.replace(`/sign-in?redirect=${encodeURIComponent(`/posts/edit/${id}`)}`); + return; + } + + const title = form.title.value.trim(); + const content = form.content.value.trim(); + if (!title || !content) { + alert('제목과 내용을 모두 입력해주세요.'); + return; + } + + btn.disabled = true; + try { + const resp = await callApi(`/posts/${id}`, { + method: 'PUT', + body: JSON.stringify({ title, content }) + }); + + // 상태코드별 안내 + if (resp.status === 401) { alert('로그인이 필요합니다.'); location.href = '/sign-in'; return; } + if (resp.status === 403) { alert('본인이 작성한 글만 수정할 수 있습니다.'); return; } + if (resp.status === 404) { alert('존재하지 않는 게시글입니다.'); location.replace('/posts'); return; } + if (resp.status >= 500){ alert('다른 사용자의 글은 수정할 수 없습니다.'); return; } + if (!resp.ok) { + const t = await resp.text().catch(() => ''); + throw new Error(t || '수정 실패'); + } + + alert('수정되었습니다.'); + location.replace(`/posts/${id}`); + } catch (err) { + alert(err.message || '수정 실패'); + } finally { + btn.disabled = false; + } + }); +})(); diff --git a/src/main/resources/static/js/signup-1.0.js b/src/main/resources/static/js/signup-1.0.js new file mode 100644 index 0000000..4d9a429 --- /dev/null +++ b/src/main/resources/static/js/signup-1.0.js @@ -0,0 +1,151 @@ +// 상태 플래그 +let isIdChecked = false; +let lastCheckedId = ""; + +// 요소 참조 +const usernameEl = document.getElementById('username'); +const checkIdMsgEl = document.getElementById('checkIdMessage'); +const signupMsgEl = document.getElementById('signupMessage'); +const checkBtn = document.getElementById('checkIdBtn'); + +// 메시지 유틸 +function setMsg(el, text, color=''){ if(!el) return; el.textContent = text; el.style.color = color || ''; } + +// 아이디 입력 변경 시 중복확인 초기화 +usernameEl?.addEventListener('input', () => { + isIdChecked = false; lastCheckedId = ''; setMsg(checkIdMsgEl, ''); setMsg(signupMsgEl, ''); +}); + +// 중복요청 제어 +let currentAbort = null; +let lastRequestToken = 0; + +// 아이디 중복 확인 (★ loginId 파라미터 사용) +checkBtn?.addEventListener('click', async () => { + const loginId = (usernameEl?.value || '').trim(); + if (!loginId) { setMsg(checkIdMsgEl, '아이디를 입력해주세요.', 'red'); isIdChecked = false; return; } + + if (currentAbort) currentAbort.abort(); + currentAbort = new AbortController(); + const signal = currentAbort.signal; + + const prevBtnText = checkBtn.textContent; + checkBtn.disabled = true; checkBtn.textContent = '확인 중...'; + setMsg(checkIdMsgEl, '중복 여부를 확인하는 중입니다...', '#555'); + + const reqToken = ++lastRequestToken; + + try { + const res = await fetch(`/api/v1/auth/check-id?loginId=${encodeURIComponent(loginId)}`, { + method:'GET', headers:{ 'Accept':'application/json' }, cache:'no-store', signal + }); + + if (reqToken !== lastRequestToken) return; + + if (res.status === 200) { // 사용 가능 + setMsg(checkIdMsgEl, '사용 가능한 아이디입니다.', 'green'); + isIdChecked = true; lastCheckedId = loginId; return; + } + if (res.status === 409) { // 이미 존재 + setMsg(checkIdMsgEl, '이미 사용 중인 아이디입니다.', 'red'); + isIdChecked = false; return; + } + + // 다른 구현 대비(선택) + if (res.ok) { + const ct = res.headers.get('content-type') || ''; + if (ct.includes('application/json')) { + try { + const data = await res.json(); + if (typeof data?.available === 'boolean') { + if (data.available) { setMsg(checkIdMsgEl, '사용 가능한 아이디입니다.', 'green'); isIdChecked = true; lastCheckedId = loginId; } + else { setMsg(checkIdMsgEl, '이미 사용 중인 아이디입니다.', 'red'); isIdChecked = false; } + return; + } + } catch {} + } + } + setMsg(checkIdMsgEl, '서버 오류로 중복 확인에 실패했습니다.', 'red'); + isIdChecked = false; + + } catch (err) { + if (err.name !== 'AbortError') { console.error(err); setMsg(checkIdMsgEl, '서버에 연결할 수 없습니다.', 'red'); isIdChecked = false; } + } finally { + if (reqToken === lastRequestToken) { checkBtn.disabled = false; checkBtn.textContent = prevBtnText; } + } +}); + +// 폼 제출 +document.getElementById('signupForm')?.addEventListener('submit', async (e) => { + e.preventDefault(); + + const name = (document.getElementById('name')?.value || '').trim(); + const studentId = (document.getElementById('studentId')?.value || '').trim(); + const phone = (document.getElementById('phone')?.value || '').trim(); + const loginId = (usernameEl?.value || '').trim(); + const password = (document.getElementById('password')?.value || ''); + const confirmPassword = (document.getElementById('confirmPassword')?.value || ''); + const email = (document.getElementById('email')?.value || '').trim(); + + if (!name || !studentId || !phone || !loginId || !password || !confirmPassword || !email) { setMsg(signupMsgEl, '모든 항목을 입력해주세요.', 'red'); return; } + if (password !== confirmPassword) { setMsg(signupMsgEl, '비밀번호가 일치하지 않습니다.', 'red'); return; } + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { setMsg(signupMsgEl, '올바른 이메일 형식을 입력하세요.', 'red'); return; } + if (password.length < 8) { setMsg(signupMsgEl, '비밀번호는 최소 8자 이상이어야 합니다.', 'red'); return; } + if (!/^010\d{7,8}$/.test(phone)) { setMsg(signupMsgEl, '전화번호는 010으로 시작하고 하이픈 없이 숫자만 입력하세요.', 'red'); return; } + if (!isIdChecked || lastCheckedId !== loginId) { setMsg(signupMsgEl, '아이디 중복 확인을 먼저 해주세요.', 'red'); return; } + + try { + const res = await fetch('/api/v1/auth/register', { + method:'POST', + headers:{ 'Content-Type':'application/json' }, + body: JSON.stringify({ name, studentNumber:studentId, phoneNumber:phone, userId:loginId, password, email }) + }); + + if (res.ok) { alert('회원가입 성공! 로그인 페이지로 이동합니다.'); window.location.href = '/sign-in'; return; } + + let errorMessage = '회원가입 실패. 입력값을 확인해주세요.'; + try { + const ct = res.headers.get('content-type') || ''; + if (ct.includes('application/json')) { + const data = await res.json(); + if (data?.message) errorMessage = data.message; + } else { + const text = await res.text(); if (text) errorMessage = text; + } + } catch {} + + setMsg(signupMsgEl, errorMessage, 'red'); + } catch (err) { + console.error(err); + setMsg(signupMsgEl, '서버와의 연결에 실패했습니다.', 'red'); + } +}); + +// 전화번호: 숫자만, 11자리 제한 +document.getElementById('phone')?.addEventListener('input', function () { + let v = this.value.replace(/[^0-9]/g, ''); + if (v.length > 11) v = v.slice(0, 11); + this.value = v; +}); + +// 비밀번호 보기/숨김 토글 +function attachPwToggle(inputId, btnId){ + const input = document.getElementById(inputId); + const btn = document.getElementById(btnId); + if(!input || !btn) return; + + const icon = btn.querySelector('.mi'); // visibility / visibility_off + btn.setAttribute('aria-pressed','false'); + btn.setAttribute('aria-label','비밀번호 보기'); + if(icon) icon.textContent = 'visibility_off'; + + btn.addEventListener('click', ()=>{ + const show = input.type === 'password'; + input.type = show ? 'text' : 'password'; + btn.setAttribute('aria-pressed', String(show)); + btn.setAttribute('aria-label', show ? '비밀번호 숨기기' : '비밀번호 보기'); + if(icon) icon.textContent = show ? 'visibility' : 'visibility_off'; + }); +} +attachPwToggle('password','togglePwBtn'); +attachPwToggle('confirmPassword','toggleConfirmPwBtn'); diff --git a/src/main/resources/static/js/write-page-1.0.js b/src/main/resources/static/js/write-page-1.0.js new file mode 100644 index 0000000..69a1035 --- /dev/null +++ b/src/main/resources/static/js/write-page-1.0.js @@ -0,0 +1,43 @@ +// 에디터 포맷 명령 실행 +function formatText(command, value = null) { + document.execCommand(command, false, value); +} + +// 글쓰기 저장 기능 +document.getElementById('write-form').addEventListener('submit', function (e) { + e.preventDefault(); + + const title = document.getElementById('title').value.trim(); + const content = document.getElementById('editor').innerHTML.trim(); + + if (!title || !content) { + alert('제목과 본문을 모두 입력해주세요.'); + return; + } + + // 숨은 필드에 에디터 내용 저장 + document.getElementById('content-hidden').value = content; + + const posts = JSON.parse(localStorage.getItem('posts') || '[]'); + posts.unshift({ title, content, date: new Date().toLocaleString() }); + localStorage.setItem('posts', JSON.stringify(posts)); + + alert('✅ 글이 저장되었습니다!'); + this.reset(); + document.getElementById('editor').innerHTML = ''; +}); + +// 햄버거 메뉴 토글 +const toggleBtn = document.getElementById('menuToggle'); +const nav = document.getElementById('mainNav'); +const overlay = document.getElementById('overlay'); + +toggleBtn.addEventListener('click', () => { + nav.classList.toggle('active'); + overlay.classList.toggle('active'); +}); + +overlay.addEventListener('click', () => { + nav.classList.remove('active'); + overlay.classList.remove('active'); +}); diff --git a/src/main/resources/templates/Mainpage/mainpage-1.0.html b/src/main/resources/templates/Mainpage/mainpage-1.0.html new file mode 100644 index 0000000..db21838 --- /dev/null +++ b/src/main/resources/templates/Mainpage/mainpage-1.0.html @@ -0,0 +1,50 @@ + + + + + SE LAB 메인페이지 + + + + + + + +
    + + +
    +
    + SE LAB 로고 + SE LAB +
    + + + + + + +
    + + +
    + + +
    +

    여기에 메인 콘텐츠를 작성하세요.

    +
    + + +
    + ⓒ 2025 SE LAB. All rights reserved. +
    + +
    + + diff --git a/src/main/resources/templates/login/login-1.0.html b/src/main/resources/templates/login/login-1.0.html new file mode 100644 index 0000000..db30afd --- /dev/null +++ b/src/main/resources/templates/login/login-1.0.html @@ -0,0 +1,31 @@ + + + + + 로그인 + + + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/post/post-create.html b/src/main/resources/templates/post/post-create.html new file mode 100644 index 0000000..6f474c6 --- /dev/null +++ b/src/main/resources/templates/post/post-create.html @@ -0,0 +1,87 @@ + + + + + + + SE LAB | 글 작성 + + + + + + + + + + +
    + +
    +
    + SE LAB 로고 + SE LAB +
    + + + + + + +
    + + +
    + + +
    +

    글 작성

    + + +
    + + + + + +
    + 취소 + +
    +
    +
    + + +
    ⓒ 2025 SE LAB. All rights reserved.
    +
    + + + + + + diff --git a/src/main/resources/templates/post/post-details.html b/src/main/resources/templates/post/post-details.html new file mode 100644 index 0000000..b99ba47 --- /dev/null +++ b/src/main/resources/templates/post/post-details.html @@ -0,0 +1,86 @@ + + + + + + + SE LAB | 게시글 상세 + + + + + + + + + + +
    + +
    +
    + SE LAB 로고 + SE LAB +
    + + + + +
    + + +
    + + +
    + +
    + + ← 목록 +
    + + + +
    + + +
    + +

    (제목)

    + + +
    + 작성자: - + · + 작성일: - +
    + + +
    +
    +
    + + +
    ⓒ 2025 SE LAB. All rights reserved.
    +
    + + + + + + diff --git a/src/main/resources/templates/post/post-list.html b/src/main/resources/templates/post/post-list.html new file mode 100644 index 0000000..ce7af73 --- /dev/null +++ b/src/main/resources/templates/post/post-list.html @@ -0,0 +1,102 @@ + + + + + SE LAB | 게시글 목록 + + + + + + + + + + + + + + + + + +
    + +
    +
    + SE LAB 로고 + SE LAB +
    + + + + + + +
    + + +
    + + +
    +

    게시글 목록

    + + +
    + +
    + + + + +
    + +
    + + + 새 글 작성 +
    + + +
    + + + + + + + + + + + + + + +
    ID제목작성자작성일
    + + + +
    + + +
    + + + + +
    +
    + + +
    ⓒ 2025 SE LAB. All rights reserved.
    +
    + + + diff --git a/src/main/resources/templates/post/post-update.html b/src/main/resources/templates/post/post-update.html new file mode 100644 index 0000000..2c46837 --- /dev/null +++ b/src/main/resources/templates/post/post-update.html @@ -0,0 +1,89 @@ + + + + + + + SE LAB | 글 수정 + + + + + + + + + + +
    + +
    +
    + SE LAB 로고 + SE LAB +
    + + + +
    + + +
    + + +
    +

    글 수정

    + + +
    + + + + + +
    + 취소 + +
    +
    +
    + + +
    ⓒ 2025 SE LAB. All rights reserved.
    +
    + + + + + + diff --git a/src/main/resources/templates/post/post_details.html b/src/main/resources/templates/post/post_details.html deleted file mode 100644 index 566549b..0000000 --- a/src/main/resources/templates/post/post_details.html +++ /dev/null @@ -1,10 +0,0 @@ - - - - - Title - - - - - \ No newline at end of file diff --git a/src/main/resources/templates/post/post_list.html b/src/main/resources/templates/post/post_list.html deleted file mode 100644 index 566549b..0000000 --- a/src/main/resources/templates/post/post_list.html +++ /dev/null @@ -1,10 +0,0 @@ - - - - - Title - - - - - \ No newline at end of file diff --git a/src/main/resources/templates/post/post_update.html b/src/main/resources/templates/post/post_update.html deleted file mode 100644 index 566549b..0000000 --- a/src/main/resources/templates/post/post_update.html +++ /dev/null @@ -1,10 +0,0 @@ - - - - - Title - - - - - \ No newline at end of file diff --git a/src/main/resources/templates/profile/login.html b/src/main/resources/templates/profile/login.html new file mode 100644 index 0000000..e69de29 diff --git a/src/main/resources/templates/profile/signup.html b/src/main/resources/templates/profile/signup.html new file mode 100644 index 0000000..554a31e --- /dev/null +++ b/src/main/resources/templates/profile/signup.html @@ -0,0 +1,19 @@ + + + + + 회원가입 + + + + + + diff --git a/src/main/resources/templates/signup/signup-1.0.html b/src/main/resources/templates/signup/signup-1.0.html new file mode 100644 index 0000000..f052023 --- /dev/null +++ b/src/main/resources/templates/signup/signup-1.0.html @@ -0,0 +1,66 @@ + + + + + + 회원가입 + + + + + + + + + + + + + + + diff --git a/src/main/resources/templates/write-page/write-page-1.0.html b/src/main/resources/templates/write-page/write-page-1.0.html new file mode 100644 index 0000000..b6e5a1e --- /dev/null +++ b/src/main/resources/templates/write-page/write-page-1.0.html @@ -0,0 +1,73 @@ + + + + + 글쓰기 - SE LAB + + + + + +
    + + +
    +
    + SE LAB 로고 + SE LAB +
    + + + + +
    + +
    + + +
    +
    +
    + + + + + +
    + + + + + +
    + + +
    + + + +
    +
    +
    + + +
    + ⓒ 2025 SE LAB. All rights reserved. +
    +
    + + + + +