feat: 지진파(USGS) + 기압(Open-Meteo) 수집기 + DB 테이블 설계 (#39)
Co-authored-by: htlee <htlee@gcsc.co.kr> Co-committed-by: htlee <htlee@gcsc.co.kr>
This commit is contained in:
부모
0fd32081b0
커밋
4b41ed0d9d
@ -0,0 +1,113 @@
|
||||
package gc.mda.kcg.collector.sensor;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import gc.mda.kcg.collector.CollectorStatusTracker;
|
||||
import gc.mda.kcg.domain.sensor.PressureReading;
|
||||
import gc.mda.kcg.domain.sensor.PressureReadingRepository;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.format.DateTimeParseException;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class PressureCollector {
|
||||
|
||||
private static final String[] STATION_NAMES = {"tehran", "isfahan", "bandar-abbas", "shiraz", "tabriz"};
|
||||
private static final double[] LATS = {35.69, 32.65, 27.19, 29.59, 38.08};
|
||||
private static final double[] LNGS = {51.39, 51.68, 56.27, 52.58, 46.29};
|
||||
|
||||
private static final String OPEN_METEO_URL =
|
||||
"https://api.open-meteo.com/v1/forecast?" +
|
||||
"latitude=35.69,32.65,27.19,29.59,38.08" +
|
||||
"&longitude=51.39,51.68,56.27,52.58,46.29" +
|
||||
"&hourly=surface_pressure&past_hours=24&forecast_hours=0&timezone=UTC";
|
||||
|
||||
private final RestTemplate restTemplate;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final PressureReadingRepository repository;
|
||||
private final CollectorStatusTracker tracker;
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
Thread.ofVirtual().name("pressure-init").start(() -> {
|
||||
log.info("Open-Meteo 기압 데이터 초기 로드");
|
||||
collect();
|
||||
});
|
||||
}
|
||||
|
||||
@Scheduled(initialDelay = 45_000, fixedDelay = 600_000)
|
||||
public void collectScheduled() {
|
||||
collect();
|
||||
}
|
||||
|
||||
private void collect() {
|
||||
try {
|
||||
String body = restTemplate.getForObject(OPEN_METEO_URL, String.class);
|
||||
if (body == null || body.isBlank()) return;
|
||||
|
||||
JsonNode root = objectMapper.readTree(body);
|
||||
int saved = 0;
|
||||
Instant now = Instant.now();
|
||||
|
||||
// Open-Meteo 멀티 위치: 배열 형태 응답
|
||||
if (root.isArray()) {
|
||||
for (int i = 0; i < root.size() && i < STATION_NAMES.length; i++) {
|
||||
saved += parseStation(root.get(i), STATION_NAMES[i], LATS[i], LNGS[i], now);
|
||||
}
|
||||
} else {
|
||||
// 단일 위치 응답 (파라미터 1개일 때)
|
||||
saved += parseStation(root, STATION_NAMES[0], LATS[0], LNGS[0], now);
|
||||
}
|
||||
|
||||
tracker.recordSuccess("pressure", "iran", saved);
|
||||
log.debug("Open-Meteo 기압 수집 완료: {}건 저장", saved);
|
||||
} catch (Exception e) {
|
||||
tracker.recordFailure("pressure", "iran", e.getMessage());
|
||||
log.warn("Open-Meteo 기압 수집 실패: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private int parseStation(JsonNode stationData, String station, double lat, double lng, Instant now) {
|
||||
JsonNode hourly = stationData.path("hourly");
|
||||
JsonNode times = hourly.path("time");
|
||||
JsonNode pressures = hourly.path("surface_pressure");
|
||||
if (!times.isArray() || !pressures.isArray()) return 0;
|
||||
|
||||
int saved = 0;
|
||||
for (int j = 0; j < times.size() && j < pressures.size(); j++) {
|
||||
String timeStr = times.get(j).asText();
|
||||
double pressure = pressures.get(j).asDouble(Double.NaN);
|
||||
if (Double.isNaN(pressure)) continue;
|
||||
|
||||
try {
|
||||
// Open-Meteo 시간 형식: "2026-03-18T07:00" (Z 없음) → Z 추가
|
||||
Instant readingTime = OffsetDateTime.parse(timeStr + "Z").toInstant();
|
||||
try {
|
||||
repository.save(PressureReading.builder()
|
||||
.station(station)
|
||||
.lat(lat)
|
||||
.lng(lng)
|
||||
.pressureHpa(pressure)
|
||||
.readingTime(readingTime)
|
||||
.collectedAt(now)
|
||||
.build());
|
||||
saved++;
|
||||
} catch (Exception ignored) {
|
||||
// unique constraint violation = 이미 존재, 무시
|
||||
}
|
||||
} catch (DateTimeParseException e) {
|
||||
log.debug("기압 시간 파싱 실패: {}", timeStr);
|
||||
}
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,90 @@
|
||||
package gc.mda.kcg.collector.sensor;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import gc.mda.kcg.collector.CollectorStatusTracker;
|
||||
import gc.mda.kcg.domain.sensor.SeismicEvent;
|
||||
import gc.mda.kcg.domain.sensor.SeismicEventRepository;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class SeismicCollector {
|
||||
|
||||
private static final String USGS_URL =
|
||||
"https://earthquake.usgs.gov/fdsnws/event/1/query?format=geojson" +
|
||||
"&minlatitude=25&maxlatitude=40&minlongitude=44&maxlongitude=63" +
|
||||
"&orderby=time&limit=50&minmagnitude=2";
|
||||
|
||||
private final RestTemplate restTemplate;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final SeismicEventRepository repository;
|
||||
private final CollectorStatusTracker tracker;
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
Thread.ofVirtual().name("seismic-init").start(() -> {
|
||||
log.info("USGS 지진 데이터 초기 로드");
|
||||
collect();
|
||||
});
|
||||
}
|
||||
|
||||
@Scheduled(initialDelay = 60_000, fixedDelay = 300_000)
|
||||
public void collectScheduled() {
|
||||
collect();
|
||||
}
|
||||
|
||||
private void collect() {
|
||||
try {
|
||||
String body = restTemplate.getForObject(USGS_URL, String.class);
|
||||
if (body == null || body.isBlank()) return;
|
||||
|
||||
JsonNode root = objectMapper.readTree(body);
|
||||
JsonNode features = root.path("features");
|
||||
if (!features.isArray()) return;
|
||||
|
||||
int saved = 0;
|
||||
Instant now = Instant.now();
|
||||
for (JsonNode f : features) {
|
||||
JsonNode props = f.path("properties");
|
||||
JsonNode coords = f.path("geometry").path("coordinates");
|
||||
|
||||
String usgsId = f.path("id").asText(null);
|
||||
if (usgsId == null) continue;
|
||||
if (repository.existsByUsgsId(usgsId)) continue;
|
||||
|
||||
double mag = props.path("mag").asDouble(0);
|
||||
String place = props.path("place").asText(null);
|
||||
long timeMs = props.path("time").asLong(0);
|
||||
double lng = coords.get(0).asDouble();
|
||||
double lat = coords.get(1).asDouble();
|
||||
Double depth = coords.has(2) ? coords.get(2).asDouble() : null;
|
||||
|
||||
repository.save(SeismicEvent.builder()
|
||||
.usgsId(usgsId)
|
||||
.magnitude(mag)
|
||||
.depth(depth)
|
||||
.lat(lat)
|
||||
.lng(lng)
|
||||
.place(place)
|
||||
.eventTime(Instant.ofEpochMilli(timeMs))
|
||||
.collectedAt(now)
|
||||
.build());
|
||||
saved++;
|
||||
}
|
||||
tracker.recordSuccess("seismic", "iran", saved);
|
||||
log.debug("USGS 지진 수집 완료: {}건 저장", saved);
|
||||
} catch (Exception e) {
|
||||
tracker.recordFailure("seismic", "iran", e.getMessage());
|
||||
log.warn("USGS 지진 수집 실패: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -18,13 +18,16 @@ public class CacheConfig {
|
||||
public static final String OSINT_IRAN = "osint-iran";
|
||||
public static final String OSINT_KOREA = "osint-korea";
|
||||
public static final String SATELLITES = "satellites";
|
||||
public static final String SEISMIC = "seismic";
|
||||
public static final String PRESSURE = "pressure";
|
||||
|
||||
@Bean
|
||||
public CacheManager cacheManager() {
|
||||
CaffeineCacheManager manager = new CaffeineCacheManager(
|
||||
AIRCRAFT_IRAN, AIRCRAFT_KOREA,
|
||||
OSINT_IRAN, OSINT_KOREA,
|
||||
SATELLITES
|
||||
SATELLITES,
|
||||
SEISMIC, PRESSURE
|
||||
);
|
||||
manager.setCaffeine(Caffeine.newBuilder()
|
||||
.expireAfterWrite(2, TimeUnit.DAYS)
|
||||
|
||||
@ -0,0 +1,49 @@
|
||||
package gc.mda.kcg.domain.sensor;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@Entity
|
||||
@Table(
|
||||
name = "pressure_readings",
|
||||
schema = "kcg",
|
||||
uniqueConstraints = @UniqueConstraint(columnNames = {"station", "reading_time"})
|
||||
)
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class PressureReading {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, length = 50)
|
||||
private String station;
|
||||
|
||||
@Column(nullable = false)
|
||||
private double lat;
|
||||
|
||||
@Column(nullable = false)
|
||||
private double lng;
|
||||
|
||||
@Column(nullable = false)
|
||||
private double pressureHpa;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Instant readingTime;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Instant collectedAt;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
if (collectedAt == null) {
|
||||
collectedAt = Instant.now();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
package gc.mda.kcg.domain.sensor;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
public interface PressureReadingRepository extends JpaRepository<PressureReading, Long> {
|
||||
List<PressureReading> findByStationAndReadingTimeAfterOrderByReadingTimeAsc(String station, Instant since);
|
||||
List<PressureReading> findByReadingTimeAfterOrderByReadingTimeAsc(Instant since);
|
||||
}
|
||||
@ -0,0 +1,50 @@
|
||||
package gc.mda.kcg.domain.sensor;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@Entity
|
||||
@Table(name = "seismic_events", schema = "kcg")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class SeismicEvent {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(unique = true, nullable = false, length = 30)
|
||||
private String usgsId;
|
||||
|
||||
@Column(nullable = false)
|
||||
private double magnitude;
|
||||
|
||||
private Double depth;
|
||||
|
||||
@Column(nullable = false)
|
||||
private double lat;
|
||||
|
||||
@Column(nullable = false)
|
||||
private double lng;
|
||||
|
||||
@Column(length = 255)
|
||||
private String place;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Instant eventTime;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Instant collectedAt;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
if (collectedAt == null) {
|
||||
collectedAt = Instant.now();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
package gc.mda.kcg.domain.sensor;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
public interface SeismicEventRepository extends JpaRepository<SeismicEvent, Long> {
|
||||
boolean existsByUsgsId(String usgsId);
|
||||
List<SeismicEvent> findByEventTimeAfterOrderByEventTimeDesc(Instant since);
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
package gc.mda.kcg.domain.sensor;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/sensor")
|
||||
@RequiredArgsConstructor
|
||||
public class SensorController {
|
||||
|
||||
private final SeismicEventRepository seismicRepo;
|
||||
private final PressureReadingRepository pressureRepo;
|
||||
|
||||
/**
|
||||
* 지진 이벤트 조회 (USGS 수집 데이터)
|
||||
*/
|
||||
@GetMapping("/seismic")
|
||||
public Map<String, Object> getSeismic(
|
||||
@RequestParam(defaultValue = "24") int hours) {
|
||||
Instant since = Instant.now().minus(hours, ChronoUnit.HOURS);
|
||||
List<SensorDto.SeismicDto> data = seismicRepo
|
||||
.findByEventTimeAfterOrderByEventTimeDesc(since)
|
||||
.stream().map(SensorDto.SeismicDto::from).toList();
|
||||
return Map.of("count", data.size(), "data", data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 기압 데이터 조회 (Open-Meteo 수집 데이터)
|
||||
*/
|
||||
@GetMapping("/pressure")
|
||||
public Map<String, Object> getPressure(
|
||||
@RequestParam(defaultValue = "24") int hours) {
|
||||
Instant since = Instant.now().minus(hours, ChronoUnit.HOURS);
|
||||
List<SensorDto.PressureDto> data = pressureRepo
|
||||
.findByReadingTimeAfterOrderByReadingTimeAsc(since)
|
||||
.stream().map(SensorDto.PressureDto::from).toList();
|
||||
return Map.of("count", data.size(), "data", data);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,51 @@
|
||||
package gc.mda.kcg.domain.sensor;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
public class SensorDto {
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
public static class SeismicDto {
|
||||
private String usgsId;
|
||||
private double magnitude;
|
||||
private Double depth;
|
||||
private double lat;
|
||||
private double lng;
|
||||
private String place;
|
||||
private long timestamp; // epoch ms
|
||||
|
||||
public static SeismicDto from(SeismicEvent e) {
|
||||
return SeismicDto.builder()
|
||||
.usgsId(e.getUsgsId())
|
||||
.magnitude(e.getMagnitude())
|
||||
.depth(e.getDepth())
|
||||
.lat(e.getLat())
|
||||
.lng(e.getLng())
|
||||
.place(e.getPlace())
|
||||
.timestamp(e.getEventTime().toEpochMilli())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
public static class PressureDto {
|
||||
private String station;
|
||||
private double lat;
|
||||
private double lng;
|
||||
private double pressureHpa;
|
||||
private long timestamp; // epoch ms
|
||||
|
||||
public static PressureDto from(PressureReading r) {
|
||||
return PressureDto.builder()
|
||||
.station(r.getStation())
|
||||
.lat(r.getLat())
|
||||
.lng(r.getLng())
|
||||
.pressureHpa(r.getPressureHpa())
|
||||
.timestamp(r.getReadingTime().toEpochMilli())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
}
|
||||
30
database/migration/004_sensor_data.sql
Normal file
30
database/migration/004_sensor_data.sql
Normal file
@ -0,0 +1,30 @@
|
||||
SET search_path TO kcg, public;
|
||||
|
||||
-- 지진 이벤트 (USGS)
|
||||
CREATE TABLE IF NOT EXISTS seismic_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
usgs_id VARCHAR(30) UNIQUE NOT NULL,
|
||||
magnitude DOUBLE PRECISION NOT NULL,
|
||||
depth DOUBLE PRECISION,
|
||||
lat DOUBLE PRECISION NOT NULL,
|
||||
lng DOUBLE PRECISION NOT NULL,
|
||||
place VARCHAR(255),
|
||||
event_time TIMESTAMPTZ NOT NULL,
|
||||
collected_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_seismic_events_time ON seismic_events(event_time DESC);
|
||||
|
||||
-- 기압 데이터 (Open-Meteo)
|
||||
CREATE TABLE IF NOT EXISTS pressure_readings (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
station VARCHAR(50) NOT NULL,
|
||||
lat DOUBLE PRECISION NOT NULL,
|
||||
lng DOUBLE PRECISION NOT NULL,
|
||||
pressure_hpa DOUBLE PRECISION NOT NULL,
|
||||
reading_time TIMESTAMPTZ NOT NULL,
|
||||
collected_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE(station, reading_time)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_pressure_readings_time ON pressure_readings(reading_time DESC);
|
||||
@ -4,6 +4,14 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 추가
|
||||
- 지진파 수집기: USGS FDSN API, 이란 bbox(M2+), 5분 주기
|
||||
- 기압 수집기: Open-Meteo API, 이란 5개 관측점(테헤란/이스파한/반다르아바스/시라즈/타브리즈), 10분 주기
|
||||
- DB 테이블: seismic_events, pressure_readings (마이그레이션 004)
|
||||
- REST API: GET /api/sensor/seismic, GET /api/sensor/pressure
|
||||
- SensorChart 그래프 순서 변경: 지진파 → 기압 → 소음 → 방사선
|
||||
- 소음/방사선 차트에 (DEMO) 라벨 표시
|
||||
|
||||
### 변경
|
||||
- 프론트엔드 패키지 구조 리팩토링: components/ → common/layers/iran/korea/ 분리
|
||||
- App.tsx God Component 분해: 1,179줄 → 588줄 (데이터 훅 3개 추출)
|
||||
|
||||
@ -1131,6 +1131,12 @@
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.chart-demo-label {
|
||||
font-size: 9px;
|
||||
color: #ef4444;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Footer / Controls */
|
||||
.app-footer {
|
||||
background: var(--bg-card);
|
||||
|
||||
@ -54,19 +54,6 @@ export function SensorChart({ data, currentTime, startTime }: Props) {
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="chart-item">
|
||||
<h4>{t('sensor.noiseLevelDb')}</h4>
|
||||
<ResponsiveContainer width="100%" height={80}>
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
|
||||
<XAxis dataKey="time" tick={{ fontSize: 10, fill: '#888' }} />
|
||||
<YAxis domain={[0, 140]} tick={{ fontSize: 10, fill: '#888' }} />
|
||||
<Tooltip contentStyle={{ background: '#1a1a2e', border: '1px solid #333' }} />
|
||||
<Line type="monotone" dataKey="noiseLevel" stroke="#f97316" dot={false} strokeWidth={1.5} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="chart-item">
|
||||
<h4>{t('sensor.airPressureHpa')}</h4>
|
||||
<ResponsiveContainer width="100%" height={80}>
|
||||
@ -81,7 +68,26 @@ export function SensorChart({ data, currentTime, startTime }: Props) {
|
||||
</div>
|
||||
|
||||
<div className="chart-item">
|
||||
<h4>{t('sensor.radiationUsv')}</h4>
|
||||
<h4>
|
||||
{t('sensor.noiseLevelDb')}{' '}
|
||||
<span className="chart-demo-label">(DEMO)</span>
|
||||
</h4>
|
||||
<ResponsiveContainer width="100%" height={80}>
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
|
||||
<XAxis dataKey="time" tick={{ fontSize: 10, fill: '#888' }} />
|
||||
<YAxis domain={[0, 140]} tick={{ fontSize: 10, fill: '#888' }} />
|
||||
<Tooltip contentStyle={{ background: '#1a1a2e', border: '1px solid #333' }} />
|
||||
<Line type="monotone" dataKey="noiseLevel" stroke="#f97316" dot={false} strokeWidth={1.5} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="chart-item">
|
||||
<h4>
|
||||
{t('sensor.radiationUsv')}{' '}
|
||||
<span className="chart-demo-label">(DEMO)</span>
|
||||
</h4>
|
||||
<ResponsiveContainer width="100%" height={80}>
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user