package kr.itn.itnhub.provision; import kr.itn.itnhub.config.MattermostProperties; import kr.itn.itnhub.mattermost.MattermostClient; import kr.itn.itnhub.org.Organization; import kr.itn.itnhub.org.OrganizationMapper; 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; public ChannelProvisionService(MattermostClient mattermost, OrganizationMapper mapper, MattermostProperties props) { this.mattermost = mattermost; this.mapper = mapper; this.props = props; } public ProvisionResult provision(Long orgId) { Organization org = mapper.findById(orgId); if (org == null) { throw new IllegalArgumentException("기관을 찾을 수 없습니다: " + orgId); } if (!org.hasRequiredContact()) { 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); return new ProvisionResult(mj, law, messages.toString().trim()); } 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 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); }; } }