Swing 알림 체계 도입 - 기존 Q&A 관리자 알림 제거 - Swing Notifier 구현
This commit is contained in:
+383
-15
@@ -1,18 +1,90 @@
|
||||
package com.eactive.eai.rms.ext.djb.apistatus;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import com.eactive.apim.portal.common.entity.Auditable;
|
||||
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncident;
|
||||
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncidentApi;
|
||||
import com.eactive.apim.portal.djb.apistatus.incident.entity.DjbApistatusIncidentTimeline;
|
||||
import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentKind;
|
||||
import com.eactive.apim.portal.djb.apistatus.incident.entity.IncidentState;
|
||||
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentApiRepository;
|
||||
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentRepository;
|
||||
import com.eactive.apim.portal.djb.apistatus.incident.repository.DjbApistatusIncidentTimelineRepository;
|
||||
import com.eactive.apim.portal.portalNotice.entity.PortalNotice;
|
||||
import com.eactive.eai.rms.data.entity.onl.apim.portalnotice.PortalNoticeService;
|
||||
import com.eactive.eai.rms.data.entity.onl.djb.apistatus.ApiNameRow;
|
||||
|
||||
/**
|
||||
* API 상태 탐지 결과를 개발자포탈 API Status 화면용 장애 데이터로 반영한다.
|
||||
*
|
||||
* <p>탐지 자체는 {@link ApiStatusService} 가 수행하며, 본 서비스는 그 결과를
|
||||
* {@code DJB_APISTATUS_INCIDENT} / {@code _API} / {@code _TIMELINE} + {@code PTL_NOTICE} 초안으로 기록한다.</p>
|
||||
*
|
||||
* <p>점검(CONTROL_*) 은 관리자가 공지사항으로 등록한 점검 일정이 원천이다
|
||||
* ({@code ApiStatusRepository.findApiStatusEvents} 가 INCIDENT 기간을 읽어 CTRL_YN 을 판정).
|
||||
* 따라서 CONTROL 이벤트를 다시 기록하면 순환이 되므로 기록하지 않는다.</p>
|
||||
*/
|
||||
@Service
|
||||
@Transactional
|
||||
@Transactional(transactionManager = "transactionManagerForEMS", rollbackFor = Exception.class)
|
||||
public class ApiStatusDetectionService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ApiStatusDetectionService.class);
|
||||
|
||||
|
||||
public static final String EVENT_CONTROL_START = "CONTROL_START";
|
||||
public static final String EVENT_CONTROL_END = "CONTROL_END";
|
||||
public static final String EVENT_ERROR_START = "ERROR_START";
|
||||
public static final String EVENT_ERROR_END = "ERROR_END";
|
||||
public static final String EVENT_DELAY_START = "DELAY_START";
|
||||
public static final String EVENT_DELAY_END = "DELAY_END";
|
||||
|
||||
/** INTERFACE_ID 멱등 윈도우 (분) */
|
||||
private static final int IDEMPOTENT_WINDOW_MINUTES = 5;
|
||||
|
||||
/** 종결 상태 - 신규 탐지 병합 대상에서 제외 */
|
||||
private static final List<IncidentState> CLOSED_STATES =
|
||||
Collections.unmodifiableList(Arrays.asList(IncidentState.RESOLVED, IncidentState.CANCELED));
|
||||
|
||||
private static final String AUTHOR_SYSTEM = "SYSTEM";
|
||||
private static final String DETECTED_BY_AUTO = "AUTO";
|
||||
private static final String NOTICE_TYPE_INCIDENT = "3";
|
||||
private static final int NOTICE_SUBJECT_MAX_LENGTH = 255;
|
||||
|
||||
@Autowired
|
||||
private DjbApistatusIncidentRepository incidentRepository;
|
||||
|
||||
@Autowired
|
||||
private DjbApistatusIncidentApiRepository incidentApiRepository;
|
||||
|
||||
@Autowired
|
||||
private DjbApistatusIncidentTimelineRepository incidentTimelineRepository;
|
||||
|
||||
@Autowired
|
||||
private PortalNoticeService portalNoticeService;
|
||||
|
||||
@Autowired
|
||||
private ApiStatusRepository apiStatusRepository;
|
||||
|
||||
/**
|
||||
* 이벤트 감지
|
||||
* @param event "CONTROL_START" : 점검시작
|
||||
@@ -25,17 +97,313 @@ public class ApiStatusDetectionService {
|
||||
*/
|
||||
public void detect(String event, List<String> apiIds) {
|
||||
log.debug("이벤트 감지: {} {}", event, apiIds);
|
||||
}
|
||||
|
||||
// API FSM 내 아직 장애로 남아있는 API 목록
|
||||
public void remainDownApiIds() {
|
||||
|
||||
}
|
||||
|
||||
|
||||
// 정상 동작 - detect()에서 일괄 처리 (복구 = 점검종료, 장애종료, 지연종료)
|
||||
// public void recovered(String event, String[] apiIds) {
|
||||
//
|
||||
// }
|
||||
|
||||
}
|
||||
if (StringUtils.isBlank(event) || apiIds == null || apiIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
List<String> targets = apiIds.stream()
|
||||
.filter(StringUtils::isNotBlank)
|
||||
.distinct()
|
||||
.sorted()
|
||||
.collect(Collectors.toList());
|
||||
if (targets.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event) {
|
||||
case EVENT_ERROR_START:
|
||||
openIncident(event, targets, "에러율 임계 초과", "장애");
|
||||
break;
|
||||
case EVENT_DELAY_START:
|
||||
openIncident(event, targets, "응답시간 임계 초과", "응답지연");
|
||||
break;
|
||||
case EVENT_ERROR_END:
|
||||
case EVENT_DELAY_END:
|
||||
markRecovered(event, targets);
|
||||
break;
|
||||
case EVENT_CONTROL_START:
|
||||
case EVENT_CONTROL_END:
|
||||
// 점검은 관리자 등록 공지가 원천 - 재기록하지 않음
|
||||
log.debug("점검 이벤트는 장애 데이터로 기록하지 않음: {} {}", event, targets);
|
||||
break;
|
||||
default:
|
||||
log.warn("알 수 없는 이벤트: {}", event);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 미종결 장애로 남아있는 API 목록 (탐지 Job 폴링용)
|
||||
*/
|
||||
@Transactional(transactionManager = "transactionManagerForEMS", readOnly = true)
|
||||
public List<String> remainDownApiIds() {
|
||||
List<DjbApistatusIncident> openIncidents =
|
||||
incidentRepository.findByKindAndStateNotInOrderByStartedAtDesc(IncidentKind.INCIDENT, CLOSED_STATES);
|
||||
if (openIncidents.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
List<Long> incidentIds = openIncidents.stream()
|
||||
.map(DjbApistatusIncident::getIncidentId)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return incidentApiRepository.findByIncidentIdInOrderByIncidentIdAscApiIdAsc(incidentIds).stream()
|
||||
.filter(api -> api.getRecoveredAt() == null)
|
||||
.map(DjbApistatusIncidentApi::getApiId)
|
||||
.distinct()
|
||||
.sorted()
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
// ────────────── 장애 발생 ──────────────
|
||||
|
||||
private void openIncident(String event, List<String> apiIds, String summary, String titleKeyword) {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
String interfaceId = makeInterfaceId(event, apiIds, now);
|
||||
|
||||
// 1) 동일 윈도우 재탐지 - 타임라인만 남기고 종료
|
||||
Optional<DjbApistatusIncident> duplicated = incidentRepository.findByInterfaceId(interfaceId);
|
||||
if (duplicated.isPresent()) {
|
||||
appendTimeline(duplicated.get().getIncidentId(), null,
|
||||
"재탐지 신호 수신 (" + event + ")", now);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2) 이미 열려있는 자동 등록 장애가 해당 API 를 포함하면 영향 API 만 병합
|
||||
// (관리자가 직접 작성한 장애 공지는 건드리지 않는다)
|
||||
List<DjbApistatusIncident> openIncidents = incidentRepository
|
||||
.findOpenByApiIds(IncidentKind.INCIDENT, DETECTED_BY_AUTO, CLOSED_STATES, apiIds);
|
||||
if (!openIncidents.isEmpty()) {
|
||||
mergeIntoOpenIncident(openIncidents.get(0), event, apiIds, now);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3) 신규 장애 + 공지 초안 생성
|
||||
Map<String, String> apiNames = resolveApiNames(apiIds);
|
||||
String title = buildTitle(titleKeyword, apiIds, apiNames);
|
||||
|
||||
PortalNotice notice = createDraftNotice(title, summary, apiIds, apiNames, now);
|
||||
|
||||
DjbApistatusIncident incident = new DjbApistatusIncident();
|
||||
incident.setKind(IncidentKind.INCIDENT);
|
||||
incident.setState(IncidentState.INVESTIGATING);
|
||||
incident.setTitle(title);
|
||||
incident.setSummary(summary);
|
||||
incident.setStartedAt(now);
|
||||
incident.setDetectedBy(DETECTED_BY_AUTO);
|
||||
incident.setInterfaceId(interfaceId);
|
||||
incident.setNoticeId(notice.getId());
|
||||
incident.setDraftYn("Y");
|
||||
incident.setFixYn("N");
|
||||
stampAudit(incident, now);
|
||||
|
||||
DjbApistatusIncident saved;
|
||||
try {
|
||||
saved = incidentRepository.saveAndFlush(incident);
|
||||
} catch (DataIntegrityViolationException e) {
|
||||
// INTERFACE_ID UNIQUE 충돌 - 동시 탐지. 공지 초안 정리 후 타임라인만 남긴다
|
||||
log.info("장애 중복 감지 (interfaceId={}) - 기존 장애에 타임라인 추가", interfaceId, e);
|
||||
portalNoticeService.deleteById(notice.getId());
|
||||
incidentRepository.findByInterfaceId(interfaceId).ifPresent(existing ->
|
||||
appendTimeline(existing.getIncidentId(), null,
|
||||
"재탐지 신호 수신 (" + event + ")", now));
|
||||
return;
|
||||
}
|
||||
|
||||
saveIncidentApis(saved.getIncidentId(), apiIds, apiNames, now);
|
||||
appendTimeline(saved.getIncidentId(), IncidentState.INVESTIGATING,
|
||||
summary + " 자동 감지 (" + event + ")\n영향 API: " + String.join(", ", apiIds), now);
|
||||
|
||||
log.info("자동 장애 등록: incidentId={}, noticeId={}, apis={}",
|
||||
saved.getIncidentId(), notice.getId(), apiIds);
|
||||
}
|
||||
|
||||
private void mergeIntoOpenIncident(DjbApistatusIncident incident, String event,
|
||||
List<String> apiIds, LocalDateTime now) {
|
||||
Long incidentId = incident.getIncidentId();
|
||||
|
||||
Set<String> mapped = incidentApiRepository.findByIncidentIdOrderByApiId(incidentId).stream()
|
||||
.map(DjbApistatusIncidentApi::getApiId)
|
||||
.collect(Collectors.toCollection(HashSet::new));
|
||||
|
||||
List<String> added = apiIds.stream()
|
||||
.filter(apiId -> !mapped.contains(apiId))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
if (added.isEmpty()) {
|
||||
appendTimeline(incidentId, null, "재탐지 신호 수신 (" + event + ")", now);
|
||||
log.debug("기존 장애에 이미 포함된 API - 타임라인만 추가: incidentId={}, apis={}", incidentId, apiIds);
|
||||
return;
|
||||
}
|
||||
|
||||
saveIncidentApis(incidentId, added, resolveApiNames(added), now);
|
||||
appendTimeline(incidentId, null,
|
||||
"영향 API 추가 감지 (" + event + ")\n" + String.join(", ", added), now);
|
||||
log.info("기존 장애에 영향 API 병합: incidentId={}, addedApis={}", incidentId, added);
|
||||
}
|
||||
|
||||
// ────────────── 복구 ──────────────
|
||||
|
||||
private void markRecovered(String event, List<String> apiIds) {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
|
||||
List<DjbApistatusIncidentApi> candidates =
|
||||
incidentApiRepository.findByApiIdInAndRecoveredAtIsNull(apiIds);
|
||||
if (candidates.isEmpty()) {
|
||||
log.debug("복구 대상 장애 없음: {} {}", event, apiIds);
|
||||
return;
|
||||
}
|
||||
|
||||
Map<Long, DjbApistatusIncident> openIncidents = incidentRepository
|
||||
.findByKindAndStateNotInOrderByStartedAtDesc(IncidentKind.INCIDENT, CLOSED_STATES).stream()
|
||||
.collect(Collectors.toMap(DjbApistatusIncident::getIncidentId, incident -> incident));
|
||||
|
||||
Map<Long, List<String>> recoveredByIncident = new HashMap<>();
|
||||
for (DjbApistatusIncidentApi api : candidates) {
|
||||
if (!openIncidents.containsKey(api.getIncidentId())) {
|
||||
continue;
|
||||
}
|
||||
api.setRecoveredAt(now);
|
||||
api.setLastModifiedBy(AUTHOR_SYSTEM);
|
||||
api.setLastModifiedDate(now);
|
||||
incidentApiRepository.save(api);
|
||||
|
||||
recoveredByIncident.computeIfAbsent(api.getIncidentId(), key -> new ArrayList<>())
|
||||
.add(api.getApiId());
|
||||
}
|
||||
|
||||
if (recoveredByIncident.isEmpty()) {
|
||||
log.debug("복구 대상 장애 없음 (미종결 장애 미포함): {} {}", event, apiIds);
|
||||
return;
|
||||
}
|
||||
|
||||
for (Map.Entry<Long, List<String>> entry : recoveredByIncident.entrySet()) {
|
||||
Long incidentId = entry.getKey();
|
||||
List<String> recoveredApis = entry.getValue();
|
||||
DjbApistatusIncident incident = openIncidents.get(incidentId);
|
||||
|
||||
boolean allRecovered = incidentApiRepository.countByIncidentIdAndRecoveredAtIsNull(incidentId) == 0;
|
||||
IncidentState nextState = allRecovered ? IncidentState.RESOLVED : IncidentState.MONITORING;
|
||||
|
||||
if (incident.getState() != nextState) {
|
||||
incident.setPreviousState(incident.getState());
|
||||
incident.setState(nextState);
|
||||
}
|
||||
if (allRecovered) {
|
||||
incident.setEndAt(now);
|
||||
}
|
||||
incident.setLastModifiedBy(AUTHOR_SYSTEM);
|
||||
incident.setLastModifiedDate(now);
|
||||
incidentRepository.save(incident);
|
||||
|
||||
String body = (allRecovered ? "전체 복구 확인 (" : "일부 복구 확인 (") + event + ")\n"
|
||||
+ String.join(", ", recoveredApis);
|
||||
appendTimeline(incidentId, nextState, body, now);
|
||||
|
||||
log.info("자동 복구 처리: incidentId={}, state={}, apis={}", incidentId, nextState, recoveredApis);
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────── 내부 헬퍼 ──────────────
|
||||
|
||||
private String makeInterfaceId(String event, List<String> sortedApiIds, LocalDateTime now) {
|
||||
long windowSeconds = IDEMPOTENT_WINDOW_MINUTES * 60L;
|
||||
long flooredEpoch = now.atZone(ZoneId.systemDefault()).toEpochSecond()
|
||||
/ windowSeconds * windowSeconds;
|
||||
String joined = String.join(",", sortedApiIds);
|
||||
String interfaceId = event + ":" + joined + ":" + flooredEpoch;
|
||||
|
||||
// INTERFACE_ID 는 VARCHAR2(200) - 초과 시 API 목록을 해시로 축약
|
||||
if (interfaceId.length() > 200) {
|
||||
interfaceId = event + ":#" + Integer.toHexString(joined.hashCode())
|
||||
+ ":" + sortedApiIds.size() + ":" + flooredEpoch;
|
||||
}
|
||||
return interfaceId;
|
||||
}
|
||||
|
||||
private Map<String, String> resolveApiNames(List<String> apiIds) {
|
||||
Map<String, String> names = new HashMap<>();
|
||||
try {
|
||||
for (ApiNameRow row : apiStatusRepository.findApiNames(apiIds)) {
|
||||
names.put(row.getEaisvcname(), row.getEaisvcdesc());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("API 명 조회 실패 - API ID 를 그대로 사용", e);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
private String apiLabel(String apiId, Map<String, String> apiNames) {
|
||||
return StringUtils.defaultIfBlank(apiNames.get(apiId), apiId);
|
||||
}
|
||||
|
||||
private String buildTitle(String titleKeyword, List<String> apiIds, Map<String, String> apiNames) {
|
||||
String first = apiLabel(apiIds.get(0), apiNames);
|
||||
if (apiIds.size() > 1) {
|
||||
return "[자동감지] " + first + " 외 " + (apiIds.size() - 1) + "종 API " + titleKeyword;
|
||||
}
|
||||
return "[자동감지] " + first + " API " + titleKeyword;
|
||||
}
|
||||
|
||||
private PortalNotice createDraftNotice(String title, String summary, List<String> apiIds,
|
||||
Map<String, String> apiNames, LocalDateTime now) {
|
||||
StringBuilder detail = new StringBuilder();
|
||||
detail.append("<p>").append(summary).append(" 로 자동 감지된 장애입니다. 관리자 검수 후 정식 게시됩니다.</p>");
|
||||
detail.append("<p>영향 API</p><ul>");
|
||||
for (String apiId : apiIds) {
|
||||
detail.append("<li>").append(apiLabel(apiId, apiNames)).append(" (").append(apiId).append(")</li>");
|
||||
}
|
||||
detail.append("</ul>");
|
||||
|
||||
PortalNotice notice = new PortalNotice();
|
||||
notice.setId(null);
|
||||
notice.setNoticeSubject(StringUtils.abbreviate(title, NOTICE_SUBJECT_MAX_LENGTH));
|
||||
notice.setNoticeDetail(detail.toString());
|
||||
notice.setNoticeType(NOTICE_TYPE_INCIDENT);
|
||||
notice.setUseYn("N");
|
||||
notice.setFixYn("N");
|
||||
notice.setReadCount(0L);
|
||||
notice.setInquirerName(AUTHOR_SYSTEM);
|
||||
notice.setCreatedBy(AUTHOR_SYSTEM);
|
||||
notice.setCreatedDate(now);
|
||||
|
||||
portalNoticeService.save(notice);
|
||||
return notice;
|
||||
}
|
||||
|
||||
private void saveIncidentApis(Long incidentId, Collection<String> apiIds,
|
||||
Map<String, String> apiNames, LocalDateTime now) {
|
||||
List<DjbApistatusIncidentApi> rows = new ArrayList<>();
|
||||
for (String apiId : apiIds) {
|
||||
DjbApistatusIncidentApi api = new DjbApistatusIncidentApi();
|
||||
api.setIncidentId(incidentId);
|
||||
api.setApiId(apiId);
|
||||
api.setApiName(apiNames.get(apiId));
|
||||
stampAudit(api, now);
|
||||
rows.add(api);
|
||||
}
|
||||
if (!rows.isEmpty()) {
|
||||
incidentApiRepository.saveAll(rows);
|
||||
}
|
||||
}
|
||||
|
||||
private void appendTimeline(Long incidentId, IncidentState stateAfter, String body, LocalDateTime now) {
|
||||
DjbApistatusIncidentTimeline timeline = new DjbApistatusIncidentTimeline();
|
||||
timeline.setIncidentId(incidentId);
|
||||
timeline.setEventAt(now);
|
||||
timeline.setStateAfter(stateAfter);
|
||||
timeline.setBody(StringUtils.abbreviate(body, 4000));
|
||||
timeline.setAuthorType(AUTHOR_SYSTEM);
|
||||
timeline.setVisibleYn("Y");
|
||||
stampAudit(timeline, now);
|
||||
incidentTimelineRepository.save(timeline);
|
||||
}
|
||||
|
||||
/**
|
||||
* 스케줄러 스레드는 로그인 세션이 없어 AuditorAware 가 비어있다. CREATED_BY 는 NOT NULL 이므로 직접 채운다.
|
||||
*/
|
||||
private void stampAudit(Auditable entity, LocalDateTime now) {
|
||||
entity.setCreatedBy(AUTHOR_SYSTEM);
|
||||
entity.setCreatedDate(now);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@ import java.util.List;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.Collection;
|
||||
|
||||
import com.eactive.eai.data.jpa.BaseRepository;
|
||||
import com.eactive.eai.rms.data.entity.onl.djb.apistatus.ApiNameRow;
|
||||
import com.eactive.eai.rms.data.entity.onl.djb.apistatus.ApiStatus;
|
||||
import com.eactive.eai.rms.data.entity.onl.djb.apistatus.ApiStatusEvent;
|
||||
|
||||
@@ -73,4 +76,11 @@ public interface ApiStatusRepository extends BaseRepository<ApiStatus, String> {
|
||||
@Param("delayRangeMinute") int delayRangeMinute,
|
||||
@Param("delayAvgRespTime") int delayAvgRespTime
|
||||
);
|
||||
|
||||
/**
|
||||
* EAI 서비스명 조회. 장애/점검 영향 API 의 API_NAME 캐시용.
|
||||
*/
|
||||
@Query(nativeQuery = true, value =
|
||||
" SELECT EAISVCNAME, EAISVCDESC FROM TSEAIHE01 WHERE EAISVCNAME IN (:apiIds)")
|
||||
List<ApiNameRow> findApiNames(@Param("apiIds") Collection<String> apiIds);
|
||||
}
|
||||
+2
-2
@@ -82,9 +82,9 @@ public class MessageRequestManService extends BaseService {
|
||||
return t == null ? "" : t.format(f);
|
||||
}
|
||||
|
||||
/** 상태 검색 콤보 (PENDING/SENT/FAILED). */
|
||||
/** 상태 검색 콤보 (PENDING/SENT/FAILED/SKIPPED). SKIPPED 는 행번 아닌 ID 로 발송을 건너뛴 건. */
|
||||
public List<ComboVo> statusCombo() {
|
||||
return Arrays.stream(new String[]{"PENDING", "SENT", "FAILED"})
|
||||
return Arrays.stream(new String[]{"PENDING", "SENT", "FAILED", "SKIPPED"})
|
||||
.map(s -> {
|
||||
ComboVo v = new ComboVo();
|
||||
v.setCode(s);
|
||||
|
||||
Reference in New Issue
Block a user