diff --git a/.github/workflows/deploy-to-dev-ec2.yml b/.github/workflows/deploy-to-dev-ec2.yml index 84b57c7..f7975e2 100644 --- a/.github/workflows/deploy-to-dev-ec2.yml +++ b/.github/workflows/deploy-to-dev-ec2.yml @@ -147,7 +147,8 @@ jobs: ANTHROPIC_API_KEY="${{ secrets.ANTHROPIC_API_KEY }}" \ GOOGLE_GENAI_API_KEY="${{ secrets.GOOGLE_GENAI_API_KEY }}" \ FIREBASE_ADMIN_KEY="${{ secrets.FIREBASE_ADMIN_KEY }}" \ + ADMIN_PAGE_PASSWORD="${{ secrets.ADMIN_PAGE_PASSWORD }}" \ nohup java -jar "$JAR_PATH" \ --spring.profiles.active=dev > app.log 2>&1 & - echo "✅ 배포 완료!" \ No newline at end of file + echo "✅ 배포 완료!" diff --git a/.github/workflows/deploy-to-prod-ec2.yml b/.github/workflows/deploy-to-prod-ec2.yml index 8f0220f..bf2f312 100644 --- a/.github/workflows/deploy-to-prod-ec2.yml +++ b/.github/workflows/deploy-to-prod-ec2.yml @@ -144,6 +144,7 @@ jobs: ANTHROPIC_API_KEY="${{ secrets.ANTHROPIC_API_KEY }}" \ GOOGLE_GENAI_API_KEY="${{ secrets.GOOGLE_GENAI_API_KEY }}" \ FIREBASE_ADMIN_KEY="${{ secrets.FIREBASE_ADMIN_KEY }}" \ + ADMIN_PAGE_PASSWORD="${{ secrets.ADMIN_PAGE_PASSWORD }}" \ nohup java -jar "$JAR_PATH" \ --spring.profiles.active=prod > app-prod.log 2>&1 & diff --git a/commitlint.config.js b/commitlint.config.js index bb7e447..47cc82a 100644 --- a/commitlint.config.js +++ b/commitlint.config.js @@ -14,6 +14,7 @@ module.exports = { "revert" ]], "scope-enum": [2, "always", [ + "admin", "auth", "user", "ai", @@ -100,6 +101,9 @@ module.exports = { scope: { description: '[Scope] 이번 변경이 적용된 범위를 선택해주세요 (범위 생략하려면 empty 선택)', enum: { + admin: { + description: '🧑‍💻 어드민 도메인 (예: 어드민 페이지)' + }, auth: { description: '🔐 인증/인가 도메인 (예: OAuth2, JWT, 세션)' }, diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/api/AdminAuthController.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/AdminAuthController.java new file mode 100644 index 0000000..2787dd9 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/AdminAuthController.java @@ -0,0 +1,54 @@ +package com.devkor.ifive.nadab.domain.admin.api; + +import com.devkor.ifive.nadab.domain.admin.api.dto.request.AdminLoginRequest; +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminAuthStatusResponse; +import com.devkor.ifive.nadab.domain.admin.application.AdminPageAuthCommandService; +import com.devkor.ifive.nadab.domain.admin.infra.security.AdminPageAuthCookieService; +import com.devkor.ifive.nadab.domain.admin.infra.security.AdminPageAuthTokenService; +import com.devkor.ifive.nadab.global.core.response.ApiResponseDto; +import com.devkor.ifive.nadab.global.core.response.ApiResponseEntity; +import io.swagger.v3.oas.annotations.Hidden; +import jakarta.annotation.security.PermitAll; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Hidden +@RestController +@RequestMapping("/admin/api") +@RequiredArgsConstructor +public class AdminAuthController { + + private final AdminPageAuthCommandService adminPageAuthCommandService; + private final AdminPageAuthTokenService adminPageAuthTokenService; + private final AdminPageAuthCookieService adminPageAuthCookieService; + + @PostMapping("/login") + @PermitAll + public ResponseEntity> login( + @RequestBody @Valid AdminLoginRequest request, + HttpServletResponse response + ) { + adminPageAuthCommandService.validatePassword(request.password()); + String token = adminPageAuthTokenService.issueToken(); + adminPageAuthCookieService.addCookie(response, token); + return ApiResponseEntity.noContent(); + } + + @PostMapping("/logout") + public ResponseEntity> logout(HttpServletResponse response) { + adminPageAuthCookieService.expireCookie(response); + return ApiResponseEntity.noContent(); + } + + @GetMapping("/auth-status") + public ResponseEntity> authStatus() { + return ApiResponseEntity.ok(new AdminAuthStatusResponse(true)); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/api/AdminPageController.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/AdminPageController.java new file mode 100644 index 0000000..54b8ad5 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/AdminPageController.java @@ -0,0 +1,23 @@ +package com.devkor.ifive.nadab.domain.admin.api; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +@Controller +public class AdminPageController { + + @GetMapping("/admin/login") + public String loginPage() { + return "admin/login"; + } + + @GetMapping("/admin") + public String adminRoot() { + return "redirect:/admin/tabs/app-versions"; + } + + @GetMapping("/admin/tabs/app-versions") + public String adminVersionPage() { + return "admin/version"; + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/api/AdminVersionController.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/AdminVersionController.java new file mode 100644 index 0000000..7859ce1 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/AdminVersionController.java @@ -0,0 +1,84 @@ +package com.devkor.ifive.nadab.domain.admin.api; + +import com.devkor.ifive.nadab.domain.admin.api.dto.request.AdminVersionCreateRequest; +import com.devkor.ifive.nadab.domain.admin.api.dto.request.AdminVersionItemUpsertRequest; +import com.devkor.ifive.nadab.domain.admin.api.dto.request.AdminVersionSummaryUpdateRequest; +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminLatestVersionsResponse; +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminVersionCreateResponse; +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminVersionItemCreateResponse; +import com.devkor.ifive.nadab.domain.admin.application.AdminVersionCommandService; +import com.devkor.ifive.nadab.domain.admin.application.AdminVersionItemCommandService; +import com.devkor.ifive.nadab.domain.admin.application.AdminVersionQueryService; +import com.devkor.ifive.nadab.global.core.response.ApiResponseDto; +import com.devkor.ifive.nadab.global.core.response.ApiResponseEntity; +import io.swagger.v3.oas.annotations.Hidden; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Hidden +@RestController +@RequestMapping("/admin/api/versions") +@RequiredArgsConstructor +public class AdminVersionController { + + private final AdminVersionQueryService adminVersionQueryService; + private final AdminVersionCommandService adminVersionCommandService; + private final AdminVersionItemCommandService adminVersionItemCommandService; + + @GetMapping("/latest") + public ResponseEntity> getLatestVersions() { + return ApiResponseEntity.ok(adminVersionQueryService.getLatestVersions()); + } + + @PostMapping + public ResponseEntity> createVersion( + @RequestBody @Valid AdminVersionCreateRequest request + ) { + Long appVersionId = adminVersionCommandService.createVersion(request); + return ApiResponseEntity.created(new AdminVersionCreateResponse(appVersionId)); + } + + @PutMapping("/{appVersionId}/summary") + public ResponseEntity> updateVersionSummary( + @PathVariable Long appVersionId, + @RequestBody @Valid AdminVersionSummaryUpdateRequest request + ) { + adminVersionCommandService.updateSummary(appVersionId, request.summary()); + return ApiResponseEntity.noContent(); + } + + @PostMapping("/{appVersionId}/items") + public ResponseEntity> createVersionItem( + @PathVariable Long appVersionId, + @RequestBody @Valid AdminVersionItemUpsertRequest request + ) { + Long appVersionItemId = adminVersionItemCommandService.createItem(appVersionId, request); + return ApiResponseEntity.created(new AdminVersionItemCreateResponse(appVersionItemId)); + } + + @PutMapping("/items/{appVersionItemId}") + public ResponseEntity> updateVersionItem( + @PathVariable Long appVersionItemId, + @RequestBody @Valid AdminVersionItemUpsertRequest request + ) { + adminVersionItemCommandService.updateItem(appVersionItemId, request); + return ApiResponseEntity.noContent(); + } + + @DeleteMapping("/items/{appVersionItemId}") + public ResponseEntity> deleteVersionItem( + @PathVariable Long appVersionItemId + ) { + adminVersionItemCommandService.deleteItem(appVersionItemId); + return ApiResponseEntity.noContent(); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/request/AdminLoginRequest.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/request/AdminLoginRequest.java new file mode 100644 index 0000000..1fd2b85 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/request/AdminLoginRequest.java @@ -0,0 +1,9 @@ +package com.devkor.ifive.nadab.domain.admin.api.dto.request; + +import jakarta.validation.constraints.NotBlank; + +public record AdminLoginRequest( + @NotBlank(message = "비밀번호는 필수입니다.") + String password +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/request/AdminVersionCreateRequest.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/request/AdminVersionCreateRequest.java new file mode 100644 index 0000000..dad51c9 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/request/AdminVersionCreateRequest.java @@ -0,0 +1,20 @@ +package com.devkor.ifive.nadab.domain.admin.api.dto.request; + +import com.devkor.ifive.nadab.domain.appversion.core.entity.AppPlatform; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record AdminVersionCreateRequest( + @NotNull(message = "플랫폼은 필수입니다.") + AppPlatform platform, + + @NotBlank(message = "버전은 필수입니다.") + @Size(max = 30, message = "버전은 30자 이하여야 합니다.") + String version, + + @NotNull(message = "요약은 null일 수 없습니다.") + @Size(max = 120, message = "요약은 120자 이하여야 합니다.") + String summary +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/request/AdminVersionItemUpsertRequest.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/request/AdminVersionItemUpsertRequest.java new file mode 100644 index 0000000..6297f4d --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/request/AdminVersionItemUpsertRequest.java @@ -0,0 +1,23 @@ +package com.devkor.ifive.nadab.domain.admin.api.dto.request; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record AdminVersionItemUpsertRequest( + @NotBlank(message = "업데이트명은 필수입니다.") + @Size(max = 100, message = "업데이트명은 100자 이하여야 합니다.") + String title, + + @NotBlank(message = "상세 내용은 필수입니다.") + @Size(max = 500, message = "상세 내용은 500자 이하여야 합니다.") + String description, + + @NotNull(message = "displayOrder는 필수입니다.") + @Min(value = 1, message = "displayOrder는 1 이상이어야 합니다.") + @Max(value = 9999, message = "displayOrder는 9999 이하여야 합니다.") + Integer displayOrder +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/request/AdminVersionSummaryUpdateRequest.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/request/AdminVersionSummaryUpdateRequest.java new file mode 100644 index 0000000..bad6e1c --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/request/AdminVersionSummaryUpdateRequest.java @@ -0,0 +1,11 @@ +package com.devkor.ifive.nadab.domain.admin.api.dto.request; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record AdminVersionSummaryUpdateRequest( + @NotNull(message = "요약은 null일 수 없습니다.") + @Size(max = 120, message = "요약은 120자 이하여야 합니다.") + String summary +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminAuthStatusResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminAuthStatusResponse.java new file mode 100644 index 0000000..ab53f15 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminAuthStatusResponse.java @@ -0,0 +1,6 @@ +package com.devkor.ifive.nadab.domain.admin.api.dto.response; + +public record AdminAuthStatusResponse( + boolean authenticated +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminLatestVersionsResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminLatestVersionsResponse.java new file mode 100644 index 0000000..cca38a3 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminLatestVersionsResponse.java @@ -0,0 +1,8 @@ +package com.devkor.ifive.nadab.domain.admin.api.dto.response; + +import java.util.List; + +public record AdminLatestVersionsResponse( + List versions +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminVersionCreateResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminVersionCreateResponse.java new file mode 100644 index 0000000..aed9aa4 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminVersionCreateResponse.java @@ -0,0 +1,6 @@ +package com.devkor.ifive.nadab.domain.admin.api.dto.response; + +public record AdminVersionCreateResponse( + Long appVersionId +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminVersionItemCreateResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminVersionItemCreateResponse.java new file mode 100644 index 0000000..394facd --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminVersionItemCreateResponse.java @@ -0,0 +1,6 @@ +package com.devkor.ifive.nadab.domain.admin.api.dto.response; + +public record AdminVersionItemCreateResponse( + Long appVersionItemId +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminVersionItemResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminVersionItemResponse.java new file mode 100644 index 0000000..b90e938 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminVersionItemResponse.java @@ -0,0 +1,9 @@ +package com.devkor.ifive.nadab.domain.admin.api.dto.response; + +public record AdminVersionItemResponse( + Long id, + String title, + String description, + Integer displayOrder +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminVersionResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminVersionResponse.java new file mode 100644 index 0000000..01bc69c --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/response/AdminVersionResponse.java @@ -0,0 +1,14 @@ +package com.devkor.ifive.nadab.domain.admin.api.dto.response; + +import com.devkor.ifive.nadab.domain.appversion.core.entity.AppPlatform; + +import java.util.List; + +public record AdminVersionResponse( + Long id, + AppPlatform platform, + String version, + String summary, + List items +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminPageAuthCommandService.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminPageAuthCommandService.java new file mode 100644 index 0000000..4e236b3 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminPageAuthCommandService.java @@ -0,0 +1,26 @@ +package com.devkor.ifive.nadab.domain.admin.application; + +import com.devkor.ifive.nadab.domain.admin.core.properties.AdminPageProperties; +import com.devkor.ifive.nadab.global.core.response.ErrorCode; +import com.devkor.ifive.nadab.global.exception.UnauthorizedException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +@Service +@RequiredArgsConstructor +public class AdminPageAuthCommandService { + + private final AdminPageProperties adminPageProperties; + + public void validatePassword(String rawPassword) { + byte[] input = rawPassword.getBytes(StandardCharsets.UTF_8); + byte[] expected = adminPageProperties.getPassword().getBytes(StandardCharsets.UTF_8); + + if (!MessageDigest.isEqual(input, expected)) { + throw new UnauthorizedException(ErrorCode.ADMIN_PAGE_INVALID_PASSWORD); + } + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminVersionCommandService.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminVersionCommandService.java new file mode 100644 index 0000000..6f760a7 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminVersionCommandService.java @@ -0,0 +1,48 @@ +package com.devkor.ifive.nadab.domain.admin.application; + +import com.devkor.ifive.nadab.domain.admin.api.dto.request.AdminVersionCreateRequest; +import com.devkor.ifive.nadab.domain.appversion.core.entity.AppVersion; +import com.devkor.ifive.nadab.domain.appversion.core.repository.AppVersionRepository; +import com.devkor.ifive.nadab.global.core.response.ErrorCode; +import com.devkor.ifive.nadab.global.exception.ConflictException; +import com.devkor.ifive.nadab.global.exception.NotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class AdminVersionCommandService { + + private final AppVersionRepository appVersionRepository; + + public Long createVersion(AdminVersionCreateRequest request) { + if (appVersionRepository.existsByPlatformAndVersion(request.platform(), request.version())) { + throw new ConflictException(ErrorCode.APP_VERSION_ALREADY_EXISTS); + } + + appVersionRepository.findByPlatformAndIsLatestTrue(request.platform()) + .ifPresent(AppVersion::markAsNotLatest); + appVersionRepository.flush(); + + AppVersion appVersion = AppVersion.create( + request.platform(), + request.version(), + request.summary() + ); + try { + appVersionRepository.saveAndFlush(appVersion); + return appVersion.getId(); + } catch (DataIntegrityViolationException e) { + throw new ConflictException(ErrorCode.APP_VERSION_ALREADY_EXISTS); + } + } + + public void updateSummary(Long appVersionId, String summary) { + AppVersion appVersion = appVersionRepository.findById(appVersionId) + .orElseThrow(() -> new NotFoundException(ErrorCode.APP_VERSION_NOT_FOUND)); + appVersion.updateSummary(summary); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminVersionItemCommandService.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminVersionItemCommandService.java new file mode 100644 index 0000000..e5a6700 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminVersionItemCommandService.java @@ -0,0 +1,71 @@ +package com.devkor.ifive.nadab.domain.admin.application; + +import com.devkor.ifive.nadab.domain.admin.api.dto.request.AdminVersionItemUpsertRequest; +import com.devkor.ifive.nadab.domain.appversion.core.entity.AppVersion; +import com.devkor.ifive.nadab.domain.appversion.core.entity.AppVersionItem; +import com.devkor.ifive.nadab.domain.appversion.core.repository.AppVersionItemRepository; +import com.devkor.ifive.nadab.domain.appversion.core.repository.AppVersionRepository; +import com.devkor.ifive.nadab.global.core.response.ErrorCode; +import com.devkor.ifive.nadab.global.exception.ConflictException; +import com.devkor.ifive.nadab.global.exception.NotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class AdminVersionItemCommandService { + + private final AppVersionRepository appVersionRepository; + private final AppVersionItemRepository appVersionItemRepository; + + public Long createItem(Long appVersionId, AdminVersionItemUpsertRequest request) { + AppVersion appVersion = appVersionRepository.findById(appVersionId) + .orElseThrow(() -> new NotFoundException(ErrorCode.APP_VERSION_NOT_FOUND)); + + if (appVersionItemRepository.existsByAppVersionIdAndDisplayOrder(appVersionId, request.displayOrder())) { + throw new ConflictException(ErrorCode.APP_VERSION_ITEM_DISPLAY_ORDER_DUPLICATED); + } + + AppVersionItem item = AppVersionItem.create( + appVersion, + request.title(), + request.description(), + request.displayOrder() + ); + + try { + appVersionItemRepository.saveAndFlush(item); + return item.getId(); + } catch (DataIntegrityViolationException e) { + throw new ConflictException(ErrorCode.APP_VERSION_ITEM_DISPLAY_ORDER_DUPLICATED); + } + } + + public void updateItem(Long appVersionItemId, AdminVersionItemUpsertRequest request) { + AppVersionItem item = appVersionItemRepository.findById(appVersionItemId) + .orElseThrow(() -> new NotFoundException(ErrorCode.APP_VERSION_ITEM_NOT_FOUND)); + + Long appVersionId = item.getAppVersion().getId(); + if (appVersionItemRepository.existsByAppVersionIdAndDisplayOrderAndIdNot( + appVersionId, request.displayOrder(), appVersionItemId + )) { + throw new ConflictException(ErrorCode.APP_VERSION_ITEM_DISPLAY_ORDER_DUPLICATED); + } + + try { + item.update(request.title(), request.description(), request.displayOrder()); + appVersionItemRepository.flush(); + } catch (DataIntegrityViolationException e) { + throw new ConflictException(ErrorCode.APP_VERSION_ITEM_DISPLAY_ORDER_DUPLICATED); + } + } + + public void deleteItem(Long appVersionItemId) { + AppVersionItem item = appVersionItemRepository.findById(appVersionItemId) + .orElseThrow(() -> new NotFoundException(ErrorCode.APP_VERSION_ITEM_NOT_FOUND)); + appVersionItemRepository.delete(item); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminVersionQueryService.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminVersionQueryService.java new file mode 100644 index 0000000..6b470c3 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminVersionQueryService.java @@ -0,0 +1,64 @@ +package com.devkor.ifive.nadab.domain.admin.application; + +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminLatestVersionsResponse; +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminVersionItemResponse; +import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminVersionResponse; +import com.devkor.ifive.nadab.domain.appversion.core.entity.AppVersion; +import com.devkor.ifive.nadab.domain.appversion.core.entity.AppVersionItem; +import com.devkor.ifive.nadab.domain.appversion.core.repository.AppVersionItemRepository; +import com.devkor.ifive.nadab.domain.appversion.core.repository.AppVersionRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminVersionQueryService { + + private final AppVersionRepository appVersionRepository; + private final AppVersionItemRepository appVersionItemRepository; + + public AdminLatestVersionsResponse getLatestVersions() { + List latestVersions = appVersionRepository.findByIsLatestTrue(); + List appVersionIds = latestVersions.stream() + .map(AppVersion::getId) + .toList(); + + List items = appVersionIds.isEmpty() + ? List.of() + : appVersionItemRepository.findByAppVersionIdInOrderByDisplayOrderAsc(appVersionIds); + + Map> itemsByVersionId = items.stream() + .collect(Collectors.groupingBy( + item -> item.getAppVersion().getId(), + Collectors.mapping( + item -> new AdminVersionItemResponse( + item.getId(), + item.getTitle(), + item.getDescription(), + item.getDisplayOrder() + ), + Collectors.toList() + ) + )); + + List versions = latestVersions.stream() + .sorted(Comparator.comparing(version -> version.getPlatform().name())) + .map(version -> new AdminVersionResponse( + version.getId(), + version.getPlatform(), + version.getVersion(), + version.getSummary(), + itemsByVersionId.getOrDefault(version.getId(), List.of()) + )) + .toList(); + + return new AdminLatestVersionsResponse(versions); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/core/properties/AdminPageProperties.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/core/properties/AdminPageProperties.java new file mode 100644 index 0000000..4db600d --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/core/properties/AdminPageProperties.java @@ -0,0 +1,26 @@ +package com.devkor.ifive.nadab.domain.admin.core.properties; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; +import org.springframework.validation.annotation.Validated; + +@Component +@Getter +@Setter +@Validated +@ConfigurationProperties(prefix = "admin.page") +public class AdminPageProperties { + + @NotBlank + private String password; + + @Min(60) + private long tokenExpirationSeconds = 43200; + + @NotBlank + private String cookieName = "admin_auth"; +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/infra/security/AdminPageAuthCookieService.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/infra/security/AdminPageAuthCookieService.java new file mode 100644 index 0000000..3e73840 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/infra/security/AdminPageAuthCookieService.java @@ -0,0 +1,43 @@ +package com.devkor.ifive.nadab.domain.admin.infra.security; + +import com.devkor.ifive.nadab.domain.admin.core.properties.AdminPageProperties; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class AdminPageAuthCookieService { + + @Value("${app.cookie.secure}") + private boolean cookieSecure; + + @Value("${app.cookie.same-site}") + private String cookieSameSite; + + private final AdminPageProperties adminPageProperties; + + public void addCookie(HttpServletResponse response, String token) { + ResponseCookie cookie = ResponseCookie.from(adminPageProperties.getCookieName(), token) + .httpOnly(true) + .secure(cookieSecure) + .sameSite(cookieSameSite) + .path("/admin") + .maxAge(adminPageProperties.getTokenExpirationSeconds()) + .build(); + response.addHeader("Set-Cookie", cookie.toString()); + } + + public void expireCookie(HttpServletResponse response) { + ResponseCookie cookie = ResponseCookie.from(adminPageProperties.getCookieName(), "") + .httpOnly(true) + .secure(cookieSecure) + .sameSite(cookieSameSite) + .path("/admin") + .maxAge(0) + .build(); + response.addHeader("Set-Cookie", cookie.toString()); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/infra/security/AdminPageAuthInterceptor.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/infra/security/AdminPageAuthInterceptor.java new file mode 100644 index 0000000..76578ed --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/infra/security/AdminPageAuthInterceptor.java @@ -0,0 +1,74 @@ +package com.devkor.ifive.nadab.domain.admin.infra.security; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +@Component +@RequiredArgsConstructor +public class AdminPageAuthInterceptor implements HandlerInterceptor { + + private final AdminPageAuthTokenService adminPageAuthTokenService; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { + if (HttpMethod.OPTIONS.matches(request.getMethod())) { + return true; + } + + String requestUri = request.getRequestURI(); + String token = extractCookieValue(request, adminPageAuthTokenService.getCookieName()); + boolean isTokenValid = adminPageAuthTokenService.isValid(token); + + if (isLoginApi(requestUri)) { + return true; + } + + if (isLoginPage(requestUri)) { + if (isTokenValid) { + response.sendRedirect("/admin"); + return false; + } + return true; + } + + if (isTokenValid) { + return true; + } + + if (requestUri.startsWith("/admin/api/")) { + response.setStatus(HttpStatus.UNAUTHORIZED.value()); + return false; + } + + response.sendRedirect("/admin/login"); + return false; + } + + private boolean isLoginApi(String requestUri) { + return "/admin/api/login".equals(requestUri); + } + + private boolean isLoginPage(String requestUri) { + return "/admin/login".equals(requestUri) || requestUri.startsWith("/admin/login/"); + } + + private String extractCookieValue(HttpServletRequest request, String cookieName) { + Cookie[] cookies = request.getCookies(); + if (cookies == null) { + return null; + } + + for (Cookie cookie : cookies) { + if (cookieName.equals(cookie.getName())) { + return cookie.getValue(); + } + } + return null; + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/infra/security/AdminPageAuthTokenService.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/infra/security/AdminPageAuthTokenService.java new file mode 100644 index 0000000..9a31db4 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/infra/security/AdminPageAuthTokenService.java @@ -0,0 +1,100 @@ +package com.devkor.ifive.nadab.domain.admin.infra.security; + +import com.devkor.ifive.nadab.domain.admin.core.properties.AdminPageProperties; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Instant; +import java.util.Base64; + +@Component +@RequiredArgsConstructor +public class AdminPageAuthTokenService { + + private static final String HMAC_ALGORITHM = "HmacSHA256"; + + private final AdminPageProperties adminPageProperties; + + public String issueToken() { + long issuedAt = Instant.now().getEpochSecond(); + long expiresAt = issuedAt + adminPageProperties.getTokenExpirationSeconds(); + + String payload = issuedAt + ":" + expiresAt; + String encodedPayload = encode(payload.getBytes(StandardCharsets.UTF_8)); + String signature = sign(encodedPayload); + + return encodedPayload + "." + signature; + } + + public boolean isValid(String token) { + if (token == null || token.isBlank()) { + return false; + } + + String[] parts = token.split("\\."); + if (parts.length != 2) { + return false; + } + + String encodedPayload = parts[0]; + String signature = parts[1]; + String expectedSignature = sign(encodedPayload); + + if (!MessageDigest.isEqual( + signature.getBytes(StandardCharsets.UTF_8), + expectedSignature.getBytes(StandardCharsets.UTF_8) + )) { + return false; + } + + String decodedPayload; + try { + decodedPayload = new String( + Base64.getUrlDecoder().decode(encodedPayload), + StandardCharsets.UTF_8 + ); + } catch (IllegalArgumentException e) { + return false; + } + + String[] payloadParts = decodedPayload.split(":"); + if (payloadParts.length != 2) { + return false; + } + + long expiresAt; + try { + expiresAt = Long.parseLong(payloadParts[1]); + } catch (NumberFormatException e) { + return false; + } + + return Instant.now().getEpochSecond() < expiresAt; + } + + public String getCookieName() { + return adminPageProperties.getCookieName(); + } + + private String sign(String encodedPayload) { + try { + Mac mac = Mac.getInstance(HMAC_ALGORITHM); + mac.init(new SecretKeySpec( + adminPageProperties.getPassword().getBytes(StandardCharsets.UTF_8), + HMAC_ALGORITHM + )); + byte[] signature = mac.doFinal(encodedPayload.getBytes(StandardCharsets.UTF_8)); + return encode(signature); + } catch (Exception e) { + throw new IllegalStateException("Admin token signing failed", e); + } + } + + private String encode(byte[] bytes) { + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/infra/security/AdminPageWebMvcConfig.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/infra/security/AdminPageWebMvcConfig.java new file mode 100644 index 0000000..c10d71b --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/infra/security/AdminPageWebMvcConfig.java @@ -0,0 +1,19 @@ +package com.devkor.ifive.nadab.domain.admin.infra.security; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +@RequiredArgsConstructor +public class AdminPageWebMvcConfig implements WebMvcConfigurer { + + private final AdminPageAuthInterceptor adminPageAuthInterceptor; + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(adminPageAuthInterceptor) + .addPathPatterns("/admin/**"); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/appversion/application/AppVersionDismissalCommandService.java b/src/main/java/com/devkor/ifive/nadab/domain/appversion/application/AppVersionDismissalCommandService.java new file mode 100644 index 0000000..2a8588a --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/appversion/application/AppVersionDismissalCommandService.java @@ -0,0 +1,42 @@ +package com.devkor.ifive.nadab.domain.appversion.application; + +import com.devkor.ifive.nadab.domain.appversion.core.entity.AppVersion; +import com.devkor.ifive.nadab.domain.appversion.core.entity.UserAppVersionDismissal; +import com.devkor.ifive.nadab.domain.appversion.core.repository.AppVersionRepository; +import com.devkor.ifive.nadab.domain.appversion.core.repository.UserAppVersionDismissalRepository; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.domain.user.core.repository.UserRepository; +import com.devkor.ifive.nadab.global.core.response.ErrorCode; +import com.devkor.ifive.nadab.global.exception.NotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class AppVersionDismissalCommandService { + + private final UserRepository userRepository; + private final AppVersionRepository appVersionRepository; + private final UserAppVersionDismissalRepository userAppVersionDismissalRepository; + + public void dismiss(Long userId, Long appVersionId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new NotFoundException(ErrorCode.USER_NOT_FOUND)); + + AppVersion appVersion = appVersionRepository.findById(appVersionId) + .orElseThrow(() -> new NotFoundException(ErrorCode.APP_VERSION_NOT_FOUND)); + + if (userAppVersionDismissalRepository.existsByUserIdAndAppVersionId(userId, appVersionId)) { + return; + } + + try { + userAppVersionDismissalRepository.save(UserAppVersionDismissal.create(user, appVersion)); + } catch (DataIntegrityViolationException ignored) { + // Ignore duplicate insert race condition. + } + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/appversion/application/AppVersionQueryService.java b/src/main/java/com/devkor/ifive/nadab/domain/appversion/application/AppVersionQueryService.java new file mode 100644 index 0000000..d9e9272 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/appversion/application/AppVersionQueryService.java @@ -0,0 +1,85 @@ +package com.devkor.ifive.nadab.domain.appversion.application; + +import com.devkor.ifive.nadab.domain.appversion.core.entity.AppPlatform; +import com.devkor.ifive.nadab.domain.appversion.core.entity.AppVersion; +import com.devkor.ifive.nadab.domain.appversion.core.entity.AppVersionItem; +import com.devkor.ifive.nadab.domain.appversion.core.repository.AppVersionItemRepository; +import com.devkor.ifive.nadab.domain.appversion.core.repository.AppVersionRepository; +import com.devkor.ifive.nadab.domain.appversion.core.repository.UserAppVersionDismissalRepository; +import com.devkor.ifive.nadab.domain.dailyreport.api.dto.response.HomeLatestVersionResponse; +import com.devkor.ifive.nadab.domain.dailyreport.api.dto.response.HomePlatformVersionResponse; +import com.devkor.ifive.nadab.domain.dailyreport.api.dto.response.HomeVersionItemResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AppVersionQueryService { + + private final AppVersionRepository appVersionRepository; + private final AppVersionItemRepository appVersionItemRepository; + private final UserAppVersionDismissalRepository userAppVersionDismissalRepository; + + public HomeLatestVersionResponse getHomeLatestVersion(Long userId) { + List latestVersions = appVersionRepository.findByIsLatestTrue(); + + List appVersionIds = latestVersions.stream() + .map(AppVersion::getId) + .toList(); + + Set dismissedAppVersionIds = appVersionIds.isEmpty() + ? Set.of() + : userAppVersionDismissalRepository.findDismissedAppVersionIds(userId, appVersionIds).stream() + .collect(Collectors.toSet()); + + List versionItems = appVersionIds.isEmpty() + ? List.of() + : appVersionItemRepository.findByAppVersionIdInOrderByDisplayOrderAsc(appVersionIds); + + Map> itemResponsesByVersionId = versionItems.stream() + .collect(Collectors.groupingBy( + item -> item.getAppVersion().getId(), + Collectors.mapping( + item -> new HomeVersionItemResponse(item.getTitle(), item.getDescription()), + Collectors.toList() + ) + )); + + Map latestVersionByPlatform = latestVersions.stream() + .collect(Collectors.toMap( + AppVersion::getPlatform, + appVersion -> toPlatformResponse( + appVersion, + itemResponsesByVersionId.getOrDefault(appVersion.getId(), List.of()), + dismissedAppVersionIds.contains(appVersion.getId()) + ), + (left, right) -> right + )); + + return new HomeLatestVersionResponse( + latestVersionByPlatform.get(AppPlatform.IOS), + latestVersionByPlatform.get(AppPlatform.ANDROID) + ); + } + + private HomePlatformVersionResponse toPlatformResponse( + AppVersion appVersion, + List items, + boolean dismissed + ) { + return new HomePlatformVersionResponse( + appVersion.getId(), + appVersion.getVersion(), + appVersion.getSummary(), + items, + dismissed + ); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/appversion/core/entity/AppPlatform.java b/src/main/java/com/devkor/ifive/nadab/domain/appversion/core/entity/AppPlatform.java new file mode 100644 index 0000000..a719806 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/appversion/core/entity/AppPlatform.java @@ -0,0 +1,6 @@ +package com.devkor.ifive.nadab.domain.appversion.core.entity; + +public enum AppPlatform { + IOS, + ANDROID +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/appversion/core/entity/AppVersion.java b/src/main/java/com/devkor/ifive/nadab/domain/appversion/core/entity/AppVersion.java new file mode 100644 index 0000000..49a3d47 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/appversion/core/entity/AppVersion.java @@ -0,0 +1,59 @@ +package com.devkor.ifive.nadab.domain.appversion.core.entity; + +import com.devkor.ifive.nadab.global.shared.entity.AuditableEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table( + name = "app_versions", + uniqueConstraints = { + @UniqueConstraint(name = "uk_app_versions_platform_version", columnNames = {"platform", "version"}) + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class AppVersion extends AuditableEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Enumerated(EnumType.STRING) + @Column(name = "platform", nullable = false, length = 20) + private AppPlatform platform; + + @Column(name = "version", nullable = false, length = 30) + private String version; + + @Column(name = "is_latest", nullable = false) + private Boolean isLatest; + + @Column(name = "summary", nullable = false, length = 120) + private String summary; + + private AppVersion(AppPlatform platform, String version, boolean isLatest, String summary) { + this.platform = platform; + this.version = version; + this.isLatest = isLatest; + this.summary = summary; + } + + public static AppVersion create(AppPlatform platform, String version, String summary) { + return new AppVersion(platform, version, true, summary); + } + + public void markAsLatest() { + this.isLatest = true; + } + + public void markAsNotLatest() { + this.isLatest = false; + } + + public void updateSummary(String summary) { + this.summary = summary; + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/appversion/core/entity/AppVersionItem.java b/src/main/java/com/devkor/ifive/nadab/domain/appversion/core/entity/AppVersionItem.java new file mode 100644 index 0000000..f63f6fe --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/appversion/core/entity/AppVersionItem.java @@ -0,0 +1,57 @@ +package com.devkor.ifive.nadab.domain.appversion.core.entity; + +import com.devkor.ifive.nadab.global.shared.entity.AuditableEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table( + name = "app_version_items", + uniqueConstraints = { + @UniqueConstraint(name = "uk_app_version_items_order", columnNames = {"app_version_id", "display_order"}) + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class AppVersionItem extends AuditableEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn( + name = "app_version_id", + nullable = false, + foreignKey = @ForeignKey(name = "fk_app_version_items_app_version") + ) + private AppVersion appVersion; + + @Column(name = "title", nullable = false, length = 100) + private String title; + + @Column(name = "description", nullable = false, length = 500) + private String description; + + @Column(name = "display_order", nullable = false) + private Integer displayOrder; + + private AppVersionItem(AppVersion appVersion, String title, String description, Integer displayOrder) { + this.appVersion = appVersion; + this.title = title; + this.description = description; + this.displayOrder = displayOrder; + } + + public static AppVersionItem create(AppVersion appVersion, String title, String description, Integer displayOrder) { + return new AppVersionItem(appVersion, title, description, displayOrder); + } + + public void update(String title, String description, Integer displayOrder) { + this.title = title; + this.description = description; + this.displayOrder = displayOrder; + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/appversion/core/entity/UserAppVersionDismissal.java b/src/main/java/com/devkor/ifive/nadab/domain/appversion/core/entity/UserAppVersionDismissal.java new file mode 100644 index 0000000..277e3d4 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/appversion/core/entity/UserAppVersionDismissal.java @@ -0,0 +1,47 @@ +package com.devkor.ifive.nadab.domain.appversion.core.entity; + +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.global.shared.entity.CreatableEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table( + name = "user_app_version_dismissals", + uniqueConstraints = { + @UniqueConstraint(name = "uk_uavd_user_app_version", columnNames = {"user_id", "app_version_id"}) + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UserAppVersionDismissal extends CreatableEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn( + name = "user_id", + nullable = false, + foreignKey = @ForeignKey(name = "fk_uavd_user") + ) + private User user; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn( + name = "app_version_id", + nullable = false, + foreignKey = @ForeignKey(name = "fk_uavd_app_version") + ) + private AppVersion appVersion; + + public static UserAppVersionDismissal create(User user, AppVersion appVersion) { + UserAppVersionDismissal dismissal = new UserAppVersionDismissal(); + dismissal.user = user; + dismissal.appVersion = appVersion; + return dismissal; + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/appversion/core/repository/AppVersionItemRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/appversion/core/repository/AppVersionItemRepository.java new file mode 100644 index 0000000..664fc31 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/appversion/core/repository/AppVersionItemRepository.java @@ -0,0 +1,14 @@ +package com.devkor.ifive.nadab.domain.appversion.core.repository; + +import com.devkor.ifive.nadab.domain.appversion.core.entity.AppVersionItem; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface AppVersionItemRepository extends JpaRepository { + List findByAppVersionIdInOrderByDisplayOrderAsc(List appVersionIds); + + boolean existsByAppVersionIdAndDisplayOrder(Long appVersionId, Integer displayOrder); + + boolean existsByAppVersionIdAndDisplayOrderAndIdNot(Long appVersionId, Integer displayOrder, Long id); +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/appversion/core/repository/AppVersionRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/appversion/core/repository/AppVersionRepository.java new file mode 100644 index 0000000..dd437ff --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/appversion/core/repository/AppVersionRepository.java @@ -0,0 +1,16 @@ +package com.devkor.ifive.nadab.domain.appversion.core.repository; + +import com.devkor.ifive.nadab.domain.appversion.core.entity.AppVersion; +import com.devkor.ifive.nadab.domain.appversion.core.entity.AppPlatform; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface AppVersionRepository extends JpaRepository { + List findByIsLatestTrue(); + + Optional findByPlatformAndIsLatestTrue(AppPlatform platform); + + boolean existsByPlatformAndVersion(AppPlatform platform, String version); +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/appversion/core/repository/UserAppVersionDismissalRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/appversion/core/repository/UserAppVersionDismissalRepository.java new file mode 100644 index 0000000..2a50697 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/appversion/core/repository/UserAppVersionDismissalRepository.java @@ -0,0 +1,24 @@ +package com.devkor.ifive.nadab.domain.appversion.core.repository; + +import com.devkor.ifive.nadab.domain.appversion.core.entity.UserAppVersionDismissal; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface UserAppVersionDismissalRepository extends JpaRepository { + + boolean existsByUserIdAndAppVersionId(Long userId, Long appVersionId); + + @Query(""" + select d.appVersion.id + from UserAppVersionDismissal d + where d.user.id = :userId + and d.appVersion.id in :appVersionIds + """) + List findDismissedAppVersionIds( + @Param("userId") Long userId, + @Param("appVersionIds") List appVersionIds + ); +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/HomeController.java b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/HomeController.java index 89405f5..c32aa6e 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/HomeController.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/HomeController.java @@ -1,5 +1,7 @@ package com.devkor.ifive.nadab.domain.dailyreport.api; +import com.devkor.ifive.nadab.domain.appversion.application.AppVersionDismissalCommandService; +import com.devkor.ifive.nadab.domain.dailyreport.api.dto.request.HomeVersionDismissRequest; import com.devkor.ifive.nadab.domain.dailyreport.api.dto.response.HomeResponse; import com.devkor.ifive.nadab.domain.dailyreport.application.HomeQueryService; import com.devkor.ifive.nadab.global.core.response.ApiResponseDto; @@ -11,11 +13,14 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -26,6 +31,7 @@ public class HomeController { private final HomeQueryService homeQueryService; + private final AppVersionDismissalCommandService appVersionDismissalCommandService; @GetMapping @PreAuthorize("isAuthenticated()") @@ -40,6 +46,14 @@ public class HomeController { 3. 총 기록 일수: 실제 답변한 날짜의 총 개수 4. 친구 프로필: 나와 같은 오늘의 질문에 답변한 친구들의 프로필 사진 URL (최대 5개) 5. 친구 답변 수: 나와 같은 오늘의 질문에 답변한 친구의 총 수 + 6. 플랫폼별 최신 버전 정보 (android, ios) + - 앱 버전 ID + - 최신 버전 문자열 + - 업데이트 요약 문장 + - 업데이트 항목 목록 + - 업데이트 항목명 + - 업데이트 상세 설명 + - 다시 보지 않기 여부 ### 친구 프로필 조회 기준 - 나의 오늘의 질문과 동일한 질문에 답변한 친구만 표시 @@ -98,4 +112,32 @@ public ResponseEntity> getHomeData( HomeResponse response = homeQueryService.getHomeData(principal.getId()); return ApiResponseEntity.ok(response); } -} \ No newline at end of file + + @PostMapping("/version-dismissals") + @PreAuthorize("isAuthenticated()") + @Operation( + summary = "홈 업데이트 다시 보지 않기 저장", + description = """ + 사용자가 홈화면에 표시되는 앱 버전 업데이트 알림을 다시 보지 않도록 설정합니다. + + ### 요청 정보 + - appVersionId: 숨김 처리할 앱 버전 ID (홈화면 정보 조회 api에서 제공되는 앱 버전 ID 사용) + """, + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse(responseCode = "204", description = "저장 성공", content = @Content), + @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content), + @ApiResponse(responseCode = "404", description = """ + - ErrorCode: USER_NOT_FOUND - 사용자를 찾을 수 없음 + - ErrorCode: APP_VERSION_NOT_FOUND - 앱 버전을 찾을 수 없음 + """, content = @Content) + } + ) + public ResponseEntity> dismissHomeVersion( + @AuthenticationPrincipal UserPrincipal principal, + @Valid @RequestBody HomeVersionDismissRequest request + ) { + appVersionDismissalCommandService.dismiss(principal.getId(), request.appVersionId()); + return ApiResponseEntity.noContent(); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/request/HomeVersionDismissRequest.java b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/request/HomeVersionDismissRequest.java new file mode 100644 index 0000000..f1981bf --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/request/HomeVersionDismissRequest.java @@ -0,0 +1,12 @@ +package com.devkor.ifive.nadab.domain.dailyreport.api.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +@Schema(description = "홈 버전 업데이트 다시 보지 않기 요청") +public record HomeVersionDismissRequest( + @NotNull + @Schema(description = "숨김 처리할 앱 버전 ID", example = "1") + Long appVersionId +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/HomeLatestVersionResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/HomeLatestVersionResponse.java new file mode 100644 index 0000000..b9f7de8 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/HomeLatestVersionResponse.java @@ -0,0 +1,11 @@ +package com.devkor.ifive.nadab.domain.dailyreport.api.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "플랫폼별 최신 앱 버전") +public record HomeLatestVersionResponse( + HomePlatformVersionResponse ios, + + HomePlatformVersionResponse android +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/HomePlatformVersionResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/HomePlatformVersionResponse.java new file mode 100644 index 0000000..34653ac --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/HomePlatformVersionResponse.java @@ -0,0 +1,24 @@ +package com.devkor.ifive.nadab.domain.dailyreport.api.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +@Schema(description = "플랫폼별 최신 버전 정보") +public record HomePlatformVersionResponse( + @Schema(description = "앱 버전 ID", example = "1") + Long appVersionId, + + @Schema(description = "최신 앱 버전", example = "1.2.0") + String version, + + @Schema(description = "업데이트 요약 문장", example = "좋아요와 댓글로 마음을 전해요.") + String summary, + + @Schema(description = "업데이트 항목 목록") + List items, + + @Schema(description = "다시 보지 않기 여부", example = "false") + boolean dismissed +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/HomeResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/HomeResponse.java index 3c59b14..4f75b7e 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/HomeResponse.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/HomeResponse.java @@ -20,6 +20,9 @@ public record HomeResponse( List answeredFriendProfiles, @Schema(description = "나와 같은 오늘의 질문에 답변한 친구의 총 수", example = "8") - int answeredFriendCount + int answeredFriendCount, + + @Schema(description = "플랫폼별 최신 앱 버전") + HomeLatestVersionResponse latestVersion ) { -} \ No newline at end of file +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/HomeVersionItemResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/HomeVersionItemResponse.java new file mode 100644 index 0000000..cf06f42 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/HomeVersionItemResponse.java @@ -0,0 +1,13 @@ +package com.devkor.ifive.nadab.domain.dailyreport.api.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "업데이트 항목") +public record HomeVersionItemResponse( + @Schema(description = "업데이트 항목명", example = "월간 리포트") + String title, + + @Schema(description = "업데이트 상세 설명", example = "한 달의 기록을 한눈에 돌아볼 수 있어요.") + String description +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/application/HomeQueryService.java b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/application/HomeQueryService.java index a1d05f0..4f8324a 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/application/HomeQueryService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/application/HomeQueryService.java @@ -1,5 +1,7 @@ package com.devkor.ifive.nadab.domain.dailyreport.application; +import com.devkor.ifive.nadab.domain.appversion.application.AppVersionQueryService; +import com.devkor.ifive.nadab.domain.dailyreport.api.dto.response.HomeLatestVersionResponse; import com.devkor.ifive.nadab.domain.dailyreport.api.dto.response.HomeResponse; import com.devkor.ifive.nadab.domain.dailyreport.core.repository.AnswerEntryQueryRepository; import com.devkor.ifive.nadab.domain.question.core.entity.UserDailyQuestion; @@ -27,6 +29,7 @@ public class HomeQueryService { private final AnswerEntryQueryRepository answerEntryQueryRepository; private final UserDailyQuestionRepository userDailyQuestionRepository; private final ProfileImageUrlBuilder profileImageUrlBuilder; + private final AppVersionQueryService appVersionQueryService; public HomeResponse getHomeData(Long userId) { LocalDate today = TodayDateTimeProvider.getTodayDate(); @@ -71,12 +74,15 @@ public HomeResponse getHomeData(Long userId) { } // 7. 응답 생성 + HomeLatestVersionResponse latestVersion = appVersionQueryService.getHomeLatestVersion(userId); + return new HomeResponse( weeklyAnsweredDates, currentStreak, totalAnsweredDays, answeredFriendProfiles, - answeredFriendCount + answeredFriendCount, + latestVersion ); } @@ -105,4 +111,4 @@ private int calculateCurrentStreak(LocalDate today, List answerDates) return streak; } -} \ No newline at end of file +} diff --git a/src/main/java/com/devkor/ifive/nadab/global/core/response/ErrorCode.java b/src/main/java/com/devkor/ifive/nadab/global/core/response/ErrorCode.java index 3c40d99..bbaed66 100644 --- a/src/main/java/com/devkor/ifive/nadab/global/core/response/ErrorCode.java +++ b/src/main/java/com/devkor/ifive/nadab/global/core/response/ErrorCode.java @@ -416,7 +416,20 @@ public enum ErrorCode { DAILY_REPORT_LIKE_LIST_FORBIDDEN(HttpStatus.FORBIDDEN, "본인의 게시글 좋아요 리스트만 조회할 수 있습니다"), // 404 Not Found - LIKE_NOT_FOUND(HttpStatus.NOT_FOUND, "좋아요를 찾을 수 없습니다"); + LIKE_NOT_FOUND(HttpStatus.NOT_FOUND, "좋아요를 찾을 수 없습니다"), + + // ==================== APP_VERSION (앱 버전) ==================== + // 404 Not Found + APP_VERSION_NOT_FOUND(HttpStatus.NOT_FOUND, "앱 버전을 찾을 수 없습니다"), + APP_VERSION_ITEM_NOT_FOUND(HttpStatus.NOT_FOUND, "앱 버전 항목을 찾을 수 없습니다"), + + // 409 Conflict + APP_VERSION_ALREADY_EXISTS(HttpStatus.CONFLICT, "해당 플랫폼의 같은 버전이 이미 존재합니다"), + APP_VERSION_ITEM_DISPLAY_ORDER_DUPLICATED(HttpStatus.CONFLICT, "같은 버전 내 displayOrder가 중복됩니다"), + + // ==================== ADMIN (어드민) ==================== + // 401 Unauthorized + ADMIN_PAGE_INVALID_PASSWORD(HttpStatus.UNAUTHORIZED, "관리자 페이지 비밀번호가 올바르지 않습니다"); private final HttpStatus httpStatus; diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f05305a..a3a30f5 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -83,6 +83,12 @@ app: secure: false same-site: Lax +admin: + page: + password: ${ADMIN_PAGE_PASSWORD} + token-expiration-seconds: 43200 + cookie-name: admin_auth + api_prefix: /api/v1 firebase: @@ -95,4 +101,4 @@ openai: model: gpt-image-1.5 size: 1024x1024 quality: low - output-format: webp \ No newline at end of file + output-format: webp diff --git a/src/main/resources/db/migration/V20260527_1600__IS_create_app_versions_table.sql b/src/main/resources/db/migration/V20260527_1600__IS_create_app_versions_table.sql new file mode 100644 index 0000000..8e61573 --- /dev/null +++ b/src/main/resources/db/migration/V20260527_1600__IS_create_app_versions_table.sql @@ -0,0 +1,34 @@ +CREATE TABLE app_versions ( + id BIGSERIAL PRIMARY KEY, + platform VARCHAR(20) NOT NULL, + version VARCHAR(30) NOT NULL, + is_latest BOOLEAN NOT NULL, + summary VARCHAR(120) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uk_app_versions_platform_version UNIQUE (platform, version) +); + +CREATE TABLE app_version_items ( + id BIGSERIAL PRIMARY KEY, + app_version_id BIGINT NOT NULL, + title VARCHAR(100) NOT NULL, + description VARCHAR(500) NOT NULL, + display_order INT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT fk_app_version_items_app_version FOREIGN KEY (app_version_id) + REFERENCES app_versions(id) ON DELETE CASCADE, + CONSTRAINT uk_app_version_items_order UNIQUE (app_version_id, display_order) +); + +CREATE INDEX idx_app_version_items_app_version_id + ON app_version_items (app_version_id); + +CREATE UNIQUE INDEX uk_app_versions_platform_latest + ON app_versions (platform) + WHERE is_latest = true; + +INSERT INTO app_versions (platform, version, is_latest, summary) VALUES + ('IOS', '1.2.0', true, ''), + ('ANDROID', '1.2.0', true, ''); diff --git a/src/main/resources/db/migration/V20260530_2300__IS_create_user_app_version_dismissals_table.sql b/src/main/resources/db/migration/V20260530_2300__IS_create_user_app_version_dismissals_table.sql new file mode 100644 index 0000000..bbf424e --- /dev/null +++ b/src/main/resources/db/migration/V20260530_2300__IS_create_user_app_version_dismissals_table.sql @@ -0,0 +1,11 @@ +CREATE TABLE user_app_version_dismissals ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + app_version_id BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT fk_uavd_user FOREIGN KEY (user_id) + REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT fk_uavd_app_version FOREIGN KEY (app_version_id) + REFERENCES app_versions(id) ON DELETE CASCADE, + CONSTRAINT uk_uavd_user_app_version UNIQUE (user_id, app_version_id) +); diff --git a/src/main/resources/templates/admin/login.html b/src/main/resources/templates/admin/login.html new file mode 100644 index 0000000..0858d5c --- /dev/null +++ b/src/main/resources/templates/admin/login.html @@ -0,0 +1,164 @@ + + + + + + NADAB Admin Login + + + +
+

관리자 로그인

+

비밀번호를 입력해 버전 관리 페이지에 접근합니다.

+
+ + + +
+
+
+ + + + diff --git a/src/main/resources/templates/admin/version.html b/src/main/resources/templates/admin/version.html new file mode 100644 index 0000000..db82efe --- /dev/null +++ b/src/main/resources/templates/admin/version.html @@ -0,0 +1,735 @@ + + + + + + NADAB Admin Version + + + +
+
+

Admin Console

+ +
+ + + +
+

Create Version

+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+

Current Latest Versions

+
+
+
+ + + + + +