ITN Dev 07-24
feat: 기관 진행단계(1-12) 저장/변경과 이력 관리를 추가
- V8 마이그레이션으로 organization.stage와 stage_history 테이블 도입
- PUT /api/orgs/{id}/stage: 1..12 범위 검증 후 단계 변경 + 이력 적재
- 채널 2개가 처음 모두 갖춰지면 진행단계 1(신청)을 자동 시작(재실행 안전)
- GET /api/orgs/{id}/timeline: 단계 이력·업무메모·채널 첨부파일을 최신순으로 합쳐 제공
  (Mattermost 조회 실패는 채널별로 흡수하고 200 유지)
@1119125bb90b8cab784c147020ce5c8d0c9ac3e0
src/main/java/kr/itn/itnhub/config/GlobalExceptionHandler.java
--- src/main/java/kr/itn/itnhub/config/GlobalExceptionHandler.java
+++ src/main/java/kr/itn/itnhub/config/GlobalExceptionHandler.java
@@ -6,6 +6,7 @@
 import kr.itn.itnhub.feed.InvalidMessageException;
 import kr.itn.itnhub.org.OrgNotFoundException;
 import kr.itn.itnhub.seed.SeedParseException;
+import kr.itn.itnhub.stage.InvalidStageException;
 import org.springframework.http.HttpStatus;
 import org.springframework.http.ResponseEntity;
 import org.springframework.web.bind.MethodArgumentNotValidException;
@@ -47,6 +48,9 @@
  *
  * <p>{@link InvalidContactException}은 담당자 구분(category)이 APPLICANT/MJ/LAWYER/OPERATOR
  * 중 하나가 아니거나, 기관에 배정하려는 담당자의 구분이 그 역할과 맞지 않을 때
+ * 던진다 - 흔한 사용자 실수이므로 400을 쓴다.</p>
+ *
+ * <p>{@link InvalidStageException}은 진행단계 변경 요청의 값이 1..12 범위를 벗어났을 때
  * 던진다 - 흔한 사용자 실수이므로 400을 쓴다.</p>
  *
  * <p><b>여기에 {@code Exception.class} catch-all을 추가하지 말 것.</b> 예상하지 못한
@@ -102,4 +106,9 @@
     public ResponseEntity<ApiError> handleInvalidContact(InvalidContactException e) {
         return ResponseEntity.badRequest().body(new ApiError(e.getMessage()));
     }
+
+    @ExceptionHandler(InvalidStageException.class)
+    public ResponseEntity<ApiError> handleInvalidStage(InvalidStageException e) {
+        return ResponseEntity.badRequest().body(new ApiError(e.getMessage()));
+    }
 }
src/main/java/kr/itn/itnhub/org/OrgResponse.java
--- src/main/java/kr/itn/itnhub/org/OrgResponse.java
+++ src/main/java/kr/itn/itnhub/org/OrgResponse.java
@@ -13,7 +13,8 @@
         Contact lawyer,
         String lawyerAssignedDate,
         String channelIdMj,
-        String channelIdLaw) {
+        String channelIdLaw,
+        Integer stage) {
 
     public static OrgResponse of(Organization org) {
         return new OrgResponse(
@@ -27,6 +28,7 @@
                 org.getLawyer(),
                 org.getLawyerAssignedDate(),
                 org.getChannelIdMj(),
-                org.getChannelIdLaw());
+                org.getChannelIdLaw(),
+                org.getStage());
     }
 }
src/main/java/kr/itn/itnhub/org/Organization.java
--- src/main/java/kr/itn/itnhub/org/Organization.java
+++ src/main/java/kr/itn/itnhub/org/Organization.java
@@ -15,6 +15,7 @@
     private String lawyerAssignedDate;
     private String channelIdMj;
     private String channelIdLaw;
+    private Integer stage;
 
     /** findAll/findById가 contact 테이블과 조인해 채워준다. 시드/upsert 경로에서는 비어 있다. */
     private Contact applicant;
@@ -81,6 +82,10 @@
     public String getChannelIdLaw() { return channelIdLaw; }
     public void setChannelIdLaw(String channelIdLaw) { this.channelIdLaw = channelIdLaw; }
 
+    /** null이면 아직 어떤 진행단계도 시작되지 않은 것이다(채널 생성 전). */
+    public Integer getStage() { return stage; }
+    public void setStage(Integer stage) { this.stage = stage; }
+
     public Contact getApplicant() { return applicant; }
     public void setApplicant(Contact applicant) { this.applicant = applicant; }
 
