From 0476bc0b97bf0bd5ce814405797107bd1620ef5e Mon Sep 17 00:00:00 2001 From: pbem22 Date: Mon, 20 Jul 2026 23:41:01 +0900 Subject: [PATCH 01/14] =?UTF-8?q?feat:=20#212=20=EC=96=B4=EB=93=9C?= =?UTF-8?q?=EB=AF=BC=20=ED=8C=A8=EB=84=90=20=EA=B5=AC=ED=98=84=20(SSR)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Thymeleaf + Bootstrap 5 기반 SSR 어드민 패널 추가 - InMemoryUserDetailsManager로 환경변수 기반 어드민 계정 관리 - Spring Security 별도 FilterChain(@Order(1))으로 API와 세션 분리 - 대시보드(통계), 유저 관리(정지/해제/강제탈퇴), 분철글 관리(상태필터/삭제) 구현 - UserStatus에 SUSPENDED 추가, User에 suspend/unsuspend/withdraw(reason) 메서드 추가 - ErrorStatus에 PROMPT_ERROR(50005) 추가 --- build.gradle | 7 ++ .../admin/controller/AdminController.java | 82 +++++++++++++++++++ .../domain/admin/service/AdminService.java | 78 ++++++++++++++++++ .../poti/domain/auth/service/AuthService.java | 2 +- .../repository/GroupBuyRepository.java | 4 + .../sopt/poti/domain/user/entity/User.java | 16 +++- .../poti/domain/user/entity/UserStatus.java | 1 + .../global/config/AdminSecurityConfig.java | 65 +++++++++++++++ .../sopt/poti/global/error/ErrorStatus.java | 3 +- .../poti/global/security/SecurityConfig.java | 2 + src/main/resources/application.yml | 11 +++ .../resources/templates/admin/dashboard.html | 35 ++++++++ .../resources/templates/admin/layout.html | 52 ++++++++++++ src/main/resources/templates/admin/login.html | 33 ++++++++ src/main/resources/templates/admin/posts.html | 75 +++++++++++++++++ src/main/resources/templates/admin/users.html | 79 ++++++++++++++++++ 16 files changed, 541 insertions(+), 4 deletions(-) create mode 100644 src/main/java/org/sopt/poti/domain/admin/controller/AdminController.java create mode 100644 src/main/java/org/sopt/poti/domain/admin/service/AdminService.java create mode 100644 src/main/java/org/sopt/poti/global/config/AdminSecurityConfig.java create mode 100644 src/main/resources/templates/admin/dashboard.html create mode 100644 src/main/resources/templates/admin/layout.html create mode 100644 src/main/resources/templates/admin/login.html create mode 100644 src/main/resources/templates/admin/posts.html create mode 100644 src/main/resources/templates/admin/users.html diff --git a/build.gradle b/build.gradle index ad36883d..286a7500 100644 --- a/build.gradle +++ b/build.gradle @@ -65,6 +65,13 @@ dependencies { // Swagger (SpringDoc) implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.3' + + // Firebase Admin SDK + implementation 'com.google.firebase:firebase-admin:9.2.0' + + // Thymeleaf (어드민 SSR) + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6' } ext { diff --git a/src/main/java/org/sopt/poti/domain/admin/controller/AdminController.java b/src/main/java/org/sopt/poti/domain/admin/controller/AdminController.java new file mode 100644 index 00000000..ce497439 --- /dev/null +++ b/src/main/java/org/sopt/poti/domain/admin/controller/AdminController.java @@ -0,0 +1,82 @@ +package org.sopt.poti.domain.admin.controller; + +import lombok.RequiredArgsConstructor; +import org.sopt.poti.domain.admin.service.AdminService; +import org.sopt.poti.domain.groupbuy.entity.GroupBuyPostStatus; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; + +@Controller +@RequestMapping("/admin") +@RequiredArgsConstructor +public class AdminController { + + private final AdminService adminService; + + @GetMapping("/login") + public String loginPage() { + return "admin/login"; + } + + @GetMapping("/dashboard") + public String dashboard(Model model) { + model.addAttribute("userCount", adminService.countUsers()); + model.addAttribute("postCount", adminService.countPosts()); + model.addAttribute("orderCount", adminService.countOrders()); + return "admin/dashboard"; + } + + @GetMapping("/users") + public String users( + @RequestParam(defaultValue = "0") int page, + Model model + ) { + PageRequest pageable = PageRequest.of(page, 20, Sort.by("id").descending()); + model.addAttribute("users", adminService.getUsers(pageable)); + return "admin/users"; + } + + @PostMapping("/users/{userId}/suspend") + public String suspend(@PathVariable Long userId) { + adminService.suspendUser(userId); + return "redirect:/admin/users"; + } + + @PostMapping("/users/{userId}/unsuspend") + public String unsuspend(@PathVariable Long userId) { + adminService.unsuspendUser(userId); + return "redirect:/admin/users"; + } + + @PostMapping("/users/{userId}/withdraw") + public String forceWithdraw(@PathVariable Long userId) { + adminService.forceWithdrawUser(userId); + return "redirect:/admin/users"; + } + + @GetMapping("/posts") + public String posts( + @RequestParam(required = false) GroupBuyPostStatus status, + @RequestParam(defaultValue = "0") int page, + Model model + ) { + PageRequest pageable = PageRequest.of(page, 20, Sort.by("id").descending()); + model.addAttribute("posts", adminService.getPosts(status, pageable)); + model.addAttribute("selectedStatus", status); + model.addAttribute("statuses", GroupBuyPostStatus.values()); + return "admin/posts"; + } + + @PostMapping("/posts/{postId}/delete") + public String deletePost(@PathVariable Long postId) { + adminService.deletePost(postId); + return "redirect:/admin/posts"; + } +} diff --git a/src/main/java/org/sopt/poti/domain/admin/service/AdminService.java b/src/main/java/org/sopt/poti/domain/admin/service/AdminService.java new file mode 100644 index 00000000..a5ac6890 --- /dev/null +++ b/src/main/java/org/sopt/poti/domain/admin/service/AdminService.java @@ -0,0 +1,78 @@ +package org.sopt.poti.domain.admin.service; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.sopt.poti.domain.groupbuy.entity.GroupBuyPost; +import org.sopt.poti.domain.groupbuy.entity.GroupBuyPostStatus; +import org.sopt.poti.domain.groupbuy.repository.GroupBuyRepository; +import org.sopt.poti.domain.order.repository.OrderRepository; +import org.sopt.poti.domain.user.entity.User; +import org.sopt.poti.domain.user.entity.UserStatus; +import org.sopt.poti.domain.user.repository.UserRepository; +import org.sopt.poti.global.error.BusinessException; +import org.sopt.poti.global.error.ErrorStatus; +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 +@Transactional(readOnly = true) +public class AdminService { + + private final UserRepository userRepository; + private final GroupBuyRepository groupBuyRepository; + private final OrderRepository orderRepository; + + public long countUsers() { + return userRepository.count(); + } + + public long countPosts() { + return groupBuyRepository.count(); + } + + public long countOrders() { + return orderRepository.count(); + } + + public Page getUsers(Pageable pageable) { + return userRepository.findAll(pageable); + } + + public Page getPosts(GroupBuyPostStatus status, Pageable pageable) { + if (status != null) { + return groupBuyRepository.findByStatus(status, pageable); + } + return groupBuyRepository.findAll(pageable); + } + + @Transactional + public void suspendUser(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorStatus.USER_NOT_FOUND)); + user.suspend(); + } + + @Transactional + public void unsuspendUser(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorStatus.USER_NOT_FOUND)); + user.unsuspend(); + } + + @Transactional + public void forceWithdrawUser(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorStatus.USER_NOT_FOUND)); + user.withdraw(null); + } + + @Transactional + public void deletePost(Long postId) { + GroupBuyPost post = groupBuyRepository.findById(postId) + .orElseThrow(() -> new BusinessException(ErrorStatus.POST_NOT_FOUND)); + groupBuyRepository.delete(post); + } +} diff --git a/src/main/java/org/sopt/poti/domain/auth/service/AuthService.java b/src/main/java/org/sopt/poti/domain/auth/service/AuthService.java index dddcea13..da00314c 100644 --- a/src/main/java/org/sopt/poti/domain/auth/service/AuthService.java +++ b/src/main/java/org/sopt/poti/domain/auth/service/AuthService.java @@ -155,7 +155,7 @@ public void logout(String accessToken, Long userId) { @Transactional public void withdraw(String accessToken, Long userId) { User user = userService.getUserById(userId); - user.withdraw(); // 유저 상태 변경, 개인 정보 마스킹, deletedAt 설정 + user.withdraw(null); // 로그아웃 처리 (refresh token 삭제, access token 블랙리스트 추가) logout(accessToken, userId); diff --git a/src/main/java/org/sopt/poti/domain/groupbuy/repository/GroupBuyRepository.java b/src/main/java/org/sopt/poti/domain/groupbuy/repository/GroupBuyRepository.java index d0fcae03..49b120f8 100644 --- a/src/main/java/org/sopt/poti/domain/groupbuy/repository/GroupBuyRepository.java +++ b/src/main/java/org/sopt/poti/domain/groupbuy/repository/GroupBuyRepository.java @@ -2,6 +2,8 @@ import java.util.List; import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.sopt.poti.domain.groupbuy.entity.GroupBuyPost; import org.sopt.poti.domain.groupbuy.entity.GroupBuyPostStatus; import org.springframework.data.jpa.repository.JpaRepository; @@ -28,4 +30,6 @@ List findByLeader_IdAndStatusInOrderByCreatedAtDesc(Long leaderId, List statuses); boolean existsByOrderNumber(String orderNumber); + + Page findByStatus(GroupBuyPostStatus status, Pageable pageable); } diff --git a/src/main/java/org/sopt/poti/domain/user/entity/User.java b/src/main/java/org/sopt/poti/domain/user/entity/User.java index 9fd868f2..79515102 100644 --- a/src/main/java/org/sopt/poti/domain/user/entity/User.java +++ b/src/main/java/org/sopt/poti/domain/user/entity/User.java @@ -63,6 +63,9 @@ public class User extends BaseSoftDeleteEntity { @JoinColumn(name = "favorite_artist_id", nullable = true) private Artist favoriteArtist; + @Column(name = "withdrawal_reason", length = 500) + private String withdrawalReason; + @Builder private User(String socialId, SocialType socialType, String email, String nickname, String profileImageUrl, Artist favoriteArtist, Role role) { @@ -107,12 +110,21 @@ public void updateRatingAvg(double avg) { this.ratingAvg = avg; } - public void withdraw() { + public void suspend() { + this.status = UserStatus.SUSPENDED; + } + + public void unsuspend() { + this.status = UserStatus.ACTIVE; + } + + public void withdraw(String reason) { this.status = UserStatus.WITHDRAWN; this.deletedAt = LocalDateTime.now(); + this.withdrawalReason = reason; this.nickname = "탈퇴한 사용자"; this.email = null; this.profileImageUrl = null; - this.socialId = "deleted_" + this.id + "_" + UUID.randomUUID(); // 유니크 제약 회피 + this.socialId = "deleted_" + this.id + "_" + UUID.randomUUID(); } } \ No newline at end of file diff --git a/src/main/java/org/sopt/poti/domain/user/entity/UserStatus.java b/src/main/java/org/sopt/poti/domain/user/entity/UserStatus.java index ff276799..a16cd63c 100644 --- a/src/main/java/org/sopt/poti/domain/user/entity/UserStatus.java +++ b/src/main/java/org/sopt/poti/domain/user/entity/UserStatus.java @@ -7,6 +7,7 @@ @RequiredArgsConstructor public enum UserStatus { ACTIVE("활동 중"), + SUSPENDED("이용 정지"), WITHDRAWN("탈퇴"); private final String description; diff --git a/src/main/java/org/sopt/poti/global/config/AdminSecurityConfig.java b/src/main/java/org/sopt/poti/global/config/AdminSecurityConfig.java new file mode 100644 index 00000000..76fc675d --- /dev/null +++ b/src/main/java/org/sopt/poti/global/config/AdminSecurityConfig.java @@ -0,0 +1,65 @@ +package org.sopt.poti.global.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +@Order(1) +public class AdminSecurityConfig { + + @Value("${admin.username}") + private String adminUsername; + + @Value("${admin.password}") + private String adminPassword; + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public UserDetailsService adminUserDetailsService() { + return new InMemoryUserDetailsManager( + User.withUsername(adminUsername) + .password(passwordEncoder().encode(adminPassword)) + .roles("ADMIN") + .build() + ); + } + + @Bean + public SecurityFilterChain adminFilterChain(HttpSecurity http) throws Exception { + http + .securityMatcher("/admin/**") + .authorizeHttpRequests(auth -> auth + .requestMatchers("/admin/login").permitAll() + .anyRequest().hasRole("ADMIN") + ) + .formLogin(form -> form + .loginPage("/admin/login") + .loginProcessingUrl("/admin/login") + .defaultSuccessUrl("/admin/dashboard", true) + .failureUrl("/admin/login?error") + .usernameParameter("username") + .passwordParameter("password") + ) + .logout(logout -> logout + .logoutUrl("/admin/logout") + .logoutSuccessUrl("/admin/login") + .invalidateHttpSession(true) + ) + .userDetailsService(adminUserDetailsService()); + + return http.build(); + } +} diff --git a/src/main/java/org/sopt/poti/global/error/ErrorStatus.java b/src/main/java/org/sopt/poti/global/error/ErrorStatus.java index fc5f3b9b..0aede97c 100644 --- a/src/main/java/org/sopt/poti/global/error/ErrorStatus.java +++ b/src/main/java/org/sopt/poti/global/error/ErrorStatus.java @@ -79,7 +79,8 @@ public enum ErrorStatus { EXTERNAL_API_ERROR(50001, HttpStatus.INTERNAL_SERVER_ERROR, "외부 API 호출 중 오류가 발생했습니다."), ENCRYPTION_ERROR(50002, HttpStatus.INTERNAL_SERVER_ERROR, "데이터 암호화 중 오류가 발생했습니다."), DECRYPTION_ERROR(50003, HttpStatus.INTERNAL_SERVER_ERROR, "데이터 복호화 중 오류가 발생했습니다."), - AES_KEY_LENGTH(50004, HttpStatus.INTERNAL_SERVER_ERROR, "암호화 키는 32바이트여야 합니다."); + AES_KEY_LENGTH(50004, HttpStatus.INTERNAL_SERVER_ERROR, "암호화 키는 32바이트여야 합니다."), + PROMPT_ERROR(50005, HttpStatus.INTERNAL_SERVER_ERROR, "AI 프롬프트 처리 중 오류가 발생했습니다."); private final int code; private final HttpStatus httpStatus; diff --git a/src/main/java/org/sopt/poti/global/security/SecurityConfig.java b/src/main/java/org/sopt/poti/global/security/SecurityConfig.java index d2a759fe..027a252e 100644 --- a/src/main/java/org/sopt/poti/global/security/SecurityConfig.java +++ b/src/main/java/org/sopt/poti/global/security/SecurityConfig.java @@ -7,6 +7,7 @@ import org.sopt.poti.global.security.jwt.JwtExceptionFilter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; 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; @@ -17,6 +18,7 @@ @Configuration @EnableWebSecurity @RequiredArgsConstructor +@Order(2) public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f5421127..fc2c9eb4 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -105,6 +105,17 @@ springdoc: disable-swagger-default-url: true # 기본 petstore URL 끄기 display-request-duration: true # 요청 소요 시간 표시 + +firebase: + credentials-path: classpath:firebase-service-account.json + +admin: + username: ${ADMIN_USERNAME} + password: ${ADMIN_PASSWORD} + +gemini: + api-key: ${GEMINI_KEY} + discord: webhook-url: ${DISCORD_WEBHOOK} diff --git a/src/main/resources/templates/admin/dashboard.html b/src/main/resources/templates/admin/dashboard.html new file mode 100644 index 00000000..5535438e --- /dev/null +++ b/src/main/resources/templates/admin/dashboard.html @@ -0,0 +1,35 @@ + + + +
+

