Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 42 additions & 2 deletions frontend/src/pages/AdminPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ type AuditLog = {
targetId: number | null;
message: string;
details: string | null;
provider: string | null;
success: boolean;
createdAt: string | null;
};
Expand All @@ -192,6 +193,11 @@ type SyncStatus = {
failureCount: number | null;
lastErrorMessage: string | null;
status: string | null;
provider: string | null;
lastOperation: string | null;
lastErrorCategory: string | null;
lastHttpStatus: number | null;
lastAttemptCount: number | null;
};

type CoverageStatus = "loading" | "ready" | "error";
Expand Down Expand Up @@ -1166,6 +1172,9 @@ export function AdminPage({ authState }: AdminPageProps) {

const syncStatusByTask = new Map(syncStatuses.map((status) => [status.task, status]));
const apiFootballStatus = syncStatusByTask.get("api-football");
const newsProviderStatuses = [syncStatusByTask.get("serp-api"), syncStatusByTask.get("openai")].filter(
(status): status is SyncStatus => status !== undefined,
);
const activeSyncTasks = new Set(syncJobs.filter((job) => job.active).map((job) => job.task));
const runningSyncJobs = syncJobs.filter((job) => job.status === "RUNNING" || job.status === "CANCEL_REQUESTED");
const queuedSyncJobs = syncJobs.filter((job) => job.status === "QUEUED");
Expand Down Expand Up @@ -1441,8 +1450,8 @@ export function AdminPage({ authState }: AdminPageProps) {
<details className="panel admin-page-panel admin-utility-section">
<summary>
<span>
<span className="eyebrow">API-Football</span>
<strong>Manual Sync</strong>
<span className="eyebrow">External APIs</span>
<strong>Provider Status &amp; API-Football Sync</strong>
</span>
</summary>
<p className="muted admin-sync-message">
Expand All @@ -1460,9 +1469,32 @@ export function AdminPage({ authState }: AdminPageProps) {
<span>๋งˆ์ง€๋ง‰ ์‹œ๋„: {formatDateTime(apiFootballStatus?.lastAttemptAt ?? null)}</span>
{apiFootballStatus?.lastFailureAt ? <span>๋งˆ์ง€๋ง‰ ์‹คํŒจ: {formatDateTime(apiFootballStatus.lastFailureAt)}</span> : null}
{(apiFootballStatus?.failureCount ?? 0) > 0 ? <span>๊ธฐ๋ก๋œ ์žฅ์• : {apiFootballStatus?.failureCount}ํšŒ</span> : null}
{apiFootballStatus?.lastOperation ? <span>์ž‘์—…: {apiFootballStatus.lastOperation}</span> : null}
{apiFootballStatus?.lastAttemptCount ? <span>์‹œ๋„ ํšŸ์ˆ˜: {apiFootballStatus.lastAttemptCount}</span> : null}
{apiFootballStatus?.lastErrorCategory ? <span>์˜ค๋ฅ˜: {apiFootballStatus.lastErrorCategory}{apiFootballStatus.lastHttpStatus ? ` (${apiFootballStatus.lastHttpStatus})` : ""}</span> : null}
</div>
{apiFootballStatus?.lastErrorMessage ? <p className="admin-api-health-error">{apiFootballStatus.lastErrorMessage}</p> : null}
</div>
{newsProviderStatuses.map((status) => (
<div className={`admin-api-health admin-api-health-${(status.status ?? "NEVER_SYNCED").toLowerCase()}`} key={status.task}>
<div className="admin-api-health-heading">
<strong>{status.label} ์ƒํƒœ</strong>
<span className={`status-pill sync-status-${(status.status ?? "NEVER_SYNCED").toLowerCase()}`}>
{apiFootballStatusLabel(status.status)}
</span>
</div>
<div className="admin-api-health-details">
<span>๋งˆ์ง€๋ง‰ ์„ฑ๊ณต: {formatDateTime(status.lastSuccessAt)}</span>
<span>๋งˆ์ง€๋ง‰ ์‹œ๋„: {formatDateTime(status.lastAttemptAt)}</span>
{status.lastFailureAt ? <span>๋งˆ์ง€๋ง‰ ์‹คํŒจ: {formatDateTime(status.lastFailureAt)}</span> : null}
<span>์—ฐ์† ์‹คํŒจ: {status.failureCount ?? 0}ํšŒ</span>
{status.lastOperation ? <span>์ž‘์—…: {status.lastOperation}</span> : null}
{status.lastAttemptCount ? <span>์‹œ๋„ ํšŸ์ˆ˜: {status.lastAttemptCount}</span> : null}
{status.lastErrorCategory ? <span>์˜ค๋ฅ˜: {status.lastErrorCategory}{status.lastHttpStatus ? ` (${status.lastHttpStatus})` : ""}</span> : null}
</div>
{status.lastErrorMessage ? <p className="admin-api-health-error">{status.lastErrorMessage}</p> : null}
</div>
))}
<div className="admin-sync-actions">
{syncTasks.map((item) => {
const status = syncStatusByTask.get(item.task);
Expand Down Expand Up @@ -1569,6 +1601,7 @@ export function AdminPage({ authState }: AdminPageProps) {
<article className="admin-log-item" key={log.id}>
<div className="admin-log-badges">
<span className="status-pill">{log.type}</span>
{log.provider ? <span className="status-pill admin-log-category">{externalApiProviderLabel(log.provider)}</span> : null}
{log.syncCategory ? <span className="status-pill admin-log-category">{log.syncCategory}</span> : null}
</div>
<div>
Expand Down Expand Up @@ -2664,3 +2697,10 @@ function formatFixtureScore(fixture: FixtureSummaryAdmin) {
function labelize(value: string) {
return value.replace(/[A-Z]/g, (letter) => ` ${letter}`).replace(/^./, (letter) => letter.toUpperCase());
}

function externalApiProviderLabel(provider: string) {
if (provider === "SERP_API") return "SerpAPI";
if (provider === "OPENAI") return "OpenAI";
if (provider === "API_FOOTBALL") return "API-Football";
return provider;
}
5 changes: 5 additions & 0 deletions frontend/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -5056,6 +5056,11 @@ h2 {
color: #9b2418;
font-size: 0.8rem;
font-weight: 800;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
overflow: hidden;
overflow-wrap: anywhere;
}

Expand Down
6 changes: 6 additions & 0 deletions src/main/java/com/son/soccerStreaming/admin/dto/AdminDto.java
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,7 @@ public static class AuditLogResponse {
private Long targetId;
private String message;
private String details;
private String provider;
private boolean success;
private LocalDateTime createdAt;
}
Expand Down Expand Up @@ -536,5 +537,10 @@ public static class SyncStatusItem {
private Integer failureCount;
private String lastErrorMessage;
private String status;
private String provider;
private String lastOperation;
private String lastErrorCategory;
private Integer lastHttpStatus;
private Integer lastAttemptCount;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ public class AdminAuditLog {
@Column(length = 4000)
private String details;

@Column(length = 30)
private String provider;

@Column(nullable = false)
private boolean success;

Expand All @@ -67,4 +70,18 @@ public static AdminAuditLog of(AppUser adminUser, AdminAuditType type, String ta
.createdAt(LocalDateTime.now())
.build();
}

public static AdminAuditLog externalApiCall(AppUser adminUser, String provider, String message,
String details, boolean success) {
return AdminAuditLog.builder()
.adminUser(adminUser)
.type(AdminAuditType.EXTERNAL_API_CALL)
.targetType("EXTERNAL_API")
.message(message)
.details(details)
.provider(provider)
.success(success)
.createdAt(LocalDateTime.now())
.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@ public enum AdminAuditType {
MEDIA_UPLOAD,
MEDIA_RESTORE,
OVERRIDE_CLEAR,
SYNC
SYNC,
EXTERNAL_API_CALL
}
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ public class AdminService {
private static final Pattern SUBSTITUTION_DETAIL_PATTERN = Pattern.compile("Substitution\\s+\\d+");
private static final Pattern AUDIT_MESSAGE_PARAMETER_PATTERN = Pattern.compile("\\b(sequence|player|team)=(\\d+)\\b");
private static final Set<String> PUBLIC_SYNC_DETAIL_KEYS = Set.of("league", "season", "fixtureId");
private static final Set<String> PUBLIC_EXTERNAL_API_DETAIL_KEYS = Set.of(
"operation", "teamId", "articleId", "batchSize", "resultCount", "attempts", "durationMs", "httpStatus", "errorCategory");
private static final Map<String, String> SYNC_CATEGORY_LABELS = Map.of(
"seasons", "Seasons",
"teams", "Teams",
Expand All @@ -125,6 +127,8 @@ public class AdminService {

private static final List<SyncStatusDefinition> SYNC_STATUS_DEFINITIONS = List.of(
new SyncStatusDefinition("api-football", "API-Football"),
new SyncStatusDefinition("serp-api", "SerpAPI"),
new SyncStatusDefinition("openai", "OpenAI"),
new SyncStatusDefinition("seasons", "Seasons"),
new SyncStatusDefinition("teams", "Teams"),
new SyncStatusDefinition("standings", "Standings"),
Expand Down Expand Up @@ -1311,6 +1315,7 @@ private AdminDto.AuditLogResponse toAuditLogResponse(AdminAuditLog log) {
.targetId(log.getTargetId())
.message(publicAuditMessage(log))
.details(publicAuditDetails(log))
.provider(log.getProvider())
.success(log.isSuccess())
.createdAt(log.getCreatedAt())
.build();
Expand Down Expand Up @@ -1341,6 +1346,9 @@ private String publicAuditDetails(AdminAuditLog log) {
if (log.getType() == AdminAuditType.OVERRIDE_CLEAR) {
addAllowedDetailParameter(parameters, log.getDetails(), "field", "field");
}
if (log.getType() == AdminAuditType.EXTERNAL_API_CALL) {
addWhitelistedParameters(parameters, log.getDetails(), PUBLIC_EXTERNAL_API_DETAIL_KEYS);
}

if (parameters.isEmpty()) {
return null;
Expand Down Expand Up @@ -1398,6 +1406,16 @@ private void addFixtureAuditParameters(Map<String, String> parameters, String me
}
}

private void addWhitelistedParameters(Map<String, String> parameters, String details, Set<String> allowedKeys) {
if (details == null || details.isBlank()) return;
for (String part : details.split(";")) {
String[] pair = part.trim().split("=", 2);
if (pair.length == 2 && allowedKeys.contains(pair[0]) && !pair[1].isBlank()) {
parameters.put(pair[0], pair[1].trim());
}
}
}

private void addAllowedDetailParameter(Map<String, String> parameters, String details,
String sourceName, String outputName) {
if (details == null || details.isBlank()) {
Expand All @@ -1421,6 +1439,9 @@ private String publicAuditMessage(AdminAuditLog log) {
case MEDIA_RESTORE -> "๊ด€๋ฆฌ์ž ์ด๋ฏธ์ง€๊ฐ€ ์›๋ณธ์œผ๋กœ ๋ณต์›๋˜์—ˆ์Šต๋‹ˆ๋‹ค.";
case OVERRIDE_CLEAR -> "์ˆ˜๋™ ์ˆ˜์ • ์„ค์ •์ด ํ•ด์ œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.";
case SYNC -> publicSyncAuditMessage(log);
case EXTERNAL_API_CALL -> log.isSuccess()
? "์™ธ๋ถ€ API ํ˜ธ์ถœ์ด ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."
: "์™ธ๋ถ€ API ํ˜ธ์ถœ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.";
};
}

Expand Down Expand Up @@ -1454,11 +1475,16 @@ private AdminDto.SyncStatusItem toSyncStatusItem(SyncStatusDefinition definition
.failureCount(syncFailureCount(status))
.lastErrorMessage(status == null ? null : status.getLastErrorMessage())
.status(syncDisplayStatus(status))
.provider(status == null ? null : status.getProvider())
.lastOperation(status == null ? null : status.getLastOperation())
.lastErrorCategory(status == null ? null : status.getLastErrorCategory())
.lastHttpStatus(status == null ? null : status.getLastHttpStatus())
.lastAttemptCount(status == null ? null : status.getLastAttemptCount())
.build();
}

private String syncStatusKey(String task, Integer season) {
if ("api-football".equals(task)) {
if ("api-football".equals(task) || "serp-api".equals(task) || "openai".equals(task)) {
return task;
}
if ("seasons".equals(task)) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.son.soccerStreaming.admin.service;

import com.son.soccerStreaming.global.externalapi.ExternalApiCallCompletedEvent;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;

@Slf4j
@Component
@RequiredArgsConstructor
public class ExternalApiAuditLogListener {
private final ExternalApiAuditLogService auditLogService;

@EventListener
public void onCompleted(ExternalApiCallCompletedEvent event) {
if (event.context() == null || !event.context().isAdminRequest()) return;
try {
auditLogService.record(event);
} catch (RuntimeException exception) {
log.atError()
.addKeyValue("event.action", "external-api-audit-persist")
.addKeyValue("event.outcome", "failure")
.addKeyValue("event.code", "EXTERNAL_API_AUDIT_PERSIST_FAILED")
.addKeyValue("external_api.provider", event.provider().name())
.addKeyValue("external_api.operation", event.operation())
.setCause(exception)
.log("Failed to persist external API admin audit.");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package com.son.soccerStreaming.admin.service;

import com.son.soccerStreaming.admin.entity.AdminAuditLog;
import com.son.soccerStreaming.admin.repository.AdminAuditLogRepository;
import com.son.soccerStreaming.auth.repository.AppUserRepository;
import com.son.soccerStreaming.global.externalapi.ExternalApiCallCompletedEvent;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

import java.util.ArrayList;
import java.util.List;

@Service
@Slf4j
@RequiredArgsConstructor
public class ExternalApiAuditLogService {
private final AppUserRepository appUserRepository;
private final AdminAuditLogRepository auditLogRepository;

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void record(ExternalApiCallCompletedEvent event) {
var context = event.context();
if (context == null || !context.isAdminRequest()) return;
var admin = appUserRepository.findById(context.adminUserId()).orElse(null);
if (admin == null) {
log.atWarn()
.addKeyValue("event.action", "external-api-audit-persist")
.addKeyValue("event.outcome", "failure")
.addKeyValue("event.code", "EXTERNAL_API_AUDIT_ADMIN_NOT_FOUND")
.addKeyValue("external_api.provider", event.provider().name())
.addKeyValue("external_api.operation", event.operation())
.addKeyValue("user.id", context.adminUserId())
.log("External API admin audit was skipped because the admin user was not found.");
return;
}

List<String> details = new ArrayList<>();
details.add("operation=" + event.operation());
if (context.teamId() != null) details.add("teamId=" + context.teamId());
if (context.articleId() != null) details.add("articleId=" + context.articleId());
if (context.batchSize() != null) details.add("batchSize=" + context.batchSize());
if (event.resultCount() != null) details.add("resultCount=" + event.resultCount());
details.add("attempts=" + event.attempts());
details.add("durationMs=" + event.durationMs());
if (event.httpStatus() != null) details.add("httpStatus=" + event.httpStatus());
if (event.errorCategory() != null) details.add("errorCategory=" + event.errorCategory().name());

String message = event.provider().displayName() + " " + event.operation()
+ (event.success() ? " call completed" : " call failed");
auditLogRepository.save(AdminAuditLog.externalApiCall(
admin, event.provider().name(), message, String.join("; ", details), event.success()));
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package com.son.soccerStreaming.apifootball.client;

import com.son.soccerStreaming.apifootball.service.ApiFootballSyncStatusService;
import lombok.RequiredArgsConstructor;
import com.son.soccerStreaming.global.externalapi.ExternalApiErrorCategory;
import com.son.soccerStreaming.global.externalapi.ExternalApiException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
Expand All @@ -12,13 +12,7 @@

@Slf4j
@Component
@RequiredArgsConstructor
public class ApiFootballCircuitBreaker {

private static final String GLOBAL_SYNC_KEY = "api-football";
private static final String GLOBAL_DISPLAY_NAME = "API-Football";

private final ApiFootballSyncStatusService syncStatusService;
private final Clock clock = Clock.systemUTC();
private final AtomicInteger consecutiveFailures = new AtomicInteger();
private final AtomicLong openUntilEpochMs = new AtomicLong();
Expand Down Expand Up @@ -52,11 +46,12 @@ public void recordSuccess() {
}
consecutiveFailures.set(0);
openUntilEpochMs.set(0L);
syncStatusService.recordSuccessByKey(GLOBAL_SYNC_KEY, GLOBAL_DISPLAY_NAME);
}

public void recordFailure(String operation, Exception exception) {
if (!enabled || exception instanceof ApiFootballCircuitOpenException) {
if (!enabled || exception instanceof ApiFootballCircuitOpenException
|| exception instanceof ExternalApiException external
&& external.getCategory() == ExternalApiErrorCategory.CIRCUIT_OPEN) {
return;
}
int failures = consecutiveFailures.incrementAndGet();
Expand All @@ -68,7 +63,6 @@ public void recordFailure(String operation, Exception exception) {
openUntilEpochMs.set(openUntil);
log.warn("API-Football circuit opened. operation={}, failureCount={}, openUntilEpochMs={}",
operation, failures, openUntil);
syncStatusService.recordFailureByKey(GLOBAL_SYNC_KEY, GLOBAL_DISPLAY_NAME, exception);
}

private int configuredFailureThreshold() {
Expand Down
Loading
Loading