Compare commits

..

No commits in common. "main" and "feature/auth-api" have entirely different histories.

56개의 변경된 파일25개의 추가작업 그리고 2174개의 파일을 삭제

파일 보기

@ -1,49 +0,0 @@
name: Build and Deploy API
on:
push:
branches:
- main
jobs:
build-and-deploy:
runs-on: ubuntu-latest
container:
image: maven:3.9-eclipse-temurin-17
steps:
- name: Checkout
run: |
git clone --depth=1 --branch=${GITHUB_REF_NAME} \
http://gitea:3000/${GITHUB_REPOSITORY}.git .
- name: Configure Maven settings
run: |
mkdir -p ~/.m2
cat > ~/.m2/settings.xml << 'SETTINGS'
<settings>
<mirrors>
<mirror>
<id>nexus</id>
<mirrorOf>*</mirrorOf>
<url>https://nexus.gc-si.dev/repository/maven-public/</url>
</mirror>
</mirrors>
<servers>
<server>
<id>nexus</id>
<username>${{ secrets.NEXUS_USERNAME }}</username>
<password>${{ secrets.NEXUS_PASSWORD }}</password>
</server>
</servers>
</settings>
SETTINGS
- name: Build
run: mvn clean package -DskipTests -B
- name: Deploy
run: |
cp target/gc-guide-api-*.jar /deploy/api/app.jar
date '+%Y-%m-%d %H:%M:%S' > /deploy/api/.deploy-trigger
echo "Deployed at $(cat /deploy/api/.deploy-trigger)"
ls -la /deploy/api/

파일 보기

@ -1,9 +0,0 @@
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY target/gc-guide-api-*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

파일 보기

@ -87,13 +87,6 @@
<scope>runtime</scope> <scope>runtime</scope>
</dependency> </dependency>
<!-- SpringDoc OpenAPI (Swagger UI) -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.8.6</version>
</dependency>
<!-- H2 (로컬 개발용) --> <!-- H2 (로컬 개발용) -->
<dependency> <dependency>
<groupId>com.h2database</groupId> <groupId>com.h2database</groupId>

파일 보기

@ -3,27 +3,12 @@ package com.gcsc.guide.auth;
import com.gcsc.guide.dto.AuthResponse; import com.gcsc.guide.dto.AuthResponse;
import com.gcsc.guide.dto.GoogleLoginRequest; import com.gcsc.guide.dto.GoogleLoginRequest;
import com.gcsc.guide.dto.UserResponse; import com.gcsc.guide.dto.UserResponse;
import com.gcsc.guide.entity.Role;
import com.gcsc.guide.entity.User; import com.gcsc.guide.entity.User;
import com.gcsc.guide.repository.RoleRepository;
import com.gcsc.guide.repository.UserRepository; 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 com.google.api.client.googleapis.auth.oauth2.GoogleIdToken;
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.servlet.http.HttpServletRequest;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import java.util.HashSet;
import java.util.List;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
@ -34,7 +19,6 @@ import org.springframework.web.server.ResponseStatusException;
@RestController @RestController
@RequestMapping("/api/auth") @RequestMapping("/api/auth")
@RequiredArgsConstructor @RequiredArgsConstructor
@Tag(name = "01. 인증", description = "Google OAuth2 로그인 및 JWT 토큰 관리")
public class AuthController { public class AuthController {
private static final String AUTO_ADMIN_EMAIL = "htlee@gcsc.co.kr"; private static final String AUTO_ADMIN_EMAIL = "htlee@gcsc.co.kr";
@ -42,24 +26,12 @@ public class AuthController {
private final GoogleTokenVerifier googleTokenVerifier; private final GoogleTokenVerifier googleTokenVerifier;
private final JwtTokenProvider jwtTokenProvider; private final JwtTokenProvider jwtTokenProvider;
private final UserRepository userRepository; private final UserRepository userRepository;
private final RoleRepository roleRepository;
private final ActivityService activityService;
private final SettingsService settingsService;
@Operation(summary = "Google 로그인", /**
description = "Google ID Token을 검증하고 JWT를 발급합니다. " * Google ID Token으로 로그인/회원가입 처리 JWT 발급
+ "신규 사용자는 PENDING 상태로 생성되며, htlee@gcsc.co.kr은 자동 ACTIVE + 관리자 부여됩니다.", */
security = {})
@ApiResponses({
@ApiResponse(responseCode = "200", description = "로그인 성공, JWT 토큰 발급",
content = @Content(schema = @Schema(implementation = AuthResponse.class))),
@ApiResponse(responseCode = "401", description = "유효하지 않은 Google 토큰 또는 허용되지 않은 이메일 도메인",
content = @Content)
})
@PostMapping("/google") @PostMapping("/google")
public ResponseEntity<AuthResponse> googleLogin( public ResponseEntity<AuthResponse> googleLogin(@Valid @RequestBody GoogleLoginRequest request) {
@Valid @RequestBody GoogleLoginRequest request,
HttpServletRequest httpRequest) {
GoogleIdToken.Payload payload = googleTokenVerifier.verify(request.idToken()); GoogleIdToken.Payload payload = googleTokenVerifier.verify(request.idToken());
if (payload == null) { if (payload == null) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "유효하지 않은 Google 토큰입니다"); throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "유효하지 않은 Google 토큰입니다");
@ -82,25 +54,15 @@ public class AuthController {
User userWithRoles = userRepository.findByEmailWithRoles(email) User userWithRoles = userRepository.findByEmailWithRoles(email)
.orElseThrow(); .orElseThrow();
activityService.recordLogin(
userWithRoles.getId(),
resolveClientIp(httpRequest),
httpRequest.getHeader("User-Agent"));
String token = jwtTokenProvider.generateToken( String token = jwtTokenProvider.generateToken(
userWithRoles.getId(), userWithRoles.getEmail(), userWithRoles.isAdmin()); userWithRoles.getId(), userWithRoles.getEmail(), userWithRoles.isAdmin());
return ResponseEntity.ok(new AuthResponse(token, UserResponse.from(userWithRoles))); return ResponseEntity.ok(new AuthResponse(token, UserResponse.from(userWithRoles)));
} }
@Operation(summary = "현재 사용자 정보 조회", /**
description = "JWT 토큰으로 인증된 현재 사용자의 상세 정보와 롤 목록을 반환합니다.") * 현재 인증된 사용자 정보 조회
@ApiResponses({ */
@ApiResponse(responseCode = "200", description = "사용자 정보 조회 성공"),
@ApiResponse(responseCode = "401", description = "인증 실패 (토큰 없음/만료)", content = @Content),
@ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없음", content = @Content)
})
@SecurityRequirement(name = "Bearer JWT")
@GetMapping("/me") @GetMapping("/me")
public ResponseEntity<UserResponse> getCurrentUser(Authentication authentication) { public ResponseEntity<UserResponse> getCurrentUser(Authentication authentication) {
Long userId = (Long) authentication.getPrincipal(); Long userId = (Long) authentication.getPrincipal();
@ -111,10 +73,9 @@ public class AuthController {
return ResponseEntity.ok(UserResponse.from(user)); return ResponseEntity.ok(UserResponse.from(user));
} }
@Operation(summary = "로그아웃", /**
description = "Stateless JWT 방식이므로 서버 측 처리 없이 204를 반환합니다. 클라이언트에서 토큰을 삭제하세요.") * 로그아웃 (Stateless JWT이므로 서버 처리 없음, 프론트에서 토큰 삭제)
@ApiResponse(responseCode = "204", description = "로그아웃 성공") */
@SecurityRequirement(name = "Bearer JWT")
@PostMapping("/logout") @PostMapping("/logout")
public ResponseEntity<Void> logout() { public ResponseEntity<Void> logout() {
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
@ -127,31 +88,9 @@ public class AuthController {
newUser.activate(); newUser.activate();
newUser.grantAdmin(); newUser.grantAdmin();
log.info("관리자 자동 승인: {}", email); log.info("관리자 자동 승인: {}", email);
} else if (settingsService.isAutoApproveEnabled()) {
newUser.activate();
log.info("자동 승인 (설정 활성화): {}", email);
}
List<Role> defaultRoles = roleRepository.findByDefaultGrantTrue();
if (!defaultRoles.isEmpty()) {
newUser.updateRoles(new HashSet<>(defaultRoles));
log.info("기본 롤 부여: {} → {}", email,
defaultRoles.stream().map(Role::getName).toList());
} }
newUser.updateLastLogin(); newUser.updateLastLogin();
return userRepository.save(newUser); return userRepository.save(newUser);
} }
private String resolveClientIp(HttpServletRequest request) {
String xff = request.getHeader("X-Forwarded-For");
if (xff != null && !xff.isBlank()) {
return xff.split(",")[0].trim();
}
String realIp = request.getHeader("X-Real-IP");
if (realIp != null && !realIp.isBlank()) {
return realIp.trim();
}
return request.getRemoteAddr();
}
} }

파일 보기

@ -8,9 +8,7 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.List;
@Slf4j @Slf4j
@Component @Component
@ -20,26 +18,12 @@ public class GoogleTokenVerifier {
private final String allowedEmailDomain; private final String allowedEmailDomain;
public GoogleTokenVerifier( public GoogleTokenVerifier(
@Value("${app.google.client-ids:}") String clientIdsCsv, @Value("${app.google.client-id}") String clientId,
@Value("${app.google.client-id:}") String clientId,
@Value("${app.allowed-email-domain}") String allowedEmailDomain @Value("${app.allowed-email-domain}") String allowedEmailDomain
) { ) {
List<String> audiences = new ArrayList<>();
if (clientIdsCsv != null && !clientIdsCsv.isBlank()) {
for (String part : clientIdsCsv.split(",")) {
String trimmed = part == null ? "" : part.trim();
if (!trimmed.isEmpty()) audiences.add(trimmed);
}
}
if (audiences.isEmpty() && clientId != null && !clientId.isBlank()) {
audiences.add(clientId.trim());
}
if (audiences.isEmpty()) {
log.warn("Google client id is not configured (app.google.client-id / app.google.client-ids empty). Google login will fail.");
}
this.verifier = new GoogleIdTokenVerifier.Builder( this.verifier = new GoogleIdTokenVerifier.Builder(
new NetHttpTransport(), GsonFactory.getDefaultInstance()) new NetHttpTransport(), GsonFactory.getDefaultInstance())
.setAudience(audiences.isEmpty() ? Collections.emptyList() : audiences) .setAudience(Collections.singletonList(clientId))
.build(); .build();
this.allowedEmailDomain = allowedEmailDomain; this.allowedEmailDomain = allowedEmailDomain;
} }

파일 보기

@ -1,54 +0,0 @@
package com.gcsc.guide.config;
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Contact;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
import io.swagger.v3.oas.models.servers.Server;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.List;
@Configuration
public class OpenApiConfig {
private static final String SECURITY_SCHEME_NAME = "Bearer JWT";
@Value("${server.port:8080}")
private int serverPort;
@Bean
public OpenAPI openAPI() {
return new OpenAPI()
.info(new Info()
.title("GC Guide API")
.description("GC SI 개발자 가이드 사이트 백엔드 API.\n\n"
+ "### 인증 방식\n"
+ "1. `POST /api/auth/google`에 Google ID Token을 전송하여 JWT를 발급받습니다.\n"
+ "2. 발급받은 JWT를 `Authorization: Bearer {token}` 헤더에 포함하여 요청합니다.\n\n"
+ "### 권한 구분\n"
+ "- **Public**: 인증 없이 접근 가능\n"
+ "- **Authenticated**: 로그인 필요 (ACTIVE 상태)\n"
+ "- **Admin**: 관리자 권한 필요 (isAdmin=true)")
.version("1.0.0")
.contact(new Contact()
.name("GC SI Dev Team")
.email("htlee@gcsc.co.kr")))
.servers(List.of(
new Server().url("https://guide.gc-si.dev").description("Production"),
new Server().url("http://localhost:" + serverPort).description("Local")))
.addSecurityItem(new SecurityRequirement().addList(SECURITY_SCHEME_NAME))
.components(new Components()
.addSecuritySchemes(SECURITY_SCHEME_NAME,
new SecurityScheme()
.name(SECURITY_SCHEME_NAME)
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT")
.description("Google 로그인 후 발급받은 JWT 토큰")));
}
}

파일 보기

@ -1,13 +1,10 @@
package com.gcsc.guide.config; package com.gcsc.guide.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.gcsc.guide.auth.JwtAuthenticationFilter; import com.gcsc.guide.auth.JwtAuthenticationFilter;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.http.MediaType;
import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.config.http.SessionCreationPolicy;
@ -17,9 +14,7 @@ import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import java.util.Map;
@Configuration @Configuration
@EnableWebSecurity @EnableWebSecurity
@ -27,9 +22,8 @@ import java.util.Map;
public class SecurityConfig { public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter; private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final ObjectMapper objectMapper;
@Value("${app.cors.allowed-origins:http://localhost:5173,http://localhost:5175,http://127.0.0.1:5175,https://guide.gc-si.dev,https://wing.gc-si.dev}") @Value("${app.cors.allowed-origins:http://localhost:5173,https://guide.gc-si.dev}")
private List<String> allowedOrigins; private List<String> allowedOrigins;
@Bean @Bean
@ -41,42 +35,14 @@ public class SecurityConfig {
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
.requestMatchers( .requestMatchers(
"/api/auth/google", "/api/auth/**",
"/api/auth/logout",
"/api/health", "/api/health",
"/actuator/health", "/actuator/health",
"/h2-console/**", "/h2-console/**"
"/swagger-ui/**",
"/swagger-ui.html",
"/v3/api-docs/**"
).permitAll() ).permitAll()
.requestMatchers("/api/admin/**").authenticated() .requestMatchers("/api/admin/**").authenticated()
.anyRequest().authenticated() .anyRequest().authenticated()
) )
.exceptionHandling(ex -> ex
.authenticationEntryPoint((request, response, authException) -> {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
objectMapper.writeValue(response.getOutputStream(), Map.of(
"status", 401,
"error", "Unauthorized",
"message", "인증이 필요합니다. JWT 토큰을 Authorization 헤더에 포함하세요.",
"timestamp", LocalDateTime.now().toString()
));
})
.accessDeniedHandler((request, response, accessDeniedException) -> {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
objectMapper.writeValue(response.getOutputStream(), Map.of(
"status", 403,
"error", "Forbidden",
"message", "접근 권한이 없습니다.",
"timestamp", LocalDateTime.now().toString()
));
})
)
.headers(headers -> headers.frameOptions(frame -> frame.sameOrigin())) .headers(headers -> headers.frameOptions(frame -> frame.sameOrigin()))
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
@ -89,12 +55,11 @@ public class SecurityConfig {
config.setAllowedOrigins(allowedOrigins); config.setAllowedOrigins(allowedOrigins);
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("*")); config.setAllowedHeaders(List.of("*"));
config.setExposedHeaders(List.of("Authorization"));
config.setAllowCredentials(true); config.setAllowCredentials(true);
config.setMaxAge(3600L); config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config); source.registerCorsConfiguration("/api/**", config);
return source; return source;
} }
} }

