Skip to content

Commit 98b1b08

Browse files
authored
feat(admin): 앱 버전 수정 및 히스토리 조회 기능 추가 (#189)
* feat(admin): 앱 버전 수정과 순서 검증 추가 관리자 앱 버전 화면에서 최신 버전 문자열을 직접 수정할 수 있도록 API와 UI를 추가하고, version 입력을 major.minor.patch 형식으로 제한함. 같은 플랫폼의 기존 다른 버전보다 낮거나 같은 값은 생성·수정 모두에서 차단하도록 검증을 보강함 * feat(admin): 앱 버전 이력 조회 페이지 추가 앱 버전 히스토리 조회 API와 관리자 페이지를 추가하고, 플랫폼 필터와 latest 상태, summary, 공지 항목, 생성·수정 시각을 함께 확인하도록 구성함
1 parent ae0e604 commit 98b1b08

15 files changed

Lines changed: 1029 additions & 3 deletions

File tree

src/main/java/com/devkor/ifive/nadab/domain/admin/api/AdminPageController.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,9 @@ public String adminRoot() {
2020
public String adminVersionPage() {
2121
return "admin/version";
2222
}
23+
24+
@GetMapping("/admin/tabs/app-version-history")
25+
public String adminVersionHistoryPage() {
26+
return "admin/version-history";
27+
}
2328
}

src/main/java/com/devkor/ifive/nadab/domain/admin/api/AdminVersionController.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@
33
import com.devkor.ifive.nadab.domain.admin.api.dto.request.AdminVersionCreateRequest;
44
import com.devkor.ifive.nadab.domain.admin.api.dto.request.AdminVersionItemUpsertRequest;
55
import com.devkor.ifive.nadab.domain.admin.api.dto.request.AdminVersionSummaryUpdateRequest;
6+
import com.devkor.ifive.nadab.domain.admin.api.dto.request.AdminVersionUpdateRequest;
67
import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminLatestVersionsResponse;
8+
import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminVersionHistoryResponse;
79
import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminVersionCreateResponse;
810
import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminVersionItemCreateResponse;
11+
import com.devkor.ifive.nadab.domain.appversion.core.entity.AppPlatform;
912
import com.devkor.ifive.nadab.domain.admin.application.AdminVersionCommandService;
1013
import com.devkor.ifive.nadab.domain.admin.application.AdminVersionItemCommandService;
1114
import com.devkor.ifive.nadab.domain.admin.application.AdminVersionQueryService;
@@ -22,6 +25,7 @@
2225
import org.springframework.web.bind.annotation.RequestBody;
2326
import org.springframework.web.bind.annotation.PathVariable;
2427
import org.springframework.web.bind.annotation.RequestMapping;
28+
import org.springframework.web.bind.annotation.RequestParam;
2529
import org.springframework.web.bind.annotation.RestController;
2630

2731
@Hidden
@@ -39,6 +43,13 @@ public ResponseEntity<ApiResponseDto<AdminLatestVersionsResponse>> getLatestVers
3943
return ApiResponseEntity.ok(adminVersionQueryService.getLatestVersions());
4044
}
4145

