Compare commits
1 Commits
5579c5b00f
...
v0.1.21
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6428e5d5f |
@@ -19,6 +19,8 @@ public class DaemonController {
|
|||||||
|
|
||||||
@GetMapping("/config")
|
@GetMapping("/config")
|
||||||
public DaemonConfig getConfig() {
|
public DaemonConfig getConfig() {
|
||||||
|
// #275 — 데몬 운영 설정은 admin 전용 (이전: 공개 노출 — 정보 누출 위험)
|
||||||
|
AuthUtil.requireAdmin();
|
||||||
DaemonConfig config = daemonConfigService.getConfig();
|
DaemonConfig config = daemonConfigService.getConfig();
|
||||||
return config != null ? config : DaemonConfig.builder().build();
|
return config != null ? config : DaemonConfig.builder().build();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import lombok.NoArgsConstructor;
|
|||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
public class SiteVisitStats {
|
public class SiteVisitStats {
|
||||||
private int today;
|
// #274 — long으로 변경 (21억 이상 누적 시 int 오버플로 방지)
|
||||||
private int total;
|
private long today;
|
||||||
|
private long total;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ public interface StatsMapper {
|
|||||||
|
|
||||||
void recordVisit();
|
void recordVisit();
|
||||||
|
|
||||||
int getTodayVisits();
|
long getTodayVisits();
|
||||||
|
|
||||||
int getTotalVisits();
|
long getTotalVisits();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,8 +27,15 @@ public class CacheService {
|
|||||||
this.redis = redis;
|
this.redis = redis;
|
||||||
this.mapper = mapper;
|
this.mapper = mapper;
|
||||||
this.ttl = Duration.ofSeconds(ttlSeconds);
|
this.ttl = Duration.ofSeconds(ttlSeconds);
|
||||||
try {
|
// #276 — ping 연결 자원 누수 방지: try-with-resources
|
||||||
redis.getConnectionFactory().getConnection().ping();
|
var factory = redis.getConnectionFactory();
|
||||||
|
if (factory == null) {
|
||||||
|
log.warn("Redis ConnectionFactory is null, caching disabled");
|
||||||
|
disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try (var conn = factory.getConnection()) {
|
||||||
|
conn.ping();
|
||||||
log.info("Redis connected");
|
log.info("Redis connected");
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("Redis unavailable ({}), caching disabled", e.getMessage());
|
log.warn("Redis unavailable ({}), caching disabled", e.getMessage());
|
||||||
@@ -37,6 +44,13 @@ public class CacheService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public String makeKey(String... parts) {
|
public String makeKey(String... parts) {
|
||||||
|
// #276 — null/빈 파트로 "tasteby::" 같은 잘못된 키 생성 방지
|
||||||
|
if (parts == null || parts.length == 0) {
|
||||||
|
throw new IllegalArgumentException("makeKey requires at least one part");
|
||||||
|
}
|
||||||
|
for (String p : parts) {
|
||||||
|
if (p == null) throw new IllegalArgumentException("makeKey parts must not be null");
|
||||||
|
}
|
||||||
return PREFIX + String.join(":", parts);
|
return PREFIX + String.join(":", parts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package com.tasteby.service;
|
|||||||
|
|
||||||
import com.tasteby.domain.DaemonConfig;
|
import com.tasteby.domain.DaemonConfig;
|
||||||
import com.tasteby.mapper.DaemonConfigMapper;
|
import com.tasteby.mapper.DaemonConfigMapper;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@@ -27,20 +29,33 @@ public class DaemonConfigService {
|
|||||||
current.setScanEnabled(Boolean.TRUE.equals(body.get("scan_enabled")));
|
current.setScanEnabled(Boolean.TRUE.equals(body.get("scan_enabled")));
|
||||||
}
|
}
|
||||||
if (body.containsKey("scan_interval_min")) {
|
if (body.containsKey("scan_interval_min")) {
|
||||||
current.setScanIntervalMin(((Number) body.get("scan_interval_min")).intValue());
|
// #275 — 0/음수 입력으로 30초 사이클 폭주 방지. ClassCastException 대신 400.
|
||||||
|
current.setScanIntervalMin(requirePositiveInt(body.get("scan_interval_min"), "scan_interval_min"));
|
||||||
}
|
}
|
||||||
if (body.containsKey("process_enabled")) {
|
if (body.containsKey("process_enabled")) {
|
||||||
current.setProcessEnabled(Boolean.TRUE.equals(body.get("process_enabled")));
|
current.setProcessEnabled(Boolean.TRUE.equals(body.get("process_enabled")));
|
||||||
}
|
}
|
||||||
if (body.containsKey("process_interval_min")) {
|
if (body.containsKey("process_interval_min")) {
|
||||||
current.setProcessIntervalMin(((Number) body.get("process_interval_min")).intValue());
|
current.setProcessIntervalMin(requirePositiveInt(body.get("process_interval_min"), "process_interval_min"));
|
||||||
}
|
}
|
||||||
if (body.containsKey("process_limit")) {
|
if (body.containsKey("process_limit")) {
|
||||||
current.setProcessLimit(((Number) body.get("process_limit")).intValue());
|
current.setProcessLimit(requirePositiveInt(body.get("process_limit"), "process_limit"));
|
||||||
}
|
}
|
||||||
mapper.updateConfig(current);
|
mapper.updateConfig(current);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** #275 — 양의 정수 가드. 비숫자/0/음수는 400. */
|
||||||
|
private static int requirePositiveInt(Object raw, String field) {
|
||||||
|
if (!(raw instanceof Number n)) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, field + "은(는) 정수여야 합니다");
|
||||||
|
}
|
||||||
|
int v = n.intValue();
|
||||||
|
if (v < 1) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, field + "은(는) 1 이상이어야 합니다 (폭주 방지)");
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
public void updateLastScan() {
|
public void updateLastScan() {
|
||||||
mapper.updateLastScan();
|
mapper.updateLastScan();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,8 +50,13 @@ public class DaemonScheduler {
|
|||||||
Instant lastScan = config.getLastScanAt() != null ? config.getLastScanAt().toInstant() : null;
|
Instant lastScan = config.getLastScanAt() != null ? config.getLastScanAt().toInstant() : null;
|
||||||
if (lastScan == null || Instant.now().isAfter(lastScan.plus(config.getScanIntervalMin(), ChronoUnit.MINUTES))) {
|
if (lastScan == null || Instant.now().isAfter(lastScan.plus(config.getScanIntervalMin(), ChronoUnit.MINUTES))) {
|
||||||
log.info("Running scheduled channel scan...");
|
log.info("Running scheduled channel scan...");
|
||||||
int newVideos = youTubeService.scanAllChannels();
|
int newVideos = 0;
|
||||||
daemonConfigService.updateLastScan();
|
try {
|
||||||
|
newVideos = youTubeService.scanAllChannels();
|
||||||
|
} finally {
|
||||||
|
// #275 — 외부 호출 예외 시에도 last_scan_at을 갱신해 다음 cron까지의 backoff를 보장
|
||||||
|
daemonConfigService.updateLastScan();
|
||||||
|
}
|
||||||
if (newVideos > 0) {
|
if (newVideos > 0) {
|
||||||
cacheService.flush();
|
cacheService.flush();
|
||||||
log.info("Scan completed: {} new videos", newVideos);
|
log.info("Scan completed: {} new videos", newVideos);
|
||||||
@@ -63,8 +68,12 @@ public class DaemonScheduler {
|
|||||||
Instant lastProcess = config.getLastProcessAt() != null ? config.getLastProcessAt().toInstant() : null;
|
Instant lastProcess = config.getLastProcessAt() != null ? config.getLastProcessAt().toInstant() : null;
|
||||||
if (lastProcess == null || Instant.now().isAfter(lastProcess.plus(config.getProcessIntervalMin(), ChronoUnit.MINUTES))) {
|
if (lastProcess == null || Instant.now().isAfter(lastProcess.plus(config.getProcessIntervalMin(), ChronoUnit.MINUTES))) {
|
||||||
log.info("Running scheduled video processing (limit={})...", config.getProcessLimit());
|
log.info("Running scheduled video processing (limit={})...", config.getProcessLimit());
|
||||||
int restaurants = pipelineService.processPending(config.getProcessLimit());
|
int restaurants = 0;
|
||||||
daemonConfigService.updateLastProcess();
|
try {
|
||||||
|
restaurants = pipelineService.processPending(config.getProcessLimit());
|
||||||
|
} finally {
|
||||||
|
daemonConfigService.updateLastProcess();
|
||||||
|
}
|
||||||
if (restaurants > 0) {
|
if (restaurants > 0) {
|
||||||
cacheService.flush();
|
cacheService.flush();
|
||||||
log.info("Processing completed: {} restaurants extracted", restaurants);
|
log.info("Processing completed: {} restaurants extracted", restaurants);
|
||||||
|
|||||||
@@ -2,11 +2,16 @@ package com.tasteby.service;
|
|||||||
|
|
||||||
import com.tasteby.domain.SiteVisitStats;
|
import com.tasteby.domain.SiteVisitStats;
|
||||||
import com.tasteby.mapper.StatsMapper;
|
import com.tasteby.mapper.StatsMapper;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class StatsService {
|
public class StatsService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(StatsService.class);
|
||||||
|
|
||||||
private final StatsMapper mapper;
|
private final StatsMapper mapper;
|
||||||
|
|
||||||
public StatsService(StatsMapper mapper) {
|
public StatsService(StatsMapper mapper) {
|
||||||
@@ -14,7 +19,19 @@ public class StatsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void recordVisit() {
|
public void recordVisit() {
|
||||||
mapper.recordVisit();
|
// #274 — 자정 경계 동시성: 두 트랜잭션이 동시에 'NOT MATCHED' 판정 → 둘 다 INSERT
|
||||||
|
// → PK/UNIQUE 충돌 시 한 쪽 500. 1회 재시도(다음엔 MATCHED → UPDATE 분기).
|
||||||
|
try {
|
||||||
|
mapper.recordVisit();
|
||||||
|
} catch (DataIntegrityViolationException e) {
|
||||||
|
log.debug("recordVisit conflict (midnight race), retry once: {}", e.getMessage());
|
||||||
|
try {
|
||||||
|
mapper.recordVisit();
|
||||||
|
} catch (DataIntegrityViolationException retryFail) {
|
||||||
|
// 두 번째 시도도 실패: 카운트 1건 손실은 수용 (운영 영향 미미)
|
||||||
|
log.warn("recordVisit double-conflict, dropping one count: {}", retryFail.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public SiteVisitStats getVisits() {
|
public SiteVisitStats getVisits() {
|
||||||
|
|||||||
Reference in New Issue
Block a user