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
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import com.pooli.policy.domain.dto.response.AdminPolicyActiveResDto;
import com.pooli.policy.domain.dto.response.AdminPolicyCateResDto;
import com.pooli.policy.domain.dto.response.AdminPolicyResDto;
import com.pooli.policy.domain.dto.response.RepeatBlockRehydrateAllResDto;
import com.pooli.policy.service.AdminPolicyService;

import io.swagger.v3.oas.annotations.Operation;
Expand Down Expand Up @@ -280,6 +281,37 @@ public ResponseEntity<AdminPolicyActiveResDto> updateActivationPolicy(
return ResponseEntity.ok(response);
}

@Operation(
summary = "관리자 기능: repeat block Redis 전체 재적재",
description = "관리자 전용. 모든 활성 LINE의 repeat block 정책을 DB에서 읽어 Redis snapshot으로 즉시 동기화합니다."
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "repeat block Redis 전체 재적재 성공"),
@ApiResponse(
responseCode = "403",
description = """
관리자 권한 없음

- COMMON:4301 관리자 권한이 없음
"""
),
@ApiResponse(
responseCode = "500",
description = """
서버 내부 오류

- COMMON:5000 서버 내부 오류
- COMMON:5001 데이터베이스 오류
"""
)
})
@PreAuthorize("@authz.requireAdmin(authentication)")
@PostMapping("/repeat-blocks/rehydrate-all")
public ResponseEntity<RepeatBlockRehydrateAllResDto> rehydrateAllRepeatBlocksToRedis() {
RepeatBlockRehydrateAllResDto response = adminPolicyService.rehydrateAllRepeatBlocksToRedis();
return ResponseEntity.ok(response);
}

