diff --git a/src/main/java/com/specialwarriors/conal/common/auth/exception/AuthException.java b/src/main/java/com/specialwarriors/conal/common/auth/exception/AuthException.java index e344757..6721d12 100644 --- a/src/main/java/com/specialwarriors/conal/common/auth/exception/AuthException.java +++ b/src/main/java/com/specialwarriors/conal/common/auth/exception/AuthException.java @@ -10,8 +10,7 @@ public enum AuthException implements BaseException { OAUTH_PROVIDER_ERROR(HttpStatus.UNAUTHORIZED, "인증 제공자 오류"), - OAUTH_USER_CANCELLED(HttpStatus.UNAUTHORIZED, "사용자가 인증을 취소함"), - USER_MAPPING_FAILED(HttpStatus.UNAUTHORIZED, "OAuth 사용자 정보를 처리할 수 없음"), + OAUTH_USER_CANCELED(HttpStatus.UNAUTHORIZED, "사용자가 인증을 취소함"), INVALID_REDIRECT_URI(HttpStatus.UNAUTHORIZED, "리다이렉트 URI가 유효하지 않음"), EMPTY_OAUTH_TOKEN(HttpStatus.UNAUTHORIZED, "ACCESS TOKEN이 비어있음"); diff --git a/src/main/java/com/specialwarriors/conal/common/auth/jwt/JwtTokenProvider.java b/src/main/java/com/specialwarriors/conal/common/auth/jwt/JwtTokenProvider.java index 9279138..1daa841 100644 --- a/src/main/java/com/specialwarriors/conal/common/auth/jwt/JwtTokenProvider.java +++ b/src/main/java/com/specialwarriors/conal/common/auth/jwt/JwtTokenProvider.java @@ -17,11 +17,13 @@ public class JwtTokenProvider { private final SecretKey secretKey; public JwtTokenProvider(@Value("${spring.jwt.secret-key}") String secret) { + secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), SIG.HS256.key().build().getAlgorithm()); } public String createVoteUserToken(String email, Date issuedAt, long expirationMillis) { + Date expiration = new Date(issuedAt.getTime() + expirationMillis); return Jwts.builder() diff --git a/src/main/java/com/specialwarriors/conal/common/auth/oauth/CustomOAuth2FailureHandler.java b/src/main/java/com/specialwarriors/conal/common/auth/oauth/CustomOAuth2FailureHandler.java index 2116721..b627799 100644 --- a/src/main/java/com/specialwarriors/conal/common/auth/oauth/CustomOAuth2FailureHandler.java +++ b/src/main/java/com/specialwarriors/conal/common/auth/oauth/CustomOAuth2FailureHandler.java @@ -20,7 +20,7 @@ public void onAuthenticationFailure(HttpServletRequest request, HttpServletRespo if (exception.getMessage().contains("redirect")) { log.warn(new GeneralException(AuthException.INVALID_REDIRECT_URI).getMessage()); } else if (exception.getMessage().contains("user cancelled")) { - log.warn(new GeneralException(AuthException.OAUTH_USER_CANCELLED).getMessage()); + log.warn(new GeneralException(AuthException.OAUTH_USER_CANCELED).getMessage()); } else { log.warn(new GeneralException(AuthException.OAUTH_PROVIDER_ERROR).getMessage()); } diff --git a/src/main/java/com/specialwarriors/conal/common/auth/oauth/CustomOAuth2UserService.java b/src/main/java/com/specialwarriors/conal/common/auth/oauth/CustomOAuth2UserService.java index e14e718..2e5130b 100644 --- a/src/main/java/com/specialwarriors/conal/common/auth/oauth/CustomOAuth2UserService.java +++ b/src/main/java/com/specialwarriors/conal/common/auth/oauth/CustomOAuth2UserService.java @@ -23,8 +23,8 @@ public class CustomOAuth2UserService implements OAuth2UserService delegate = - new DefaultOAuth2UserService(); + + OAuth2UserService delegate = new DefaultOAuth2UserService(); OAuth2User oauth2User = delegate.loadUser(userRequest); // GitHub 사용자 정보 추출 diff --git a/src/main/java/com/specialwarriors/conal/common/auth/session/SessionAuthenticationFilter.java b/src/main/java/com/specialwarriors/conal/common/auth/session/SessionAuthenticationFilter.java index 3558453..5a54bc4 100644 --- a/src/main/java/com/specialwarriors/conal/common/auth/session/SessionAuthenticationFilter.java +++ b/src/main/java/com/specialwarriors/conal/common/auth/session/SessionAuthenticationFilter.java @@ -19,13 +19,10 @@ public class SessionAuthenticationFilter extends OncePerRequestFilter { private final SessionManager sessionManager; @Override - protected void doFilterInternal(HttpServletRequest request, - HttpServletResponse response, - FilterChain filterChain) - throws ServletException, IOException { + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { Long userId = sessionManager.getUserId(request); - if (userId == null) { response.sendRedirect("/login/failure"); return; @@ -36,7 +33,9 @@ protected void doFilterInternal(HttpServletRequest request, @Override protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { + String path = request.getRequestURI(); + return PERMIT_ALL_PATHS.contains(path); } diff --git a/src/main/java/com/specialwarriors/conal/common/auth/session/SessionManager.java b/src/main/java/com/specialwarriors/conal/common/auth/session/SessionManager.java index d97c267..22160b1 100644 --- a/src/main/java/com/specialwarriors/conal/common/auth/session/SessionManager.java +++ b/src/main/java/com/specialwarriors/conal/common/auth/session/SessionManager.java @@ -9,11 +9,13 @@ public class SessionManager { public void createSession(HttpServletRequest request, Long userId) { + HttpSession session = request.getSession(); session.setAttribute("userId", userId); } public Long getUserId(HttpServletRequest request) { + HttpSession session = request.getSession(false); return Optional.ofNullable(session) @@ -23,6 +25,7 @@ public Long getUserId(HttpServletRequest request) { } public void clearSession(HttpServletRequest request) { + HttpSession session = request.getSession(false); if (session != null) { session.invalidate(); diff --git a/src/main/java/com/specialwarriors/conal/common/config/RedisConfig.java b/src/main/java/com/specialwarriors/conal/common/config/RedisConfig.java index 36e20e9..8018e2e 100644 --- a/src/main/java/com/specialwarriors/conal/common/config/RedisConfig.java +++ b/src/main/java/com/specialwarriors/conal/common/config/RedisConfig.java @@ -1,14 +1,14 @@ package com.specialwarriors.conal.common.config; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.ReactiveRedisConnectionFactory; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.data.redis.connection.ReactiveRedisConnectionFactory; import org.springframework.data.redis.core.ReactiveRedisTemplate; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.serializer.RedisSerializationContext; import org.springframework.data.redis.serializer.StringRedisSerializer; @@ -23,16 +23,19 @@ public class RedisConfig { @Bean public RedisConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory(redisHost, redisPort); } @Bean public ReactiveRedisConnectionFactory reactiveRedisConnectionFactory() { + return new LettuceConnectionFactory(redisHost, redisPort); } @Bean public RedisTemplate redisTemplate() { + RedisTemplate redisTemplate = new RedisTemplate<>(); redisTemplate.setConnectionFactory(redisConnectionFactory()); diff --git a/src/main/java/com/specialwarriors/conal/common/config/WebClientConfig.java b/src/main/java/com/specialwarriors/conal/common/config/WebClientConfig.java index ca6d64c..fb80787 100644 --- a/src/main/java/com/specialwarriors/conal/common/config/WebClientConfig.java +++ b/src/main/java/com/specialwarriors/conal/common/config/WebClientConfig.java @@ -23,6 +23,7 @@ public class WebClientConfig { @Bean public WebClient githubWebClient() { + return WebClient.builder() .baseUrl("https://api.github.com") .defaultHeader(HttpHeaders.USER_AGENT, "spring-webclient") @@ -32,6 +33,7 @@ public WebClient githubWebClient() { @Bean public WebClient githubRevokeWebClient() { + String basicAuth = Base64.getEncoder() .encodeToString((clientId + ":" + clientSecret).getBytes(StandardCharsets.UTF_8)); diff --git a/src/main/java/com/specialwarriors/conal/common/exception/GlobalExceptionHandler.java b/src/main/java/com/specialwarriors/conal/common/exception/GlobalExceptionHandler.java index d6dcb5c..05ded74 100644 --- a/src/main/java/com/specialwarriors/conal/common/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/specialwarriors/conal/common/exception/GlobalExceptionHandler.java @@ -10,7 +10,6 @@ public class GlobalExceptionHandler { @ExceptionHandler(GeneralException.class) public ResponseEntity handleGeneralException(GeneralException e) { - return ResponseEntity.status(e.getStatus()) - .body(new ExceptionResponse(e.getMessage())); + return ResponseEntity.status(e.getStatus()).body(new ExceptionResponse(e.getMessage())); } } diff --git a/src/main/java/com/specialwarriors/conal/contribution/scheduler/ContributionScheduler.java b/src/main/java/com/specialwarriors/conal/contribution/scheduler/ContributionScheduler.java index 939a71b..52acb55 100644 --- a/src/main/java/com/specialwarriors/conal/contribution/scheduler/ContributionScheduler.java +++ b/src/main/java/com/specialwarriors/conal/contribution/scheduler/ContributionScheduler.java @@ -25,19 +25,20 @@ public class ContributionScheduler { @Scheduled(cron = "0 0 9 ? * FRI") public void sendContribution() { + List notificationAgreements = notificationAgreementQuery - .findAllByType(NotificationType.CONTRIBUTION); + .findAllByType(NotificationType.CONTRIBUTION); List githubRepoIds = notificationAgreements.stream() - .map(NotificationAgreement::getGithubRepoId) - .toList(); + .map(NotificationAgreement::getGithubRepoId) + .toList(); List githubRepos = githubRepoRepository.findAllById(githubRepoIds); for (GithubRepo githubRepo : githubRepos) { for (Contributor contributor : githubRepo.getContributors()) { mailUtil.sendContributionForm( - contributionService.sendEmail(contributor, githubRepo)); + contributionService.sendEmail(contributor, githubRepo)); } } } diff --git a/src/main/java/com/specialwarriors/conal/contribution/service/ContributionService.java b/src/main/java/com/specialwarriors/conal/contribution/service/ContributionService.java index 4793d53..14dbf4b 100644 --- a/src/main/java/com/specialwarriors/conal/contribution/service/ContributionService.java +++ b/src/main/java/com/specialwarriors/conal/contribution/service/ContributionService.java @@ -11,11 +11,12 @@ public class ContributionService { public ContributionFormResponse sendEmail(Contributor contributor, GithubRepo githubRepo) { + Long userId = githubRepo.getUser().getId(); Long repoId = githubRepo.getId(); return new ContributionFormResponse(userId, repoId, - contributor.getEmail()); + contributor.getEmail()); } diff --git a/src/main/java/com/specialwarriors/conal/github/controller/GitHubController.java b/src/main/java/com/specialwarriors/conal/github/controller/GitHubController.java index 6cfc703..7148031 100644 --- a/src/main/java/com/specialwarriors/conal/github/controller/GitHubController.java +++ b/src/main/java/com/specialwarriors/conal/github/controller/GitHubController.java @@ -21,25 +21,26 @@ public class GitHubController { @GetMapping("/repos/{owner}/{repo}/details") public Mono>> getContributorDetailsFromRedis( - @PathVariable String owner, - @PathVariable String repo + @PathVariable String owner, + @PathVariable String repo ) { + return githubService.getContributorsFromRedis(owner, repo) - .flatMapMany(Flux::fromIterable) - .flatMap(login -> - githubService.getContributorDetailFromRedis(owner, repo, login) - .map(detailMap -> Map.entry(login, detailMap))) - .collectMap(Map.Entry::getKey, Map.Entry::getValue); + .flatMapMany(Flux::fromIterable) + .flatMap(login -> + githubService.getContributorDetailFromRedis(owner, repo, login) + .map(detailMap -> Map.entry(login, detailMap))) + .collectMap(Map.Entry::getKey, Map.Entry::getValue); } @PostMapping("/repos/{owner}/{repo}/update") public Mono> updateAllGithubContributorAndContribution( - @PathVariable String owner, - @PathVariable String repo + @PathVariable String owner, + @PathVariable String repo ) { + return githubService.updateRepoContribution(owner, repo) - .thenReturn(ResponseEntity.ok("전체 랭킹 업데이트 완료")); + .thenReturn(ResponseEntity.ok("전체 랭킹 업데이트 완료")); } - } \ No newline at end of file diff --git a/src/main/java/com/specialwarriors/conal/github/dto/GitHubContributor.java b/src/main/java/com/specialwarriors/conal/github/dto/GitHubContributor.java index a06d177..970c71e 100644 --- a/src/main/java/com/specialwarriors/conal/github/dto/GitHubContributor.java +++ b/src/main/java/com/specialwarriors/conal/github/dto/GitHubContributor.java @@ -1,8 +1,7 @@ package com.specialwarriors.conal.github.dto; public record GitHubContributor( - - String login + String login ) { } diff --git a/src/main/java/com/specialwarriors/conal/github/service/GitHubService.java b/src/main/java/com/specialwarriors/conal/github/service/GitHubService.java index 1f5de99..8a8e9de 100644 --- a/src/main/java/com/specialwarriors/conal/github/service/GitHubService.java +++ b/src/main/java/com/specialwarriors/conal/github/service/GitHubService.java @@ -27,84 +27,88 @@ public class GitHubService { private final WebClient githubWebClient; public Mono updateRepoContribution(String owner, String repo) { + return getContributors(owner, repo) - .flatMapMany(contributors -> { - String contributorKey = buildContributorsKey(owner, repo); - List logins = contributors.stream() - .map(GitHubContributor::login) - .collect(Collectors.toList()); - - return reactiveRedisTemplate.delete(contributorKey) - .thenMany(Flux.fromIterable(logins)) - .flatMap(login -> reactiveRedisTemplate.opsForList() - .rightPush(contributorKey, login)) - .then(reactiveRedisTemplate.expire(contributorKey, TTL)) - .thenMany(Flux.fromIterable(contributors)); - }) - .flatMap(contributor -> updateContributorScore(owner, repo, contributor)) - .then(); + .flatMapMany(contributors -> { + String contributorKey = buildContributorsKey(owner, repo); + List logins = contributors.stream() + .map(GitHubContributor::login) + .collect(Collectors.toList()); + + return reactiveRedisTemplate.delete(contributorKey) + .thenMany(Flux.fromIterable(logins)) + .flatMap(login -> reactiveRedisTemplate.opsForList() + .rightPush(contributorKey, login)) + .then(reactiveRedisTemplate.expire(contributorKey, TTL)) + .thenMany(Flux.fromIterable(contributors)); + }) + .flatMap(contributor -> updateContributorScore(owner, repo, contributor)) + .then(); } private Mono> getContributors(String owner, String repo) { + return githubWebClient.get() - .uri("/repos/{owner}/{repo}/contributors", owner, repo) - .retrieve() - .bodyToFlux(GitHubContributor.class) - .collectList(); + .uri("/repos/{owner}/{repo}/contributors", owner, repo) + .retrieve() + .bodyToFlux(GitHubContributor.class) + .collectList(); } private Mono updateContributorScore(String owner, String repo, - GitHubContributor contributor) { + GitHubContributor contributor) { + String login = contributor.login(); return Mono.zip( - getCommitCount(owner, repo, login), - getPullRequestCount(owner, repo, login), - getMergedPullRequestCount(owner, repo, login), - getIssueCount(owner, repo, login) + getCommitCount(owner, repo, login), + getPullRequestCount(owner, repo, login), + getMergedPullRequestCount(owner, repo, login), + getIssueCount(owner, repo, login) ).flatMap(tuple -> { long commit = tuple.getT1(); long pr = tuple.getT2(); long mpr = tuple.getT3(); long issue = tuple.getT4(); - double score = commit * 0.1 + pr * 0.5 + mpr + issue * 0.2; + long score = commit + pr + mpr + issue; String detailKey = buildDetailKey(owner, repo, login); return reactiveRedisTemplate.opsForHash().putAll(detailKey, Map.of( - "commit", String.valueOf(commit), - "pr", String.valueOf(pr), - "mpr", String.valueOf(mpr), - "issue", String.valueOf(issue), - "score", String.valueOf(score) - )) - .then(reactiveRedisTemplate.expire(detailKey, TTL)); + "commit", String.valueOf(commit), + "pr", String.valueOf(pr), + "mpr", String.valueOf(mpr), + "issue", String.valueOf(issue), + "score", String.valueOf(score) + )) + .then(reactiveRedisTemplate.expire(detailKey, TTL)); }).then(); } private Mono getCommitCount(String owner, String repo, String login) { + return getAllCommits(owner, repo, 1) - .filter(commit -> { - Map author = (Map) commit.get("author"); + .filter(commit -> { + Map author = (Map) commit.get("author"); - return author != null && login.equalsIgnoreCase((String) author.get("login")); - }) - .count(); + return author != null && login.equalsIgnoreCase((String) author.get("login")); + }) + .count(); } private Flux getAllCommits(String owner, String repo, int page) { return githubWebClient.get() - .uri(uriBuilder -> uriBuilder - .path("/repos/{owner}/{repo}/commits") - .queryParam("per_page", PER_PAGE) - .queryParam("page", page) - .build(owner, repo)) - .retrieve() - .bodyToFlux(Map.class) - .collectList() - .flatMapMany(list -> list.size() < PER_PAGE ? Flux.fromIterable(list) - : Flux.fromIterable(list).concatWith(getAllCommits(owner, repo, page + 1))); + .uri(uriBuilder -> uriBuilder + .path("/repos/{owner}/{repo}/commits") + .queryParam("per_page", PER_PAGE) + .queryParam("page", page) + .build(owner, repo)) + .retrieve() + .bodyToFlux(Map.class) + .collectList() + .flatMapMany(list -> list.size() < PER_PAGE ? Flux.fromIterable(list) + : Flux.fromIterable(list).concatWith(getAllCommits(owner, repo, page + 1))); } private Mono getPullRequestCount(String owner, String repo, String login) { @@ -123,14 +127,14 @@ private Mono getIssueCount(String owner, String repo, String login) { } private Mono queryGithubIssueCount(String owner, String repo, String login, - String queryType) { + String queryType) { String query = String.format("repo:%s/%s+%s+author:%s", owner, repo, queryType, login); return githubWebClient.get() - .uri(uriBuilder -> uriBuilder.path("/search/issues").queryParam("q", query).build()) - .retrieve() - .bodyToMono(Map.class) - .map(map -> ((Number) map.get("total_count")).longValue()); + .uri(uriBuilder -> uriBuilder.path("/search/issues").queryParam("q", query).build()) + .retrieve() + .bodyToMono(Map.class) + .map(map -> ((Number) map.get("total_count")).longValue()); } private String buildDetailKey(String owner, String repo, String login) { @@ -150,23 +154,23 @@ public Mono> getContributorsFromRedis(String owner, String repo) { String key = "contributors:" + owner + ":" + repo; return reactiveRedisTemplate.opsForList() - .range(key, 0, -1) - .collectList(); + .range(key, 0, -1) + .collectList(); } /** * 특정 contributor에 대한 상세 점수 정보 가져오기 */ public Mono> getContributorDetailFromRedis(String owner, String repo, - String contributor) { + String contributor) { String detailKey = "detail:" + owner + ":" + repo + ":" + contributor; return reactiveRedisTemplate.opsForHash() - .entries(detailKey) - .collectMap( - e -> e.getKey().toString(), - e -> e.getValue().toString() - ); + .entries(detailKey) + .collectMap( + e -> e.getKey().toString(), + e -> e.getValue().toString() + ); } } diff --git a/src/main/java/com/specialwarriors/conal/github_repo/controller/GithubRepoController.java b/src/main/java/com/specialwarriors/conal/github_repo/controller/GithubRepoController.java index e10dfc5..dd53e5b 100644 --- a/src/main/java/com/specialwarriors/conal/github_repo/controller/GithubRepoController.java +++ b/src/main/java/com/specialwarriors/conal/github_repo/controller/GithubRepoController.java @@ -28,6 +28,7 @@ public class GithubRepoController { @GetMapping("/new") public String showCreateForm(@SessionAttribute Long userId, Model model) { + model.addAttribute("repoRequest", new GithubRepoCreateRequest("", "", null, Set.of())); model.addAttribute("userId", userId); @@ -38,7 +39,8 @@ public String showCreateForm(@SessionAttribute Long userId, Model model) { // 저장 (POST) @PostMapping public String createGitHubRepo(@SessionAttribute Long userId, - @ModelAttribute GithubRepoCreateRequest request) { + @ModelAttribute GithubRepoCreateRequest request) { + GithubRepoCreateResponse response = githubRepoService.createGithubRepo(userId, request); gitHubService.updateRepoContribution(response.owner(), response.repo()).subscribe(); @@ -48,8 +50,7 @@ public String createGitHubRepo(@SessionAttribute Long userId, // 목록 조회 (GET) @GetMapping public String getGithubRepos(@SessionAttribute Long userId, - @RequestParam(defaultValue = "0") int page, - Model model) { + @RequestParam(defaultValue = "0") int page, Model model) { GithubRepoPageResponse response = githubRepoService.getGithubRepoInfos(userId, page); model.addAttribute("repositories", response); @@ -61,8 +62,8 @@ public String getGithubRepos(@SessionAttribute Long userId, // 단일 조회 (GET) @GetMapping("/{repositoryId}") public String getRepositoryId(@SessionAttribute Long userId, - @PathVariable long repositoryId, - Model model) { + @PathVariable long repositoryId, Model model) { + GithubRepoGetResponse response = githubRepoService.getGithubRepoInfo(userId, repositoryId); model.addAttribute("repoInfo", response); @@ -71,7 +72,8 @@ public String getRepositoryId(@SessionAttribute Long userId, @PostMapping("/{repositoryId}") public String deleteRepository(@SessionAttribute Long userId, - @PathVariable long repositoryId) { + @PathVariable long repositoryId) { + githubRepoService.deleteRepo(userId, repositoryId); return "redirect:/home"; diff --git a/src/main/java/com/specialwarriors/conal/github_repo/dto/GithubRepoMapper.java b/src/main/java/com/specialwarriors/conal/github_repo/dto/GithubRepoMapper.java index a819ee1..07263d1 100644 --- a/src/main/java/com/specialwarriors/conal/github_repo/dto/GithubRepoMapper.java +++ b/src/main/java/com/specialwarriors/conal/github_repo/dto/GithubRepoMapper.java @@ -15,26 +15,25 @@ public interface GithubRepoMapper { GithubRepo toGithubRepo(GithubRepoCreateRequest request); - default GithubRepoCreateResponse toGithubRepoCreateResponse(String owner, - String reponame) { + default GithubRepoCreateResponse toGithubRepoCreateResponse(String owner, String reponame) { return new GithubRepoCreateResponse( - owner, - reponame + owner, + reponame ); } default GithubRepoGetResponse toGithubRepoGetResponse(GithubRepo repo, String owner, - String reponame, Long userId) { + String reponame, Long userId) { return new GithubRepoGetResponse( - userId, - repo.getId(), - repo.getName(), - repo.getUrl(), - repo.getEndDate(), - owner, - reponame + userId, + repo.getId(), + repo.getName(), + repo.getUrl(), + repo.getEndDate(), + owner, + reponame ); } @@ -44,14 +43,14 @@ default GithubRepoGetResponse toGithubRepoGetResponse(GithubRepo repo, String ow default GithubRepoPageResponse toGithubRepoPageResponse(Page page, Long userId) { return new GithubRepoPageResponse( - page.getContent().stream() - .map(this::toGithubRepoSummary) - .toList(), - userId, - page.getNumber(), - page.getTotalPages(), - page.getTotalElements() + page.getContent().stream() + .map(this::toGithubRepoSummary) + .toList(), + userId, + page.getNumber(), + page.getTotalPages(), + page.getTotalElements() ); } - + } diff --git a/src/main/java/com/specialwarriors/conal/github_repo/repository/GithubRepoRepository.java b/src/main/java/com/specialwarriors/conal/github_repo/repository/GithubRepoRepository.java index c156f83..427c499 100644 --- a/src/main/java/com/specialwarriors/conal/github_repo/repository/GithubRepoRepository.java +++ b/src/main/java/com/specialwarriors/conal/github_repo/repository/GithubRepoRepository.java @@ -1,6 +1,5 @@ package com.specialwarriors.conal.github_repo.repository; - import com.specialwarriors.conal.github_repo.domain.GithubRepo; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; diff --git a/src/main/java/com/specialwarriors/conal/github_repo/repository/GithubRepoRepositoryImpl.java b/src/main/java/com/specialwarriors/conal/github_repo/repository/GithubRepoRepositoryImpl.java index 8e4ae93..2c85d02 100644 --- a/src/main/java/com/specialwarriors/conal/github_repo/repository/GithubRepoRepositoryImpl.java +++ b/src/main/java/com/specialwarriors/conal/github_repo/repository/GithubRepoRepositoryImpl.java @@ -11,7 +11,6 @@ import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; - @Repository @RequiredArgsConstructor public class GithubRepoRepositoryImpl implements GithubRepoRepositoryCustom { @@ -20,18 +19,19 @@ public class GithubRepoRepositoryImpl implements GithubRepoRepositoryCustom { @Override public Page findGithubRepoPages(Long userId, Pageable pageable) { + List content = queryFactory - .selectFrom(githubRepo) - .where(githubRepo.user.id.eq(userId)) - .offset(pageable.getOffset()) - .limit(pageable.getPageSize()) - .fetch(); + .selectFrom(githubRepo) + .where(githubRepo.user.id.eq(userId)) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); Long total = queryFactory - .select(githubRepo.count()) - .from(githubRepo) - .where(githubRepo.user.id.eq(userId)) - .fetchOne(); + .select(githubRepo.count()) + .from(githubRepo) + .where(githubRepo.user.id.eq(userId)) + .fetchOne(); return new PageImpl<>(content, pageable, total); } diff --git a/src/main/java/com/specialwarriors/conal/github_repo/service/GithubRepoQuery.java b/src/main/java/com/specialwarriors/conal/github_repo/service/GithubRepoQuery.java index ed8e93d..ce65429 100644 --- a/src/main/java/com/specialwarriors/conal/github_repo/service/GithubRepoQuery.java +++ b/src/main/java/com/specialwarriors/conal/github_repo/service/GithubRepoQuery.java @@ -16,7 +16,7 @@ public class GithubRepoQuery { public GithubRepo findByUserIdAndRepositoryId(Long userId, Long repositoryId) { GithubRepo githubRepo = githubRepoRepository.findById(repositoryId).orElseThrow(() -> - new GeneralException(GithubRepoException.NOT_FOUND_GITHUBREPO) + new GeneralException(GithubRepoException.NOT_FOUND_GITHUBREPO) ); if (!userId.equals(githubRepo.getUser().getId())) { @@ -29,6 +29,6 @@ public GithubRepo findByUserIdAndRepositoryId(Long userId, Long repositoryId) { public GithubRepo findByRepositoryId(long repositoryId) { return githubRepoRepository.findById(repositoryId) - .orElseThrow(() -> new GeneralException(GithubRepoException.NOT_FOUND_GITHUBREPO)); + .orElseThrow(() -> new GeneralException(GithubRepoException.NOT_FOUND_GITHUBREPO)); } } diff --git a/src/main/java/com/specialwarriors/conal/github_repo/service/GithubRepoService.java b/src/main/java/com/specialwarriors/conal/github_repo/service/GithubRepoService.java index 98bf3eb..88c81e6 100644 --- a/src/main/java/com/specialwarriors/conal/github_repo/service/GithubRepoService.java +++ b/src/main/java/com/specialwarriors/conal/github_repo/service/GithubRepoService.java @@ -11,12 +11,12 @@ import com.specialwarriors.conal.github_repo.dto.response.GithubRepoPageResponse; import com.specialwarriors.conal.github_repo.exception.GithubRepoException; import com.specialwarriors.conal.github_repo.repository.GithubRepoRepository; +import com.specialwarriors.conal.github_repo.util.UrlUtil; import com.specialwarriors.conal.notification.domain.NotificationAgreement; import com.specialwarriors.conal.notification.enums.NotificationType; import com.specialwarriors.conal.notification.repository.NotificationAgreementRepository; import com.specialwarriors.conal.user.domain.User; import com.specialwarriors.conal.user.service.UserQuery; -import com.specialwarriors.conal.util.UrlUtil; import java.util.List; import java.util.Set; import java.util.regex.Pattern; @@ -34,13 +34,13 @@ public class GithubRepoService { private static final int PAGE_SIZE = 7; private static final Pattern EMAIL_PATTERN = Pattern.compile( - "^[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,6}$", - Pattern.CASE_INSENSITIVE + "^[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,6}$", + Pattern.CASE_INSENSITIVE ); private static final Pattern GITHUB_URL_PATTERN = Pattern.compile( - "^(https://)?(www\\.)?github\\.com/[^/\\s]+/[^/\\s]+/?$", - Pattern.CASE_INSENSITIVE + "^(https://)?(www\\.)?github\\.com/[^/\\s]+/[^/\\s]+/?$", + Pattern.CASE_INSENSITIVE ); private final GithubRepoRepository githubRepoRepository; @@ -53,6 +53,7 @@ public class GithubRepoService { @Transactional public GithubRepoCreateResponse createGithubRepo(Long userId, GithubRepoCreateRequest request) { + validateCreateRequest(request); User user = userQuery.findById(userId); @@ -96,7 +97,7 @@ private void validateCreateRequest(GithubRepoCreateRequest request) { private List createAndSaveContributors(Set emails) { List contributors = emails.stream() - .map(Contributor::new).toList(); + .map(Contributor::new).toList(); return (List) contributorRepository.saveAll(contributors); } @@ -104,33 +105,36 @@ private List createAndSaveContributors(Set emails) { private List createAndAttachNotifications() { return notificationAgreementRepository.saveAll( - List.of( - new NotificationAgreement(NotificationType.VOTE), - new NotificationAgreement(NotificationType.CONTRIBUTION) - ) + List.of( + new NotificationAgreement(NotificationType.VOTE), + new NotificationAgreement(NotificationType.CONTRIBUTION) + ) ); } @Transactional(readOnly = true) public GithubRepoGetResponse getGithubRepoInfo(Long userId, Long repoId) { + GithubRepo githubRepo = githubRepoQuery.findByUserIdAndRepositoryId(userId, repoId); String[] ownerAndRepo = UrlUtil.urlToOwnerAndReponame(githubRepo.getUrl()); return githubRepoMapper.toGithubRepoGetResponse(githubRepo, ownerAndRepo[0], - ownerAndRepo[1], userId); + ownerAndRepo[1], userId); } @Transactional(readOnly = true) public GithubRepoPageResponse getGithubRepoInfos(Long userId, int page) { + Pageable pageable = PageRequest.of(page, PAGE_SIZE); Page resultPage = githubRepoRepository.findGithubRepoPages(userId, - pageable); + pageable); return githubRepoMapper.toGithubRepoPageResponse(resultPage, userId); } @Transactional public void deleteRepo(Long userId, Long repositoryId) { + GithubRepo repo = githubRepoQuery.findByUserIdAndRepositoryId(userId, repositoryId); contributorRepository.deleteAllByGithubRepo(repo); notificationAgreementRepository.deleteByGithubRepoId(repo.getId()); diff --git a/src/main/java/com/specialwarriors/conal/notification/controller/NotificationRestController.java b/src/main/java/com/specialwarriors/conal/notification/controller/NotificationRestController.java index 39da918..e6317c2 100644 --- a/src/main/java/com/specialwarriors/conal/notification/controller/NotificationRestController.java +++ b/src/main/java/com/specialwarriors/conal/notification/controller/NotificationRestController.java @@ -17,8 +17,8 @@ public class NotificationRestController { @PostMapping("/users/{userId}/repositories/{repositoryId}/notifications") public ResponseEntity updateNotificationAgreement(@PathVariable long userId, - @PathVariable long repositoryId, - @RequestBody NotificationAgreementUpdateRequest request) { + @PathVariable long repositoryId, + @RequestBody NotificationAgreementUpdateRequest request) { notificationService.updateNotificationAgreement(userId, repositoryId, request); diff --git a/src/main/java/com/specialwarriors/conal/notification/repository/NotificationAgreementRepository.java b/src/main/java/com/specialwarriors/conal/notification/repository/NotificationAgreementRepository.java index 9daccd4..bb02be9 100644 --- a/src/main/java/com/specialwarriors/conal/notification/repository/NotificationAgreementRepository.java +++ b/src/main/java/com/specialwarriors/conal/notification/repository/NotificationAgreementRepository.java @@ -8,10 +8,10 @@ @Repository public interface NotificationAgreementRepository extends - JpaRepository { + JpaRepository { List findAllByGithubRepoIdAndNotificationType(Long githubRepoId, - NotificationType notificationType); + NotificationType notificationType); List findAllByNotificationType(NotificationType notificationType); diff --git a/src/main/java/com/specialwarriors/conal/notification/service/NotificationAgreementQuery.java b/src/main/java/com/specialwarriors/conal/notification/service/NotificationAgreementQuery.java index 399dba0..276f640 100644 --- a/src/main/java/com/specialwarriors/conal/notification/service/NotificationAgreementQuery.java +++ b/src/main/java/com/specialwarriors/conal/notification/service/NotificationAgreementQuery.java @@ -2,7 +2,6 @@ import com.specialwarriors.conal.common.exception.GeneralException; import com.specialwarriors.conal.github_repo.domain.GithubRepo; -import com.specialwarriors.conal.github_repo.service.GithubRepoQuery; import com.specialwarriors.conal.notification.domain.NotificationAgreement; import com.specialwarriors.conal.notification.enums.NotificationType; import com.specialwarriors.conal.notification.exception.NotificationAgreementException; @@ -15,18 +14,17 @@ @RequiredArgsConstructor public class NotificationAgreementQuery { - private final GithubRepoQuery githubRepoQuery; private final NotificationAgreementRepository notificationAgreementRepository; public NotificationAgreement findByGithubRepoAndType(GithubRepo githubRepo, - NotificationType type) { + NotificationType type) { return notificationAgreementRepository - .findAllByGithubRepoIdAndNotificationType(githubRepo.getId(), type) - .stream() - .findFirst() - .orElseThrow(() -> new GeneralException( - NotificationAgreementException.NOTIFICATION_AGREEMENT_NOT_FOUND)); + .findAllByGithubRepoIdAndNotificationType(githubRepo.getId(), type) + .stream() + .findFirst() + .orElseThrow(() -> new GeneralException( + NotificationAgreementException.NOTIFICATION_AGREEMENT_NOT_FOUND)); } public List findAllByType(NotificationType type) { diff --git a/src/main/java/com/specialwarriors/conal/notification/service/NotificationService.java b/src/main/java/com/specialwarriors/conal/notification/service/NotificationService.java index 1e52198..06bae4f 100644 --- a/src/main/java/com/specialwarriors/conal/notification/service/NotificationService.java +++ b/src/main/java/com/specialwarriors/conal/notification/service/NotificationService.java @@ -23,13 +23,13 @@ public class NotificationService { @Transactional public void updateNotificationAgreement(long userId, long repositoryId, - NotificationAgreementUpdateRequest request) { + NotificationAgreementUpdateRequest request) { GithubRepo githubRepo = githubRepoQuery.findByRepositoryId(repositoryId); NotificationType notificationType = NotificationType.valueOf(request.type()); NotificationAgreement notificationAgreement = notificationAgreementQuery - .findByGithubRepoAndType(githubRepo, notificationType); + .findByGithubRepoAndType(githubRepo, notificationType); // 사용자가 자신의 github repo에 접근한 것이 맞는 지 검증 User user = userQuery.findById(userId); diff --git a/src/main/java/com/specialwarriors/conal/user/service/UserService.java b/src/main/java/com/specialwarriors/conal/user/service/UserService.java index a4452f3..6298bf0 100644 --- a/src/main/java/com/specialwarriors/conal/user/service/UserService.java +++ b/src/main/java/com/specialwarriors/conal/user/service/UserService.java @@ -2,18 +2,25 @@ import com.specialwarriors.conal.common.auth.oauth.GithubOAuth2WebClient; import com.specialwarriors.conal.common.exception.GeneralException; +import com.specialwarriors.conal.github_repo.domain.GithubRepo; +import com.specialwarriors.conal.github_repo.repository.GithubRepoRepository; +import com.specialwarriors.conal.notification.repository.NotificationAgreementRepository; import com.specialwarriors.conal.user.domain.User; import com.specialwarriors.conal.user.exception.UserException; import com.specialwarriors.conal.user.repository.UserRepository; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor public class UserService { private final UserRepository userRepository; + private final GithubRepoRepository githubRepoRepository; + private final NotificationAgreementRepository notificationAgreementRepository; private final RedisTemplate redisTemplate; private final GithubOAuth2WebClient githubOAuth2WebClient; @@ -23,11 +30,19 @@ public User getUserByUserId(Long userId) { .orElseThrow(() -> new GeneralException(UserException.USER_NOT_FOUND)); } + @Transactional public void deleteUser(Long userId) { User user = userRepository.findById(userId) .orElseThrow(() -> new GeneralException(UserException.USER_NOT_FOUND)); + List repos = user.getGithubRepos(); + for (GithubRepo repo : repos) { + notificationAgreementRepository.deleteByGithubRepoId(repo.getId()); + } + + githubRepoRepository.deleteAll(repos); + String oauthAccessToken = redisTemplate.opsForValue() .get("github:token:" + user.getGithubId()); diff --git a/src/main/java/com/specialwarriors/conal/util/MailUtil.java b/src/main/java/com/specialwarriors/conal/util/MailUtil.java index f335c3f..f82aa1f 100644 --- a/src/main/java/com/specialwarriors/conal/util/MailUtil.java +++ b/src/main/java/com/specialwarriors/conal/util/MailUtil.java @@ -5,6 +5,8 @@ import com.specialwarriors.conal.vote.dto.response.VoteResultResponse; import jakarta.mail.MessagingException; import jakarta.mail.internet.MimeMessage; +import java.util.HashMap; +import java.util.Map; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.mail.MailException; @@ -14,9 +16,6 @@ import org.thymeleaf.context.Context; import org.thymeleaf.spring6.SpringTemplateEngine; -import java.util.HashMap; -import java.util.Map; - @Component @RequiredArgsConstructor public class MailUtil { @@ -31,6 +30,7 @@ public class MailUtil { private final JavaMailSender mailSender; public void sendVoteForm(VoteFormResponse response) { + Map templateModel = new HashMap<>(); templateModel.put("repoId", response.repoId()); templateModel.put("userToken", response.userToken()); @@ -47,6 +47,7 @@ public void sendVoteForm(VoteFormResponse response) { } public void sendVoteResult(String to, VoteResultResponse response) { + Map templateModel = new HashMap<>(); templateModel.put("response", response); @@ -59,6 +60,7 @@ public void sendVoteResult(String to, VoteResultResponse response) { private void sendHtmlMail(String to, String subject, String templateName, Map templateModel) { + Context thymeleafContext = new Context(); thymeleafContext.setVariables(templateModel); @@ -79,6 +81,7 @@ private void sendHtmlMail(String to, String subject, String templateName, } public void sendContributionForm(ContributionFormResponse response) { + HashMap templateModel = new HashMap<>(); templateModel.put("email", response.email()); diff --git a/src/main/java/com/specialwarriors/conal/util/UrlUtil.java b/src/main/java/com/specialwarriors/conal/util/UrlUtil.java index 06c0fb6..b851c76 100644 --- a/src/main/java/com/specialwarriors/conal/util/UrlUtil.java +++ b/src/main/java/com/specialwarriors/conal/util/UrlUtil.java @@ -1,4 +1,4 @@ -package com.specialwarriors.conal.util; +package com.specialwarriors.conal.github_repo.util; import com.specialwarriors.conal.common.exception.GeneralException; import com.specialwarriors.conal.github_repo.exception.GithubRepoException; @@ -8,9 +8,10 @@ public class UrlUtil { private static final Pattern GITHUB_URL_PATTERN = - Pattern.compile("^https://github\\.com/([^/]+)/([^/]+)$"); + Pattern.compile("^https://github\\.com/([^/]+)/([^/]+)$"); public static String[] urlToOwnerAndReponame(String url) { + Matcher matcher = GITHUB_URL_PATTERN.matcher(url); if (matcher.find()) { String owner = matcher.group(1); diff --git a/src/main/java/com/specialwarriors/conal/vote/controller/VoteController.java b/src/main/java/com/specialwarriors/conal/vote/controller/VoteController.java index 65fa333..d450cfd 100644 --- a/src/main/java/com/specialwarriors/conal/vote/controller/VoteController.java +++ b/src/main/java/com/specialwarriors/conal/vote/controller/VoteController.java @@ -23,8 +23,7 @@ public class VoteController { @GetMapping("/repositories/{repoId}/vote-form") public String getVoteForm(@PathVariable long repoId, - @RequestParam String userToken, - Model model) { + @RequestParam String userToken, Model model) { model.addAttribute("repositoryId", repoId); model.addAttribute("userToken", userToken); @@ -38,6 +37,7 @@ public String getVoteForm(@PathVariable long repoId, @PostMapping("/repositories/{repoId}/votes") public String submitVote(@PathVariable long repoId, @ModelAttribute @Valid VoteSubmitRequest request) { + boolean result = voteService.saveVoteRequest(repoId, request); if (!result) { diff --git a/src/main/java/com/specialwarriors/conal/vote/exception/VoteException.java b/src/main/java/com/specialwarriors/conal/vote/exception/VoteException.java index 8c09973..e75b182 100644 --- a/src/main/java/com/specialwarriors/conal/vote/exception/VoteException.java +++ b/src/main/java/com/specialwarriors/conal/vote/exception/VoteException.java @@ -9,8 +9,8 @@ @RequiredArgsConstructor public enum VoteException implements BaseException { - VOTE_NOT_FOUND(HttpStatus.BAD_REQUEST, "존재하지 않는 투표입니다."), - UNAUTHORIZED_VOTE_ACCESS(HttpStatus.BAD_REQUEST, "투표 접근 권한이 없습니다"), + VOTE_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 투표입니다."), + UNAUTHORIZED_VOTE_ACCESS(HttpStatus.UNAUTHORIZED, "투표 접근 권한이 없습니다"), ALREADY_VOTED(HttpStatus.BAD_REQUEST, "이미 투표했습니다."); private final HttpStatus status; diff --git a/src/main/java/com/specialwarriors/conal/vote/scheduler/VoteScheduler.java b/src/main/java/com/specialwarriors/conal/vote/scheduler/VoteScheduler.java index d417150..2110314 100644 --- a/src/main/java/com/specialwarriors/conal/vote/scheduler/VoteScheduler.java +++ b/src/main/java/com/specialwarriors/conal/vote/scheduler/VoteScheduler.java @@ -21,8 +21,9 @@ public class VoteScheduler { @Scheduled(cron = "0 0 9 ? * FRI") public void openWeeklyVote() { + List notificationAgreements = notificationAgreementQuery - .findAllByType(NotificationType.VOTE); + .findAllByType(NotificationType.VOTE); List repositoryIds = extractGithubRepoIdsFrom(notificationAgreements); @@ -31,8 +32,9 @@ public void openWeeklyVote() { @Scheduled(cron = "0 0 18 ? * FRI") public void sendWeeklyVoteForm() { + List notificationAgreements = notificationAgreementQuery - .findAllByType(NotificationType.VOTE); + .findAllByType(NotificationType.VOTE); List repositoryIds = extractGithubRepoIdsFrom(notificationAgreements); @@ -43,13 +45,14 @@ public void sendWeeklyVoteForm() { @Scheduled(cron = "0 0 9 ? * MON") public void sendWeeklyVoteResult() { + List notificationAgreements = notificationAgreementQuery - .findAllByType(NotificationType.VOTE); + .findAllByType(NotificationType.VOTE); List repositoryIds = extractGithubRepoIdsFrom(notificationAgreements); List voteResults = repositoryIds.stream() - .map(voteService::getVoteResult) - .toList(); + .map(voteService::getVoteResult) + .toList(); for (VoteResultResponse voteResult : voteResults) { List emails = voteResult.emails(); @@ -59,11 +62,11 @@ public void sendWeeklyVoteResult() { private List extractGithubRepoIdsFrom( - List notificationAgreements) { + List notificationAgreements) { return notificationAgreements.stream() - .map(NotificationAgreement::getGithubRepoId) - .distinct() - .toList(); + .map(NotificationAgreement::getGithubRepoId) + .distinct() + .toList(); } } diff --git a/src/main/java/com/specialwarriors/conal/vote/service/VoteService.java b/src/main/java/com/specialwarriors/conal/vote/service/VoteService.java index 6f1d0b0..9e09e25 100644 --- a/src/main/java/com/specialwarriors/conal/vote/service/VoteService.java +++ b/src/main/java/com/specialwarriors/conal/vote/service/VoteService.java @@ -32,6 +32,7 @@ public class VoteService { private final JwtTokenProvider jwtProvider; public void openVote(long repoId) { + String voteKey = VOTE_OPEN_KEY_FORMAT.formatted(repoId); // 투표 참여자를 고유하게 식별한 토큰 생성 @@ -51,6 +52,7 @@ public void openVote(long repoId) { } public List findVoteTargetEmails(long repoId, String userToken) { + String voteKey = VOTE_OPEN_KEY_FORMAT.formatted(repoId); if (!redisTemplate.hasKey(voteKey)) { @@ -70,6 +72,7 @@ public List findVoteTargetEmails(long repoId, String userToken) { } public List getVoteFormResponse(long repoId) { + String voteKey = VOTE_OPEN_KEY_FORMAT.formatted(repoId); // 존재하는 투표인지 검증 @@ -89,6 +92,7 @@ public List getVoteFormResponse(long repoId) { } public boolean saveVoteRequest(long repoId, VoteSubmitRequest request) { + String voteKey = VOTE_OPEN_KEY_FORMAT.formatted(repoId); // 존재하는 투표인지 검증 @@ -118,6 +122,7 @@ public boolean saveVoteRequest(long repoId, VoteSubmitRequest request) { } public void saveVoteResult(long repoId, VoteSubmitRequest request) { + final String votedEmail = request.votedEmail(); final String voteResFormat = "vote:res:%s"; String key = voteResFormat.formatted(repoId); @@ -127,6 +132,7 @@ public void saveVoteResult(long repoId, VoteSubmitRequest request) { } public VoteResultResponse getVoteResult(long repoId) { + final String voteResFormat = "vote:res:%s"; String key = voteResFormat.formatted(repoId); diff --git a/src/main/resources/templates/repo/detail.html b/src/main/resources/templates/repo/detail.html index 5f988b4..3fec81d 100644 --- a/src/main/resources/templates/repo/detail.html +++ b/src/main/resources/templates/repo/detail.html @@ -406,7 +406,7 @@

기여자 기여도

fetch(`/api/github/repos/${owner}/${repo}/update`, {method: 'POST'}) .then(res => { if (!res.ok) { - throw new Error("업데이트 실패"); + throw new Error("일시적으로 요청이 많습니다. 잠시 후에 다시 시도해주세요!"); } return res.text(); }) @@ -414,7 +414,7 @@

기여자 기여도

setTimeout(fetchDetails, 2000); alert("업데이트 완료"); }) - .catch(err => alert("업데이트 오류: " + err.message)) + .catch(err => alert(err.message)) .finally(() => { setTimeout(() => { btn.disabled = false; diff --git a/src/main/resources/templates/user/mypage.html b/src/main/resources/templates/user/mypage.html index d7f53b4..29e0b81 100644 --- a/src/main/resources/templates/user/mypage.html +++ b/src/main/resources/templates/user/mypage.html @@ -94,7 +94,8 @@ -
+ +