파일 보기

@ -1,58 +0,0 @@
package com.gcsc.guide.controller;
import com.gcsc.guide.dto.LoginHistoryResponse;
import com.gcsc.guide.dto.TrackPageViewRequest;
import com.gcsc.guide.service.ActivityService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.ArraySchema;
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.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/activity")
@RequiredArgsConstructor
@Tag(name = "05. 활동 기록", description = "페이지 뷰 추적 및 로그인 이력 조회")
@SecurityRequirement(name = "Bearer JWT")
public class ActivityController {
private final ActivityService activityService;
@Operation(summary = "페이지 뷰 기록",
description = "현재 사용자가 특정 페이지를 조회했음을 기록합니다. 프론트엔드에서 페이지 이동 시 호출합니다.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "기록 성공"),
@ApiResponse(responseCode = "401", description = "인증 실패", content = @Content)
})
@PostMapping("/track")
public ResponseEntity<Void> trackPageView(
Authentication authentication,
@Valid @RequestBody TrackPageViewRequest request) {
Long userId = (Long) authentication.getPrincipal();
activityService.trackPageView(userId, request.pagePath());
return ResponseEntity.ok().build();
}
@Operation(summary = "현재 사용자의 로그인 이력 조회",
description = "JWT로 인증된 현재 사용자의 최근 로그인 이력(IP, User-Agent, 시간)을 반환합니다.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "조회 성공",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = LoginHistoryResponse.class)))),
@ApiResponse(responseCode = "401", description = "인증 실패", content = @Content)
})
@GetMapping("/login-history")
public ResponseEntity<List<LoginHistoryResponse>> getLoginHistory(Authentication authentication) {
Long userId = (Long) authentication.getPrincipal();
return ResponseEntity.ok(activityService.getLoginHistory(userId));
}
}

파일 보기

