feat(wing): Wing 데모 사이트 프록시 API + 복수 Google Client ID 지원
- WingAisController: AIS 선박 위치 조회 프록시 (bbox 필터링 포함) - WingDataController: 해역/케이블 정적 GeoJSON 데이터 서빙 - GoogleTokenVerifier: app.google.client-ids 복수 audience 지원 - wing-data/: zones, chinese-permitted GeoJSON 데이터 파일 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
부모
cf9b4d118b
커밋
69de3f9ae7
@ -8,7 +8,9 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@ -18,12 +20,26 @@ public class GoogleTokenVerifier {
|
||||
private final String allowedEmailDomain;
|
||||
|
||||
public GoogleTokenVerifier(
|
||||
@Value("${app.google.client-id}") String clientId,
|
||||
@Value("${app.google.client-ids:}") String clientIdsCsv,
|
||||
@Value("${app.google.client-id:}") String clientId,
|
||||
@Value("${app.allowed-email-domain}") String allowedEmailDomain
|
||||
) {
|
||||
List<String> audiences = new ArrayList<>();
|
||||
if (clientIdsCsv != null && !clientIdsCsv.isBlank()) {
|
||||
for (String part : clientIdsCsv.split(",")) {
|
||||
String trimmed = part == null ? "" : part.trim();
|
||||
if (!trimmed.isEmpty()) audiences.add(trimmed);
|
||||
}
|
||||
}
|
||||
if (audiences.isEmpty() && clientId != null && !clientId.isBlank()) {
|
||||
audiences.add(clientId.trim());
|
||||
}
|
||||
if (audiences.isEmpty()) {
|
||||
log.warn("Google client id is not configured (app.google.client-id / app.google.client-ids empty). Google login will fail.");
|
||||
}
|
||||
this.verifier = new GoogleIdTokenVerifier.Builder(
|
||||
new NetHttpTransport(), GsonFactory.getDefaultInstance())
|
||||
.setAudience(Collections.singletonList(clientId))
|
||||
.setAudience(audiences.isEmpty() ? Collections.emptyList() : audiences)
|
||||
.build();
|
||||
this.allowedEmailDomain = allowedEmailDomain;
|
||||
}
|
||||
|
||||
215
src/main/java/com/gcsc/guide/controller/WingAisController.java
Normal file
215
src/main/java/com/gcsc/guide/controller/WingAisController.java
Normal file
@ -0,0 +1,215 @@
|
||||
package com.gcsc.guide.controller;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
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.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/wing/ais-target")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "WING · AIS", description = "WING demo AIS proxy (JWT required)")
|
||||
public class WingAisController {
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@Value("${app.wing.ais.upstream-base:http://211.208.115.83:8041}")
|
||||
private String upstreamBase;
|
||||
|
||||
@Value("${app.wing.ais.timeout-ms:20000}")
|
||||
private long timeoutMs;
|
||||
|
||||
private HttpClient httpClient;
|
||||
|
||||
@PostConstruct
|
||||
void initHttpClient() {
|
||||
this.httpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(5))
|
||||
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||
.build();
|
||||
}
|
||||
|
||||
private record Bbox(double lonMin, double latMin, double lonMax, double latMax) {
|
||||
}
|
||||
|
||||
@GetMapping("/search")
|
||||
public ResponseEntity<?> search(
|
||||
@RequestParam(name = "minutes") String minutesRaw,
|
||||
@RequestParam(name = "bbox", required = false) String bboxRaw,
|
||||
@RequestParam(name = "centerLon", required = false) Double centerLon,
|
||||
@RequestParam(name = "centerLat", required = false) Double centerLat,
|
||||
@RequestParam(name = "radiusMeters", required = false) Double radiusMeters
|
||||
) {
|
||||
Integer minutes = parseMinutes(minutesRaw);
|
||||
if (minutes == null) {
|
||||
return error(HttpStatus.BAD_REQUEST, "invalid minutes", "BAD_REQUEST");
|
||||
}
|
||||
|
||||
Bbox bbox = parseBbox(bboxRaw);
|
||||
if (bboxRaw != null && bbox == null) {
|
||||
return error(HttpStatus.BAD_REQUEST, "invalid bbox", "BAD_REQUEST");
|
||||
}
|
||||
|
||||
URI upstreamUrl = buildUpstreamUrl(minutes, centerLon, centerLat, radiusMeters);
|
||||
HttpRequest req = HttpRequest.newBuilder(upstreamUrl)
|
||||
.timeout(Duration.ofMillis(timeoutMs))
|
||||
.header("accept", "application/json")
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
int status;
|
||||
String body;
|
||||
try {
|
||||
HttpResponse<String> res = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
status = res.statusCode();
|
||||
body = res.body() == null ? "" : res.body();
|
||||
} catch (java.net.http.HttpTimeoutException e) {
|
||||
log.warn("AIS upstream timeout ({}ms): {}", timeoutMs, upstreamUrl);
|
||||
return error(HttpStatus.GATEWAY_TIMEOUT, "upstream timeout (" + timeoutMs + "ms)", "UPSTREAM_TIMEOUT");
|
||||
} catch (Exception e) {
|
||||
log.warn("AIS upstream fetch failed: {} ({})", upstreamUrl, e.toString());
|
||||
return error(HttpStatus.BAD_GATEWAY, "upstream fetch failed", "UPSTREAM_FETCH_FAILED");
|
||||
}
|
||||
|
||||
if (status < 200 || status >= 300) {
|
||||
log.warn("AIS upstream error: status={} url={}", status, upstreamUrl);
|
||||
return error(HttpStatus.BAD_GATEWAY, "upstream error", "UPSTREAM");
|
||||
}
|
||||
|
||||
// Fast path: no bbox requested, proxy raw payload.
|
||||
if (bbox == null) {
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(body);
|
||||
}
|
||||
|
||||
try {
|
||||
Map<String, Object> json = objectMapper.readValue(body, new TypeReference<>() {
|
||||
});
|
||||
|
||||
Object dataObj = json.get("data");
|
||||
List<?> rows = dataObj instanceof List<?> l ? l : List.of();
|
||||
List<Object> filtered = new ArrayList<>(rows.size());
|
||||
for (Object row : rows) {
|
||||
if (inBbox(row, bbox)) {
|
||||
filtered.add(row);
|
||||
}
|
||||
}
|
||||
|
||||
json.put("data", filtered);
|
||||
|
||||
Object msgObj = json.get("message");
|
||||
String msg = msgObj instanceof String s ? s : "";
|
||||
String suffix = " (bbox: " + filtered.size() + "/" + rows.size() + ")";
|
||||
json.put("message", (msg + suffix).trim());
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(json);
|
||||
} catch (Exception e) {
|
||||
log.warn("AIS upstream JSON parse/filter failed: {}", e.toString());
|
||||
return error(HttpStatus.BAD_GATEWAY, "upstream invalid json", "UPSTREAM_INVALID_JSON");
|
||||
}
|
||||
}
|
||||
|
||||
private Integer parseMinutes(String raw) {
|
||||
if (raw == null) return null;
|
||||
int minutes;
|
||||
try {
|
||||
minutes = Integer.parseInt(raw);
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
if (minutes <= 0 || minutes > 60 * 24) return null;
|
||||
return minutes;
|
||||
}
|
||||
|
||||
private Bbox parseBbox(String raw) {
|
||||
if (raw == null || raw.isBlank()) return null;
|
||||
String[] parts = raw.split(",");
|
||||
if (parts.length != 4) return null;
|
||||
Double lonMin = toDouble(parts[0]);
|
||||
Double latMin = toDouble(parts[1]);
|
||||
Double lonMax = toDouble(parts[2]);
|
||||
Double latMax = toDouble(parts[3]);
|
||||
if (lonMin == null || latMin == null || lonMax == null || latMax == null) return null;
|
||||
|
||||
boolean ok =
|
||||
lonMin >= -180 && lonMax <= 180 &&
|
||||
latMin >= -90 && latMax <= 90 &&
|
||||
lonMin < lonMax &&
|
||||
latMin < latMax;
|
||||
if (!ok) return null;
|
||||
return new Bbox(lonMin, latMin, lonMax, latMax);
|
||||
}
|
||||
|
||||
private boolean inBbox(Object row, Bbox bbox) {
|
||||
if (!(row instanceof Map<?, ?> m)) return false;
|
||||
Double lon = toDouble(m.get("lon"));
|
||||
Double lat = toDouble(m.get("lat"));
|
||||
if (lon == null || lat == null) return false;
|
||||
return lon >= bbox.lonMin && lon <= bbox.lonMax && lat >= bbox.latMin && lat <= bbox.latMax;
|
||||
}
|
||||
|
||||
private Double toDouble(Object value) {
|
||||
if (value == null) return null;
|
||||
if (value instanceof Number n) return n.doubleValue();
|
||||
if (value instanceof String s) {
|
||||
String t = s.trim();
|
||||
if (t.isEmpty()) return null;
|
||||
try {
|
||||
return Double.parseDouble(t);
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private URI buildUpstreamUrl(int minutes, Double centerLon, Double centerLat, Double radiusMeters) {
|
||||
String base = upstreamBase == null ? "" : upstreamBase.trim();
|
||||
if (base.endsWith("/")) base = base.substring(0, base.length() - 1);
|
||||
StringBuilder sb = new StringBuilder(base);
|
||||
sb.append("/snp-api/api/ais-target/search");
|
||||
sb.append("?minutes=").append(minutes);
|
||||
|
||||
// Upstream supports center/radius filtering; bbox is ignored (filtered server-side here).
|
||||
if (centerLon != null && Double.isFinite(centerLon)) sb.append("¢erLon=").append(centerLon);
|
||||
if (centerLat != null && Double.isFinite(centerLat)) sb.append("¢erLat=").append(centerLat);
|
||||
if (radiusMeters != null && Double.isFinite(radiusMeters)) sb.append("&radiusMeters=").append(radiusMeters);
|
||||
|
||||
return URI.create(sb.toString());
|
||||
}
|
||||
|
||||
private ResponseEntity<Map<String, Object>> error(HttpStatus status, String message, String errorCode) {
|
||||
return ResponseEntity.status(status)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(Map.of(
|
||||
"success", false,
|
||||
"message", message,
|
||||
"data", List.of(),
|
||||
"errorCode", errorCode
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,57 @@
|
||||
package com.gcsc.guide.controller;
|
||||
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.CacheControl;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.springframework.http.HttpStatus.NOT_FOUND;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/wing/data")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "WING · Data", description = "WING embedded datasets (JWT required)")
|
||||
public class WingDataController {
|
||||
|
||||
@GetMapping(value = "/zones", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public ResponseEntity<Resource> zones() {
|
||||
return serveJson("wing-data/zones.wgs84.geojson");
|
||||
}
|
||||
|
||||
@GetMapping(value = "/legacy", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public ResponseEntity<Resource> legacyChinesePermitted() {
|
||||
return serveJson("wing-data/chinese-permitted.v1.json");
|
||||
}
|
||||
|
||||
@GetMapping(value = "/subcables/geo", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public ResponseEntity<Resource> subcablesGeo() {
|
||||
return serveJson("wing-data/subcables/cable-geo.json");
|
||||
}
|
||||
|
||||
@GetMapping(value = "/subcables/details", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public ResponseEntity<Resource> subcablesDetails() {
|
||||
return serveJson("wing-data/subcables/cable-details.min.json");
|
||||
}
|
||||
|
||||
private ResponseEntity<Resource> serveJson(String classpathLocation) {
|
||||
Resource resource = new ClassPathResource(classpathLocation);
|
||||
if (!resource.exists()) {
|
||||
throw new ResponseStatusException(NOT_FOUND, "Resource not found: " + classpathLocation);
|
||||
}
|
||||
return ResponseEntity.ok()
|
||||
// Authenticated endpoint: allow browser caching but keep it private.
|
||||
.cacheControl(CacheControl.maxAge(6, TimeUnit.HOURS).cachePrivate())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(resource);
|
||||
}
|
||||
}
|
||||
|
||||
1
src/main/resources/wing-data/chinese-permitted.v1.json
Normal file
1
src/main/resources/wing-data/chinese-permitted.v1.json
Normal file
File diff suppressed because one or more lines are too long
1
src/main/resources/wing-data/zones.wgs84.geojson
Normal file
1
src/main/resources/wing-data/zones.wgs84.geojson
Normal file
File diff suppressed because one or more lines are too long
불러오는 중...
Reference in New Issue
Block a user