대시보드

+
+
+
+
+
총 유저 수
+
0
+
+
+
+
+
+
+
분철글 수
+
0
+
+
+
+
+
+
+
총 주문 수
+
0
+
+
+
+
+
+ + diff --git a/src/main/resources/templates/admin/layout.html b/src/main/resources/templates/admin/layout.html new file mode 100644 index 00000000..5e705f9a --- /dev/null +++ b/src/main/resources/templates/admin/layout.html @@ -0,0 +1,52 @@ + + + + + + POTI 어드민 + + + + + + + +
+ +
+ +
+
+ +
+ + + diff --git a/src/main/resources/templates/admin/login.html b/src/main/resources/templates/admin/login.html new file mode 100644 index 00000000..b26b2d1f --- /dev/null +++ b/src/main/resources/templates/admin/login.html @@ -0,0 +1,33 @@ + + + + + + POTI Admin - 로그인 + + + + + + + diff --git a/src/main/resources/templates/admin/posts.html b/src/main/resources/templates/admin/posts.html new file mode 100644 index 00000000..f3d1f4da --- /dev/null +++ b/src/main/resources/templates/admin/posts.html @@ -0,0 +1,75 @@ + + + +
+

분철글 관리

+ + +
+ 전체 + +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
ID제목아티스트모집자상태등록일관리
+ + +
+ +
+
분철글이 없습니다.
+
+
+ + + +
+ + diff --git a/src/main/resources/templates/admin/users.html b/src/main/resources/templates/admin/users.html new file mode 100644 index 00000000..beed9398 --- /dev/null +++ b/src/main/resources/templates/admin/users.html @@ -0,0 +1,79 @@ + + + +
+