src/main/java/kr/itn/itnhub/org/OrganizationMapper.java
--- src/main/java/kr/itn/itnhub/org/OrganizationMapper.java
+++ src/main/java/kr/itn/itnhub/org/OrganizationMapper.java
@@ -33,4 +33,7 @@
 
     int updateChannelIdLaw(@Param("id") Long id,
                            @Param("channelId") String channelId);
+
+    int updateStage(@Param("id") Long id,
+                    @Param("stage") Integer stage);
 }
src/main/java/kr/itn/itnhub/provision/ChannelProvisionService.java
--- src/main/java/kr/itn/itnhub/provision/ChannelProvisionService.java
+++ src/main/java/kr/itn/itnhub/provision/ChannelProvisionService.java
@@ -5,6 +5,7 @@
 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;
@@ -32,13 +33,16 @@
     private final MattermostClient mattermost;
     private final OrganizationMapper mapper;
     private final MattermostProperties props;
+    private final StageHistoryMapper stageHistoryMapper;
 
     public ChannelProvisionService(MattermostClient mattermost,
                                    OrganizationMapper mapper,
-                                   MattermostProperties props) {
+                                   MattermostProperties props,
+                                   StageHistoryMapper stageHistoryMapper) {
         this.mattermost = mattermost;
         this.mapper = mapper;
         this.props = props;
+        this.stageHistoryMapper = stageHistoryMapper;
     }
 
     public ProvisionResult provision(Long orgId) {
@@ -62,9 +66,39 @@
         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,
 
src/main/java/kr/itn/itnhub/stage/InvalidStageException.java (added)
+++ src/main/java/kr/itn/itnhub/stage/InvalidStageException.java
@@ -0,0 +1,11 @@
+package kr.itn.itnhub.stage;
+
+/**
+ * 진행단계 값이 1..12 범위를 벗어났을 때(또는 비어 있을 때) 던진다. 흔한 사용자 실수이므로
+ * {@link kr.itn.itnhub.config.GlobalExceptionHandler}가 400으로 변환한다.
+ */
+public class InvalidStageException extends RuntimeException {
+    public InvalidStageException(String message) {
+        super(message);
+    }
+}
 
src/main/java/kr/itn/itnhub/stage/StageController.java (added)
+++ src/main/java/kr/itn/itnhub/stage/StageController.java
@@ -0,0 +1,23 @@
+package kr.itn.itnhub.stage;
+
+import kr.itn.itnhub.org.OrgResponse;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RestController;
+
+/** 기관관리 상세의 [진행단계 변경] 및 업무단계 현황 스트립이 부르는 엔드포인트. */
+@RestController
+public class StageController {
+
+    private final StageService stageService;
+
+    public StageController(StageService stageService) {
+        this.stageService = stageService;
+    }
+
+    @PutMapping("/api/orgs/{id}/stage")
+    public OrgResponse updateStage(@PathVariable Long id, @RequestBody StageRequest request) {
+        return stageService.updateStage(id, request);
+    }
+}
 
src/main/java/kr/itn/itnhub/stage/StageHistoryMapper.java (added)
+++ src/main/java/kr/itn/itnhub/stage/StageHistoryMapper.java
@@ -0,0 +1,15 @@
+package kr.itn.itnhub.stage;
+
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+@Mapper
+public interface StageHistoryMapper {
+
+    /** 최신순. */
+    List<StageHistoryRow> findByOrg(@Param("orgId") Long orgId);
+
+    int insert(@Param("orgId") Long orgId, @Param("stage") int stage);
+}
 
src/main/java/kr/itn/itnhub/stage/StageHistoryRow.java (added)
+++ src/main/java/kr/itn/itnhub/stage/StageHistoryRow.java
@@ -0,0 +1,14 @@
+package kr.itn.itnhub.stage;
+
+/** stage_history 한 행. MyBatis 매핑용이라 record가 아니라 setter가 있는 클래스로 둔다. */
+public class StageHistoryRow {
+
+    private int stage;
+    private long changedAt;
+
+    public int getStage() { return stage; }
+    public void setStage(int stage) { this.stage = stage; }
+
+    public long getChangedAt() { return changedAt; }
+    public void setChangedAt(long changedAt) { this.changedAt = changedAt; }
+}
 
src/main/java/kr/itn/itnhub/stage/StageRequest.java (added)
+++ src/main/java/kr/itn/itnhub/stage/StageRequest.java
@@ -0,0 +1,6 @@
+package kr.itn.itnhub.stage;
+
+/** 진행단계 변경 요청. 1..12 검증은 {@link StageService}가 한다(범위를 벗어난 값에 대해
+ * 한글 메시지를 직접 지어야 하므로 {@code @Valid}가 아니라 수동 검증을 쓴다). */
+public record StageRequest(Integer stage) {
+}
 
src/main/java/kr/itn/itnhub/stage/StageService.java (added)
+++ src/main/java/kr/itn/itnhub/stage/StageService.java
@@ -0,0 +1,43 @@
+package kr.itn.itnhub.stage;
+
+import kr.itn.itnhub.org.OrgNotFoundException;
+import kr.itn.itnhub.org.OrgResponse;
+import kr.itn.itnhub.org.Organization;
+import kr.itn.itnhub.org.OrganizationMapper;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * 기관관리 상세 화면의 [진행단계 변경] 및 업무단계 현황 스트립이 쓰는 서비스. 단계 값 자체는
+ * organization.stage 한 칸에 저장하고, 변경 이력은 전부 stage_history에 별도로 쌓는다 -
+ * 타임라인 탭이 "언제 몇 단계로 넘어갔는지"를 나중에 다시 훑어볼 수 있어야 하기 때문이다.
+ */
+@Service
+public class StageService {
+
+    private final OrganizationMapper orgMapper;
+    private final StageHistoryMapper historyMapper;
+
+    public StageService(OrganizationMapper orgMapper, StageHistoryMapper historyMapper) {
+        this.orgMapper = orgMapper;
+        this.historyMapper = historyMapper;
+    }
+
+    @Transactional
+    public OrgResponse updateStage(Long orgId, StageRequest request) {
+        Organization org = orgMapper.findById(orgId);
+        if (org == null) {
+            throw new OrgNotFoundException("기관을 찾을 수 없습니다: " + orgId);
+        }
+
+        Integer stage = request.stage();
+        if (stage == null || stage < 1 || stage > 12) {
+            throw new InvalidStageException("진행단계는 1에서 12 사이의 값이어야 합니다.");
+        }
+
+        orgMapper.updateStage(orgId, stage);
+        historyMapper.insert(orgId, stage);
+
+        return OrgResponse.of(orgMapper.findById(orgId));
+    }
+}
 
src/main/java/kr/itn/itnhub/timeline/TimelineController.java (added)
+++ src/main/java/kr/itn/itnhub/timeline/TimelineController.java
@@ -0,0 +1,23 @@
+package kr.itn.itnhub.timeline;
+
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+/** 기관관리 상세의 타임라인 탭이 부르는 읽기 전용 엔드포인트. */
+@RestController
+public class TimelineController {
+
+    private final TimelineService timelineService;
+
+    public TimelineController(TimelineService timelineService) {
+        this.timelineService = timelineService;
+    }
+
+    @GetMapping("/api/orgs/{id}/timeline")
+    public List<TimelineEvent> timeline(@PathVariable Long id) {
+        return timelineService.timeline(id);
+    }
+}
 
src/main/java/kr/itn/itnhub/timeline/TimelineEvent.java (added)
+++ src/main/java/kr/itn/itnhub/timeline/TimelineEvent.java
@@ -0,0 +1,11 @@
+package kr.itn.itnhub.timeline;
+
+/**
+ * 타임라인 탭 한 줄. {@code type}은 {@code STAGE}/{@code MEMO}/{@code FILE} 중 하나다.
+ *
+ * <p>{@code STAGE}: title = 단계 번호(문자열, 프론트가 라벨로 매핑), detail = null.<br>
+ * {@code MEMO}: title = 담당자 이름, detail = 메모 첫 줄(60자 절단).<br>
+ * {@code FILE}: title = 파일명, detail = 채널 라벨(문정원/법률검토) + " · " + 올린 사람.</p>
+ */
+public record TimelineEvent(String type, long at, String title, String detail) {
+}
 
src/main/java/kr/itn/itnhub/timeline/TimelineService.java (added)
+++ src/main/java/kr/itn/itnhub/timeline/TimelineService.java
@@ -0,0 +1,103 @@
+package kr.itn.itnhub.timeline;
+
+import kr.itn.itnhub.feed.FileView;
+import kr.itn.itnhub.mattermost.MattermostClient;
+import kr.itn.itnhub.memo.WorkMemo;
+import kr.itn.itnhub.memo.WorkMemoMapper;
+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 kr.itn.itnhub.stage.StageHistoryRow;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+
+/**
+ * 기관관리 상세의 타임라인 탭이 쓰는 서비스. 단계변경 이력(stage_history) + 업무메모(work_memo) +
+ * 채널 첨부파일(Mattermost)을 한 번에 모아 최신순으로 합쳐 돌려준다.
+ *
+ * <p>Mattermost 조회는 채널별로 개별 catch한다 - 한쪽 채널(혹은 Mattermost 서버 자체)이
+ * 실패해도 이미 DB에서 구한 단계·메모 이력까지 통째로 못 보여줄 이유가 없다.</p>
+ */
+@Service
+public class TimelineService {
+
+    private static final Logger log = LoggerFactory.getLogger(TimelineService.class);
+
+    private static final String LABEL_MJ = "문정원";
+    private static final String LABEL_LAW = "법률검토";
+
+    /** 메모 상세문(detail)로 보여줄 첫 줄의 최대 길이. */
+    private static final int MEMO_DETAIL_MAX_LEN = 60;
+
+    private final OrganizationMapper orgMapper;
+    private final StageHistoryMapper stageHistoryMapper;
+    private final WorkMemoMapper memoMapper;
+    private final MattermostClient mattermost;
+
+    public TimelineService(OrganizationMapper orgMapper, StageHistoryMapper stageHistoryMapper,
+                           WorkMemoMapper memoMapper, MattermostClient mattermost) {
+        this.orgMapper = orgMapper;
+        this.stageHistoryMapper = stageHistoryMapper;
+        this.memoMapper = memoMapper;
+        this.mattermost = mattermost;
+    }
+
+    public List<TimelineEvent> timeline(Long orgId) {
+        Organization org = orgMapper.findById(orgId);
+        if (org == null) {
+            throw new OrgNotFoundException("기관을 찾을 수 없습니다: " + orgId);
+        }
+
+        List<TimelineEvent> events = new ArrayList<>();
+
+        for (StageHistoryRow row : stageHistoryMapper.findByOrg(orgId)) {
+            events.add(new TimelineEvent("STAGE", row.getChangedAt(), String.valueOf(row.getStage()), null));
+        }
+
+        for (WorkMemo memo : memoMapper.findByOrg(orgId)) {
+            events.add(new TimelineEvent("MEMO", memo.getCreatedAt(), memo.getContactName(),
+                    firstLine(memo.getBody())));
+        }
+
+        events.addAll(fileEvents(org.getChannelIdMj(), LABEL_MJ));
+        events.addAll(fileEvents(org.getChannelIdLaw(), LABEL_LAW));
+
+        events.sort(Comparator.comparingLong(TimelineEvent::at).reversed());
+        return events;
+    }
+
+    private List<TimelineEvent> fileEvents(String channelId, String channelLabel) {
+        if (channelId == null || channelId.isBlank()) {
+            return List.of();
+        }
+        try {
+            List<FileView> files = mattermost.collectChannelFiles(channelId);
+            List<TimelineEvent> events = new ArrayList<>(files.size());
+            for (FileView file : files) {
+                events.add(new TimelineEvent("FILE", file.createAt(), file.name(),
+                        channelLabel + " · " + file.uploader()));
+            }
+            return events;
+        } catch (RuntimeException e) {
+            log.warn("타임라인 자료 조회 실패 channel={}", channelLabel, e);
+            return List.of();
+        }
+    }
+
+    private String firstLine(String body) {
+        if (body == null) {
+            return "";
+        }
+        String line = body.strip().split("\\R", 2)[0];
+        if (line.length() > MEMO_DETAIL_MAX_LEN) {
+            return line.substring(0, MEMO_DETAIL_MAX_LEN);
+        }
+        return line;
+    }
+}
 
src/main/resources/db/migration/V8__org_stage.sql (added)
+++ src/main/resources/db/migration/V8__org_stage.sql
@@ -0,0 +1,9 @@
+alter table organization add column stage smallint;
+
+create table stage_history (
+    id         bigserial primary key,
+    org_id     bigint   not null references organization (id) on delete cascade,
+    stage      smallint not null,
+    changed_at timestamptz not null default now()
+);
+create index ix_stage_history_org on stage_history (org_id, changed_at desc);
src/main/resources/mapper/OrganizationMapper.xml
--- src/main/resources/mapper/OrganizationMapper.xml
+++ src/main/resources/mapper/OrganizationMapper.xml
@@ -20,6 +20,7 @@
     <result property="lawyerAssignedDate" column="lawyer_assigned_date"/>
     <result property="channelIdMj" column="channel_id_mj"/>
     <result property="channelIdLaw" column="channel_id_law"/>
+    <result property="stage" column="stage"/>
 
     <association property="applicant" javaType="kr.itn.itnhub.contact.Contact">
       <id property="id" column="applicant_id"/>
@@ -69,7 +70,7 @@
   <sql id="joinedColumns">
     o.id, o.org_no, o.org_name, o.channel_slug,
     o.applicant_contact_id, o.mj_contact_id, o.itn_contact_id, o.lawyer_contact_id,
-    o.lawyer_assigned_date, o.channel_id_mj, o.channel_id_law,
+    o.lawyer_assigned_date, o.channel_id_mj, o.channel_id_law, o.stage,
     ac.id as applicant_id, ac.category as applicant_category, ac.name as applicant_name,
     ac.affiliation as applicant_affiliation, ac.dept_name as applicant_dept_name,
     ac.title as applicant_title, ac.phone as applicant_phone, ac.email as applicant_email,
@@ -155,4 +156,11 @@
     where id = #{id}
   </update>
 
+  <update id="updateStage">
+    update organization set
+      stage      = #{stage},
+      updated_at = now()
+    where id = #{id}
+  </update>
+
 </mapper>
 
src/main/resources/mapper/StageHistoryMapper.xml (added)
+++ src/main/resources/mapper/StageHistoryMapper.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="kr.itn.itnhub.stage.StageHistoryMapper">
+
+  <!--
+    changed_at을 밀리초 epoch로 내려준다 - WorkMemoMapper.created_at과 동일한 관례.
+  -->
+  <select id="findByOrg" resultType="kr.itn.itnhub.stage.StageHistoryRow">
+    select stage, (extract(epoch from changed_at) * 1000)::bigint as changed_at
+    from stage_history
+    where org_id = #{orgId}
+    order by changed_at desc, id desc
+  </select>
+
+  <insert id="insert">
+    insert into stage_history (org_id, stage)
+    values (#{orgId}, #{stage})
+  </insert>
+
+</mapper>
src/test/java/kr/itn/itnhub/provision/ChannelProvisionServiceTest.java
--- src/test/java/kr/itn/itnhub/provision/ChannelProvisionServiceTest.java
+++ src/test/java/kr/itn/itnhub/provision/ChannelProvisionServiceTest.java
@@ -295,4 +295,56 @@
         assertThat(after.getChannelIdMj()).isNull();
         assertThat(after.getChannelIdLaw()).isEqualTo("id-law");
     }
+
+    @Test
+    void 채널_2개가_처음_모두_갖춰지면_1단계로_자동_시작하고_이력이_한_행_남는다() {
+        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())).thenReturn("id-law");
+
+        service.provision(orgId);
+
+        Organization after = mapper.findById(orgId);
+        assertThat(after.getStage()).isEqualTo(1);
+        assertThat(jdbc.queryForObject(
+                "select count(*) from stage_history where org_id = ?", Integer.class, orgId))
+                .isEqualTo(1);
+    }
+
+    @Test
+    void 이미_단계가_있는_기관은_채널_재확인으로_단계가_바뀌지_않는다() {
+        mapper.updateChannelIdMj(orgId, "id-mj");
+        mapper.updateChannelIdLaw(orgId, "id-law");
+        mapper.updateStage(orgId, 5);
+
+        service.provision(orgId);
+
+        Organization after = mapper.findById(orgId);
+        assertThat(after.getStage()).isEqualTo(5);
+        assertThat(jdbc.queryForObject(
+                "select count(*) from stage_history where org_id = ?", Integer.class, orgId))
+                .isEqualTo(0);
+    }
+
+    @Test
+    void 이미_채널이_있어_CACHED만_반환되는_재실행은_이력을_추가하지_않는다() {
+        mapper.updateChannelIdMj(orgId, "id-mj");
+        mapper.updateChannelIdLaw(orgId, "id-law");
+
+        // 첫 provision으로 자동 1단계 시작 + 이력 1건.
+        service.provision(orgId);
+        assertThat(jdbc.queryForObject(
+                "select count(*) from stage_history where org_id = ?", Integer.class, orgId))
+                .isEqualTo(1);
+
+        // 다시 호출해도(CACHED만 반환) 이력은 늘지 않는다.
+        ProvisionResult result = service.provision(orgId);
+
+        assertThat(result.mj()).isEqualTo(ProvisionOutcome.CACHED);
+        assertThat(result.law()).isEqualTo(ProvisionOutcome.CACHED);
+        assertThat(jdbc.queryForObject(
+                "select count(*) from stage_history where org_id = ?", Integer.class, orgId))
+                .isEqualTo(1);
+    }
 }
 
