From ce6e88e22158a9fb91cf3222cc26d44022737276 Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 16 Feb 2026 23:19:50 +0900 Subject: [PATCH] =?UTF-8?q?feat(settings):=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95=20=EA=B8=B0=EB=B0=98=20=EC=8B=A0=EA=B7=9C?= =?UTF-8?q?=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=9E=90=EB=8F=99=EC=8A=B9?= =?UTF-8?q?=EC=9D=B8=20+=20=EA=B8=B0=EB=B3=B8=20=EB=A1=A4=20=EB=B6=80?= =?UTF-8?q?=EC=97=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AppSetting 엔티티 + Repository (key-value 설정 저장소) - SettingsService (자동승인 조회/수정) - AdminSettingsController (GET/PUT /api/admin/settings/registration) - Role.defaultGrant 컬럼 + AdminRoleController default-grant 토글 - AuthController: 신규 사용자 생성 시 자동승인 + 기본롤 부여 로직 - data.sql: WING_PERMIT 롤 시드 + auto-approve 설정 시드 Co-Authored-By: Claude Opus 4.6 --- .../com/gcsc/guide/auth/AuthController.java | 19 +++++++ .../guide/controller/AdminRoleController.java | 17 ++++++ .../controller/AdminSettingsController.java | 53 +++++++++++++++++++ .../dto/RegistrationSettingsResponse.java | 8 +++ .../java/com/gcsc/guide/dto/RoleResponse.java | 6 ++- .../guide/dto/UpdateDefaultGrantRequest.java | 7 +++ .../UpdateRegistrationSettingsRequest.java | 7 +++ .../com/gcsc/guide/entity/AppSetting.java | 46 ++++++++++++++++ src/main/java/com/gcsc/guide/entity/Role.java | 7 +++ .../repository/AppSettingRepository.java | 11 ++++ .../gcsc/guide/repository/RoleRepository.java | 2 + .../com/gcsc/guide/service/RoleService.java | 8 +++ .../gcsc/guide/service/SettingsService.java | 47 ++++++++++++++++ src/main/resources/data.sql | 16 ++++-- 14 files changed, 247 insertions(+), 7 deletions(-) create mode 100644 src/main/java/com/gcsc/guide/controller/AdminSettingsController.java create mode 100644 src/main/java/com/gcsc/guide/dto/RegistrationSettingsResponse.java create mode 100644 src/main/java/com/gcsc/guide/dto/UpdateDefaultGrantRequest.java create mode 100644 src/main/java/com/gcsc/guide/dto/UpdateRegistrationSettingsRequest.java create mode 100644 src/main/java/com/gcsc/guide/entity/AppSetting.java create mode 100644 src/main/java/com/gcsc/guide/repository/AppSettingRepository.java create mode 100644 src/main/java/com/gcsc/guide/service/SettingsService.java diff --git a/src/main/java/com/gcsc/guide/auth/AuthController.java b/src/main/java/com/gcsc/guide/auth/AuthController.java index e399059..a4975b7 100644 --- a/src/main/java/com/gcsc/guide/auth/AuthController.java +++ b/src/main/java/com/gcsc/guide/auth/AuthController.java @@ -3,9 +3,12 @@ package com.gcsc.guide.auth; import com.gcsc.guide.dto.AuthResponse; import com.gcsc.guide.dto.GoogleLoginRequest; import com.gcsc.guide.dto.UserResponse; +import com.gcsc.guide.entity.Role; import com.gcsc.guide.entity.User; +import com.gcsc.guide.repository.RoleRepository; import com.gcsc.guide.repository.UserRepository; import com.gcsc.guide.service.ActivityService; +import com.gcsc.guide.service.SettingsService; import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; @@ -18,6 +21,10 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; @@ -36,7 +43,9 @@ public class AuthController { private final GoogleTokenVerifier googleTokenVerifier; private final JwtTokenProvider jwtTokenProvider; private final UserRepository userRepository; + private final RoleRepository roleRepository; private final ActivityService activityService; + private final SettingsService settingsService; @Operation(summary = "Google 로그인", description = "Google ID Token을 검증하고 JWT를 발급합니다. " @@ -119,6 +128,16 @@ public class AuthController { newUser.activate(); newUser.grantAdmin(); log.info("관리자 자동 승인: {}", email); + } else if (settingsService.isAutoApproveEnabled()) { + newUser.activate(); + log.info("자동 승인 (설정 활성화): {}", email); + } + + List defaultRoles = roleRepository.findByDefaultGrantTrue(); + if (!defaultRoles.isEmpty()) { + newUser.updateRoles(new HashSet<>(defaultRoles)); + log.info("기본 롤 부여: {} → {}", email, + defaultRoles.stream().map(Role::getName).toList()); } newUser.updateLastLogin(); diff --git a/src/main/java/com/gcsc/guide/controller/AdminRoleController.java b/src/main/java/com/gcsc/guide/controller/AdminRoleController.java index 26c6b53..d3c0853 100644 --- a/src/main/java/com/gcsc/guide/controller/AdminRoleController.java +++ b/src/main/java/com/gcsc/guide/controller/AdminRoleController.java @@ -3,6 +3,7 @@ package com.gcsc.guide.controller; import com.gcsc.guide.dto.AddPermissionRequest; import com.gcsc.guide.dto.CreateRoleRequest; import com.gcsc.guide.dto.RoleResponse; +import com.gcsc.guide.dto.UpdateDefaultGrantRequest; import com.gcsc.guide.service.RoleService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -119,6 +120,22 @@ public class AdminRoleController { return ResponseEntity.ok(roleService.addPermission(id, request.urlPattern())); } + @Operation(summary = "롤 기본 부여 토글", + description = "신규 가입자에게 해당 롤을 기본 부여할지 설정합니다. defaultGrant=true인 롤은 첫 로그인 시 자동 할당됩니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "수정 성공", + content = @Content(schema = @Schema(implementation = RoleResponse.class))), + @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content), + @ApiResponse(responseCode = "403", description = "관리자 권한 필요", content = @Content), + @ApiResponse(responseCode = "404", description = "롤을 찾을 수 없음", content = @Content) + }) + @PutMapping("/{id}/default-grant") + public ResponseEntity updateDefaultGrant( + @Parameter(description = "롤 ID", required = true) @PathVariable Long id, + @Valid @RequestBody UpdateDefaultGrantRequest request) { + return ResponseEntity.ok(roleService.updateDefaultGrant(id, request.defaultGrant())); + } + @Operation(summary = "URL 패턴 삭제", description = "특정 URL 패턴(권한)을 삭제합니다.") @ApiResponses({ diff --git a/src/main/java/com/gcsc/guide/controller/AdminSettingsController.java b/src/main/java/com/gcsc/guide/controller/AdminSettingsController.java new file mode 100644 index 0000000..88096ba --- /dev/null +++ b/src/main/java/com/gcsc/guide/controller/AdminSettingsController.java @@ -0,0 +1,53 @@ +package com.gcsc.guide.controller; + +import com.gcsc.guide.dto.RegistrationSettingsResponse; +import com.gcsc.guide.dto.UpdateRegistrationSettingsRequest; +import com.gcsc.guide.service.SettingsService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/admin/settings") +@RequiredArgsConstructor +@Tag(name = "07. 관리자 - 설정", description = "신규 가입 자동승인, 기본 롤 부여 등 시스템 설정 관리") +@SecurityRequirement(name = "Bearer JWT") +public class AdminSettingsController { + + private final SettingsService settingsService; + + @Operation(summary = "가입 설정 조회", + description = "자동 승인 여부와 기본 부여 롤 목록을 조회합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content(schema = @Schema(implementation = RegistrationSettingsResponse.class))), + @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content), + @ApiResponse(responseCode = "403", description = "관리자 권한 필요", content = @Content) + }) + @GetMapping("/registration") + public ResponseEntity getRegistrationSettings() { + return ResponseEntity.ok(settingsService.getRegistrationSettings()); + } + + @Operation(summary = "가입 설정 수정", + description = "자동 승인 여부를 변경합니다. true로 설정하면 신규 가입자가 즉시 ACTIVE 상태가 됩니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "수정 성공", + content = @Content(schema = @Schema(implementation = RegistrationSettingsResponse.class))), + @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content), + @ApiResponse(responseCode = "403", description = "관리자 권한 필요", content = @Content) + }) + @PutMapping("/registration") + public ResponseEntity updateRegistrationSettings( + @Valid @RequestBody UpdateRegistrationSettingsRequest request) { + return ResponseEntity.ok(settingsService.updateAutoApprove(request.autoApprove())); + } +} diff --git a/src/main/java/com/gcsc/guide/dto/RegistrationSettingsResponse.java b/src/main/java/com/gcsc/guide/dto/RegistrationSettingsResponse.java new file mode 100644 index 0000000..aad30f2 --- /dev/null +++ b/src/main/java/com/gcsc/guide/dto/RegistrationSettingsResponse.java @@ -0,0 +1,8 @@ +package com.gcsc.guide.dto; + +import java.util.List; + +public record RegistrationSettingsResponse( + boolean autoApprove, + List defaultRoles +) {} diff --git a/src/main/java/com/gcsc/guide/dto/RoleResponse.java b/src/main/java/com/gcsc/guide/dto/RoleResponse.java index ef8ede5..7444403 100644 --- a/src/main/java/com/gcsc/guide/dto/RoleResponse.java +++ b/src/main/java/com/gcsc/guide/dto/RoleResponse.java @@ -9,7 +9,8 @@ public record RoleResponse( Long id, String name, String description, - List urlPatterns + List urlPatterns, + boolean defaultGrant ) { public static RoleResponse from(Role role) { @@ -21,7 +22,8 @@ public record RoleResponse( role.getId(), role.getName(), role.getDescription(), - patterns + patterns, + role.isDefaultGrant() ); } } diff --git a/src/main/java/com/gcsc/guide/dto/UpdateDefaultGrantRequest.java b/src/main/java/com/gcsc/guide/dto/UpdateDefaultGrantRequest.java new file mode 100644 index 0000000..9864d6e --- /dev/null +++ b/src/main/java/com/gcsc/guide/dto/UpdateDefaultGrantRequest.java @@ -0,0 +1,7 @@ +package com.gcsc.guide.dto; + +import jakarta.validation.constraints.NotNull; + +public record UpdateDefaultGrantRequest( + @NotNull Boolean defaultGrant +) {} diff --git a/src/main/java/com/gcsc/guide/dto/UpdateRegistrationSettingsRequest.java b/src/main/java/com/gcsc/guide/dto/UpdateRegistrationSettingsRequest.java new file mode 100644 index 0000000..d743059 --- /dev/null +++ b/src/main/java/com/gcsc/guide/dto/UpdateRegistrationSettingsRequest.java @@ -0,0 +1,7 @@ +package com.gcsc.guide.dto; + +import jakarta.validation.constraints.NotNull; + +public record UpdateRegistrationSettingsRequest( + @NotNull Boolean autoApprove +) {} diff --git a/src/main/java/com/gcsc/guide/entity/AppSetting.java b/src/main/java/com/gcsc/guide/entity/AppSetting.java new file mode 100644 index 0000000..1ae6b98 --- /dev/null +++ b/src/main/java/com/gcsc/guide/entity/AppSetting.java @@ -0,0 +1,46 @@ +package com.gcsc.guide.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "app_settings") +@Getter +@NoArgsConstructor +public class AppSetting { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "setting_key", nullable = false, unique = true, length = 100) + private String settingKey; + + @Column(name = "setting_value", nullable = false, length = 500) + private String settingValue; + + @Column(length = 255) + private String description; + + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + public AppSetting(String settingKey, String settingValue, String description) { + this.settingKey = settingKey; + this.settingValue = settingValue; + this.description = description; + } + + public void updateValue(String value) { + this.settingValue = value; + this.updatedAt = LocalDateTime.now(); + } + + @PrePersist + protected void onCreate() { + this.updatedAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/gcsc/guide/entity/Role.java b/src/main/java/com/gcsc/guide/entity/Role.java index 37f92d9..a87bca0 100644 --- a/src/main/java/com/gcsc/guide/entity/Role.java +++ b/src/main/java/com/gcsc/guide/entity/Role.java @@ -24,6 +24,9 @@ public class Role { @Column(length = 255) private String description; + @Column(name = "default_grant") + private boolean defaultGrant = false; + @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt; @@ -40,6 +43,10 @@ public class Role { this.description = description; } + public void updateDefaultGrant(boolean defaultGrant) { + this.defaultGrant = defaultGrant; + } + @PrePersist protected void onCreate() { this.createdAt = LocalDateTime.now(); diff --git a/src/main/java/com/gcsc/guide/repository/AppSettingRepository.java b/src/main/java/com/gcsc/guide/repository/AppSettingRepository.java new file mode 100644 index 0000000..5013705 --- /dev/null +++ b/src/main/java/com/gcsc/guide/repository/AppSettingRepository.java @@ -0,0 +1,11 @@ +package com.gcsc.guide.repository; + +import com.gcsc.guide.entity.AppSetting; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface AppSettingRepository extends JpaRepository { + + Optional findBySettingKey(String settingKey); +} diff --git a/src/main/java/com/gcsc/guide/repository/RoleRepository.java b/src/main/java/com/gcsc/guide/repository/RoleRepository.java index c8e5409..5c23e8c 100644 --- a/src/main/java/com/gcsc/guide/repository/RoleRepository.java +++ b/src/main/java/com/gcsc/guide/repository/RoleRepository.java @@ -16,4 +16,6 @@ public interface RoleRepository extends JpaRepository { @Query("SELECT r FROM Role r LEFT JOIN FETCH r.urlPatterns WHERE r.id = :id") Optional findByIdWithUrlPatterns(Long id); + + List findByDefaultGrantTrue(); } diff --git a/src/main/java/com/gcsc/guide/service/RoleService.java b/src/main/java/com/gcsc/guide/service/RoleService.java index 1cfc1db..f8d7afd 100644 --- a/src/main/java/com/gcsc/guide/service/RoleService.java +++ b/src/main/java/com/gcsc/guide/service/RoleService.java @@ -81,6 +81,14 @@ public class RoleService { roleUrlPatternRepository.deleteById(permissionId); } + @Transactional + public RoleResponse updateDefaultGrant(Long roleId, boolean defaultGrant) { + Role role = roleRepository.findByIdWithUrlPatterns(roleId) + .orElseThrow(() -> new ResourceNotFoundException("롤", roleId)); + role.updateDefaultGrant(defaultGrant); + return RoleResponse.from(roleRepository.save(role)); + } + private Role findRoleById(Long roleId) { return roleRepository.findById(roleId) .orElseThrow(() -> new ResourceNotFoundException("롤", roleId)); diff --git a/src/main/java/com/gcsc/guide/service/SettingsService.java b/src/main/java/com/gcsc/guide/service/SettingsService.java new file mode 100644 index 0000000..2d2478e --- /dev/null +++ b/src/main/java/com/gcsc/guide/service/SettingsService.java @@ -0,0 +1,47 @@ +package com.gcsc.guide.service; + +import com.gcsc.guide.dto.RegistrationSettingsResponse; +import com.gcsc.guide.dto.RoleResponse; +import com.gcsc.guide.entity.AppSetting; +import com.gcsc.guide.repository.AppSettingRepository; +import com.gcsc.guide.repository.RoleRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class SettingsService { + + private static final String AUTO_APPROVE_KEY = "registration.auto-approve"; + + private final AppSettingRepository appSettingRepository; + private final RoleRepository roleRepository; + + @Transactional(readOnly = true) + public boolean isAutoApproveEnabled() { + return appSettingRepository.findBySettingKey(AUTO_APPROVE_KEY) + .map(s -> Boolean.parseBoolean(s.getSettingValue())) + .orElse(false); + } + + @Transactional(readOnly = true) + public RegistrationSettingsResponse getRegistrationSettings() { + boolean autoApprove = isAutoApproveEnabled(); + List defaultRoles = roleRepository.findByDefaultGrantTrue().stream() + .map(RoleResponse::from) + .toList(); + return new RegistrationSettingsResponse(autoApprove, defaultRoles); + } + + @Transactional + public RegistrationSettingsResponse updateAutoApprove(boolean autoApprove) { + AppSetting setting = appSettingRepository.findBySettingKey(AUTO_APPROVE_KEY) + .orElseGet(() -> new AppSetting(AUTO_APPROVE_KEY, "false", "신규 가입자 자동 승인 여부")); + setting.updateValue(String.valueOf(autoApprove)); + appSettingRepository.save(setting); + return getRegistrationSettings(); + } +} diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql index 475970f..471b4b1 100644 --- a/src/main/resources/data.sql +++ b/src/main/resources/data.sql @@ -1,11 +1,17 @@ -- 초기 롤 시드 데이터 -INSERT INTO roles (name, description, created_at) VALUES - ('ADMIN', '전체 접근 권한 (관리자 페이지 포함)', NOW()), - ('DEVELOPER', '전체 개발 가이드 접근', NOW()), - ('FRONT_DEV', '프론트엔드 개발 가이드만', NOW()); +INSERT INTO roles (name, description, default_grant, created_at) VALUES + ('ADMIN', '전체 접근 권한 (관리자 페이지 포함)', false, NOW()), + ('DEVELOPER', '전체 개발 가이드 접근', false, NOW()), + ('FRONT_DEV', '프론트엔드 개발 가이드만', false, NOW()), + ('WING_PERMIT', 'Wing 데모 사이트 접근 권한', true, NOW()); -- 롤별 URL 패턴 INSERT INTO role_url_patterns (role_id, url_pattern, created_at) VALUES ((SELECT id FROM roles WHERE name = 'ADMIN'), '/**', NOW()), ((SELECT id FROM roles WHERE name = 'DEVELOPER'), '/dev/**', NOW()), - ((SELECT id FROM roles WHERE name = 'FRONT_DEV'), '/dev/front/**', NOW()); + ((SELECT id FROM roles WHERE name = 'FRONT_DEV'), '/dev/front/**', NOW()), + ((SELECT id FROM roles WHERE name = 'WING_PERMIT'), '/wing/**', NOW()); + +-- 시스템 설정 +INSERT INTO app_settings (setting_key, setting_value, description, updated_at) VALUES + ('registration.auto-approve', 'true', '신규 가입자 자동 승인 여부', NOW());