유저 관리

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
ID닉네임이메일소셜상태가입일관리
+ + + + + + +
+
+ +
+
+ +
+
+ +
+
+
유저가 없습니다.
+
+
+ + + +
+ + From 04b74bfd725e684294ec27d92782a037d45abede Mon Sep 17 00:00:00 2001 From: pbem22 Date: Tue, 21 Jul 2026 21:02:46 +0900 Subject: [PATCH 02/14] =?UTF-8?q?fix:=20#212=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=ED=99=98=EA=B2=BD=20=EC=96=B4=EB=93=9C=EB=AF=BC/Gemini=20?= =?UTF-8?q?=ED=99=98=EA=B2=BD=EB=B3=80=EC=88=98=20=EB=8D=94=EB=AF=B8?= =?UTF-8?q?=EA=B0=92=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/test/resources/application-test.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 19c014cd..947f5130 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -54,3 +54,10 @@ discord: security: encryption-key: 12345678901234567890123456789012 + +admin: + username: test-admin + password: test-password + +gemini: + api-key: test-gemini-key From d8120343f02136aa63dcee3388b7678a85cced09 Mon Sep 17 00:00:00 2001 From: pbem22 Date: Tue, 21 Jul 2026 21:04:47 +0900 Subject: [PATCH 03/14] =?UTF-8?q?fix:=20#212=20CodeRabbit=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81=20-=20=EC=A0=95=EC=A7=80=20?= =?UTF-8?q?=EC=9C=A0=EC=A0=80=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=B0=A8?= =?UTF-8?q?=EB=8B=A8=20=EB=B0=8F=20=EC=A0=95=EC=A7=80/=ED=83=88=ED=87=B4?= =?UTF-8?q?=20=EC=8B=9C=20RefreshToken=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/org/sopt/poti/domain/admin/service/AdminService.java | 4 ++++ .../java/org/sopt/poti/domain/auth/service/AuthService.java | 4 +++- src/main/java/org/sopt/poti/global/error/ErrorStatus.java | 1 + 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/sopt/poti/domain/admin/service/AdminService.java b/src/main/java/org/sopt/poti/domain/admin/service/AdminService.java index a5ac6890..b3f012d6 100644 --- a/src/main/java/org/sopt/poti/domain/admin/service/AdminService.java +++ b/src/main/java/org/sopt/poti/domain/admin/service/AdminService.java @@ -2,6 +2,7 @@ import java.util.List; import lombok.RequiredArgsConstructor; +import org.sopt.poti.domain.auth.repository.RefreshTokenRepository; import org.sopt.poti.domain.groupbuy.entity.GroupBuyPost; import org.sopt.poti.domain.groupbuy.entity.GroupBuyPostStatus; import org.sopt.poti.domain.groupbuy.repository.GroupBuyRepository; @@ -24,6 +25,7 @@ public class AdminService { private final UserRepository userRepository; private final GroupBuyRepository groupBuyRepository; private final OrderRepository orderRepository; + private final RefreshTokenRepository refreshTokenRepository; public long countUsers() { return userRepository.count(); @@ -53,6 +55,7 @@ public void suspendUser(Long userId) { User user = userRepository.findById(userId) .orElseThrow(() -> new BusinessException(ErrorStatus.USER_NOT_FOUND)); user.suspend(); + refreshTokenRepository.deleteById(userId); } @Transactional @@ -67,6 +70,7 @@ public void forceWithdrawUser(Long userId) { User user = userRepository.findById(userId) .orElseThrow(() -> new BusinessException(ErrorStatus.USER_NOT_FOUND)); user.withdraw(null); + refreshTokenRepository.deleteById(userId); } @Transactional diff --git a/src/main/java/org/sopt/poti/domain/auth/service/AuthService.java b/src/main/java/org/sopt/poti/domain/auth/service/AuthService.java index da00314c..44d6c8fa 100644 --- a/src/main/java/org/sopt/poti/domain/auth/service/AuthService.java +++ b/src/main/java/org/sopt/poti/domain/auth/service/AuthService.java @@ -59,10 +59,12 @@ public AuthResponse socialLogin(AuthRequest request) { if (existingUser.isPresent()) { user = existingUser.get(); - // 탈퇴한 유저인지 확인 if (user.getStatus() == UserStatus.WITHDRAWN) { throw new BusinessException(ErrorStatus.USER_NOT_FOUND); } + if (user.getStatus() == UserStatus.SUSPENDED) { + throw new BusinessException(ErrorStatus.USER_SUSPENDED); + } isNewUser = (user.getNickname() == null); // TODO: 기존 유저의 정보(닉네임, 프로필 이미지 등)가 변경되었다면 업데이트 로직 추가 } else { diff --git a/src/main/java/org/sopt/poti/global/error/ErrorStatus.java b/src/main/java/org/sopt/poti/global/error/ErrorStatus.java index 0aede97c..46a219b7 100644 --- a/src/main/java/org/sopt/poti/global/error/ErrorStatus.java +++ b/src/main/java/org/sopt/poti/global/error/ErrorStatus.java @@ -53,6 +53,7 @@ public enum ErrorStatus { * 404 Not Found */ USER_NOT_FOUND(40400, HttpStatus.NOT_FOUND, "존재하지 않는 사용자입니다."), + USER_SUSPENDED(40302, HttpStatus.FORBIDDEN, "이용 정지된 계정입니다."), ITEM_NOT_FOUND(40401, HttpStatus.NOT_FOUND, "존재하지 않는 상품입니다."), ARTIST_NOT_FOUND(40402, HttpStatus.NOT_FOUND, "존재하지 않는 아티스트입니다."), NOT_FOUND_HANDLER(40403, HttpStatus.NOT_FOUND, "존재하지 않는 API 경로입니다."), From b6c1d3f7209304c4d0beae2aee33fb2a69927173 Mon Sep 17 00:00:00 2001 From: pbem22 Date: Mon, 27 Jul 2026 16:08:34 +0900 Subject: [PATCH 04/14] remove unused PROMPT_ERROR from ErrorStatus (Gemini feature removed) --- src/main/java/org/sopt/poti/global/error/ErrorStatus.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/java/org/sopt/poti/global/error/ErrorStatus.java b/src/main/java/org/sopt/poti/global/error/ErrorStatus.java index 08605531..4dc7b1b0 100644 --- a/src/main/java/org/sopt/poti/global/error/ErrorStatus.java +++ b/src/main/java/org/sopt/poti/global/error/ErrorStatus.java @@ -81,8 +81,7 @@ public enum ErrorStatus { EXTERNAL_API_ERROR(50001, HttpStatus.INTERNAL_SERVER_ERROR, "외부 API 호출 중 오류가 발생했습니다."), ENCRYPTION_ERROR(50002, HttpStatus.INTERNAL_SERVER_ERROR, "데이터 암호화 중 오류가 발생했습니다."), DECRYPTION_ERROR(50003, HttpStatus.INTERNAL_SERVER_ERROR, "데이터 복호화 중 오류가 발생했습니다."), - AES_KEY_LENGTH(50004, HttpStatus.INTERNAL_SERVER_ERROR, "암호화 키는 32바이트여야 합니다."), - PROMPT_ERROR(50005, HttpStatus.INTERNAL_SERVER_ERROR, "AI 프롬프트 처리 중 오류가 발생했습니다."); + AES_KEY_LENGTH(50004, HttpStatus.INTERNAL_SERVER_ERROR, "암호화 키는 32바이트여야 합니다."); private final int code; From 8593239bd65df36fc7a55be480c745ac050ab12a Mon Sep 17 00:00:00 2001 From: pbem22 Date: Mon, 27 Jul 2026 23:12:20 +0900 Subject: [PATCH 05/14] =?UTF-8?q?fix:=20=EC=A3=BC=EB=AC=B8=20=EC=A1=B4?= =?UTF-8?q?=EC=9E=AC=ED=95=98=EB=8A=94=20=EB=B6=84=EC=B2=A0=EA=B8=80=20?= =?UTF-8?q?=EC=96=B4=EB=93=9C=EB=AF=BC=20=EA=B0=95=EC=A0=9C=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EC=8B=9C=20FK=20=EC=A0=9C=EC=95=BD=20=EC=9C=84?= =?UTF-8?q?=EB=B0=98=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - deletePost() 실행 전 주문 존재 여부 체크 추가 - 주문이 있으면 POST_HAS_ORDERS(40020) 예외 반환 - OrderRepository에 existsByGroupBuyPost_Id() 추가 --- .../org/sopt/poti/domain/admin/service/AdminService.java | 3 +++ .../sopt/poti/domain/order/repository/OrderRepository.java | 5 +++++ src/main/java/org/sopt/poti/global/error/ErrorStatus.java | 1 + 3 files changed, 9 insertions(+) diff --git a/src/main/java/org/sopt/poti/domain/admin/service/AdminService.java b/src/main/java/org/sopt/poti/domain/admin/service/AdminService.java index b3f012d6..80f222e5 100644 --- a/src/main/java/org/sopt/poti/domain/admin/service/AdminService.java +++ b/src/main/java/org/sopt/poti/domain/admin/service/AdminService.java @@ -77,6 +77,9 @@ public void forceWithdrawUser(Long userId) { public void deletePost(Long postId) { GroupBuyPost post = groupBuyRepository.findById(postId) .orElseThrow(() -> new BusinessException(ErrorStatus.POST_NOT_FOUND)); + if (orderRepository.existsByGroupBuyPost_Id(postId)) { + throw new BusinessException(ErrorStatus.POST_HAS_ORDERS); + } groupBuyRepository.delete(post); } } diff --git a/src/main/java/org/sopt/poti/domain/order/repository/OrderRepository.java b/src/main/java/org/sopt/poti/domain/order/repository/OrderRepository.java index 9c234b71..b283cfea 100644 --- a/src/main/java/org/sopt/poti/domain/order/repository/OrderRepository.java +++ b/src/main/java/org/sopt/poti/domain/order/repository/OrderRepository.java @@ -48,4 +48,9 @@ public interface OrderRepository extends JpaRepository, OrderReposi // 특정 게시글에서, 주어진 상태 목록에 해당하는 주문이 몇 개인지 조회 long countByGroupBuyPostIdAndStatusIn(Long postId, Collection statuses); + + boolean existsByGroupBuyPost_Id(Long postId); + + @Query("SELECT o.user.id FROM Order o WHERE o.groupBuyPost.id = :postId") + List findUserIdsByGroupBuyPost_Id(@Param("postId") Long postId); } \ No newline at end of file diff --git a/src/main/java/org/sopt/poti/global/error/ErrorStatus.java b/src/main/java/org/sopt/poti/global/error/ErrorStatus.java index 4dc7b1b0..c715ad17 100644 --- a/src/main/java/org/sopt/poti/global/error/ErrorStatus.java +++ b/src/main/java/org/sopt/poti/global/error/ErrorStatus.java @@ -32,6 +32,7 @@ public enum ErrorStatus { ORDER_NOT_SHIPPED(40017, HttpStatus.BAD_REQUEST, "배송 시작 상태에서만 배송 완료로 변경할 수 있습니다."), POST_NOT_SHIPPING(40018, HttpStatus.BAD_REQUEST, "배송 중인 공구글만 배송 완료로 변경할 수 있습니다."), ACTIVE_TRANSACTION_EXISTS(40019, HttpStatus.BAD_REQUEST, "진행 중인 거래가 있어 탈퇴할 수 없습니다."), + POST_HAS_ORDERS(40020, HttpStatus.BAD_REQUEST, "주문이 존재하는 분철글은 삭제할 수 없습니다."), /** * 401 Unauthorized From 33f94b98cbcda7561c941ab6a840a19e2425e062 Mon Sep 17 00:00:00 2001 From: pbem22 Date: Mon, 27 Jul 2026 23:20:08 +0900 Subject: [PATCH 06/14] =?UTF-8?q?fix:=20=EC=A7=84=ED=96=89=20=EC=A4=91?= =?UTF-8?q?=EC=9D=B8=20=EB=B6=84=EC=B2=A0=EA=B8=80=20=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?=EC=B0=A8=EB=8B=A8=20=EB=B0=8F=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EC=8B=A4=EC=88=98=20=EB=B0=A9=EC=A7=80=20confirm=20=EA=B0=95?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CLOSED/PAYMENT_DONE/SHIPPING 상태 글은 주문 유무 무관하게 삭제 차단 (POST_IN_PROGRESS 40021) - 상태 기반 체크를 주문 존재 체크보다 먼저 수행 - 프론트 confirm: RECRUITING은 일반 경고, 그 외 상태는 '진행 중' 강조 메시지로 구분 --- .../sopt/poti/domain/admin/service/AdminService.java | 10 ++++++++++ .../java/org/sopt/poti/global/error/ErrorStatus.java | 1 + src/main/resources/templates/admin/posts.html | 4 +++- 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/sopt/poti/domain/admin/service/AdminService.java b/src/main/java/org/sopt/poti/domain/admin/service/AdminService.java index 80f222e5..cb5171a4 100644 --- a/src/main/java/org/sopt/poti/domain/admin/service/AdminService.java +++ b/src/main/java/org/sopt/poti/domain/admin/service/AdminService.java @@ -1,6 +1,7 @@ package org.sopt.poti.domain.admin.service; import java.util.List; +import java.util.Set; import lombok.RequiredArgsConstructor; import org.sopt.poti.domain.auth.repository.RefreshTokenRepository; import org.sopt.poti.domain.groupbuy.entity.GroupBuyPost; @@ -73,10 +74,19 @@ public void forceWithdrawUser(Long userId) { refreshTokenRepository.deleteById(userId); } + private static final Set UNDELETABLE_STATUSES = Set.of( + GroupBuyPostStatus.CLOSED, + GroupBuyPostStatus.PAYMENT_DONE, + GroupBuyPostStatus.SHIPPING + ); + @Transactional public void deletePost(Long postId) { GroupBuyPost post = groupBuyRepository.findById(postId) .orElseThrow(() -> new BusinessException(ErrorStatus.POST_NOT_FOUND)); + if (UNDELETABLE_STATUSES.contains(post.getStatus())) { + throw new BusinessException(ErrorStatus.POST_IN_PROGRESS); + } if (orderRepository.existsByGroupBuyPost_Id(postId)) { throw new BusinessException(ErrorStatus.POST_HAS_ORDERS); } diff --git a/src/main/java/org/sopt/poti/global/error/ErrorStatus.java b/src/main/java/org/sopt/poti/global/error/ErrorStatus.java index c715ad17..c51e864a 100644 --- a/src/main/java/org/sopt/poti/global/error/ErrorStatus.java +++ b/src/main/java/org/sopt/poti/global/error/ErrorStatus.java @@ -33,6 +33,7 @@ public enum ErrorStatus { POST_NOT_SHIPPING(40018, HttpStatus.BAD_REQUEST, "배송 중인 공구글만 배송 완료로 변경할 수 있습니다."), ACTIVE_TRANSACTION_EXISTS(40019, HttpStatus.BAD_REQUEST, "진행 중인 거래가 있어 탈퇴할 수 없습니다."), POST_HAS_ORDERS(40020, HttpStatus.BAD_REQUEST, "주문이 존재하는 분철글은 삭제할 수 없습니다."), + POST_IN_PROGRESS(40021, HttpStatus.BAD_REQUEST, "진행 중인 분철글은 삭제할 수 없습니다."), /** * 401 Unauthorized diff --git a/src/main/resources/templates/admin/posts.html b/src/main/resources/templates/admin/posts.html index f3d1f4da..2446c3e9 100644 --- a/src/main/resources/templates/admin/posts.html +++ b/src/main/resources/templates/admin/posts.html @@ -43,7 +43,9 @@