@ -1,153 +0,0 @@
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;
import io.swagger.v3.oas.annotations.media.ArraySchema;
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.*;
import java.net.URI;
import java.util.List;
@RestController
@RequestMapping("/api/admin/roles")
@RequiredArgsConstructor
@Tag(name = "03. 관리자 - 롤/권한", description = "롤 CRUD 및 URL 패턴 기반 권한 관리")
@SecurityRequirement(name = "Bearer JWT")
public class AdminRoleController {
private final RoleService roleService;
@Operation(summary = "전체 롤 목록 조회",
description = "등록된 모든 롤과 각 롤에 할당된 URL 패턴을 조회합니다.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "조회 성공",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = RoleResponse.class)))),
@ApiResponse(responseCode = "401", description = "인증 실패", content = @Content),
@ApiResponse(responseCode = "403", description = "관리자 권한 필요", content = @Content)
})
@GetMapping
public ResponseEntity<List<RoleResponse>> getRoles() {
return ResponseEntity.ok(roleService.getRoles());
}
@Operation(summary = "롤 생성",
description = "새로운 롤을 생성합니다. 생성 후 URL 패턴을 별도로 추가해야 합니다.")
@ApiResponses({
@ApiResponse(responseCode = "201", description = "롤 생성 성공",
content = @Content(schema = @Schema(implementation = RoleResponse.class))),
@ApiResponse(responseCode = "401", description = "인증 실패", content = @Content),
@ApiResponse(responseCode = "403", description = "관리자 권한 필요", content = @Content)
})
@PostMapping
public ResponseEntity<RoleResponse> createRole(@Valid @RequestBody CreateRoleRequest request) {
RoleResponse role = roleService.createRole(request.name(), request.description());
return ResponseEntity.created(URI.create("/api/admin/roles/" + role.id())).body(role);
}
@Operation(summary = "롤 수정",
description = "기존 롤의 이름과 설명을 수정합니다.")
@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}")
public ResponseEntity<RoleResponse> updateRole(
@Parameter(description = "롤 ID", required = true) @PathVariable Long id,
@Valid @RequestBody CreateRoleRequest request) {
return ResponseEntity.ok(roleService.updateRole(id, request.name(), request.description()));
}
@Operation(summary = "롤 삭제",
description = "롤을 삭제합니다. 해당 롤에 연결된 URL 패턴도 함께 삭제됩니다.")
@ApiResponses({
@ApiResponse(responseCode = "204", description = "롤 삭제 성공"),
@ApiResponse(responseCode = "401", description = "인증 실패", content = @Content),
@ApiResponse(responseCode = "403", description = "관리자 권한 필요", content = @Content),
@ApiResponse(responseCode = "404", description = "롤을 찾을 수 없음", content = @Content)
})
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteRole(
@Parameter(description = "롤 ID", required = true) @PathVariable Long id) {
roleService.deleteRole(id);
return ResponseEntity.noContent().build();
}
@Operation(summary = "롤의 URL 패턴 목록 조회",
description = "특정 롤에 할당된 URL 패턴(권한) 목록을 조회합니다.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "조회 성공",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = String.class)))),
@ApiResponse(responseCode = "401", description = "인증 실패", content = @Content),
@ApiResponse(responseCode = "403", description = "관리자 권한 필요", content = @Content),
@ApiResponse(responseCode = "404", description = "롤을 찾을 수 없음", content = @Content)
})
@GetMapping("/{id}/permissions")
public ResponseEntity<List<String>> getPermissions(
@Parameter(description = "롤 ID", required = true) @PathVariable Long id) {
return ResponseEntity.ok(roleService.getPermissions(id));
}
@Operation(summary = "URL 패턴 추가",
description = "롤에 새로운 URL 패턴(권한)을 추가합니다. Ant-style 패턴을 지원합니다 (예: /dev/**, /dev/front/**).")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "URL 패턴 추가 성공",
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)
})
@PostMapping("/{id}/permissions")
public ResponseEntity<RoleResponse> addPermission(
@Parameter(description = "롤 ID", required = true) @PathVariable Long id,
@Valid @RequestBody AddPermissionRequest request) {
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<RoleResponse> 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({
@ApiResponse(responseCode = "204", description = "URL 패턴 삭제 성공"),
@ApiResponse(responseCode = "401", description = "인증 실패", content = @Content),
@ApiResponse(responseCode = "403", description = "관리자 권한 필요", content = @Content),
@ApiResponse(responseCode = "404", description = "URL 패턴을 찾을 수 없음", content = @Content)
})
@DeleteMapping("/permissions/{permissionId}")
public ResponseEntity<Void> deletePermission(
@Parameter(description = "URL 패턴 ID", required = true) @PathVariable Long permissionId) {
roleService.deletePermission(permissionId);
return ResponseEntity.noContent().build();
}
}

파일 보기

@ -1,53 +0,0 @@
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<RegistrationSettingsResponse> 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<RegistrationSettingsResponse> updateRegistrationSettings(
@Valid @RequestBody UpdateRegistrationSettingsRequest request) {
return ResponseEntity.ok(settingsService.updateAutoApprove(request.autoApprove()));
}
}

파일 보기

@ -1,39 +0,0 @@
package com.gcsc.guide.controller;
import com.gcsc.guide.dto.StatsResponse;
import com.gcsc.guide.service.UserService;
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 lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/admin/stats")
@RequiredArgsConstructor
@Tag(name = "04. 관리자 - 통계", description = "사용자 통계 및 시스템 현황 대시보드")
@SecurityRequirement(name = "Bearer JWT")
public class AdminStatsController {
private final UserService userService;
@Operation(summary = "전체 통계 조회",
description = "사용자 상태별 수, 오늘 로그인 수, 전체 롤 수 등 시스템 현황 통계를 반환합니다.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "통계 조회 성공",
content = @Content(schema = @Schema(implementation = StatsResponse.class))),
@ApiResponse(responseCode = "401", description = "인증 실패", content = @Content),
@ApiResponse(responseCode = "403", description = "관리자 권한 필요", content = @Content)
})
@GetMapping
public ResponseEntity<StatsResponse> getStats() {
return ResponseEntity.ok(userService.getStats());
}
}

파일 보기

@ -1,137 +0,0 @@
package com.gcsc.guide.controller;
import com.gcsc.guide.dto.UpdateRolesRequest;
import com.gcsc.guide.dto.UserResponse;
import com.gcsc.guide.service.UserService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.ArraySchema;
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.*;
import java.util.List;
@RestController
@RequestMapping("/api/admin/users")
@RequiredArgsConstructor
@Tag(name = "02. 관리자 - 사용자", description = "사용자 관리 (승인/거절/비활성화/롤/관리자 권한)")
@SecurityRequirement(name = "Bearer JWT")
public class AdminUserController {
private final UserService userService;
@Operation(summary = "전체 사용자 목록 조회",
description = "모든 사용자 목록을 조회합니다. status 파라미터로 특정 상태의 사용자만 필터링할 수 있습니다.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "조회 성공",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = UserResponse.class)))),
@ApiResponse(responseCode = "401", description = "인증 실패", content = @Content),
@ApiResponse(responseCode = "403", description = "관리자 권한 필요", content = @Content)
})
@GetMapping
public ResponseEntity<List<UserResponse>> getUsers(
@Parameter(description = "사용자 상태 필터 (PENDING, ACTIVE, REJECTED, DISABLED)",
example = "PENDING")
@RequestParam(required = false) String status) {
return ResponseEntity.ok(userService.getUsers(status));
}
@Operation(summary = "사용자 승인",
description = "PENDING 상태의 사용자를 ACTIVE로 변경합니다.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "승인 성공",
content = @Content(schema = @Schema(implementation = UserResponse.class))),
@ApiResponse(responseCode = "401", description = "인증 실패", content = @Content),
@ApiResponse(responseCode = "403", description = "관리자 권한 필요", content = @Content),
@ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없음", content = @Content)
})
@PutMapping("/{id}/approve")
public ResponseEntity<UserResponse> approveUser(
@Parameter(description = "사용자 ID", required = true) @PathVariable Long id) {
return ResponseEntity.ok(userService.approveUser(id));
}
@Operation(summary = "사용자 거절",
description = "PENDING 상태의 사용자를 REJECTED로 변경합니다.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "거절 성공",
content = @Content(schema = @Schema(implementation = UserResponse.class))),
@ApiResponse(responseCode = "401", description = "인증 실패", content = @Content),
@ApiResponse(responseCode = "403", description = "관리자 권한 필요", content = @Content),
@ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없음", content = @Content)
})
@PutMapping("/{id}/reject")
public ResponseEntity<UserResponse> rejectUser(
@Parameter(description = "사용자 ID", required = true) @PathVariable Long id) {
return ResponseEntity.ok(userService.rejectUser(id));
}
@Operation(summary = "사용자 비활성화",
description = "ACTIVE 상태의 사용자를 DISABLED로 변경합니다.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "비활성화 성공",
content = @Content(schema = @Schema(implementation = UserResponse.class))),
@ApiResponse(responseCode = "401", description = "인증 실패", content = @Content),
@ApiResponse(responseCode = "403", description = "관리자 권한 필요", content = @Content),
@ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없음", content = @Content)
})
@PutMapping("/{id}/disable")
public ResponseEntity<UserResponse> disableUser(
@Parameter(description = "사용자 ID", required = true) @PathVariable Long id) {
return ResponseEntity.ok(userService.disableUser(id));
}
@Operation(summary = "사용자 롤 업데이트",
description = "사용자에게 할당된 롤을 변경합니다. 기존 롤을 모두 제거하고 전달된 roleIds로 교체합니다.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "롤 업데이트 성공",
content = @Content(schema = @Schema(implementation = UserResponse.class))),
@ApiResponse(responseCode = "401", description = "인증 실패", content = @Content),
@ApiResponse(responseCode = "403", description = "관리자 권한 필요", content = @Content),
@ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없음", content = @Content)
})
@PutMapping("/{id}/roles")
public ResponseEntity<UserResponse> updateUserRoles(
@Parameter(description = "사용자 ID", required = true) @PathVariable Long id,
@Valid @RequestBody UpdateRolesRequest request) {
return ResponseEntity.ok(userService.updateUserRoles(id, request.roleIds()));
}
@Operation(summary = "관리자 권한 부여",
description = "사용자에게 관리자(isAdmin) 권한을 부여합니다.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "관리자 권한 부여 성공",
content = @Content(schema = @Schema(implementation = UserResponse.class))),
@ApiResponse(responseCode = "401", description = "인증 실패", content = @Content),
@ApiResponse(responseCode = "403", description = "관리자 권한 필요", content = @Content),
@ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없음", content = @Content)
})
@PostMapping("/{id}/admin")
public ResponseEntity<UserResponse> grantAdmin(
@Parameter(description = "사용자 ID", required = true) @PathVariable Long id) {
return ResponseEntity.ok(userService.grantAdmin(id));
}
@Operation(summary = "관리자 권한 해제",
description = "사용자의 관리자(isAdmin) 권한을 해제합니다.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "관리자 권한 해제 성공",
content = @Content(schema = @Schema(implementation = UserResponse.class))),
@ApiResponse(responseCode = "401", description = "인증 실패", content = @Content),
@ApiResponse(responseCode = "403", description = "관리자 권한 필요", content = @Content),
@ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없음", content = @Content)
})
@DeleteMapping("/{id}/admin")
public ResponseEntity<UserResponse> revokeAdmin(
@Parameter(description = "사용자 ID", required = true) @PathVariable Long id) {
return ResponseEntity.ok(userService.revokeAdmin(id));
}
}

