Compare commits

...

17 커밋

작성자 SHA1 메시지 날짜
30f0b28460 Merge pull request 'fix(security): 인증 에러 401 응답 + permitAll 패턴 수정' (#9) from develop into main
All checks were successful
Build and Deploy API / build-and-deploy (push) Successful in 17s
Reviewed-on: #9
2026-02-14 22:06:45 +09:00
8e780413ce fix(security): permitAll 패턴에서 /api/auth/me 제외
/api/auth/** 와일드카드가 /api/auth/me까지 공개하여
인증 없이 접근 시 NPE(500) 발생. /api/auth/google과
/api/auth/logout만 공개하도록 수정.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 22:01:17 +09:00
c0e33e11d7 Merge pull request 'fix(security): 인증 에러 401 응답 + CORS 헤더 누락 수정' (#8) from develop into main
All checks were successful
Build and Deploy API / build-and-deploy (push) Successful in 17s
Reviewed-on: #8
2026-02-14 21:55:11 +09:00
c295da16d1 Merge branch 'feature/admin-and-api' into develop 2026-02-14 21:54:26 +09:00
709edd0345 fix(security): 인증 에러 401 응답 + CORS 헤더 누락 수정
- AuthenticationEntryPoint 추가: 미인증 요청에 403 대신 401 반환
- AccessDeniedHandler 추가: 권한 부족 시 403 + JSON body 반환
- CORS 설정 범위를 /api/** → /** 로 확장하여 에러 응답에도 CORS 헤더 포함
- exposedHeaders에 Authorization 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 21:54:01 +09:00
e92b0e15ef Merge pull request 'develop' (#7) from develop into main
All checks were successful
Build and Deploy API / build-and-deploy (push) Successful in 17s
Reviewed-on: #7
2026-02-14 21:38:36 +09:00
1955165985 Merge pull request 'docs: Swagger/OpenAPI 문서 전체 구현' (#6) from feature/admin-and-api into develop
Reviewed-on: #6
2026-02-14 21:36:51 +09:00
357879988e docs: Swagger/OpenAPI 문서 전체 구현
springdoc-openapi 2.8.6 기반으로 모든 API 엔드포인트에
Swagger 어노테이션을 추가하여 API 문서를 자동 생성합니다.

- OpenApiConfig: JWT 보안 스킴, 서버 목록, API 정보 설정
- SecurityConfig: swagger-ui 경로 공개 접근 허용
- 7개 Controller: @Tag, @Operation, @ApiResponses, @Parameter 등
  (00.시스템, 01.인증, 02~04.관리자, 05.활동, 06.이슈)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 21:30:48 +09:00
57b11774eb Merge pull request 'develop' (#5) from develop into main
All checks were successful
Build and Deploy API / build-and-deploy (push) Successful in 16s
Reviewed-on: #5
2026-02-14 21:15:16 +09:00
487832675c Merge pull request 'feat: 관리자/활동/이슈 API 전체 구현' (#4) from feature/admin-and-api into develop
Reviewed-on: #4
2026-02-14 21:14:24 +09:00
9db7b8bfb4 feat: 관리자/활동/이슈 API 전체 구현
- Entity: LoginHistory, PageView, Issue, IssueComment 추가
- Repository: 각 엔티티별 JpaRepository 추가
- Service: UserService, RoleService, ActivityService, IssueService
- Admin API: 사용자 관리 7개, 롤/권한 관리 7개, 통계 1개 엔드포인트
- Activity API: 페이지뷰 기록, 로그인 이력 조회
- Issue API: CRUD + 코멘트, 프로젝트/위치/Gitea 링크 지원
- Exception: GlobalExceptionHandler, ResourceNotFoundException, BusinessException
- AuthController: 로그인 시 LoginHistory 기록 추가
- Dockerfile 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 21:13:14 +09:00
a3369f5bd3 fix: git clone URL을 Gitea 내부 주소로 변경
- GITHUB_SERVER_URL은 https://github.com으로 고정되어 클론 실패
- http://gitea:3000 (Docker 내부 네트워크)으로 직접 지정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 20:21:37 +09:00
3e918baf74 Merge pull request 'fix: CI/CD 워크플로우 checkout 에러 수정' (#3) from develop into main
All checks were successful
Build and Deploy API / build-and-deploy (push) Successful in 21s
Reviewed-on: #3
2026-02-14 20:21:12 +09:00
d1416d89a6 fix: CI/CD 워크플로우 checkout 에러 수정
- container 이미지(maven)에 Node.js 없어서 actions/checkout 실패
- git clone으로 대체
- .deploy-trigger 파일 생성 → systemd path unit이 감지하여 서비스 자동 재시작

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 20:18:28 +09:00
acf18221ae Merge pull request 'feat: CI/CD 자동 배포 워크플로우 추가' (#2) from develop into main
Some checks failed
Build and Deploy API / build-and-deploy (push) Failing after 21s
Reviewed-on: #2
2026-02-14 20:15:38 +09:00
e98efbd6e0 feat: CI/CD 자동 배포 워크플로우 추가
- .gitea/workflows/deploy.yml: main 머지 시 Maven 빌드 + JAR 배포
- container: maven:3.9-eclipse-temurin-17 이미지 사용
- Nexus Maven 프록시 설정 포함

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 20:15:08 +09:00
cc03aa14ff Merge pull request 'feat(auth): JWT 기반 Google 로그인 인증 API 구현' (#1) from feature/auth-api into develop
Reviewed-on: #1
2026-02-14 17:32:13 +09:00
40개의 변경된 파일1615개의 추가작업 그리고 13개의 파일을 삭제

파일 보기

@ -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
파일 보기

@ -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"]

파일 보기

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

파일 보기

@ -5,7 +5,16 @@ import com.gcsc.guide.dto.GoogleLoginRequest;
import com.gcsc.guide.dto.UserResponse;
import com.gcsc.guide.entity.User;
import com.gcsc.guide.repository.UserRepository;
import com.gcsc.guide.service.ActivityService;
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 lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@ -19,6 +28,7 @@ import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
@Tag(name = "01. 인증", description = "Google OAuth2 로그인 및 JWT 토큰 관리")
public class AuthController {
private static final String AUTO_ADMIN_EMAIL = "htlee@gcsc.co.kr";
@ -26,12 +36,22 @@ public class AuthController {
private final GoogleTokenVerifier googleTokenVerifier;
private final JwtTokenProvider jwtTokenProvider;
private final UserRepository userRepository;
private final ActivityService activityService;
/**
* Google ID Token으로 로그인/회원가입 처리 JWT 발급
*/
@Operation(summary = "Google 로그인",
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")
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());
if (payload == null) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "유효하지 않은 Google 토큰입니다");
@ -54,15 +74,25 @@ public class AuthController {
User userWithRoles = userRepository.findByEmailWithRoles(email)
.orElseThrow();
activityService.recordLogin(
userWithRoles.getId(),
httpRequest.getRemoteAddr(),
httpRequest.getHeader("User-Agent"));
String token = jwtTokenProvider.generateToken(
userWithRoles.getId(), userWithRoles.getEmail(), userWithRoles.isAdmin());
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")
public ResponseEntity<UserResponse> getCurrentUser(Authentication authentication) {
Long userId = (Long) authentication.getPrincipal();
@ -73,9 +103,10 @@ public class AuthController {
return ResponseEntity.ok(UserResponse.from(user));
}
/**
* 로그아웃 (Stateless JWT이므로 서버 처리 없음, 프론트에서 토큰 삭제)
*/
@Operation(summary = "로그아웃",
description = "Stateless JWT 방식이므로 서버 측 처리 없이 204를 반환합니다. 클라이언트에서 토큰을 삭제하세요.")
@ApiResponse(responseCode = "204", description = "로그아웃 성공")
@SecurityRequirement(name = "Bearer JWT")
@PostMapping("/logout")
public ResponseEntity<Void> logout() {
return ResponseEntity.noContent().build();

파일 보기

@ -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;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.gcsc.guide.auth.JwtAuthenticationFilter;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
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.configuration.EnableWebSecurity;
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.UrlBasedCorsConfigurationSource;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
@Configuration
@EnableWebSecurity
@ -22,6 +27,7 @@ import java.util.List;
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final ObjectMapper objectMapper;
@Value("${app.cors.allowed-origins:http://localhost:5173,https://guide.gc-si.dev}")
private List<String> allowedOrigins;
@ -35,14 +41,42 @@ public class SecurityConfig {
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers(
"/api/auth/**",
"/api/auth/google",
"/api/auth/logout",
"/api/health",
"/actuator/health",
"/h2-console/**"
"/h2-console/**",
"/swagger-ui/**",
"/swagger-ui.html",
"/v3/api-docs/**"
).permitAll()
.requestMatchers("/api/admin/**").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()))
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
@ -55,11 +89,12 @@ public class SecurityConfig {
config.setAllowedOrigins(allowedOrigins);
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setExposedHeaders(List.of("Authorization"));
config.setAllowCredentials(true);
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
source.registerCorsConfiguration("/**", config);
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));
}
}