46+
@GetMapping("/history")
47+
public ResponseEntity<ApiResponseDto<AdminVersionHistoryResponse>> getVersionHistory(
48+
@RequestParam(required = false) AppPlatform platform
49+
) {
50+
return ApiResponseEntity.ok(adminVersionQueryService.getVersionHistory(platform));
51+
}
52+
4253
@PostMapping
4354
public ResponseEntity<ApiResponseDto<AdminVersionCreateResponse>> createVersion(
4455
@RequestBody @Valid AdminVersionCreateRequest request
@@ -56,6 +67,15 @@ public ResponseEntity<ApiResponseDto<Void>> updateVersionSummary(
5667
return ApiResponseEntity.noContent();
5768
}
5869

70+
@PutMapping("/{appVersionId}/version")
71+
public ResponseEntity<ApiResponseDto<Void>> updateVersion(
72+
@PathVariable Long appVersionId,
73+
@RequestBody @Valid AdminVersionUpdateRequest request
74+
) {
75+
adminVersionCommandService.updateVersion(appVersionId, request.version());
76+
return ApiResponseEntity.noContent();
77+
}
78+
5979
@PostMapping("/{appVersionId}/items")
6080
public ResponseEntity<ApiResponseDto<AdminVersionItemCreateResponse>> createVersionItem(
6181
@PathVariable Long appVersionId,

src/main/java/com/devkor/ifive/nadab/domain/admin/api/dto/request/AdminVersionCreateRequest.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import com.devkor.ifive.nadab.domain.appversion.core.entity.AppPlatform;
44
import jakarta.validation.constraints.NotBlank;
55
import jakarta.validation.constraints.NotNull;
6+
import jakarta.validation.constraints.Pattern;
67
import jakarta.validation.constraints.Size;
78

89
public record AdminVersionCreateRequest(
@@ -11,6 +12,7 @@ public record AdminVersionCreateRequest(
1112

1213
@NotBlank(message = "버전은 필수입니다.")
1314
@Size(max = 30, message = "버전은 30자 이하여야 합니다.")
15+
@Pattern(regexp = "\\d+\\.\\d+\\.\\d+", message = "Version must use major.minor.patch format.")
1416
String version,
1517

1618
@NotNull(message = "요약은 null일 수 없습니다.")
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.devkor.ifive.nadab.domain.admin.api.dto.request;
2+
3+
import jakarta.validation.constraints.NotBlank;
4+
import jakarta.validation.constraints.Pattern;
5+
import jakarta.validation.constraints.Size;
6+
7+
public record AdminVersionUpdateRequest(
8+
@NotBlank(message = "Version is required.")
9+
@Size(max = 30, message = "Version must be 30 characters or less.")
10+
@Pattern(regexp = "\\d+\\.\\d+\\.\\d+", message = "Version must use major.minor.patch format.")
11+
String version
12+
) {
13+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package com.devkor.ifive.nadab.domain.admin.api.dto.response;
2+
3+
import com.devkor.ifive.nadab.domain.appversion.core.entity.AppPlatform;
4+
5+
import java.time.OffsetDateTime;
6+
import java.util.List;
7+
8+
public record AdminVersionHistoryItemResponse(
9+
Long id,
10+
AppPlatform platform,
11+
String version,
12+
Boolean isLatest,
13+
String summary,
14+
OffsetDateTime createdAt,
15+
OffsetDateTime updatedAt,
16+
List<AdminVersionItemResponse> items
17+
) {
18+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package com.devkor.ifive.nadab.domain.admin.api.dto.response;
2+
3+
import java.util.List;
4+
5+
public record AdminVersionHistoryResponse(
6+
List<AdminVersionHistoryItemResponse> versions
7+
) {
8+
}

src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminVersionCommandService.java

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,25 +4,39 @@
44
import com.devkor.ifive.nadab.domain.appversion.core.entity.AppVersion;
55
import com.devkor.ifive.nadab.domain.appversion.core.repository.AppVersionRepository;
66
import com.devkor.ifive.nadab.global.core.response.ErrorCode;
7+
import com.devkor.ifive.nadab.global.exception.BadRequestException;
78
import com.devkor.ifive.nadab.global.exception.ConflictException;
89
import com.devkor.ifive.nadab.global.exception.NotFoundException;
910
import lombok.RequiredArgsConstructor;
1011
import org.springframework.dao.DataIntegrityViolationException;
1112
import org.springframework.stereotype.Service;
1213
import org.springframework.transaction.annotation.Transactional;
1314

15+
import java.math.BigInteger;
16+
import java.util.Comparator;
17+
import java.util.List;
18+
import java.util.regex.Pattern;
19+
1420
@Service
1521
@RequiredArgsConstructor
1622
@Transactional
1723
public class AdminVersionCommandService {
1824

25+
private static final Pattern SEMANTIC_VERSION_PATTERN = Pattern.compile("\\d+\\.\\d+\\.\\d+");
26+
1927
private final AppVersionRepository appVersionRepository;
2028

2129
public Long createVersion(AdminVersionCreateRequest request) {
2230
if (appVersionRepository.existsByPlatformAndVersion(request.platform(), request.version())) {
2331
throw new ConflictException(ErrorCode.APP_VERSION_ALREADY_EXISTS);
2432
}
2533

34+
SemanticVersion newVersion = SemanticVersion.parse(request.version());
35+
validateVersionGreaterThanOtherVersions(
36+
newVersion,
37+
appVersionRepository.findByPlatform(request.platform())
38+
);
39+
2640
appVersionRepository.findByPlatformAndIsLatestTrue(request.platform())
2741
.ifPresent(AppVersion::markAsNotLatest);
2842
appVersionRepository.flush();
@@ -45,4 +59,73 @@ public void updateSummary(Long appVersionId, String summary) {
4559
.orElseThrow(() -> new NotFoundException(ErrorCode.APP_VERSION_NOT_FOUND));
4660
appVersion.updateSummary(summary);
4761
}
62+
63+
public void updateVersion(Long appVersionId, String version) {
64+
AppVersion appVersion = appVersionRepository.findById(appVersionId)
65+
.orElseThrow(() -> new NotFoundException(ErrorCode.APP_VERSION_NOT_FOUND));
66+
67+
if (appVersionRepository.existsByPlatformAndVersionAndIdNot(
68+
appVersion.getPlatform(), version, appVersionId
69+
)) {
70+
throw new ConflictException(ErrorCode.APP_VERSION_ALREADY_EXISTS);
71+
}
72+
73+
SemanticVersion newVersion = SemanticVersion.parse(version);
74+
validateVersionGreaterThanOtherVersions(
75+
newVersion,
76+
appVersionRepository.findByPlatformAndIdNot(appVersion.getPlatform(), appVersionId)
77+
);
78+
79+
try {
80+
appVersion.updateVersion(version);
81+
appVersionRepository.flush();
82+
} catch (DataIntegrityViolationException e) {
83+
throw new ConflictException(ErrorCode.APP_VERSION_ALREADY_EXISTS);
84+
}
85+
}
86+
87+
private void validateVersionGreaterThanOtherVersions(
88+
SemanticVersion newVersion,
89+
List<AppVersion> otherAppVersions
90+
) {
91+
otherAppVersions.stream()
92+
.map(versionEntity -> SemanticVersion.parse(versionEntity.getVersion()))
93+
.max(Comparator.naturalOrder())
94+
.filter(maxVersion -> newVersion.compareTo(maxVersion) <= 0)
95+
.ifPresent(maxVersion -> {
96+
throw new ConflictException(ErrorCode.APP_VERSION_MUST_BE_GREATER_THAN_EXISTING);
97+
});
98+
}
99+
100+
private record SemanticVersion(BigInteger major, BigInteger minor, BigInteger patch)
101+
implements Comparable<SemanticVersion> {
102+
103+
private static SemanticVersion parse(String version) {
104+
if (!SEMANTIC_VERSION_PATTERN.matcher(version).matches()) {
105+
throw new BadRequestException(ErrorCode.APP_VERSION_INVALID_FORMAT);
106+
}
107+
108+
String[] parts = version.split("\\.");
109+
return new SemanticVersion(
110+
new BigInteger(parts[0]),
111+
new BigInteger(parts[1]),
112+
new BigInteger(parts[2])
113+
);
114+
}
115+
116+
@Override
117+
public int compareTo(SemanticVersion other) {
118+
int majorComparison = major.compareTo(other.major);
119+
if (majorComparison != 0) {
120+
return majorComparison;
121+
}
122+
123+
int minorComparison = minor.compareTo(other.minor);
124+
if (minorComparison != 0) {
125+
return minorComparison;
126+
}
127+
128+
return patch.compareTo(other.patch);
129+
}
130+
}
48131
}

src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminVersionQueryService.java

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
package com.devkor.ifive.nadab.domain.admin.application;
22

33
import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminLatestVersionsResponse;
4+
import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminVersionHistoryItemResponse;
5+
import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminVersionHistoryResponse;
46
import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminVersionItemResponse;
57
import com.devkor.ifive.nadab.domain.admin.api.dto.response.AdminVersionResponse;
8+
import com.devkor.ifive.nadab.domain.appversion.core.entity.AppPlatform;
69
import com.devkor.ifive.nadab.domain.appversion.core.entity.AppVersion;
710
import com.devkor.ifive.nadab.domain.appversion.core.entity.AppVersionItem;
811
import com.devkor.ifive.nadab.domain.appversion.core.repository.AppVersionItemRepository;
@@ -61,4 +64,46 @@ public AdminLatestVersionsResponse getLatestVersions() {
6164

6265
return new AdminLatestVersionsResponse(versions);
6366
}
67+
68+
public AdminVersionHistoryResponse getVersionHistory(AppPlatform platform) {
69+
List<AppVersion> appVersions = platform == null
70+
? appVersionRepository.findAllByOrderByCreatedAtDescIdDesc()
71+
: appVersionRepository.findByPlatformOrderByCreatedAtDescIdDesc(platform);
72+
List<Long> appVersionIds = appVersions.stream()
73+
.map(AppVersion::getId)
74+
.toList();
75+
76+
List<AppVersionItem> items = appVersionIds.isEmpty()
77+
? List.of()
78+
: appVersionItemRepository.findByAppVersionIdInOrderByDisplayOrderAsc(appVersionIds);
79+
80+
Map<Long, List<AdminVersionItemResponse>> itemsByVersionId = items.stream()
81+
.collect(Collectors.groupingBy(
82+
item -> item.getAppVersion().getId(),
83+
Collectors.mapping(
84+
item -> new AdminVersionItemResponse(
85+
item.getId(),
86+
item.getTitle(),
87+
item.getDescription(),
88+
item.getDisplayOrder()
89+
),
90+
Collectors.toList()
91+
)
92+
));
93+
94+
List<AdminVersionHistoryItemResponse> versions = appVersions.stream()
95+
.map(version -> new AdminVersionHistoryItemResponse(
96+
version.getId(),
97+
version.getPlatform(),
98+
version.getVersion(),
99+
version.getIsLatest(),
100+
version.getSummary(),
101+
version.getCreatedAt(),
102+
version.getUpdatedAt(),
103+
itemsByVersionId.getOrDefault(version.getId(), List.of())
104+
))
105+
.toList();
106+
107+
return new AdminVersionHistoryResponse(versions);
108+
}
64109
}

src/main/java/com/devkor/ifive/nadab/domain/appversion/core/entity/AppVersion.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,4 +56,8 @@ public void markAsNotLatest() {
5656
public void updateSummary(String summary) {
5757
this.summary = summary;
5858
}
59+
60+
public void updateVersion(String version) {
61+
this.version = version;
62+
}
5963
}

src/main/java/com/devkor/ifive/nadab/domain/appversion/core/repository/AppVersionRepository.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,14 @@ public interface AppVersionRepository extends JpaRepository<AppVersion, Long> {
1313
Optional<AppVersion> findByPlatformAndIsLatestTrue(AppPlatform platform);
1414

1515
boolean existsByPlatformAndVersion(AppPlatform platform, String version);
16+
17+
boolean existsByPlatformAndVersionAndIdNot(AppPlatform platform, String version, Long id);
18+
19+
List<AppVersion> findByPlatform(AppPlatform platform);
20+
21+
List<AppVersion> findByPlatformAndIdNot(AppPlatform platform, Long id);
22+
23+
List<AppVersion> findAllByOrderByCreatedAtDescIdDesc();
24+
25+
List<AppVersion> findByPlatformOrderByCreatedAtDescIdDesc(AppPlatform platform);
1626
}

0 commit comments

Comments
 (0)