파일 보기

@ -1,22 +1,13 @@
package com.gcsc.guide.controller; package com.gcsc.guide.controller;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.util.Map; import java.util.Map;
@RestController @RestController
@Tag(name = "00. 시스템", description = "헬스체크 및 시스템 상태 확인")
public class HealthController { public class HealthController {
@Operation(summary = "헬스체크",
description = "서버 가동 상태를 확인합니다. 인증 없이 접근 가능합니다.",
security = {})
@ApiResponse(responseCode = "200", description = "서버 정상 가동 중")
@GetMapping("/api/health") @GetMapping("/api/health")
public Map<String, String> health() { public Map<String, String> health() {
return Map.of( return Map.of(

파일 보기

@ -1,112 +0,0 @@
package com.gcsc.guide.controller;
import com.gcsc.guide.dto.*;
import com.gcsc.guide.service.IssueService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
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.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.net.URI;
import java.util.Map;
@RestController
@RequestMapping("/api/issues")
@RequiredArgsConstructor
@Tag(name = "06. 이슈 관리", description = "이슈 등록/조회/수정 및 코멘트 관리")
@SecurityRequirement(name = "Bearer JWT")
public class IssueController {
private final IssueService issueService;
@Operation(summary = "이슈 목록 조회",
description = "이슈 목록을 페이징으로 조회합니다. status 파라미터로 상태별 필터링이 가능합니다.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "조회 성공"),
@ApiResponse(responseCode = "401", description = "인증 실패", content = @Content)
})
@GetMapping
public ResponseEntity<Page<IssueResponse>> getIssues(
@Parameter(description = "이슈 상태 필터 (OPEN, IN_PROGRESS, CLOSED)", example = "OPEN")
@RequestParam(required = false) String status,
@Parameter(description = "페이징 파라미터 (page, size, sort)")
@PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable) {
return ResponseEntity.ok(issueService.getIssues(status, pageable));
}
@Operation(summary = "이슈 생성",
description = "새로운 이슈를 생성합니다. 프로젝트명, 위치, Gitea 이슈 링크 등을 선택적으로 포함할 수 있습니다.")
@ApiResponses({
@ApiResponse(responseCode = "201", description = "이슈 생성 성공",
content = @Content(schema = @Schema(implementation = IssueResponse.class))),
@ApiResponse(responseCode = "401", description = "인증 실패", content = @Content)
})
@PostMapping
public ResponseEntity<IssueResponse> createIssue(
Authentication authentication,
@Valid @RequestBody CreateIssueRequest request) {
Long authorId = (Long) authentication.getPrincipal();
IssueResponse issue = issueService.createIssue(authorId, request);
return ResponseEntity.created(URI.create("/api/issues/" + issue.id())).body(issue);
}
@Operation(summary = "이슈 상세 조회",
description = "이슈의 상세 정보와 코멘트 목록을 함께 반환합니다.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "조회 성공"),
@ApiResponse(responseCode = "401", description = "인증 실패", content = @Content),
@ApiResponse(responseCode = "404", description = "이슈를 찾을 수 없음", content = @Content)
})
@GetMapping("/{id}")
public ResponseEntity<Map<String, Object>> getIssue(
@Parameter(description = "이슈 ID", required = true) @PathVariable Long id) {
return ResponseEntity.ok(issueService.getIssueDetail(id));
}
@Operation(summary = "이슈 수정",
description = "이슈의 제목, 내용, 상태, 우선순위, 담당자 등을 수정합니다.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "수정 성공",
content = @Content(schema = @Schema(implementation = IssueResponse.class))),
@ApiResponse(responseCode = "401", description = "인증 실패", content = @Content),
@ApiResponse(responseCode = "404", description = "이슈를 찾을 수 없음", content = @Content)
})
@PutMapping("/{id}")
public ResponseEntity<IssueResponse> updateIssue(
@Parameter(description = "이슈 ID", required = true) @PathVariable Long id,
@RequestBody UpdateIssueRequest request) {
return ResponseEntity.ok(issueService.updateIssue(id, request));
}
@Operation(summary = "코멘트 추가",
description = "이슈에 새로운 코멘트를 추가합니다.")
@ApiResponses({
@ApiResponse(responseCode = "201", description = "코멘트 추가 성공",
content = @Content(schema = @Schema(implementation = IssueCommentResponse.class))),
@ApiResponse(responseCode = "401", description = "인증 실패", content = @Content),
@ApiResponse(responseCode = "404", description = "이슈를 찾을 수 없음", content = @Content)
})
@PostMapping("/{id}/comments")
public ResponseEntity<IssueCommentResponse> addComment(
@Parameter(description = "이슈 ID", required = true) @PathVariable Long id,
Authentication authentication,
@Valid @RequestBody CreateCommentRequest request) {
Long authorId = (Long) authentication.getPrincipal();
IssueCommentResponse comment = issueService.addComment(id, authorId, request.body());
return ResponseEntity.created(URI.create("/api/issues/" + id + "/comments/" + comment.id()))
.body(comment);
}
}

파일 보기

@ -1,215 +0,0 @@
package com.gcsc.guide.controller;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@Slf4j
@RestController
@RequestMapping("/api/wing/ais-target")
@RequiredArgsConstructor
@Tag(name = "WING · AIS", description = "WING demo AIS proxy (JWT required)")
public class WingAisController {
private final ObjectMapper objectMapper;
@Value("${app.wing.ais.upstream-base:http://211.208.115.83:8041}")
private String upstreamBase;
@Value("${app.wing.ais.timeout-ms:20000}")
private long timeoutMs;
private HttpClient httpClient;
@PostConstruct
void initHttpClient() {
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
}
private record Bbox(double lonMin, double latMin, double lonMax, double latMax) {
}
@GetMapping("/search")
public ResponseEntity<?> search(
@RequestParam(name = "minutes") String minutesRaw,
@RequestParam(name = "bbox", required = false) String bboxRaw,
@RequestParam(name = "centerLon", required = false) Double centerLon,
@RequestParam(name = "centerLat", required = false) Double centerLat,
@RequestParam(name = "radiusMeters", required = false) Double radiusMeters
) {
Integer minutes = parseMinutes(minutesRaw);
if (minutes == null) {
return error(HttpStatus.BAD_REQUEST, "invalid minutes", "BAD_REQUEST");
}
Bbox bbox = parseBbox(bboxRaw);
if (bboxRaw != null && bbox == null) {
return error(HttpStatus.BAD_REQUEST, "invalid bbox", "BAD_REQUEST");
}
URI upstreamUrl = buildUpstreamUrl(minutes, centerLon, centerLat, radiusMeters);
HttpRequest req = HttpRequest.newBuilder(upstreamUrl)
.timeout(Duration.ofMillis(timeoutMs))
.header("accept", "application/json")
.GET()
.build();
int status;
String body;
try {
HttpResponse<String> res = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
status = res.statusCode();
body = res.body() == null ? "" : res.body();
} catch (java.net.http.HttpTimeoutException e) {
log.warn("AIS upstream timeout ({}ms): {}", timeoutMs, upstreamUrl);
return error(HttpStatus.GATEWAY_TIMEOUT, "upstream timeout (" + timeoutMs + "ms)", "UPSTREAM_TIMEOUT");
} catch (Exception e) {
log.warn("AIS upstream fetch failed: {} ({})", upstreamUrl, e.toString());
return error(HttpStatus.BAD_GATEWAY, "upstream fetch failed", "UPSTREAM_FETCH_FAILED");
}
if (status < 200 || status >= 300) {
log.warn("AIS upstream error: status={} url={}", status, upstreamUrl);
return error(HttpStatus.BAD_GATEWAY, "upstream error", "UPSTREAM");
}
// Fast path: no bbox requested, proxy raw payload.
if (bbox == null) {
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_JSON)
.body(body);
}
try {
Map<String, Object> json = objectMapper.readValue(body, new TypeReference<>() {
});
Object dataObj = json.get("data");
List<?> rows = dataObj instanceof List<?> l ? l : List.of();
List<Object> filtered = new ArrayList<>(rows.size());
for (Object row : rows) {
if (inBbox(row, bbox)) {
filtered.add(row);
}
}
json.put("data", filtered);
Object msgObj = json.get("message");
String msg = msgObj instanceof String s ? s : "";
String suffix = " (bbox: " + filtered.size() + "/" + rows.size() + ")";
json.put("message", (msg + suffix).trim());
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_JSON)
.body(json);
} catch (Exception e) {
log.warn("AIS upstream JSON parse/filter failed: {}", e.toString());
return error(HttpStatus.BAD_GATEWAY, "upstream invalid json", "UPSTREAM_INVALID_JSON");
}
}
private Integer parseMinutes(String raw) {
if (raw == null) return null;
int minutes;
try {
minutes = Integer.parseInt(raw);
} catch (NumberFormatException e) {
return null;
}
if (minutes <= 0 || minutes > 60 * 24) return null;
return minutes;
}
private Bbox parseBbox(String raw) {
if (raw == null || raw.isBlank()) return null;
String[] parts = raw.split(",");
if (parts.length != 4) return null;
Double lonMin = toDouble(parts[0]);
Double latMin = toDouble(parts[1]);
Double lonMax = toDouble(parts[2]);
Double latMax = toDouble(parts[3]);
if (lonMin == null || latMin == null || lonMax == null || latMax == null) return null;
boolean ok =
lonMin >= -180 && lonMax <= 180 &&
latMin >= -90 && latMax <= 90 &&
lonMin < lonMax &&
latMin < latMax;
if (!ok) return null;
return new Bbox(lonMin, latMin, lonMax, latMax);
}
private boolean inBbox(Object row, Bbox bbox) {
if (!(row instanceof Map<?, ?> m)) return false;
Double lon = toDouble(m.get("lon"));
Double lat = toDouble(m.get("lat"));
if (lon == null || lat == null) return false;
return lon >= bbox.lonMin && lon <= bbox.lonMax && lat >= bbox.latMin && lat <= bbox.latMax;
}
private Double toDouble(Object value) {
if (value == null) return null;
if (value instanceof Number n) return n.doubleValue();
if (value instanceof String s) {
String t = s.trim();
if (t.isEmpty()) return null;
try {
return Double.parseDouble(t);
} catch (NumberFormatException e) {
return null;
}
}
return null;
}
private URI buildUpstreamUrl(int minutes, Double centerLon, Double centerLat, Double radiusMeters) {
String base = upstreamBase == null ? "" : upstreamBase.trim();
if (base.endsWith("/")) base = base.substring(0, base.length() - 1);
StringBuilder sb = new StringBuilder(base);
sb.append("/snp-api/api/ais-target/search");
sb.append("?minutes=").append(minutes);
// Upstream supports center/radius filtering; bbox is ignored (filtered server-side here).
if (centerLon != null && Double.isFinite(centerLon)) sb.append("&centerLon=").append(centerLon);
if (centerLat != null && Double.isFinite(centerLat)) sb.append("&centerLat=").append(centerLat);
if (radiusMeters != null && Double.isFinite(radiusMeters)) sb.append("&radiusMeters=").append(radiusMeters);
return URI.create(sb.toString());
}
private ResponseEntity<Map<String, Object>> error(HttpStatus status, String message, String errorCode) {
return ResponseEntity.status(status)
.contentType(MediaType.APPLICATION_JSON)
.body(Map.of(
"success", false,
"message", message,
"data", List.of(),
"errorCode", errorCode
));
}
}