파일 보기

@ -0,0 +1,136 @@
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.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 = "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,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());
}
}

파일 보기

@ -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;
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.RestController;
import java.util.Map;
@RestController
@Tag(name = "00. 시스템", description = "헬스체크 및 시스템 상태 확인")
public class HealthController {
@Operation(summary = "헬스체크",
description = "서버 가동 상태를 확인합니다. 인증 없이 접근 가능합니다.",
security = {})
@ApiResponse(responseCode = "200", description = "서버 정상 가동 중")
@GetMapping("/api/health")
public Map<String, String> health() {
return Map.of(

파일 보기

@ -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);
}
}

파일 보기

@ -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
) {
}

파일 보기

@ -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
) {
}

파일 보기

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

파일 보기

@ -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()
);
}
}

파일 보기

@ -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()
);
}
}

파일 보기

@ -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,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,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,10 @@
package com.gcsc.guide.dto;
import jakarta.validation.constraints.NotNull;
import java.util.List;
public record UpdateRolesRequest(
@NotNull List<Long> roleIds
) {
}

파일 보기

@ -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();
}
}

파일 보기

@ -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();
}
}

파일 보기

@ -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();
}
}

파일 보기

@ -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();
}
}

파일 보기

@ -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,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);
}

파일 보기

@ -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> {
}

파일 보기

@ -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> {
}

파일 보기

@ -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();
}
}

파일 보기

@ -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));
}
}

파일 보기

@ -0,0 +1,88 @@
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);
}
private Role findRoleById(Long roleId) {
return roleRepository.findById(roleId)
.orElseThrow(() -> new ResourceNotFoundException("", roleId));
}
}

파일 보기

@ -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));
}
}

파일 보기

@ -31,6 +31,17 @@ app:
cors:
allowed-origins: ${CORS_ORIGINS:http://localhost:5173,https://guide.gc-si.dev}
# SpringDoc / Swagger
springdoc:
api-docs:
path: /v3/api-docs
swagger-ui:
path: /swagger-ui.html
tags-sorter: alpha
operations-sorter: method
doc-expansion: none
display-request-duration: true
# Actuator
management:
endpoints: