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> channelContacts = new LinkedHashMap<>(); addChannelContacts(channelContacts, org.getChannelIdMj(), org.getApplicant(), org.getMj(), org.getItn()); addChannelContacts(channelContacts, org.getChannelIdLaw(), org.getLawyer(), org.getMj(), org.getItn()); // 이메일(대소문자 무시) 기준으로 담당자를 중복 없이 모은다 - 같은 사람이 양쪽 // 채널(예: 문정원 담당자)에 동시에 배정될 수 있으므로 계정 조회/생성은 한 번만 한다. Map distinctContacts = new LinkedHashMap<>(); for (List contacts : channelContacts.values()) { for (Contact contact : contacts) { distinctContacts.putIfAbsent(contact.getEmail().toLowerCase(), contact); } } Map resolvedUserIds = new LinkedHashMap<>(); for (Contact contact : distinctContacts.values()) { try { resolvedUserIds.put(contact.getEmail().toLowerCase(), resolveUser(contact)); } catch (RuntimeException e) { log.warn("멤버 계정 준비 실패 org={} contact={}", org.getOrgName(), contact.getEmail(), e); appendMemberFailure(messages, contact, e); } } for (Map.Entry> entry : channelContacts.entrySet()) { String channelId = entry.getKey(); for (Contact contact : entry.getValue()) { String userId = resolvedUserIds.get(contact.getEmail().toLowerCase()); if (userId == null) { // 계정 준비 단계에서 이미 실패해 메시지에 남겼다 - 채널 추가는 시도하지 않는다. continue; } try { mattermost.addUserToChannel(channelId, userId); } catch (RuntimeException e) { log.warn("채널 멤버 추가 실패 org={} channel={} contact={}", org.getOrgName(), channelId, contact.getEmail(), e); appendMemberFailure(messages, contact, e); } } } } /** null 계약(계약자가 없거나 이메일이 비어 있으면)는 후보에서 제외하고, 채널 ID가 빈칸이면 그 채널 자체를 건너뛴다. */ private void addChannelContacts(Map> channelContacts, String channelId, Contact... candidates) { if (!filled(channelId)) { return; } List contacts = new ArrayList<>(); for (Contact candidate : candidates) { if (candidate != null && filled(candidate.getEmail())) { contacts.add(candidate); } } channelContacts.put(channelId, contacts); } /** * 이메일로 기존 계정을 찾고, 없으면 새로 만든 뒤 팀에 추가한다. 계정명이 이미 쓰이고 * 있으면({@code username_exists}) 로컬파트에 도메인 첫 라벨을 덧붙여 한 번만 재시도한다. */ private String resolveUser(Contact contact) { String email = contact.getEmail(); String userId = mattermost.findUserIdByEmail(email).orElse(null); if (userId == null) { String username = usernameFromEmail(email); try { userId = mattermost.createUser(email, username, props.defaultUserPassword()); } catch (RuntimeException e) { if (e.getMessage() != null && e.getMessage().contains("username_exists")) { userId = mattermost.createUser(email, retryUsernameFromEmail(email), props.defaultUserPassword()); } else { throw e; } } } mattermost.addUserToTeam(userId); return userId; } private void appendMemberFailure(StringBuilder messages, Contact contact, RuntimeException e) { messages.append("[멤버 초대] ").append(orDash(contact.getName())).append(": ") .append(e.getMessage()).append(System.lineSeparator()); } private static String localPart(String email) { int at = email.indexOf('@'); return at >= 0 ? email.substring(0, at) : email; } private static String domainPart(String email) { int at = email.indexOf('@'); return at >= 0 ? email.substring(at + 1) : ""; } /** [a-z0-9._-] 밖의 문자는 전부 '-'로 치환한다. */ private static String sanitizeUsernameSegment(String raw) { return raw.toLowerCase().replaceAll("[^a-z0-9._-]", "-"); } private static String usernameFromEmail(String email) { return sanitizeUsernameSegment(localPart(email)); } private static String retryUsernameFromEmail(String email) { String domain = domainPart(email); int dot = domain.indexOf('.'); String firstLabel = dot >= 0 ? domain.substring(0, dot) : domain; return usernameFromEmail(email) + "." + sanitizeUsernameSegment(firstLabel); } /** * 채널 2개가 이번 호출로 처음 모두 갖춰지고 아직 어떤 단계도 시작되지 않았다면 * 진행단계를 1(신청)로 자동 시작한다. 이미 단계가 있는 기관은 건드리지 않는다 * (재실행 안전성 - 사람이 수동으로 바꿔둔 단계를 채널 재확인이 되돌리면 안 된다). * *

이 훅은 채널 생성이 이미 끝난 뒤에 실행되므로, 실패해도 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 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); postIntro(org, kind, label, created, messages); 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; } } /** * 새로 생성된 채널에만 담당자 안내(최초 게시글)를 올리고 핀 고정한다. VBA M10의 이식. * CACHED/RECOVERED 채널에는 올리지 않는다 - 재실행할 때마다 안내글이 쌓이면 안 되고, * 기존 채널에는 이미 안내글이 있을 수 있기 때문이다. * *

실패해도 채널 생성 결과(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); }; } }