pc 앱 배포,다운로드 기능

This commit is contained in:
Macbook
2026-06-14 10:33:53 +09:00
parent 92f67580e3
commit 407e746822
14 changed files with 640 additions and 86 deletions
@@ -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