ITN Dev 07-22
feat: 재실행 안전한 채널 프로비저닝 추가
VBA M07의 3단계 판정(ID 보유 -> 내부명 조회 -> 표시명 조회 -> 생성)을
ChannelProvisionService로 이식. 트랜잭션을 걸지 않아 외부 API 호출 중
한쪽이 실패해도 성공한 채널의 ID는 반드시 저장되어 재시도가 안전하다.
@ffa4d32409d3a6664101a3ab3860c2df47c15b00
 
src/main/java/kr/itn/itnhub/provision/ChannelProvisionService.java (added)
+++ src/main/java/kr/itn/itnhub/provision/ChannelProvisionService.java
@@ -0,0 +1,118 @@
+package kr.itn.itnhub.provision;
+
+import kr.itn.itnhub.config.MattermostProperties;
+import kr.itn.itnhub.mattermost.MattermostClient;
+import kr.itn.itnhub.mattermost.MattermostException;
+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<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 (MattermostException e) {
+            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)을 결정하는 유일한 지점.
+     * 문자열 키 기반 동적 분기 대신 정적 타입(ChannelKind)으로만 분기하여,
+     * 잘못된 컬럼에 쓰는 실수를 컴파일 타임에 막는다.
+     */
+    private void applyChannelId(Long orgId, ChannelKind kind, String channelId) {
+        if (kind == ChannelKind.MJ) {
+            mapper.updateChannelIdMj(orgId, channelId);
+        } else {
+            mapper.updateChannelIdLaw(orgId, channelId);
+        }
+    }
+}
 
src/main/java/kr/itn/itnhub/provision/ProvisionOutcome.java (added)
+++ src/main/java/kr/itn/itnhub/provision/ProvisionOutcome.java
@@ -0,0 +1,12 @@
+package kr.itn.itnhub.provision;
+
+public enum ProvisionOutcome {
+    /** DB에 이미 Channel ID가 있어 아무것도 하지 않음. */
+    CACHED,
+    /** 서버에 채널이 있어 ID만 가져와 저장함. */
+    RECOVERED,
+    /** 새로 만듦. */
+    CREATED,
+    /** 실패. 반대쪽 결과는 유지된다. */
+    FAILED
+}
 
src/main/java/kr/itn/itnhub/provision/ProvisionResult.java (added)
+++ src/main/java/kr/itn/itnhub/provision/ProvisionResult.java
@@ -0,0 +1,8 @@
+package kr.itn.itnhub.provision;
+
+public record ProvisionResult(ProvisionOutcome mj, ProvisionOutcome law, String message) {
+
+    public boolean complete() {
+        return mj != ProvisionOutcome.FAILED && law != ProvisionOutcome.FAILED;
+    }
+}
 
