diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 9308ba4..96f770c 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -14,7 +14,7 @@ jobs: - name: Checkout run: | git clone --depth=1 --branch=${GITHUB_REF_NAME} \ - ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git . + http://gitea:3000/${GITHUB_REPOSITORY}.git . - name: Configure Maven settings run: | diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..693a7f6 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/src/main/java/com/gcsc/guide/auth/AuthController.java b/src/main/java/com/gcsc/guide/auth/AuthController.java index 174467d..2a6d750 100644 --- a/src/main/java/com/gcsc/guide/auth/AuthController.java +++ b/src/main/java/com/gcsc/guide/auth/AuthController.java @@ -5,7 +5,9 @@ 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 jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -26,12 +28,15 @@ public class AuthController { private final GoogleTokenVerifier googleTokenVerifier; private final JwtTokenProvider jwtTokenProvider; private final UserRepository userRepository; + private final ActivityService activityService; /** * Google ID Token으로 로그인/회원가입 처리 후 JWT 발급 */ @PostMapping("/google") - public ResponseEntity googleLogin(@Valid @RequestBody GoogleLoginRequest request) { + public ResponseEntity googleLogin( + @Valid @RequestBody GoogleLoginRequest request, + HttpServletRequest httpRequest) { GoogleIdToken.Payload payload = googleTokenVerifier.verify(request.idToken()); if (payload == null) { throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "유효하지 않은 Google 토큰입니다"); @@ -54,6 +59,11 @@ 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()); diff --git a/src/main/java/com/gcsc/guide/controller/ActivityController.java b/src/main/java/com/gcsc/guide/controller/ActivityController.java new file mode 100644 index 0000000..212d510 --- /dev/null +++ b/src/main/java/com/gcsc/guide/controller/ActivityController.java @@ -0,0 +1,40 @@ +package com.gcsc.guide.controller; + +import com.gcsc.guide.dto.LoginHistoryResponse; +import com.gcsc.guide.dto.TrackPageViewRequest; +import com.gcsc.guide.service.ActivityService; +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; + +/** + * 활동 기록 API + */ +@RestController +@RequestMapping("/api/activity") +@RequiredArgsConstructor +public class ActivityController { + + private final ActivityService activityService; + + /** 페이지 뷰 기록 */ + @PostMapping("/track") + public ResponseEntity trackPageView( + Authentication authentication, + @Valid @RequestBody TrackPageViewRequest request) { + Long userId = (Long) authentication.getPrincipal(); + activityService.trackPageView(userId, request.pagePath()); + return ResponseEntity.ok().build(); + } + + /** 현재 사용자의 로그인 이력 조회 */ + @GetMapping("/login-history") + public ResponseEntity> getLoginHistory(Authentication authentication) { + Long userId = (Long) authentication.getPrincipal(); + return ResponseEntity.ok(activityService.getLoginHistory(userId)); + } +} diff --git a/src/main/java/com/gcsc/guide/controller/AdminRoleController.java b/src/main/java/com/gcsc/guide/controller/AdminRoleController.java new file mode 100644 index 0000000..6538533 --- /dev/null +++ b/src/main/java/com/gcsc/guide/controller/AdminRoleController.java @@ -0,0 +1,73 @@ +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 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; + +/** + * 관리자 롤/권한 관리 API + */ +@RestController +@RequestMapping("/api/admin/roles") +@RequiredArgsConstructor +public class AdminRoleController { + + private final RoleService roleService; + + /** 전체 롤 목록 */ + @GetMapping + public ResponseEntity> getRoles() { + return ResponseEntity.ok(roleService.getRoles()); + } + + /** 롤 생성 */ + @PostMapping + public ResponseEntity 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); + } + + /** 롤 수정 */ + @PutMapping("/{id}") + public ResponseEntity updateRole( + @PathVariable Long id, + @Valid @RequestBody CreateRoleRequest request) { + return ResponseEntity.ok(roleService.updateRole(id, request.name(), request.description())); + } + + /** 롤 삭제 */ + @DeleteMapping("/{id}") + public ResponseEntity deleteRole(@PathVariable Long id) { + roleService.deleteRole(id); + return ResponseEntity.noContent().build(); + } + + /** 롤의 URL 패턴 목록 */ + @GetMapping("/{id}/permissions") + public ResponseEntity> getPermissions(@PathVariable Long id) { + return ResponseEntity.ok(roleService.getPermissions(id)); + } + + /** URL 패턴 추가 */ + @PostMapping("/{id}/permissions") + public ResponseEntity addPermission( + @PathVariable Long id, + @Valid @RequestBody AddPermissionRequest request) { + return ResponseEntity.ok(roleService.addPermission(id, request.urlPattern())); + } + + /** URL 패턴 삭제 */ + @DeleteMapping("/permissions/{permissionId}") + public ResponseEntity deletePermission(@PathVariable Long permissionId) { + roleService.deletePermission(permissionId); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/gcsc/guide/controller/AdminStatsController.java b/src/main/java/com/gcsc/guide/controller/AdminStatsController.java new file mode 100644 index 0000000..0a4a6d7 --- /dev/null +++ b/src/main/java/com/gcsc/guide/controller/AdminStatsController.java @@ -0,0 +1,26 @@ +package com.gcsc.guide.controller; + +import com.gcsc.guide.dto.StatsResponse; +import com.gcsc.guide.service.UserService; +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; + +/** + * 관리자 통계 API + */ +@RestController +@RequestMapping("/api/admin/stats") +@RequiredArgsConstructor +public class AdminStatsController { + + private final UserService userService; + + /** 전체 통계 조회 */ + @GetMapping + public ResponseEntity getStats() { + return ResponseEntity.ok(userService.getStats()); + } +} diff --git a/src/main/java/com/gcsc/guide/controller/AdminUserController.java b/src/main/java/com/gcsc/guide/controller/AdminUserController.java new file mode 100644 index 0000000..2f53d71 --- /dev/null +++ b/src/main/java/com/gcsc/guide/controller/AdminUserController.java @@ -0,0 +1,67 @@ +package com.gcsc.guide.controller; + +import com.gcsc.guide.dto.UpdateRolesRequest; +import com.gcsc.guide.dto.UserResponse; +import com.gcsc.guide.service.UserService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 관리자 사용자 관리 API + */ +@RestController +@RequestMapping("/api/admin/users") +@RequiredArgsConstructor +public class AdminUserController { + + private final UserService userService; + + /** 전체 사용자 목록 조회 (status 필터 선택) */ + @GetMapping + public ResponseEntity> getUsers( + @RequestParam(required = false) String status) { + return ResponseEntity.ok(userService.getUsers(status)); + } + + /** 사용자 승인 (PENDING → ACTIVE) */ + @PutMapping("/{id}/approve") + public ResponseEntity approveUser(@PathVariable Long id) { + return ResponseEntity.ok(userService.approveUser(id)); + } + + /** 사용자 거절 (PENDING → REJECTED) */ + @PutMapping("/{id}/reject") + public ResponseEntity rejectUser(@PathVariable Long id) { + return ResponseEntity.ok(userService.rejectUser(id)); + } + + /** 사용자 비활성화 (ACTIVE → DISABLED) */ + @PutMapping("/{id}/disable") + public ResponseEntity disableUser(@PathVariable Long id) { + return ResponseEntity.ok(userService.disableUser(id)); + } + + /** 사용자 롤 업데이트 */ + @PutMapping("/{id}/roles") + public ResponseEntity updateUserRoles( + @PathVariable Long id, + @Valid @RequestBody UpdateRolesRequest request) { + return ResponseEntity.ok(userService.updateUserRoles(id, request.roleIds())); + } + + /** 관리자 권한 부여 */ + @PostMapping("/{id}/admin") + public ResponseEntity grantAdmin(@PathVariable Long id) { + return ResponseEntity.ok(userService.grantAdmin(id)); + } + + /** 관리자 권한 해제 */ + @DeleteMapping("/{id}/admin") + public ResponseEntity revokeAdmin(@PathVariable Long id) { + return ResponseEntity.ok(userService.revokeAdmin(id)); + } +} diff --git a/src/main/java/com/gcsc/guide/controller/IssueController.java b/src/main/java/com/gcsc/guide/controller/IssueController.java new file mode 100644 index 0000000..5852b9e --- /dev/null +++ b/src/main/java/com/gcsc/guide/controller/IssueController.java @@ -0,0 +1,71 @@ +package com.gcsc.guide.controller; + +import com.gcsc.guide.dto.*; +import com.gcsc.guide.service.IssueService; +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; + +/** + * 이슈 관리 API + */ +@RestController +@RequestMapping("/api/issues") +@RequiredArgsConstructor +public class IssueController { + + private final IssueService issueService; + + /** 이슈 목록 (status 필터, 페이징) */ + @GetMapping + public ResponseEntity> getIssues( + @RequestParam(required = false) String status, + @PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable) { + return ResponseEntity.ok(issueService.getIssues(status, pageable)); + } + + /** 이슈 생성 */ + @PostMapping + public ResponseEntity 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); + } + + /** 이슈 상세 (코멘트 포함) */ + @GetMapping("/{id}") + public ResponseEntity> getIssue(@PathVariable Long id) { + return ResponseEntity.ok(issueService.getIssueDetail(id)); + } + + /** 이슈 수정 */ + @PutMapping("/{id}") + public ResponseEntity updateIssue( + @PathVariable Long id, + @RequestBody UpdateIssueRequest request) { + return ResponseEntity.ok(issueService.updateIssue(id, request)); + } + + /** 코멘트 추가 */ + @PostMapping("/{id}/comments") + public ResponseEntity addComment( + @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); + } +} diff --git a/src/main/java/com/gcsc/guide/dto/AddPermissionRequest.java b/src/main/java/com/gcsc/guide/dto/AddPermissionRequest.java new file mode 100644 index 0000000..6f0f323 --- /dev/null +++ b/src/main/java/com/gcsc/guide/dto/AddPermissionRequest.java @@ -0,0 +1,8 @@ +package com.gcsc.guide.dto; + +import jakarta.validation.constraints.NotBlank; + +public record AddPermissionRequest( + @NotBlank String urlPattern +) { +} diff --git a/src/main/java/com/gcsc/guide/dto/CreateCommentRequest.java b/src/main/java/com/gcsc/guide/dto/CreateCommentRequest.java new file mode 100644 index 0000000..ab8c3b3 --- /dev/null +++ b/src/main/java/com/gcsc/guide/dto/CreateCommentRequest.java @@ -0,0 +1,8 @@ +package com.gcsc.guide.dto; + +import jakarta.validation.constraints.NotBlank; + +public record CreateCommentRequest( + @NotBlank String body +) { +} diff --git a/src/main/java/com/gcsc/guide/dto/CreateIssueRequest.java b/src/main/java/com/gcsc/guide/dto/CreateIssueRequest.java new file mode 100644 index 0000000..1fb7f75 --- /dev/null +++ b/src/main/java/com/gcsc/guide/dto/CreateIssueRequest.java @@ -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 +) { +} diff --git a/src/main/java/com/gcsc/guide/dto/CreateRoleRequest.java b/src/main/java/com/gcsc/guide/dto/CreateRoleRequest.java new file mode 100644 index 0000000..677b356 --- /dev/null +++ b/src/main/java/com/gcsc/guide/dto/CreateRoleRequest.java @@ -0,0 +1,9 @@ +package com.gcsc.guide.dto; + +import jakarta.validation.constraints.NotBlank; + +public record CreateRoleRequest( + @NotBlank String name, + String description +) { +} diff --git a/src/main/java/com/gcsc/guide/dto/IssueCommentResponse.java b/src/main/java/com/gcsc/guide/dto/IssueCommentResponse.java new file mode 100644 index 0000000..ba19f07 --- /dev/null +++ b/src/main/java/com/gcsc/guide/dto/IssueCommentResponse.java @@ -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() + ); + } +} diff --git a/src/main/java/com/gcsc/guide/dto/IssueResponse.java b/src/main/java/com/gcsc/guide/dto/IssueResponse.java new file mode 100644 index 0000000..c875547 --- /dev/null +++ b/src/main/java/com/gcsc/guide/dto/IssueResponse.java @@ -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() + ); + } +} diff --git a/src/main/java/com/gcsc/guide/dto/LoginHistoryResponse.java b/src/main/java/com/gcsc/guide/dto/LoginHistoryResponse.java new file mode 100644 index 0000000..f3cb518 --- /dev/null +++ b/src/main/java/com/gcsc/guide/dto/LoginHistoryResponse.java @@ -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() + ); + } +} diff --git a/src/main/java/com/gcsc/guide/dto/StatsResponse.java b/src/main/java/com/gcsc/guide/dto/StatsResponse.java new file mode 100644 index 0000000..54656a5 --- /dev/null +++ b/src/main/java/com/gcsc/guide/dto/StatsResponse.java @@ -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 +) { +} diff --git a/src/main/java/com/gcsc/guide/dto/TrackPageViewRequest.java b/src/main/java/com/gcsc/guide/dto/TrackPageViewRequest.java new file mode 100644 index 0000000..dc17a9c --- /dev/null +++ b/src/main/java/com/gcsc/guide/dto/TrackPageViewRequest.java @@ -0,0 +1,8 @@ +package com.gcsc.guide.dto; + +import jakarta.validation.constraints.NotBlank; + +public record TrackPageViewRequest( + @NotBlank String pagePath +) { +} diff --git a/src/main/java/com/gcsc/guide/dto/UpdateIssueRequest.java b/src/main/java/com/gcsc/guide/dto/UpdateIssueRequest.java new file mode 100644 index 0000000..c874e6d --- /dev/null +++ b/src/main/java/com/gcsc/guide/dto/UpdateIssueRequest.java @@ -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 +) { +} diff --git a/src/main/java/com/gcsc/guide/dto/UpdateRolesRequest.java b/src/main/java/com/gcsc/guide/dto/UpdateRolesRequest.java new file mode 100644 index 0000000..5c3a44a --- /dev/null +++ b/src/main/java/com/gcsc/guide/dto/UpdateRolesRequest.java @@ -0,0 +1,10 @@ +package com.gcsc.guide.dto; + +import jakarta.validation.constraints.NotNull; + +import java.util.List; + +public record UpdateRolesRequest( + @NotNull List roleIds +) { +} diff --git a/src/main/java/com/gcsc/guide/entity/Issue.java b/src/main/java/com/gcsc/guide/entity/Issue.java new file mode 100644 index 0000000..05e6ce9 --- /dev/null +++ b/src/main/java/com/gcsc/guide/entity/Issue.java @@ -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(); + } +} diff --git a/src/main/java/com/gcsc/guide/entity/IssueComment.java b/src/main/java/com/gcsc/guide/entity/IssueComment.java new file mode 100644 index 0000000..9d2b735 --- /dev/null +++ b/src/main/java/com/gcsc/guide/entity/IssueComment.java @@ -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(); + } +} diff --git a/src/main/java/com/gcsc/guide/entity/LoginHistory.java b/src/main/java/com/gcsc/guide/entity/LoginHistory.java new file mode 100644 index 0000000..434a25f --- /dev/null +++ b/src/main/java/com/gcsc/guide/entity/LoginHistory.java @@ -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(); + } +} diff --git a/src/main/java/com/gcsc/guide/entity/PageView.java b/src/main/java/com/gcsc/guide/entity/PageView.java new file mode 100644 index 0000000..f68e871 --- /dev/null +++ b/src/main/java/com/gcsc/guide/entity/PageView.java @@ -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(); + } +} diff --git a/src/main/java/com/gcsc/guide/exception/BusinessException.java b/src/main/java/com/gcsc/guide/exception/BusinessException.java new file mode 100644 index 0000000..37efb03 --- /dev/null +++ b/src/main/java/com/gcsc/guide/exception/BusinessException.java @@ -0,0 +1,8 @@ +package com.gcsc.guide.exception; + +public class BusinessException extends RuntimeException { + + public BusinessException(String message) { + super(message); + } +} diff --git a/src/main/java/com/gcsc/guide/exception/GlobalExceptionHandler.java b/src/main/java/com/gcsc/guide/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..cec7073 --- /dev/null +++ b/src/main/java/com/gcsc/guide/exception/GlobalExceptionHandler.java @@ -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> handleNotFound(ResourceNotFoundException e) { + return buildResponse(HttpStatus.NOT_FOUND, e.getMessage()); + } + + @ExceptionHandler(BusinessException.class) + public ResponseEntity> handleBusiness(BusinessException e) { + return buildResponse(HttpStatus.BAD_REQUEST, e.getMessage()); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> 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> handleResponseStatus(ResponseStatusException e) { + return buildResponse(HttpStatus.valueOf(e.getStatusCode().value()), e.getReason()); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleUnexpected(Exception e) { + log.error("예상치 못한 오류 발생", e); + return buildResponse(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습니다"); + } + + private ResponseEntity> 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() + )); + } +} diff --git a/src/main/java/com/gcsc/guide/exception/ResourceNotFoundException.java b/src/main/java/com/gcsc/guide/exception/ResourceNotFoundException.java new file mode 100644 index 0000000..9677dee --- /dev/null +++ b/src/main/java/com/gcsc/guide/exception/ResourceNotFoundException.java @@ -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); + } +} diff --git a/src/main/java/com/gcsc/guide/repository/IssueCommentRepository.java b/src/main/java/com/gcsc/guide/repository/IssueCommentRepository.java new file mode 100644 index 0000000..4d2782b --- /dev/null +++ b/src/main/java/com/gcsc/guide/repository/IssueCommentRepository.java @@ -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 { + + @Query("SELECT c FROM IssueComment c JOIN FETCH c.author WHERE c.issue.id = :issueId ORDER BY c.createdAt ASC") + List findByIssueIdWithAuthor(Long issueId); +} diff --git a/src/main/java/com/gcsc/guide/repository/IssueRepository.java b/src/main/java/com/gcsc/guide/repository/IssueRepository.java new file mode 100644 index 0000000..3561f6c --- /dev/null +++ b/src/main/java/com/gcsc/guide/repository/IssueRepository.java @@ -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 { + + Page 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 findByIdWithUsers(Long id); +} diff --git a/src/main/java/com/gcsc/guide/repository/LoginHistoryRepository.java b/src/main/java/com/gcsc/guide/repository/LoginHistoryRepository.java new file mode 100644 index 0000000..ee3a90a --- /dev/null +++ b/src/main/java/com/gcsc/guide/repository/LoginHistoryRepository.java @@ -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 { + + List findByUserIdOrderByLoginAtDesc(Long userId); + + long countByLoginAtAfter(LocalDateTime after); +} diff --git a/src/main/java/com/gcsc/guide/repository/PageViewRepository.java b/src/main/java/com/gcsc/guide/repository/PageViewRepository.java new file mode 100644 index 0000000..5561f61 --- /dev/null +++ b/src/main/java/com/gcsc/guide/repository/PageViewRepository.java @@ -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 { +} diff --git a/src/main/java/com/gcsc/guide/repository/RoleUrlPatternRepository.java b/src/main/java/com/gcsc/guide/repository/RoleUrlPatternRepository.java new file mode 100644 index 0000000..aa8b7c1 --- /dev/null +++ b/src/main/java/com/gcsc/guide/repository/RoleUrlPatternRepository.java @@ -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 { +} diff --git a/src/main/java/com/gcsc/guide/service/ActivityService.java b/src/main/java/com/gcsc/guide/service/ActivityService.java new file mode 100644 index 0000000..0e706af --- /dev/null +++ b/src/main/java/com/gcsc/guide/service/ActivityService.java @@ -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 getLoginHistory(Long userId) { + return loginHistoryRepository.findByUserIdOrderByLoginAtDesc(userId).stream() + .map(LoginHistoryResponse::from) + .toList(); + } +} diff --git a/src/main/java/com/gcsc/guide/service/IssueService.java b/src/main/java/com/gcsc/guide/service/IssueService.java new file mode 100644 index 0000000..487ac49 --- /dev/null +++ b/src/main/java/com/gcsc/guide/service/IssueService.java @@ -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 getIssues(String status, Pageable pageable) { + Page 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 getIssueDetail(Long issueId) { + Issue issue = issueRepository.findByIdWithUsers(issueId) + .orElseThrow(() -> new ResourceNotFoundException("이슈", issueId)); + + List 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)); + } +} diff --git a/src/main/java/com/gcsc/guide/service/RoleService.java b/src/main/java/com/gcsc/guide/service/RoleService.java new file mode 100644 index 0000000..1cfc1db --- /dev/null +++ b/src/main/java/com/gcsc/guide/service/RoleService.java @@ -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 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 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)); + } +} diff --git a/src/main/java/com/gcsc/guide/service/UserService.java b/src/main/java/com/gcsc/guide/service/UserService.java new file mode 100644 index 0000000..c38fdc6 --- /dev/null +++ b/src/main/java/com/gcsc/guide/service/UserService.java @@ -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 getUsers(String status) { + List 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 roleIds) { + User user = findUserById(userId); + Set 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)); + } +}