파일 보기

@ -1,57 +0,0 @@
package com.gcsc.guide.controller;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.http.CacheControl;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.util.concurrent.TimeUnit;
import static org.springframework.http.HttpStatus.NOT_FOUND;
@RestController
@RequestMapping("/api/wing/data")
@RequiredArgsConstructor
@Tag(name = "WING · Data", description = "WING embedded datasets (JWT required)")
public class WingDataController {
@GetMapping(value = "/zones", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Resource> zones() {
return serveJson("wing-data/zones.wgs84.geojson");
}
@GetMapping(value = "/legacy", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Resource> legacyChinesePermitted() {
return serveJson("wing-data/chinese-permitted.v1.json");
}
@GetMapping(value = "/subcables/geo", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Resource> subcablesGeo() {
return serveJson("wing-data/subcables/cable-geo.json");
}
@GetMapping(value = "/subcables/details", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Resource> subcablesDetails() {
return serveJson("wing-data/subcables/cable-details.min.json");
}
private ResponseEntity<Resource> serveJson(String classpathLocation) {
Resource resource = new ClassPathResource(classpathLocation);
if (!resource.exists()) {
throw new ResponseStatusException(NOT_FOUND, "Resource not found: " + classpathLocation);
}
return ResponseEntity.ok()
// Authenticated endpoint: allow browser caching but keep it private.
.cacheControl(CacheControl.maxAge(6, TimeUnit.HOURS).cachePrivate())
.contentType(MediaType.APPLICATION_JSON)
.body(resource);
}
}

파일 보기

@ -1,8 +0,0 @@
package com.gcsc.guide.dto;
import jakarta.validation.constraints.NotBlank;
public record AddPermissionRequest(
@NotBlank String urlPattern
) {
}

파일 보기

@ -1,8 +0,0 @@
package com.gcsc.guide.dto;
import jakarta.validation.constraints.NotBlank;
public record CreateCommentRequest(
@NotBlank String body
) {
}

파일 보기

@ -1,14 +0,0 @@
package com.gcsc.guide.dto;
import jakarta.validation.constraints.NotBlank;
public record CreateIssueRequest(
@NotBlank String title,
String body,
String priority,
String project,
String location,
String giteaIssueUrl,
Integer giteaIssueId
) {
}

파일 보기

@ -1,9 +0,0 @@
package com.gcsc.guide.dto;
import jakarta.validation.constraints.NotBlank;
public record CreateRoleRequest(
@NotBlank String name,
String description
) {
}

파일 보기

@ -1,30 +0,0 @@
package com.gcsc.guide.dto;
import com.gcsc.guide.entity.IssueComment;
import java.time.LocalDateTime;
public record IssueCommentResponse(
Long id,
String body,
IssueResponse.AuthorInfo author,
LocalDateTime createdAt
) {
public static IssueCommentResponse from(IssueComment comment) {
IssueResponse.AuthorInfo authorInfo = comment.getAuthor() != null
? new IssueResponse.AuthorInfo(
comment.getAuthor().getId(),
comment.getAuthor().getName(),
comment.getAuthor().getEmail(),
comment.getAuthor().getAvatarUrl())
: null;
return new IssueCommentResponse(
comment.getId(),
comment.getBody(),
authorInfo,
comment.getCreatedAt()
);
}
}

파일 보기

@ -1,53 +0,0 @@
package com.gcsc.guide.dto;
import com.gcsc.guide.entity.Issue;
import java.time.LocalDateTime;
public record IssueResponse(
Long id,
String title,
String body,
String status,
String priority,
String project,
String location,
String giteaIssueUrl,
Integer giteaIssueId,
AuthorInfo author,
AuthorInfo assignee,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
public record AuthorInfo(Long id, String name, String email, String avatarUrl) {
}
public static IssueResponse from(Issue issue) {
AuthorInfo authorInfo = issue.getAuthor() != null
? new AuthorInfo(issue.getAuthor().getId(), issue.getAuthor().getName(),
issue.getAuthor().getEmail(), issue.getAuthor().getAvatarUrl())
: null;
AuthorInfo assigneeInfo = issue.getAssignee() != null
? new AuthorInfo(issue.getAssignee().getId(), issue.getAssignee().getName(),
issue.getAssignee().getEmail(), issue.getAssignee().getAvatarUrl())
: null;
return new IssueResponse(
issue.getId(),
issue.getTitle(),
issue.getBody(),
issue.getStatus(),
issue.getPriority(),
issue.getProject(),
issue.getLocation(),
issue.getGiteaIssueUrl(),
issue.getGiteaIssueId(),
authorInfo,
assigneeInfo,
issue.getCreatedAt(),
issue.getUpdatedAt()
);
}
}

파일 보기

@ -1,22 +0,0 @@
package com.gcsc.guide.dto;
import com.gcsc.guide.entity.LoginHistory;
import java.time.LocalDateTime;
public record LoginHistoryResponse(
Long id,
LocalDateTime loginAt,
String ipAddress,
String userAgent
) {
public static LoginHistoryResponse from(LoginHistory history) {
return new LoginHistoryResponse(
history.getId(),
history.getLoginAt(),
history.getIpAddress(),
history.getUserAgent()
);
}
}

파일 보기

@ -1,8 +0,0 @@
package com.gcsc.guide.dto;
import java.util.List;
public record RegistrationSettingsResponse(
boolean autoApprove,
List<RoleResponse> defaultRoles
) {}

파일 보기

@ -9,8 +9,7 @@ public record RoleResponse(
Long id, Long id,
String name, String name,
String description, String description,
List<String> urlPatterns, List<String> urlPatterns
boolean defaultGrant
) { ) {
public static RoleResponse from(Role role) { public static RoleResponse from(Role role) {
@ -22,8 +21,7 @@ public record RoleResponse(
role.getId(), role.getId(),
role.getName(), role.getName(),
role.getDescription(), role.getDescription(),
patterns, patterns
role.isDefaultGrant()
); );
} }
} }

파일 보기

@ -1,12 +0,0 @@
package com.gcsc.guide.dto;
public record StatsResponse(
long totalUsers,
long activeUsers,
long pendingUsers,
long rejectedUsers,
long disabledUsers,
long todayLogins,
long totalRoles
) {
}

파일 보기

@ -1,8 +0,0 @@
package com.gcsc.guide.dto;
import jakarta.validation.constraints.NotBlank;
public record TrackPageViewRequest(
@NotBlank String pagePath
) {
}

파일 보기

@ -1,7 +0,0 @@
package com.gcsc.guide.dto;
import jakarta.validation.constraints.NotNull;
public record UpdateDefaultGrantRequest(
@NotNull Boolean defaultGrant
) {}

파일 보기

@ -1,14 +0,0 @@
package com.gcsc.guide.dto;
public record UpdateIssueRequest(
String title,
String body,
String status,
String priority,
String project,
String location,
Long assigneeId,
String giteaIssueUrl,
Integer giteaIssueId
) {
}

파일 보기

@ -1,7 +0,0 @@
package com.gcsc.guide.dto;
import jakarta.validation.constraints.NotNull;
public record UpdateRegistrationSettingsRequest(
@NotNull Boolean autoApprove
) {}

파일 보기

@ -1,10 +0,0 @@
package com.gcsc.guide.dto;
import jakarta.validation.constraints.NotNull;
import java.util.List;
public record UpdateRolesRequest(
@NotNull List<Long> roleIds
) {
}

파일 보기

@ -1,46 +0,0 @@
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();
}
}