src/test/java/kr/itn/itnhub/stage/StageControllerTest.java (added)
+++ src/test/java/kr/itn/itnhub/stage/StageControllerTest.java
@@ -0,0 +1,125 @@
+package kr.itn.itnhub.stage;
+
+import kr.itn.itnhub.AbstractDbTest;
+import kr.itn.itnhub.mattermost.MattermostClient;
+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.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.http.MediaType;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.security.test.context.support.WithMockUser;
+import org.springframework.test.web.servlet.MockMvc;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.hamcrest.Matchers.containsString;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@AutoConfigureMockMvc
+@WithMockUser(roles = "ADMIN")
+class StageControllerTest extends AbstractDbTest {
+
+    @Autowired
+    MockMvc mvc;
+
+    @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");
+        mapper.upsertBySeed(org);
+
+        orgId = mapper.findAll().get(0).getId();
+    }
+
+    @Test
+    void 단계를_바꾸면_응답과_재조회에_반영되고_이력이_남는다() throws Exception {
+        mvc.perform(put("/api/orgs/{id}/stage", orgId)
+                        .with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"stage\": 3}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.stage").value(3));
+
+        assertThat(mapper.findById(orgId).getStage()).isEqualTo(3);
+        assertThat(jdbc.queryForObject(
+                "select count(*) from stage_history where org_id = ? and stage = 3",
+                Integer.class, orgId)).isEqualTo(1);
+    }
+
+    @Test
+    void 여러_번_바꾸면_이력이_각각_쌓인다() throws Exception {
+        mvc.perform(put("/api/orgs/{id}/stage", orgId).with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON).content("{\"stage\": 1}"))
+                .andExpect(status().isOk());
+        mvc.perform(put("/api/orgs/{id}/stage", orgId).with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON).content("{\"stage\": 2}"))
+                .andExpect(status().isOk());
+
+        assertThat(jdbc.queryForObject(
+                "select count(*) from stage_history where org_id = ?", Integer.class, orgId))
+                .isEqualTo(2);
+        assertThat(mapper.findById(orgId).getStage()).isEqualTo(2);
+    }
+
+    @Test
+    void 범위를_벗어난_값은_400이다() throws Exception {
+        mvc.perform(put("/api/orgs/{id}/stage", orgId)
+                        .with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"stage\": 13}"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.message").value(containsString("1에서 12")));
+
+        assertThat(mapper.findById(orgId).getStage()).isNull();
+    }
+
+    @Test
+    void 하한_미만인_0단계는_400이다() throws Exception {
+        mvc.perform(put("/api/orgs/{id}/stage", orgId)
+                        .with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"stage\": 0}"))
+                .andExpect(status().isBadRequest());
+    }
+
+    @Test
+    void 값이_없으면_400이다() throws Exception {
+        mvc.perform(put("/api/orgs/{id}/stage", orgId)
+                        .with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{}"))
+                .andExpect(status().isBadRequest());
+    }
+
+    @Test
+    void 존재하지_않는_기관이면_404다() throws Exception {
+        long missingId = orgId + 999999L;
+
+        mvc.perform(put("/api/orgs/{id}/stage", missingId)
+                        .with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"stage\": 1}"))
+                .andExpect(status().isNotFound());
+    }
+}
 