@Operation(
summary = "관리자 기능: 정책 카테고리 조회",
description = "관리자 전용. 백오피스에서 정책의 카테고리를 조회합니다."
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.pooli.policy.domain.dto.response;

import java.util.List;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Schema(description = "관리자 - repeat block Redis 전체 재적재 응답 DTO")
public class RepeatBlockRehydrateAllResDto {

@Schema(description = "재적재 대상 line 수", example = "120")
private int totalLineCount;

@Schema(description = "재적재 성공 line 수", example = "118")
private int successCount;

@Schema(description = "재적재 실패 line 수", example = "2")
private int failureCount;

@Schema(description = "재적재 실패 line 목록", example = "[104, 208]")
private List<Long> failedLineIds;
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import com.pooli.policy.domain.dto.response.AdminPolicyActiveResDto;
import com.pooli.policy.domain.dto.response.AdminPolicyCateResDto;
import com.pooli.policy.domain.dto.response.AdminPolicyResDto;
import com.pooli.policy.domain.dto.response.RepeatBlockRehydrateAllResDto;

import java.util.List;

Expand Down Expand Up @@ -38,4 +39,7 @@ public interface AdminPolicyService {
// 정책 카테고리 삭제
AdminPolicyCateResDto deleteCategory(Integer policyCategoryId);

// repeat block Redis 전체 재적재
RepeatBlockRehydrateAllResDto rehydrateAllRepeatBlocksToRedis();

}
141 changes: 141 additions & 0 deletions src/main/java/com/pooli/policy/service/AdminPolicyServiceImpl.java
Original file line number Diff line number Diff line change
@@ -1,15 +1,25 @@
package com.pooli.policy.service;

import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;

import com.fasterxml.jackson.databind.node.JsonNodeFactory;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.pooli.common.exception.CommonErrorCode;
import com.pooli.notification.domain.dto.request.NotiSendReqDto;
import com.pooli.notification.domain.enums.AlarmType;
import com.pooli.notification.domain.enums.NotificationTargetType;
import com.pooli.notification.service.AlarmHistoryService;
import com.pooli.policy.domain.dto.response.RepeatBlockPolicyResDto;
import com.pooli.policy.domain.dto.response.RepeatBlockRehydrateAllResDto;
import com.pooli.traffic.service.policy.TrafficPolicyWriteThroughService;
import com.pooli.traffic.service.outbox.PolicySyncResult;
import com.pooli.traffic.service.runtime.TrafficRedisKeyFactory;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
Expand All @@ -24,9 +34,12 @@
import com.pooli.policy.domain.dto.response.AdminPolicyResDto;
import com.pooli.policy.exception.PolicyErrorCode;
import com.pooli.policy.mapper.AdminPolicyMapper;
import com.pooli.policy.mapper.RepeatBlockMapper;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@Service
@RequiredArgsConstructor
@Transactional
Expand All @@ -36,6 +49,11 @@ public class AdminPolicyServiceImpl implements AdminPolicyService {
private final AlarmHistoryService alarmHistoryService;
private final PolicyHistoryService policyHistoryService;
private final ObjectProvider<TrafficPolicyWriteThroughService> trafficPolicyWriteThroughServiceProvider;
private final RepeatBlockMapper repeatBlockMapper;
private final TrafficRedisKeyFactory trafficRedisKeyFactory;

@Qualifier("cacheStringRedisTemplate")
private final StringRedisTemplate cacheStringRedisTemplate;

@Override
@Transactional(readOnly = true)
Expand Down Expand Up @@ -213,6 +231,107 @@ public AdminPolicyCateResDto deleteCategory(Integer policyCategoryId) {
.build();
}

@Override
@Transactional(readOnly = true)
public RepeatBlockRehydrateAllResDto rehydrateAllRepeatBlocksToRedis() {
TrafficPolicyWriteThroughService writeThroughService = requireWriteThroughServiceForRehydrate();

List<Long> lineIds = loadLineIdsFromExistingRepeatBlockKeys();
if (lineIds == null || lineIds.isEmpty()) {
return RepeatBlockRehydrateAllResDto.builder()
.totalLineCount(0)
.successCount(0)
.failureCount(0)
.failedLineIds(List.of())
.build();
}

int successCount = 0;
List<Long> failedLineIds = new ArrayList<>();

// 운영자가 즉시 상태를 확인할 수 있도록 동기 방식으로 모든 line을 순회 반영한다.
for (Long lineId : lineIds) {
if (lineId == null || lineId <= 0) {
failedLineIds.add(lineId);
continue;
}

List<RepeatBlockPolicyResDto> repeatBlocks = repeatBlockMapper.selectRepeatBlocksByLineId(lineId);
long version = System.currentTimeMillis();
PolicySyncResult syncResult = writeThroughService.syncRepeatBlockUntracked(lineId, repeatBlocks, version);
if (isSuccessEquivalent(syncResult)) {
successCount++;
continue;
}

failedLineIds.add(lineId);
log.warn("repeat_block_rehydrate_failed lineId={} result={}", lineId, syncResult);
}

int totalLineCount = lineIds.size();
int failureCount = failedLineIds.size();
log.info(
"repeat_block_rehydrate_all_completed totalLineCount={} successCount={} failureCount={}",
totalLineCount,
successCount,
failureCount
);

return RepeatBlockRehydrateAllResDto.builder()
.totalLineCount(totalLineCount)
.successCount(successCount)
.failureCount(failureCount)
.failedLineIds(failedLineIds)
.build();
}

/**
* Redis에 이미 존재하는 repeat_block 키에서 lineId 목록을 추출합니다.
* 관리자 일괄 재적재 시 불필요한 DB full-scan을 피하기 위한 대상 축소 단계입니다.
*/
private List<Long> loadLineIdsFromExistingRepeatBlockKeys() {
String repeatBlockPattern = trafficRedisKeyFactory.repeatBlockKeyPattern();
String repeatBlockPrefix = trafficRedisKeyFactory.repeatBlockKeyPrefix();

Set<String> existingRepeatBlockKeys = cacheStringRedisTemplate.keys(repeatBlockPattern);
if (existingRepeatBlockKeys == null || existingRepeatBlockKeys.isEmpty()) {
return List.of();
}

Set<Long> lineIds = new LinkedHashSet<>();
for (String key : existingRepeatBlockKeys) {
Long parsedLineId = parseLineIdFromRepeatBlockKey(key, repeatBlockPrefix);
if (parsedLineId == null) {
log.warn("repeat_block_rehydrate_skip_invalid_key key={} prefix={}", key, repeatBlockPrefix);
continue;
}
lineIds.add(parsedLineId);
}
return new ArrayList<>(lineIds);
}

/**
* namespaced repeat_block 키에서 lineId suffix를 파싱합니다.
*/
private Long parseLineIdFromRepeatBlockKey(String key, String keyPrefix) {
if (key == null || keyPrefix == null || !key.startsWith(keyPrefix)) {
return null;
}
String suffix = key.substring(keyPrefix.length()).trim();
if (suffix.isEmpty()) {
return null;
}
try {
long lineId = Long.parseLong(suffix);
if (lineId <= 0) {
return null;
}
return lineId;
} catch (NumberFormatException e) {
return null;
}
}

private void applyWriteThrough(
String operationName,
java.util.function.Consumer<TrafficPolicyWriteThroughService> callback
Expand Down Expand Up @@ -259,4 +378,26 @@ public void afterCommit() {

alarmHistoryService.sendNotificationAsync(req);
}

private TrafficPolicyWriteThroughService requireWriteThroughServiceForRehydrate() {
if (trafficPolicyWriteThroughServiceProvider == null) {
throw new ApplicationException(
CommonErrorCode.INTERNAL_SERVER_ERROR,
"Repeat block Redis rehydrate를 수행할 수 없습니다. write-through provider가 비어 있습니다."
);
}

TrafficPolicyWriteThroughService writeThroughService = trafficPolicyWriteThroughServiceProvider.getIfAvailable();
if (writeThroughService == null) {
throw new ApplicationException(
CommonErrorCode.INTERNAL_SERVER_ERROR,
"Repeat block Redis rehydrate를 수행할 수 없습니다. cache Redis profile이 비활성입니다."
);
}
return writeThroughService;
}

private boolean isSuccessEquivalent(PolicySyncResult result) {
return result == PolicySyncResult.SUCCESS || result == PolicySyncResult.STALE_REJECTED;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,10 @@ && isImmediateBlocked(payload.getLineId(), now)) {
return buildResult(0L, TrafficLuaStatus.BLOCKED_IMMEDIATE);
}

DayOfWeek nowDayOfWeek = toLuaDayOfWeek(now);
DayOfWeek yesterdayDayOfWeek = previousLuaDayOfWeek(nowDayOfWeek);
if (isPolicyEnabled(policyActivation, POLICY_REPEAT_BLOCK_ID)
&& isRepeatBlocked(payload.getLineId(), now.toLocalTime(), toLuaDayOfWeek(now))) {
&& isRepeatBlocked(payload.getLineId(), now.toLocalTime(), nowDayOfWeek, yesterdayDayOfWeek)) {
return buildResult(0L, TrafficLuaStatus.BLOCKED_REPEAT);
}
}
Expand Down Expand Up @@ -339,7 +341,12 @@ private boolean isImmediateBlocked(Long lineId, LocalDateTime now) {
/**
* repeat block 정책의 요일/시간대 차단 여부를 확인합니다.
*/
private boolean isRepeatBlocked(Long lineId, LocalTime nowTime, DayOfWeek nowDayOfWeek) {
private boolean isRepeatBlocked(
Long lineId,
LocalTime nowTime,
DayOfWeek nowDayOfWeek,
DayOfWeek yesterdayDayOfWeek
) {
List<RepeatBlockPolicyResDto> repeatBlocks = repeatBlockMapper.selectRepeatBlocksByLineId(lineId);
if (repeatBlocks == null || repeatBlocks.isEmpty()) {
return false;
Expand All @@ -356,15 +363,33 @@ private boolean isRepeatBlocked(Long lineId, LocalTime nowTime, DayOfWeek nowDay
}

for (RepeatBlockDayResDto day : days) {
if (day == null || day.getDayOfWeek() != nowDayOfWeek) {
if (day == null || day.getDayOfWeek() == null) {
continue;
}
if (day.getStartAt() == null || day.getEndAt() == null) {
continue;
}

boolean inRange = !nowTime.isBefore(day.getStartAt()) && !nowTime.isAfter(day.getEndAt());
if (inRange) {
LocalTime startAt = day.getStartAt();
LocalTime endAt = day.getEndAt();

if (!startAt.isAfter(endAt)) {
if (day.getDayOfWeek() != nowDayOfWeek) {
continue;
}
boolean inRange = !nowTime.isBefore(startAt) && !nowTime.isAfter(endAt);
if (inRange) {
return true;
}
continue;
}

// 자정 넘김 구간(start > end)은 당일 밤 구간과 익일 새벽 구간으로 나눠 판정한다.
boolean isTodaySegmentBlocked = day.getDayOfWeek() == nowDayOfWeek
&& !nowTime.isBefore(startAt);
boolean isNextDaySegmentBlocked = day.getDayOfWeek() == yesterdayDayOfWeek
&& !nowTime.isAfter(endAt);
if (isTodaySegmentBlocked || isNextDaySegmentBlocked) {
return true;
}
}
Expand Down Expand Up @@ -516,6 +541,18 @@ private DayOfWeek toLuaDayOfWeek(LocalDateTime now) {
};
}

/**
* Lua day_num 체계(SUN=0) 기준으로 전일 요일을 계산합니다.
*/
private DayOfWeek previousLuaDayOfWeek(DayOfWeek nowDayOfWeek) {
if (nowDayOfWeek == null) {
return null;
}
DayOfWeek[] values = DayOfWeek.values();
int previousIndex = (nowDayOfWeek.ordinal() + values.length - 1) % values.length;
return values[previousIndex];
}

private long normalizeNonNegative(Long value) {
if (value == null || value <= 0) {
return 0L;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ public class TrafficPolicyWriteThroughService {
private static final int WRITE_THROUGH_RETRY_MAX = 3;
private static final long WRITE_THROUGH_RETRY_BACKOFF_MS = 50L;
private static final int APP_SPEED_LIMIT_UPLOAD_MULTIPLIER = 125;
private static final int END_OF_DAY_SECOND = 86_399;
private static final int START_OF_DAY_SECOND = 0;

private final TrafficRedisKeyFactory trafficRedisKeyFactory;
private final TrafficRedisRuntimePolicy trafficRedisRuntimePolicy;
Expand Down Expand Up @@ -478,9 +480,22 @@ private Map<String, String> buildRepeatBlockHash(List<RepeatBlockPolicyResDto> r
int startAtSec = day.getStartAt().toSecondOfDay();
int endAtSec = day.getEndAt().toSecondOfDay();

String field = "day:" + dayNum + ":" + repeatBlock.getRepeatBlockId();
String value = startAtSec + ":" + endAtSec;
hashToWrite.put(field, value);
if (startAtSec <= endAtSec) {
String field = "day:" + dayNum + ":" + repeatBlock.getRepeatBlockId();
String value = startAtSec + ":" + endAtSec;
hashToWrite.put(field, value);
continue;
}

// 자정 넘김 구간은 Lua 판정(day_num 단일 조회)과 맞추기 위해 당일/익일 2개 field로 분할한다.
int nextDayNum = (dayNum + 1) % 7;
String todayField = "day:" + dayNum + ":" + repeatBlock.getRepeatBlockId() + ":0";
String nextDayField = "day:" + nextDayNum + ":" + repeatBlock.getRepeatBlockId() + ":1";

String todayValue = startAtSec + ":" + END_OF_DAY_SECOND;
String nextDayValue = START_OF_DAY_SECOND + ":" + endAtSec;
hashToWrite.put(todayField, todayValue);
hashToWrite.put(nextDayField, nextDayValue);
}
}

Expand Down
Loading
Loading