파일 보기

@ -1,99 +0,0 @@
package com.gcsc.guide.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Entity
@Table(name = "issues")
@Getter
@NoArgsConstructor
public class Issue {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String title;
@Column(columnDefinition = "TEXT")
private String body;
@Column(nullable = false, length = 20)
private String status = "OPEN";
@Column(nullable = false, length = 20)
private String priority = "NORMAL";
@Column(length = 100)
private String project;
@Column(length = 255)
private String location;
@Column(name = "gitea_issue_url", length = 500)
private String giteaIssueUrl;
@Column(name = "gitea_issue_id")
private Integer giteaIssueId;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "author_id")
private User author;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "assignee_id")
private User assignee;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
public Issue(String title, String body, String priority, String project,
String location, User author) {
this.title = title;
this.body = body;
this.priority = priority;
this.project = project;
this.location = location;
this.author = author;
}
public void update(String title, String body, String priority,
String project, String location) {
this.title = title;
this.body = body;
this.priority = priority;
this.project = project;
this.location = location;
}
public void updateStatus(String status) {
this.status = status;
}
public void assignTo(User assignee) {
this.assignee = assignee;
}
public void linkGiteaIssue(String giteaIssueUrl, Integer giteaIssueId) {
this.giteaIssueUrl = giteaIssueUrl;
this.giteaIssueId = giteaIssueId;
}
@PrePersist
protected void onCreate() {
this.createdAt = LocalDateTime.now();
this.updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
this.updatedAt = LocalDateTime.now();
}
}

파일 보기

@ -1,43 +0,0 @@
package com.gcsc.guide.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Entity
@Table(name = "issue_comments")
@Getter
@NoArgsConstructor
public class IssueComment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "issue_id", nullable = false)
private Issue issue;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "author_id")
private User author;
@Column(nullable = false, columnDefinition = "TEXT")
private String body;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
public IssueComment(Issue issue, User author, String body) {
this.issue = issue;
this.author = author;
this.body = body;
}
@PrePersist
protected void onCreate() {
this.createdAt = LocalDateTime.now();
}
}

파일 보기

@ -1,42 +0,0 @@
package com.gcsc.guide.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Entity
@Table(name = "login_history")
@Getter
@NoArgsConstructor
public class LoginHistory {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;
@Column(name = "login_at", nullable = false, updatable = false)
private LocalDateTime loginAt;
@Column(name = "ip_address", length = 45)
private String ipAddress;
@Column(name = "user_agent", length = 500)
private String userAgent;
public LoginHistory(User user, String ipAddress, String userAgent) {
this.user = user;
this.ipAddress = ipAddress;
this.userAgent = userAgent;
}
@PrePersist
protected void onCreate() {
this.loginAt = LocalDateTime.now();
}
}

파일 보기

@ -1,38 +0,0 @@
package com.gcsc.guide.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Entity
@Table(name = "page_views")
@Getter
@NoArgsConstructor
public class PageView {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;
@Column(name = "page_path", nullable = false)
private String pagePath;
@Column(name = "viewed_at", nullable = false, updatable = false)
private LocalDateTime viewedAt;
public PageView(User user, String pagePath) {
this.user = user;
this.pagePath = pagePath;
}
@PrePersist
protected void onCreate() {
this.viewedAt = LocalDateTime.now();
}
}

파일 보기

@ -24,9 +24,6 @@ public class Role {
@Column(length = 255) @Column(length = 255)
private String description; private String description;
@Column(name = "default_grant")
private boolean defaultGrant = false;
@Column(name = "created_at", nullable = false, updatable = false) @Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt; private LocalDateTime createdAt;
@ -43,10 +40,6 @@ public class Role {
this.description = description; this.description = description;
} }
public void updateDefaultGrant(boolean defaultGrant) {
this.defaultGrant = defaultGrant;
}
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
this.createdAt = LocalDateTime.now(); this.createdAt = LocalDateTime.now();

파일 보기

@ -1,8 +0,0 @@
package com.gcsc.guide.exception;
public class BusinessException extends RuntimeException {
public BusinessException(String message) {
super(message);
}
}

파일 보기