src/test/java/kr/itn/itnhub/provision/ChannelProvisionServiceTest.java (added)
+++ src/test/java/kr/itn/itnhub/provision/ChannelProvisionServiceTest.java
@@ -0,0 +1,216 @@
+package kr.itn.itnhub.provision;
+
+import kr.itn.itnhub.AbstractDbTest;
+import kr.itn.itnhub.mattermost.MattermostClient;
+import kr.itn.itnhub.mattermost.MattermostException;
+import kr.itn.itnhub.org.OrgStatus;
+import kr.itn.itnhub.org.Organization;
+import kr.itn.itnhub.org.OrganizationMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.jdbc.core.JdbcTemplate;
+
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class ChannelProvisionServiceTest extends AbstractDbTest {
+
+    @Autowired
+    ChannelProvisionService service;
+
+    @Autowired
+    OrganizationMapper mapper;
+
+    @Autowired
+    JdbcTemplate jdbc;
+
+    @MockBean
+    MattermostClient mattermost;
+
+    private Long orgId;
+
+    @BeforeEach
+    void setUp() {
+        jdbc.update("delete from organization");
+
+        Organization org = new Organization();
+        org.setOrgNo("001");
+        org.setOrgName("국제방송교류재단");
+        org.setChannelSlug("001");
+        org.setDeptName("데이터정보화팀");
+        org.setManagerName("송민지");
+        org.setManagerPhone("02-3475-5434");
+        org.setManagerEmail("ming@arirang.com");
+        mapper.upsertBySeed(org);
+
+        orgId = mapper.findAll().get(0).getId();
+    }
+
+    @Test
+    void 채널이_없으면_두_개를_생성하고_ID를_저장한다() {
+        when(mattermost.findChannelIdByInternalName(anyString())).thenReturn(Optional.empty());
+        when(mattermost.findChannelIdByDisplayName(anyString())).thenReturn(Optional.empty());
+        when(mattermost.createPrivateChannel("org-001-mj", "001_국제방송교류재단 (문정원)"))
+                .thenReturn("id-mj");
+        when(mattermost.createPrivateChannel("org-001-law", "001_국제방송교류재단 (법률검토)"))
+                .thenReturn("id-law");
+
+        ProvisionResult result = service.provision(orgId);
+
+        assertThat(result.mj()).isEqualTo(ProvisionOutcome.CREATED);
+        assertThat(result.law()).isEqualTo(ProvisionOutcome.CREATED);
+
+        Organization after = mapper.findById(orgId);
+        assertThat(after.getChannelIdMj()).isEqualTo("id-mj");
+        assertThat(after.getChannelIdLaw()).isEqualTo("id-law");
+        assertThat(after.getStatus()).isEqualTo(OrgStatus.ACTIVE);
+    }
+
+    @Test
+    void 이미_ID가_있으면_아무것도_생성하지_않는다() {
+        mapper.updateChannelIdMj(orgId, "id-mj");
+        mapper.updateChannelIdLaw(orgId, "id-law");
+
+        ProvisionResult result = service.provision(orgId);
+
+        assertThat(result.mj()).isEqualTo(ProvisionOutcome.CACHED);
+        assertThat(result.law()).isEqualTo(ProvisionOutcome.CACHED);
+
+        verify(mattermost, never()).createPrivateChannel(anyString(), anyString());
+        verify(mattermost, never()).findChannelIdByInternalName(anyString());
+    }
+
+    @Test
+    void DB에는_없고_서버에_내부명이_있으면_ID를_복구한다() {
+        when(mattermost.findChannelIdByInternalName("org-001-mj"))
+                .thenReturn(Optional.of("recovered-mj"));
+        when(mattermost.findChannelIdByInternalName("org-001-law"))
+                .thenReturn(Optional.of("recovered-law"));
+
+        ProvisionResult result = service.provision(orgId);
+
+        assertThat(result.mj()).isEqualTo(ProvisionOutcome.RECOVERED);
+        assertThat(result.law()).isEqualTo(ProvisionOutcome.RECOVERED);
+
+        verify(mattermost, never()).createPrivateChannel(anyString(), anyString());
+
+        Organization after = mapper.findById(orgId);
+        assertThat(after.getChannelIdMj()).isEqualTo("recovered-mj");
+        assertThat(after.getChannelIdLaw()).isEqualTo("recovered-law");
+    }
+
+    @Test
+    void 내부명으로_못_찾으면_표시명으로_복구를_시도한다() {
+        when(mattermost.findChannelIdByInternalName(anyString())).thenReturn(Optional.empty());
+        when(mattermost.findChannelIdByDisplayName("001_국제방송교류재단 (문정원)"))
+                .thenReturn(Optional.of("bydisplay-mj"));
+        when(mattermost.findChannelIdByDisplayName("001_국제방송교류재단 (법률검토)"))
+                .thenReturn(Optional.empty());
+        when(mattermost.createPrivateChannel(eq("org-001-law"), anyString()))
+                .thenReturn("new-law");
+
+        ProvisionResult result = service.provision(orgId);
+
+        assertThat(result.mj()).isEqualTo(ProvisionOutcome.RECOVERED);
+        assertThat(result.law()).isEqualTo(ProvisionOutcome.CREATED);
+
+        verify(mattermost, never()).createPrivateChannel(eq("org-001-mj"), anyString());
+    }
+
+    @Test
+    void 법률검토가_실패해도_문정원_ID는_저장된다() {
+        when(mattermost.findChannelIdByInternalName(anyString())).thenReturn(Optional.empty());
+        when(mattermost.findChannelIdByDisplayName(anyString())).thenReturn(Optional.empty());
+        when(mattermost.createPrivateChannel(eq("org-001-mj"), anyString()))
+                .thenReturn("id-mj");
+        when(mattermost.createPrivateChannel(eq("org-001-law"), anyString()))
+                .thenThrow(new MattermostException("서버 오류"));
+
+        ProvisionResult result = service.provision(orgId);
+
+        assertThat(result.mj()).isEqualTo(ProvisionOutcome.CREATED);
+        assertThat(result.law()).isEqualTo(ProvisionOutcome.FAILED);
+        assertThat(result.message()).contains("법률검토");
+
+        Organization after = mapper.findById(orgId);
+        assertThat(after.getChannelIdMj()).isEqualTo("id-mj");
+        assertThat(after.getChannelIdLaw()).isNull();
+        assertThat(after.getStatus()).isEqualTo(OrgStatus.PARTIAL);
+    }
+
+    @Test
+    void 부분_실패_후_재시도는_없는_쪽만_생성한다() {
+        mapper.updateChannelIdMj(orgId, "id-mj");
+
+        when(mattermost.findChannelIdByInternalName("org-001-law")).thenReturn(Optional.empty());
+        when(mattermost.findChannelIdByDisplayName(anyString())).thenReturn(Optional.empty());
+        when(mattermost.createPrivateChannel(eq("org-001-law"), anyString()))
+                .thenReturn("retry-law");
+
+        ProvisionResult result = service.provision(orgId);
+
+        assertThat(result.mj()).isEqualTo(ProvisionOutcome.CACHED);
+        assertThat(result.law()).isEqualTo(ProvisionOutcome.CREATED);
+
+        verify(mattermost, never()).createPrivateChannel(eq("org-001-mj"), anyString());
+        assertThat(mapper.findById(orgId).getStatus()).isEqualTo(OrgStatus.ACTIVE);
+    }
+
+    @Test
+    void 치안정책연구소는_policy_내부명으로_생성한다() {
+        Organization special = new Organization();
+        special.setOrgNo("008");
+        special.setOrgName("경찰청_치안정책연구소");
+        special.setChannelSlug("008-policy");
+        special.setDeptName("연구지원과");
+        special.setManagerName("담당자");
+        special.setManagerPhone("02-0000-0000");
+        special.setManagerEmail("a@police.go.kr");
+        mapper.upsertBySeed(special);
+
+        Long specialId = mapper.findAll().stream()
+                .filter(o -> "경찰청_치안정책연구소".equals(o.getOrgName()))
+                .findFirst().orElseThrow().getId();
+
+        when(mattermost.findChannelIdByInternalName(anyString())).thenReturn(Optional.empty());
+        when(mattermost.findChannelIdByDisplayName(anyString())).thenReturn(Optional.empty());
+        when(mattermost.createPrivateChannel(anyString(), anyString())).thenReturn("x");
+
+        service.provision(specialId);
+
+        verify(mattermost).createPrivateChannel(
+                "org-008-policy-mj", "008_경찰청_치안정책연구소 (문정원)");
+        verify(mattermost).createPrivateChannel(
+                "org-008-policy-law", "008_경찰청_치안정책연구소 (법률검토)");
+    }
+
+    @Test
+    void 담당자정보가_없으면_생성하지_않는다() {
+        Organization pending = new Organization();
+        pending.setOrgNo("099");
+        pending.setOrgName("정보없는기관");
+        pending.setChannelSlug("099");
+        mapper.upsertBySeed(pending);
+
+        Long pendingId = mapper.findAll().stream()
+                .filter(o -> "정보없는기관".equals(o.getOrgName()))
+                .findFirst().orElseThrow().getId();
+
+        ProvisionResult result = service.provision(pendingId);
+
+        assertThat(result.mj()).isEqualTo(ProvisionOutcome.FAILED);
+        assertThat(result.law()).isEqualTo(ProvisionOutcome.FAILED);
+        assertThat(result.message()).contains("담당자 정보");
+
+        verify(mattermost, never()).createPrivateChannel(anyString(), anyString());
+    }
+}
Add a comment
List