src/test/java/kr/itn/itnhub/timeline/TimelineControllerTest.java (added)
+++ src/test/java/kr/itn/itnhub/timeline/TimelineControllerTest.java
@@ -0,0 +1,124 @@
+package kr.itn.itnhub.timeline;
+
+import kr.itn.itnhub.AbstractDbTest;
+import kr.itn.itnhub.feed.FileView;
+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.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.security.test.context.support.WithMockUser;
+import org.springframework.test.web.servlet.MockMvc;
+
+import java.util.List;
+
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@AutoConfigureMockMvc
+@WithMockUser(roles = "ADMIN")
+class TimelineControllerTest extends AbstractDbTest {
+
+    @Autowired
+    MockMvc mvc;
+
+    @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");
+        mapper.upsertBySeed(org);
+
+        orgId = mapper.findAll().get(0).getId();
+        mapper.updateChannelIdMj(orgId, "chan-mj");
+        mapper.updateChannelIdLaw(orgId, "chan-law");
+    }
+
+    private void insertStageHistory(long orgId, int stage, long epochSeconds) {
+        jdbc.update("insert into stage_history (org_id, stage, changed_at) values (?, ?, to_timestamp(?))",
+                orgId, stage, epochSeconds);
+    }
+
+    private void insertMemo(long orgId, String contactName, String body, long epochSeconds) {
+        jdbc.update("insert into work_memo (org_id, contact_name, body, created_at) "
+                        + "values (?, ?, ?, to_timestamp(?))",
+                orgId, contactName, body, epochSeconds);
+    }
+
+    @Test
+    void 세_종류_이벤트를_최신순으로_합쳐서_돌려준다() throws Exception {
+        insertStageHistory(orgId, 1, 1_000L);
+        insertMemo(orgId, "송민지", "안녕하세요\n둘째줄입니다", 2_000L);
+
+        when(mattermost.collectChannelFiles("chan-mj")).thenReturn(List.of(
+                new FileView("f1", "보고서.pdf", 100L, "application/pdf", 3_000_000L, "박아이티")));
+        when(mattermost.collectChannelFiles("chan-law")).thenReturn(List.of());
+
+        mvc.perform(get("/api/orgs/{id}/timeline", orgId))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$", org.hamcrest.Matchers.hasSize(3)))
+                .andExpect(jsonPath("$[0].type").value("FILE"))
+                .andExpect(jsonPath("$[0].title").value("보고서.pdf"))
+                .andExpect(jsonPath("$[0].detail").value("문정원 · 박아이티"))
+                .andExpect(jsonPath("$[1].type").value("MEMO"))
+                .andExpect(jsonPath("$[1].title").value("송민지"))
+                .andExpect(jsonPath("$[1].detail").value("안녕하세요"))
+                .andExpect(jsonPath("$[2].type").value("STAGE"))
+                .andExpect(jsonPath("$[2].title").value("1"))
+                .andExpect(jsonPath("$[2].detail").doesNotExist());
+    }
+
+    @Test
+    void Mattermost_조회가_실패해도_단계와_메모_이벤트는_그대로_200으로_돌려준다() throws Exception {
+        insertStageHistory(orgId, 2, 1_000L);
+        insertMemo(orgId, "김문정", "메모내용", 2_000L);
+
+        when(mattermost.collectChannelFiles("chan-mj")).thenThrow(new MattermostException("서버 오류"));
+        when(mattermost.collectChannelFiles("chan-law")).thenThrow(new MattermostException("서버 오류"));
+
+        mvc.perform(get("/api/orgs/{id}/timeline", orgId))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$", org.hamcrest.Matchers.hasSize(2)))
+                .andExpect(jsonPath("$[0].type").value("MEMO"))
+                .andExpect(jsonPath("$[1].type").value("STAGE"));
+    }
+
+    @Test
+    void 존재하지_않는_기관이면_404다() throws Exception {
+        long missingId = orgId + 999999L;
+
+        mvc.perform(get("/api/orgs/{id}/timeline", missingId))
+                .andExpect(status().isNotFound());
+    }
+
+    @Test
+    void 기록이_없으면_빈_배열을_돌려준다() throws Exception {
+        when(mattermost.collectChannelFiles("chan-mj")).thenReturn(List.of());
+        when(mattermost.collectChannelFiles("chan-law")).thenReturn(List.of());
+
+        mvc.perform(get("/api/orgs/{id}/timeline", orgId))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$", org.hamcrest.Matchers.hasSize(0)));
+    }
+}
Add a comment
List