@ -1,56 +0,0 @@
package com.gcsc.guide.exception;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
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 org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.util.Map;
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<Map<String, Object>> handleNotFound(ResourceNotFoundException e) {
return buildResponse(HttpStatus.NOT_FOUND, e.getMessage());
}
@ExceptionHandler(BusinessException.class)
public ResponseEntity<Map<String, Object>> handleBusiness(BusinessException e) {
return buildResponse(HttpStatus.BAD_REQUEST, e.getMessage());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> handleValidation(MethodArgumentNotValidException e) {
String message = e.getBindingResult().getFieldErrors().stream()
.map(fe -> fe.getField() + ": " + fe.getDefaultMessage())
.reduce((a, b) -> a + ", " + b)
.orElse("입력값이 올바르지 않습니다");
return buildResponse(HttpStatus.BAD_REQUEST, message);
}
@ExceptionHandler(ResponseStatusException.class)
public ResponseEntity<Map<String, Object>> handleResponseStatus(ResponseStatusException e) {
return buildResponse(HttpStatus.valueOf(e.getStatusCode().value()), e.getReason());
}
@ExceptionHandler(Exception.class)
public ResponseEntity<Map<String, Object>> handleUnexpected(Exception e) {
log.error("예상치 못한 오류 발생", e);
return buildResponse(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습니다");
}
private ResponseEntity<Map<String, Object>> buildResponse(HttpStatus status, String message) {
return ResponseEntity.status(status).body(Map.of(
"status", status.value(),
"error", status.getReasonPhrase(),
"message", message != null ? message : "알 수 없는 오류",
"timestamp", LocalDateTime.now().toString()
));
}
}

파일 보기

@ -1,12 +0,0 @@
package com.gcsc.guide.exception;
public class ResourceNotFoundException extends RuntimeException {
public ResourceNotFoundException(String resource, Long id) {
super(resource + "을(를) 찾을 수 없습니다 (id=" + id + ")");
}
public ResourceNotFoundException(String message) {
super(message);
}
}

파일 보기

@ -1,11 +0,0 @@
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<AppSetting, Long> {
Optional<AppSetting> findBySettingKey(String settingKey);
}

파일 보기

@ -1,13 +0,0 @@
package com.gcsc.guide.repository;
import com.gcsc.guide.entity.IssueComment;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import java.util.List;
public interface IssueCommentRepository extends JpaRepository<IssueComment, Long> {
@Query("SELECT c FROM IssueComment c JOIN FETCH c.author WHERE c.issue.id = :issueId ORDER BY c.createdAt ASC")
List<IssueComment> findByIssueIdWithAuthor(Long issueId);
}

파일 보기

@ -1,17 +0,0 @@
package com.gcsc.guide.repository;
import com.gcsc.guide.entity.Issue;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import java.util.Optional;
public interface IssueRepository extends JpaRepository<Issue, Long> {
Page<Issue> findByStatus(String status, Pageable pageable);
@Query("SELECT i FROM Issue i LEFT JOIN FETCH i.author LEFT JOIN FETCH i.assignee WHERE i.id = :id")
Optional<Issue> findByIdWithUsers(Long id);
}

파일 보기

@ -1,14 +0,0 @@
package com.gcsc.guide.repository;
import com.gcsc.guide.entity.LoginHistory;
import org.springframework.data.jpa.repository.JpaRepository;
import java.time.LocalDateTime;
import java.util.List;
public interface LoginHistoryRepository extends JpaRepository<LoginHistory, Long> {
List<LoginHistory> findByUserIdOrderByLoginAtDesc(Long userId);
long countByLoginAtAfter(LocalDateTime after);
}

파일 보기

@ -1,7 +0,0 @@
package com.gcsc.guide.repository;
import com.gcsc.guide.entity.PageView;
import org.springframework.data.jpa.repository.JpaRepository;
public interface PageViewRepository extends JpaRepository<PageView, Long> {
}

파일 보기

@ -16,6 +16,4 @@ public interface RoleRepository extends JpaRepository<Role, Long> {
@Query("SELECT r FROM Role r LEFT JOIN FETCH r.urlPatterns WHERE r.id = :id") @Query("SELECT r FROM Role r LEFT JOIN FETCH r.urlPatterns WHERE r.id = :id")
Optional<Role> findByIdWithUrlPatterns(Long id); Optional<Role> findByIdWithUrlPatterns(Long id);
List<Role> findByDefaultGrantTrue();
} }

파일 보기

@ -1,7 +0,0 @@
package com.gcsc.guide.repository;
import com.gcsc.guide.entity.RoleUrlPattern;
import org.springframework.data.jpa.repository.JpaRepository;
public interface RoleUrlPatternRepository extends JpaRepository<RoleUrlPattern, Long> {
}

파일 보기

@ -1,45 +0,0 @@
package com.gcsc.guide.service;
import com.gcsc.guide.dto.LoginHistoryResponse;
import com.gcsc.guide.entity.LoginHistory;
import com.gcsc.guide.entity.PageView;
import com.gcsc.guide.entity.User;
import com.gcsc.guide.exception.ResourceNotFoundException;
import com.gcsc.guide.repository.LoginHistoryRepository;
import com.gcsc.guide.repository.PageViewRepository;
import com.gcsc.guide.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@RequiredArgsConstructor
public class ActivityService {
private final LoginHistoryRepository loginHistoryRepository;
private final PageViewRepository pageViewRepository;
private final UserRepository userRepository;
@Transactional
public void recordLogin(Long userId, String ipAddress, String userAgent) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new ResourceNotFoundException("사용자", userId));
loginHistoryRepository.save(new LoginHistory(user, ipAddress, userAgent));
}
@Transactional
public void trackPageView(Long userId, String pagePath) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new ResourceNotFoundException("사용자", userId));
pageViewRepository.save(new PageView(user, pagePath));
}
@Transactional(readOnly = true)
public List<LoginHistoryResponse> getLoginHistory(Long userId) {
return loginHistoryRepository.findByUserIdOrderByLoginAtDesc(userId).stream()
.map(LoginHistoryResponse::from)
.toList();
}
}

파일 보기

@ -1,118 +0,0 @@
package com.gcsc.guide.service;
import com.gcsc.guide.dto.*;
import com.gcsc.guide.entity.Issue;
import com.gcsc.guide.entity.IssueComment;
import com.gcsc.guide.entity.User;
import com.gcsc.guide.exception.ResourceNotFoundException;
import com.gcsc.guide.repository.IssueCommentRepository;
import com.gcsc.guide.repository.IssueRepository;
import com.gcsc.guide.repository.UserRepository;
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;
import java.util.List;
import java.util.Map;
@Service
@RequiredArgsConstructor
public class IssueService {
private final IssueRepository issueRepository;
private final IssueCommentRepository issueCommentRepository;
private final UserRepository userRepository;
@Transactional(readOnly = true)
public Page<IssueResponse> getIssues(String status, Pageable pageable) {
Page<Issue> issues;
if (status != null && !status.isBlank()) {
issues = issueRepository.findByStatus(status.toUpperCase(), pageable);
} else {
issues = issueRepository.findAll(pageable);
}
return issues.map(IssueResponse::from);
}
@Transactional
public IssueResponse createIssue(Long authorId, CreateIssueRequest request) {
User author = userRepository.findById(authorId)
.orElseThrow(() -> new ResourceNotFoundException("사용자", authorId));
Issue issue = new Issue(
request.title(),
request.body(),
request.priority() != null ? request.priority() : "NORMAL",
request.project(),
request.location(),
author
);
if (request.giteaIssueUrl() != null) {
issue.linkGiteaIssue(request.giteaIssueUrl(), request.giteaIssueId());
}
return IssueResponse.from(issueRepository.save(issue));
}
@Transactional(readOnly = true)
public Map<String, Object> getIssueDetail(Long issueId) {
Issue issue = issueRepository.findByIdWithUsers(issueId)
.orElseThrow(() -> new ResourceNotFoundException("이슈", issueId));
List<IssueCommentResponse> comments = issueCommentRepository
.findByIssueIdWithAuthor(issueId).stream()
.map(IssueCommentResponse::from)
.toList();
return Map.of(
"issue", IssueResponse.from(issue),
"comments", comments
);
}
@Transactional
public IssueResponse updateIssue(Long issueId, UpdateIssueRequest request) {
Issue issue = issueRepository.findByIdWithUsers(issueId)
.orElseThrow(() -> new ResourceNotFoundException("이슈", issueId));
if (request.title() != null) {
issue.update(
request.title(),
request.body(),
request.priority() != null ? request.priority() : issue.getPriority(),
request.project() != null ? request.project() : issue.getProject(),
request.location() != null ? request.location() : issue.getLocation()
);
}
if (request.status() != null) {
issue.updateStatus(request.status().toUpperCase());
}
if (request.assigneeId() != null) {
User assignee = userRepository.findById(request.assigneeId())
.orElseThrow(() -> new ResourceNotFoundException("담당자", request.assigneeId()));
issue.assignTo(assignee);
}
if (request.giteaIssueUrl() != null) {
issue.linkGiteaIssue(request.giteaIssueUrl(), request.giteaIssueId());
}
return IssueResponse.from(issueRepository.save(issue));
}
@Transactional
public IssueCommentResponse addComment(Long issueId, Long authorId, String body) {
Issue issue = issueRepository.findById(issueId)
.orElseThrow(() -> new ResourceNotFoundException("이슈", issueId));
User author = userRepository.findById(authorId)
.orElseThrow(() -> new ResourceNotFoundException("사용자", authorId));
IssueComment comment = new IssueComment(issue, author, body);
return IssueCommentResponse.from(issueCommentRepository.save(comment));
}
}

파일 보기

