pc 앱 배포,다운로드 기능
This commit is contained in:
@@ -1,12 +1,19 @@
|
||||
package com.goldenchart.controller;
|
||||
|
||||
import com.goldenchart.dto.DesktopAppBuildStatusDto;
|
||||
import com.goldenchart.dto.DesktopAppBuildTriggerDto;
|
||||
import com.goldenchart.dto.DesktopAppReleaseInfoDto;
|
||||
import com.goldenchart.service.DesktopAppBuildService;
|
||||
import com.goldenchart.service.DesktopAppReleaseService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/desktop-app")
|
||||
@@ -14,12 +21,27 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
public class DesktopAppReleaseController {
|
||||
|
||||
private final DesktopAppReleaseService releaseService;
|
||||
private final DesktopAppBuildService buildService;
|
||||
|
||||
@GetMapping("/info")
|
||||
public DesktopAppReleaseInfoDto info(HttpServletRequest request) {
|
||||
return releaseService.getInfo(resolvePublicBaseUrl(request));
|
||||
}
|
||||
|
||||
@GetMapping("/build/status")
|
||||
public DesktopAppBuildStatusDto buildStatus() {
|
||||
return buildService.getStatus();
|
||||
}
|
||||
|
||||
@PostMapping("/build")
|
||||
public DesktopAppBuildTriggerDto triggerBuild(
|
||||
@RequestHeader(value = "X-User-Id", required = false) Long userId) {
|
||||
if (userId == null) {
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "관리자 로그인이 필요합니다.");
|
||||
}
|
||||
return buildService.triggerBuild(userId);
|
||||
}
|
||||
|
||||
private static String resolvePublicBaseUrl(HttpServletRequest request) {
|
||||
String forwardedProto = request.getHeader("X-Forwarded-Proto");
|
||||
String forwardedHost = request.getHeader("X-Forwarded-Host");
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.goldenchart.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
public class DesktopAppBuildStatusDto {
|
||||
/** Jenkins 연동 활성 여부 */
|
||||
private boolean jenkinsEnabled;
|
||||
/** 현재 빌드 진행 중 */
|
||||
private boolean building;
|
||||
/** idle | queued | building | success | failure | disabled | error */
|
||||
private String status;
|
||||
private Integer buildNumber;
|
||||
private String message;
|
||||
private String buildUrl;
|
||||
private Long startedAtMs;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.goldenchart.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
public class DesktopAppBuildTriggerDto {
|
||||
private boolean accepted;
|
||||
private Integer buildNumber;
|
||||
private String message;
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package com.goldenchart.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.goldenchart.dto.DesktopAppBuildStatusDto;
|
||||
import com.goldenchart.dto.DesktopAppBuildTriggerDto;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import org.springframework.web.reactive.function.client.WebClientResponseException;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.web.util.UriUtils;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class DesktopAppBuildService {
|
||||
|
||||
private final WebClient.Builder webClientBuilder;
|
||||
private final RolePermissionService rolePermissionService;
|
||||
|
||||
@Value("${goldenchart.desktop-app.jenkins.enabled:true}")
|
||||
private boolean jenkinsEnabled;
|
||||
|
||||
@Value("${goldenchart.desktop-app.jenkins.url:http://127.0.0.1:8090}")
|
||||
private String jenkinsUrl;
|
||||
|
||||
@Value("${goldenchart.desktop-app.jenkins.job:goldenChart-Desktop-Pipeline}")
|
||||
private String jenkinsJob;
|
||||
|
||||
@Value("${goldenchart.desktop-app.jenkins.user:}")
|
||||
private String jenkinsUser;
|
||||
|
||||
@Value("${goldenchart.desktop-app.jenkins.token:}")
|
||||
private String jenkinsToken;
|
||||
|
||||
@Value("${goldenchart.desktop-app.git-bare-dir:}")
|
||||
private String gitBareDir;
|
||||
|
||||
@Value("${goldenchart.desktop-app.work-tree:}")
|
||||
private String workTree;
|
||||
|
||||
public DesktopAppBuildStatusDto getStatus() {
|
||||
if (!jenkinsEnabled) {
|
||||
return DesktopAppBuildStatusDto.builder()
|
||||
.jenkinsEnabled(false)
|
||||
.building(false)
|
||||
.status("disabled")
|
||||
.message("Jenkins 연동이 비활성화되어 있습니다.")
|
||||
.build();
|
||||
}
|
||||
try {
|
||||
return fetchLastBuildStatus();
|
||||
} catch (Exception e) {
|
||||
log.warn("[desktop-build] status failed: {}", e.getMessage());
|
||||
return DesktopAppBuildStatusDto.builder()
|
||||
.jenkinsEnabled(true)
|
||||
.building(false)
|
||||
.status("error")
|
||||
.message("Jenkins 상태 조회 실패: " + e.getMessage())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
public DesktopAppBuildTriggerDto triggerBuild(Long userId) {
|
||||
rolePermissionService.requireAdmin(userId);
|
||||
if (!jenkinsEnabled) {
|
||||
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "Jenkins 연동이 비활성화되어 있습니다.");
|
||||
}
|
||||
|
||||
syncGitLatest();
|
||||
|
||||
DesktopAppBuildStatusDto current = fetchLastBuildStatus();
|
||||
if (current.isBuilding()) {
|
||||
return DesktopAppBuildTriggerDto.builder()
|
||||
.accepted(false)
|
||||
.buildNumber(current.getBuildNumber())
|
||||
.message("이미 빌드가 진행 중입니다 (#" + current.getBuildNumber() + ").")
|
||||
.build();
|
||||
}
|
||||
|
||||
triggerJenkinsBuild();
|
||||
DesktopAppBuildStatusDto after = fetchLastBuildStatus();
|
||||
return DesktopAppBuildTriggerDto.builder()
|
||||
.accepted(true)
|
||||
.buildNumber(after.getBuildNumber())
|
||||
.message("Jenkins에서 macOS·Windows 데스크톱 앱 빌드를 시작했습니다. 완료 후 설치 파일이 자동으로 반영됩니다.")
|
||||
.build();
|
||||
}
|
||||
|
||||
private DesktopAppBuildStatusDto fetchLastBuildStatus() {
|
||||
WebClient client = jenkinsClient();
|
||||
String jobPath = jobApiPath();
|
||||
JsonNode job = client.get()
|
||||
.uri(jobPath + "/api/json?tree=lastBuild[number,building,result,url,timestamp]")
|
||||
.retrieve()
|
||||
.bodyToMono(JsonNode.class)
|
||||
.block(Duration.ofSeconds(15));
|
||||
|
||||
if (job == null || job.path("lastBuild").isMissingNode() || job.path("lastBuild").isNull()) {
|
||||
return DesktopAppBuildStatusDto.builder()
|
||||
.jenkinsEnabled(true)
|
||||
.building(false)
|
||||
.status("idle")
|
||||
.message("아직 빌드 이력이 없습니다.")
|
||||
.build();
|
||||
}
|
||||
|
||||
JsonNode last = job.path("lastBuild");
|
||||
boolean building = last.path("building").asBoolean(false);
|
||||
String result = textOrNull(last.path("result"));
|
||||
int number = last.path("number").asInt();
|
||||
String url = textOrNull(last.path("url"));
|
||||
Long startedAt = last.path("timestamp").isNumber() ? last.path("timestamp").asLong() : null;
|
||||
|
||||
String status;
|
||||
String message;
|
||||
if (building) {
|
||||
status = "building";
|
||||
message = "빌드 진행 중… (#" + number + ")";
|
||||
} else if (result == null) {
|
||||
status = "queued";
|
||||
message = "빌드 대기 중… (#" + number + ")";
|
||||
} else {
|
||||
status = result.toLowerCase(Locale.ROOT);
|
||||
message = switch (status) {
|
||||
case "success" -> "최근 빌드 성공 (#" + number + ")";
|
||||
case "failure" -> "최근 빌드 실패 (#" + number + ")";
|
||||
case "aborted" -> "최근 빌드 중단 (#" + number + ")";
|
||||
default -> "최근 빌드: " + result + " (#" + number + ")";
|
||||
};
|
||||
}
|
||||
|
||||
return DesktopAppBuildStatusDto.builder()
|
||||
.jenkinsEnabled(true)
|
||||
.building(building)
|
||||
.status(status)
|
||||
.buildNumber(number)
|
||||
.message(message)
|
||||
.buildUrl(url)
|
||||
.startedAtMs(startedAt)
|
||||
.build();
|
||||
}
|
||||
|
||||
private void triggerJenkinsBuild() {
|
||||
WebClient client = jenkinsClient();
|
||||
String buildUrl = jobApiPath() + "/build";
|
||||
try {
|
||||
postBuild(client, buildUrl, null);
|
||||
} catch (WebClientResponseException.Forbidden e) {
|
||||
Map<String, String> crumb = fetchCrumb(client);
|
||||
postBuild(client, buildUrl, crumb);
|
||||
}
|
||||
}
|
||||
|
||||
private void postBuild(WebClient client, String buildUrl, Map<String, String> crumb) {
|
||||
var spec = client.post().uri(buildUrl);
|
||||
if (crumb != null) {
|
||||
spec = spec.header(crumb.get("field"), crumb.get("value"));
|
||||
}
|
||||
spec.retrieve()
|
||||
.toBodilessEntity()
|
||||
.block(Duration.ofSeconds(20));
|
||||
}
|
||||
|
||||
private Map<String, String> fetchCrumb(WebClient client) {
|
||||
JsonNode node = client.get()
|
||||
.uri("/crumbIssuer/api/json")
|
||||
.retrieve()
|
||||
.bodyToMono(JsonNode.class)
|
||||
.block(Duration.ofSeconds(10));
|
||||
if (node == null) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Jenkins CSRF crumb 조회 실패");
|
||||
}
|
||||
return Map.of(
|
||||
"field", node.path("crumbRequestField").asText("Jenkins-Crumb"),
|
||||
"value", node.path("crumb").asText()
|
||||
);
|
||||
}
|
||||
|
||||
private WebClient jenkinsClient() {
|
||||
WebClient.Builder builder = webClientBuilder
|
||||
.baseUrl(normalizeBase(jenkinsUrl))
|
||||
.defaultHeader("Accept", MediaType.APPLICATION_JSON_VALUE);
|
||||
if (StringUtils.hasText(jenkinsUser) && StringUtils.hasText(jenkinsToken)) {
|
||||
builder.defaultHeaders(h -> h.setBasicAuth(jenkinsUser.trim(), jenkinsToken.trim()));
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
private String jobApiPath() {
|
||||
String encoded = UriUtils.encodePathSegment(jenkinsJob.trim(), StandardCharsets.UTF_8);
|
||||
return "/job/" + encoded;
|
||||
}
|
||||
|
||||
private static String normalizeBase(String url) {
|
||||
if (!StringUtils.hasText(url)) return "http://127.0.0.1:8090";
|
||||
return url.trim().replaceAll("/+$", "");
|
||||
}
|
||||
|
||||
private static String textOrNull(JsonNode node) {
|
||||
if (node == null || node.isNull() || node.isMissingNode()) return null;
|
||||
String t = node.asText(null);
|
||||
return t == null || t.isBlank() ? null : t;
|
||||
}
|
||||
|
||||
private void syncGitLatest() {
|
||||
if (!StringUtils.hasText(gitBareDir)) {
|
||||
log.debug("[desktop-build] git-bare-dir 미설정 — Jenkins SCM checkout에 위임");
|
||||
return;
|
||||
}
|
||||
List<String[]> commands = new ArrayList<>();
|
||||
commands.add(new String[]{"fetch", "origin", "main:main"});
|
||||
if (StringUtils.hasText(workTree)) {
|
||||
commands.add(new String[]{"checkout", "-f", "main"});
|
||||
}
|
||||
for (String[] args : commands) {
|
||||
runGit(args);
|
||||
}
|
||||
}
|
||||
|
||||
private void runGit(String[] args) {
|
||||
List<String> cmd = new ArrayList<>();
|
||||
cmd.add("git");
|
||||
cmd.add("--git-dir=" + gitBareDir.trim());
|
||||
if (StringUtils.hasText(workTree)) {
|
||||
cmd.add("--work-tree=" + workTree.trim());
|
||||
}
|
||||
for (String a : args) cmd.add(a);
|
||||
try {
|
||||
ProcessBuilder pb = new ProcessBuilder(cmd);
|
||||
pb.redirectErrorStream(true);
|
||||
Process p = pb.start();
|
||||
int code = p.waitFor();
|
||||
if (code != 0) {
|
||||
log.warn("[desktop-build] git {} → exit {}", String.join(" ", args), code);
|
||||
} else {
|
||||
log.info("[desktop-build] git {} OK", String.join(" ", args));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[desktop-build] git {} failed: {}", String.join(" ", args), e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -116,6 +116,14 @@ goldenchart:
|
||||
release-dir: ${GC_DESKTOP_APP_RELEASE_DIR:data/desktop-releases}
|
||||
version: ${GC_DESKTOP_APP_VERSION:}
|
||||
public-base-url: ${GC_DESKTOP_APP_PUBLIC_BASE_URL:http://exdev.co.kr}
|
||||
git-bare-dir: ${GC_GIT_BARE_DIR:}
|
||||
work-tree: ${GC_WORK_TREE:}
|
||||
jenkins:
|
||||
enabled: ${GC_DESKTOP_JENKINS_ENABLED:true}
|
||||
url: ${GC_DESKTOP_JENKINS_URL:http://127.0.0.1:8090}
|
||||
job: ${GC_DESKTOP_JENKINS_JOB:goldenChart-Desktop-Pipeline}
|
||||
user: ${GC_DESKTOP_JENKINS_USER:}
|
||||
token: ${GC_DESKTOP_JENKINS_TOKEN:}
|
||||
cors:
|
||||
allowed-origins:
|
||||
- http://localhost:5173
|
||||
|
||||
Reference in New Issue
Block a user