diff --git a/.github/workflows/aws-cicd-dev.yml b/.github/workflows/aws-cicd-dev.yml index 83033cf9..9fdcb789 100644 --- a/.github/workflows/aws-cicd-dev.yml +++ b/.github/workflows/aws-cicd-dev.yml @@ -4,6 +4,7 @@ on: push: branches: - develop + - feat/LA-20 env: REGISTRY: "docker.io" @@ -56,6 +57,7 @@ jobs: run: | echo "${APPLICATION_SECRET_PROPERTIES}" > ./layer-api/src/main/resources/application-secret.properties echo "${APPLICATION_SECRET_PROPERTIES}" > ./layer-batch/src/main/resources/application-secret.properties + echo "${APPLICATION_SECRET_PROPERTIES}" > ./layer-admin/src/main/resources/application-secret.properties - name: Build layer-api module run: ./gradlew :layer-api:build @@ -69,6 +71,12 @@ jobs: - name: Test layer-batch module run: ./gradlew :layer-batch:test + - name: Build layer-admin module + run: ./gradlew :layer-admin:build + + - name: Test layer-admin module + run: ./gradlew :layer-admin:test + - name: Docker Hub Login uses: docker/login-action@v1 with: @@ -82,6 +90,7 @@ jobs: images: | ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/${{ env.IMAGE_NAME }}_layer-api ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/${{ env.IMAGE_NAME }}_layer-batch + ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/${{ env.IMAGE_NAME }}_layer-admin - name: Push layer-api Docker Image uses: docker/build-push-action@v4 @@ -104,6 +113,16 @@ jobs: tags: | ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/${{ env.IMAGE_NAME }}_layer-batch:latest + - name: Push layer-admin Docker Image + uses: docker/build-push-action@v4 + with: + context: ./layer-admin + file: ./layer-admin/Dockerfile-admin # Dockerfile 이름 지정 + platforms: linux/amd64 + push: true + tags: | + ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/${{ env.IMAGE_NAME }}_layer-admin:latest + deploy: name: Deploy needs: [ build, setup ] @@ -119,6 +138,7 @@ jobs: run: | echo "${{ secrets.APPLICATION_SECRET_PROPERTIES }}" > ./layer-api/infra/${{ env.DEPLOY_TARGET }}/application-secret.properties echo "${{ secrets.APPLICATION_SECRET_PROPERTIES }}" > ./layer-batch/src/main/resources/application-secret.properties + echo "${{ secrets.APPLICATION_SECRET_PROPERTIES }}" > ./layer-admin/src/main/resources/application-secret.properties - name: Archive Files run: | diff --git a/.github/workflows/aws-cicd-prod.yml b/.github/workflows/aws-cicd-prod.yml index 6b3c8eb9..120105e4 100644 --- a/.github/workflows/aws-cicd-prod.yml +++ b/.github/workflows/aws-cicd-prod.yml @@ -57,6 +57,7 @@ jobs: run: | echo "${APPLICATION_SECRET_PROPERTIES}" > ./layer-api/src/main/resources/application-secret.properties echo "${APPLICATION_SECRET_PROPERTIES}" > ./layer-batch/src/main/resources/application-secret.properties + echo "${APPLICATION_SECRET_PROPERTIES}" > ./layer-admin/src/main/resources/application-secret.properties - name: Build layer-api module run: ./gradlew :layer-api:build @@ -70,6 +71,12 @@ jobs: - name: Test layer-batch module run: ./gradlew :layer-batch:test + - name: Build layer-admin module + run: ./gradlew :layer-admin:build + + - name: Test layer-admin module + run: ./gradlew :layer-admin:test + - name: Docker Hub Login uses: docker/login-action@v1 with: @@ -83,6 +90,7 @@ jobs: images: | ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/${{ env.IMAGE_NAME }}_layer-api ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/${{ env.IMAGE_NAME }}_layer-batch + ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/${{ env.IMAGE_NAME }}_layer-admin - name: Push layer-api Docker Image uses: docker/build-push-action@v4 @@ -106,6 +114,16 @@ jobs: ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/${{ env.IMAGE_NAME }}_layer-batch:latest no-cache: true + - name: Push layer-admin Docker Image + uses: docker/build-push-action@v4 + with: + context: ./layer-admin + file: ./layer-admin/Dockerfile-admin # Dockerfile 이름 지정 + platforms: linux/amd64 + push: true + tags: | + ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/${{ env.IMAGE_NAME }}_layer-admin:latest + deploy: name: Deploy needs: [ build, setup ] @@ -121,6 +139,7 @@ jobs: run: | echo "${{ secrets.APPLICATION_SECRET_PROPERTIES }}" > ./layer-api/infra/${{ env.DEPLOY_TARGET }}/application-secret.properties echo "${{ secrets.APPLICATION_SECRET_PROPERTIES }}" > ./layer-batch/src/main/resources/application-secret.properties + echo "${{ secrets.APPLICATION_SECRET_PROPERTIES }}" > ./layer-admin/src/main/resources/application-secret.properties - name: Archive Files run: | diff --git a/.gitignore b/.gitignore index e0630052..4e76a4c9 100644 --- a/.gitignore +++ b/.gitignore @@ -57,4 +57,5 @@ credentials.json layer-api/src/main/resources/tokens/StoredCredential -layer-batch/src/main/resources/application-secret.properties \ No newline at end of file +layer-batch/src/main/resources/application-secret.properties +layer-admin/src/main/resources/application-secret.properties \ No newline at end of file diff --git a/build.gradle b/build.gradle index a9a41f8e..ac8dbd9e 100644 --- a/build.gradle +++ b/build.gradle @@ -66,7 +66,6 @@ project(":layer-api") { implementation project(path: ':layer-domain') implementation project(path: ':layer-external') - implementation 'org.springframework.boot:spring-boot-starter-actuator' implementation 'org.springframework.boot:spring-boot-starter-validation' @@ -82,7 +81,6 @@ project(":layer-api") { implementation 'org.springframework.boot:spring-boot-starter-data-redis' testImplementation 'org.springframework.boot:spring-boot-starter-test' - // openfeign implementation("org.springframework.cloud:spring-cloud-starter-openfeign:4.1.2") @@ -195,4 +193,24 @@ project(":layer-batch") { runtimeOnly 'com.mysql:mysql-connector-j' } +} + +project(":layer-admin") { + jar.enabled = false + bootJar.enabled = true + + dependencies { + implementation project(path: ':layer-domain') + + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + runtimeOnly 'com.mysql:mysql-connector-j' + + // swagger + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0") + + // Security + implementation 'org.springframework.boot:spring-boot-starter-security' + } + } \ No newline at end of file diff --git a/config/tokens/StoredCredential.txt b/config/tokens/StoredCredential.txt new file mode 100644 index 00000000..63b1c6c1 Binary files /dev/null and b/config/tokens/StoredCredential.txt differ diff --git a/layer-admin/Dockerfile-admin b/layer-admin/Dockerfile-admin new file mode 100644 index 00000000..7c2422f7 --- /dev/null +++ b/layer-admin/Dockerfile-admin @@ -0,0 +1,10 @@ +FROM openjdk:17 + +ARG JAR_FILE=./build/libs/*.jar +ARG SPRING_PROFILE + +COPY ${JAR_FILE} layer-admin.jar + +ENV SPRING_PROFILE=${SPRING_PROFILE} + +ENTRYPOINT ["java", "-Duser.timezone=Asia/Seoul" ,"-jar" ,"layer-admin.jar"] \ No newline at end of file diff --git a/layer-admin/src/main/java/org/layer/AdminApplication.java b/layer-admin/src/main/java/org/layer/AdminApplication.java new file mode 100644 index 00000000..c13ea0e3 --- /dev/null +++ b/layer-admin/src/main/java/org/layer/AdminApplication.java @@ -0,0 +1,14 @@ +package org.layer; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@SpringBootApplication +@EnableJpaAuditing +public class AdminApplication { + public static void main(String[] args) { + SpringApplication.run(AdminApplication.class, args); + } + +} diff --git a/layer-admin/src/main/java/org/layer/config/SecurityConfig.java b/layer-admin/src/main/java/org/layer/config/SecurityConfig.java new file mode 100644 index 00000000..43d2555b --- /dev/null +++ b/layer-admin/src/main/java/org/layer/config/SecurityConfig.java @@ -0,0 +1,23 @@ +package org.layer.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +public class SecurityConfig { + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) // CSRF 비활성화 + .authorizeHttpRequests(auth -> auth + .anyRequest().permitAll() // 모든 요청 허용 + ) + .httpBasic(AbstractHttpConfigurer::disable); // HTTP Basic 인증 비활성화 + + return http.build(); + } +} diff --git a/layer-admin/src/main/java/org/layer/member/controller/AdminMemberApi.java b/layer-admin/src/main/java/org/layer/member/controller/AdminMemberApi.java new file mode 100644 index 00000000..747b24d8 --- /dev/null +++ b/layer-admin/src/main/java/org/layer/member/controller/AdminMemberApi.java @@ -0,0 +1,26 @@ +package org.layer.member.controller; + +import org.layer.member.controller.dto.GetMembersActivitiesResponse; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RequestParam; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "[ADMIN] 회원 서비스", description = "회원 관련 api") +public interface AdminMemberApi { + + @Operation(summary = "회원 활동 목록 조회") + @Parameters({ + @Parameter(name = "password", description = "패스워드", example = "abcdef", required = true), + @Parameter(name = "page", description = "페이지 수, 최솟값 1", example = "1", required = true), + @Parameter(name = "take", description = "가져올 데이터 수", example = "20", required = true) + }) + ResponseEntity getMemberActivities( + @RequestParam String password, + @RequestParam int page, + @RequestParam int take); + +} diff --git a/layer-admin/src/main/java/org/layer/member/controller/AdminMemberController.java b/layer-admin/src/main/java/org/layer/member/controller/AdminMemberController.java new file mode 100644 index 00000000..1d85167b --- /dev/null +++ b/layer-admin/src/main/java/org/layer/member/controller/AdminMemberController.java @@ -0,0 +1,29 @@ +package org.layer.member.controller; + +import org.layer.member.controller.dto.GetMembersActivitiesResponse; +import org.layer.member.service.AdminMemberService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import lombok.RequiredArgsConstructor; + +@RequestMapping("/admin/members") +@RequiredArgsConstructor +@RestController +public class AdminMemberController implements AdminMemberApi { + private final AdminMemberService adminMemberService; + + @Override + @GetMapping + public ResponseEntity getMemberActivities( + @RequestParam String password, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int take) { + + return ResponseEntity.ok().body(adminMemberService.getMemberActivities(password, page, take)); + } + +} diff --git a/layer-admin/src/main/java/org/layer/member/controller/dto/GetMemberActivityResponse.java b/layer-admin/src/main/java/org/layer/member/controller/dto/GetMemberActivityResponse.java new file mode 100644 index 00000000..3a957e66 --- /dev/null +++ b/layer-admin/src/main/java/org/layer/member/controller/dto/GetMemberActivityResponse.java @@ -0,0 +1,29 @@ +package org.layer.member.controller.dto; + +import java.time.LocalDateTime; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +@Schema(name = "GetMemberActivityResponse", description = "회원 활동 Dto") +public record GetMemberActivityResponse( + @NotNull + @Schema(description = "회원 이름", example = "홍길동") + String name, + @NotNull + @Schema(description = "최근 활동 날짜", example = "2024-11-30T16:21:47.031Z") + LocalDateTime recentActivityDate, + @NotNull + @Schema(description = "소속된 스페이스 수", example = "7") + long spaceCount, + @NotNull + @Schema(description = "작성한 회고 수", example = "15") + long retrospectAnswerCount, + @NotNull + @Schema(description = "회원가입 날짜", example = "2024-10-30T16:21:47.031Z") + LocalDateTime signUpDate, + @NotNull + @Schema(description = "회원가입 플랫폼", example = "KAKAO") + String socialType +) { +} diff --git a/layer-admin/src/main/java/org/layer/member/controller/dto/GetMembersActivitiesResponse.java b/layer-admin/src/main/java/org/layer/member/controller/dto/GetMembersActivitiesResponse.java new file mode 100644 index 00000000..3db49acb --- /dev/null +++ b/layer-admin/src/main/java/org/layer/member/controller/dto/GetMembersActivitiesResponse.java @@ -0,0 +1,15 @@ +package org.layer.member.controller.dto; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +@Schema(name = "GetMembersActivitiesResponse", description = "회원 활동 목록 Dto") +public record GetMembersActivitiesResponse( + @NotNull + @Schema(description = "회원 활동 목록", example = "") + List responses + +) { +} diff --git a/layer-admin/src/main/java/org/layer/member/service/AdminMemberService.java b/layer-admin/src/main/java/org/layer/member/service/AdminMemberService.java new file mode 100644 index 00000000..d761a368 --- /dev/null +++ b/layer-admin/src/main/java/org/layer/member/service/AdminMemberService.java @@ -0,0 +1,52 @@ +package org.layer.member.service; + +import java.util.List; + +import org.layer.domain.answer.repository.AdminAnswerRepository; +import org.layer.domain.member.entity.Member; +import org.layer.domain.member.repository.AdminMemberRepository; +import org.layer.domain.space.repository.AdminMemberSpaceRelationRepository; +import org.layer.member.controller.dto.GetMemberActivityResponse; +import org.layer.member.controller.dto.GetMembersActivitiesResponse; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminMemberService { + private final AdminMemberRepository adminMemberRepository; + private final AdminMemberSpaceRelationRepository adminMemberSpaceRelationRepository; + private final AdminAnswerRepository adminAnswerRepository; + + @Value("${admin.password}") + private String password; + + public GetMembersActivitiesResponse getMemberActivities(String password, int page, int take) { + + // TODO: 검증 로직 필터단으로 옮기기 + if (!password.equals(this.password)) { + throw new IllegalArgumentException("비밀번호가 올바르지 않습니다."); + } + + PageRequest pageRequest = PageRequest.of(page - 1, take); + Page members = adminMemberRepository.findAll(pageRequest); + + List responses = members.getContent().stream() + .map(member -> { + + Long spaceCount = adminMemberSpaceRelationRepository.countAllByMemberId(member.getId()); + Long retrospectAnswerCount = adminAnswerRepository.countAllByMemberId(member.getId()); + + return new GetMemberActivityResponse(member.getName(), null, spaceCount, retrospectAnswerCount, + member.getCreatedAt(), member.getSocialType().name()); + }).toList(); + + return new GetMembersActivitiesResponse(responses); + } +} diff --git a/layer-admin/src/main/resources/application-dev.yml b/layer-admin/src/main/resources/application-dev.yml new file mode 100644 index 00000000..941c6352 --- /dev/null +++ b/layer-admin/src/main/resources/application-dev.yml @@ -0,0 +1,23 @@ +server: + port: 3000 + +spring: + config: + import: application-secret.properties + datasource: + url: ${AWS_DEV_DB_URL} + username: ${AWS_PROD_DB_NAME} + password: ${AWS_PROD_DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + jpa: + hibernate: + ddl-auto: validate + properties: + hibernate: + format_sql: true + show_sql: true + open-in-view: false + database: mysql + +admin: + password: ${ADMIN_PASSWORD} \ No newline at end of file diff --git a/layer-admin/src/main/resources/application-prod.yml b/layer-admin/src/main/resources/application-prod.yml new file mode 100644 index 00000000..d053cad6 --- /dev/null +++ b/layer-admin/src/main/resources/application-prod.yml @@ -0,0 +1,23 @@ +server: + port: 3000 + +spring: + config: + import: application-secret.properties + datasource: + url: ${AWS_PROD_DB_URL} + username: ${AWS_PROD_DB_NAME} + password: ${AWS_PROD_DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + jpa: + hibernate: + ddl-auto: validate + properties: + hibernate: + format_sql: true + show_sql: true + open-in-view: false + database: mysql + +admin: + password: ${ADMIN_PASSWORD} \ No newline at end of file diff --git a/layer-api/infra/development/docker-compose.yaml b/layer-api/infra/development/docker-compose.yaml index edd2b8c9..b0ea5bbe 100644 --- a/layer-api/infra/development/docker-compose.yaml +++ b/layer-api/infra/development/docker-compose.yaml @@ -29,6 +29,24 @@ services: - java-app restart: always + admin-app: + image: docker.io/clean01/layer-server_layer-admin:latest # + container_name: layer-admin + ports: + - "3000:3000" + environment: + - TZ=Asia/Seoul + - SPRING_PROFILES_ACTIVE=dev + volumes: + - ./application-secret.properties:/config/application-secret.properties + - ./log:/log + - ./tokens:/config/tokens + networks: + - app-network + depends_on: + - java-app + restart: always + nginx: image: nginx:latest container_name: nginx diff --git a/layer-api/infra/development/nginx.conf b/layer-api/infra/development/nginx.conf index 4bcbe484..639490fb 100644 --- a/layer-api/infra/development/nginx.conf +++ b/layer-api/infra/development/nginx.conf @@ -5,6 +5,10 @@ http { server layer-api:8080; } + upstream layer-admin { + server layer-admin:3000; + } + server { listen 80; @@ -15,5 +19,13 @@ http { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } + + location /admin/ { + proxy_pass http://layer-admin; # Proxy to layer-admin + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } } } \ No newline at end of file diff --git a/layer-domain/src/main/java/org/layer/domain/answer/repository/AdminAnswerRepository.java b/layer-domain/src/main/java/org/layer/domain/answer/repository/AdminAnswerRepository.java new file mode 100644 index 00000000..b0b044c6 --- /dev/null +++ b/layer-domain/src/main/java/org/layer/domain/answer/repository/AdminAnswerRepository.java @@ -0,0 +1,8 @@ +package org.layer.domain.answer.repository; + +import org.layer.domain.answer.entity.Answer; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AdminAnswerRepository extends JpaRepository { + Long countAllByMemberId(Long memberId); +} diff --git a/layer-domain/src/main/java/org/layer/domain/member/repository/AdminMemberRepository.java b/layer-domain/src/main/java/org/layer/domain/member/repository/AdminMemberRepository.java new file mode 100644 index 00000000..2c8aa81e --- /dev/null +++ b/layer-domain/src/main/java/org/layer/domain/member/repository/AdminMemberRepository.java @@ -0,0 +1,10 @@ +package org.layer.domain.member.repository; + +import org.layer.domain.member.entity.Member; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AdminMemberRepository extends JpaRepository { + Page findAll(Pageable pageable); +} diff --git a/layer-domain/src/main/java/org/layer/domain/space/repository/AdminMemberSpaceRelationRepository.java b/layer-domain/src/main/java/org/layer/domain/space/repository/AdminMemberSpaceRelationRepository.java new file mode 100644 index 00000000..7d3307e1 --- /dev/null +++ b/layer-domain/src/main/java/org/layer/domain/space/repository/AdminMemberSpaceRelationRepository.java @@ -0,0 +1,9 @@ +package org.layer.domain.space.repository; + +import org.layer.domain.space.entity.MemberSpaceRelation; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AdminMemberSpaceRelationRepository extends JpaRepository { + + Long countAllByMemberId(Long memberId); +} diff --git a/settings.gradle b/settings.gradle index b5e01a2c..b8ffbacd 100644 --- a/settings.gradle +++ b/settings.gradle @@ -4,4 +4,5 @@ include 'layer-common' include 'layer-domain' include 'layer-external' include 'layer-batch' +include 'layer-admin'