package kr.itn.itnhub.provision; import kr.itn.itnhub.config.MattermostProperties; import kr.itn.itnhub.contact.Contact; 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.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; 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()); ensureMembers(org.getId(), messages); return new ProvisionResult(mj, law, messages.toString().trim()); } /** * 채널에 배정된 담당자들의 Mattermost 계정을 보장하고 알맞은 채널에 추가한다. VBA에는 * 없던 신규 기능으로, 채널 존재 여부와 무관하게 (CACHED 포함) 매번 실행되어 멤버십 * 드리프트를 치유한다 - findUserIdByEmail이 계정 존재를 먼저 확인하고 채널 추가도 * 멱등이라 재실행해도 안전하다. * *
이 훅도 postIntro와 같은 원칙을 따른다: 담당자 한 명(또는 채널 추가 한 건)의 실패가 * 나머지 처리를 막지 않고, provision() 결과(CREATED/RECOVERED/CACHED/FAILED)도 뒤집지 * 않는다 - 실패는 messages에만 남는다.
*/ private void ensureMembers(Long orgId, StringBuilder messages) { Organization org = mapper.findById(orgId); if (org == null) { return; } Map이 훅은 채널 생성이 이미 끝난 뒤에 실행되므로, 실패해도 provision() 결과에는 * 영향을 주지 않고 경고 로그만 남긴다 - stage_history 문제로 채널 생성 자체를 * 실패로 보고하면 재시도만 반복하게 만든다.
*/ 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실패해도 채널 생성 결과(CREATED)를 뒤집지 않는다 - 채널은 이미 만들어졌으므로 * 안내글만 나중에 수동으로 올리면 되는 문제다. 메시지에 경고만 남긴다.
*/ private void postIntro(Organization org, ChannelKind kind, String label, String channelId, StringBuilder messages) { try { String postId = mattermost.createPost(channelId, introMessage(org, kind), List.of()); if (postId != null && !postId.isBlank()) { mattermost.pinPost(postId); } } catch (RuntimeException e) { log.warn("최초 게시글 등록 실패 org={} channel={}", org.getOrgName(), channelId, e); messages.append("[").append(label).append("] 채널은 생성됐지만 안내글 등록에 실패했습니다: ") .append(e.getMessage()).append(System.lineSeparator()); } } private String introMessage(Organization org, ChannelKind kind) { StringBuilder sb = new StringBuilder(); sb.append("안녕하세요.\n"); sb.append(org.getOrgName()) .append(kind == ChannelKind.MJ ? " 업무협의 채널입니다.\n" : " 법률검토 채널입니다.\n"); appendContactBlock(sb, "신청기관 담당자", "부서", org.getApplicant()); appendContactBlock(sb, "문정원 담당자", "부서", org.getMj()); appendContactBlock(sb, "㈜아이티앤 담당자", "부서", org.getItn()); appendContactBlock(sb, "담당 변호사", "소속", org.getLawyer()); return sb.toString().stripTrailing(); } /** 배정 안 된 역할은 블록 자체를 생략한다. 빈 항목은 '-'로 채운다. */ private void appendContactBlock(StringBuilder sb, String title, String orgLabel, Contact contact) { if (contact == null) { return; } String orgValue = "소속".equals(orgLabel) ? orDash(contact.getAffiliation()) : orDash(contact.getDeptName()); sb.append('\n') .append("■ ").append(title).append('\n') .append(orgLabel).append(" : ").append(orgValue).append('\n') .append("담당자 : ").append(orDash(contact.getName())).append('\n') .append("직위 : ").append(orDash(contact.getTitle())).append('\n') .append("연락처 : ").append(orDash(contact.getPhone())).append('\n') .append("이메일 : ").append(orDash(contact.getEmail())).append('\n'); } private static String orDash(String value) { return (value == null || value.isBlank()) ? "-" : value; } /** * 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); }; } }