Compare commits
31 커밋
feature/au
...
main
| 작성자 | SHA1 | 날짜 | |
|---|---|---|---|
| b39b0df6b9 | |||
| c35084d3dc | |||
| 4092f5e8b4 | |||
| f62751229e | |||
| 2619dce781 | |||
| 69de3f9ae7 | |||
| cf9b4d118b | |||
| ce6e88e221 | |||
| 4c837b0ce4 | |||
| 460f941a8b | |||
| 04f3de3890 | |||
| a5f58970a9 | |||
| 89b7936671 | |||
| 3b70d8c33d | |||
| 30f0b28460 | |||
| 8e780413ce | |||
| c0e33e11d7 | |||
| c295da16d1 | |||
| 709edd0345 | |||
| e92b0e15ef | |||
| 1955165985 | |||
| 357879988e | |||
| 57b11774eb | |||
| 487832675c | |||
| 9db7b8bfb4 | |||
| a3369f5bd3 | |||
| 3e918baf74 | |||
| d1416d89a6 | |||
| acf18221ae | |||
| e98efbd6e0 | |||
| cc03aa14ff |
49
.gitea/workflows/deploy.yml
Normal file
49
.gitea/workflows/deploy.yml
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
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/
|
||||||
9
Dockerfile
Normal file
9
Dockerfile
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
FROM eclipse-temurin:17-jre-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY target/gc-guide-api-*.jar app.jar
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||||
7
pom.xml
7
pom.xml
@ -87,6 +87,13 @@
|
|||||||
<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,12 +3,27 @@ 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;
|
||||||
@ -19,6 +34,7 @@ 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";
|
||||||
@ -26,12 +42,24 @@ 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 로그인",
|
||||||
* Google ID Token으로 로그인/회원가입 처리 후 JWT 발급
|
description = "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(@Valid @RequestBody GoogleLoginRequest request) {
|
public ResponseEntity<AuthResponse> googleLogin(
|
||||||
|
@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 토큰입니다");
|
||||||
@ -54,15 +82,25 @@ 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();
|
||||||
@ -73,9 +111,10 @@ public class AuthController {
|
|||||||
return ResponseEntity.ok(UserResponse.from(user));
|
return ResponseEntity.ok(UserResponse.from(user));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
@Operation(summary = "로그아웃",
|
||||||
* 로그아웃 (Stateless JWT이므로 서버 측 처리 없음, 프론트에서 토큰 삭제)
|
description = "Stateless JWT 방식이므로 서버 측 처리 없이 204를 반환합니다. 클라이언트에서 토큰을 삭제하세요.")
|
||||||
*/
|
@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();
|
||||||
@ -88,9 +127,31 @@ 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,7 +8,9 @@ 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
|
||||||
@ -18,12 +20,26 @@ public class GoogleTokenVerifier {
|
|||||||
private final String allowedEmailDomain;
|
private final String allowedEmailDomain;
|
||||||
|
|
||||||
public GoogleTokenVerifier(
|
public GoogleTokenVerifier(
|
||||||
@Value("${app.google.client-id}") String clientId,
|
@Value("${app.google.client-ids:}") String clientIdsCsv,
|
||||||
|
@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(Collections.singletonList(clientId))
|
.setAudience(audiences.isEmpty() ? Collections.emptyList() : audiences)
|
||||||
.build();
|
.build();
|
||||||
this.allowedEmailDomain = allowedEmailDomain;
|
this.allowedEmailDomain = allowedEmailDomain;
|
||||||
}
|
}
|
||||||
|
|||||||
54
src/main/java/com/gcsc/guide/config/OpenApiConfig.java
Normal file
54
src/main/java/com/gcsc/guide/config/OpenApiConfig.java
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
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,10 +1,13 @@
|
|||||||
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;
|
||||||
@ -14,7 +17,9 @@ 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
|
||||||
@ -22,8 +27,9 @@ import java.util.List;
|
|||||||
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,https://guide.gc-si.dev}")
|
@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}")
|
||||||
private List<String> allowedOrigins;
|
private List<String> allowedOrigins;
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
@ -35,14 +41,42 @@ public class SecurityConfig {
|
|||||||
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||||
.authorizeHttpRequests(auth -> auth
|
.authorizeHttpRequests(auth -> auth
|
||||||
.requestMatchers(
|
.requestMatchers(
|
||||||
"/api/auth/**",
|
"/api/auth/google",
|
||||||
|
"/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);
|
||||||
|
|
||||||
@ -55,11 +89,12 @@ 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("/api/**", config);
|
source.registerCorsConfiguration("/**", config);
|
||||||
return source;
|
return source;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,58 @@
|
|||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
153
src/main/java/com/gcsc/guide/controller/AdminRoleController.java
Normal file
153
src/main/java/com/gcsc/guide/controller/AdminRoleController.java
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,39 @@
|
|||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
137
src/main/java/com/gcsc/guide/controller/AdminUserController.java
Normal file
137
src/main/java/com/gcsc/guide/controller/AdminUserController.java
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
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,13 +1,22 @@
|
|||||||
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(
|
||||||
|
|||||||
112
src/main/java/com/gcsc/guide/controller/IssueController.java
Normal file
112
src/main/java/com/gcsc/guide/controller/IssueController.java
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
215
src/main/java/com/gcsc/guide/controller/WingAisController.java
Normal file
215
src/main/java/com/gcsc/guide/controller/WingAisController.java
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
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("¢erLon=").append(centerLon);
|
||||||
|
if (centerLat != null && Double.isFinite(centerLat)) sb.append("¢erLat=").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
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
package com.gcsc.guide.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
public record AddPermissionRequest(
|
||||||
|
@NotBlank String urlPattern
|
||||||
|
) {
|
||||||
|
}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
package com.gcsc.guide.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
public record CreateCommentRequest(
|
||||||
|
@NotBlank String body
|
||||||
|
) {
|
||||||
|
}
|
||||||
14
src/main/java/com/gcsc/guide/dto/CreateIssueRequest.java
Normal file
14
src/main/java/com/gcsc/guide/dto/CreateIssueRequest.java
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
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
|
||||||
|
) {
|
||||||
|
}
|
||||||
9
src/main/java/com/gcsc/guide/dto/CreateRoleRequest.java
Normal file
9
src/main/java/com/gcsc/guide/dto/CreateRoleRequest.java
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
package com.gcsc.guide.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
public record CreateRoleRequest(
|
||||||
|
@NotBlank String name,
|
||||||
|
String description
|
||||||
|
) {
|
||||||
|
}
|
||||||
30
src/main/java/com/gcsc/guide/dto/IssueCommentResponse.java
Normal file
30
src/main/java/com/gcsc/guide/dto/IssueCommentResponse.java
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
53
src/main/java/com/gcsc/guide/dto/IssueResponse.java
Normal file
53
src/main/java/com/gcsc/guide/dto/IssueResponse.java
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/main/java/com/gcsc/guide/dto/LoginHistoryResponse.java
Normal file
22
src/main/java/com/gcsc/guide/dto/LoginHistoryResponse.java
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
package com.gcsc.guide.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record RegistrationSettingsResponse(
|
||||||
|
boolean autoApprove,
|
||||||
|
List<RoleResponse> defaultRoles
|
||||||
|
) {}
|
||||||
@ -9,7 +9,8 @@ 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) {
|
||||||
@ -21,7 +22,8 @@ public record RoleResponse(
|
|||||||
role.getId(),
|
role.getId(),
|
||||||
role.getName(),
|
role.getName(),
|
||||||
role.getDescription(),
|
role.getDescription(),
|
||||||
patterns
|
patterns,
|
||||||
|
role.isDefaultGrant()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
12
src/main/java/com/gcsc/guide/dto/StatsResponse.java
Normal file
12
src/main/java/com/gcsc/guide/dto/StatsResponse.java
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
package com.gcsc.guide.dto;
|
||||||
|
|
||||||
|
public record StatsResponse(
|
||||||
|
long totalUsers,
|
||||||
|
long activeUsers,
|
||||||
|
long pendingUsers,
|
||||||
|
long rejectedUsers,
|
||||||
|
long disabledUsers,
|
||||||
|
long todayLogins,
|
||||||
|
long totalRoles
|
||||||
|
) {
|
||||||
|
}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
package com.gcsc.guide.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
public record TrackPageViewRequest(
|
||||||
|
@NotBlank String pagePath
|
||||||
|
) {
|
||||||
|
}
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
package com.gcsc.guide.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
public record UpdateDefaultGrantRequest(
|
||||||
|
@NotNull Boolean defaultGrant
|
||||||
|
) {}
|
||||||
14
src/main/java/com/gcsc/guide/dto/UpdateIssueRequest.java
Normal file
14
src/main/java/com/gcsc/guide/dto/UpdateIssueRequest.java
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
package com.gcsc.guide.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
public record UpdateRegistrationSettingsRequest(
|
||||||
|
@NotNull Boolean autoApprove
|
||||||
|
) {}
|
||||||
10
src/main/java/com/gcsc/guide/dto/UpdateRolesRequest.java
Normal file
10
src/main/java/com/gcsc/guide/dto/UpdateRolesRequest.java
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
package com.gcsc.guide.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record UpdateRolesRequest(
|
||||||
|
@NotNull List<Long> roleIds
|
||||||
|
) {
|
||||||
|
}
|
||||||
46
src/main/java/com/gcsc/guide/entity/AppSetting.java
Normal file
46
src/main/java/com/gcsc/guide/entity/AppSetting.java
Normal file
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
99
src/main/java/com/gcsc/guide/entity/Issue.java
Normal file
99
src/main/java/com/gcsc/guide/entity/Issue.java
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/main/java/com/gcsc/guide/entity/IssueComment.java
Normal file
43
src/main/java/com/gcsc/guide/entity/IssueComment.java
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
42
src/main/java/com/gcsc/guide/entity/LoginHistory.java
Normal file
42
src/main/java/com/gcsc/guide/entity/LoginHistory.java
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/main/java/com/gcsc/guide/entity/PageView.java
Normal file
38
src/main/java/com/gcsc/guide/entity/PageView.java
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
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,6 +24,9 @@ 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;
|
||||||
|
|
||||||
@ -40,6 +43,10 @@ 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();
|
||||||
|
|||||||
@ -0,0 +1,8 @@
|
|||||||
|
package com.gcsc.guide.exception;
|
||||||
|
|
||||||
|
public class BusinessException extends RuntimeException {
|
||||||
|
|
||||||
|
public BusinessException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,56 @@
|
|||||||
|
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()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<AppSetting, Long> {
|
||||||
|
|
||||||
|
Optional<AppSetting> findBySettingKey(String settingKey);
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
17
src/main/java/com/gcsc/guide/repository/IssueRepository.java
Normal file
17
src/main/java/com/gcsc/guide/repository/IssueRepository.java
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
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,4 +16,6 @@ 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();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,7 @@
|
|||||||
|
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> {
|
||||||
|
}
|
||||||
45
src/main/java/com/gcsc/guide/service/ActivityService.java
Normal file
45
src/main/java/com/gcsc/guide/service/ActivityService.java
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
118
src/main/java/com/gcsc/guide/service/IssueService.java
Normal file
118
src/main/java/com/gcsc/guide/service/IssueService.java
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
96
src/main/java/com/gcsc/guide/service/RoleService.java
Normal file
96
src/main/java/com/gcsc/guide/service/RoleService.java
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/main/java/com/gcsc/guide/service/SettingsService.java
Normal file
47
src/main/java/com/gcsc/guide/service/SettingsService.java
Normal file
@ -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<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();
|
||||||
|
}
|
||||||
|
}
|
||||||
120
src/main/java/com/gcsc/guide/service/UserService.java
Normal file
120
src/main/java/com/gcsc/guide/service/UserService.java
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
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,9 +27,26 @@ 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:}
|
||||||
allowed-email-domain: gcsc.co.kr
|
# Optional: allow multiple audiences (comma-separated) for shared auth across multiple frontends.
|
||||||
|
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,https://guide.gc-si.dev}
|
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}
|
||||||
|
|
||||||
|
# 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,11 +1,17 @@
|
|||||||
-- 초기 롤 시드 데이터
|
-- 초기 롤 시드 데이터
|
||||||
INSERT INTO roles (name, description, created_at) VALUES
|
INSERT INTO roles (name, description, default_grant, created_at) VALUES
|
||||||
('ADMIN', '전체 접근 권한 (관리자 페이지 포함)', NOW()),
|
('ADMIN', '전체 접근 권한 (관리자 페이지 포함)', false, NOW()),
|
||||||
('DEVELOPER', '전체 개발 가이드 접근', NOW()),
|
('DEVELOPER', '전체 개발 가이드 접근', false, NOW()),
|
||||||
('FRONT_DEV', '프론트엔드 개발 가이드만', NOW());
|
('FRONT_DEV', '프론트엔드 개발 가이드만', false, 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());
|
||||||
|
|||||||
1
src/main/resources/wing-data/chinese-permitted.v1.json
Normal file
1
src/main/resources/wing-data/chinese-permitted.v1.json
Normal file
File diff suppressed because one or more lines are too long
1
src/main/resources/wing-data/zones.wgs84.geojson
Normal file
1
src/main/resources/wing-data/zones.wgs84.geojson
Normal file
File diff suppressed because one or more lines are too long
불러오는 중...
Reference in New Issue
Block a user