분철글 관리

+ th:attr="onclick=${post.status.name() == 'RECRUITING'} + ? 'return confirm(\'「' + ${post.title} + '」을 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.\')' + : 'return confirm(\'「' + ${post.title} + '」은 현재 진행 중인 분철글입니다.\n정말 삭제하시겠습니까?\')'">삭제
From a82749345db934003967105b54a0db6595dc58a0 Mon Sep 17 00:00:00 2001 From: pbem22 Date: Mon, 27 Jul 2026 23:22:59 +0900 Subject: [PATCH 07/14] =?UTF-8?q?fix:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=ED=8C=A8=EB=84=90=20=EC=97=A3=EC=A7=80=20=EC=BC=80=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - User.suspend/unsuspend: WITHDRAWN 계정 상태 변경 시도 시 예외 발생 - AdminService.forceWithdrawUser: 이미 탈퇴한 유저 중복 탈퇴 차단 - AdminController: BusinessException 발생 시 JSON 대신 flash 메시지로 redirect - layout.html: 에러 메시지 alert 영역 추가 --- .../admin/controller/AdminController.java | 34 ++++++++++++++----- .../domain/admin/service/AdminService.java | 3 ++ .../sopt/poti/domain/user/entity/User.java | 6 ++++ .../resources/templates/admin/layout.html | 5 +++ 4 files changed, 40 insertions(+), 8 deletions(-) diff --git a/src/main/java/org/sopt/poti/domain/admin/controller/AdminController.java b/src/main/java/org/sopt/poti/domain/admin/controller/AdminController.java index ce497439..a70f05e1 100644 --- a/src/main/java/org/sopt/poti/domain/admin/controller/AdminController.java +++ b/src/main/java/org/sopt/poti/domain/admin/controller/AdminController.java @@ -3,6 +3,7 @@ import lombok.RequiredArgsConstructor; import org.sopt.poti.domain.admin.service.AdminService; import org.sopt.poti.domain.groupbuy.entity.GroupBuyPostStatus; +import org.sopt.poti.global.error.BusinessException; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; import org.springframework.stereotype.Controller; @@ -12,6 +13,7 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; @Controller @RequestMapping("/admin") @@ -44,20 +46,32 @@ public String users( } @PostMapping("/users/{userId}/suspend") - public String suspend(@PathVariable Long userId) { - adminService.suspendUser(userId); + public String suspend(@PathVariable Long userId, RedirectAttributes ra) { + try { + adminService.suspendUser(userId); + } catch (BusinessException e) { + ra.addFlashAttribute("errorMessage", e.getErrorStatus().getMessage()); + } return "redirect:/admin/users"; } @PostMapping("/users/{userId}/unsuspend") - public String unsuspend(@PathVariable Long userId) { - adminService.unsuspendUser(userId); + public String unsuspend(@PathVariable Long userId, RedirectAttributes ra) { + try { + adminService.unsuspendUser(userId); + } catch (BusinessException e) { + ra.addFlashAttribute("errorMessage", e.getErrorStatus().getMessage()); + } return "redirect:/admin/users"; } @PostMapping("/users/{userId}/withdraw") - public String forceWithdraw(@PathVariable Long userId) { - adminService.forceWithdrawUser(userId); + public String forceWithdraw(@PathVariable Long userId, RedirectAttributes ra) { + try { + adminService.forceWithdrawUser(userId); + } catch (BusinessException e) { + ra.addFlashAttribute("errorMessage", e.getErrorStatus().getMessage()); + } return "redirect:/admin/users"; } @@ -75,8 +89,12 @@ public String posts( } @PostMapping("/posts/{postId}/delete") - public String deletePost(@PathVariable Long postId) { - adminService.deletePost(postId); + public String deletePost(@PathVariable Long postId, RedirectAttributes ra) { + try { + adminService.deletePost(postId); + } catch (BusinessException e) { + ra.addFlashAttribute("errorMessage", e.getErrorStatus().getMessage()); + } return "redirect:/admin/posts"; } } diff --git a/src/main/java/org/sopt/poti/domain/admin/service/AdminService.java b/src/main/java/org/sopt/poti/domain/admin/service/AdminService.java index cb5171a4..2a5a5ddc 100644 --- a/src/main/java/org/sopt/poti/domain/admin/service/AdminService.java +++ b/src/main/java/org/sopt/poti/domain/admin/service/AdminService.java @@ -70,6 +70,9 @@ public void unsuspendUser(Long userId) { public void forceWithdrawUser(Long userId) { User user = userRepository.findById(userId) .orElseThrow(() -> new BusinessException(ErrorStatus.USER_NOT_FOUND)); + if (user.getStatus() == UserStatus.WITHDRAWN) { + throw new BusinessException(ErrorStatus.USER_NOT_FOUND); + } user.withdraw(null); refreshTokenRepository.deleteById(userId); } diff --git a/src/main/java/org/sopt/poti/domain/user/entity/User.java b/src/main/java/org/sopt/poti/domain/user/entity/User.java index 79515102..2ff1275f 100644 --- a/src/main/java/org/sopt/poti/domain/user/entity/User.java +++ b/src/main/java/org/sopt/poti/domain/user/entity/User.java @@ -111,10 +111,16 @@ public void updateRatingAvg(double avg) { } public void suspend() { + if (this.status == UserStatus.WITHDRAWN) { + throw new IllegalStateException("탈퇴한 계정은 정지할 수 없습니다."); + } this.status = UserStatus.SUSPENDED; } public void unsuspend() { + if (this.status == UserStatus.WITHDRAWN) { + throw new IllegalStateException("탈퇴한 계정은 활성화할 수 없습니다."); + } this.status = UserStatus.ACTIVE; } diff --git a/src/main/resources/templates/admin/layout.html b/src/main/resources/templates/admin/layout.html index 5e705f9a..800d4432 100644 --- a/src/main/resources/templates/admin/layout.html +++ b/src/main/resources/templates/admin/layout.html @@ -42,6 +42,11 @@
+
From a6ea90823d2c5a2d76f56d4118a5fa5f0a225c3f Mon Sep 17 00:00:00 2001 From: pbem22 Date: Mon, 27 Jul 2026 23:32:52 +0900 Subject: [PATCH 08/14] =?UTF-8?q?fix:=20=ED=83=88=ED=87=B4=20=EC=9C=A0?= =?UTF-8?q?=EC=A0=80=20=EC=A0=95=EC=A7=80/=ED=95=B4=EC=A0=9C=20=EC=8B=9C?= =?UTF-8?q?=EB=8F=84=20=EC=8B=9C=20BusinessException=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 탈퇴한 유저에 대해 suspend/unsuspend 호출 시 IllegalStateException 대신 BusinessException을 던져 AdminController에서 flash 에러로 정상 처리되도록 수정 --- .../org/sopt/poti/domain/admin/service/AdminService.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main/java/org/sopt/poti/domain/admin/service/AdminService.java b/src/main/java/org/sopt/poti/domain/admin/service/AdminService.java index 2a5a5ddc..9f135f5b 100644 --- a/src/main/java/org/sopt/poti/domain/admin/service/AdminService.java +++ b/src/main/java/org/sopt/poti/domain/admin/service/AdminService.java @@ -55,6 +55,9 @@ public Page getPosts(GroupBuyPostStatus status, Pageable pageable) public void suspendUser(Long userId) { User user = userRepository.findById(userId) .orElseThrow(() -> new BusinessException(ErrorStatus.USER_NOT_FOUND)); + if (user.getStatus() == UserStatus.WITHDRAWN) { + throw new BusinessException(ErrorStatus.USER_NOT_FOUND); + } user.suspend(); refreshTokenRepository.deleteById(userId); } @@ -63,6 +66,9 @@ public void suspendUser(Long userId) { public void unsuspendUser(Long userId) { User user = userRepository.findById(userId) .orElseThrow(() -> new BusinessException(ErrorStatus.USER_NOT_FOUND)); + if (user.getStatus() == UserStatus.WITHDRAWN) { + throw new BusinessException(ErrorStatus.USER_NOT_FOUND); + } user.unsuspend(); } From 5c321d8de05af51e034179ade6c483da0c6bfa81 Mon Sep 17 00:00:00 2001 From: pbem22 Date: Tue, 28 Jul 2026 12:49:38 +0900 Subject: [PATCH 09/14] =?UTF-8?q?feat:=20#212=20=EC=96=B4=EB=93=9C?= =?UTF-8?q?=EB=AF=BC=20=ED=8C=A8=EB=84=90=20UI=20=EC=A0=84=EB=A9=B4=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 사이드바: 다크 테마, 활성 메뉴 accent 색상, 계층 구조 개선 - 대시보드: 컬러 스탯 카드 + 아이콘 + 빠른 이동 섹션 - 유저/분철글 테이블: 상태별 색상 배지, 액션 버튼 시각화 - 분철글 상태 필터를 pill 탭으로 교체 - confirm 다이얼로그를 JS 함수로 분리하여 가독성 향상 --- .../resources/templates/admin/dashboard.html | 75 +++++-- .../resources/templates/admin/layout.html | 198 +++++++++++++++--- src/main/resources/templates/admin/posts.html | 180 ++++++++++++---- src/main/resources/templates/admin/users.html | 160 +++++++++++--- 4 files changed, 510 insertions(+), 103 deletions(-) diff --git a/src/main/resources/templates/admin/dashboard.html b/src/main/resources/templates/admin/dashboard.html index 5535438e..80ade450 100644 --- a/src/main/resources/templates/admin/dashboard.html +++ b/src/main/resources/templates/admin/dashboard.html @@ -3,30 +3,77 @@ th:replace="~{admin/layout :: layout(~{::section}, 'dashboard')}">
-

대시보드

-
+
+
-
-
-
총 유저 수
-
0
+
+
+
+ 총 유저 수 +
+ +
+
+
0
+
가입된 전체 유저
+
+ +
-
-
-
분철글 수
-
0
+
+
+
+ 분철글 수 +
+ +
+
+
0
+
등록된 전체 분철글
+
+ +
-
-
-
총 주문 수
-
0
+
+
+
+ 총 주문 수 +
+ +
+
+
0
+
전체 주문 건수
+
+
+
+
+ + + diff --git a/src/main/resources/templates/admin/layout.html b/src/main/resources/templates/admin/layout.html index 800d4432..17d3ee68 100644 --- a/src/main/resources/templates/admin/layout.html +++ b/src/main/resources/templates/admin/layout.html @@ -7,47 +7,197 @@
+
- + + diff --git a/src/main/resources/templates/admin/users.html b/src/main/resources/templates/admin/users.html index 3b55bb74..1b974bef 100644 --- a/src/main/resources/templates/admin/users.html +++ b/src/main/resources/templates/admin/users.html @@ -1,7 +1,8 @@ - + +
- - -
+
@@ -85,13 +85,9 @@ - + - + +
- - - - 활성 정지 @@ -140,7 +136,7 @@ - - + + From 5fc998e7501221932182eedac70fbffe840479b6 Mon Sep 17 00:00:00 2001 From: pbem22 Date: Tue, 28 Jul 2026 13:55:34 +0900 Subject: [PATCH 12/14] =?UTF-8?q?fix:=20#212=20@Order=EB=A5=BC=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=EC=97=90=EC=84=9C=20@Bean=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/org/sopt/poti/global/config/AdminSecurityConfig.java | 2 +- src/main/java/org/sopt/poti/global/security/SecurityConfig.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/sopt/poti/global/config/AdminSecurityConfig.java b/src/main/java/org/sopt/poti/global/config/AdminSecurityConfig.java index 75c76111..e5f09869 100644 --- a/src/main/java/org/sopt/poti/global/config/AdminSecurityConfig.java +++ b/src/main/java/org/sopt/poti/global/config/AdminSecurityConfig.java @@ -13,7 +13,6 @@ import org.springframework.security.web.SecurityFilterChain; @Configuration -@Order(1) public class AdminSecurityConfig { @Value("${admin.username}") @@ -38,6 +37,7 @@ public UserDetailsService adminUserDetailsService() { } @Bean + @Order(1) public SecurityFilterChain adminFilterChain(HttpSecurity http) throws Exception { http .securityMatcher("/admin/**") diff --git a/src/main/java/org/sopt/poti/global/security/SecurityConfig.java b/src/main/java/org/sopt/poti/global/security/SecurityConfig.java index 027a252e..47108d5f 100644 --- a/src/main/java/org/sopt/poti/global/security/SecurityConfig.java +++ b/src/main/java/org/sopt/poti/global/security/SecurityConfig.java @@ -18,7 +18,6 @@ @Configuration @EnableWebSecurity @RequiredArgsConstructor -@Order(2) public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; @@ -38,6 +37,7 @@ public class SecurityConfig { }; @Bean + @Order(2) public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(AbstractHttpConfigurer::disable) // CSRF 보호 비활성화 (Stateless) From 8e7218f12da1027885d699c0debbd25e99461206 Mon Sep 17 00:00:00 2001 From: pbem22 Date: Tue, 28 Jul 2026 14:13:56 +0900 Subject: [PATCH 13/14] =?UTF-8?q?fix:=20#212=20=EC=A0=95=EC=A7=80=20?= =?UTF-8?q?=EC=9C=A0=EC=A0=80=20JWT=20=EC=B0=A8=EB=8B=A8=20=EB=B0=8F=20?= =?UTF-8?q?=EB=B6=84=EC=B2=A0=EA=B8=80=20=EC=82=AD=EC=A0=9C=20race=20condi?= =?UTF-8?q?tion=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/sopt/poti/domain/admin/service/AdminService.java | 8 +++++++- .../poti/global/security/jwt/JwtAuthenticationFilter.java | 5 +++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/sopt/poti/domain/admin/service/AdminService.java b/src/main/java/org/sopt/poti/domain/admin/service/AdminService.java index 28537be9..379f198d 100644 --- a/src/main/java/org/sopt/poti/domain/admin/service/AdminService.java +++ b/src/main/java/org/sopt/poti/domain/admin/service/AdminService.java @@ -14,6 +14,7 @@ import org.sopt.poti.domain.user.repository.UserRepository; import org.sopt.poti.global.error.BusinessException; import org.sopt.poti.global.error.ErrorStatus; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @@ -102,6 +103,11 @@ public void deletePost(Long postId) { if (orderRepository.existsByGroupBuyPost_Id(postId)) { throw new BusinessException(ErrorStatus.POST_HAS_ORDERS); } - groupBuyRepository.delete(post); + try { + groupBuyRepository.delete(post); + groupBuyRepository.flush(); + } catch (DataIntegrityViolationException e) { + throw new BusinessException(ErrorStatus.POST_HAS_ORDERS); + } } } diff --git a/src/main/java/org/sopt/poti/global/security/jwt/JwtAuthenticationFilter.java b/src/main/java/org/sopt/poti/global/security/jwt/JwtAuthenticationFilter.java index 73ef16fe..54ece09a 100644 --- a/src/main/java/org/sopt/poti/global/security/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/org/sopt/poti/global/security/jwt/JwtAuthenticationFilter.java @@ -8,6 +8,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.sopt.poti.domain.user.entity.User; +import org.sopt.poti.domain.user.entity.UserStatus; import org.sopt.poti.domain.user.repository.UserRepository; import org.sopt.poti.global.error.BusinessException; import org.sopt.poti.global.error.ErrorStatus; @@ -41,6 +42,10 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse User user = userRepository.findById(userId) .orElseThrow(() -> new BusinessException(ErrorStatus.USER_NOT_FOUND)); + if (user.getStatus() == UserStatus.SUSPENDED) { + throw new BusinessException(ErrorStatus.USER_SUSPENDED); + } + UserPrincipal userPrincipal = UserPrincipal.create(user); UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( userPrincipal, null, userPrincipal.getAuthorities()); From cb3420a8045b30eaeefd8205e0fc0c358a7d351c Mon Sep 17 00:00:00 2001 From: pbem22 Date: Tue, 28 Jul 2026 14:25:39 +0900 Subject: [PATCH 14/14] =?UTF-8?q?fix:=20#212=20=EC=96=B4=EB=93=9C=EB=AF=BC?= =?UTF-8?q?=20CSS=EB=A5=BC=20=EC=A0=95=EC=A0=81=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B6=84=EB=A6=AC=ED=95=98=EC=97=AC=20UI=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../poti/global/security/SecurityConfig.java | 1 + src/main/resources/static/css/admin.css | 323 ++++++++++++++++++ .../resources/templates/admin/dashboard.html | 89 ++--- .../resources/templates/admin/layout.html | 143 +------- src/main/resources/templates/admin/posts.html | 146 ++------ src/main/resources/templates/admin/users.html | 145 +++----- 6 files changed, 439 insertions(+), 408 deletions(-) create mode 100644 src/main/resources/static/css/admin.css diff --git a/src/main/java/org/sopt/poti/global/security/SecurityConfig.java b/src/main/java/org/sopt/poti/global/security/SecurityConfig.java index 47108d5f..f985915a 100644 --- a/src/main/java/org/sopt/poti/global/security/SecurityConfig.java +++ b/src/main/java/org/sopt/poti/global/security/SecurityConfig.java @@ -33,6 +33,7 @@ public class SecurityConfig { "/swagger-ui/**", // Swagger 문서 관련 "/v3/api-docs/**", "/favicon.ico", + "/css/**", "/dev/login", // 개발자용 자동 로그인 (토큰 발급) }; diff --git a/src/main/resources/static/css/admin.css b/src/main/resources/static/css/admin.css new file mode 100644 index 00000000..602100c8 --- /dev/null +++ b/src/main/resources/static/css/admin.css @@ -0,0 +1,323 @@ +/* ── Variables ── */ +:root { + --sidebar-bg: #0f172a; + --sidebar-hover: #1e293b; + --accent: #6366f1; + --accent-light: #818cf8; + --bg: #f1f5f9; + --surface: #ffffff; + --border: #e2e8f0; + --text: #0f172a; + --text-muted: #64748b; +} + +/* ── Reset ── */ +*, *::before, *::after { box-sizing: border-box; } +body { + margin: 0; + background: var(--bg); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + font-size: 14px; + color: var(--text); +} + +/* ── Sidebar ── */ +.sidebar { + width: 240px; + min-height: 100vh; + background: var(--sidebar-bg); + display: flex; + flex-direction: column; + position: fixed; + top: 0; left: 0; + z-index: 100; +} +.sidebar-brand { + padding: 24px 20px 18px; + border-bottom: 1px solid #1e293b; +} +.sidebar-brand .brand-title { + color: #fff; + font-size: 1.05rem; + font-weight: 700; +} +.sidebar-brand .brand-sub { + color: #475569; + font-size: 0.7rem; + margin-top: 3px; +} +.sidebar-nav { + flex: 1; + padding: 10px 10px 0; +} +.nav-label { + color: #475569; + font-size: 0.62rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + padding: 14px 8px 6px; +} +.sidebar-link { + display: flex; + align-items: center; + gap: 10px; + color: #94a3b8; + text-decoration: none; + padding: 9px 12px; + border-radius: 8px; + font-size: 0.85rem; + font-weight: 500; + transition: background 0.15s, color 0.15s; + margin-bottom: 2px; +} +.sidebar-link i { font-size: 0.95rem; } +.sidebar-link:hover { background: #1e293b; color: #e2e8f0; } +.sidebar-link.active { background: var(--accent); color: #fff; } +.sidebar-footer { + padding: 10px; + border-top: 1px solid #1e293b; +} +.sidebar-footer button { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + background: none; + border: none; + color: #64748b; + font-size: 0.82rem; + font-weight: 500; + padding: 9px 12px; + border-radius: 8px; + cursor: pointer; + text-align: left; + transition: background 0.15s, color 0.15s; +} +.sidebar-footer button:hover { background: #1e293b; color: #94a3b8; } + +/* ── Main Layout ── */ +.main-wrapper { + margin-left: 240px; + min-height: 100vh; + display: flex; + flex-direction: column; +} +.topbar { + background: var(--surface); + border-bottom: 1px solid var(--border); + padding: 14px 28px; + display: flex; + align-items: center; + justify-content: space-between; + position: sticky; + top: 0; + z-index: 50; +} +.topbar-title { + font-size: 0.95rem; + font-weight: 700; + color: var(--text); +} +.topbar-badge { + background: var(--bg); + color: var(--text-muted); + font-size: 0.72rem; + padding: 3px 10px; + border-radius: 20px; + font-weight: 500; +} +.main-content { padding: 24px 28px; flex: 1; } + +/* ── Flash Alert ── */ +.alert-flash { + border: none; + border-radius: 10px; + font-size: 0.85rem; + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 20px; + padding: 12px 16px; +} + +/* ── Stat Cards (Dashboard) ── */ +.stat-card { + background: var(--surface); + border-radius: 14px; + border: none; + overflow: hidden; + box-shadow: 0 1px 3px rgba(0,0,0,.06), 0 1px 2px rgba(0,0,0,.04); +} +.stat-card .card-body { padding: 20px 22px; } +.stat-label { + font-size: 0.75rem; + font-weight: 600; + color: var(--text-muted); + margin-bottom: 10px; +} +.stat-icon { + width: 38px; height: 38px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + font-size: 1rem; +} +.stat-value { + font-size: 1.8rem; + font-weight: 700; + color: var(--text); + line-height: 1; + margin-top: 2px; +} +.stat-desc { + font-size: 0.72rem; + color: var(--text-muted); + margin-top: 6px; +} +.stat-bar { height: 3px; } + +/* ── Quick Links ── */ +.quick-card { + background: var(--surface); + border-radius: 14px; + border: none; + box-shadow: 0 1px 3px rgba(0,0,0,.06); + padding: 20px 22px; +} +.quick-card .section-title { + font-size: 0.8rem; + font-weight: 600; + color: var(--text-muted); + margin-bottom: 12px; +} +.quick-link { + display: inline-flex; + align-items: center; + gap: 7px; + background: var(--bg); + border: 1px solid var(--border); + color: var(--text); + border-radius: 8px; + padding: 7px 14px; + font-size: 0.82rem; + font-weight: 500; + text-decoration: none; + transition: border-color 0.15s, background 0.15s; +} +.quick-link:hover { border-color: var(--accent); color: var(--accent); background: #eef2ff; } + +/* ── Table Card ── */ +.table-card { + background: var(--surface); + border-radius: 14px; + border: none; + overflow: hidden; + box-shadow: 0 1px 3px rgba(0,0,0,.06), 0 1px 2px rgba(0,0,0,.04); +} +.admin-table { margin: 0; } +.admin-table thead th { + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); + background: #f8fafc; + border-bottom: 1px solid var(--border); + padding: 11px 16px; + white-space: nowrap; +} +.admin-table tbody td { + padding: 13px 16px; + font-size: 0.85rem; + color: var(--text); + vertical-align: middle; + border-bottom: 1px solid #f1f5f9; +} +.admin-table tbody tr:hover td { background: #f8fafc; } +.admin-table tbody tr:last-child td { border-bottom: none; } +.cell-id { font-size: 0.75rem; color: var(--text-muted); } +.cell-sub { font-size: 0.78rem; color: var(--text-muted); } + +/* ── Badges ── */ +.badge-pill { + display: inline-block; + font-size: 0.68rem; + font-weight: 700; + padding: 3px 9px; + border-radius: 20px; + letter-spacing: 0.01em; +} +.badge-active { background: #dcfce7; color: #15803d; } +.badge-suspended { background: #fee2e2; color: #b91c1c; } +.badge-withdrawn { background: #f1f5f9; color: #94a3b8; } +.badge-kakao { background: #fef9c3; color: #854d0e; } + +.status-recruiting { background: #dbeafe; color: #1d4ed8; } +.status-closed { background: #fef9c3; color: #854d0e; } +.status-payment_done { background: #dcfce7; color: #15803d; } +.status-shipping { background: #ede9fe; color: #6d28d9; } +.status-delivered { background: #f1f5f9; color: #475569; } + +/* ── Action Buttons ── */ +.btn-action { + display: inline-block; + font-size: 0.75rem; + font-weight: 600; + padding: 4px 11px; + border-radius: 7px; + border: none; + cursor: pointer; + transition: filter 0.15s; + white-space: nowrap; + line-height: 1.6; +} +.btn-action:hover { filter: brightness(0.93); } +.btn-suspend { background: #fee2e2; color: #b91c1c; } +.btn-release { background: #dcfce7; color: #15803d; } +.btn-withdraw { background: #f1f5f9; color: #64748b; } +.btn-delete { background: #fee2e2; color: #b91c1c; } + +/* ── Filter Tabs ── */ +.filter-tabs { + display: flex; + gap: 7px; + flex-wrap: wrap; + margin-bottom: 16px; +} +.filter-tab { + font-size: 0.78rem; + font-weight: 600; + padding: 5px 13px; + border-radius: 20px; + border: 1.5px solid var(--border); + background: var(--surface); + color: var(--text-muted); + text-decoration: none; + transition: all 0.15s; +} +.filter-tab:hover { border-color: var(--accent); color: var(--accent); } +.filter-tab.is-active { background: var(--accent); border-color: var(--accent); color: #fff; } + +/* ── Empty State ── */ +.empty-state { + padding: 52px 0; + text-align: center; + color: var(--text-muted); +} +.empty-state i { font-size: 2.2rem; display: block; margin-bottom: 10px; opacity: 0.4; } +.empty-state p { font-size: 0.85rem; margin: 0; } + +/* ── Pagination ── */ +.pagination { gap: 3px; } +.page-link { + font-size: 0.78rem; + color: var(--accent); + border-color: var(--border); + border-radius: 7px !important; + padding: 5px 10px; +} +.page-link:hover { background: #eef2ff; border-color: var(--accent); color: var(--accent); } +.page-item.active .page-link { background: var(--accent); border-color: var(--accent); } +.page-item.disabled .page-link { pointer-events: none; opacity: 0.4; } diff --git a/src/main/resources/templates/admin/dashboard.html b/src/main/resources/templates/admin/dashboard.html index 80ade450..f806ef74 100644 --- a/src/main/resources/templates/admin/dashboard.html +++ b/src/main/resources/templates/admin/dashboard.html @@ -3,78 +3,63 @@ th:replace="~{admin/layout :: layout(~{::section}, 'dashboard')}">
-
- +
-
-
-
- 총 유저 수 -
- +
+
+
+ 총 유저 수 +
+
-
0
-
가입된 전체 유저
+
0
+
가입된 전체 유저
-
+
- -
-
-
-
- 분철글 수 -
- +
+
+
+ 분철글 수 +
+
-
0
-
등록된 전체 분철글
+
0
+
등록된 전체 분철글
-
+
- -
-
-
-
- 총 주문 수 -
- +
+
+
+ 총 주문 수 +
+
-
0
-
전체 주문 건수
+
0
+
전체 주문 건수
-
+
- -
diff --git a/src/main/resources/templates/admin/layout.html b/src/main/resources/templates/admin/layout.html index 17d3ee68..bfeb684d 100644 --- a/src/main/resources/templates/admin/layout.html +++ b/src/main/resources/templates/admin/layout.html @@ -6,152 +6,19 @@ POTI 어드민 - +
+
- -