@ -1,96 +0,0 @@
package com.gcsc.guide.service;
import com.gcsc.guide.dto.RoleResponse;
import com.gcsc.guide.entity.Role;
import com.gcsc.guide.entity.RoleUrlPattern;
import com.gcsc.guide.exception.BusinessException;
import com.gcsc.guide.exception.ResourceNotFoundException;
import com.gcsc.guide.repository.RoleRepository;
import com.gcsc.guide.repository.RoleUrlPatternRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@RequiredArgsConstructor
public class RoleService {
private final RoleRepository roleRepository;
private final RoleUrlPatternRepository roleUrlPatternRepository;
@Transactional(readOnly = true)
public List<RoleResponse> getRoles() {
return roleRepository.findAllWithUrlPatterns().stream()
.map(RoleResponse::from)
.toList();
}
@Transactional
public RoleResponse createRole(String name, String description) {
roleRepository.findByName(name).ifPresent(r -> {
throw new BusinessException("이미 존재하는 롤 이름입니다: " + name);
});
Role role = new Role(name, description);
return RoleResponse.from(roleRepository.save(role));
}
@Transactional
public RoleResponse updateRole(Long roleId, String name, String description) {
Role role = findRoleById(roleId);
roleRepository.findByName(name)
.filter(r -> !r.getId().equals(roleId))
.ifPresent(r -> {
throw new BusinessException("이미 존재하는 롤 이름입니다: " + name);
});
role.update(name, description);
return RoleResponse.from(roleRepository.save(role));
}
@Transactional
public void deleteRole(Long roleId) {
if (!roleRepository.existsById(roleId)) {
throw new ResourceNotFoundException("", roleId);
}
roleRepository.deleteById(roleId);
}
@Transactional(readOnly = true)
public List<String> getPermissions(Long roleId) {
Role role = roleRepository.findByIdWithUrlPatterns(roleId)
.orElseThrow(() -> new ResourceNotFoundException("", roleId));
return role.getUrlPatterns().stream()
.map(RoleUrlPattern::getUrlPattern)
.toList();
}
@Transactional
public RoleResponse addPermission(Long roleId, String urlPattern) {
Role role = roleRepository.findByIdWithUrlPatterns(roleId)
.orElseThrow(() -> new ResourceNotFoundException("", roleId));
role.getUrlPatterns().add(new RoleUrlPattern(role, urlPattern));
return RoleResponse.from(roleRepository.save(role));
}
@Transactional
public void deletePermission(Long permissionId) {
if (!roleUrlPatternRepository.existsById(permissionId)) {
throw new ResourceNotFoundException("권한", permissionId);
}
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));
}
}

파일 보기

@ -1,47 +0,0 @@
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<RoleResponse> 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();
}
}

파일 보기

@ -1,120 +0,0 @@
package com.gcsc.guide.service;
import com.gcsc.guide.dto.StatsResponse;
import com.gcsc.guide.dto.UserResponse;
import com.gcsc.guide.entity.Role;
import com.gcsc.guide.entity.User;
import com.gcsc.guide.entity.UserStatus;
import com.gcsc.guide.exception.BusinessException;
import com.gcsc.guide.exception.ResourceNotFoundException;
import com.gcsc.guide.repository.LoginHistoryRepository;
import com.gcsc.guide.repository.RoleRepository;
import com.gcsc.guide.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final RoleRepository roleRepository;
private final LoginHistoryRepository loginHistoryRepository;
@Transactional(readOnly = true)
public List<UserResponse> getUsers(String status) {
List<User> users;
if (status != null && !status.isBlank()) {
UserStatus userStatus = UserStatus.valueOf(status.toUpperCase());
users = userRepository.findByStatus(userStatus);
} else {
users = userRepository.findAll();
}
return users.stream().map(UserResponse::from).toList();
}
@Transactional
public UserResponse approveUser(Long userId) {
User user = findUserById(userId);
if (user.getStatus() != UserStatus.PENDING) {
throw new BusinessException("PENDING 상태의 사용자만 승인할 수 있습니다 (현재: " + user.getStatus() + ")");
}
user.activate();
return UserResponse.from(userRepository.save(user));
}
@Transactional
public UserResponse rejectUser(Long userId) {
User user = findUserById(userId);
if (user.getStatus() != UserStatus.PENDING) {
throw new BusinessException("PENDING 상태의 사용자만 거절할 수 있습니다 (현재: " + user.getStatus() + ")");
}
user.reject();
return UserResponse.from(userRepository.save(user));
}
@Transactional
public UserResponse disableUser(Long userId) {
User user = findUserById(userId);
if (user.getStatus() != UserStatus.ACTIVE) {
throw new BusinessException("ACTIVE 상태의 사용자만 비활성화할 수 있습니다 (현재: " + user.getStatus() + ")");
}
user.disable();
return UserResponse.from(userRepository.save(user));
}
@Transactional
public UserResponse updateUserRoles(Long userId, List<Long> roleIds) {
User user = findUserById(userId);
Set<Role> roles = new HashSet<>(roleRepository.findAllById(roleIds));
if (roles.size() != roleIds.size()) {
throw new BusinessException("일부 롤을 찾을 수 없습니다");
}
user.updateRoles(roles);
userRepository.save(user);
return UserResponse.from(userRepository.findByIdWithRoles(userId).orElseThrow());
}
@Transactional
public UserResponse grantAdmin(Long userId) {
User user = findUserById(userId);
user.grantAdmin();
return UserResponse.from(userRepository.save(user));
}
@Transactional
public UserResponse revokeAdmin(Long userId) {
User user = findUserById(userId);
user.revokeAdmin();
return UserResponse.from(userRepository.save(user));
}
@Transactional(readOnly = true)
public StatsResponse getStats() {
long totalUsers = userRepository.count();
long activeUsers = userRepository.countByStatus(UserStatus.ACTIVE);
long pendingUsers = userRepository.countByStatus(UserStatus.PENDING);
long rejectedUsers = userRepository.countByStatus(UserStatus.REJECTED);
long disabledUsers = userRepository.countByStatus(UserStatus.DISABLED);
long todayLogins = loginHistoryRepository.countByLoginAtAfter(
LocalDate.now().atStartOfDay());
long totalRoles = roleRepository.count();
return new StatsResponse(
totalUsers, activeUsers, pendingUsers,
rejectedUsers, disabledUsers, todayLogins, totalRoles
);
}
private User findUserById(Long userId) {
return userRepository.findById(userId)
.orElseThrow(() -> new ResourceNotFoundException("사용자", userId));
}
}

파일 보기

@ -27,26 +27,9 @@ app:
expiration-ms: ${JWT_EXPIRATION:86400000} # 24시간 expiration-ms: ${JWT_EXPIRATION:86400000} # 24시간
google: google:
client-id: ${GOOGLE_CLIENT_ID:} client-id: ${GOOGLE_CLIENT_ID:}
# Optional: allow multiple audiences (comma-separated) for shared auth across multiple frontends. allowed-email-domain: gcsc.co.kr
client-ids: ${GOOGLE_CLIENT_IDS:${GOOGLE_CLIENT_ID:}}
allowed-email-domain: ${ALLOWED_EMAIL_DOMAIN:gcsc.co.kr}
wing:
ais:
upstream-base: ${WING_AIS_UPSTREAM_BASE:http://211.208.115.83:8041}
timeout-ms: ${WING_AIS_TIMEOUT_MS:20000}
cors: cors:
allowed-origins: ${CORS_ORIGINS:http://localhost:5173,http://localhost:5175,http://127.0.0.1:5175,https://guide.gc-si.dev,https://wing.gc-si.dev} allowed-origins: ${CORS_ORIGINS:http://localhost:5173,https://guide.gc-si.dev}
# SpringDoc / Swagger
springdoc:
api-docs:
path: /v3/api-docs
swagger-ui:
path: /swagger-ui.html
tags-sorter: alpha
operations-sorter: method
doc-expansion: none
display-request-duration: true
# Actuator # Actuator
management: management:

파일 보기

@ -1,17 +1,11 @@
-- 초기 롤 시드 데이터 -- 초기 롤 시드 데이터
INSERT INTO roles (name, description, default_grant, created_at) VALUES INSERT INTO roles (name, description, created_at) VALUES
('ADMIN', '전체 접근 권한 (관리자 페이지 포함)', false, NOW()), ('ADMIN', '전체 접근 권한 (관리자 페이지 포함)', NOW()),
('DEVELOPER', '전체 개발 가이드 접근', false, NOW()), ('DEVELOPER', '전체 개발 가이드 접근', NOW()),
('FRONT_DEV', '프론트엔드 개발 가이드만', false, NOW()), ('FRONT_DEV', '프론트엔드 개발 가이드만', NOW());
('WING_PERMIT', 'Wing 데모 사이트 접근 권한', true, NOW());
-- 롤별 URL 패턴 -- 롤별 URL 패턴
INSERT INTO role_url_patterns (role_id, url_pattern, created_at) VALUES 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 = 'ADMIN'), '/**', NOW()),
((SELECT id FROM roles WHERE name = 'DEVELOPER'), '/dev/**', 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());

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long