File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
package kr.itn.itnhub.provision;
import kr.itn.itnhub.config.MattermostProperties;
import kr.itn.itnhub.mattermost.MattermostClient;
import kr.itn.itnhub.org.OrgNotFoundException;
import kr.itn.itnhub.org.Organization;
import kr.itn.itnhub.org.OrganizationMapper;
import kr.itn.itnhub.stage.StageHistoryMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.util.Optional;
/**
* 기관의 채널 2개(문정원/법률검토)를 보장한다. VBA M07의 이식.
*
* 판정 순서 (한쪽 채널당):
* 1. DB에 Channel ID가 있으면 그대로 둔다 → CACHED
* 2. 서버에 내부명으로 채널이 있으면 ID를 가져온다 → RECOVERED
* 3. 표시명으로 있으면 ID를 가져온다 → RECOVERED
* 4. 없을 때만 새로 만든다 → CREATED
*
* 트랜잭션을 쓰지 않는다. 외부 API 호출이라 롤백이 불가능하고,
* 한쪽이 실패해도 성공한 쪽의 ID는 반드시 남아야 재시도가 안전해지기 때문이다.
* 절대 @Transactional을 붙이지 말 것.
*/
@Service
public class ChannelProvisionService {
private static final Logger log = LoggerFactory.getLogger(ChannelProvisionService.class);
private final MattermostClient mattermost;
private final OrganizationMapper mapper;
private final MattermostProperties props;
private final StageHistoryMapper stageHistoryMapper;
public ChannelProvisionService(MattermostClient mattermost,
OrganizationMapper mapper,
MattermostProperties props,
StageHistoryMapper stageHistoryMapper) {
this.mattermost = mattermost;
this.mapper = mapper;
this.props = props;
this.stageHistoryMapper = stageHistoryMapper;
}
public ProvisionResult provision(Long orgId) {
Organization org = mapper.findById(orgId);
if (org == null) {
throw new OrgNotFoundException("기관을 찾을 수 없습니다: " + orgId);
}
if (!org.hasApplicantAssigned()) {
return new ProvisionResult(
ProvisionOutcome.FAILED,
ProvisionOutcome.FAILED,
"신청기관 담당자가 지정되지 않아 채널을 만들 수 없습니다.");
}
StringBuilder messages = new StringBuilder();
ProvisionOutcome mj = ensure(org, ChannelKind.MJ,
org.getChannelIdMj(), props.channelNameMj(), messages);
ProvisionOutcome law = ensure(org, ChannelKind.LAW,
org.getChannelIdLaw(), props.channelNameLaw(), messages);
maybeStartStage(org.getId());
return new ProvisionResult(mj, law, messages.toString().trim());
}
/**
* 채널 2개가 이번 호출로 처음 모두 갖춰지고 아직 어떤 단계도 시작되지 않았다면
* 진행단계를 1(신청)로 자동 시작한다. 이미 단계가 있는 기관은 건드리지 않는다
* (재실행 안전성 - 사람이 수동으로 바꿔둔 단계를 채널 재확인이 되돌리면 안 된다).
*
* <p>이 훅은 채널 생성이 이미 끝난 뒤에 실행되므로, 실패해도 provision() 결과에는
* 영향을 주지 않고 경고 로그만 남긴다 - stage_history 문제로 채널 생성 자체를
* 실패로 보고하면 재시도만 반복하게 만든다.</p>
*/
private void maybeStartStage(Long orgId) {
try {
Organization current = mapper.findById(orgId);
if (current == null || current.getStage() != null) {
return;
}
if (filled(current.getChannelIdMj()) && filled(current.getChannelIdLaw())) {
mapper.updateStage(orgId, 1);
stageHistoryMapper.insert(orgId, 1);
}
} catch (RuntimeException e) {
log.warn("진행단계 자동 시작 실패 org={}", orgId, e);
}
}
private static boolean filled(String value) {
return value != null && !value.isBlank();
}
private ProvisionOutcome ensure(Organization org,
ChannelKind kind,
String storedChannelId,
String label,
StringBuilder messages) {
if (storedChannelId != null && !storedChannelId.isBlank()) {
return ProvisionOutcome.CACHED;
}
String internalName = ChannelNaming.internalName(
org.getOrgNo(), org.getOrgName(), kind);
String displayName = ChannelNaming.displayName(
org.getOrgNo(), org.getOrgName(), label);
try {
Optional<String> recovered = mattermost.findChannelIdByInternalName(internalName);
if (recovered.isEmpty()) {
recovered = mattermost.findChannelIdByDisplayName(displayName);
}
if (recovered.isPresent()) {
applyChannelId(org.getId(), kind, recovered.get());
return ProvisionOutcome.RECOVERED;
}
String created = mattermost.createPrivateChannel(internalName, displayName);
applyChannelId(org.getId(), kind, created);
return ProvisionOutcome.CREATED;
} catch (RuntimeException e) {
// MattermostException(서버 API 실패)뿐 아니라, 채널 생성/복구 직후 ID를 저장하는
// mapper 호출이 던지는 DataAccessException 등도 여기서 잡는다. Error나 checked
// exception은 의도적으로 넓히지 않는다 - 이 두 경우는 채널 하나만 FAILED로
// 강등시키고 메시지에 남긴 뒤, 나머지 채널은 계속 시도되게 해야 한다.
log.warn("채널 처리 실패 org={} channel={}", org.getOrgName(), displayName, e);
messages.append("[").append(label).append("] ")
.append(e.getMessage()).append(System.lineSeparator());
return ProvisionOutcome.FAILED;
}
}
/**
* Channel ID를 저장할 컬럼(mj/law)을 결정하는 유일한 지점.
* switch 식(expression)으로 작성해 ChannelKind에 값이 추가되고도 분기를
* 채우지 않으면 컴파일이 실패하도록 강제한다 - if/else였다면 새 값이
* 조용히 else(법률검토) 분기로 흘러 들어가는 것을 막지 못했을 것이다.
*/
private void applyChannelId(Long orgId, ChannelKind kind, String channelId) {
// 반환값(영향받은 행 수)은 쓰지 않지만, switch를 식으로 유지해야
// 컴파일러가 exhaustiveness를 검사한다 - 문(statement) 형태의
// switch/if-else는 이 검사를 강제하지 않는다.
var ignored = switch (kind) {
case MJ -> mapper.updateChannelIdMj(orgId, channelId);
case LAW -> mapper.updateChannelIdLaw(orgId, channelId);
};
}
}